diff --git a/apps/www/src/app/examples/timeline-stress/page.tsx b/apps/www/src/app/examples/timeline-stress/page.tsx new file mode 100644 index 000000000..e679c7a13 --- /dev/null +++ b/apps/www/src/app/examples/timeline-stress/page.tsx @@ -0,0 +1,435 @@ +'use client'; + +import { + Button, + // biome-ignore lint/suspicious/noShadowRestrictedNames: legitimate export name + DataView, + type DataViewField, + Flex, + Text, + type TimelineCardContext, + type TimelineMarker +} from '@raystack/apsara'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +/** + * Timeline virtualization harness. + * + * The doc demos run a dozen cards, which says nothing about what the renderer + * does at scale. This drives it at 1k–50k so the culling is observable: the + * readout counts what is actually in the DOM and times the frames the browser + * spends while scrolling, which is the number jsdom cannot produce. + * + * Toggle `virtualized` off at 10k to see the difference — and expect the tab + * to struggle, which is the point. + */ + +type Task = { + id: string; + title: string; + team: string; + status: 'todo' | 'active' | 'done'; + start: string; + end: string; +}; + +const DAY_MS = 86_400_000; +const TEAMS = ['Eng', 'Design', 'Ops', 'Data', 'Support']; +const STATUSES: Task['status'][] = ['todo', 'active', 'done']; +const DOMAIN_START = Date.parse('2025-01-01T00:00:00.000Z'); +/** Domain lengths the harness can switch between, in days. */ +const SPANS = { '1 year': 365, '5 years': 1826 } as const; +type SpanLabel = keyof typeof SPANS; + +const isoDay = (ms: number) => new Date(ms).toISOString().slice(0, 10); + +/** + * Hoisted so the reference is stable. The timeline memoizes its resolved + * markers on this prop, and an inline literal would invalidate that memo on + * every commit — churn this page would then report as its own frame cost. + */ +const MARKERS: TimelineMarker[] = [ + { date: '2025-07-01', label: 'H2', variant: 'accent' } +]; + +/** Seeded LCG — the same row count always renders the same canvas. */ +function seededRandom(seed: number) { + let state = seed; + return () => { + state = (state * 1664525 + 1013904223) >>> 0; + return state / 0x100000000; + }; +} + +function makeTasks(count: number, domainDays: number): Task[] { + const random = seededRandom(count + domainDays); + return Array.from({ length: count }, (_, i) => { + const startDay = Math.floor(random() * domainDays); + // Mostly short spans with a long tail, so lanes pack unevenly the way + // real schedules do rather than into a tidy grid. + const span = 1 + Math.floor(random() ** 3 * 40); + return { + id: `t${i}`, + title: `Task ${i}`, + team: TEAMS[i % TEAMS.length], + status: STATUSES[i % STATUSES.length], + start: new Date(DOMAIN_START + startDay * DAY_MS).toISOString(), + end: new Date(DOMAIN_START + (startDay + span) * DAY_MS).toISOString() + }; + }); +} + +const fields: DataViewField[] = [ + { accessorKey: 'title', label: 'Title', sortable: true }, + { + accessorKey: 'team', + label: 'Team', + groupable: true, + showGroupCount: true, + filterable: true, + filterType: 'select', + filterOptions: TEAMS.map(team => ({ label: team, value: team })) + }, + { accessorKey: 'status', label: 'Status', groupable: true, filterable: true } +]; + +const STATUS_COLOR: Record = { + todo: 'var(--rs-color-background-neutral-primary)', + active: 'var(--rs-color-background-accent-primary)', + done: 'var(--rs-color-background-success-primary)' +}; + +/** + * Card height is fixed at 64 to sit inside the 66px lane pitch. Under + * `virtualized` the lane height is exactly `estimatedRowHeight`, so a taller + * card would overflow into the lane below instead of growing its own. + */ +function TaskCard({ + task, + context +}: { + task: Task; + context: TimelineCardContext; +}) { + return ( +
+ {context.collapsed ? '•' : task.title} +
+ ); +} + +interface Stats { + cards: number; + nodes: number; + ticks: number; + bands: number; + groupSlots: number; + canvasWidth: number; + canvasHeight: number; + /** + * Height of a single gridline. These are pinned `top: 0; bottom: 0`, so + * unclamped they are as tall as the whole canvas and their rasterization + * cost tracks the domain rather than the viewport — the dominant scroll cost + * before the clamp landed. Worth watching directly: it should stay near the + * pane height, not the canvas height. + */ + gridlineHeight: number; +} + +/** + * Worst frame over the last second of scrolling. A mean would hide exactly + * what matters — one 200ms frame is a visible stall no average survives. + * + * Takes the element rather than a ref: the pane is found by query after the + * timeline paints, and assigning a ref does not re-run an effect, so a + * ref-based version silently never attaches its listener. + */ +function useFrameMonitor(pane: HTMLElement | null) { + const [worstFrame, setWorstFrame] = useState(0); + const frameRef = useRef(null); + /** Deadline for the sampling loop, pushed out by each new scroll event. */ + const untilRef = useRef(0); + + const measure = useCallback(() => { + // Extend the window while the user keeps scrolling, rather than sampling a + // fixed second from the first event and ignoring the rest of the drag. + untilRef.current = performance.now() + 1000; + if (frameRef.current !== null) return; + let worst = 0; + let last = performance.now(); + const step = () => { + const now = performance.now(); + const delta = now - last; + last = now; + if (delta > worst) worst = delta; + if (now < untilRef.current) { + frameRef.current = requestAnimationFrame(step); + } else { + frameRef.current = null; + setWorstFrame(Math.round(worst)); + } + }; + frameRef.current = requestAnimationFrame(step); + }, []); + + useEffect(() => { + if (!pane) return; + pane.addEventListener('scroll', measure, { passive: true }); + return () => { + pane.removeEventListener('scroll', measure); + if (frameRef.current !== null) cancelAnimationFrame(frameRef.current); + }; + }, [pane, measure]); + + return worstFrame; +} + +export default function TimelineStressPage() { + const [rowCount, setRowCount] = useState(10_000); + const [spanLabel, setSpanLabel] = useState('1 year'); + const [virtualized, setVirtualized] = useState(true); + const [onePerRow, setOnePerRow] = useState(false); + const [grouped, setGrouped] = useState(false); + const [stats, setStats] = useState(null); + const [buildMs, setBuildMs] = useState(0); + const [stableRenderCard, setStableRenderCard] = useState(true); + + // State, not a ref: the frame monitor's effect has to re-run once the pane + // exists, and the pane is only found after the timeline paints. + const [pane, setPane] = useState(null); + const worstFrame = useFrameMonitor(pane); + + const domainDays = SPANS[spanLabel]; + const tasks = useMemo( + () => makeTasks(rowCount, domainDays), + [rowCount, domainDays] + ); + // Generation and the axis read the same day count, so the domain can never + // drift out from under the data. + const range = useMemo( + () => + [ + isoDay(DOMAIN_START), + isoDay(DOMAIN_START + (domainDays - 1) * DAY_MS) + ] as [string, string], + [domainDays] + ); + + /** + * The card memo compares `renderCard` by identity, so an inline arrow — what + * a consumer writes by default — makes every mounted card re-render on every + * commit. This toggle isolates that cost from the canvas's own. + */ + const memoizedRenderCard = useCallback( + (row: { original: Task }, context: TimelineCardContext) => ( + + ), + [] + ); + + // Remount on every switch: mount cost is part of what's being measured, and + // a stale canvas would otherwise linger under the new settings. + const runKey = `${rowCount}-${domainDays}-${virtualized}-${onePerRow}-${grouped}`; + + // biome-ignore lint/correctness/useExhaustiveDependencies: `runKey` isn't read here — it's the remount signal, and re-running on it is the point. + useEffect(() => { + const start = performance.now(); + // After paint, so the number covers layout of what actually mounted. + const id = requestAnimationFrame(() => { + const pane = document.querySelector( + '[data-slot="data-view-timeline"]' + ); + setPane(pane); + if (!pane) return; + const count = (slot: string) => + pane.querySelectorAll(`[data-slot="data-view-timeline-${slot}"]`) + .length; + const canvas = pane.querySelector('[role="list"]'); + const gridline = pane.querySelector( + '[data-slot="data-view-timeline-gridline"]' + ); + setBuildMs(Math.round(performance.now() - start)); + setStats({ + cards: count('card'), + nodes: pane.querySelectorAll('*').length, + ticks: count('axis-tick'), + bands: count('axis-band'), + groupSlots: count('group-slot'), + canvasWidth: canvas ? Math.round(canvas.offsetWidth) : 0, + canvasHeight: canvas ? Math.round(canvas.offsetHeight) : 0, + gridlineHeight: gridline ? Math.round(gridline.offsetHeight) : 0 + }); + }); + return () => cancelAnimationFrame(id); + // Re-measure whenever the run changes — `runKey` also remounts the view. + }, [runKey]); + + const stat = (label: string, value: string | number) => ( + + + {label} + + + {value} + + + ); + + return ( + + + Timeline virtualization stress + + Scroll the canvas in both directions and watch the counts. Culling is + bounded by the viewport, so they should barely move with row count. + + + + + + + Rows + + + {[1_000, 10_000, 50_000].map(count => ( + + ))} + + + + + Span + + + {(Object.keys(SPANS) as SpanLabel[]).map(label => ( + + ))} + + + + + Options + + + {( + [ + ['virtualized', virtualized, setVirtualized], + ['one lane per row', onePerRow, setOnePerRow], + ['group by team', grouped, setGrouped], + ['stable renderCard', stableRenderCard, setStableRenderCard] + ] as const + ).map(([label, on, set]) => ( + + ))} + + + + + + {stat('Rows', rowCount.toLocaleString())} + {stat('Cards in DOM', stats?.cards.toLocaleString() ?? '—')} + {stat('DOM nodes', stats?.nodes.toLocaleString() ?? '—')} + {stat('Tick labels', stats?.ticks ?? '—')} + {stat('Month bands', stats?.bands ?? '—')} + {stat('Group slots', stats?.groupSlots ?? '—')} + {stat('Canvas', `${stats?.canvasWidth.toLocaleString() ?? '—'}px`)} + {stat( + 'Canvas height', + `${stats?.canvasHeight.toLocaleString() ?? '—'}px` + )} + {stat( + 'Gridline height', + `${stats?.gridlineHeight.toLocaleString() ?? '—'}px` + )} + {stat('Mount', `${buildMs}ms`)} + {stat('Worst frame', worstFrame ? `${worstFrame}ms` : 'scroll me')} + + + + + key={runKey} + data={tasks} + fields={fields} + mode='client' + defaultSort={{ name: 'start', order: 'asc' }} + query={grouped ? { group_by: ['team'] } : undefined} + getRowId={task => task.id} + > + + + + + + + startField='start' + endField='end' + range={range} + scale='day' + unitWidth={40} + virtualized={virtualized} + lanePacking={onePerRow ? 'one-per-row' : 'auto'} + defaultScrollTo='start' + markers={MARKERS} + renderCard={ + stableRenderCard + ? memoizedRenderCard + : (row, context) => ( + + ) + } + /> + + + + + ); +} diff --git a/apps/www/src/content/docs/components/dataview/index.mdx b/apps/www/src/content/docs/components/dataview/index.mdx index bf642aeab..a4eb369b6 100644 --- a/apps/www/src/content/docs/components/dataview/index.mdx +++ b/apps/www/src/content/docs/components/dataview/index.mdx @@ -435,7 +435,7 @@ The Timeline owns **positioning** — the time scale (date → x, span → width `context.collapsed` flips when the span is narrower than `minCardWidth` (default 60px) — render a compact stub instead of letting the full card clip (point cards never collapse; they size to their content). Wrap card fields in `DataView.DisplayAccess` so the toolbar's Display Properties toggles reach them. -Card height is content-driven, the same contract as `DataView.List` rows: cards auto-measure after paint, each lane sizes to its tallest card, and `estimatedRowHeight` (default 66) is only a layout hint until real heights arrive. Give your card an explicit height if you want uniform cards. Keep `renderCard` referentially stable (define it outside the component or wrap it in `useCallback`) — cards are memoized against it, and an inline closure forces every visible card to re-render on each scroll frame. +Card height is content-driven, the same contract as `DataView.List` rows: cards auto-measure after paint, each lane sizes to its tallest card, and `estimatedRowHeight` (default 66) is only a layout hint until real heights arrive. Under `virtualized` this inverts — a culled card never reports a height, so measuring would resize lanes as you scroll and shift every lane below them. Lanes there take a fixed `estimatedRowHeight` pitch, and a card taller than it overlaps the lane below instead of growing its own. Give your card an explicit height if you want uniform cards, and keep that height within the pitch if you virtualize. Keep `renderCard` referentially stable (define it outside the component or wrap it in `useCallback`) — cards are memoized against it, and an inline closure forces every visible card to re-render on each scroll frame. ### Point markers @@ -554,8 +554,8 @@ Start with `isLoading={true}` and fire an initial fetch on mount: with no data a ### Notes - **Grouping** renders as swim-lane sections and **sorting** only reaches `lanePacking="one-per-row"` — see [Grouping](#grouping) and [Ordering](#ordering). Hide a control that has no meaning for your configuration (``), or pass the timeline a per-view `fields` override without `sortable`/`groupable`. -- **Vertical space is not virtualized.** `virtualized` culls horizontally only, so a grouped timeline renders every section's cards that fall in the visible time window. Deep grouping over thousands of rows will render a tall canvas. -- **`virtualized`** enables horizontal culling: only cards and gridlines near the viewport render. Recommended whenever the domain is long or rows are numerous. +- **`virtualized`** culls both axes: cards, gridlines, tick labels, month bands, and markers render only near the viewport, and the grid and marker lines span the visible window rather than the full canvas height. A frame costs what is on screen rather than what is in the data, so a long domain and deep grouping stay affordable. It defaults to `false` — pass it explicitly. Recommended whenever the domain is long or rows are numerous. +- **Without `virtualized`, nothing is culled vertically.** Every lane in the domain stays mounted and every card in the visible time window renders, so deep grouping over thousands of rows builds a tall, fully populated canvas. The trade is content-driven lane heights (see [Cards](#cards)), which virtualization gives up. - **Interaction** — cards receive row clicks via the root's `onRowClick`; the background supports mouse drag-to-pan with a momentum glide; scrolling past the domain edge won't trigger browser back-swipe. The pane is a focusable, labelled region (`aria-label`, default "Timeline"), so keyboard users can Tab to it and scroll with the arrow keys. ## Accessibility diff --git a/packages/raystack/components/data-view/__tests__/helpers.ts b/packages/raystack/components/data-view/__tests__/helpers.ts new file mode 100644 index 000000000..ab4bb06de --- /dev/null +++ b/packages/raystack/components/data-view/__tests__/helpers.ts @@ -0,0 +1,37 @@ +/** + * Fixtures shared by the data-view util suites. + * + * Kept in one place because `pack-lanes.test.ts` pins recorded goldens built + * from these generators: a golden only means something if the data behind it + * cannot drift, and two copies of an LCG eventually stop agreeing. + */ + +/** Seeded LCG — a failing case has to be reproducible. */ +export function seededRandom(seed: number) { + let state = seed; + return () => { + state = (state * 1664525 + 1013904223) >>> 0; + return state / 0x100000000; + }; +} + +/** FNV-1a over the decimal text, so [1, 23] and [12, 3] can't collide. */ +export function digest(values: readonly number[]): string { + let hash = 0x811c9dc5; + for (const value of values) { + const text = `${value},`; + for (let i = 0; i < text.length; i++) { + hash ^= text.charCodeAt(i); + hash = Math.imul(hash, 0x01000193) >>> 0; + } + } + return hash.toString(16).padStart(8, '0'); +} + +export const randomItems = (seed: number, count: number) => { + const random = seededRandom(seed); + return Array.from({ length: count }, () => ({ + x: Math.round(random() * 10000), + width: Math.round(random() * 200) + })); +}; diff --git a/packages/raystack/components/data-view/__tests__/order-by-x.test.ts b/packages/raystack/components/data-view/__tests__/order-by-x.test.ts new file mode 100644 index 000000000..e95664041 --- /dev/null +++ b/packages/raystack/components/data-view/__tests__/order-by-x.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from 'vitest'; +import { orderByX } from '../utils/order-by-x'; +import { seededRandom } from './helpers'; + +/** + * `orderByX` returns indices ascending by `x`, ties broken by input order. + * + * It has two implementations behind one signature — a comparison sort below 64 + * items, a counting sort at or above it — so most of these run a differential + * against `Array#sort`, which is the specification the counting sort has to + * reproduce exactly (including the tie-break, which lane packing depends on). + */ + +const BUCKET_SORT_MIN_ITEMS = 64; + +/** The order the counting sort has to match. */ +const referenceOrder = (items: { x: number }[]) => + items.map((_, i) => i).sort((a, b) => items[a].x - items[b].x || a - b); + +describe('orderByX', () => { + const sortedXs = (items: { x: number }[]) => + Array.from(orderByX(items), index => items[index].x); + + it('returns an empty order for empty input', () => { + expect(Array.from(orderByX([]))).toEqual([]); + }); + + it('returns the only index for a single item', () => { + expect(Array.from(orderByX([{ x: 42 }]))).toEqual([0]); + }); + + it('orders ascending by x, breaking ties by input order', () => { + const items = [{ x: 30 }, { x: 10 }, { x: 30 }, { x: -5 }]; + expect(Array.from(orderByX(items))).toEqual([3, 1, 0, 2]); + }); + + /* Past 64 items orderByX swaps its comparison sort for a counting sort, so + the rest of these run above that threshold. */ + + it('agrees with itself either side of the counting-sort threshold', () => { + // The implementation is chosen by item count, so the same data must order + // identically at 63 items and at 64 — otherwise adding one card silently + // repacks the lanes. Ties are dense here to put the tie-break under load. + const items = Array.from({ length: BUCKET_SORT_MIN_ITEMS }, (_, i) => ({ + x: (i % 8) * 50 + })); + const below = items.slice(0, BUCKET_SORT_MIN_ITEMS - 1); + expect(Array.from(orderByX(below))).toEqual(referenceOrder(below)); + expect(Array.from(orderByX(items))).toEqual(referenceOrder(items)); + }); + + it('matches a comparison sort on randomly spread items', () => { + const random = seededRandom(7); + for (let round = 0; round < 20; round++) { + const items = Array.from({ length: 500 }, () => ({ + x: Math.round((random() - 0.5) * 20000) + })); + expect(Array.from(orderByX(items))).toEqual(referenceOrder(items)); + } + }); + + it('matches a comparison sort when every x is negative', () => { + // `minX` is negative, so the bucket index is driven entirely by the offset + // rather than by x itself — the case where a missing `- minX` still looks + // correct on non-negative data. + const random = seededRandom(13); + const items = Array.from({ length: 400 }, () => ({ + x: -Math.round(random() * 50000) - 1 + })); + expect(Array.from(orderByX(items))).toEqual(referenceOrder(items)); + }); + + it('keeps input order when every item shares one x', () => { + // Zero extent — nothing to bucket by, and the tie-break is input order. + const items = Array.from({ length: 100 }, () => ({ x: 42 })); + expect(Array.from(orderByX(items))).toEqual( + Array.from({ length: 100 }, (_, i) => i) + ); + }); + + it('sorts a bucket deeper than the insertion-sort cutoff', () => { + // 80 items on one x land in a single bucket, past the depth where that + // bucket hands off to a comparison sort. + const items = [ + ...Array.from({ length: 80 }, () => ({ x: 100 })), + ...Array.from({ length: 40 }, (_, i) => ({ x: 900 - i })) + ]; + expect(sortedXs(items)).toEqual( + items.map(item => item.x).sort((a, b) => a - b) + ); + }); + + it('orders items clustered at both ends of the extent', () => { + // A near-empty middle makes the uniform bucket split maximally uneven. + const items = [ + ...Array.from({ length: 60 }, (_, i) => ({ x: i / 1000 })), + ...Array.from({ length: 60 }, (_, i) => ({ x: 10000 + i / 1000 })) + ]; + expect(sortedXs(items)).toEqual( + items.map(item => item.x).sort((a, b) => a - b) + ); + }); + + it('still returns a usable permutation for non-finite x', () => { + // Non-finite geometry shouldn't reach here — x comes from the time scale — + // but a NaN must not corrupt the ordering of the cards around it or drop + // an index, which would lose a card from the canvas entirely. Ordering + // *among* non-finite values is not asserted: `a.x - b.x` is NaN for those + // pairs, so no total order exists to assert against. + const cases: { x: number }[][] = [ + Array.from({ length: 70 }, (_, i) => ({ + x: i === 30 ? Number.NaN : i * 10 + })), + Array.from({ length: 70 }, (_, i) => ({ + x: i === 5 ? Number.POSITIVE_INFINITY : i * 10 + })), + Array.from({ length: 70 }, () => ({ x: Number.NaN })) + ]; + for (const items of cases) { + const order = Array.from(orderByX(items)); + expect(order.length).toBe(items.length); + expect([...order].sort((a, b) => a - b)).toEqual( + Array.from({ length: items.length }, (_, i) => i) + ); + } + }); + + it('orders the finite items correctly around an infinity', () => { + // Infinity widens the extent to the point where every finite item buckets + // together, which forces the deep-bucket comparison fallback. The finite + // cards must still come out in order. + const items = [ + ...Array.from({ length: 69 }, (_, i) => ({ x: 690 - i * 10 })), + { x: Number.POSITIVE_INFINITY } + ]; + const finite = Array.from(orderByX(items)) + .map(index => items[index].x) + .filter(Number.isFinite); + expect(finite).toEqual([...finite].sort((a, b) => a - b)); + }); +}); diff --git a/packages/raystack/components/data-view/__tests__/pack-lanes.test.ts b/packages/raystack/components/data-view/__tests__/pack-lanes.test.ts new file mode 100644 index 000000000..5463fab99 --- /dev/null +++ b/packages/raystack/components/data-view/__tests__/pack-lanes.test.ts @@ -0,0 +1,301 @@ +import { describe, expect, it } from 'vitest'; +import { packLanes } from '../utils/pack-lanes'; +import { digest, randomItems } from './helpers'; + +/** + * `packLanes` is greedy first-fit interval scheduling: items are visited in + * ascending x and dropped into the lowest-numbered lane free at that point. + * + * It has two implementations behind one signature — a direct lane scan below 64 + * items, an O(n) sweep at or above it — so most of these are differentials + * against the scan. The scan is the specification: it is the pre-rewrite + * implementation, transcribed, and lane assignment is visible output (a card's + * lane is its vertical position, and `context.laneIndex` is public API through + * `renderCard`). + */ + +const SWEEP_MIN_ITEMS = 64; +const DEFAULT_GAP_PX = 8; + +/** First-fit by scanning every lane end — the pre-rewrite implementation. */ +function packByScanReference( + items: { x: number; width: number }[], + gapPx = DEFAULT_GAP_PX +) { + const order = items + .map((_, i) => i) + .sort((a, b) => items[a].x - items[b].x || a - b); + const laneEnds: number[] = []; + const lanes = new Array(items.length).fill(0); + for (const index of order) { + const item = items[index]; + let lane = laneEnds.findIndex(end => end + gapPx <= item.x); + if (lane === -1) { + lane = laneEnds.length; + laneEnds.push(0); + } + laneEnds[lane] = item.x + item.width; + lanes[index] = lane; + } + return { lanes, laneCount: laneEnds.length }; +} + +/* ─────────────────── characterisation: lane assignment ─────────────────── + Goldens pinning `packLanes` output at sizes too large to hand-write. They + were recorded from the implementation before the O(n) rewrite, so a digest + mismatch means lane assignment moved — which is a visible change. + + Digests rather than 500-element literals: the arrays are only ever compared, + never read, and an unreadable wall of numbers hides what the test is for. */ + +describe('packLanes (characterisation)', () => { + it('pins lane assignment for randomly spread items', () => { + const { lanes, laneCount } = packLanes(randomItems(42, 500)); + expect({ laneCount, lanes: digest(lanes) }).toMatchInlineSnapshot(` + { + "laneCount": 13, + "lanes": "908c8fec", + } + `); + }); + + it('pins lane assignment for a second, denser spread', () => { + const { lanes, laneCount } = packLanes(randomItems(7, 500), 4); + expect({ laneCount, lanes: digest(lanes) }).toMatchInlineSnapshot(` + { + "laneCount": 12, + "lanes": "e1515f6a", + } + `); + }); + + it('pins lane assignment when every item overlaps every other', () => { + // No lane is ever reusable, so lane count tracks item count — the shape + // that makes the lane scan quadratic. + const items = Array.from({ length: 400 }, (_, i) => ({ + x: i, + width: 10000 + })); + const { lanes, laneCount } = packLanes(items); + expect({ laneCount, lanes: digest(lanes) }).toMatchInlineSnapshot(` + { + "laneCount": 400, + "lanes": "732748cb", + } + `); + }); + + it('pins lane assignment when items cluster on a single x', () => { + const items = Array.from({ length: 200 }, (_, i) => ({ + x: 500, + width: i % 3 + })); + const { lanes, laneCount } = packLanes(items); + expect({ laneCount, lanes: digest(lanes) }).toMatchInlineSnapshot(` + { + "laneCount": 200, + "lanes": "23ec0f1f", + } + `); + }); + + it('pins lane assignment across the gap boundary', () => { + // Alternates releases landing exactly on the gap boundary (reusable) with + // ones a pixel short (not) — the comparison most at risk from a rewrite. + const items = Array.from({ length: 300 }, (_, i) => ({ + x: i * 108 + (i % 2), + width: 100 + })); + const { lanes, laneCount } = packLanes(items); + expect({ laneCount, lanes: digest(lanes) }).toMatchInlineSnapshot(` + { + "laneCount": 2, + "lanes": "05a0dca4", + } + `); + }); + + it('pins lane assignment for items sharing exact edges', () => { + // Ties on x, where assignment depends on the visit order the sort gives. + const items = Array.from({ length: 120 }, (_, i) => ({ + x: (i % 4) * 250, + width: 100 + (i % 7) * 10 + })); + const { lanes, laneCount } = packLanes(items); + expect({ laneCount, lanes: digest(lanes) }).toMatchInlineSnapshot(` + { + "laneCount": 30, + "lanes": "34e5e40d", + } + `); + }); +}); + +/* ────────────────────────────── behaviour ────────────────────────────── */ + +describe('packLanes', () => { + it('returns no lanes for empty input', () => { + expect(packLanes([])).toEqual({ lanes: [], laneCount: 0 }); + }); + + it('packs non-overlapping items into the same lane', () => { + const { lanes, laneCount } = packLanes([ + { x: 0, width: 100 }, + { x: 120, width: 50 } + ]); + expect(lanes).toEqual([0, 0]); + expect(laneCount).toBe(1); + }); + + it('opens a new lane for overlapping items', () => { + const { lanes, laneCount } = packLanes([ + { x: 0, width: 100 }, + { x: 50, width: 100 } + ]); + expect(lanes).toEqual([0, 1]); + expect(laneCount).toBe(2); + }); + + it('respects the gap: items closer than gapPx do not share a lane', () => { + // First ends at 100; second starts at 104 < 100 + 8 → new lane. + const tight = packLanes([ + { x: 0, width: 100 }, + { x: 104, width: 50 } + ]); + expect(tight.lanes).toEqual([0, 1]); + // Exactly at the gap boundary → same lane. + const exact = packLanes([ + { x: 0, width: 100 }, + { x: 108, width: 50 } + ]); + expect(exact.lanes).toEqual([0, 0]); + }); + + it('packs edge-to-edge when the gap is zero', () => { + // gapPx 0 makes touching cards reusable, the boundary the default gap + // hides: lane reuse now turns on `end <= x` rather than `end + 8 <= x`. + const { lanes, laneCount } = packLanes( + [ + { x: 0, width: 100 }, + { x: 100, width: 50 }, + { x: 99, width: 10 } + ], + 0 + ); + expect(lanes).toEqual([0, 0, 1]); + expect(laneCount).toBe(2); + }); + + it('assigns lanes by ascending x regardless of input order', () => { + const { lanes, laneCount } = packLanes([ + { x: 220, width: 60 }, // fits after the first item + { x: 0, width: 100 }, + { x: 50, width: 100 } // overlaps the first → lane 1 + ]); + expect(lanes).toEqual([0, 0, 1]); + expect(laneCount).toBe(2); + }); + + it('gives every zero-width item its own lane at a shared x', () => { + // Width 0 still occupies its x, and the gap keeps the next item out, so + // these cannot collapse onto one lane. + const items = Array.from({ length: 5 }, () => ({ x: 40, width: 0 })); + const { lanes, laneCount } = packLanes(items); + expect(lanes).toEqual([0, 1, 2, 3, 4]); + expect(laneCount).toBe(5); + }); + + it('agrees with the lane scan either side of the sweep threshold', () => { + // The implementation is chosen by item count, so the same data must pack + // identically at 63 items and at 64. A mismatch means adding one card + // repacks every lane. + const items = Array.from({ length: SWEEP_MIN_ITEMS }, (_, i) => ({ + x: (i % 16) * 60, + width: 100 + (i % 5) * 20 + })); + const below = items.slice(0, SWEEP_MIN_ITEMS - 1); + expect(packLanes(below)).toEqual(packByScanReference(below)); + expect(packLanes(items)).toEqual(packByScanReference(items)); + }); + + it('matches a lane scan across many random inputs', () => { + // The goldens above pin six fixed shapes; this sweeps far more input than + // snapshots can carry, and reports the mismatched lane directly rather + // than as a changed hash. + for (let seed = 1; seed <= 25; seed++) { + const items = randomItems(seed, 300); + expect(packLanes(items)).toEqual(packByScanReference(items)); + } + }); + + it('matches a lane scan on negative coordinates', () => { + // Column bucketing is offset by the minimum x, so an entirely negative + // domain is the case where a missing offset still passes on ordinary data. + const items = Array.from({ length: 300 }, (_, i) => ({ + x: -30000 + i * 37, + width: 40 + (i % 11) * 15 + })); + expect(packLanes(items)).toEqual(packByScanReference(items)); + }); + + it('reuses lanes above the first bitmap block', () => { + // The free-lane bitmap is two-level: 32 words of 32 lanes per summary + // block, so lanes 0-1023 live in block 0 and everything above in block 1. + // Every other test tops out at 400 lanes, leaving the multi-block walk in + // `takeSmallestFree` unexercised. + // + // Widths shrink as x grows, so higher lanes free up first. By x = 4000 + // lanes 0-1334 are still occupied and 1335+ are free, which forces the + // search past an all-zero block-0 summary before it finds anything. + const opening = Array.from({ length: 1500 }, (_, i) => ({ + x: i, + width: 12000 - 7 * i + })); + const followers = Array.from({ length: 120 }, (_, i) => ({ + x: 4000 + i, + width: 50 + })); + const items = [...opening, ...followers]; + + const result = packLanes(items); + expect(result).toEqual(packByScanReference(items)); + + // Proves the block-1 path actually ran rather than the data quietly + // staying inside block 0. + const followerLanes = result.lanes.slice(opening.length); + expect(Math.min(...followerLanes)).toBeGreaterThanOrEqual(1024); + expect(result.laneCount).toBe(1500); + }); + + it('survives non-finite geometry without losing a card', () => { + // Non-finite geometry shouldn't reach here — x and width come from the + // time scale — but a NaN must not throw or drop an item, which would leave + // a card unplaced on the canvas. The assignment itself is not pinned: NaN + // comparisons are false in both directions, so "first fit" has no meaning + // for those items and the two implementations are free to disagree. + const cases: { x: number; width: number }[][] = [ + Array.from({ length: 80 }, (_, i) => ({ + x: i * 20, + width: i === 9 ? Number.NaN : 30 + })), + Array.from({ length: 80 }, (_, i) => ({ + x: i === 3 ? Number.NaN : i * 20, + width: 30 + })), + Array.from({ length: 80 }, (_, i) => ({ + x: i * 20, + width: i === 40 ? Number.POSITIVE_INFINITY : 30 + })) + ]; + for (const items of cases) { + const { lanes, laneCount } = packLanes(items); + expect(lanes).toHaveLength(items.length); + expect(laneCount).toBeGreaterThan(0); + for (const lane of lanes) { + expect(Number.isInteger(lane)).toBe(true); + expect(lane).toBeGreaterThanOrEqual(0); + expect(lane).toBeLessThan(laneCount); + } + } + }); +}); diff --git a/packages/raystack/components/data-view/__tests__/timeline.test.tsx b/packages/raystack/components/data-view/__tests__/timeline.test.tsx index cdf089356..bc3770db9 100644 --- a/packages/raystack/components/data-view/__tests__/timeline.test.tsx +++ b/packages/raystack/components/data-view/__tests__/timeline.test.tsx @@ -20,7 +20,6 @@ import type { TimelineActions } from '../data-view.types'; import { useDataView } from '../hooks/useDataView'; -import { packLanes } from '../utils/pack-lanes'; import { buildAxis, createTimeScale, toTimestamp } from '../utils/time-scale'; beforeAll(() => { @@ -41,57 +40,6 @@ afterEach(() => { vi.restoreAllMocks(); }); -/* ─────────────────────────── utils: packLanes ────────────────────────── */ - -describe('packLanes', () => { - it('returns no lanes for empty input', () => { - expect(packLanes([])).toEqual({ lanes: [], laneCount: 0 }); - }); - - it('packs non-overlapping items into the same lane', () => { - const { lanes, laneCount } = packLanes([ - { x: 0, width: 100 }, - { x: 120, width: 50 } - ]); - expect(lanes).toEqual([0, 0]); - expect(laneCount).toBe(1); - }); - - it('opens a new lane for overlapping items', () => { - const { lanes, laneCount } = packLanes([ - { x: 0, width: 100 }, - { x: 50, width: 100 } - ]); - expect(lanes).toEqual([0, 1]); - expect(laneCount).toBe(2); - }); - - it('respects the gap: items closer than gapPx do not share a lane', () => { - // First ends at 100; second starts at 104 < 100 + 8 → new lane. - const tight = packLanes([ - { x: 0, width: 100 }, - { x: 104, width: 50 } - ]); - expect(tight.lanes).toEqual([0, 1]); - // Exactly at the gap boundary → same lane. - const exact = packLanes([ - { x: 0, width: 100 }, - { x: 108, width: 50 } - ]); - expect(exact.lanes).toEqual([0, 0]); - }); - - it('assigns lanes by ascending x regardless of input order', () => { - const { lanes, laneCount } = packLanes([ - { x: 220, width: 60 }, // fits after the first item - { x: 0, width: 100 }, - { x: 50, width: 100 } // overlaps the first → lane 1 - ]); - expect(lanes).toEqual([0, 0, 1]); - expect(laneCount).toBe(2); - }); -}); - /* ─────────────────────────── utils: time scale ───────────────────────── */ describe('toTimestamp', () => { @@ -1120,6 +1068,343 @@ describe('DataView.Timeline', () => { }); }); +/* ────────────────── characterisation: rendered geometry ────────────────── + Behaviour the virtualization work must leave alone. The existing suite + already pins card x/width, lane tops, group section stacking, and the + horizontal cull window; these cover what it doesn't. */ + +describe('DataView.Timeline (characterisation)', () => { + const cardIds = () => + Array.from(document.querySelectorAll('[data-testid^="card-"]')).map( + card => card.getAttribute('data-testid')?.replace('card-', '') ?? '' + ); + + it('renders cards in chronological DOM order, not row-model order', () => { + // Row model is sorted by title (o1, o2, o3); the canvas emits by x. DOM + // order is invisible on an absolutely-positioned canvas but is the order a + // screen reader walks the `role="list"`. + renderTimeline(); + expect(cardIds()).toEqual(['o1', 'o3', 'o2']); // x = 80, 100, 220 + }); + + it('keeps chronological DOM order under one-per-row packing', () => { + // Lanes follow row-model order here, so DOM order and lane order disagree + // — worth pinning separately from the packed case above. + renderTimeline({ lanePacking: 'one-per-row' }); + expect(cardIds()).toEqual(['o1', 'o3', 'o2']); + expect(screen.getByTestId('card-o2').dataset.lane).toBe('1'); + expect(screen.getByTestId('card-o3').dataset.lane).toBe('2'); + }); + + it('falls back to the overscan floor with no measurable viewport', () => { + // jsdom reports a zero-size client box, as does SSR. Overscan collapses to + // its 200px floor, so the window is [-200, 200] horizontally and unbounded + // vertically — o2 at x=220 falls outside, o1 and o3 don't. + renderTimeline({ virtualized: true }); + expect(cardIds()).toEqual(['o1', 'o3']); + }); + + it('ignores measured card heights when virtualized', () => { + // The one behaviour vertical culling deliberately moved. Unvirtualized, + // this same setup re-stacks lane 1 to 122px on the measured 90px height + // (see "re-stacks lanes to the tallest measured card height"). Virtualized, + // a culled card never mounts and so never measures, so honouring + // measurements would resize lanes under the user mid-scroll — lanes hold + // the estimate instead, putting lane 1 back at 16 + 66 + 16. + vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation( + function (this: HTMLElement) { + return this.getAttribute('role') === 'listitem' ? 90 : 0; + } + ); + renderTimeline({ virtualized: true }); + expect(screen.getByTestId('card-o1').parentElement!.style.top).toBe('16px'); + expect(screen.getByTestId('card-o3').parentElement!.style.top).toBe('98px'); + }); + + it('keeps lane and canvas geometry stable across a re-render', () => { + const { rerender } = renderTimeline(); + const before = ['o1', 'o2', 'o3'].map( + id => screen.getByTestId(`card-${id}`).parentElement!.style.top + ); + rerender( + + data={orders} + fields={fields} + mode='client' + defaultSort={{ name: 'title', order: 'asc' }} + getRowId={(row: Order) => row.id} + > + + startField='start' + endField='end' + range={['2025-01-01', '2025-01-31']} + scale='day' + unitWidth={20} + today={false} + defaultScrollTo='start' + renderCard={row => ( +
+ {row.original.title} +
+ )} + /> + + ); + const after = ['o1', 'o2', 'o3'].map( + id => screen.getByTestId(`card-${id}`).parentElement!.style.top + ); + expect(after).toEqual(before); + }); +}); + +/* ─────────────────── characterisation: axis chrome ─────────────────────── + Counts of the non-card furniture: tick labels, month bands, marker badges + and lines, group slots. Cards and gridlines already cull; the rest is what + Phase 2 changes, so these pin where it stands first — unvirtualized, where + nothing may move, and virtualized, where it should. */ + +describe('DataView.Timeline chrome (characterisation)', () => { + const countSlot = (container: HTMLElement, slot: string) => + container.querySelectorAll(`[data-slot="data-view-timeline-${slot}"]`) + .length; + + /** A year-long domain: 7280px of canvas against a 600px pane. */ + const yearProps = { + range: ['2025-01-01', '2025-12-31'] as [string, string], + markers: [ + { date: '2025-02-14' }, + { date: '2025-06-30', label: 'Mid' }, + { date: '2025-11-05', label: 'Launch' } + ] + }; + + it('renders chrome for the whole domain when not virtualized', () => { + stubPane(); + const { container } = renderTimeline(yearProps); + expect({ + tickLabels: countSlot(container, 'axis-tick'), + monthBands: countSlot(container, 'axis-band'), + markerBadges: countSlot(container, 'axis-marker'), + markerLines: countSlot(container, 'marker'), + gridlines: countSlot(container, 'gridline') + }).toMatchInlineSnapshot(` + { + "gridlines": 366, + "markerBadges": 3, + "markerLines": 3, + "monthBands": 12, + "tickLabels": 183, + } + `); + }); + + it('culls chrome to the visible window when virtualized', () => { + // Was the full domain on every count but gridlines (183 tick labels, 12 + // bands, 3 markers) regardless of how narrow the window onto it was. + stubPane(); + const { container } = renderTimeline({ ...yearProps, virtualized: true }); + expect({ + tickLabels: countSlot(container, 'axis-tick'), + monthBands: countSlot(container, 'axis-band'), + markerBadges: countSlot(container, 'axis-marker'), + markerLines: countSlot(container, 'marker'), + gridlines: countSlot(container, 'gridline') + }).toMatchInlineSnapshot(` + { + "gridlines": 46, + "markerBadges": 1, + "markerLines": 1, + "monthBands": 2, + "tickLabels": 23, + } + `); + // Window [-600, 1200] spans Jan-Mar, so only the February marker is in it. + expect(screen.queryByText('Launch')).toBeNull(); + }); + + it('keeps the band straddling the left edge of the window', async () => { + // Bands tile the domain, so the one under the viewport's left edge starts + // before the cull bound. Dropping it would strand the sticky month label + // that is supposed to ride the left edge while its band spans the view. + stubPane(); + const { container } = renderTimeline({ ...yearProps, virtualized: true }); + const root = container.firstElementChild as HTMLElement; + await act(async () => { + root.scrollLeft = 2000; // mid-April, 240px into the month's band + fireEvent.scroll(root); + await new Promise(resolve => setTimeout(resolve, 30)); + }); + const labels = Array.from( + container.querySelectorAll('[data-slot="data-view-timeline-axis-band"]') + ).map(band => band.textContent); + expect(labels[0]).toBe('Mar'); + expect(labels).toContain('Apr'); + }); + + it('renders a slot per group section when virtualized', () => { + stubPane(); + const { container } = renderGrouped({ virtualized: true }); + expect(countSlot(container, 'group-slot')).toMatchInlineSnapshot(`2`); + }); + + it('drops group slots scrolled out of the window', async () => { + stubPane(); + const { container } = renderGrouped({ virtualized: true }); + const root = container.firstElementChild as HTMLElement; + await act(async () => { + root.scrollTop = 450; + fireEvent.scroll(root); + await new Promise(resolve => setTimeout(resolve, 30)); + }); + // Eng's section ends at 218, above the window [250, 850]; Design's spans + // 218-354, inside it. Every group's slot used to stay mounted, however far + // off-screen the section was. + expect(countSlot(container, 'group-slot')).toMatchInlineSnapshot(`1`); + expect(screen.getByText('Design')).toBeInTheDocument(); + expect(screen.queryByText('Eng')).toBeNull(); + }); +}); + +/* ──────────────────────────── virtualization ───────────────────────────── + `virtualized` culls on both axes. Vertical culling needs a pane height and + jsdom does no layout, so these stub the scroll container's client box — with + the deliberate exception of the last test, covering the unmeasured fallback. + Lane pitch under virtualization is fixed: estimatedRowHeight (66) + laneGap + (16) = 82, lane 0 starting at 16. */ + +/** + * Give the scroll pane a viewport; everything else keeps jsdom's zeroes. + * jsdom does no layout, so culling has no window without this. + */ +function stubPane({ width = 600, height = 200 } = {}) { + const paneOnly = (el: HTMLElement, value: number) => + el.dataset.slot === 'data-view-timeline' ? value : 0; + vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockImplementation( + function (this: HTMLElement) { + return paneOnly(this, width); + } + ); + vi.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockImplementation( + function (this: HTMLElement) { + return paneOnly(this, height); + } + ); +} + +describe('DataView.Timeline virtualization', () => { + /** One lane per row, every card at the same x — isolates vertical culling. */ + const stackedOrders: Order[] = Array.from({ length: 12 }, (_, i) => ({ + id: `r${String(i + 1).padStart(2, '0')}`, + title: String(i + 1).padStart(2, '0'), + start: '2025-01-05', + end: '2025-01-10' + })); + + const renderedIds = () => + Array.from(document.querySelectorAll('[data-testid^="card-r"]')).map( + card => card.getAttribute('data-testid')?.replace('card-', '') ?? '' + ); + + it('renders only the lanes the vertical window covers', () => { + stubPane(); + renderTimeline( + { virtualized: true, lanePacking: 'one-per-row' }, + stackedOrders + ); + // Overscan is half the 200px pane, floored at 200 → window [-200, 400], + // covering lanes 0-4 (lane 5's top is 426). Without vertical culling all + // 12 mount, and 10k rows would mount 10k. + expect(renderedIds()).toEqual(['r01', 'r02', 'r03', 'r04', 'r05']); + }); + + it('swaps in lower lanes as the pane scrolls down', async () => { + stubPane(); + const { container } = renderTimeline( + { virtualized: true, lanePacking: 'one-per-row' }, + stackedOrders + ); + const root = container.firstElementChild as HTMLElement; + await act(async () => { + root.scrollTop = 500; + fireEvent.scroll(root); + await new Promise(resolve => setTimeout(resolve, 30)); + }); + // Window [300, 900] → lanes 3-10. The lanes above scroll out of the top as + // the ones past the initial cut-off mount. + expect(renderedIds()).toEqual([ + 'r04', + 'r05', + 'r06', + 'r07', + 'r08', + 'r09', + 'r10', + 'r11' + ]); + }); + + it('culls vertically through group sections too', async () => { + stubPane(); + const { container } = renderGrouped({ virtualized: true }); + // Eng's two lanes (tops 54, 136) and Design's one (272) all start inside + // the initial window. + expect(screen.getByTestId('card-a1')).toBeInTheDocument(); + expect(screen.getByTestId('card-b1')).toBeInTheDocument(); + + const root = container.firstElementChild as HTMLElement; + await act(async () => { + root.scrollTop = 450; + fireEvent.scroll(root); + await new Promise(resolve => setTimeout(resolve, 30)); + }); + // Window [250, 850]: Eng's lanes end at 120 and 202, above it; Design's + // spans 272-338, inside. Bands break the uniform pitch, so this exercises + // the searched path, not the arithmetic one. + expect(screen.queryByTestId('card-a1')).toBeNull(); + expect(screen.queryByTestId('card-a2')).toBeNull(); + expect(screen.getByTestId('card-b1')).toBeInTheDocument(); + }); + + it('keeps a card whose span reaches the window from off-screen left', async () => { + stubPane(); + const { container } = renderTimeline( + { virtualized: true, range: ['2025-01-01', '2025-12-31'] }, + [ + // Both start at x = 2000, left of the window below; only the first is + // wide enough to reach into it. + { id: 'wide', title: 'Wide', start: '2025-04-11', end: '2025-05-11' }, + { + id: 'narrow', + title: 'Narrow', + start: '2025-04-11', + end: '2025-04-12' + } + ] + ); + const root = container.firstElementChild as HTMLElement; + await act(async () => { + root.scrollLeft = 2400; + fireEvent.scroll(root); + await new Promise(resolve => setTimeout(resolve, 30)); + }); + // Window [2100, 3300]. Culling searches by x, which is ordered, while + // width isn't — so each lane widens its left bound by its own widest card + // and then re-checks the right edge exactly. + expect(screen.getByTestId('card-wide')).toBeInTheDocument(); // 2000 → 2600 + expect(screen.queryByTestId('card-narrow')).toBeNull(); // 2000 → 2020 + }); + + it('skips vertical culling when the pane reports no height', () => { + // No stub: jsdom leaves clientHeight 0, as does SSR. A zero height would + // otherwise cull to a sliver of lanes — horizontal culling carries on. + renderTimeline( + { virtualized: true, lanePacking: 'one-per-row' }, + stackedOrders + ); + expect(renderedIds()).toHaveLength(12); + }); +}); + /* ───────────────────────────── ordering contract ───────────────────────── Sort can't move a card horizontally (x is locked to time), so it only shows up where vertical order is free: `lanePacking="one-per-row"`. `auto` packing diff --git a/packages/raystack/components/data-view/components/timeline.tsx b/packages/raystack/components/data-view/components/timeline.tsx index e155219ba..3fd95d7c4 100644 --- a/packages/raystack/components/data-view/components/timeline.tsx +++ b/packages/raystack/components/data-view/components/timeline.tsx @@ -26,6 +26,7 @@ import { TimelineScale } from '../data-view.types'; import { useDataView } from '../hooks/useDataView'; +import { orderByX } from '../utils/order-by-x'; import { packLanes } from '../utils/pack-lanes'; import { buildAxis, @@ -77,12 +78,19 @@ const SCROLL_EDGE_INSET_PX = 24; const clampSpeed = (v: number) => Math.max(-MOMENTUM_MAX_SPEED, Math.min(v, MOMENTUM_MAX_SPEED)); +/** + * `lo + ((hi - lo) >> 1)` rather than `(lo + hi) >> 1` throughout: `>>` coerces + * to int32, so the latter wraps to a negative midpoint once `lo + hi` passes + * 2^31. These lists can't approach that, but the two forms cost the same and + * mixing them in one file reads as though one of them were load-bearing. + */ + /** First index in `list` (ascending by `x`) with `x >= value`. */ function lowerBoundByX(list: readonly { x: number }[], value: number): number { let lo = 0; let hi = list.length; while (lo < hi) { - const mid = (lo + hi) >> 1; + const mid = lo + ((hi - lo) >> 1); if (list[mid].x < value) lo = mid + 1; else hi = mid; } @@ -94,13 +102,133 @@ function upperBoundByX(list: readonly { x: number }[], value: number): number { let lo = 0; let hi = list.length; while (lo < hi) { - const mid = (lo + hi) >> 1; + const mid = lo + ((hi - lo) >> 1); if (list[mid].x <= value) lo = mid + 1; else hi = mid; } return lo; } +/** + * First box whose bottom edge reaches `value`, over parallel top/height + * arrays. Used for lanes and for group-section slots: both stack without + * overlapping, so tops *and* bottoms ascend, which holds for the measured + * (variable-height) lane geometry as well as the fixed pitch. + */ +function lowerBoundByBottom( + tops: readonly number[], + heights: readonly number[], + value: number +): number { + let lo = 0; + let hi = tops.length; + while (lo < hi) { + const mid = lo + ((hi - lo) >> 1); + if (tops[mid] + heights[mid] < value) lo = mid + 1; + else hi = mid; + } + return lo; +} + +/** First box whose top edge is past `value`. */ +function upperBoundByTop(tops: readonly number[], value: number): number { + let lo = 0; + let hi = tops.length; + while (lo < hi) { + const mid = lo + ((hi - lo) >> 1); + if (tops[mid] <= value) lo = mid + 1; + else hi = mid; + } + return lo; +} + +/** Lane runs at or below this length scan faster than they binary-search. */ +const LANE_SCAN_MAX = 8; + +/** + * Overscan floor, in px, on each side of the viewport. Also sets how far the + * pane may travel before the culling window is recomputed — see `readViewport`. + */ +const MIN_OVERSCAN_PX = 200; + +/** + * Overscan as a fraction of the viewport, per side. The mounted set covers + * `(1 + 2r)²` viewports of canvas, so the cost of a commit grows with the + * *square* of this while the distance it buys before the next commit grows + * only linearly — a full viewport each side (r = 1) mounts 9 viewports' worth + * to save one viewport of travel. Half is the better trade: 4 viewports + * mounted, commits roughly twice as cheap, twice as often. + */ +const OVERSCAN_RATIO = 0.5; + +/** Overscan for one axis, given that axis's viewport extent. */ +const overscanFor = (extent: number) => + Math.max(extent * OVERSCAN_RATIO, MIN_OVERSCAN_PX); + +/** + * Lane tops as an arithmetic series, when the stack is uninterrupted enough to + * have one — see where it's built in the geometry memo. + */ +interface UniformPitch { + /** Top of lane 0. */ + first: number; + /** Lane height plus the gap below it. */ + pitch: number; + /** Lane height alone. */ + height: number; +} + +/** + * Lanes intersecting the vertical window `[min, max]`, as a `[start, end)` + * range. Uniform geometry inverts the series arithmetically — O(1), no search + * at all; anything else binary-searches the lane boxes. + */ +function resolveLaneRange( + min: number, + max: number, + uniform: UniformPitch | null, + tops: readonly number[], + heights: readonly number[] +): { start: number; end: number } { + const laneCount = tops.length; + if (uniform) { + // Lane i spans [first + i·pitch, first + i·pitch + height]. + const start = Math.ceil( + (min - uniform.first - uniform.height) / uniform.pitch + ); + const end = Math.floor((max - uniform.first) / uniform.pitch) + 1; + return { + start: Math.max(0, Math.min(start, laneCount)), + end: Math.max(0, Math.min(end, laneCount)) + }; + } + return { + start: lowerBoundByBottom(tops, heights, min), + end: upperBoundByTop(tops, max) + }; +} + +/** + * First slot in one lane's run of the card index (`[from, to)` of `items`, + * ascending by x) whose card starts at or past `value`. + */ +function lowerBoundLaneCard( + cards: readonly { x: number }[], + items: Int32Array, + from: number, + to: number, + value: number +): number { + let lo = from; + let hi = to; + while (lo < hi) { + const mid = lo + ((hi - lo) >> 1); + if (cards[items[mid]].x < value) lo = mid + 1; + else hi = mid; + } + return lo; +} + /** A row with its start/end resolved to timestamps. */ interface TimedItem { row: Row; @@ -119,6 +247,18 @@ interface PositionedItem extends TimedItem { packWidth: number; } +/** + * A positioned row with its lane resolved, ready to render. The lane fields + * are filled in after packing rather than at construction — see the `cards` + * memo — so they are mutable and zero until then. + */ +interface LaidOutCard extends PositionedItem { + /** Section-relative — what `renderCard` sees as `context.laneIndex`. */ + laneIndex: number; + /** Global across sections — indexes `laneTops` and the card index. */ + lane: number; +} + /** * One vertical section of the canvas. `group` is the row model's group row * (`groupData` entry) when `group_by` is active, null for the implicit @@ -143,6 +283,11 @@ interface TimelineCardViewProps { /** Null when `endField` is omitted (point marker). */ endTime: number | null; renderCard: DataViewTimelineProps['renderCard']; + /** + * False under a fixed lane pitch (virtualized), where nothing consumes the + * measurement — skipping it drops one ResizeObserver per rendered card. + */ + measure: boolean; /** Reports the wrapper's rendered height so lanes can size to content. */ onMeasure: (rowId: string, height: number) => void; onRowClick?: (row: TData) => void; @@ -167,6 +312,7 @@ function TimelineCardViewInner({ startTime, endTime, renderCard, + measure, onMeasure, onRowClick, className @@ -179,14 +325,14 @@ function TimelineCardViewInner({ // replace its `estimatedRowHeight` seed with the real value. useEffect(() => { const el = elementRef.current; - if (!el) return; + if (!el || !measure) return; const report = () => onMeasure(rowId, el.offsetHeight); report(); if (typeof ResizeObserver === 'undefined') return; const observer = new ResizeObserver(report); observer.observe(el); return () => observer.disconnect(); - }, [onMeasure, rowId]); + }, [measure, onMeasure, rowId]); const context: TimelineCardContext = { width: spanWidth, @@ -406,11 +552,22 @@ export function DataViewTimeline({ return list; }, [sections, startField, endField]); - // Flattened for the domain extent — grouping never changes the time domain. - const timedItems = useMemo( - () => timedSections.flatMap(section => section.items), - [timedSections] - ); + // Data extent, for the domain below — grouping never changes the time domain. + // Reduced in place rather than through a flattened copy: the extent is two + // numbers, and materialising every row again to find them doubled the + // pipeline's peak allocation for nothing. + const dataExtent = useMemo(() => { + let min = Infinity; + let max = -Infinity; + for (const section of timedSections) { + for (const item of section.items) { + if (item.startTime < min) min = item.startTime; + const end = item.endTime ?? item.startTime; + if (end > max) max = end; + } + } + return { min, max }; + }, [timedSections]); const todayTime = useMemo(() => { if (today === false) return null; @@ -444,12 +601,7 @@ export function DataViewTimeline({ return { min: Math.min(a, b), max: Math.max(a, b), explicit: true }; } } - let min = Infinity; - let max = -Infinity; - for (const item of timedItems) { - min = Math.min(min, item.startTime); - max = Math.max(max, item.endTime ?? item.startTime); - } + let { min, max } = dataExtent; for (const time of [todayTime ?? Infinity, ...markerTimes]) { if (!Number.isFinite(time)) continue; min = Math.min(min, time); @@ -463,7 +615,7 @@ export function DataViewTimeline({ return { min: anchor - pad, max: anchor + pad, explicit: false }; } return { min, max, explicit: false }; - }, [range, timedItems, todayTime, markerTimes, scale]); + }, [range, dataExtent, todayTime, markerTimes, scale]); const timeScale = useMemo( () => @@ -495,9 +647,9 @@ export function DataViewTimeline({ // with no cards is dropped entirely (no band, no empty strip), the same way // the ungrouped timeline silently culls out-of-domain rows. const positionedSections = useMemo(() => { - const list: TimelineSection>[] = []; + const list: TimelineSection>[] = []; for (const section of timedSections) { - const items: PositionedItem[] = []; + const items: LaidOutCard[] = []; for (const item of section.items) { const effectiveEnd = item.endTime ?? item.startTime; if (item.startTime > timeScale.t1 || effectiveEnd < timeScale.t0) { @@ -514,7 +666,18 @@ export function DataViewTimeline({ // know their width — `estimatedPointWidth` stands in for lane packing // and culling so wide chips don't overlap within a lane. const packWidth = renderWidth ?? estimatedPointWidth; - items.push({ ...item, x, spanWidth, renderWidth, packWidth }); + // Lane fields are declared here, filled by the `cards` memo once + // packing has run: one object per card for the whole pipeline, and one + // hidden class for V8 rather than a reshape halfway through. + items.push({ + ...item, + x, + spanWidth, + renderWidth, + packWidth, + laneIndex: 0, + lane: 0 + }); } if (items.length === 0) continue; list.push({ key: section.key, group: section.group, items }); @@ -545,6 +708,15 @@ export function DataViewTimeline({ return { laidOutSections: list, laneCount: offset }; }, [positionedSections, lanePacking]); + /** + * Virtualizing vertically means a card off-screen never mounts and so never + * measures — leaving lanes to resize under the user as they scroll. Lanes + * therefore take `estimatedRowHeight` as their exact height while + * virtualized: geometry stays stable, and cards taller than it overflow + * their lane rather than growing it. + */ + const fixedLaneHeight = virtualized; + // Measured card heights by row id, `estimatedRowHeight` standing in until a // card reports (same estimate-then-measure contract as `DataView.List`). // Kept in a ref — measurements arrive per card per paint, and a version @@ -561,27 +733,54 @@ export function DataViewTimeline({ setMeasureVersion(version => version + 1); }, []); - // All sections' cards flattened and sorted ascending by x so per-frame - // culling can binary-search the visible slice instead of scanning every item. - // Lane semantics (packing order, one-per-row row order) are unaffected — - // lanes are assigned before the sort. DOM order becomes chronological. - const cards = useMemo(() => { - const list = laidOutSections.flatMap(section => - section.items.map((item, index) => ({ - ...item, + // All sections' cards flattened and sorted ascending by x, which is what + // lets per-frame culling search for its window instead of scanning every + // item. Lane semantics (packing order, one-per-row row order) are unaffected + // — lanes are assigned before the sort. + // + // DOM order is chronological, except under vertical culling: that path emits + // lane by lane, so cards come out grouped by lane and chronological within + // one. Cards are absolutely positioned, so this is invisible on screen; it + // only reorders how a screen reader walks the `role="list"`, which + // virtualization already leaves partial. + const { cards, maxPackWidth } = useMemo(() => { + const list: LaidOutCard[] = []; + // Lanes are written onto the positioned items rather than spread into new + // objects: those items are built by `positionedSections` for this pipeline + // alone and never escape it, and at 50k rows the copy was an extra 50k + // allocations for two integer fields. Assignment is idempotent, so a + // recompute that reuses the same `positionedSections` writes the same + // values back. + for (const section of laidOutSections) { + for (let index = 0; index < section.items.length; index++) { + const item = section.items[index]; // Section-relative (renderCard's `context.laneIndex`) … - laneIndex: section.lanes[index], + item.laneIndex = section.lanes[index]; // … and global, for the lane-top lookup at render time. - lane: section.laneOffset + section.lanes[index] - })) - ); - list.sort((a, b) => a.x - b.x); - return list; + item.lane = section.laneOffset + section.lanes[index]; + list.push(item); + } + } + // Counting sort rather than `Array#sort` — see `orderByX`. + const order = orderByX(list); + const sorted = new Array>(list.length); + // Widest card, folded into this pass: culling widens its left bound by it, + // because x is ordered and width isn't. + let widest = 0; + for (let i = 0; i < order.length; i++) { + const item = list[order[i]]; + sorted[i] = item; + if (item.packWidth > widest) widest = item.packWidth; + } + return { cards: sorted, maxPackWidth: widest }; }, [laidOutSections]); // Drop measurements for rows that left the data set so a shrunk lane - // doesn't stay sized to a card that no longer exists. + // doesn't stay sized to a card that no longer exists. Under a fixed pitch + // nothing reads the map and cards never report into it, so the whole + // O(rows) sweep — and the row-id Set it builds — is skipped. useEffect(() => { + if (fixedLaneHeight) return; const ids = new Set(cards.map(item => item.row.id)); const map = measuredHeightsRef.current; let changed = false; @@ -592,21 +791,36 @@ export function DataViewTimeline({ } } if (changed) setMeasureVersion(version => version + 1); - }, [cards]); + }, [cards, fixedLaneHeight]); // Vertical geometry. Each lane is as tall as its tallest card — measured // height when known, `estimatedRowHeight` until then — so lane tops are // cumulative rather than a fixed pitch. Sections stack: band, then that // section's lanes, then the next section's band. `groupBands` carries each // band's slot (top + full section height) for the sticky pin below. - const { laneTops, groupBands, canvasHeight } = useMemo(() => { + const { + laneTops, + laneHeights, + uniformPitch, + groupBands, + groupBandTops, + groupBandHeights, + canvasHeight + } = useMemo(() => { // Reads measuredHeightsRef; measureVersion invalidates on new reports. void measureVersion; const measured = measuredHeightsRef.current; - const heights = new Array(laneCount).fill(0); - for (const item of cards) { - const height = measured.get(item.row.id) ?? estimatedRowHeight; - if (height > heights[item.lane]) heights[item.lane] = height; + const heights = new Array(laneCount).fill(estimatedRowHeight); + // Measured lanes only outside virtualization: a culled card never reports + // a height, so lanes would resize as the user scrolls and shift every lane + // below them. The fixed pitch also drops this pass from O(cards) to + // O(lanes) — it stops depending on the measurements entirely. + if (!fixedLaneHeight) { + heights.fill(0); + for (const item of cards) { + const height = measured.get(item.row.id) ?? estimatedRowHeight; + if (height > heights[item.lane]) heights[item.lane] = height; + } } const tops = new Array(laneCount); const bands: { @@ -643,23 +857,72 @@ export function DataViewTimeline({ // Nothing positioned (all rows culled, or loading with no rows yet): keep // reserving one lane's worth of canvas so the pane doesn't collapse. if (laneCount === 0) y = estimatedRowHeight + laneGap * 2; - return { laneTops: tops, groupBands: bands, canvasHeight: y }; + // Lane tops are an arithmetic series only when nothing interrupts the + // stack — one section (a second one inserts its own leading gap) and no + // band above it. That's the shape that scales to thousands of lanes, and + // it lets vertical culling map a scroll offset straight to a lane index; + // anything else falls back to a binary search over `tops`. + const uniform = + fixedLaneHeight && laneCount > 0 && laidOutSections.length === 1 + ? { + first: bands.length > 0 ? GROUP_BAND_HEIGHT + laneGap : laneGap, + pitch: estimatedRowHeight + laneGap, + height: estimatedRowHeight + } + : null; + return { + laneTops: tops, + laneHeights: heights, + uniformPitch: uniform, + groupBands: bands, + // Slot boxes as parallel arrays, so culling them reuses the same box + // search as the lanes instead of rebuilding these every frame. + groupBandTops: bands.map(band => band.top), + groupBandHeights: bands.map(band => band.height), + canvasHeight: y + }; }, [ cards, laidOutSections, laneCount, estimatedRowHeight, + fixedLaneHeight, laneGap, showGroupHeaders, measureVersion ]); - // Widens the culling window's left bound: a card is visible when - // `x + width >= min`, and width isn't sorted — only x is. - const maxPackWidth = useMemo( - () => cards.reduce((max, item) => Math.max(max, item.packWidth), 0), - [cards] - ); + /** + * Cards grouped by lane, in CSR form: `items` holds card indices bucketed by + * lane, `starts[lane]` to `starts[lane + 1]` delimiting each lane's run. Two + * counting passes, one flat `Int32Array`, no per-lane array allocation. + * + * This is what keeps a frame proportional to what's on screen: culling walks + * only the lanes the viewport covers and binary-searches the x window inside + * each, instead of scanning a slice of every card in the time window. Built + * from the x-ascending `cards`, and the scatter is stable, so each lane's run + * is x-ascending too. Per-lane `maxPackWidths` narrows the left-overhang + * allowance to that lane's widest card rather than the whole canvas's. + */ + const laneIndex = useMemo(() => { + if (!virtualized || laneCount === 0 || cards.length === 0) return null; + const starts = new Int32Array(laneCount + 1); + for (const item of cards) starts[item.lane + 1]++; + for (let lane = 0; lane < laneCount; lane++) { + starts[lane + 1] += starts[lane]; + } + const cursor = Int32Array.from(starts.subarray(0, laneCount)); + const items = new Int32Array(cards.length); + const maxPackWidths = new Float64Array(laneCount); + for (let i = 0; i < cards.length; i++) { + const item = cards[i]; + items[cursor[item.lane]++] = i; + if (item.packWidth > maxPackWidths[item.lane]) { + maxPackWidths[item.lane] = item.packWidth; + } + } + return { starts, items, maxPackWidths }; + }, [virtualized, cards, laneCount]); const resolvedMarkers = useMemo(() => { const list: ResolvedMarker[] = []; @@ -697,15 +960,49 @@ export function DataViewTimeline({ const [viewport, setViewport] = useState<{ left: number; width: number; + top: number; + height: number; } | null>(null); const rafIdRef = useRef(null); + /** + * The pane's live client box, unquantized. `viewport` state lags it on + * purpose (see below); anything needing the true offset — the visible-range + * callback — reads this instead. + */ + const viewportRef = useRef<{ + left: number; + width: number; + top: number; + height: number; + } | null>(null); const readViewport = useCallback(() => { const el = scrollRef.current; if (!el) return; + viewportRef.current = { + left: el.scrollLeft, + width: el.clientWidth, + top: el.scrollTop, + height: el.clientHeight + }; setViewport(prev => { - const next = { left: el.scrollLeft, width: el.clientWidth }; - if (prev && prev.left === next.left && prev.width === next.width) { + const next = viewportRef.current!; + if (!prev) return next; + // A resize invalidates the window outright. + if (prev.width !== next.width || prev.height !== next.height) return next; + // Otherwise hold the last window until the pane has travelled half its + // overscan. Scrolling is compositor work the browser does for free; + // committing state on every pixel drags React into all 60 frames a + // second to rebuild a slice that is nearly always identical. Overscan is + // half a viewport on each side (`OVERSCAN_RATIO`), so half of that is + // spare coverage: the rendered set still spans the visible window with + // an overscan/2 margin at the moment of the next commit. + const slackX = overscanFor(prev.width) / 2; + const slackY = overscanFor(prev.height) / 2; + if ( + Math.abs(prev.left - next.left) < slackX && + Math.abs(prev.top - next.top) < slackY + ) { return prev; } return next; @@ -919,7 +1216,10 @@ export function DataViewTimeline({ if (rafIdRef.current !== null) return; rafIdRef.current = requestAnimationFrame(() => { rafIdRef.current = null; - if (needsViewport) readViewport(); + if (needsViewport) { + readViewport(); + notifyRef.current(); + } updateCursorFromPointer(); }); }, [needsViewport, showCursorLine, readViewport, updateCursorFromPointer]); @@ -936,33 +1236,50 @@ export function DataViewTimeline({ [] ); - useEffect(() => { + // Layout effect, not a passive one: culling with no viewport yet falls back + // to rendering every card, so reading it after paint would flash the whole + // canvas into the DOM on mount before the first cull. + // biome-ignore lint/correctness/useExhaustiveDependencies: `hasData` re-runs the attempt when the renderer (re)mounts its DOM (the ref is null while hidden). + useLayoutEffect(() => { if (!needsViewport || !isActive) return; readViewport(); const el = scrollRef.current; if (!el || typeof ResizeObserver === 'undefined') return; - const observer = new ResizeObserver(readViewport); + const observer = new ResizeObserver(() => { + readViewport(); + // A resize changes the visible window even with the scroll offset fixed. + notifyRef.current(); + }); observer.observe(el); return () => observer.disconnect(); - }, [needsViewport, isActive, readViewport]); - - // Deduped within one pixel of time — `timeScale`/`viewport` identity churn - // (a resize, a domain rebuild from streamed-in rows) must not re-fire - // consumers with an unchanged window; each fire typically triggers a fetch - // check. Exact equality is too strict for "unchanged": the px→time→px - // round-trip of scroll anchoring isn't bit-exact in floats, and browsers - // quantize an anchored scrollLeft to device pixels — either can drift the - // recomputed edges without the window visibly moving. Anything under one - // pixel's worth of time is that noise, not a scroll (the baseline is the - // last *notified* window, so slow sub-pixel scrolling still accumulates - // past the threshold and fires). + // `hasData` re-runs the attempt when the renderer mounts its DOM. + }, [needsViewport, isActive, hasData, readViewport]); + + // Reads the *measured* offset, not the quantized `viewport` state: culling + // can coast on a stale window because it overscans, but a consumer fetching + // by visible range cannot — it would be handed a window the user scrolled + // past. Running off the ref also keeps this off React's critical path + // entirely: a timeline with only `onVisibleRangeChange` set now re-renders + // on nothing at all while scrolling. + // + // Deduped within one pixel of time — `timeScale` identity churn (a resize, a + // domain rebuild from streamed-in rows) must not re-fire consumers with an + // unchanged window; each fire typically triggers a fetch check. Exact + // equality is too strict for "unchanged": the px→time→px round-trip of + // scroll anchoring isn't bit-exact in floats, and browsers quantize an + // anchored scrollLeft to device pixels — either can drift the recomputed + // edges without the window visibly moving. Anything under one pixel's worth + // of time is that noise, not a scroll (the baseline is the last *notified* + // window, so slow sub-pixel scrolling still accumulates past the threshold + // and fires). const lastNotifiedRangeRef = useRef<{ from: number; to: number } | null>( null ); - useEffect(() => { - if (!onVisibleRangeChange || !viewport) return; - const from = timeScale.timeAt(viewport.left); - const to = timeScale.timeAt(viewport.left + viewport.width); + const notifyVisibleRange = useCallback(() => { + const measured = viewportRef.current; + if (!onVisibleRangeChange || !measured) return; + const from = timeScale.timeAt(measured.left); + const to = timeScale.timeAt(measured.left + measured.width); const prev = lastNotifiedRangeRef.current; const pxOfTime = 1 / timeScale.pxPerMs; if ( @@ -974,7 +1291,16 @@ export function DataViewTimeline({ } lastNotifiedRangeRef.current = { from, to }; onVisibleRangeChange([new Date(from), new Date(to)]); - }, [onVisibleRangeChange, viewport, timeScale]); + }, [onVisibleRangeChange, timeScale]); + + // Scroll handlers close over this rather than the callback itself, so a new + // `timeScale` or consumer function doesn't have to re-attach them. + const notifyRef = useRef(notifyVisibleRange); + useEffect(() => { + notifyRef.current = notifyVisibleRange; + // Mount and domain changes notify from here; scrolling calls the ref. + notifyVisibleRange(); + }, [notifyVisibleRange]); // Time-target resolution shared by `defaultScrollTo` and the imperative // handle. 'today' resolves to the today-line when shown, else the actual @@ -1036,10 +1362,16 @@ export function DataViewTimeline({ // card on screen doesn't move the view. Compared by value: `tableQuery` // identity churns on unrelated updates (sort, grouping); the ref seeds with // the mount-time key so the initial position stays `defaultScrollTo`'s job. - const queryKey = JSON.stringify({ - filters: tableQuery?.filters ?? null, - search: tableQuery?.search ?? null - }); + // Memoized on the two inputs it serializes: this ran on every render, and + // scrolling now renders whenever the culling window commits. + const queryKey = useMemo( + () => + JSON.stringify({ + filters: tableQuery?.filters ?? null, + search: tableQuery?.search ?? null + }), + [tableQuery?.filters, tableQuery?.search] + ); const lastQueryKeyRef = useRef(queryKey); useEffect(() => { if (queryKey === lastQueryKeyRef.current) return; @@ -1117,20 +1449,26 @@ export function DataViewTimeline({ if (saved) { el.scrollLeft = saved.left; el.scrollTop = saved.top; - return; + } else { + const time = resolveScrollTarget(defaultScrollTo) ?? timeScale.t0; + const align = + defaultScrollTo === 'start' + ? 'start' + : defaultScrollTo === 'end' + ? 'end' + : 'center'; + scrollToTime(time, align, 'auto'); } - const time = resolveScrollTarget(defaultScrollTo) ?? timeScale.t0; - const align = - defaultScrollTo === 'start' - ? 'start' - : defaultScrollTo === 'end' - ? 'end' - : 'center'; - scrollToTime(time, align, 'auto'); + // This effect runs after the viewport-tracking one, so the offset it just + // set is newer than the tracked viewport. Re-read before paint, otherwise + // the first frame culls against scroll 0 (the domain start) and renders a + // window the user is not looking at. + readViewport(); }, [ defaultScrollTo, resolveScrollTarget, scrollToTime, + readViewport, timeScale, isActive, hasData @@ -1150,20 +1488,23 @@ export function DataViewTimeline({ ) { const leftEdgeTime = prev.t0 + el.scrollLeft / prev.pxPerMs; el.scrollLeft = Math.max(0, timeScale.x(leftEdgeTime)); + // Anchoring moved the content under the culling window — resync it in + // the same layout pass rather than a frame later. + readViewport(); } scrollAnchorRef.current = { t0: timeScale.t0, pxPerMs: timeScale.pxPerMs }; - }, [timeScale]); + }, [timeScale, readViewport]); if (!isActive) return null; // Render nothing when there's truly no data and no loading — sibling // `` / `` handle messaging. if (!hasData) return null; - // Horizontal culling window — one extra viewport on each side as overscan. - const overscan = viewport ? Math.max(viewport.width, 400) : 0; + // Horizontal culling window — half a viewport on each side as overscan. + const overscan = viewport ? overscanFor(viewport.width) : 0; const cullRange = virtualized && viewport ? { @@ -1171,22 +1512,132 @@ export function DataViewTimeline({ max: viewport.left + viewport.width + overscan } : null; - // Visible slices via binary search — both lists are sorted ascending by x, - // so per-frame culling costs O(log n + visible) instead of scanning every - // item. The card slice widens its left bound by the widest card (x is - // sorted, width isn't), so each sliced card still gets a right-edge check. - const cardSlice = cullRange - ? cards.slice( - lowerBoundByX(cards, cullRange.min - maxPackWidth), - upperBoundByX(cards, cullRange.max) - ) - : cards; - const gridTicks = cullRange + // Vertical window, same overscan rule. Only when the pane reports a height: + // an unmeasured container (jsdom, SSR) would otherwise cull to a sliver of + // lanes, so it keeps the horizontal-only behaviour instead. + const verticalOverscan = viewport ? overscanFor(viewport.height) : 0; + const verticalRange = + cullRange && viewport && viewport.height > 0 + ? { + min: viewport.top - verticalOverscan, + max: viewport.top + viewport.height + verticalOverscan + } + : null; + const laneRange = + verticalRange && laneIndex + ? resolveLaneRange( + verticalRange.min, + verticalRange.max, + uniformPitch, + laneTops, + laneHeights + ) + : null; + + /** + * Vertical span for the grid, marker, and cursor lines. + * + * The stylesheet pins them `top: 0; bottom: 0`, so each is a one-pixel-wide + * element as tall as the whole canvas — 28,798px at 10k rows, 139,170px at + * 50k. Rasterizing a dashed sub-pixel border over that height costs the same + * whether one card is on screen or four hundred, which is why culling their + * *count* (183 -> 31) never moved frame time. Clamped to the vertical window + * they cost what a viewport costs. Measured on a 10k-row canvas: p95 frame + * 291.7ms -> 20.9ms, frames over 50ms 40 -> 0. + * + * Only under `virtualized` — `needsViewport` leaves `viewport` null + * otherwise, and lines then keep their full-canvas span as before. + */ + const lineTop = verticalRange ? Math.max(0, verticalRange.min) : 0; + const lineSpan = verticalRange + ? { + top: lineTop, + height: Math.max( + 0, + Math.min(canvasHeight, verticalRange.max) - lineTop + ), + // `top` + `height` + the stylesheet's `bottom: 0` over-constrains the + // box; releasing `bottom` is what lets `height` win. + bottom: 'auto' as const + } + : null; + + // Visible cards. Two paths, both O(what's rendered) rather than O(cards): + // + // - With a lane range, walk only the lanes the viewport covers and binary + // search the x window inside each lane's run of the index. Visible lanes + // are bounded by the pane's height over the lane pitch (~10-20), so this + // stays flat no matter how many lanes exist below. + // - Without one (unmeasured height), the old global slice over the + // x-ascending `cards`. + // + // Both widen the left bound by the widest card — x is ordered, width isn't — + // so every candidate still gets an exact right-edge check. + let cardSlice: LaidOutCard[]; + if (cullRange && laneRange && laneIndex) { + const { starts, items, maxPackWidths } = laneIndex; + const visible: LaidOutCard[] = []; + for (let lane = laneRange.start; lane < laneRange.end; lane++) { + const from = starts[lane]; + const to = starts[lane + 1]; + if (from === to) continue; + const left = cullRange.min - maxPackWidths[lane]; + // A short run (`one-per-row` gives every lane exactly one card) scans + // faster than it searches. + let i = + to - from > LANE_SCAN_MAX + ? lowerBoundLaneCard(cards, items, from, to, left) + : from; + for (; i < to; i++) { + const item = cards[items[i]]; + if (item.x > cullRange.max) break; + if (item.x + item.packWidth < cullRange.min) continue; + visible.push(item); + } + } + cardSlice = visible; + } else if (cullRange) { + cardSlice = cards.slice( + lowerBoundByX(cards, cullRange.min - maxPackWidth), + upperBoundByX(cards, cullRange.max) + ); + } else { + cardSlice = cards; + } + // Axis chrome, culled to the same window as the cards. A year at day scale + // is ~180 tick labels and 12 bands; a decade is ten times that, all of it + // mounted for a viewport showing a few weeks. + const visibleTicks = cullRange ? ticks.slice( lowerBoundByX(ticks, cullRange.min), upperBoundByX(ticks, cullRange.max) ) : ticks; + // Bands tile the domain edge to edge, so the one straddling the left bound + // starts before it — step back one from the first band past the bound rather + // than widening by a max width the way the cards do. + const visibleBands = cullRange + ? bands.slice( + Math.max(0, upperBoundByX(bands, cullRange.min) - 1), + upperBoundByX(bands, cullRange.max) + ) + : bands; + // Markers are consumer-supplied and usually few, so a scan beats a search. + const visibleMarkers = cullRange + ? resolvedMarkers.filter( + marker => marker.x >= cullRange.min && marker.x <= cullRange.max + ) + : resolvedMarkers; + // Group slots stack the same way lanes do — ascending, contiguous, each + // spanning its whole section — so the vertical window slices them directly. + // The section under the pin line always intersects the window, which is what + // keeps its sticky band pinned. + const visibleGroupBands = verticalRange + ? groupBands.slice( + lowerBoundByBottom(groupBandTops, groupBandHeights, verticalRange.min), + upperBoundByTop(groupBandTops, verticalRange.max) + ) + : groupBands; const cardClassName = cx( styles.timelineCard, onRowClick && styles.clickable, @@ -1220,7 +1671,7 @@ export function DataViewTimeline({ style={{ width: timeScale.totalWidth }} data-slot='data-view-timeline-axis' > - {bands.map(band => ( + {visibleBands.map(band => (
({
))} - {ticks.map(tick => + {visibleTicks.map(tick => tick.showLabel ? (
({
) : null )} - {resolvedMarkers.map(marker => ( + {visibleMarkers.map(marker => (
({ style={{ width: timeScale.totalWidth, height: canvasHeight }} data-slot='data-view-timeline-group-layer' > - {groupBands.map(band => ( + {visibleGroupBands.map(band => (
({ data-slot='data-view-timeline-canvas' > {showGridlines - ? gridTicks.map(tick => + ? visibleTicks.map(tick => tick.index % gridlineEvery === 0 ? (