Skip to content

Commit 967efd8

Browse files
[scheduler] Improve timeline event layout performance
1 parent 7b70c2f commit 967efd8

7 files changed

Lines changed: 335 additions & 163 deletions

File tree

‎packages/x-scheduler-internals/package.json‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,9 @@
5757
],
5858
"scripts": {
5959
"typescript": "tsgo -p tsconfig.json",
60-
"build": "code-infra build --tsgo --copy \"src/**/*.css\" --copy \"src/**/*.css:esm\"",
61-
"prebuild": "rimraf build tsconfig.build.tsbuildinfo"
60+
"build": "code-infra build --tsgo --copy \"src/**/*.css\" --copy \"src/**/*.css:esm\" --ignore 'src/**/*.bench.ts'",
61+
"prebuild": "rimraf build tsconfig.build.tsbuildinfo",
62+
"bench:jsdom": "pnpm vitest bench -c ./vitest.config.jsdom.mts"
6263
},
6364
"repository": {
6465
"type": "git",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { bench, describe } from 'vitest';
2+
import type { SchedulerEventOccurrence } from '../models';
3+
import { computeOccurrencesWithTimelinePosition } from './useEventOccurrencesWithTimelinePosition';
4+
5+
const denseOccurrences = createOccurrences(2_000, () => [0, 60]);
6+
const rollingOccurrences = createOccurrences(10_000, (index) => [index, index + 100]);
7+
8+
describe('timeline occurrence positioning', () => {
9+
bench('2,000 fully overlapping occurrences', () => {
10+
computeOccurrencesWithTimelinePosition(denseOccurrences, Infinity);
11+
});
12+
13+
bench('10,000 rolling occurrences with 100 concurrent events', () => {
14+
computeOccurrencesWithTimelinePosition(rollingOccurrences, 1);
15+
});
16+
});
17+
18+
function createOccurrences(
19+
count: number,
20+
getRange: (index: number) => [start: number, end: number],
21+
) {
22+
return Array.from({ length: count }, (_, index) => {
23+
const [start, end] = getRange(index);
24+
return {
25+
id: index,
26+
key: String(index),
27+
displayTimezone: {
28+
start: { timestamp: start },
29+
end: { timestamp: end },
30+
},
31+
} as SchedulerEventOccurrence;
32+
});
33+
}

‎packages/x-scheduler-internals/src/use-event-occurrences-with-timeline-position/useEventOccurrencesWithTimelinePosition.test.ts‎

Lines changed: 136 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ import { adapter, EventBuilder } from 'test/utils/scheduler';
22
import { renderHook } from '@mui/internal-test-utils';
33
import type { SchedulerProcessedEvent } from '@mui/x-scheduler-internals/models';
44
import { getOccurrencesFromEvents } from '@mui/x-scheduler-internals/internals';
5+
import { sortEventOccurrences } from '@mui/x-scheduler-internals/sort-event-occurrences';
56
import {
7+
computeOccurrencesWithTimelinePosition,
68
computeOccurrencesFirstIndexLookup,
79
useEventOccurrencesWithTimelinePosition,
810
} from './useEventOccurrencesWithTimelinePosition';
@@ -237,12 +239,145 @@ describe('useDayListEventOccurrencesWithPosition', () => {
237239
visibleResources: {},
238240
recurringEventsPlugin: null,
239241
});
240-
const lookup = computeOccurrencesFirstIndexLookup(adapter, occurrences);
242+
const lookup = computeOccurrencesFirstIndexLookup(occurrences);
241243

242244
expect(Object.keys(lookup)).to.have.length(result.occurrences.length);
243245
for (const occurrence of result.occurrences) {
244246
expect(lookup[occurrence.key]).to.equal(occurrence.position.firstIndex);
245247
}
246248
});
247249
});
250+
251+
it('should update the available span when a freed lane is reused', () => {
252+
const occurrences = [
253+
EventBuilder.new().id('A').singleDay('2024-01-15T10:00:00Z', 30).toOccurrence(),
254+
EventBuilder.new().id('B').singleDay('2024-01-15T10:01:00Z', 39).toOccurrence(),
255+
EventBuilder.new().id('C').singleDay('2024-01-15T10:10:00Z', 120).toOccurrence(),
256+
EventBuilder.new().id('D').singleDay('2024-01-15T10:50:00Z', 60).toOccurrence(),
257+
EventBuilder.new().id('E').singleDay('2024-01-15T11:00:00Z', 30).toOccurrence(),
258+
];
259+
260+
const resultBeforeReuse = computeOccurrencesWithTimelinePosition(
261+
occurrences.slice(0, -1),
262+
Infinity,
263+
);
264+
const result = computeOccurrencesWithTimelinePosition(occurrences, Infinity);
265+
266+
expect(resultBeforeReuse.occurrences[3].position).to.deep.equal({
267+
firstIndex: 1,
268+
lastIndex: 2,
269+
});
270+
expect(result.maxIndex).to.equal(3);
271+
expect(result.occurrences.map(({ id, position }) => ({ id, ...position }))).to.deep.equal([
272+
{ id: 'A', firstIndex: 1, lastIndex: 1 },
273+
{ id: 'B', firstIndex: 2, lastIndex: 2 },
274+
{ id: 'C', firstIndex: 3, lastIndex: 3 },
275+
{ id: 'D', firstIndex: 1, lastIndex: 1 },
276+
{ id: 'E', firstIndex: 2, lastIndex: 2 },
277+
]);
278+
});
279+
280+
it('should match the quadratic layout for varied event distributions', () => {
281+
let seed = 239;
282+
const random = () => {
283+
seed = (seed * 16807) % 2147483647;
284+
return (seed - 1) / 2147483646;
285+
};
286+
287+
for (let iteration = 0; iteration < 100; iteration += 1) {
288+
const occurrenceCount = 1 + Math.floor(random() * 40);
289+
const occurrences = Array.from({ length: occurrenceCount }, (_, index) => {
290+
const startMinutes = Math.floor(random() * 12) * 30;
291+
const durationMinutes = Math.floor(random() * 9) * 15;
292+
const start = adapter.addMinutes(collectionStart, startMinutes).toISOString();
293+
return EventBuilder.new()
294+
.id(`${iteration}-${index}`)
295+
.singleDay(start, durationMinutes)
296+
.toOccurrence();
297+
});
298+
const maxSpan = [1, 2, 4, Infinity][iteration % 4];
299+
300+
const result = computeOccurrencesWithTimelinePosition(occurrences, maxSpan);
301+
const expected = computeLayoutQuadratically(occurrences, maxSpan);
302+
303+
expect(result.occurrences.map(({ id, position }) => ({ id, ...position }))).to.deep.equal(
304+
expected.occurrences,
305+
);
306+
expect(result.maxIndex).to.equal(expected.maxIndex);
307+
}
308+
});
309+
310+
it('should position a dense set of occurrences', () => {
311+
const occurrences = Array.from({ length: 1_000 }, (_, index) =>
312+
EventBuilder.new().id(index).singleDay('2024-01-15T10:00:00Z', 120).toOccurrence(),
313+
);
314+
315+
const result = computeOccurrencesWithTimelinePosition(occurrences, Infinity);
316+
317+
expect(result.maxIndex).to.equal(1_000);
318+
expect(result.occurrences[0].position).to.deep.equal({ firstIndex: 1, lastIndex: 1 });
319+
expect(result.occurrences[999].position).to.deep.equal({
320+
firstIndex: 1_000,
321+
lastIndex: 1_000,
322+
});
323+
});
248324
});
325+
326+
function computeLayoutQuadratically(
327+
occurrences: ReturnType<EventBuilder['toOccurrence']>[],
328+
maxSpan: number,
329+
) {
330+
const sortedOccurrences = sortEventOccurrences(occurrences);
331+
const firstIndexLookup: Record<string, number> = {};
332+
let maxIndex = 1;
333+
334+
for (let index = 0; index < sortedOccurrences.length; index += 1) {
335+
const occurrence = sortedOccurrences[index];
336+
const usedIndexes = new Set<number>();
337+
for (let previousIndex = 0; previousIndex < index; previousIndex += 1) {
338+
const previousOccurrence = sortedOccurrences[previousIndex];
339+
if (
340+
previousOccurrence.displayTimezone.end.timestamp >
341+
occurrence.displayTimezone.start.timestamp
342+
) {
343+
usedIndexes.add(firstIndexLookup[previousOccurrence.key]);
344+
}
345+
}
346+
347+
let firstIndex = 1;
348+
while (usedIndexes.has(firstIndex)) {
349+
firstIndex += 1;
350+
}
351+
firstIndexLookup[occurrence.key] = firstIndex;
352+
maxIndex = Math.max(maxIndex, firstIndex);
353+
}
354+
355+
return {
356+
occurrences: sortedOccurrences.map((occurrence, index) => {
357+
const firstIndex = firstIndexLookup[occurrence.key];
358+
const usedIndexes = new Set(
359+
sortedOccurrences
360+
.filter((otherOccurrence, otherIndex) =>
361+
otherIndex < index
362+
? otherOccurrence.displayTimezone.end.timestamp >
363+
occurrence.displayTimezone.start.timestamp
364+
: otherIndex > index &&
365+
otherOccurrence.displayTimezone.start.timestamp <
366+
occurrence.displayTimezone.end.timestamp,
367+
)
368+
.map((otherOccurrence) => firstIndexLookup[otherOccurrence.key]),
369+
);
370+
let lastIndex = firstIndex;
371+
while (
372+
!usedIndexes.has(lastIndex + 1) &&
373+
lastIndex < maxIndex &&
374+
lastIndex - firstIndex < maxSpan - 1
375+
) {
376+
lastIndex += 1;
377+
}
378+
379+
return { id: occurrence.id, firstIndex, lastIndex };
380+
}),
381+
maxIndex,
382+
};
383+
}

0 commit comments

Comments
 (0)