diff --git a/API-FRICTION.md b/API-FRICTION.md index 5204435b..2e540e61 100644 --- a/API-FRICTION.md +++ b/API-FRICTION.md @@ -2373,6 +2373,18 @@ Each entry records: activation and pan, keyboard, Reset, revisions, visual, and strict type scenarios at 98.6% diagnostic geometry. TanStack uses 512 authored lines and 51.59 kB gzip versus ECharts' 727 lines and 172.82 kB. +- Zoom line-continuity application follow-up: filtering the line to observed + rows inside the accepted window removed the segments that cross fractional + time boundaries. Case 90 now keeps dots on real visible observations and + gives its decorative line exact boundary rows interpolated from the nearest + chronological observations. This preserves the painted segment without + pulling offscreen values into the inferred y domain. Focused model and scene + tests cover shuffled input, stable duplicate timestamps, exact boundaries, + observation-free weekends, invalid windows and values, one-sided data, + line gaps, chart clipping, and definition datum ownership. + The 19 focused tests, full 1,934-test validation, and paired quick browser + matrix pass; the new fractional-boundary screenshot retains visual evidence + at the window that exposed the gap. - Scale-handle follow-up: cases 91 and 92 now use exact-subpath `handleX` in their ordinary definitions. The behavior maps ordered semantic candidates through the final x scale, paints the track, optional rule and handle, and diff --git a/benchmarks/conformance/cases/90-zoomable-time-window/case.json b/benchmarks/conformance/cases/90-zoomable-time-window/case.json index 00952ea8..fc8126da 100644 --- a/benchmarks/conformance/cases/90-zoomable-time-window/case.json +++ b/benchmarks/conformance/cases/90-zoomable-time-window/case.json @@ -156,6 +156,10 @@ } ] }, + { + "type": "screenshot", + "name": "fractional-boundary-line-continuity" + }, { "type": "wheel", "target": { "view": "main", "anchor": "date:2018-01-10" }, diff --git a/benchmarks/conformance/cases/90-zoomable-time-window/example.tsx b/benchmarks/conformance/cases/90-zoomable-time-window/example.tsx index 02400384..7bb8e301 100644 --- a/benchmarks/conformance/cases/90-zoomable-time-window/example.tsx +++ b/benchmarks/conformance/cases/90-zoomable-time-window/example.tsx @@ -16,6 +16,7 @@ import { scaleLinear, scaleUtc } from 'd3-scale' import { selectZoomRows, visibleZoomData, + visibleZoomLineData, zoomDateKey, zoomFullDomain, zoomSpanDays, @@ -49,11 +50,12 @@ export function zoomTimeWindowDefinition( onChange: (window: ZoomXWindow, reason: ZoomXChange) => void, onActiveChange?: (active: boolean) => void, ) { - const rows = visibleZoomData(zoomRows, window) + const visibleRows = visibleZoomData(zoomRows, window) + const lineRows = visibleZoomLineData(zoomRows, window) return defineChart({ marks: [ decorative( - lineY(rows, { + lineY(lineRows, { id: 'zoom-series-line', x: 'Date', y: 'Close', @@ -61,7 +63,7 @@ export function zoomTimeWindowDefinition( strokeWidth: 2.5, }), ), - dot(rows, { + dot(visibleRows, { id: 'zoom-series-points', x: 'Date', y: 'Close', @@ -71,6 +73,7 @@ export function zoomTimeWindowDefinition( strokeWidth: 1, }), ], + clip: true, scales: { x: { scale: scaleUtc().domain([window.start, window.end]), diff --git a/benchmarks/conformance/cases/90-zoomable-time-window/model.test.ts b/benchmarks/conformance/cases/90-zoomable-time-window/model.test.ts new file mode 100644 index 00000000..e73d904c --- /dev/null +++ b/benchmarks/conformance/cases/90-zoomable-time-window/model.test.ts @@ -0,0 +1,146 @@ +import { aapl } from '@tanstack/charts-data/aapl' +import { describe, expect, it } from 'vitest' +import { selectZoomRows, visibleZoomLineData, zoomDateKey } from './model' +import type { ZoomLineRow } from './model' + +const rows = selectZoomRows(aapl) + +describe('visibleZoomLineData', () => { + it('sorts visible rows and interpolates the viewport boundaries', () => { + const reversed = [...rows].reverse() + const originalOrder = reversed.map((row) => row.Date.getTime()) + const lineRows = visibleZoomLineData( + reversed, + dateWindow('2018-01-06', '2018-01-14'), + ) + + expect(keys(lineRows)).toEqual([ + '2018-01-06', + '2018-01-08', + '2018-01-09', + '2018-01-10', + '2018-01-11', + '2018-01-12', + '2018-01-14', + ]) + expect(lineRows[0]?.Close).toBeCloseTo( + row('2018-01-05').Close + + (row('2018-01-08').Close - row('2018-01-05').Close) / 3, + ) + expect(lineRows.at(-1)?.Close).toBeCloseTo( + row('2018-01-12').Close + + (row('2018-01-16').Close - row('2018-01-12').Close) / 2, + ) + expect(reversed.map((row) => row.Date.getTime())).toEqual(originalOrder) + }) + + it('interpolates the segment when the window has no observations', () => { + const lineRows = visibleZoomLineData( + rows, + dateWindow('2018-01-06', '2018-01-07'), + ) + const before = row('2018-01-05') + const after = row('2018-01-08') + + expect(keys(lineRows)).toEqual(['2018-01-06', '2018-01-07']) + expect(lineRows[0]?.Close).toBeCloseTo( + before.Close + (after.Close - before.Close) / 3, + ) + expect(lineRows[1]?.Close).toBeCloseTo( + before.Close + ((after.Close - before.Close) * 2) / 3, + ) + }) + + it('does not synthesize a row where an observation meets the boundary', () => { + expect( + keys(visibleZoomLineData(rows, dateWindow('2018-01-08', '2018-01-16'))), + ).toEqual([ + '2018-01-08', + '2018-01-09', + '2018-01-10', + '2018-01-11', + '2018-01-12', + '2018-01-16', + ]) + expect( + keys(visibleZoomLineData(rows, dateWindow('2018-01-06', '2018-01-12'))), + ).toEqual([ + '2018-01-06', + '2018-01-08', + '2018-01-09', + '2018-01-10', + '2018-01-11', + '2018-01-12', + ]) + }) + + it('keeps duplicate timestamps in stable source order', () => { + const jan8 = row('2018-01-08') + const jan9 = row('2018-01-09') + const jan10 = row('2018-01-10') + const duplicate = { ...jan9, Volume: jan9.Volume + 1 } + const input = [jan10, duplicate, jan8, jan9] + + expect( + visibleZoomLineData(input, dateWindow('2018-01-08', '2018-01-10')), + ).toEqual([jan8, duplicate, jan9, jan10]) + }) + + it('preserves an invalid close as a line gap', () => { + const jan8 = row('2018-01-08') + const gap = { ...row('2018-01-09'), Close: Number.NaN } + const jan10 = row('2018-01-10') + + const lineRows = visibleZoomLineData( + [jan10, gap, jan8], + dateWindow('2018-01-08', '2018-01-10'), + ) + + expect(keys(lineRows)).toEqual(['2018-01-08', '2018-01-09', '2018-01-10']) + expect(lineRows[1]?.Close).toBeNaN() + }) + + it('does not invent a segment around one exact observation', () => { + expect( + keys(visibleZoomLineData(rows, dateWindow('2018-01-09', '2018-01-09'))), + ).toEqual(['2018-01-09']) + }) + + it('returns no segment for empty, invalid, reversed, or one-sided data', () => { + expect( + visibleZoomLineData([], dateWindow('2018-01-06', '2018-01-07')), + ).toEqual([]) + expect( + visibleZoomLineData(rows, dateWindow('2018-01-01', '2018-01-01')), + ).toEqual([]) + expect( + visibleZoomLineData(rows, dateWindow('2018-01-19', '2018-01-20')), + ).toEqual([]) + expect( + visibleZoomLineData(rows, dateWindow('2018-01-10', '2018-01-09')), + ).toEqual([]) + expect( + visibleZoomLineData(rows, { + start: new Date(Number.NaN), + end: new Date(Date.UTC(2018, 0, 9)), + }), + ).toEqual([]) + }) +}) + +function dateWindow(start: string, end: string) { + return { + start: new Date(`${start}T00:00:00.000Z`), + end: new Date(`${end}T00:00:00.000Z`), + } +} + +function keys(input: readonly ZoomLineRow[]) { + return input.map((row) => zoomDateKey(row.Date)) +} + +function row(date: string) { + const match = rows.find((candidate) => zoomDateKey(candidate.Date) === date) + if (!match) throw new Error(`Missing fixture row for ${date}`) + return match +} diff --git a/benchmarks/conformance/cases/90-zoomable-time-window/model.ts b/benchmarks/conformance/cases/90-zoomable-time-window/model.ts index d0483e3b..2ceba792 100644 --- a/benchmarks/conformance/cases/90-zoomable-time-window/model.ts +++ b/benchmarks/conformance/cases/90-zoomable-time-window/model.ts @@ -5,6 +5,11 @@ export interface ZoomWindow { end: Date } +export interface ZoomLineRow { + readonly Date: Date + readonly Close: number +} + export const zoomFullDomain: readonly [Date, Date] = [ new Date(Date.UTC(2018, 0, 2)), new Date(Date.UTC(2018, 0, 18)), @@ -44,6 +49,74 @@ export function visibleZoomData(rows: readonly AaplRow[], window: ZoomWindow) { }) } +export function visibleZoomLineData( + rows: readonly AaplRow[], + window: ZoomWindow, +): readonly ZoomLineRow[] { + const start = window.start.getTime() + const end = window.end.getTime() + if (!Number.isFinite(start) || !Number.isFinite(end) || start > end) return [] + + const ordered = rows + .map((row, index) => ({ row, index, timestamp: row.Date.getTime() })) + .filter(({ timestamp }) => Number.isFinite(timestamp)) + .sort( + (left, right) => + left.timestamp - right.timestamp || left.index - right.index, + ) + const visible: typeof ordered = [] + let before: (typeof ordered)[number] | undefined + let after: (typeof ordered)[number] | undefined + + for (const entry of ordered) { + if (entry.timestamp < start) { + before = entry + continue + } + if (entry.timestamp > end) { + after = entry + break + } + visible.push(entry) + } + + if (!visible.length) { + if (!before || !after) return [] + if (start === end) { + return [interpolateZoomLineRow(before.row, after.row, start)] + } + return [ + interpolateZoomLineRow(before.row, after.row, start), + interpolateZoomLineRow(before.row, after.row, end), + ] + } + + const lineRows: ZoomLineRow[] = visible.map(({ row }) => row) + const first = visible[0]! + const last = visible.at(-1)! + if (before && first.timestamp > start) { + lineRows.unshift(interpolateZoomLineRow(before.row, first.row, start)) + } + if (after && last.timestamp < end) { + lineRows.push(interpolateZoomLineRow(last.row, after.row, end)) + } + return lineRows +} + +function interpolateZoomLineRow( + left: AaplRow, + right: AaplRow, + timestamp: number, +): ZoomLineRow { + const leftTime = left.Date.getTime() + const span = right.Date.getTime() - leftTime + const ratio = span === 0 ? 0 : (timestamp - leftTime) / span + return { + Date: new Date(timestamp), + Close: left.Close + (right.Close - left.Close) * ratio, + } +} + export function zoomSpanDays(window: ZoomWindow) { return (window.end.getTime() - window.start.getTime()) / millisecondsPerDay } diff --git a/benchmarks/conformance/cases/90-zoomable-time-window/tanstack.test.ts b/benchmarks/conformance/cases/90-zoomable-time-window/tanstack.test.ts index 61a0ef4c..27f49329 100644 --- a/benchmarks/conformance/cases/90-zoomable-time-window/tanstack.test.ts +++ b/benchmarks/conformance/cases/90-zoomable-time-window/tanstack.test.ts @@ -8,11 +8,17 @@ import { initialZoomWindow, selectZoomRows, visibleZoomData, + visibleZoomLineData, zoomSpanDays, } from './model' import { mount, zoomTimeWindowDefinition } from './tanstack' import type { AaplRow } from '@tanstack/charts-data/aapl' -import type { ChartDefinition, ChartSpecDatum } from '@tanstack/charts' +import type { + ChartDefinition, + ChartSpecDatum, + SceneNode, + ScenePolyline, +} from '@tanstack/charts' import type { ConformanceInput } from '../../types' const input = { @@ -45,6 +51,65 @@ describe('definition-owned zoomable time window', () => { expect(controls[0]).toMatchObject({ kind: 'zoom-x' }) }) + it('keeps the line continuous through both viewport edges', () => { + const window = { + start: new Date(Date.UTC(2018, 0, 6)), + end: new Date(Date.UTC(2018, 0, 14)), + } + const definition = zoomTimeWindowDefinition(window, () => {}) + const scene = createChartScene(definition, { + width: input.width, + height: input.height, + }) + const line = scenePolylines(scene.nodes).find((node) => + node.key.startsWith('zoom-series-line:'), + ) + const marks = scene.nodes.find( + (node) => node.kind === 'group' && node.key === 'marks', + ) + + expect(definition.clip).toBe(true) + expect(marks?.kind === 'group' ? marks.clip : undefined).toEqual( + scene.chart, + ) + expect(scene.points).toHaveLength(5) + expect( + scene.points.every(({ markId }) => markId === 'zoom-series-points'), + ).toBe(true) + expect(line?.points).toHaveLength(7) + expect(line?.points[0]?.[0]).toBeCloseTo(scene.chart.x) + expect(line?.points.at(-1)?.[0]).toBeCloseTo( + scene.chart.x + scene.chart.width, + ) + }) + + it('draws a bracketing segment through an observation-free window', () => { + const window = { + start: new Date(Date.UTC(2018, 0, 6)), + end: new Date(Date.UTC(2018, 0, 7)), + } + const definition = zoomTimeWindowDefinition(window, () => {}) + const scene = createChartScene(definition, { + width: input.width, + height: input.height, + }) + const lineRows = visibleZoomLineData(rows, window) + const line = scenePolylines(scene.nodes).find((node) => + node.key.startsWith('zoom-series-line:'), + ) + + expect(scene.points).toEqual([]) + expect(line?.points).toHaveLength(2) + expect(line?.points[0]?.[0]).toBeCloseTo(scene.chart.x) + expect(line?.points[1]?.[0]).toBeCloseTo(scene.chart.x + scene.chart.width) + expect(scene.scales.y.domain).toEqual( + lineRows.map((row) => row.Close).sort((left, right) => left - right), + ) + expect( + scene.nodes.find((node) => node.kind === 'group' && node.key === 'marks'), + ).toMatchObject({ clip: scene.chart }) + }) + it('accepts keyboard changes, external reset, and responsive updates', () => { const container = document.createElement('div') document.body.append(container) @@ -89,6 +154,30 @@ describe('definition-owned zoomable time window', () => { }) expect(document.activeElement).toBe(surface) + const svg = container.querySelector('svg.ts-chart') + if (!svg) throw new Error('Expected a rendered chart SVG') + svg.getBoundingClientRect = () => + ({ left: 100, top: 200, width: 640, height: 360 }) as DOMRect + const lineGeometry = driver.geometry?.({ view: 'main', role: 'line' })[0] + if (!lineGeometry) throw new Error('Expected clipped line geometry') + expect(driver.geometry?.({ view: 'main', role: 'dot' })).toHaveLength(5) + const narrowedScene = createChartScene( + zoomTimeWindowDefinition( + { + start: new Date(Date.UTC(2018, 0, 6)), + end: new Date(Date.UTC(2018, 0, 14)), + }, + () => {}, + ), + { width: input.width, height: input.height }, + ) + expect(lineGeometry.x).toBeCloseTo(100 + narrowedScene.chart.x) + expect(lineGeometry.width).toBeCloseTo(narrowedScene.chart.width) + expect(lineGeometry.y).toBeGreaterThanOrEqual(200 + narrowedScene.chart.y) + expect(lineGeometry.y + lineGeometry.height).toBeLessThanOrEqual( + 200 + narrowedScene.chart.y + narrowedScene.chart.height, + ) + act(() => { handle.update({ ...input, revision: 1 }) }) @@ -165,3 +254,12 @@ describe('definition-owned zoomable time window', () => { expect(view).toContain('data-conformance-zoom-reset') }) }) + +function scenePolylines(nodes: readonly SceneNode[]): ScenePolyline[] { + const result: ScenePolyline[] = [] + for (const node of nodes) { + if (node.kind === 'polyline') result.push(node) + if (node.kind === 'group') result.push(...scenePolylines(node.children)) + } + return result +} diff --git a/benchmarks/conformance/cases/90-zoomable-time-window/tanstack.ts b/benchmarks/conformance/cases/90-zoomable-time-window/tanstack.ts index 76d6fb5f..f768add5 100644 --- a/benchmarks/conformance/cases/90-zoomable-time-window/tanstack.ts +++ b/benchmarks/conformance/cases/90-zoomable-time-window/tanstack.ts @@ -20,6 +20,7 @@ import { initialZoomWindow, selectZoomRows, visibleZoomData, + visibleZoomLineData, zoomDateFromAnchor, zoomDateKey, zoomSpanDays, @@ -152,12 +153,12 @@ function zoomGeometry( const bounds = svg.getBoundingClientRect() const scaleX = bounds.width / scene.width const scaleY = bounds.height / scene.height - const points = visibleZoomData(zoomRows, window).map( + const visiblePoints = visibleZoomData(zoomRows, window).map( (row) => [scene.scales.x.map(row.Date), scene.scales.y.map(row.Close)] as const, ) if (query.role === 'dot') { - return points.map(([x, y]) => ({ + return visiblePoints.map(([x, y]) => ({ x: bounds.left + (x - 3.5) * scaleX, y: bounds.top + (y - 3.5) * scaleY, width: 7 * scaleX, @@ -166,7 +167,12 @@ function zoomGeometry( })) } if (query.role !== 'line') return [] - const sample = clientPointBounds(points, bounds, { + const linePoints = visibleZoomLineData(zoomRows, window).map( + (row) => + [scene.scales.x.map(row.Date), scene.scales.y.map(row.Close)] as const, + ) + if (linePoints.length < 2) return [] + const sample = clientPointBounds(linePoints, bounds, { scaleX, scaleY, paint: color, diff --git a/benchmarks/conformance/catalog-index.json b/benchmarks/conformance/catalog-index.json index 9d3c84f0..173cbeb8 100644 --- a/benchmarks/conformance/catalog-index.json +++ b/benchmarks/conformance/catalog-index.json @@ -6392,6 +6392,10 @@ } ] }, + { + "type": "screenshot", + "name": "fractional-boundary-line-continuity" + }, { "type": "wheel", "target": { diff --git a/benchmarks/conformance/previews/90-zoomable-time-window.svg b/benchmarks/conformance/previews/90-zoomable-time-window.svg index 405e40a1..11bc9f3c 100644 --- a/benchmarks/conformance/previews/90-zoomable-time-window.svg +++ b/benchmarks/conformance/previews/90-zoomable-time-window.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/manifest.json b/benchmarks/conformance/previews/manifest.json index e1176205..705fc05e 100644 --- a/benchmarks/conformance/previews/manifest.json +++ b/benchmarks/conformance/previews/manifest.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "width": 288, "height": 192, - "sourceHash": "d3177faf65fca2414b431c195a003cf9afdceb8ae2f1b37e4f8cb81578dc8293", + "sourceHash": "47054ba4a4cf94a127a28f3dddf74635da4b2acbf24b0dc793c89ae159e2c3be", "assets": [ { "id": "01-line-gaps", @@ -401,8 +401,8 @@ }, { "id": "90-zoomable-time-window", - "sha256": "0278fb0d29f8cf41fb2f3309b8d5850b525a14b2a701d78824d86afdd526ca5c", - "bytes": 3875 + "sha256": "442a60e1a5a18d2226862b177d3c44fd75637104f0a2a9220c84cb33540f5490", + "bytes": 4050 }, { "id": "91-timeline-playback-scrubber",