diff --git a/README.md b/README.md index fec2d4699b..a0acc8de91 100644 --- a/README.md +++ b/README.md @@ -1403,7 +1403,7 @@ The **fill** defaults to none. The **stroke** defaults to currentColor if the fi Points along the line are connected in input order. Likewise, if there are multiple series via the *z*, *fill*, or *stroke* channel, the series are drawn in input order such that the last series is drawn on top. Typically, the data is already in sorted order, such as chronological for time series; if sorting is needed, consider a [sort transform](#transforms). -The line mark supports [curve options](#curves) to control interpolation between points, and [marker options](#markers) to add a marker (such as a dot or an arrowhead) on each of the control points. If any of the *x* or *y* values are invalid (undefined, null, or NaN), the line will be interrupted, resulting in a break that divides the line shape into multiple segments. (See [d3-shape’s *line*.defined](https://github.com/d3/d3-shape/blob/main/README.md#line_defined) for more.) If a line segment consists of only a single point, it may appear invisible unless rendered with rounded or square line caps. In addition, some curves such as *cardinal-open* only render a visible segment if it contains multiple points. +The line mark supports [curve options](#curves) to control interpolation between points, and [marker options](#markers) to add a marker (such as a dot or an arrowhead) on each of the control points. The default curve is *auto*, which is equivalent to *linear* if there is no [projection](#projection-options), and otherwise uses the associated projection. If any of the *x* or *y* values are invalid (undefined, null, or NaN), the line will be interrupted, resulting in a break that divides the line shape into multiple segments. (See [d3-shape’s *line*.defined](https://github.com/d3/d3-shape/blob/main/README.md#line_defined) for more.) If a line segment consists of only a single point, it may appear invisible unless rendered with rounded or square line caps. In addition, some curves such as *cardinal-open* only render a visible segment if it contains multiple points. #### Plot.line(*data*, *options*) @@ -2836,9 +2836,9 @@ The following named curve methods are supported: * *step* - a piecewise constant function where *y* changes at the midpoint of *x* * *step-after* - a piecewise constant function where *y* changes after *x* * *step-before* - a piecewise constant function where *x* changes after *y* -* *projected* - use the (possibly spherical) [projection](#projection-options) +* *auto* - like *linear*, but use the (possibly spherical) [projection](#projection-options), if any -If *curve* is a function, it will be invoked with a given *context* in the same fashion as a [D3 curve factory](https://github.com/d3/d3-shape/blob/main/README.md#custom-curves). The *projected* curve is only available for the [line mark](#line) and is typically used in conjunction with a spherical [projection](#projection-options) to interpolate along [geodesics](https://en.wikipedia.org/wiki/Geodesic). +If *curve* is a function, it will be invoked with a given *context* in the same fashion as a [D3 curve factory](https://github.com/d3/d3-shape/blob/main/README.md#custom-curves). The *auto* curve is only available for the [line mark](#line) and is typically used in conjunction with a spherical [projection](#projection-options) to interpolate along [geodesics](https://en.wikipedia.org/wiki/Geodesic). The tension option only has an effect on bundle, cardinal and Catmull–Rom splines (*bundle*, *cardinal*, *cardinal-open*, *cardinal-closed*, *catmull-rom*, *catmull-rom-open*, and *catmull-rom-closed*). For bundle splines, it corresponds to [beta](https://github.com/d3/d3-shape/blob/main/README.md#curveBundle_beta); for cardinal splines, [tension](https://github.com/d3/d3-shape/blob/main/README.md#curveCardinal_tension); for Catmull–Rom splines, [alpha](https://github.com/d3/d3-shape/blob/main/README.md#curveCatmullRom_alpha). @@ -2861,7 +2861,7 @@ The following named markers are supported: If *marker* is true, it defaults to *circle*. If *marker* is a function, it will be called with a given *color* and must return an SVG marker element. -The primary color of a marker is inherited from the *stroke* of the associated mark. The *arrow* marker is [automatically oriented](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/orient) such that it points in the tangential direction of the path at the position the marker is placed. The *circle* markers are centered around the given vertex. Note that lines whose curve is not *linear* (the default), markers are not necessarily drawn at the data positions given by *x* and *y*; marker placement is determined by the (possibly Bézier) path segments generated by the curve. To ensure that symbols are drawn at a given *x* and *y* position, consider using a [dot](#dot). +The primary color of a marker is inherited from the *stroke* of the associated mark. The *arrow* marker is [automatically oriented](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/orient) such that it points in the tangential direction of the path at the position the marker is placed. The *circle* markers are centered around the given vertex. Note that for lines whose curve is not *linear*, markers are not necessarily drawn at the data positions given by *x* and *y*; marker placement is determined by the (possibly Bézier) path segments generated by the curve. To ensure that symbols are drawn at a given *x* and *y* position, consider using a [dot](#dot). ## Formats diff --git a/src/channel.js b/src/channel.js index eb8eec1fd8..2a5bdc37e6 100644 --- a/src/channel.js +++ b/src/channel.js @@ -1,6 +1,5 @@ import {ascending, descending, rollup, sort} from "d3"; import {first, isIterable, labelof, map, maybeValue, range, valueof} from "./options.js"; -import {maybeApplyProjection} from "./projection.js"; import {registry} from "./scales/index.js"; import {maybeReduce} from "./transforms/group.js"; @@ -25,8 +24,8 @@ export function Channels(descriptors, data) { } // TODO Use Float64Array for scales with numeric ranges, e.g. position? -export function valueObject(channels, scales, {projection}) { - const values = Object.fromEntries( +export function valueObject(channels, scales) { + return Object.fromEntries( Object.entries(channels).map(([name, {scale: scaleName, value}]) => { let scale; if (scaleName !== undefined) { @@ -35,22 +34,6 @@ export function valueObject(channels, scales, {projection}) { return [name, scale === undefined ? value : map(value, scale)]; }) ); - - // If there is a projection, and there are both x and y channels (or x1 and - // y1, or x2 andy2 channels), and those channels are associated with the x and - // y scale respectively (and not already in screen coordinates as with an - // initializer), then apply the projection, replacing the x and y values. Note - // that the x and y scales themselves don’t exist if there is a projection, - // but whether the channels are associated with scales still determines - // whether the projection should apply; think of the projection as a - // combination xy-scale. - if (projection) { - maybeApplyProjection("x", "y", channels, values, projection); - maybeApplyProjection("x1", "y1", channels, values, projection); - maybeApplyProjection("x2", "y2", channels, values, projection); - } - - return values; } // Note: mutates channel.domain! This is set to a function so that it is lazily diff --git a/src/marks/density.js b/src/marks/density.js index eec57de311..c862a0d540 100644 --- a/src/marks/density.js +++ b/src/marks/density.js @@ -2,6 +2,7 @@ import {contourDensity, create, geoPath} from "d3"; import {valueObject} from "../channel.js"; import {isTypedArray, maybeTuple, maybeZ} from "../options.js"; import {Mark} from "../plot.js"; +import {maybeProject} from "../projection.js"; import {coerceNumbers} from "../scales.js"; import { applyFrameAnchor, @@ -92,18 +93,15 @@ function densityInitializer(options, fillDensity, strokeDensity) { const [cx, cy] = applyFrameAnchor(this, dimensions); const {width, height} = dimensions; - // Extract the scaled (or projected!) values for the x and y channels. - let {x: X, y: Y} = valueObject( - { - ...(channels.x && {x: channels.x}), - ...(channels.y && {y: channels.y}) - }, - scales, - context - ); + // Extract the (possibly) scaled values for the x and y channels. + const position = valueObject({...(channels.x && {x: channels.x}), ...(channels.y && {y: channels.y})}, scales); + + // Apply the projection. + if (context.projection) maybeProject("x", "y", channels, position, context); // Coerce the x and y channels to numbers (so that null is properly treated // as an undefined value rather than being coerced to zero). + let {x: X, y: Y} = position; if (X) X = coerceNumbers(X); if (Y) Y = coerceNumbers(Y); diff --git a/src/marks/line.js b/src/marks/line.js index 53a9525589..95bc1c416d 100644 --- a/src/marks/line.js +++ b/src/marks/line.js @@ -1,4 +1,4 @@ -import {geoPath, line as shapeLine} from "d3"; +import {curveLinear, geoPath, line as shapeLine} from "d3"; import {create} from "../context.js"; import {Curve} from "../curve.js"; import {indexOf, identity, maybeTuple, maybeZ} from "../options.js"; @@ -24,37 +24,45 @@ const defaults = { strokeMiterlimit: 1 }; -const curveProjected = Symbol("projected"); +// This is a special built-in curve that will use d3.geoPath when there is a +// projection, and the linear curve when there is not. You can explicitly +// opt-out of d3.geoPath and instead use d3.line with the "linear" curve. +function curveAuto(context) { + return curveLinear(context); +} -// For the “projected” curve, return a symbol instead of a curve -// implementation; we’ll use d3.geoPath instead of d3.line to render. -function LineCurve({curve, tension}) { - return typeof curve !== "function" && `${curve}`.toLowerCase() === "projected" - ? curveProjected - : Curve(curve, tension); +// For the “auto” curve, return a symbol instead of a curve implementation; +// we’ll use d3.geoPath instead of d3.line to render if there’s a projection. +function LineCurve({curve = curveAuto, tension}) { + return typeof curve !== "function" && `${curve}`.toLowerCase() === "auto" ? curveAuto : Curve(curve, tension); } export class Line extends Mark { constructor(data, options = {}) { const {x, y, z} = options; - const curve = LineCurve(options); super( data, { - x: {value: x, scale: curve === curveProjected ? undefined : "x"}, // unscaled if projected - y: {value: y, scale: curve === curveProjected ? undefined : "y"}, // unscaled if projected + x: {value: x, scale: "x"}, + y: {value: y, scale: "y"}, z: {value: maybeZ(options), optional: true} }, options, defaults ); this.z = z; - this.curve = curve; + this.curve = LineCurve(options); markers(this, options); } filter(index) { return index; } + project(channels, values, context) { + // For the auto curve, projection is handled at render. + if (this.curve !== curveAuto) { + super.project(channels, values, context); + } + } render(index, scales, channels, dimensions, context) { const {x: X, y: Y} = channels; const {curve} = this; @@ -72,7 +80,7 @@ export class Line extends Mark { .call(applyGroupedMarkers, this, channels) .attr( "d", - curve === curveProjected + curve === curveAuto && context.projection ? sphereLine(context.projection, X, Y) : shapeLine() .curve(curve) diff --git a/src/plot.js b/src/plot.js index badf692b7e..f5adcfdb34 100644 --- a/src/plot.js +++ b/src/plot.js @@ -17,6 +17,7 @@ import { where, yes } from "./options.js"; +import {maybeProject} from "./projection.js"; import {Scales, ScaleFunctions, autoScaleRange, exposeScales} from "./scales.js"; import {position, registry as scaleRegistry} from "./scales/index.js"; import {applyInlineStyles, maybeClassName, maybeClip, styles} from "./style.js"; @@ -169,9 +170,16 @@ export function plot(options = {}) { autoScaleLabels(channelsByScale, scaleDescriptors, axes, dimensions, options); - // Compute value objects, applying scales and projection as needed. + // Compute value objects, applying scales as needed. for (const state of stateByMark.values()) { - state.values = valueObject(state.channels, scales, context); + state.values = valueObject(state.channels, scales); + } + + // Apply projection as needed. + if (context.projection) { + for (const [mark, state] of stateByMark) { + mark.project(state.channels, state.values, context); + } } const {width, height} = dimensions; @@ -367,6 +375,19 @@ export class Mark { } return index; } + // If there is a projection, and there are both x and y channels (or x1 and + // y1, or x2 and y2 channels), and those channels are associated with the x + // and y scale respectively (and not already in screen coordinates as with an + // initializer), then apply the projection, replacing the x and y values. Note + // that the x and y scales themselves don’t exist if there is a projection, + // but whether the channels are associated with scales still determines + // whether the projection should apply; think of the projection as a + // combination xy-scale. + project(channels, values, context) { + maybeProject("x", "y", channels, values, context); + maybeProject("x1", "y1", channels, values, context); + maybeProject("x2", "y2", channels, values, context); + } plot({marks = [], ...options} = {}) { return plot({...options, marks: [...marks, this]}); } diff --git a/src/projection.js b/src/projection.js index 404f62b87b..00ef46fced 100644 --- a/src/projection.js +++ b/src/projection.js @@ -203,11 +203,12 @@ function conicProjection(createProjection, kx, ky) { } // Applies a point-wise projection to the given paired x and y channels. -export function maybeApplyProjection(cx, cy, channels, values, projection) { +// Note: mutates values! +export function maybeProject(cx, cy, channels, values, context) { const x = channels[cx] && channels[cx].scale === "x"; const y = channels[cy] && channels[cy].scale === "y"; if (x && y) { - applyProjection(cx, cy, values, projection); + project(cx, cy, values, context.projection); } else if (x) { throw new Error(`projection requires paired x and y channels; ${cx} is missing ${cy}`); } else if (y) { @@ -215,7 +216,7 @@ export function maybeApplyProjection(cx, cy, channels, values, projection) { } } -function applyProjection(cx, cy, values, projection) { +function project(cx, cy, values, projection) { const x = values[cx]; const y = values[cy]; const n = x.length; diff --git a/src/transforms/hexbin.js b/src/transforms/hexbin.js index 3f880dff30..1b7af7f952 100644 --- a/src/transforms/hexbin.js +++ b/src/transforms/hexbin.js @@ -4,6 +4,7 @@ import {sqrt3} from "../symbols.js"; import {isNoneish, number, valueof} from "../options.js"; import {initializer} from "./basic.js"; import {hasOutput, maybeGroup, maybeOutputs, maybeSubgroup} from "./group.js"; +import {maybeProject} from "../projection.js"; // We don’t want the hexagons to align with the edges of the plot frame, as that // would cause extreme x-values (the upper bound of the default x-scale domain) @@ -31,15 +32,20 @@ export function hexbin(outputs = {fill: "count"}, {binWidth, ...options} = {}) { if (options.symbol === undefined) options.symbol = "hexagon"; if (options.r === undefined && !hasOutput(outputs, "r")) options.r = binWidth / 2; - return initializer(options, (data, facets, {x: X, y: Y, z: Z, fill: F, stroke: S, symbol: Q}, scales, _, context) => { + return initializer(options, (data, facets, channels, scales, _, context) => { + let {x: X, y: Y, z: Z, fill: F, stroke: S, symbol: Q} = channels; if (X === undefined) throw new Error("missing channel: x"); if (Y === undefined) throw new Error("missing channel: y"); - // Extract the scaled (or projected!) values for the x and y channels. - ({x: X, y: Y} = valueObject({x: X, y: Y}, scales, context)); + // Extract the (possibly) scaled values for the x and y channels. + const position = valueObject({x: X, y: Y}, scales); + + // Apply the projection. + if (context.projection) maybeProject("x", "y", channels, position, context); // Coerce the x and y channels to numbers (so that null is properly // treated as an undefined value rather than being coerced to zero). + ({x: X, y: Y} = position); X = coerceNumbers(X); Y = coerceNumbers(Y); @@ -84,7 +90,7 @@ export function hexbin(outputs = {fill: "count"}, {binWidth, ...options} = {}) { } // Construct the output channels, and populate the radius scale hint. - const channels = { + const binChannels = { x: {value: BX}, y: {value: BY}, ...(Z && {z: {value: GZ}}), @@ -99,7 +105,7 @@ export function hexbin(outputs = {fill: "count"}, {binWidth, ...options} = {}) { ) }; - return {data, facets: binFacets, channels}; + return {data, facets: binFacets, channels: binChannels}; }); } diff --git a/test/marks/line-test.js b/test/marks/line-test.js index 052925fa9a..494337420a 100644 --- a/test/marks/line-test.js +++ b/test/marks/line-test.js @@ -1,5 +1,5 @@ import * as Plot from "@observablehq/plot"; -import {curveLinear, curveStep} from "d3"; +import {curveStep} from "d3"; import assert from "assert"; it("line() has the expected defaults", () => { @@ -26,7 +26,7 @@ it("line() has the expected defaults", () => { Object.values(line.channels).map((c) => c.scale), ["x", "y"] ); - assert.strictEqual(line.curve, curveLinear); + assert.strictEqual(line.curve.name, "curveAuto"); assert.strictEqual(line.fill, "none"); assert.strictEqual(line.fillOpacity, undefined); assert.strictEqual(line.stroke, "currentColor"); diff --git a/test/plots/beagle.js b/test/plots/beagle.js index c5219f9b7f..d5d34b43eb 100644 --- a/test/plots/beagle.js +++ b/test/plots/beagle.js @@ -15,7 +15,7 @@ export default async function () { marks: [ Plot.geo(land, {fill: "currentColor"}), Plot.graticule(), - Plot.line(beagle, {stroke: (d, i) => i, z: null, curve: "projected"}), + Plot.line(beagle, {stroke: (d, i) => i, z: null}), Plot.sphere() ] });