Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit a209461

Browse files
committedJul 5, 2024·
Plot.poi uses @mapbox/polylabel to derive centroids
1 parent 8e52154 commit a209461

File tree

10 files changed

+769
-5
lines changed

10 files changed

+769
-5
lines changed
 

Diff for: ‎package.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,8 @@
8989
"dependencies": {
9090
"d3": "^7.9.0",
9191
"interval-tree-1d": "^1.0.0",
92-
"isoformat": "^0.2.0"
92+
"isoformat": "^0.2.0",
93+
"polylabel": "^2.0.0"
9394
},
9495
"engines": {
9596
"node": ">=12"

Diff for: ‎src/index.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ export {Vector, vector, vectorX, vectorY, spike} from "./marks/vector.js";
4141
export {valueof, column, identity, indexOf} from "./options.js";
4242
export {filter, reverse, sort, shuffle, basic as transform, initializer} from "./transforms/basic.js";
4343
export {bin, binX, binY} from "./transforms/bin.js";
44-
export {centroid, geoCentroid} from "./transforms/centroid.js";
44+
export {centroid, geoCentroid, poi} from "./transforms/centroid.js";
4545
export {dodgeX, dodgeY} from "./transforms/dodge.js";
4646
export {find, group, groupX, groupY, groupZ} from "./transforms/group.js";
4747
export {hexbin} from "./transforms/hexbin.js";

Diff for: ‎src/marks/geo.js

+2-2
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {negative, positive} from "../defined.js";
44
import {Mark} from "../mark.js";
55
import {identity, maybeNumberChannel} from "../options.js";
66
import {applyChannelStyles, applyDirectStyles, applyIndirectStyles, applyTransform} from "../style.js";
7-
import {centroid} from "../transforms/centroid.js";
7+
import {poi} from "../transforms/centroid.js";
88
import {withDefaultSort} from "./dot.js";
99

1010
const defaults = {
@@ -70,7 +70,7 @@ function scaleProjection({x: X, y: Y}) {
7070
}
7171

7272
export function geo(data, options = {}) {
73-
if (options.tip && options.x === undefined && options.y === undefined) options = centroid(options);
73+
if (options.tip && options.x === undefined && options.y === undefined) options = poi(options);
7474
else if (options.geometry === undefined) options = {...options, geometry: identity};
7575
return new Geo(data, options);
7676
}

Diff for: ‎src/transforms/centroid.d.ts

+15
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,21 @@ export interface CentroidOptions {
2020
*/
2121
export function centroid<T>(options?: T & CentroidOptions): Initialized<T>;
2222

23+
/**
24+
* Given a **geometry** input channel of GeoJSON geometry, derives **x** and
25+
* **y** output channels representing the point that gives the largest possible
26+
* ellipse of horizontal to vertical ratio 2 inscribed in Polygon or
27+
* MultiPolygon geometries, and the classic centroid for point and line
28+
* geometries. Usually a good place to anchor a label, an interactive tip, or a
29+
* representative dot for a voronoi mesh. The pois are computed in screen
30+
* coordinates according to the plot’s associated **projection** (or *x* and *y*
31+
* scales), if any.
32+
*
33+
* For classic centroids, see Plot.centroid; for centroids of spherical
34+
* geometry, see Plot.geoCentroid.
35+
*/
36+
export function poi<T>(options?: T & CentroidOptions): Initialized<T>;
37+
2338
/**
2439
* Given a **geometry** input channel of spherical GeoJSON geometry, derives
2540
* **x** and **y** output channels representing the spherical centroids of the

Diff for: ‎src/transforms/centroid.js

+51-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import {geoCentroid as GeoCentroid, geoPath} from "d3";
1+
import {geoCentroid as GeoCentroid, geoPath, greatest, polygonArea, polygonContains} from "d3";
22
import {memoize1} from "../memoize.js";
33
import {identity, valueof} from "../options.js";
44
import {initializer} from "./basic.js";
5+
import polylabel from "polylabel";
56

67
export function centroid({geometry = identity, ...options} = {}) {
78
const getG = memoize1((data) => valueof(data, geometry));
@@ -28,6 +29,55 @@ export function centroid({geometry = identity, ...options} = {}) {
2829
);
2930
}
3031

32+
export function poi({geometry = identity, ...options} = {}) {
33+
const getG = memoize1((data) => valueof(data, geometry));
34+
return initializer(
35+
{...options, x: null, y: null, geometry: {transform: getG}},
36+
(data, facets, channels, scales, dimensions, {projection}) => {
37+
const G = getG(data);
38+
const n = G.length;
39+
const X = new Float64Array(n);
40+
const Y = new Float64Array(n);
41+
let polygons, holes, ring;
42+
const alpha = 2;
43+
const context = {
44+
arc() {},
45+
moveTo(x, y) {
46+
ring = [[x, -alpha * y]];
47+
},
48+
lineTo(x, y) {
49+
ring.push([x, -alpha * y]);
50+
},
51+
closePath() {
52+
ring.push(ring[0]);
53+
if (polygonArea(ring) > 0) polygons.push([ring]);
54+
else holes.push(ring);
55+
}
56+
};
57+
const path = geoPath(projection, context);
58+
for (let i = 0; i < n; ++i) {
59+
polygons = [];
60+
holes = [];
61+
path(G[i]);
62+
for (const h of holes) polygons.find(([ring]) => polygonContains(ring, h[0]))?.push(h);
63+
const a = greatest(
64+
polygons.map((d) => polylabel(d, 0.01)),
65+
(d) => d.distance
66+
);
67+
[X[i], Y[i]] = a ? [a[0], -a[1] / alpha] : path.centroid(G[i]);
68+
}
69+
return {
70+
data,
71+
facets,
72+
channels: {
73+
x: {value: X, scale: projection == null ? "x" : null, source: null},
74+
y: {value: Y, scale: projection == null ? "y" : null, source: null}
75+
}
76+
};
77+
}
78+
);
79+
}
80+
3181
export function geoCentroid({geometry = identity, ...options} = {}) {
3282
const getG = memoize1((data) => valueof(data, geometry));
3383
const getC = memoize1((data) => valueof(getG(data), GeoCentroid));

Diff for: ‎test/output/countryPois.svg

+503
Loading

Diff for: ‎test/output/geoTipPoi.svg

+142
Loading

Diff for: ‎test/plots/country-centroids.ts

+17
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,20 @@ export async function countryCentroids() {
1818
]
1919
});
2020
}
21+
22+
export async function countryPois() {
23+
const world = await d3.json<any>("data/countries-110m.json");
24+
const land = feature(world, world.objects.land);
25+
const countries = feature(world, world.objects.countries);
26+
return Plot.plot({
27+
projection: "orthographic",
28+
marks: [
29+
Plot.graticule(),
30+
Plot.geo(land, {fill: "#ddd"}),
31+
Plot.geo(countries, {stroke: "#fff"}),
32+
Plot.text(countries, Plot.geoCentroid({fill: "red", text: "id"})),
33+
Plot.text(countries, Plot.poi({fill: "green", text: "id"})),
34+
Plot.frame()
35+
]
36+
});
37+
}

Diff for: ‎test/plots/geo-tip.ts

+24
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,30 @@ export async function geoTipCentroid() {
6060
});
6161
}
6262

63+
/** The geo mark with the tip option and the poi transform. */
64+
export async function geoTipPoi() {
65+
const [london, boroughs] = await getLondonBoroughs();
66+
const access = await getLondonAccess();
67+
return Plot.plot({
68+
width: 900,
69+
projection: {type: "transverse-mercator", rotate: [2, 0, 0], domain: london},
70+
color: {scheme: "RdYlBu", pivot: 0.5},
71+
marks: [
72+
Plot.geo(
73+
access,
74+
Plot.poi({
75+
fx: "year",
76+
geometry: (d) => boroughs.get(d.borough),
77+
fill: "access",
78+
stroke: "var(--plot-background)",
79+
strokeWidth: 0.75,
80+
channels: {borough: "borough"},
81+
tip: true
82+
})
83+
)
84+
]
85+
});
86+
}
6387
/** The geo mark with the tip option and the geoCentroid transform. */
6488
export async function geoTipGeoCentroid() {
6589
const [london, boroughs] = await getLondonBoroughs();

Diff for: ‎yarn.lock

+12
Original file line numberDiff line numberDiff line change
@@ -2896,6 +2896,13 @@ picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.3.1:
28962896
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42"
28972897
integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==
28982898

2899+
polylabel@^2.0.0:
2900+
version "2.0.0"
2901+
resolved "https://registry.yarnpkg.com/polylabel/-/polylabel-2.0.0.tgz#9c5f2a290a6b48b37b4b13c99ca5c4ea521facbb"
2902+
integrity sha512-iH6nrGEf8imzlmGqKJ0fXoTjmPREvjbEzAK8LflNvAN9NFwe9FRYpIa+R7l6DJRvkXh5P6/WimlO9YcK+JtIBQ==
2903+
dependencies:
2904+
tinyqueue "^2.0.3"
2905+
28992906
postcss@^8.4.38:
29002907
version "8.4.38"
29012908
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.38.tgz#b387d533baf2054288e337066d81c6bee9db9e0e"
@@ -3297,6 +3304,11 @@ text-table@^0.2.0:
32973304
resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4"
32983305
integrity sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==
32993306

3307+
tinyqueue@^2.0.3:
3308+
version "2.0.3"
3309+
resolved "https://registry.yarnpkg.com/tinyqueue/-/tinyqueue-2.0.3.tgz#64d8492ebf39e7801d7bd34062e29b45b2035f08"
3310+
integrity sha512-ppJZNDuKGgxzkHihX8v9v9G5f+18gzaTfrukGrq6ueg0lmH4nqVnA2IPG0AEH3jKEk2GRJCUhDoqpoiw3PHLBA==
3311+
33003312
to-regex-range@^5.0.1:
33013313
version "5.0.1"
33023314
resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4"

0 commit comments

Comments
 (0)
Please sign in to comment.