Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 88 additions & 2 deletions docs/components/tooltips.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved
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}`
Expand Down Expand Up @@ -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.
Expand Down
62 changes: 61 additions & 1 deletion js/src/30_ticks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading