From a6d4533ef719cc4beafeb808386e1b353cbe6de6 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Tue, 18 Apr 2023 21:58:08 -0700 Subject: [PATCH 1/4] derive x & y scale domains from geometry --- src/dimensions.js | 5 +- src/marks/geo.js | 2 +- src/plot.js | 28 +++++++--- src/projection.js | 26 +++++++-- src/scales/index.js | 6 +- test/output/geoLine.svg | 65 ++++++++++++++++++++++ test/output/projectionHeightGeometry.svg | 70 ++++++++++++++++++++---- test/plots/geo-line.ts | 7 +++ test/plots/index.ts | 1 + 9 files changed, 180 insertions(+), 30 deletions(-) create mode 100644 test/output/geoLine.svg create mode 100644 test/plots/geo-line.ts diff --git a/src/dimensions.js b/src/dimensions.js index 64458520a4..43b24b8b3a 100644 --- a/src/dimensions.js +++ b/src/dimensions.js @@ -38,7 +38,7 @@ export function createDimensions(scales, marks, options = {}) { // specified explicitly, adjust the automatic height accordingly. let { width = 640, - height = autoHeight(scales, marks, options, { + height = autoHeight(scales, options, { width, marginTopDefault, marginRightDefault, @@ -89,14 +89,13 @@ export function createDimensions(scales, marks, options = {}) { function autoHeight( {x, y, fy, fx}, - marks, {projection, aspectRatio}, {width, marginTopDefault, marginRightDefault, marginBottomDefault, marginLeftDefault} ) { const nfy = fy ? fy.scale.domain().length : 1; // If a projection is specified, use its natural aspect ratio (if known). - const ar = projectionAspectRatio(projection, marks); + const ar = projectionAspectRatio(projection); if (ar) { const nfx = fx ? fx.scale.domain().length : 1; const far = ((1.1 * nfy - 0.1) / (1.1 * nfx - 0.1)) * ar; // 0.1 is default facet padding diff --git a/src/marks/geo.js b/src/marks/geo.js index 7797189fee..e986539377 100644 --- a/src/marks/geo.js +++ b/src/marks/geo.js @@ -22,7 +22,7 @@ export class Geo extends Mark { super( data, { - geometry: {value: options.geometry}, + geometry: {value: options.geometry, scale: "projection"}, r: {value: vr, scale: "r", filter: positive, optional: true} }, withDefaultSort(options), diff --git a/src/plot.js b/src/plot.js index 53cee69a52..05b6026a5a 100644 --- a/src/plot.js +++ b/src/plot.js @@ -10,7 +10,7 @@ import {axisFx, axisFy, axisX, axisY, gridFx, gridFy, gridX, gridY} from "./mark import {frame} from "./marks/frame.js"; import {tip} from "./marks/tip.js"; import {arrayify, isColor, isIterable, isNone, isScaleOptions, map, yes, maybeIntervalTransform} from "./options.js"; -import {createProjection} from "./projection.js"; +import {createProjection, getGeometryChannels} from "./projection.js"; import {createScales, createScaleFunctions, autoScaleRange, exposeScales} from "./scales.js"; import {innerDimensions, outerDimensions} from "./scales.js"; import {position, registry as scaleRegistry} from "./scales/index.js"; @@ -416,15 +416,26 @@ function addScaleChannels(channelsByScale, stateByMark, filter = yes) { const channel = channels[name]; const {scale} = channel; if (scale != null && filter(scale)) { - const scaleChannels = channelsByScale.get(scale); - if (scaleChannels !== undefined) scaleChannels.push(channel); - else channelsByScale.set(scale, [channel]); + if (scale === "projection") { + // TODO only do this if there’s no projection + const [x, y] = getGeometryChannels(channel); + addScaleChannel(channelsByScale, "x", x); + addScaleChannel(channelsByScale, "y", y); + } else { + addScaleChannel(channelsByScale, scale, channel); + } } } } return channelsByScale; } +function addScaleChannel(channelsByScale, scale, channel) { + const scaleChannels = channelsByScale.get(scale); + if (scaleChannels !== undefined) scaleChannels.push(channel); + else channelsByScale.set(scale, [channel]); +} + // Returns the facet groups, and possibly fx and fy channels, associated with // the top-level facet option {data, x, y}. function maybeTopFacet(facet, options) { @@ -518,8 +529,8 @@ function inferAxes(marks, channelsByScale, options) { } = options; // Disable axes if the corresponding scale is not present. - if (projection || (!isScaleOptions(x) && !hasScaleChannel("x", marks))) xAxis = xGrid = null; - if (projection || (!isScaleOptions(y) && !hasScaleChannel("y", marks))) yAxis = yGrid = null; + if (projection || (!isScaleOptions(x) && !hasPositionChannel("x", marks))) xAxis = xGrid = null; + if (projection || (!isScaleOptions(y) && !hasPositionChannel("y", marks))) yAxis = yGrid = null; if (!channelsByScale.has("fx")) fxAxis = fxGrid = null; if (!channelsByScale.has("fy")) fyAxis = fyGrid = null; @@ -647,10 +658,11 @@ function hasAxis(marks, k) { return marks.some((m) => m.ariaLabel?.startsWith(prefix)); } -function hasScaleChannel(k, marks) { +function hasPositionChannel(k, marks) { for (const mark of marks) { for (const key in mark.channels) { - if (mark.channels[key].scale === k) { + const {scale} = mark.channels[key]; + if (scale === k || scale === "projection") { return true; } } diff --git a/src/projection.js b/src/projection.js index e7f416f784..f6746a9b1b 100644 --- a/src/projection.js +++ b/src/projection.js @@ -14,6 +14,7 @@ import { geoOrthographic, geoPath, geoStereographic, + geoStream, geoTransform, geoTransverseMercator } from "d3"; @@ -229,10 +230,10 @@ export function project(cx, cy, values, projection) { // construct the projection), we have to test the raw projection option rather // than the materialized projection; therefore we must be extremely careful that // the logic of this function exactly matches Projection above! -export function projectionAspectRatio(projection, marks) { +export function projectionAspectRatio(projection) { if (typeof projection?.stream === "function") return defaultAspectRatio; if (isObject(projection)) projection = projection.type; - if (projection == null) return hasGeometry(marks) ? defaultAspectRatio : undefined; + if (projection == null) return; if (typeof projection !== "function") { const {aspectRatio} = namedProjection(projection); if (aspectRatio) return aspectRatio; @@ -254,7 +255,22 @@ export function applyPosition(channels, scales, {projection}) { return position; } -function hasGeometry(marks) { - for (const mark of marks) if (mark.channels.geometry) return true; - return false; +export function getGeometryChannels(channel) { + const X = []; + const Y = []; + const x = {scale: "x", value: X}; + const y = {scale: "y", value: Y}; + const sink = { + point(x, y) { + X.push(x); + Y.push(y); + }, + lineStart() {}, + lineEnd() {}, + polygonStart() {}, + polygonEnd() {}, + sphere() {} + }; + for (const object of channel.value) geoStream(object, sink); + return [x, y]; } diff --git a/src/scales/index.js b/src/scales/index.js index e928fcf2a4..a152db0ef8 100644 --- a/src/scales/index.js +++ b/src/scales/index.js @@ -22,6 +22,9 @@ export const opacity = Symbol("opacity"); // Symbol scales have a default range of categorical symbols. export const symbol = Symbol("symbol"); +// There isn’t really a projection scale; this represents x and y for geometry. +export const projection = Symbol("projection"); + // TODO Rather than hard-coding the list of known scale names, collect the names // and categories for each plot specification, so that custom marks can register // custom scales. @@ -34,5 +37,6 @@ export const registry = new Map([ ["color", color], ["opacity", opacity], ["symbol", symbol], - ["length", length] + ["length", length], + ["projection", projection] ]); diff --git a/test/output/geoLine.svg b/test/output/geoLine.svg new file mode 100644 index 0000000000..5c8aaad0e5 --- /dev/null +++ b/test/output/geoLine.svg @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + 60 + 70 + 80 + 90 + 100 + 110 + 120 + 130 + 140 + 150 + 160 + 170 + 180 + 190 + + + + + + + + + + 2014 + 2015 + 2016 + 2017 + 2018 + + + + + \ No newline at end of file diff --git a/test/output/projectionHeightGeometry.svg b/test/output/projectionHeightGeometry.svg index 24ad13d9ee..2bcb529c54 100644 --- a/test/output/projectionHeightGeometry.svg +++ b/test/output/projectionHeightGeometry.svg @@ -1,4 +1,4 @@ - + - - - 0 + + + 0 - - 1 + + 1 + + + + + + + + + + + + + + + + + 100 + 200 + 300 + + + 100 + 200 + 300 + + + + + + + + + + + + + + + + 200 + 250 + 300 + 350 + 400 + 450 + 500 - - + + - - + + - - + + \ No newline at end of file diff --git a/test/plots/geo-line.ts b/test/plots/geo-line.ts new file mode 100644 index 0000000000..022222a466 --- /dev/null +++ b/test/plots/geo-line.ts @@ -0,0 +1,7 @@ +import * as Plot from "@observablehq/plot"; +import * as d3 from "d3"; + +export async function geoLine() { + const aapl = await d3.csv("data/aapl.csv", d3.autoType); + return Plot.geo({type: "LineString", coordinates: aapl.map((d) => [d.Date, d.Close])}).plot(); +} diff --git a/test/plots/index.ts b/test/plots/index.ts index 64f185bdd9..3428fb3287 100644 --- a/test/plots/index.ts +++ b/test/plots/index.ts @@ -92,6 +92,7 @@ export * from "./frame.js"; export * from "./fruit-sales-date.js"; export * from "./fruit-sales.js"; export * from "./function-contour.js"; +export * from "./geo-line.js"; export * from "./geo-link.js"; export * from "./gistemp-anomaly-moving.js"; export * from "./gistemp-anomaly-transform.js"; From eca4083519e191cf94093ca930527b088b1b9a4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Fri, 2 Jun 2023 17:55:30 +0200 Subject: [PATCH 2/4] 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 --- src/plot.js | 26 +++--- src/projection.js | 13 ++- test/output/projectionHeightGeometry.svg | 70 +++------------ test/output/projectionHeightGeometryNull.svg | 90 ++++++++++++++++++++ test/output/projectionNull.svg | 62 ++++++++++++++ test/plots/index.ts | 1 + test/plots/projection-height-geometry.ts | 21 ++++- test/plots/projection-null.ts | 12 +++ 8 files changed, 223 insertions(+), 72 deletions(-) create mode 100644 test/output/projectionHeightGeometryNull.svg create mode 100644 test/output/projectionNull.svg create mode 100644 test/plots/projection-null.ts diff --git a/src/plot.js b/src/plot.js index 05b6026a5a..5febfbe538 100644 --- a/src/plot.js +++ b/src/plot.js @@ -10,7 +10,7 @@ import {axisFx, axisFy, axisX, axisY, gridFx, gridFy, gridX, gridY} from "./mark import {frame} from "./marks/frame.js"; import {tip} from "./marks/tip.js"; import {arrayify, isColor, isIterable, isNone, isScaleOptions, map, yes, maybeIntervalTransform} from "./options.js"; -import {createProjection, getGeometryChannels} from "./projection.js"; +import {createProjection, getGeometryChannels, hasProjection} from "./projection.js"; import {createScales, createScaleFunctions, autoScaleRange, exposeScales} from "./scales.js"; import {innerDimensions, outerDimensions} from "./scales.js"; import {position, registry as scaleRegistry} from "./scales/index.js"; @@ -48,8 +48,8 @@ export function plot(options = {}) { // Compute a Map from scale name to an array of associated channels. const channelsByScale = new Map(); - if (topFacetState) addScaleChannels(channelsByScale, [topFacetState]); - addScaleChannels(channelsByScale, facetStateByMark); + if (topFacetState) addScaleChannels(channelsByScale, [topFacetState], options); + addScaleChannels(channelsByScale, facetStateByMark, options); // Add implicit axis marks. Because this happens after faceting (because it // depends on whether faceting is present), we must initialize the facet state @@ -139,7 +139,7 @@ export function plot(options = {}) { } // Initalize the scales and dimensions. - const scaleDescriptors = createScales(addScaleChannels(channelsByScale, stateByMark), options); + const scaleDescriptors = createScales(addScaleChannels(channelsByScale, stateByMark, options), options); const scales = createScaleFunctions(scaleDescriptors); const dimensions = createDimensions(scaleDescriptors, marks, options); @@ -217,8 +217,8 @@ export function plot(options = {}) { // reinitialization. Preserve existing scale labels, if any. if (newByScale.size) { const newChannelsByScale = new Map(); - addScaleChannels(newChannelsByScale, stateByMark, (key) => newByScale.has(key)); - addScaleChannels(channelsByScale, stateByMark, (key) => newByScale.has(key)); + addScaleChannels(newChannelsByScale, stateByMark, options, (key) => newByScale.has(key)); + addScaleChannels(channelsByScale, stateByMark, options, (key) => newByScale.has(key)); const newScaleDescriptors = inheritScaleLabels(createScales(newChannelsByScale, options), scaleDescriptors); const newScales = createScaleFunctions(newScaleDescriptors); Object.assign(scaleDescriptors, newScaleDescriptors); @@ -410,17 +410,21 @@ function inferChannelScales(channels) { } } -function addScaleChannels(channelsByScale, stateByMark, filter = yes) { +function addScaleChannels(channelsByScale, stateByMark, options, filter = yes) { for (const {channels} of stateByMark.values()) { for (const name in channels) { const channel = channels[name]; const {scale} = channel; if (scale != null && filter(scale)) { + // Geo marks affect the default x and y domains if there is no + // projection. Skip this (as an optimization) when a projection is + // specified, or when the domains for x and y are specified. if (scale === "projection") { - // TODO only do this if there’s no projection - const [x, y] = getGeometryChannels(channel); - addScaleChannel(channelsByScale, "x", x); - addScaleChannel(channelsByScale, "y", y); + if (!hasProjection(options)) { + const [x, y] = getGeometryChannels(channel); + if (options.x?.domain === undefined) addScaleChannel(channelsByScale, "x", x); + if (options.y?.domain === undefined) addScaleChannel(channelsByScale, "y", y); + } } else { addScaleChannel(channelsByScale, scale, channel); } diff --git a/src/projection.js b/src/projection.js index f6746a9b1b..26afac4241 100644 --- a/src/projection.js +++ b/src/projection.js @@ -223,13 +223,24 @@ export function project(cx, cy, values, projection) { } } +// Returns true if a projection was specified. This should match the logic of +// createProjection above, and is called before we construct the projection. +// (Though note that we ignore the edge case where the projection initializer +// may return null.) +export function hasProjection({projection} = {}) { + if (projection == null) return false; + if (typeof projection.stream === "function") return true; + if (isObject(projection)) projection = projection.type; + return projection != null; +} + // When a named projection is specified, we can use its natural aspect ratio to // determine a good value for the projection’s height based on the desired // width. When we don’t have a way to know, the golden ratio is our best guess. // Due to a circular dependency (we need to know the height before we can // construct the projection), we have to test the raw projection option rather // than the materialized projection; therefore we must be extremely careful that -// the logic of this function exactly matches Projection above! +// the logic of this function exactly matches createProjection above! export function projectionAspectRatio(projection) { if (typeof projection?.stream === "function") return defaultAspectRatio; if (isObject(projection)) projection = projection.type; diff --git a/test/output/projectionHeightGeometry.svg b/test/output/projectionHeightGeometry.svg index 2bcb529c54..24ad13d9ee 100644 --- a/test/output/projectionHeightGeometry.svg +++ b/test/output/projectionHeightGeometry.svg @@ -1,4 +1,4 @@ - + - - - 0 + + + 0 - - 1 - - - - - - - - - - - - - - - - - 100 - 200 - 300 - - - 100 - 200 - 300 - - - - - - - - - - - - - - - - 200 - 250 - 300 - 350 - 400 - 450 - 500 + + 1 - - + + - - + + - - + + \ No newline at end of file diff --git a/test/output/projectionHeightGeometryNull.svg b/test/output/projectionHeightGeometryNull.svg new file mode 100644 index 0000000000..c1b4ae17cd --- /dev/null +++ b/test/output/projectionHeightGeometryNull.svg @@ -0,0 +1,90 @@ + + + + + 0 + + + 1 + + + + + + + + + + + + + + + + + + + + + + + + + 50 + 100 + 150 + 200 + 250 + 300 + 350 + + + 50 + 100 + 150 + 200 + 250 + 300 + 350 + + + + + + + + + + + + 200 + 300 + 400 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/projectionNull.svg b/test/output/projectionNull.svg new file mode 100644 index 0000000000..765084d95c --- /dev/null +++ b/test/output/projectionNull.svg @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + −80 + −60 + −40 + −20 + 0 + 20 + 40 + 60 + 80 + + + + + + + + + + + + −150 + −100 + −50 + 0 + 50 + 100 + 150 + + + + + + + + \ No newline at end of file diff --git a/test/plots/index.ts b/test/plots/index.ts index 3428fb3287..15ade2348a 100644 --- a/test/plots/index.ts +++ b/test/plots/index.ts @@ -225,6 +225,7 @@ export * from "./projection-height-equal-earth.js"; export * from "./projection-height-geometry.js"; export * from "./projection-height-mercator.js"; export * from "./projection-height-orthographic.js"; +export * from "./projection-null.js"; export * from "./random-bins-xy.js"; export * from "./random-bins.js"; export * from "./random-quantile.js"; diff --git a/test/plots/projection-height-geometry.ts b/test/plots/projection-height-geometry.ts index 7f94fb62fe..0b90067a66 100644 --- a/test/plots/projection-height-geometry.ts +++ b/test/plots/projection-height-geometry.ts @@ -6,12 +6,29 @@ export async function projectionHeightGeometry() { coordinates: Array.from({length: 201}, (_, i) => { const angle = (i / 100) * Math.PI; const r = (i % 2) + 5; - return [340 + 30 * r * Math.cos(angle), 205 + 30 * r * Math.sin(angle)]; + return [300 + 30 * r * Math.cos(angle), 185 + 30 * r * Math.sin(angle)]; }) } as const; return Plot.plot({ facet: {data: [0, 1], y: [0, 1]}, - projection: null, + projection: "identity", + marks: [Plot.geo(shape), Plot.frame({stroke: "red", strokeDasharray: 4})] + }); +} + +export async function projectionHeightGeometryNull() { + const shape = { + type: "LineString", + coordinates: Array.from({length: 201}, (_, i) => { + const angle = (i / 100) * Math.PI; + const r = (i % 2) + 5; + return [300 + 30 * r * Math.cos(angle), 185 + 30 * r * Math.sin(angle)]; + }) + } as const; + return Plot.plot({ + aspectRatio: true, + width: 400, + facet: {data: [0, 1], y: [0, 1]}, marks: [Plot.geo(shape), Plot.frame({stroke: "red", strokeDasharray: 4})] }); } diff --git a/test/plots/projection-null.ts b/test/plots/projection-null.ts new file mode 100644 index 0000000000..0261d46b57 --- /dev/null +++ b/test/plots/projection-null.ts @@ -0,0 +1,12 @@ +import * as Plot from "@observablehq/plot"; +import * as d3 from "d3"; +import {feature} from "topojson-client"; + +export async function projectionNull() { + const world = await d3.json("data/countries-110m.json"); + const land = feature(world, world.objects.land); + return Plot.plot({ + projection: null, + marks: [Plot.geo(land), Plot.graticule()] + }); +} From faf24a373a9ec8af1cf16c388d24c5bea54b2e82 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Fri, 2 Jun 2023 09:23:49 -0700 Subject: [PATCH 3/4] skip if both xy domains are specified --- src/plot.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/plot.js b/src/plot.js index 5febfbe538..3610bf0064 100644 --- a/src/plot.js +++ b/src/plot.js @@ -421,9 +421,13 @@ function addScaleChannels(channelsByScale, stateByMark, options, filter = yes) { // specified, or when the domains for x and y are specified. if (scale === "projection") { if (!hasProjection(options)) { - const [x, y] = getGeometryChannels(channel); - if (options.x?.domain === undefined) addScaleChannel(channelsByScale, "x", x); - if (options.y?.domain === undefined) addScaleChannel(channelsByScale, "y", y); + const dx = options.x?.domain; + const dy = options.y?.domain; + if (dx === undefined || dy === undefined) { + const [x, y] = getGeometryChannels(channel); + if (dx === undefined) addScaleChannel(channelsByScale, "x", x); + if (dy === undefined) addScaleChannel(channelsByScale, "y", y); + } } } else { addScaleChannel(channelsByScale, scale, channel); From f836cd913cbbb63eeed70ce1cab4bec5d3b1581d Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Fri, 2 Jun 2023 09:24:37 -0700 Subject: [PATCH 4/4] shorten --- src/plot.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/plot.js b/src/plot.js index 3610bf0064..6862ebf42d 100644 --- a/src/plot.js +++ b/src/plot.js @@ -421,12 +421,12 @@ function addScaleChannels(channelsByScale, stateByMark, options, filter = yes) { // specified, or when the domains for x and y are specified. if (scale === "projection") { if (!hasProjection(options)) { - const dx = options.x?.domain; - const dy = options.y?.domain; - if (dx === undefined || dy === undefined) { + const gx = options.x?.domain === undefined; + const gy = options.y?.domain === undefined; + if (gx || gy) { const [x, y] = getGeometryChannels(channel); - if (dx === undefined) addScaleChannel(channelsByScale, "x", x); - if (dy === undefined) addScaleChannel(channelsByScale, "y", y); + if (gx) addScaleChannel(channelsByScale, "x", x); + if (gy) addScaleChannel(channelsByScale, "y", y); } } } else {