@pascal-app/editor 1.0.7 → 1.1.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/package.json +9 -15
- package/src/components/editor/action-menu-button.tsx +1 -1
- package/src/components/editor/alignment-3d-guide-layer.tsx +1 -1
- package/src/components/editor/custom-camera-controls.tsx +101 -45
- package/src/components/editor/duplicate-node.ts +2 -1
- package/src/components/editor/editor-layout-v2.tsx +1 -1
- package/src/components/editor/elevation-3d-guide-layer.tsx +1 -1
- package/src/components/editor/first-person-controls.tsx +20 -1983
- package/src/components/editor/fit-scene-framing.test.ts +318 -0
- package/src/components/editor/fit-scene-framing.ts +142 -0
- package/src/components/editor/floating-action-menu.tsx +5 -3
- package/src/components/editor/floating-building-action-menu.tsx +1 -1
- package/src/components/editor/floor-openings-overlay.tsx +1 -1
- package/src/components/editor/floor-region-controls.tsx +8 -4
- package/src/components/editor/floorplan-navigation-presentation.test.ts +0 -40
- package/src/components/editor/floorplan-navigation-presentation.ts +0 -66
- package/src/components/editor/floorplan-panel.tsx +199 -30
- package/src/components/editor/floorplan-wall-typing-keys.test.tsx +152 -0
- package/src/components/editor/floorplan-wall-typing-keys.ts +79 -0
- package/src/components/editor/follow-pascal.test.tsx +1 -2
- package/src/components/editor/follow-pascal.tsx +1 -1
- package/src/components/editor/footprint-height-handle.tsx +2 -2
- package/src/components/editor/group-actions.ts +1 -2
- package/src/components/editor/group-floating-action-menu.tsx +1 -1
- package/src/components/editor/group-move-3d.ts +1 -2
- package/src/components/editor/group-rotate-handle.tsx +1 -2
- package/src/components/editor/group-transform-shared.ts +3 -1
- package/src/components/editor/handles/use-handle-drag.ts +1 -2
- package/src/components/editor/index.tsx +43 -13
- package/src/components/editor/inspector-extension-actions.tsx +1 -1
- package/src/components/editor/measurement-pill.tsx +1 -1
- package/src/components/editor/node-arrow-handles.tsx +2 -2
- package/src/components/editor/open-wall-ends-3d-layer.tsx +2 -3
- package/src/components/editor/open-wall-ends-hint.tsx +1 -1
- package/src/components/editor/opening-guides-3d-layer.tsx +1 -1
- package/src/components/editor/quick-measurement-card.tsx +1 -1
- package/src/components/editor/room-controls.tsx +10 -6
- package/src/components/editor/room-handles-2d.tsx +3 -3
- package/src/components/editor/room-handles.tsx +4 -5
- package/src/components/editor/room-highlight.tsx +6 -105
- package/src/components/editor/room-labels.test.ts +8 -822
- package/src/components/editor/room-labels.tsx +41 -1670
- package/src/components/editor/room-webgpu-materials.test.ts +1 -1
- package/src/components/editor/selection-manager.tsx +5 -3
- package/src/components/editor/site-edge-labels.tsx +7 -3
- package/src/components/editor/snapshot-capture-overlay.tsx +2 -5
- package/src/components/editor/use-floorplan-background-placement.ts +2 -1
- package/src/components/editor/views/plugins-view.tsx +1 -1
- package/src/components/editor/views/view-bar.tsx +9 -9
- package/src/components/editor/wall-measurement-label.tsx +31 -15
- package/src/components/editor/wall-move-side-handles.tsx +11 -7
- package/src/components/editor/wall-pull-grip.tsx +2 -2
- package/src/components/editor/wall-region-handles.tsx +2 -2
- package/src/components/editor/wall-snap-beacon-layer.tsx +53 -32
- package/src/components/editor-2d/floorplan-group-move.tsx +3 -4
- package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +13 -13
- package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +2 -3
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +2 -1
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +23 -6
- package/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts +0 -37
- package/src/components/editor-2d/renderers/floorplan-annotation-layout.ts +0 -30
- package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.tsx +13 -3
- package/src/components/editor-2d/renderers/floorplan-draft-measurement.tsx +7 -1
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +14 -3
- package/src/components/editor-2d/renderers/floorplan-placement-typing-hud.test.tsx +35 -0
- package/src/components/editor-2d/renderers/floorplan-placement-typing-hud.tsx +65 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +30 -10
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +39 -35
- package/src/components/systems/roof/roof-edit-system.test.tsx +15 -1
- package/src/components/systems/roof/roof-edit-system.tsx +33 -21
- package/src/components/tools/distribution/distribution-run-tool.test.tsx +371 -3
- package/src/components/tools/distribution/distribution-run-tool.tsx +151 -33
- package/src/components/tools/item/move-tool.tsx +1 -6
- package/src/components/tools/item/placement-history.test.tsx +1 -1
- package/src/components/tools/item/use-placement-coordinator.tsx +3 -4
- package/src/components/tools/paint-region/wall-paint-region-tool.tsx +7 -3
- package/src/components/tools/registry/move-registry-node-tool.test.ts +42 -1
- package/src/components/tools/registry/move-registry-node-tool.tsx +39 -16
- package/src/components/tools/shared/cursor-sphere.tsx +1 -1
- package/src/components/tools/shared/draft-measurement-label.tsx +1 -1
- package/src/components/tools/shared/placement-box.tsx +2 -6
- package/src/components/tools/shared/placement-coordinate-input.tsx +94 -0
- package/src/components/tools/shared/placement-dimension-guides.tsx +1 -1
- package/src/components/tools/shared/segment-angle.ts +0 -4
- package/src/components/tools/site/terrain-brush-cursor.tsx +1 -1
- package/src/components/tools/tool-manager.tsx +1 -24
- package/src/components/tools/wall/wall-typed-entry.test.ts +99 -0
- package/src/components/tools/wall/wall-typed-entry.ts +70 -0
- package/src/components/ui/action-menu/camera-actions.tsx +2 -2
- package/src/components/ui/action-menu/control-modes.tsx +1 -1
- package/src/components/ui/action-menu/follow-pascal-pill.tsx +1 -1
- package/src/components/ui/action-menu/index.tsx +3 -3
- package/src/components/ui/action-menu/measurement-control.tsx +3 -3
- package/src/components/ui/action-menu/placement.ts +0 -18
- package/src/components/ui/action-menu/point-ask-button.tsx +1 -1
- package/src/components/ui/action-menu/view-toggles.tsx +5 -5
- package/src/components/ui/controls/finish-swatch.tsx +1 -1
- package/src/components/ui/controls/material-paint-panel.tsx +2 -2
- package/src/components/ui/controls/scene-material-list.tsx +2 -2
- package/src/components/ui/controls/select-control.tsx +1 -1
- package/src/components/ui/controls/slider-control.tsx +18 -6
- package/src/components/ui/controls/terrain-sculpt-panel.tsx +1 -1
- package/src/components/ui/controls/tool-options-panel.tsx +74 -44
- package/src/components/ui/floating-level-selector.tsx +3 -3
- package/src/components/ui/helpers/contextual-helper-panel.tsx +1 -1
- package/src/components/ui/helpers/helper-manager.test.tsx +54 -1
- package/src/components/ui/helpers/helper-manager.tsx +55 -1
- package/src/components/ui/level-duplicate-dialog.tsx +1 -1
- package/src/components/ui/panels/collections/collections-popover.tsx +2 -2
- package/src/components/ui/panels/floor-foundation-panel.tsx +3 -3
- package/src/components/ui/panels/floor-opening-panel.tsx +5 -5
- package/src/components/ui/panels/mobile-selection-bar.tsx +28 -24
- package/src/components/ui/panels/multi-height-mode.tsx +3 -3
- package/src/components/ui/panels/multi-selection-panel.tsx +1 -1
- package/src/components/ui/panels/node-display.ts +1 -1
- package/src/components/ui/panels/panel-manager.tsx +5 -3
- package/src/components/ui/panels/panel-wrapper.tsx +42 -19
- package/src/components/ui/panels/parametric-inspector.tsx +1 -1
- package/src/components/ui/panels/room-construction-rows.tsx +7 -7
- package/src/components/ui/panels/room-name-options.ts +1 -5
- package/src/components/ui/panels/room-openings-field.tsx +1 -1
- package/src/components/ui/panels/room-panel.tsx +7 -9
- package/src/components/ui/primitives/color-dot.tsx +2 -2
- package/src/components/ui/primitives/opacity-control.tsx +2 -2
- package/src/components/ui/primitives/sidebar.tsx +3 -3
- package/src/components/ui/right-stack.test.ts +13 -6
- package/src/components/ui/right-stack.tsx +22 -11
- package/src/components/ui/sidebar/icon-rail.tsx +1 -1
- package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +1 -1
- package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +2 -2
- package/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.tsx +1 -1
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +2 -2
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +1 -1
- package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +2 -6
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +3 -10
- package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +46 -7
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/tree-node-drag.tsx +14 -5
- package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/zone-panel/index.tsx +3 -3
- package/src/components/ui/sidebar/tab-bar.tsx +1 -1
- package/src/components/ui/wall-type/wall-type-editor-dialog.tsx +2 -2
- package/src/components/ui/wall-type/wall-type-menu.tsx +1 -1
- package/src/components/viewer-overlay.tsx +1 -2
- package/src/hooks/use-auto-frame.ts +1 -2
- package/src/hooks/use-keyboard.ts +1 -2
- package/src/hooks/use-selected-room.ts +5 -7
- package/src/i18n/floor-footprint-name.ts +1 -1
- package/src/i18n/locales/en/messages.po +1036 -306
- package/src/i18n/locales/en/messages.ts +1 -1
- package/src/i18n/locales/en-XA/messages.ts +1 -1
- package/src/i18n/locales/es/messages.po +2267 -439
- package/src/i18n/locales/fr/messages.po +2267 -439
- package/src/i18n/locales/ja/messages.po +2267 -439
- package/src/i18n/locales/zh-CN/messages.po +2267 -439
- package/src/i18n/locales.test.ts +1 -1
- package/src/index.tsx +183 -178
- package/src/lib/below-grade.test.tsx +4 -4
- package/src/lib/camera-facing.test.ts +1 -1
- package/src/lib/camera-facing.ts +2 -3
- package/src/lib/contextual-help.ts +6 -0
- package/src/lib/editor-level-display.ts +1 -1
- package/src/lib/elevation-guides.ts +21 -6
- package/src/lib/elevator-support.ts +1 -72
- package/src/lib/floor-footprints.test.ts +3 -3
- package/src/lib/floor-footprints.ts +1 -9
- package/src/lib/floor-foundation-support.test.ts +8 -3
- package/src/lib/floor-opening-draft.ts +2 -2
- package/src/lib/floor-region-session.ts +1 -1
- package/src/lib/floorplan/annotation-visibility.test.ts +1 -1
- package/src/lib/floorplan/annotation-visibility.ts +1 -1
- package/src/lib/floorplan/floorplan-export.test.ts +1 -1
- package/src/lib/floorplan/floorplan-export.tsx +8 -11
- package/src/lib/floorplan/floorplan-extension.ts +4 -157
- package/src/lib/floorplan/floorplan-mode.ts +2 -1
- package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +1 -1
- package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +8 -6
- package/src/lib/floorplan/floorplan-plugin-install.test.tsx +3 -4
- package/src/lib/floorplan/geometry.ts +2 -112
- package/src/lib/floorplan/index.ts +0 -9
- package/src/lib/floorplan/items.ts +2 -1
- package/src/lib/floorplan/open-wall-ends.ts +2 -1
- package/src/lib/floorplan/schedules.ts +6 -3
- package/src/lib/floorplan/sheet-export.ts +7 -3
- package/src/lib/floorplan/site-plan/site-parts.ts +7 -1
- package/src/lib/floorplan/site-plan/site-plan-layer.tsx +1 -1
- package/src/lib/follow-pascal-presentation.ts +1 -1
- package/src/lib/gesture-lifecycle.ts +3 -1
- package/src/lib/glb-export.ts +1 -1
- package/src/lib/interaction/scope.ts +1 -0
- package/src/lib/layer-thickness.ts +1 -1
- package/src/lib/level-selection.test.ts +18 -2
- package/src/lib/level-selection.ts +31 -8
- package/src/lib/linear-display.ts +1 -1
- package/src/lib/live-gesture-round-trip.test.ts +1 -1
- package/src/lib/material-paint.ts +1 -1
- package/src/lib/measurement-label.test.ts +1 -1
- package/src/lib/measurements.test.ts +7 -5
- package/src/lib/mezzanine-selection.test.ts +1 -1
- package/src/lib/node-action-movement.test.tsx +115 -0
- package/src/lib/node-action-movement.ts +2 -0
- package/src/lib/paint-region-mode.ts +1 -1
- package/src/lib/placement-metadata.ts +4 -0
- package/src/lib/point-ask/describe-target.ts +6 -8
- package/src/lib/point-ask/region.ts +1 -1
- package/src/lib/point-ask/session.ts +1 -2
- package/src/lib/print-shell-compiler.ts +12 -1
- package/src/lib/print-wall-solids.test.ts +11 -0
- package/src/lib/print-wall-solids.ts +9 -0
- package/src/lib/roof-duplication.test.ts +100 -0
- package/src/lib/roof-duplication.ts +30 -5
- package/src/lib/room-built-on.test.ts +2 -5
- package/src/lib/room-built-on.ts +1 -15
- package/src/lib/room-construction-commands.ts +3 -2
- package/src/lib/room-controls-phase6.test.ts +1 -1
- package/src/lib/room-handle-drag.ts +8 -37
- package/src/lib/room-name-catalog.test.ts +2 -1
- package/src/lib/room-name-messages.test.ts +2 -1
- package/src/lib/room-name-messages.ts +1 -118
- package/src/lib/room-name-storage.test.ts +2 -1
- package/src/lib/room-push-dimensions.test.ts +1 -1
- package/src/lib/room-selection-commands.ts +2 -2
- package/src/lib/room-selection.test.ts +6 -6
- package/src/lib/room-transform-session.ts +1 -2
- package/src/lib/room-zone-routing.ts +2 -2
- package/src/lib/scene-clipboard.ts +2 -1
- package/src/lib/selection-routing.test.ts +0 -28
- package/src/lib/selection-routing.ts +1 -40
- package/src/lib/sfx-bus.ts +8 -32
- package/src/lib/sfx-player.test.ts +72 -1
- package/src/lib/sfx-player.ts +49 -337
- package/src/lib/snapping-mode.test.ts +23 -1
- package/src/lib/viewer-selection.test.ts +38 -10
- package/src/lib/wall-pull.test.ts +1 -1
- package/src/lib/wall-pull.ts +1 -27
- package/src/lib/wall-region-list.ts +3 -1
- package/src/lib/window-interaction.test.ts +4 -2
- package/src/store/use-editor.tsx +22 -26
- package/src/store/use-floorplan-annotation-visibility.ts +5 -5
- package/src/store/use-placement-typing.test.ts +165 -0
- package/src/store/use-placement-typing.ts +186 -0
- package/src/store/use-wall-region-handles-selection.ts +1 -2
- package/src/components/editor/__bench__/room-label-placement.bench.ts +0 -108
- package/src/components/editor/first-person/build-collider-world.test.ts +0 -596
- package/src/components/editor/first-person/build-collider-world.ts +0 -549
- package/src/components/editor/first-person/ground-holes.test.ts +0 -128
- package/src/components/editor/first-person/ground-holes.ts +0 -130
- package/src/components/editor/first-person/mechanism-targets.test.ts +0 -162
- package/src/components/editor/first-person/mechanism-targets.ts +0 -88
- package/src/components/editor/first-person/terrain-collider.test.ts +0 -132
- package/src/components/editor/first-person/terrain-collider.ts +0 -212
- package/src/components/editor/floorplan-camera-sync.test.ts +0 -249
- package/src/components/editor/floorplan-camera-sync.ts +0 -213
- package/src/components/editor/localized-html.tsx +0 -22
- package/src/components/editor-2d/renderers/floorplan-dimension-renderer.test.tsx +0 -236
- package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +0 -708
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +0 -385
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +0 -670
- package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +0 -89
- package/src/components/editor-2d/renderers/floorplan-label-angle.ts +0 -106
- package/src/components/editor-2d/svg-paths.ts +0 -124
- package/src/components/tools/elevator/elevator-defaults.ts +0 -8
- package/src/components/tools/elevator/elevator-tool.tsx +0 -372
- package/src/components/tools/elevator/move-elevator-tool.tsx +0 -262
- package/src/components/ui/action-menu/action-button.tsx +0 -89
- package/src/components/ui/action-menu/follow-pascal-toggle.tsx +0 -42
- package/src/components/ui/primitives/button.tsx +0 -69
- package/src/components/ui/primitives/dropdown-menu.tsx +0 -232
- package/src/components/ui/primitives/popover.tsx +0 -42
- package/src/components/ui/primitives/switch.tsx +0 -29
- package/src/components/ui/primitives/tooltip.tsx +0 -57
- package/src/components/ui/scene-loader.tsx +0 -73
- package/src/components/viewer/floorplan-compass-button.tsx +0 -67
- package/src/components/viewer/floorplan-preview-geometry.test.ts +0 -107
- package/src/components/viewer/floorplan-preview-geometry.ts +0 -281
- package/src/components/viewer/floorplan-preview-navigation.test.ts +0 -43
- package/src/components/viewer/floorplan-preview-navigation.ts +0 -62
- package/src/components/viewer/floorplan-preview-visibility.test.ts +0 -35
- package/src/components/viewer/floorplan-preview-visibility.ts +0 -19
- package/src/components/viewer/floorplan-preview.tsx +0 -1065
- package/src/components/viewer/render-settings-menu.tsx +0 -294
- package/src/components/viewer/sky-control.tsx +0 -710
- package/src/components/viewer/use-viewer-camera-navigation-sync.ts +0 -140
- package/src/components/viewer/use-viewer-ui-sounds.test.ts +0 -15
- package/src/components/viewer/use-viewer-ui-sounds.ts +0 -46
- package/src/components/viewer/viewer-controls-bar.tsx +0 -496
- package/src/components/viewer/viewer-hover-label.tsx +0 -42
- package/src/components/viewer/viewer-rooms.tsx +0 -248
- package/src/components/viewer/viewer-scene-header.tsx +0 -286
- package/src/components/viewer/viewer-selection-manager.tsx +0 -124
- package/src/components/viewer/viewer-stage-modes.test.ts +0 -38
- package/src/components/viewer/viewer-stage-modes.ts +0 -49
- package/src/components/viewer/viewer-stage-switcher.tsx +0 -97
- package/src/components/viewer/viewer-stage.test.tsx +0 -47
- package/src/components/viewer/viewer-stage.tsx +0 -221
- package/src/components/walkthrough-hud.tsx +0 -193
- package/src/hooks/use-mobile.ts +0 -19
- package/src/i18n/level-name.ts +0 -39
- package/src/i18n/scene-theme-name.ts +0 -16
- package/src/lib/below-grade.ts +0 -133
- package/src/lib/camera-pose.test.ts +0 -237
- package/src/lib/camera-pose.ts +0 -172
- package/src/lib/door-interaction.ts +0 -102
- package/src/lib/floorplan/floorplan-extension.test.ts +0 -42
- package/src/lib/floorplan/floorplan-readonly.test.ts +0 -47
- package/src/lib/floorplan/floorplan-readonly.ts +0 -95
- package/src/lib/floorplan/marks.test.ts +0 -213
- package/src/lib/floorplan/marks.ts +0 -238
- package/src/lib/measurement-label.ts +0 -68
- package/src/lib/measurements.ts +0 -225
- package/src/lib/node-display-label.ts +0 -37
- package/src/lib/room-assembly-overlay.test.ts +0 -275
- package/src/lib/room-assembly-overlay.ts +0 -265
- package/src/lib/room-selection.ts +0 -417
- package/src/lib/scene-bounds.test.ts +0 -183
- package/src/lib/scene-bounds.ts +0 -169
- package/src/lib/typing-target.ts +0 -9
- package/src/lib/viewer-selection.ts +0 -595
- package/src/lib/window-interaction.ts +0 -142
- package/src/lib/zone-content.ts +0 -133
- package/src/store/camera-pose-store.test.ts +0 -29
- package/src/store/camera-pose-store.ts +0 -27
- package/src/store/navigation-sync-pose-store.test.ts +0 -34
- package/src/store/navigation-sync-pose-store.ts +0 -27
- package/src/store/use-audio.test.ts +0 -146
- package/src/store/use-audio.tsx +0 -169
- package/src/store/use-first-person-hud.ts +0 -40
- package/src/store/use-follow-pascal.test.ts +0 -62
- package/src/store/use-follow-pascal.ts +0 -53
|
@@ -3,103 +3,63 @@
|
|
|
3
3
|
import {
|
|
4
4
|
type AnyNode,
|
|
5
5
|
type AnyNodeId,
|
|
6
|
-
area,
|
|
7
|
-
containsPoint,
|
|
8
|
-
DEFAULT_LEVEL_HEIGHT,
|
|
9
6
|
emitter,
|
|
10
|
-
|
|
11
|
-
type PascalI18n,
|
|
12
|
-
type Polygon,
|
|
13
|
-
polygonInteriorPoint,
|
|
14
|
-
type Ring,
|
|
7
|
+
roomFloorElevation,
|
|
15
8
|
sceneRegistry,
|
|
16
|
-
useLocale,
|
|
17
9
|
usePascalI18n,
|
|
18
10
|
useScene,
|
|
19
11
|
type ZoneNode,
|
|
20
12
|
} from '@pascal-app/core'
|
|
21
|
-
import {
|
|
22
|
-
import { useFrame, useThree } from '@react-three/fiber'
|
|
13
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
23
14
|
import {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
type
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
} from 'react'
|
|
38
|
-
import {
|
|
39
|
-
import {
|
|
40
|
-
type Camera,
|
|
41
|
-
type Group,
|
|
42
|
-
Matrix4,
|
|
43
|
-
type Object3D,
|
|
44
|
-
Ray,
|
|
45
|
-
Raycaster,
|
|
46
|
-
Vector2,
|
|
47
|
-
Vector3,
|
|
48
|
-
} from 'three'
|
|
15
|
+
RoomLabelAnchor3D,
|
|
16
|
+
type RoomLabelEntry,
|
|
17
|
+
type RoomLabelSelection,
|
|
18
|
+
RoomPillChip,
|
|
19
|
+
RoomPillDetail,
|
|
20
|
+
RoomPillsLayer2D,
|
|
21
|
+
RoomPillsLayer3D,
|
|
22
|
+
roomLabelEntries,
|
|
23
|
+
roomLabelsVisible,
|
|
24
|
+
selectionModifiersFromEvent,
|
|
25
|
+
useFadePresence,
|
|
26
|
+
zoneLabel,
|
|
27
|
+
} from '@pascal-app/viewer/ui'
|
|
28
|
+
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
29
|
+
import type { Vector3 } from 'three'
|
|
49
30
|
import { useShallow } from 'zustand/react/shallow'
|
|
50
31
|
import { useRoomRecords } from '../../hooks/use-selected-room'
|
|
51
|
-
import { getAreaUnitLabel, type LinearUnit, squareMetersToAreaUnit } from '../../lib/measurements'
|
|
52
|
-
import { roomFloorElevation } from '../../lib/room-handle-drag'
|
|
53
|
-
import { displayRoomName } from '../../lib/room-name-messages'
|
|
54
|
-
import type { RoomSelectionRecord } from '../../lib/room-selection'
|
|
55
32
|
import { clickZoneArea, hoverZoneArea, leaveZoneArea } from '../../lib/room-zone-routing'
|
|
56
|
-
import { selectionModifiersFromEvent } from '../../lib/selection-routing'
|
|
57
33
|
import useEditor from '../../store/use-editor'
|
|
58
34
|
import useInteractionScope from '../../store/use-interaction-scope'
|
|
59
35
|
import { isViewVisible, VIEW_2D, VIEW_3D } from '../../store/view-layout'
|
|
60
|
-
import { Html } from './localized-html'
|
|
61
|
-
|
|
62
|
-
const CONTEXT_TYPES = new Set(['site', 'building', 'level'])
|
|
63
36
|
|
|
64
|
-
export type RoomLabelVisibilityState = {
|
|
37
|
+
export type RoomLabelVisibilityState = RoomLabelSelection & {
|
|
65
38
|
phase: string
|
|
66
39
|
mode: string
|
|
67
40
|
scopeIdle: boolean
|
|
68
41
|
room: unknown
|
|
69
|
-
selectedTypes: readonly (string | undefined)[]
|
|
70
|
-
zoneId: string | null
|
|
71
|
-
focusedUnitId: string | null
|
|
72
42
|
isCaptureMode: boolean
|
|
73
43
|
isThumbnailCapture: boolean
|
|
74
44
|
}
|
|
75
45
|
|
|
76
46
|
/**
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
47
|
+
* In the editor, the pills are the resting view of a level only inside the
|
|
48
|
+
* building with the select tool idle: a room kept, a capture or a gesture in
|
|
49
|
+
* flight takes them away too.
|
|
80
50
|
*/
|
|
81
|
-
export function
|
|
51
|
+
export function editorRoomLabelsVisible(state: RoomLabelVisibilityState): boolean {
|
|
82
52
|
return (
|
|
83
53
|
state.phase === 'building' &&
|
|
84
54
|
state.mode === 'select' &&
|
|
85
55
|
state.scopeIdle &&
|
|
86
56
|
!state.room &&
|
|
87
|
-
!state.zoneId &&
|
|
88
|
-
!state.focusedUnitId &&
|
|
89
57
|
!state.isCaptureMode &&
|
|
90
58
|
!state.isThumbnailCapture &&
|
|
91
|
-
state
|
|
59
|
+
roomLabelsVisible(state)
|
|
92
60
|
)
|
|
93
61
|
}
|
|
94
62
|
|
|
95
|
-
export function formatRoomArea(squareMeters: number, unit: LinearUnit, locale?: string): string {
|
|
96
|
-
return `${formatNumber(squareMetersToAreaUnit(squareMeters, unit), {
|
|
97
|
-
min: 1,
|
|
98
|
-
max: 1,
|
|
99
|
-
locale,
|
|
100
|
-
})} ${getAreaUnitLabel(unit)}`
|
|
101
|
-
}
|
|
102
|
-
|
|
103
63
|
function useRoomLabelsVisible() {
|
|
104
64
|
const [isThumbnailCapture, setThumbnailCapture] = useState(false)
|
|
105
65
|
useEffect(() => {
|
|
@@ -129,7 +89,7 @@ function useRoomLabelsVisible() {
|
|
|
129
89
|
selectedIds.map((id) => (s.nodes[id as AnyNodeId] as AnyNode | undefined)?.type),
|
|
130
90
|
),
|
|
131
91
|
)
|
|
132
|
-
return
|
|
92
|
+
return editorRoomLabelsVisible({
|
|
133
93
|
...editor,
|
|
134
94
|
scopeIdle,
|
|
135
95
|
zoneId,
|
|
@@ -139,629 +99,6 @@ function useRoomLabelsVisible() {
|
|
|
139
99
|
})
|
|
140
100
|
}
|
|
141
101
|
|
|
142
|
-
export type LabelRect = { left: number; top: number; right: number; bottom: number }
|
|
143
|
-
type PaneSize = { width: number; height: number }
|
|
144
|
-
|
|
145
|
-
/** A pill's screen rect: centred on where its anchor lands, at its size. */
|
|
146
|
-
export function pillRect(x: number, y: number, [width, height]: readonly [number, number]) {
|
|
147
|
-
return { left: x - width / 2, top: y - height / 2, right: x + width / 2, bottom: y + height / 2 }
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
/** A pill's measured size: its full width, its height, and its width with the name alone. */
|
|
151
|
-
export type PillSize = readonly [fullWidth: number, height: number, nameWidth: number]
|
|
152
|
-
|
|
153
|
-
/** How much of a pill shows: dot, name and area; dot and name; its dot; nothing. */
|
|
154
|
-
export type PillTier = 'full' | 'name' | 'dot' | 'hidden'
|
|
155
|
-
|
|
156
|
-
/** Where a pill goes: how much of it shows, moved `dy` px off its anchor. */
|
|
157
|
-
export type PillPlacement = { tier: PillTier; dy: number }
|
|
158
|
-
|
|
159
|
-
/**
|
|
160
|
-
* A pill to place: where its room's anchor lands on screen, its size, its
|
|
161
|
-
* room's area and the part of the room's outline in front of the camera, on
|
|
162
|
-
* screen.
|
|
163
|
-
*/
|
|
164
|
-
export type PillCandidate = {
|
|
165
|
-
id: string
|
|
166
|
-
x: number
|
|
167
|
-
y: number
|
|
168
|
-
size: PillSize
|
|
169
|
-
area: number
|
|
170
|
-
outline: Polygon
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
/** The collapsed pill: its colour dot in a small round box. */
|
|
174
|
-
const PILL_DOT_SIZE = 16
|
|
175
|
-
/** The clear space around a placed pill, and around the detail chip. */
|
|
176
|
-
export const PILL_GAP = 4
|
|
177
|
-
const NUDGE_STEP = 4
|
|
178
|
-
const TIERS = ['full', 'name', 'dot'] as const
|
|
179
|
-
|
|
180
|
-
function near(a: LabelRect, b: LabelRect) {
|
|
181
|
-
return (
|
|
182
|
-
a.left < b.right + PILL_GAP &&
|
|
183
|
-
b.left < a.right + PILL_GAP &&
|
|
184
|
-
a.top < b.bottom + PILL_GAP &&
|
|
185
|
-
b.top < a.bottom + PILL_GAP
|
|
186
|
-
)
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
function tierSize(size: PillSize, tier: Exclude<PillTier, 'hidden'>): readonly [number, number] {
|
|
190
|
-
if (tier === 'full') return [size[0], size[1]]
|
|
191
|
-
if (tier === 'name') return [size[2], size[1]]
|
|
192
|
-
return [PILL_DOT_SIZE, PILL_DOT_SIZE]
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
/** The rect a placed pill takes on screen, or null when it is hidden. */
|
|
196
|
-
export function placedRect(pill: PillCandidate, { tier, dy }: PillPlacement): LabelRect | null {
|
|
197
|
-
return tier === 'hidden' ? null : pillRect(pill.x, pill.y + dy, tierSize(pill.size, tier))
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
function segmentTouchesRect(
|
|
201
|
-
[ax, ay]: readonly [number, number],
|
|
202
|
-
[bx, by]: readonly [number, number],
|
|
203
|
-
rect: LabelRect,
|
|
204
|
-
) {
|
|
205
|
-
if (
|
|
206
|
-
Math.max(ax, bx) < rect.left ||
|
|
207
|
-
Math.min(ax, bx) > rect.right ||
|
|
208
|
-
Math.max(ay, by) < rect.top ||
|
|
209
|
-
Math.min(ay, by) > rect.bottom
|
|
210
|
-
)
|
|
211
|
-
return false
|
|
212
|
-
// Liang–Barsky: narrow the segment's span to each side of the rect in turn.
|
|
213
|
-
let t0 = 0
|
|
214
|
-
let t1 = 1
|
|
215
|
-
const clip = (p: number, q: number) => {
|
|
216
|
-
if (p === 0) return q >= 0
|
|
217
|
-
const t = q / p
|
|
218
|
-
if (p < 0) {
|
|
219
|
-
if (t > t1) return false
|
|
220
|
-
if (t > t0) t0 = t
|
|
221
|
-
} else {
|
|
222
|
-
if (t < t0) return false
|
|
223
|
-
if (t < t1) t1 = t
|
|
224
|
-
}
|
|
225
|
-
return true
|
|
226
|
-
}
|
|
227
|
-
return (
|
|
228
|
-
clip(ax - bx, ax - rect.left) &&
|
|
229
|
-
clip(bx - ax, rect.right - ax) &&
|
|
230
|
-
clip(ay - by, ay - rect.top) &&
|
|
231
|
-
clip(by - ay, rect.bottom - ay)
|
|
232
|
-
)
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
function ringTouchesRect(ring: Ring, rect: LabelRect) {
|
|
236
|
-
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
|
|
237
|
-
if (segmentTouchesRect(ring[j]!, ring[i]!, rect)) return true
|
|
238
|
-
}
|
|
239
|
-
return false
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
/**
|
|
243
|
-
* Whether `rect` lies inside `outline`: no edge of the outline or its holes
|
|
244
|
-
* touches the rect, and the rect's centre is inside.
|
|
245
|
-
*/
|
|
246
|
-
function rectInPolygon(rect: LabelRect, outline: Polygon): boolean {
|
|
247
|
-
if (ringTouchesRect(outline.outer, rect)) return false
|
|
248
|
-
if (outline.holes.some((hole) => ringTouchesRect(hole, rect))) return false
|
|
249
|
-
return containsPoint([outline], [(rect.left + rect.right) / 2, (rect.top + rect.bottom) / 2])
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
/**
|
|
253
|
-
* 0, then the nudge the pill took last time (so it keeps to that side), then
|
|
254
|
-
* up and down by growing steps, up to `budget` px either way.
|
|
255
|
-
*/
|
|
256
|
-
function* nudges(budget: number, previous: number | undefined) {
|
|
257
|
-
yield 0
|
|
258
|
-
if (previous) yield previous
|
|
259
|
-
for (let d = NUDGE_STEP; d <= budget; d += NUDGE_STEP) {
|
|
260
|
-
if (-d !== previous) yield -d
|
|
261
|
-
if (d !== previous) yield d
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
function ringBox(ring: Ring): LabelRect {
|
|
266
|
-
const box = { left: Infinity, top: Infinity, right: -Infinity, bottom: -Infinity }
|
|
267
|
-
for (const [x, y] of ring) {
|
|
268
|
-
box.left = Math.min(box.left, x)
|
|
269
|
-
box.top = Math.min(box.top, y)
|
|
270
|
-
box.right = Math.max(box.right, x)
|
|
271
|
-
box.bottom = Math.max(box.bottom, y)
|
|
272
|
-
}
|
|
273
|
-
return box
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
function slotFor(
|
|
277
|
-
pill: PillCandidate,
|
|
278
|
-
bounds: PaneSize,
|
|
279
|
-
placed: LabelRect[],
|
|
280
|
-
previous: PillPlacement | undefined,
|
|
281
|
-
): PillPlacement {
|
|
282
|
-
const { outline } = pill
|
|
283
|
-
// The pane, narrowed to the room's bounding box: cheap to reject against.
|
|
284
|
-
const box = ringBox(outline.outer)
|
|
285
|
-
box.left = Math.max(box.left, 0)
|
|
286
|
-
box.top = Math.max(box.top, 0)
|
|
287
|
-
box.right = Math.min(box.right, bounds.width)
|
|
288
|
-
box.bottom = Math.min(box.bottom, bounds.height)
|
|
289
|
-
for (const tier of TIERS) {
|
|
290
|
-
const size = tierSize(pill.size, tier)
|
|
291
|
-
// Wider or taller than the room's box: no nudge fits it.
|
|
292
|
-
if (size[0] > box.right - box.left || size[1] > box.bottom - box.top) continue
|
|
293
|
-
const budget = tier === 'dot' ? PILL_DOT_SIZE : pill.size[1]
|
|
294
|
-
for (const dy of nudges(budget, previous?.tier === tier ? previous.dy : undefined)) {
|
|
295
|
-
const rect = pillRect(pill.x, pill.y + dy, size)
|
|
296
|
-
if (
|
|
297
|
-
rect.left < box.left ||
|
|
298
|
-
rect.top < box.top ||
|
|
299
|
-
rect.right > box.right ||
|
|
300
|
-
rect.bottom > box.bottom
|
|
301
|
-
)
|
|
302
|
-
continue
|
|
303
|
-
if (!rectInPolygon(rect, outline)) continue
|
|
304
|
-
if (placed.some((other) => near(rect, other))) continue
|
|
305
|
-
placed.push(rect)
|
|
306
|
-
return { tier, dy }
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
return { tier: 'hidden', dy: 0 }
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
/**
|
|
313
|
-
* Where every pill goes, so that none overlap and each stays inside its room
|
|
314
|
-
* and the pane. Larger rooms are placed first (ties by id, so the order holds
|
|
315
|
-
* while the camera moves). Each pill takes the first slot that fits: its full
|
|
316
|
-
* pill, then its name alone, then its dot, each at its anchor or nudged up or
|
|
317
|
-
* down; with no slot it is hidden. A room's size on screen decides how much of
|
|
318
|
-
* its label shows, so the detail follows the zoom.
|
|
319
|
-
*/
|
|
320
|
-
export function placePills(
|
|
321
|
-
candidates: readonly PillCandidate[],
|
|
322
|
-
bounds: PaneSize,
|
|
323
|
-
previous: ReadonlyMap<string, PillPlacement> = new Map(),
|
|
324
|
-
): Map<string, PillPlacement> {
|
|
325
|
-
const placed: LabelRect[] = []
|
|
326
|
-
const placements = new Map<string, PillPlacement>()
|
|
327
|
-
const order = [...candidates].sort(
|
|
328
|
-
(a, b) => b.area - a.area || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0),
|
|
329
|
-
)
|
|
330
|
-
for (const pill of order)
|
|
331
|
-
placements.set(pill.id, slotFor(pill, bounds, placed, previous.get(pill.id)))
|
|
332
|
-
return placements
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
/** The placed pills whose rects come within the gap of `chip`. */
|
|
336
|
-
export function suppressedBy(
|
|
337
|
-
chip: LabelRect,
|
|
338
|
-
rects: ReadonlyMap<string, LabelRect>,
|
|
339
|
-
): ReadonlySet<string> {
|
|
340
|
-
const ids = new Set<string>()
|
|
341
|
-
for (const [id, rect] of rects) if (near(chip, rect)) ids.add(id)
|
|
342
|
-
return ids
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
export type RoomLabelEntry = {
|
|
346
|
-
zoneId: string
|
|
347
|
-
polygon: [number, number][]
|
|
348
|
-
holes: [number, number][][]
|
|
349
|
-
area: number
|
|
350
|
-
}
|
|
351
|
-
type LabelEntry = RoomLabelEntry
|
|
352
|
-
|
|
353
|
-
/** Where the pointer meets a room's plane: the point in it, and how far along the pointer's ray. */
|
|
354
|
-
export type RoomHit = readonly [x: number, y: number, distance: number]
|
|
355
|
-
|
|
356
|
-
// Planes closer together than this along the ray (in metres) count as one floor.
|
|
357
|
-
const SAME_FLOOR = 1e-3
|
|
358
|
-
|
|
359
|
-
/**
|
|
360
|
-
* The room under the pointer: of the rooms whose outline, holes excluded,
|
|
361
|
-
* holds the point where the pointer meets their plane, the nearest along the
|
|
362
|
-
* pointer's ray (the floor it reaches first), then the smallest (a zone drawn
|
|
363
|
-
* inside a room, on the same floor). `pointOn` gives that point for each room
|
|
364
|
-
* (in 3D, where the ray meets the room's floor), or null if it never meets it.
|
|
365
|
-
*/
|
|
366
|
-
export function roomAt(
|
|
367
|
-
entries: readonly RoomLabelEntry[],
|
|
368
|
-
pointOn: (entry: RoomLabelEntry) => RoomHit | null,
|
|
369
|
-
): string | null {
|
|
370
|
-
let found: RoomLabelEntry | null = null
|
|
371
|
-
let foundAt = Number.POSITIVE_INFINITY
|
|
372
|
-
for (const entry of entries) {
|
|
373
|
-
const hit = pointOn(entry)
|
|
374
|
-
if (!hit) continue
|
|
375
|
-
const [x, y, distance] = hit
|
|
376
|
-
if (found) {
|
|
377
|
-
const nearer = distance < foundAt - SAME_FLOOR
|
|
378
|
-
const sameFloor = !nearer && distance <= foundAt + SAME_FLOOR
|
|
379
|
-
if (!(nearer || (sameFloor && entry.area < found.area))) continue
|
|
380
|
-
}
|
|
381
|
-
if (containsPoint([{ outer: entry.polygon, holes: entry.holes }], [x, y])) {
|
|
382
|
-
found = entry
|
|
383
|
-
foundAt = distance
|
|
384
|
-
}
|
|
385
|
-
}
|
|
386
|
-
return found?.zoneId ?? null
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
// Mid-height of a default storey: above furniture, below the ceiling.
|
|
390
|
-
const LABEL_HEIGHT = DEFAULT_LEVEL_HEIGHT / 2
|
|
391
|
-
// The view has to hold still this long before pills are re-placed; while it
|
|
392
|
-
// moves they keep their places and ride their rooms, so nothing jitters.
|
|
393
|
-
const DECLUTTER_REST_SECONDS = 0.12
|
|
394
|
-
// Below this the view counts as still: well under a pixel on screen.
|
|
395
|
-
const VIEW_EPSILON = 1e-4
|
|
396
|
-
/** How long a pill takes to fade, move or collapse. */
|
|
397
|
-
const PILL_FADE_MS = 180
|
|
398
|
-
/** How long the pointer rests on a room before its detail opens. */
|
|
399
|
-
export const REVEAL_DWELL_MS = 80
|
|
400
|
-
/** How long the detail stays once the pointer has left its room. */
|
|
401
|
-
export const REVEAL_GRACE_MS = 120
|
|
402
|
-
// The detail chip stays this far inside its layer.
|
|
403
|
-
const DETAIL_INSET = 8
|
|
404
|
-
|
|
405
|
-
function pillFadeMs(): number {
|
|
406
|
-
return typeof window !== 'undefined' &&
|
|
407
|
-
window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
|
|
408
|
-
? 0
|
|
409
|
-
: PILL_FADE_MS
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
/**
|
|
413
|
-
* When a layer re-places its pills: once the view has held still, and again
|
|
414
|
-
* whenever its pills change while it rests. Still means within an epsilon of
|
|
415
|
-
* the view last seen moving — camera damping keeps nudging the camera by
|
|
416
|
-
* fractions of a millimetre for a second or more after it visibly stops, and
|
|
417
|
-
* waiting for it to stop exactly left pills misplaced for that long.
|
|
418
|
-
*/
|
|
419
|
-
export class DeclutterClock {
|
|
420
|
-
private reference: number[] | null = null
|
|
421
|
-
private movedAt = Number.NEGATIVE_INFINITY
|
|
422
|
-
private settled = false
|
|
423
|
-
private dirty = true
|
|
424
|
-
|
|
425
|
-
/** Whether the view rests and its pills were placed for it. */
|
|
426
|
-
get atRest() {
|
|
427
|
-
return this.settled
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
/** The pills changed (mounted, unmounted, resized): place them again. */
|
|
431
|
-
invalidate() {
|
|
432
|
-
this.dirty = true
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
/** Whether to place pills for this view (any flat list of numbers) at `now` seconds. */
|
|
436
|
-
shouldRead(view: ArrayLike<number>, now: number): boolean {
|
|
437
|
-
const reference = this.reference
|
|
438
|
-
let moved = !reference || reference.length !== view.length
|
|
439
|
-
for (let i = 0; !moved && i < view.length; i++) {
|
|
440
|
-
moved = Math.abs(view[i]! - reference![i]!) > VIEW_EPSILON
|
|
441
|
-
}
|
|
442
|
-
if (moved) {
|
|
443
|
-
this.reference = Array.from(view)
|
|
444
|
-
this.movedAt = now
|
|
445
|
-
this.settled = false
|
|
446
|
-
}
|
|
447
|
-
if (now - this.movedAt < DECLUTTER_REST_SECONDS || (this.settled && !this.dirty)) return false
|
|
448
|
-
this.settled = true
|
|
449
|
-
this.dirty = false
|
|
450
|
-
return true
|
|
451
|
-
}
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
/** The pills a layer has mounted, by zone id; the layer hears each one come and go. */
|
|
455
|
-
export class PillElements extends Map<string, HTMLElement> {
|
|
456
|
-
onMount: ((id: string, element: HTMLElement) => void) | null = null
|
|
457
|
-
onUnmount: ((element: HTMLElement) => void) | null = null
|
|
458
|
-
onMeasure: ((id: string, element: HTMLElement) => void) | null = null
|
|
459
|
-
|
|
460
|
-
measure(id: string) {
|
|
461
|
-
const element = this.get(id)
|
|
462
|
-
if (element) this.onMeasure?.(id, element)
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
override set(id: string, element: HTMLElement) {
|
|
466
|
-
super.set(id, element)
|
|
467
|
-
this.onMount?.(id, element)
|
|
468
|
-
return this
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
override delete(id: string) {
|
|
472
|
-
const element = this.get(id)
|
|
473
|
-
const removed = super.delete(id)
|
|
474
|
-
if (element) this.onUnmount?.(element)
|
|
475
|
-
return removed
|
|
476
|
-
}
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
/** Where each room's pill floats, level-local; moving one has the pills placed again. */
|
|
480
|
-
export class RoomAnchors extends Map<string, Vector3> {
|
|
481
|
-
onMove: (() => void) | null = null
|
|
482
|
-
|
|
483
|
-
override set(id: string, position: Vector3) {
|
|
484
|
-
const moved = !this.get(id)?.equals(position)
|
|
485
|
-
super.set(id, position)
|
|
486
|
-
if (moved) this.onMove?.()
|
|
487
|
-
return this
|
|
488
|
-
}
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
/** Read a pill's full border-box and its width without the area, whatever it shows now. */
|
|
492
|
-
export function measureFullPill(
|
|
493
|
-
element: HTMLElement,
|
|
494
|
-
read: (
|
|
495
|
-
element: Element,
|
|
496
|
-
) => Pick<CSSStyleDeclaration, 'width' | 'height' | 'columnGap'> = getComputedStyle,
|
|
497
|
-
): PillSize {
|
|
498
|
-
const tier = element.dataset.roomLabelTier
|
|
499
|
-
delete element.dataset.roomLabelTier
|
|
500
|
-
try {
|
|
501
|
-
const style = read(element)
|
|
502
|
-
const width = Number.parseFloat(style.width)
|
|
503
|
-
const areaLabel = element.querySelector('[data-room-label-area]')
|
|
504
|
-
const areaWidth = areaLabel
|
|
505
|
-
? Number.parseFloat(read(areaLabel).width) + Number.parseFloat(style.columnGap)
|
|
506
|
-
: 0
|
|
507
|
-
return [width, Number.parseFloat(style.height), width - areaWidth]
|
|
508
|
-
} finally {
|
|
509
|
-
if (tier !== undefined) element.dataset.roomLabelTier = tier
|
|
510
|
-
}
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
type Timer = ReturnType<typeof setTimeout>
|
|
514
|
-
// Wrapped, not passed as is: browsers throw "Illegal invocation" when the
|
|
515
|
-
// global timers are called as methods of another object.
|
|
516
|
-
const startTimer = (run: () => void, ms: number): Timer => setTimeout(run, ms)
|
|
517
|
-
const stopTimer = (timer: Timer) => clearTimeout(timer)
|
|
518
|
-
|
|
519
|
-
/**
|
|
520
|
-
* Applies placements to the mounted pills as direct style writes: the nudge
|
|
521
|
-
* as a `translate`, the tier as `data-room-label-tier`, both eased by the
|
|
522
|
-
* pill's transition. A pill placed hidden fades out without taking the
|
|
523
|
-
* pointer, then leaves the DOM (`gone`); one given room again mounts and fades
|
|
524
|
-
* back in. A mounted pill that a pass leaves out (not measured yet, or its
|
|
525
|
-
* anchor off screen) waits hidden: no pill shows that placement has not
|
|
526
|
-
* cleared.
|
|
527
|
-
*/
|
|
528
|
-
export class PillPlacer {
|
|
529
|
-
gone: ReadonlySet<string> = new Set()
|
|
530
|
-
/** The last pass's placements: a remounting pill takes its own at once. */
|
|
531
|
-
placements: ReadonlyMap<string, PillPlacement> = new Map()
|
|
532
|
-
private readonly fading = new Map<string, Timer>()
|
|
533
|
-
private suppressed: ReadonlySet<string> = new Set()
|
|
534
|
-
private open: string | null = null
|
|
535
|
-
private shown = true
|
|
536
|
-
|
|
537
|
-
constructor(
|
|
538
|
-
private readonly elements: ReadonlyMap<string, HTMLElement>,
|
|
539
|
-
private readonly onGone: (gone: ReadonlySet<string>) => void,
|
|
540
|
-
private readonly schedule: (run: () => void, ms: number) => Timer = startTimer,
|
|
541
|
-
private readonly cancel: (timer: Timer) => void = stopTimer,
|
|
542
|
-
) {}
|
|
543
|
-
|
|
544
|
-
apply(placements: ReadonlyMap<string, PillPlacement>, fadeMs = pillFadeMs()) {
|
|
545
|
-
this.placements = placements
|
|
546
|
-
for (const [id, element] of this.elements) {
|
|
547
|
-
const placement = placements.get(id)
|
|
548
|
-
if (placement && placement.tier !== 'hidden') {
|
|
549
|
-
const timer = this.fading.get(id)
|
|
550
|
-
if (timer !== undefined) this.cancel(timer)
|
|
551
|
-
this.fading.delete(id)
|
|
552
|
-
this.show(element, placement)
|
|
553
|
-
continue
|
|
554
|
-
}
|
|
555
|
-
element.dataset.roomLabelHidden = ''
|
|
556
|
-
if (!placement || this.fading.has(id)) continue
|
|
557
|
-
this.fading.set(
|
|
558
|
-
id,
|
|
559
|
-
this.schedule(() => {
|
|
560
|
-
this.fading.delete(id)
|
|
561
|
-
this.setGone(new Set(this.gone).add(id))
|
|
562
|
-
}, fadeMs),
|
|
563
|
-
)
|
|
564
|
-
}
|
|
565
|
-
const kept = [...this.gone].filter((id) => (placements.get(id)?.tier ?? 'hidden') === 'hidden')
|
|
566
|
-
if (kept.length !== this.gone.size) this.setGone(new Set(kept))
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
/** A pill mounting takes its last placement at once, or waits hidden for one. */
|
|
570
|
-
mount(id: string, element: HTMLElement) {
|
|
571
|
-
const placement = this.placements.get(id)
|
|
572
|
-
if (placement && placement.tier !== 'hidden') this.show(element, placement)
|
|
573
|
-
else element.dataset.roomLabelHidden = ''
|
|
574
|
-
this.mark(id, element)
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
/**
|
|
578
|
-
* Fade the pills under the detail chip out of its way, and every other back.
|
|
579
|
-
* The open room's own pill fades too but still takes the pointer: the chip
|
|
580
|
-
* over it is read-only, so the pill stays the control.
|
|
581
|
-
*/
|
|
582
|
-
suppress(ids: ReadonlySet<string>, open: string | null) {
|
|
583
|
-
this.suppressed = ids
|
|
584
|
-
this.open = open
|
|
585
|
-
for (const [id, element] of this.elements) this.mark(id, element)
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
/** Remount hidden pills invisibly so changed text or fonts can be measured. */
|
|
589
|
-
remeasure() {
|
|
590
|
-
if (this.gone.size) this.setGone(new Set())
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
/** Fade every mounted pill out (the layer is leaving), or the placed ones back in. */
|
|
594
|
-
showAll(shown: boolean) {
|
|
595
|
-
this.shown = shown
|
|
596
|
-
for (const [id, element] of this.elements) {
|
|
597
|
-
const placement = this.placements.get(id)
|
|
598
|
-
if (shown && placement && placement.tier !== 'hidden') delete element.dataset.roomLabelHidden
|
|
599
|
-
else element.dataset.roomLabelHidden = ''
|
|
600
|
-
}
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
dispose() {
|
|
604
|
-
for (const timer of this.fading.values()) this.cancel(timer)
|
|
605
|
-
this.fading.clear()
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
private show(element: HTMLElement, { tier, dy }: PillPlacement) {
|
|
609
|
-
const translate = dy ? `0 ${dy}px` : ''
|
|
610
|
-
if (element.style.translate !== translate) element.style.translate = translate
|
|
611
|
-
if (tier === 'full') delete element.dataset.roomLabelTier
|
|
612
|
-
else if (element.dataset.roomLabelTier !== tier) element.dataset.roomLabelTier = tier
|
|
613
|
-
if (this.shown) delete element.dataset.roomLabelHidden
|
|
614
|
-
else element.dataset.roomLabelHidden = ''
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
private mark(id: string, element: HTMLElement) {
|
|
618
|
-
const { dataset } = element
|
|
619
|
-
if (id === this.open) dataset.roomLabelOpen = ''
|
|
620
|
-
else delete dataset.roomLabelOpen
|
|
621
|
-
if (id !== this.open && this.suppressed.has(id)) dataset.roomLabelSuppressed = ''
|
|
622
|
-
else delete dataset.roomLabelSuppressed
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
private setGone(gone: ReadonlySet<string>) {
|
|
626
|
-
this.gone = gone
|
|
627
|
-
this.onGone(gone)
|
|
628
|
-
}
|
|
629
|
-
}
|
|
630
|
-
|
|
631
|
-
/**
|
|
632
|
-
* Which room's detail chip is open. A pill under the pointer, or a pill with
|
|
633
|
-
* keyboard focus, opens at once; when both are set, whichever moved last
|
|
634
|
-
* wins. Otherwise the room under the pointer opens once the pointer has
|
|
635
|
-
* rested on it for a moment, and stays a moment after the pointer leaves, so
|
|
636
|
-
* crossing a wall hands over from one room to the next without a gap and
|
|
637
|
-
* passing over a room opens nothing. Escape closes it until the pointer or
|
|
638
|
-
* focus moves on to another room.
|
|
639
|
-
*/
|
|
640
|
-
export class LabelReveal {
|
|
641
|
-
active: string | null = null
|
|
642
|
-
private activeFocused = false
|
|
643
|
-
private hovered: string | null = null
|
|
644
|
-
private focused: string | null = null
|
|
645
|
-
private picked: string | null = null
|
|
646
|
-
private keyboardLast = false
|
|
647
|
-
private dismissed: string | null = null
|
|
648
|
-
private pending: { id: string | null; timer: Timer } | null = null
|
|
649
|
-
|
|
650
|
-
constructor(
|
|
651
|
-
private readonly onChange: (id: string | null, focused: boolean) => void,
|
|
652
|
-
private readonly schedule: (run: () => void, ms: number) => Timer = startTimer,
|
|
653
|
-
private readonly cancel: (timer: Timer) => void = stopTimer,
|
|
654
|
-
) {}
|
|
655
|
-
|
|
656
|
-
/** The pill under the pointer. */
|
|
657
|
-
hover(id: string | null) {
|
|
658
|
-
if (id !== null && id !== this.hovered) this.keyboardLast = false
|
|
659
|
-
this.hovered = id
|
|
660
|
-
this.update()
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
/** The pill with keyboard focus. */
|
|
664
|
-
focus(id: string | null) {
|
|
665
|
-
if (id !== null) this.keyboardLast = true
|
|
666
|
-
this.focused = id
|
|
667
|
-
this.update()
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
/** The room under the pointer. */
|
|
671
|
-
pick(id: string | null) {
|
|
672
|
-
this.picked = id
|
|
673
|
-
this.update()
|
|
674
|
-
}
|
|
675
|
-
|
|
676
|
-
/** Close what is open, and keep it closed while the pointer and focus stay on it. */
|
|
677
|
-
dismiss() {
|
|
678
|
-
if (this.active === null) return
|
|
679
|
-
this.dismissed = this.active
|
|
680
|
-
this.stop()
|
|
681
|
-
this.set(null)
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
/**
|
|
685
|
-
* Close at once and forget the pointer: the view moved, or the layer hid.
|
|
686
|
-
* Focus is kept, so a focused pill opens again once the view rests.
|
|
687
|
-
*/
|
|
688
|
-
clear() {
|
|
689
|
-
this.hovered = null
|
|
690
|
-
this.picked = null
|
|
691
|
-
this.stop()
|
|
692
|
-
this.set(null)
|
|
693
|
-
}
|
|
694
|
-
|
|
695
|
-
private update() {
|
|
696
|
-
const direct = this.keyboardLast
|
|
697
|
-
? (this.focused ?? this.hovered)
|
|
698
|
-
: (this.hovered ?? this.focused)
|
|
699
|
-
if (this.dismissed !== null) {
|
|
700
|
-
if ((direct ?? this.picked) === this.dismissed) {
|
|
701
|
-
this.stop()
|
|
702
|
-
return
|
|
703
|
-
}
|
|
704
|
-
this.dismissed = null
|
|
705
|
-
}
|
|
706
|
-
if (direct) {
|
|
707
|
-
this.stop()
|
|
708
|
-
this.set(direct)
|
|
709
|
-
return
|
|
710
|
-
}
|
|
711
|
-
const next = this.picked
|
|
712
|
-
if (next === this.active) {
|
|
713
|
-
this.stop()
|
|
714
|
-
this.set(next)
|
|
715
|
-
return
|
|
716
|
-
}
|
|
717
|
-
if (this.pending && this.pending.id === next) return
|
|
718
|
-
this.stop()
|
|
719
|
-
this.pending = {
|
|
720
|
-
id: next,
|
|
721
|
-
timer: this.schedule(
|
|
722
|
-
() => {
|
|
723
|
-
this.pending = null
|
|
724
|
-
this.set(next)
|
|
725
|
-
},
|
|
726
|
-
next ? REVEAL_DWELL_MS : REVEAL_GRACE_MS,
|
|
727
|
-
),
|
|
728
|
-
}
|
|
729
|
-
}
|
|
730
|
-
|
|
731
|
-
private stop() {
|
|
732
|
-
if (this.pending) this.cancel(this.pending.timer)
|
|
733
|
-
this.pending = null
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
private set(id: string | null) {
|
|
737
|
-
const focused = id !== null && this.focused === id
|
|
738
|
-
if (id === this.active && focused === this.activeFocused) return
|
|
739
|
-
this.active = id
|
|
740
|
-
this.activeFocused = focused
|
|
741
|
-
this.onChange(id, focused)
|
|
742
|
-
}
|
|
743
|
-
}
|
|
744
|
-
|
|
745
|
-
/** True while `shown`, and through the fade once it turns false; then false, so the pills unmount. */
|
|
746
|
-
export function useFadePresence(shown: boolean, onGone?: () => void): boolean {
|
|
747
|
-
const [present, setPresent] = useState(shown)
|
|
748
|
-
useEffect(() => {
|
|
749
|
-
if (shown) {
|
|
750
|
-
setPresent(true)
|
|
751
|
-
return
|
|
752
|
-
}
|
|
753
|
-
const timer = setTimeout(() => setPresent(false), pillFadeMs())
|
|
754
|
-
return () => clearTimeout(timer)
|
|
755
|
-
}, [shown])
|
|
756
|
-
const goneRef = useRef(onGone)
|
|
757
|
-
goneRef.current = onGone
|
|
758
|
-
const isPresent = shown || present
|
|
759
|
-
useEffect(() => {
|
|
760
|
-
if (!isPresent) goneRef.current?.()
|
|
761
|
-
}, [isPresent])
|
|
762
|
-
return isPresent
|
|
763
|
-
}
|
|
764
|
-
|
|
765
102
|
/**
|
|
766
103
|
* One pill layer per level: the current level's fades in, a level just left
|
|
767
104
|
* fades out over the next one, then unmounts.
|
|
@@ -780,322 +117,7 @@ function useFadingLevels(levelId: string | null, visible: boolean) {
|
|
|
780
117
|
return { levels, drop }
|
|
781
118
|
}
|
|
782
119
|
|
|
783
|
-
|
|
784
|
-
type DetailOrigin = { x: number; y: number; onDot: boolean; bounds: PaneSize }
|
|
785
|
-
type RoomLabelDetailState = { id: string; open: boolean; focused: boolean; at: DetailOrigin }
|
|
786
|
-
type PointerAt = { x: number; y: number }
|
|
787
|
-
|
|
788
|
-
/**
|
|
789
|
-
* One layer's placement and reveal, shared by its frame loop, its pointer
|
|
790
|
-
* listeners and its pills: the pills it has mounted, the size each last
|
|
791
|
-
* rendered at (kept for pills that left the DOM), the last placement pass, and
|
|
792
|
-
* which room's detail is open.
|
|
793
|
-
*/
|
|
794
|
-
class PillLayer {
|
|
795
|
-
readonly elements = new PillElements()
|
|
796
|
-
readonly clock = new DeclutterClock()
|
|
797
|
-
readonly sizes = new Map<string, PillSize>()
|
|
798
|
-
readonly placer: PillPlacer
|
|
799
|
-
readonly reveal: LabelReveal
|
|
800
|
-
container: () => HTMLElement | null = () => null
|
|
801
|
-
surface: () => Element | null | undefined = () => null
|
|
802
|
-
/** The pointer over the host in client px while no button is down, null off it. */
|
|
803
|
-
pointer: PointerAt | null = null
|
|
804
|
-
pointerMoved = false
|
|
805
|
-
private candidates = new Map<string, PillCandidate>()
|
|
806
|
-
private rects = new Map<string, LabelRect>()
|
|
807
|
-
private bounds: PaneSize = { width: 0, height: 0 }
|
|
808
|
-
private chip: LabelRect | null = null
|
|
809
|
-
// The open detail, a store of its own: see `OpenRoomLabelDetail`.
|
|
810
|
-
private detail: RoomLabelDetailState | null = null
|
|
811
|
-
private readonly detailListeners = new Set<() => void>()
|
|
812
|
-
|
|
813
|
-
constructor(onGone: (gone: ReadonlySet<string>) => void) {
|
|
814
|
-
this.placer = new PillPlacer(this.elements, onGone)
|
|
815
|
-
this.reveal = new LabelReveal((id, focused) => this.openDetail(id, focused))
|
|
816
|
-
}
|
|
817
|
-
|
|
818
|
-
subscribeDetail = (listener: () => void) => {
|
|
819
|
-
this.detailListeners.add(listener)
|
|
820
|
-
return () => {
|
|
821
|
-
this.detailListeners.delete(listener)
|
|
822
|
-
}
|
|
823
|
-
}
|
|
824
|
-
|
|
825
|
-
getDetail = () => this.detail
|
|
826
|
-
|
|
827
|
-
place(candidates: PillCandidate[], bounds: PaneSize) {
|
|
828
|
-
const placements = placePills(candidates, bounds, this.placer.placements)
|
|
829
|
-
this.placer.apply(placements)
|
|
830
|
-
this.bounds = { width: bounds.width, height: bounds.height }
|
|
831
|
-
this.candidates = new Map(candidates.map((candidate) => [candidate.id, candidate]))
|
|
832
|
-
this.rects = new Map()
|
|
833
|
-
for (const candidate of candidates) {
|
|
834
|
-
const rect = placedRect(candidate, placements.get(candidate.id)!)
|
|
835
|
-
if (rect) this.rects.set(candidate.id, rect)
|
|
836
|
-
}
|
|
837
|
-
// An open chip follows its pill to its new place.
|
|
838
|
-
if (this.detail?.open) this.openDetail(this.detail.id, this.detail.focused)
|
|
839
|
-
this.suppress()
|
|
840
|
-
}
|
|
841
|
-
|
|
842
|
-
/**
|
|
843
|
-
* After the frame's placement, if any: close the detail while the view
|
|
844
|
-
* moves; once it rests, follow the room under the pointer, again after each
|
|
845
|
-
* placement so a wheel zoom opens the room left under the cursor.
|
|
846
|
-
*/
|
|
847
|
-
settle(placed: boolean, pick: (pointer: PointerAt) => string | null) {
|
|
848
|
-
if (!this.clock.atRest) {
|
|
849
|
-
this.reveal.clear()
|
|
850
|
-
return
|
|
851
|
-
}
|
|
852
|
-
if (!(placed || this.pointerMoved)) return
|
|
853
|
-
this.pointerMoved = false
|
|
854
|
-
this.reveal.pick(this.pointer && pick(this.pointer))
|
|
855
|
-
}
|
|
856
|
-
|
|
857
|
-
/** Where the detail chip now sits, or null once it closes or unmounts. */
|
|
858
|
-
placeDetail = (rect: LabelRect | null) => {
|
|
859
|
-
this.chip = rect
|
|
860
|
-
this.suppress()
|
|
861
|
-
}
|
|
862
|
-
|
|
863
|
-
private suppress() {
|
|
864
|
-
if (!this.chip) {
|
|
865
|
-
this.placer.suppress(new Set(), null)
|
|
866
|
-
return
|
|
867
|
-
}
|
|
868
|
-
const open = this.detail?.open ? this.detail.id : null
|
|
869
|
-
this.placer.suppress(suppressedBy(this.chip, this.rects), open)
|
|
870
|
-
}
|
|
871
|
-
|
|
872
|
-
private openDetail(id: string | null, focused: boolean) {
|
|
873
|
-
const at = id ? this.locate(id) : null
|
|
874
|
-
this.detail =
|
|
875
|
-
id && at ? { id, open: true, focused, at } : this.detail && { ...this.detail, open: false }
|
|
876
|
-
for (const listener of this.detailListeners) listener()
|
|
877
|
-
}
|
|
878
|
-
|
|
879
|
-
private locate(id: string): DetailOrigin | null {
|
|
880
|
-
const bounds = this.bounds
|
|
881
|
-
const rect = this.rects.get(id)
|
|
882
|
-
const dot = rect && this.elements.get(id)?.querySelector<HTMLElement>('[data-room-label-dot]')
|
|
883
|
-
if (rect && dot) {
|
|
884
|
-
// From the placed rect, not the pill's live box: it may still be easing in.
|
|
885
|
-
const x = rect.left + dot.offsetLeft + dot.offsetWidth / 2
|
|
886
|
-
return { x, y: (rect.top + rect.bottom) / 2, onDot: true, bounds }
|
|
887
|
-
}
|
|
888
|
-
const anchor = this.candidates.get(id)
|
|
889
|
-
if (anchor) return { x: anchor.x, y: anchor.y, onDot: false, bounds }
|
|
890
|
-
const box = this.container()?.getBoundingClientRect()
|
|
891
|
-
if (!(box && this.pointer)) return null
|
|
892
|
-
return { x: this.pointer.x - box.left, y: this.pointer.y - box.top, onDot: false, bounds }
|
|
893
|
-
}
|
|
894
|
-
}
|
|
895
|
-
|
|
896
|
-
const samePx = (a: number, b: number) => Math.abs(a - b) < 0.5
|
|
897
|
-
|
|
898
|
-
/**
|
|
899
|
-
* The state of one layer of pills, kept in step with them: measured as they
|
|
900
|
-
* mount (and observed, so a web font arriving, a rename or a unit change
|
|
901
|
-
* places them again), shown and hidden with the layer, and its detail opened
|
|
902
|
-
* by keyboard focus or the pointer over `host`: over the `surface` (the canvas
|
|
903
|
-
* or the plan) or the layer's own labels, not the UI over them.
|
|
904
|
-
*/
|
|
905
|
-
function usePillLayer(
|
|
906
|
-
shown: boolean,
|
|
907
|
-
entries: readonly LabelEntry[],
|
|
908
|
-
host: HTMLElement | null,
|
|
909
|
-
container: () => HTMLElement | null,
|
|
910
|
-
surface: () => Element | null | undefined,
|
|
911
|
-
) {
|
|
912
|
-
const [gone, setGone] = useState<ReadonlySet<string>>(() => new Set())
|
|
913
|
-
const [layer] = useState(() => new PillLayer(setGone))
|
|
914
|
-
layer.container = container
|
|
915
|
-
layer.surface = surface
|
|
916
|
-
const unit = useViewer((s) => s.unit)
|
|
917
|
-
const shownRef = useRef(shown)
|
|
918
|
-
shownRef.current = shown
|
|
919
|
-
useEffect(() => {
|
|
920
|
-
const { clock, elements, placer, sizes } = layer
|
|
921
|
-
elements.onMeasure = (id, element) => {
|
|
922
|
-
const size = measureFullPill(element)
|
|
923
|
-
if (!(size[0] > 0 && size[1] > 0 && size[2] > 0)) return
|
|
924
|
-
const previous = sizes.get(id)
|
|
925
|
-
if (previous?.every((value, i) => samePx(value, size[i]!))) return
|
|
926
|
-
sizes.set(id, size)
|
|
927
|
-
clock.invalidate()
|
|
928
|
-
}
|
|
929
|
-
const observer =
|
|
930
|
-
typeof ResizeObserver === 'undefined'
|
|
931
|
-
? null
|
|
932
|
-
: new ResizeObserver((records) => {
|
|
933
|
-
for (const record of records) {
|
|
934
|
-
const element = record.target as HTMLElement
|
|
935
|
-
const id = element.dataset.roomLabel
|
|
936
|
-
const box = record.borderBoxSize?.[0]
|
|
937
|
-
if (!(id && box && box.inlineSize > 0)) continue
|
|
938
|
-
// A new tier resizes the pill to a size already known; anything
|
|
939
|
-
// else (a dot back on screen with text changed while its wrapper
|
|
940
|
-
// was display:none) is measured again.
|
|
941
|
-
const tier = element.dataset.roomLabelTier
|
|
942
|
-
const size = sizes.get(id)
|
|
943
|
-
const expected =
|
|
944
|
-
size && tierSize(size, tier === 'name' || tier === 'dot' ? tier : 'full')
|
|
945
|
-
if (
|
|
946
|
-
expected &&
|
|
947
|
-
samePx(expected[0], box.inlineSize) &&
|
|
948
|
-
samePx(expected[1], box.blockSize)
|
|
949
|
-
)
|
|
950
|
-
continue
|
|
951
|
-
elements.measure(id)
|
|
952
|
-
}
|
|
953
|
-
})
|
|
954
|
-
elements.onMount = (id, element) => {
|
|
955
|
-
placer.mount(id, element)
|
|
956
|
-
elements.measure(id)
|
|
957
|
-
observer?.observe(element)
|
|
958
|
-
clock.invalidate()
|
|
959
|
-
}
|
|
960
|
-
elements.onUnmount = (element) => {
|
|
961
|
-
observer?.unobserve(element)
|
|
962
|
-
clock.invalidate()
|
|
963
|
-
}
|
|
964
|
-
for (const [id, element] of elements) elements.onMount(id, element)
|
|
965
|
-
const fontsChanged = () => {
|
|
966
|
-
placer.remeasure()
|
|
967
|
-
for (const id of elements.keys()) elements.measure(id)
|
|
968
|
-
}
|
|
969
|
-
document.fonts?.addEventListener('loadingdone', fontsChanged)
|
|
970
|
-
return () => {
|
|
971
|
-
elements.onMount = null
|
|
972
|
-
elements.onUnmount = null
|
|
973
|
-
elements.onMeasure = null
|
|
974
|
-
document.fonts?.removeEventListener('loadingdone', fontsChanged)
|
|
975
|
-
observer?.disconnect()
|
|
976
|
-
placer.dispose()
|
|
977
|
-
layer.reveal.clear()
|
|
978
|
-
}
|
|
979
|
-
}, [layer])
|
|
980
|
-
// biome-ignore lint/correctness/useExhaustiveDependencies: Hidden pills must remount to measure changed entries or units.
|
|
981
|
-
useEffect(() => {
|
|
982
|
-
layer.placer.remeasure()
|
|
983
|
-
layer.clock.invalidate()
|
|
984
|
-
}, [entries, layer, unit])
|
|
985
|
-
useEffect(() => {
|
|
986
|
-
layer.placer.showAll(shown)
|
|
987
|
-
if (shown) layer.clock.invalidate()
|
|
988
|
-
else layer.reveal.clear()
|
|
989
|
-
}, [layer, shown])
|
|
990
|
-
useEffect(() => {
|
|
991
|
-
if (!host) return
|
|
992
|
-
const { reveal } = layer
|
|
993
|
-
const labelAt = (target: EventTarget | null) => {
|
|
994
|
-
const label =
|
|
995
|
-
target instanceof Element ? target.closest<HTMLElement>('[data-room-label]') : null
|
|
996
|
-
if (!(label && layer.container()?.contains(label))) return null
|
|
997
|
-
return label.dataset.roomLabel ?? null
|
|
998
|
-
}
|
|
999
|
-
const move = (event: PointerEvent) => {
|
|
1000
|
-
const label = labelAt(event.target)
|
|
1001
|
-
const over =
|
|
1002
|
-
event.buttons === 0 &&
|
|
1003
|
-
event.pointerType !== 'touch' &&
|
|
1004
|
-
(label !== null ||
|
|
1005
|
-
(event.target instanceof Node && !!layer.surface()?.contains(event.target)))
|
|
1006
|
-
layer.pointer = over ? { x: event.clientX, y: event.clientY } : null
|
|
1007
|
-
layer.pointerMoved = true
|
|
1008
|
-
if (shownRef.current && layer.clock.atRest) reveal.hover(over ? label : null)
|
|
1009
|
-
}
|
|
1010
|
-
const leave = () => {
|
|
1011
|
-
layer.pointer = null
|
|
1012
|
-
reveal.hover(null)
|
|
1013
|
-
reveal.pick(null)
|
|
1014
|
-
}
|
|
1015
|
-
const focusIn = (event: FocusEvent) => {
|
|
1016
|
-
const id = labelAt(event.target)
|
|
1017
|
-
if (id && shownRef.current && (event.target as Element).matches(':focus-visible'))
|
|
1018
|
-
reveal.focus(id)
|
|
1019
|
-
}
|
|
1020
|
-
const focusOut = () => reveal.focus(null)
|
|
1021
|
-
// The canvas and the plan pan while Space is held; on a pill, Space presses it.
|
|
1022
|
-
const keepSpace = (event: KeyboardEvent) => {
|
|
1023
|
-
if (event.code === 'Space' && labelAt(event.target)) event.stopPropagation()
|
|
1024
|
-
}
|
|
1025
|
-
const dismiss = (event: KeyboardEvent) => {
|
|
1026
|
-
if (event.key === 'Escape') reveal.dismiss()
|
|
1027
|
-
}
|
|
1028
|
-
// Captured: a plan or canvas handler stopping the move must not blind the layer.
|
|
1029
|
-
host.addEventListener('pointermove', move, { capture: true, passive: true })
|
|
1030
|
-
// A drag ending with the pointer held still picks the room under it once the view rests.
|
|
1031
|
-
host.addEventListener('pointerup', move, { capture: true, passive: true })
|
|
1032
|
-
host.addEventListener('pointerleave', leave)
|
|
1033
|
-
host.addEventListener('focusin', focusIn)
|
|
1034
|
-
host.addEventListener('focusout', focusOut)
|
|
1035
|
-
host.addEventListener('keydown', keepSpace)
|
|
1036
|
-
host.addEventListener('keyup', keepSpace)
|
|
1037
|
-
document.addEventListener('keydown', dismiss)
|
|
1038
|
-
return () => {
|
|
1039
|
-
host.removeEventListener('pointermove', move, { capture: true })
|
|
1040
|
-
host.removeEventListener('pointerup', move, { capture: true })
|
|
1041
|
-
host.removeEventListener('pointerleave', leave)
|
|
1042
|
-
host.removeEventListener('focusin', focusIn)
|
|
1043
|
-
host.removeEventListener('focusout', focusOut)
|
|
1044
|
-
host.removeEventListener('keydown', keepSpace)
|
|
1045
|
-
host.removeEventListener('keyup', keepSpace)
|
|
1046
|
-
document.removeEventListener('keydown', dismiss)
|
|
1047
|
-
}
|
|
1048
|
-
}, [host, layer])
|
|
1049
|
-
return { layer, gone }
|
|
1050
|
-
}
|
|
1051
|
-
|
|
1052
|
-
/**
|
|
1053
|
-
* The pills of one level: every detected room, and every drawn zone that
|
|
1054
|
-
* bounds none. A smaller one inside a larger one (a zone drawn in a room) is
|
|
1055
|
-
* a hole in the larger: the label there, and the room under the pointer
|
|
1056
|
-
* there, are the smaller one's.
|
|
1057
|
-
*/
|
|
1058
|
-
export function roomLabelEntries(
|
|
1059
|
-
rooms: readonly RoomSelectionRecord[],
|
|
1060
|
-
zones: readonly ZoneNode[],
|
|
1061
|
-
): RoomLabelEntry[] {
|
|
1062
|
-
const hiddenIds = new Set<string>(
|
|
1063
|
-
zones.filter((zone) => zone.visible === false).map((zone) => zone.id),
|
|
1064
|
-
)
|
|
1065
|
-
const roomIds = new Set(rooms.map((room) => room.zoneId))
|
|
1066
|
-
const entries: RoomLabelEntry[] = [
|
|
1067
|
-
...rooms
|
|
1068
|
-
.filter((room) => !hiddenIds.has(room.zoneId))
|
|
1069
|
-
.map((room) => ({
|
|
1070
|
-
zoneId: room.zoneId,
|
|
1071
|
-
polygon: room.polygon,
|
|
1072
|
-
holes: room.holes,
|
|
1073
|
-
area: room.area,
|
|
1074
|
-
})),
|
|
1075
|
-
...zones
|
|
1076
|
-
.filter((zone) => zone.visible !== false && !roomIds.has(zone.id) && zone.polygon.length >= 3)
|
|
1077
|
-
.map((zone) => ({
|
|
1078
|
-
zoneId: zone.id,
|
|
1079
|
-
polygon: zone.polygon,
|
|
1080
|
-
holes: zone.holes ?? [],
|
|
1081
|
-
area: area([{ outer: zone.polygon, holes: zone.holes ?? [] }]),
|
|
1082
|
-
})),
|
|
1083
|
-
]
|
|
1084
|
-
const insides = entries.map((entry) =>
|
|
1085
|
-
polygonInteriorPoint({ polygon: entry.polygon, holes: entry.holes }),
|
|
1086
|
-
)
|
|
1087
|
-
return entries.map((entry) => {
|
|
1088
|
-
const outline = [{ outer: entry.polygon, holes: entry.holes }]
|
|
1089
|
-
const nested = entries.filter(
|
|
1090
|
-
(other, i) => other.area < entry.area && containsPoint(outline, insides[i]!),
|
|
1091
|
-
)
|
|
1092
|
-
return nested.length === 0
|
|
1093
|
-
? entry
|
|
1094
|
-
: { ...entry, holes: [...entry.holes, ...nested.map((other) => other.polygon)] }
|
|
1095
|
-
})
|
|
1096
|
-
}
|
|
1097
|
-
|
|
1098
|
-
function useRoomLabelEntries(levelId: string | null): LabelEntry[] {
|
|
120
|
+
function useRoomLabelEntries(levelId: string | null): RoomLabelEntry[] {
|
|
1099
121
|
const rooms = useRoomRecords(levelId)
|
|
1100
122
|
const zones = useScene(
|
|
1101
123
|
useShallow((s) =>
|
|
@@ -1109,155 +131,11 @@ function useRoomLabelEntries(levelId: string | null): LabelEntry[] {
|
|
|
1109
131
|
return useMemo(() => roomLabelEntries(rooms, zones), [rooms, zones])
|
|
1110
132
|
}
|
|
1111
133
|
|
|
1112
|
-
/** A label's colour dot, name and area: a pill hides what its tier leaves out, the detail shows all. */
|
|
1113
|
-
function LabelParts({
|
|
1114
|
-
color,
|
|
1115
|
-
name,
|
|
1116
|
-
areaLabel,
|
|
1117
|
-
full,
|
|
1118
|
-
}: {
|
|
1119
|
-
color: string
|
|
1120
|
-
name: string
|
|
1121
|
-
areaLabel: string
|
|
1122
|
-
full: boolean
|
|
1123
|
-
}) {
|
|
1124
|
-
return (
|
|
1125
|
-
<>
|
|
1126
|
-
<span
|
|
1127
|
-
className="h-2 w-2 shrink-0 rounded-full"
|
|
1128
|
-
data-room-label-dot=""
|
|
1129
|
-
style={{ backgroundColor: color }}
|
|
1130
|
-
/>
|
|
1131
|
-
<span
|
|
1132
|
-
className={
|
|
1133
|
-
full
|
|
1134
|
-
? 'min-w-0 font-medium [overflow-wrap:anywhere]'
|
|
1135
|
-
: 'max-w-40 truncate font-medium [[data-room-label-tier=dot]>&]:hidden'
|
|
1136
|
-
}
|
|
1137
|
-
>
|
|
1138
|
-
{name}
|
|
1139
|
-
</span>
|
|
1140
|
-
<span
|
|
1141
|
-
className="shrink-0 whitespace-nowrap font-mono text-white/60 tabular-nums [[data-room-label-tier]>&]:hidden"
|
|
1142
|
-
data-room-label-area=""
|
|
1143
|
-
>
|
|
1144
|
-
{areaLabel}
|
|
1145
|
-
</span>
|
|
1146
|
-
</>
|
|
1147
|
-
)
|
|
1148
|
-
}
|
|
1149
|
-
|
|
1150
|
-
/**
|
|
1151
|
-
* The pill itself, a button: a colour dot, the name (cut at 160 px) and the
|
|
1152
|
-
* area in the active unit, as much of it as its placement shows.
|
|
1153
|
-
*/
|
|
1154
|
-
export function RoomPillChip({
|
|
1155
|
-
zoneId,
|
|
1156
|
-
view,
|
|
1157
|
-
color,
|
|
1158
|
-
name,
|
|
1159
|
-
areaM2,
|
|
1160
|
-
elements,
|
|
1161
|
-
onClick,
|
|
1162
|
-
onHover,
|
|
1163
|
-
onLeave,
|
|
1164
|
-
}: {
|
|
1165
|
-
zoneId: string
|
|
1166
|
-
view: '2d' | '3d'
|
|
1167
|
-
color: string
|
|
1168
|
-
name: string
|
|
1169
|
-
areaM2: number
|
|
1170
|
-
elements: Map<string, HTMLElement>
|
|
1171
|
-
onClick: (event: ReactMouseEvent) => void
|
|
1172
|
-
onHover: (event: ReactPointerEvent) => void
|
|
1173
|
-
onLeave: () => void
|
|
1174
|
-
}) {
|
|
1175
|
-
const unit = useViewer((s) => s.unit)
|
|
1176
|
-
const locale = useLocale()
|
|
1177
|
-
const areaLabel = formatRoomArea(areaM2, unit, locale)
|
|
1178
|
-
// biome-ignore lint/correctness/useExhaustiveDependencies: A dot's border box does not resize when its hidden text changes.
|
|
1179
|
-
useLayoutEffect(() => {
|
|
1180
|
-
if (elements instanceof PillElements) elements.measure(zoneId)
|
|
1181
|
-
}, [areaM2, elements, locale, name, unit, zoneId])
|
|
1182
|
-
const register = useCallback(
|
|
1183
|
-
(element: HTMLElement | null) => {
|
|
1184
|
-
if (element) elements.set(zoneId, element)
|
|
1185
|
-
else elements.delete(zoneId)
|
|
1186
|
-
},
|
|
1187
|
-
[elements, zoneId],
|
|
1188
|
-
)
|
|
1189
|
-
return (
|
|
1190
|
-
<button
|
|
1191
|
-
aria-label={`${name}, ${areaLabel}`}
|
|
1192
|
-
// Fades in once placed, and out while hidden (then out of the tab order)
|
|
1193
|
-
// or under the detail chip (taking no pointer unless it is the open
|
|
1194
|
-
// room's); eases into its nudged place and its tier.
|
|
1195
|
-
className="pointer-events-auto flex cursor-pointer select-none items-center gap-1.5 whitespace-nowrap rounded-full border border-white/10 bg-neutral-950/75 px-2.5 py-1 text-white text-xs shadow-sm backdrop-blur-sm transition-[opacity,translate,visibility] duration-180 starting:opacity-0 focus-visible:outline-2 focus-visible:outline-white focus-visible:outline-offset-2 focus-visible:ring-6 focus-visible:ring-neutral-950/85 data-room-label-hidden:pointer-events-none data-room-label-suppressed:pointer-events-none data-room-label-hidden:invisible data-room-label-hidden:opacity-0 data-room-label-open:opacity-0 data-room-label-suppressed:opacity-0 data-room-label-open:duration-150 data-room-label-suppressed:duration-150 data-[room-label-tier=dot]:gap-0 data-[room-label-tier=dot]:p-[3px] motion-reduce:transition-none"
|
|
1196
|
-
data-room-label={zoneId}
|
|
1197
|
-
data-room-label-view={view}
|
|
1198
|
-
onClick={(event) => {
|
|
1199
|
-
event.stopPropagation()
|
|
1200
|
-
onClick(event)
|
|
1201
|
-
}}
|
|
1202
|
-
onPointerDown={(event) => event.stopPropagation()}
|
|
1203
|
-
// Move as well as enter: leaving the canvas for the pill ends the canvas
|
|
1204
|
-
// hover after the pill's enter has run.
|
|
1205
|
-
onPointerEnter={onHover}
|
|
1206
|
-
onPointerLeave={onLeave}
|
|
1207
|
-
onPointerMove={onHover}
|
|
1208
|
-
ref={register}
|
|
1209
|
-
type="button"
|
|
1210
|
-
>
|
|
1211
|
-
<LabelParts areaLabel={areaLabel} color={color} full={false} name={name} />
|
|
1212
|
-
</button>
|
|
1213
|
-
)
|
|
1214
|
-
}
|
|
1215
|
-
|
|
1216
|
-
/**
|
|
1217
|
-
* The detail chip: the open room's whole label, wrapping to fit its layer.
|
|
1218
|
-
* It only reads: the pill under it is the control, and the pointer goes
|
|
1219
|
-
* through it to the rooms it covers.
|
|
1220
|
-
*/
|
|
1221
|
-
export function RoomPillDetail({
|
|
1222
|
-
zoneId,
|
|
1223
|
-
view,
|
|
1224
|
-
color,
|
|
1225
|
-
name,
|
|
1226
|
-
areaM2,
|
|
1227
|
-
}: {
|
|
1228
|
-
zoneId: string
|
|
1229
|
-
view: '2d' | '3d'
|
|
1230
|
-
color: string
|
|
1231
|
-
name: string
|
|
1232
|
-
areaM2: number
|
|
1233
|
-
}) {
|
|
1234
|
-
const unit = useViewer((s) => s.unit)
|
|
1235
|
-
const locale = useLocale()
|
|
1236
|
-
return (
|
|
1237
|
-
<div
|
|
1238
|
-
aria-hidden
|
|
1239
|
-
className="flex select-none items-center gap-1.5 rounded-[13px] border border-white/10 bg-neutral-950/90 px-2.5 py-1 text-white text-xs shadow-elevation-3 backdrop-blur-sm"
|
|
1240
|
-
data-room-label-detail={zoneId}
|
|
1241
|
-
data-room-label-view={view}
|
|
1242
|
-
>
|
|
1243
|
-
<LabelParts areaLabel={formatRoomArea(areaM2, unit, locale)} color={color} full name={name} />
|
|
1244
|
-
</div>
|
|
1245
|
-
)
|
|
1246
|
-
}
|
|
1247
|
-
|
|
1248
134
|
function useLabelZone(zoneId: string) {
|
|
1249
135
|
const zone = useScene((s) => s.nodes[zoneId as AnyNodeId] as ZoneNode | undefined)
|
|
1250
136
|
return zone && zone.visible !== false ? zone : null
|
|
1251
137
|
}
|
|
1252
138
|
|
|
1253
|
-
const labelName = (zone: ZoneNode, i18n: PascalI18n) => {
|
|
1254
|
-
const name = zone.name?.trim()
|
|
1255
|
-
if (name) return displayRoomName(name, i18n)
|
|
1256
|
-
return zone.spaceRole === 'room'
|
|
1257
|
-
? i18n._({ id: 'editor.roomLabel.room', message: 'Room' })
|
|
1258
|
-
: i18n._({ id: 'editor.roomLabel.zone', message: 'Zone' })
|
|
1259
|
-
}
|
|
1260
|
-
|
|
1261
139
|
/**
|
|
1262
140
|
* One room's pill in the editor. Hover and click take the same rule as the
|
|
1263
141
|
* zone's area in the plan (`hoverZoneArea`, `clickZoneArea`); only the pill
|
|
@@ -1268,7 +146,7 @@ function RoomPill({
|
|
|
1268
146
|
view,
|
|
1269
147
|
elements,
|
|
1270
148
|
}: {
|
|
1271
|
-
entry:
|
|
149
|
+
entry: RoomLabelEntry
|
|
1272
150
|
view: '2d' | '3d'
|
|
1273
151
|
elements: Map<string, HTMLElement>
|
|
1274
152
|
}) {
|
|
@@ -1283,7 +161,7 @@ function RoomPill({
|
|
|
1283
161
|
areaM2={entry.area}
|
|
1284
162
|
color={zone.color}
|
|
1285
163
|
elements={elements}
|
|
1286
|
-
name={
|
|
164
|
+
name={zoneLabel(zone, i18n)}
|
|
1287
165
|
onClick={(event) => clickZoneArea(entry.zoneId, selectionModifiersFromEvent(event))}
|
|
1288
166
|
onHover={(event) => hoverZoneArea(entry.zoneId, selectionModifiersFromEvent(event))}
|
|
1289
167
|
onLeave={() => leaveZoneArea(entry.zoneId)}
|
|
@@ -1293,7 +171,7 @@ function RoomPill({
|
|
|
1293
171
|
)
|
|
1294
172
|
}
|
|
1295
173
|
|
|
1296
|
-
function RoomDetail({ entry, view }: { entry:
|
|
174
|
+
function RoomDetail({ entry, view }: { entry: RoomLabelEntry; view: '2d' | '3d' }) {
|
|
1297
175
|
const i18n = usePascalI18n()
|
|
1298
176
|
const zone = useLabelZone(entry.zoneId)
|
|
1299
177
|
if (!zone) return null
|
|
@@ -1301,95 +179,15 @@ function RoomDetail({ entry, view }: { entry: LabelEntry; view: '2d' | '3d' }) {
|
|
|
1301
179
|
<RoomPillDetail
|
|
1302
180
|
areaM2={entry.area}
|
|
1303
181
|
color={zone.color}
|
|
1304
|
-
name={
|
|
182
|
+
name={zoneLabel(zone, i18n)}
|
|
1305
183
|
view={view}
|
|
1306
184
|
zoneId={entry.zoneId}
|
|
1307
185
|
/>
|
|
1308
186
|
)
|
|
1309
187
|
}
|
|
1310
188
|
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
* the pill's, or centred where a hidden room's label would sit, and kept 8 px
|
|
1314
|
-
* inside the layer. The pills it covers fade out of its way; nothing moves.
|
|
1315
|
-
* One chip serves every room, so a handoff moves it without fading it again.
|
|
1316
|
-
*/
|
|
1317
|
-
function RoomLabelDetail({
|
|
1318
|
-
detail,
|
|
1319
|
-
onPlaced,
|
|
1320
|
-
children,
|
|
1321
|
-
}: {
|
|
1322
|
-
detail: RoomLabelDetailState
|
|
1323
|
-
onPlaced: (rect: LabelRect | null) => void
|
|
1324
|
-
children: ReactNode
|
|
1325
|
-
}) {
|
|
1326
|
-
const ref = useRef<HTMLDivElement>(null)
|
|
1327
|
-
const { open, at } = detail
|
|
1328
|
-
useLayoutEffect(() => {
|
|
1329
|
-
const frame = ref.current
|
|
1330
|
-
if (!(frame && open)) {
|
|
1331
|
-
onPlaced(null)
|
|
1332
|
-
return
|
|
1333
|
-
}
|
|
1334
|
-
const place = () => {
|
|
1335
|
-
const width = frame.offsetWidth
|
|
1336
|
-
const height = frame.offsetHeight
|
|
1337
|
-
const dot = frame.querySelector<HTMLElement>('[data-room-label-dot]')
|
|
1338
|
-
const x = at.onDot && dot ? at.x - dot.offsetLeft - dot.offsetWidth / 2 : at.x - width / 2
|
|
1339
|
-
const left = Math.max(DETAIL_INSET, Math.min(x, at.bounds.width - DETAIL_INSET - width))
|
|
1340
|
-
const top = Math.max(
|
|
1341
|
-
DETAIL_INSET,
|
|
1342
|
-
Math.min(at.y - height / 2, at.bounds.height - DETAIL_INSET - height),
|
|
1343
|
-
)
|
|
1344
|
-
frame.style.left = `${left}px`
|
|
1345
|
-
frame.style.top = `${top}px`
|
|
1346
|
-
onPlaced({ left, top, right: left + width, bottom: top + height })
|
|
1347
|
-
}
|
|
1348
|
-
place()
|
|
1349
|
-
// Placed again when its text reflows: a rename, a unit change, a font arriving.
|
|
1350
|
-
const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(place)
|
|
1351
|
-
observer?.observe(frame)
|
|
1352
|
-
return () => observer?.disconnect()
|
|
1353
|
-
}, [at, onPlaced, open])
|
|
1354
|
-
// Gone with its room: the pills it covered come back.
|
|
1355
|
-
useLayoutEffect(() => () => onPlaced(null), [onPlaced])
|
|
1356
|
-
return (
|
|
1357
|
-
<div
|
|
1358
|
-
className="pointer-events-none absolute z-30 w-max origin-left rounded-[13px] transition-[opacity,scale] duration-150 ease-[cubic-bezier(0.22,1,0.36,1)] starting:scale-[0.98] starting:opacity-0 data-closed:scale-[0.98] data-closed:opacity-0 data-closed:duration-50 data-focused:outline-2 data-focused:outline-white data-focused:outline-offset-2 data-focused:ring-6 data-focused:ring-neutral-950/85 motion-reduce:transition-none"
|
|
1359
|
-
data-closed={open ? undefined : ''}
|
|
1360
|
-
data-focused={open && detail.focused ? '' : undefined}
|
|
1361
|
-
ref={ref}
|
|
1362
|
-
style={{ maxWidth: at.bounds.width - 2 * DETAIL_INSET }}
|
|
1363
|
-
>
|
|
1364
|
-
{children}
|
|
1365
|
-
</div>
|
|
1366
|
-
)
|
|
1367
|
-
}
|
|
1368
|
-
|
|
1369
|
-
/**
|
|
1370
|
-
* The open room's detail, read from the layer's own store where it renders,
|
|
1371
|
-
* so a reveal re-renders the chip alone, not the canvas tree or every pill.
|
|
1372
|
-
*/
|
|
1373
|
-
function OpenRoomLabelDetail({
|
|
1374
|
-
layer,
|
|
1375
|
-
entries,
|
|
1376
|
-
renderDetail,
|
|
1377
|
-
}: {
|
|
1378
|
-
layer: PillLayer
|
|
1379
|
-
entries: readonly LabelEntry[]
|
|
1380
|
-
renderDetail: (entry: LabelEntry) => ReactNode
|
|
1381
|
-
}) {
|
|
1382
|
-
const detail = useSyncExternalStore(layer.subscribeDetail, layer.getDetail, layer.getDetail)
|
|
1383
|
-
const entry = detail && entries.find((candidate) => candidate.zoneId === detail.id)
|
|
1384
|
-
if (!(detail && entry)) return null
|
|
1385
|
-
return (
|
|
1386
|
-
<RoomLabelDetail detail={detail} onPlaced={layer.placeDetail}>
|
|
1387
|
-
{renderDetail(entry)}
|
|
1388
|
-
</RoomLabelDetail>
|
|
1389
|
-
)
|
|
1390
|
-
}
|
|
1391
|
-
|
|
1392
|
-
const renderDetail2D = (entry: LabelEntry) => <RoomDetail entry={entry} view="2d" />
|
|
189
|
+
const renderDetail2D = (entry: RoomLabelEntry) => <RoomDetail entry={entry} view="2d" />
|
|
190
|
+
const renderDetail3D = (entry: RoomLabelEntry) => <RoomDetail entry={entry} view="3d" />
|
|
1393
191
|
|
|
1394
192
|
/** Light name-and-area pills over each room and drawn zone of the active level. */
|
|
1395
193
|
export function RoomLabels3D() {
|
|
@@ -1418,7 +216,7 @@ function LevelRoomLabels3D({
|
|
|
1418
216
|
<RoomPillsLayer3D
|
|
1419
217
|
entries={entries}
|
|
1420
218
|
levelObject={() => sceneRegistry.nodes.get(levelId)}
|
|
1421
|
-
renderDetail={
|
|
219
|
+
renderDetail={renderDetail3D}
|
|
1422
220
|
renderLabel={(entry, anchors, elements) => (
|
|
1423
221
|
<EditorRoomLabel3D anchors={anchors} elements={elements} entry={entry} />
|
|
1424
222
|
)}
|
|
@@ -1432,7 +230,7 @@ function EditorRoomLabel3D({
|
|
|
1432
230
|
anchors,
|
|
1433
231
|
elements,
|
|
1434
232
|
}: {
|
|
1435
|
-
entry:
|
|
233
|
+
entry: RoomLabelEntry
|
|
1436
234
|
anchors: Map<string, Vector3>
|
|
1437
235
|
elements: Map<string, HTMLElement>
|
|
1438
236
|
}) {
|
|
@@ -1444,290 +242,7 @@ function EditorRoomLabel3D({
|
|
|
1444
242
|
)
|
|
1445
243
|
}
|
|
1446
244
|
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
/** Where a level-local point lands in the canvas, in px, with its depth; null behind the camera. */
|
|
1450
|
-
function screenPoint(x: number, y: number, z: number, size: PaneSize) {
|
|
1451
|
-
const e = toScreen.elements
|
|
1452
|
-
const w = e[3] * x + e[7] * y + e[11] * z + e[15]
|
|
1453
|
-
if (!(w > 0)) return null
|
|
1454
|
-
return [
|
|
1455
|
-
(((e[0] * x + e[4] * y + e[8] * z + e[12]) / w + 1) / 2) * size.width,
|
|
1456
|
-
((1 - (e[1] * x + e[5] * y + e[9] * z + e[13]) / w) / 2) * size.height,
|
|
1457
|
-
(e[2] * x + e[6] * y + e[10] * z + e[14]) / w,
|
|
1458
|
-
] as const
|
|
1459
|
-
}
|
|
1460
|
-
|
|
1461
|
-
// How far in front of the camera an outline is cut off (in clip-space w, the
|
|
1462
|
-
// depth along the view for a perspective camera): past it, points project
|
|
1463
|
-
// ever further out and then flip to the other side of the screen.
|
|
1464
|
-
const NEAR_W = 1e-3
|
|
1465
|
-
|
|
1466
|
-
/**
|
|
1467
|
-
* A level-local ring at height `y` on screen: cut to the part in front of the
|
|
1468
|
-
* camera (Sutherland–Hodgman against w = NEAR_W, which is linear in the
|
|
1469
|
-
* level's coordinates), then projected. Null when none of it is in front.
|
|
1470
|
-
*/
|
|
1471
|
-
function screenRing(points: readonly [number, number][], y: number, size: PaneSize): Ring | null {
|
|
1472
|
-
const e = toScreen.elements
|
|
1473
|
-
const w = ([x, z]: readonly [number, number]) => e[3] * x + e[7] * y + e[11] * z + e[15]
|
|
1474
|
-
const ring: Ring = []
|
|
1475
|
-
const add = (x: number, z: number) => {
|
|
1476
|
-
const point = screenPoint(x, y, z, size)
|
|
1477
|
-
if (point) ring.push([point[0], point[1]])
|
|
1478
|
-
}
|
|
1479
|
-
for (let i = 0, j = points.length - 1; i < points.length; j = i++) {
|
|
1480
|
-
const a = points[j]!
|
|
1481
|
-
const b = points[i]!
|
|
1482
|
-
const wa = w(a)
|
|
1483
|
-
const wb = w(b)
|
|
1484
|
-
if (wa >= NEAR_W !== wb >= NEAR_W) {
|
|
1485
|
-
const t = (wa - NEAR_W) / (wa - wb)
|
|
1486
|
-
add(a[0] + t * (b[0] - a[0]), a[1] + t * (b[1] - a[1]))
|
|
1487
|
-
}
|
|
1488
|
-
if (wb >= NEAR_W) add(b[0], b[1])
|
|
1489
|
-
}
|
|
1490
|
-
return ring.length >= 3 ? ring : null
|
|
1491
|
-
}
|
|
1492
|
-
|
|
1493
|
-
/** Where the pills of a level land on screen, each with the part of its room in front of the camera. */
|
|
1494
|
-
export function candidates3D(
|
|
1495
|
-
entries: readonly LabelEntry[],
|
|
1496
|
-
anchors: ReadonlyMap<string, Vector3>,
|
|
1497
|
-
sizes: ReadonlyMap<string, PillSize>,
|
|
1498
|
-
camera: Camera,
|
|
1499
|
-
level: Object3D,
|
|
1500
|
-
size: PaneSize,
|
|
1501
|
-
): PillCandidate[] {
|
|
1502
|
-
toScreen.multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse)
|
|
1503
|
-
toScreen.multiply(level.matrixWorld)
|
|
1504
|
-
const candidates: PillCandidate[] = []
|
|
1505
|
-
for (const entry of entries) {
|
|
1506
|
-
const anchor = anchors.get(entry.zoneId)
|
|
1507
|
-
const pill = sizes.get(entry.zoneId)
|
|
1508
|
-
if (!(anchor && pill)) continue
|
|
1509
|
-
const at = screenPoint(anchor.x, anchor.y, anchor.z, size)
|
|
1510
|
-
// Behind the camera or past its far plane: not on screen to collide.
|
|
1511
|
-
if (!at || Math.abs(at[2]) > 1) continue
|
|
1512
|
-
// The room's outline at its pill's height, where the pill has to fit.
|
|
1513
|
-
const outer = screenRing(entry.polygon, anchor.y, size)
|
|
1514
|
-
if (!outer) continue
|
|
1515
|
-
const holes: Ring[] = []
|
|
1516
|
-
for (const hole of entry.holes) {
|
|
1517
|
-
const ring = screenRing(hole, anchor.y, size)
|
|
1518
|
-
if (ring) holes.push(ring)
|
|
1519
|
-
}
|
|
1520
|
-
candidates.push({
|
|
1521
|
-
id: entry.zoneId,
|
|
1522
|
-
x: at[0],
|
|
1523
|
-
y: at[1],
|
|
1524
|
-
size: pill,
|
|
1525
|
-
area: entry.area,
|
|
1526
|
-
outline: { outer, holes },
|
|
1527
|
-
})
|
|
1528
|
-
}
|
|
1529
|
-
return candidates
|
|
1530
|
-
}
|
|
1531
|
-
|
|
1532
|
-
const pickRaycaster = new Raycaster()
|
|
1533
|
-
const pickPointer = new Vector2()
|
|
1534
|
-
const levelInverse = new Matrix4()
|
|
1535
|
-
const levelRay = new Ray()
|
|
1536
|
-
|
|
1537
|
-
/** The room whose floor the pointer's ray meets first, testing each room at its own floor. */
|
|
1538
|
-
function pick3D(
|
|
1539
|
-
entries: readonly LabelEntry[],
|
|
1540
|
-
anchors: ReadonlyMap<string, Vector3>,
|
|
1541
|
-
pointer: PointerAt,
|
|
1542
|
-
camera: Camera,
|
|
1543
|
-
level: Object3D,
|
|
1544
|
-
size: PaneSize & { left: number; top: number },
|
|
1545
|
-
): string | null {
|
|
1546
|
-
pickPointer.set(
|
|
1547
|
-
((pointer.x - size.left) / size.width) * 2 - 1,
|
|
1548
|
-
-((pointer.y - size.top) / size.height) * 2 + 1,
|
|
1549
|
-
)
|
|
1550
|
-
pickRaycaster.setFromCamera(pickPointer, camera)
|
|
1551
|
-
levelRay.copy(pickRaycaster.ray).applyMatrix4(levelInverse.copy(level.matrixWorld).invert())
|
|
1552
|
-
const { origin, direction } = levelRay
|
|
1553
|
-
return roomAt(entries, (entry) => {
|
|
1554
|
-
const anchor = anchors.get(entry.zoneId)
|
|
1555
|
-
if (!anchor) return null
|
|
1556
|
-
const t = (anchor.y - LABEL_HEIGHT - origin.y) / direction.y
|
|
1557
|
-
return t > 0 && Number.isFinite(t)
|
|
1558
|
-
? [origin.x + t * direction.x, origin.z + t * direction.z, t]
|
|
1559
|
-
: null
|
|
1560
|
-
})
|
|
1561
|
-
}
|
|
1562
|
-
|
|
1563
|
-
// The detail chip's own Html sits at its layer's top-left corner and places
|
|
1564
|
-
// the chip itself, so drei must never hide it: the object it rides is not
|
|
1565
|
-
// where it shows.
|
|
1566
|
-
const atLayerOrigin = () => [0, 0]
|
|
1567
|
-
const keepShown = () => {}
|
|
1568
|
-
|
|
1569
|
-
/**
|
|
1570
|
-
* The pills of one level in 3D, riding the level's object (so they follow
|
|
1571
|
-
* level stacking) and placed from where their anchors land on screen. They
|
|
1572
|
-
* live in their own stacking layer clipped to the canvas, so every piece of
|
|
1573
|
-
* UI over the canvas stays above them; the open room's chip, the label being
|
|
1574
|
-
* read, has a layer of its own above the canvas's flags.
|
|
1575
|
-
*/
|
|
1576
|
-
export function RoomPillsLayer3D({
|
|
1577
|
-
entries,
|
|
1578
|
-
levelObject,
|
|
1579
|
-
renderLabel,
|
|
1580
|
-
renderDetail,
|
|
1581
|
-
shown = true,
|
|
1582
|
-
}: {
|
|
1583
|
-
entries: readonly LabelEntry[]
|
|
1584
|
-
levelObject: () => Object3D | undefined
|
|
1585
|
-
renderLabel: (
|
|
1586
|
-
entry: LabelEntry,
|
|
1587
|
-
anchors: Map<string, Vector3>,
|
|
1588
|
-
elements: Map<string, HTMLElement>,
|
|
1589
|
-
) => ReactNode
|
|
1590
|
-
renderDetail: (entry: LabelEntry) => ReactNode
|
|
1591
|
-
shown?: boolean
|
|
1592
|
-
}) {
|
|
1593
|
-
const ref = useRef<Group>(null)
|
|
1594
|
-
const [anchors] = useState(() => new RoomAnchors())
|
|
1595
|
-
const view = useRef(new Float64Array(50))
|
|
1596
|
-
const host = useThree((s) => (s.events.connected ?? s.gl.domElement.parentNode) as HTMLElement)
|
|
1597
|
-
const [portals] = useState(() =>
|
|
1598
|
-
typeof document === 'undefined'
|
|
1599
|
-
? null
|
|
1600
|
-
: {
|
|
1601
|
-
pills: { current: document.createElement('div') },
|
|
1602
|
-
detail: { current: document.createElement('div') },
|
|
1603
|
-
},
|
|
1604
|
-
)
|
|
1605
|
-
const canvas = useThree((s) => s.gl.domElement)
|
|
1606
|
-
const { layer, gone } = usePillLayer(
|
|
1607
|
-
shown,
|
|
1608
|
-
entries,
|
|
1609
|
-
host,
|
|
1610
|
-
() => portals?.pills.current ?? null,
|
|
1611
|
-
() => canvas,
|
|
1612
|
-
)
|
|
1613
|
-
useEffect(() => {
|
|
1614
|
-
if (!(host && portals)) return
|
|
1615
|
-
const { pills, detail } = portals
|
|
1616
|
-
pills.current.className = 'pointer-events-none absolute inset-0 isolate z-0 overflow-hidden'
|
|
1617
|
-
// Over the flags and guides on the canvas (z-index 25 at most), unlike the pills.
|
|
1618
|
-
detail.current.className = 'pointer-events-none absolute inset-0 z-30'
|
|
1619
|
-
host.append(pills.current, detail.current)
|
|
1620
|
-
return () => {
|
|
1621
|
-
pills.current.remove()
|
|
1622
|
-
detail.current.remove()
|
|
1623
|
-
}
|
|
1624
|
-
}, [host, portals])
|
|
1625
|
-
|
|
1626
|
-
useEffect(() => {
|
|
1627
|
-
anchors.onMove = () => layer.clock.invalidate()
|
|
1628
|
-
return () => {
|
|
1629
|
-
anchors.onMove = null
|
|
1630
|
-
}
|
|
1631
|
-
}, [anchors, layer])
|
|
1632
|
-
|
|
1633
|
-
useEffect(() => {
|
|
1634
|
-
const ids = new Set(entries.map((entry) => entry.zoneId))
|
|
1635
|
-
for (const id of anchors.keys()) if (!ids.has(id)) anchors.delete(id)
|
|
1636
|
-
layer.clock.invalidate()
|
|
1637
|
-
}, [anchors, entries, layer])
|
|
1638
|
-
|
|
1639
|
-
useFrame(({ camera, clock: frameClock, size }) => {
|
|
1640
|
-
if (!ref.current) return
|
|
1641
|
-
const level = levelObject()
|
|
1642
|
-
ref.current.visible = !!level
|
|
1643
|
-
if (!level) return
|
|
1644
|
-
level.updateWorldMatrix(true, false)
|
|
1645
|
-
ref.current.matrix.copy(level.matrixWorld)
|
|
1646
|
-
if (!shown) return
|
|
1647
|
-
|
|
1648
|
-
const flat = view.current
|
|
1649
|
-
flat.set(camera.matrixWorld.elements, 0)
|
|
1650
|
-
flat.set(camera.projectionMatrix.elements, 16)
|
|
1651
|
-
flat.set(level.matrixWorld.elements, 32)
|
|
1652
|
-
flat[48] = size.width
|
|
1653
|
-
flat[49] = size.height
|
|
1654
|
-
const placed = layer.clock.shouldRead(flat, frameClock.elapsedTime)
|
|
1655
|
-
if (placed) {
|
|
1656
|
-
timeSpan('room-labels', () =>
|
|
1657
|
-
layer.place(candidates3D(entries, anchors, layer.sizes, camera, level, size), size),
|
|
1658
|
-
)
|
|
1659
|
-
}
|
|
1660
|
-
layer.settle(placed, (pointer) => pick3D(entries, anchors, pointer, camera, level, size))
|
|
1661
|
-
})
|
|
1662
|
-
|
|
1663
|
-
if (entries.length === 0 || !portals) return null
|
|
1664
|
-
return (
|
|
1665
|
-
<PillPortalContext.Provider value={portals.pills}>
|
|
1666
|
-
<HiddenPillsContext.Provider value={gone}>
|
|
1667
|
-
<group matrixAutoUpdate={false} ref={ref}>
|
|
1668
|
-
{entries.map((entry) => (
|
|
1669
|
-
<Fragment key={entry.zoneId}>{renderLabel(entry, anchors, layer.elements)}</Fragment>
|
|
1670
|
-
))}
|
|
1671
|
-
<Html
|
|
1672
|
-
calculatePosition={atLayerOrigin}
|
|
1673
|
-
onOcclude={keepShown}
|
|
1674
|
-
portal={portals.detail}
|
|
1675
|
-
style={{ pointerEvents: 'none' }}
|
|
1676
|
-
>
|
|
1677
|
-
<OpenRoomLabelDetail entries={entries} layer={layer} renderDetail={renderDetail} />
|
|
1678
|
-
</Html>
|
|
1679
|
-
</group>
|
|
1680
|
-
</HiddenPillsContext.Provider>
|
|
1681
|
-
</PillPortalContext.Provider>
|
|
1682
|
-
)
|
|
1683
|
-
}
|
|
1684
|
-
|
|
1685
|
-
const PillPortalContext = createContext<{ current: HTMLElement } | null>(null)
|
|
1686
|
-
const HiddenPillsContext = createContext<ReadonlySet<string>>(new Set())
|
|
1687
|
-
|
|
1688
|
-
/** Where a room's pill floats: over its interior, at mid-storey above its floor. */
|
|
1689
|
-
export function RoomLabelAnchor3D({
|
|
1690
|
-
entry,
|
|
1691
|
-
floorY,
|
|
1692
|
-
anchors,
|
|
1693
|
-
children,
|
|
1694
|
-
}: {
|
|
1695
|
-
entry: LabelEntry
|
|
1696
|
-
floorY: number
|
|
1697
|
-
anchors: Map<string, Vector3>
|
|
1698
|
-
children: ReactNode
|
|
1699
|
-
}) {
|
|
1700
|
-
const portal = useContext(PillPortalContext)
|
|
1701
|
-
const gone = useContext(HiddenPillsContext)
|
|
1702
|
-
const position = useMemo(() => {
|
|
1703
|
-
const [x, z] = polygonInteriorPoint({ polygon: entry.polygon, holes: entry.holes })
|
|
1704
|
-
return new Vector3(x, floorY + LABEL_HEIGHT, z)
|
|
1705
|
-
}, [entry.polygon, entry.holes, floorY])
|
|
1706
|
-
// Hidden HTML still needs a live anchor so moving a room can give its pill
|
|
1707
|
-
// room again. The layer drops anchors of rooms that left.
|
|
1708
|
-
useEffect(() => {
|
|
1709
|
-
anchors.set(entry.zoneId, position)
|
|
1710
|
-
}, [anchors, entry.zoneId, position])
|
|
1711
|
-
if (gone.has(entry.zoneId)) return null
|
|
1712
|
-
return (
|
|
1713
|
-
<Html
|
|
1714
|
-
center
|
|
1715
|
-
portal={portal ?? undefined}
|
|
1716
|
-
position={position}
|
|
1717
|
-
// The wrapper lets the canvas keep everything but the pill itself.
|
|
1718
|
-
style={{ pointerEvents: 'none' }}
|
|
1719
|
-
zIndexRange={[20, 0]}
|
|
1720
|
-
>
|
|
1721
|
-
{children}
|
|
1722
|
-
</Html>
|
|
1723
|
-
)
|
|
1724
|
-
}
|
|
1725
|
-
|
|
1726
|
-
/**
|
|
1727
|
-
* The same pills over the 2D plan: screen-positioned from the plan's transform,
|
|
1728
|
-
* re-placed as in 3D once the plan rests, and clipped to the plan's pane in the
|
|
1729
|
-
* plan's own stacking layer, under the UI over it.
|
|
1730
|
-
*/
|
|
245
|
+
/** The same pills over the 2D plan, for the plan's active level. */
|
|
1731
246
|
export function RoomLabels2D({ levelId }: { levelId: string | null }) {
|
|
1732
247
|
const planShown = useEditor((s) => isViewVisible(s, VIEW_2D))
|
|
1733
248
|
const visible = useRoomLabelsVisible() && planShown
|
|
@@ -1748,157 +263,13 @@ function LevelRoomLabels2D({
|
|
|
1748
263
|
}) {
|
|
1749
264
|
const present = useFadePresence(shown, () => onGone(levelId))
|
|
1750
265
|
const entries = useRoomLabelEntries(present ? levelId : null)
|
|
1751
|
-
|
|
1752
|
-
const paneRef = useRef<HTMLDivElement>(null)
|
|
1753
|
-
const [host, setHost] = useState<HTMLElement | null>(null)
|
|
1754
|
-
const [nodes] = useState(() => new PillElements())
|
|
1755
|
-
const { layer, gone } = usePillLayer(
|
|
1756
|
-
shown,
|
|
1757
|
-
entries,
|
|
1758
|
-
host,
|
|
1759
|
-
() => paneRef.current,
|
|
1760
|
-
() => ref.current?.ownerSVGElement,
|
|
1761
|
-
)
|
|
1762
|
-
const shownRef = useRef(shown)
|
|
1763
|
-
shownRef.current = shown
|
|
1764
|
-
const anchors = useMemo(
|
|
1765
|
-
() =>
|
|
1766
|
-
new Map(
|
|
1767
|
-
entries.map((entry) => [
|
|
1768
|
-
entry.zoneId,
|
|
1769
|
-
polygonInteriorPoint({ polygon: entry.polygon, holes: entry.holes }),
|
|
1770
|
-
]),
|
|
1771
|
-
),
|
|
1772
|
-
[entries],
|
|
1773
|
-
)
|
|
1774
|
-
|
|
1775
|
-
useEffect(() => {
|
|
1776
|
-
setHost(ref.current?.ownerSVGElement?.parentElement ?? null)
|
|
1777
|
-
}, [])
|
|
1778
|
-
|
|
1779
|
-
useEffect(() => {
|
|
1780
|
-
if (!present || entries.length === 0) return
|
|
1781
|
-
let raf = 0
|
|
1782
|
-
let lastKey = ''
|
|
1783
|
-
const view = new Float64Array(10)
|
|
1784
|
-
// A pill wrapper that mounts is placed on the next frame.
|
|
1785
|
-
nodes.onMount = () => {
|
|
1786
|
-
lastKey = ''
|
|
1787
|
-
}
|
|
1788
|
-
layer.clock.invalidate()
|
|
1789
|
-
const tick = (now: number) => {
|
|
1790
|
-
raf = requestAnimationFrame(tick)
|
|
1791
|
-
const group = ref.current
|
|
1792
|
-
const svg = group?.ownerSVGElement
|
|
1793
|
-
const ctm = group?.getScreenCTM()
|
|
1794
|
-
const paneElement = paneRef.current
|
|
1795
|
-
if (!(svg && ctm && paneElement)) return
|
|
1796
|
-
const pane = svg.getBoundingClientRect()
|
|
1797
|
-
const origin = paneElement.offsetParent?.getBoundingClientRect() ?? { left: 0, top: 0 }
|
|
1798
|
-
const key = `${ctm.a},${ctm.b},${ctm.c},${ctm.d},${ctm.e},${ctm.f},${pane.left},${pane.top},${pane.width},${pane.height},${origin.left},${origin.top}`
|
|
1799
|
-
// A plan point in the pane's px.
|
|
1800
|
-
const place = ([x, y]: readonly [number, number]): [number, number] => [
|
|
1801
|
-
ctm.a * x + ctm.c * y + ctm.e - pane.left,
|
|
1802
|
-
ctm.b * x + ctm.d * y + ctm.f - pane.top,
|
|
1803
|
-
]
|
|
1804
|
-
const inside = ([x, y]: readonly [number, number]) =>
|
|
1805
|
-
x >= 0 && x <= pane.width && y >= 0 && y <= pane.height
|
|
1806
|
-
if (key !== lastKey) {
|
|
1807
|
-
lastKey = key
|
|
1808
|
-
Object.assign(paneElement.style, {
|
|
1809
|
-
left: `${pane.left - origin.left}px`,
|
|
1810
|
-
top: `${pane.top - origin.top}px`,
|
|
1811
|
-
width: `${pane.width}px`,
|
|
1812
|
-
height: `${pane.height}px`,
|
|
1813
|
-
})
|
|
1814
|
-
for (const [id, element] of nodes) {
|
|
1815
|
-
const anchor = anchors.get(id)
|
|
1816
|
-
if (!anchor) continue
|
|
1817
|
-
const at = place(anchor)
|
|
1818
|
-
element.style.left = `${at[0]}px`
|
|
1819
|
-
element.style.top = `${at[1]}px`
|
|
1820
|
-
element.style.display = inside(at) ? '' : 'none'
|
|
1821
|
-
}
|
|
1822
|
-
}
|
|
1823
|
-
view[0] = ctm.a
|
|
1824
|
-
view[1] = ctm.b
|
|
1825
|
-
view[2] = ctm.c
|
|
1826
|
-
view[3] = ctm.d
|
|
1827
|
-
view[4] = ctm.e
|
|
1828
|
-
view[5] = ctm.f
|
|
1829
|
-
view[6] = pane.left
|
|
1830
|
-
view[7] = pane.top
|
|
1831
|
-
view[8] = pane.width
|
|
1832
|
-
view[9] = pane.height
|
|
1833
|
-
if (!shownRef.current) return
|
|
1834
|
-
const placed = layer.clock.shouldRead(view, now / 1000)
|
|
1835
|
-
if (placed) {
|
|
1836
|
-
timeSpan('room-labels', () => {
|
|
1837
|
-
const candidates: PillCandidate[] = []
|
|
1838
|
-
for (const entry of entries) {
|
|
1839
|
-
const anchor = anchors.get(entry.zoneId)
|
|
1840
|
-
const pill = layer.sizes.get(entry.zoneId)
|
|
1841
|
-
if (!(anchor && pill)) continue
|
|
1842
|
-
const at = place(anchor)
|
|
1843
|
-
if (!inside(at)) continue
|
|
1844
|
-
candidates.push({
|
|
1845
|
-
id: entry.zoneId,
|
|
1846
|
-
x: at[0],
|
|
1847
|
-
y: at[1],
|
|
1848
|
-
size: pill,
|
|
1849
|
-
area: entry.area,
|
|
1850
|
-
outline: {
|
|
1851
|
-
outer: entry.polygon.map(place),
|
|
1852
|
-
holes: entry.holes.map((hole) => hole.map(place)),
|
|
1853
|
-
},
|
|
1854
|
-
})
|
|
1855
|
-
}
|
|
1856
|
-
layer.place(candidates, pane)
|
|
1857
|
-
})
|
|
1858
|
-
}
|
|
1859
|
-
layer.settle(placed, (pointer) => {
|
|
1860
|
-
const inverse = ctm.inverse()
|
|
1861
|
-
// The plan is one plane, met at the same distance for every room: the smallest wins.
|
|
1862
|
-
const point: RoomHit = [
|
|
1863
|
-
inverse.a * pointer.x + inverse.c * pointer.y + inverse.e,
|
|
1864
|
-
inverse.b * pointer.x + inverse.d * pointer.y + inverse.f,
|
|
1865
|
-
0,
|
|
1866
|
-
]
|
|
1867
|
-
return roomAt(entries, () => point)
|
|
1868
|
-
})
|
|
1869
|
-
}
|
|
1870
|
-
raf = requestAnimationFrame(tick)
|
|
1871
|
-
return () => {
|
|
1872
|
-
cancelAnimationFrame(raf)
|
|
1873
|
-
nodes.onMount = null
|
|
1874
|
-
}
|
|
1875
|
-
}, [anchors, entries, layer, nodes, present])
|
|
1876
|
-
|
|
266
|
+
if (!present) return null
|
|
1877
267
|
return (
|
|
1878
|
-
<
|
|
1879
|
-
{
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
.filter((entry) => !gone.has(entry.zoneId))
|
|
1885
|
-
.map((entry) => (
|
|
1886
|
-
<div
|
|
1887
|
-
className="pointer-events-none absolute -translate-x-1/2 -translate-y-1/2"
|
|
1888
|
-
key={entry.zoneId}
|
|
1889
|
-
ref={(element) => {
|
|
1890
|
-
if (element) nodes.set(entry.zoneId, element)
|
|
1891
|
-
else nodes.delete(entry.zoneId)
|
|
1892
|
-
}}
|
|
1893
|
-
style={{ display: 'none' }}
|
|
1894
|
-
>
|
|
1895
|
-
<RoomPill elements={layer.elements} entry={entry} view="2d" />
|
|
1896
|
-
</div>
|
|
1897
|
-
))}
|
|
1898
|
-
<OpenRoomLabelDetail entries={entries} layer={layer} renderDetail={renderDetail2D} />
|
|
1899
|
-
</div>,
|
|
1900
|
-
host,
|
|
1901
|
-
)}
|
|
1902
|
-
</g>
|
|
268
|
+
<RoomPillsLayer2D
|
|
269
|
+
entries={entries}
|
|
270
|
+
renderDetail={renderDetail2D}
|
|
271
|
+
renderLabel={(entry, elements) => <RoomPill elements={elements} entry={entry} view="2d" />}
|
|
272
|
+
shown={shown}
|
|
273
|
+
/>
|
|
1903
274
|
)
|
|
1904
275
|
}
|