├── .nvmrc ├── docs ├── .gitignore ├── index.md.sh └── snapshots │ └── [snapshot].png.js ├── src ├── noop.js ├── pointEqual.js ├── polyhedral │ ├── octahedron.js │ ├── matrix.js │ ├── voronoi.js │ └── index.js ├── clip │ ├── buffer.js │ ├── rejoin.js │ ├── index.js │ └── polygon.js ├── lagrange.js ├── butterfly.js ├── cubic.js ├── dodecahedral.js ├── cartesian.js ├── collignon.js ├── index.js ├── math.js ├── newton.js ├── grayfuller.js ├── rhombic.js ├── complex.js ├── intersect.js ├── icosahedral.js ├── polygonContains.js ├── deltoidal.js ├── waterman.js ├── cox.js ├── tetrahedralLee.js ├── reclip.js ├── airocean.js ├── complexLog.js ├── imago.js └── cahillKeyes.js ├── .npmignore ├── .gitignore ├── test ├── snapshots │ ├── cox.png │ ├── cubic.png │ ├── imago.png │ ├── airocean.png │ ├── berghaus.png │ ├── cubic45.png │ ├── gingery.png │ ├── gingery3.png │ ├── gingery7.png │ ├── healpix.png │ ├── healpix5.png │ ├── rhombic.png │ ├── airocean702.png │ ├── airocean732.png │ ├── berghaus13.png │ ├── berghaus7.png │ ├── cahillKeyes.png │ ├── complexLog.png │ ├── deltoidal.png │ ├── goodeOcean.png │ ├── icosahedral.png │ ├── rhombic00.png │ ├── clipPointTrue.png │ ├── dodecahedral.png │ ├── rhombicHalf1.png │ ├── rhombicHalf2.png │ ├── clipPointFalse.png │ ├── clipPointSmall.png │ ├── clipPointWorld.png │ ├── interruptedBoggs.png │ ├── tetrahedralLee.png │ ├── polyhedralWaterman.png │ ├── interruptedHomolosine.png │ ├── interruptedMollweide.png │ ├── interruptedSinusoidal.png │ ├── polyhedralButterfly.png │ ├── polyhedralCollignon.png │ ├── tetrahedralLeeSouth.png │ ├── twoPointEquidistantUsa.png │ ├── interruptedSinuMollweide.png │ └── interruptedMollweideHemispheres.png ├── .eslintrc.json ├── snapshot-test.js ├── clip │ └── polygon-test.js ├── invert-test.js ├── asserts.js ├── intersect-test.js └── snapshots.js ├── .eslintrc.json ├── observablehq.config.ts ├── .github ├── eslint.json └── workflows │ ├── node.js.yml │ ├── publish.yml │ └── pages.yml ├── LICENSE ├── rollup.config.js ├── package.json └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | 10.17.0 2 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | /.observablehq/ -------------------------------------------------------------------------------- /src/noop.js: -------------------------------------------------------------------------------- 1 | export default function noop() {} 2 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | *.sublime-* 2 | dist/*.zip 3 | img/ 4 | test/ 5 | -------------------------------------------------------------------------------- /docs/index.md.sh: -------------------------------------------------------------------------------- 1 | echo "--- 2 | theme: alt 3 | --- 4 | " 5 | 6 | cat README.md 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.sublime-workspace 2 | .DS_Store 3 | dist/ 4 | node_modules 5 | npm-debug.log 6 | -------------------------------------------------------------------------------- /test/snapshots/cox.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d3/d3-geo-polygon/HEAD/test/snapshots/cox.png -------------------------------------------------------------------------------- /test/snapshots/cubic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d3/d3-geo-polygon/HEAD/test/snapshots/cubic.png -------------------------------------------------------------------------------- /test/snapshots/imago.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d3/d3-geo-polygon/HEAD/test/snapshots/imago.png -------------------------------------------------------------------------------- /test/snapshots/airocean.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d3/d3-geo-polygon/HEAD/test/snapshots/airocean.png -------------------------------------------------------------------------------- /test/snapshots/berghaus.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d3/d3-geo-polygon/HEAD/test/snapshots/berghaus.png -------------------------------------------------------------------------------- /test/snapshots/cubic45.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d3/d3-geo-polygon/HEAD/test/snapshots/cubic45.png -------------------------------------------------------------------------------- /test/snapshots/gingery.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d3/d3-geo-polygon/HEAD/test/snapshots/gingery.png -------------------------------------------------------------------------------- /test/snapshots/gingery3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d3/d3-geo-polygon/HEAD/test/snapshots/gingery3.png -------------------------------------------------------------------------------- /test/snapshots/gingery7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d3/d3-geo-polygon/HEAD/test/snapshots/gingery7.png -------------------------------------------------------------------------------- /test/snapshots/healpix.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d3/d3-geo-polygon/HEAD/test/snapshots/healpix.png -------------------------------------------------------------------------------- /test/snapshots/healpix5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d3/d3-geo-polygon/HEAD/test/snapshots/healpix5.png -------------------------------------------------------------------------------- /test/snapshots/rhombic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d3/d3-geo-polygon/HEAD/test/snapshots/rhombic.png -------------------------------------------------------------------------------- /test/snapshots/airocean702.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d3/d3-geo-polygon/HEAD/test/snapshots/airocean702.png -------------------------------------------------------------------------------- /test/snapshots/airocean732.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d3/d3-geo-polygon/HEAD/test/snapshots/airocean732.png -------------------------------------------------------------------------------- /test/snapshots/berghaus13.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d3/d3-geo-polygon/HEAD/test/snapshots/berghaus13.png -------------------------------------------------------------------------------- /test/snapshots/berghaus7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d3/d3-geo-polygon/HEAD/test/snapshots/berghaus7.png -------------------------------------------------------------------------------- /test/snapshots/cahillKeyes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d3/d3-geo-polygon/HEAD/test/snapshots/cahillKeyes.png -------------------------------------------------------------------------------- /test/snapshots/complexLog.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d3/d3-geo-polygon/HEAD/test/snapshots/complexLog.png -------------------------------------------------------------------------------- /test/snapshots/deltoidal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d3/d3-geo-polygon/HEAD/test/snapshots/deltoidal.png -------------------------------------------------------------------------------- /test/snapshots/goodeOcean.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d3/d3-geo-polygon/HEAD/test/snapshots/goodeOcean.png -------------------------------------------------------------------------------- /test/snapshots/icosahedral.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d3/d3-geo-polygon/HEAD/test/snapshots/icosahedral.png -------------------------------------------------------------------------------- /test/snapshots/rhombic00.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d3/d3-geo-polygon/HEAD/test/snapshots/rhombic00.png -------------------------------------------------------------------------------- /test/snapshots/clipPointTrue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d3/d3-geo-polygon/HEAD/test/snapshots/clipPointTrue.png -------------------------------------------------------------------------------- /test/snapshots/dodecahedral.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d3/d3-geo-polygon/HEAD/test/snapshots/dodecahedral.png -------------------------------------------------------------------------------- /test/snapshots/rhombicHalf1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d3/d3-geo-polygon/HEAD/test/snapshots/rhombicHalf1.png -------------------------------------------------------------------------------- /test/snapshots/rhombicHalf2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d3/d3-geo-polygon/HEAD/test/snapshots/rhombicHalf2.png -------------------------------------------------------------------------------- /src/pointEqual.js: -------------------------------------------------------------------------------- 1 | export default function pointEqual(a, b) { 2 | return a && b && a[0] === b[0] && a[1] === b[1]; 3 | } -------------------------------------------------------------------------------- /test/snapshots/clipPointFalse.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d3/d3-geo-polygon/HEAD/test/snapshots/clipPointFalse.png -------------------------------------------------------------------------------- /test/snapshots/clipPointSmall.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d3/d3-geo-polygon/HEAD/test/snapshots/clipPointSmall.png -------------------------------------------------------------------------------- /test/snapshots/clipPointWorld.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d3/d3-geo-polygon/HEAD/test/snapshots/clipPointWorld.png -------------------------------------------------------------------------------- /test/snapshots/interruptedBoggs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d3/d3-geo-polygon/HEAD/test/snapshots/interruptedBoggs.png -------------------------------------------------------------------------------- /test/snapshots/tetrahedralLee.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d3/d3-geo-polygon/HEAD/test/snapshots/tetrahedralLee.png -------------------------------------------------------------------------------- /test/snapshots/polyhedralWaterman.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d3/d3-geo-polygon/HEAD/test/snapshots/polyhedralWaterman.png -------------------------------------------------------------------------------- /test/snapshots/interruptedHomolosine.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d3/d3-geo-polygon/HEAD/test/snapshots/interruptedHomolosine.png -------------------------------------------------------------------------------- /test/snapshots/interruptedMollweide.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d3/d3-geo-polygon/HEAD/test/snapshots/interruptedMollweide.png -------------------------------------------------------------------------------- /test/snapshots/interruptedSinusoidal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d3/d3-geo-polygon/HEAD/test/snapshots/interruptedSinusoidal.png -------------------------------------------------------------------------------- /test/snapshots/polyhedralButterfly.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d3/d3-geo-polygon/HEAD/test/snapshots/polyhedralButterfly.png -------------------------------------------------------------------------------- /test/snapshots/polyhedralCollignon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d3/d3-geo-polygon/HEAD/test/snapshots/polyhedralCollignon.png -------------------------------------------------------------------------------- /test/snapshots/tetrahedralLeeSouth.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d3/d3-geo-polygon/HEAD/test/snapshots/tetrahedralLeeSouth.png -------------------------------------------------------------------------------- /test/snapshots/twoPointEquidistantUsa.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d3/d3-geo-polygon/HEAD/test/snapshots/twoPointEquidistantUsa.png -------------------------------------------------------------------------------- /test/snapshots/interruptedSinuMollweide.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d3/d3-geo-polygon/HEAD/test/snapshots/interruptedSinuMollweide.png -------------------------------------------------------------------------------- /test/snapshots/interruptedMollweideHemispheres.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d3/d3-geo-polygon/HEAD/test/snapshots/interruptedMollweideHemispheres.png -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["eslint:recommended"], 3 | "env": { 4 | "es2020": true, 5 | "node": true, 6 | "browser": true 7 | }, 8 | "parserOptions": { 9 | "sourceType": "module" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /test/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "eslint:recommended", 3 | "parserOptions": { 4 | "sourceType": "module", 5 | "ecmaVersion": 8 6 | }, 7 | "rules": { 8 | "no-cond-assign": 0, 9 | "no-constant-condition": 0 10 | }, 11 | "env": { 12 | "mocha": true, 13 | "node": true 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /observablehq.config.ts: -------------------------------------------------------------------------------- 1 | import * as snapshots from "./test/snapshots.js"; 2 | 3 | export default { 4 | title: "d3-geo-polygon", 5 | root: "docs", 6 | output: "dist/docs", 7 | async *dynamicPaths() { 8 | for (const snapshot of Object.keys(snapshots)) { 9 | yield `/snapshots/${snapshot}.png`; 10 | yield `/snapshots/${snapshot}-dark.png`; 11 | } 12 | }, 13 | }; 14 | -------------------------------------------------------------------------------- /src/polyhedral/octahedron.js: -------------------------------------------------------------------------------- 1 | // TODO generate on-the-fly to avoid external modification. 2 | const octahedron = [ 3 | [0, 90], 4 | [-90, 0], [0, 0], [90, 0], [180, 0], 5 | [0, -90] 6 | ]; 7 | 8 | export default [ 9 | [0, 2, 1], 10 | [0, 3, 2], 11 | [5, 1, 2], 12 | [5, 2, 3], 13 | [0, 1, 4], 14 | [0, 4, 3], 15 | [5, 4, 1], 16 | [5, 3, 4] 17 | ].map((face) => face.map((i) => octahedron[i])); 18 | -------------------------------------------------------------------------------- /.github/eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "problemMatcher": [ 3 | { 4 | "owner": "eslint-compact", 5 | "pattern": [ 6 | { 7 | "regexp": "^(.+):\\sline\\s(\\d+),\\scol\\s(\\d+),\\s(Error|Warning|Info)\\s-\\s(.+)\\s\\((.+)\\)$", 8 | "file": 1, 9 | "line": 2, 10 | "column": 3, 11 | "severity": 4, 12 | "message": 5, 13 | "code": 6 14 | } 15 | ] 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /src/clip/buffer.js: -------------------------------------------------------------------------------- 1 | import noop from "../noop.js"; 2 | 3 | export default function() { 4 | let lines = []; 5 | let line; 6 | return { 7 | point: function(x, y, i, t) { 8 | const point = [x, y]; 9 | // when called by clipPolygon, store index and t 10 | if (arguments.length > 2) { point.index = i; point.t = t; } 11 | line.push(point); 12 | }, 13 | lineStart: function() { 14 | lines.push(line = []); 15 | }, 16 | lineEnd: noop, 17 | rejoin: function() { 18 | if (lines.length > 1) lines.push(lines.pop().concat(lines.shift())); 19 | }, 20 | result: function() { 21 | const result = lines; 22 | lines = []; 23 | line = null; 24 | return result; 25 | } 26 | }; 27 | } 28 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 2 | 3 | name: Node.js CI 4 | 5 | on: 6 | push: 7 | branches: [ main ] 8 | pull_request: 9 | branches: [ main ] 10 | 11 | jobs: 12 | build: 13 | 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | matrix: 18 | node-version: [20] 19 | 20 | steps: 21 | - uses: actions/checkout@v4 22 | - name: Use Node.js ${{ matrix.node-version }} 23 | uses: actions/setup-node@v4 24 | with: 25 | node-version: ${{ matrix.node-version }} 26 | - run: yarn --frozen-lockfile 27 | - run: | 28 | echo ::add-matcher::.github/eslint.json 29 | yarn run eslint src test --format=compact 30 | - run: yarn test 31 | -------------------------------------------------------------------------------- /docs/snapshots/[snapshot].png.js: -------------------------------------------------------------------------------- 1 | import * as snapshots from "../../test/snapshots.js"; 2 | import {parseArgs} from "node:util"; 3 | import sharp from "sharp"; 4 | 5 | const { 6 | values: {snapshot} 7 | } = parseArgs({ 8 | options: {snapshot: {type: "string"}} 9 | }); 10 | 11 | const {projection, dark} = snapshot.match(/^(?.*?)(-(?dark))?$/).groups; 12 | 13 | snapshots[projection]() 14 | .then(async(canvas) => { 15 | if (dark) { 16 | const context = canvas.getContext("2d"); 17 | const im = context.getImageData(0, 0, canvas.width, canvas.height); 18 | const {data} = im 19 | for (let i = 0; i < data.length; ++i) { 20 | if ((i % 4) < 3) data[i] = 30 + 225 * (1 - data[i] / 255); 21 | } 22 | context.putImageData(im, 0, 0); 23 | } 24 | process.stdout.write(await sharp(canvas.toBuffer()).png({quality: 60}).toBuffer()) 25 | }); 26 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | workflow_dispatch: {} 5 | release: 6 | types: [published] 7 | 8 | jobs: 9 | publish: 10 | runs-on: ubuntu-latest 11 | permissions: 12 | contents: read 13 | packages: write 14 | id-token: write 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: actions/setup-node@v4 18 | with: 19 | node-version: 20 20 | registry-url: "https://registry.npmjs.org" 21 | cache: "yarn" 22 | - run: yarn --frozen-lockfile 23 | - run: yarn test 24 | - uses: step-security/wait-for-secrets@v1 25 | id: wait-for-secrets 26 | with: 27 | secrets: | 28 | OTP: 29 | name: 'OTP to publish package' 30 | description: 'OTP from authenticator app' 31 | - run: npm publish --otp ${{ steps.wait-for-secrets.outputs.OTP }} 32 | env: 33 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 34 | -------------------------------------------------------------------------------- /src/lagrange.js: -------------------------------------------------------------------------------- 1 | // code duplicated from d3-geo-projection 2 | import {abs, asin, atan2, cos, epsilon, halfPi, pow, sign, sin} from "./math.js"; 3 | 4 | export function lagrangeRaw(n) { 5 | 6 | function forward(lambda, phi) { 7 | if (abs(abs(phi) - halfPi) < epsilon) return [0, phi < 0 ? -2 : 2]; 8 | const sinPhi = sin(phi); 9 | const v = pow((1 + sinPhi) / (1 - sinPhi), n / 2); 10 | const c = 0.5 * (v + 1 / v) + cos(lambda *= n); 11 | return [2 * sin(lambda) / c, (v - 1 / v) / c]; 12 | } 13 | 14 | forward.invert = (x, y) => { 15 | const y0 = abs(y); 16 | if (abs(y0 - 2) < epsilon) return x ? null : [0, sign(y) * halfPi]; 17 | if (y0 > 2) return null; 18 | 19 | x /= 2, y /= 2; 20 | const x2 = x * x; 21 | const y2 = y * y; 22 | let t = 2 * y / (1 + x2 + y2); // tanh(nPhi) 23 | t = pow((1 + t) / (1 - t), 1 / n); 24 | return [ 25 | atan2(2 * x, 1 - x2 - y2) / n, 26 | asin((t - 1) / (t + 1)) 27 | ]; 28 | }; 29 | 30 | return forward; 31 | } 32 | -------------------------------------------------------------------------------- /src/butterfly.js: -------------------------------------------------------------------------------- 1 | import {geoCentroid as centroid, geoGnomonic as gnomonic} from "d3-geo"; 2 | import {pi} from "./math.js"; 3 | import polyhedral from "./polyhedral/index.js"; 4 | import octahedron from "./polyhedral/octahedron.js"; 5 | 6 | export default function(faceProjection = ((face) => { 7 | const c = centroid({type: "MultiPoint", coordinates: face}); 8 | return gnomonic().scale(1).translate([0, 0]).rotate([-c[0], -c[1]]); 9 | })) { 10 | const faces = octahedron.map((face) => ({face, project: faceProjection(face)})); 11 | 12 | [-1, 0, 0, 1, 0, 1, 4, 5].forEach((d, i) => { 13 | const node = faces[d]; 14 | node && (node.children || (node.children = [])).push(faces[i]); 15 | }); 16 | 17 | return polyhedral( 18 | faces[0], 19 | (lambda, phi) => faces[ 20 | lambda < -pi / 2 ? phi < 0 ? 6 : 4 21 | : lambda < 0 ? phi < 0 ? 2 : 0 22 | : lambda < pi / 2 ? phi < 0 ? 3 : 1 23 | : phi < 0 ? 7 : 5] 24 | ) 25 | .angle(-30) 26 | .scale(101.858) 27 | .center([0, 45]); 28 | } 29 | -------------------------------------------------------------------------------- /src/cubic.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Cubic map 3 | * 4 | * Implemented for D3.js by Enrico Spinielli (2017) and Philippe Rivière (2017—2024) 5 | * 6 | */ 7 | import voronoi from "./polyhedral/voronoi.js"; 8 | import {atan, degrees, sqrt1_2} from "./math.js"; 9 | 10 | const phi1 = atan(sqrt1_2) * degrees; 11 | const cube1 = [ 12 | [0, phi1], [90, phi1], [180, phi1], [-90, phi1], 13 | [0, -phi1], [90, -phi1], [180, -phi1], [-90, -phi1] 14 | ]; 15 | const cube = [ 16 | [0, 3, 2, 1], // N 17 | [0, 1, 5, 4], 18 | [1, 2, 6, 5], 19 | [2, 3, 7, 6], 20 | [3, 0, 4, 7], 21 | [4, 5, 6, 7] // S 22 | ].map((face) => face.map((i) => cube1[i])); 23 | 24 | export default function() { 25 | const polygons = { 26 | type: "FeatureCollection", 27 | features: cube.map((face) => ({ 28 | type: "Feature", 29 | geometry: { 30 | type: "Polygon", 31 | coordinates: [[...face, face[0]]] 32 | } 33 | })) 34 | }; 35 | 36 | const parents = [-1, 0, 1, 5, 3, 2]; 37 | 38 | return voronoi() 39 | .polygons(polygons) 40 | .parents(parents) 41 | .scale(96.8737) 42 | .center([135, -45]) 43 | .rotate([120,0]); 44 | } 45 | 46 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2017-2024 Mike Bostock 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any purpose 4 | with or without fee is hereby granted, provided that the above copyright notice 5 | and this permission notice appear in all copies. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 8 | REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND 9 | FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 10 | INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS 11 | OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER 12 | TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF 13 | THIS SOFTWARE. 14 | 15 | Philippe Rivière’s implementation of the Gray-Fuller raw projection (grayfuller.js) 16 | belongs to the public domain. 17 | 18 | > Buckminster Fuller’s spherical triangle transformation procedure 19 | > 20 | > Based on Robert W. Gray’s formulae published in “Exact Transformation Equations 21 | > For Fuller's World Map,” _Cartographica_, 32(3): 17-25 (1995). 22 | > 23 | > Implemented for D3.js by Philippe Rivière, 2018 (https://visionscarto.net/) 24 | > 25 | > To the extent possible under law, Philippe Rivière has waived all copyright 26 | > and related or neighboring rights to this implementation. (Public Domain.) 27 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import { readFileSync } from "fs"; 2 | import terser from "@rollup/plugin-terser"; 3 | import meta from "./package.json" assert { type: "json" }; 4 | 5 | // Extract copyrights from the LICENSE. 6 | const copyright = readFileSync("./LICENSE", "utf-8") 7 | .split(/\n/g) 8 | .filter((line) => /^Copyright\s+/.test(line)) 9 | .map((line) => line.replace(/^Copyright\s+/, "")) 10 | .join(", "); 11 | 12 | const config = { 13 | input: "src/index.js", 14 | external: Object.keys(meta.dependencies || {}).filter((key) => 15 | /^d3-/.test(key) 16 | ), 17 | output: { 18 | file: `dist/${meta.name}.js`, 19 | name: "d3", 20 | format: "umd", 21 | indent: false, 22 | extend: true, 23 | banner: `// ${meta.homepage} v${meta.version} Copyright ${copyright}`, 24 | globals: Object.assign( 25 | {}, 26 | ...Object.keys(meta.dependencies || {}) 27 | .filter((key) => /^d3-/.test(key)) 28 | .map((key) => ({ [key]: "d3" })) 29 | ), 30 | }, 31 | plugins: [], 32 | }; 33 | 34 | export default [ 35 | config, 36 | { 37 | ...config, 38 | output: { 39 | ...config.output, 40 | file: `dist/${meta.name}.min.js`, 41 | }, 42 | plugins: [ 43 | ...config.plugins, 44 | terser({ 45 | output: { 46 | preamble: config.output.banner, 47 | }, 48 | }), 49 | ], 50 | }, 51 | ]; 52 | -------------------------------------------------------------------------------- /src/dodecahedral.js: -------------------------------------------------------------------------------- 1 | import {acos, asin, degrees, sqrt} from "./math.js"; 2 | import voronoi from "./polyhedral/voronoi.js"; 3 | 4 | export default function() { 5 | const A0 = asin(1/sqrt(3)) * degrees; 6 | const A1 = acos((sqrt(5) - 1) / sqrt(3) / 2) * degrees; 7 | const A2 = 90 - A1; 8 | const A3 = acos(-(1 + sqrt(5)) / sqrt(3) / 2) * degrees; 9 | 10 | const dodecahedron = [ 11 | [[45,A0],[0,A1],[180,A1],[135,A0],[90,A2]], 12 | [[45,A0],[A2,0],[-A2,0],[-45,A0],[0,A1]], 13 | [[45,A0],[90,A2],[90,-A2],[45,-A0],[A2,0]], 14 | [[0,A1],[-45,A0],[-90,A2],[-135,A0],[180,A1]], 15 | [[A2,0],[45,-A0],[0,-A1],[-45,-A0],[-A2,0]], 16 | [[90,A2],[135,A0],[A3,0],[135,-A0],[90,-A2]], 17 | [[45,-A0],[90,-A2],[135,-A0],[180,-A1],[0,-A1]], 18 | [[135,A0],[180,A1],[-135,A0],[-A3,0],[A3,0]], 19 | [[-45,A0],[-A2,0],[-45,-A0],[-90,-A2],[-90,A2]], 20 | [[-45,-A0],[0,-A1],[180,-A1],[-135,-A0],[-90,-A2]], 21 | [[135,-A0],[A3,0],[-A3,0],[-135,-A0],[180,-A1]], 22 | [[-135,A0],[-90,A2],[-90,-A2],[-135,-A0],[-A3,0]] 23 | ]; 24 | 25 | 26 | const polygons = { 27 | type: "FeatureCollection", 28 | features: dodecahedron.map((face) => ({ 29 | type: "Feature", 30 | geometry: { 31 | type: "Polygon", 32 | coordinates: [ [...face, face[0]] ] 33 | } 34 | })) 35 | }; 36 | 37 | return voronoi() 38 | .parents([-1,0,4,8,1,2,2,3,1,8,6,3]) 39 | .angle(72 * 1.5) 40 | .polygons(polygons) 41 | .scale(99.8) 42 | .rotate([-8,0,-32]); 43 | } -------------------------------------------------------------------------------- /src/cartesian.js: -------------------------------------------------------------------------------- 1 | import {asin, atan2, cos, degrees, epsilon2, radians, sin, hypot} from "./math.js"; 2 | 3 | export function spherical(cartesian) { 4 | return [atan2(cartesian[1], cartesian[0]), asin(cartesian[2])]; 5 | } 6 | 7 | export function sphericalDegrees(cartesian) { 8 | const c = spherical(cartesian); 9 | return [c[0] * degrees, c[1] * degrees]; 10 | } 11 | 12 | export function cartesian(spherical) { 13 | const lambda = spherical[0], phi = spherical[1], cosPhi = cos(phi); 14 | return [cosPhi * cos(lambda), cosPhi * sin(lambda), sin(phi)]; 15 | } 16 | 17 | export function cartesianDegrees(spherical) { 18 | return cartesian([spherical[0] * radians, spherical[1] * radians]); 19 | } 20 | 21 | export function cartesianDot(a, b) { 22 | return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; 23 | } 24 | 25 | export function cartesianCross(a, b) { 26 | return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]; 27 | } 28 | 29 | // TODO return a 30 | export function cartesianAddInPlace(a, b) { 31 | a[0] += b[0], a[1] += b[1], a[2] += b[2]; 32 | } 33 | 34 | export function cartesianScale(vector, k) { 35 | return [vector[0] * k, vector[1] * k, vector[2] * k]; 36 | } 37 | 38 | export function cartesianNormalize(d) { 39 | const l = hypot(d[0], d[1], d[2]); 40 | return [d[0] / l, d[1] / l, d[2] / l]; 41 | } 42 | 43 | export function cartesianEqual(a, b) { 44 | const dx = b[0] - a[0]; 45 | const dy = b[1] - a[1]; 46 | const dz = b[2] - a[2]; 47 | return dx * dx + dy * dy + dz * dz < epsilon2 * epsilon2; 48 | } 49 | -------------------------------------------------------------------------------- /test/snapshot-test.js: -------------------------------------------------------------------------------- 1 | import assert from "assert"; 2 | import {mkdirSync, promises} from "fs"; 3 | import {resolve} from "path"; 4 | import pixelmatch from "pixelmatch"; 5 | import {PNG} from "pngjs"; 6 | import * as snapshots from "./snapshots.js"; 7 | 8 | const {readFile, writeFile} = promises; 9 | 10 | mkdirSync("./test/snapshots", {recursive: true}); 11 | 12 | for (const [name, snapshot] of Object.entries(snapshots)) { 13 | it(`snapshot ${name}`, async function() { 14 | this.timeout(10000); 15 | const canvas = await snapshot(); 16 | const actual = PNG.sync.read(canvas.toBuffer()); 17 | const outfile = resolve("./test/snapshots", `${name}.png`); 18 | let expected; 19 | 20 | try { 21 | expected = PNG.sync.read(await readFile(outfile)); 22 | } catch (error) { 23 | if (error.code === "ENOENT" && process.env.CI !== "true") { 24 | console.warn(`! generating ${outfile}`); 25 | await writeFile(outfile, PNG.sync.write(actual)); 26 | return; 27 | } else { 28 | throw error; 29 | } 30 | } 31 | 32 | const diff = new PNG({ 33 | width: expected.width, 34 | height: expected.height 35 | }); 36 | 37 | const n = pixelmatch( 38 | expected.data, 39 | actual.data, 40 | diff.data, 41 | expected.width, 42 | expected.height, 43 | {threshold: 0.1} 44 | ); 45 | 46 | if (n > 0) { 47 | await writeFile(`${name}.diff.png`, PNG.sync.write(diff)); 48 | } 49 | 50 | assert(n === 0, `${name} must match snapshot (${n} differences; see ${name}.diff.png)`); 51 | }); 52 | } 53 | -------------------------------------------------------------------------------- /src/collignon.js: -------------------------------------------------------------------------------- 1 | import {geoCentroid as centroid, geoProjection as projection} from "d3-geo"; 2 | import {asin, pi, sin, sqrt, sqrtPi} from "./math.js"; 3 | import polyhedral from "./polyhedral/index.js"; 4 | import octahedron from "./polyhedral/octahedron.js"; 5 | 6 | // code duplicated from d3-geo-projection 7 | export function collignonRaw(lambda, phi) { 8 | const alpha = sqrt(1 - sin(phi)); 9 | return [(2 / sqrtPi) * lambda * alpha, sqrtPi * (1 - alpha)]; 10 | } 11 | 12 | collignonRaw.invert = function(x, y) { 13 | const lambda = (y / sqrtPi - 1); 14 | return [lambda ? x * sqrt(pi) / lambda / 2 : 0, asin(1 - lambda ** 2)]; 15 | }; 16 | 17 | 18 | const kx = 2 / sqrt(3); 19 | 20 | function collignonK(a, b) { 21 | const p = collignonRaw(a, b); 22 | return [p[0] * kx, p[1]]; 23 | } 24 | 25 | collignonK.invert = (x,y) => collignonRaw.invert(x / kx, y); 26 | 27 | export default function(faceProjection = (face) => { 28 | const c = centroid({type: "MultiPoint", coordinates: face}); 29 | return projection(collignonK).translate([0, 0]).scale(1).rotate(c[1] > 0 ? [-c[0], 0] : [180 - c[0], 180]); 30 | }) { 31 | const faces = octahedron.map((face) => ({face, project: faceProjection(face)})); 32 | 33 | [-1, 0, 0, 1, 0, 1, 4, 5].forEach((d, i) => { 34 | const node = faces[d]; 35 | node && (node.children || (node.children = [])).push(faces[i]); 36 | }); 37 | 38 | return polyhedral( 39 | faces[0], 40 | (lambda, phi) => faces[lambda < -pi / 2 ? phi < 0 ? 6 : 4 41 | : lambda < 0 ? phi < 0 ? 2 : 0 42 | : lambda < pi / 2 ? phi < 0 ? 3 : 1 43 | : phi < 0 ? 7 : 5]) 44 | .angle(-30) 45 | .scale(121.906) 46 | .center([0, 48.5904]); 47 | } 48 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export {default as geoClipPolygon} from "./clip/polygon.js"; 2 | export {default as geoIntersectArc} from "./intersect.js"; 3 | export {default as geoPolyhedral} from "./polyhedral/index.js"; 4 | export {default as geoPolyhedralButterfly} from "./butterfly.js"; 5 | export {default as geoPolyhedralCollignon} from "./collignon.js"; 6 | export {default as geoPolyhedralWaterman} from "./waterman.js"; 7 | export {default as geoPolyhedralVoronoi} from "./polyhedral/voronoi.js"; 8 | export {default as geoDodecahedral} from "./dodecahedral.js"; 9 | export {default as geoCox, coxRaw as geoCoxRaw} from "./cox.js"; 10 | export {default as geoTetrahedralLee, leeRaw as geoLeeRaw} from "./tetrahedralLee.js"; 11 | export {default as geoGrayFullerRaw} from "./grayfuller.js"; 12 | export {default as geoAirocean} from "./airocean.js"; 13 | export {default as geoIcosahedral} from "./icosahedral.js"; 14 | export {default as geoImago, imagoBlock as geoImagoBlock, imagoRaw as geoImagoRaw} from "./imago.js"; 15 | export {default as geoCubic} from "./cubic.js"; 16 | export {default as geoRhombic} from "./rhombic.js"; 17 | export {default as geoDeltoidal} from "./deltoidal.js"; 18 | export {default as geoCahillKeyes, cahillKeyesRaw as geoCahillKeyesRaw} from "./cahillKeyes.js"; 19 | export {default as geoComplexLog, complexLogRaw as geoComplexLogRaw} from "./complexLog.js"; 20 | export { 21 | geoBerghaus, 22 | geoGingery, 23 | geoHealpix, 24 | geoInterrupt, 25 | geoInterruptedBoggs, 26 | geoInterruptedHomolosine, 27 | geoInterruptedMollweide, 28 | geoInterruptedMollweideHemispheres, 29 | geoInterruptedSinuMollweide, 30 | geoInterruptedSinusoidal, 31 | geoTwoPointEquidistant, 32 | geoTwoPointEquidistantUsa 33 | } from "./reclip.js"; 34 | -------------------------------------------------------------------------------- /.github/workflows/pages.yml: -------------------------------------------------------------------------------- 1 | name: 🎉 Deploy to GitHub Pages 2 | 3 | on: 4 | # Run when the default branch is updated 5 | push: 6 | branches: ["main"] 7 | 8 | # Run manually from the Actions tab 9 | workflow_dispatch: 10 | 11 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 12 | permissions: 13 | contents: read 14 | pages: write 15 | id-token: write 16 | 17 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. 18 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. 19 | concurrency: 20 | group: "pages" 21 | cancel-in-progress: false 22 | 23 | jobs: 24 | # Build job 25 | build: 26 | runs-on: ubuntu-latest 27 | steps: 28 | - name: Checkout 29 | uses: actions/checkout@v4 30 | - name: Set up Node.js 31 | uses: actions/setup-node@v4 32 | with: 33 | node-version: '20' # Or your preferred Node.js version 34 | cache: 'yarn' 35 | - name: Install dependencies 36 | run: yarn install --frozen-lockfile 37 | - name: Build 38 | run: yarn build 39 | - name: Setup Pages 40 | uses: actions/configure-pages@v4 41 | - name: Upload artifact 42 | uses: actions/upload-pages-artifact@v3 43 | with: 44 | # Upload the output directory 45 | path: './dist/docs' 46 | 47 | # Deployment job 48 | deploy: 49 | environment: 50 | name: github-pages 51 | url: ${{ steps.deployment.outputs.page_url }} 52 | runs-on: ubuntu-latest 53 | needs: build 54 | steps: 55 | - name: Deploy to GitHub Pages 56 | id: deployment 57 | uses: actions/deploy-pages@v4 58 | -------------------------------------------------------------------------------- /src/polyhedral/matrix.js: -------------------------------------------------------------------------------- 1 | import {atan2, cos, sin, sqrt} from "../math.js"; 2 | 3 | // Note: 6-element arrays are used to denote the 3x3 affine transform matrix: 4 | // [a, b, c, 5 | // d, e, f, 6 | // 0, 0, 1] - this redundant row is left out. 7 | 8 | // Transform matrix for [a0, a1] -> [b0, b1]. 9 | export default function(a, b) { 10 | const u = subtract(a[1], a[0]); 11 | const v = subtract(b[1], b[0]); 12 | const phi = angle(u, v); 13 | const s = length(u) / length(v); 14 | 15 | return multiply([ 16 | 1, 0, a[0][0], 17 | 0, 1, a[0][1] 18 | ], multiply([ 19 | s, 0, 0, 20 | 0, s, 0 21 | ], multiply([ 22 | cos(phi), sin(phi), 0, 23 | -sin(phi), cos(phi), 0 24 | ], [ 25 | 1, 0, -b[0][0], 26 | 0, 1, -b[0][1] 27 | ]))); 28 | } 29 | 30 | // Inverts a transform matrix. 31 | export function inverse(m) { 32 | const k = 1 / (m[0] * m[4] - m[1] * m[3]); 33 | return [ 34 | k * m[4], -k * m[1], k * (m[1] * m[5] - m[2] * m[4]), 35 | -k * m[3], k * m[0], k * (m[2] * m[3] - m[0] * m[5]) 36 | ]; 37 | } 38 | 39 | // Multiplies two 3x2 matrices. 40 | export function multiply(a, b) { 41 | return [ 42 | a[0] * b[0] + a[1] * b[3], 43 | a[0] * b[1] + a[1] * b[4], 44 | a[0] * b[2] + a[1] * b[5] + a[2], 45 | a[3] * b[0] + a[4] * b[3], 46 | a[3] * b[1] + a[4] * b[4], 47 | a[3] * b[2] + a[4] * b[5] + a[5] 48 | ]; 49 | } 50 | 51 | // Subtracts 2D vectors. 52 | function subtract(a, b) { 53 | return [a[0] - b[0], a[1] - b[1]]; 54 | } 55 | 56 | // Magnitude of a 2D vector. 57 | function length(v) { 58 | return sqrt(v[0] * v[0] + v[1] * v[1]); 59 | } 60 | 61 | // Angle between two 2D vectors. 62 | function angle(a, b) { 63 | return atan2(a[0] * b[1] - a[1] * b[0], a[0] * b[0] + a[1] * b[1]); 64 | } 65 | -------------------------------------------------------------------------------- /src/math.js: -------------------------------------------------------------------------------- 1 | export const abs = Math.abs; 2 | export const atan = Math.atan; 3 | export const atan2 = Math.atan2; 4 | export const ceil = Math.ceil; 5 | export const cos = Math.cos; 6 | export const exp = Math.exp; 7 | export const floor = Math.floor; 8 | export const hypot = Math.hypot; 9 | export const log = Math.log; 10 | export const max = Math.max; 11 | export const min = Math.min; 12 | export const pow = Math.pow; 13 | export const round = Math.round; 14 | export const sign = Math.sign || function(x) { return x > 0 ? 1 : x < 0 ? -1 : 0; }; 15 | export const sin = Math.sin; 16 | export const tan = Math.tan; 17 | 18 | export const epsilon = 1e-6; 19 | export const epsilon2 = 1e-12; 20 | export const pi = Math.PI; 21 | export const halfPi = pi / 2; 22 | export const quarterPi = pi / 4; 23 | export const sqrt1_2 = Math.SQRT1_2; 24 | export const sqrt2 = sqrt(2); 25 | export const sqrtPi = sqrt(pi); 26 | export const tau = pi * 2; 27 | export const degrees = 180 / pi; 28 | export const radians = pi / 180; 29 | 30 | export function sinci(x) { 31 | return x ? x / sin(x) : 1; 32 | } 33 | 34 | export function asin(x) { 35 | return x > 1 ? halfPi : x < -1 ? -halfPi : Math.asin(x); 36 | } 37 | 38 | export function acos(x) { 39 | return x > 1 ? 0 : x < -1 ? pi : Math.acos(x); 40 | } 41 | 42 | export function sqrt(x) { 43 | return x > 0 ? Math.sqrt(x) : 0; 44 | } 45 | 46 | export function tanh(x) { 47 | x = exp(2 * x); 48 | return (x - 1) / (x + 1); 49 | } 50 | 51 | export function sinh(x) { 52 | return (exp(x) - exp(-x)) / 2; 53 | } 54 | 55 | export function cosh(x) { 56 | return (exp(x) + exp(-x)) / 2; 57 | } 58 | 59 | export function arsinh(x) { 60 | return log(x + sqrt(x * x + 1)); 61 | } 62 | 63 | export function arcosh(x) { 64 | return log(x + sqrt(x * x - 1)); 65 | } 66 | -------------------------------------------------------------------------------- /test/clip/polygon-test.js: -------------------------------------------------------------------------------- 1 | import assert from "assert"; 2 | import { geoClipPolygon } from "../../src/index.js"; 3 | import { geoEquirectangular, geoPath } from "d3-geo"; 4 | 5 | it("clipPolygon clips line", () => { 6 | const clipPolygon = geoClipPolygon({ 7 | type: "Polygon", 8 | coordinates: [ 9 | [ 10 | [-10, -10], 11 | [-10, 10], 12 | [10, 10], 13 | [10, -10], 14 | [-10, -10], 15 | ], 16 | ], 17 | }); 18 | const projection = geoEquirectangular().clipAngle(10).preclip(clipPolygon); 19 | const path = geoPath().projection(projection); 20 | assert.deepStrictEqual( 21 | path({ 22 | type: "LineString", 23 | coordinates: [ 24 | [-20, 0], 25 | [20, 0], 26 | ], 27 | }), 28 | path({ 29 | type: "LineString", 30 | coordinates: [ 31 | [-10.5, 0], 32 | [10.5, 0], 33 | ], 34 | }) 35 | ); 36 | }); 37 | 38 | it("clipPolygon interpolates when the intersections are on the same segment", function () { 39 | const clipPolygon = geoClipPolygon({ 40 | type: "Polygon", 41 | coordinates: [ 42 | [ 43 | [-10, -11], 44 | [10, 10], 45 | [11, -10], 46 | [-10, -11], 47 | ], 48 | ], 49 | }), 50 | projection = geoEquirectangular().preclip(clipPolygon).precision(0.1), 51 | path = geoPath().projection(projection); 52 | assert.deepStrictEqual( 53 | path({ 54 | type: "Polygon", 55 | coordinates: [ 56 | [ 57 | [0, -11], 58 | [1, -11], 59 | [1, -10], 60 | [0, -10], 61 | [0, -11], 62 | ], 63 | ], 64 | }).replace(/[.]\d+/g, ""), 65 | "M482,278L482,276L480,276L480,278L466,279L453,279L506,223L509,276L495,277Z" 66 | ); 67 | }); 68 | -------------------------------------------------------------------------------- /src/newton.js: -------------------------------------------------------------------------------- 1 | import {abs, epsilon, epsilon2} from "./math.js"; 2 | 3 | // Approximate Newton-Raphson 4 | // Solve f(x) = y, start from x 5 | export function solve(f, y, x) { 6 | let steps = 100, delta, f0, f1; 7 | x = x === undefined ? 0 : +x; 8 | y = +y; 9 | do { 10 | f0 = f(x); 11 | f1 = f(x + epsilon); 12 | if (f0 === f1) f1 = f0 + epsilon; 13 | x -= delta = (-1 * epsilon * (f0 - y)) / (f0 - f1); 14 | } while (steps-- > 0 && abs(delta) > epsilon); 15 | return steps < 0 ? NaN : x; 16 | } 17 | 18 | // Approximate Newton-Raphson in 2D 19 | // Solve f(a,b) = [x,y] 20 | export function solve2d(f, MAX_ITERATIONS = 40, eps = epsilon2) { 21 | return (x, y, a = 0, b = 0) => { 22 | let err2, da, db; 23 | for (let i = 0; i < MAX_ITERATIONS; ++i) { 24 | const p = f(a, b), 25 | // diffs 26 | tx = p[0] - x, 27 | ty = p[1] - y; 28 | if (abs(tx) < eps && abs(ty) < eps) break; // we're there! 29 | 30 | // backtrack if we overshot 31 | const h = tx * tx + ty * ty; 32 | if (h > err2) { 33 | a -= da /= 2; 34 | b -= db /= 2; 35 | continue; 36 | } 37 | err2 = h; 38 | 39 | // partial derivatives 40 | const ea = (a > 0 ? -1 : 1) * eps, 41 | eb = (b > 0 ? -1 : 1) * eps, 42 | pa = f(a + ea, b), 43 | pb = f(a, b + eb), 44 | dxa = (pa[0] - p[0]) / ea, 45 | dya = (pa[1] - p[1]) / ea, 46 | dxb = (pb[0] - p[0]) / eb, 47 | dyb = (pb[1] - p[1]) / eb, 48 | // determinant 49 | D = dyb * dxa - dya * dxb, 50 | // newton step — or half-step for small D 51 | l = (abs(D) < 0.5 ? 0.5 : 1) / D; 52 | da = (ty * dxb - tx * dyb) * l; 53 | db = (tx * dya - ty * dxa) * l; 54 | a += da; 55 | b += db; 56 | if (abs(da) < eps && abs(db) < eps) break; // we're crawling 57 | } 58 | return [a, b]; 59 | }; 60 | } -------------------------------------------------------------------------------- /test/invert-test.js: -------------------------------------------------------------------------------- 1 | import { 2 | geoAirocean, 3 | geoCahillKeyes, 4 | geoComplexLog, 5 | geoCubic, 6 | geoDodecahedral, 7 | geoIcosahedral, 8 | geoImago, 9 | geoImagoBlock, 10 | geoTetrahedralLee, 11 | } from "../src/index.js"; 12 | import { assertProjectionEqual } from "./asserts.js"; 13 | 14 | it("inverse polyhedrals", function () { 15 | [geoAirocean(), geoCubic(), geoIcosahedral(), geoDodecahedral()].forEach( 16 | function (projection) { 17 | [ 18 | [-23, 12], 19 | [10, 10], 20 | [100, -45], 21 | ].forEach(function (location) { 22 | projection.angle(Math.random() * 360); 23 | assertProjectionEqual(projection, location, projection(location), 1e-5); 24 | }); 25 | } 26 | ); 27 | }); 28 | 29 | it("inverse Imago, tetrahedralLee", function () { 30 | [geoImago(), geoImagoBlock(), geoTetrahedralLee()].forEach(function ( 31 | projection 32 | ) { 33 | [ 34 | [-23, 12], 35 | [10, 10], 36 | [100, -45], 37 | ].forEach(function (location) { 38 | projection.angle(Math.random() * 360); 39 | assertProjectionEqual(projection, location, projection(location), 1e-5); 40 | }); 41 | }); 42 | }); 43 | 44 | it("inverse Cahill-Keyes", function () { 45 | [geoCahillKeyes()].forEach(function (projection) { 46 | [ 47 | [-23, 12], 48 | [10, 10], 49 | [100, -45], 50 | ].forEach(function (location) { 51 | projection.angle(Math.random() * 360); 52 | assertProjectionEqual(projection, location, projection(location), 1e-5); 53 | }); 54 | }); 55 | }); 56 | 57 | it("inverse complex log", function () { 58 | [geoComplexLog()].forEach(function (projection) { 59 | [ 60 | [0, 0], 61 | [-23, 12], 62 | [10, 10], 63 | [100, -45], 64 | ].forEach(function (location) { 65 | projection.angle(Math.random() * 360); 66 | assertProjectionEqual(projection, location, projection(location), 1e-5); 67 | }); 68 | }); 69 | }); 70 | -------------------------------------------------------------------------------- /src/grayfuller.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Buckminster Fuller’s spherical triangle transformation procedure 3 | * 4 | * Based on Robert W. Gray’s formulae published in “Exact Transformation Equations 5 | * For Fuller's World Map,” _Cartographica_, 32(3): 17-25 (1995). 6 | * 7 | * Implemented for D3.js by Philippe Rivière, 2018 (https://visionscarto.net/) 8 | * 9 | * To the extent possible under law, Philippe Rivière has waived all copyright 10 | * and related or neighboring rights to this implementation. (Public Domain.) 11 | */ 12 | import { abs, atan2, cos, epsilon, sin, sqrt } from "./math.js"; 13 | import { geoGnomonicRaw as gnomonicRaw } from "d3-geo"; 14 | 15 | export default function GrayFullerRaw() { 16 | const SQRT_3 = sqrt(3); 17 | 18 | // Gray’s constants 19 | const Z = sqrt(5 + 2 * sqrt(5)) / sqrt(15); 20 | const el = sqrt(8) / sqrt(5 + sqrt(5)); 21 | const dve = sqrt(3 + sqrt(5)) / sqrt(5 + sqrt(5)); 22 | 23 | const grayfuller = function(lambda, phi) { 24 | const cosPhi = cos(phi), 25 | s = Z / (cosPhi * cos(lambda)), 26 | x = cosPhi * sin(lambda) * s, 27 | y = sin(phi) * s, 28 | a1p = atan2(2 * y / SQRT_3 + el / 3 - el / 2, dve), 29 | a2p = atan2(x - y / SQRT_3 + el / 3 - el / 2, dve), 30 | a3p = atan2(el / 3 - x - y / SQRT_3 - el / 2, dve); 31 | 32 | return [SQRT_3 * (a2p - a3p), 2 * a1p - a2p - a3p]; 33 | }; 34 | 35 | // Inverse approximation 36 | grayfuller.invert = function(x, y) { 37 | // if the point is out of the triangle, return 38 | // something meaningless (but far away enough) 39 | if (x * x + y * y > 5) return [0, 3]; 40 | 41 | const R = 2.9309936378128416; 42 | const p = gnomonicRaw.invert(x / R, y / R); 43 | 44 | let j = 0, dx, dy; 45 | do { 46 | const f = grayfuller(p[0], p[1]); 47 | dx = x - f[0], 48 | dy = y - f[1]; 49 | p[0] += 0.2 * dx; 50 | p[1] += 0.2 * dy; 51 | } while (j++ < 30 && abs(dx) + abs(dy) > epsilon); 52 | 53 | return p; 54 | }; 55 | 56 | return grayfuller; 57 | } 58 | -------------------------------------------------------------------------------- /src/rhombic.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Rhombic Dodecahedron map 3 | * 4 | * Implemented for D3.js by Ronnie Bathoorn (2024) 5 | * based on Cubic map by Enrico Spinielli (2017) and Philippe Rivière (2017, 2018) 6 | * 7 | */ 8 | import { atan, degrees } from "./math.js"; 9 | import voronoi from "./polyhedral/voronoi.js"; 10 | import { geoCentroid } from "d3-geo"; 11 | 12 | export default function () { 13 | const phi1 = atan(Math.SQRT1_2) * degrees; 14 | const vertices = [ 15 | [0, 90], // 0 16 | [0, phi1], // 1 17 | [90, phi1], // 2 18 | [180, phi1], // 3 19 | [-90, phi1], // 4 20 | [45, 0], // 5 21 | [135, 0], // 6 22 | [-135, 0], // 7 23 | [-45, 0], // 8 24 | [0, -phi1], // 9 25 | [90, -phi1], // 10 26 | [180, -phi1], // 11 27 | [-90, -phi1], // 12 28 | [0, -90], // 13 29 | ]; 30 | 31 | // rhombic dodecahedron 32 | const polyhedron = [ 33 | [0, 1, 8, 4], 34 | [0, 2, 5, 1], 35 | [0, 3, 6, 2], 36 | [0, 4, 7, 3], 37 | 38 | [1, 5, 9, 8], 39 | [2, 6, 10, 5], 40 | [3, 7, 11, 6], 41 | [4, 8, 12, 7], 42 | 43 | [8, 9, 13, 12], 44 | [5, 10, 13, 9], 45 | [6, 11, 13, 10], 46 | [7, 12, 13, 11], 47 | ].map((face) => face.map((i) => vertices[i])); 48 | 49 | const polygons = { 50 | type: "FeatureCollection", 51 | features: polyhedron.map((face) => ({ 52 | type: "Feature", 53 | properties: { 54 | sitecoordinates: geoCentroid({ 55 | type: "MultiPoint", 56 | coordinates: face, 57 | }), 58 | }, 59 | geometry: { 60 | type: "Polygon", 61 | coordinates: [[...face, face[0]]], 62 | }, 63 | })) 64 | }; 65 | 66 | const parents = [ 67 | -1, // 0 68 | 0, // 1 69 | 6, // 2 70 | 2, // 3 71 | 1, // 4 72 | 9, // 5 73 | 11, // 6 74 | 3, // 7 75 | 4, // 8 76 | 8, // 9 77 | 5, // 10 78 | 10, // 11 79 | ]; 80 | 81 | return voronoi() 82 | .polygons(polygons) 83 | .parents(parents) 84 | .angle(20) 85 | .rotate([80, 0, -Math.asin(Math.sqrt(3) / 3) * 90]) 86 | .translate([213, 252]) 87 | .scale(106.48) 88 | } 89 | -------------------------------------------------------------------------------- /src/complex.js: -------------------------------------------------------------------------------- 1 | import { abs, atan2, cos, exp, halfPi, log, pow, sin, sqrt } from "./math.js"; 2 | 3 | export function complexAtan(x, y) { 4 | const x2 = x * x, 5 | y_1 = y + 1, 6 | t = 1 - x2 - y * y; 7 | return [ 8 | 0.5 * ((x >= 0 ? halfPi : -halfPi) - atan2(t, 2 * x)), 9 | -0.25 * log(t * t + 4 * x2) + 0.5 * log(y_1 * y_1 + x2) 10 | ]; 11 | } 12 | 13 | export function complexDivide(a, b) { 14 | if (b[1]) (a = complexMul(a, [b[0], -b[1]])), (b = complexNorm2(b)); 15 | else b = b[0]; 16 | return [a[0] / b, a[1] / b]; 17 | } 18 | 19 | export function complexMul(a, b) { 20 | return [a[0] * b[0] - a[1] * b[1], a[1] * b[0] + a[0] * b[1]]; 21 | } 22 | 23 | export function complexAdd(a, b) { 24 | return [a[0] + b[0], a[1] + b[1]]; 25 | } 26 | 27 | export function complexSub(a, b) { 28 | return [a[0] - b[0], a[1] - b[1]]; 29 | } 30 | 31 | export function complexNorm2(a) { 32 | return a[0] * a[0] + a[1] * a[1]; 33 | } 34 | 35 | export function complexNorm(a) { 36 | return sqrt(complexNorm2(a)); 37 | } 38 | 39 | export function complexLogHypot(a, b) { 40 | const _a = abs(a); 41 | const _b = abs(b); 42 | if (a === 0) return log(_b); 43 | if (b === 0) return log(_a); 44 | if (_a < 3000 && _b < 3000) return log(a * a + b * b) * 0.5; 45 | return log(a / cos(atan2(b, a))); 46 | } 47 | 48 | // adapted from https://github.com/infusion/Complex.js 49 | export function complexPow(a, n) { 50 | let b = a[1], 51 | arg, 52 | loh; 53 | a = a[0]; 54 | if (a === 0 && b === 0) return [0, 0]; 55 | 56 | if (typeof n === "number") n = [n, 0]; 57 | 58 | if (!n[1]) { 59 | if (b === 0 && a >= 0) { 60 | return [pow(a, n[0]), 0]; 61 | } else if (a === 0) { 62 | switch ((n[1] % 4 + 4) % 4) { 63 | case 0: 64 | return [pow(b, n[0]), 0]; 65 | case 1: 66 | return [0, pow(b, n[0])]; 67 | case 2: 68 | return [-pow(b, n[0]), 0]; 69 | case 3: 70 | return [0, -pow(b, n[0])]; 71 | } 72 | } 73 | } 74 | 75 | arg = atan2(b, a); 76 | loh = complexLogHypot(a, b); 77 | a = exp(n[0] * loh - n[1] * arg); 78 | b = n[1] * loh + n[0] * arg; 79 | return [a * cos(b), a * sin(b)]; 80 | } 81 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "d3-geo-polygon", 3 | "version": "2.0.1", 4 | "description": "Clipping and geometric operations for spherical polygons.", 5 | "homepage": "https://github.com/d3/d3-geo-polygon", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/d3/d3-geo-polygon.git" 9 | }, 10 | "keywords": [ 11 | "d3", 12 | "d3-module", 13 | "cartography", 14 | "projection", 15 | "polygon" 16 | ], 17 | "license": "ISC", 18 | "author": { 19 | "name": "Mike Bostock", 20 | "url": "https://bost.ocks.org/mike" 21 | }, 22 | "contributors": [ 23 | { 24 | "name": "Jason Davies", 25 | "url": "https://www.jasondavies.com" 26 | }, 27 | { 28 | "name": "Philippe Rivière", 29 | "url": "https://visionscarto.net" 30 | }, 31 | { 32 | "name": "Enrico Spinielli", 33 | "url": "https://enrico.spinielli.net/" 34 | }, 35 | { 36 | "name": "Ronnie Bathoorn", 37 | "url": "https://github.com/bathoorn" 38 | } 39 | ], 40 | "type": "module", 41 | "files": [ 42 | "dist/**/*.js", 43 | "src/**/*.js" 44 | ], 45 | "module": "src/index.js", 46 | "main": "src/index.js", 47 | "jsdelivr": "dist/d3-geo-polygon.min.js", 48 | "unpkg": "dist/d3-geo-polygon.min.js", 49 | "exports": { 50 | "umd": "./dist/d3-geo-polygon.min.js", 51 | "default": "./src/index.js" 52 | }, 53 | "sideEffects": false, 54 | "dependencies": { 55 | "d3-array": "2.5.0 - 3", 56 | "d3-geo": "2 - 3", 57 | "d3-geo-projection": "4" 58 | }, 59 | "devDependencies": { 60 | "@observablehq/framework": "^1.12.0", 61 | "@rollup/plugin-terser": "0.4", 62 | "canvas": "2", 63 | "eslint": "8", 64 | "mocha": "10", 65 | "pixelmatch": "5", 66 | "pngjs": "6", 67 | "rollup": "3", 68 | "sharp": "^0.33.5", 69 | "topojson-client": "3", 70 | "world-atlas": "2" 71 | }, 72 | "scripts": { 73 | "docs:dev": "observable preview", 74 | "docs:build": "observable build", 75 | "docs:deploy": "observable deploy", 76 | "build": "yarn docs:build", 77 | "deploy": "yarn docs:deploy", 78 | "test": "mocha 'test/**/*-test.js' && eslint src test", 79 | "prepublishOnly": "rm -rf dist && rollup -c" 80 | }, 81 | "engines": { 82 | "node": ">=18" 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/intersect.js: -------------------------------------------------------------------------------- 1 | import {abs, acos, cos, degrees, epsilon, epsilon2, pi, radians} from "./math.js"; 2 | import {cartesian, cartesianCross, cartesianDot, cartesianEqual, cartesianNormalize, spherical} from "./cartesian.js"; 3 | 4 | export class intersectSegment { 5 | constructor(from, to) { 6 | this.from = from, this.to = to; 7 | this.normal = cartesianCross(from, to); 8 | this.fromNormal = cartesianCross(this.normal, from); 9 | this.toNormal = cartesianCross(this.normal, to); 10 | this.l = acos(cartesianDot(from, to)); 11 | } 12 | } 13 | 14 | // >> here a and b are segments processed by intersectSegment 15 | export function intersect(a, b) { 16 | if (cartesianEqual(a.from, b.from) || cartesianEqual(a.from, b.to)) return a.from; 17 | if (cartesianEqual(a.to, b.from) || cartesianEqual(a.to, b.to)) return a.to; 18 | 19 | // Slightly faster lookup when there is no intersection 20 | const lc = (a.l + b.l < pi) ? cos(a.l + b.l) - epsilon : -1; 21 | if (cartesianDot(a.from, b.from) < lc 22 | || cartesianDot(a.from, b.to) < lc 23 | || cartesianDot(a.to, b.from) < lc 24 | || cartesianDot(a.to, b.to) < lc) 25 | return; 26 | 27 | const axb = cartesianNormalize(cartesianCross(a.normal, b.normal)); 28 | 29 | const a0 = cartesianDot(axb, a.fromNormal); 30 | const a1 = cartesianDot(axb, a.toNormal); 31 | const b0 = cartesianDot(axb, b.fromNormal); 32 | const b1 = cartesianDot(axb, b.toNormal); 33 | 34 | // check if the candidate lies on both segments 35 | // or is almost equal to one of the four points 36 | if (a0 >= 0 && a1 <= 0 && b0 >= 0 && b1 <= 0) 37 | return axb; 38 | 39 | // same test for the antipode 40 | if (a0 <= 0 && a1 >= 0 && b0 <= 0 && b1 >= 0) 41 | return axb.map(d => -d); 42 | } 43 | 44 | export function intersectPointOnLine(p, a) { 45 | const a0 = cartesianDot(p, a.fromNormal); 46 | const a1 = cartesianDot(p, a.toNormal); 47 | p = cartesianDot(p, a.normal); 48 | return abs(p) < epsilon2 && (a0 > -epsilon2 && a1 < epsilon2 || a0 < epsilon2 && a1 > -epsilon2); 49 | } 50 | 51 | export const intersectCoincident = {}; 52 | 53 | export default function(a, b) { 54 | const ca = a.map(p => cartesian(p.map(d => d * radians))); 55 | const cb = b.map(p => cartesian(p.map(d => d * radians))); 56 | const i = intersect( 57 | new intersectSegment(ca[0], ca[1]), 58 | new intersectSegment(cb[0], cb[1]) 59 | ); 60 | return i ? spherical(i).map((d) => d * degrees) : null; 61 | } 62 | -------------------------------------------------------------------------------- /test/asserts.js: -------------------------------------------------------------------------------- 1 | import assert from "assert"; 2 | 3 | export function assertPathEqual(actual, expected) { 4 | assert.strictEqual(normalizePath(actual + ""), normalizePath(expected + "")); 5 | } 6 | 7 | const reNumber = /[-+]?(?:\d+\.\d+|\d+\.|\.\d+|\d+)(?:[eE][-]?\d+)?/g; 8 | 9 | function normalizePath(path) { 10 | return path.replace(reNumber, formatNumber); 11 | } 12 | 13 | function formatNumber(s) { 14 | return Math.abs((s = +s) - Math.round(s)) < 1e-6 ? Math.round(s) : s.toFixed(6); 15 | } 16 | 17 | export function assertInDelta(actual, expected, delta) { 18 | delta = delta || 1e-6; 19 | assert(inDelta(actual, expected, delta), 20 | `${actual} should be within ${delta} of ${expected}`); 21 | } 22 | 23 | function inDelta(actual, expected, delta) { 24 | return (Array.isArray(expected) ? inDeltaArray 25 | : typeof expected === "object" ? inDeltaObject 26 | : inDeltaNumber)(actual, expected, delta); 27 | } 28 | 29 | function inDeltaArray(actual, expected, delta) { 30 | let n = expected.length, i = -1; 31 | if (actual.length !== n) return false; 32 | while (++i < n) if (!inDelta(actual[i], expected[i], delta)) return false; 33 | return true; 34 | } 35 | 36 | function inDeltaObject(actual, expected, delta) { 37 | for (let i in expected) if (!inDelta(actual[i], expected[i], delta)) return false; 38 | for (let i in actual) if (!(i in expected)) return false; 39 | return true; 40 | } 41 | 42 | function inDeltaNumber(actual, expected, delta) { 43 | return actual >= expected - delta && actual <= expected + delta; 44 | } 45 | 46 | 47 | export function assertProjectionEqual(projection, location, point, delta) { 48 | assert(planarEqual(projection(location), point, delta || 1e-6) 49 | && sphericalEqual(projection.invert(point), location, delta || 1e-3), 50 | `${[projection.invert(point), projection(location)]} should be projected equivalents; expected: ${[location, point]}`); 51 | } 52 | 53 | function planarEqual(actual, expected, delta) { 54 | return Array.isArray(actual) 55 | && actual.length === 2 56 | && inDelta(actual[0], expected[0], delta) 57 | && inDelta(actual[1], expected[1], delta); 58 | } 59 | 60 | function sphericalEqual(actual, expected, delta) { 61 | return Array.isArray(actual) 62 | && actual.length === 2 63 | && longitudeEqual(actual[0], expected[0], delta) 64 | && inDelta(actual[1], expected[1], delta); 65 | } 66 | 67 | function longitudeEqual(actual, expected, delta) { 68 | actual = Math.abs(actual - expected) % 360; 69 | return actual <= delta || actual >= 360 - delta; 70 | } 71 | -------------------------------------------------------------------------------- /src/icosahedral.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Icosahedral map 3 | * 4 | * Implemented for D3.js by Jason Davies (2013), 5 | * Enrico Spinielli (2017) and Philippe Rivière (2017, 2018) 6 | * 7 | */ 8 | import { atan, degrees } from "./math.js"; 9 | import voronoi from "./polyhedral/voronoi.js"; 10 | 11 | 12 | export default function() { 13 | const theta = atan(0.5) * degrees; 14 | 15 | // construction inspired by 16 | // https://en.wikipedia.org/wiki/Regular_icosahedron#Spherical_coordinates 17 | const vertices = [[0, 90], [0, -90]].concat( 18 | [0,1,2,3,4,5,6,7,8,9].map((i) => [(i * 36 + 180) % 360 - 180, i & 1 ? theta : -theta]) 19 | ); 20 | 21 | // icosahedron 22 | const polyhedron = [ 23 | [0, 3, 11], 24 | [0, 5, 3], 25 | [0, 7, 5], 26 | [0, 9, 7], 27 | [0, 11, 9], // North 28 | [2, 11, 3], 29 | [3, 4, 2], 30 | [4, 3, 5], 31 | [5, 6, 4], 32 | [6, 5, 7], 33 | [7, 8, 6], 34 | [8, 7, 9], 35 | [9, 10, 8], 36 | [10, 9, 11], 37 | [11, 2, 10], // Equator 38 | [1, 2, 4], 39 | [1, 4, 6], 40 | [1, 6, 8], 41 | [1, 8, 10], 42 | [1, 10, 2] // South 43 | ].map((face) => face.map((i) => vertices[i])); 44 | 45 | const polygons = { 46 | type: "FeatureCollection", 47 | features: polyhedron.map((face) => ({ 48 | type: "Feature", 49 | geometry: { 50 | type: "Polygon", 51 | coordinates: [[...face, face[0]]] 52 | } 53 | })) 54 | }; 55 | 56 | const parents = [ 57 | // N 58 | -1, // 0 59 | 7, // 1 60 | 9, // 2 61 | 11, // 3 62 | 13, // 4 63 | 64 | // Eq 65 | 0, // 5 66 | 5, // 6 67 | 6, // 7 68 | 7, // 8 69 | 8, // 9 70 | 71 | 9, // 10 72 | 10, // 11 73 | 11, // 12 74 | 12, // 13 75 | 13, // 14 76 | 77 | // S 78 | 6, // 15 79 | 8, // 16 80 | 10, // 17 81 | 12, // 18 82 | 14, // 19 83 | ]; 84 | 85 | return voronoi() 86 | .parents(parents) 87 | .polygons(polygons) 88 | .rotate([108,0]) 89 | .scale(131.777) 90 | .center([162, 0]); 91 | } 92 | 93 | 94 | /* 95 | // Jarke J. van Wijk, "Unfolding the Earth: Myriahedral Projections", 96 | // The Cartographic Journal Vol. 45 No. 1 pp. 32–42 February 2008, fig. 8 97 | // https://bl.ocks.org/espinielli/475f5fde42a5513ab7eba3f53033ea9e 98 | d3.geoIcosahedral().parents([-1,0,1,11,3,0,7,1,7,8,9,10,11,12,13,6,8,10,19,15]) 99 | .angle(-60) 100 | .rotate([-83.65929, 25.44458, -87.45184]) 101 | */ -------------------------------------------------------------------------------- /src/polygonContains.js: -------------------------------------------------------------------------------- 1 | import {Adder} from "d3-array"; 2 | import {cartesian, cartesianCross, cartesianNormalize} from "./cartesian.js"; 3 | import {asin, atan2, cos, epsilon, pi, quarterPi, sin, tau} from "./math.js"; 4 | 5 | export default function(polygon, point) { 6 | const lambda = point[0]; 7 | const phi = point[1]; 8 | const normal = [sin(lambda), -cos(lambda), 0]; 9 | let angle = 0; 10 | let winding = 0; 11 | 12 | const sum = new Adder(); 13 | 14 | for (let i = 0, n = polygon.length; i < n; ++i) { 15 | if (!(m = (ring = polygon[i]).length)) continue; 16 | var ring, 17 | m, 18 | point0 = ring[m - 1], 19 | lambda0 = point0[0], 20 | phi0 = point0[1] / 2 + quarterPi, 21 | sinPhi0 = sin(phi0), 22 | cosPhi0 = cos(phi0); 23 | 24 | for (let j = 0; j < m; ++j, lambda0 = lambda1, sinPhi0 = sinPhi1, cosPhi0 = cosPhi1, point0 = point1) { 25 | var point1 = ring[j], 26 | lambda1 = point1[0], 27 | phi1 = point1[1] / 2 + quarterPi, 28 | sinPhi1 = sin(phi1), 29 | cosPhi1 = cos(phi1), 30 | delta = lambda1 - lambda0, 31 | sign = delta >= 0 ? 1 : -1, 32 | absDelta = sign * delta, 33 | antimeridian = absDelta > pi, 34 | k = sinPhi0 * sinPhi1; 35 | 36 | sum.add(atan2(k * sign * sin(absDelta), cosPhi0 * cosPhi1 + k * cos(absDelta))); 37 | angle += antimeridian ? delta + sign * tau : delta; 38 | 39 | // Are the longitudes either side of the point’s meridian (lambda), 40 | // and are the latitudes smaller than the parallel (phi)? 41 | if (antimeridian ^ lambda0 >= lambda ^ lambda1 >= lambda) { 42 | const arc = cartesianNormalize(cartesianCross(cartesian(point0), cartesian(point1))); 43 | const intersection = cartesianNormalize(cartesianCross(normal, arc)); 44 | const phiArc = (antimeridian ^ delta >= 0 ? -1 : 1) * asin(intersection[2]); 45 | if (phi > phiArc || phi === phiArc && (arc[0] || arc[1])) { 46 | winding += antimeridian ^ delta >= 0 ? 1 : -1; 47 | } 48 | } 49 | } 50 | } 51 | 52 | // First, determine whether the South pole is inside or outside: 53 | // 54 | // It is inside if: 55 | // * the polygon winds around it in a clockwise direction. 56 | // * the polygon does not (cumulatively) wind around it, but has a negative 57 | // (counter-clockwise) area. 58 | // 59 | // Second, count the (signed) number of times a segment crosses a lambda 60 | // from the point to the South pole. If it is zero, then the point is the 61 | // same side as the South pole. 62 | 63 | return (angle < -epsilon || angle < epsilon && +sum < -epsilon) ^ (winding & 1); 64 | } 65 | -------------------------------------------------------------------------------- /test/intersect-test.js: -------------------------------------------------------------------------------- 1 | import assert from "assert"; 2 | import { assertInDelta } from "./asserts.js"; 3 | import { geoIntersectArc } from "../src/index.js"; 4 | 5 | it("spherical intersections", function () { 6 | let e = geoIntersectArc( 7 | [ 8 | [0, 0], 9 | [0, 90], 10 | ], 11 | [ 12 | [-10, 40], 13 | [10, 40], 14 | ] 15 | ); 16 | assert(e); 17 | assertInDelta(e, [0, 40.43246108], 1e-8); 18 | 19 | // https://observablehq.com/@fil/spherical-intersection#points 20 | const p = [ 21 | [0, 70], 22 | [-10, 10], 23 | [-40, 30], 24 | [10, 45], 25 | ]; 26 | 27 | assertInDelta(geoIntersectArc([p[0], p[1]], [p[0], p[3]]), p[0], 1e-8); 28 | assertInDelta(geoIntersectArc([p[0], p[1]], [p[1], p[3]]), p[1], 1e-8); 29 | assertInDelta(geoIntersectArc([p[0], p[1]], [p[2], p[0]]), p[0], 1e-8); 30 | assertInDelta(geoIntersectArc([p[0], p[1]], [p[2], p[1]]), p[1], 1e-8); 31 | 32 | assertInDelta( 33 | geoIntersectArc([p[0], p[1]], [p[2], p[3]]), 34 | [-7.081398732358556, 42.94731141237317], 35 | 1e-8 36 | ); 37 | 38 | assertInDelta( 39 | geoIntersectArc([p[1], p[0]], [p[2], p[3]]), 40 | [-7.081398732358556, 42.94731141237317], 41 | 1e-8 42 | ); 43 | 44 | assert(!geoIntersectArc([p[2], p[1]], [p[0], p[3]])); 45 | 46 | assertInDelta( 47 | geoIntersectArc( 48 | [ 49 | [0, 89.99], 50 | [0, -89.99], 51 | ], 52 | [ 53 | [-89.99, 0], 54 | [89.99, 0], 55 | ] 56 | ), 57 | [0, 0], 58 | 1e-8 59 | ); 60 | 61 | assertInDelta( 62 | geoIntersectArc( 63 | [ 64 | [0, 89.99], 65 | [0, -89.99], 66 | ], 67 | [ 68 | [0, 0], 69 | [25, 0], 70 | ] 71 | ), 72 | [0, 0], 73 | 1e-8 74 | ); 75 | 76 | e = geoIntersectArc( 77 | [ 78 | [0, 0], 79 | [0, 90], 80 | ], 81 | [ 82 | [0, 0], 83 | [90, 0], 84 | ] 85 | ); 86 | assert.deepStrictEqual(e, [0, 0]); 87 | 88 | assert( 89 | !geoIntersectArc( 90 | [ 91 | [0, 0], 92 | [0, 90], 93 | ], 94 | [ 95 | [10, 0], 96 | [90, 0], 97 | ] 98 | ) 99 | ); 100 | assert( 101 | !geoIntersectArc( 102 | [ 103 | [0, 90], 104 | [0, 0], 105 | ], 106 | [ 107 | [10, 0], 108 | [90, 0], 109 | ] 110 | ) 111 | ); 112 | assert( 113 | !geoIntersectArc( 114 | [ 115 | [0, 0], 116 | [0, 90], 117 | ], 118 | [ 119 | [90, 0], 120 | [10, 0], 121 | ] 122 | ) 123 | ); 124 | assert( 125 | !geoIntersectArc( 126 | [ 127 | [0, 90], 128 | [0, 0], 129 | ], 130 | [ 131 | [90, 0], 132 | [10, 0], 133 | ] 134 | ) 135 | ); 136 | }); 137 | -------------------------------------------------------------------------------- /src/clip/rejoin.js: -------------------------------------------------------------------------------- 1 | import pointEqual from "../pointEqual.js"; 2 | 3 | function Intersection(point, points, other, entry) { 4 | this.x = point; 5 | this.z = points; 6 | this.o = other; // another intersection 7 | this.e = entry; // is an entry? 8 | this.v = false; // visited 9 | this.n = this.p = null; // next & previous 10 | } 11 | 12 | // A generalized polygon clipping algorithm: given a polygon that has been cut 13 | // into its visible line segments, and rejoins the segments by interpolating 14 | // along the clip edge. 15 | export default function(segments, compareIntersection, startInside, interpolate, stream) { 16 | const subject = []; 17 | const clip = []; 18 | 19 | segments.forEach((segment) => { 20 | let n; 21 | if ((n = segment.length - 1) <= 0) return; 22 | let p0 = segment[0]; 23 | const p1 = segment[n]; 24 | 25 | // If the first and last points of a segment are coincident, then treat as a 26 | // closed ring. TODO if all rings are closed, then the winding order of the 27 | // exterior ring should be checked. 28 | if (pointEqual(p0, p1)) { 29 | stream.lineStart(); 30 | for (let i = 0; i < n; ++i) stream.point((p0 = segment[i])[0], p0[1]); 31 | stream.lineEnd(); 32 | return; 33 | } 34 | 35 | let x; 36 | subject.push(x = new Intersection(p0, segment, null, true)); 37 | clip.push(x.o = new Intersection(p0, null, x, false)); 38 | subject.push(x = new Intersection(p1, segment, null, false)); 39 | clip.push(x.o = new Intersection(p1, null, x, true)); 40 | }); 41 | 42 | if (!subject.length) return; 43 | 44 | clip.sort(compareIntersection); 45 | link(subject); 46 | link(clip); 47 | 48 | for (let i = 0, n = clip.length; i < n; ++i) { 49 | clip[i].e = startInside = !startInside; 50 | } 51 | 52 | let start = subject[0], 53 | points, 54 | point; 55 | 56 | // eslint-disable-next-line no-constant-condition 57 | while (1) { 58 | // Find first unvisited intersection. 59 | let current = start, 60 | isSubject = true; 61 | while (current.v) if ((current = current.n) === start) return; 62 | points = current.z; 63 | stream.lineStart(); 64 | do { 65 | current.v = current.o.v = true; 66 | if (current.e) { 67 | if (isSubject) { 68 | for (let i = 0, n = points.length; i < n; ++i) stream.point((point = points[i])[0], point[1]); 69 | } else { 70 | interpolate(current.x, current.n.x, 1, stream); 71 | } 72 | current = current.n; 73 | } else { 74 | if (isSubject) { 75 | points = current.p.z; 76 | for (let i = points.length - 1; i >= 0; --i) stream.point((point = points[i])[0], point[1]); 77 | } else { 78 | interpolate(current.x, current.p.x, -1, stream); 79 | } 80 | current = current.p; 81 | } 82 | current = current.o; 83 | points = current.z; 84 | isSubject = !isSubject; 85 | } while (!current.v); 86 | stream.lineEnd(); 87 | } 88 | } 89 | 90 | function link(array) { 91 | const n = array.length; 92 | if (!n) return; 93 | let i = 0, 94 | a = array[0], 95 | b; 96 | while (++i < n) { 97 | a.n = b = array[i]; 98 | b.p = a; 99 | a = b; 100 | } 101 | a.n = b = array[0]; 102 | b.p = a; 103 | } 104 | -------------------------------------------------------------------------------- /src/polyhedral/voronoi.js: -------------------------------------------------------------------------------- 1 | import { 2 | geoCentroid as centroid, 3 | geoGnomonic as gnomonic, 4 | geoDistance 5 | } from "d3-geo"; 6 | import { degrees } from "../math.js"; 7 | import polyhedral from "./index.js"; 8 | 9 | // it is possible to pass a specific projection on each face 10 | // by default is is a gnomonic projection centered on the face's centroid 11 | // scale 1 by convention 12 | const faceProjection0 = (face) => gnomonic() 13 | .scale(1) 14 | .translate([0, 0]) 15 | .rotate([ 16 | Math.abs(face.site[1]) > 89.99999999 ? 0 : -face.site[0], 17 | -face.site[1] 18 | ]); 19 | 20 | export default function( 21 | parents = [], 22 | polygons = { features: [] }, 23 | faceProjection = faceProjection0, 24 | find 25 | ) { 26 | if (find === undefined) find = find0; 27 | let faces = []; 28 | function build_tree() { 29 | // the faces from the polyhedron each yield 30 | // - face: its vertices 31 | // - site: its voronoi site (default: centroid) 32 | // - project: local projection on this face 33 | faces = polygons.features.map((feature, i) => { 34 | const polygon = feature.geometry.coordinates[0]; 35 | const face = polygon.slice(0, -1); 36 | face.site = 37 | feature.properties && feature.properties.sitecoordinates 38 | ? feature.properties.sitecoordinates 39 | : centroid(feature.geometry); 40 | return { 41 | face, 42 | site: face.site, 43 | id: i, 44 | project: faceProjection(face) 45 | }; 46 | }); 47 | 48 | // Build a tree of the faces, starting with face 0 (North Pole) 49 | // which has no parent (-1) 50 | parents.forEach((d, i) => { 51 | const node = faces[d]; 52 | node && (node.children || (node.children = [])).push(faces[i]); 53 | }); 54 | } 55 | 56 | // a basic function to find the polygon that contains the point 57 | function find0(lambda, phi) { 58 | let d0 = Infinity; 59 | let found = -1; 60 | for (let i = 0; i < faces.length; i++) { 61 | const d = geoDistance(faces[i].site, [lambda, phi]); 62 | if (d < d0) { 63 | d0 = d; 64 | found = i; 65 | } 66 | } 67 | return found; 68 | } 69 | 70 | function faceFind(lambda, phi) { 71 | return faces[find(lambda * degrees, phi * degrees)]; 72 | } 73 | 74 | let p = gnomonic(); 75 | 76 | function reset() { 77 | let rotate = p.rotate(), 78 | translate = p.translate(), 79 | center = p.center(), 80 | scale = p.scale(), 81 | angle = p.angle(); 82 | 83 | if (faces.length) { 84 | p = polyhedral(faces.find((face, i) => face && !faces[parents[i]]), faceFind); 85 | } 86 | 87 | p.parents = function(_) { 88 | if (!arguments.length) return parents; 89 | parents = _; 90 | build_tree(); 91 | return reset(); 92 | }; 93 | 94 | p.polygons = function(_) { 95 | if (!arguments.length) return polygons; 96 | polygons = _; 97 | build_tree(); 98 | return reset(); 99 | }; 100 | 101 | p.faceProjection = function(_) { 102 | if (!arguments.length) return faceProjection; 103 | faceProjection = _; 104 | build_tree(); 105 | return reset(); 106 | }; 107 | 108 | p.faceFind = function(_) { 109 | if (!arguments.length) return find; 110 | find = _; 111 | return reset(); 112 | }; 113 | 114 | return p 115 | .rotate(rotate) 116 | .translate(translate) 117 | .center(center) 118 | .scale(scale) 119 | .angle(angle); 120 | } 121 | 122 | build_tree(); 123 | return reset(); 124 | } 125 | -------------------------------------------------------------------------------- /src/deltoidal.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Deltoidal Hexecontahedron map 3 | * 4 | * Implemented for D3.js by Ronnie Bathoorn (2024), 5 | * based on Icosahedron map by Jason Davies (2013) 6 | * Enrico Spinielli (2017) and Philippe Rivière (2017, 2018) 7 | * 8 | */ 9 | import { atan, degrees } from "./math.js"; 10 | import voronoi from "./polyhedral/voronoi.js"; 11 | import { geoCentroid, geoInterpolate } from "d3-geo"; 12 | 13 | export default function () { 14 | const theta = atan(0.5) * degrees; 15 | 16 | // construction inspired by 17 | // https://en.wikipedia.org/wiki/Regular_icosahedron#Spherical_coordinates 18 | const vertices = [[0, 90], [0, -90]].concat( 19 | [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map((i) => [ 20 | ((i * 36 + 180) % 360) - 180, 21 | i & 1 ? theta : -theta 22 | ]) 23 | ); 24 | 25 | // icosahedron 26 | const polyhedron = [ 27 | [0, 3, 11], 28 | [0, 5, 3], 29 | [0, 7, 5], 30 | [0, 9, 7], 31 | [0, 11, 9], // North 32 | [2, 11, 3], 33 | [3, 4, 2], 34 | [4, 3, 5], 35 | [5, 6, 4], 36 | [6, 5, 7], 37 | [7, 8, 6], 38 | [8, 7, 9], 39 | [9, 10, 8], 40 | [10, 9, 11], 41 | [11, 2, 10], // Equator 42 | [1, 2, 4], 43 | [1, 4, 6], 44 | [1, 6, 8], 45 | [1, 8, 10], 46 | [1, 10, 2], // South 47 | ].map((face) => { 48 | const t = face.map((i) => vertices[i]); 49 | // create 3 polygons from these using centroid and midpoints 50 | const a0 = geoInterpolate(t[1], t[2])(0.5); 51 | const a1 = geoInterpolate(t[0], t[2])(0.5); 52 | const a2 = geoInterpolate(t[0], t[1])(0.5); 53 | const c = geoCentroid({ type: "MultiPoint", coordinates: t }); 54 | return [ 55 | [t[0], a2, c, a1], 56 | [t[1], a0, c, a2], 57 | [t[2], a1, c, a0] 58 | ]; 59 | }); 60 | 61 | const polygons = { 62 | type: "FeatureCollection", 63 | features: polyhedron.flat().map((face) => ({ 64 | type: "Feature", 65 | properties: { 66 | sitecoordinates: geoCentroid({ 67 | type: "MultiPoint", 68 | coordinates: face, 69 | }), 70 | }, 71 | geometry: { 72 | type: "Polygon", 73 | coordinates: [[...face, face[0]]], 74 | }, 75 | })) 76 | }; 77 | 78 | const parents = [ 79 | -1, // 0 80 | 2, // 1 81 | 0, // 2 82 | 5, // 3 83 | 5, // 4 84 | 22, // 5 85 | 8, // 6 86 | 8, // 7 87 | 28, // 8 88 | 11, // 9 89 | 11, // 10 90 | 34, // 11 91 | 14, // 12 92 | 14, // 13 93 | 40, // 14 94 | 16, // 15 95 | 2, // 16 96 | 16, // 17 97 | 17, // 18 98 | 18, // 19 99 | 18, // 20 100 | 19, // 21 101 | 21, // 22 102 | 21, // 23 103 | 23, // 24 104 | 24, // 25 105 | 24, // 26 106 | 25, // 27 107 | 27, // 28 108 | 27, // 29 109 | 29, // 30 110 | 30, // 31 111 | 30, // 32 112 | 31, // 33 113 | 33, // 34 114 | 33, // 35 115 | 35, // 36 116 | 36, // 37 117 | 36, // 38 118 | 37, // 39 119 | 39, // 40 120 | 39, // 41 121 | 41, // 42 122 | 42, // 43 123 | 42, // 44 124 | 46, // 45 125 | 20, // 46 126 | 46, // 47 127 | 49, // 48 128 | 26, // 49 129 | 49, // 50 130 | 52, // 51 131 | 32, // 52 132 | 52, // 53 133 | 55, // 54 134 | 38, // 55 135 | 55, // 56 136 | 58, // 57 137 | 44, // 58 138 | 58, // 59 139 | ]; 140 | 141 | //return polygons; 142 | return voronoi() 143 | .parents(parents) 144 | .polygons(polygons) 145 | .angle(3) 146 | .rotate([108, 0]) 147 | .translate([72, 252]) 148 | .scale(136.67); 149 | } 150 | -------------------------------------------------------------------------------- /src/waterman.js: -------------------------------------------------------------------------------- 1 | import {geoCentroid as centroid, geoGnomonic as gnomonic} from "d3-geo"; 2 | import {asin, atan2, cos, degrees, max, min, pi, radians, sin} from "./math.js"; 3 | import polyhedral from "./polyhedral/index.js"; 4 | import octahedron from "./polyhedral/octahedron.js"; 5 | 6 | export default function(faceProjection = ((face) => { 7 | const c = face.length === 6 ? centroid({type: "MultiPoint", coordinates: face}) : face[0]; 8 | return gnomonic().scale(1).translate([0, 0]).rotate([-c[0], -c[1]]); 9 | })) { 10 | const w5 = octahedron.map((face) => { 11 | const xyz = face.map(cartesian); 12 | const n = xyz.length; 13 | const hexagon = []; 14 | let a = xyz[n - 1], b; 15 | for (let i = 0; i < n; ++i) { 16 | b = xyz[i]; 17 | hexagon.push(spherical([ 18 | a[0] * 0.9486832980505138 + b[0] * 0.31622776601683794, 19 | a[1] * 0.9486832980505138 + b[1] * 0.31622776601683794, 20 | a[2] * 0.9486832980505138 + b[2] * 0.31622776601683794 21 | ]), spherical([ 22 | b[0] * 0.9486832980505138 + a[0] * 0.31622776601683794, 23 | b[1] * 0.9486832980505138 + a[1] * 0.31622776601683794, 24 | b[2] * 0.9486832980505138 + a[2] * 0.31622776601683794 25 | ])); 26 | a = b; 27 | } 28 | return hexagon; 29 | }); 30 | 31 | const cornerNormals = []; 32 | 33 | const parents = [-1, 0, 0, 1, 0, 1, 4, 5]; 34 | 35 | w5.forEach((hexagon, j) => { 36 | const face = octahedron[j], 37 | n = face.length, 38 | normals = cornerNormals[j] = []; 39 | for (let i = 0; i < n; ++i) { 40 | w5.push([ 41 | face[i], 42 | hexagon[(i * 2 + 2) % (2 * n)], 43 | hexagon[(i * 2 + 1) % (2 * n)] 44 | ]); 45 | parents.push(j); 46 | normals.push(cross( 47 | cartesian(hexagon[(i * 2 + 2) % (2 * n)]), 48 | cartesian(hexagon[(i * 2 + 1) % (2 * n)]) 49 | )); 50 | } 51 | }); 52 | 53 | const faces = w5.map((face) => ({ 54 | project: faceProjection(face), 55 | face 56 | })); 57 | 58 | parents.forEach((d, i) => { 59 | const parent = faces[d]; 60 | parent && (parent.children || (parent.children = [])).push(faces[i]); 61 | }); 62 | 63 | function face(lambda, phi) { 64 | const cosphi = cos(phi); 65 | const p = [cosphi * cos(lambda), cosphi * sin(lambda), sin(phi)]; 66 | 67 | const hexagon = lambda < -pi / 2 ? phi < 0 ? 6 : 4 68 | : lambda < 0 ? phi < 0 ? 2 : 0 69 | : lambda < pi / 2 ? phi < 0 ? 3 : 1 70 | : phi < 0 ? 7 : 5; 71 | 72 | const n = cornerNormals[hexagon]; 73 | 74 | return faces[dot(n[0], p) < 0 ? 8 + 3 * hexagon 75 | : dot(n[1], p) < 0 ? 8 + 3 * hexagon + 1 76 | : dot(n[2], p) < 0 ? 8 + 3 * hexagon + 2 77 | : hexagon]; 78 | } 79 | 80 | return polyhedral(faces[0], face) 81 | .angle(-30) 82 | .scale(110.625) 83 | .center([0, 45]); 84 | } 85 | 86 | function dot(a, b) { 87 | let s = 0; 88 | for (let i = 0; i < a.length; ++i) s += a[i] * b[i]; 89 | return s; 90 | } 91 | 92 | function cross(a, b) { 93 | return [ 94 | a[1] * b[2] - a[2] * b[1], 95 | a[2] * b[0] - a[0] * b[2], 96 | a[0] * b[1] - a[1] * b[0] 97 | ]; 98 | } 99 | 100 | // Converts 3D Cartesian to spherical coordinates (degrees). 101 | function spherical(cartesian) { 102 | return [ 103 | atan2(cartesian[1], cartesian[0]) * degrees, 104 | asin(max(-1, min(1, cartesian[2]))) * degrees 105 | ]; 106 | } 107 | 108 | // Converts spherical coordinates (degrees) to 3D Cartesian. 109 | function cartesian(coordinates) { 110 | const lambda = coordinates[0] * radians; 111 | const phi = coordinates[1] * radians; 112 | const cosphi = cos(phi); 113 | return [ 114 | cosphi * cos(lambda), 115 | cosphi * sin(lambda), 116 | sin(phi) 117 | ]; 118 | } 119 | -------------------------------------------------------------------------------- /src/cox.js: -------------------------------------------------------------------------------- 1 | import { geoProjection as projection, geoStream } from "d3-geo"; 2 | import { scan } from "d3-array"; 3 | import { asin, degrees, epsilon, sqrt } from "./math.js"; 4 | import { lagrangeRaw } from "./lagrange.js"; 5 | import { complexAdd, complexMul, complexNorm2, complexPow } from "./complex.js"; 6 | 7 | // w1 = gamma(1/n) * gamma(1 - 2/n) / n / gamma(1 - 1/n) 8 | // https://blocks.roadtolarissa.com/Fil/852557838117687bbd985e4b38ff77d4 9 | const w = [-1 / 2, sqrt(3) / 2], 10 | w1 = [1.7666387502854533, 0], 11 | m = 0.3 * 0.3; 12 | 13 | // Approximate \int _0 ^sm(z) dt / (1 - t^3)^(2/3) 14 | // sm maps a triangle to a disc, sm^-1 does the opposite 15 | function sm_1(z) { 16 | // rotate to have s ~= 1 17 | const rot = complexPow( 18 | w, 19 | scan( 20 | [0, 1, 2].map(function(i) { 21 | return -complexMul(z, complexPow(w, [i, 0]))[0]; 22 | }) 23 | ) 24 | ); 25 | 26 | let y = complexMul(rot, z); 27 | y = [1 - y[0], -y[1]]; 28 | 29 | // McIlroy formula 5 p6 and table for F3 page 16 30 | const F0 = [ 31 | 1.44224957030741, 32 | 0.240374928384568, 33 | 0.0686785509670194, 34 | 0.0178055502507087, 35 | 0.00228276285265497, 36 | -1.48379585422573e-3, 37 | -1.64287728109203e-3, 38 | -1.02583417082273e-3, 39 | -4.83607537673571e-4, 40 | -1.67030822094781e-4, 41 | -2.45024395166263e-5, 42 | 2.14092375450951e-5, 43 | 2.55897270486771e-5, 44 | 1.73086854400834e-5, 45 | 8.72756299984649e-6, 46 | 3.18304486798473e-6, 47 | 4.79323894565283e-7, 48 | -4.58968389565456e-7, 49 | -5.62970586787826e-7, 50 | -3.92135372833465e-7 51 | ]; 52 | 53 | let F = [0, 0]; 54 | for (let i = F0.length; i--; ) F = complexAdd([F0[i], 0], complexMul(F, y)); 55 | 56 | let k = complexMul( 57 | complexAdd(w1, complexMul([-F[0], -F[1]], complexPow(y, 1 - 2 / 3))), 58 | complexMul(rot, rot) 59 | ); 60 | 61 | // when we are close to [0,0] we switch to another approximation: 62 | // https://www.wolframalpha.com/input/?i=(-2%2F3+choose+k)++*+(-1)%5Ek++%2F+(k%2B1)+with+k%3D0,1,2,3,4 63 | // the difference is _very_ tiny but necessary 64 | // if we want projection(0,0) === [0,0] 65 | const n = complexNorm2(z); 66 | if (n < m) { 67 | const H0 = [ 68 | 1, 69 | 1 / 3, 70 | 5 / 27, 71 | 10 / 81, 72 | 22 / 243 //… 73 | ]; 74 | const z3 = complexPow(z, [3, 0]); 75 | let h = [0, 0]; 76 | for (let i = H0.length; i--; ) h = complexAdd([H0[i], 0], complexMul(h, z3)); 77 | h = complexMul(h, z); 78 | k = complexAdd(complexMul(k, [n / m, 0]), complexMul(h, [1 - n / m, 0])); 79 | } 80 | 81 | return k; 82 | } 83 | 84 | const lagrange1_2 = lagrangeRaw ? lagrangeRaw(0.5) : null; 85 | export function coxRaw(lambda, phi) { 86 | const s = lagrange1_2(lambda, phi); 87 | const t = sm_1([s[1] / 2, s[0] / 2]); 88 | return [t[1], t[0]]; 89 | } 90 | 91 | // the Sphere should go *exactly* to the vertices of the triangles 92 | // because they are singular points 93 | function sphere() { 94 | const c = 2 * asin(1 / sqrt(5)) * degrees; 95 | return { 96 | type: "Polygon", 97 | coordinates: [ 98 | [[0, 90], [-180, -c + epsilon], [0, -90], [180, -c + epsilon], [0, 90]] 99 | ] 100 | }; 101 | } 102 | 103 | export default function() { 104 | const p = projection(coxRaw); 105 | 106 | const stream_ = p.stream; 107 | p.stream = function(stream) { 108 | const rotate = p.rotate(), 109 | rotateStream = stream_(stream), 110 | sphereStream = (p.rotate([0, 0]), stream_(stream)); 111 | p.rotate(rotate); 112 | rotateStream.sphere = function() { 113 | geoStream(sphere(), sphereStream); 114 | }; 115 | return rotateStream; 116 | }; 117 | 118 | return p 119 | .scale(188.305) 120 | .translate([480, 333.167]); 121 | } 122 | -------------------------------------------------------------------------------- /src/clip/index.js: -------------------------------------------------------------------------------- 1 | import clipBuffer from "./buffer.js"; 2 | import clipRejoin from "./rejoin.js"; 3 | import {epsilon, halfPi} from "../math.js"; 4 | import polygonContains from "../polygonContains.js"; 5 | import {merge} from "d3-array"; 6 | 7 | export default function(pointVisible, clipLine, interpolate, start, sort, {clipPoint = false} = {}) { 8 | if (typeof sort === "undefined") sort = compareIntersection; 9 | 10 | return function(sink) { 11 | const line = clipLine(sink); 12 | const ringBuffer = clipBuffer(); 13 | const ringSink = clipLine(ringBuffer); 14 | let polygonStarted = false, 15 | polygon, 16 | segments, 17 | ring; 18 | 19 | const clip = { 20 | point, 21 | lineStart, 22 | lineEnd, 23 | polygonStart: function() { 24 | clip.point = pointRing; 25 | clip.lineStart = ringStart; 26 | clip.lineEnd = ringEnd; 27 | segments = []; 28 | polygon = []; 29 | }, 30 | polygonEnd: function() { 31 | clip.point = point; 32 | clip.lineStart = lineStart; 33 | clip.lineEnd = lineEnd; 34 | segments = merge(segments); 35 | const startInside = polygonContains(polygon, start); 36 | if (segments.length) { 37 | if (!polygonStarted) sink.polygonStart(), polygonStarted = true; 38 | clipRejoin(segments, sort, startInside, interpolate, sink); 39 | } else if (startInside) { 40 | if (!polygonStarted) sink.polygonStart(), polygonStarted = true; 41 | interpolate(null, null, 1, sink); 42 | } 43 | if (polygonStarted) sink.polygonEnd(), polygonStarted = false; 44 | segments = polygon = null; 45 | }, 46 | sphere: () => interpolate(null, null, 1, sink) 47 | }; 48 | 49 | function point(lambda, phi) { 50 | if ((!clipPoint && !ring) || pointVisible(lambda, phi)) sink.point(lambda, phi); 51 | } 52 | 53 | function pointLine(lambda, phi) { 54 | line.point(lambda, phi); 55 | } 56 | 57 | function lineStart() { 58 | clip.point = pointLine; 59 | line.lineStart(); 60 | } 61 | 62 | function lineEnd() { 63 | clip.point = point; 64 | line.lineEnd(); 65 | } 66 | 67 | function pointRing(lambda, phi, close) { 68 | ring.push([lambda, phi]); 69 | ringSink.point(lambda, phi, close); 70 | } 71 | 72 | function ringStart() { 73 | ringSink.lineStart(); 74 | ring = []; 75 | } 76 | 77 | function ringEnd() { 78 | pointRing(ring[0][0], ring[0][1], true); 79 | ringSink.lineEnd(); 80 | 81 | const clean = ringSink.clean(); 82 | const ringSegments = ringBuffer.result(); 83 | const n = ringSegments.length; 84 | let m, segment, point; 85 | 86 | ring.pop(); 87 | polygon.push(ring); 88 | ring = null; 89 | 90 | if (!n) return; 91 | 92 | // No intersections. 93 | if (clean & 1) { 94 | segment = ringSegments[0]; 95 | if ((m = segment.length - 1) > 0) { 96 | if (!polygonStarted) sink.polygonStart(), polygonStarted = true; 97 | sink.lineStart(); 98 | for (let i = 0; i < m; ++i) sink.point((point = segment[i])[0], point[1]); 99 | sink.lineEnd(); 100 | } 101 | return; 102 | } 103 | 104 | // Rejoin connected segments. 105 | // TODO reuse ringBuffer.rejoin()? 106 | if (n > 1 && clean & 2) ringSegments.push(ringSegments.pop().concat(ringSegments.shift())); 107 | 108 | segments.push(ringSegments.filter(validSegment)); 109 | } 110 | 111 | return clip; 112 | }; 113 | } 114 | 115 | function validSegment(segment) { 116 | return segment.length > 1; 117 | } 118 | 119 | // Intersections are sorted along the clip edge. For both antimeridian cutting 120 | // and circle clipping, the same comparison is used. 121 | function compareIntersection(a, b) { 122 | return ((a = a.x)[0] < 0 ? a[1] - halfPi - epsilon : halfPi - a[1]) 123 | - ((b = b.x)[0] < 0 ? b[1] - halfPi - epsilon : halfPi - b[1]); 124 | } 125 | -------------------------------------------------------------------------------- /src/tetrahedralLee.js: -------------------------------------------------------------------------------- 1 | import { 2 | geoProjection as projection, 3 | geoStereographicRaw, 4 | geoCentroid 5 | } from "d3-geo"; 6 | import { greatest } from "d3-array"; 7 | import { abs, asin, degrees, sqrt } from "./math.js"; 8 | import { 9 | complexAdd, 10 | complexMul, 11 | complexNorm, 12 | complexPow, 13 | complexSub, 14 | } from "./complex.js"; 15 | import { solve2d } from "./newton.js"; 16 | import voronoi from "./polyhedral/voronoi.js"; 17 | 18 | export function leeRaw(lambda, phi) { 19 | const w = [-1 / 2, sqrt(3) / 2]; 20 | let k = [0, 0], 21 | h = [0, 0], 22 | z = complexMul(geoStereographicRaw(lambda, phi), [sqrt(2), 0]); 23 | 24 | // rotate to have s ~= 1 25 | const sector = greatest([0, 1, 2], (i) => complexMul(z, complexPow(w, [i, 0]))[0]); 26 | const rot = complexPow(w, [sector, 0]); 27 | 28 | const n = complexNorm(z); 29 | 30 | if (n > 0.3) { 31 | // if |z| > 0.5, use the approx based on y = (1-z) 32 | // McIlroy formula 6 p6 and table for G page 16 33 | const y = complexSub([1, 0], complexMul(rot, z)); 34 | 35 | // w1 = gamma(1/3) * gamma(1/2) / 3 / gamma(5/6); 36 | // https://bl.ocks.org/Fil/1aeff1cfda7188e9fbf037d8e466c95c 37 | const w1 = 1.4021821053254548; 38 | 39 | const G0 = [ 40 | 1.15470053837925, 0.192450089729875, 0.0481125224324687, 41 | 0.010309826235529, 3.34114739114366e-4, -1.50351632601465e-3, 42 | -1.2304417796231e-3, -6.75190201960282e-4, -2.84084537293856e-4, 43 | -8.21205120500051e-5, -1.59257630018706e-6, 1.91691805888369e-5, 44 | 1.73095888028726e-5, 1.03865580818367e-5, 4.70614523937179e-6, 45 | 1.4413500104181e-6, 1.92757960170179e-8, -3.82869799649063e-7, 46 | -3.57526015225576e-7, -2.2175964844211e-7, 47 | ]; 48 | 49 | let G = [0, 0]; 50 | for (let i = G0.length; i--; ) { 51 | G = complexAdd([G0[i], 0], complexMul(G, y)); 52 | } 53 | 54 | k = complexSub([w1, 0], complexMul(complexPow(y, 1 / 2), G)); 55 | k = complexMul(k, rot); 56 | k = complexMul(k, rot); 57 | } 58 | 59 | if (n < 0.5) { 60 | // if |z| < 0.3 61 | // https://www.wolframalpha.com/input/?i=series+of+((1-z%5E3))+%5E+(-1%2F2)+at+z%3D0 (and ask for "more terms") 62 | // 1 + z^3/2 + (3 z^6)/8 + (5 z^9)/16 + (35 z^12)/128 + (63 z^15)/256 + (231 z^18)/1024 + O(z^21) 63 | // https://www.wolframalpha.com/input/?i=integral+of+1+%2B+z%5E3%2F2+%2B+(3+z%5E6)%2F8+%2B+(5+z%5E9)%2F16+%2B+(35+z%5E12)%2F128+%2B+(63+z%5E15)%2F256+%2B+(231+z%5E18)%2F1024 64 | // (231 z^19)/19456 + (63 z^16)/4096 + (35 z^13)/1664 + z^10/32 + (3 z^7)/56 + z^4/8 + z + constant 65 | const H0 = [1, 1 / 8, 3 / 56, 1 / 32, 35 / 1664, 63 / 4096, 231 / 19456]; 66 | const z3 = complexPow(z, [3, 0]); 67 | for (let i = H0.length; i--; ) h = complexAdd([H0[i], 0], complexMul(h, z3)); 68 | h = complexMul(h, z); 69 | } 70 | 71 | if (n < 0.3) return h; 72 | if (n > 0.5) return k; 73 | 74 | // in between 0.3 and 0.5, interpolate 75 | const t = (n - 0.3) / (0.5 - 0.3); 76 | return complexAdd(complexMul(k, [t, 0]), complexMul(h, [1 - t, 0])); 77 | } 78 | 79 | const leeSolver = solve2d(leeRaw); 80 | leeRaw.invert = function (x, y) { 81 | if (x > 1.5) return false; // immediately avoid using the wrong face 82 | const p = leeSolver(x, y, x, y * 0.5); 83 | const q = leeRaw(p[0], p[1]); 84 | q[0] -= x; 85 | q[1] -= y; 86 | return (q[0] * q[0] + q[1] * q[1] < 1e-8) 87 | ? p 88 | : [-10, 0]; // far out of the face 89 | }; 90 | 91 | const asin1_3 = asin(1 / 3); 92 | const centers = [ 93 | [0, 90], 94 | [-180, -asin1_3 * degrees], 95 | [-60, -asin1_3 * degrees], 96 | [60, -asin1_3 * degrees], 97 | ]; 98 | const tetrahedron = [ 99 | [1, 2, 3], 100 | [0, 2, 1], 101 | [0, 3, 2], 102 | [0, 1, 3], 103 | ].map((face) => face.map((i) => centers[i])); 104 | 105 | export default function ( 106 | faceProjection = (face) => { 107 | const c = geoCentroid({ type: "MultiPoint", coordinates: face }); 108 | const rotate = abs(c[1]) == 90 ? [0, -c[1], -30] : [-c[0], -c[1], 30]; 109 | return projection(leeRaw).scale(1).translate([0, 0]).rotate(rotate); 110 | } 111 | ) { 112 | return voronoi([-1, 0, 0, 0], { 113 | features: tetrahedron.map((t) => ({ 114 | type: "Feature", 115 | geometry: {type: "Polygon", coordinates: [[...t, t[0]]]} 116 | })) 117 | }, faceProjection) 118 | .rotate([30, 180]) // North Pole aspect 119 | .angle(30) 120 | .scale(118.662) 121 | .translate([480, 195.47]); 122 | } 123 | -------------------------------------------------------------------------------- /src/reclip.js: -------------------------------------------------------------------------------- 1 | import {merge} from "d3-array"; 2 | import {geoDistance, geoInterpolate} from "d3-geo"; 3 | import { 4 | geoBerghaus as berghaus, 5 | geoGingery as gingery, 6 | geoHealpix as healpix, 7 | geoInterrupt as interrupt, 8 | geoInterruptedBoggs as interruptedBoggs, 9 | geoInterruptedHomolosine as interruptedHomolosine, 10 | geoInterruptedMollweide as interruptedMollweide, 11 | geoInterruptedMollweideHemispheres as interruptedMollweideHemispheres, 12 | geoInterruptedSinuMollweide as interruptedSinuMollweide, 13 | geoInterruptedSinusoidal as interruptedSinusoidal, 14 | geoTwoPointEquidistant as twoPointEquidistant 15 | } from "d3-geo-projection"; 16 | import geoClipPolygon from "./clip/polygon.js"; 17 | 18 | /** 19 | * Reclip projections from d3-geo-projection 20 | */ 21 | export function geoBerghaus() { return reclip(berghaus.apply(this, arguments)); } 22 | export function geoGingery() { return reclip(gingery.apply(this, arguments)); } 23 | export function geoHealpix() { return reclip(healpix.apply(this, arguments), true); } 24 | export function geoInterrupt() { return clipInterrupted(interrupt.apply(this, arguments)); } 25 | export function geoInterruptedBoggs() { return clipInterrupted(interruptedBoggs.apply(this, arguments)); } 26 | export function geoInterruptedHomolosine() { return clipInterrupted(interruptedHomolosine.apply(this, arguments)); } 27 | export function geoInterruptedMollweide() { return clipInterrupted(interruptedMollweide.apply(this, arguments)); } 28 | export function geoInterruptedMollweideHemispheres() { return clipInterrupted(interruptedMollweideHemispheres.apply(this, arguments)); } 29 | export function geoInterruptedSinuMollweide() { return clipInterrupted(interruptedSinuMollweide.apply(this, arguments)); } 30 | export function geoInterruptedSinusoidal() { return clipInterrupted(interruptedSinusoidal.apply(this, arguments)); } 31 | export function geoTwoPointEquidistant() { return clipTwoPointEquidistant.apply(this, arguments); } 32 | export function geoTwoPointEquidistantUsa() { return geoTwoPointEquidistant([-158, 21.5], [-77, 39]); } 33 | 34 | function reclip(projection, vertical = false) { 35 | const {lobes} = projection; 36 | function reset(projection) { 37 | const rotate = projection.rotate(); 38 | const scale = projection.scale(); 39 | const translate = projection.translate(); 40 | projection.rotate([0, 0]).translate([0, 0]); 41 | projection.lobes = function (_) { 42 | return !arguments.length ? lobes() : reset(lobes(_)); 43 | }; 44 | 45 | projection.preclip((stream) => stream); // clipNone 46 | const R = 1 - 1e-7; 47 | const Rx = vertical ? 1 : R; 48 | let points = []; 49 | projection 50 | .stream({ 51 | point(x, y) { 52 | points.push([x * Rx, y * R]); 53 | }, 54 | lineStart() {}, 55 | lineEnd() {}, 56 | polygonStart() {}, 57 | polygonEnd() {}, 58 | sphere() {}, 59 | }) 60 | .sphere(); 61 | 62 | projection.scale(scale); 63 | points = points.map(projection.invert); 64 | points.push(points[0]); 65 | 66 | return projection 67 | .rotate(rotate) 68 | .translate(translate) 69 | .preclip(geoClipPolygon({ type: "Polygon", coordinates: [points] })); 70 | } 71 | return reset(projection); 72 | } 73 | 74 | function clipInterrupted(projection) { 75 | const { lobes } = projection; 76 | function reset(projection) { 77 | const l = lobes?.(); 78 | const polygon = merge( 79 | Array.from(l, (d, i) => { 80 | const hemisphere = d.flatMap( 81 | (q) => Array.from(q, (p) => geoInterpolate(p, [q[1][0], 0])(1e-9)) // pull inside each lobe 82 | ); 83 | return i === 0 84 | ? hemisphere // north 85 | : [...hemisphere].reverse(); 86 | }) 87 | ); 88 | 89 | projection.lobes = function (_) { 90 | return !arguments.length ? lobes() : reset(lobes(_)); 91 | }; 92 | 93 | return projection.preclip( 94 | geoClipPolygon({ 95 | type: "Polygon", 96 | coordinates: [[...polygon, polygon[0]]], 97 | }) 98 | ); 99 | } 100 | 101 | return reset(projection); 102 | } 103 | 104 | function clipTwoPointEquidistant(a, b) { 105 | const epsilon = 1e-3; 106 | const u = geoDistance(a, b) * 90 / Math.PI + epsilon; 107 | const ellipse = { 108 | type: "Polygon", 109 | coordinates: [[ 110 | [180 - u, epsilon], 111 | [180 - u, -epsilon], 112 | [-180 + u, -epsilon], 113 | [-180 + u, epsilon], 114 | [180 - u, epsilon] 115 | ] 116 | ] 117 | }; 118 | return twoPointEquidistant(a, b).preclip(geoClipPolygon(ellipse).clipPoint(false)); 119 | } 120 | -------------------------------------------------------------------------------- /src/polyhedral/index.js: -------------------------------------------------------------------------------- 1 | import {geoArea, geoBounds as bounds, geoCentroid as centroid, geoInterpolate as interpolate, geoProjection as projection} from "d3-geo"; 2 | import clipPolygon from "../clip/polygon.js"; 3 | import {abs, degrees, epsilon, radians} from "../math.js"; 4 | import matrix, {multiply, inverse} from "./matrix.js"; 5 | import pointEqual from "../pointEqual.js"; 6 | 7 | // Creates a polyhedral projection. 8 | // * tree: a spanning tree of polygon faces. Nodes are automatically 9 | // augmented with a transform matrix. 10 | // * face: a function that returns the appropriate node for a given {lambda, phi} 11 | // point (radians). 12 | export default function(tree, face) { 13 | 14 | recurse(tree, {transform: null}); 15 | 16 | function recurse(node, parent) { 17 | node.edges = faceEdges(node.face); 18 | // Find shared edge. 19 | if (parent.face) { 20 | const shared = node.shared = sharedEdge(node.face, parent.face); 21 | const m = matrix(shared.map(parent.project), shared.map(node.project)); 22 | node.transform = parent.transform ? multiply(parent.transform, m) : m; 23 | // Replace shared edge in parent edges array. 24 | let edges = parent.edges; 25 | for (let i = 0, n = edges.length; i < n; ++i) { 26 | if (pointEqual(shared[0], edges[i][1]) && pointEqual(shared[1], edges[i][0])) edges[i] = node; 27 | if (pointEqual(shared[0], edges[i][0]) && pointEqual(shared[1], edges[i][1])) edges[i] = node; 28 | } 29 | edges = node.edges; 30 | for (let i = 0, n = edges.length; i < n; ++i) { 31 | if (pointEqual(shared[0], edges[i][0]) && pointEqual(shared[1], edges[i][1])) edges[i] = parent; 32 | if (pointEqual(shared[0], edges[i][1]) && pointEqual(shared[1], edges[i][0])) edges[i] = parent; 33 | } 34 | } else { 35 | node.transform = parent.transform; 36 | } 37 | if (node.children) node.children.forEach((child) => recurse(child, node)); 38 | return node; 39 | } 40 | 41 | function forward(lambda, phi) { 42 | const node = face(lambda, phi); 43 | const point = node.project([lambda * degrees, phi * degrees]); 44 | const t = node.transform; 45 | return t 46 | ? [t[0] * point[0] + t[1] * point[1] + t[2], -(t[3] * point[0] + t[4] * point[1] + t[5])] 47 | : [point[0], -point[1]]; 48 | } 49 | 50 | // Naive inverse! A faster solution would use bounding boxes, or even a 51 | // polygonal quadtree. 52 | if (hasInverse(tree)) forward.invert = function(x, y) { 53 | const coordinates = faceInvert(tree, [x, -y]); 54 | return coordinates && (coordinates[0] *= radians, coordinates[1] *= radians, coordinates); 55 | }; 56 | 57 | function faceInvert(node, coordinates) { 58 | const invert = node.project.invert; 59 | let point = coordinates; 60 | let p; 61 | let t = node.transform; 62 | if (t) { 63 | t = inverse(t); 64 | point = [t[0] * point[0] + t[1] * point[1] + t[2], (t[3] * point[0] + t[4] * point[1] + t[5])]; 65 | } 66 | if (invert && node === faceDegrees(p = invert(point))) return p; 67 | const children = node.children; 68 | for (let i = 0, n = children && children.length; i < n; ++i) { 69 | p = faceInvert(children[i], coordinates); 70 | if (p) return p; 71 | } 72 | } 73 | 74 | function faceDegrees(coordinates) { 75 | return face(coordinates[0] * radians, coordinates[1] * radians); 76 | } 77 | 78 | const proj = projection(forward); 79 | 80 | // run around the mesh of faces and stream all vertices to create the clipping polygon 81 | const p = []; 82 | const geometry = {type: "MultiPolygon", coordinates: [[p]]}; 83 | outline({point: (lambda, phi) => p.push([lambda, phi])}, tree); 84 | p.push(p[0]); 85 | proj.preclip(clipPolygon(geometry).clipPoint(geoArea(geometry) < 4 * Math.PI - 0.1)); 86 | proj.tree = function() { return tree; }; 87 | 88 | return proj; 89 | } 90 | 91 | function outline(stream, node, parent) { 92 | let point, 93 | edges = node.edges, 94 | n = edges.length, 95 | edge, 96 | multiPoint = {type: "MultiPoint", coordinates: node.face}, 97 | notPoles = node.face.filter(function(d) { return abs(d[1]) !== 90; }), 98 | b = bounds({type: "MultiPoint", coordinates: notPoles}), 99 | inside = false, 100 | j = -1, 101 | dx = b[1][0] - b[0][0]; 102 | // TODO 103 | node.centroid = dx === 180 || dx === 360 104 | ? [(b[0][0] + b[1][0]) / 2, (b[0][1] + b[1][1]) / 2] 105 | : centroid(multiPoint); 106 | // First find the shared edge… 107 | if (parent) while (++j < n) { 108 | if (edges[j] === parent) break; 109 | } 110 | ++j; 111 | for (let i = 0; i < n; ++i) { 112 | edge = edges[(i + j) % n]; 113 | if (Array.isArray(edge)) { 114 | if (!inside) { 115 | stream.point((point = interpolate(edge[0], node.centroid)(epsilon))[0], point[1]); 116 | inside = true; 117 | } 118 | stream.point((point = interpolate(edge[1], node.centroid)(epsilon))[0], point[1]); 119 | } else { 120 | inside = false; 121 | if (edge !== parent) outline(stream, edge, node); 122 | } 123 | } 124 | } 125 | 126 | // Finds a shared edge given two clockwise polygons. 127 | function sharedEdge(a, b) { 128 | const n = a.length; 129 | let x, y, found = null; 130 | for (let i = 0; i < n; ++i) { 131 | x = a[i]; 132 | for (let j = b.length; --j >= 0;) { 133 | y = b[j]; 134 | if (x[0] === y[0] && x[1] === y[1]) { 135 | if (found) return [found, x]; 136 | found = x; 137 | } 138 | } 139 | } 140 | } 141 | 142 | // Converts an array of n face vertices to an array of n + 1 edges. 143 | function faceEdges(face) { 144 | const n = face.length; 145 | const edges = []; 146 | for (let i = 0, a = face[n - 1]; i < n; ++i) edges.push([a, a = face[i]]); 147 | return edges; 148 | } 149 | 150 | function hasInverse(node) { 151 | return node.project.invert || node.children && node.children.some(hasInverse); 152 | } 153 | -------------------------------------------------------------------------------- /src/airocean.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Buckminster Fuller’s AirOcean arrangement of the icosahedron 3 | * 4 | * Implemented for D3.js by Jason Davies (2013), 5 | * Enrico Spinielli (2017) and Philippe Rivière (2017, 2018) 6 | * 7 | */ 8 | import { atan, degrees } from "./math.js"; 9 | import polyhedral from "./polyhedral/index.js"; 10 | import { default as grayFullerRaw } from "./grayfuller.js"; 11 | import { 12 | geoCentroid as centroid, 13 | geoContains as contains, 14 | geoGnomonic as gnomonic, 15 | geoProjection as projection 16 | } from "d3-geo"; 17 | import { range } from "d3-array"; 18 | 19 | function airoceanRaw(faceProjection) { 20 | const theta = atan(0.5) * degrees; 21 | 22 | // construction inspired by 23 | // https://en.wikipedia.org/wiki/Regular_icosahedron#Spherical_coordinates 24 | const vertices = [[0, 90], [0, -90]].concat( 25 | range(10).map((i) => [(i * 36 + 180) % 360 - 180, i & 1 ? theta : -theta]) 26 | ); 27 | 28 | // icosahedron 29 | const polyhedron = [ 30 | [0, 3, 11], 31 | [0, 5, 3], 32 | [0, 7, 5], 33 | [0, 9, 7], 34 | [0, 11, 9], // North 35 | [2, 11, 3], 36 | [3, 4, 2], 37 | [4, 3, 5], 38 | [5, 6, 4], 39 | [6, 5, 7], 40 | [7, 8, 6], 41 | [8, 7, 9], 42 | [9, 10, 8], 43 | [10, 9, 11], 44 | [11, 2, 10], // Equator 45 | [1, 2, 4], 46 | [1, 4, 6], 47 | [1, 6, 8], 48 | [1, 8, 10], 49 | [1, 10, 2] // South 50 | ].map((face) => face.map((i) => vertices[i])); 51 | 52 | // add centroid 53 | polyhedron.forEach((face) => (face.centroid = centroid({ type: "MultiPoint", coordinates: face }))); 54 | 55 | // split the relevant faces: 56 | // * face[15] in the centroid: this will become face[15], face[20] and face[21] 57 | // * face[14] in the middle of the side: this will become face[14] and face[22] 58 | (function() { 59 | let face, tmp, mid, centroid; 60 | 61 | // Split face[15] in 3 faces at centroid. 62 | face = polyhedron[15]; 63 | centroid = face.centroid; 64 | tmp = face.slice(); 65 | face[0] = centroid; // (new) face[15] 66 | 67 | face = [tmp[0], centroid, tmp[2]]; 68 | face.centroid = centroid; 69 | polyhedron.push(face); // face[20] 70 | 71 | face = [tmp[0], tmp[1], centroid]; 72 | face.centroid = centroid; 73 | polyhedron.push(face); // face[21] 74 | 75 | // Split face 14 at the edge. 76 | face = polyhedron[14]; 77 | centroid = face.centroid; 78 | tmp = face.slice(); 79 | 80 | // compute planar midpoint 81 | const proj = gnomonic() 82 | .scale(1) 83 | .translate([0, 0]) 84 | .rotate([-centroid[0], -centroid[1]]); 85 | const a = proj(face[1]), 86 | b = proj(face[2]); 87 | mid = proj.invert([(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]); 88 | face[1] = mid; // (new) face[14] 89 | 90 | // build the new half face 91 | face = [tmp[0], tmp[1], mid]; 92 | face.centroid = centroid; // use original face[14] centroid 93 | polyhedron.push(face); // face[22] 94 | 95 | // cut face 19 to connect to 22 96 | face = polyhedron[19]; 97 | centroid = face.centroid; 98 | tmp = face.slice(); 99 | face[1] = mid; 100 | 101 | // build the new half face 102 | face = [mid, tmp[0], tmp[1]]; 103 | face.centroid = centroid; 104 | polyhedron.push(face); // face[23] 105 | })(); 106 | 107 | const airocean = function(faceProjection) { 108 | faceProjection = 109 | faceProjection || 110 | // for half-triangles this is definitely not centroid({type: "MultiPoint", coordinates: face}); 111 | ((face) => gnomonic() 112 | .scale(1) 113 | .translate([0, 0]) 114 | .rotate([-face.centroid[0], -face.centroid[1]])); 115 | 116 | const faces = polyhedron.map((face, i) => { 117 | const polygon = face.slice(); 118 | polygon.push(polygon[0]); 119 | 120 | return { 121 | face: face, 122 | site: face.centroid, 123 | id: i, 124 | contains: function(lambda, phi) { 125 | return contains({ type: "Polygon", coordinates: [polygon] }, [ 126 | lambda * degrees, 127 | phi * degrees 128 | ]); 129 | }, 130 | project: faceProjection(face) 131 | }; 132 | }); 133 | 134 | // Connect each face to a parent face. 135 | const parents = [ 136 | // N 137 | -1, // 0 138 | 0, // 1 139 | 1, // 2 140 | 11, // 3 141 | 13, // 4 142 | 143 | // Eq 144 | 6, // 5 145 | 7, // 6 146 | 1, // 7 147 | 7, // 8 148 | 8, // 9 149 | 150 | 9, // 10 151 | 10, // 11 152 | 11, // 12 153 | 12, // 13 154 | 13, // 14 155 | 156 | // S 157 | 6, // 15 158 | 8, // 16 159 | 10, // 17 160 | 17, // 18 161 | 21, // 19 162 | 16, // 20 163 | 15, // 21 164 | 19, // 22 165 | 19 // 23 166 | ]; 167 | 168 | parents.forEach((d, i) => { 169 | const node = faces[d]; 170 | node && (node.children || (node.children = [])).push(faces[i]); 171 | }); 172 | 173 | function face(lambda, phi) { 174 | for (let i = 0; i < faces.length; ++i) { 175 | if (faces[i].contains(lambda, phi)) return faces[i]; 176 | } 177 | } 178 | 179 | // Polyhedral projection 180 | const proj = polyhedral( 181 | faces[0], // the root face 182 | face // a function that returns a face given coords 183 | ); 184 | 185 | proj.faces = faces; 186 | return proj; 187 | }; 188 | 189 | return airocean(faceProjection); 190 | } 191 | 192 | export default function () { 193 | const p = airoceanRaw((face) => { 194 | const c = face.centroid; 195 | 196 | face.direction = 197 | Math.abs(c[1] - 52.62) < 1 || Math.abs(c[1] + 10.81) < 1 ? 0 : 60; 198 | return projection(grayFullerRaw()) 199 | .scale(1) 200 | .translate([0, 0]) 201 | .rotate([-c[0], -c[1], face.direction || 0]); 202 | }); 203 | 204 | return p 205 | .rotate([-83.65929, 25.44458, -87.45184]) 206 | .angle(-60) 207 | .scale(45.4631) 208 | .center([126, 0]); 209 | } 210 | -------------------------------------------------------------------------------- /src/complexLog.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Complex logarithm projection 3 | * 4 | * Based on the following papers by Joachim Böttger et al.: 5 | * - Detail‐In‐Context Visualization for Satellite Imagery (2008) (https://doi.org/10.1111/j.1467-8659.2008.01156.x) 6 | * - Complex Logarithmic Views for Small Details in Large Contexts (2006) (https://doi.org/10.1109/TVCG.2006.126) 7 | * 8 | * Implemented for d3 by Matthias Albrecht and Jochen Görtler (2019) 9 | * 10 | */ 11 | 12 | import { geoProjectionMutator as projectionMutator, geoAzimuthalEqualAreaRaw as azimuthalEqualAreaRaw } from "d3-geo"; 13 | import { abs, sin, cos, pi, exp, atan2 } from "./math.js"; 14 | import { complexMul, complexLogHypot } from "./complex.js"; 15 | import clipPolygon from "./clip/polygon.js"; 16 | 17 | // Default planar projection and cutoff latitude, see below for an explanation of these settings. 18 | const DEFAULT_PLANAR_PROJECTION_RAW = azimuthalEqualAreaRaw; 19 | const DEFAULT_CUTOFF_LATITUDE = -0.05; 20 | 21 | // Offset used to prevent logarithm of 0. 22 | const CARTESIAN_OFFSET = 1e-10; 23 | 24 | // Projection parameters for the default 960x500 projection area. 25 | const DEFAULT_PROJECTION_PARAMS = { 26 | angle: 90, 27 | center: [0, 5.022570623227068], 28 | scale: 79.92959180396787, 29 | translate: [479.9999905630355, 250.35977064160338] 30 | } 31 | 32 | // Vertices of the clipping polygon in spherical coordinates. 33 | // It contains the whole world except a small strip along longitude 0/180 crossing the south pole. 34 | const CLIP_POLY_SPHERICAL = [ 35 | [-180, -1e-4], 36 | [180, -1e-4], 37 | [1e-4, DEFAULT_CUTOFF_LATITUDE], 38 | [-1e-4, DEFAULT_CUTOFF_LATITUDE] 39 | ] 40 | 41 | // Clipping polygon precision. 42 | const N_SIDE = 5; 43 | const N_BOTTOM = 50; 44 | 45 | 46 | export function complexLogRaw(planarProjectionRaw = DEFAULT_PLANAR_PROJECTION_RAW) { 47 | function forward(lambda, phi) { 48 | // Project on plane. 49 | // Interpret projected point on complex plane. 50 | const azi1 = planarProjectionRaw(lambda, phi); 51 | 52 | // Rotate by -90 degrees in complex plane so the following complex log projection will be horizontally centered 53 | const aziComp = complexMul(azi1, [cos(-pi / 2), sin(-pi / 2)]); 54 | 55 | // Small offset to prevent logarithm of 0. 56 | if (aziComp[0] == 0 && aziComp[1] == 0) { 57 | aziComp[0] += CARTESIAN_OFFSET; 58 | aziComp[1] += CARTESIAN_OFFSET; 59 | } 60 | 61 | // Apply complex logarithm. 62 | return [complexLogHypot(aziComp[0], aziComp[1]), atan2(aziComp[1], aziComp[0])]; 63 | } 64 | 65 | function invert(x, y) { 66 | // Inverse complex logarithm (complex exponential function). 67 | const inv1 = [exp(x) * cos(y), exp(x) * sin(y)]; 68 | 69 | // Undo rotation. 70 | const invLogComp = complexMul(inv1, [cos(pi / 2), sin(pi / 2)]); 71 | 72 | // Invert azimuthal equal area. 73 | return planarProjectionRaw.invert(invLogComp[0], invLogComp[1]); 74 | } 75 | 76 | forward.invert = invert; 77 | return forward; 78 | } 79 | 80 | 81 | export default function(planarProjectionRaw = DEFAULT_PLANAR_PROJECTION_RAW, cutoffLatitude = DEFAULT_CUTOFF_LATITUDE) { 82 | const mutator = projectionMutator(complexLogRaw); 83 | const projection = mutator(planarProjectionRaw); 84 | 85 | // Projection used to project onto the complex plane. 86 | projection.planarProjectionRaw = function(_) { 87 | return arguments.length ? clipped(mutator(planarProjectionRaw = _)) : planarProjectionRaw; 88 | } 89 | 90 | // Latitude relative to the projection center at which to cutoff/clip the projection, lower values result in more detail around the projection center. 91 | // Value must be < 0 because complex log projects the origin to infinity. 92 | projection.cutoffLatitude = function(_) { 93 | return arguments.length ? (cutoffLatitude = _, clipped(mutator(planarProjectionRaw))) : cutoffLatitude; 94 | } 95 | 96 | function clipped(projection) { 97 | const angle = projection.angle(); 98 | const scale = projection.scale(); 99 | const center = projection.center(); 100 | const translate = projection.translate(); 101 | const rotate = projection.rotate(); 102 | 103 | projection 104 | .angle(DEFAULT_PROJECTION_PARAMS.angle) 105 | .scale(1) 106 | .center([0, 0]) 107 | .rotate([0, 0]) 108 | .translate([0, 0]) 109 | .preclip(); 110 | 111 | // These are corner vertices of a rectangle in the projected complex log view. 112 | const topLeft = projection(CLIP_POLY_SPHERICAL[0]); 113 | const topRight = projection(CLIP_POLY_SPHERICAL[1]); 114 | const bottomRight = projection([CLIP_POLY_SPHERICAL[2][0], cutoffLatitude]); 115 | const bottomLeft = projection([CLIP_POLY_SPHERICAL[3][0], cutoffLatitude]); 116 | const width = abs(topRight[0] - topLeft[0]); 117 | const height = abs(bottomRight[1] - topRight[1]); 118 | 119 | // Prevent overlapping polygons that result from paths that go from one side to the other, 120 | // so cut along 180°/-180° degree line (left and right in complex log projected view). 121 | // This means cutting against a rectangular shaped polygon in the projected view. 122 | // The following generator produces a polygon that is shaped like this: 123 | // 124 | // Winding order: ==> 125 | // 126 | // ******************| 127 | // | | 128 | // | | 129 | // | | 130 | // | | 131 | // | | 132 | // |------------------ 133 | // 134 | // N_SIDE determines how many vertices to insert along the sides (marked as | above). 135 | // N_BOTTOM determines how many vertices to insert along the bottom (marked as - above). 136 | // 137 | // The resulting polygon vertices are back-projected to spherical coordinates. 138 | const polygon = { 139 | type: "Polygon", 140 | coordinates: [ 141 | [ 142 | topLeft, 143 | ...Array.from({length: N_SIDE}, (_, t) => [bottomRight[0], bottomRight[1] - height * (N_SIDE- t) / N_SIDE]), 144 | ...Array.from({length: N_BOTTOM}, (_, t) => [bottomRight[0] - width * t / N_BOTTOM, bottomRight[1]]), 145 | ...Array.from({length: N_SIDE}, (_, t) => [bottomLeft[0], bottomLeft[1] - height * t / N_SIDE]), 146 | topLeft 147 | ].map(point => projection.invert(point)) 148 | ] 149 | }; 150 | 151 | return projection 152 | .angle(angle) 153 | .scale(scale) 154 | .center(center) 155 | .translate(translate) 156 | .rotate(rotate) 157 | .preclip(clipPolygon(polygon)); 158 | } 159 | 160 | // The following values are for the default 960x500 projection area 161 | return clipped(projection) 162 | .angle(DEFAULT_PROJECTION_PARAMS.angle) 163 | .center(DEFAULT_PROJECTION_PARAMS.center) 164 | .scale(DEFAULT_PROJECTION_PARAMS.scale) 165 | .translate(DEFAULT_PROJECTION_PARAMS.translate); 166 | } 167 | -------------------------------------------------------------------------------- /src/clip/polygon.js: -------------------------------------------------------------------------------- 1 | import clip from "./index.js"; 2 | import {atan2, cos, max, min, pi, radians, sign, sin, sqrt} from "../math.js"; 3 | import {cartesian, cartesianCross, cartesianDot, cartesianEqual, spherical} from "../cartesian.js"; 4 | import {intersectCoincident, intersectPointOnLine, intersectSegment, intersect} from "../intersect.js"; 5 | import polygonContains from "../polygonContains.js"; 6 | 7 | const clipNone = (stream) => stream; 8 | 9 | // clipPolygon 10 | export default function (geometry) { 11 | let clipPoint = true; 12 | 13 | function clipGeometry(geometry) { 14 | if (geometry.type === "Polygon") geometry = {type: "MultiPolygon", coordinates: [geometry.coordinates]}; 15 | if (geometry.type !== "MultiPolygon") return clipNone; 16 | const clips = geometry.coordinates.map((polygon) => { 17 | polygon = polygon.map(ringRadians); 18 | const pointVisible = visible(polygon); 19 | const segments = ringSegments(polygon[0]); // todo holes? 20 | return clip( 21 | pointVisible, 22 | clipLine(segments, pointVisible), 23 | interpolate(segments, polygon), 24 | polygon[0][0], 25 | clipPolygonSort, 26 | {clipPoint} 27 | ); 28 | }); 29 | 30 | function clipPolygon(stream) { 31 | const clipstream = clips.map(clip => clip(stream)); 32 | return { 33 | point(lambda, phi) { 34 | clipstream.forEach((clip) => clip.point(lambda, phi)); 35 | }, 36 | lineStart() { 37 | clipstream.forEach((clip) => clip.lineStart()); 38 | }, 39 | lineEnd() { 40 | clipstream.forEach((clip) => clip.lineEnd()); 41 | }, 42 | polygonStart() { 43 | clipstream.forEach((clip) => clip.polygonStart()); 44 | }, 45 | polygonEnd() { 46 | clipstream.forEach((clip) => clip.polygonEnd()); 47 | }, 48 | sphere() { 49 | clipstream.forEach((clip) => clip.sphere()); 50 | }, 51 | }; 52 | } 53 | 54 | clipPolygon.polygon = (_) => _ !== undefined ? clipGeometry(geometry = _) : geometry; 55 | clipPolygon.clipPoint = (_) => _ !== undefined ? ((clipPoint = !!_), clipGeometry(geometry)) : clipPoint; 56 | 57 | return clipPolygon; 58 | } 59 | 60 | return clipGeometry(geometry); 61 | } 62 | 63 | function ringRadians(ring) { 64 | return ring.map((point) => [point[0] * radians, point[1] * radians]); 65 | } 66 | 67 | function ringSegments(ring) { 68 | const segments = []; 69 | let c0; 70 | ring.forEach((point, i) => { 71 | const c = cartesian(point); 72 | if (i) segments.push(new intersectSegment(c0, c)); 73 | c0 = c; 74 | return point; 75 | }); 76 | return segments; 77 | } 78 | 79 | function clipPolygonSort(a, b) { 80 | (a = a.x), (b = b.x); 81 | return a.index - b.index || a.t - b.t; 82 | } 83 | 84 | function interpolate(segments, polygon) { 85 | return (from, to, direction, stream) => { 86 | if (from == null) { 87 | stream.polygonStart(); 88 | polygon.forEach((ring) => { 89 | stream.lineStart(); 90 | ring.forEach((point) => stream.point(point[0], point[1])); 91 | stream.lineEnd(); 92 | }); 93 | stream.polygonEnd(); 94 | } else if ( 95 | from.index !== to.index && 96 | from.index != null && 97 | to.index != null 98 | ) { 99 | for ( 100 | let i = from.index; 101 | i !== to.index; 102 | i = (i + direction + segments.length) % segments.length 103 | ) { 104 | const segment = segments[i]; 105 | const point = spherical(direction > 0 ? segment.to : segment.from); 106 | stream.point(point[0], point[1]); 107 | } 108 | } else if ( 109 | from.index === to.index && 110 | from.t > to.t && 111 | from.index != null && 112 | to.index != null 113 | ) { 114 | for (let i = 0; i < segments.length; ++i) { 115 | const segment = 116 | segments[ 117 | (from.index + i * direction + segments.length) % segments.length 118 | ]; 119 | const point = spherical(direction > 0 ? segment.to : segment.from); 120 | stream.point(point[0], point[1]); 121 | } 122 | } 123 | }; 124 | } 125 | 126 | // Geodesic coordinates for two 3D points. 127 | function clipPolygonDistance(a, b) { 128 | const axb = cartesianCross(a, b); 129 | return atan2(sqrt(cartesianDot(axb, axb)), cartesianDot(a, b)); 130 | } 131 | 132 | function visible(polygon) { 133 | return (lambda, phi) => polygonContains(polygon, [lambda, phi]); 134 | } 135 | 136 | function randsign(i, j) { 137 | return sign(sin(100 * i + j)); 138 | } 139 | 140 | function clipLine(segments, pointVisible) { 141 | return function (stream) { 142 | let point0, lambda00, phi00, v00, v0, clean, line, lines = []; 143 | return { 144 | lineStart() { 145 | point0 = null; 146 | clean = 1; 147 | line = []; 148 | }, 149 | lineEnd() { 150 | if (v0) lines.push(line); 151 | lines.forEach((line) => { 152 | stream.lineStart(); 153 | line.forEach((point) => stream.point(...point)); // can have 4 dimensions 154 | stream.lineEnd(); 155 | }); 156 | lines = []; 157 | }, 158 | point(lambda, phi, close) { 159 | if (cos(lambda) == -1) lambda -= sign(sin(lambda)) * 1e-5; // move away from -180/180 https://github.com/d3/d3-geo/pull/108#issuecomment-323798937 160 | if (close) (lambda = lambda00), (phi = phi00); 161 | let point = cartesian([lambda * 0.9999999999, phi + 1e-14]); 162 | let v = v0; 163 | if (point0) { 164 | const intersections = []; 165 | let segment = new intersectSegment(point0, point); 166 | for (let i = 0, j = 100; i < segments.length && j > 0; ++i) { 167 | const s = segments[i]; 168 | const intersection = intersect(segment, s); 169 | if (intersection) { 170 | if ( 171 | intersection === intersectCoincident || 172 | cartesianEqual(intersection, point0) || 173 | cartesianEqual(intersection, point) || 174 | cartesianEqual(intersection, s.from) || 175 | cartesianEqual(intersection, s.to) 176 | ) { 177 | const t = 1e-4; 178 | lambda = ((lambda + 3 * pi + randsign(i, j) * t) % (2 * pi)) - pi; 179 | phi = min(pi / 2 - t, max(t - pi / 2, phi + randsign(i, j) * t)); 180 | segment = new intersectSegment(point0, (point = cartesian([lambda, phi]))); 181 | (i = -1), --j; 182 | intersections.length = 0; 183 | continue; 184 | } 185 | const sph = spherical(intersection); 186 | intersection.distance = clipPolygonDistance(point0, intersection); 187 | intersection.index = i; 188 | intersection.t = clipPolygonDistance(s.from, intersection); 189 | intersection[0] = sph[0]; 190 | intersection[1] = sph[1]; 191 | delete intersection[2]; 192 | intersections.push(intersection); 193 | } 194 | } 195 | if (intersections.length) { 196 | clean = 0; 197 | intersections.sort((a, b) => a.distance - b.distance); 198 | for (let i = 0; i < intersections.length; ++i) { 199 | const intersection = intersections[i]; 200 | v = !v; 201 | if (v) { 202 | line = []; 203 | line.push([ 204 | intersection[0], 205 | intersection[1], 206 | intersection.index, 207 | intersection.t 208 | ]); 209 | } else { 210 | line.push([ 211 | intersection[0], 212 | intersection[1], 213 | intersection.index, 214 | intersection.t 215 | ]); 216 | lines.push(line); 217 | } 218 | } 219 | } 220 | if (v) line.push([lambda, phi]); 221 | } else { 222 | for (let i = 0, j = 100; i < segments.length && j > 0; ++i) { 223 | const s = segments[i]; 224 | if (intersectPointOnLine(point, s)) { 225 | const t = 1e-4; 226 | lambda = ((lambda + 3 * pi + randsign(i, j) * t) % (2 * pi)) - pi; 227 | phi = min( 228 | pi / 2 - 1e-4, 229 | max(1e-4 - pi / 2, phi + randsign(i, j) * t) 230 | ); 231 | point = cartesian([lambda, phi]); 232 | (i = -1), --j; 233 | } 234 | } 235 | v00 = v = pointVisible((lambda00 = lambda), (phi00 = phi)); 236 | if (v) (line = []), line.push([lambda, phi]); 237 | } 238 | point0 = point; 239 | v0 = v; 240 | }, 241 | // Rejoin first and last segments if there were intersections and the first 242 | // and last points were visible. 243 | clean() { 244 | return clean | ((v00 && v0) << 1); 245 | } 246 | }; 247 | }; 248 | } 249 | -------------------------------------------------------------------------------- /test/snapshots.js: -------------------------------------------------------------------------------- 1 | import { Canvas } from "canvas"; 2 | import { readFile } from "fs/promises"; 3 | import { feature } from "topojson-client"; 4 | import { geoGraticule, geoPath } from "d3-geo"; 5 | import { geoHomolosineRaw } from "d3-geo-projection"; 6 | import { 7 | geoAirocean, 8 | geoBerghaus, 9 | geoClipPolygon, 10 | geoCox, 11 | geoCahillKeyes, 12 | geoComplexLog, 13 | geoCubic, 14 | geoDeltoidal, 15 | geoGingery, 16 | geoHealpix, 17 | geoInterrupt, 18 | geoInterruptedBoggs, 19 | geoInterruptedHomolosine, 20 | geoInterruptedMollweide, 21 | geoInterruptedMollweideHemispheres, 22 | geoInterruptedSinuMollweide, 23 | geoInterruptedSinusoidal, 24 | geoRhombic, 25 | geoDodecahedral, 26 | geoIcosahedral, 27 | geoImago, 28 | geoPolyhedralButterfly, 29 | geoPolyhedralCollignon, 30 | geoPolyhedralWaterman, 31 | geoTetrahedralLee, 32 | geoTwoPointEquidistantUsa 33 | } from "../src/index.js"; 34 | 35 | const width = 960; 36 | const height = 500; 37 | 38 | async function renderWorld(projection, { points, extent, clip = false } = {}) { 39 | const graticule = geoGraticule(); 40 | const outline = 41 | extent === undefined 42 | ? { type: "Sphere" } 43 | : graticule.extent(extent).outline(); 44 | const world = JSON.parse( 45 | await readFile("./node_modules/world-atlas/land-50m.json") 46 | ); 47 | const canvas = new Canvas(width, height); 48 | const context = canvas.getContext("2d"); 49 | const path = geoPath(projection, context); 50 | context.fillStyle = "#fff"; 51 | context.beginPath(); 52 | path(outline); 53 | context.fill(); 54 | context.save(); 55 | if (clip) { 56 | context.beginPath(); 57 | path(outline); 58 | context.clip(); 59 | } 60 | context.beginPath(); 61 | path(feature(world, world.objects.land)); 62 | context.fillStyle = "#000"; 63 | context.fill(); 64 | context.beginPath(); 65 | path(graticule()); 66 | context.strokeStyle = "rgba(119,119,119,0.5)"; 67 | context.stroke(); 68 | context.restore(); 69 | context.beginPath(); 70 | path(outline); 71 | context.strokeStyle = "#000"; 72 | context.stroke(); 73 | if (points) { 74 | context.beginPath(); 75 | path({type: "MultiPoint", coordinates: points}); 76 | context.fillStyle = "steelblue"; 77 | context.fill(); 78 | } 79 | return canvas; 80 | } 81 | 82 | export async function airocean() { 83 | return renderWorld(geoAirocean().precision(0.1)); 84 | } 85 | 86 | export async function cox() { 87 | return renderWorld(geoCox().precision(0.1)); 88 | } 89 | 90 | export async function cahillKeyes() { 91 | return renderWorld(geoCahillKeyes().precision(0.1)); 92 | } 93 | 94 | export async function complexLog() { 95 | return renderWorld(geoComplexLog().precision(0.1)); 96 | } 97 | 98 | export async function cubic() { 99 | return renderWorld(geoCubic().precision(0.1)); 100 | } 101 | 102 | export async function dodecahedral() { 103 | return renderWorld(geoDodecahedral().precision(0.1)); 104 | } 105 | 106 | export async function icosahedral() { 107 | return renderWorld(geoIcosahedral().precision(0.1)); 108 | } 109 | 110 | export async function imago() { 111 | return renderWorld(geoImago().precision(0.1)); 112 | } 113 | 114 | export async function polyhedralButterfly() { 115 | return renderWorld(geoPolyhedralButterfly().precision(0.1)); 116 | } 117 | 118 | export async function polyhedralCollignon() { 119 | return renderWorld(geoPolyhedralCollignon().precision(0.1)); 120 | } 121 | 122 | export async function polyhedralWaterman() { 123 | return renderWorld(geoPolyhedralWaterman().precision(0.1)); 124 | } 125 | 126 | export async function deltoidal() { 127 | return renderWorld(geoDeltoidal().precision(0.1)); 128 | } 129 | 130 | export async function rhombic() { 131 | return renderWorld(geoRhombic().precision(0.1)); 132 | } 133 | 134 | export async function tetrahedralLee() { 135 | return renderWorld(geoTetrahedralLee().precision(0.1)); 136 | } 137 | 138 | export async function tetrahedralLeeSouth() { 139 | return renderWorld( 140 | geoTetrahedralLee() 141 | .rotate([-30, 0]) 142 | .angle(-30) 143 | .precision(0.1) 144 | .fitSize([width, height], { type: "Sphere" }) 145 | ); 146 | } 147 | 148 | // reclip 149 | export async function berghaus() { 150 | return renderWorld(geoBerghaus()); 151 | } 152 | 153 | export async function gingery() { 154 | return renderWorld(geoGingery()); 155 | } 156 | 157 | export async function berghaus7() { 158 | return renderWorld(geoBerghaus().lobes(7).fitSize([width, height], { type: "Sphere" })); 159 | } 160 | 161 | export async function berghaus13() { 162 | return renderWorld(geoBerghaus().lobes(13).fitSize([width, height], { type: "Sphere" })); 163 | } 164 | 165 | export async function gingery7() { 166 | return renderWorld(geoGingery().lobes(7).fitSize([width, height], { type: "Sphere" })); 167 | } 168 | 169 | export async function gingery3() { 170 | return renderWorld(geoGingery().lobes(3).fitSize([width, height], { type: "Sphere" })); 171 | } 172 | 173 | export async function goodeOcean() { 174 | return renderWorld( 175 | geoInterrupt(geoHomolosineRaw, [ 176 | [ 177 | [ 178 | [-180, 0], 179 | [-130, 90], 180 | [-95, 0], 181 | ], 182 | [ 183 | [-95, 0], 184 | [-30, 90], 185 | [55, 0], 186 | ], 187 | [ 188 | [55, 0], 189 | [120, 90], 190 | [180, 0], 191 | ], 192 | ], 193 | [ 194 | [ 195 | [-180, 0], 196 | [-120, -90], 197 | [-60, 0], 198 | ], 199 | [ 200 | [-60, 0], 201 | [20, -90], 202 | [85, 0], 203 | ], 204 | [ 205 | [85, 0], 206 | [140, -90], 207 | [180, 0], 208 | ], 209 | ], 210 | ]) 211 | .rotate([-204, 0]) 212 | .precision(0.1) 213 | ); 214 | } 215 | 216 | export async function interruptedBoggs() { 217 | return renderWorld(geoInterruptedBoggs()); 218 | } 219 | 220 | export async function healpix() { 221 | return renderWorld(geoHealpix()); 222 | } 223 | 224 | export async function healpix5() { 225 | return renderWorld(geoHealpix().lobes(5)); 226 | } 227 | 228 | export async function interruptedHomolosine() { 229 | return renderWorld(geoInterruptedHomolosine()); 230 | } 231 | 232 | export async function interruptedMollweide() { 233 | return renderWorld(geoInterruptedMollweide()); 234 | } 235 | 236 | export async function interruptedMollweideHemispheres() { 237 | return renderWorld(geoInterruptedMollweideHemispheres()); 238 | } 239 | 240 | export async function interruptedSinuMollweide() { 241 | return renderWorld(geoInterruptedSinuMollweide()); 242 | } 243 | 244 | export async function interruptedSinusoidal() { 245 | return renderWorld(geoInterruptedSinusoidal()); 246 | } 247 | 248 | // https://github.com/d3/d3-geo/issues/46 249 | export async function twoPointEquidistantUsa() { 250 | return renderWorld( 251 | geoTwoPointEquidistantUsa().fitSize([width, height], { type: "Sphere" }) 252 | ); 253 | } 254 | 255 | // more tests 256 | 257 | // https://github.com/d3/d3-geo-polygon/issues/7 258 | export async function cubic45() { 259 | return renderWorld( 260 | geoCubic() 261 | .parents([-1, 2, 0, 2, 5, 2]) 262 | .rotate([0, 0, 45]) 263 | .fitSize([width, height], { type: "Sphere" }) 264 | ); 265 | } 266 | 267 | // https://github.com/d3/d3-geo-polygon/issues/30 268 | export async function airocean702() { 269 | return renderWorld(geoAirocean().rotate([-70.2, -47, -121.6])); 270 | } 271 | 272 | // https://github.com/d3/d3-geo-polygon/issues/30 273 | export async function airocean732() { 274 | return renderWorld(geoAirocean().rotate([88, -37.8, -73.2])); 275 | } 276 | 277 | export async function rhombic00() { 278 | return renderWorld(geoRhombic().rotate([0, 0]).precision(0.1)); 279 | } 280 | 281 | // https://github.com/d3/d3-geo-polygon/issues/62 282 | export async function rhombicHalf1() { 283 | return renderWorld( 284 | geoRhombic() 285 | .parents([-1, 0, 6, 2, 1, 9, 11, 3, 4, 8, 6, 10]) 286 | .precision(0.1) 287 | .fitSize([width, height], { type: "Sphere" }) 288 | ); 289 | } 290 | export async function rhombicHalf2() { 291 | return renderWorld( 292 | geoRhombic() 293 | .parents([4, 0, 6, 2, 1, 9, 11, 3, 4, 8, -1, 10]) 294 | .angle(-19.5) 295 | .precision(0.1) 296 | .fitSize([width, height], { type: "Sphere" }) 297 | ); 298 | } 299 | 300 | // https://github.com/d3/d3-geo-polygon/issues/4 301 | export async function clipPointWorld() { 302 | return renderWorld(geoRhombic(), {points: [ 303 | [0, 0], 304 | [10 - 0.0001, 0], 305 | [10 + 0.0001, 0], 306 | [10, -10], 307 | [10, -20], 308 | ]}); 309 | } 310 | export async function clipPointTrue() { 311 | const projection = geoRhombic(); 312 | const polygon = projection.preclip().polygon(); 313 | projection.preclip(geoClipPolygon(polygon).clipPoint(true)); 314 | return renderWorld(projection, {points: [ 315 | [0, 0], 316 | [10 - 0.0001, 0], 317 | [10 + 0.0001, 0], 318 | [10, -10], 319 | [10, -20], 320 | ]}); 321 | } 322 | export async function clipPointSmall() { 323 | const projection = geoRhombic().parents([-1, 0, 6, 2, 1, 9, 11, 3, 4, 8, 6, 10]); 324 | return renderWorld(projection, {points: [ 325 | [0, 0], 326 | [10 - 0.0001, 0], 327 | [10 + 0.0001, 0], 328 | [10, -10], 329 | [10, -20], 330 | ]}); 331 | } 332 | export async function clipPointFalse() { 333 | const projection = geoRhombic(); 334 | projection.preclip(geoClipPolygon( 335 | geoRhombic().parents([-1, 0, 6, 2, 1, 9, 11, 3, 4, 8, 6, 10]).preclip().polygon()).clipPoint(false) 336 | ); 337 | return renderWorld(projection, {points: [ 338 | [0, 0], 339 | [10 - 0.0001, 0], 340 | [10 + 0.0001, 0], 341 | [10, -10], 342 | [10, -20], 343 | ]}); 344 | } 345 | -------------------------------------------------------------------------------- /src/imago.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Imago projection, by Justin Kunimune 3 | * 4 | * Inspired by Hajime Narukawa’s AuthaGraph 5 | * 6 | */ 7 | import { 8 | abs, 9 | acos, 10 | asin, 11 | atan, 12 | atan2, 13 | cos, 14 | degrees, 15 | epsilon, 16 | floor, 17 | halfPi, 18 | hypot, 19 | pi, 20 | pow, 21 | sign, 22 | sin, 23 | sqrt, 24 | tan, 25 | } from "./math.js"; 26 | import { geoProjectionMutator as projectionMutator } from "d3-geo"; 27 | import clipPolygon from "./clip/polygon.js"; 28 | import { solve } from "./newton.js"; 29 | 30 | const ASIN_ONE_THD = asin(1 / 3), 31 | centrums = [ 32 | [halfPi, 0, 0, -halfPi, 0, sqrt(3)], 33 | [-ASIN_ONE_THD, 0, pi, halfPi, 0, -sqrt(3)], 34 | [-ASIN_ONE_THD, (2 * pi) / 3, pi, (5 * pi) / 6, 3, 0], 35 | [-ASIN_ONE_THD, (-2 * pi) / 3, pi, pi / 6, -3, 0], 36 | ], 37 | TETRAHEDRON_WIDE_VERTEX = { 38 | sphereSym: 3, 39 | planarSym: 6, 40 | width: 6, 41 | height: 2 * sqrt(3), 42 | centrums, 43 | rotateOOB: function (x, y, xCen, yCen) { 44 | yCen * 0; 45 | if (abs(x) > this.width / 2) return [2 * xCen - x, -y]; 46 | else return [-x, this.height * sign(y) - y]; 47 | }, 48 | inBounds: () => true, 49 | }, 50 | configuration = TETRAHEDRON_WIDE_VERTEX; 51 | 52 | export function imagoRaw(k) { 53 | function faceProject(lon, lat) { 54 | const tht = atan(((lon - asin(sin(lon) / sqrt(3))) / pi) * sqrt(12)), 55 | p = (halfPi - lat) / atan(sqrt(2) / cos(lon)); 56 | 57 | return [(pow(p, k) * sqrt(3)) / cos(tht), tht]; 58 | } 59 | 60 | function faceInverse(r, th) { 61 | const l = solve( 62 | (l) => atan(((l - asin(sin(l) / sqrt(3))) / pi) * sqrt(12)), 63 | th, 64 | th / 2 65 | ), 66 | R = r / (sqrt(3) / cos(th)); 67 | return [halfPi - pow(R, 1 / k) * atan(sqrt(2) / cos(l)), l]; 68 | } 69 | 70 | function obliquifySphc(latF, lonF, pole) { 71 | if (pole == null) 72 | // null pole indicates that this procedure should be bypassed 73 | return [latF, lonF]; 74 | 75 | const lat0 = pole[0], 76 | lon0 = pole[1], 77 | tht0 = pole[2]; 78 | 79 | let lat1, lon1; 80 | if (lat0 == halfPi) lat1 = latF; 81 | else 82 | lat1 = asin( 83 | sin(lat0) * sin(latF) + cos(lat0) * cos(latF) * cos(lon0 - lonF) 84 | ); // relative latitude 85 | 86 | if (lat0 == halfPi) 87 | // accounts for all the 0/0 errors at the poles 88 | lon1 = lonF - lon0; 89 | else if (lat0 == -halfPi) lon1 = lon0 - lonF - pi; 90 | else { 91 | lon1 = 92 | acos( 93 | (cos(lat0) * sin(latF) - sin(lat0) * cos(latF) * cos(lon0 - lonF)) / 94 | cos(lat1) 95 | ) - pi; // relative longitude 96 | if (isNaN(lon1)) { 97 | if ( 98 | (cos(lon0 - lonF) >= 0 && latF < lat0) || 99 | (cos(lon0 - lonF) < 0 && latF < -lat0) 100 | ) 101 | lon1 = 0; 102 | else lon1 = -pi; 103 | } else if (sin(lonF - lon0) > 0) 104 | // it's a plus-or-minus arccos. 105 | lon1 = -lon1; 106 | } 107 | lon1 = lon1 - tht0; 108 | 109 | return [lat1, lon1]; 110 | } 111 | 112 | function obliquifyPlnr(coords, pole) { 113 | if (pole == null) 114 | //this indicates that you just shouldn't do this calculation 115 | return coords; 116 | 117 | let lat1 = coords[0], 118 | lon1 = coords[1]; 119 | const lat0 = pole[0], 120 | lon0 = pole[1], 121 | tht0 = pole[2]; 122 | 123 | lon1 += tht0; 124 | let latf = asin(sin(lat0) * sin(lat1) - cos(lat0) * cos(lon1) * cos(lat1)), 125 | lonf, 126 | innerFunc = sin(lat1) / cos(lat0) / cos(latf) - tan(lat0) * tan(latf); 127 | if (lat0 == halfPi) 128 | // accounts for special case when lat0 = pi/2 129 | lonf = lon1 + lon0; 130 | else if (lat0 == -halfPi) 131 | // accounts for special case when lat0 = -pi/2 132 | lonf = -lon1 + lon0 + pi; 133 | else if (abs(innerFunc) > 1) { 134 | // accounts for special case when cos(lat1) -> 0 135 | if ((lon1 == 0 && lat1 < -lat0) || (lon1 != 0 && lat1 < lat0)) 136 | lonf = lon0 + pi; 137 | else lonf = lon0; 138 | } else if (sin(lon1) > 0) lonf = lon0 + acos(innerFunc); 139 | else lonf = lon0 - acos(innerFunc); 140 | 141 | let thtf = pole[2]; 142 | 143 | return [latf, lonf, thtf]; 144 | } 145 | 146 | function forward(lon, lat) { 147 | const width = configuration.width, 148 | height = configuration.height; 149 | const numSym = configuration.sphereSym; //we're about to be using this variable a lot 150 | let latR = -Infinity; 151 | let lonR = -Infinity; 152 | let centrum = null; 153 | for (const testCentrum of centrums) { 154 | //iterate through the centrums to see which goes here 155 | const relCoords = obliquifySphc(lat, lon, testCentrum); 156 | if (relCoords[0] > latR) { 157 | latR = relCoords[0]; 158 | lonR = relCoords[1]; 159 | centrum = testCentrum; 160 | } 161 | } 162 | 163 | const lonR0 = 164 | floor((lonR + pi / numSym) / ((2 * pi) / numSym)) * ((2 * pi) / numSym); 165 | 166 | const rth = faceProject(lonR - lonR0, latR); 167 | const r = rth[0]; 168 | const th = rth[1] + centrum[3] + (lonR0 * numSym) / configuration.planarSym; 169 | const x0 = centrum[4]; 170 | const y0 = centrum[5]; 171 | 172 | let output = [r * cos(th) + x0, r * sin(th) + y0]; 173 | if (abs(output[0]) > width / 2 || abs(output[1]) > height / 2) { 174 | output = configuration.rotateOOB(output[0], output[1], x0, y0); 175 | } 176 | return output; 177 | } 178 | 179 | function invert(x, y) { 180 | if (isNaN(x) || isNaN(y)) return null; 181 | 182 | if (!configuration.inBounds(x, y)) return null; 183 | 184 | const numSym = configuration.planarSym; 185 | 186 | let rM = +Infinity; 187 | let centrum = null; //iterate to see which centrum we get 188 | for (const testCentrum of centrums) { 189 | const rR = hypot(x - testCentrum[4], y - testCentrum[5]); 190 | if (rR < rM) { 191 | //pick the centrum that minimises r 192 | rM = rR; 193 | centrum = testCentrum; 194 | } 195 | } 196 | const th0 = centrum[3], 197 | x0 = centrum[4], 198 | y0 = centrum[5], 199 | r = hypot(x - x0, y - y0), 200 | th = atan2(y - y0, x - x0) - th0, 201 | thBase = 202 | floor((th + pi / numSym) / ((2 * pi) / numSym)) * ((2 * pi) / numSym); 203 | 204 | let relCoords = faceInverse(r, th - thBase); 205 | 206 | if (relCoords == null) return null; 207 | 208 | relCoords[1] = (thBase * numSym) / configuration.sphereSym + relCoords[1]; 209 | let absCoords = obliquifyPlnr(relCoords, centrum); 210 | return [absCoords[1], absCoords[0]]; 211 | } 212 | 213 | forward.invert = invert; 214 | 215 | return forward; 216 | } 217 | 218 | export function imagoBlock() { 219 | let k = 0.68; 220 | const m = projectionMutator(imagoRaw); 221 | const p = m(k); 222 | 223 | p.k = function (_) { 224 | return arguments.length ? m((k = +_)) : k; 225 | }; 226 | 227 | const a = -atan(1 / sqrt(2)) * degrees, 228 | border = [ 229 | [-180 + epsilon, a + epsilon], 230 | [0, 90], 231 | [180 - epsilon, a + epsilon], 232 | [180 - epsilon, a - epsilon], 233 | [-180 + epsilon, a - epsilon], 234 | [-180 + epsilon, a + epsilon], 235 | ]; 236 | 237 | return p 238 | .preclip( 239 | clipPolygon({ 240 | type: "Polygon", 241 | coordinates: [border], 242 | }) 243 | ) 244 | .scale(144.04) 245 | .rotate([18, -12.5, 3.5]) 246 | .center([0, 35.2644]); 247 | } 248 | 249 | function imagoWideRaw(k, shift) { 250 | const imago = imagoRaw(k); 251 | const height = configuration.height; 252 | 253 | function forward(lon, lat) { 254 | const p = imago(lon, lat), 255 | q = [p[1], -p[0]]; 256 | 257 | if (q[1] > 0) { 258 | q[0] = height - q[0]; 259 | q[1] *= -1; 260 | } 261 | 262 | q[0] += shift; 263 | if (q[0] < 0) q[0] += height * 2; 264 | 265 | return q; 266 | } 267 | 268 | function invert(x, y) { 269 | x = (x - shift) / height; 270 | 271 | if (x > 1.5) x -= 2; 272 | 273 | if (x > 0.5) { 274 | x = 1 - x; 275 | y *= -1; 276 | } 277 | 278 | return imago.invert(-y, x * height); 279 | } 280 | 281 | forward.invert = invert; 282 | return forward; 283 | } 284 | 285 | export default function () { 286 | let k = 0.59; 287 | let shift = 1.16; 288 | const m = projectionMutator(imagoWideRaw); 289 | const p = m(k, shift); 290 | 291 | p.shift = function (_) { 292 | return arguments.length ? clipped(m(k, (shift = +_))) : shift; 293 | }; 294 | p.k = function (_) { 295 | return arguments.length ? clipped(m((k = +_), shift)) : k; 296 | }; 297 | 298 | function clipped(p) { 299 | const N = 100 + 2 * epsilon, 300 | border = [], 301 | e = 3e-3; 302 | 303 | const scale = p.scale(), 304 | center = p.center(), 305 | translate = p.translate(), 306 | rotate = p.rotate(); 307 | p.scale(1).center([0, 90]).rotate([0, 0]).translate([shift, 0]); 308 | for (let i = N - epsilon; i > 0; i--) { 309 | border.unshift( 310 | p.invert([ 311 | 1.5 * configuration.height - e, 312 | ((configuration.width / 2) * i) / N, 313 | ]) 314 | ); 315 | border.push( 316 | p.invert([ 317 | -0.5 * configuration.height + e, 318 | ((configuration.width / 2) * i) / N, 319 | ]) 320 | ); 321 | } 322 | border.push(border[0]); 323 | 324 | return p 325 | .scale(scale) 326 | .center(center) 327 | .translate(translate) 328 | .rotate(rotate) 329 | .preclip( 330 | clipPolygon({ 331 | type: "Polygon", 332 | coordinates: [border], 333 | }) 334 | ); 335 | } 336 | 337 | return clipped(p) 338 | .rotate([18, -12.5, 3.5]) 339 | .scale(138.42) 340 | .translate([480, 250]) 341 | .center([-139.405, 40.5844]); 342 | } 343 | -------------------------------------------------------------------------------- /src/cahillKeyes.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Cahill-Keyes projection 3 | * 4 | * Implemented in Perl by Mary Jo Graça (2011) 5 | * 6 | * Ported to D3.js by Enrico Spinielli (2013) 7 | * 8 | */ 9 | import { abs, cos, degrees, pi, radians, sin, sign, sqrt, tan } from "./math.js"; 10 | import { cartesianCross, cartesianDegrees, cartesianDot, sphericalDegrees } from "./cartesian.js"; 11 | import polyhedral from "./polyhedral/index.js"; 12 | import { geoProjectionMutator as projectionMutator } from "d3-geo"; 13 | import {solve2d} from "./newton.js"; 14 | 15 | export default function(faceProjection) { 16 | faceProjection = 17 | faceProjection || 18 | (() => cahillKeyesProjection().scale(1)); 19 | 20 | const octa = [[0, 90], [-90, 0], [0, 0], [90, 0], [180, 0], [0, -90]]; 21 | 22 | const octahedron = [ 23 | [0, 2, 1], 24 | [0, 3, 2], 25 | [5, 1, 2], 26 | [5, 2, 3], 27 | [0, 1, 4], 28 | [0, 4, 3], 29 | [5, 4, 1], 30 | [5, 3, 4] 31 | ].map((face) => face.map((i) => octa[i])); 32 | 33 | const ck = octahedron.map((face) => { 34 | const xyz = face.map(cartesianDegrees), 35 | n = xyz.length, 36 | theta = 17 * radians, 37 | cosTheta = cos(theta), 38 | sinTheta = sin(theta), 39 | hexagon = []; 40 | let a = xyz[n - 1]; 41 | let b; 42 | for (let i = 0; i < n; ++i) { 43 | b = xyz[i]; 44 | hexagon.push( 45 | sphericalDegrees([ 46 | a[0] * cosTheta + b[0] * sinTheta, 47 | a[1] * cosTheta + b[1] * sinTheta, 48 | a[2] * cosTheta + b[2] * sinTheta 49 | ]), 50 | sphericalDegrees([ 51 | b[0] * cosTheta + a[0] * sinTheta, 52 | b[1] * cosTheta + a[1] * sinTheta, 53 | b[2] * cosTheta + a[2] * sinTheta 54 | ]) 55 | ); 56 | a = b; 57 | } 58 | return hexagon; 59 | }); 60 | 61 | const cornerNormals = []; 62 | 63 | const parents = [-1, 3, 0, 2, 0, 1, 4, 5]; 64 | 65 | ck.forEach((hexagon, j) => { 66 | const face = octahedron[j], 67 | n = face.length, 68 | normals = (cornerNormals[j] = []); 69 | for (let i = 0; i < n; ++i) { 70 | ck.push([ 71 | face[i], 72 | hexagon[(i * 2 + 2) % (2 * n)], 73 | hexagon[(i * 2 + 1) % (2 * n)] 74 | ]); 75 | parents.push(j); 76 | normals.push( 77 | cartesianCross( 78 | cartesianDegrees(hexagon[(i * 2 + 2) % (2 * n)]), 79 | cartesianDegrees(hexagon[(i * 2 + 1) % (2 * n)]) 80 | ) 81 | ); 82 | } 83 | }); 84 | 85 | const faces = ck.map((face) => ({project: faceProjection(face), face})); 86 | 87 | parents.forEach((d, i) => { 88 | const parent = faces[d]; 89 | parent && (parent.children || (parent.children = [])).push(faces[i]); 90 | }); 91 | return polyhedral(faces[0], face, 0, true) 92 | .scale(0.023975) 93 | .rotate([20, 0]) 94 | .center([0,-17]); 95 | 96 | function face(lambda, phi) { 97 | const cosPhi = cos(phi); 98 | const p = [cosPhi * cos(lambda), cosPhi * sin(lambda), sin(phi)]; 99 | 100 | const hexagon = 101 | lambda < -pi / 2 102 | ? phi < 0 ? 6 : 4 103 | : lambda < 0 104 | ? phi < 0 ? 2 : 0 105 | : lambda < pi / 2 ? (phi < 0 ? 3 : 1) : phi < 0 ? 7 : 5; 106 | 107 | const n = cornerNormals[hexagon]; 108 | 109 | return faces[ 110 | cartesianDot(n[0], p) < 0 111 | ? 8 + 3 * hexagon 112 | : cartesianDot(n[1], p) < 0 113 | ? 8 + 3 * hexagon + 1 114 | : cartesianDot(n[2], p) < 0 ? 8 + 3 * hexagon + 2 : hexagon 115 | ]; 116 | } 117 | } 118 | 119 | // all names of reference points, A, B, D, ... , G, P75 120 | // or zones, A-L, are detailed fully in Gene Keyes' 121 | // web site http://www.genekeyes.com/CKOG-OOo/7-CKOG-illus-&-coastline.html 122 | 123 | export function cahillKeyesRaw(mg) { 124 | const CK = { 125 | lengthMG: mg // magic scaling length 126 | }; 127 | 128 | preliminaries(); 129 | 130 | function preliminaries() { 131 | let pointN, lengthMB, lengthMN, lengthNG, pointU; 132 | let m = 29, // meridian 133 | p = 15, // parallel 134 | p73a, 135 | lF, 136 | lT, 137 | lM, 138 | l, 139 | pointV, 140 | k = sqrt(3); 141 | 142 | CK.lengthMA = 940 / 10000 * CK.lengthMG; 143 | CK.lengthParallel0to73At0 = CK.lengthMG / 100; 144 | CK.lengthParallel73to90At0 = 145 | (CK.lengthMG - CK.lengthMA - CK.lengthParallel0to73At0 * 73) / (90 - 73); 146 | CK.sin60 = k / 2; // √3/2 147 | CK.cos60 = 0.5; 148 | CK.pointM = [0, 0]; 149 | CK.pointG = [CK.lengthMG, 0]; 150 | pointN = [CK.lengthMG, CK.lengthMG * tan(30 * radians)]; 151 | CK.pointA = [CK.lengthMA, 0]; 152 | CK.pointB = lineIntersection(CK.pointM, 30, CK.pointA, 45); 153 | CK.lengthAG = distance(CK.pointA, CK.pointG); 154 | CK.lengthAB = distance(CK.pointA, CK.pointB); 155 | lengthMB = distance(CK.pointM, CK.pointB); 156 | lengthMN = distance(CK.pointM, pointN); 157 | lengthNG = distance(pointN, CK.pointG); 158 | CK.pointD = interpolate(lengthMB, lengthMN, pointN, CK.pointM); 159 | CK.pointF = [CK.lengthMG, lengthNG - lengthMB]; 160 | CK.pointE = [ 161 | pointN[0] - CK.lengthMA * sin(30 * radians), 162 | pointN[1] - CK.lengthMA * cos(30 * radians) 163 | ]; 164 | CK.lengthGF = distance(CK.pointG, CK.pointF); 165 | CK.lengthBD = distance(CK.pointB, CK.pointD); 166 | CK.lengthBDE = CK.lengthBD + CK.lengthAB; // lengthAB = lengthDE 167 | CK.lengthGFE = CK.lengthGF + CK.lengthAB; // lengthAB = lengthFE 168 | CK.deltaMEq = CK.lengthGFE / 45; 169 | CK.lengthAP75 = (90 - 75) * CK.lengthParallel73to90At0; 170 | CK.lengthAP73 = CK.lengthMG - CK.lengthMA - CK.lengthParallel0to73At0 * 73; 171 | pointU = [ 172 | CK.pointA[0] + CK.lengthAP73 * cos(30 * radians), 173 | CK.pointA[1] + CK.lengthAP73 * sin(30 * radians) 174 | ]; 175 | CK.pointT = lineIntersection(pointU, -60, CK.pointB, 30); 176 | 177 | p73a = parallel73(m); 178 | lF = p73a.lengthParallel73; 179 | lT = lengthTorridSegment(m); 180 | lM = lengthMiddleSegment(m); 181 | l = p * (lT + lM + lF) / 73; 182 | pointV = [0, 0]; 183 | CK.pointC = [0, 0]; 184 | CK.radius = 0; 185 | 186 | l = l - lT; 187 | pointV = interpolate(l, lM, jointT(m), jointF(m)); 188 | CK.pointC[1] = 189 | (pointV[0] * pointV[0] + 190 | pointV[1] * pointV[1] - 191 | CK.pointD[0] * CK.pointD[0] - 192 | CK.pointD[1] * CK.pointD[1]) / 193 | (2 * (k * pointV[0] + pointV[1] - k * CK.pointD[0] - CK.pointD[1])); 194 | CK.pointC[0] = k * CK.pointC[1]; 195 | CK.radius = distance(CK.pointC, CK.pointD); 196 | 197 | return CK; 198 | } 199 | 200 | //**** helper functions ****// 201 | 202 | // distance between two 2D coordinates 203 | 204 | function distance(p1, p2) { 205 | return Math.hypot(p1[0] - p2[0], p1[1] - p2[1]); 206 | } 207 | 208 | // return 2D point at position length/totallength of the line 209 | // defined by two 2D points, start and end. 210 | 211 | function interpolate(length, totalLength, start, end) { 212 | return [ 213 | start[0] + (end[0] - start[0]) * length / totalLength, 214 | start[1] + (end[1] - start[1]) * length / totalLength 215 | ]; 216 | } 217 | 218 | // return the 2D point intersection between two lines defined 219 | // by one 2D point and a slope each. 220 | 221 | function lineIntersection(point1, slope1, point2, slope2) { 222 | // s1/s2 = slope in degrees 223 | const m1 = tan(slope1 * radians); 224 | const m2 = tan(slope2 * radians); 225 | const x = (m1 * point1[0] - m2 * point2[0] - point1[1] + point2[1]) / (m1 - m2); 226 | return [x, m1 * (x - point1[0]) + point1[1]]; 227 | } 228 | 229 | // return the 2D point intercepting a circumference centered 230 | // at cc and of radius rn and a line defined by 2 points, p1 and p2: 231 | // First element of the returned array is a flag to state whether there is 232 | // an intersection, a value of zero (0) means NO INTERSECTION. 233 | // The following array is the 2D point of the intersection. 234 | // Equations from "Intersection of a Line and a Sphere (or circle)/Line Segment" 235 | // at http://paulbourke.net/geometry/circlesphere/ 236 | function circleLineIntersection(cc, r, p1, p2) { 237 | let x1 = p1[0], 238 | y1 = p1[1], 239 | x2 = p2[0], 240 | y2 = p2[1], 241 | xc = cc[0], 242 | yc = cc[1], 243 | a = (x2 - x1) * (x2 - x1) + (y2 - y1) * (y2 - y1), 244 | b = 2 * ((x2 - x1) * (x1 - xc) + (y2 - y1) * (y1 - yc)), 245 | c = 246 | xc * xc + yc * yc + x1 * x1 + y1 * y1 - 2 * (xc * x1 + yc * y1) - r * r, 247 | d = b * b - 4 * a * c, 248 | u1 = 0, 249 | u2 = 0, 250 | x = 0, 251 | y = 0; 252 | if (a === 0) { 253 | return [0, [0, 0]]; 254 | } else if (d < 0) { 255 | return [0, [0, 0]]; 256 | } 257 | u1 = (-b + sqrt(d)) / (2 * a); 258 | u2 = (-b - sqrt(d)) / (2 * a); 259 | if (0 <= u1 && u1 <= 1) { 260 | x = x1 + u1 * (x2 - x1); 261 | y = y1 + u1 * (y2 - y1); 262 | return [1, [x, y]]; 263 | } else if (0 <= u2 && u2 <= 1) { 264 | x = x1 + u2 * (x2 - x1); 265 | y = y1 + u2 * (y2 - y1); 266 | return [1, [x, y]]; 267 | } else { 268 | return [0, [0, 0]]; 269 | } 270 | } 271 | 272 | // counterclockwise rotate 2D vector, xy, by angle (in degrees) 273 | // [original CKOG uses clockwise rotation] 274 | 275 | function rotate(xy, angle) { 276 | const xynew = [0, 0]; 277 | 278 | if (angle === -60) { 279 | xynew[0] = xy[0] * CK.cos60 + xy[1] * CK.sin60; 280 | xynew[1] = -xy[0] * CK.sin60 + xy[1] * CK.cos60; 281 | } else if (angle === -120) { 282 | xynew[0] = -xy[0] * CK.cos60 + xy[1] * CK.sin60; 283 | xynew[1] = -xy[0] * CK.sin60 - xy[1] * CK.cos60; 284 | } else { 285 | // !!!!! This should not happen for this projection!!!! 286 | // the general algorithm: cos(angle) * xy + sin(angle) * perpendicular(xy) 287 | // return cos(angle * radians) * xy + sin(angle * radians) * perpendicular(xy); 288 | //console.log("rotate: angle " + angle + " different than -60 or -120!"); 289 | // counterclockwise 290 | xynew[0] = xy[0] * cos(angle * radians) - xy[1] * sin(angle * radians); 291 | xynew[1] = xy[0] * sin(angle * radians) + xy[1] * cos(angle * radians); 292 | } 293 | 294 | return xynew; 295 | } 296 | 297 | // truncate towards zero like int() in Perl 298 | function truncate(n) { 299 | return Math[n > 0 ? "floor" : "ceil"](n); 300 | } 301 | 302 | function equator(m) { 303 | const l = CK.deltaMEq * m; 304 | return (l <= CK.lengthGF) 305 | ? [CK.pointG[0], l] 306 | : interpolate(l - CK.lengthGF, CK.lengthAB, CK.pointF, CK.pointE); 307 | } 308 | 309 | function jointE(m) { 310 | return equator(m); 311 | } 312 | 313 | function jointT(m) { 314 | return lineIntersection(CK.pointM, 2 * m / 3, jointE(m), m / 3); 315 | } 316 | 317 | function jointF(m) { 318 | if (m === 0) { 319 | return [CK.pointA + CK.lengthAB, 0]; 320 | } 321 | return lineIntersection(CK.pointA, m, CK.pointM, 2 * m / 3); 322 | } 323 | 324 | function lengthTorridSegment(m) { 325 | return distance(jointE(m), jointT(m)); 326 | } 327 | 328 | function lengthMiddleSegment(m) { 329 | return distance(jointT(m), jointF(m)); 330 | } 331 | 332 | function parallel73(m) { 333 | let p73 = [0, 0], 334 | jF = jointF(m), 335 | lF = 0, 336 | xy = [0, 0]; 337 | if (m <= 30) { 338 | p73[0] = CK.pointA[0] + CK.lengthAP73 * cos(m * radians); 339 | p73[1] = CK.pointA[1] + CK.lengthAP73 * sin(m * radians); 340 | lF = distance(jF, p73); 341 | } else { 342 | p73 = lineIntersection(CK.pointT, -60, jF, m); 343 | lF = distance(jF, p73); 344 | if (m > 44) { 345 | xy = lineIntersection(CK.pointT, -60, jF, 2 / 3 * m); 346 | if (xy[0] > p73[0]) { 347 | p73 = xy; 348 | lF = -distance(jF, p73); 349 | } 350 | } 351 | } 352 | return { 353 | parallel73: p73, 354 | lengthParallel73: lF 355 | }; 356 | } 357 | 358 | function parallel75(m) { 359 | return [ 360 | CK.pointA[0] + CK.lengthAP75 * cos(m * radians), 361 | CK.pointA[1] + CK.lengthAP75 * sin(m * radians) 362 | ]; 363 | } 364 | 365 | // special functions to transform lon/lat to x/y 366 | function ll2mp(lon, lat) { 367 | const south = [0, 6, 7, 8, 5]; 368 | let o = truncate((lon + 180) / 90 + 1); 369 | let m = (lon + 720) % 90 - 45; // meridian 370 | 371 | const s = sign(m); 372 | m = abs(m); 373 | if (o === 5) o = 1; 374 | if (lat < 0) o = south[o]; 375 | return [m, abs(lat), s, o]; 376 | } 377 | 378 | function zoneA(m, p) { 379 | return [CK.pointA[0] + (90 - p) * 104, 0]; 380 | } 381 | 382 | function zoneB(m, p) { 383 | return [CK.pointG[0] - p * 100, 0]; 384 | } 385 | 386 | function zoneC(m, p) { 387 | const l = 104 * (90 - p); 388 | return [ 389 | CK.pointA[0] + l * cos(m * radians), 390 | CK.pointA[1] + l * sin(m * radians) 391 | ]; 392 | } 393 | 394 | function zoneD(m /*, p */) { 395 | // p = p; // just keep it for symmetry in signature 396 | return equator(m); 397 | } 398 | 399 | function zoneE(m, p) { 400 | const l = 1560 + (75 - p) * 100; 401 | return [ 402 | CK.pointA[0] + l * cos(m * radians), 403 | CK.pointA[1] + l * sin(m * radians) 404 | ]; 405 | } 406 | 407 | function zoneF(m, p) { 408 | return interpolate(p, 15, CK.pointE, CK.pointD); 409 | } 410 | 411 | function zoneG(m, p) { 412 | const l = p - 15; 413 | return interpolate(l, 58, CK.pointD, CK.pointT); 414 | } 415 | 416 | function zoneH(m, p) { 417 | const p75 = parallel75(45), 418 | p73a = parallel73(m), 419 | p73 = p73a.parallel73, 420 | lF = distance(CK.pointT, CK.pointB), 421 | lF75 = distance(CK.pointB, p75), 422 | l = (75 - p) * (lF75 + lF) / 2; 423 | return (l <= lF75) 424 | ? interpolate(l, lF75, p75, CK.pointB) 425 | : interpolate(l - lF75, lF, CK.pointB, p73); 426 | } 427 | 428 | function zoneI(m, p) { 429 | const p73a = parallel73(m), 430 | lT = lengthTorridSegment(m), 431 | lM = lengthMiddleSegment(m), 432 | l = p * (lT + lM + p73a.lengthParallel73) / 73; 433 | return (l <= lT) 434 | ? interpolate(l, lT, jointE(m), jointT(m)) 435 | : (l <= lT + lM) 436 | ? interpolate(l - lT, lM, jointT(m), jointF(m)) 437 | : interpolate(l - lT - lM, p73a.lengthParallel73, jointF(m), p73a.parallel73); 438 | } 439 | 440 | function zoneJ(m, p) { 441 | const p75 = parallel75(m), 442 | lF75 = distance(jointF(m), p75), 443 | p73a = parallel73(m), 444 | p73 = p73a.parallel73, 445 | lF = p73a.lengthParallel73, 446 | l = (75 - p) * (lF75 - lF) / 2; 447 | 448 | return (l <= lF75) 449 | ? interpolate(l, lF75, p75, jointF(m)) 450 | : interpolate(l - lF75, -lF, jointF(m), p73); 451 | } 452 | 453 | function zoneK(m, p, l15) { 454 | const l = p * l15 / 15, 455 | lT = lengthTorridSegment(m), 456 | lM = lengthMiddleSegment(m); 457 | return (l <= lT) 458 | // point is in torrid segment 459 | ? interpolate(l, lT, jointE(m), jointT(m)) 460 | // point is in middle segment 461 | : interpolate(l - lT, lM, jointT(m), jointF(m)); 462 | } 463 | 464 | function zoneL(m, p, l15) { 465 | const p73a = parallel73(m), 466 | p73 = p73a.parallel73, 467 | lT = lengthTorridSegment(m), 468 | lM = lengthMiddleSegment(m), 469 | lF = p73a.lengthParallel73, 470 | l = l15 + (p - 15) * (lT + lM + lF - l15) / 58; 471 | return (l <= lT) 472 | // on torrid segment 473 | ? interpolate(l, lT, jointE(m), jointF(m)) 474 | : (l <= lT + lM) 475 | // on middle segment 476 | ? interpolate(l - lT, lM, jointT(m), jointF(m)) 477 | // on frigid segment 478 | : interpolate(l - lT - lM, lF, jointF(m), p73); 479 | } 480 | 481 | // convert half-octant meridian,parallel to x,y coordinates. 482 | // arguments are meridian, parallel 483 | 484 | function mp2xy(m, p) { 485 | // zones (a) and (b) 486 | if (m === 0) return (p >= 75) ? zoneA(m, p) : zoneB(m, p); 487 | else if (p >= 75) return zoneC(m, p); 488 | else if (p === 0) return zoneD(m, p); 489 | else if (p >= 73 && m <= 30) return zoneE(m, p); 490 | else if (m === 45) return (p <= 15) ? zoneF(m, p) : (p <= 73) ? zoneG(m, p) : zoneH(m, p); 491 | else { 492 | if (m <= 29) return zoneI(m, p); 493 | else { 494 | // supple zones (j), (k) and (l) 495 | if (p >= 73) return zoneJ(m, p); 496 | else { 497 | const lT = lengthTorridSegment(m); 498 | let l15; 499 | 500 | //zones (k) and (l) 501 | let p15a = circleLineIntersection( 502 | CK.pointC, 503 | CK.radius, 504 | jointT(m), 505 | jointF(m) 506 | ); 507 | let flag15 = p15a[0]; 508 | let p15 = p15a[1]; 509 | if (flag15 === 1) { 510 | // intersection is in middle segment 511 | l15 = lT + distance(jointT(m), p15); 512 | } else { 513 | // intersection is in torrid segment 514 | p15a = circleLineIntersection( 515 | CK.pointC, 516 | CK.radius, 517 | jointE(m), 518 | jointT(m) 519 | ); 520 | flag15 = p15a[0]; 521 | p15 = p15a[1]; 522 | if (flag15 === 0) { 523 | //console.log("Something weird!"); 524 | // TODO: Trap this! Something odd happened! 525 | } 526 | l15 = lT - distance(jointT(m), p15); 527 | } 528 | return (p <= 15) ? zoneK(m, p, l15) : zoneL(m, p, l15); 529 | } 530 | } 531 | } 532 | } 533 | 534 | // from half-octant to megamap (single rotated octant) 535 | 536 | function mj2g(xy, octant) { 537 | let xynew; 538 | 539 | if (octant === 0) { 540 | xynew = rotate(xy, -60); 541 | } else if (octant === 1) { 542 | xynew = rotate(xy, -120); 543 | xynew[0] -= CK.lengthMG; 544 | } else if (octant === 2) { 545 | xynew = rotate(xy, -60); 546 | xynew[0] -= CK.lengthMG; 547 | } else if (octant === 3) { 548 | xynew = rotate(xy, -120); 549 | xynew[0] += CK.lengthMG; 550 | } else if (octant === 4) { 551 | xynew = rotate(xy, -60); 552 | xynew[0] += CK.lengthMG; 553 | } else if (octant === 5) { 554 | xynew = rotate([2 * CK.lengthMG - xy[0], xy[1]], -60); 555 | xynew[0] += CK.lengthMG; 556 | } else if (octant === 6) { 557 | xynew = rotate([2 * CK.lengthMG - xy[0], xy[1]], -120); 558 | xynew[0] -= CK.lengthMG; 559 | } else if (octant === 7) { 560 | xynew = rotate([2 * CK.lengthMG - xy[0], xy[1]], -60); 561 | xynew[0] -= CK.lengthMG; 562 | } else if (octant === 8) { 563 | xynew = rotate([2 * CK.lengthMG - xy[0], xy[1]], -120); 564 | xynew[0] += CK.lengthMG; 565 | } 566 | return xynew; 567 | } 568 | 569 | // general CK map projection 570 | 571 | function forward(lambda, phi) { 572 | // lambda, phi are in radians. 573 | const lon = lambda * degrees, 574 | lat = phi * degrees, 575 | res = ll2mp(lon, lat), 576 | m = res[0], // 0 ≤ m ≤ 45 577 | p = res[1], // 0 ≤ p ≤ 90 578 | s = res[2], // -1 / 1 = side of m 579 | o = res[3], // octant 580 | xy = mp2xy(m, p); 581 | return mj2g([xy[0], s * xy[1]], o); 582 | } 583 | 584 | forward.invert = solve2d(forward); 585 | 586 | return forward; 587 | } 588 | 589 | function cahillKeyesProjection() { 590 | const mg = 10000; 591 | const m = projectionMutator(cahillKeyesRaw); 592 | return m(mg); 593 | } 594 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # d3-geo-polygon 2 | 3 | Clipping and geometric operations for spherical polygons. 4 | 5 | 6 | 7 | world map 8 | 9 | 10 | ```js run=false 11 | const projection = geoDodecahedral(); 12 | ``` 13 | 14 | This module introduces a dozen projections that need polygon clipping. It can also be used to clip a projection with an arbitrary polygon: 15 | 16 | ```js run=false 17 | const projection = geoEquirectangular() 18 | .preclip(geoClipPolygon({ 19 | type: "Polygon", 20 | coordinates: [[[-10, -10], [-10, 10], [10, 10], [10, -10], [-10, -10]]] 21 | })); 22 | ``` 23 | 24 | ## Installing 25 | 26 | In Observable Framework, [import](https://observablehq.com/framework/imports) with the `npm:` protocol: 27 | 28 | ```html run=false 29 | 33 | ``` 34 | 35 | If you use npm, `npm install d3-geo-polygon`. You can also download the [latest release on GitHub](https://github.com/d3/d3-geo-polygon/releases/latest). For vanilla HTML in modern browsers, import d3-geo-polygon from Skypack: 36 | 37 | ```html run=false 38 | 42 | ``` 43 | 44 | For legacy environments, you can load d3-geo-projection’s UMD bundle from an npm-based CDN such as jsDelivr; a `d3` global is exported: 45 | 46 | ```html run=false 47 | 48 | 49 | 50 | 55 | ``` 56 | 57 | 58 | 59 | Daily downloads of d3-geo-polygon 60 | 61 | 62 | Daily downloads of d3-geo-polygon · [oss-analytics](https://observablehq.observablehq.cloud/oss-analytics/@d3/d3-geo-polygon) 63 | 64 | ## API Reference 65 | 66 | # d3.geoClipPolygon(polygon) · [Source](https://github.com/d3/d3-geo-polygon/blob/main/src/clip/polygon.js), [Examples](https://observablehq.com/@d3/spherical-clipping) 67 | 68 | Given a GeoJSON *polygon* or *multipolygon*, returns a clip function suitable for [_projection_.preclip](https://d3js.org/d3-geo/projection#projection_preclip). 69 | 70 | # clip.polygon([geometry]) 71 | 72 | If geometry is specified, sets the clipping polygon to the geometry and returns a new clip function. Otherwise returns the clipping polygon. 73 | 74 | # clip.clipPoint([clipPoint]) 75 | 76 | Whether the projection should clip points. If clipPoint is false, the clip function only clips line and polygon geometries. If clipPoint is true, points outside the clipping polygon are not projected. Typically set to false when the projection covers the whole sphere, to make sure that all points —even those on the edge of the clipping polygon— get projected. 77 | 78 | # d3.geoIntersectArc(arcs) · [Source](https://github.com/d3/d3-geo-polygon/blob/main/src/intersect.js), [Examples](https://observablehq.com/@fil/spherical-intersection) 79 | 80 | Given two spherical arcs [point0, point1] and [point2, point3], returns their intersection, or undefined if there is none. See “[Spherical Intersection](https://observablehq.com/@fil/spherical-intersection)”. 81 | 82 | ## Projections 83 | 84 | New projections are introduced: 85 | 86 | # d3.geoPolyhedralVoronoi([parents], [polygons], [faceProjection], [faceFind]) · [Source](https://github.com/d3/d3-geo-polygon/blob/main/src/polyhedral/voronoi.js) 87 | 88 | Returns a polyhedral projection based on the *polygons*, arranged in a tree. 89 | 90 | The tree is specified by passing *parents*, an array of indices indicating the parent of each face. The root of the tree is the first face without a parent (with the array typically specifying -1). 91 | 92 | *polygons* are a GeoJSON FeatureCollection of geoVoronoi cells, which should indicate the corresponding sites (see [d3-geo-voronoi](https://github.com/Fil/d3-geo-voronoi)). An optional [_faceProjection_](#geoPolyhedral) is passed to d3.geoPolyhedral() -- note that the gnomonic projection on the polygons’ sites is the only faceProjection that works in the general case. 93 | 94 | The .parents([parents]), .polygons([polygons]), .faceProjection([faceProjection]) set and read the corresponding options. Use .faceFind(voronoi.find) for faster results. 95 | 96 | # d3.geoCubic() · [Source](https://github.com/d3/d3-geo-polygon/blob/main/src/cubic.js), [Examples](https://observablehq.com/@fil/cubic-projections) 97 | 98 | [world map](https://observablehq.com/@fil/cubic-projections) 99 | 100 | The cubic projection. 101 | 102 | # d3.geoDodecahedral() · [Source](https://github.com/d3/d3-geo-polygon/blob/main/src/polyhedral/dodecahedral.js), [Examples](https://observablehq.com/@fil/dodecahedral-projection) 103 | 104 | [world map](https://observablehq.com/@fil/dodecahedral-projection) 105 | 106 | The pentagonal dodecahedral projection. 107 | 108 | # d3.geoRhombic() · [Source](https://github.com/d3/d3-geo-polygon/blob/main/src/polyhedral/rhombic.js), [Examples](https://observablehq.com/d/881a8431e638b408) 109 | 110 | [world map](https://observablehq.com/d/881a8431e638b408) 111 | 112 | The rhombic dodecahedral projection. 113 | 114 | # d3.geoDeltoidal() · [Source](https://github.com/d3/d3-geo-polygon/blob/main/src/polyhedral/deltoidal.js), [Examples](https://observablehq.com/d/881a8431e638b408) 115 | 116 | [world map](https://observablehq.com/d/881a8431e638b408) 117 | 118 | The deltoidal hexecontahedral projection. 119 | 120 | # d3.geoIcosahedral() · [Source](https://github.com/d3/d3-geo-polygon/blob/main/src/icosahedral.js), [Examples](https://observablehq.com/@fil/icosahedral-projections) 121 | 122 | [world map](https://observablehq.com/@fil/icosahedral-projections) 123 | 124 | The icosahedral projection. 125 | 126 | # d3.geoAirocean() · [Source](https://github.com/d3/d3-geo-polygon/blob/main/src/airocean.js), [Examples](https://observablehq.com/@fil/airocean-projection) 127 | 128 | [world map](https://observablehq.com/@fil/airocean-projection) 129 | 130 | Buckminster Fuller’s Airocean projection (also known as “Dymaxion”), based on a very specific arrangement of the icosahedron which allows continuous continent shapes. Fuller’s triangle transformation, as formulated by Robert W. Gray (and implemented by Philippe Rivière), makes the projection almost equal-area. 131 | 132 | # d3.geoCahillKeyes() · [Source](https://github.com/d3/d3-geo-polygon/blob/main/src/cahillKeyes.js), [Examples](https://observablehq.com/@d3/cahill-keyes) 133 |
# d3.geoCahillKeyes 134 | 135 | [world map](https://www.genekeyes.com/) 136 | 137 | 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). 138 | 139 | # d3.geoImago() · [Source](https://github.com/d3/d3-geo-polygon/blob/main/src/imago.js), [Examples](https://observablehq.com/@fil/the-imago-projection) 140 | 141 | [world map](https://kunimune.home.blog/2017/11/23/the-secrets-of-the-authagraph-revealed/) 142 | 143 | The Imago projection, engineered by Justin Kunimune (2017), is inspired by Hajime Narukawa’s AuthaGraph design (1999). 144 | 145 | # imago.k([k]) 146 | 147 | Exponent. Useful values include 0.59 (default, minimizes angular distortion of the continents), 0.68 (gives the closest approximation of the AuthaGraph) and 0.72 (prevents kinks in the graticule). 148 | 149 | # imago.shift([shift]) 150 | 151 | Horizontal shift. Defaults to 1.16. 152 | 153 | # d3.geoTetrahedralLee() · [Source](https://github.com/d3/d3-geo-polygon/blob/main/src/tetrahedralLee.js), [Examples](https://observablehq.com/@fil/lee-projection) 154 |
# d3.geoLeeRaw 155 | 156 | [world map](https://observablehq.com/@d3/lees-tetrahedral) 157 | 158 | Lee’s tetrahedral conformal projection. 159 | 160 | # Default angle is +30°, apex up (-30° for base up, apex down). 161 | 162 | Default aspect uses _projection_.rotate([30, 180]) and has the North Pole at the triangle’s center -- use _projection_.rotate([-30, 0]) for the [South aspect](https://observablehq.com/@fil/lee-projection). 163 | 164 | # d3.geoCox() · [Source](https://github.com/d3/d3-geo-polygon/blob/main/src/cox.js), [Examples](https://observablehq.com/@fil/cox-conformal-projection-in-a-triangle) 165 |
# d3.geoCoxRaw 166 | 167 | [world map](https://visionscarto.net/cox-conformal-projection) 168 | 169 | The Cox conformal projection. 170 | 171 | # d3.geoComplexLog([planarProjectionRaw[, cutoffLatitude]]) · [Source](https://github.com/d3/d3-geo-polygon/blob/main/src/complexLog.js), [Example](https://cgmi.github.io/complex-log-projection/) 172 |
# d3.geoComplexLogRaw([planarProjectionRaw]) 173 | 174 | [world map](https://cgmi.github.io/complex-log-projection/) 175 | 176 | Complex logarithmic view. This projection is based on the papers by Joachim Böttger et al.: 177 | 178 | - [Detail‐In‐Context Visualization for Satellite Imagery (2008)](https://doi.org/10.1111/j.1467-8659.2008.01156.x) 179 | - [Complex Logarithmic Views for Small Details in Large Contexts (2006)](https://doi.org/10.1109/TVCG.2006.126) 180 | 181 | The specified raw projection *planarProjectionRaw* is used to project onto the complex plane on which the complex logarithm is applied. 182 | Recommended are [azimuthal equal-area](https://github.com/d3/d3-geo#geoAzimuthalEqualAreaRaw) (default) or [azimuthal equidistant](https://github.com/d3/d3-geo#geoAzimuthalEquidistantRaw). 183 | 184 | *cutoffLatitude* is the latitude relative to the projection center at which to cutoff/clip the projection, lower values result in more detail around the projection center. Value must be < 0 because complex log projects the origin to infinity. 185 | 186 | # complexLog.planarProjectionRaw([projectionRaw]) 187 | 188 | If *projectionRaw* is specified, sets the planar raw projection. See above. If *projectionRaw* is not specified, returns the current planar raw projection. 189 | 190 | # complexLog.cutoffLatitude([latitude]) 191 | 192 | If *latitude* is specified, sets the cutoff latitude. See above. If *latitude* is not specified, returns the current cutoff latitude. 193 | 194 | ## Updated projections 195 | 196 | d3-geo-polygon adds polygon clipping to the polyhedral and interrupted projections from [d3-geo-projection](https://github.com/d3/d3-geo-projection). Thus, it supersedes the following symbols: 197 | 198 | # d3.geoPolyhedral(tree, face) · [Source](https://github.com/d3/d3-geo-polygon/blob/main/src/polyhedral/index.js), [Examples](https://observablehq.com/@fil/polyhedral-projections-with-d3-geo-polygon) 199 | 200 | Defines a new polyhedral projection. The *tree* is a spanning tree of polygon face nodes; each *node* is assigned a *node*.transform matrix. The *face* function returns the appropriate *node* for a given *lambda* and *phi* in radians. 201 | 202 | Polyhedral projections’ default **clipPoint** depends on whether the clipping polygon covers the whole sphere. When the polygon’s area is almost complete (larger than 4π minus .1 steradian), clipPoint is set to false, and all point geometries are displayed, even if they (technically) fall outside the clipping polygon. For smaller polygons, clipPoint defaults to true, thus hiding points outside the clipping region. 203 | 204 | # polyhedral.tree() returns the spanning tree of the polyhedron, from which one can infer the faces’ centers, polygons, shared edges etc. 205 | 206 | # d3.geoPolyhedralButterfly() · [Source](https://github.com/d3/d3-geo-polygon/blob/main/src/polyhedral/butterfly.js) 207 | 208 | [world map](https://observablehq.com/@d3/polyhedral-gnomonic) 209 | 210 | The gnomonic butterfly projection. 211 | 212 | # d3.geoPolyhedralCollignon() · [Source](https://github.com/d3/d3-geo-polygon/blob/main/src/polyhedral/collignon.js) 213 | 214 | [world map](https://www.jasondavies.com/maps/collignon-butterfly/) 215 | 216 | The Collignon butterfly projection. 217 | 218 | # d3.geoPolyhedralWaterman() · [Source](https://github.com/d3/d3-geo-polygon/blob/main/src/polyhedral/waterman.js) 219 | 220 | [world map](https://www.jasondavies.com/maps/waterman-butterfly/) 221 | 222 | A butterfly projection inspired by Steve Waterman’s design. 223 | 224 | # d3.geoBerghaus · [Source](https://github.com/d3/d3-geo-polygon/blob/main/src/reclip.js) 225 | 226 | [world map](https://observablehq.com/@d3/interrupted-clipped) 227 | 228 | The Berghaus projection. 229 | 230 | # d3.geoGingery · [Source](https://github.com/d3/d3-geo-polygon/blob/main/src/reclip.js) 231 | 232 | [world map](https://observablehq.com/@d3/interrupted-clipped) 233 | 234 | The Gingery projection. 235 | 236 | # d3.geoHealpix · [Source](https://github.com/d3/d3-geo-polygon/blob/main/src/reclip.js) 237 | 238 | [world map](https://observablehq.com/@d3/interrupted-clipped) 239 | 240 | The HEALPix projection. 241 | 242 | # d3.geoInterruptedBoggs · [Source](https://github.com/d3/d3-geo-polygon/blob/main/src/reclip.js) 243 | 244 | [world map](https://observablehq.com/@d3/interrupted-clipped) 245 | 246 | Bogg’s interrupted eumorphic projection. 247 | 248 | # d3.geoInterruptedHomolosine · [Source](https://github.com/d3/d3-geo-polygon/blob/main/src/reclip.js) 249 | 250 | [world map](https://observablehq.com/@d3/interrupted-clipped) 251 | 252 | Goode’s interrupted homolosine projection. 253 | 254 | # d3.geoInterruptedMollweide · [Source](https://github.com/d3/d3-geo-polygon/blob/main/src/reclip.js) 255 | 256 | [world map](https://observablehq.com/@d3/interrupted-clipped) 257 | 258 | Goode’s interrupted Mollweide projection. 259 | 260 | # d3.geoInterruptedMollweideHemispheres · [Source](https://github.com/d3/d3-geo-polygon/blob/main/src/reclip.js) 261 | 262 | [world map](https://observablehq.com/@d3/interrupted-clipped) 263 | 264 | The Mollweide projection interrupted into two (equal-area) hemispheres. 265 | 266 | # d3.geoInterruptedSinuMollweide · [Source](https://github.com/d3/d3-geo-polygon/blob/main/src/reclip.js) 267 | 268 | [world map](https://observablehq.com/@d3/interrupted-clipped) 269 | 270 | Alan K. Philbrick’s interrupted sinu-Mollweide projection. 271 | 272 | # d3.geoInterruptedSinusoidal · [Source](https://github.com/d3/d3-geo-polygon/blob/main/src/reclip.js) 273 | 274 | [world map](https://observablehq.com/@d3/interrupted-clipped) 275 | 276 | An interrupted sinusoidal projection with asymmetrical lobe boundaries. 277 | 278 | # d3.geoTwoPointEquidistant(point0, point1) · [Source](https://github.com/d3/d3-geo-polygon/blob/main/src/reclip.js) 279 | 280 | The two-point equidistant projection, displaying 99.9996% of the sphere thanks to polygon clipping. 281 | 282 | # d3.geoTwoPointEquidistantUsa() · [Source](https://github.com/d3/d3-geo-polygon/blob/main/src/reclip.js) 283 | 284 | [world map](https://observablehq.com/@d3/two-point-equidistant) 285 | 286 | The two-point equidistant projection with points [-158°, 21.5°] and [-77°, 39°], approximately representing Honolulu, HI and Washington, D.C. 287 | 288 | *Note:* These re-clipped projections are not supported in the legacy UMD bundle. 289 | --------------------------------------------------------------------------------