diff --git a/.changeset/accurate-svg-pointers.md b/.changeset/accurate-svg-pointers.md new file mode 100644 index 00000000..37703317 --- /dev/null +++ b/.changeset/accurate-svg-pointers.md @@ -0,0 +1,6 @@ +--- +'@tanstack/charts': patch +--- + +Correct SVG pointer hit testing when the rendered viewport and chart scene use +different aspect ratios. diff --git a/API-FRICTION.md b/API-FRICTION.md index bf6b19ab..8eafea25 100644 --- a/API-FRICTION.md +++ b/API-FRICTION.md @@ -5,7 +5,7 @@ observed difficulty from examples, production migrations, tests, and agent evaluations so later API, documentation, and TanStack Intent skill work is based on evidence. -Last updated: 2026-08-01 +Last updated: 2026-08-02 ## Triage rule @@ -226,6 +226,10 @@ Each entry records: | F-188 | Paired interaction assertions assumed equal timing | Tooling | monitoring | | F-189 | The motion spike exposed duplicate configuration surfaces | API | resolved | | F-190 | Static conformance sampled active motion | Tooling | resolved | +| F-191 | Axis tick styling and edge alignment required shell work | API/Application | monitoring | +| F-192 | SVG letterboxing shifted pointer hit testing | API | resolved | +| F-193 | Fixed catalog height hid compact responsive examples | Tooling/App | resolved | +| F-194 | Behavior runs omitted the interactive input | Tooling | resolved | ## Findings @@ -4742,3 +4746,99 @@ Each entry records: - Verification: the quick 112–117 browser matrix passes all six visual cases, the focus/crosshair scenario, both revisions, and 320/640px viewports. Mean final-frame geometry similarity is 94.2%, with clean strict types. + +### F-191 — Axis tick styling and edge alignment required shell work + +- Status: monitoring +- Severity: low +- Owner: API/Application +- Observed in: matching the token activity calendar to a supplied visual + reference +- Friction: the reference required larger, quieter month labels than the + default axis typography. Axis presentation exposes tick values, formatting, + spacing, thinning, size, and padding, but not label font size or opacity. The + example therefore needed a shell-scoped `.ts-chart__axes text` rule to reach + the requested presentation. It also needed the first label to align with the + painted calendar's leading edge, while every unrotated band tick label + currently uses a middle anchor with no per-tick anchor or offset. The shell + measures the first cell after each render and adjusts only that generated + label; the final label stays at its month position so the preceding gap does + not widen. The cell mark also defaults to a 0.75-pixel inset, so omitting the + authored inset still recessed the first and final columns from a flush scale + range. +- Current decision: keep the override local to the application shell and avoid + expanding the public axis API from one styling and edge-alignment case. Use + an explicit zero cell inset with zero band outer padding and horizontal chart + margins when flush calendar edges are intended. + Revisit if production migrations or unrelated examples repeat the need for + authored tick-label typography or per-tick anchoring. +- Verification: browser inspection at gallery widths confirms twelve 13px + month labels, with Aug starting at the first cell edge and Jul retaining its + normal month position, 364 approximately square daily cells, and no + application, page, or overlay errors. Workspace typecheck and the focused + quick conformance matrix pass initial and updated data at 320px and 640px. + +### F-192 — SVG letterboxing shifted pointer hit testing + +- Status: resolved +- Severity: high +- Owner: API +- Observed in: daily token usage calendar tooltip verification +- Friction: the SVG surface converted browser pointer coordinates with the + element's complete bounding rectangle. When the responsive viewport and + scene had different aspect ratios, SVG's default `xMidYMid meet` transform + added letterboxing that the conversion ignored. A 640-by-480 scene in the + 604-by-480 gallery viewport therefore resolved calendar cells roughly one + weekday row below the pointer near the top of the chart. +- Decision: convert client coordinates through the inverse SVG screen matrix. + This delegates view-box, aspect-ratio, CSS transform, and viewport placement + semantics to the browser instead of duplicating them with bounding-rectangle + arithmetic. Preserve out-of-scene coordinates for overflowing marks and + retain the previous bounds conversion only for incomplete DOM + implementations such as jsdom, which do not expose `getScreenCTM`. +- Verification: unit regressions reproduce the gallery dimensions and its + 13.5-pixel vertical letterbox, verify the exact scene coordinate, and cover + the incomplete-DOM fallback. Browser conformance forces a mismatched SVG + viewport and proves the hovered cell retains its expected tooltip. The SVG + surface, renderer, and workspace tests pass; the reviewed shared-path cost is + recorded in the updated universal bundle baseline. + +### F-193 — Fixed catalog height hid compact responsive examples + +- Status: resolved +- Severity: low +- Owner: Tooling/Application +- Observed in: calendar heatmap responsive sizing +- Friction: the calendar could derive a compact height from its available width + and preserve square day cells, but the catalog renderer retained a generic + 480-pixel minimum height. The resulting blank panel made the example appear + fixed-height even after its SVG had correctly shrunk. +- Decision: keep the global catalog sizing contract unchanged for charts that + need the full benchmark height. The calendar shell temporarily sets its host + minimum height to the width-derived chart height and restores the previous + value when destroyed. Width remains fully fluid; no example-specific maximum + is imposed. +- Verification: shell tests cover fluid 320- and 960-pixel widths plus teardown. + Browser measurements confirm square day cells at each width, with no + horizontal overflow or fixed maximum-width behavior. + +### F-194 — Behavior runs omitted the interactive input + +- Status: resolved +- Severity: medium +- Owner: Tooling +- Observed in: automating the SVG letterbox pointer regression +- Friction: `ConformanceInput` exposed an `interactive` flag and catalog embeds + supplied it, but the browser behavior runner did not. Examples therefore had + to force interactive behavior at definition setup and could not scope a + viewport mismatch to semantic interaction checks without affecting static + visual measurements. +- Decision: mark behavior-run inputs as interactive and expose a separate + `behavior` flag for interaction-only layout. Remove the token calendar's + duplicated always-interactive shell option. +- Verification: the pointer-tooltip scenario creates a 120-pixel SVG viewport + mismatch only during the token calendar's behavior runs, targets the painted + Aug 3 cell, and asserts the exact focused date and tooltip. The fixed runtime + passes at 320 and 640 pixels across both revisions; reverting the runtime fix + makes the same scenario report Aug 5 or Aug 6. Static visual measurements and + public catalog mounts retain their original dimensions. diff --git a/benchmarks/bundle-size/universal-baseline.json b/benchmarks/bundle-size/universal-baseline.json index 6dfb399d..f1c659de 100644 --- a/benchmarks/bundle-size/universal-baseline.json +++ b/benchmarks/bundle-size/universal-baseline.json @@ -15,24 +15,24 @@ "gzip": 21632 }, "TanStack DOM host": { - "bytes": 44394, - "gzip": 16197 + "bytes": 44570, + "gzip": 16278 }, "React adapter": { - "bytes": 46562, - "gzip": 16900 + "bytes": 46738, + "gzip": 16971 }, "React line consumer": { - "bytes": 69228, - "gzip": 25972 + "bytes": 69404, + "gzip": 26052 }, "Compact-scale line scene": { "bytes": 22019, "gzip": 8326 }, "React compact-scale line consumer": { - "bytes": 51739, - "gzip": 18875 + "bytes": 51915, + "gzip": 18954 }, "Custom-scale line scene": { "bytes": 20204, diff --git a/benchmarks/comparison/bundle-baseline.json b/benchmarks/comparison/bundle-baseline.json index 6acbd654..ed6622c9 100644 --- a/benchmarks/comparison/bundle-baseline.json +++ b/benchmarks/comparison/bundle-baseline.json @@ -1,8 +1,8 @@ { "schemaVersion": 3, - "generatedAt": "2026-08-02T18:36:02.890Z", + "generatedAt": "2026-08-02T19:11:27.130Z", "packageVersions": { - "tanstack": "0.5.1", + "tanstack": "0.6.0", "chartjs": "4.5.1", "echarts": "6.1.0", "recharts": "3.10.1", @@ -11,7 +11,7 @@ "sources": { "tanstack": { "kind": "workspace", - "revision": "515da25d87b2c2eb4ded774ce45e4050e03bf5e7" + "revision": "eaa96a340cde6f07a1b7edb6a111e2a52d7d1c03" }, "chartjs": { "kind": "package", @@ -44,88 +44,88 @@ }, "bundles": { "tanstack-line-basic": { - "minifiedBytes": 78035, - "gzipBytes": 29392, - "brotliBytes": 26029, - "incrementalGzipBytes": 29392, - "incrementalBrotliBytes": 26029 + "minifiedBytes": 78211, + "gzipBytes": 29465, + "brotliBytes": 26139, + "incrementalGzipBytes": 29465, + "incrementalBrotliBytes": 26139 }, "tanstack-line-interactive": { - "minifiedBytes": 83206, - "gzipBytes": 31035, - "brotliBytes": 27376, - "incrementalGzipBytes": 31035, - "incrementalBrotliBytes": 27376 + "minifiedBytes": 83382, + "gzipBytes": 31104, + "brotliBytes": 27459, + "incrementalGzipBytes": 31104, + "incrementalBrotliBytes": 27459 }, "tanstack-line-advanced": { - "minifiedBytes": 90391, - "gzipBytes": 33385, - "brotliBytes": 29378, - "incrementalGzipBytes": 33385, - "incrementalBrotliBytes": 29378 + "minifiedBytes": 90567, + "gzipBytes": 33457, + "brotliBytes": 29507, + "incrementalGzipBytes": 33457, + "incrementalBrotliBytes": 29507 }, "tanstack-bar-basic": { - "minifiedBytes": 85046, - "gzipBytes": 32063, - "brotliBytes": 28287, - "incrementalGzipBytes": 32063, - "incrementalBrotliBytes": 28287 + "minifiedBytes": 85222, + "gzipBytes": 32128, + "brotliBytes": 28411, + "incrementalGzipBytes": 32128, + "incrementalBrotliBytes": 28411 }, "tanstack-bar-interactive": { - "minifiedBytes": 89072, - "gzipBytes": 33257, - "brotliBytes": 29274, - "incrementalGzipBytes": 33257, - "incrementalBrotliBytes": 29274 + "minifiedBytes": 89248, + "gzipBytes": 33326, + "brotliBytes": 29367, + "incrementalGzipBytes": 33326, + "incrementalBrotliBytes": 29367 }, "tanstack-bar-advanced": { - "minifiedBytes": 89411, - "gzipBytes": 33389, - "brotliBytes": 29442, - "incrementalGzipBytes": 33389, - "incrementalBrotliBytes": 29442 + "minifiedBytes": 89587, + "gzipBytes": 33458, + "brotliBytes": 29488, + "incrementalGzipBytes": 33458, + "incrementalBrotliBytes": 29488 }, "tanstack-area-basic": { - "minifiedBytes": 81844, - "gzipBytes": 30875, - "brotliBytes": 27354, - "incrementalGzipBytes": 30875, - "incrementalBrotliBytes": 27354 + "minifiedBytes": 82020, + "gzipBytes": 30953, + "brotliBytes": 27423, + "incrementalGzipBytes": 30953, + "incrementalBrotliBytes": 27423 }, "tanstack-area-interactive": { - "minifiedBytes": 87023, - "gzipBytes": 32562, - "brotliBytes": 28759, - "incrementalGzipBytes": 32562, - "incrementalBrotliBytes": 28759 + "minifiedBytes": 87199, + "gzipBytes": 32637, + "brotliBytes": 28802, + "incrementalGzipBytes": 32637, + "incrementalBrotliBytes": 28802 }, "tanstack-area-advanced": { - "minifiedBytes": 94390, - "gzipBytes": 34858, - "brotliBytes": 30726, - "incrementalGzipBytes": 34858, - "incrementalBrotliBytes": 30726 + "minifiedBytes": 94566, + "gzipBytes": 34929, + "brotliBytes": 30869, + "incrementalGzipBytes": 34929, + "incrementalBrotliBytes": 30869 }, "tanstack-scatter-basic": { - "minifiedBytes": 77710, - "gzipBytes": 29291, - "brotliBytes": 25985, - "incrementalGzipBytes": 29291, - "incrementalBrotliBytes": 25985 + "minifiedBytes": 77886, + "gzipBytes": 29367, + "brotliBytes": 26035, + "incrementalGzipBytes": 29367, + "incrementalBrotliBytes": 26035 }, "tanstack-scatter-interactive": { - "minifiedBytes": 82881, - "gzipBytes": 30933, - "brotliBytes": 27290, - "incrementalGzipBytes": 30933, - "incrementalBrotliBytes": 27290 + "minifiedBytes": 83057, + "gzipBytes": 31005, + "brotliBytes": 27357, + "incrementalGzipBytes": 31005, + "incrementalBrotliBytes": 27357 }, "tanstack-scatter-advanced": { - "minifiedBytes": 82897, - "gzipBytes": 30938, - "brotliBytes": 27342, - "incrementalGzipBytes": 30938, - "incrementalBrotliBytes": 27342 + "minifiedBytes": 83073, + "gzipBytes": 31010, + "brotliBytes": 27387, + "incrementalGzipBytes": 31010, + "incrementalBrotliBytes": 27387 }, "chartjs-line-basic": { "minifiedBytes": 137909, diff --git a/benchmarks/conformance/cases/118-token-usage-calendar/case.json b/benchmarks/conformance/cases/118-token-usage-calendar/case.json new file mode 100644 index 00000000..3e6043a6 --- /dev/null +++ b/benchmarks/conformance/cases/118-token-usage-calendar/case.json @@ -0,0 +1,51 @@ +{ + "schemaVersion": 1, + "order": 1190, + "id": "118-token-usage-calendar", + "title": "Token use calendar heatmap", + "family": "time", + "intent": "Show twelve months of token activity in contribution-style week columns, with recent usage emerging in the right half and quiet days kept visually consistent.", + "support": "native", + "features": [ + "calendar time bin transform", + "calendar layout", + "cell mark", + "two band scales", + "categorical intensity color scale", + "keyboard focus and tooltip", + "letterboxed interaction viewport" + ], + "geometry": [{ "role": "cell", "count": 364 }], + "interactionScenarios": [ + { + "id": "letterboxed-cell-tooltip", + "steps": [ + { + "type": "pointerMove", + "target": { "anchor": "date:2025-08-03" } + }, + { + "type": "assert", + "assertions": [ + { "path": "focus.date", "equals": "2025-08-03" }, + { "path": "tooltip.visible", "equals": true }, + { "path": "tooltip.text", "includes": "Aug 3" } + ] + }, + { "type": "pointerLeave" }, + { + "type": "assert", + "assertions": [{ "path": "tooltip.visible", "equals": false }] + } + ] + } + ], + "source": { + "title": "D3 calendar time intervals", + "url": "https://d3js.org/d3-time" + }, + "ai": { + "create": "Create a deterministic twelve-month token-activity panel from August through July. Aggregate raw usage events with binTimeX and utcDay, place days in Sunday-based week columns and weekday rows, keep the first half mostly quiet, and build recent intensity in the right half. Keep the visible tooltip to a compact token total and date, and render every zero-usage day with one consistent neutral color.", + "maintain": "Preserve the explicit UTC day domain, 364 complete daily cells, transform lineage, deterministic revisions, Sunday week alignment, twelve month labels, weekday order, accessible color meaning, late-timeline activity shape, and consistent zero-usage treatment." + } +} diff --git a/benchmarks/conformance/cases/118-token-usage-calendar/layout.ts b/benchmarks/conformance/cases/118-token-usage-calendar/layout.ts new file mode 100644 index 00000000..17e03049 --- /dev/null +++ b/benchmarks/conformance/cases/118-token-usage-calendar/layout.ts @@ -0,0 +1,36 @@ +import { calendarWeekCount } from './model' + +export const calendarBandPaddingInner = 0.08 +export const calendarBandPaddingOuter = 0 +export const calendarMargin = { + top: 4, + right: 0, + bottom: 30, + left: 0, +} as const + +export function calendarGridHeight(width: number): number { + const plotWidth = Math.max( + 1, + width - calendarMargin.left - calendarMargin.right, + ) + const xStep = + plotWidth / + (calendarWeekCount - + calendarBandPaddingInner + + calendarBandPaddingOuter * 2) + return xStep * (7 - calendarBandPaddingInner + calendarBandPaddingOuter * 2) +} + +export function calendarChartHeight(width: number): number { + return Math.ceil( + calendarMargin.top + calendarGridHeight(width) + calendarMargin.bottom, + ) +} + +export function calendarBottomMargin(width: number, height: number): number { + return Math.max( + calendarMargin.bottom, + height - calendarMargin.top - calendarGridHeight(width), + ) +} diff --git a/benchmarks/conformance/cases/118-token-usage-calendar/model.test.ts b/benchmarks/conformance/cases/118-token-usage-calendar/model.test.ts new file mode 100644 index 00000000..221d2ed1 --- /dev/null +++ b/benchmarks/conformance/cases/118-token-usage-calendar/model.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest' +import { + calendarMonthTicks, + formatTokenUsage, + tokenUsageCalendar, + tokenUsageEvents, +} from './model' + +describe('token use calendar heatmap', () => { + it('uses the UTC daily transform to materialize a complete twelve-month domain', () => { + const days = tokenUsageCalendar(0) + + expect(days).toHaveLength(364) + expect(days[0]).toMatchObject({ + dateKey: '2025-08-03', + week: 0, + weekday: 'Sun', + }) + expect(days.at(-1)).toMatchObject({ + dateKey: '2026-08-01', + week: 51, + weekday: 'Sat', + }) + expect(days.some((day) => day.level === 'No usage')).toBe(true) + }) + + it('preserves transform lineage and sums every raw event into its day', () => { + const events = tokenUsageEvents(2) + const days = tokenUsageCalendar(2) + + expect(days.flatMap((day) => day.sourceIndexes)).toHaveLength(events.length) + for (const day of days) { + expect(day.tokens).toBe( + day.source.reduce((total, event) => total + event.tokens, 0), + ) + expect(day.sessions).toBe(day.source.length) + } + }) + + it('keeps deterministic data and stable month-label positions', () => { + expect(tokenUsageCalendar(3)).toEqual(tokenUsageCalendar(3)) + const monthTicks = calendarMonthTicks() + expect(monthTicks.values).toHaveLength(12) + expect([...monthTicks.labels.values()]).toEqual([ + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + ]) + }) + + it('concentrates active and intense days toward the recent end', () => { + const days = tokenUsageCalendar(0) + const midpoint = Math.floor(days.length / 2) + const earlier = days.slice(0, midpoint) + const recent = days.slice(midpoint) + + expect(recent.filter((day) => day.tokens > 0).length).toBeGreaterThan( + earlier.filter((day) => day.tokens > 0).length * 5, + ) + expect(recent.some((day) => day.tokens > 150_000_000)).toBe(true) + expect(days.some((day) => day.level === 'No usage')).toBe(true) + }) + + it('formats a compact, single-line tooltip without diagnostic details', () => { + const day = tokenUsageCalendar(0)[0]! + + expect( + formatTokenUsage({ + ...day, + date: new Date('2026-03-26T00:00:00Z'), + tokens: 185_200_000, + }), + ).toBe('185.2M tokens on Mar 26') + expect( + formatTokenUsage({ + ...day, + date: new Date('2026-03-27T00:00:00Z'), + tokens: 0, + }), + ).toBe('0 tokens on Mar 27') + }) +}) diff --git a/benchmarks/conformance/cases/118-token-usage-calendar/model.ts b/benchmarks/conformance/cases/118-token-usage-calendar/model.ts new file mode 100644 index 00000000..4b7cf2b4 --- /dev/null +++ b/benchmarks/conformance/cases/118-token-usage-calendar/model.ts @@ -0,0 +1,157 @@ +import { binTimeX } from '@tanstack/charts' +import { utcDay, utcMonth, utcSunday } from 'd3-time' + +export interface TokenUsageEvent { + readonly at: Date + readonly tokens: number +} + +export const weekdays = [ + 'Sun', + 'Mon', + 'Tue', + 'Wed', + 'Thu', + 'Fri', + 'Sat', +] as const + +export const usageLevels = [ + 'No usage', + 'Up to 25M', + '25M–75M', + '75M–150M', + 'Over 150M', +] as const + +export type UsageLevel = (typeof usageLevels)[number] + +export const usageColors = [ + '#f3f4f6', + '#dbeafe', + '#bfdbfe', + '#93c5fd', + '#3b82f6', +] as const + +export interface TokenUsageDay { + readonly date: Date + readonly dateKey: string + readonly week: number + readonly weekday: (typeof weekdays)[number] + readonly tokens: number + readonly sessions: number + readonly level: UsageLevel + readonly source: readonly TokenUsageEvent[] + readonly sourceIndexes: readonly number[] +} + +export const calendarStart = new Date('2025-08-03T00:00:00Z') +export const calendarEnd = new Date('2026-08-01T00:00:00Z') +export const calendarWeekCount = utcSunday.count(calendarStart, calendarEnd) + 1 + +const dateFormat = new Intl.DateTimeFormat('en-US', { + month: 'short', + day: 'numeric', + timeZone: 'UTC', +}) + +const monthFormat = new Intl.DateTimeFormat('en-US', { + month: 'short', + timeZone: 'UTC', +}) + +const tokenFormat = new Intl.NumberFormat('en-US', { + notation: 'compact', + maximumFractionDigits: 1, +}) + +export function tokenUsageCalendar(revision: number): TokenUsageDay[] { + const events = tokenUsageEvents(revision) + const daily = binTimeX(events, { + value: 'at', + interval: utcDay, + domain: [calendarStart, calendarEnd], + outputs: { + tokens: { value: 'tokens', reduce: 'sum' }, + sessions: { reduce: 'count' }, + }, + }) + + return daily.map((day) => { + const date = day.x1 + const dateKey = isoDate(date) + return { + date, + dateKey, + week: utcSunday.count(calendarStart, date), + weekday: weekdays[date.getUTCDay()] ?? 'Sun', + tokens: day.tokens, + sessions: day.sessions, + level: usageLevel(day.tokens), + source: day.source, + sourceIndexes: day.sourceIndexes, + } + }) +} + +export function tokenUsageEvents(revision: number): TokenUsageEvent[] { + const dates = utcDay.range(calendarStart, utcDay.offset(calendarEnd, 1)) + return dates.flatMap((date, dayIndex) => { + const weekday = date.getUTCDay() + const weekend = weekday === 0 || weekday === 6 + const progress = dayIndex / Math.max(1, dates.length - 1) + const activityRamp = Math.min(1, Math.max(0, (progress - 0.46) / 0.08)) + const activity = (dayIndex * 37 + weekday * 17 + revision * 11) % 100 + const quietThreshold = Math.round(99 - activityRamp * (weekend ? 67 : 81)) + if (activity < quietThreshold) return [] + + const intensityRamp = Math.min(1, Math.max(0, (progress - 0.5) / 0.5)) + const surge = + intensityRamp > 0.8 && (dayIndex * 11 + weekday * 7 + revision) % 13 === 0 + ? 90_000_000 + : 0 + const total = + 4_000_000 + + ((dayIndex * 7_919_000 + weekday * 13_337_000 + revision * 9_973_000) % + 28_000_000) + + Math.round(intensityRamp * 78_000_000) + + surge + const sessions = 1 + ((dayIndex + revision + weekday) % 3) + return Array.from({ length: sessions }, (_value, sessionIndex) => ({ + at: new Date(date.getTime() + (8 + sessionIndex * 4) * 3_600_000), + tokens: + Math.floor(total / sessions) + + (sessionIndex < total % sessions ? 1 : 0), + })) + }) +} + +export function calendarMonthTicks(): { + readonly values: readonly number[] + readonly labels: ReadonlyMap +} { + const dates = [calendarStart, ...utcMonth.range(calendarStart, calendarEnd)] + const labels = new Map() + for (const date of dates) { + const week = utcSunday.count(calendarStart, date) + if (!labels.has(week)) labels.set(week, monthFormat.format(date)) + } + return { values: [...labels.keys()], labels } +} + +export function formatTokenUsage(day: TokenUsageDay): string { + return `${tokenFormat.format(day.tokens)} tokens on ${dateFormat.format(day.date)}` +} + +function usageLevel(tokens: number): UsageLevel { + if (tokens === 0) return 'No usage' + if (tokens <= 25_000_000) return 'Up to 25M' + if (tokens <= 75_000_000) return '25M–75M' + if (tokens <= 150_000_000) return '75M–150M' + return 'Over 150M' +} + +function isoDate(date: Date): string { + return date.toISOString().slice(0, 10) +} diff --git a/benchmarks/conformance/cases/118-token-usage-calendar/plot.ts b/benchmarks/conformance/cases/118-token-usage-calendar/plot.ts new file mode 100644 index 00000000..68a634d4 --- /dev/null +++ b/benchmarks/conformance/cases/118-token-usage-calendar/plot.ts @@ -0,0 +1,61 @@ +import * as Plot from '@observablehq/plot' +import { + calendarMonthTicks, + calendarWeekCount, + formatTokenUsage, + tokenUsageCalendar, + usageColors, + usageLevels, + weekdays, +} from './model' +import { withTokenActivityShell } from './shell' +import { mountObservablePlot } from '../../shared/mount' +import type { ConformanceMount } from '../../types' + +const weekDomain = Array.from( + { length: calendarWeekCount }, + (_value, index) => index, +) + +const mountPlot: ConformanceMount = (container, input) => + mountObservablePlot(container, input, (nextInput) => { + const days = tokenUsageCalendar(nextInput.revision) + const monthTicks = calendarMonthTicks() + + return Plot.plot({ + width: nextInput.width, + height: nextInput.height, + ariaLabel: + 'Token activity from August 2025 through July 2026. Weeks are columns and Sunday through Saturday are rows. Pale gray means no usage and blue intensity shows token usage.', + x: { + domain: weekDomain, + ticks: monthTicks.values, + tickFormat: (week) => monthTicks.labels.get(Number(week)) ?? '', + tickSize: 0, + label: null, + }, + y: { + domain: weekdays, + axis: null, + }, + color: { + type: 'ordinal', + domain: usageLevels, + range: usageColors, + label: 'Daily token usage', + }, + marks: [ + Plot.cell(days, { + className: 'token-usage-cells', + x: 'week', + y: 'weekday', + fill: 'level', + inset: 0, + rx: 3, + title: formatTokenUsage, + }), + ], + }) + }) + +export const mount = withTokenActivityShell(mountPlot) diff --git a/benchmarks/conformance/cases/118-token-usage-calendar/shell.test.ts b/benchmarks/conformance/cases/118-token-usage-calendar/shell.test.ts new file mode 100644 index 00000000..565409ff --- /dev/null +++ b/benchmarks/conformance/cases/118-token-usage-calendar/shell.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' +import { calendarChartHeight } from './layout' +import { withTokenActivityShell } from './shell' +import type { ConformanceInput, ConformanceMount } from '../../types' + +describe('token activity shell', () => { + it('keeps the gallery interactive and aligns only the first month label', () => { + const inputs: ConformanceInput[] = [] + const mountChart: ConformanceMount = (container, input) => { + inputs.push(input) + container.innerHTML = ` + + + Aug + Jul + + + + + ` + return { + update(nextInput) { + inputs.push(nextInput) + }, + destroy() {}, + } + } + const container = document.createElement('div') + container.style.minHeight = '480px' + const mount = withTokenActivityShell(mountChart) + const handle = mount(container, { + width: 320, + height: 180, + revision: 0, + interactive: true, + }) + const labels = container.querySelectorAll('text') + const shell = container.querySelector('.token-activity-shell') + + expect(inputs[0]?.interactive).toBe(true) + expect(inputs[0]?.width).toBe(320) + expect(inputs[0]?.height).toBe(calendarChartHeight(320)) + expect(shell?.style.width).toBe('100%') + expect(shell?.style.height).toBe(`${calendarChartHeight(320)}px`) + expect(container.style.minHeight).toBe(`${calendarChartHeight(320)}px`) + expect(labels[0]?.getAttribute('x')).toBe('10') + expect(labels[0]?.getAttribute('text-anchor')).toBe('start') + expect(labels[1]?.getAttribute('x')).toBe('92') + expect(labels[1]?.getAttribute('text-anchor')).toBe('middle') + + handle.update({ + width: 960, + height: 240, + revision: 1, + interactive: true, + }) + expect(inputs[1]?.interactive).toBe(true) + expect(inputs[1]?.width).toBe(960) + expect(inputs[1]?.height).toBe(calendarChartHeight(960)) + expect(shell?.style.height).toBe(`${calendarChartHeight(960)}px`) + expect(container.style.minHeight).toBe(`${calendarChartHeight(960)}px`) + + handle.destroy() + expect(container.style.minHeight).toBe('480px') + }) +}) diff --git a/benchmarks/conformance/cases/118-token-usage-calendar/shell.ts b/benchmarks/conformance/cases/118-token-usage-calendar/shell.ts new file mode 100644 index 00000000..150e4189 --- /dev/null +++ b/benchmarks/conformance/cases/118-token-usage-calendar/shell.ts @@ -0,0 +1,198 @@ +import type { + ConformanceInput, + ConformanceMount, + ConformanceTestDriver, +} from '../../types' +import { calendarChartHeight } from './layout' +import { + formatTokenUsage, + tokenUsageCalendar, + type TokenUsageDay, +} from './model' + +export function withTokenActivityShell( + mountChart: ConformanceMount, +): ConformanceMount { + return (container, input) => { + let currentInput = input + let targetDateKey: string | null = null + const previousMinHeight = container.style.minHeight + const document = container.ownerDocument + const shell = document.createElement('div') + const chartSurface = document.createElement('div') + const style = document.createElement('style') + + shell.className = 'token-activity-shell' + shell.style.boxSizing = 'border-box' + shell.style.width = '100%' + shell.style.overflow = 'hidden' + + chartSurface.style.minHeight = '0' + chartSurface.style.width = '100%' + style.textContent = ` + .token-activity-shell .ts-chart__axes text { + font-size: 13px; + opacity: 0.62; + } + + .ts-chart-tooltip.token-activity-tooltip { + max-width: calc(100% - 24px) !important; + padding: 6px 9px !important; + border: 1px solid #e5e7eb !important; + border-radius: 9px !important; + background: #fff !important; + color: #202124 !important; + box-shadow: none !important; + font: 500 12px/1.25 system-ui, sans-serif !important; + overflow-wrap: normal !important; + white-space: nowrap !important; + } + ` + shell.append(style, chartSurface) + container.append(shell) + + resizeShell(container, shell, chartSurface, input) + const chart = mountChart(chartSurface, chartInput(input)) + alignFirstMonthLabel(chartSurface) + + const driver: ConformanceTestDriver = { + resolveTarget(target) { + targetDateKey = null + if (target.view && target.view !== 'main') return null + const dateKey = target.anchor.startsWith('date:') + ? target.anchor.slice('date:'.length) + : null + const days = tokenUsageCalendar(currentInput.revision) + const index = days.findIndex((day) => day.dateKey === dateKey) + const cell = calendarCells(chartSurface, days.length)[index] + if (!cell) return null + targetDateKey = days[index]?.dateKey ?? null + const bounds = cell.getBoundingClientRect() + const chart = chartSurface.querySelector('svg') + const resolved = { + x: bounds.left + bounds.width / 2, + y: bounds.top + bounds.height / 2, + } + return chart ? { ...resolved, focusElement: chart } : resolved + }, + readState() { + const days = tokenUsageCalendar(currentInput.revision) + if (chartSurface.querySelector('svg.ts-chart')) { + return tanstackInteractionState(document, days, targetDateKey) + } + const cells = calendarCells(chartSurface, days.length) + const index = cells.findIndex((cell) => cell.matches(':hover')) + const day = days[index] + return interactionState(day) + }, + } + + return { + driver, + update(nextInput) { + targetDateKey = null + currentInput = nextInput + resizeShell(container, shell, chartSurface, nextInput) + chart.update(chartInput(nextInput)) + alignFirstMonthLabel(chartSurface) + }, + destroy() { + chart.destroy() + shell.remove() + container.style.minHeight = previousMinHeight + }, + } + } +} + +function chartInput(input: ConformanceInput): ConformanceInput { + return { + ...input, + height: calendarChartHeight(input.width), + } +} + +function alignFirstMonthLabel(chartSurface: HTMLElement) { + const chart = chartSurface.querySelector('svg.ts-chart') + if (!chart) return + + const cells = chart.querySelectorAll( + 'rect[data-ts-key^="rect-0:"]', + ) + const labels = chart.querySelectorAll('.ts-chart__axes text') + const firstCell = cells.item(0) + const firstLabel = labels.item(0) + if (!firstCell || !firstLabel) return + + const start = Number(firstCell.getAttribute('x')) + if (!Number.isFinite(start)) return + + firstLabel.setAttribute('x', String(start)) + firstLabel.setAttribute('text-anchor', 'start') +} + +function resizeShell( + container: HTMLElement, + shell: HTMLElement, + chartSurface: HTMLElement, + input: ConformanceInput, +) { + const sceneHeight = calendarChartHeight(input.width) + const height = input.behavior ? sceneHeight + 120 : sceneHeight + container.style.minHeight = `${height}px` + shell.style.height = `${height}px` + chartSurface.style.height = `${height}px` +} + +function calendarCells( + chartSurface: HTMLElement, + expectedCount: number, +): SVGRectElement[] { + const tanstack = [ + ...chartSurface.querySelectorAll( + 'rect[data-ts-key^="rect-0:"]', + ), + ] + if (tanstack.length === expectedCount) return tanstack + const plot = [ + ...chartSurface.querySelectorAll('.token-usage-cells rect'), + ] + return plot.length === expectedCount ? plot : [] +} + +function tanstackInteractionState( + document: Document, + days: readonly TokenUsageDay[], + targetDateKey: string | null, +) { + const tooltip = document.querySelector('.ts-chart-tooltip') + const bounds = tooltip?.getBoundingClientRect() + const style = tooltip && document.defaultView?.getComputedStyle(tooltip) + const visible = Boolean( + tooltip && + !tooltip.hidden && + bounds && + bounds.width > 0 && + bounds.height > 0 && + style?.display !== 'none' && + style?.visibility !== 'hidden' && + style?.opacity !== '0', + ) + const text = visible ? (tooltip?.textContent?.trim() ?? '') : '' + const targetDay = days.find((day) => day.dateKey === targetDateKey) + const day = + visible && targetDay && text.includes(formatTokenUsage(targetDay)) + ? targetDay + : undefined + return interactionState(day, text) +} + +function interactionState(day?: TokenUsageDay, text = '') { + return { + focus: { date: day?.dateKey ?? null }, + tooltip: { + visible: Boolean(day), + text: text || (day ? formatTokenUsage(day) : ''), + }, + } +} diff --git a/benchmarks/conformance/cases/118-token-usage-calendar/tanstack.ts b/benchmarks/conformance/cases/118-token-usage-calendar/tanstack.ts new file mode 100644 index 00000000..b57d6147 --- /dev/null +++ b/benchmarks/conformance/cases/118-token-usage-calendar/tanstack.ts @@ -0,0 +1,92 @@ +import { cell, defineChart } from '@tanstack/charts' +import { portal } from '@tanstack/charts/tooltip/portal' +import { scaleBand, scaleOrdinal } from 'd3-scale' +import { + calendarMonthTicks, + calendarWeekCount, + formatTokenUsage, + tokenUsageCalendar, + usageColors, + usageLevels, + weekdays, +} from './model' +import { + calendarBandPaddingInner, + calendarBandPaddingOuter, + calendarBottomMargin, + calendarMargin, +} from './layout' +import { withTokenActivityShell } from './shell' +import { tanstackMount } from '../../shared/mount' +import type { ConformanceInput } from '../../types' + +const weekDomain = Array.from( + { length: calendarWeekCount }, + (_value, index) => index, +) + +export const tokenUsageCalendarDefinition = (input: ConformanceInput) => { + const days = tokenUsageCalendar(input.revision) + const monthTicks = calendarMonthTicks() + + return defineChart(({ width, height }) => { + return { + marks: [ + cell(days, { + x: 'week', + y: 'weekday', + color: 'level', + key: 'dateKey', + inset: 0, + radius: 3, + }), + ], + x: { + scale: scaleBand() + .domain(weekDomain) + .paddingInner(calendarBandPaddingInner) + .paddingOuter(calendarBandPaddingOuter), + axis: { + line: false, + ticks: { + values: monthTicks.values, + size: 0, + padding: 7, + format: (week: number) => monthTicks.labels.get(week) ?? '', + }, + tickLabels: { thin: { minGap: 8, priority: 'ends' } }, + }, + }, + y: { + scale: scaleBand() + .domain(weekdays) + .paddingInner(calendarBandPaddingInner) + .paddingOuter(calendarBandPaddingOuter), + axis: false, + }, + color: { + scale: scaleOrdinal() + .domain(usageLevels) + .range(usageColors), + }, + margin: { + ...calendarMargin, + bottom: calendarBottomMargin(width, height), + }, + } + }) +} + +export const mount = withTokenActivityShell( + tanstackMount( + tokenUsageCalendarDefinition, + 'Token activity from August 2025 through July 2026. Weeks are columns and Sunday through Saturday are rows. Pale gray means no usage; blue intensity ranges from up to 25 million through over 150 million tokens.', + { + anchor: 'point', + className: 'token-activity-tooltip', + format: ({ datum }) => formatTokenUsage(datum), + offset: 5, + portal, + }, + ), +) diff --git a/benchmarks/conformance/cases/25-calendar-heatmap/case.json b/benchmarks/conformance/cases/25-calendar-heatmap/case.json index 40001b7b..0fd709c1 100644 --- a/benchmarks/conformance/cases/25-calendar-heatmap/case.json +++ b/benchmarks/conformance/cases/25-calendar-heatmap/case.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "order": 250, "id": "25-calendar-heatmap", - "title": "Calendar heatmap", + "title": "Precipitation calendar heatmap", "family": "time", "intent": "Show Seattle daily precipitation across calendar weeks while retaining weekday rhythm and dry days.", "support": "native", diff --git a/benchmarks/conformance/cases/34-pointer-tooltip/case.json b/benchmarks/conformance/cases/34-pointer-tooltip/case.json index 55c83cc1..523ca723 100644 --- a/benchmarks/conformance/cases/34-pointer-tooltip/case.json +++ b/benchmarks/conformance/cases/34-pointer-tooltip/case.json @@ -30,7 +30,8 @@ "type": "assert", "assertions": [ { "path": "tooltip.visible", "equals": true }, - { "path": "tooltip.text", "includes": "Apple" } + { "path": "tooltip.text", "includes": "Apple" }, + { "path": "focus.dates", "includes": "2013-05-23" } ] }, { "type": "screenshot", "name": "focused-dot-open" }, diff --git a/benchmarks/conformance/cases/34-pointer-tooltip/plot.ts b/benchmarks/conformance/cases/34-pointer-tooltip/plot.ts index d43b3a2d..8bdd2f86 100644 --- a/benchmarks/conformance/cases/34-pointer-tooltip/plot.ts +++ b/benchmarks/conformance/cases/34-pointer-tooltip/plot.ts @@ -6,6 +6,7 @@ import { selectPointerTooltipData } from './selection' export const mount: ConformanceMount = (container, input) => { let rows = selectPointerTooltipData(aapl, input.revision) + let targetDate: string | null = null const handle = mountObservablePlot(container, input, (nextInput) => { rows = selectPointerTooltipData(aapl, nextInput.revision) return Plot.plot({ @@ -55,6 +56,7 @@ export const mount: ConformanceMount = (container, input) => { '[aria-label="dot"] circle', )[index] if (!circle) return null + targetDate = date const bounds = circle.getBoundingClientRect() const svg = container.querySelector('svg') const resolved = { @@ -69,17 +71,21 @@ export const mount: ConformanceMount = (container, input) => { const style = tooltip && container.ownerDocument.defaultView?.getComputedStyle(tooltip) + const visible = Boolean( + tooltip && + bounds && + bounds.width > 0 && + bounds.height > 0 && + style?.display !== 'none' && + style?.visibility !== 'hidden' && + style?.opacity !== '0', + ) return { + focus: { + dates: visible && targetDate ? [targetDate] : [], + }, tooltip: { - visible: Boolean( - tooltip && - bounds && - bounds.width > 0 && - bounds.height > 0 && - style?.display !== 'none' && - style?.visibility !== 'hidden' && - style?.opacity !== '0', - ), + visible, text: tooltip?.textContent?.trim() ?? '', }, } diff --git a/benchmarks/conformance/cases/34-pointer-tooltip/tanstack.ts b/benchmarks/conformance/cases/34-pointer-tooltip/tanstack.ts index 4b6589bd..3b375a4f 100644 --- a/benchmarks/conformance/cases/34-pointer-tooltip/tanstack.ts +++ b/benchmarks/conformance/cases/34-pointer-tooltip/tanstack.ts @@ -98,10 +98,19 @@ export function mount( .points.find((candidate) => dateKey(candidate.datum.Date) === date) const svg = container.querySelector('svg') if (!point || !svg) return null - const bounds = svg.getBoundingClientRect() + const matrix = svg.getScreenCTM() + if (!matrix) { + const bounds = svg.getBoundingClientRect() + const scene = host.getScene() + return { + x: bounds.left + (point.x / scene.width) * bounds.width, + y: bounds.top + (point.y / scene.height) * bounds.height, + focusElement: svg, + } + } return { - x: bounds.left + (point.x / host.getScene().width) * bounds.width, - y: bounds.top + (point.y / host.getScene().height) * bounds.height, + x: matrix.a * point.x + matrix.c * point.y + matrix.e, + y: matrix.b * point.x + matrix.d * point.y + matrix.f, focusElement: svg, } }, diff --git a/benchmarks/conformance/types.ts b/benchmarks/conformance/types.ts index c128c48e..60524786 100644 --- a/benchmarks/conformance/types.ts +++ b/benchmarks/conformance/types.ts @@ -35,6 +35,8 @@ export interface ConformanceInput { height: number revision: number interactive?: boolean + /** True only for semantic browser scenarios, not catalog or visual mounts. */ + behavior?: boolean } export interface ConformanceHandle { diff --git a/docs/comparison.md b/docs/comparison.md index 3b3b6e84..1f0d470d 100644 --- a/docs/comparison.md +++ b/docs/comparison.md @@ -12,14 +12,14 @@ untested behavior into a checkmark. | Library | Package | Measured source | | -------------------------------------------------------------------------------------- | -------------------- | ------------------- | -| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `515da25` | +| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `eaa96a3` | | [Chart.js](https://www.chartjs.org/docs/latest/) | `chart.js` | npm `4.5.1` | | [Apache ECharts](https://echarts.apache.org/handbook/en/best-practices/canvas-vs-svg/) | `echarts` | npm `6.1.0` | | [Recharts](https://recharts.github.io/en-US/) | `recharts` | npm `3.10.1` | | [Observable Plot](https://observablehq.com/plot/features/plots) | `@observablehq/plot` | npm `0.6.17` | The competitor versions are exact package pins, not latest versions inferred -at page render time. The measured TanStack workspace revision is `515da25`. +at page render time. The measured TanStack workspace revision is `eaa96a3`. ## Capability matrix @@ -106,7 +106,7 @@ Vega-Lite, AG Charts, and uPlot main exports were read from Bundlephobia on July | Library | Bundle size | React externalized | Evidence | | ------------------ | -------------------------------------- | -----------------: | ---------------------------------------------------------- | -| TanStack Charts | 28.60–34.04 KiB | — | Controlled suite | +| TanStack Charts | 28.68–34.11 KiB | — | Controlled suite | | D3 | 90 KB gzip | — | External main export | | Chart.js | 44.70–58.21 KiB | — | Controlled suite | | Apache ECharts | 153.10–173.18 KiB | — | Controlled suite | diff --git a/docs/examples/heatmaps-and-densities.md b/docs/examples/heatmaps-and-densities.md index 015e546f..e571996a 100644 --- a/docs/examples/heatmaps-and-densities.md +++ b/docs/examples/heatmaps-and-densities.md @@ -13,6 +13,7 @@ per raw observation. | Reader question | Start with | | ------------------------------------------------------------- | --------------------------- | +| How does daily activity vary by week and weekday? | Token use calendar heatmap | | How many observations fall in each quantitative x-y interval? | Binned quantitative heatmap | | What smooth regions enclose similar point density? | Density contours | | Where are dense clusters while retaining local bin shape? | Hexagonal bins | @@ -22,6 +23,35 @@ per raw observation. color channels. [Legends and Color](../guides/legends-and-color.md) covers continuous color meaning and accessible legend design. +## Bin events into a calendar + +A contribution-style calendar exposes both long-term activity and weekday +rhythm without drawing one long daily time axis. This example aggregates raw, +session-level token events into a complete twelve-month UTC day domain, then +maps Sunday weeks to columns and weekdays to rows. + + + +The example uses `binTimeX` with D3's `utcDay` interval and an explicit +twelve-month domain. Each output row contains the day interval, the summed token +count, the session count, and source lineage. Empty bins become real zero-value +days and share one consistent neutral treatment. + +Calendar placement is a second, explicit step: `utcSunday.count` produces the +week column and `date.getUTCDay()` selects the row. A categorical usage scale +creates contribution-style levels, while the accessible chart description and +cell color explain the zero-to-high usage range. The compact focus tooltip keeps +the visible detail to the exact token total and date. Keep all calendar +calculations in one time basis—UTC here—to avoid moving events between days +around daylight-saving transitions. + ## Aggregate into quantitative cells A two-dimensional binned heatmap makes density bounded: the number of rendered diff --git a/docs/framework/octane/quick-start.md b/docs/framework/octane/quick-start.md index 7dc21447..136fb072 100644 --- a/docs/framework/octane/quick-start.md +++ b/docs/framework/octane/quick-start.md @@ -182,7 +182,7 @@ same Octane adapter: + +The example uses `binTimeX` with D3's `utcDay` interval and an explicit +twelve-month domain. Each output row contains the day interval, the summed token +count, the session count, and source lineage. Empty bins become real zero-value +days and share one consistent neutral treatment. + +Calendar placement is a second, explicit step: `utcSunday.count` produces the +week column and `date.getUTCDay()` selects the row. A categorical usage scale +creates contribution-style levels, while the accessible chart description and +cell color explain the zero-to-high usage range. The compact focus tooltip keeps +the visible detail to the exact token total and date. Keep all calendar +calculations in one time basis—UTC here—to avoid moving events between days +around daylight-saving transitions. + ## Aggregate into quantitative cells A two-dimensional binned heatmap makes density bounded: the number of rendered diff --git a/packages/charts-core/docs/framework/octane/quick-start.md b/packages/charts-core/docs/framework/octane/quick-start.md index 7dc21447..136fb072 100644 --- a/packages/charts-core/docs/framework/octane/quick-start.md +++ b/packages/charts-core/docs/framework/octane/quick-start.md @@ -182,7 +182,7 @@ same Octane adapter: