@ifc-lite/viewer 1.21.0 → 1.22.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 (87) hide show
  1. package/.turbo/turbo-build.log +57 -50
  2. package/.turbo/turbo-typecheck.log +1 -1
  3. package/CHANGELOG.md +10 -0
  4. package/dist/assets/arrow-fie-E7fe.js +20 -0
  5. package/dist/assets/ascii-points-source-bTjLVmUX.js +1 -0
  6. package/dist/assets/{basketViewActivator-Bzw51jhm.js → basketViewActivator-EHAhHlwN.js} +12 -13
  7. package/dist/assets/bcf-Bhx-K17f.js +281 -0
  8. package/dist/assets/{browser-C5TFR7sH.js → browser-CVf8ATeW.js} +6 -6
  9. package/dist/assets/cesium-B4ZIU9jS.js +17742 -0
  10. package/dist/assets/decode-worker-CYqSjk1n.js +172 -0
  11. package/dist/assets/e57-source-CQHxE8n3.js +1 -0
  12. package/dist/assets/emscripten-module.browser-DcFZLAUx.js +1 -0
  13. package/dist/assets/exporters-KTio0Tdm.js +5723 -0
  14. package/dist/assets/geometry-controller.worker-Cm2P_EJr.js +7 -0
  15. package/dist/assets/geometry.worker-DchLBqZ8.js +1 -0
  16. package/dist/assets/{ids-B7AXEv7h.js → ids-CS7VCFin.js} +5 -5
  17. package/dist/assets/ifc-lite-C6wEhXa6.js +7 -0
  18. package/dist/assets/{ifc-lite_bg-DlKs5-yM.wasm → ifc-lite_bg-CSeT3fNI.wasm} +0 -0
  19. package/dist/assets/{ifc-lite_bg-PqmRe3Ph.wasm → ifc-lite_bg-ns4cSnX2.wasm} +0 -0
  20. package/dist/assets/{index-DVNSvEMh.js → index-8k9h-ANq.js} +60997 -59926
  21. package/dist/assets/index-BZC2YaOP.css +1 -0
  22. package/dist/assets/index-HqAIQkr6.js +22 -0
  23. package/dist/assets/inline-worker-BpBzlmd6.js +1 -0
  24. package/dist/assets/las-BW6LIc_j.js +1 -0
  25. package/dist/assets/las-source-C_IGrgRq.js +1 -0
  26. package/dist/assets/laz-source-jj3xI5Y4.js +125 -0
  27. package/dist/assets/maplibre-gl-C4LXKM6c.js +808 -0
  28. package/dist/assets/{native-bridge-BiD01jI9.js → native-bridge-DNrEhx2R.js} +5 -8
  29. package/dist/assets/{parser.worker-Bnbrl6gy.js → parser.worker-BcjkIo89.js} +2 -2
  30. package/dist/assets/pcd-source-Ck0UnVDn.js +3 -0
  31. package/dist/assets/ply-source-C8jjyzxE.js +4 -0
  32. package/dist/assets/{exporters-u0sz2Upj.js → sandbox-BSn5MyEJ.js} +11745 -7412
  33. package/dist/assets/{server-client-DP8fMPY9.js → server-client-D-kU2XAF.js} +4 -4
  34. package/dist/assets/{three-CDRZThFA.js → three-DwNDHx9-.js} +163 -171
  35. package/dist/assets/wasm-bridge-Cha08LdC.js +1 -0
  36. package/dist/assets/{workerHelpers-CBbWSJmd.js → workerHelpers-pUUnk9Wc.js} +1 -1
  37. package/dist/assets/zip-BJqVbRkU.js +2 -0
  38. package/dist/index.html +10 -12
  39. package/package.json +11 -11
  40. package/src/components/mcp/PlaygroundChat.tsx +90 -52
  41. package/src/components/viewer/CesiumOverlay.tsx +150 -91
  42. package/src/components/viewer/CesiumPlacementEditor.tsx +1009 -0
  43. package/src/components/viewer/ChatPanel.tsx +76 -93
  44. package/src/components/viewer/EntityContextMenu.tsx +68 -10
  45. package/src/components/viewer/MainToolbar.tsx +33 -3
  46. package/src/components/viewer/ViewportContainer.tsx +70 -16
  47. package/src/components/viewer/ViewportOverlays.tsx +2 -98
  48. package/src/components/viewer/chat/ByokKeyModal.tsx +338 -0
  49. package/src/components/viewer/chat/ByokStreamingPill.tsx +62 -0
  50. package/src/components/viewer/chat/ByokTrustDiagram.tsx +192 -0
  51. package/src/components/viewer/properties/GeoreferencingPanel.tsx +49 -52
  52. package/src/components/viewer/properties/ModelMetadataPanel.tsx +55 -44
  53. package/src/components/viewer/selectionHandlers.ts +7 -1
  54. package/src/lib/geo/cesium-bridge.ts +86 -50
  55. package/src/lib/geo/cesium-placement.test.ts +244 -0
  56. package/src/lib/geo/cesium-placement.ts +231 -0
  57. package/src/lib/geo/effective-georef.test.ts +74 -1
  58. package/src/lib/geo/effective-georef.ts +40 -93
  59. package/src/lib/geo/geo-scale.ts +104 -0
  60. package/src/lib/geo/reproject.test.ts +130 -0
  61. package/src/lib/geo/reproject.ts +37 -12
  62. package/src/lib/geo/terrain-elevation.ts +198 -89
  63. package/src/lib/lens/adapter.ts +52 -6
  64. package/src/lib/llm/clipboard-detect.test.ts +150 -0
  65. package/src/lib/llm/clipboard-detect.ts +90 -0
  66. package/src/lib/llm/models.ts +28 -0
  67. package/src/lib/llm/stream-direct.ts +16 -4
  68. package/src/lib/llm/types.ts +8 -0
  69. package/src/services/playground-model.ts +55 -0
  70. package/src/store/index.ts +4 -5
  71. package/src/store/slices/cesiumSlice.ts +100 -19
  72. package/src/store.ts +3 -0
  73. package/dist/assets/arrow-CZ5kQ26f.js +0 -20
  74. package/dist/assets/bcf-4K724hw0.js +0 -281
  75. package/dist/assets/cesium-DUOzBlqv.js +0 -17817
  76. package/dist/assets/decode-worker-t2EGKAxO.js +0 -1708
  77. package/dist/assets/emscripten-module.browser-CY5t0Vfq.js +0 -1
  78. package/dist/assets/geometry-controller.worker-NH8pZmrU.js +0 -7
  79. package/dist/assets/geometry.worker-Bp4rW_R1.js +0 -1
  80. package/dist/assets/ifc-lite-DfZHk36-.js +0 -7
  81. package/dist/assets/index-CSWgTe1s.css +0 -1
  82. package/dist/assets/index-XwKzDuw6.js +0 -22
  83. package/dist/assets/maplibre-gl-CGLcoNXc.js +0 -811
  84. package/dist/assets/sandbox-DPD1ROr0.js +0 -9700
  85. package/dist/assets/wasm-bridge-CErti6zX.js +0 -1
  86. package/dist/assets/zip-DBEtpeu6.js +0 -12
  87. package/src/components/viewer/CesiumSettingsDialog.tsx +0 -100
@@ -0,0 +1,1009 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
6
+ import { Check, ChevronDown, GripVertical, Move, RotateCcw, X } from 'lucide-react';
7
+ import type { CoordinateInfo } from '@ifc-lite/geometry';
8
+ import type { MapConversion, ProjectedCRS } from '@ifc-lite/parser';
9
+
10
+ import { toast } from '@/components/ui/toast';
11
+ import { getGlobalRenderer } from '@/hooks/useBCF';
12
+ import {
13
+ closestYOnVerticalLineFromRay,
14
+ getMapUnitScale,
15
+ intersectRayWithHorizontalPlane,
16
+ mapUnitsToMeters,
17
+ metersToMapUnits,
18
+ projectedDeltaToViewerDelta,
19
+ viewerDeltaToProjectedDelta,
20
+ } from '@/lib/geo/cesium-placement';
21
+ import { findClampAnchorY } from '@/lib/geo/clamp-anchor';
22
+ import { cn } from '@/lib/utils';
23
+ import { useViewerStore, type CesiumPlacementDraft } from '@/store';
24
+
25
+ interface CesiumPlacementEditorProps {
26
+ modelId: string;
27
+ mapConversion: MapConversion;
28
+ baseMapConversion: MapConversion;
29
+ projectedCRS?: ProjectedCRS;
30
+ coordinateInfo?: CoordinateInfo;
31
+ lengthUnitScale?: number;
32
+ storeyElevations?: Map<number, number>;
33
+ }
34
+
35
+ type ScreenPoint = { x: number; y: number };
36
+ type WorldPoint = { x: number; y: number; z: number };
37
+
38
+ function getGizmoWorldSize(coordinateInfo: CoordinateInfo | undefined): number {
39
+ const bounds = coordinateInfo?.originalBounds;
40
+ if (!bounds) return 25;
41
+ const dx = bounds.max.x - bounds.min.x;
42
+ const dy = bounds.max.y - bounds.min.y;
43
+ const dz = bounds.max.z - bounds.min.z;
44
+ const size = Math.max(dx, dy, dz) * 0.45;
45
+ return Math.min(80, Math.max(15, size));
46
+ }
47
+
48
+ // Drag math is anchored in WORLD SPACE (ray-plane / ray-line intersection)
49
+ // rather than projected screen-axis pixels. Screen-space linearisations alias
50
+ // badly when the gizmo plane is near-edge-on to the camera (det → 0), which
51
+ // previously produced "huge jumps" for XY drag at oblique tilts. Ray-based
52
+ // math stays exact at any camera angle short of full grazing.
53
+ type DragState =
54
+ | {
55
+ mode: 'height';
56
+ startDraft: CesiumPlacementDraft;
57
+ anchorX: number;
58
+ anchorZ: number;
59
+ startWorldY: number;
60
+ }
61
+ | {
62
+ mode: 'xy';
63
+ startDraft: CesiumPlacementDraft;
64
+ planeY: number;
65
+ startHit: WorldPoint;
66
+ };
67
+
68
+ function round2(value: number): number {
69
+ return Math.round(value * 100) / 100;
70
+ }
71
+
72
+ function formatSigned(value: number, suffix: string): string {
73
+ const rounded = Math.abs(value) < 0.005 ? 0 : round2(value);
74
+ return `${rounded >= 0 ? '+' : ''}${rounded.toFixed(2)} ${suffix}`;
75
+ }
76
+
77
+ function normalizeDegrees(value: number): number {
78
+ let normalized = value % 360;
79
+ if (normalized > 180) normalized -= 360;
80
+ if (normalized <= -180) normalized += 360;
81
+ return normalized;
82
+ }
83
+
84
+ function axisAngleDegrees(conversion: Pick<MapConversion, 'xAxisAbscissa' | 'xAxisOrdinate'>): number {
85
+ return normalizeDegrees(
86
+ Math.atan2(conversion.xAxisOrdinate ?? 0, conversion.xAxisAbscissa ?? 1) * 180 / Math.PI,
87
+ );
88
+ }
89
+
90
+ function axisFromAngleDegrees(angleDegrees: number): Pick<MapConversion, 'xAxisAbscissa' | 'xAxisOrdinate'> {
91
+ const radians = angleDegrees * Math.PI / 180;
92
+ return {
93
+ xAxisAbscissa: Math.round(Math.cos(radians) * 1_000_000) / 1_000_000,
94
+ xAxisOrdinate: Math.round(Math.sin(radians) * 1_000_000) / 1_000_000,
95
+ };
96
+ }
97
+
98
+ interface PointerRay {
99
+ origin: { x: number; y: number; z: number };
100
+ direction: { x: number; y: number; z: number };
101
+ }
102
+
103
+ /**
104
+ * Resolve a CSS-pixel pointer event into a world-space ray via the renderer
105
+ * camera. Returns null when the renderer/canvas isn't mounted yet.
106
+ *
107
+ * Note: `unprojectToRay` consumes drawing-buffer pixels, so we rescale the
108
+ * CSS-pixel pointer coordinates by `canvas.width / rect.width` (and the same
109
+ * for height). Mixing CSS and drawing-buffer units silently scales the ray
110
+ * direction on high-DPI displays — the previous gizmo bug-pattern.
111
+ */
112
+ function rayFromPointerEvent(clientX: number, clientY: number): PointerRay | null {
113
+ const renderer = getGlobalRenderer();
114
+ const camera = renderer?.getCamera();
115
+ const canvas = renderer?.getCanvas();
116
+ if (!camera || !canvas) return null;
117
+ const rect = canvas.getBoundingClientRect();
118
+ if (rect.width <= 0 || rect.height <= 0) return null;
119
+ const bufferX = ((clientX - rect.left) / rect.width) * canvas.width;
120
+ const bufferY = ((clientY - rect.top) / rect.height) * canvas.height;
121
+ const ray = camera.unprojectToRay(bufferX, bufferY, canvas.width, canvas.height);
122
+ if (!ray) return null;
123
+ return ray;
124
+ }
125
+
126
+ const PANEL_WIDTH = 320;
127
+ const PANEL_MARGIN = 16;
128
+ const PANEL_STORAGE_KEY = 'ifc-lite:cesium-placement-panel:v1';
129
+
130
+ interface PanelPreferences {
131
+ x?: number;
132
+ y?: number;
133
+ collapsed?: boolean;
134
+ }
135
+
136
+ function readPanelPreferences(): PanelPreferences {
137
+ if (typeof window === 'undefined') return {};
138
+ try {
139
+ const raw = window.localStorage.getItem(PANEL_STORAGE_KEY);
140
+ if (!raw) return {};
141
+ const parsed = JSON.parse(raw) as PanelPreferences;
142
+ return parsed && typeof parsed === 'object' ? parsed : {};
143
+ } catch (err) {
144
+ console.warn('[CesiumPlacementEditor] failed to read panel prefs', err);
145
+ return {};
146
+ }
147
+ }
148
+
149
+ function writePanelPreferences(prefs: PanelPreferences): void {
150
+ if (typeof window === 'undefined') return;
151
+ try {
152
+ window.localStorage.setItem(PANEL_STORAGE_KEY, JSON.stringify(prefs));
153
+ } catch (err) {
154
+ console.warn('[CesiumPlacementEditor] failed to persist panel prefs', err);
155
+ }
156
+ }
157
+
158
+ interface ContainerBox {
159
+ width: number;
160
+ height: number;
161
+ }
162
+
163
+ function clampPanelPosition(
164
+ x: number,
165
+ y: number,
166
+ panelHeight: number,
167
+ container: ContainerBox,
168
+ ): ScreenPoint {
169
+ // ViewportContainer is the offset parent — its width is the panel-group
170
+ // centre column, not the full window. Clamp inside its rect, leaving
171
+ // PANEL_MARGIN on every edge.
172
+ const maxX = Math.max(PANEL_MARGIN, container.width - PANEL_WIDTH - PANEL_MARGIN);
173
+ const maxY = Math.max(PANEL_MARGIN, container.height - panelHeight - PANEL_MARGIN);
174
+ return {
175
+ x: Math.min(Math.max(x, PANEL_MARGIN), maxX),
176
+ y: Math.min(Math.max(y, PANEL_MARGIN), maxY),
177
+ };
178
+ }
179
+
180
+ function defaultPanelPosition(panelHeight: number, container: ContainerBox): ScreenPoint {
181
+ return {
182
+ x: Math.max(PANEL_MARGIN, container.width - PANEL_WIDTH - PANEL_MARGIN),
183
+ y: Math.max(PANEL_MARGIN, container.height - panelHeight - PANEL_MARGIN),
184
+ };
185
+ }
186
+
187
+ export function CesiumPlacementEditor({
188
+ modelId,
189
+ mapConversion,
190
+ baseMapConversion,
191
+ projectedCRS,
192
+ coordinateInfo,
193
+ lengthUnitScale = 1,
194
+ storeyElevations,
195
+ }: CesiumPlacementEditorProps) {
196
+ const editMode = useViewerStore((s) => s.cesiumPlacementEditMode);
197
+ const draftModelId = useViewerStore((s) => s.cesiumPlacementDraftModelId);
198
+ const draft = useViewerStore((s) => s.cesiumPlacementDraft);
199
+ const beginDraft = useViewerStore((s) => s.beginCesiumPlacementDraft);
200
+ const updateDraft = useViewerStore((s) => s.updateCesiumPlacementDraft);
201
+ const resetDraft = useViewerStore((s) => s.resetCesiumPlacementDraft);
202
+ const setEditMode = useViewerStore((s) => s.setCesiumPlacementEditMode);
203
+ const setActiveTool = useViewerStore((s) => s.setActiveTool);
204
+ const setGeorefFields = useViewerStore((s) => s.setGeorefFields);
205
+ const [projection, setProjection] = useState<{
206
+ center: ScreenPoint;
207
+ heightTip: ScreenPoint;
208
+ planeCorners: [ScreenPoint, ScreenPoint, ScreenPoint, ScreenPoint];
209
+ } | null>(null);
210
+ const dragStateRef = useRef<DragState | null>(null);
211
+
212
+ // Panel chrome state: position (draggable, persisted), collapse, and the
213
+ // header-drag offset captured on pointer-down. Position is lazy-initialised
214
+ // from localStorage so we don't flicker through a centred mount.
215
+ const panelRef = useRef<HTMLDivElement>(null);
216
+ const [panelCollapsed, setPanelCollapsed] = useState<boolean>(
217
+ () => readPanelPreferences().collapsed ?? false,
218
+ );
219
+ const [panelPosition, setPanelPosition] = useState<ScreenPoint | null>(() => {
220
+ const prefs = readPanelPreferences();
221
+ if (typeof prefs.x === 'number' && typeof prefs.y === 'number') {
222
+ return { x: prefs.x, y: prefs.y };
223
+ }
224
+ return null;
225
+ });
226
+ const panelDragRef = useRef<{ offsetX: number; offsetY: number; pointerId: number } | null>(null);
227
+
228
+ // Resolve the panel's positioning container (its `offsetParent`, which is
229
+ // ViewportContainer's relative root). We measure it for default placement,
230
+ // clamping, and drag math so the panel stays inside the centre viewport
231
+ // column instead of being computed against the whole window.
232
+ const getContainerBox = useCallback((): ContainerBox | null => {
233
+ const parent = panelRef.current?.offsetParent as HTMLElement | null;
234
+ if (!parent) return null;
235
+ const rect = parent.getBoundingClientRect();
236
+ return { width: rect.width, height: rect.height };
237
+ }, []);
238
+
239
+ const getContainerOrigin = useCallback((): { left: number; top: number } | null => {
240
+ const parent = panelRef.current?.offsetParent as HTMLElement | null;
241
+ if (!parent) return null;
242
+ const rect = parent.getBoundingClientRect();
243
+ return { left: rect.left, top: rect.top };
244
+ }, []);
245
+
246
+ // When a saved position exists, clamp it on mount and after resizes so
247
+ // it stays inside the viewport container. When no saved position exists
248
+ // we leave panelPosition === null and rely on CSS right/bottom anchoring
249
+ // for the default bottom-right placement, which avoids a measure-first
250
+ // flicker and works even when the offsetParent isn't measurable yet.
251
+ useLayoutEffect(() => {
252
+ const apply = () => {
253
+ const container = getContainerBox();
254
+ if (!container) return;
255
+ const height = panelRef.current?.offsetHeight ?? 280;
256
+ setPanelPosition((prev) => {
257
+ if (prev === null) return null;
258
+ return clampPanelPosition(prev.x, prev.y, height, container);
259
+ });
260
+ };
261
+ apply();
262
+ if (typeof window === 'undefined') return;
263
+ window.addEventListener('resize', apply);
264
+ return () => window.removeEventListener('resize', apply);
265
+ }, [panelCollapsed, getContainerBox]);
266
+
267
+ useEffect(() => {
268
+ if (!panelPosition) return;
269
+ writePanelPreferences({ x: panelPosition.x, y: panelPosition.y, collapsed: panelCollapsed });
270
+ }, [panelPosition, panelCollapsed]);
271
+
272
+ const handlePanelHeaderPointerDown = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
273
+ // Ignore drags that originate inside the header's action buttons —
274
+ // those have their own click handlers.
275
+ if ((e.target as HTMLElement).closest('[data-panel-action]')) return;
276
+ if (!panelRef.current) return;
277
+ const rect = panelRef.current.getBoundingClientRect();
278
+ e.preventDefault();
279
+ e.stopPropagation();
280
+ e.currentTarget.setPointerCapture(e.pointerId);
281
+ panelDragRef.current = {
282
+ offsetX: e.clientX - rect.left,
283
+ offsetY: e.clientY - rect.top,
284
+ pointerId: e.pointerId,
285
+ };
286
+ }, []);
287
+
288
+ const handlePanelHeaderPointerMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
289
+ const drag = panelDragRef.current;
290
+ if (!drag || drag.pointerId !== e.pointerId) return;
291
+ e.preventDefault();
292
+ e.stopPropagation();
293
+ const container = getContainerBox();
294
+ const origin = getContainerOrigin();
295
+ if (!container || !origin) return;
296
+ const height = panelRef.current?.offsetHeight ?? 280;
297
+ // Translate pointer-client coords into container-local coords, then clamp.
298
+ const localX = e.clientX - drag.offsetX - origin.left;
299
+ const localY = e.clientY - drag.offsetY - origin.top;
300
+ setPanelPosition(clampPanelPosition(localX, localY, height, container));
301
+ }, [getContainerBox, getContainerOrigin]);
302
+
303
+ const handlePanelHeaderPointerUp = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
304
+ const drag = panelDragRef.current;
305
+ if (!drag || drag.pointerId !== e.pointerId) return;
306
+ panelDragRef.current = null;
307
+ try {
308
+ e.currentTarget.releasePointerCapture(e.pointerId);
309
+ } catch (_err) {
310
+ /* cleanup — safe to ignore: pointer already released by browser */
311
+ }
312
+ }, []);
313
+
314
+ const togglePanelCollapsed = useCallback(() => {
315
+ setPanelCollapsed((prev) => !prev);
316
+ }, []);
317
+
318
+ useEffect(() => {
319
+ if (!editMode) return;
320
+ if (draftModelId !== modelId || !draft) {
321
+ beginDraft(modelId, baseMapConversion);
322
+ }
323
+ }, [baseMapConversion, beginDraft, draft, draftModelId, editMode, modelId]);
324
+
325
+ const activeDraft: CesiumPlacementDraft = draftModelId === modelId && draft
326
+ ? draft
327
+ : {
328
+ eastings: mapConversion.eastings,
329
+ northings: mapConversion.northings,
330
+ orthogonalHeight: mapConversion.orthogonalHeight,
331
+ // MapConversion's cos/sin pair is optional; identity = no rotation.
332
+ xAxisAbscissa: mapConversion.xAxisAbscissa ?? 1,
333
+ xAxisOrdinate: mapConversion.xAxisOrdinate ?? 0,
334
+ };
335
+
336
+ const mapUnitScale = getMapUnitScale(projectedCRS, lengthUnitScale);
337
+ const mapUnitSuffix = mapUnitScale === 1 ? 'm' : 'map units';
338
+ const baseAngle = axisAngleDegrees(baseMapConversion);
339
+ const activeAngle = axisAngleDegrees(activeDraft);
340
+ const deltaE = activeDraft.eastings - baseMapConversion.eastings;
341
+ const deltaN = activeDraft.northings - baseMapConversion.northings;
342
+ const deltaH = activeDraft.orthogonalHeight - baseMapConversion.orthogonalHeight;
343
+ const deltaAngle = normalizeDegrees(activeAngle - baseAngle);
344
+ const deltaHeightMeters = mapUnitsToMeters(deltaH, projectedCRS, lengthUnitScale);
345
+ const dirty = Math.abs(deltaE) > 1e-6 || Math.abs(deltaN) > 1e-6 || Math.abs(deltaH) > 1e-6 || Math.abs(deltaAngle) > 1e-6;
346
+ const nudgeStep = round2(metersToMapUnits(1, projectedCRS, lengthUnitScale));
347
+
348
+ const anchorWorld = useMemo((): WorldPoint => {
349
+ const bounds = coordinateInfo?.originalBounds;
350
+ const centerX = bounds ? (bounds.min.x + bounds.max.x) / 2 : 0;
351
+ const centerZ = bounds ? (bounds.min.z + bounds.max.z) / 2 : 0;
352
+ const anchorY = findClampAnchorY(bounds, storeyElevations);
353
+ const xyOffset = projectedDeltaToViewerDelta(
354
+ deltaE,
355
+ deltaN,
356
+ baseMapConversion,
357
+ projectedCRS,
358
+ lengthUnitScale,
359
+ );
360
+
361
+ return {
362
+ x: centerX + xyOffset.x,
363
+ y: anchorY + deltaHeightMeters,
364
+ z: centerZ + xyOffset.z,
365
+ };
366
+ }, [
367
+ baseMapConversion,
368
+ coordinateInfo?.originalBounds,
369
+ deltaE,
370
+ deltaN,
371
+ deltaHeightMeters,
372
+ lengthUnitScale,
373
+ projectedCRS,
374
+ storeyElevations,
375
+ ]);
376
+ const gizmoHalfWorldSize = useMemo(
377
+ () => getGizmoWorldSize(coordinateInfo),
378
+ [coordinateInfo],
379
+ );
380
+
381
+ useEffect(() => {
382
+ if (!editMode) return;
383
+ let raf = 0;
384
+ const project = () => {
385
+ const renderer = getGlobalRenderer();
386
+ const camera = renderer?.getCamera();
387
+ const canvas = renderer?.getCanvas();
388
+ if (camera && canvas) {
389
+ const w = canvas.clientWidth;
390
+ const h = canvas.clientHeight;
391
+ const center = camera.projectToScreen(anchorWorld, w, h);
392
+ const heightAxisMeters = gizmoHalfWorldSize * 1.25;
393
+ const heightTip = camera.projectToScreen(
394
+ { ...anchorWorld, y: anchorWorld.y + heightAxisMeters },
395
+ w,
396
+ h,
397
+ );
398
+ const rotationRadians = deltaAngle * Math.PI / 180;
399
+ const ux = { x: Math.cos(rotationRadians), z: -Math.sin(rotationRadians) };
400
+ const uz = { x: Math.sin(rotationRadians), z: Math.cos(rotationRadians) };
401
+ const corner = (sx: number, sz: number) => camera.projectToScreen(
402
+ {
403
+ x: anchorWorld.x + ux.x * sx + uz.x * sz,
404
+ y: anchorWorld.y,
405
+ z: anchorWorld.z + ux.z * sx + uz.z * sz,
406
+ },
407
+ w,
408
+ h,
409
+ );
410
+ const c0 = corner(-gizmoHalfWorldSize, -gizmoHalfWorldSize);
411
+ const c1 = corner(gizmoHalfWorldSize, -gizmoHalfWorldSize);
412
+ const c2 = corner(gizmoHalfWorldSize, gizmoHalfWorldSize);
413
+ const c3 = corner(-gizmoHalfWorldSize, gizmoHalfWorldSize);
414
+ if (center && heightTip && c0 && c1 && c2 && c3) {
415
+ setProjection({
416
+ center,
417
+ heightTip,
418
+ planeCorners: [c0, c1, c2, c3],
419
+ });
420
+ }
421
+ }
422
+ raf = requestAnimationFrame(project);
423
+ };
424
+ project();
425
+ return () => cancelAnimationFrame(raf);
426
+ }, [anchorWorld, deltaAngle, editMode, gizmoHalfWorldSize]);
427
+
428
+ const handleHeightPointerDown = useCallback((e: React.PointerEvent<HTMLElement>) => {
429
+ if (!projection) return;
430
+ const ray = rayFromPointerEvent(e.clientX, e.clientY);
431
+ if (!ray) return;
432
+ const startWorldY = closestYOnVerticalLineFromRay(ray, anchorWorld.x, anchorWorld.z);
433
+ if (startWorldY === null) return;
434
+ e.preventDefault();
435
+ e.stopPropagation();
436
+ e.currentTarget.setPointerCapture(e.pointerId);
437
+ dragStateRef.current = {
438
+ mode: 'height',
439
+ startDraft: activeDraft,
440
+ anchorX: anchorWorld.x,
441
+ anchorZ: anchorWorld.z,
442
+ startWorldY,
443
+ };
444
+ }, [activeDraft, anchorWorld.x, anchorWorld.z, projection]);
445
+
446
+ const handlePlanePointerDown = useCallback((e: React.PointerEvent<HTMLElement>) => {
447
+ if (!projection) return;
448
+ const ray = rayFromPointerEvent(e.clientX, e.clientY);
449
+ if (!ray) return;
450
+ const startHit = intersectRayWithHorizontalPlane(ray, anchorWorld.y);
451
+ if (!startHit) return;
452
+ e.preventDefault();
453
+ e.stopPropagation();
454
+ e.currentTarget.setPointerCapture(e.pointerId);
455
+ dragStateRef.current = {
456
+ mode: 'xy',
457
+ startDraft: activeDraft,
458
+ planeY: anchorWorld.y,
459
+ startHit,
460
+ };
461
+ }, [activeDraft, anchorWorld.y, projection]);
462
+
463
+ const handlePointerMove = useCallback((e: React.PointerEvent<Element>) => {
464
+ const dragState = dragStateRef.current;
465
+ if (!dragState) return;
466
+ e.preventDefault();
467
+ e.stopPropagation();
468
+
469
+ const ray = rayFromPointerEvent(e.clientX, e.clientY);
470
+ if (!ray) return;
471
+
472
+ if (dragState.mode === 'height') {
473
+ const worldY = closestYOnVerticalLineFromRay(ray, dragState.anchorX, dragState.anchorZ);
474
+ if (worldY === null) return;
475
+ const deltaMeters = worldY - dragState.startWorldY;
476
+ updateDraft({
477
+ orthogonalHeight: round2(
478
+ dragState.startDraft.orthogonalHeight
479
+ + metersToMapUnits(deltaMeters, projectedCRS, lengthUnitScale),
480
+ ),
481
+ });
482
+ return;
483
+ }
484
+
485
+ const hit = intersectRayWithHorizontalPlane(ray, dragState.planeY);
486
+ if (!hit) return;
487
+ const deltaX = hit.x - dragState.startHit.x;
488
+ const deltaZ = hit.z - dragState.startHit.z;
489
+ // The horizontal-plane hit gives the world-space displacement directly;
490
+ // viewerDeltaToProjectedDelta then applies the file's xAxis rotation and
491
+ // unit scale to express it in Eastings/Northings.
492
+ const projectedDelta = viewerDeltaToProjectedDelta(
493
+ deltaX,
494
+ deltaZ,
495
+ dragState.startDraft,
496
+ projectedCRS,
497
+ lengthUnitScale,
498
+ );
499
+ updateDraft({
500
+ eastings: round2(dragState.startDraft.eastings + projectedDelta.eastings),
501
+ northings: round2(dragState.startDraft.northings + projectedDelta.northings),
502
+ });
503
+ }, [lengthUnitScale, projectedCRS, updateDraft]);
504
+
505
+ const handlePointerUp = useCallback((e: React.PointerEvent<Element>) => {
506
+ if (!dragStateRef.current) return;
507
+ dragStateRef.current = null;
508
+ try {
509
+ e.currentTarget.releasePointerCapture(e.pointerId);
510
+ } catch (_err) {
511
+ /* cleanup — safe to ignore: pointer already released by browser */
512
+ }
513
+ }, []);
514
+
515
+ const handleReset = useCallback(() => {
516
+ beginDraft(modelId, baseMapConversion);
517
+ }, [baseMapConversion, beginDraft, modelId]);
518
+
519
+ const handleApply = useCallback(() => {
520
+ if (!dirty) return;
521
+ setGeorefFields(modelId, 'mapConversion', [
522
+ { field: 'eastings', value: activeDraft.eastings, oldValue: baseMapConversion.eastings },
523
+ { field: 'northings', value: activeDraft.northings, oldValue: baseMapConversion.northings },
524
+ { field: 'orthogonalHeight', value: activeDraft.orthogonalHeight, oldValue: baseMapConversion.orthogonalHeight },
525
+ // MapConversion's cos/sin pair is optional in the IFC schema; fall back
526
+ // to the identity (1, 0) so the diff against an un-rotated source picks
527
+ // up the new explicit rotation rather than skipping the field entirely.
528
+ { field: 'xAxisAbscissa', value: activeDraft.xAxisAbscissa, oldValue: baseMapConversion.xAxisAbscissa ?? 1 },
529
+ { field: 'xAxisOrdinate', value: activeDraft.xAxisOrdinate, oldValue: baseMapConversion.xAxisOrdinate ?? 0 },
530
+ ]);
531
+ resetDraft();
532
+ toast.success('Georeference placement updated');
533
+ }, [activeDraft, baseMapConversion, dirty, modelId, resetDraft, setGeorefFields]);
534
+
535
+ const nudge = useCallback((eastDelta: number, northDelta: number) => {
536
+ updateDraft({
537
+ eastings: round2(activeDraft.eastings + eastDelta),
538
+ northings: round2(activeDraft.northings + northDelta),
539
+ });
540
+ }, [activeDraft.eastings, activeDraft.northings, updateDraft]);
541
+
542
+ const nudgeHeight = useCallback((heightDelta: number) => {
543
+ updateDraft({
544
+ orthogonalHeight: round2(activeDraft.orthogonalHeight + heightDelta),
545
+ });
546
+ }, [activeDraft.orthogonalHeight, updateDraft]);
547
+
548
+ const nudgeRotation = useCallback((angleDelta: number) => {
549
+ updateDraft(axisFromAngleDegrees(activeAngle + angleDelta));
550
+ }, [activeAngle, updateDraft]);
551
+
552
+ const handleClose = useCallback(() => {
553
+ setEditMode(false);
554
+ setActiveTool('select');
555
+ resetDraft();
556
+ }, [resetDraft, setActiveTool, setEditMode]);
557
+
558
+ if (!editMode) return null;
559
+
560
+ // The gizmo overlay (SVG axes + drag pads) renders only once we have a
561
+ // valid projection of the anchor; the side panel renders unconditionally
562
+ // so the user can still nudge, apply, or close even if the gizmo is
563
+ // off-screen or behind the camera.
564
+ const gizmoVisuals = projection
565
+ ? (() => {
566
+ const planePoints = projection.planeCorners
567
+ .map((point) => `${point.x},${point.y}`)
568
+ .join(' ');
569
+ const minPlaneX = Math.min(...projection.planeCorners.map((p) => p.x));
570
+ const maxPlaneX = Math.max(...projection.planeCorners.map((p) => p.x));
571
+ const minPlaneY = Math.min(...projection.planeCorners.map((p) => p.y));
572
+ const maxPlaneY = Math.max(...projection.planeCorners.map((p) => p.y));
573
+ const hitPadding = 16;
574
+ const [c0, c1, c2, c3] = projection.planeCorners;
575
+ const xAxisStart = { x: (c0.x + c3.x) / 2, y: (c0.y + c3.y) / 2 };
576
+ const xAxisEnd = { x: (c1.x + c2.x) / 2, y: (c1.y + c2.y) / 2 };
577
+ const zAxisStart = { x: (c0.x + c1.x) / 2, y: (c0.y + c1.y) / 2 };
578
+ const zAxisEnd = { x: (c2.x + c3.x) / 2, y: (c2.y + c3.y) / 2 };
579
+ return {
580
+ planePoints,
581
+ minPlaneX,
582
+ maxPlaneX,
583
+ minPlaneY,
584
+ maxPlaneY,
585
+ hitPadding,
586
+ xAxisStart,
587
+ xAxisEnd,
588
+ zAxisStart,
589
+ zAxisEnd,
590
+ };
591
+ })()
592
+ : null;
593
+
594
+ const renderGizmo = () => {
595
+ if (!gizmoVisuals || !projection) return null;
596
+ const {
597
+ planePoints,
598
+ minPlaneX,
599
+ maxPlaneX,
600
+ minPlaneY,
601
+ maxPlaneY,
602
+ hitPadding,
603
+ xAxisStart,
604
+ xAxisEnd,
605
+ zAxisStart,
606
+ zAxisEnd,
607
+ } = gizmoVisuals;
608
+ return (
609
+ <>
610
+ <svg className="absolute inset-0 z-20 h-full w-full pointer-events-none">
611
+ <defs>
612
+ <pattern id="cesium-placement-grid" width="12" height="12" patternUnits="userSpaceOnUse">
613
+ <path d="M 12 0 L 0 0 0 12" fill="none" stroke="rgb(45 212 191)" strokeWidth="0.8" opacity="0.45" />
614
+ </pattern>
615
+ </defs>
616
+ <g style={{ pointerEvents: 'auto' }}>
617
+ <polygon
618
+ points={planePoints}
619
+ fill="url(#cesium-placement-grid)"
620
+ stroke="rgb(45 212 191)"
621
+ strokeWidth="3"
622
+ opacity="0.92"
623
+ pointerEvents="none"
624
+ >
625
+ <title>Drag to move Eastings/Northings</title>
626
+ </polygon>
627
+ <line
628
+ x1={xAxisStart.x}
629
+ y1={xAxisStart.y}
630
+ x2={xAxisEnd.x}
631
+ y2={xAxisEnd.y}
632
+ stroke="white"
633
+ strokeWidth="2"
634
+ opacity="0.8"
635
+ pointerEvents="none"
636
+ />
637
+ <line
638
+ x1={zAxisStart.x}
639
+ y1={zAxisStart.y}
640
+ x2={zAxisEnd.x}
641
+ y2={zAxisEnd.y}
642
+ stroke="white"
643
+ strokeWidth="2"
644
+ opacity="0.8"
645
+ pointerEvents="none"
646
+ />
647
+ <text
648
+ x={projection.center.x}
649
+ y={maxPlaneY + 22}
650
+ textAnchor="middle"
651
+ fill="rgb(153 246 228)"
652
+ fontSize="11"
653
+ fontFamily="monospace"
654
+ fontWeight="700"
655
+ pointerEvents="none"
656
+ >
657
+ DRAG XY
658
+ </text>
659
+ <line
660
+ x1={projection.center.x}
661
+ y1={projection.center.y}
662
+ x2={projection.heightTip.x}
663
+ y2={projection.heightTip.y}
664
+ stroke="rgb(251 191 36)"
665
+ strokeWidth="3"
666
+ strokeLinecap="round"
667
+ opacity="0.95"
668
+ />
669
+ <circle
670
+ cx={projection.heightTip.x}
671
+ cy={projection.heightTip.y}
672
+ r="10"
673
+ fill="rgb(251 191 36)"
674
+ stroke="white"
675
+ strokeWidth="2"
676
+ cursor="grab"
677
+ pointerEvents="none"
678
+ >
679
+ <title>Drag to change OrthogonalHeight</title>
680
+ </circle>
681
+ <circle
682
+ cx={projection.center.x}
683
+ cy={projection.center.y}
684
+ r="4"
685
+ fill="white"
686
+ stroke="rgb(45 212 191)"
687
+ strokeWidth="2"
688
+ />
689
+ </g>
690
+ </svg>
691
+
692
+ <button
693
+ type="button"
694
+ aria-label="Drag Eastings and Northings"
695
+ className="absolute z-[21] cursor-grab bg-transparent active:cursor-grabbing"
696
+ style={{
697
+ left: minPlaneX - hitPadding,
698
+ top: minPlaneY - hitPadding,
699
+ width: Math.max(56, maxPlaneX - minPlaneX + hitPadding * 2),
700
+ height: Math.max(56, maxPlaneY - minPlaneY + hitPadding * 2),
701
+ }}
702
+ onPointerDown={handlePlanePointerDown}
703
+ onPointerMove={handlePointerMove}
704
+ onPointerUp={handlePointerUp}
705
+ onPointerCancel={handlePointerUp}
706
+ />
707
+ <button
708
+ type="button"
709
+ aria-label="Drag OrthogonalHeight"
710
+ className="absolute z-[22] cursor-grab rounded-full bg-transparent active:cursor-grabbing"
711
+ style={{
712
+ left: projection.heightTip.x - 18,
713
+ top: projection.heightTip.y - 18,
714
+ width: 36,
715
+ height: 36,
716
+ }}
717
+ onPointerDown={handleHeightPointerDown}
718
+ onPointerMove={handlePointerMove}
719
+ onPointerUp={handlePointerUp}
720
+ onPointerCancel={handlePointerUp}
721
+ />
722
+ </>
723
+ );
724
+ };
725
+
726
+ return (
727
+ <>
728
+ {renderGizmo()}
729
+
730
+ <div
731
+ ref={panelRef}
732
+ className={cn(
733
+ 'absolute z-30 select-none border-2 border-zinc-900 dark:border-zinc-100',
734
+ 'bg-white text-zinc-900 dark:bg-black dark:text-zinc-100 font-mono',
735
+ )}
736
+ style={
737
+ // First render (no saved position yet): anchor bottom-right via
738
+ // CSS right/bottom so the panel appears immediately, no
739
+ // measure-first flicker, no offsetParent dependency. Once the
740
+ // user drags (or a saved position is restored), switch to
741
+ // absolute left/top in container-local coordinates.
742
+ panelPosition
743
+ ? {
744
+ left: panelPosition.x,
745
+ top: panelPosition.y,
746
+ width: PANEL_WIDTH,
747
+ boxShadow: '4px 4px 0px 0px rgba(0,0,0,0.35)',
748
+ }
749
+ : {
750
+ right: PANEL_MARGIN,
751
+ bottom: PANEL_MARGIN,
752
+ width: PANEL_WIDTH,
753
+ boxShadow: '4px 4px 0px 0px rgba(0,0,0,0.35)',
754
+ }
755
+ }
756
+ >
757
+ {/* Header — drag handle, title, dirty indicator, collapse/close */}
758
+ <div
759
+ className={cn(
760
+ 'flex items-center gap-2 px-2.5 py-2 border-b-2 border-zinc-900 dark:border-zinc-100',
761
+ 'bg-zinc-50 dark:bg-zinc-950 cursor-grab active:cursor-grabbing touch-none',
762
+ )}
763
+ onPointerDown={handlePanelHeaderPointerDown}
764
+ onPointerMove={handlePanelHeaderPointerMove}
765
+ onPointerUp={handlePanelHeaderPointerUp}
766
+ onPointerCancel={handlePanelHeaderPointerUp}
767
+ role="toolbar"
768
+ aria-label="Move georeference panel header"
769
+ >
770
+ <GripVertical className="h-3.5 w-3.5 text-zinc-400 dark:text-zinc-600" aria-hidden />
771
+ <Move className="h-3.5 w-3.5 text-emerald-600 dark:text-emerald-400" aria-hidden />
772
+ <span className="text-[11px] font-bold uppercase tracking-[0.18em]">
773
+ Move Georef
774
+ </span>
775
+ {dirty && (
776
+ <span
777
+ className="h-1.5 w-1.5 bg-amber-500"
778
+ title="Unsaved changes"
779
+ aria-label="Unsaved changes"
780
+ />
781
+ )}
782
+ <div className="ml-auto flex items-center gap-0.5">
783
+ <button
784
+ type="button"
785
+ data-panel-action
786
+ onClick={togglePanelCollapsed}
787
+ className={cn(
788
+ 'inline-flex h-6 w-6 items-center justify-center border border-transparent',
789
+ 'hover:bg-zinc-200 dark:hover:bg-zinc-800',
790
+ )}
791
+ aria-label={panelCollapsed ? 'Expand panel' : 'Collapse panel'}
792
+ aria-expanded={!panelCollapsed}
793
+ >
794
+ <ChevronDown
795
+ className={cn(
796
+ 'h-3.5 w-3.5 transition-transform',
797
+ panelCollapsed && '-rotate-90',
798
+ )}
799
+ />
800
+ </button>
801
+ <button
802
+ type="button"
803
+ data-panel-action
804
+ onClick={handleClose}
805
+ className={cn(
806
+ 'inline-flex h-6 w-6 items-center justify-center border border-transparent',
807
+ 'hover:bg-zinc-200 dark:hover:bg-zinc-800',
808
+ )}
809
+ aria-label="Close georeference move mode"
810
+ >
811
+ <X className="h-3.5 w-3.5" />
812
+ </button>
813
+ </div>
814
+ </div>
815
+
816
+ {!panelCollapsed && (
817
+ <div className="p-3 text-[10px]">
818
+ <div className="grid grid-cols-4 gap-1 border-b border-zinc-200 dark:border-zinc-800 pb-2">
819
+ <Metric
820
+ label="Delta E"
821
+ value={formatSigned(deltaE, mapUnitSuffix)}
822
+ accent="text-emerald-700 dark:text-emerald-300"
823
+ />
824
+ <Metric
825
+ label="Delta N"
826
+ value={formatSigned(deltaN, mapUnitSuffix)}
827
+ accent="text-emerald-700 dark:text-emerald-300"
828
+ />
829
+ <Metric
830
+ label="Delta Z"
831
+ value={formatSigned(deltaH, mapUnitSuffix)}
832
+ accent="text-amber-700 dark:text-amber-300"
833
+ />
834
+ <Metric
835
+ label="Delta R"
836
+ value={formatSigned(deltaAngle, 'deg')}
837
+ accent="text-fuchsia-700 dark:text-fuchsia-300"
838
+ />
839
+ </div>
840
+
841
+ <div className="mt-2 space-y-1">
842
+ <div className="pb-1 text-[9px] leading-snug text-zinc-500 dark:text-zinc-400">
843
+ Drag the pad on the model to move Eastings/Northings. Drag the
844
+ knob to change height.
845
+ </div>
846
+ <PreviewRow
847
+ label="Eastings"
848
+ value={`${activeDraft.eastings.toFixed(2)} ${mapUnitSuffix}`}
849
+ />
850
+ <PreviewRow
851
+ label="Northings"
852
+ value={`${activeDraft.northings.toFixed(2)} ${mapUnitSuffix}`}
853
+ />
854
+ <PreviewRow
855
+ label="OrthogonalHeight"
856
+ value={`${activeDraft.orthogonalHeight.toFixed(2)} ${mapUnitSuffix}`}
857
+ />
858
+ <PreviewRow label="XAxis angle" value={`${activeAngle.toFixed(2)} deg`} />
859
+ </div>
860
+
861
+ <div className="mt-3 grid grid-cols-[1fr_auto_1fr] items-center gap-1 text-[9px]">
862
+ <span className="text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">
863
+ Nudge 1 m
864
+ </span>
865
+ <NudgeButton onClick={() => nudge(0, nudgeStep)} aria-label="Nudge north">
866
+ N+
867
+ </NudgeButton>
868
+ <span />
869
+ <NudgeButton onClick={() => nudge(-nudgeStep, 0)} aria-label="Nudge west">
870
+ E-
871
+ </NudgeButton>
872
+ <NudgeButton onClick={() => nudge(nudgeStep, 0)} aria-label="Nudge east">
873
+ E+
874
+ </NudgeButton>
875
+ <NudgeButton onClick={() => nudge(0, -nudgeStep)} aria-label="Nudge south">
876
+ N-
877
+ </NudgeButton>
878
+ </div>
879
+
880
+ <div className="mt-2 flex items-center gap-1 text-[9px]">
881
+ <span className="mr-auto text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">
882
+ Height
883
+ </span>
884
+ <NudgeButton
885
+ onClick={() => nudgeHeight(-nudgeStep)}
886
+ tone="amber"
887
+ aria-label="Nudge height down"
888
+ >
889
+ Z-
890
+ </NudgeButton>
891
+ <NudgeButton
892
+ onClick={() => nudgeHeight(nudgeStep)}
893
+ tone="amber"
894
+ aria-label="Nudge height up"
895
+ >
896
+ Z+
897
+ </NudgeButton>
898
+ </div>
899
+
900
+ <div className="mt-2 flex items-center gap-1 text-[9px]">
901
+ <span className="mr-auto text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">
902
+ Rotate
903
+ </span>
904
+ <NudgeButton
905
+ onClick={() => nudgeRotation(-1)}
906
+ tone="fuchsia"
907
+ aria-label="Rotate negative one degree"
908
+ >
909
+ R-
910
+ </NudgeButton>
911
+ <NudgeButton
912
+ onClick={() => nudgeRotation(1)}
913
+ tone="fuchsia"
914
+ aria-label="Rotate positive one degree"
915
+ >
916
+ R+
917
+ </NudgeButton>
918
+ </div>
919
+
920
+ <div className="mt-3 flex items-center gap-2">
921
+ <button
922
+ type="button"
923
+ onClick={handleApply}
924
+ disabled={!dirty}
925
+ className={cn(
926
+ 'inline-flex flex-1 items-center justify-center gap-1.5 border-2 px-2 py-1.5 text-[10px] font-bold uppercase tracking-wide transition-colors',
927
+ dirty
928
+ ? 'border-emerald-600 bg-emerald-600 text-white hover:bg-emerald-700 hover:border-emerald-700 dark:border-emerald-500 dark:bg-emerald-500 dark:text-zinc-950 dark:hover:bg-emerald-400 dark:hover:border-emerald-400'
929
+ : 'border-zinc-300 bg-zinc-100 text-zinc-400 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-600 cursor-not-allowed',
930
+ )}
931
+ >
932
+ <Check className="h-3 w-3" />
933
+ Set as georeference
934
+ </button>
935
+ <button
936
+ type="button"
937
+ onClick={handleReset}
938
+ disabled={!dirty}
939
+ className={cn(
940
+ 'inline-flex items-center justify-center gap-1 border-2 px-2 py-1.5 text-[10px] uppercase tracking-wide transition-colors',
941
+ dirty
942
+ ? 'border-zinc-300 bg-white text-zinc-700 hover:bg-zinc-100 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-200 dark:hover:bg-zinc-800'
943
+ : 'border-zinc-200 bg-zinc-50 text-zinc-400 dark:border-zinc-900 dark:bg-zinc-950 dark:text-zinc-600 cursor-not-allowed',
944
+ )}
945
+ >
946
+ <RotateCcw className="h-3 w-3" />
947
+ Reset
948
+ </button>
949
+ </div>
950
+ </div>
951
+ )}
952
+ </div>
953
+ </>
954
+ );
955
+ }
956
+
957
+ function Metric({ label, value, accent }: { label: string; value: string; accent: string }) {
958
+ return (
959
+ <div>
960
+ <div className="text-[8px] uppercase tracking-[0.16em] text-zinc-500 dark:text-zinc-400">
961
+ {label}
962
+ </div>
963
+ <div className={cn('mt-0.5 whitespace-nowrap text-[10px] font-semibold', accent)}>
964
+ {value}
965
+ </div>
966
+ </div>
967
+ );
968
+ }
969
+
970
+ function PreviewRow({ label, value }: { label: string; value: string }) {
971
+ return (
972
+ <div className="flex items-center justify-between gap-3">
973
+ <span className="text-zinc-500 dark:text-zinc-400">{label}</span>
974
+ <span className="text-zinc-900 dark:text-zinc-100">{value}</span>
975
+ </div>
976
+ );
977
+ }
978
+
979
+ type NudgeButtonProps = {
980
+ children: React.ReactNode;
981
+ onClick: () => void;
982
+ tone?: 'emerald' | 'amber' | 'fuchsia';
983
+ } & Pick<React.ButtonHTMLAttributes<HTMLButtonElement>, 'aria-label'>;
984
+
985
+ function NudgeButton({ children, onClick, tone = 'emerald', ...rest }: NudgeButtonProps) {
986
+ const toneClass = {
987
+ emerald:
988
+ 'text-emerald-700 hover:bg-emerald-50 hover:border-emerald-600 dark:text-emerald-300 dark:hover:bg-emerald-950 dark:hover:border-emerald-400',
989
+ amber:
990
+ 'text-amber-700 hover:bg-amber-50 hover:border-amber-600 dark:text-amber-300 dark:hover:bg-amber-950 dark:hover:border-amber-400',
991
+ fuchsia:
992
+ 'text-fuchsia-700 hover:bg-fuchsia-50 hover:border-fuchsia-600 dark:text-fuchsia-300 dark:hover:bg-fuchsia-950 dark:hover:border-fuchsia-400',
993
+ }[tone];
994
+
995
+ return (
996
+ <button
997
+ type="button"
998
+ onClick={onClick}
999
+ className={cn(
1000
+ 'border-2 border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900',
1001
+ 'px-2 py-1 font-bold tracking-wider transition-colors',
1002
+ toneClass,
1003
+ )}
1004
+ {...rest}
1005
+ >
1006
+ {children}
1007
+ </button>
1008
+ );
1009
+ }