Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/data/api.data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
13 changes: 13 additions & 0 deletions docs/features/scales.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. <VersionBadge pr="2427" /> 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}});
```
18 changes: 18 additions & 0 deletions src/dimensions.d.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,21 @@
/** Options for specifying the dimensions of a plot or standalone projection. */
Copy link
Copy Markdown
Contributor Author

@Fil Fil Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note that the terse definitions here are in fact only for Plot.scale({projection}), and get overridden by more detailed definitions in Plot.plot(), i.e.

plot/src/plot.d.ts

Lines 11 to 23 in 1c6b239

/**
* The outer width of the plot in pixels, including margins. Defaults to 640.
* On Observable, this can be set to the built-in [width][1] for full-width
* responsive plots. Note: the default style has a max-width of 100%; the plot
* will automatically shrink to fit even when a fixed width is specified.
*
* [1]: https://github.com/observablehq/stdlib/blob/main/README.md#width
*/
width?: number;
/**
* The outer height of the plot in pixels, including margins. The default
* depends on the plot’s scales, and the plot’s width if an aspectRatio is

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. */
Expand Down
3 changes: 2 additions & 1 deletion src/plot.d.ts
Original file line number Diff line number Diff line change
@@ -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

/**
Expand Down
4 changes: 2 additions & 2 deletions src/projection.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -114,7 +114,7 @@ export function createProjection(
};
}

function exposeProjection(projection) {
function prepareProjection(projection) {
return typeof projection === "function"
? {
stream: (s) => projection.stream(s),
Expand Down
5 changes: 4 additions & 1 deletion src/scales.d.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
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";
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:
Expand Down Expand Up @@ -672,4 +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: {[name in ScaleName]?: ScaleOptions}): Scale;
export function scale(options: {projection: ProjectionOptions & DimensionOptions}): Projection;
11 changes: 10 additions & 1 deletion src/scales.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import {
coerceDates
} from "./options.js";
import {orderof} from "./order.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,
Expand Down Expand Up @@ -526,12 +528,19 @@ 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" ? exposeProjection(options[key]) : exposeScale(normalizeScale(key, options[key]));
}
if (scale === undefined) throw new Error("invalid scale definition; no scale found");
return scale;
}

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;
}

export function exposeScales(scales, context) {
return (key) => {
if (!registry.has((key = `${key}`))) throw new Error(`unknown scale: ${key}`);
Expand Down
89 changes: 89 additions & 0 deletions test/scales/scales-test.js
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -2419,3 +2420,91 @@ 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')", () => {
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 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}});
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: []
}).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}});
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}});
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}});
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]);
});
});