Skip to content

Commit 081ef48

Browse files
committed
move animation code to time.js
1 parent 5e37062 commit 081ef48

File tree

2 files changed

+394
-291
lines changed

2 files changed

+394
-291
lines changed

src/plot.js

Lines changed: 33 additions & 273 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,21 @@
1-
import {bisectLeft, cross, difference, easeQuadInOut, extent, group, groups, InternMap, interpolate, interpolateNumber, interpolateRound, interpolateHsl, intersection, scaleLinear, select} from "d3";
1+
import {cross, difference, group, 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";
55
import {defined} from "./defined.js";
66
import {Dimensions} from "./dimensions.js";
77
import {Legends, exposeLegends} from "./legends.js";
8-
import {arrayify, constant, isDomainSort, isScaleOptions, keyword, map, maybeNamed, range, second, valueof, where, yes} from "./options.js";
9-
import {Scales, ScaleFunctions, autoScaleRange, coerceNumbers, exposeScales, isOrdinalScale} from "./scales.js";
8+
import {arrayify, isDomainSort, isScaleOptions, keyword, map, maybeNamed, range, second, valueof, where, yes} from "./options.js";
9+
import {Scales, ScaleFunctions, autoScaleRange, exposeScales} from "./scales.js";
1010
import {position, registry as scaleRegistry} from "./scales/index.js";
1111
import {applyInlineStyles, maybeClassName, maybeClip, styles} from "./style.js";
12-
import {maybeTimeFilter, maybeTween, defaultKeys} from "./time.js";
12+
import {animate, maybeTimeFilter, defaultKey, prepareTimeScale} from "./time.js";
1313
import {basic, initializer} from "./transforms/basic.js";
1414
import {maybeInterval} from "./transforms/interval.js";
1515
import {consumeWarnings} from "./warnings.js";
1616

1717
export function plot(options = {}) {
18-
const {facet, time, style, caption, ariaLabel, ariaDescription} = options;
18+
const {facet, style, caption, ariaLabel, ariaDescription} = options;
1919

2020
// className for inline styles
2121
const className = maybeClassName(options.className);
@@ -72,7 +72,7 @@ export function plot(options = {}) {
7272
}
7373

7474
// Initialize the marks’ state.
75-
const markTimes = new Map();
75+
let hasTime = !!options.time;
7676
for (const mark of marks) {
7777
if (stateByMark.has(mark)) throw new Error("duplicate mark; each mark must be unique");
7878

@@ -84,8 +84,12 @@ export function plot(options = {}) {
8484

8585
const {data, facets, channels, time, timeFacets} = mark.initialize(markFacets, facetChannels);
8686
applyScaleTransforms(channels, options);
87-
stateByMark.set(mark, {data, facets, channels});
88-
if (timeFacets.length) markTimes.set(mark, {time, timeFacets});
87+
if (timeFacets.length) {
88+
stateByMark.set(mark, {data, facets, channels, time, timeFacets, layouts: []});
89+
hasTime = true;
90+
} else {
91+
stateByMark.set(mark, {data, facets, channels});
92+
}
8993
}
9094

9195
// Initalize the scales and axes.
@@ -135,79 +139,18 @@ export function plot(options = {}) {
135139
}
136140

137141
// Infer the time scale
138-
let interpolateTime;
139-
let timeScale;
140-
if (markTimes.size) {
141-
({time: timeScale} = Scales(new Map([["time", Array.from(markTimes, ([, {time}]) => ({value: time}))]]), options));
142-
if (isOrdinalScale(timeScale)) {
143-
const index = new InternMap(timeScale.domain.map((d, i) => [d, i]));
144-
// ordinal times are mapped to their rank in the ordinal domain
145-
for (const [, m] of markTimes) {
146-
const domain = [...intersection(timeScale.domain, m.time)];
147-
m.time = m.time.map(d => index.get(d));
148-
m.domain = domain.map(d => index.get(d));
149-
}
150-
} else {
151-
for (const [, m] of markTimes) {
152-
m.time = coerceNumbers(m.time);
153-
m.domain = [];
154-
for (const t of m.time) {
155-
if (isNaN(t) || !isFinite(t)) continue;
156-
const i = bisectLeft(m.domain, t);
157-
if (m.domain[i] === t) continue;
158-
m.domain.splice(i, 0, t);
159-
}
160-
}
161-
}
162-
163-
const {
164-
delay = 0,
165-
duration = 5000,
166-
direction = 1,
167-
playbackRate = 1,
168-
initial,
169-
autoplay = true,
170-
iterations = 0,
171-
loop = !!iterations,
172-
alternate = false,
173-
loopDelay = 1000
174-
} = time == null ? {} : time;
175-
interpolateTime = scaleLinear()
176-
.domain(isOrdinalScale(timeScale) ? [0, timeScale.domain.length - 1] : extent(timeScale.domain))
177-
.range([0, 1]);
178-
179-
if (typeof delay !== "number" || delay < 0 || !isFinite(delay)) throw new Error(`Unsupported delay ${delay}.`);
180-
if (typeof duration !== "number" || duration < 0 || !isFinite(duration)) throw new Error(`Unsupported duration ${duration}.`);
181-
if (![-1, 1, null].includes(direction)) throw new Error(`Unsupported direction ${direction}.`);
182-
if (initial != null && Number.isNaN(interpolateTime(initial))) throw new Error(`Unsupported initial time ${initial}.`);
183-
if (typeof autoplay !== "boolean") throw new Error(`Unsupported autoplay option ${autoplay}.`);
184-
if (typeof loop !== "boolean") throw new Error(`Unsupported loop option ${loop}.`);
185-
if (typeof playbackRate !== "number") throw new Error(`Unsupported playback rate ${playbackRate}.`);
186-
if (typeof alternate !== "boolean") throw new Error(`Unsupported alternate option ${alternate}.`);
187-
if (typeof loopDelay !== "number" || loopDelay < 0) throw new Error(`Unsupported loop delay ${loopDelay}.`);
188-
scaleDescriptors.time = {
189-
type: timeScale.type,
190-
domain: timeScale.domain,
191-
delay,
192-
duration,
193-
direction,
194-
playbackRate,
195-
initial,
196-
autoplay,
197-
iterations,
198-
loop,
199-
alternate,
200-
loopDelay,
201-
scale: timeScale.scale
202-
};
142+
const time = hasTime && prepareTimeScale(options, stateByMark);
143+
if (time) {
144+
scaleDescriptors.time = time;
145+
scales.time = time.scale;
203146
}
204147

205148
for (const [mark, state] of stateByMark) {
206149
const {facets, values} = state;
207150

208151
// Reassemble time facets
209152
if (mark.time) {
210-
const m = markTimes.get(mark);
153+
const m = stateByMark.get(mark);
211154
const {domain, time, timeFacets} = m;
212155
if (domain.length <= 1) continue;
213156

@@ -226,8 +169,6 @@ export function plot(options = {}) {
226169
}
227170
}
228171

229-
const animateMarks = [];
230-
231172
const {width, height} = dimensions;
232173

233174
const svg = create("svg", context)
@@ -305,14 +246,14 @@ export function plot(options = {}) {
305246
.attr("transform", facetTranslate(fx, fy))
306247
.each(function(key) {
307248
const j = indexByFacet.get(key);
308-
for (const [mark, {channels, values, facets}] of stateByMark) {
249+
for (const [mark, {channels, values, facets, layouts}] of stateByMark) {
309250
const facet = facets ? mark.filter(facets[j] ?? facets[0], channels, values) : null;
310-
const index = mark.time ? [] : facet;
251+
const index = layouts ? [] : facet;
311252
const node = mark.render(index, scales, values, subdimensions, context);
312253
if (node != null) {
313254
this.appendChild(node);
314-
if (mark.time) {
315-
animateMarks.push({
255+
if (layouts) {
256+
layouts.push({
316257
mark,
317258
node,
318259
facet,
@@ -323,14 +264,14 @@ export function plot(options = {}) {
323264
}
324265
});
325266
} else {
326-
for (const [mark, {channels, values, facets}] of stateByMark) {
267+
for (const [mark, {channels, values, facets, layouts}] of stateByMark) {
327268
const facet = facets ? mark.filter(facets[0], channels, values) : null;
328-
const index = mark.time ? [] : facet;
269+
const index = layouts ? [] : facet;
329270
const node = mark.render(index, scales, values, dimensions, context);
330271
if (node != null) {
331272
svg.appendChild(node);
332273
if (mark.time) {
333-
animateMarks.push({
274+
layouts.push({
334275
mark,
335276
node,
336277
facet,
@@ -373,191 +314,14 @@ export function plot(options = {}) {
373314
.text(`${w.toLocaleString("en-US")} warning${w === 1 ? "" : "s"}. Please check the console.`);
374315
}
375316

376-
if (animateMarks.length > 0) {
377-
const {alternate, autoplay, delay, direction, duration, initial, iterations, loopDelay} = scaleDescriptors.time;
378-
let {loop, playbackRate} = scaleDescriptors.time;
379-
let lastTick;
380-
let t1, currentTime, ended = false, paused = !autoplay;
381-
382-
const timeupdate = (t) => {
383-
if (t1 === (t = Math.max(0, Math.min(1, t)))) return;
384-
currentTime = interpolateTime.invert(t1 = t);
385-
for (const timeMark of animateMarks) {
386-
const {mark, facet, dimensions} = timeMark;
387-
const {channels: {key}} = stateByMark.get(mark);
388-
const {domain} = markTimes.get(mark);
389-
const K = key ? key.value : null;
390-
const i0 = bisectLeft(domain, currentTime);
391-
const time0 = domain[i0 - 1];
392-
const time1 = domain[i0] !== undefined ? domain[i0] : time0;
393-
const timet = time1 === time0 ? 0 : (t - interpolateTime(time0)) / (interpolateTime(time1) - interpolateTime(time0));
394-
const {interp, opacity} = stateByMark.get(mark);
395-
const T = interp.time;
396-
let timeNode;
397-
const I0 = facet.filter(i => T[i] === time0); // preceding keyframe
398-
const I1 = facet.filter(i => T[i] === time1); // following keyframe
399-
let enter = [], update = [], target = [], exit = [];
400-
if (K) {
401-
const K0 = new Set(I0.map(i => K[i]));
402-
const K1 = new Set(I1.map(i => K[i]));
403-
const Kenter = difference(K1, K0);
404-
const Kupdate = intersection(K0, K1);
405-
const Kexit = difference(K0, K1);
406-
enter = I1.filter(i => Kenter.has(K[i]));
407-
update = I0.filter(i => Kupdate.has(K[i]));
408-
target = update.map(i => I1.find(j => K[i] === K[j])); // TODO: use an index
409-
exit = I0.filter(i => Kexit.has(K[i]));
410-
} else {
411-
enter = I1;
412-
exit = I0;
413-
}
414-
const n = update.length;
415-
const nt = n + enter.length + exit.length;
416-
const Ii = Uint32Array.from({length: nt}).map((_, i) => i + T.length);
417-
if (exit.length || enter.length) interp.opacity = opacity;
418-
419-
// TODO This is interpolating the already-scaled values, but we
420-
// probably want to interpolate in data space instead and then
421-
// re-apply the scales. I’m not sure what to do for ordinal data,
422-
// but interpolating in data space will ensure that the resulting
423-
// instantaneous visualization is meaningful and valid. TODO If the
424-
// data is sparse (not all series have values for all times), then
425-
// we will need a separate key channel to align the start and end
426-
// values for interpolation; this code currently assumes that the
427-
// data is complete.
428-
for (const k in interp) {
429-
if (k === "time") {
430-
for (let i = 0; i < nt; ++i) interp[k][Ii[i]] = currentTime;
431-
} else if (k === "opacity") {
432-
const _exit = easeQuadInOut(1 - timet);
433-
const _enter = easeQuadInOut(timet);
434-
for (let i = 0; i < exit.length; ++i) interp[k][Ii[i]] = _exit;
435-
for (let i = 0; i < n; ++i) interp[k][Ii[exit.length + i]] = 1;
436-
for (let i = 0; i < enter.length; ++i) interp[k][Ii[exit.length + n + i]] = _enter;
437-
} else {
438-
const tween = maybeTween(mark.tween, k);
439-
const interpolator = tween ? tween :
440-
["time"].includes(k) ? () => constant(currentTime) :
441-
["x", "x1", "x2", "y", "y1", "y2", "r"].includes(k) ? interpolateNumber :
442-
["fill", "stroke"].includes(k) ? interpolateHsl :
443-
["text"].includes(k) ? (a, b) => typeof a === "number" ? (frac(a) || frac(b) || Math.abs(a-b) < 3) ? interpolateNumber(a, b) : interpolateRound(a, b) : constant(a) :
444-
interpolate;
445-
for (let i = 0; i < exit.length; ++i) interp[k][Ii[i]] = interp[k][exit[i]];
446-
for (let i = 0; i < n; ++i) {
447-
const prev = interp[k][update[i]], next = interp[k][target[i]];
448-
interp[k][Ii[i + exit.length]] = prev == next ? prev : interpolator(prev, next)(timet);
449-
}
450-
for (let i = 0; i < enter.length; ++i) interp[k][Ii[i + n + exit.length]] = interp[k][enter[i]];
451-
}
452-
}
453-
const ifacet = [...facet.filter(i => T[i] < time1), ...(currentTime < time1) ? Ii : [], ...facet.filter(i => T[i] >= time1)];
454-
const index = mark.timeFilter(ifacet, T, currentTime);
455-
timeNode = mark.render(index, scales, interp, dimensions, context);
456-
timeMark.node.replaceWith(timeMark.node = timeNode);
457-
}
458-
459-
// https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/timeupdate_event
460-
if (window.CustomEvent) figure.dispatchEvent(new window.CustomEvent("timeupdate"));
461-
};
462-
463-
let ticker = direction * playbackRate < 0 ? 1 : 0;
464-
const tick = function() {
465-
if (paused) {
466-
lastTick = undefined;
467-
} else {
468-
// advance (or rewind) the clock by dt
469-
const dt = lastTick === undefined
470-
? (lastTick = performance.now(), 0)
471-
: performance.now() - lastTick;
472-
lastTick += dt;
473-
ticker += dt * direction * playbackRate / duration;
474-
}
475-
476-
// t is the projection of the clock to the looping interval
477-
let t = ticker;
478-
479-
if (loop) {
480-
const s = 1 + loopDelay / duration;
481-
const t0 = Math.floor((0.5 + Math.abs(t - 0.5)) / s);
482-
if (iterations && t0 >= iterations) {
483-
t = 2; // ends
484-
} else {
485-
const f = t - s * t0 * Math.sign(t - 0.5);
486-
if (alternate) {
487-
t = t0 % 2 ? 1 - f : f;
488-
} else {
489-
t = f;
490-
}
491-
t = Math.max(0, Math.min(1, t));
492-
}
493-
}
494-
ended = t < 0 || t > 1;
495-
if (ended) paused = true;
496-
497-
timeupdate(t);
498-
if (figure.parentElement) requestAnimationFrame(tick);
499-
};
500-
501-
// When using setTime, the argument is in the original time domain
502-
const setTime = function(time) {
503-
if (isOrdinalScale(timeScale)) {
504-
const i = timeScale.domain.indexOf(time);
505-
if (i === -1) throw new Error(`unknown time ${time}`);
506-
time = i;
507-
}
508-
ticker = interpolateTime(time);
509-
currentTime = interpolateTime.invert(ticker);
510-
ended = ticker < 0 || ticker > 1;
511-
lastTick = t1 = undefined;
512-
timeupdate(Math.max(0, Math.min(1, ticker)));
513-
};
514-
515-
// https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/play
516-
figure.play = () => {
517-
if (ended) {
518-
setTime(initial == null
519-
? timeScale.domain[direction * playbackRate < 0 ? timeScale.domain.length - 1 : 0]
520-
: initial
521-
);
522-
}
523-
paused = false;
524-
return new Promise(r => r());
525-
};
526-
527-
// https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/pause
528-
figure.pause = () => {
529-
paused = true;
530-
t1 = undefined;
531-
};
532-
533-
// https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/duration
534-
Object.defineProperty(figure, 'duration', {get: () => duration / 1000});
535-
536-
// https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/paused
537-
Object.defineProperty(figure, 'paused', {get: () => paused});
538-
539-
// https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/ended
540-
Object.defineProperty(figure, 'ended', {get: () => ended});
541-
542-
// https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/currentTime
543-
Object.defineProperty(figure, 'currentTime', {
544-
get: () => isOrdinalScale(timeScale) ? timeScale.domain[Math.floor(currentTime)]
545-
: timeScale.type === "utc" || timeScale.type === "time" ? new Date(currentTime)
546-
: currentTime,
547-
set: setTime
548-
});
549-
550-
// https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/playbackRate
551-
// https://github.com/whatwg/html/issues/3754
552-
Object.defineProperty(figure, 'playbackRate', {get: () => playbackRate, set: (l) => {!isNaN(l = +l) && (playbackRate = l);}});
553-
554-
// https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/loop
555-
Object.defineProperty(figure, 'loop', {get: () => loop, set: (l) => {loop = !!l;}});
556-
557-
if (initial != null) setTime(initial);
558-
559-
timeupdate(ticker);
560-
setTimeout(tick, delay);
317+
if (hasTime) {
318+
animate(
319+
stateByMark,
320+
time,
321+
scales,
322+
figure,
323+
context
324+
);
561325
}
562326

563327
return figure;
@@ -596,7 +360,7 @@ export class Mark {
596360
group(facet, i => T[i]),
597361
([t, I]) => (timeFacets.push(j), time.push(t), I)
598362
));
599-
this.channels.key = {value: this.key ?? defaultKeys(T), filter: null};
363+
this.channels.key = {value: this.key ?? defaultKey(T), filter: null};
600364
}
601365
if (this.transform != null) {
602366
({data, facets} = this.transform(data, facets)), data = arrayify(data);
@@ -766,7 +530,3 @@ class FacetMap2 extends FacetMap {
766530
return this;
767531
}
768532
}
769-
770-
function frac(x) {
771-
return x - Math.floor(x);
772-
}

0 commit comments

Comments
 (0)