diff --git a/src/curve.ts b/src/curve.ts index 9c627ee03c..f9ef6ff4c7 100644 --- a/src/curve.ts +++ b/src/curve.ts @@ -20,7 +20,14 @@ import { curveStepAfter, curveStepBefore } from "d3"; -import type {CurveFactory, CurveBundleFactory, CurveCardinalFactory, CurveCatmullRomFactory} from "d3"; +import type { + CurveFactory, + CurveBundleFactory, + CurveCardinalFactory, + CurveCatmullRomFactory, + CurveGenerator, + Path +} from "d3"; type CurveFunction = CurveFactory | CurveBundleFactory | CurveCardinalFactory | CurveCatmullRomFactory; type CurveName = @@ -83,3 +90,16 @@ export function Curve(curve: CurveName | CurveFunction = curveLinear, tension?: } return c; } + +// For the “auto” curve, return a symbol instead of a curve implementation; +// we’ll use d3.geoPath to render if there’s a projection. +export function PathCurve(curve: CurveName | CurveFunction = curveAuto, tension?: number): CurveFunction { + return typeof curve !== "function" && `${curve}`.toLowerCase() === "auto" ? curveAuto : Curve(curve, tension); +} + +// 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. +export function curveAuto(context: CanvasRenderingContext2D | Path): CurveGenerator { + return curveLinear(context); +} diff --git a/src/marks/line.js b/src/marks/line.js index 77a7d080ad..60ae5f55c6 100644 --- a/src/marks/line.js +++ b/src/marks/line.js @@ -1,6 +1,6 @@ -import {curveLinear, geoPath, line as shapeLine} from "d3"; +import {geoPath, line as shapeLine} from "d3"; import {create} from "../context.js"; -import {Curve} from "../curve.js"; +import {curveAuto, PathCurve} from "../curve.js"; import {Mark} from "../mark.js"; import {indexOf, identity, maybeTuple, maybeZ} from "../options.js"; import {coerceNumbers} from "../scales.js"; @@ -24,22 +24,9 @@ const defaults = { strokeMiterlimit: 1 }; -// 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 “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 {x, y, z, curve, tension} = options; super( data, { @@ -51,7 +38,7 @@ export class Line extends Mark { defaults ); this.z = z; - this.curve = LineCurve(options); + this.curve = PathCurve(curve, tension); markers(this, options); } filter(index) { diff --git a/src/marks/link.js b/src/marks/link.js index 1c6ff42a80..69c0971404 100644 --- a/src/marks/link.js +++ b/src/marks/link.js @@ -1,7 +1,8 @@ -import {pathRound as path} from "d3"; +import {geoPath, pathRound as path} from "d3"; import {create} from "../context.js"; -import {Curve} from "../curve.js"; +import {curveAuto, PathCurve} from "../curve.js"; import {Mark} from "../mark.js"; +import {coerceNumbers} from "../scales.js"; import {applyChannelStyles, applyDirectStyles, applyIndirectStyles, applyTransform} from "../style.js"; import {markers, applyMarkers} from "./marker.js"; @@ -26,9 +27,15 @@ export class Link extends Mark { options, defaults ); - this.curve = Curve(curve, tension); + this.curve = PathCurve(curve, tension); markers(this, options); } + 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 {x1: X1, y1: Y1, x2: X2 = X1, y2: Y2 = Y1} = channels; const {curve} = this; @@ -42,15 +49,20 @@ export class Link extends Mark { .enter() .append("path") .call(applyDirectStyles, this) - .attr("d", (i) => { - const p = path(); - const c = curve(p); - c.lineStart(); - c.point(X1[i], Y1[i]); - c.point(X2[i], Y2[i]); - c.lineEnd(); - return p; - }) + .attr( + "d", + curve === curveAuto && context.projection + ? sphereLink(context.projection, X1, Y1, X2, Y2) + : (i) => { + const p = path(); + const c = curve(p); + c.lineStart(); + c.point(X1[i], Y1[i]); + c.point(X2[i], Y2[i]); + c.lineEnd(); + return p; + } + ) .call(applyChannelStyles, this, channels) .call(applyMarkers, this, channels) ) @@ -58,6 +70,22 @@ export class Link extends Mark { } } +function sphereLink(projection, X1, Y1, X2, Y2) { + const path = geoPath(projection); + X1 = coerceNumbers(X1); + Y1 = coerceNumbers(Y1); + X2 = coerceNumbers(X2); + Y2 = coerceNumbers(Y2); + return (i) => + path({ + type: "LineString", + coordinates: [ + [X1[i], Y1[i]], + [X2[i], Y2[i]] + ] + }); +} + /** @jsdoc link */ export function link(data, options = {}) { let {x, x1, x2, y, y1, y2, ...remainingOptions} = options; diff --git a/test/output/geoLink.svg b/test/output/geoLink.svg new file mode 100644 index 0000000000..6a5ecbeb61 --- /dev/null +++ b/test/output/geoLink.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/plots/geo-link.js b/test/plots/geo-link.js new file mode 100644 index 0000000000..22c042e8c5 --- /dev/null +++ b/test/plots/geo-link.js @@ -0,0 +1,14 @@ +import * as Plot from "@observablehq/plot"; + +export async function geoLink() { + const xy = {x1: [-122.4194], y1: [37.7749], x2: [2.3522], y2: [48.8566]}; + return Plot.plot({ + projection: "equal-earth", + marks: [ + Plot.sphere(), + Plot.graticule(), + Plot.link({length: 1}, {curve: "linear", strokeOpacity: 0.3, ...xy}), + Plot.link({length: 1}, {markerEnd: "arrow", ...xy}) + ] + }); +} diff --git a/test/plots/index.js b/test/plots/index.js index 68c24f37ec..3e95fe8e9f 100644 --- a/test/plots/index.js +++ b/test/plots/index.js @@ -289,6 +289,7 @@ export * from "./electricity-demand.js"; export * from "./federal-funds.js"; export * from "./frame.js"; export * from "./function-contour.js"; +export * from "./geo-link.js"; export * from "./heatmap.js"; export * from "./image-rendering.js"; export * from "./legend-color.js";