@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.
- package/README.md +106 -0
- package/package.json +39 -37
- package/src/components/editor/__bench__/room-label-placement.bench.ts +108 -0
- package/src/components/editor/bake-thumbnail.tsx +6 -15
- package/src/components/editor/construction-reveal-audio.test.tsx +150 -0
- package/src/components/editor/construction-reveal-audio.tsx +40 -0
- package/src/components/editor/custom-camera-controls.tsx +81 -8
- package/src/components/editor/editor-layout-mobile.tsx +1 -1
- package/src/components/editor/editor-layout-v2.tsx +1 -1
- package/src/components/editor/export-manager.tsx +14 -19
- package/src/components/editor/first-person/build-collider-world.test.ts +269 -0
- package/src/components/editor/first-person/build-collider-world.ts +22 -5
- package/src/components/editor/first-person/ground-holes.test.ts +128 -0
- package/src/components/editor/first-person/ground-holes.ts +130 -0
- package/src/components/editor/first-person/mechanism-targets.test.ts +89 -2
- package/src/components/editor/first-person/mechanism-targets.ts +52 -0
- package/src/components/editor/first-person-controls.tsx +19 -11
- package/src/components/editor/floor-aware-camera-controls.test.ts +24 -0
- package/src/components/editor/floorplan-mode-coordinator.tsx +2 -2
- package/src/components/editor/floorplan-panel.tsx +72 -23
- package/src/components/editor/follow-pascal.test.tsx +912 -0
- package/src/components/editor/follow-pascal.tsx +354 -0
- package/src/components/editor/group-actions.ts +2 -0
- package/src/components/editor/group-move-3d.ts +2 -0
- package/src/components/editor/group-rotate-handle.tsx +2 -0
- package/src/components/editor/group-transform-shared.test.ts +460 -0
- package/src/components/editor/index.tsx +166 -79
- package/src/components/editor/riser-diagram-panel.tsx +14 -7
- package/src/components/editor/room-highlight.tsx +13 -5
- package/src/components/editor/room-labels.test.ts +621 -128
- package/src/components/editor/room-labels.tsx +1090 -190
- package/src/components/editor/selection-manager.tsx +38 -117
- package/src/components/editor/slab-hole-highlights.tsx +2 -2
- package/src/components/editor/snapshot-capture-overlay.tsx +3 -2
- package/src/components/editor/snapshot-capture.test.ts +69 -58
- package/src/components/editor/snapshot-capture.ts +7 -1
- package/src/components/editor/thumbnail-generator.test.ts +350 -4
- package/src/components/editor/thumbnail-generator.tsx +128 -54
- package/src/components/editor/use-floorplan-background-placement.ts +3 -1
- package/src/components/editor/views/plugins-view.tsx +1 -8
- package/src/components/editor/views/view-bar.test.tsx +54 -0
- package/src/components/editor/views/view-bar.tsx +140 -17
- package/src/components/editor/wall-snap-beacon-layer.tsx +5 -4
- package/src/components/editor-2d/floorplan-group-move.tsx +3 -0
- package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +2 -2
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +3 -4
- package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.test.ts +1 -1
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +29 -7
- package/src/components/editor-2d/renderers/floorplan-reveal.test.tsx +254 -0
- package/src/components/editor-2d/renderers/floorplan-reveal.ts +141 -0
- package/src/components/point-ask/bubble.tsx +538 -0
- package/src/components/point-ask/hover-chip.tsx +91 -0
- package/src/components/point-ask/mode-pill.tsx +92 -0
- package/src/components/point-ask/optional-motion.ts +22 -0
- package/src/components/point-ask/outline-corners.tsx +93 -0
- package/src/components/point-ask/pins.tsx +711 -0
- package/src/components/point-ask/point-ask-layer.tsx +83 -0
- package/src/components/point-ask/point-ask-styles.ts +105 -0
- package/src/components/point-ask/region-marquee-styles.ts +15 -0
- package/src/components/point-ask/region-marquee.tsx +64 -0
- package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +12 -2
- package/src/components/systems/roof/roof-edit-system.tsx +1 -0
- package/src/components/tools/distribution/connection-compatibility.test.ts +34 -0
- package/src/components/tools/distribution/connection-compatibility.ts +43 -0
- package/src/components/tools/distribution/distribution-public.ts +9 -0
- package/src/components/tools/distribution/distribution-run-contract.test.ts +26 -0
- package/src/components/tools/distribution/distribution-run-contract.ts +94 -0
- package/src/components/tools/distribution/distribution-run-tool.test.ts +241 -0
- package/src/components/tools/distribution/distribution-run-tool.test.tsx +125 -0
- package/src/components/tools/distribution/distribution-run-tool.tsx +1323 -0
- package/src/components/tools/distribution/draw-alignment.ts +35 -0
- package/src/components/tools/distribution/ports.test.ts +56 -0
- package/src/components/tools/distribution/ports.ts +378 -0
- package/src/components/tools/distribution/run-cursor.test.ts +135 -0
- package/src/components/tools/distribution/run-cursor.ts +36 -0
- package/src/components/tools/distribution/run-direction-feedback.test.ts +60 -0
- package/src/components/tools/distribution/run-direction-feedback.tsx +251 -0
- package/src/components/tools/distribution/run-port-snap.test.ts +79 -0
- package/src/components/tools/distribution/run-port-snap.ts +25 -0
- package/src/components/tools/distribution/run-screen-direction.test.ts +23 -0
- package/src/components/tools/distribution/run-screen-direction.ts +30 -0
- package/src/components/tools/elevator/elevator-tool.tsx +3 -6
- package/src/components/tools/elevator/move-elevator-tool.tsx +2 -3
- package/src/components/tools/item/item-surface-strategy-resolver.test.ts +67 -68
- package/src/components/tools/item/placement-history.test.tsx +38 -0
- package/src/components/tools/item/placement-strategies.test.ts +46 -1
- package/src/components/tools/item/placement-strategies.ts +3 -6
- package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +56 -56
- package/src/components/tools/item/use-draft-node.test.tsx +94 -94
- package/src/components/tools/item/use-placement-coordinator.tsx +55 -49
- package/src/components/tools/registry/item-surface-move.test.ts +62 -63
- package/src/components/tools/registry/item-surface-move.ts +2 -0
- package/src/components/tools/registry/item-surface-resolver.test.ts +26 -26
- package/src/components/tools/registry/move-registry-node-tool.test.ts +268 -1
- package/src/components/tools/registry/move-registry-node-tool.tsx +63 -29
- package/src/components/tools/tool-manager.tsx +14 -5
- package/src/components/tools/wall/wall-drafting.test.ts +170 -57
- package/src/components/ui/action-menu/action-button.tsx +6 -1
- package/src/components/ui/action-menu/camera-actions.tsx +7 -3
- package/src/components/ui/action-menu/control-modes.tsx +13 -7
- package/src/components/ui/action-menu/follow-pascal-pill.tsx +32 -0
- package/src/components/ui/action-menu/follow-pascal-toggle.tsx +36 -0
- package/src/components/ui/action-menu/index.tsx +56 -33
- package/src/components/ui/action-menu/measurement-control.tsx +11 -9
- package/src/components/ui/action-menu/placement.ts +20 -0
- package/src/components/ui/action-menu/point-ask-button.tsx +52 -0
- package/src/components/ui/action-menu/view-toggles.tsx +47 -42
- package/src/components/ui/build-panel/build-panel.tsx +10 -3
- package/src/components/ui/command-palette/editor-commands.tsx +3 -12
- package/src/components/ui/command-palette/index.tsx +45 -15
- package/src/components/ui/command-palette/ranked-groups.test.ts +31 -0
- package/src/components/ui/controls/action-button.tsx +5 -2
- package/src/components/ui/controls/metric-control.tsx +1 -1
- package/src/components/ui/controls/segmented-control.tsx +21 -8
- package/src/components/ui/controls/select-control.tsx +1 -1
- package/src/components/ui/controls/slider-control.tsx +1 -1
- package/src/components/ui/controls/toggle-control.tsx +3 -3
- package/src/components/ui/controls/tool-options-panel.tsx +1 -1
- package/src/components/ui/floating-level-selector.tsx +150 -45
- package/src/components/ui/helpers/contextual-helper-panel.tsx +32 -7
- package/src/components/ui/helpers/helper-manager.test.tsx +54 -2
- package/src/components/ui/helpers/helper-manager.tsx +7 -1
- package/src/components/ui/helpers/item-helper.tsx +9 -1
- package/src/components/ui/panels/collections/collections-popover.tsx +8 -8
- package/src/components/ui/panels/mobile-panel-sheet.tsx +2 -2
- package/src/components/ui/panels/mobile-selection-bar.tsx +6 -3
- package/src/components/ui/panels/multi-selection-panel.tsx +120 -3
- package/src/components/ui/panels/panel-wrapper.tsx +53 -27
- package/src/components/ui/panels/parametric-field-control.tsx +2 -2
- package/src/components/ui/panels/parametric-inspector.tsx +3 -3
- package/src/components/ui/panels/room-panel.tsx +65 -0
- package/src/components/ui/primitives/badge.tsx +8 -0
- package/src/components/ui/primitives/sidebar.tsx +1 -1
- package/src/components/ui/right-stack.tsx +21 -2
- package/src/components/ui/sidebar/app-sidebar.tsx +1 -1
- package/src/components/ui/sidebar/icon-rail.tsx +3 -3
- package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +1 -1
- package/src/components/ui/sidebar/panels/items-panel/index.tsx +6 -6
- package/src/components/ui/sidebar/panels/settings-panel/animation-speed-setting.tsx +40 -0
- package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +7 -8
- package/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.test.tsx +170 -0
- package/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.tsx +92 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +107 -23
- package/src/components/ui/sidebar/panels/settings-panel/interface-theme.test.tsx +20 -0
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +4 -10
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -11
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +5 -12
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +13 -10
- package/src/components/ui/sidebar/panels/zone-panel/index.tsx +2 -2
- package/src/components/ui/sidebar/tab-bar.tsx +4 -4
- package/src/components/viewer/viewer-controls-bar.tsx +18 -6
- package/src/components/viewer/viewer-rooms.tsx +16 -0
- package/src/hooks/use-auto-save.integration.test.tsx +96 -38
- package/src/hooks/use-auto-save.ts +26 -1
- package/src/hooks/use-ceiling-events.test.ts +123 -118
- package/src/hooks/use-grid-events.test.ts +105 -105
- package/src/hooks/use-keyboard.test.ts +65 -16
- package/src/hooks/use-keyboard.ts +29 -12
- package/src/hooks/use-placement-notice.test.ts +24 -1
- package/src/hooks/use-placement-notice.ts +37 -6
- package/src/index.tsx +71 -24
- package/src/lib/__fixtures__/owner-victor-seattle-parking.json +1 -0
- package/src/lib/__fixtures__/owner-villa-aurelia-basement.json +1 -0
- package/src/lib/add-storey.test.ts +177 -0
- package/src/lib/add-storey.ts +59 -0
- package/src/lib/below-grade.test.tsx +171 -0
- package/src/lib/below-grade.ts +133 -0
- package/src/lib/camera-facing.test.ts +24 -0
- package/src/lib/camera-facing.ts +24 -0
- package/src/lib/direct-manipulation.ts +2 -0
- package/src/lib/download-project-file.ts +17 -0
- package/src/lib/editor-level-display.ts +9 -2
- package/src/lib/editor-views.ts +35 -1
- package/src/lib/export-texture-utils.ts +2 -4
- package/src/lib/floor-foundation-support.test.ts +1 -0
- package/src/lib/floorplan/floorplan-pdfkit-document.ts +31 -16
- package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +39 -0
- package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +4 -0
- package/src/lib/floorplan/pdfkit-standard-fonts.d.ts +5 -0
- package/src/lib/floorplan/sheet-export.test.ts +24 -0
- package/src/lib/floorplan/site-plan/build-site-plan-drawing.ts +34 -45
- package/src/lib/floorplan/site-plan/coverage.ts +2 -4
- package/src/lib/floorplan/site-plan/front-edge.ts +2 -1
- package/src/lib/floorplan/site-plan/geometry.test.ts +9 -15
- package/src/lib/floorplan/site-plan/geometry.ts +1 -416
- package/src/lib/floorplan/site-plan/index.ts +0 -16
- package/src/lib/floorplan/site-plan/site-annotations.test.ts +117 -0
- package/src/lib/floorplan/site-plan/site-annotations.ts +3 -2
- package/src/lib/floorplan/site-plan/site-parts.ts +14 -106
- package/src/lib/floorplan/site-plan/yards.test.ts +2 -1
- package/src/lib/follow-pascal-exterior.test-fixture.ts +17 -0
- package/src/lib/follow-pascal-exterior.test.ts +171 -0
- package/src/lib/follow-pascal-exterior.ts +39 -0
- package/src/lib/follow-pascal-presentation.test.ts +203 -0
- package/src/lib/follow-pascal-presentation.ts +129 -0
- package/src/lib/follow-pascal-sight.ts +84 -0
- package/src/lib/follow-pascal-targets.test.ts +406 -0
- package/src/lib/follow-pascal-targets.ts +198 -0
- package/src/lib/follow-pascal.test.ts +350 -0
- package/src/lib/follow-pascal.ts +284 -0
- package/src/lib/fresh-planar-placement.test.ts +112 -0
- package/src/lib/fresh-planar-placement.ts +57 -3
- package/src/lib/geometry-script/author.ts +6 -1
- package/src/lib/geometry-script/client.ts +8 -1
- package/src/lib/geometry-script/finish-scripts.ts +127 -0
- package/src/lib/geometry-script/protocol.ts +1 -0
- package/src/lib/glb-export.test.ts +187 -162
- package/src/lib/glb-export.ts +4 -1
- package/src/lib/history.test.ts +12 -7
- package/src/lib/interaction/scope.ts +4 -0
- package/src/lib/level-selection.test.ts +57 -0
- package/src/lib/level-selection.ts +16 -1
- package/src/lib/lot/clean-ring.test.ts +2 -1
- package/src/lib/lot/drop-in.ts +9 -24
- package/src/lib/lot/lot-patch.test.ts +1 -1
- package/src/lib/lot/lot-patch.ts +2 -2
- package/src/lib/measurement-kind.test.ts +6 -8
- package/src/lib/measurements.test.ts +23 -0
- package/src/lib/orbit-floor-clearance.test.ts +36 -0
- package/src/lib/orbit-floor-clearance.ts +48 -0
- package/src/lib/paint-pass-through.test.ts +11 -0
- package/src/lib/paint-pass-through.ts +4 -2
- package/src/lib/paint-scope.ts +1 -0
- package/src/lib/parametric-node-update.ts +16 -7
- package/src/lib/plugin-chat.ts +15 -0
- package/src/lib/point-ask/__fixtures__/consumer-browser.tsx +76 -0
- package/src/lib/point-ask/__fixtures__/house.ts +81 -0
- package/src/lib/point-ask/ask-created.test.ts +195 -0
- package/src/lib/point-ask/ask-flow.test.ts +380 -0
- package/src/lib/point-ask/build-context.test.ts +173 -0
- package/src/lib/point-ask/build-context.ts +94 -0
- package/src/lib/point-ask/capture-crop.ts +129 -0
- package/src/lib/point-ask/choreography.ts +69 -0
- package/src/lib/point-ask/compare.test.ts +145 -0
- package/src/lib/point-ask/compare.ts +73 -0
- package/src/lib/point-ask/describe-target.test.ts +165 -0
- package/src/lib/point-ask/describe-target.ts +234 -0
- package/src/lib/point-ask/dictation.ts +79 -0
- package/src/lib/point-ask/edge-marker.test.ts +61 -0
- package/src/lib/point-ask/edge-marker.ts +27 -0
- package/src/lib/point-ask/frame-loop.ts +25 -0
- package/src/lib/point-ask/history-marks.test.ts +94 -0
- package/src/lib/point-ask/history-marks.ts +39 -0
- package/src/lib/point-ask/host-api.ts +32 -0
- package/src/lib/point-ask/outline-box.test.ts +30 -0
- package/src/lib/point-ask/outline-box.ts +11 -0
- package/src/lib/point-ask/overlay-metrics.ts +6 -0
- package/src/lib/point-ask/pick.test.ts +220 -0
- package/src/lib/point-ask/pick.ts +121 -0
- package/src/lib/point-ask/pin-machine.test.ts +494 -0
- package/src/lib/point-ask/pin-machine.ts +217 -0
- package/src/lib/point-ask/placement.test.ts +203 -0
- package/src/lib/point-ask/placement.ts +141 -0
- package/src/lib/point-ask/pointer-tracker.ts +34 -0
- package/src/lib/point-ask/projector.ts +83 -0
- package/src/lib/point-ask/region.test.ts +157 -0
- package/src/lib/point-ask/region.ts +136 -0
- package/src/lib/point-ask/retry.test.ts +217 -0
- package/src/lib/point-ask/retry.ts +37 -0
- package/src/lib/point-ask/scene-commit.test.ts +312 -0
- package/src/lib/point-ask/scene-commit.ts +32 -0
- package/src/lib/point-ask/session.ts +1391 -0
- package/src/lib/point-ask/spring-keyframes.test.ts +45 -0
- package/src/lib/point-ask/spring-keyframes.ts +38 -0
- package/src/lib/point-ask/spring.test.ts +63 -0
- package/src/lib/point-ask/spring.ts +107 -0
- package/src/lib/point-ask/suggestions.test.ts +80 -0
- package/src/lib/point-ask/suggestions.ts +125 -0
- package/src/lib/point-ask/target-scope.test.ts +622 -0
- package/src/lib/point-ask/target-scope.ts +338 -0
- package/src/lib/point-ask/types.ts +48 -0
- package/src/lib/portable-export.test.ts +34 -1
- package/src/lib/portable-export.ts +119 -53
- package/src/lib/print-feature-thickness.ts +10 -1
- package/src/lib/print-roof-solids.test.ts +39 -1
- package/src/lib/print-roof-solids.ts +43 -10
- package/src/lib/print-shell-compiler-manifold-core.ts +1 -1
- package/src/lib/room-assembly-overlay.ts +2 -3
- package/src/lib/room-selection.test.ts +45 -1
- package/src/lib/scene-clipboard.test.ts +246 -1
- package/src/lib/scene-clipboard.ts +44 -14
- package/src/lib/selection-routing.ts +1 -1
- package/src/lib/sfx-bus.ts +13 -0
- package/src/lib/sfx-player.test.ts +70 -9
- package/src/lib/sfx-player.ts +6 -0
- package/src/lib/stair-levels.test.ts +25 -0
- package/src/lib/stair-levels.ts +22 -3
- package/src/lib/terrace-draft.ts +2 -1
- package/src/lib/viewer-selection.ts +22 -11
- package/src/lib/wall-placement-pointer.test.ts +42 -0
- package/src/lib/wall-placement-pointer.ts +57 -0
- package/src/lib/window-changes.ts +35 -0
- package/src/store/use-audio.test.ts +146 -0
- package/src/store/use-audio.tsx +127 -3
- package/src/store/use-construction-reveal.test.ts +173 -0
- package/src/store/use-construction-reveal.ts +145 -0
- package/src/store/use-corner-snap-hint.test.ts +24 -0
- package/src/store/use-corner-snap-hint.ts +47 -0
- package/src/store/use-follow-pascal.test.ts +62 -0
- package/src/store/use-follow-pascal.ts +53 -0
- package/src/store/use-point-ask.ts +247 -0
- package/src/store/use-stair-build-preview.ts +25 -5
- package/src/store/view-layout.test.ts +27 -1
- package/src/store/view-layout.ts +4 -1
- package/src/test-utils/render-current-markup.tsx +16 -0
- package/src/components/tools/stair/stair-click-guard.test.ts +0 -58
- package/src/components/tools/stair/stair-click-guard.ts +0 -72
- package/src/components/tools/stair/stair-defaults.ts +0 -15
- 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
|
-
|
|
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
|
|
67
|
-
const
|
|
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:
|
|
70
|
-
width: '
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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.
|
|
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
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
|
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
|
-
{
|
|
124
|
-
{
|
|
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({
|
|
133
|
-
|
|
134
|
-
expect(
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
//
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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({
|
|
174
|
-
})
|
|
175
|
-
|
|
176
|
-
test('
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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(
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
194
|
-
|
|
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
|
|
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
|
-
|
|
261
|
-
|
|
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,
|
|
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) => ({
|
|
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
|
|
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
|
-
|
|
535
|
+
placements([
|
|
278
536
|
['near', full(-8)],
|
|
279
|
-
['far', {
|
|
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(
|
|
542
|
+
expect(elements.get('far')!.dataset.roomLabelTier).toBe('dot')
|
|
285
543
|
placer.apply(
|
|
286
|
-
|
|
544
|
+
placements([
|
|
287
545
|
['near', full()],
|
|
288
|
-
['far',
|
|
546
|
+
['far', { tier: 'name', dy: 0 }],
|
|
289
547
|
]),
|
|
290
548
|
180,
|
|
291
549
|
)
|
|
292
550
|
expect(elements.get('near')!.style.translate).toBe('')
|
|
293
|
-
expect(
|
|
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,
|
|
563
|
+
const { elements, clock, changes, placer } = setup()
|
|
298
564
|
placer.apply(
|
|
299
|
-
|
|
565
|
+
placements([
|
|
300
566
|
['near', full()],
|
|
301
|
-
['far',
|
|
567
|
+
['far', hidden],
|
|
302
568
|
]),
|
|
303
569
|
180,
|
|
304
570
|
)
|
|
305
571
|
expect(has(elements.get('far'), 'roomLabelHidden')).toBe(true)
|
|
306
|
-
|
|
572
|
+
clock.advance(179)
|
|
307
573
|
expect(changes).toEqual([])
|
|
308
|
-
|
|
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,
|
|
314
|
-
placer.apply(
|
|
315
|
-
|
|
316
|
-
const
|
|
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(
|
|
584
|
+
placer.apply(placements([['far', full(12)]]), 180)
|
|
319
585
|
expect(placer.gone).toEqual(new Set())
|
|
320
|
-
placer.mount('far',
|
|
321
|
-
expect(
|
|
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
|
-
|
|
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
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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,
|
|
335
|
-
placer.apply(
|
|
336
|
-
|
|
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 =
|
|
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(
|
|
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(
|
|
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
|
})
|