@ifc-lite/viewer 1.17.6 → 1.19.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.
- package/.turbo/turbo-build.log +20 -15
- package/.turbo/turbo-typecheck.log +1 -1
- package/CHANGELOG.md +949 -0
- package/dist/assets/{basketViewActivator-86rgogji.js → basketViewActivator-RZy5c3Td.js} +1 -1
- package/dist/assets/decode-worker-Collf_X_.js +1320 -0
- package/dist/assets/{exporters-CcPS9MK5.js → exporters-BraHBeoi.js} +4194 -3025
- package/dist/assets/{geometry.worker-BFUYA08u.js → geometry.worker-DQEZB2rB.js} +1 -1
- package/dist/assets/ifc-lite_bg-4yUkDRD8.wasm +0 -0
- package/dist/assets/index-0XpVr_S5.css +1 -0
- package/dist/assets/{index-Bfms9I4A.js → index-BOi3BuUI.js} +46423 -31181
- package/dist/assets/index-XwKzDuw6.js +22 -0
- package/dist/assets/{native-bridge-DUyLCMZS.js → native-bridge-CpBeOPQa.js} +1 -1
- package/dist/assets/sandbox-Baez7n-t.js +9682 -0
- package/dist/assets/{server-client-BuZK7OST.js → server-client-BB6cMAXE.js} +1 -1
- package/dist/assets/{wasm-bridge-JsqEGDV8.js → wasm-bridge-CAYCUHbE.js} +1 -1
- package/dist/index.html +6 -6
- package/package.json +11 -10
- package/src/apache-arrow.d.ts +30 -0
- package/src/components/viewer/AddElementPanel.tsx +758 -0
- package/src/components/viewer/BulkPropertyEditor.tsx +7 -0
- package/src/components/viewer/ChatPanel.tsx +64 -2
- package/src/components/viewer/CommandPalette.tsx +56 -7
- package/src/components/viewer/EntityContextMenu.tsx +168 -4
- package/src/components/viewer/ExportChangesButton.tsx +25 -5
- package/src/components/viewer/ExportDialog.tsx +19 -1
- package/src/components/viewer/MainToolbar.tsx +73 -12
- package/src/components/viewer/PointCloudPanel.tsx +174 -0
- package/src/components/viewer/PropertiesPanel.tsx +222 -22
- package/src/components/viewer/SearchInline.tsx +669 -0
- package/src/components/viewer/SearchModal.filter.builder.tsx +766 -0
- package/src/components/viewer/SearchModal.filter.tsx +514 -0
- package/src/components/viewer/SearchModal.text.tsx +388 -0
- package/src/components/viewer/SearchModal.tsx +235 -0
- package/src/components/viewer/ToolOverlays.tsx +5 -0
- package/src/components/viewer/ViewerLayout.tsx +24 -4
- package/src/components/viewer/Viewport.tsx +29 -2
- package/src/components/viewer/ViewportContainer.tsx +45 -5
- package/src/components/viewer/ViewportOverlays.tsx +13 -2
- package/src/components/viewer/annotations/AnnotationDropInput.tsx +203 -0
- package/src/components/viewer/annotations/AnnotationLayer.tsx +287 -0
- package/src/components/viewer/annotations/AnnotationPin.tsx +90 -0
- package/src/components/viewer/annotations/AnnotationPopover.tsx +296 -0
- package/src/components/viewer/bcf/BCFTopicDetail.tsx +1 -1
- package/src/components/viewer/lists/ListPanel.tsx +14 -21
- package/src/components/viewer/properties/RawStepCard.tsx +332 -0
- package/src/components/viewer/properties/RawStepRow.tsx +261 -0
- package/src/components/viewer/properties/ScheduleCard.tsx +224 -0
- package/src/components/viewer/properties/TaskEditCard.tsx +510 -0
- package/src/components/viewer/properties/raw-step-format.ts +193 -0
- package/src/components/viewer/schedule/AnimationSettingsPopover.tsx +542 -0
- package/src/components/viewer/schedule/GanttDependencyArrows.tsx +89 -0
- package/src/components/viewer/schedule/GanttDragTooltip.tsx +48 -0
- package/src/components/viewer/schedule/GanttEmptyState.tsx +97 -0
- package/src/components/viewer/schedule/GanttPanel.tsx +295 -0
- package/src/components/viewer/schedule/GanttTaskBar.tsx +199 -0
- package/src/components/viewer/schedule/GanttTaskTree.tsx +250 -0
- package/src/components/viewer/schedule/GanttTimeline.tsx +305 -0
- package/src/components/viewer/schedule/GanttToolbar.tsx +406 -0
- package/src/components/viewer/schedule/GenerateAdvancedPanel.tsx +147 -0
- package/src/components/viewer/schedule/GenerateScheduleDialog.tsx +392 -0
- package/src/components/viewer/schedule/HeightStrategyPanel.tsx +120 -0
- package/src/components/viewer/schedule/generate-schedule.test.ts +439 -0
- package/src/components/viewer/schedule/generate-schedule.ts +648 -0
- package/src/components/viewer/schedule/schedule-animator.test.ts +452 -0
- package/src/components/viewer/schedule/schedule-animator.ts +488 -0
- package/src/components/viewer/schedule/schedule-selection.test.ts +148 -0
- package/src/components/viewer/schedule/schedule-selection.ts +163 -0
- package/src/components/viewer/schedule/schedule-utils.ts +223 -0
- package/src/components/viewer/schedule/useConstructionSequence.ts +156 -0
- package/src/components/viewer/schedule/useGanttBarDrag.test.ts +90 -0
- package/src/components/viewer/schedule/useGanttBarDrag.ts +305 -0
- package/src/components/viewer/schedule/useGanttSelection3DHighlight.ts +152 -0
- package/src/components/viewer/schedule/useOverlayCompositor.ts +108 -0
- package/src/components/viewer/selectionHandlers.ts +446 -0
- package/src/components/viewer/tools/AddElementOverlay.tsx +581 -0
- package/src/components/viewer/useDuplicateShortcut.ts +77 -0
- package/src/components/viewer/useMouseControls.ts +9 -1
- package/src/components/viewer/usePointCloudLifecycle.ts +64 -0
- package/src/components/viewer/usePointCloudSync.ts +98 -0
- package/src/hooks/ingest/pointCloudIngest.ts +391 -0
- package/src/hooks/ingest/viewerModelIngest.ts +32 -3
- package/src/hooks/useIfcFederation.ts +72 -3
- package/src/hooks/useIfcLoader.ts +89 -13
- package/src/hooks/useKeyboardShortcuts.ts +25 -0
- package/src/hooks/useSandbox.ts +1 -1
- package/src/hooks/useSearchIndex.ts +125 -0
- package/src/index.css +66 -0
- package/src/lib/llm/system-prompt.test.ts +14 -0
- package/src/lib/llm/system-prompt.ts +102 -1
- package/src/lib/llm/types.ts +6 -0
- package/src/lib/recent-files.ts +38 -4
- package/src/lib/scripts/templates/bim-globals.d.ts +136 -114
- package/src/lib/scripts/templates/construction-schedule.ts +223 -0
- package/src/lib/scripts/templates.ts +7 -0
- package/src/lib/search/common-ifc-types.ts +36 -0
- package/src/lib/search/filter-evaluate.test.ts +537 -0
- package/src/lib/search/filter-evaluate.ts +610 -0
- package/src/lib/search/filter-rules.test.ts +119 -0
- package/src/lib/search/filter-rules.ts +198 -0
- package/src/lib/search/filter-schema.test.ts +233 -0
- package/src/lib/search/filter-schema.ts +146 -0
- package/src/lib/search/recent-searches.test.ts +116 -0
- package/src/lib/search/recent-searches.ts +93 -0
- package/src/lib/search/result-export.test.ts +101 -0
- package/src/lib/search/result-export.ts +104 -0
- package/src/lib/search/saved-filters.test.ts +118 -0
- package/src/lib/search/saved-filters.ts +154 -0
- package/src/lib/search/tier0-scan.test.ts +196 -0
- package/src/lib/search/tier0-scan.ts +237 -0
- package/src/lib/search/tier1-index.test.ts +242 -0
- package/src/lib/search/tier1-index.ts +448 -0
- package/src/sdk/adapters/export-adapter.test.ts +434 -1
- package/src/sdk/adapters/export-adapter.ts +404 -1
- package/src/sdk/adapters/export-schedule-splice.test.ts +127 -0
- package/src/sdk/adapters/export-schedule-splice.ts +87 -0
- package/src/sdk/adapters/model-compat.ts +8 -2
- package/src/sdk/adapters/schedule-adapter.ts +73 -0
- package/src/sdk/adapters/store-adapter.ts +201 -0
- package/src/sdk/adapters/visibility-adapter.ts +3 -0
- package/src/sdk/local-backend.ts +16 -8
- package/src/services/desktop-export.ts +3 -1
- package/src/services/desktop-native-metadata.ts +41 -18
- package/src/services/file-dialog.ts +8 -3
- package/src/services/tauri-modules.d.ts +25 -0
- package/src/store/basketVisibleSet.ts +3 -0
- package/src/store/globalId.ts +4 -1
- package/src/store/index.ts +79 -1
- package/src/store/slices/addElementMeshes.ts +365 -0
- package/src/store/slices/addElementSlice.ts +275 -0
- package/src/store/slices/annotationsSlice.test.ts +133 -0
- package/src/store/slices/annotationsSlice.ts +251 -0
- package/src/store/slices/dataSlice.test.ts +23 -4
- package/src/store/slices/dataSlice.ts +1 -1
- package/src/store/slices/modelSlice.test.ts +67 -9
- package/src/store/slices/modelSlice.ts +39 -7
- package/src/store/slices/mutationSlice.ts +964 -3
- package/src/store/slices/overlayCompositor.test.ts +164 -0
- package/src/store/slices/overlaySlice.test.ts +93 -0
- package/src/store/slices/overlaySlice.ts +151 -0
- package/src/store/slices/pinboardSlice.test.ts +6 -1
- package/src/store/slices/playbackSlice.ts +128 -0
- package/src/store/slices/pointCloudSlice.ts +102 -0
- package/src/store/slices/schedule-edit-helpers.test.ts +97 -0
- package/src/store/slices/schedule-edit-helpers.ts +179 -0
- package/src/store/slices/scheduleSlice.test.ts +694 -0
- package/src/store/slices/scheduleSlice.ts +1330 -0
- package/src/store/slices/searchSlice.test.ts +342 -0
- package/src/store/slices/searchSlice.ts +341 -0
- package/src/store/slices/selectionSlice.test.ts +46 -0
- package/src/store/slices/selectionSlice.ts +20 -0
- package/src/store/types.ts +7 -0
- package/src/store.ts +14 -0
- package/vite.config.ts +1 -0
- package/dist/assets/ifc-lite_bg-BINvzoCP.wasm +0 -0
- package/dist/assets/index-_bfZsDCC.css +0 -1
- 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
|
+
|