fix: make nightdrive signage reliable and legible
@@ -0,0 +1,118 @@
|
|||||||
|
# Nightdrive sign passing and navigator evidence
|
||||||
|
|
||||||
|
Status date: 2026-09-03
|
||||||
|
|
||||||
|
This directory records the final bounded Three.js roadside-sign continuation. It supersedes the earlier sign-pass evidence for current navigator triggering, route corridors, live resize behavior, and departure motion. The approved portfolio content, navigation presentation, HUD, controls, car, road, terrain, sun, atmosphere, camera, and forward-travel direction were not redesigned.
|
||||||
|
|
||||||
|
## Shared causes found
|
||||||
|
|
||||||
|
The remaining defects had related state, projection, and rendering causes:
|
||||||
|
|
||||||
|
1. The navigation controller described every link jump only as generic explicit navigation. That correctly suppressed skip links, direct fragments, restoration, and the hero shortcut, but gave the sign controller no way to distinguish an intentional navigator destination.
|
||||||
|
2. A navigation source persists for several render frames. Consuming that source directly would duplicate a sign, so navigator intent needed a monotonic epoch and a single canonical destination.
|
||||||
|
3. Passive trigger distance was shared across very different document layouts. In portrait and short landscape, the usable world corridor occurs in a narrower inter-panel interval, so the old threshold could latch while an opaque panel still occupied the sign's dwell region.
|
||||||
|
4. Placement considered the destination panel but not every opaque panel still visible during a transition. Experience, Additional work, and Contact could therefore clear their incoming card while touching the outgoing Profile, Selected builds, or Toolkit surface.
|
||||||
|
5. A quality-tier rebuild could reconstruct the renderer with the lifecycle's older responsive profile. The next update then moved a visible sign abruptly. Projection must be owned by the current renderer viewport, with a bounded live reflow.
|
||||||
|
6. The sign face needed more than a larger bitmap: the source aspect, UVs, font readiness, color space, mipmap path, anisotropy, tone mapping, bloom exposure, and projected dwell size all affected perceived sharpness.
|
||||||
|
7. Departure changed opacity more than physical pose. It read as a slide-deck fade because the sign neither grew in perspective nor crossed the roadside edge as the car passed it.
|
||||||
|
8. A full-resolution evidence review exposed one final route-specific issue: the rapid Selected builds reconciliation used the outer-right pose and clipped 13px beyond the viewport in the mobile browser profile. Moving every outer-right sign inward then touched Toolkit before Contact. The final fix assigns Selected builds to the inner-right corridor while retaining Contact's genuinely necessary outer-right placement.
|
||||||
|
9. Traversal state could race a still-departing sign: a stale completion after returning to Start could disqualify the next Profile traversal, while the new same-label Profile candidate could be dropped behind the old presentation. Visible signs now carry their traversal generation, and only passive same-label work from a newer traversal may become the single pending target. A separate destination-idempotency guard makes another click on the already-active navigator target a no-op even after its first sign has completed.
|
||||||
|
10. Reverse re-arming originally removed only the immediate candidate from the completion ledger. A multi-section retreat therefore left later routes ineligible, and a still-departing old sign could write its route back after the retreat. A qualifying retreat now advances the traversal generation and re-arms the candidate plus every later route; cooldown state retains its generation so a quick forward return cannot be discarded.
|
||||||
|
11. A passive portrait sign could already be readable when its matching navigator button was clicked. Keeping the passive pose would place it behind the newly aligned full-width card, while relatching it would duplicate the label. The controller now promotes the existing presentation to a navigator arrival, and the renderer moves it to the portrait-safe pose over the same bounded 320ms pose transition used for resize.
|
||||||
|
12. Promotion initially preserved the passive dwell clock. Under a slow navigation alignment, part of the guaranteed readable interval could therefore elapse while the sign was still moving into the navigator-safe corridor. Promotion now starts a fresh 1,400ms dwell from the navigator sample while retaining the same object and presentation count.
|
||||||
|
|
||||||
|
## Canonical candidacy and placement map
|
||||||
|
|
||||||
|
Passive candidates require motion to be enabled, a forward document direction, positive scroll delta, and the next eligible route. The ordinary trigger is at most 0.55 viewport heights from the activation line on wide layouts, 0.23vh on portrait layouts, and 0.38vh on short landscape. Crossing a real section boundary remains authoritative and reconciles directly to the eligible route reached, including large and multi-section jumps.
|
||||||
|
|
||||||
|
Navigator buttons add one explicit sign intent to their navigation epoch. That intent selects the canonical destination directly rather than waiting for a passive threshold. Repeated render frames and another click on the same already-active destination cannot duplicate it; after the visitor moves elsewhere, a later navigator return is a new eligible arrival. A navigator target queued behind a readable sign bypasses the passive completed-route filter, and returning to the currently visible label cancels any obsolete queued destination. Start remains ineligible.
|
||||||
|
|
||||||
|
| Route target | Face | Marker | Readable corridor | Safe-placement intent |
|
||||||
|
| --- | --- | ---: | --- | --- |
|
||||||
|
| `start` | Start | 01 | none | deliberately ineligible |
|
||||||
|
| `profile` | Profile | 02 | left | opposite the right Profile card |
|
||||||
|
| `experience` | Experience | 03 | left | clears both the outgoing Profile card and incoming Career surface |
|
||||||
|
| `infrastructure` | Infrastructure | 04 | outer-right | outside the broad systems-content corridor, with a route-owned 1.6-world-unit inward dwell bias to preserve the viewport edge |
|
||||||
|
| `selected-builds` | Selected builds | 05 | right | inside the visible right roadside corridor, clear of the large left project card |
|
||||||
|
| `additional-work` | Additional work | 06 | outer-left | outside the outgoing centered Selected builds surface |
|
||||||
|
| `toolkit` | Toolkit | 07 | outer-left | outside the wide toolkit content corridor |
|
||||||
|
| `contact` | Contact | 08 | outer-right | clears the still-visible wide Toolkit surface |
|
||||||
|
|
||||||
|
Every eligible route approaches from its corridor's horizon pose, settles at the responsive dwell pose, then travels toward and past the chase camera on the same roadside. Wide, tall, and short pose families are derived from the current renderer viewport. Short landscape receives aspect-bounded lateral relief. A navigator arrival on a narrow portrait viewport uses a compact, terrain-planted near-car pose in the stable gap between the destination and following panels. If navigation promotes an already-readable passive sign, the same object transitions to that safe pose without a label redraw, a second presentation, or a one-frame teleport.
|
||||||
|
|
||||||
|
Browser projection tests exercise all seven passive transitions at 1440×900, 390×844, and 844×390. During dwell, the complete face must remain at least 8px inside the viewport, 16px clear of every visible opaque content surface, and 8px clear of navigation, HUD, and controls. Navigator-triggered Profile placement is separately measured at 780×493 and 390×844.
|
||||||
|
|
||||||
|
## Lifecycle and retarget contract
|
||||||
|
|
||||||
|
| Phase | Duration | Contract |
|
||||||
|
| --- | ---: | --- |
|
||||||
|
| Anticipate | 300ms | latch one canonical target; remain concealed at the route horizon |
|
||||||
|
| Approach | 650–900ms | elapsed-time, frame-rate-independent horizon-to-dwell travel; acceleration can shorten by at most 250ms |
|
||||||
|
| Dwell | at least 1,400ms | readable from the first actual dwell frame; reverse, target passing, delayed frames, and newer candidates cannot shorten it |
|
||||||
|
| Depart | 700ms | constant world scale; positive-z movement toward the chase camera plus a roadside sweep; opacity stays full through progress 0.58 and falls off only during the edge pass |
|
||||||
|
| Cooldown | 350ms | suppress immediate replay while retaining only the latest valid pending destination |
|
||||||
|
|
||||||
|
The controller owns one visible sign and one optional pending target. A concealed sign may retarget immediately. A visible sign never changes label. A readable stale sign completes its minimum dwell and departs cleanly; only the latest relevant pending destination follows. If navigation returns to the currently visible target, that intent cancels an obsolete queued destination instead of replaying either label. A presentation may complete only into the traversal generation in which it was latched, so an older departure cannot poison the new traversal's completed-route ledger. Large jumps do not flash or queue obsolete intermediate labels. Reverse document input never reverses physical travel through the car. Live lifecycle sampling continues without new input. Delayed frames enter dwell and start a fresh minimum dwell clock rather than skipping the readable state.
|
||||||
|
|
||||||
|
Completed passive routes stay armed-off through small oscillations. Retreat beyond the responsive trigger plus 0.20vh rearm margin starts a new traversal and re-arms that candidate and every later route. A navigator jump to Start, an unrequested explicit jump, or history restoration starts a fresh passive traversal without displaying a suppressed sign or allowing an older departure to re-complete the new ledger.
|
||||||
|
|
||||||
|
Initialization, direct fragments, history restoration, skip links, the non-navigator “Explore work” jump, Static mode, reduced motion, and hidden-document resets remain sign-suppressed. Navigator buttons are the sole explicit-navigation path that requests a destination sign.
|
||||||
|
|
||||||
|
## Physical departure evidence
|
||||||
|
|
||||||
|
At 1440×900, the Profile dwell face measured 211.09px wide. During departure it remained fully opaque while growing to 410.98px at progress 0.500 and sweeping left from 402.91px to 71.74px. At progress 0.643 it crossed the roadside edge (left −342.88px, width 675.49px) and only then began its restrained falloff (opacity 0.920). This produces the visual event of the car overtaking a fixed roadside object instead of fading a card in place.
|
||||||
|
|
||||||
|
## Text and runtime-owned geometry
|
||||||
|
|
||||||
|
The face is an exact 4:1 plane backed by a 2048×512 sRGB canvas texture, trilinear mipmaps, linear magnification, premultiplied alpha, and up to 8× renderer-supported anisotropy. The bundled Geist Mono marker and label faces are awaited before the first draw. The face bypasses tone mapping and is inset ahead of a separate panel to avoid bloom contamination and z-fighting. Labels redraw and upload only while concealed; there is no per-frame canvas work or texture replacement.
|
||||||
|
|
||||||
|
The refined housing uses a clipped beveled body, inset backplate and face panel, rear rails, restrained edge and route accents, two narrow posts, paired braces, fasteners, and separate planted footings. The sign owns 20 renderable parts through 13 geometries, 7 materials, and 1 texture. Those objects are created once per world generation and disposed through the scene resource bag; lifecycle updates do not rebuild the hierarchy or traverse it.
|
||||||
|
|
||||||
|
## Measured browser matrix
|
||||||
|
|
||||||
|
All recorded dwell frames reported a 2048×512 texture, 8× anisotropy, a browser maximum texture size of 8192, and `fontReadyWhenDrawn: true`.
|
||||||
|
|
||||||
|
| Scenario | CSS face width | Renderer ratio | Source texels per rendered face pixel |
|
||||||
|
| --- | ---: | ---: | ---: |
|
||||||
|
| 390×844 portrait, device DPR 3 / Low | 162.10px | 1.25 | 10.11 |
|
||||||
|
| 844×390 short landscape, DPR 1 / High | 151.31px | 1 | 13.54 |
|
||||||
|
| 768×1024 tablet, DPR 1 / High | 196.67px | 1 | 10.41 |
|
||||||
|
| 1440×900 desktop, DPR 1 / High | 211.09px | 1 | 9.70 |
|
||||||
|
| 1440×900 desktop, device DPR 2 / High | 211.07px | 1.5 | 6.47 |
|
||||||
|
| 1440×900 desktop, DPR 1 / Low | 211.09px | 1 | 9.70 |
|
||||||
|
| 2560×1440 ultrawide, DPR 1 / High | 337.72px | 1 | 6.06 |
|
||||||
|
|
||||||
|
The ratio is the 2,048 source width divided by CSS face width × renderer pixel ratio. It records sampling headroom rather than claiming an unavailable GPU-inspector metric. Low and High retain identical sign typography and geometry. The renderer ratio remains tier-capped independently of the source texture, including device DPR 3.
|
||||||
|
|
||||||
|
## Evidence index
|
||||||
|
|
||||||
|
- Supplied overlap baseline: [`before/profile-overlap-1280x720-dpr1.25.png`](before/profile-overlap-1280x720-dpr1.25.png)
|
||||||
|
- Corrected desktop sides: [`after/profile-desktop-1440x900-dpr1-high.png`](after/profile-desktop-1440x900-dpr1-high.png), [`after/experience-desktop-1440x900-dpr1-high.png`](after/experience-desktop-1440x900-dpr1-high.png)
|
||||||
|
- Navigator arrival: [`after/navigator-profile-780x493-dwell.png`](after/navigator-profile-780x493-dwell.png), [`after/navigator-profile-portrait-390x844-dpr3-dwell.png`](after/navigator-profile-portrait-390x844-dpr3-dwell.png)
|
||||||
|
- Responsive families: [`after/profile-portrait-390x844-dpr3-low.png`](after/profile-portrait-390x844-dpr3-low.png), [`after/profile-short-844x390-dpr1-high.png`](after/profile-short-844x390-dpr1-high.png), [`after/profile-tablet-768x1024-dpr1-high.png`](after/profile-tablet-768x1024-dpr1-high.png), [`after/profile-ultrawide-2560x1440-dpr1-high.png`](after/profile-ultrawide-2560x1440-dpr1-high.png)
|
||||||
|
- DPR and quality: [`after/profile-desktop-1440x900-dpr2-high.png`](after/profile-desktop-1440x900-dpr2-high.png), [`after/profile-desktop-1440x900-dpr1-low.png`](after/profile-desktop-1440x900-dpr1-low.png)
|
||||||
|
- Lifecycle: [`lifecycle/profile-anticipate.png`](lifecycle/profile-anticipate.png), [`lifecycle/profile-approach.png`](lifecycle/profile-approach.png), [`lifecycle/profile-dwell.png`](lifecycle/profile-dwell.png), [`lifecycle/profile-depart-early.png`](lifecycle/profile-depart-early.png), [`lifecycle/profile-depart-passing.png`](lifecycle/profile-depart-passing.png), [`lifecycle/profile-depart-edge.png`](lifecycle/profile-depart-edge.png)
|
||||||
|
- Adverse input: [`lifecycle/slow-scroll-dwell.png`](lifecycle/slow-scroll-dwell.png), [`lifecycle/repeated-tiny-input-dwell.png`](lifecycle/repeated-tiny-input-dwell.png), [`lifecycle/reverse-during-approach.png`](lifecycle/reverse-during-approach.png), [`lifecycle/reverse-during-dwell.png`](lifecycle/reverse-during-dwell.png), [`lifecycle/rapid-multi-section-reconciliation.png`](lifecycle/rapid-multi-section-reconciliation.png), [`lifecycle/delayed-frame-readable-dwell.png`](lifecycle/delayed-frame-readable-dwell.png), [`lifecycle/keyboard-scroll-dwell.png`](lifecycle/keyboard-scroll-dwell.png)
|
||||||
|
- Visible resize: [`lifecycle/resize-visible-before-1440x900.png`](lifecycle/resize-visible-before-1440x900.png), [`lifecycle/resize-visible-after-844x390.png`](lifecycle/resize-visible-after-844x390.png)
|
||||||
|
- Suppressed modes: [`after/static-mode-1440x900.png`](after/static-mode-1440x900.png), [`after/reduced-motion-390x844-dpr3.png`](after/reduced-motion-390x844-dpr3.png)
|
||||||
|
- Machine-readable results: [`browser-matrix.json`](browser-matrix.json), [`lifecycle/trace.json`](lifecycle/trace.json), [`browser-console.json`](browser-console.json)
|
||||||
|
|
||||||
|
The lifecycle trace records candidate/latched target outcomes, phase, opacity, projected bounds, visible duration, active section, direction, navigator intent, multi-section reconciliation, and resize state. The navigator scenario presents Profile exactly once and draws only `Profile`; the rapid scenario reconciles directly to Selected builds in the right corridor, with final bounds 857.27–1056.10px; no visible label mutates.
|
||||||
|
|
||||||
|
## Fresh verification record
|
||||||
|
|
||||||
|
- `npm run build:preview`: passed asset verification, type checking, 43 unit files / 566 tests, client and SSR builds, prerendering, artifact validation, and the transfer gate.
|
||||||
|
- Focused controller/navigation/drive/world verification: 4 files / 117 tests passed.
|
||||||
|
- Focused browser reflow verification: the already-readable portrait navigator promotion passed in 4.5s, and the responsive world-safe-corridor test passed in 16.8s, unchanged and with one worker.
|
||||||
|
- Complete browser matrix: the first frozen run produced 189 passes, 48 intentional project skips, and one parallel-load Toolkit→Contact margin sample; the second produced 188 passes, the same 48 skips, a different responsive-sampling timeout, and an unrelated hydration sample that caught the transient `Motion: preparing` status. The all-seven-transition test passed in the second parallel run and also passed unchanged alone in 39.9s. The responsive sign test passed unchanged alone in 16.8s, and hydration passed unchanged alone in 1.8s. No assertion or timeout was changed.
|
||||||
|
- Authenticated runtime gate: passed at High, renderer ratio 1.5, with 5,608,790 texture bytes, 755,916 geometry bytes, 161,344,784 render-target bytes, 33,554,432 runtime-allowance bytes, and 201,263,922 total accounted bytes.
|
||||||
|
- Transfer gate: passed with 21,499 bytes initial HTML/critical CSS, 80,974 bytes initial application JavaScript, 69,040 bytes initial fonts, and 223,856 bytes first live scene; zero violations.
|
||||||
|
- Deployment suite: 7/9 passed. Its two failures are the checked-in execution-security evidence expiring before the run date, before sign or portfolio behavior is evaluated.
|
||||||
|
- Long-drive performance harness: no valid measurement. Desktop reached its fixed 75s timeout while locating the visible Toolkit navigation target; mobile did not leave scene loading before its five-second deadline and correctly selected fallback. No metric is claimed and no harness threshold was changed.
|
||||||
|
- [`browser-console.json`](browser-console.json): empty error list.
|
||||||
|
|
||||||
|
## Blender decision and ownership
|
||||||
|
|
||||||
|
Another GLB is not justified. The sign's finite construction is lightweight, while its label, candidacy, lifecycle, projection, responsive pose, and quality behavior must remain runtime-owned. Keeping the whole sign in stable Three.js geometry avoids another asset transfer and keeps dynamic text separate by construction.
|
||||||
|
|
||||||
|
The finite authored grand-tourer remains Blender-owned and unchanged. Dynamic text, sign behavior, responsive placement, road, terrain, atmosphere, sun, camera, and motion remain Three.js-owned.
|
||||||
|
After Width: | Height: | Size: 320 KiB |
|
After Width: | Height: | Size: 139 KiB |
|
After Width: | Height: | Size: 258 KiB |
|
After Width: | Height: | Size: 348 KiB |
|
After Width: | Height: | Size: 193 KiB |
|
After Width: | Height: | Size: 990 KiB |
|
After Width: | Height: | Size: 245 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 210 KiB |
|
After Width: | Height: | Size: 595 KiB |
|
After Width: | Height: | Size: 748 KiB |
|
After Width: | Height: | Size: 426 KiB |
|
After Width: | Height: | Size: 67 KiB |
@@ -0,0 +1,113 @@
|
|||||||
|
{
|
||||||
|
"generatedAt": "2026-09-03T12:01:53.288Z",
|
||||||
|
"errors": [
|
||||||
|
{
|
||||||
|
"scenario": "profile-desktop-1440x900-dpr1-high",
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "experience-desktop-1440x900-dpr1-high",
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "profile-portrait-390x844-dpr3-low",
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "profile-short-844x390-dpr1-high",
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "profile-tablet-768x1024-dpr1-high",
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "profile-ultrawide-2560x1440-dpr1-high",
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "profile-desktop-1440x900-dpr2-high",
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "profile-desktop-1440x900-dpr1-low",
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "profile-anticipate",
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "profile-approach",
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "profile-dwell",
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "profile-depart-early",
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "profile-depart-passing",
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "profile-depart-edge",
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "lifecycle",
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "navigator-click",
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "navigator-click-portrait",
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "slow-scroll",
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "repeated-tiny-input",
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "reverse-approach",
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "reverse-dwell",
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "rapid-jump",
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "delayed-frame",
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "visible-resize",
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "keyboard-scroll",
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "static",
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "reduced-motion",
|
||||||
|
"errors": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,557 @@
|
|||||||
|
{
|
||||||
|
"generatedAt": "2026-09-03T12:01:53.287Z",
|
||||||
|
"scenarios": [
|
||||||
|
{
|
||||||
|
"name": "profile-desktop-1440x900-dpr1-high",
|
||||||
|
"width": 1440,
|
||||||
|
"height": 900,
|
||||||
|
"dpr": 1,
|
||||||
|
"tier": "high",
|
||||||
|
"targetId": "profile",
|
||||||
|
"frame": {
|
||||||
|
"now": 3077.3,
|
||||||
|
"targetId": "profile",
|
||||||
|
"phase": "dwell",
|
||||||
|
"progress": 1,
|
||||||
|
"opacity": 1,
|
||||||
|
"viewportProfile": "wide",
|
||||||
|
"activeSectionId": "start",
|
||||||
|
"direction": 0,
|
||||||
|
"visible": true,
|
||||||
|
"corridor": "left",
|
||||||
|
"bounds": {
|
||||||
|
"left": 402.9099204145575,
|
||||||
|
"top": 418.7729846317115,
|
||||||
|
"right": 613.9982107225217,
|
||||||
|
"bottom": 470.8324692196562,
|
||||||
|
"width": 211.08829030796414,
|
||||||
|
"height": 52.05948458794472
|
||||||
|
},
|
||||||
|
"texture": {
|
||||||
|
"width": 2048,
|
||||||
|
"height": 512,
|
||||||
|
"maximumSize": 8192,
|
||||||
|
"anisotropy": 8,
|
||||||
|
"fontReadyWhenDrawn": true
|
||||||
|
},
|
||||||
|
"rendererPixelRatio": 1,
|
||||||
|
"viewport": {
|
||||||
|
"width": 1440,
|
||||||
|
"height": 900
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"geometry": {
|
||||||
|
"panel": {
|
||||||
|
"left": 777.59375,
|
||||||
|
"top": 681,
|
||||||
|
"right": 1382.390625,
|
||||||
|
"bottom": 1139.28125
|
||||||
|
},
|
||||||
|
"fixedZones": [
|
||||||
|
{
|
||||||
|
"left": 0,
|
||||||
|
"top": 0,
|
||||||
|
"right": 1440,
|
||||||
|
"bottom": 53
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"left": 12,
|
||||||
|
"top": 844,
|
||||||
|
"right": 262.078125,
|
||||||
|
"bottom": 888
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"left": 1275.5625,
|
||||||
|
"top": 844,
|
||||||
|
"right": 1428,
|
||||||
|
"bottom": 888
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sourceTexelsPerRenderedPixel": 9.702101414588657
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "experience-desktop-1440x900-dpr1-high",
|
||||||
|
"width": 1440,
|
||||||
|
"height": 900,
|
||||||
|
"dpr": 1,
|
||||||
|
"tier": "high",
|
||||||
|
"targetId": "experience",
|
||||||
|
"frame": {
|
||||||
|
"now": 2511.7,
|
||||||
|
"targetId": "experience",
|
||||||
|
"phase": "dwell",
|
||||||
|
"progress": 1,
|
||||||
|
"opacity": 1,
|
||||||
|
"viewportProfile": "wide",
|
||||||
|
"activeSectionId": "profile",
|
||||||
|
"direction": 0,
|
||||||
|
"visible": true,
|
||||||
|
"corridor": "left",
|
||||||
|
"bounds": {
|
||||||
|
"left": 402.9087500447251,
|
||||||
|
"top": 418.75799808412745,
|
||||||
|
"right": 613.9986358424793,
|
||||||
|
"bottom": 470.817578359102,
|
||||||
|
"width": 211.08988579775422,
|
||||||
|
"height": 52.059580274974564
|
||||||
|
},
|
||||||
|
"texture": {
|
||||||
|
"width": 2048,
|
||||||
|
"height": 512,
|
||||||
|
"maximumSize": 8192,
|
||||||
|
"anisotropy": 8,
|
||||||
|
"fontReadyWhenDrawn": true
|
||||||
|
},
|
||||||
|
"rendererPixelRatio": 1,
|
||||||
|
"viewport": {
|
||||||
|
"width": 1440,
|
||||||
|
"height": 900
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"geometry": {
|
||||||
|
"panel": {
|
||||||
|
"left": 57.59375,
|
||||||
|
"top": 680.28125,
|
||||||
|
"right": 719.984375,
|
||||||
|
"bottom": 1830.875
|
||||||
|
},
|
||||||
|
"fixedZones": [
|
||||||
|
{
|
||||||
|
"left": 0,
|
||||||
|
"top": 0,
|
||||||
|
"right": 1440,
|
||||||
|
"bottom": 53
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"left": 12,
|
||||||
|
"top": 844,
|
||||||
|
"right": 262.078125,
|
||||||
|
"bottom": 888
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"left": 1251.796875,
|
||||||
|
"top": 844,
|
||||||
|
"right": 1428,
|
||||||
|
"bottom": 888
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sourceTexelsPerRenderedPixel": 9.702028082776994
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "profile-portrait-390x844-dpr3-low",
|
||||||
|
"width": 390,
|
||||||
|
"height": 844,
|
||||||
|
"dpr": 3,
|
||||||
|
"tier": "low",
|
||||||
|
"targetId": "profile",
|
||||||
|
"frame": {
|
||||||
|
"now": 1811.6,
|
||||||
|
"targetId": "profile",
|
||||||
|
"phase": "dwell",
|
||||||
|
"progress": 1,
|
||||||
|
"opacity": 1,
|
||||||
|
"viewportProfile": "tall",
|
||||||
|
"activeSectionId": "start",
|
||||||
|
"direction": 0,
|
||||||
|
"visible": true,
|
||||||
|
"corridor": "left",
|
||||||
|
"bounds": {
|
||||||
|
"left": 36.41235262317512,
|
||||||
|
"top": 391.66234381308163,
|
||||||
|
"right": 198.51070849895177,
|
||||||
|
"bottom": 432.3830059465598,
|
||||||
|
"width": 162.09835587577666,
|
||||||
|
"height": 40.720662133478186
|
||||||
|
},
|
||||||
|
"texture": {
|
||||||
|
"width": 2048,
|
||||||
|
"height": 512,
|
||||||
|
"maximumSize": 8192,
|
||||||
|
"anisotropy": 8,
|
||||||
|
"fontReadyWhenDrawn": true
|
||||||
|
},
|
||||||
|
"rendererPixelRatio": 1.25,
|
||||||
|
"viewport": {
|
||||||
|
"width": 390,
|
||||||
|
"height": 844
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"geometry": {
|
||||||
|
"panel": {
|
||||||
|
"left": 16,
|
||||||
|
"top": 461,
|
||||||
|
"right": 374,
|
||||||
|
"bottom": 997.109375
|
||||||
|
},
|
||||||
|
"fixedZones": [
|
||||||
|
{
|
||||||
|
"left": 0,
|
||||||
|
"top": 0,
|
||||||
|
"right": 390,
|
||||||
|
"bottom": 53
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"left": 8,
|
||||||
|
"top": 792,
|
||||||
|
"right": 213.890625,
|
||||||
|
"bottom": 836
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"left": 305.375,
|
||||||
|
"top": 792,
|
||||||
|
"right": 382,
|
||||||
|
"bottom": 836
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sourceTexelsPerRenderedPixel": 10.107443663744379
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "profile-short-844x390-dpr1-high",
|
||||||
|
"width": 844,
|
||||||
|
"height": 390,
|
||||||
|
"dpr": 1,
|
||||||
|
"tier": "high",
|
||||||
|
"targetId": "profile",
|
||||||
|
"frame": {
|
||||||
|
"now": 1964.2,
|
||||||
|
"targetId": "profile",
|
||||||
|
"phase": "dwell",
|
||||||
|
"progress": 1,
|
||||||
|
"opacity": 1,
|
||||||
|
"viewportProfile": "short",
|
||||||
|
"activeSectionId": "start",
|
||||||
|
"direction": 0,
|
||||||
|
"visible": true,
|
||||||
|
"corridor": "left",
|
||||||
|
"bounds": {
|
||||||
|
"left": 83.0504359770391,
|
||||||
|
"top": 190.7715027229274,
|
||||||
|
"right": 234.36132780345383,
|
||||||
|
"bottom": 226.177162894483,
|
||||||
|
"width": 151.31089182641472,
|
||||||
|
"height": 35.40566017155558
|
||||||
|
},
|
||||||
|
"texture": {
|
||||||
|
"width": 2048,
|
||||||
|
"height": 512,
|
||||||
|
"maximumSize": 8192,
|
||||||
|
"anisotropy": 8,
|
||||||
|
"fontReadyWhenDrawn": true
|
||||||
|
},
|
||||||
|
"rendererPixelRatio": 1,
|
||||||
|
"viewport": {
|
||||||
|
"width": 844,
|
||||||
|
"height": 390
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"geometry": {
|
||||||
|
"panel": {
|
||||||
|
"left": 303.828125,
|
||||||
|
"top": 259.03125,
|
||||||
|
"right": 810.21875,
|
||||||
|
"bottom": 673.40625
|
||||||
|
},
|
||||||
|
"fixedZones": [
|
||||||
|
{
|
||||||
|
"left": 0,
|
||||||
|
"top": 0,
|
||||||
|
"right": 844,
|
||||||
|
"bottom": 53
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"left": 12,
|
||||||
|
"top": 8465,
|
||||||
|
"right": 262.078125,
|
||||||
|
"bottom": 8509
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"left": 0,
|
||||||
|
"top": 0,
|
||||||
|
"right": 0,
|
||||||
|
"bottom": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sourceTexelsPerRenderedPixel": 13.535046785326498
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "profile-tablet-768x1024-dpr1-high",
|
||||||
|
"width": 768,
|
||||||
|
"height": 1024,
|
||||||
|
"dpr": 1,
|
||||||
|
"tier": "high",
|
||||||
|
"targetId": "profile",
|
||||||
|
"frame": {
|
||||||
|
"now": 2196,
|
||||||
|
"targetId": "profile",
|
||||||
|
"phase": "dwell",
|
||||||
|
"progress": 1,
|
||||||
|
"opacity": 1,
|
||||||
|
"viewportProfile": "tall",
|
||||||
|
"activeSectionId": "start",
|
||||||
|
"direction": 0,
|
||||||
|
"visible": true,
|
||||||
|
"corridor": "left",
|
||||||
|
"bounds": {
|
||||||
|
"left": 191.5893710701102,
|
||||||
|
"top": 475.18004358029873,
|
||||||
|
"right": 388.25854417015336,
|
||||||
|
"bottom": 524.5852618542048,
|
||||||
|
"width": 196.66917310004317,
|
||||||
|
"height": 49.40521827390603
|
||||||
|
},
|
||||||
|
"texture": {
|
||||||
|
"width": 2048,
|
||||||
|
"height": 512,
|
||||||
|
"maximumSize": 8192,
|
||||||
|
"anisotropy": 8,
|
||||||
|
"fontReadyWhenDrawn": true
|
||||||
|
},
|
||||||
|
"rendererPixelRatio": 1,
|
||||||
|
"viewport": {
|
||||||
|
"width": 768,
|
||||||
|
"height": 1024
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"geometry": {
|
||||||
|
"panel": {
|
||||||
|
"left": 276.46875,
|
||||||
|
"top": 562.828125,
|
||||||
|
"right": 737.265625,
|
||||||
|
"bottom": 1024.640625
|
||||||
|
},
|
||||||
|
"fixedZones": [
|
||||||
|
{
|
||||||
|
"left": 0,
|
||||||
|
"top": 0,
|
||||||
|
"right": 768,
|
||||||
|
"bottom": 53
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"left": 12,
|
||||||
|
"top": 968,
|
||||||
|
"right": 262.078125,
|
||||||
|
"bottom": 1012
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"left": 603.5625,
|
||||||
|
"top": 968,
|
||||||
|
"right": 756,
|
||||||
|
"bottom": 1012
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sourceTexelsPerRenderedPixel": 10.413426607321972
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "profile-ultrawide-2560x1440-dpr1-high",
|
||||||
|
"width": 2560,
|
||||||
|
"height": 1440,
|
||||||
|
"dpr": 1,
|
||||||
|
"tier": "high",
|
||||||
|
"targetId": "profile",
|
||||||
|
"frame": {
|
||||||
|
"now": 3528.4,
|
||||||
|
"targetId": "profile",
|
||||||
|
"phase": "dwell",
|
||||||
|
"progress": 1,
|
||||||
|
"opacity": 1,
|
||||||
|
"viewportProfile": "wide",
|
||||||
|
"activeSectionId": "start",
|
||||||
|
"direction": 0,
|
||||||
|
"visible": true,
|
||||||
|
"corridor": "left",
|
||||||
|
"bounds": {
|
||||||
|
"left": 772.6710568680685,
|
||||||
|
"top": 670.2319070306204,
|
||||||
|
"right": 1110.3915295981597,
|
||||||
|
"bottom": 753.5258288498234,
|
||||||
|
"width": 337.72047273009116,
|
||||||
|
"height": 83.29392181920298
|
||||||
|
},
|
||||||
|
"texture": {
|
||||||
|
"width": 2048,
|
||||||
|
"height": 512,
|
||||||
|
"maximumSize": 8192,
|
||||||
|
"anisotropy": 8,
|
||||||
|
"fontReadyWhenDrawn": true
|
||||||
|
},
|
||||||
|
"rendererPixelRatio": 1,
|
||||||
|
"viewport": {
|
||||||
|
"width": 2560,
|
||||||
|
"height": 1440
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"geometry": {
|
||||||
|
"panel": {
|
||||||
|
"left": 1382.390625,
|
||||||
|
"top": 1107,
|
||||||
|
"right": 2457.578125,
|
||||||
|
"bottom": 1510.15625
|
||||||
|
},
|
||||||
|
"fixedZones": [
|
||||||
|
{
|
||||||
|
"left": 0,
|
||||||
|
"top": 0,
|
||||||
|
"right": 2560,
|
||||||
|
"bottom": 53
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"left": 12,
|
||||||
|
"top": 1384,
|
||||||
|
"right": 262.078125,
|
||||||
|
"bottom": 1428
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"left": 2395.5625,
|
||||||
|
"top": 1384,
|
||||||
|
"right": 2548,
|
||||||
|
"bottom": 1428
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sourceTexelsPerRenderedPixel": 6.064186702820287
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "profile-desktop-1440x900-dpr2-high",
|
||||||
|
"width": 1440,
|
||||||
|
"height": 900,
|
||||||
|
"dpr": 2,
|
||||||
|
"tier": "high",
|
||||||
|
"targetId": "profile",
|
||||||
|
"frame": {
|
||||||
|
"now": 2904.3,
|
||||||
|
"targetId": "profile",
|
||||||
|
"phase": "dwell",
|
||||||
|
"progress": 1,
|
||||||
|
"opacity": 1,
|
||||||
|
"viewportProfile": "wide",
|
||||||
|
"activeSectionId": "start",
|
||||||
|
"direction": 0,
|
||||||
|
"visible": true,
|
||||||
|
"corridor": "left",
|
||||||
|
"bounds": {
|
||||||
|
"left": 402.92090777471464,
|
||||||
|
"top": 418.91425432105405,
|
||||||
|
"right": 613.9941436298301,
|
||||||
|
"bottom": 470.9728306161199,
|
||||||
|
"width": 211.07323585511546,
|
||||||
|
"height": 52.05857629506585
|
||||||
|
},
|
||||||
|
"texture": {
|
||||||
|
"width": 2048,
|
||||||
|
"height": 512,
|
||||||
|
"maximumSize": 8192,
|
||||||
|
"anisotropy": 8,
|
||||||
|
"fontReadyWhenDrawn": true
|
||||||
|
},
|
||||||
|
"rendererPixelRatio": 1.5,
|
||||||
|
"viewport": {
|
||||||
|
"width": 1440,
|
||||||
|
"height": 900
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"geometry": {
|
||||||
|
"panel": {
|
||||||
|
"left": 777.59375,
|
||||||
|
"top": 681,
|
||||||
|
"right": 1382.390625,
|
||||||
|
"bottom": 1139.28125
|
||||||
|
},
|
||||||
|
"fixedZones": [
|
||||||
|
{
|
||||||
|
"left": 0,
|
||||||
|
"top": 0,
|
||||||
|
"right": 1440,
|
||||||
|
"bottom": 53
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"left": 12,
|
||||||
|
"top": 844,
|
||||||
|
"right": 262.078125,
|
||||||
|
"bottom": 888
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"left": 1275.5625,
|
||||||
|
"top": 844,
|
||||||
|
"right": 1428,
|
||||||
|
"bottom": 888
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sourceTexelsPerRenderedPixel": 6.468528934054543
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "profile-desktop-1440x900-dpr1-low",
|
||||||
|
"width": 1440,
|
||||||
|
"height": 900,
|
||||||
|
"dpr": 1,
|
||||||
|
"tier": "low",
|
||||||
|
"targetId": "profile",
|
||||||
|
"frame": {
|
||||||
|
"now": 1915.2,
|
||||||
|
"targetId": "profile",
|
||||||
|
"phase": "dwell",
|
||||||
|
"progress": 1,
|
||||||
|
"opacity": 1,
|
||||||
|
"viewportProfile": "wide",
|
||||||
|
"activeSectionId": "start",
|
||||||
|
"direction": 0,
|
||||||
|
"visible": true,
|
||||||
|
"corridor": "left",
|
||||||
|
"bounds": {
|
||||||
|
"left": 402.91180139937455,
|
||||||
|
"top": 418.79709521769956,
|
||||||
|
"right": 613.997524232391,
|
||||||
|
"bottom": 470.8564255936192,
|
||||||
|
"width": 211.08572283301646,
|
||||||
|
"height": 52.059330375919615
|
||||||
|
},
|
||||||
|
"texture": {
|
||||||
|
"width": 2048,
|
||||||
|
"height": 512,
|
||||||
|
"maximumSize": 8192,
|
||||||
|
"anisotropy": 8,
|
||||||
|
"fontReadyWhenDrawn": true
|
||||||
|
},
|
||||||
|
"rendererPixelRatio": 1,
|
||||||
|
"viewport": {
|
||||||
|
"width": 1440,
|
||||||
|
"height": 900
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"geometry": {
|
||||||
|
"panel": {
|
||||||
|
"left": 777.59375,
|
||||||
|
"top": 681,
|
||||||
|
"right": 1382.390625,
|
||||||
|
"bottom": 1139.28125
|
||||||
|
},
|
||||||
|
"fixedZones": [
|
||||||
|
{
|
||||||
|
"left": 0,
|
||||||
|
"top": 0,
|
||||||
|
"right": 1440,
|
||||||
|
"bottom": 53
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"left": 12,
|
||||||
|
"top": 844,
|
||||||
|
"right": 262.078125,
|
||||||
|
"bottom": 888
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"left": 1275.5625,
|
||||||
|
"top": 844,
|
||||||
|
"right": 1428,
|
||||||
|
"bottom": 888
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sourceTexelsPerRenderedPixel": 9.702219423054542
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 348 KiB |
|
After Width: | Height: | Size: 349 KiB |
|
After Width: | Height: | Size: 357 KiB |
|
After Width: | Height: | Size: 355 KiB |
|
After Width: | Height: | Size: 346 KiB |
|
After Width: | Height: | Size: 333 KiB |
|
After Width: | Height: | Size: 338 KiB |
|
After Width: | Height: | Size: 348 KiB |
|
After Width: | Height: | Size: 371 KiB |
|
After Width: | Height: | Size: 354 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 347 KiB |
|
After Width: | Height: | Size: 361 KiB |
|
After Width: | Height: | Size: 363 KiB |
|
After Width: | Height: | Size: 355 KiB |
@@ -141,6 +141,28 @@ The later navigator failure shared one state-ownership cause: explicit, initial,
|
|||||||
|
|
||||||
The focused controller regression failed against the previous implementation because Profile remained `null` after a completed lifecycle and an explicit Contact-to-Start round trip, then passed after the ownership correction. The real-browser regression clicks a section, returns to Start, and verifies that the Profile sign is emitted on the next passive scroll. It passed in both desktop and mobile projects. The final four-worker browser matrix passed 180 checks with 44 deliberate project/capability skips and no failures, including navigation, history, keyboard, accessibility, responsive, Static, reduced-motion, fallback, visual, and flash-safety coverage.
|
The focused controller regression failed against the previous implementation because Profile remained `null` after a completed lifecycle and an explicit Contact-to-Start round trip, then passed after the ownership correction. The real-browser regression clicks a section, returns to Start, and verifies that the Profile sign is emitted on the next passive scroll. It passed in both desktop and mobile projects. The final four-worker browser matrix passed 180 checks with 44 deliberate project/capability skips and no failures, including navigation, history, keyboard, accessibility, responsive, Static, reduced-motion, fallback, visual, and flash-safety coverage.
|
||||||
|
|
||||||
|
### Sign passing and navigator-trigger continuation
|
||||||
|
|
||||||
|
The final 2026-09-03 signs-only continuation supersedes the earlier sign-pass route, navigator, resize, texture, and departure details above. Durable evidence is retained under `docs/reviews/evidence/nightdrive-iteration/sign-passing-refinement/`; its [`README.md`](evidence/nightdrive-iteration/sign-passing-refinement/README.md) is the canonical evidence index and records the complete route/trigger/pose contract. The supplied Profile-overlap baseline remains separate from the corrected viewport, DPR, quality, lifecycle, adverse-input, navigator, Static, reduced-motion, and empty-console evidence.
|
||||||
|
|
||||||
|
The investigation found six shared causes. Navigator actions exposed only a generic explicit-navigation source, so preserving skip/direct/history suppression also concealed intentional navigator arrivals. Completed-route state was not generation-bound, allowing an older departure to poison a traversal restarted at Start; reverse hysteresis re-armed only one route; and cooldown discarded legitimate same-label work from a newer traversal. The old desktop placement model did not account for the outgoing opaque panel or route-specific viewport-edge pressure. Perceived face softness came from the combined source aspect, font-readiness, UV, filtering, color-space, tone-mapping, bloom, projection, and DPR path rather than texture dimensions alone. Finally, departure mostly reduced opacity in place, which produced the reported slide-transition effect instead of a physical roadside pass.
|
||||||
|
|
||||||
|
The controller now consumes a navigator request exactly once through a monotonic navigation epoch and canonical destination. Start is ineligible; navigator Profile through Contact are eligible. Another click on the same already-active destination is idempotent, including after the first lifecycle completes. A different destination may replace a concealed label immediately or become the sole pending label behind a readable sign; returning to the visible target cancels the obsolete pending label. If a passive portrait sign is already readable when its matching navigator link is activated, the same object is promoted to the safe navigator pose over 320ms—without a redraw, replay, or teleport. Initial positioning, direct fragments, history restoration, skip links, the non-navigator Explore shortcut, Static, reduced motion, and hidden-document reset remain sign-suppressed; the next passive traversal is re-armed where appropriate.
|
||||||
|
|
||||||
|
Passive candidacy requires enabled motion, forward document direction, positive input, and the canonical upcoming route. The trigger maxima are 0.55vh for wide layouts, 0.23vh for portrait, and 0.38vh for short landscape; an actual boundary crossing wins and reconciles directly to the route reached. A qualifying reverse retreat beyond the trigger plus 0.20vh starts a new traversal and re-arms that route and every later route. Visible/cooldown state carries the traversal generation, preventing stale completion or same-label cooldown races. The clock contract is 300ms anticipate, 650–900ms approach, at least 1,400ms dwell measured from the first actual readable sample, 700ms pass-by departure, and 350ms cooldown. Delayed frames cannot skip dwell, stopping input cannot freeze a live sign, reverse input cannot move it physically backward, and a visible label never mutates.
|
||||||
|
|
||||||
|
The final route corridors are Profile left, Experience left, Infrastructure outer-right with a route-owned 1.6-world-unit inward dwell bias, Selected builds right, Additional work outer-left, Toolkit outer-left, and Contact outer-right. Projection assertions cover all seven passive transitions and require the complete dwell face to stay at least 8px inside the viewport, 16px outside visible opaque content, and 8px outside fixed controls. The responsive renderer—not stale lifecycle metadata—owns the current wide/tall/short pose. Visible resize and passive-to-navigator changes share one bounded pose transition and do not revive, duplicate, or strand the object.
|
||||||
|
|
||||||
|
The refined sign remains stable Three.js-owned geometry: one clipped/beveled housing, inset face panel, backplate, rear rails, paired narrow posts and braces, fasteners, and planted footings. It uses 20 renderable parts, 13 geometries, 7 materials, and one texture. The 4:1 face is a 2048×512 sRGB canvas texture with premultiplied alpha, trilinear mipmaps, linear magnification, up to 8× supported anisotropy, awaited Geist Mono fonts, and a non-tone-mapped face material. It redraws/uploads only when a concealed label changes. Recorded source headroom ranged from 6.06 to 13.54 texels per rendered face pixel across 390×844/DPR 3 Low through 2560×1440 High, and every recorded draw reported `fontReadyWhenDrawn: true`.
|
||||||
|
|
||||||
|
The new departure keeps constant world scale and moves the planted sign toward the chase camera while sweeping it out through its authored roadside edge. At 1440×900 the Profile face grew in projection from 211.09px at dwell to 410.98px at progress 0.500 while remaining fully opaque and moving left from 402.91px to 71.74px. At progress 0.643 it was already crossing the edge (left −342.88px, width 675.49px) before the restrained opacity falloff began at 0.920. The result reads as the car overtaking a fixed roadside object rather than a card fading in place.
|
||||||
|
|
||||||
|
Fresh verification on 2026-09-03 passed type checking, the production preview build, all 43 unit files / 566 tests, and the focused sign/navigation/world set (4 files / 117 tests). The first complete four-worker browser matrix completed 189 passes and 48 intentional project skips; its sole failure was the 1440×900 Toolkit→Contact safety-margin sample under parallel load. A second frozen repeat completed 188 passes and the same 48 skips: the seven-transition assertion passed that time, while a different responsive sign sample timed out during `page.evaluate` and an unrelated hydration test sampled the transient `Motion: preparing` label. The exact unchanged seven-transition test passed with one worker in 39.9s, the responsive sign and navigator-promotion checks passed unchanged with one worker in 16.8s and 4.5s, and the hydration check passed unchanged alone in 1.8s. These are retained as observed load-only matrix flakes rather than hidden by timeout or assertion changes. The authenticated High-tier allocation gate passed at a renderer ratio of 1.5 with 5,608,790 texture bytes, 755,916 geometry bytes, 161,344,784 render-target bytes, a 33,554,432-byte runtime allowance, and 201,263,922 total accounted bytes. The transfer gate passed with 80,974 bytes of initial application JavaScript and 223,856 bytes for the first live scene; it reported no violations. The saved console capture contains no browser errors.
|
||||||
|
|
||||||
|
Two pre-existing harness limitations remain explicit. Deployment verification passed 7/9 tests; both failures stop at the repository's expired execution-security-evidence timestamp before portfolio behavior is exercised. The long-drive performance harness did not produce a valid trace: desktop exhausted its fixed 75s timeout while resolving the visible Toolkit navigation target, and mobile stayed in scene loading until the five-second live-scene deadline selected the static fallback. No performance number is inferred from either invalid run, and no timeout, lifecycle assertion, or budget was weakened.
|
||||||
|
|
||||||
|
No additional Blender asset was justified. The finite authored car remains Blender-owned and unchanged. Dynamic sign text, candidacy, lifecycle, responsive placement, road, terrain, atmosphere, sun, camera, and motion remain Three.js-owned.
|
||||||
|
|
||||||
## Direction review checklist
|
## Direction review checklist
|
||||||
|
|
||||||
- [x] Information uses near-black opaque panels rather than neon glass.
|
- [x] Information uses near-black opaque panels rather than neon glass.
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type { JourneySnapshot } from "@/journey/types";
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
items: ReadonlyArray<{ id: SectionId; label: string }>;
|
items: ReadonlyArray<{ id: SectionId; label: string }>;
|
||||||
journey: JourneySnapshot;
|
journey: JourneySnapshot;
|
||||||
onSectionLinkClick: (event: MouseEvent) => void;
|
onSectionLinkClick: (event: MouseEvent, requestSign?: boolean) => void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const navigationLabels: Readonly<Record<SectionId, string>> = {
|
const navigationLabels: Readonly<Record<SectionId, string>> = {
|
||||||
@@ -42,7 +42,7 @@ onBeforeUnmount(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const navigate = (event: MouseEvent): void => {
|
const navigate = (event: MouseEvent): void => {
|
||||||
props.onSectionLinkClick(event);
|
props.onSectionLinkClick(event, true);
|
||||||
if (window.matchMedia("(max-width: 767px)").matches)
|
if (window.matchMedia("(max-width: 767px)").matches)
|
||||||
disclosure.value?.removeAttribute("open");
|
disclosure.value?.removeAttribute("open");
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -121,6 +121,8 @@ export function useJourneySections(
|
|||||||
const sign = signController.sample({
|
const sign = signController.sample({
|
||||||
now,
|
now,
|
||||||
source: navigationState.value.source,
|
source: navigationState.value.source,
|
||||||
|
navigationEpoch: navigationState.value.epoch,
|
||||||
|
navigationDestinationId: navigationState.value.signDestinationId,
|
||||||
direction: driveSnapshot.direction,
|
direction: driveSnapshot.direction,
|
||||||
acceleration: driveSnapshot.acceleration,
|
acceleration: driveSnapshot.acceleration,
|
||||||
deltaY: driveSnapshot.scrollY - previousScrollY,
|
deltaY: driveSnapshot.scrollY - previousScrollY,
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ const isEditableTarget = (target: EventTarget | null): boolean =>
|
|||||||
|
|
||||||
export interface KeyboardNavigation {
|
export interface KeyboardNavigation {
|
||||||
state: Readonly<Ref<NavigationState>>;
|
state: Readonly<Ref<NavigationState>>;
|
||||||
onSectionLinkClick(event: MouseEvent): void;
|
onSectionLinkClick(event: MouseEvent, requestSign?: boolean): void;
|
||||||
notePassiveSection(id: SectionId): void;
|
notePassiveSection(id: SectionId): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,6 +44,7 @@ export function useKeyboardNavigation(
|
|||||||
epoch: 0,
|
epoch: 0,
|
||||||
source: "initial",
|
source: "initial",
|
||||||
destinationId: null,
|
destinationId: null,
|
||||||
|
signDestinationId: null,
|
||||||
});
|
});
|
||||||
let controller: NavigationController | undefined;
|
let controller: NavigationController | undefined;
|
||||||
let teardown = (): void => {};
|
let teardown = (): void => {};
|
||||||
@@ -60,7 +61,7 @@ export function useKeyboardNavigation(
|
|||||||
else controller.restore(id, target);
|
else controller.restore(id, target);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSectionLinkClick = (event: MouseEvent): void => {
|
const onSectionLinkClick = (event: MouseEvent, requestSign = false): void => {
|
||||||
if (
|
if (
|
||||||
event.defaultPrevented ||
|
event.defaultPrevented ||
|
||||||
event.button !== 0 ||
|
event.button !== 0 ||
|
||||||
@@ -76,7 +77,7 @@ export function useKeyboardNavigation(
|
|||||||
const validated = validateSectionAnchor(event.currentTarget, canonicalIds);
|
const validated = validateSectionAnchor(event.currentTarget, canonicalIds);
|
||||||
if (validated === null) return;
|
if (validated === null) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
controller.navigate(validated.id, validated.target);
|
controller.navigate(validated.id, validated.target, { requestSign });
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export interface NavigationState {
|
|||||||
epoch: number;
|
epoch: number;
|
||||||
source: JourneySource;
|
source: JourneySource;
|
||||||
destinationId: SectionId | null;
|
destinationId: SectionId | null;
|
||||||
|
signDestinationId: SectionId | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NavigationRuntime {
|
export interface NavigationRuntime {
|
||||||
@@ -28,7 +29,11 @@ export interface NavigationRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface NavigationController {
|
export interface NavigationController {
|
||||||
navigate(id: SectionId, target: HTMLElement): number;
|
navigate(
|
||||||
|
id: SectionId,
|
||||||
|
target: HTMLElement,
|
||||||
|
options?: { requestSign?: boolean },
|
||||||
|
): number;
|
||||||
noteUserScrollIntent(): number;
|
noteUserScrollIntent(): number;
|
||||||
notePassiveSection(id: SectionId): void;
|
notePassiveSection(id: SectionId): void;
|
||||||
initialize(id: SectionId, target: HTMLElement): number;
|
initialize(id: SectionId, target: HTMLElement): number;
|
||||||
@@ -118,6 +123,7 @@ export function createNavigationController(
|
|||||||
epoch: 0,
|
epoch: 0,
|
||||||
source: "initial",
|
source: "initial",
|
||||||
destinationId: null,
|
destinationId: null,
|
||||||
|
signDestinationId: null,
|
||||||
};
|
};
|
||||||
const subscribers = new Set<(state: NavigationState) => void>();
|
const subscribers = new Set<(state: NavigationState) => void>();
|
||||||
let frameId: number | undefined;
|
let frameId: number | undefined;
|
||||||
@@ -159,12 +165,14 @@ export function createNavigationController(
|
|||||||
const beginEpoch = (
|
const beginEpoch = (
|
||||||
source: JourneySource,
|
source: JourneySource,
|
||||||
destinationId: SectionId | null,
|
destinationId: SectionId | null,
|
||||||
|
signDestinationId: SectionId | null = null,
|
||||||
): number => {
|
): number => {
|
||||||
cancelPending();
|
cancelPending();
|
||||||
currentState = {
|
currentState = {
|
||||||
epoch: currentState.epoch + 1,
|
epoch: currentState.epoch + 1,
|
||||||
source,
|
source,
|
||||||
destinationId,
|
destinationId,
|
||||||
|
signDestinationId,
|
||||||
};
|
};
|
||||||
if (TEST_BUILD)
|
if (TEST_BUILD)
|
||||||
window.__PORTFOLIO_PERFORMANCE__?.noteNavigationEpoch(currentState.epoch);
|
window.__PORTFOLIO_PERFORMANCE__?.noteNavigationEpoch(currentState.epoch);
|
||||||
@@ -292,9 +300,13 @@ export function createNavigationController(
|
|||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
navigate(id, target) {
|
navigate(id, target, options) {
|
||||||
requireTarget(id, target);
|
requireTarget(id, target);
|
||||||
const epoch = beginEpoch("explicit-navigation", id);
|
const epoch = beginEpoch(
|
||||||
|
"explicit-navigation",
|
||||||
|
id,
|
||||||
|
options?.requestSign === true ? id : null,
|
||||||
|
);
|
||||||
if (canonicalSectionIdFromHash(runtime.getHash(), canonicalIds) !== id) {
|
if (canonicalSectionIdFromHash(runtime.getHash(), canonicalIds) !== id) {
|
||||||
runtime.pushHash(`#${id}`);
|
runtime.pushHash(`#${id}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
SIGN_LIFECYCLE,
|
SIGN_LIFECYCLE,
|
||||||
SIGN_ROUTE_ORDER,
|
SIGN_ROUTE_ORDER,
|
||||||
signRouteFor,
|
signRouteFor,
|
||||||
|
signTriggerMaximumVhFor,
|
||||||
signViewportProfileFor,
|
signViewportProfileFor,
|
||||||
type SignViewportProfile,
|
type SignViewportProfile,
|
||||||
} from '@/journey/sign-routes';
|
} from '@/journey/sign-routes';
|
||||||
@@ -18,15 +19,27 @@ interface VisibleState {
|
|||||||
latchedAt: number;
|
latchedAt: number;
|
||||||
approachDuration: number;
|
approachDuration: number;
|
||||||
viewportProfile: SignViewportProfile;
|
viewportProfile: SignViewportProfile;
|
||||||
|
navigationArrival: boolean;
|
||||||
dwellStartedAt: number | null;
|
dwellStartedAt: number | null;
|
||||||
departStartedAt: number | null;
|
departStartedAt: number | null;
|
||||||
|
traversalGeneration: number;
|
||||||
generation: number;
|
generation: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PendingTarget {
|
||||||
|
targetId: SectionId;
|
||||||
|
navigationArrival: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
type State =
|
type State =
|
||||||
| { kind: 'idle' }
|
| { kind: 'idle' }
|
||||||
| VisibleState
|
| VisibleState
|
||||||
| { kind: 'cooldown'; until: number };
|
| {
|
||||||
|
kind: 'cooldown';
|
||||||
|
targetId: SectionId;
|
||||||
|
traversalGeneration: number;
|
||||||
|
until: number;
|
||||||
|
};
|
||||||
|
|
||||||
const clamp01 = (value: number): number => Math.min(1, Math.max(0, value));
|
const clamp01 = (value: number): number => Math.min(1, Math.max(0, value));
|
||||||
|
|
||||||
@@ -35,8 +48,17 @@ const smoothstep = (minimum: number, maximum: number, value: number): number =>
|
|||||||
return progress * progress * (3 - 2 * progress);
|
return progress * progress * (3 - 2 * progress);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const withNavigationArrival = (
|
||||||
|
snapshot: SignSnapshot,
|
||||||
|
navigationArrival: boolean,
|
||||||
|
): SignSnapshot => {
|
||||||
|
if (navigationArrival) snapshot.navigationArrival = true;
|
||||||
|
return snapshot;
|
||||||
|
};
|
||||||
|
|
||||||
const isFiniteSample = (input: SignControllerSample): boolean =>
|
const isFiniteSample = (input: SignControllerSample): boolean =>
|
||||||
Number.isFinite(input.now) &&
|
Number.isFinite(input.now) &&
|
||||||
|
Number.isFinite(input.navigationEpoch) &&
|
||||||
Number.isFinite(input.acceleration) &&
|
Number.isFinite(input.acceleration) &&
|
||||||
Number.isFinite(input.deltaY) &&
|
Number.isFinite(input.deltaY) &&
|
||||||
(input.viewportWidth === undefined || (
|
(input.viewportWidth === undefined || (
|
||||||
@@ -51,13 +73,17 @@ export function createSignController(): SignController {
|
|||||||
let state: State = { kind: 'idle' };
|
let state: State = { kind: 'idle' };
|
||||||
let lastNow: number | null = null;
|
let lastNow: number | null = null;
|
||||||
let generation = 0;
|
let generation = 0;
|
||||||
let pendingTargetId: SectionId | null = null;
|
let pendingTarget: PendingTarget | null = null;
|
||||||
|
let lastNavigationEpoch = 0;
|
||||||
|
let lastNavigationTargetId: SectionId | null = null;
|
||||||
|
let traversalGeneration = 0;
|
||||||
const completedTraversalTargets = new Set<SectionId>();
|
const completedTraversalTargets = new Set<SectionId>();
|
||||||
|
|
||||||
const reset = (now?: number): void => {
|
const reset = (now?: number): void => {
|
||||||
generation += 1;
|
generation += 1;
|
||||||
state = { kind: 'idle' };
|
state = { kind: 'idle' };
|
||||||
pendingTargetId = null;
|
pendingTarget = null;
|
||||||
|
lastNavigationTargetId = null;
|
||||||
lastNow = now !== undefined && Number.isFinite(now) ? now : null;
|
lastNow = now !== undefined && Number.isFinite(now) ? now : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -67,6 +93,7 @@ export function createSignController(): SignController {
|
|||||||
acceleration: number,
|
acceleration: number,
|
||||||
viewportWidth: number,
|
viewportWidth: number,
|
||||||
viewportHeight: number,
|
viewportHeight: number,
|
||||||
|
navigationArrival = false,
|
||||||
): VisibleState => {
|
): VisibleState => {
|
||||||
generation += 1;
|
generation += 1;
|
||||||
return {
|
return {
|
||||||
@@ -76,8 +103,10 @@ export function createSignController(): SignController {
|
|||||||
approachDuration: SIGN_LIFECYCLE.approachMs
|
approachDuration: SIGN_LIFECYCLE.approachMs
|
||||||
- SIGN_LIFECYCLE.approachReductionMs * clamp01(acceleration),
|
- SIGN_LIFECYCLE.approachReductionMs * clamp01(acceleration),
|
||||||
viewportProfile: signViewportProfileFor(viewportWidth, viewportHeight),
|
viewportProfile: signViewportProfileFor(viewportWidth, viewportHeight),
|
||||||
|
navigationArrival,
|
||||||
dwellStartedAt: null,
|
dwellStartedAt: null,
|
||||||
departStartedAt: null,
|
departStartedAt: null,
|
||||||
|
traversalGeneration,
|
||||||
generation,
|
generation,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -104,58 +133,74 @@ export function createSignController(): SignController {
|
|||||||
if (visible.departStartedAt !== null) {
|
if (visible.departStartedAt !== null) {
|
||||||
const progress = clamp01((now - visible.departStartedAt) / SIGN_LIFECYCLE.departMs);
|
const progress = clamp01((now - visible.departStartedAt) / SIGN_LIFECYCLE.departMs);
|
||||||
if (progress >= 1) {
|
if (progress >= 1) {
|
||||||
if (visible.dwellStartedAt !== null) completedTraversalTargets.add(visible.targetId);
|
if (
|
||||||
|
visible.dwellStartedAt !== null
|
||||||
|
&& visible.traversalGeneration === traversalGeneration
|
||||||
|
) {
|
||||||
|
completedTraversalTargets.add(visible.targetId);
|
||||||
|
}
|
||||||
state = {
|
state = {
|
||||||
kind: 'cooldown',
|
kind: 'cooldown',
|
||||||
|
targetId: visible.targetId,
|
||||||
|
traversalGeneration: visible.traversalGeneration,
|
||||||
until: visible.departStartedAt
|
until: visible.departStartedAt
|
||||||
+ SIGN_LIFECYCLE.departMs
|
+ SIGN_LIFECYCLE.departMs
|
||||||
+ SIGN_LIFECYCLE.cooldownMs,
|
+ SIGN_LIFECYCLE.cooldownMs,
|
||||||
};
|
};
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return {
|
return withNavigationArrival({
|
||||||
targetId: visible.targetId,
|
targetId: visible.targetId,
|
||||||
phase: 'depart',
|
phase: 'depart',
|
||||||
progress,
|
progress,
|
||||||
opacity: 1 - smoothstep(0.1, 1, progress),
|
opacity: 1 - smoothstep(0.58, 0.94, progress),
|
||||||
viewportProfile: visible.viewportProfile,
|
viewportProfile: visible.viewportProfile,
|
||||||
};
|
}, visible.navigationArrival);
|
||||||
}
|
}
|
||||||
|
|
||||||
const elapsed = Math.max(0, now - visible.latchedAt);
|
const elapsed = Math.max(0, now - visible.latchedAt);
|
||||||
if (elapsed < SIGN_LIFECYCLE.anticipateMs) {
|
if (elapsed < SIGN_LIFECYCLE.anticipateMs) {
|
||||||
return {
|
return withNavigationArrival({
|
||||||
targetId: visible.targetId,
|
targetId: visible.targetId,
|
||||||
phase: 'anticipate',
|
phase: 'anticipate',
|
||||||
progress: clamp01(elapsed / SIGN_LIFECYCLE.anticipateMs),
|
progress: clamp01(elapsed / SIGN_LIFECYCLE.anticipateMs),
|
||||||
opacity: 0,
|
opacity: 0,
|
||||||
viewportProfile: visible.viewportProfile,
|
viewportProfile: visible.viewportProfile,
|
||||||
};
|
}, visible.navigationArrival);
|
||||||
}
|
}
|
||||||
|
|
||||||
const approachElapsed = elapsed - SIGN_LIFECYCLE.anticipateMs;
|
const approachElapsed = elapsed - SIGN_LIFECYCLE.anticipateMs;
|
||||||
if (approachElapsed < visible.approachDuration) {
|
if (approachElapsed < visible.approachDuration) {
|
||||||
const progress = clamp01(approachElapsed / visible.approachDuration);
|
const progress = clamp01(approachElapsed / visible.approachDuration);
|
||||||
return {
|
return withNavigationArrival({
|
||||||
targetId: visible.targetId,
|
targetId: visible.targetId,
|
||||||
phase: 'approach',
|
phase: 'approach',
|
||||||
progress,
|
progress,
|
||||||
opacity: smoothstep(0, 0.42, progress),
|
opacity: smoothstep(0, 0.42, progress),
|
||||||
viewportProfile: visible.viewportProfile,
|
viewportProfile: visible.viewportProfile,
|
||||||
};
|
}, visible.navigationArrival);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (visible.dwellStartedAt === null) visible.dwellStartedAt = now;
|
if (visible.dwellStartedAt === null) visible.dwellStartedAt = now;
|
||||||
return {
|
return withNavigationArrival({
|
||||||
targetId: visible.targetId,
|
targetId: visible.targetId,
|
||||||
phase: 'dwell',
|
phase: 'dwell',
|
||||||
progress: 1,
|
progress: 1,
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
viewportProfile: visible.viewportProfile,
|
viewportProfile: visible.viewportProfile,
|
||||||
};
|
}, visible.navigationArrival);
|
||||||
};
|
};
|
||||||
|
|
||||||
const canonicalCandidate = (input: SignControllerSample): SectionId | null => {
|
const rearmRoutesFrom = (targetId: SectionId): void => {
|
||||||
|
const targetIndex = SIGN_ROUTE_ORDER.indexOf(targetId);
|
||||||
|
if (targetIndex < 0) return;
|
||||||
|
for (let index = targetIndex; index < SIGN_ROUTE_ORDER.length; index += 1) {
|
||||||
|
const routeId = SIGN_ROUTE_ORDER[index];
|
||||||
|
if (routeId !== undefined) completedTraversalTargets.delete(routeId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const passiveCandidate = (input: SignControllerSample): SectionId | null => {
|
||||||
if (
|
if (
|
||||||
input.direction !== 1
|
input.direction !== 1
|
||||||
|| input.deltaY <= 0
|
|| input.deltaY <= 0
|
||||||
@@ -175,7 +220,11 @@ export function createSignController(): SignController {
|
|||||||
) return null;
|
) return null;
|
||||||
const route = signRouteFor(input.candidateId);
|
const route = signRouteFor(input.candidateId);
|
||||||
return route.eligible
|
return route.eligible
|
||||||
&& input.candidateDistanceVh <= route.triggerMaximumVh
|
&& input.candidateDistanceVh <= signTriggerMaximumVhFor(
|
||||||
|
route,
|
||||||
|
input.viewportWidth ?? input.viewportHeight * (16 / 9),
|
||||||
|
input.viewportHeight,
|
||||||
|
)
|
||||||
&& !completedTraversalTargets.has(route.id)
|
&& !completedTraversalTargets.has(route.id)
|
||||||
? route.id
|
? route.id
|
||||||
: null;
|
: null;
|
||||||
@@ -188,32 +237,130 @@ export function createSignController(): SignController {
|
|||||||
}
|
}
|
||||||
lastNow = input.now;
|
lastNow = input.now;
|
||||||
|
|
||||||
if (!input.motionAllowed || input.source !== 'passive-scroll') {
|
const receivedNavigationEpoch = input.source !== 'passive-scroll'
|
||||||
if (input.source !== 'passive-scroll') {
|
&& input.navigationEpoch > lastNavigationEpoch;
|
||||||
|
const receivedNavigationIntent = input.source === 'explicit-navigation'
|
||||||
|
&& receivedNavigationEpoch;
|
||||||
|
let navigationCandidate: SectionId | null = null;
|
||||||
|
let repeatedNavigationIntent = false;
|
||||||
|
if (receivedNavigationEpoch) {
|
||||||
|
lastNavigationEpoch = input.navigationEpoch;
|
||||||
|
if (receivedNavigationIntent) {
|
||||||
|
const destinationId = input.navigationDestinationId;
|
||||||
|
repeatedNavigationIntent = destinationId !== null
|
||||||
|
&& destinationId === lastNavigationTargetId
|
||||||
|
&& (
|
||||||
|
input.activeSectionId === destinationId
|
||||||
|
|| (state.kind === 'visible' && state.targetId === destinationId)
|
||||||
|
);
|
||||||
|
lastNavigationTargetId = destinationId;
|
||||||
|
} else {
|
||||||
|
lastNavigationTargetId = null;
|
||||||
|
}
|
||||||
|
if (!repeatedNavigationIntent) {
|
||||||
|
traversalGeneration += 1;
|
||||||
completedTraversalTargets.clear();
|
completedTraversalTargets.clear();
|
||||||
}
|
}
|
||||||
|
const destinationId = input.navigationDestinationId;
|
||||||
|
if (
|
||||||
|
receivedNavigationIntent
|
||||||
|
&& destinationId !== null
|
||||||
|
&& !repeatedNavigationIntent
|
||||||
|
) {
|
||||||
|
const route = signRouteFor(destinationId);
|
||||||
|
if (route.eligible) navigationCandidate = route.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!input.motionAllowed) {
|
||||||
|
reset(input.now);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (input.source === 'initial' || input.source === 'history-restoration') {
|
||||||
reset(input.now);
|
reset(input.now);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const candidate = canonicalCandidate(input);
|
if (
|
||||||
const discontinuous =
|
receivedNavigationIntent
|
||||||
|
&& navigationCandidate === null
|
||||||
|
&& !repeatedNavigationIntent
|
||||||
|
) {
|
||||||
|
pendingTarget = null;
|
||||||
|
if (state.kind === 'idle' || state.kind === 'cooldown') {
|
||||||
|
state = { kind: 'idle' };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.source === 'passive-scroll' && input.direction < 0) {
|
||||||
|
pendingTarget = null;
|
||||||
|
if (input.candidateId !== null && input.candidateDistanceVh !== null) {
|
||||||
|
const retreatRoute = signRouteFor(input.candidateId);
|
||||||
|
if (
|
||||||
|
retreatRoute.eligible
|
||||||
|
&& input.candidateDistanceVh
|
||||||
|
>= signTriggerMaximumVhFor(
|
||||||
|
retreatRoute,
|
||||||
|
input.viewportWidth ?? input.viewportHeight * (16 / 9),
|
||||||
|
input.viewportHeight,
|
||||||
|
) + SIGN_LIFECYCLE.rearmMarginVh
|
||||||
|
) {
|
||||||
|
traversalGeneration += 1;
|
||||||
|
rearmRoutesFrom(retreatRoute.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate = receivedNavigationIntent
|
||||||
|
? navigationCandidate
|
||||||
|
: input.source === 'passive-scroll'
|
||||||
|
? passiveCandidate(input)
|
||||||
|
: null;
|
||||||
|
const discontinuous = input.source === 'passive-scroll' && (
|
||||||
input.crossedBoundaries > 1
|
input.crossedBoundaries > 1
|
||||||
|| Math.abs(input.deltaY) > input.viewportHeight * 0.75;
|
|| Math.abs(input.deltaY) > input.viewportHeight * 0.75
|
||||||
|
);
|
||||||
|
|
||||||
if (state.kind === 'cooldown') {
|
if (state.kind === 'cooldown') {
|
||||||
if (candidate !== null) pendingTargetId = candidate;
|
if (
|
||||||
|
receivedNavigationIntent
|
||||||
|
&& input.navigationDestinationId === state.targetId
|
||||||
|
&& pendingTarget?.targetId === state.targetId
|
||||||
|
) {
|
||||||
|
pendingTarget.navigationArrival = true;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
candidate !== null
|
||||||
|
&& (
|
||||||
|
candidate !== state.targetId
|
||||||
|
|| receivedNavigationIntent
|
||||||
|
|| state.traversalGeneration !== traversalGeneration
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
pendingTarget = {
|
||||||
|
targetId: candidate,
|
||||||
|
navigationArrival: receivedNavigationIntent,
|
||||||
|
};
|
||||||
|
}
|
||||||
if (input.now < state.until) return null;
|
if (input.now < state.until) return null;
|
||||||
state = { kind: 'idle' };
|
state = { kind: 'idle' };
|
||||||
if (pendingTargetId !== null && !completedTraversalTargets.has(pendingTargetId)) {
|
const queued = pendingTarget;
|
||||||
const targetId = pendingTargetId;
|
pendingTarget = null;
|
||||||
pendingTargetId = null;
|
if (
|
||||||
|
queued !== null
|
||||||
|
&& (
|
||||||
|
queued.navigationArrival
|
||||||
|
|| !completedTraversalTargets.has(queued.targetId)
|
||||||
|
)
|
||||||
|
) {
|
||||||
state = latch(
|
state = latch(
|
||||||
targetId,
|
queued.targetId,
|
||||||
input.now,
|
input.now,
|
||||||
input.acceleration,
|
input.acceleration,
|
||||||
input.viewportWidth ?? input.viewportHeight * (16 / 9),
|
input.viewportWidth ?? input.viewportHeight * (16 / 9),
|
||||||
input.viewportHeight,
|
input.viewportHeight,
|
||||||
|
queued.navigationArrival,
|
||||||
);
|
);
|
||||||
return snapshotVisible(state, input.now);
|
return snapshotVisible(state, input.now);
|
||||||
}
|
}
|
||||||
@@ -227,28 +374,66 @@ export function createSignController(): SignController {
|
|||||||
input.acceleration,
|
input.acceleration,
|
||||||
input.viewportWidth ?? input.viewportHeight * (16 / 9),
|
input.viewportWidth ?? input.viewportHeight * (16 / 9),
|
||||||
input.viewportHeight,
|
input.viewportHeight,
|
||||||
|
receivedNavigationIntent,
|
||||||
);
|
);
|
||||||
return snapshotVisible(state, input.now);
|
return snapshotVisible(state, input.now);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (candidate !== null && candidate !== state.targetId) {
|
const navigationTargetsVisibleSign = receivedNavigationIntent
|
||||||
pendingTargetId = candidate;
|
&& input.navigationDestinationId === state.targetId;
|
||||||
|
if (navigationTargetsVisibleSign) {
|
||||||
|
pendingTarget = null;
|
||||||
|
if (!repeatedNavigationIntent) {
|
||||||
|
state.traversalGeneration = traversalGeneration;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!state.navigationArrival
|
||||||
|
&& state.dwellStartedAt !== null
|
||||||
|
&& state.departStartedAt === null
|
||||||
|
) {
|
||||||
|
state.dwellStartedAt = input.now;
|
||||||
|
}
|
||||||
|
state.navigationArrival = true;
|
||||||
|
}
|
||||||
|
const candidateStartsNewPresentation = candidate !== null && (
|
||||||
|
candidate !== state.targetId
|
||||||
|
|| (
|
||||||
|
input.source === 'passive-scroll'
|
||||||
|
&& state.traversalGeneration !== traversalGeneration
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if (candidateStartsNewPresentation && candidate !== null) {
|
||||||
|
pendingTarget = {
|
||||||
|
targetId: candidate,
|
||||||
|
navigationArrival: receivedNavigationIntent,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
const current = snapshotVisible(state, input.now);
|
const current = snapshotVisible(state, input.now);
|
||||||
if (current === null) return null;
|
if (current === null) return null;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
current.opacity <= SIGN_LIFECYCLE.concealedOpacity
|
receivedNavigationIntent
|
||||||
&& candidate !== null
|
&& navigationCandidate === null
|
||||||
&& candidate !== state.targetId
|
&& !repeatedNavigationIntent
|
||||||
|
&& current.opacity <= SIGN_LIFECYCLE.concealedOpacity
|
||||||
) {
|
) {
|
||||||
pendingTargetId = null;
|
state = { kind: 'idle' };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
current.opacity <= SIGN_LIFECYCLE.concealedOpacity
|
||||||
|
&& candidateStartsNewPresentation
|
||||||
|
&& candidate !== null
|
||||||
|
) {
|
||||||
|
pendingTarget = null;
|
||||||
state = latch(
|
state = latch(
|
||||||
candidate,
|
candidate,
|
||||||
input.now,
|
input.now,
|
||||||
input.acceleration,
|
input.acceleration,
|
||||||
input.viewportWidth ?? input.viewportHeight * (16 / 9),
|
input.viewportWidth ?? input.viewportHeight * (16 / 9),
|
||||||
input.viewportHeight,
|
input.viewportHeight,
|
||||||
|
receivedNavigationIntent,
|
||||||
);
|
);
|
||||||
return snapshotVisible(state, input.now);
|
return snapshotVisible(state, input.now);
|
||||||
}
|
}
|
||||||
@@ -264,7 +449,10 @@ export function createSignController(): SignController {
|
|||||||
|
|
||||||
const activeIndex = SIGN_ROUTE_ORDER.indexOf(input.activeSectionId);
|
const activeIndex = SIGN_ROUTE_ORDER.indexOf(input.activeSectionId);
|
||||||
const targetIndex = SIGN_ROUTE_ORDER.indexOf(state.targetId);
|
const targetIndex = SIGN_ROUTE_ORDER.indexOf(state.targetId);
|
||||||
const targetPassed = activeIndex >= 0 && targetIndex >= 0 && activeIndex > targetIndex;
|
const targetPassed = input.source === 'passive-scroll'
|
||||||
|
&& activeIndex >= 0
|
||||||
|
&& targetIndex >= 0
|
||||||
|
&& activeIndex > targetIndex;
|
||||||
if (targetPassed && state.departStartedAt === null) {
|
if (targetPassed && state.departStartedAt === null) {
|
||||||
if (current.opacity <= SIGN_LIFECYCLE.concealedOpacity) {
|
if (current.opacity <= SIGN_LIFECYCLE.concealedOpacity) {
|
||||||
state = { kind: 'idle' };
|
state = { kind: 'idle' };
|
||||||
@@ -274,7 +462,7 @@ export function createSignController(): SignController {
|
|||||||
return snapshotVisible(state, input.now);
|
return snapshotVisible(state, input.now);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (input.direction < 0) {
|
if (input.source === 'passive-scroll' && input.direction < 0) {
|
||||||
if (current.opacity <= SIGN_LIFECYCLE.concealedOpacity) {
|
if (current.opacity <= SIGN_LIFECYCLE.concealedOpacity) {
|
||||||
state = { kind: 'idle' };
|
state = { kind: 'idle' };
|
||||||
return null;
|
return null;
|
||||||
@@ -293,7 +481,7 @@ export function createSignController(): SignController {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const needsFrame = (): boolean => {
|
const needsFrame = (): boolean => {
|
||||||
if (state.kind === 'cooldown') return pendingTargetId !== null;
|
if (state.kind === 'cooldown') return pendingTarget !== null;
|
||||||
return state.kind === 'visible' && lastNow !== null;
|
return state.kind === 'visible' && lastNow !== null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import type { SectionId } from '@/content/types';
|
|||||||
export type SignCorridor = 'left' | 'right' | 'outer-left' | 'outer-right';
|
export type SignCorridor = 'left' | 'right' | 'outer-left' | 'outer-right';
|
||||||
export type SignViewportProfile = 'wide' | 'tall' | 'short';
|
export type SignViewportProfile = 'wide' | 'tall' | 'short';
|
||||||
|
|
||||||
|
export const PORTRAIT_SIGN_TRIGGER_MAXIMUM_VH = 0.23;
|
||||||
|
export const SHORT_LANDSCAPE_SIGN_TRIGGER_MAXIMUM_VH = 0.38;
|
||||||
|
|
||||||
export interface SignRouteSpec {
|
export interface SignRouteSpec {
|
||||||
id: SectionId;
|
id: SectionId;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -10,6 +13,7 @@ export interface SignRouteSpec {
|
|||||||
eligible: boolean;
|
eligible: boolean;
|
||||||
corridor: SignCorridor | null;
|
corridor: SignCorridor | null;
|
||||||
triggerMaximumVh: number;
|
triggerMaximumVh: number;
|
||||||
|
dwellOffsetX?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SIGN_LIFECYCLE = Object.freeze({
|
export const SIGN_LIFECYCLE = Object.freeze({
|
||||||
@@ -20,6 +24,7 @@ export const SIGN_LIFECYCLE = Object.freeze({
|
|||||||
departMs: 700,
|
departMs: 700,
|
||||||
cooldownMs: 350,
|
cooldownMs: 350,
|
||||||
concealedOpacity: 0.02,
|
concealedOpacity: 0.02,
|
||||||
|
rearmMarginVh: 0.2,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const SIGN_ROUTE_ORDER: readonly SectionId[] = [
|
export const SIGN_ROUTE_ORDER: readonly SectionId[] = [
|
||||||
@@ -41,22 +46,22 @@ const routeSpecs: Readonly<Record<SectionId, SignRouteSpec>> = Object.freeze({
|
|||||||
id: 'profile', label: 'Profile', marker: '02', eligible: true, corridor: 'left', triggerMaximumVh: 0.55,
|
id: 'profile', label: 'Profile', marker: '02', eligible: true, corridor: 'left', triggerMaximumVh: 0.55,
|
||||||
},
|
},
|
||||||
experience: {
|
experience: {
|
||||||
id: 'experience', label: 'Experience', marker: '03', eligible: true, corridor: 'right', triggerMaximumVh: 0.55,
|
id: 'experience', label: 'Experience', marker: '03', eligible: true, corridor: 'left', triggerMaximumVh: 0.55,
|
||||||
},
|
},
|
||||||
infrastructure: {
|
infrastructure: {
|
||||||
id: 'infrastructure', label: 'Infrastructure', marker: '04', eligible: true, corridor: 'outer-right', triggerMaximumVh: 0.55,
|
id: 'infrastructure', label: 'Infrastructure', marker: '04', eligible: true, corridor: 'outer-right', triggerMaximumVh: 0.55, dwellOffsetX: -1.6,
|
||||||
},
|
},
|
||||||
'selected-builds': {
|
'selected-builds': {
|
||||||
id: 'selected-builds', label: 'Selected builds', marker: '05', eligible: true, corridor: 'outer-right', triggerMaximumVh: 0.55,
|
id: 'selected-builds', label: 'Selected builds', marker: '05', eligible: true, corridor: 'right', triggerMaximumVh: 0.55,
|
||||||
},
|
},
|
||||||
'additional-work': {
|
'additional-work': {
|
||||||
id: 'additional-work', label: 'Additional work', marker: '06', eligible: true, corridor: 'left', triggerMaximumVh: 0.55,
|
id: 'additional-work', label: 'Additional work', marker: '06', eligible: true, corridor: 'outer-left', triggerMaximumVh: 0.55,
|
||||||
},
|
},
|
||||||
toolkit: {
|
toolkit: {
|
||||||
id: 'toolkit', label: 'Toolkit', marker: '07', eligible: true, corridor: 'outer-left', triggerMaximumVh: 0.55,
|
id: 'toolkit', label: 'Toolkit', marker: '07', eligible: true, corridor: 'outer-left', triggerMaximumVh: 0.55,
|
||||||
},
|
},
|
||||||
contact: {
|
contact: {
|
||||||
id: 'contact', label: 'Contact', marker: '08', eligible: true, corridor: 'right', triggerMaximumVh: 0.55,
|
id: 'contact', label: 'Contact', marker: '08', eligible: true, corridor: 'outer-right', triggerMaximumVh: 0.55,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -73,3 +78,18 @@ export function signViewportProfileFor(
|
|||||||
const aspect = safeWidth / safeHeight;
|
const aspect = safeWidth / safeHeight;
|
||||||
return safeHeight < 560 && aspect >= 1 ? 'short' : aspect < 1.05 ? 'tall' : 'wide';
|
return safeHeight < 560 && aspect >= 1 ? 'short' : aspect < 1.05 ? 'tall' : 'wide';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function signTriggerMaximumVhFor(
|
||||||
|
route: SignRouteSpec,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
): number {
|
||||||
|
const profile = signViewportProfileFor(width, height);
|
||||||
|
if (profile === 'tall') {
|
||||||
|
return Math.min(route.triggerMaximumVh, PORTRAIT_SIGN_TRIGGER_MAXIMUM_VH);
|
||||||
|
}
|
||||||
|
if (profile === 'short') {
|
||||||
|
return Math.min(route.triggerMaximumVh, SHORT_LANDSCAPE_SIGN_TRIGGER_MAXIMUM_VH);
|
||||||
|
}
|
||||||
|
return route.triggerMaximumVh;
|
||||||
|
}
|
||||||
|
|||||||
@@ -40,11 +40,14 @@ export interface SignSnapshot {
|
|||||||
progress: number;
|
progress: number;
|
||||||
opacity: number;
|
opacity: number;
|
||||||
viewportProfile?: SignViewportProfile;
|
viewportProfile?: SignViewportProfile;
|
||||||
|
navigationArrival?: true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SignControllerSample {
|
export interface SignControllerSample {
|
||||||
now: number;
|
now: number;
|
||||||
source: JourneySource;
|
source: JourneySource;
|
||||||
|
navigationEpoch: number;
|
||||||
|
navigationDestinationId: SectionId | null;
|
||||||
direction: -1 | 0 | 1;
|
direction: -1 | 0 | 1;
|
||||||
acceleration: number;
|
acceleration: number;
|
||||||
deltaY: number;
|
deltaY: number;
|
||||||
|
|||||||
@@ -577,6 +577,9 @@ function copyJourneySnapshot(snapshot: JourneySnapshot): JourneySnapshot {
|
|||||||
...(snapshot.sign.viewportProfile === undefined
|
...(snapshot.sign.viewportProfile === undefined
|
||||||
? {}
|
? {}
|
||||||
: { viewportProfile: snapshot.sign.viewportProfile }),
|
: { viewportProfile: snapshot.sign.viewportProfile }),
|
||||||
|
...(snapshot.sign.navigationArrival === true
|
||||||
|
? { navigationArrival: true as const }
|
||||||
|
: {}),
|
||||||
} : null,
|
} : null,
|
||||||
arrived: Boolean(snapshot.arrived),
|
arrived: Boolean(snapshot.arrived),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ export async function createProceduralSceneGeneration(
|
|||||||
await prepareSignTypography(context.signal);
|
await prepareSignTypography(context.signal);
|
||||||
const sign = createSign(context.resources, {
|
const sign = createSign(context.resources, {
|
||||||
maximumAnisotropy: renderer.capabilities.getMaxAnisotropy(),
|
maximumAnisotropy: renderer.capabilities.getMaxAnisotropy(),
|
||||||
|
maximumTextureSize: renderer.capabilities.maxTextureSize,
|
||||||
});
|
});
|
||||||
const atmosphere = createAtmosphere(context.resources, quality);
|
const atmosphere = createAtmosphere(context.resources, quality);
|
||||||
scene.add(
|
scene.add(
|
||||||
@@ -228,7 +229,7 @@ export async function createProceduralSceneGeneration(
|
|||||||
response.bloomEnergy,
|
response.bloomEnergy,
|
||||||
);
|
);
|
||||||
car.update(visualSpeed, response.suspensionEnergy, elapsedSeconds);
|
car.update(visualSpeed, response.suspensionEnergy, elapsedSeconds);
|
||||||
sign.update(snapshot.sign);
|
sign.update(snapshot.sign, now);
|
||||||
atmosphere.update(
|
atmosphere.update(
|
||||||
driveDistance,
|
driveDistance,
|
||||||
visualSpeed,
|
visualSpeed,
|
||||||
@@ -310,6 +311,7 @@ export async function createProceduralSceneGeneration(
|
|||||||
direction: snapshot.direction,
|
direction: snapshot.direction,
|
||||||
...presentation,
|
...presentation,
|
||||||
rendererPixelRatio: renderer.getPixelRatio(),
|
rendererPixelRatio: renderer.getPixelRatio(),
|
||||||
|
viewport: { width, height },
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import * as THREE from 'three';
|
|||||||
import type { SectionId } from '@/content/types';
|
import type { SectionId } from '@/content/types';
|
||||||
import type { SignSnapshot } from '@/journey/types';
|
import type { SignSnapshot } from '@/journey/types';
|
||||||
import {
|
import {
|
||||||
|
SIGN_LIFECYCLE,
|
||||||
signRouteFor,
|
signRouteFor,
|
||||||
signViewportProfileFor,
|
signViewportProfileFor,
|
||||||
type SignCorridor,
|
type SignCorridor,
|
||||||
@@ -28,64 +29,77 @@ interface ProjectingCamera {
|
|||||||
|
|
||||||
const SIGN_MARKER_FONT = '560 46px "Geist Mono Variable", ui-monospace, monospace';
|
const SIGN_MARKER_FONT = '560 46px "Geist Mono Variable", ui-monospace, monospace';
|
||||||
const SIGN_LABEL_FONT = '660 164px "Geist Mono Variable", ui-monospace, monospace';
|
const SIGN_LABEL_FONT = '660 164px "Geist Mono Variable", ui-monospace, monospace';
|
||||||
|
const SHORT_CORRIDOR_REFERENCE_ASPECT = 1.58;
|
||||||
|
const SHORT_CORRIDOR_BASE_RELIEF = 20;
|
||||||
|
const SHORT_CORRIDOR_RELIEF_PER_ASPECT = 20;
|
||||||
|
const SHORT_CORRIDOR_MINIMUM_RELIEF = 12;
|
||||||
|
const SHORT_CORRIDOR_MAXIMUM_RELIEF = 34;
|
||||||
|
const RESPONSIVE_REFLOW_MS = 320;
|
||||||
|
|
||||||
const SIGN_POSES: Readonly<Record<SignViewportProfile, Readonly<Record<SignCorridor, SignPoseSet>>>> = {
|
const SIGN_POSES: Readonly<Record<SignViewportProfile, Readonly<Record<SignCorridor, SignPoseSet>>>> = {
|
||||||
wide: {
|
wide: {
|
||||||
left: {
|
left: {
|
||||||
horizon: { x: -9.6, y: 7.2, z: -156, scale: 0.2 },
|
horizon: { x: -9.6, y: 7.2, z: -156, scale: 0.2 },
|
||||||
dwell: { x: -11.6, y: 7.9, z: -16, scale: 1.25 },
|
dwell: { x: -11.9, y: 7.95, z: -16, scale: 1.36 },
|
||||||
},
|
},
|
||||||
right: {
|
right: {
|
||||||
horizon: { x: 10.2, y: 7.2, z: -156, scale: 0.2 },
|
horizon: { x: 10.2, y: 7.2, z: -156, scale: 0.2 },
|
||||||
dwell: { x: 12.4, y: 7.9, z: -16, scale: 1.25 },
|
dwell: { x: 12.8, y: 7.95, z: -16, scale: 1.36 },
|
||||||
},
|
},
|
||||||
'outer-left': {
|
'outer-left': {
|
||||||
horizon: { x: -17.5, y: 7.4, z: -168, scale: 0.2 },
|
horizon: { x: -21.5, y: 7.4, z: -168, scale: 0.2 },
|
||||||
dwell: { x: -21.5, y: 8.55, z: -24, scale: 1.35 },
|
dwell: { x: -30.4, y: 8.65, z: -24, scale: 1.46 },
|
||||||
},
|
},
|
||||||
'outer-right': {
|
'outer-right': {
|
||||||
horizon: { x: 18.5, y: 7.4, z: -168, scale: 0.2 },
|
horizon: { x: 26.5, y: 7.4, z: -168, scale: 0.2 },
|
||||||
dwell: { x: 22.5, y: 8.55, z: -24, scale: 1.35 },
|
dwell: { x: 41.8, y: 8.65, z: -24, scale: 1.46 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
tall: {
|
tall: {
|
||||||
left: {
|
left: {
|
||||||
horizon: { x: -4.2, y: 6.4, z: -136, scale: 0.16 },
|
horizon: { x: -4.2, y: 6.4, z: -136, scale: 0.16 },
|
||||||
dwell: { x: -5.2, y: 8.1, z: -24, scale: 1.3 },
|
dwell: { x: -5.4, y: 8.15, z: -24, scale: 1.4 },
|
||||||
},
|
},
|
||||||
right: {
|
right: {
|
||||||
horizon: { x: 4.2, y: 6.4, z: -136, scale: 0.16 },
|
horizon: { x: 4.2, y: 6.4, z: -136, scale: 0.16 },
|
||||||
dwell: { x: 5.2, y: 8.1, z: -24, scale: 1.3 },
|
dwell: { x: 5.4, y: 8.15, z: -24, scale: 1.4 },
|
||||||
},
|
},
|
||||||
'outer-left': {
|
'outer-left': {
|
||||||
horizon: { x: -5.8, y: 6.7, z: -148, scale: 0.16 },
|
horizon: { x: -5.8, y: 6.7, z: -148, scale: 0.16 },
|
||||||
dwell: { x: -7.2, y: 8.72, z: -30, scale: 1.4 },
|
dwell: { x: -7.6, y: 8.8, z: -30, scale: 1.48 },
|
||||||
},
|
},
|
||||||
'outer-right': {
|
'outer-right': {
|
||||||
horizon: { x: 5.8, y: 6.7, z: -148, scale: 0.16 },
|
horizon: { x: 5.8, y: 6.7, z: -148, scale: 0.16 },
|
||||||
dwell: { x: 7.2, y: 8.72, z: -30, scale: 1.4 },
|
dwell: { x: 7.6, y: 8.8, z: -30, scale: 1.48 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
short: {
|
short: {
|
||||||
left: {
|
left: {
|
||||||
horizon: { x: -7.8, y: 6.6, z: -144, scale: 0.18 },
|
horizon: { x: -7.8, y: 6.6, z: -144, scale: 0.18 },
|
||||||
dwell: { x: -8.8, y: 5.45, z: -26, scale: 2.55 },
|
dwell: { x: -8.8, y: 5.45, z: -26, scale: 2.62 },
|
||||||
},
|
},
|
||||||
right: {
|
right: {
|
||||||
horizon: { x: 7.8, y: 6.6, z: -144, scale: 0.18 },
|
horizon: { x: 7.8, y: 6.6, z: -144, scale: 0.18 },
|
||||||
dwell: { x: 8.8, y: 5.45, z: -26, scale: 2.55 },
|
dwell: { x: 8.8, y: 5.45, z: -26, scale: 2.62 },
|
||||||
},
|
},
|
||||||
'outer-left': {
|
'outer-left': {
|
||||||
horizon: { x: -11.2, y: 6.8, z: -154, scale: 0.18 },
|
horizon: { x: -14.2, y: 6.8, z: -154, scale: 0.18 },
|
||||||
dwell: { x: -12.6, y: 5.8, z: -32, scale: 2.25 },
|
dwell: { x: -26.5, y: 5.8, z: -32, scale: 2.3 },
|
||||||
},
|
},
|
||||||
'outer-right': {
|
'outer-right': {
|
||||||
horizon: { x: 11.2, y: 6.8, z: -154, scale: 0.18 },
|
horizon: { x: 14.2, y: 6.8, z: -154, scale: 0.18 },
|
||||||
dwell: { x: 12.6, y: 5.8, z: -32, scale: 2.25 },
|
dwell: { x: 26.5, y: 5.8, z: -32, scale: 2.3 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const PORTRAIT_NAVIGATION_DWELL: Readonly<Record<SignCorridor, SignPose>> = {
|
||||||
|
left: { x: -2.9, y: 3, z: 8, scale: 0.25 },
|
||||||
|
right: { x: 2.9, y: 3, z: 8, scale: 0.25 },
|
||||||
|
'outer-left': { x: -2.9, y: 3, z: 8, scale: 0.25 },
|
||||||
|
'outer-right': { x: 2.9, y: 3, z: 8, scale: 0.25 },
|
||||||
|
};
|
||||||
|
|
||||||
export interface SignWorld {
|
export interface SignWorld {
|
||||||
group: SignGroup;
|
group: SignGroup;
|
||||||
inspect(
|
inspect(
|
||||||
@@ -95,7 +109,7 @@ export interface SignWorld {
|
|||||||
): SignPresentation | null;
|
): SignPresentation | null;
|
||||||
setViewportAspect(aspect: number): void;
|
setViewportAspect(aspect: number): void;
|
||||||
setViewportSize(width: number, height: number): void;
|
setViewportSize(width: number, height: number): void;
|
||||||
update(sign: SignSnapshot | null): void;
|
update(sign: SignSnapshot | null, now?: number): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SignPresentation {
|
export interface SignPresentation {
|
||||||
@@ -113,6 +127,7 @@ export interface SignPresentation {
|
|||||||
texture: {
|
texture: {
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
|
maximumSize: number;
|
||||||
anisotropy: number;
|
anisotropy: number;
|
||||||
fontReadyWhenDrawn: boolean;
|
fontReadyWhenDrawn: boolean;
|
||||||
};
|
};
|
||||||
@@ -120,6 +135,7 @@ export interface SignPresentation {
|
|||||||
|
|
||||||
export interface SignRenderOptions {
|
export interface SignRenderOptions {
|
||||||
maximumAnisotropy?: number;
|
maximumAnisotropy?: number;
|
||||||
|
maximumTextureSize?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SignGroup {
|
interface SignGroup {
|
||||||
@@ -163,67 +179,84 @@ export function createSign(
|
|||||||
group.visible = false;
|
group.visible = false;
|
||||||
|
|
||||||
const housingMaterial = resources.own('material', new THREE.MeshStandardMaterial({
|
const housingMaterial = resources.own('material', new THREE.MeshStandardMaterial({
|
||||||
color: 0x071019,
|
color: 0x08131c,
|
||||||
roughness: 0.58,
|
roughness: 0.5,
|
||||||
metalness: 0.66,
|
metalness: 0.7,
|
||||||
transparent: true,
|
transparent: true,
|
||||||
opacity: 0,
|
opacity: 0,
|
||||||
}));
|
}));
|
||||||
const backplateMaterial = resources.own('material', new THREE.MeshStandardMaterial({
|
const backplateMaterial = resources.own('material', new THREE.MeshStandardMaterial({
|
||||||
color: 0x111d27,
|
color: 0x172731,
|
||||||
roughness: 0.78,
|
roughness: 0.72,
|
||||||
metalness: 0.34,
|
metalness: 0.38,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0,
|
||||||
|
}));
|
||||||
|
const facePanelMaterial = resources.own('material', new THREE.MeshStandardMaterial({
|
||||||
|
color: 0x061018,
|
||||||
|
roughness: 0.9,
|
||||||
|
metalness: 0.12,
|
||||||
transparent: true,
|
transparent: true,
|
||||||
opacity: 0,
|
opacity: 0,
|
||||||
}));
|
}));
|
||||||
const supportMaterial = resources.own('material', new THREE.MeshStandardMaterial({
|
const supportMaterial = resources.own('material', new THREE.MeshStandardMaterial({
|
||||||
color: 0x142631,
|
color: 0x21343e,
|
||||||
roughness: 0.52,
|
roughness: 0.56,
|
||||||
metalness: 0.72,
|
metalness: 0.68,
|
||||||
transparent: true,
|
transparent: true,
|
||||||
opacity: 0,
|
opacity: 0,
|
||||||
}));
|
}));
|
||||||
const edgeMaterial = resources.own('material', new THREE.MeshStandardMaterial({
|
const edgeMaterial = resources.own('material', new THREE.MeshStandardMaterial({
|
||||||
color: 0x3a8892,
|
color: 0x39747a,
|
||||||
emissive: 0x53d2dc,
|
emissive: 0x55b9c0,
|
||||||
emissiveIntensity: 1.15,
|
emissiveIntensity: 0.42,
|
||||||
roughness: 0.34,
|
|
||||||
metalness: 0.58,
|
|
||||||
transparent: true,
|
|
||||||
opacity: 0,
|
|
||||||
}));
|
|
||||||
const routeMaterial = resources.own('material', new THREE.MeshStandardMaterial({
|
|
||||||
color: 0x8b522d,
|
|
||||||
emissive: 0xe49a58,
|
|
||||||
emissiveIntensity: 0.72,
|
|
||||||
roughness: 0.42,
|
roughness: 0.42,
|
||||||
metalness: 0.5,
|
metalness: 0.5,
|
||||||
transparent: true,
|
transparent: true,
|
||||||
opacity: 0,
|
opacity: 0,
|
||||||
}));
|
}));
|
||||||
|
const routeMaterial = resources.own('material', new THREE.MeshStandardMaterial({
|
||||||
|
color: 0x8e6446,
|
||||||
|
emissive: 0xb9723f,
|
||||||
|
emissiveIntensity: 0.28,
|
||||||
|
roughness: 0.48,
|
||||||
|
metalness: 0.42,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0,
|
||||||
|
}));
|
||||||
|
|
||||||
const housing = new THREE.Mesh(
|
const housing = new THREE.Mesh(
|
||||||
resources.own('geometry', createSignBody(8.55, 2.65, 0.42, 0.22)),
|
resources.own('geometry', createSignBody(8.64, 2.72, 0.5, 0.24)),
|
||||||
housingMaterial,
|
housingMaterial,
|
||||||
);
|
);
|
||||||
housing.name = 'sign-housing';
|
housing.name = 'sign-housing';
|
||||||
housing.position.z = -0.05;
|
housing.position.z = -0.08;
|
||||||
group.add(housing);
|
group.add(housing);
|
||||||
|
|
||||||
const backplate = new THREE.Mesh(
|
const backplate = new THREE.Mesh(
|
||||||
resources.own('geometry', createSignBody(8.24, 2.34, 0.2, 0.18)),
|
resources.own('geometry', createSignBody(8.3, 2.38, 0.2, 0.18)),
|
||||||
backplateMaterial,
|
backplateMaterial,
|
||||||
);
|
);
|
||||||
backplate.name = 'sign-backplate';
|
backplate.name = 'sign-backplate';
|
||||||
backplate.position.z = 0.17;
|
backplate.position.z = 0.15;
|
||||||
group.add(backplate);
|
group.add(backplate);
|
||||||
|
|
||||||
|
const facePanel = new THREE.Mesh(
|
||||||
|
resources.own('geometry', createSignBody(8.08, 2.08, 0.1, 0.14)),
|
||||||
|
facePanelMaterial,
|
||||||
|
);
|
||||||
|
facePanel.name = 'sign-face-panel';
|
||||||
|
facePanel.position.z = 0.43;
|
||||||
|
group.add(facePanel);
|
||||||
|
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
canvas.width = 2_048;
|
const maximumTextureSize = normalizeTextureLimit(options.maximumTextureSize);
|
||||||
canvas.height = 512;
|
canvas.width = Math.min(2_048, powerOfTwoAtMost(maximumTextureSize));
|
||||||
|
canvas.height = Math.max(32, canvas.width / 4);
|
||||||
const context = canvas.getContext('2d');
|
const context = canvas.getContext('2d');
|
||||||
const texture = resources.own('texture', new THREE.CanvasTexture(canvas));
|
const texture = resources.own('texture', new THREE.CanvasTexture(canvas));
|
||||||
texture.colorSpace = THREE.SRGBColorSpace;
|
texture.colorSpace = THREE.SRGBColorSpace;
|
||||||
|
texture.premultiplyAlpha = true;
|
||||||
texture.generateMipmaps = true;
|
texture.generateMipmaps = true;
|
||||||
texture.minFilter = THREE.LinearMipmapLinearFilter;
|
texture.minFilter = THREE.LinearMipmapLinearFilter;
|
||||||
texture.magFilter = THREE.LinearFilter;
|
texture.magFilter = THREE.LinearFilter;
|
||||||
@@ -240,6 +273,7 @@ export function createSign(
|
|||||||
polygonOffset: true,
|
polygonOffset: true,
|
||||||
polygonOffsetFactor: -1,
|
polygonOffsetFactor: -1,
|
||||||
polygonOffsetUnits: -1,
|
polygonOffsetUnits: -1,
|
||||||
|
depthWrite: false,
|
||||||
toneMapped: false,
|
toneMapped: false,
|
||||||
}));
|
}));
|
||||||
const face = new THREE.Mesh(
|
const face = new THREE.Mesh(
|
||||||
@@ -247,79 +281,123 @@ export function createSign(
|
|||||||
faceMaterial,
|
faceMaterial,
|
||||||
);
|
);
|
||||||
face.name = 'sign-face';
|
face.name = 'sign-face';
|
||||||
face.position.z = 0.625;
|
face.position.z = 0.54;
|
||||||
face.userData.textureUpdateCount = 0;
|
face.userData.textureUpdateCount = 0;
|
||||||
face.userData.fontReadyWhenDrawn = false;
|
face.userData.fontReadyWhenDrawn = false;
|
||||||
group.add(face);
|
group.add(face);
|
||||||
|
|
||||||
const edgeLight = new THREE.Group();
|
const edgeLight = new THREE.Group();
|
||||||
edgeLight.name = 'sign-edge-light';
|
edgeLight.name = 'sign-edge-light';
|
||||||
const edgeGeometry = resources.own('geometry', new THREE.BoxGeometry(0.055, 1.62, 0.055));
|
const edgeGeometry = resources.own('geometry', new THREE.BoxGeometry(0.045, 1.56, 0.045));
|
||||||
const upperEdgeGeometry = resources.own('geometry', new THREE.BoxGeometry(1.32, 0.045, 0.055));
|
const upperEdgeGeometry = resources.own('geometry', new THREE.BoxGeometry(1.18, 0.04, 0.045));
|
||||||
const leftEdge = new THREE.Mesh(edgeGeometry, edgeMaterial);
|
const leftEdge = new THREE.Mesh(edgeGeometry, edgeMaterial);
|
||||||
leftEdge.position.set(-3.9, 0, 0.665);
|
leftEdge.position.set(-3.9, 0, 0.57);
|
||||||
edgeLight.add(leftEdge);
|
edgeLight.add(leftEdge);
|
||||||
const upperEdge = new THREE.Mesh(upperEdgeGeometry, edgeMaterial);
|
const upperEdge = new THREE.Mesh(upperEdgeGeometry, edgeMaterial);
|
||||||
upperEdge.position.set(-3.28, 1, 0.665);
|
upperEdge.position.set(-3.33, 1, 0.57);
|
||||||
edgeLight.add(upperEdge);
|
edgeLight.add(upperEdge);
|
||||||
group.add(edgeLight);
|
group.add(edgeLight);
|
||||||
|
|
||||||
const routeAccent = new THREE.Mesh(
|
const routeAccent = new THREE.Mesh(
|
||||||
resources.own('geometry', new THREE.BoxGeometry(1.18, 0.055, 0.065)),
|
resources.own('geometry', new THREE.BoxGeometry(1.08, 0.045, 0.055)),
|
||||||
routeMaterial,
|
routeMaterial,
|
||||||
);
|
);
|
||||||
routeAccent.name = 'sign-route-accent';
|
routeAccent.name = 'sign-route-accent';
|
||||||
routeAccent.position.set(3.35, -1, 0.665);
|
routeAccent.position.set(3.38, -1, 0.57);
|
||||||
group.add(routeAccent);
|
group.add(routeAccent);
|
||||||
|
|
||||||
|
const backRails = new THREE.Group();
|
||||||
|
backRails.name = 'sign-back-rails';
|
||||||
|
const backRailGeometry = resources.own('geometry', new THREE.BoxGeometry(7.15, 0.13, 0.16));
|
||||||
|
for (const y of [-0.68, 0.68]) {
|
||||||
|
const rail = new THREE.Mesh(backRailGeometry, supportMaterial);
|
||||||
|
rail.position.set(0, y, -0.38);
|
||||||
|
backRails.add(rail);
|
||||||
|
}
|
||||||
|
group.add(backRails);
|
||||||
|
|
||||||
|
const postGeometry = resources.own('geometry', new THREE.BoxGeometry(0.22, 5.05, 0.26));
|
||||||
const post = new THREE.Mesh(
|
const post = new THREE.Mesh(
|
||||||
resources.own('geometry', new THREE.CylinderGeometry(0.095, 0.145, 5.05, 8)),
|
postGeometry,
|
||||||
supportMaterial,
|
supportMaterial,
|
||||||
);
|
);
|
||||||
post.name = 'sign-post';
|
post.name = 'sign-post';
|
||||||
post.position.set(2.75, -3.58, -0.16);
|
post.position.set(2.62, -3.58, -0.38);
|
||||||
group.add(post);
|
group.add(post);
|
||||||
|
|
||||||
|
const secondaryPost = new THREE.Mesh(postGeometry, supportMaterial);
|
||||||
|
secondaryPost.name = 'sign-post-secondary';
|
||||||
|
secondaryPost.position.set(-2.62, -3.58, -0.38);
|
||||||
|
group.add(secondaryPost);
|
||||||
|
const mountedPosts = [post, secondaryPost] as const;
|
||||||
|
|
||||||
const bracket = new THREE.Group();
|
const bracket = new THREE.Group();
|
||||||
bracket.name = 'sign-bracket';
|
bracket.name = 'sign-bracket';
|
||||||
const arm = new THREE.Mesh(
|
const arm = new THREE.Mesh(
|
||||||
resources.own('geometry', new THREE.BoxGeometry(2.95, 0.11, 0.15)),
|
resources.own('geometry', new THREE.BoxGeometry(6.1, 0.13, 0.18)),
|
||||||
supportMaterial,
|
supportMaterial,
|
||||||
);
|
);
|
||||||
arm.position.set(1.4, -1.24, -0.15);
|
arm.position.set(0, -1.24, -0.38);
|
||||||
bracket.add(arm);
|
bracket.add(arm);
|
||||||
const brace = new THREE.Mesh(
|
const braceGeometry = resources.own('geometry', new THREE.BoxGeometry(0.12, 1.18, 0.14));
|
||||||
resources.own('geometry', new THREE.BoxGeometry(0.1, 1.25, 0.12)),
|
for (const side of [-1, 1]) {
|
||||||
supportMaterial,
|
const brace = new THREE.Mesh(braceGeometry, supportMaterial);
|
||||||
);
|
brace.position.set(side * 2.18, -1.68, -0.38);
|
||||||
brace.position.set(2.05, -1.74, -0.15);
|
brace.rotation.z = side * -0.72;
|
||||||
brace.rotation.z = -0.72;
|
|
||||||
bracket.add(brace);
|
bracket.add(brace);
|
||||||
|
}
|
||||||
group.add(bracket);
|
group.add(bracket);
|
||||||
|
|
||||||
const footing = new THREE.Mesh(
|
const fasteners = new THREE.Group();
|
||||||
resources.own('geometry', new THREE.CylinderGeometry(0.28, 0.34, 0.34, 8)),
|
fasteners.name = 'sign-fasteners';
|
||||||
supportMaterial,
|
const fastenerGeometry = resources.own('geometry', new THREE.CylinderGeometry(0.055, 0.055, 0.045, 8));
|
||||||
);
|
for (const x of [-3.72, 3.72]) {
|
||||||
|
for (const y of [-0.78, 0.78]) {
|
||||||
|
const fastener = new THREE.Mesh(fastenerGeometry, supportMaterial);
|
||||||
|
fastener.position.set(x, y, 0.57);
|
||||||
|
fastener.rotation.x = Math.PI * 0.5;
|
||||||
|
fasteners.add(fastener);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
group.add(fasteners);
|
||||||
|
|
||||||
|
const footing = new THREE.Group();
|
||||||
footing.name = 'sign-footing';
|
footing.name = 'sign-footing';
|
||||||
footing.position.set(2.75, -6.18, -0.16);
|
const footingGeometry = resources.own('geometry', new THREE.CylinderGeometry(0.3, 0.38, 0.32, 8));
|
||||||
|
for (const x of [-2.62, 2.62]) {
|
||||||
|
const base = new THREE.Mesh(footingGeometry, supportMaterial);
|
||||||
|
base.position.x = x;
|
||||||
|
footing.add(base);
|
||||||
|
}
|
||||||
|
footing.position.set(0, -6.18, -0.38);
|
||||||
group.add(footing);
|
group.add(footing);
|
||||||
|
|
||||||
const fadeMaterials = [
|
const fadeMaterials = [
|
||||||
[housingMaterial, 0.97],
|
[housingMaterial, 0.97],
|
||||||
[backplateMaterial, 1],
|
[backplateMaterial, 1],
|
||||||
|
[facePanelMaterial, 1],
|
||||||
[supportMaterial, 0.94],
|
[supportMaterial, 0.94],
|
||||||
[edgeMaterial, 0.76],
|
[edgeMaterial, 0.76],
|
||||||
[routeMaterial, 0.82],
|
[routeMaterial, 0.82],
|
||||||
[faceMaterial, 1],
|
[faceMaterial, 1],
|
||||||
] as const;
|
] as const;
|
||||||
const departureOrigin = new THREE.Vector3();
|
const departureOrigin = new THREE.Vector3();
|
||||||
|
const poseTransitionOrigin = new THREE.Vector3();
|
||||||
|
const poseTransitionDestination = new THREE.Vector3();
|
||||||
let departureScale = 1;
|
let departureScale = 1;
|
||||||
|
let poseTransitionOriginScale = 1;
|
||||||
|
let poseTransitionOriginRotationY = 0;
|
||||||
|
let poseTransitionPending = false;
|
||||||
|
let poseTransitionStartedAt: number | null = null;
|
||||||
let target: SectionId | null = null;
|
let target: SectionId | null = null;
|
||||||
let pendingViewport: SignViewportProfile = 'wide';
|
let pendingViewport: SignViewportProfile = 'wide';
|
||||||
let activeViewport: SignViewportProfile = 'wide';
|
let activeViewport: SignViewportProfile = 'wide';
|
||||||
|
let pendingAspect = 16 / 9;
|
||||||
|
let activeAspect = 16 / 9;
|
||||||
|
let activeNavigationArrival = false;
|
||||||
let activeCorridor: SignCorridor = 'right';
|
let activeCorridor: SignCorridor = 'right';
|
||||||
let previousPhase: SignSnapshot['phase'] | null = null;
|
let previousPhase: SignSnapshot['phase'] | null = null;
|
||||||
|
let renderedOpacity = 0;
|
||||||
const faceCornerCoordinates = [
|
const faceCornerCoordinates = [
|
||||||
[-4, -1, 0],
|
[-4, -1, 0],
|
||||||
[4, -1, 0],
|
[4, -1, 0],
|
||||||
@@ -341,8 +419,34 @@ export function createSign(
|
|||||||
for (const [material, weight] of fadeMaterials) material.opacity = opacity * weight;
|
for (const [material, weight] of fadeMaterials) material.opacity = opacity * weight;
|
||||||
};
|
};
|
||||||
|
|
||||||
const applyPose = (pose: SignPose): void => {
|
const shortCorridorRelief = (): number => activeViewport === 'short'
|
||||||
group.position.set(pose.x, pose.y, pose.z);
|
? THREE.MathUtils.clamp(
|
||||||
|
SHORT_CORRIDOR_BASE_RELIEF
|
||||||
|
+ (activeAspect - SHORT_CORRIDOR_REFERENCE_ASPECT)
|
||||||
|
* SHORT_CORRIDOR_RELIEF_PER_ASPECT,
|
||||||
|
SHORT_CORRIDOR_MINIMUM_RELIEF,
|
||||||
|
SHORT_CORRIDOR_MAXIMUM_RELIEF,
|
||||||
|
)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const resolvedPoseX = (
|
||||||
|
pose: SignPose,
|
||||||
|
side: number,
|
||||||
|
weight: number,
|
||||||
|
routeOffsetX = 0,
|
||||||
|
): number => pose.x + side * shortCorridorRelief() * weight + routeOffsetX * weight;
|
||||||
|
|
||||||
|
const applyPose = (
|
||||||
|
pose: SignPose,
|
||||||
|
side: number,
|
||||||
|
lateralWeight: number,
|
||||||
|
routeOffsetX = 0,
|
||||||
|
): void => {
|
||||||
|
group.position.set(
|
||||||
|
resolvedPoseX(pose, side, lateralWeight, routeOffsetX),
|
||||||
|
pose.y,
|
||||||
|
pose.z,
|
||||||
|
);
|
||||||
group.scale.setScalar(pose.scale);
|
group.scale.setScalar(pose.scale);
|
||||||
plantMounting(pose.y, pose.scale);
|
plantMounting(pose.y, pose.scale);
|
||||||
};
|
};
|
||||||
@@ -352,10 +456,38 @@ export function createSign(
|
|||||||
const footingY = -groupY / safeScale;
|
const footingY = -groupY / safeScale;
|
||||||
const postTopY = -1.36;
|
const postTopY = -1.36;
|
||||||
const postHeight = Math.max(0.36, postTopY - footingY);
|
const postHeight = Math.max(0.36, postTopY - footingY);
|
||||||
post.position.y = (postTopY + footingY) * 0.5;
|
for (const mountedPost of mountedPosts) {
|
||||||
post.scale.set(1 / safeScale, postHeight / 5.05, 1 / safeScale);
|
mountedPost.position.y = (postTopY + footingY) * 0.5;
|
||||||
|
mountedPost.scale.set(1, postHeight / 5.05, 1);
|
||||||
|
}
|
||||||
footing.position.y = footingY;
|
footing.position.y = footingY;
|
||||||
footing.scale.setScalar(1 / Math.max(1, safeScale));
|
footing.scale.setScalar(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stagePoseTransition = (): void => {
|
||||||
|
if (previousPhase === null || previousPhase === 'depart') return;
|
||||||
|
poseTransitionOrigin.copy(group.position);
|
||||||
|
poseTransitionOriginScale = group.scale.x;
|
||||||
|
poseTransitionOriginRotationY = group.rotation.y;
|
||||||
|
poseTransitionPending = true;
|
||||||
|
poseTransitionStartedAt = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const stageResponsiveViewport = (
|
||||||
|
viewport: SignViewportProfile,
|
||||||
|
aspect: number,
|
||||||
|
): void => {
|
||||||
|
const changed = viewport !== pendingViewport
|
||||||
|
|| Math.abs(aspect - pendingAspect) > 0.001;
|
||||||
|
if (changed) stagePoseTransition();
|
||||||
|
pendingViewport = viewport;
|
||||||
|
pendingAspect = aspect;
|
||||||
|
if (previousPhase === null) {
|
||||||
|
activeViewport = pendingViewport;
|
||||||
|
activeAspect = pendingAspect;
|
||||||
|
poseTransitionPending = false;
|
||||||
|
poseTransitionStartedAt = null;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -395,32 +527,66 @@ export function createSign(
|
|||||||
texture: {
|
texture: {
|
||||||
width: canvas.width,
|
width: canvas.width,
|
||||||
height: canvas.height,
|
height: canvas.height,
|
||||||
|
maximumSize: maximumTextureSize,
|
||||||
anisotropy: texture.anisotropy,
|
anisotropy: texture.anisotropy,
|
||||||
fontReadyWhenDrawn: face.userData.fontReadyWhenDrawn === true,
|
fontReadyWhenDrawn: face.userData.fontReadyWhenDrawn === true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
setViewportAspect(aspect) {
|
setViewportAspect(aspect) {
|
||||||
pendingViewport = !Number.isFinite(aspect) || aspect < 0.85 ? 'tall' : 'wide';
|
const nextAspect = Number.isFinite(aspect) && aspect > 0 ? aspect : 1;
|
||||||
if (previousPhase === null) activeViewport = pendingViewport;
|
stageResponsiveViewport(nextAspect < 0.85 ? 'tall' : 'wide', nextAspect);
|
||||||
},
|
},
|
||||||
setViewportSize(width, height) {
|
setViewportSize(width, height) {
|
||||||
pendingViewport = signViewportProfileFor(width, height);
|
const nextAspect = Number.isFinite(width) && Number.isFinite(height) && height > 0
|
||||||
if (previousPhase === null) activeViewport = pendingViewport;
|
? Math.max(1, width) / height
|
||||||
|
: 1;
|
||||||
|
stageResponsiveViewport(signViewportProfileFor(width, height), nextAspect);
|
||||||
},
|
},
|
||||||
update(sign) {
|
update(sign, now) {
|
||||||
if (!sign) {
|
if (!sign) {
|
||||||
group.visible = false;
|
group.visible = false;
|
||||||
applyOpacity(0);
|
applyOpacity(0);
|
||||||
|
renderedOpacity = 0;
|
||||||
previousPhase = null;
|
previousPhase = null;
|
||||||
activeViewport = pendingViewport;
|
activeViewport = pendingViewport;
|
||||||
|
activeAspect = pendingAspect;
|
||||||
|
activeNavigationArrival = false;
|
||||||
|
poseTransitionPending = false;
|
||||||
|
poseTransitionStartedAt = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (target !== null && target !== sign.targetId && group.visible) return;
|
if (
|
||||||
|
target !== null
|
||||||
|
&& target !== sign.targetId
|
||||||
|
&& renderedOpacity > SIGN_LIFECYCLE.concealedOpacity
|
||||||
|
) return;
|
||||||
|
const navigationArrival = sign.navigationArrival === true;
|
||||||
if (target !== sign.targetId) {
|
if (target !== sign.targetId) {
|
||||||
activeViewport = sign.viewportProfile ?? pendingViewport;
|
// The renderer owns projection. A quality-tier change can rebuild this world
|
||||||
|
// while a lifecycle is already visible, so its latched profile may describe
|
||||||
|
// the generation that was just disposed rather than this canvas.
|
||||||
|
activeViewport = pendingViewport;
|
||||||
|
activeAspect = pendingAspect;
|
||||||
|
activeNavigationArrival = navigationArrival;
|
||||||
|
poseTransitionPending = false;
|
||||||
|
poseTransitionStartedAt = null;
|
||||||
updateTexture(sign.targetId);
|
updateTexture(sign.targetId);
|
||||||
|
} else if (navigationArrival !== activeNavigationArrival) {
|
||||||
|
stagePoseTransition();
|
||||||
|
activeNavigationArrival = navigationArrival;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (poseTransitionPending && sign.phase === 'depart') {
|
||||||
|
poseTransitionPending = false;
|
||||||
|
poseTransitionStartedAt = null;
|
||||||
|
} else if (poseTransitionPending) {
|
||||||
|
activeViewport = pendingViewport;
|
||||||
|
activeAspect = pendingAspect;
|
||||||
|
if (poseTransitionStartedAt === null) {
|
||||||
|
poseTransitionStartedAt = Number.isFinite(now) ? now! : 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const opacity = clamp01(sign.opacity);
|
const opacity = clamp01(sign.opacity);
|
||||||
@@ -428,41 +594,83 @@ export function createSign(
|
|||||||
const route = signRouteFor(sign.targetId);
|
const route = signRouteFor(sign.targetId);
|
||||||
const corridor = route.corridor ?? 'right';
|
const corridor = route.corridor ?? 'right';
|
||||||
activeCorridor = corridor;
|
activeCorridor = corridor;
|
||||||
const { horizon, dwell } = SIGN_POSES[activeViewport][corridor];
|
const poseSet = SIGN_POSES[activeViewport][corridor];
|
||||||
|
const horizon = poseSet.horizon;
|
||||||
|
const dwell = activeViewport === 'tall' && activeNavigationArrival
|
||||||
|
? PORTRAIT_NAVIGATION_DWELL[corridor]
|
||||||
|
: poseSet.dwell;
|
||||||
const side = corridor.includes('left') ? -1 : 1;
|
const side = corridor.includes('left') ? -1 : 1;
|
||||||
|
const routeOffsetX = route.dwellOffsetX ?? 0;
|
||||||
|
|
||||||
if (sign.phase === 'anticipate') {
|
if (sign.phase === 'anticipate') {
|
||||||
applyPose(horizon);
|
applyPose(horizon, side, 0.35);
|
||||||
} else if (sign.phase === 'approach') {
|
} else if (sign.phase === 'approach') {
|
||||||
group.position.set(
|
group.position.set(
|
||||||
THREE.MathUtils.lerp(horizon.x, dwell.x, progress),
|
THREE.MathUtils.lerp(
|
||||||
|
resolvedPoseX(horizon, side, 0.35),
|
||||||
|
resolvedPoseX(dwell, side, 1, routeOffsetX),
|
||||||
|
progress,
|
||||||
|
),
|
||||||
THREE.MathUtils.lerp(horizon.y, dwell.y, progress),
|
THREE.MathUtils.lerp(horizon.y, dwell.y, progress),
|
||||||
THREE.MathUtils.lerp(horizon.z, dwell.z, progress),
|
THREE.MathUtils.lerp(horizon.z, dwell.z, progress),
|
||||||
);
|
);
|
||||||
group.scale.setScalar(THREE.MathUtils.lerp(horizon.scale, dwell.scale, progress));
|
group.scale.setScalar(THREE.MathUtils.lerp(horizon.scale, dwell.scale, progress));
|
||||||
plantMounting(group.position.y, group.scale.x);
|
plantMounting(group.position.y, group.scale.x);
|
||||||
} else if (sign.phase === 'dwell') {
|
} else if (sign.phase === 'dwell') {
|
||||||
applyPose(dwell);
|
applyPose(dwell, side, 1, routeOffsetX);
|
||||||
} else {
|
} else {
|
||||||
if (previousPhase !== 'depart') {
|
if (previousPhase !== 'depart') {
|
||||||
if (previousPhase === null) applyPose(dwell);
|
if (previousPhase === null) applyPose(dwell, side, 1, routeOffsetX);
|
||||||
departureOrigin.copy(group.position);
|
departureOrigin.copy(group.position);
|
||||||
departureScale = group.scale.x;
|
departureScale = group.scale.x;
|
||||||
}
|
}
|
||||||
|
const outerCorridor = corridor.startsWith('outer');
|
||||||
const lateralExit = activeViewport === 'wide'
|
const lateralExit = activeViewport === 'wide'
|
||||||
? 14
|
? outerCorridor ? 2.6 : 3.4
|
||||||
: activeViewport === 'short' ? 10 : 8;
|
: activeViewport === 'short' ? 2.2 : 2.4;
|
||||||
group.position.set(
|
group.position.set(
|
||||||
departureOrigin.x + progress * side * lateralExit,
|
departureOrigin.x + progress * side * lateralExit,
|
||||||
departureOrigin.y + progress * 0.72,
|
departureOrigin.y,
|
||||||
departureOrigin.z - progress * 32,
|
THREE.MathUtils.lerp(departureOrigin.z, 18, progress),
|
||||||
);
|
);
|
||||||
group.scale.setScalar(departureScale * THREE.MathUtils.lerp(1, 0.82, progress));
|
group.scale.setScalar(departureScale);
|
||||||
|
plantMounting(group.position.y, group.scale.x);
|
||||||
}
|
}
|
||||||
|
|
||||||
group.rotation.y = -side * (activeViewport === 'wide' ? 0.2 : 0.12);
|
const desiredRotationY = -side * (activeViewport === 'wide' ? 0.14 : 0.1);
|
||||||
|
if (poseTransitionPending) {
|
||||||
|
const elapsed = Number.isFinite(now)
|
||||||
|
? Math.max(0, now! - (poseTransitionStartedAt ?? now!))
|
||||||
|
: RESPONSIVE_REFLOW_MS;
|
||||||
|
const mix = smoothstep(clamp01(elapsed / RESPONSIVE_REFLOW_MS));
|
||||||
|
poseTransitionDestination.copy(group.position);
|
||||||
|
const poseTransitionDestinationScale = group.scale.x;
|
||||||
|
group.position.set(
|
||||||
|
THREE.MathUtils.lerp(poseTransitionOrigin.x, poseTransitionDestination.x, mix),
|
||||||
|
THREE.MathUtils.lerp(poseTransitionOrigin.y, poseTransitionDestination.y, mix),
|
||||||
|
THREE.MathUtils.lerp(poseTransitionOrigin.z, poseTransitionDestination.z, mix),
|
||||||
|
);
|
||||||
|
group.scale.setScalar(THREE.MathUtils.lerp(
|
||||||
|
poseTransitionOriginScale,
|
||||||
|
poseTransitionDestinationScale,
|
||||||
|
mix,
|
||||||
|
));
|
||||||
|
group.rotation.y = THREE.MathUtils.lerp(
|
||||||
|
poseTransitionOriginRotationY,
|
||||||
|
desiredRotationY,
|
||||||
|
mix,
|
||||||
|
);
|
||||||
|
plantMounting(group.position.y, group.scale.x);
|
||||||
|
if (mix >= 1) {
|
||||||
|
poseTransitionPending = false;
|
||||||
|
poseTransitionStartedAt = null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
group.rotation.y = desiredRotationY;
|
||||||
|
}
|
||||||
applyOpacity(opacity);
|
applyOpacity(opacity);
|
||||||
group.visible = opacity > 0;
|
group.visible = opacity > 0;
|
||||||
|
renderedOpacity = opacity;
|
||||||
previousPhase = sign.phase;
|
previousPhase = sign.phase;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -505,28 +713,75 @@ function drawLabel(
|
|||||||
height: number,
|
height: number,
|
||||||
): void {
|
): void {
|
||||||
context.clearRect(0, 0, width, height);
|
context.clearRect(0, 0, width, height);
|
||||||
context.fillStyle = '#07111a';
|
context.save();
|
||||||
context.fillRect(0, 0, width, height);
|
context.scale(width / 2_048, height / 512);
|
||||||
context.fillStyle = '#4cbac4';
|
context.imageSmoothingEnabled = true;
|
||||||
context.fillRect(86, 72, 12, height - 144);
|
context.imageSmoothingQuality = 'high';
|
||||||
context.fillStyle = '#879da4';
|
|
||||||
|
context.globalAlpha = 0.16;
|
||||||
|
context.fillStyle = '#8aa1a6';
|
||||||
|
for (const [x, y, scratchWidth] of [
|
||||||
|
[312, 84, 142],
|
||||||
|
[742, 424, 96],
|
||||||
|
[1_116, 66, 188],
|
||||||
|
[1_488, 446, 112],
|
||||||
|
] as const) {
|
||||||
|
context.fillRect(x, y, scratchWidth, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
context.globalAlpha = 1;
|
||||||
|
context.fillStyle = '#52bbc2';
|
||||||
|
context.fillRect(82, 72, 10, 368);
|
||||||
|
context.fillStyle = '#91a6a9';
|
||||||
context.font = SIGN_MARKER_FONT;
|
context.font = SIGN_MARKER_FONT;
|
||||||
context.textAlign = 'left';
|
context.textAlign = 'left';
|
||||||
context.textBaseline = 'middle';
|
context.textBaseline = 'middle';
|
||||||
context.fillText(`ROUTE ${route.marker} / NEXT`, 150, 126);
|
context.fillText(`ROUTE ${route.marker} / NEXT`, 142, 120);
|
||||||
context.fillStyle = '#c8d8da';
|
|
||||||
context.font = SIGN_LABEL_FONT;
|
context.globalAlpha = 0.6;
|
||||||
context.fillText(route.label, 150, 326, width - 420);
|
context.fillStyle = '#2f4650';
|
||||||
context.strokeStyle = '#cf8e54';
|
context.fillRect(142, 174, 1_610, 2);
|
||||||
context.lineWidth = 12;
|
|
||||||
|
context.globalAlpha = 1;
|
||||||
|
context.fillStyle = '#d7e4e2';
|
||||||
|
const labelSize = fittedLabelSize(context, route.label, 1_520);
|
||||||
|
context.font = `660 ${labelSize}px "Geist Mono Variable", ui-monospace, monospace`;
|
||||||
|
context.fillText(route.label, 142, 326);
|
||||||
|
|
||||||
|
context.strokeStyle = '#cb9160';
|
||||||
|
context.lineWidth = 10;
|
||||||
context.lineCap = 'square';
|
context.lineCap = 'square';
|
||||||
|
context.lineJoin = 'miter';
|
||||||
context.beginPath();
|
context.beginPath();
|
||||||
context.moveTo(width - 250, 304);
|
context.moveTo(1_776, 304);
|
||||||
context.lineTo(width - 158, 304);
|
context.lineTo(1_890, 304);
|
||||||
context.lineTo(width - 198, 264);
|
context.lineTo(1_846, 260);
|
||||||
context.moveTo(width - 158, 304);
|
context.moveTo(1_890, 304);
|
||||||
context.lineTo(width - 198, 344);
|
context.lineTo(1_846, 348);
|
||||||
context.stroke();
|
context.stroke();
|
||||||
|
context.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
function fittedLabelSize(
|
||||||
|
context: CanvasRenderingContext2D,
|
||||||
|
label: string,
|
||||||
|
maximumWidth: number,
|
||||||
|
): number {
|
||||||
|
for (let size = 164; size >= 112; size -= 4) {
|
||||||
|
context.font = `660 ${size}px "Geist Mono Variable", ui-monospace, monospace`;
|
||||||
|
if (context.measureText(label).width <= maximumWidth) return size;
|
||||||
|
}
|
||||||
|
return 112;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTextureLimit(value: number | undefined): number {
|
||||||
|
return value !== undefined && Number.isFinite(value)
|
||||||
|
? Math.max(32, Math.floor(value))
|
||||||
|
: 2_048;
|
||||||
|
}
|
||||||
|
|
||||||
|
function powerOfTwoAtMost(value: number): number {
|
||||||
|
return 2 ** Math.floor(Math.log2(Math.max(1, value)));
|
||||||
}
|
}
|
||||||
|
|
||||||
function clamp01(value: number): number {
|
function clamp01(value: number): number {
|
||||||
|
|||||||
@@ -198,10 +198,18 @@ test("explicit keyboard navigation focuses a section heading without a fixed-sur
|
|||||||
await page.keyboard.press("Enter");
|
await page.keyboard.press("Enter");
|
||||||
await expect(page.locator("#contact-heading")).toBeFocused();
|
await expect(page.locator("#contact-heading")).toBeFocused();
|
||||||
|
|
||||||
|
await expect.poll(async () => {
|
||||||
const geometry = await currentFocusGeometry(page);
|
const geometry = await currentFocusGeometry(page);
|
||||||
expect(geometry.focusVisible).toBe(true);
|
return {
|
||||||
expect(geometry.insideSafeBounds).toBe(true);
|
focusVisible: geometry.focusVisible,
|
||||||
expect(geometry.overlapArea).toBe(0);
|
insideSafeBounds: geometry.insideSafeBounds,
|
||||||
|
overlapArea: geometry.overlapArea,
|
||||||
|
};
|
||||||
|
}).toEqual({
|
||||||
|
focusVisible: true,
|
||||||
|
insideSafeBounds: true,
|
||||||
|
overlapArea: 0,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("responsive navigation exposes the exact visible label as each link's accessible name", async ({
|
test("responsive navigation exposes the exact visible label as each link's accessible name", async ({
|
||||||
|
|||||||
@@ -83,14 +83,19 @@ test('draws only the independently eligible next roadside sign during passive fo
|
|||||||
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
|
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
|
||||||
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', 'running');
|
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', 'running');
|
||||||
|
|
||||||
const oracle = await page.evaluate(() => {
|
const viewport = page.viewportSize();
|
||||||
|
if (viewport === null) throw new Error('Missing browser viewport');
|
||||||
|
const oracle = await page.evaluate((candidateDistanceVh) => {
|
||||||
const profile = document.getElementById('profile-heading');
|
const profile = document.getElementById('profile-heading');
|
||||||
if (!(profile instanceof HTMLElement)) throw new Error('Missing profile heading');
|
if (!(profile instanceof HTMLElement)) throw new Error('Missing profile heading');
|
||||||
const viewportHeight = window.visualViewport?.height ?? window.innerHeight;
|
const viewportHeight = window.visualViewport?.height ?? window.innerHeight;
|
||||||
const headingTop = profile.getBoundingClientRect().top + window.scrollY;
|
const headingTop = profile.getBoundingClientRect().top + window.scrollY;
|
||||||
const destination = Math.max(1, headingTop - viewportHeight * (0.35 + 0.45));
|
const destination = Math.max(
|
||||||
|
1,
|
||||||
|
headingTop - viewportHeight * (0.35 + candidateDistanceVh),
|
||||||
|
);
|
||||||
return { destination, viewportHeight, expectedLabel: 'Profile' };
|
return { destination, viewportHeight, expectedLabel: 'Profile' };
|
||||||
});
|
}, triggerProbeDistanceVh(viewport.width, viewport.height));
|
||||||
await page.evaluate(() => window.dispatchEvent(new WheelEvent('wheel', { deltaY: 1 })));
|
await page.evaluate(() => window.dispatchEvent(new WheelEvent('wheel', { deltaY: 1 })));
|
||||||
for (let y = 1; y < oracle.destination; y += oracle.viewportHeight * 0.5) {
|
for (let y = 1; y < oracle.destination; y += oracle.viewportHeight * 0.5) {
|
||||||
await page.evaluate((nextY) => window.scrollTo(0, nextY), y);
|
await page.evaluate((nextY) => window.scrollTo(0, nextY), y);
|
||||||
@@ -134,6 +139,21 @@ test('keeps a readable sign immutable through reversal, resize, and a multi-sect
|
|||||||
|
|
||||||
await page.mouse.wheel(0, -120);
|
await page.mouse.wheel(0, -120);
|
||||||
await page.setViewportSize({ width: 844, height: 390 });
|
await page.setViewportSize({ width: 844, height: 390 });
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
const resizedFrame = (await readSceneInstrumentation(page)).signFrames.findLast((frame) => (
|
||||||
|
frame.targetId === 'profile'
|
||||||
|
&& frame.viewport.width === 844
|
||||||
|
&& frame.viewport.height === 390
|
||||||
|
));
|
||||||
|
if (resizedFrame === undefined) throw new Error('Missing post-resize Profile frame');
|
||||||
|
expect(resizedFrame.phase).toBe('dwell');
|
||||||
|
expect(resizedFrame.viewportProfile).toBe('short');
|
||||||
|
expect(resizedFrame.bounds.left).toBeGreaterThanOrEqual(8);
|
||||||
|
expect(resizedFrame.bounds.right).toBeLessThanOrEqual(288);
|
||||||
|
expect(countSignPresentations(
|
||||||
|
(await readSceneInstrumentation(page)).signFrames,
|
||||||
|
'profile',
|
||||||
|
)).toBe(1);
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
const target = document.getElementById('selected-builds-heading');
|
const target = document.getElementById('selected-builds-heading');
|
||||||
if (!(target instanceof HTMLElement)) throw new Error('Missing selected builds heading');
|
if (!(target instanceof HTMLElement)) throw new Error('Missing selected builds heading');
|
||||||
@@ -146,7 +166,57 @@ test('keeps a readable sign immutable through reversal, resize, and a multi-sect
|
|||||||
expect((await readSceneInstrumentation(page)).maximumCanvasCount).toBeLessThanOrEqual(1);
|
expect((await readSceneInstrumentation(page)).maximumCanvasCount).toBeLessThanOrEqual(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('suppresses roadside signs for explicit navigation and history restoration', async ({
|
test('carries a departed sign past the car before its restrained edge fade', async ({
|
||||||
|
page,
|
||||||
|
distUrl,
|
||||||
|
}) => {
|
||||||
|
await page.setViewportSize({ width: 1_440, height: 900 });
|
||||||
|
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
|
||||||
|
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', 'running');
|
||||||
|
|
||||||
|
const viewport = page.viewportSize();
|
||||||
|
if (viewport === null) throw new Error('Missing browser viewport');
|
||||||
|
const destination = await page.evaluate((candidateDistanceVh) => {
|
||||||
|
const profile = document.getElementById('profile-heading');
|
||||||
|
if (!(profile instanceof HTMLElement)) throw new Error('Missing profile heading');
|
||||||
|
const viewportHeight = window.visualViewport?.height ?? window.innerHeight;
|
||||||
|
return Math.max(
|
||||||
|
1,
|
||||||
|
profile.getBoundingClientRect().top + window.scrollY
|
||||||
|
- viewportHeight * (0.35 + candidateDistanceVh),
|
||||||
|
);
|
||||||
|
}, triggerProbeDistanceVh(viewport.width, viewport.height));
|
||||||
|
await page.mouse.wheel(0, 1);
|
||||||
|
await page.evaluate((scrollY) => window.scrollTo(0, scrollY), destination);
|
||||||
|
await expect.poll(async () => (
|
||||||
|
await readSceneInstrumentation(page)
|
||||||
|
).signFrames.some((frame) => frame.targetId === 'profile' && frame.phase === 'dwell')).toBe(true);
|
||||||
|
await expect.poll(async () => (
|
||||||
|
await readSceneInstrumentation(page)
|
||||||
|
).signFrames.some((frame) => (
|
||||||
|
frame.targetId === 'profile'
|
||||||
|
&& frame.phase === 'depart'
|
||||||
|
&& frame.progress >= 0.52
|
||||||
|
)), { intervals: [16], timeout: 5_000 }).toBe(true);
|
||||||
|
|
||||||
|
const frames = (await readSceneInstrumentation(page)).signFrames;
|
||||||
|
const dwell = frames.findLast((frame) => frame.targetId === 'profile' && frame.phase === 'dwell');
|
||||||
|
const passing = frames.findLast((frame) => (
|
||||||
|
frame.targetId === 'profile'
|
||||||
|
&& frame.phase === 'depart'
|
||||||
|
&& frame.progress >= 0.3
|
||||||
|
&& frame.opacity >= 0.98
|
||||||
|
));
|
||||||
|
if (dwell === undefined || passing === undefined) {
|
||||||
|
throw new Error('Missing comparable Profile dwell and pass-by frames');
|
||||||
|
}
|
||||||
|
expect(passing.opacity).toBeGreaterThanOrEqual(0.98);
|
||||||
|
expect(passing.bounds.width).toBeGreaterThan(dwell.bounds.width * 1.18);
|
||||||
|
expect(passing.bounds.left).toBeLessThan(dwell.bounds.left - 24);
|
||||||
|
expect(passing.targetId).toBe(dwell.targetId);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows exactly one destination sign for a navigator click and suppresses history restoration', async ({
|
||||||
page,
|
page,
|
||||||
distUrl,
|
distUrl,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -155,15 +225,168 @@ test('suppresses roadside signs for explicit navigation and history restoration'
|
|||||||
|
|
||||||
await clickNavigationLink(page, '#selected-builds');
|
await clickNavigationLink(page, '#selected-builds');
|
||||||
await expect(page.locator('#selected-builds-heading')).toBeFocused();
|
await expect(page.locator('#selected-builds-heading')).toBeFocused();
|
||||||
await page.waitForTimeout(400);
|
await expect.poll(async () => (
|
||||||
expect((await readSceneInstrumentation(page)).routeLabelsDrawn).toEqual([]);
|
await readSceneInstrumentation(page)
|
||||||
|
).signFrames.findLast((frame) => (
|
||||||
|
frame.targetId === 'selected-builds'
|
||||||
|
))?.phase).toBe('dwell');
|
||||||
|
|
||||||
|
await clickNavigationLink(page, '#selected-builds');
|
||||||
|
await expect(page.locator('#selected-builds-heading')).toBeFocused();
|
||||||
|
await page.waitForTimeout(2_400);
|
||||||
|
const afterRepeat = await readSceneInstrumentation(page);
|
||||||
|
expect(afterRepeat.routeLabelsDrawn).toEqual(['Selected builds']);
|
||||||
|
expect(countSignPresentations(afterRepeat.signFrames, 'selected-builds')).toBe(1);
|
||||||
|
|
||||||
await page.goBack({ waitUntil: 'domcontentloaded' });
|
await page.goBack({ waitUntil: 'domcontentloaded' });
|
||||||
await page.waitForTimeout(400);
|
await page.waitForTimeout(400);
|
||||||
expect((await readSceneInstrumentation(page)).routeLabelsDrawn).toEqual([]);
|
expect((await readSceneInstrumentation(page)).routeLabelsDrawn).toEqual(['Selected builds']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('restores the next sign after a navigator round trip', async ({
|
test('keeps the non-navigator Explore work section jump sign-suppressed', async ({
|
||||||
|
page,
|
||||||
|
distUrl,
|
||||||
|
}) => {
|
||||||
|
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
|
||||||
|
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', 'running');
|
||||||
|
|
||||||
|
await page.locator('.hero-journey__action').click();
|
||||||
|
await expect(page.locator('#selected-builds-heading')).toBeFocused();
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
expect((await readSceneInstrumentation(page)).routeLabelsDrawn).toEqual([]);
|
||||||
|
|
||||||
|
const viewport = page.viewportSize();
|
||||||
|
if (viewport === null) throw new Error('Missing browser viewport');
|
||||||
|
const destination = await page.evaluate((candidateDistanceVh) => {
|
||||||
|
const heading = document.getElementById('additional-work-heading');
|
||||||
|
if (!(heading instanceof HTMLElement)) throw new Error('Missing Additional work heading');
|
||||||
|
const viewportHeight = window.visualViewport?.height ?? window.innerHeight;
|
||||||
|
return Math.max(
|
||||||
|
1,
|
||||||
|
heading.getBoundingClientRect().top + window.scrollY
|
||||||
|
- viewportHeight * (0.35 + candidateDistanceVh),
|
||||||
|
);
|
||||||
|
}, triggerProbeDistanceVh(viewport.width, viewport.height));
|
||||||
|
await page.mouse.wheel(0, 1);
|
||||||
|
await page.evaluate((scrollY) => window.scrollTo(0, scrollY), destination);
|
||||||
|
await expect.poll(async () => (
|
||||||
|
await readSceneInstrumentation(page)
|
||||||
|
).signFrames.findLast((frame) => frame.targetId === 'additional-work')?.phase).toBe('dwell');
|
||||||
|
expect((await readSceneInstrumentation(page)).routeLabelsDrawn).toEqual(['Additional work']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps a navigator-triggered Profile sign fully beside its visible destination panel', async ({
|
||||||
|
page,
|
||||||
|
distUrl,
|
||||||
|
}) => {
|
||||||
|
await page.setViewportSize({ width: 780, height: 493 });
|
||||||
|
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
|
||||||
|
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', 'running');
|
||||||
|
|
||||||
|
await clickNavigationLink(page, '#profile');
|
||||||
|
await expect(page.locator('#profile-heading')).toBeFocused();
|
||||||
|
await expect.poll(async () => (
|
||||||
|
await readSceneInstrumentation(page)
|
||||||
|
).signFrames.some((frame) => (
|
||||||
|
frame.targetId === 'profile' && frame.phase === 'dwell'
|
||||||
|
))).toBe(true);
|
||||||
|
|
||||||
|
const frame = (await readSceneInstrumentation(page)).signFrames.findLast((candidate) => (
|
||||||
|
candidate.targetId === 'profile' && candidate.phase === 'dwell'
|
||||||
|
));
|
||||||
|
if (frame === undefined) throw new Error('Missing navigator-triggered Profile dwell frame');
|
||||||
|
const panel = await page.locator('#profile').evaluate((element) => {
|
||||||
|
const bounds = element.getBoundingClientRect();
|
||||||
|
return { left: bounds.left, top: bounds.top, right: bounds.right, bottom: bounds.bottom };
|
||||||
|
});
|
||||||
|
expect(frame.bounds.right).toBeLessThanOrEqual(panel.left - 16);
|
||||||
|
expect(frame.bounds.left).toBeGreaterThanOrEqual(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps a portrait navigator sign clear of its full-width destination panel', async ({
|
||||||
|
page,
|
||||||
|
distUrl,
|
||||||
|
}, testInfo) => {
|
||||||
|
test.skip(testInfo.project.name !== 'desktop-chromium', 'One exact portrait composition is sufficient.');
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
|
||||||
|
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', 'running');
|
||||||
|
|
||||||
|
await clickNavigationLink(page, '#profile');
|
||||||
|
await expect(page.locator('#profile-heading')).toBeFocused();
|
||||||
|
await expect.poll(async () => (
|
||||||
|
await readSceneInstrumentation(page)
|
||||||
|
).signFrames.some((frame) => frame.targetId === 'profile' && frame.phase === 'dwell')).toBe(true);
|
||||||
|
const frame = (await readSceneInstrumentation(page)).signFrames.findLast((candidate) => (
|
||||||
|
candidate.targetId === 'profile' && candidate.phase === 'dwell'
|
||||||
|
));
|
||||||
|
if (frame === undefined) throw new Error('Missing portrait navigator Profile dwell frame');
|
||||||
|
const panels = await page.locator('#profile, #experience').evaluateAll((elements) => (
|
||||||
|
elements.map((element) => {
|
||||||
|
const bounds = element.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
id: element.id,
|
||||||
|
left: bounds.left,
|
||||||
|
top: bounds.top,
|
||||||
|
right: bounds.right,
|
||||||
|
bottom: bounds.bottom,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
));
|
||||||
|
for (const panel of panels) {
|
||||||
|
expect(
|
||||||
|
rectanglesOverlap(frame.bounds, panel, 16),
|
||||||
|
JSON.stringify({ frame: frame.bounds, panel }),
|
||||||
|
).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('moves an already-readable portrait sign into navigator-safe placement without replaying it', async ({
|
||||||
|
page,
|
||||||
|
distUrl,
|
||||||
|
}, testInfo) => {
|
||||||
|
test.skip(testInfo.project.name !== 'desktop-chromium', 'One exact portrait composition is sufficient.');
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
|
||||||
|
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', 'running');
|
||||||
|
|
||||||
|
const destination = await page.evaluate(() => {
|
||||||
|
const profile = document.getElementById('profile-heading');
|
||||||
|
if (!(profile instanceof HTMLElement)) throw new Error('Missing Profile heading');
|
||||||
|
const viewportHeight = window.visualViewport?.height ?? window.innerHeight;
|
||||||
|
return Math.max(
|
||||||
|
1,
|
||||||
|
profile.getBoundingClientRect().top + window.scrollY
|
||||||
|
- viewportHeight * (0.35 + 0.22),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await page.mouse.wheel(0, 1);
|
||||||
|
await page.evaluate((scrollY) => window.scrollTo(0, scrollY), destination);
|
||||||
|
await expect.poll(async () => (
|
||||||
|
await readSceneInstrumentation(page)
|
||||||
|
).signFrames.findLast((frame) => frame.targetId === 'profile')?.phase).toBe('dwell');
|
||||||
|
|
||||||
|
await clickNavigationLink(page, '#profile');
|
||||||
|
await expect(page.locator('#profile-heading')).toBeFocused();
|
||||||
|
const panel = await page.locator('#profile').evaluate((element) => {
|
||||||
|
const bounds = element.getBoundingClientRect();
|
||||||
|
return { left: bounds.left, top: bounds.top, right: bounds.right, bottom: bounds.bottom };
|
||||||
|
});
|
||||||
|
await expect.poll(async () => {
|
||||||
|
const frame = (await readSceneInstrumentation(page)).signFrames.findLast((candidate) => (
|
||||||
|
candidate.targetId === 'profile'
|
||||||
|
&& candidate.phase === 'dwell'
|
||||||
|
&& candidate.navigationArrival === true
|
||||||
|
));
|
||||||
|
return frame === undefined ? true : rectanglesOverlap(frame.bounds, panel, 16);
|
||||||
|
}, { intervals: [16], timeout: 5_000 }).toBe(false);
|
||||||
|
|
||||||
|
const instrumentation = await readSceneInstrumentation(page);
|
||||||
|
expect(countSignPresentations(instrumentation.signFrames, 'profile')).toBe(1);
|
||||||
|
expect(instrumentation.routeLabelsDrawn).toEqual(['Profile']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('re-arms passive signage after a navigator round trip to Start', async ({
|
||||||
page,
|
page,
|
||||||
distUrl,
|
distUrl,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -172,31 +395,39 @@ test('restores the next sign after a navigator round trip', async ({
|
|||||||
|
|
||||||
await clickNavigationLink(page, '#profile');
|
await clickNavigationLink(page, '#profile');
|
||||||
await expect(page.locator('#profile-heading')).toBeFocused();
|
await expect(page.locator('#profile-heading')).toBeFocused();
|
||||||
|
await expect.poll(async () => (
|
||||||
|
await readSceneInstrumentation(page)
|
||||||
|
).signFrames.some((frame) => frame.targetId === 'profile' && frame.phase === 'dwell')).toBe(true);
|
||||||
|
await page.waitForTimeout(2_500);
|
||||||
await clickNavigationLink(page, '#start');
|
await clickNavigationLink(page, '#start');
|
||||||
await expect(page.locator('#start-heading')).toBeFocused();
|
await expect(page.locator('#start-heading')).toBeFocused();
|
||||||
|
|
||||||
const destination = await page.evaluate(() => {
|
const viewport = page.viewportSize();
|
||||||
|
if (viewport === null) throw new Error('Missing browser viewport');
|
||||||
|
const destination = await page.evaluate((candidateDistanceVh) => {
|
||||||
const profile = document.getElementById('profile-heading');
|
const profile = document.getElementById('profile-heading');
|
||||||
if (!(profile instanceof HTMLElement)) throw new Error('Missing profile heading');
|
if (!(profile instanceof HTMLElement)) throw new Error('Missing profile heading');
|
||||||
const viewportHeight = window.visualViewport?.height ?? window.innerHeight;
|
const viewportHeight = window.visualViewport?.height ?? window.innerHeight;
|
||||||
return Math.max(
|
return Math.max(
|
||||||
1,
|
1,
|
||||||
profile.getBoundingClientRect().top + window.scrollY
|
profile.getBoundingClientRect().top + window.scrollY
|
||||||
- viewportHeight * (0.35 + 0.45),
|
- viewportHeight * (0.35 + candidateDistanceVh),
|
||||||
);
|
);
|
||||||
});
|
}, triggerProbeDistanceVh(viewport.width, viewport.height));
|
||||||
await page.mouse.wheel(0, 1);
|
await page.mouse.wheel(0, 1);
|
||||||
await page.evaluate((scrollY) => window.scrollTo(0, scrollY), destination);
|
await page.evaluate((scrollY) => window.scrollTo(0, scrollY), destination);
|
||||||
|
|
||||||
await expect.poll(async () => (
|
await expect.poll(async () => countSignPresentations(
|
||||||
await readSceneInstrumentation(page)
|
(await readSceneInstrumentation(page)).signFrames,
|
||||||
).signFrames.some((frame) => frame.targetId === 'profile')).toBe(true);
|
'profile',
|
||||||
|
)).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('reconciles an idle rapid jump to the latest active route without flashing intermediates', async ({
|
test('reconciles an idle rapid jump to the latest active route without flashing intermediates', async ({
|
||||||
page,
|
page,
|
||||||
distUrl,
|
distUrl,
|
||||||
}) => {
|
}) => {
|
||||||
|
await page.setViewportSize({ width: 1_440, height: 900 });
|
||||||
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
|
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
|
||||||
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', 'running');
|
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', 'running');
|
||||||
|
|
||||||
@@ -211,8 +442,20 @@ test('reconciles an idle rapid jump to the latest active route without flashing
|
|||||||
|
|
||||||
await expect.poll(async () => (await readSceneInstrumentation(page)).routeLabelsDrawn)
|
await expect.poll(async () => (await readSceneInstrumentation(page)).routeLabelsDrawn)
|
||||||
.toContain('Selected builds');
|
.toContain('Selected builds');
|
||||||
expect([...new Set((await readSceneInstrumentation(page)).routeLabelsDrawn)])
|
await expect.poll(async () => (
|
||||||
|
await readSceneInstrumentation(page)
|
||||||
|
).signFrames.findLast((frame) => (
|
||||||
|
frame.targetId === 'selected-builds'
|
||||||
|
))?.phase, { intervals: [16], timeout: 5_000 }).toBe('dwell');
|
||||||
|
const instrumentation = await readSceneInstrumentation(page);
|
||||||
|
expect([...new Set(instrumentation.routeLabelsDrawn)])
|
||||||
.toEqual(['Selected builds']);
|
.toEqual(['Selected builds']);
|
||||||
|
const dwell = instrumentation.signFrames.findLast((frame) => (
|
||||||
|
frame.targetId === 'selected-builds' && frame.phase === 'dwell'
|
||||||
|
));
|
||||||
|
if (dwell === undefined) throw new Error('Missing reconciled Selected builds dwell frame');
|
||||||
|
expect(dwell.bounds.left).toBeGreaterThanOrEqual(8);
|
||||||
|
expect(dwell.bounds.right).toBeLessThanOrEqual(1_432);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('keeps the readable Profile face outside its destination panel with verified texel density', async ({
|
test('keeps the readable Profile face outside its destination panel with verified texel density', async ({
|
||||||
@@ -267,6 +510,7 @@ test('keeps a fresh readable sign inside responsive world-safe corridors', async
|
|||||||
|
|
||||||
for (const viewport of [
|
for (const viewport of [
|
||||||
{ width: 390, height: 844, profile: 'tall', minimumFaceWidth: 120 },
|
{ width: 390, height: 844, profile: 'tall', minimumFaceWidth: 120 },
|
||||||
|
{ width: 780, height: 493, profile: 'short', minimumFaceWidth: 120 },
|
||||||
{ width: 844, height: 390, profile: 'short', minimumFaceWidth: 130 },
|
{ width: 844, height: 390, profile: 'short', minimumFaceWidth: 130 },
|
||||||
{ width: 768, height: 1_024, profile: 'tall', minimumFaceWidth: 150 },
|
{ width: 768, height: 1_024, profile: 'tall', minimumFaceWidth: 150 },
|
||||||
{ width: 1_440, height: 900, profile: 'wide', minimumFaceWidth: 180 },
|
{ width: 1_440, height: 900, profile: 'wide', minimumFaceWidth: 180 },
|
||||||
@@ -275,16 +519,16 @@ test('keeps a fresh readable sign inside responsive world-safe corridors', async
|
|||||||
await page.setViewportSize(viewport);
|
await page.setViewportSize(viewport);
|
||||||
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
|
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
|
||||||
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', 'running');
|
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', 'running');
|
||||||
const destination = await page.evaluate(() => {
|
const destination = await page.evaluate((candidateDistanceVh) => {
|
||||||
const profile = document.getElementById('profile-heading');
|
const profile = document.getElementById('profile-heading');
|
||||||
if (!(profile instanceof HTMLElement)) throw new Error('Missing profile heading');
|
if (!(profile instanceof HTMLElement)) throw new Error('Missing profile heading');
|
||||||
const viewportHeight = window.visualViewport?.height ?? window.innerHeight;
|
const viewportHeight = window.visualViewport?.height ?? window.innerHeight;
|
||||||
return Math.max(
|
return Math.max(
|
||||||
1,
|
1,
|
||||||
profile.getBoundingClientRect().top + window.scrollY
|
profile.getBoundingClientRect().top + window.scrollY
|
||||||
- viewportHeight * (0.35 + 0.45),
|
- viewportHeight * (0.35 + candidateDistanceVh),
|
||||||
);
|
);
|
||||||
});
|
}, triggerProbeDistanceVh(viewport.width, viewport.height));
|
||||||
await page.mouse.wheel(0, 1);
|
await page.mouse.wheel(0, 1);
|
||||||
await page.evaluate((scrollY) => window.scrollTo(0, scrollY), destination);
|
await page.evaluate((scrollY) => window.scrollTo(0, scrollY), destination);
|
||||||
await expect.poll(async () => (
|
await expect.poll(async () => (
|
||||||
@@ -332,6 +576,159 @@ test('keeps a fresh readable sign inside responsive world-safe corridors', async
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('keeps the upcoming Experience sign clear of both outgoing and incoming panels', async ({
|
||||||
|
page,
|
||||||
|
distUrl,
|
||||||
|
}, testInfo) => {
|
||||||
|
test.skip(testInfo.project.name !== 'desktop-chromium', 'One exact desktop composition is sufficient.');
|
||||||
|
await page.setViewportSize({ width: 1_440, height: 900 });
|
||||||
|
await page.goto(`${distUrl}/#profile`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', 'running');
|
||||||
|
await expect(page.locator('nav a[href="#profile"]')).toHaveAttribute('aria-current', 'page');
|
||||||
|
const destination = await page.evaluate(() => {
|
||||||
|
const heading = document.getElementById('experience-heading');
|
||||||
|
if (!(heading instanceof HTMLElement)) throw new Error('Missing Experience heading');
|
||||||
|
const viewportHeight = window.visualViewport?.height ?? window.innerHeight;
|
||||||
|
return Math.max(
|
||||||
|
1,
|
||||||
|
heading.getBoundingClientRect().top + window.scrollY
|
||||||
|
- viewportHeight * (0.35 + 0.45),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await page.mouse.wheel(0, 1);
|
||||||
|
await page.evaluate((scrollY) => window.scrollTo(0, scrollY), destination);
|
||||||
|
await expect.poll(async () => (
|
||||||
|
await readSceneInstrumentation(page)
|
||||||
|
).signFrames.at(-1)?.phase, { intervals: [16], timeout: 5_000 }).toBe('dwell');
|
||||||
|
|
||||||
|
const frame = (await readSceneInstrumentation(page)).signFrames.findLast((candidate) => (
|
||||||
|
candidate.targetId === 'experience' && candidate.phase === 'dwell'
|
||||||
|
));
|
||||||
|
if (frame === undefined) throw new Error('Missing readable Experience sign frame');
|
||||||
|
const panels = await page.locator('#profile, #experience').evaluateAll((elements) => (
|
||||||
|
elements.map((element) => {
|
||||||
|
const bounds = element.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
id: element.id,
|
||||||
|
left: bounds.left,
|
||||||
|
top: bounds.top,
|
||||||
|
right: bounds.right,
|
||||||
|
bottom: bounds.bottom,
|
||||||
|
};
|
||||||
|
}).filter((bounds) => bounds.bottom > 0 && bounds.top < window.innerHeight)
|
||||||
|
));
|
||||||
|
expect(panels.map(({ id }) => id)).toEqual(['profile', 'experience']);
|
||||||
|
for (const panel of panels) {
|
||||||
|
expect(rectanglesOverlap(frame.bounds, panel, 16), panel.id).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps every passive transition sign clear of visible opaque panels and fixed controls', async ({
|
||||||
|
page,
|
||||||
|
distUrl,
|
||||||
|
}, testInfo) => {
|
||||||
|
test.skip(testInfo.project.name !== 'desktop-chromium', 'One exact responsive transition matrix is sufficient.');
|
||||||
|
test.setTimeout(180_000);
|
||||||
|
|
||||||
|
for (const viewport of [
|
||||||
|
{ width: 1_440, height: 900 },
|
||||||
|
{ width: 390, height: 844 },
|
||||||
|
{ width: 844, height: 390 },
|
||||||
|
] as const) {
|
||||||
|
await page.setViewportSize(viewport);
|
||||||
|
for (let index = 1; index < sections.length; index += 1) {
|
||||||
|
const previous = sections[index - 1];
|
||||||
|
const next = sections[index];
|
||||||
|
if (previous === undefined || next === undefined) throw new Error('Incomplete route transition');
|
||||||
|
const startUrl = previous.id === 'start' ? distUrl : `${distUrl}/#${previous.id}`;
|
||||||
|
await page.goto(startUrl, { waitUntil: 'domcontentloaded' });
|
||||||
|
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', 'running');
|
||||||
|
await expect(page.locator(`nav a[href="#${previous.id}"]`)).toHaveAttribute('aria-current', 'page');
|
||||||
|
const transition = `${viewport.width}x${viewport.height} ${previous.id} -> ${next.id}`;
|
||||||
|
|
||||||
|
const destination = await page.evaluate(({ targetId, candidateDistanceVh }) => {
|
||||||
|
const heading = document.getElementById(`${targetId}-heading`);
|
||||||
|
if (!(heading instanceof HTMLElement)) throw new Error(`Missing ${targetId} heading`);
|
||||||
|
const viewportHeight = window.visualViewport?.height ?? window.innerHeight;
|
||||||
|
return Math.max(
|
||||||
|
1,
|
||||||
|
heading.getBoundingClientRect().top + window.scrollY
|
||||||
|
- viewportHeight * (0.35 + candidateDistanceVh),
|
||||||
|
);
|
||||||
|
}, {
|
||||||
|
targetId: next.id,
|
||||||
|
candidateDistanceVh: viewport.width < viewport.height
|
||||||
|
? 0.225
|
||||||
|
: viewport.height < 560 ? 0.38 : 0.45,
|
||||||
|
});
|
||||||
|
await page.mouse.wheel(0, 1);
|
||||||
|
await page.evaluate((scrollY) => window.scrollTo(0, scrollY), destination);
|
||||||
|
await expect.poll(async () => (
|
||||||
|
await readSceneInstrumentation(page)
|
||||||
|
).signFrames.findLast((frame) => frame.targetId === next.id)?.phase, {
|
||||||
|
intervals: [16],
|
||||||
|
message: `${transition} should reach readable dwell`,
|
||||||
|
timeout: 5_000,
|
||||||
|
}).toBe('dwell');
|
||||||
|
|
||||||
|
const frame = (await readSceneInstrumentation(page)).signFrames.findLast((candidate) => (
|
||||||
|
candidate.targetId === next.id && candidate.phase === 'dwell'
|
||||||
|
));
|
||||||
|
if (frame === undefined) throw new Error(`Missing readable ${next.id} sign frame`);
|
||||||
|
const occupied = await page.evaluate(() => {
|
||||||
|
const alpha = (color: string): number => {
|
||||||
|
const match = color.match(/rgba?\([^)]*(?:,|\s\/\s)([\d.]+)\)$/u);
|
||||||
|
return match?.[1] === undefined ? 1 : Number.parseFloat(match[1]);
|
||||||
|
};
|
||||||
|
const boundsFor = (element: Element) => {
|
||||||
|
const bounds = element.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
id: element.id || element.closest('[id]')?.id || element.className,
|
||||||
|
left: bounds.left,
|
||||||
|
top: bounds.top,
|
||||||
|
right: bounds.right,
|
||||||
|
bottom: bounds.bottom,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const panels = [...document.querySelectorAll(
|
||||||
|
[
|
||||||
|
'.hero-journey__content',
|
||||||
|
'.portfolio-section:not([data-layout="full-diagram"]):not([data-layout="project-stage"])',
|
||||||
|
'.portfolio-section[data-layout="full-diagram"] > h2',
|
||||||
|
'.infrastructure-case > h3',
|
||||||
|
'.infrastructure-case > p',
|
||||||
|
'.infrastructure-flow-figure',
|
||||||
|
'.portfolio-section[data-layout="project-stage"] > h2',
|
||||||
|
'.portfolio-section[data-layout="project-stage"] > .section-eyebrow',
|
||||||
|
'.project-case-study',
|
||||||
|
'.project-card',
|
||||||
|
].join(', '),
|
||||||
|
)].filter((element) => {
|
||||||
|
const bounds = element.getBoundingClientRect();
|
||||||
|
const style = getComputedStyle(element);
|
||||||
|
const painted = style.backgroundImage !== 'none' || alpha(style.backgroundColor) > 0.08;
|
||||||
|
return painted && bounds.bottom > 0 && bounds.top < window.innerHeight;
|
||||||
|
}).map(boundsFor);
|
||||||
|
const fixed = [...document.querySelectorAll(
|
||||||
|
'.site-navigation, .drive-controls, .journey-hud',
|
||||||
|
)].map(boundsFor);
|
||||||
|
return { panels, fixed };
|
||||||
|
});
|
||||||
|
const panelOverlaps = occupied.panels
|
||||||
|
.filter((panel) => rectanglesOverlap(frame.bounds, panel, 16))
|
||||||
|
.map((panel) => ({ panel, sign: frame.bounds }));
|
||||||
|
const fixedOverlaps = occupied.fixed
|
||||||
|
.filter((zone) => rectanglesOverlap(frame.bounds, zone, 8));
|
||||||
|
expect(frame.bounds.left, `${next.id} left edge`).toBeGreaterThanOrEqual(8);
|
||||||
|
expect(frame.bounds.right, `${next.id} right edge`).toBeLessThanOrEqual(viewport.width - 8);
|
||||||
|
expect(frame.bounds.top, `${next.id} top edge`).toBeGreaterThanOrEqual(8);
|
||||||
|
expect(frame.bounds.bottom, `${next.id} bottom edge`).toBeLessThanOrEqual(viewport.height - 8);
|
||||||
|
expect(panelOverlaps, transition).toEqual([]);
|
||||||
|
expect(fixedOverlaps, transition).toEqual([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function colorEnergy(color: string): number {
|
function colorEnergy(color: string): number {
|
||||||
const value = Number.parseInt(color.slice(1), 16);
|
const value = Number.parseInt(color.slice(1), 16);
|
||||||
return ((value >> 16) & 0xff) + ((value >> 8) & 0xff) + (value & 0xff);
|
return ((value >> 16) & 0xff) + ((value >> 8) & 0xff) + (value & 0xff);
|
||||||
@@ -349,3 +746,24 @@ function rectanglesOverlap(
|
|||||||
|| first.top >= second.bottom + gap
|
|| first.top >= second.bottom + gap
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function countSignPresentations(
|
||||||
|
frames: ReadonlyArray<{ targetId: string; phase: string }>,
|
||||||
|
targetId: string,
|
||||||
|
): number {
|
||||||
|
let presentations = 0;
|
||||||
|
let previousPhase: string | null = null;
|
||||||
|
for (const frame of frames) {
|
||||||
|
if (frame.targetId !== targetId) continue;
|
||||||
|
if (frame.phase === 'anticipate' && previousPhase !== 'anticipate') presentations += 1;
|
||||||
|
previousPhase = frame.phase;
|
||||||
|
}
|
||||||
|
return presentations;
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerProbeDistanceVh(width: number, height: number): number {
|
||||||
|
const aspect = width / height;
|
||||||
|
if (height < 560 && aspect >= 1) return 0.37;
|
||||||
|
if (aspect < 1.05) return 0.22;
|
||||||
|
return 0.45;
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export interface SignFrameInstrumentation {
|
|||||||
visible: boolean;
|
visible: boolean;
|
||||||
targetId: string;
|
targetId: string;
|
||||||
phase: string;
|
phase: string;
|
||||||
|
navigationArrival?: true;
|
||||||
progress: number;
|
progress: number;
|
||||||
opacity: number;
|
opacity: number;
|
||||||
activeSectionId: string;
|
activeSectionId: string;
|
||||||
@@ -25,8 +26,15 @@ export interface SignFrameInstrumentation {
|
|||||||
corridor: string;
|
corridor: string;
|
||||||
viewportProfile: string;
|
viewportProfile: string;
|
||||||
bounds: { left: number; top: number; right: number; bottom: number; width: number; height: number };
|
bounds: { left: number; top: number; right: number; bottom: number; width: number; height: number };
|
||||||
texture: { width: number; height: number; anisotropy: number; fontReadyWhenDrawn: boolean };
|
texture: {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
maximumSize: number;
|
||||||
|
anisotropy: number;
|
||||||
|
fontReadyWhenDrawn: boolean;
|
||||||
|
};
|
||||||
rendererPixelRatio: number;
|
rendererPixelRatio: number;
|
||||||
|
viewport: { width: number; height: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
type InstrumentedWindow = Window & {
|
type InstrumentedWindow = Window & {
|
||||||
|
|||||||
@@ -206,6 +206,7 @@ describe("navigation epochs and effects", () => {
|
|||||||
epoch: 2,
|
epoch: 2,
|
||||||
source: "passive-scroll",
|
source: "passive-scroll",
|
||||||
destinationId: null,
|
destinationId: null,
|
||||||
|
signDestinationId: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -283,6 +284,23 @@ describe("navigation epochs and effects", () => {
|
|||||||
epoch: 2,
|
epoch: 2,
|
||||||
source: "history-restoration",
|
source: "history-restoration",
|
||||||
destinationId: "profile",
|
destinationId: "profile",
|
||||||
|
signDestinationId: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks only an explicitly requested navigator epoch for roadside signage", () => {
|
||||||
|
const harness = createRuntime(true);
|
||||||
|
const controller = createNavigationController(sectionIds, harness.runtime);
|
||||||
|
|
||||||
|
controller.navigate("profile", target("profile"));
|
||||||
|
expect(controller.state().signDestinationId).toBeNull();
|
||||||
|
|
||||||
|
controller.navigate("experience", target("experience"), { requestSign: true });
|
||||||
|
expect(controller.state()).toMatchObject({
|
||||||
|
epoch: 2,
|
||||||
|
source: "explicit-navigation",
|
||||||
|
destinationId: "experience",
|
||||||
|
signDestinationId: "experience",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -466,6 +466,7 @@ describe('useScrollDrive', () => {
|
|||||||
epoch: 0,
|
epoch: 0,
|
||||||
source: 'passive-scroll' as const,
|
source: 'passive-scroll' as const,
|
||||||
destinationId: null,
|
destinationId: null,
|
||||||
|
signDestinationId: null,
|
||||||
});
|
});
|
||||||
const motionAllowed = shallowRef(true);
|
const motionAllowed = shallowRef(true);
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ const sample = (
|
|||||||
): SignControllerSample => ({
|
): SignControllerSample => ({
|
||||||
now: 0,
|
now: 0,
|
||||||
source: 'passive-scroll',
|
source: 'passive-scroll',
|
||||||
|
navigationEpoch: 0,
|
||||||
|
navigationDestinationId: null,
|
||||||
direction: 1,
|
direction: 1,
|
||||||
acceleration: 0,
|
acceleration: 0,
|
||||||
deltaY: 16,
|
deltaY: 16,
|
||||||
@@ -26,12 +28,12 @@ describe('clock-driven sign lifecycle', () => {
|
|||||||
expect(SIGN_ROUTE_ORDER.map((id) => signRouteFor(id))).toEqual([
|
expect(SIGN_ROUTE_ORDER.map((id) => signRouteFor(id))).toEqual([
|
||||||
{ id: 'start', label: 'Start', marker: '01', eligible: false, corridor: null, triggerMaximumVh: 0 },
|
{ id: 'start', label: 'Start', marker: '01', eligible: false, corridor: null, triggerMaximumVh: 0 },
|
||||||
{ id: 'profile', label: 'Profile', marker: '02', eligible: true, corridor: 'left', triggerMaximumVh: 0.55 },
|
{ id: 'profile', label: 'Profile', marker: '02', eligible: true, corridor: 'left', triggerMaximumVh: 0.55 },
|
||||||
{ id: 'experience', label: 'Experience', marker: '03', eligible: true, corridor: 'right', triggerMaximumVh: 0.55 },
|
{ id: 'experience', label: 'Experience', marker: '03', eligible: true, corridor: 'left', triggerMaximumVh: 0.55 },
|
||||||
{ id: 'infrastructure', label: 'Infrastructure', marker: '04', eligible: true, corridor: 'outer-right', triggerMaximumVh: 0.55 },
|
{ id: 'infrastructure', label: 'Infrastructure', marker: '04', eligible: true, corridor: 'outer-right', triggerMaximumVh: 0.55, dwellOffsetX: -1.6 },
|
||||||
{ id: 'selected-builds', label: 'Selected builds', marker: '05', eligible: true, corridor: 'outer-right', triggerMaximumVh: 0.55 },
|
{ id: 'selected-builds', label: 'Selected builds', marker: '05', eligible: true, corridor: 'right', triggerMaximumVh: 0.55 },
|
||||||
{ id: 'additional-work', label: 'Additional work', marker: '06', eligible: true, corridor: 'left', triggerMaximumVh: 0.55 },
|
{ id: 'additional-work', label: 'Additional work', marker: '06', eligible: true, corridor: 'outer-left', triggerMaximumVh: 0.55 },
|
||||||
{ id: 'toolkit', label: 'Toolkit', marker: '07', eligible: true, corridor: 'outer-left', triggerMaximumVh: 0.55 },
|
{ id: 'toolkit', label: 'Toolkit', marker: '07', eligible: true, corridor: 'outer-left', triggerMaximumVh: 0.55 },
|
||||||
{ id: 'contact', label: 'Contact', marker: '08', eligible: true, corridor: 'right', triggerMaximumVh: 0.55 },
|
{ id: 'contact', label: 'Contact', marker: '08', eligible: true, corridor: 'outer-right', triggerMaximumVh: 0.55 },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -103,6 +105,36 @@ describe('clock-driven sign lifecycle', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses the portrait section gap instead of latching behind a full-width panel', () => {
|
||||||
|
const controller = createSignController();
|
||||||
|
expect(controller.sample(sample({
|
||||||
|
viewportWidth: 390,
|
||||||
|
viewportHeight: 844,
|
||||||
|
candidateDistanceVh: 0.3,
|
||||||
|
}))).toBeNull();
|
||||||
|
expect(controller.sample(sample({
|
||||||
|
now: 16,
|
||||||
|
viewportWidth: 390,
|
||||||
|
viewportHeight: 844,
|
||||||
|
candidateDistanceVh: 0.22,
|
||||||
|
}))).toMatchObject({ targetId: 'profile', phase: 'anticipate', viewportProfile: 'tall' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the short-landscape section gap when no horizontal corridor is wide enough', () => {
|
||||||
|
const controller = createSignController();
|
||||||
|
expect(controller.sample(sample({
|
||||||
|
viewportWidth: 844,
|
||||||
|
viewportHeight: 390,
|
||||||
|
candidateDistanceVh: 0.42,
|
||||||
|
}))).toBeNull();
|
||||||
|
expect(controller.sample(sample({
|
||||||
|
now: 16,
|
||||||
|
viewportWidth: 844,
|
||||||
|
viewportHeight: 390,
|
||||||
|
candidateDistanceVh: 0.38,
|
||||||
|
}))).toMatchObject({ targetId: 'profile', phase: 'anticipate', viewportProfile: 'short' });
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps the latched viewport profile immutable across a responsive resize', () => {
|
it('keeps the latched viewport profile immutable across a responsive resize', () => {
|
||||||
const controller = createSignController();
|
const controller = createSignController();
|
||||||
expect(controller.sample(sample())).toMatchObject({ viewportProfile: 'wide' });
|
expect(controller.sample(sample())).toMatchObject({ viewportProfile: 'wide' });
|
||||||
@@ -166,7 +198,6 @@ describe('clock-driven sign lifecycle', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
['explicit navigation', { source: 'explicit-navigation' as const }],
|
|
||||||
['history restoration', { source: 'history-restoration' as const }],
|
['history restoration', { source: 'history-restoration' as const }],
|
||||||
['initial positioning', { source: 'initial' as const }],
|
['initial positioning', { source: 'initial' as const }],
|
||||||
['disabled motion', { motionAllowed: false }],
|
['disabled motion', { motionAllowed: false }],
|
||||||
@@ -178,6 +209,459 @@ describe('clock-driven sign lifecycle', () => {
|
|||||||
expect(controller.needsFrame()).toBe(false);
|
expect(controller.needsFrame()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('consumes an eligible navigator destination once without requiring scroll input', () => {
|
||||||
|
const controller = createSignController();
|
||||||
|
const navigation = sample({
|
||||||
|
source: 'explicit-navigation',
|
||||||
|
navigationEpoch: 1,
|
||||||
|
navigationDestinationId: 'selected-builds',
|
||||||
|
direction: 0,
|
||||||
|
deltaY: 0,
|
||||||
|
crossedBoundaries: 0,
|
||||||
|
activeSectionId: 'start',
|
||||||
|
candidateId: 'profile',
|
||||||
|
candidateDistanceVh: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(controller.sample(navigation)).toMatchObject({
|
||||||
|
targetId: 'selected-builds',
|
||||||
|
phase: 'anticipate',
|
||||||
|
navigationArrival: true,
|
||||||
|
});
|
||||||
|
expect(controller.sample({ ...navigation, now: 1_200 })).toMatchObject({
|
||||||
|
targetId: 'selected-builds',
|
||||||
|
phase: 'dwell',
|
||||||
|
});
|
||||||
|
expect(controller.sample({ ...navigation, now: 2_600 })).toMatchObject({
|
||||||
|
targetId: 'selected-builds',
|
||||||
|
phase: 'depart',
|
||||||
|
progress: 0,
|
||||||
|
});
|
||||||
|
expect(controller.sample({ ...navigation, now: 3_300 })).toBeNull();
|
||||||
|
expect(controller.sample({ ...navigation, now: 4_000 })).toBeNull();
|
||||||
|
expect(controller.needsFrame()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('coalesces rapid navigator clicks to the latest concealed target without label mutation', () => {
|
||||||
|
const controller = createSignController();
|
||||||
|
|
||||||
|
expect(controller.sample(sample({
|
||||||
|
source: 'explicit-navigation',
|
||||||
|
navigationEpoch: 1,
|
||||||
|
navigationDestinationId: 'profile',
|
||||||
|
direction: 0,
|
||||||
|
deltaY: 0,
|
||||||
|
}))).toMatchObject({ targetId: 'profile', phase: 'anticipate' });
|
||||||
|
expect(controller.sample(sample({
|
||||||
|
now: 100,
|
||||||
|
source: 'explicit-navigation',
|
||||||
|
navigationEpoch: 2,
|
||||||
|
navigationDestinationId: 'selected-builds',
|
||||||
|
direction: 0,
|
||||||
|
deltaY: 0,
|
||||||
|
}))).toMatchObject({ targetId: 'selected-builds', phase: 'anticipate' });
|
||||||
|
expect(controller.sample(sample({
|
||||||
|
now: 1_300,
|
||||||
|
source: 'explicit-navigation',
|
||||||
|
navigationEpoch: 2,
|
||||||
|
navigationDestinationId: 'selected-builds',
|
||||||
|
direction: 0,
|
||||||
|
deltaY: 0,
|
||||||
|
}))).toMatchObject({ targetId: 'selected-builds', phase: 'dwell' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not queue a second presentation for a repeated same-target navigator click', () => {
|
||||||
|
const controller = createSignController();
|
||||||
|
const navigation = sample({
|
||||||
|
source: 'explicit-navigation',
|
||||||
|
navigationEpoch: 1,
|
||||||
|
navigationDestinationId: 'selected-builds',
|
||||||
|
direction: 0,
|
||||||
|
deltaY: 0,
|
||||||
|
});
|
||||||
|
controller.sample(navigation);
|
||||||
|
controller.sample({ ...navigation, now: 1_200 });
|
||||||
|
|
||||||
|
expect(controller.sample({
|
||||||
|
...navigation,
|
||||||
|
now: 1_300,
|
||||||
|
navigationEpoch: 2,
|
||||||
|
})).toMatchObject({ targetId: 'selected-builds', phase: 'dwell' });
|
||||||
|
controller.sample({ ...navigation, now: 2_600, navigationEpoch: 2 });
|
||||||
|
controller.sample({ ...navigation, now: 3_300, navigationEpoch: 2 });
|
||||||
|
expect(controller.sample({
|
||||||
|
...navigation,
|
||||||
|
now: 3_650,
|
||||||
|
navigationEpoch: 2,
|
||||||
|
})).toBeNull();
|
||||||
|
expect(controller.sample({
|
||||||
|
...navigation,
|
||||||
|
now: 4_000,
|
||||||
|
navigationEpoch: 3,
|
||||||
|
activeSectionId: 'selected-builds',
|
||||||
|
})).toBeNull();
|
||||||
|
expect(controller.needsFrame()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('promotes a rearmed passive sign when the prior navigator target is clicked again', () => {
|
||||||
|
const controller = createSignController();
|
||||||
|
const navigation = sample({
|
||||||
|
source: 'explicit-navigation',
|
||||||
|
navigationEpoch: 1,
|
||||||
|
navigationDestinationId: 'profile',
|
||||||
|
direction: 0,
|
||||||
|
deltaY: 0,
|
||||||
|
});
|
||||||
|
controller.sample(navigation);
|
||||||
|
controller.sample({ ...navigation, now: 1_200, activeSectionId: 'profile' });
|
||||||
|
controller.sample({ ...navigation, now: 2_600, activeSectionId: 'profile' });
|
||||||
|
controller.sample({ ...navigation, now: 3_300, activeSectionId: 'profile' });
|
||||||
|
controller.sample(sample({
|
||||||
|
now: 3_400,
|
||||||
|
direction: -1,
|
||||||
|
deltaY: -800,
|
||||||
|
crossedBoundaries: 1,
|
||||||
|
activeSectionId: 'start',
|
||||||
|
candidateId: 'profile',
|
||||||
|
candidateDistanceVh: 0.8,
|
||||||
|
}));
|
||||||
|
controller.sample(sample({
|
||||||
|
now: 3_650,
|
||||||
|
direction: 1,
|
||||||
|
deltaY: 120,
|
||||||
|
activeSectionId: 'start',
|
||||||
|
candidateId: 'profile',
|
||||||
|
candidateDistanceVh: 0.5,
|
||||||
|
}));
|
||||||
|
controller.sample(sample({
|
||||||
|
now: 4_850,
|
||||||
|
direction: 0,
|
||||||
|
deltaY: 0,
|
||||||
|
activeSectionId: 'profile',
|
||||||
|
candidateId: 'experience',
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(controller.sample({
|
||||||
|
...navigation,
|
||||||
|
now: 4_900,
|
||||||
|
navigationEpoch: 2,
|
||||||
|
activeSectionId: 'profile',
|
||||||
|
})).toMatchObject({
|
||||||
|
targetId: 'profile',
|
||||||
|
phase: 'dwell',
|
||||||
|
navigationArrival: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves a full readable dwell when navigation promotes a late passive dwell', () => {
|
||||||
|
const controller = createSignController();
|
||||||
|
controller.sample(sample());
|
||||||
|
controller.sample(sample({ now: 1_200, direction: 0, deltaY: 0 }));
|
||||||
|
|
||||||
|
const promoted = sample({
|
||||||
|
now: 2_590,
|
||||||
|
source: 'explicit-navigation',
|
||||||
|
navigationEpoch: 1,
|
||||||
|
navigationDestinationId: 'profile',
|
||||||
|
direction: 0,
|
||||||
|
deltaY: 0,
|
||||||
|
activeSectionId: 'profile',
|
||||||
|
});
|
||||||
|
expect(controller.sample(promoted)).toMatchObject({
|
||||||
|
targetId: 'profile',
|
||||||
|
phase: 'dwell',
|
||||||
|
navigationArrival: true,
|
||||||
|
});
|
||||||
|
expect(controller.sample({ ...promoted, now: 3_989 })).toMatchObject({
|
||||||
|
phase: 'dwell',
|
||||||
|
navigationArrival: true,
|
||||||
|
});
|
||||||
|
expect(controller.sample({ ...promoted, now: 3_990 })).toMatchObject({
|
||||||
|
phase: 'depart',
|
||||||
|
progress: 0,
|
||||||
|
navigationArrival: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('promotes a rearmed sign queued during cooldown when its navigator target is clicked', () => {
|
||||||
|
const controller = createSignController();
|
||||||
|
const navigation = sample({
|
||||||
|
source: 'explicit-navigation',
|
||||||
|
navigationEpoch: 1,
|
||||||
|
navigationDestinationId: 'profile',
|
||||||
|
direction: 0,
|
||||||
|
deltaY: 0,
|
||||||
|
});
|
||||||
|
controller.sample(navigation);
|
||||||
|
controller.sample({ ...navigation, now: 1_200, activeSectionId: 'profile' });
|
||||||
|
controller.sample({ ...navigation, now: 2_600, activeSectionId: 'profile' });
|
||||||
|
controller.sample(sample({
|
||||||
|
now: 2_800,
|
||||||
|
direction: -1,
|
||||||
|
deltaY: -800,
|
||||||
|
crossedBoundaries: 1,
|
||||||
|
activeSectionId: 'start',
|
||||||
|
candidateId: 'profile',
|
||||||
|
candidateDistanceVh: 0.8,
|
||||||
|
}));
|
||||||
|
controller.sample(sample({
|
||||||
|
now: 3_300,
|
||||||
|
direction: 0,
|
||||||
|
deltaY: 0,
|
||||||
|
activeSectionId: 'start',
|
||||||
|
candidateId: 'profile',
|
||||||
|
candidateDistanceVh: 0.8,
|
||||||
|
}));
|
||||||
|
expect(controller.sample(sample({
|
||||||
|
now: 3_400,
|
||||||
|
direction: 1,
|
||||||
|
deltaY: 120,
|
||||||
|
activeSectionId: 'start',
|
||||||
|
candidateId: 'profile',
|
||||||
|
candidateDistanceVh: 0.5,
|
||||||
|
}))).toBeNull();
|
||||||
|
expect(controller.needsFrame()).toBe(true);
|
||||||
|
expect(controller.sample({
|
||||||
|
...navigation,
|
||||||
|
now: 3_450,
|
||||||
|
navigationEpoch: 2,
|
||||||
|
activeSectionId: 'profile',
|
||||||
|
})).toBeNull();
|
||||||
|
|
||||||
|
expect(controller.sample({
|
||||||
|
...navigation,
|
||||||
|
now: 3_650,
|
||||||
|
navigationEpoch: 2,
|
||||||
|
activeSectionId: 'profile',
|
||||||
|
})).toMatchObject({
|
||||||
|
targetId: 'profile',
|
||||||
|
phase: 'anticipate',
|
||||||
|
navigationArrival: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves a readable label and queues only the latest navigator destination', () => {
|
||||||
|
const controller = createSignController();
|
||||||
|
controller.sample(sample());
|
||||||
|
controller.sample(sample({ now: 1_200, direction: 0, deltaY: 0 }));
|
||||||
|
|
||||||
|
expect(controller.sample(sample({
|
||||||
|
now: 1_300,
|
||||||
|
source: 'explicit-navigation',
|
||||||
|
navigationEpoch: 1,
|
||||||
|
navigationDestinationId: 'experience',
|
||||||
|
direction: 0,
|
||||||
|
deltaY: 0,
|
||||||
|
}))).toMatchObject({ targetId: 'profile', phase: 'dwell' });
|
||||||
|
expect(controller.sample(sample({
|
||||||
|
now: 1_400,
|
||||||
|
source: 'explicit-navigation',
|
||||||
|
navigationEpoch: 2,
|
||||||
|
navigationDestinationId: 'toolkit',
|
||||||
|
direction: 0,
|
||||||
|
deltaY: 0,
|
||||||
|
}))).toMatchObject({ targetId: 'profile', phase: 'dwell' });
|
||||||
|
controller.sample(sample({
|
||||||
|
now: 2_600,
|
||||||
|
source: 'explicit-navigation',
|
||||||
|
navigationEpoch: 2,
|
||||||
|
navigationDestinationId: 'toolkit',
|
||||||
|
direction: 0,
|
||||||
|
deltaY: 0,
|
||||||
|
}));
|
||||||
|
controller.sample(sample({
|
||||||
|
now: 3_300,
|
||||||
|
source: 'explicit-navigation',
|
||||||
|
navigationEpoch: 2,
|
||||||
|
navigationDestinationId: 'toolkit',
|
||||||
|
direction: 0,
|
||||||
|
deltaY: 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(controller.sample(sample({
|
||||||
|
now: 3_650,
|
||||||
|
source: 'explicit-navigation',
|
||||||
|
navigationEpoch: 2,
|
||||||
|
navigationDestinationId: 'toolkit',
|
||||||
|
direction: 0,
|
||||||
|
deltaY: 0,
|
||||||
|
}))).toMatchObject({
|
||||||
|
targetId: 'toolkit',
|
||||||
|
phase: 'anticipate',
|
||||||
|
navigationArrival: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancels an obsolete queued destination when navigation returns to the visible target', () => {
|
||||||
|
const controller = createSignController();
|
||||||
|
controller.sample(sample());
|
||||||
|
controller.sample(sample({ now: 1_200, direction: 0, deltaY: 0 }));
|
||||||
|
|
||||||
|
expect(controller.sample(sample({
|
||||||
|
now: 1_300,
|
||||||
|
source: 'explicit-navigation',
|
||||||
|
navigationEpoch: 1,
|
||||||
|
navigationDestinationId: 'toolkit',
|
||||||
|
direction: 0,
|
||||||
|
deltaY: 0,
|
||||||
|
activeSectionId: 'toolkit',
|
||||||
|
}))).toMatchObject({ targetId: 'profile', phase: 'dwell' });
|
||||||
|
expect(controller.sample(sample({
|
||||||
|
now: 1_400,
|
||||||
|
source: 'explicit-navigation',
|
||||||
|
navigationEpoch: 2,
|
||||||
|
navigationDestinationId: 'profile',
|
||||||
|
direction: 0,
|
||||||
|
deltaY: 0,
|
||||||
|
activeSectionId: 'profile',
|
||||||
|
}))).toMatchObject({
|
||||||
|
targetId: 'profile',
|
||||||
|
phase: 'dwell',
|
||||||
|
navigationArrival: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
controller.sample(sample({
|
||||||
|
now: 2_800,
|
||||||
|
source: 'explicit-navigation',
|
||||||
|
navigationEpoch: 2,
|
||||||
|
navigationDestinationId: 'profile',
|
||||||
|
direction: 0,
|
||||||
|
deltaY: 0,
|
||||||
|
activeSectionId: 'profile',
|
||||||
|
}));
|
||||||
|
controller.sample(sample({
|
||||||
|
now: 3_500,
|
||||||
|
source: 'explicit-navigation',
|
||||||
|
navigationEpoch: 2,
|
||||||
|
navigationDestinationId: 'profile',
|
||||||
|
direction: 0,
|
||||||
|
deltaY: 0,
|
||||||
|
activeSectionId: 'profile',
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(controller.sample(sample({
|
||||||
|
now: 3_850,
|
||||||
|
source: 'explicit-navigation',
|
||||||
|
navigationEpoch: 2,
|
||||||
|
navigationDestinationId: 'profile',
|
||||||
|
direction: 0,
|
||||||
|
deltaY: 0,
|
||||||
|
activeSectionId: 'profile',
|
||||||
|
}))).toBeNull();
|
||||||
|
expect(controller.needsFrame()).toBe(false);
|
||||||
|
|
||||||
|
expect(controller.sample(sample({
|
||||||
|
now: 3_900,
|
||||||
|
direction: -1,
|
||||||
|
deltaY: -40,
|
||||||
|
activeSectionId: 'start',
|
||||||
|
candidateId: 'profile',
|
||||||
|
candidateDistanceVh: 0.6,
|
||||||
|
}))).toBeNull();
|
||||||
|
expect(controller.sample(sample({
|
||||||
|
now: 4_000,
|
||||||
|
direction: 1,
|
||||||
|
deltaY: 40,
|
||||||
|
activeSectionId: 'start',
|
||||||
|
candidateId: 'profile',
|
||||||
|
candidateDistanceVh: 0.5,
|
||||||
|
}))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honors a navigator return to a previously completed route after a readable sign', () => {
|
||||||
|
const controller = createSignController();
|
||||||
|
controller.sample(sample());
|
||||||
|
controller.sample(sample({ now: 1_200, direction: 0, deltaY: 0 }));
|
||||||
|
controller.sample(sample({ now: 2_600, direction: 0, deltaY: 0 }));
|
||||||
|
controller.sample(sample({ now: 3_300, direction: 0, deltaY: 0 }));
|
||||||
|
controller.sample(sample({
|
||||||
|
now: 3_650,
|
||||||
|
activeSectionId: 'profile',
|
||||||
|
candidateId: 'experience',
|
||||||
|
candidateDistanceVh: 0.5,
|
||||||
|
}));
|
||||||
|
controller.sample(sample({
|
||||||
|
now: 4_850,
|
||||||
|
direction: 0,
|
||||||
|
deltaY: 0,
|
||||||
|
activeSectionId: 'profile',
|
||||||
|
candidateId: 'experience',
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(controller.sample(sample({
|
||||||
|
now: 5_000,
|
||||||
|
source: 'explicit-navigation',
|
||||||
|
navigationEpoch: 1,
|
||||||
|
navigationDestinationId: 'profile',
|
||||||
|
direction: 0,
|
||||||
|
deltaY: 0,
|
||||||
|
activeSectionId: 'profile',
|
||||||
|
}))).toMatchObject({ targetId: 'experience', phase: 'dwell' });
|
||||||
|
controller.sample(sample({
|
||||||
|
now: 6_250,
|
||||||
|
source: 'explicit-navigation',
|
||||||
|
navigationEpoch: 1,
|
||||||
|
navigationDestinationId: 'profile',
|
||||||
|
direction: 0,
|
||||||
|
deltaY: 0,
|
||||||
|
activeSectionId: 'profile',
|
||||||
|
}));
|
||||||
|
controller.sample(sample({
|
||||||
|
now: 6_950,
|
||||||
|
source: 'explicit-navigation',
|
||||||
|
navigationEpoch: 1,
|
||||||
|
navigationDestinationId: 'profile',
|
||||||
|
direction: 0,
|
||||||
|
deltaY: 0,
|
||||||
|
activeSectionId: 'profile',
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(controller.sample(sample({
|
||||||
|
now: 7_300,
|
||||||
|
source: 'explicit-navigation',
|
||||||
|
navigationEpoch: 1,
|
||||||
|
navigationDestinationId: 'profile',
|
||||||
|
direction: 0,
|
||||||
|
deltaY: 0,
|
||||||
|
activeSectionId: 'profile',
|
||||||
|
}))).toMatchObject({
|
||||||
|
targetId: 'profile',
|
||||||
|
phase: 'anticipate',
|
||||||
|
navigationArrival: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('queues a fresh navigator arrival for the cooldown route after moving elsewhere', () => {
|
||||||
|
const controller = createSignController();
|
||||||
|
controller.sample(sample());
|
||||||
|
controller.sample(sample({ now: 1_200, direction: 0, deltaY: 0 }));
|
||||||
|
controller.sample(sample({ now: 2_600, direction: 0, deltaY: 0 }));
|
||||||
|
controller.sample(sample({ now: 3_300, direction: 0, deltaY: 0 }));
|
||||||
|
|
||||||
|
expect(controller.sample(sample({
|
||||||
|
now: 3_400,
|
||||||
|
source: 'explicit-navigation',
|
||||||
|
navigationEpoch: 1,
|
||||||
|
navigationDestinationId: 'profile',
|
||||||
|
direction: 0,
|
||||||
|
deltaY: 0,
|
||||||
|
activeSectionId: 'experience',
|
||||||
|
}))).toBeNull();
|
||||||
|
expect(controller.sample(sample({
|
||||||
|
now: 3_650,
|
||||||
|
source: 'explicit-navigation',
|
||||||
|
navigationEpoch: 1,
|
||||||
|
navigationDestinationId: 'profile',
|
||||||
|
direction: 0,
|
||||||
|
deltaY: 0,
|
||||||
|
activeSectionId: 'profile',
|
||||||
|
}))).toMatchObject({
|
||||||
|
targetId: 'profile',
|
||||||
|
phase: 'anticipate',
|
||||||
|
navigationArrival: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
['multiple crossed sections', { crossedBoundaries: 2 }],
|
['multiple crossed sections', { crossedBoundaries: 2 }],
|
||||||
['a jump over 0.75 viewport heights', { deltaY: 601 }],
|
['a jump over 0.75 viewport heights', { deltaY: 601 }],
|
||||||
@@ -435,6 +919,69 @@ describe('clock-driven sign lifecycle', () => {
|
|||||||
}))).toMatchObject({ targetId: 'profile', phase: 'depart', progress: 0 });
|
}))).toMatchObject({ targetId: 'profile', phase: 'depart', progress: 0 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps a passed sign opaque until its spatial departure is well underway', () => {
|
||||||
|
const controller = createSignController();
|
||||||
|
controller.sample(sample());
|
||||||
|
controller.sample(sample({ now: 1_200, direction: 0, deltaY: 0 }));
|
||||||
|
controller.sample(sample({ now: 2_600, direction: 0, deltaY: 0 }));
|
||||||
|
|
||||||
|
expect(controller.sample(sample({
|
||||||
|
now: 2_985,
|
||||||
|
direction: 0,
|
||||||
|
deltaY: 0,
|
||||||
|
activeSectionId: 'profile',
|
||||||
|
}))).toMatchObject({
|
||||||
|
phase: 'depart',
|
||||||
|
progress: 0.55,
|
||||||
|
opacity: 1,
|
||||||
|
});
|
||||||
|
expect(controller.sample(sample({
|
||||||
|
now: 3_230,
|
||||||
|
direction: 0,
|
||||||
|
deltaY: 0,
|
||||||
|
activeSectionId: 'profile',
|
||||||
|
}))).toMatchObject({
|
||||||
|
phase: 'depart',
|
||||||
|
progress: 0.9,
|
||||||
|
opacity: expect.closeTo(0.04, 1),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('discards a queued forward jump after a meaningful reverse retreat', () => {
|
||||||
|
const controller = createSignController();
|
||||||
|
controller.sample(sample());
|
||||||
|
controller.sample(sample({ now: 1_200, direction: 0, deltaY: 0 }));
|
||||||
|
|
||||||
|
controller.sample(sample({
|
||||||
|
now: 1_300,
|
||||||
|
deltaY: 2_400,
|
||||||
|
crossedBoundaries: 4,
|
||||||
|
activeSectionId: 'selected-builds',
|
||||||
|
candidateId: 'additional-work',
|
||||||
|
candidateDistanceVh: 0.4,
|
||||||
|
}));
|
||||||
|
controller.sample(sample({
|
||||||
|
now: 1_500,
|
||||||
|
direction: -1,
|
||||||
|
deltaY: -2_400,
|
||||||
|
crossedBoundaries: 4,
|
||||||
|
activeSectionId: 'start',
|
||||||
|
candidateId: 'profile',
|
||||||
|
candidateDistanceVh: 0.8,
|
||||||
|
}));
|
||||||
|
controller.sample(sample({ now: 2_600, direction: 0, deltaY: 0 }));
|
||||||
|
controller.sample(sample({ now: 3_300, direction: 0, deltaY: 0 }));
|
||||||
|
|
||||||
|
expect(controller.sample(sample({
|
||||||
|
now: 3_650,
|
||||||
|
direction: 0,
|
||||||
|
deltaY: 0,
|
||||||
|
activeSectionId: 'start',
|
||||||
|
candidateId: 'selected-builds',
|
||||||
|
candidateDistanceVh: 0.4,
|
||||||
|
}))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('does not retrigger a completed route sign during small boundary oscillations', () => {
|
it('does not retrigger a completed route sign during small boundary oscillations', () => {
|
||||||
const controller = createSignController();
|
const controller = createSignController();
|
||||||
controller.sample(sample());
|
controller.sample(sample());
|
||||||
@@ -459,6 +1006,164 @@ describe('clock-driven sign lifecycle', () => {
|
|||||||
}))).toBeNull();
|
}))).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('re-arms a completed sign only after retreating beyond the trigger hysteresis', () => {
|
||||||
|
const controller = createSignController();
|
||||||
|
controller.sample(sample());
|
||||||
|
controller.sample(sample({ now: 1_200, direction: 0, deltaY: 0 }));
|
||||||
|
controller.sample(sample({ now: 2_600, direction: 0, deltaY: 0 }));
|
||||||
|
controller.sample(sample({ now: 3_300, direction: 0, deltaY: 0 }));
|
||||||
|
|
||||||
|
expect(controller.sample(sample({
|
||||||
|
now: 3_500,
|
||||||
|
direction: -1,
|
||||||
|
deltaY: -600,
|
||||||
|
crossedBoundaries: 1,
|
||||||
|
activeSectionId: 'start',
|
||||||
|
candidateId: 'profile',
|
||||||
|
candidateDistanceVh: 0.8,
|
||||||
|
}))).toBeNull();
|
||||||
|
expect(controller.sample(sample({
|
||||||
|
now: 3_650,
|
||||||
|
direction: 1,
|
||||||
|
deltaY: 120,
|
||||||
|
activeSectionId: 'start',
|
||||||
|
candidateId: 'profile',
|
||||||
|
candidateDistanceVh: 0.5,
|
||||||
|
}))).toMatchObject({ targetId: 'profile', phase: 'anticipate' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not let a departing sign overwrite a meaningful reverse rearm', () => {
|
||||||
|
const controller = createSignController();
|
||||||
|
controller.sample(sample());
|
||||||
|
controller.sample(sample({ now: 1_200, direction: 0, deltaY: 0 }));
|
||||||
|
controller.sample(sample({ now: 2_600, direction: 0, deltaY: 0 }));
|
||||||
|
|
||||||
|
expect(controller.sample(sample({
|
||||||
|
now: 2_800,
|
||||||
|
direction: -1,
|
||||||
|
deltaY: -800,
|
||||||
|
crossedBoundaries: 1,
|
||||||
|
activeSectionId: 'start',
|
||||||
|
candidateId: 'profile',
|
||||||
|
candidateDistanceVh: 0.8,
|
||||||
|
}))).toMatchObject({ targetId: 'profile', phase: 'depart' });
|
||||||
|
controller.sample(sample({
|
||||||
|
now: 3_300,
|
||||||
|
direction: 0,
|
||||||
|
deltaY: 0,
|
||||||
|
activeSectionId: 'start',
|
||||||
|
candidateId: 'profile',
|
||||||
|
candidateDistanceVh: 0.8,
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(controller.sample(sample({
|
||||||
|
now: 3_400,
|
||||||
|
direction: 1,
|
||||||
|
deltaY: 120,
|
||||||
|
activeSectionId: 'start',
|
||||||
|
candidateId: 'profile',
|
||||||
|
candidateDistanceVh: 0.5,
|
||||||
|
}))).toBeNull();
|
||||||
|
expect(controller.needsFrame()).toBe(true);
|
||||||
|
expect(controller.sample(sample({
|
||||||
|
now: 3_650,
|
||||||
|
direction: 0,
|
||||||
|
deltaY: 0,
|
||||||
|
activeSectionId: 'start',
|
||||||
|
candidateId: 'profile',
|
||||||
|
candidateDistanceVh: 0.5,
|
||||||
|
}))).toMatchObject({ targetId: 'profile', phase: 'anticipate' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-arms every later route after one large reverse retreat', () => {
|
||||||
|
const controller = createSignController();
|
||||||
|
controller.sample(sample());
|
||||||
|
controller.sample(sample({ now: 1_200, direction: 0, deltaY: 0 }));
|
||||||
|
controller.sample(sample({ now: 2_600, direction: 0, deltaY: 0 }));
|
||||||
|
controller.sample(sample({ now: 3_300, direction: 0, deltaY: 0 }));
|
||||||
|
controller.sample(sample({
|
||||||
|
now: 3_650,
|
||||||
|
activeSectionId: 'profile',
|
||||||
|
candidateId: 'experience',
|
||||||
|
candidateDistanceVh: 0.5,
|
||||||
|
}));
|
||||||
|
controller.sample(sample({
|
||||||
|
now: 4_850,
|
||||||
|
direction: 0,
|
||||||
|
deltaY: 0,
|
||||||
|
activeSectionId: 'profile',
|
||||||
|
candidateId: 'experience',
|
||||||
|
}));
|
||||||
|
controller.sample(sample({
|
||||||
|
now: 6_250,
|
||||||
|
direction: 0,
|
||||||
|
deltaY: 0,
|
||||||
|
activeSectionId: 'experience',
|
||||||
|
candidateId: 'infrastructure',
|
||||||
|
}));
|
||||||
|
controller.sample(sample({
|
||||||
|
now: 6_950,
|
||||||
|
direction: 0,
|
||||||
|
deltaY: 0,
|
||||||
|
activeSectionId: 'experience',
|
||||||
|
candidateId: 'infrastructure',
|
||||||
|
}));
|
||||||
|
|
||||||
|
controller.sample(sample({
|
||||||
|
now: 7_100,
|
||||||
|
direction: -1,
|
||||||
|
deltaY: -2_400,
|
||||||
|
crossedBoundaries: 2,
|
||||||
|
activeSectionId: 'start',
|
||||||
|
candidateId: 'profile',
|
||||||
|
candidateDistanceVh: 0.8,
|
||||||
|
}));
|
||||||
|
controller.sample(sample({ now: 7_300 }));
|
||||||
|
controller.sample(sample({ now: 8_500, direction: 0, deltaY: 0 }));
|
||||||
|
controller.sample(sample({ now: 9_900, direction: 0, deltaY: 0 }));
|
||||||
|
controller.sample(sample({ now: 10_600, direction: 0, deltaY: 0 }));
|
||||||
|
|
||||||
|
expect(controller.sample(sample({
|
||||||
|
now: 10_950,
|
||||||
|
activeSectionId: 'profile',
|
||||||
|
candidateId: 'experience',
|
||||||
|
candidateDistanceVh: 0.5,
|
||||||
|
}))).toMatchObject({ targetId: 'experience', phase: 'anticipate' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['an unrequested explicit jump', 'explicit-navigation' as const],
|
||||||
|
['history restoration', 'history-restoration' as const],
|
||||||
|
])('starts a fresh passive traversal after %s', (_label, source) => {
|
||||||
|
const controller = createSignController();
|
||||||
|
controller.sample(sample());
|
||||||
|
controller.sample(sample({ now: 1_200, direction: 0, deltaY: 0 }));
|
||||||
|
controller.sample(sample({ now: 2_600, direction: 0, deltaY: 0 }));
|
||||||
|
controller.sample(sample({ now: 3_300, direction: 0, deltaY: 0 }));
|
||||||
|
|
||||||
|
expect(controller.sample(sample({
|
||||||
|
now: 3_400,
|
||||||
|
source,
|
||||||
|
navigationEpoch: 1,
|
||||||
|
navigationDestinationId: null,
|
||||||
|
direction: 0,
|
||||||
|
deltaY: 0,
|
||||||
|
activeSectionId: 'start',
|
||||||
|
candidateId: null,
|
||||||
|
candidateDistanceVh: null,
|
||||||
|
}))).toBeNull();
|
||||||
|
expect(controller.sample(sample({
|
||||||
|
now: 3_500,
|
||||||
|
source: 'passive-scroll',
|
||||||
|
navigationEpoch: 2,
|
||||||
|
direction: 1,
|
||||||
|
deltaY: 120,
|
||||||
|
activeSectionId: 'start',
|
||||||
|
candidateId: 'profile',
|
||||||
|
candidateDistanceVh: 0.5,
|
||||||
|
}))).toMatchObject({ targetId: 'profile', phase: 'anticipate' });
|
||||||
|
});
|
||||||
|
|
||||||
it('starts a fresh passive sign traversal after explicit navigation', () => {
|
it('starts a fresh passive sign traversal after explicit navigation', () => {
|
||||||
const controller = createSignController();
|
const controller = createSignController();
|
||||||
controller.sample(sample());
|
controller.sample(sample());
|
||||||
@@ -469,6 +1174,8 @@ describe('clock-driven sign lifecycle', () => {
|
|||||||
expect(controller.sample(sample({
|
expect(controller.sample(sample({
|
||||||
now: 3_400,
|
now: 3_400,
|
||||||
source: 'explicit-navigation',
|
source: 'explicit-navigation',
|
||||||
|
navigationEpoch: 1,
|
||||||
|
navigationDestinationId: 'contact',
|
||||||
activeSectionId: 'contact',
|
activeSectionId: 'contact',
|
||||||
candidateId: null,
|
candidateId: null,
|
||||||
candidateDistanceVh: null,
|
candidateDistanceVh: null,
|
||||||
@@ -476,6 +1183,8 @@ describe('clock-driven sign lifecycle', () => {
|
|||||||
expect(controller.sample(sample({
|
expect(controller.sample(sample({
|
||||||
now: 3_500,
|
now: 3_500,
|
||||||
source: 'explicit-navigation',
|
source: 'explicit-navigation',
|
||||||
|
navigationEpoch: 2,
|
||||||
|
navigationDestinationId: 'start',
|
||||||
activeSectionId: 'start',
|
activeSectionId: 'start',
|
||||||
}))).toBeNull();
|
}))).toBeNull();
|
||||||
|
|
||||||
@@ -484,4 +1193,64 @@ describe('clock-driven sign lifecycle', () => {
|
|||||||
phase: 'anticipate',
|
phase: 'anticipate',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not let a stale navigator sign re-complete a traversal restarted at Start', () => {
|
||||||
|
const controller = createSignController();
|
||||||
|
const profileNavigation = sample({
|
||||||
|
source: 'explicit-navigation',
|
||||||
|
navigationEpoch: 1,
|
||||||
|
navigationDestinationId: 'profile',
|
||||||
|
candidateId: null,
|
||||||
|
candidateDistanceVh: null,
|
||||||
|
});
|
||||||
|
controller.sample(profileNavigation);
|
||||||
|
controller.sample({
|
||||||
|
...profileNavigation,
|
||||||
|
now: 1_200,
|
||||||
|
direction: 0,
|
||||||
|
deltaY: 0,
|
||||||
|
activeSectionId: 'profile',
|
||||||
|
});
|
||||||
|
controller.sample({
|
||||||
|
...profileNavigation,
|
||||||
|
now: 2_600,
|
||||||
|
direction: 0,
|
||||||
|
deltaY: 0,
|
||||||
|
activeSectionId: 'profile',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(controller.sample(sample({
|
||||||
|
now: 2_800,
|
||||||
|
source: 'explicit-navigation',
|
||||||
|
navigationEpoch: 2,
|
||||||
|
navigationDestinationId: 'start',
|
||||||
|
direction: 0,
|
||||||
|
deltaY: 0,
|
||||||
|
activeSectionId: 'start',
|
||||||
|
candidateId: null,
|
||||||
|
candidateDistanceVh: null,
|
||||||
|
}))).toMatchObject({ targetId: 'profile', phase: 'depart' });
|
||||||
|
expect(controller.sample(sample({
|
||||||
|
now: 2_900,
|
||||||
|
viewportWidth: 390,
|
||||||
|
viewportHeight: 844,
|
||||||
|
candidateDistanceVh: 0.22,
|
||||||
|
}))).toMatchObject({ targetId: 'profile', phase: 'depart' });
|
||||||
|
expect(controller.sample(sample({
|
||||||
|
now: 3_300,
|
||||||
|
direction: 0,
|
||||||
|
deltaY: 0,
|
||||||
|
activeSectionId: 'start',
|
||||||
|
}))).toBeNull();
|
||||||
|
expect(controller.sample(sample({
|
||||||
|
now: 3_650,
|
||||||
|
direction: 0,
|
||||||
|
deltaY: 0,
|
||||||
|
viewportWidth: 390,
|
||||||
|
viewportHeight: 844,
|
||||||
|
}))).toMatchObject({
|
||||||
|
targetId: 'profile',
|
||||||
|
phase: 'anticipate',
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -565,11 +565,15 @@ describe("procedural world models", () => {
|
|||||||
const sign = createSign(resources);
|
const sign = createSign(resources);
|
||||||
for (const name of [
|
for (const name of [
|
||||||
"sign-backplate",
|
"sign-backplate",
|
||||||
|
"sign-face-panel",
|
||||||
"sign-face",
|
"sign-face",
|
||||||
"sign-housing",
|
"sign-housing",
|
||||||
"sign-edge-light",
|
"sign-edge-light",
|
||||||
"sign-post",
|
"sign-post",
|
||||||
|
"sign-post-secondary",
|
||||||
"sign-bracket",
|
"sign-bracket",
|
||||||
|
"sign-back-rails",
|
||||||
|
"sign-fasteners",
|
||||||
"sign-footing",
|
"sign-footing",
|
||||||
]) {
|
]) {
|
||||||
expect(sign.group.getObjectByName(name), name).toBeDefined();
|
expect(sign.group.getObjectByName(name), name).toBeDefined();
|
||||||
@@ -577,8 +581,14 @@ describe("procedural world models", () => {
|
|||||||
const face = sign.group.getObjectByName("sign-face") as {
|
const face = sign.group.getObjectByName("sign-face") as {
|
||||||
userData: { textureUpdateCount: number };
|
userData: { textureUpdateCount: number };
|
||||||
material: {
|
material: {
|
||||||
|
depthWrite: boolean;
|
||||||
opacity: number;
|
opacity: number;
|
||||||
map: { anisotropy: number; image: { width: number; height: number } };
|
toneMapped: boolean;
|
||||||
|
map: {
|
||||||
|
anisotropy: number;
|
||||||
|
image: { width: number; height: number };
|
||||||
|
premultiplyAlpha: boolean;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
geometry: {
|
geometry: {
|
||||||
boundingBox: { min: { x: number; y: number }; max: { x: number; y: number } } | null;
|
boundingBox: { min: { x: number; y: number }; max: { x: number; y: number } } | null;
|
||||||
@@ -606,7 +616,25 @@ describe("procedural world models", () => {
|
|||||||
expect(faceWidth / faceHeight).toBeCloseTo(4, 6);
|
expect(faceWidth / faceHeight).toBeCloseTo(4, 6);
|
||||||
expect(face.material.map.image).toMatchObject({ width: 2_048, height: 512 });
|
expect(face.material.map.image).toMatchObject({ width: 2_048, height: 512 });
|
||||||
expect(face.material.map.anisotropy).toBeGreaterThanOrEqual(8);
|
expect(face.material.map.anisotropy).toBeGreaterThanOrEqual(8);
|
||||||
|
expect(face.material.map.premultiplyAlpha).toBe(true);
|
||||||
|
expect(face.material.depthWrite).toBe(false);
|
||||||
|
expect(face.material.toneMapped).toBe(false);
|
||||||
|
expect(face.material).toBeInstanceOf(THREE.MeshBasicMaterial);
|
||||||
|
const facePanel = sign.group.getObjectByName("sign-face-panel") as { material: unknown };
|
||||||
|
expect(facePanel.material).toBeInstanceOf(THREE.MeshStandardMaterial);
|
||||||
const allocation = resources.snapshot();
|
const allocation = resources.snapshot();
|
||||||
|
let renderablePartCount = 0;
|
||||||
|
(sign.group as unknown as {
|
||||||
|
traverse(callback: (object: { isMesh?: boolean }) => void): void;
|
||||||
|
}).traverse((object) => {
|
||||||
|
if (object.isMesh === true) renderablePartCount += 1;
|
||||||
|
});
|
||||||
|
expect(renderablePartCount).toBe(20);
|
||||||
|
expect(allocation.owned).toMatchObject({
|
||||||
|
geometry: 13,
|
||||||
|
material: 7,
|
||||||
|
texture: 1,
|
||||||
|
});
|
||||||
|
|
||||||
sign.update(null);
|
sign.update(null);
|
||||||
expect((sign.group as { visible: boolean }).visible).toBe(false);
|
expect((sign.group as { visible: boolean }).visible).toBe(false);
|
||||||
@@ -656,6 +684,50 @@ describe("procedural world models", () => {
|
|||||||
resources.dispose();
|
resources.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("caps the sign texture to the renderer limit without changing its four-to-one sampling ratio", () => {
|
||||||
|
const resources = new SceneResourceBag();
|
||||||
|
const sign = createSign(resources, {
|
||||||
|
maximumAnisotropy: 4,
|
||||||
|
maximumTextureSize: 1_024,
|
||||||
|
});
|
||||||
|
sign.update({ targetId: "profile", phase: "dwell", progress: 1, opacity: 1 });
|
||||||
|
|
||||||
|
expect(sign.inspect(createChaseCamera(16 / 9), 1_440, 900)?.texture).toMatchObject({
|
||||||
|
width: 1_024,
|
||||||
|
height: 256,
|
||||||
|
maximumSize: 1_024,
|
||||||
|
anisotropy: 4,
|
||||||
|
});
|
||||||
|
resources.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a new target when the previous face is below the concealed threshold", () => {
|
||||||
|
const resources = new SceneResourceBag();
|
||||||
|
const sign = createSign(resources);
|
||||||
|
const face = sign.group.getObjectByName("sign-face") as {
|
||||||
|
userData: { textureUpdateCount: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
sign.update({
|
||||||
|
targetId: "profile",
|
||||||
|
phase: "approach",
|
||||||
|
progress: 0.03,
|
||||||
|
opacity: 0.01,
|
||||||
|
});
|
||||||
|
expect(face.userData.textureUpdateCount).toBe(1);
|
||||||
|
|
||||||
|
sign.update({
|
||||||
|
targetId: "experience",
|
||||||
|
phase: "anticipate",
|
||||||
|
progress: 0,
|
||||||
|
opacity: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(face.userData.textureUpdateCount).toBe(2);
|
||||||
|
expect(sign.inspect(createChaseCamera(16 / 9), 1_440, 900)?.corridor).toBe("left");
|
||||||
|
resources.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
it("begins sign departure from the rendered approach pose without a teleport", () => {
|
it("begins sign departure from the rendered approach pose without a teleport", () => {
|
||||||
const resources = new SceneResourceBag();
|
const resources = new SceneResourceBag();
|
||||||
const sign = createSign(resources);
|
const sign = createSign(resources);
|
||||||
@@ -681,21 +753,26 @@ describe("procedural world models", () => {
|
|||||||
resources.dispose();
|
resources.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("defers responsive pose changes for the complete lifecycle, including concealed anticipation", () => {
|
it("reflows a live sign to a new responsive pose without a resize teleport", () => {
|
||||||
const resources = new SceneResourceBag();
|
const resources = new SceneResourceBag();
|
||||||
const sign = createSign(resources);
|
const sign = createSign(resources);
|
||||||
const camera = createChaseCamera(16 / 9);
|
|
||||||
sign.setViewportSize(1_440, 900);
|
sign.setViewportSize(1_440, 900);
|
||||||
sign.update({ targetId: "profile", phase: "anticipate", progress: 0, opacity: 0 });
|
const dwell = { targetId: "profile", phase: "dwell", progress: 1, opacity: 1 } as const;
|
||||||
|
sign.update(dwell, 0);
|
||||||
|
const resizeOrigin = sign.group.position.clone();
|
||||||
|
|
||||||
sign.setViewportSize(844, 390);
|
sign.setViewportSize(844, 390);
|
||||||
const concealed = sign.inspect(camera, 844, 390);
|
sign.update(dwell, 100);
|
||||||
expect(concealed?.viewportProfile).toBe("wide");
|
expect(sign.group.position.distanceTo(resizeOrigin)).toBeLessThan(0.001);
|
||||||
sign.update({ targetId: "profile", phase: "approach", progress: 0.5, opacity: 0.7 });
|
sign.update(dwell, 260);
|
||||||
expect(sign.inspect(camera, 844, 390)?.viewportProfile).toBe("wide");
|
const midpoint = sign.group.position.clone();
|
||||||
|
expect(midpoint.distanceTo(resizeOrigin)).toBeGreaterThan(0.1);
|
||||||
|
sign.update(dwell, 450);
|
||||||
|
expect(sign.group.position.distanceTo(midpoint)).toBeGreaterThan(0.1);
|
||||||
|
|
||||||
sign.update(null);
|
sign.update(null);
|
||||||
sign.update({ targetId: "experience", phase: "anticipate", progress: 0, opacity: 0 });
|
sign.update({ targetId: "experience", phase: "anticipate", progress: 0, opacity: 0 });
|
||||||
|
const camera = createChaseCamera(844 / 390);
|
||||||
expect(sign.inspect(camera, 844, 390)?.viewportProfile).toBe("short");
|
expect(sign.inspect(camera, 844, 390)?.viewportProfile).toBe("short");
|
||||||
resources.dispose();
|
resources.dispose();
|
||||||
|
|
||||||
@@ -709,23 +786,70 @@ describe("procedural world models", () => {
|
|||||||
opacity: 1,
|
opacity: 1,
|
||||||
viewportProfile: "wide",
|
viewportProfile: "wide",
|
||||||
});
|
});
|
||||||
expect(rebuilt.inspect(camera, 844, 390)?.viewportProfile).toBe("wide");
|
expect(rebuilt.inspect(camera, 844, 390)?.viewportProfile).toBe("short");
|
||||||
rebuiltResources.dispose();
|
rebuiltResources.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("departs laterally without crossing the chase camera plane or growing toward the car", () => {
|
it("reflows a visible portrait sign into its navigator-safe pose without a teleport", () => {
|
||||||
const resources = new SceneResourceBag();
|
const resources = new SceneResourceBag();
|
||||||
const sign = createSign(resources);
|
const sign = createSign(resources);
|
||||||
|
const camera = createChaseCamera(390 / 844);
|
||||||
|
sign.setViewportSize(390, 844);
|
||||||
|
const passiveDwell = {
|
||||||
|
targetId: "profile",
|
||||||
|
phase: "dwell",
|
||||||
|
progress: 1,
|
||||||
|
opacity: 1,
|
||||||
|
} as const;
|
||||||
|
sign.update(passiveDwell, 0);
|
||||||
|
const passivePosition = sign.group.position.clone();
|
||||||
|
|
||||||
|
const navigationDwell = { ...passiveDwell, navigationArrival: true as const };
|
||||||
|
sign.update(navigationDwell, 100);
|
||||||
|
expect(sign.group.position.distanceTo(passivePosition)).toBeLessThan(0.001);
|
||||||
|
sign.update(navigationDwell, 260);
|
||||||
|
const midpoint = sign.group.position.clone();
|
||||||
|
expect(midpoint.distanceTo(passivePosition)).toBeGreaterThan(0.1);
|
||||||
|
sign.update(navigationDwell, 450);
|
||||||
|
|
||||||
|
const presentation = sign.inspect(camera, 390, 844);
|
||||||
|
if (presentation === null) throw new Error("Missing navigator-safe Profile sign");
|
||||||
|
expect(sign.group.position.distanceTo(midpoint)).toBeGreaterThan(0.1);
|
||||||
|
expect(presentation.bounds.top).toBeGreaterThanOrEqual(626);
|
||||||
|
expect(presentation.bounds.bottom).toBeLessThanOrEqual(690);
|
||||||
|
resources.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drives past a sign at constant world scale before carrying it beyond the roadside edge", () => {
|
||||||
|
const resources = new SceneResourceBag();
|
||||||
|
const sign = createSign(resources);
|
||||||
|
const camera = createChaseCamera(16 / 9);
|
||||||
sign.setViewportSize(1_440, 900);
|
sign.setViewportSize(1_440, 900);
|
||||||
sign.update({ targetId: "profile", phase: "dwell", progress: 1, opacity: 1 });
|
sign.update({ targetId: "profile", phase: "dwell", progress: 1, opacity: 1 });
|
||||||
const dwellX = sign.group.position.x;
|
const dwell = sign.inspect(camera, 1_440, 900);
|
||||||
|
if (dwell === null) throw new Error("Missing Profile dwell presentation");
|
||||||
|
const dwellPosition = sign.group.position.clone();
|
||||||
const dwellScale = sign.group.scale.x;
|
const dwellScale = sign.group.scale.x;
|
||||||
|
|
||||||
sign.update({ targetId: "profile", phase: "depart", progress: 0.9, opacity: 0.1 });
|
sign.update({ targetId: "profile", phase: "depart", progress: 0.5, opacity: 1 });
|
||||||
|
const passing = sign.inspect(camera, 1_440, 900);
|
||||||
|
if (passing === null) throw new Error("Missing Profile pass-by presentation");
|
||||||
|
|
||||||
expect(sign.group.position.x).toBeLessThan(dwellX);
|
expect(sign.group.position.x).toBeLessThan(dwellPosition.x);
|
||||||
expect(sign.group.position.z).toBeLessThanOrEqual(0);
|
expect(sign.group.position.y).toBeCloseTo(dwellPosition.y, 6);
|
||||||
expect(sign.group.scale.x).toBeLessThan(dwellScale);
|
expect(sign.group.position.z).toBeGreaterThan(dwellPosition.z);
|
||||||
|
expect(sign.group.scale.x).toBeCloseTo(dwellScale, 6);
|
||||||
|
expect(passing.bounds.width).toBeGreaterThan(dwell.bounds.width);
|
||||||
|
expect(passing.bounds.left).toBeLessThan(dwell.bounds.left);
|
||||||
|
|
||||||
|
const passingZ = sign.group.position.z;
|
||||||
|
sign.update({ targetId: "profile", phase: "depart", progress: 0.9, opacity: 0.1 });
|
||||||
|
const leaving = sign.inspect(camera, 1_440, 900);
|
||||||
|
if (leaving === null) throw new Error("Missing Profile departure presentation");
|
||||||
|
expect(sign.group.position.z).toBeGreaterThan(passingZ);
|
||||||
|
expect(sign.group.position.z).toBeLessThan(camera.position.z - 2);
|
||||||
|
expect(sign.group.scale.x).toBeCloseTo(dwellScale, 6);
|
||||||
|
expect(leaving.bounds.right).toBeLessThan(passing.bounds.right);
|
||||||
resources.dispose();
|
resources.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -746,7 +870,82 @@ describe("procedural world models", () => {
|
|||||||
resources.dispose();
|
resources.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("places route signs in the corridor opposite their destination content", () => {
|
it("keeps a short-landscape left sign inside the narrow desktop roadside gutter", () => {
|
||||||
|
const resources = new SceneResourceBag();
|
||||||
|
const sign = createSign(resources);
|
||||||
|
const camera = createChaseCamera(780 / 493);
|
||||||
|
camera.position.set(-1.35, 8.6, 22);
|
||||||
|
camera.lookAt(1.6, 4.8, -128);
|
||||||
|
sign.setViewportSize(780, 493);
|
||||||
|
sign.update({ targetId: "profile", phase: "dwell", progress: 1, opacity: 1 });
|
||||||
|
|
||||||
|
const presentation = sign.inspect(camera, 780, 493);
|
||||||
|
if (presentation === null) throw new Error("Missing short-landscape Profile sign");
|
||||||
|
expect(presentation.bounds.left).toBeGreaterThanOrEqual(8);
|
||||||
|
expect(presentation.bounds.right).toBeLessThanOrEqual(265);
|
||||||
|
resources.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a short-landscape left sign horizontally clear at 844 by 390", () => {
|
||||||
|
const resources = new SceneResourceBag();
|
||||||
|
const sign = createSign(resources);
|
||||||
|
const camera = createChaseCamera(844 / 390);
|
||||||
|
camera.position.set(-1.35, 8.6, 22);
|
||||||
|
camera.lookAt(1.6, 4.8, -128);
|
||||||
|
sign.setViewportSize(844, 390);
|
||||||
|
sign.update({ targetId: "profile", phase: "dwell", progress: 1, opacity: 1 });
|
||||||
|
|
||||||
|
const presentation = sign.inspect(camera, 844, 390);
|
||||||
|
if (presentation === null) throw new Error("Missing 844 by 390 Profile sign");
|
||||||
|
expect(presentation.bounds.left).toBeGreaterThanOrEqual(8);
|
||||||
|
expect(presentation.bounds.right).toBeLessThanOrEqual(288);
|
||||||
|
resources.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("places a portrait navigator arrival below its full-width destination panel", () => {
|
||||||
|
const resources = new SceneResourceBag();
|
||||||
|
const sign = createSign(resources);
|
||||||
|
const camera = createChaseCamera(390 / 844);
|
||||||
|
sign.setViewportSize(390, 844);
|
||||||
|
sign.update({
|
||||||
|
targetId: "profile",
|
||||||
|
phase: "dwell",
|
||||||
|
progress: 1,
|
||||||
|
opacity: 1,
|
||||||
|
navigationArrival: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const presentation = sign.inspect(camera, 390, 844);
|
||||||
|
if (presentation === null) throw new Error("Missing portrait navigator sign");
|
||||||
|
expect(presentation.bounds.left).toBeGreaterThanOrEqual(8);
|
||||||
|
expect(presentation.bounds.top).toBeGreaterThanOrEqual(626);
|
||||||
|
expect(presentation.bounds.right).toBeLessThanOrEqual(382);
|
||||||
|
expect(presentation.bounds.bottom).toBeLessThanOrEqual(690);
|
||||||
|
expect(presentation.bounds.width).toBeGreaterThanOrEqual(85);
|
||||||
|
resources.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lands a resized Profile lifecycle in the short-landscape safe corridor", () => {
|
||||||
|
const resources = new SceneResourceBag();
|
||||||
|
const sign = createSign(resources);
|
||||||
|
sign.setViewportSize(1_440, 900);
|
||||||
|
sign.update({ targetId: "profile", phase: "dwell", progress: 1, opacity: 1 });
|
||||||
|
|
||||||
|
sign.setViewportSize(844, 390);
|
||||||
|
sign.update({ targetId: "profile", phase: "dwell", progress: 1, opacity: 1 }, 100);
|
||||||
|
sign.update({ targetId: "profile", phase: "dwell", progress: 1, opacity: 1 }, 450);
|
||||||
|
const camera = createChaseCamera(844 / 390);
|
||||||
|
camera.position.set(-1.35, 8.6, 22);
|
||||||
|
camera.lookAt(1.6, 4.8, -128);
|
||||||
|
const presentation = sign.inspect(camera, 844, 390);
|
||||||
|
if (presentation === null) throw new Error("Missing resized Profile sign");
|
||||||
|
expect(presentation.viewportProfile).toBe("short");
|
||||||
|
expect(presentation.bounds.left).toBeGreaterThanOrEqual(8);
|
||||||
|
expect(presentation.bounds.right).toBeLessThanOrEqual(288);
|
||||||
|
resources.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("places route signs in their authored transition-safe corridors", () => {
|
||||||
const resources = new SceneResourceBag();
|
const resources = new SceneResourceBag();
|
||||||
const sign = createSign(resources);
|
const sign = createSign(resources);
|
||||||
sign.setViewportAspect(16 / 9);
|
sign.setViewportAspect(16 / 9);
|
||||||
@@ -762,7 +961,7 @@ describe("procedural world models", () => {
|
|||||||
expect(profileBounds.maxX).toBeLessThan(0.08);
|
expect(profileBounds.maxX).toBeLessThan(0.08);
|
||||||
|
|
||||||
sign.update(null);
|
sign.update(null);
|
||||||
sign.update({ targetId: "experience", phase: "dwell", progress: 1, opacity: 1 });
|
sign.update({ targetId: "contact", phase: "dwell", progress: 1, opacity: 1 });
|
||||||
sign.group.updateMatrixWorld(true);
|
sign.group.updateMatrixWorld(true);
|
||||||
const experienceBounds = projectedBounds(profileFace, camera);
|
const experienceBounds = projectedBounds(profileFace, camera);
|
||||||
expect(experienceBounds.minX).toBeGreaterThan(0);
|
expect(experienceBounds.minX).toBeGreaterThan(0);
|
||||||
|
|||||||