From 48ad69d9bdcb3a1699ee975c02f928a53b83313c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Tue, 26 Mar 2024 17:13:39 +0100 Subject: [PATCH 1/2] expose projection --- src/plot.d.ts | 14 +++++++++- src/plot.js | 3 ++- src/projection.d.ts | 23 ++++++++++++++++ src/projection.js | 65 ++++++++++++++++++++++++++++++--------------- 4 files changed, 81 insertions(+), 24 deletions(-) diff --git a/src/plot.d.ts b/src/plot.d.ts index 05fc238dc5..e4951e9040 100644 --- a/src/plot.d.ts +++ b/src/plot.d.ts @@ -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 { @@ -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 diff --git a/src/plot.js b/src/plot.js index 541218f306..de2214cd00 100644 --- a/src/plot.js +++ b/src/plot.js @@ -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"; @@ -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); diff --git a/src/projection.d.ts b/src/projection.d.ts index 8856c28c6b..63a6f79a02 100644 --- a/src/projection.d.ts +++ b/src/projection.d.ts @@ -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). @@ -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; + /** The projection’s stream. */ + stream: GeoStreamWrapper["stream"]; + rotate: ProjectionOptions["rotate"]; + /** The projection’s reference scale. */ + scale: number; + parallels: ProjectionOptions["parallels"]; + precision: ProjectionOptions["precision"]; +} diff --git a/src/projection.js b/src/projection.js index 26afac4241..51c74da93d 100644 --- a/src/projection.js +++ b/src/projection.js @@ -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"; @@ -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; @@ -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)); @@ -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.`); } @@ -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}`); } @@ -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); @@ -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); @@ -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 + }); +} From de9ca9c1de0a8fec50cdab5cbae08d9480868b30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Fri, 3 Jan 2025 15:44:38 +0100 Subject: [PATCH 2/2] dataify --- src/plot.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plot.js b/src/plot.js index e57349940b..10c51debb1 100644 --- a/src/plot.js +++ b/src/plot.js @@ -10,7 +10,7 @@ import {axisFx, axisFy, axisX, axisY, gridFx, gridFy, gridX, gridY} from "./mark import {frame} from "./marks/frame.js"; import {tip} from "./marks/tip.js"; import {isColor, isIterable, isNone, isScaleOptions} from "./options.js"; -import {arrayify, lengthof, map, yes, maybeIntervalTransform, subarray} from "./options.js"; +import {dataify, lengthof, map, yes, maybeIntervalTransform, subarray} from "./options.js"; import {createProjection, exposeProjection, getGeometryChannels, hasProjection, xyProjection} from "./projection.js"; import {createScales, createScaleFunctions, autoScaleRange, exposeScales} from "./scales.js"; import {innerDimensions, outerDimensions} from "./scales.js";