diff --git a/src/plot.js b/src/plot.js index bbd88c8c88..5ace09fe6b 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"; @@ -72,16 +72,56 @@ 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); + 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"); - 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}); } @@ -126,17 +166,13 @@ 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); } + const animateMarks = []; + const {width, height} = dimensions; const svg = create("svg", context) @@ -219,7 +255,15 @@ export function plot(options = {}) { 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 (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)])) + }); + } } } }); @@ -230,12 +274,20 @@ export function plot(options = {}) { 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 (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.length) { + 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, @@ -251,17 +303,16 @@ export function plot(options = {}) { 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, @@ -275,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); } } @@ -353,7 +401,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; @@ -366,7 +414,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-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 new file mode 100644 index 0000000000..9e9b3236f4 --- /dev/null +++ b/test/plots/gapminder-box.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, + x: { + type: "log", + transform: d => Math.pow(10, d) + }, + marks: [ + Plot.boxX(gapminder, { + x: d => Math.log10(d.gdpPercap), + 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..9a34ee3f1d 100644 --- a/test/plots/index.js +++ b/test/plots/index.js @@ -76,6 +76,9 @@ 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 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"; export {default as gistempAnomalyMoving} from "./gistemp-anomaly-moving.js";