Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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.
Returns the [scale object](./scales.md#scale-options) for the scale with the specified *name* (such as *x*, *color*, or *projection*) 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.

## *plot*.legend(*name*, *options*) {#plot_legend}

Expand Down
30 changes: 30 additions & 0 deletions docs/features/projections.md
Original file line number Diff line number Diff line change
Expand Up @@ -274,3 +274,33 @@ 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 the resolved projection options, reflecting the actual values used to construct the projection.

The projection object also exposes an **apply** method that projects a [*longitude*, *latitude*] point to [*x*, *y*] pixel coordinates:

```js
projection.apply([-122.42, 37.78]) // San Francisco → [x, y]
```

An **invert** method is also available to convert [*x*, *y*] pixels back to coordinates:

```js
projection.invert([320, 240]) // [x, y] → [longitude, latitude]
```

To reuse a projection across plots, pass the projection object as the **projection** option of another plot. The projection is reconstructed from the resolved options to fit the new plot's dimensions:

```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
13 changes: 12 additions & 1 deletion src/plot.d.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
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 {ProjectionFactory, ProjectionImplementation, ProjectionName} from "./projection.js";
import type {ProjectionOptions, 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,16 @@ export interface Plot {
*/
scale(name: ScaleName): Scale | undefined;

/**
* Returns this plot’s projection, or undefined if this plot does not use a
* projection. The returned object includes the resolved projection options
* (*type*, *domain*, *rotate*, etc.), an **apply** method for projecting
* [longitude, latitude] to [x, y] pixels, and when supported, an **invert**
* method for the reverse transformation. The object can be passed as the
* **projection** option of another plot to reuse the same 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
4 changes: 4 additions & 0 deletions src/plot.js
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,10 @@ export function plot(options = {}) {
if ("value" in svg) (figure.value = svg.value), delete svg.value;
}

if (context.projection) {
const {stream: _, ...projection} = context.projection;
scales.scales.projection = projection;
Comment thread
mbostock marked this conversation as resolved.
Outdated
}
figure.scale = exposeScales(scales.scales);
figure.legend = exposeLegends(scaleDescriptors, context, options);

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 ProjectionOptions, 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;
}
43 changes: 35 additions & 8 deletions src/projection.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ export function createProjection(
if (projection == null) return;
}

const type = projection; // save before namedProjection overwrites it
Comment thread
mbostock marked this conversation as resolved.
Outdated

// For named projections, retrieve the corresponding projection initializer.
if (typeof projection !== "function") ({type: projection} = namedProjection(projection));

Expand All @@ -74,17 +76,18 @@ export function createProjection(

// The projection initializer might decide to not use a projection.
if (projection == null) return;
clip = maybePostClip(clip, marginLeft, marginTop, width - marginRight, height - marginBottom);
const postClip = maybePostClip(clip, marginLeft, marginTop, width - marginRight, height - marginBottom);

// Translate the origin to the top-left corner, respecting margins and insets.
let tx = marginLeft + insetLeft;
let ty = marginTop + insetTop;
let transform;
let k;

// 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;
Expand All @@ -94,6 +97,7 @@ export function createProjection(
}
});
} else {
k = undefined;
warn(`Warning: the projection could not be fit to the specified domain; using the default scale.`);
}
}
Expand All @@ -107,7 +111,29 @@ export function createProjection(
}
});

return {stream: (s) => projection.stream(transform.stream(clip(s)))};
return {
...options,
type,
...(domain && {domain}),
insetTop,
insetRight,
insetBottom,
insetLeft,
clip,
stream(s) {
return projection.stream(transform.stream(postClip(s)));
},
apply([x, y]) {
let result = null;
projection.stream(transform.stream({point: (x, y) => void (result = [x, y])})).point(x, y);
Comment thread
mbostock marked this conversation as resolved.
Outdated
return result;
},
...(projection.invert && {
invert([x, y]) {
return projection.invert([(x - tx) / (k ?? 1), (y - ty) / (k ?? 1)]);
Comment thread
mbostock marked this conversation as resolved.
Outdated
}
})
};
Comment thread
mbostock marked this conversation as resolved.
}

function namedProjection(projection) {
Expand Down Expand Up @@ -195,15 +221,16 @@ function conicProjection(createProjection, kx, ky) {
};
}

const identity = constant({stream: (stream) => stream});
const identity = constant({stream: (stream) => stream, invert: (point) => point});
Comment thread
mbostock marked this conversation as resolved.
Outdated

const reflectY = constant(
geoTransform({
const reflectY = constant({
...geoTransform({
point(x, y) {
this.stream.point(x, -y);
}
})
);
}),
invert: ([x, y]) => [x, -y]
});

// Applies a point-wise projection to the given paired x and y channels.
// Note: mutates values!
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
};
Loading