Skip to content
Merged
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: 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, 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()]});
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 {Projection} 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?: Projection;

/** A function to draw GeoJSON with the current projection, if any, otherwise with the x and y scales. */
path: () => GeoPath;
Expand Down
7 changes: 7 additions & 0 deletions src/plot.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -406,6 +407,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"): Projection | 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
9 changes: 9 additions & 0 deletions src/projection.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
86 changes: 65 additions & 21 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 projection; // d3 projection
if (typeof projection.stream === "function") return exposeProjection(projection); // projection implementation
let options;
let domain;
let clip = "frame";
Expand Down Expand Up @@ -80,34 +80,48 @@ 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;
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) => projection.stream(transform.stream(clip(s)))};
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;
},
...(projection.invert && {
invert(p) {
return projection.invert(transform.invert(p));
}
})
};
}

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) {
Expand Down Expand Up @@ -195,15 +209,45 @@ 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]
});

function translate(tx, ty) {
return tx === 0 && ty === 0
? identity()
: {
...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.
// Note: mutates values!
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
};
112 changes: 111 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,113 @@ 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(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("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("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 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.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", () => {
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.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", () => {
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");
// Same dimensions, so pixel coordinates match
const point = [-1.55, 47.22];
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 projection1 = plot1.scale("projection");
const plot2 = Plot.plot({width: 300, projection: projection1, marks: [Plot.graticule()]});
const projection2 = plot2.scale("projection");
// 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]);
});
});