Timeline
Why a time filter is a histogram whose brush owns its clause and sticks to its bars, why play runs inside the brushed range, accumulating, through the brush's own clause and at the page's pace, why its window reaches the page as identity and greys what has no time, why time is read from the schema and never guessed, and why a date on a relation is a vertex — and what would reverse each rule.
A timeline is a brushable histogram of one temporal column, which filters the page to the brushed
range and can play that range forward. ChartTimeline is the analytics part, and a timeline under a
graph canvas is the same part over vertices, every vertex type as one relation, on the column the
graph's time-by setting chooses. The reference is Cosmograph's
CosmographTimeline, read from @cosmograph/ui and @cosmograph/cosmograph 2.5.1. This page is
why ours is shaped as it is.
A histogram whose brush owns one clause
A timeline is three layers of bars over bin(field) and an interval brush that publishes one
clause: the bars behind are the column unfiltered, grey; the two above are what the page keeps, the
window excluded — the range dimmed, and in front, in colour, what the clause holds — and the
brush's clause is a chip in the bar like any other control's. The window is in colour and what it
leaves out is grey, as in Cosmograph: the layers above are clipped, so inside the window they cover
the grey bars behind and outside it only the grey shows. While play runs inside the range, the
clause holds less than the range, and the part of the range it has not reached is dimmed. The brush exempts
its whole plot, so a drag asks the database nothing here. A first version filtered the bars in front
by the window (peers: false) instead, and every step of a drag re-queried them; vgplot's Plot
redraws its whole svg for one mark's answer, brush included, so the timeline flickered and rebuilt
under the pointer. A clip is an attribute, drawn in the same frame. The brush is an outline,
because d3's grey fill over the window read as the part left out. What would reverse it: a Plot
that redraws one mark in place; then the bars in front can be the window's own query again, and
show its counts rather than its extent. Cosmograph's Timeline draws the same two
layers (highlightSelectedData) and publishes isBetween(column, range) from a client of its own.
Ours is a ChartRoot of ChartRectY marks and a ChartIntervalX. The bins are SQL, through vgplot's
bin(), which bins a temporal channel by date.
One clause means one source, for as long as the window lasts. An interval is its clause's source,
and vgplot builds new interactors with every plot, so a plot rebuilt under a window left that window
in the selection with no brush to move or clear it, and the next brush published beside it: the
bar held two windows and intersected them. Two things rebuilt it. bin() returns a new closure per
call, which the plot's signature reads by identity, so every render was a new grammar: bin on
/analytics is made once per field and options. And a resize or a theme change rebuilds by design,
so the clause's source is the interactor's place in the plot, not the interactor: ChartRoot
gives each place a source for the root's life, and whichever interactor holds the place publishes
under it. Handing the clause from each interactor to the next was not enough. In keasy, the readout
beside the bars changed width as it played, every width a rebuild, and a rebuild that landed before
the last had drawn handed over from an interactor that had never published; a drag begun on the old
plot went on publishing through its brush, which d3 keeps listening to. The bar held four windows at
once. With one source per place the selection holds one clause whoever publishes, and an interactor
no longer on screen speaks through the one that is. The heir is handed the value, draws its brush
from it and republishes it for its own marks; where the grammar changed, the old clause stays the
page's and the new interactor takes the page's place for its own key
(a relation that changes).
And the range is no longer drawn beside the bars, so it no longer resizes the plot. What would reverse it:
interactors in vgplot that outlive their plot, or a plot that resizes without being rebuilt; then
there is nothing to hand over and no place to keep.
It replaced the dashboard's private Timeline, the filter control behind a temporal field's chip
(packages/ui/src/charts/dashboard-filters.tsx), which drew the same marks with no playback and
could not be placed anywhere else. The chip renders ChartTimeline now: one implementation, as the
graph and the dashboard need the same part. Cosmograph's binning was turned down: it reads the
whole column into the browser and bins it in JavaScript, 250 bars by default. Ours asks the
coordinator for one row per bin, which a pre-aggregate can answer.
What would reverse it: a temporal column whose bins cannot be a GROUP BY — a time with a
timezone per row, read as wall-clock time. Then the bins are computed over the rows the client holds,
as Cosmograph's are.
Play runs inside the brushed range, accumulating
The brushed range is the reader's and never moves; play runs inside it. Each step publishes
[the range's start, one bar further], from the range's first bar to its end, so the graph fills
in over time. That is Kepler.gl's incremental animation window, whose start stays put while its
end grows to the end of the last bin
(animation-controller.ts
L214-232). It is also the reading of Gapminder's play, where the chart builds through time
(Vizabi time.js).
The brush stays drawn as the range's outline, the bars from its start to the frame are in colour,
the rest of the range is dimmed, and outside it is grey. A pause or the end shows the same, frozen.
When nothing plays, the range is a plain window.
Cosmograph's moving window was built first and turned down. Cosmograph's Timeline moves the
selection itself a bar at a time, keeping its width, and clears it at the end
(@cosmograph/ui 2.5.1, modules/timeline/component.js: _animateSelection and
stopAnimation). Built that way here, it threw away the range the reader had chosen: the window left
the years it was brushed over, and a sweep from a narrow window over a large graph greyed almost
everything. A reader who brushes 1980 – 1990 is asking to watch those years, so the range stays and
the frame grows inside it. What would reverse it: readers who want a fixed-width window moving over
time — a rolling year; then a second mode, Kepler's free, beside this one, chosen in the settings.
With no range, Play is disabled, as Cosmograph's is. It is described, and its tooltip says why:
Brush a range to play. Cosmograph disables its control without a selection
(_initAnimationControls and the disabled class). The sweep from the axis's start that we tried
instead played a range nobody chose.
At the end, it stops and keeps the whole range; the next Play starts over from its first bar. That is Vizabi's rule, go to start if we start from end point, and what Observable's Scrubber does on the next press. Cosmograph clears the selection at the end, which reads as play undoing the reader's range, and Kepler loops forever, which never lets a graph settle. What would reverse it: a reader who wants it to loop; then a loop setting, off by default.
A pointer coming down on the bars or the brush while it plays pauses it. Scrubber stops on any
input it did not make, Vizabi sets playing = false on a drag, and
Animated Vega-Lite pauses when the slider is scrubbed.
A drag makes a new range, and the next Play starts from that range's start. Cosmograph and Flourish
play on under the pointer, so the reader's gesture and the step fight over the same brush.
Each step waits for the page to answer the last, and takes no less than
max(50 ms, 50 ms × 60 / bars). The step publishes, then waits for the clients of the page
(Selection.pending("value"), which resolves once the coordinator has answered every client the
value reached, the graph's among them) and for that least time. Cosmograph's 50 ms a bar does not
wait, and on SNB's 341,661 vertices the canvas fell behind the readout. Kepler schedules its next
frame only after React has drawn the last, and Vizabi skips frames when they lag: both let the render
set the pace. A graph that answers slowly plays slowly, rather than out of step, and dashboards follow
at the same rate, each query a Mosaic client throttles to one in flight. On fewer bars a step takes
longer, so a sweep of the whole axis lasts about as long.
Under prefers-reduced-motion a step takes 500 ms at least. The rule on
preferences is to honour the query wherever something moves. Play is
what the person asked for and pause is one press away, so it still plays, ten times slower, each
frame a state to read. None of the references handles the query. What would reverse it: a reader
under the query for whom even that is too much; then Play steps one bar per press.
One clause, and one chip, for the timeline's life. Each frame is published through the
interval's own clause, whose source is its place in the plot (above), so a frame, a drag, a stuck
drag-end and a rebuild all replace the same clause. Cosmograph does the same with one
FilteringClient per timeline publishing as source: this. Mosaic depends on it: a crossfilter
selection drops queued values whose clause has the same source (queueFilter), so with one source a
burst of steps collapses to the newest. While it plays the chip reads as it does paused, its text
following the window, with no marker; its × stops play and lets the range go, as the page's
Clear every filter does.
The timeline is a compact band, 44 px, drawn as Cosmograph's app draws it: no border or panel of
its own, the tick labels in a row at the top, each to the right of a thin rule that runs the band's
height, and the bars under them. Plot's axis attributes cannot put a label beside its tick, so the
axis is Plot's axisX mark with anchor: "top", textAnchor: "start", and gridX for the rules
through the bars. The last tick, at the axis's end, has no room to its right and goes unlabelled.
With no range the bars are a low-contrast fill, the dimmed layer alone; a range is bright inside
it. What would reverse it: a timeline tall enough for a y axis, or labels that need the room a
bottom axis gives; then the axis returns under the bars.
Nothing shows until the bars are drawn. The band holds its 44 px from the first render, empty:
no Play, no ticks, no skeleton, and no Play in the accessibility tree. When the plot has drawn its
bars, the band and Play appear together with a short fade, none under prefers-reduced-motion. A
Play over an empty band offered a range nobody could see.
Under a graph, the timeline is part of the graph's load — by a rule of the graph's, not the
timeline's. The graph's first load waits for every chart on its page that has not drawn its first
answer: a chart holds the page's first draw (holdFirstDraw on the page's selection) from the moment
it asks until its first answer is drawn, fails, or stops asking under a MosaicClients that is off,
and the graph stays Loading until its canvas's first frame and that count is zero, so the canvas,
the bars and Play appear at once. It is held once; a chart that comes, or a column chosen or changed
later, waits on its own, and the graph is not sent back to Loading. It replaced a hold the graph's
own timeline part took on the store, which made the rule one part's when it is every chart's. What
would reverse it: a page whose charts are much slower than its graph; then the canvas shows first
and the charts catch up, as a column chosen later does.
Play is a bare glyph at the band's left edge, muted until hovered, as Cosmograph's is. Cosmograph
draws a borderless icon, 20 – 30 px wide and half opaque, as a flex item left of its bars
(style.js, animationControl). Ours is a solid 20 px glyph centred on the band, with 10 px on either
side, so the band's edge is as far from it as the bars are, with no box at all: no border, background or hover tint. It sits muted at 0.6 opacity, rises to
the foreground at 1 on hover and focus, and to 0.2 when there is nothing to play. Keyboard focus
outlines the glyph alone. It is a plain button, not Button, whose recipe is the box. It is named Play time or Pause time, the same words as its
tooltip, so it does not read as the canvas toolbar's layout transport. The bars take every pixel
right of it. The window's range is not drawn beside them: the chip reads it, and a slot that changed
width resized and so rebuilt the plot. It is still read, as a group whose aria-valuetext is the
window. A pause and the end are announced; the steps are not, since one every 50 ms would flood a
screen reader. Space on the focused figure plays and pauses.
A clause belongs to the source that published it, and only that source changes it, so play asks
the brush's clause for each frame. interval.clause(frame) builds it, in data rather than pixels:
publish inverts a pixel floored to the interactor's pixelSize, so a frame it set would hold
1909.9 where the bar starts at 1910.
The range sticks to the bars' edges, Cosmograph's stickySelection. When a drag ends — a
d3-brush end with a sourceEvent, which a move made in code never has — the range is moved to the
nearest edges of the bars the brushed mark drew, at least one bar wide. So it reads 1910 – 1940,
its chip too, and each frame ends on an edge: one bar, whatever its width, a month of uneven days
included. The edges are the mark's own x1 and x2, the bins vgplot asked for, so nothing is binned
twice. Mosaic draws this line itself. An interval interactor never follows its selection, while an
input that is driven from outside is bound to a Param: Slider follows a Param's value events
and not a Selection's. ChartTimeline reaches its interactor through Plot.interactors, which
vgplot exposes.
Two alternatives were turned down.
valueandonValueChangeon the brush, Ark's controlled pair. React would hold the range beside the clause that already holds it: two sources of truth for one filter.- Patching mosaic-plot so an interval follows its selection. That moves the brush when another
source updates the clause, and the model says no other source does. Clearing is the one case where
something else touches the clause: the bar's chip and the page's Clear every filter retract it, and the
interactor's
resetalready answers that.
What would reverse it: an interval interactor in Mosaic bound to a Param — the brush following
a value, as Slider does. Then play writes the Param, and ChartTimeline stops reaching into
Plot.interactors.
Time is read from the schema, never guessed
A column is temporal when the corpus says it is: a date or timestamp column, or one whose
datatype is xsd:date, xsd:dateTime or xsd:gYear. fossil_columns.datatype carries the IRI
the shape declared, so an xsd:gYear stored as an int32 is drawn as years. Cosmograph instead
samples 100 values and calls a column of year-like integers a year. A count of rows, a price or an
id in that range would be read as a date, and the schema already says which column is a year.
The column is a setting of the graph, not a selector of the part's. time-by sits beside
X axis and Group by in GRAPH_SECTION, and the root answers its options from the corpus's
temporal columns, as it answers theirs. Cosmograph's own app configures its timeline the same way, in
its settings panel. A selector inside the part would have been a second door to a choice the graph
already makes in one place. None, the default, draws no timeline. The histogram is the union of the
tables that have the column, one colour, and while a window is set only the vertices with the
column inside it stay in colour.
The window reaches every other relation as identity, not as its column. A window is date BETWEEN … on vertices, and only the types with a date can answer it. The canvas reads vertices
too, so it reads the window as published; a dashboard's relation, or a filter over a type with no
date, reads it as the page's resolver crosses it — dense_id IN (SELECT dense_id FROM vertices WHERE <the window>) — the way a dashboard's tiles cross (one selection across
relations). It names the key alone,
so every client answers it, without a Binder error. The chip reads date 1910 – 1940: the clause
itself, on the page's one selection.
A vertex without the column is outside every window, so it is greyed, as Cosmograph greys it.
The crossing was first an anti-join, dense_id NOT IN (… WHERE NOT <the window>), which dropped only
the dated rows the window leaves out and kept every type without the column whole. In keasy that read
wrong: on SNB, a window over 30 birthdays left 340,163 of 341,661 vertices in colour, every post,
place and tag among them, and it looked like a window that selected more than it should. Cosmograph's
Timeline filters its points by isBetween(column, range), and a point with no time answers false,
so it is greyed with what the window leaves out. The semi-join is that rule over identity: only the
rows that have the column and fall in the window are kept, so the same window reads 30 of 341,661.
The edges follow by the rule that a link needs both its ends.
A dashboard over a relation whose root has no time column shows no rows while a window is set. It answers the same clause through its root, and none of its keys are in the window. That is Mosaic's model: a client applies every clause it is handed, and a cross-filter is the rows every clause keeps, so a relation that cannot be in the window is not shown as if it were. It is not left whole, which would say the window had nothing to do with it. Why it is empty is on the bar: the chip names the column, birthday 1981 – 1985, and removing it brings the rows back.
What would reverse it: clients of the page that each apply the clause rule themselves, as
GraphClient once did, skipping a clause they cannot answer. Then a column clause could go to the page
as itself. Mosaic's clients apply every clause they are handed, so the rule lives in the clause. And
for the greying: readers who take a greyed type with no time as one the window judged, often enough
that the chip does not correct them; then the types without the column are drawn apart from the
window's greyed-out vertices — fainter, or listed in the legend as having no time — and are still
not kept whole.
What would reverse it: a corpus whose times arrive as text with no datatype, such as one written without a shape. Then a column is offered when its values parse, and the parse is the rule. And for the setting: a page with two timelines over one graph, each on its own column. Then the column is a prop of the part again, and the setting is the default the root hands it.
Filtered is greyed out, and playing does not change that
What the window leaves out is greyed out, and nothing is hidden. What has no time is left out
too. Cosmograph 2.5 does the same: it hands cosmos.gl highlightedPointIndices and
highlightedLinkIndices, and the rest takes the greyout opacity. So there is no "hide" preference for playback. A graph that grows over time reads as points
coming into colour against the shape of the whole.
What would reverse it: readers who take the greyed-out points as present while it plays, often enough that the counts beside the canvas do not correct them. Then playback dims what is out of the window further than a filter does, and still does not hide it, because a hidden point moves the layout's sense of where things are.
A date on a relation is a vertex
A relation that carries a time, such as when a prize was won or since when two people know each
other, is a vertex between its two ends: the W3C's n-ary relations, schema.org's Role. A Nobel
Award with its year stands between the laureate and the category, and LDBC's knows is a
Knowing with its creationDate. The timeline filters those vertices, and the edges into the
greyed ones grey with them by the rule that a link needs both its ends. Cosmograph's useLinksData,
a filter over the edges themselves, is not built. A fossil corpus has no edge properties, because a
shape cannot declare one before SHACL 1.2 and Turtle 1.2 are Recommendations; fossil plans them in
its own design/later/edge-properties.
What would reverse it: a corpus that writes edge properties. Then an edge table lists its columns
as a vertex table does, a link gets an index as a vertex does, and a clause over an edge column keeps
the edges it matches and their endpoints — Cosmograph's rule, endpointPointsOf.
What a test reads
The part is accessible first, in the testing rule's order. Play and pause are
one button, Play time or Pause time, with aria-pressed, disabled and described with no range;
the chart is aria-busy while its query runs; the window the clause holds is a group whose
aria-valuetext reads 1950 – 1960; and a pause or the end is a status, Paused at 1950 – 1955.
ChartTimeline registers its x scale under its title in window.__KANZO_TESTING__.charts when a
test has installed the hook. TimelineHarness then brushes in data and plays.
Held by packages/ui/src/charts/chart-timeline.test.tsx ("reads a brushed range as the edges it
spans, in axis order — the frames play shows"; "has nothing to play without a range, as
Cosmograph's has not"; "is 50 ms a bar on 60 bars or more, and slower on fewer, so a sweep takes as
long"; "is 500 ms at least under prefers-reduced-motion"; "reserves the band's height and draws nothing in it — no Play, no ticks — until the bars are
drawn"; a brushed window sticks to the nearest edges, at least one bar
wide; a year reads as its integer, on the axis too),
packages/ui/src/charts/filter-bar.test.tsx ("reads a clause as its field and value, whatever
relation it was published on"),
packages/graph/src/parts/timeline.test.tsx ("is driven by TimelineHarness: the window the group
reads, and play and pause through the one button"; "draws the chosen column as a timeline named by it";
"holds the graph's loading until its bars are drawn, as any chart on its page, and mounts its band
once"; "makes Play a bare glyph the band's height, with the same 10
px either side, as Cosmograph's"; "crosses a window on it into every other relation, keeping
none of a type without the column"; "holds one clause and one chip through every
state play passes — a rebuild and a stale brush included"; "is disabled with no range, and playable
once one is brushed"; "accumulates inside the range a bar at a time, stops at its end with the whole
range, and starts over on the next press"; "pauses when the pointer comes down on the bars, holds
the frame, and plays on from it"; "reads its chip as the window while it plays, and the chip's remove stops it and
lets the window go"; "plays and pauses with Space on the focused timeline"; "waits for the page to
answer each window before the next one"; "neither rebuilds nor redraws the plot while a brush is
dragged"; "holds one window, which each brush replaces — through a re-render and a rebuild of the
plot"; "draws the window's bars in colour by clipping the bars in front to it, with no query"; "draws
the axis at the top, each label right of its rule, whole, the first at the axis's start"), packages/mosaic/src/crossfilter.test.ts
("crosses into and out of a union by the key its types share") and
packages/graph/src/parts/graph-section.test.tsx (the setting offers the xsd:gYear column by its
datatype, and no integer of the same range).
Analytics
How the dashboard is built — Mosaic's public surface and nothing under it, a spec that is read whole or refused, a recommender made of Show Me's cases, three tables over one closed set of kinds, and an editor nobody downloads until they edit — and what would reverse each rule.
Crossfilter
Why everything that narrows the page is a filter — one clause on one page selection, the timeline and the canvas included — why "kept" is decided once and only drawn differently per view, why a position is an encoding and "no position" a state of its own, and what that changes in the graph, the timeline and the gestures.