@pascal-app/editor 1.0.4 → 1.0.6

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 +40 -38
  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
@@ -1,19 +1,33 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
+ import type { Polygon } from '@pascal-app/core'
2
3
  import { ZoneNode } from '@pascal-app/core/schema'
4
+ import { Object3D, PerspectiveCamera, Vector3 } from 'three'
3
5
  import type { RoomSelectionRecord } from '../../lib/room-selection'
4
6
  import {
7
+ candidates3D,
5
8
  DeclutterClock,
6
9
  formatRoomArea,
10
+ type LabelRect,
11
+ LabelReveal,
7
12
  measureFullPill,
8
- PILL_DOT_SIZE,
13
+ PILL_GAP,
9
14
  type PillCandidate,
10
15
  type PillPlacement,
11
16
  PillPlacer,
17
+ type PillSize,
12
18
  pillRect,
19
+ placedRect,
13
20
  placePills,
21
+ REVEAL_DWELL_MS,
22
+ REVEAL_GRACE_MS,
23
+ RoomAnchors,
24
+ type RoomHit,
25
+ type RoomLabelEntry,
14
26
  type RoomLabelVisibilityState,
27
+ roomAt,
15
28
  roomLabelEntries,
16
29
  roomLabelsVisible,
30
+ suppressedBy,
17
31
  } from './room-labels'
18
32
 
19
33
  const resting: RoomLabelVisibilityState = {
@@ -28,6 +42,47 @@ const resting: RoomLabelVisibilityState = {
28
42
  isThumbnailCapture: false,
29
43
  }
30
44
 
45
+ type Timer = ReturnType<typeof setTimeout>
46
+
47
+ /** Timers on a clock the test advances by hand. */
48
+ function manualClock() {
49
+ let now = 0
50
+ let nextId = 1
51
+ const due = new Map<number, { at: number; run: () => void }>()
52
+ return {
53
+ schedule: (run: () => void, ms: number) => {
54
+ const id = nextId++
55
+ due.set(id, { at: now + ms, run })
56
+ return id as unknown as Timer
57
+ },
58
+ cancel: (timer: Timer) => {
59
+ due.delete(timer as unknown as number)
60
+ },
61
+ pending: () => due.size,
62
+ advance(ms: number) {
63
+ const end = now + ms
64
+ for (;;) {
65
+ const [id, timer] = [...due].sort((a, b) => a[1].at - b[1].at)[0] ?? []
66
+ if (id === undefined || !timer || timer.at > end) break
67
+ due.delete(id)
68
+ now = timer.at
69
+ timer.run()
70
+ }
71
+ now = end
72
+ },
73
+ }
74
+ }
75
+
76
+ const square = (cx: number, cy: number, side: number): Polygon => ({
77
+ outer: [
78
+ [cx - side / 2, cy - side / 2],
79
+ [cx + side / 2, cy - side / 2],
80
+ [cx + side / 2, cy + side / 2],
81
+ [cx - side / 2, cy + side / 2],
82
+ ],
83
+ holes: [],
84
+ })
85
+
31
86
  describe('room label pills', () => {
32
87
  test('show on the resting level, with only the site, building or level picked', () => {
33
88
  expect(roomLabelsVisible(resting)).toBe(true)
@@ -63,17 +118,26 @@ describe('room label pills', () => {
63
118
  expect(pillRect(100, 50, [80, 20])).toEqual({ left: 60, top: 40, right: 140, bottom: 60 })
64
119
  })
65
120
 
66
- test('a collapsed pill measures its updated full label and stays collapsed', () => {
67
- const element = { dataset: { roomLabelCollapsed: '' } } as unknown as HTMLElement
121
+ test('a pill showing less measures its whole label, and its width with the name alone', () => {
122
+ const areaLabel = {}
123
+ const element = {
124
+ dataset: { roomLabelTier: 'dot' },
125
+ querySelector: () => areaLabel,
126
+ } as unknown as HTMLElement
68
127
  let width = 161.5
69
- const read = (target: HTMLElement) => ({
70
- width: 'roomLabelCollapsed' in target.dataset ? '16px' : `${width}px`,
71
- height: 'roomLabelCollapsed' in target.dataset ? '16px' : '26px',
72
- })
73
- expect(measureFullPill(element, read)).toEqual([161.5, 26])
128
+ const read = (target: Element) => {
129
+ if (target === areaLabel) return { width: '40px', height: '16px', columnGap: 'normal' }
130
+ const dot = 'roomLabelTier' in (target as HTMLElement).dataset
131
+ return {
132
+ width: dot ? '16px' : `${width}px`,
133
+ height: dot ? '16px' : '26px',
134
+ columnGap: '6px',
135
+ }
136
+ }
137
+ expect(measureFullPill(element, read)).toEqual([161.5, 26, 115.5])
74
138
  width = 176.25
75
- expect(measureFullPill(element, read)).toEqual([176.25, 26])
76
- expect(element.dataset.roomLabelCollapsed).toBe('')
139
+ expect(measureFullPill(element, read)).toEqual([176.25, 26, 130.25])
140
+ expect(element.dataset.roomLabelTier).toBe('dot')
77
141
  })
78
142
 
79
143
  test('invisible rooms and drawn zones do not reserve space for a pill', () => {
@@ -102,97 +166,282 @@ describe('room label pills', () => {
102
166
  area: 9,
103
167
  })
104
168
  })
169
+
170
+ test('a zone drawn inside a room is a hole in it, so their labels and the pointer agree', () => {
171
+ const living = square(5, 5, 10)
172
+ const nook = square(7, 7, 3)
173
+ const entries = roomLabelEntries(
174
+ [],
175
+ [
176
+ ZoneNode.parse({ id: 'zone_living', name: 'Living room', polygon: living.outer }),
177
+ ZoneNode.parse({ id: 'zone_nook', name: 'Reading nook', polygon: nook.outer }),
178
+ ZoneNode.parse({ id: 'zone_hall', name: 'Hall', polygon: square(15, 5, 10).outer }),
179
+ ],
180
+ )
181
+ const [livingEntry, nookEntry, hallEntry] = entries
182
+ expect(livingEntry!.holes).toEqual([nook.outer])
183
+ expect(livingEntry!.area).toBe(100)
184
+ expect(nookEntry!.holes).toEqual([])
185
+ expect(hallEntry!.holes).toEqual([])
186
+ // At 40 px per metre, the living room's label next to the nook moves off it.
187
+ const onScreen = (zoneId: string, x: number, y: number): PillCandidate => {
188
+ const entry = entries.find((candidate) => candidate.zoneId === zoneId)!
189
+ const scale = ([px, py]: [number, number]): [number, number] => [px * 40, py * 40]
190
+ return {
191
+ id: zoneId,
192
+ x: x * 40,
193
+ y: y * 40,
194
+ size: [80, 24, 50],
195
+ area: entry.area,
196
+ outline: { outer: entry.polygon.map(scale), holes: entry.holes.map((h) => h.map(scale)) },
197
+ }
198
+ }
199
+ const placements = placePills([onScreen('zone_living', 7, 8.7), onScreen('zone_nook', 7, 7)], {
200
+ width: 1000,
201
+ height: 1000,
202
+ })
203
+ expect(placements.get('zone_living')).toEqual({ tier: 'full', dy: 8 })
204
+ expect(placements.get('zone_nook')).toEqual({ tier: 'full', dy: 0 })
205
+ expect(roomAt(entries, () => [7, 7, 0])).toBe('zone_nook')
206
+ expect(roomAt(entries, () => [2, 2, 0])).toBe('zone_living')
207
+ })
105
208
  })
106
209
 
107
210
  describe('pill placement', () => {
108
- const pill = (id: string, x: number, y: number, area: number, width = 120): PillCandidate => ({
109
- id,
110
- x,
111
- y,
112
- size: [width, 24],
113
- area,
114
- })
115
- const rectOf = (candidate: PillCandidate, dy: number, dot: boolean) =>
116
- pillRect(candidate.x, candidate.y + dy, dot ? [PILL_DOT_SIZE, PILL_DOT_SIZE] : candidate.size)
117
- const apart = (a: ReturnType<typeof pillRect>, b: ReturnType<typeof pillRect>) =>
118
- a.right <= b.left || b.right <= a.left || a.bottom <= b.top || b.bottom <= a.top
211
+ const pane = { width: 1000, height: 1000 }
212
+ const everywhere = square(0, 0, 1e5)
213
+ const pill = (
214
+ id: string,
215
+ x: number,
216
+ y: number,
217
+ area: number,
218
+ size: PillSize = [120, 24, 70],
219
+ outline: Polygon = everywhere,
220
+ ): PillCandidate => ({ id, x, y, size, area, outline })
221
+ const near = (a: LabelRect, b: LabelRect) =>
222
+ a.left < b.right + PILL_GAP &&
223
+ b.left < a.right + PILL_GAP &&
224
+ a.top < b.bottom + PILL_GAP &&
225
+ b.top < a.bottom + PILL_GAP
119
226
 
120
- test('apart, every pill stays on its room', () => {
121
- const placements = placePills([pill('a', 100, 100, 20), pill('b', 400, 100, 10)])
227
+ test('apart, every pill shows in full at its anchor', () => {
228
+ const placements = placePills([pill('a', 100, 100, 20), pill('b', 400, 100, 10)], pane)
122
229
  expect([...placements.values()]).toEqual([
123
- { mode: 'full', dy: 0 },
124
- { mode: 'full', dy: 0 },
230
+ { tier: 'full', dy: 0 },
231
+ { tier: 'full', dy: 0 },
125
232
  ])
126
233
  })
127
234
 
128
235
  test('two overlapping pills: the larger room keeps its place, the other moves the least it can', () => {
129
236
  const big = pill('big', 100, 100, 30)
130
237
  const small = pill('small', 110, 106, 10)
131
- const placements = placePills([small, big])
132
- expect(placements.get('big')).toEqual({ mode: 'full', dy: 0 })
133
- const moved = placements.get('small')!
134
- expect(moved.mode).toBe('full')
135
- expect(Math.abs(moved.dy)).toBeLessThanOrEqual(24)
136
- expect(apart(rectOf(big, 0, false), rectOf(small, moved.dy, false))).toBe(true)
137
- // 24 px tall, 6 px apart, 2 px gap: 20 px down is the nearest clear slot (up needs 32).
138
- expect(moved.dy).toBe(20)
139
- })
140
-
141
- test('crowded beyond a pill height, a pill collapses to its dot; hidden only if the dot collides too', () => {
142
- const wide = pill('wide', 200, 100, 40, 300)
143
- const above = pill('above', 200, 74, 30, 300)
144
- const below = pill('below', 200, 126, 20, 300)
145
- const squeezed = pill('squeezed', 120, 100, 10)
146
- const placements = placePills([squeezed, below, above, wide])
147
- expect(placements.get('wide')?.mode).toBe('full')
148
- expect(placements.get('above')?.mode).toBe('full')
149
- expect(placements.get('below')?.mode).toBe('full')
150
- // Its dot sits inside the wide pill: nothing left but to hide it.
151
- expect(placements.get('squeezed')).toEqual({ mode: 'hidden', dy: 0 })
152
-
153
- const nearEdge = pill('near-edge', 30, 100, 10)
154
- const crowded = placePills([nearEdge, below, above, wide])
155
- // Its full pill still collides, but its dot at the anchor is clear.
156
- expect(crowded.get('near-edge')).toEqual({ mode: 'dot', dy: 0 })
157
- })
158
-
159
- test('a dot with no room at its anchor takes the nearest free slot instead of hiding', () => {
160
- const big = pill('big', 100, 100, 30)
161
- const closet = pill('closet', 100, 100, 5)
162
- const placements = placePills([closet, big])
163
- // The full pill would need 26 px (past its 24 px budget); its dot clears at 24 px up.
164
- expect(placements.get('closet')).toEqual({ mode: 'dot', dy: -24 })
165
- expect(apart(rectOf(big, 0, false), rectOf(closet, -24, true))).toBe(true)
238
+ const placements = placePills([small, big], pane)
239
+ expect(placements.get('big')).toEqual({ tier: 'full', dy: 0 })
240
+ // 24 px tall, 6 px apart, 4 px gap: 24 px down is the nearest clear slot (up needs 34).
241
+ expect(placements.get('small')).toEqual({ tier: 'full', dy: 24 })
242
+ })
243
+
244
+ test('how much of a label shows follows how large its room is on screen', () => {
245
+ // A 4 m square room at 40, 25, 10 and 3 px per metre.
246
+ const tiers = [40, 25, 10, 3].map(
247
+ (scale) =>
248
+ placePills(
249
+ [pill('room', 500, 500, 16, [120, 24, 70], square(500, 500, 4 * scale))],
250
+ pane,
251
+ ).get('room')!.tier,
252
+ )
253
+ expect(tiers).toEqual(['full', 'name', 'dot', 'hidden'])
254
+ })
255
+
256
+ test('a thin room at an angle gets its dot, however large its bounding box', () => {
257
+ // A 1.2 m corridor 8 m long at 45°, at 40 px per metre.
258
+ const along = [160 / Math.SQRT2, 160 / Math.SQRT2]
259
+ const across = [-24 / Math.SQRT2, 24 / Math.SQRT2]
260
+ const corner = (a: number, b: number): [number, number] => [
261
+ 500 + a * along[0]! + b * across[0]!,
262
+ 500 + a * along[1]! + b * across[1]!,
263
+ ]
264
+ const corridor = {
265
+ outer: [corner(-1, -1), corner(1, -1), corner(1, 1), corner(-1, 1)],
266
+ holes: [],
267
+ }
268
+ expect(
269
+ placePills([pill('hall', 500, 500, 9.6, [120, 24, 70], corridor)], pane).get('hall'),
270
+ ).toEqual({ tier: 'dot', dy: 0 })
271
+ })
272
+
273
+ test('a label next to a hole in its room moves clear of the hole', () => {
274
+ const atrium = { ...square(200, 200, 200), holes: [square(200, 200, 80).outer] }
275
+ // At its anchor the pill's bottom edge crosses the hole's top edge (y 160).
276
+ expect(
277
+ placePills([pill('room', 200, 150, 34, [120, 24, 70], atrium)], pane).get('room'),
278
+ ).toEqual({
279
+ tier: 'full',
280
+ dy: -4,
281
+ })
282
+ })
283
+
284
+ test('a label never leaves the pane: near its edge it shows less', () => {
285
+ const placements = placePills([pill('a', 50, 500, 10), pill('b', 5, 500, 10)], pane)
286
+ expect(placements.get('a')).toEqual({ tier: 'name', dy: 0 })
287
+ expect(placements.get('b')).toEqual({ tier: 'hidden', dy: 0 })
288
+ })
289
+
290
+ test('a pill that has to move keeps to the side it took last time; one with room goes back', () => {
291
+ const bar = pill('bar', 100, 100, 30, [120, 8, 60])
292
+ const moved = pill('moved', 100, 100, 10)
293
+ expect(placePills([bar, moved], pane).get('moved')).toEqual({ tier: 'full', dy: -20 })
294
+ const previous = new Map<string, PillPlacement>([['moved', { tier: 'full', dy: 20 }]])
295
+ expect(placePills([bar, moved], pane, previous).get('moved')).toEqual({ tier: 'full', dy: 20 })
296
+ expect(placePills([moved], pane, previous).get('moved')).toEqual({ tier: 'full', dy: 0 })
166
297
  })
167
298
 
168
299
  test('the order is fixed by room size and id, not by input order', () => {
169
300
  const candidates = [pill('b', 100, 100, 10), pill('a', 105, 100, 10), pill('c', 110, 100, 10)]
170
- const first = placePills(candidates)
171
- const shuffled = placePills([candidates[2]!, candidates[0]!, candidates[1]!])
301
+ const first = placePills(candidates, pane)
302
+ const shuffled = placePills([candidates[2]!, candidates[0]!, candidates[1]!], pane)
172
303
  expect(shuffled).toEqual(first)
173
- expect(first.get('a')).toEqual({ mode: 'full', dy: 0 })
174
- })
175
-
176
- test('no two placed boxes ever overlap', () => {
177
- const candidates = Array.from({ length: 40 }, (_, i) =>
178
- pill(
179
- `p${i}`,
180
- 100 + ((i * 37) % 220),
181
- 100 + ((i * 53) % 140),
182
- (i * 7) % 13,
183
- 90 + (i % 5) * 20,
184
- ),
304
+ expect(first.get('a')).toEqual({ tier: 'full', dy: 0 })
305
+ })
306
+
307
+ test('placed labels never come within the gap of each other, and each stays in its room', () => {
308
+ let seed = 7
309
+ const random = () => {
310
+ seed = (seed * 1_103_515_245 + 12_345) % 2 ** 31
311
+ return seed / 2 ** 31
312
+ }
313
+ // A plan of 8 × 5 rooms of random widths and depths, filling the pane.
314
+ const cuts = (count: number, length: number) => {
315
+ const weights = Array.from({ length: count }, () => 0.2 + random())
316
+ const total = weights.reduce((sum, weight) => sum + weight, 0)
317
+ let at = 0
318
+ return [0, ...weights.map((weight) => (at += (weight / total) * length))]
319
+ }
320
+ const xs = cuts(8, 1200)
321
+ const ys = cuts(5, 800)
322
+ const rooms = xs.slice(1).flatMap((right, i) =>
323
+ ys.slice(1).map((bottom, j) => {
324
+ const left = xs[i]!
325
+ const top = ys[j]!
326
+ const outline: Polygon = {
327
+ outer: [
328
+ [left, top],
329
+ [right, top],
330
+ [right, bottom],
331
+ [left, bottom],
332
+ ],
333
+ holes: [],
334
+ }
335
+ const width = 40 + random() * 140
336
+ return {
337
+ box: { left, top, right, bottom },
338
+ candidate: pill(
339
+ `r${i}-${j}`,
340
+ left + (right - left) * (0.3 + random() * 0.4),
341
+ top + (bottom - top) * (0.3 + random() * 0.4),
342
+ (right - left) * (bottom - top),
343
+ [width, 24, width * 0.6],
344
+ outline,
345
+ ),
346
+ }
347
+ }),
185
348
  )
186
- const placements = placePills(candidates)
187
- const boxes = candidates.flatMap((candidate) => {
188
- const placement = placements.get(candidate.id)!
189
- return placement.mode === 'hidden'
190
- ? []
191
- : [rectOf(candidate, placement.dy, placement.mode === 'dot')]
349
+ const placements = placePills(
350
+ rooms.map((room) => room.candidate),
351
+ { width: 1200, height: 800 },
352
+ )
353
+ const placed = rooms.flatMap(({ box, candidate }) => {
354
+ const rect = placedRect(candidate, placements.get(candidate.id)!)
355
+ return rect ? [{ box, rect }] : []
192
356
  })
193
- for (let i = 0; i < boxes.length; i++) {
194
- for (let j = 0; j < i; j++) expect(apart(boxes[i]!, boxes[j]!)).toBe(true)
357
+ expect(placed.length).toBeGreaterThan(30)
358
+ for (const { box, rect } of placed) {
359
+ expect(rect.left).toBeGreaterThanOrEqual(box.left)
360
+ expect(rect.right).toBeLessThanOrEqual(box.right)
361
+ expect(rect.top).toBeGreaterThanOrEqual(box.top)
362
+ expect(rect.bottom).toBeLessThanOrEqual(box.bottom)
363
+ }
364
+ for (let i = 0; i < placed.length; i++) {
365
+ for (let j = 0; j < i; j++) expect(near(placed[i]!.rect, placed[j]!.rect)).toBe(false)
366
+ }
367
+ })
368
+ })
369
+
370
+ describe('the room under the pointer', () => {
371
+ const room = (zoneId: string, polygon: Polygon, area: number): RoomLabelEntry => ({
372
+ zoneId,
373
+ polygon: polygon.outer,
374
+ holes: polygon.holes,
375
+ area,
376
+ })
377
+ const hall = room('hall', square(5, 5, 10), 100)
378
+ const nook = room('nook', square(2, 2, 3), 9)
379
+ const atrium = room('atrium', { ...square(25, 5, 10), holes: [square(25, 5, 4).outer] }, 84)
380
+
381
+ test('is the smallest room holding the point: a zone drawn inside the room it stands in', () => {
382
+ const at = (x: number, y: number) => roomAt([hall, nook, atrium], () => [x, y, 0])
383
+ expect(at(2, 2)).toBe('nook')
384
+ expect(at(8, 8)).toBe('hall')
385
+ expect(at(22, 2)).toBe('atrium')
386
+ })
387
+
388
+ test('is no room outside every room or inside a hole', () => {
389
+ expect(roomAt([hall, atrium], () => [15, 5, 0])).toBeNull()
390
+ expect(roomAt([hall, atrium], () => [25, 5, 0])).toBeNull()
391
+ })
392
+
393
+ test('only tests a room where the pointer meets its own plane', () => {
394
+ expect(roomAt([hall, nook], (entry) => (entry === nook ? null : [2, 2, 0]))).toBe('hall')
395
+ })
396
+
397
+ test('is the floor the pointer reaches first, before a smaller one further down', () => {
398
+ // A raised floor over the whole hall, and a small room on the floor below it.
399
+ const raised = room('raised', square(5, 5, 10), 100)
400
+ const below = room('below', square(5, 5, 2), 4)
401
+ const hits = new Map<RoomLabelEntry, RoomHit>([
402
+ [raised, [5, 5, 3]],
403
+ [below, [5, 5, 6]],
404
+ ])
405
+ expect(roomAt([raised, below], (entry) => hits.get(entry) ?? null)).toBe('raised')
406
+ // Met at the same distance, they are one floor: the smaller wins.
407
+ hits.set(raised, [5, 5, 6 + 1e-4])
408
+ expect(roomAt([raised, below], (entry) => hits.get(entry) ?? null)).toBe('below')
409
+ })
410
+ })
411
+
412
+ describe('pills in 3D', () => {
413
+ test('a corridor running behind the camera still holds its pill to its width', () => {
414
+ // 0.3 m wide and 20 m long, half of it behind a camera standing in it.
415
+ const corridor: RoomLabelEntry = {
416
+ zoneId: 'corridor',
417
+ polygon: [
418
+ [-0.15, -10],
419
+ [0.15, -10],
420
+ [0.15, 10],
421
+ [-0.15, 10],
422
+ ],
423
+ holes: [],
424
+ area: 6,
195
425
  }
426
+ const camera = new PerspectiveCamera(50, 1440 / 900, 0.1, 1000)
427
+ camera.position.set(0, 1.6, 0)
428
+ camera.updateMatrixWorld()
429
+ const level = new Object3D()
430
+ level.updateMatrixWorld()
431
+ const size = { width: 1440, height: 900 }
432
+ const [candidate] = candidates3D(
433
+ [corridor],
434
+ new Map([['corridor', new Vector3(0, 1.25, -5)]]),
435
+ new Map<string, PillSize>([['corridor', [120, 24, 70]]]),
436
+ camera,
437
+ level,
438
+ size,
439
+ )
440
+ expect(
441
+ candidate!.outline.outer.every(([x, y]) => Number.isFinite(x) && Number.isFinite(y)),
442
+ ).toBe(true)
443
+ // About 58 px wide where the pill would sit: only its dot fits.
444
+ expect(placePills([candidate!], size).get('corridor')).toEqual({ tier: 'dot', dy: 0 })
196
445
  })
197
446
  })
198
447
 
@@ -231,127 +480,371 @@ describe('when pills re-read their overlaps', () => {
231
480
  }
232
481
  expect(clock.shouldRead(at(0), 0)).toBe(false)
233
482
  expect(clock.shouldRead(at(0), 0.2)).toBe(true)
483
+ expect(clock.atRest).toBe(true)
234
484
  expect(clock.shouldRead(at(0), 0.4)).toBe(false)
235
485
  // Moving: pills ride their rooms, nothing is re-placed until it rests.
236
486
  const moving = [0.5, 0.55, 0.6, 0.65, 0.7].map((t) => clock.shouldRead(at(t), t))
237
487
  expect(moving.some(Boolean)).toBe(false)
488
+ expect(clock.atRest).toBe(false)
238
489
  expect(clock.shouldRead(at(0.7), 0.85)).toBe(true)
239
490
  clock.invalidate()
240
491
  expect(clock.shouldRead(at(0.7), 0.86)).toBe(true)
241
492
  })
493
+
494
+ test('a room whose anchor moves has the pills placed again', () => {
495
+ const anchors = new RoomAnchors()
496
+ let moves = 0
497
+ anchors.onMove = () => moves++
498
+ anchors.set('kitchen', new Vector3(1, 1.25, 2))
499
+ anchors.set('kitchen', new Vector3(1, 1.25, 2))
500
+ expect(moves).toBe(1)
501
+ anchors.set('kitchen', new Vector3(1, 1.75, 2))
502
+ expect(moves).toBe(2)
503
+ })
242
504
  })
243
505
 
244
506
  describe('applying placements', () => {
507
+ const element = () =>
508
+ ({
509
+ dataset: {} as Record<string, string>,
510
+ style: { translate: '' },
511
+ }) as unknown as HTMLElement
245
512
  function setup() {
246
- const element = () =>
247
- ({
248
- dataset: {} as Record<string, string>,
249
- style: { translate: '' },
250
- }) as unknown as HTMLElement
251
513
  const elements = new Map([
252
514
  ['near', element()],
253
515
  ['far', element()],
254
516
  ])
255
- const timers: Array<{ run: () => void; ms: number; cancelled: boolean }> = []
517
+ const clock = manualClock()
256
518
  const changes: ReadonlySet<string>[] = []
257
519
  const placer = new PillPlacer(
258
520
  elements,
259
521
  (gone) => changes.push(gone),
260
- (run, ms) => {
261
- const timer = { run, ms, cancelled: false }
262
- timers.push(timer)
263
- return timer as unknown as ReturnType<typeof setTimeout>
264
- },
265
- (timer) => {
266
- ;(timer as unknown as { cancelled: boolean }).cancelled = true
267
- },
522
+ clock.schedule,
523
+ clock.cancel,
268
524
  )
269
- return { elements, timers, changes, placer }
525
+ return { elements, clock, changes, placer }
270
526
  }
271
527
  const has = (element: HTMLElement | undefined, flag: string) => flag in element!.dataset
272
- const full = (dy = 0) => ({ mode: 'full' as const, dy })
528
+ const full = (dy = 0): PillPlacement => ({ tier: 'full', dy })
529
+ const hidden: PillPlacement = { tier: 'hidden', dy: 0 }
530
+ const placements = (entries: [string, PillPlacement][]) => new Map(entries)
273
531
 
274
- test('a nudge is a translate, a dot is a collapsed pill, both undone when placed back', () => {
532
+ test('a nudge is a translate, a tier is a flag, both undone when placed back', () => {
275
533
  const { elements, placer } = setup()
276
534
  placer.apply(
277
- new Map<string, PillPlacement>([
535
+ placements([
278
536
  ['near', full(-8)],
279
- ['far', { mode: 'dot' as const, dy: 0 }],
537
+ ['far', { tier: 'dot', dy: 0 }],
280
538
  ]),
281
539
  180,
282
540
  )
283
541
  expect(elements.get('near')!.style.translate).toBe('0 -8px')
284
- expect(has(elements.get('far'), 'roomLabelCollapsed')).toBe(true)
542
+ expect(elements.get('far')!.dataset.roomLabelTier).toBe('dot')
285
543
  placer.apply(
286
- new Map<string, PillPlacement>([
544
+ placements([
287
545
  ['near', full()],
288
- ['far', full()],
546
+ ['far', { tier: 'name', dy: 0 }],
289
547
  ]),
290
548
  180,
291
549
  )
292
550
  expect(elements.get('near')!.style.translate).toBe('')
293
- expect(has(elements.get('far'), 'roomLabelCollapsed')).toBe(false)
551
+ expect(elements.get('far')!.dataset.roomLabelTier).toBe('name')
552
+ placer.apply(
553
+ placements([
554
+ ['near', full()],
555
+ ['far', full()],
556
+ ]),
557
+ 180,
558
+ )
559
+ expect(has(elements.get('far'), 'roomLabelTier')).toBe(false)
294
560
  })
295
561
 
296
562
  test('a hidden pill fades out without taking the pointer, then leaves the DOM', () => {
297
- const { elements, timers, changes, placer } = setup()
563
+ const { elements, clock, changes, placer } = setup()
298
564
  placer.apply(
299
- new Map<string, PillPlacement>([
565
+ placements([
300
566
  ['near', full()],
301
- ['far', { mode: 'hidden' as const, dy: 0 }],
567
+ ['far', hidden],
302
568
  ]),
303
569
  180,
304
570
  )
305
571
  expect(has(elements.get('far'), 'roomLabelHidden')).toBe(true)
306
- expect(timers.map(({ ms }) => ms)).toEqual([180])
572
+ clock.advance(179)
307
573
  expect(changes).toEqual([])
308
- timers[0]!.run()
574
+ clock.advance(1)
309
575
  expect(placer.gone).toEqual(new Set(['far']))
310
576
  })
311
577
 
312
578
  test('a pill given room again mounts in its new place, or turns back mid-fade', () => {
313
- const { elements, timers, placer } = setup()
314
- placer.apply(new Map<string, PillPlacement>([['far', { mode: 'hidden' as const, dy: 0 }]]), 180)
315
- timers[0]!.run()
316
- const element = elements.get('far')!
579
+ const { elements, clock, placer } = setup()
580
+ placer.apply(placements([['far', hidden]]), 180)
581
+ clock.advance(180)
582
+ const far = elements.get('far')!
317
583
  elements.delete('far')
318
- placer.apply(new Map<string, PillPlacement>([['far', full(12)]]), 180)
584
+ placer.apply(placements([['far', full(12)]]), 180)
319
585
  expect(placer.gone).toEqual(new Set())
320
- placer.mount('far', element)
321
- expect(element.style.translate).toBe('0 12px')
586
+ placer.mount('far', far)
587
+ expect(far.style.translate).toBe('0 12px')
588
+ expect(has(far, 'roomLabelHidden')).toBe(false)
322
589
 
323
590
  const again = setup()
324
- again.placer.apply(
325
- new Map<string, PillPlacement>([['far', { mode: 'hidden' as const, dy: 0 }]]),
591
+ again.placer.apply(placements([['far', hidden]]), 180)
592
+ again.placer.apply(placements([['far', full()]]), 180)
593
+ again.clock.advance(500)
594
+ expect(again.placer.gone).toEqual(new Set())
595
+ expect(has(again.elements.get('far'), 'roomLabelHidden')).toBe(false)
596
+ })
597
+
598
+ test('a pill no pass has placed waits hidden, still mounted, until one places it', () => {
599
+ const { elements, clock, placer } = setup()
600
+ const near = elements.get('near')!
601
+ placer.mount('near', near)
602
+ expect(has(near, 'roomLabelHidden')).toBe(true)
603
+ // Not measured yet, so not a candidate: it stays hidden and is not removed.
604
+ placer.apply(placements([['far', full()]]), 180)
605
+ expect(has(near, 'roomLabelHidden')).toBe(true)
606
+ expect(clock.pending()).toBe(0)
607
+ placer.apply(
608
+ placements([
609
+ ['near', full()],
610
+ ['far', full()],
611
+ ]),
326
612
  180,
327
613
  )
328
- again.placer.apply(new Map<string, PillPlacement>([['far', full()]]), 180)
329
- expect(again.timers[0]!.cancelled).toBe(true)
330
- expect(has(again.elements.get('far'), 'roomLabelHidden')).toBe(false)
614
+ expect(has(near, 'roomLabelHidden')).toBe(false)
615
+ // Its anchor leaves the screen: hidden again, and still mounted.
616
+ placer.apply(placements([['far', full()]]), 180)
617
+ expect(has(near, 'roomLabelHidden')).toBe(true)
618
+ clock.advance(500)
619
+ expect(placer.gone).toEqual(new Set())
331
620
  })
332
621
 
333
622
  test('hidden pills can refresh their text measurements without becoming visible', () => {
334
- const { elements, timers, placer } = setup()
335
- placer.apply(new Map<string, PillPlacement>([['far', { mode: 'hidden', dy: 0 }]]), 180)
336
- timers[0]!.run()
623
+ const { elements, clock, placer } = setup()
624
+ placer.apply(placements([['far', hidden]]), 180)
625
+ clock.advance(180)
337
626
  elements.delete('far')
338
627
  placer.remeasure()
339
628
  expect(placer.gone.size).toBe(0)
340
- const fresh = { dataset: {}, style: { translate: '' } } as unknown as HTMLElement
629
+ const fresh = element()
341
630
  elements.set('far', fresh)
342
631
  placer.mount('far', fresh)
343
632
  expect(has(fresh, 'roomLabelHidden')).toBe(true)
344
- placer.apply(new Map<string, PillPlacement>([['far', full()]]), 180)
633
+ placer.apply(placements([['far', full()]]), 180)
345
634
  expect(has(fresh, 'roomLabelHidden')).toBe(false)
346
635
  })
347
636
 
348
637
  test('the whole layer fades out and back in, except pills still hidden', () => {
349
638
  const { elements, placer } = setup()
350
- placer.apply(new Map<string, PillPlacement>([['far', { mode: 'hidden' as const, dy: 0 }]]), 180)
639
+ placer.apply(
640
+ placements([
641
+ ['near', full()],
642
+ ['far', hidden],
643
+ ]),
644
+ 180,
645
+ )
351
646
  placer.showAll(false)
352
647
  expect(has(elements.get('near'), 'roomLabelHidden')).toBe(true)
353
648
  placer.showAll(true)
354
649
  expect(has(elements.get('near'), 'roomLabelHidden')).toBe(false)
355
650
  expect(has(elements.get('far'), 'roomLabelHidden')).toBe(true)
356
651
  })
652
+
653
+ test('the pills under the detail chip fade out of its way, and come back', () => {
654
+ const { elements, placer } = setup()
655
+ placer.suppress(new Set(['near', 'far']), 'far')
656
+ expect(has(elements.get('near'), 'roomLabelSuppressed')).toBe(true)
657
+ // The open room's own pill fades under its chip but stays the control.
658
+ expect(has(elements.get('far'), 'roomLabelSuppressed')).toBe(false)
659
+ expect(has(elements.get('far'), 'roomLabelOpen')).toBe(true)
660
+ const remounted = element()
661
+ placer.mount('near', remounted)
662
+ expect(has(remounted, 'roomLabelSuppressed')).toBe(true)
663
+ placer.suppress(new Set(), null)
664
+ expect(has(elements.get('near'), 'roomLabelSuppressed')).toBe(false)
665
+ expect(has(elements.get('far'), 'roomLabelOpen')).toBe(false)
666
+ })
667
+
668
+ test('with the default timers, a pill hiding never stops the pills after it', () => {
669
+ // Browser timers throw when called as a method of anything but the window.
670
+ const realSetTimeout = globalThis.setTimeout
671
+ const realClearTimeout = globalThis.clearTimeout
672
+ const clock = manualClock()
673
+ function strictSetTimeout(this: unknown, run: () => void, ms: number) {
674
+ if (this !== undefined && this !== globalThis) throw new TypeError('Illegal invocation')
675
+ return clock.schedule(run, ms)
676
+ }
677
+ function strictClearTimeout(this: unknown, timer: Timer) {
678
+ if (this !== undefined && this !== globalThis) throw new TypeError('Illegal invocation')
679
+ clock.cancel(timer)
680
+ }
681
+ globalThis.setTimeout = strictSetTimeout as unknown as typeof setTimeout
682
+ globalThis.clearTimeout = strictClearTimeout as unknown as typeof clearTimeout
683
+ try {
684
+ const elements = new Map([
685
+ ['a', element()],
686
+ ['b', element()],
687
+ ])
688
+ const placer = new PillPlacer(elements, () => {})
689
+ placer.apply(
690
+ placements([
691
+ ['a', hidden],
692
+ ['b', full(8)],
693
+ ]),
694
+ 180,
695
+ )
696
+ expect(elements.get('b')!.style.translate).toBe('0 8px')
697
+ clock.advance(180)
698
+ expect(placer.gone).toEqual(new Set(['a']))
699
+
700
+ placer.apply(placements([['b', hidden]]), 180)
701
+ placer.apply(placements([['b', full()]]), 180)
702
+ clock.advance(180)
703
+ expect(placer.gone).toEqual(new Set(['a']))
704
+ expect(has(elements.get('b'), 'roomLabelHidden')).toBe(false)
705
+
706
+ const reveal = new LabelReveal(() => {})
707
+ reveal.pick('b')
708
+ reveal.pick('a')
709
+ reveal.clear()
710
+ expect(clock.pending()).toBe(0)
711
+ } finally {
712
+ globalThis.setTimeout = realSetTimeout
713
+ globalThis.clearTimeout = realClearTimeout
714
+ }
715
+ })
716
+ })
717
+
718
+ describe("a room's detail", () => {
719
+ function setup() {
720
+ const clock = manualClock()
721
+ const changes: (string | null)[] = []
722
+ const focused: boolean[] = []
723
+ const reveal = new LabelReveal(
724
+ (id, isFocused) => {
725
+ changes.push(id)
726
+ focused.push(isFocused)
727
+ },
728
+ clock.schedule,
729
+ clock.cancel,
730
+ )
731
+ return { clock, changes, focused, reveal }
732
+ }
733
+
734
+ test('opens at once for a pill under the pointer, after a short rest for a room under it', () => {
735
+ const { clock, changes, reveal } = setup()
736
+ reveal.hover('closet')
737
+ expect(changes).toEqual(['closet'])
738
+ reveal.hover(null)
739
+ clock.advance(REVEAL_GRACE_MS)
740
+ expect(changes).toEqual(['closet', null])
741
+ reveal.pick('bath')
742
+ clock.advance(REVEAL_DWELL_MS - 1)
743
+ expect(reveal.active).toBeNull()
744
+ clock.advance(1)
745
+ expect(reveal.active).toBe('bath')
746
+ })
747
+
748
+ test('hands over from room to room with one detail open, and opens nothing for a room passed over', () => {
749
+ const { clock, changes, reveal } = setup()
750
+ reveal.pick('hall')
751
+ clock.advance(REVEAL_DWELL_MS)
752
+ reveal.pick('bath')
753
+ clock.advance(REVEAL_DWELL_MS - 1)
754
+ expect(reveal.active).toBe('hall')
755
+ clock.advance(1)
756
+ reveal.pick('closet')
757
+ clock.advance(REVEAL_DWELL_MS / 2)
758
+ reveal.pick('bedroom')
759
+ clock.advance(REVEAL_DWELL_MS / 2)
760
+ expect(reveal.active).toBe('bath')
761
+ clock.advance(REVEAL_DWELL_MS / 2)
762
+ expect(changes).toEqual(['hall', 'bath', 'bedroom'])
763
+ })
764
+
765
+ test('stays a moment after the pointer leaves its room, and stays if it comes back', () => {
766
+ const { clock, changes, reveal } = setup()
767
+ reveal.pick('bath')
768
+ clock.advance(REVEAL_DWELL_MS)
769
+ reveal.pick(null)
770
+ clock.advance(REVEAL_GRACE_MS - 1)
771
+ reveal.pick('bath')
772
+ clock.advance(1000)
773
+ expect(changes).toEqual(['bath'])
774
+ reveal.pick(null)
775
+ clock.advance(REVEAL_GRACE_MS)
776
+ expect(changes).toEqual(['bath', null])
777
+ })
778
+
779
+ test('opens at once for keyboard focus, and says so while that pill keeps it', () => {
780
+ const { clock, changes, focused, reveal } = setup()
781
+ reveal.focus('kitchen')
782
+ reveal.hover('kitchen')
783
+ reveal.hover(null)
784
+ reveal.focus(null)
785
+ clock.advance(REVEAL_GRACE_MS)
786
+ expect(changes).toEqual(['kitchen', null])
787
+ expect(focused).toEqual([true, false])
788
+ })
789
+
790
+ test('with the pointer on one pill and focus on another, the one that moved last wins', () => {
791
+ const { changes, focused, reveal } = setup()
792
+ reveal.hover('kitchen')
793
+ reveal.focus('bath')
794
+ expect(reveal.active).toBe('bath')
795
+ reveal.hover('kitchen')
796
+ expect(reveal.active).toBe('bath')
797
+ reveal.hover('closet')
798
+ reveal.focus('hall')
799
+ reveal.hover(null)
800
+ expect(changes).toEqual(['kitchen', 'bath', 'closet', 'hall'])
801
+ expect(focused).toEqual([false, true, false, true])
802
+ })
803
+
804
+ test('Escape closes it until the pointer or focus moves on to another room', () => {
805
+ const { clock, changes, reveal } = setup()
806
+ reveal.pick('bath')
807
+ clock.advance(REVEAL_DWELL_MS)
808
+ reveal.dismiss()
809
+ reveal.pick('bath')
810
+ clock.advance(1000)
811
+ reveal.hover('bath')
812
+ expect(changes).toEqual(['bath', null])
813
+ reveal.hover(null)
814
+ reveal.pick('closet')
815
+ clock.advance(REVEAL_DWELL_MS)
816
+ reveal.pick('bath')
817
+ clock.advance(REVEAL_DWELL_MS)
818
+ expect(changes).toEqual(['bath', null, 'closet', 'bath'])
819
+ })
820
+
821
+ test('a focused pill opens again once the view rests', () => {
822
+ const { changes, reveal } = setup()
823
+ reveal.focus('kitchen')
824
+ reveal.clear()
825
+ reveal.pick(null)
826
+ expect(changes).toEqual(['kitchen', null, 'kitchen'])
827
+ })
828
+
829
+ test('closes at once when the view moves, and forgets the pointer', () => {
830
+ const { clock, reveal } = setup()
831
+ reveal.pick('bath')
832
+ clock.advance(REVEAL_DWELL_MS)
833
+ reveal.clear()
834
+ expect(reveal.active).toBeNull()
835
+ clock.advance(1000)
836
+ expect(reveal.active).toBeNull()
837
+ expect(clock.pending()).toBe(0)
838
+ })
839
+
840
+ test('fades out exactly the pills within the gap of its chip', () => {
841
+ const chip = { left: 0, top: 0, right: 100, bottom: 24 }
842
+ const rects = new Map([
843
+ ['under', { left: 50, top: 10, right: 80, bottom: 30 }],
844
+ ['touching', { left: 100 + PILL_GAP - 1, top: 0, right: 140, bottom: 20 }],
845
+ ['clear', { left: 100 + PILL_GAP, top: 0, right: 140, bottom: 20 }],
846
+ ['far', { left: 400, top: 400, right: 450, bottom: 420 }],
847
+ ])
848
+ expect(suppressedBy(chip, rects)).toEqual(new Set(['under', 'touching']))
849
+ })
357
850
  })