From ce2bb45081acc037d4c9f96c68a32cdd23e560b0 Mon Sep 17 00:00:00 2001 From: Boris Masis Date: Tue, 25 Aug 2026 08:47:33 -0700 Subject: [PATCH 01/59] Fix sequence playback starving on long sequences MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Caching every image of a sequence is a batching optimization for the sequence graph mode, but it gated image caching: it only completes once every batch has been retrieved, which on long sequences takes long enough to starve playback. Run it alongside the sequence request instead of in front of it. Also resolve trajectory positions through a Map instead of Array.indexOf — these are looked up on every animation frame, so the linear scans scaled with sequence length — and return early when the trajectory reaches beyond the sequence into another one, where there is nothing to request. --- src/viewer/PlayService.ts | 60 +++++++++++++++++++++++++++++++++------ 1 file changed, 52 insertions(+), 8 deletions(-) diff --git a/src/viewer/PlayService.ts b/src/viewer/PlayService.ts index 6cbbcd769..798ba2a00 100644 --- a/src/viewer/PlayService.ts +++ b/src/viewer/PlayService.ts @@ -2,6 +2,7 @@ import { combineLatest as observableCombineLatest, empty as observableEmpty, from as observableFrom, + merge as observableMerge, of as observableOf, zip as observableZip, Observable, @@ -160,9 +161,8 @@ export class PlayService { return observableOf<[Sequence, NavigationDirection]>([undefined, direction]); } - const sequence$: Observable = (mode === GraphMode.Sequence ? - this._graphService.cacheSequenceImages$(sequenceId, imageId) : - this._graphService.cacheSequence$(sequenceId)).pipe( + const sequence$: Observable = this._graphService + .cacheSequence$(sequenceId).pipe( retry(3), catchError( (error: Error): Observable => { @@ -171,8 +171,29 @@ export class PlayService { return observableOf(undefined); })); + // Caching all images of the sequence is a batching + // optimization for the sequence graph mode. It must + // run alongside image caching instead of gating it: + // it only completes once every batch has been + // retrieved, which for long sequences takes long + // enough to starve playback of images while running. + const sequenceImages$: Observable = + mode === GraphMode.Sequence ? + this._graphService + .cacheSequenceImages$(sequenceId, imageId) + .pipe( + retry(3), + catchError( + (error: Error): Observable => { + console.error(error); + + return observableEmpty(); + })) : + observableEmpty(); + return observableCombineLatest( - sequence$, + observableMerge(sequence$, sequenceImages$).pipe( + distinctUntilChanged()), observableOf(direction)); }), switchMap( @@ -186,6 +207,21 @@ export class PlayService { imageIds.reverse(); } + // Positions are resolved on every animation frame, + // linear lookups would scale with the sequence length. + const imageIndices: Map = + new Map(); + for (let i: number = 0; i < imageIds.length; i++) { + imageIndices.set(imageIds[i], i); + } + + const indexOf: (id: string) => number = + (id: string): number => { + const index: number = imageIndices.get(id); + + return index === undefined ? -1 : index; + }; + return this._stateService.currentState$.pipe( map( (frame: AnimationFrame): [string, number] => { @@ -197,8 +233,12 @@ export class PlayService { [lastTrajectoryKey, imagesAhead]: [string, number]): [string, string[]] => { - if (lastRequestKey === undefined) { - lastRequestKey = lastTrajectoryKey; + // The trajectory reaches beyond the + // sequence when traversing into another + // one, there is nothing to request here. + const current: number = indexOf(lastTrajectoryKey); + if (current === -1) { + return [lastRequestKey, []]; } const lastIndex: number = imageIds.length - 1; @@ -206,8 +246,12 @@ export class PlayService { return [lastRequestKey, []]; } - const current: number = imageIds.indexOf(lastTrajectoryKey); - const start: number = imageIds.indexOf(lastRequestKey) + 1; + const lastRequestIndex: number = + lastRequestKey === undefined ? + -1 : indexOf(lastRequestKey); + const start: number = + (lastRequestIndex === -1 ? + current : lastRequestIndex) + 1; const end: number = Math.min(lastIndex, current + this._imagesAhead - imagesAhead) + 1; if (end <= start) { From 6912be178b24cb6fb0a9b4578779deeb75a8942d Mon Sep 17 00:00:00 2001 From: Boris Masis Date: Tue, 25 Aug 2026 08:47:45 -0700 Subject: [PATCH 02/59] Reorientation: honor navigation intent across sequences, reorient on jumps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reorientation reset the view to the travel direction on every sequence change, which threw away the view the user had navigated in with. Three changes: Cross-sequence moves now follow the move's intent. Step* keeps the viewing direction by definition, so a step into a new sequence carries the incoming view unchanged and seeds the new sequence's look-around offset from it. Turn* is a deliberate rotation, so it re-bases to the new sequence's travel direction while preserving the look-around offset. Everything else — Next/Prev, map click, shared link, fresh load — still resets to travel plus horizon. The image stream is direction-blind, so Navigator records the direction of the last move and the reorientation component consumes it synchronously with the landing image. Spatial navigation does not all go through moveDir$: the keyboard handler and the pano/step direction circles resolve the edge themselves and call moveTo$, so moveTo$ takes the intended direction too and those call sites pass it. Spherical counts as a step, since that is how panoramas navigate. Within a sequence, a landing with no direction on an image that is not a neighbour of the one we left is a jump (map click, pKey change), not a step. The incoming view says nothing about the new position, so reorient even when the landing image's GPS speed reads as stationary — previously such a jump kept the old heading and left the user facing sideways. Both neighbours are now pre-oriented rather than just the next one. Without a hint, backward navigation only reorients on arrival past the 15 degree threshold, so on gentle stretches it held the carried bearing. The engine records prevId and warms the previous image to support this. Adds getReorientedBearing(id), returning the heading an image will be shown at from the precomputed cache, so a host application can point map UI at the same direction the viewer will land on. The look-around offset applies only to same-sequence ids, since a cross-sequence landing resets it. --- package.json | 2 +- .../direction/DirectionDOMRenderer.ts | 10 +- .../keyboard/KeySpatialNavigationHandler.ts | 8 +- .../reorientation/ReorientationComponent.ts | 218 +++++++++++++----- .../reorientation/ReorientationEngine.ts | 12 + src/viewer/Navigator.ts | 26 ++- 6 files changed, 215 insertions(+), 61 deletions(-) diff --git a/package.json b/package.json index 5e76e579a..53c79373b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mapillary-js", - "version": "5.0.0-beta.5", + "version": "5.0.0-beta.5-fb4", "description": "A WebGL interactive street imagery library", "license": "MIT", "main": "dist/mapillary.js", diff --git a/src/component/direction/DirectionDOMRenderer.ts b/src/component/direction/DirectionDOMRenderer.ts index 8803b3f4a..ad4309ef1 100644 --- a/src/component/direction/DirectionDOMRenderer.ts +++ b/src/component/direction/DirectionDOMRenderer.ts @@ -259,7 +259,8 @@ export class DirectionDOMRenderer { sphericalEdge.data.worldMotionAzimuth, rotation, this._calculator.outerRadius, - "mapillary-direction-arrow-spherical")); + "mapillary-direction-arrow-spherical", + sphericalEdge.data.direction)); } for (let stepEdge of this._stepEdges) { @@ -307,7 +308,8 @@ export class DirectionDOMRenderer { azimuth, rotation, this._calculator.outerRadius, - "mapillary-direction-arrow-step"); + "mapillary-direction-arrow-step", + direction); } return this._createVNodeInactive(key, azimuth, rotation); @@ -325,6 +327,7 @@ export class DirectionDOMRenderer { rotation, this._calculator.innerRadius, "mapillary-direction-arrow-spherical", + sphericalEdge.data.direction, true)); } @@ -372,11 +375,12 @@ export class DirectionDOMRenderer { rotation: EulerRotation, offset: number, className: string, + direction: NavigationDirection, shiftVertically?: boolean): vd.VNode { let onClick: (e: Event) => void = (e: Event): void => { - navigator.moveTo$(key) + navigator.moveTo$(key, direction) .subscribe( undefined, (error: Error): void => { diff --git a/src/component/keyboard/KeySpatialNavigationHandler.ts b/src/component/keyboard/KeySpatialNavigationHandler.ts index 90184860f..8a18a5694 100644 --- a/src/component/keyboard/KeySpatialNavigationHandler.ts +++ b/src/component/keyboard/KeySpatialNavigationHandler.ts @@ -136,7 +136,7 @@ export class KeySpatialNavigationHandler extends HandlerBase { diff --git a/src/component/reorientation/ReorientationComponent.ts b/src/component/reorientation/ReorientationComponent.ts index 2ffb87fdd..4339a71df 100644 --- a/src/component/reorientation/ReorientationComponent.ts +++ b/src/component/reorientation/ReorientationComponent.ts @@ -13,6 +13,7 @@ import { } from "./ReorientationEngine"; import { Image } from "../../graph/Image"; +import { NavigationDirection } from "../../graph/edge/NavigationDirection"; import { Sequence } from "../../graph/Sequence"; import { Container } from "../../viewer/Container"; import { Navigator } from "../../viewer/Navigator"; @@ -28,8 +29,17 @@ const MIN_REORIENT_DEG = 15; * (the great-circle bearing toward the next image in the sequence) as the * user navigates, instead of preserving the previous look direction. If the * user drags to look around, that manual offset is preserved across the rest - * of the sequence rather than re-facing forward on every step. Active by - * default; disable with `component: { reorientation: false }`. + * of the sequence rather than re-facing forward on every step. + * + * Crossing into a new sequence via a spatial move honors the move's intent + * rather than snapping to travel: a Step* (which by definition keeps the + * viewing direction) carries the incoming view unchanged, and a Turn* rotates + * the base to the new sequence's travel while preserving the look-around offset + * (so the turn happens relative to where the user was looking). Every other way + * into a new sequence — Next/Prev, map click, shared link, fresh load — resets + * to travel + horizon as before. + * + * Active by default; disable with `component: { reorientation: false }`. * * @example * ```js @@ -90,8 +100,12 @@ export class ReorientationComponent if (!image) { return; } + const fromId = this._activeId; this._activeId = image.id; - this._reorient(image); + // Consume the direction here, synchronously with the landing, so + // it attributes to this image and not a later re-emit. + const direction = this._navigator.consumeMoveDirection(); + this._reorient(image, direction, fromId); })); // A finished drag is a genuine user look-around (our own steering goes @@ -112,7 +126,40 @@ export class ReorientationComponent return {}; } - private _reorient(image: Image): void { + /** + * The reoriented viewer bearing (degrees clockwise from north) the given + * image will be shown at once navigated to: its travel direction plus the + * manual look-around offset preserved across the sequence — the exact + * heading the reoriented view (and any cone tracking it) ends up at. + * + * Synchronous: reads only the engine's precomputed cache (populated for the + * images around the current one), so there is no network round-trip. + * Returns null when there is no valid reorientation for the id — not a + * reoriented pano, end of sequence, or not yet computed — so callers can + * fall back to the image's own compass angle. + */ + public getReorientedBearing(id: string): number | null { + const engine = this._engine; + if (!engine) { + return null; + } + const result = engine.get(id); + if (!result || !result.valid || typeof result.travel !== "number") { + return null; + } + // The look-around offset belongs to the active sequence and is reset on + // a cross-sequence jump, so apply it only to same-sequence ids — + // otherwise the hover cone predicts travel+offset while the actual + // landing (fresh sequence → offset 0) shows plain travel. + const offset = result.seq != null && result.seq === this._lastSeq ? + this._userOffsetX * 360 : 0; + return ((result.travel + offset) % 360 + 360) % 360; + } + + private _reorient( + image: Image, + direction: NavigationDirection, + fromId: string): void { const id = image.id; const seed = this._seed(image); const engine = this._engine; @@ -137,21 +184,39 @@ export class ReorientationComponent this._computedBasicX = result.basicX; // A fresh load or a jump to another capture lands on a new - // sequence; reorient it to travel direction even if the landing - // image's GPS speed reads as stationary. Within a sequence, - // preserve the view when not moving rather than spinning on - // jitter. + // sequence; reorient it even if the landing image's GPS speed + // reads as stationary. Within a sequence, preserve the view when + // not moving rather than spinning on jitter. const freshSequence = result.seq != null && result.seq !== this._lastSeq; this._lastSeq = result.seq; + + // How we crossed into the new sequence decides what to land on. + // Step* means "keep the viewing direction", so carry the incoming + // view unchanged (offset seeded from it below, no rotation). + // Turn* is a deliberate rotation, so re-base to the new + // sequence's travel but keep the look-around offset — the turn + // happens relative to the current view. Everything else (Next/ + // Prev, map click, shared link, fresh load) resets to travel. + const carryView = freshSequence && this._isStep(direction); + const turnView = freshSequence && this._isTurn(direction); + const resetView = freshSequence && !carryView && !turnView; + + if (freshSequence) { + // Drop look-ahead hints from the prior sequence so nothing + // carries over; this sequence registers its own as it goes. + this._navigator.stateService.clearReorientations(); + } + // Pitch is tracked deterministically via the offset (the live // view y drifts ~18° through the spherical projection, so it - // can't be measured per image). On a sequence change the held - // pitch resets to horizon; the reset amount is how far the - // carried pitch must move, used to force a reorientation even - // when the horizontal change is small. + // can't be measured per image). On a reset the held pitch resets + // to horizon; the reset amount is how far the carried pitch must + // move, used to force a reorientation even when the horizontal + // change is small. Carry/turn keep the pitch as part of the + // preserved look-around. let pitchResetDeg = 0; - if (freshSequence) { + if (resetView) { // Don't carry x/pitch/rotation offsets across sequences: // start fresh at travel direction + horizon. A cross-sequence // jump carries the prior image's view, which would otherwise @@ -160,18 +225,22 @@ export class ReorientationComponent this._userOffsetX = 0; this._userOffsetY = 0; this._ySeeded = true; - // Drop look-ahead hints from the prior sequence so nothing - // carries over; this sequence registers its own as it goes. - this._navigator.stateService.clearReorientations(); } - if (!result.moving && !freshSequence) { - // Low motion within the current sequence — preserve the view. + // A directionless landing on an image that isn't a neighbour of + // the one we left is a jump (map click, URL/pKey change), not a + // step: the incoming view says nothing about the new position, + // so there is nothing worth preserving. + const leftAnother = fromId != null && fromId !== id; + const fromResult = leftAnother ? engine.get(fromId) : null; + const jump = direction == null && leftAnother && + fromResult?.nextId !== id && fromResult?.prevId !== id; + + if (!result.moving && !freshSequence && !jump) { + // Low motion between adjacent images — preserve the view + // rather than spin on stationary GPS jitter. return; } - // Horizontal target: travel direction plus any manual offset. - const targetX = this._applyOffsetX(result.basicX); - // Read this image's current view and reorient only if doing so // would move it past the threshold on either axis — otherwise // the move is just jitter. Measured per image (current view → @@ -186,9 +255,19 @@ export class ReorientationComponent this._userOffsetY = center[1] - 0.5; this._ySeeded = true; } + // Step* into a new sequence: keep the view the user + // carried in. Seed this sequence's look-around offset + // from that carried view so in-sequence steps preserve + // it; targetX then equals center[0], so nothing rotates. + if (carryView) { + this._userOffsetX = + wrapDelta(center[0] - result.basicX); + } const targetY = Math.max(0, Math.min(1, 0.5 + this._userOffsetY)); + const targetX = this._applyOffsetX(result.basicX); + // Horizontal only: the pitch is held deterministically, // so the live y wobbles with spherical-projection round- // trip noise (~0.1 ≈ 18°). Gating on it would just snap @@ -210,38 +289,27 @@ export class ReorientationComponent } } - // Pre-orient the next image (hint) so a hard cut to it - // doesn't flash the carried view — but only if its - // reorientation would also clear the threshold. This is - // cross-image, so reason in absolute bearings: the view - // the next image carries in is where this one ends up. - if (result.nextId && typeof result.cca === "number") { + // Pre-orient BOTH neighbors (hints) so a hard cut + // doesn't flash the carried view and stepping either way + // lands on the travel direction deterministically. Prev + // needs this as much as next: without a hint, backward + // navigation only reorients on arrival past the 15° + // threshold, so on gentle stretches it holds the carried + // bearing and the prev cone (which predicts travel + + // offset) no longer matches. Cross-image, so reason in + // absolute bearings: the view a neighbor carries in is + // where this image ends up. + if (typeof result.cca === "number") { const endX = move ? targetX : center[0]; - const endBearing = - result.cca + (endX - 0.5) * 360; - const nid = String(result.nextId); - engine.precompute(nid) - .then((): void => { - if (this._engine !== engine) { - return; - } - const nr = engine.get(nid); - if (!nr || !nr.valid || - typeof nr.cca !== "number") { - return; - } - const nTargetX = this._applyOffsetX(nr.basicX); - const nCarriedX = - bearingToBasicX(endBearing, nr.cca); - const nDx = Math.abs( - wrapDelta(nTargetX - nCarriedX)) * 360; - if (nDx >= MIN_REORIENT_DEG) { - this._navigator.stateService - .setReorientation( - nid, [nTargetX, targetY]); - } - }) - .catch((): void => { /* skip */ }); + const endBearing = result.cca + (endX - 0.5) * 360; + if (result.nextId) { + this._hintNeighbor( + engine, result.nextId, endBearing, targetY); + } + if (result.prevId) { + this._hintNeighbor( + engine, result.prevId, endBearing, targetY); + } } }); }) @@ -252,6 +320,52 @@ export class ReorientationComponent return ((basicX + this._userOffsetX) % 1 + 1) % 1; } + // Spherical is the pano-to-pano step, so it counts as one here. + private _isStep(direction: NavigationDirection): boolean { + return direction === NavigationDirection.StepLeft || + direction === NavigationDirection.StepRight || + direction === NavigationDirection.StepForward || + direction === NavigationDirection.StepBackward || + direction === NavigationDirection.Spherical; + } + + private _isTurn(direction: NavigationDirection): boolean { + return direction === NavigationDirection.TurnLeft || + direction === NavigationDirection.TurnRight || + direction === NavigationDirection.TurnU; + } + + // Pre-set a neighbor's reorientation to travel + offset, but only if it + // would clear the threshold against the view it carries in from this image + // (endBearing) — otherwise the step is small enough to leave alone. + private _hintNeighbor( + engine: ReorientationEngine, + id: string, + endBearing: number, + targetY: number): void { + // Depth 0: we only need this neighbor's own result cached, not another + // forward prefetch cascade (the current image's _reorient already warms + // ahead). Avoids re-walking the already-scheduled chain per navigation. + engine.precompute(id, undefined, 0) + .then((): void => { + if (this._engine !== engine) { + return; + } + const nr = engine.get(id); + if (!nr || !nr.valid || typeof nr.cca !== "number") { + return; + } + const nTargetX = this._applyOffsetX(nr.basicX); + const nCarriedX = bearingToBasicX(endBearing, nr.cca); + const nDx = Math.abs(wrapDelta(nTargetX - nCarriedX)) * 360; + if (nDx >= MIN_REORIENT_DEG) { + this._navigator.stateService + .setReorientation(id, [nTargetX, targetY]); + } + }) + .catch((): void => { /* skip */ }); + } + private _captureOffset(): void { const id = this._activeId; const engine = this._engine; diff --git a/src/component/reorientation/ReorientationEngine.ts b/src/component/reorientation/ReorientationEngine.ts index 28fc9b16a..bd1da587a 100644 --- a/src/component/reorientation/ReorientationEngine.ts +++ b/src/component/reorientation/ReorientationEngine.ts @@ -32,6 +32,7 @@ export interface ReorientationResult { valid: boolean; reason?: string; nextId?: string; + prevId?: string; travel?: number; basicX?: number; dist?: number; @@ -338,6 +339,7 @@ export class ReorientationEngine { const result: ReorientationResult = { valid: true, nextId, + prevId: idx > 0 ? ids[idx - 1] : undefined, travel: tb, basicX: bearingToBasicX(tb, cur.cca), dist, @@ -348,6 +350,16 @@ export class ReorientationEngine { }; this._cache.set(imgId, result); this._prefetchNext(result, depth); + // Warm the immediate previous image so a "previous" hover can read + // its reoriented bearing instead of falling back to the raw compass + // angle. Only from the root request (depth === prefetchAhead) and at + // depth 0, so it resolves that one image without cascading backward. + // Load-bearing for the paths where the component early-returns + // before hinting neighbors (no motion, or no compass angle). + if (depth === cfg.prefetchAhead && result.prevId != null) { + this.precompute(result.prevId, undefined, 0) + .catch(() => { /* ignore prefetch errors */ }); + } }); } diff --git a/src/viewer/Navigator.ts b/src/viewer/Navigator.ts index 9f6b7029f..e29922f22 100644 --- a/src/viewer/Navigator.ts +++ b/src/viewer/Navigator.ts @@ -58,6 +58,8 @@ export class Navigator { private _idRequested$: BehaviorSubject; private _movedToId$: BehaviorSubject; + private _lastMoveDirection: NavigationDirection; + private _request$: ReplaySubject; private _requestSubscription: Subscription; private _imageRequestSubscription: Subscription; @@ -117,6 +119,8 @@ export class Navigator { this._idRequested$ = new BehaviorSubject(null); this._movedToId$ = new BehaviorSubject(null); + this._lastMoveDirection = null; + this._request$ = null; this._requestSubscription = null; this._imageRequestSubscription = null; @@ -168,8 +172,22 @@ export class Navigator { this._stateService.dispose(); } + /** + * The direction of the most recent {@link moveDir$} navigation, consumed + * (reset to null) on read so it attributes to exactly one landing image. + * Null for direct id moves (map click, shared link, fresh load) and once + * read. Lets the reorientation component tell a spatial step/turn apart + * from a non-directional jump, which the direction-blind image stream can't. + */ + public consumeMoveDirection(): NavigationDirection { + const direction = this._lastMoveDirection; + this._lastMoveDirection = null; + return direction; + } + public moveDir$(direction: NavigationDirection): Observable { this._abortRequest(`in dir ${NavigationDirection[direction]}`); + this._lastMoveDirection = direction; this._loadingService.startLoading(this._loadingName); @@ -206,8 +224,14 @@ export class Navigator { return this._makeRequest$(image$); } - public moveTo$(id: string): Observable { + public moveTo$( + id: string, + direction: NavigationDirection = null): Observable { this._abortRequest(`to id ${id}`); + // Spatial handlers (keyboard, pano/step direction circles) resolve the + // edge themselves and move by id, but still know the intended direction; + // record it so reorientation can tell a step/turn from a bare jump. + this._lastMoveDirection = direction; this._loadingService.startLoading(this._loadingName); From 0f3392e61fcde53331a4c19a44f5f073d244f3ad Mon Sep 17 00:00:00 2001 From: Boris Masis Date: Fri, 28 Aug 2026 08:47:29 -0700 Subject: [PATCH 03/59] Reorientation: adopt host views, keep carried views on arrows, fix corners MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three changes to how the reorientation component decides what the user should be looking at. Arrows keep the view they carry. Crossing into a new sequence with a direction arrow used to branch on Step* vs Turn*, rotating the base to the new sequence's travel on a turn. The state layer has already matched the angle by then, so rotating on top of it fought the transition the arrow just made. Both now keep the carried view and adopt it as the new sequence's look-around offset. The same applies to a sideways hop — an arrow landing somewhere other than the image next to the one we left is a parallel pass or a doubling-back return leg, whose travel can be the reverse of ours, and facing it would swing the user around. Hosts can hand over a view. A shared link carries explicit basic coordinates the user framed themselves, and the component never observed them, so the offset was discarded and the next navigation snapped to travel. adoptView() takes those coordinates as the look-around offset instead. Passing them in rather than reading the viewer avoids racing the host applying them. reorientsOnSpatialNavTo() exposes the arrow decision so a host can predict the landing heading, and reorientOnSpatialNav opts out of in-sequence arrow reorientation to compare against plain carried-view navigation. Sharp corners are motion, not noise. The outlier test rejected a large bearing change between two consecutive segments that were both at sustained speed. That is a corner, not a bad fix — noise only reaches the test through the low-speed branch — and rejecting it marked the sharpest corners, and via the history window the whole stretch after them, as "not moving": exactly where reorientation is wanted. The test now only applies when at least one segment is below the moving speed. The low-speed distance floor drops from 2m to 0.5m for the same reason: walking-pace capture puts frames about a metre apart, and the compass agreement already separates that from a stationary camera's GPS drift. --- package.json | 2 +- .../interfaces/ReorientationConfiguration.ts | 21 +- .../reorientation/ReorientationComponent.ts | 186 +++++++++++++++--- .../reorientation/ReorientationEngine.ts | 16 +- 4 files changed, 193 insertions(+), 32 deletions(-) diff --git a/package.json b/package.json index 53c79373b..b7d22a06e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mapillary-js", - "version": "5.0.0-beta.5-fb4", + "version": "5.0.0-beta.5-fb5", "description": "A WebGL interactive street imagery library", "license": "MIT", "main": "dist/mapillary.js", diff --git a/src/component/interfaces/ReorientationConfiguration.ts b/src/component/interfaces/ReorientationConfiguration.ts index d6cdabfb1..04c37990e 100644 --- a/src/component/interfaces/ReorientationConfiguration.ts +++ b/src/component/interfaces/ReorientationConfiguration.ts @@ -18,6 +18,21 @@ import { ComponentConfiguration } from "./ComponentConfiguration"; * ``` */ export interface ReorientationConfiguration extends ComponentConfiguration { + /** + * Whether a spatial navigation (the direction component's step, turn and + * spherical arrows, and their keyboard equivalents) reorients the landing + * image when it stays within the current sequence. + * + * Crossing into a new sequence with an arrow always keeps the carried view + * regardless of this setting: the state layer has already matched the + * angle, and reorienting on top of that fights the transition the arrow + * just made. Within a sequence the carried view instead drifts off-axis as + * the road bends, which is what reorientation corrects. + * + * @default true + */ + reorientOnSpatialNav?: boolean; + /** * Number of images ahead in the sequence to precompute the * reorientation for, so that a step lands on an already resolved @@ -41,7 +56,11 @@ export interface ReorientationConfiguration extends ComponentConfiguration { * with the travel bearing within {@link lowSpeedTurnMaxDeltaDeg} and * the step is longer than this distance in meters. * - * @default 2 + * Low enough to accept walking-pace capture, where frames are often only + * about a metre apart; the compass agreement above is what separates that + * from a stationary camera's GPS drift. + * + * @default 0.5 */ lowSpeedTurnDistanceM?: number; diff --git a/src/component/reorientation/ReorientationComponent.ts b/src/component/reorientation/ReorientationComponent.ts index 4339a71df..aca4d9af2 100644 --- a/src/component/reorientation/ReorientationComponent.ts +++ b/src/component/reorientation/ReorientationComponent.ts @@ -1,3 +1,4 @@ +import { Observable, Subject } from "rxjs"; import { first } from "rxjs/operators"; import { Component } from "../Component"; @@ -6,6 +7,7 @@ import { ReorientationConfiguration } from "../interfaces/ReorientationConfiguration"; import { bearingToBasicX, + DEFAULT_REORIENTATION_CONFIGURATION, ReorientationEngine, ReorientationImage, ReorientationProvider, @@ -22,6 +24,15 @@ import { Navigator } from "../../viewer/Navigator"; // degrees (on either axis) — small moves are just jitter. const MIN_REORIENT_DEG = 15; +/** + * The heading an image ended up being shown at, once reorientation has decided + * whether to turn it. Emitted on {@link ReorientationComponent.settled$}. + */ +export interface ReorientationSettledEvent { + id: string; + bearing: number; +} + /** * @class ReorientationComponent * @@ -31,13 +42,11 @@ const MIN_REORIENT_DEG = 15; * user drags to look around, that manual offset is preserved across the rest * of the sequence rather than re-facing forward on every step. * - * Crossing into a new sequence via a spatial move honors the move's intent - * rather than snapping to travel: a Step* (which by definition keeps the - * viewing direction) carries the incoming view unchanged, and a Turn* rotates - * the base to the new sequence's travel while preserving the look-around offset - * (so the turn happens relative to where the user was looking). Every other way - * into a new sequence — Next/Prev, map click, shared link, fresh load — resets - * to travel + horizon as before. + * Crossing into a new sequence with a direction arrow keeps the carried view + * rather than snapping to travel — the arrow's own transition already matched + * the angle — and adopts it as the new sequence's look-around offset. Every + * other way into a new sequence — Next/Prev, map click, shared link, fresh + * load — resets to travel + horizon. * * Active by default; disable with `component: { reorientation: false }`. * @@ -63,6 +72,9 @@ export class ReorientationComponent // load or a deliberate jump to another capture) can be reoriented even when // the landing image's own GPS speed reads as stationary. private _lastSeq: string; + private _reorientOnSpatialNav: boolean; + private _adoptedView: number[]; + private _settled$ = new Subject(); // Manual horizontal look-around offset, preserved within a sequence so the // engine doesn't yank the view back to the travel direction on every step. @@ -85,11 +97,18 @@ export class ReorientationComponent subs.push(this._configuration$.subscribe( (configuration: ReorientationConfiguration): void => { + this._reorientOnSpatialNav = + configuration.reorientOnSpatialNav ?? + DEFAULT_REORIENTATION_CONFIGURATION.reorientOnSpatialNav; this._engine = new ReorientationEngine( this._createProvider(), configuration); this._activeId = null; this._computedBasicX = 0.5; this._lastSeq = null; + // NOT _adoptedView: it is host intent that can be handed over + // before this fires (activation and configure() both re-run + // this), and clearing it here silently drops the view the host + // asked to keep. this._resetOffset(); })); @@ -138,6 +157,19 @@ export class ReorientationComponent * reoriented pano, end of sequence, or not yet computed — so callers can * fall back to the image's own compass angle. */ + /** + * The heading each image settles on, emitted once this component has + * decided whether to turn it. + * + * A host drawing its own indicator cannot infer this: the decision is + * asynchronous (it waits on the engine), and when the view is carried + * across — any direction arrow, or a turn below the reorientation + * threshold — no camera moves, so no bearing event is produced either. + */ + public get settled$(): Observable { + return this._settled$; + } + public getReorientedBearing(id: string): number | null { const engine = this._engine; if (!engine) { @@ -156,6 +188,50 @@ export class ReorientationComponent return ((result.travel + offset) % 360 + 360) % 360; } + /** + * Treat the given basic coordinates as the user's look-around offset rather + * than reorienting away from them, and land the current image on them. + * + * For a view the host already knows about but this component never observed + * — a shared link carrying explicit basic coordinates, say — the offset + * would otherwise be discarded and the next navigation would snap to the + * travel direction. Coordinates are passed in rather than read from the + * viewer so the call does not race the host applying them. + */ + public adoptView(basic: number[]): void { + this._adoptedView = basic != null && basic.length === 2 ? + [basic[0], basic[1]] : null; + } + + /** + * Whether navigating to the given image with a direction arrow would + * reorient it, rather than keeping the view the arrow carries across. + * + * Lets a host predict the heading the user is about to land on: the + * {@link getReorientedBearing} value when this is true, the viewer's + * current bearing when it is false. Only an arrow onto the immediate + * neighbour within the current sequence reorients — a sideways hop or a + * sequence crossing keeps the carried view. + */ + public reorientsOnSpatialNavTo(id: string): boolean { + const engine = this._engine; + if (!this._reorientOnSpatialNav || + engine == null || + this._activeId == null) { + return false; + } + const from = engine.get(this._activeId); + const to = engine.get(id); + if (from == null || !from.valid || to == null || !to.valid) { + return false; + } + if (to.seq == null || to.seq !== this._lastSeq) { + return false; + } + + return from.nextId === id || from.prevId === id; + } + private _reorient( image: Image, direction: NavigationDirection, @@ -179,6 +255,12 @@ export class ReorientationComponent image.mesh.vertices.length : -1; const hardCut = meshV <= 0; if (!result || !result.valid) { + // An adopted view belongs to the navigation that supplied + // it. A landing image that cannot be reoriented never + // consumes it, and leaving it set would frame whichever + // pano resolves next with a stale look-around offset. + this._adoptedView = null; + return; } this._computedBasicX = result.basicX; @@ -191,16 +273,30 @@ export class ReorientationComponent result.seq != null && result.seq !== this._lastSeq; this._lastSeq = result.seq; - // How we crossed into the new sequence decides what to land on. - // Step* means "keep the viewing direction", so carry the incoming - // view unchanged (offset seeded from it below, no rotation). - // Turn* is a deliberate rotation, so re-base to the new - // sequence's travel but keep the look-around offset — the turn - // happens relative to the current view. Everything else (Next/ - // Prev, map click, shared link, fresh load) resets to travel. - const carryView = freshSequence && this._isStep(direction); - const turnView = freshSequence && this._isTurn(direction); - const resetView = freshSequence && !carryView && !turnView; + // An arrow move already lands at the heading the user was + // looking at, because the state layer carries the view across + // the image change. Crossing into a new sequence that way keeps + // that view and adopts it as this sequence's look-around offset, + // so nothing rotates here and the following in-sequence steps + // preserve the framing the user arrived with. Everything else + // (Next/Prev, map click, shared link, fresh load) resets to + // travel. + const spatialNav = + this._isStep(direction) || this._isTurn(direction); + const leftAnother = fromId != null && fromId !== id; + const fromResult = leftAnother ? engine.get(fromId) : null; + const neighbor = fromResult != null && + (fromResult.nextId === id || fromResult.prevId === id); + // An arrow that lands somewhere other than the image next to + // the one we left is a sideways hop, not a step along the road: + // a parallel pass, or the return leg of a capture that doubles + // back, whose travel direction can be the reverse of ours. + // Facing its travel would swing the user around, so treat it + // like a sequence crossing and keep the carried view. + const lateralHop = + spatialNav && leftAnother && fromResult != null && !neighbor; + const carryView = spatialNav && (freshSequence || lateralHop); + const resetView = freshSequence && !carryView; if (freshSequence) { // Drop look-ahead hints from the prior sequence so nothing @@ -208,6 +304,16 @@ export class ReorientationComponent this._navigator.stateService.clearReorientations(); } + // Within a sequence an arrow step is where reorientation earns + // its keep: the carried view drifts off-axis as the road bends. + // Opt out to compare against plain carried-view navigation. + if (spatialNav && !freshSequence && + !this._reorientOnSpatialNav) { + this._adoptedView = null; + + return; + } + // Pitch is tracked deterministically via the offset (the live // view y drifts ~18° through the spherical projection, so it // can't be measured per image). On a reset the held pitch resets @@ -230,10 +336,7 @@ export class ReorientationComponent // the one we left is a jump (map click, URL/pKey change), not a // step: the incoming view says nothing about the new position, // so there is nothing worth preserving. - const leftAnother = fromId != null && fromId !== id; - const fromResult = leftAnother ? engine.get(fromId) : null; - const jump = direction == null && leftAnother && - fromResult?.nextId !== id && fromResult?.prevId !== id; + const jump = direction == null && leftAnother && !neighbor; if (!result.moving && !freshSequence && !jump) { // Low motion between adjacent images — preserve the view @@ -250,18 +353,38 @@ export class ReorientationComponent if (this._activeId !== id || this._engine !== engine) { return; } + // Adopt the host-supplied view as the offset before any + // of the reset/carry decisions above take effect, so a + // shared link's framing becomes the look-around offset + // this sequence preserves. Read the view as those coords + // too, not as whatever the viewer shows right now: the + // host may not have applied them yet, and steering there + // ourselves would animate a rotation the user did not + // ask for. Equal to targetX, so this image never moves. + let viewX = center[0]; + if (this._adoptedView != null) { + const adopted = this._adoptedView; + this._adoptedView = null; + viewX = adopted[0]; + this._userOffsetX = + wrapDelta(adopted[0] - result.basicX); + this._userOffsetY = adopted[1] - 0.5; + this._ySeeded = true; + } // Seed the held pitch offset once from the loaded view. if (!this._ySeeded) { this._userOffsetY = center[1] - 0.5; this._ySeeded = true; } - // Step* into a new sequence: keep the view the user - // carried in. Seed this sequence's look-around offset - // from that carried view so in-sequence steps preserve - // it; targetX then equals center[0], so nothing rotates. + // Arrow into a new sequence or a sideways hop: keep the + // view the user carried in. Seed the look-around offset + // from that view so following steps preserve it; + // targetX then equals viewX, so nothing rotates. Reads + // viewX rather than center so it agrees with an adopted + // view instead of overwriting the offset just set. if (carryView) { this._userOffsetX = - wrapDelta(center[0] - result.basicX); + wrapDelta(viewX - result.basicX); } const targetY = Math.max(0, Math.min(1, 0.5 + this._userOffsetY)); @@ -273,7 +396,7 @@ export class ReorientationComponent // trip noise (~0.1 ≈ 18°). Gating on it would just snap // back projection drift — the very jitter we're avoiding. const dxDeg = - Math.abs(wrapDelta(targetX - center[0])) * 360; + Math.abs(wrapDelta(targetX - viewX)) * 360; // Reorient if the horizontal move clears the threshold, // or (on a sequence change) the pitch must reset by more // than the threshold to clear a carried look up/down. @@ -300,8 +423,15 @@ export class ReorientationComponent // absolute bearings: the view a neighbor carries in is // where this image ends up. if (typeof result.cca === "number") { - const endX = move ? targetX : center[0]; + const endX = move ? targetX : viewX; const endBearing = result.cca + (endX - 0.5) * 360; + // Carrying the view moves no camera, so a host + // watching bearing events would never learn where + // this image ended up. Tell it outright. + this._settled$.next({ + id, + bearing: ((endBearing % 360) + 360) % 360, + }); if (result.nextId) { this._hintNeighbor( engine, result.nextId, endBearing, targetY); diff --git a/src/component/reorientation/ReorientationEngine.ts b/src/component/reorientation/ReorientationEngine.ts index bd1da587a..d3984f97d 100644 --- a/src/component/reorientation/ReorientationEngine.ts +++ b/src/component/reorientation/ReorientationEngine.ts @@ -44,9 +44,10 @@ export interface ReorientationResult { export const DEFAULT_REORIENTATION_CONFIGURATION: Required = { + reorientOnSpatialNav: true, prefetchAhead: 3, movingSpeedMps: 1, - lowSpeedTurnDistanceM: 2, + lowSpeedTurnDistanceM: 0.5, lowSpeedTurnMaxDeltaDeg: 30, outlierMaxDeltaDeg: 90, previousContextWindow: 5, @@ -294,9 +295,20 @@ export class ReorientationEngine { } } + // The outlier test exists to stop a single noisy fix from steering + // the view, and noise only reaches it through the low-speed branch + // above — two consecutive segments both at sustained speed are real + // displacement, so a large bearing change between them is a corner, + // not jitter. Rejecting those turned the sharpest corners (and, via + // the history window below, the whole stretch after them) into + // "not moving", which is exactly where reorientation is wanted. + const sustained = speed >= cfg.movingSpeedMps && + prev != null && prev.speed >= cfg.movingSpeedMps; + if (moving) { if (prev && prev.moving) { - if (angleDelta(tb, prev.travel) > cfg.outlierMaxDeltaDeg) { + if (!sustained && + angleDelta(tb, prev.travel) > cfg.outlierMaxDeltaDeg) { moving = false; } } else { From c8b1e6b381d3c455a9065cd75331c4252d4f5597 Mon Sep 17 00:00:00 2001 From: Boris Masis Date: Wed, 2 Sep 2026 12:35:45 -0700 Subject: [PATCH 04/59] Add tooltips to sequence controls --- src/component/sequence/SequenceDOMRenderer.ts | 167 ++++++++++-------- src/component/util/Tooltip.ts | 35 ++++ src/component/zoom/ZoomComponent.ts | 17 +- styles/sequence.css | 5 + styles/tooltip.css | 65 +++++++ 5 files changed, 212 insertions(+), 77 deletions(-) create mode 100644 src/component/util/Tooltip.ts create mode 100644 styles/tooltip.css diff --git a/src/component/sequence/SequenceDOMRenderer.ts b/src/component/sequence/SequenceDOMRenderer.ts index 26508fddf..9edb52c2a 100644 --- a/src/component/sequence/SequenceDOMRenderer.ts +++ b/src/component/sequence/SequenceDOMRenderer.ts @@ -15,6 +15,7 @@ import { ViewportSize } from "../../render/interfaces/ViewportSize"; import { Container } from "../../viewer/Container"; import { Navigator } from "../../viewer/Navigator"; import { SequenceConfiguration } from "../interfaces/SequenceConfiguration"; +import { createTooltipProperties } from "../util/Tooltip"; import { SequenceMode } from "./SequenceMode"; import { SequenceComponent } from "./SequenceComponent"; @@ -305,19 +306,27 @@ export class SequenceDOMRenderer { NavigationDirection.Prev : NavigationDirection.Next; const playing: boolean = configuration.playing; - const switchButtonProperties: vd.createProperties = { - onclick: (): void => { - if (!playing) { - component.configure({ direction }); - } - }, - }; + const switchButtonProperties: vd.createProperties = createTooltipProperties( + "Reverse direction", + { + onclick: (): void => { + if (!playing) { + component.configure({ direction }); + } + }, + }); const switchButtonClassName: string = configuration.playing ? ".mapillary-sequence-switch-button-inactive" : ".mapillary-sequence-switch-button"; const switchButton: vd.VNode = vd.h("div" + switchButtonClassName, switchButtonProperties, [switchIcon]); const slowIcon: vd.VNode = vd.h("div.mapillary-sequence-slow-icon.mapillary-sequence-icon-visible", []); - const slowContainer: vd.VNode = vd.h("div.mapillary-sequence-slow-container", [slowIcon]); + const slowContainer: vd.VNode = vd.h( + "div.mapillary-sequence-slow-container", + createTooltipProperties("Slower"), + [slowIcon]); const fastIcon: vd.VNode = vd.h("div.mapillary-sequence-fast-icon.mapillary-sequence-icon-visible", []); - const fastContainer: vd.VNode = vd.h("div.mapillary-sequence-fast-container", [fastIcon]); + const fastContainer: vd.VNode = vd.h( + "div.mapillary-sequence-fast-container", + createTooltipProperties("Faster"), + [fastIcon]); const closeIcon: vd.VNode = vd.h("div.mapillary-sequence-close-icon.mapillary-sequence-icon-visible", []); const closeButtonProperties: vd.createProperties = { onclick: (): void => { @@ -352,7 +361,9 @@ export class SequenceDOMRenderer { (): void => { component.stop(); } : canPlay ? (): void => { component.play(); } : null; - let buttonProperties: vd.createProperties = { onclick: onclick }; + let buttonProperties: vd.createProperties = createTooltipProperties( + configuration.playing ? "Stop" : "Play", + { onclick }); let iconProperties: vd.createProperties = {}; if (configuration.direction === NavigationDirection.Prev) { @@ -374,45 +385,51 @@ export class SequenceDOMRenderer { private _createSequenceControls(containerWidth: number): vd.VNode { const borderRadius: number = Math.round(8 / this._stepperDefaultWidth * containerWidth); - const expanderProperties: vd.createProperties = { - onclick: (): void => { - this._expandControls = !this._expandControls; - this._mode = SequenceMode.Default; - this._notifyChanged$.next(this); - }, - style: { - "border-bottom-right-radius": `${borderRadius}px`, - "border-top-right-radius": `${borderRadius}px`, - }, - }; + const expanderProperties: vd.createProperties = createTooltipProperties( + "Options", + { + onclick: (): void => { + this._expandControls = !this._expandControls; + this._mode = SequenceMode.Default; + this._notifyChanged$.next(this); + }, + style: { + "border-bottom-right-radius": `${borderRadius}px`, + "border-top-right-radius": `${borderRadius}px`, + }, + }); const expanderBar: vd.VNode = vd.h("div.mapillary-sequence-expander-bar", []); const expander: vd.VNode = vd.h("div.mapillary-sequence-expander-button", expanderProperties, [expanderBar]); const fastIconClassName: string = this._mode === SequenceMode.Playback ? ".mapillary-sequence-fast-icon-gray.mapillary-sequence-icon-visible" : ".mapillary-sequence-fast-icon"; const fastIcon: vd.VNode = vd.h("div" + fastIconClassName, []); - const playbackProperties: vd.createProperties = { - onclick: (): void => { - this._mode = this._mode === SequenceMode.Playback ? - SequenceMode.Default : - SequenceMode.Playback; - this._notifyChanged$.next(this); - }, - }; + const playbackProperties: vd.createProperties = createTooltipProperties( + "Playback speed", + { + onclick: (): void => { + this._mode = this._mode === SequenceMode.Playback ? + SequenceMode.Default : + SequenceMode.Playback; + this._notifyChanged$.next(this); + }, + }); const playback: vd.VNode = vd.h("div.mapillary-sequence-playback-button", playbackProperties, [fastIcon]); const timelineIconClassName: string = this._mode === SequenceMode.Timeline ? ".mapillary-sequence-timeline-icon-gray.mapillary-sequence-icon-visible" : ".mapillary-sequence-timeline-icon"; const timelineIcon: vd.VNode = vd.h("div" + timelineIconClassName, []); - const timelineProperties: vd.createProperties = { - onclick: (): void => { - this._mode = this._mode === SequenceMode.Timeline ? - SequenceMode.Default : - SequenceMode.Timeline; - this._notifyChanged$.next(this); - }, - }; + const timelineProperties: vd.createProperties = createTooltipProperties( + "Navigate capture", + { + onclick: (): void => { + this._mode = this._mode === SequenceMode.Timeline ? + SequenceMode.Default : + SequenceMode.Timeline; + this._notifyChanged$.next(this); + }, + }); const timeline: vd.VNode = vd.h("div.mapillary-sequence-timeline-button", timelineProperties, [timelineIcon]); @@ -437,44 +454,48 @@ export class SequenceDOMRenderer { configuration: SequenceConfiguration, navigator: Navigator): vd.VNode[] { - let nextProperties: vd.createProperties = { - onclick: nextId != null ? - (): void => { - navigator.moveDir$(NavigationDirection.Next) - .subscribe( - undefined, - (error: Error): void => { - if (!(error instanceof CancelMapillaryError)) { - console.error(error); - } - }); - } : - null, - onpointerenter: (): void => { this._mouseEnterDirection$.next(NavigationDirection.Next); }, - onpointerleave: (): void => { this._mouseLeaveDirection$.next(NavigationDirection.Next); }, - }; + let nextProperties: vd.createProperties = createTooltipProperties( + "Next", + { + onclick: nextId != null ? + (): void => { + navigator.moveDir$(NavigationDirection.Next) + .subscribe( + undefined, + (error: Error): void => { + if (!(error instanceof CancelMapillaryError)) { + console.error(error); + } + }); + } : + null, + onpointerenter: (): void => { this._mouseEnterDirection$.next(NavigationDirection.Next); }, + onpointerleave: (): void => { this._mouseLeaveDirection$.next(NavigationDirection.Next); }, + }); const borderRadius: number = Math.round(8 / this._stepperDefaultWidth * containerWidth); - let prevProperties: vd.createProperties = { - onclick: prevId != null ? - (): void => { - navigator.moveDir$(NavigationDirection.Prev) - .subscribe( - undefined, - (error: Error): void => { - if (!(error instanceof CancelMapillaryError)) { - console.error(error); - } - }); - } : - null, - onpointerenter: (): void => { this._mouseEnterDirection$.next(NavigationDirection.Prev); }, - onpointerleave: (): void => { this._mouseLeaveDirection$.next(NavigationDirection.Prev); }, - style: { - "border-bottom-left-radius": `${borderRadius}px`, - "border-top-left-radius": `${borderRadius}px`, - }, - }; + let prevProperties: vd.createProperties = createTooltipProperties( + "Previous", + { + onclick: prevId != null ? + (): void => { + navigator.moveDir$(NavigationDirection.Prev) + .subscribe( + undefined, + (error: Error): void => { + if (!(error instanceof CancelMapillaryError)) { + console.error(error); + } + }); + } : + null, + onpointerenter: (): void => { this._mouseEnterDirection$.next(NavigationDirection.Prev); }, + onpointerleave: (): void => { this._mouseLeaveDirection$.next(NavigationDirection.Prev); }, + style: { + "border-bottom-left-radius": `${borderRadius}px`, + "border-top-left-radius": `${borderRadius}px`, + }, + }); let nextClass: string = this._getStepClassName(NavigationDirection.Next, nextId, configuration.highlightId); let prevClass: string = this._getStepClassName(NavigationDirection.Prev, prevId, configuration.highlightId); diff --git a/src/component/util/Tooltip.ts b/src/component/util/Tooltip.ts new file mode 100644 index 000000000..0ce33b827 --- /dev/null +++ b/src/component/util/Tooltip.ts @@ -0,0 +1,35 @@ +import * as vd from "virtual-dom"; + +export function createTooltipProperties( + text: string, + properties: vd.createProperties = {}, + position?: "left"): vd.createProperties { + + const onPointerDown: (event: PointerEvent) => void = properties.onpointerdown; + const onPointerLeave: (event: PointerEvent) => void = properties.onpointerleave; + const attributes: { [key: string]: string } = { + ...properties.attributes, + "data-mapillary-tooltip": text, + }; + + if (position) { + attributes["data-mapillary-tooltip-position"] = position; + } + + return { + ...properties, + attributes, + onpointerdown: (event: PointerEvent): void => { + (event.currentTarget as HTMLElement).setAttribute("data-mapillary-tooltip-dismissed", ""); + if (onPointerDown) { + onPointerDown(event); + } + }, + onpointerleave: (event: PointerEvent): void => { + (event.currentTarget as HTMLElement).removeAttribute("data-mapillary-tooltip-dismissed"); + if (onPointerLeave) { + onPointerLeave(event); + } + }, + }; +} diff --git a/src/component/zoom/ZoomComponent.ts b/src/component/zoom/ZoomComponent.ts index e875c775d..5c9fe80ca 100644 --- a/src/component/zoom/ZoomComponent.ts +++ b/src/component/zoom/ZoomComponent.ts @@ -13,6 +13,7 @@ import { import { Component } from "../Component"; import { ZoomConfiguration } from "../interfaces/ZoomConfiguration"; +import { createTooltipProperties } from "../util/Tooltip"; import { Transform } from "../../geo/Transform"; import { ViewportCoords } from "../../geo/ViewportCoords"; @@ -67,14 +68,22 @@ export class ZoomComponent extends Component { const zoom: number = frame.state.zoom; const zoomInIcon: vd.VNode = vd.h("div.mapillary-zoom-in-icon", []); + const zoomInProperties: vd.createProperties = createTooltipProperties("Zoom in", {}, "left"); const zoomInButton: vd.VNode = zoom >= 3 || state === State.Waiting ? - vd.h("div.mapillary-zoom-in-button-inactive", [zoomInIcon]) : - vd.h("div.mapillary-zoom-in-button", { onclick: (): void => { this._zoomDelta$.next(1); } }, [zoomInIcon]); + vd.h("div.mapillary-zoom-in-button-inactive", zoomInProperties, [zoomInIcon]) : + vd.h( + "div.mapillary-zoom-in-button", + { ...zoomInProperties, onclick: (): void => { this._zoomDelta$.next(1); } }, + [zoomInIcon]); const zoomOutIcon: vd.VNode = vd.h("div.mapillary-zoom-out-icon", []); + const zoomOutProperties: vd.createProperties = createTooltipProperties("Zoom out", {}, "left"); const zoomOutButton: vd.VNode = zoom <= 0 || state === State.Waiting ? - vd.h("div.mapillary-zoom-out-button-inactive", [zoomOutIcon]) : - vd.h("div.mapillary-zoom-out-button", { onclick: (): void => { this._zoomDelta$.next(-1); } }, [zoomOutIcon]); + vd.h("div.mapillary-zoom-out-button-inactive", zoomOutProperties, [zoomOutIcon]) : + vd.h( + "div.mapillary-zoom-out-button", + { ...zoomOutProperties, onclick: (): void => { this._zoomDelta$.next(-1); } }, + [zoomOutIcon]); const compact: string = configuration.size === ComponentSize.Small || configuration.size === ComponentSize.Automatic && size.width < 640 ? diff --git a/styles/sequence.css b/styles/sequence.css index c6028bbe9..cbb1dd112 100644 --- a/styles/sequence.css +++ b/styles/sequence.css @@ -16,6 +16,11 @@ .mapillary-sequence-stepper { transform: translate(-50%, 0); + z-index: 1; +} + +.mapillary-sequence-controls { + z-index: 1; } .mapillary-sequence-expander-button, diff --git a/styles/tooltip.css b/styles/tooltip.css new file mode 100644 index 000000000..131eb3ca9 --- /dev/null +++ b/styles/tooltip.css @@ -0,0 +1,65 @@ +.mapillary-viewer [data-mapillary-tooltip] { + position: relative; +} + +.mapillary-viewer [data-mapillary-tooltip]::before, +.mapillary-viewer [data-mapillary-tooltip]::after { + opacity: 0; + pointer-events: none; + position: absolute; + transition: opacity 0.2s ease-in-out; + visibility: hidden; +} + +.mapillary-viewer [data-mapillary-tooltip]::before { + background-color: #212b36; + border-radius: 4px; + box-shadow: 0 4px 8px 3px rgba(0, 0, 0, 0.15), 0 1px 3px 0 rgba(0, 0, 0, 0.3); + color: white; + content: attr(data-mapillary-tooltip); + font-family: "Source Sans Pro", sans-serif; + font-size: 12px; + font-weight: 400; + left: 50%; + line-height: 18px; + max-width: 240px; + padding: 4px 8px; + text-align: center; + top: calc(100% + 8px); + transform: translateX(-50%); + white-space: nowrap; + z-index: 1001; +} + +.mapillary-viewer [data-mapillary-tooltip]::after { + background-color: #212b36; + border-radius: 2px; + content: ""; + height: 8px; + left: calc(50% - 4px); + top: calc(100% + 4px); + transform: rotate(45deg); + width: 8px; + z-index: 1000; +} + +.mapillary-viewer [data-mapillary-tooltip-position="left"]::before { + left: auto; + right: calc(100% + 8px); + top: 50%; + transform: translateY(-50%); +} + +.mapillary-viewer [data-mapillary-tooltip-position="left"]::after { + left: auto; + right: calc(100% + 4px); + top: calc(50% - 4px); +} + +@media (hover: hover) { + .mapillary-viewer [data-mapillary-tooltip]:hover:not([data-mapillary-tooltip-dismissed])::before, + .mapillary-viewer [data-mapillary-tooltip]:hover:not([data-mapillary-tooltip-dismissed])::after { + opacity: 1; + visibility: visible; + } +} From a124e0209ceddf960f4b19066681ade1eea1ff55 Mon Sep 17 00:00:00 2001 From: Boris Masis Date: Wed, 2 Sep 2026 16:18:23 -0700 Subject: [PATCH 05/59] Reorientation: reject implausible computed tracks --- .../reorientation/ReorientationComponent.ts | 3 + .../reorientation/ReorientationEngine.ts | 90 +++++++++++++++---- .../reorientation/ReorientationEngine.test.ts | 31 ++++++- 3 files changed, 103 insertions(+), 21 deletions(-) diff --git a/src/component/reorientation/ReorientationComponent.ts b/src/component/reorientation/ReorientationComponent.ts index aca4d9af2..12daf9a2b 100644 --- a/src/component/reorientation/ReorientationComponent.ts +++ b/src/component/reorientation/ReorientationComponent.ts @@ -524,10 +524,13 @@ export class ReorientationComponent private _seed(image: Image): ReorientationImage { const lngLat = image.lngLat; + const originalLngLat = image.originalLngLat; return { id: image.id, lat: lngLat ? lngLat.lat : null, lng: lngLat ? lngLat.lng : null, + originalLat: originalLngLat ? originalLngLat.lat : null, + originalLng: originalLngLat ? originalLngLat.lng : null, cca: image.computedCompassAngle, cam: image.cameraType, seq: image.sequenceId, diff --git a/src/component/reorientation/ReorientationEngine.ts b/src/component/reorientation/ReorientationEngine.ts index d3984f97d..fd6890956 100644 --- a/src/component/reorientation/ReorientationEngine.ts +++ b/src/component/reorientation/ReorientationEngine.ts @@ -9,6 +9,8 @@ export interface ReorientationImage { id: string; lat: number; lng: number; + originalLat?: number; + originalLng?: number; cca: number; cam: string; seq: string; @@ -58,6 +60,7 @@ export const DEFAULT_REORIENTATION_CONFIGURATION: const RAD = Math.PI / 180; const DEG = 180 / Math.PI; const EARTH_RADIUS_METERS = 6371000; +const MAX_REASONABLE_SPEED_MPS = 100; function isNum(v: number): boolean { return typeof v === "number" && Number.isFinite(v); @@ -114,6 +117,13 @@ interface PrevContext { travel: number; } +interface Segment { + dist: number; + speed: number; + speedExcessive: boolean; + travel: number; +} + /** * Resolves, for an image in a sequence, the basic-x that frames the * direction of travel toward the next image. Motion is detected from GPS @@ -130,6 +140,7 @@ export class ReorientationEngine { private _config: Required; private _cache: Map; private _pending: Map>; + private _originalGeometrySequences: Set; constructor( provider: ReorientationProvider, @@ -141,6 +152,7 @@ export class ReorientationEngine { }; this._cache = new Map(); this._pending = new Map>(); + this._originalGeometrySequences = new Set(); } public get(id: string): ReorientationResult | null { @@ -225,10 +237,10 @@ export class ReorientationEngine { nextId: string, depth: number): Promise { const cfg = this._config; - const dist = haversineDist(cur.lat, cur.lng, nxt.lat, nxt.lng); - let tb = bearing(cur.lat, cur.lng, nxt.lat, nxt.lng); - const dt = (nxt.ts && cur.ts) ? (nxt.ts - cur.ts) / 1000 : 0; - const speed = dt > 0 ? dist / dt : 0; + const segment = this._segment(cur, nxt); + const dist = segment.dist; + let tb = segment.travel; + const speed = segment.speed; let moving = false; let prevCtx: PrevContext = null; @@ -253,33 +265,36 @@ export class ReorientationEngine { if (!hasPosition(prev)) { return null; } - const pDist = - haversineDist(prev.lat, prev.lng, cur.lat, cur.lng); - const pTravel = - bearing(prev.lat, prev.lng, cur.lat, cur.lng); - const pDt = (cur.ts && prev.ts) ? - (cur.ts - prev.ts) / 1000 : 0; - const pSpeed = pDt > 0 ? pDist / pDt : 0; - let pMoving = pSpeed >= cfg.movingSpeedMps; + const previousSegment = this._segment(prev, cur); + let pMoving = previousSegment.speedExcessive ? + previousSegment.dist > cfg.lowSpeedTurnDistanceM : + previousSegment.speed >= cfg.movingSpeedMps; if (!pMoving && isNum(prev.cca)) { - const pDelta = angleDelta(pTravel, prev.cca); + const pDelta = + angleDelta(previousSegment.travel, prev.cca); if (pDelta < cfg.lowSpeedTurnMaxDeltaDeg && - pDist > cfg.lowSpeedTurnDistanceM) { + previousSegment.dist > + cfg.lowSpeedTurnDistanceM) { pMoving = true; } } return { valid: true, moving: pMoving, - speed: pSpeed, - travel: pTravel, + speed: previousSegment.speed, + travel: previousSegment.travel, }; }) .catch(() => null); } return prevPromise.then((prev) => { - if (speed >= cfg.movingSpeedMps) { + if (segment.speedExcessive) { + // Video frame timestamps can imply impossible speeds. There is + // still real displacement, but it must pass the bearing-outlier + // checks below rather than receiving the sustained-speed bypass. + moving = dist > cfg.lowSpeedTurnDistanceM; + } else if (speed >= cfg.movingSpeedMps) { // On direct jumps there is no prior segment, so only the // first image in a sequence may assume sustained motion. moving = prev ? @@ -302,8 +317,11 @@ export class ReorientationEngine { // not jitter. Rejecting those turned the sharpest corners (and, via // the history window below, the whole stretch after them) into // "not moving", which is exactly where reorientation is wanted. - const sustained = speed >= cfg.movingSpeedMps && - prev != null && prev.speed >= cfg.movingSpeedMps; + const sustained = !segment.speedExcessive && + speed >= cfg.movingSpeedMps && + prev != null && + prev.speed >= cfg.movingSpeedMps && + prev.speed <= MAX_REASONABLE_SPEED_MPS; if (moving) { if (prev && prev.moving) { @@ -375,6 +393,40 @@ export class ReorientationEngine { }); } + private _segment(cur: ReorientationImage, nxt: ReorientationImage): Segment { + const dt = (nxt.ts && cur.ts) ? (nxt.ts - cur.ts) / 1000 : 0; + let dist = haversineDist(cur.lat, cur.lng, nxt.lat, nxt.lng); + let travel = bearing(cur.lat, cur.lng, nxt.lat, nxt.lng); + let speed = dt > 0 ? dist / dt : 0; + + // SfM geometry can occasionally be displaced or scrambled while the + // original capture track is coherent. An impossible computed speed is + // the signal to use that original track for the whole sequence instead + // of switching sources again when one noisy segment happens to be short. + const hasOriginal = + isNum(cur.originalLat) && isNum(cur.originalLng) && + isNum(nxt.originalLat) && isNum(nxt.originalLng); + if (hasOriginal && speed > MAX_REASONABLE_SPEED_MPS) { + this._originalGeometrySequences.add(cur.seq); + } + if (hasOriginal && this._originalGeometrySequences.has(cur.seq)) { + dist = haversineDist( + cur.originalLat, cur.originalLng, + nxt.originalLat, nxt.originalLng); + travel = bearing( + cur.originalLat, cur.originalLng, + nxt.originalLat, nxt.originalLng); + speed = dt > 0 ? dist / dt : 0; + } + + return { + dist, + speed, + speedExcessive: speed > MAX_REASONABLE_SPEED_MPS, + travel, + }; + } + private _prefetchNext(d: ReorientationResult, depth: number): void { if (depth > 0 && d && d.valid && d.nextId) { this.precompute(d.nextId, null, depth - 1) diff --git a/test/component/reorientation/ReorientationEngine.test.ts b/test/component/reorientation/ReorientationEngine.test.ts index 0b0fd4901..83e417a9e 100644 --- a/test/component/reorientation/ReorientationEngine.test.ts +++ b/test/component/reorientation/ReorientationEngine.test.ts @@ -65,8 +65,20 @@ function spherical( lat: number, lng: number, cca: number, - ts: number): ReorientationImage { - return { id, lat, lng, cca, cam: "spherical", seq: "s", ts }; + ts: number, + originalLat?: number, + originalLng?: number): ReorientationImage { + return { + id, + lat, + lng, + originalLat, + originalLng, + cca, + cam: "spherical", + seq: "s", + ts, + }; } describe("ReorientationEngine.precompute", () => { @@ -90,6 +102,21 @@ describe("ReorientationEngine.precompute", () => { expect(result.basicX).toBeCloseTo(0.75, 2); }); + it("uses the original track when computed speed is impossible", async () => { + const images: Fixture = { + a: spherical("a", 0, 0, 90, 1000, 0, 0), + b: spherical("b", 0.001, 0, 90, 1040, 0, 0.00006), + c: spherical("c", 0.00101, 0, 90, 1080, 0, 0.00012), + }; + const engine = new ReorientationEngine(provider(images, ["a", "b", "c"])); + + await engine.precompute("a"); + await engine.precompute("b"); + + expect(engine.get("a").travel).toBeCloseTo(90, 1); + expect(engine.get("b").travel).toBeCloseTo(90, 1); + }); + it("accepts a low-speed step as a turn when compass agrees", async () => { // ~11 m apart but 100 s apart -> ~0.11 m/s (below movingSpeedMps), // yet compass (90) agrees with eastward travel, so it counts. From a9f0f17ec91ee92389adf73b332f148d34cb4bd1 Mon Sep 17 00:00:00 2001 From: Boris Masis Date: Wed, 2 Sep 2026 17:17:02 -0700 Subject: [PATCH 06/59] Remove viewer canvas focus outline --- styles/mapillary.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/styles/mapillary.css b/styles/mapillary.css index 979f4d9e3..0b77fc165 100644 --- a/styles/mapillary.css +++ b/styles/mapillary.css @@ -5,6 +5,10 @@ -webkit-tap-highlight-color: rgba(0,0,0,0); } +.mapillary-canvas:focus { + outline: none; +} + .mapillary-interactive { cursor: grab; } From 8de856a3b9c5a01e97faed9d016c01ac03863aaa Mon Sep 17 00:00:00 2001 From: Boris Masis Date: Wed, 2 Sep 2026 17:34:49 -0700 Subject: [PATCH 07/59] Show sequence arrows before spatial graph loads --- src/component/direction/DirectionComponent.ts | 68 +++++++-- .../direction/DirectionDOMRenderer.ts | 12 +- src/graph/Graph.ts | 129 ++++++++++++------ src/graph/GraphService.ts | 56 ++++++++ .../direction/DirectionComponent.test.ts | 44 ++++++ 5 files changed, 256 insertions(+), 53 deletions(-) diff --git a/src/component/direction/DirectionComponent.ts b/src/component/direction/DirectionComponent.ts index b490397fa..14b976f9f 100644 --- a/src/component/direction/DirectionComponent.ts +++ b/src/component/direction/DirectionComponent.ts @@ -1,6 +1,7 @@ import * as vd from "virtual-dom"; import { combineLatest as observableCombineLatest, + concat as observableConcat, of as observableOf, Observable, Subject, @@ -13,6 +14,7 @@ import { share, startWith, switchMap, + takeUntil, tap, withLatestFrom, } from "rxjs/operators"; @@ -151,18 +153,60 @@ export class DirectionComponent extends Component { withLatestFrom(this._configuration$), switchMap( ([image, configuration]: [Image, DirectionConfiguration]): Observable<[NavigationEdgeStatus, Sequence]> => { - return observableCombineLatest( - image.spatialEdges$, - configuration.distinguishSequence ? - this._navigator.graphService - .cacheSequence$(image.sequenceId).pipe( - catchError( - (error: Error): Observable => { - console.error(`Failed to cache sequence (${image.sequenceId})`, error); - - return observableOf(null); - })) : - observableOf(null)); + if (!configuration.distinguishSequence) { + return image.spatialEdges$.pipe( + map( + (edgeStatus: NavigationEdgeStatus): [NavigationEdgeStatus, Sequence] => { + return [edgeStatus, null]; + })); + } + + return this._navigator.graphService + .cacheSequence$(image.sequenceId).pipe( + catchError( + (error: Error): Observable => { + console.error(`Failed to cache sequence (${image.sequenceId})`, error); + + return observableOf(null); + }), + switchMap( + (sequence: Sequence): Observable<[NavigationEdgeStatus, Sequence]> => { + if (sequence == null) { + return image.spatialEdges$.pipe( + map( + (edgeStatus: NavigationEdgeStatus): + [NavigationEdgeStatus, Sequence] => { + return [edgeStatus, null]; + })); + } + + const spatialEdges$ = image.spatialEdges$.pipe( + filter( + (edgeStatus: NavigationEdgeStatus): boolean => { + return edgeStatus.cached; + })); + const sequenceEdges$ = this._navigator.graphService + .cacheSequenceSpatialEdges$(image.id).pipe( + takeUntil(spatialEdges$), + catchError( + (error: Error): Observable => { + console.error( + `Failed to cache sequence spatial edges (${image.id})`, + error); + + return observableOf({ + cached: false, + edges: [], + }); + })); + + return observableConcat(sequenceEdges$, spatialEdges$).pipe( + map( + (edgeStatus: NavigationEdgeStatus): + [NavigationEdgeStatus, Sequence] => { + return [edgeStatus, sequence]; + })); + })); })) .subscribe( ([edgeStatus, sequence]: [NavigationEdgeStatus, Sequence]): void => { diff --git a/src/component/direction/DirectionDOMRenderer.ts b/src/component/direction/DirectionDOMRenderer.ts index ad4309ef1..4be01d650 100644 --- a/src/component/direction/DirectionDOMRenderer.ts +++ b/src/component/direction/DirectionDOMRenderer.ts @@ -32,6 +32,7 @@ export class DirectionDOMRenderer { private _distinguishSequence: boolean; private _needsRender: boolean; + private _edgesCached: boolean; private _stepEdges: NavigationEdge[]; private _turnEdges: NavigationEdge[]; @@ -57,6 +58,7 @@ export class DirectionDOMRenderer { this._distinguishSequence = false; this._needsRender = false; + this._edgesCached = false; this._stepEdges = []; this._turnEdges = []; @@ -200,6 +202,7 @@ export class DirectionDOMRenderer { } private _clearEdges(): void { + this._edgesCached = false; this._stepEdges = []; this._turnEdges = []; this._sphericalEdges = []; @@ -207,6 +210,7 @@ export class DirectionDOMRenderer { } private _setEdges(edgeStatus: NavigationEdgeStatus, sequence: Sequence): void { + this._edgesCached = edgeStatus.cached; this._stepEdges = []; this._turnEdges = []; this._sphericalEdges = []; @@ -410,7 +414,9 @@ export class DirectionDOMRenderer { let onClick: (e: Event) => void = (e: Event): void => { - navigator.moveDir$(direction) + (this._edgesCached ? + navigator.moveDir$(direction) : + navigator.moveTo$(key, direction)) .subscribe( undefined, (error: Error): void => { @@ -438,7 +444,9 @@ export class DirectionDOMRenderer { let onClick: (e: Event) => void = (e: Event): void => { - navigator.moveDir$(direction) + (this._edgesCached ? + navigator.moveDir$(direction) : + navigator.moveTo$(key, direction)) .subscribe( undefined, (error: Error): void => { diff --git a/src/graph/Graph.ts b/src/graph/Graph.ts index 7dfb4a114..4e8d6913f 100644 --- a/src/graph/Graph.ts +++ b/src/graph/Graph.ts @@ -44,6 +44,7 @@ import { ImagesContract } from "../api/contracts/ImagesContract"; import { SequenceContract } from "../api/contracts/SequenceContract"; import { CoreImagesContract } from "../api/contracts/CoreImagesContract"; import { CancelMapillaryError } from "../error/CancelMapillaryError"; +import { geodeticToEnu } from "../geo/GeoCoords"; type NodeTiles = { cache: string[]; @@ -908,6 +909,38 @@ export class Graph { return spatialArea$; } + /** + * Compute spatial edges to the adjacent images in a node's sequence. + * + * @param {string} key - Key of node. + * @returns {Array} Spatial edges to cached adjacent images. + * @throws {GraphMapillaryError} When the node or its sequence is not cached. + */ + public getSequenceSpatialEdges(key: string): NavigationEdge[] { + const node: Image = this.getNode(key); + if (!(node.sequenceId in this._sequences)) { + throw new GraphMapillaryError(`Sequence is not cached (${key}), (${node.sequenceId})`); + } + + const sequence: Sequence = this._sequences[node.sequenceId].sequence; + const prevKey: string = sequence.findPrev(node.id); + const nextKey: string = sequence.findNext(node.id); + const potentialNodes: Image[] = []; + + for (const candidateKey of [prevKey, nextKey]) { + if (candidateKey == null || !this.hasNode(candidateKey)) { + continue; + } + + const candidate: Image = this.getNode(candidateKey); + if (candidate.complete && this._filter(candidate)) { + potentialNodes.push(candidate); + } + } + + return this._computeSpatialEdges(node, potentialNodes, prevKey, nextKey, []); + } + /** * Cache spatial edges for a node. * @@ -920,49 +953,29 @@ export class Graph { throw new GraphMapillaryError(`Spatial edges already cached (${key}).`); } - let node: Image = this.getNode(key); - let sequence: Sequence = this._sequences[node.sequenceId].sequence; - - let fallbackKeys: string[] = []; - let prevKey: string = sequence.findPrev(node.id); - if (prevKey != null) { - fallbackKeys.push(prevKey); - } - - let nextKey: string = sequence.findNext(node.id); - if (nextKey != null) { - fallbackKeys.push(nextKey); - } - - let allSpatialNodes: { [key: string]: Image; } = this._requiredSpatialArea[key].all; - let potentialNodes: Image[] = []; - let filter: FilterFunction = this._filter; - for (let spatialNodeKey in allSpatialNodes) { + const node: Image = this.getNode(key); + const sequence: Sequence = this._sequences[node.sequenceId].sequence; + const prevKey: string = sequence.findPrev(node.id); + const nextKey: string = sequence.findNext(node.id); + const fallbackKeys: string[] = [prevKey, nextKey] + .filter((fallbackKey: string): boolean => fallbackKey != null); + const allSpatialNodes: { [key: string]: Image; } = this._requiredSpatialArea[key].all; + const potentialNodes: Image[] = []; + const filter: FilterFunction = this._filter; + + for (const spatialNodeKey in allSpatialNodes) { if (!allSpatialNodes.hasOwnProperty(spatialNodeKey)) { continue; } - let spatialNode: Image = allSpatialNodes[spatialNodeKey]; - + const spatialNode: Image = allSpatialNodes[spatialNodeKey]; if (spatialNode.complete && filter(spatialNode)) { potentialNodes.push(spatialNode); } } - let potentialEdges: PotentialEdge[] = - this._edgeCalculator.getPotentialEdges(node, potentialNodes, fallbackKeys); - - let edges: NavigationEdge[] = - this._edgeCalculator.computeStepEdges( - node, - potentialEdges, - prevKey, - nextKey); - - edges = edges.concat(this._edgeCalculator.computeTurnEdges(node, potentialEdges)); - edges = edges.concat(this._edgeCalculator.computeSphericalEdges(node, potentialEdges)); - edges = edges.concat(this._edgeCalculator.computePerspectiveToSphericalEdges(node, potentialEdges)); - edges = edges.concat(this._edgeCalculator.computeSimilarEdges(node, potentialEdges)); + const edges: NavigationEdge[] = + this._computeSpatialEdges(node, potentialNodes, prevKey, nextKey, fallbackKeys); node.cacheSpatialEdges(edges); @@ -1266,12 +1279,29 @@ export class Graph { cacheNodes: {}, }; - for (let spatialItem of spatialItems) { - spatialNodes.all[spatialItem.node.id] = spatialItem.node; + for (const spatialItem of spatialItems) { + const spatialNode: Image = spatialItem.node; + const spatialLngLat: LngLat = spatialNode.lngLat; + const enu: number[] = geodeticToEnu( + spatialLngLat.lng, + spatialLngLat.lat, + 0, + node.lngLat.lng, + node.lngLat.lat, + 0); + const horizontalDistanceSquared: number = enu[0] * enu[0] + enu[1] * enu[1]; + + // Adjacent sequence images remain eligible as distance fallbacks. + if (horizontalDistanceSquared > this._tileThreshold * this._tileThreshold && + spatialNode.sequenceId !== node.sequenceId) { + continue; + } + + spatialNodes.all[spatialNode.id] = spatialNode; - if (!spatialItem.node.complete) { - spatialNodes.cacheKeys.push(spatialItem.node.id); - spatialNodes.cacheNodes[spatialItem.node.id] = spatialItem.node; + if (!spatialNode.complete) { + spatialNodes.cacheKeys.push(spatialNode.id); + spatialNodes.cacheNodes[spatialNode.id] = spatialNode; } } @@ -1776,6 +1806,27 @@ export class Graph { this._filterSubscription.unsubscribe(); } + private _computeSpatialEdges( + node: Image, + potentialNodes: Image[], + prevKey: string, + nextKey: string, + fallbackKeys: string[]): NavigationEdge[] { + + const potentialEdges: PotentialEdge[] = + this._edgeCalculator.getPotentialEdges(node, potentialNodes, fallbackKeys); + + let edges: NavigationEdge[] = + this._edgeCalculator.computeStepEdges(node, potentialEdges, prevKey, nextKey); + + edges = edges.concat(this._edgeCalculator.computeTurnEdges(node, potentialEdges)); + edges = edges.concat(this._edgeCalculator.computeSphericalEdges(node, potentialEdges)); + edges = edges.concat(this._edgeCalculator.computePerspectiveToSphericalEdges(node, potentialEdges)); + edges = edges.concat(this._edgeCalculator.computeSimilarEdges(node, potentialEdges)); + + return edges; + } + private _addNewKeys(keys: { [key: string]: boolean; }, dict: { [key: string]: T; }): void { for (let key in dict) { if (!dict.hasOwnProperty(key) || !this.hasNode(key)) { diff --git a/src/graph/GraphService.ts b/src/graph/GraphService.ts index a13c8fe10..68a4eea3c 100644 --- a/src/graph/GraphService.ts +++ b/src/graph/GraphService.ts @@ -28,6 +28,7 @@ import { FilterExpression } from "./FilterExpression"; import { Graph } from "./Graph"; import { GraphMode } from "./GraphMode"; import { Image } from "./Image"; +import { NavigationEdgeStatus } from "./interfaces/NavigationEdgeStatus"; import { Sequence } from "./Sequence"; import { LngLat } from "../api/interfaces/LngLat"; @@ -469,6 +470,61 @@ export class GraphService { })); } + /** + * Cache the adjacent sequence images and compute provisional spatial edges. + * + * @param {string} id - Id of the source image. + * @returns {Observable} Observable emitting edges to + * adjacent sequence images without waiting for the full spatial area. + */ + public cacheSequenceSpatialEdges$(id: string): Observable { + return this._graph$.pipe( + first(), + mergeMap( + (graph: Graph): Observable => { + if (graph.isCachingNodeSequence(id) || !graph.hasNodeSequence(id)) { + return graph.cacheNodeSequence$(id); + } + + return observableOf(graph); + }), + mergeMap( + (graph: Graph): Observable => { + const node: Image = graph.getNode(id); + const sequence: Sequence = graph.getSequence(node.sequenceId); + const adjacentIds: string[] = [ + sequence.findPrev(id), + sequence.findNext(id), + ].filter((adjacentId: string): boolean => adjacentId != null); + + if (adjacentIds.length === 0) { + return observableOf(graph); + } + + return observableFrom(adjacentIds).pipe( + mergeMap( + (adjacentId: string): Observable => { + if (graph.isCachingFull(adjacentId) || !graph.hasNode(adjacentId)) { + return graph.cacheFull$(adjacentId); + } + + if (graph.isCachingFill(adjacentId) || !graph.getNode(adjacentId).complete) { + return graph.cacheFill$(adjacentId); + } + + return observableOf(graph); + }), + takeLast(1)); + }), + map( + (graph: Graph): NavigationEdgeStatus => { + return { + cached: false, + edges: graph.getSequenceSpatialEdges(id), + }; + })); + } + /** * Cache a sequence and its images in the graph and retrieve the sequence. * diff --git a/test/component/direction/DirectionComponent.test.ts b/test/component/direction/DirectionComponent.test.ts index c35254c15..0d45afbff 100644 --- a/test/component/direction/DirectionComponent.test.ts +++ b/test/component/direction/DirectionComponent.test.ts @@ -2,6 +2,7 @@ import { throwError as observableThrowError, of as observableOf, Subject } from import { DirectionComponent } from "../../../src/component/direction/DirectionComponent"; import { DirectionDOMRenderer } from "../../../src/component/direction/DirectionDOMRenderer"; import { ImageCache } from "../../../src/graph/ImageCache"; +import { NavigationEdgeStatus } from "../../../src/graph/interfaces/NavigationEdgeStatus"; import { Sequence } from "../../../src/graph/Sequence"; import { Container } from "../../../src/viewer/Container"; import { Navigator } from "../../../src/viewer/Navigator"; @@ -79,6 +80,8 @@ describe("DirectionComponent.activate", () => { const sequence: Sequence = new Sequence({ id: "skey", image_ids: [] }); (navigatorMock.graphService.cacheSequence$).and.returnValue(observableOf(sequence)); + (navigatorMock.graphService.cacheSequenceSpatialEdges$).and.returnValue( + observableOf({ cached: false, edges: [] })); const image: Image = new ImageHelper().createImage(); image.initializeCache(new ImageCache(undefined)); @@ -108,6 +111,8 @@ describe("DirectionComponent.activate", () => { const sequence: Sequence = new Sequence({ id: "skey", image_ids: [] }); const cacheSequence$: Subject = new Subject(); (navigatorMock.graphService.cacheSequence$).and.returnValue(cacheSequence$); + (navigatorMock.graphService.cacheSequenceSpatialEdges$).and.returnValue( + observableOf({ cached: false, edges: [] })); const image: Image = new ImageHelper().createImage(); image.initializeCache(new ImageCache(undefined)); @@ -121,6 +126,45 @@ describe("DirectionComponent.activate", () => { expect(setEdgesSpy.calls.argsFor(0)[1]).toBe(sequence); }); + it("should set provisional sequence edges before spatial edges", () => { + const containerMock: Container = new ContainerMockCreator().create(); + const navigatorMock: Navigator = new NavigatorMockCreator().create(); + const renderer: DirectionDOMRenderer = new DirectionDOMRenderer({ distinguishSequence: true }, { height: 1, width: 1 }); + const setEdgesSpy: jasmine.Spy = spyOn(renderer, "setEdges").and.stub(); + + const directionComponent: DirectionComponent = + new DirectionComponent( + DirectionComponent.componentName, + containerMock, + navigatorMock, + renderer); + + directionComponent.configure({ distinguishSequence: true }); + directionComponent.activate(); + + const sequence: Sequence = new Sequence({ id: "skey", image_ids: [] }); + (navigatorMock.graphService.cacheSequence$).and.returnValue(observableOf(sequence)); + + const sequenceEdges$ = new Subject(); + (navigatorMock.graphService.cacheSequenceSpatialEdges$).and.returnValue(sequenceEdges$); + + const image: Image = new ImageHelper().createImage(); + image.initializeCache(new ImageCache(undefined)); + (>navigatorMock.stateService.currentImage$).next(image); + + const provisional: NavigationEdgeStatus = { cached: false, edges: [] }; + sequenceEdges$.next(provisional); + + expect(setEdgesSpy.calls.count()).toBe(1); + expect(setEdgesSpy.calls.argsFor(0)[0]).toBe(provisional); + + const spatial: NavigationEdgeStatus = { cached: true, edges: [] }; + image.cacheSpatialEdges(spatial.edges); + + expect(setEdgesSpy.calls.count()).toBe(2); + expect(setEdgesSpy.calls.argsFor(1)[0]).toEqual(spatial); + }); + it("should set edges when distinguishing sequence and cache sequence throws", () => { spyOn(console, "error").and.stub(); From 103c4e73000c8c8ba7050d6e17a7212e7202fb9d Mon Sep 17 00:00:00 2001 From: Boris Masis Date: Wed, 2 Sep 2026 17:54:30 -0700 Subject: [PATCH 08/59] Keep sequence arrows after spatial graph loads --- src/graph/Graph.ts | 20 ++++++++++++++++++-- src/graph/edge/EdgeCalculator.ts | 9 +++++++-- test/graph/Graph.test.ts | 12 ++++++++++++ test/graph/edge/EdgeCalculator.Pano.test.ts | 12 ++++++++++++ 4 files changed, 49 insertions(+), 4 deletions(-) diff --git a/src/graph/Graph.ts b/src/graph/Graph.ts index 4e8d6913f..28aca6f0b 100644 --- a/src/graph/Graph.ts +++ b/src/graph/Graph.ts @@ -938,7 +938,10 @@ export class Graph { } } - return this._computeSpatialEdges(node, potentialNodes, prevKey, nextKey, []); + const fallbackKeys: string[] = [prevKey, nextKey] + .filter((fallbackKey: string): boolean => fallbackKey != null); + + return this._computeSpatialEdges(node, potentialNodes, prevKey, nextKey, fallbackKeys); } /** @@ -961,6 +964,7 @@ export class Graph { .filter((fallbackKey: string): boolean => fallbackKey != null); const allSpatialNodes: { [key: string]: Image; } = this._requiredSpatialArea[key].all; const potentialNodes: Image[] = []; + const potentialNodeIds: { [key: string]: boolean; } = {}; const filter: FilterFunction = this._filter; for (const spatialNodeKey in allSpatialNodes) { @@ -971,6 +975,18 @@ export class Graph { const spatialNode: Image = allSpatialNodes[spatialNodeKey]; if (spatialNode.complete && filter(spatialNode)) { potentialNodes.push(spatialNode); + potentialNodeIds[spatialNode.id] = true; + } + } + + for (const fallbackKey of fallbackKeys) { + if (potentialNodeIds[fallbackKey] || !this.hasNode(fallbackKey)) { + continue; + } + + const fallbackNode: Image = this.getNode(fallbackKey); + if (fallbackNode.complete && filter(fallbackNode)) { + potentialNodes.push(fallbackNode); } } @@ -1820,7 +1836,7 @@ export class Graph { this._edgeCalculator.computeStepEdges(node, potentialEdges, prevKey, nextKey); edges = edges.concat(this._edgeCalculator.computeTurnEdges(node, potentialEdges)); - edges = edges.concat(this._edgeCalculator.computeSphericalEdges(node, potentialEdges)); + edges = edges.concat(this._edgeCalculator.computeSphericalEdges(node, potentialEdges, fallbackKeys)); edges = edges.concat(this._edgeCalculator.computePerspectiveToSphericalEdges(node, potentialEdges)); edges = edges.concat(this._edgeCalculator.computeSimilarEdges(node, potentialEdges)); diff --git a/src/graph/edge/EdgeCalculator.ts b/src/graph/edge/EdgeCalculator.ts index 72c328669..7766b8c82 100644 --- a/src/graph/edge/EdgeCalculator.ts +++ b/src/graph/edge/EdgeCalculator.ts @@ -573,9 +573,13 @@ export class EdgeCalculator { * * @param {Image} node - Source node. * @param {Array} potentialEdges - Potential edges. + * @param {Array} fallbackIds - Ids allowed beyond the maximum distance. * @throws {ArgumentMapillaryError} If node is not full. */ - public computeSphericalEdges(node: Image, potentialEdges: PotentialEdge[]): NavigationEdge[] { + public computeSphericalEdges( + node: Image, + potentialEdges: PotentialEdge[], + fallbackIds: string[] = []): NavigationEdge[] { if (!node.complete) { throw new ArgumentMapillaryError("Image has to be full."); } @@ -589,7 +593,8 @@ export class EdgeCalculator { let potentialSteps: [NavigationDirection, PotentialEdge][] = []; for (let potential of potentialEdges) { - if (potential.distance > this._settings.sphericalMaxDistance) { + if (potential.distance > this._settings.sphericalMaxDistance && + fallbackIds.indexOf(potential.id) < 0) { continue; } diff --git a/test/graph/Graph.test.ts b/test/graph/Graph.test.ts index 30a170448..71efa1429 100644 --- a/test/graph/Graph.test.ts +++ b/test/graph/Graph.test.ts @@ -1929,6 +1929,17 @@ describe("Graph.cacheSpatialEdges", () => { getSequence.complete(); const node = graph.getNode(fullNode.id); + const previousEnt = helper.createImageEnt(); + previousEnt.id = "prev"; + previousEnt.sequence.id = fullNode.sequence.id; + const previous = new Image(previousEnt); + previous.makeComplete(previousEnt); + const hasNode = graph.hasNode.bind(graph); + const getNode = graph.getNode.bind(graph); + spyOn(graph, "hasNode").and.callFake( + (key: string): boolean => key === previous.id || hasNode(key)); + spyOn(graph, "getNode").and.callFake( + (key: string): Image => key === previous.id ? previous : getNode(key)); spyOn(graphCalculator, "boundingBoxCorners") .and.returnValue([ @@ -1951,6 +1962,7 @@ describe("Graph.cacheSpatialEdges", () => { graph.cacheSpatialEdges(fullNode.id); expect(getPotentialSpy.calls.first().args.length).toBe(3); + expect(getPotentialSpy.calls.first().args[1]).toEqual([previous]); expect(getPotentialSpy.calls.first().args[2].length).toBe(2); expect(getPotentialSpy.calls.first().args[2].indexOf("prev")).not.toBe(-1); expect(getPotentialSpy.calls.first().args[2].indexOf("next")).not.toBe(-1); diff --git a/test/graph/edge/EdgeCalculator.Pano.test.ts b/test/graph/edge/EdgeCalculator.Pano.test.ts index a1a8ed47e..bcb92a2b4 100644 --- a/test/graph/edge/EdgeCalculator.Pano.test.ts +++ b/test/graph/edge/EdgeCalculator.Pano.test.ts @@ -77,6 +77,18 @@ describe("EdgeCalculator.computeSphericalEdges", () => { expect(sphericalEdges.length).toBe(0); }); + it("should have a distant spherical edge when it is a fallback", () => { + potentialEdge1.distance = settings.sphericalMaxDistance + 1; + + const sphericalEdges = edgeCalculator.computeSphericalEdges( + image, + [potentialEdge1], + [potentialEdge1.id]); + + expect(sphericalEdges.length).toBe(1); + expect(sphericalEdges[0].target).toBe(potentialEdge1.id); + }); + it("should not have a spherical edge with to short distance", () => { potentialEdge1.distance = settings.sphericalMinDistance / 2; From c4a2360a7a7c90bd9e0b974f6b9904b02ea3725e Mon Sep 17 00:00:00 2001 From: Boris Masis Date: Wed, 2 Sep 2026 18:09:42 -0700 Subject: [PATCH 09/59] Sync reorientation source with shipped behavior --- .../reorientation/ReorientationComponent.ts | 103 +++++++++++++----- 1 file changed, 75 insertions(+), 28 deletions(-) diff --git a/src/component/reorientation/ReorientationComponent.ts b/src/component/reorientation/ReorientationComponent.ts index 12daf9a2b..154c7c0ee 100644 --- a/src/component/reorientation/ReorientationComponent.ts +++ b/src/component/reorientation/ReorientationComponent.ts @@ -76,6 +76,12 @@ export class ReorientationComponent private _adoptedView: number[]; private _settled$ = new Subject(); + // Live viewer bearing, and its value snapshotted at the moment an image + // change arrives — see the currentImage$ subscription for why the snapshot + // is needed. + private _liveBearing: number; + private _incomingBearing: number; + // Manual horizontal look-around offset, preserved within a sequence so the // engine doesn't yank the view back to the travel direction on every step. private _userOffsetX: number; @@ -121,12 +127,20 @@ export class ReorientationComponent } const fromId = this._activeId; this._activeId = image.id; + // Snapshot now, synchronously with the change. By the time the + // engine resolves, the transition has already begun moving the + // camera, so neither the live bearing nor getCenter() still + // describes the view the user carried in. + this._incomingBearing = this._liveBearing; // Consume the direction here, synchronously with the landing, so // it attributes to this image and not a later re-emit. const direction = this._navigator.consumeMoveDirection(); this._reorient(image, direction, fromId); })); + subs.push(this._container.renderService.bearing$.subscribe( + (bearing: number): void => { this._liveBearing = bearing; })); + // A finished drag is a genuine user look-around (our own steering goes // through the state, not pointer events), so capture the offset. subs.push(this._container.mouseService.mouseDragEnd$.subscribe( @@ -145,18 +159,6 @@ export class ReorientationComponent return {}; } - /** - * The reoriented viewer bearing (degrees clockwise from north) the given - * image will be shown at once navigated to: its travel direction plus the - * manual look-around offset preserved across the sequence — the exact - * heading the reoriented view (and any cone tracking it) ends up at. - * - * Synchronous: reads only the engine's precomputed cache (populated for the - * images around the current one), so there is no network round-trip. - * Returns null when there is no valid reorientation for the id — not a - * reoriented pano, end of sequence, or not yet computed — so callers can - * fall back to the image's own compass angle. - */ /** * The heading each image settles on, emitted once this component has * decided whether to turn it. @@ -170,6 +172,18 @@ export class ReorientationComponent return this._settled$; } + /** + * The reoriented viewer bearing (degrees clockwise from north) the given + * image will be shown at once navigated to: its travel direction plus the + * manual look-around offset preserved across the sequence — the exact + * heading the reoriented view (and any cone tracking it) ends up at. + * + * Synchronous: reads only the engine's precomputed cache (populated for the + * images around the current one), so there is no network round-trip. + * Returns null when there is no valid reorientation for the id — not a + * reoriented pano, end of sequence, or not yet computed — so callers can + * fall back to the image's own compass angle. + */ public getReorientedBearing(id: string): number | null { const engine = this._engine; if (!engine) { @@ -204,32 +218,54 @@ export class ReorientationComponent } /** - * Whether navigating to the given image with a direction arrow would - * reorient it, rather than keeping the view the arrow carries across. + * The heading the given image would be shown at if it were reached right + * now with a direction arrow, or null when that cannot be determined + * (reorientation absent, nothing cached yet, not a reorientable pano). * - * Lets a host predict the heading the user is about to land on: the - * {@link getReorientedBearing} value when this is true, the viewer's - * current bearing when it is false. Only an arrow onto the immediate - * neighbour within the current sequence reorients — a sideways hop or a - * sequence crossing keeps the carried view. + * Mirrors the decision _reorient makes on arrival, including the minimum + * turn threshold, so a host can draw a hover indicator that matches where + * the view will actually land. The travel direction alone is not that + * answer: an arrow carries the view across a sequence boundary or a + * sideways hop, and a turn smaller than the threshold is skipped. */ - public reorientsOnSpatialNavTo(id: string): boolean { + public predictBearingTo(id: string): number | null { const engine = this._engine; - if (!this._reorientOnSpatialNav || - engine == null || - this._activeId == null) { - return false; + if (engine == null || + this._activeId == null || + typeof this._liveBearing !== "number") { + return null; } const from = engine.get(this._activeId); + if (from == null || !from.valid) { + return null; + } + // Answered before looking the target up: anything that is not the + // in-sequence neighbour is a sideways hop or a sequence crossing, both + // of which carry the view. The engine only caches within the current + // sequence, so those targets are usually absent and requiring them here + // would return null for exactly the cases the host most needs. + const neighbor = from.nextId === id || from.prevId === id; + if (!neighbor || !this._reorientOnSpatialNav) { + return this._liveBearing; + } + const to = engine.get(id); - if (from == null || !from.valid || to == null || !to.valid) { - return false; + if (to == null || !to.valid || + typeof to.cca !== "number" || + typeof to.travel !== "number") { + return null; } if (to.seq == null || to.seq !== this._lastSeq) { - return false; + return this._liveBearing; } - return from.nextId === id || from.prevId === id; + const targetX = this._applyOffsetX(to.basicX); + const viewX = bearingToBasicX(this._liveBearing, to.cca); + const dxDeg = Math.abs(wrapDelta(targetX - viewX)) * 360; + + return dxDeg >= MIN_REORIENT_DEG ? + ((((to.cca + (targetX - 0.5) * 360) % 360) + 360) % 360) : + this._liveBearing; } private _reorient( @@ -371,6 +407,17 @@ export class ReorientationComponent this._userOffsetY = adopted[1] - 0.5; this._ySeeded = true; } + else if (carryView && + typeof this._incomingBearing === "number" && + typeof result.cca === "number") { + // Not center[0]: on a sideways hop or a sequence + // crossing the two frames can be ~180 deg apart, and + // the state layer is still settling that when this + // resolves, so the sampled centre lags the carried + // view by however far it has got. + viewX = bearingToBasicX( + this._incomingBearing, result.cca); + } // Seed the held pitch offset once from the loaded view. if (!this._ySeeded) { this._userOffsetY = center[1] - 0.5; From 10e9002b2c8333dde85aef795f3909b43f1ea611 Mon Sep 17 00:00:00 2001 From: Boris Masis Date: Wed, 2 Sep 2026 18:09:42 -0700 Subject: [PATCH 10/59] Prepare mapillary-js fb6 package --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index b7d22a06e..ba292953a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mapillary-js", - "version": "5.0.0-beta.5-fb5", + "version": "5.0.0-beta.5-fb6", "description": "A WebGL interactive street imagery library", "license": "MIT", "main": "dist/mapillary.js", From 356ec9de54e3c703f025d5824e6476a7d3d75dbb Mon Sep 17 00:00:00 2001 From: Boris Masis Date: Mon, 7 Sep 2026 09:51:26 -0700 Subject: [PATCH 11/59] Level image horizons from reconstruction --- package.json | 2 +- .../reorientation/ReorientationComponent.ts | 404 ++++++++++++++---- src/render/RenderCamera.ts | 8 + src/state/StateContext.ts | 4 + src/state/StateService.ts | 5 + src/state/interfaces/IStateContext.ts | 1 + src/state/state/InteractiveStateBase.ts | 10 +- src/state/state/StateBase.ts | 2 + src/state/state/TraversingState.ts | 4 +- test/render/RenderCamera.test.ts | 13 + test/state/state/TraversingState.test.ts | 25 ++ 11 files changed, 381 insertions(+), 97 deletions(-) diff --git a/package.json b/package.json index ba292953a..d19a23136 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mapillary-js", - "version": "5.0.0-beta.5-fb6", + "version": "5.0.0-beta.5-fb7", "description": "A WebGL interactive street imagery library", "license": "MIT", "main": "dist/mapillary.js", diff --git a/src/component/reorientation/ReorientationComponent.ts b/src/component/reorientation/ReorientationComponent.ts index 154c7c0ee..f74045738 100644 --- a/src/component/reorientation/ReorientationComponent.ts +++ b/src/component/reorientation/ReorientationComponent.ts @@ -1,5 +1,6 @@ import { Observable, Subject } from "rxjs"; -import { first } from "rxjs/operators"; +import { filter, first } from "rxjs/operators"; +import * as THREE from "three"; import { Component } from "../Component"; import { ComponentName } from "../ComponentName"; @@ -17,12 +18,22 @@ import { import { Image } from "../../graph/Image"; import { NavigationDirection } from "../../graph/edge/NavigationDirection"; import { Sequence } from "../../graph/Sequence"; +import { isSpherical } from "../../geo/Geo"; +import { Transform } from "../../geo/Transform"; +import { ViewportCoords } from "../../geo/ViewportCoords"; +import { RenderCamera } from "../../render/RenderCamera"; import { Container } from "../../viewer/Container"; import { Navigator } from "../../viewer/Navigator"; // Skip a reorientation when it would move the current view less than this many // degrees (on either axis) — small moves are just jitter. const MIN_REORIENT_DEG = 15; +const MAX_HORIZON_CORRECTION_DEG = 60; +const MAX_REORIENTATION_ROLL_DEG = 30; +const MAX_PERSPECTIVE_AUTO_ZOOM = 0.75; +const MAX_PERSPECTIVE_FOV = 125; +const PERSPECTIVE_EDGE_MARGIN = 1e-3; +const USER_ZOOM_EPSILON = 1e-2; /** * The heading an image ended up being shown at, once reorientation has decided @@ -36,17 +47,17 @@ export interface ReorientationSettledEvent { /** * @class ReorientationComponent * - * @classdesc Reorients each spherical image to face the direction of travel - * (the great-circle bearing toward the next image in the sequence) as the - * user navigates, instead of preserving the previous look direction. If the - * user drags to look around, that manual offset is preserved across the rest - * of the sequence rather than re-facing forward on every step. + * @classdesc Reorients spherical images to face the direction of travel and + * levels their horizon. Perspective images keep their heading but are leveled + * and fitted so rotation does not expose the image boundary. If the user drags + * to look around, that manual offset is preserved across the rest of a + * spherical sequence rather than re-facing forward on every step. * - * Crossing into a new sequence with a direction arrow keeps the carried view - * rather than snapping to travel — the arrow's own transition already matched - * the angle — and adopts it as the new sequence's look-around offset. Every - * other way into a new sequence — Next/Prev, map click, shared link, fresh - * load — resets to travel + horizon. + * Crossing into a new spherical sequence with a direction arrow keeps the + * carried view rather than snapping to travel — the arrow's own transition + * already matched the angle — and adopts it as the new sequence's look-around + * offset. Every other way into a new sequence — Next/Prev, map click, shared + * link, fresh load — resets to travel + horizon. * * Active by default; disable with `component: { reorientation: false }`. * @@ -72,9 +83,14 @@ export class ReorientationComponent // load or a deliberate jump to another capture) can be reoriented even when // the landing image's own GPS speed reads as stationary. private _lastSeq: string; + private _imageSequence: string; + private _sequenceChanged: boolean; + private _appliedPerspectiveZoom: number; + private _userZoomOverride: boolean; private _reorientOnSpatialNav: boolean; private _adoptedView: number[]; - private _settled$ = new Subject(); + private _settled$: Subject = + new Subject(); // Live viewer bearing, and its value snapshotted at the moment an image // change arrives — see the currentImage$ subscription for why the snapshot @@ -86,79 +102,19 @@ export class ReorientationComponent // engine doesn't yank the view back to the travel direction on every step. private _userOffsetX: number; - // Vertical look offset from the horizon. Seeded once per activation from the - // current view (so a shared link's y / carried pitch is kept) and updated on - // drag, then applied deterministically as 0.5 + offset. Re-reading the live + // Vertical look offset from the reconstructed horizon. Seeded once per + // activation from the current view and updated on drag. Re-reading the live // y every image round-trips through the spherical projection and drifts, so // it is held, not re-read. private _userOffsetY: number; private _ySeeded: boolean; + private _currentTransform: Transform; + private _viewportCoords: ViewportCoords = new ViewportCoords(); constructor(name: string, container: Container, navigator: Navigator) { super(name, container, navigator); } - protected _activate(): void { - const subs = this._subscriptions; - - subs.push(this._configuration$.subscribe( - (configuration: ReorientationConfiguration): void => { - this._reorientOnSpatialNav = - configuration.reorientOnSpatialNav ?? - DEFAULT_REORIENTATION_CONFIGURATION.reorientOnSpatialNav; - this._engine = new ReorientationEngine( - this._createProvider(), configuration); - this._activeId = null; - this._computedBasicX = 0.5; - this._lastSeq = null; - // NOT _adoptedView: it is host intent that can be handed over - // before this fires (activation and configure() both re-run - // this), and clearing it here silently drops the view the host - // asked to keep. - this._resetOffset(); - })); - - // currentImage$ fires reliably on every navigation, including in-app - // jumps to another sequence (currentState$ did not deliver those). - subs.push(this._navigator.stateService.currentImage$.subscribe( - (image: Image): void => { - if (!image) { - return; - } - const fromId = this._activeId; - this._activeId = image.id; - // Snapshot now, synchronously with the change. By the time the - // engine resolves, the transition has already begun moving the - // camera, so neither the live bearing nor getCenter() still - // describes the view the user carried in. - this._incomingBearing = this._liveBearing; - // Consume the direction here, synchronously with the landing, so - // it attributes to this image and not a later re-emit. - const direction = this._navigator.consumeMoveDirection(); - this._reorient(image, direction, fromId); - })); - - subs.push(this._container.renderService.bearing$.subscribe( - (bearing: number): void => { this._liveBearing = bearing; })); - - // A finished drag is a genuine user look-around (our own steering goes - // through the state, not pointer events), so capture the offset. - subs.push(this._container.mouseService.mouseDragEnd$.subscribe( - (): void => { this._captureOffset(); })); - } - - protected _deactivate(): void { - this._subscriptions.unsubscribe(); - this._engine = null; - this._activeId = null; - this._lastSeq = null; - this._resetOffset(); - } - - protected _getDefaultConfiguration(): ReorientationConfiguration { - return {}; - } - /** * The heading each image settles on, emitted once this component has * decided whether to turn it. @@ -268,6 +224,92 @@ export class ReorientationComponent this._liveBearing; } + protected _activate(): void { + const subs = this._subscriptions; + + subs.push(this._configuration$.subscribe( + (configuration: ReorientationConfiguration): void => { + this._reorientOnSpatialNav = + configuration.reorientOnSpatialNav ?? + DEFAULT_REORIENTATION_CONFIGURATION.reorientOnSpatialNav; + this._engine = new ReorientationEngine( + this._createProvider(), configuration); + this._activeId = null; + this._computedBasicX = 0.5; + this._lastSeq = null; + this._imageSequence = null; + this._sequenceChanged = false; + this._appliedPerspectiveZoom = 0; + this._userZoomOverride = false; + // NOT _adoptedView: it is host intent that can be handed over + // before this fires (activation and configure() both re-run + // this), and clearing it here silently drops the view the host + // asked to keep. + this._resetOffset(); + })); + + // currentImage$ fires reliably on every navigation, including in-app + // jumps to another sequence (currentState$ did not deliver those). + subs.push(this._navigator.stateService.currentImage$.subscribe( + (image: Image): void => { + if (!image) { + return; + } + this._currentTransform = new Transform( + image.exifOrientation, + image.width, + image.height, + image.scale, + image.rotation, + [0, 0, 0], + image.image, + image.camera); + const fromId = this._activeId; + this._sequenceChanged = fromId != null && + image.sequenceId !== this._imageSequence; + if (this._sequenceChanged) { + this._navigator.stateService.setZoom(0); + this._appliedPerspectiveZoom = 0; + this._userZoomOverride = false; + } + this._activeId = image.id; + this._imageSequence = image.sequenceId; + // Snapshot now, synchronously with the change. By the time the + // engine resolves, the transition has already begun moving the + // camera, so neither the live bearing nor getCenter() still + // describes the view the user carried in. + this._incomingBearing = this._liveBearing; + // Consume the direction here, synchronously with the landing, so + // it attributes to this image and not a later re-emit. + const direction = this._navigator.consumeMoveDirection(); + this._reorient(image, direction, fromId); + })); + + subs.push(this._container.renderService.bearing$.subscribe( + (bearing: number): void => { this._liveBearing = bearing; })); + + // A finished drag is a genuine user look-around (our own steering goes + // through the state, not pointer events), so capture the offset. + subs.push(this._container.mouseService.mouseDragEnd$.subscribe( + (): void => { this._captureOffset(); })); + } + + protected _deactivate(): void { + this._subscriptions.unsubscribe(); + this._engine = null; + this._activeId = null; + this._lastSeq = null; + this._imageSequence = null; + this._sequenceChanged = false; + this._appliedPerspectiveZoom = 0; + this._userZoomOverride = false; + this._resetOffset(); + } + + protected _getDefaultConfiguration(): ReorientationConfiguration { + return {}; + } + private _reorient( image: Image, direction: NavigationDirection, @@ -282,6 +324,10 @@ export class ReorientationComponent return; } const result = engine.get(id); + if (!isSpherical(image.cameraType)) { + this._levelPerspective(id); + return; + } // Read mesh now (after the precompute delay) so it's loaded: // an image with SfM mesh eases to the travel direction, one // without (disconnected) hard-cuts. The transition type is NOT @@ -296,9 +342,22 @@ export class ReorientationComponent // consumes it, and leaving it set would frame whichever // pano resolves next with a stale look-around offset. this._adoptedView = null; + this._navigator.stateService.traverse(); return; } + const safetyCenter = this._adoptedView ?? + [result.basicX, this._horizonY(result.basicX)]; + const rollDeg = this._rollDeg(safetyCenter); + if (rollDeg == null || + rollDeg > MAX_REORIENTATION_ROLL_DEG) { + this._navigator.stateService.traverse(); + this._navigator.stateService.setCenter( + this._adoptedView ?? [result.basicX, 0.5]); + this._adoptedView = null; + return; + } + this._navigator.stateService.gravityTraverse(); this._computedBasicX = result.basicX; // A fresh load or a jump to another capture lands on a new @@ -404,7 +463,8 @@ export class ReorientationComponent viewX = adopted[0]; this._userOffsetX = wrapDelta(adopted[0] - result.basicX); - this._userOffsetY = adopted[1] - 0.5; + this._userOffsetY = + adopted[1] - this._horizonY(adopted[0]); this._ySeeded = true; } else if (carryView && @@ -420,7 +480,8 @@ export class ReorientationComponent } // Seed the held pitch offset once from the loaded view. if (!this._ySeeded) { - this._userOffsetY = center[1] - 0.5; + this._userOffsetY = + center[1] - this._horizonY(center[0]); this._ySeeded = true; } // Arrow into a new sequence or a sideways hop: keep the @@ -433,21 +494,16 @@ export class ReorientationComponent this._userOffsetX = wrapDelta(viewX - result.basicX); } - const targetY = - Math.max(0, Math.min(1, 0.5 + this._userOffsetY)); - const targetX = this._applyOffsetX(result.basicX); + const targetY = Math.max( + 0, + Math.min(1, this._horizonY(targetX) + this._userOffsetY)); - // Horizontal only: the pitch is held deterministically, - // so the live y wobbles with spherical-projection round- - // trip noise (~0.1 ≈ 18°). Gating on it would just snap - // back projection drift — the very jitter we're avoiding. const dxDeg = Math.abs(wrapDelta(targetX - viewX)) * 360; - // Reorient if the horizontal move clears the threshold, - // or (on a sequence change) the pitch must reset by more - // than the threshold to clear a carried look up/down. + const dyDeg = Math.abs(targetY - center[1]) * 180; const move = dxDeg >= MIN_REORIENT_DEG || + dyDeg >= MIN_REORIENT_DEG || pitchResetDeg >= MIN_REORIENT_DEG; if (move) { if (hardCut) { @@ -543,6 +599,143 @@ export class ReorientationComponent .catch((): void => { /* skip */ }); } + private _levelPerspective(id: string): void { + if (this._adoptedView != null) { + this._navigator.stateService.gravityTraverse(); + this._adoptedView = null; + return; + } + + this._navigator.stateService.getCenter().pipe(first()).subscribe( + (): void => { + if (this._activeId !== id || this._adoptedView != null) { + this._adoptedView = null; + return; + } + const target = [0.5, this._horizonY(0.5)]; + this._container.renderService.renderCameraFrame$.pipe( + filter((render: RenderCamera): boolean => + render.currentImageId === id), + first(), + ).subscribe((render: RenderCamera): void => { + if (this._activeId !== id || this._adoptedView != null) { + this._adoptedView = null; + return; + } + const zoom = this._perspectiveAutoZoom( + target, render.unzoomedCurrentFov); + const stateService = this._navigator.stateService; + if (!this._sequenceChanged && + Math.abs(render.zoom - this._appliedPerspectiveZoom) > + USER_ZOOM_EPSILON) { + this._userZoomOverride = true; + } + if (zoom == null) { + stateService.traverse(); + stateService.zoomTo( + this._userZoomOverride ? render.zoom : 0); + this._appliedPerspectiveZoom = 0; + return; + } + stateService.gravityTraverse(); + stateService.rotateToBasicSmooth(target); + stateService.zoomTo( + this._userZoomOverride ? render.zoom : zoom); + this._appliedPerspectiveZoom = zoom; + }); + }); + } + + private _rollDeg(center: number[]): number | null { + const transform = this._currentTransform; + if (transform == null) { + return null; + } + const origin = new THREE.Vector3().fromArray( + transform.unprojectSfM([0, 0], 0)); + const direction = new THREE.Vector3().fromArray( + transform.unprojectBasic(center, 10)) + .sub(origin) + .normalize(); + const imageUp = transform.upVector() + .addScaledVector(direction, -transform.upVector().dot(direction)) + .normalize(); + const gravityUp = new THREE.Vector3(0, 0, 1) + .addScaledVector(direction, -direction.z) + .normalize(); + const rollDeg = imageUp.angleTo(gravityUp) * 180 / Math.PI; + return Number.isFinite(rollDeg) ? rollDeg : null; + } + + private _perspectiveAutoZoom( + center: number[], baseFov: number): number | null { + const transform = this._currentTransform; + const element = this._container.container; + if (transform == null || element.offsetHeight === 0) { + return null; + } + + const origin = new THREE.Vector3().fromArray( + transform.unprojectSfM([0, 0], 0)); + const rollDeg = this._rollDeg(center); + if (rollDeg == null || + rollDeg > MAX_REORIENTATION_ROLL_DEG) { + return null; + } + + const camera = new THREE.PerspectiveCamera( + 60, + element.offsetWidth / element.offsetHeight, + 1e-1, + 1e4); + camera.position.copy(origin); + + const maxFov = (basic: number[], up: THREE.Vector3): number => { + camera.up.copy(up); + camera.lookAt(new THREE.Vector3().fromArray( + transform.unprojectBasic(basic, 10))); + camera.updateMatrixWorld(true); + + const fits = (fov: number): boolean => { + camera.fov = fov; + camera.updateProjectionMatrix(); + const corners = [ + this._viewportCoords.viewportToBasic( + -1, 1, transform, camera), + this._viewportCoords.viewportToBasic( + 1, 1, transform, camera), + this._viewportCoords.viewportToBasic( + 1, -1, transform, camera), + this._viewportCoords.viewportToBasic( + -1, -1, transform, camera), + ]; + return corners.every((point: number[]): boolean => + point != null && + point[0] >= PERSPECTIVE_EDGE_MARGIN && + point[0] <= 1 - PERSPECTIVE_EDGE_MARGIN && + point[1] >= PERSPECTIVE_EDGE_MARGIN && + point[1] <= 1 - PERSPECTIVE_EDGE_MARGIN); + }; + + let low = 0; + let high = MAX_PERSPECTIVE_FOV; + for (let i = 0; i < 16; i++) { + const middle = (low + high) / 2; + if (fits(middle)) { + low = middle; + } else { + high = middle; + } + } + return low; + }; + + const levelFov = maxFov(center, new THREE.Vector3(0, 0, 1)); + const zoom = Math.max(0, Math.log(baseFov / levelFov) / Math.log(2)); + return Number.isFinite(zoom) && zoom <= MAX_PERSPECTIVE_AUTO_ZOOM ? + zoom : null; + } + private _captureOffset(): void { const id = this._activeId; const engine = this._engine; @@ -558,11 +751,44 @@ export class ReorientationComponent const basis = result && result.valid ? result.basicX : this._computedBasicX; this._userOffsetX = wrapDelta(center[0] - basis); - this._userOffsetY = center[1] - 0.5; + this._userOffsetY = + center[1] - this._horizonY(center[0]); this._ySeeded = true; }); } + private _horizonY(x: number): number { + const transform = this._currentTransform; + if (transform == null) { + return 0.5; + } + + const cameraZ = transform.unprojectSfM([0, 0], 0)[2]; + let low = 0; + let high = 1; + let lowZ = transform.unprojectBasic([x, low], 10)[2] - cameraZ; + const highZ = transform.unprojectBasic([x, high], 10)[2] - cameraZ; + if (lowZ * highZ > 0) { + return 0.5; + } + + for (let i = 0; i < 32; i++) { + const middle = (low + high) / 2; + const middleZ = + transform.unprojectBasic([x, middle], 10)[2] - cameraZ; + if (lowZ * middleZ > 0) { + low = middle; + lowZ = middleZ; + } else { + high = middle; + } + } + + const horizon = (low + high) / 2; + return Math.abs(horizon - 0.5) * 180 <= + MAX_HORIZON_CORRECTION_DEG ? horizon : 0.5; + } + private _resetOffset(): void { this._userOffsetX = 0; this._userOffsetY = 0; diff --git a/src/render/RenderCamera.ts b/src/render/RenderCamera.ts index 801c6c522..cbe2ae0b1 100644 --- a/src/render/RenderCamera.ts +++ b/src/render/RenderCamera.ts @@ -136,6 +136,10 @@ export class RenderCamera { return this._frameId === this._changedForFrame; } + public get currentImageId(): string { + return this._currentImageId; + } + public get frameId(): number { return this._frameId; } @@ -156,6 +160,10 @@ export class RenderCamera { return this._zoom; } + public get unzoomedCurrentFov(): number { + return this._computeCurrentFov(0); + } + public get size(): ViewportSize { return this._size; } diff --git a/src/state/StateContext.ts b/src/state/StateContext.ts index f8e18aa06..cb659b44f 100644 --- a/src/state/StateContext.ts +++ b/src/state/StateContext.ts @@ -139,6 +139,10 @@ export class StateContext implements IStateContext { this._state.setZoom(zoom); } + public zoomTo(zoom: number): void { + this._state.zoomTo(zoom); + } + public update(delta: number): void { this._state.update(delta); } diff --git a/src/state/StateService.ts b/src/state/StateService.ts index d39b1ffd4..bf8c58570 100644 --- a/src/state/StateService.ts +++ b/src/state/StateService.ts @@ -550,6 +550,11 @@ export class StateService { this._invokeContextOperation((context: IStateContext) => { context.setZoom(zoom); }); } + public zoomTo(zoom: number): void { + this._inMotionOperation$.next(true); + this._invokeContextOperation((context: IStateContext) => { context.zoomTo(zoom); }); + } + public start(): void { this._clock.start(); if (this._frameId == null) { diff --git a/src/state/interfaces/IStateContext.ts b/src/state/interfaces/IStateContext.ts index 702b3b0c3..0c256571f 100644 --- a/src/state/interfaces/IStateContext.ts +++ b/src/state/interfaces/IStateContext.ts @@ -39,6 +39,7 @@ export interface IStateContext extends IAnimationState { getCenter(): number[]; setCenter(center: number[]): void; setZoom(zoom: number): void; + zoomTo(zoom: number): void; setSpeed(speed: number): void; setTransitionMode(mode: TransitionMode): void; diff --git a/src/state/state/InteractiveStateBase.ts b/src/state/state/InteractiveStateBase.ts index ccf9ae34a..df0a66492 100644 --- a/src/state/state/InteractiveStateBase.ts +++ b/src/state/state/InteractiveStateBase.ts @@ -322,6 +322,11 @@ export abstract class InteractiveStateBase extends StateBase { this._desiredZoom = this._zoom; } + public zoomTo(zoom: number): void { + this._desiredZoom = this._spatial.clamp( + zoom, this._minZoom, this._maxZoom); + } + protected _applyRotation(delta: EulerRotation, camera: Camera): void { if (camera == null) { return; @@ -549,9 +554,6 @@ export abstract class InteractiveStateBase extends StateBase { } protected _setDesiredZoom(): void { - this._desiredZoom = - isSpherical(this._currentImage.cameraType) || - this._previousImage == null ? - this._zoom : 0; + this._desiredZoom = this._zoom; } } diff --git a/src/state/state/StateBase.ts b/src/state/state/StateBase.ts index dd4b4c14b..f909fa7af 100644 --- a/src/state/state/StateBase.ts +++ b/src/state/state/StateBase.ts @@ -213,6 +213,8 @@ export abstract class StateBase implements IStateBase { public setZoom(zoom: number): void { /*noop*/ } + public zoomTo(zoom: number): void { /*noop*/ } + public dolly(delta: number): void { /*noop*/ } public orbit(rotation: EulerRotation): void { /*noop*/ } diff --git a/src/state/state/TraversingState.ts b/src/state/state/TraversingState.ts index 6ef5a19a4..461633a43 100644 --- a/src/state/state/TraversingState.ts +++ b/src/state/state/TraversingState.ts @@ -87,9 +87,7 @@ export class TraversingState extends InteractiveStateBase { this._resetTransition(); this._clearRotation(); - this._desiredZoom = - isSpherical(this._currentImage.cameraType) ? - this._zoom : 0; + this._desiredZoom = this._zoom; this._desiredLookat = null; diff --git a/test/render/RenderCamera.test.ts b/test/render/RenderCamera.test.ts index 4f5c8d493..3ca90c4b7 100644 --- a/test/render/RenderCamera.test.ts +++ b/test/render/RenderCamera.test.ts @@ -198,6 +198,19 @@ describe("RenderCamera.perspective.fov", () => { expect(renderCamera.perspective.fov).toBeLessThan(fov); }); + + it("should expose the current image and unzoomed field of view", () => { + const renderCamera = new RenderCamera(1, 1, RenderMode.Fill); + const frame: AnimationFrame = new FrameHelper().createFrame(); + renderCamera.setFrame(frame); + const unzoomedFov = renderCamera.unzoomedCurrentFov; + + frame.state.zoom = 1; + renderCamera.setFrame(frame); + + expect(renderCamera.currentImageId).toBe(frame.state.currentImage.id); + expect(renderCamera.unzoomedCurrentFov).toBe(unzoomedFov); + }); }); describe("RenderCamera.setProjectionMatrix", () => { diff --git a/test/state/state/TraversingState.test.ts b/test/state/state/TraversingState.test.ts index 419d34730..e0fdb47d7 100644 --- a/test/state/state/TraversingState.test.ts +++ b/test/state/state/TraversingState.test.ts @@ -40,6 +40,10 @@ class TestTraversingState extends TraversingState { return this._currentCamera; } + public get desiredZoom(): number { + return this._desiredZoom; + } + public get previousCamera(): Camera { return this._previousCamera; } @@ -56,6 +60,27 @@ function createTestImage(): TestImage { return image; } +describe("TraversingState.zoomTo", () => { + it("should update desired zoom without changing current zoom", () => { + const state: IStateBase = { + alpha: 1, + camera: new Camera(), + currentIndex: -1, + geometry: new S2GeometryProvider(), + reference: { alt: 0, lat: 0, lng: 0 }, + trajectory: [], + transitionMode: TransitionMode.Default, + zoom: 1, + }; + const traversingState = new TestTraversingState(state); + + traversingState.zoomTo(0.25); + + expect(traversingState.zoom).toBe(1); + expect(traversingState.desiredZoom).toBe(0.25); + }); +}); + describe("TraversingState.currentCamera.lookat", () => { let precision: number = 1e-8; From 8fa116d11ed43cee4896cd6b3bd0098b0c888151 Mon Sep 17 00:00:00 2001 From: Boris Masis Date: Tue, 8 Sep 2026 00:42:11 -0700 Subject: [PATCH 12/59] Handle unmerged panorama navigation fallbacks --- package.json | 2 +- .../reorientation/ReorientationComponent.ts | 11 ++++-- src/graph/Graph.ts | 3 +- src/graph/edge/EdgeCalculator.ts | 10 +++-- test/graph/Graph.test.ts | 24 ++++++++++++ test/graph/edge/EdgeCalculator.test.ts | 37 +++++++++++++++++++ 6 files changed, 77 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index d19a23136..024ffce5b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mapillary-js", - "version": "5.0.0-beta.5-fb7", + "version": "5.0.0-beta.5-fb8", "description": "A WebGL interactive street imagery library", "license": "MIT", "main": "dist/mapillary.js", diff --git a/src/component/reorientation/ReorientationComponent.ts b/src/component/reorientation/ReorientationComponent.ts index f74045738..8803e835d 100644 --- a/src/component/reorientation/ReorientationComponent.ts +++ b/src/component/reorientation/ReorientationComponent.ts @@ -337,12 +337,15 @@ export class ReorientationComponent image.mesh.vertices.length : -1; const hardCut = meshV <= 0; if (!result || !result.valid) { - // An adopted view belongs to the navigation that supplied - // it. A landing image that cannot be reoriented never - // consumes it, and leaving it set would frame whichever - // pano resolves next with a stale look-around offset. + // Switching out of Gravity can reset a center queued before + // the image loaded, so restore an explicit shared-link view + // after the fallback transition. + const adoptedView = this._adoptedView; this._adoptedView = null; this._navigator.stateService.traverse(); + if (adoptedView != null) { + this._navigator.stateService.setCenter(adoptedView); + } return; } diff --git a/src/graph/Graph.ts b/src/graph/Graph.ts index 28aca6f0b..854b15865 100644 --- a/src/graph/Graph.ts +++ b/src/graph/Graph.ts @@ -2023,7 +2023,8 @@ export class Graph { fillNode.computed_altitude = this._defaultAlt; } - if (fillNode.computed_rotation == null) { + if (fillNode.computed_rotation == null || + fillNode.computed_rotation.length !== 3) { fillNode.computed_rotation = this._graphCalculator.rotationFromCompass(fillNode.compass_angle, fillNode.exif_orientation); } diff --git a/src/graph/edge/EdgeCalculator.ts b/src/graph/edge/EdgeCalculator.ts index 7766b8c82..245487450 100644 --- a/src/graph/edge/EdgeCalculator.ts +++ b/src/graph/edge/EdgeCalculator.ts @@ -65,7 +65,8 @@ export class EdgeCalculator { throw new ArgumentMapillaryError("Image has to be full."); } - if (!node.merged) { + if (!node.merged && + (fallbackIds.length === 0 || node.rotation.length !== 3)) { return []; } @@ -77,8 +78,9 @@ export class EdgeCalculator { let potentialEdges: PotentialEdge[] = []; for (let potential of potentialImages) { - if (!potential.merged || - potential.id === node.id) { + const fallback = fallbackIds.indexOf(potential.id) > -1; + if (((!node.merged || !potential.merged) && !fallback) || + potential.id === node.id || potential.rotation.length !== 3) { continue; } @@ -130,7 +132,7 @@ export class EdgeCalculator { potential.sequenceId === node.sequenceId; let sameMergeCC: boolean = - potential.mergeId === node.mergeId; + node.mergeId != null && potential.mergeId === node.mergeId; let sameUser: boolean = potential.creatorId === node.creatorId; diff --git a/test/graph/Graph.test.ts b/test/graph/Graph.test.ts index 71efa1429..2d71d4f99 100644 --- a/test/graph/Graph.test.ts +++ b/test/graph/Graph.test.ts @@ -320,6 +320,30 @@ describe("Graph.cacheFull$", () => { expect(graph.getNode(fullNode.id).id).toBe(fullNode.id); }); + it("should derive rotation when the API returns an empty vector", (done: Function) => { + const api = new APIWrapper(new DataProvider()); + const calculator = new GraphCalculator(); + const rotation = [1, 2, 3]; + const rotationSpy = spyOn(calculator, "rotationFromCompass") + .and.returnValue(rotation); + const getImages = new Subject(); + spyOn(api, "getImages$").and.returnValue(getImages); + spyOn(api.data.geometry, "lngLatToCellId").and.returnValue("cell-id"); + const graph = new Graph(api, undefined, undefined, calculator); + const fullNode = helper.createImageEnt(); + fullNode.computed_rotation = []; + + graph.cacheFull$(fullNode.id).subscribe( + (g: Graph): void => { + expect(rotationSpy).toHaveBeenCalledWith( + fullNode.compass_angle, fullNode.exif_orientation); + expect(g.getNode(fullNode.id).rotation).toEqual(rotation); + done(); + }); + getImages.next([{ node: fullNode, node_id: fullNode.id }]); + getImages.complete(); + }); + it("should not make additional calls when fetching same node twice", () => { const api = new APIWrapper(new DataProvider()); const calculator = new GraphCalculator(); diff --git a/test/graph/edge/EdgeCalculator.test.ts b/test/graph/edge/EdgeCalculator.test.ts index 8476b9e77..2db6e3907 100644 --- a/test/graph/edge/EdgeCalculator.test.ts +++ b/test/graph/edge/EdgeCalculator.test.ts @@ -80,6 +80,43 @@ describe("EdgeCalculator.getPotentialEdges", () => { expect(potentialEdges.length).toBe(0); }); + it("should return an unmerged fallback edge", () => { + let key = "key"; + let edgeKey = "edgeKey"; + let sequenceKey = "skey"; + + let lla: LngLatAlt = { alt: 0, lat: 0, lng: 0 }; + let image = helper.createCompleteImage( + key, + lla, + sequenceKey, + [0, -Math.PI / 2, 0], + null, + "spherical"); + let geodetic = GeoCoords.enuToGeodetic( + 10, + 0, + 0, + lla.lng, + lla.lat, + lla.alt); + let edgeImage = helper.createCompleteImage( + edgeKey, + { alt: geodetic[2], lat: geodetic[1], lng: geodetic[0] }, + sequenceKey, + [0, -Math.PI / 2, 0], + null, + "spherical"); + + let potentialEdges = + edgeCalculator.getPotentialEdges(image, [edgeImage], [edgeKey]); + + expect(potentialEdges.length).toBe(1); + expect(potentialEdges[0].id).toBe(edgeKey); + expect(potentialEdges[0].sameMergeCC).toBe(false); + expect(potentialEdges[0].sameSequence).toBe(true); + }); + it("should return a potential edge", () => { let key = "key"; let edgeKey = "edgeKey"; From 02e8de06df8602ce86f2a043e6510dad89bad091 Mon Sep 17 00:00:00 2001 From: Boris Masis Date: Tue, 8 Sep 2026 01:19:18 -0700 Subject: [PATCH 13/59] Align fallback panorama arrows with capture heading --- src/graph/Graph.ts | 5 ++++- test/graph/Graph.test.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/graph/Graph.ts b/src/graph/Graph.ts index 854b15865..6e928aecf 100644 --- a/src/graph/Graph.ts +++ b/src/graph/Graph.ts @@ -44,6 +44,7 @@ import { ImagesContract } from "../api/contracts/ImagesContract"; import { SequenceContract } from "../api/contracts/SequenceContract"; import { CoreImagesContract } from "../api/contracts/CoreImagesContract"; import { CancelMapillaryError } from "../error/CancelMapillaryError"; +import { isSpherical } from "../geo/Geo"; import { geodeticToEnu } from "../geo/GeoCoords"; type NodeTiles = { @@ -2025,7 +2026,9 @@ export class Graph { if (fillNode.computed_rotation == null || fillNode.computed_rotation.length !== 3) { - fillNode.computed_rotation = this._graphCalculator.rotationFromCompass(fillNode.compass_angle, fillNode.exif_orientation); + const compassAngle = isSpherical(fillNode.camera_type) ? + fillNode.compass_angle - 90 : fillNode.compass_angle; + fillNode.computed_rotation = this._graphCalculator.rotationFromCompass(compassAngle, fillNode.exif_orientation); } node.makeComplete(fillNode); diff --git a/test/graph/Graph.test.ts b/test/graph/Graph.test.ts index 2d71d4f99..845d89e92 100644 --- a/test/graph/Graph.test.ts +++ b/test/graph/Graph.test.ts @@ -344,6 +344,32 @@ describe("Graph.cacheFull$", () => { getImages.complete(); }); + it("should align a derived spherical rotation with the panorama heading", (done: Function) => { + const api = new APIWrapper(new DataProvider()); + const calculator = new GraphCalculator(); + const rotation = [1, 2, 3]; + const rotationSpy = spyOn(calculator, "rotationFromCompass") + .and.returnValue(rotation); + const getImages = new Subject(); + spyOn(api, "getImages$").and.returnValue(getImages); + spyOn(api.data.geometry, "lngLatToCellId").and.returnValue("cell-id"); + const graph = new Graph(api, undefined, undefined, calculator); + const fullNode = helper.createImageEnt(); + fullNode.camera_type = "spherical"; + fullNode.compass_angle = 180; + fullNode.computed_rotation = []; + + graph.cacheFull$(fullNode.id).subscribe( + (g: Graph): void => { + expect(rotationSpy).toHaveBeenCalledWith( + 90, fullNode.exif_orientation); + expect(g.getNode(fullNode.id).rotation).toEqual(rotation); + done(); + }); + getImages.next([{ node: fullNode, node_id: fullNode.id }]); + getImages.complete(); + }); + it("should not make additional calls when fetching same node twice", () => { const api = new APIWrapper(new DataProvider()); const calculator = new GraphCalculator(); From 201cef0ef99aba04c0b3d9ac8ede198156a36de9 Mon Sep 17 00:00:00 2001 From: Boris Masis Date: Tue, 8 Sep 2026 01:54:08 -0700 Subject: [PATCH 14/59] Reorient unmerged panoramas from capture metadata When SfM compass data is unavailable, derive the panorama frame from the original compass angle and use the sequence GPS track for direction of movement. --- package.json | 2 +- src/component/reorientation/ReorientationComponent.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 024ffce5b..8387ae1d2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mapillary-js", - "version": "5.0.0-beta.5-fb8", + "version": "5.0.0-beta.5-fb10", "description": "A WebGL interactive street imagery library", "license": "MIT", "main": "dist/mapillary.js", diff --git a/src/component/reorientation/ReorientationComponent.ts b/src/component/reorientation/ReorientationComponent.ts index 8803e835d..29b47bb7d 100644 --- a/src/component/reorientation/ReorientationComponent.ts +++ b/src/component/reorientation/ReorientationComponent.ts @@ -807,7 +807,7 @@ export class ReorientationComponent lng: lngLat ? lngLat.lng : null, originalLat: originalLngLat ? originalLngLat.lat : null, originalLng: originalLngLat ? originalLngLat.lng : null, - cca: image.computedCompassAngle, + cca: image.compassAngle, cam: image.cameraType, seq: image.sequenceId, ts: image.capturedAt, From e01c784fbd4ab4fdbc289b278adf8c3c3f6fd8a8 Mon Sep 17 00:00:00 2001 From: Boris Masis Date: Tue, 8 Sep 2026 04:20:39 -0700 Subject: [PATCH 15/59] Avoid box artifacts for default panorama meshes --- package.json | 2 +- src/component/slider/SliderMeshFactory.ts | 5 +++- src/component/util/MeshFactory.ts | 5 +++- src/util/Mesh.ts | 12 ++++++++ test/util/Mesh.test.ts | 36 +++++++++++++++++++++++ 5 files changed, 57 insertions(+), 3 deletions(-) create mode 100644 src/util/Mesh.ts create mode 100644 test/util/Mesh.test.ts diff --git a/package.json b/package.json index 8387ae1d2..1e6ccd4d7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mapillary-js", - "version": "5.0.0-beta.5-fb10", + "version": "5.0.0-beta.5-fb11", "description": "A WebGL interactive street imagery library", "license": "MIT", "main": "dist/mapillary.js", diff --git a/src/component/slider/SliderMeshFactory.ts b/src/component/slider/SliderMeshFactory.ts index ad0c9af06..317351d30 100644 --- a/src/component/slider/SliderMeshFactory.ts +++ b/src/component/slider/SliderMeshFactory.ts @@ -4,6 +4,7 @@ import { SliderShaders } from "./SliderShaders"; import { Image } from "../../graph/Image"; import { isFisheye, isSpherical } from "../../geo/Geo"; +import { isDefaultCubeMesh } from "../../util/Mesh"; import { SliderTransform } from "./SliderTransform"; export class SliderMeshFactory { @@ -332,7 +333,9 @@ export class SliderMeshFactory { } private _useMesh(transform: SliderTransform, image: Image): boolean { - return image.mesh.vertices.length && transform.hasValidScale; + return image.mesh.vertices.length > 0 && + !isDefaultCubeMesh(image.mesh) && + transform.hasValidScale; } private _getImageSphereGeo(transform: SliderTransform, image: Image): THREE.BufferGeometry { diff --git a/src/component/util/MeshFactory.ts b/src/component/util/MeshFactory.ts index 297b29df7..31a8d91a3 100644 --- a/src/component/util/MeshFactory.ts +++ b/src/component/util/MeshFactory.ts @@ -8,6 +8,7 @@ import { Camera } from "../../geometry/Camera"; import { resolveShader } from "../../shader/Resolver"; import { GLShader } from "../../shader/Shader"; +import { isDefaultCubeMesh } from "../../util/Mesh"; function makeCameraUniforms(camera: Camera): { [key: string]: IUniform; } { const cameraUniforms: { [key: string]: IUniform; } = {}; @@ -145,7 +146,9 @@ export class MeshFactory { } private _useMesh(transform: Transform, image: Image): boolean { - return image.mesh.vertices.length && transform.hasValidScale; + return image.mesh.vertices.length > 0 && + !isDefaultCubeMesh(image.mesh) && + transform.hasValidScale; } private _getImageSphereGeo(transform: Transform, image: Image): THREE.BufferGeometry { diff --git a/src/util/Mesh.ts b/src/util/Mesh.ts new file mode 100644 index 000000000..972c0eb93 --- /dev/null +++ b/src/util/Mesh.ts @@ -0,0 +1,12 @@ +import { MeshContract } from "../api/contracts/MeshContract"; + +export function isDefaultCubeMesh(mesh: MeshContract): boolean { + if (mesh.vertices.length !== 24 || mesh.faces.length !== 36) { + return false; + } + + const radius = Math.abs(mesh.vertices[0]); + return radius > 0 && mesh.vertices.every( + (coordinate: number): boolean => + Math.abs(Math.abs(coordinate) - radius) < 1e-8); +} diff --git a/test/util/Mesh.test.ts b/test/util/Mesh.test.ts new file mode 100644 index 000000000..053c96c82 --- /dev/null +++ b/test/util/Mesh.test.ts @@ -0,0 +1,36 @@ +import { isDefaultCubeMesh } from "../../src/util/Mesh"; + +const cubeVertices: number[] = [ + -2.8867513459481287, -2.8867513459481287, -2.8867513459481287, + -2.8867513459481287, -2.8867513459481287, 2.8867513459481287, + -2.8867513459481287, 2.8867513459481287, -2.8867513459481287, + -2.8867513459481287, 2.8867513459481287, 2.8867513459481287, + 2.8867513459481287, -2.8867513459481287, -2.8867513459481287, + 2.8867513459481287, -2.8867513459481287, 2.8867513459481287, + 2.8867513459481287, 2.8867513459481287, -2.8867513459481287, + 2.8867513459481287, 2.8867513459481287, 2.8867513459481287, +]; + +const cubeFaces: number[] = [ + 6, 2, 0, 6, 4, 0, 5, 4, 0, 5, 1, 0, + 5, 6, 4, 5, 6, 7, 3, 2, 0, 3, 1, 0, + 3, 6, 2, 3, 6, 7, 3, 5, 1, 3, 5, 7, +]; + +describe("isDefaultCubeMesh", () => { + it("identifies the default cube", () => { + expect(isDefaultCubeMesh({ faces: cubeFaces, vertices: cubeVertices })) + .toBe(true); + }); + + it("keeps reconstructed meshes", () => { + const vertices = cubeVertices.slice(); + vertices[0] = -3.5; + + expect(isDefaultCubeMesh({ faces: cubeFaces, vertices })).toBe(false); + }); + + it("keeps empty meshes", () => { + expect(isDefaultCubeMesh({ faces: [], vertices: [] })).toBe(false); + }); +}); From 2e100caecc5101c80e6c345eaecbbc8b1b439ae5 Mon Sep 17 00:00:00 2001 From: Boris Masis Date: Tue, 8 Sep 2026 04:39:53 -0700 Subject: [PATCH 16/59] Fix fallback panorama arrow alignment --- src/graph/Graph.ts | 5 +---- test/graph/Graph.test.ts | 26 -------------------------- 2 files changed, 1 insertion(+), 30 deletions(-) diff --git a/src/graph/Graph.ts b/src/graph/Graph.ts index 6e928aecf..854b15865 100644 --- a/src/graph/Graph.ts +++ b/src/graph/Graph.ts @@ -44,7 +44,6 @@ import { ImagesContract } from "../api/contracts/ImagesContract"; import { SequenceContract } from "../api/contracts/SequenceContract"; import { CoreImagesContract } from "../api/contracts/CoreImagesContract"; import { CancelMapillaryError } from "../error/CancelMapillaryError"; -import { isSpherical } from "../geo/Geo"; import { geodeticToEnu } from "../geo/GeoCoords"; type NodeTiles = { @@ -2026,9 +2025,7 @@ export class Graph { if (fillNode.computed_rotation == null || fillNode.computed_rotation.length !== 3) { - const compassAngle = isSpherical(fillNode.camera_type) ? - fillNode.compass_angle - 90 : fillNode.compass_angle; - fillNode.computed_rotation = this._graphCalculator.rotationFromCompass(compassAngle, fillNode.exif_orientation); + fillNode.computed_rotation = this._graphCalculator.rotationFromCompass(fillNode.compass_angle, fillNode.exif_orientation); } node.makeComplete(fillNode); diff --git a/test/graph/Graph.test.ts b/test/graph/Graph.test.ts index 845d89e92..2d71d4f99 100644 --- a/test/graph/Graph.test.ts +++ b/test/graph/Graph.test.ts @@ -344,32 +344,6 @@ describe("Graph.cacheFull$", () => { getImages.complete(); }); - it("should align a derived spherical rotation with the panorama heading", (done: Function) => { - const api = new APIWrapper(new DataProvider()); - const calculator = new GraphCalculator(); - const rotation = [1, 2, 3]; - const rotationSpy = spyOn(calculator, "rotationFromCompass") - .and.returnValue(rotation); - const getImages = new Subject(); - spyOn(api, "getImages$").and.returnValue(getImages); - spyOn(api.data.geometry, "lngLatToCellId").and.returnValue("cell-id"); - const graph = new Graph(api, undefined, undefined, calculator); - const fullNode = helper.createImageEnt(); - fullNode.camera_type = "spherical"; - fullNode.compass_angle = 180; - fullNode.computed_rotation = []; - - graph.cacheFull$(fullNode.id).subscribe( - (g: Graph): void => { - expect(rotationSpy).toHaveBeenCalledWith( - 90, fullNode.exif_orientation); - expect(g.getNode(fullNode.id).rotation).toEqual(rotation); - done(); - }); - getImages.next([{ node: fullNode, node_id: fullNode.id }]); - getImages.complete(); - }); - it("should not make additional calls when fetching same node twice", () => { const api = new APIWrapper(new DataProvider()); const calculator = new GraphCalculator(); From 3b8bf88651a47fcec81288d342707fc9c3319636 Mon Sep 17 00:00:00 2001 From: Boris Masis Date: Tue, 8 Sep 2026 05:05:16 -0700 Subject: [PATCH 17/59] Avoid transitions across incompatible camera frames --- src/state/state/StateBase.ts | 12 +++++++++++- test/state/state/StateBase.test.ts | 23 +++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/state/state/StateBase.ts b/src/state/state/StateBase.ts index f909fa7af..087020f4e 100644 --- a/src/state/state/StateBase.ts +++ b/src/state/state/StateBase.ts @@ -12,6 +12,8 @@ import { Image } from "../../graph/Image"; import { IGeometryProvider } from "../../mapillary"; import { connectedComponent } from "../../api/CellMath"; +const MAX_CAMERA_UP_DELTA = Math.PI / 6; + export abstract class StateBase implements IStateBase { protected _spatial: Spatial; protected _geometry: IGeometryProvider; @@ -349,7 +351,8 @@ export abstract class StateBase implements IStateBase { this._currentImage.merged && this._previousImage.merged && this._hasStructure() && - this._withinDistance() + this._withinDistance() && + this._withinCameraUpDelta() ); } @@ -489,6 +492,13 @@ export abstract class StateBase implements IStateBase { previous.mesh.vertices.length > 0; } + private _withinCameraUpDelta(): boolean { + // Blending camera frames with sharply different up vectors visibly + // rolls the scene through an orientation neither image has. + return this._previousCamera.up.angleTo(this._currentCamera.up) <= + MAX_CAMERA_UP_DELTA; + } + private _withinDistance(): boolean { const current = this._currentImage; const previous = this._previousImage; diff --git a/test/state/state/StateBase.test.ts b/test/state/state/StateBase.test.ts index 985d50972..eac7754c8 100644 --- a/test/state/state/StateBase.test.ts +++ b/test/state/state/StateBase.test.ts @@ -118,6 +118,29 @@ describe("StateBase.motionlessTransition", () => { expect(stateBase.motionlessTransition()).toBe(false); }); + it("should be true if camera up vectors diverge", () => { + const state: IStateBase = createState(); + const stateBase: TestStateBase = new TestStateBase(state); + + const image1 = createCompleteImage() as TestImage; + image1.mesh = { + vertices: [0, 0, 0, 1, 1, 1, 2, 2, 2], + faces: [0, 1, 2], + }; + const image2 = createCompleteImage() as TestImage; + image2.mesh = { + vertices: [0, 0, 0, 1, 1, 1, 2, 2, 2], + faces: [0, 1, 2], + }; + + stateBase.set([image1]); + stateBase.set([image2]); + stateBase.previousCamera.up.set(0, 0, 1); + stateBase.currentCamera.up.set(0, 1, 0); + + expect(stateBase.motionlessTransition()).toBe(true); + }); + it("should be true if only previous image has structure", () => { const state: IStateBase = createState(); const stateBase: TestStateBase = new TestStateBase(state); From d2ae749c46165f21458c419d994f1f560e4cf626 Mon Sep 17 00:00:00 2001 From: Boris Masis Date: Tue, 8 Sep 2026 05:14:01 -0700 Subject: [PATCH 18/59] Cross-fade fallback image transitions --- src/state/state/TraversingState.ts | 15 ++++--- test/state/state/TraversingState.test.ts | 56 ++++++++++++++++++++++-- 2 files changed, 62 insertions(+), 9 deletions(-) diff --git a/src/state/state/TraversingState.ts b/src/state/state/TraversingState.ts index 461633a43..20ec7c2bd 100644 --- a/src/state/state/TraversingState.ts +++ b/src/state/state/TraversingState.ts @@ -5,6 +5,7 @@ import { IStateBase } from "../interfaces/IStateBase"; import { Image } from "../../graph/Image"; import { isSpherical } from "../../geo/Geo"; import { isNullImageId } from "../../util/Common"; +import { TransitionMode } from "../TransitionMode"; export class TraversingState extends InteractiveStateBase { @@ -92,7 +93,7 @@ export class TraversingState extends InteractiveStateBase { this._desiredLookat = null; // Orient the new image before it is rendered this frame so a - // motionless (instant) transition lands already facing the + // transition without camera motion lands already facing the // registered direction instead of flashing the carried view. this._applyReorientation(); } @@ -119,11 +120,15 @@ export class TraversingState extends InteractiveStateBase { this._updateZoom(animationSpeed); this._updateLookat(animationSpeed); - this._camera.lerpCameras(this._previousCamera, this._currentCamera, this.alpha); + // Fallback transitions cannot safely interpolate camera geometry, but + // their image alpha can still advance smoothly to produce a dissolve. + const cameraAlpha = this._motionless ? Math.ceil(this._alpha) : this._alpha; + this._camera.lerpCameras(this._previousCamera, this._currentCamera, cameraAlpha); } protected _getAlpha(): number { - return this._motionless ? Math.ceil(this._alpha) : this._alpha; + return this._motionless && this.transitionMode === TransitionMode.Instantaneous ? + Math.ceil(this._alpha) : this._alpha; } protected _setCurrentCamera(): void { @@ -156,8 +161,8 @@ export class TraversingState extends InteractiveStateBase { } private _applyReorientation(): void { - // Only for instant cuts: a smooth transition should ease into the new - // direction, not start already there. + // Only for transitions without camera motion: a spatial transition + // should ease into the new direction, not start already there. if (!this._motionless || this._currentImage == null) { return; } diff --git a/test/state/state/TraversingState.test.ts b/test/state/state/TraversingState.test.ts index e0fdb47d7..177c02529 100644 --- a/test/state/state/TraversingState.test.ts +++ b/test/state/state/TraversingState.test.ts @@ -49,11 +49,11 @@ class TestTraversingState extends TraversingState { } } -function createTestImage(): TestImage { +function createTestImage(id: string = "key", lng: number = 0): TestImage { const image = new TestImage({ - computed_geometry: { lat: 0, lng: 0 }, - id: "key", - geometry: { lat: 0, lng: 0 }, + computed_geometry: { lat: 0, lng }, + id, + geometry: { lat: 0, lng }, sequence: { id: "skey" }, }); image.mesh = { vertices: [], faces: [] }; @@ -305,3 +305,51 @@ describe("TraversingState.previousCamera.lookat", () => { expect(traversingState.previousCamera.lookat.z).toBeCloseTo(lookat.z, precision); }); }); + +describe("TraversingState mesh-less transition", () => { + function createCachedImage(id: string, lng: number): TestImage { + const helper = new ImageHelper(); + const image = createTestImage(id, lng); + image.makeComplete(helper.createSpatialImageEnt()); + image.initializeCache(new ImageCache(new DataProvider())); + image.cacheCamera(new ProjectionService()); + return image; + } + + function createTraversingState(mode: TransitionMode): TestTraversingState { + return new TestTraversingState({ + alpha: 1, + camera: new Camera(), + currentIndex: -1, + geometry: new S2GeometryProvider(), + reference: { alt: 0, lat: 0, lng: 0 }, + trajectory: [], + transitionMode: mode, + zoom: 0, + }); + } + + it("should cross-fade while snapping the camera", () => { + const traversingState = createTraversingState(TransitionMode.Default); + traversingState.set([createCachedImage("previous", 0)]); + traversingState.set([createCachedImage("current", 0.00001)]); + + traversingState.update(0.1); + + expect(traversingState.motionless).toBe(true); + expect(traversingState.alpha).toBeGreaterThan(0); + expect(traversingState.alpha).toBeLessThan(1); + expect(traversingState.camera.position.distanceTo( + traversingState.currentCamera.position)).toBeCloseTo(0); + }); + + it("should preserve instantaneous transition mode", () => { + const traversingState = createTraversingState(TransitionMode.Instantaneous); + traversingState.set([createCachedImage("previous", 0)]); + traversingState.set([createCachedImage("current", 0.00001)]); + + traversingState.update(0.1); + + expect(traversingState.alpha).toBe(1); + }); +}); From 179d33686c2ba3f1453a8e7fd008591e439854cf Mon Sep 17 00:00:00 2001 From: Boris Masis Date: Tue, 8 Sep 2026 05:31:03 -0700 Subject: [PATCH 19/59] Use fast dissolves for incompatible transitions --- src/component/image/ImageGLRenderer.ts | 12 ++++++-- src/state/state/StateBase.ts | 6 ++-- src/state/state/TraversingState.ts | 8 +++++- test/state/state/StateBase.test.ts | 36 ++++++++++++++++++++++++ test/state/state/TraversingState.test.ts | 2 +- 5 files changed, 57 insertions(+), 7 deletions(-) diff --git a/src/component/image/ImageGLRenderer.ts b/src/component/image/ImageGLRenderer.ts index 4eb1eb8bf..58ea01e9f 100644 --- a/src/component/image/ImageGLRenderer.ts +++ b/src/component/image/ImageGLRenderer.ts @@ -257,17 +257,23 @@ export class ImageGLRenderer { } if (previousKey != null) { - if (previousKey !== this._currentKey && previousKey !== this._previousKey) { + const alignPrevious = state.motionless && state.alpha < 1; + if (alignPrevious || + (previousKey !== this._currentKey && previousKey !== this._previousKey)) { + // A dissolve needs both images in the destination camera frame; + // retaining the source mesh exposes reconstruction gaps and warps. + const previousTransform = alignPrevious ? + state.currentTransform : state.previousTransform; const previousMesh = this._factory.createMesh( state.previousImage, - state.previousTransform, + previousTransform, shader); const previousPlane: MeshSceneItem = { mesh: previousMesh, imageId: previousKey, - camera: state.previousImage.camera, + camera: alignPrevious ? state.currentImage.camera : state.previousImage.camera, }; this._scene.updateImagePlanes([previousPlane]); } diff --git a/src/state/state/StateBase.ts b/src/state/state/StateBase.ts index 087020f4e..97a718318 100644 --- a/src/state/state/StateBase.ts +++ b/src/state/state/StateBase.ts @@ -12,6 +12,7 @@ import { Image } from "../../graph/Image"; import { IGeometryProvider } from "../../mapillary"; import { connectedComponent } from "../../api/CellMath"; +const MAX_CAMERA_TRANSITION_DISTANCE = 20; const MAX_CAMERA_UP_DELTA = Math.PI / 6; export abstract class StateBase implements IStateBase { @@ -513,7 +514,8 @@ export abstract class StateBase implements IStateBase { previous.lngLat.lng, previous.lngLat.lat); - // 50 km/h moves 28m in 2s - return distance < 30; + // Beyond the spatial-navigation range, mesh interpolation magnifies + // reconstruction errors into severe zooms and warped intermediate views. + return distance <= MAX_CAMERA_TRANSITION_DISTANCE; } } diff --git a/src/state/state/TraversingState.ts b/src/state/state/TraversingState.ts index 20ec7c2bd..26c98fba3 100644 --- a/src/state/state/TraversingState.ts +++ b/src/state/state/TraversingState.ts @@ -7,6 +7,8 @@ import { isSpherical } from "../../geo/Geo"; import { isNullImageId } from "../../util/Common"; import { TransitionMode } from "../TransitionMode"; +const FALLBACK_TRANSITION_SPEED = 2.5; + export class TraversingState extends InteractiveStateBase { private _baseAlpha: number; @@ -99,7 +101,11 @@ export class TraversingState extends InteractiveStateBase { } let animationSpeed: number = this._animationSpeed * delta / 1e-1 * 6; - this._baseAlpha = Math.min(1, this._baseAlpha + this._speedCoefficient * animationSpeed); + const transitionSpeed = this._motionless && this.transitionMode !== TransitionMode.Instantaneous ? + FALLBACK_TRANSITION_SPEED : 1; + this._baseAlpha = Math.min( + 1, + this._baseAlpha + this._speedCoefficient * animationSpeed * transitionSpeed); if (this._smoothing) { this._alpha = MathUtils.smootherstep(this._baseAlpha, 0, 1); } else { diff --git a/test/state/state/StateBase.test.ts b/test/state/state/StateBase.test.ts index eac7754c8..a652adee1 100644 --- a/test/state/state/StateBase.test.ts +++ b/test/state/state/StateBase.test.ts @@ -118,6 +118,42 @@ describe("StateBase.motionlessTransition", () => { expect(stateBase.motionlessTransition()).toBe(false); }); + it("should be true beyond the spatial navigation range", () => { + const stateBase = new TestStateBase(createState()); + const helper = new ImageHelper(); + + const previousEnt = helper.createImageEnt(); + previousEnt.id = "previous"; + previousEnt.merge_id = "1"; + const previous = new TestImage(previousEnt); + previous.makeComplete(previousEnt); + previous.initializeCache(new ImageCache(new DataProvider())); + previous.cacheCamera(new ProjectionService()); + previous.mesh = { + vertices: [0, 0, 0, 1, 1, 1, 2, 2, 2], + faces: [0, 1, 2], + }; + + const currentEnt = helper.createImageEnt(); + currentEnt.id = "current"; + currentEnt.merge_id = "1"; + currentEnt.computed_geometry = { lat: 0, lng: 0.0002 }; + currentEnt.geometry = currentEnt.computed_geometry; + const current = new TestImage(currentEnt); + current.makeComplete(currentEnt); + current.initializeCache(new ImageCache(new DataProvider())); + current.cacheCamera(new ProjectionService()); + current.mesh = { + vertices: [0, 0, 0, 1, 1, 1, 2, 2, 2], + faces: [0, 1, 2], + }; + + stateBase.set([previous]); + stateBase.set([current]); + + expect(stateBase.motionlessTransition()).toBe(true); + }); + it("should be true if camera up vectors diverge", () => { const state: IStateBase = createState(); const stateBase: TestStateBase = new TestStateBase(state); diff --git a/test/state/state/TraversingState.test.ts b/test/state/state/TraversingState.test.ts index 177c02529..03ab6fe51 100644 --- a/test/state/state/TraversingState.test.ts +++ b/test/state/state/TraversingState.test.ts @@ -337,7 +337,7 @@ describe("TraversingState mesh-less transition", () => { traversingState.update(0.1); expect(traversingState.motionless).toBe(true); - expect(traversingState.alpha).toBeGreaterThan(0); + expect(traversingState.alpha).toBeGreaterThan(0.2); expect(traversingState.alpha).toBeLessThan(1); expect(traversingState.camera.position.distanceTo( traversingState.currentCamera.position)).toBeCloseTo(0); From 16282f5fba010c68909a5a19e5e27bddd531e8b7 Mon Sep 17 00:00:00 2001 From: Boris Masis Date: Tue, 8 Sep 2026 05:31:03 -0700 Subject: [PATCH 20/59] Match fallback arrow spacing to spatial navigation --- src/graph/Graph.ts | 80 ++++++++++++++++++++++++++------ src/graph/GraphService.ts | 48 +++++++++++-------- src/graph/edge/EdgeCalculator.ts | 9 ++++ test/graph/Graph.test.ts | 46 ++++++++++++++++++ 4 files changed, 149 insertions(+), 34 deletions(-) diff --git a/src/graph/Graph.ts b/src/graph/Graph.ts index 854b15865..aaa85e7ac 100644 --- a/src/graph/Graph.ts +++ b/src/graph/Graph.ts @@ -910,24 +910,36 @@ export class Graph { } /** - * Compute spatial edges to the adjacent images in a node's sequence. - * - * @param {string} key - Key of node. - * @returns {Array} Spatial edges to cached adjacent images. - * @throws {GraphMapillaryError} When the node or its sequence is not cached. + * Get sequence targets near the preferred spatial-navigation distance. */ - public getSequenceSpatialEdges(key: string): NavigationEdge[] { + public getSequenceSpatialTargetIds(key: string): string[] { const node: Image = this.getNode(key); if (!(node.sequenceId in this._sequences)) { throw new GraphMapillaryError(`Sequence is not cached (${key}), (${node.sequenceId})`); } - const sequence: Sequence = this._sequences[node.sequenceId].sequence; - const prevKey: string = sequence.findPrev(node.id); - const nextKey: string = sequence.findNext(node.id); + const sequence = this._sequences[node.sequenceId].sequence; + return [ + this._getSequenceSpatialTarget(node, sequence, -1), + this._getSequenceSpatialTarget(node, sequence, 1), + ]; + } + + /** + * Compute spatial edges to nearby images in a node's sequence. + * + * @param {string} key - Key of node. + * @returns {Array} Spatial edges to cached sequence images. + * @throws {GraphMapillaryError} When the node or its sequence is not cached. + */ + public getSequenceSpatialEdges(key: string): NavigationEdge[] { + const node: Image = this.getNode(key); + const targetKeys = this.getSequenceSpatialTargetIds(key); + const prevKey = targetKeys[0]; + const nextKey = targetKeys[1]; const potentialNodes: Image[] = []; - for (const candidateKey of [prevKey, nextKey]) { + for (const candidateKey of targetKeys) { if (candidateKey == null || !this.hasNode(candidateKey)) { continue; } @@ -938,7 +950,7 @@ export class Graph { } } - const fallbackKeys: string[] = [prevKey, nextKey] + const fallbackKeys = targetKeys .filter((fallbackKey: string): boolean => fallbackKey != null); return this._computeSpatialEdges(node, potentialNodes, prevKey, nextKey, fallbackKeys); @@ -957,10 +969,10 @@ export class Graph { } const node: Image = this.getNode(key); - const sequence: Sequence = this._sequences[node.sequenceId].sequence; - const prevKey: string = sequence.findPrev(node.id); - const nextKey: string = sequence.findNext(node.id); - const fallbackKeys: string[] = [prevKey, nextKey] + const targetKeys = this.getSequenceSpatialTargetIds(key); + const prevKey = targetKeys[0]; + const nextKey = targetKeys[1]; + const fallbackKeys = targetKeys .filter((fallbackKey: string): boolean => fallbackKey != null); const allSpatialNodes: { [key: string]: Image; } = this._requiredSpatialArea[key].all; const potentialNodes: Image[] = []; @@ -1822,6 +1834,44 @@ export class Graph { this._filterSubscription.unsubscribe(); } + private _getSequenceSpatialTarget( + node: Image, + sequence: Sequence, + direction: number): string { + + const index = sequence.imageIds.indexOf(node.id); + const adjacentIndex = index + direction; + if (index < 0 || adjacentIndex < 0 || adjacentIndex >= sequence.imageIds.length) { + return null; + } + + const adjacentId = sequence.imageIds[adjacentIndex]; + if (!this.hasNode(adjacentId) || !this.getNode(adjacentId).complete) { + return adjacentId; + } + + const adjacent = this.getNode(adjacentId); + const enu = geodeticToEnu( + adjacent.lngLat.lng, + adjacent.lngLat.lat, + adjacent.computedAltitude, + node.lngLat.lng, + node.lngLat.lat, + node.computedAltitude); + const distance = Math.sqrt(enu[0] * enu[0] + enu[1] * enu[1]); + if (!Number.isFinite(distance) || distance < 0.1) { + return adjacentId; + } + + const preferredDistance = this._edgeCalculator.getPreferredSpatialDistance(node); + const offset = Math.max(1, Math.min(10, Math.round(preferredDistance / distance))); + const targetIndex = Math.max( + 0, + Math.min(sequence.imageIds.length - 1, index + direction * offset)); + + return sequence.imageIds[targetIndex]; + } + private _computeSpatialEdges( node: Image, potentialNodes: Image[], diff --git a/src/graph/GraphService.ts b/src/graph/GraphService.ts index 68a4eea3c..0e5a6b849 100644 --- a/src/graph/GraphService.ts +++ b/src/graph/GraphService.ts @@ -492,29 +492,18 @@ export class GraphService { (graph: Graph): Observable => { const node: Image = graph.getNode(id); const sequence: Sequence = graph.getSequence(node.sequenceId); - const adjacentIds: string[] = [ + const adjacentIds = [ sequence.findPrev(id), sequence.findNext(id), ].filter((adjacentId: string): boolean => adjacentId != null); - if (adjacentIds.length === 0) { - return observableOf(graph); - } - - return observableFrom(adjacentIds).pipe( - mergeMap( - (adjacentId: string): Observable => { - if (graph.isCachingFull(adjacentId) || !graph.hasNode(adjacentId)) { - return graph.cacheFull$(adjacentId); - } - - if (graph.isCachingFill(adjacentId) || !graph.getNode(adjacentId).complete) { - return graph.cacheFill$(adjacentId); - } - - return observableOf(graph); - }), - takeLast(1)); + return this._cacheFullImages$(graph, adjacentIds); + }), + mergeMap( + (graph: Graph): Observable => { + const targetIds = graph.getSequenceSpatialTargetIds(id) + .filter((targetId: string): boolean => targetId != null); + return this._cacheFullImages$(graph, targetIds); }), map( (graph: Graph): NavigationEdgeStatus => { @@ -705,6 +694,27 @@ export class GraphService { })); } + private _cacheFullImages$(graph: Graph, ids: string[]): Observable { + if (ids.length === 0) { + return observableOf(graph); + } + + return observableFrom(ids).pipe( + mergeMap( + (id: string): Observable => { + if (graph.isCachingFull(id) || !graph.hasNode(id)) { + return graph.cacheFull$(id); + } + + if (graph.isCachingFill(id) || !graph.getNode(id).complete) { + return graph.cacheFill$(id); + } + + return observableOf(graph); + }), + takeLast(1)); + } + private _abortSubjects(subjects: Subject[]): void { for (const subject of subjects.slice()) { this._removeFromArray(subject, subjects); diff --git a/src/graph/edge/EdgeCalculator.ts b/src/graph/edge/EdgeCalculator.ts index 245487450..f5646405b 100644 --- a/src/graph/edge/EdgeCalculator.ts +++ b/src/graph/edge/EdgeCalculator.ts @@ -49,6 +49,15 @@ export class EdgeCalculator { this._coefficients = coefficients != null ? coefficients : new EdgeCalculatorCoefficients(); } + /** + * Returns the preferred navigation distance for an image's camera type. + */ + public getPreferredSpatialDistance(node: Image): number { + return isSpherical(node.cameraType) ? + this._settings.sphericalPreferredDistance : + this._settings.stepPreferredDistance; + } + /** * Returns the potential edges to destination nodes for a set * of nodes with respect to a source node. diff --git a/test/graph/Graph.test.ts b/test/graph/Graph.test.ts index 2d71d4f99..30d8da7bc 100644 --- a/test/graph/Graph.test.ts +++ b/test/graph/Graph.test.ts @@ -1993,6 +1993,52 @@ describe("Graph.cacheSpatialEdges", () => { expect(getPotentialSpy.calls.first().args[2].indexOf(fullNode.id)).toBe(-1); }); + it("should target the sequence image nearest the preferred distance", () => { + const api = new APIWrapper(new DataProvider()); + const graph = new Graph(api); + const fullNode = helper.createImageEnt(); + fullNode.camera_type = "spherical"; + + spyOn(api.data.geometry, "lngLatToCellId").and.returnValue("cell-id"); + const getImages = new Subject(); + spyOn(api, "getImages$").and.returnValue(getImages); + const getSequence = new Subject(); + spyOn(api, "getSequence$").and.returnValue(getSequence); + + graph.cacheFull$(fullNode.id).subscribe(() => { /*noop*/ }); + getImages.next([{ node: fullNode, node_id: fullNode.id }]); + getImages.complete(); + + graph.cacheNodeSequence$(fullNode.id).subscribe(() => { /*noop*/ }); + getSequence.next({ + id: fullNode.sequence.id, + image_ids: ["prev-target", "prev", fullNode.id, "next", "next-target"], + }); + getSequence.complete(); + + const adjacentNodes: { [id: string]: Image; } = {}; + for (const [id, lng] of [["prev", -0.000027], ["next", 0.000027]] as [string, number][]) { + const imageEnt = helper.createImageEnt(); + imageEnt.id = id; + imageEnt.sequence.id = fullNode.sequence.id; + imageEnt.computed_geometry = { lat: 0, lng }; + imageEnt.geometry = imageEnt.computed_geometry; + const image = new Image(imageEnt); + image.makeComplete(imageEnt); + adjacentNodes[id] = image; + } + + const hasNode = graph.hasNode.bind(graph); + const getNode = graph.getNode.bind(graph); + spyOn(graph, "hasNode").and.callFake( + (key: string): boolean => key in adjacentNodes || hasNode(key)); + spyOn(graph, "getNode").and.callFake( + (key: string): Image => key in adjacentNodes ? adjacentNodes[key] : getNode(key)); + + expect(graph.getSequenceSpatialTargetIds(fullNode.id)) + .toEqual(["prev-target", "next-target"]); + }); + test("should apply filter", () => { const cellId = "cell-id"; const dataProvider = new DataProvider(); From 38b7769d9a8c463983819e9019b8765b27860753 Mon Sep 17 00:00:00 2001 From: Boris Masis Date: Tue, 8 Sep 2026 06:47:15 -0700 Subject: [PATCH 21/59] Avoid spins from reversed computed tracks --- .../reorientation/ReorientationEngine.ts | 18 +++++++++++++----- .../reorientation/ReorientationEngine.test.ts | 19 +++++++++++++++++++ 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/src/component/reorientation/ReorientationEngine.ts b/src/component/reorientation/ReorientationEngine.ts index fd6890956..b9602ee9d 100644 --- a/src/component/reorientation/ReorientationEngine.ts +++ b/src/component/reorientation/ReorientationEngine.ts @@ -400,14 +400,22 @@ export class ReorientationEngine { let speed = dt > 0 ? dist / dt : 0; // SfM geometry can occasionally be displaced or scrambled while the - // original capture track is coherent. An impossible computed speed is - // the signal to use that original track for the whole sequence instead - // of switching sources again when one noisy segment happens to be short. + // original capture track is coherent. Keep one geometry source for the + // sequence once the computed track becomes physically implausible. const hasOriginal = isNum(cur.originalLat) && isNum(cur.originalLng) && isNum(nxt.originalLat) && isNum(nxt.originalLng); - if (hasOriginal && speed > MAX_REASONABLE_SPEED_MPS) { - this._originalGeometrySequences.add(cur.seq); + if (hasOriginal) { + const originalTravel = bearing( + cur.originalLat, cur.originalLng, + nxt.originalLat, nxt.originalLng); + const computedHeadingDelta = angleDelta(travel, cur.cca); + const originalHeadingDelta = angleDelta(originalTravel, cur.cca); + if (speed > MAX_REASONABLE_SPEED_MPS || + (computedHeadingDelta > this._config.outlierMaxDeltaDeg && + originalHeadingDelta < this._config.lowSpeedTurnMaxDeltaDeg)) { + this._originalGeometrySequences.add(cur.seq); + } } if (hasOriginal && this._originalGeometrySequences.has(cur.seq)) { dist = haversineDist( diff --git a/test/component/reorientation/ReorientationEngine.test.ts b/test/component/reorientation/ReorientationEngine.test.ts index 83e417a9e..fd434edda 100644 --- a/test/component/reorientation/ReorientationEngine.test.ts +++ b/test/component/reorientation/ReorientationEngine.test.ts @@ -117,6 +117,25 @@ describe("ReorientationEngine.precompute", () => { expect(engine.get("b").travel).toBeCloseTo(90, 1); }); + it("uses the original track when computed geometry reverses", async () => { + const images: Fixture = { + a: spherical("a", 0, 0, 90, 1000, 0, 0), + b: spherical("b", 0, 0.00018, 90, 3000, 0, 0.00018), + c: spherical("c", 0, 0.00004, 90, 5000, 0, 0.00036), + d: spherical("d", 0, 0.00054, 90, 7000, 0, 0.00054), + }; + const engine = new ReorientationEngine( + provider(images, ["a", "b", "c", "d"])); + + await engine.precompute("a"); + await engine.precompute("b"); + await engine.precompute("c"); + + expect(engine.get("b").travel).toBeCloseTo(90, 1); + expect(engine.get("b").basicX).toBeCloseTo(0.5, 2); + expect(engine.get("c").travel).toBeCloseTo(90, 1); + }); + it("accepts a low-speed step as a turn when compass agrees", async () => { // ~11 m apart but 100 s apart -> ~0.11 m/s (below movingSpeedMps), // yet compass (90) agrees with eastward travel, so it counts. From 8f279165e80419ffc5a68d0a6dfa335e6688a401 Mon Sep 17 00:00:00 2001 From: Boris Masis Date: Tue, 8 Sep 2026 06:57:20 -0700 Subject: [PATCH 22/59] Preserve heading on reorientation fallback --- .../reorientation/ReorientationComponent.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/component/reorientation/ReorientationComponent.ts b/src/component/reorientation/ReorientationComponent.ts index 29b47bb7d..50b846b0d 100644 --- a/src/component/reorientation/ReorientationComponent.ts +++ b/src/component/reorientation/ReorientationComponent.ts @@ -355,8 +355,19 @@ export class ReorientationComponent if (rollDeg == null || rollDeg > MAX_REORIENTATION_ROLL_DEG) { this._navigator.stateService.traverse(); - this._navigator.stateService.setCenter( - this._adoptedView ?? [result.basicX, 0.5]); + let fallbackCenter = + this._adoptedView ?? [result.basicX, 0.5]; + if (this._adoptedView == null && + this._isStep(direction) && + typeof this._incomingBearing === "number" && + typeof result.cca === "number") { + const carriedX = bearingToBasicX( + this._incomingBearing, result.cca); + fallbackCenter = [carriedX, 0.5]; + this._userOffsetX = + wrapDelta(carriedX - result.basicX); + } + this._navigator.stateService.setCenter(fallbackCenter); this._adoptedView = null; return; } From 4e22ea378816040302d76de09a79cb9cb4c66d4f Mon Sep 17 00:00:00 2001 From: Boris Masis Date: Tue, 8 Sep 2026 07:04:45 -0700 Subject: [PATCH 23/59] Preserve smooth transitions within reconstructions --- src/state/state/StateBase.ts | 9 +++++-- test/state/state/StateBase.test.ts | 40 ++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/src/state/state/StateBase.ts b/src/state/state/StateBase.ts index 97a718318..4dbc563f4 100644 --- a/src/state/state/StateBase.ts +++ b/src/state/state/StateBase.ts @@ -508,14 +508,19 @@ export abstract class StateBase implements IStateBase { return true; } + if (current.clusterId != null && + current.clusterId === previous.clusterId) { + return true; + } + const distance = this._spatial.distanceFromLngLat( current.lngLat.lng, current.lngLat.lat, previous.lngLat.lng, previous.lngLat.lat); - // Beyond the spatial-navigation range, mesh interpolation magnifies - // reconstruction errors into severe zooms and warped intermediate views. + // Across reconstruction frames, long-range mesh interpolation magnifies + // alignment errors into severe zooms and warped intermediate views. return distance <= MAX_CAMERA_TRANSITION_DISTANCE; } } diff --git a/test/state/state/StateBase.test.ts b/test/state/state/StateBase.test.ts index a652adee1..ab29f1e3f 100644 --- a/test/state/state/StateBase.test.ts +++ b/test/state/state/StateBase.test.ts @@ -125,6 +125,7 @@ describe("StateBase.motionlessTransition", () => { const previousEnt = helper.createImageEnt(); previousEnt.id = "previous"; previousEnt.merge_id = "1"; + previousEnt.cluster = { id: "previous", url: null }; const previous = new TestImage(previousEnt); previous.makeComplete(previousEnt); previous.initializeCache(new ImageCache(new DataProvider())); @@ -137,6 +138,7 @@ describe("StateBase.motionlessTransition", () => { const currentEnt = helper.createImageEnt(); currentEnt.id = "current"; currentEnt.merge_id = "1"; + currentEnt.cluster = { id: "current", url: null }; currentEnt.computed_geometry = { lat: 0, lng: 0.0002 }; currentEnt.geometry = currentEnt.computed_geometry; const current = new TestImage(currentEnt); @@ -154,6 +156,44 @@ describe("StateBase.motionlessTransition", () => { expect(stateBase.motionlessTransition()).toBe(true); }); + it("should allow distant transitions within one reconstruction", () => { + const stateBase = new TestStateBase(createState()); + const helper = new ImageHelper(); + + const previousEnt = helper.createImageEnt(); + previousEnt.id = "previous"; + previousEnt.merge_id = "1"; + previousEnt.cluster = { id: "same", url: null }; + const previous = new TestImage(previousEnt); + previous.makeComplete(previousEnt); + previous.initializeCache(new ImageCache(new DataProvider())); + previous.cacheCamera(new ProjectionService()); + previous.mesh = { + vertices: [0, 0, 0, 1, 1, 1, 2, 2, 2], + faces: [0, 1, 2], + }; + + const currentEnt = helper.createImageEnt(); + currentEnt.id = "current"; + currentEnt.merge_id = "1"; + currentEnt.cluster = { id: "same", url: null }; + currentEnt.computed_geometry = { lat: 0, lng: 0.0002 }; + currentEnt.geometry = currentEnt.computed_geometry; + const current = new TestImage(currentEnt); + current.makeComplete(currentEnt); + current.initializeCache(new ImageCache(new DataProvider())); + current.cacheCamera(new ProjectionService()); + current.mesh = { + vertices: [0, 0, 0, 1, 1, 1, 2, 2, 2], + faces: [0, 1, 2], + }; + + stateBase.set([previous]); + stateBase.set([current]); + + expect(stateBase.motionlessTransition()).toBe(false); + }); + it("should be true if camera up vectors diverge", () => { const state: IStateBase = createState(); const stateBase: TestStateBase = new TestStateBase(state); From 77642e18b56d1c388adf6092c2ea35c29170613e Mon Sep 17 00:00:00 2001 From: Boris Masis Date: Tue, 8 Sep 2026 07:22:44 -0700 Subject: [PATCH 24/59] Freeze source frame during fallback fades --- src/component/image/ImageGLRenderer.ts | 59 +++++++++++++++++++++----- src/component/util/MeshFactory.ts | 14 +++++- 2 files changed, 61 insertions(+), 12 deletions(-) diff --git a/src/component/image/ImageGLRenderer.ts b/src/component/image/ImageGLRenderer.ts index 58ea01e9f..acc8c7b88 100644 --- a/src/component/image/ImageGLRenderer.ts +++ b/src/component/image/ImageGLRenderer.ts @@ -4,6 +4,7 @@ import { Subscription } from "rxjs"; import { IAnimationState } from "../../state/interfaces/IAnimationState"; import { AnimationFrame } from "../../state/interfaces/AnimationFrame"; import { Image } from "../../graph/Image"; +import { Camera } from "../../geo/Camera"; import { Transform } from "../../geo/Transform"; import { TextureProvider } from "../../tile/TextureProvider"; import { MeshFactory } from "../util/MeshFactory"; @@ -27,6 +28,10 @@ export class ImageGLRenderer { private _frameId: number; private _needsRender: boolean; + private _motionless: boolean; + private _previousCamera: Camera; + private _previousPerspective: THREE.PerspectiveCamera; + constructor() { this._factory = new MeshFactory(); this._scene = new MeshScene(); @@ -41,6 +46,10 @@ export class ImageGLRenderer { this._frameId = 0; this._needsRender = false; + + this._motionless = false; + this._previousCamera = null; + this._previousPerspective = null; } public get frameId(): number { @@ -177,7 +186,9 @@ export class ImageGLRenderer { renderer.render(this._scene.scenePeriphery, perspectiveCamera); renderer.render(this._scene.scene, perspectiveCamera); - renderer.render(this._scene.sceneOld, perspectiveCamera); + renderer.render( + this._scene.sceneOld, + this._getPreviousPerspective(perspectiveCamera)); for (const plane of planes) { (plane.mesh.material).uniforms.opacity.value = this._alpha; @@ -197,9 +208,33 @@ export class ImageGLRenderer { disposeProvider(); } + this._motionless = false; + this._previousCamera = null; + this._previousPerspective = null; this._needsRender = true; } + private _getPreviousPerspective( + perspectiveCamera: THREE.PerspectiveCamera) + : THREE.PerspectiveCamera { + if (!this._motionless || this._previousCamera == null) { + return perspectiveCamera; + } + if (this._previousPerspective == null) { + this._previousPerspective = perspectiveCamera.clone(); + this._previousPerspective.position.copy( + this._previousCamera.position); + this._previousPerspective.up.copy(this._previousCamera.up); + this._previousPerspective.matrixAutoUpdate = true; + this._previousPerspective.lookAt(this._previousCamera.lookat); + this._previousPerspective.matrixAutoUpdate = false; + this._previousPerspective.updateMatrix(); + this._previousPerspective.updateMatrixWorld(false); + } + + return this._previousPerspective; + } + private _setShader(shader: GLShader, planes: MeshSceneItem[]): void { for (const plane of planes) { const material = plane.mesh.material; @@ -257,23 +292,21 @@ export class ImageGLRenderer { } if (previousKey != null) { - const alignPrevious = state.motionless && state.alpha < 1; - if (alignPrevious || - (previousKey !== this._currentKey && previousKey !== this._previousKey)) { - // A dissolve needs both images in the destination camera frame; - // retaining the source mesh exposes reconstruction gaps and warps. - const previousTransform = alignPrevious ? - state.currentTransform : state.previousTransform; + const freezePrevious = state.motionless && state.alpha < 1; + if (freezePrevious || + (previousKey !== this._currentKey && + previousKey !== this._previousKey)) { const previousMesh = this._factory.createMesh( state.previousImage, - previousTransform, - shader); + state.previousTransform, + shader, + !freezePrevious); const previousPlane: MeshSceneItem = { mesh: previousMesh, imageId: previousKey, - camera: alignPrevious ? state.currentImage.camera : state.previousImage.camera, + camera: state.previousImage.camera, }; this._scene.updateImagePlanes([previousPlane]); } @@ -281,6 +314,10 @@ export class ImageGLRenderer { this._previousKey = previousKey; } + this._motionless = state.motionless && state.alpha < 1; + this._previousCamera = this._motionless ? + state.previousCamera.clone() : null; + this._previousPerspective = null; this._currentKey = currentKey; const currentMesh = this._factory.createMesh( diff --git a/src/component/util/MeshFactory.ts b/src/component/util/MeshFactory.ts index 31a8d91a3..94a00cc17 100644 --- a/src/component/util/MeshFactory.ts +++ b/src/component/util/MeshFactory.ts @@ -70,7 +70,11 @@ export class MeshFactory { this._imageSphereRadius = imageSphereRadius != null ? imageSphereRadius : 200; } - public createMesh(image: Image, transform: Transform, shader: GLShader): THREE.Mesh { + public createMesh( + image: Image, + transform: Transform, + shader: GLShader, + useMesh: boolean = true): THREE.Mesh { const texture = this._createTexture(image.image); const materialParameters = this._createMaterialParameters( @@ -79,6 +83,14 @@ export class MeshFactory { shader); const material = new THREE.ShaderMaterial(materialParameters); + if (!useMesh) { + const geometry = isSpherical(transform.cameraType) ? + this._getFlatImageSphereGeo(transform) : + isFisheye(transform.cameraType) ? + this._getRegularFlatImagePlaneGeoFisheye(transform) : + this._getRegularFlatImagePlaneGeo(transform); + return new THREE.Mesh(geometry, material); + } if (isSpherical(transform.cameraType)) { return this._createImageSphere(image, transform, material); } else if (isFisheye(transform.cameraType)) { From aa918e0b295c596582cff361f6e5f7af48e845c6 Mon Sep 17 00:00:00 2001 From: Boris Masis Date: Tue, 8 Sep 2026 07:31:58 -0700 Subject: [PATCH 25/59] Validate horizon alignment at travel view --- src/component/reorientation/ReorientationComponent.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/component/reorientation/ReorientationComponent.ts b/src/component/reorientation/ReorientationComponent.ts index 50b846b0d..cc374d7e8 100644 --- a/src/component/reorientation/ReorientationComponent.ts +++ b/src/component/reorientation/ReorientationComponent.ts @@ -349,7 +349,7 @@ export class ReorientationComponent return; } - const safetyCenter = this._adoptedView ?? + const safetyCenter = [result.basicX, this._horizonY(result.basicX)]; const rollDeg = this._rollDeg(safetyCenter); if (rollDeg == null || From 9aac82aca880d252ff77aac3b71afe5155240f9f Mon Sep 17 00:00:00 2001 From: Boris Masis Date: Tue, 8 Sep 2026 08:01:36 -0700 Subject: [PATCH 26/59] Prepare mapillary-js fb12 package --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 1e6ccd4d7..fa873b4f7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mapillary-js", - "version": "5.0.0-beta.5-fb11", + "version": "5.0.0-beta.5-fb12", "description": "A WebGL interactive street imagery library", "license": "MIT", "main": "dist/mapillary.js", From a37236d49cb1454281af6096fcbfaf8321e6fb4b Mon Sep 17 00:00:00 2001 From: Boris Masis Date: Wed, 9 Sep 2026 04:30:59 -0700 Subject: [PATCH 27/59] Preserve shared view from sequence endpoints --- .../reorientation/ReorientationComponent.ts | 38 ++++++++++++++----- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/src/component/reorientation/ReorientationComponent.ts b/src/component/reorientation/ReorientationComponent.ts index cc374d7e8..9caaa2fcc 100644 --- a/src/component/reorientation/ReorientationComponent.ts +++ b/src/component/reorientation/ReorientationComponent.ts @@ -56,8 +56,8 @@ export interface ReorientationSettledEvent { * Crossing into a new spherical sequence with a direction arrow keeps the * carried view rather than snapping to travel — the arrow's own transition * already matched the angle — and adopts it as the new sequence's look-around - * offset. Every other way into a new sequence — Next/Prev, map click, shared - * link, fresh load — resets to travel + horizon. + * offset. Every other way into a new sequence — Next/Prev, map click, or a + * fresh load without an explicitly adopted view — resets to travel + horizon. * * Active by default; disable with `component: { reorientation: false }`. * @@ -89,6 +89,7 @@ export class ReorientationComponent private _userZoomOverride: boolean; private _reorientOnSpatialNav: boolean; private _adoptedView: number[]; + private _adoptedSequence: string; private _settled$: Subject = new Subject(); @@ -171,6 +172,8 @@ export class ReorientationComponent public adoptView(basic: number[]): void { this._adoptedView = basic != null && basic.length === 2 ? [basic[0], basic[1]] : null; + this._adoptedSequence = this._adoptedView != null ? + this._imageSequence : null; } /** @@ -265,6 +268,10 @@ export class ReorientationComponent image.image, image.camera); const fromId = this._activeId; + if (this._adoptedView != null && + this._adoptedSequence == null) { + this._adoptedSequence = image.sequenceId; + } this._sequenceChanged = fromId != null && image.sequenceId !== this._imageSequence; if (this._sequenceChanged) { @@ -324,6 +331,11 @@ export class ReorientationComponent return; } const result = engine.get(id); + if (this._adoptedView != null && + this._adoptedSequence != null && + image.sequenceId !== this._adoptedSequence) { + this._clearAdoptedView(); + } if (!isSpherical(image.cameraType)) { this._levelPerspective(id); return; @@ -339,9 +351,10 @@ export class ReorientationComponent if (!result || !result.valid) { // Switching out of Gravity can reset a center queued before // the image loaded, so restore an explicit shared-link view - // after the fallback transition. + // after the fallback transition. Keep it pending because an + // endpoint has no travel result from which to derive the + // offset that its first valid same-sequence neighbor needs. const adoptedView = this._adoptedView; - this._adoptedView = null; this._navigator.stateService.traverse(); if (adoptedView != null) { this._navigator.stateService.setCenter(adoptedView); @@ -368,7 +381,7 @@ export class ReorientationComponent wrapDelta(carriedX - result.basicX); } this._navigator.stateService.setCenter(fallbackCenter); - this._adoptedView = null; + this._clearAdoptedView(); return; } this._navigator.stateService.gravityTraverse(); @@ -418,7 +431,7 @@ export class ReorientationComponent // Opt out to compare against plain carried-view navigation. if (spatialNav && !freshSequence && !this._reorientOnSpatialNav) { - this._adoptedView = null; + this._clearAdoptedView(); return; } @@ -473,7 +486,7 @@ export class ReorientationComponent let viewX = center[0]; if (this._adoptedView != null) { const adopted = this._adoptedView; - this._adoptedView = null; + this._clearAdoptedView(); viewX = adopted[0]; this._userOffsetX = wrapDelta(adopted[0] - result.basicX); @@ -567,6 +580,11 @@ export class ReorientationComponent return ((basicX + this._userOffsetX) % 1 + 1) % 1; } + private _clearAdoptedView(): void { + this._adoptedView = null; + this._adoptedSequence = null; + } + // Spherical is the pano-to-pano step, so it counts as one here. private _isStep(direction: NavigationDirection): boolean { return direction === NavigationDirection.StepLeft || @@ -616,14 +634,14 @@ export class ReorientationComponent private _levelPerspective(id: string): void { if (this._adoptedView != null) { this._navigator.stateService.gravityTraverse(); - this._adoptedView = null; + this._clearAdoptedView(); return; } this._navigator.stateService.getCenter().pipe(first()).subscribe( (): void => { if (this._activeId !== id || this._adoptedView != null) { - this._adoptedView = null; + this._clearAdoptedView(); return; } const target = [0.5, this._horizonY(0.5)]; @@ -633,7 +651,7 @@ export class ReorientationComponent first(), ).subscribe((render: RenderCamera): void => { if (this._activeId !== id || this._adoptedView != null) { - this._adoptedView = null; + this._clearAdoptedView(); return; } const zoom = this._perspectiveAutoZoom( From 361283d2a33f1b4b699a0a0ef41ed64a0d82507d Mon Sep 17 00:00:00 2001 From: Boris Masis Date: Wed, 9 Sep 2026 05:14:36 -0700 Subject: [PATCH 28/59] Keep horizon leveling for steep capture pitch --- src/component/reorientation/ReorientationComponent.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/component/reorientation/ReorientationComponent.ts b/src/component/reorientation/ReorientationComponent.ts index 9caaa2fcc..932664a09 100644 --- a/src/component/reorientation/ReorientationComponent.ts +++ b/src/component/reorientation/ReorientationComponent.ts @@ -28,7 +28,7 @@ import { Navigator } from "../../viewer/Navigator"; // Skip a reorientation when it would move the current view less than this many // degrees (on either axis) — small moves are just jitter. const MIN_REORIENT_DEG = 15; -const MAX_HORIZON_CORRECTION_DEG = 60; +const MAX_HORIZON_CORRECTION_DEG = 75; const MAX_REORIENTATION_ROLL_DEG = 30; const MAX_PERSPECTIVE_AUTO_ZOOM = 0.75; const MAX_PERSPECTIVE_FOV = 125; From fb996961d2c1be2c86db4ec05f2a0666c1d74f13 Mon Sep 17 00:00:00 2001 From: Boris Masis Date: Thu, 10 Sep 2026 00:25:29 -0700 Subject: [PATCH 29/59] Place spherical sequence arrows front and back --- .../direction/DirectionDOMRenderer.ts | 26 ++++++-- .../direction/DirectionDOMRenderer.test.ts | 64 +++++++++++++++++++ 2 files changed, 84 insertions(+), 6 deletions(-) diff --git a/src/component/direction/DirectionDOMRenderer.ts b/src/component/direction/DirectionDOMRenderer.ts index 4be01d650..8932d59be 100644 --- a/src/component/direction/DirectionDOMRenderer.ts +++ b/src/component/direction/DirectionDOMRenderer.ts @@ -38,6 +38,7 @@ export class DirectionDOMRenderer { private _turnEdges: NavigationEdge[]; private _sphericalEdges: NavigationEdge[]; private _sequenceEdgeKeys: string[]; + private _sequenceEdgeDirections: { [key: string]: NavigationDirection }; private _stepDirections: NavigationDirection[]; private _turnDirections: NavigationDirection[]; @@ -64,6 +65,7 @@ export class DirectionDOMRenderer { this._turnEdges = []; this._sphericalEdges = []; this._sequenceEdgeKeys = []; + this._sequenceEdgeDirections = {}; this._stepDirections = [ NavigationDirection.StepForward, @@ -207,6 +209,7 @@ export class DirectionDOMRenderer { this._turnEdges = []; this._sphericalEdges = []; this._sequenceEdgeKeys = []; + this._sequenceEdgeDirections = {}; } private _setEdges(edgeStatus: NavigationEdgeStatus, sequence: Sequence): void { @@ -215,6 +218,7 @@ export class DirectionDOMRenderer { this._turnEdges = []; this._sphericalEdges = []; this._sequenceEdgeKeys = []; + this._sequenceEdgeDirections = {}; for (let edge of edgeStatus.edges) { let direction: NavigationDirection = edge.data.direction; @@ -238,15 +242,19 @@ export class DirectionDOMRenderer { let edges: NavigationEdge[] = this._sphericalEdges .concat(this._stepEdges) .concat(this._turnEdges); + const imageIndex: number = this._image == null ? -1 : sequence.imageIds.indexOf(this._image.id); for (let edge of edges) { let edgeKey: string = edge.target; + const edgeIndex: number = sequence.imageIds.indexOf(edgeKey); + if (edgeIndex < 0) { + continue; + } - for (let sequenceKey of sequence.imageIds) { - if (sequenceKey === edgeKey) { - this._sequenceEdgeKeys.push(edgeKey); - break; - } + this._sequenceEdgeKeys.push(edgeKey); + if (imageIndex > -1 && edgeIndex !== imageIndex) { + this._sequenceEdgeDirections[edgeKey] = edgeIndex < imageIndex ? + NavigationDirection.Prev : NavigationDirection.Next; } } } @@ -256,11 +264,17 @@ export class DirectionDOMRenderer { let arrows: vd.VNode[] = []; for (let sphericalEdge of this._sphericalEdges) { + const sequenceDirection: NavigationDirection = + this._sequenceEdgeDirections[sphericalEdge.target]; + const azimuth: number = sequenceDirection == null ? + sphericalEdge.data.worldMotionAzimuth : + rotation.phi + (sequenceDirection === NavigationDirection.Prev ? Math.PI : 0); + arrows.push( this._createVNodeByKey( navigator, sphericalEdge.target, - sphericalEdge.data.worldMotionAzimuth, + azimuth, rotation, this._calculator.outerRadius, "mapillary-direction-arrow-spherical", diff --git a/test/component/direction/DirectionDOMRenderer.test.ts b/test/component/direction/DirectionDOMRenderer.test.ts index 1b90a9ad0..049b0a711 100644 --- a/test/component/direction/DirectionDOMRenderer.test.ts +++ b/test/component/direction/DirectionDOMRenderer.test.ts @@ -5,6 +5,9 @@ import { ImageHelper } from "../../helper/ImageHelper"; import { Navigator } from "../../../src/viewer/Navigator"; import { Image } from "../../../src/graph/Image"; +import { Sequence } from "../../../src/graph/Sequence"; +import { NavigationDirection } from "../../../src/graph/edge/NavigationDirection"; +import { NavigationEdge } from "../../../src/graph/edge/interfaces/NavigationEdge"; import { DirectionDOMRenderer } from "../../../src/component/direction/DirectionDOMRenderer"; import { DirectionConfiguration } from "../../../src/component/interfaces/DirectionConfiguration"; import { RenderCamera } from "../../../src/render/RenderCamera"; @@ -226,3 +229,64 @@ describe("DirectionDOMRenderer.needsRender", () => { expect(renderer.needsRender).toBe(true); }); }); + +describe("DirectionDOMRenderer.render", () => { + it("should place spherical sequence arrows in front and back", () => { + let configuration: DirectionConfiguration = { + distinguishSequence: true, + maxWidth: 200, + minWidth: 100, + }; + let renderer: DirectionDOMRenderer = new DirectionDOMRenderer(configuration, { height: 1, width: 1 }); + let image: Image = new ImageHelper().createImage("spherical"); + let sequence: Sequence = new Sequence({ + id: image.sequenceId, + image_ids: ["previous", image.id, "next"], + }); + let edges: NavigationEdge[] = [ + { + data: { + direction: NavigationDirection.Spherical, + worldMotionAzimuth: Math.PI / 2, + }, + source: image.id, + target: "next", + }, + { + data: { + direction: NavigationDirection.Spherical, + worldMotionAzimuth: -Math.PI / 2, + }, + source: image.id, + target: "previous", + }, + { + data: { + direction: NavigationDirection.Spherical, + worldMotionAzimuth: Math.PI / 2, + }, + source: image.id, + target: "spatial", + }, + ]; + + renderer.setConfiguration(configuration); + renderer.setImage(image); + renderer.setEdges({ cached: true, edges }, sequence); + + let renderCamera: RenderCamera = new RenderCamera(1, 1, RenderMode.Fill); + renderCamera.camera.up.fromArray([0, 0, 1]); + renderCamera.camera.lookat.fromArray([1, 1, 0]); + renderer.setRenderCamera(renderCamera); + + let navigator: Navigator = new Navigator({ container: "containerid" }); + let arrows: any[] = renderer.render(navigator).children; + + expect(arrows[0].properties.attributes["data-id"]).toBe("next"); + expect(arrows[0].properties.style.transform).toContain("rotate(0deg)"); + expect(arrows[1].properties.attributes["data-id"]).toBe("previous"); + expect(arrows[1].properties.style.transform).toContain("rotate(-180deg)"); + expect(arrows[2].properties.attributes["data-id"]).toBe("spatial"); + expect(arrows[2].properties.style.transform).toContain("rotate(-90deg)"); + }); +}); From 4333a4cc254e03bd33c2aaf2bfa977f45328fec6 Mon Sep 17 00:00:00 2001 From: Boris Masis Date: Thu, 10 Sep 2026 00:50:26 -0700 Subject: [PATCH 30/59] Expose GPS travel bearing for map indicators --- .../reorientation/ReorientationComponent.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/component/reorientation/ReorientationComponent.ts b/src/component/reorientation/ReorientationComponent.ts index 932664a09..258cf23fd 100644 --- a/src/component/reorientation/ReorientationComponent.ts +++ b/src/component/reorientation/ReorientationComponent.ts @@ -159,6 +159,22 @@ export class ReorientationComponent return ((result.travel + offset) % 360 + 360) % 360; } + /** + * The GPS-derived direction of travel for an image, independent of its + * compass orientation and the viewer's look-around offset. + * + * Returns null until the reorientation engine has resolved the image or + * when the sequence cannot provide a valid neighboring segment. + */ + public getTravelBearing(id: string): number | null { + const result = this._engine == null ? null : this._engine.get(id); + if (!result || !result.valid || typeof result.travel !== "number") { + return null; + } + + return ((result.travel % 360) + 360) % 360; + } + /** * Treat the given basic coordinates as the user's look-around offset rather * than reorienting away from them, and land the current image on them. From 503d9da6fa268a3cda96e0196c53b3381a5ff877 Mon Sep 17 00:00:00 2001 From: Boris Masis Date: Thu, 10 Sep 2026 01:19:54 -0700 Subject: [PATCH 31/59] Keep sequence arrows oriented while panning --- .../direction/DirectionDOMRenderer.ts | 34 +++++++++---------- .../direction/DirectionDOMRenderer.test.ts | 33 ++++++++++++++---- 2 files changed, 43 insertions(+), 24 deletions(-) diff --git a/src/component/direction/DirectionDOMRenderer.ts b/src/component/direction/DirectionDOMRenderer.ts index 8932d59be..731c731af 100644 --- a/src/component/direction/DirectionDOMRenderer.ts +++ b/src/component/direction/DirectionDOMRenderer.ts @@ -38,7 +38,7 @@ export class DirectionDOMRenderer { private _turnEdges: NavigationEdge[]; private _sphericalEdges: NavigationEdge[]; private _sequenceEdgeKeys: string[]; - private _sequenceEdgeDirections: { [key: string]: NavigationDirection }; + private _sequenceAzimuthOffset: number; private _stepDirections: NavigationDirection[]; private _turnDirections: NavigationDirection[]; @@ -65,7 +65,7 @@ export class DirectionDOMRenderer { this._turnEdges = []; this._sphericalEdges = []; this._sequenceEdgeKeys = []; - this._sequenceEdgeDirections = {}; + this._sequenceAzimuthOffset = 0; this._stepDirections = [ NavigationDirection.StepForward, @@ -137,6 +137,11 @@ export class DirectionDOMRenderer { */ public setImage(image: Image): void { this._image = image; + this._sequenceAzimuthOffset = image != null && + isSpherical(image.cameraType) && + !Number.isFinite(image.computedCompassAngle) && + Number.isFinite(image.compassAngle) ? + this._spatial.degToRad(90 - image.compassAngle) : 0; this._clearEdges(); this._setNeedsRender(); @@ -209,7 +214,6 @@ export class DirectionDOMRenderer { this._turnEdges = []; this._sphericalEdges = []; this._sequenceEdgeKeys = []; - this._sequenceEdgeDirections = {}; } private _setEdges(edgeStatus: NavigationEdgeStatus, sequence: Sequence): void { @@ -218,7 +222,6 @@ export class DirectionDOMRenderer { this._turnEdges = []; this._sphericalEdges = []; this._sequenceEdgeKeys = []; - this._sequenceEdgeDirections = {}; for (let edge of edgeStatus.edges) { let direction: NavigationDirection = edge.data.direction; @@ -242,7 +245,6 @@ export class DirectionDOMRenderer { let edges: NavigationEdge[] = this._sphericalEdges .concat(this._stepEdges) .concat(this._turnEdges); - const imageIndex: number = this._image == null ? -1 : sequence.imageIds.indexOf(this._image.id); for (let edge of edges) { let edgeKey: string = edge.target; @@ -252,10 +254,6 @@ export class DirectionDOMRenderer { } this._sequenceEdgeKeys.push(edgeKey); - if (imageIndex > -1 && edgeIndex !== imageIndex) { - this._sequenceEdgeDirections[edgeKey] = edgeIndex < imageIndex ? - NavigationDirection.Prev : NavigationDirection.Next; - } } } } @@ -264,17 +262,11 @@ export class DirectionDOMRenderer { let arrows: vd.VNode[] = []; for (let sphericalEdge of this._sphericalEdges) { - const sequenceDirection: NavigationDirection = - this._sequenceEdgeDirections[sphericalEdge.target]; - const azimuth: number = sequenceDirection == null ? - sphericalEdge.data.worldMotionAzimuth : - rotation.phi + (sequenceDirection === NavigationDirection.Prev ? Math.PI : 0); - arrows.push( this._createVNodeByKey( navigator, sphericalEdge.target, - azimuth, + this._getSphericalEdgeAzimuth(sphericalEdge), rotation, this._calculator.outerRadius, "mapillary-direction-arrow-spherical", @@ -286,7 +278,7 @@ export class DirectionDOMRenderer { this._createSphericalToPerspectiveArrow( navigator, stepEdge.target, - stepEdge.data.worldMotionAzimuth, + this._getSphericalEdgeAzimuth(stepEdge), rotation, stepEdge.data.direction)); } @@ -294,6 +286,14 @@ export class DirectionDOMRenderer { return arrows; } + private _getSphericalEdgeAzimuth(edge: NavigationEdge): number { + const sequenceEdge = this._sequenceEdgeKeys.indexOf(edge.target) > -1; + // Unmerged equirectangular captures are world-aligned at their east + // axis even when their compass field describes vehicle travel. + return edge.data.worldMotionAzimuth + + (sequenceEdge ? this._sequenceAzimuthOffset : 0); + } + private _createSphericalToPerspectiveArrow( navigator: Navigator, key: string, diff --git a/test/component/direction/DirectionDOMRenderer.test.ts b/test/component/direction/DirectionDOMRenderer.test.ts index 049b0a711..a8347c219 100644 --- a/test/component/direction/DirectionDOMRenderer.test.ts +++ b/test/component/direction/DirectionDOMRenderer.test.ts @@ -231,14 +231,19 @@ describe("DirectionDOMRenderer.needsRender", () => { }); describe("DirectionDOMRenderer.render", () => { - it("should place spherical sequence arrows in front and back", () => { + it("should align unmerged spherical sequence arrows with the panorama", () => { let configuration: DirectionConfiguration = { distinguishSequence: true, maxWidth: 200, minWidth: 100, }; let renderer: DirectionDOMRenderer = new DirectionDOMRenderer(configuration, { height: 1, width: 1 }); - let image: Image = new ImageHelper().createImage("spherical"); + const imageEnt = new ImageHelper().createImageEnt(); + imageEnt.camera_type = "spherical"; + imageEnt.compass_angle = 180; + imageEnt.computed_compass_angle = null; + let image: Image = new Image(imageEnt); + image.makeComplete(imageEnt); let sequence: Sequence = new Sequence({ id: image.sequenceId, image_ids: ["previous", image.id, "next"], @@ -247,7 +252,7 @@ describe("DirectionDOMRenderer.render", () => { { data: { direction: NavigationDirection.Spherical, - worldMotionAzimuth: Math.PI / 2, + worldMotionAzimuth: -Math.PI / 2, }, source: image.id, target: "next", @@ -255,7 +260,7 @@ describe("DirectionDOMRenderer.render", () => { { data: { direction: NavigationDirection.Spherical, - worldMotionAzimuth: -Math.PI / 2, + worldMotionAzimuth: Math.PI / 2, }, source: image.id, target: "previous", @@ -275,8 +280,7 @@ describe("DirectionDOMRenderer.render", () => { renderer.setEdges({ cached: true, edges }, sequence); let renderCamera: RenderCamera = new RenderCamera(1, 1, RenderMode.Fill); - renderCamera.camera.up.fromArray([0, 0, 1]); - renderCamera.camera.lookat.fromArray([1, 1, 0]); + (renderCamera)._rotation = { phi: -Math.PI, theta: 0 }; renderer.setRenderCamera(renderCamera); let navigator: Navigator = new Navigator({ container: "containerid" }); @@ -287,6 +291,21 @@ describe("DirectionDOMRenderer.render", () => { expect(arrows[1].properties.attributes["data-id"]).toBe("previous"); expect(arrows[1].properties.style.transform).toContain("rotate(-180deg)"); expect(arrows[2].properties.attributes["data-id"]).toBe("spatial"); - expect(arrows[2].properties.style.transform).toContain("rotate(-90deg)"); + expect(arrows[2].properties.style.transform).toContain("rotate(-270deg)"); + + (renderCamera)._rotation = { phi: -Math.PI / 2, theta: 0 }; + renderer.setRenderCamera(renderCamera); + arrows = renderer.render(navigator).children; + + expect(arrows[0].properties.style.transform).toContain("rotate(90deg)"); + expect(arrows[1].properties.style.transform).toContain("rotate(-90deg)"); + expect(arrows[2].properties.style.transform).toContain("rotate(-180deg)"); + + renderer.setImage(image); + renderer.setEdges({ cached: true, edges }, sequence); + arrows = renderer.render(navigator).children; + + expect(arrows[0].properties.style.transform).toContain("rotate(90deg)"); + expect(arrows[1].properties.style.transform).toContain("rotate(-90deg)"); }); }); From 933d22f488c905c821ce6b623d582abe04659ba0 Mon Sep 17 00:00:00 2001 From: Boris Masis Date: Thu, 10 Sep 2026 02:10:37 -0700 Subject: [PATCH 32/59] Reset orientation on direct image jumps --- .../reorientation/ReorientationComponent.ts | 94 ++++++++++--------- 1 file changed, 49 insertions(+), 45 deletions(-) diff --git a/src/component/reorientation/ReorientationComponent.ts b/src/component/reorientation/ReorientationComponent.ts index 258cf23fd..05f3e868f 100644 --- a/src/component/reorientation/ReorientationComponent.ts +++ b/src/component/reorientation/ReorientationComponent.ts @@ -364,6 +364,47 @@ export class ReorientationComponent const meshV = image.mesh && image.mesh.vertices ? image.mesh.vertices.length : -1; const hardCut = meshV <= 0; + const sequenceId = result?.seq ?? image.sequenceId; + const freshSequence = + sequenceId != null && sequenceId !== this._lastSeq; + this._lastSeq = sequenceId; + + // An arrow move already lands at the heading the user was + // looking at, because the state layer carries the view across + // the image change. Crossing into a new sequence that way keeps + // that view and adopts it as this sequence's look-around offset, + // so nothing rotates here and the following in-sequence steps + // preserve the framing the user arrived with. Everything else + // (Next/Prev, map click, shared link, fresh load) resets to + // travel. + const spatialNav = + this._isStep(direction) || this._isTurn(direction); + const leftAnother = fromId != null && fromId !== id; + const fromResult = leftAnother ? engine.get(fromId) : null; + const neighbor = fromResult != null && + (fromResult.nextId === id || fromResult.prevId === id); + // An arrow that lands somewhere other than the image next to + // the one we left is a sideways hop, not a step along the road: + // a parallel pass, or the return leg of a capture that doubles + // back, whose travel direction can be the reverse of ours. + // Facing its travel would swing the user around, so treat it + // like a sequence crossing and keep the carried view. + const lateralHop = + spatialNav && leftAnother && fromResult != null && !neighbor; + const carryView = spatialNav && (freshSequence || lateralHop); + // A directionless landing on an image that isn't a neighbour of + // the one we left is a jump (map click, URL/pKey change), not a + // step: the incoming view says nothing about the new position, + // so there is nothing worth preserving. + const jump = direction == null && leftAnother && !neighbor; + const resetView = (freshSequence || jump) && !carryView; + + if (freshSequence) { + // Drop look-ahead hints from the prior sequence so nothing + // carries over; this sequence registers its own as it goes. + this._navigator.stateService.clearReorientations(); + } + if (!result || !result.valid) { // Switching out of Gravity can reset a center queued before // the image loaded, so restore an explicit shared-link view @@ -372,8 +413,13 @@ export class ReorientationComponent // offset that its first valid same-sequence neighbor needs. const adoptedView = this._adoptedView; this._navigator.stateService.traverse(); + if (resetView) { + this._resetOffset(); + } if (adoptedView != null) { this._navigator.stateService.setCenter(adoptedView); + } else if (resetView) { + this._navigator.stateService.setCenter([0.5, 0.5]); } return; @@ -384,6 +430,9 @@ export class ReorientationComponent if (rollDeg == null || rollDeg > MAX_REORIENTATION_ROLL_DEG) { this._navigator.stateService.traverse(); + if (resetView) { + this._resetOffset(); + } let fallbackCenter = this._adoptedView ?? [result.basicX, 0.5]; if (this._adoptedView == null && @@ -403,45 +452,6 @@ export class ReorientationComponent this._navigator.stateService.gravityTraverse(); this._computedBasicX = result.basicX; - // A fresh load or a jump to another capture lands on a new - // sequence; reorient it even if the landing image's GPS speed - // reads as stationary. Within a sequence, preserve the view when - // not moving rather than spinning on jitter. - const freshSequence = - result.seq != null && result.seq !== this._lastSeq; - this._lastSeq = result.seq; - - // An arrow move already lands at the heading the user was - // looking at, because the state layer carries the view across - // the image change. Crossing into a new sequence that way keeps - // that view and adopts it as this sequence's look-around offset, - // so nothing rotates here and the following in-sequence steps - // preserve the framing the user arrived with. Everything else - // (Next/Prev, map click, shared link, fresh load) resets to - // travel. - const spatialNav = - this._isStep(direction) || this._isTurn(direction); - const leftAnother = fromId != null && fromId !== id; - const fromResult = leftAnother ? engine.get(fromId) : null; - const neighbor = fromResult != null && - (fromResult.nextId === id || fromResult.prevId === id); - // An arrow that lands somewhere other than the image next to - // the one we left is a sideways hop, not a step along the road: - // a parallel pass, or the return leg of a capture that doubles - // back, whose travel direction can be the reverse of ours. - // Facing its travel would swing the user around, so treat it - // like a sequence crossing and keep the carried view. - const lateralHop = - spatialNav && leftAnother && fromResult != null && !neighbor; - const carryView = spatialNav && (freshSequence || lateralHop); - const resetView = freshSequence && !carryView; - - if (freshSequence) { - // Drop look-ahead hints from the prior sequence so nothing - // carries over; this sequence registers its own as it goes. - this._navigator.stateService.clearReorientations(); - } - // Within a sequence an arrow step is where reorientation earns // its keep: the carried view drifts off-axis as the road bends. // Opt out to compare against plain carried-view navigation. @@ -470,12 +480,6 @@ export class ReorientationComponent this._userOffsetY = 0; this._ySeeded = true; } - // A directionless landing on an image that isn't a neighbour of - // the one we left is a jump (map click, URL/pKey change), not a - // step: the incoming view says nothing about the new position, - // so there is nothing worth preserving. - const jump = direction == null && leftAnother && !neighbor; - if (!result.moving && !freshSequence && !jump) { // Low motion between adjacent images — preserve the view // rather than spin on stationary GPS jitter. From 4851865b35bd721be3d673b1b47cc324242d3d7c Mon Sep 17 00:00:00 2001 From: Boris Masis Date: Thu, 10 Sep 2026 03:16:17 -0700 Subject: [PATCH 33/59] Preserve current view on sequence navigation --- .../reorientation/ReorientationComponent.ts | 14 ++++++++++++++ src/state/state/InteractiveStateBase.ts | 3 +++ src/viewer/Navigator.ts | 7 +++++++ 3 files changed, 24 insertions(+) diff --git a/src/component/reorientation/ReorientationComponent.ts b/src/component/reorientation/ReorientationComponent.ts index 05f3e868f..5631c9913 100644 --- a/src/component/reorientation/ReorientationComponent.ts +++ b/src/component/reorientation/ReorientationComponent.ts @@ -383,6 +383,20 @@ export class ReorientationComponent const fromResult = leftAnother ? engine.get(fromId) : null; const neighbor = fromResult != null && (fromResult.nextId === id || fromResult.prevId === id); + // Drag inertia can keep changing the view after mouseDragEnd$. + // For top Next/Previous, use the bearing at navigation time so + // the held offset cannot lag behind the view the user just set. + if ((direction === NavigationDirection.Next || + direction === NavigationDirection.Prev) && neighbor && + fromResult.valid && + typeof fromResult.cca === "number" && + typeof fromResult.basicX === "number" && + typeof this._incomingBearing === "number") { + const incomingX = bearingToBasicX( + this._incomingBearing, fromResult.cca); + this._userOffsetX = + wrapDelta(incomingX - fromResult.basicX); + } // An arrow that lands somewhere other than the image next to // the one we left is a sideways hop, not a step along the road: // a parallel pass, or the return leg of a capture that doubles diff --git a/src/state/state/InteractiveStateBase.ts b/src/state/state/InteractiveStateBase.ts index df0a66492..052e4eaa5 100644 --- a/src/state/state/InteractiveStateBase.ts +++ b/src/state/state/InteractiveStateBase.ts @@ -195,6 +195,9 @@ export abstract class InteractiveStateBase extends StateBase { this._desiredLookat = null; this._requestedRotationDelta = null; this._requestedBasicRotation = null; + this._requestedBasicRotationUnbounded = null; + this._rotationDelta.reset(); + this._basicRotation = [0, 0]; const threshold: number = 0.05 / Math.pow(2, this._zoom); diff --git a/src/viewer/Navigator.ts b/src/viewer/Navigator.ts index e29922f22..fed8dc0fc 100644 --- a/src/viewer/Navigator.ts +++ b/src/viewer/Navigator.ts @@ -189,6 +189,13 @@ export class Navigator { this._abortRequest(`in dir ${NavigationDirection[direction]}`); this._lastMoveDirection = direction; + // Sequence navigation commits the current framing; drag momentum must + // not keep rotating the newly loaded image. + if (direction === NavigationDirection.Next || + direction === NavigationDirection.Prev) { + this.stateService.rotateBasicWithoutInertia([0, 0]); + } + this._loadingService.startLoading(this._loadingName); const image$ = this.stateService.currentImage$.pipe( From 2d68e1a27a81aa695d1b21bc024c4ab243de772a Mon Sep 17 00:00:00 2001 From: Boris Masis Date: Thu, 10 Sep 2026 03:23:03 -0700 Subject: [PATCH 34/59] Face travel on unmerged panorama loads --- .../reorientation/ReorientationComponent.ts | 4 ++++ .../reorientation/ReorientationEngine.ts | 6 +++++- .../reorientation/ReorientationEngine.test.ts | 20 ++++++++++++++++++- 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/component/reorientation/ReorientationComponent.ts b/src/component/reorientation/ReorientationComponent.ts index 5631c9913..40303416b 100644 --- a/src/component/reorientation/ReorientationComponent.ts +++ b/src/component/reorientation/ReorientationComponent.ts @@ -871,6 +871,10 @@ export class ReorientationComponent originalLat: originalLngLat ? originalLngLat.lat : null, originalLng: originalLngLat ? originalLngLat.lng : null, cca: image.compassAngle, + // Unmerged equirectangular pixels use an east-facing axis; their + // raw compass describes travel rather than the panorama center. + viewCompassAngle: isSpherical(image.cameraType) && + !Number.isFinite(image.computedCompassAngle) ? 90 : undefined, cam: image.cameraType, seq: image.sequenceId, ts: image.capturedAt, diff --git a/src/component/reorientation/ReorientationEngine.ts b/src/component/reorientation/ReorientationEngine.ts index b9602ee9d..1a4a0c3dd 100644 --- a/src/component/reorientation/ReorientationEngine.ts +++ b/src/component/reorientation/ReorientationEngine.ts @@ -12,6 +12,8 @@ export interface ReorientationImage { originalLat?: number; originalLng?: number; cca: number; + /** World bearing represented by basic x=0.5 when it differs from CCA. */ + viewCompassAngle?: number; cam: string; seq: string; ts: number; @@ -371,7 +373,9 @@ export class ReorientationEngine { nextId, prevId: idx > 0 ? ids[idx - 1] : undefined, travel: tb, - basicX: bearingToBasicX(tb, cur.cca), + basicX: bearingToBasicX( + tb, + isNum(cur.viewCompassAngle) ? cur.viewCompassAngle : cur.cca), dist, cca: cur.cca, speed, diff --git a/test/component/reorientation/ReorientationEngine.test.ts b/test/component/reorientation/ReorientationEngine.test.ts index fd434edda..606c9d832 100644 --- a/test/component/reorientation/ReorientationEngine.test.ts +++ b/test/component/reorientation/ReorientationEngine.test.ts @@ -67,7 +67,8 @@ function spherical( cca: number, ts: number, originalLat?: number, - originalLng?: number): ReorientationImage { + originalLng?: number, + viewCompassAngle?: number): ReorientationImage { return { id, lat, @@ -75,6 +76,7 @@ function spherical( originalLat, originalLng, cca, + viewCompassAngle, cam: "spherical", seq: "s", ts, @@ -102,6 +104,22 @@ describe("ReorientationEngine.precompute", () => { expect(result.basicX).toBeCloseTo(0.75, 2); }); + it("frames travel against a distinct panorama-view axis", async () => { + const images: Fixture = { + a: spherical("a", 0.0002, 0, 180, 1000, undefined, undefined, 90), + b: spherical("b", 0.0001, 0, 180, 2000, undefined, undefined, 90), + c: spherical("c", 0, 0, 180, 3000, undefined, undefined, 90), + }; + const engine = new ReorientationEngine(provider(images, ["a", "b", "c"])); + + await engine.precompute("b"); + const result = engine.get("b"); + + expect(result.travel).toBeCloseTo(180, 1); + expect(result.cca).toBeCloseTo(180, 1); + expect(result.basicX).toBeCloseTo(0.75, 2); + }); + it("uses the original track when computed speed is impossible", async () => { const images: Fixture = { a: spherical("a", 0, 0, 90, 1000, 0, 0), From ea652b7ea31468d3203cd3b2000a142259d76f6e Mon Sep 17 00:00:00 2001 From: Boris Masis Date: Thu, 10 Sep 2026 03:30:55 -0700 Subject: [PATCH 35/59] Preserve look direction while playback advances --- .../reorientation/ReorientationComponent.ts | 34 ++++++++++++++----- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/src/component/reorientation/ReorientationComponent.ts b/src/component/reorientation/ReorientationComponent.ts index 40303416b..b82ef4a27 100644 --- a/src/component/reorientation/ReorientationComponent.ts +++ b/src/component/reorientation/ReorientationComponent.ts @@ -98,6 +98,7 @@ export class ReorientationComponent // is needed. private _liveBearing: number; private _incomingBearing: number; + private _dragging: boolean = false; // Manual horizontal look-around offset, preserved within a sequence so the // engine doesn't yank the view back to the travel direction on every step. @@ -305,16 +306,27 @@ export class ReorientationComponent // Consume the direction here, synchronously with the landing, so // it attributes to this image and not a later re-emit. const direction = this._navigator.consumeMoveDirection(); - this._reorient(image, direction, fromId); + this._reorient(image, direction, fromId, this._dragging); })); subs.push(this._container.renderService.bearing$.subscribe( (bearing: number): void => { this._liveBearing = bearing; })); + subs.push(this._container.mouseService.mouseDragStart$.subscribe( + (): void => { this._dragging = true; })); + // A finished drag is a genuine user look-around (our own steering goes - // through the state, not pointer events), so capture the offset. + // through the state, not pointer events), so capture the offset. During + // playback, stop residual momentum from spilling into later images. subs.push(this._container.mouseService.mouseDragEnd$.subscribe( - (): void => { this._captureOffset(); })); + (): void => { + if (this._navigator.playService.playing) { + this._navigator.stateService + .rotateBasicWithoutInertia([0, 0]); + } + this._dragging = false; + this._captureOffset(); + })); } protected _deactivate(): void { @@ -326,6 +338,7 @@ export class ReorientationComponent this._sequenceChanged = false; this._appliedPerspectiveZoom = 0; this._userZoomOverride = false; + this._dragging = false; this._resetOffset(); } @@ -336,7 +349,8 @@ export class ReorientationComponent private _reorient( image: Image, direction: NavigationDirection, - fromId: string): void { + fromId: string, + draggingAtNavigation: boolean): void { const id = image.id; const seed = this._seed(image); const engine = this._engine; @@ -383,11 +397,11 @@ export class ReorientationComponent const fromResult = leftAnother ? engine.get(fromId) : null; const neighbor = fromResult != null && (fromResult.nextId === id || fromResult.prevId === id); - // Drag inertia can keep changing the view after mouseDragEnd$. - // For top Next/Previous, use the bearing at navigation time so - // the held offset cannot lag behind the view the user just set. + // The held offset can lag during drag inertia or when playback + // crosses an image mid-drag, so use the bearing at navigation. if ((direction === NavigationDirection.Next || - direction === NavigationDirection.Prev) && neighbor && + direction === NavigationDirection.Prev || + (draggingAtNavigation && this._dragging)) && neighbor && fromResult.valid && typeof fromResult.cca === "number" && typeof fromResult.basicX === "number" && @@ -466,6 +480,10 @@ export class ReorientationComponent this._navigator.stateService.gravityTraverse(); this._computedBasicX = result.basicX; + if (draggingAtNavigation && this._dragging && neighbor) { + return; + } + // Within a sequence an arrow step is where reorientation earns // its keep: the carried view drifts off-axis as the road bends. // Opt out to compare against plain carried-view navigation. From 3e65da7972a778ac0411c73083031b2c7bd07c7e Mon Sep 17 00:00:00 2001 From: Boris Masis Date: Thu, 10 Sep 2026 04:40:28 -0700 Subject: [PATCH 36/59] Smooth initial orientation without reconstruction --- src/component/reorientation/ReorientationComponent.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/component/reorientation/ReorientationComponent.ts b/src/component/reorientation/ReorientationComponent.ts index b82ef4a27..a8bb9df02 100644 --- a/src/component/reorientation/ReorientationComponent.ts +++ b/src/component/reorientation/ReorientationComponent.ts @@ -370,14 +370,13 @@ export class ReorientationComponent this._levelPerspective(id); return; } - // Read mesh now (after the precompute delay) so it's loaded: - // an image with SfM mesh eases to the travel direction, one - // without (disconnected) hard-cuts. The transition type is NOT - // used — an SfM image eases however you arrive (fresh URL, - // in-sequence step, or feed-click jump). + // A disconnected image still hard-cuts after navigation so its + // image change is not followed by a distracting pan. On initial + // load there is no preceding image cut, so use the same smooth + // orientation as a reconstructed image. const meshV = image.mesh && image.mesh.vertices ? image.mesh.vertices.length : -1; - const hardCut = meshV <= 0; + const hardCut = meshV <= 0 && fromId != null; const sequenceId = result?.seq ?? image.sequenceId; const freshSequence = sequenceId != null && sequenceId !== this._lastSeq; From 63f589ae20d29d1a08e1417e2cc42f0eb663d40f Mon Sep 17 00:00:00 2001 From: Boris Masis Date: Thu, 10 Sep 2026 05:16:25 -0700 Subject: [PATCH 37/59] Stabilize navigation across placeholder panoramas --- .../reorientation/ReorientationComponent.ts | 71 ++++++++++++++----- src/component/slider/SliderMeshFactory.ts | 6 +- src/component/util/MeshFactory.ts | 6 +- src/state/state/StateBase.ts | 5 +- src/state/state/TraversingState.ts | 9 ++- src/util/Mesh.ts | 6 ++ test/state/state/StateBase.test.ts | 32 +++++++++ test/util/Mesh.test.ts | 19 ++++- 8 files changed, 119 insertions(+), 35 deletions(-) diff --git a/src/component/reorientation/ReorientationComponent.ts b/src/component/reorientation/ReorientationComponent.ts index a8bb9df02..fb0c8790d 100644 --- a/src/component/reorientation/ReorientationComponent.ts +++ b/src/component/reorientation/ReorientationComponent.ts @@ -22,6 +22,7 @@ import { isSpherical } from "../../geo/Geo"; import { Transform } from "../../geo/Transform"; import { ViewportCoords } from "../../geo/ViewportCoords"; import { RenderCamera } from "../../render/RenderCamera"; +import { hasReconstructionMesh } from "../../util/Mesh"; import { Container } from "../../viewer/Container"; import { Navigator } from "../../viewer/Navigator"; @@ -99,6 +100,7 @@ export class ReorientationComponent private _liveBearing: number; private _incomingBearing: number; private _dragging: boolean = false; + private _userViewChanged: boolean = false; // Manual horizontal look-around offset, preserved within a sequence so the // engine doesn't yank the view back to the travel direction on every step. @@ -261,6 +263,7 @@ export class ReorientationComponent this._sequenceChanged = false; this._appliedPerspectiveZoom = 0; this._userZoomOverride = false; + this._userViewChanged = false; // NOT _adoptedView: it is host intent that can be handed over // before this fires (activation and configure() both re-run // this), and clearing it here silently drops the view the host @@ -306,7 +309,16 @@ export class ReorientationComponent // Consume the direction here, synchronously with the landing, so // it attributes to this image and not a later re-emit. const direction = this._navigator.consumeMoveDirection(); - this._reorient(image, direction, fromId, this._dragging); + const commitSequenceView = this._userViewChanged && + (direction === NavigationDirection.Next || + direction === NavigationDirection.Prev); + this._userViewChanged = false; + this._reorient( + image, + direction, + fromId, + this._dragging, + commitSequenceView); })); subs.push(this._container.renderService.bearing$.subscribe( @@ -325,6 +337,7 @@ export class ReorientationComponent .rotateBasicWithoutInertia([0, 0]); } this._dragging = false; + this._userViewChanged = true; this._captureOffset(); })); } @@ -339,6 +352,7 @@ export class ReorientationComponent this._appliedPerspectiveZoom = 0; this._userZoomOverride = false; this._dragging = false; + this._userViewChanged = false; this._resetOffset(); } @@ -350,7 +364,8 @@ export class ReorientationComponent image: Image, direction: NavigationDirection, fromId: string, - draggingAtNavigation: boolean): void { + draggingAtNavigation: boolean, + commitSequenceView: boolean): void { const id = image.id; const seed = this._seed(image); const engine = this._engine; @@ -374,9 +389,11 @@ export class ReorientationComponent // image change is not followed by a distracting pan. On initial // load there is no preceding image cut, so use the same smooth // orientation as a reconstructed image. - const meshV = image.mesh && image.mesh.vertices ? - image.mesh.vertices.length : -1; - const hardCut = meshV <= 0 && fromId != null; + const hasReconstruction = + hasReconstructionMesh(image.mesh); + const hardCut = !hasReconstruction && fromId != null; + const horizonY = (x: number): number => + hasReconstruction ? this._horizonY(x) : 0.5; const sequenceId = result?.seq ?? image.sequenceId; const freshSequence = sequenceId != null && sequenceId !== this._lastSeq; @@ -396,10 +413,9 @@ export class ReorientationComponent const fromResult = leftAnother ? engine.get(fromId) : null; const neighbor = fromResult != null && (fromResult.nextId === id || fromResult.prevId === id); - // The held offset can lag during drag inertia or when playback - // crosses an image mid-drag, so use the bearing at navigation. - if ((direction === NavigationDirection.Next || - direction === NavigationDirection.Prev || + // A user's drag can keep moving after mouse-up, but automatic + // camera movement must not become a persistent look offset. + if ((commitSequenceView || (draggingAtNavigation && this._dragging)) && neighbor && fromResult.valid && typeof fromResult.cca === "number" && @@ -451,9 +467,9 @@ export class ReorientationComponent return; } - const safetyCenter = - [result.basicX, this._horizonY(result.basicX)]; - const rollDeg = this._rollDeg(safetyCenter); + const safetyCenter = [result.basicX, horizonY(result.basicX)]; + const rollDeg = hasReconstruction ? + this._rollDeg(safetyCenter) : 0; if (rollDeg == null || rollDeg > MAX_REORIENTATION_ROLL_DEG) { this._navigator.stateService.traverse(); @@ -476,8 +492,16 @@ export class ReorientationComponent this._clearAdoptedView(); return; } - this._navigator.stateService.gravityTraverse(); + if (hasReconstruction) { + this._navigator.stateService.gravityTraverse(); + } else { + this._navigator.stateService.traverse(); + } this._computedBasicX = result.basicX; + if (!hasReconstruction && !this._ySeeded) { + this._userOffsetY = 0; + this._ySeeded = true; + } if (draggingAtNavigation && this._dragging && neighbor) { return; @@ -542,7 +566,7 @@ export class ReorientationComponent this._userOffsetX = wrapDelta(adopted[0] - result.basicX); this._userOffsetY = - adopted[1] - this._horizonY(adopted[0]); + adopted[1] - horizonY(adopted[0]); this._ySeeded = true; } else if (carryView && @@ -558,8 +582,7 @@ export class ReorientationComponent } // Seed the held pitch offset once from the loaded view. if (!this._ySeeded) { - this._userOffsetY = - center[1] - this._horizonY(center[0]); + this._userOffsetY = center[1] - horizonY(center[0]); this._ySeeded = true; } // Arrow into a new sequence or a sideways hop: keep the @@ -575,7 +598,7 @@ export class ReorientationComponent const targetX = this._applyOffsetX(result.basicX); const targetY = Math.max( 0, - Math.min(1, this._horizonY(targetX) + this._userOffsetY)); + Math.min(1, horizonY(targetX) + this._userOffsetY)); const dxDeg = Math.abs(wrapDelta(targetX - viewX)) * 360; @@ -881,17 +904,27 @@ export class ReorientationComponent private _seed(image: Image): ReorientationImage { const lngLat = image.lngLat; const originalLngLat = image.originalLngLat; + const hasComputedCompass = + Number.isFinite(image.computedCompassAngle); + const hasReconstruction = hasReconstructionMesh(image.mesh); + // Placeholder geometry can carry a computed pose that is wildly + // inconsistent between neighboring frames. Its raw capture heading is + // the stable center axis of the underlying panorama. + const useOriginalCompass = !hasReconstruction && + hasComputedCompass && + Number.isFinite(image.originalCompassAngle); return { id: image.id, lat: lngLat ? lngLat.lat : null, lng: lngLat ? lngLat.lng : null, originalLat: originalLngLat ? originalLngLat.lat : null, originalLng: originalLngLat ? originalLngLat.lng : null, - cca: image.compassAngle, + cca: useOriginalCompass ? + image.originalCompassAngle : image.compassAngle, // Unmerged equirectangular pixels use an east-facing axis; their // raw compass describes travel rather than the panorama center. viewCompassAngle: isSpherical(image.cameraType) && - !Number.isFinite(image.computedCompassAngle) ? 90 : undefined, + !hasComputedCompass ? 90 : undefined, cam: image.cameraType, seq: image.sequenceId, ts: image.capturedAt, diff --git a/src/component/slider/SliderMeshFactory.ts b/src/component/slider/SliderMeshFactory.ts index 317351d30..5bd9376fd 100644 --- a/src/component/slider/SliderMeshFactory.ts +++ b/src/component/slider/SliderMeshFactory.ts @@ -4,7 +4,7 @@ import { SliderShaders } from "./SliderShaders"; import { Image } from "../../graph/Image"; import { isFisheye, isSpherical } from "../../geo/Geo"; -import { isDefaultCubeMesh } from "../../util/Mesh"; +import { hasReconstructionMesh } from "../../util/Mesh"; import { SliderTransform } from "./SliderTransform"; export class SliderMeshFactory { @@ -333,9 +333,7 @@ export class SliderMeshFactory { } private _useMesh(transform: SliderTransform, image: Image): boolean { - return image.mesh.vertices.length > 0 && - !isDefaultCubeMesh(image.mesh) && - transform.hasValidScale; + return hasReconstructionMesh(image.mesh) && transform.hasValidScale; } private _getImageSphereGeo(transform: SliderTransform, image: Image): THREE.BufferGeometry { diff --git a/src/component/util/MeshFactory.ts b/src/component/util/MeshFactory.ts index 94a00cc17..a02294036 100644 --- a/src/component/util/MeshFactory.ts +++ b/src/component/util/MeshFactory.ts @@ -8,7 +8,7 @@ import { Camera } from "../../geometry/Camera"; import { resolveShader } from "../../shader/Resolver"; import { GLShader } from "../../shader/Shader"; -import { isDefaultCubeMesh } from "../../util/Mesh"; +import { hasReconstructionMesh } from "../../util/Mesh"; function makeCameraUniforms(camera: Camera): { [key: string]: IUniform; } { const cameraUniforms: { [key: string]: IUniform; } = {}; @@ -158,9 +158,7 @@ export class MeshFactory { } private _useMesh(transform: Transform, image: Image): boolean { - return image.mesh.vertices.length > 0 && - !isDefaultCubeMesh(image.mesh) && - transform.hasValidScale; + return hasReconstructionMesh(image.mesh) && transform.hasValidScale; } private _getImageSphereGeo(transform: Transform, image: Image): THREE.BufferGeometry { diff --git a/src/state/state/StateBase.ts b/src/state/state/StateBase.ts index 4dbc563f4..9f531b50b 100644 --- a/src/state/state/StateBase.ts +++ b/src/state/state/StateBase.ts @@ -10,6 +10,7 @@ import { Transform } from "../../geo/Transform"; import { LngLatAlt } from "../../api/interfaces/LngLatAlt"; import { Image } from "../../graph/Image"; import { IGeometryProvider } from "../../mapillary"; +import { hasReconstructionMesh } from "../../util/Mesh"; import { connectedComponent } from "../../api/CellMath"; const MAX_CAMERA_TRANSITION_DISTANCE = 20; @@ -489,8 +490,8 @@ export abstract class StateBase implements IStateBase { const current = this._currentImage; const previous = this._previousImage; - return current.mesh.vertices.length > 0 && - previous.mesh.vertices.length > 0; + return hasReconstructionMesh(current.mesh) && + hasReconstructionMesh(previous.mesh); } private _withinCameraUpDelta(): boolean { diff --git a/src/state/state/TraversingState.ts b/src/state/state/TraversingState.ts index 26c98fba3..6acb98ad5 100644 --- a/src/state/state/TraversingState.ts +++ b/src/state/state/TraversingState.ts @@ -5,6 +5,7 @@ import { IStateBase } from "../interfaces/IStateBase"; import { Image } from "../../graph/Image"; import { isSpherical } from "../../geo/Geo"; import { isNullImageId } from "../../util/Common"; +import { hasReconstructionMesh } from "../../util/Mesh"; import { TransitionMode } from "../TransitionMode"; const FALLBACK_TRANSITION_SPEED = 2.5; @@ -172,11 +173,9 @@ export class TraversingState extends InteractiveStateBase { if (!this._motionless || this._currentImage == null) { return; } - // Only pre-orient (snap) mesh-less images. An image with SfM mesh eases - // to the travel direction, and easing must start from the carried view — - // pre-snapping it here would lose the ease. - if (this._currentImage.mesh != null && - this._currentImage.mesh.vertices.length > 0) { + // Only pre-orient (snap) images without real SfM structure. An image + // with reconstructed geometry eases from the carried view instead. + if (hasReconstructionMesh(this._currentImage.mesh)) { return; } const basic = this._reorientations.get(this._currentImage.id); diff --git a/src/util/Mesh.ts b/src/util/Mesh.ts index 972c0eb93..fa7b75db7 100644 --- a/src/util/Mesh.ts +++ b/src/util/Mesh.ts @@ -1,5 +1,11 @@ import { MeshContract } from "../api/contracts/MeshContract"; +export function hasReconstructionMesh(mesh: MeshContract): boolean { + return mesh != null && + mesh.vertices.length > 0 && + !isDefaultCubeMesh(mesh); +} + export function isDefaultCubeMesh(mesh: MeshContract): boolean { if (mesh.vertices.length !== 24 || mesh.faces.length !== 36) { return false; diff --git a/test/state/state/StateBase.test.ts b/test/state/state/StateBase.test.ts index ab29f1e3f..82a19a83b 100644 --- a/test/state/state/StateBase.test.ts +++ b/test/state/state/StateBase.test.ts @@ -118,6 +118,38 @@ describe("StateBase.motionlessTransition", () => { expect(stateBase.motionlessTransition()).toBe(false); }); + it("should be true for placeholder cube meshes", () => { + const state: IStateBase = createState(); + const stateBase: TestStateBase = new TestStateBase(state); + const helper: ImageHelper = new ImageHelper(); + const vertices = [ + -1, -1, -1, -1, -1, 1, -1, 1, -1, -1, 1, 1, + 1, -1, -1, 1, -1, 1, 1, 1, -1, 1, 1, 1, + ]; + const faces = new Array(36).fill(0); + + const imageEnt1: ImageEnt = helper.createImageEnt(); + imageEnt1.merge_id = "1"; + const image1 = new TestImage(imageEnt1); + image1.makeComplete(imageEnt1); + image1.initializeCache(new ImageCache(new DataProvider())); + image1.cacheCamera(new ProjectionService()); + image1.mesh = { vertices, faces }; + + const imageEnt2: ImageEnt = helper.createImageEnt(); + imageEnt2.merge_id = "1"; + const image2 = new TestImage(imageEnt2); + image2.makeComplete(imageEnt2); + image2.initializeCache(new ImageCache(new DataProvider())); + image2.cacheCamera(new ProjectionService()); + image2.mesh = { vertices, faces }; + + stateBase.set([image1]); + stateBase.set([image2]); + + expect(stateBase.motionlessTransition()).toBe(true); + }); + it("should be true beyond the spatial navigation range", () => { const stateBase = new TestStateBase(createState()); const helper = new ImageHelper(); diff --git a/test/util/Mesh.test.ts b/test/util/Mesh.test.ts index 053c96c82..2cfc0b3f8 100644 --- a/test/util/Mesh.test.ts +++ b/test/util/Mesh.test.ts @@ -1,4 +1,7 @@ -import { isDefaultCubeMesh } from "../../src/util/Mesh"; +import { + hasReconstructionMesh, + isDefaultCubeMesh, +} from "../../src/util/Mesh"; const cubeVertices: number[] = [ -2.8867513459481287, -2.8867513459481287, -2.8867513459481287, @@ -17,6 +20,20 @@ const cubeFaces: number[] = [ 3, 6, 2, 3, 6, 7, 3, 5, 1, 3, 5, 7, ]; +describe("hasReconstructionMesh", () => { + it("requires non-placeholder geometry", () => { + expect(hasReconstructionMesh({ faces: [], vertices: [] })).toBe(false); + expect(hasReconstructionMesh({ + faces: cubeFaces, + vertices: cubeVertices, + })).toBe(false); + expect(hasReconstructionMesh({ + faces: [0, 1, 2], + vertices: [0, 0, 0, 1, 1, 1, 2, 2, 2], + })).toBe(true); + }); +}); + describe("isDefaultCubeMesh", () => { it("identifies the default cube", () => { expect(isDefaultCubeMesh({ faces: cubeFaces, vertices: cubeVertices })) From 0c3796f3c512521f144ec6e0f9967f112ceb9513 Mon Sep 17 00:00:00 2001 From: Boris Masis Date: Thu, 10 Sep 2026 05:30:51 -0700 Subject: [PATCH 38/59] Bump internal package version to fb13 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index fa873b4f7..513193945 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mapillary-js", - "version": "5.0.0-beta.5-fb12", + "version": "5.0.0-beta.5-fb13", "description": "A WebGL interactive street imagery library", "license": "MIT", "main": "dist/mapillary.js", From 1c6f81595f49d9ebf1d2df9baaa319e4361509ab Mon Sep 17 00:00:00 2001 From: Boris Masis Date: Thu, 10 Sep 2026 06:56:25 -0700 Subject: [PATCH 39/59] Report reorientation bearings in map coordinates Preserve the panorama view axis in engine results so public predictions and settled events return geographic bearings even when an unmerged panorama uses a fixed equirectangular axis. --- .../reorientation/ReorientationComponent.ts | 43 +++++++++++++------ .../reorientation/ReorientationEngine.ts | 8 ++-- .../reorientation/ReorientationEngine.test.ts | 1 + 3 files changed, 37 insertions(+), 15 deletions(-) diff --git a/src/component/reorientation/ReorientationComponent.ts b/src/component/reorientation/ReorientationComponent.ts index fb0c8790d..14b316de5 100644 --- a/src/component/reorientation/ReorientationComponent.ts +++ b/src/component/reorientation/ReorientationComponent.ts @@ -12,6 +12,7 @@ import { ReorientationEngine, ReorientationImage, ReorientationProvider, + ReorientationResult, wrapDelta, } from "./ReorientationEngine"; @@ -214,9 +215,12 @@ export class ReorientationComponent return null; } const from = engine.get(this._activeId); - if (from == null || !from.valid) { + if (from == null || !from.valid || + typeof from.cca !== "number" || + typeof from.viewCompassAngle !== "number") { return null; } + const liveBearing = this._mapBearing(from, this._liveBearing); // Answered before looking the target up: anything that is not the // in-sequence neighbour is a sideways hop or a sequence crossing, both // of which carry the view. The engine only caches within the current @@ -224,26 +228,26 @@ export class ReorientationComponent // would return null for exactly the cases the host most needs. const neighbor = from.nextId === id || from.prevId === id; if (!neighbor || !this._reorientOnSpatialNav) { - return this._liveBearing; + return liveBearing; } const to = engine.get(id); if (to == null || !to.valid || - typeof to.cca !== "number" || + typeof to.viewCompassAngle !== "number" || typeof to.travel !== "number") { return null; } if (to.seq == null || to.seq !== this._lastSeq) { - return this._liveBearing; + return liveBearing; } const targetX = this._applyOffsetX(to.basicX); - const viewX = bearingToBasicX(this._liveBearing, to.cca); + const viewX = bearingToBasicX(liveBearing, to.viewCompassAngle); const dxDeg = Math.abs(wrapDelta(targetX - viewX)) * 360; return dxDeg >= MIN_REORIENT_DEG ? - ((((to.cca + (targetX - 0.5) * 360) % 360) + 360) % 360) : - this._liveBearing; + this._bearingForView(to, targetX) : + liveBearing; } protected _activate(): void { @@ -626,15 +630,15 @@ export class ReorientationComponent // offset) no longer matches. Cross-image, so reason in // absolute bearings: the view a neighbor carries in is // where this image ends up. - if (typeof result.cca === "number") { + if (typeof result.viewCompassAngle === "number") { const endX = move ? targetX : viewX; - const endBearing = result.cca + (endX - 0.5) * 360; + const endBearing = this._bearingForView(result, endX); // Carrying the view moves no camera, so a host // watching bearing events would never learn where // this image ended up. Tell it outright. this._settled$.next({ id, - bearing: ((endBearing % 360) + 360) % 360, + bearing: endBearing, }); if (result.nextId) { this._hintNeighbor( @@ -654,6 +658,19 @@ export class ReorientationComponent return ((basicX + this._userOffsetX) % 1 + 1) % 1; } + private _bearingForView( + result: ReorientationResult, + basicX: number): number { + const bearing = result.viewCompassAngle + (basicX - 0.5) * 360; + return ((bearing % 360) + 360) % 360; + } + + private _mapBearing( + result: ReorientationResult, + bearing: number): number { + return ((bearing + result.viewCompassAngle - result.cca) % 360 + 360) % 360; + } + private _clearAdoptedView(): void { this._adoptedView = null; this._adoptedSequence = null; @@ -691,11 +708,13 @@ export class ReorientationComponent return; } const nr = engine.get(id); - if (!nr || !nr.valid || typeof nr.cca !== "number") { + if (!nr || !nr.valid || + typeof nr.viewCompassAngle !== "number") { return; } const nTargetX = this._applyOffsetX(nr.basicX); - const nCarriedX = bearingToBasicX(endBearing, nr.cca); + const nCarriedX = bearingToBasicX( + endBearing, nr.viewCompassAngle); const nDx = Math.abs(wrapDelta(nTargetX - nCarriedX)) * 360; if (nDx >= MIN_REORIENT_DEG) { this._navigator.stateService diff --git a/src/component/reorientation/ReorientationEngine.ts b/src/component/reorientation/ReorientationEngine.ts index 1a4a0c3dd..c33a10280 100644 --- a/src/component/reorientation/ReorientationEngine.ts +++ b/src/component/reorientation/ReorientationEngine.ts @@ -41,6 +41,7 @@ export interface ReorientationResult { basicX?: number; dist?: number; cca?: number; + viewCompassAngle?: number; speed?: number; moving?: boolean; seq?: string; @@ -368,16 +369,17 @@ export class ReorientationEngine { } } + const viewCompassAngle = isNum(cur.viewCompassAngle) ? + cur.viewCompassAngle : cur.cca; const result: ReorientationResult = { valid: true, nextId, prevId: idx > 0 ? ids[idx - 1] : undefined, travel: tb, - basicX: bearingToBasicX( - tb, - isNum(cur.viewCompassAngle) ? cur.viewCompassAngle : cur.cca), + basicX: bearingToBasicX(tb, viewCompassAngle), dist, cca: cur.cca, + viewCompassAngle, speed, moving, seq: cur.seq, diff --git a/test/component/reorientation/ReorientationEngine.test.ts b/test/component/reorientation/ReorientationEngine.test.ts index 606c9d832..e418e3cc7 100644 --- a/test/component/reorientation/ReorientationEngine.test.ts +++ b/test/component/reorientation/ReorientationEngine.test.ts @@ -117,6 +117,7 @@ describe("ReorientationEngine.precompute", () => { expect(result.travel).toBeCloseTo(180, 1); expect(result.cca).toBeCloseTo(180, 1); + expect(result.viewCompassAngle).toBeCloseTo(90, 1); expect(result.basicX).toBeCloseTo(0.75, 2); }); From b176d0f3f1db74037ade5f068e4d33fd0b101e86 Mon Sep 17 00:00:00 2001 From: Boris Masis Date: Thu, 17 Sep 2026 04:25:26 -0700 Subject: [PATCH 40/59] Allow valid tilted reconstructions --- src/component/reorientation/ReorientationComponent.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/component/reorientation/ReorientationComponent.ts b/src/component/reorientation/ReorientationComponent.ts index 14b316de5..f5d6e75a6 100644 --- a/src/component/reorientation/ReorientationComponent.ts +++ b/src/component/reorientation/ReorientationComponent.ts @@ -31,7 +31,7 @@ import { Navigator } from "../../viewer/Navigator"; // degrees (on either axis) — small moves are just jitter. const MIN_REORIENT_DEG = 15; const MAX_HORIZON_CORRECTION_DEG = 75; -const MAX_REORIENTATION_ROLL_DEG = 30; +const MAX_REORIENTATION_ROLL_DEG = 45; const MAX_PERSPECTIVE_AUTO_ZOOM = 0.75; const MAX_PERSPECTIVE_FOV = 125; const PERSPECTIVE_EDGE_MARGIN = 1e-3; From 35307912d8b0df8e2316edd8d59b085f7cc7f804 Mon Sep 17 00:00:00 2001 From: Boris Masis Date: Mon, 21 Sep 2026 05:34:01 -0700 Subject: [PATCH 41/59] Reject inconsistent reconstructed headings --- .../reorientation/ReorientationComponent.ts | 7 +- .../reorientation/ReorientationEngine.ts | 27 +++++++- .../reorientation/ReorientationEngine.test.ts | 67 +++++++++++++++++++ 3 files changed, 98 insertions(+), 3 deletions(-) diff --git a/src/component/reorientation/ReorientationComponent.ts b/src/component/reorientation/ReorientationComponent.ts index f5d6e75a6..cf6f0599e 100644 --- a/src/component/reorientation/ReorientationComponent.ts +++ b/src/component/reorientation/ReorientationComponent.ts @@ -394,7 +394,8 @@ export class ReorientationComponent // load there is no preceding image cut, so use the same smooth // orientation as a reconstructed image. const hasReconstruction = - hasReconstructionMesh(image.mesh); + hasReconstructionMesh(image.mesh) && + result?.computedCompassOutlier !== true; const hardCut = !hasReconstruction && fromId != null; const horizonY = (x: number): number => hasReconstruction ? this._horizonY(x) : 0.5; @@ -940,6 +941,10 @@ export class ReorientationComponent originalLng: originalLngLat ? originalLngLat.lng : null, cca: useOriginalCompass ? image.originalCompassAngle : image.compassAngle, + computedCca: hasComputedCompass ? + image.computedCompassAngle : undefined, + originalCca: Number.isFinite(image.originalCompassAngle) ? + image.originalCompassAngle : undefined, // Unmerged equirectangular pixels use an east-facing axis; their // raw compass describes travel rather than the panorama center. viewCompassAngle: isSpherical(image.cameraType) && diff --git a/src/component/reorientation/ReorientationEngine.ts b/src/component/reorientation/ReorientationEngine.ts index c33a10280..a6c795ea2 100644 --- a/src/component/reorientation/ReorientationEngine.ts +++ b/src/component/reorientation/ReorientationEngine.ts @@ -12,6 +12,8 @@ export interface ReorientationImage { originalLat?: number; originalLng?: number; cca: number; + computedCca?: number; + originalCca?: number; /** World bearing represented by basic x=0.5 when it differs from CCA. */ viewCompassAngle?: number; cam: string; @@ -42,6 +44,7 @@ export interface ReorientationResult { dist?: number; cca?: number; viewCompassAngle?: number; + computedCompassOutlier?: boolean; speed?: number; moving?: boolean; seq?: string; @@ -64,6 +67,7 @@ const RAD = Math.PI / 180; const DEG = 180 / Math.PI; const EARTH_RADIUS_METERS = 6371000; const MAX_REASONABLE_SPEED_MPS = 100; +const MAX_COMPUTED_COMPASS_DELTA_DEG = 45; function isNum(v: number): boolean { return typeof v === "number" && Number.isFinite(v); @@ -369,8 +373,26 @@ export class ReorientationEngine { } } - const viewCompassAngle = isNum(cur.viewCompassAngle) ? - cur.viewCompassAngle : cur.cca; + const hasCompassCalibration = + isNum(cur.computedCca) && + isNum(cur.originalCca) && + isNum(nxt.computedCca) && + isNum(nxt.originalCca); + const currentCompassOffset = hasCompassCalibration ? + (cur.computedCca - cur.originalCca + 360) % 360 : 0; + const nextCompassOffset = hasCompassCalibration ? + (nxt.computedCca - nxt.originalCca + 360) % 360 : 0; + const computedCompassOutlier = + hasCompassCalibration && + angleDelta(cur.computedCca, cur.originalCca) > + MAX_COMPUTED_COMPASS_DELTA_DEG && + angleDelta(currentCompassOffset, nextCompassOffset) > + MAX_COMPUTED_COMPASS_DELTA_DEG && + angleDelta(tb, cur.originalCca) < + cfg.lowSpeedTurnMaxDeltaDeg; + const viewCompassAngle = computedCompassOutlier ? + cur.originalCca : + (isNum(cur.viewCompassAngle) ? cur.viewCompassAngle : cur.cca); const result: ReorientationResult = { valid: true, nextId, @@ -380,6 +402,7 @@ export class ReorientationEngine { dist, cca: cur.cca, viewCompassAngle, + computedCompassOutlier, speed, moving, seq: cur.seq, diff --git a/test/component/reorientation/ReorientationEngine.test.ts b/test/component/reorientation/ReorientationEngine.test.ts index e418e3cc7..31b4c63a8 100644 --- a/test/component/reorientation/ReorientationEngine.test.ts +++ b/test/component/reorientation/ReorientationEngine.test.ts @@ -121,6 +121,73 @@ describe("ReorientationEngine.precompute", () => { expect(result.basicX).toBeCloseTo(0.75, 2); }); + it("uses raw compass when reconstructed heading conflicts with travel", async () => { + const current = spherical("b", 0, 0, 0, 2000); + current.computedCca = 0; + current.originalCca = 90; + const next = spherical("c", 0, 0.0001, 90, 3000); + next.computedCca = 90; + next.originalCca = 90; + const images: Fixture = { + a: spherical("a", 0, -0.0001, 90, 1000), + b: current, + c: next, + }; + const engine = new ReorientationEngine(provider(images, ["a", "b", "c"])); + + await engine.precompute("b"); + const result = engine.get("b"); + + expect(result.computedCompassOutlier).toBe(true); + expect(result.cca).toBeCloseTo(0, 1); + expect(result.viewCompassAngle).toBeCloseTo(90, 1); + expect(result.basicX).toBeCloseTo(0.5, 2); + }); + + it("keeps reconstructed heading when raw compass conflicts with travel", async () => { + const current = spherical("b", 0, 0, 0, 2000); + current.computedCca = 0; + current.originalCca = 180; + const next = spherical("c", 0, 0.0001, 90, 3000); + next.computedCca = 90; + next.originalCca = 90; + const images: Fixture = { + a: spherical("a", 0, -0.0001, 90, 1000), + b: current, + c: next, + }; + const engine = new ReorientationEngine(provider(images, ["a", "b", "c"])); + + await engine.precompute("b"); + const result = engine.get("b"); + + expect(result.computedCompassOutlier).toBe(false); + expect(result.viewCompassAngle).toBeCloseTo(0, 1); + expect(result.basicX).toBeCloseTo(0.75, 2); + }); + + it("keeps a stable reconstructed-to-raw compass calibration", async () => { + const current = spherical("b", 0, 0, 270, 2000); + current.computedCca = 270; + current.originalCca = 90; + const next = spherical("c", 0, 0.0001, 270, 3000); + next.computedCca = 270; + next.originalCca = 90; + const images: Fixture = { + a: spherical("a", 0, -0.0001, 270, 1000), + b: current, + c: next, + }; + const engine = new ReorientationEngine(provider(images, ["a", "b", "c"])); + + await engine.precompute("b"); + const result = engine.get("b"); + + expect(result.computedCompassOutlier).toBe(false); + expect(result.viewCompassAngle).toBeCloseTo(270, 1); + expect(result.basicX).toBeCloseTo(0, 2); + }); + it("uses the original track when computed speed is impossible", async () => { const images: Fixture = { a: spherical("a", 0, 0, 90, 1000, 0, 0), From 272326de129e37edcab082d03d19d6e28b1a7039 Mon Sep 17 00:00:00 2001 From: Boris Masis Date: Mon, 21 Sep 2026 05:56:20 -0700 Subject: [PATCH 42/59] Reject sideways reconstructed tracks --- src/component/reorientation/ReorientationEngine.ts | 4 +++- .../reorientation/ReorientationEngine.test.ts | 13 +++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/component/reorientation/ReorientationEngine.ts b/src/component/reorientation/ReorientationEngine.ts index a6c795ea2..d84a7d4e1 100644 --- a/src/component/reorientation/ReorientationEngine.ts +++ b/src/component/reorientation/ReorientationEngine.ts @@ -68,6 +68,7 @@ const DEG = 180 / Math.PI; const EARTH_RADIUS_METERS = 6371000; const MAX_REASONABLE_SPEED_MPS = 100; const MAX_COMPUTED_COMPASS_DELTA_DEG = 45; +const MAX_COMPUTED_POSITION_HEADING_DELTA_DEG = 45; function isNum(v: number): boolean { return typeof v === "number" && Number.isFinite(v); @@ -441,7 +442,8 @@ export class ReorientationEngine { const computedHeadingDelta = angleDelta(travel, cur.cca); const originalHeadingDelta = angleDelta(originalTravel, cur.cca); if (speed > MAX_REASONABLE_SPEED_MPS || - (computedHeadingDelta > this._config.outlierMaxDeltaDeg && + (computedHeadingDelta > + MAX_COMPUTED_POSITION_HEADING_DELTA_DEG && originalHeadingDelta < this._config.lowSpeedTurnMaxDeltaDeg)) { this._originalGeometrySequences.add(cur.seq); } diff --git a/test/component/reorientation/ReorientationEngine.test.ts b/test/component/reorientation/ReorientationEngine.test.ts index 31b4c63a8..fd813b753 100644 --- a/test/component/reorientation/ReorientationEngine.test.ts +++ b/test/component/reorientation/ReorientationEngine.test.ts @@ -222,6 +222,19 @@ describe("ReorientationEngine.precompute", () => { expect(engine.get("c").travel).toBeCloseTo(90, 1); }); + it("uses the original track when computed geometry points sideways", async () => { + const images: Fixture = { + a: spherical("a", 0, 0, 90, 1000, 0, 0), + b: spherical("b", -0.0000819, 0.0000574, 90, 3000, 0, 0.0001), + }; + const engine = new ReorientationEngine(provider(images, ["a", "b"])); + + await engine.precompute("a"); + + expect(engine.get("a").travel).toBeCloseTo(90, 1); + expect(engine.get("a").basicX).toBeCloseTo(0.5, 2); + }); + it("accepts a low-speed step as a turn when compass agrees", async () => { // ~11 m apart but 100 s apart -> ~0.11 m/s (below movingSpeedMps), // yet compass (90) agrees with eastward travel, so it counts. From 0c8d5b9b7a415967f2e26b9017dd5bf61f98ac5d Mon Sep 17 00:00:00 2001 From: Boris Masis Date: Mon, 21 Sep 2026 06:21:11 -0700 Subject: [PATCH 43/59] Avoid bad-pose flashes on reconstructed fallbacks --- .../reorientation/ReorientationComponent.ts | 6 ++-- src/state/StateContext.ts | 11 +++++-- src/state/StateService.ts | 11 +++++-- src/state/interfaces/IStateBase.ts | 7 +++- src/state/interfaces/IStateContext.ts | 5 ++- src/state/state/StateBase.ts | 17 ++++++---- src/state/state/TraversingState.ts | 25 ++++++++------ test/state/state/TraversingState.test.ts | 33 +++++++++++++++++-- 8 files changed, 88 insertions(+), 27 deletions(-) diff --git a/src/component/reorientation/ReorientationComponent.ts b/src/component/reorientation/ReorientationComponent.ts index cf6f0599e..89f6f3535 100644 --- a/src/component/reorientation/ReorientationComponent.ts +++ b/src/component/reorientation/ReorientationComponent.ts @@ -718,8 +718,10 @@ export class ReorientationComponent endBearing, nr.viewCompassAngle); const nDx = Math.abs(wrapDelta(nTargetX - nCarriedX)) * 360; if (nDx >= MIN_REORIENT_DEG) { - this._navigator.stateService - .setReorientation(id, [nTargetX, targetY]); + this._navigator.stateService.setReorientation( + id, + [nTargetX, targetY], + nr.computedCompassOutlier === true); } }) .catch((): void => { /* skip */ }); diff --git a/src/state/StateContext.ts b/src/state/StateContext.ts index cb659b44f..0cbcb599e 100644 --- a/src/state/StateContext.ts +++ b/src/state/StateContext.ts @@ -2,6 +2,7 @@ import { State } from "./State"; import { TransitionMode } from "./TransitionMode"; import { EulerRotation } from "./interfaces/EulerRotation"; import { IStateContext } from "./interfaces/IStateContext"; +import { ReorientationHint } from "./interfaces/IStateBase"; import { StateBase } from "./state/StateBase"; import { Camera } from "../geo/Camera"; @@ -28,7 +29,7 @@ export class StateContext implements IStateContext { currentIndex: -1, geometry, reference: { alt: 0, lat: 0, lng: 0 }, - reorientations: new Map(), + reorientations: new Map(), trajectory: [], transitionMode: transitionMode == null ? TransitionMode.Default : transitionMode, zoom: 0, @@ -211,8 +212,12 @@ export class StateContext implements IStateContext { this._state.rotateToBasicSmooth(basic); } - public setReorientation(imageId: string, basic: number[]): void { - this._state.setReorientation(imageId, basic); + public setReorientation( + imageId: string, + basic: number[], + forceOnReconstruction: boolean = false): void { + this._state.setReorientation( + imageId, basic, forceOnReconstruction); } public clearReorientations(): void { diff --git a/src/state/StateService.ts b/src/state/StateService.ts index bf8c58570..9b43e68f0 100644 --- a/src/state/StateService.ts +++ b/src/state/StateService.ts @@ -470,8 +470,15 @@ export class StateService { this._invokeContextOperation((context: IStateContext) => { context.rotateToBasicSmooth(basic); }); } - public setReorientation(imageId: string, basic: number[]): void { - this._invokeContextOperation((context: IStateContext) => { context.setReorientation(imageId, basic); }); + public setReorientation( + imageId: string, + basic: number[], + forceOnReconstruction: boolean = false): void { + this._invokeContextOperation( + (context: IStateContext) => { + context.setReorientation( + imageId, basic, forceOnReconstruction); + }); } public clearReorientations(): void { diff --git a/src/state/interfaces/IStateBase.ts b/src/state/interfaces/IStateBase.ts index 7429790e2..8d9745d06 100644 --- a/src/state/interfaces/IStateBase.ts +++ b/src/state/interfaces/IStateBase.ts @@ -4,6 +4,11 @@ import { TransitionMode } from "../TransitionMode"; import { Image } from "../../graph/Image"; import { IGeometryProvider } from "../../mapillary"; +export interface ReorientationHint { + basic: number[]; + forceOnReconstruction: boolean; +} + export interface IStateBase { alpha: number; camera: Camera; @@ -14,7 +19,7 @@ export interface IStateBase { // current on a motionless (instant) transition so it renders already // oriented instead of flashing the carried view for one frame. Shared by // reference across state transitions. - reorientations?: Map; + reorientations?: Map; trajectory: Image[]; transitionMode: TransitionMode; zoom: number; diff --git a/src/state/interfaces/IStateContext.ts b/src/state/interfaces/IStateContext.ts index 0c256571f..d4323b6d6 100644 --- a/src/state/interfaces/IStateContext.ts +++ b/src/state/interfaces/IStateContext.ts @@ -30,7 +30,10 @@ export interface IStateContext extends IAnimationState { rotateBasicWithoutInertia(basicRotation: number[]): void; rotateToBasic(basic: number[]): void; rotateToBasicSmooth(basic: number[]): void; - setReorientation(imageId: string, basic: number[]): void; + setReorientation( + imageId: string, + basic: number[], + forceOnReconstruction?: boolean): void; clearReorientations(): void; move(delta: number): void; moveTo(position: number): void; diff --git a/src/state/state/StateBase.ts b/src/state/state/StateBase.ts index 9f531b50b..62c4bad64 100644 --- a/src/state/state/StateBase.ts +++ b/src/state/state/StateBase.ts @@ -2,7 +2,7 @@ import * as Geo from "../../geo/Geo"; import { TransitionMode } from "../TransitionMode"; import { EulerRotation } from "../interfaces/EulerRotation"; -import { IStateBase } from "../interfaces/IStateBase"; +import { IStateBase, ReorientationHint } from "../interfaces/IStateBase"; import { ArgumentMapillaryError } from "../../error/ArgumentMapillaryError"; import { Camera } from "../../geo/Camera"; import { Spatial } from "../../geo/Spatial"; @@ -41,7 +41,7 @@ export abstract class StateBase implements IStateBase { protected _motionless: boolean; - protected _reorientations: Map; + protected _reorientations: Map; private _referenceThreshold: number; private _referenceCellIds: Set; @@ -58,7 +58,8 @@ export abstract class StateBase implements IStateBase { // Shared by reference across transitions so a reorientation registered // before navigating is available when the target image becomes current. - this._reorientations = state.reorientations || new Map(); + this._reorientations = state.reorientations || + new Map(); this._reference = state.reference; this._referenceCellIds = new Set( @@ -175,12 +176,16 @@ export abstract class StateBase implements IStateBase { return this._transitionMode; } - public get reorientations(): Map { + public get reorientations(): Map { return this._reorientations; } - public setReorientation(imageId: string, basic: number[]): void { - this._reorientations.set(imageId, basic); + public setReorientation( + imageId: string, + basic: number[], + forceOnReconstruction: boolean = false): void { + this._reorientations.set( + imageId, { basic, forceOnReconstruction }); } public clearReorientations(): void { diff --git a/src/state/state/TraversingState.ts b/src/state/state/TraversingState.ts index 6acb98ad5..c3721e7fe 100644 --- a/src/state/state/TraversingState.ts +++ b/src/state/state/TraversingState.ts @@ -70,6 +70,7 @@ export class TraversingState extends InteractiveStateBase { this._setDesiredCenter(); this._setDesiredZoom(); + this._applyReorientation(); if (this._trajectory.length < 3) { this._smoothing = true; @@ -168,25 +169,29 @@ export class TraversingState extends InteractiveStateBase { } private _applyReorientation(): void { - // Only for transitions without camera motion: a spatial transition - // should ease into the new direction, not start already there. - if (!this._motionless || this._currentImage == null) { + if (this._currentImage == null) { return; } - // Only pre-orient (snap) images without real SfM structure. An image - // with reconstructed geometry eases from the carried view instead. - if (hasReconstructionMesh(this._currentImage.mesh)) { + const reorientation = this._reorientations.get(this._currentImage.id); + if (reorientation == null || + !isSpherical(this._currentImage.cameraType)) { return; } - const basic = this._reorientations.get(this._currentImage.id); - if (basic == null || !isSpherical(this._currentImage.cameraType)) { + // A forced hint means the reconstruction was rejected. Convert that + // transition to a fallback cut so the known-bad pose never renders. + if (reorientation.forceOnReconstruction) { + this._motionless = true; + } else if (!this._motionless || + hasReconstructionMesh(this._currentImage.mesh)) { return; } this._currentCamera.lookat.fromArray( - this.currentTransform.unprojectBasic(basic, this._lookatDepth)); + this.currentTransform.unprojectBasic( + reorientation.basic, this._lookatDepth)); const previousTransform = this.previousTransform != null ? this.previousTransform : this.currentTransform; this._previousCamera.lookat.fromArray( - previousTransform.unprojectBasic(basic, this._lookatDepth)); + previousTransform.unprojectBasic( + reorientation.basic, this._lookatDepth)); } } diff --git a/test/state/state/TraversingState.test.ts b/test/state/state/TraversingState.test.ts index 03ab6fe51..8ffae5d6d 100644 --- a/test/state/state/TraversingState.test.ts +++ b/test/state/state/TraversingState.test.ts @@ -307,10 +307,15 @@ describe("TraversingState.previousCamera.lookat", () => { }); describe("TraversingState mesh-less transition", () => { - function createCachedImage(id: string, lng: number): TestImage { + function createCachedImage( + id: string, + lng: number, + spherical: boolean = false): TestImage { const helper = new ImageHelper(); const image = createTestImage(id, lng); - image.makeComplete(helper.createSpatialImageEnt()); + const spatialImage = helper.createSpatialImageEnt(); + spatialImage.camera_type = spherical ? "spherical" : "perspective"; + image.makeComplete(spatialImage); image.initializeCache(new ImageCache(new DataProvider())); image.cacheCamera(new ProjectionService()); return image; @@ -352,4 +357,28 @@ describe("TraversingState mesh-less transition", () => { expect(traversingState.alpha).toBe(1); }); + + it("should force a reorientation hint onto rejected reconstruction", () => { + const current = createCachedImage("current", 0, true); + current.mesh = { + vertices: [0, 0, 1, 1, 0, 1, 0, 1, 1], + faces: [0, 1, 2], + }; + const unforced = createTraversingState(TransitionMode.Default); + unforced.setReorientation("current", [0.25, 0.5]); + unforced.set([current]); + const originalLookat = unforced.currentCamera.lookat.clone(); + + const forced = createTraversingState(TransitionMode.Default); + forced.setReorientation("current", [0.25, 0.5], true); + forced.set([current]); + + expect(forced.motionless).toBe(true); + expect(forced.currentCamera.lookat.equals(originalLookat)).toBe(false); + const expected = new THREE.Vector3().fromArray( + forced.currentTransform.unprojectBasic( + [0.25, 0.5], (forced as any)._lookatDepth)); + expect(forced.currentCamera.lookat.distanceTo(expected)) + .toBeCloseTo(0, 8); + }); }); From 8ad758de7e1635068b3faed3ee1ecb1ca278fd48 Mon Sep 17 00:00:00 2001 From: Boris Masis Date: Mon, 21 Sep 2026 06:50:19 -0700 Subject: [PATCH 44/59] Make reorientation configurable and preserve same-sequence view --- .../interfaces/ReorientationConfiguration.ts | 14 ++++ .../reorientation/ReorientationComponent.ts | 82 ++++++++++++++----- .../reorientation/ReorientationEngine.ts | 2 + 3 files changed, 79 insertions(+), 19 deletions(-) diff --git a/src/component/interfaces/ReorientationConfiguration.ts b/src/component/interfaces/ReorientationConfiguration.ts index 04c37990e..d2cc018d8 100644 --- a/src/component/interfaces/ReorientationConfiguration.ts +++ b/src/component/interfaces/ReorientationConfiguration.ts @@ -18,6 +18,20 @@ import { ComponentConfiguration } from "./ComponentConfiguration"; * ``` */ export interface ReorientationConfiguration extends ComponentConfiguration { + /** + * Whether reconstructed images are automatically leveled to the horizon. + * + * @default true + */ + automaticHorizonLeveling?: boolean; + + /** + * Whether spherical images automatically face their direction of travel. + * + * @default true + */ + reorientToFront?: boolean; + /** * Whether a spatial navigation (the direction component's step, turn and * spherical arrows, and their keyboard equivalents) reorients the landing diff --git a/src/component/reorientation/ReorientationComponent.ts b/src/component/reorientation/ReorientationComponent.ts index 89f6f3535..a735e4e1b 100644 --- a/src/component/reorientation/ReorientationComponent.ts +++ b/src/component/reorientation/ReorientationComponent.ts @@ -89,6 +89,8 @@ export class ReorientationComponent private _sequenceChanged: boolean; private _appliedPerspectiveZoom: number; private _userZoomOverride: boolean; + private _automaticHorizonLeveling: boolean; + private _reorientToFront: boolean; private _reorientOnSpatialNav: boolean; private _adoptedView: number[]; private _adoptedSequence: string; @@ -154,6 +156,15 @@ export class ReorientationComponent if (!result || !result.valid || typeof result.travel !== "number") { return null; } + if (!this._reorientToFront && + result.seq != null && result.seq === this._lastSeq) { + const active = this._engine.get(this._activeId); + if (active?.valid && + typeof active.viewCompassAngle === "number" && + typeof this._liveBearing === "number") { + return this._mapBearing(active, this._liveBearing); + } + } // The look-around offset belongs to the active sequence and is reset on // a cross-sequence jump, so apply it only to same-sequence ids — // otherwise the hover cone predicts travel+offset while the actual @@ -163,6 +174,19 @@ export class ReorientationComponent return ((result.travel + offset) % 360 + 360) % 360; } + /** Resolve an uncached image before returning its predicted view bearing. */ + public getReorientedBearingAsync(id: string): Promise { + const engine = this._engine; + if (engine == null) { + return Promise.resolve(null); + } + + return engine.precompute(id, undefined, 0) + .then((): number | null => + this._engine === engine ? this.getReorientedBearing(id) : null) + .catch((): null => null); + } + /** * The GPS-derived direction of travel for an image, independent of its * compass orientation and the viewer's look-around offset. @@ -227,7 +251,8 @@ export class ReorientationComponent // sequence, so those targets are usually absent and requiring them here // would return null for exactly the cases the host most needs. const neighbor = from.nextId === id || from.prevId === id; - if (!neighbor || !this._reorientOnSpatialNav) { + if (!neighbor || !this._reorientToFront || + !this._reorientOnSpatialNav) { return liveBearing; } @@ -255,6 +280,11 @@ export class ReorientationComponent subs.push(this._configuration$.subscribe( (configuration: ReorientationConfiguration): void => { + this._automaticHorizonLeveling = + configuration.automaticHorizonLeveling ?? + DEFAULT_REORIENTATION_CONFIGURATION.automaticHorizonLeveling; + this._reorientToFront = configuration.reorientToFront ?? + DEFAULT_REORIENTATION_CONFIGURATION.reorientToFront; this._reorientOnSpatialNav = configuration.reorientOnSpatialNav ?? DEFAULT_REORIENTATION_CONFIGURATION.reorientOnSpatialNav; @@ -398,7 +428,8 @@ export class ReorientationComponent result?.computedCompassOutlier !== true; const hardCut = !hasReconstruction && fromId != null; const horizonY = (x: number): number => - hasReconstruction ? this._horizonY(x) : 0.5; + hasReconstruction && this._automaticHorizonLeveling ? + this._horizonY(x) : 0.5; const sequenceId = result?.seq ?? image.sequenceId; const freshSequence = sequenceId != null && sequenceId !== this._lastSeq; @@ -409,9 +440,9 @@ export class ReorientationComponent // the image change. Crossing into a new sequence that way keeps // that view and adopts it as this sequence's look-around offset, // so nothing rotates here and the following in-sequence steps - // preserve the framing the user arrived with. Everything else - // (Next/Prev, map click, shared link, fresh load) resets to - // travel. + // preserve the framing the user arrived with. A direct jump + // within the same sequence preserves that manual offset too; + // entering another sequence resets to travel. const spatialNav = this._isStep(direction) || this._isTurn(direction); const leftAnother = fromId != null && fromId !== id; @@ -440,12 +471,11 @@ export class ReorientationComponent const lateralHop = spatialNav && leftAnother && fromResult != null && !neighbor; const carryView = spatialNav && (freshSequence || lateralHop); - // A directionless landing on an image that isn't a neighbour of - // the one we left is a jump (map click, URL/pKey change), not a - // step: the incoming view says nothing about the new position, - // so there is nothing worth preserving. + // A directionless non-neighbor landing is a map/pKey jump. It + // still bypasses the low-motion guard, while resetView below + // distinguishes a same-capture jump from a new capture. const jump = direction == null && leftAnother && !neighbor; - const resetView = (freshSequence || jump) && !carryView; + const resetView = freshSequence && !carryView; if (freshSequence) { // Drop look-ahead hints from the prior sequence so nothing @@ -473,7 +503,8 @@ export class ReorientationComponent return; } const safetyCenter = [result.basicX, horizonY(result.basicX)]; - const rollDeg = hasReconstruction ? + const rollDeg = hasReconstruction && + this._automaticHorizonLeveling ? this._rollDeg(safetyCenter) : 0; if (rollDeg == null || rollDeg > MAX_REORIENTATION_ROLL_DEG) { @@ -497,13 +528,14 @@ export class ReorientationComponent this._clearAdoptedView(); return; } - if (hasReconstruction) { + if (hasReconstruction && this._automaticHorizonLeveling) { this._navigator.stateService.gravityTraverse(); } else { this._navigator.stateService.traverse(); } this._computedBasicX = result.basicX; - if (!hasReconstruction && !this._ySeeded) { + if ((!hasReconstruction || !this._automaticHorizonLeveling) && + !this._ySeeded) { this._userOffsetY = 0; this._ySeeded = true; } @@ -535,9 +567,12 @@ export class ReorientationComponent // start fresh at travel direction + horizon. A cross-sequence // jump carries the prior image's view, which would otherwise // persist the previous sequence's pitch (e.g. "looking down"). - pitchResetDeg = Math.abs(this._userOffsetY) * 180; + pitchResetDeg = this._automaticHorizonLeveling ? + Math.abs(this._userOffsetY) * 180 : 0; this._userOffsetX = 0; - this._userOffsetY = 0; + if (this._automaticHorizonLeveling) { + this._userOffsetY = 0; + } this._ySeeded = true; } if (!result.moving && !freshSequence && !jump) { @@ -600,10 +635,12 @@ export class ReorientationComponent this._userOffsetX = wrapDelta(viewX - result.basicX); } - const targetX = this._applyOffsetX(result.basicX); - const targetY = Math.max( - 0, - Math.min(1, horizonY(targetX) + this._userOffsetY)); + const targetX = this._reorientToFront ? + this._applyOffsetX(result.basicX) : viewX; + const targetY = this._automaticHorizonLeveling ? + Math.max(0, Math.min( + 1, horizonY(targetX) + this._userOffsetY)) : + center[1]; const dxDeg = Math.abs(wrapDelta(targetX - viewX)) * 360; @@ -700,6 +737,9 @@ export class ReorientationComponent id: string, endBearing: number, targetY: number): void { + if (!this._reorientToFront) { + return; + } // Depth 0: we only need this neighbor's own result cached, not another // forward prefetch cascade (the current image's _reorient already warms // ahead). Avoids re-walking the already-scheduled chain per navigation. @@ -728,6 +768,10 @@ export class ReorientationComponent } private _levelPerspective(id: string): void { + if (!this._automaticHorizonLeveling) { + this._clearAdoptedView(); + return; + } if (this._adoptedView != null) { this._navigator.stateService.gravityTraverse(); this._clearAdoptedView(); diff --git a/src/component/reorientation/ReorientationEngine.ts b/src/component/reorientation/ReorientationEngine.ts index d84a7d4e1..a2857e830 100644 --- a/src/component/reorientation/ReorientationEngine.ts +++ b/src/component/reorientation/ReorientationEngine.ts @@ -52,6 +52,8 @@ export interface ReorientationResult { export const DEFAULT_REORIENTATION_CONFIGURATION: Required = { + automaticHorizonLeveling: true, + reorientToFront: true, reorientOnSpatialNav: true, prefetchAhead: 3, movingSpeedMps: 1, From 793caef3ba4bf0b0ef8db44724c122fbb3edf4df Mon Sep 17 00:00:00 2001 From: Boris Masis Date: Mon, 21 Sep 2026 07:01:22 -0700 Subject: [PATCH 45/59] Preorient rejected poses below turn threshold --- src/component/reorientation/ReorientationComponent.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/component/reorientation/ReorientationComponent.ts b/src/component/reorientation/ReorientationComponent.ts index a735e4e1b..db1f4b215 100644 --- a/src/component/reorientation/ReorientationComponent.ts +++ b/src/component/reorientation/ReorientationComponent.ts @@ -757,11 +757,16 @@ export class ReorientationComponent const nCarriedX = bearingToBasicX( endBearing, nr.viewCompassAngle); const nDx = Math.abs(wrapDelta(nTargetX - nCarriedX)) * 360; - if (nDx >= MIN_REORIENT_DEG) { + const rejectedReconstruction = + nr.computedCompassOutlier === true; + // Even when the final world bearings nearly match, the rejected + // pose can map that bearing to a very different basic x for its + // first frame. Force the fallback cut before it can render. + if (rejectedReconstruction || nDx >= MIN_REORIENT_DEG) { this._navigator.stateService.setReorientation( id, [nTargetX, targetY], - nr.computedCompassOutlier === true); + rejectedReconstruction); } }) .catch((): void => { /* skip */ }); From 9dbcc736239b6d6ac5d27b0e18f99897462c2969 Mon Sep 17 00:00:00 2001 From: Boris Masis Date: Mon, 21 Sep 2026 07:18:12 -0700 Subject: [PATCH 46/59] Reject coupled reconstruction heading and track jumps --- .../reorientation/ReorientationEngine.ts | 5 +++- .../reorientation/ReorientationEngine.test.ts | 23 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/component/reorientation/ReorientationEngine.ts b/src/component/reorientation/ReorientationEngine.ts index a2857e830..b4f52f1c2 100644 --- a/src/component/reorientation/ReorientationEngine.ts +++ b/src/component/reorientation/ReorientationEngine.ts @@ -442,7 +442,10 @@ export class ReorientationEngine { cur.originalLat, cur.originalLng, nxt.originalLat, nxt.originalLng); const computedHeadingDelta = angleDelta(travel, cur.cca); - const originalHeadingDelta = angleDelta(originalTravel, cur.cca); + const originalHeading = isNum(cur.originalCca) ? + cur.originalCca : cur.cca; + const originalHeadingDelta = + angleDelta(originalTravel, originalHeading); if (speed > MAX_REASONABLE_SPEED_MPS || (computedHeadingDelta > MAX_COMPUTED_POSITION_HEADING_DELTA_DEG && diff --git a/test/component/reorientation/ReorientationEngine.test.ts b/test/component/reorientation/ReorientationEngine.test.ts index fd813b753..309d21734 100644 --- a/test/component/reorientation/ReorientationEngine.test.ts +++ b/test/component/reorientation/ReorientationEngine.test.ts @@ -235,6 +235,29 @@ describe("ReorientationEngine.precompute", () => { expect(engine.get("a").basicX).toBeCloseTo(0.5, 2); }); + it("uses raw track and compass when both computed values jump", async () => { + const current = spherical("b", 0, 0.00018, 0, 3000, 0, 0.00018); + current.computedCca = 0; + current.originalCca = 90; + const next = spherical("c", 0, 0.00004, 90, 5000, 0, 0.00036); + next.computedCca = 90; + next.originalCca = 90; + const images: Fixture = { + a: spherical("a", 0, 0, 90, 1000, 0, 0), + b: current, + c: next, + }; + const engine = new ReorientationEngine(provider(images, ["a", "b", "c"])); + + await engine.precompute("b"); + const result = engine.get("b"); + + expect(result.travel).toBeCloseTo(90, 1); + expect(result.computedCompassOutlier).toBe(true); + expect(result.viewCompassAngle).toBeCloseTo(90, 1); + expect(result.basicX).toBeCloseTo(0.5, 2); + }); + it("accepts a low-speed step as a turn when compass agrees", async () => { // ~11 m apart but 100 s apart -> ~0.11 m/s (below movingSpeedMps), // yet compass (90) agrees with eastward travel, so it counts. From aee23df784a1fc561da51c1f8c22250566ef20c8 Mon Sep 17 00:00:00 2001 From: Boris Masis Date: Mon, 21 Sep 2026 07:47:04 -0700 Subject: [PATCH 47/59] Cut transitions across reconstruction calibration jumps --- .../reorientation/ReorientationComponent.ts | 15 +++--- .../reorientation/ReorientationEngine.ts | 49 ++++++++++++++----- .../reorientation/ReorientationEngine.test.ts | 36 ++++++++++++++ 3 files changed, 82 insertions(+), 18 deletions(-) diff --git a/src/component/reorientation/ReorientationComponent.ts b/src/component/reorientation/ReorientationComponent.ts index db1f4b215..eac051ee7 100644 --- a/src/component/reorientation/ReorientationComponent.ts +++ b/src/component/reorientation/ReorientationComponent.ts @@ -757,16 +757,17 @@ export class ReorientationComponent const nCarriedX = bearingToBasicX( endBearing, nr.viewCompassAngle); const nDx = Math.abs(wrapDelta(nTargetX - nCarriedX)) * 360; - const rejectedReconstruction = - nr.computedCompassOutlier === true; - // Even when the final world bearings nearly match, the rejected - // pose can map that bearing to a very different basic x for its - // first frame. Force the fallback cut before it can render. - if (rejectedReconstruction || nDx >= MIN_REORIENT_DEG) { + const unsafeTransition = + nr.computedCompassOutlier === true || + nr.reconstructionDiscontinuity === true; + // Even when the final world bearings nearly match, a rejected + // or discontinuous pose can map that bearing to a very different + // basic x for its first frame. Cut before it can render. + if (unsafeTransition || nDx >= MIN_REORIENT_DEG) { this._navigator.stateService.setReorientation( id, [nTargetX, targetY], - rejectedReconstruction); + unsafeTransition); } }) .catch((): void => { /* skip */ }); diff --git a/src/component/reorientation/ReorientationEngine.ts b/src/component/reorientation/ReorientationEngine.ts index b4f52f1c2..b563d9cfe 100644 --- a/src/component/reorientation/ReorientationEngine.ts +++ b/src/component/reorientation/ReorientationEngine.ts @@ -45,6 +45,8 @@ export interface ReorientationResult { cca?: number; viewCompassAngle?: number; computedCompassOutlier?: boolean; + reconstructionDiscontinuity?: boolean; + computedCompassOffset?: number; speed?: number; moving?: boolean; seq?: string; @@ -125,6 +127,8 @@ interface PrevContext { moving: boolean; speed: number; travel: number; + compassOffset?: number; + computedCompassOutlier?: boolean; } interface Segment { @@ -263,6 +267,8 @@ export class ReorientationEngine { moving: pd.moving, speed: pd.speed, travel: pd.travel, + compassOffset: pd.computedCompassOffset, + computedCompassOutlier: pd.computedCompassOutlier, }; break; } @@ -288,11 +294,16 @@ export class ReorientationEngine { pMoving = true; } } + const compassOffset = + isNum(prev.computedCca) && isNum(prev.originalCca) ? + (prev.computedCca - prev.originalCca + 360) % 360 : + undefined; return { valid: true, moving: pMoving, speed: previousSegment.speed, travel: previousSegment.travel, + compassOffset, }; }) .catch(() => null); @@ -376,23 +387,36 @@ export class ReorientationEngine { } } - const hasCompassCalibration = - isNum(cur.computedCca) && - isNum(cur.originalCca) && - isNum(nxt.computedCca) && - isNum(nxt.originalCca); - const currentCompassOffset = hasCompassCalibration ? + const hasCurrentCompassCalibration = + isNum(cur.computedCca) && isNum(cur.originalCca); + const hasNextCompassCalibration = + isNum(nxt.computedCca) && isNum(nxt.originalCca); + const currentCompassOffset = hasCurrentCompassCalibration ? (cur.computedCca - cur.originalCca + 360) % 360 : 0; - const nextCompassOffset = hasCompassCalibration ? + const nextCompassOffset = hasNextCompassCalibration ? (nxt.computedCca - nxt.originalCca + 360) % 360 : 0; + const reconstructionDiscontinuity = + hasCurrentCompassCalibration && + prev?.compassOffset != null && + angleDelta(currentCompassOffset, prev.compassOffset) > + MAX_COMPUTED_COMPASS_DELTA_DEG; + const continuesRejectedCalibration = + hasCurrentCompassCalibration && + prev?.computedCompassOutlier === true && + prev.compassOffset != null && + angleDelta(currentCompassOffset, prev.compassOffset) <= + MAX_COMPUTED_COMPASS_DELTA_DEG; const computedCompassOutlier = - hasCompassCalibration && + hasCurrentCompassCalibration && angleDelta(cur.computedCca, cur.originalCca) > MAX_COMPUTED_COMPASS_DELTA_DEG && - angleDelta(currentCompassOffset, nextCompassOffset) > - MAX_COMPUTED_COMPASS_DELTA_DEG && angleDelta(tb, cur.originalCca) < - cfg.lowSpeedTurnMaxDeltaDeg; + cfg.lowSpeedTurnMaxDeltaDeg && + ((hasNextCompassCalibration && + angleDelta(currentCompassOffset, nextCompassOffset) > + MAX_COMPUTED_COMPASS_DELTA_DEG) || + reconstructionDiscontinuity || + continuesRejectedCalibration); const viewCompassAngle = computedCompassOutlier ? cur.originalCca : (isNum(cur.viewCompassAngle) ? cur.viewCompassAngle : cur.cca); @@ -406,6 +430,9 @@ export class ReorientationEngine { cca: cur.cca, viewCompassAngle, computedCompassOutlier, + reconstructionDiscontinuity, + computedCompassOffset: hasCurrentCompassCalibration ? + currentCompassOffset : undefined, speed, moving, seq: cur.seq, diff --git a/test/component/reorientation/ReorientationEngine.test.ts b/test/component/reorientation/ReorientationEngine.test.ts index 309d21734..a96bd2177 100644 --- a/test/component/reorientation/ReorientationEngine.test.ts +++ b/test/component/reorientation/ReorientationEngine.test.ts @@ -184,10 +184,46 @@ describe("ReorientationEngine.precompute", () => { const result = engine.get("b"); expect(result.computedCompassOutlier).toBe(false); + expect(result.reconstructionDiscontinuity).toBe(false); expect(result.viewCompassAngle).toBeCloseTo(270, 1); expect(result.basicX).toBeCloseTo(0, 2); }); + it("rejects an abrupt reconstructed calibration change", async () => { + const previous = spherical("a", 0, -0.0001, 90, 1000); + previous.computedCca = 90; + previous.originalCca = 90; + const current = spherical("b", 0, 0, 270, 2000); + current.computedCca = 270; + current.originalCca = 90; + const next = spherical("c", 0, 0.0001, 270, 3000); + next.computedCca = 270; + next.originalCca = 90; + const following = spherical("d", 0, 0.0002, 270, 4000); + following.computedCca = 270; + following.originalCca = 90; + const images: Fixture = { + a: previous, + b: current, + c: next, + d: following, + }; + const engine = new ReorientationEngine( + provider(images, ["a", "b", "c", "d"])); + + await engine.precompute("b"); + await engine.precompute("c"); + const result = engine.get("b"); + const continued = engine.get("c"); + + expect(result.computedCompassOutlier).toBe(true); + expect(result.reconstructionDiscontinuity).toBe(true); + expect(result.viewCompassAngle).toBeCloseTo(90, 1); + expect(result.basicX).toBeCloseTo(0.5, 2); + expect(continued.computedCompassOutlier).toBe(true); + expect(continued.reconstructionDiscontinuity).toBe(false); + }); + it("uses the original track when computed speed is impossible", async () => { const images: Fixture = { a: spherical("a", 0, 0, 90, 1000, 0, 0), From f205e30bf51a19795ccaa79db3747623b3c45256 Mon Sep 17 00:00:00 2001 From: Boris Masis Date: Mon, 21 Sep 2026 08:57:10 -0700 Subject: [PATCH 48/59] Prepare reorientation before sequence navigation --- .../reorientation/ReorientationComponent.ts | 60 ++++++++++++++++++- src/viewer/Navigator.ts | 22 ++++++- test/viewer/Navigator.test.ts | 56 +++++++++++++++++ 3 files changed, 134 insertions(+), 4 deletions(-) diff --git a/src/component/reorientation/ReorientationComponent.ts b/src/component/reorientation/ReorientationComponent.ts index eac051ee7..3fcc04c15 100644 --- a/src/component/reorientation/ReorientationComponent.ts +++ b/src/component/reorientation/ReorientationComponent.ts @@ -278,6 +278,10 @@ export class ReorientationComponent protected _activate(): void { const subs = this._subscriptions; + this._navigator.setMovePreparer( + (id: string, direction: NavigationDirection): Promise => + this._prepareMove(id, direction)); + subs.push(this._configuration$.subscribe( (configuration: ReorientationConfiguration): void => { this._automaticHorizonLeveling = @@ -378,6 +382,7 @@ export class ReorientationComponent protected _deactivate(): void { this._subscriptions.unsubscribe(); + this._navigator.setMovePreparer(null); this._engine = null; this._activeId = null; this._lastSeq = null; @@ -692,6 +697,55 @@ export class ReorientationComponent .catch((): void => { /* skip images we can't resolve */ }); } + private _prepareMove( + id: string, + direction: NavigationDirection): Promise { + if (direction !== NavigationDirection.Next && + direction !== NavigationDirection.Prev) { + return Promise.resolve(); + } + const engine = this._engine; + const activeId = this._activeId; + if (engine == null || activeId == null || !this._reorientToFront) { + return Promise.resolve(); + } + + // A sequence button can become clickable before the first image's + // async orientation metadata is ready. Wait for both sides so the + // target hint exists before StateService makes that image visible. + return Promise.all([ + engine.precompute(activeId, undefined, 0), + engine.precompute(id, undefined, 0), + ]).then((): Promise => { + if (this._engine !== engine || this._activeId !== activeId) { + return Promise.resolve(); + } + const active = engine.get(activeId); + if (active == null || !active.valid || + typeof active.basicX !== "number" || + typeof active.viewCompassAngle !== "number") { + return Promise.resolve(); + } + const endBearing = this._adoptedView != null ? + this._bearingForView(active, this._adoptedView[0]) : + (typeof this._liveBearing === "number" ? + this._mapBearing(active, this._liveBearing) : + this._bearingForView( + active, this._applyOffsetX(active.basicX))); + + return new Promise((resolve: () => void): void => { + this._navigator.stateService.getCenter().pipe(first()).subscribe( + (center: number[]): void => { + const targetY = this._adoptedView != null ? + this._adoptedView[1] : center[1]; + this._hintNeighbor( + engine, id, endBearing, targetY).then(resolve); + }, + (): void => resolve()); + }); + }).catch((): void => { /* navigation proceeds without a hint */ }); + } + private _applyOffsetX(basicX: number): number { return ((basicX + this._userOffsetX) % 1 + 1) % 1; } @@ -736,14 +790,14 @@ export class ReorientationComponent engine: ReorientationEngine, id: string, endBearing: number, - targetY: number): void { + targetY: number): Promise { if (!this._reorientToFront) { - return; + return Promise.resolve(); } // Depth 0: we only need this neighbor's own result cached, not another // forward prefetch cascade (the current image's _reorient already warms // ahead). Avoids re-walking the already-scheduled chain per navigation. - engine.precompute(id, undefined, 0) + return engine.precompute(id, undefined, 0) .then((): void => { if (this._engine !== engine) { return; diff --git a/src/viewer/Navigator.ts b/src/viewer/Navigator.ts index fed8dc0fc..b145c0cca 100644 --- a/src/viewer/Navigator.ts +++ b/src/viewer/Navigator.ts @@ -59,6 +59,8 @@ export class Navigator { private _movedToId$: BehaviorSubject; private _lastMoveDirection: NavigationDirection; + private _movePreparer: + (id: string, direction: NavigationDirection) => Promise; private _request$: ReplaySubject; private _requestSubscription: Subscription; @@ -120,6 +122,7 @@ export class Navigator { this._movedToId$ = new BehaviorSubject(null); this._lastMoveDirection = null; + this._movePreparer = null; this._request$ = null; this._requestSubscription = null; @@ -185,6 +188,12 @@ export class Navigator { return direction; } + /** Wait for a component to prepare target state before an image is shown. */ + public setMovePreparer( + preparer: (id: string, direction: NavigationDirection) => Promise): void { + this._movePreparer = preparer; + } + public moveDir$(direction: NavigationDirection): Observable { this._abortRequest(`in dir ${NavigationDirection[direction]}`); this._lastMoveDirection = direction; @@ -225,7 +234,9 @@ export class Navigator { return observableThrowError(new Error(`Direction (${direction}) does not exist for current image.`)); } - return this._moveTo$(directionId); + return this._prepareMove$(directionId, direction).pipe( + mergeMap((): Observable => + this._moveTo$(directionId))); })); return this._makeRequest$(image$); @@ -367,6 +378,15 @@ export class Navigator { return request$; } + private _prepareMove$( + id: string, + direction: NavigationDirection): Observable { + const preparation = this._movePreparer == null ? + Promise.resolve() : + this._movePreparer(id, direction).catch((): void => undefined); + return observableFrom(preparation); + } + private _moveTo$(id: string): Observable { this._idRequested$.next(id); diff --git a/test/viewer/Navigator.test.ts b/test/viewer/Navigator.test.ts index e488e5b18..676bbedb6 100644 --- a/test/viewer/Navigator.test.ts +++ b/test/viewer/Navigator.test.ts @@ -16,6 +16,7 @@ import { StateServiceMockCreator } from "../helper/StateServiceMockCreator"; import { Navigator } from "../../src/viewer/Navigator"; import { Image } from "../../src/graph/Image"; +import { ImageCache } from "../../src/graph/ImageCache"; import { APIWrapper } from "../../src/api/APIWrapper"; import { CoreImageEnt } from "../../src/api/ents/CoreImageEnt"; import { Graph } from "../../src/graph/Graph"; @@ -353,6 +354,61 @@ describe("Navigator.moveToKey$", () => { }); }); +describe("Navigator.moveDir$", () => { + it("should wait for target preparation before showing the image", (done: () => void) => { + const api: APIWrapper = new APIWrapper(new DataProvider()); + const graphService: GraphService = new GraphService(new Graph(api)); + const loadingService: LoadingService = new LoadingService(); + const stateService: StateService = new StateServiceMockCreator().create(); + const cacheService: CacheService = + new CacheService(graphService, stateService, api); + spyOn(loadingService, "startLoading").and.stub(); + spyOn(loadingService, "stopLoading").and.stub(); + + const current: Image = new ImageHelper().createImage(); + current.initializeCache(new ImageCache(undefined)); + current.cacheSequenceEdges([{ + source: current.id, + target: "target", + data: { + direction: NavigationDirection.Next, + worldMotionAzimuth: 0, + }, + }]); + const target: Image = new ImageHelper().createImage(); + const cacheImageSpy: jasmine.Spy = spyOn(graphService, "cacheImage$") + .and.returnValue(observableOf(target)); + const navigator: Navigator = new Navigator( + { container: "co" }, + api, + graphService, + loadingService, + stateService, + cacheService); + + let finishPreparation: () => void; + const preparation = new Promise((resolve: () => void): void => { + finishPreparation = resolve; + }); + const prepareSpy: jasmine.Spy = jasmine.createSpy("prepare") + .and.returnValue(preparation); + navigator.setMovePreparer(prepareSpy); + + navigator.moveDir$(NavigationDirection.Next).subscribe( + (image: Image): void => { + expect(image).toBe(target); + expect(cacheImageSpy.calls.count()).toBe(1); + done(); + }); + (>stateService.currentImage$).next(current); + + expect(prepareSpy).toHaveBeenCalledWith( + "target", NavigationDirection.Next); + expect(cacheImageSpy.calls.count()).toBe(0); + finishPreparation(); + }); +}); + describe("Navigator.movedToKey$", () => { it("should emit when move succeeds", (done: Function) => { const api: APIWrapper = new APIWrapper(new DataProvider()); From 033f49c133fbb34d278c9442a17d97397b48241e Mon Sep 17 00:00:00 2001 From: Boris Masis Date: Tue, 22 Sep 2026 02:35:12 -0700 Subject: [PATCH 49/59] Reject inconsistent reconstructed leveling poses --- .../reorientation/ReorientationComponent.ts | 89 +++++++++++++-- .../ReorientationLeveling.test.ts | 107 ++++++++++++++++++ 2 files changed, 185 insertions(+), 11 deletions(-) create mode 100644 test/component/reorientation/ReorientationLeveling.test.ts diff --git a/src/component/reorientation/ReorientationComponent.ts b/src/component/reorientation/ReorientationComponent.ts index 3fcc04c15..3730940fa 100644 --- a/src/component/reorientation/ReorientationComponent.ts +++ b/src/component/reorientation/ReorientationComponent.ts @@ -32,11 +32,39 @@ import { Navigator } from "../../viewer/Navigator"; const MIN_REORIENT_DEG = 15; const MAX_HORIZON_CORRECTION_DEG = 75; const MAX_REORIENTATION_ROLL_DEG = 45; +// Abrupt roll or pitch changes between neighboring captures are reconstruction +// errors, not plausible camera motion, so their horizon is not worth using. +const MAX_LEVEL_ROLL_DELTA_DEG = 15; +const MAX_LEVEL_PITCH_DELTA_DEG = 15; const MAX_PERSPECTIVE_AUTO_ZOOM = 0.75; const MAX_PERSPECTIVE_FOV = 125; const PERSPECTIVE_EDGE_MARGIN = 1e-3; const USER_ZOOM_EPSILON = 1e-2; +/** Pitch correction, in degrees, that levelling to a horizon row implies. */ +export function horizonPitchDeg(horizonY: number): number { + return (0.5 - horizonY) * 180; +} + +/** + * Whether a reconstructed pose is plausible enough to level to. Judged on its + * roll, and — once the sequence has an accepted pose to compare against — on + * how far its pitch has moved since that pose. Roll is unsigned, as + * {@link ReorientationComponent._rollDeg} reports it. + */ +export function isLevelPlausible( + rollDeg: number, + pitchDeg: number, + baselineRollDeg: number, + baselinePitchDeg: number): boolean { + if (!Number.isFinite(rollDeg) || !Number.isFinite(pitchDeg)) { + return false; + } + return baselineRollDeg == null || baselinePitchDeg == null || + Math.abs(rollDeg - baselineRollDeg) <= MAX_LEVEL_ROLL_DELTA_DEG && + Math.abs(pitchDeg - baselinePitchDeg) <= MAX_LEVEL_PITCH_DELTA_DEG; +} + /** * The heading an image ended up being shown at, once reorientation has decided * whether to turn it. Emitted on {@link ReorientationComponent.settled$}. @@ -115,6 +143,10 @@ export class ReorientationComponent // it is held, not re-read. private _userOffsetY: number; private _ySeeded: boolean; + // Last accepted reconstructed pose. Rejected poses must not shift this + // baseline or their correct neighbors would be rejected instead. + private _levelRollDeg: number; + private _levelPitchDeg: number; private _currentTransform: Transform; private _viewportCoords: ViewportCoords = new ViewportCoords(); @@ -432,9 +464,10 @@ export class ReorientationComponent hasReconstructionMesh(image.mesh) && result?.computedCompassOutlier !== true; const hardCut = !hasReconstruction && fromId != null; + let levelingActive = + hasReconstruction && this._automaticHorizonLeveling; const horizonY = (x: number): number => - hasReconstruction && this._automaticHorizonLeveling ? - this._horizonY(x) : 0.5; + levelingActive ? this._horizonY(x) : 0.5; const sequenceId = result?.seq ?? image.sequenceId; const freshSequence = sequenceId != null && sequenceId !== this._lastSeq; @@ -487,6 +520,14 @@ export class ReorientationComponent // carries over; this sequence registers its own as it goes. this._navigator.stateService.clearReorientations(); } + // Comparing pitch against the previous image only means + // something between adjacent images. A jump lands anywhere in + // the capture, where a different pitch is the terrain rather + // than a bad pose, so it starts the comparison over. + if (freshSequence || !neighbor) { + this._levelRollDeg = null; + this._levelPitchDeg = null; + } if (!result || !result.valid) { // Switching out of Gravity can reset a center queued before @@ -507,9 +548,10 @@ export class ReorientationComponent return; } - const safetyCenter = [result.basicX, horizonY(result.basicX)]; - const rollDeg = hasReconstruction && - this._automaticHorizonLeveling ? + const horizonRow = levelingActive ? + this._horizonRow(result.basicX) : null; + const safetyCenter = [result.basicX, horizonRow ?? 0.5]; + const rollDeg = levelingActive ? this._rollDeg(safetyCenter) : 0; if (rollDeg == null || rollDeg > MAX_REORIENTATION_ROLL_DEG) { @@ -533,14 +575,16 @@ export class ReorientationComponent this._clearAdoptedView(); return; } - if (hasReconstruction && this._automaticHorizonLeveling) { + if (levelingActive && !this._acceptLevel(rollDeg, horizonRow)) { + levelingActive = false; + } + if (levelingActive) { this._navigator.stateService.gravityTraverse(); } else { this._navigator.stateService.traverse(); } this._computedBasicX = result.basicX; - if ((!hasReconstruction || !this._automaticHorizonLeveling) && - !this._ySeeded) { + if (!levelingActive && !this._ySeeded) { this._userOffsetY = 0; this._ySeeded = true; } @@ -878,6 +922,23 @@ export class ReorientationComponent }); } + private _acceptLevel(rollDeg: number, horizonRow: number): boolean { + if (horizonRow == null) { + return false; + } + const pitchDeg = horizonPitchDeg(horizonRow); + if (!isLevelPlausible( + rollDeg, + pitchDeg, + this._levelRollDeg, + this._levelPitchDeg)) { + return false; + } + this._levelRollDeg = rollDeg; + this._levelPitchDeg = pitchDeg; + return true; + } + private _rollDeg(center: number[]): number | null { const transform = this._currentTransform; if (transform == null) { @@ -990,9 +1051,13 @@ export class ReorientationComponent } private _horizonY(x: number): number { + return this._horizonRow(x) ?? 0.5; + } + + private _horizonRow(x: number): number | null { const transform = this._currentTransform; if (transform == null) { - return 0.5; + return null; } const cameraZ = transform.unprojectSfM([0, 0], 0)[2]; @@ -1001,7 +1066,7 @@ export class ReorientationComponent let lowZ = transform.unprojectBasic([x, low], 10)[2] - cameraZ; const highZ = transform.unprojectBasic([x, high], 10)[2] - cameraZ; if (lowZ * highZ > 0) { - return 0.5; + return null; } for (let i = 0; i < 32; i++) { @@ -1018,13 +1083,15 @@ export class ReorientationComponent const horizon = (low + high) / 2; return Math.abs(horizon - 0.5) * 180 <= - MAX_HORIZON_CORRECTION_DEG ? horizon : 0.5; + MAX_HORIZON_CORRECTION_DEG ? horizon : null; } private _resetOffset(): void { this._userOffsetX = 0; this._userOffsetY = 0; this._ySeeded = false; + this._levelRollDeg = null; + this._levelPitchDeg = null; } private _seed(image: Image): ReorientationImage { diff --git a/test/component/reorientation/ReorientationLeveling.test.ts b/test/component/reorientation/ReorientationLeveling.test.ts new file mode 100644 index 000000000..514f751e4 --- /dev/null +++ b/test/component/reorientation/ReorientationLeveling.test.ts @@ -0,0 +1,107 @@ +import { + horizonPitchDeg, + isLevelPlausible, +} from "../../../src/component/reorientation/ReorientationComponent"; + +/** + * Sequence 1lt7kgfezr0ysp9vmlirl4, measured in the viewer. Images without a + * reconstruction mesh never reach the plausibility check and are omitted. + */ +const MEASURED: { id: string, rollDeg: number, horizonY: number }[] = [ + { id: "915416489297910", rollDeg: 1.08, horizonY: 0.3795 }, + { id: "520184629350307", rollDeg: 11, horizonY: 0.3817 }, + { id: "535844770743193", rollDeg: 5.73, horizonY: 0.2645 }, + { id: "818284102398965", rollDeg: 4.85, horizonY: 0.374 }, + { id: "764978360865844", rollDeg: 0.29, horizonY: 0.3128 }, + { id: "4134207483305086", rollDeg: 34, horizonY: 0.4995 }, + { id: "784577412247663", rollDeg: 7.68, horizonY: 0.7393 }, +]; + +const BAD = [ + "535844770743193", + "4134207483305086", + "784577412247663", +]; + +function replay( + images: { id: string, rollDeg: number, horizonY: number }[]): string[] { + let baselineRoll: number = null; + let baselinePitch: number = null; + const rejected: string[] = []; + for (const image of images) { + const pitchDeg = horizonPitchDeg(image.horizonY); + if (isLevelPlausible( + image.rollDeg, + pitchDeg, + baselineRoll, + baselinePitch)) { + baselineRoll = image.rollDeg; + baselinePitch = pitchDeg; + } else { + rejected.push(image.id); + } + } + return rejected; +} + +describe("isLevelPlausible", () => { + it("accepts a level pose when the sequence has no baseline", () => { + expect(isLevelPlausible(0, 0, null, null)).toBe(true); + }); + + it("accepts a pose that stays close to the baseline", () => { + expect(isLevelPlausible(2, 21.3, 1, 21.7)).toBe(true); + }); + + it("rejects a pose whose roll jumps away from the baseline", () => { + expect(isLevelPlausible(34, 0, 1, 0)).toBe(false); + }); + + it("accepts a consistently tilted camera", () => { + expect(isLevelPlausible(34, 2, 30, 0)).toBe(true); + }); + + it("rejects a pose whose pitch jumps away from the baseline", () => { + expect(isLevelPlausible(5.73, 42.4, 11, 21.3)).toBe(false); + }); + + it("rejects a pose in either pitch direction", () => { + expect(isLevelPlausible(7.68, -43.1, 0.29, 33.7)).toBe(false); + }); + + it("rejects non-finite measurements", () => { + expect(isLevelPlausible(NaN, 0, null, null)).toBe(false); + expect(isLevelPlausible(0, NaN, null, null)).toBe(false); + }); + + it("accepts the first pose without a consistency baseline", () => { + expect(isLevelPlausible(34, 60, null, null)).toBe(true); + }); +}); + +describe("horizonPitchDeg", () => { + it("reports no correction for a centred horizon", () => { + expect(horizonPitchDeg(0.5)).toBeCloseTo(0, 6); + }); + + it("reports a positive correction for a horizon above centre", () => { + expect(horizonPitchDeg(0.25)).toBeCloseTo(45, 6); + }); +}); + +describe("isLevelPlausible.sequence", () => { + it("rejects exactly the badly reoriented images of the repro", () => { + expect(replay(MEASURED)).toEqual(BAD); + }); + + it("keeps levelling the images after a rejected one", () => { + const rejected = replay(MEASURED); + expect(rejected).not.toContain("818284102398965"); + expect(rejected).not.toContain("764978360865844"); + }); + + it("recovers a baseline after a sequence reset", () => { + const afterReset = replay([MEASURED[6]]); + expect(afterReset).toEqual([]); + }); +}); From c4ef78e884873fa0b4716c2ccbc04e0a9c92e219 Mon Sep 17 00:00:00 2001 From: Boris Masis Date: Tue, 22 Sep 2026 04:13:06 -0700 Subject: [PATCH 50/59] Keep fallback pitch offsets relative to raw center --- .../reorientation/ReorientationComponent.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/component/reorientation/ReorientationComponent.ts b/src/component/reorientation/ReorientationComponent.ts index 3730940fa..9d5516278 100644 --- a/src/component/reorientation/ReorientationComponent.ts +++ b/src/component/reorientation/ReorientationComponent.ts @@ -147,6 +147,7 @@ export class ReorientationComponent // baseline or their correct neighbors would be rejected instead. private _levelRollDeg: number; private _levelPitchDeg: number; + private _levelAccepted: boolean; private _currentTransform: Transform; private _viewportCoords: ViewportCoords = new ViewportCoords(); @@ -357,6 +358,7 @@ export class ReorientationComponent [0, 0, 0], image.image, image.camera); + this._levelAccepted = false; const fromId = this._activeId; if (this._adoptedView != null && this._adoptedSequence == null) { @@ -530,6 +532,7 @@ export class ReorientationComponent } if (!result || !result.valid) { + this._levelAccepted = false; // Switching out of Gravity can reset a center queued before // the image loaded, so restore an explicit shared-link view // after the fallback transition. Keep it pending because an @@ -555,6 +558,7 @@ export class ReorientationComponent this._rollDeg(safetyCenter) : 0; if (rollDeg == null || rollDeg > MAX_REORIENTATION_ROLL_DEG) { + this._levelAccepted = false; this._navigator.stateService.traverse(); if (resetView) { this._resetOffset(); @@ -578,6 +582,7 @@ export class ReorientationComponent if (levelingActive && !this._acceptLevel(rollDeg, horizonRow)) { levelingActive = false; } + this._levelAccepted = levelingActive; if (levelingActive) { this._navigator.stateService.gravityTraverse(); } else { @@ -1045,11 +1050,15 @@ export class ReorientationComponent result.basicX : this._computedBasicX; this._userOffsetX = wrapDelta(center[0] - basis); this._userOffsetY = - center[1] - this._horizonY(center[0]); + center[1] - this._levelRow(center[0]); this._ySeeded = true; }); } + private _levelRow(x: number): number { + return this._levelAccepted ? this._horizonY(x) : 0.5; + } + private _horizonY(x: number): number { return this._horizonRow(x) ?? 0.5; } @@ -1092,6 +1101,7 @@ export class ReorientationComponent this._ySeeded = false; this._levelRollDeg = null; this._levelPitchDeg = null; + this._levelAccepted = false; } private _seed(image: Image): ReorientationImage { From b54a60f712895ba5ab2ec220cfa29b44e3799127 Mon Sep 17 00:00:00 2001 From: Boris Masis Date: Tue, 22 Sep 2026 04:21:56 -0700 Subject: [PATCH 51/59] Prepare mapillary-js fb14 package --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 513193945..45a799ec3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mapillary-js", - "version": "5.0.0-beta.5-fb13", + "version": "5.0.0-beta.5-fb14", "description": "A WebGL interactive street imagery library", "license": "MIT", "main": "dist/mapillary.js", From 1a63ce3a722a37465af196834f61c438a9ef7e9d Mon Sep 17 00:00:00 2001 From: Boris Masis Date: Wed, 23 Sep 2026 04:42:02 -0700 Subject: [PATCH 52/59] Keep coherent reconstructed tracks for reorientation --- .../reorientation/ReorientationEngine.ts | 100 +++++++++++------- .../reorientation/ReorientationEngine.test.ts | 26 +++++ 2 files changed, 90 insertions(+), 36 deletions(-) diff --git a/src/component/reorientation/ReorientationEngine.ts b/src/component/reorientation/ReorientationEngine.ts index b563d9cfe..ddb74ccbe 100644 --- a/src/component/reorientation/ReorientationEngine.ts +++ b/src/component/reorientation/ReorientationEngine.ts @@ -40,6 +40,7 @@ export interface ReorientationResult { nextId?: string; prevId?: string; travel?: number; + computedTravel?: number; basicX?: number; dist?: number; cca?: number; @@ -127,6 +128,7 @@ interface PrevContext { moving: boolean; speed: number; travel: number; + computedTravel?: number; compassOffset?: number; computedCompassOutlier?: boolean; } @@ -136,6 +138,9 @@ interface Segment { speed: number; speedExcessive: boolean; travel: number; + computedSpeed: number; + computedTravel: number; + originalTravel?: number; } /** @@ -251,10 +256,7 @@ export class ReorientationEngine { nextId: string, depth: number): Promise { const cfg = this._config; - const segment = this._segment(cur, nxt); - const dist = segment.dist; - let tb = segment.travel; - const speed = segment.speed; + let segment = this._segment(cur, nxt); let moving = false; let prevCtx: PrevContext = null; @@ -267,6 +269,7 @@ export class ReorientationEngine { moving: pd.moving, speed: pd.speed, travel: pd.travel, + computedTravel: pd.computedTravel, compassOffset: pd.computedCompassOffset, computedCompassOutlier: pd.computedCompassOutlier, }; @@ -303,6 +306,7 @@ export class ReorientationEngine { moving: pMoving, speed: previousSegment.speed, travel: previousSegment.travel, + computedTravel: previousSegment.computedTravel, compassOffset, }; }) @@ -310,6 +314,14 @@ export class ReorientationEngine { } return prevPromise.then((prev) => { + if (this._shouldUseOriginalGeometry(cur, segment, prev)) { + this._originalGeometrySequences.add(cur.seq); + segment = this._segment(cur, nxt); + } + const dist = segment.dist; + let tb = segment.travel; + const speed = segment.speed; + if (segment.speedExcessive) { // Video frame timestamps can imply impossible speeds. There is // still real displacement, but it must pass the bearing-outlier @@ -425,6 +437,7 @@ export class ReorientationEngine { nextId, prevId: idx > 0 ? ids[idx - 1] : undefined, travel: tb, + computedTravel: segment.computedTravel, basicX: bearingToBasicX(tb, viewCompassAngle), dist, cca: cur.cca, @@ -454,50 +467,65 @@ export class ReorientationEngine { private _segment(cur: ReorientationImage, nxt: ReorientationImage): Segment { const dt = (nxt.ts && cur.ts) ? (nxt.ts - cur.ts) / 1000 : 0; - let dist = haversineDist(cur.lat, cur.lng, nxt.lat, nxt.lng); - let travel = bearing(cur.lat, cur.lng, nxt.lat, nxt.lng); - let speed = dt > 0 ? dist / dt : 0; - - // SfM geometry can occasionally be displaced or scrambled while the - // original capture track is coherent. Keep one geometry source for the - // sequence once the computed track becomes physically implausible. + const computedDist = haversineDist(cur.lat, cur.lng, nxt.lat, nxt.lng); + const computedTravel = bearing(cur.lat, cur.lng, nxt.lat, nxt.lng); + const computedSpeed = dt > 0 ? computedDist / dt : 0; const hasOriginal = isNum(cur.originalLat) && isNum(cur.originalLng) && isNum(nxt.originalLat) && isNum(nxt.originalLng); - if (hasOriginal) { - const originalTravel = bearing( - cur.originalLat, cur.originalLng, - nxt.originalLat, nxt.originalLng); - const computedHeadingDelta = angleDelta(travel, cur.cca); - const originalHeading = isNum(cur.originalCca) ? - cur.originalCca : cur.cca; - const originalHeadingDelta = - angleDelta(originalTravel, originalHeading); - if (speed > MAX_REASONABLE_SPEED_MPS || - (computedHeadingDelta > - MAX_COMPUTED_POSITION_HEADING_DELTA_DEG && - originalHeadingDelta < this._config.lowSpeedTurnMaxDeltaDeg)) { - this._originalGeometrySequences.add(cur.seq); - } - } - if (hasOriginal && this._originalGeometrySequences.has(cur.seq)) { - dist = haversineDist( - cur.originalLat, cur.originalLng, - nxt.originalLat, nxt.originalLng); - travel = bearing( - cur.originalLat, cur.originalLng, - nxt.originalLat, nxt.originalLng); - speed = dt > 0 ? dist / dt : 0; - } + const originalDist = hasOriginal ? haversineDist( + cur.originalLat, cur.originalLng, + nxt.originalLat, nxt.originalLng) : undefined; + const originalTravel = hasOriginal ? bearing( + cur.originalLat, cur.originalLng, + nxt.originalLat, nxt.originalLng) : undefined; + const useOriginal = hasOriginal && + this._originalGeometrySequences.has(cur.seq); + const dist = useOriginal ? originalDist : computedDist; + const travel = useOriginal ? originalTravel : computedTravel; + const speed = dt > 0 ? dist / dt : 0; return { dist, speed, speedExcessive: speed > MAX_REASONABLE_SPEED_MPS, travel, + computedSpeed, + computedTravel, + originalTravel, }; } + private _shouldUseOriginalGeometry( + cur: ReorientationImage, + segment: Segment, + prev: PrevContext): boolean { + if (!isNum(segment.originalTravel)) { + return false; + } + if (segment.computedSpeed > MAX_REASONABLE_SPEED_MPS) { + return true; + } + + const originalHeading = isNum(cur.originalCca) ? + cur.originalCca : cur.cca; + const headingFavorsOriginal = + angleDelta(segment.computedTravel, cur.cca) > + MAX_COMPUTED_POSITION_HEADING_DELTA_DEG && + angleDelta(segment.originalTravel, originalHeading) < + this._config.lowSpeedTurnMaxDeltaDeg; + if (!headingFavorsOriginal) { + return false; + } + + // Raw GPS heading often agrees with raw compass by construction. Only + // prefer it when the computed track also breaks continuity; otherwise + // a smooth reconstructed track can be replaced by a noisy raw one. + return prev?.computedTravel == null || + angleDelta(segment.computedTravel, prev.computedTravel) > + MAX_COMPUTED_POSITION_HEADING_DELTA_DEG; + } + private _prefetchNext(d: ReorientationResult, depth: number): void { if (depth > 0 && d && d.valid && d.nextId) { this.precompute(d.nextId, null, depth - 1) diff --git a/test/component/reorientation/ReorientationEngine.test.ts b/test/component/reorientation/ReorientationEngine.test.ts index a96bd2177..73f062869 100644 --- a/test/component/reorientation/ReorientationEngine.test.ts +++ b/test/component/reorientation/ReorientationEngine.test.ts @@ -271,6 +271,32 @@ describe("ReorientationEngine.precompute", () => { expect(engine.get("a").basicX).toBeCloseTo(0.5, 2); }); + it("keeps a coherent computed track when raw positions zigzag", async () => { + const previous = spherical( + "a", 47.359316158503, 8.522159587028, 148.26818781405, + 1768975532063, 47.359348856991, 8.5221314103916); + previous.computedCca = 148.26818781405; + previous.originalCca = 210.92003060148; + const current = spherical( + "b", 47.359301627069, 8.5221372232107, 148.10483449986, + 1768975533663, 47.359324640976, 8.5221127109815); + current.computedCca = 148.10483449986; + current.originalCca = 208.06971887641; + const next = spherical( + "c", 47.359291658286, 8.5221209467221, 147.99487211037, + 1768975534743, 47.359297753971, 8.522101362013); + next.computedCca = 147.99487211037; + next.originalCca = 163.10847738317; + const engine = new ReorientationEngine(provider( + { a: previous, b: current, c: next }, ["a", "b", "c"])); + + await engine.precompute("b"); + const result = engine.get("b"); + + expect(result.travel).toBeCloseTo(227.9, 1); + expect(result.basicX).toBeCloseTo(0.72, 2); + }); + it("uses raw track and compass when both computed values jump", async () => { const current = spherical("b", 0, 0.00018, 0, 3000, 0, 0.00018); current.computedCca = 0; From e411457c01e0033978bf7283b54241ff17ea1ad2 Mon Sep 17 00:00:00 2001 From: Boris Masis Date: Wed, 23 Sep 2026 05:20:27 -0700 Subject: [PATCH 53/59] Keep reorientation off the navigation critical path --- .../reorientation/ReorientationComponent.ts | 56 +------------------ src/graph/GraphService.ts | 17 ++++++ src/viewer/Navigator.ts | 22 +------- test/graph/GraphService.test.ts | 31 ++++++++++ test/viewer/Navigator.test.ts | 56 ------------------- 5 files changed, 50 insertions(+), 132 deletions(-) diff --git a/src/component/reorientation/ReorientationComponent.ts b/src/component/reorientation/ReorientationComponent.ts index 9d5516278..6e0c94867 100644 --- a/src/component/reorientation/ReorientationComponent.ts +++ b/src/component/reorientation/ReorientationComponent.ts @@ -311,10 +311,6 @@ export class ReorientationComponent protected _activate(): void { const subs = this._subscriptions; - this._navigator.setMovePreparer( - (id: string, direction: NavigationDirection): Promise => - this._prepareMove(id, direction)); - subs.push(this._configuration$.subscribe( (configuration: ReorientationConfiguration): void => { this._automaticHorizonLeveling = @@ -416,7 +412,6 @@ export class ReorientationComponent protected _deactivate(): void { this._subscriptions.unsubscribe(); - this._navigator.setMovePreparer(null); this._engine = null; this._activeId = null; this._lastSeq = null; @@ -746,55 +741,6 @@ export class ReorientationComponent .catch((): void => { /* skip images we can't resolve */ }); } - private _prepareMove( - id: string, - direction: NavigationDirection): Promise { - if (direction !== NavigationDirection.Next && - direction !== NavigationDirection.Prev) { - return Promise.resolve(); - } - const engine = this._engine; - const activeId = this._activeId; - if (engine == null || activeId == null || !this._reorientToFront) { - return Promise.resolve(); - } - - // A sequence button can become clickable before the first image's - // async orientation metadata is ready. Wait for both sides so the - // target hint exists before StateService makes that image visible. - return Promise.all([ - engine.precompute(activeId, undefined, 0), - engine.precompute(id, undefined, 0), - ]).then((): Promise => { - if (this._engine !== engine || this._activeId !== activeId) { - return Promise.resolve(); - } - const active = engine.get(activeId); - if (active == null || !active.valid || - typeof active.basicX !== "number" || - typeof active.viewCompassAngle !== "number") { - return Promise.resolve(); - } - const endBearing = this._adoptedView != null ? - this._bearingForView(active, this._adoptedView[0]) : - (typeof this._liveBearing === "number" ? - this._mapBearing(active, this._liveBearing) : - this._bearingForView( - active, this._applyOffsetX(active.basicX))); - - return new Promise((resolve: () => void): void => { - this._navigator.stateService.getCenter().pipe(first()).subscribe( - (center: number[]): void => { - const targetY = this._adoptedView != null ? - this._adoptedView[1] : center[1]; - this._hintNeighbor( - engine, id, endBearing, targetY).then(resolve); - }, - (): void => resolve()); - }); - }).catch((): void => { /* navigation proceeds without a hint */ }); - } - private _applyOffsetX(basicX: number): number { return ((basicX + this._userOffsetX) % 1 + 1) % 1; } @@ -1149,7 +1095,7 @@ export class ReorientationComponent return Promise.resolve(cached); } return new Promise((resolve, reject) => { - graphService.cacheImage$(id).pipe(first()).subscribe( + graphService.cacheImageMetadata$(id).pipe(first()).subscribe( (image: Image): void => { const o = this._seed(image); imageCache.set(id, o); diff --git a/src/graph/GraphService.ts b/src/graph/GraphService.ts index 0e5a6b849..2e1978940 100644 --- a/src/graph/GraphService.ts +++ b/src/graph/GraphService.ts @@ -201,6 +201,23 @@ export class GraphService { })); } + /** Retrieve full image metadata without caching render assets or edges. */ + public cacheImageMetadata$(id: string): Observable { + return this._graph$.pipe( + first(), + mergeMap( + (graph: Graph): Observable => { + return this._cacheFullImages$(graph, [id]); + }), + map( + (graph: Graph): Image => { + if (!graph.hasNode(id)) { + throw new GraphMapillaryError(`Failed to cache image metadata (${id})`); + } + return graph.getNode(id); + })); + } + /** * Cache a image in the graph and retrieve it. * diff --git a/src/viewer/Navigator.ts b/src/viewer/Navigator.ts index b145c0cca..fed8dc0fc 100644 --- a/src/viewer/Navigator.ts +++ b/src/viewer/Navigator.ts @@ -59,8 +59,6 @@ export class Navigator { private _movedToId$: BehaviorSubject; private _lastMoveDirection: NavigationDirection; - private _movePreparer: - (id: string, direction: NavigationDirection) => Promise; private _request$: ReplaySubject; private _requestSubscription: Subscription; @@ -122,7 +120,6 @@ export class Navigator { this._movedToId$ = new BehaviorSubject(null); this._lastMoveDirection = null; - this._movePreparer = null; this._request$ = null; this._requestSubscription = null; @@ -188,12 +185,6 @@ export class Navigator { return direction; } - /** Wait for a component to prepare target state before an image is shown. */ - public setMovePreparer( - preparer: (id: string, direction: NavigationDirection) => Promise): void { - this._movePreparer = preparer; - } - public moveDir$(direction: NavigationDirection): Observable { this._abortRequest(`in dir ${NavigationDirection[direction]}`); this._lastMoveDirection = direction; @@ -234,9 +225,7 @@ export class Navigator { return observableThrowError(new Error(`Direction (${direction}) does not exist for current image.`)); } - return this._prepareMove$(directionId, direction).pipe( - mergeMap((): Observable => - this._moveTo$(directionId))); + return this._moveTo$(directionId); })); return this._makeRequest$(image$); @@ -378,15 +367,6 @@ export class Navigator { return request$; } - private _prepareMove$( - id: string, - direction: NavigationDirection): Observable { - const preparation = this._movePreparer == null ? - Promise.resolve() : - this._movePreparer(id, direction).catch((): void => undefined); - return observableFrom(preparation); - } - private _moveTo$(id: string): Observable { this._idRequested$.next(id); diff --git a/test/graph/GraphService.test.ts b/test/graph/GraphService.test.ts index 3ed873565..9c0c67ccb 100644 --- a/test/graph/GraphService.test.ts +++ b/test/graph/GraphService.test.ts @@ -665,6 +665,37 @@ class TestNode extends Image { } } +describe("GraphService.cacheImageMetadata$", () => { + it("should cache metadata without caching render assets", (done: Function) => { + const api: APIWrapper = new APIWrapper(new DataProvider()); + const graph: Graph = new Graph(api); + + spyOn(graph, "isCachingFull").and.returnValue(false); + spyOn(graph, "hasNode").and.returnValues(false, true); + + const cacheFull$: Subject = new Subject(); + const cacheFullSpy: jasmine.Spy = spyOn(graph, "cacheFull$"); + cacheFullSpy.and.returnValue(cacheFull$); + + const image: TestNode = new TestNode( + new ImageHelper().createCoreImageEnt()); + const cacheAssetsSpy: jasmine.Spy = spyOn(image, "cacheAssets$"); + spyOn(graph, "getNode").and.returnValue(image); + + const graphService: GraphService = new GraphService(graph); + graphService.cacheImageMetadata$(image.id).subscribe( + (result: Image): void => { + expect(result).toBe(image); + expect(cacheFullSpy).toHaveBeenCalledWith(image.id); + expect(cacheAssetsSpy).not.toHaveBeenCalled(); + done(); + }); + + cacheFull$.next(graph); + cacheFull$.complete(); + }); +}); + describe("GraphService.cacheNode$", () => { let helper: ImageHelper; diff --git a/test/viewer/Navigator.test.ts b/test/viewer/Navigator.test.ts index 676bbedb6..e488e5b18 100644 --- a/test/viewer/Navigator.test.ts +++ b/test/viewer/Navigator.test.ts @@ -16,7 +16,6 @@ import { StateServiceMockCreator } from "../helper/StateServiceMockCreator"; import { Navigator } from "../../src/viewer/Navigator"; import { Image } from "../../src/graph/Image"; -import { ImageCache } from "../../src/graph/ImageCache"; import { APIWrapper } from "../../src/api/APIWrapper"; import { CoreImageEnt } from "../../src/api/ents/CoreImageEnt"; import { Graph } from "../../src/graph/Graph"; @@ -354,61 +353,6 @@ describe("Navigator.moveToKey$", () => { }); }); -describe("Navigator.moveDir$", () => { - it("should wait for target preparation before showing the image", (done: () => void) => { - const api: APIWrapper = new APIWrapper(new DataProvider()); - const graphService: GraphService = new GraphService(new Graph(api)); - const loadingService: LoadingService = new LoadingService(); - const stateService: StateService = new StateServiceMockCreator().create(); - const cacheService: CacheService = - new CacheService(graphService, stateService, api); - spyOn(loadingService, "startLoading").and.stub(); - spyOn(loadingService, "stopLoading").and.stub(); - - const current: Image = new ImageHelper().createImage(); - current.initializeCache(new ImageCache(undefined)); - current.cacheSequenceEdges([{ - source: current.id, - target: "target", - data: { - direction: NavigationDirection.Next, - worldMotionAzimuth: 0, - }, - }]); - const target: Image = new ImageHelper().createImage(); - const cacheImageSpy: jasmine.Spy = spyOn(graphService, "cacheImage$") - .and.returnValue(observableOf(target)); - const navigator: Navigator = new Navigator( - { container: "co" }, - api, - graphService, - loadingService, - stateService, - cacheService); - - let finishPreparation: () => void; - const preparation = new Promise((resolve: () => void): void => { - finishPreparation = resolve; - }); - const prepareSpy: jasmine.Spy = jasmine.createSpy("prepare") - .and.returnValue(preparation); - navigator.setMovePreparer(prepareSpy); - - navigator.moveDir$(NavigationDirection.Next).subscribe( - (image: Image): void => { - expect(image).toBe(target); - expect(cacheImageSpy.calls.count()).toBe(1); - done(); - }); - (>stateService.currentImage$).next(current); - - expect(prepareSpy).toHaveBeenCalledWith( - "target", NavigationDirection.Next); - expect(cacheImageSpy.calls.count()).toBe(0); - finishPreparation(); - }); -}); - describe("Navigator.movedToKey$", () => { it("should emit when move succeeds", (done: Function) => { const api: APIWrapper = new APIWrapper(new DataProvider()); From 9ba3153980463b2288abe0e795b83e2338acb59e Mon Sep 17 00:00:00 2001 From: Boris Masis Date: Wed, 23 Sep 2026 05:54:14 -0700 Subject: [PATCH 54/59] Respect user input during pending reorientation --- .../reorientation/ReorientationComponent.ts | 70 ++++++++++++++----- 1 file changed, 52 insertions(+), 18 deletions(-) diff --git a/src/component/reorientation/ReorientationComponent.ts b/src/component/reorientation/ReorientationComponent.ts index 6e0c94867..467d3da16 100644 --- a/src/component/reorientation/ReorientationComponent.ts +++ b/src/component/reorientation/ReorientationComponent.ts @@ -132,6 +132,7 @@ export class ReorientationComponent private _incomingBearing: number; private _dragging: boolean = false; private _userViewChanged: boolean = false; + private _userViewRevision: number = 0; // Manual horizontal look-around offset, preserved within a sequence so the // engine doesn't yank the view back to the travel direction on every step. @@ -386,28 +387,25 @@ export class ReorientationComponent direction, fromId, this._dragging, - commitSequenceView); + commitSequenceView, + this._userViewRevision); })); subs.push(this._container.renderService.bearing$.subscribe( (bearing: number): void => { this._liveBearing = bearing; })); subs.push(this._container.mouseService.mouseDragStart$.subscribe( - (): void => { this._dragging = true; })); + (): void => { this._startUserViewChange(); })); + subs.push(this._container.touchService.singleTouchDragStart$.subscribe( + (): void => { this._startUserViewChange(); })); // A finished drag is a genuine user look-around (our own steering goes // through the state, not pointer events), so capture the offset. During // playback, stop residual momentum from spilling into later images. subs.push(this._container.mouseService.mouseDragEnd$.subscribe( - (): void => { - if (this._navigator.playService.playing) { - this._navigator.stateService - .rotateBasicWithoutInertia([0, 0]); - } - this._dragging = false; - this._userViewChanged = true; - this._captureOffset(); - })); + (): void => { this._finishUserViewChange(); })); + subs.push(this._container.touchService.singleTouchDragEnd$.subscribe( + (): void => { this._finishUserViewChange(); })); } protected _deactivate(): void { @@ -433,7 +431,8 @@ export class ReorientationComponent direction: NavigationDirection, fromId: string, draggingAtNavigation: boolean, - commitSequenceView: boolean): void { + commitSequenceView: boolean, + userViewRevision: number): void { const id = image.id; const seed = this._seed(image); const engine = this._engine; @@ -449,6 +448,25 @@ export class ReorientationComponent image.sequenceId !== this._adoptedSequence) { this._clearAdoptedView(); } + const sequenceId = result?.seq ?? image.sequenceId; + const freshSequence = + sequenceId != null && sequenceId !== this._lastSeq; + if (this._dragging || this._userViewChanged || + this._userViewRevision !== userViewRevision) { + if (freshSequence) { + this._navigator.stateService.clearReorientations(); + this._resetOffset(); + } + this._lastSeq = sequenceId; + if (result?.valid) { + this._computedBasicX = result.basicX; + } + this._clearAdoptedView(); + if (!this._dragging) { + this._captureOffset(); + } + return; + } if (!isSpherical(image.cameraType)) { this._levelPerspective(id); return; @@ -465,9 +483,6 @@ export class ReorientationComponent hasReconstruction && this._automaticHorizonLeveling; const horizonY = (x: number): number => levelingActive ? this._horizonY(x) : 0.5; - const sequenceId = result?.seq ?? image.sequenceId; - const freshSequence = - sequenceId != null && sequenceId !== this._lastSeq; this._lastSeq = sequenceId; // An arrow move already lands at the heading the user was @@ -741,6 +756,21 @@ export class ReorientationComponent .catch((): void => { /* skip images we can't resolve */ }); } + private _startUserViewChange(): void { + this._dragging = true; + this._userViewRevision++; + this._navigator.stateService.clearReorientations(); + } + + private _finishUserViewChange(): void { + if (this._navigator.playService.playing) { + this._navigator.stateService.rotateBasicWithoutInertia([0, 0]); + } + this._dragging = false; + this._userViewChanged = true; + this._captureOffset(); + } + private _applyOffsetX(basicX: number): number { return ((basicX + this._userOffsetX) % 1 + 1) % 1; } @@ -789,12 +819,14 @@ export class ReorientationComponent if (!this._reorientToFront) { return Promise.resolve(); } + const userViewRevision = this._userViewRevision; // Depth 0: we only need this neighbor's own result cached, not another // forward prefetch cascade (the current image's _reorient already warms // ahead). Avoids re-walking the already-scheduled chain per navigation. return engine.precompute(id, undefined, 0) .then((): void => { - if (this._engine !== engine) { + if (this._engine !== engine || + this._userViewRevision !== userViewRevision) { return; } const nr = engine.get(id); @@ -835,7 +867,8 @@ export class ReorientationComponent this._navigator.stateService.getCenter().pipe(first()).subscribe( (): void => { - if (this._activeId !== id || this._adoptedView != null) { + if (this._activeId !== id || this._adoptedView != null || + this._dragging || this._userViewChanged) { this._clearAdoptedView(); return; } @@ -845,7 +878,8 @@ export class ReorientationComponent render.currentImageId === id), first(), ).subscribe((render: RenderCamera): void => { - if (this._activeId !== id || this._adoptedView != null) { + if (this._activeId !== id || this._adoptedView != null || + this._dragging || this._userViewChanged) { this._clearAdoptedView(); return; } From d5c12711a2f3cc5c763830a8116176c9fdf47fac Mon Sep 17 00:00:00 2001 From: Boris Masis Date: Wed, 23 Sep 2026 07:45:13 -0700 Subject: [PATCH 55/59] Batch reorientation metadata lookahead --- .../reorientation/ReorientationComponent.ts | 63 ++++++--- .../reorientation/ReorientationEngine.ts | 54 +++++--- src/graph/Graph.ts | 122 ++++++++++++------ src/graph/GraphService.ts | 20 +++ .../reorientation/ReorientationEngine.test.ts | 41 ++++++ test/graph/Graph.test.ts | 31 +++++ test/graph/GraphService.test.ts | 31 +++++ 7 files changed, 294 insertions(+), 68 deletions(-) diff --git a/src/component/reorientation/ReorientationComponent.ts b/src/component/reorientation/ReorientationComponent.ts index 467d3da16..f7c5a7888 100644 --- a/src/component/reorientation/ReorientationComponent.ts +++ b/src/component/reorientation/ReorientationComponent.ts @@ -1089,7 +1089,11 @@ export class ReorientationComponent const originalLngLat = image.originalLngLat; const hasComputedCompass = Number.isFinite(image.computedCompassAngle); - const hasReconstruction = hasReconstructionMesh(image.mesh); + // Lookahead metadata has no loaded mesh. Its computed pose is + // provisional and is replaced by the real mesh classification when the + // image becomes current. + const hasReconstruction = image.hasInitializedCache() ? + hasReconstructionMesh(image.mesh) : hasComputedCompass; // Placeholder geometry can carry a computed pose that is wildly // inconsistent between neighboring frames. Its raw capture heading is // the stable center axis of the underlying panorama. @@ -1121,23 +1125,52 @@ export class ReorientationComponent private _createProvider(): ReorientationProvider { const graphService = this._navigator.graphService; const imageCache = new Map(); + const imagePending = new Map>(); + const fetchImages = (ids: string[]): Promise => { + const normalized = ids.map(String); + const missing = Array.from(new Set(normalized)).filter( + (id: string): boolean => + !imageCache.has(id) && !imagePending.has(id)); + if (missing.length > 0) { + const batch = new Promise((resolve, reject) => { + graphService.cacheImagesMetadata$(missing) + .pipe(first()) + .subscribe(resolve, reject); + }).then((images: Image[]): void => { + for (const image of images) { + imageCache.set(image.id, this._seed(image)); + } + }); + for (const id of missing) { + const pending = batch + .then((): ReorientationImage => { + const image = imageCache.get(id); + if (image == null) { + throw new Error(`Missing image metadata (${id})`); + } + imagePending.delete(id); + return image; + }) + .catch((error: Error): Promise => { + imagePending.delete(id); + return Promise.reject(error); + }); + imagePending.set(id, pending); + } + } + return Promise.all(normalized.map( + (id: string): Promise => + imageCache.has(id) ? + Promise.resolve(imageCache.get(id)) : + imagePending.get(id))); + }; return { fetchImage: (id: string): Promise => { - id = String(id); - const cached = imageCache.get(id); - if (cached) { - return Promise.resolve(cached); - } - return new Promise((resolve, reject) => { - graphService.cacheImageMetadata$(id).pipe(first()).subscribe( - (image: Image): void => { - const o = this._seed(image); - imageCache.set(id, o); - resolve(o); - }, - (e: Error): void => reject(e)); - }); + return fetchImages([id]).then( + (images: ReorientationImage[]): ReorientationImage => + images[0]); }, + fetchImages, cacheImage: (image: ReorientationImage): void => { if (image && image.id != null) { imageCache.set(String(image.id), image); diff --git a/src/component/reorientation/ReorientationEngine.ts b/src/component/reorientation/ReorientationEngine.ts index ddb74ccbe..26a255e81 100644 --- a/src/component/reorientation/ReorientationEngine.ts +++ b/src/component/reorientation/ReorientationEngine.ts @@ -27,6 +27,7 @@ export interface ReorientationImage { */ export interface ReorientationProvider { fetchImage(id: string): Promise; + fetchImages?(ids: string[]): Promise; fetchSeqIds(seqId: string): Promise; cacheImage?(image: ReorientationImage): void; } @@ -187,21 +188,28 @@ export class ReorientationEngine { if (depth == null) { depth = cfg.prefetchAhead; } - if (this._cache.has(imgId)) { - this._prefetchNext(this._cache.get(imgId), depth); - return Promise.resolve(); + if (seed && isNum(seed.lat) && isNum(seed.lng) && + typeof this._provider.cacheImage === "function") { + this._provider.cacheImage(seed); + } + const cached = this._cache.get(imgId); + if (cached != null) { + const seedChanged = seed != null && + (cached.cca !== seed.cca || + cached.viewCompassAngle !== seed.viewCompassAngle); + if (!seedChanged) { + this._prefetchNext(cached, depth); + return Promise.resolve(); + } + this._cache.delete(imgId); } if (this._pending.has(imgId)) { - return this._pending.get(imgId).then(() => { - this._prefetchNext(this._cache.get(imgId), depth); - }); + return this._pending.get(imgId).then(() => + this.precompute(imgId, seed, depth)); } let p: Promise; if (seed && isNum(seed.lat) && isNum(seed.lng)) { - if (typeof this._provider.cacheImage === "function") { - this._provider.cacheImage(seed); - } p = Promise.resolve(seed); } else { p = this._provider.fetchImage(imgId); @@ -237,13 +245,29 @@ export class ReorientationEngine { return; } const nextId = ids[idx + 1]; - return this._provider.fetchImage(nextId).then((nxt) => { - if (!hasPosition(nxt)) { - this._invalid(imgId, "Next image missing geometry"); - return; + let warm: Promise = Promise.resolve([]); + if (this._provider.fetchImages != null) { + const requiredIds = [ids[idx - 1], nextId] + .filter((id: string): boolean => id != null); + warm = this._provider.fetchImages(requiredIds) + .catch((): ReorientationImage[] => []); + const aheadIds = ids.slice( + idx + 2, Math.min(ids.length, idx + depth + 2)); + if (aheadIds.length > 0) { + this._provider.fetchImages(aheadIds) + .catch((): ReorientationImage[] => []); } - return this._decide(imgId, cur, nxt, ids, idx, nextId, depth); - }); + } + return warm + .then(() => this._provider.fetchImage(nextId)) + .then((nxt) => { + if (!hasPosition(nxt)) { + this._invalid(imgId, "Next image missing geometry"); + return; + } + return this._decide( + imgId, cur, nxt, ids, idx, nextId, depth); + }); }); } diff --git a/src/graph/Graph.ts b/src/graph/Graph.ts index aaa85e7ac..12cea760c 100644 --- a/src/graph/Graph.ts +++ b/src/graph/Graph.ts @@ -559,6 +559,55 @@ export class Graph { return this._cachingFill$[key]; } + /** + * Retrieve and cache full node properties for multiple images in one request. + * + * @param {string[]} keys - Keys of nodes to fill. + * @returns {Observable} Observable emitting the graph + * when all nodes have been updated. + */ + public cacheFullImages$(keys: string[]): Observable { + const streams = new Set>(); + const uncached: string[] = []; + for (const key of Array.from(new Set(keys))) { + if (key in this._cachingFull$) { + streams.add(this._cachingFull$[key]); + } else if (key in this._cachingFill$) { + streams.add(this._cachingFill$[key]); + } else if (!this.hasNode(key)) { + uncached.push(key); + } else if (!this.getNode(key).complete) { + streams.add(this.cacheFill$(key)); + } + } + + if (uncached.length > 0) { + const batch$ = this._api.getImages$(uncached).pipe( + tap((items: ImagesContract): void => { + this._storeFullImages(items); + }), + map((): Graph => this), + finalize((): void => { + for (const key of uncached) { + if (this._cachingFull$[key] === batch$) { + delete this._cachingFull$[key]; + } + } + this._changed$.next(this); + }), + publishReplay(1), + refCount()); + for (const key of uncached) { + this._cachingFull$[key] = batch$; + } + streams.add(batch$); + } + + return streams.size > 0 ? + observableMerge(...Array.from(streams)).pipe(last()) : + observableOf(this); + } + /** * Retrieve and cache full node properties. * @@ -578,44 +627,9 @@ export class Graph { } this._cachingFull$[key] = this._api.getImages$([key]).pipe( - tap( - (items: ImagesContract): void => { - for (const item of items) { - if (!item.node) { - throw new GraphMapillaryError( - `Image does not exist (${key}, ${item.node}).`); - } - - const id = item.node_id; - if (this.hasNode(id)) { - const node = this.getNode(key); - if (!node.complete) { - this._makeFull(node, item.node); - } - } else { - if (item.node.sequence.id == null) { - throw new GraphMapillaryError( - `Image has no sequence key (${key}).`); - } - - let node: Image = null; - if (this._preDeletedNodes.has(id)) { - node = this._unDeleteNode(id); - } else { - node = new Image(item.node); - } - this._makeFull(node, item.node); - - const lngLat = this._getNodeLngLat(node); - const cellId = this._api.data.geometry - .lngLatToCellId(lngLat); - this._preStore(cellId, node); - this._setNode(node); - - delete this._cachingFull$[id]; - } - } - }), + tap((items: ImagesContract): void => { + this._storeFullImages(items, key); + }), map((): Graph => this), finalize( (): void => { @@ -2068,6 +2082,38 @@ export class Graph { } } + private _storeFullImages(items: ImagesContract, errorKey?: string): void { + for (const item of items) { + const key = errorKey ?? item.node_id; + if (!item.node) { + throw new GraphMapillaryError( + `Image does not exist (${key}, ${item.node}).`); + } + + const id = item.node_id; + if (this.hasNode(id)) { + const existingNode = this.getNode(id); + if (!existingNode.complete) { + this._makeFull(existingNode, item.node); + } + continue; + } + if (item.node.sequence.id == null) { + throw new GraphMapillaryError( + `Image has no sequence key (${key}).`); + } + + const node = this._preDeletedNodes.has(id) ? + this._unDeleteNode(id) : new Image(item.node); + this._makeFull(node, item.node); + const lngLat = this._getNodeLngLat(node); + const cellId = this._api.data.geometry.lngLatToCellId(lngLat); + this._preStore(cellId, node); + this._setNode(node); + delete this._cachingFull$[id]; + } + } + private _makeFull(node: Image, fillNode: SpatialImageEnt): void { if (fillNode.computed_altitude == null) { fillNode.computed_altitude = this._defaultAlt; diff --git a/src/graph/GraphService.ts b/src/graph/GraphService.ts index 2e1978940..2640d6d92 100644 --- a/src/graph/GraphService.ts +++ b/src/graph/GraphService.ts @@ -201,6 +201,26 @@ export class GraphService { })); } + /** Retrieve full image metadata without caching render assets or edges. */ + public cacheImagesMetadata$(ids: string[]): Observable { + return this._graph$.pipe( + first(), + mergeMap( + (graph: Graph): Observable => { + return graph.cacheFullImages$(ids); + }), + map( + (graph: Graph): Image[] => { + return ids.map((id: string): Image => { + if (!graph.hasNode(id)) { + throw new GraphMapillaryError( + `Failed to cache image metadata (${id})`); + } + return graph.getNode(id); + }); + })); + } + /** Retrieve full image metadata without caching render assets or edges. */ public cacheImageMetadata$(id: string): Observable { return this._graph$.pipe( diff --git a/test/component/reorientation/ReorientationEngine.test.ts b/test/component/reorientation/ReorientationEngine.test.ts index 73f062869..0def50579 100644 --- a/test/component/reorientation/ReorientationEngine.test.ts +++ b/test/component/reorientation/ReorientationEngine.test.ts @@ -57,6 +57,9 @@ function provider(images: Fixture, seqIds: string[]): ReorientationProvider { }, fetchSeqIds: (_seqId: string): Promise => Promise.resolve(seqIds.slice()), + cacheImage: (image: ReorientationImage): void => { + images[image.id] = image; + }, }; } @@ -84,6 +87,44 @@ function spherical( } describe("ReorientationEngine.precompute", () => { + it("warms the configured sequence window in one batch", async () => { + const images: Fixture = { + a: spherical("a", 0, -0.0001, 0, 1000), + b: spherical("b", 0, 0, 0, 2000), + c: spherical("c", 0, 0.0001, 0, 3000), + d: spherical("d", 0, 0.0002, 0, 4000), + e: spherical("e", 0, 0.0003, 0, 5000), + }; + const dataProvider = provider(images, ["a", "b", "c", "d", "e"]); + dataProvider.fetchImages = jasmine.createSpy("fetchImages") + .and.callFake((ids: string[]): Promise => + Promise.resolve(ids.map(id => images[id]))); + const engine = new ReorientationEngine(dataProvider); + + await engine.precompute("b", images.b, 2); + + expect(dataProvider.fetchImages).toHaveBeenCalledWith(["a", "c"]); + expect(dataProvider.fetchImages).toHaveBeenCalledWith(["d", "e"]); + expect(engine.get("b").valid).toBe(true); + }); + + it("recomputes provisional metadata when the loaded seed changes", async () => { + const images: Fixture = { + a: spherical("a", 0, -0.0001, 0, 1000), + b: spherical("b", 0, 0, 0, 2000), + c: spherical("c", 0, 0.0001, 0, 3000), + }; + const engine = new ReorientationEngine(provider(images, ["a", "b", "c"])); + await engine.precompute("b", images.b, 0); + expect(engine.get("b").basicX).toBeCloseTo(0.75, 2); + + const loaded = { ...images.b, cca: 90, viewCompassAngle: 90 }; + await engine.precompute("b", loaded, 0); + + expect(engine.get("b").cca).toBe(90); + expect(engine.get("b").basicX).toBeCloseTo(0.5, 2); + }); + it("frames the travel direction for a moving image", async () => { // Straight eastward run, ~11 m apart, 1 s apart -> ~11 m/s, facing // north (cca 0) so travel east maps to basic-x 0.75. (capturedAt is an diff --git a/test/graph/Graph.test.ts b/test/graph/Graph.test.ts index 30d8da7bc..ab9be2ef3 100644 --- a/test/graph/Graph.test.ts +++ b/test/graph/Graph.test.ts @@ -261,6 +261,37 @@ describe("Graph.cacheBoundingBox$", () => { }); }); +describe("Graph.cacheFullImages$", () => { + it("should fetch multiple images in one request", (done: Function) => { + const api = new APIWrapper(new DataProvider()); + const calculator = new GraphCalculator(); + const getImages = new Subject(); + const getImagesSpy = spyOn(api, "getImages$") + .and.returnValue(getImages); + spyOn(api.data.geometry, "lngLatToCellId").and.returnValue("cell-id"); + const graph = new Graph(api, undefined, undefined, calculator); + const firstNode = new ImageHelper().createImageEnt(); + const secondNode = new ImageHelper().createImageEnt(); + secondNode.id = `${firstNode.id}-2`; + + graph.cacheFullImages$([firstNode.id, secondNode.id]).subscribe( + (result: Graph): void => { + expect(result.getNode(firstNode.id).complete).toBe(true); + expect(result.getNode(secondNode.id).complete).toBe(true); + expect(getImagesSpy).toHaveBeenCalledWith( + [firstNode.id, secondNode.id]); + expect(getImagesSpy.calls.count()).toBe(1); + done(); + }); + + getImages.next([ + { node: firstNode, node_id: firstNode.id }, + { node: secondNode, node_id: secondNode.id }, + ]); + getImages.complete(); + }); +}); + describe("Graph.cacheFull$", () => { let helper: ImageHelper; diff --git a/test/graph/GraphService.test.ts b/test/graph/GraphService.test.ts index 9c0c67ccb..3704a8834 100644 --- a/test/graph/GraphService.test.ts +++ b/test/graph/GraphService.test.ts @@ -665,6 +665,37 @@ class TestNode extends Image { } } +describe("GraphService.cacheImagesMetadata$", () => { + it("should cache image metadata as one graph batch", (done: Function) => { + const api: APIWrapper = new APIWrapper(new DataProvider()); + const graph: Graph = new Graph(api); + const cacheFullImages$: Subject = new Subject(); + const cacheSpy = spyOn(graph, "cacheFullImages$") + .and.returnValue(cacheFullImages$); + const firstImage: TestNode = new TestNode( + new ImageHelper().createCoreImageEnt()); + const secondEnt = new ImageHelper().createCoreImageEnt(); + secondEnt.id = `${firstImage.id}-2`; + const secondImage: TestNode = new TestNode(secondEnt); + spyOn(graph, "hasNode").and.returnValue(true); + spyOn(graph, "getNode").and.callFake((id: string): Image => + id === firstImage.id ? firstImage : secondImage); + const graphService: GraphService = new GraphService(graph); + + graphService.cacheImagesMetadata$( + [firstImage.id, secondImage.id]).subscribe( + (images: Image[]): void => { + expect(images).toEqual([firstImage, secondImage]); + expect(cacheSpy).toHaveBeenCalledWith( + [firstImage.id, secondImage.id]); + done(); + }); + + cacheFullImages$.next(graph); + cacheFullImages$.complete(); + }); +}); + describe("GraphService.cacheImageMetadata$", () => { it("should cache metadata without caching render assets", (done: Function) => { const api: APIWrapper = new APIWrapper(new DataProvider()); From 528f27897960426bb650ae90ebca1e4054bb3ec8 Mon Sep 17 00:00:00 2001 From: Boris Masis Date: Wed, 23 Sep 2026 08:40:36 -0700 Subject: [PATCH 56/59] Prioritize sequence images during playback --- src/viewer/PlayService.ts | 45 ++++++++++----------------------- test/viewer/PlayService.test.ts | 27 +++++++++++++++----- 2 files changed, 35 insertions(+), 37 deletions(-) diff --git a/src/viewer/PlayService.ts b/src/viewer/PlayService.ts index 798ba2a00..3df463260 100644 --- a/src/viewer/PlayService.ts +++ b/src/viewer/PlayService.ts @@ -2,7 +2,6 @@ import { combineLatest as observableCombineLatest, empty as observableEmpty, from as observableFrom, - merge as observableMerge, of as observableOf, zip as observableZip, Observable, @@ -129,12 +128,18 @@ export class PlayService { const stateSpeed: number = this._setSpeed(this._speed); this._stateService.setSpeed(stateSpeed); - this._graphModeSubscription = this._speed$.pipe( - map( - (speed: number): GraphMode => { - return speed > PlayService.sequenceSpeed ? GraphMode.Sequence : GraphMode.Spatial; - }), - distinctUntilChanged()) + this._graphModeSubscription = observableCombineLatest( + this._speed$, + this._direction$).pipe( + map( + ([speed, direction]: [number, NavigationDirection]): GraphMode => { + const sequenceDirection = + direction === NavigationDirection.Next || + direction === NavigationDirection.Prev; + return sequenceDirection || speed > PlayService.sequenceSpeed ? + GraphMode.Sequence : GraphMode.Spatial; + }), + distinctUntilChanged()) .subscribe( (mode: GraphMode): void => { this._graphService.setGraphMode(mode); @@ -151,10 +156,9 @@ export class PlayService { ([sequenceId]: [string, string]): string => { return sequenceId; })), - this._graphService.graphMode$, this._direction$).pipe( switchMap( - ([[sequenceId, imageId], mode, direction]: [[string, string], GraphMode, NavigationDirection]): + ([[sequenceId], direction]: [[string, string], NavigationDirection]): Observable<[Sequence, NavigationDirection]> => { if (direction !== NavigationDirection.Next && direction !== NavigationDirection.Prev) { @@ -171,29 +175,8 @@ export class PlayService { return observableOf(undefined); })); - // Caching all images of the sequence is a batching - // optimization for the sequence graph mode. It must - // run alongside image caching instead of gating it: - // it only completes once every batch has been - // retrieved, which for long sequences takes long - // enough to starve playback of images while running. - const sequenceImages$: Observable = - mode === GraphMode.Sequence ? - this._graphService - .cacheSequenceImages$(sequenceId, imageId) - .pipe( - retry(3), - catchError( - (error: Error): Observable => { - console.error(error); - - return observableEmpty(); - })) : - observableEmpty(); - return observableCombineLatest( - observableMerge(sequence$, sequenceImages$).pipe( - distinctUntilChanged()), + sequence$, observableOf(direction)); }), switchMap( diff --git a/test/viewer/PlayService.test.ts b/test/viewer/PlayService.test.ts index 14f381e4f..941f579b3 100644 --- a/test/viewer/PlayService.test.ts +++ b/test/viewer/PlayService.test.ts @@ -242,6 +242,7 @@ describe("PlayService.play", () => { const setGraphModeSpy: jasmine.Spy = spyOn(graphService, "setGraphMode").and.stub(); + playService.setDirection(NavigationDirection.TurnLeft); playService.setSpeed(0); playService.play(); @@ -255,6 +256,21 @@ describe("PlayService.play", () => { expect(setGraphModeSpy.calls.argsFor(2)[0]).toBe(GraphMode.Spatial); }); + it("should keep sequence graph mode for sequence playback", () => { + const playService: PlayService = new PlayService(graphService, stateService); + + const setGraphModeSpy: jasmine.Spy = spyOn(graphService, "setGraphMode").and.stub(); + + playService.setDirection(NavigationDirection.Next); + playService.setSpeed(0); + playService.play(); + playService.setSpeed(1); + playService.setSpeed(0); + + expect(setGraphModeSpy.calls.count()).toBe(1); + expect(setGraphModeSpy.calls.argsFor(0)[0]).toBe(GraphMode.Sequence); + }); + it("should stop immediately if image does not have an edge in current direction and no bridge", () => { const playService: PlayService = new PlayService(graphService, stateService); @@ -520,10 +536,9 @@ describe("PlayService.play", () => { expect(stopSpy.calls.count()).toBe(1); }); - it("should cache sequence when in spatial graph mode", () => { + it("should cache sequence metadata at low speed", () => { const playService: PlayService = new PlayService(graphService, stateService); playService.setDirection(NavigationDirection.Next); - // Set speed to zero so that graph mode is set to spatial when calling play playService.setSpeed(0); const cacheSequenceSpy: jasmine.Spy = spyOn(graphService, "cacheSequence$"); @@ -547,7 +562,7 @@ describe("PlayService.play", () => { playService.stop(); }); - it("should cache sequence images when in sequence graph mode", () => { + it("should not eagerly cache all sequence images", () => { const playService: PlayService = new PlayService(graphService, stateService); playService.setDirection(NavigationDirection.Next); // Set speed to one so that graph mode is set to sequence when calling play @@ -566,10 +581,10 @@ describe("PlayService.play", () => { const currentImageSubject: Subject = >stateService.currentImage$; currentImageSubject.next(currentImage); - expect(cacheSequenceSpy.calls.count()).toBe(0); + expect(cacheSequenceSpy.calls.count()).toBe(1); + expect(cacheSequenceSpy.calls.argsFor(0)[0]).toBe(currentImage.sequenceId); - expect(cacheSequenceImagesSpy.calls.count()).toBe(1); - expect(cacheSequenceImagesSpy.calls.argsFor(0)[0]).toBe(currentImage.sequenceId); + expect(cacheSequenceImagesSpy.calls.count()).toBe(0); playService.stop(); }); From 857120955387b035b90b623201db633029a1e93b Mon Sep 17 00:00:00 2001 From: Boris Masis Date: Thu, 24 Sep 2026 08:38:24 -0700 Subject: [PATCH 57/59] Honor Graph image query batch limits --- src/graph/Graph.ts | 9 +++++---- test/graph/Graph.test.ts | 37 ++++++++++++++++++++----------------- 2 files changed, 25 insertions(+), 21 deletions(-) diff --git a/src/graph/Graph.ts b/src/graph/Graph.ts index 12cea760c..1e67d5ef9 100644 --- a/src/graph/Graph.ts +++ b/src/graph/Graph.ts @@ -46,6 +46,8 @@ import { CoreImagesContract } from "../api/contracts/CoreImagesContract"; import { CancelMapillaryError } from "../error/CancelMapillaryError"; import { geodeticToEnu } from "../geo/GeoCoords"; +const MAX_GRAPH_IMAGE_BATCH_SIZE = 120; + type NodeTiles = { cache: string[]; caching: string[]; @@ -462,9 +464,9 @@ export class Graph { } const coreNodeBatches: string[][] = []; - const batchSize: number = 200; while (coreNodes.length > 0) { - coreNodeBatches.push(coreNodes.splice(0, batchSize)); + coreNodeBatches.push( + coreNodes.splice(0, MAX_GRAPH_IMAGE_BATCH_SIZE)); } const fullNodes$ = observableOf(fullNodes); @@ -743,9 +745,8 @@ export class Graph { batches.push(keys.splice(startIndex, referenceBatchSize)); } - const batchSize: number = 200; while (keys.length > 0) { - batches.push(keys.splice(0, batchSize)); + batches.push(keys.splice(0, MAX_GRAPH_IMAGE_BATCH_SIZE)); } let batchesToCache: number = batches.length; diff --git a/test/graph/Graph.test.ts b/test/graph/Graph.test.ts index ab9be2ef3..f98217e1d 100644 --- a/test/graph/Graph.test.ts +++ b/test/graph/Graph.test.ts @@ -1530,7 +1530,7 @@ describe("Graph.cacheSequenceNodes$", () => { expect(graph.hasNode(nodeKey)).toBe(false); }); - it("should start caching in with single batch when lass than or equal to 200 nodes", () => { + it("should start caching with a single batch for at most 120 nodes", () => { const api = new APIWrapper(new DataProvider()); const graphCalculator = new GraphCalculator(); const edgeCalculator = new EdgeCalculator(); @@ -1550,7 +1550,7 @@ describe("Graph.cacheSequenceNodes$", () => { const result: SequenceContract = { id: sequenceId, - image_ids: Array(200) + image_ids: Array(120) .fill(undefined) .map((_, i) => i.toString()) }; @@ -1562,15 +1562,15 @@ describe("Graph.cacheSequenceNodes$", () => { expect(graph.isCachingSequenceNodes(sequenceId)).toBe(true); expect(imageByKeySpy.calls.count()).toBe(1); - expect(imageByKeySpy.calls.argsFor(0)[0].length).toBe(200); + expect(imageByKeySpy.calls.argsFor(0)[0].length).toBe(120); expect( imageByKeySpy.calls.allArgs() .map((args: string[][]): number => { return args[0].length; }) .reduce((acc: number, cur: number): number => { return acc + cur; }, 0)) - .toBe(200); + .toBe(120); }); - it("should start caching in batches when more than 200 nodes", () => { + it("should start caching in batches when more than 120 nodes", () => { const api = new APIWrapper(new DataProvider()); const graphCalculator = new GraphCalculator(); const edgeCalculator = new EdgeCalculator(); @@ -1590,7 +1590,7 @@ describe("Graph.cacheSequenceNodes$", () => { const result: SequenceContract = { id: sequenceId, - image_ids: Array(201) + image_ids: Array(121) .fill(undefined) .map((_, i) => i.toString()), }; @@ -1602,13 +1602,13 @@ describe("Graph.cacheSequenceNodes$", () => { expect(graph.isCachingSequenceNodes(sequenceId)).toBe(true); expect(imageByKeySpy.calls.count()).toBe(2); - expect(imageByKeySpy.calls.argsFor(0)[0].length).toBe(200); + expect(imageByKeySpy.calls.argsFor(0)[0].length).toBe(120); expect(imageByKeySpy.calls.argsFor(1)[0].length).toBe(1); expect( imageByKeySpy.calls.allArgs() .map((args: string[][]): number => { return args[0].length; }) .reduce((acc: number, cur: number): number => { return acc + cur; }, 0)) - .toBe(201); + .toBe(121); }); it("should start caching prioritized batch when reference node key is specified at start", () => { @@ -1646,11 +1646,12 @@ describe("Graph.cacheSequenceNodes$", () => { expect(graph.isCachingSequenceNodes(sequenceId)).toBe(true); - expect(imageByKeySpy.calls.count()).toBe(3); + expect(imageByKeySpy.calls.count()).toBe(4); expect(imageByKeySpy.calls.argsFor(0)[0].length).toBe(50); expect(imageByKeySpy.calls.argsFor(0)[0][0]).toBe(referenceNodeKey); - expect(imageByKeySpy.calls.argsFor(1)[0].length).toBe(200); - expect(imageByKeySpy.calls.argsFor(2)[0].length).toBe(400 - 200 - 50); + expect(imageByKeySpy.calls.argsFor(1)[0].length).toBe(120); + expect(imageByKeySpy.calls.argsFor(2)[0].length).toBe(120); + expect(imageByKeySpy.calls.argsFor(3)[0].length).toBe(400 - 2 * 120 - 50); expect( imageByKeySpy.calls.allArgs() .map((args: string[][]): number => { return args[0].length; }) @@ -1693,12 +1694,13 @@ describe("Graph.cacheSequenceNodes$", () => { expect(graph.isCachingSequenceNodes(sequenceId)).toBe(true); - expect(imageByKeySpy.calls.count()).toBe(3); + expect(imageByKeySpy.calls.count()).toBe(4); expect(imageByKeySpy.calls.argsFor(0)[0].length).toBe(50); expect(imageByKeySpy.calls.argsFor(0)[0][0]).toBe((400 - 50).toString()); expect(imageByKeySpy.calls.argsFor(0)[0][49]).toBe(referenceNodeKey); - expect(imageByKeySpy.calls.argsFor(1)[0].length).toBe(200); - expect(imageByKeySpy.calls.argsFor(2)[0].length).toBe(400 - 200 - 50); + expect(imageByKeySpy.calls.argsFor(1)[0].length).toBe(120); + expect(imageByKeySpy.calls.argsFor(2)[0].length).toBe(120); + expect(imageByKeySpy.calls.argsFor(3)[0].length).toBe(400 - 2 * 120 - 50); expect( imageByKeySpy.calls.allArgs() .map((args: string[][]): number => { return args[0].length; }) @@ -1741,13 +1743,14 @@ describe("Graph.cacheSequenceNodes$", () => { expect(graph.isCachingSequenceNodes(sequenceId)).toBe(true); - expect(imageByKeySpy.calls.count()).toBe(3); + expect(imageByKeySpy.calls.count()).toBe(4); expect(imageByKeySpy.calls.argsFor(0)[0].length).toBe(50); expect(imageByKeySpy.calls.argsFor(0)[0][0]).toBe((200 - 25).toString()); expect(imageByKeySpy.calls.argsFor(0)[0][25]).toBe(referenceNodeKey); expect(imageByKeySpy.calls.argsFor(0)[0][49]).toBe((200 + 24).toString()); - expect(imageByKeySpy.calls.argsFor(1)[0].length).toBe(200); - expect(imageByKeySpy.calls.argsFor(2)[0].length).toBe(400 - 200 - 50); + expect(imageByKeySpy.calls.argsFor(1)[0].length).toBe(120); + expect(imageByKeySpy.calls.argsFor(2)[0].length).toBe(120); + expect(imageByKeySpy.calls.argsFor(3)[0].length).toBe(400 - 2 * 120 - 50); expect( imageByKeySpy.calls.allArgs() .map((args: string[][]): number => { return args[0].length; }) From f1840550c081f071d4a02d1a4f110ae4ea83345a Mon Sep 17 00:00:00 2001 From: Boris Masis Date: Fri, 25 Sep 2026 02:40:07 -0700 Subject: [PATCH 58/59] Prepare mapillary-js fb15 package --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 45a799ec3..3ecd3904e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mapillary-js", - "version": "5.0.0-beta.5-fb14", + "version": "5.0.0-beta.5-fb15", "description": "A WebGL interactive street imagery library", "license": "MIT", "main": "dist/mapillary.js", From f3162d56a4115fd2bf184dfe8590306c85ee8e5f Mon Sep 17 00:00:00 2001 From: Boris Masis Date: Fri, 25 Sep 2026 02:44:24 -0700 Subject: [PATCH 59/59] Keep metadata batches within Graph limits --- src/graph/Graph.ts | 9 +++++---- src/graph/GraphService.ts | 17 ----------------- test/graph/Graph.test.ts | 27 +++++++++++++++++++++++++++ test/graph/GraphService.test.ts | 31 ------------------------------- 4 files changed, 32 insertions(+), 52 deletions(-) diff --git a/src/graph/Graph.ts b/src/graph/Graph.ts index 1e67d5ef9..170d7284b 100644 --- a/src/graph/Graph.ts +++ b/src/graph/Graph.ts @@ -583,14 +583,15 @@ export class Graph { } } - if (uncached.length > 0) { - const batch$ = this._api.getImages$(uncached).pipe( + while (uncached.length > 0) { + const batchKeys = uncached.splice(0, MAX_GRAPH_IMAGE_BATCH_SIZE); + const batch$ = this._api.getImages$(batchKeys).pipe( tap((items: ImagesContract): void => { this._storeFullImages(items); }), map((): Graph => this), finalize((): void => { - for (const key of uncached) { + for (const key of batchKeys) { if (this._cachingFull$[key] === batch$) { delete this._cachingFull$[key]; } @@ -599,7 +600,7 @@ export class Graph { }), publishReplay(1), refCount()); - for (const key of uncached) { + for (const key of batchKeys) { this._cachingFull$[key] = batch$; } streams.add(batch$); diff --git a/src/graph/GraphService.ts b/src/graph/GraphService.ts index 2640d6d92..a67c56e94 100644 --- a/src/graph/GraphService.ts +++ b/src/graph/GraphService.ts @@ -221,23 +221,6 @@ export class GraphService { })); } - /** Retrieve full image metadata without caching render assets or edges. */ - public cacheImageMetadata$(id: string): Observable { - return this._graph$.pipe( - first(), - mergeMap( - (graph: Graph): Observable => { - return this._cacheFullImages$(graph, [id]); - }), - map( - (graph: Graph): Image => { - if (!graph.hasNode(id)) { - throw new GraphMapillaryError(`Failed to cache image metadata (${id})`); - } - return graph.getNode(id); - })); - } - /** * Cache a image in the graph and retrieve it. * diff --git a/test/graph/Graph.test.ts b/test/graph/Graph.test.ts index f98217e1d..653cb35ba 100644 --- a/test/graph/Graph.test.ts +++ b/test/graph/Graph.test.ts @@ -290,6 +290,33 @@ describe("Graph.cacheFullImages$", () => { ]); getImages.complete(); }); + + it("should limit requests to 120 images", (done: Function) => { + const api = new APIWrapper(new DataProvider()); + const calculator = new GraphCalculator(); + const nodes = Array(121).fill(undefined).map((_, index: number) => { + const node = new ImageHelper().createImageEnt(); + node.id = `image-${index}`; + return node; + }); + const getImagesSpy = spyOn(api, "getImages$") + .and.callFake((ids: string[]): Observable => + observableOf(ids.map((id: string) => { + const node = nodes.find( + (candidate: ImageEnt): boolean => candidate.id === id); + return { node, node_id: id }; + }))); + spyOn(api.data.geometry, "lngLatToCellId").and.returnValue("cell-id"); + const graph = new Graph(api, undefined, undefined, calculator); + + graph.cacheFullImages$(nodes.map((node: ImageEnt) => node.id)).subscribe( + (): void => { + expect(getImagesSpy.calls.count()).toBe(2); + expect(getImagesSpy.calls.argsFor(0)[0].length).toBe(120); + expect(getImagesSpy.calls.argsFor(1)[0].length).toBe(1); + done(); + }); + }); }); describe("Graph.cacheFull$", () => { diff --git a/test/graph/GraphService.test.ts b/test/graph/GraphService.test.ts index 3704a8834..8f082164b 100644 --- a/test/graph/GraphService.test.ts +++ b/test/graph/GraphService.test.ts @@ -696,37 +696,6 @@ describe("GraphService.cacheImagesMetadata$", () => { }); }); -describe("GraphService.cacheImageMetadata$", () => { - it("should cache metadata without caching render assets", (done: Function) => { - const api: APIWrapper = new APIWrapper(new DataProvider()); - const graph: Graph = new Graph(api); - - spyOn(graph, "isCachingFull").and.returnValue(false); - spyOn(graph, "hasNode").and.returnValues(false, true); - - const cacheFull$: Subject = new Subject(); - const cacheFullSpy: jasmine.Spy = spyOn(graph, "cacheFull$"); - cacheFullSpy.and.returnValue(cacheFull$); - - const image: TestNode = new TestNode( - new ImageHelper().createCoreImageEnt()); - const cacheAssetsSpy: jasmine.Spy = spyOn(image, "cacheAssets$"); - spyOn(graph, "getNode").and.returnValue(image); - - const graphService: GraphService = new GraphService(graph); - graphService.cacheImageMetadata$(image.id).subscribe( - (result: Image): void => { - expect(result).toBe(image); - expect(cacheFullSpy).toHaveBeenCalledWith(image.id); - expect(cacheAssetsSpy).not.toHaveBeenCalled(); - done(); - }); - - cacheFull$.next(graph); - cacheFull$.complete(); - }); -}); - describe("GraphService.cacheNode$", () => { let helper: ImageHelper;