Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

expose projection #2038

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
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
14 changes: 13 additions & 1 deletion 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 {LegendOptions} from "./legends.js";
import type {Data, MarkOptions, Markish} from "./mark.js";
import type {ProjectionFactory, ProjectionImplementation, ProjectionName, ProjectionOptions} from "./projection.js";
import type {
Projection,
ProjectionFactory,
ProjectionImplementation,
ProjectionName,
ProjectionOptions
} from "./projection.js";
import type {Scale, ScaleDefaults, ScaleName, ScaleOptions} from "./scales.js";

export interface PlotOptions extends ScaleDefaults {
Expand Down Expand Up @@ -404,6 +410,12 @@ export interface Plot {
*/
scale(name: ScaleName): Scale | undefined;

/**
* Returns this plot’s projection, or undefined if this plot does not use a
* projection.
*/
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
3 changes: 2 additions & 1 deletion src/plot.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {frame} from "./marks/frame.js";
import {tip} from "./marks/tip.js";
import {isColor, isIterable, isNone, isScaleOptions} from "./options.js";
import {arrayify, map, yes, maybeIntervalTransform, subarray} from "./options.js";
import {createProjection, getGeometryChannels, hasProjection} from "./projection.js";
import {createProjection, exposeProjection, getGeometryChannels, hasProjection} from "./projection.js";
import {createScales, createScaleFunctions, autoScaleRange, exposeScales} from "./scales.js";
import {innerDimensions, outerDimensions} from "./scales.js";
import {isPosition, registry as scaleRegistry} from "./scales/index.js";
Expand Down Expand Up @@ -334,6 +334,7 @@ export function plot(options = {}) {
if (caption != null) figure.append(createFigcaption(document, caption));
}

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

Expand Down
23 changes: 23 additions & 0 deletions src/projection.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,12 @@ export interface ProjectionOptions extends InsetOptions {
*/
type?: ProjectionName | ProjectionFactory | null;

/**
* The projection’s name. If you pass a projection function, you can mention
* its name which will be passed through to the exposed *plot*.projection().
*/
name?: string;

/**
* A GeoJSON object to fit to the plot’s frame (minus insets); defaults to a
* Sphere for spherical projections (outline of the the whole globe).
Expand Down Expand Up @@ -112,3 +118,20 @@ export interface ProjectionOptions extends InsetOptions {
*/
clip?: boolean | number | "frame" | null;
}

/**
* A materialized projection, as returned by *plot*.projection()
*/
export interface Projection {
/** The projection’s name, if specified. */
name?: string;
/** A function that projects a point coordinates. */
point: (point: [number, number]) => [x: number, y: number] | undefined;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be apply to match a scale? (And we might want an invert, too?)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, but I was wondering if apply should be for geometries, not points. However we can handle both types since they're easy to detect (geometries have a .type property).

/** The projection’s stream. */
stream: GeoStreamWrapper["stream"];
rotate: ProjectionOptions["rotate"];
/** The projection’s reference scale. */
scale: number;
parallels: ProjectionOptions["parallels"];
precision: ProjectionOptions["precision"];
}
65 changes: 43 additions & 22 deletions src/projection.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export function createProjection(
if (projection == null) return;
if (typeof projection.stream === "function") return projection; // d3 projection
let options;
let name;
let domain;
let clip = "frame";

Expand All @@ -58,13 +59,14 @@ export function createProjection(
insetBottom = inset !== undefined ? inset : insetBottom,
insetLeft = inset !== undefined ? inset : insetLeft,
clip = clip,
name,
...options
} = projection);
if (projection == null) return;
}

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

// Compute the frame dimensions and invoke the projection initializer.
const {width, height, marginLeft, marginRight, marginTop, marginBottom} = dimensions;
Expand All @@ -82,6 +84,7 @@ export function createProjection(
let transform;

// If a domain is specified, fit the projection to the frame.
let scale = projection.scale?.() || 1;
if (domain != null) {
const [[x0, y0], [x1, y1]] = geoPath(projection).bounds(domain);
const k = Math.min(dx / (x1 - x0), dy / (y1 - y0));
Expand All @@ -93,6 +96,7 @@ export function createProjection(
this.stream.point(x * k + tx, y * k + ty);
}
});
scale *= k;
} else {
warn(`Warning: the projection could not be fit to the specified domain; using the default scale.`);
}
Expand All @@ -107,43 +111,45 @@ export function createProjection(
}
});

return {stream: (s) => projection.stream(transform.stream(clip(s)))};
console.warn({name, ...options, scale});
return {name, stream: (s) => projection.stream(transform.stream(clip(s))), ...options, scale};
}

function namedProjection(projection) {
switch (`${projection}`.toLowerCase()) {
const name = `${projection}`.toLowerCase();
switch (name) {
case "albers-usa":
return scaleProjection(geoAlbersUsa, 0.7463, 0.4673);
return scaleProjection(name, geoAlbersUsa, 0.7463, 0.4673);
case "albers":
return conicProjection(geoAlbers, 0.7463, 0.4673);
return conicProjection(name, geoAlbers, 0.7463, 0.4673);
case "azimuthal-equal-area":
return scaleProjection(geoAzimuthalEqualArea, 4, 4);
return scaleProjection(name, geoAzimuthalEqualArea, 4, 4);
case "azimuthal-equidistant":
return scaleProjection(geoAzimuthalEquidistant, tau, tau);
return scaleProjection(name, geoAzimuthalEquidistant, tau, tau);
case "conic-conformal":
return conicProjection(geoConicConformal, tau, tau);
return conicProjection(name, geoConicConformal, tau, tau);
case "conic-equal-area":
return conicProjection(geoConicEqualArea, 6.1702, 2.9781);
return conicProjection(name, geoConicEqualArea, 6.1702, 2.9781);
case "conic-equidistant":
return conicProjection(geoConicEquidistant, 7.312, 3.6282);
return conicProjection(name, geoConicEquidistant, 7.312, 3.6282);
case "equal-earth":
return scaleProjection(geoEqualEarth, 5.4133, 2.6347);
return scaleProjection(name, geoEqualEarth, 5.4133, 2.6347);
case "equirectangular":
return scaleProjection(geoEquirectangular, tau, pi);
return scaleProjection(name, geoEquirectangular, tau, pi);
case "gnomonic":
return scaleProjection(geoGnomonic, 3.4641, 3.4641);
return scaleProjection(name, geoGnomonic, 3.4641, 3.4641);
case "identity":
return {type: identity};
return {name, type: identity};
case "reflect-y":
return {type: reflectY};
return {name, type: reflectY};
case "mercator":
return scaleProjection(geoMercator, tau, tau);
return scaleProjection(name, geoMercator, tau, tau);
case "orthographic":
return scaleProjection(geoOrthographic, 2, 2);
return scaleProjection(name, geoOrthographic, 2, 2);
case "stereographic":
return scaleProjection(geoStereographic, 2, 2);
return scaleProjection(name, geoStereographic, 2, 2);
case "transverse-mercator":
return scaleProjection(geoTransverseMercator, tau, tau);
return scaleProjection(name, geoTransverseMercator, tau, tau);
default:
throw new Error(`unknown projection type: ${projection}`);
}
Expand All @@ -160,8 +166,9 @@ function maybePostClip(clip, x1, y1, x2, y2) {
}
}

function scaleProjection(createProjection, kx, ky) {
function scaleProjection(name, createProjection, kx, ky) {
return {
name,
type: ({width, height, rotate, precision = 0.15, clip}) => {
const projection = createProjection();
if (precision != null) projection.precision?.(precision);
Expand All @@ -175,9 +182,10 @@ function scaleProjection(createProjection, kx, ky) {
};
}

function conicProjection(createProjection, kx, ky) {
const {type, aspectRatio} = scaleProjection(createProjection, kx, ky);
function conicProjection(name, createProjection, kx, ky) {
const {type, aspectRatio} = scaleProjection(name, createProjection, kx, ky);
return {
name,
type: (options) => {
const {parallels, domain, width, height} = options;
const projection = type(options);
Expand Down Expand Up @@ -285,3 +293,16 @@ export function getGeometryChannels(channel) {
for (const object of channel.value) geoStream(object, sink);
return [x, y];
}

export function exposeProjection(projection) {
if (projection === undefined) return projection;
const {name, stream, ...options} = projection;
let x, y;
const pointProjection = stream({point: (x_, y_) => ((x = x_), (y = y_))});
return () => ({
name,
stream,
point: (coordinates) => (geoStream({type: "Point", coordinates}, pointProjection), [x, y]),
...options
});
}
Loading