Kanzo UI
The design of Kanzo UI

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.

  • value and onValueChange on 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 reset already 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).

On this page