diff --git a/package.json b/package.json index e1da72155e..0bd69e3984 100644 --- a/package.json +++ b/package.json @@ -89,7 +89,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 4cca38b84f..f5147e542d 100644 --- a/src/index.js +++ b/src/index.js @@ -41,7 +41,7 @@ export {Vector, vector, vectorX, vectorY, spike} from "./marks/vector.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 854e3dcef5..eb49314e03 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 = { @@ -70,7 +70,7 @@ function scaleProjection({x: X, y: Y}) { } 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..1bbaa91313 --- /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 83ed001bb6..6cf938d31c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2896,6 +2896,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.38: version "8.4.38" resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.38.tgz#b387d533baf2054288e337066d81c6bee9db9e0e" @@ -3188,7 +3195,16 @@ speakingurl@^14.0.1: resolved "https://registry.yarnpkg.com/speakingurl/-/speakingurl-14.0.1.tgz#f37ec8ddc4ab98e9600c1c9ec324a8c48d772a53" integrity sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ== -"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -3213,7 +3229,14 @@ string_decoder@^1.1.1: dependencies: safe-buffer "~5.2.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -3297,6 +3320,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-regex-range@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" @@ -3530,7 +3558,16 @@ workerpool@6.2.1: resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.2.1.tgz#46fc150c17d826b86a008e5a4508656777e9c343" integrity sha512-ILEIE97kDZvF9Wb9f6h5aXK4swSlKGUcOEGiIYb2OOu/IrDU9iwj0fD//SsA6E5ibwJxpEvhullJY4Sl4GcpAw== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==