diff --git a/docs/components/tooltips.md b/docs/components/tooltips.md
index 3ff029ff..b21f956b 100644
--- a/docs/components/tooltips.md
+++ b/docs/components/tooltips.md
@@ -84,14 +84,90 @@ 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. 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.
+
+How closely your data is sampled sharpens that clock: a pattern the chart
+picks for you is never coarser than the gap between the hovered point and the
+one beside it, so neighbouring points always read differently. Ten-second
+readings over a three-minute window read `Sep 17, 10:00:10`, not the
+`Sep 17, 10:00` the span alone would give them and that every point of that
+minute would share. The date the window called for stays — precision is added
+to it, never swapped for it, so a per-second series two months wide reads
+`Sep 17, 2026, 10:00:10`. Sampled finer than a second, there is no pattern
+left to sharpen to, so the ISO timestamp comes back whatever the window —
+500 ms readings three minutes wide read the full stamp, not `Sep 17, 10:00`.
+
+A pattern you wrote yourself is used exactly as written, including one the
+tooltip inherits from the axis — that is the point of the inheritance, so the
+tooltip and the ticks beneath it agree. If `xy.x_axis(format="%H:%M")` is too
+coarse to tell your points apart, write the seconds in.
+
+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.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
+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
+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
One tooltip configuration serves every mark in the chart: the braced `{day}`
@@ -241,6 +317,16 @@ 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, never coarser than your data's
+own sampling. 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 b1809b8e..cf0dfcbb 100644
--- a/js/src/30_ticks.ts
+++ b/js/src/30_ticks.ts
@@ -234,7 +234,67 @@ 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.
+// Two independent questions, which one ladder of whole patterns could not keep
+// apart:
+//
+// How much CONTEXT — whether the year, the date, or neither belongs on the
+// value — is the span's to answer. A month-wide window wants the year, a
+// three-minute one does not.
+//
+// How much PRECISION is the span's answer FLOORED by the data's own spacing. 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 cannot know, because it
+// says nothing about how close the samples are. Ten-second samples in a
+// three-minute window share a minute, and hourly samples over a month share a
+// date.
+//
+// Composing the two is what keeps them from trading off: refining a ladder of
+// patterns instead would have bought the precision by dropping the date, so
+// per-second samples in a month-wide window read `10:00:10` and never said
+// which day.
+const SPAN_TIERS = [
+ { min: 28 * MS.d, date: "%b %d, %Y", precision: 0 },
+ { min: MS.m, date: "%b %d", precision: 1 },
+ { min: MS.s, date: null, precision: 2 },
+];
+// Indexed by precision: the clock a value needs to separate it from the next.
+// Past the end is finer than any strftime token, so the caller keeps the ISO
+// stamp, which is the only form carrying milliseconds.
+const TIME_PARTS = [null, "%H:%M", "%H:%M:%S"];
+
+// The coarsest clock that still tells apart two samples `gap` ms apart. The
+// comparison is >=, so a gap that equals a unit is separated by it: points a
+// day apart differ in date, and a month-wide window of them is not given a
+// clock it has no use for.
+function gapPrecision(gap) {
+ if (gap >= MS.d) return 0;
+ if (gap >= MS.m) return 1;
+ if (gap >= MS.s) return 2;
+ return TIME_PARTS.length;
+}
+
+export function defaultTimeFormat(span, resolution) {
+ if (!Number.isFinite(span) || span <= 0) return null;
+ const tier = SPAN_TIERS.find((t) => span >= t.min);
+ if (!tier) return null;
+ const precision = Math.max(
+ tier.precision,
+ Number.isFinite(resolution) && resolution > 0 ? gapPrecision(resolution) : 0,
+ );
+ if (precision >= TIME_PARTS.length) return null;
+ return [tier.date, TIME_PARTS[precision]].filter(Boolean).join(", ") || 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 963b7845..86e522d3 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,57 @@ 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;
+ },
+
+ // 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=`.
+ _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 +371,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)}` });
@@ -382,13 +449,24 @@ 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 : fmtValue(row.x, row.x_kind),
+ value: polar && polarFormat === undefined ? polar.value : this._formatTooltipValue(
+ row.x, row.x_kind,
+ polar ? polarFormat : this._tooltipChannelFormat(formats, "x", row.trace),
+ { channel: "x", row },
+ ),
});
}
}
@@ -398,14 +476,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 +507,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 +533,93 @@ 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`]];
+ // 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 = (TOOLTIP_CHANNELS.has(key) ? key : null)
+ || this._tooltipChannelFor(row.trace, field)
+ || aliases[field]
+ || 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 = this._tooltipContextTrace(context);
+ return this._axis(channel === "x" ? (g && g.xAxis) : (g && g.yAxis));
+ },
+
+ // The gpu trace a tooltip value came from: named outright by a band item,
+ // else looked up from the row's trace id.
+ _tooltipContextTrace(context) {
+ return context.g || (context.row
+ ? this.gpuTraces.find((t) => t.trace.id === context.row.trace)
+ : null);
+ },
+
+ // How finely the hovered series is actually sampled, in ms, so the default
+ // pattern can keep that precision (`defaultTimeFormat`). The gap to the
+ // hovered point's own neighbours is what decides whether its tooltip reads
+ // differently from theirs, and it is O(1); a row with no index to hand falls
+ // back to the column's mean spacing, which is exact for a regular series.
+ _tooltipTimeResolution(context) {
+ const channel = context.channel;
+ const g = this._tooltipContextTrace(context);
+ const cpu = g && g._cpu;
+ const column = cpu && (channel === "x" ? cpu.x : cpu.y);
+ if (!column || column.length < 2) return null;
+ const meta = channel === "x" ? cpu.xMeta || g.xMeta : cpu.yMeta || g.yMeta;
+ const at = (i) => this._decodeValue(column, meta, i);
+ const idx = context.row && Number.isInteger(context.row.index) ? context.row.index : -1;
+ if (idx >= 0 && idx < column.length) {
+ let gap = Infinity;
+ if (idx > 0) gap = Math.min(gap, Math.abs(at(idx) - at(idx - 1)));
+ if (idx + 1 < column.length) gap = Math.min(gap, Math.abs(at(idx + 1) - at(idx)));
+ if (Number.isFinite(gap) && gap > 0) return gap;
+ }
+ // No index to hand (a band title names a slot, not a row): the mean
+ // spacing, which is the exact gap for the regular series this matters for.
+ const total = Math.abs(at(column.length - 1) - at(0));
+ return total > 0 ? total / (column.length - 1) : null;
+ },
+
+ // 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 default resolved from the span in
+ // view and the series' own sampling (§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), this._tooltipTimeResolution(context));
},
- _formatTooltipValue(value, kind, format) {
+ // 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 +629,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) {
@@ -656,8 +840,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
@@ -666,9 +853,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, row: first },
+ );
} 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, row: first },
+ );
}
if (title) items.push({ kind: "title", value: title });
// `fields=["x"]` in `mode="x"` asks for the band coordinate and nothing
@@ -690,14 +884,21 @@ Object.assign(ChartView.prototype, {
if (fields) {
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, row },
+ );
})
.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, row },
+ );
}
items.push({ kind: "series", label: name, value, color: this._seriesColorCss(g) });
});
diff --git a/news/521.feature.md b/news/521.feature.md
new file mode 100644
index 00000000..ede16b06
--- /dev/null
+++ b/news/521.feature.md
@@ -0,0 +1,19 @@
+`xy.tooltip(format=...)` now formats dates and times. A strftime pattern —
+`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=` 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 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. Your data's
+own spacing sharpens it further where the window alone would be too coarse to
+tell neighbouring points apart, without ever dropping the date the window
+called for: ten-second readings three minutes wide read `Sep 17, 10:00:10`,
+and a per-second series two months wide reads `Sep 17, 2026, 10:00:10`. 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 a02fd490..4c2093f4 100644
--- a/spec/api/interaction.md
+++ b/spec/api/interaction.md
@@ -475,6 +475,92 @@ 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 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
+ 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 strftime `format=`**
+ first, so a tooltip and the tick labels under it read alike, then a default
+ resolved from the **visible span** and the hovered series' **own sampling**.
+ 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. Colour and size have none, and never reach this
+ path at all: their values ship with no kind, so they are read as numbers and
+ a `format=` on them is the numeric spec.
+
+ The pattern is **composed** from two independent answers, never traded off
+ one against the other. The span answers how much CONTEXT the value carries
+ — whether the year, the date, or neither belongs on it — and sets a baseline
+ clock:
+
+ | span in view | date part | clock |
+ | --- | --- | --- |
+ | ≥ 28 d | `%b %d, %Y` | none |
+ | ≥ 1 min | `%b %d` | `%H:%M` |
+ | ≥ 1 s | none | `%H:%M:%S` |
+ | < 1 s | — | none — the ISO stamp stays, as it is the only form carrying ms |
+
+ The hovered series' sampling then answers how much PRECISION the clock must
+ keep, and can only sharpen it: the clock is the finer of the span's and the
+ coarsest unit that still separates samples a `resolution` apart — `%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. `resolution` is the gap
+ between the hovered point and its neighbours (`_tooltipTimeResolution`,
+ `js/src/52_tooltip.ts`). Every call site carries the hovered row, a band's
+ title and series rows included, so that gap is normally the row's own; a
+ context that reaches the formatter without a row index falls back to the
+ column's mean spacing, exact for the regular series this matters for.
+
+ A tooltip names ONE point, and a pattern that prints the same string for a
+ point and the one beside it names none: ten-second samples in a three-minute
+ window share a minute, hourly samples in a month-wide one share a date, and
+ the span alone cannot know it, because a span says nothing about how close
+ the samples are. The unit comparison is `>=`, so a gap that equals a unit is
+ separated by it — daily samples already differ in date, and a month-wide
+ window of them is not given a clock it has no use for.
+
+ Composing the two is what keeps them independent. Resolving instead by
+ walking down a ladder of whole patterns buys the precision by dropping the
+ date: per-second samples in a month-wide window land on `%H:%M:%S` and name
+ no day at all. Composed, they read `%b %d, %Y, %H:%M:%S`.
+
+ The floor is the resolved default's alone. An authored pattern is used as
+ written, and so is one inherited from the axis — that inheritance exists so
+ a tooltip and the ticks under it read alike, which a silent sharpening would
+ undo. `x_axis(format="%H:%M")` over ten-second samples therefore does
+ collapse neighbouring points, and the answer is to author `%H:%M:%S`.
+
+ 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/spec/assets/tooltip-time-format-before-after.png b/spec/assets/tooltip-time-format-before-after.png
new file mode 100644
index 00000000..50a3e712
Binary files /dev/null and b/spec/assets/tooltip-time-format-before-after.png differ
diff --git a/tests/test_tooltip_time_format.py b/tests/test_tooltip_time_format.py
new file mode 100644
index 00000000..8bb7778d
--- /dev/null
+++ b/tests/test_tooltip_time_format.py
@@ -0,0 +1,638 @@
+"""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, never coarser than the series' own
+sampling — 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 os
+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):
+ """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"),
+ 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"
+
+
+# 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. `_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 = """
+
+"""
+
+_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...
+ out.byColumn = withSpec({ format: { time: "%b %d, %Y, %H:%M" } });
+ // ...and by the channel the column is bound to.
+ out.byChannel = withSpec({ format: { x: "%Y/%m/%d %H:%M" } });
+ // A numeric spec on a time value still means "as a number" — `%` alone is
+ // the number grammar's percent suffix, not a strftime token.
+ out.numericOnTime = withSpec({ format: { time: ",.0f" } });
+ // The reported repro: a custom field list with labels.
+ out.fields = withSpec({
+ fields: ["time", "yes"],
+ format: { time: "%b %d, %Y, %H:%M", yes: ".0%" },
+ labels: { time: "", yes: "Yes" },
+ });
+ // A listed field with no format of its own: the column reaches the row
+ // under its OWN name (`_applySharedTooltipFields`), so the channel behind
+ // it is the only route to an axis and its span.
+ out.fieldsBare = withSpec({ fields: ["time", "yes"], format: {} });
+ // `format=` alone, with no fields and no title, reaches the default rows.
+ out.formatOnly = withSpec({ format: { time: "%H:%M", yes: ".1%" } });
+ // With nothing authored on the tooltip, the axis's own format wins.
+ 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);
+ view.view.ranges.x = [centre - ms / 2, centre + ms / 2];
+ return withSpec({ format: {} });
+ };
+ 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.
+"""
+
+
+# The adjacent-points probe stands apart from the market prelude: it hovers two
+# points rather than one and reads the x value off each, which is the whole
+# question it asks — whether the default pattern tells neighbours apart.
+_ADJACENT_PROBE = """
+
+"""
+
+
+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 (
+ _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:
+ """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:
+ # `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:
+ return run_browser_probe(
+ chromium, document, Path(td) / "tooltip.html", attribute, label=label
+ )
+
+
+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")
+
+ # 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:
+ """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")
+
+ # The span picks the date context, and the series' own spacing floors the
+ # clock (spec §7.4). These samples are five minutes apart, so a months-wide
+ # window keeps both: the year, because the window is that wide, and the
+ # time, because dropping it would print one string for every point of the
+ # hovered hour.
+ assert payload["spanMonths"]["rows"][0] == "xSep 17, 2026, 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
+ # stamp — which carries them — stays the honest answer.
+ 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 clock sharpens until it can separate them. The
+ # date the window called for is untouched by that — the two are composed,
+ # not traded off.
+ assert seconds["first"][0] == "xSep 17, 10:00:10", seconds
+ assert seconds["second"][0] == "xSep 17, 10: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 sharpening stops as soon as the pattern 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
+
+
+def test_browser_time_tooltip_default_keeps_the_date_a_wide_window_calls_for() -> None:
+ """Precision is never bought with context. Resolving a one-second series
+ by walking DOWN a ladder of whole patterns reaches `%H:%M:%S`, which names
+ no day at all — so a point two months out read `10:00:10` and the reader
+ could not tell which of sixty days it was on. The date the span calls for
+ and the clock the sampling calls for are composed instead."""
+ payload = _run_format_probe(
+ _sampled_chart(dt.timedelta(seconds=1)),
+ "data-xy-tipadj",
+ # Forty days of window over forty seconds of data: every point lands in
+ # the same pixel, so which one is hovered is not the question here —
+ # the pattern it is read through is.
+ _adjacent_probe(40 * 864e5, "data-xy-tipadj"),
+ "one-second samples in a wide window",
+ )
+ row = payload["first"][0]
+ assert row.startswith("xSep 17, 2026, 10:00:"), payload
+
+
+_BAND_FORMAT_PROBE = """
+
+"""
+
+
+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"
+ )
+
+ # 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"]
+
+
+_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