@pascal-app/editor 1.0.4 → 1.0.5

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