Skip to content

time channel #995

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 5 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
120 changes: 115 additions & 5 deletions src/plot.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import {cross, difference, groups, InternMap, select} from "d3";
import {bisectLeft, cross, difference, groups, InternMap, interpolate, interpolateNumber, 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, where, yes} from "./options.js";
import {Scales, ScaleFunctions, autoScaleRange, exposeScales} from "./scales.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 {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 {basic, initializer} from "./transforms/basic.js";
import {maybeInterval} from "./transforms/interval.js";
import {consumeWarnings} from "./warnings.js";
Expand Down Expand Up @@ -124,6 +126,11 @@ 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);
Expand Down Expand Up @@ -213,11 +220,84 @@ export function plot(options = {}) {
}
});
} else {
const timeMarks = [];
for (const [mark, {channels, values, facets}] of stateByMark) {
const facet = facets ? mark.filter(facets[0], channels, values) : null;
const node = mark.render(facet, scales, values, dimensions, context);
const index = channels.time ? [] : 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 (t < 1) requestAnimationFrame(tick);
});
}
}

// Wrap the plot in a figure with a caption, if desired.
Expand Down Expand Up @@ -257,15 +337,17 @@ export function plot(options = {}) {

export class Mark {
constructor(data, channels = {}, options = {}, defaults) {
const {facet = "auto", sort, dx, dy, clip, channels: extraChannels} = options;
const {facet = "auto", sort, time, timeFilter, 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);
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.channels = Object.fromEntries(Object.entries(channels).filter(([name, {value, optional}]) => {
if (value != null) return true;
if (optional) return false;
Expand Down Expand Up @@ -367,6 +449,34 @@ 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
37 changes: 37 additions & 0 deletions src/time.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
export function maybeTimeFilter(filter = "eq") {
if (typeof filter === "function") return timeFunction(filter);
switch (`${filter}`.toLowerCase()) {
case "lt": return timeLt;
case "lte": return timeLte;
case "gt": return timeGt;
case "gte": return timeGte;
case "eq": return timeEq;
}
throw new Error(`invalid time filter: ${filter}`);
}

function timeFunction(f) {
return (I, T, time) => {
return I.filter(i => f(T[i], time));
};
}

function timeLt(I, T, time) {
return I.filter(i => T[i] < time);
}

function timeLte(I, T, time) {
return I.filter(i => T[i] <= time);
}

function timeGt(I, T, time) {
return I.filter(i => T[i] > time);
}

function timeGte(I, T, time) {
return I.filter(i => T[i] >= time);
}

function timeEq(I, T, time) {
return I.filter(i => T[i] === time);
}
Loading