@ifc-lite/viewer 1.17.4 → 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 (196) hide show
  1. package/.turbo/turbo-build.log +20 -17
  2. package/.turbo/turbo-typecheck.log +1 -1
  3. package/CHANGELOG.md +630 -0
  4. package/DESKTOP_CONTRACT_VERSION +1 -1
  5. package/dist/assets/{basketViewActivator-BmnNtVfZ.js → basketViewActivator-Cm1QEk_R.js} +1 -1
  6. package/dist/assets/drawing-2d-DoxKMqbO.js +257 -0
  7. package/dist/assets/{exporters-ChAtBmlj.js → exporters-B_OBqIyD.js} +3479 -2845
  8. package/dist/assets/{geometry.worker-BQ0rzNo-.js → geometry.worker-xHHy-9DV.js} +1 -1
  9. package/dist/assets/ids-DQ5jY0E8.js +1 -0
  10. package/dist/assets/ifc-lite_bg-ADjKXSms.wasm +0 -0
  11. package/dist/assets/{index-Co8E2-FE.js → index-BKq-M3Mk.js} +55873 -40593
  12. package/dist/assets/index-COnQRuqY.css +1 -0
  13. package/dist/assets/{native-bridge-BRvbckFQ.js → native-bridge-SHXiQwFW.js} +104 -104
  14. package/dist/assets/sandbox-jez21HtV.js +9627 -0
  15. package/dist/assets/{server-client-BV8zHZ7Y.js → server-client-ncOQVNso.js} +1 -1
  16. package/dist/assets/{wasm-bridge-g01g7T9b.js → wasm-bridge-DyfBSB8z.js} +1 -1
  17. package/dist/index.html +8 -7
  18. package/index.html +1 -0
  19. package/package.json +13 -13
  20. package/src/App.tsx +16 -2
  21. package/src/apache-arrow.d.ts +30 -0
  22. package/src/components/viewer/AddElementPanel.tsx +758 -0
  23. package/src/components/viewer/BulkPropertyEditor.tsx +7 -0
  24. package/src/components/viewer/CesiumOverlay.tsx +62 -19
  25. package/src/components/viewer/ChatPanel.tsx +259 -93
  26. package/src/components/viewer/CommandPalette.tsx +56 -7
  27. package/src/components/viewer/EntityContextMenu.tsx +168 -4
  28. package/src/components/viewer/ExportChangesButton.tsx +25 -5
  29. package/src/components/viewer/ExportDialog.tsx +19 -1
  30. package/src/components/viewer/MainToolbar.tsx +73 -13
  31. package/src/components/viewer/PropertiesPanel.tsx +237 -23
  32. package/src/components/viewer/SearchInline.tsx +669 -0
  33. package/src/components/viewer/SearchModal.filter.builder.tsx +766 -0
  34. package/src/components/viewer/SearchModal.filter.tsx +514 -0
  35. package/src/components/viewer/SearchModal.text.tsx +388 -0
  36. package/src/components/viewer/SearchModal.tsx +235 -0
  37. package/src/components/viewer/SettingsPage.tsx +252 -101
  38. package/src/components/viewer/ThemeSwitch.tsx +63 -7
  39. package/src/components/viewer/ToolOverlays.tsx +5 -0
  40. package/src/components/viewer/ViewerLayout.tsx +25 -4
  41. package/src/components/viewer/Viewport.tsx +25 -3
  42. package/src/components/viewer/ViewportContainer.tsx +51 -64
  43. package/src/components/viewer/ViewportOverlays.tsx +5 -2
  44. package/src/components/viewer/annotations/AnnotationDropInput.tsx +203 -0
  45. package/src/components/viewer/annotations/AnnotationLayer.tsx +287 -0
  46. package/src/components/viewer/annotations/AnnotationPin.tsx +90 -0
  47. package/src/components/viewer/annotations/AnnotationPopover.tsx +296 -0
  48. package/src/components/viewer/bcf/BCFTopicDetail.tsx +4 -4
  49. package/src/components/viewer/chat/ModelSelector.tsx +90 -54
  50. package/src/components/viewer/lists/ListPanel.tsx +14 -21
  51. package/src/components/viewer/properties/GeoreferencingPanel.tsx +113 -51
  52. package/src/components/viewer/properties/LocationMap.tsx +9 -7
  53. package/src/components/viewer/properties/ModelMetadataPanel.tsx +1 -1
  54. package/src/components/viewer/properties/RawStepCard.tsx +332 -0
  55. package/src/components/viewer/properties/RawStepRow.tsx +261 -0
  56. package/src/components/viewer/properties/ScheduleCard.tsx +224 -0
  57. package/src/components/viewer/properties/TaskEditCard.tsx +510 -0
  58. package/src/components/viewer/properties/raw-step-format.ts +193 -0
  59. package/src/components/viewer/schedule/AnimationSettingsPopover.tsx +542 -0
  60. package/src/components/viewer/schedule/GanttDependencyArrows.tsx +89 -0
  61. package/src/components/viewer/schedule/GanttDragTooltip.tsx +48 -0
  62. package/src/components/viewer/schedule/GanttEmptyState.tsx +97 -0
  63. package/src/components/viewer/schedule/GanttPanel.tsx +295 -0
  64. package/src/components/viewer/schedule/GanttTaskBar.tsx +199 -0
  65. package/src/components/viewer/schedule/GanttTaskTree.tsx +250 -0
  66. package/src/components/viewer/schedule/GanttTimeline.tsx +305 -0
  67. package/src/components/viewer/schedule/GanttToolbar.tsx +406 -0
  68. package/src/components/viewer/schedule/GenerateAdvancedPanel.tsx +147 -0
  69. package/src/components/viewer/schedule/GenerateScheduleDialog.tsx +392 -0
  70. package/src/components/viewer/schedule/HeightStrategyPanel.tsx +120 -0
  71. package/src/components/viewer/schedule/generate-schedule.test.ts +439 -0
  72. package/src/components/viewer/schedule/generate-schedule.ts +648 -0
  73. package/src/components/viewer/schedule/schedule-animator.test.ts +452 -0
  74. package/src/components/viewer/schedule/schedule-animator.ts +488 -0
  75. package/src/components/viewer/schedule/schedule-selection.test.ts +148 -0
  76. package/src/components/viewer/schedule/schedule-selection.ts +163 -0
  77. package/src/components/viewer/schedule/schedule-utils.ts +223 -0
  78. package/src/components/viewer/schedule/useConstructionSequence.ts +156 -0
  79. package/src/components/viewer/schedule/useGanttBarDrag.test.ts +90 -0
  80. package/src/components/viewer/schedule/useGanttBarDrag.ts +305 -0
  81. package/src/components/viewer/schedule/useGanttSelection3DHighlight.ts +152 -0
  82. package/src/components/viewer/schedule/useOverlayCompositor.ts +108 -0
  83. package/src/components/viewer/selectionHandlers.ts +446 -0
  84. package/src/components/viewer/tools/AddElementOverlay.tsx +540 -0
  85. package/src/components/viewer/tools/SectionCapControls.tsx +237 -0
  86. package/src/components/viewer/tools/SectionPanel.tsx +39 -18
  87. package/src/components/viewer/useAnimationLoop.ts +9 -1
  88. package/src/components/viewer/useDuplicateShortcut.ts +77 -0
  89. package/src/components/viewer/useMouseControls.ts +9 -1
  90. package/src/components/viewer/useRenderUpdates.ts +1 -1
  91. package/src/hooks/ids/idsDataAccessor.ts +60 -24
  92. package/src/hooks/ingest/viewerModelIngest.ts +7 -2
  93. package/src/hooks/useIfcFederation.ts +326 -71
  94. package/src/hooks/useIfcLoader.ts +23 -10
  95. package/src/hooks/useKeyboardShortcuts.ts +25 -0
  96. package/src/hooks/useSandbox.ts +1 -1
  97. package/src/hooks/useSearchIndex.ts +125 -0
  98. package/src/hooks/useViewControls.ts +13 -5
  99. package/src/index.css +550 -10
  100. package/src/lib/desktop-entitlement.ts +2 -4
  101. package/src/lib/geo/cesium-bridge.ts +15 -7
  102. package/src/lib/geo/effective-georef.test.ts +73 -0
  103. package/src/lib/geo/effective-georef.ts +111 -0
  104. package/src/lib/geo/reproject.ts +105 -19
  105. package/src/lib/llm/byok-guard.test.ts +77 -0
  106. package/src/lib/llm/byok-guard.ts +39 -0
  107. package/src/lib/llm/free-models.test.ts +0 -6
  108. package/src/lib/llm/models.ts +104 -42
  109. package/src/lib/llm/stream-client.ts +74 -110
  110. package/src/lib/llm/stream-direct.test.ts +130 -0
  111. package/src/lib/llm/stream-direct.ts +316 -0
  112. package/src/lib/llm/system-prompt.test.ts +14 -0
  113. package/src/lib/llm/system-prompt.ts +102 -1
  114. package/src/lib/llm/types.ts +20 -2
  115. package/src/lib/recent-files.ts +38 -4
  116. package/src/lib/scripts/templates/bim-globals.d.ts +136 -114
  117. package/src/lib/scripts/templates/construction-schedule.ts +223 -0
  118. package/src/lib/scripts/templates.ts +7 -0
  119. package/src/lib/search/common-ifc-types.ts +36 -0
  120. package/src/lib/search/filter-evaluate.test.ts +537 -0
  121. package/src/lib/search/filter-evaluate.ts +610 -0
  122. package/src/lib/search/filter-rules.test.ts +119 -0
  123. package/src/lib/search/filter-rules.ts +198 -0
  124. package/src/lib/search/filter-schema.test.ts +233 -0
  125. package/src/lib/search/filter-schema.ts +146 -0
  126. package/src/lib/search/recent-searches.test.ts +116 -0
  127. package/src/lib/search/recent-searches.ts +93 -0
  128. package/src/lib/search/result-export.test.ts +101 -0
  129. package/src/lib/search/result-export.ts +104 -0
  130. package/src/lib/search/saved-filters.test.ts +118 -0
  131. package/src/lib/search/saved-filters.ts +154 -0
  132. package/src/lib/search/tier0-scan.test.ts +196 -0
  133. package/src/lib/search/tier0-scan.ts +237 -0
  134. package/src/lib/search/tier1-index.test.ts +242 -0
  135. package/src/lib/search/tier1-index.ts +448 -0
  136. package/src/main.tsx +1 -10
  137. package/src/sdk/adapters/export-adapter.test.ts +434 -1
  138. package/src/sdk/adapters/export-adapter.ts +404 -1
  139. package/src/sdk/adapters/export-schedule-splice.test.ts +127 -0
  140. package/src/sdk/adapters/export-schedule-splice.ts +87 -0
  141. package/src/sdk/adapters/model-compat.ts +8 -2
  142. package/src/sdk/adapters/schedule-adapter.ts +73 -0
  143. package/src/sdk/adapters/store-adapter.ts +201 -0
  144. package/src/sdk/adapters/visibility-adapter.ts +3 -0
  145. package/src/sdk/local-backend.ts +16 -8
  146. package/src/services/api-keys.ts +73 -0
  147. package/src/services/desktop-export.ts +3 -1
  148. package/src/services/desktop-native-metadata.ts +41 -18
  149. package/src/services/file-dialog.ts +4 -1
  150. package/src/services/tauri-modules.d.ts +25 -0
  151. package/src/store/basketVisibleSet.ts +3 -0
  152. package/src/store/constants.ts +20 -2
  153. package/src/store/globalId.ts +4 -1
  154. package/src/store/index.ts +82 -6
  155. package/src/store/slices/addElementMeshes.ts +365 -0
  156. package/src/store/slices/addElementSlice.ts +275 -0
  157. package/src/store/slices/annotationsSlice.test.ts +133 -0
  158. package/src/store/slices/annotationsSlice.ts +251 -0
  159. package/src/store/slices/cesiumSlice.ts +5 -0
  160. package/src/store/slices/chatSlice.test.ts +6 -76
  161. package/src/store/slices/chatSlice.ts +17 -58
  162. package/src/store/slices/dataSlice.test.ts +23 -4
  163. package/src/store/slices/dataSlice.ts +1 -1
  164. package/src/store/slices/modelSlice.test.ts +67 -9
  165. package/src/store/slices/modelSlice.ts +39 -7
  166. package/src/store/slices/mutationSlice.ts +964 -3
  167. package/src/store/slices/overlayCompositor.test.ts +164 -0
  168. package/src/store/slices/overlaySlice.test.ts +93 -0
  169. package/src/store/slices/overlaySlice.ts +151 -0
  170. package/src/store/slices/pinboardSlice.test.ts +6 -1
  171. package/src/store/slices/playbackSlice.ts +128 -0
  172. package/src/store/slices/schedule-edit-helpers.test.ts +97 -0
  173. package/src/store/slices/schedule-edit-helpers.ts +179 -0
  174. package/src/store/slices/scheduleSlice.test.ts +694 -0
  175. package/src/store/slices/scheduleSlice.ts +1330 -0
  176. package/src/store/slices/searchSlice.test.ts +342 -0
  177. package/src/store/slices/searchSlice.ts +341 -0
  178. package/src/store/slices/sectionSlice.test.ts +87 -7
  179. package/src/store/slices/sectionSlice.ts +151 -5
  180. package/src/store/slices/selectionSlice.test.ts +46 -0
  181. package/src/store/slices/selectionSlice.ts +20 -0
  182. package/src/store/slices/uiSlice.ts +28 -5
  183. package/src/store/types.ts +26 -0
  184. package/src/store.ts +14 -0
  185. package/src/utils/nativeSpatialDataStore.ts +4 -1
  186. package/src/utils/viewportUtils.ts +7 -2
  187. package/src/vite-env.d.ts +0 -4
  188. package/dist/assets/drawing-2d-gWfpdfYe.js +0 -257
  189. package/dist/assets/ids-B4jTqB1O.js +0 -1
  190. package/dist/assets/ifc-lite_bg-BX4E7TX8.wasm +0 -0
  191. package/dist/assets/index-DckuDqlv.css +0 -1
  192. package/dist/assets/sandbox-DZiNLNMk.js +0 -5933
  193. package/src/components/viewer/UpgradePage.tsx +0 -71
  194. package/src/lib/desktop/ClerkDesktopEntitlementSync.tsx +0 -175
  195. package/src/lib/llm/ClerkChatSync.tsx +0 -74
  196. package/src/lib/llm/clerk-auth.ts +0 -62
@@ -0,0 +1,542 @@
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
+ * AnimationSettingsPopover — compact dropdown from the Gantt toolbar that
7
+ * controls the 4D animation behaviour.
8
+ *
9
+ * Two conceptual layers:
10
+ * • **Timing** — schedule-driven visibility: hide upcoming products,
11
+ * remove demolished ones. Always available.
12
+ * • **Colour overlays** (phased only, opt-in) — task-type palette with
13
+ * a fully editable colour picker on each swatch.
14
+ *
15
+ * Layout rationale: in phased mode the palette editor is front and centre
16
+ * (right after the style tiles) so users can actually find it — previous
17
+ * iterations buried it at the bottom of the popover and the common
18
+ * complaint was "I don't see how I can change colours". Each swatch is a
19
+ * 20 px clickable preview bound to a native `<input type="color">`.
20
+ */
21
+
22
+ import { useCallback } from 'react';
23
+ import { Sparkles, RotateCcw, Paintbrush, Palette, Eye } from 'lucide-react';
24
+ import { Button } from '@/components/ui/button';
25
+ import {
26
+ DropdownMenu,
27
+ DropdownMenuContent,
28
+ DropdownMenuTrigger,
29
+ DropdownMenuSeparator,
30
+ } from '@/components/ui/dropdown-menu';
31
+ import { Switch } from '@/components/ui/switch';
32
+ import { Label } from '@/components/ui/label';
33
+ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
34
+ import { cn } from '@/lib/utils';
35
+ import { useViewerStore } from '@/store';
36
+ import {
37
+ DEFAULT_PALETTE,
38
+ type AnimationSettings,
39
+ type TaskPaletteKey,
40
+ type RGBA,
41
+ } from './schedule-animator';
42
+
43
+ interface AnimationSettingsPopoverProps {
44
+ animationEnabled: boolean;
45
+ onToggleAnimation: () => void;
46
+ }
47
+
48
+ /** Palette entries surfaced in the customizer — every IfcTaskTypeEnum
49
+ * value the animator uses. Ordered by expected real-world frequency. */
50
+ const PALETTE_LEGEND: { key: TaskPaletteKey; label: string }[] = [
51
+ { key: 'CONSTRUCTION', label: 'Construction' },
52
+ { key: 'INSTALLATION', label: 'Installation' },
53
+ { key: 'RENOVATION', label: 'Renovation' },
54
+ { key: 'MAINTENANCE', label: 'Maintenance' },
55
+ { key: 'LOGISTIC', label: 'Logistic' },
56
+ { key: 'OPERATION', label: 'Operation' },
57
+ { key: 'MOVE', label: 'Move' },
58
+ { key: 'ATTENDANCE', label: 'Attendance' },
59
+ { key: 'DEMOLITION', label: 'Demolition' },
60
+ { key: 'DISMANTLE', label: 'Dismantle' },
61
+ { key: 'REMOVAL', label: 'Removal' },
62
+ { key: 'DISPOSAL', label: 'Disposal' },
63
+ { key: 'USERDEFINED', label: 'User-defined' },
64
+ { key: 'NOTDEFINED', label: 'Not defined' },
65
+ ];
66
+
67
+ function rgbaToCss(rgba: RGBA): string {
68
+ const r = Math.round(rgba[0] * 255);
69
+ const g = Math.round(rgba[1] * 255);
70
+ const b = Math.round(rgba[2] * 255);
71
+ return `rgba(${r},${g},${b},${rgba[3]})`;
72
+ }
73
+
74
+ function rgbaToHex(rgba: RGBA): string {
75
+ const toHex = (v: number) => Math.round(Math.min(1, Math.max(0, v)) * 255).toString(16).padStart(2, '0');
76
+ return `#${toHex(rgba[0])}${toHex(rgba[1])}${toHex(rgba[2])}`;
77
+ }
78
+
79
+ /** Parse `#RRGGBB` into [r,g,b] floats 0-1 (alpha left to caller). */
80
+ function hexToRgb(hex: string): [number, number, number] | null {
81
+ const m = hex.match(/^#?([0-9a-f]{6})$/i);
82
+ if (!m) return null;
83
+ const v = parseInt(m[1], 16);
84
+ return [((v >> 16) & 0xff) / 255, ((v >> 8) & 0xff) / 255, (v & 0xff) / 255];
85
+ }
86
+
87
+ /** Colour-equal within 1/255 — used to spot user-customised entries. */
88
+ function rgbEquals(a: RGBA, b: RGBA): boolean {
89
+ const eps = 1 / 512;
90
+ return Math.abs(a[0] - b[0]) < eps && Math.abs(a[1] - b[1]) < eps && Math.abs(a[2] - b[2]) < eps;
91
+ }
92
+
93
+ export function AnimationSettingsPopover({
94
+ animationEnabled,
95
+ onToggleAnimation,
96
+ }: AnimationSettingsPopoverProps) {
97
+ const settings = useViewerStore(s => s.animationSettings);
98
+ const patch = useViewerStore(s => s.patchAnimationSettings);
99
+ const reset = useViewerStore(s => s.resetAnimationSettings);
100
+
101
+ // Minimal / Phased tiles are presets over the underlying colour
102
+ // flags, not a separate mode flag. "Phased" turns on task-type
103
+ // coloring at a sensible default intensity; "Minimal" turns every
104
+ // colour overlay off. Users can still toggle individual flags
105
+ // inside the Phased panel after picking either preset.
106
+ const applyMinimalPreset = useCallback(() => patch({
107
+ colorizeByTaskType: false,
108
+ showPreparationGhost: false,
109
+ showCompletedTint: false,
110
+ paletteIntensity: 0,
111
+ }), [patch]);
112
+ const applyPhasedPreset = useCallback(() => patch({
113
+ colorizeByTaskType: true,
114
+ paletteIntensity: 0.6,
115
+ // Leave ghost / completed off by default — power-user toggles inside.
116
+ }), [patch]);
117
+
118
+ const setPaletteColor = useCallback((key: TaskPaletteKey, hex: string) => {
119
+ const rgb = hexToRgb(hex);
120
+ if (!rgb) return;
121
+ const prev = settings.palette[key] ?? DEFAULT_PALETTE[key];
122
+ // Preserve the existing alpha — the native picker is opaque so we only
123
+ // update RGB. Keeps the PREPARATION ghost at its baked low alpha even
124
+ // when users edit its hue.
125
+ const next: RGBA = [rgb[0], rgb[1], rgb[2], prev[3]];
126
+ patch({ palette: { ...settings.palette, [key]: next } });
127
+ }, [patch, settings.palette]);
128
+
129
+ const resetPaletteEntry = useCallback((key: TaskPaletteKey) => {
130
+ patch({ palette: { ...settings.palette, [key]: DEFAULT_PALETTE[key] } });
131
+ }, [patch, settings.palette]);
132
+
133
+ // Derive the tile state from the underlying flags — "phased" means
134
+ // at least one colour overlay is on. No separate `style` bit.
135
+ const phased = settings.colorizeByTaskType
136
+ || settings.showPreparationGhost
137
+ || settings.showCompletedTint;
138
+ const palette = settings.palette;
139
+ const prepColor = palette.PREPARATION ?? DEFAULT_PALETTE.PREPARATION;
140
+ const prepIsDefault = rgbEquals(prepColor, DEFAULT_PALETTE.PREPARATION);
141
+ const completedColor = palette.COMPLETED ?? DEFAULT_PALETTE.COMPLETED;
142
+ const completedIsDefault = rgbEquals(completedColor, DEFAULT_PALETTE.COMPLETED);
143
+
144
+ return (
145
+ <DropdownMenu>
146
+ <Tooltip>
147
+ <TooltipTrigger asChild>
148
+ <DropdownMenuTrigger asChild>
149
+ <Button
150
+ size="icon-sm"
151
+ variant={animationEnabled ? 'default' : 'ghost'}
152
+ aria-label="Animation settings"
153
+ >
154
+ <Sparkles className="h-4 w-4" />
155
+ </Button>
156
+ </DropdownMenuTrigger>
157
+ </TooltipTrigger>
158
+ <TooltipContent>4D animation settings</TooltipContent>
159
+ </Tooltip>
160
+ <DropdownMenuContent align="end" className="w-[360px] p-3 max-h-[min(80vh,700px)] overflow-y-auto">
161
+ {/* ── Master toggle ────────────────────────────────────────── */}
162
+ <div className="flex items-center justify-between gap-3 pb-2">
163
+ <div className="grid gap-0.5">
164
+ <span className="text-sm font-medium">4D animation</span>
165
+ <span className="text-[11px] text-muted-foreground">
166
+ Drives viewport from the Gantt clock.
167
+ </span>
168
+ </div>
169
+ <Switch checked={animationEnabled} onCheckedChange={onToggleAnimation} />
170
+ </div>
171
+
172
+ <DropdownMenuSeparator />
173
+
174
+ {/* ── Style tiles — two ways to visualize the schedule ─────── */}
175
+ <div className="grid gap-1.5 py-2">
176
+ <Label className="text-[11px] uppercase tracking-wide text-muted-foreground">Style</Label>
177
+ <div className="grid grid-cols-2 gap-2">
178
+ <StyleTile
179
+ icon={<Eye className="h-3.5 w-3.5" />}
180
+ label="Minimal"
181
+ description="Visibility only — no colour"
182
+ active={!phased}
183
+ onSelect={() => applyMinimalPreset()}
184
+ />
185
+ <StyleTile
186
+ icon={<Palette className="h-3.5 w-3.5" />}
187
+ label="Phased"
188
+ description="Task-type colour overlays"
189
+ active={phased}
190
+ onSelect={() => applyPhasedPreset()}
191
+ />
192
+ </div>
193
+ </div>
194
+
195
+ {/* ── Phased: palette editor FIRST so it's impossible to miss ── */}
196
+ {phased && (
197
+ <>
198
+ <DropdownMenuSeparator />
199
+ <div className="grid gap-1.5 py-2">
200
+ <div className="flex items-center gap-1.5">
201
+ <Paintbrush className="h-3 w-3 text-primary" />
202
+ <Label className="text-[11px] uppercase tracking-wide text-muted-foreground">
203
+ Task-type palette
204
+ </Label>
205
+ </div>
206
+ <span className="text-[11px] text-muted-foreground">
207
+ Click any swatch to change its colour. Hover a modified entry
208
+ to reset just that one.
209
+ </span>
210
+ <div className="grid grid-cols-1 gap-0.5 pt-1">
211
+ {PALETTE_LEGEND.map(entry => {
212
+ const current = palette[entry.key] ?? DEFAULT_PALETTE[entry.key];
213
+ return (
214
+ <PaletteRow
215
+ key={entry.key}
216
+ label={entry.label}
217
+ colorKey={entry.key}
218
+ rgba={current}
219
+ onChange={setPaletteColor}
220
+ onResetEntry={resetPaletteEntry}
221
+ isDefault={rgbEquals(current, DEFAULT_PALETTE[entry.key])}
222
+ />
223
+ );
224
+ })}
225
+ </div>
226
+ </div>
227
+ </>
228
+ )}
229
+
230
+ {/* ── Minimal: clear CTA explaining what phased adds ───────── */}
231
+ {!phased && (
232
+ <>
233
+ <DropdownMenuSeparator />
234
+ <button
235
+ type="button"
236
+ onClick={() => applyPhasedPreset()}
237
+ className="w-full rounded-md border border-primary/40 bg-primary/5 hover:bg-primary/10 transition-colors px-3 py-2 text-left my-1"
238
+ >
239
+ <div className="flex items-center gap-1.5">
240
+ <Palette className="h-3.5 w-3.5 text-primary" />
241
+ <span className="text-xs font-medium">Switch to Phased to customize colours</span>
242
+ </div>
243
+ <span className="text-[11px] text-muted-foreground">
244
+ Unlocks task-type palette editing, preparation ghost, and
245
+ colour intensity.
246
+ </span>
247
+ </button>
248
+ </>
249
+ )}
250
+
251
+ <DropdownMenuSeparator />
252
+
253
+ {/* ── Timing-layer toggles (always visible) ────────────────── */}
254
+ <div className="grid gap-2 py-2">
255
+ <Label className="text-[11px] uppercase tracking-wide text-muted-foreground">Timing</Label>
256
+ <ToggleRow
257
+ label="Hide upcoming products"
258
+ description="Don't render work that hasn't started yet."
259
+ checked={settings.hideBeforePreparation}
260
+ onChange={v => patch({ hideBeforePreparation: v })}
261
+ />
262
+ <ToggleRow
263
+ label="Hide unscheduled products"
264
+ description="Hide anything not assigned to a task — stops untaskd geometry rendering as material default (often pure white)."
265
+ checked={settings.hideUntaskedProducts}
266
+ onChange={v => patch({ hideUntaskedProducts: v })}
267
+ />
268
+ <ToggleRow
269
+ label="Animate demolition"
270
+ description="Remove products when demolition tasks complete."
271
+ checked={settings.animateDemolition}
272
+ onChange={v => patch({ animateDemolition: v })}
273
+ />
274
+ </div>
275
+
276
+ {phased && (
277
+ <>
278
+ <DropdownMenuSeparator />
279
+
280
+ {/* ── Colour-layer toggles ─────────────────────────────── */}
281
+ <div className="grid gap-2 py-2">
282
+ <Label className="text-[11px] uppercase tracking-wide text-muted-foreground">
283
+ Colour overlays
284
+ </Label>
285
+ <ToggleRow
286
+ label="Colour by task type"
287
+ description="Paint the palette colour over active products."
288
+ checked={settings.colorizeByTaskType}
289
+ onChange={v => patch({ colorizeByTaskType: v })}
290
+ />
291
+ <ToggleRow
292
+ label="Preparation ghost"
293
+ description="Dim products inside the look-ahead window."
294
+ checked={settings.showPreparationGhost}
295
+ onChange={v => patch({ showPreparationGhost: v })}
296
+ />
297
+
298
+ {settings.showPreparationGhost && (
299
+ <div className="flex items-center justify-between gap-3 pl-2 pt-1 border-l-2 border-primary/30">
300
+ <span className="grid gap-0.5 min-w-0">
301
+ <span className="text-xs font-medium">Ghost colour</span>
302
+ <span className="text-[10px] text-muted-foreground">
303
+ Low-alpha dim applied to upcoming products.
304
+ </span>
305
+ </span>
306
+ <PaletteSwatch
307
+ colorKey="PREPARATION"
308
+ rgba={prepColor}
309
+ onChange={setPaletteColor}
310
+ isDefault={prepIsDefault}
311
+ />
312
+ </div>
313
+ )}
314
+
315
+ <ToggleRow
316
+ label="Tint completed products"
317
+ description="Paint a neutral tint over built products so they're distinguishable from material-default geometry."
318
+ checked={settings.showCompletedTint}
319
+ onChange={v => patch({ showCompletedTint: v })}
320
+ />
321
+
322
+ {settings.showCompletedTint && (
323
+ <div className="flex items-center justify-between gap-3 pl-2 pt-1 border-l-2 border-primary/30">
324
+ <span className="grid gap-0.5 min-w-0">
325
+ <span className="text-xs font-medium">Completed colour</span>
326
+ <span className="text-[10px] text-muted-foreground">
327
+ Low-alpha tint applied after a task finishes.
328
+ </span>
329
+ </span>
330
+ <PaletteSwatch
331
+ colorKey="COMPLETED"
332
+ rgba={completedColor}
333
+ onChange={setPaletteColor}
334
+ isDefault={completedIsDefault}
335
+ />
336
+ </div>
337
+ )}
338
+ </div>
339
+
340
+ <DropdownMenuSeparator />
341
+
342
+ {/* ── Sliders ──────────────────────────────────────────── */}
343
+ <div className="grid gap-3 py-2">
344
+ <div className="grid gap-1">
345
+ <div className="flex items-center justify-between">
346
+ <Label htmlFor="prep-days" className="text-xs">Look-ahead window</Label>
347
+ <span className="text-xs font-mono text-muted-foreground">{settings.preparationDays}d</span>
348
+ </div>
349
+ <input
350
+ id="prep-days"
351
+ type="range"
352
+ min={0}
353
+ max={14}
354
+ step={1}
355
+ value={settings.preparationDays}
356
+ onChange={(e) => patch({ preparationDays: Number(e.target.value) })}
357
+ className="w-full accent-primary"
358
+ />
359
+ </div>
360
+
361
+ <div className="grid gap-1">
362
+ <div className="flex items-center justify-between">
363
+ <Label htmlFor="palette-intensity" className="text-xs">Colour intensity</Label>
364
+ <span className="text-xs font-mono text-muted-foreground">
365
+ {Math.round(settings.paletteIntensity * 100)}%
366
+ </span>
367
+ </div>
368
+ <input
369
+ id="palette-intensity"
370
+ type="range"
371
+ min={0}
372
+ max={100}
373
+ step={5}
374
+ value={Math.round(settings.paletteIntensity * 100)}
375
+ onChange={(e) => patch({ paletteIntensity: Number(e.target.value) / 100 })}
376
+ className="w-full accent-primary"
377
+ />
378
+ <span className="text-[10px] text-muted-foreground">
379
+ 0% = no colour (equivalent to Minimal); 100% = solid paint.
380
+ </span>
381
+ </div>
382
+ </div>
383
+ </>
384
+ )}
385
+
386
+ <DropdownMenuSeparator />
387
+
388
+ <div className="flex items-center justify-end pt-1">
389
+ <Button size="sm" variant="ghost" onClick={reset} className="gap-1.5 text-xs">
390
+ <RotateCcw className="h-3 w-3" />
391
+ Reset defaults
392
+ </Button>
393
+ </div>
394
+ </DropdownMenuContent>
395
+ </DropdownMenu>
396
+ );
397
+ }
398
+
399
+ interface StyleTileProps {
400
+ icon: React.ReactNode;
401
+ label: string;
402
+ description: string;
403
+ active: boolean;
404
+ onSelect: () => void;
405
+ }
406
+
407
+ function StyleTile({ icon, label, description, active, onSelect }: StyleTileProps) {
408
+ return (
409
+ <button
410
+ type="button"
411
+ onClick={onSelect}
412
+ className={cn(
413
+ 'flex flex-col gap-0.5 rounded-md border p-2 text-left transition-colors',
414
+ active ? 'border-primary bg-primary/10' : 'border-input hover:bg-muted/40',
415
+ )}
416
+ aria-pressed={active}
417
+ >
418
+ <span className="flex items-center gap-1.5">
419
+ <span className={active ? 'text-primary' : 'text-muted-foreground'}>{icon}</span>
420
+ <span className="text-xs font-medium">{label}</span>
421
+ </span>
422
+ <span className="text-[10px] text-muted-foreground">{description}</span>
423
+ </button>
424
+ );
425
+ }
426
+
427
+ interface ToggleRowProps {
428
+ label: string;
429
+ description: string;
430
+ checked: boolean;
431
+ onChange: (next: boolean) => void;
432
+ }
433
+
434
+ function ToggleRow({ label, description, checked, onChange }: ToggleRowProps) {
435
+ return (
436
+ <label className="flex items-center justify-between gap-3 cursor-pointer">
437
+ <span className="grid gap-0.5 min-w-0">
438
+ <span className="text-xs font-medium truncate">{label}</span>
439
+ <span className="text-[10px] text-muted-foreground">{description}</span>
440
+ </span>
441
+ <Switch checked={checked} onCheckedChange={onChange} />
442
+ </label>
443
+ );
444
+ }
445
+
446
+ interface PaletteRowProps {
447
+ label: string;
448
+ colorKey: TaskPaletteKey;
449
+ rgba: RGBA;
450
+ onChange: (key: TaskPaletteKey, hex: string) => void;
451
+ onResetEntry: (key: TaskPaletteKey) => void;
452
+ isDefault: boolean;
453
+ }
454
+
455
+ /**
456
+ * Full-width palette row — 20 px clickable swatch + friendly label + hex
457
+ * code + per-entry reset on hover when modified. Larger than the old 14 px
458
+ * swatches so the interactive affordance actually reads as a button.
459
+ */
460
+ function PaletteRow({ label, colorKey, rgba, onChange, onResetEntry, isDefault }: PaletteRowProps) {
461
+ return (
462
+ <div className="group flex items-center gap-2 px-1.5 py-1 rounded hover:bg-muted/40 transition-colors">
463
+ <PaletteSwatch
464
+ colorKey={colorKey}
465
+ rgba={rgba}
466
+ onChange={onChange}
467
+ isDefault={isDefault}
468
+ />
469
+ <div className="flex flex-col min-w-0 flex-1">
470
+ <span className="text-xs font-medium truncate" title={colorKey}>
471
+ {label}
472
+ </span>
473
+ <span className="text-[10px] font-mono text-muted-foreground">
474
+ {rgbaToHex(rgba).toUpperCase()}
475
+ {!isDefault && <span className="ml-1 text-primary">• modified</span>}
476
+ </span>
477
+ </div>
478
+ {!isDefault && (
479
+ <Tooltip>
480
+ <TooltipTrigger asChild>
481
+ <button
482
+ type="button"
483
+ onClick={() => onResetEntry(colorKey)}
484
+ className="opacity-0 group-hover:opacity-100 focus:opacity-100 transition-opacity h-5 w-5 flex items-center justify-center rounded hover:bg-muted text-muted-foreground hover:text-foreground shrink-0"
485
+ aria-label={`Reset ${label} to default colour`}
486
+ >
487
+ <RotateCcw className="h-3 w-3" />
488
+ </button>
489
+ </TooltipTrigger>
490
+ <TooltipContent>Reset to default</TooltipContent>
491
+ </Tooltip>
492
+ )}
493
+ </div>
494
+ );
495
+ }
496
+
497
+ interface PaletteSwatchProps {
498
+ colorKey: TaskPaletteKey;
499
+ rgba: RGBA;
500
+ onChange: (key: TaskPaletteKey, hex: string) => void;
501
+ /** Kept for parent-side rendering; not used inside the swatch. */
502
+ isDefault?: boolean;
503
+ }
504
+
505
+ /**
506
+ * 20 × 20 px swatch that doubles as a `<input type="color">`. A subtle
507
+ * checker pattern behind the colour communicates alpha (useful for the
508
+ * PREPARATION ghost which has baked low alpha), and a ring on
509
+ * hover/focus confirms it's interactive.
510
+ */
511
+ function PaletteSwatch({ colorKey, rgba, onChange }: PaletteSwatchProps) {
512
+ return (
513
+ <label
514
+ className={cn(
515
+ 'relative h-5 w-5 rounded border-2 border-border shrink-0 cursor-pointer overflow-hidden',
516
+ 'hover:ring-2 hover:ring-primary/50 focus-within:ring-2 focus-within:ring-primary/60',
517
+ 'transition-shadow',
518
+ )}
519
+ style={{
520
+ // Checkerboard showing through low-alpha colours.
521
+ backgroundImage:
522
+ 'linear-gradient(45deg, #ccc 25%, transparent 25%), linear-gradient(-45deg, #ccc 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #ccc 75%), linear-gradient(-45deg, transparent 75%, #ccc 75%)',
523
+ backgroundSize: '6px 6px',
524
+ backgroundPosition: '0 0, 0 3px, 3px -3px, -3px 0px',
525
+ }}
526
+ title={`${colorKey} — click to edit`}
527
+ aria-label={`Change colour for ${colorKey}`}
528
+ >
529
+ <span
530
+ className="absolute inset-0 rounded-sm"
531
+ style={{ backgroundColor: rgbaToCss(rgba) }}
532
+ aria-hidden
533
+ />
534
+ <input
535
+ type="color"
536
+ value={rgbaToHex(rgba)}
537
+ onChange={(e) => onChange(colorKey, e.target.value)}
538
+ className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
539
+ />
540
+ </label>
541
+ );
542
+ }
@@ -0,0 +1,89 @@
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
+ * GanttDependencyArrows — renders IfcRelSequence links between tasks as
7
+ * orthogonal connectors (FS / SS / FF / SF). Lives as a standalone file
8
+ * so it can be memoized against a stable `sequences` array reference
9
+ * independent of playback-tick re-renders in the parent timeline.
10
+ */
11
+
12
+ import { memo } from 'react';
13
+ import type { ScheduleSequenceInfo } from '@ifc-lite/parser';
14
+ import { timeToX } from './schedule-utils';
15
+ import { GANTT_ROW_HEIGHT } from './GanttTaskTree';
16
+
17
+ export interface GanttDependencyArrowsProps {
18
+ sequences: ScheduleSequenceInfo[];
19
+ taskRowIndex: Map<string, number>;
20
+ /** Memoized { start, finish } per task globalId — avoids re-parsing ISO. */
21
+ taskEpochs: Map<string, { start: number | undefined; finish: number | undefined }>;
22
+ rangeStart: number;
23
+ rangeEnd: number;
24
+ pixelWidth: number;
25
+ }
26
+
27
+ export const GanttDependencyArrows = memo(function GanttDependencyArrows({
28
+ sequences,
29
+ taskRowIndex,
30
+ taskEpochs,
31
+ rangeStart,
32
+ rangeEnd,
33
+ pixelWidth,
34
+ }: GanttDependencyArrowsProps) {
35
+ return (
36
+ <g opacity={0.45}>
37
+ {sequences.map((seq, i) => {
38
+ const fromEpochs = taskEpochs.get(seq.relatingTaskGlobalId);
39
+ const toEpochs = taskEpochs.get(seq.relatedTaskGlobalId);
40
+ const rowFrom = taskRowIndex.get(seq.relatingTaskGlobalId);
41
+ const rowTo = taskRowIndex.get(seq.relatedTaskGlobalId);
42
+ if (!fromEpochs || !toEpochs || rowFrom === undefined || rowTo === undefined) return null;
43
+ const fromStart = fromEpochs.start;
44
+ const fromFinish = fromEpochs.finish;
45
+ const toStart = toEpochs.start;
46
+ const toFinish = toEpochs.finish;
47
+ if (
48
+ fromStart === undefined || fromFinish === undefined ||
49
+ toStart === undefined || toFinish === undefined
50
+ ) return null;
51
+
52
+ let x1 = 0, x2 = 0;
53
+ switch (seq.sequenceType) {
54
+ case 'START_START':
55
+ x1 = timeToX(fromStart, rangeStart, rangeEnd, pixelWidth);
56
+ x2 = timeToX(toStart, rangeStart, rangeEnd, pixelWidth);
57
+ break;
58
+ case 'FINISH_FINISH':
59
+ x1 = timeToX(fromFinish, rangeStart, rangeEnd, pixelWidth);
60
+ x2 = timeToX(toFinish, rangeStart, rangeEnd, pixelWidth);
61
+ break;
62
+ case 'START_FINISH':
63
+ x1 = timeToX(fromStart, rangeStart, rangeEnd, pixelWidth);
64
+ x2 = timeToX(toFinish, rangeStart, rangeEnd, pixelWidth);
65
+ break;
66
+ case 'FINISH_START':
67
+ default:
68
+ x1 = timeToX(fromFinish, rangeStart, rangeEnd, pixelWidth);
69
+ x2 = timeToX(toStart, rangeStart, rangeEnd, pixelWidth);
70
+ break;
71
+ }
72
+ const y1 = rowFrom * GANTT_ROW_HEIGHT + GANTT_ROW_HEIGHT / 2;
73
+ const y2 = rowTo * GANTT_ROW_HEIGHT + GANTT_ROW_HEIGHT / 2;
74
+ const midX = (x1 + x2) / 2;
75
+ return (
76
+ <path
77
+ key={`seq-${i}`}
78
+ d={`M ${x1} ${y1} L ${midX} ${y1} L ${midX} ${y2} L ${x2} ${y2}`}
79
+ fill="none"
80
+ stroke="currentColor"
81
+ strokeWidth={1}
82
+ strokeDasharray="3 2"
83
+ pointerEvents="none"
84
+ />
85
+ );
86
+ })}
87
+ </g>
88
+ );
89
+ });
@@ -0,0 +1,48 @@
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
+ * GanttDragTooltip — floating readout pinned near the top of the timeline
7
+ * during a bar drag. Shows the proposed new start / finish / duration so
8
+ * the user can see the commit target without staring at the bar itself.
9
+ * Fixed positioning (not absolute) keeps it above any scroll; `top-16`
10
+ * anchors below the toolbar region.
11
+ */
12
+
13
+ export interface GanttDragTooltipProps {
14
+ live: {
15
+ taskGlobalId: string | null;
16
+ mode: 'shift' | 'resize-start' | 'resize-finish' | null;
17
+ liveStartMs: number;
18
+ liveFinishMs: number;
19
+ };
20
+ }
21
+
22
+ export function GanttDragTooltip({ live }: GanttDragTooltipProps) {
23
+ const durMs = Math.max(0, live.liveFinishMs - live.liveStartMs);
24
+ const durDays = (durMs / 86_400_000).toFixed(2).replace(/\.?0+$/, '');
25
+ const fmt = (ms: number) => {
26
+ const d = new Date(ms);
27
+ const pad = (n: number) => n.toString().padStart(2, '0');
28
+ return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`;
29
+ };
30
+ const modeLabel =
31
+ live.mode === 'shift' ? 'Shifting'
32
+ : live.mode === 'resize-start' ? 'Resizing start'
33
+ : live.mode === 'resize-finish' ? 'Resizing finish'
34
+ : '';
35
+ return (
36
+ <div
37
+ className="fixed z-50 pointer-events-none top-16 left-1/2 -translate-x-1/2 rounded-md border border-sky-400 bg-sky-50 dark:bg-sky-950 dark:border-sky-700 px-3 py-1.5 shadow-lg text-[11px] font-mono text-sky-900 dark:text-sky-100"
38
+ role="status"
39
+ aria-live="polite"
40
+ >
41
+ <div className="font-sans text-[10px] uppercase tracking-wider opacity-70">{modeLabel}</div>
42
+ <div>Start {fmt(live.liveStartMs)}</div>
43
+ <div>Finish {fmt(live.liveFinishMs)}</div>
44
+ <div className="opacity-80">Duration {durDays}d</div>
45
+ <div className="font-sans text-[9px] opacity-50 mt-0.5">Shift = no snap · Esc = cancel</div>
46
+ </div>
47
+ );
48
+ }