Skip to content

Commit e59b17a

Browse files
Filmbostock
andauthored
plot.scale("projection") (#2397)
* expose projection * polish * consolidate types * purtier * expose projection implementation, not options * purdier * restore projection.apply * projection.invert * restore Projection interface * test projection.stream * context.projection is Projection --------- Co-authored-by: Mike Bostock <mbostock@gmail.com>
1 parent 4445615 commit e59b17a

10 files changed

Lines changed: 231 additions & 29 deletions

File tree

docs/features/plots.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -313,7 +313,7 @@ const color = plot.scale("color"); // get the color scale
313313
console.log(color.range); // inspect the scale’s range
314314
```
315315

316-
Returns the [scale object](./scales.md#scale-options) for the scale with the specified *name* (such as *x* or *color*) on the given *plot*, where *plot* is a rendered plot element returned by [plot](#plot). If the associated *plot* has no scale with the given *name*, returns undefined.
316+
Given a rendered *plot* element returned by [plot](#plot), returns the *plot*’s [scale object](./scales.md#scale-options) for the scale with the specified *name* (such as *x* or *color*), or the [projection](./projections.md) if the *name* is *projection*. If the associated *plot* has no scale (or projection) with the given *name*, returns undefined.
317317

318318
## *plot*.legend(*name*, *options*) {#plot_legend}
319319

docs/features/projections.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,3 +274,19 @@ The following projection clipping methods are supported for **clip**:
274274
* null or false - do not clip
275275

276276
Whereas the **clip** [mark option](./marks.md#mark-options) is implemented using SVG clipping, the **clip** projection option affects the generated geometry and typically produces smaller SVG output.
277+
278+
## Materialized projection
279+
280+
After rendering, you can retrieve the materialized projection from a plot using [*plot*.scale](./plots.md#plot_scale):
281+
282+
```js
283+
const plot = Plot.plot({projection: "mercator", marks: [Plot.graticule()]});
284+
const projection = plot.scale("projection");
285+
```
286+
287+
The returned object exposes a *projection*.stream method (see d3-geo) that can be used to project geometry, as well as *projection*.apply(*point*) and (if supported) *projection*.invert(*point*). To reuse a projection across plots, pass the projection object as the **projection** option of another plot:
288+
289+
```js
290+
const plot1 = Plot.plot({projection: "mercator", marks: [Plot.graticule()]});
291+
const plot2 = Plot.plot({projection: plot1.scale("projection"), marks: [Plot.geo(land)]});
292+
```

src/context.d.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import type {GeoPath, GeoStreamWrapper} from "d3";
1+
import type {GeoPath} from "d3";
22
import type {MarkOptions} from "./mark.js";
3+
import type {Projection} from "./projection.js";
34

45
/** Additional rendering context provided to marks and initializers. */
56
export interface Context {
@@ -16,7 +17,7 @@ export interface Context {
1617
className: string;
1718

1819
/** The current projection, if any. */
19-
projection?: GeoStreamWrapper;
20+
projection?: Projection;
2021

2122
/** A function to draw GeoJSON with the current projection, if any; otherwise, with the x and y scales. */
2223
path: () => GeoPath;

src/plot.d.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type {ChannelValue} from "./channel.js";
22
import type {ColorLegendOptions, LegendOptions, OpacityLegendOptions, SymbolLegendOptions} from "./legends.js";
33
import type {Data, MarkOptions, Markish} from "./mark.js";
44
import type {ProjectionFactory, ProjectionImplementation, ProjectionName, ProjectionOptions} from "./projection.js";
5+
import type {Projection} from "./projection.js";
56
import type {Scale, ScaleDefaults, ScaleName, ScaleOptions} from "./scales.js";
67

78
export interface PlotOptions extends ScaleDefaults {
@@ -406,6 +407,12 @@ export interface Plot {
406407
*/
407408
scale(name: ScaleName): Scale | undefined;
408409

410+
/**
411+
* Returns this plot’s projection, or undefined if this plot does not use a
412+
* projection.
413+
*/
414+
scale(name: "projection"): Projection | undefined;
415+
409416
/**
410417
* Generates a legend for the scale with the specified *name* and the given
411418
* *options*, returning either an SVG or HTML element depending on the scale

src/plot.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -340,7 +340,7 @@ export function plot(options = {}) {
340340
if ("value" in svg) (figure.value = svg.value), delete svg.value;
341341
}
342342

343-
figure.scale = exposeScales(scales.scales);
343+
figure.scale = exposeScales(scales.scales, context);
344344
figure.legend = exposeLegends(scaleDescriptors, context, options);
345345

346346
const w = consumeWarnings();

src/projection.d.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,3 +112,12 @@ export interface ProjectionOptions extends InsetOptions {
112112
*/
113113
clip?: boolean | number | "frame" | null;
114114
}
115+
116+
/** A materialized projection, as returned by plot.scale("projection"). */
117+
export interface Projection extends ProjectionImplementation {
118+
/** Returns the projected [x, y] coordinates for the given [longitude, latitude], if possible. */
119+
apply(point: [longitude: number, latitude: number]): [x: number, y: number] | null;
120+
121+
/** Returns the the unprojected [longitude, latitude] for the given [x, y] coordinates, if possible. */
122+
invert?(point: [x: number, y: number]): [longitude: number, latitude: number] | null;
123+
}

src/projection.js

Lines changed: 65 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ export function createProjection(
3838
dimensions
3939
) {
4040
if (projection == null) return;
41-
if (typeof projection.stream === "function") return projection; // d3 projection
41+
if (typeof projection.stream === "function") return exposeProjection(projection); // projection implementation
4242
let options;
4343
let domain;
4444
let clip = "frame";
@@ -80,34 +80,48 @@ export function createProjection(
8080
let tx = marginLeft + insetLeft;
8181
let ty = marginTop + insetTop;
8282
let transform;
83+
let k = 1;
8384

8485
// If a domain is specified, fit the projection to the frame.
8586
if (domain != null) {
8687
const [[x0, y0], [x1, y1]] = geoPath(projection).bounds(domain);
87-
const k = Math.min(dx / (x1 - x0), dy / (y1 - y0));
88+
k = Math.min(dx / (x1 - x0), dy / (y1 - y0));
8889
if (k > 0) {
8990
tx -= (k * (x0 + x1) - dx) / 2;
9091
ty -= (k * (y0 + y1) - dy) / 2;
91-
transform = geoTransform({
92-
point(x, y) {
93-
this.stream.point(x * k + tx, y * k + ty);
94-
}
95-
});
92+
transform = scaleAndTranslate(k, tx, ty);
9693
} else {
9794
warn(`Warning: the projection could not be fit to the specified domain; using the default scale.`);
9895
}
9996
}
10097

101-
transform ??=
102-
tx === 0 && ty === 0
103-
? identity()
104-
: geoTransform({
105-
point(x, y) {
106-
this.stream.point(x + tx, y + ty);
107-
}
108-
});
98+
transform ??= translate(tx, ty);
10999

110-
return {stream: (s) => projection.stream(transform.stream(clip(s)))};
100+
return {
101+
stream(s) {
102+
return projection.stream(transform.stream(clip(s)));
103+
},
104+
apply(p) {
105+
let result = null;
106+
this.stream({point: (...p) => (result = p)}).point(...p);
107+
return result;
108+
},
109+
...(projection.invert && {
110+
invert(p) {
111+
return projection.invert(transform.invert(p));
112+
}
113+
})
114+
};
115+
}
116+
117+
function exposeProjection(projection) {
118+
return typeof projection === "function"
119+
? {
120+
stream: (s) => projection.stream(s),
121+
apply: (p) => projection(p),
122+
...(projection.invert && {invert: (p) => projection.invert(p)})
123+
}
124+
: projection;
111125
}
112126

113127
function namedProjection(projection) {
@@ -195,15 +209,45 @@ function conicProjection(createProjection, kx, ky) {
195209
};
196210
}
197211

198-
const identity = constant({stream: (stream) => stream});
212+
const identity = constant({
213+
stream: (stream) => stream,
214+
invert: (point) => point
215+
});
199216

200-
const reflectY = constant(
201-
geoTransform({
217+
const reflectY = constant({
218+
...geoTransform({
202219
point(x, y) {
203220
this.stream.point(x, -y);
204221
}
205-
})
206-
);
222+
}),
223+
invert: ([x, y]) => [x, -y]
224+
});
225+
226+
function translate(tx, ty) {
227+
return tx === 0 && ty === 0
228+
? identity()
229+
: {
230+
...geoTransform({
231+
point(x, y) {
232+
this.stream.point(x + tx, y + ty);
233+
}
234+
}),
235+
invert: ([x, y]) => [x - tx, y - ty]
236+
};
237+
}
238+
239+
function scaleAndTranslate(k, tx, ty) {
240+
return k === 1
241+
? translate(tx, ty)
242+
: {
243+
...geoTransform({
244+
point(x, y) {
245+
this.stream.point(x * k + tx, y * k + ty);
246+
}
247+
}),
248+
invert: ([x, y]) => [(x - tx) / k, (y - ty) / k]
249+
};
250+
}
207251

208252
// Applies a point-wise projection to the given paired x and y channels.
209253
// Note: mutates values!

src/scales.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -532,10 +532,10 @@ export function scale(options = {}) {
532532
return scale;
533533
}
534534

535-
export function exposeScales(scales) {
535+
export function exposeScales(scales, context) {
536536
return (key) => {
537537
if (!registry.has((key = `${key}`))) throw new Error(`unknown scale: ${key}`);
538-
return scales[key];
538+
return (key === "projection" ? context : scales)[key];
539539
};
540540
}
541541

test/assert.js

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,10 +58,25 @@ async function doesNotWarnAsync(run) {
5858
return result;
5959
}
6060

61+
function allCloseTo(actual, expected, delta = 1e-6) {
62+
delta = Number(delta);
63+
actual = [...actual].map(Number);
64+
expected = [...expected].map(Number);
65+
assert(
66+
actual.length === expected.length && actual.every((a, i) => Math.abs(expected[i] - a) <= delta),
67+
`expected ${formatNumbers(actual)} to be close to ${formatNumbers(expected)} ±${delta}`
68+
);
69+
}
70+
71+
function formatNumbers(numbers) {
72+
return `[${numbers.map((n) => n.toFixed(6)).join(", ")}]`;
73+
}
74+
6175
export default {
6276
...assert,
6377
warns,
6478
warnsAsync,
6579
doesNotWarn,
66-
doesNotWarnAsync
80+
doesNotWarnAsync,
81+
allCloseTo
6782
};

test/scales/scales-test.js

Lines changed: 111 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import * as Plot from "@observablehq/plot";
22
import * as d3 from "d3";
33
import assert from "../assert.js";
4-
import {it} from "vitest";
4+
import {describe, it} from "vitest";
55

66
// TODO Expose as d3.schemeObservable10, or Plot.scheme("observable10")?
77
const schemeObservable10 = [
@@ -2309,3 +2309,113 @@ function scaleApply(x, pairs) {
23092309
assert.strictEqual(+x.invert(output).toFixed(10), input);
23102310
}
23112311
}
2312+
2313+
describe("plot(…).scale('projection')", () => {
2314+
it("returns undefined when no projection is used", () => {
2315+
const plot = Plot.frame().plot();
2316+
assert.strictEqual(plot.scale("projection"), undefined);
2317+
});
2318+
2319+
it("returns the projection for a named projection", () => {
2320+
const plot = Plot.plot({projection: "mercator", marks: [Plot.graticule()]});
2321+
const projection = plot.scale("projection");
2322+
assert.strictEqual(d3.geoPath(projection)({type: "Point", coordinates: [-1.55, 47.22]}), "M316.749,224.179m0,4.5a4.5,4.5 0 1,1 0,-9a4.5,4.5 0 1,1 0,9z"); // prettier-ignore
2323+
assert.allCloseTo(projection.apply([-1.55, 47.22]), [316.74875, 224.179291]);
2324+
assert.allCloseTo(projection.invert([316.74875, 224.179291]), [-1.55, 47.22]);
2325+
});
2326+
2327+
it("returns the projection for a projection implementation", () => {
2328+
const plot = Plot.plot({projection: d3.geoMercator(), marks: [Plot.graticule()]});
2329+
const projection = plot.scale("projection");
2330+
assert.strictEqual(d3.geoPath(projection)({type: "Point", coordinates: [-1.55, 47.22]}), "M475.862,106.646m0,4.5a4.5,4.5 0 1,1 0,-9a4.5,4.5 0 1,1 0,9z"); // prettier-ignore
2331+
assert.allCloseTo(projection.apply([-1.55, 47.22]), [475.862361, 106.646008]);
2332+
assert.allCloseTo(projection.invert([475.862361, 106.646008]), [-1.55, 47.22]);
2333+
});
2334+
2335+
it("is the same for 'mercator' and {type: 'mercator'}", () => {
2336+
const projection1 = Plot.plot({projection: "mercator", marks: [Plot.graticule()]}).scale("projection");
2337+
const projection2 = Plot.plot({projection: {type: "mercator"}, marks: [Plot.graticule()]}).scale("projection");
2338+
assert.allCloseTo(projection1.apply([-1.55, 47.22]), projection2.apply([-1.55, 47.22]));
2339+
assert.allCloseTo(projection1.invert([316.74875, 224.179291]), projection2.invert([316.74875, 224.179291]));
2340+
});
2341+
2342+
it("exposes apply and invert for identity", () => {
2343+
const domain = {
2344+
type: "Polygon",
2345+
coordinates: [
2346+
[
2347+
[0, 0],
2348+
[200, 0],
2349+
[200, 100],
2350+
[0, 100],
2351+
[0, 0]
2352+
]
2353+
]
2354+
};
2355+
const plot = Plot.plot({
2356+
width: 400,
2357+
height: 200,
2358+
margin: 0,
2359+
projection: {type: "identity", domain},
2360+
marks: [Plot.frame()]
2361+
});
2362+
const p = plot.scale("projection");
2363+
assert.allCloseTo(p.apply([0, 0]), [0, 0]);
2364+
assert.allCloseTo(p.apply([200, 100]), [400, 200]);
2365+
assert.allCloseTo(p.apply([100, 50]), [200, 100]);
2366+
assert.allCloseTo(p.invert([0, 0]), [0, 0]);
2367+
assert.allCloseTo(p.invert([400, 200]), [200, 100]);
2368+
assert.allCloseTo(p.invert([200, 100]), [100, 50]);
2369+
});
2370+
2371+
it("exposes apply and invert for reflect-y", () => {
2372+
const domain = {
2373+
type: "Polygon",
2374+
coordinates: [
2375+
[
2376+
[0, 0],
2377+
[200, 0],
2378+
[200, 100],
2379+
[0, 100],
2380+
[0, 0]
2381+
]
2382+
]
2383+
};
2384+
const plot = Plot.plot({
2385+
width: 400,
2386+
height: 200,
2387+
margin: 0,
2388+
projection: {type: "reflect-y", domain},
2389+
marks: [Plot.frame()]
2390+
});
2391+
const p = plot.scale("projection");
2392+
assert.allCloseTo(p.apply([0, 0]), [0, 200]);
2393+
assert.allCloseTo(p.apply([200, 100]), [400, 0]);
2394+
assert.allCloseTo(p.apply([100, 50]), [200, 100]);
2395+
assert.allCloseTo(p.invert([0, 200]), [0, 0]);
2396+
assert.allCloseTo(p.invert([400, 0]), [200, 100]);
2397+
assert.allCloseTo(p.invert([200, 100]), [100, 50]);
2398+
});
2399+
2400+
it("round-trips to a second plot", () => {
2401+
const plot1 = Plot.plot({projection: "mercator", marks: [Plot.graticule()]});
2402+
const p1 = plot1.scale("projection");
2403+
const plot2 = Plot.plot({projection: p1, marks: [Plot.graticule()]});
2404+
const p2 = plot2.scale("projection");
2405+
// Same dimensions, so pixel coordinates match
2406+
const point = [-1.55, 47.22];
2407+
assert.allCloseTo(p1.apply(point), p2.apply(point));
2408+
});
2409+
2410+
it("round-trips with different dimensions", () => {
2411+
const plot1 = Plot.plot({width: 640, projection: "mercator", marks: [Plot.graticule()]});
2412+
const projection1 = plot1.scale("projection");
2413+
const plot2 = Plot.plot({width: 300, projection: projection1, marks: [Plot.graticule()]});
2414+
const projection2 = plot2.scale("projection");
2415+
// Different dimensions, but pixel coordinates still match
2416+
assert.allCloseTo(projection1.apply([-1.55, 47.22]), [316.74875, 224.179291]);
2417+
assert.allCloseTo(projection2.apply([-1.55, 47.22]), [316.74875, 224.179291]);
2418+
// But invert still round-trips
2419+
assert.allCloseTo(projection2.invert(projection2.apply([-1.55, 47.22])), [-1.55, 47.22]);
2420+
});
2421+
});

0 commit comments

Comments
 (0)