Skip to content

Commit 56125df

Browse files
committed
Plot.poi uses @mapbox/polylabel to derive centroids
1 parent b2b587a commit 56125df

File tree

10 files changed

+769
-5
lines changed

10 files changed

+769
-5
lines changed

package.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,8 @@
8686
"dependencies": {
8787
"d3": "^7.9.0",
8888
"interval-tree-1d": "^1.0.0",
89-
"isoformat": "^0.2.0"
89+
"isoformat": "^0.2.0",
90+
"polylabel": "^2.0.0"
9091
},
9192
"engines": {
9293
"node": ">=12"

src/index.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ export {WaffleX, WaffleY, waffleX, waffleY} from "./marks/waffle.js";
4242
export {valueof, column, identity, indexOf} from "./options.js";
4343
export {filter, reverse, sort, shuffle, basic as transform, initializer} from "./transforms/basic.js";
4444
export {bin, binX, binY} from "./transforms/bin.js";
45-
export {centroid, geoCentroid} from "./transforms/centroid.js";
45+
export {centroid, geoCentroid, poi} from "./transforms/centroid.js";
4646
export {dodgeX, dodgeY} from "./transforms/dodge.js";
4747
export {find, group, groupX, groupY, groupZ} from "./transforms/group.js";
4848
export {hexbin} from "./transforms/hexbin.js";

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 = {
@@ -56,7 +56,7 @@ export class Geo extends Mark {
5656
}
5757

5858
export function geo(data, options = {}) {
59-
if (options.tip && options.x === undefined && options.y === undefined) options = centroid(options);
59+
if (options.tip && options.x === undefined && options.y === undefined) options = poi(options);
6060
else if (options.geometry === undefined) options = {...options, geometry: identity};
6161
return new Geo(data, options);
6262
}

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

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)),
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));

test/output/countryPois.svg

+503
Loading

test/output/geoTipPoi.svg

+142
Loading

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+
}

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();

yarn.lock

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

3032+
polylabel@^2.0.0:
3033+
version "2.0.1"
3034+
resolved "https://registry.yarnpkg.com/polylabel/-/polylabel-2.0.1.tgz#7c2f02b96bd50331a81990dcb9e134c05f996419"
3035+
integrity sha512-B6Yu+Bdl/8SGtjVhyUfZzD3DwciCS9SPVtHiNdt8idHHatvTHp5Ss8XGDRmQFtfF1ZQnfK+Cj5dXdpkUXBbXgA==
3036+
dependencies:
3037+
tinyqueue "^3.0.0"
3038+
30323039
postcss@^8.4.39, postcss@^8.4.40:
30333040
version "8.4.41"
30343041
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.41.tgz#d6104d3ba272d882fe18fc07d15dc2da62fa2681"
@@ -3455,6 +3462,11 @@ text-table@^0.2.0:
34553462
resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4"
34563463
integrity sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==
34573464

3465+
tinyqueue@^3.0.0:
3466+
version "3.0.0"
3467+
resolved "https://registry.yarnpkg.com/tinyqueue/-/tinyqueue-3.0.0.tgz#101ea761ccc81f979e29200929e78f1556e3661e"
3468+
integrity sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==
3469+
34583470
to-fast-properties@^2.0.0:
34593471
version "2.0.0"
34603472
resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-2.0.0.tgz#dc5e698cbd079265bc73e0377681a4e4e83f616e"

0 commit comments

Comments
 (0)