Skip to content

Commit ce8ccab

Browse files
mbostockFil
andauthored
derive x & y scale domains from geometry (#1468)
* derive x & y scale domains from geometry * follow-up: derive x & y scale domains from geometry (#1663) * fix pending issues and add tests * optimize and comment * move hasProjection --------- Co-authored-by: Mike Bostock <[email protected]> --------- Co-authored-by: Philippe Rivière <[email protected]>
1 parent de76f93 commit ce8ccab

12 files changed

+332
-27
lines changed

src/dimensions.js

+2-3
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ export function createDimensions(scales, marks, options = {}) {
3838
// specified explicitly, adjust the automatic height accordingly.
3939
let {
4040
width = 640,
41-
height = autoHeight(scales, marks, options, {
41+
height = autoHeight(scales, options, {
4242
width,
4343
marginTopDefault,
4444
marginRightDefault,
@@ -89,14 +89,13 @@ export function createDimensions(scales, marks, options = {}) {
8989

9090
function autoHeight(
9191
{x, y, fy, fx},
92-
marks,
9392
{projection, aspectRatio},
9493
{width, marginTopDefault, marginRightDefault, marginBottomDefault, marginLeftDefault}
9594
) {
9695
const nfy = fy ? fy.scale.domain().length : 1;
9796

9897
// If a projection is specified, use its natural aspect ratio (if known).
99-
const ar = projectionAspectRatio(projection, marks);
98+
const ar = projectionAspectRatio(projection);
10099
if (ar) {
101100
const nfx = fx ? fx.scale.domain().length : 1;
102101
const far = ((1.1 * nfy - 0.1) / (1.1 * nfx - 0.1)) * ar; // 0.1 is default facet padding

src/marks/geo.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ export class Geo extends Mark {
2222
super(
2323
data,
2424
{
25-
geometry: {value: options.geometry},
25+
geometry: {value: options.geometry, scale: "projection"},
2626
r: {value: vr, scale: "r", filter: positive, optional: true}
2727
},
2828
withDefaultSort(options),

src/plot.js

+34-14
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {axisFx, axisFy, axisX, axisY, gridFx, gridFy, gridX, gridY} from "./mark
1010
import {frame} from "./marks/frame.js";
1111
import {tip} from "./marks/tip.js";
1212
import {arrayify, isColor, isIterable, isNone, isScaleOptions, map, yes, maybeIntervalTransform} from "./options.js";
13-
import {createProjection} from "./projection.js";
13+
import {createProjection, getGeometryChannels, hasProjection} from "./projection.js";
1414
import {createScales, createScaleFunctions, autoScaleRange, exposeScales} from "./scales.js";
1515
import {innerDimensions, outerDimensions} from "./scales.js";
1616
import {position, registry as scaleRegistry} from "./scales/index.js";
@@ -48,8 +48,8 @@ export function plot(options = {}) {
4848

4949
// Compute a Map from scale name to an array of associated channels.
5050
const channelsByScale = new Map();
51-
if (topFacetState) addScaleChannels(channelsByScale, [topFacetState]);
52-
addScaleChannels(channelsByScale, facetStateByMark);
51+
if (topFacetState) addScaleChannels(channelsByScale, [topFacetState], options);
52+
addScaleChannels(channelsByScale, facetStateByMark, options);
5353

5454
// Add implicit axis marks. Because this happens after faceting (because it
5555
// depends on whether faceting is present), we must initialize the facet state
@@ -139,7 +139,7 @@ export function plot(options = {}) {
139139
}
140140

141141
// Initalize the scales and dimensions.
142-
const scaleDescriptors = createScales(addScaleChannels(channelsByScale, stateByMark), options);
142+
const scaleDescriptors = createScales(addScaleChannels(channelsByScale, stateByMark, options), options);
143143
const scales = createScaleFunctions(scaleDescriptors);
144144
const dimensions = createDimensions(scaleDescriptors, marks, options);
145145

@@ -217,8 +217,8 @@ export function plot(options = {}) {
217217
// reinitialization. Preserve existing scale labels, if any.
218218
if (newByScale.size) {
219219
const newChannelsByScale = new Map();
220-
addScaleChannels(newChannelsByScale, stateByMark, (key) => newByScale.has(key));
221-
addScaleChannels(channelsByScale, stateByMark, (key) => newByScale.has(key));
220+
addScaleChannels(newChannelsByScale, stateByMark, options, (key) => newByScale.has(key));
221+
addScaleChannels(channelsByScale, stateByMark, options, (key) => newByScale.has(key));
222222
const newScaleDescriptors = inheritScaleLabels(createScales(newChannelsByScale, options), scaleDescriptors);
223223
const newScales = createScaleFunctions(newScaleDescriptors);
224224
Object.assign(scaleDescriptors, newScaleDescriptors);
@@ -410,21 +410,40 @@ function inferChannelScales(channels) {
410410
}
411411
}
412412

413-
function addScaleChannels(channelsByScale, stateByMark, filter = yes) {
413+
function addScaleChannels(channelsByScale, stateByMark, options, filter = yes) {
414414
for (const {channels} of stateByMark.values()) {
415415
for (const name in channels) {
416416
const channel = channels[name];
417417
const {scale} = channel;
418418
if (scale != null && filter(scale)) {
419-
const scaleChannels = channelsByScale.get(scale);
420-
if (scaleChannels !== undefined) scaleChannels.push(channel);
421-
else channelsByScale.set(scale, [channel]);
419+
// Geo marks affect the default x and y domains if there is no
420+
// projection. Skip this (as an optimization) when a projection is
421+
// specified, or when the domains for x and y are specified.
422+
if (scale === "projection") {
423+
if (!hasProjection(options)) {
424+
const gx = options.x?.domain === undefined;
425+
const gy = options.y?.domain === undefined;
426+
if (gx || gy) {
427+
const [x, y] = getGeometryChannels(channel);
428+
if (gx) addScaleChannel(channelsByScale, "x", x);
429+
if (gy) addScaleChannel(channelsByScale, "y", y);
430+
}
431+
}
432+
} else {
433+
addScaleChannel(channelsByScale, scale, channel);
434+
}
422435
}
423436
}
424437
}
425438
return channelsByScale;
426439
}
427440

441+
function addScaleChannel(channelsByScale, scale, channel) {
442+
const scaleChannels = channelsByScale.get(scale);
443+
if (scaleChannels !== undefined) scaleChannels.push(channel);
444+
else channelsByScale.set(scale, [channel]);
445+
}
446+
428447
// Returns the facet groups, and possibly fx and fy channels, associated with
429448
// the top-level facet option {data, x, y}.
430449
function maybeTopFacet(facet, options) {
@@ -518,8 +537,8 @@ function inferAxes(marks, channelsByScale, options) {
518537
} = options;
519538

520539
// Disable axes if the corresponding scale is not present.
521-
if (projection || (!isScaleOptions(x) && !hasScaleChannel("x", marks))) xAxis = xGrid = null;
522-
if (projection || (!isScaleOptions(y) && !hasScaleChannel("y", marks))) yAxis = yGrid = null;
540+
if (projection || (!isScaleOptions(x) && !hasPositionChannel("x", marks))) xAxis = xGrid = null;
541+
if (projection || (!isScaleOptions(y) && !hasPositionChannel("y", marks))) yAxis = yGrid = null;
523542
if (!channelsByScale.has("fx")) fxAxis = fxGrid = null;
524543
if (!channelsByScale.has("fy")) fyAxis = fyGrid = null;
525544

@@ -647,10 +666,11 @@ function hasAxis(marks, k) {
647666
return marks.some((m) => m.ariaLabel?.startsWith(prefix));
648667
}
649668

650-
function hasScaleChannel(k, marks) {
669+
function hasPositionChannel(k, marks) {
651670
for (const mark of marks) {
652671
for (const key in mark.channels) {
653-
if (mark.channels[key].scale === k) {
672+
const {scale} = mark.channels[key];
673+
if (scale === k || scale === "projection") {
654674
return true;
655675
}
656676
}

src/projection.js

+33-6
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
geoOrthographic,
1515
geoPath,
1616
geoStereographic,
17+
geoStream,
1718
geoTransform,
1819
geoTransverseMercator
1920
} from "d3";
@@ -222,17 +223,28 @@ export function project(cx, cy, values, projection) {
222223
}
223224
}
224225

226+
// Returns true if a projection was specified. This should match the logic of
227+
// createProjection above, and is called before we construct the projection.
228+
// (Though note that we ignore the edge case where the projection initializer
229+
// may return null.)
230+
export function hasProjection({projection} = {}) {
231+
if (projection == null) return false;
232+
if (typeof projection.stream === "function") return true;
233+
if (isObject(projection)) projection = projection.type;
234+
return projection != null;
235+
}
236+
225237
// When a named projection is specified, we can use its natural aspect ratio to
226238
// determine a good value for the projection’s height based on the desired
227239
// width. When we don’t have a way to know, the golden ratio is our best guess.
228240
// Due to a circular dependency (we need to know the height before we can
229241
// construct the projection), we have to test the raw projection option rather
230242
// than the materialized projection; therefore we must be extremely careful that
231-
// the logic of this function exactly matches Projection above!
232-
export function projectionAspectRatio(projection, marks) {
243+
// the logic of this function exactly matches createProjection above!
244+
export function projectionAspectRatio(projection) {
233245
if (typeof projection?.stream === "function") return defaultAspectRatio;
234246
if (isObject(projection)) projection = projection.type;
235-
if (projection == null) return hasGeometry(marks) ? defaultAspectRatio : undefined;
247+
if (projection == null) return;
236248
if (typeof projection !== "function") {
237249
const {aspectRatio} = namedProjection(projection);
238250
if (aspectRatio) return aspectRatio;
@@ -254,7 +266,22 @@ export function applyPosition(channels, scales, {projection}) {
254266
return position;
255267
}
256268

257-
function hasGeometry(marks) {
258-
for (const mark of marks) if (mark.channels.geometry) return true;
259-
return false;
269+
export function getGeometryChannels(channel) {
270+
const X = [];
271+
const Y = [];
272+
const x = {scale: "x", value: X};
273+
const y = {scale: "y", value: Y};
274+
const sink = {
275+
point(x, y) {
276+
X.push(x);
277+
Y.push(y);
278+
},
279+
lineStart() {},
280+
lineEnd() {},
281+
polygonStart() {},
282+
polygonEnd() {},
283+
sphere() {}
284+
};
285+
for (const object of channel.value) geoStream(object, sink);
286+
return [x, y];
260287
}

src/scales/index.js

+5-1
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ export const opacity = Symbol("opacity");
2222
// Symbol scales have a default range of categorical symbols.
2323
export const symbol = Symbol("symbol");
2424

25+
// There isn’t really a projection scale; this represents x and y for geometry.
26+
export const projection = Symbol("projection");
27+
2528
// TODO Rather than hard-coding the list of known scale names, collect the names
2629
// and categories for each plot specification, so that custom marks can register
2730
// custom scales.
@@ -34,5 +37,6 @@ export const registry = new Map([
3437
["color", color],
3538
["opacity", opacity],
3639
["symbol", symbol],
37-
["length", length]
40+
["length", length],
41+
["projection", projection]
3842
]);

test/output/geoLine.svg

+65
Loading

0 commit comments

Comments
 (0)