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": { diff --git a/docs/features/scales.md b/docs/features/scales.md index c9a494b805..23df4810b3 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}}); +``` 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/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.d.ts b/src/scales.d.ts index f3bd753c4a..45fe9c1dc2 100644 --- a/src/scales.d.ts +++ b/src/scales.d.ts @@ -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: @@ -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; diff --git a/src/scales.js b/src/scales.js index d1fc1150c0..f543022c92 100644 --- a/src/scales.js +++ b/src/scales.js @@ -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, @@ -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}`); diff --git a/test/scales/scales-test.js b/test/scales/scales-test.js index 29925d812e..69cc3aeb37 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,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]); + }); +});