type DragOptions = { /** * Maps a viewport X coordinate to a bar index, or null if outside the row. * The orchestrator owns the row element bound from `TimelineBars`. */ indexFromClientX(clientX: number): number | null; /** Returns the bucket label at index, or undefined if out of range. */ labelAt(index: number): string | undefined; /** * True when a label represents an aggregated year bar — controls click-to-zoom * semantics (clicking a year zooms into its 12 months; clicking a month doesn't). */ isYearLabel(label: string): boolean; /** * Emits a selection. `includeZoom` is true for a range drag or a click on a * year bar; false for a click on a month bar. */ emit(startIndex: number, endIndex: number, includeZoom: boolean): void; }; /** * Drag state machine for the timeline density widget. Exposes: * - `isDragging`, `lowIndex`, `highIndex` reactive read-onlies for the UI * - `pointerDown` / `pointerEnter` / `click` handlers for bar buttons * - `cleanup()` to drop document-level listeners on component unmount * * Document-level listeners (pointermove, pointerup, pointercancel) keep drag * tracking alive while the cursor leaves the original bar or the timeline row. * `cleanup()` must be called from a Svelte `$effect` teardown so a route change * mid-drag does not leak listeners. */ export function createTimelineDrag(opts: DragOptions) { let startIndex = $state(null); let endIndex = $state(null); // Set after a pointerup-driven emit so the synthesized click that follows is // suppressed (we'd otherwise emit twice). Keyboard Enter/Space fires click // without preceding pointerdown, so click stays the keyboard surface. let suppressClick = $state(false); function handleDocumentMove(e: PointerEvent) { const idx = opts.indexFromClientX(e.clientX); if (idx !== null) endIndex = idx; } function handleDocumentUp() { cleanup(); finalizeDrag(); } function handleDocumentCancel() { cleanup(); startIndex = null; endIndex = null; } function cleanup() { document.removeEventListener('pointermove', handleDocumentMove); document.removeEventListener('pointerup', handleDocumentUp); document.removeEventListener('pointercancel', handleDocumentCancel); } function finalizeDrag() { if (startIndex === null || endIndex === null) return; const start = startIndex; const end = endIndex; startIndex = null; endIndex = null; const isRangeDrag = start !== end; const startLabel = opts.labelAt(start); // Range drag → atomic zoom + filter. Same-bar release on a year bar → // zoom into that year's months. Same-bar release on a month bar → // filter only. const includeZoom = isRangeDrag || (!!startLabel && opts.isYearLabel(startLabel)); opts.emit(start, end, includeZoom); suppressClick = true; queueMicrotask(() => { suppressClick = false; }); } return { get isDragging() { return startIndex !== null; }, get lowIndex() { if (startIndex === null) return null; if (endIndex === null) return startIndex; return Math.min(startIndex, endIndex); }, get highIndex() { if (startIndex === null) return null; if (endIndex === null) return startIndex; return Math.max(startIndex, endIndex); }, pointerDown(event: PointerEvent, index: number) { if (event.button !== 0) return; startIndex = index; endIndex = index; document.addEventListener('pointermove', handleDocumentMove); document.addEventListener('pointerup', handleDocumentUp); document.addEventListener('pointercancel', handleDocumentCancel); }, pointerEnter(index: number) { if (startIndex === null) return; endIndex = index; }, click(index: number) { if (suppressClick) { suppressClick = false; return; } const label = opts.labelAt(index); const includeZoom = !!label && opts.isYearLabel(label); opts.emit(index, index, includeZoom); }, cleanup }; }