@ifc-lite/viewer 1.17.6 → 1.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/.turbo/turbo-build.log +17 -14
  2. package/.turbo/turbo-typecheck.log +1 -1
  3. package/CHANGELOG.md +513 -0
  4. package/dist/assets/{basketViewActivator-86rgogji.js → basketViewActivator-Cm1QEk_R.js} +1 -1
  5. package/dist/assets/{exporters-CcPS9MK5.js → exporters-B_OBqIyD.js} +3235 -2648
  6. package/dist/assets/{geometry.worker-BFUYA08u.js → geometry.worker-xHHy-9DV.js} +1 -1
  7. package/dist/assets/{ifc-lite_bg-BINvzoCP.wasm → ifc-lite_bg-ADjKXSms.wasm} +0 -0
  8. package/dist/assets/{index-Bfms9I4A.js → index-BKq-M3Mk.js} +44124 -30920
  9. package/dist/assets/index-COnQRuqY.css +1 -0
  10. package/dist/assets/{native-bridge-DUyLCMZS.js → native-bridge-SHXiQwFW.js} +1 -1
  11. package/dist/assets/sandbox-jez21HtV.js +9627 -0
  12. package/dist/assets/{server-client-BuZK7OST.js → server-client-ncOQVNso.js} +1 -1
  13. package/dist/assets/{wasm-bridge-JsqEGDV8.js → wasm-bridge-DyfBSB8z.js} +1 -1
  14. package/dist/index.html +6 -6
  15. package/package.json +10 -10
  16. package/src/apache-arrow.d.ts +30 -0
  17. package/src/components/viewer/AddElementPanel.tsx +758 -0
  18. package/src/components/viewer/BulkPropertyEditor.tsx +7 -0
  19. package/src/components/viewer/ChatPanel.tsx +64 -2
  20. package/src/components/viewer/CommandPalette.tsx +56 -7
  21. package/src/components/viewer/EntityContextMenu.tsx +168 -4
  22. package/src/components/viewer/ExportChangesButton.tsx +25 -5
  23. package/src/components/viewer/ExportDialog.tsx +19 -1
  24. package/src/components/viewer/MainToolbar.tsx +69 -10
  25. package/src/components/viewer/PropertiesPanel.tsx +222 -22
  26. package/src/components/viewer/SearchInline.tsx +669 -0
  27. package/src/components/viewer/SearchModal.filter.builder.tsx +766 -0
  28. package/src/components/viewer/SearchModal.filter.tsx +514 -0
  29. package/src/components/viewer/SearchModal.text.tsx +388 -0
  30. package/src/components/viewer/SearchModal.tsx +235 -0
  31. package/src/components/viewer/ToolOverlays.tsx +5 -0
  32. package/src/components/viewer/ViewerLayout.tsx +24 -4
  33. package/src/components/viewer/Viewport.tsx +11 -1
  34. package/src/components/viewer/ViewportContainer.tsx +2 -0
  35. package/src/components/viewer/annotations/AnnotationDropInput.tsx +203 -0
  36. package/src/components/viewer/annotations/AnnotationLayer.tsx +287 -0
  37. package/src/components/viewer/annotations/AnnotationPin.tsx +90 -0
  38. package/src/components/viewer/annotations/AnnotationPopover.tsx +296 -0
  39. package/src/components/viewer/bcf/BCFTopicDetail.tsx +1 -1
  40. package/src/components/viewer/lists/ListPanel.tsx +14 -21
  41. package/src/components/viewer/properties/RawStepCard.tsx +332 -0
  42. package/src/components/viewer/properties/RawStepRow.tsx +261 -0
  43. package/src/components/viewer/properties/ScheduleCard.tsx +224 -0
  44. package/src/components/viewer/properties/TaskEditCard.tsx +510 -0
  45. package/src/components/viewer/properties/raw-step-format.ts +193 -0
  46. package/src/components/viewer/schedule/AnimationSettingsPopover.tsx +542 -0
  47. package/src/components/viewer/schedule/GanttDependencyArrows.tsx +89 -0
  48. package/src/components/viewer/schedule/GanttDragTooltip.tsx +48 -0
  49. package/src/components/viewer/schedule/GanttEmptyState.tsx +97 -0
  50. package/src/components/viewer/schedule/GanttPanel.tsx +295 -0
  51. package/src/components/viewer/schedule/GanttTaskBar.tsx +199 -0
  52. package/src/components/viewer/schedule/GanttTaskTree.tsx +250 -0
  53. package/src/components/viewer/schedule/GanttTimeline.tsx +305 -0
  54. package/src/components/viewer/schedule/GanttToolbar.tsx +406 -0
  55. package/src/components/viewer/schedule/GenerateAdvancedPanel.tsx +147 -0
  56. package/src/components/viewer/schedule/GenerateScheduleDialog.tsx +392 -0
  57. package/src/components/viewer/schedule/HeightStrategyPanel.tsx +120 -0
  58. package/src/components/viewer/schedule/generate-schedule.test.ts +439 -0
  59. package/src/components/viewer/schedule/generate-schedule.ts +648 -0
  60. package/src/components/viewer/schedule/schedule-animator.test.ts +452 -0
  61. package/src/components/viewer/schedule/schedule-animator.ts +488 -0
  62. package/src/components/viewer/schedule/schedule-selection.test.ts +148 -0
  63. package/src/components/viewer/schedule/schedule-selection.ts +163 -0
  64. package/src/components/viewer/schedule/schedule-utils.ts +223 -0
  65. package/src/components/viewer/schedule/useConstructionSequence.ts +156 -0
  66. package/src/components/viewer/schedule/useGanttBarDrag.test.ts +90 -0
  67. package/src/components/viewer/schedule/useGanttBarDrag.ts +305 -0
  68. package/src/components/viewer/schedule/useGanttSelection3DHighlight.ts +152 -0
  69. package/src/components/viewer/schedule/useOverlayCompositor.ts +108 -0
  70. package/src/components/viewer/selectionHandlers.ts +446 -0
  71. package/src/components/viewer/tools/AddElementOverlay.tsx +540 -0
  72. package/src/components/viewer/useDuplicateShortcut.ts +77 -0
  73. package/src/components/viewer/useMouseControls.ts +9 -1
  74. package/src/hooks/useIfcLoader.ts +22 -10
  75. package/src/hooks/useKeyboardShortcuts.ts +25 -0
  76. package/src/hooks/useSandbox.ts +1 -1
  77. package/src/hooks/useSearchIndex.ts +125 -0
  78. package/src/index.css +66 -0
  79. package/src/lib/llm/system-prompt.test.ts +14 -0
  80. package/src/lib/llm/system-prompt.ts +102 -1
  81. package/src/lib/llm/types.ts +6 -0
  82. package/src/lib/recent-files.ts +38 -4
  83. package/src/lib/scripts/templates/bim-globals.d.ts +136 -114
  84. package/src/lib/scripts/templates/construction-schedule.ts +223 -0
  85. package/src/lib/scripts/templates.ts +7 -0
  86. package/src/lib/search/common-ifc-types.ts +36 -0
  87. package/src/lib/search/filter-evaluate.test.ts +537 -0
  88. package/src/lib/search/filter-evaluate.ts +610 -0
  89. package/src/lib/search/filter-rules.test.ts +119 -0
  90. package/src/lib/search/filter-rules.ts +198 -0
  91. package/src/lib/search/filter-schema.test.ts +233 -0
  92. package/src/lib/search/filter-schema.ts +146 -0
  93. package/src/lib/search/recent-searches.test.ts +116 -0
  94. package/src/lib/search/recent-searches.ts +93 -0
  95. package/src/lib/search/result-export.test.ts +101 -0
  96. package/src/lib/search/result-export.ts +104 -0
  97. package/src/lib/search/saved-filters.test.ts +118 -0
  98. package/src/lib/search/saved-filters.ts +154 -0
  99. package/src/lib/search/tier0-scan.test.ts +196 -0
  100. package/src/lib/search/tier0-scan.ts +237 -0
  101. package/src/lib/search/tier1-index.test.ts +242 -0
  102. package/src/lib/search/tier1-index.ts +448 -0
  103. package/src/sdk/adapters/export-adapter.test.ts +434 -1
  104. package/src/sdk/adapters/export-adapter.ts +404 -1
  105. package/src/sdk/adapters/export-schedule-splice.test.ts +127 -0
  106. package/src/sdk/adapters/export-schedule-splice.ts +87 -0
  107. package/src/sdk/adapters/model-compat.ts +8 -2
  108. package/src/sdk/adapters/schedule-adapter.ts +73 -0
  109. package/src/sdk/adapters/store-adapter.ts +201 -0
  110. package/src/sdk/adapters/visibility-adapter.ts +3 -0
  111. package/src/sdk/local-backend.ts +16 -8
  112. package/src/services/desktop-export.ts +3 -1
  113. package/src/services/desktop-native-metadata.ts +41 -18
  114. package/src/services/file-dialog.ts +4 -1
  115. package/src/services/tauri-modules.d.ts +25 -0
  116. package/src/store/basketVisibleSet.ts +3 -0
  117. package/src/store/globalId.ts +4 -1
  118. package/src/store/index.ts +70 -1
  119. package/src/store/slices/addElementMeshes.ts +365 -0
  120. package/src/store/slices/addElementSlice.ts +275 -0
  121. package/src/store/slices/annotationsSlice.test.ts +133 -0
  122. package/src/store/slices/annotationsSlice.ts +251 -0
  123. package/src/store/slices/dataSlice.test.ts +23 -4
  124. package/src/store/slices/dataSlice.ts +1 -1
  125. package/src/store/slices/modelSlice.test.ts +67 -9
  126. package/src/store/slices/modelSlice.ts +39 -7
  127. package/src/store/slices/mutationSlice.ts +964 -3
  128. package/src/store/slices/overlayCompositor.test.ts +164 -0
  129. package/src/store/slices/overlaySlice.test.ts +93 -0
  130. package/src/store/slices/overlaySlice.ts +151 -0
  131. package/src/store/slices/pinboardSlice.test.ts +6 -1
  132. package/src/store/slices/playbackSlice.ts +128 -0
  133. package/src/store/slices/schedule-edit-helpers.test.ts +97 -0
  134. package/src/store/slices/schedule-edit-helpers.ts +179 -0
  135. package/src/store/slices/scheduleSlice.test.ts +694 -0
  136. package/src/store/slices/scheduleSlice.ts +1330 -0
  137. package/src/store/slices/searchSlice.test.ts +342 -0
  138. package/src/store/slices/searchSlice.ts +341 -0
  139. package/src/store/slices/selectionSlice.test.ts +46 -0
  140. package/src/store/slices/selectionSlice.ts +20 -0
  141. package/src/store.ts +14 -0
  142. package/dist/assets/index-_bfZsDCC.css +0 -1
  143. package/dist/assets/sandbox-C8575tul.js +0 -5951
@@ -0,0 +1,163 @@
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
+ * schedule-selection — pure helpers for Gantt ↔ 3D viewport sync.
7
+ *
8
+ * Two public surfaces:
9
+ *
10
+ * 1. `collectProductLocalIdsForTasks` — given a set of Gantt-selected task
11
+ * globalIds, walk the task tree and return the *union* of every
12
+ * descendant task's `productExpressIds`. Selecting a parent row in the
13
+ * Gantt therefore isolates every leaf below it — matching the mental
14
+ * model that a WBS row represents "all the work under this heading".
15
+ *
16
+ * 2. `findTaskForProductGlobalId` — reverse lookup used by the viewport →
17
+ * Gantt sync: user clicks a product in 3D, we find the first task that
18
+ * claims it and highlight that row in the Gantt (expanding ancestors
19
+ * so the row is visible).
20
+ *
21
+ * All functions are pure and side-effect-free; the React hooks in
22
+ * `useGanttSelection3DSync` / `useViewportToGanttSync` wire them into the
23
+ * store.
24
+ */
25
+
26
+ import type { ScheduleExtraction, ScheduleTaskInfo } from '@ifc-lite/parser';
27
+
28
+ /**
29
+ * Walk the task graph starting at `rootGlobalIds`, collecting every
30
+ * descendant task's LOCAL product expressIds (including each root itself).
31
+ *
32
+ * "Descendants" follows `task.childGlobalIds`. Cycles are defended against
33
+ * via a visited set — schedule data from `extractScheduleOnDemand` is a
34
+ * forest in practice, but we don't trust that at this boundary.
35
+ *
36
+ * Returns LOCAL expressIds. The caller federation-translates them via
37
+ * `toGlobalIdFromModels` since the viewport operates on globals.
38
+ */
39
+ export function collectProductLocalIdsForTasks(
40
+ data: ScheduleExtraction | null,
41
+ rootGlobalIds: Iterable<string>,
42
+ ): Set<number> {
43
+ const productIds = new Set<number>();
44
+ if (!data || data.tasks.length === 0) return productIds;
45
+
46
+ const byGlobalId = new Map<string, ScheduleTaskInfo>();
47
+ for (const task of data.tasks) byGlobalId.set(task.globalId, task);
48
+
49
+ const visited = new Set<string>();
50
+ const queue: string[] = [];
51
+ for (const g of rootGlobalIds) {
52
+ if (byGlobalId.has(g) && !visited.has(g)) {
53
+ queue.push(g);
54
+ visited.add(g);
55
+ }
56
+ }
57
+
58
+ while (queue.length > 0) {
59
+ const gid = queue.shift()!;
60
+ const task = byGlobalId.get(gid);
61
+ if (!task) continue;
62
+ for (const local of task.productExpressIds) {
63
+ productIds.add(local);
64
+ }
65
+ for (const childGid of task.childGlobalIds) {
66
+ if (!visited.has(childGid) && byGlobalId.has(childGid)) {
67
+ visited.add(childGid);
68
+ queue.push(childGid);
69
+ }
70
+ }
71
+ }
72
+ return productIds;
73
+ }
74
+
75
+ /**
76
+ * Find the *first* task in the schedule whose product list contains the
77
+ * given globalId (renderer-space / federated ID), returning its task
78
+ * globalId plus the full ancestor chain so the Gantt can expand rows to
79
+ * reveal it.
80
+ *
81
+ * Multi-task assignment is rare but happens — we return the first match in
82
+ * `data.tasks` iteration order, which is a deterministic function of the
83
+ * STEP file. Callers that need multi-match behaviour can layer on top.
84
+ */
85
+ export interface TaskHitForProduct {
86
+ /** globalId of the task that owns the product. */
87
+ taskGlobalId: string;
88
+ /** Ancestor task globalIds from root → direct parent (excludes the hit). */
89
+ ancestorGlobalIds: string[];
90
+ }
91
+
92
+ export function findTaskForProductGlobalId(
93
+ data: ScheduleExtraction | null,
94
+ productGlobalId: number,
95
+ ): TaskHitForProduct | null {
96
+ if (!data || data.tasks.length === 0) return null;
97
+
98
+ // Fast path: generated schedules populate `productGlobalIds` with the
99
+ // renderer-space IDs, so we can string-match the number directly. For
100
+ // extracted schedules the field may be empty — in that case we fall back
101
+ // to a per-model local-id scan (not implemented here; the caller is
102
+ // expected to have pre-translated productGlobalIds for extracted data).
103
+ const productIdStr = String(productGlobalId);
104
+ const hit = data.tasks.find(t => t.productGlobalIds.includes(productIdStr));
105
+ if (!hit) return null;
106
+
107
+ // Walk parent pointers to build the ancestor chain. `parentGlobalId` may
108
+ // be absent for root tasks — we stop at the first missing link.
109
+ const byGlobalId = new Map<string, ScheduleTaskInfo>();
110
+ for (const task of data.tasks) byGlobalId.set(task.globalId, task);
111
+
112
+ const ancestors: string[] = [];
113
+ const seen = new Set<string>([hit.globalId]);
114
+ let cursor = hit.parentGlobalId;
115
+ while (cursor && !seen.has(cursor)) {
116
+ seen.add(cursor);
117
+ ancestors.unshift(cursor);
118
+ const parent = byGlobalId.get(cursor);
119
+ cursor = parent?.parentGlobalId;
120
+ }
121
+
122
+ return { taskGlobalId: hit.globalId, ancestorGlobalIds: ancestors };
123
+ }
124
+
125
+ /**
126
+ * Reverse lookup that also handles extracted (non-generated) schedules,
127
+ * which don't pre-populate `productGlobalIds`. The caller supplies a
128
+ * `localFromGlobal(globalId) => localExpressId` federation translator.
129
+ *
130
+ * If the schedule is generated (has non-empty productGlobalIds), the
131
+ * cheap string-match path inside `findTaskForProductGlobalId` wins and
132
+ * this fallback is not reached.
133
+ */
134
+ export function findTaskForProductGlobalIdWithLocal(
135
+ data: ScheduleExtraction | null,
136
+ productGlobalId: number,
137
+ localFromGlobal: (globalId: number) => number | undefined,
138
+ ): TaskHitForProduct | null {
139
+ const fast = findTaskForProductGlobalId(data, productGlobalId);
140
+ if (fast) return fast;
141
+ if (!data) return null;
142
+
143
+ const local = localFromGlobal(productGlobalId);
144
+ if (local === undefined) return null;
145
+
146
+ const hit = data.tasks.find(t => t.productExpressIds.includes(local));
147
+ if (!hit) return null;
148
+
149
+ const byGlobalId = new Map<string, ScheduleTaskInfo>();
150
+ for (const task of data.tasks) byGlobalId.set(task.globalId, task);
151
+
152
+ const ancestors: string[] = [];
153
+ const seen = new Set<string>([hit.globalId]);
154
+ let cursor = hit.parentGlobalId;
155
+ while (cursor && !seen.has(cursor)) {
156
+ seen.add(cursor);
157
+ ancestors.unshift(cursor);
158
+ const parent = byGlobalId.get(cursor);
159
+ cursor = parent?.parentGlobalId;
160
+ }
161
+
162
+ return { taskGlobalId: hit.globalId, ancestorGlobalIds: ancestors };
163
+ }
@@ -0,0 +1,223 @@
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
+ * Helpers for the Gantt UI — date formatting, tick generation, and the
7
+ * task-tree flattener that feeds the virtualized list.
8
+ */
9
+
10
+ import type { ScheduleExtraction, ScheduleTaskInfo } from '@ifc-lite/parser';
11
+ import type { GanttTimeScale } from '@/store';
12
+ import { taskStartEpoch, taskFinishEpoch } from '@/store';
13
+
14
+ export interface FlattenedTask {
15
+ task: ScheduleTaskInfo;
16
+ depth: number;
17
+ hasChildren: boolean;
18
+ expanded: boolean;
19
+ }
20
+
21
+ /**
22
+ * Flatten a task tree into the display order used by the Gantt list,
23
+ * honoring the current expanded set. Tasks without parents are treated as
24
+ * roots; each root and its expanded descendants appear in depth-first order.
25
+ */
26
+ export function flattenTaskTree(
27
+ data: ScheduleExtraction | null,
28
+ expanded: Set<string>,
29
+ filterScheduleGlobalId?: string,
30
+ ): FlattenedTask[] {
31
+ if (!data) return [];
32
+ const taskByGlobalId = new Map<string, ScheduleTaskInfo>();
33
+ for (const t of data.tasks) taskByGlobalId.set(t.globalId, t);
34
+
35
+ /**
36
+ * A task is in-scope when no schedule filter is active, or when it (or any
37
+ * descendant) is controlled by the filter. Ancestors pass through so the
38
+ * expand/collapse chain stays visible even when only a leaf matches.
39
+ */
40
+ const isVisibleForSchedule = (task: ScheduleTaskInfo): boolean => (
41
+ !filterScheduleGlobalId
42
+ || task.controllingScheduleGlobalIds.includes(filterScheduleGlobalId)
43
+ || descendantsInSchedule(task, taskByGlobalId, filterScheduleGlobalId)
44
+ );
45
+
46
+ const result: FlattenedTask[] = [];
47
+ const roots = data.tasks.filter(t => !t.parentGlobalId);
48
+ const filteredRoots = roots.filter(isVisibleForSchedule);
49
+
50
+ const visit = (task: ScheduleTaskInfo, depth: number) => {
51
+ const hasChildren = task.childGlobalIds.length > 0;
52
+ const isExpanded = expanded.has(task.globalId);
53
+ result.push({ task, depth, hasChildren, expanded: isExpanded });
54
+ if (hasChildren && isExpanded) {
55
+ for (const childGid of task.childGlobalIds) {
56
+ const child = taskByGlobalId.get(childGid);
57
+ // Reuse the same predicate so out-of-scope descendants don't leak
58
+ // through an in-scope ancestor.
59
+ if (child && isVisibleForSchedule(child)) visit(child, depth + 1);
60
+ }
61
+ }
62
+ };
63
+ for (const root of filteredRoots) visit(root, 0);
64
+
65
+ // Tasks that are not reachable through IfcRelNests from any root — append
66
+ // at depth 0 so they're not orphaned. Apply the same predicate so the
67
+ // schedule filter is respected.
68
+ const seen = new Set(result.map(r => r.task.globalId));
69
+ for (const task of data.tasks) {
70
+ if (seen.has(task.globalId)) continue;
71
+ if (!isVisibleForSchedule(task)) continue;
72
+ result.push({ task, depth: 0, hasChildren: false, expanded: false });
73
+ }
74
+
75
+ return result;
76
+ }
77
+
78
+ function descendantsInSchedule(
79
+ task: ScheduleTaskInfo,
80
+ index: Map<string, ScheduleTaskInfo>,
81
+ scheduleGid: string,
82
+ ): boolean {
83
+ for (const childGid of task.childGlobalIds) {
84
+ const child = index.get(childGid);
85
+ if (!child) continue;
86
+ if (child.controllingScheduleGlobalIds.includes(scheduleGid)) return true;
87
+ if (descendantsInSchedule(child, index, scheduleGid)) return true;
88
+ }
89
+ return false;
90
+ }
91
+
92
+ /**
93
+ * Compute evenly spaced tick marks across [start..end] matching the given
94
+ * time scale. Returns tick timestamps in epoch ms.
95
+ */
96
+ export function computeTicks(
97
+ start: number,
98
+ end: number,
99
+ scale: GanttTimeScale,
100
+ ): number[] {
101
+ const ticks: number[] = [];
102
+ if (end <= start) return [start];
103
+ const startDate = new Date(start);
104
+
105
+ const addTick = (t: number) => { if (t >= start && t <= end) ticks.push(t); };
106
+
107
+ switch (scale) {
108
+ case 'hour': {
109
+ const step = 3_600_000;
110
+ for (let t = Math.ceil(start / step) * step; t <= end; t += step) addTick(t);
111
+ break;
112
+ }
113
+ case 'day': {
114
+ const d = new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate());
115
+ while (d.getTime() <= end) {
116
+ addTick(d.getTime());
117
+ d.setDate(d.getDate() + 1);
118
+ }
119
+ break;
120
+ }
121
+ case 'week': {
122
+ const d = new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate());
123
+ // Back up to the previous Monday (ISO week anchor).
124
+ d.setDate(d.getDate() - ((d.getDay() + 6) % 7));
125
+ while (d.getTime() <= end) {
126
+ addTick(d.getTime());
127
+ d.setDate(d.getDate() + 7);
128
+ }
129
+ break;
130
+ }
131
+ case 'month': {
132
+ const d = new Date(startDate.getFullYear(), startDate.getMonth(), 1);
133
+ while (d.getTime() <= end) {
134
+ addTick(d.getTime());
135
+ d.setMonth(d.getMonth() + 1);
136
+ }
137
+ break;
138
+ }
139
+ case 'year': {
140
+ const d = new Date(startDate.getFullYear(), 0, 1);
141
+ while (d.getTime() <= end) {
142
+ addTick(d.getTime());
143
+ d.setFullYear(d.getFullYear() + 1);
144
+ }
145
+ break;
146
+ }
147
+ }
148
+ // Always include endpoints for a clean label frame.
149
+ if (ticks[0] !== start) ticks.unshift(start);
150
+ if (ticks[ticks.length - 1] !== end) ticks.push(end);
151
+ return ticks;
152
+ }
153
+
154
+ export function formatTickLabel(t: number, scale: GanttTimeScale): string {
155
+ const d = new Date(t);
156
+ switch (scale) {
157
+ case 'hour':
158
+ return `${d.getHours().toString().padStart(2, '0')}:00`;
159
+ case 'day':
160
+ case 'week':
161
+ return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
162
+ case 'month':
163
+ return d.toLocaleDateString(undefined, { month: 'short', year: 'numeric' });
164
+ case 'year':
165
+ return String(d.getFullYear());
166
+ default:
167
+ return d.toLocaleDateString();
168
+ }
169
+ }
170
+
171
+ export function formatDateTime(t: number | undefined): string {
172
+ if (t === undefined) return '—';
173
+ const d = new Date(t);
174
+ return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
175
+ }
176
+
177
+ /**
178
+ * Map an epoch ms to an x pixel given the timeline bounds.
179
+ */
180
+ export function timeToX(t: number, start: number, end: number, pixelWidth: number): number {
181
+ if (end <= start) return 0;
182
+ const clamped = Math.min(Math.max(t, start), end);
183
+ return ((clamped - start) / (end - start)) * pixelWidth;
184
+ }
185
+
186
+ /**
187
+ * Utility for task bars — returns their horizontal start/width in pixels.
188
+ */
189
+ export function taskBarGeometry(
190
+ task: ScheduleTaskInfo,
191
+ rangeStart: number,
192
+ rangeEnd: number,
193
+ pixelWidth: number,
194
+ ): { x: number; width: number } | null {
195
+ const start = taskStartEpoch(task);
196
+ const finish = taskFinishEpoch(task);
197
+ if (start === undefined || finish === undefined) return null;
198
+ const x = timeToX(start, rangeStart, rangeEnd, pixelWidth);
199
+ const x2 = timeToX(finish, rangeStart, rangeEnd, pixelWidth);
200
+ // Milestones have zero width — render as a diamond 10px wide; other tasks
201
+ // get at least 2px so they don't disappear at very wide zoom-outs.
202
+ const width = Math.max(task.isMilestone ? 0 : 2, x2 - x);
203
+ return { x, width };
204
+ }
205
+
206
+ /**
207
+ * Produce a short "5d" / "3h" / "2w" label from an ISO 8601 duration string.
208
+ */
209
+ export function formatDurationShort(iso: string | undefined): string {
210
+ if (!iso) return '—';
211
+ const m = iso.match(/^P(?:(\d+(?:\.\d+)?)Y)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)W)?(?:(\d+(?:\.\d+)?)D)?(?:T(?:(\d+(?:\.\d+)?)H)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)S)?)?$/);
212
+ if (!m) return iso;
213
+ const [, y, mo, w, d, h, mi, s] = m;
214
+ const parts: string[] = [];
215
+ if (y) parts.push(`${y}y`);
216
+ if (mo) parts.push(`${mo}mo`);
217
+ if (w) parts.push(`${w}w`);
218
+ if (d) parts.push(`${d}d`);
219
+ if (h) parts.push(`${h}h`);
220
+ if (mi) parts.push(`${mi}m`);
221
+ if (s) parts.push(`${s}s`);
222
+ return parts.length ? parts.join(' ') : '—';
223
+ }
@@ -0,0 +1,156 @@
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
+ * useConstructionSequence — computes the 4D animation frame each playback
7
+ * tick and registers it as the 'animation' overlay layer. The compositor
8
+ * (`useOverlayCompositor`) does the actual write to the renderer.
9
+ *
10
+ * This hook no longer owns visibility reconciliation — that pattern
11
+ * (`contributedHiddenRef` / `contributedColorsRef`) lived here and was
12
+ * duplicated across every channel consumer. After P4 the compositor is
13
+ * the single writer to `hiddenEntities` / `pendingColorUpdates`, so this
14
+ * hook's only job is to emit its desired state as an overlay layer.
15
+ *
16
+ * Invariants:
17
+ * • **Federation-awareness.** The animator returns local `productExpressIds`.
18
+ * The renderer operates on global IDs. We translate via
19
+ * `toGlobalIdFromModels` before registering the layer.
20
+ *
21
+ * Playback tick: a requestAnimationFrame loop advances `playbackTime` when
22
+ * `playbackIsPlaying` && `animationEnabled` are both true.
23
+ */
24
+
25
+ import { useEffect } from 'react';
26
+ import {
27
+ useViewerStore,
28
+ toGlobalIdFromModels,
29
+ type ForwardModelMapLike,
30
+ } from '@/store';
31
+ import { resolveScheduleSourceModelId } from '@/store/slices/schedule-edit-helpers';
32
+ import { computeAnimationFrame, type RGBA } from './schedule-animator';
33
+
34
+ /**
35
+ * Map the schedule's local product expressIds to renderer global IDs.
36
+ *
37
+ * Schedule extraction is per-model (the schedule-adapter caches one
38
+ * extraction per active model), so every local expressId is attributed to
39
+ * that model. Federation-aware per-product attribution — tasks whose
40
+ * `productExpressIds` span multiple models — would require extending
41
+ * `ScheduleExtraction` with a source-model field; explicit follow-up.
42
+ */
43
+ function localIdsToGlobal<T>(
44
+ localMap: Map<number, T> | Set<number>,
45
+ models: ForwardModelMapLike,
46
+ activeModelId: string | null | undefined,
47
+ ): Map<number, T> | Set<number> {
48
+ const sourceModelId = resolveScheduleSourceModelId(models, activeModelId);
49
+
50
+ if (localMap instanceof Set) {
51
+ const out = new Set<number>();
52
+ for (const local of localMap) {
53
+ out.add(toGlobalIdFromModels(models, sourceModelId, local));
54
+ }
55
+ return out;
56
+ }
57
+ const out = new Map<number, T>();
58
+ for (const [local, v] of localMap) {
59
+ out.set(toGlobalIdFromModels(models, sourceModelId, local), v);
60
+ }
61
+ return out;
62
+ }
63
+
64
+ export function useConstructionSequence(): void {
65
+ const animationEnabled = useViewerStore(s => s.animationEnabled);
66
+ const isPlaying = useViewerStore(s => s.playbackIsPlaying);
67
+ const playbackTime = useViewerStore(s => s.playbackTime);
68
+ const scheduleData = useViewerStore(s => s.scheduleData);
69
+ const activeWorkScheduleId = useViewerStore(s => s.activeWorkScheduleId);
70
+ const advancePlaybackBy = useViewerStore(s => s.advancePlaybackBy);
71
+ const animationSettings = useViewerStore(s => s.animationSettings);
72
+
73
+ // rAF playback loop — ticks the simulated clock.
74
+ useEffect(() => {
75
+ if (!isPlaying || !animationEnabled) return;
76
+ let frame: number | null = null;
77
+ let last = performance.now();
78
+ const tick = (now: number) => {
79
+ const delta = now - last;
80
+ last = now;
81
+ advancePlaybackBy(delta);
82
+ frame = requestAnimationFrame(tick);
83
+ };
84
+ frame = requestAnimationFrame(tick);
85
+ return () => {
86
+ if (frame !== null) cancelAnimationFrame(frame);
87
+ };
88
+ }, [isPlaying, animationEnabled, advancePlaybackBy]);
89
+
90
+ // Register / update the 'animation' overlay layer on every state change.
91
+ // The compositor hook picks this up and reconciles it into the renderer.
92
+ useEffect(() => {
93
+ const store = useViewerStore.getState();
94
+
95
+ // Animation off / no data → remove our layer (compositor will
96
+ // restore whatever we had hidden/coloured).
97
+ if (!animationEnabled || !scheduleData) {
98
+ store.removeOverlayLayer('animation');
99
+ return;
100
+ }
101
+
102
+ const models: ForwardModelMapLike = store.models;
103
+ const activeModelId = store.activeModelId;
104
+
105
+ // Enumerate the source model's LOCAL expressIds so the animator can
106
+ // hide coverage-gap products (those with no controlling task). We walk
107
+ // meshes rather than the full IFC entity table because only meshed
108
+ // products can visibly "show up" as the material default in the
109
+ // viewport — non-meshed entities don't render regardless.
110
+ //
111
+ // Runs only when the untasked-hide setting is on so we don't pay the
112
+ // mesh-iteration cost on every playback frame when the feature is off.
113
+ let allLocalIds: Set<number> | undefined;
114
+ if (animationSettings.hideUntaskedProducts) {
115
+ const fullModels = store.models;
116
+ const sourceModelId = resolveScheduleSourceModelId(fullModels, activeModelId);
117
+ const sourceModel = sourceModelId ? fullModels.get(sourceModelId) : undefined;
118
+ const meshes = sourceModel?.geometryResult?.meshes;
119
+ const idOffset = sourceModel?.idOffset ?? 0;
120
+ if (meshes && meshes.length > 0) {
121
+ allLocalIds = new Set<number>();
122
+ for (const mesh of meshes) {
123
+ allLocalIds.add(mesh.expressId - idOffset);
124
+ }
125
+ }
126
+ }
127
+
128
+ // Animator is a single source of truth — always emits hiddenIds (so
129
+ // `minimal` still removes demolished products and hides upcoming
130
+ // ones) and only emits colour overrides when style === 'phased'.
131
+ const frame = computeAnimationFrame(
132
+ scheduleData, playbackTime, animationSettings, activeWorkScheduleId || null,
133
+ allLocalIds,
134
+ );
135
+ const nextLocalHidden: Set<number> = frame.hiddenIds;
136
+ const nextLocalColors: Map<number, RGBA> = frame.colorOverrides;
137
+
138
+ const nextHidden = localIdsToGlobal(nextLocalHidden, models, activeModelId) as Set<number>;
139
+ const nextColors = localIdsToGlobal(nextLocalColors, models, activeModelId) as Map<number, RGBA>;
140
+
141
+ store.registerOverlayLayer({
142
+ id: 'animation',
143
+ priority: 100,
144
+ hiddenIds: nextHidden,
145
+ colorOverrides: nextColors.size > 0 ? nextColors : null,
146
+ });
147
+ }, [animationEnabled, playbackTime, scheduleData, activeWorkScheduleId, animationSettings]);
148
+
149
+ // Unmount cleanup — drop our layer. The compositor restores whatever
150
+ // we had contributed to the renderer.
151
+ useEffect(() => {
152
+ return () => {
153
+ useViewerStore.getState().removeOverlayLayer('animation');
154
+ };
155
+ }, []);
156
+ }
@@ -0,0 +1,90 @@
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
+ * Unit tests for the pure math inside the bar-drag hook. Drag interaction
7
+ * itself is inherently manual-QA (pointer events + rAF), so the machinery
8
+ * that matters for correctness lives in these two pure helpers —
9
+ * `snapDeltaMs` and `pxPerMs`. Both are exercised on the round-trip
10
+ * pattern the hook uses at runtime: pixel delta → ms delta → snap.
11
+ */
12
+
13
+ import { describe, it } from 'node:test';
14
+ import assert from 'node:assert/strict';
15
+ import type { ScheduleTimeRange } from '@/store';
16
+ import { snapDeltaMs, pxPerMs } from './useGanttBarDrag';
17
+
18
+ const DAY = 86_400_000;
19
+ const HOUR = 3_600_000;
20
+
21
+ describe('snapDeltaMs', () => {
22
+ it('snaps to the nearest unit', () => {
23
+ assert.strictEqual(snapDeltaMs(1.4 * HOUR, HOUR), 1 * HOUR);
24
+ assert.strictEqual(snapDeltaMs(1.6 * HOUR, HOUR), 2 * HOUR);
25
+ });
26
+
27
+ it('rounds half-way values up (Math.round semantics)', () => {
28
+ assert.strictEqual(snapDeltaMs(0.5 * HOUR, HOUR), 1 * HOUR);
29
+ });
30
+
31
+ it('handles negative deltas symmetrically', () => {
32
+ assert.strictEqual(snapDeltaMs(-0.4 * HOUR, HOUR), 0);
33
+ assert.strictEqual(snapDeltaMs(-1.6 * HOUR, HOUR), -2 * HOUR);
34
+ });
35
+
36
+ it('is a no-op when unit is 0 or negative (Shift-held path)', () => {
37
+ assert.strictEqual(snapDeltaMs(1234.5, 0), 1234.5);
38
+ assert.strictEqual(snapDeltaMs(1234.5, -10), 1234.5);
39
+ });
40
+
41
+ it('returns 0 for 0 delta regardless of unit', () => {
42
+ assert.strictEqual(snapDeltaMs(0, HOUR), 0);
43
+ assert.strictEqual(snapDeltaMs(0, 0), 0);
44
+ });
45
+ });
46
+
47
+ describe('pxPerMs', () => {
48
+ const thirtyDayRange: ScheduleTimeRange = {
49
+ start: 0,
50
+ end: 30 * DAY,
51
+ synthetic: false,
52
+ };
53
+
54
+ it('divides pixel width by time span', () => {
55
+ // 1500 px over 30 days → 50 px/day → 50 / 86_400_000 ms.
56
+ const ratio = pxPerMs(1500, thirtyDayRange);
57
+ assert.ok(Math.abs(ratio - 1500 / (30 * DAY)) < 1e-12);
58
+ });
59
+
60
+ it('returns 0 when the range is degenerate', () => {
61
+ assert.strictEqual(pxPerMs(1000, { start: 10, end: 10, synthetic: false }), 0);
62
+ assert.strictEqual(pxPerMs(1000, { start: 100, end: 0, synthetic: false }), 0);
63
+ });
64
+
65
+ it('returns 0 when pixel width is zero or negative', () => {
66
+ assert.strictEqual(pxPerMs(0, thirtyDayRange), 0);
67
+ assert.strictEqual(pxPerMs(-50, thirtyDayRange), 0);
68
+ });
69
+ });
70
+
71
+ describe('drag math — round-trip pattern the hook uses', () => {
72
+ // The hook does: rawDeltaMs = (clientX - startClientX) / pxPerMs(...)
73
+ // This guards the division: at sane widths + ranges, a 100-px drag
74
+ // yields a sensible ms delta which then snaps to a day-ish unit.
75
+ it('100 px drag over a 30-day schedule at 1500 px → ~2 days', () => {
76
+ const range: ScheduleTimeRange = { start: 0, end: 30 * DAY, synthetic: false };
77
+ const ratio = pxPerMs(1500, range);
78
+ const ms = 100 / ratio;
79
+ const snappedToDay = snapDeltaMs(ms, DAY);
80
+ // 100 / (1500 / (30*DAY)) = 2 days exactly.
81
+ assert.strictEqual(snappedToDay, 2 * DAY);
82
+ });
83
+
84
+ it('1 px drag over a 30-day schedule snaps to 0 with a day unit', () => {
85
+ const range: ScheduleTimeRange = { start: 0, end: 30 * DAY, synthetic: false };
86
+ const ratio = pxPerMs(1500, range);
87
+ const ms = 1 / ratio;
88
+ assert.strictEqual(snapDeltaMs(ms, DAY), 0);
89
+ });
90
+ });