Skip to content
Draft
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
6 changes: 6 additions & 0 deletions .changeset/accurate-svg-pointers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@tanstack/charts': patch
---

Correct SVG pointer hit testing when the rendered viewport and chart scene use
different aspect ratios.
79 changes: 79 additions & 0 deletions API-FRICTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,9 @@ 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 | Axis tick styling and edge alignment required shell work | API/Application | monitoring |
| F-167 | SVG letterboxing shifted pointer hit testing | API | resolved |
| F-168 | Fixed catalog height hid compact responsive examples | Tooling/App | resolved |

## Findings

Expand Down Expand Up @@ -3986,3 +3989,79 @@ 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 — 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-167 — 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-168 — 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.
16 changes: 8 additions & 8 deletions benchmarks/bundle-size/universal-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
26 changes: 26 additions & 0 deletions benchmarks/conformance/cases/112-token-usage-calendar/case.json
Original file line number Diff line number Diff line change
@@ -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."
}
}
36 changes: 36 additions & 0 deletions benchmarks/conformance/cases/112-token-usage-calendar/layout.ts
Original file line number Diff line number Diff line change
@@ -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),
)
}
Original file line number Diff line number Diff line change
@@ -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')
})
})
Loading