Format dates and times in tooltips, in one grammar with the numbers
Stacked on #509 — based on alek/tooltip-x-band so the diff is only my commits. Retarget to main once #509 merges.
#509 gives the pointer a band anywhere in the plot; this makes the value it shows readable. Together they are the tooltip a time-series app (the report came from a prediction market) actually needs: hover anywhere along x, read the time, read every series.
Before / after
The docs demo this PR adds, rendered twice in headless Chromium — once against this PR's base client, once against this branch's — hovering the same point through the view's own hover API, so it shows the shipped path rather than a mock.

The bug
xy.tooltip(format={"time": "%b %d, %Y, %H:%M"}) was accepted, shipped correctly on the wire, and then ignored. _formatTooltipValue only ever tried the numeric spec, so a strftime pattern fell through to the raw ISO stamp:
2026-09-17 10:03:05.264Z # what you got, whatever you passed to format=
Sep 17, 2026, 10:05 # what you asked for
fmtTimeSpec existed but was reachable only from axis labels — no tooltip path called it, for any field, table-backed or not. Reproduced on origin/main in headless Chromium before the change, and the same repro is now a test.
What changed
One grammar resolves every tooltip value format= can address — the default x/y/colour/size rows, fields=, title= placeholders, and a band's title and series rows — in both modes:
-
A strftime pattern formats a time-kinded value; anything else stays the numeric spec. The test is a
%followed by one ofYmdHMSbB, not the%alone, so".0%"still means percent andformat={"time": ",.0f"}still means epoch milliseconds as a number. An authored format always wins over an inherited one. -
Both key spellings resolve.
format=/labels=are keyed by the author's column names; rows are keyed by channel, and a table-backed datetime reaches the row asx. Lookup now walks the channel, then the short channel namelabels=already documents ("color","size"), then the column bound to that channel on the hovered trace, read from thesourcesmap the payload already carries. Two series binding different columns toykeep their own formats, and the band title takes the format keyed by the time column instead of only"x". -
A time value with nothing authored on it stops reading as an ISO stamp. It follows the axis's own strftime
format=when there is one, so a tooltip and the ticks beneath it agree; otherwise the pattern is composed from two independent answers.The span says how much context the value carries, and sets a baseline clock:
span in view date part clock ≥ 28 d %b %d, %Ynone ≥ 1 min %b %d%H:%M≥ 1 s none %H:%M:%S< 1 s — none — the ISO stamp stays, being the only form carrying ms The hovered series' sampling can only sharpen that clock, never touch the date:
%H:%Mfrom a minute up,%H:%M:%Sfrom a second up, and finer than a second there is no strftime token, so the ISO stamp stays. A tooltip names ONE point, and a pattern that prints the same string for a point and the one beside it names neither — the span alone cannot know, because it says nothing about how close the samples are. Ten-second samples three minutes wide readSep 17, 10:00:10; per-second samples two months wide readSep 17, 2026, 10:00:10. The comparison is>=, so daily samples in a month-wide window are not given a clock they have no use for.The floor is this resolved default's alone — an authored pattern, and one inherited from the axis, is used as written, because the inheritance exists so a tooltip and its ticks agree. It sharpens as you zoom, which is what "the format depends on the duration of the trade" wants without anyone configuring it.
-
format=works on its own. An otherwise-empty tooltip short-circuited to the default rows before formats were read, soformat=did nothing unless it was accompanied byfields=ortitle=.
Composition-specific readouts are deliberately untouched and documented as such: sankey flow and node totals, funnel stage rows, and a categorical colour label, which is a category name rather than a value. Colour and size values never reach the time path at all — their rows ship with no kind, so a format= on them is always the numeric spec.
Measured, in the real client
| config | before | after |
|---|---|---|
format={"time": "%b %d, %Y, %H:%M"} | 2026-09-17 10:10:00Z | Sep 17, 2026, 10:10 |
| no format, 95-minute window | 2026-09-17 10:05:00Z | Sep 17, 10:05 |
x_axis(format="%H:%M"), no tooltip format | 2026-09-17 10:05:00Z | 10:05 |
mode="x" band title | 2026-09-17 10:05:00Z | Sep 17, 2026, 10:05 |
mode="x", format={"yes": ".0%", "no": ".3f"} | Yes 0.14 / No 0.86 | Yes 14% / No 0.860 |
Scope
Live-only, like the tooltips themselves — no exporter, payload, or wire change, and the strftime token set is unchanged (still fmtTimeSpec's, shared with axis labels and mirrored in python/xy/_svg.py), so the TS↔Python parity tests are untouched. mode="nearest" and mode="x" behave identically apart from the rendered text.
Not in this PR: shipping string columns listed in fields= (#510) — that is a data-path change rather than a formatting one, and is the natural follow-up.
Spec / docs / tests
spec/api/interaction.md §7.4 (new); docs/components/tooltips.md gains a "Time and Date Values" section with a live demo and an FAQ entry; news fragment.
tests/test_tooltip_time_format.py — 9 tests, browser probes driving the real client through each resolution path: the reported repro, both key spellings, the numeric-on-time precedence guard, a listed field with no format, format= alone, axis inheritance, the four span tiers, two adjacent points of a ten-second series in a three-minute window, a one-second series in a forty-day window, the per-trace channel split, the polar angle's spoke label vs. an explicit format, and the labels= channel vocabulary.
Review
greptile, cubic and CodeRabbit filed 15 threads across the first four passes; all are addressed and resolved. Two were real bugs caught before merge and each now has a test that fails without its fix:
- Per-trace channel — a column used as x by one trace and y by another resolved through the figure-level
aliasesmap, which keeps only the first binding, so the second trace formatted by the wrong channel, axis and span. - Polar angle — an authored
format=never reached the angle row. Fixed narrowly rather than as suggested:polar.valuemay be an authored spoke label, so routing every customized row through the formatter would have turnedDirection: northintoDirection: 1.57. Only an explicit format for that channel overrides the label.
@FarhanAliRaza's review found a third, on the default path (fixed in 08cc63e), and the fix for it drew a fourth (fixed in b86314b):
- The span default hid the data's own resolution. A tier picked from the visible window alone says nothing about how close the samples are, so ten-second samples in a three-minute window both read
Sep 17, 10:00and every hour of a day readSep 17, 2026over a month of hourly data — worse than the ISO stamp the default replaced, which at least kept them distinct. His two §7.4 corrections are in the same commit: the colour/size sentence, and a "one granularity finer than the axis labels" claim that was never true, sincefmtTimepicks label precision from the tick step rather than the span. - Resolving it by walking down a ladder spent context to buy precision (greptile and cubic, independently). Each rung of a ladder of whole patterns changes the date and the clock at once, so per-second samples in a month-wide window landed on
%H:%M:%Sand named no day — and, one rung up, five-minute samples in a 120-day window lost the year. Hence the composition above, which answers the two questions separately so neither can be spent on the other.
Each has a test verified to fail on the head before its fix, and cubic's two doc-accuracy findings from the same pass (the floor applies to the resolved default, not an inherited axis pattern; the mean-spacing fallback is no longer the band title's case) are in b86314b.