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