Format dates and times in tooltips, in one grammar with the numbers

SS
5d ago
+1115-25

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.

tooltip time format before/after

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 of YmdHMSbB, not the % alone, so ".0%" still means percent and format={"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 as x. Lookup now walks the channel, then the short channel name labels= already documents ("color", "size"), then the column bound to that channel on the hovered trace, read from the sources map the payload already carries. Two series binding different columns to y keep 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 viewdate partclock
    ≥ 28 d%b %d, %Ynone
    ≥ 1 min%b %d%H:%M
    ≥ 1 snone%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:%M from a minute up, %H:%M:%S from 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 read Sep 17, 10:00:10; per-second samples two months wide read Sep 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, so format= did nothing unless it was accompanied by fields= or title=.

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

configbeforeafter
format={"time": "%b %d, %Y, %H:%M"}2026-09-17 10:10:00ZSep 17, 2026, 10:10
no format, 95-minute window2026-09-17 10:05:00ZSep 17, 10:05
x_axis(format="%H:%M"), no tooltip format2026-09-17 10:05:00Z10:05
mode="x" band title2026-09-17 10:05:00ZSep 17, 2026, 10:05
mode="x", format={"yes": ".0%", "no": ".3f"}Yes 0.14 / No 0.86Yes 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 aliases map, 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.value may be an authored spoke label, so routing every customized row through the formatter would have turned Direction: north into Direction: 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:00 and every hour of a day read Sep 17, 2026 over 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, since fmtTime picks 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:%S and 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.