diff --git a/README.md b/README.md
index c3e6d62..dfe91f7 100644
--- a/README.md
+++ b/README.md
@@ -111,6 +111,20 @@ Buckminster Fuller’s Airocean projection (also known as “Dymaxion”), based
The Cahill-Keyes projection, designed by Gene Keyes (1975), is built on Bernard J. S. Cahill’s 1909 octant design. Implementation by Mary Jo Graça (2011), ported to D3 by Enrico Spinielli (2013).
+# d3.geoImago() [<>](https://github.com/d3/d3-geo-polygon/blob/master/src/imago.js "Source")
+
+[
](https://kunimune.home.blog/2017/11/23/the-secrets-of-the-authagraph-revealed/)
+
+The Imago projection, engineered by Justin Kunimune (2017), is inspired by Hajime Narukawa’s AuthaGraph design (1999).
+
+# imago.k([k])
+
+Exponent. Useful values include 0.59 (default, minimizes angular distortion of the continents), 0.68 (gives the best approximation of the Authagraph) and 0.72 (prevents kinks in the graticule).
+
+# imago.shift([shift])
+
+Horizontal shift. Defaults to 1.16.
+
# d3.geoTetrahedralLee() [<>](https://github.com/d3/d3-geo-polygon/blob/master/src/tetrahedralLee.js "Source")
# d3.geoLeeRaw
diff --git a/img/imago.png b/img/imago.png
new file mode 100644
index 0000000..55affbc
Binary files /dev/null and b/img/imago.png differ
diff --git a/src/imago.js b/src/imago.js
new file mode 100644
index 0000000..ca621f0
--- /dev/null
+++ b/src/imago.js
@@ -0,0 +1,346 @@
+/*
+ * Imago projection, by Justin Kunimune
+ *
+ * Inspired by Hajime Narukawa’s AuthaGraph
+ *
+ */
+import {
+ abs,
+ acos,
+ asin,
+ atan,
+ atan2,
+ cos,
+ degrees,
+ epsilon,
+ floor,
+ halfPi,
+ pi,
+ pow,
+ sign,
+ sin,
+ sqrt,
+ tan
+} from "./math";
+import { geoProjectionMutator as projectionMutator } from "d3-geo";
+import { default as clipPolygon } from "./clip/polygon";
+import { solve } from "./newton.js";
+
+var hypot = Math.hypot;
+
+var ASIN_ONE_THD = asin(1 / 3),
+ centrums = [
+ [halfPi, 0, 0, -halfPi, 0, sqrt(3)],
+ [-ASIN_ONE_THD, 0, pi, halfPi, 0, -sqrt(3)],
+ [-ASIN_ONE_THD, (2 * pi) / 3, pi, (5 * pi) / 6, 3, 0],
+ [-ASIN_ONE_THD, (-2 * pi) / 3, pi, pi / 6, -3, 0]
+ ],
+ TETRAHEDRON_WIDE_VERTEX = {
+ sphereSym: 3,
+ planarSym: 6,
+ width: 6,
+ height: 2 * sqrt(3),
+ centrums,
+ rotateOOB: function(x, y, xCen, yCen) {
+ yCen * 0;
+ if (abs(x) > this.width / 2) return [2 * xCen - x, -y];
+ else return [-x, this.height * sign(y) - y];
+ },
+ inBounds: () => true
+ },
+ configuration = TETRAHEDRON_WIDE_VERTEX;
+
+export function imagoRaw(k) {
+ function faceProject(lon, lat) {
+ const tht = atan(((lon - asin(sin(lon) / sqrt(3))) / pi) * sqrt(12)),
+ p = (halfPi - lat) / atan(sqrt(2) / cos(lon));
+
+ return [(pow(p, k) * sqrt(3)) / cos(tht), tht];
+ }
+
+ function faceInverse(r, th) {
+ const l = solve(
+ l => atan(((l - asin(sin(l) / sqrt(3))) / pi) * sqrt(12)),
+ th,
+ th / 2
+ ),
+ R = r / (sqrt(3) / cos(th));
+ return [halfPi - pow(R, 1 / k) * atan(sqrt(2) / cos(l)), l];
+ }
+
+ function obliquifySphc(latF, lonF, pole) {
+ if (pole == null)
+ // null pole indicates that this procedure should be bypassed
+ return [latF, lonF];
+
+ const lat0 = pole[0],
+ lon0 = pole[1],
+ tht0 = pole[2];
+
+ let lat1, lon1;
+ if (lat0 == halfPi) lat1 = latF;
+ else
+ lat1 = asin(
+ sin(lat0) * sin(latF) + cos(lat0) * cos(latF) * cos(lon0 - lonF)
+ ); // relative latitude
+
+ if (lat0 == halfPi)
+ // accounts for all the 0/0 errors at the poles
+ lon1 = lonF - lon0;
+ else if (lat0 == -halfPi) lon1 = lon0 - lonF - pi;
+ else {
+ lon1 =
+ acos(
+ (cos(lat0) * sin(latF) - sin(lat0) * cos(latF) * cos(lon0 - lonF)) /
+ cos(lat1)
+ ) - pi; // relative longitude
+ if (isNaN(lon1)) {
+ if (
+ (cos(lon0 - lonF) >= 0 && latF < lat0) ||
+ (cos(lon0 - lonF) < 0 && latF < -lat0)
+ )
+ lon1 = 0;
+ else lon1 = -pi;
+ } else if (sin(lonF - lon0) > 0)
+ // it's a plus-or-minus arccos.
+ lon1 = -lon1;
+ }
+ lon1 = lon1 - tht0;
+
+ return [lat1, lon1];
+ }
+
+ function obliquifyPlnr(coords, pole) {
+ if (pole == null)
+ //this indicates that you just shouldn't do this calculation
+ return coords;
+
+ let lat1 = coords[0],
+ lon1 = coords[1];
+ const lat0 = pole[0],
+ lon0 = pole[1],
+ tht0 = pole[2];
+
+ lon1 += tht0;
+ let latf = asin(sin(lat0) * sin(lat1) - cos(lat0) * cos(lon1) * cos(lat1)),
+ lonf,
+ innerFunc = sin(lat1) / cos(lat0) / cos(latf) - tan(lat0) * tan(latf);
+ if (lat0 == halfPi)
+ // accounts for special case when lat0 = pi/2
+ lonf = lon1 + lon0;
+ else if (lat0 == -halfPi)
+ // accounts for special case when lat0 = -pi/2
+ lonf = -lon1 + lon0 + pi;
+ else if (abs(innerFunc) > 1) {
+ // accounts for special case when cos(lat1) -> 0
+ if ((lon1 == 0 && lat1 < -lat0) || (lon1 != 0 && lat1 < lat0))
+ lonf = lon0 + pi;
+ else lonf = lon0;
+ } else if (sin(lon1) > 0) lonf = lon0 + acos(innerFunc);
+ else lonf = lon0 - acos(innerFunc);
+
+ let thtf = pole[2];
+
+ return [latf, lonf, thtf];
+ }
+
+ function forward(lon, lat) {
+ const width = configuration.width,
+ height = configuration.height;
+ const numSym = configuration.sphereSym; //we're about to be using this variable a lot
+ let latR = -Infinity;
+ let lonR = -Infinity;
+ let centrum = null;
+ for (const testCentrum of centrums) {
+ //iterate through the centrums to see which goes here
+ const relCoords = obliquifySphc(lat, lon, testCentrum);
+ if (relCoords[0] > latR) {
+ latR = relCoords[0];
+ lonR = relCoords[1];
+ centrum = testCentrum;
+ }
+ }
+
+ const lonR0 =
+ floor((lonR + pi / numSym) / ((2 * pi) / numSym)) * ((2 * pi) / numSym);
+
+ const rth = faceProject(lonR - lonR0, latR);
+ const r = rth[0];
+ const th = rth[1] + centrum[3] + (lonR0 * numSym) / configuration.planarSym;
+ const x0 = centrum[4];
+ const y0 = centrum[5];
+
+ let output = [r * cos(th) + x0, r * sin(th) + y0];
+ if (abs(output[0]) > width / 2 || abs(output[1]) > height / 2) {
+ output = configuration.rotateOOB(output[0], output[1], x0, y0);
+ }
+ return output;
+ }
+
+ function invert(x, y) {
+ if (isNaN(x) || isNaN(y)) return null;
+
+ if (!configuration.inBounds(x, y)) return null;
+
+ const numSym = configuration.planarSym;
+
+ let rM = +Infinity;
+ let centrum = null; //iterate to see which centrum we get
+ for (const testCentrum of centrums) {
+ const rR = hypot(x - testCentrum[4], y - testCentrum[5]);
+ if (rR < rM) {
+ //pick the centrum that minimises r
+ rM = rR;
+ centrum = testCentrum;
+ }
+ }
+ const th0 = centrum[3],
+ x0 = centrum[4],
+ y0 = centrum[5],
+ r = hypot(x - x0, y - y0),
+ th = atan2(y - y0, x - x0) - th0,
+ thBase =
+ floor((th + pi / numSym) / ((2 * pi) / numSym)) * ((2 * pi) / numSym);
+
+ let relCoords = faceInverse(r, th - thBase);
+
+ if (relCoords == null) return null;
+
+ relCoords[1] = (thBase * numSym) / configuration.sphereSym + relCoords[1];
+ let absCoords = obliquifyPlnr(relCoords, centrum);
+ return [absCoords[1], absCoords[0]];
+ }
+
+ forward.invert = invert;
+
+ return forward;
+}
+
+export function imagoBlock() {
+ var k = 0.68,
+ m = projectionMutator(imagoRaw),
+ p = m(k);
+
+ p.k = function(_) {
+ return arguments.length ? m((k = +_)) : k;
+ };
+
+ var a = -atan(1 / sqrt(2)) * degrees,
+ border = [
+ [-180 + epsilon, a + epsilon],
+ [0, 90],
+ [180 - epsilon, a + epsilon],
+ [180 - epsilon, a - epsilon],
+ [-180 + epsilon, a - epsilon],
+ [-180 + epsilon, a + epsilon]
+ ];
+
+ return p
+ .preclip(clipPolygon({
+ type: "Polygon",
+ coordinates: [border]
+ }))
+ .scale(144.04)
+ .rotate([18, -12.5, 3.5])
+ .center([0, 35.2644]);
+}
+
+function imagoWideRaw(k, shift) {
+ var imago = imagoRaw(k);
+ const height = configuration.height;
+
+ function forward(lon, lat) {
+ const p = imago(lon, lat),
+ q = [p[1], -p[0]];
+
+ if (q[1] > 0) {
+ q[0] = height - q[0];
+ q[1] *= -1;
+ }
+
+ q[0] += shift;
+ if (q[0] < 0) q[0] += height * 2;
+
+ return q;
+ }
+
+ function invert(x, y) {
+ x = (x - shift) / height;
+
+ if (x > 1.5) {
+ x -= 2;
+ }
+
+ if (x > 0.5) {
+ x = 1 - x;
+ y *= -1;
+ }
+
+ return imago.invert(-y, x * height);
+ }
+
+ forward.invert = invert;
+ return forward;
+}
+
+export default function() {
+ var k = 0.59,
+ shift = 1.16,
+ m = projectionMutator(imagoWideRaw),
+ p = m(k, shift);
+
+ p.shift = function(_) {
+ return arguments.length ? clipped(m(k, (shift = +_))) : shift;
+ };
+ p.k = function(_) {
+ return arguments.length ? clipped(m((k = +_), shift)) : k;
+ };
+
+ function clipped(p) {
+ const N = 100 + 2 * epsilon,
+ border = [],
+ e = 3e-3;
+
+ const scale = p.scale(),
+ center = p.center(),
+ translate = p.translate(),
+ rotate = p.rotate();
+ p.scale(1)
+ .center([0, 90])
+ .rotate([0, 0])
+ .translate([shift, 0]);
+ for (let i = N - epsilon; i > 0; i--) {
+ border.unshift(
+ p.invert([
+ 1.5 * configuration.height - e,
+ ((configuration.width / 2) * i) / N
+ ])
+ );
+ border.push(
+ p.invert([
+ -0.5 * configuration.height + e,
+ ((configuration.width / 2) * i) / N
+ ])
+ );
+ }
+ border.push(border[0]);
+
+ return p
+ .scale(scale)
+ .center(center)
+ .translate(translate)
+ .rotate(rotate)
+ .preclip(
+ clipPolygon({
+ type: "Polygon",
+ coordinates: [border]
+ })
+ );
+ }
+
+ return clipped(p)
+ .rotate([18, -12.5, 3.5])
+ .scale(138.42)
+ .translate([480, 250])
+ .center([-139.405, 40.5844]);
+}
diff --git a/src/index.js b/src/index.js
index 5ef6aa6..b0833df 100644
--- a/src/index.js
+++ b/src/index.js
@@ -10,5 +10,6 @@ export {default as geoTetrahedralLee, leeRaw as geoLeeRaw} from "./tetrahedralLe
export {default as geoGrayFullerRaw} from "./grayfuller";
export {default as geoAirocean} from "./airocean";
export {default as geoIcosahedral} from "./icosahedral";
+export {default as geoImago, imagoBlock as geoImagoBlock, imagoRaw as geoImagoRaw} from "./imago";
export {default as geoCubic} from "./cubic";
export {default as geoCahillKeyes, cahillKeyesRaw as geoCahillKeyesRaw} from "./cahillKeyes";
diff --git a/src/newton.js b/src/newton.js
new file mode 100644
index 0000000..8d53099
--- /dev/null
+++ b/src/newton.js
@@ -0,0 +1,16 @@
+import {abs, epsilon} from "./math";
+
+// Newton-Raphson
+// Solve f(x) = y, start from x
+export function solve(f, y, x) {
+ var steps = 100, delta, f0, f1;
+ x = x === undefined ? 0 : +x;
+ y = +y;
+ do {
+ f0 = f(x);
+ f1 = f(x + epsilon);
+ if (f0 === f1) f1 = f0 + epsilon;
+ x -= delta = (-1 * epsilon * (f0 - y)) / (f0 - f1);
+ } while (steps-- > 0 && abs(delta) > epsilon);
+ return steps < 0 ? NaN : x;
+}
diff --git a/test/compare-images b/test/compare-images
index bf0ff0b..67d64bf 100755
--- a/test/compare-images
+++ b/test/compare-images
@@ -7,6 +7,7 @@ for i in \
cubic \
dodecahedral \
icosahedral \
+ imago \
polyhedralButterfly \
polyhedralCollignon \
polyhedralWaterman \
diff --git a/test/invert-test.js b/test/invert-test.js
index c6b4617..c07d7d9 100644
--- a/test/invert-test.js
+++ b/test/invert-test.js
@@ -17,3 +17,16 @@ tape("inverse polyhedrals", function(test) {
test.end();
});
+tape("inverse Imago", function(test) {
+
+ [ d3.geoImago(), d3.geoImagoBlock() ]
+ .forEach(function(projection) {
+ [ [-23, 12], [10,10], [100,-45] ]
+ .forEach(function(location) {
+ projection.angle(Math.random()*360);
+ test.projectionEqual(projection, location, projection(location), 1e-5);
+ });
+ });
+ test.end();
+});
+