Skip to content

faceted time #1018

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 46 commits into
base: mbostock/time
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
74c6996
time and facets
Fil Jul 29, 2022
f7ac0a4
Avoid duplicating points (since Ii represents currentTime, it's === t…
Fil Jul 29, 2022
8acc5b1
a bit cleaner
Fil Jul 29, 2022
a68f332
optimization: don't interpolate same values (in particular, colors)
Fil Jul 29, 2022
3d86d4a
transforms *almost* work now
Fil Jul 29, 2022
5a9abae
a test for faceting (even more broken than the others)
Fil Jul 29, 2022
c086ee4
checkpoint
Fil Aug 1, 2022
6005af8
facets closer to working
Fil Aug 1, 2022
38d0c3c
a few broken tests
Fil Aug 1, 2022
3f0e2e4
reassemble after initializers (should work with dodge)
Fil Aug 1, 2022
4f9c586
object consistency, avoid interpolating strings
Fil Aug 1, 2022
6373d24
fix time filters on the box examples
Fil Aug 1, 2022
64ea634
pass the subdimensions to time facets
Fil Aug 1, 2022
1fdb546
key
Fil Aug 2, 2022
25131da
slightly better for exit
Fil Aug 2, 2022
b62f924
test plots
Fil Aug 2, 2022
9a0e97a
object consistency is now obtained by the key accessor (or default key)
Fil Aug 2, 2022
61a60cf
allows several marks to play together even when their time domains ar…
Fil Aug 2, 2022
02cdbfe
animation: "fade" vs animation: "interpolate" fixes the issue with ou…
Fil Aug 3, 2022
126d717
* delay, duration, tweens
Fil Aug 3, 2022
c22af4f
remove the animation=fade setting, seems we can live without it (for …
Fil Aug 4, 2022
78eeef1
implement the https://developer.mozilla.org/en-US/docs/Web/API/HTMLMe…
Fil Aug 4, 2022
96d195a
fix setTime/ended; use window.CustomEvent
Fil Aug 4, 2022
5c23cca
ensure deterministic tests
Fil Aug 4, 2022
4bbc1ac
support playbackRate
Fil Aug 5, 2022
27b7f0e
when pausing, we need to draw
Fil Aug 5, 2022
1b483a9
stop ticking when we're detached from the dom
Fil Aug 5, 2022
b4814f3
nicer ticks for the bar chart race
Fil Aug 5, 2022
fd3a3f6
improve bar chart race
Fil Aug 5, 2022
85d1c02
revert 3d86d4abd since we have time facets now
Fil Aug 5, 2022
941db98
ordinal times
Fil Aug 5, 2022
219087a
expose time scale
Fil Aug 8, 2022
f54cf64
data source
Fil Aug 8, 2022
eb5e0a1
time key
Fil Aug 9, 2022
0b51175
animate bins, and a todo for groups
Fil Aug 9, 2022
4b04ab3
an example plot which doesn't fully work (the bins appear and vanish …
Fil Aug 9, 2022
18ef5bf
bins work by adding fake data to fill up the empty bins
Fil Aug 10, 2022
5ae6d20
ensure paused is boolean
Fil Aug 10, 2022
3aa4c72
immediately apply currentTime changes
Fil Aug 10, 2022
0b2db83
nicer gapminder-bin test
Fil Aug 10, 2022
6e165a4
multiple time snapshots
Fil Aug 10, 2022
db7c46f
more time snapshots
Fil Aug 10, 2022
5e37062
format commit (just to get CI to work)
Fil Aug 10, 2022
4e7bb98
move animation code to time.js
Fil Aug 10, 2022
a1d9ee0
draft documentation for time (to merge in README when all is set)
Fil Aug 10, 2022
1fc07ea
some todos
Fil Aug 10, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/marks/box.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export function boxX(data, {
const group = y != null ? groupY : groupZ;
return marks(
ruleY(data, group({x1: loqr1, x2: hiqr2}, {x, y, stroke, strokeOpacity, ...options})),
barX(data, group({x1: "p25", x2: "p75"}, {x, y, fill, fillOpacity, ...options})),
barX(data, group({x1: "p25", x2: "p75"}, {x, y, fill, fillOpacity, z: stroke, ...options})),
tickX(data, group({x: "p50"}, {x, y, stroke, strokeOpacity, strokeWidth, sort, ...options})),
dot(data, map({x: oqr}, {x, y, z: y, stroke, strokeOpacity, ...options}))
);
Expand Down
220 changes: 98 additions & 122 deletions src/plot.js
Original file line number Diff line number Diff line change
@@ -1,16 +1,15 @@
import {bisectLeft, cross, difference, groups, InternMap, interpolate, interpolateNumber, select} from "d3";
import {cross, difference, group, groups, InternMap, select} from "d3";
import {Axes, autoAxisTicks, autoScaleLabels} from "./axes.js";
import {Channel, Channels, channelDomain, valueObject} from "./channel.js";
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, take, where, yes} from "./options.js";
import {Scales, ScaleFunctions, autoScaleRange, exposeScales, coerceNumbers} from "./scales.js";
import {arrayify, isDomainSort, isScaleOptions, keyword, map, maybeNamed, range, second, valueof, where, yes} from "./options.js";
import {Scales, ScaleFunctions, autoScaleRange, exposeScales} from "./scales.js";
import {position, registry as scaleRegistry} from "./scales/index.js";
import {inferDomain} from "./scales/quantitative.js";
import {applyInlineStyles, maybeClassName, maybeClip, styles} from "./style.js";
import {maybeTimeFilter} from "./time.js";
import {animate, maybeTimeFilter, defaultKey, prepareTimeScale} from "./time.js";
import {basic, initializer} from "./transforms/basic.js";
import {maybeInterval} from "./transforms/interval.js";
import {consumeWarnings} from "./warnings.js";
Expand Down Expand Up @@ -73,16 +72,24 @@ export function plot(options = {}) {
}

// Initialize the marks’ state.
let hasTime = !!options.time;
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);

const {data, facets, channels, time, timeFacets} = mark.initialize(markFacets, facetChannels);
applyScaleTransforms(channels, options);
stateByMark.set(mark, {data, facets, channels});
if (timeFacets.length) {
stateByMark.set(mark, {data, facets, channels, time, timeFacets, layouts: []});
hasTime = true;
} else {
stateByMark.set(mark, {data, facets, channels});
}
}

// Initalize the scales and axes.
Expand Down Expand Up @@ -126,16 +133,42 @@ 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);

// Compute value objects, applying scales as needed.
for (const state of stateByMark.values()) {
state.values = valueObject(state.channels, scales);
}

// Infer the time scale
const time = hasTime && prepareTimeScale(options, stateByMark);
if (time) {
scaleDescriptors.time = time;
scales.time = time.scale;
}

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

// Reassemble time facets
if (mark.time) {
const m = stateByMark.get(mark);
const {domain, time, timeFacets} = m;
if (domain.length <= 1) continue;

const newTime = [];
const newFacets = [];
for (let k = 0; k < facets.length; ++k) {
const t = time[k], j = timeFacets[k];
for (const i of facets[k]) newTime[i] = t;
newFacets[j] = newFacets[j] ? newFacets[j].concat(facets[k]) : facets[k];
}

state.facets = newFacets;
state.interp = Object.fromEntries(Object.entries(values).map(([key, value]) => [key, Array.from(value)]));
state.interp.time = newTime;
state.opacity = new Array(newTime.length).fill(1);
}
}

const {width, height} = dimensions;

const svg = create("svg", context)
Expand Down Expand Up @@ -213,90 +246,39 @@ export function plot(options = {}) {
.attr("transform", facetTranslate(fx, fy))
.each(function(key) {
const j = indexByFacet.get(key);
for (const [mark, {channels, values, facets}] of stateByMark) {
for (const [mark, {channels, values, facets, layouts}] 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);
const index = layouts ? [] : facet;
const node = mark.render(index, scales, values, subdimensions, context);
if (node != null) {
this.appendChild(node);
if (layouts) {
layouts.push({
mark,
node,
facet,
dimensions: subdimensions
});
}
}
}
});
} else {
const timeMarks = [];
for (const [mark, {channels, values, facets}] of stateByMark) {
for (const [mark, {channels, values, facets, layouts}] of stateByMark) {
const facet = facets ? mark.filter(facets[0], channels, values) : null;
const index = channels.time ? [] : facet;
const index = layouts ? [] : facet;
const node = mark.render(index, scales, values, dimensions, context);
if (channels.time) timeMarks.push({mark, node, interp: Object.fromEntries(Object.entries(values).map(([key, value]) => [key, Array.from(value)]))});
if (node != null) svg.appendChild(node);
}
if (timeMarks.length) {
// 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,
// and other timing parameters of the animation.
const interpolateTime = interpolateNumber(...timeDomain);
const delay = 0; // TODO configurable; delay initial rendering
const duration = 5000; // TODO configurable
const startTime = performance.now() + delay;
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, node, interp} = timeMark;
const {channels, values, facets} = stateByMark.get(mark);
const facet = facets ? mark.filter(facets[0], channels, values) : null;
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,
// but interpolating in data space will ensure that the resulting
// instantaneous visualization is meaningful and valid. TODO If the
// data is sparse (not all series have values for all times), or if
// the data is inconsistently ordered, then we will need a separate
// key channel to align the start and end values for interpolation;
// this code currently assumes that the data is complete and the
// order is consistent. TODO The sort transform (which happens by
// 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 (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) {
interp[k][Ii[i]] = interpolate(values[k][I0[i]], values[k][I1[i]])(timet);
}
}
}

// TODO We need to switch to using temporal facets so that the
// facets are guaranteed to be in chronological order. (Within a
// facet, there’s no guarantee that the index is sorted
// chronologically.)
const ifacet = [...facet.filter(i => T[i] <= time0), ...Ii, ...facet.filter(i => T[i] > time0)];
const index = mark.timeFilter(ifacet, interp.time, currentTime);
timeNode = mark.render(index, scales, interp, dimensions, context);
} else {
const index = mark.timeFilter(facet, T, currentTime);
timeNode = mark.render(index, scales, values, dimensions, context);
}
node.replaceWith(timeNode);
timeMark.node = timeNode;
if (node != null) {
svg.appendChild(node);
if (mark.time) {
layouts.push({
mark,
node,
facet,
dimensions
});
}
if (t < 1) requestAnimationFrame(tick);
});
}
}
}

Expand Down Expand Up @@ -332,22 +314,34 @@ export function plot(options = {}) {
.text(`${w.toLocaleString("en-US")} warning${w === 1 ? "" : "s"}. Please check the console.`);
}

if (hasTime) {
animate(
stateByMark,
time,
scales,
figure,
context
);
}

return figure;
}

export class Mark {
constructor(data, channels = {}, options = {}, defaults) {
const {facet = "auto", sort, time, timeFilter, dx, dy, clip, channels: extraChannels} = options;
const {facet = "auto", sort, time, key, timeFilter, tween, dx, dy, clip, channels: extraChannels} = options;
this.data = data;
this.sort = isDomainSort(sort) ? sort : null;
this.initializer = initializer(options).initializer;
this.transform = this.initializer ? options.transform : basic(options).transform;
this.facet = facet == null || facet === false ? null : keyword(facet === true ? "include" : facet, "facet", ["auto", "include", "exclude"]);
this.timeFilter = maybeTimeFilter(timeFilter);
this.tween = tween;
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.time = time;
this.key = key;
this.channels = Object.fromEntries(Object.entries(channels).filter(([name, {value, optional}]) => {
if (value != null) return true;
if (optional) return false;
Expand All @@ -360,10 +354,20 @@ 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);
const T = valueof(data, this.time), time = [], timeFacets = [];
if (T != null) {
facets = facets.flatMap((facet, j) => Array.from(
group(facet, i => T[i]),
([t, I]) => (timeFacets.push(j), time.push(t), I)
));
this.channels.key = {value: this.key ?? defaultKey(T), filter: null};
}
if (this.transform != null) {
({data, facets} = 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};
return {data, facets, channels, time, timeFacets};
}
filter(index, channels, values) {
for (const name in channels) {
Expand Down Expand Up @@ -449,34 +453,6 @@ function addScaleChannels(channelsByScale, stateByMark, filter = yes) {
return channelsByScale;
}

// TODO There should be a way to set at explicit domain of the time scale, and
// probably also a way to control whether times are expressed (and coerced) to
// numbers or dates. And maybe non-linear (log or sqrt) time, too, or should
// that be controlled with easing?
function findTimeChannels(stateByMark) {
const channels = [];
for (const {channels: {time}} of stateByMark.values()) {
if (time) {
coerceNumbers(time.value); // Note: mutates!
channels.push(time);
}
}
return channels;
}

function aggregateTimes(channels) {
const times = [];
for (const {value} of channels) {
for (let t of value) {
if (t == null || isNaN(t = +t)) continue;
const i = bisectLeft(times, t);
if (times[i] === t) continue;
times.splice(i, 0, t);
}
}
return times;
}

// Derives a copy of the specified axis with the label disabled.
function nolabel(axis) {
return axis === undefined || axis.label === undefined
Expand Down
18 changes: 17 additions & 1 deletion src/scales.js
Original file line number Diff line number Diff line change
Expand Up @@ -414,10 +414,23 @@ function exposeScale({
interval,
transform,
percent,
pivot
pivot,

// time
alternate,
autoplay,
delay,
direction,
duration,
initial,
iterations,
loopDelay,
loop,
playbackRate
}) {
if (type === "identity") return {type: "identity", apply: d => d, invert: d => d};
const unknown = scale.unknown ? scale.unknown() : undefined;

return {
type,
domain: slice(domain), // defensive copy
Expand Down Expand Up @@ -449,6 +462,9 @@ function exposeScale({
...scale.padding && (scale.paddingInner ? {paddingInner: scale.paddingInner(), paddingOuter: scale.paddingOuter()} : {padding: scale.padding()}),
...scale.bandwidth && {bandwidth: scale.bandwidth(), step: scale.step()},

// time
...duration !== undefined && {alternate, autoplay, delay, direction, duration, initial, iterations, loopDelay, loop, playbackRate},

// utilities
apply: t => scale(t),
...scale.invert && {invert: t => scale.invert(t)}
Expand Down
6 changes: 5 additions & 1 deletion src/scales/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ export const opacity = Symbol("opacity");
// Symbol scales have a default range of d3.symbols.
export const symbol = Symbol("symbol");

// A time scale maps the given times (ordinal or continuous) to the [0, 1] interval
export const time = Symbol("time");

// 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.
Expand All @@ -34,5 +37,6 @@ export const registry = new Map([
["color", color],
["opacity", opacity],
["symbol", symbol],
["length", length]
["length", length],
["time", time]
]);
Loading