Skip to content

Commit 680966f

Browse files
Filmbostock
andauthored
projection support for x1/y1 x2/y2 marks (#1139)
* support for x1/y1 x2/y2 marks * Update README.md * remove projectionParty * stricter, and improve error messages * revert change to isCollapsed * pRetTIEr * simpler Co-authored-by: Mike Bostock <[email protected]>
1 parent 7ba4e20 commit 680966f

File tree

8 files changed

+181
-27
lines changed

8 files changed

+181
-27
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -313,7 +313,7 @@ Top-level options are also supported as shorthand: **grid** (for *x* and *y* onl
313313

314314
### Projection options
315315

316-
The top-level **projection** option applies a two-dimensional (often geographic) projection in place of *x* and *y* scales. It is typically used in conjunction with a [geo mark](#geo) to produce a map, but can be used with any mark that supports *x* and *y* channels, such as [dot](#dot) and [text](#text). The following built-in named projections are supported:
316+
The top-level **projection** option applies a two-dimensional (often geographic) projection in place of *x* and *y* scales. It is typically used in conjunction with a [geo mark](#geo) to produce a map, but can be used with any mark that supports *x* and *y* channels, such as [dot](#dot), [text](#text), [arrow](#arrow), and [rect](#rect). For marks that use *x1*, *y1*, *x2*, and *y2* channels, the two projected points are ⟨*x1*, *y1*⟩ and ⟨*x2*, *y2*⟩; otherwise, the projected point is ⟨*x*, *y*. The following built-in named projections are supported:
317317

318318
* *equirectangular* - the equirectangular, or *plate carrée*, projection
319319
* *orthographic* - the orthographic projection

src/channel.js

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

@@ -26,33 +26,28 @@ export function Channels(descriptors, data) {
2626

2727
// TODO Use Float64Array for scales with numeric ranges, e.g. position?
2828
export function valueObject(channels, scales, {projection}) {
29-
let x, y; // names of channels bound to x and y scale
30-
3129
const values = Object.fromEntries(
3230
Object.entries(channels).map(([name, {scale: scaleName, value}]) => {
3331
let scale;
3432
if (scaleName !== undefined) {
35-
if (scaleName === "x") x = x === undefined ? name : "*";
36-
else if (scaleName === "y") y = y === undefined ? name : "*";
3733
scale = scales[scaleName];
3834
}
3935
return [name, scale === undefined ? value : map(value, scale)];
4036
})
4137
);
4238

43-
// If there is a projection, and there are both x and y channels, and those x
44-
// and y channels are associated with the x and y scale respectively (and not
45-
// already in screen coordinates as with an initializer), then apply the
46-
// projection, replacing the x and y values. Note that the x and y scales
47-
// themselves don’t exist if there is a projection, but whether the channels
48-
// are associated with scales still determines whether the projection should
49-
// apply; think of the projection as a combination xy-scale.
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.
5047
if (projection) {
51-
if (x === "x" && y === "y") {
52-
applyProjection(values, projection);
53-
} else if (x || y) {
54-
throw new Error("projection requires x and y channels");
55-
}
48+
maybeApplyProjection("x", "y", channels, values, projection);
49+
maybeApplyProjection("x1", "y1", channels, values, projection);
50+
maybeApplyProjection("x2", "y2", channels, values, projection);
5651
}
5752

5853
return values;

src/marks/rect.js

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ export class Rect extends Mark {
5555
const {x, y} = scales;
5656
const {x1: X1, y1: Y1, x2: X2, y2: Y2} = channels;
5757
const {marginTop, marginRight, marginBottom, marginLeft, width, height} = dimensions;
58+
const {projection} = context;
5859
const {insetTop, insetRight, insetBottom, insetLeft, rx, ry} = this;
5960
return create("svg:g", context)
6061
.call(applyIndirectStyles, this, scales, dimensions, context)
@@ -66,17 +67,27 @@ export class Rect extends Mark {
6667
.enter()
6768
.append("rect")
6869
.call(applyDirectStyles, this)
69-
.attr("x", X1 && X2 && !isCollapsed(x) ? (i) => Math.min(X1[i], X2[i]) + insetLeft : marginLeft + insetLeft)
70-
.attr("y", Y1 && Y2 && !isCollapsed(y) ? (i) => Math.min(Y1[i], Y2[i]) + insetTop : marginTop + insetTop)
70+
.attr(
71+
"x",
72+
X1 && X2 && (projection || !isCollapsed(x))
73+
? (i) => Math.min(X1[i], X2[i]) + insetLeft
74+
: marginLeft + insetLeft
75+
)
76+
.attr(
77+
"y",
78+
Y1 && Y2 && (projection || !isCollapsed(y))
79+
? (i) => Math.min(Y1[i], Y2[i]) + insetTop
80+
: marginTop + insetTop
81+
)
7182
.attr(
7283
"width",
73-
X1 && X2 && !isCollapsed(x)
84+
X1 && X2 && (projection || !isCollapsed(x))
7485
? (i) => Math.max(0, Math.abs(X2[i] - X1[i]) - insetLeft - insetRight)
7586
: width - marginRight - marginLeft - insetRight - insetLeft
7687
)
7788
.attr(
7889
"height",
79-
Y1 && Y2 && !isCollapsed(y)
90+
Y1 && Y2 && (projection || !isCollapsed(y))
8091
? (i) => Math.max(0, Math.abs(Y1[i] - Y2[i]) - insetTop - insetBottom)
8192
: height - marginTop - marginBottom - insetTop - insetBottom
8293
)

src/projection.js

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -202,11 +202,25 @@ function conicProjection(createProjection, kx, ky) {
202202
};
203203
}
204204

205-
export function applyProjection(values, projection) {
206-
const {x, y} = values;
205+
// Applies a point-wise projection to the given paired x and y channels.
206+
export function maybeApplyProjection(cx, cy, channels, values, projection) {
207+
const x = channels[cx] && channels[cx].scale === "x";
208+
const y = channels[cy] && channels[cy].scale === "y";
209+
if (x && y) {
210+
applyProjection(cx, cy, values, projection);
211+
} else if (x) {
212+
throw new Error(`projection requires paired x and y channels; ${cx} is missing ${cy}`);
213+
} else if (y) {
214+
throw new Error(`projection requires paired x and y channels; ${cy} is missing ${cx}`);
215+
}
216+
}
217+
218+
function applyProjection(cx, cy, values, projection) {
219+
const x = values[cx];
220+
const y = values[cy];
207221
const n = x.length;
208-
const X = (values.x = new Float64Array(n).fill(NaN));
209-
const Y = (values.y = new Float64Array(n).fill(NaN));
222+
const X = (values[cx] = new Float64Array(n).fill(NaN));
223+
const Y = (values[cy] = new Float64Array(n).fill(NaN));
210224
let i;
211225
const stream = projection.stream({
212226
point(x, y) {

test/marks/rule-test.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,5 +192,8 @@ it("ruleY(data, {x1, x2, y}) specifies x1, x2, y", () => {
192192
});
193193

194194
it("rule() is incompatible with a projection", () => {
195-
assert.throws(() => Plot.ruleX([]).plot({projection: {stream: () => ({})}}), /projection requires x and y channels/);
195+
assert.throws(
196+
() => Plot.ruleX([]).plot({projection: {stream: () => ({})}}),
197+
/projection requires paired x and y channels; x is missing y/
198+
);
196199
});

test/output/boundingBoxes.svg

Lines changed: 90 additions & 0 deletions
Loading

test/plots/bounding-boxes.js

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import * as Plot from "@observablehq/plot";
2+
import * as d3 from "d3";
3+
import {geoChamberlinAfrica} from "d3-geo-projection";
4+
import {feature} from "topojson-client";
5+
6+
export default async function () {
7+
const world = await d3.json("data/countries-110m.json");
8+
const land = feature(world, world.objects.land);
9+
const countries = feature(world, world.objects.countries).features;
10+
return Plot.plot({
11+
width: 600,
12+
height: 600,
13+
projection: {
14+
type: geoChamberlinAfrica,
15+
domain: {
16+
type: "MultiPoint",
17+
coordinates: [
18+
[-20, 0],
19+
[55, 0]
20+
]
21+
}
22+
},
23+
marks: [
24+
Plot.geo(land),
25+
Plot.rect(countries, {
26+
transform: (data, facets) => ({
27+
data: data.map((c) => d3.geoBounds(c).flat()), // returns [x1, y1, x2, y2]
28+
facets
29+
}),
30+
x1: "0",
31+
y1: "1",
32+
x2: "2",
33+
y2: "3",
34+
stroke: "green"
35+
}),
36+
Plot.arrow([1], {x1: -10, y1: 10, x2: 20, y2: -32, bend: true, stroke: "red"}),
37+
Plot.sphere()
38+
]
39+
});
40+
}

test/plots/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export {default as beagle} from "./beagle.js";
2929
export {default as beckerBarley} from "./becker-barley.js";
3030
export {default as binStrings} from "./bin-strings.js";
3131
export {default as binTimestamps} from "./bin-timestamps.js";
32+
export {default as boundingBoxes} from "./bounding-boxes.js";
3233
export {default as boxplot} from "./boxplot.js";
3334
export {default as caltrain} from "./caltrain.js";
3435
export {default as caltrainDirection} from "./caltrain-direction.js";

0 commit comments

Comments
 (0)