Skip to content
Open
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.
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;

/**
* 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;
}
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

// 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];
}
};
}

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});

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