@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.
- package/.turbo/turbo-build.log +17 -14
- package/.turbo/turbo-typecheck.log +1 -1
- package/CHANGELOG.md +513 -0
- package/dist/assets/{basketViewActivator-86rgogji.js → basketViewActivator-Cm1QEk_R.js} +1 -1
- package/dist/assets/{exporters-CcPS9MK5.js → exporters-B_OBqIyD.js} +3235 -2648
- package/dist/assets/{geometry.worker-BFUYA08u.js → geometry.worker-xHHy-9DV.js} +1 -1
- package/dist/assets/{ifc-lite_bg-BINvzoCP.wasm → ifc-lite_bg-ADjKXSms.wasm} +0 -0
- package/dist/assets/{index-Bfms9I4A.js → index-BKq-M3Mk.js} +44124 -30920
- package/dist/assets/index-COnQRuqY.css +1 -0
- package/dist/assets/{native-bridge-DUyLCMZS.js → native-bridge-SHXiQwFW.js} +1 -1
- package/dist/assets/sandbox-jez21HtV.js +9627 -0
- package/dist/assets/{server-client-BuZK7OST.js → server-client-ncOQVNso.js} +1 -1
- package/dist/assets/{wasm-bridge-JsqEGDV8.js → wasm-bridge-DyfBSB8z.js} +1 -1
- package/dist/index.html +6 -6
- package/package.json +10 -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 +69 -10
- 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 +11 -1
- package/src/components/viewer/ViewportContainer.tsx +2 -0
- 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 +540 -0
- package/src/components/viewer/useDuplicateShortcut.ts +77 -0
- package/src/components/viewer/useMouseControls.ts +9 -1
- package/src/hooks/useIfcLoader.ts +22 -10
- 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 +4 -1
- 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 +70 -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/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.ts +14 -0
- package/dist/assets/index-_bfZsDCC.css +0 -1
- package/dist/assets/sandbox-C8575tul.js +0 -5951
|
@@ -0,0 +1,97 @@
|
|
|
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
|
+
import { Calendar, CalendarClock, CalendarPlus, X } from 'lucide-react';
|
|
6
|
+
import { Button } from '@/components/ui/button';
|
|
7
|
+
|
|
8
|
+
interface GanttEmptyStateProps {
|
|
9
|
+
loading: boolean;
|
|
10
|
+
hasModel: boolean;
|
|
11
|
+
/** When true, the active model has a spatial hierarchy — enables the CTA. */
|
|
12
|
+
canGenerate?: boolean;
|
|
13
|
+
/** Human-readable extraction error (last parser failure), if any. */
|
|
14
|
+
extractionError?: string | null;
|
|
15
|
+
onClose?: () => void;
|
|
16
|
+
onGenerate?: () => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function GanttEmptyState({
|
|
20
|
+
loading,
|
|
21
|
+
hasModel,
|
|
22
|
+
canGenerate,
|
|
23
|
+
extractionError,
|
|
24
|
+
onClose,
|
|
25
|
+
onGenerate,
|
|
26
|
+
}: GanttEmptyStateProps) {
|
|
27
|
+
return (
|
|
28
|
+
<div className="relative h-full w-full flex flex-col items-center justify-center text-center p-8 gap-3 text-muted-foreground">
|
|
29
|
+
{onClose && (
|
|
30
|
+
<Button
|
|
31
|
+
size="icon-sm"
|
|
32
|
+
variant="ghost"
|
|
33
|
+
className="absolute top-2 right-2"
|
|
34
|
+
onClick={onClose}
|
|
35
|
+
aria-label="Close"
|
|
36
|
+
>
|
|
37
|
+
<X className="h-4 w-4" />
|
|
38
|
+
</Button>
|
|
39
|
+
)}
|
|
40
|
+
<div className="relative">
|
|
41
|
+
<Calendar className="h-12 w-12" strokeWidth={1} />
|
|
42
|
+
<CalendarClock className="h-6 w-6 absolute -bottom-1 -right-1 text-primary" strokeWidth={1.5} />
|
|
43
|
+
</div>
|
|
44
|
+
{!hasModel ? (
|
|
45
|
+
<>
|
|
46
|
+
<h3 className="text-sm font-semibold text-foreground">Load a model with IfcTasks</h3>
|
|
47
|
+
<p className="text-xs max-w-sm">
|
|
48
|
+
Open an IFC file containing <span className="font-mono">IfcTask</span> or
|
|
49
|
+
<span className="font-mono"> IfcWorkSchedule</span> entities to see the construction
|
|
50
|
+
schedule here.
|
|
51
|
+
</p>
|
|
52
|
+
</>
|
|
53
|
+
) : loading ? (
|
|
54
|
+
<p className="text-xs">Extracting schedule…</p>
|
|
55
|
+
) : extractionError ? (
|
|
56
|
+
<>
|
|
57
|
+
<h3 className="text-sm font-semibold text-destructive">Schedule extraction failed</h3>
|
|
58
|
+
<p className="text-xs max-w-md text-muted-foreground">
|
|
59
|
+
<span className="font-mono text-destructive">{extractionError}</span>
|
|
60
|
+
<br />
|
|
61
|
+
Re-open the model or inspect the browser console for details.
|
|
62
|
+
</p>
|
|
63
|
+
{canGenerate && onGenerate && (
|
|
64
|
+
<div className="flex flex-col items-center gap-2 pt-2">
|
|
65
|
+
<Button size="sm" variant="outline" onClick={onGenerate} className="gap-2">
|
|
66
|
+
<CalendarPlus className="h-4 w-4" />
|
|
67
|
+
Generate a schedule instead
|
|
68
|
+
</Button>
|
|
69
|
+
</div>
|
|
70
|
+
)}
|
|
71
|
+
</>
|
|
72
|
+
) : (
|
|
73
|
+
<>
|
|
74
|
+
<h3 className="text-sm font-semibold text-foreground">No schedule found</h3>
|
|
75
|
+
<p className="text-xs max-w-md">
|
|
76
|
+
This model doesn't define any <span className="font-mono">IfcTask</span>,
|
|
77
|
+
<span className="font-mono"> IfcWorkSchedule</span>, or
|
|
78
|
+
<span className="font-mono"> IfcRelSequence</span> entities. The Gantt panel powers
|
|
79
|
+
itself from those entities and the products they control via
|
|
80
|
+
<span className="font-mono"> IfcRelAssignsToProcess</span>.
|
|
81
|
+
</p>
|
|
82
|
+
{canGenerate && onGenerate && (
|
|
83
|
+
<div className="flex flex-col items-center gap-2 pt-2">
|
|
84
|
+
<Button size="sm" onClick={onGenerate} className="gap-2">
|
|
85
|
+
<CalendarPlus className="h-4 w-4" />
|
|
86
|
+
Generate schedule
|
|
87
|
+
</Button>
|
|
88
|
+
<p className="text-xs text-muted-foreground max-w-xs">
|
|
89
|
+
Build a schedule by storey, building, or element-Z height slice.
|
|
90
|
+
</p>
|
|
91
|
+
</div>
|
|
92
|
+
)}
|
|
93
|
+
</>
|
|
94
|
+
)}
|
|
95
|
+
</div>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
@@ -0,0 +1,295 @@
|
|
|
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
|
+
* GanttPanel — 4D / IfcTask Gantt chart rendered in the viewer's bottom panel.
|
|
7
|
+
*
|
|
8
|
+
* Gantt ↔ 3D: selecting task rows highlights their products in the 3D
|
|
9
|
+
* viewport via the renderer's selection-highlight channel (no isolation,
|
|
10
|
+
* no hiding — highlight only). Clearing the selection removes the
|
|
11
|
+
* highlight. The 4D animator runs completely uninterrupted either way.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
15
|
+
import { useShallow } from 'zustand/react/shallow';
|
|
16
|
+
import { extractScheduleOnDemand } from '@ifc-lite/parser';
|
|
17
|
+
import { useViewerStore } from '@/store';
|
|
18
|
+
import { resolveScheduleSourceModelId } from '@/store/slices/schedule-edit-helpers';
|
|
19
|
+
import { useIfc } from '@/hooks/useIfc';
|
|
20
|
+
import { GanttToolbar } from './GanttToolbar';
|
|
21
|
+
import { GanttTaskTree } from './GanttTaskTree';
|
|
22
|
+
import { GanttTimeline } from './GanttTimeline';
|
|
23
|
+
import { GanttEmptyState } from './GanttEmptyState';
|
|
24
|
+
import { GenerateScheduleDialog } from './GenerateScheduleDialog';
|
|
25
|
+
import { flattenTaskTree } from './schedule-utils';
|
|
26
|
+
import { canGenerateScheduleFrom, resolveActiveDataStore } from './generate-schedule';
|
|
27
|
+
import { useConstructionSequence } from './useConstructionSequence';
|
|
28
|
+
import { useGanttSelection3DHighlight } from './useGanttSelection3DHighlight';
|
|
29
|
+
import { useOverlayCompositor } from './useOverlayCompositor';
|
|
30
|
+
|
|
31
|
+
interface GanttPanelProps {
|
|
32
|
+
onClose?: () => void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const LEFT_PANE_WIDTH = 320;
|
|
36
|
+
|
|
37
|
+
export function GanttPanel({ onClose }: GanttPanelProps) {
|
|
38
|
+
const { ifcDataStore, models, loading, activeModelId } = useIfc();
|
|
39
|
+
|
|
40
|
+
// Resolve the active model once; shared by extraction + canGenerate.
|
|
41
|
+
const activeStore = useMemo(
|
|
42
|
+
() => resolveActiveDataStore(ifcDataStore, activeModelId, models),
|
|
43
|
+
[ifcDataStore, activeModelId, models],
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
const {
|
|
47
|
+
scheduleData,
|
|
48
|
+
scheduleRange,
|
|
49
|
+
activeWorkScheduleId,
|
|
50
|
+
expandedTaskGlobalIds,
|
|
51
|
+
hoveredTaskGlobalId,
|
|
52
|
+
selectedTaskGlobalIds,
|
|
53
|
+
ganttTimeScale,
|
|
54
|
+
playbackTime,
|
|
55
|
+
setScheduleData,
|
|
56
|
+
toggleTaskExpanded,
|
|
57
|
+
setHoveredTaskGlobalId,
|
|
58
|
+
setSelectedTaskGlobalIds,
|
|
59
|
+
seekSchedule,
|
|
60
|
+
} = useViewerStore(useShallow(s => ({
|
|
61
|
+
scheduleData: s.scheduleData,
|
|
62
|
+
scheduleRange: s.scheduleRange,
|
|
63
|
+
activeWorkScheduleId: s.activeWorkScheduleId,
|
|
64
|
+
expandedTaskGlobalIds: s.expandedTaskGlobalIds,
|
|
65
|
+
hoveredTaskGlobalId: s.hoveredTaskGlobalId,
|
|
66
|
+
selectedTaskGlobalIds: s.selectedTaskGlobalIds,
|
|
67
|
+
ganttTimeScale: s.ganttTimeScale,
|
|
68
|
+
playbackTime: s.playbackTime,
|
|
69
|
+
setScheduleData: s.setScheduleData,
|
|
70
|
+
toggleTaskExpanded: s.toggleTaskExpanded,
|
|
71
|
+
setHoveredTaskGlobalId: s.setHoveredTaskGlobalId,
|
|
72
|
+
setSelectedTaskGlobalIds: s.setSelectedTaskGlobalIds,
|
|
73
|
+
seekSchedule: s.seekSchedule,
|
|
74
|
+
})));
|
|
75
|
+
|
|
76
|
+
/** Last schedule-extraction error message (surfaced in the empty state). */
|
|
77
|
+
const [extractionError, setExtractionError] = useState<string | null>(null);
|
|
78
|
+
|
|
79
|
+
// Extract schedule data whenever the resolved data store changes.
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
if (!activeStore) {
|
|
82
|
+
if (scheduleData) setScheduleData(null);
|
|
83
|
+
setExtractionError(null);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
const extraction = extractScheduleOnDemand(activeStore);
|
|
88
|
+
|
|
89
|
+
// CRITICAL guard: do NOT overwrite an in-memory user-edited /
|
|
90
|
+
// generated schedule with null just because the underlying
|
|
91
|
+
// IfcDataStore reference shifted (this effect re-runs when
|
|
92
|
+
// geometry finishes streaming, spatial hierarchy rebuilds, or
|
|
93
|
+
// any other store mutation changes the activeStore identity).
|
|
94
|
+
// Earlier revisions did `setScheduleData(hasSchedule ? extraction
|
|
95
|
+
// : null)` unconditionally, which silently wiped the generated
|
|
96
|
+
// schedule moments before the user clicked Export — leading to
|
|
97
|
+
// an exported IFC with no task entities and an empty Gantt on
|
|
98
|
+
// re-import. Only replace when the extraction actually has data,
|
|
99
|
+
// or when we've previously had no schedule in memory.
|
|
100
|
+
const s = useViewerStore.getState();
|
|
101
|
+
const hasPendingSchedule = !!s.scheduleData && s.scheduleData.tasks.length > 0
|
|
102
|
+
&& (s.scheduleIsEdited || s.scheduleData.tasks.some(t => !t.expressId || t.expressId <= 0));
|
|
103
|
+
if (extraction.hasSchedule) {
|
|
104
|
+
// New extraction wins — this is the "fresh file with a real
|
|
105
|
+
// schedule" case. Any generated tail in memory is replaced;
|
|
106
|
+
// that's intentional because we can't reconcile it with a
|
|
107
|
+
// different source.
|
|
108
|
+
setScheduleData(extraction);
|
|
109
|
+
} else if (!hasPendingSchedule) {
|
|
110
|
+
// No extraction + no pending edits → fine to clear.
|
|
111
|
+
setScheduleData(null);
|
|
112
|
+
} // else: keep whatever's in memory (generated / edited).
|
|
113
|
+
setExtractionError(null);
|
|
114
|
+
} catch (err) {
|
|
115
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
116
|
+
console.warn('[GanttPanel] Failed to extract schedule', err);
|
|
117
|
+
setScheduleData(null);
|
|
118
|
+
setExtractionError(message);
|
|
119
|
+
}
|
|
120
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
121
|
+
}, [activeStore]);
|
|
122
|
+
|
|
123
|
+
// Single compositor — reads the overlay-layer registry and writes the
|
|
124
|
+
// composite into the renderer's legacy hiddenEntities / pendingColorUpdates
|
|
125
|
+
// channels. Must be mounted BEFORE any layer owner in the render tree so
|
|
126
|
+
// its first reconcile can observe their initial contributions.
|
|
127
|
+
useOverlayCompositor();
|
|
128
|
+
|
|
129
|
+
// Drive the 3D viewport's hidden-entity set from the playback clock.
|
|
130
|
+
// Registers the 'animation' overlay layer; the compositor above does
|
|
131
|
+
// the actual write.
|
|
132
|
+
useConstructionSequence();
|
|
133
|
+
|
|
134
|
+
// Highlight the current Gantt selection's products in 3D. Selection-only
|
|
135
|
+
// — no visibility changes — so it never interferes with the animator.
|
|
136
|
+
useGanttSelection3DHighlight();
|
|
137
|
+
|
|
138
|
+
// Flatten task tree honoring expand/collapse state.
|
|
139
|
+
const rows = useMemo(
|
|
140
|
+
() => flattenTaskTree(scheduleData, expandedTaskGlobalIds, activeWorkScheduleId || undefined),
|
|
141
|
+
[scheduleData, expandedTaskGlobalIds, activeWorkScheduleId],
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
// Shared scroll position between task list and timeline (so rows line up).
|
|
145
|
+
const [scrollTop, setScrollTop] = useState(0);
|
|
146
|
+
const leftRef = useRef<HTMLDivElement>(null);
|
|
147
|
+
|
|
148
|
+
// Generate-from-storeys dialog state lives in the slice so the command
|
|
149
|
+
// palette / hotkeys can open it without going through this component.
|
|
150
|
+
const generateOpen = useViewerStore(s => s.generateScheduleDialogOpen);
|
|
151
|
+
const setGenerateOpen = useViewerStore(s => s.setGenerateScheduleDialogOpen);
|
|
152
|
+
const canGenerate = useMemo(() => {
|
|
153
|
+
// Geometry-only models (no spatial hierarchy) can still generate via
|
|
154
|
+
// the Height strategy, so surface the button whenever EITHER a
|
|
155
|
+
// spatial tree OR meshes exist on the active source model.
|
|
156
|
+
const sourceModelId = resolveScheduleSourceModelId(models, activeModelId);
|
|
157
|
+
const meshes = sourceModelId ? models.get(sourceModelId)?.geometryResult?.meshes : undefined;
|
|
158
|
+
const ctx = meshes && meshes.length > 0
|
|
159
|
+
? { meshes, idOffset: models.get(sourceModelId!)?.idOffset ?? 0 }
|
|
160
|
+
: undefined;
|
|
161
|
+
return canGenerateScheduleFrom(activeStore, ctx);
|
|
162
|
+
}, [activeStore, activeModelId, models]);
|
|
163
|
+
|
|
164
|
+
const handleSelect = (globalId: string, multi: boolean) => {
|
|
165
|
+
const current = new Set(selectedTaskGlobalIds);
|
|
166
|
+
if (multi) {
|
|
167
|
+
// Ctrl/Shift-click — toggle membership of the clicked row.
|
|
168
|
+
if (current.has(globalId)) current.delete(globalId);
|
|
169
|
+
else current.add(globalId);
|
|
170
|
+
} else {
|
|
171
|
+
// Plain click — toggle if it's the ONLY selected row (click again to
|
|
172
|
+
// deselect), otherwise replace the selection. This is what users
|
|
173
|
+
// expect from file-manager-style rows: one click selects, same click
|
|
174
|
+
// again clears.
|
|
175
|
+
const isSoleSelection = current.size === 1 && current.has(globalId);
|
|
176
|
+
if (isSoleSelection) {
|
|
177
|
+
current.clear();
|
|
178
|
+
} else {
|
|
179
|
+
current.clear();
|
|
180
|
+
current.add(globalId);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
setSelectedTaskGlobalIds(Array.from(current));
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Empty-space click (task-tree background, timeline background) clears
|
|
188
|
+
* the current Gantt selection. Matches the deselect ergonomics of every
|
|
189
|
+
* other list widget and gives the user a predictable "out" that doesn't
|
|
190
|
+
* require hunting the same row again.
|
|
191
|
+
*/
|
|
192
|
+
const handleBackgroundClick = () => {
|
|
193
|
+
if (selectedTaskGlobalIds.size > 0) setSelectedTaskGlobalIds([]);
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const showEmpty = !scheduleData || !scheduleRange || rows.length === 0;
|
|
197
|
+
|
|
198
|
+
// Keyboard shortcuts for schedule undo/redo — active only while the
|
|
199
|
+
// Gantt panel (or a descendant) has focus, so the shortcut doesn't
|
|
200
|
+
// steal Ctrl+Z from the script editor / text inputs elsewhere.
|
|
201
|
+
const onPanelKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
|
202
|
+
const mod = e.ctrlKey || e.metaKey;
|
|
203
|
+
if (!mod) return;
|
|
204
|
+
// Ignore when the user is typing into an input/textarea — the
|
|
205
|
+
// browser's own undo history is usually what they want there.
|
|
206
|
+
const target = e.target as HTMLElement | null;
|
|
207
|
+
const tag = target?.tagName;
|
|
208
|
+
if (tag === 'INPUT' || tag === 'TEXTAREA' || target?.isContentEditable) return;
|
|
209
|
+
|
|
210
|
+
if (e.key === 'z' || e.key === 'Z') {
|
|
211
|
+
e.preventDefault();
|
|
212
|
+
if (e.shiftKey) useViewerStore.getState().redoScheduleEdit();
|
|
213
|
+
else useViewerStore.getState().undoScheduleEdit();
|
|
214
|
+
} else if (e.key === 'y' || e.key === 'Y') {
|
|
215
|
+
e.preventDefault();
|
|
216
|
+
useViewerStore.getState().redoScheduleEdit();
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
return (
|
|
221
|
+
<div
|
|
222
|
+
className="h-full w-full flex flex-col overflow-hidden bg-background outline-none"
|
|
223
|
+
tabIndex={-1}
|
|
224
|
+
onKeyDown={onPanelKeyDown}
|
|
225
|
+
>
|
|
226
|
+
<GanttToolbar
|
|
227
|
+
onClose={onClose}
|
|
228
|
+
onOpenGenerate={() => setGenerateOpen(true)}
|
|
229
|
+
canGenerate={canGenerate}
|
|
230
|
+
/>
|
|
231
|
+
|
|
232
|
+
<GenerateScheduleDialog open={generateOpen} onOpenChange={setGenerateOpen} />
|
|
233
|
+
|
|
234
|
+
{showEmpty ? (
|
|
235
|
+
<GanttEmptyState
|
|
236
|
+
loading={loading}
|
|
237
|
+
hasModel={!!ifcDataStore || models.size > 0}
|
|
238
|
+
canGenerate={canGenerate}
|
|
239
|
+
extractionError={extractionError}
|
|
240
|
+
onGenerate={() => setGenerateOpen(true)}
|
|
241
|
+
onClose={onClose}
|
|
242
|
+
/>
|
|
243
|
+
) : (
|
|
244
|
+
<div className="flex-1 min-h-0 flex">
|
|
245
|
+
<div
|
|
246
|
+
ref={leftRef}
|
|
247
|
+
style={{ width: LEFT_PANE_WIDTH, flex: `0 0 ${LEFT_PANE_WIDTH}px` }}
|
|
248
|
+
className="relative"
|
|
249
|
+
>
|
|
250
|
+
<GanttTaskTree
|
|
251
|
+
rows={rows}
|
|
252
|
+
selectedGlobalIds={selectedTaskGlobalIds}
|
|
253
|
+
hoveredGlobalId={hoveredTaskGlobalId}
|
|
254
|
+
onToggleExpand={toggleTaskExpanded}
|
|
255
|
+
onSelect={handleSelect}
|
|
256
|
+
onBackgroundClick={handleBackgroundClick}
|
|
257
|
+
onReorder={(sourceGid, targetIdx) => {
|
|
258
|
+
// The target index is the flattened-rows position of the
|
|
259
|
+
// drop target. Map to the underlying tasks-array position
|
|
260
|
+
// via the row's globalId. With a single-level tree this
|
|
261
|
+
// is 1:1; nested children align because `rows` is a flat
|
|
262
|
+
// pre-order traversal.
|
|
263
|
+
const targetGid = rows[targetIdx]?.task.globalId;
|
|
264
|
+
if (!targetGid) return;
|
|
265
|
+
const store = useViewerStore.getState();
|
|
266
|
+
const allTasks = store.scheduleData?.tasks ?? [];
|
|
267
|
+
const newIdx = allTasks.findIndex(t => t.globalId === targetGid);
|
|
268
|
+
if (newIdx >= 0) store.moveTask(sourceGid, newIdx);
|
|
269
|
+
}}
|
|
270
|
+
onHover={setHoveredTaskGlobalId}
|
|
271
|
+
scrollTop={scrollTop}
|
|
272
|
+
onScroll={setScrollTop}
|
|
273
|
+
/>
|
|
274
|
+
</div>
|
|
275
|
+
<div className="flex-1 min-w-0">
|
|
276
|
+
<GanttTimeline
|
|
277
|
+
rows={rows}
|
|
278
|
+
data={scheduleData}
|
|
279
|
+
range={scheduleRange}
|
|
280
|
+
scale={ganttTimeScale}
|
|
281
|
+
playbackTime={playbackTime}
|
|
282
|
+
selectedGlobalIds={selectedTaskGlobalIds}
|
|
283
|
+
hoveredGlobalId={hoveredTaskGlobalId}
|
|
284
|
+
onSelect={handleSelect}
|
|
285
|
+
onHover={setHoveredTaskGlobalId}
|
|
286
|
+
onScrubSeek={seekSchedule}
|
|
287
|
+
scrollTop={scrollTop}
|
|
288
|
+
onScroll={setScrollTop}
|
|
289
|
+
/>
|
|
290
|
+
</div>
|
|
291
|
+
</div>
|
|
292
|
+
)}
|
|
293
|
+
</div>
|
|
294
|
+
);
|
|
295
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
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
|
+
* GanttTaskBar — renders a single Gantt row's bar (or diamond for milestones)
|
|
7
|
+
* plus its drag hit-zones and completion overlay. Extracted from
|
|
8
|
+
* GanttTimeline so the orchestrator there stays focused on layout / ticks /
|
|
9
|
+
* cursor / arrows.
|
|
10
|
+
*
|
|
11
|
+
* Memoized on its own props so panning the playback cursor across a
|
|
12
|
+
* schedule with hundreds of rows doesn't re-diff every bar every frame.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { memo } from 'react';
|
|
16
|
+
import type { ScheduleTaskInfo } from '@ifc-lite/parser';
|
|
17
|
+
import { timeToX, formatDateTime } from './schedule-utils';
|
|
18
|
+
import { GANTT_ROW_HEIGHT } from './GanttTaskTree';
|
|
19
|
+
|
|
20
|
+
export interface GanttTaskBarProps {
|
|
21
|
+
task: ScheduleTaskInfo;
|
|
22
|
+
rowIndex: number;
|
|
23
|
+
/** Task start as epoch ms (already parsed by the parent's taskEpochs map). */
|
|
24
|
+
start: number;
|
|
25
|
+
/** Task finish as epoch ms. */
|
|
26
|
+
finish: number;
|
|
27
|
+
rangeStart: number;
|
|
28
|
+
rangeEnd: number;
|
|
29
|
+
pixelWidth: number;
|
|
30
|
+
playbackTime: number;
|
|
31
|
+
isSelected: boolean;
|
|
32
|
+
isDragging: boolean;
|
|
33
|
+
onHover: (globalId: string | null) => void;
|
|
34
|
+
onSelect: (globalId: string, multi: boolean) => void;
|
|
35
|
+
onPointerDown: (
|
|
36
|
+
e: React.PointerEvent<SVGElement>,
|
|
37
|
+
taskGlobalId: string,
|
|
38
|
+
mode: 'shift' | 'resize-start' | 'resize-finish',
|
|
39
|
+
) => void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const GanttTaskBar = memo(function GanttTaskBar({
|
|
43
|
+
task,
|
|
44
|
+
rowIndex,
|
|
45
|
+
start,
|
|
46
|
+
finish,
|
|
47
|
+
rangeStart,
|
|
48
|
+
rangeEnd,
|
|
49
|
+
pixelWidth,
|
|
50
|
+
playbackTime,
|
|
51
|
+
isSelected,
|
|
52
|
+
isDragging,
|
|
53
|
+
onHover,
|
|
54
|
+
onSelect,
|
|
55
|
+
onPointerDown,
|
|
56
|
+
}: GanttTaskBarProps) {
|
|
57
|
+
const y = rowIndex * GANTT_ROW_HEIGHT;
|
|
58
|
+
const barX = timeToX(start, rangeStart, rangeEnd, pixelWidth);
|
|
59
|
+
const barX2 = timeToX(finish, rangeStart, rangeEnd, pixelWidth);
|
|
60
|
+
const barWidth = Math.max(task.isMilestone ? 0 : 2, barX2 - barX);
|
|
61
|
+
|
|
62
|
+
const isActive = playbackTime >= start && playbackTime <= finish;
|
|
63
|
+
const isDone = playbackTime > finish;
|
|
64
|
+
const isPending = !isActive && !isDone;
|
|
65
|
+
const isCritical = task.taskTime?.isCritical ?? false;
|
|
66
|
+
|
|
67
|
+
if (task.isMilestone) {
|
|
68
|
+
const cx = barX;
|
|
69
|
+
const cy = y + GANTT_ROW_HEIGHT / 2;
|
|
70
|
+
const s = 6;
|
|
71
|
+
return (
|
|
72
|
+
<g
|
|
73
|
+
onMouseEnter={() => onHover(task.globalId)}
|
|
74
|
+
onMouseLeave={() => onHover(null)}
|
|
75
|
+
onClick={(e) => {
|
|
76
|
+
e.stopPropagation();
|
|
77
|
+
onSelect(task.globalId, e.shiftKey || e.ctrlKey || e.metaKey);
|
|
78
|
+
}}
|
|
79
|
+
className="cursor-pointer"
|
|
80
|
+
>
|
|
81
|
+
<polygon
|
|
82
|
+
points={`${cx},${cy - s} ${cx + s},${cy} ${cx},${cy + s} ${cx - s},${cy}`}
|
|
83
|
+
fill={isDone ? '#f59e0b' : isActive ? '#fbbf24' : '#94a3b8'}
|
|
84
|
+
stroke={isSelected ? '#111827' : '#713f12'}
|
|
85
|
+
strokeWidth={isSelected ? 1.5 : 1}
|
|
86
|
+
/>
|
|
87
|
+
<title>
|
|
88
|
+
{task.name || task.globalId}
|
|
89
|
+
{'\n'}
|
|
90
|
+
{formatDateTime(start)}
|
|
91
|
+
</title>
|
|
92
|
+
</g>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Edge hit zones for resize. Minimum 4 px wide so we stay
|
|
97
|
+
// clickable even on very short bars; capped at 25 % of the
|
|
98
|
+
// bar width so on bars < 20 px the whole bar becomes a shift
|
|
99
|
+
// zone (you can still resize via the Inspector).
|
|
100
|
+
const edgeZone = Math.min(8, Math.max(4, Math.floor(barWidth * 0.25)));
|
|
101
|
+
const showEdgeHandles = barWidth >= edgeZone * 2 + 4;
|
|
102
|
+
const barTop = y + 6;
|
|
103
|
+
const barH = GANTT_ROW_HEIGHT - 12;
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<g
|
|
107
|
+
onMouseEnter={() => onHover(task.globalId)}
|
|
108
|
+
onMouseLeave={() => onHover(null)}
|
|
109
|
+
onClick={(e) => {
|
|
110
|
+
e.stopPropagation();
|
|
111
|
+
onSelect(task.globalId, e.shiftKey || e.ctrlKey || e.metaKey);
|
|
112
|
+
}}
|
|
113
|
+
>
|
|
114
|
+
<rect
|
|
115
|
+
x={barX}
|
|
116
|
+
y={barTop}
|
|
117
|
+
width={Math.max(2, barWidth)}
|
|
118
|
+
height={barH}
|
|
119
|
+
rx={3}
|
|
120
|
+
ry={3}
|
|
121
|
+
fill={
|
|
122
|
+
isCritical
|
|
123
|
+
? isDone
|
|
124
|
+
? '#dc2626'
|
|
125
|
+
: isActive
|
|
126
|
+
? '#ef4444'
|
|
127
|
+
: '#7f1d1d'
|
|
128
|
+
: isDone
|
|
129
|
+
? '#6366f1'
|
|
130
|
+
: isActive
|
|
131
|
+
? '#818cf8'
|
|
132
|
+
: '#c7d2fe'
|
|
133
|
+
}
|
|
134
|
+
fillOpacity={isPending ? 0.55 : 0.95}
|
|
135
|
+
stroke={isDragging ? '#0ea5e9' : isSelected ? '#111827' : 'transparent'}
|
|
136
|
+
strokeWidth={isDragging ? 2 : isSelected ? 1.5 : 0}
|
|
137
|
+
/>
|
|
138
|
+
{task.taskTime?.completion !== undefined && (
|
|
139
|
+
<rect
|
|
140
|
+
x={barX}
|
|
141
|
+
y={barTop}
|
|
142
|
+
width={Math.max(0, barWidth) * Math.min(1, Math.max(0, task.taskTime.completion / 100))}
|
|
143
|
+
height={barH}
|
|
144
|
+
rx={3}
|
|
145
|
+
ry={3}
|
|
146
|
+
fill="#111827"
|
|
147
|
+
fillOpacity={0.28}
|
|
148
|
+
pointerEvents="none"
|
|
149
|
+
/>
|
|
150
|
+
)}
|
|
151
|
+
{/* Shift hit-zone: the interior of the bar. Draws no fill
|
|
152
|
+
(the visible fill rect above handles that) but owns the
|
|
153
|
+
pointer events that map to drag-body. */}
|
|
154
|
+
<rect
|
|
155
|
+
x={showEdgeHandles ? barX + edgeZone : barX}
|
|
156
|
+
y={barTop}
|
|
157
|
+
width={
|
|
158
|
+
showEdgeHandles
|
|
159
|
+
? Math.max(1, barWidth - edgeZone * 2)
|
|
160
|
+
: Math.max(2, barWidth)
|
|
161
|
+
}
|
|
162
|
+
height={barH}
|
|
163
|
+
fill="transparent"
|
|
164
|
+
className="cursor-move"
|
|
165
|
+
onPointerDown={(e) => onPointerDown(e, task.globalId, 'shift')}
|
|
166
|
+
/>
|
|
167
|
+
{/* Edge resize hit-zones. Only render when the bar is wide
|
|
168
|
+
enough for separate zones — otherwise the whole bar is
|
|
169
|
+
a shift zone and resize goes through the Inspector. */}
|
|
170
|
+
{showEdgeHandles && (
|
|
171
|
+
<>
|
|
172
|
+
<rect
|
|
173
|
+
x={barX}
|
|
174
|
+
y={barTop}
|
|
175
|
+
width={edgeZone}
|
|
176
|
+
height={barH}
|
|
177
|
+
fill="transparent"
|
|
178
|
+
className="cursor-ew-resize"
|
|
179
|
+
onPointerDown={(e) => onPointerDown(e, task.globalId, 'resize-start')}
|
|
180
|
+
/>
|
|
181
|
+
<rect
|
|
182
|
+
x={barX + barWidth - edgeZone}
|
|
183
|
+
y={barTop}
|
|
184
|
+
width={edgeZone}
|
|
185
|
+
height={barH}
|
|
186
|
+
fill="transparent"
|
|
187
|
+
className="cursor-ew-resize"
|
|
188
|
+
onPointerDown={(e) => onPointerDown(e, task.globalId, 'resize-finish')}
|
|
189
|
+
/>
|
|
190
|
+
</>
|
|
191
|
+
)}
|
|
192
|
+
<title>
|
|
193
|
+
{task.name || task.globalId}
|
|
194
|
+
{'\n'}
|
|
195
|
+
{formatDateTime(start)} → {formatDateTime(finish)}
|
|
196
|
+
</title>
|
|
197
|
+
</g>
|
|
198
|
+
);
|
|
199
|
+
});
|