Skip to content

Commit

Permalink
Plot.poi uses @mapbox/polylabel to derive centroids
Browse files Browse the repository at this point in the history
  • Loading branch information
Fil committed Nov 24, 2024
1 parent b2b587a commit 441f25c
Show file tree
Hide file tree
Showing 10 changed files with 769 additions and 5 deletions.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
4 changes: 2 additions & 2 deletions src/marks/geo.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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);
}
Expand Down
15 changes: 15 additions & 0 deletions src/transforms/centroid.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,21 @@ export interface CentroidOptions {
*/
export function centroid<T>(options?: T & CentroidOptions): Initialized<T>;

/**
* 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<T>(options?: T & CentroidOptions): Initialized<T>;

/**
* Given a **geometry** input channel of spherical GeoJSON geometry, derives
* **x** and **y** output channels representing the spherical centroids of the
Expand Down
52 changes: 51 additions & 1 deletion src/transforms/centroid.js
Original file line number Diff line number Diff line change
@@ -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));
Expand All @@ -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));
Expand Down
503 changes: 503 additions & 0 deletions test/output/countryPois.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
142 changes: 142 additions & 0 deletions test/output/geoTipPoi.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
17 changes: 17 additions & 0 deletions test/plots/country-centroids.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,20 @@ export async function countryCentroids() {
]
});
}

export async function countryPois() {
const world = await d3.json<any>("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()
]
});
}
24 changes: 24 additions & 0 deletions test/plots/geo-tip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
12 changes: 12 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down

0 comments on commit 441f25c

Please sign in to comment.