Skip to content

Commit 437e15f

Browse files
mbostockFil
andauthored
promote geometry to x and y for tip (#2088)
* 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>
1 parent ca6fb81 commit 437e15f

File tree

18 files changed

+1119
-93
lines changed

18 files changed

+1119
-93
lines changed

docs/marks/geo.md

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,8 @@ Plot.plot({
4949
marks: [
5050
Plot.geo(counties, {
5151
fill: (d) => d.properties.unemployment,
52-
title: (d) => `${d.properties.name}\n${d.properties.unemployment}%`
52+
title: (d) => `${d.properties.name} ${d.properties.unemployment}%`,
53+
tip: true
5354
})
5455
]
5556
})
@@ -129,17 +130,16 @@ Plot.plot({
129130
```
130131
:::
131132

132-
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.
133+
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.
133134

134135
:::plot defer https://observablehq.com/@observablehq/plot-state-centroids
135136
```js
136137
Plot.plot({
137138
projection: "albers-usa",
138139
marks: [
139-
Plot.geo(statemesh, {strokeOpacity: 0.2}),
140+
Plot.geo(states, {strokeOpacity: 0.1, tip: true, title: (d) => d.properties.name}),
140141
Plot.geo(nation),
141-
Plot.dot(states, Plot.centroid({fill: "red", stroke: "var(--vp-c-bg-alt)"})),
142-
Plot.tip(states, Plot.pointer(Plot.centroid({title: (d) => d.properties.name})))
142+
Plot.dot(states, Plot.centroid({fill: "red", stroke: "var(--vp-c-bg-alt)"}))
143143
]
144144
})
145145
```
@@ -157,7 +157,7 @@ Plot.plot({
157157
marks: [
158158
Plot.geo(statemesh, {strokeOpacity: 0.2}),
159159
Plot.geo(nation),
160-
Plot.geo(walmarts, {fy: (d) => d.properties.date, r: 1.5, fill: "blue"}),
160+
Plot.geo(walmarts, {fy: (d) => d.properties.date, r: 1.5, fill: "blue", tip: true, title: (d) => d.properties.date}),
161161
Plot.axisFy({frameAnchor: "top", dy: 30, tickFormat: (d) => `${d.getUTCFullYear()}’s`})
162162
]
163163
})
@@ -176,6 +176,8 @@ The **geometry** channel specifies the geometry (GeoJSON object) to draw; if not
176176

177177
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.
178178

179+
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.
180+
179181
## geo(*data*, *options*) {#geo}
180182

181183
```js

src/marks/geo.d.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,20 @@ export interface GeoOptions extends MarkOptions {
1010
*/
1111
geometry?: ChannelValue;
1212

13+
/**
14+
* In conjunction with the tip option, the horizontal position channel
15+
* specifying the tip’s anchor, typically bound to the *x* scale. If not
16+
* specified, defaults to the centroid of the projected geometry.
17+
*/
18+
x?: ChannelValue;
19+
20+
/**
21+
* In conjunction with the tip option, the vertical position channel
22+
* specifying the tip’s anchor, typically bound to the *y* scale. If not
23+
* specified, defaults to the centroid of the projected geometry.
24+
*/
25+
y?: ChannelValue;
26+
1327
/**
1428
* The size of Point and MultiPoint geometries, defaulting to a constant 3
1529
* pixels. If **r** is a number, it is interpreted as a constant radius in

src/marks/geo.js

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +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";
78
import {withDefaultSort} from "./dot.js";
89

910
const defaults = {
@@ -22,8 +23,10 @@ export class Geo extends Mark {
2223
super(
2324
data,
2425
{
25-
geometry: {value: options.geometry, scale: "projection"},
26-
r: {value: vr, scale: "r", filter: positive, optional: true}
26+
x: {value: options.tip ? options.x : null, scale: "x", optional: true},
27+
y: {value: options.tip ? options.y : null, scale: "y", optional: true},
28+
r: {value: vr, scale: "r", filter: positive, optional: true},
29+
geometry: {value: options.geometry, scale: "projection"}
2730
},
2831
withDefaultSort(options),
2932
defaults
@@ -66,7 +69,7 @@ function scaleProjection({x: X, y: Y}) {
6669
}
6770
}
6871

69-
export function geo(data, {geometry = identity, ...options} = {}) {
72+
export function geo(data, options = {}) {
7073
switch (data?.type) {
7174
case "FeatureCollection":
7275
data = data.features;
@@ -85,7 +88,9 @@ export function geo(data, {geometry = identity, ...options} = {}) {
8588
data = [data];
8689
break;
8790
}
88-
return new Geo(data, {geometry, ...options});
91+
if (options.tip && options.x === undefined && options.y === undefined) options = centroid(options);
92+
else if (options.geometry === undefined) options = {...options, geometry: identity};
93+
return new Geo(data, options);
8994
}
9095

9196
export function sphere({strokeWidth = 1.5, ...options} = {}) {

src/marks/tip.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,7 @@ function getSourceChannels(channels, scales) {
344344
// Then fallback to all other (non-ignored) channels.
345345
for (const key in channels) {
346346
if (key in sources || key in format || ignoreChannels.has(key)) continue;
347+
if ((key === "x" || key === "y") && channels.geometry) continue; // ignore x & y on geo
347348
const source = getSource(channels, key);
348349
if (source) {
349350
// Ignore color channels if the values are all literal colors.

src/memoize.js

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,25 @@
1+
const unset = Symbol("unset");
2+
13
export function memoize1(compute) {
4+
return (compute.length === 1 ? memoize1Arg : memoize1Args)(compute);
5+
}
6+
7+
function memoize1Arg(compute) {
8+
let cacheValue;
9+
let cacheKey = unset;
10+
return (key) => {
11+
if (!Object.is(cacheKey, key)) {
12+
cacheKey = key;
13+
cacheValue = compute(key);
14+
}
15+
return cacheValue;
16+
};
17+
}
18+
19+
function memoize1Args(compute) {
220
let cacheValue, cacheKeys;
321
return (...keys) => {
4-
if (cacheKeys?.length !== keys.length || cacheKeys.some((k, i) => k !== keys[i])) {
22+
if (cacheKeys?.length !== keys.length || cacheKeys.some((k, i) => !Object.is(k, keys[i]))) {
523
cacheKeys = keys;
624
cacheValue = compute(...keys);
725
}

src/transforms/centroid.js

Lines changed: 28 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,40 @@
11
import {geoCentroid as GeoCentroid, geoPath} from "d3";
2+
import {memoize1} from "../memoize.js";
23
import {identity, valueof} from "../options.js";
34
import {initializer} from "./basic.js";
45

56
export function centroid({geometry = identity, ...options} = {}) {
6-
// Suppress defaults for x and y since they will be computed by the initializer.
7-
return initializer({...options, x: null, y: null}, (data, facets, channels, scales, dimensions, {projection}) => {
8-
const G = valueof(data, geometry);
9-
const n = G.length;
10-
const X = new Float64Array(n);
11-
const Y = new Float64Array(n);
12-
const path = geoPath(projection);
13-
for (let i = 0; i < n; ++i) [X[i], Y[i]] = path.centroid(G[i]);
14-
return {
15-
data,
16-
facets,
17-
channels: {
18-
x: {value: X, scale: projection == null ? "x" : null, source: null},
19-
y: {value: Y, scale: projection == null ? "y" : null, source: null}
20-
}
21-
};
22-
});
7+
const getG = memoize1((data) => valueof(data, geometry));
8+
return initializer(
9+
// Suppress defaults for x and y since they will be computed by the initializer.
10+
// Propagate the (memoized) geometry channel in case it’s still needed.
11+
{...options, x: null, y: null, geometry: {transform: getG}},
12+
(data, facets, channels, scales, dimensions, {projection}) => {
13+
const G = getG(data);
14+
const n = G.length;
15+
const X = new Float64Array(n);
16+
const Y = new Float64Array(n);
17+
const path = geoPath(projection);
18+
for (let i = 0; i < n; ++i) [X[i], Y[i]] = path.centroid(G[i]);
19+
return {
20+
data,
21+
facets,
22+
channels: {
23+
x: {value: X, scale: projection == null ? "x" : null, source: null},
24+
y: {value: Y, scale: projection == null ? "y" : null, source: null}
25+
}
26+
};
27+
}
28+
);
2329
}
2430

2531
export function geoCentroid({geometry = identity, ...options} = {}) {
26-
let C;
32+
const getG = memoize1((data) => valueof(data, geometry));
33+
const getC = memoize1((data) => valueof(getG(data), GeoCentroid));
2734
return {
2835
...options,
29-
x: {transform: (data) => Float64Array.from((C = valueof(valueof(data, geometry), GeoCentroid)), ([x]) => x)},
30-
y: {transform: () => Float64Array.from(C, ([, y]) => y)}
36+
x: {transform: (data) => Float64Array.from(getC(data), ([x]) => x)},
37+
y: {transform: (data) => Float64Array.from(getC(data), ([, y]) => y)},
38+
geometry: {transform: getG}
3139
};
3240
}

test/data/README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,14 @@ CBO
115115
https://www.cbo.gov/topics/budget/accuracy-projections
116116
https://observablehq.com/@tophtucker/examples-of-bitemporal-charts
117117

118+
## london.json
119+
giCentre, City University of London
120+
https://github.com/gicentre/data
121+
122+
## london-car-access.csv
123+
Derived by Jo Wood from UK Census data
124+
https://github.com/observablehq/plot/pull/2086
125+
118126
## metros.csv
119127
The New York Times
120128
https://www.nytimes.com/2019/12/02/upshot/wealth-poverty-divide-american-cities.html

test/data/london-car-access.csv

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
borough,y2001,y2011,y2021
2+
City of London,0.380,0.306,0.228
3+
Barking and Dagenham,0.621,0.604,0.652
4+
Barnet,0.733,0.713,0.701
5+
Bexley,0.763,0.763,0.776
6+
Brent,0.627,0.570,0.559
7+
Bromley,0.770,0.765,0.771
8+
Camden,0.444,0.389,0.364
9+
Croydon,0.702,0.665,0.664
10+
Ealing,0.683,0.647,0.632
11+
Enfield,0.715,0.675,0.690
12+
Greenwich,0.592,0.580,0.569
13+
Hammersmith and Fulham,0.514,0.448,0.425
14+
Haringey,0.535,0.482,0.473
15+
Harrow,0.773,0.765,0.753
16+
Havering,0.767,0.770,0.785
17+
Hillingdon,0.783,0.773,0.777
18+
Hounslow,0.714,0.684,0.672
19+
Islington,0.424,0.353,0.331
20+
Kensington and Chelsea,0.496,0.440,0.417
21+
Kingston upon Thames,0.762,0.749,0.743
22+
Lambeth,0.491,0.422,0.420
23+
Lewisham,0.572,0.519,0.523
24+
Merton,0.699,0.674,0.670
25+
Newham,0.511,0.479,0.483
26+
Redbridge,0.738,0.721,0.725
27+
Richmond upon Thames,0.763,0.753,0.746
28+
Southwark,0.481,0.416,0.397
29+
Sutton,0.767,0.766,0.772
30+
Tower Hamlets,0.432,0.370,0.336
31+
Waltham Forest,0.610,0.581,0.579
32+
Wandsworth,0.593,0.547,0.521
33+
Westminster,0.436,0.371,0.338
34+
Hackney,0.440,0.354,0.351

0 commit comments

Comments
 (0)