Skip to content

Commit 49e9fc9

Browse files
committed
Add timeline slider with pan capability and fade mode
- Created custom TimeRangeSlider component with draggable range track - Support for dragging individual thumbs to resize range - Support for dragging the selected track to pan the time window - Full keyboard navigation (arrows to resize, Shift+arrows to pan) - Complete ARIA accessibility support - Added fadeInsteadOfHide option to timeline filters - Implemented opacity-based filtering (0.05 alpha) for edges and nodes - Faded nodes without active edges in timeline range - Updated translations to be more user-friendly - Timeline filter now shows 'Timeline' label - Fade checkbox shows 'Show faded edges and nodes'
1 parent 64d2bc5 commit 49e9fc9

File tree

5 files changed

+510
-79
lines changed

5 files changed

+510
-79
lines changed
Lines changed: 389 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,389 @@
1+
import { DateTime } from "luxon";
2+
import { FC, useCallback, useEffect, useRef, useState } from "react";
3+
4+
interface TimeRangeSliderProps {
5+
min: Date;
6+
max: Date;
7+
value: [Date, Date];
8+
onChange: (range: [Date, Date]) => void;
9+
onCommit?: (range: [Date, Date]) => void;
10+
step?: number; // milliseconds, defaults to 1 day
11+
disabled?: boolean;
12+
formatLabel?: (date: Date) => string;
13+
marks?: Record<number, string>;
14+
}
15+
16+
type DragMode = "min" | "max" | "range" | null;
17+
18+
export const TimeRangeSlider: FC<TimeRangeSliderProps> = ({
19+
min,
20+
max,
21+
value,
22+
onChange,
23+
onCommit,
24+
step = 24 * 60 * 60 * 1000, // 1 day default
25+
disabled = false,
26+
formatLabel,
27+
marks,
28+
}) => {
29+
const railRef = useRef<HTMLDivElement>(null);
30+
const [dragMode, setDragMode] = useState<DragMode>(null);
31+
const [dragStartX, setDragStartX] = useState(0);
32+
const [dragStartValues, setDragStartValues] = useState<[number, number]>([0, 0]);
33+
const [focusedThumb, setFocusedThumb] = useState<"min" | "max" | null>(null);
34+
35+
const minTime = min.getTime();
36+
const maxTime = max.getTime();
37+
const totalDuration = maxTime - minTime;
38+
39+
const [minValue, maxValue] = value;
40+
const minValueTime = minValue.getTime();
41+
const maxValueTime = maxValue.getTime();
42+
43+
// Calculate positions as percentages
44+
const minPosition = ((minValueTime - minTime) / totalDuration) * 100;
45+
const maxPosition = ((maxValueTime - minTime) / totalDuration) * 100;
46+
47+
const formatDate = useCallback(
48+
(date: Date) => {
49+
if (formatLabel) return formatLabel(date);
50+
return DateTime.fromJSDate(date).toFormat("yyyy-MM-dd");
51+
},
52+
[formatLabel],
53+
);
54+
55+
const snapToStep = useCallback(
56+
(time: number) => {
57+
const stepsFromMin = Math.round((time - minTime) / step);
58+
return Math.max(minTime, Math.min(maxTime, minTime + stepsFromMin * step));
59+
},
60+
[minTime, maxTime, step],
61+
);
62+
63+
const getTimeFromPosition = useCallback(
64+
(clientX: number) => {
65+
if (!railRef.current) return minTime;
66+
const rect = railRef.current.getBoundingClientRect();
67+
const percentage = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
68+
const time = minTime + percentage * totalDuration;
69+
return snapToStep(time);
70+
},
71+
[minTime, totalDuration, snapToStep],
72+
);
73+
74+
const handleMouseDown = useCallback(
75+
(mode: DragMode, e: React.MouseEvent) => {
76+
if (disabled) return;
77+
e.preventDefault();
78+
setDragMode(mode);
79+
setDragStartX(e.clientX);
80+
setDragStartValues([minValueTime, maxValueTime]);
81+
},
82+
[disabled, minValueTime, maxValueTime],
83+
);
84+
85+
const handleMouseMove = useCallback(
86+
(e: MouseEvent) => {
87+
if (!dragMode || !railRef.current) return;
88+
89+
const rect = railRef.current.getBoundingClientRect();
90+
const deltaX = e.clientX - dragStartX;
91+
const deltaTime = (deltaX / rect.width) * totalDuration;
92+
const [startMin, startMax] = dragStartValues;
93+
94+
let newMinTime: number;
95+
let newMaxTime: number;
96+
97+
if (dragMode === "min") {
98+
newMinTime = snapToStep(startMin + deltaTime);
99+
newMinTime = Math.max(minTime, Math.min(newMinTime, startMax - step));
100+
newMaxTime = maxValueTime;
101+
} else if (dragMode === "max") {
102+
newMaxTime = snapToStep(startMax + deltaTime);
103+
newMaxTime = Math.max(startMin + step, Math.min(newMaxTime, maxTime));
104+
newMinTime = minValueTime;
105+
} else if (dragMode === "range") {
106+
const rangeDuration = startMax - startMin;
107+
newMinTime = snapToStep(startMin + deltaTime);
108+
newMaxTime = newMinTime + rangeDuration;
109+
110+
// Keep range within bounds
111+
if (newMinTime < minTime) {
112+
newMinTime = minTime;
113+
newMaxTime = minTime + rangeDuration;
114+
}
115+
if (newMaxTime > maxTime) {
116+
newMaxTime = maxTime;
117+
newMinTime = maxTime - rangeDuration;
118+
}
119+
} else {
120+
return;
121+
}
122+
123+
onChange([new Date(newMinTime), new Date(newMaxTime)]);
124+
},
125+
[
126+
dragMode,
127+
dragStartX,
128+
dragStartValues,
129+
totalDuration,
130+
snapToStep,
131+
minTime,
132+
maxTime,
133+
step,
134+
onChange,
135+
minValueTime,
136+
maxValueTime,
137+
],
138+
);
139+
140+
const handleMouseUp = useCallback(() => {
141+
if (dragMode && onCommit) {
142+
onCommit([minValue, maxValue]);
143+
}
144+
setDragMode(null);
145+
}, [dragMode, minValue, maxValue, onCommit]);
146+
147+
useEffect(() => {
148+
if (dragMode) {
149+
document.addEventListener("mousemove", handleMouseMove);
150+
document.addEventListener("mouseup", handleMouseUp);
151+
return () => {
152+
document.removeEventListener("mousemove", handleMouseMove);
153+
document.removeEventListener("mouseup", handleMouseUp);
154+
};
155+
}
156+
}, [dragMode, handleMouseMove, handleMouseUp]);
157+
158+
const handleKeyDown = useCallback(
159+
(thumb: "min" | "max", e: React.KeyboardEvent) => {
160+
if (disabled) return;
161+
162+
const isShiftPressed = e.shiftKey;
163+
let handled = false;
164+
165+
if (isShiftPressed) {
166+
// Shift + Arrow: Pan the range
167+
const rangeDuration = maxValueTime - minValueTime;
168+
let newMinTime = minValueTime;
169+
let newMaxTime = maxValueTime;
170+
171+
if (e.key === "ArrowRight") {
172+
newMinTime = Math.min(minValueTime + step, maxTime - rangeDuration);
173+
newMaxTime = newMinTime + rangeDuration;
174+
handled = true;
175+
} else if (e.key === "ArrowLeft") {
176+
newMinTime = Math.max(minValueTime - step, minTime);
177+
newMaxTime = newMinTime + rangeDuration;
178+
handled = true;
179+
}
180+
181+
if (handled) {
182+
onChange([new Date(newMinTime), new Date(newMaxTime)]);
183+
if (onCommit) onCommit([new Date(newMinTime), new Date(newMaxTime)]);
184+
}
185+
} else {
186+
// Arrow: Resize the range
187+
if (thumb === "min") {
188+
if (e.key === "ArrowRight") {
189+
const newMinTime = Math.min(minValueTime + step, maxValueTime - step);
190+
onChange([new Date(newMinTime), maxValue]);
191+
if (onCommit) onCommit([new Date(newMinTime), maxValue]);
192+
handled = true;
193+
} else if (e.key === "ArrowLeft") {
194+
const newMinTime = Math.max(minValueTime - step, minTime);
195+
onChange([new Date(newMinTime), maxValue]);
196+
if (onCommit) onCommit([new Date(newMinTime), maxValue]);
197+
handled = true;
198+
}
199+
} else {
200+
if (e.key === "ArrowRight") {
201+
const newMaxTime = Math.min(maxValueTime + step, maxTime);
202+
onChange([minValue, new Date(newMaxTime)]);
203+
if (onCommit) onCommit([minValue, new Date(newMaxTime)]);
204+
handled = true;
205+
} else if (e.key === "ArrowLeft") {
206+
const newMaxTime = Math.max(maxValueTime - step, minValueTime + step);
207+
onChange([minValue, new Date(newMaxTime)]);
208+
if (onCommit) onCommit([minValue, new Date(newMaxTime)]);
209+
handled = true;
210+
}
211+
}
212+
}
213+
214+
if (handled) {
215+
e.preventDefault();
216+
}
217+
},
218+
[disabled, minValueTime, maxValueTime, step, minTime, maxTime, minValue, maxValue, onChange, onCommit],
219+
);
220+
221+
const handleRailClick = useCallback(
222+
(e: React.MouseEvent) => {
223+
if (disabled || dragMode) return;
224+
const clickedTime = getTimeFromPosition(e.clientX);
225+
const rangeCenter = (minValueTime + maxValueTime) / 2;
226+
227+
if (clickedTime < rangeCenter) {
228+
// Click before range: move min thumb
229+
const newMinTime = Math.max(minTime, Math.min(clickedTime, maxValueTime - step));
230+
onChange([new Date(newMinTime), maxValue]);
231+
if (onCommit) onCommit([new Date(newMinTime), maxValue]);
232+
} else {
233+
// Click after range: move max thumb
234+
const newMaxTime = Math.max(minValueTime + step, Math.min(clickedTime, maxTime));
235+
onChange([minValue, new Date(newMaxTime)]);
236+
if (onCommit) onCommit([minValue, new Date(newMaxTime)]);
237+
}
238+
},
239+
[
240+
disabled,
241+
dragMode,
242+
getTimeFromPosition,
243+
minValueTime,
244+
maxValueTime,
245+
minTime,
246+
maxTime,
247+
step,
248+
minValue,
249+
maxValue,
250+
onChange,
251+
onCommit,
252+
],
253+
);
254+
255+
return (
256+
<div className="time-range-slider" style={{ padding: "20px 0", userSelect: "none" }}>
257+
{/* Rail */}
258+
<div
259+
ref={railRef}
260+
onClick={handleRailClick}
261+
style={{
262+
position: "relative",
263+
height: "6px",
264+
backgroundColor: "#e0e0e0",
265+
borderRadius: "3px",
266+
cursor: disabled ? "not-allowed" : "pointer",
267+
}}
268+
role="group"
269+
aria-label="Time range slider"
270+
>
271+
{/* Track (selected range) */}
272+
<div
273+
onMouseDown={(e) => handleMouseDown("range", e)}
274+
style={{
275+
position: "absolute",
276+
left: `${minPosition}%`,
277+
right: `${100 - maxPosition}%`,
278+
height: "100%",
279+
backgroundColor: disabled ? "#ccc" : "#000",
280+
borderRadius: "3px",
281+
cursor: disabled ? "not-allowed" : dragMode === "range" ? "grabbing" : "grab",
282+
}}
283+
role="presentation"
284+
/>
285+
286+
{/* Min Thumb */}
287+
<div
288+
onMouseDown={(e) => handleMouseDown("min", e)}
289+
onKeyDown={(e) => handleKeyDown("min", e)}
290+
onFocus={() => setFocusedThumb("min")}
291+
onBlur={() => setFocusedThumb(null)}
292+
tabIndex={disabled ? -1 : 0}
293+
role="slider"
294+
aria-valuemin={minTime}
295+
aria-valuemax={maxTime}
296+
aria-valuenow={minValueTime}
297+
aria-valuetext={formatDate(minValue)}
298+
aria-label="Minimum date"
299+
aria-disabled={disabled}
300+
style={{
301+
position: "absolute",
302+
left: `${minPosition}%`,
303+
top: "50%",
304+
transform: "translate(-50%, -50%)",
305+
width: "20px",
306+
height: "20px",
307+
backgroundColor: disabled ? "#999" : "#000",
308+
borderRadius: "50%",
309+
border: focusedThumb === "min" ? "2px solid #0066cc" : "2px solid #fff",
310+
boxShadow: "0 2px 4px rgba(0,0,0,0.2)",
311+
cursor: disabled ? "not-allowed" : dragMode === "min" ? "grabbing" : "grab",
312+
zIndex: 2,
313+
}}
314+
/>
315+
316+
{/* Max Thumb */}
317+
<div
318+
onMouseDown={(e) => handleMouseDown("max", e)}
319+
onKeyDown={(e) => handleKeyDown("max", e)}
320+
onFocus={() => setFocusedThumb("max")}
321+
onBlur={() => setFocusedThumb(null)}
322+
tabIndex={disabled ? -1 : 0}
323+
role="slider"
324+
aria-valuemin={minTime}
325+
aria-valuemax={maxTime}
326+
aria-valuenow={maxValueTime}
327+
aria-valuetext={formatDate(maxValue)}
328+
aria-label="Maximum date"
329+
aria-disabled={disabled}
330+
style={{
331+
position: "absolute",
332+
left: `${maxPosition}%`,
333+
top: "50%",
334+
transform: "translate(-50%, -50%)",
335+
width: "20px",
336+
height: "20px",
337+
backgroundColor: disabled ? "#999" : "#000",
338+
borderRadius: "50%",
339+
border: focusedThumb === "max" ? "2px solid #0066cc" : "2px solid #fff",
340+
boxShadow: "0 2px 4px rgba(0,0,0,0.2)",
341+
cursor: disabled ? "not-allowed" : dragMode === "max" ? "grabbing" : "grab",
342+
zIndex: 2,
343+
}}
344+
/>
345+
346+
{/* Tick marks */}
347+
{marks &&
348+
Object.entries(marks).map(([time, label]) => {
349+
const timeNum = Number(time);
350+
const position = ((timeNum - minTime) / totalDuration) * 100;
351+
return (
352+
<div
353+
key={time}
354+
style={{
355+
position: "absolute",
356+
left: `${position}%`,
357+
top: "100%",
358+
transform: "translateX(-50%)",
359+
marginTop: "8px",
360+
fontSize: "11px",
361+
color: "#666",
362+
whiteSpace: "nowrap",
363+
}}
364+
>
365+
{label}
366+
</div>
367+
);
368+
})}
369+
</div>
370+
371+
{/* Value labels */}
372+
<div style={{ marginTop: "30px", display: "flex", justifyContent: "space-between", fontSize: "12px" }}>
373+
<div>
374+
<strong>From:</strong> {formatDate(minValue)}
375+
</div>
376+
<div>
377+
<strong>To:</strong> {formatDate(maxValue)}
378+
</div>
379+
</div>
380+
381+
{/* Instructions */}
382+
{focusedThumb && (
383+
<div style={{ marginTop: "8px", fontSize: "11px", color: "#666", fontStyle: "italic" }}>
384+
Arrow keys: resize range • Shift + Arrow keys: pan range
385+
</div>
386+
)}
387+
</div>
388+
);
389+
};

0 commit comments

Comments
 (0)