@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,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
|
+
}
|