From 8ae11df1afa90589f71b0e79eaf98a9c14487b40 Mon Sep 17 00:00:00 2001 From: Rishabh Date: Tue, 11 Aug 2026 15:42:48 +0530 Subject: [PATCH 01/10] perf(data-view): virtualize the timeline vertically MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `virtualized` culled horizontally only, so every card whose x fell in the time window mounted regardless of where it sat vertically. With one lane per row, 10k cards meant 10k wrappers and 10k ResizeObservers on a canvas ~820k px tall. Culling now covers both axes, at a cost proportional to what is on screen: - Cards are indexed by lane in CSR form (one flat Int32Array, no per-lane allocation). A frame walks only the lanes the viewport covers and searches the x window inside each, rather than slicing every card in the time window. Visible lanes are bounded by pane height over lane pitch, not by row count. - Lane lookup inverts the pitch arithmetically when the stack is uniform, and binary-searches the lane boxes when group bands break it. - The viewport is read in a layout effect, so the first paint is already culled instead of flashing the whole canvas. Lane heights become fixed to `estimatedRowHeight` while virtualized. A culled card never mounts and so never measures, so measured lanes would resize under the user mid-scroll. This is a behaviour change: a card taller than the value now overflows its lane instead of growing it. The unvirtualized path still measures and re-stacks exactly as before. Also replaces two hot spots the culling would otherwise inherit: - `packLanes` swaps its per-item lane scan for a sweep (free-lane bitmap plus a Dial bucket queue) past 64 items. Identical assignment — first-fit is smallest-free-lane — in O(n) rather than O(items x lanes). 10k mutually overlapping cards: 53.4ms -> 0.8ms; at realistic density 4.5ms -> 1.1ms. - Ordering cards by x moves to a counting sort, 1.9ms -> 0.1ms, degrading to a comparison sort on clustered input. 10k cards over a year: 10,000 rendered wrappers -> 255, 20,760 DOM nodes -> 965, 10,000 ResizeObservers -> 0. Per-frame cost in a real browser is not yet measured; jsdom does no layout. Lane assignment is pinned by golden digests recorded from the previous implementation, so the rewrites are verified against it rather than against their own output. Co-Authored-By: Claude Opus 5 (1M context) --- .../data-view/__tests__/timeline.test.tsx | 458 +++++++++++++++++ .../data-view/components/timeline.tsx | 466 ++++++++++++++---- .../components/data-view/data-view.types.tsx | 23 +- .../components/data-view/utils/order-by-x.tsx | 110 +++++ .../components/data-view/utils/pack-lanes.tsx | 175 ++++++- 5 files changed, 1129 insertions(+), 103 deletions(-) create mode 100644 packages/raystack/components/data-view/utils/order-by-x.tsx diff --git a/packages/raystack/components/data-view/__tests__/timeline.test.tsx b/packages/raystack/components/data-view/__tests__/timeline.test.tsx index cdf089356..7482e9678 100644 --- a/packages/raystack/components/data-view/__tests__/timeline.test.tsx +++ b/packages/raystack/components/data-view/__tests__/timeline.test.tsx @@ -20,6 +20,7 @@ import type { TimelineActions } from '../data-view.types'; import { useDataView } from '../hooks/useDataView'; +import { orderByX } from '../utils/order-by-x'; import { packLanes } from '../utils/pack-lanes'; import { buildAxis, createTimeScale, toTimestamp } from '../utils/time-scale'; @@ -41,6 +42,129 @@ afterEach(() => { vi.restoreAllMocks(); }); +/* ─────────────────── 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: a card's + lane is its vertical position on the canvas, and `context.laneIndex` is + public API through `renderCard`. + + 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. */ + +/** Seeded LCG — a failing case has to be reproducible. */ +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. */ +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'); +} + +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) + })); +}; + +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", + } + `); + }); +}); + /* ─────────────────────────── utils: packLanes ────────────────────────── */ describe('packLanes', () => { @@ -90,6 +214,103 @@ describe('packLanes', () => { expect(lanes).toEqual([0, 0, 1]); expect(laneCount).toBe(2); }); + + 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. The oracle is the pre-rewrite implementation, + // transcribed — every input here is past the size where packLanes switches + // from that scan to the sweep. + function packByScanReference( + items: { x: number; width: number }[], + gapPx = 8 + ) { + 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 }; + } + + for (let seed = 1; seed <= 25; seed++) { + const items = randomItems(seed, 300); + expect(packLanes(items)).toEqual(packByScanReference(items)); + } + }); +}); + +/* ─────────────────────────── utils: orderByX ─────────────────────────── */ + +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('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('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) + })); + const expected = items + .map((_, i) => i) + .sort((a, b) => items[a].x - items[b].x || a - b); + expect(Array.from(orderByX(items))).toEqual(expected); + } + }); + + 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) + ); + }); }); /* ─────────────────────────── utils: time scale ───────────────────────── */ @@ -1120,6 +1341,243 @@ 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('renders every card when virtualized without a measurable viewport', () => { + // jsdom reports a zero-size client box, as does SSR. The cull window is + // then [-400, 400] horizontally with nothing vertical, so both cards here + // survive — this pins the fallback, not the culling. + renderTimeline({ virtualized: true }); + expect(cardIds()).toEqual(['o1', 'o3', 'o2']); + }); + + 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); + }); +}); + +/* ──────────────────────────── 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. */ + +describe('DataView.Timeline virtualization', () => { + /** Give the scroll pane a viewport; everything else keeps jsdom's zeroes. */ + 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); + } + ); + } + + /** 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 + ); + // Window [0 - 400, 0 + 200 + 400] → lanes 0-7 (lane 8's top is 672). + // Without vertical culling all 12 mount, and 10k rows mount 10k. + expect(renderedIds()).toEqual([ + 'r01', + 'r02', + 'r03', + 'r04', + 'r05', + 'r06', + 'r07', + 'r08' + ]); + }); + + 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 [100, 1100] → lane 0 (bottom 82) drops off the top and the lanes + // past the initial cut-off mount. + expect(renderedIds()).toEqual([ + 'r02', + 'r03', + 'r04', + 'r05', + 'r06', + 'r07', + 'r08', + 'r09', + 'r10', + 'r11', + 'r12' + ]); + }); + + 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 = 700; + fireEvent.scroll(root); + await new Promise(resolve => setTimeout(resolve, 30)); + }); + // Window [300, 1300]: Eng's lanes end at 120 and 202, above it. 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 = 3000; + fireEvent.scroll(root); + await new Promise(resolve => setTimeout(resolve, 30)); + }); + // Window [2400, 4200]. 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..9fa29ecf5 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,112 @@ 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 lane whose bottom edge reaches `value`. Lane boxes stack without + * overlapping, so tops *and* bottoms both ascend — the search holds for the + * measured (variable-height) geometry too, not just the fixed pitch. + */ +function lowerBoundLaneByBottom( + 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 lane whose top edge is past `value`. */ +function upperBoundLaneByTop(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; + +/** + * 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: lowerBoundLaneByBottom(tops, heights, min), + end: upperBoundLaneByTop(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 +226,14 @@ interface PositionedItem extends TimedItem { packWidth: number; } +/** A positioned row with its lane resolved, ready to render. */ +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 +258,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 +287,7 @@ function TimelineCardViewInner({ startTime, endTime, renderCard, + measure, onMeasure, onRowClick, className @@ -179,14 +300,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, @@ -545,6 +666,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,22 +691,34 @@ 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. + // 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 = useMemo(() => { - const list = laidOutSections.flatMap(section => - section.items.map((item, index) => ({ - ...item, - // Section-relative (renderCard's `context.laneIndex`) … - 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; + const list: LaidOutCard[] = []; + for (const section of laidOutSections) { + for (let index = 0; index < section.items.length; index++) { + list.push({ + ...section.items[index], + // Section-relative (renderCard's `context.laneIndex`) … + laneIndex: section.lanes[index], + // … and global, for the lane-top lookup at render time. + lane: section.laneOffset + section.lanes[index] + }); + } + } + // Counting sort rather than `Array#sort` — see `orderByX`. + const order = orderByX(list); + const sorted = new Array>(list.length); + for (let i = 0; i < order.length; i++) sorted[i] = list[order[i]]; + return sorted; }, [laidOutSections]); // Drop measurements for rows that left the data set so a shrunk lane @@ -599,60 +741,88 @@ export function DataViewTimeline({ // 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(() => { - // 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 tops = new Array(laneCount); - const bands: { - key: string; - group: GroupedData; - top: number; - height: number; - }[] = []; - let y = 0; - for (const section of laidOutSections) { - const sectionTop = y; - const banded = showGroupHeaders && section.group !== null; - if (banded) y += GROUP_BAND_HEIGHT; - y += laneGap; - for (let i = 0; i < section.laneCount; i++) { - const lane = section.laneOffset + i; - // A lane with no cards (empty data) still reserves the estimate. - if (heights[lane] === 0) heights[lane] = estimatedRowHeight; - tops[lane] = y; - y += heights[lane] + laneGap; + const { laneTops, laneHeights, uniformPitch, groupBands, canvasHeight } = + useMemo(() => { + // Reads measuredHeightsRef; measureVersion invalidates on new reports. + void measureVersion; + const measured = measuredHeightsRef.current; + 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; + } } - // Slots are contiguous (each spans its whole section, trailing gap - // included), so a pinned band is pushed out by the next section's band - // exactly as that one arrives at the pin line. - if (banded && section.group) { - bands.push({ - key: section.key, - group: section.group, - top: sectionTop, - height: y - sectionTop - }); + const tops = new Array(laneCount); + const bands: { + key: string; + group: GroupedData; + top: number; + height: number; + }[] = []; + let y = 0; + for (const section of laidOutSections) { + const sectionTop = y; + const banded = showGroupHeaders && section.group !== null; + if (banded) y += GROUP_BAND_HEIGHT; + y += laneGap; + for (let i = 0; i < section.laneCount; i++) { + const lane = section.laneOffset + i; + // A lane with no cards (empty data) still reserves the estimate. + if (heights[lane] === 0) heights[lane] = estimatedRowHeight; + tops[lane] = y; + y += heights[lane] + laneGap; + } + // Slots are contiguous (each spans its whole section, trailing gap + // included), so a pinned band is pushed out by the next section's band + // exactly as that one arrives at the pin line. + if (banded && section.group) { + bands.push({ + key: section.key, + group: section.group, + top: sectionTop, + height: y - sectionTop + }); + } } - } - // 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 }; - }, [ - cards, - laidOutSections, - laneCount, - estimatedRowHeight, - laneGap, - showGroupHeaders, - measureVersion - ]); + // 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; + // 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, + 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. @@ -661,6 +831,38 @@ export function DataViewTimeline({ [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[] = []; if ( @@ -697,6 +899,8 @@ export function DataViewTimeline({ const [viewport, setViewport] = useState<{ left: number; width: number; + top: number; + height: number; } | null>(null); const rafIdRef = useRef(null); @@ -704,8 +908,19 @@ export function DataViewTimeline({ const el = scrollRef.current; if (!el) return; setViewport(prev => { - const next = { left: el.scrollLeft, width: el.clientWidth }; - if (prev && prev.left === next.left && prev.width === next.width) { + const next = { + left: el.scrollLeft, + width: el.clientWidth, + top: el.scrollTop, + height: el.clientHeight + }; + if ( + prev && + prev.left === next.left && + prev.width === next.width && + prev.top === next.top && + prev.height === next.height + ) { return prev; } return next; @@ -936,7 +1151,10 @@ 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. + useLayoutEffect(() => { if (!needsViewport || !isActive) return; readViewport(); const el = scrollRef.current; @@ -944,7 +1162,8 @@ export function DataViewTimeline({ const observer = new ResizeObserver(readViewport); observer.observe(el); return () => observer.disconnect(); - }, [needsViewport, isActive, readViewport]); + // `hasData` re-runs the attempt when the renderer mounts its DOM. + }, [needsViewport, isActive, hasData, readViewport]); // Deduped within one pixel of time — `timeScale`/`viewport` identity churn // (a resize, a domain rebuild from streamed-in rows) must not re-fire @@ -1117,20 +1336,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,12 +1375,15 @@ 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 @@ -1171,16 +1399,63 @@ 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; + // 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 ? Math.max(viewport.height, 400) : 0; + const laneRange = + cullRange && viewport && viewport.height > 0 && laneIndex + ? resolveLaneRange( + viewport.top - verticalOverscan, + viewport.top + viewport.height + verticalOverscan, + uniformPitch, + laneTops, + laneHeights + ) + : 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; + } const gridTicks = cullRange ? ticks.slice( lowerBoundByX(ticks, cullRange.min), @@ -1372,6 +1647,7 @@ export function DataViewTimeline({ startTime={item.startTime} endTime={item.endTime} renderCard={renderCard} + measure={!fixedLaneHeight} onMeasure={handleCardMeasure} onRowClick={onRowClick} className={cardClassName} diff --git a/packages/raystack/components/data-view/data-view.types.tsx b/packages/raystack/components/data-view/data-view.types.tsx index bb8ac8a6c..79601c67a 100644 --- a/packages/raystack/components/data-view/data-view.types.tsx +++ b/packages/raystack/components/data-view/data-view.types.tsx @@ -407,10 +407,17 @@ export interface DataViewTimelineProps { */ lanePacking?: 'auto' | 'one-per-row'; /** - * Estimated card height in px, same contract as `DataView.List`: cards - * render at their natural content height and are measured after paint; the - * estimate only seeds lane layout until real heights arrive. Each lane - * sizes to its tallest card. Default 66. + * Lane height in px. Default 66. + * + * Unvirtualized this is an estimate, same contract as `DataView.List`: cards + * render at their natural content height and are measured after paint, the + * estimate only seeding lane layout until real heights arrive, and each lane + * sizing to its tallest card. + * + * With `virtualized` it is exact. A culled card never mounts and so never + * measures, so measured lanes would resize under the user as they scroll — + * lanes take this value instead, and a card taller than it overflows its + * lane rather than growing it. Set it to your card's height. */ estimatedRowHeight?: number; /** Vertical gap between lanes in px. Default 16. */ @@ -425,7 +432,13 @@ export interface DataViewTimelineProps { */ estimatedPointWidth?: number; - /** When true, only cards/gridlines near the visible viewport are rendered (horizontal culling). */ + /** + * Render only the cards and gridlines near the visible viewport, culling on + * both axes — a frame costs what's on screen rather than what's in the data. + * Recommended whenever the domain is long or rows are numerous. + * + * Lane heights become fixed to `estimatedRowHeight`; see the note there. + */ virtualized?: boolean; /** diff --git a/packages/raystack/components/data-view/utils/order-by-x.tsx b/packages/raystack/components/data-view/utils/order-by-x.tsx new file mode 100644 index 000000000..843f5610e --- /dev/null +++ b/packages/raystack/components/data-view/utils/order-by-x.tsx @@ -0,0 +1,110 @@ +/** Anything placed on the timeline's x axis. */ +export interface XPositioned { + x: number; +} + +/** + * Below this the counting sort's setup (three passes plus two typed arrays) + * costs more than a comparison sort. `packLanes` runs per group section, so + * most calls are small. + */ +const BUCKET_SORT_MIN_ITEMS = 64; + +/** + * A bucket deeper than this would drag insertion sort towards O(m²) (thousands + * of cards landing in one pixel column), so it hands off to a comparison sort + * instead — bounding the pathological case at O(n log n). + */ +const INSERTION_SORT_MAX_BUCKET = 32; + +/** + * Item indices ordered ascending by `x`, ties broken by input order. + * + * Counting sort over uniform x buckets. `x` is affine in time, so cards spread + * near-uniformly across the domain and buckets stay ~1 deep — O(n) at the + * sizes that matter, where a comparison sort is O(n log n). Clustered input + * degrades gracefully rather than falling off a cliff (see the two constants + * above). + * + * Returns indices rather than sorted items so callers can reuse one ordering + * for several parallel arrays without copying the items themselves. + */ +export function orderByX(items: readonly XPositioned[]): Int32Array { + const n = items.length; + const order = new Int32Array(n); + if (n === 0) return order; + + if (n < BUCKET_SORT_MIN_ITEMS) { + const plain = new Array(n); + for (let i = 0; i < n; i++) plain[i] = i; + plain.sort((a, b) => items[a].x - items[b].x || a - b); + order.set(plain); + return order; + } + + let minX = Infinity; + let maxX = -Infinity; + for (let i = 0; i < n; i++) { + const { x } = items[i]; + if (x < minX) minX = x; + if (x > maxX) maxX = x; + } + + const span = maxX - minX; + // Every item at the same x (or a non-finite extent): input order already is + // the tie-break order. + if (!(span > 0)) { + for (let i = 0; i < n; i++) order[i] = i; + return order; + } + + // One bucket per item — the density that keeps buckets ~1 deep. + const bucketCount = n; + const scale = bucketCount / span; + const bucketOf = new Int32Array(n); + // `starts` is counts shifted by one, prefix-summed in place: after the sum, + // starts[b] is bucket b's first slot and starts[b + 1] its end. + const starts = new Int32Array(bucketCount + 1); + for (let i = 0; i < n; i++) { + let bucket = Math.floor((items[i].x - minX) * scale); + if (bucket < 0) bucket = 0; + else if (bucket >= bucketCount) bucket = bucketCount - 1; + bucketOf[i] = bucket; + starts[bucket + 1]++; + } + for (let bucket = 0; bucket < bucketCount; bucket++) { + starts[bucket + 1] += starts[bucket]; + } + + // Stable scatter — within a bucket, items stay in input order, which is the + // tie-break the comparison path applies for equal x. + const cursor = Int32Array.from(starts.subarray(0, bucketCount)); + for (let i = 0; i < n; i++) order[cursor[bucketOf[i]]++] = i; + + for (let bucket = 0; bucket < bucketCount; bucket++) { + const from = starts[bucket]; + const to = starts[bucket + 1]; + const size = to - from; + if (size < 2) continue; + if (size <= INSERTION_SORT_MAX_BUCKET) { + // Insertion sort with a strict `>` shift is stable, so equal-x items + // keep the input order the scatter gave them. + for (let i = from + 1; i < to; i++) { + const index = order[i]; + const { x } = items[index]; + let j = i - 1; + while (j >= from && items[order[j]].x > x) { + order[j + 1] = order[j]; + j--; + } + order[j + 1] = index; + } + } else { + const slice = Array.from(order.subarray(from, to)); + slice.sort((a, b) => items[a].x - items[b].x || a - b); + order.set(slice, from); + } + } + + return order; +} diff --git a/packages/raystack/components/data-view/utils/pack-lanes.tsx b/packages/raystack/components/data-view/utils/pack-lanes.tsx index 295dd8365..d589d3f2e 100644 --- a/packages/raystack/components/data-view/utils/pack-lanes.tsx +++ b/packages/raystack/components/data-view/utils/pack-lanes.tsx @@ -1,3 +1,5 @@ +import { orderByX } from './order-by-x'; + export interface PackLaneItem { /** Left edge in px (time-scale space). */ x: number; @@ -17,20 +19,40 @@ export interface PackLanesResult { */ const DEFAULT_CARD_GAP_PX = 8; +/** + * Below this, scanning `laneEnds` linearly beats the sweep's typed-array + * setup — the scan is only quadratic once both the item count and the lane + * count are large. + */ +const SWEEP_MIN_ITEMS = 64; + /** * Greedy interval scheduling. Items are visited in ascending `x` order and * each is dropped into the first lane whose last occupant ends at least * `gapPx` before the item starts; a new lane is opened when none fits. * Produces the dense "packed" layout of the timeline design — many * non-overlapping cards share a lane. + * + * Two implementations, identical output: a direct scan for small inputs, and + * an O(n) sweep once the input is large enough for the scan's O(items × lanes) + * to bite (10k mutually overlapping cards is ~10^7 comparisons). */ export function packLanes( items: PackLaneItem[], gapPx: number = DEFAULT_CARD_GAP_PX ): PackLanesResult { - const order = items - .map((_, i) => i) - .sort((a, b) => items[a].x - items[b].x || a - b); + const order = orderByX(items); + return items.length < SWEEP_MIN_ITEMS + ? packByScan(items, gapPx, order) + : packBySweep(items, gapPx, order); +} + +/** First-fit by scanning every lane end — O(items × lanes). */ +function packByScan( + items: PackLaneItem[], + gapPx: number, + order: Int32Array +): PackLanesResult { const laneEnds: number[] = []; const lanes = new Array(items.length).fill(0); for (const index of order) { @@ -45,3 +67,150 @@ export function packLanes( } return { lanes, laneCount: laneEnds.length }; } + +/** + * First-fit as a left-to-right sweep — same assignment as `packByScan`, in + * O(n) rather than O(items × lanes). + * + * Two structures replace the linear `findIndex`: + * + * - A **free-lane bitmap** (words plus a summary word per 32 words) answers + * "smallest free lane" in a couple of `Math.clz32` calls. Smallest-free-id + * is exactly what first-fit picks, so lane assignment is unchanged. + * - A **bucket queue** releases lanes as the sweep passes them. Lanes are + * filed under the column their occupant frees up in; because the sweep + * advances monotonically in x, columns strictly behind the current one + * release wholesale, and only the current column needs an exact per-lane + * check. That is Dial's monotone priority queue: O(1) amortized per + * insert/extract, versus O(log lanes) for a heap. + */ +function packBySweep( + items: PackLaneItem[], + gapPx: number, + order: Int32Array +): PackLanesResult { + const n = items.length; + const lanes = new Array(n).fill(0); + + // Column space spans starts *and* release times, so a lane always files + // into a real column. + let minX = Infinity; + let maxRelease = -Infinity; + for (let i = 0; i < n; i++) { + const item = items[i]; + if (item.x < minX) minX = item.x; + const release = item.x + item.width + gapPx; + if (release > maxRelease) maxRelease = release; + } + // Degenerate extents (every card at one x, non-finite geometry) collapse to + // a single column: the exact per-lane check still runs, it just runs on the + // whole queue. + const span = maxRelease - minX; + const colCount = span > 0 ? n : 1; + const colScale = span > 0 ? colCount / span : 0; + const colOf = (value: number) => { + const col = Math.floor((value - minX) * colScale); + if (col < 0) return 0; + return col >= colCount ? colCount - 1 : col; + }; + + // Release queue: an intrusive singly-linked list per column. A lane is in at + // most one column at a time, so `releaseNext` needs one slot per lane and + // lanes never exceed items. + const releaseHead = new Int32Array(colCount).fill(-1); + const releaseNext = new Int32Array(n).fill(-1); + /** Time (px) at which each lane's occupant frees it — end + gap. */ + const laneRelease = new Float64Array(n); + + // Free-lane bitmap. `summary` bit s.w is set when word w of block s has any + // free lane, so the search skips 1024 lanes at a time. + const wordCount = (n + 31) >> 5; + const words = new Uint32Array(wordCount); + const summary = new Uint32Array((wordCount + 31) >> 5); + + const markFree = (lane: number) => { + const word = lane >> 5; + words[word] |= 1 << (lane & 31); + summary[word >> 5] |= 1 << (word & 31); + }; + + /** Lowest set bit's index. Undefined for 0 — callers guard. */ + const lowestBit = (bits: number) => 31 - Math.clz32(bits & -bits); + + const takeSmallestFree = () => { + for (let block = 0; block < summary.length; block++) { + while (summary[block] !== 0) { + const blockBits = summary[block]; + const wordOffset = lowestBit(blockBits); + const word = (block << 5) + wordOffset; + const bits = words[word]; + if (bits === 0) { + // Word emptied without its summary bit clearing — can't happen + // below, but clearing here keeps the loop finite regardless. + summary[block] = blockBits & ~(1 << wordOffset); + continue; + } + const bitOffset = lowestBit(bits); + words[word] = bits & ~(1 << bitOffset); + if (words[word] === 0) summary[block] = blockBits & ~(1 << wordOffset); + return (word << 5) + bitOffset; + } + } + return -1; + }; + + let laneCount = 0; + // Every column before this one has been drained. + let drainedCol = 0; + + for (let k = 0; k < n; k++) { + const index = order[k]; + const item = items[index]; + const x = item.x; + const col = colOf(x); + + // Columns strictly behind the sweep release unconditionally: their release + // times all fall below the current column's left edge, which is <= x. + while (drainedCol < col) { + let lane = releaseHead[drainedCol]; + while (lane !== -1) { + const next = releaseNext[lane]; + releaseNext[lane] = -1; + markFree(lane); + lane = next; + } + releaseHead[drainedCol] = -1; + drainedCol++; + } + + // The current column straddles x, so its lanes need the exact test. Ones + // that aren't free yet are relinked for the next item in this column. + let pending = releaseHead[col]; + let stillBusy = -1; + while (pending !== -1) { + const next = releaseNext[pending]; + if (laneRelease[pending] <= x) { + releaseNext[pending] = -1; + markFree(pending); + } else { + releaseNext[pending] = stillBusy; + stillBusy = pending; + } + pending = next; + } + releaseHead[col] = stillBusy; + + let lane = takeSmallestFree(); + if (lane === -1) lane = laneCount++; + lanes[index] = lane; + + const release = x + item.width + gapPx; + laneRelease[lane] = release; + // Release is at or after x, so this never files into a drained column. + const releaseCol = colOf(release); + releaseNext[lane] = releaseHead[releaseCol]; + releaseHead[releaseCol] = lane; + } + + return { lanes, laneCount }; +} From 4dcdbb24c7d8e6ba17afb0c2577d20102376583c Mon Sep 17 00:00:00 2001 From: Rishabh Date: Wed, 12 Aug 2026 08:18:04 +0530 Subject: [PATCH 02/10] perf(data-view): cull timeline axis chrome, cut per-scroll work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of the timeline virtualization work: the axis furniture was never culled, and the culling window was recomputed on every scroll frame. Chrome culling. Gridlines already sliced to the visible window, but tick labels, month bands, marker badges and lines, and group-section slots all rendered across the whole domain however narrow the window onto it was. They now share the card window. Over a year at day scale: 183 tick labels -> 31, 12 month bands -> 3, and a group slot scrolled far off-screen no longer mounts. Bands tile the domain edge to edge, so the one straddling the left bound is kept by stepping back from the first band past it, which is what the sticky month label rides on. Scroll work. Three changes, measured in a production build at 50k rows: - The culling window is quantized: it holds until the pane has travelled half its overscan, instead of committing React state on all 60 frames a second to rebuild a nearly identical slice. Scrolling is compositor work the browser does for free; this keeps React out of it. p95 frame 68.7ms -> 32.6ms. - Overscan drops from a full viewport per side to half. The mounted set covers (1 + 2r)^2 viewports, so cost grows with the square of overscan while the travel it buys grows linearly. Mounted cards at 10k: 287 -> 141. - `onVisibleRangeChange` reads the live offset from a ref rather than the quantized state, so it stays exact while no longer forcing a render. A timeline with only that prop set now re-renders on nothing while scrolling. Allocation and complexity, mostly aimed at mount: - `timedItems` materialised every row again to compute a two-number extent; now reduced in place, O(1) space. - Lane fields are written onto the positioned items instead of spread into new objects — one object per card for the whole pipeline instead of two. - `maxPackWidth` folded into the ordering pass rather than its own traversal. - The measurement-cleanup sweep and its row-id Set are skipped under a fixed lane pitch, where nothing reads the measurements. - `queryKey`'s JSON.stringify was running on every render, so on every commit. Also drops one ResizeObserver per card while virtualized (verified: 10,001 -> 0 at 10k rows), since a fixed pitch consumes no measurements. Adds apps/www/src/app/examples/timeline-stress as a manual-QA harness at 1k/10k/50k rows with toggles and a live DOM/frame readout. What this does not fix: at 50k the commit itself still costs ~70ms in a production build, and that cost is flat against the rendered set — 450 cards and 265 cards measure the same, and 17 cards on a taller canvas measured worse. It is browser-side style/layout/paint over a 14,600 x 139,170px canvas, so no amount of further culling touches it. Fixing it means not having a canvas that size: a viewport-sized layer positioned by transform over a full-size spacer. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/app/examples/timeline-stress/page.tsx | 363 +++++++++++++ .../data-view/__tests__/timeline.test.tsx | 190 +++++-- .../data-view/components/timeline.tsx | 482 ++++++++++++------ 3 files changed, 823 insertions(+), 212 deletions(-) create mode 100644 apps/www/src/app/examples/timeline-stress/page.tsx 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..167ed65da --- /dev/null +++ b/apps/www/src/app/examples/timeline-stress/page.tsx @@ -0,0 +1,363 @@ +'use client'; + +import { + Button, + // biome-ignore lint/suspicious/noShadowRestrictedNames: legitimate export name + DataView, + type DataViewField, + Flex, + Text, + type TimelineCardContext +} 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'); +const DOMAIN_DAYS = 365; + +/** 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): Task[] { + const random = seededRandom(count); + return Array.from({ length: count }, (_, i) => { + const startDay = Math.floor(random() * DOMAIN_DAYS); + // 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; + canvasHeight: 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. + */ +function useFrameMonitor(paneRef: React.RefObject) { + const [worstFrame, setWorstFrame] = useState(0); + const frameRef = useRef(null); + + const measure = useCallback(() => { + if (frameRef.current !== null) return; + let worst = 0; + let last = performance.now(); + let until = last + 1000; + const step = () => { + const now = performance.now(); + const delta = now - last; + last = now; + if (delta > worst) worst = delta; + if (now < until) { + frameRef.current = requestAnimationFrame(step); + } else { + frameRef.current = null; + setWorstFrame(Math.round(worst)); + } + }; + // Extend the window while the user keeps scrolling. + until = performance.now() + 1000; + frameRef.current = requestAnimationFrame(step); + }, []); + + useEffect(() => { + const el = paneRef.current; + if (!el) return; + el.addEventListener('scroll', measure, { passive: true }); + return () => { + el.removeEventListener('scroll', measure); + if (frameRef.current !== null) cancelAnimationFrame(frameRef.current); + }; + }, [paneRef, measure]); + + return worstFrame; +} + +export default function TimelineStressPage() { + const [rowCount, setRowCount] = useState(10_000); + 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); + + const paneRef = useRef(null); + const worstFrame = useFrameMonitor(paneRef); + + const tasks = useMemo(() => makeTasks(rowCount), [rowCount]); + + /** + * 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}-${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"]' + ); + paneRef.current = pane; + if (!pane) return; + const count = (slot: string) => + pane.querySelectorAll(`[data-slot="data-view-timeline-${slot}"]`) + .length; + const canvas = pane.querySelector('[role="list"]'); + 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'), + canvasHeight: canvas ? Math.round(canvas.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 => ( + + ))} + + + + + 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 height', + `${stats?.canvasHeight.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={['2025-01-01', '2025-12-31']} + scale='day' + unitWidth={40} + virtualized={virtualized} + lanePacking={onePerRow ? 'one-per-row' : 'auto'} + defaultScrollTo='start' + markers={[{ date: '2025-07-01', label: 'H2', variant: 'accent' }]} + renderCard={ + stableRenderCard + ? memoizedRenderCard + : (row, context) => ( + + ) + } + /> + + + + + ); +} diff --git a/packages/raystack/components/data-view/__tests__/timeline.test.tsx b/packages/raystack/components/data-view/__tests__/timeline.test.tsx index 7482e9678..93402d408 100644 --- a/packages/raystack/components/data-view/__tests__/timeline.test.tsx +++ b/packages/raystack/components/data-view/__tests__/timeline.test.tsx @@ -1369,12 +1369,12 @@ describe('DataView.Timeline (characterisation)', () => { expect(screen.getByTestId('card-o3').dataset.lane).toBe('2'); }); - it('renders every card when virtualized without a measurable viewport', () => { - // jsdom reports a zero-size client box, as does SSR. The cull window is - // then [-400, 400] horizontally with nothing vertical, so both cards here - // survive — this pins the fallback, not the culling. + 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', 'o2']); + expect(cardIds()).toEqual(['o1', 'o3']); }); it('ignores measured card heights when virtualized', () => { @@ -1430,6 +1430,114 @@ describe('DataView.Timeline (characterisation)', () => { }); }); +/* ─────────────────── 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 @@ -1437,23 +1545,26 @@ describe('DataView.Timeline (characterisation)', () => { Lane pitch under virtualization is fixed: estimatedRowHeight (66) + laneGap (16) = 82, lane 0 starting at 16. */ -describe('DataView.Timeline virtualization', () => { - /** Give the scroll pane a viewport; everything else keeps jsdom's zeroes. */ - 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); - } - ); - } +/** + * 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')}`, @@ -1473,18 +1584,10 @@ describe('DataView.Timeline virtualization', () => { { virtualized: true, lanePacking: 'one-per-row' }, stackedOrders ); - // Window [0 - 400, 0 + 200 + 400] → lanes 0-7 (lane 8's top is 672). - // Without vertical culling all 12 mount, and 10k rows mount 10k. - expect(renderedIds()).toEqual([ - 'r01', - 'r02', - 'r03', - 'r04', - 'r05', - 'r06', - 'r07', - 'r08' - ]); + // 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 () => { @@ -1499,11 +1602,9 @@ describe('DataView.Timeline virtualization', () => { fireEvent.scroll(root); await new Promise(resolve => setTimeout(resolve, 30)); }); - // Window [100, 1100] → lane 0 (bottom 82) drops off the top and the lanes - // past the initial cut-off mount. + // 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([ - 'r02', - 'r03', 'r04', 'r05', 'r06', @@ -1511,8 +1612,7 @@ describe('DataView.Timeline virtualization', () => { 'r08', 'r09', 'r10', - 'r11', - 'r12' + 'r11' ]); }); @@ -1526,13 +1626,13 @@ describe('DataView.Timeline virtualization', () => { const root = container.firstElementChild as HTMLElement; await act(async () => { - root.scrollTop = 700; + root.scrollTop = 450; fireEvent.scroll(root); await new Promise(resolve => setTimeout(resolve, 30)); }); - // Window [300, 1300]: Eng's lanes end at 120 and 202, above it. Bands break - // the uniform pitch, so this exercises the searched path, not the - // arithmetic one. + // 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(); @@ -1556,11 +1656,11 @@ describe('DataView.Timeline virtualization', () => { ); const root = container.firstElementChild as HTMLElement; await act(async () => { - root.scrollLeft = 3000; + root.scrollLeft = 2400; fireEvent.scroll(root); await new Promise(resolve => setTimeout(resolve, 30)); }); - // Window [2400, 4200]. Culling searches by x, which is ordered, while + // 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 diff --git a/packages/raystack/components/data-view/components/timeline.tsx b/packages/raystack/components/data-view/components/timeline.tsx index 9fa29ecf5..9e7ee3517 100644 --- a/packages/raystack/components/data-view/components/timeline.tsx +++ b/packages/raystack/components/data-view/components/timeline.tsx @@ -110,11 +110,12 @@ function upperBoundByX(list: readonly { x: number }[], value: number): number { } /** - * First lane whose bottom edge reaches `value`. Lane boxes stack without - * overlapping, so tops *and* bottoms both ascend — the search holds for the - * measured (variable-height) geometry too, not just the fixed pitch. + * 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 lowerBoundLaneByBottom( +function lowerBoundByBottom( tops: readonly number[], heights: readonly number[], value: number @@ -129,8 +130,8 @@ function lowerBoundLaneByBottom( return lo; } -/** First lane whose top edge is past `value`. */ -function upperBoundLaneByTop(tops: readonly number[], value: number): number { +/** 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) { @@ -144,6 +145,26 @@ function upperBoundLaneByTop(tops: readonly number[], value: number): number { /** 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. @@ -182,8 +203,8 @@ function resolveLaneRange( }; } return { - start: lowerBoundLaneByBottom(tops, heights, min), - end: upperBoundLaneByTop(tops, max) + start: lowerBoundByBottom(tops, heights, min), + end: upperBoundByTop(tops, max) }; } @@ -226,7 +247,11 @@ interface PositionedItem extends TimedItem { packWidth: number; } -/** A positioned row with its lane resolved, ready to render. */ +/** + * 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; @@ -527,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; @@ -565,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); @@ -584,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( () => @@ -616,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) { @@ -635,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 }); @@ -701,29 +743,44 @@ export function DataViewTimeline({ // 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 = useMemo(() => { + 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++) { - list.push({ - ...section.items[index], - // Section-relative (renderCard's `context.laneIndex`) … - laneIndex: section.lanes[index], - // … and global, for the lane-top lookup at render time. - lane: section.laneOffset + section.lanes[index] - }); + const item = section.items[index]; + // Section-relative (renderCard's `context.laneIndex`) … + item.laneIndex = section.lanes[index]; + // … and global, for the lane-top lookup at render time. + 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); - for (let i = 0; i < order.length; i++) sorted[i] = list[order[i]]; - return sorted; + // 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; @@ -734,102 +791,106 @@ 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, laneHeights, uniformPitch, groupBands, canvasHeight } = - useMemo(() => { - // Reads measuredHeightsRef; measureVersion invalidates on new reports. - void measureVersion; - const measured = measuredHeightsRef.current; - 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 { + 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(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: { - key: string; - group: GroupedData; - top: number; - height: number; - }[] = []; - let y = 0; - for (const section of laidOutSections) { - const sectionTop = y; - const banded = showGroupHeaders && section.group !== null; - if (banded) y += GROUP_BAND_HEIGHT; - y += laneGap; - for (let i = 0; i < section.laneCount; i++) { - const lane = section.laneOffset + i; - // A lane with no cards (empty data) still reserves the estimate. - if (heights[lane] === 0) heights[lane] = estimatedRowHeight; - tops[lane] = y; - y += heights[lane] + laneGap; - } - // Slots are contiguous (each spans its whole section, trailing gap - // included), so a pinned band is pushed out by the next section's band - // exactly as that one arrives at the pin line. - if (banded && section.group) { - bands.push({ - key: section.key, - group: section.group, - top: sectionTop, - height: y - sectionTop - }); - } + } + const tops = new Array(laneCount); + const bands: { + key: string; + group: GroupedData; + top: number; + height: number; + }[] = []; + let y = 0; + for (const section of laidOutSections) { + const sectionTop = y; + const banded = showGroupHeaders && section.group !== null; + if (banded) y += GROUP_BAND_HEIGHT; + y += laneGap; + for (let i = 0; i < section.laneCount; i++) { + const lane = section.laneOffset + i; + // A lane with no cards (empty data) still reserves the estimate. + if (heights[lane] === 0) heights[lane] = estimatedRowHeight; + tops[lane] = y; + y += heights[lane] + laneGap; } - // 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; - // 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, - 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] - ); + // Slots are contiguous (each spans its whole section, trailing gap + // included), so a pinned band is pushed out by the next section's band + // exactly as that one arrives at the pin line. + if (banded && section.group) { + bands.push({ + key: section.key, + group: section.group, + top: sectionTop, + height: y - sectionTop + }); + } + } + // 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; + // 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 + ]); /** * Cards grouped by lane, in CSR form: `items` holds card indices bucketed by @@ -903,23 +964,44 @@ export function DataViewTimeline({ 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, - top: el.scrollTop, - height: el.clientHeight - }; + 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 + // a full viewport on each side, so half of it 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 ( - prev && - prev.left === next.left && - prev.width === next.width && - prev.top === next.top && - prev.height === next.height + Math.abs(prev.left - next.left) < slackX && + Math.abs(prev.top - next.top) < slackY ) { return prev; } @@ -1134,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]); @@ -1154,34 +1239,47 @@ export function DataViewTimeline({ // 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(); // `hasData` re-runs the attempt when the renderer mounts its DOM. }, [needsViewport, isActive, hasData, 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). + // 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 ( @@ -1193,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 @@ -1255,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; @@ -1391,7 +1504,7 @@ export function DataViewTimeline({ if (!hasData) return null; // Horizontal culling window — one extra viewport on each side as overscan. - const overscan = viewport ? Math.max(viewport.width, 400) : 0; + const overscan = viewport ? overscanFor(viewport.width) : 0; const cullRange = virtualized && viewport ? { @@ -1402,12 +1515,19 @@ export function DataViewTimeline({ // 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 ? Math.max(viewport.height, 400) : 0; + 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 = - cullRange && viewport && viewport.height > 0 && laneIndex + verticalRange && laneIndex ? resolveLaneRange( - viewport.top - verticalOverscan, - viewport.top + viewport.height + verticalOverscan, + verticalRange.min, + verticalRange.max, uniformPitch, laneTops, laneHeights @@ -1456,12 +1576,40 @@ export function DataViewTimeline({ } else { cardSlice = cards; } - const gridTicks = cullRange + // 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, @@ -1495,7 +1643,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 ? (
({ ) : null ) : null} - {resolvedMarkers.map(marker => ( + {visibleMarkers.map(marker => (