Skip to content
Open
435 changes: 435 additions & 0 deletions apps/www/src/app/examples/timeline-stress/page.tsx

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions apps/www/src/content/docs/components/dataview/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -435,7 +435,7 @@ The Timeline owns **positioning** — the time scale (date → x, span → width

`context.collapsed` flips when the span is narrower than `minCardWidth` (default 60px) — render a compact stub instead of letting the full card clip (point cards never collapse; they size to their content). Wrap card fields in `DataView.DisplayAccess` so the toolbar's Display Properties toggles reach them.

Card height is content-driven, the same contract as `DataView.List` rows: cards auto-measure after paint, each lane sizes to its tallest card, and `estimatedRowHeight` (default 66) is only a layout hint until real heights arrive. Give your card an explicit height if you want uniform cards. Keep `renderCard` referentially stable (define it outside the component or wrap it in `useCallback`) — cards are memoized against it, and an inline closure forces every visible card to re-render on each scroll frame.
Card height is content-driven, the same contract as `DataView.List` rows: cards auto-measure after paint, each lane sizes to its tallest card, and `estimatedRowHeight` (default 66) is only a layout hint until real heights arrive. Under `virtualized` this inverts — a culled card never reports a height, so measuring would resize lanes as you scroll and shift every lane below them. Lanes there take a fixed `estimatedRowHeight` pitch, and a card taller than it overlaps the lane below instead of growing its own. Give your card an explicit height if you want uniform cards, and keep that height within the pitch if you virtualize. Keep `renderCard` referentially stable (define it outside the component or wrap it in `useCallback`) — cards are memoized against it, and an inline closure forces every visible card to re-render on each scroll frame.

### Point markers

Expand Down Expand Up @@ -554,8 +554,8 @@ Start with `isLoading={true}` and fire an initial fetch on mount: with no data a
### Notes

- **Grouping** renders as swim-lane sections and **sorting** only reaches `lanePacking="one-per-row"` — see [Grouping](#grouping) and [Ordering](#ordering). Hide a control that has no meaning for your configuration (`<DataView.DisplayControls hideOrdering />`), or pass the timeline a per-view `fields` override without `sortable`/`groupable`.
- **Vertical space is not virtualized.** `virtualized` culls horizontally only, so a grouped timeline renders every section's cards that fall in the visible time window. Deep grouping over thousands of rows will render a tall canvas.
- **`virtualized`** enables horizontal culling: only cards and gridlines near the viewport render. Recommended whenever the domain is long or rows are numerous.
- **`virtualized`** culls both axes: cards, gridlines, tick labels, month bands, and markers render only near the viewport, and the grid and marker lines span the visible window rather than the full canvas height. A frame costs what is on screen rather than what is in the data, so a long domain and deep grouping stay affordable. It defaults to `false` — pass it explicitly. Recommended whenever the domain is long or rows are numerous.
- **Without `virtualized`, nothing is culled vertically.** Every lane in the domain stays mounted and every card in the visible time window renders, so deep grouping over thousands of rows builds a tall, fully populated canvas. The trade is content-driven lane heights (see [Cards](#cards)), which virtualization gives up.
- **Interaction** — cards receive row clicks via the root's `onRowClick`; the background supports mouse drag-to-pan with a momentum glide; scrolling past the domain edge won't trigger browser back-swipe. The pane is a focusable, labelled region (`aria-label`, default "Timeline"), so keyboard users can Tab to it and scroll with the arrow keys.

## Accessibility
Expand Down
37 changes: 37 additions & 0 deletions packages/raystack/components/data-view/__tests__/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/**
* Fixtures shared by the data-view util suites.
*
* Kept in one place because `pack-lanes.test.ts` pins recorded goldens built
* from these generators: a golden only means something if the data behind it
* cannot drift, and two copies of an LCG eventually stop agreeing.
*/

/** Seeded LCG — a failing case has to be reproducible. */
export function seededRandom(seed: number) {
let state = seed;
return () => {
state = (state * 1664525 + 1013904223) >>> 0;
return state / 0x100000000;
};
}

/** FNV-1a over the decimal text, so [1, 23] and [12, 3] can't collide. */
export function digest(values: readonly number[]): string {
let hash = 0x811c9dc5;
for (const value of values) {
const text = `${value},`;
for (let i = 0; i < text.length; i++) {
hash ^= text.charCodeAt(i);
hash = Math.imul(hash, 0x01000193) >>> 0;
}
}
return hash.toString(16).padStart(8, '0');
}

export const randomItems = (seed: number, count: number) => {
const random = seededRandom(seed);
return Array.from({ length: count }, () => ({
x: Math.round(random() * 10000),
width: Math.round(random() * 200)
}));
};
141 changes: 141 additions & 0 deletions packages/raystack/components/data-view/__tests__/order-by-x.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { describe, expect, it } from 'vitest';
import { orderByX } from '../utils/order-by-x';
import { seededRandom } from './helpers';

/**
* `orderByX` returns indices ascending by `x`, ties broken by input order.
*
* It has two implementations behind one signature — a comparison sort below 64
* items, a counting sort at or above it — so most of these run a differential
* against `Array#sort`, which is the specification the counting sort has to
* reproduce exactly (including the tie-break, which lane packing depends on).
*/

const BUCKET_SORT_MIN_ITEMS = 64;

/** The order the counting sort has to match. */
const referenceOrder = (items: { x: number }[]) =>
items.map((_, i) => i).sort((a, b) => items[a].x - items[b].x || a - b);

describe('orderByX', () => {
const sortedXs = (items: { x: number }[]) =>
Array.from(orderByX(items), index => items[index].x);

it('returns an empty order for empty input', () => {
expect(Array.from(orderByX([]))).toEqual([]);
});

it('returns the only index for a single item', () => {
expect(Array.from(orderByX([{ x: 42 }]))).toEqual([0]);
});

it('orders ascending by x, breaking ties by input order', () => {
const items = [{ x: 30 }, { x: 10 }, { x: 30 }, { x: -5 }];
expect(Array.from(orderByX(items))).toEqual([3, 1, 0, 2]);
});

/* Past 64 items orderByX swaps its comparison sort for a counting sort, so
the rest of these run above that threshold. */

it('agrees with itself either side of the counting-sort threshold', () => {
// The implementation is chosen by item count, so the same data must order
// identically at 63 items and at 64 — otherwise adding one card silently
// repacks the lanes. Ties are dense here to put the tie-break under load.
const items = Array.from({ length: BUCKET_SORT_MIN_ITEMS }, (_, i) => ({
x: (i % 8) * 50
}));
const below = items.slice(0, BUCKET_SORT_MIN_ITEMS - 1);
expect(Array.from(orderByX(below))).toEqual(referenceOrder(below));
expect(Array.from(orderByX(items))).toEqual(referenceOrder(items));
});

it('matches a comparison sort on randomly spread items', () => {
const random = seededRandom(7);
for (let round = 0; round < 20; round++) {
const items = Array.from({ length: 500 }, () => ({
x: Math.round((random() - 0.5) * 20000)
}));
expect(Array.from(orderByX(items))).toEqual(referenceOrder(items));
}
});

it('matches a comparison sort when every x is negative', () => {
// `minX` is negative, so the bucket index is driven entirely by the offset
// rather than by x itself — the case where a missing `- minX` still looks
// correct on non-negative data.
const random = seededRandom(13);
const items = Array.from({ length: 400 }, () => ({
x: -Math.round(random() * 50000) - 1
}));
expect(Array.from(orderByX(items))).toEqual(referenceOrder(items));
});

it('keeps input order when every item shares one x', () => {
// Zero extent — nothing to bucket by, and the tie-break is input order.
const items = Array.from({ length: 100 }, () => ({ x: 42 }));
expect(Array.from(orderByX(items))).toEqual(
Array.from({ length: 100 }, (_, i) => i)
);
});

it('sorts a bucket deeper than the insertion-sort cutoff', () => {
// 80 items on one x land in a single bucket, past the depth where that
// bucket hands off to a comparison sort.
const items = [
...Array.from({ length: 80 }, () => ({ x: 100 })),
...Array.from({ length: 40 }, (_, i) => ({ x: 900 - i }))
];
expect(sortedXs(items)).toEqual(
items.map(item => item.x).sort((a, b) => a - b)
);
});

it('orders items clustered at both ends of the extent', () => {
// A near-empty middle makes the uniform bucket split maximally uneven.
const items = [
...Array.from({ length: 60 }, (_, i) => ({ x: i / 1000 })),
...Array.from({ length: 60 }, (_, i) => ({ x: 10000 + i / 1000 }))
];
expect(sortedXs(items)).toEqual(
items.map(item => item.x).sort((a, b) => a - b)
);
});

it('still returns a usable permutation for non-finite x', () => {
// Non-finite geometry shouldn't reach here — x comes from the time scale —
// but a NaN must not corrupt the ordering of the cards around it or drop
// an index, which would lose a card from the canvas entirely. Ordering
// *among* non-finite values is not asserted: `a.x - b.x` is NaN for those
// pairs, so no total order exists to assert against.
const cases: { x: number }[][] = [
Array.from({ length: 70 }, (_, i) => ({
x: i === 30 ? Number.NaN : i * 10
})),
Array.from({ length: 70 }, (_, i) => ({
x: i === 5 ? Number.POSITIVE_INFINITY : i * 10
})),
Array.from({ length: 70 }, () => ({ x: Number.NaN }))
];
for (const items of cases) {
const order = Array.from(orderByX(items));
expect(order.length).toBe(items.length);
expect([...order].sort((a, b) => a - b)).toEqual(
Array.from({ length: items.length }, (_, i) => i)
);
}
});

it('orders the finite items correctly around an infinity', () => {
// Infinity widens the extent to the point where every finite item buckets
// together, which forces the deep-bucket comparison fallback. The finite
// cards must still come out in order.
const items = [
...Array.from({ length: 69 }, (_, i) => ({ x: 690 - i * 10 })),
{ x: Number.POSITIVE_INFINITY }
];
const finite = Array.from(orderByX(items))
.map(index => items[index].x)
.filter(Number.isFinite);
expect(finite).toEqual([...finite].sort((a, b) => a - b));
});
});
Loading
Loading