@ifc-lite/viewer 1.17.6 → 1.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/.turbo/turbo-build.log +17 -14
  2. package/.turbo/turbo-typecheck.log +1 -1
  3. package/CHANGELOG.md +513 -0
  4. package/dist/assets/{basketViewActivator-86rgogji.js → basketViewActivator-Cm1QEk_R.js} +1 -1
  5. package/dist/assets/{exporters-CcPS9MK5.js → exporters-B_OBqIyD.js} +3235 -2648
  6. package/dist/assets/{geometry.worker-BFUYA08u.js → geometry.worker-xHHy-9DV.js} +1 -1
  7. package/dist/assets/{ifc-lite_bg-BINvzoCP.wasm → ifc-lite_bg-ADjKXSms.wasm} +0 -0
  8. package/dist/assets/{index-Bfms9I4A.js → index-BKq-M3Mk.js} +44124 -30920
  9. package/dist/assets/index-COnQRuqY.css +1 -0
  10. package/dist/assets/{native-bridge-DUyLCMZS.js → native-bridge-SHXiQwFW.js} +1 -1
  11. package/dist/assets/sandbox-jez21HtV.js +9627 -0
  12. package/dist/assets/{server-client-BuZK7OST.js → server-client-ncOQVNso.js} +1 -1
  13. package/dist/assets/{wasm-bridge-JsqEGDV8.js → wasm-bridge-DyfBSB8z.js} +1 -1
  14. package/dist/index.html +6 -6
  15. package/package.json +10 -10
  16. package/src/apache-arrow.d.ts +30 -0
  17. package/src/components/viewer/AddElementPanel.tsx +758 -0
  18. package/src/components/viewer/BulkPropertyEditor.tsx +7 -0
  19. package/src/components/viewer/ChatPanel.tsx +64 -2
  20. package/src/components/viewer/CommandPalette.tsx +56 -7
  21. package/src/components/viewer/EntityContextMenu.tsx +168 -4
  22. package/src/components/viewer/ExportChangesButton.tsx +25 -5
  23. package/src/components/viewer/ExportDialog.tsx +19 -1
  24. package/src/components/viewer/MainToolbar.tsx +69 -10
  25. package/src/components/viewer/PropertiesPanel.tsx +222 -22
  26. package/src/components/viewer/SearchInline.tsx +669 -0
  27. package/src/components/viewer/SearchModal.filter.builder.tsx +766 -0
  28. package/src/components/viewer/SearchModal.filter.tsx +514 -0
  29. package/src/components/viewer/SearchModal.text.tsx +388 -0
  30. package/src/components/viewer/SearchModal.tsx +235 -0
  31. package/src/components/viewer/ToolOverlays.tsx +5 -0
  32. package/src/components/viewer/ViewerLayout.tsx +24 -4
  33. package/src/components/viewer/Viewport.tsx +11 -1
  34. package/src/components/viewer/ViewportContainer.tsx +2 -0
  35. package/src/components/viewer/annotations/AnnotationDropInput.tsx +203 -0
  36. package/src/components/viewer/annotations/AnnotationLayer.tsx +287 -0
  37. package/src/components/viewer/annotations/AnnotationPin.tsx +90 -0
  38. package/src/components/viewer/annotations/AnnotationPopover.tsx +296 -0
  39. package/src/components/viewer/bcf/BCFTopicDetail.tsx +1 -1
  40. package/src/components/viewer/lists/ListPanel.tsx +14 -21
  41. package/src/components/viewer/properties/RawStepCard.tsx +332 -0
  42. package/src/components/viewer/properties/RawStepRow.tsx +261 -0
  43. package/src/components/viewer/properties/ScheduleCard.tsx +224 -0
  44. package/src/components/viewer/properties/TaskEditCard.tsx +510 -0
  45. package/src/components/viewer/properties/raw-step-format.ts +193 -0
  46. package/src/components/viewer/schedule/AnimationSettingsPopover.tsx +542 -0
  47. package/src/components/viewer/schedule/GanttDependencyArrows.tsx +89 -0
  48. package/src/components/viewer/schedule/GanttDragTooltip.tsx +48 -0
  49. package/src/components/viewer/schedule/GanttEmptyState.tsx +97 -0
  50. package/src/components/viewer/schedule/GanttPanel.tsx +295 -0
  51. package/src/components/viewer/schedule/GanttTaskBar.tsx +199 -0
  52. package/src/components/viewer/schedule/GanttTaskTree.tsx +250 -0
  53. package/src/components/viewer/schedule/GanttTimeline.tsx +305 -0
  54. package/src/components/viewer/schedule/GanttToolbar.tsx +406 -0
  55. package/src/components/viewer/schedule/GenerateAdvancedPanel.tsx +147 -0
  56. package/src/components/viewer/schedule/GenerateScheduleDialog.tsx +392 -0
  57. package/src/components/viewer/schedule/HeightStrategyPanel.tsx +120 -0
  58. package/src/components/viewer/schedule/generate-schedule.test.ts +439 -0
  59. package/src/components/viewer/schedule/generate-schedule.ts +648 -0
  60. package/src/components/viewer/schedule/schedule-animator.test.ts +452 -0
  61. package/src/components/viewer/schedule/schedule-animator.ts +488 -0
  62. package/src/components/viewer/schedule/schedule-selection.test.ts +148 -0
  63. package/src/components/viewer/schedule/schedule-selection.ts +163 -0
  64. package/src/components/viewer/schedule/schedule-utils.ts +223 -0
  65. package/src/components/viewer/schedule/useConstructionSequence.ts +156 -0
  66. package/src/components/viewer/schedule/useGanttBarDrag.test.ts +90 -0
  67. package/src/components/viewer/schedule/useGanttBarDrag.ts +305 -0
  68. package/src/components/viewer/schedule/useGanttSelection3DHighlight.ts +152 -0
  69. package/src/components/viewer/schedule/useOverlayCompositor.ts +108 -0
  70. package/src/components/viewer/selectionHandlers.ts +446 -0
  71. package/src/components/viewer/tools/AddElementOverlay.tsx +540 -0
  72. package/src/components/viewer/useDuplicateShortcut.ts +77 -0
  73. package/src/components/viewer/useMouseControls.ts +9 -1
  74. package/src/hooks/useIfcLoader.ts +22 -10
  75. package/src/hooks/useKeyboardShortcuts.ts +25 -0
  76. package/src/hooks/useSandbox.ts +1 -1
  77. package/src/hooks/useSearchIndex.ts +125 -0
  78. package/src/index.css +66 -0
  79. package/src/lib/llm/system-prompt.test.ts +14 -0
  80. package/src/lib/llm/system-prompt.ts +102 -1
  81. package/src/lib/llm/types.ts +6 -0
  82. package/src/lib/recent-files.ts +38 -4
  83. package/src/lib/scripts/templates/bim-globals.d.ts +136 -114
  84. package/src/lib/scripts/templates/construction-schedule.ts +223 -0
  85. package/src/lib/scripts/templates.ts +7 -0
  86. package/src/lib/search/common-ifc-types.ts +36 -0
  87. package/src/lib/search/filter-evaluate.test.ts +537 -0
  88. package/src/lib/search/filter-evaluate.ts +610 -0
  89. package/src/lib/search/filter-rules.test.ts +119 -0
  90. package/src/lib/search/filter-rules.ts +198 -0
  91. package/src/lib/search/filter-schema.test.ts +233 -0
  92. package/src/lib/search/filter-schema.ts +146 -0
  93. package/src/lib/search/recent-searches.test.ts +116 -0
  94. package/src/lib/search/recent-searches.ts +93 -0
  95. package/src/lib/search/result-export.test.ts +101 -0
  96. package/src/lib/search/result-export.ts +104 -0
  97. package/src/lib/search/saved-filters.test.ts +118 -0
  98. package/src/lib/search/saved-filters.ts +154 -0
  99. package/src/lib/search/tier0-scan.test.ts +196 -0
  100. package/src/lib/search/tier0-scan.ts +237 -0
  101. package/src/lib/search/tier1-index.test.ts +242 -0
  102. package/src/lib/search/tier1-index.ts +448 -0
  103. package/src/sdk/adapters/export-adapter.test.ts +434 -1
  104. package/src/sdk/adapters/export-adapter.ts +404 -1
  105. package/src/sdk/adapters/export-schedule-splice.test.ts +127 -0
  106. package/src/sdk/adapters/export-schedule-splice.ts +87 -0
  107. package/src/sdk/adapters/model-compat.ts +8 -2
  108. package/src/sdk/adapters/schedule-adapter.ts +73 -0
  109. package/src/sdk/adapters/store-adapter.ts +201 -0
  110. package/src/sdk/adapters/visibility-adapter.ts +3 -0
  111. package/src/sdk/local-backend.ts +16 -8
  112. package/src/services/desktop-export.ts +3 -1
  113. package/src/services/desktop-native-metadata.ts +41 -18
  114. package/src/services/file-dialog.ts +4 -1
  115. package/src/services/tauri-modules.d.ts +25 -0
  116. package/src/store/basketVisibleSet.ts +3 -0
  117. package/src/store/globalId.ts +4 -1
  118. package/src/store/index.ts +70 -1
  119. package/src/store/slices/addElementMeshes.ts +365 -0
  120. package/src/store/slices/addElementSlice.ts +275 -0
  121. package/src/store/slices/annotationsSlice.test.ts +133 -0
  122. package/src/store/slices/annotationsSlice.ts +251 -0
  123. package/src/store/slices/dataSlice.test.ts +23 -4
  124. package/src/store/slices/dataSlice.ts +1 -1
  125. package/src/store/slices/modelSlice.test.ts +67 -9
  126. package/src/store/slices/modelSlice.ts +39 -7
  127. package/src/store/slices/mutationSlice.ts +964 -3
  128. package/src/store/slices/overlayCompositor.test.ts +164 -0
  129. package/src/store/slices/overlaySlice.test.ts +93 -0
  130. package/src/store/slices/overlaySlice.ts +151 -0
  131. package/src/store/slices/pinboardSlice.test.ts +6 -1
  132. package/src/store/slices/playbackSlice.ts +128 -0
  133. package/src/store/slices/schedule-edit-helpers.test.ts +97 -0
  134. package/src/store/slices/schedule-edit-helpers.ts +179 -0
  135. package/src/store/slices/scheduleSlice.test.ts +694 -0
  136. package/src/store/slices/scheduleSlice.ts +1330 -0
  137. package/src/store/slices/searchSlice.test.ts +342 -0
  138. package/src/store/slices/searchSlice.ts +341 -0
  139. package/src/store/slices/selectionSlice.test.ts +46 -0
  140. package/src/store/slices/selectionSlice.ts +20 -0
  141. package/src/store.ts +14 -0
  142. package/dist/assets/index-_bfZsDCC.css +0 -1
  143. package/dist/assets/sandbox-C8575tul.js +0 -5951
@@ -0,0 +1,250 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ /**
6
+ * GanttTaskTree — left pane showing the hierarchical task list with
7
+ * expand/collapse chevrons, milestone diamond markers, and duration.
8
+ */
9
+
10
+ import { memo, useCallback, useLayoutEffect, useRef, useState } from 'react';
11
+ import { ChevronRight, ChevronDown, Diamond, CircleDot, Flag, GripVertical } from 'lucide-react';
12
+ import { cn } from '@/lib/utils';
13
+ import type { FlattenedTask } from './schedule-utils';
14
+ import { formatDurationShort } from './schedule-utils';
15
+
16
+ export const GANTT_ROW_HEIGHT = 28;
17
+ /**
18
+ * Height of the sticky column-header row. MUST match the timeline's tick
19
+ * header so scroll-sync between the two panes lands on the same row —
20
+ * otherwise the highlight band / task bars drift by one row.
21
+ */
22
+ export const GANTT_HEADER_HEIGHT = 28;
23
+
24
+ interface GanttTaskTreeProps {
25
+ rows: FlattenedTask[];
26
+ selectedGlobalIds: Set<string>;
27
+ hoveredGlobalId: string | null;
28
+ onToggleExpand: (globalId: string) => void;
29
+ onSelect: (globalId: string, multi: boolean) => void;
30
+ /** Click on empty-space below the rows clears the selection. */
31
+ onBackgroundClick?: () => void;
32
+ /** User finished a drag — move the source row to the index of the target row. */
33
+ onReorder?: (sourceGlobalId: string, targetIndex: number) => void;
34
+ onHover: (globalId: string | null) => void;
35
+ scrollTop: number;
36
+ onScroll: (scrollTop: number) => void;
37
+ }
38
+
39
+ export const GanttTaskTree = memo(function GanttTaskTree({
40
+ rows,
41
+ selectedGlobalIds,
42
+ hoveredGlobalId,
43
+ onToggleExpand,
44
+ onSelect,
45
+ onBackgroundClick,
46
+ onReorder,
47
+ onHover,
48
+ scrollTop,
49
+ onScroll,
50
+ }: GanttTaskTreeProps) {
51
+ // Drag-to-reorder state. Uses native HTML5 drag-and-drop for
52
+ // accessibility (screen-readers can speak the cursor transitions)
53
+ // and cross-browser reliability. `dropIndex` drives the horizontal
54
+ // drop-indicator line between rows.
55
+ const dragSourceRef = useRef<string | null>(null);
56
+ const [dropIndex, setDropIndex] = useState<number | null>(null);
57
+ const containerRef = useRef<HTMLDivElement | null>(null);
58
+
59
+ const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
60
+ onScroll(e.currentTarget.scrollTop);
61
+ }, [onScroll]);
62
+
63
+ // Sync externally-controlled scrollTop (e.g. timeline → task tree alignment).
64
+ useLayoutEffect(() => {
65
+ const el = containerRef.current;
66
+ if (!el) return;
67
+ if (el.scrollTop !== scrollTop) {
68
+ el.scrollTop = scrollTop;
69
+ }
70
+ }, [scrollTop]);
71
+
72
+ /**
73
+ * Click on the scroll container itself (not a row/cell/button) clears
74
+ * the Gantt selection. Uses `e.currentTarget === e.target` so clicks
75
+ * that bubble up from a row don't also fire deselect.
76
+ */
77
+ const handleContainerClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
78
+ if (e.currentTarget === e.target) onBackgroundClick?.();
79
+ }, [onBackgroundClick]);
80
+
81
+ return (
82
+ <div
83
+ ref={containerRef}
84
+ className="h-full overflow-y-auto overflow-x-hidden border-r bg-background"
85
+ onScroll={handleScroll}
86
+ onClick={handleContainerClick}
87
+ data-testid="gantt-task-tree"
88
+ >
89
+ {/*
90
+ Sticky column header — mirrors the timeline's tick header so both
91
+ scroll containers have identical content layouts and `scrollTop` sync
92
+ lands on the same row.
93
+ */}
94
+ <div
95
+ className="sticky top-0 z-10 bg-card/90 backdrop-blur-sm border-b flex items-center justify-between px-2 text-[10px] uppercase tracking-wide text-muted-foreground font-medium"
96
+ style={{ height: GANTT_HEADER_HEIGHT }}
97
+ >
98
+ <span>Task</span>
99
+ <span>Duration</span>
100
+ </div>
101
+ <div style={{ height: rows.length * GANTT_ROW_HEIGHT }}>
102
+ {/*
103
+ ARIA grid semantics: the table is a grid, each <tr> keeps its
104
+ native/`row` role (so `aria-selected` is valid), and the focusable
105
+ primary cell carries `tabIndex` + keyboard handlers. This keeps the
106
+ chevron <button> as a real button (not nested inside a `button`).
107
+ */}
108
+ <table
109
+ className="w-full text-xs border-collapse"
110
+ role="grid"
111
+ aria-multiselectable="true"
112
+ >
113
+ <tbody>
114
+ {rows.map((row, rowIdx) => {
115
+ const { task, depth, hasChildren, expanded } = row;
116
+ const isSelected = selectedGlobalIds.has(task.globalId);
117
+ const isHovered = hoveredGlobalId === task.globalId;
118
+ const label = task.name || task.identification || task.globalId.slice(0, 8);
119
+ const showDropAbove = onReorder && dropIndex === rowIdx;
120
+ return (
121
+ <tr
122
+ key={task.globalId}
123
+ role="row"
124
+ aria-selected={isSelected}
125
+ style={{ height: GANTT_ROW_HEIGHT }}
126
+ className={cn(
127
+ 'border-b border-border/40 transition-colors select-none',
128
+ isSelected && 'bg-primary/15',
129
+ !isSelected && isHovered && 'bg-muted/60',
130
+ !isSelected && !isHovered && 'hover:bg-muted/40',
131
+ showDropAbove && 'border-t-2 border-t-primary',
132
+ )}
133
+ onMouseEnter={() => onHover(task.globalId)}
134
+ onMouseLeave={() => onHover(null)}
135
+ draggable={onReorder ? true : undefined}
136
+ onDragStart={onReorder ? (e) => {
137
+ dragSourceRef.current = task.globalId;
138
+ // dataTransfer must be set on Firefox for drag to fire.
139
+ e.dataTransfer.effectAllowed = 'move';
140
+ e.dataTransfer.setData('text/plain', task.globalId);
141
+ } : undefined}
142
+ onDragOver={onReorder ? (e) => {
143
+ if (!dragSourceRef.current || dragSourceRef.current === task.globalId) return;
144
+ e.preventDefault();
145
+ e.dataTransfer.dropEffect = 'move';
146
+ setDropIndex(rowIdx);
147
+ } : undefined}
148
+ onDragLeave={onReorder ? () => {
149
+ if (dropIndex === rowIdx) setDropIndex(null);
150
+ } : undefined}
151
+ onDrop={onReorder ? (e) => {
152
+ e.preventDefault();
153
+ const src = dragSourceRef.current;
154
+ if (src && src !== task.globalId) onReorder(src, rowIdx);
155
+ dragSourceRef.current = null;
156
+ setDropIndex(null);
157
+ } : undefined}
158
+ onDragEnd={onReorder ? () => {
159
+ dragSourceRef.current = null;
160
+ setDropIndex(null);
161
+ } : undefined}
162
+ >
163
+ <td
164
+ role="gridcell"
165
+ tabIndex={0}
166
+ aria-label={label}
167
+ className={cn(
168
+ 'px-1 whitespace-nowrap overflow-hidden text-ellipsis cursor-pointer',
169
+ 'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary',
170
+ )}
171
+ style={{ paddingLeft: 4 + depth * 14 }}
172
+ onClick={(e) => onSelect(task.globalId, e.shiftKey || e.ctrlKey || e.metaKey)}
173
+ onKeyDown={(e) => {
174
+ // The cell handles its own key events; the nested
175
+ // chevron <button> retains native activation via
176
+ // `stopPropagation` inside its own handlers.
177
+ if (e.key !== 'Enter' && e.key !== ' ') return;
178
+ e.preventDefault();
179
+ onSelect(task.globalId, e.shiftKey || e.ctrlKey || e.metaKey);
180
+ }}
181
+ >
182
+ <span className="inline-flex items-center gap-1 group">
183
+ {onReorder && (
184
+ <GripVertical
185
+ className="w-3 h-3 text-muted-foreground/30 group-hover:text-muted-foreground/70 transition-colors shrink-0"
186
+ aria-hidden
187
+ />
188
+ )}
189
+ {hasChildren ? (
190
+ <button
191
+ type="button"
192
+ aria-expanded={expanded}
193
+ aria-label={`${expanded ? 'Collapse' : 'Expand'} ${label}`}
194
+ onClick={(e) => {
195
+ e.stopPropagation();
196
+ onToggleExpand(task.globalId);
197
+ }}
198
+ onKeyDown={(e) => {
199
+ // Let the browser activate the button natively;
200
+ // don't let Enter/Space bubble and also trigger
201
+ // row selection in the parent cell.
202
+ if (e.key === 'Enter' || e.key === ' ') e.stopPropagation();
203
+ }}
204
+ className="w-4 h-4 flex items-center justify-center text-muted-foreground hover:text-foreground"
205
+ >
206
+ {expanded ? (
207
+ <ChevronDown className="w-3 h-3" />
208
+ ) : (
209
+ <ChevronRight className="w-3 h-3" />
210
+ )}
211
+ </button>
212
+ ) : (
213
+ <span className="w-4 h-4 inline-block" />
214
+ )}
215
+
216
+ {task.isMilestone ? (
217
+ <Diamond className="w-3 h-3 text-amber-500 fill-amber-500" />
218
+ ) : task.taskTime?.isCritical ? (
219
+ <Flag className="w-3 h-3 text-red-500 fill-red-500" />
220
+ ) : (
221
+ <CircleDot className="w-3 h-3 text-primary/70" />
222
+ )}
223
+
224
+ <span
225
+ className={cn(
226
+ 'truncate',
227
+ task.isMilestone && 'font-semibold',
228
+ task.taskTime?.isCritical && 'text-red-600',
229
+ )}
230
+ title={task.name || task.globalId}
231
+ >
232
+ {label}
233
+ </span>
234
+ </span>
235
+ </td>
236
+ <td
237
+ role="gridcell"
238
+ className="px-2 text-muted-foreground font-mono text-right whitespace-nowrap"
239
+ >
240
+ {formatDurationShort(task.taskTime?.scheduleDuration)}
241
+ </td>
242
+ </tr>
243
+ );
244
+ })}
245
+ </tbody>
246
+ </table>
247
+ </div>
248
+ </div>
249
+ );
250
+ });
@@ -0,0 +1,305 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ /**
6
+ * GanttTimeline — right pane SVG timeline. Renders tick header, task bars,
7
+ * milestone diamonds, dependency arrows, and the playback cursor.
8
+ */
9
+
10
+ import { memo, useMemo, useCallback, useRef, useLayoutEffect, useState } from 'react';
11
+ import type { ScheduleExtraction } from '@ifc-lite/parser';
12
+ import { cn } from '@/lib/utils';
13
+ import { taskStartEpoch, taskFinishEpoch } from '@/store';
14
+ import type { GanttTimeScale, ScheduleTimeRange } from '@/store';
15
+ import type { FlattenedTask } from './schedule-utils';
16
+ import {
17
+ computeTicks,
18
+ formatTickLabel,
19
+ timeToX,
20
+ } from './schedule-utils';
21
+ import { GANTT_ROW_HEIGHT, GANTT_HEADER_HEIGHT } from './GanttTaskTree';
22
+ import { useGanttBarDrag } from './useGanttBarDrag';
23
+ import { GanttTaskBar } from './GanttTaskBar';
24
+ import { GanttDependencyArrows } from './GanttDependencyArrows';
25
+ import { GanttDragTooltip } from './GanttDragTooltip';
26
+
27
+ // Alias kept for local readability; binds to the shared constant so the
28
+ // timeline header and the task-tree header stay the same height.
29
+ const HEADER_HEIGHT = GANTT_HEADER_HEIGHT;
30
+
31
+ interface GanttTimelineProps {
32
+ rows: FlattenedTask[];
33
+ data: ScheduleExtraction;
34
+ range: ScheduleTimeRange;
35
+ scale: GanttTimeScale;
36
+ playbackTime: number;
37
+ selectedGlobalIds: Set<string>;
38
+ hoveredGlobalId: string | null;
39
+ onSelect: (globalId: string, multi: boolean) => void;
40
+ onHover: (globalId: string | null) => void;
41
+ onScrubSeek: (time: number) => void;
42
+ scrollTop: number;
43
+ onScroll: (scrollTop: number) => void;
44
+ }
45
+
46
+ export const GanttTimeline = memo(function GanttTimeline({
47
+ rows,
48
+ data,
49
+ range,
50
+ scale,
51
+ playbackTime,
52
+ selectedGlobalIds,
53
+ hoveredGlobalId,
54
+ onSelect,
55
+ onHover,
56
+ onScrubSeek,
57
+ scrollTop,
58
+ onScroll,
59
+ }: GanttTimelineProps) {
60
+ const containerRef = useRef<HTMLDivElement>(null);
61
+ const [pixelWidth, setPixelWidth] = useState(1000);
62
+
63
+ // Drag state machine for bar shift / resize. Returned `live` drives
64
+ // the floating tooltip rendered below the timeline SVG.
65
+ const barDrag = useGanttBarDrag({ range, pixelWidth, scale });
66
+
67
+ /**
68
+ * Minimum pixels per time-scale unit. When the schedule spans more units
69
+ * than the container can show at this density, we grow the SVG past the
70
+ * pane width and the container scrolls horizontally — instead of
71
+ * squeezing bars into unreadable 2-pixel stripes with overlapping tick
72
+ * labels. Tuned so "Week" scale gives ~80 px per week (readable labels,
73
+ * click-accurate bars) and larger scales get proportionally more.
74
+ */
75
+ const MIN_PX_PER_TICK: Record<GanttTimeScale, number> = {
76
+ hour: 40,
77
+ day: 60,
78
+ week: 80,
79
+ month: 100,
80
+ year: 140,
81
+ };
82
+ const MS_PER_TICK_FOR_SCALE: Record<GanttTimeScale, number> = {
83
+ hour: 3_600_000,
84
+ day: 86_400_000,
85
+ week: 7 * 86_400_000,
86
+ month: 30 * 86_400_000,
87
+ year: 365 * 86_400_000,
88
+ };
89
+
90
+ // Resize observer keeps pixelWidth synced with the right pane width, but
91
+ // grows when the schedule is too long to fit at the configured density.
92
+ useLayoutEffect(() => {
93
+ const el = containerRef.current;
94
+ if (!el) return;
95
+ const recompute = () => {
96
+ const span = Math.max(1, range.end - range.start);
97
+ const tickMs = MS_PER_TICK_FOR_SCALE[scale];
98
+ const minPerTick = MIN_PX_PER_TICK[scale];
99
+ const required = Math.ceil((span / tickMs) * minPerTick);
100
+ setPixelWidth(Math.max(200, el.clientWidth, required));
101
+ };
102
+ const ro = new ResizeObserver(recompute);
103
+ ro.observe(el);
104
+ recompute();
105
+ return () => ro.disconnect();
106
+ // eslint-disable-next-line react-hooks/exhaustive-deps
107
+ }, [range.start, range.end, scale]);
108
+
109
+ const ticks = useMemo(
110
+ () => computeTicks(range.start, range.end, scale),
111
+ [range, scale],
112
+ );
113
+
114
+ const rowsHeight = rows.length * GANTT_ROW_HEIGHT;
115
+
116
+ /** Pre-compute per-task y-row lookup for sequence arrows. */
117
+ const taskRowIndex = useMemo(() => {
118
+ const m = new Map<string, number>();
119
+ for (let i = 0; i < rows.length; i++) m.set(rows[i].task.globalId, i);
120
+ return m;
121
+ }, [rows]);
122
+
123
+ /**
124
+ * Memoize `{ start, finish }` epoch tuples per task. The rAF playback loop
125
+ * writes `playbackTime` on every frame (~60 Hz), so re-parsing ISO
126
+ * datetimes / running the duration regex inside the `rows.map` was showing
127
+ * up as a hot path for schedules with hundreds of rows. Recompute only when
128
+ * the rows themselves change (task adds / reorders / schedule reloads).
129
+ */
130
+ const taskEpochs = useMemo(() => {
131
+ const m = new Map<string, { start: number | undefined; finish: number | undefined }>();
132
+ for (const row of rows) {
133
+ m.set(row.task.globalId, {
134
+ start: taskStartEpoch(row.task),
135
+ finish: taskFinishEpoch(row.task),
136
+ });
137
+ }
138
+ return m;
139
+ }, [rows]);
140
+
141
+ const cursorX = useMemo(
142
+ () => timeToX(playbackTime, range.start, range.end, pixelWidth),
143
+ [playbackTime, range, pixelWidth],
144
+ );
145
+
146
+ const handleContainerScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
147
+ onScroll(e.currentTarget.scrollTop);
148
+ }, [onScroll]);
149
+
150
+ useLayoutEffect(() => {
151
+ const el = containerRef.current;
152
+ if (!el) return;
153
+ if (el.scrollTop !== scrollTop) {
154
+ el.scrollTop = scrollTop;
155
+ }
156
+ }, [scrollTop]);
157
+
158
+ const handleTimelineClick = useCallback((e: React.MouseEvent<SVGSVGElement>) => {
159
+ const target = e.currentTarget;
160
+ const rect = target.getBoundingClientRect();
161
+ // `rect.left` tracks the svg's visible left edge, which shifts when the
162
+ // container scrolls horizontally. Re-anchor to the SVG origin by adding
163
+ // the scroll offset — keeps click→time mapping correct once horizontal
164
+ // zoom produces overflow. No-op today because pixelWidth === clientWidth.
165
+ const scrollLeft = containerRef.current?.scrollLeft ?? 0;
166
+ const x = e.clientX - rect.left + scrollLeft;
167
+ const pct = Math.min(1, Math.max(0, x / pixelWidth));
168
+ onScrubSeek(range.start + pct * (range.end - range.start));
169
+ }, [pixelWidth, range, onScrubSeek]);
170
+
171
+ return (
172
+ <div
173
+ ref={containerRef}
174
+ className="h-full overflow-auto relative bg-gradient-to-b from-muted/10 to-transparent"
175
+ onScroll={handleContainerScroll}
176
+ data-testid="gantt-timeline"
177
+ >
178
+ {/* Sticky header */}
179
+ <div
180
+ className="sticky top-0 z-10 bg-card/90 backdrop-blur-sm border-b"
181
+ style={{ height: HEADER_HEIGHT }}
182
+ >
183
+ <svg width={pixelWidth} height={HEADER_HEIGHT} className="block">
184
+ {ticks.map((t, i) => {
185
+ const x = timeToX(t, range.start, range.end, pixelWidth);
186
+ return (
187
+ <g key={`t-${i}`}>
188
+ <line x1={x} y1={0} x2={x} y2={HEADER_HEIGHT} stroke="currentColor" strokeOpacity={0.15} />
189
+ <text
190
+ x={x + 3}
191
+ y={HEADER_HEIGHT - 8}
192
+ className="text-[10px] fill-muted-foreground font-mono"
193
+ >
194
+ {formatTickLabel(t, scale)}
195
+ </text>
196
+ </g>
197
+ );
198
+ })}
199
+ </svg>
200
+ </div>
201
+
202
+ {/* Timeline body */}
203
+ <svg
204
+ width={pixelWidth}
205
+ height={rowsHeight}
206
+ className="block cursor-crosshair"
207
+ onClick={handleTimelineClick}
208
+ >
209
+ {/* Vertical grid */}
210
+ {ticks.map((t, i) => {
211
+ const x = timeToX(t, range.start, range.end, pixelWidth);
212
+ return (
213
+ <line
214
+ key={`g-${i}`}
215
+ x1={x}
216
+ y1={0}
217
+ x2={x}
218
+ y2={rowsHeight}
219
+ stroke="currentColor"
220
+ strokeOpacity={0.08}
221
+ />
222
+ );
223
+ })}
224
+
225
+ {/* Row backgrounds + hover/selection highlight */}
226
+ {rows.map((row, i) => {
227
+ const y = i * GANTT_ROW_HEIGHT;
228
+ const isSel = selectedGlobalIds.has(row.task.globalId);
229
+ const isHov = hoveredGlobalId === row.task.globalId;
230
+ const highlight = isSel ? 'rgba(99, 102, 241, 0.14)' : isHov ? 'rgba(148, 148, 148, 0.09)' : 'transparent';
231
+ return (
232
+ <rect
233
+ key={`bg-${row.task.globalId}`}
234
+ x={0}
235
+ y={y}
236
+ width={pixelWidth}
237
+ height={GANTT_ROW_HEIGHT}
238
+ fill={highlight}
239
+ onMouseEnter={() => onHover(row.task.globalId)}
240
+ onMouseLeave={() => onHover(null)}
241
+ />
242
+ );
243
+ })}
244
+
245
+ {/* Dependency arrows (drawn before bars so bars overlap) */}
246
+ <GanttDependencyArrows
247
+ sequences={data.sequences}
248
+ taskRowIndex={taskRowIndex}
249
+ taskEpochs={taskEpochs}
250
+ rangeStart={range.start}
251
+ rangeEnd={range.end}
252
+ pixelWidth={pixelWidth}
253
+ />
254
+
255
+ {/* Task bars — use the memoized taskEpochs map so we don't re-parse
256
+ ISO datetimes on every playback tick. */}
257
+ {rows.map((row, i) => {
258
+ const { task } = row;
259
+ const epochs = taskEpochs.get(task.globalId);
260
+ const start = epochs?.start;
261
+ const finish = epochs?.finish;
262
+ if (start === undefined || finish === undefined) return null;
263
+ return (
264
+ <GanttTaskBar
265
+ key={task.globalId}
266
+ task={task}
267
+ rowIndex={i}
268
+ start={start}
269
+ finish={finish}
270
+ rangeStart={range.start}
271
+ rangeEnd={range.end}
272
+ pixelWidth={pixelWidth}
273
+ playbackTime={playbackTime}
274
+ isSelected={selectedGlobalIds.has(task.globalId)}
275
+ isDragging={barDrag.live.taskGlobalId === task.globalId}
276
+ onHover={onHover}
277
+ onSelect={onSelect}
278
+ onPointerDown={barDrag.onPointerDown}
279
+ />
280
+ );
281
+ })}
282
+
283
+ {/* Playback cursor */}
284
+ <line
285
+ x1={cursorX}
286
+ y1={0}
287
+ x2={cursorX}
288
+ y2={rowsHeight}
289
+ stroke="#0ea5e9"
290
+ strokeWidth={1.5}
291
+ strokeDasharray="4 2"
292
+ className={cn('pointer-events-none drop-shadow')}
293
+ />
294
+ </svg>
295
+
296
+ {/* Live-drag tooltip — floats next to the cursor, absolute-
297
+ positioned inside the scroll container so it scrolls with
298
+ everything else. Only visible while a drag is active. */}
299
+ {barDrag.live.taskGlobalId && (
300
+ <GanttDragTooltip live={barDrag.live} />
301
+ )}
302
+ </div>
303
+ );
304
+ });
305
+