Skip to content

Commit

Permalink
promote geometry to x and y for tip (#2088)
Browse files Browse the repository at this point in the history
* promote geometry to x and y for tip

* update docs

* data provenance

* geo mark x & y

* move defaults to geo

---------

Co-authored-by: Philippe Rivière <fil@rezo.net>
  • Loading branch information
mbostock and Fil committed Jun 16, 2024
1 parent ca6fb81 commit 437e15f
Show file tree
Hide file tree
Showing 18 changed files with 1,119 additions and 93 deletions.
14 changes: 8 additions & 6 deletions docs/marks/geo.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ Plot.plot({
marks: [
Plot.geo(counties, {
fill: (d) => d.properties.unemployment,
title: (d) => `${d.properties.name}\n${d.properties.unemployment}%`
title: (d) => `${d.properties.name} ${d.properties.unemployment}%`,
tip: true
})
]
})
Expand Down Expand Up @@ -129,17 +130,16 @@ Plot.plot({
```
:::

The geo mark doesn’t have **x** and **y** channels; to derive those, for example to add [interactive tips](./tip.md), you can apply a [centroid transform](../transforms/centroid.md) on the geometries.
By default, the geo mark doesn’t have **x** and **y** channels; when you use the [**tip** option](./tip.md), the [centroid transform](../transforms/centroid.md) is implicitly applied on the geometries to compute the tip position by generating **x** and **y** channels. <VersionBadge pr="2088" /> You can alternatively specify these channels explicitly. The centroids are shown below in red.

:::plot defer https://observablehq.com/@observablehq/plot-state-centroids
```js
Plot.plot({
projection: "albers-usa",
marks: [
Plot.geo(statemesh, {strokeOpacity: 0.2}),
Plot.geo(states, {strokeOpacity: 0.1, tip: true, title: (d) => d.properties.name}),
Plot.geo(nation),
Plot.dot(states, Plot.centroid({fill: "red", stroke: "var(--vp-c-bg-alt)"})),
Plot.tip(states, Plot.pointer(Plot.centroid({title: (d) => d.properties.name})))
Plot.dot(states, Plot.centroid({fill: "red", stroke: "var(--vp-c-bg-alt)"}))
]
})
```
Expand All @@ -157,7 +157,7 @@ Plot.plot({
marks: [
Plot.geo(statemesh, {strokeOpacity: 0.2}),
Plot.geo(nation),
Plot.geo(walmarts, {fy: (d) => d.properties.date, r: 1.5, fill: "blue"}),
Plot.geo(walmarts, {fy: (d) => d.properties.date, r: 1.5, fill: "blue", tip: true, title: (d) => d.properties.date}),
Plot.axisFy({frameAnchor: "top", dy: 30, tickFormat: (d) => `${d.getUTCFullYear()}’s`})
]
})
Expand All @@ -176,6 +176,8 @@ The **geometry** channel specifies the geometry (GeoJSON object) to draw; if not

In addition to the [standard mark options](../features/marks.md#mark-options), the **r** option controls the size of Point and MultiPoint geometries. It can be specified as either a channel or constant. When **r** is specified as a number, it is interpreted as a constant radius in pixels; otherwise it is interpreted as a channel and the effective radius is controlled by the *r* scale. If the **r** option is not specified it defaults to 3 pixels. Geometries with a nonpositive radius are not drawn. If **r** is a channel, geometries will be sorted by descending radius by default.

The **x** and **y** position channels may also be specified in conjunction with the **tip** option. <VersionBadge pr="2088" /> These are bound to the *x* and *y* scale (or projection), respectively.

## geo(*data*, *options*) {#geo}

```js
Expand Down
14 changes: 14 additions & 0 deletions src/marks/geo.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,20 @@ export interface GeoOptions extends MarkOptions {
*/
geometry?: ChannelValue;

/**
* In conjunction with the tip option, the horizontal position channel
* specifying the tip’s anchor, typically bound to the *x* scale. If not
* specified, defaults to the centroid of the projected geometry.
*/
x?: ChannelValue;

/**
* In conjunction with the tip option, the vertical position channel
* specifying the tip’s anchor, typically bound to the *y* scale. If not
* specified, defaults to the centroid of the projected geometry.
*/
y?: ChannelValue;

/**
* The size of Point and MultiPoint geometries, defaulting to a constant 3
* pixels. If **r** is a number, it is interpreted as a constant radius in
Expand Down
13 changes: 9 additions & 4 deletions src/marks/geo.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +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 {withDefaultSort} from "./dot.js";

const defaults = {
Expand All @@ -22,8 +23,10 @@ export class Geo extends Mark {
super(
data,
{
geometry: {value: options.geometry, scale: "projection"},
r: {value: vr, scale: "r", filter: positive, optional: true}
x: {value: options.tip ? options.x : null, scale: "x", optional: true},
y: {value: options.tip ? options.y : null, scale: "y", optional: true},
r: {value: vr, scale: "r", filter: positive, optional: true},
geometry: {value: options.geometry, scale: "projection"}
},
withDefaultSort(options),
defaults
Expand Down Expand Up @@ -66,7 +69,7 @@ function scaleProjection({x: X, y: Y}) {
}
}

export function geo(data, {geometry = identity, ...options} = {}) {
export function geo(data, options = {}) {
switch (data?.type) {
case "FeatureCollection":
data = data.features;
Expand All @@ -85,7 +88,9 @@ export function geo(data, {geometry = identity, ...options} = {}) {
data = [data];
break;
}
return new Geo(data, {geometry, ...options});
if (options.tip && options.x === undefined && options.y === undefined) options = centroid(options);
else if (options.geometry === undefined) options = {...options, geometry: identity};
return new Geo(data, options);
}

export function sphere({strokeWidth = 1.5, ...options} = {}) {
Expand Down
1 change: 1 addition & 0 deletions src/marks/tip.js
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,7 @@ function getSourceChannels(channels, scales) {
// Then fallback to all other (non-ignored) channels.
for (const key in channels) {
if (key in sources || key in format || ignoreChannels.has(key)) continue;
if ((key === "x" || key === "y") && channels.geometry) continue; // ignore x & y on geo
const source = getSource(channels, key);
if (source) {
// Ignore color channels if the values are all literal colors.
Expand Down
20 changes: 19 additions & 1 deletion src/memoize.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,25 @@
const unset = Symbol("unset");

export function memoize1(compute) {
return (compute.length === 1 ? memoize1Arg : memoize1Args)(compute);
}

function memoize1Arg(compute) {
let cacheValue;
let cacheKey = unset;
return (key) => {
if (!Object.is(cacheKey, key)) {
cacheKey = key;
cacheValue = compute(key);
}
return cacheValue;
};
}

function memoize1Args(compute) {
let cacheValue, cacheKeys;
return (...keys) => {
if (cacheKeys?.length !== keys.length || cacheKeys.some((k, i) => k !== keys[i])) {
if (cacheKeys?.length !== keys.length || cacheKeys.some((k, i) => !Object.is(k, keys[i]))) {
cacheKeys = keys;
cacheValue = compute(...keys);
}
Expand Down
48 changes: 28 additions & 20 deletions src/transforms/centroid.js
Original file line number Diff line number Diff line change
@@ -1,32 +1,40 @@
import {geoCentroid as GeoCentroid, geoPath} from "d3";
import {memoize1} from "../memoize.js";
import {identity, valueof} from "../options.js";
import {initializer} from "./basic.js";

export function centroid({geometry = identity, ...options} = {}) {
// Suppress defaults for x and y since they will be computed by the initializer.
return initializer({...options, x: null, y: null}, (data, facets, channels, scales, dimensions, {projection}) => {
const G = valueof(data, geometry);
const n = G.length;
const X = new Float64Array(n);
const Y = new Float64Array(n);
const path = geoPath(projection);
for (let i = 0; i < n; ++i) [X[i], Y[i]] = 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}
}
};
});
const getG = memoize1((data) => valueof(data, geometry));
return initializer(
// Suppress defaults for x and y since they will be computed by the initializer.
// Propagate the (memoized) geometry channel in case it’s still needed.
{...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);
const path = geoPath(projection);
for (let i = 0; i < n; ++i) [X[i], Y[i]] = 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} = {}) {
let C;
const getG = memoize1((data) => valueof(data, geometry));
const getC = memoize1((data) => valueof(getG(data), GeoCentroid));
return {
...options,
x: {transform: (data) => Float64Array.from((C = valueof(valueof(data, geometry), GeoCentroid)), ([x]) => x)},
y: {transform: () => Float64Array.from(C, ([, y]) => y)}
x: {transform: (data) => Float64Array.from(getC(data), ([x]) => x)},
y: {transform: (data) => Float64Array.from(getC(data), ([, y]) => y)},
geometry: {transform: getG}
};
}
8 changes: 8 additions & 0 deletions test/data/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,14 @@ CBO
https://www.cbo.gov/topics/budget/accuracy-projections
https://observablehq.com/@tophtucker/examples-of-bitemporal-charts

## london.json
giCentre, City University of London
https://github.com/gicentre/data

## london-car-access.csv
Derived by Jo Wood from UK Census data
https://github.com/observablehq/plot/pull/2086

## metros.csv
The New York Times
https://www.nytimes.com/2019/12/02/upshot/wealth-poverty-divide-american-cities.html
Expand Down
34 changes: 34 additions & 0 deletions test/data/london-car-access.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
borough,y2001,y2011,y2021
City of London,0.380,0.306,0.228
Barking and Dagenham,0.621,0.604,0.652
Barnet,0.733,0.713,0.701
Bexley,0.763,0.763,0.776
Brent,0.627,0.570,0.559
Bromley,0.770,0.765,0.771
Camden,0.444,0.389,0.364
Croydon,0.702,0.665,0.664
Ealing,0.683,0.647,0.632
Enfield,0.715,0.675,0.690
Greenwich,0.592,0.580,0.569
Hammersmith and Fulham,0.514,0.448,0.425
Haringey,0.535,0.482,0.473
Harrow,0.773,0.765,0.753
Havering,0.767,0.770,0.785
Hillingdon,0.783,0.773,0.777
Hounslow,0.714,0.684,0.672
Islington,0.424,0.353,0.331
Kensington and Chelsea,0.496,0.440,0.417
Kingston upon Thames,0.762,0.749,0.743
Lambeth,0.491,0.422,0.420
Lewisham,0.572,0.519,0.523
Merton,0.699,0.674,0.670
Newham,0.511,0.479,0.483
Redbridge,0.738,0.721,0.725
Richmond upon Thames,0.763,0.753,0.746
Southwark,0.481,0.416,0.397
Sutton,0.767,0.766,0.772
Tower Hamlets,0.432,0.370,0.336
Waltham Forest,0.610,0.581,0.579
Wandsworth,0.593,0.547,0.521
Westminster,0.436,0.371,0.338
Hackney,0.440,0.354,0.351
Loading

0 comments on commit 437e15f

Please sign in to comment.