Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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)]});
```
19 changes: 17 additions & 2 deletions src/plot.d.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
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,
ProjectionOptions,
ProjectionScale
} from "./projection.js";
import type {Scale, ScaleDefaults, ScaleName, ScaleOptions} from "./scales.js";

export interface PlotOptions extends ScaleDefaults {
Expand Down Expand Up @@ -400,11 +406,20 @@ export interface PlotFacetOptions {
* methods to allow sharing of scales and legends across plots.
*/
export interface Plot {
/**
* 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"): ProjectionScale | undefined;
/**
* Returns this plot’s scale with the given *name*, or undefined if this plot
* does not use the specified scale.
*/
scale(name: ScaleName): Scale | undefined;
scale(name: ScaleName | "projection"): Scale | ProjectionScale | undefined;
Comment thread
mbostock marked this conversation as resolved.
Outdated

/**
* Generates a legend for the scale with the specified *name* and the given
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
39 changes: 39 additions & 0 deletions src/projection.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,3 +112,42 @@ export interface ProjectionOptions extends InsetOptions {
*/
clip?: boolean | number | "frame" | null;
}

/** A materialized projection, as returned by plot.scale("projection"). */
export interface ProjectionScale {
/** The projection type name or factory function. */
type: ProjectionName | ProjectionFactory;

/** The GeoJSON domain, if specified. */
domain?: GeoPermissibleObjects;

/** The projection rotation [lambda, phi, gamma]. */
rotate?: [number, number, number?];

/** The standard parallels (conic projections). */
parallels?: [number, number];

/** The sampling threshold. */
precision?: number;

/** The clipping method. */
clip?: boolean | number | "frame" | null;

/** Top inset in pixels. */
insetTop: number;

/** Right inset in pixels. */
insetRight: number;

/** Bottom inset in pixels. */
insetBottom: number;

/** Left inset in pixels. */
insetLeft: number;

/** Project [longitude, latitude] to [x, y] pixel coordinates. */
apply(point: [number, number]): [number, number] | undefined;

/** Invert [x, y] pixel coordinates to [longitude, latitude]. */
invert?(point: [number, number]): [number, number] | undefined;
}
52 changes: 44 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,38 @@ export function createProjection(
}
});

return {stream: (s) => projection.stream(transform.stream(clip(s)))};
const stream = (s) => projection.stream(transform.stream(postClip(s)));

return {
type,
...(domain != null && {domain}),
...(options?.rotate != null && {rotate: options.rotate}),
...(options?.parallels != null && {parallels: options.parallels}),
...(options?.precision != null && {precision: options.precision}),
...(clip !== "frame" && {clip}),
...(insetTop && {insetTop}),
...(insetRight && {insetRight}),
...(insetBottom && {insetBottom}),
...(insetLeft && {insetLeft}),
stream,
apply([x, y] = []) {
let result;
const s = projection.stream(
transform.stream({
point(x, y) {
result = [x, y];
}
})
);
s.point(x, y);
return result;
},
invert([x, y] = []) {
const px = (x - tx) / (k ?? 1);
const py = (y - ty) / (k ?? 1);
return projection.invert ? projection.invert([px, py]) : [px, py];
}
};
Comment thread
mbostock marked this conversation as resolved.
}

function namedProjection(projection) {
Expand Down Expand Up @@ -195,15 +230,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
14 changes: 13 additions & 1 deletion test/assert.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,22 @@ async function doesNotWarnAsync(run) {
return result;
}

function inDelta(actual, expected, delta = 1e-6) {
if (Array.isArray(expected)) {
assert.strictEqual(actual.length, expected.length);
for (let i = 0; i < expected.length; i++) {
inDelta(actual[i], expected[i], delta);
}
} else {
assert.ok(Math.abs(actual - expected) < delta, `${actual} is not within ${delta} of ${expected}`);
}
}

export default {
...assert,
warns,
warnsAsync,
doesNotWarn,
doesNotWarnAsync
doesNotWarnAsync,
inDelta
};
Loading
Loading