From 56125df0b87eae39f3c51a4048860cefdb51865d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Fri, 5 Jul 2024 17:22:20 +0200 Subject: [PATCH 1/2] Plot.poi uses @mapbox/polylabel to derive centroids --- package.json | 3 +- src/index.js | 2 +- src/marks/geo.js | 4 +- src/transforms/centroid.d.ts | 15 + src/transforms/centroid.js | 52 +++- test/output/countryPois.svg | 503 ++++++++++++++++++++++++++++++++ test/output/geoTipPoi.svg | 142 +++++++++ test/plots/country-centroids.ts | 17 ++ test/plots/geo-tip.ts | 24 ++ yarn.lock | 12 + 10 files changed, 769 insertions(+), 5 deletions(-) create mode 100644 test/output/countryPois.svg create mode 100644 test/output/geoTipPoi.svg diff --git a/package.json b/package.json index f493bcb9bb..f1ecbbb91c 100644 --- a/package.json +++ b/package.json @@ -86,7 +86,8 @@ "dependencies": { "d3": "^7.9.0", "interval-tree-1d": "^1.0.0", - "isoformat": "^0.2.0" + "isoformat": "^0.2.0", + "polylabel": "^2.0.0" }, "engines": { "node": ">=12" diff --git a/src/index.js b/src/index.js index a95fdbc035..2375e481da 100644 --- a/src/index.js +++ b/src/index.js @@ -42,7 +42,7 @@ export {WaffleX, WaffleY, waffleX, waffleY} from "./marks/waffle.js"; export {valueof, column, identity, indexOf} from "./options.js"; export {filter, reverse, sort, shuffle, basic as transform, initializer} from "./transforms/basic.js"; export {bin, binX, binY} from "./transforms/bin.js"; -export {centroid, geoCentroid} from "./transforms/centroid.js"; +export {centroid, geoCentroid, poi} from "./transforms/centroid.js"; export {dodgeX, dodgeY} from "./transforms/dodge.js"; export {find, group, groupX, groupY, groupZ} from "./transforms/group.js"; export {hexbin} from "./transforms/hexbin.js"; diff --git a/src/marks/geo.js b/src/marks/geo.js index 60252dd447..4eaef69de1 100644 --- a/src/marks/geo.js +++ b/src/marks/geo.js @@ -4,7 +4,7 @@ import {negative, positive} from "../defined.js"; import {Mark} from "../mark.js"; import {identity, maybeNumberChannel} from "../options.js"; import {applyChannelStyles, applyDirectStyles, applyIndirectStyles, applyTransform} from "../style.js"; -import {centroid} from "../transforms/centroid.js"; +import {poi} from "../transforms/centroid.js"; import {withDefaultSort} from "./dot.js"; const defaults = { @@ -56,7 +56,7 @@ export class Geo extends Mark { } export function geo(data, options = {}) { - if (options.tip && options.x === undefined && options.y === undefined) options = centroid(options); + if (options.tip && options.x === undefined && options.y === undefined) options = poi(options); else if (options.geometry === undefined) options = {...options, geometry: identity}; return new Geo(data, options); } diff --git a/src/transforms/centroid.d.ts b/src/transforms/centroid.d.ts index 03aceccc63..5933e285c2 100644 --- a/src/transforms/centroid.d.ts +++ b/src/transforms/centroid.d.ts @@ -20,6 +20,21 @@ export interface CentroidOptions { */ export function centroid(options?: T & CentroidOptions): Initialized; +/** + * Given a **geometry** input channel of GeoJSON geometry, derives **x** and + * **y** output channels representing the point that gives the largest possible + * ellipse of horizontal to vertical ratio 2 inscribed in Polygon or + * MultiPolygon geometries, and the classic centroid for point and line + * geometries. Usually a good place to anchor a label, an interactive tip, or a + * representative dot for a voronoi mesh. The pois are computed in screen + * coordinates according to the plot’s associated **projection** (or *x* and *y* + * scales), if any. + * + * For classic centroids, see Plot.centroid; for centroids of spherical + * geometry, see Plot.geoCentroid. + */ +export function poi(options?: T & CentroidOptions): Initialized; + /** * Given a **geometry** input channel of spherical GeoJSON geometry, derives * **x** and **y** output channels representing the spherical centroids of the diff --git a/src/transforms/centroid.js b/src/transforms/centroid.js index a7d745e64f..f9677a00c0 100644 --- a/src/transforms/centroid.js +++ b/src/transforms/centroid.js @@ -1,7 +1,8 @@ -import {geoCentroid as GeoCentroid, geoPath} from "d3"; +import {geoCentroid as GeoCentroid, geoPath, greatest, polygonArea, polygonContains} from "d3"; import {memoize1} from "../memoize.js"; import {identity, valueof} from "../options.js"; import {initializer} from "./basic.js"; +import polylabel from "polylabel"; export function centroid({geometry = identity, ...options} = {}) { const getG = memoize1((data) => valueof(data, geometry)); @@ -28,6 +29,55 @@ export function centroid({geometry = identity, ...options} = {}) { ); } +export function poi({geometry = identity, ...options} = {}) { + const getG = memoize1((data) => valueof(data, geometry)); + return initializer( + {...options, x: null, y: null, geometry: {transform: getG}}, + (data, facets, channels, scales, dimensions, {projection}) => { + const G = getG(data); + const n = G.length; + const X = new Float64Array(n); + const Y = new Float64Array(n); + let polygons, holes, ring; + const alpha = 2; + const context = { + arc() {}, + moveTo(x, y) { + ring = [[x, -alpha * y]]; + }, + lineTo(x, y) { + ring.push([x, -alpha * y]); + }, + closePath() { + ring.push(ring[0]); + if (polygonArea(ring) > 0) polygons.push([ring]); + else holes.push(ring); + } + }; + const path = geoPath(projection, context); + for (let i = 0; i < n; ++i) { + polygons = []; + holes = []; + path(G[i]); + for (const h of holes) polygons.find(([ring]) => polygonContains(ring, h[0]))?.push(h); + const a = greatest( + polygons.map((d) => polylabel(d)), + (d) => d.distance + ); + [X[i], Y[i]] = a ? [a[0], -a[1] / alpha] : path.centroid(G[i]); + } + return { + data, + facets, + channels: { + x: {value: X, scale: projection == null ? "x" : null, source: null}, + y: {value: Y, scale: projection == null ? "y" : null, source: null} + } + }; + } + ); +} + export function geoCentroid({geometry = identity, ...options} = {}) { const getG = memoize1((data) => valueof(data, geometry)); const getC = memoize1((data) => valueof(getG(data), GeoCentroid)); diff --git a/test/output/countryPois.svg b/test/output/countryPois.svg new file mode 100644 index 0000000000..4bb0c8f5c5 --- /dev/null +++ b/test/output/countryPois.svg @@ -0,0 +1,503 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 834 + 732 + 398 + 860 + 032 + 152 + 180 + 706 + 404 + 729 + 148 + 332 + 214 + 044 + 238 + 578 + 304 + 260 + 710 + 426 + 858 + 076 + 068 + 604 + 170 + 591 + 188 + 558 + 340 + 222 + 084 + 862 + 328 + 740 + 250 + 218 + 630 + 388 + 192 + 716 + 072 + 516 + 686 + 466 + 478 + 204 + 562 + 566 + 120 + 768 + 288 + 384 + 324 + 624 + 430 + 694 + 854 + 140 + 178 + 266 + 226 + 894 + 454 + 508 + 748 + 024 + 108 + 376 + 422 + 450 + 275 + 270 + 788 + 012 + 400 + 784 + 634 + 414 + 368 + 512 + 356 + 524 + 586 + 004 + 762 + 417 + 795 + 364 + 760 + 051 + 752 + 112 + 804 + 616 + 040 + 348 + 498 + 642 + 440 + 428 + 233 + 276 + 100 + 300 + 792 + 008 + 191 + 756 + 442 + 056 + 528 + 620 + 724 + 372 + 144 + 380 + 208 + 826 + 352 + 031 + 268 + 705 + 246 + 703 + 203 + 232 + 600 + 887 + 682 + 010 + 196 + 504 + 818 + 434 + 231 + 262 + 800 + 646 + 070 + 807 + 688 + 499 + 780 + 728 + + + 834 + 732 + 124 + 840 + 398 + 860 + 032 + 152 + 180 + 706 + 404 + 729 + 148 + 332 + 214 + 643 + 044 + 238 + 578 + 304 + 260 + 710 + 426 + 484 + 858 + 076 + 068 + 604 + 170 + 591 + 188 + 558 + 340 + 222 + 320 + 084 + 862 + 328 + 740 + 250 + 218 + 630 + 388 + 192 + 716 + 072 + 516 + 686 + 466 + 478 + 204 + 562 + 566 + 120 + 768 + 288 + 384 + 324 + 624 + 430 + 694 + 854 + 140 + 178 + 266 + 226 + 894 + 454 + 508 + 748 + 024 + 108 + 376 + 422 + 450 + 275 + 270 + 788 + 012 + 400 + 784 + 634 + 414 + 368 + 512 + 496 + 356 + 050 + 064 + 524 + 586 + 004 + 762 + 417 + 795 + 364 + 760 + 051 + 752 + 112 + 804 + 616 + 040 + 348 + 498 + 642 + 440 + 428 + 233 + 276 + 100 + 300 + 792 + 008 + 191 + 756 + 442 + 056 + 528 + 620 + 724 + 372 + 144 + 156 + 380 + 208 + 826 + 352 + 031 + 268 + 705 + 246 + 703 + 203 + 232 + 600 + 887 + 682 + 010 + 196 + 504 + 818 + 434 + 231 + 262 + 800 + 646 + 070 + 807 + 688 + 499 + 780 + 728 + + + \ No newline at end of file diff --git a/test/output/geoTipPoi.svg b/test/output/geoTipPoi.svg new file mode 100644 index 0000000000..e729853b92 --- /dev/null +++ b/test/output/geoTipPoi.svg @@ -0,0 +1,142 @@ + + + + + 2001 + + + 2011 + + + 2021 + + + + year + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/plots/country-centroids.ts b/test/plots/country-centroids.ts index 55dd28a407..1fe6365784 100644 --- a/test/plots/country-centroids.ts +++ b/test/plots/country-centroids.ts @@ -18,3 +18,20 @@ export async function countryCentroids() { ] }); } + +export async function countryPois() { + const world = await d3.json("data/countries-110m.json"); + const land = feature(world, world.objects.land); + const countries = feature(world, world.objects.countries); + return Plot.plot({ + projection: "orthographic", + marks: [ + Plot.graticule(), + Plot.geo(land, {fill: "#ddd"}), + Plot.geo(countries, {stroke: "#fff"}), + Plot.text(countries, Plot.geoCentroid({fill: "red", text: "id"})), + Plot.text(countries, Plot.poi({fill: "green", text: "id"})), + Plot.frame() + ] + }); +} diff --git a/test/plots/geo-tip.ts b/test/plots/geo-tip.ts index 3079418444..b900a4dd20 100644 --- a/test/plots/geo-tip.ts +++ b/test/plots/geo-tip.ts @@ -60,6 +60,30 @@ export async function geoTipCentroid() { }); } +/** The geo mark with the tip option and the poi transform. */ +export async function geoTipPoi() { + const [london, boroughs] = await getLondonBoroughs(); + const access = await getLondonAccess(); + return Plot.plot({ + width: 900, + projection: {type: "transverse-mercator", rotate: [2, 0, 0], domain: london}, + color: {scheme: "RdYlBu", pivot: 0.5}, + marks: [ + Plot.geo( + access, + Plot.poi({ + fx: "year", + geometry: (d) => boroughs.get(d.borough), + fill: "access", + stroke: "var(--plot-background)", + strokeWidth: 0.75, + channels: {borough: "borough"}, + tip: true + }) + ) + ] + }); +} /** The geo mark with the tip option and the geoCentroid transform. */ export async function geoTipGeoCentroid() { const [london, boroughs] = await getLondonBoroughs(); diff --git a/yarn.lock b/yarn.lock index 4d03086e4d..cbb46edd5b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3029,6 +3029,13 @@ picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.3.1: resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== +polylabel@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/polylabel/-/polylabel-2.0.1.tgz#7c2f02b96bd50331a81990dcb9e134c05f996419" + integrity sha512-B6Yu+Bdl/8SGtjVhyUfZzD3DwciCS9SPVtHiNdt8idHHatvTHp5Ss8XGDRmQFtfF1ZQnfK+Cj5dXdpkUXBbXgA== + dependencies: + tinyqueue "^3.0.0" + postcss@^8.4.39, postcss@^8.4.40: version "8.4.41" resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.41.tgz#d6104d3ba272d882fe18fc07d15dc2da62fa2681" @@ -3455,6 +3462,11 @@ text-table@^0.2.0: resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" integrity sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw== +tinyqueue@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/tinyqueue/-/tinyqueue-3.0.0.tgz#101ea761ccc81f979e29200929e78f1556e3661e" + integrity sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g== + to-fast-properties@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-2.0.0.tgz#dc5e698cbd079265bc73e0377681a4e4e83f616e" From 96de2813da2be5e10621bf66b5e5a55173d35b61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Thu, 16 Apr 2026 17:34:34 +0200 Subject: [PATCH 2/2] post-merge changes --- pnpm-lock.yaml | 15 + test/output/countryPois.svg | 594 ++++++++++++++++++------------------ 2 files changed, 312 insertions(+), 297 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d4b3762802..9fcd2ef2f5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: isoformat: specifier: ^0.2.0 version: 0.2.1 + polylabel: + specifier: ^2.0.0 + version: 2.0.1 devDependencies: '@eslint/eslintrc': specifier: ^3.2.0 @@ -2317,6 +2320,9 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + polylabel@2.0.1: + resolution: {integrity: sha512-B6Yu+Bdl/8SGtjVhyUfZzD3DwciCS9SPVtHiNdt8idHHatvTHp5Ss8XGDRmQFtfF1ZQnfK+Cj5dXdpkUXBbXgA==} + postcss@8.5.8: resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} engines: {node: ^10 || ^12 || >=14} @@ -2556,6 +2562,9 @@ packages: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinyqueue@3.0.0: + resolution: {integrity: sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==} + tinyrainbow@3.0.3: resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} engines: {node: '>=14.0.0'} @@ -4986,6 +4995,10 @@ snapshots: picomatch@4.0.3: {} + polylabel@2.0.1: + dependencies: + tinyqueue: 3.0.0 + postcss@8.5.8: dependencies: nanoid: 3.3.11 @@ -5253,6 +5266,8 @@ snapshots: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinyqueue@3.0.0: {} + tinyrainbow@3.0.3: {} tldts-core@7.0.27: {} diff --git a/test/output/countryPois.svg b/test/output/countryPois.svg index 4bb0c8f5c5..869200d90e 100644 --- a/test/output/countryPois.svg +++ b/test/output/countryPois.svg @@ -199,305 +199,305 @@ - 834 - 732 - 398 - 860 - 032 - 152 - 180 - 706 - 404 - 729 - 148 - 332 - 214 - 044 - 238 - 578 - 304 - 260 - 710 - 426 - 858 - 076 - 068 - 604 - 170 - 591 - 188 - 558 - 340 - 222 - 084 - 862 - 328 - 740 - 250 - 218 - 630 - 388 - 192 - 716 - 072 - 516 - 686 - 466 - 478 - 204 - 562 - 566 - 120 - 768 - 288 - 384 - 324 - 624 - 430 - 694 - 854 - 140 - 178 - 266 - 226 - 894 - 454 - 508 - 748 - 024 - 108 - 376 - 422 - 450 - 275 - 270 - 788 - 012 - 400 - 784 - 634 - 414 - 368 - 512 - 356 - 524 - 586 - 004 - 762 - 417 - 795 - 364 - 760 - 051 - 752 - 112 - 804 - 616 - 040 - 348 - 498 - 642 - 440 - 428 - 233 - 276 - 100 - 300 - 792 - 008 - 191 - 756 - 442 - 056 - 528 - 620 - 724 - 372 - 144 - 380 - 208 - 826 - 352 - 031 - 268 - 705 - 246 - 703 - 203 - 232 - 600 - 887 - 682 - 010 - 196 - 504 - 818 - 434 - 231 - 262 - 800 - 646 - 070 - 807 - 688 - 499 - 780 - 728 + 834 + 732 + 398 + 860 + 032 + 152 + 180 + 706 + 404 + 729 + 148 + 332 + 214 + 044 + 238 + 578 + 304 + 260 + 710 + 426 + 858 + 076 + 068 + 604 + 170 + 591 + 188 + 558 + 340 + 222 + 084 + 862 + 328 + 740 + 250 + 218 + 630 + 388 + 192 + 716 + 072 + 516 + 686 + 466 + 478 + 204 + 562 + 566 + 120 + 768 + 288 + 384 + 324 + 624 + 430 + 694 + 854 + 140 + 178 + 266 + 226 + 894 + 454 + 508 + 748 + 024 + 108 + 376 + 422 + 450 + 275 + 270 + 788 + 012 + 400 + 784 + 634 + 414 + 368 + 512 + 356 + 524 + 586 + 004 + 762 + 417 + 795 + 364 + 760 + 051 + 752 + 112 + 804 + 616 + 040 + 348 + 498 + 642 + 440 + 428 + 233 + 276 + 100 + 300 + 792 + 008 + 191 + 756 + 442 + 056 + 528 + 620 + 724 + 372 + 144 + 380 + 208 + 826 + 352 + 031 + 268 + 705 + 246 + 703 + 203 + 232 + 600 + 887 + 682 + 010 + 196 + 504 + 818 + 434 + 231 + 262 + 800 + 646 + 070 + 807 + 688 + 499 + 780 + 728 - 834 - 732 - 124 - 840 - 398 - 860 - 032 - 152 - 180 - 706 - 404 - 729 - 148 - 332 - 214 - 643 - 044 - 238 - 578 - 304 - 260 - 710 - 426 - 484 - 858 - 076 - 068 - 604 - 170 - 591 - 188 - 558 - 340 - 222 - 320 - 084 - 862 - 328 - 740 - 250 - 218 - 630 - 388 - 192 - 716 - 072 - 516 - 686 - 466 - 478 - 204 - 562 - 566 - 120 - 768 - 288 - 384 - 324 - 624 - 430 - 694 - 854 - 140 - 178 - 266 - 226 - 894 - 454 - 508 - 748 - 024 - 108 - 376 - 422 - 450 - 275 - 270 - 788 - 012 - 400 - 784 - 634 - 414 - 368 - 512 - 496 - 356 - 050 - 064 - 524 - 586 - 004 - 762 - 417 - 795 - 364 - 760 - 051 - 752 - 112 - 804 - 616 - 040 - 348 - 498 - 642 - 440 - 428 - 233 - 276 - 100 - 300 - 792 - 008 - 191 - 756 - 442 - 056 - 528 - 620 - 724 - 372 - 144 - 156 - 380 - 208 - 826 - 352 - 031 - 268 - 705 - 246 - 703 - 203 - 232 - 600 - 887 - 682 - 010 - 196 - 504 - 818 - 434 - 231 - 262 - 800 - 646 - 070 - 807 - 688 - 499 - 780 - 728 + 834 + 732 + 124 + 840 + 398 + 860 + 032 + 152 + 180 + 706 + 404 + 729 + 148 + 332 + 214 + 643 + 044 + 238 + 578 + 304 + 260 + 710 + 426 + 484 + 858 + 076 + 068 + 604 + 170 + 591 + 188 + 558 + 340 + 222 + 320 + 084 + 862 + 328 + 740 + 250 + 218 + 630 + 388 + 192 + 716 + 072 + 516 + 686 + 466 + 478 + 204 + 562 + 566 + 120 + 768 + 288 + 384 + 324 + 624 + 430 + 694 + 854 + 140 + 178 + 266 + 226 + 894 + 454 + 508 + 748 + 024 + 108 + 376 + 422 + 450 + 275 + 270 + 788 + 012 + 400 + 784 + 634 + 414 + 368 + 512 + 496 + 356 + 050 + 064 + 524 + 586 + 004 + 762 + 417 + 795 + 364 + 760 + 051 + 752 + 112 + 804 + 616 + 040 + 348 + 498 + 642 + 440 + 428 + 233 + 276 + 100 + 300 + 792 + 008 + 191 + 756 + 442 + 056 + 528 + 620 + 724 + 372 + 144 + 156 + 380 + 208 + 826 + 352 + 031 + 268 + 705 + 246 + 703 + 203 + 232 + 600 + 887 + 682 + 010 + 196 + 504 + 818 + 434 + 231 + 262 + 800 + 646 + 070 + 807 + 688 + 499 + 780 + 728 \ No newline at end of file