diff --git a/README.md b/README.md index 955a6ec8ac..163cc70b05 100644 --- a/README.md +++ b/README.md @@ -2836,8 +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* +* *projection* - use the (possibly spherical) [projection](#projection-options) -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). +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 *projection* 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). diff --git a/src/marks/line.js b/src/marks/line.js index bb4c7fffaa..80a21f0564 100644 --- a/src/marks/line.js +++ b/src/marks/line.js @@ -1,8 +1,9 @@ -import {line as shapeLine} from "d3"; +import {geoPath, line as shapeLine} from "d3"; import {create} from "../context.js"; import {Curve} from "../curve.js"; import {indexOf, identity, maybeTuple, maybeZ} from "../options.js"; import {Mark} from "../plot.js"; +import {coerceNumbers} from "../scales.js"; import { applyDirectStyles, applyIndirectStyles, @@ -23,21 +24,32 @@ const defaults = { strokeMiterlimit: 1 }; +const curveProjection = Symbol("projection"); + +// For the “projection” 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() === "projection" + ? curveProjection + : Curve(curve, tension); +} + export class Line extends Mark { constructor(data, options = {}) { - const {x, y, z, curve, tension} = options; + const {x, y, z} = options; + const curve = LineCurve(options); super( data, { - x: {value: x, scale: "x"}, - y: {value: y, scale: "y"}, + x: {value: x, scale: curve === curveProjection ? undefined : "x"}, // unscaled if projected + y: {value: y, scale: curve === curveProjection ? undefined : "y"}, // unscaled if projected z: {value: maybeZ(options), optional: true} }, options, defaults ); this.z = z; - this.curve = Curve(curve, tension); + this.curve = curve; markers(this, options); } filter(index) { @@ -45,6 +57,7 @@ export class Line extends Mark { } render(index, scales, channels, dimensions, context) { const {x: X, y: Y} = channels; + const {curve} = this; return create("svg:g", context) .call(applyIndirectStyles, this, scales, dimensions, context) .call(applyTransform, this, scales) @@ -59,17 +72,39 @@ export class Line extends Mark { .call(applyGroupedMarkers, this, channels) .attr( "d", - shapeLine() - .curve(this.curve) - .defined((i) => i >= 0) - .x((i) => X[i]) - .y((i) => Y[i]) + curve === curveProjection + ? sphereLine(context.projection, X, Y) + : shapeLine() + .curve(curve) + .defined((i) => i >= 0) + .x((i) => X[i]) + .y((i) => Y[i]) ) ) .node(); } } +function sphereLine(projection, X, Y) { + const path = geoPath(projection); + X = coerceNumbers(X); + Y = coerceNumbers(Y); + return (I) => { + let line = []; + const lines = [line]; + for (const i of I) { + // Check for undefined value; see groupIndex. + if (i === -1) { + line = []; + lines.push(line); + } else { + line.push([X[i], Y[i]]); + } + } + return path({type: "MultiLineString", coordinates: lines}); + }; +} + /** @jsdoc line */ export function line(data, options = {}) { let {x, y, ...remainingOptions} = options; diff --git a/test/data/README.md b/test/data/README.md index 37c82c5ba7..5cc6a5ee29 100644 --- a/test/data/README.md +++ b/test/data/README.md @@ -19,6 +19,9 @@ https://www.flother.is/2017/olympic-games-data/ ## barley.csv http://search.r-project.org/R/library/lattice/html/barley.html +## beagle.csv +https://observablehq.com/@bmschmidt/data-driven-projections-darwins-world + ## bls-metro-unemployment.csv Bureau of Labor Statistics https://www.bls.gov/ diff --git a/test/data/beagle.csv b/test/data/beagle.csv new file mode 100644 index 0000000000..c7a6c873c2 --- /dev/null +++ b/test/data/beagle.csv @@ -0,0 +1,303 @@ +-4.17,50.37 +-7.01,47.88 +-8.75,46.24 +-9.69,45.34 +-10.75,44.27 +-11.6,43.42 +-15.05,38.4 +-15.53,37.48 +-16.54,34.36 +-21.27,20.48 +-23.4,15.05 +-23.5,14.9 +-24.29,14.22 +-25.08,13.55 +-25.32,13.28 +-26.57,11.87 +-38.42,-13.48 +-36.32,-17.2 +-38.67,-17.96 +-43.13,-22.9 +-38.75,-16.92 +-38.52,-20.17 +-40.52,-23.07 +-42.68,-23.08 +-43.13,-22.9 +-43.13,-22.9 +-45.73,-27.13 +-48.09,-30.17 +-50.3,-33.35 +-53.32,-34.98 +-58.36,-34.59 +-56.6,-36.38 +-56.64,-36.51 +-57.96,-38.6 +-58.79,-38.77 +-61.06,-39.23 +-60.91,-39.27 +-60.81,-39.29 +-60.67,-39.32 +-60.6,-39.34 +-60.43,-39.38 +-60.39,-39.39 +-60.29,-39.41 +-60.28,-39.41 +-60.21,-39.43 +-60.07,-39.46 +-59.94,-39.5 +-59.87,-39.51 +-59.52,-39.53 +-58.92,-39.41 +-57.17,-38.85 +-57.75,-34.68 +-56.58,-35.13 +-56.18,-34.87 +-58.01,-39.19 +-58.17,-39.33 +-59.72,-40.05 +-62.1,-40.8 +-61.49,-42.15 +-61.37,-43.57 +-63.37,-46.28 +-68.16,-56.48 +-69.31,-57.01 +-69.4,-56.26 +-69.38,-56.25 +-71.15,-56.73 +-69.4,-56.12 +-69.21,-56.29 +-68.74,-55.98 +-63.33,-53.3 +-62.88,-53.3 +-59.36,-50.12 +-59.92,-49.07 +-61.6,-47.2 +-62.33,-43.02 +-62.62,-41.13 +-62.66,-41.15 +-60.17,-40.48 +-55.54,-37.01 +-55.5,-37.06 +-54.95,-34.9 +-55.78,-34.9 +-54.87,-34.9 +-53.28,-35.23 +-58.07,-38.15 +-59.29,-40.13 +-59.14,-38.31 +-58.88,-38.09 +-61.97,-41.67 +-56.2,-34.9 +-54.95,-34.9 +-56.24,-35.18 +-56.97,-37.77 +-55.81,-34.9 +-56.45,-37.18 +-57.02,-37.82 +-58.4,-41.25 +-58.82,-42.53 +-60.19,-43.53 +-60.77,-44.2 +-64.43,-46.79 +-65.48,-47.63 +-66.37,-48.77 +-67.4,-49 +-66.01,-49.31 +-70.55,-53.11 +-70.71,-53.34 +-70.79,-53.43 +-70.91,-53.63 +-70.53,-53.59 +-70.51,-53.58 +-70.11,-53.54 +-68.93,-53.41 +-68.36,-53.34 +-68.23,-53.33 +-69.78,-52.81 +-70.11,-54.02 +-72.78,-52.38 +-72.6,-52.51 +-67.01,-55.24 +-66.64,-55.15 +-65.81,-54.83 +-60.8,-49.44 +-64.15,-50.17 +-65.08,-49.77 +-66.63,-50.95 +-66.76,-52.47 +-66.71,-52.28 +-66.71,-52.46 +-70.69,-53.3 +-70.93,-53.63 +-72.06,-54.13 +-72.91,-54.5 +-74.99,-54.78 +-76.23,-44.45 +-74.14,-43.29 +-73.87,-43.18 +-75.08,-42.69 +-74.96,-42.23 +-75.01,-42.19 +-75.44,-41.83 +-73.75,-35.95 +-73.18,-34.6 +-72.89,-34.17 +-71.95,-33.27 +-71.85,-33.19 +-78.03,-39.85 +-76.35,-41.48 +-76.28,-41.53 +-77.34,-43.72 +-76.75,-44.3 +-76.3,-44.44 +-74.54,-44.44 +-73.9,-43.54 +-74.09,-44 +-74.34,-45.03 +-74.6,-45.3 +-75.05,-45.9 +-75.56,-46.58 +-74.33,-44.57 +-74.05,-43.81 +-73.5,-39.83 +-73.53,-39.72 +-73.6,-38.95 +-73.77,-38.95 +-73.91,-38.3 +-73.08,-36.83 +-72.86,-35.55 +-72.82,-35.28 +-72.43,-33.75 +-72.27,-33.61 +-79.05,-34.18 +-81.16,-35.03 +-76.25,-35.63 +-73.04,-36.64 +-71.6,-33 +-71.59,-32.69 +-71.54,-32.39 +-71.28,-31.58 +-71.68,-30.22 +-71.43,-29.97 +-74.41,-31.44 +-71.65,-27.68 +-71.48,-25.53 +-70.32,-20.29 +-72.32,-18.78 +-72.78,-18.47 +-83.32,-6.87 +-89.95,-0.84 +-91.2,-1.09 +-91.33,-0.55 +-91.24,-0.36 +-90.66,0.52 +-90.67,0.26 +-91.29,-0.57 +-90.85,0.16 +-90.84,0.22 +-92.41,1.38 +-99.48,-0.92 +-104.57,-4.9 +-105.44,-5.48 +-107,-6.53 +-126.11,-11.71 +-142.07,-15.39 +-146.09,-16.09 +-147.83,-16.79 +-148.52,-17.08 +-148.67,-17.15 +-149.26,-17.4 +-152.63,-17.33 +-153.93,-17.58 +-155,-17.9 +-160.64,-19.3 +-165.21,-21.14 +-167,-22.03 +-169.98,-23.1 +-172.81,-24.23 +-179.87,-28.13 +179.93,-28.36 +179.67,-28.66 +174.28,-35.28 +174,-35.98 +164.88,-34.33 +163.97,-34.35 +159.9,-35.1 +150.44,-39.11 +149.93,-42.8 +148.24,-42.86 +147.96,-42.87 +145.23,-44.12 +133.38,-41.66 +132.35,-41.43 +129.02,-40.57 +125.48,-39.63 +123.3,-38.02 +123.2,-37.92 +122.76,-37.58 +117.29,-35.03 +113.89,-34.87 +112.97,-32.72 +109.52,-28.13 +105.73,-22.96 +104.26,-20.76 +97.54,-12.42 +97.38,-12.34 +97.36,-12.33 +97.07,-12.17 +44.99,-26.58 +42.67,-27.52 +42.55,-27.52 +42.13,-27.51 +37.87,-27.83 +36.97,-27.97 +22.76,-34.79 +21.92,-35.46 +21.05,-35.2 +20.37,-35.08 +19.97,-34.87 +20.03,-35.18 +19.85,-35.19 +17.13,-34.95 +17.06,-34.95 +16.92,-34.95 +16.91,-31.68 +15.48,-30.48 +5.22,-23.07 +3.27,-21 +-0.53,-18.87 +-3.72,-17.88 +-6.49,-15.29 +-8.21,-13.94 +-22.72,-11.28 +-23.87,-11.44 +-38.51,-12.98 +-38.25,-13.03 +-36.28,-11.5 +-35.31,-9.68 +-35.1,-7.97 +-34.86,-8.06 +-34.83,-7.96 +-34.42,-6.67 +-29.5,2.3 +-24.3,9.95 +-23.42,12.86 +-23.27,13.9 +-25.35,14.4 +-30.61,19.05 +-32.42,23.12 +-32.6,23.33 +-34.57,25.68 +-35.78,27.87 +-34.5,32.68 +-32.37,35.25 +-28.86,37.25 +-27.55,38.18 +-27.09,38.76 +-26.14,38.07 +-24.5,39.33 +-23.7,39.92 +-13.93,45.63 +-8.97,48.25 +-5.05,50.14 +-4.17,50.37 \ No newline at end of file diff --git a/test/output/beagle.svg b/test/output/beagle.svg new file mode 100644 index 0000000000..899ca78c26 --- /dev/null +++ b/test/output/beagle.svg @@ -0,0 +1,326 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/plots/beagle.js b/test/plots/beagle.js new file mode 100644 index 0000000000..79a1302ab5 --- /dev/null +++ b/test/plots/beagle.js @@ -0,0 +1,22 @@ +import * as Plot from "@observablehq/plot"; +import * as d3 from "d3"; +import {feature} from "topojson-client"; + +export default async function () { + const world = await d3.json("data/countries-50m.json"); + // note: this returns strings; we should clean it up to make a better example, + // but we keep it to help test the projection’s robustness + const beagle = await d3.text("data/beagle.csv").then(d3.csvParseRows); + const land = feature(world, world.objects.land); + return Plot.plot({ + width: 960, + height: 480, + projection: {type: "equal-earth", rotate: [-10, 0]}, + marks: [ + Plot.geo(land, {fill: "currentColor"}), + Plot.graticule(), + Plot.line(beagle, {stroke: (d, i) => i, z: null, curve: "projection"}), + Plot.sphere() + ] + }); +} diff --git a/test/plots/index.js b/test/plots/index.js index 6ed2454083..d3e5284ea7 100644 --- a/test/plots/index.js +++ b/test/plots/index.js @@ -25,6 +25,7 @@ export {default as athletesWeightCumulative} from "./athletes-weight-cumulative. export {default as availability} from "./availability.js"; export {default as ballotStatusRace} from "./ballot-status-race.js"; export {default as bandClip} from "./band-clip.js"; +export {default as beagle} from "./beagle.js"; export {default as beckerBarley} from "./becker-barley.js"; export {default as binStrings} from "./bin-strings.js"; export {default as binTimestamps} from "./bin-timestamps.js";