From 04b1ccbd679eb38a99059a625832aac70ffbbf4c Mon Sep 17 00:00:00 2001 From: Kyle Gill Date: Fri, 31 Jul 2026 21:07:28 -0600 Subject: [PATCH 1/2] Fix SVG pointer mapping and add token calendar --- .changeset/accurate-svg-pointers.md | 6 + API-FRICTION.md | 100 +++++++++++ .../bundle-size/universal-baseline.json | 16 +- .../cases/112-token-usage-calendar/case.json | 26 +++ .../cases/112-token-usage-calendar/layout.ts | 36 ++++ .../112-token-usage-calendar/model.test.ts | 91 ++++++++++ .../cases/112-token-usage-calendar/model.ts | 157 ++++++++++++++++++ .../cases/112-token-usage-calendar/plot.ts | 60 +++++++ .../112-token-usage-calendar/shell.test.ts | 56 +++++++ .../cases/112-token-usage-calendar/shell.ts | 107 ++++++++++++ .../112-token-usage-calendar/tanstack.ts | 93 +++++++++++ .../cases/25-calendar-heatmap/case.json | 2 +- docs/examples/heatmaps-and-densities.md | 30 ++++ docs/framework/octane/quick-start.md | 2 +- .../docs/examples/heatmaps-and-densities.md | 30 ++++ .../docs/framework/octane/quick-start.md | 2 +- packages/charts-core/src/svg-surface.test.ts | 92 ++++++++++ packages/charts-core/src/svg-surface.ts | 26 ++- scripts/catalog-definition-shapes.test.mjs | 3 +- 19 files changed, 918 insertions(+), 17 deletions(-) create mode 100644 .changeset/accurate-svg-pointers.md create mode 100644 benchmarks/conformance/cases/112-token-usage-calendar/case.json create mode 100644 benchmarks/conformance/cases/112-token-usage-calendar/layout.ts create mode 100644 benchmarks/conformance/cases/112-token-usage-calendar/model.test.ts create mode 100644 benchmarks/conformance/cases/112-token-usage-calendar/model.ts create mode 100644 benchmarks/conformance/cases/112-token-usage-calendar/plot.ts create mode 100644 benchmarks/conformance/cases/112-token-usage-calendar/shell.test.ts create mode 100644 benchmarks/conformance/cases/112-token-usage-calendar/shell.ts create mode 100644 benchmarks/conformance/cases/112-token-usage-calendar/tanstack.ts create mode 100644 packages/charts-core/src/svg-surface.test.ts 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 10343d2f..436a52d7 100644 --- a/API-FRICTION.md +++ b/API-FRICTION.md @@ -201,6 +201,10 @@ Each entry records: | F-163 | Cross-row transforms lacked a public ownership boundary | API | resolved | | F-164 | Sankey widths required a custom scene renderer | API | resolved | | F-165 | Incidental D3 utilities leaked into core paths | API/Tooling | resolved | +| F-166 | Conditional Plot paint filtered ordinary cells | Tooling/Docs | resolved | +| F-167 | Axis tick styling and edge alignment required shell work | API/Application | monitoring | +| F-168 | SVG letterboxing shifted pointer hit testing | API | resolved | +| F-169 | Fixed catalog height hid compact responsive examples | Tooling/App | resolved | ## Findings @@ -3986,3 +3990,99 @@ Each entry records: every `d3-*` module plus `internmap` from compact consumers, while selected transform, polar, geo, and curve features retain their owned D3 implementation. + +### F-166 — Conditional Plot paint filtered ordinary cells + +- Status: resolved +- Severity: low +- Owner: Tooling/Documentation +- Observed in: daily token usage calendar conformance reference +- Friction: one `Plot.cell` mark returned an amber stroke for missing telemetry + and `null` for ordinary days. Plot treated the null paint channel as a row + filter, so the reference emitted only the four missing cells instead of the + complete 182-day calendar. The failure first appeared as a geometry and + paint mismatch rather than an obvious authoring error. +- Decision: keep all shared channels on the cell mark. The final application + removed the synthetic missing-telemetry state because its zero-token tooltip + made the extra gray category confusing. This remains a reference-authoring + concern and does not justify inference or a workaround in the TanStack Charts + API. +- Verification: the focused model and shell tests render all 364 cells with one + neutral zero-usage category, while the TanStack Charts and Observable Plot + references retain matching color domains and accessible descriptions. + +### F-167 — 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-168 — 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: focused regressions reproduce the gallery dimensions and its + 13.5-pixel vertical letterbox, verify the exact scene coordinate, and cover + the incomplete-DOM fallback. The SVG surface and renderer tests pass; + browser verification confirms the calendar focus marker and tooltip match + the hovered cell. The reviewed shared-path cost is 176 minified bytes and + 70–81 gzip bytes across the locked DOM and React consumers, recorded in the + updated universal bundle baseline. + +### F-169 — 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. diff --git a/benchmarks/bundle-size/universal-baseline.json b/benchmarks/bundle-size/universal-baseline.json index ec3e2747..26c8154d 100644 --- a/benchmarks/bundle-size/universal-baseline.json +++ b/benchmarks/bundle-size/universal-baseline.json @@ -15,24 +15,24 @@ "gzip": 21428 }, "TanStack DOM host": { - "bytes": 38770, - "gzip": 14229 + "bytes": 38946, + "gzip": 14310 }, "React adapter": { - "bytes": 40937, - "gzip": 14924 + "bytes": 41113, + "gzip": 14994 }, "React line consumer": { - "bytes": 63516, - "gzip": 23973 + "bytes": 63692, + "gzip": 24053 }, "Compact-scale line scene": { "bytes": 21550, "gzip": 8118 }, "React compact-scale line consumer": { - "bytes": 46033, - "gzip": 16876 + "bytes": 46209, + "gzip": 16956 }, "Custom-scale line scene": { "bytes": 19734, diff --git a/benchmarks/conformance/cases/112-token-usage-calendar/case.json b/benchmarks/conformance/cases/112-token-usage-calendar/case.json new file mode 100644 index 00000000..f87a207a --- /dev/null +++ b/benchmarks/conformance/cases/112-token-usage-calendar/case.json @@ -0,0 +1,26 @@ +{ + "schemaVersion": 1, + "order": 1130, + "id": "112-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" + ], + "geometry": [{ "role": "cell", "count": 364 }], + "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/112-token-usage-calendar/layout.ts b/benchmarks/conformance/cases/112-token-usage-calendar/layout.ts new file mode 100644 index 00000000..17e03049 --- /dev/null +++ b/benchmarks/conformance/cases/112-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/112-token-usage-calendar/model.test.ts b/benchmarks/conformance/cases/112-token-usage-calendar/model.test.ts new file mode 100644 index 00000000..221d2ed1 --- /dev/null +++ b/benchmarks/conformance/cases/112-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/112-token-usage-calendar/model.ts b/benchmarks/conformance/cases/112-token-usage-calendar/model.ts new file mode 100644 index 00000000..4b7cf2b4 --- /dev/null +++ b/benchmarks/conformance/cases/112-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/112-token-usage-calendar/plot.ts b/benchmarks/conformance/cases/112-token-usage-calendar/plot.ts new file mode 100644 index 00000000..8fdc2fb1 --- /dev/null +++ b/benchmarks/conformance/cases/112-token-usage-calendar/plot.ts @@ -0,0 +1,60 @@ +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, { + x: 'week', + y: 'weekday', + fill: 'level', + inset: 0, + rx: 3, + title: formatTokenUsage, + }), + ], + }) + }) + +export const mount = withTokenActivityShell(mountPlot) diff --git a/benchmarks/conformance/cases/112-token-usage-calendar/shell.test.ts b/benchmarks/conformance/cases/112-token-usage-calendar/shell.test.ts new file mode 100644 index 00000000..db1223e0 --- /dev/null +++ b/benchmarks/conformance/cases/112-token-usage-calendar/shell.test.ts @@ -0,0 +1,56 @@ +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, { interactive: true }) + const handle = mount(container, { width: 320, height: 180, revision: 0 }) + 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 }) + 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/112-token-usage-calendar/shell.ts b/benchmarks/conformance/cases/112-token-usage-calendar/shell.ts new file mode 100644 index 00000000..ca0971f9 --- /dev/null +++ b/benchmarks/conformance/cases/112-token-usage-calendar/shell.ts @@ -0,0 +1,107 @@ +import type { ConformanceInput, ConformanceMount } from '../../types' +import { calendarChartHeight } from './layout' + +interface TokenActivityShellOptions { + readonly interactive?: boolean +} + +export function withTokenActivityShell( + mountChart: ConformanceMount, + options: TokenActivityShellOptions = {}, +): ConformanceMount { + return (container, input) => { + 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, options)) + alignFirstMonthLabel(chartSurface) + + return { + update(nextInput) { + resizeShell(container, shell, chartSurface, nextInput) + chart.update(chartInput(nextInput, options)) + alignFirstMonthLabel(chartSurface) + }, + destroy() { + chart.destroy() + shell.remove() + container.style.minHeight = previousMinHeight + }, + } + } +} + +function chartInput( + input: ConformanceInput, + options: TokenActivityShellOptions, +): ConformanceInput { + return { + ...input, + height: calendarChartHeight(input.width), + ...(options.interactive ? { interactive: true } : {}), + } +} + +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 height = calendarChartHeight(input.width) + container.style.minHeight = `${height}px` + shell.style.height = `${height}px` + chartSurface.style.height = `${height}px` +} diff --git a/benchmarks/conformance/cases/112-token-usage-calendar/tanstack.ts b/benchmarks/conformance/cases/112-token-usage-calendar/tanstack.ts new file mode 100644 index 00000000..d3b319fd --- /dev/null +++ b/benchmarks/conformance/cases/112-token-usage-calendar/tanstack.ts @@ -0,0 +1,93 @@ +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, + }, + ), + { interactive: true }, +) 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/docs/examples/heatmaps-and-densities.md b/docs/examples/heatmaps-and-densities.md index 015e546f..07f15feb 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: