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 01/11] 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); + }); +}); From c933ee31752d5a746381562be4a4dfadc6d65c9f Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Tue, 31 Mar 2026 13:36:21 -0700 Subject: [PATCH 02/11] polish --- src/plot.d.ts | 20 +++++------ src/projection.js | 36 ++++++++++---------- test/assert.js | 23 +++++++------ test/scales/scales-test.js | 68 ++++++++++++++++++++------------------ 4 files changed, 75 insertions(+), 72 deletions(-) diff --git a/src/plot.d.ts b/src/plot.d.ts index 2871e610f0..58d514fb5d 100644 --- a/src/plot.d.ts +++ b/src/plot.d.ts @@ -1,13 +1,8 @@ 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, - ProjectionScale -} from "./projection.js"; +import type {ProjectionFactory, ProjectionImplementation, ProjectionName} from "./projection.js"; +import type {ProjectionOptions, ProjectionScale} from "./projection.js"; import type {Scale, ScaleDefaults, ScaleName, ScaleOptions} from "./scales.js"; export interface PlotOptions extends ScaleDefaults { @@ -406,6 +401,12 @@ export interface PlotFacetOptions { * methods to allow sharing of scales and legends across plots. */ export interface Plot { + /** + * 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; + /** * Returns this plot’s projection, or undefined if this plot does not use a * projection. The returned object includes the resolved projection options @@ -415,11 +416,6 @@ export interface Plot { * **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 | "projection"): Scale | ProjectionScale | undefined; /** * Generates a legend for the scale with the specified *name* and the given diff --git a/src/projection.js b/src/projection.js index d2a2eb0d5e..7eb5ef9b83 100644 --- a/src/projection.js +++ b/src/projection.js @@ -111,21 +111,19 @@ export function createProjection( } }); - const stream = (s) => projection.stream(transform.stream(postClip(s))); - return { + ...options, 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] = []) { + ...(domain && {domain}), + insetTop, + insetRight, + insetBottom, + insetLeft, + clip, + stream(s) { + return projection.stream(transform.stream(postClip(s))); + }, + apply([x, y]) { let result; const s = projection.stream( transform.stream({ @@ -137,11 +135,13 @@ export function createProjection( 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]; - } + ...(projection.invert && { + invert([x, y]) { + const px = (x - tx) / (k ?? 1); + const py = (y - ty) / (k ?? 1); + return projection.invert([px, py]); + } + }) }; } diff --git a/test/assert.js b/test/assert.js index 6d3dc1e267..e7f59670be 100644 --- a/test/assert.js +++ b/test/assert.js @@ -58,15 +58,18 @@ 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}`); - } +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 { @@ -75,5 +78,5 @@ export default { warnsAsync, doesNotWarn, doesNotWarnAsync, - inDelta + allCloseTo }; diff --git a/test/scales/scales-test.js b/test/scales/scales-test.js index 775abe17cf..b87972bc2c 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 = [ @@ -2318,21 +2318,25 @@ describe("plot(…).scale('projection')", () => { 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 + const projection = plot.scale("projection"); + const {apply, invert, ...rest} = projection; + assert.strictEqual(typeof apply, "function"); + assert.strictEqual(typeof invert, "function"); + assert.deepStrictEqual(rest, { + type: "mercator", + clip: "frame", + insetTop: 0, + insetRight: 0, + insetBottom: 0, + insetLeft: 0 + }); }); 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])); + assert.allCloseTo(p1.apply([-1.55, 47.22]), p2.apply([-1.55, 47.22])); }); it("exposes apply and invert that round-trip", () => { @@ -2342,14 +2346,14 @@ describe("plot(…).scale('projection')", () => { const px = p.apply(point); assert.ok(Array.isArray(px)); assert.strictEqual(px.length, 2); - assert.inDelta(p.invert(px), point); + assert.allCloseTo(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]); + assert.allCloseTo(p.parallels, [30, 40]); }); it("exposes rotate", () => { @@ -2382,12 +2386,12 @@ describe("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]); + 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", () => { @@ -2414,12 +2418,12 @@ describe("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]); + 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", () => { @@ -2430,20 +2434,20 @@ describe("plot(…).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)); + 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 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"); + const projection1 = plot1.scale("projection"); + const plot2 = Plot.plot({width: 300, projection: projection1, marks: [Plot.graticule()]}); + const projection2 = plot2.scale("projection"); + assert.strictEqual(projection2.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)); + assert.allCloseTo(projection1.apply([-1.55, 47.22]), [316.748750, 224.179291]); + assert.allCloseTo(projection2.apply([-1.55, 47.22]), [148.212639, 104.897665]); // But invert still round-trips - assert.inDelta(p2.invert(p2.apply(point)), point); + assert.allCloseTo(projection2.invert(projection2.apply([-1.55, 47.22])), [-1.55, 47.22]); }); it("exposes domain when specified", () => { From 810dbf9e73077ef86ba39df1142cb056f535cb11 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Tue, 31 Mar 2026 13:49:04 -0700 Subject: [PATCH 03/11] consolidate types --- src/context.d.ts | 5 +++-- src/plot.d.ts | 4 ++-- src/projection.d.ts | 40 +++++----------------------------------- src/projection.js | 15 +++------------ 4 files changed, 13 insertions(+), 51 deletions(-) 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 58d514fb5d..2a6843416d 100644 --- a/src/plot.d.ts +++ b/src/plot.d.ts @@ -2,7 +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} from "./projection.js"; -import type {ProjectionOptions, ProjectionScale} from "./projection.js"; +import type {ProjectionOptions, Projection} from "./projection.js"; import type {Scale, ScaleDefaults, ScaleName, ScaleOptions} from "./scales.js"; export interface PlotOptions extends ScaleDefaults { @@ -415,7 +415,7 @@ export interface Plot { * 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; + scale(name: "projection"): Projection | undefined; /** * Generates a legend for the scale with the specified *name* and the given diff --git a/src/projection.d.ts b/src/projection.d.ts index 509e201943..276e2a7235 100644 --- a/src/projection.d.ts +++ b/src/projection.d.ts @@ -114,40 +114,10 @@ export interface ProjectionOptions extends InsetOptions { } /** A materialized projection, as returned by plot.scale("projection"). */ -export interface ProjectionScale { - /** The projection type name or factory function. */ - type: ProjectionName | ProjectionFactory; +export interface Projection extends ProjectionOptions, 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; - /** 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; + /** 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 7eb5ef9b83..644d8ed9aa 100644 --- a/src/projection.js +++ b/src/projection.js @@ -124,22 +124,13 @@ export function createProjection( return projection.stream(transform.stream(postClip(s))); }, apply([x, y]) { - let result; - const s = projection.stream( - transform.stream({ - point(x, y) { - result = [x, y]; - } - }) - ); - s.point(x, y); + let result = null; + projection.stream(transform.stream({point: (x, y) => void (result = [x, y])})).point(x, y); return result; }, ...(projection.invert && { invert([x, y]) { - const px = (x - tx) / (k ?? 1); - const py = (y - ty) / (k ?? 1); - return projection.invert([px, py]); + return projection.invert([(x - tx) / (k ?? 1), (y - ty) / (k ?? 1)]); } }) }; From a507d1d374ac7d0f96b46b50cbad181bda7da19c Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Tue, 31 Mar 2026 13:53:55 -0700 Subject: [PATCH 04/11] purtier --- test/scales/scales-test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/scales/scales-test.js b/test/scales/scales-test.js index b87972bc2c..387018b954 100644 --- a/test/scales/scales-test.js +++ b/test/scales/scales-test.js @@ -2444,7 +2444,7 @@ describe("plot(…).scale('projection')", () => { const projection2 = plot2.scale("projection"); assert.strictEqual(projection2.type, "mercator"); // Different dimensions, so pixel coordinates differ but projection type is preserved - assert.allCloseTo(projection1.apply([-1.55, 47.22]), [316.748750, 224.179291]); + assert.allCloseTo(projection1.apply([-1.55, 47.22]), [316.74875, 224.179291]); assert.allCloseTo(projection2.apply([-1.55, 47.22]), [148.212639, 104.897665]); // But invert still round-trips assert.allCloseTo(projection2.invert(projection2.apply([-1.55, 47.22])), [-1.55, 47.22]); From 75bb0a0e2811004b71b18a821ba706ac13548a8a Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Tue, 31 Mar 2026 17:37:43 -0700 Subject: [PATCH 05/11] expose projection implementation, not options --- docs/features/plots.md | 2 +- docs/features/projections.md | 16 +--- src/context.d.ts | 4 +- src/plot.d.ts | 11 +-- src/plot.js | 6 +- src/projection.d.ts | 9 -- src/projection.js | 43 ++-------- src/scales.js | 4 +- test/scales/scales-test.js | 161 +++++++++++++++++------------------ 9 files changed, 97 insertions(+), 159 deletions(-) diff --git a/docs/features/plots.md b/docs/features/plots.md index 04b1dc438b..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*, *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. +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 f559c144aa..52b3745310 100644 --- a/docs/features/projections.md +++ b/docs/features/projections.md @@ -284,21 +284,7 @@ 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: +The returned object exposes a *projection*.stream method (see d3-geo) that can be used to project geometry. 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()]}); diff --git a/src/context.d.ts b/src/context.d.ts index 1c79bbe34b..2b618f7308 100644 --- a/src/context.d.ts +++ b/src/context.d.ts @@ -1,6 +1,6 @@ import type {GeoPath} from "d3"; import type {MarkOptions} from "./mark.js"; -import type {Projection} from "./projection.js"; +import type {ProjectionImplementation} from "./projection.js"; /** Additional rendering context provided to marks and initializers. */ export interface Context { @@ -17,7 +17,7 @@ export interface Context { className: string; /** The current projection, if any. */ - projection?: Projection; + projection?: ProjectionImplementation; /** 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 2a6843416d..ee04c465c9 100644 --- a/src/plot.d.ts +++ b/src/plot.d.ts @@ -1,8 +1,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} from "./projection.js"; -import type {ProjectionOptions, Projection} from "./projection.js"; +import type {ProjectionFactory, ProjectionImplementation, ProjectionName, ProjectionOptions} from "./projection.js"; import type {Scale, ScaleDefaults, ScaleName, ScaleOptions} from "./scales.js"; export interface PlotOptions extends ScaleDefaults { @@ -409,13 +408,9 @@ 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. + * projection. */ - scale(name: "projection"): Projection | undefined; + scale(name: "projection"): ProjectionImplementation | 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 b361324159..2d6d9174bf 100644 --- a/src/plot.js +++ b/src/plot.js @@ -340,11 +340,7 @@ 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.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 276e2a7235..8856c28c6b 100644 --- a/src/projection.d.ts +++ b/src/projection.d.ts @@ -112,12 +112,3 @@ export interface ProjectionOptions extends InsetOptions { */ clip?: boolean | number | "frame" | null; } - -/** A materialized projection, as returned by plot.scale("projection"). */ -export interface Projection extends ProjectionOptions, 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 644d8ed9aa..20e011101a 100644 --- a/src/projection.js +++ b/src/projection.js @@ -63,8 +63,6 @@ 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)); @@ -76,18 +74,17 @@ export function createProjection( // The projection initializer might decide to not use a projection. if (projection == null) return; - const postClip = maybePostClip(clip, marginLeft, marginTop, width - marginRight, height - marginBottom); + clip = 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); - k = Math.min(dx / (x1 - x0), dy / (y1 - y0)); + const k = Math.min(dx / (x1 - x0), dy / (y1 - y0)); if (k > 0) { tx -= (k * (x0 + x1) - dx) / 2; ty -= (k * (y0 + y1) - dy) / 2; @@ -97,7 +94,6 @@ export function createProjection( } }); } else { - k = undefined; warn(`Warning: the projection could not be fit to the specified domain; using the default scale.`); } } @@ -111,29 +107,7 @@ export function createProjection( } }); - return { - ...options, - type, - ...(domain && {domain}), - insetTop, - insetRight, - insetBottom, - insetLeft, - clip, - stream(s) { - return projection.stream(transform.stream(postClip(s))); - }, - apply([x, y]) { - let result = null; - projection.stream(transform.stream({point: (x, y) => void (result = [x, y])})).point(x, y); - return result; - }, - ...(projection.invert && { - invert([x, y]) { - return projection.invert([(x - tx) / (k ?? 1), (y - ty) / (k ?? 1)]); - } - }) - }; + return {stream: (s) => projection.stream(transform.stream(clip(s)))}; } function namedProjection(projection) { @@ -221,16 +195,15 @@ function conicProjection(createProjection, kx, ky) { }; } -const identity = constant({stream: (stream) => stream, invert: (point) => point}); +const identity = constant({stream: (stream) => stream}); -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/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/scales/scales-test.js b/test/scales/scales-test.js index 387018b954..e6cbb1c5f2 100644 --- a/test/scales/scales-test.js +++ b/test/scales/scales-test.js @@ -2319,48 +2319,45 @@ describe("plot(…).scale('projection')", () => { it("returns the projection for a named projection", () => { const plot = Plot.plot({projection: "mercator", marks: [Plot.graticule()]}); const projection = plot.scale("projection"); - const {apply, invert, ...rest} = projection; - assert.strictEqual(typeof apply, "function"); - assert.strictEqual(typeof invert, "function"); - assert.deepStrictEqual(rest, { - type: "mercator", - clip: "frame", - insetTop: 0, - insetRight: 0, - insetBottom: 0, - insetLeft: 0 - }); + assert.strictEqual(typeof projection.stream, "function"); + assert.allCloseTo(applyProjection(projection, [-1.55, 47.22]), [316.748750, 224.179291]); }); + function applyProjection(projection, [x, y]) { + let result = null; + projection.stream({point: (x, y) => void (result = [x, y])}).point(x, y); + return result; + } + 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.allCloseTo(p1.apply([-1.55, 47.22]), p2.apply([-1.55, 47.22])); + assert.allCloseTo(applyProjection(p1, [-1.55, 47.22]), applyProjection(p2, [-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); + const px = applyProjection(p, point); assert.ok(Array.isArray(px)); assert.strictEqual(px.length, 2); - assert.allCloseTo(p.invert(px), point); + // assert.allCloseTo(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.allCloseTo(p.parallels, [30, 40]); - }); + // 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.allCloseTo(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 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 = { @@ -2383,15 +2380,15 @@ describe("plot(…).scale('projection')", () => { 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.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]); + // assert.strictEqual(p.type, "identity"); + // assert.strictEqual(typeof p.apply, "function"); + // assert.strictEqual(typeof p.invert, "function"); + assert.allCloseTo(applyProjection(p, [0, 0]), [0, 0]); + assert.allCloseTo(applyProjection(p, [200, 100]), [400, 200]); + assert.allCloseTo(applyProjection(p, [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", () => { @@ -2415,15 +2412,15 @@ describe("plot(…).scale('projection')", () => { 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.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]); + // assert.strictEqual(p.type, "reflect-y"); + // assert.strictEqual(typeof p.apply, "function"); + // assert.strictEqual(typeof p.invert, "function"); + assert.allCloseTo(applyProjection(p, [0, 0]), [0, 200]); + assert.allCloseTo(applyProjection(p, [200, 100]), [400, 0]); + assert.allCloseTo(applyProjection(p, [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", () => { @@ -2431,10 +2428,10 @@ describe("plot(…).scale('projection')", () => { const p1 = plot1.scale("projection"); const plot2 = Plot.plot({projection: p1, marks: [Plot.graticule()]}); const p2 = plot2.scale("projection"); - assert.strictEqual(p2.type, "mercator"); + // assert.strictEqual(p2.type, "mercator"); // Same dimensions, so pixel coordinates match const point = [-1.55, 47.22]; - assert.allCloseTo(p1.apply(point), p2.apply(point)); + assert.allCloseTo(applyProjection(p1, point), applyProjection(p2, point)); }); it("round-trips with different dimensions", () => { @@ -2442,43 +2439,43 @@ describe("plot(…).scale('projection')", () => { const projection1 = plot1.scale("projection"); const plot2 = Plot.plot({width: 300, projection: projection1, marks: [Plot.graticule()]}); const projection2 = plot2.scale("projection"); - assert.strictEqual(projection2.type, "mercator"); + // assert.strictEqual(projection2.type, "mercator"); // Different dimensions, so pixel coordinates differ but projection type is preserved - assert.allCloseTo(projection1.apply([-1.55, 47.22]), [316.74875, 224.179291]); - assert.allCloseTo(projection2.apply([-1.55, 47.22]), [148.212639, 104.897665]); + assert.allCloseTo(applyProjection(projection1, [-1.55, 47.22]), [316.74875, 224.179291]); + assert.allCloseTo(applyProjection(projection2, [-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]); - }); - - 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); - }); + // assert.allCloseTo(projection2.invert(projection2.apply([-1.55, 47.22])), [-1.55, 47.22]); + }); + + // 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); + // }); }); From 45c5408dcda635a13e8be9f6e549e4967a665002 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Tue, 31 Mar 2026 17:44:00 -0700 Subject: [PATCH 06/11] purdier --- test/scales/scales-test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/scales/scales-test.js b/test/scales/scales-test.js index e6cbb1c5f2..141dfb5e32 100644 --- a/test/scales/scales-test.js +++ b/test/scales/scales-test.js @@ -2320,7 +2320,7 @@ describe("plot(…).scale('projection')", () => { const plot = Plot.plot({projection: "mercator", marks: [Plot.graticule()]}); const projection = plot.scale("projection"); assert.strictEqual(typeof projection.stream, "function"); - assert.allCloseTo(applyProjection(projection, [-1.55, 47.22]), [316.748750, 224.179291]); + assert.allCloseTo(applyProjection(projection, [-1.55, 47.22]), [316.74875, 224.179291]); }); function applyProjection(projection, [x, y]) { From 22feade47824cb9fa8023a0c90423777af0bd2bb Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Tue, 31 Mar 2026 18:00:32 -0700 Subject: [PATCH 07/11] restore projection.apply --- src/projection.js | 57 ++++++++++++++++++++++++++++---------- test/scales/scales-test.js | 34 ++++++++++------------- 2 files changed, 56 insertions(+), 35 deletions(-) diff --git a/src/projection.js b/src/projection.js index 20e011101a..cbec621ac8 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"; @@ -88,26 +88,35 @@ export function createProjection( 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) { + return projection.stream(transform.stream(clip(s))); + }, + apply(p) { + let result = null; + this.stream({point: (...p) => (result = p)}).point(...p); + return result; + } + // TODO invert + }; +} - return {stream: (s) => projection.stream(transform.stream(clip(s)))}; +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) { @@ -205,6 +214,24 @@ const reflectY = constant( }) ); +function scaleAndTranslate(k, tx, ty) { + return geoTransform({ + point(x, y) { + this.stream.point(x * k + tx, y * k + ty); + } + }); +} + +function translate(tx, ty) { + return tx === 0 && ty === 0 + ? identity() + : geoTransform({ + point(x, y) { + this.stream.point(x + tx, y + ty); + } + }); +} + // Applies a point-wise projection to the given paired x and y channels. // Note: mutates values! export function project(cx, cy, values, projection) { diff --git a/test/scales/scales-test.js b/test/scales/scales-test.js index 141dfb5e32..8f84ac4c2d 100644 --- a/test/scales/scales-test.js +++ b/test/scales/scales-test.js @@ -2320,27 +2320,21 @@ describe("plot(…).scale('projection')", () => { const plot = Plot.plot({projection: "mercator", marks: [Plot.graticule()]}); const projection = plot.scale("projection"); assert.strictEqual(typeof projection.stream, "function"); - assert.allCloseTo(applyProjection(projection, [-1.55, 47.22]), [316.74875, 224.179291]); + assert.allCloseTo(projection.apply([-1.55, 47.22]), [316.74875, 224.179291]); }); - function applyProjection(projection, [x, y]) { - let result = null; - projection.stream({point: (x, y) => void (result = [x, y])}).point(x, y); - return result; - } - 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.allCloseTo(applyProjection(p1, [-1.55, 47.22]), applyProjection(p2, [-1.55, 47.22])); + assert.allCloseTo(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 = applyProjection(p, point); + const px = p.apply(point); assert.ok(Array.isArray(px)); assert.strictEqual(px.length, 2); // assert.allCloseTo(p.invert(px), point); @@ -2381,11 +2375,11 @@ describe("plot(…).scale('projection')", () => { }); const p = plot.scale("projection"); // assert.strictEqual(p.type, "identity"); - // assert.strictEqual(typeof p.apply, "function"); + assert.strictEqual(typeof p.apply, "function"); + 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.strictEqual(typeof p.invert, "function"); - assert.allCloseTo(applyProjection(p, [0, 0]), [0, 0]); - assert.allCloseTo(applyProjection(p, [200, 100]), [400, 200]); - assert.allCloseTo(applyProjection(p, [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]); @@ -2413,11 +2407,11 @@ describe("plot(…).scale('projection')", () => { }); const p = plot.scale("projection"); // assert.strictEqual(p.type, "reflect-y"); - // assert.strictEqual(typeof p.apply, "function"); + assert.strictEqual(typeof p.apply, "function"); + 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.strictEqual(typeof p.invert, "function"); - assert.allCloseTo(applyProjection(p, [0, 0]), [0, 200]); - assert.allCloseTo(applyProjection(p, [200, 100]), [400, 0]); - assert.allCloseTo(applyProjection(p, [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]); @@ -2431,7 +2425,7 @@ describe("plot(…).scale('projection')", () => { // assert.strictEqual(p2.type, "mercator"); // Same dimensions, so pixel coordinates match const point = [-1.55, 47.22]; - assert.allCloseTo(applyProjection(p1, point), applyProjection(p2, point)); + assert.allCloseTo(p1.apply(point), p2.apply(point)); }); it("round-trips with different dimensions", () => { @@ -2441,8 +2435,8 @@ describe("plot(…).scale('projection')", () => { const projection2 = plot2.scale("projection"); // assert.strictEqual(projection2.type, "mercator"); // Different dimensions, so pixel coordinates differ but projection type is preserved - assert.allCloseTo(applyProjection(projection1, [-1.55, 47.22]), [316.74875, 224.179291]); - assert.allCloseTo(applyProjection(projection2, [-1.55, 47.22]), [316.74875, 224.179291]); + 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]); }); From 8c92e4b1282ac9aee7d2310a8d7f5a5a3a2e4bce Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Tue, 31 Mar 2026 19:17:43 -0700 Subject: [PATCH 08/11] projection.invert --- docs/features/projections.md | 2 +- src/projection.js | 59 +++++++++++++++++++++++------------- test/scales/scales-test.js | 23 ++++++-------- 3 files changed, 49 insertions(+), 35 deletions(-) diff --git a/docs/features/projections.md b/docs/features/projections.md index 52b3745310..c412c586b3 100644 --- a/docs/features/projections.md +++ b/docs/features/projections.md @@ -284,7 +284,7 @@ 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. To reuse a projection across plots, pass the projection object as the **projection** option of another plot: +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()]}); diff --git a/src/projection.js b/src/projection.js index cbec621ac8..8dc01a0444 100644 --- a/src/projection.js +++ b/src/projection.js @@ -80,11 +80,12 @@ 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; @@ -104,8 +105,12 @@ export function createProjection( let result = null; this.stream({point: (...p) => (result = p)}).point(...p); return result; - } - // TODO invert + }, + ...(projection.invert && { + invert(p) { + return projection.invert(transform.invert(p)); + } + }) }; } @@ -204,32 +209,44 @@ 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); } - }) -); - -function scaleAndTranslate(k, tx, ty) { - return geoTransform({ - point(x, y) { - this.stream.point(x * k + tx, y * k + ty); - } - }); -} + }), + 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); - } - }); + : { + ...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. diff --git a/test/scales/scales-test.js b/test/scales/scales-test.js index 8f84ac4c2d..f70ccb0a98 100644 --- a/test/scales/scales-test.js +++ b/test/scales/scales-test.js @@ -2326,7 +2326,6 @@ describe("plot(…).scale('projection')", () => { 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.allCloseTo(p1.apply([-1.55, 47.22]), p2.apply([-1.55, 47.22])); }); @@ -2337,7 +2336,7 @@ describe("plot(…).scale('projection')", () => { const px = p.apply(point); assert.ok(Array.isArray(px)); assert.strictEqual(px.length, 2); - // assert.allCloseTo(p.invert(px), point); + assert.allCloseTo(p.invert(px), point); }); // it("exposes parallels for conic projections", () => { @@ -2374,15 +2373,14 @@ describe("plot(…).scale('projection')", () => { marks: [Plot.frame()] }); const p = plot.scale("projection"); - // assert.strictEqual(p.type, "identity"); assert.strictEqual(typeof p.apply, "function"); 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.strictEqual(typeof p.invert, "function"); - // assert.allCloseTo(p.invert([0, 0]), [0, 0]); - // assert.allCloseTo(p.invert([400, 200]), [200, 100]); - // assert.allCloseTo(p.invert([200, 100]), [100, 50]); + assert.strictEqual(typeof p.invert, "function"); + 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", () => { @@ -2406,15 +2404,14 @@ describe("plot(…).scale('projection')", () => { marks: [Plot.frame()] }); const p = plot.scale("projection"); - // assert.strictEqual(p.type, "reflect-y"); assert.strictEqual(typeof p.apply, "function"); 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.strictEqual(typeof p.invert, "function"); - // assert.allCloseTo(p.invert([0, 200]), [0, 0]); - // assert.allCloseTo(p.invert([400, 0]), [200, 100]); - // assert.allCloseTo(p.invert([200, 100]), [100, 50]); + assert.strictEqual(typeof p.invert, "function"); + 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", () => { @@ -2438,7 +2435,7 @@ describe("plot(…).scale('projection')", () => { 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]); + assert.allCloseTo(projection2.invert(projection2.apply([-1.55, 47.22])), [-1.55, 47.22]); }); // it("exposes domain when specified", () => { From 6a5e757c9714a0eff5ed8b7853fba0eb9ea90e8d Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Tue, 31 Mar 2026 19:21:15 -0700 Subject: [PATCH 09/11] restore Projection interface --- src/plot.d.ts | 3 ++- src/projection.d.ts | 9 +++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/plot.d.ts b/src/plot.d.ts index ee04c465c9..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 { @@ -410,7 +411,7 @@ export interface Plot { * Returns this plot’s projection, or undefined if this plot does not use a * projection. */ - scale(name: "projection"): ProjectionImplementation | undefined; + scale(name: "projection"): Projection | undefined; /** * Generates a legend for the scale with the specified *name* and the given 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; +} From 08ef2dd72053125081fa30f37089cfc37c3545d1 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Tue, 31 Mar 2026 19:29:57 -0700 Subject: [PATCH 10/11] test projection.stream --- test/scales/scales-test.js | 79 +++++++------------------------------- 1 file changed, 14 insertions(+), 65 deletions(-) diff --git a/test/scales/scales-test.js b/test/scales/scales-test.js index f70ccb0a98..d3834a5cd6 100644 --- a/test/scales/scales-test.js +++ b/test/scales/scales-test.js @@ -2319,39 +2319,26 @@ describe("plot(…).scale('projection')", () => { it("returns the projection for a named projection", () => { const plot = Plot.plot({projection: "mercator", marks: [Plot.graticule()]}); const projection = plot.scale("projection"); - assert.strictEqual(typeof projection.stream, "function"); + 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("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.allCloseTo(p1.apply([-1.55, 47.22]), p2.apply([-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("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.allCloseTo(p.invert(px), point); + 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 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.allCloseTo(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", @@ -2373,11 +2360,9 @@ describe("plot(…).scale('projection')", () => { marks: [Plot.frame()] }); const p = plot.scale("projection"); - assert.strictEqual(typeof p.apply, "function"); 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.strictEqual(typeof p.invert, "function"); assert.allCloseTo(p.invert([0, 0]), [0, 0]); assert.allCloseTo(p.invert([400, 200]), [200, 100]); assert.allCloseTo(p.invert([200, 100]), [100, 50]); @@ -2404,11 +2389,9 @@ describe("plot(…).scale('projection')", () => { marks: [Plot.frame()] }); const p = plot.scale("projection"); - assert.strictEqual(typeof p.apply, "function"); 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.strictEqual(typeof p.invert, "function"); assert.allCloseTo(p.invert([0, 200]), [0, 0]); assert.allCloseTo(p.invert([400, 0]), [200, 100]); assert.allCloseTo(p.invert([200, 100]), [100, 50]); @@ -2419,7 +2402,6 @@ describe("plot(…).scale('projection')", () => { 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.allCloseTo(p1.apply(point), p2.apply(point)); @@ -2430,43 +2412,10 @@ describe("plot(…).scale('projection')", () => { const projection1 = plot1.scale("projection"); const plot2 = Plot.plot({width: 300, projection: projection1, marks: [Plot.graticule()]}); const projection2 = plot2.scale("projection"); - // assert.strictEqual(projection2.type, "mercator"); - // Different dimensions, so pixel coordinates differ but projection type is preserved + // 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]); }); - - // 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); - // }); }); From 0ff5e33037db6a5202ed0bb338723c83e1fd1090 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Tue, 31 Mar 2026 19:33:38 -0700 Subject: [PATCH 11/11] context.projection is Projection --- src/context.d.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/context.d.ts b/src/context.d.ts index 2b618f7308..1c79bbe34b 100644 --- a/src/context.d.ts +++ b/src/context.d.ts @@ -1,6 +1,6 @@ import type {GeoPath} from "d3"; import type {MarkOptions} from "./mark.js"; -import type {ProjectionImplementation} from "./projection.js"; +import type {Projection} from "./projection.js"; /** Additional rendering context provided to marks and initializers. */ export interface Context { @@ -17,7 +17,7 @@ export interface Context { className: string; /** The current projection, if any. */ - projection?: ProjectionImplementation; + projection?: Projection; /** A function to draw GeoJSON with the current projection, if any, otherwise with the x and y scales. */ path: () => GeoPath;