Skip to content

Commit 6ccde89

Browse files
committed
time channel
1 parent ddf3db9 commit 6ccde89

File tree

5 files changed

+149
-3
lines changed

5 files changed

+149
-3
lines changed

src/plot.js

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {cross, difference, groups, InternMap, select} from "d3";
1+
import {bisectLeft, cross, difference, groups, InternMap, select} from "d3";
22
import {Axes, autoAxisTicks, autoScaleLabels} from "./axes.js";
33
import {Channel, Channels, channelDomain, valueObject} from "./channel.js";
44
import {Context, create} from "./context.js";
@@ -124,6 +124,9 @@ export function plot(options = {}) {
124124

125125
autoScaleLabels(channelsByScale, scaleDescriptors, axes, dimensions, options);
126126

127+
// Aggregate and sort time channels.
128+
const times = aggregateTimes(stateByMark);
129+
127130
// Compute value objects, applying scales as needed.
128131
for (const state of stateByMark.values()) {
129132
state.values = valueObject(state.channels, scales);
@@ -213,11 +216,31 @@ export function plot(options = {}) {
213216
}
214217
});
215218
} else {
219+
const timeMarks = [];
216220
for (const [mark, {channels, values, facets}] of stateByMark) {
217221
const facet = facets ? mark.filter(facets[0], channels, values) : null;
218-
const node = mark.render(facet, scales, values, dimensions, context);
222+
const index = channels.time ? [] : facet;
223+
const node = mark.render(index, scales, values, dimensions, context);
224+
if (channels.time) timeMarks.push({mark, node});
219225
if (node != null) svg.appendChild(node);
220226
}
227+
if (timeMarks.length) {
228+
let timeIndex = -1;
229+
requestAnimationFrame(function tick() {
230+
if (++timeIndex >= times.length) return;
231+
const time = times[timeIndex];
232+
for (const timeMark of timeMarks) {
233+
const {mark, node} = timeMark;
234+
const {channels, values, facets} = stateByMark.get(mark);
235+
const facet = facets ? mark.filter(facets[0], channels, values) : null;
236+
const index = facet.filter(i => channels.time.value[i] <= time);
237+
const timeNode = mark.render(index, scales, values, dimensions, context);
238+
node.replaceWith(timeNode);
239+
timeMark.node = timeNode;
240+
}
241+
requestAnimationFrame(tick);
242+
});
243+
}
221244
}
222245

223246
// Wrap the plot in a figure with a caption, if desired.
@@ -257,7 +280,7 @@ export function plot(options = {}) {
257280

258281
export class Mark {
259282
constructor(data, channels = {}, options = {}, defaults) {
260-
const {facet = "auto", sort, dx, dy, clip, channels: extraChannels} = options;
283+
const {facet = "auto", sort, time, dx, dy, clip, channels: extraChannels} = options;
261284
this.data = data;
262285
this.sort = isDomainSort(sort) ? sort : null;
263286
this.initializer = initializer(options).initializer;
@@ -266,6 +289,7 @@ export class Mark {
266289
channels = maybeNamed(channels);
267290
if (extraChannels !== undefined) channels = {...maybeNamed(extraChannels), ...channels};
268291
if (defaults !== undefined) channels = {...styles(this, options, defaults), ...channels};
292+
if (time != null) channels = {time: {value: time}, ...channels};
269293
this.channels = Object.fromEntries(Object.entries(channels).filter(([name, {value, optional}]) => {
270294
if (value != null) return true;
271295
if (optional) return false;
@@ -367,6 +391,21 @@ function addScaleChannels(channelsByScale, stateByMark, filter = yes) {
367391
return channelsByScale;
368392
}
369393

394+
function aggregateTimes(stateByMark) {
395+
const times = [];
396+
for (const {channels: {time}} of stateByMark.values()) {
397+
if (time) {
398+
for (let t of time.value) {
399+
if (t == null || isNaN(t = +t)) continue;
400+
const i = bisectLeft(times, t);
401+
if (times[i] === t) continue;
402+
times.splice(i, 0, t);
403+
}
404+
}
405+
}
406+
return times;
407+
}
408+
370409
// Derives a copy of the specified axis with the label disabled.
371410
function nolabel(axis) {
372411
return axis === undefined || axis.label === undefined

test/jsdom.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ function withJsdom(run) {
2424
global.Node = jsdom.window.Node;
2525
global.NodeList = jsdom.window.NodeList;
2626
global.HTMLCollection = jsdom.window.HTMLCollection;
27+
global.requestAnimationFrame = () => 0;
28+
global.cancelAnimationFrame = () => {};
2729
global.fetch = async (href) => new Response(path.resolve("./test", href));
2830
try {
2931
return await run();
@@ -35,6 +37,8 @@ function withJsdom(run) {
3537
delete global.Node;
3638
delete global.NodeList;
3739
delete global.HTMLCollection;
40+
delete global.requestAnimationFrame;
41+
delete global.cancelAnimationFrame;
3842
delete global.fetch;
3943
}
4044
};

test/output/drivingAnimation.svg

Lines changed: 89 additions & 0 deletions
Loading

test/plots/driving-animation.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import * as Plot from "@observablehq/plot";
2+
import * as d3 from "d3";
3+
4+
export default async function() {
5+
const driving = await d3.csv("data/driving.csv", d3.autoType);
6+
return Plot.plot({
7+
inset: 10,
8+
grid: true,
9+
marks: [
10+
Plot.line(driving, {x: "miles", y: "gas", time: "year"})
11+
]
12+
});
13+
}

test/plots/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ export {default as dotSort} from "./dot-sort.js";
5858
export {default as downloads} from "./downloads.js";
5959
export {default as downloadsOrdinal} from "./downloads-ordinal.js";
6060
export {default as driving} from "./driving.js";
61+
export {default as drivingAnimation} from "./driving-animation.js";
6162
export {default as empty} from "./empty.js";
6263
export {default as emptyLegend} from "./empty-legend.js";
6364
export {default as emptyX} from "./empty-x.js";

0 commit comments

Comments
 (0)