Skip to content

Commit c6a49aa

Browse files
mbostockFil
andauthored
allow marks to override channel projection (#1171)
* allow marks to override channel projection * simplify * curve: "auto" instead of "projected" * simplify * Update README * Update README * Apply suggestions from code review Co-authored-by: Philippe Rivière <[email protected]> Co-authored-by: Philippe Rivière <[email protected]>
1 parent 89db4ac commit c6a49aa

File tree

9 files changed

+75
-58
lines changed

9 files changed

+75
-58
lines changed

README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1403,7 +1403,7 @@ The **fill** defaults to none. The **stroke** defaults to currentColor if the fi
14031403
14041404
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).
14051405
1406-
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.
1406+
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.
14071407
14081408
#### Plot.line(*data*, *options*)
14091409
@@ -2836,9 +2836,9 @@ The following named curve methods are supported:
28362836
* *step* - a piecewise constant function where *y* changes at the midpoint of *x*
28372837
* *step-after* - a piecewise constant function where *y* changes after *x*
28382838
* *step-before* - a piecewise constant function where *x* changes after *y*
2839-
* *projected* - use the (possibly spherical) [projection](#projection-options)
2839+
* *auto* - like *linear*, but use the (possibly spherical) [projection](#projection-options), if any
28402840
2841-
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).
2841+
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).
28422842
28432843
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).
28442844
@@ -2861,7 +2861,7 @@ The following named markers are supported:
28612861
28622862
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.
28632863
2864-
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).
2864+
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).
28652865
28662866
## Formats
28672867

src/channel.js

Lines changed: 2 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import {ascending, descending, rollup, sort} from "d3";
22
import {first, isIterable, labelof, map, maybeValue, range, valueof} from "./options.js";
3-
import {maybeApplyProjection} from "./projection.js";
43
import {registry} from "./scales/index.js";
54
import {maybeReduce} from "./transforms/group.js";
65

@@ -25,8 +24,8 @@ export function Channels(descriptors, data) {
2524
}
2625

2726
// TODO Use Float64Array for scales with numeric ranges, e.g. position?
28-
export function valueObject(channels, scales, {projection}) {
29-
const values = Object.fromEntries(
27+
export function valueObject(channels, scales) {
28+
return Object.fromEntries(
3029
Object.entries(channels).map(([name, {scale: scaleName, value}]) => {
3130
let scale;
3231
if (scaleName !== undefined) {
@@ -35,22 +34,6 @@ export function valueObject(channels, scales, {projection}) {
3534
return [name, scale === undefined ? value : map(value, scale)];
3635
})
3736
);
38-
39-
// If there is a projection, and there are both x and y channels (or x1 and
40-
// y1, or x2 andy2 channels), and those channels are associated with the x and
41-
// y scale respectively (and not already in screen coordinates as with an
42-
// initializer), then apply the projection, replacing the x and y values. Note
43-
// that the x and y scales themselves don’t exist if there is a projection,
44-
// but whether the channels are associated with scales still determines
45-
// whether the projection should apply; think of the projection as a
46-
// combination xy-scale.
47-
if (projection) {
48-
maybeApplyProjection("x", "y", channels, values, projection);
49-
maybeApplyProjection("x1", "y1", channels, values, projection);
50-
maybeApplyProjection("x2", "y2", channels, values, projection);
51-
}
52-
53-
return values;
5437
}
5538

5639
// Note: mutates channel.domain! This is set to a function so that it is lazily

src/marks/density.js

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {contourDensity, create, geoPath} from "d3";
22
import {valueObject} from "../channel.js";
33
import {isTypedArray, maybeTuple, maybeZ} from "../options.js";
44
import {Mark} from "../plot.js";
5+
import {maybeProject} from "../projection.js";
56
import {coerceNumbers} from "../scales.js";
67
import {
78
applyFrameAnchor,
@@ -92,18 +93,15 @@ function densityInitializer(options, fillDensity, strokeDensity) {
9293
const [cx, cy] = applyFrameAnchor(this, dimensions);
9394
const {width, height} = dimensions;
9495

95-
// Extract the scaled (or projected!) values for the x and y channels.
96-
let {x: X, y: Y} = valueObject(
97-
{
98-
...(channels.x && {x: channels.x}),
99-
...(channels.y && {y: channels.y})
100-
},
101-
scales,
102-
context
103-
);
96+
// Extract the (possibly) scaled values for the x and y channels.
97+
const position = valueObject({...(channels.x && {x: channels.x}), ...(channels.y && {y: channels.y})}, scales);
98+
99+
// Apply the projection.
100+
if (context.projection) maybeProject("x", "y", channels, position, context);
104101

105102
// Coerce the x and y channels to numbers (so that null is properly treated
106103
// as an undefined value rather than being coerced to zero).
104+
let {x: X, y: Y} = position;
107105
if (X) X = coerceNumbers(X);
108106
if (Y) Y = coerceNumbers(Y);
109107

src/marks/line.js

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {geoPath, line as shapeLine} from "d3";
1+
import {curveLinear, geoPath, line as shapeLine} from "d3";
22
import {create} from "../context.js";
33
import {Curve} from "../curve.js";
44
import {indexOf, identity, maybeTuple, maybeZ} from "../options.js";
@@ -24,37 +24,45 @@ const defaults = {
2424
strokeMiterlimit: 1
2525
};
2626

27-
const curveProjected = Symbol("projected");
27+
// This is a special built-in curve that will use d3.geoPath when there is a
28+
// projection, and the linear curve when there is not. You can explicitly
29+
// opt-out of d3.geoPath and instead use d3.line with the "linear" curve.
30+
function curveAuto(context) {
31+
return curveLinear(context);
32+
}
2833

29-
// For the “projected” curve, return a symbol instead of a curve
30-
// implementation; we’ll use d3.geoPath instead of d3.line to render.
31-
function LineCurve({curve, tension}) {
32-
return typeof curve !== "function" && `${curve}`.toLowerCase() === "projected"
33-
? curveProjected
34-
: Curve(curve, tension);
34+
// For the “auto” curve, return a symbol instead of a curve implementation;
35+
// we’ll use d3.geoPath instead of d3.line to render if there’s a projection.
36+
function LineCurve({curve = curveAuto, tension}) {
37+
return typeof curve !== "function" && `${curve}`.toLowerCase() === "auto" ? curveAuto : Curve(curve, tension);
3538
}
3639

3740
export class Line extends Mark {
3841
constructor(data, options = {}) {
3942
const {x, y, z} = options;
40-
const curve = LineCurve(options);
4143
super(
4244
data,
4345
{
44-
x: {value: x, scale: curve === curveProjected ? undefined : "x"}, // unscaled if projected
45-
y: {value: y, scale: curve === curveProjected ? undefined : "y"}, // unscaled if projected
46+
x: {value: x, scale: "x"},
47+
y: {value: y, scale: "y"},
4648
z: {value: maybeZ(options), optional: true}
4749
},
4850
options,
4951
defaults
5052
);
5153
this.z = z;
52-
this.curve = curve;
54+
this.curve = LineCurve(options);
5355
markers(this, options);
5456
}
5557
filter(index) {
5658
return index;
5759
}
60+
project(channels, values, context) {
61+
// For the auto curve, projection is handled at render.
62+
if (this.curve !== curveAuto) {
63+
super.project(channels, values, context);
64+
}
65+
}
5866
render(index, scales, channels, dimensions, context) {
5967
const {x: X, y: Y} = channels;
6068
const {curve} = this;
@@ -72,7 +80,7 @@ export class Line extends Mark {
7280
.call(applyGroupedMarkers, this, channels)
7381
.attr(
7482
"d",
75-
curve === curveProjected
83+
curve === curveAuto && context.projection
7684
? sphereLine(context.projection, X, Y)
7785
: shapeLine()
7886
.curve(curve)

src/plot.js

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
where,
1818
yes
1919
} from "./options.js";
20+
import {maybeProject} from "./projection.js";
2021
import {Scales, ScaleFunctions, autoScaleRange, exposeScales} from "./scales.js";
2122
import {position, registry as scaleRegistry} from "./scales/index.js";
2223
import {applyInlineStyles, maybeClassName, maybeClip, styles} from "./style.js";
@@ -169,9 +170,16 @@ export function plot(options = {}) {
169170

170171
autoScaleLabels(channelsByScale, scaleDescriptors, axes, dimensions, options);
171172

172-
// Compute value objects, applying scales and projection as needed.
173+
// Compute value objects, applying scales as needed.
173174
for (const state of stateByMark.values()) {
174-
state.values = valueObject(state.channels, scales, context);
175+
state.values = valueObject(state.channels, scales);
176+
}
177+
178+
// Apply projection as needed.
179+
if (context.projection) {
180+
for (const [mark, state] of stateByMark) {
181+
mark.project(state.channels, state.values, context);
182+
}
175183
}
176184

177185
const {width, height} = dimensions;
@@ -367,6 +375,19 @@ export class Mark {
367375
}
368376
return index;
369377
}
378+
// If there is a projection, and there are both x and y channels (or x1 and
379+
// y1, or x2 and y2 channels), and those channels are associated with the x
380+
// and y scale respectively (and not already in screen coordinates as with an
381+
// initializer), then apply the projection, replacing the x and y values. Note
382+
// that the x and y scales themselves don’t exist if there is a projection,
383+
// but whether the channels are associated with scales still determines
384+
// whether the projection should apply; think of the projection as a
385+
// combination xy-scale.
386+
project(channels, values, context) {
387+
maybeProject("x", "y", channels, values, context);
388+
maybeProject("x1", "y1", channels, values, context);
389+
maybeProject("x2", "y2", channels, values, context);
390+
}
370391
plot({marks = [], ...options} = {}) {
371392
return plot({...options, marks: [...marks, this]});
372393
}

src/projection.js

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -203,19 +203,20 @@ function conicProjection(createProjection, kx, ky) {
203203
}
204204

205205
// Applies a point-wise projection to the given paired x and y channels.
206-
export function maybeApplyProjection(cx, cy, channels, values, projection) {
206+
// Note: mutates values!
207+
export function maybeProject(cx, cy, channels, values, context) {
207208
const x = channels[cx] && channels[cx].scale === "x";
208209
const y = channels[cy] && channels[cy].scale === "y";
209210
if (x && y) {
210-
applyProjection(cx, cy, values, projection);
211+
project(cx, cy, values, context.projection);
211212
} else if (x) {
212213
throw new Error(`projection requires paired x and y channels; ${cx} is missing ${cy}`);
213214
} else if (y) {
214215
throw new Error(`projection requires paired x and y channels; ${cy} is missing ${cx}`);
215216
}
216217
}
217218

218-
function applyProjection(cx, cy, values, projection) {
219+
function project(cx, cy, values, projection) {
219220
const x = values[cx];
220221
const y = values[cy];
221222
const n = x.length;

src/transforms/hexbin.js

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {sqrt3} from "../symbols.js";
44
import {isNoneish, number, valueof} from "../options.js";
55
import {initializer} from "./basic.js";
66
import {hasOutput, maybeGroup, maybeOutputs, maybeSubgroup} from "./group.js";
7+
import {maybeProject} from "../projection.js";
78

89
// We don’t want the hexagons to align with the edges of the plot frame, as that
910
// 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} = {}) {
3132
if (options.symbol === undefined) options.symbol = "hexagon";
3233
if (options.r === undefined && !hasOutput(outputs, "r")) options.r = binWidth / 2;
3334

34-
return initializer(options, (data, facets, {x: X, y: Y, z: Z, fill: F, stroke: S, symbol: Q}, scales, _, context) => {
35+
return initializer(options, (data, facets, channels, scales, _, context) => {
36+
let {x: X, y: Y, z: Z, fill: F, stroke: S, symbol: Q} = channels;
3537
if (X === undefined) throw new Error("missing channel: x");
3638
if (Y === undefined) throw new Error("missing channel: y");
3739

38-
// Extract the scaled (or projected!) values for the x and y channels.
39-
({x: X, y: Y} = valueObject({x: X, y: Y}, scales, context));
40+
// Extract the (possibly) scaled values for the x and y channels.
41+
const position = valueObject({x: X, y: Y}, scales);
42+
43+
// Apply the projection.
44+
if (context.projection) maybeProject("x", "y", channels, position, context);
4045

4146
// Coerce the x and y channels to numbers (so that null is properly
4247
// treated as an undefined value rather than being coerced to zero).
48+
({x: X, y: Y} = position);
4349
X = coerceNumbers(X);
4450
Y = coerceNumbers(Y);
4551

@@ -84,7 +90,7 @@ export function hexbin(outputs = {fill: "count"}, {binWidth, ...options} = {}) {
8490
}
8591

8692
// Construct the output channels, and populate the radius scale hint.
87-
const channels = {
93+
const binChannels = {
8894
x: {value: BX},
8995
y: {value: BY},
9096
...(Z && {z: {value: GZ}}),
@@ -99,7 +105,7 @@ export function hexbin(outputs = {fill: "count"}, {binWidth, ...options} = {}) {
99105
)
100106
};
101107

102-
return {data, facets: binFacets, channels};
108+
return {data, facets: binFacets, channels: binChannels};
103109
});
104110
}
105111

test/marks/line-test.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import * as Plot from "@observablehq/plot";
2-
import {curveLinear, curveStep} from "d3";
2+
import {curveStep} from "d3";
33
import assert from "assert";
44

55
it("line() has the expected defaults", () => {
@@ -26,7 +26,7 @@ it("line() has the expected defaults", () => {
2626
Object.values(line.channels).map((c) => c.scale),
2727
["x", "y"]
2828
);
29-
assert.strictEqual(line.curve, curveLinear);
29+
assert.strictEqual(line.curve.name, "curveAuto");
3030
assert.strictEqual(line.fill, "none");
3131
assert.strictEqual(line.fillOpacity, undefined);
3232
assert.strictEqual(line.stroke, "currentColor");

test/plots/beagle.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export default async function () {
1515
marks: [
1616
Plot.geo(land, {fill: "currentColor"}),
1717
Plot.graticule(),
18-
Plot.line(beagle, {stroke: (d, i) => i, z: null, curve: "projected"}),
18+
Plot.line(beagle, {stroke: (d, i) => i, z: null}),
1919
Plot.sphere()
2020
]
2121
});

0 commit comments

Comments
 (0)