@ubuilder-ai/workspace 0.1.0-alpha.1
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/README.md +7 -0
- package/dist/floorplan-annotation-obstacles.d.ts +4 -0
- package/dist/floorplan-annotation-obstacles.d.ts.map +1 -0
- package/dist/floorplan-annotation-obstacles.js +25 -0
- package/dist/floorplan-camera-sync.d.ts +24 -0
- package/dist/floorplan-camera-sync.d.ts.map +1 -0
- package/dist/floorplan-camera-sync.js +187 -0
- package/dist/floorplan-coordinates.d.ts +10 -0
- package/dist/floorplan-coordinates.d.ts.map +1 -0
- package/dist/floorplan-coordinates.js +25 -0
- package/dist/floorplan-dimension-renderer.d.ts +42 -0
- package/dist/floorplan-dimension-renderer.d.ts.map +1 -0
- package/dist/floorplan-dimension-renderer.js +245 -0
- package/dist/floorplan-geometry-renderer.d.ts +56 -0
- package/dist/floorplan-geometry-renderer.d.ts.map +1 -0
- package/dist/floorplan-geometry-renderer.js +319 -0
- package/dist/floorplan-initial-view.d.ts +29 -0
- package/dist/floorplan-initial-view.d.ts.map +1 -0
- package/dist/floorplan-initial-view.js +30 -0
- package/dist/floorplan-label-angle.d.ts +24 -0
- package/dist/floorplan-label-angle.d.ts.map +1 -0
- package/dist/floorplan-label-angle.js +60 -0
- package/dist/floorplan-navigation-presentation.d.ts +57 -0
- package/dist/floorplan-navigation-presentation.d.ts.map +1 -0
- package/dist/floorplan-navigation-presentation.js +81 -0
- package/dist/floorplan-navigation-shell.d.ts +87 -0
- package/dist/floorplan-navigation-shell.d.ts.map +1 -0
- package/dist/floorplan-navigation-shell.js +1178 -0
- package/dist/floorplan-navigation-types.d.ts +10 -0
- package/dist/floorplan-navigation-types.d.ts.map +1 -0
- package/dist/floorplan-navigation-types.js +1 -0
- package/dist/floorplan-registry-projection.d.ts +84 -0
- package/dist/floorplan-registry-projection.d.ts.map +1 -0
- package/dist/floorplan-registry-projection.js +260 -0
- package/dist/floorplan-render-context.d.ts +56 -0
- package/dist/floorplan-render-context.d.ts.map +1 -0
- package/dist/floorplan-render-context.js +75 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +18 -0
- package/dist/spatial-workspace-camera-action-control.d.ts +18 -0
- package/dist/spatial-workspace-camera-action-control.d.ts.map +1 -0
- package/dist/spatial-workspace-camera-action-control.js +13 -0
- package/dist/spatial-workspace-display-control.d.ts +64 -0
- package/dist/spatial-workspace-display-control.d.ts.map +1 -0
- package/dist/spatial-workspace-display-control.js +81 -0
- package/dist/spatial-workspace-gis-control.d.ts +58 -0
- package/dist/spatial-workspace-gis-control.d.ts.map +1 -0
- package/dist/spatial-workspace-gis-control.js +38 -0
- package/dist/spatial-workspace-instance.d.ts +2 -0
- package/dist/spatial-workspace-instance.d.ts.map +1 -0
- package/dist/spatial-workspace-instance.js +14 -0
- package/dist/spatial-workspace-level-selector.d.ts +21 -0
- package/dist/spatial-workspace-level-selector.d.ts.map +1 -0
- package/dist/spatial-workspace-level-selector.js +11 -0
- package/dist/spatial-workspace-level-wall-control.d.ts +38 -0
- package/dist/spatial-workspace-level-wall-control.d.ts.map +1 -0
- package/dist/spatial-workspace-level-wall-control.js +41 -0
- package/dist/spatial-workspace-mode.d.ts +11 -0
- package/dist/spatial-workspace-mode.d.ts.map +1 -0
- package/dist/spatial-workspace-mode.js +29 -0
- package/dist/spatial-workspace-styles.d.ts +3 -0
- package/dist/spatial-workspace-styles.d.ts.map +1 -0
- package/dist/spatial-workspace-styles.js +72 -0
- package/dist/spatial-workspace-view-mode-control.d.ts +19 -0
- package/dist/spatial-workspace-view-mode-control.d.ts.map +1 -0
- package/dist/spatial-workspace-view-mode-control.js +14 -0
- package/dist/spatial-workspace-viewport.d.ts +25 -0
- package/dist/spatial-workspace-viewport.d.ts.map +1 -0
- package/dist/spatial-workspace-viewport.js +75 -0
- package/package.json +47 -0
|
@@ -0,0 +1,1178 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from 'react';
|
|
4
|
+
import { createPortal } from 'react-dom';
|
|
5
|
+
import { floorplanLocalToWorldPoint, worldToFloorplanLocalPoint, } from './floorplan-coordinates';
|
|
6
|
+
import { resolveEmptyFloorplanViewWidth, resolveFloorplanSyncedView, } from './floorplan-initial-view';
|
|
7
|
+
import { canApplyFloorplanNavigationSync, canZoomFloorplanDuringNavigation, createFloorplanNavigationSyncScheduler, finalizeFloorplanNavigation, flushFloorplanRotationPresentationRestore, getFloorplanRotationOverscanViewBox, queueFloorplanRotationPresentationRestore, resolveFloorplanPresentationViewBox, setFloorplanCompassRotation, } from './floorplan-navigation-presentation';
|
|
8
|
+
const FALLBACK_VIEW_SIZE = 12;
|
|
9
|
+
const FLOORPLAN_PADDING = 2;
|
|
10
|
+
const MIN_VIEWPORT_WIDTH_RATIO = 0.08;
|
|
11
|
+
const MAX_VIEWPORT_WIDTH_RATIO = 40;
|
|
12
|
+
const FLOORPLAN_ROTATION_DEGREES_PER_PIXEL = 0.35;
|
|
13
|
+
const FLOORPLAN_VIEW_ANIMATION_TIME_CONSTANT_MS = 90;
|
|
14
|
+
const FLOORPLAN_VIEW_ANIMATION_EPSILON = 0.0005;
|
|
15
|
+
const FLOORPLAN_ROTATION_ANIMATION_EPSILON_DEG = 0.01;
|
|
16
|
+
function floorplanViewportEquals(a, b) {
|
|
17
|
+
if (a === b)
|
|
18
|
+
return true;
|
|
19
|
+
if (!(a && b))
|
|
20
|
+
return false;
|
|
21
|
+
return a.centerX === b.centerX && a.centerY === b.centerY && a.width === b.width;
|
|
22
|
+
}
|
|
23
|
+
function floorplanViewportWithinEpsilon(a, b, epsilon) {
|
|
24
|
+
return (Math.abs(a.centerX - b.centerX) < epsilon &&
|
|
25
|
+
Math.abs(a.centerY - b.centerY) < epsilon &&
|
|
26
|
+
Math.abs(a.width - b.width) < epsilon);
|
|
27
|
+
}
|
|
28
|
+
function restoreFloorplanNavigationSyncPresentation(presentationState) {
|
|
29
|
+
presentationState.svg.style.transform = presentationState.svgStyle.transform;
|
|
30
|
+
presentationState.svg.style.transformOrigin = presentationState.svgStyle.transformOrigin;
|
|
31
|
+
presentationState.svg.style.willChange = presentationState.svgStyle.willChange;
|
|
32
|
+
}
|
|
33
|
+
export const __testFloorplanNavigationNativeEvents = {
|
|
34
|
+
register(svg, handlers) {
|
|
35
|
+
svg.addEventListener('wheel', handlers.wheel, { passive: false });
|
|
36
|
+
svg.addEventListener('gesturestart', handlers.gesturestart, { passive: false });
|
|
37
|
+
svg.addEventListener('gesturechange', handlers.gesturechange, { passive: false });
|
|
38
|
+
svg.addEventListener('gestureend', handlers.gestureend, { passive: false });
|
|
39
|
+
return () => {
|
|
40
|
+
svg.removeEventListener('wheel', handlers.wheel);
|
|
41
|
+
svg.removeEventListener('gesturestart', handlers.gesturestart);
|
|
42
|
+
svg.removeEventListener('gesturechange', handlers.gesturechange);
|
|
43
|
+
svg.removeEventListener('gestureend', handlers.gestureend);
|
|
44
|
+
};
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
function clamp(value, min, max) {
|
|
48
|
+
return Math.min(Math.max(value, min), max);
|
|
49
|
+
}
|
|
50
|
+
function resolveFloorplanViewWidth(requestedWidth, currentWidth, fittedViewport, clampViewWidth) {
|
|
51
|
+
if (!(Number.isFinite(requestedWidth) && requestedWidth > 0)) {
|
|
52
|
+
return currentWidth;
|
|
53
|
+
}
|
|
54
|
+
if (!clampViewWidth || !fittedViewport) {
|
|
55
|
+
return Math.max(0.001, requestedWidth);
|
|
56
|
+
}
|
|
57
|
+
const minWidth = fittedViewport.width * MIN_VIEWPORT_WIDTH_RATIO;
|
|
58
|
+
const maxWidth = fittedViewport.width * MAX_VIEWPORT_WIDTH_RATIO;
|
|
59
|
+
return clamp(requestedWidth, Math.min(minWidth, currentWidth), Math.max(maxWidth, currentWidth));
|
|
60
|
+
}
|
|
61
|
+
export function rotateFloorplanPoint(point, rotationDegrees) {
|
|
62
|
+
if (rotationDegrees === 0) {
|
|
63
|
+
return point;
|
|
64
|
+
}
|
|
65
|
+
const radians = (rotationDegrees * Math.PI) / 180;
|
|
66
|
+
const cos = Math.cos(radians);
|
|
67
|
+
const sin = Math.sin(radians);
|
|
68
|
+
return {
|
|
69
|
+
x: point.x * cos - point.y * sin,
|
|
70
|
+
y: point.x * sin + point.y * cos,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
function radiansToDegrees(angle) {
|
|
74
|
+
return (angle * 180) / Math.PI;
|
|
75
|
+
}
|
|
76
|
+
function degreesToRadians(angle) {
|
|
77
|
+
return (angle * Math.PI) / 180;
|
|
78
|
+
}
|
|
79
|
+
function nearestEquivalentDegrees(angle, reference) {
|
|
80
|
+
let nextAngle = angle;
|
|
81
|
+
while (nextAngle - reference > 180) {
|
|
82
|
+
nextAngle -= 360;
|
|
83
|
+
}
|
|
84
|
+
while (nextAngle - reference < -180) {
|
|
85
|
+
nextAngle += 360;
|
|
86
|
+
}
|
|
87
|
+
return nextAngle;
|
|
88
|
+
}
|
|
89
|
+
function floorplanRotationFromCameraAzimuth(azimuth, reference, baselineRotationDeg) {
|
|
90
|
+
return nearestEquivalentDegrees(radiansToDegrees(azimuth) - baselineRotationDeg, reference);
|
|
91
|
+
}
|
|
92
|
+
function cameraAzimuthFromFloorplanRotation(rotationDeg, baselineRotationDeg) {
|
|
93
|
+
return degreesToRadians(rotationDeg + baselineRotationDeg);
|
|
94
|
+
}
|
|
95
|
+
export function useFloorplanNavigation({ active, activationAzimuth, baselineRotationDeg, contentKey, fitPoints, fitSuspended, hasContent, initialNavigationPose, onNavigationStart, planeWorldY, publishContinuously, publishNavigationPose, subscribeNavigationPose, worldOrigin, worldRotationY, }) {
|
|
96
|
+
const viewportHostRef = useRef(null);
|
|
97
|
+
const svgRef = useRef(null);
|
|
98
|
+
const backgroundRef = useRef(null);
|
|
99
|
+
const sceneRef = useRef(null);
|
|
100
|
+
const contentRef = useRef(null);
|
|
101
|
+
const panStateRef = useRef(null);
|
|
102
|
+
const rotationStateRef = useRef(null);
|
|
103
|
+
const pendingRotationRestoreRef = useRef(null);
|
|
104
|
+
const spacePanPressedRef = useRef(false);
|
|
105
|
+
const navigationClickSuppressedRef = useRef(false);
|
|
106
|
+
const gestureScaleRef = useRef(1);
|
|
107
|
+
const hasUserAdjustedViewportRef = useRef(false);
|
|
108
|
+
const previousContentKeyRef = useRef(null);
|
|
109
|
+
const previousActiveRef = useRef(false);
|
|
110
|
+
const latestUserRotationDegRef = useRef(0);
|
|
111
|
+
const latestViewportRef = useRef(null);
|
|
112
|
+
const latestFittedViewportRef = useRef(null);
|
|
113
|
+
const blankInitialViewPublishedForKeyRef = useRef(null);
|
|
114
|
+
const viewAnimationFrameRef = useRef(null);
|
|
115
|
+
const viewAnimationTargetRef = useRef(null);
|
|
116
|
+
const zoomCommitTimerRef = useRef(null);
|
|
117
|
+
const renderScaleCommitTimerRef = useRef(null);
|
|
118
|
+
const viewportInteractionInProgressRef = useRef(false);
|
|
119
|
+
const imperativeViewBoxRef = useRef(null);
|
|
120
|
+
const navigationSyncPresentationRef = useRef(null);
|
|
121
|
+
const applyNavigationSyncPresentationRef = useRef(() => { });
|
|
122
|
+
const commitNavigationSyncPresentationRef = useRef(() => { });
|
|
123
|
+
const navigationSyncSchedulerRef = useRef(null);
|
|
124
|
+
const latestRenderUnitsPerPixelRef = useRef(1);
|
|
125
|
+
const zoomPoseRef = useRef(null);
|
|
126
|
+
const panPoseRef = useRef(null);
|
|
127
|
+
const latestNavigationPoseRef = useRef(initialNavigationPose);
|
|
128
|
+
const compassNeedleRef = useRef(null);
|
|
129
|
+
const hiddenCompassAnimationRef = useRef(null);
|
|
130
|
+
const activeRef = useRef(active);
|
|
131
|
+
activeRef.current = active;
|
|
132
|
+
const [isSpacePanPressed, setIsSpacePanPressed] = useState(false);
|
|
133
|
+
const [isPanning, setIsPanning] = useState(false);
|
|
134
|
+
const [isRotating, setIsRotating] = useState(false);
|
|
135
|
+
const [surfaceSize, setSurfaceSize] = useState({ width: 1, height: 1 });
|
|
136
|
+
const [viewport, setViewport] = useState(null);
|
|
137
|
+
const [renderUnitsPerPixel, setRenderUnitsPerPixel] = useState(null);
|
|
138
|
+
const [measuredSceneBBox, setMeasuredSceneBBox] = useState(null);
|
|
139
|
+
const [floorplanUserRotationDeg, setFloorplanUserRotationDeg] = useState(0);
|
|
140
|
+
if (!viewportInteractionInProgressRef.current) {
|
|
141
|
+
latestViewportRef.current = viewport;
|
|
142
|
+
}
|
|
143
|
+
const worldRotationDeg = (worldRotationY * 180) / Math.PI;
|
|
144
|
+
const sceneRotationDeg = baselineRotationDeg + floorplanUserRotationDeg - worldRotationDeg;
|
|
145
|
+
const getSceneRotationDeg = useCallback(() => baselineRotationDeg + latestUserRotationDegRef.current - worldRotationDeg, [baselineRotationDeg, worldRotationDeg]);
|
|
146
|
+
if (activeRef.current && !viewportInteractionInProgressRef.current) {
|
|
147
|
+
latestUserRotationDegRef.current = floorplanUserRotationDeg;
|
|
148
|
+
}
|
|
149
|
+
useEffect(() => {
|
|
150
|
+
const host = viewportHostRef.current;
|
|
151
|
+
if (!host)
|
|
152
|
+
return;
|
|
153
|
+
const updateSize = () => {
|
|
154
|
+
const rect = host.getBoundingClientRect();
|
|
155
|
+
setSurfaceSize({
|
|
156
|
+
width: Math.max(rect.width, 1),
|
|
157
|
+
height: Math.max(rect.height, 1),
|
|
158
|
+
});
|
|
159
|
+
};
|
|
160
|
+
updateSize();
|
|
161
|
+
const resizeObserver = new ResizeObserver(updateSize);
|
|
162
|
+
resizeObserver.observe(host);
|
|
163
|
+
return () => resizeObserver.disconnect();
|
|
164
|
+
}, []);
|
|
165
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: visibility remounts the observed SVG subtree.
|
|
166
|
+
useLayoutEffect(() => {
|
|
167
|
+
const element = contentRef.current;
|
|
168
|
+
if (!element)
|
|
169
|
+
return;
|
|
170
|
+
let scheduledFrame = null;
|
|
171
|
+
let mutationVersion = 0;
|
|
172
|
+
let observedVersion = 0;
|
|
173
|
+
const measure = () => {
|
|
174
|
+
let bbox;
|
|
175
|
+
try {
|
|
176
|
+
const measured = element.getBBox();
|
|
177
|
+
bbox = {
|
|
178
|
+
x: measured.x,
|
|
179
|
+
y: measured.y,
|
|
180
|
+
width: measured.width,
|
|
181
|
+
height: measured.height,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (bbox.width <= 0 && bbox.height <= 0)
|
|
188
|
+
return;
|
|
189
|
+
setMeasuredSceneBBox((previous) => {
|
|
190
|
+
if (previous &&
|
|
191
|
+
previous.x === bbox.x &&
|
|
192
|
+
previous.y === bbox.y &&
|
|
193
|
+
previous.width === bbox.width &&
|
|
194
|
+
previous.height === bbox.height) {
|
|
195
|
+
return previous;
|
|
196
|
+
}
|
|
197
|
+
return bbox;
|
|
198
|
+
});
|
|
199
|
+
};
|
|
200
|
+
const flushWhenSettled = () => {
|
|
201
|
+
if (observedVersion !== mutationVersion) {
|
|
202
|
+
observedVersion = mutationVersion;
|
|
203
|
+
scheduledFrame = requestAnimationFrame(flushWhenSettled);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
scheduledFrame = null;
|
|
207
|
+
measure();
|
|
208
|
+
};
|
|
209
|
+
const observer = new MutationObserver(() => {
|
|
210
|
+
mutationVersion += 1;
|
|
211
|
+
if (scheduledFrame !== null)
|
|
212
|
+
return;
|
|
213
|
+
observedVersion = mutationVersion - 1;
|
|
214
|
+
scheduledFrame = requestAnimationFrame(flushWhenSettled);
|
|
215
|
+
});
|
|
216
|
+
measure();
|
|
217
|
+
observer.observe(element, {
|
|
218
|
+
attributes: true,
|
|
219
|
+
characterData: true,
|
|
220
|
+
childList: true,
|
|
221
|
+
subtree: true,
|
|
222
|
+
});
|
|
223
|
+
return () => {
|
|
224
|
+
observer.disconnect();
|
|
225
|
+
if (scheduledFrame !== null)
|
|
226
|
+
cancelAnimationFrame(scheduledFrame);
|
|
227
|
+
};
|
|
228
|
+
}, [active]);
|
|
229
|
+
const svgAspectRatio = surfaceSize.width / surfaceSize.height || 1;
|
|
230
|
+
const fittedViewport = useMemo(() => {
|
|
231
|
+
let minX = Number.POSITIVE_INFINITY;
|
|
232
|
+
let maxX = Number.NEGATIVE_INFINITY;
|
|
233
|
+
let minY = Number.POSITIVE_INFINITY;
|
|
234
|
+
let maxY = Number.NEGATIVE_INFINITY;
|
|
235
|
+
for (const point of fitPoints) {
|
|
236
|
+
const svgPoint = rotateFloorplanPoint(point, sceneRotationDeg);
|
|
237
|
+
minX = Math.min(minX, svgPoint.x);
|
|
238
|
+
maxX = Math.max(maxX, svgPoint.x);
|
|
239
|
+
minY = Math.min(minY, svgPoint.y);
|
|
240
|
+
maxY = Math.max(maxY, svgPoint.y);
|
|
241
|
+
}
|
|
242
|
+
if (measuredSceneBBox && measuredSceneBBox.width >= 0 && measuredSceneBBox.height >= 0) {
|
|
243
|
+
const { x, y, width, height } = measuredSceneBBox;
|
|
244
|
+
const corners = [
|
|
245
|
+
rotateFloorplanPoint({ x, y }, sceneRotationDeg),
|
|
246
|
+
rotateFloorplanPoint({ x: x + width, y }, sceneRotationDeg),
|
|
247
|
+
rotateFloorplanPoint({ x, y: y + height }, sceneRotationDeg),
|
|
248
|
+
rotateFloorplanPoint({ x: x + width, y: y + height }, sceneRotationDeg),
|
|
249
|
+
];
|
|
250
|
+
for (const corner of corners) {
|
|
251
|
+
minX = Math.min(minX, corner.x);
|
|
252
|
+
maxX = Math.max(maxX, corner.x);
|
|
253
|
+
minY = Math.min(minY, corner.y);
|
|
254
|
+
maxY = Math.max(maxY, corner.y);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
if (!Number.isFinite(minX) || !Number.isFinite(minY)) {
|
|
258
|
+
return {
|
|
259
|
+
centerX: 0,
|
|
260
|
+
centerY: 0,
|
|
261
|
+
width: resolveEmptyFloorplanViewWidth(svgAspectRatio),
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
const rawWidth = maxX - minX;
|
|
265
|
+
const rawHeight = maxY - minY;
|
|
266
|
+
const paddedWidth = rawWidth + FLOORPLAN_PADDING * 2;
|
|
267
|
+
const paddedHeight = rawHeight + FLOORPLAN_PADDING * 2;
|
|
268
|
+
const width = Math.max(FALLBACK_VIEW_SIZE, paddedWidth, paddedHeight * svgAspectRatio);
|
|
269
|
+
return {
|
|
270
|
+
centerX: (minX + maxX) / 2,
|
|
271
|
+
centerY: (minY + maxY) / 2,
|
|
272
|
+
width,
|
|
273
|
+
};
|
|
274
|
+
}, [fitPoints, measuredSceneBBox, sceneRotationDeg, svgAspectRatio]);
|
|
275
|
+
latestFittedViewportRef.current = fittedViewport;
|
|
276
|
+
const applyViewportImperatively = useCallback((nextViewport) => {
|
|
277
|
+
const nextHeight = nextViewport.width / svgAspectRatio;
|
|
278
|
+
const nextMinX = nextViewport.centerX - nextViewport.width / 2;
|
|
279
|
+
const nextMinY = nextViewport.centerY - nextHeight / 2;
|
|
280
|
+
const nextViewBox = {
|
|
281
|
+
minX: nextMinX,
|
|
282
|
+
minY: nextMinY,
|
|
283
|
+
width: nextViewport.width,
|
|
284
|
+
height: nextHeight,
|
|
285
|
+
};
|
|
286
|
+
const nextPaintViewBox = getFloorplanRotationOverscanViewBox(nextViewBox);
|
|
287
|
+
imperativeViewBoxRef.current = nextViewBox;
|
|
288
|
+
hasUserAdjustedViewportRef.current = true;
|
|
289
|
+
latestViewportRef.current = nextViewport;
|
|
290
|
+
svgRef.current?.setAttribute('viewBox', `${nextMinX} ${nextMinY} ${nextViewport.width} ${nextHeight}`);
|
|
291
|
+
const background = backgroundRef.current;
|
|
292
|
+
if (background) {
|
|
293
|
+
background.setAttribute('x', String(nextPaintViewBox.minX));
|
|
294
|
+
background.setAttribute('y', String(nextPaintViewBox.minY));
|
|
295
|
+
background.setAttribute('width', String(nextPaintViewBox.width));
|
|
296
|
+
background.setAttribute('height', String(nextPaintViewBox.height));
|
|
297
|
+
}
|
|
298
|
+
}, [svgAspectRatio]);
|
|
299
|
+
const applyNavigationState = useCallback((nextViewport, userRotationDeg) => {
|
|
300
|
+
hasUserAdjustedViewportRef.current = true;
|
|
301
|
+
latestUserRotationDegRef.current = userRotationDeg;
|
|
302
|
+
latestViewportRef.current = nextViewport;
|
|
303
|
+
setFloorplanUserRotationDeg((current) => current === userRotationDeg ? current : userRotationDeg);
|
|
304
|
+
setViewport((current) => floorplanViewportEquals(current, nextViewport) ? current : nextViewport);
|
|
305
|
+
}, []);
|
|
306
|
+
const stopViewAnimation = useCallback(() => {
|
|
307
|
+
if (viewAnimationFrameRef.current !== null) {
|
|
308
|
+
window.cancelAnimationFrame(viewAnimationFrameRef.current);
|
|
309
|
+
viewAnimationFrameRef.current = null;
|
|
310
|
+
}
|
|
311
|
+
viewAnimationTargetRef.current = null;
|
|
312
|
+
}, []);
|
|
313
|
+
const animateNavigationState = useCallback((nextViewport, userRotationDeg) => {
|
|
314
|
+
viewAnimationTargetRef.current = {
|
|
315
|
+
viewport: nextViewport,
|
|
316
|
+
userRotationDeg,
|
|
317
|
+
lastTimestamp: viewAnimationTargetRef.current?.lastTimestamp ?? null,
|
|
318
|
+
};
|
|
319
|
+
if (viewAnimationFrameRef.current !== null)
|
|
320
|
+
return;
|
|
321
|
+
const step = (timestamp) => {
|
|
322
|
+
const target = viewAnimationTargetRef.current;
|
|
323
|
+
if (!target) {
|
|
324
|
+
viewAnimationFrameRef.current = null;
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
const currentViewport = latestViewportRef.current ?? target.viewport;
|
|
328
|
+
const currentRotationDeg = latestUserRotationDegRef.current;
|
|
329
|
+
const deltaMs = target.lastTimestamp === null ? 16.67 : timestamp - target.lastTimestamp;
|
|
330
|
+
target.lastTimestamp = timestamp;
|
|
331
|
+
const alpha = 1 - Math.exp(-deltaMs / FLOORPLAN_VIEW_ANIMATION_TIME_CONSTANT_MS);
|
|
332
|
+
const targetRotationDeg = nearestEquivalentDegrees(target.userRotationDeg, currentRotationDeg);
|
|
333
|
+
const nextRotationDeg = currentRotationDeg + (targetRotationDeg - currentRotationDeg) * alpha;
|
|
334
|
+
const animatedViewport = {
|
|
335
|
+
centerX: currentViewport.centerX + (target.viewport.centerX - currentViewport.centerX) * alpha,
|
|
336
|
+
centerY: currentViewport.centerY + (target.viewport.centerY - currentViewport.centerY) * alpha,
|
|
337
|
+
width: currentViewport.width + (target.viewport.width - currentViewport.width) * alpha,
|
|
338
|
+
};
|
|
339
|
+
const isComplete = floorplanViewportWithinEpsilon(animatedViewport, target.viewport, FLOORPLAN_VIEW_ANIMATION_EPSILON) &&
|
|
340
|
+
Math.abs(targetRotationDeg - nextRotationDeg) < FLOORPLAN_ROTATION_ANIMATION_EPSILON_DEG;
|
|
341
|
+
if (isComplete) {
|
|
342
|
+
applyNavigationState(target.viewport, targetRotationDeg);
|
|
343
|
+
viewAnimationTargetRef.current = null;
|
|
344
|
+
viewAnimationFrameRef.current = null;
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
applyNavigationState(animatedViewport, nextRotationDeg);
|
|
348
|
+
viewAnimationFrameRef.current = window.requestAnimationFrame(step);
|
|
349
|
+
};
|
|
350
|
+
viewAnimationFrameRef.current = window.requestAnimationFrame(step);
|
|
351
|
+
}, [applyNavigationState]);
|
|
352
|
+
const applyNavigationView = useCallback((localCenter, userRotationDeg, viewWidth, options) => {
|
|
353
|
+
const currentViewport = latestViewportRef.current ?? latestFittedViewportRef.current;
|
|
354
|
+
if (!currentViewport)
|
|
355
|
+
return;
|
|
356
|
+
const nextSceneRotationDeg = baselineRotationDeg + userRotationDeg - worldRotationDeg;
|
|
357
|
+
const centerSvg = rotateFloorplanPoint(localCenter, nextSceneRotationDeg);
|
|
358
|
+
const nextWidth = resolveFloorplanViewWidth(viewWidth ?? currentViewport.width, currentViewport.width, latestFittedViewportRef.current, options?.clampViewWidth !== false);
|
|
359
|
+
const nextViewport = { centerX: centerSvg.x, centerY: centerSvg.y, width: nextWidth };
|
|
360
|
+
if (options?.smooth) {
|
|
361
|
+
animateNavigationState(nextViewport, userRotationDeg);
|
|
362
|
+
}
|
|
363
|
+
else {
|
|
364
|
+
stopViewAnimation();
|
|
365
|
+
applyNavigationState(nextViewport, userRotationDeg);
|
|
366
|
+
}
|
|
367
|
+
}, [
|
|
368
|
+
animateNavigationState,
|
|
369
|
+
applyNavigationState,
|
|
370
|
+
baselineRotationDeg,
|
|
371
|
+
stopViewAnimation,
|
|
372
|
+
worldRotationDeg,
|
|
373
|
+
]);
|
|
374
|
+
const smoothNavigationView = useCallback((localCenter, userRotationDeg, viewWidth) => {
|
|
375
|
+
applyNavigationView(localCenter, userRotationDeg, viewWidth, { smooth: true });
|
|
376
|
+
}, [applyNavigationView]);
|
|
377
|
+
useEffect(() => () => stopViewAnimation(), [stopViewAnimation]);
|
|
378
|
+
const publishLocalNavigationPose = useCallback((localCenter, userRotationDeg, viewWidth) => {
|
|
379
|
+
const currentViewport = latestViewportRef.current ?? latestFittedViewportRef.current;
|
|
380
|
+
const resolvedViewWidth = viewWidth ?? currentViewport?.width;
|
|
381
|
+
if (!(resolvedViewWidth && resolvedViewWidth > 0))
|
|
382
|
+
return;
|
|
383
|
+
const worldCenter = floorplanLocalToWorldPoint(localCenter, worldOrigin, worldRotationY);
|
|
384
|
+
const targetY = latestNavigationPoseRef.current?.target[1] ?? planeWorldY;
|
|
385
|
+
publishNavigationPose({
|
|
386
|
+
source: '2d',
|
|
387
|
+
target: [worldCenter.x, targetY, worldCenter.z],
|
|
388
|
+
azimuth: cameraAzimuthFromFloorplanRotation(userRotationDeg, baselineRotationDeg),
|
|
389
|
+
viewWidth: resolvedViewWidth,
|
|
390
|
+
});
|
|
391
|
+
}, [baselineRotationDeg, planeWorldY, publishNavigationPose, worldOrigin, worldRotationY]);
|
|
392
|
+
const applyNavigationSyncPresentation = useCallback((pose) => {
|
|
393
|
+
const currentViewport = latestViewportRef.current ?? latestFittedViewportRef.current;
|
|
394
|
+
const svg = svgRef.current;
|
|
395
|
+
if (!(currentViewport && svg))
|
|
396
|
+
return;
|
|
397
|
+
let presentationState = navigationSyncPresentationRef.current;
|
|
398
|
+
if (!presentationState) {
|
|
399
|
+
const initialUserRotationDeg = latestUserRotationDegRef.current;
|
|
400
|
+
const initialSceneRotationDeg = baselineRotationDeg + initialUserRotationDeg - worldRotationDeg;
|
|
401
|
+
presentationState = {
|
|
402
|
+
initialUserRotationDeg,
|
|
403
|
+
initialSceneRotationDeg,
|
|
404
|
+
latestUserRotationDeg: initialUserRotationDeg,
|
|
405
|
+
latestSceneRotationDeg: initialSceneRotationDeg,
|
|
406
|
+
latestViewport: currentViewport,
|
|
407
|
+
svg,
|
|
408
|
+
svgStyle: {
|
|
409
|
+
transform: svg.style.transform,
|
|
410
|
+
transformOrigin: svg.style.transformOrigin,
|
|
411
|
+
willChange: svg.style.willChange,
|
|
412
|
+
},
|
|
413
|
+
};
|
|
414
|
+
navigationSyncPresentationRef.current = presentationState;
|
|
415
|
+
viewportInteractionInProgressRef.current = true;
|
|
416
|
+
svg.style.transformOrigin = 'center';
|
|
417
|
+
svg.style.willChange = 'transform';
|
|
418
|
+
}
|
|
419
|
+
const nextUserRotationDeg = floorplanRotationFromCameraAzimuth(pose.azimuth, presentationState.latestUserRotationDeg, baselineRotationDeg);
|
|
420
|
+
const localCenter = worldToFloorplanLocalPoint(pose.target[0], pose.target[2], worldOrigin, worldRotationY);
|
|
421
|
+
const nextWidth = resolveFloorplanViewWidth(pose.viewWidth, currentViewport.width, latestFittedViewportRef.current, false);
|
|
422
|
+
const nextSceneRotationDeg = baselineRotationDeg + nextUserRotationDeg - worldRotationDeg;
|
|
423
|
+
const nextCenterSvg = rotateFloorplanPoint(localCenter, nextSceneRotationDeg);
|
|
424
|
+
const nextViewport = { centerX: nextCenterSvg.x, centerY: nextCenterSvg.y, width: nextWidth };
|
|
425
|
+
const presentationCenterSvg = rotateFloorplanPoint(localCenter, presentationState.initialSceneRotationDeg);
|
|
426
|
+
applyViewportImperatively({
|
|
427
|
+
centerX: presentationCenterSvg.x,
|
|
428
|
+
centerY: presentationCenterSvg.y,
|
|
429
|
+
width: nextWidth,
|
|
430
|
+
});
|
|
431
|
+
svg.style.transform = `rotate(${nextUserRotationDeg - presentationState.initialUserRotationDeg}deg)`;
|
|
432
|
+
latestUserRotationDegRef.current = nextUserRotationDeg;
|
|
433
|
+
latestViewportRef.current = nextViewport;
|
|
434
|
+
presentationState.latestUserRotationDeg = nextUserRotationDeg;
|
|
435
|
+
presentationState.latestSceneRotationDeg = nextSceneRotationDeg;
|
|
436
|
+
presentationState.latestViewport = nextViewport;
|
|
437
|
+
}, [applyViewportImperatively, baselineRotationDeg, worldOrigin, worldRotationDeg, worldRotationY]);
|
|
438
|
+
const commitNavigationSyncPresentation = useCallback((_pose) => {
|
|
439
|
+
const presentationState = navigationSyncPresentationRef.current;
|
|
440
|
+
if (!presentationState)
|
|
441
|
+
return;
|
|
442
|
+
applyViewportImperatively(presentationState.latestViewport);
|
|
443
|
+
const scene = sceneRef.current;
|
|
444
|
+
if (scene) {
|
|
445
|
+
if (presentationState.latestSceneRotationDeg === 0) {
|
|
446
|
+
scene.removeAttribute('transform');
|
|
447
|
+
}
|
|
448
|
+
else {
|
|
449
|
+
scene.setAttribute('transform', `rotate(${presentationState.latestSceneRotationDeg})`);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
restoreFloorplanNavigationSyncPresentation(presentationState);
|
|
453
|
+
navigationSyncPresentationRef.current = null;
|
|
454
|
+
viewportInteractionInProgressRef.current = false;
|
|
455
|
+
imperativeViewBoxRef.current = null;
|
|
456
|
+
setFloorplanUserRotationDeg((current) => current === presentationState.latestUserRotationDeg
|
|
457
|
+
? current
|
|
458
|
+
: presentationState.latestUserRotationDeg);
|
|
459
|
+
setViewport((current) => floorplanViewportEquals(current, presentationState.latestViewport)
|
|
460
|
+
? current
|
|
461
|
+
: presentationState.latestViewport);
|
|
462
|
+
}, [applyViewportImperatively]);
|
|
463
|
+
applyNavigationSyncPresentationRef.current = applyNavigationSyncPresentation;
|
|
464
|
+
commitNavigationSyncPresentationRef.current = commitNavigationSyncPresentation;
|
|
465
|
+
if (!navigationSyncSchedulerRef.current) {
|
|
466
|
+
navigationSyncSchedulerRef.current = createFloorplanNavigationSyncScheduler({
|
|
467
|
+
applyPresentation: (pose) => applyNavigationSyncPresentationRef.current(pose),
|
|
468
|
+
commit: (pose) => commitNavigationSyncPresentationRef.current(pose),
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
const navigationSyncScheduler = navigationSyncSchedulerRef.current;
|
|
472
|
+
const discardNavigationSyncPresentation = useCallback(() => {
|
|
473
|
+
navigationSyncScheduler.discard();
|
|
474
|
+
const presentationState = navigationSyncPresentationRef.current;
|
|
475
|
+
if (presentationState) {
|
|
476
|
+
restoreFloorplanNavigationSyncPresentation(presentationState);
|
|
477
|
+
navigationSyncPresentationRef.current = null;
|
|
478
|
+
}
|
|
479
|
+
viewportInteractionInProgressRef.current = false;
|
|
480
|
+
imperativeViewBoxRef.current = null;
|
|
481
|
+
}, [navigationSyncScheduler]);
|
|
482
|
+
const syncViewportToNavigationPose = useCallback((pose) => {
|
|
483
|
+
if (!activeRef.current)
|
|
484
|
+
return;
|
|
485
|
+
const localNavigationInProgress = viewportInteractionInProgressRef.current && navigationSyncPresentationRef.current === null;
|
|
486
|
+
if (!canApplyFloorplanNavigationSync(localNavigationInProgress))
|
|
487
|
+
return;
|
|
488
|
+
if (!hasContent && !(surfaceSize.width > 1 && surfaceSize.height > 1))
|
|
489
|
+
return;
|
|
490
|
+
let scheduledPose = pose;
|
|
491
|
+
if (!hasContent) {
|
|
492
|
+
const nextUserRotationDeg = floorplanRotationFromCameraAzimuth(pose.azimuth, latestUserRotationDegRef.current, baselineRotationDeg);
|
|
493
|
+
const navigationLocalCenter = worldToFloorplanLocalPoint(pose.target[0], pose.target[2], worldOrigin, worldRotationY);
|
|
494
|
+
const resolvedView = resolveFloorplanSyncedView({
|
|
495
|
+
emptyLocalCenter: { x: 0, y: 0 },
|
|
496
|
+
emptyViewWidth: resolveEmptyFloorplanViewWidth(svgAspectRatio),
|
|
497
|
+
hasAuthoredContent: false,
|
|
498
|
+
navigationLocalCenter,
|
|
499
|
+
navigationUserRotationDeg: nextUserRotationDeg,
|
|
500
|
+
navigationViewWidth: pose.viewWidth,
|
|
501
|
+
});
|
|
502
|
+
const worldCenter = floorplanLocalToWorldPoint(resolvedView.localCenter, worldOrigin, worldRotationY);
|
|
503
|
+
scheduledPose = {
|
|
504
|
+
...pose,
|
|
505
|
+
target: [worldCenter.x, planeWorldY, worldCenter.z],
|
|
506
|
+
azimuth: cameraAzimuthFromFloorplanRotation(resolvedView.userRotationDeg, baselineRotationDeg),
|
|
507
|
+
viewWidth: resolvedView.viewWidth,
|
|
508
|
+
};
|
|
509
|
+
const blankKey = contentKey ?? 'blank-floorplan';
|
|
510
|
+
if (blankInitialViewPublishedForKeyRef.current !== blankKey) {
|
|
511
|
+
blankInitialViewPublishedForKeyRef.current = blankKey;
|
|
512
|
+
publishNavigationPose({
|
|
513
|
+
source: '2d',
|
|
514
|
+
target: [...scheduledPose.target],
|
|
515
|
+
azimuth: scheduledPose.azimuth,
|
|
516
|
+
viewWidth: scheduledPose.viewWidth,
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
navigationSyncScheduler.update(scheduledPose);
|
|
521
|
+
}, [
|
|
522
|
+
baselineRotationDeg,
|
|
523
|
+
contentKey,
|
|
524
|
+
hasContent,
|
|
525
|
+
navigationSyncScheduler,
|
|
526
|
+
planeWorldY,
|
|
527
|
+
publishNavigationPose,
|
|
528
|
+
surfaceSize.height,
|
|
529
|
+
surfaceSize.width,
|
|
530
|
+
svgAspectRatio,
|
|
531
|
+
worldOrigin,
|
|
532
|
+
worldRotationY,
|
|
533
|
+
]);
|
|
534
|
+
const cancelHiddenCompassAnimation = useCallback(() => {
|
|
535
|
+
if (hiddenCompassAnimationRef.current !== null) {
|
|
536
|
+
cancelAnimationFrame(hiddenCompassAnimationRef.current);
|
|
537
|
+
hiddenCompassAnimationRef.current = null;
|
|
538
|
+
}
|
|
539
|
+
}, []);
|
|
540
|
+
const animateHiddenCompassNeedle = useCallback((targetDeg) => {
|
|
541
|
+
cancelHiddenCompassAnimation();
|
|
542
|
+
let last = performance.now();
|
|
543
|
+
const tick = (now) => {
|
|
544
|
+
hiddenCompassAnimationRef.current = null;
|
|
545
|
+
if (activeRef.current)
|
|
546
|
+
return;
|
|
547
|
+
const deltaMs = now - last;
|
|
548
|
+
last = now;
|
|
549
|
+
const currentDeg = latestUserRotationDegRef.current;
|
|
550
|
+
const decay = Math.exp(-deltaMs / FLOORPLAN_VIEW_ANIMATION_TIME_CONSTANT_MS);
|
|
551
|
+
let nextDeg = targetDeg - (targetDeg - currentDeg) * decay;
|
|
552
|
+
if (Math.abs(targetDeg - nextDeg) < 0.05)
|
|
553
|
+
nextDeg = targetDeg;
|
|
554
|
+
latestUserRotationDegRef.current = nextDeg;
|
|
555
|
+
setFloorplanCompassRotation(compassNeedleRef.current, nextDeg);
|
|
556
|
+
if (nextDeg !== targetDeg) {
|
|
557
|
+
hiddenCompassAnimationRef.current = requestAnimationFrame(tick);
|
|
558
|
+
}
|
|
559
|
+
};
|
|
560
|
+
hiddenCompassAnimationRef.current = requestAnimationFrame(tick);
|
|
561
|
+
}, [cancelHiddenCompassAnimation]);
|
|
562
|
+
const receiveNavigationPose = useCallback((pose) => {
|
|
563
|
+
const previousPose = latestNavigationPoseRef.current;
|
|
564
|
+
if (previousPose?.source === pose.source && previousPose.revision === pose.revision)
|
|
565
|
+
return;
|
|
566
|
+
latestNavigationPoseRef.current = pose;
|
|
567
|
+
if (!activeRef.current) {
|
|
568
|
+
const nextDeg = floorplanRotationFromCameraAzimuth(pose.azimuth, latestUserRotationDegRef.current, baselineRotationDeg);
|
|
569
|
+
if (pose.source === '3d') {
|
|
570
|
+
cancelHiddenCompassAnimation();
|
|
571
|
+
latestUserRotationDegRef.current = nextDeg;
|
|
572
|
+
setFloorplanCompassRotation(compassNeedleRef.current, nextDeg);
|
|
573
|
+
}
|
|
574
|
+
else {
|
|
575
|
+
animateHiddenCompassNeedle(nextDeg);
|
|
576
|
+
}
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
if (pose.source === '3d')
|
|
580
|
+
syncViewportToNavigationPose(pose);
|
|
581
|
+
}, [
|
|
582
|
+
animateHiddenCompassNeedle,
|
|
583
|
+
baselineRotationDeg,
|
|
584
|
+
cancelHiddenCompassAnimation,
|
|
585
|
+
syncViewportToNavigationPose,
|
|
586
|
+
]);
|
|
587
|
+
useEffect(() => subscribeNavigationPose(receiveNavigationPose), [receiveNavigationPose, subscribeNavigationPose]);
|
|
588
|
+
useLayoutEffect(() => {
|
|
589
|
+
const wasActive = previousActiveRef.current;
|
|
590
|
+
previousActiveRef.current = active;
|
|
591
|
+
if (!active || wasActive)
|
|
592
|
+
return;
|
|
593
|
+
const pose = latestNavigationPoseRef.current;
|
|
594
|
+
if (pose?.source === '3d') {
|
|
595
|
+
syncViewportToNavigationPose(activationAzimuth === null ? pose : { ...pose, azimuth: activationAzimuth });
|
|
596
|
+
}
|
|
597
|
+
}, [activationAzimuth, active, syncViewportToNavigationPose]);
|
|
598
|
+
useEffect(() => {
|
|
599
|
+
if (active)
|
|
600
|
+
return;
|
|
601
|
+
discardNavigationSyncPresentation();
|
|
602
|
+
}, [active, discardNavigationSyncPresentation]);
|
|
603
|
+
useEffect(() => () => {
|
|
604
|
+
discardNavigationSyncPresentation();
|
|
605
|
+
cancelHiddenCompassAnimation();
|
|
606
|
+
}, [cancelHiddenCompassAnimation, discardNavigationSyncPresentation]);
|
|
607
|
+
useLayoutEffect(() => {
|
|
608
|
+
if (!active) {
|
|
609
|
+
setFloorplanCompassRotation(compassNeedleRef.current, latestUserRotationDegRef.current);
|
|
610
|
+
}
|
|
611
|
+
});
|
|
612
|
+
useEffect(() => {
|
|
613
|
+
const contentChanged = previousContentKeyRef.current !== contentKey;
|
|
614
|
+
if (contentChanged) {
|
|
615
|
+
previousContentKeyRef.current = contentKey;
|
|
616
|
+
if (!latestNavigationPoseRef.current) {
|
|
617
|
+
stopViewAnimation();
|
|
618
|
+
hasUserAdjustedViewportRef.current = false;
|
|
619
|
+
latestUserRotationDegRef.current = 0;
|
|
620
|
+
latestViewportRef.current = null;
|
|
621
|
+
setFloorplanUserRotationDeg(0);
|
|
622
|
+
setViewport(null);
|
|
623
|
+
}
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
if (!(hasUserAdjustedViewportRef.current || fitSuspended())) {
|
|
627
|
+
setViewport((current) => floorplanViewportEquals(current, fittedViewport) ? current : fittedViewport);
|
|
628
|
+
}
|
|
629
|
+
}, [contentKey, fitSuspended, fittedViewport, stopViewAnimation]);
|
|
630
|
+
const viewBox = useMemo(() => {
|
|
631
|
+
const currentViewport = viewport ?? fittedViewport;
|
|
632
|
+
const width = currentViewport.width;
|
|
633
|
+
const height = width / svgAspectRatio;
|
|
634
|
+
return {
|
|
635
|
+
minX: currentViewport.centerX - width / 2,
|
|
636
|
+
minY: currentViewport.centerY - height / 2,
|
|
637
|
+
width,
|
|
638
|
+
height,
|
|
639
|
+
};
|
|
640
|
+
}, [fittedViewport, svgAspectRatio, viewport]);
|
|
641
|
+
const presentationViewBox = resolveFloorplanPresentationViewBox(viewBox, imperativeViewBoxRef.current, viewportInteractionInProgressRef.current);
|
|
642
|
+
const paintViewBox = getFloorplanRotationOverscanViewBox(viewBox);
|
|
643
|
+
const presentationPaintViewBox = getFloorplanRotationOverscanViewBox(presentationViewBox);
|
|
644
|
+
const worldUnitsPerPixel = useMemo(() => {
|
|
645
|
+
const widthUnitsPerPixel = viewBox.width / Math.max(surfaceSize.width, 1);
|
|
646
|
+
const heightUnitsPerPixel = viewBox.height / Math.max(surfaceSize.height, 1);
|
|
647
|
+
return (widthUnitsPerPixel + heightUnitsPerPixel) / 2;
|
|
648
|
+
}, [surfaceSize.height, surfaceSize.width, viewBox.height, viewBox.width]);
|
|
649
|
+
const getLiveUnitsPerPixel = useCallback(() => {
|
|
650
|
+
const width = latestViewportRef.current?.width ?? viewBox.width;
|
|
651
|
+
const height = width / svgAspectRatio;
|
|
652
|
+
const widthUnitsPerPixel = width / Math.max(surfaceSize.width, 1);
|
|
653
|
+
const heightUnitsPerPixel = height / Math.max(surfaceSize.height, 1);
|
|
654
|
+
return (widthUnitsPerPixel + heightUnitsPerPixel) / 2;
|
|
655
|
+
}, [surfaceSize.height, surfaceSize.width, svgAspectRatio, viewBox.width]);
|
|
656
|
+
const liveUnitsPerPixel = viewBox.width / Math.max(surfaceSize.width, 1);
|
|
657
|
+
const unitsPerPixel = renderUnitsPerPixel ?? liveUnitsPerPixel;
|
|
658
|
+
latestRenderUnitsPerPixelRef.current = unitsPerPixel;
|
|
659
|
+
const getScenePointFromClientPoint = useCallback((clientX, clientY) => {
|
|
660
|
+
const svg = svgRef.current;
|
|
661
|
+
const target = sceneRef.current ?? svg;
|
|
662
|
+
const ctm = target?.getScreenCTM();
|
|
663
|
+
if (!(svg && ctm))
|
|
664
|
+
return null;
|
|
665
|
+
const screenPoint = svg.createSVGPoint();
|
|
666
|
+
screenPoint.x = clientX;
|
|
667
|
+
screenPoint.y = clientY;
|
|
668
|
+
const transformedPoint = screenPoint.matrixTransform(ctm.inverse());
|
|
669
|
+
return { x: transformedPoint.x, y: transformedPoint.y };
|
|
670
|
+
}, []);
|
|
671
|
+
const alignToNorth = useCallback(() => {
|
|
672
|
+
if (!activeRef.current) {
|
|
673
|
+
const pose = latestNavigationPoseRef.current;
|
|
674
|
+
if (!pose)
|
|
675
|
+
return;
|
|
676
|
+
const currentRotation = latestUserRotationDegRef.current;
|
|
677
|
+
publishNavigationPose({
|
|
678
|
+
source: '2d',
|
|
679
|
+
target: [...pose.target],
|
|
680
|
+
azimuth: cameraAzimuthFromFloorplanRotation(nearestEquivalentDegrees(0, currentRotation), baselineRotationDeg),
|
|
681
|
+
viewWidth: pose.viewWidth,
|
|
682
|
+
});
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
const currentViewport = latestViewportRef.current ?? latestFittedViewportRef.current;
|
|
686
|
+
if (!currentViewport)
|
|
687
|
+
return;
|
|
688
|
+
const currentUserRotationDeg = latestUserRotationDegRef.current;
|
|
689
|
+
const currentSceneRotationDeg = baselineRotationDeg + currentUserRotationDeg - worldRotationDeg;
|
|
690
|
+
const localCenter = rotateFloorplanPoint({ x: currentViewport.centerX, y: currentViewport.centerY }, -currentSceneRotationDeg);
|
|
691
|
+
const nextUserRotationDeg = nearestEquivalentDegrees(0, currentUserRotationDeg);
|
|
692
|
+
smoothNavigationView(localCenter, nextUserRotationDeg, currentViewport.width);
|
|
693
|
+
publishLocalNavigationPose(localCenter, nextUserRotationDeg, currentViewport.width);
|
|
694
|
+
}, [
|
|
695
|
+
baselineRotationDeg,
|
|
696
|
+
publishLocalNavigationPose,
|
|
697
|
+
publishNavigationPose,
|
|
698
|
+
smoothNavigationView,
|
|
699
|
+
worldRotationDeg,
|
|
700
|
+
]);
|
|
701
|
+
const applyRotationImperatively = useCallback((rotationState, nextUserRotationDeg) => {
|
|
702
|
+
const currentViewport = latestViewportRef.current ?? rotationState.latestViewport;
|
|
703
|
+
const nextSceneRotationDeg = baselineRotationDeg + nextUserRotationDeg - worldRotationDeg;
|
|
704
|
+
const nextCenterSvg = rotateFloorplanPoint(rotationState.viewportCenterLocal, nextSceneRotationDeg);
|
|
705
|
+
const nextViewport = {
|
|
706
|
+
centerX: nextCenterSvg.x,
|
|
707
|
+
centerY: nextCenterSvg.y,
|
|
708
|
+
width: currentViewport.width,
|
|
709
|
+
};
|
|
710
|
+
hasUserAdjustedViewportRef.current = true;
|
|
711
|
+
latestUserRotationDegRef.current = nextUserRotationDeg;
|
|
712
|
+
latestViewportRef.current = nextViewport;
|
|
713
|
+
setFloorplanCompassRotation(compassNeedleRef.current, nextUserRotationDeg);
|
|
714
|
+
rotationState.svg.style.transform = `rotate(${nextUserRotationDeg - rotationState.initialUserRotationDeg}deg)`;
|
|
715
|
+
rotationState.latestUserRotationDeg = nextUserRotationDeg;
|
|
716
|
+
rotationState.latestViewport = nextViewport;
|
|
717
|
+
}, [baselineRotationDeg, worldRotationDeg]);
|
|
718
|
+
const commitZoom = useCallback(() => {
|
|
719
|
+
if (zoomCommitTimerRef.current !== null) {
|
|
720
|
+
window.clearTimeout(zoomCommitTimerRef.current);
|
|
721
|
+
zoomCommitTimerRef.current = null;
|
|
722
|
+
}
|
|
723
|
+
const nextViewport = latestViewportRef.current;
|
|
724
|
+
const pendingPose = zoomPoseRef.current;
|
|
725
|
+
zoomPoseRef.current = null;
|
|
726
|
+
viewportInteractionInProgressRef.current = false;
|
|
727
|
+
imperativeViewBoxRef.current = null;
|
|
728
|
+
if (!nextViewport)
|
|
729
|
+
return;
|
|
730
|
+
setRenderUnitsPerPixel((current) => current ?? latestRenderUnitsPerPixelRef.current);
|
|
731
|
+
setViewport((current) => floorplanViewportEquals(current, nextViewport) ? current : nextViewport);
|
|
732
|
+
if (renderScaleCommitTimerRef.current !== null) {
|
|
733
|
+
window.clearTimeout(renderScaleCommitTimerRef.current);
|
|
734
|
+
}
|
|
735
|
+
renderScaleCommitTimerRef.current = window.setTimeout(() => {
|
|
736
|
+
renderScaleCommitTimerRef.current = null;
|
|
737
|
+
setRenderUnitsPerPixel(null);
|
|
738
|
+
}, 350);
|
|
739
|
+
if (pendingPose) {
|
|
740
|
+
publishLocalNavigationPose(pendingPose.localCenter, pendingPose.userRotationDeg, pendingPose.viewWidth);
|
|
741
|
+
}
|
|
742
|
+
}, [publishLocalNavigationPose]);
|
|
743
|
+
const scheduleZoomCommit = useCallback(() => {
|
|
744
|
+
if (zoomCommitTimerRef.current !== null)
|
|
745
|
+
window.clearTimeout(zoomCommitTimerRef.current);
|
|
746
|
+
zoomCommitTimerRef.current = window.setTimeout(commitZoom, 300);
|
|
747
|
+
}, [commitZoom]);
|
|
748
|
+
const zoomViewportAtClientPoint = useCallback((clientX, clientY, widthFactor) => {
|
|
749
|
+
if (!canZoomFloorplanDuringNavigation(rotationStateRef.current !== null))
|
|
750
|
+
return;
|
|
751
|
+
if (!Number.isFinite(widthFactor) || widthFactor <= 0)
|
|
752
|
+
return;
|
|
753
|
+
const localPoint = getScenePointFromClientPoint(clientX, clientY);
|
|
754
|
+
if (!localPoint)
|
|
755
|
+
return;
|
|
756
|
+
const currentSceneRotationDeg = baselineRotationDeg + latestUserRotationDegRef.current - worldRotationDeg;
|
|
757
|
+
const svgPoint = rotateFloorplanPoint(localPoint, currentSceneRotationDeg);
|
|
758
|
+
const currentViewport = latestViewportRef.current ?? latestFittedViewportRef.current;
|
|
759
|
+
if (!currentViewport)
|
|
760
|
+
return;
|
|
761
|
+
const currentViewBox = {
|
|
762
|
+
minX: currentViewport.centerX - currentViewport.width / 2,
|
|
763
|
+
minY: currentViewport.centerY - currentViewport.width / svgAspectRatio / 2,
|
|
764
|
+
width: currentViewport.width,
|
|
765
|
+
height: currentViewport.width / svgAspectRatio,
|
|
766
|
+
};
|
|
767
|
+
const nextWidth = resolveFloorplanViewWidth(currentViewport.width * widthFactor, currentViewport.width, latestFittedViewportRef.current, true);
|
|
768
|
+
const nextHeight = nextWidth / svgAspectRatio;
|
|
769
|
+
const normalizedX = (svgPoint.x - currentViewBox.minX) / currentViewBox.width;
|
|
770
|
+
const normalizedY = (svgPoint.y - currentViewBox.minY) / currentViewBox.height;
|
|
771
|
+
const nextMinX = svgPoint.x - normalizedX * nextWidth;
|
|
772
|
+
const nextMinY = svgPoint.y - normalizedY * nextHeight;
|
|
773
|
+
const nextCenterSvg = {
|
|
774
|
+
x: nextMinX + nextWidth / 2,
|
|
775
|
+
y: nextMinY + nextHeight / 2,
|
|
776
|
+
};
|
|
777
|
+
const localCenter = rotateFloorplanPoint(nextCenterSvg, -currentSceneRotationDeg);
|
|
778
|
+
const nextViewport = {
|
|
779
|
+
centerX: nextCenterSvg.x,
|
|
780
|
+
centerY: nextCenterSvg.y,
|
|
781
|
+
width: nextWidth,
|
|
782
|
+
};
|
|
783
|
+
stopViewAnimation();
|
|
784
|
+
if (renderScaleCommitTimerRef.current !== null) {
|
|
785
|
+
window.clearTimeout(renderScaleCommitTimerRef.current);
|
|
786
|
+
renderScaleCommitTimerRef.current = null;
|
|
787
|
+
}
|
|
788
|
+
viewportInteractionInProgressRef.current = true;
|
|
789
|
+
applyViewportImperatively(nextViewport);
|
|
790
|
+
scheduleZoomCommit();
|
|
791
|
+
const userRotationDeg = latestUserRotationDegRef.current;
|
|
792
|
+
zoomPoseRef.current = { localCenter, userRotationDeg, viewWidth: nextWidth };
|
|
793
|
+
if (publishContinuously()) {
|
|
794
|
+
publishLocalNavigationPose(localCenter, userRotationDeg, nextWidth);
|
|
795
|
+
}
|
|
796
|
+
}, [
|
|
797
|
+
applyViewportImperatively,
|
|
798
|
+
baselineRotationDeg,
|
|
799
|
+
getScenePointFromClientPoint,
|
|
800
|
+
publishContinuously,
|
|
801
|
+
publishLocalNavigationPose,
|
|
802
|
+
scheduleZoomCommit,
|
|
803
|
+
stopViewAnimation,
|
|
804
|
+
svgAspectRatio,
|
|
805
|
+
worldRotationDeg,
|
|
806
|
+
]);
|
|
807
|
+
const commitPan = useCallback(() => {
|
|
808
|
+
const nextViewport = latestViewportRef.current;
|
|
809
|
+
const pendingPose = panPoseRef.current;
|
|
810
|
+
panPoseRef.current = null;
|
|
811
|
+
viewportInteractionInProgressRef.current = false;
|
|
812
|
+
imperativeViewBoxRef.current = null;
|
|
813
|
+
if (nextViewport) {
|
|
814
|
+
setViewport((current) => floorplanViewportEquals(current, nextViewport) ? current : nextViewport);
|
|
815
|
+
}
|
|
816
|
+
if (pendingPose) {
|
|
817
|
+
publishLocalNavigationPose(pendingPose.localCenter, pendingPose.userRotationDeg, pendingPose.viewWidth);
|
|
818
|
+
}
|
|
819
|
+
}, [publishLocalNavigationPose]);
|
|
820
|
+
const commitRotation = useCallback((rotationState) => {
|
|
821
|
+
viewportInteractionInProgressRef.current = false;
|
|
822
|
+
imperativeViewBoxRef.current = null;
|
|
823
|
+
queueFloorplanRotationPresentationRestore(pendingRotationRestoreRef, rotationState);
|
|
824
|
+
setFloorplanUserRotationDeg((current) => current === rotationState.latestUserRotationDeg
|
|
825
|
+
? current
|
|
826
|
+
: rotationState.latestUserRotationDeg);
|
|
827
|
+
setViewport((current) => floorplanViewportEquals(current, rotationState.latestViewport)
|
|
828
|
+
? current
|
|
829
|
+
: rotationState.latestViewport);
|
|
830
|
+
publishLocalNavigationPose(rotationState.viewportCenterLocal, rotationState.latestUserRotationDeg, rotationState.latestViewport.width);
|
|
831
|
+
}, [publishLocalNavigationPose]);
|
|
832
|
+
useLayoutEffect(() => {
|
|
833
|
+
flushFloorplanRotationPresentationRestore(pendingRotationRestoreRef);
|
|
834
|
+
});
|
|
835
|
+
const endPointerNavigation = useCallback((event) => {
|
|
836
|
+
const wasPanning = panStateRef.current !== null;
|
|
837
|
+
const rotationState = rotationStateRef.current;
|
|
838
|
+
if (event &&
|
|
839
|
+
(panStateRef.current || rotationStateRef.current) &&
|
|
840
|
+
event.currentTarget.hasPointerCapture(event.pointerId)) {
|
|
841
|
+
event.currentTarget.releasePointerCapture(event.pointerId);
|
|
842
|
+
}
|
|
843
|
+
panStateRef.current = null;
|
|
844
|
+
rotationStateRef.current = null;
|
|
845
|
+
if (wasPanning)
|
|
846
|
+
commitPan();
|
|
847
|
+
if (rotationState)
|
|
848
|
+
commitRotation(rotationState);
|
|
849
|
+
setIsPanning(false);
|
|
850
|
+
setIsRotating(false);
|
|
851
|
+
window.setTimeout(() => {
|
|
852
|
+
navigationClickSuppressedRef.current = false;
|
|
853
|
+
}, 0);
|
|
854
|
+
}, [commitPan, commitRotation]);
|
|
855
|
+
useEffect(() => {
|
|
856
|
+
if (active)
|
|
857
|
+
return;
|
|
858
|
+
blankInitialViewPublishedForKeyRef.current = null;
|
|
859
|
+
stopViewAnimation();
|
|
860
|
+
const rotationState = rotationStateRef.current;
|
|
861
|
+
finalizeFloorplanNavigation({
|
|
862
|
+
zoomPending: zoomCommitTimerRef.current !== null || zoomPoseRef.current !== null,
|
|
863
|
+
panActive: panStateRef.current !== null,
|
|
864
|
+
rotationState,
|
|
865
|
+
commitZoom,
|
|
866
|
+
commitPan,
|
|
867
|
+
commitRotation,
|
|
868
|
+
});
|
|
869
|
+
spacePanPressedRef.current = false;
|
|
870
|
+
panStateRef.current = null;
|
|
871
|
+
rotationStateRef.current = null;
|
|
872
|
+
viewportInteractionInProgressRef.current = false;
|
|
873
|
+
imperativeViewBoxRef.current = null;
|
|
874
|
+
setIsSpacePanPressed(false);
|
|
875
|
+
setIsPanning(false);
|
|
876
|
+
setIsRotating(false);
|
|
877
|
+
}, [active, commitPan, commitRotation, commitZoom, stopViewAnimation]);
|
|
878
|
+
useEffect(() => {
|
|
879
|
+
if (!active)
|
|
880
|
+
return;
|
|
881
|
+
setMeasuredSceneBBox(null);
|
|
882
|
+
if (!latestNavigationPoseRef.current) {
|
|
883
|
+
stopViewAnimation();
|
|
884
|
+
hasUserAdjustedViewportRef.current = false;
|
|
885
|
+
latestUserRotationDegRef.current = 0;
|
|
886
|
+
latestViewportRef.current = null;
|
|
887
|
+
setFloorplanUserRotationDeg(0);
|
|
888
|
+
setViewport(null);
|
|
889
|
+
}
|
|
890
|
+
}, [active, stopViewAnimation]);
|
|
891
|
+
useEffect(() => () => {
|
|
892
|
+
if (zoomCommitTimerRef.current !== null)
|
|
893
|
+
window.clearTimeout(zoomCommitTimerRef.current);
|
|
894
|
+
if (renderScaleCommitTimerRef.current !== null) {
|
|
895
|
+
window.clearTimeout(renderScaleCommitTimerRef.current);
|
|
896
|
+
}
|
|
897
|
+
viewportInteractionInProgressRef.current = false;
|
|
898
|
+
imperativeViewBoxRef.current = null;
|
|
899
|
+
}, []);
|
|
900
|
+
useEffect(() => {
|
|
901
|
+
const handleKeyDown = (event) => {
|
|
902
|
+
const target = event.target;
|
|
903
|
+
const isEditableTarget = target instanceof HTMLInputElement ||
|
|
904
|
+
target instanceof HTMLTextAreaElement ||
|
|
905
|
+
target instanceof HTMLSelectElement ||
|
|
906
|
+
Boolean(target?.isContentEditable);
|
|
907
|
+
if (isEditableTarget)
|
|
908
|
+
return;
|
|
909
|
+
if (event.code === 'Space' && active) {
|
|
910
|
+
event.preventDefault();
|
|
911
|
+
spacePanPressedRef.current = true;
|
|
912
|
+
setIsSpacePanPressed(true);
|
|
913
|
+
}
|
|
914
|
+
};
|
|
915
|
+
const handleKeyUp = (event) => {
|
|
916
|
+
if (event.code === 'Space') {
|
|
917
|
+
spacePanPressedRef.current = false;
|
|
918
|
+
setIsSpacePanPressed(false);
|
|
919
|
+
}
|
|
920
|
+
};
|
|
921
|
+
const handleBlur = () => {
|
|
922
|
+
spacePanPressedRef.current = false;
|
|
923
|
+
setIsSpacePanPressed(false);
|
|
924
|
+
};
|
|
925
|
+
window.addEventListener('keydown', handleKeyDown);
|
|
926
|
+
window.addEventListener('keyup', handleKeyUp);
|
|
927
|
+
window.addEventListener('blur', handleBlur);
|
|
928
|
+
return () => {
|
|
929
|
+
window.removeEventListener('keydown', handleKeyDown);
|
|
930
|
+
window.removeEventListener('keyup', handleKeyUp);
|
|
931
|
+
window.removeEventListener('blur', handleBlur);
|
|
932
|
+
};
|
|
933
|
+
}, [active]);
|
|
934
|
+
const handlePointerDownCapture = useCallback((event) => {
|
|
935
|
+
if (event.button === 1 || (event.button === 0 && spacePanPressedRef.current)) {
|
|
936
|
+
event.preventDefault();
|
|
937
|
+
event.stopPropagation();
|
|
938
|
+
navigationSyncScheduler.flush();
|
|
939
|
+
if (zoomCommitTimerRef.current !== null)
|
|
940
|
+
commitZoom();
|
|
941
|
+
stopViewAnimation();
|
|
942
|
+
navigationClickSuppressedRef.current = true;
|
|
943
|
+
const currentViewport = latestViewportRef.current ?? latestFittedViewportRef.current;
|
|
944
|
+
if (!currentViewport)
|
|
945
|
+
return;
|
|
946
|
+
viewportInteractionInProgressRef.current = true;
|
|
947
|
+
panStateRef.current = {
|
|
948
|
+
pointerId: event.pointerId,
|
|
949
|
+
clientX: event.clientX,
|
|
950
|
+
clientY: event.clientY,
|
|
951
|
+
centerSvg: { x: currentViewport.centerX, y: currentViewport.centerY },
|
|
952
|
+
};
|
|
953
|
+
setIsPanning(true);
|
|
954
|
+
onNavigationStart?.();
|
|
955
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
956
|
+
return;
|
|
957
|
+
}
|
|
958
|
+
if (event.button !== 2)
|
|
959
|
+
return;
|
|
960
|
+
event.preventDefault();
|
|
961
|
+
event.stopPropagation();
|
|
962
|
+
navigationSyncScheduler.flush();
|
|
963
|
+
if (zoomCommitTimerRef.current !== null)
|
|
964
|
+
commitZoom();
|
|
965
|
+
stopViewAnimation();
|
|
966
|
+
const currentViewport = latestViewportRef.current ?? latestFittedViewportRef.current;
|
|
967
|
+
const svg = svgRef.current;
|
|
968
|
+
if (!(currentViewport && svg))
|
|
969
|
+
return;
|
|
970
|
+
const currentUserRotationDeg = latestUserRotationDegRef.current;
|
|
971
|
+
const currentSceneRotationDeg = baselineRotationDeg + currentUserRotationDeg - worldRotationDeg;
|
|
972
|
+
const viewportCenterLocal = rotateFloorplanPoint({ x: currentViewport.centerX, y: currentViewport.centerY }, -currentSceneRotationDeg);
|
|
973
|
+
const svgStyle = {
|
|
974
|
+
transform: svg.style.transform,
|
|
975
|
+
transformOrigin: svg.style.transformOrigin,
|
|
976
|
+
willChange: svg.style.willChange,
|
|
977
|
+
};
|
|
978
|
+
viewportInteractionInProgressRef.current = true;
|
|
979
|
+
svg.style.transformOrigin = 'center';
|
|
980
|
+
svg.style.willChange = 'transform';
|
|
981
|
+
rotationStateRef.current = {
|
|
982
|
+
pointerId: event.pointerId,
|
|
983
|
+
startClientX: event.clientX,
|
|
984
|
+
initialUserRotationDeg: currentUserRotationDeg,
|
|
985
|
+
viewportCenterLocal,
|
|
986
|
+
svg,
|
|
987
|
+
svgStyle,
|
|
988
|
+
latestUserRotationDeg: currentUserRotationDeg,
|
|
989
|
+
latestViewport: currentViewport,
|
|
990
|
+
};
|
|
991
|
+
setIsRotating(true);
|
|
992
|
+
onNavigationStart?.();
|
|
993
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
994
|
+
}, [
|
|
995
|
+
baselineRotationDeg,
|
|
996
|
+
commitZoom,
|
|
997
|
+
navigationSyncScheduler,
|
|
998
|
+
onNavigationStart,
|
|
999
|
+
stopViewAnimation,
|
|
1000
|
+
worldRotationDeg,
|
|
1001
|
+
]);
|
|
1002
|
+
const handlePointerMove = useCallback((event) => {
|
|
1003
|
+
const rotationState = rotationStateRef.current;
|
|
1004
|
+
if (rotationState?.pointerId === event.pointerId) {
|
|
1005
|
+
event.preventDefault();
|
|
1006
|
+
event.stopPropagation();
|
|
1007
|
+
const angleDeltaDeg = (rotationState.startClientX - event.clientX) * FLOORPLAN_ROTATION_DEGREES_PER_PIXEL;
|
|
1008
|
+
const nextUserRotationDeg = rotationState.initialUserRotationDeg + angleDeltaDeg;
|
|
1009
|
+
applyRotationImperatively(rotationState, nextUserRotationDeg);
|
|
1010
|
+
if (publishContinuously()) {
|
|
1011
|
+
publishLocalNavigationPose(rotationState.viewportCenterLocal, nextUserRotationDeg, rotationState.latestViewport.width);
|
|
1012
|
+
}
|
|
1013
|
+
return true;
|
|
1014
|
+
}
|
|
1015
|
+
const panState = panStateRef.current;
|
|
1016
|
+
if (panState?.pointerId === event.pointerId) {
|
|
1017
|
+
event.preventDefault();
|
|
1018
|
+
event.stopPropagation();
|
|
1019
|
+
const deltaX = event.clientX - panState.clientX;
|
|
1020
|
+
const deltaY = event.clientY - panState.clientY;
|
|
1021
|
+
const currentViewport = latestViewportRef.current ?? latestFittedViewportRef.current;
|
|
1022
|
+
if (!currentViewport)
|
|
1023
|
+
return true;
|
|
1024
|
+
const currentHeight = currentViewport.width / svgAspectRatio;
|
|
1025
|
+
const worldPerPixelX = currentViewport.width / surfaceSize.width;
|
|
1026
|
+
const worldPerPixelY = currentHeight / surfaceSize.height;
|
|
1027
|
+
const nextCenterSvg = {
|
|
1028
|
+
x: panState.centerSvg.x - deltaX * worldPerPixelX,
|
|
1029
|
+
y: panState.centerSvg.y - deltaY * worldPerPixelY,
|
|
1030
|
+
};
|
|
1031
|
+
const currentUserRotationDeg = latestUserRotationDegRef.current;
|
|
1032
|
+
const currentSceneRotationDeg = baselineRotationDeg + currentUserRotationDeg - worldRotationDeg;
|
|
1033
|
+
const localCenter = rotateFloorplanPoint(nextCenterSvg, -currentSceneRotationDeg);
|
|
1034
|
+
const nextViewport = {
|
|
1035
|
+
centerX: nextCenterSvg.x,
|
|
1036
|
+
centerY: nextCenterSvg.y,
|
|
1037
|
+
width: currentViewport.width,
|
|
1038
|
+
};
|
|
1039
|
+
applyViewportImperatively(nextViewport);
|
|
1040
|
+
panPoseRef.current = {
|
|
1041
|
+
localCenter,
|
|
1042
|
+
userRotationDeg: currentUserRotationDeg,
|
|
1043
|
+
viewWidth: currentViewport.width,
|
|
1044
|
+
};
|
|
1045
|
+
if (publishContinuously()) {
|
|
1046
|
+
publishLocalNavigationPose(localCenter, currentUserRotationDeg, currentViewport.width);
|
|
1047
|
+
}
|
|
1048
|
+
panStateRef.current = {
|
|
1049
|
+
pointerId: event.pointerId,
|
|
1050
|
+
clientX: event.clientX,
|
|
1051
|
+
clientY: event.clientY,
|
|
1052
|
+
centerSvg: nextCenterSvg,
|
|
1053
|
+
};
|
|
1054
|
+
return true;
|
|
1055
|
+
}
|
|
1056
|
+
return false;
|
|
1057
|
+
}, [
|
|
1058
|
+
applyRotationImperatively,
|
|
1059
|
+
applyViewportImperatively,
|
|
1060
|
+
baselineRotationDeg,
|
|
1061
|
+
publishContinuously,
|
|
1062
|
+
publishLocalNavigationPose,
|
|
1063
|
+
surfaceSize.height,
|
|
1064
|
+
surfaceSize.width,
|
|
1065
|
+
svgAspectRatio,
|
|
1066
|
+
worldRotationDeg,
|
|
1067
|
+
]);
|
|
1068
|
+
useEffect(() => {
|
|
1069
|
+
const svg = svgRef.current;
|
|
1070
|
+
if (!svg)
|
|
1071
|
+
return;
|
|
1072
|
+
const getFallbackClientPoint = () => {
|
|
1073
|
+
const rect = svg.getBoundingClientRect();
|
|
1074
|
+
return { clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2 };
|
|
1075
|
+
};
|
|
1076
|
+
const handleNativeWheel = (event) => {
|
|
1077
|
+
event.preventDefault();
|
|
1078
|
+
event.stopPropagation();
|
|
1079
|
+
navigationSyncScheduler.flush();
|
|
1080
|
+
const widthFactor = Math.exp(event.deltaY * (event.ctrlKey ? 0.003 : 0.0015));
|
|
1081
|
+
zoomViewportAtClientPoint(event.clientX, event.clientY, widthFactor);
|
|
1082
|
+
};
|
|
1083
|
+
const handleGestureStart = (event) => {
|
|
1084
|
+
navigationSyncScheduler.flush();
|
|
1085
|
+
const gestureEvent = event;
|
|
1086
|
+
gestureScaleRef.current = gestureEvent.scale ?? 1;
|
|
1087
|
+
event.preventDefault();
|
|
1088
|
+
event.stopPropagation();
|
|
1089
|
+
};
|
|
1090
|
+
const handleGestureChange = (event) => {
|
|
1091
|
+
const gestureEvent = event;
|
|
1092
|
+
const nextScale = gestureEvent.scale ?? 1;
|
|
1093
|
+
const previousScale = gestureScaleRef.current || 1;
|
|
1094
|
+
const widthFactor = previousScale / nextScale;
|
|
1095
|
+
const fallbackClientPoint = getFallbackClientPoint();
|
|
1096
|
+
zoomViewportAtClientPoint(gestureEvent.clientX ?? fallbackClientPoint.clientX, gestureEvent.clientY ?? fallbackClientPoint.clientY, widthFactor);
|
|
1097
|
+
gestureScaleRef.current = nextScale;
|
|
1098
|
+
event.preventDefault();
|
|
1099
|
+
event.stopPropagation();
|
|
1100
|
+
};
|
|
1101
|
+
const handleGestureEnd = (event) => {
|
|
1102
|
+
gestureScaleRef.current = 1;
|
|
1103
|
+
commitZoom();
|
|
1104
|
+
event.preventDefault();
|
|
1105
|
+
event.stopPropagation();
|
|
1106
|
+
};
|
|
1107
|
+
return __testFloorplanNavigationNativeEvents.register(svg, {
|
|
1108
|
+
wheel: handleNativeWheel,
|
|
1109
|
+
gesturestart: handleGestureStart,
|
|
1110
|
+
gesturechange: handleGestureChange,
|
|
1111
|
+
gestureend: handleGestureEnd,
|
|
1112
|
+
});
|
|
1113
|
+
}, [commitZoom, navigationSyncScheduler, zoomViewportAtClientPoint]);
|
|
1114
|
+
return {
|
|
1115
|
+
active,
|
|
1116
|
+
alignToNorth,
|
|
1117
|
+
averageUnitsPerPixel: worldUnitsPerPixel,
|
|
1118
|
+
backgroundRef,
|
|
1119
|
+
compassNeedleRef,
|
|
1120
|
+
contentRef,
|
|
1121
|
+
floorplanUserRotationDeg,
|
|
1122
|
+
getLiveUnitsPerPixel,
|
|
1123
|
+
getScenePointFromClientPoint,
|
|
1124
|
+
getSceneRotationDeg,
|
|
1125
|
+
handlePointerDownCapture,
|
|
1126
|
+
handlePointerMove,
|
|
1127
|
+
isNavigating: isPanning || isRotating,
|
|
1128
|
+
navigationCursor: isPanning || isRotating ? 'grabbing' : isSpacePanPressed ? 'grab' : null,
|
|
1129
|
+
navigationOverlayVisible: isSpacePanPressed || isPanning || isRotating,
|
|
1130
|
+
paintViewBox,
|
|
1131
|
+
presentationPaintViewBox,
|
|
1132
|
+
presentationViewBox,
|
|
1133
|
+
sceneRef,
|
|
1134
|
+
sceneRotationDeg,
|
|
1135
|
+
consumeSuppressedClick: () => {
|
|
1136
|
+
if (!navigationClickSuppressedRef.current)
|
|
1137
|
+
return false;
|
|
1138
|
+
navigationClickSuppressedRef.current = false;
|
|
1139
|
+
return true;
|
|
1140
|
+
},
|
|
1141
|
+
surfaceSize,
|
|
1142
|
+
svgRef,
|
|
1143
|
+
unitsPerPixel,
|
|
1144
|
+
viewBox,
|
|
1145
|
+
viewportHostRef,
|
|
1146
|
+
endPointerNavigation,
|
|
1147
|
+
};
|
|
1148
|
+
}
|
|
1149
|
+
function FloorplanCompassButton({ label, navigation, }) {
|
|
1150
|
+
return (_jsx("button", { "aria-label": label, className: "group absolute bottom-3 left-3 z-30 flex h-8 w-8 items-center justify-center rounded-full border border-black/10 bg-white/85 shadow-sm backdrop-blur-md transition hover:bg-white hover:shadow-md dark:border-white/10 dark:bg-neutral-900/85 dark:hover:bg-neutral-900", onClick: (event) => {
|
|
1151
|
+
event.preventDefault();
|
|
1152
|
+
event.stopPropagation();
|
|
1153
|
+
navigation.alignToNorth();
|
|
1154
|
+
}, onPointerDown: (event) => event.stopPropagation(), type: "button", children: _jsx("span", { className: "relative flex h-6 w-6 items-center justify-center rounded-full bg-[#b8b8b8] shadow-inner dark:bg-neutral-700", children: _jsxs("svg", { "aria-hidden": "true", className: "h-6 w-6", ref: navigation.compassNeedleRef, style: { transform: `rotate(${navigation.floorplanUserRotationDeg}deg)` }, viewBox: "0 0 48 48", children: [_jsx("path", { d: "M24 4.5 31.5 25 24 21.5 16.5 25Z", fill: "#f15b5b" }), _jsx("path", { d: "M24 43.5 16.5 23 24 26.5 31.5 23Z", fill: "#ffffff" })] }) }) }));
|
|
1155
|
+
}
|
|
1156
|
+
export function FloorplanNavigationShell({ active, available, backgroundFill, children, compassHost, compassLabel, definitions, emptyState, navigation, onClick, onPointerMove, overlays, renderCompassTooltip, style, ...svgProps }) {
|
|
1157
|
+
const compassButton = _jsx(FloorplanCompassButton, { label: compassLabel, navigation: navigation });
|
|
1158
|
+
const compass = renderCompassTooltip
|
|
1159
|
+
? renderCompassTooltip(compassButton, compassLabel)
|
|
1160
|
+
: compassButton;
|
|
1161
|
+
return (_jsxs("div", { className: "relative min-h-0 flex-1 bg-white", ref: navigation.viewportHostRef, children: [overlays, available && (compassHost ? createPortal(compass, compassHost) : compass), !available ? (emptyState) : active ? (_jsxs("svg", { ...svgProps, className: "h-full w-full touch-none", "data-pascal-floorplan-2d": "", onClick: (event) => {
|
|
1162
|
+
if (navigation.consumeSuppressedClick()) {
|
|
1163
|
+
event.preventDefault();
|
|
1164
|
+
event.stopPropagation();
|
|
1165
|
+
return;
|
|
1166
|
+
}
|
|
1167
|
+
onClick?.(event);
|
|
1168
|
+
}, onPointerCancel: navigation.endPointerNavigation, onPointerDownCapture: navigation.handlePointerDownCapture, onPointerMove: (event) => {
|
|
1169
|
+
if (!navigation.handlePointerMove(event))
|
|
1170
|
+
onPointerMove?.(event);
|
|
1171
|
+
}, onPointerUp: navigation.endPointerNavigation, ref: navigation.svgRef, style: {
|
|
1172
|
+
...style,
|
|
1173
|
+
cursor: navigation.navigationCursor ?? style?.cursor,
|
|
1174
|
+
overflow: 'visible',
|
|
1175
|
+
}, viewBox: `${navigation.presentationViewBox.minX} ${navigation.presentationViewBox.minY} ${navigation.presentationViewBox.width} ${navigation.presentationViewBox.height}`, children: [definitions, _jsx("rect", { fill: backgroundFill, height: navigation.presentationPaintViewBox.height, ref: navigation.backgroundRef, width: navigation.presentationPaintViewBox.width, x: navigation.presentationPaintViewBox.minX, y: navigation.presentationPaintViewBox.minY }), _jsx("g", { "data-floorplan-scene": "", ref: navigation.sceneRef, transform: navigation.sceneRotationDeg !== 0
|
|
1176
|
+
? `rotate(${navigation.sceneRotationDeg})`
|
|
1177
|
+
: undefined, children: children }), navigation.navigationOverlayVisible ? (_jsx("rect", { fill: "transparent", height: navigation.presentationViewBox.height, pointerEvents: "all", style: { cursor: navigation.navigationCursor ?? 'grab' }, width: navigation.presentationViewBox.width, x: navigation.presentationViewBox.minX, y: navigation.presentationViewBox.minY })) : null] })) : null] }));
|
|
1178
|
+
}
|