@pascal-app/editor 1.0.0-beta.3 → 1.0.0-beta.5

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 (73) hide show
  1. package/package.json +7 -6
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +12 -3
  3. package/src/components/editor/editor-layout-v2.tsx +8 -3
  4. package/src/components/editor/elevation-3d-guide-layer.tsx +3 -2
  5. package/src/components/editor/first-person-controls.tsx +2 -45
  6. package/src/components/editor/floating-action-menu.tsx +13 -4
  7. package/src/components/editor/floorplan-background-selection.test.ts +31 -4
  8. package/src/components/editor/floorplan-background-selection.ts +27 -7
  9. package/src/components/editor/floorplan-panel.tsx +5 -45
  10. package/src/components/editor/group-floating-action-menu.tsx +30 -15
  11. package/src/components/editor/index.tsx +112 -25
  12. package/src/components/editor/measurement-pill.tsx +12 -12
  13. package/src/components/editor/node-action-menu.tsx +29 -1
  14. package/src/components/editor/opening-guides-3d-layer.tsx +13 -4
  15. package/src/components/editor/selection-manager.tsx +28 -5
  16. package/src/components/editor/site-edge-labels.tsx +2 -1
  17. package/src/components/editor/snapshot-capture-overlay.tsx +11 -2
  18. package/src/components/editor/thumbnail-generator.tsx +27 -11
  19. package/src/components/editor/wall-measurement-label.tsx +3 -2
  20. package/src/components/editor-2d/floorplan-group-action-menu.tsx +23 -16
  21. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +26 -84
  22. package/src/components/tools/item/placement-strategies.test.ts +167 -0
  23. package/src/components/tools/item/placement-strategies.ts +57 -22
  24. package/src/components/tools/item/use-placement-coordinator.tsx +19 -27
  25. package/src/components/tools/shared/placement-box.tsx +9 -4
  26. package/src/components/tools/site/terrain-brush-cursor.tsx +2 -1
  27. package/src/components/tools/wall/wall-drafting.test.ts +207 -2
  28. package/src/components/tools/wall/wall-drafting.ts +84 -400
  29. package/src/components/ui/command-palette/editor-commands.tsx +3 -1
  30. package/src/components/ui/floating-level-selector.tsx +1 -1
  31. package/src/components/ui/panels/multi-selection-panel.tsx +54 -13
  32. package/src/components/ui/panels/panel-wrapper.tsx +228 -12
  33. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +7 -2
  34. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +11 -0
  35. package/src/components/ui/sidebar/panels/site-panel/index.tsx +12 -0
  36. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +8 -1
  37. package/src/components/ui/sidebar/tab-bar.tsx +7 -1
  38. package/src/components/viewer/floorplan-compass-button.tsx +50 -0
  39. package/src/components/viewer/floorplan-preview-geometry.test.ts +107 -0
  40. package/src/components/viewer/floorplan-preview-geometry.ts +281 -0
  41. package/src/components/viewer/floorplan-preview-navigation.test.ts +43 -0
  42. package/src/components/viewer/floorplan-preview-navigation.ts +62 -0
  43. package/src/components/viewer/floorplan-preview.tsx +1069 -0
  44. package/src/components/viewer/use-viewer-camera-navigation-sync.ts +140 -0
  45. package/src/components/viewer/viewer-stage-modes.test.ts +38 -0
  46. package/src/components/viewer/viewer-stage-modes.ts +49 -0
  47. package/src/components/viewer/viewer-stage-switcher.tsx +95 -0
  48. package/src/components/viewer/viewer-stage.test.tsx +47 -0
  49. package/src/components/viewer/viewer-stage.tsx +218 -0
  50. package/src/components/viewer-overlay.tsx +12 -8
  51. package/src/hooks/use-auto-save.test.ts +50 -1
  52. package/src/hooks/use-auto-save.ts +44 -9
  53. package/src/hooks/use-keyboard.ts +35 -1
  54. package/src/index.tsx +27 -0
  55. package/src/lib/contextual-help.test.ts +8 -0
  56. package/src/lib/contextual-help.ts +8 -0
  57. package/src/lib/floorplan/floorplan-readonly.test.ts +47 -0
  58. package/src/lib/floorplan/floorplan-readonly.ts +84 -0
  59. package/src/lib/history.test.ts +75 -10
  60. package/src/lib/history.ts +58 -13
  61. package/src/lib/inspector-card-mode.test.ts +92 -0
  62. package/src/lib/inspector-card-mode.ts +60 -0
  63. package/src/lib/measurement-parser.ts +3 -0
  64. package/src/lib/measurements.test.ts +4 -0
  65. package/src/lib/paint-scope.test.ts +58 -2
  66. package/src/lib/paint-scope.ts +8 -2
  67. package/src/lib/selection-routing.test.ts +20 -5
  68. package/src/lib/selection-routing.ts +15 -1
  69. package/src/lib/session-groups.test.ts +88 -0
  70. package/src/lib/session-groups.ts +201 -0
  71. package/src/lib/structured-clone-fallback.test.ts +45 -0
  72. package/src/lib/structured-clone-fallback.ts +24 -0
  73. package/src/store/use-session-groups.ts +103 -0
@@ -0,0 +1,1069 @@
1
+ 'use client'
2
+
3
+ import {
4
+ type AnyNode,
5
+ type AnyNodeId,
6
+ type FloorplanGeometry,
7
+ type FloorplanPalette,
8
+ type GeometryContext,
9
+ isNodeKindEnabled,
10
+ nodeRegistry,
11
+ useScene,
12
+ } from '@pascal-app/core'
13
+ import { AnyNode as AnyNodeSchema } from '@pascal-app/core/schema'
14
+ import { useViewer } from '@pascal-app/viewer'
15
+ import { Maximize2, Minus, Plus } from 'lucide-react'
16
+ import {
17
+ type KeyboardEvent as ReactKeyboardEvent,
18
+ type PointerEvent as ReactPointerEvent,
19
+ type WheelEvent as ReactWheelEvent,
20
+ useCallback,
21
+ useEffect,
22
+ useId,
23
+ useLayoutEffect,
24
+ useMemo,
25
+ useRef,
26
+ useState,
27
+ } from 'react'
28
+ import { createPortal } from 'react-dom'
29
+ import {
30
+ FLOORPLAN_VIEW_ROTATION_DEG,
31
+ floorplanLocalToWorldPoint,
32
+ worldToFloorplanLocalPoint,
33
+ } from '../../lib/floorplan'
34
+ import { getFloorplanNodeExtension } from '../../lib/floorplan/floorplan-extension'
35
+ import { buildFloorplanContext, floorplanLayerRank } from '../../lib/floorplan/floorplan-readonly'
36
+ import { subscribeNavigationSyncPose } from '../../store/navigation-sync-pose-store'
37
+ import useEditor, { type NavigationSyncPose } from '../../store/use-editor'
38
+ import {
39
+ subscribeFloorplanCameraNavigation,
40
+ useFloorplanCameraSyncBridge,
41
+ } from '../editor/floorplan-camera-sync'
42
+ import {
43
+ createFloorplanNavigationSyncScheduler,
44
+ setFloorplanCompassRotation,
45
+ } from '../editor/floorplan-navigation-presentation'
46
+ import { FloorplanGeometryRenderer } from '../editor-2d/renderers/floorplan-geometry-renderer'
47
+ import { FloorplanCompassButton } from './floorplan-compass-button'
48
+ import {
49
+ type FloorplanBounds,
50
+ type FloorplanViewBox,
51
+ getFloorplanBounds,
52
+ padFloorplanBounds,
53
+ panFloorplanViewBox,
54
+ scaleFloorplanViewBox,
55
+ scaleFloorplanViewBoxBetweenClients,
56
+ } from './floorplan-preview-geometry'
57
+ import {
58
+ cameraAzimuthFromFloorplanRotation,
59
+ floorplanRotationFromCameraAzimuth,
60
+ floorplanViewBoxFromNavigationPose,
61
+ nearestEquivalentDegrees,
62
+ rotateFloorplanPoint,
63
+ visibleFloorplanViewWidth,
64
+ } from './floorplan-preview-navigation'
65
+
66
+ const READ_ONLY_PALETTE: FloorplanPalette = {
67
+ selectedStroke: '#4f46e5',
68
+ selectedFill: '#e0e7ff',
69
+ selectedHatch: '#818cf8',
70
+ wallHoverStroke: '#64748b',
71
+ endpointHandleFill: '#ffffff',
72
+ endpointHandleStroke: '#f97316',
73
+ endpointHandleHoverStroke: '#fb923c',
74
+ endpointHandleActiveFill: '#fed7aa',
75
+ endpointHandleActiveStroke: '#ea580c',
76
+ curveHandleFill: '#ffffff',
77
+ curveHandleStroke: '#0d9488',
78
+ curveHandleHoverStroke: '#14b8a6',
79
+ measurementStroke: '#475569',
80
+ measurementLabelBackground: '#ffffff',
81
+ measurementLabelText: '#0f172a',
82
+ }
83
+ const EMPTY_PREVIEW_NODES: Record<string, AnyNode> = {}
84
+ const EMPTY_INSTALLED_PLUGINS: readonly string[] = []
85
+
86
+ export type FloorplanPreviewScene = {
87
+ nodes: Record<string, AnyNode>
88
+ installedPlugins?: readonly string[]
89
+ }
90
+
91
+ export type FloorplanPreviewProps = {
92
+ className?: string
93
+ compassHost?: Element | null
94
+ levelId?: string | null
95
+ navigationVisible?: boolean
96
+ onLevelChange?: (levelId: string) => void
97
+ scene?: FloorplanPreviewScene | null
98
+ showCompass?: boolean
99
+ showLevelSelector?: boolean
100
+ synchronizeNavigation?: boolean
101
+ }
102
+
103
+ type PointerPoint = { x: number; y: number }
104
+ type DragState = {
105
+ mode: 'pan' | 'rotate'
106
+ pointerId: number
107
+ point: PointerPoint
108
+ rotationDeg: number
109
+ viewBox: FloorplanViewBox
110
+ }
111
+ type PinchState = {
112
+ pointerIds: [number, number]
113
+ midpoint: PointerPoint
114
+ distance: number
115
+ viewBox: FloorplanViewBox
116
+ }
117
+ type FloorplanRenderEntry = {
118
+ id: string
119
+ geometry: FloorplanGeometry
120
+ includeInInitialFit: boolean
121
+ }
122
+ type FloorplanSourceEntry = {
123
+ node: AnyNode
124
+ contextOverrides?: Pick<GeometryContext, 'children' | 'siblings' | 'parent'>
125
+ }
126
+ type MeasuredFit = { key: string; bounds: FloorplanBounds }
127
+
128
+ const useClientLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect
129
+
130
+ function FloorplanCameraSyncMount() {
131
+ useFloorplanCameraSyncBridge()
132
+ return null
133
+ }
134
+
135
+ function boundsToViewBox(bounds: FloorplanBounds): FloorplanViewBox {
136
+ return {
137
+ x: bounds.minX,
138
+ y: bounds.minY,
139
+ width: Math.max(bounds.maxX - bounds.minX, 1),
140
+ height: Math.max(bounds.maxY - bounds.minY, 1),
141
+ }
142
+ }
143
+
144
+ function levelLabel(level: AnyNode): string {
145
+ const named = (level as { name?: string }).name?.trim()
146
+ if (named) return named
147
+ const ordinal = (level as { level?: number }).level ?? 0
148
+ if (ordinal === 0) return 'Ground floor'
149
+ if (ordinal < 0) return `Basement ${Math.abs(ordinal)}`
150
+ return `Level ${ordinal}`
151
+ }
152
+
153
+ function collectLevelTree(root: AnyNode, nodes: Record<string, AnyNode>): AnyNode[] {
154
+ const result: AnyNode[] = [root]
155
+ const seen = new Set<string>()
156
+ const queue = [...((root as { children?: AnyNodeId[] }).children ?? [])]
157
+ let index = 0
158
+ while (index < queue.length) {
159
+ const id = queue[index++]
160
+ if (!id || seen.has(id)) continue
161
+ seen.add(id)
162
+ const node = nodes[id]
163
+ if (!node) continue
164
+ result.push(node)
165
+ queue.push(...((node as { children?: AnyNodeId[] }).children ?? []))
166
+ }
167
+ return result
168
+ }
169
+
170
+ export function normalizeFloorplanPreviewNodes(
171
+ nodes: Record<string, unknown>,
172
+ ): Record<string, AnyNode> {
173
+ const normalized: Record<string, AnyNode> = {}
174
+ for (const [id, node] of Object.entries(nodes)) {
175
+ const builtin = AnyNodeSchema.safeParse(node)
176
+ if (builtin.success) {
177
+ normalized[builtin.data.id] = builtin.data
178
+ continue
179
+ }
180
+ const type =
181
+ node && typeof node === 'object' && !Array.isArray(node)
182
+ ? (node as { type?: unknown }).type
183
+ : null
184
+ const registered =
185
+ typeof type === 'string' ? nodeRegistry.get(type)?.schema.safeParse(node) : null
186
+ if (registered?.success) {
187
+ const parsed = registered.data as AnyNode
188
+ normalized[parsed.id] = parsed
189
+ } else {
190
+ console.warn(`[floorplan-preview] Skipping invalid node ${id}`, builtin.error.issues)
191
+ }
192
+ }
193
+ return normalized
194
+ }
195
+
196
+ function isVisibleInFloorplan(node: AnyNode, nodes: Record<string, AnyNode>): boolean {
197
+ const seen = new Set<string>()
198
+ let current: AnyNode | undefined = node
199
+ while (current) {
200
+ if (seen.has(current.id)) return true
201
+ seen.add(current.id)
202
+ if (current.visible === false) return false
203
+ current = current.parentId ? nodes[current.parentId] : undefined
204
+ }
205
+ return true
206
+ }
207
+
208
+ function buildFloorplanGeometries(
209
+ nodes: Record<string, AnyNode>,
210
+ installedPlugins: readonly string[] | undefined,
211
+ level: AnyNode,
212
+ unit: 'metric' | 'imperial',
213
+ metricNotation: 'meters' | 'millimeters',
214
+ ): FloorplanRenderEntry[] {
215
+ const building = level.parentId ? nodes[level.parentId] : undefined
216
+ const levelTree = collectLevelTree(level, nodes)
217
+ const entries: FloorplanSourceEntry[] = levelTree.map((node) => ({ node }))
218
+ const entryIds = new Set(entries.map((entry) => entry.node.id))
219
+ if (building) {
220
+ for (const candidate of Object.values(nodes)) {
221
+ const definition = nodeRegistry.get(candidate.type)
222
+ if (
223
+ definition?.floorplanScope === 'building' &&
224
+ candidate.parentId === building.id &&
225
+ !entryIds.has(candidate.id)
226
+ ) {
227
+ entries.push({
228
+ node: candidate,
229
+ contextOverrides: { children: [], siblings: [], parent: level },
230
+ })
231
+ entryIds.add(candidate.id)
232
+ }
233
+ }
234
+ }
235
+ for (const candidate of Object.values(nodes)) {
236
+ const definition = nodeRegistry.get(candidate.type)
237
+ const linkedLevelIds = getFloorplanNodeExtension(definition)?.linkedLevelIds
238
+ if (
239
+ definition?.floorplan &&
240
+ linkedLevelIds?.(candidate).includes(level.id) &&
241
+ !entryIds.has(candidate.id)
242
+ ) {
243
+ const childIds = (candidate as { children?: AnyNodeId[] }).children
244
+ const children = Array.isArray(childIds)
245
+ ? childIds.map((id) => nodes[id]).filter((child): child is AnyNode => child !== undefined)
246
+ : []
247
+ entries.push({
248
+ node: candidate,
249
+ contextOverrides: { children, siblings: [], parent: level },
250
+ })
251
+ entryIds.add(candidate.id)
252
+ }
253
+ }
254
+
255
+ const renderable = entries
256
+ .filter((entry) => isVisibleInFloorplan(entry.node, nodes))
257
+ .filter((entry) => isNodeKindEnabled(entry.node.type, installedPlugins))
258
+ .filter((entry) => nodeRegistry.get(entry.node.type)?.floorplan)
259
+ .sort((a, b) => floorplanLayerRank(a.node.type) - floorplanLayerRank(b.node.type))
260
+ const byType = new Map<string, AnyNode[]>()
261
+ for (const node of levelTree) {
262
+ if (!isNodeKindEnabled(node.type, installedPlugins)) continue
263
+ if (!nodeRegistry.get(node.type)?.computeFloorplanLevelData) continue
264
+ const siblings = byType.get(node.type) ?? []
265
+ siblings.push(node)
266
+ byType.set(node.type, siblings)
267
+ }
268
+ const levelData = new Map<string, unknown>()
269
+ for (const [type, siblings] of byType) {
270
+ const definition = nodeRegistry.get(type)
271
+ if (definition?.computeFloorplanLevelData) {
272
+ levelData.set(type, definition.computeFloorplanLevelData({ siblings, nodes }))
273
+ }
274
+ }
275
+
276
+ const geometries: FloorplanRenderEntry[] = []
277
+ for (const { node, contextOverrides } of renderable) {
278
+ const definition = nodeRegistry.get(node.type)
279
+ if (!definition?.floorplan) continue
280
+ const baseContext = buildFloorplanContext(
281
+ node,
282
+ nodes,
283
+ {
284
+ automaticDimensions: false,
285
+ selected: false,
286
+ unit,
287
+ metricNotation,
288
+ purpose: 'document',
289
+ highlighted: false,
290
+ hovered: false,
291
+ moving: false,
292
+ palette: READ_ONLY_PALETTE,
293
+ },
294
+ levelData.get(node.type),
295
+ )
296
+ const context = contextOverrides ? { ...baseContext, ...contextOverrides } : baseContext
297
+ try {
298
+ const geometry = definition.floorplan(node as never, context)
299
+ if (geometry) {
300
+ geometries.push({
301
+ id: node.id,
302
+ geometry,
303
+ includeInInitialFit: definition.category !== 'furnish',
304
+ })
305
+ }
306
+ } catch (error) {
307
+ console.error(`[floorplan-preview] Failed to render ${node.type}:${node.id}`, error)
308
+ }
309
+ }
310
+ return geometries
311
+ }
312
+
313
+ export function FloorplanPreview({
314
+ className,
315
+ compassHost,
316
+ levelId,
317
+ navigationVisible = true,
318
+ onLevelChange,
319
+ scene,
320
+ showCompass = true,
321
+ showLevelSelector = true,
322
+ synchronizeNavigation = false,
323
+ }: FloorplanPreviewProps) {
324
+ const storeNodes = useScene((state) => (scene ? EMPTY_PREVIEW_NODES : state.nodes))
325
+ const storeInstalledPlugins = useScene((state) =>
326
+ scene ? EMPTY_INSTALLED_PLUGINS : state.installedPlugins,
327
+ )
328
+ const unit = useViewer((state) => state.unit)
329
+ const metricNotation = useViewer((state) => state.metricNotation)
330
+ const externalNodes = useMemo(
331
+ () => (scene?.nodes ? normalizeFloorplanPreviewNodes(scene.nodes) : null),
332
+ [scene?.nodes],
333
+ )
334
+ const nodes = externalNodes ?? storeNodes
335
+ const installedPlugins = scene ? scene.installedPlugins : storeInstalledPlugins
336
+ const levels = useMemo(
337
+ () =>
338
+ Object.values(nodes)
339
+ .filter((node) => node.type === 'level')
340
+ .sort(
341
+ (a, b) => ((a as { level?: number }).level ?? 0) - ((b as { level?: number }).level ?? 0),
342
+ ),
343
+ [nodes],
344
+ )
345
+ const [internalLevelId, setInternalLevelId] = useState<string | null>(null)
346
+ const activeLevelId =
347
+ (levelId && levels.some((level) => level.id === levelId) ? levelId : null) ??
348
+ (internalLevelId && levels.some((level) => level.id === internalLevelId)
349
+ ? internalLevelId
350
+ : null) ??
351
+ levels[0]?.id ??
352
+ null
353
+ const activeLevel = activeLevelId ? nodes[activeLevelId as AnyNodeId] : undefined
354
+ const activeBuilding = activeLevel?.parentId
355
+ ? nodes[activeLevel.parentId as AnyNodeId]
356
+ : undefined
357
+ const buildingPosition = useMemo<[number, number, number]>(
358
+ () => (activeBuilding?.type === 'building' ? activeBuilding.position : [0, 0, 0]),
359
+ [activeBuilding],
360
+ )
361
+ const buildingRotationY = activeBuilding?.type === 'building' ? activeBuilding.rotation[1] : 0
362
+ const buildingRotationDeg = (buildingRotationY * 180) / Math.PI
363
+ const renderEntries = useMemo(
364
+ () =>
365
+ activeLevel
366
+ ? buildFloorplanGeometries(nodes, installedPlugins, activeLevel, unit, metricNotation)
367
+ : [],
368
+ [activeLevel, installedPlugins, metricNotation, nodes, unit],
369
+ )
370
+ const framingEntries = useMemo(() => {
371
+ const structural = renderEntries.filter((entry) => entry.includeInInitialFit)
372
+ return structural.length > 0 ? structural : renderEntries
373
+ }, [renderEntries])
374
+ const fitKey = `${activeLevelId ?? 'none'}:${framingEntries.map((entry) => entry.id).join('|')}`
375
+ const geometricFitBounds = useMemo(
376
+ () => getFloorplanBounds(framingEntries.map((entry) => entry.geometry)),
377
+ [framingEntries],
378
+ )
379
+ const [measuredFit, setMeasuredFit] = useState<MeasuredFit | null>(null)
380
+ const fittedViewBox = useMemo(() => {
381
+ const bounds = measuredFit?.key === fitKey ? measuredFit.bounds : geometricFitBounds
382
+ return bounds
383
+ ? boundsToViewBox(padFloorplanBounds(bounds))
384
+ : { x: -5, y: -5, width: 10, height: 10 }
385
+ }, [fitKey, geometricFitBounds, measuredFit])
386
+ const [viewBox, setViewBox] = useState<FloorplanViewBox>(fittedViewBox)
387
+ const viewBoxRef = useRef(viewBox)
388
+ const interactionRef = useRef<HTMLDivElement | null>(null)
389
+ const svgRef = useRef<SVGSVGElement | null>(null)
390
+ const gridRef = useRef<SVGRectElement | null>(null)
391
+ const sceneRef = useRef<SVGGElement | null>(null)
392
+ const compassNeedleRef = useRef<SVGSVGElement | null>(null)
393
+ const fitElementRefs = useRef(new Map<string, SVGGElement>())
394
+ const pointersRef = useRef(new Map<number, PointerPoint>())
395
+ const dragRef = useRef<DragState | null>(null)
396
+ const pinchRef = useRef<PinchState | null>(null)
397
+ const [isPanning, setIsPanning] = useState(false)
398
+ const [isRotating, setIsRotating] = useState(false)
399
+ const [viewportSize, setViewportSize] = useState({ width: 1000, height: 1000 })
400
+ const [rotationDeg, setRotationDeg] = useState(0)
401
+ const rotationDegRef = useRef(rotationDeg)
402
+ const latestNavigationPoseRef = useRef<NavigationSyncPose | null>(null)
403
+ const gridPatternId = useId().replaceAll(':', '')
404
+
405
+ const updateViewBox = useCallback(
406
+ (update: FloorplanViewBox | ((current: FloorplanViewBox) => FloorplanViewBox)) => {
407
+ setViewBox((current) => {
408
+ const next = typeof update === 'function' ? update(current) : update
409
+ viewBoxRef.current = next
410
+ return next
411
+ })
412
+ },
413
+ [],
414
+ )
415
+
416
+ const presentViewBox = useCallback((next: FloorplanViewBox) => {
417
+ viewBoxRef.current = next
418
+ const value = `${next.x} ${next.y} ${next.width} ${next.height}`
419
+ svgRef.current?.setAttribute('viewBox', value)
420
+ const grid = gridRef.current
421
+ if (grid) {
422
+ grid.setAttribute('x', String(next.x))
423
+ grid.setAttribute('y', String(next.y))
424
+ grid.setAttribute('width', String(next.width))
425
+ grid.setAttribute('height', String(next.height))
426
+ }
427
+ }, [])
428
+
429
+ const presentRotation = useCallback(
430
+ (nextRotationDeg: number) => {
431
+ rotationDegRef.current = nextRotationDeg
432
+ const sceneRotationDeg = FLOORPLAN_VIEW_ROTATION_DEG + nextRotationDeg - buildingRotationDeg
433
+ const sceneElement = sceneRef.current
434
+ if (sceneElement) {
435
+ if (sceneRotationDeg === 0) sceneElement.removeAttribute('transform')
436
+ else sceneElement.setAttribute('transform', `rotate(${sceneRotationDeg})`)
437
+ }
438
+ setFloorplanCompassRotation(compassNeedleRef.current, nextRotationDeg)
439
+ },
440
+ [buildingRotationDeg],
441
+ )
442
+
443
+ const commitPresentation = useCallback(
444
+ (nextViewBox: FloorplanViewBox, nextRotationDeg: number) => {
445
+ presentViewBox(nextViewBox)
446
+ presentRotation(nextRotationDeg)
447
+ setViewBox(nextViewBox)
448
+ setRotationDeg(nextRotationDeg)
449
+ },
450
+ [presentRotation, presentViewBox],
451
+ )
452
+
453
+ const publishNavigation = useCallback(
454
+ (nextViewBox: FloorplanViewBox, nextRotationDeg: number) => {
455
+ if (!synchronizeNavigation) return
456
+ const sceneRotationDeg = FLOORPLAN_VIEW_ROTATION_DEG + nextRotationDeg - buildingRotationDeg
457
+ const displayedCenter = {
458
+ x: nextViewBox.x + nextViewBox.width / 2,
459
+ y: nextViewBox.y + nextViewBox.height / 2,
460
+ }
461
+ const localCenter = rotateFloorplanPoint(displayedCenter, -sceneRotationDeg)
462
+ const worldCenter = floorplanLocalToWorldPoint(
463
+ localCenter,
464
+ buildingPosition,
465
+ buildingRotationY,
466
+ )
467
+ useEditor.getState().publishNavigationSyncPose({
468
+ source: '2d',
469
+ target: [
470
+ worldCenter.x,
471
+ latestNavigationPoseRef.current?.target[1] ?? buildingPosition[1],
472
+ worldCenter.z,
473
+ ],
474
+ azimuth: cameraAzimuthFromFloorplanRotation(nextRotationDeg),
475
+ viewWidth: visibleFloorplanViewWidth(nextViewBox, viewportSize),
476
+ })
477
+ },
478
+ [buildingPosition, buildingRotationDeg, buildingRotationY, synchronizeNavigation, viewportSize],
479
+ )
480
+
481
+ const applyNavigationPresentationRef = useRef<(pose: NavigationSyncPose) => void>(() => {})
482
+ const commitNavigationPresentationRef = useRef<(pose: NavigationSyncPose) => void>(() => {})
483
+ const navigationSchedulerRef = useRef<ReturnType<
484
+ typeof createFloorplanNavigationSyncScheduler<NavigationSyncPose>
485
+ > | null>(null)
486
+ if (!navigationSchedulerRef.current) {
487
+ navigationSchedulerRef.current = createFloorplanNavigationSyncScheduler<NavigationSyncPose>({
488
+ applyPresentation: (pose) => applyNavigationPresentationRef.current(pose),
489
+ commit: (pose) => commitNavigationPresentationRef.current(pose),
490
+ })
491
+ }
492
+
493
+ applyNavigationPresentationRef.current = (pose) => {
494
+ latestNavigationPoseRef.current = pose
495
+ const nextRotationDeg = floorplanRotationFromCameraAzimuth(pose.azimuth, rotationDegRef.current)
496
+ presentRotation(nextRotationDeg)
497
+ if (!navigationVisible) return
498
+ const localCenter = worldToFloorplanLocalPoint(
499
+ pose.target[0],
500
+ pose.target[2],
501
+ buildingPosition,
502
+ buildingRotationY,
503
+ )
504
+ presentViewBox(
505
+ floorplanViewBoxFromNavigationPose(
506
+ pose,
507
+ localCenter,
508
+ FLOORPLAN_VIEW_ROTATION_DEG + nextRotationDeg - buildingRotationDeg,
509
+ viewportSize,
510
+ ),
511
+ )
512
+ }
513
+
514
+ commitNavigationPresentationRef.current = (_pose) => {
515
+ if (!navigationVisible) return
516
+ setViewBox(viewBoxRef.current)
517
+ setRotationDeg(rotationDegRef.current)
518
+ }
519
+
520
+ useClientLayoutEffect(() => {
521
+ let bounds: FloorplanBounds | null = null
522
+ for (const entry of framingEntries) {
523
+ const element = fitElementRefs.current.get(entry.id)
524
+ if (!element || typeof element.getBBox !== 'function') continue
525
+ let box: DOMRect
526
+ try {
527
+ box = element.getBBox()
528
+ } catch {
529
+ continue
530
+ }
531
+ if (![box.x, box.y, box.width, box.height].every(Number.isFinite)) continue
532
+ if (box.width === 0 && box.height === 0) continue
533
+ const next = {
534
+ minX: box.x,
535
+ minY: box.y,
536
+ maxX: box.x + box.width,
537
+ maxY: box.y + box.height,
538
+ }
539
+ bounds = bounds
540
+ ? {
541
+ minX: Math.min(bounds.minX, next.minX),
542
+ minY: Math.min(bounds.minY, next.minY),
543
+ maxX: Math.max(bounds.maxX, next.maxX),
544
+ maxY: Math.max(bounds.maxY, next.maxY),
545
+ }
546
+ : next
547
+ }
548
+ if (bounds) setMeasuredFit({ key: fitKey, bounds })
549
+ }, [fitKey, framingEntries])
550
+
551
+ useClientLayoutEffect(() => {
552
+ if (synchronizeNavigation && latestNavigationPoseRef.current) return
553
+ updateViewBox(fittedViewBox)
554
+ pointersRef.current.clear()
555
+ dragRef.current = null
556
+ pinchRef.current = null
557
+ setIsPanning(false)
558
+ setIsRotating(false)
559
+ }, [fittedViewBox, synchronizeNavigation, updateViewBox])
560
+
561
+ useEffect(() => {
562
+ const svg = svgRef.current
563
+ if (!svg) return
564
+ const updateSize = () => {
565
+ const rect = svg.getBoundingClientRect()
566
+ setViewportSize({ width: Math.max(rect.width, 1), height: Math.max(rect.height, 1) })
567
+ }
568
+ updateSize()
569
+ if (typeof ResizeObserver === 'undefined') {
570
+ window.addEventListener('resize', updateSize)
571
+ return () => window.removeEventListener('resize', updateSize)
572
+ }
573
+ const observer = new ResizeObserver(updateSize)
574
+ observer.observe(svg)
575
+ return () => observer.disconnect()
576
+ }, [])
577
+
578
+ useClientLayoutEffect(() => {
579
+ presentRotation(rotationDegRef.current)
580
+ }, [presentRotation])
581
+
582
+ useEffect(() => {
583
+ if (!synchronizeNavigation) return
584
+ const scheduler = navigationSchedulerRef.current
585
+ if (!scheduler) return
586
+ const receivePose = (pose: NavigationSyncPose) => {
587
+ latestNavigationPoseRef.current = pose
588
+ if (navigationVisible) scheduler.update(pose)
589
+ else applyNavigationPresentationRef.current(pose)
590
+ }
591
+ const unsubscribeCamera = subscribeFloorplanCameraNavigation(receivePose)
592
+ const unsubscribeStored = subscribeNavigationSyncPose((pose) => {
593
+ if (pose.source === '2d' && !navigationVisible) receivePose(pose)
594
+ })
595
+ return () => {
596
+ unsubscribeCamera()
597
+ unsubscribeStored()
598
+ scheduler.discard()
599
+ }
600
+ }, [navigationVisible, synchronizeNavigation])
601
+
602
+ useEffect(() => {
603
+ if (!(synchronizeNavigation && navigationVisible && latestNavigationPoseRef.current)) return
604
+ navigationSchedulerRef.current?.update(latestNavigationPoseRef.current)
605
+ }, [navigationVisible, synchronizeNavigation])
606
+
607
+ const chooseLevel = useCallback(
608
+ (nextLevelId: string) => {
609
+ setInternalLevelId(nextLevelId)
610
+ onLevelChange?.(nextLevelId)
611
+ },
612
+ [onLevelChange],
613
+ )
614
+
615
+ const updateLocalViewBox = useCallback(
616
+ (
617
+ update: FloorplanViewBox | ((current: FloorplanViewBox) => FloorplanViewBox),
618
+ commit = true,
619
+ ) => {
620
+ const current = viewBoxRef.current
621
+ const next = typeof update === 'function' ? update(current) : update
622
+ presentViewBox(next)
623
+ if (commit) setViewBox(next)
624
+ publishNavigation(next, rotationDegRef.current)
625
+ return next
626
+ },
627
+ [presentViewBox, publishNavigation],
628
+ )
629
+
630
+ const zoom = useCallback(
631
+ (factor: number, anchorX = 0.5, anchorY = 0.5) => {
632
+ updateLocalViewBox((current) => scaleFloorplanViewBox(current, factor, anchorX, anchorY))
633
+ },
634
+ [updateLocalViewBox],
635
+ )
636
+
637
+ const onWheel = useCallback(
638
+ (event: ReactWheelEvent<SVGSVGElement>) => {
639
+ event.preventDefault()
640
+ const rect = event.currentTarget.getBoundingClientRect()
641
+ updateLocalViewBox((current) =>
642
+ scaleFloorplanViewBoxBetweenClients(
643
+ current,
644
+ event.deltaY > 0 ? 1.12 : 0.88,
645
+ rect,
646
+ [event.clientX, event.clientY],
647
+ [event.clientX, event.clientY],
648
+ ),
649
+ )
650
+ },
651
+ [updateLocalViewBox],
652
+ )
653
+
654
+ const onPointerDown = useCallback((event: ReactPointerEvent<SVGSVGElement>) => {
655
+ if (event.pointerType === 'mouse' && event.button !== 0 && event.button !== 2) return
656
+ event.preventDefault()
657
+ interactionRef.current?.focus()
658
+ try {
659
+ event.currentTarget.setPointerCapture(event.pointerId)
660
+ } catch {
661
+ // Pointer capture is an enhancement; the gesture still works while events stay over the SVG.
662
+ }
663
+ const point = { x: event.clientX, y: event.clientY }
664
+ pointersRef.current.set(event.pointerId, point)
665
+ const pointers = Array.from(pointersRef.current.entries())
666
+ if (pointers.length === 1) {
667
+ dragRef.current = {
668
+ mode: event.pointerType === 'mouse' && event.button === 2 ? 'rotate' : 'pan',
669
+ pointerId: event.pointerId,
670
+ point,
671
+ rotationDeg: rotationDegRef.current,
672
+ viewBox: viewBoxRef.current,
673
+ }
674
+ pinchRef.current = null
675
+ } else {
676
+ const [first, second] = pointers
677
+ if (!first || !second) return
678
+ const dx = second[1].x - first[1].x
679
+ const dy = second[1].y - first[1].y
680
+ pinchRef.current = {
681
+ pointerIds: [first[0], second[0]],
682
+ midpoint: { x: (first[1].x + second[1].x) / 2, y: (first[1].y + second[1].y) / 2 },
683
+ distance: Math.max(Math.hypot(dx, dy), 1),
684
+ viewBox: viewBoxRef.current,
685
+ }
686
+ dragRef.current = null
687
+ }
688
+ setIsPanning(true)
689
+ setIsRotating(dragRef.current?.mode === 'rotate')
690
+ }, [])
691
+
692
+ const onPointerMove = useCallback(
693
+ (event: ReactPointerEvent<SVGSVGElement>) => {
694
+ if (!pointersRef.current.has(event.pointerId)) return
695
+ pointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY })
696
+ const rect = event.currentTarget.getBoundingClientRect()
697
+ const pinch = pinchRef.current
698
+ if (pinch) {
699
+ const first = pointersRef.current.get(pinch.pointerIds[0])
700
+ const second = pointersRef.current.get(pinch.pointerIds[1])
701
+ if (!(first && second)) return
702
+ const dx = second.x - first.x
703
+ const dy = second.y - first.y
704
+ const midpoint: PointerPoint = { x: (first.x + second.x) / 2, y: (first.y + second.y) / 2 }
705
+ updateLocalViewBox(
706
+ scaleFloorplanViewBoxBetweenClients(
707
+ pinch.viewBox,
708
+ pinch.distance / Math.max(Math.hypot(dx, dy), 1),
709
+ rect,
710
+ [pinch.midpoint.x, pinch.midpoint.y],
711
+ [midpoint.x, midpoint.y],
712
+ ),
713
+ false,
714
+ )
715
+ return
716
+ }
717
+ const drag = dragRef.current
718
+ if (!drag || drag.pointerId !== event.pointerId) return
719
+ if (drag.mode === 'rotate') {
720
+ const nextRotationDeg = drag.rotationDeg + (event.clientX - drag.point.x) * 0.35
721
+ const initialSceneRotationDeg =
722
+ FLOORPLAN_VIEW_ROTATION_DEG + drag.rotationDeg - buildingRotationDeg
723
+ const nextSceneRotationDeg =
724
+ FLOORPLAN_VIEW_ROTATION_DEG + nextRotationDeg - buildingRotationDeg
725
+ const displayedCenter = {
726
+ x: drag.viewBox.x + drag.viewBox.width / 2,
727
+ y: drag.viewBox.y + drag.viewBox.height / 2,
728
+ }
729
+ const localCenter = rotateFloorplanPoint(displayedCenter, -initialSceneRotationDeg)
730
+ const nextCenter = rotateFloorplanPoint(localCenter, nextSceneRotationDeg)
731
+ const nextViewBox = {
732
+ ...drag.viewBox,
733
+ x: nextCenter.x - drag.viewBox.width / 2,
734
+ y: nextCenter.y - drag.viewBox.height / 2,
735
+ }
736
+ presentRotation(nextRotationDeg)
737
+ presentViewBox(nextViewBox)
738
+ publishNavigation(nextViewBox, nextRotationDeg)
739
+ return
740
+ }
741
+ updateLocalViewBox(
742
+ panFloorplanViewBox(
743
+ drag.viewBox,
744
+ rect,
745
+ [drag.point.x, drag.point.y],
746
+ [event.clientX, event.clientY],
747
+ ),
748
+ false,
749
+ )
750
+ },
751
+ [buildingRotationDeg, presentRotation, presentViewBox, publishNavigation, updateLocalViewBox],
752
+ )
753
+
754
+ const onPointerUp = useCallback((event: ReactPointerEvent<SVGSVGElement>) => {
755
+ pointersRef.current.delete(event.pointerId)
756
+ if (event.currentTarget.hasPointerCapture(event.pointerId)) {
757
+ event.currentTarget.releasePointerCapture(event.pointerId)
758
+ }
759
+ pinchRef.current = null
760
+ const [remaining] = pointersRef.current.entries()
761
+ if (remaining) {
762
+ dragRef.current = {
763
+ mode: 'pan',
764
+ pointerId: remaining[0],
765
+ point: remaining[1],
766
+ rotationDeg: rotationDegRef.current,
767
+ viewBox: viewBoxRef.current,
768
+ }
769
+ setIsRotating(false)
770
+ } else {
771
+ dragRef.current = null
772
+ setIsPanning(false)
773
+ setIsRotating(false)
774
+ setViewBox(viewBoxRef.current)
775
+ setRotationDeg(rotationDegRef.current)
776
+ }
777
+ }, [])
778
+
779
+ const onKeyDown = useCallback(
780
+ (event: ReactKeyboardEvent<HTMLDivElement>) => {
781
+ const panStep = 0.08
782
+ switch (event.key) {
783
+ case '+':
784
+ case '=':
785
+ event.preventDefault()
786
+ zoom(0.8)
787
+ return
788
+ case '-':
789
+ case '_':
790
+ event.preventDefault()
791
+ zoom(1.2)
792
+ return
793
+ case '0':
794
+ case 'f':
795
+ case 'F':
796
+ event.preventDefault()
797
+ updateLocalViewBox(fittedViewBox)
798
+ return
799
+ case 'ArrowLeft':
800
+ event.preventDefault()
801
+ updateLocalViewBox((current) => ({
802
+ ...current,
803
+ x: current.x - current.width * panStep,
804
+ }))
805
+ return
806
+ case 'ArrowRight':
807
+ event.preventDefault()
808
+ updateLocalViewBox((current) => ({
809
+ ...current,
810
+ x: current.x + current.width * panStep,
811
+ }))
812
+ return
813
+ case 'ArrowUp':
814
+ event.preventDefault()
815
+ updateLocalViewBox((current) => ({
816
+ ...current,
817
+ y: current.y - current.height * panStep,
818
+ }))
819
+ return
820
+ case 'ArrowDown':
821
+ event.preventDefault()
822
+ updateLocalViewBox((current) => ({
823
+ ...current,
824
+ y: current.y + current.height * panStep,
825
+ }))
826
+ }
827
+ },
828
+ [fittedViewBox, updateLocalViewBox, zoom],
829
+ )
830
+
831
+ const alignToNorth = useCallback(() => {
832
+ const currentRotationDeg = rotationDegRef.current
833
+ const nextRotationDeg = nearestEquivalentDegrees(0, currentRotationDeg)
834
+ if (!navigationVisible) {
835
+ const pose = latestNavigationPoseRef.current
836
+ if (!pose) return
837
+ useEditor.getState().publishNavigationSyncPose({
838
+ source: '2d',
839
+ target: [...pose.target],
840
+ azimuth: cameraAzimuthFromFloorplanRotation(nextRotationDeg),
841
+ viewWidth: pose.viewWidth,
842
+ })
843
+ return
844
+ }
845
+
846
+ const currentViewBox = viewBoxRef.current
847
+ const currentSceneRotationDeg =
848
+ FLOORPLAN_VIEW_ROTATION_DEG + currentRotationDeg - buildingRotationDeg
849
+ const nextSceneRotationDeg = FLOORPLAN_VIEW_ROTATION_DEG + nextRotationDeg - buildingRotationDeg
850
+ const currentCenter = {
851
+ x: currentViewBox.x + currentViewBox.width / 2,
852
+ y: currentViewBox.y + currentViewBox.height / 2,
853
+ }
854
+ const localCenter = rotateFloorplanPoint(currentCenter, -currentSceneRotationDeg)
855
+ const nextCenter = rotateFloorplanPoint(localCenter, nextSceneRotationDeg)
856
+ const nextViewBox = {
857
+ ...currentViewBox,
858
+ x: nextCenter.x - currentViewBox.width / 2,
859
+ y: nextCenter.y - currentViewBox.height / 2,
860
+ }
861
+ commitPresentation(nextViewBox, nextRotationDeg)
862
+ publishNavigation(nextViewBox, nextRotationDeg)
863
+ }, [buildingRotationDeg, commitPresentation, navigationVisible, publishNavigation])
864
+
865
+ const screenUnitsPerPixel = Math.max(
866
+ viewBox.width / Math.max(viewportSize.width, 1),
867
+ viewBox.height / Math.max(viewportSize.height, 1),
868
+ )
869
+
870
+ const compassControl = (
871
+ <FloorplanCompassButton
872
+ needleRef={compassNeedleRef}
873
+ northRotationDeg={rotationDeg}
874
+ onAlignNorth={alignToNorth}
875
+ />
876
+ )
877
+
878
+ if (levels.length === 0) {
879
+ return (
880
+ <div
881
+ className={className}
882
+ style={{ display: 'grid', placeItems: 'center', background: '#f8fafc', color: '#64748b' }}
883
+ >
884
+ No floor plans are available for this scene.
885
+ </div>
886
+ )
887
+ }
888
+
889
+ return (
890
+ <div
891
+ className={className}
892
+ style={{ position: 'relative', overflow: 'hidden', background: '#f8fafc' }}
893
+ >
894
+ {synchronizeNavigation ? <FloorplanCameraSyncMount /> : null}
895
+ <div
896
+ aria-keyshortcuts="ArrowUp ArrowDown ArrowLeft ArrowRight + - 0 F"
897
+ aria-label={`${activeLevel ? levelLabel(activeLevel) : 'Floor plan'} 2D view`}
898
+ onKeyDown={onKeyDown}
899
+ role="application"
900
+ ref={interactionRef}
901
+ style={{ width: '100%', height: '100%' }}
902
+ // biome-ignore lint/a11y/noNoninteractiveTabindex: The plan canvas supports keyboard pan, zoom, and fit controls.
903
+ tabIndex={0}
904
+ >
905
+ <svg
906
+ aria-hidden="true"
907
+ data-floorplan-preview=""
908
+ onContextMenu={(event) => event.preventDefault()}
909
+ onPointerCancel={onPointerUp}
910
+ onPointerDown={onPointerDown}
911
+ onPointerMove={onPointerMove}
912
+ onPointerUp={onPointerUp}
913
+ onWheel={onWheel}
914
+ preserveAspectRatio="xMidYMid meet"
915
+ ref={svgRef}
916
+ style={{
917
+ width: '100%',
918
+ height: '100%',
919
+ cursor: isRotating ? 'ew-resize' : isPanning ? 'grabbing' : 'grab',
920
+ touchAction: 'none',
921
+ }}
922
+ viewBox={`${viewBox.x} ${viewBox.y} ${viewBox.width} ${viewBox.height}`}
923
+ >
924
+ <defs>
925
+ <pattern height="1" id={gridPatternId} patternUnits="userSpaceOnUse" width="1">
926
+ <path
927
+ d="M 1 0 L 0 0 0 1"
928
+ fill="none"
929
+ stroke="#cbd5e1"
930
+ strokeOpacity="0.32"
931
+ strokeWidth="0.012"
932
+ />
933
+ </pattern>
934
+ </defs>
935
+ <rect
936
+ fill={`url(#${gridPatternId})`}
937
+ height={viewBox.height}
938
+ ref={gridRef}
939
+ width={viewBox.width}
940
+ x={viewBox.x}
941
+ y={viewBox.y}
942
+ />
943
+ <g
944
+ pointerEvents="none"
945
+ ref={sceneRef}
946
+ transform={
947
+ FLOORPLAN_VIEW_ROTATION_DEG + rotationDeg - buildingRotationDeg === 0
948
+ ? undefined
949
+ : `rotate(${FLOORPLAN_VIEW_ROTATION_DEG + rotationDeg - buildingRotationDeg})`
950
+ }
951
+ >
952
+ {renderEntries.map((entry) => (
953
+ <g
954
+ key={entry.id}
955
+ ref={(element) => {
956
+ if (element) fitElementRefs.current.set(entry.id, element)
957
+ else fitElementRefs.current.delete(entry.id)
958
+ }}
959
+ >
960
+ <FloorplanGeometryRenderer
961
+ geometry={entry.geometry}
962
+ pointerEventsOverride="none"
963
+ screenUnitsPerPixel={screenUnitsPerPixel}
964
+ />
965
+ </g>
966
+ ))}
967
+ </g>
968
+ </svg>
969
+ </div>
970
+
971
+ {showLevelSelector && levels.length > 1 ? (
972
+ <label
973
+ style={{
974
+ position: 'absolute',
975
+ bottom: 16,
976
+ left: 64,
977
+ display: 'grid',
978
+ gap: 4,
979
+ color: '#475569',
980
+ fontSize: 11,
981
+ fontWeight: 600,
982
+ }}
983
+ >
984
+ Floor
985
+ <select
986
+ aria-label="Floor"
987
+ onChange={(event) => chooseLevel(event.target.value)}
988
+ style={{
989
+ border: '1px solid rgba(148,163,184,.55)',
990
+ borderRadius: 999,
991
+ background: 'rgba(255,255,255,.94)',
992
+ padding: '8px 30px 8px 12px',
993
+ color: '#0f172a',
994
+ boxShadow: '0 8px 24px rgba(15,23,42,.10)',
995
+ }}
996
+ value={activeLevelId ?? ''}
997
+ >
998
+ {levels.map((level) => (
999
+ <option key={level.id} value={level.id}>
1000
+ {levelLabel(level)}
1001
+ </option>
1002
+ ))}
1003
+ </select>
1004
+ </label>
1005
+ ) : null}
1006
+
1007
+ {showCompass
1008
+ ? compassHost
1009
+ ? createPortal(compassControl, compassHost)
1010
+ : compassControl
1011
+ : null}
1012
+
1013
+ <div
1014
+ style={{
1015
+ position: 'absolute',
1016
+ right: 16,
1017
+ bottom: 16,
1018
+ display: 'flex',
1019
+ gap: 4,
1020
+ border: '1px solid rgba(148,163,184,.45)',
1021
+ borderRadius: 999,
1022
+ background: 'rgba(255,255,255,.94)',
1023
+ padding: 4,
1024
+ boxShadow: '0 8px 24px rgba(15,23,42,.10)',
1025
+ }}
1026
+ >
1027
+ <button
1028
+ aria-label="Zoom out"
1029
+ onClick={() => zoom(1.2)}
1030
+ style={controlStyle}
1031
+ title="Zoom out"
1032
+ type="button"
1033
+ >
1034
+ <Minus size={16} />
1035
+ </button>
1036
+ <button
1037
+ aria-label="Fit floor plan"
1038
+ onClick={() => updateLocalViewBox(fittedViewBox)}
1039
+ style={controlStyle}
1040
+ title="Fit floor plan"
1041
+ type="button"
1042
+ >
1043
+ <Maximize2 size={15} />
1044
+ </button>
1045
+ <button
1046
+ aria-label="Zoom in"
1047
+ onClick={() => zoom(0.8)}
1048
+ style={controlStyle}
1049
+ title="Zoom in"
1050
+ type="button"
1051
+ >
1052
+ <Plus size={16} />
1053
+ </button>
1054
+ </div>
1055
+ </div>
1056
+ )
1057
+ }
1058
+
1059
+ const controlStyle = {
1060
+ display: 'grid',
1061
+ width: 32,
1062
+ height: 32,
1063
+ placeItems: 'center',
1064
+ border: 0,
1065
+ borderRadius: 999,
1066
+ background: 'transparent',
1067
+ color: '#334155',
1068
+ cursor: 'pointer',
1069
+ } as const