@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.
Files changed (336) hide show
  1. package/package.json +9 -15
  2. package/src/components/editor/action-menu-button.tsx +1 -1
  3. package/src/components/editor/alignment-3d-guide-layer.tsx +1 -1
  4. package/src/components/editor/custom-camera-controls.tsx +101 -45
  5. package/src/components/editor/duplicate-node.ts +2 -1
  6. package/src/components/editor/editor-layout-v2.tsx +1 -1
  7. package/src/components/editor/elevation-3d-guide-layer.tsx +1 -1
  8. package/src/components/editor/first-person-controls.tsx +20 -1983
  9. package/src/components/editor/fit-scene-framing.test.ts +318 -0
  10. package/src/components/editor/fit-scene-framing.ts +142 -0
  11. package/src/components/editor/floating-action-menu.tsx +5 -3
  12. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  13. package/src/components/editor/floor-openings-overlay.tsx +1 -1
  14. package/src/components/editor/floor-region-controls.tsx +8 -4
  15. package/src/components/editor/floorplan-navigation-presentation.test.ts +0 -40
  16. package/src/components/editor/floorplan-navigation-presentation.ts +0 -66
  17. package/src/components/editor/floorplan-panel.tsx +199 -30
  18. package/src/components/editor/floorplan-wall-typing-keys.test.tsx +152 -0
  19. package/src/components/editor/floorplan-wall-typing-keys.ts +79 -0
  20. package/src/components/editor/follow-pascal.test.tsx +1 -2
  21. package/src/components/editor/follow-pascal.tsx +1 -1
  22. package/src/components/editor/footprint-height-handle.tsx +2 -2
  23. package/src/components/editor/group-actions.ts +1 -2
  24. package/src/components/editor/group-floating-action-menu.tsx +1 -1
  25. package/src/components/editor/group-move-3d.ts +1 -2
  26. package/src/components/editor/group-rotate-handle.tsx +1 -2
  27. package/src/components/editor/group-transform-shared.ts +3 -1
  28. package/src/components/editor/handles/use-handle-drag.ts +1 -2
  29. package/src/components/editor/index.tsx +43 -13
  30. package/src/components/editor/inspector-extension-actions.tsx +1 -1
  31. package/src/components/editor/measurement-pill.tsx +1 -1
  32. package/src/components/editor/node-arrow-handles.tsx +2 -2
  33. package/src/components/editor/open-wall-ends-3d-layer.tsx +2 -3
  34. package/src/components/editor/open-wall-ends-hint.tsx +1 -1
  35. package/src/components/editor/opening-guides-3d-layer.tsx +1 -1
  36. package/src/components/editor/quick-measurement-card.tsx +1 -1
  37. package/src/components/editor/room-controls.tsx +10 -6
  38. package/src/components/editor/room-handles-2d.tsx +3 -3
  39. package/src/components/editor/room-handles.tsx +4 -5
  40. package/src/components/editor/room-highlight.tsx +6 -105
  41. package/src/components/editor/room-labels.test.ts +8 -822
  42. package/src/components/editor/room-labels.tsx +41 -1670
  43. package/src/components/editor/room-webgpu-materials.test.ts +1 -1
  44. package/src/components/editor/selection-manager.tsx +5 -3
  45. package/src/components/editor/site-edge-labels.tsx +7 -3
  46. package/src/components/editor/snapshot-capture-overlay.tsx +2 -5
  47. package/src/components/editor/use-floorplan-background-placement.ts +2 -1
  48. package/src/components/editor/views/plugins-view.tsx +1 -1
  49. package/src/components/editor/views/view-bar.tsx +9 -9
  50. package/src/components/editor/wall-measurement-label.tsx +31 -15
  51. package/src/components/editor/wall-move-side-handles.tsx +11 -7
  52. package/src/components/editor/wall-pull-grip.tsx +2 -2
  53. package/src/components/editor/wall-region-handles.tsx +2 -2
  54. package/src/components/editor/wall-snap-beacon-layer.tsx +53 -32
  55. package/src/components/editor-2d/floorplan-group-move.tsx +3 -4
  56. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +13 -13
  57. package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +2 -3
  58. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +2 -1
  59. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +23 -6
  60. package/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts +0 -37
  61. package/src/components/editor-2d/renderers/floorplan-annotation-layout.ts +0 -30
  62. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.tsx +13 -3
  63. package/src/components/editor-2d/renderers/floorplan-draft-measurement.tsx +7 -1
  64. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +14 -3
  65. package/src/components/editor-2d/renderers/floorplan-placement-typing-hud.test.tsx +35 -0
  66. package/src/components/editor-2d/renderers/floorplan-placement-typing-hud.tsx +65 -0
  67. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +30 -10
  68. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +39 -35
  69. package/src/components/systems/roof/roof-edit-system.test.tsx +15 -1
  70. package/src/components/systems/roof/roof-edit-system.tsx +33 -21
  71. package/src/components/tools/distribution/distribution-run-tool.test.tsx +371 -3
  72. package/src/components/tools/distribution/distribution-run-tool.tsx +151 -33
  73. package/src/components/tools/item/move-tool.tsx +1 -6
  74. package/src/components/tools/item/placement-history.test.tsx +1 -1
  75. package/src/components/tools/item/use-placement-coordinator.tsx +3 -4
  76. package/src/components/tools/paint-region/wall-paint-region-tool.tsx +7 -3
  77. package/src/components/tools/registry/move-registry-node-tool.test.ts +42 -1
  78. package/src/components/tools/registry/move-registry-node-tool.tsx +39 -16
  79. package/src/components/tools/shared/cursor-sphere.tsx +1 -1
  80. package/src/components/tools/shared/draft-measurement-label.tsx +1 -1
  81. package/src/components/tools/shared/placement-box.tsx +2 -6
  82. package/src/components/tools/shared/placement-coordinate-input.tsx +94 -0
  83. package/src/components/tools/shared/placement-dimension-guides.tsx +1 -1
  84. package/src/components/tools/shared/segment-angle.ts +0 -4
  85. package/src/components/tools/site/terrain-brush-cursor.tsx +1 -1
  86. package/src/components/tools/tool-manager.tsx +1 -24
  87. package/src/components/tools/wall/wall-typed-entry.test.ts +99 -0
  88. package/src/components/tools/wall/wall-typed-entry.ts +70 -0
  89. package/src/components/ui/action-menu/camera-actions.tsx +2 -2
  90. package/src/components/ui/action-menu/control-modes.tsx +1 -1
  91. package/src/components/ui/action-menu/follow-pascal-pill.tsx +1 -1
  92. package/src/components/ui/action-menu/index.tsx +3 -3
  93. package/src/components/ui/action-menu/measurement-control.tsx +3 -3
  94. package/src/components/ui/action-menu/placement.ts +0 -18
  95. package/src/components/ui/action-menu/point-ask-button.tsx +1 -1
  96. package/src/components/ui/action-menu/view-toggles.tsx +5 -5
  97. package/src/components/ui/controls/finish-swatch.tsx +1 -1
  98. package/src/components/ui/controls/material-paint-panel.tsx +2 -2
  99. package/src/components/ui/controls/scene-material-list.tsx +2 -2
  100. package/src/components/ui/controls/select-control.tsx +1 -1
  101. package/src/components/ui/controls/slider-control.tsx +18 -6
  102. package/src/components/ui/controls/terrain-sculpt-panel.tsx +1 -1
  103. package/src/components/ui/controls/tool-options-panel.tsx +74 -44
  104. package/src/components/ui/floating-level-selector.tsx +3 -3
  105. package/src/components/ui/helpers/contextual-helper-panel.tsx +1 -1
  106. package/src/components/ui/helpers/helper-manager.test.tsx +54 -1
  107. package/src/components/ui/helpers/helper-manager.tsx +55 -1
  108. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  109. package/src/components/ui/panels/collections/collections-popover.tsx +2 -2
  110. package/src/components/ui/panels/floor-foundation-panel.tsx +3 -3
  111. package/src/components/ui/panels/floor-opening-panel.tsx +5 -5
  112. package/src/components/ui/panels/mobile-selection-bar.tsx +28 -24
  113. package/src/components/ui/panels/multi-height-mode.tsx +3 -3
  114. package/src/components/ui/panels/multi-selection-panel.tsx +1 -1
  115. package/src/components/ui/panels/node-display.ts +1 -1
  116. package/src/components/ui/panels/panel-manager.tsx +5 -3
  117. package/src/components/ui/panels/panel-wrapper.tsx +42 -19
  118. package/src/components/ui/panels/parametric-inspector.tsx +1 -1
  119. package/src/components/ui/panels/room-construction-rows.tsx +7 -7
  120. package/src/components/ui/panels/room-name-options.ts +1 -5
  121. package/src/components/ui/panels/room-openings-field.tsx +1 -1
  122. package/src/components/ui/panels/room-panel.tsx +7 -9
  123. package/src/components/ui/primitives/color-dot.tsx +2 -2
  124. package/src/components/ui/primitives/opacity-control.tsx +2 -2
  125. package/src/components/ui/primitives/sidebar.tsx +3 -3
  126. package/src/components/ui/right-stack.test.ts +13 -6
  127. package/src/components/ui/right-stack.tsx +22 -11
  128. package/src/components/ui/sidebar/icon-rail.tsx +1 -1
  129. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +1 -1
  130. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +2 -2
  131. package/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.tsx +1 -1
  132. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +2 -2
  133. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +1 -1
  134. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +2 -6
  135. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +1 -1
  136. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +1 -1
  137. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +1 -1
  138. package/src/components/ui/sidebar/panels/site-panel/index.tsx +3 -10
  139. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +1 -1
  140. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +1 -1
  141. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +46 -7
  142. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +1 -1
  143. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +1 -1
  144. package/src/components/ui/sidebar/panels/site-panel/tree-node-drag.tsx +14 -5
  145. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +2 -2
  146. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +1 -1
  147. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +3 -3
  148. package/src/components/ui/sidebar/tab-bar.tsx +1 -1
  149. package/src/components/ui/wall-type/wall-type-editor-dialog.tsx +2 -2
  150. package/src/components/ui/wall-type/wall-type-menu.tsx +1 -1
  151. package/src/components/viewer-overlay.tsx +1 -2
  152. package/src/hooks/use-auto-frame.ts +1 -2
  153. package/src/hooks/use-keyboard.ts +1 -2
  154. package/src/hooks/use-selected-room.ts +5 -7
  155. package/src/i18n/floor-footprint-name.ts +1 -1
  156. package/src/i18n/locales/en/messages.po +1036 -306
  157. package/src/i18n/locales/en/messages.ts +1 -1
  158. package/src/i18n/locales/en-XA/messages.ts +1 -1
  159. package/src/i18n/locales/es/messages.po +2267 -439
  160. package/src/i18n/locales/fr/messages.po +2267 -439
  161. package/src/i18n/locales/ja/messages.po +2267 -439
  162. package/src/i18n/locales/zh-CN/messages.po +2267 -439
  163. package/src/i18n/locales.test.ts +1 -1
  164. package/src/index.tsx +183 -178
  165. package/src/lib/below-grade.test.tsx +4 -4
  166. package/src/lib/camera-facing.test.ts +1 -1
  167. package/src/lib/camera-facing.ts +2 -3
  168. package/src/lib/contextual-help.ts +6 -0
  169. package/src/lib/editor-level-display.ts +1 -1
  170. package/src/lib/elevation-guides.ts +21 -6
  171. package/src/lib/elevator-support.ts +1 -72
  172. package/src/lib/floor-footprints.test.ts +3 -3
  173. package/src/lib/floor-footprints.ts +1 -9
  174. package/src/lib/floor-foundation-support.test.ts +8 -3
  175. package/src/lib/floor-opening-draft.ts +2 -2
  176. package/src/lib/floor-region-session.ts +1 -1
  177. package/src/lib/floorplan/annotation-visibility.test.ts +1 -1
  178. package/src/lib/floorplan/annotation-visibility.ts +1 -1
  179. package/src/lib/floorplan/floorplan-export.test.ts +1 -1
  180. package/src/lib/floorplan/floorplan-export.tsx +8 -11
  181. package/src/lib/floorplan/floorplan-extension.ts +4 -157
  182. package/src/lib/floorplan/floorplan-mode.ts +2 -1
  183. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +1 -1
  184. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +8 -6
  185. package/src/lib/floorplan/floorplan-plugin-install.test.tsx +3 -4
  186. package/src/lib/floorplan/geometry.ts +2 -112
  187. package/src/lib/floorplan/index.ts +0 -9
  188. package/src/lib/floorplan/items.ts +2 -1
  189. package/src/lib/floorplan/open-wall-ends.ts +2 -1
  190. package/src/lib/floorplan/schedules.ts +6 -3
  191. package/src/lib/floorplan/sheet-export.ts +7 -3
  192. package/src/lib/floorplan/site-plan/site-parts.ts +7 -1
  193. package/src/lib/floorplan/site-plan/site-plan-layer.tsx +1 -1
  194. package/src/lib/follow-pascal-presentation.ts +1 -1
  195. package/src/lib/gesture-lifecycle.ts +3 -1
  196. package/src/lib/glb-export.ts +1 -1
  197. package/src/lib/interaction/scope.ts +1 -0
  198. package/src/lib/layer-thickness.ts +1 -1
  199. package/src/lib/level-selection.test.ts +18 -2
  200. package/src/lib/level-selection.ts +31 -8
  201. package/src/lib/linear-display.ts +1 -1
  202. package/src/lib/live-gesture-round-trip.test.ts +1 -1
  203. package/src/lib/material-paint.ts +1 -1
  204. package/src/lib/measurement-label.test.ts +1 -1
  205. package/src/lib/measurements.test.ts +7 -5
  206. package/src/lib/mezzanine-selection.test.ts +1 -1
  207. package/src/lib/node-action-movement.test.tsx +115 -0
  208. package/src/lib/node-action-movement.ts +2 -0
  209. package/src/lib/paint-region-mode.ts +1 -1
  210. package/src/lib/placement-metadata.ts +4 -0
  211. package/src/lib/point-ask/describe-target.ts +6 -8
  212. package/src/lib/point-ask/region.ts +1 -1
  213. package/src/lib/point-ask/session.ts +1 -2
  214. package/src/lib/print-shell-compiler.ts +12 -1
  215. package/src/lib/print-wall-solids.test.ts +11 -0
  216. package/src/lib/print-wall-solids.ts +9 -0
  217. package/src/lib/roof-duplication.test.ts +100 -0
  218. package/src/lib/roof-duplication.ts +30 -5
  219. package/src/lib/room-built-on.test.ts +2 -5
  220. package/src/lib/room-built-on.ts +1 -15
  221. package/src/lib/room-construction-commands.ts +3 -2
  222. package/src/lib/room-controls-phase6.test.ts +1 -1
  223. package/src/lib/room-handle-drag.ts +8 -37
  224. package/src/lib/room-name-catalog.test.ts +2 -1
  225. package/src/lib/room-name-messages.test.ts +2 -1
  226. package/src/lib/room-name-messages.ts +1 -118
  227. package/src/lib/room-name-storage.test.ts +2 -1
  228. package/src/lib/room-push-dimensions.test.ts +1 -1
  229. package/src/lib/room-selection-commands.ts +2 -2
  230. package/src/lib/room-selection.test.ts +6 -6
  231. package/src/lib/room-transform-session.ts +1 -2
  232. package/src/lib/room-zone-routing.ts +2 -2
  233. package/src/lib/scene-clipboard.ts +2 -1
  234. package/src/lib/selection-routing.test.ts +0 -28
  235. package/src/lib/selection-routing.ts +1 -40
  236. package/src/lib/sfx-bus.ts +8 -32
  237. package/src/lib/sfx-player.test.ts +72 -1
  238. package/src/lib/sfx-player.ts +49 -337
  239. package/src/lib/snapping-mode.test.ts +23 -1
  240. package/src/lib/viewer-selection.test.ts +38 -10
  241. package/src/lib/wall-pull.test.ts +1 -1
  242. package/src/lib/wall-pull.ts +1 -27
  243. package/src/lib/wall-region-list.ts +3 -1
  244. package/src/lib/window-interaction.test.ts +4 -2
  245. package/src/store/use-editor.tsx +22 -26
  246. package/src/store/use-floorplan-annotation-visibility.ts +5 -5
  247. package/src/store/use-placement-typing.test.ts +165 -0
  248. package/src/store/use-placement-typing.ts +186 -0
  249. package/src/store/use-wall-region-handles-selection.ts +1 -2
  250. package/src/components/editor/__bench__/room-label-placement.bench.ts +0 -108
  251. package/src/components/editor/first-person/build-collider-world.test.ts +0 -596
  252. package/src/components/editor/first-person/build-collider-world.ts +0 -549
  253. package/src/components/editor/first-person/ground-holes.test.ts +0 -128
  254. package/src/components/editor/first-person/ground-holes.ts +0 -130
  255. package/src/components/editor/first-person/mechanism-targets.test.ts +0 -162
  256. package/src/components/editor/first-person/mechanism-targets.ts +0 -88
  257. package/src/components/editor/first-person/terrain-collider.test.ts +0 -132
  258. package/src/components/editor/first-person/terrain-collider.ts +0 -212
  259. package/src/components/editor/floorplan-camera-sync.test.ts +0 -249
  260. package/src/components/editor/floorplan-camera-sync.ts +0 -213
  261. package/src/components/editor/localized-html.tsx +0 -22
  262. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.test.tsx +0 -236
  263. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +0 -708
  264. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +0 -385
  265. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +0 -670
  266. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +0 -89
  267. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +0 -106
  268. package/src/components/editor-2d/svg-paths.ts +0 -124
  269. package/src/components/tools/elevator/elevator-defaults.ts +0 -8
  270. package/src/components/tools/elevator/elevator-tool.tsx +0 -372
  271. package/src/components/tools/elevator/move-elevator-tool.tsx +0 -262
  272. package/src/components/ui/action-menu/action-button.tsx +0 -89
  273. package/src/components/ui/action-menu/follow-pascal-toggle.tsx +0 -42
  274. package/src/components/ui/primitives/button.tsx +0 -69
  275. package/src/components/ui/primitives/dropdown-menu.tsx +0 -232
  276. package/src/components/ui/primitives/popover.tsx +0 -42
  277. package/src/components/ui/primitives/switch.tsx +0 -29
  278. package/src/components/ui/primitives/tooltip.tsx +0 -57
  279. package/src/components/ui/scene-loader.tsx +0 -73
  280. package/src/components/viewer/floorplan-compass-button.tsx +0 -67
  281. package/src/components/viewer/floorplan-preview-geometry.test.ts +0 -107
  282. package/src/components/viewer/floorplan-preview-geometry.ts +0 -281
  283. package/src/components/viewer/floorplan-preview-navigation.test.ts +0 -43
  284. package/src/components/viewer/floorplan-preview-navigation.ts +0 -62
  285. package/src/components/viewer/floorplan-preview-visibility.test.ts +0 -35
  286. package/src/components/viewer/floorplan-preview-visibility.ts +0 -19
  287. package/src/components/viewer/floorplan-preview.tsx +0 -1065
  288. package/src/components/viewer/render-settings-menu.tsx +0 -294
  289. package/src/components/viewer/sky-control.tsx +0 -710
  290. package/src/components/viewer/use-viewer-camera-navigation-sync.ts +0 -140
  291. package/src/components/viewer/use-viewer-ui-sounds.test.ts +0 -15
  292. package/src/components/viewer/use-viewer-ui-sounds.ts +0 -46
  293. package/src/components/viewer/viewer-controls-bar.tsx +0 -496
  294. package/src/components/viewer/viewer-hover-label.tsx +0 -42
  295. package/src/components/viewer/viewer-rooms.tsx +0 -248
  296. package/src/components/viewer/viewer-scene-header.tsx +0 -286
  297. package/src/components/viewer/viewer-selection-manager.tsx +0 -124
  298. package/src/components/viewer/viewer-stage-modes.test.ts +0 -38
  299. package/src/components/viewer/viewer-stage-modes.ts +0 -49
  300. package/src/components/viewer/viewer-stage-switcher.tsx +0 -97
  301. package/src/components/viewer/viewer-stage.test.tsx +0 -47
  302. package/src/components/viewer/viewer-stage.tsx +0 -221
  303. package/src/components/walkthrough-hud.tsx +0 -193
  304. package/src/hooks/use-mobile.ts +0 -19
  305. package/src/i18n/level-name.ts +0 -39
  306. package/src/i18n/scene-theme-name.ts +0 -16
  307. package/src/lib/below-grade.ts +0 -133
  308. package/src/lib/camera-pose.test.ts +0 -237
  309. package/src/lib/camera-pose.ts +0 -172
  310. package/src/lib/door-interaction.ts +0 -102
  311. package/src/lib/floorplan/floorplan-extension.test.ts +0 -42
  312. package/src/lib/floorplan/floorplan-readonly.test.ts +0 -47
  313. package/src/lib/floorplan/floorplan-readonly.ts +0 -95
  314. package/src/lib/floorplan/marks.test.ts +0 -213
  315. package/src/lib/floorplan/marks.ts +0 -238
  316. package/src/lib/measurement-label.ts +0 -68
  317. package/src/lib/measurements.ts +0 -225
  318. package/src/lib/node-display-label.ts +0 -37
  319. package/src/lib/room-assembly-overlay.test.ts +0 -275
  320. package/src/lib/room-assembly-overlay.ts +0 -265
  321. package/src/lib/room-selection.ts +0 -417
  322. package/src/lib/scene-bounds.test.ts +0 -183
  323. package/src/lib/scene-bounds.ts +0 -169
  324. package/src/lib/typing-target.ts +0 -9
  325. package/src/lib/viewer-selection.ts +0 -595
  326. package/src/lib/window-interaction.ts +0 -142
  327. package/src/lib/zone-content.ts +0 -133
  328. package/src/store/camera-pose-store.test.ts +0 -29
  329. package/src/store/camera-pose-store.ts +0 -27
  330. package/src/store/navigation-sync-pose-store.test.ts +0 -34
  331. package/src/store/navigation-sync-pose-store.ts +0 -27
  332. package/src/store/use-audio.test.ts +0 -146
  333. package/src/store/use-audio.tsx +0 -169
  334. package/src/store/use-first-person-hud.ts +0 -40
  335. package/src/store/use-follow-pascal.test.ts +0 -62
  336. 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
- formatNumber,
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 { timeSpan, useViewer } from '@pascal-app/viewer'
22
- import { useFrame, useThree } from '@react-three/fiber'
13
+ import { useViewer } from '@pascal-app/viewer'
23
14
  import {
24
- createContext,
25
- Fragment,
26
- type MouseEvent as ReactMouseEvent,
27
- type ReactNode,
28
- type PointerEvent as ReactPointerEvent,
29
- useCallback,
30
- useContext,
31
- useEffect,
32
- useLayoutEffect,
33
- useMemo,
34
- useRef,
35
- useState,
36
- useSyncExternalStore,
37
- } from 'react'
38
- import { createPortal } from 'react-dom'
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
- * Room pills are the resting view of a level: inside the building, select tool
78
- * idle, and nothing picked but the site, building or level itself. Anything
79
- * selected, a unit being arranged, or a gesture in flight takes them away.
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 roomLabelsVisible(state: RoomLabelVisibilityState): boolean {
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.selectedTypes.every((type) => type !== undefined && CONTEXT_TYPES.has(type))
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 roomLabelsVisible({
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
- /** Where the detail chip opens, in its layer's px: on the pill's colour dot, or centred. */
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: LabelEntry
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={labelName(zone, i18n)}
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: LabelEntry; view: '2d' | '3d' }) {
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={labelName(zone, i18n)}
182
+ name={zoneLabel(zone, i18n)}
1305
183
  view={view}
1306
184
  zoneId={entry.zoneId}
1307
185
  />
1308
186
  )
1309
187
  }
1310
188
 
1311
- /**
1312
- * The open room's whole label over its layer: opened with its colour dot on
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={(entry) => <RoomDetail entry={entry} view="3d" />}
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: LabelEntry
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
- const toScreen = new Matrix4()
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
- const ref = useRef<SVGGElement>(null)
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
- <g ref={ref}>
1879
- {present &&
1880
- host &&
1881
- createPortal(
1882
- <div className="pointer-events-none absolute isolate z-0 overflow-hidden" ref={paneRef}>
1883
- {entries
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
  }