From da64d2e804d96aa5d9f38a9eca993da513fb02c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Fri, 27 Mar 2026 15:34:50 +0100 Subject: [PATCH] expose projection --- docs/features/plots.md | 2 +- docs/features/projections.md | 30 +++++++ src/plot.d.ts | 19 +++- src/plot.js | 4 + src/projection.d.ts | 39 ++++++++ src/projection.js | 52 +++++++++-- test/assert.js | 14 ++- test/scales/scales-test.js | 169 +++++++++++++++++++++++++++++++++++ 8 files changed, 317 insertions(+), 12 deletions(-) diff --git a/docs/features/plots.md b/docs/features/plots.md index 0902dadda9..04b1dc438b 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. +Returns the [scale object](./scales.md#scale-options) for the scale with the specified *name* (such as *x*, *color*, or *projection*) 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. ## *plot*.legend(*name*, *options*) {#plot_legend} diff --git a/docs/features/projections.md b/docs/features/projections.md index 80a26e9079..f559c144aa 100644 --- a/docs/features/projections.md +++ b/docs/features/projections.md @@ -274,3 +274,33 @@ 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 the resolved projection options, reflecting the actual values used to construct the projection. + +The projection object also exposes an **apply** method that projects a [*longitude*, *latitude*] point to [*x*, *y*] pixel coordinates: + +```js +projection.apply([-122.42, 37.78]) // San Francisco → [x, y] +``` + +An **invert** method is also available to convert [*x*, *y*] pixels back to coordinates: + +```js +projection.invert([320, 240]) // [x, y] → [longitude, latitude] +``` + +To reuse a projection across plots, pass the projection object as the **projection** option of another plot. The projection is reconstructed from the resolved options to fit the new plot's dimensions: + +```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/plot.d.ts b/src/plot.d.ts index 8cec69b03f..2871e610f0 100644 --- a/src/plot.d.ts +++ b/src/plot.d.ts @@ -1,7 +1,13 @@ 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 { + ProjectionFactory, + ProjectionImplementation, + ProjectionName, + ProjectionOptions, + ProjectionScale +} from "./projection.js"; import type {Scale, ScaleDefaults, ScaleName, ScaleOptions} from "./scales.js"; export interface PlotOptions extends ScaleDefaults { @@ -400,11 +406,20 @@ export interface PlotFacetOptions { * methods to allow sharing of scales and legends across plots. */ export interface Plot { + /** + * Returns this plot’s projection, or undefined if this plot does not use a + * projection. The returned object includes the resolved projection options + * (*type*, *domain*, *rotate*, etc.), an **apply** method for projecting + * [longitude, latitude] to [x, y] pixels, and when supported, an **invert** + * method for the reverse transformation. The object can be passed as the + * **projection** option of another plot to reuse the same projection. + */ + scale(name: "projection"): ProjectionScale | undefined; /** * Returns this plot’s scale with the given *name*, or undefined if this plot * does not use the specified scale. */ - scale(name: ScaleName): Scale | undefined; + scale(name: ScaleName | "projection"): Scale | ProjectionScale | undefined; /** * Generates a legend for the scale with the specified *name* and the given diff --git a/src/plot.js b/src/plot.js index 16976c2585..b361324159 100644 --- a/src/plot.js +++ b/src/plot.js @@ -340,6 +340,10 @@ export function plot(options = {}) { if ("value" in svg) (figure.value = svg.value), delete svg.value; } + if (context.projection) { + const {stream: _, ...projection} = context.projection; + scales.scales.projection = projection; + } figure.scale = exposeScales(scales.scales); figure.legend = exposeLegends(scaleDescriptors, context, options); diff --git a/src/projection.d.ts b/src/projection.d.ts index 8856c28c6b..509e201943 100644 --- a/src/projection.d.ts +++ b/src/projection.d.ts @@ -112,3 +112,42 @@ export interface ProjectionOptions extends InsetOptions { */ clip?: boolean | number | "frame" | null; } + +/** A materialized projection, as returned by plot.scale("projection"). */ +export interface ProjectionScale { + /** The projection type name or factory function. */ + type: ProjectionName | ProjectionFactory; + + /** The GeoJSON domain, if specified. */ + domain?: GeoPermissibleObjects; + + /** The projection rotation [lambda, phi, gamma]. */ + rotate?: [number, number, number?]; + + /** The standard parallels (conic projections). */ + parallels?: [number, number]; + + /** The sampling threshold. */ + precision?: number; + + /** The clipping method. */ + clip?: boolean | number | "frame" | null; + + /** Top inset in pixels. */ + insetTop: number; + + /** Right inset in pixels. */ + insetRight: number; + + /** Bottom inset in pixels. */ + insetBottom: number; + + /** Left inset in pixels. */ + insetLeft: number; + + /** Project [longitude, latitude] to [x, y] pixel coordinates. */ + apply(point: [number, number]): [number, number] | undefined; + + /** Invert [x, y] pixel coordinates to [longitude, latitude]. */ + invert?(point: [number, number]): [number, number] | undefined; +} diff --git a/src/projection.js b/src/projection.js index 20e011101a..d2a2eb0d5e 100644 --- a/src/projection.js +++ b/src/projection.js @@ -63,6 +63,8 @@ export function createProjection( if (projection == null) return; } + const type = projection; // save before namedProjection overwrites it + // For named projections, retrieve the corresponding projection initializer. if (typeof projection !== "function") ({type: projection} = namedProjection(projection)); @@ -74,17 +76,18 @@ export function createProjection( // The projection initializer might decide to not use a projection. if (projection == null) return; - clip = maybePostClip(clip, marginLeft, marginTop, width - marginRight, height - marginBottom); + const postClip = maybePostClip(clip, marginLeft, marginTop, width - marginRight, height - marginBottom); // Translate the origin to the top-left corner, respecting margins and insets. let tx = marginLeft + insetLeft; let ty = marginTop + insetTop; let transform; + let k; // 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; @@ -94,6 +97,7 @@ export function createProjection( } }); } else { + k = undefined; warn(`Warning: the projection could not be fit to the specified domain; using the default scale.`); } } @@ -107,7 +111,38 @@ export function createProjection( } }); - return {stream: (s) => projection.stream(transform.stream(clip(s)))}; + const stream = (s) => projection.stream(transform.stream(postClip(s))); + + return { + type, + ...(domain != null && {domain}), + ...(options?.rotate != null && {rotate: options.rotate}), + ...(options?.parallels != null && {parallels: options.parallels}), + ...(options?.precision != null && {precision: options.precision}), + ...(clip !== "frame" && {clip}), + ...(insetTop && {insetTop}), + ...(insetRight && {insetRight}), + ...(insetBottom && {insetBottom}), + ...(insetLeft && {insetLeft}), + stream, + apply([x, y] = []) { + let result; + const s = projection.stream( + transform.stream({ + point(x, y) { + result = [x, y]; + } + }) + ); + s.point(x, y); + return result; + }, + invert([x, y] = []) { + const px = (x - tx) / (k ?? 1); + const py = (y - ty) / (k ?? 1); + return projection.invert ? projection.invert([px, py]) : [px, py]; + } + }; } function namedProjection(projection) { @@ -195,15 +230,16 @@ 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] +}); // Applies a point-wise projection to the given paired x and y channels. // Note: mutates values! diff --git a/test/assert.js b/test/assert.js index d70d760d52..2f3f70a2cb 100644 --- a/test/assert.js +++ b/test/assert.js @@ -58,10 +58,22 @@ async function doesNotWarnAsync(run) { return result; } +function inDelta(actual, expected, delta = 1e-6) { + if (Array.isArray(expected)) { + assert.strictEqual(actual.length, expected.length); + for (let i = 0; i < expected.length; i++) { + inDelta(actual[i], expected[i], delta); + } + } else { + assert.ok(Math.abs(actual - expected) < delta, `${actual} is not within ${delta} of ${expected}`); + } +} + export default { ...assert, warns, warnsAsync, doesNotWarn, - doesNotWarnAsync + doesNotWarnAsync, + inDelta }; diff --git a/test/scales/scales-test.js b/test/scales/scales-test.js index 878987d363..df95915d40 100644 --- a/test/scales/scales-test.js +++ b/test/scales/scales-test.js @@ -2309,3 +2309,172 @@ 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 p = plot.scale("projection"); + assert.strictEqual(p.type, "mercator"); + assert.strictEqual(typeof p.apply, "function"); + assert.strictEqual(typeof p.invert, "function"); + assert.strictEqual("stream" in p, false); + assert.strictEqual("clip" in p, false); // default omitted + assert.strictEqual("precision" in p, false); // default omitted + assert.strictEqual("insetTop" in p, false); // default omitted + }); + + it("is the same for 'mercator' and {type: 'mercator'}", () => { + const p1 = Plot.plot({projection: "mercator", marks: [Plot.graticule()]}).scale("projection"); + const p2 = Plot.plot({projection: {type: "mercator"}, marks: [Plot.graticule()]}).scale("projection"); + assert.strictEqual(p1.type, p2.type); + assert.inDelta(p1.apply([-1.55, 47.22]), p2.apply([-1.55, 47.22])); + }); + + it("exposes apply and invert that round-trip", () => { + const plot = Plot.plot({projection: "mercator", marks: [Plot.graticule()]}); + const p = plot.scale("projection"); + const point = [-1.55, 47.22]; + const px = p.apply(point); + assert.ok(Array.isArray(px)); + assert.strictEqual(px.length, 2); + assert.inDelta(p.invert(px), point); + }); + + it("exposes parallels for conic projections", () => { + const plot = Plot.plot({projection: {type: "conic-equal-area", parallels: [30, 40]}, marks: [Plot.graticule()]}); + const p = plot.scale("projection"); + assert.strictEqual(p.type, "conic-equal-area"); + assert.inDelta(p.parallels, [30, 40]); + }); + + it("exposes rotate", () => { + const plot = Plot.plot({projection: {type: "orthographic", rotate: [90, -30]}, marks: [Plot.graticule()]}); + const p = plot.scale("projection"); + assert.deepStrictEqual(p.rotate, [90, -30]); + }); + + 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.strictEqual(p.type, "identity"); + assert.strictEqual(typeof p.apply, "function"); + assert.strictEqual(typeof p.invert, "function"); + assert.inDelta(p.apply([0, 0]), [0, 0]); + assert.inDelta(p.apply([200, 100]), [400, 200]); + assert.inDelta(p.apply([100, 50]), [200, 100]); + assert.inDelta(p.invert([0, 0]), [0, 0]); + assert.inDelta(p.invert([400, 200]), [200, 100]); + assert.inDelta(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.strictEqual(p.type, "reflect-y"); + assert.strictEqual(typeof p.apply, "function"); + assert.strictEqual(typeof p.invert, "function"); + assert.inDelta(p.apply([0, 0]), [0, 200]); + assert.inDelta(p.apply([200, 100]), [400, 0]); + assert.inDelta(p.apply([100, 50]), [200, 100]); + assert.inDelta(p.invert([0, 200]), [0, 0]); + assert.inDelta(p.invert([400, 0]), [200, 100]); + assert.inDelta(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"); + assert.strictEqual(p2.type, "mercator"); + // Same dimensions, so pixel coordinates match + const point = [-1.55, 47.22]; + assert.inDelta(p1.apply(point), p2.apply(point)); + }); + + it("round-trips with different dimensions", () => { + const plot1 = Plot.plot({width: 640, projection: "mercator", marks: [Plot.graticule()]}); + const p1 = plot1.scale("projection"); + const plot2 = Plot.plot({width: 300, projection: p1, marks: [Plot.graticule()]}); + const p2 = plot2.scale("projection"); + assert.strictEqual(p2.type, "mercator"); + // Different dimensions, so pixel coordinates differ but projection type is preserved + const point = [-1.55, 47.22]; + assert.notDeepStrictEqual(p1.apply(point), p2.apply(point)); + // But invert still round-trips + assert.inDelta(p2.invert(p2.apply(point)), point); + }); + + it("exposes domain when specified", () => { + const domain = {type: "Sphere"}; + const plot = Plot.plot({ + projection: {type: "orthographic", domain}, + marks: [Plot.graticule()] + }); + const p = plot.scale("projection"); + assert.strictEqual(p.domain, domain); + }); + + it("exposes non-default clip and precision", () => { + const plot = Plot.plot({ + projection: {type: "orthographic", clip: 85, precision: 0.5}, + marks: [Plot.graticule()] + }); + const p = plot.scale("projection"); + assert.strictEqual(p.clip, 85); + assert.strictEqual(p.precision, 0.5); + }); + + it("exposes insets", () => { + const plot = Plot.plot({ + projection: {type: "mercator", inset: 10}, + marks: [Plot.graticule()] + }); + const p = plot.scale("projection"); + assert.strictEqual(p.insetTop, 10); + assert.strictEqual(p.insetRight, 10); + assert.strictEqual(p.insetBottom, 10); + assert.strictEqual(p.insetLeft, 10); + }); +});