From 3d86d4abd2bb9719859dc25a0c8feeb39cf766db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Fri, 29 Jul 2022 15:44:45 +0200 Subject: [PATCH 1/4] transforms *almost* work now --- src/plot.js | 26 +++++++++++++++++++++-- test/plots/gapminder-box.js | 27 +++++++++++++++++++++++ test/plots/gapminder-dodge.js | 40 +++++++++++++++++++++++++++++++++++ test/plots/index.js | 2 ++ 4 files changed, 93 insertions(+), 2 deletions(-) create mode 100644 test/plots/gapminder-box.js create mode 100644 test/plots/gapminder-dodge.js diff --git a/src/plot.js b/src/plot.js index bbd88c8c88..31ab4686fb 100644 --- a/src/plot.js +++ b/src/plot.js @@ -5,7 +5,7 @@ import {Context, create} from "./context.js"; import {defined} from "./defined.js"; import {Dimensions} from "./dimensions.js"; import {Legends, exposeLegends} from "./legends.js"; -import {arrayify, isDomainSort, isScaleOptions, keyword, map, maybeNamed, range, second, where, yes} from "./options.js"; +import {arrayify, isDomainSort, isScaleOptions, keyword, map, maybeNamed, range, second, valueof, where, yes} from "./options.js"; import {Scales, ScaleFunctions, autoScaleRange, exposeScales, coerceNumbers} from "./scales.js"; import {position, registry as scaleRegistry} from "./scales/index.js"; import {inferDomain} from "./scales/quantitative.js"; @@ -366,7 +366,29 @@ export class Mark { initialize(facets, facetChannels) { let data = arrayify(this.data); if (facets === undefined && data != null) facets = [range(data)]; - if (this.transform != null) ({facets, data} = this.transform(data, facets)), data = arrayify(data); + if (this.transform != null) { + if (this.channels.time) { + // Split facets by keyframe, transform + const {value} = this.channels.time; + const T = valueof(data, value); + const times = [...new Set(T)]; + const n = facets.length; + facets = facets.flatMap(facet => times.map(time => facet.filter(i => T[i] === time))); + ({data, facets} = this.transform(data, facets)); + + // and reassemble + const TT = []; // keyframes for the new indices + this.channels.time.value = { transform: () => TT}; + for (let i = 0; i < facets.length; ++i) { + const time = times[i % times.length]; + for (const j of facets[i]) TT[j] = time; + } + facets = Array.from({length: n}, (_, i) => facets.filter((_, j) => j % n === i).flat()); + } else { + ({facets, data} = this.transform(data, facets)); + } + data = arrayify(data); + } const channels = Channels(this.channels, data); if (this.sort != null) channelDomain(channels, facetChannels, data, this.sort); return {data, facets, channels}; diff --git a/test/plots/gapminder-box.js b/test/plots/gapminder-box.js new file mode 100644 index 0000000000..1b6362cf6f --- /dev/null +++ b/test/plots/gapminder-box.js @@ -0,0 +1,27 @@ +import * as Plot from "@observablehq/plot"; +import * as d3 from "d3"; + +export default async function () { + const gapminder = await d3.tsv("data/gapminder.tsv", d3.autoType); + return Plot.plot({ + marginLeft: 70, + inset: 10, + grid: true, + // facet:{ data: gapminder, y: "continent"}, + x: { + type: "log", + transform: d => Math.pow(10, d) + }, + marks: [ + Plot.boxX(gapminder, { + x: d => Math.log10(d.gdpPercap), + sort: null, + y: "continent", + stroke: "continent", + strokeWidth: 0.5, + time: "year", + timeFilter: "lte" + }) + ] + }); +} diff --git a/test/plots/gapminder-dodge.js b/test/plots/gapminder-dodge.js new file mode 100644 index 0000000000..964707ac73 --- /dev/null +++ b/test/plots/gapminder-dodge.js @@ -0,0 +1,40 @@ +import * as Plot from "@observablehq/plot"; +import * as d3 from "d3"; + +export default async function () { + let gapminder = await d3.tsv("data/gapminder.tsv", d3.autoType); + gapminder = gapminder.filter(d => d.continent === "Europe"); + return Plot.plot({ + height: 400, + marginLeft: 75, + inset: 10, + grid: true, + x: { + type: "log", + transform: d => Math.pow(10, d) + }, + marks: [ + Plot.dot(gapminder, Plot.dodgeY({ + x: d => Math.log10(d.gdpPercap), + z: d => `${d.year} & ${d.continent}`, + r: "pop", + stroke: "continent", + sort: null +// time: "year" + })), + Plot.dot(gapminder, Plot.dodgeY({ + x: d => Math.log10(d.gdpPercap), + z: d => `${d.year} & ${d.continent}`, + r: "pop", + fill: "continent", + sort: null, + fillOpacity: 0.3, + strokeWidth: 0.5, + time: "year" + })), + void Plot.text(gapminder, {frameAnchor: "top-left", text: "year", time: "year"}) +// Plot.text(gapminder, Plot.selectFirst({frameAnchor: "top-left", text: "year", time: "year"})) + + ] + }); +} diff --git a/test/plots/index.js b/test/plots/index.js index 6f5e0d3872..e3dcd89a59 100644 --- a/test/plots/index.js +++ b/test/plots/index.js @@ -76,6 +76,8 @@ export {default as frameCorners} from "./frame-corners.js"; export {default as fruitSales} from "./fruit-sales.js"; export {default as fruitSalesDate} from "./fruit-sales-date.js"; export {default as gapminder} from "./gapminder.js"; +export {default as gapminderBox} from "./gapminder-box.js"; +export {default as gapminderDodge} from "./gapminder-dodge.js"; export {default as gapminderContinent} from "./gapminder-continent.js"; export {default as gistempAnomaly} from "./gistemp-anomaly.js"; export {default as gistempAnomalyMoving} from "./gistemp-anomaly-moving.js"; From 5a9abaee9db08295222bd38c67a5ffdd8b201df0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Fri, 29 Jul 2022 15:50:32 +0200 Subject: [PATCH 2/4] a test for faceting (even more broken than the others) --- test/plots/gapminder-box-facet.js | 25 +++++++++++++++++++++++++ test/plots/gapminder-box.js | 2 -- test/plots/index.js | 1 + 3 files changed, 26 insertions(+), 2 deletions(-) create mode 100644 test/plots/gapminder-box-facet.js diff --git a/test/plots/gapminder-box-facet.js b/test/plots/gapminder-box-facet.js new file mode 100644 index 0000000000..e609a17607 --- /dev/null +++ b/test/plots/gapminder-box-facet.js @@ -0,0 +1,25 @@ +import * as Plot from "@observablehq/plot"; +import * as d3 from "d3"; + +export default async function () { + const gapminder = await d3.tsv("data/gapminder.tsv", d3.autoType); + return Plot.plot({ + marginLeft: 70, + inset: 10, + grid: true, + facet:{ data: gapminder, y: "continent"}, + x: { + type: "log", + transform: d => Math.pow(10, d) + }, + marks: [ + Plot.boxX(gapminder, { + x: d => Math.log10(d.gdpPercap), + stroke: "continent", + strokeWidth: 0.5, + time: "year", + timeFilter: "lte" + }) + ] + }); +} diff --git a/test/plots/gapminder-box.js b/test/plots/gapminder-box.js index 1b6362cf6f..9e9b3236f4 100644 --- a/test/plots/gapminder-box.js +++ b/test/plots/gapminder-box.js @@ -7,7 +7,6 @@ export default async function () { marginLeft: 70, inset: 10, grid: true, - // facet:{ data: gapminder, y: "continent"}, x: { type: "log", transform: d => Math.pow(10, d) @@ -15,7 +14,6 @@ export default async function () { marks: [ Plot.boxX(gapminder, { x: d => Math.log10(d.gdpPercap), - sort: null, y: "continent", stroke: "continent", strokeWidth: 0.5, diff --git a/test/plots/index.js b/test/plots/index.js index e3dcd89a59..9a34ee3f1d 100644 --- a/test/plots/index.js +++ b/test/plots/index.js @@ -77,6 +77,7 @@ export {default as fruitSales} from "./fruit-sales.js"; export {default as fruitSalesDate} from "./fruit-sales-date.js"; export {default as gapminder} from "./gapminder.js"; export {default as gapminderBox} from "./gapminder-box.js"; +export {default as gapminderBoxFacet} from "./gapminder-box-facet.js"; export {default as gapminderDodge} from "./gapminder-dodge.js"; export {default as gapminderContinent} from "./gapminder-continent.js"; export {default as gistempAnomaly} from "./gistemp-anomaly.js"; From c086ee4819eb7c0a104fb4303be9efdb5dfeec23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Mon, 1 Aug 2022 16:21:48 +0200 Subject: [PATCH 3/4] checkpoint --- src/plot.js | 37 ++++++++++++++++++++----------------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/src/plot.js b/src/plot.js index 31ab4686fb..f06919e6ed 100644 --- a/src/plot.js +++ b/src/plot.js @@ -72,9 +72,22 @@ export function plot(options = {}) { } } + // Aggregate and sort time channels. + const timeMarks = new Map(); + for (const mark of marks) { + if (mark.timeChannel) { + timeMarks.set(mark, valueof(mark.data, mark.timeChannel.time.value)); + } + } + const timeChannels = Array.from(timeMarks, ([,times]) => ({value: times})); + const timeDomain = inferDomain(timeChannels); + const times = aggregateTimes(timeChannels); + // Initialize the marks’ state. for (const mark of marks) { if (stateByMark.has(mark)) throw new Error("duplicate mark; each mark must be unique"); + + // TODO: augment the facets with time, for time-aware marks const markFacets = facetsIndex === undefined ? undefined : mark.facet === "auto" ? mark.data === facet.data ? facetsIndex : undefined : mark.facet === "include" ? facetsIndex @@ -126,12 +139,6 @@ export function plot(options = {}) { autoScaleLabels(channelsByScale, scaleDescriptors, axes, dimensions, options); - // Aggregate and sort time channels. - const timeChannels = findTimeChannels(stateByMark); - const timeDomain = inferDomain(timeChannels); - const times = aggregateTimes(timeChannels); - const timeMarks = []; - // Compute value objects, applying scales as needed. for (const state of stateByMark.values()) { state.values = valueObject(state.channels, scales); @@ -217,10 +224,7 @@ export function plot(options = {}) { for (const [mark, {channels, values, facets}] of stateByMark) { const facet = facets ? mark.filter(facets[j] ?? facets[0], channels, values) : null; const node = mark.render(facet, scales, values, subdimensions, context); - if (node != null) { - this.appendChild(node); - if (channels.time) timeMarks.push({mark, node, facet, interp: Object.fromEntries(Object.entries(values).map(([key, value]) => [key, Array.from(value)]))}); - } + if (node != null) this.appendChild(node); } }); } else { @@ -228,14 +232,11 @@ export function plot(options = {}) { const facet = facets ? mark.filter(facets[0], channels, values) : null; const index = channels.time ? [] : facet; const node = mark.render(index, scales, values, dimensions, context); - if (node != null) { - svg.appendChild(node); - if (channels.time) timeMarks.push({mark, node, facet, interp: Object.fromEntries(Object.entries(values).map(([key, value]) => [key, Array.from(value)]))}); - } + if (node != null) svg.appendChild(node); } } - if (timeMarks.length) { + if (timeMarks.size > 0) { // TODO There needs to be an option to avoid interpolation and just play // the distinct times, as given, in ascending order, as keyframes. And // there needs to be an option to control the delay, duration, iterations, @@ -244,7 +245,9 @@ export function plot(options = {}) { const delay = 0; // TODO configurable; delay initial rendering const duration = 5000; // TODO configurable const startTime = performance.now() + delay; - requestAnimationFrame(function tick() { + console.warn(timeMarks); + + if (false) requestAnimationFrame(function tick() { const t = Math.max(0, Math.min(1, (performance.now() - startTime) / duration)); const currentTime = interpolateTime(t); const i0 = bisectLeft(times, currentTime); @@ -353,7 +356,7 @@ export class Mark { channels = maybeNamed(channels); if (extraChannels !== undefined) channels = {...maybeNamed(extraChannels), ...channels}; if (defaults !== undefined) channels = {...styles(this, options, defaults), ...channels}; - if (time != null) channels = {time: {value: time}, ...channels}; + this.timeChannel = (time != null) ? {time: {value: time}} : null; this.channels = Object.fromEntries(Object.entries(channels).filter(([name, {value, optional}]) => { if (value != null) return true; if (optional) return false; From 6005af814911befd76e60d817609da4657cf28af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Mon, 1 Aug 2022 19:41:06 +0200 Subject: [PATCH 4/4] facets closer to working --- src/plot.js | 91 +++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 68 insertions(+), 23 deletions(-) diff --git a/src/plot.js b/src/plot.js index f06919e6ed..5ace09fe6b 100644 --- a/src/plot.js +++ b/src/plot.js @@ -79,22 +79,49 @@ export function plot(options = {}) { timeMarks.set(mark, valueof(mark.data, mark.timeChannel.time.value)); } } - const timeChannels = Array.from(timeMarks, ([,times]) => ({value: times})); + const timeChannels = Array.from(timeMarks, ([, times]) => ({value: times})); const timeDomain = inferDomain(timeChannels); const times = aggregateTimes(timeChannels); + const timesIndex = new Map(times.map((d,i) => [d,i])); // Initialize the marks’ state. for (const mark of marks) { if (stateByMark.has(mark)) throw new Error("duplicate mark; each mark must be unique"); - // TODO: augment the facets with time, for time-aware marks - const markFacets = facetsIndex === undefined ? undefined + let markFacets = facetsIndex === undefined ? undefined : mark.facet === "auto" ? mark.data === facet.data ? facetsIndex : undefined : mark.facet === "include" ? facetsIndex : mark.facet === "exclude" ? facetsExclude || (facetsExclude = facetsIndex.map(f => Uint32Array.from(difference(facetIndex, f)))) : undefined; - const {data, facets, channels} = mark.initialize(markFacets, facetChannels); + + // Split across time facets + if (timeMarks.has(mark) && times.length > 1) { + const T = timeMarks.get(mark); + markFacets = (markFacets || [range(mark.data)]).flatMap(facet => { + const keyFrames = Array.from(times, () => []); + for (const i of facet) { + keyFrames[timesIndex.get(T[i])].push(i); + } + return keyFrames; + }); + } + + let {data, facets, channels} = mark.initialize(markFacets, facetChannels); applyScaleTransforms(channels, options); + + // Reassemble across time facets + if (timeMarks.has(mark) && times.length > 1) { + const newFacets = []; + const newTimes = []; + for (let k = 0; k < facets.length; ++k) { + const j = Math.floor(k / times.length); + newFacets[j] = newFacets[j] ? newFacets[j].concat(facets[k]) : facets[k]; + for (const i of facets[k]) newTimes[i] = times[k % times.length]; + } + facets = newFacets; + timeMarks.set(mark, newTimes); + } + stateByMark.set(mark, {data, facets, channels}); } @@ -144,6 +171,8 @@ export function plot(options = {}) { state.values = valueObject(state.channels, scales); } + const animateMarks = []; + const {width, height} = dimensions; const svg = create("svg", context) @@ -224,7 +253,18 @@ export function plot(options = {}) { for (const [mark, {channels, values, facets}] of stateByMark) { const facet = facets ? mark.filter(facets[j] ?? facets[0], channels, values) : null; const node = mark.render(facet, scales, values, subdimensions, context); - if (node != null) this.appendChild(node); + if (node != null) { + this.appendChild(node); + if (timeMarks.has(mark)) { + animateMarks.push({ + mark, + node, + facet, + time: timeMarks.get(mark), + interp: Object.fromEntries(Object.entries(values).map(([key, value]) => [key, Array.from(value)])) + }); + } + } } }); } else { @@ -232,11 +272,22 @@ export function plot(options = {}) { const facet = facets ? mark.filter(facets[0], channels, values) : null; const index = channels.time ? [] : facet; const node = mark.render(index, scales, values, dimensions, context); - if (node != null) svg.appendChild(node); + if (node != null) { + svg.appendChild(node); + if (timeMarks.has(mark)) { + animateMarks.push({ + mark, + node, + facet, + time: timeMarks.get(mark), + interp: Object.fromEntries(Object.entries(values).map(([key, value]) => [key, Array.from(value)])) + }); + } + } } } - if (timeMarks.size > 0) { + if (animateMarks.length > 0) { // TODO There needs to be an option to avoid interpolation and just play // the distinct times, as given, in ascending order, as keyframes. And // there needs to be an option to control the delay, duration, iterations, @@ -245,26 +296,23 @@ export function plot(options = {}) { const delay = 0; // TODO configurable; delay initial rendering const duration = 5000; // TODO configurable const startTime = performance.now() + delay; - console.warn(timeMarks); - - if (false) requestAnimationFrame(function tick() { + requestAnimationFrame(function tick() { const t = Math.max(0, Math.min(1, (performance.now() - startTime) / duration)); const currentTime = interpolateTime(t); const i0 = bisectLeft(times, currentTime); const time0 = times[i0 - 1]; const time1 = times[i0]; const timet = (currentTime - time0) / (time1 - time0); - for (const timeMark of timeMarks) { - const {mark, facet, interp} = timeMark; + for (const timeMark of animateMarks) { + const {mark, facet, time: T, interp} = timeMark; + interp.time = T.slice(); const {values} = stateByMark.get(mark); - const {time: T} = values; let timeNode; if (isFinite(timet)) { const I0 = facet.filter(i => T[i] === time0); // preceding keyframe const I1 = facet.filter(i => T[i] === time1); // following keyframe const n = I0.length; // TODO enter, exit, key const Ii = I0.map((_, i) => i + facet.length); // TODO optimize - // TODO This is interpolating the already-scaled values, but we // probably want to interpolate in data space instead and then // re-apply the scales. I’m not sure what to do for ordinal data, @@ -278,16 +326,13 @@ export function plot(options = {}) { // default with the dot mark) breaks consistent ordering! TODO If // the time filter is not “eq” (strict equals) here, then we’ll need // to combine the interpolated data with the filtered data. + for (let i = 0; i < n; ++i) { + interp.time[Ii[i]] = currentTime; + } for (const k in values) { - if (k === "time") { - for (let i = 0; i < n; ++i) { - interp[k][Ii[i]] = currentTime; - } - } else { - for (let i = 0; i < n; ++i) { - const past = values[k][I0[i]], future = values[k][I1[i]]; - interp[k][Ii[i]] = past == future ? past : interpolate(past, future)(timet); - } + for (let i = 0; i < n; ++i) { + const past = values[k][I0[i]], future = values[k][I1[i]]; + interp[k][Ii[i]] = past == future ? past : interpolate(past, future)(timet); } }