Skip to content

allow marks to override channel projection #1171

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Dec 7, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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*)

Expand Down Expand Up @@ -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).

Expand All @@ -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

Expand Down
21 changes: 2 additions & 19 deletions src/channel.js
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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) {
Expand All @@ -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
Expand Down
16 changes: 7 additions & 9 deletions src/marks/density.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);

Expand Down
34 changes: 21 additions & 13 deletions src/marks/line.js
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;
Expand All @@ -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)
Expand Down
25 changes: 23 additions & 2 deletions src/plot.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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]});
}
Expand Down
7 changes: 4 additions & 3 deletions src/projection.js
Original file line number Diff line number Diff line change
Expand Up @@ -203,19 +203,20 @@ 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) {
throw new Error(`projection requires paired x and y channels; ${cy} is missing ${cx}`);
}
}

function applyProjection(cx, cy, values, projection) {
function project(cx, cy, values, projection) {
const x = values[cx];
const y = values[cy];
const n = x.length;
Expand Down
16 changes: 11 additions & 5 deletions src/transforms/hexbin.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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}}),
Expand All @@ -99,7 +105,7 @@ export function hexbin(outputs = {fill: "count"}, {binWidth, ...options} = {}) {
)
};

return {data, facets: binFacets, channels};
return {data, facets: binFacets, channels: binChannels};
});
}

Expand Down
4 changes: 2 additions & 2 deletions test/marks/line-test.js
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand All @@ -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");
Expand Down
2 changes: 1 addition & 1 deletion test/plots/beagle.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
]
});
Expand Down