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] 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"