From cadbb910db7d5997fa90683f89aaeb5db2b94e50 Mon Sep 17 00:00:00 2001 From: Sriman Date: Tue, 22 Sep 2026 01:18:33 +0000 Subject: [PATCH 01/11] Format dates and times in tooltips, in one grammar with the numbers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `xy.tooltip(format={"time": "%b %d, %Y, %H:%M"})` was accepted, shipped on the wire, and then ignored: the tooltip's value formatter only ever tried the numeric spec, so a strftime pattern fell through to the raw ISO stamp (`2026-09-17 10:03:05.264Z`). `fmtTimeSpec` existed but was reachable only from axis labels. Now one grammar resolves every tooltip value, in nearest mode, in a shared-axis band, and in the default tooltip: - A strftime pattern (a `%` followed by one of `YmdHMSbB`) formats a time-kinded value; anything else stays the numeric spec, so `".0%"` still means percent and `format={"time": ",.0f"}` still means epoch milliseconds. An authored format always wins over an inherited one. - `format=` is keyed by the author's column names, rows are keyed by channel: a table-backed datetime reaches the row as `x`. Both keys now resolve — the channel, the short channel name `labels=` documents, 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 rather than only `"x"`. - A time value with nothing authored on it no longer reads as an ISO stamp either. It follows the axis's own `format=` when there is one, so a tooltip and the ticks beneath it agree, and otherwise the pattern its visible span reads best in: `Sep 17, 2026` for months, `Sep 17, 10:05` for hours or days, `10:05:00` for seconds. Each tier is one granularity finer than the axis labels the same span produces, because a tooltip names one point rather than an interval, and it sharpens as you zoom. Under a second the ISO stamp stays, being the only form that carries milliseconds. - `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=`. Spec: interaction.md §7.4. Docs: a "Time and Date Values" section in the tooltips guide, plus the FAQ entry. Tests: tests/test_tooltip_time_format.py — browser probes driving the real client through each resolution path, including the reported repro, the per-trace split, and the span ladder. --- docs/components/tooltips.md | 66 +++++- js/src/30_ticks.ts | 24 +- js/src/52_tooltip.ts | 191 +++++++++++++-- news/+tooltip-time-format.feature.md | 13 ++ spec/api/interaction.md | 42 ++++ tests/test_tooltip_time_format.py | 336 +++++++++++++++++++++++++++ 6 files changed, 645 insertions(+), 27 deletions(-) create mode 100644 news/+tooltip-time-format.feature.md create mode 100644 tests/test_tooltip_time_format.py diff --git a/docs/components/tooltips.md b/docs/components/tooltips.md index 50aa1fb5a..f1641704a 100644 --- a/docs/components/tooltips.md +++ b/docs/components/tooltips.md @@ -84,14 +84,67 @@ def tooltip_fields_demo(): ~~~ Braced field names in `title` are replaced from the hovered row. `format` maps -source field names to the client's numeric format strings, while `labels` maps -those same source names to presentation text. Labels never change title +source field names to format strings — numeric specs such as `",.0f"` and +`".1%"`, or strftime patterns such as `"%b %d, %Y"` on date and time values +(see [Time and Date Values](#time-and-date-values)) — while `labels` maps those +same source names to presentation text. Labels never change title placeholder lookup or the event payload. When `fields` is omitted, `labels` renames the matching default x/y/color/size rows; direct array channels can use the channel names `"x"`, `"y"`, `"color"`, and `"size"`. A source column that is not bound to a rendered channel is not shipped merely because its name appears in `fields`. +### Time and Date Values + +A datetime column formats with a strftime pattern in the same `format=` map. +The tokens are `%Y %m %d %H %M %S %b %B`, the set the axis labels use: + +~~~python demo exec +import datetime + +import reflex_xy +import xy + +tooltip_time_start = datetime.datetime(2026, 9, 17, 10, 0) +tooltip_time_data = { + "time": [tooltip_time_start + datetime.timedelta(hours=6 * i) for i in range(16)], + "yes": [0.41, 0.44, 0.43, 0.47, 0.52, 0.55, 0.53, 0.58, + 0.61, 0.59, 0.64, 0.68, 0.66, 0.71, 0.74, 0.72], +} + +tooltip_time_chart = xy.line_chart( + xy.line(x="time", y="yes", data=tooltip_time_data, name="Yes"), + xy.tooltip( + mode="x", + format={"time": "%b %d, %Y, %H:%M", "yes": ".0%"}, + labels={"yes": "Yes"}, + ), +) + + +def tooltip_time_demo(): + return reflex_xy.chart(tooltip_time_chart, height="320px") +~~~ + +With no `format=` for a time field, the tooltip does not fall back to a raw +timestamp. It uses the axis's own `format=` when the axis has one, so the +tooltip and the tick labels beneath it read alike; otherwise it picks the +pattern the visible span reads best in — `Sep 17, 2026` for a window of months, +`Sep 17, 10:05` for one of hours or days, `10:05:00` for one of seconds. Zoom +in and the tooltip sharpens with the axis. Below a second the ISO timestamp +stays, because it is the only form that carries milliseconds. + +Because the format is chosen per field, one chart can carry a precise +timestamp in the tooltip and short labels on the axis: + +~~~python +xy.x_axis(format="%b %d") # axis: Sep 17 +xy.tooltip(format={"time": "%b %d, %Y, %H:%M"}) # tooltip: Sep 17, 2026, 10:05 +~~~ + +`format=` works on its own — without `fields=` or `title=` — in which case it +formats the default x/y/color/size rows in place. + ### Title Templates Across Multiple Series One tooltip configuration serves every mark in the chart: the braced `{day}` @@ -240,6 +293,15 @@ the available x/y values plus any encoded color or size values. Add `xy.tooltip()` as a chart child only when you want to choose fields, formats, or a title template. +### How do I show a formatted date or time in a tooltip? + +Pass a strftime pattern for that column, e.g. +`xy.tooltip(format={"time": "%b %d, %Y, %H:%M"})`. It applies wherever the value +appears — a field row, a `title=` placeholder, or the band title under +`mode="x"`. With nothing passed, a time value follows the axis `format=` if +there is one, and otherwise the visible span. See +[Time and Date Values](#time-and-date-values). + ### How do I customize which fields a tooltip shows and how numbers are formatted? Pass `fields=` and `format=` to `xy.tooltip()`, e.g. diff --git a/js/src/30_ticks.ts b/js/src/30_ticks.ts index b1809b8e6..3951a40f3 100644 --- a/js/src/30_ticks.ts +++ b/js/src/30_ticks.ts @@ -234,7 +234,29 @@ export function fmtNumberSpec(v, format) { return `${prefix}${text}${percent ? "%" : ""}${suffix}`; } -function fmtTimeSpec(ms, format) { +// Whether a `format=` string is a strftime pattern rather than a numeric spec. +// `%` alone is the number grammar's percent suffix (`".0%"`), so the test is a +// `%` followed by one of the tokens `fmtTimeSpec` actually replaces — that way +// `format=".0%"` on a time-kinded value still means "as a number". +export function isTimeFormat(format) { + return typeof format === "string" && /%[YmdHMSbB]/.test(format); +} + +// The strftime pattern a time value reads best in when nothing was authored, +// chosen from the span currently in view. A tooltip names ONE point, so each +// tier is a granularity finer than the axis labels the same span produces +// (§7.4): a week-wide window ticks in days and hovers to the minute. Below a +// second there is no strftime token for milliseconds, so the caller keeps the +// ISO fallback, which carries them. +export function defaultTimeFormat(span) { + if (!Number.isFinite(span) || span <= 0) return null; + if (span >= 28 * MS.d) return "%b %d, %Y"; + if (span >= MS.m) return "%b %d, %H:%M"; + if (span >= MS.s) return "%H:%M:%S"; + return null; +} + +export function fmtTimeSpec(ms, format) { if (typeof format !== "string") return null; const d = new Date(ms); if (!Number.isFinite(d.getTime())) return null; diff --git a/js/src/52_tooltip.ts b/js/src/52_tooltip.ts index b3a5ed93e..e2f747f48 100644 --- a/js/src/52_tooltip.ts +++ b/js/src/52_tooltip.ts @@ -1,10 +1,26 @@ -import { fmtCategory, fmtNumberSpec, fmtValue } from "./30_ticks"; +import { + defaultTimeFormat, fmtCategory, fmtNumberSpec, fmtTimeSpec, fmtValue, isTimeFormat, +} from "./30_ticks"; import { ChartView } from "./50_chartview"; // ChartView tooltip resolution: map a hovered vertex back to its source // row, denormalize units, and compose the tooltip lines/DOM. Split out of // 50_chartview.js; augments the prototype so `this.*` is unchanged. +// Row keys that are channels rather than source columns. A value found under +// one of these is already positional; anything else reached the row as a copied +// source column and resolves its channel through `aliases`. +const TOOLTIP_CHANNELS = new Set([ + "x", "y", "color_value", "color_category", "size_value", +]); + +// Short names the docs give these channels in `labels=`/`format=`. +const TOOLTIP_CHANNEL_ALIASES = { + color_value: "color", + color_category: "color", + size_value: "size", +}; + Object.assign(ChartView.prototype, { _showTooltip(hit, clientX, clientY) { this._clearBandHover(); @@ -193,6 +209,43 @@ Object.assign(ChartView.prototype, { return lo + v * (hi - lo); }, + // The authored column name that names this trace's `channel`. `format=` and + // `labels=` are keyed by the user's column names; rows are keyed by channel. + // `sources` carries the per-TRACE mapping, so two series binding different + // columns to y keep their own formats — the figure-level `aliases` map + // cannot tell them apart. + _tooltipFieldFor(traceId, channel) { + const sources = (this.spec.tooltip && this.spec.tooltip.sources) || {}; + for (const [field, entries] of Object.entries(sources)) { + if (!Array.isArray(entries)) continue; + if (entries.some((e) => e && e.trace === traceId && e.channel === channel)) return field; + } + return null; + }, + + // `format=` for a listed field: the author's own key wins, then the channel + // it is bound to, so `format={"x": ...}` and `format={"time": ...}` are both + // honored however the field was named in `fields=`/`title=`. + _tooltipFormatFor(formats, field, channel, traceId) { + if (field !== undefined && formats[field] !== undefined) return formats[field]; + return this._tooltipChannelFormat(formats, channel, traceId); + }, + + // `format=` for a value known only by its channel. The channel key wins when + // the author used one (`format={"x": ...}`), then the column name bound to + // that channel — without this, `format={"time": ...}` was accepted, shipped, + // and then ignored by every path that looks a value up by channel. + _tooltipChannelFormat(formats, channel, traceId) { + if (formats[channel] !== undefined) return formats[channel]; + // The short channel names `labels=` documents for direct array channels + // ("color", "size") name the same value here, so the two maps stay one + // vocabulary. + const short = TOOLTIP_CHANNEL_ALIASES[channel]; + if (short !== undefined && formats[short] !== undefined) return formats[short]; + const field = this._tooltipFieldFor(traceId, channel); + return field === null ? undefined : formats[field]; + }, + _defaultTooltipLabel(channel, fallback, labels, aliases) { for (const [field, alias] of Object.entries(aliases)) { if (alias === channel && typeof labels[field] === "string") { @@ -304,7 +357,7 @@ Object.assign(ChartView.prototype, { return (own / total) * 100; }, - _defaultTooltipItems(row, labels = {}, aliases = {}) { + _defaultTooltipItems(row, labels = {}, aliases = {}, formats = {}) { const items = []; if (row.source !== undefined && row.target !== undefined) { items.push({ kind: "title", value: `${String(row.source)} → ${String(row.target)}` }); @@ -388,7 +441,10 @@ Object.assign(ChartView.prototype, { items.push({ kind: "field", label: polar && !customized ? polar.label : label, - value: polar ? polar.value : fmtValue(row.x, row.x_kind), + value: polar ? polar.value : this._formatTooltipValue( + row.x, row.x_kind, + this._tooltipChannelFormat(formats, "x", row.trace), { channel: "x", row }, + ), }); } } @@ -398,14 +454,24 @@ Object.assign(ChartView.prototype, { items.push({ kind: "field", label: polar && !customized ? polar.label : label, - value: fmtValue(row.y, row.y_kind), + value: this._formatTooltipValue( + row.y, row.y_kind, + this._tooltipChannelFormat(formats, "y", row.trace), { channel: "y", row }, + ), }); } if (row.color_value !== undefined) { const { label } = this._defaultTooltipLabel( "color_value", "color", labels, aliases, ); - items.push({ kind: "field", label, value: fmtValue(row.color_value) }); + items.push({ + kind: "field", + label, + value: this._formatTooltipValue( + row.color_value, undefined, + this._tooltipChannelFormat(formats, "color_value", row.trace), + ), + }); } if (row.color_category !== undefined) { const { label, customized } = this._defaultTooltipLabel( @@ -419,7 +485,14 @@ Object.assign(ChartView.prototype, { const { label } = this._defaultTooltipLabel( "size_value", "size", labels, aliases, ); - items.push({ kind: "field", label, value: fmtValue(row.size_value) }); + items.push({ + kind: "field", + label, + value: this._formatTooltipValue( + row.size_value, undefined, + this._tooltipChannelFormat(formats, "size_value", row.trace), + ), + }); } if (!items.length) items.push({ kind: "value", value: `#${row.index}` }); return items; @@ -438,10 +511,57 @@ Object.assign(ChartView.prototype, { const aliases = (this.spec.tooltip && this.spec.tooltip.aliases) || {}; const key = row[field] !== undefined ? field : aliases[field]; if (!key || row[key] === undefined) return [undefined, undefined]; - return [row[key], row[`${key}_kind`]]; - }, - - _formatTooltipValue(value, kind, format) { + // Third element: the CHANNEL behind the value, which is the only route + // back to the axis that says how to format it. Not the key that found the + // value: `_applySharedTooltipFields` copies a source column onto the row + // under its own name, so the key is usually the column ("time"), while the + // axis is reachable only through the channel it is bound to ("x"). + const channel = aliases[field] || (TOOLTIP_CHANNELS.has(key) ? key : null); + return [row[key], row[`${key}_kind`], channel]; + }, + + // The axis a tooltip field reads its scale from: the hovered trace's own x + // or y axis, so a secondary or named axis formats by its own `format=` and + // its own visible range. Channels that are not positions (colour, size) have + // no axis and resolve to null. + _tooltipValueAxis(context) { + const channel = context && context.channel; + if (channel !== "x" && channel !== "y") return null; + const g = context.g || (context.row + ? this.gpuTraces.find((t) => t.trace.id === context.row.trace) + : null); + return this._axis(channel === "x" ? (g && g.xAxis) : (g && g.yAxis)); + }, + + // strftime pattern for a time-kinded tooltip value with no authored + // `format=`: the axis's own `format=` first, so the tooltip and the tick + // labels under it read the same, then the span-aware default (§7.4). + _tooltipTimeFormat(context) { + const axis = this._tooltipValueAxis(context); + if (!axis) return null; + if (isTimeFormat(axis.format)) return axis.format; + const [lo, hi] = this._axisRange(axis.id); + return defaultTimeFormat(Math.abs(hi - lo)); + }, + + // One grammar for every tooltip value: a strftime `format=` on a time value, + // else the numeric spec, else the kind's plain text. A time value with no + // strftime format still resolves one from its axis (`_tooltipTimeFormat`), + // which is why `context` travels with every call — without it a table-backed + // datetime fell through to the raw ISO string no `format=` could reach. + _formatTooltipValue(value, kind, format, context?) { + if (kind === "time_ms") { + // An authored `format=` always wins, whichever grammar it is written in: + // a strftime pattern resolves here, a numeric spec falls through to the + // number path below (`format={"time": ",.0f"}` means epoch milliseconds, + // as a number). Only a value with nothing authored on it inherits the + // axis/span default. + const spec = isTimeFormat(format) ? format + : typeof format === "string" ? null + : this._tooltipTimeFormat(context); + const text = spec === null || spec === undefined ? null : fmtTimeSpec(value, spec); + if (text !== null) return text; + } const formatted = fmtNumberSpec(value, format); if (formatted !== null) return formatted; return fmtValue(value, kind); @@ -451,31 +571,37 @@ Object.assign(ChartView.prototype, { const tooltip = this.spec.tooltip || {}; const labels = tooltip.labels || {}; const aliases = tooltip.aliases || {}; + const formats = tooltip.format || {}; if (!tooltip.title && !Array.isArray(tooltip.fields)) { - return this._defaultTooltipItems(row, labels, aliases); + return this._defaultTooltipItems(row, labels, aliases, formats); } - const formats = tooltip.format || {}; const items = []; if (typeof tooltip.title === "string") { const title = tooltip.title.replace(/\{([^}]+)\}/g, (_, field) => { - const [value, kind] = this._tooltipLookup(row, field); - return value === undefined ? "" : this._formatTooltipValue(value, kind, formats[field]); + const [value, kind, channel] = this._tooltipLookup(row, field); + return value === undefined ? "" : this._formatTooltipValue( + value, kind, this._tooltipFormatFor(formats, field, channel, row.trace), + { channel, row }, + ); }); if (title) items.push({ kind: "title", value: title }); } if (Array.isArray(tooltip.fields)) { for (const field of tooltip.fields) { if (typeof field !== "string") continue; - const [value, kind] = this._tooltipLookup(row, field); + const [value, kind, channel] = this._tooltipLookup(row, field); if (value === undefined) continue; items.push({ kind: "field", label: typeof labels[field] === "string" ? labels[field] : field, - value: this._formatTooltipValue(value, kind, formats[field]), + value: this._formatTooltipValue( + value, kind, this._tooltipFormatFor(formats, field, channel, row.trace), + { channel, row }, + ), }); } } - return items.length ? items : this._defaultTooltipItems(row, labels, aliases); + return items.length ? items : this._defaultTooltipItems(row, labels, aliases, formats); }, _tooltipLines(items) { @@ -618,8 +744,11 @@ Object.assign(ChartView.prototype, { let title; if (typeof tooltip.title === "string") { title = tooltip.title.replace(/\{([^}]+)\}/g, (_, field) => { - const [value, kind] = this._tooltipLookup(first, field); - return value === undefined ? "" : this._formatTooltipValue(value, kind, formats[field]); + const [value, kind, channel] = this._tooltipLookup(first, field); + return value === undefined ? "" : this._formatTooltipValue( + value, kind, this._tooltipFormatFor(formats, field, channel, first.trace), + { channel, row: first }, + ); }); } else if (this._bandTitleValue !== undefined && this._bandTitleValue !== null) { // A bar band is titled by its category centre, not the anchor slot; the @@ -628,9 +757,16 @@ Object.assign(ChartView.prototype, { const [value, kind] = this._sourceDisplayValue( hits[0] && hits[0].g, along, this._bandTitleValue, first[`${along}_kind`], ); - title = this._formatTooltipValue(value, kind, formats[along]); + title = this._formatTooltipValue( + value, kind, this._tooltipChannelFormat(formats, along, first.trace), + { channel: along, g: hits[0] && hits[0].g }, + ); } else if (first[along] !== undefined) { - title = this._formatTooltipValue(first[along], first[`${along}_kind`], formats[along]); + title = this._formatTooltipValue( + first[along], first[`${along}_kind`], + this._tooltipChannelFormat(formats, along, first.trace), + { channel: along, g: hits[0] && hits[0].g }, + ); } if (title) items.push({ kind: "title", value: title }); const fields = Array.isArray(tooltip.fields) @@ -643,14 +779,21 @@ Object.assign(ChartView.prototype, { if (fields && fields.length) { value = fields .map((f) => { - const [v, k] = this._tooltipLookup(row, f); - return v === undefined ? null : this._formatTooltipValue(v, k, formats[f]); + const [v, k, channel] = this._tooltipLookup(row, f); + return v === undefined ? null : this._formatTooltipValue( + v, k, this._tooltipFormatFor(formats, f, channel, row.trace), { channel, g }, + ); }) .filter((v) => v !== null) .join(" "); } else { const v = row[across]; - value = v === undefined ? "" : this._formatTooltipValue(v, row[`${across}_kind`], formats[across]); + value = v === undefined ? "" + : this._formatTooltipValue( + v, row[`${across}_kind`], + this._tooltipChannelFormat(formats, across, row.trace), + { channel: across, g }, + ); } items.push({ kind: "series", label: name, value, color: this._seriesColorCss(g) }); }); diff --git a/news/+tooltip-time-format.feature.md b/news/+tooltip-time-format.feature.md new file mode 100644 index 000000000..4f019ccf5 --- /dev/null +++ b/news/+tooltip-time-format.feature.md @@ -0,0 +1,13 @@ +`xy.tooltip(format=...)` now formats dates and times. A strftime pattern — +`format={"time": "%b %d, %Y, %H:%M"}` — applies to any datetime-typed field, +including one sourced from a data table, which previously ignored the format +and rendered a raw ISO stamp such as `2026-09-17 10:03:05.264Z`. The format is +resolved from either the author's column name or the channel it is bound to, +per hovered trace, so two series binding different columns to `y` keep their +own formats and the band title under `mode="x"` takes the format keyed by the +time column. A time value with no `format=` no longer falls back to the ISO +stamp either: it follows the axis's own `format=` when there is one, and +otherwise the pattern the visible span reads best in (`Sep 17, 2026` for a +window of months, `Sep 17, 10:05` for hours or days, `10:05:00` for seconds), +sharpening as you zoom. `format=` also works on its own now, formatting the +default x/y/color/size rows without needing `fields=` or `title=`. diff --git a/spec/api/interaction.md b/spec/api/interaction.md index 08dd184ff..79b94decb 100644 --- a/spec/api/interaction.md +++ b/spec/api/interaction.md @@ -468,6 +468,48 @@ Wire: `tooltip.mode`, shipped only when not `"nearest"`. Live capture: `spec/assets/tooltip-x-band.png` — the pointer (red ring) far above Page B, the band tooltip, the cursor line, and both active dots. +### 7.4 Value formatting (`xy.tooltip(format=...)`) + +One grammar resolves every tooltip value, in both modes and in the default +tooltip (`js/src/52_tooltip.ts`, `_formatTooltipValue`): + +- An authored `format=` always wins. A **strftime pattern** (a `%` followed by + one of `YmdHMSbB`, the tokens `fmtTimeSpec` replaces) formats a time-kinded + value; anything else is the numeric spec. The test is the token, not the `%`, + so `format={"yes": ".0%"}` still means percent and `format={"time": ",.0f"}` + still means "epoch milliseconds, as a number". +- `format=` and `labels=` are keyed by the author's **column names**, but rows + are keyed by **channel** — a table-backed datetime reaches the row as `x`. + Both keys resolve: the channel key first, 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` therefore keep + their own formats. Before this, a format keyed by the column name was + accepted, shipped, and then silently ignored by every path that looks a value + up by channel — the band title among them. +- A time value with nothing authored on it resolves a pattern rather than + falling through to the ISO stamp: the **axis's own `format=`** first, so a + tooltip and the tick labels under it read alike, then the **visible span**: + + | span in view | pattern | + | --- | --- | + | ≥ 28 d | `%b %d, %Y` | + | ≥ 1 min | `%b %d, %H:%M` | + | ≥ 1 s | `%H:%M:%S` | + | < 1 s | none — the ISO stamp stays, as it is the only form carrying ms | + + Each tier is one granularity finer than the axis labels the same span + produces: a tooltip names one point, an axis label a whole interval. The + span is the hovered trace's own x/y axis range, so a secondary axis formats + by its own view (`defaultTimeFormat`, `js/src/30_ticks.ts`). +- `format=` alone is enough. It used to reach values only alongside `fields=` + or `title=`, because an otherwise-empty tooltip short-circuited to the + default rows before formats were read. +- Formatting is live-only, like the tooltips themselves; static exports are + unaffected, and the token set is `fmtTimeSpec`'s, shared with axis labels and + mirrored in `python/xy/_svg.py`. + +Covered by `tests/test_tooltip_time_format.py`. + ## 8. Unconditional behavior Not configurable through any switch: tooltip rendering and the kernel `pick` diff --git a/tests/test_tooltip_time_format.py b/tests/test_tooltip_time_format.py new file mode 100644 index 000000000..16ff52aa1 --- /dev/null +++ b/tests/test_tooltip_time_format.py @@ -0,0 +1,336 @@ +"""Tooltip value formatting (`xy.tooltip(format=...)`, interaction spec §7.4). + +`format=` is one grammar for every tooltip value: a strftime pattern on a +time-kinded value, the numeric spec otherwise. A datetime column reaches the +row under its channel alias (``"time"`` -> ``"x"``), so the format has to be +resolvable from either the column name or the channel, and a time value with no +authored format still resolves one — the axis's own ``format=``, else the +pattern its visible span reads best in — instead of falling through to a raw +ISO stamp. + +Browser probes drive the real client; they skip (never fail) without Chromium, +like the repo's others. +""" + +from __future__ import annotations + +import datetime as dt +import sys +import tempfile +from pathlib import Path + +import pytest + +from conftest import probe_document, run_browser_probe + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "python")) + +import xy # noqa: E402 +from xy.export import find_chromium # noqa: E402 + +START = dt.datetime(2026, 9, 17, 10, 0) +TIMES = [START + dt.timedelta(minutes=5 * i) for i in range(20)] +YES = [0.10 + 0.04 * i for i in range(20)] +NO = [0.90 - 0.04 * i for i in range(20)] +DATA = {"time": TIMES, "yes": YES, "no": NO} +# The point every probe hovers: index 1, five minutes past the start. +HOVER_INDEX = 1 + + +def _market_chart(**tooltip): + return xy.line_chart( + xy.step("time", "yes", data=DATA, where="post", name="Yes", color="#8884d8"), + xy.step("time", "no", data=DATA, where="post", name="No", color="#82ca9d"), + xy.tooltip(**tooltip), + xy.interaction_config(hover=True), + width=640, + height=360, + ) + + +def test_tooltip_format_ships_for_table_backed_fields() -> None: + """The wire carries the authored format under the author's own column + names, plus the per-trace `sources` map the client resolves them through.""" + chart = _market_chart( + fields=["time", "yes"], + format={"time": "%b %d, %Y, %H:%M", "yes": ".0%"}, + labels={"time": "", "yes": "Yes"}, + ) + tooltip = chart.figure().build_payload()[0]["tooltip"] + assert tooltip["format"] == {"time": "%b %d, %Y, %H:%M", "yes": ".0%"} + # "time" is bound to x on both traces, "yes" to y on the first one only: + # that per-trace split is what keeps two series' formats apart. + assert tooltip["sources"]["time"] == [ + {"trace": 0, "channel": "x"}, + {"trace": 1, "channel": "x"}, + ] + assert tooltip["sources"]["yes"] == [{"trace": 0, "channel": "y"}] + assert tooltip["aliases"]["time"] == "x" + + +# Hovers the same data point under a series of tooltip specs, reporting the +# rendered text for each. Driving one chart through several specs keeps this to +# one browser launch; `sources`/`aliases` are preserved from the built spec +# because the column-name lookup resolves through them. +_FORMAT_PROBE = """ + +""" + + +def _run_format_probe(chart, attribute: str, probe: str, label: str) -> dict: + chromium = find_chromium() + if not chromium: + pytest.skip(f"no chromium available for the {label} probe") + document = probe_document(chart, probe) + with tempfile.TemporaryDirectory() as td: + return run_browser_probe( + chromium, document, Path(td) / "tooltip.html", attribute, label=label + ) + + +def test_browser_time_values_take_the_strftime_path() -> None: + probe = _FORMAT_PROBE.replace("INDEX", str(HOVER_INDEX)).replace("YES1", repr(YES[HOVER_INDEX])) + payload = _run_format_probe(_market_chart(), "data-xy-tipfmt", probe, "tooltip format") + + # A 95-minute window ticks in minutes, so the tooltip reads to the minute. + assert payload["bare"]["rows"] == ["xSep 17, 10:05", "y0.14"], payload["bare"] + assert payload["byColumn"]["rows"][0] == "xSep 17, 2026, 10:05", payload["byColumn"] + assert payload["byChannel"]["rows"][0] == "x2026/09/17 10:05", payload["byChannel"] + # Epoch milliseconds, grouped — the value, not a date. + assert payload["numericOnTime"]["rows"][0].startswith("x1,7"), payload["numericOnTime"] + assert ":" not in payload["numericOnTime"]["rows"][0], payload["numericOnTime"] + # The reported repro, now formatted: label "" collapses to the value alone. + assert payload["fields"]["rows"] == ["Sep 17, 2026, 10:05", "Yes14%"], payload["fields"] + # A listed time field with no format still resolves one. + assert payload["fieldsBare"]["rows"] == ["timeSep 17, 10:05", "yes0.14"], payload["fieldsBare"] + # `format=` with no `fields=` reaches the default rows. + assert payload["formatOnly"]["rows"] == ["x10:05", "y14.0%"], payload["formatOnly"] + # An axis `format=` is the tooltip's default, so both read alike. + assert payload["axisFormat"]["rows"][0] == "x10:05", payload["axisFormat"] + + +def test_browser_time_tooltip_default_follows_the_visible_span() -> None: + probe = _FORMAT_PROBE.replace("INDEX", str(HOVER_INDEX)).replace("YES1", repr(YES[HOVER_INDEX])) + payload = _run_format_probe(_market_chart(), "data-xy-tipfmt", probe, "tooltip span") + + # Each tier is one granularity finer than the axis labels the same span + # produces: a months-wide window hovers to the day, a minutes-wide one to + # the minute (spec §7.4). + assert payload["spanMonths"]["rows"][0] == "xSep 17, 2026", payload["spanMonths"] + assert payload["spanMinutes"]["rows"][0] == "xSep 17, 10:05", payload["spanMinutes"] + assert payload["spanSeconds"]["rows"][0] == "x10:05:00", payload["spanSeconds"] + # Below a second there is no strftime token for milliseconds, so the ISO + # stamp — which carries them — stays the honest answer. + assert payload["spanSubSecond"]["rows"][0].endswith("Z"), payload["spanSubSecond"] + + +_BAND_FORMAT_PROBE = """ + +""" + + +def test_browser_band_title_formats_its_time_coordinate() -> None: + probe = _BAND_FORMAT_PROBE.replace("INDEX", str(HOVER_INDEX)) + payload = _run_format_probe( + _market_chart(mode="x"), "data-xy-bandfmt", probe, "band tooltip format" + ) + + # The band title is the x coordinate, so it takes the same grammar as any + # other time value — including a format keyed by the column name, which the + # title only ever sees as the channel "x". + assert payload["bare"]["title"] == "Sep 17, 10:05", payload["bare"] + assert payload["timeByColumn"]["title"] == "Sep 17, 2026, 10:05", payload["timeByColumn"] + + per_trace = payload["perTrace"] + assert per_trace["title"] == "10:05", per_trace + assert per_trace["rows"] == ["Yes14%", "No0.860"], per_trace + + +_CHANNEL_PROBE = """ + +""" + + +def test_browser_format_keys_follow_the_labels_vocabulary() -> None: + """`format=` takes the same keys `labels=` does: the short channel name or + the author's own column, resolved against the hovered trace.""" + data = { + "month": [1.0, 2.0, 3.0, 4.0], + "revenue": [42000.0, 47000.0, 45000.0, 53000.0], + "growth": [0.04, 0.12, 0.01, 0.18], + } + chart = xy.scatter_chart( + xy.scatter(x="month", y="revenue", color="revenue", size="growth", data=data), + xy.tooltip(), + xy.interaction_config(hover=True), + width=640, + height=360, + ) + probe = _CHANNEL_PROBE.replace("INDEX", "1") + payload = _run_format_probe(chart, "data-xy-chanfmt", probe, "channel format keys") + + # Rows are x, y, colour, size; the colour and size rows take the format. + assert payload["shortNames"][2:] == ["color47000.0", "size0.120"], payload["shortNames"] + assert payload["columnNames"][2:] == ["color47,000", "size12.0%"], payload["columnNames"] From eafec34609f18b18264b0e28ed28bfc0ec0f30ff Mon Sep 17 00:00:00 2001 From: Sriman Date: Tue, 22 Sep 2026 01:19:24 +0000 Subject: [PATCH 02/11] Name the news fragment for its PR --- news/{+tooltip-time-format.feature.md => 521.feature.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename news/{+tooltip-time-format.feature.md => 521.feature.md} (100%) diff --git a/news/+tooltip-time-format.feature.md b/news/521.feature.md similarity index 100% rename from news/+tooltip-time-format.feature.md rename to news/521.feature.md From 0739dbcf66ae454242380c242f216bbd438cceb0 Mon Sep 17 00:00:00 2001 From: Sriman Date: Tue, 22 Sep 2026 14:38:55 +0000 Subject: [PATCH 03/11] Resolve a field's channel per trace, and let a format reach a polar angle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review findings, both reachable from supported charts: - `aliases` is figure-level and keeps only a column's FIRST binding, so a column used as x by one trace and y by another resolved to the wrong channel on the second — and with it the wrong channel-keyed format, the wrong axis format and the wrong visible span. `sources` already carries the per-trace binding; resolve through it and keep `aliases` as the fallback for fields it does not list. - The default polar angle row returned its preformatted text unconditionally, so an accepted `format={"x": ...}` never reached the formatter. It does now. The authored spoke label stays the default, though: it is what makes the row readable, and a format was never a request for radians — only an explicit format for that channel overrides it. Both are pinned by browser probes that fail without the fix (the per-trace one reports `10:05` for the second trace instead of `Sep 17, 2026`). Also from review, precision rather than behavior: the spec said "every tooltip value" while sankey, funnel and categorical-colour rows keep their own text; axis inheritance needed qualifying as strftime-only and position-channel-only in all three of spec, docs and news; the news fragment overstated the ISO fallback, which sub-second spans still take; the docs pairing example built an axis and a tooltip without a chart; and the span test re-ran the whole format probe to assert four keys, so the probe now splices in only the cases its test reads. --- docs/components/tooltips.md | 13 +- js/src/52_tooltip.ts | 31 ++++- news/521.feature.md | 18 +-- spec/api/interaction.md | 17 ++- tests/test_tooltip_time_format.py | 190 +++++++++++++++++++++++++++--- 5 files changed, 234 insertions(+), 35 deletions(-) diff --git a/docs/components/tooltips.md b/docs/components/tooltips.md index f1641704a..76451c96d 100644 --- a/docs/components/tooltips.md +++ b/docs/components/tooltips.md @@ -127,9 +127,9 @@ def tooltip_time_demo(): ~~~ With no `format=` for a time field, the tooltip does not fall back to a raw -timestamp. It uses the axis's own `format=` when the axis has one, so the -tooltip and the tick labels beneath it read alike; otherwise it picks the -pattern the visible span reads best in — `Sep 17, 2026` for a window of months, +timestamp. For a field on the x or y axis it uses that axis's own `format=` +when the axis has a strftime one, so the tooltip and the tick labels beneath it +read alike; otherwise it picks the pattern the visible span reads best in — `Sep 17, 2026` for a window of months, `Sep 17, 10:05` for one of hours or days, `10:05:00` for one of seconds. Zoom in and the tooltip sharpens with the axis. Below a second the ISO timestamp stays, because it is the only form that carries milliseconds. @@ -138,8 +138,11 @@ Because the format is chosen per field, one chart can carry a precise timestamp in the tooltip and short labels on the axis: ~~~python -xy.x_axis(format="%b %d") # axis: Sep 17 -xy.tooltip(format={"time": "%b %d, %Y, %H:%M"}) # tooltip: Sep 17, 2026, 10:05 +xy.line_chart( + xy.line(x="time", y="yes", data=data), + xy.x_axis(format="%b %d"), # axis: Sep 17 + xy.tooltip(format={"time": "%b %d, %Y, %H:%M"}), # tooltip: Sep 17, 2026, 10:05 +) ~~~ `format=` works on its own — without `fields=` or `title=` — in which case it diff --git a/js/src/52_tooltip.ts b/js/src/52_tooltip.ts index e2f747f48..26bbe2368 100644 --- a/js/src/52_tooltip.ts +++ b/js/src/52_tooltip.ts @@ -223,6 +223,20 @@ Object.assign(ChartView.prototype, { return null; }, + // The channel a field is bound to ON THIS TRACE. `aliases` is figure-level + // and `setdefault`-built, so it keeps only the FIRST binding: a column used + // as x by one trace and y by another resolved to the wrong axis on the + // second, and with it the wrong channel-keyed format, the wrong axis format + // and the wrong visible span. `sources` carries the per-trace truth; + // `aliases` stays the fallback for fields it does not list. + _tooltipChannelFor(traceId, field) { + const sources = (this.spec.tooltip && this.spec.tooltip.sources) || {}; + const entries = sources[field]; + if (!Array.isArray(entries)) return null; + const match = entries.find((e) => e && e.trace === traceId); + return (match && match.channel) || null; + }, + // `format=` for a listed field: the author's own key wins, then the channel // it is bound to, so `format={"x": ...}` and `format={"time": ...}` are both // honored however the field was named in `fields=`/`title=`. @@ -435,15 +449,23 @@ Object.assign(ChartView.prototype, { if (row.x !== undefined) { const polar = this._polarTooltipField("x", row.x, row.x_kind); const { label, customized } = this._defaultTooltipLabel("x", "x", labels, aliases); + // A polar angle reads as its authored spoke label ("North"), not as the + // radians behind it, so the polar text stays the default. An explicit + // `format=` is an instruction about THIS channel, though, and overrides + // it — otherwise `format={"x": ...}` was accepted and silently dropped. + const polarFormat = polar + ? this._tooltipChannelFormat(formats, "x", row.trace) + : undefined; // A numeric polar angle only appears when the user asked for the row // by naming it (`labels={"x": ...}`); authored spoke labels always show. if (!polar || !polar.omit || customized) { items.push({ kind: "field", label: polar && !customized ? polar.label : label, - value: polar ? polar.value : this._formatTooltipValue( + value: polar && polarFormat === undefined ? polar.value : this._formatTooltipValue( row.x, row.x_kind, - this._tooltipChannelFormat(formats, "x", row.trace), { channel: "x", row }, + polar ? polarFormat : this._tooltipChannelFormat(formats, "x", row.trace), + { channel: "x", row }, ), }); } @@ -516,7 +538,10 @@ Object.assign(ChartView.prototype, { // value: `_applySharedTooltipFields` copies a source column onto the row // under its own name, so the key is usually the column ("time"), while the // axis is reachable only through the channel it is bound to ("x"). - const channel = aliases[field] || (TOOLTIP_CHANNELS.has(key) ? key : null); + const channel = (TOOLTIP_CHANNELS.has(key) ? key : null) + || this._tooltipChannelFor(row.trace, field) + || aliases[field] + || null; return [row[key], row[`${key}_kind`], channel]; }, diff --git a/news/521.feature.md b/news/521.feature.md index 4f019ccf5..9a7d86a28 100644 --- a/news/521.feature.md +++ b/news/521.feature.md @@ -1,13 +1,15 @@ `xy.tooltip(format=...)` now formats dates and times. A strftime pattern — -`format={"time": "%b %d, %Y, %H:%M"}` — applies to any datetime-typed field, -including one sourced from a data table, which previously ignored the format -and rendered a raw ISO stamp such as `2026-09-17 10:03:05.264Z`. The format is +`format={"time": "%b %d, %Y, %H:%M"}` — applies to any datetime-typed field +the tooltip can reach, including a column sourced from a data table (one bound +to a rendered channel), which previously ignored the format and rendered a raw +ISO stamp such as `2026-09-17 10:03:05.264Z`. The format is resolved from either the author's column name or the channel it is bound to, per hovered trace, so two series binding different columns to `y` keep their own formats and the band title under `mode="x"` takes the format keyed by the -time column. A time value with no `format=` no longer falls back to the ISO -stamp either: it follows the axis's own `format=` when there is one, and -otherwise the pattern the visible span reads best in (`Sep 17, 2026` for a -window of months, `Sep 17, 10:05` for hours or days, `10:05:00` for seconds), -sharpening as you zoom. `format=` also works on its own now, formatting the +time column. A time value with no `format=` mostly stops falling back to +the ISO stamp as well: it follows the axis's own strftime `format=` when there +is one, and otherwise the pattern the visible span reads best in (`Sep 17, +2026` for a window of months, `Sep 17, 10:05` for hours or days, `10:05:00` for +seconds), sharpening as you zoom. Under a second the ISO stamp stays, being the +only form that carries milliseconds. `format=` also works on its own now, formatting the default x/y/color/size rows without needing `fields=` or `title=`. diff --git a/spec/api/interaction.md b/spec/api/interaction.md index 79b94decb..c10a5cfb4 100644 --- a/spec/api/interaction.md +++ b/spec/api/interaction.md @@ -470,8 +470,13 @@ above Page B, the band tooltip, the cursor line, and both active dots. ### 7.4 Value formatting (`xy.tooltip(format=...)`) -One grammar resolves every tooltip value, in both modes and in the default -tooltip (`js/src/52_tooltip.ts`, `_formatTooltipValue`): +One grammar resolves the tooltip values `format=` can address — the default +x/y/colour/size rows, the fields listed in `fields=`, the placeholders in +`title=`, and a band's title and series rows — in both modes +(`js/src/52_tooltip.ts`, `_formatTooltipValue`). Composition-specific readouts +keep their own text and are not `format=`-driven: sankey flow and node totals, +funnel stage rows, and a categorical colour label, which is a category name +rather than a value. - An authored `format=` always wins. A **strftime pattern** (a `%` followed by one of `YmdHMSbB`, the tokens `fmtTimeSpec` replaces) formats a time-kinded @@ -487,8 +492,12 @@ tooltip (`js/src/52_tooltip.ts`, `_formatTooltipValue`): accepted, shipped, and then silently ignored by every path that looks a value up by channel — the band title among them. - A time value with nothing authored on it resolves a pattern rather than - falling through to the ISO stamp: the **axis's own `format=`** first, so a - tooltip and the tick labels under it read alike, then the **visible span**: + falling through to the ISO stamp: the **axis's own strftime `format=`** + first, so a tooltip and the tick labels under it read alike, then the + **visible span**. Only a strftime axis format is inherited — a numeric one on + a time axis is not a pattern this value can be read through — and only a + position channel has an axis to inherit from, so a time value carried by + colour or size takes the span default and then the ISO stamp: | span in view | pattern | | --- | --- | diff --git a/tests/test_tooltip_time_format.py b/tests/test_tooltip_time_format.py index 16ff52aa1..d38969d83 100644 --- a/tests/test_tooltip_time_format.py +++ b/tests/test_tooltip_time_format.py @@ -69,11 +69,13 @@ def test_tooltip_format_ships_for_table_backed_fields() -> None: assert tooltip["aliases"]["time"] == "x" -# Hovers the same data point under a series of tooltip specs, reporting the -# rendered text for each. Driving one chart through several specs keeps this to -# one browser launch; `sources`/`aliases` are preserved from the built spec -# because the column-name lookup resolves through them. -_FORMAT_PROBE = """ +# Shared probe boilerplate: capture the view, wait for its CPU columns, and +# expose `withSpec(patch)` — hover one data point under a tooltip spec and +# report the rendered text. `sources`/`aliases` are preserved from the built +# spec, because the column-name lookup resolves through them. Each probe below +# splices its own cases in at CASES and runs only those, so a test never pays +# for a browser launch it does not assert on. +_PROBE_PRELUDE = """ +""" + +_FORMAT_CASES = """ // No format at all: the span-aware default, not an ISO stamp. out.bare = withSpec({ format: {} }); // The authored strftime pattern, keyed by the column name... @@ -138,7 +150,9 @@ def test_tooltip_format_ships_for_table_backed_fields() -> None: view.axes.x.format = "%H:%M"; out.axisFormat = withSpec({ format: {} }); view.axes.x.format = axisFormat; +""" +_SPAN_CASES = """ // The default follows the visible span: zoom the x range and re-hover. const span = (ms) => { const centre = at(INDEX); @@ -149,16 +163,19 @@ def test_tooltip_format_ships_for_table_backed_fields() -> None: out.spanMinutes = span(90 * 6e4); out.spanSeconds = span(20 * 1e3); out.spanSubSecond = span(400); - - document.body.setAttribute("data-xy-tipfmt", JSON.stringify(out)); - } catch (err) { - document.body.setAttribute("data-xy-tipfmt-error", String((err && err.stack) || err)); - } -})(); - """ +def _market_probe(cases: str, attribute: str) -> str: + """The shared prelude with `cases` spliced in, bound to `attribute`.""" + return ( + _PROBE_PRELUDE.replace("CASES", cases) + .replace("ATTRIBUTE", f'"{attribute}"') + .replace("INDEX", str(HOVER_INDEX)) + .replace("YES1", repr(YES[HOVER_INDEX])) + ) + + def _run_format_probe(chart, attribute: str, probe: str, label: str) -> dict: chromium = find_chromium() if not chromium: @@ -171,7 +188,7 @@ def _run_format_probe(chart, attribute: str, probe: str, label: str) -> dict: def test_browser_time_values_take_the_strftime_path() -> None: - probe = _FORMAT_PROBE.replace("INDEX", str(HOVER_INDEX)).replace("YES1", repr(YES[HOVER_INDEX])) + probe = _market_probe(_FORMAT_CASES, "data-xy-tipfmt") payload = _run_format_probe(_market_chart(), "data-xy-tipfmt", probe, "tooltip format") # A 95-minute window ticks in minutes, so the tooltip reads to the minute. @@ -192,8 +209,8 @@ def test_browser_time_values_take_the_strftime_path() -> None: def test_browser_time_tooltip_default_follows_the_visible_span() -> None: - probe = _FORMAT_PROBE.replace("INDEX", str(HOVER_INDEX)).replace("YES1", repr(YES[HOVER_INDEX])) - payload = _run_format_probe(_market_chart(), "data-xy-tipfmt", probe, "tooltip span") + probe = _market_probe(_SPAN_CASES, "data-xy-tipspan") + payload = _run_format_probe(_market_chart(), "data-xy-tipspan", probe, "tooltip span") # Each tier is one granularity finer than the axis labels the same span # produces: a months-wide window hovers to the day, a minutes-wide one to @@ -334,3 +351,146 @@ def test_browser_format_keys_follow_the_labels_vocabulary() -> None: # Rows are x, y, colour, size; the colour and size rows take the format. assert payload["shortNames"][2:] == ["color47000.0", "size0.120"], payload["shortNames"] assert payload["columnNames"][2:] == ["color47,000", "size12.0%"], payload["columnNames"] + + +_PER_TRACE_PROBE = """ + +""" + + +def test_browser_channel_resolves_per_trace_not_per_figure() -> None: + """One column bound to x on one trace and y on another formats by the + channel the HOVERED trace binds it to. `aliases` keeps only the first + binding, so resolving through it formatted the second trace as if it were + the first — the wrong format, and with it the wrong axis and span.""" + data = { + "t": TIMES[:6], + "v": [float(i) for i in range(6)], + "w": [2.0 * i for i in range(6)], + } + chart = xy.line_chart( + xy.line(x="t", y="v", data=data, name="across"), + xy.line(x="w", y="t", data=data, name="down"), + xy.tooltip(fields=["t"], format={"x": "%H:%M", "y": "%b %d, %Y"}), + xy.interaction_config(hover=True), + width=640, + height=360, + ) + probe = _PER_TRACE_PROBE.replace("INDEX", str(HOVER_INDEX)) + payload = _run_format_probe(chart, "data-xy-pertrace", probe, "per-trace channel") + + assert payload["alias"] == "x", payload + assert payload["sources"] == [ + {"trace": 0, "channel": "x"}, + {"trace": 1, "channel": "y"}, + ], payload + # Same column, same tooltip, two traces: x on one, y on the other. + assert payload["across"] == ["t10:05"], payload["across"] + assert payload["down"] == ["tSep 17, 2026"], payload["down"] + + +_POLAR_PROBE = """ + +""" + + +def test_browser_polar_angle_keeps_its_spoke_label_until_a_format_says_otherwise() -> None: + """An authored `format=` reaches the polar angle row, which used to drop it. + The spoke label stays the default, though: it is the reason the row is + readable, and a format was never what asked for radians.""" + import math + + import numpy as np + + theta = np.linspace(0.0, 2.0 * math.pi, 24) + chart = xy.polar_chart( + xy.line(theta, 1.0 + 0.5 * np.sin(5.0 * theta), color="#2563eb", width=2.0), + xy.theta_axis(tick_values=[0.0, math.pi], tick_labels=["north", "south"]), + xy.tooltip(), + xy.interaction_config(hover=True), + width=520, + height=520, + ) + probe = _POLAR_PROBE + payload = _run_format_probe(chart, "data-xy-polarfmt", probe, "polar angle format") + + assert payload["labelled"][0] == "Directionnorth", payload["labelled"] + # Now a number rather than the label. The hovered angle arrives f32-decoded + # (§4/§16), so zero can come back as a tiny negative — the point is the + # grammar it is rendered in, not the last bit. + angle = payload["formatted"][0] + assert angle.startswith("Direction"), angle + assert abs(float(angle.removeprefix("Direction"))) < 1e-3, angle From adb712da8af3736733a15394babfa7c273e606c8 Mon Sep 17 00:00:00 2001 From: Sriman Date: Tue, 22 Sep 2026 14:48:45 +0000 Subject: [PATCH 04/11] Document the tooltip-format tests Each browser probe test now states what it pins rather than leaving it to the assertions' inline comments, which is how the rest of tests/ reads. --- tests/test_tooltip_time_format.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/test_tooltip_time_format.py b/tests/test_tooltip_time_format.py index d38969d83..1ca7a1d9b 100644 --- a/tests/test_tooltip_time_format.py +++ b/tests/test_tooltip_time_format.py @@ -39,6 +39,8 @@ def _market_chart(**tooltip): + """A two-series time-axis chart in the shape the report came from: a + datetime column bound to x on both traces, two probability columns on y.""" return xy.line_chart( xy.step("time", "yes", data=DATA, where="post", name="Yes", color="#8884d8"), xy.step("time", "no", data=DATA, where="post", name="No", color="#82ca9d"), @@ -177,6 +179,8 @@ def _market_probe(cases: str, attribute: str) -> str: def _run_format_probe(chart, attribute: str, probe: str, label: str) -> dict: + """Render `chart` with `probe` spliced in and return the JSON the probe + wrote to `attribute`, skipping only when no browser can be spawned.""" chromium = find_chromium() if not chromium: pytest.skip(f"no chromium available for the {label} probe") @@ -188,6 +192,10 @@ def _run_format_probe(chart, attribute: str, probe: str, label: str) -> dict: def test_browser_time_values_take_the_strftime_path() -> None: + """Every way a format can be authored reaches a time value, and every way + it cannot leaves the value alone: the column name, the channel key, a + listed field, `format=` by itself, the axis's own pattern — and a numeric + spec on a time value still means "as a number".""" probe = _market_probe(_FORMAT_CASES, "data-xy-tipfmt") payload = _run_format_probe(_market_chart(), "data-xy-tipfmt", probe, "tooltip format") @@ -209,6 +217,8 @@ def test_browser_time_values_take_the_strftime_path() -> None: def test_browser_time_tooltip_default_follows_the_visible_span() -> None: + """With nothing authored, a time value reads at the granularity the window + in view calls for, sharpening as the span narrows.""" probe = _market_probe(_SPAN_CASES, "data-xy-tipspan") payload = _run_format_probe(_market_chart(), "data-xy-tipspan", probe, "tooltip span") @@ -271,6 +281,9 @@ def test_browser_time_tooltip_default_follows_the_visible_span() -> None: def test_browser_band_title_formats_its_time_coordinate() -> None: + """A band title is a value like any other: it takes the span default, an + authored format keyed by the time column — which it only ever sees as the + channel "x" — and leaves each series' own numeric format intact.""" probe = _BAND_FORMAT_PROBE.replace("INDEX", str(HOVER_INDEX)) payload = _run_format_probe( _market_chart(mode="x"), "data-xy-bandfmt", probe, "band tooltip format" From 696faf645e539b1783cdcca6bc201ab125ce3fea Mon Sep 17 00:00:00 2001 From: Sriman Date: Tue, 22 Sep 2026 14:50:29 +0000 Subject: [PATCH 05/11] Honor XY_REQUIRE_BROWSER, and match the span tiers to the spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The probe helper skipped on a missing browser before reaching `run_browser_probe`, which is where XY_REQUIRE_BROWSER turns that skip into a failure — so under the flag these tests could still pass by absence, the one thing the flag exists to prevent. Checked at the skip instead. Docs and news described the span ladder in calendar units that did not name the minute tier; both now state the thresholds the spec does. The standalone `format=` sentence also implied it reaches every default row, so it now says what it addresses: values, not a categorical colour's category name or the readouts a composition defines for itself. --- docs/components/tooltips.md | 14 +++++++++----- news/521.feature.md | 4 ++-- tests/test_tooltip_time_format.py | 6 ++++++ 3 files changed, 17 insertions(+), 7 deletions(-) diff --git a/docs/components/tooltips.md b/docs/components/tooltips.md index 76451c96d..cf2579977 100644 --- a/docs/components/tooltips.md +++ b/docs/components/tooltips.md @@ -129,10 +129,11 @@ def tooltip_time_demo(): With no `format=` for a time field, the tooltip does not fall back to a raw timestamp. For a field on the x or y axis it uses that axis's own `format=` when the axis has a strftime one, so the tooltip and the tick labels beneath it -read alike; otherwise it picks the pattern the visible span reads best in — `Sep 17, 2026` for a window of months, -`Sep 17, 10:05` for one of hours or days, `10:05:00` for one of seconds. Zoom -in and the tooltip sharpens with the axis. Below a second the ISO timestamp -stays, because it is the only form that carries milliseconds. +read alike; otherwise it picks the pattern the visible span reads best in — `Sep 17, 2026` +for a span of 28 days or more, `Sep 17, 10:05` from a minute up to that, and +`10:05:00` from a second up to a minute. Zoom in and the tooltip sharpens with +the axis. Below a second the ISO timestamp stays, because it is the only form +that carries milliseconds. Because the format is chosen per field, one chart can carry a precise timestamp in the tooltip and short labels on the axis: @@ -146,7 +147,10 @@ xy.line_chart( ~~~ `format=` works on its own — without `fields=` or `title=` — in which case it -formats the default x/y/color/size rows in place. +formats the default x/y/color/size rows in place. It addresses values: a +categorical colour row is a category name rather than a value, and the readouts +compositions define for themselves (sankey flows and node totals, funnel stages) +carry their own text, so neither takes a format. ### Title Templates Across Multiple Series diff --git a/news/521.feature.md b/news/521.feature.md index 9a7d86a28..8d3f3fbce 100644 --- a/news/521.feature.md +++ b/news/521.feature.md @@ -9,7 +9,7 @@ own formats and the band title under `mode="x"` takes the format keyed by the time column. A time value with no `format=` mostly stops falling back to the ISO stamp as well: it follows the axis's own strftime `format=` when there is one, and otherwise the pattern the visible span reads best in (`Sep 17, -2026` for a window of months, `Sep 17, 10:05` for hours or days, `10:05:00` for -seconds), sharpening as you zoom. Under a second the ISO stamp stays, being the +2026` for a span of 28 days or more, `Sep 17, 10:05` from a minute up to that, +`10:05:00` from a second up to a minute), sharpening as you zoom. Under a second the ISO stamp stays, being the only form that carries milliseconds. `format=` also works on its own now, formatting the default x/y/color/size rows without needing `fields=` or `title=`. diff --git a/tests/test_tooltip_time_format.py b/tests/test_tooltip_time_format.py index 1ca7a1d9b..e72fa850f 100644 --- a/tests/test_tooltip_time_format.py +++ b/tests/test_tooltip_time_format.py @@ -15,6 +15,7 @@ from __future__ import annotations import datetime as dt +import os import sys import tempfile from pathlib import Path @@ -183,6 +184,11 @@ def _run_format_probe(chart, attribute: str, probe: str, label: str) -> dict: wrote to `attribute`, skipping only when no browser can be spawned.""" chromium = find_chromium() if not chromium: + # `run_browser_probe` turns an unlaunchable browser into a failure under + # XY_REQUIRE_BROWSER so CI cannot pass by absence. Skipping before that + # call would slip past the guard, so it is honored here too. + if os.environ.get("XY_REQUIRE_BROWSER"): + pytest.fail(f"{label}: XY_REQUIRE_BROWSER is set but no chromium was found") pytest.skip(f"no chromium available for the {label} probe") document = probe_document(chart, probe) with tempfile.TemporaryDirectory() as td: From 6049adfb9b7874f13d473b4e98349fde75be138e Mon Sep 17 00:00:00 2001 From: Sriman Date: Tue, 22 Sep 2026 14:54:07 +0000 Subject: [PATCH 06/11] Name the durations the span probe zooms to The probe's window sizes were bare millisecond arithmetic; each now says what it represents, per the repository's rule for time-based calculations. --- tests/test_tooltip_time_format.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_tooltip_time_format.py b/tests/test_tooltip_time_format.py index e72fa850f..ded0adc05 100644 --- a/tests/test_tooltip_time_format.py +++ b/tests/test_tooltip_time_format.py @@ -162,10 +162,10 @@ def test_tooltip_format_ships_for_table_backed_fields() -> None: view.view.ranges.x = [centre - ms / 2, centre + ms / 2]; return withSpec({ format: {} }); }; - out.spanMonths = span(120 * 864e5); - out.spanMinutes = span(90 * 6e4); - out.spanSeconds = span(20 * 1e3); - out.spanSubSecond = span(400); + out.spanMonths = span(120 * 864e5); // 120 days in milliseconds. + out.spanMinutes = span(90 * 6e4); // 90 minutes in milliseconds. + out.spanSeconds = span(20 * 1e3); // 20 seconds in milliseconds. + out.spanSubSecond = span(400); // 400 milliseconds. """ From c53c2aa91917528314e3f6e8648fc40314223c2a Mon Sep 17 00:00:00 2001 From: Sriman Date: Tue, 22 Sep 2026 14:59:51 +0000 Subject: [PATCH 07/11] Add the tooltip time-format before/after capture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Headless Chromium renders the docs demo twice — once against this PR's base client, once against this branch's — hovering the same point through the view's own hover API, so the image shows the shipped path rather than a mock. Before: `2026-09-19 22:00:00Z` / `Yes: 0.64`. After: `Sep 19, 2026, 22:00` / `Yes: 64%`. --- .../assets/tooltip-time-format-before-after.png | Bin 0 -> 55508 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 spec/assets/tooltip-time-format-before-after.png diff --git a/spec/assets/tooltip-time-format-before-after.png b/spec/assets/tooltip-time-format-before-after.png new file mode 100644 index 0000000000000000000000000000000000000000..50a3e712402147c14cb085b1a15b41bbae70cbb4 GIT binary patch literal 55508 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 literal 0 HcmV?d00001 From eea20ef0cc9a5f75422a0935ea93536d91adb733 Mon Sep 17 00:00:00 2001 From: Sriman Date: Tue, 22 Sep 2026 15:20:28 +0000 Subject: [PATCH 08/11] Rewrap the span prose, fix a dropped relative pronoun, correct a comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The span paragraph ran past the file's prose wrap; "the readouts compositions define for themselves" needs its "that"; and the prelude's comment claimed every probe below splices into it, when the per-trace and polar probes stand alone — they hover two traces and re-spec between hovers respectively, so folding them in would parameterize the wait, the trace and the hover for one caller each. The comment now says which probes splice and why the others do not. --- docs/components/tooltips.md | 14 +++++++------- tests/test_tooltip_time_format.py | 11 ++++++++--- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/docs/components/tooltips.md b/docs/components/tooltips.md index cf2579977..ffaefe732 100644 --- a/docs/components/tooltips.md +++ b/docs/components/tooltips.md @@ -129,11 +129,11 @@ def tooltip_time_demo(): With no `format=` for a time field, the tooltip does not fall back to a raw timestamp. For a field on the x or y axis it uses that axis's own `format=` when the axis has a strftime one, so the tooltip and the tick labels beneath it -read alike; otherwise it picks the pattern the visible span reads best in — `Sep 17, 2026` -for a span of 28 days or more, `Sep 17, 10:05` from a minute up to that, and -`10:05:00` from a second up to a minute. Zoom in and the tooltip sharpens with -the axis. Below a second the ISO timestamp stays, because it is the only form -that carries milliseconds. +read alike; otherwise it picks the pattern the visible span reads best in — +`Sep 17, 2026` for a span of 28 days or more, `Sep 17, 10:05` from a minute up +to that, and `10:05:00` from a second up to a minute. Zoom in and the tooltip +sharpens with the axis. Below a second the ISO timestamp stays, because it is +the only form that carries milliseconds. Because the format is chosen per field, one chart can carry a precise timestamp in the tooltip and short labels on the axis: @@ -149,8 +149,8 @@ xy.line_chart( `format=` works on its own — without `fields=` or `title=` — in which case it formats the default x/y/color/size rows in place. It addresses values: a categorical colour row is a category name rather than a value, and the readouts -compositions define for themselves (sankey flows and node totals, funnel stages) -carry their own text, so neither takes a format. +that compositions define for themselves (sankey flows and node totals, funnel +stages) carry their own text, so neither takes a format. ### Title Templates Across Multiple Series diff --git a/tests/test_tooltip_time_format.py b/tests/test_tooltip_time_format.py index ded0adc05..9a120b164 100644 --- a/tests/test_tooltip_time_format.py +++ b/tests/test_tooltip_time_format.py @@ -75,9 +75,14 @@ def test_tooltip_format_ships_for_table_backed_fields() -> None: # Shared probe boilerplate: capture the view, wait for its CPU columns, and # expose `withSpec(patch)` — hover one data point under a tooltip spec and # report the rendered text. `sources`/`aliases` are preserved from the built -# spec, because the column-name lookup resolves through them. Each probe below -# splices its own cases in at CASES and runs only those, so a test never pays -# for a browser launch it does not assert on. +# spec, because the column-name lookup resolves through them. `_FORMAT_CASES` +# and `_SPAN_CASES` splice in at CASES and run only those, so a test never pays +# for a browser launch it does not assert on. The two probes below this file's +# market chart — `_PER_TRACE_PROBE` and `_POLAR_PROBE` — stand alone instead: +# one hovers a point on each of two traces, the other mutates the tooltip spec +# between hovers of one, and folding either into the prelude would mean +# parameterizing the wait, the trace and the hover for the benefit of one +# caller each. _PROBE_PRELUDE = """ +""" + + +def _sampled_chart(step: dt.timedelta, count: int = 40): + """One series sampled every `step` from `START`, for the spacing-sensitive + probes: the gap between neighbours is the whole fixture.""" + times = [START + step * i for i in range(count)] + values = [0.10 + 0.02 * i for i in range(count)] + return xy.line_chart( + xy.line("time", "value", data={"time": times, "value": values}, name="S"), + xy.tooltip(), + xy.interaction_config(hover=True), + width=640, + height=360, + ) + + +def _adjacent_probe(span_ms: float, attribute: str) -> str: + """`_ADJACENT_PROBE` bound to a window width and an output attribute.""" + return ( + _ADJACENT_PROBE.replace("ATTRIBUTE", f'"{attribute}"') + .replace("INDEX", str(HOVER_INDEX)) + .replace("SPAN", repr(float(span_ms))) + ) + + def _market_probe(cases: str, attribute: str) -> str: """The shared prelude with `cases` spliced in, bound to `attribute`.""" return ( @@ -233,10 +298,11 @@ def test_browser_time_tooltip_default_follows_the_visible_span() -> None: probe = _market_probe(_SPAN_CASES, "data-xy-tipspan") payload = _run_format_probe(_market_chart(), "data-xy-tipspan", probe, "tooltip span") - # Each tier is one granularity finer than the axis labels the same span - # produces: a months-wide window hovers to the day, a minutes-wide one to - # the minute (spec §7.4). - assert payload["spanMonths"]["rows"][0] == "xSep 17, 2026", payload["spanMonths"] + # The span picks the tier, and the series' own spacing is the floor it may + # not coarsen past (spec §7.4). These samples are five minutes apart, so + # even a months-wide window keeps the clock time: dropping it would print + # one string for every point of the hovered hour. + assert payload["spanMonths"]["rows"][0] == "xSep 17, 10:05", payload["spanMonths"] assert payload["spanMinutes"]["rows"][0] == "xSep 17, 10:05", payload["spanMinutes"] assert payload["spanSeconds"]["rows"][0] == "x10:05:00", payload["spanSeconds"] # Below a second there is no strftime token for milliseconds, so the ISO @@ -244,6 +310,36 @@ def test_browser_time_tooltip_default_follows_the_visible_span() -> None: assert payload["spanSubSecond"]["rows"][0].endswith("Z"), payload["spanSubSecond"] +def test_browser_time_tooltip_default_separates_adjacent_samples() -> None: + """The span sets how much context the pattern carries; the sampling sets + how much precision it keeps. A tier read off the span alone printed the + same string for two adjacent points whenever the samples sat inside one of + its units — ten-second samples share a minute, hourly ones share a date — + and a tooltip that cannot tell neighbours apart names no point at all.""" + seconds = _run_format_probe( + _sampled_chart(dt.timedelta(seconds=10)), + "data-xy-tipadj", + _adjacent_probe(3 * 60 * 1000, "data-xy-tipadj"), + "ten-second samples", + ) + # A three-minute window reads to the minute on its own; these points are + # ten seconds apart, so the tier refines until it can separate them. + assert seconds["first"][0] == "x10:00:10", seconds + assert seconds["second"][0] == "x10:00:20", seconds + + days = _run_format_probe( + _sampled_chart(dt.timedelta(days=1)), + "data-xy-tipadj", + _adjacent_probe(40 * 864e5, "data-xy-tipadj"), + "daily samples", + ) + # The refinement stops as soon as the tier resolves the data: daily samples + # already differ in date, so a month-wide window is not given a clock it + # has no use for. + assert days["first"][0] == "xSep 18, 2026", days + assert days["second"][0] == "xSep 19, 2026", days + + _BAND_FORMAT_PROBE = """