Skip to content

Commit 66b7ea2

Browse files
authored
feat: Add applyLanes() array util to support densely packing timelines (#580)
1 parent ede44b2 commit 66b7ea2

6 files changed

Lines changed: 414 additions & 13 deletions

File tree

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { applyLanes } from './array.js';
3+
4+
describe('applyLanes', () => {
5+
it('should assign same lane to non-overlapping events', () => {
6+
const data = [
7+
{ id: 1, start: new Date('2023-01-01'), end: new Date('2023-01-02') },
8+
{ id: 2, start: new Date('2023-01-03'), end: new Date('2023-01-05') },
9+
{ id: 3, start: new Date('2023-01-06'), end: new Date('2023-01-08') },
10+
];
11+
12+
const result = applyLanes(data);
13+
14+
expect(result).toEqual([
15+
{ id: 1, start: new Date('2023-01-01'), end: new Date('2023-01-02'), lane: 0 },
16+
{ id: 2, start: new Date('2023-01-03'), end: new Date('2023-01-05'), lane: 0 },
17+
{ id: 3, start: new Date('2023-01-06'), end: new Date('2023-01-08'), lane: 0 },
18+
]);
19+
});
20+
21+
it('should assign different lanes to overlapping events', () => {
22+
const data = [
23+
{ id: 1, start: new Date('2023-01-01'), end: new Date('2023-01-03') },
24+
{ id: 2, start: new Date('2023-01-02'), end: new Date('2023-01-04') },
25+
{ id: 3, start: new Date('2023-01-02T12:00:00'), end: new Date('2023-01-05') },
26+
];
27+
28+
const result = applyLanes(data);
29+
30+
expect(result).toEqual([
31+
{ id: 1, start: new Date('2023-01-01'), end: new Date('2023-01-03'), lane: 0 },
32+
{ id: 2, start: new Date('2023-01-02'), end: new Date('2023-01-04'), lane: 1 },
33+
{ id: 3, start: new Date('2023-01-02T12:00:00'), end: new Date('2023-01-05'), lane: 2 },
34+
]);
35+
});
36+
37+
it('should reuse lanes when events no longer overlap', () => {
38+
const data = [
39+
{ id: 1, start: new Date('2023-01-01'), end: new Date('2023-01-02') },
40+
{ id: 2, start: new Date('2023-01-01T12:00:00'), end: new Date('2023-01-03') },
41+
{ id: 3, start: new Date('2023-01-04'), end: new Date('2023-01-06') }, // starts after id: 1 ends
42+
{ id: 4, start: new Date('2023-01-05'), end: new Date('2023-01-07') }, // starts after id: 2 ends
43+
];
44+
45+
const result = applyLanes(data);
46+
47+
expect(result).toEqual([
48+
{ id: 1, start: new Date('2023-01-01'), end: new Date('2023-01-02'), lane: 0 },
49+
{ id: 2, start: new Date('2023-01-01T12:00:00'), end: new Date('2023-01-03'), lane: 1 },
50+
{ id: 3, start: new Date('2023-01-04'), end: new Date('2023-01-06'), lane: 0 }, // reuses lane 0
51+
{ id: 4, start: new Date('2023-01-05'), end: new Date('2023-01-07'), lane: 1 }, // reuses lane 1
52+
]);
53+
});
54+
55+
it('should handle events that start exactly when another ends', () => {
56+
const data = [
57+
{ id: 1, start: new Date('2023-01-01'), end: new Date('2023-01-02') },
58+
{ id: 2, start: new Date('2023-01-02'), end: new Date('2023-01-04') }, // starts exactly when id: 1 ends
59+
{ id: 3, start: new Date('2023-01-01T12:00:00'), end: new Date('2023-01-03') }, // overlaps with both
60+
];
61+
62+
const result = applyLanes(data);
63+
64+
expect(result).toEqual([
65+
{ id: 1, start: new Date('2023-01-01'), end: new Date('2023-01-02'), lane: 0 },
66+
{ id: 2, start: new Date('2023-01-02'), end: new Date('2023-01-04'), lane: 0 }, // can reuse lane 0
67+
{ id: 3, start: new Date('2023-01-01T12:00:00'), end: new Date('2023-01-03'), lane: 1 }, // overlaps, needs new lane
68+
]);
69+
});
70+
71+
it('should work with string keys for start and end', () => {
72+
const data = [
73+
{ name: 'Task 1', startTime: new Date('2023-01-01'), endTime: new Date('2023-01-03') },
74+
{ name: 'Task 2', startTime: new Date('2023-01-02'), endTime: new Date('2023-01-04') },
75+
];
76+
77+
const result = applyLanes(data, { start: 'startTime', end: 'endTime' });
78+
79+
expect(result).toEqual([
80+
{
81+
name: 'Task 1',
82+
startTime: new Date('2023-01-01'),
83+
endTime: new Date('2023-01-03'),
84+
lane: 0,
85+
},
86+
{
87+
name: 'Task 2',
88+
startTime: new Date('2023-01-02'),
89+
endTime: new Date('2023-01-04'),
90+
lane: 1,
91+
},
92+
]);
93+
});
94+
95+
it('should handle empty array', () => {
96+
const data: any[] = [];
97+
const result = applyLanes(data);
98+
expect(result).toEqual([]);
99+
});
100+
101+
it('should handle single event', () => {
102+
const data = [{ id: 1, start: new Date('2023-01-01'), end: new Date('2023-01-02') }];
103+
const result = applyLanes(data);
104+
105+
expect(result).toEqual([
106+
{ id: 1, start: new Date('2023-01-01'), end: new Date('2023-01-02'), lane: 0 },
107+
]);
108+
});
109+
110+
it('should handle complex overlapping scenario', () => {
111+
const data = [
112+
{ id: 1, start: new Date('2023-01-01'), end: new Date('2023-01-05') }, // long event
113+
{ id: 2, start: new Date('2023-01-02'), end: new Date('2023-01-03') }, // short event inside
114+
{ id: 3, start: new Date('2023-01-02T12:00:00'), end: new Date('2023-01-04') }, // overlaps with both
115+
{ id: 4, start: new Date('2023-01-03'), end: new Date('2023-01-04T12:00:00') }, // overlaps with 1 and 3
116+
{ id: 5, start: new Date('2023-01-06'), end: new Date('2023-01-08') }, // separate event
117+
];
118+
119+
const result = applyLanes(data);
120+
121+
expect(result).toEqual([
122+
{ id: 1, start: new Date('2023-01-01'), end: new Date('2023-01-05'), lane: 0 },
123+
{ id: 2, start: new Date('2023-01-02'), end: new Date('2023-01-03'), lane: 1 },
124+
{ id: 3, start: new Date('2023-01-02T12:00:00'), end: new Date('2023-01-04'), lane: 2 },
125+
{ id: 4, start: new Date('2023-01-03'), end: new Date('2023-01-04T12:00:00'), lane: 1 }, // can reuse lane 1 since id: 2 ended
126+
{ id: 5, start: new Date('2023-01-06'), end: new Date('2023-01-08'), lane: 0 }, // can reuse lane 0 since id: 1 ended
127+
]);
128+
});
129+
130+
it('should preserve all original properties', () => {
131+
const data = [
132+
{
133+
id: 1,
134+
start: new Date('2023-01-01'),
135+
end: new Date('2023-01-02'),
136+
name: 'First',
137+
priority: 'high',
138+
metadata: { foo: 'bar' },
139+
},
140+
{
141+
id: 2,
142+
start: new Date('2023-01-01T12:00:00'),
143+
end: new Date('2023-01-03'),
144+
name: 'Second',
145+
priority: 'low',
146+
metadata: { baz: 'qux' },
147+
},
148+
];
149+
150+
const result = applyLanes(data);
151+
152+
expect(result).toEqual([
153+
{
154+
id: 1,
155+
start: new Date('2023-01-01'),
156+
end: new Date('2023-01-02'),
157+
name: 'First',
158+
priority: 'high',
159+
metadata: { foo: 'bar' },
160+
lane: 0,
161+
},
162+
{
163+
id: 2,
164+
start: new Date('2023-01-01T12:00:00'),
165+
end: new Date('2023-01-03'),
166+
name: 'Second',
167+
priority: 'low',
168+
metadata: { baz: 'qux' },
169+
lane: 1,
170+
},
171+
]);
172+
});
173+
174+
it('should work with numeric values', () => {
175+
const data = [
176+
{ id: 1, start: 0, end: 3 },
177+
{ id: 2, start: 1, end: 4 },
178+
{ id: 3, start: 5, end: 7 },
179+
];
180+
181+
const result = applyLanes(data);
182+
183+
expect(result).toEqual([
184+
{ id: 1, start: 0, end: 3, lane: 0 },
185+
{ id: 2, start: 1, end: 4, lane: 1 },
186+
{ id: 3, start: 5, end: 7, lane: 0 }, // can reuse lane 0 since id: 1 ended
187+
]);
188+
});
189+
});

packages/layerchart/src/lib/utils/array.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,29 @@ export function arraysEqual(arr1: unknown[], arr2: unknown[]) {
2020
return arr2.includes(k);
2121
});
2222
}
23+
24+
/**
25+
* Add `lanes` property to each element in the data array support densely packing.
26+
* This is useful for visualizing overlapping events in a timeline / Gantt chart.
27+
*/
28+
export function applyLanes<T extends Record<string, any>>(
29+
data: T[],
30+
options: { start: keyof T; end: keyof T } = { start: 'start' as keyof T, end: 'end' as keyof T }
31+
) {
32+
const result: (T & { lane: number })[] = [];
33+
let stack: T[] = [];
34+
35+
for (const d of data) {
36+
let lane = stack.findIndex(
37+
(s) => s[options.end] <= d[options.start] && s[options.start] < d[options.start]
38+
);
39+
if (lane === -1) {
40+
lane = stack.length;
41+
}
42+
43+
result.push({ ...d, lane });
44+
stack[lane] = d;
45+
}
46+
47+
return result;
48+
}

0 commit comments

Comments
 (0)