diff --git a/docs/features/plots.md b/docs/features/plots.md index 0902dadda9..a63528480f 100644 --- a/docs/features/plots.md +++ b/docs/features/plots.md @@ -313,7 +313,7 @@ const color = plot.scale("color"); // get the color scale console.log(color.range); // inspect the scale’s range ``` -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. +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. ## *plot*.legend(*name*, *options*) {#plot_legend} diff --git a/docs/features/projections.md b/docs/features/projections.md index 80a26e9079..c412c586b3 100644 --- a/docs/features/projections.md +++ b/docs/features/projections.md @@ -274,3 +274,19 @@ The following projection clipping methods are supported for **clip**: * null or false - do not clip 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. + +## Materialized projection + +After rendering, you can retrieve the materialized projection from a plot using [*plot*.scale](./plots.md#plot_scale): + +```js +const plot = Plot.plot({projection: "mercator", marks: [Plot.graticule()]}); +const projection = plot.scale("projection"); +``` + +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: + +```js +const plot1 = Plot.plot({projection: "mercator", marks: [Plot.graticule()]}); +const plot2 = Plot.plot({projection: plot1.scale("projection"), marks: [Plot.geo(land)]}); +``` diff --git a/src/context.d.ts b/src/context.d.ts index 53a1c01fee..1c79bbe34b 100644 --- a/src/context.d.ts +++ b/src/context.d.ts @@ -1,5 +1,6 @@ -import type {GeoPath, GeoStreamWrapper} from "d3"; +import type {GeoPath} from "d3"; import type {MarkOptions} from "./mark.js"; +import type {Projection} from "./projection.js"; /** Additional rendering context provided to marks and initializers. */ export interface Context { @@ -16,7 +17,7 @@ export interface Context { className: string; /** The current projection, if any. */ - projection?: GeoStreamWrapper; + projection?: Projection; /** A function to draw GeoJSON with the current projection, if any, otherwise with the x and y scales. */ path: () => GeoPath; diff --git a/src/plot.d.ts b/src/plot.d.ts index 8cec69b03f..535f385632 100644 --- a/src/plot.d.ts +++ b/src/plot.d.ts @@ -2,6 +2,7 @@ import type {ChannelValue} from "./channel.js"; import type {ColorLegendOptions, LegendOptions, OpacityLegendOptions, SymbolLegendOptions} from "./legends.js"; import type {Data, MarkOptions, Markish} from "./mark.js"; import type {ProjectionFactory, ProjectionImplementation, ProjectionName, ProjectionOptions} from "./projection.js"; +import type {Projection} from "./projection.js"; import type {Scale, ScaleDefaults, ScaleName, ScaleOptions} from "./scales.js"; export interface PlotOptions extends ScaleDefaults { @@ -406,6 +407,12 @@ export interface Plot { */ scale(name: ScaleName): Scale | undefined; + /** + * Returns this plot’s projection, or undefined if this plot does not use a + * projection. + */ + scale(name: "projection"): Projection | undefined; + /** * Generates a legend for the scale with the specified *name* and the given * *options*, returning either an SVG or HTML element depending on the scale diff --git a/src/plot.js b/src/plot.js index 16976c2585..2d6d9174bf 100644 --- a/src/plot.js +++ b/src/plot.js @@ -340,7 +340,7 @@ export function plot(options = {}) { if ("value" in svg) (figure.value = svg.value), delete svg.value; } - figure.scale = exposeScales(scales.scales); + figure.scale = exposeScales(scales.scales, context); figure.legend = exposeLegends(scaleDescriptors, context, options); const w = consumeWarnings(); diff --git a/src/projection.d.ts b/src/projection.d.ts index 8856c28c6b..b937b0e5f3 100644 --- a/src/projection.d.ts +++ b/src/projection.d.ts @@ -112,3 +112,12 @@ export interface ProjectionOptions extends InsetOptions { */ clip?: boolean | number | "frame" | null; } + +/** A materialized projection, as returned by plot.scale("projection"). */ +export interface Projection extends ProjectionImplementation { + /** Returns the projected [x, y] coordinates for the given [longitude, latitude], if possible. */ + apply(point: [longitude: number, latitude: number]): [x: number, y: number] | null; + + /** Returns the the unprojected [longitude, latitude] for the given [x, y] coordinates, if possible. */ + invert?(point: [x: number, y: number]): [longitude: number, latitude: number] | null; +} diff --git a/src/projection.js b/src/projection.js index 20e011101a..8dc01a0444 100644 --- a/src/projection.js +++ b/src/projection.js @@ -38,7 +38,7 @@ export function createProjection( dimensions ) { if (projection == null) return; - if (typeof projection.stream === "function") return projection; // d3 projection + if (typeof projection.stream === "function") return exposeProjection(projection); // projection implementation let options; let domain; let clip = "frame"; @@ -80,34 +80,48 @@ export function createProjection( let tx = marginLeft + insetLeft; let ty = marginTop + insetTop; let transform; + let k = 1; // If a domain is specified, fit the projection to the frame. if (domain != null) { const [[x0, y0], [x1, y1]] = geoPath(projection).bounds(domain); - const k = Math.min(dx / (x1 - x0), dy / (y1 - y0)); + k = Math.min(dx / (x1 - x0), dy / (y1 - y0)); if (k > 0) { tx -= (k * (x0 + x1) - dx) / 2; ty -= (k * (y0 + y1) - dy) / 2; - transform = geoTransform({ - point(x, y) { - this.stream.point(x * k + tx, y * k + ty); - } - }); + transform = scaleAndTranslate(k, tx, ty); } else { warn(`Warning: the projection could not be fit to the specified domain; using the default scale.`); } } - transform ??= - tx === 0 && ty === 0 - ? identity() - : geoTransform({ - point(x, y) { - this.stream.point(x + tx, y + ty); - } - }); + transform ??= translate(tx, ty); - return {stream: (s) => projection.stream(transform.stream(clip(s)))}; + return { + stream(s) { + return projection.stream(transform.stream(clip(s))); + }, + apply(p) { + let result = null; + this.stream({point: (...p) => (result = p)}).point(...p); + return result; + }, + ...(projection.invert && { + invert(p) { + return projection.invert(transform.invert(p)); + } + }) + }; +} + +function exposeProjection(projection) { + return typeof projection === "function" + ? { + stream: (s) => projection.stream(s), + apply: (p) => projection(p), + ...(projection.invert && {invert: (p) => projection.invert(p)}) + } + : projection; } function namedProjection(projection) { @@ -195,15 +209,45 @@ function conicProjection(createProjection, kx, ky) { }; } -const identity = constant({stream: (stream) => stream}); +const identity = constant({ + stream: (stream) => stream, + invert: (point) => point +}); -const reflectY = constant( - geoTransform({ +const reflectY = constant({ + ...geoTransform({ point(x, y) { this.stream.point(x, -y); } - }) -); + }), + invert: ([x, y]) => [x, -y] +}); + +function translate(tx, ty) { + return tx === 0 && ty === 0 + ? identity() + : { + ...geoTransform({ + point(x, y) { + this.stream.point(x + tx, y + ty); + } + }), + invert: ([x, y]) => [x - tx, y - ty] + }; +} + +function scaleAndTranslate(k, tx, ty) { + return k === 1 + ? translate(tx, ty) + : { + ...geoTransform({ + point(x, y) { + this.stream.point(x * k + tx, y * k + ty); + } + }), + invert: ([x, y]) => [(x - tx) / k, (y - ty) / k] + }; +} // Applies a point-wise projection to the given paired x and y channels. // Note: mutates values! diff --git a/src/scales.js b/src/scales.js index 7d01163ad0..d1fc1150c0 100644 --- a/src/scales.js +++ b/src/scales.js @@ -532,10 +532,10 @@ export function scale(options = {}) { return scale; } -export function exposeScales(scales) { +export function exposeScales(scales, context) { return (key) => { if (!registry.has((key = `${key}`))) throw new Error(`unknown scale: ${key}`); - return scales[key]; + return (key === "projection" ? context : scales)[key]; }; } diff --git a/test/assert.js b/test/assert.js index d6c39d299c..e7f59670be 100644 --- a/test/assert.js +++ b/test/assert.js @@ -58,10 +58,25 @@ async function doesNotWarnAsync(run) { return result; } +function allCloseTo(actual, expected, delta = 1e-6) { + delta = Number(delta); + actual = [...actual].map(Number); + expected = [...expected].map(Number); + assert( + actual.length === expected.length && actual.every((a, i) => Math.abs(expected[i] - a) <= delta), + `expected ${formatNumbers(actual)} to be close to ${formatNumbers(expected)} ±${delta}` + ); +} + +function formatNumbers(numbers) { + return `[${numbers.map((n) => n.toFixed(6)).join(", ")}]`; +} + export default { ...assert, warns, warnsAsync, doesNotWarn, - doesNotWarnAsync + doesNotWarnAsync, + allCloseTo }; diff --git a/test/scales/scales-test.js b/test/scales/scales-test.js index f1fc21eb8f..d3834a5cd6 100644 --- a/test/scales/scales-test.js +++ b/test/scales/scales-test.js @@ -1,7 +1,7 @@ import * as Plot from "@observablehq/plot"; import * as d3 from "d3"; import assert from "../assert.js"; -import {it} from "vitest"; +import {describe, it} from "vitest"; // TODO Expose as d3.schemeObservable10, or Plot.scheme("observable10")? const schemeObservable10 = [ @@ -2309,3 +2309,113 @@ function scaleApply(x, pairs) { assert.strictEqual(+x.invert(output).toFixed(10), input); } } + +describe("plot(…).scale('projection')", () => { + it("returns undefined when no projection is used", () => { + const plot = Plot.frame().plot(); + assert.strictEqual(plot.scale("projection"), undefined); + }); + + it("returns the projection for a named projection", () => { + const plot = Plot.plot({projection: "mercator", marks: [Plot.graticule()]}); + const projection = plot.scale("projection"); + 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 + assert.allCloseTo(projection.apply([-1.55, 47.22]), [316.74875, 224.179291]); + assert.allCloseTo(projection.invert([316.74875, 224.179291]), [-1.55, 47.22]); + }); + + it("returns the projection for a projection implementation", () => { + const plot = Plot.plot({projection: d3.geoMercator(), marks: [Plot.graticule()]}); + const projection = plot.scale("projection"); + 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 + assert.allCloseTo(projection.apply([-1.55, 47.22]), [475.862361, 106.646008]); + assert.allCloseTo(projection.invert([475.862361, 106.646008]), [-1.55, 47.22]); + }); + + it("is the same for 'mercator' and {type: 'mercator'}", () => { + const projection1 = Plot.plot({projection: "mercator", marks: [Plot.graticule()]}).scale("projection"); + const projection2 = Plot.plot({projection: {type: "mercator"}, marks: [Plot.graticule()]}).scale("projection"); + assert.allCloseTo(projection1.apply([-1.55, 47.22]), projection2.apply([-1.55, 47.22])); + assert.allCloseTo(projection1.invert([316.74875, 224.179291]), projection2.invert([316.74875, 224.179291])); + }); + + it("exposes apply and invert for identity", () => { + const domain = { + type: "Polygon", + coordinates: [ + [ + [0, 0], + [200, 0], + [200, 100], + [0, 100], + [0, 0] + ] + ] + }; + const plot = Plot.plot({ + width: 400, + height: 200, + margin: 0, + projection: {type: "identity", domain}, + marks: [Plot.frame()] + }); + const p = plot.scale("projection"); + assert.allCloseTo(p.apply([0, 0]), [0, 0]); + assert.allCloseTo(p.apply([200, 100]), [400, 200]); + assert.allCloseTo(p.apply([100, 50]), [200, 100]); + assert.allCloseTo(p.invert([0, 0]), [0, 0]); + assert.allCloseTo(p.invert([400, 200]), [200, 100]); + assert.allCloseTo(p.invert([200, 100]), [100, 50]); + }); + + it("exposes apply and invert for reflect-y", () => { + const domain = { + type: "Polygon", + coordinates: [ + [ + [0, 0], + [200, 0], + [200, 100], + [0, 100], + [0, 0] + ] + ] + }; + const plot = Plot.plot({ + width: 400, + height: 200, + margin: 0, + projection: {type: "reflect-y", domain}, + marks: [Plot.frame()] + }); + const p = plot.scale("projection"); + assert.allCloseTo(p.apply([0, 0]), [0, 200]); + assert.allCloseTo(p.apply([200, 100]), [400, 0]); + assert.allCloseTo(p.apply([100, 50]), [200, 100]); + assert.allCloseTo(p.invert([0, 200]), [0, 0]); + assert.allCloseTo(p.invert([400, 0]), [200, 100]); + assert.allCloseTo(p.invert([200, 100]), [100, 50]); + }); + + it("round-trips to a second plot", () => { + const plot1 = Plot.plot({projection: "mercator", marks: [Plot.graticule()]}); + const p1 = plot1.scale("projection"); + const plot2 = Plot.plot({projection: p1, marks: [Plot.graticule()]}); + const p2 = plot2.scale("projection"); + // Same dimensions, so pixel coordinates match + const point = [-1.55, 47.22]; + assert.allCloseTo(p1.apply(point), p2.apply(point)); + }); + + it("round-trips with different dimensions", () => { + const plot1 = Plot.plot({width: 640, projection: "mercator", marks: [Plot.graticule()]}); + const projection1 = plot1.scale("projection"); + const plot2 = Plot.plot({width: 300, projection: projection1, marks: [Plot.graticule()]}); + const projection2 = plot2.scale("projection"); + // Different dimensions, but pixel coordinates still match + assert.allCloseTo(projection1.apply([-1.55, 47.22]), [316.74875, 224.179291]); + assert.allCloseTo(projection2.apply([-1.55, 47.22]), [316.74875, 224.179291]); + // But invert still round-trips + assert.allCloseTo(projection2.invert(projection2.apply([-1.55, 47.22])), [-1.55, 47.22]); + }); +});