From f2398f72cf99201f0795af641dd4caa5547590f2 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Fri, 3 Jul 2026 12:00:31 +0530 Subject: [PATCH 1/3] wip: dataview timeline --- apps/www/src/app/examples/timeline/page.tsx | 707 ++++++++++++++++++ .../data-view/__tests__/timeline.test.tsx | 531 +++++++++++++ .../data-view/components/timeline.tsx | 660 ++++++++++++++++ .../components/data-view/data-view.module.css | 157 ++++ .../components/data-view/data-view.tsx | 2 + .../components/data-view/data-view.types.tsx | 104 +++ .../raystack/components/data-view/index.ts | 6 + .../components/data-view/utils/pack-lanes.tsx | 43 ++ .../components/data-view/utils/time-scale.tsx | 182 +++++ packages/raystack/index.tsx | 4 + 10 files changed, 2396 insertions(+) create mode 100644 apps/www/src/app/examples/timeline/page.tsx create mode 100644 packages/raystack/components/data-view/__tests__/timeline.test.tsx create mode 100644 packages/raystack/components/data-view/components/timeline.tsx create mode 100644 packages/raystack/components/data-view/utils/pack-lanes.tsx create mode 100644 packages/raystack/components/data-view/utils/time-scale.tsx diff --git a/apps/www/src/app/examples/timeline/page.tsx b/apps/www/src/app/examples/timeline/page.tsx new file mode 100644 index 000000000..af7fab9ba --- /dev/null +++ b/apps/www/src/app/examples/timeline/page.tsx @@ -0,0 +1,707 @@ +/** biome-ignore-all lint/suspicious/noShadowRestrictedNames: public component name intentionally matches the package export */ +'use client'; + +import { + Badge, + DataView, + type DataViewField, + type DataViewListColumn, + Dialog, + EmptyState, + Flex, + IconButton, + Navbar, + Sidebar, + Text, + type TimelineCardContext +} from '@raystack/apsara'; +import { BellIcon, FilterIcon, SidebarIcon } from '@raystack/apsara/icons'; +import dayjs from 'dayjs'; +import { + type ReactNode, + useCallback, + useEffect, + useRef, + useState +} from 'react'; + +type OrderStatus = 'scheduled' | 'in-progress' | 'partial' | 'completed'; + +type Order = { + id: string; + name: string; + org: string; + priority: number; + status: OrderStatus; + startDate: string; + dueDate: string; +}; + +/* Dates are generated relative to today so the today-line and the + "Due in N days" danger states are always live in the example. Pinned to + midnight so server- and client-rendered output match (no hydration drift). */ +const DAY_MS = 86_400_000; +const startOfToday = () => { + const now = new Date(); + now.setHours(0, 0, 0, 0); + return now.getTime(); +}; +const daysFromNow = (days: number) => + new Date(startOfToday() + days * DAY_MS).toISOString(); + +/* ── Programmatic scroll domain (min/max of the timeline) ───────────────── + The explorable window is fixed to ±6 months around today. An explicit + `range` keeps the coordinate space stable while data streams in — the + scrollbar never jumps as new rows load. */ +const RANGE: [string, string] = [daysFromNow(-183), daysFromNow(183)]; + +/* How far beyond the visible window to prefetch on each scroll event. */ +const PREFETCH_MS = 14 * DAY_MS; +/* Simulated network latency for the fake orders API. */ +const API_LATENCY_MS = 500; + +const ORG_LIST = [ + 'Corteva Agriscience Pvt. Ltd', + 'Gridline Surveys and Geospacial Pvt Ltd', + 'NASA', + 'Planet Labs PBC', + 'Umbra Space Inc' +]; + +const NAME_POOL = [ + 'CBMA_2025_Strip1', + 'CBMA_2025_Strip2', + 'CBMA_2025_Strip3', + 'TOI3_Cluster_3.geojson', + 'TOI3_Cluster_13', + 'NASA - NASA_2_24_RaiVal_FF03_Unified 3', + 'NASA - NASA_2_24_RaiVal_FF03_Unified 4', + 'PLNT_AgMonitor_Sector_7', + 'UMB_SAR_Tasking_112' +]; + +const STATUS_LABEL: Record = { + scheduled: 'Scheduled', + 'in-progress': 'In progress', + partial: 'Partially delivered', + completed: 'Completed' +}; + +/* ── Simulated orders API with range-window fetching ────────────────────── + Orders are generated deterministically per month bucket with a seeded RNG, + so the same "API request" always returns the same rows — exactly like a + real backend queried with `?from=&to=`. */ +function mulberry32(seed: number) { + let a = seed; + return () => { + a |= 0; + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +function ordersForMonth(monthKey: string): Order[] { + const monthStart = dayjs(`${monthKey}-01`); + const rng = mulberry32(monthStart.year() * 100 + monthStart.month()); + const today = startOfToday(); + const count = 5 + Math.floor(rng() * 4); // 5–8 orders per month + return Array.from({ length: count }, (_, i) => { + const start = monthStart.add(Math.floor(rng() * 27), 'day'); + const due = start.add(2 + Math.floor(rng() * 16), 'day'); + let status: OrderStatus; + if (due.valueOf() < today) { + status = rng() < 0.75 ? 'completed' : 'partial'; + } else if (start.valueOf() > today) { + status = 'scheduled'; + } else { + status = rng() < 0.5 ? 'in-progress' : 'partial'; + } + return { + id: `${monthKey}-${i}`, + name: NAME_POOL[Math.floor(rng() * NAME_POOL.length)], + org: ORG_LIST[Math.floor(rng() * ORG_LIST.length)], + priority: 1 + Math.floor(rng() * 10), + status, + startDate: start.toISOString(), + dueDate: due.toISOString() + }; + }); +} + +function monthKeysBetween(fromMs: number, toMs: number): string[] { + const keys: string[] = []; + let cursor = dayjs(fromMs).startOf('month'); + const end = dayjs(toMs); + while (cursor.isBefore(end)) { + keys.push(cursor.format('YYYY-MM')); + cursor = cursor.add(1, 'month'); + } + return keys; +} + +function fetchOrdersApi(monthKeys: string[]): Promise { + return new Promise(resolve => + setTimeout(() => resolve(monthKeys.flatMap(ordersForMonth)), API_LATENCY_MS) + ); +} + +// Renderer-agnostic metadata — drives search, filters, and the Display +// Properties toggles that the card composes via . +const fields: DataViewField[] = [ + { + accessorKey: 'name', + label: 'Order', + filterable: true, + filterType: 'string', + sortable: true, + hideable: false + }, + { + accessorKey: 'org', + label: 'Organization', + filterable: true, + filterType: 'select', + hideable: true, + filterOptions: ORG_LIST.map(org => ({ value: org, label: org })) + }, + { + accessorKey: 'status', + label: 'Status', + filterable: true, + filterType: 'select', + hideable: true, + filterOptions: (Object.keys(STATUS_LABEL) as OrderStatus[]).map(status => ({ + value: status, + label: STATUS_LABEL[status] + })) + }, + { accessorKey: 'priority', label: 'Priority', hideable: true }, + { + accessorKey: 'startDate', + label: 'Start date', + filterable: true, + filterType: 'date', + sortable: true, + hideable: true + }, + { + accessorKey: 'dueDate', + label: 'Due date', + filterable: true, + filterType: 'date', + sortable: true, + hideable: true + } +]; + +/* Status icons matching the Figma card anatomy: outlined circle (scheduled), + pie (in progress), half-filled circle (partially delivered), check circle + (completed). */ +function StatusIcon({ status }: { status: OrderStatus }) { + const accent = 'var(--rs-color-foreground-accent-primary)'; + const success = 'var(--rs-color-foreground-success-primary)'; + const shared = { + width: 16, + height: 16, + viewBox: '0 0 16 16', + 'aria-label': STATUS_LABEL[status], + role: 'img', + style: { flexShrink: 0 } + } as const; + switch (status) { + case 'completed': + return ( + + + + + ); + case 'partial': + return ( + + + + + ); + case 'in-progress': + return ( + + + + + ); + case 'scheduled': + return ( + + + + ); + } +} + +/* ── Shared order-details dialog ────────────────────────────────────────── + One detached Dialog instance serves every card. Cards don't render their + own ; clicking a card opens this instance imperatively via + the handle, passing the clicked order as the payload. */ +const orderDialog = Dialog.createHandle(); + +const ellipsis: React.CSSProperties = { + minWidth: 0, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap' +}; + +function formatDue(date: Date) { + return date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' }); +} + +const STATUS_BADGE_VARIANT: Record< + OrderStatus, + 'accent' | 'success' | 'warning' | 'neutral' +> = { + scheduled: 'neutral', + 'in-progress': 'accent', + partial: 'warning', + completed: 'success' +}; + +/* Due-date urgency shared by the timeline card, the list view, and the + details dialog. */ +function dueState(order: Order) { + const due = new Date(order.dueDate); + const daysLeft = Math.ceil((due.getTime() - Date.now()) / DAY_MS); + return { + due, + daysLeft, + urgent: daysLeft >= 0 && daysLeft <= 5 && order.status !== 'completed' + }; +} + +function DueBadge({ order }: { order: Order }) { + const { due, daysLeft, urgent } = dueState(order); + return ( + + {urgent + ? `Due in ${daysLeft} day${daysLeft === 1 ? '' : 's'}` + : `Due on ${formatDue(due)}`} + + ); +} + +/* ── List view (table variant of DataView.List) ─────────────────────────── + Same row model, columnar presentation — switched with the view tabs in + the Display settings popover. Columns reference the same accessorKeys as + `fields`, so visibility toggles, filters, and sort apply to both views. */ +type OrderCell = { row: { original: Order } }; + +const orderColumns: DataViewListColumn[] = [ + { + accessorKey: 'name', + width: 'minmax(220px, 1.5fr)', + cell: ({ row }: OrderCell) => ( + + + + {row.original.name} + + + ) + }, + { + accessorKey: 'status', + width: '150px', + cell: ({ row }: OrderCell) => ( + + {STATUS_LABEL[row.original.status]} + + ) + }, + { + accessorKey: 'priority', + width: '90px', + cell: ({ row }: OrderCell) => ( + + P{row.original.priority} + + ) + }, + { + accessorKey: 'startDate', + width: '120px', + cell: ({ row }: OrderCell) => ( + {formatDue(new Date(row.original.startDate))} + ) + }, + { + accessorKey: 'dueDate', + width: '150px', + cell: ({ row }: OrderCell) => + }, + { + accessorKey: 'org', + width: 'minmax(180px, 1fr)', + cell: ({ row }: OrderCell) => ( + + {row.original.org} + + ) + } +]; + +/* The card is entirely consumer-owned — the Timeline only positions it. + Chrome, danger state, truncation, and the collapsed stub all live here. */ +function OrderCard({ + order, + context +}: { + order: Order; + context: TimelineCardContext; +}) { + const { urgent } = dueState(order); + + const chrome: React.CSSProperties = { + height: '100%', + boxSizing: 'border-box', + borderRadius: 'var(--rs-radius-3)', + border: `1px solid ${ + urgent + ? 'var(--rs-color-border-danger-emphasis)' + : 'var(--rs-color-border-base-primary)' + }`, + background: urgent + ? 'var(--rs-color-background-danger-primary)' + : 'var(--rs-color-background-base-primary)', + overflow: 'hidden' + }; + + // Narrow spans (context.collapsed) render as a compact priority stub, like + // the tiny "P10" card in the design. + if (context.collapsed) { + return ( + + + P{order.priority} + + + ); + } + + return ( + + + + + {order.name} + + + + P{order.priority} + + + + + + + + + + {order.org} + + + + + ); +} + +function DetailRow({ label, value }: { label: string; value: ReactNode }) { + return ( + + + {label} + + + {value} + + + ); +} + +/* The dialog body renders from the handle's payload — the order of whichever + card was clicked last. */ +function OrderDetailsDialog() { + return ( + + {({ payload: order }) => + order ? ( + + {/* Dialog.Header is a row flex by default — stack title over + description, and keep clear of the top-right close button. */} + + + + {order.name} + + + {STATUS_LABEL[order.status]} · {order.org} + + + + + + + {STATUS_LABEL[order.status]} + + } + /> + + + + + + + + ) : null + } + + ); +} + +const Page = () => { + /* ── Range-window infinite loading ────────────────────────────────────── + The timeline reports its visible [from, to] via onVisibleRangeChange. + We widen that window by a prefetch pad, split it into month buckets, + fetch only buckets never requested before, and merge rows in. Buckets + make deduplication trivial and map 1:1 to a real `?from=&to=` API. */ + const [orders, setOrders] = useState([]); + // Starts true: the first window is fetched on mount, and `isLoading` keeps + // the timeline shell (axis + skeletons) rendered while it's empty. + const [isLoading, setIsLoading] = useState(true); + const requestedRef = useRef>(new Set()); + const inflightRef = useRef(0); + const debounceRef = useRef | null>(null); + + const loadWindow = useCallback((fromMs: number, toMs: number) => { + const clampedFrom = Math.max(fromMs, Date.parse(RANGE[0])); + const clampedTo = Math.min(toMs, Date.parse(RANGE[1])); + if (clampedFrom >= clampedTo) return; + const missing = monthKeysBetween(clampedFrom, clampedTo).filter( + key => !requestedRef.current.has(key) + ); + if (missing.length === 0) return; + for (const key of missing) requestedRef.current.add(key); + inflightRef.current += 1; + setIsLoading(true); + fetchOrdersApi(missing).then(rows => { + setOrders(prev => [...prev, ...rows]); + inflightRef.current -= 1; + if (inflightRef.current === 0) setIsLoading(false); + }); + }, []); + + // Debounced so fast scrolling settles before we hit the "API". + const handleVisibleRangeChange = useCallback( + ([from, to]: [Date, Date]) => { + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => { + loadWindow(from.getTime() - PREFETCH_MS, to.getTime() + PREFETCH_MS); + }, 200); + }, + [loadWindow] + ); + + // Initial fetch around today — the timeline starts centered on it, and + // subsequent windows load through onVisibleRangeChange as the user scrolls. + useEffect(() => { + const today = startOfToday(); + loadWindow(today - 2 * PREFETCH_MS, today + 2 * PREFETCH_MS); + }, [loadWindow]); + + useEffect( + () => () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + }, + [] + ); + + return ( + + + + + + + + + Apsara + + + + + } + active + > + Timeline + + + + Help & Support + Preferences + + + + + + + + Order management · Timeline + + + + + Orders load as you scroll · {orders.length} loaded + + + + + + data={orders} + fields={fields} + mode='client' + isLoading={isLoading} + loadingRowCount={3} + defaultSort={{ name: 'dueDate', order: 'asc' }} + getRowId={(row: Order) => row.id} + onRowClick={order => orderDialog.openWithPayload(order)} + views={[ + { value: 'timeline', label: 'Timeline' }, + { value: 'list', label: 'List' } + ]} + defaultView='timeline' + > + + + + + + + {/* Empty/zero states live inside the same flex-1 pane as the + timeline: when the timeline renders null they center in the + pane instead of being pushed below an empty spacer. */} + + + name='timeline' + startField='startDate' + endField='dueDate' + scale='day' + unitWidth={40} + range={RANGE} + virtualized + onVisibleRangeChange={handleVisibleRangeChange} + renderCard={(row, context) => ( + + )} + /> + + + } + heading='No matching orders' + variant='empty1' + subHeading='Try adjusting your filters or search.' + /> + + + } + heading='No orders yet' + variant='empty1' + subHeading='Orders will show up here once they are scheduled.' + /> + + + + + + + + + + ); +}; + +export default Page; diff --git a/packages/raystack/components/data-view/__tests__/timeline.test.tsx b/packages/raystack/components/data-view/__tests__/timeline.test.tsx new file mode 100644 index 000000000..cba1e8da7 --- /dev/null +++ b/packages/raystack/components/data-view/__tests__/timeline.test.tsx @@ -0,0 +1,531 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import dayjs from 'dayjs'; +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; + +// SVG icons are inlined via @svgr/rollup at build time. In Vitest they resolve +// to undefined, so stub the `~/icons` module with no-op components. +vi.mock('~/icons', () => ({ + FilterIcon: () => null, + __esModule: true +})); + +// biome-ignore lint/suspicious/noShadowRestrictedNames: legitimate export name +import { DataView } from '../data-view'; +import type { DataViewField, DataViewTimelineProps } from '../data-view.types'; +import { packLanes } from '../utils/pack-lanes'; +import { buildAxis, createTimeScale, toTimestamp } from '../utils/time-scale'; + +beforeAll(() => { + // jsdom doesn't implement ResizeObserver — the timeline observes its scroll + // container when viewport tracking is enabled. + // biome-ignore lint/suspicious/noExplicitAny: jsdom lacks ResizeObserver + (global as any).ResizeObserver = + // biome-ignore lint/suspicious/noExplicitAny: jsdom lacks ResizeObserver + (global as any).ResizeObserver || + vi.fn().mockImplementation(() => ({ + observe: vi.fn(), + unobserve: vi.fn(), + disconnect: vi.fn() + })); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +/* ─────────────────────────── utils: packLanes ────────────────────────── */ + +describe('packLanes', () => { + it('returns no lanes for empty input', () => { + expect(packLanes([])).toEqual({ lanes: [], laneCount: 0 }); + }); + + it('packs non-overlapping items into the same lane', () => { + const { lanes, laneCount } = packLanes([ + { x: 0, width: 100 }, + { x: 120, width: 50 } + ]); + expect(lanes).toEqual([0, 0]); + expect(laneCount).toBe(1); + }); + + it('opens a new lane for overlapping items', () => { + const { lanes, laneCount } = packLanes([ + { x: 0, width: 100 }, + { x: 50, width: 100 } + ]); + expect(lanes).toEqual([0, 1]); + expect(laneCount).toBe(2); + }); + + it('respects the gap: items closer than gapPx do not share a lane', () => { + // First ends at 100; second starts at 104 < 100 + 8 → new lane. + const tight = packLanes([ + { x: 0, width: 100 }, + { x: 104, width: 50 } + ]); + expect(tight.lanes).toEqual([0, 1]); + // Exactly at the gap boundary → same lane. + const exact = packLanes([ + { x: 0, width: 100 }, + { x: 108, width: 50 } + ]); + expect(exact.lanes).toEqual([0, 0]); + }); + + it('assigns lanes by ascending x regardless of input order', () => { + const { lanes, laneCount } = packLanes([ + { x: 220, width: 60 }, // fits after the first item + { x: 0, width: 100 }, + { x: 50, width: 100 } // overlaps the first → lane 1 + ]); + expect(lanes).toEqual([0, 0, 1]); + expect(laneCount).toBe(2); + }); +}); + +/* ─────────────────────────── utils: time scale ───────────────────────── */ + +describe('toTimestamp', () => { + it('accepts Date, epoch ms, and parseable strings', () => { + const date = new Date('2025-01-05T00:00:00'); + expect(toTimestamp(date)).toBe(date.getTime()); + expect(toTimestamp(1736035200000)).toBe(1736035200000); + expect(toTimestamp('2025-01-05')).toBe(dayjs('2025-01-05').valueOf()); + }); + + it('returns null for missing or invalid values', () => { + expect(toTimestamp(null)).toBeNull(); + expect(toTimestamp(undefined)).toBeNull(); + expect(toTimestamp('not-a-date')).toBeNull(); + expect(toTimestamp(new Date('invalid'))).toBeNull(); + expect(toTimestamp(Number.NaN)).toBeNull(); + expect(toTimestamp({})).toBeNull(); + }); +}); + +describe('createTimeScale', () => { + it('snaps the domain to unit boundaries with padding', () => { + const ts = createTimeScale({ + minTime: dayjs('2025-01-05T10:30:00').valueOf(), + maxTime: dayjs('2025-01-20T18:00:00').valueOf(), + scale: 'day', + unitWidth: 20, + padUnits: 2 + }); + expect(ts.t0).toBe(dayjs('2025-01-03').startOf('day').valueOf()); + // startOf(max) + (pad + 1) days. + expect(ts.t1).toBe(dayjs('2025-01-23').startOf('day').valueOf()); + }); + + it('maps time to px linearly and inverts with timeAt', () => { + const ts = createTimeScale({ + minTime: dayjs('2025-01-01').valueOf(), + maxTime: dayjs('2025-01-31').valueOf(), + scale: 'day', + unitWidth: 20, + padUnits: 0 + }); + expect(ts.x(ts.t0)).toBe(0); + expect(ts.x(dayjs('2025-01-05').valueOf())).toBe(80); + const time = dayjs('2025-01-11').valueOf(); + expect(ts.timeAt(ts.x(time))).toBe(time); + // Domain = Jan 1 → Feb 1 = 31 days. + expect(ts.totalWidth).toBe(31 * 20); + }); +}); + +describe('buildAxis', () => { + const januaryScale = createTimeScale({ + minTime: dayjs('2025-01-01').valueOf(), + maxTime: dayjs('2025-01-31').valueOf(), + scale: 'day', + unitWidth: 20, + padUnits: 0 + }); + + it('emits one tick per unit across the domain', () => { + const { ticks } = buildAxis(januaryScale, 'day', 20); + // Jan 1 … Feb 1 inclusive. + expect(ticks).toHaveLength(32); + expect(ticks[0].label).toBe('1'); + expect(ticks[0].x).toBe(0); + expect(ticks[4].x).toBe(80); + }); + + it('thins tick labels below the minimum label spacing', () => { + // 20px per tick < 28px minimum → every 2nd label. + const thinned = buildAxis(januaryScale, 'day', 20).ticks; + expect(thinned[0].showLabel).toBe(true); + expect(thinned[1].showLabel).toBe(false); + expect(thinned[2].showLabel).toBe(true); + // 40px per tick → all labels show. + const roomy = buildAxis( + createTimeScale({ + minTime: dayjs('2025-01-01').valueOf(), + maxTime: dayjs('2025-01-31').valueOf(), + scale: 'day', + unitWidth: 40, + padUnits: 0 + }), + 'day', + 40 + ).ticks; + expect(roomy.every(t => t.showLabel)).toBe(true); + }); + + it('emits month bands over day ticks, with the year on the first band', () => { + const wide = createTimeScale({ + minTime: dayjs('2025-01-10').valueOf(), + maxTime: dayjs('2025-02-20').valueOf(), + scale: 'day', + unitWidth: 20, + padUnits: 0 + }); + const { bands } = buildAxis(wide, 'day', 20); + expect(bands.map(b => b.label)).toEqual(['Jan 2025', 'Feb']); + }); + + it('emits year bands over month ticks', () => { + const yearly = createTimeScale({ + minTime: dayjs('2024-11-01').valueOf(), + maxTime: dayjs('2025-03-01').valueOf(), + scale: 'month', + unitWidth: 96, + padUnits: 0 + }); + const { bands, ticks } = buildAxis(yearly, 'month', 96); + expect(bands.map(b => b.label)).toEqual(['2024', '2025']); + expect(ticks[0].label).toBe('Nov'); + }); +}); + +/* ─────────────────────────── renderer ────────────────────────── */ + +type Order = { + id: string; + title: string; + start: string | null; + end: string | null; +}; + +const fields: DataViewField[] = [ + { accessorKey: 'title', label: 'Title', sortable: true } +]; + +// x at unitWidth 20 over a Jan 2025 domain: Jan 5 → 80px, Jan 12 → 220px, … +const orders: Order[] = [ + { id: 'o1', title: 'Alpha', start: '2025-01-05', end: '2025-01-10' }, + { id: 'o2', title: 'Beta', start: '2025-01-12', end: '2025-01-15' }, + { id: 'o3', title: 'Gamma', start: '2025-01-06', end: '2025-01-09' } +]; + +function renderTimeline( + props: Partial> = {}, + data: Order[] = orders +) { + return render( + + data={data} + 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, context) => ( +
+ {row.original.title} +
+ )} + {...props} + /> + + ); +} + +describe('DataView.Timeline', () => { + it('positions cards from the time scale (left = start, width = span)', () => { + renderTimeline(); + const wrapper = screen.getByTestId('card-o1').parentElement!; + // Jan 5 = 4 days after Jan 1 → 4 × 20px; Jan 5 → Jan 10 = 5 days → 100px. + expect(wrapper.style.left).toBe('80px'); + expect(wrapper.style.width).toBe('100px'); + expect(wrapper).toHaveAttribute('role', 'listitem'); + }); + + it('packs overlapping cards into separate lanes and reuses free lanes', () => { + renderTimeline(); + // o1 [80..180] and o3 [100..180] overlap → different lanes. + expect(screen.getByTestId('card-o1').dataset.lane).toBe('0'); + expect(screen.getByTestId('card-o3').dataset.lane).toBe('1'); + // o2 starts at 220 ≥ o1's end + gap → back onto lane 0. + expect(screen.getByTestId('card-o2').dataset.lane).toBe('0'); + }); + + it('gives every row its own lane with lanePacking="one-per-row"', () => { + renderTimeline({ lanePacking: 'one-per-row' }); + const lanes = ['o1', 'o2', 'o3'].map( + id => screen.getByTestId(`card-${id}`).dataset.lane + ); + expect(new Set(lanes).size).toBe(3); + }); + + it('flags spans narrower than minCardWidth as collapsed', () => { + renderTimeline(undefined, [ + { id: 'wide', title: 'Wide', start: '2025-01-05', end: '2025-01-10' }, + { id: 'tiny', title: 'Tiny', start: '2025-01-12', end: '2025-01-14' } + ]); + // 5 days × 20px = 100px ≥ 60 → not collapsed. + expect(screen.getByTestId('card-wide').dataset.collapsed).toBe('false'); + // 2 days × 20px = 40px < 60 → collapsed. + expect(screen.getByTestId('card-tiny').dataset.collapsed).toBe('true'); + }); + + it('renders point markers with intrinsic width when endField is omitted', () => { + renderTimeline({ endField: undefined }, [ + { id: 'p1', title: 'Point', start: '2025-01-05', end: null } + ]); + const card = screen.getByTestId('card-p1'); + expect(card.dataset.point).toBe('true'); + expect(card.parentElement!.style.width).toBe(''); + expect(card.parentElement!.style.left).toBe('80px'); + }); + + it('culls rows entirely outside an explicit range, keeps overlapping ones', () => { + renderTimeline(undefined, [ + { id: 'in', title: 'Inside', start: '2025-01-05', end: '2025-01-10' }, + // Crosses the range end (Jan 31) → kept, clipped visually by the canvas. + { id: 'edge', title: 'Edge', start: '2025-01-30', end: '2025-02-05' }, + // Entirely past the range end → dropped (no lane, no card). + { id: 'after', title: 'After', start: '2025-02-10', end: '2025-02-15' }, + // Entirely before the range start → dropped. + { id: 'before', title: 'Before', start: '2024-12-01', end: '2024-12-20' } + ]); + expect(screen.getByTestId('card-in')).toBeInTheDocument(); + expect(screen.getByTestId('card-edge')).toBeInTheDocument(); + expect(screen.queryByTestId('card-after')).not.toBeInTheDocument(); + expect(screen.queryByTestId('card-before')).not.toBeInTheDocument(); + }); + + it('skips rows with a missing start value and warns in dev', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + renderTimeline(undefined, [ + { id: 'ok', title: 'Ok', start: '2025-01-05', end: '2025-01-10' }, + { id: 'bad', title: 'Bad', start: null, end: '2025-01-10' } + ]); + expect(screen.getByTestId('card-ok')).toBeInTheDocument(); + expect(screen.queryByTestId('card-bad')).toBeNull(); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('Skipped 1 row(s)') + ); + }); + + it('renders the today line badge and custom markers on the axis', () => { + renderTimeline({ + today: '2025-01-17', + markers: [ + { date: '2025-01-25' }, + { date: '2025-01-28', label: 'Launch', variant: 'danger' } + ] + }); + expect(screen.getByText('17 Jan')).toBeInTheDocument(); + expect(screen.getByText('25 Jan')).toBeInTheDocument(); + expect(screen.getByText('Launch')).toBeInTheDocument(); + }); + + it('renders axis bands and tick labels', () => { + renderTimeline(); + expect(screen.getByText('Jan 2025')).toBeInTheDocument(); + // unitWidth 20 thins labels to every other day (odd days from Jan 1). + expect(screen.getByText('5')).toBeInTheDocument(); + }); + + it('fires onRowClick with the row data when a card is clicked', async () => { + const onRowClick = vi.fn(); + const user = userEvent.setup(); + render( + + data={orders} + fields={fields} + mode='client' + defaultSort={{ name: 'title', order: 'asc' }} + getRowId={(row: Order) => row.id} + onRowClick={onRowClick} + > + + startField='start' + endField='end' + range={['2025-01-01', '2025-01-31']} + today={false} + defaultScrollTo='start' + renderCard={row => ( +
+ {row.original.title} +
+ )} + /> + + ); + await user.click(screen.getByTestId('card-o1')); + expect(onRowClick).toHaveBeenCalledWith(orders[0]); + }); + + it('gates itself on the active view when `name` is set', () => { + const views = [ + { value: 'gantt', label: 'Timeline' }, + { value: 'other', label: 'Other' } + ]; + const { unmount } = render( + + data={orders} + fields={fields} + mode='client' + defaultSort={{ name: 'title', order: 'asc' }} + views={views} + defaultView='other' + > + + name='gantt' + startField='start' + endField='end' + today={false} + renderCard={row =>
} + /> + + ); + expect(screen.queryByTestId('card-o1')).toBeNull(); + unmount(); + + render( + + data={orders} + fields={fields} + mode='client' + defaultSort={{ name: 'title', order: 'asc' }} + views={views} + defaultView='gantt' + > + + name='gantt' + startField='start' + endField='end' + today={false} + renderCard={row =>
} + /> + + ); + expect(screen.getByTestId('card-o1')).toBeInTheDocument(); + }); + + it('renders nothing when there is no data and not loading', () => { + const { container } = renderTimeline(undefined, []); + expect(container.querySelector('[role="list"]')).toBeNull(); + }); + + it('shows a cursor line snapped to the hovered sub-interval with a date badge', async () => { + const { container } = renderTimeline(); + const root = container.firstElementChild as HTMLElement; + // jsdom rects are all-zero and scrollLeft is 0, so canvasX = clientX. + // x=100 at 20px/day from Jan 1 → snapped to Jan 6. + await act(async () => { + fireEvent.mouseMove(root, { clientX: 100 }); + await new Promise(resolve => setTimeout(resolve, 30)); // flush rAF + }); + expect(screen.getByText('6 Jan')).toBeInTheDocument(); + + await act(async () => { + fireEvent.mouseLeave(root); + await new Promise(resolve => setTimeout(resolve, 30)); + }); + expect(screen.queryByText('6 Jan')).toBeNull(); + }); + + it('does not track the cursor when showCursorLine is false', async () => { + const { container } = renderTimeline({ showCursorLine: false }); + const root = container.firstElementChild as HTMLElement; + await act(async () => { + fireEvent.mouseMove(root, { clientX: 100 }); + await new Promise(resolve => setTimeout(resolve, 30)); + }); + expect(screen.queryByText('6 Jan')).toBeNull(); + }); + + it('keeps the left-edge time anchored when the domain is extended', async () => { + const { container, rerender } = render( + + 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 root = container.firstElementChild as HTMLElement; + // Viewport's left edge sits at Jan 11 (200px / 20px-per-day from Jan 1). + root.scrollLeft = 200; + + // Extend the range a month to the left — t0 moves to Dec 1. + rerender( + + data={orders} + fields={fields} + mode='client' + defaultSort={{ name: 'title', order: 'asc' }} + getRowId={(row: Order) => row.id} + > + + startField='start' + endField='end' + range={['2024-12-01', '2025-01-31']} + scale='day' + unitWidth={20} + today={false} + defaultScrollTo='start' + renderCard={row =>
{row.original.title}
} + /> + + ); + // Dec 1 → Jan 11 = 41 days × 20px: the same time stays at the left edge. + expect(root.scrollLeft).toBe(820); + }); + + it('fires onVisibleRangeChange with the visible time window', async () => { + const onVisibleRangeChange = vi.fn(); + renderTimeline({ onVisibleRangeChange }); + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 0)); + }); + expect(onVisibleRangeChange).toHaveBeenCalled(); + const calls = onVisibleRangeChange.mock.calls; + const [from, to] = calls[calls.length - 1][0]; + expect(from).toBeInstanceOf(Date); + expect(to).toBeInstanceOf(Date); + // jsdom viewport is 0-wide at scrollLeft 0 → both edges sit at t0 (Jan 1). + expect(from.getTime()).toBe(dayjs('2025-01-01').valueOf()); + }); +}); diff --git a/packages/raystack/components/data-view/components/timeline.tsx b/packages/raystack/components/data-view/components/timeline.tsx new file mode 100644 index 000000000..ea8e9bbe8 --- /dev/null +++ b/packages/raystack/components/data-view/components/timeline.tsx @@ -0,0 +1,660 @@ +'use client'; + +import type { Row } from '@tanstack/react-table'; +import { cx } from 'class-variance-authority'; +import dayjs from 'dayjs'; +import { + CSSProperties, + ReactNode, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState +} from 'react'; + +import { Badge } from '../../badge'; +import { Skeleton } from '../../skeleton'; +import styles from '../data-view.module.css'; +import { + DataViewTimelineProps, + TimelineCardContext, + TimelineScale +} from '../data-view.types'; +import { useDataView } from '../hooks/useDataView'; +import { packLanes } from '../utils/pack-lanes'; +import { + buildAxis, + createTimeScale, + startOfUnit, + TIMELINE_DEFAULT_UNIT_WIDTH, + TIMELINE_UNIT_MS, + toTimestamp +} from '../utils/time-scale'; +import { FilterSummary } from './clear-filters'; + +const DEFAULT_ROW_HEIGHT = 66; +const DEFAULT_LANE_GAP = 16; +const DEFAULT_MIN_CARD_WIDTH = 60; +/** Floor for the rendered wrapper so near-zero spans stay visible and clickable. */ +const MIN_RENDER_WIDTH = 24; +/** Units of padding around the data extent when no explicit `range` is given. */ +const DOMAIN_PAD_UNITS = 2; +/** Half-window (in units) of the fallback domain used while loading with no data. */ +const FALLBACK_DOMAIN_UNITS = 15; + +interface ResolvedMarker { + key: string; + time: number; + x: number; + label: ReactNode; + variant: 'default' | 'accent' | 'danger'; +} + +const MARKER_BADGE_VARIANT: Record< + ResolvedMarker['variant'], + 'accent' | 'danger' | 'neutral' +> = { + accent: 'accent', + danger: 'danger', + default: 'neutral' +}; + +/** Axis-badge label for the hover cursor, formatted per scale granularity. */ +function cursorLabel(time: number, scale: TimelineScale): string { + const date = dayjs(time); + switch (scale) { + case 'day': + case 'week': + return date.format('D MMM'); + case 'month': + return date.format('MMM YYYY'); + case 'quarter': + return `Q${Math.floor(date.month() / 3) + 1} ${date.format('YYYY')}`; + } +} + +/** + * Time-positioned card renderer. The Timeline owns the time scale (date → x, + * span → width), lane packing, the sticky two-tier axis (ticks, month/year + * bands, today + custom markers, gridlines), and native x/y scrolling. The + * card interior is entirely consumer-owned via `renderCard` — analogous to how + * `columns[].cell` owns cell interiors in `DataView.List`. + */ +export function DataViewTimeline({ + name, + fields: fieldsOverride, + startField, + endField, + renderCard, + scale = 'day', + unitWidth, + range, + today = true, + markers, + showGridlines = true, + showCursorLine = true, + defaultScrollTo = 'today', + onVisibleRangeChange, + lanePacking = 'auto', + rowHeight = DEFAULT_ROW_HEIGHT, + laneGap = DEFAULT_LANE_GAP, + minCardWidth = DEFAULT_MIN_CARD_WIDTH, + virtualized = false, + classNames = {} +}: DataViewTimelineProps) { + const { + table, + onRowClick, + isLoading, + loadingRowCount = 3, + activeView, + registerFieldsForView, + hasData + } = useDataView(); + + // Register per-view field override so the toolbar's effectiveFields reflects + // this renderer's metadata while it's the active view. + useEffect(() => { + if (!name || !fieldsOverride) return; + return registerFieldsForView(name, fieldsOverride); + }, [name, fieldsOverride, registerFieldsForView]); + + // Multi-view gate. When `name` is set, render only when this is the active + // view. When unset (single-renderer mode), always render. + const isActive = !name || activeView === undefined || activeView === name; + + const effectiveUnitWidth = unitWidth ?? TIMELINE_DEFAULT_UNIT_WIDTH[scale]; + + const rowModel = table?.getRowModel(); + const { rows = [] } = rowModel || {}; + + // Timeline bypasses group headers (RFC §Grouping) — position leaf rows only. + const leafRows = useMemo( + () => rows.filter(row => !(row.subRows && row.subRows.length > 0)), + [rows] + ); + + // Resolve each row's start/end timestamps. Rows without a valid start are + // skipped (dev warning); inverted ranges clamp to zero-length spans. + const timedItems = useMemo(() => { + const items: { + row: Row; + startTime: number; + endTime: number | null; + }[] = []; + let dropped = 0; + for (const row of leafRows) { + const original = row.original as Record; + const startTime = toTimestamp(original?.[startField]); + if (startTime === null) { + dropped++; + continue; + } + let endTime: number | null = null; + if (endField) { + endTime = toTimestamp(original?.[endField]); + if (endTime !== null && endTime < startTime) endTime = startTime; + } + items.push({ row, startTime, endTime }); + } + if (process.env.NODE_ENV !== 'production' && dropped > 0) { + console.warn( + `[DataView.Timeline] Skipped ${dropped} row(s) with a missing or invalid "${startField}" value.` + ); + } + return items; + }, [leafRows, startField, endField]); + + const todayTime = useMemo(() => { + if (today === false) return null; + if (today === true) { + // Day precision: aligns the line with its day tick and keeps SSR and + // client renders consistent (no per-ms Date.now() drift → no hydration + // mismatch). + const now = new Date(); + now.setHours(0, 0, 0, 0); + return now.getTime(); + } + return toTimestamp(today); + }, [today]); + + const markerTimes = useMemo( + () => + (markers ?? []) + .map(marker => toTimestamp(marker.date)) + .filter((time): time is number => time !== null), + [markers] + ); + + // Domain: explicit `range` wins; otherwise the extent of data + today + + // markers. With nothing to show (e.g. initial server load), fall back to a + // window centered on today so the axis and skeletons still render. + const domain = useMemo(() => { + if (range) { + const a = toTimestamp(range[0]); + const b = toTimestamp(range[1]); + if (a !== null && b !== null) { + 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); + } + for (const time of [todayTime ?? Infinity, ...markerTimes]) { + if (!Number.isFinite(time)) continue; + min = Math.min(min, time); + max = Math.max(max, time); + } + if (!Number.isFinite(min)) { + const fallback = new Date(); + fallback.setHours(0, 0, 0, 0); + const anchor = todayTime ?? fallback.getTime(); + const pad = FALLBACK_DOMAIN_UNITS * TIMELINE_UNIT_MS[scale]; + return { min: anchor - pad, max: anchor + pad, explicit: false }; + } + return { min, max, explicit: false }; + }, [range, timedItems, todayTime, markerTimes, scale]); + + const timeScale = useMemo( + () => + createTimeScale({ + minTime: domain.min, + maxTime: domain.max, + scale, + unitWidth: effectiveUnitWidth, + padUnits: domain.explicit ? 0 : DOMAIN_PAD_UNITS + }), + [domain, scale, effectiveUnitWidth] + ); + + const { ticks, bands } = useMemo( + () => buildAxis(timeScale, scale, effectiveUnitWidth), + [timeScale, scale, effectiveUnitWidth] + ); + + // Card geometry. `renderWidth` is null for point markers (no endField) — + // the wrapper sizes to its content instead of the time span. Rows entirely + // outside the domain are dropped: with an explicit `range`, fetched data + // routinely extends past the window (e.g. whole-month API buckets), and + // those rows must not render beyond the axis or occupy lanes. + const positioned = useMemo(() => { + const items: (typeof timedItems)[number][] = []; + for (const item of timedItems) { + const effectiveEnd = item.endTime ?? item.startTime; + if (item.startTime > timeScale.t1 || effectiveEnd < timeScale.t0) { + continue; + } + items.push(item); + } + return items.map(item => { + const x = timeScale.x(item.startTime); + const spanWidth = + item.endTime !== null + ? (item.endTime - item.startTime) * timeScale.pxPerMs + : 0; + const renderWidth = + item.endTime !== null ? Math.max(spanWidth, MIN_RENDER_WIDTH) : null; + return { ...item, x, spanWidth, renderWidth }; + }); + }, [timedItems, timeScale]); + + const { lanes, laneCount } = useMemo(() => { + if (lanePacking === 'one-per-row') { + return { + lanes: positioned.map((_, i) => i), + laneCount: positioned.length + }; + } + return packLanes( + positioned.map(item => ({ + x: item.x, + width: item.renderWidth ?? MIN_RENDER_WIDTH + })) + ); + }, [positioned, lanePacking]); + + const resolvedMarkers = useMemo(() => { + const list: ResolvedMarker[] = []; + if ( + todayTime !== null && + todayTime >= timeScale.t0 && + todayTime <= timeScale.t1 + ) { + list.push({ + key: '__today', + time: todayTime, + x: timeScale.x(todayTime), + label: dayjs(todayTime).format('D MMM'), + variant: 'accent' + }); + } + markers?.forEach((marker, index) => { + const time = toTimestamp(marker.date); + if (time === null || time < timeScale.t0 || time > timeScale.t1) return; + list.push({ + key: `__marker-${index}`, + time, + x: timeScale.x(time), + label: marker.label ?? dayjs(time).format('D MMM'), + variant: marker.variant ?? 'default' + }); + }); + return list; + }, [todayTime, markers, timeScale]); + + const scrollRef = useRef(null); + + // Viewport tracking — drives horizontal culling (`virtualized`) and + // `onVisibleRangeChange`. rAF-throttled so the state update runs at most + // once per frame regardless of scroll event rate. + const needsViewport = virtualized || Boolean(onVisibleRangeChange); + const [viewport, setViewport] = useState<{ + left: number; + width: number; + } | null>(null); + const rafIdRef = useRef(null); + + const readViewport = useCallback(() => { + 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) { + return prev; + } + return next; + }); + }, []); + + // Hover cursor — a crosshair line snapped to the sub-interval (tick unit) + // under the pointer, with a date badge pinned to the axis. Updates are + // rAF-throttled and recomputed on scroll too (the content moves under a + // stationary pointer). + const [cursorTime, setCursorTime] = useState(null); + const pointerXRef = useRef(null); + const cursorRafRef = useRef(null); + + const updateCursorFromPointer = useCallback(() => { + if (!showCursorLine) return; + const el = scrollRef.current; + const pointerX = pointerXRef.current; + if (!el || pointerX === null) return; + const rect = el.getBoundingClientRect(); + const canvasX = pointerX - rect.left + el.scrollLeft; + const time = Math.max( + timeScale.t0, + Math.min(timeScale.timeAt(canvasX), timeScale.t1) + ); + const snapped = startOfUnit(dayjs(time), scale).valueOf(); + setCursorTime(prev => (prev === snapped ? prev : snapped)); + }, [showCursorLine, timeScale, scale]); + + const handlePointerMove = useCallback( + (event: React.MouseEvent) => { + if (!showCursorLine) return; + pointerXRef.current = event.clientX; + if (cursorRafRef.current !== null) return; + cursorRafRef.current = requestAnimationFrame(() => { + cursorRafRef.current = null; + updateCursorFromPointer(); + }); + }, + [showCursorLine, updateCursorFromPointer] + ); + + const handlePointerLeave = useCallback(() => { + pointerXRef.current = null; + if (cursorRafRef.current !== null) { + cancelAnimationFrame(cursorRafRef.current); + cursorRafRef.current = null; + } + setCursorTime(null); + }, []); + + const handleScroll = useCallback(() => { + if (!needsViewport && !showCursorLine) return; + if (rafIdRef.current !== null) return; + rafIdRef.current = requestAnimationFrame(() => { + rafIdRef.current = null; + if (needsViewport) readViewport(); + updateCursorFromPointer(); + }); + }, [needsViewport, showCursorLine, readViewport, updateCursorFromPointer]); + + useEffect( + () => () => { + for (const ref of [rafIdRef, cursorRafRef]) { + if (ref.current !== null) { + cancelAnimationFrame(ref.current); + ref.current = null; + } + } + }, + [] + ); + + useEffect(() => { + if (!needsViewport || !isActive) return; + readViewport(); + const el = scrollRef.current; + if (!el || typeof ResizeObserver === 'undefined') return; + const observer = new ResizeObserver(readViewport); + observer.observe(el); + return () => observer.disconnect(); + }, [needsViewport, isActive, readViewport]); + + useEffect(() => { + if (!onVisibleRangeChange || !viewport) return; + onVisibleRangeChange([ + new Date(timeScale.timeAt(viewport.left)), + new Date(timeScale.timeAt(viewport.left + viewport.width)) + ]); + }, [onVisibleRangeChange, viewport, timeScale]); + + // Initial scroll position — runs once when the renderer becomes active. + const didInitScrollRef = useRef(false); + // biome-ignore lint/correctness/useExhaustiveDependencies: `isActive` re-runs the attempt once the renderer mounts its DOM (the ref is null while view-gated). + useLayoutEffect(() => { + if (didInitScrollRef.current) return; + const el = scrollRef.current; + if (!el) return; + didInitScrollRef.current = true; + let targetX = 0; + if (defaultScrollTo !== 'start') { + const targetTime = + defaultScrollTo === 'today' + ? (todayTime ?? timeScale.t0) + : (toTimestamp(defaultScrollTo) ?? timeScale.t0); + targetX = timeScale.x(targetTime) - el.clientWidth / 2; + } + el.scrollLeft = Math.max( + 0, + Math.min(targetX, timeScale.totalWidth - el.clientWidth) + ); + }, [defaultScrollTo, todayTime, timeScale, isActive]); + + // Scroll anchoring — when the time domain shifts (rows prepended by + // range-window fetching, `range` extended, zoom change), keep the time under + // the viewport's left edge stable instead of letting content jump. + const scrollAnchorRef = useRef<{ t0: number; pxPerMs: number } | null>(null); + useLayoutEffect(() => { + const el = scrollRef.current; + const prev = scrollAnchorRef.current; + if ( + el && + prev && + (prev.t0 !== timeScale.t0 || prev.pxPerMs !== timeScale.pxPerMs) + ) { + const leftEdgeTime = prev.t0 + el.scrollLeft / prev.pxPerMs; + el.scrollLeft = Math.max(0, timeScale.x(leftEdgeTime)); + } + scrollAnchorRef.current = { + t0: timeScale.t0, + pxPerMs: timeScale.pxPerMs + }; + }, [timeScale]); + + if (!isActive) return null; + // Render nothing when there's truly no data and no loading — sibling + // `` / `` handle messaging. + if (!hasData) return null; + + const lanePitch = rowHeight + laneGap; + const loaderCount = isLoading ? Math.max(1, loadingRowCount) : 0; + const canvasHeight = + Math.max(laneCount + loaderCount, 1) * lanePitch + laneGap; + + // Horizontal culling window — one extra viewport on each side as overscan. + const overscan = viewport ? Math.max(viewport.width, 400) : 0; + const cullRange = + virtualized && viewport + ? { + min: viewport.left - overscan, + max: viewport.left + viewport.width + overscan + } + : null; + const isXVisible = (x: number, width: number) => + !cullRange || (x + width >= cullRange.min && x <= cullRange.max); + + const renderLoaderCards = () => { + if (!isLoading) return null; + // Anchor skeletons near the visible viewport when it's tracked (range- + // window fetching), else near today / the domain midpoint. + let anchorX: number; + if (viewport && viewport.width > 0) { + anchorX = viewport.left + viewport.width / 2; + } else { + const anchorTime = + todayTime !== null && + todayTime >= timeScale.t0 && + todayTime <= timeScale.t1 + ? todayTime + : (timeScale.t0 + timeScale.t1) / 2; + anchorX = timeScale.x(anchorTime); + } + const loaderWidth = effectiveUnitWidth * 8; + return Array.from({ length: loaderCount }, (_, i) => { + const offset = ((i % 3) - 1) * effectiveUnitWidth * 5; + const left = Math.max( + 0, + Math.min(anchorX + offset, timeScale.totalWidth - loaderWidth) + ); + return ( +
+ +
+ ); + }); + }; + + return ( +
+
+ {bands.map(band => ( +
+ {/* Sticky-left so the label stays visible while its band spans the viewport. */} + {band.label} +
+ ))} + {ticks.map(tick => + tick.showLabel ? ( +
+ {tick.label} +
+ ) : null + )} + {resolvedMarkers.map(marker => ( +
+ + {marker.label} + +
+ ))} + {cursorTime !== null ? ( + + ) : null} +
+ +
+ {showGridlines + ? ticks.map(tick => + isXVisible(tick.x, 0) ? ( +