Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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: 1 addition & 1 deletion docs/features/plots.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
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}

Expand Down
16 changes: 16 additions & 0 deletions docs/features/projections.md
Original file line number Diff line number Diff line change
Expand Up @@ -274,3 +274,19 @@ 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 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()]});
const plot2 = Plot.plot({projection: plot1.scale("projection"), marks: [Plot.geo(land)]});
```
5 changes: 3 additions & 2 deletions src/context.d.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type {GeoPath, GeoStreamWrapper} from "d3";
import type {GeoPath} from "d3";
import type {MarkOptions} from "./mark.js";
import type {ProjectionImplementation} from "./projection.js";

/** Additional rendering context provided to marks and initializers. */
export interface Context {
Expand All @@ -16,7 +17,7 @@ export interface Context {
className: string;

/** The current projection, if any. */
projection?: GeoStreamWrapper;
projection?: ProjectionImplementation;

/** A function to draw GeoJSON with the current projection, if any, otherwise with the x and y scales. */
path: () => GeoPath;
Expand Down
6 changes: 6 additions & 0 deletions src/plot.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,12 @@ export interface Plot {
*/
scale(name: ScaleName): Scale | undefined;

/**
* Returns this plot’s projection, or undefined if this plot does not use a
* projection.
*/
scale(name: "projection"): ProjectionImplementation | undefined;

/**
* Generates a legend for the scale with the specified *name* and the given
* *options*, returning either an SVG or HTML element depending on the scale
Expand Down
2 changes: 1 addition & 1 deletion src/plot.js
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,7 @@ export function plot(options = {}) {
if ("value" in svg) (figure.value = svg.value), delete svg.value;
}

figure.scale = exposeScales(scales.scales);
figure.scale = exposeScales(scales.scales, context);
figure.legend = exposeLegends(scaleDescriptors, context, options);

const w = consumeWarnings();
Expand Down
4 changes: 2 additions & 2 deletions src/scales.js
Original file line number Diff line number Diff line change
Expand Up @@ -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];
};
}

Expand Down
17 changes: 16 additions & 1 deletion test/assert.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,25 @@ async function doesNotWarnAsync(run) {
return result;
}

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 {
...assert,
warns,
warnsAsync,
doesNotWarn,
doesNotWarnAsync
doesNotWarnAsync,
allCloseTo
};
172 changes: 171 additions & 1 deletion test/scales/scales-test.js
Original file line number Diff line number Diff line change
@@ -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 = [
Expand Down Expand Up @@ -2309,3 +2309,173 @@ 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 projection = plot.scale("projection");
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(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 = applyProjection(p, point);
assert.ok(Array.isArray(px));
assert.strictEqual(px.length, 2);
// 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 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.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", () => {
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.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", () => {
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.allCloseTo(applyProjection(p1, point), applyProjection(p2, point));
});

it("round-trips with different dimensions", () => {
const plot1 = Plot.plot({width: 640, projection: "mercator", marks: [Plot.graticule()]});
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
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);
// });
});
Loading