From 8957bff443581c9a6b6c74a3725498c034a7dd0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Mon, 13 Apr 2026 17:26:25 +0200 Subject: [PATCH 1/7] Add support for projections to the standalone Plot.scale function MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _e.g._ Plot.scale({projection: {type: "mercator", width, domain: …}}). --- src/scales.d.ts | 2 + src/scales.js | 19 ++++++++- test/scales/scales-test.js | 84 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 104 insertions(+), 1 deletion(-) diff --git a/src/scales.d.ts b/src/scales.d.ts index f3bd753c4a..ae1d7388a6 100644 --- a/src/scales.d.ts +++ b/src/scales.d.ts @@ -2,6 +2,7 @@ import type {InsetOptions} from "./inset.js"; import type {NiceInterval, RangeInterval} from "./interval.js"; import type {LegendOptions} from "./legends.js"; import type {AxisOptions} from "./marks/axis.js"; +import type {Projection, ProjectionOptions} from "./projection.js"; /** * How to interpolate range (output) values for continuous scales; one of: @@ -673,3 +674,4 @@ export interface Scale extends ScaleOptions { * ``` */ export function scale(options?: {[name in ScaleName]?: ScaleOptions}): Scale; +export function scale(options: {projection: ProjectionOptions & {width?: number; height?: number}}): Projection; diff --git a/src/scales.js b/src/scales.js index d1fc1150c0..97647993ae 100644 --- a/src/scales.js +++ b/src/scales.js @@ -10,6 +10,7 @@ import { coerceDates } from "./options.js"; import {orderof} from "./order.js"; +import {createProjection, projectionAspectRatio} from "./projection.js"; import {registry, color, position, radius, opacity, symbol, length} from "./scales/index.js"; import { createScaleLinear, @@ -526,12 +527,28 @@ export function scale(options = {}) { if (!registry.has(key)) continue; // ignore unknown properties if (!isScaleOptions(options[key])) continue; // e.g., ignore {color: "red"} if (scale !== undefined) throw new Error("ambiguous scale definition; multiple scales found"); - scale = exposeScale(normalizeScale(key, options[key])); + scale = key === "projection" ? scaleProjection(options[key]) : exposeScale(normalizeScale(key, options[key])); } if (scale === undefined) throw new Error("invalid scale definition; no scale found"); return scale; } +function scaleProjection({ + width = 640, + height, + margin = 0, + marginTop = margin, + marginRight = margin, + marginBottom = margin, + marginLeft = margin, + ...projection +}) { + if (height === undefined) height = width * projectionAspectRatio(projection); + const p = createProjection({projection}, {width, height, marginTop, marginRight, marginBottom, marginLeft}); + if (p === undefined) throw new Error("invalid scale definition; unknown projection"); + return p; +} + export function exposeScales(scales, context) { return (key) => { if (!registry.has((key = `${key}`))) throw new Error(`unknown scale: ${key}`); diff --git a/test/scales/scales-test.js b/test/scales/scales-test.js index 29925d812e..9ff25a5a02 100644 --- a/test/scales/scales-test.js +++ b/test/scales/scales-test.js @@ -1,5 +1,6 @@ import * as Plot from "@observablehq/plot"; import * as d3 from "d3"; +import * as topojson from "topojson-client"; import assert from "../assert.js"; import {describe, it} from "vitest"; @@ -2419,3 +2420,86 @@ describe("plot(…).scale('projection')", () => { assert.allCloseTo(projection2.invert(projection2.apply([-1.55, 47.22])), [-1.55, 47.22]); }); }); + +describe("Plot.scale({projection})", () => { + it("round-trips", () => { + for (const type of ["mercator", "equal-earth", "equirectangular"]) { + const projection = Plot.scale({projection: {type}}); + assert.allCloseTo(projection.invert(projection.apply([-1.55, 47.22])), [-1.55, 47.22]); + } + }); + + it("matches plot.scale('projection') given explicit dimensions", () => { + for (const [type, height] of [ + ["mercator", 640], + ["equal-earth", 311], + ["equirectangular", 320] + ]) { + const plot = Plot.plot({width: 640, height, margin: 0, projection: type, marks: [Plot.graticule()]}); + const p1 = plot.scale("projection"); + const p2 = Plot.scale({projection: {type, width: 640, height}}); + assert.allCloseTo(p1.apply([-1.55, 47.22]), p2.apply([-1.55, 47.22])); + } + }); + + it("respects margins and insets", () => { + // standalone projection + const p1 = Plot.scale({projection: {type: "mercator", width: 640, height: 640, margin: 40, inset: 10}}); + assert.allCloseTo(p1.invert(p1.apply([-1.55, 47.22])), [-1.55, 47.22]); + // equivalent plot-based projection + const p2 = Plot.plot({ + width: 640, + height: 640, + margin: 40, + projection: {type: "mercator", inset: 10}, + marks: [Plot.graticule()] + }).scale("projection"); + assert.allCloseTo(p1.apply([-1.55, 47.22]), p2.apply([-1.55, 47.22])); + // reuse the standalone projection in a plot + const p3 = Plot.plot({projection: p1, marks: [Plot.graticule()]}).scale("projection"); + assert.allCloseTo(p1.apply([-1.55, 47.22]), p3.apply([-1.55, 47.22])); + }); + + it("supports domain", async () => { + const us = await d3.json("data/us-counties-10m.json"); + const domain = topojson.feature(us, us.objects.nation); + const p1 = Plot.scale({projection: {type: "albers-usa", domain, width: 640, height: 400}}); + const p2 = Plot.plot({ + width: 640, + height: 400, + margin: 0, + projection: {type: "albers-usa", domain}, + marks: [Plot.graticule()] + }).scale("projection"); + assert.allCloseTo(p1.apply([-98, 39]), p2.apply([-98, 39])); // center of the US + assert.allCloseTo(p1.invert(p1.apply([-98, 39])), [-98, 39]); + }); + + it("supports a metric domain with reflect-y", async () => { + const house = await d3.json("data/westport-house.json"); + const p1 = Plot.scale({projection: {type: "reflect-y", domain: house, width: 640, height: 400}}); + const p2 = Plot.plot({ + width: 640, + height: 400, + margin: 0, + projection: {type: "reflect-y", domain: house}, + marks: [Plot.geo(house)] + }).scale("projection"); + assert.allCloseTo(p1.apply([200, 120]), p2.apply([200, 120])); + assert.allCloseTo(p1.invert(p1.apply([200, 120])), [200, 120]); + }); + + it("supports a metric domain with identity", async () => { + const house = await d3.json("data/westport-house.json"); + const p1 = Plot.scale({projection: {type: "identity", domain: house, width: 640, height: 400}}); + const p2 = Plot.plot({ + width: 640, + height: 400, + margin: 0, + projection: {type: "identity", domain: house}, + marks: [Plot.geo(house)] + }).scale("projection"); + assert.allCloseTo(p1.apply([200, 120]), p2.apply([200, 120])); + assert.allCloseTo(p1.invert(p1.apply([200, 120])), [200, 120]); + }); +}); From 78ba8783d7fe44768a5cb46be15af04001d68a73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Mon, 13 Apr 2026 17:46:02 +0200 Subject: [PATCH 2/7] document Plot.scale(projection) --- docs/features/scales.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/features/scales.md b/docs/features/scales.md index c9a494b805..57b10142ab 100644 --- a/docs/features/scales.md +++ b/docs/features/scales.md @@ -1065,3 +1065,16 @@ As another example, below are two plots with different options where the second const plot1 = Plot.plot({...options1}); const plot2 = Plot.plot({...options2, color: plot1.scale("color")}); ``` + +Plot.scale also supports projections. The returned projection object exposes *apply* and *invert* methods for converting between geographic and pixel coordinates, and can be passed as the **projection** option of another plot. + +```js +const projection = Plot.scale({projection: {type: "mercator"}}); +projection.apply([-1.55, 47.22]) // [316.7, 224.2] +``` + +The projection's **width** defaults to 640, and its **height** defaults to the width times the projection's natural aspect ratio. You can override these with the **width** and **height** options, and inset the projection with the **margin** and **inset** options. + +```js +const projection = Plot.scale({projection: {type: "albers-usa", domain, width: 960, height: 600}}); +``` From 43cd710470f91c2cf7092154f2d44afbf6e3c540 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Mon, 13 Apr 2026 23:54:57 +0200 Subject: [PATCH 3/7] Update docs/features/scales.md Co-authored-by: Mike Bostock --- docs/features/scales.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/features/scales.md b/docs/features/scales.md index 57b10142ab..23df4810b3 100644 --- a/docs/features/scales.md +++ b/docs/features/scales.md @@ -1066,7 +1066,7 @@ const plot1 = Plot.plot({...options1}); const plot2 = Plot.plot({...options2, color: plot1.scale("color")}); ``` -Plot.scale also supports projections. The returned projection object exposes *apply* and *invert* methods for converting between geographic and pixel coordinates, and can be passed as the **projection** option of another plot. +Plot.scale also supports projections. The returned projection object exposes *apply* and *invert* methods for converting between geographic and pixel coordinates, and can be passed as the **projection** option of another plot. ```js const projection = Plot.scale({projection: {type: "mercator"}}); From 77c6577687862a29d0c9f47e089ee6e53166fb72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Tue, 14 Apr 2026 13:47:53 +0200 Subject: [PATCH 4/7] DimensionOptions note that the terse definitions here are for Plot.scale({projection}), and get overridden by more detailed definitions in Plot.plot() --- src/dimensions.d.ts | 18 ++++++++++++++++++ src/plot.d.ts | 3 ++- src/scales.d.ts | 5 +++-- 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/src/dimensions.d.ts b/src/dimensions.d.ts index 3b5f34ba93..300c381f4e 100644 --- a/src/dimensions.d.ts +++ b/src/dimensions.d.ts @@ -1,3 +1,21 @@ +/** Options for specifying the dimensions of a plot or standalone projection. */ +export interface DimensionOptions { + /** The outer width in pixels, including margins. Defaults to 640. */ + width?: number; + /** The outer height in pixels, including margins. */ + height?: number; + /** Shorthand for setting the four margins. */ + margin?: number; + /** The top margin in pixels. */ + marginTop?: number; + /** The right margin in pixels. */ + marginRight?: number; + /** The bottom margin in pixels. */ + marginBottom?: number; + /** The left margin in pixels. */ + marginLeft?: number; +} + /** The realized screen dimensions, in pixels, of a plot. */ export interface Dimensions { /** The outer width of the plot in pixels, including margins. */ diff --git a/src/plot.d.ts b/src/plot.d.ts index 535f385632..58752f6443 100644 --- a/src/plot.d.ts +++ b/src/plot.d.ts @@ -1,11 +1,12 @@ import type {ChannelValue} from "./channel.js"; +import type {DimensionOptions} from "./dimensions.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 { +export interface PlotOptions extends ScaleDefaults, DimensionOptions { // dimensions /** diff --git a/src/scales.d.ts b/src/scales.d.ts index ae1d7388a6..45fe9c1dc2 100644 --- a/src/scales.d.ts +++ b/src/scales.d.ts @@ -1,3 +1,4 @@ +import type {DimensionOptions} from "./dimensions.js"; import type {InsetOptions} from "./inset.js"; import type {NiceInterval, RangeInterval} from "./interval.js"; import type {LegendOptions} from "./legends.js"; @@ -673,5 +674,5 @@ export interface Scale extends ScaleOptions { * const color = Plot.scale({color: {type: "linear"}}); * ``` */ -export function scale(options?: {[name in ScaleName]?: ScaleOptions}): Scale; -export function scale(options: {projection: ProjectionOptions & {width?: number; height?: number}}): Projection; +export function scale(options: {[name in ScaleName]?: ScaleOptions}): Scale; +export function scale(options: {projection: ProjectionOptions & DimensionOptions}): Projection; From 3601ab6fe9bedbcf0215e509b74ec8ca36955737 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Tue, 14 Apr 2026 13:51:44 +0200 Subject: [PATCH 5/7] exposeProjection (the old exposeProjection is renamed to prepareProjection) --- src/projection.js | 4 ++-- src/scales.js | 20 ++++++-------------- 2 files changed, 8 insertions(+), 16 deletions(-) diff --git a/src/projection.js b/src/projection.js index 8dc01a0444..806b053368 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 exposeProjection(projection); // projection implementation + if (typeof projection.stream === "function") return prepareProjection(projection); // projection implementation let options; let domain; let clip = "frame"; @@ -114,7 +114,7 @@ export function createProjection( }; } -function exposeProjection(projection) { +function prepareProjection(projection) { return typeof projection === "function" ? { stream: (s) => projection.stream(s), diff --git a/src/scales.js b/src/scales.js index 97647993ae..f543022c92 100644 --- a/src/scales.js +++ b/src/scales.js @@ -10,7 +10,8 @@ import { coerceDates } from "./options.js"; import {orderof} from "./order.js"; -import {createProjection, projectionAspectRatio} from "./projection.js"; +import {createDimensions} from "./dimensions.js"; +import {createProjection} from "./projection.js"; import {registry, color, position, radius, opacity, symbol, length} from "./scales/index.js"; import { createScaleLinear, @@ -527,24 +528,15 @@ export function scale(options = {}) { if (!registry.has(key)) continue; // ignore unknown properties if (!isScaleOptions(options[key])) continue; // e.g., ignore {color: "red"} if (scale !== undefined) throw new Error("ambiguous scale definition; multiple scales found"); - scale = key === "projection" ? scaleProjection(options[key]) : exposeScale(normalizeScale(key, options[key])); + scale = key === "projection" ? exposeProjection(options[key]) : exposeScale(normalizeScale(key, options[key])); } if (scale === undefined) throw new Error("invalid scale definition; no scale found"); return scale; } -function scaleProjection({ - width = 640, - height, - margin = 0, - marginTop = margin, - marginRight = margin, - marginBottom = margin, - marginLeft = margin, - ...projection -}) { - if (height === undefined) height = width * projectionAspectRatio(projection); - const p = createProjection({projection}, {width, height, marginTop, marginRight, marginBottom, marginLeft}); +function exposeProjection({width, height, margin, marginTop, marginRight, marginBottom, marginLeft, ...projection}) { + const dimensions = createDimensions({}, [], {projection, width, height, margin, marginTop, marginRight, marginBottom, marginLeft}); // prettier-ignore + const p = createProjection({projection}, dimensions); if (p === undefined) throw new Error("invalid scale definition; unknown projection"); return p; } From c10b6dc0444a366bf9ee04fc1b4e9638a3a41e2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Tue, 14 Apr 2026 13:53:39 +0200 Subject: [PATCH 6/7] tests! --- test/scales/scales-test.js | 73 ++++++++++++++++++++------------------ 1 file changed, 39 insertions(+), 34 deletions(-) diff --git a/test/scales/scales-test.js b/test/scales/scales-test.js index 9ff25a5a02..69cc3aeb37 100644 --- a/test/scales/scales-test.js +++ b/test/scales/scales-test.js @@ -2429,19 +2429,42 @@ describe("Plot.scale({projection})", () => { } }); - it("matches plot.scale('projection') given explicit dimensions", () => { - for (const [type, height] of [ - ["mercator", 640], - ["equal-earth", 311], - ["equirectangular", 320] + it("matches plot.scale('projection')", () => { + for (const type of ["mercator", "equal-earth", "equirectangular"]) { + const p1 = Plot.plot({projection: type}).scale("projection"); + const p2 = Plot.scale({projection: {type}}); + assert.allCloseTo(p1.apply([-1.55, 47.22]), p2.apply([-1.55, 47.22])); + } + }); + + it("matches plot.scale('projection') with explicit dimensions", () => { + for (const [type, width, height] of [ + ["mercator", 800, 500], + ["equal-earth", 960, 400] ]) { - const plot = Plot.plot({width: 640, height, margin: 0, projection: type, marks: [Plot.graticule()]}); - const p1 = plot.scale("projection"); - const p2 = Plot.scale({projection: {type, width: 640, height}}); + const p1 = Plot.plot({width, height, projection: type}).scale("projection"); + const p2 = Plot.scale({projection: {type, width, height}}); + assert.allCloseTo(p1.apply([-1.55, 47.22]), p2.apply([-1.55, 47.22])); + } + }); + + it("matches plot.scale('projection') with explicit margins", () => { + for (const type of ["mercator", "equal-earth"]) { + const p1 = Plot.plot({margin: 20, marginLeft: 40, projection: type}).scale("projection"); + const p2 = Plot.scale({projection: {type, margin: 20, marginLeft: 40}}); assert.allCloseTo(p1.apply([-1.55, 47.22]), p2.apply([-1.55, 47.22])); } }); + it("supports a custom projection factory", () => { + const sphere = {type: "Sphere"}; + const factory = ({width, height}) => d3.geoOrthographic().fitExtent([[10, 10], [width - 10, height - 10]], sphere); // prettier-ignore + const p1 = Plot.scale({projection: {type: factory, width: 400, height: 400}}); + const p2 = Plot.plot({width: 400, height: 400, projection: {type: factory}}).scale("projection"); + assert.allCloseTo(p1.apply([0, 0]), p2.apply([0, 0])); + assert.allCloseTo(p1.invert(p1.apply([10, 20])), [10, 20]); + }); + it("respects margins and insets", () => { // standalone projection const p1 = Plot.scale({projection: {type: "mercator", width: 640, height: 640, margin: 40, inset: 10}}); @@ -2452,7 +2475,7 @@ describe("Plot.scale({projection})", () => { height: 640, margin: 40, projection: {type: "mercator", inset: 10}, - marks: [Plot.graticule()] + marks: [] }).scale("projection"); assert.allCloseTo(p1.apply([-1.55, 47.22]), p2.apply([-1.55, 47.22])); // reuse the standalone projection in a plot @@ -2463,42 +2486,24 @@ describe("Plot.scale({projection})", () => { it("supports domain", async () => { const us = await d3.json("data/us-counties-10m.json"); const domain = topojson.feature(us, us.objects.nation); - const p1 = Plot.scale({projection: {type: "albers-usa", domain, width: 640, height: 400}}); - const p2 = Plot.plot({ - width: 640, - height: 400, - margin: 0, - projection: {type: "albers-usa", domain}, - marks: [Plot.graticule()] - }).scale("projection"); - assert.allCloseTo(p1.apply([-98, 39]), p2.apply([-98, 39])); // center of the US + const p1 = Plot.scale({projection: {type: "albers-usa", domain}}); + const p2 = Plot.plot({projection: {type: "albers-usa", domain}}).scale("projection"); + assert.allCloseTo(p1.apply([-98, 39]), p2.apply([-98, 39])); assert.allCloseTo(p1.invert(p1.apply([-98, 39])), [-98, 39]); }); it("supports a metric domain with reflect-y", async () => { const house = await d3.json("data/westport-house.json"); - const p1 = Plot.scale({projection: {type: "reflect-y", domain: house, width: 640, height: 400}}); - const p2 = Plot.plot({ - width: 640, - height: 400, - margin: 0, - projection: {type: "reflect-y", domain: house}, - marks: [Plot.geo(house)] - }).scale("projection"); + const p1 = Plot.scale({projection: {type: "reflect-y", domain: house}}); + const p2 = Plot.plot({projection: {type: "reflect-y", domain: house}}).scale("projection"); assert.allCloseTo(p1.apply([200, 120]), p2.apply([200, 120])); assert.allCloseTo(p1.invert(p1.apply([200, 120])), [200, 120]); }); it("supports a metric domain with identity", async () => { const house = await d3.json("data/westport-house.json"); - const p1 = Plot.scale({projection: {type: "identity", domain: house, width: 640, height: 400}}); - const p2 = Plot.plot({ - width: 640, - height: 400, - margin: 0, - projection: {type: "identity", domain: house}, - marks: [Plot.geo(house)] - }).scale("projection"); + const p1 = Plot.scale({projection: {type: "identity", domain: house}}); + const p2 = Plot.plot({projection: {type: "identity", domain: house}}).scale("projection"); assert.allCloseTo(p1.apply([200, 120]), p2.apply([200, 120])); assert.allCloseTo(p1.invert(p1.apply([200, 120])), [200, 120]); }); From 6b81a2fa085c2728522f11574331d6d6644bb694 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Tue, 14 Apr 2026 14:01:53 +0200 Subject: [PATCH 7/7] DimensionOptions map to features/scales --- docs/data/api.data.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/data/api.data.ts b/docs/data/api.data.ts index cbcd4b2139..7a18275963 100644 --- a/docs/data/api.data.ts +++ b/docs/data/api.data.ts @@ -48,6 +48,8 @@ function getHref(name: string, path: string): string { case "features/plot": case "features/projection": return `${path}s`; + case "features/dimensions": + return "features/scales"; case "features/options": return "features/transforms"; case "marks/axis": {