mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Merge pull request #396 from bilawalsidhu/refactor/location-controls
refactor: isolate Location controls and search lifecycle
This commit is contained in:
18 files changed
+873
-201
No files matched your search
@@ -13,6 +13,8 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- Extract Location controls and cancellable search presentation; preserve navigation handoff and prevent delayed POI expansion after closing the row.
|
||||
|
||||
- Separate Layers panel presentation and clear-control bindings from layer lifecycle operations; revoke listeners and subscriptions on replacement or teardown.
|
||||
|
||||
- Extract Map Source controls with listener cleanup and protection against obsolete selection feedback.
|
||||
|
||||
@@ -279,3 +279,11 @@ the caller. Hidden-page refresh scheduling remains an explicit callback.
|
||||
|
||||
`ui/layers/feedback` exposes the existing pure loading/notice reducers separately
|
||||
from DOM controls. Scheduling and presentation stay with their callers.
|
||||
|
||||
## Location controls
|
||||
|
||||
`ui/location` exports Location controls, the cancellable lookup controller and
|
||||
the existing location-status formatter. Callers supply city data, search and
|
||||
navigation operations. The component owns DOM listeners and pending expansion;
|
||||
it imports no geocoder, camera engine, layer or application bootstrap. Existing
|
||||
camera authority and search providers remain supplied by the application.
|
||||
@@ -1,5 +1,15 @@
|
||||
# God's Eye View Current State
|
||||
|
||||
## Location control ownership
|
||||
|
||||
City/POI rows, search/reset bindings, location readouts and the orbit indicator
|
||||
have a dedicated component with explicit navigation actions. A separate lookup
|
||||
controller cancels superseded searches and checks camera authority before flight
|
||||
and result presentation. Existing search providers and camera handoff policy are
|
||||
preserved. Replacing or closing a POI row cancels its pending expansion frame;
|
||||
destruction releases listeners, pending searches and the orbit indicator.
|
||||
|
||||
|
||||
## Layer panel ownership
|
||||
|
||||
Layer rows, feed feedback, counts, focus-preserving chips and toggle listeners
|
||||
|
||||
+2
-1
@@ -128,6 +128,7 @@
|
||||
"./ui/effects/bloom": "./src/bloom.js",
|
||||
"./ui/maps": "./src/ui/mapSource.js",
|
||||
"./ui/layers": "./src/ui/layers.js",
|
||||
"./ui/layers/feedback": "./src/loadingFeedback.js"
|
||||
"./ui/layers/feedback": "./src/loadingFeedback.js",
|
||||
"./ui/location": "./src/ui/location.js"
|
||||
}
|
||||
}
|
||||
@@ -159,5 +159,12 @@
|
||||
"src/ui/clearLayersControl.js",
|
||||
"scripts/qa-layer-panel.mjs",
|
||||
"src/loadingFeedback.js",
|
||||
"src/data/installationFeedback.js"
|
||||
"src/data/installationFeedback.js",
|
||||
"src/ui/location.js",
|
||||
"src/ui/locationControls.js",
|
||||
"src/ui/locationSearch.js",
|
||||
"src/locationStatus.js",
|
||||
"src/ui/locationControls.test.mjs",
|
||||
"src/ui/locationSearch.test.mjs",
|
||||
"scripts/qa-location-controls.mjs"
|
||||
]
|
||||
@@ -336,5 +336,15 @@
|
||||
"src/data/installationFeedback.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"location-controls": {
|
||||
"exports": ["./ui/location"],
|
||||
"modules": [
|
||||
"src/ui/location.js",
|
||||
"src/ui/locationControls.js",
|
||||
"src/ui/locationSearch.js",
|
||||
"src/locationStatus.js"
|
||||
],
|
||||
"external": []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
#!/usr/bin/env node
|
||||
/** Location UI acceptance; controlled search results isolate UI races from geocoder availability. */
|
||||
import fs from 'node:fs';
|
||||
import puppeteer from 'puppeteer';
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
...(process.platform === 'darwin'
|
||||
? ['--use-angle=metal', '--enable-gpu']
|
||||
: ['--use-gl=angle', '--use-angle=swiftshader']),
|
||||
],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
const errors = [];
|
||||
page.on('pageerror', (e) => errors.push(e.message));
|
||||
let failures = 0;
|
||||
const check = (name, ok) => {
|
||||
console.log(`[${ok ? 'PASS' : 'FAIL'}] ${name}`);
|
||||
if (!ok) failures++;
|
||||
};
|
||||
try {
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
await page.goto(
|
||||
`${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`,
|
||||
{ waitUntil: 'domcontentloaded' },
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView?.styleManager?._locationControls &&
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await page.click('#location-bar-toggle');
|
||||
await page.click('[data-pin-target="location-bar"]');
|
||||
const cityId = await page.$eval(
|
||||
'.location-pill',
|
||||
(node) => node.dataset.locationId,
|
||||
);
|
||||
await page.click('.location-pill');
|
||||
await page.waitForFunction(() =>
|
||||
document.getElementById('poi-row').classList.contains('expanded'),
|
||||
);
|
||||
check(
|
||||
'native city selection highlights and expands its POIs',
|
||||
await page.evaluate(
|
||||
(id) =>
|
||||
window.__godsEyeView.styleManager._activeLocationId === id &&
|
||||
document.querySelector('.location-pill.active')?.dataset.locationId ===
|
||||
id,
|
||||
cityId,
|
||||
),
|
||||
);
|
||||
await page.keyboard.press('w');
|
||||
check(
|
||||
'POI keyboard action selects the second landmark',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
window.__godsEyeView.styleManager._activePoiIndex === 1 &&
|
||||
document.querySelector('.poi-pill.active')?.dataset.poiIndex === '1',
|
||||
),
|
||||
);
|
||||
check(
|
||||
'orbit controls and indicator agree',
|
||||
await page.evaluate(() => {
|
||||
const ui = window.__godsEyeView.styleManager;
|
||||
ui.setOrbit(true);
|
||||
const active =
|
||||
ui.orbitController.active &&
|
||||
document.getElementById('orbit-indicator').classList.contains('active');
|
||||
ui.setOrbit(false);
|
||||
return (
|
||||
active &&
|
||||
!ui.orbitController.active &&
|
||||
!document.getElementById('orbit-indicator').classList.contains('active')
|
||||
);
|
||||
}),
|
||||
);
|
||||
await page.click('#search-toggle');
|
||||
await page.type('#location-search', 'qwerty');
|
||||
check(
|
||||
'typing in search leaves POI selection unchanged',
|
||||
await page.evaluate(
|
||||
() => window.__godsEyeView.styleManager._activePoiIndex === 1,
|
||||
),
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
const lookup = window.__godsEyeView.styleManager._locationLookup;
|
||||
window.__qaSearchRequests = [];
|
||||
lookup.search = (query, options) =>
|
||||
new Promise((resolve) =>
|
||||
window.__qaSearchRequests.push({ query, options, resolve }),
|
||||
);
|
||||
document.getElementById('location-search').value = 'First';
|
||||
});
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForFunction(() => window.__qaSearchRequests.length === 1);
|
||||
await page.evaluate(() => {
|
||||
document.getElementById('location-search').value = 'Second';
|
||||
document.getElementById('location-search').focus();
|
||||
});
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForFunction(() => window.__qaSearchRequests.length === 2);
|
||||
check(
|
||||
'second native submit aborts the first request and keeps current busy state',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
window.__qaSearchRequests[0].options.signal.aborted &&
|
||||
document
|
||||
.getElementById('location-search')
|
||||
.classList.contains('searching'),
|
||||
),
|
||||
);
|
||||
await page.evaluate(() =>
|
||||
window.__qaSearchRequests[1].resolve({
|
||||
label: 'Second landmark, Test city',
|
||||
}),
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView.styleManager._searchedLocationLabel ===
|
||||
'Second landmark, Test city',
|
||||
);
|
||||
await page.evaluate(() =>
|
||||
window.__qaSearchRequests[0].resolve({
|
||||
label: 'First landmark, Test city',
|
||||
}),
|
||||
);
|
||||
check(
|
||||
'late result cannot overwrite the current location readout or style',
|
||||
await page.evaluate(() => {
|
||||
const ui = window.__godsEyeView.styleManager;
|
||||
return (
|
||||
ui._searchedLocationLabel === 'Second landmark, Test city' &&
|
||||
ui._locationMiniCity.textContent.includes('Second landmark') &&
|
||||
ui._locationMiniPoi.textContent === 'Test city' &&
|
||||
ui.activeStyle === 'normal' &&
|
||||
!ui._locationSearch.classList.contains('searching')
|
||||
);
|
||||
}),
|
||||
);
|
||||
check(
|
||||
'cancelled POI expansion stays closed after a frame',
|
||||
await page.evaluate(async (id) => {
|
||||
const ui = window.__godsEyeView.styleManager;
|
||||
ui._expandPOIRow(id);
|
||||
ui._collapsePOIRow();
|
||||
await new Promise((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(resolve)),
|
||||
);
|
||||
return (
|
||||
!ui._poiRow.classList.contains('expanded') &&
|
||||
!ui._locationBarDivider.classList.contains('visible')
|
||||
);
|
||||
}, cityId),
|
||||
);
|
||||
await page.evaluate(
|
||||
(id) => window.__godsEyeView.styleManager._onCityPillClick(id),
|
||||
cityId,
|
||||
);
|
||||
await page.waitForFunction(() =>
|
||||
document.getElementById('poi-row').classList.contains('expanded'),
|
||||
);
|
||||
// Finish the camera's existing city flight before inspecting both layouts.
|
||||
await page.waitForFunction(
|
||||
() => !window.__godsEyeView.viewer.camera._currentFlight,
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(resolve)),
|
||||
),
|
||||
);
|
||||
const settled = await page
|
||||
.waitForFunction(
|
||||
() => {
|
||||
const scene = window.__godsEyeView.viewer.scene;
|
||||
for (let index = 0; index < scene.primitives.length; index++) {
|
||||
const primitive = scene.primitives.get(index);
|
||||
if (
|
||||
typeof primitive.tilesLoaded === 'boolean' &&
|
||||
!primitive.tilesLoaded
|
||||
)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{ timeout: 60000 },
|
||||
)
|
||||
.then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
check('visible city tiles settle before screenshots', settled);
|
||||
fs.mkdirSync('qa-shots/location-controls', { recursive: true });
|
||||
await page.screenshot({ path: 'qa-shots/location-controls/desktop.png' });
|
||||
await page.setViewport({ width: 620, height: 900 });
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document.getElementById('left-panel-stack')?.dataset.layoutMode ===
|
||||
'mobile',
|
||||
);
|
||||
await page.screenshot({ path: 'qa-shots/location-controls/narrow.png' });
|
||||
check(
|
||||
'native reset returns through the existing camera handoff',
|
||||
await page.evaluate(async () => {
|
||||
const ui = window.__godsEyeView.styleManager;
|
||||
ui._resetGlobeBtn.click();
|
||||
const pending = ui._globeResetPromise;
|
||||
if (!pending) return false;
|
||||
await pending;
|
||||
return (
|
||||
!ui.orbitController.active && !window.__godsEyeView.viewer.trackedEntity
|
||||
);
|
||||
}),
|
||||
);
|
||||
check(
|
||||
'destroyed controls cannot navigate or submit another lookup',
|
||||
await page.evaluate(() => {
|
||||
const ui = window.__godsEyeView.styleManager;
|
||||
const count = window.__qaSearchRequests.length;
|
||||
ui._locationControls.destroy();
|
||||
ui._locationLookup.destroy();
|
||||
const before = ui._navigationGeneration;
|
||||
ui._locationPills.querySelector('button').click();
|
||||
ui._resetGlobeBtn.click();
|
||||
ui._locationSearch.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }),
|
||||
);
|
||||
return (
|
||||
before === ui._navigationGeneration &&
|
||||
count === window.__qaSearchRequests.length
|
||||
);
|
||||
}),
|
||||
);
|
||||
check('no uncaught browser errors', errors.length === 0);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
console.log(`RESULT: ${failures} failures`);
|
||||
process.exitCode = failures ? 1 : 0;
|
||||
+24
-31
@@ -243,22 +243,22 @@ test('validated voice camera destinations share the UI navigation authority faca
|
||||
});
|
||||
|
||||
test('deferred search releases only after its final authority check', () => {
|
||||
const handler = body(
|
||||
ui,
|
||||
/this\._locationSearch\.addEventListener\('keydown', async \(e\) => \{([\s\S]*?)\n \}\);/,
|
||||
'search handler',
|
||||
);
|
||||
ordered(handler, [
|
||||
"this._beginDeferredNavigation('location')",
|
||||
'this._activeLocationSearchGeneration = generation;',
|
||||
'searchAndFlyTo(this.viewer, query',
|
||||
'beforeFly: () => this._reassertNavigationHandoff(generation)',
|
||||
'generation !== this._navigationGeneration',
|
||||
const search = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'locationSearch.js'), 'utf8');
|
||||
ordered(search, [
|
||||
'const authority = this.begin();',
|
||||
'this.onStart(authority);',
|
||||
'await this.search(query',
|
||||
'beforeFly: () => current() && this.beforeFly(authority)',
|
||||
'if (!current() || controller.signal.aborted) return;',
|
||||
'destination?.cancelled',
|
||||
'finally',
|
||||
'this._settleLocationSearchUi(generation)',
|
||||
], 'deferred search');
|
||||
assert.doesNotMatch(handler.slice(0, handler.indexOf('searchAndFlyTo')), /_releaseFollowCamera/);
|
||||
'this.onSettled(authority)',
|
||||
], 'deferred search component');
|
||||
assert.match(ui, /begin: \(\) => this\._beginDeferredNavigation\('location'\)/);
|
||||
assert.match(ui, /beforeFly: \(generation\) => this\._reassertNavigationHandoff\(generation\)/);
|
||||
assert.match(ui, /isCurrent: \(generation\) => !this\._disposed && generation === this\._navigationGeneration/);
|
||||
assert.match(ui, /onSettled: \(generation\) => this\._settleLocationSearchUi\(generation\)/);
|
||||
assert.doesNotMatch(search.slice(0, search.indexOf('await this.search')), /_releaseFollowCamera/);
|
||||
});
|
||||
|
||||
test('a direct globe gesture retires delayed camera and selection restore only', () => {
|
||||
@@ -337,25 +337,18 @@ test('teardown synchronously closes immediate camera entry points', () => {
|
||||
});
|
||||
|
||||
test('teardown refuses deferred location work before geocoding begins', () => {
|
||||
const deferred = body(
|
||||
ui,
|
||||
/_beginDeferredNavigation\(noun = 'location', \{ cancelPendingSelection = true \} = \{\}\) \{([\s\S]*?)\n \}/,
|
||||
'deferred navigation',
|
||||
);
|
||||
const deferred = body(ui, /_beginDeferredNavigation\(noun = 'location', \{ cancelPendingSelection = true \} = \{\}\) \{([\s\S]*?)\n \}/, 'deferred navigation');
|
||||
assert.match(deferred, /disposed: this\._disposed/);
|
||||
|
||||
const handler = body(
|
||||
ui,
|
||||
/this\._locationSearch\.addEventListener\('keydown', async \(e\) => \{([\s\S]*?)\n \}\);/,
|
||||
'search handler',
|
||||
);
|
||||
ordered(handler, [
|
||||
"const generation = this._beginDeferredNavigation('location');",
|
||||
'if (generation === false)',
|
||||
'this._locationSearch.blur();',
|
||||
'searchAndFlyTo(this.viewer, query',
|
||||
const search = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'locationSearch.js'), 'utf8');
|
||||
ordered(search, [
|
||||
'if (!query || this.destroyed) return;',
|
||||
'const authority = this.begin();',
|
||||
'if (authority === false)',
|
||||
'this.input.blur();',
|
||||
'this.onStart(authority);',
|
||||
'await this.search(query',
|
||||
], 'disposed search refusal');
|
||||
assert.match(handler, /if \(generation === false\) \{[\s\S]*?return;[\s\S]*?\}\s*this\._activeLocationSearchGeneration/);
|
||||
assert.match(search, /if \(authority === false\) \{[\s\S]*?return;[\s\S]*?\}\s*this\.controller/);
|
||||
});
|
||||
|
||||
test('refused canned destinations commit no location or POI state', () => {
|
||||
|
||||
@@ -468,11 +468,11 @@ test('Reset releases Contact camera ownership through its selection-preserving r
|
||||
assert.ok(contextRelease > resetStart);
|
||||
assert.ok(satelliteRelease > contextRelease);
|
||||
assert.match(ui, /this\._cockpitResetGlobeBtn = document\.getElementById\('cockpit-reset-globe'\)/);
|
||||
assert.match(
|
||||
ui,
|
||||
/for \(const button of \[this\._resetGlobeBtn, this\._cockpitResetGlobeBtn\]\) \{[\s\S]*?addEventListener\('click', this\._globeResetHandler\)/,
|
||||
'both reset controls must delegate to the one shared reset route',
|
||||
);
|
||||
const controls = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'locationControls.js'), 'utf8');
|
||||
assert.match(ui, /resetButtons: \[this\._resetGlobeBtn, this\._cockpitResetGlobeBtn\]/);
|
||||
assert.match(ui, /onReset: \(\) => this\.resetToGlobeView\(\)/);
|
||||
assert.match(controls, /for \(const button of elements\.resetButtons\)[\s\S]*?this\.bind\(button, 'click', onReset\)/,
|
||||
'both reset controls delegate to the same supplied reset action');
|
||||
assert.match(ui, /if \(this\.resetGlobeButton\) this\.resetGlobeButton\.hidden = false;/);
|
||||
assert.match(ui, /if \(this\.resetGlobeButton\) this\.resetGlobeButton\.hidden = true;/);
|
||||
});
|
||||
@@ -496,7 +496,10 @@ test('Location navigation releases immediate routes before flight and deferred r
|
||||
const poiHandler = ui.slice(ui.indexOf('_onPoiClick(cityId, poiIndex) {'), ui.indexOf('_expandPOIRow(cityId) {'));
|
||||
assert.match(cityHandler, /if \(result === false\) return;[\s\S]*?_setActiveLocation/);
|
||||
assert.match(poiHandler, /if \(result === false\) return;[\s\S]*?_setActiveLocation/);
|
||||
assert.match(ui.slice(search, search + 320), /beforeFly: \(\) => this\._reassertNavigationHandoff\(generation\)/);
|
||||
assert.match(ui, /beforeFly: \(generation\) => this\._reassertNavigationHandoff\(generation\)/);
|
||||
assert.match(ui.slice(search, search + 180), /\.\.\.options/);
|
||||
const lookup = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'locationSearch.js'), 'utf8');
|
||||
assert.match(lookup, /beforeFly: \(\) => current\(\) && this\.beforeFly\(authority\)/);
|
||||
assert.ok(voiceStart >= 0, 'voice Location must use the same handoff');
|
||||
assert.match(voiceActions, /styleManager\.reassertDeferredLocationNavigation\(generation\)/);
|
||||
});
|
||||
|
||||
@@ -44,11 +44,13 @@ test('a free-text search records its destination for the LOCATION mini-status',
|
||||
});
|
||||
|
||||
test('the mini-status reads its copy from the shared formatter', () => {
|
||||
assert.match(ui, /import \{ locationMiniStatus \} from '\.\/locationStatus\.js';/);
|
||||
const controls = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'locationControls.js'), 'utf8');
|
||||
assert.match(controls, /import \{ locationMiniStatus \} from '\.\.\/locationStatus\.js';/);
|
||||
assert.match(controls, /const lines = locationMiniStatus\(state\)/);
|
||||
const start = ui.indexOf(' _updateLocationMiniStatus() {');
|
||||
assert.ok(start > 0, '_updateLocationMiniStatus is missing');
|
||||
const body = ui.slice(start, ui.indexOf('\n }', start));
|
||||
assert.match(body, /locationMiniStatus\(\{[\s\S]*?searchedLabel: this\._searchedLocationLabel,[\s\S]*?\}\)/);
|
||||
assert.match(body, /_locationControls\?\.renderStatus\(\{[\s\S]*?searchedLabel: this\._searchedLocationLabel,[\s\S]*?\}\)/);
|
||||
// No second copy of the placeholder strings to drift out of sync.
|
||||
assert.doesNotMatch(body, /Location: --/);
|
||||
});
|
||||
|
||||
@@ -55,7 +55,10 @@ export function locationMiniStatus({
|
||||
// The remaining address is the place's context ("Japan", "Minato City,
|
||||
// Tokyo, Japan"); the readout is ellipsised in CSS, so a long tail is
|
||||
// safe. A one-segment geocode ("Japan") has no context to show.
|
||||
poi: segments.length > 1 ? segments.slice(1).join(', ') : 'Searched location',
|
||||
poi:
|
||||
segments.length > 1
|
||||
? segments.slice(1).join(', ')
|
||||
: 'Searched location',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -693,6 +693,8 @@ test('visual input listeners are revoked before asynchronous UI teardown', () =>
|
||||
assert.match(synchronous, /this\._visualEffects\.stop\(\)/);
|
||||
assert.match(synchronous, /this\._mapSourceControls\?\.destroy\(\)/);
|
||||
assert.match(synchronous, /this\._clearLayersControl\?\.destroy\(\)/);
|
||||
assert.match(synchronous, /this\._locationControls\?\.destroy\(\)/);
|
||||
assert.match(synchronous, /this\._locationLookup\?\.destroy\(\)/);
|
||||
assert.match(synchronous, /this\._displayControls\?\.destroy\(\)/);
|
||||
assert.match(synchronous, /this\._styleParameters\?\.destroy\(\)/);
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { LocationControls, LocationSearch } from './ui/location.js';
|
||||
import { bindClearLayersControl } from './ui/layers.js';
|
||||
import { createMapSourceControls } from './ui/mapSource.js';
|
||||
import { VisualEffects, STYLES, GLOBAL_POST_DEFAULTS, STYLE_PRESET_DEFAULTS, MILITARY_DETECTION_PRESET } from './ui/effects.js';
|
||||
@@ -13,7 +14,6 @@ import {
|
||||
decodeBloomIntensity,
|
||||
} from './bloom.js';
|
||||
import { LOCATIONS, CITY_POIS, GLOBE_VIEW, flyToGlobeView, flyToPresetLocation, flyToPOI, searchAndFlyTo } from './locations.js';
|
||||
import { locationMiniStatus } from './locationStatus.js';
|
||||
import { interruptCameraMotion } from './cameraVerbs.js';
|
||||
import {
|
||||
aircraftTrackingTarget,
|
||||
@@ -2085,7 +2085,6 @@ export class StyleManager {
|
||||
this._shareTrackingAcquiringKey = null;
|
||||
this._shareTrackingNoticeGeneration = 0;
|
||||
this._globeResetPromise = null;
|
||||
this._globeResetHandler = null;
|
||||
this._clearSelectedLayersPromise = null;
|
||||
this._clearSelectedLayersManagerPromise = null;
|
||||
this._dataManager = null;
|
||||
@@ -2565,7 +2564,6 @@ export class StyleManager {
|
||||
this._initLocationBar();
|
||||
this._initShareButton();
|
||||
this._initClearSelectedLayersButton();
|
||||
this._initResetGlobeButton();
|
||||
this._initHUDToggle();
|
||||
this._initModels3dToggle();
|
||||
this._applyGlobalPostDefaults();
|
||||
@@ -8528,97 +8526,45 @@ export class StyleManager {
|
||||
* @returns {void}
|
||||
*/
|
||||
_initLocationBar() {
|
||||
const QWERTY_KEYS = ['Q', 'W', 'E', 'R', 'T'];
|
||||
|
||||
// Render city pills (no submenu wrappers — POI row is separate)
|
||||
for (const [cityId, city] of Object.entries(CITY_POIS)) {
|
||||
const pill = document.createElement('button');
|
||||
pill.className = 'location-pill';
|
||||
pill.dataset.locationId = cityId;
|
||||
pill.textContent = city.name;
|
||||
pill.addEventListener('click', () => this._onCityPillClick(cityId));
|
||||
this._locationPills.appendChild(pill);
|
||||
}
|
||||
|
||||
// QWERTY keyboard navigation for POIs
|
||||
this._poiKeydownHandler = (e) => {
|
||||
if (!this._expandedCityId) return;
|
||||
// Bail while a form control is focused so POI hotkeys don't fire from a
|
||||
// <select> dropdown's type-ahead or while typing in a field (M9).
|
||||
const isFormControl = e.target?.matches?.('select, input, textarea')
|
||||
|| e.target === this._locationSearch;
|
||||
if (isFormControl) return;
|
||||
|
||||
const keyIndex = QWERTY_KEYS.indexOf(e.key.toUpperCase());
|
||||
if (keyIndex === -1) return;
|
||||
|
||||
const city = CITY_POIS[this._expandedCityId];
|
||||
if (city && keyIndex < city.pois.length) {
|
||||
this._onPoiClick(this._expandedCityId, keyIndex);
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', this._poiKeydownHandler);
|
||||
|
||||
// Search toggle (expand/collapse)
|
||||
this._searchToggle.addEventListener('click', () => {
|
||||
this._locationSearch.classList.toggle('expanded');
|
||||
if (this._locationSearch.classList.contains('expanded')) {
|
||||
this._locationSearch.focus();
|
||||
}
|
||||
this._locationControls?.destroy();
|
||||
this._locationLookup?.destroy();
|
||||
this._locationLookup = new LocationSearch({
|
||||
input: this._locationSearch,
|
||||
begin: () => this._beginDeferredNavigation('location'),
|
||||
isCurrent: (generation) => !this._disposed && generation === this._navigationGeneration,
|
||||
beforeFly: (generation) => this._reassertNavigationHandoff(generation),
|
||||
search: (query, options) => searchAndFlyTo(this.viewer, query, {
|
||||
placeSearch: this.placeSearch,
|
||||
...options,
|
||||
}),
|
||||
onStart: (generation) => { this._activeLocationSearchGeneration = generation; },
|
||||
onResult: (destination, query) => {
|
||||
this._searchedLocationLabel = destination.label || query;
|
||||
this._setActiveLocation(null);
|
||||
this._currentPoi = null;
|
||||
this._collapsePOIRow();
|
||||
this._updateLocationMiniStatus();
|
||||
},
|
||||
onMissing: () => this._showToast('Location not found'),
|
||||
onError: (error) => {
|
||||
console.error('[Search] Geocoding failed:', error);
|
||||
this._showToast('Search failed');
|
||||
},
|
||||
onSettled: (generation) => this._settleLocationSearchUi(generation),
|
||||
});
|
||||
|
||||
// Search submit on Enter
|
||||
this._locationSearch.addEventListener('keydown', async (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
const query = this._locationSearch.value.trim();
|
||||
if (!query) return;
|
||||
const generation = this._beginDeferredNavigation('location');
|
||||
if (generation === false) {
|
||||
this._locationSearch.classList.remove('searching');
|
||||
this._locationSearch.blur();
|
||||
return;
|
||||
}
|
||||
this._activeLocationSearchGeneration = generation;
|
||||
this._locationSearchController?.abort();
|
||||
const searchController = new AbortController();
|
||||
this._locationSearchController = searchController;
|
||||
this._locationSearch.classList.add('searching');
|
||||
try {
|
||||
const destination = await searchAndFlyTo(this.viewer, query, {
|
||||
placeSearch: this.placeSearch,
|
||||
signal: searchController.signal,
|
||||
beforeFly: () => this._reassertNavigationHandoff(generation),
|
||||
});
|
||||
if (this._disposed || generation !== this._navigationGeneration) return;
|
||||
if (destination?.cancelled) {
|
||||
// Authority changed while the lookup was resolving; remain inert.
|
||||
} else if (destination) {
|
||||
// The ACTIVE STYLE indicator reports the STYLE and nothing else.
|
||||
// Writing the searched city here made the top-right corner read
|
||||
// "ACTIVE STYLE / TOKYO"; where the camera is belongs to the
|
||||
// LOCATION panel's own readout, which is updated below.
|
||||
//
|
||||
// Set before _setActiveLocation(null) so its own mini-status
|
||||
// refresh already sees the destination — the readout never blinks
|
||||
// through "Location: --" on the way to the searched place.
|
||||
this._searchedLocationLabel = destination.label || query;
|
||||
this._setActiveLocation(null);
|
||||
this._currentPoi = null;
|
||||
this._collapsePOIRow();
|
||||
this._updateLocationMiniStatus();
|
||||
} else {
|
||||
this._showToast('Location not found');
|
||||
}
|
||||
} catch (err) {
|
||||
if (searchController.signal.aborted) return;
|
||||
console.error('[Search] Geocoding failed:', err);
|
||||
if (this._disposed || generation !== this._navigationGeneration) return;
|
||||
this._showToast('Search failed');
|
||||
} finally {
|
||||
if (this._locationSearchController === searchController) this._locationSearchController = null;
|
||||
this._settleLocationSearchUi(generation);
|
||||
}
|
||||
}
|
||||
this._locationControls = new LocationControls({
|
||||
elements: {
|
||||
pills: this._locationPills, poiRow: this._poiRow, divider: this._locationBarDivider,
|
||||
search: this._locationSearch, searchToggle: this._searchToggle,
|
||||
resetButtons: [this._resetGlobeBtn, this._cockpitResetGlobeBtn],
|
||||
statusCity: this._locationMiniCity, statusPoi: this._locationMiniPoi,
|
||||
},
|
||||
cities: CITY_POIS,
|
||||
getExpandedCity: () => this._expandedCityId,
|
||||
onCity: (id) => this._onCityPillClick(id),
|
||||
onPoi: (id, index) => this._onPoiClick(id, index),
|
||||
onSearch: (query) => this._locationLookup.run(query),
|
||||
onReset: () => this.resetToGlobeView(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8745,28 +8691,9 @@ export class StyleManager {
|
||||
* @returns {void}
|
||||
*/
|
||||
_expandPOIRow(cityId) {
|
||||
const QWERTY_KEYS = ['Q', 'W', 'E', 'R', 'T'];
|
||||
const city = CITY_POIS[cityId];
|
||||
if (!city) return;
|
||||
|
||||
if (!CITY_POIS[cityId]) return;
|
||||
this._expandedCityId = cityId;
|
||||
|
||||
// Build POI pill buttons
|
||||
this._poiRow.innerHTML = '';
|
||||
city.pois.forEach((poi, idx) => {
|
||||
const pill = document.createElement('button');
|
||||
pill.className = 'poi-pill';
|
||||
pill.dataset.poiIndex = idx;
|
||||
pill.innerHTML = `<span class="poi-pill-key">${QWERTY_KEYS[idx] || idx + 1}</span><span class="poi-pill-name">${poi.name}</span>`;
|
||||
pill.addEventListener('click', () => this._onPoiClick(cityId, idx));
|
||||
this._poiRow.appendChild(pill);
|
||||
});
|
||||
|
||||
// Animate expansion
|
||||
requestAnimationFrame(() => {
|
||||
this._poiRow.classList.add('expanded');
|
||||
this._locationBarDivider.classList.add('visible');
|
||||
});
|
||||
this._locationControls.showPois(cityId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -8776,8 +8703,7 @@ export class StyleManager {
|
||||
_collapsePOIRow() {
|
||||
this._expandedCityId = null;
|
||||
this._activePoiIndex = null;
|
||||
this._poiRow.classList.remove('expanded');
|
||||
this._locationBarDivider.classList.remove('visible');
|
||||
this._locationControls.hidePois();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -8785,9 +8711,7 @@ export class StyleManager {
|
||||
* @returns {void}
|
||||
*/
|
||||
_updatePoiHighlight() {
|
||||
this._poiRow.querySelectorAll('.poi-pill').forEach(pill => {
|
||||
pill.classList.toggle('active', parseInt(pill.dataset.poiIndex) === this._activePoiIndex);
|
||||
});
|
||||
this._locationControls.highlightPoi(this._activePoiIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -8813,9 +8737,7 @@ export class StyleManager {
|
||||
// free-text destination has been superseded. Clearing only on a real id
|
||||
// leaves the search path's own _setActiveLocation(null) untouched.
|
||||
if (locationId) this._searchedLocationLabel = null;
|
||||
this._locationPills.querySelectorAll('.location-pill').forEach(pill => {
|
||||
pill.classList.toggle('active', pill.dataset.locationId === locationId);
|
||||
});
|
||||
this._locationControls?.highlightCity(locationId);
|
||||
this._updateLocationMiniStatus();
|
||||
}
|
||||
|
||||
@@ -8825,14 +8747,11 @@ export class StyleManager {
|
||||
* @returns {void}
|
||||
*/
|
||||
_updateLocationMiniStatus() {
|
||||
if (!this._locationMiniCity || !this._locationMiniPoi) return;
|
||||
const lines = locationMiniStatus({
|
||||
this._locationControls?.renderStatus({
|
||||
city: this._activeLocationId ? CITY_POIS[this._activeLocationId] : null,
|
||||
currentPoi: this._currentPoi,
|
||||
searchedLabel: this._searchedLocationLabel,
|
||||
});
|
||||
this._locationMiniCity.textContent = lines.city;
|
||||
this._locationMiniPoi.textContent = lines.poi;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -8852,11 +8771,7 @@ export class StyleManager {
|
||||
* @returns {void}
|
||||
*/
|
||||
_initOrbit() {
|
||||
// Create orbit indicator element
|
||||
this._orbitIndicator = document.createElement('div');
|
||||
this._orbitIndicator.id = 'orbit-indicator';
|
||||
this._orbitIndicator.innerHTML = '<span class="orbit-icon">↻</span> ORBIT';
|
||||
document.body.appendChild(this._orbitIndicator);
|
||||
this._orbitIndicator = this._locationControls.createOrbitIndicator();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -8889,14 +8804,6 @@ export class StyleManager {
|
||||
}
|
||||
}
|
||||
|
||||
/** Wire the persistent reset control to the same route used by voice. */
|
||||
_initResetGlobeButton() {
|
||||
this._globeResetHandler = () => { this.resetToGlobeView(); };
|
||||
for (const button of [this._resetGlobeBtn, this._cockpitResetGlobeBtn]) {
|
||||
button?.addEventListener('click', this._globeResetHandler);
|
||||
}
|
||||
}
|
||||
|
||||
/** Wire the top-center action that clears only manager-owned data layers. */
|
||||
_initClearSelectedLayersButton() {
|
||||
if (!this._clearSelectedLayersBtn) return;
|
||||
@@ -9374,13 +9281,14 @@ export class StyleManager {
|
||||
this._displayControls?.destroy();
|
||||
this._mapSourceControls?.destroy();
|
||||
this._clearLayersControl?.destroy();
|
||||
this._locationControls?.destroy();
|
||||
this._visualEffects.stop();
|
||||
this._styleParameters?.destroy();
|
||||
for (const control of this._panelDisclosureControls || []) control.destroy();
|
||||
this._panelDisclosureControls = [];
|
||||
this._hoverPanelControls?.forEach((control) => control.destroy());
|
||||
this._hoverPanelControls?.clear();
|
||||
this._locationSearchController?.abort();
|
||||
this._locationLookup?.destroy();
|
||||
this._cancelMapSourceFocus?.();
|
||||
// Revoke persistence/hash authority before teardown can emit manager changes.
|
||||
this._layerStateCoordinator?.destroy();
|
||||
@@ -9458,31 +9366,13 @@ export class StyleManager {
|
||||
this._dataManagerVisibilityRequestUnsubscribe = null;
|
||||
this._dataManagerUnsubscribe?.();
|
||||
this._dataManagerUnsubscribe = null;
|
||||
if (this._globeResetHandler) {
|
||||
this._resetGlobeBtn?.removeEventListener('click', this._globeResetHandler);
|
||||
this._cockpitResetGlobeBtn?.removeEventListener('click', this._globeResetHandler);
|
||||
this._globeResetHandler = null;
|
||||
}
|
||||
|
||||
this._cctvUnsubscribe?.();
|
||||
this._cctvUnsubscribe = null;
|
||||
this._commandDockTrayObserver?.disconnect?.();
|
||||
this._commandDockTrayObserver = null;
|
||||
this._draggableResizeObserver?.disconnect();
|
||||
this._draggableResizeObserver = null;
|
||||
if (this._windowResizeHandler) {
|
||||
window.removeEventListener('resize', this._windowResizeHandler);
|
||||
this._windowResizeHandler = null;
|
||||
}
|
||||
if (this._loadingVisibilityHandler) {
|
||||
document.removeEventListener('visibilitychange', this._loadingVisibilityHandler);
|
||||
this._loadingVisibilityHandler = null;
|
||||
}
|
||||
this._stopLoadingFeedbackTicker();
|
||||
if (this._poiKeydownHandler) {
|
||||
document.removeEventListener('keydown', this._poiKeydownHandler);
|
||||
this._poiKeydownHandler = null;
|
||||
}
|
||||
|
||||
// Stop the independently owned traffic and loading feedback tickers.
|
||||
|
||||
if (this._trafficChipTicker) {
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export { LocationControls } from './locationControls.js';
|
||||
export { LocationSearch } from './locationSearch.js';
|
||||
export { addressSegments, locationMiniStatus } from '../locationStatus.js';
|
||||
@@ -0,0 +1,163 @@
|
||||
import { locationMiniStatus } from '../locationStatus.js';
|
||||
const POI_KEYS = ['Q', 'W', 'E', 'R', 'T'];
|
||||
|
||||
/** Location DOM, keyboard handling and pending row animation over supplied actions. */
|
||||
export class LocationControls {
|
||||
constructor({
|
||||
elements,
|
||||
cities,
|
||||
getExpandedCity,
|
||||
onCity,
|
||||
onPoi,
|
||||
onSearch,
|
||||
onReset,
|
||||
doc = document,
|
||||
requestFrame = (callback) => requestAnimationFrame(callback),
|
||||
cancelFrame = (id) => cancelAnimationFrame(id),
|
||||
}) {
|
||||
Object.assign(this, {
|
||||
elements,
|
||||
cities,
|
||||
getExpandedCity,
|
||||
onCity,
|
||||
onPoi,
|
||||
onSearch,
|
||||
onReset,
|
||||
doc,
|
||||
requestFrame,
|
||||
cancelFrame,
|
||||
});
|
||||
this.removers = [];
|
||||
this.poiRemovers = [];
|
||||
this.frame = null;
|
||||
this.destroyed = false;
|
||||
this.rowGeneration = 0;
|
||||
elements.pills.replaceChildren();
|
||||
for (const [id, city] of Object.entries(cities)) {
|
||||
const pill = doc.createElement('button');
|
||||
pill.type = 'button';
|
||||
pill.className = 'location-pill';
|
||||
pill.dataset.locationId = id;
|
||||
pill.textContent = city.name;
|
||||
this.bind(pill, 'click', () => onCity(id));
|
||||
elements.pills.appendChild(pill);
|
||||
}
|
||||
this.bind(doc, 'keydown', (event) => {
|
||||
const cityId = getExpandedCity();
|
||||
if (
|
||||
!cityId ||
|
||||
event.target?.matches?.('select, input, textarea') ||
|
||||
event.target === elements.search
|
||||
)
|
||||
return;
|
||||
const index = POI_KEYS.indexOf(event.key.toUpperCase());
|
||||
if (index !== -1 && index < (cities[cityId]?.pois.length || 0))
|
||||
onPoi(cityId, index);
|
||||
});
|
||||
this.bind(elements.searchToggle, 'click', () => {
|
||||
elements.search.classList.toggle('expanded');
|
||||
if (elements.search.classList.contains('expanded'))
|
||||
elements.search.focus();
|
||||
});
|
||||
this.bind(elements.search, 'keydown', (event) => {
|
||||
if (event.key === 'Enter') void onSearch(elements.search.value);
|
||||
});
|
||||
for (const button of elements.resetButtons)
|
||||
this.bind(button, 'click', onReset);
|
||||
}
|
||||
bind(element, event, handler, removers = this.removers) {
|
||||
if (!element) return;
|
||||
const listener = (...args) => {
|
||||
if (!this.destroyed) return handler(...args);
|
||||
};
|
||||
element.addEventListener(event, listener);
|
||||
removers.push(() => element.removeEventListener(event, listener));
|
||||
}
|
||||
cancelExpansion() {
|
||||
this.rowGeneration++;
|
||||
if (this.frame !== null) this.cancelFrame(this.frame);
|
||||
this.frame = null;
|
||||
}
|
||||
showPois(cityId) {
|
||||
if (this.destroyed) return;
|
||||
const city = this.cities[cityId];
|
||||
if (!city) return;
|
||||
this.cancelExpansion();
|
||||
const generation = this.rowGeneration;
|
||||
for (const remove of this.poiRemovers.splice(0)) remove();
|
||||
this.elements.poiRow.replaceChildren();
|
||||
city.pois.forEach((poi, index) => {
|
||||
const pill = this.doc.createElement('button');
|
||||
pill.type = 'button';
|
||||
pill.className = 'poi-pill';
|
||||
pill.dataset.poiIndex = index;
|
||||
const key = this.doc.createElement('span');
|
||||
key.className = 'poi-pill-key';
|
||||
key.textContent = POI_KEYS[index] || index + 1;
|
||||
const label = this.doc.createElement('span');
|
||||
label.className = 'poi-pill-name';
|
||||
label.textContent = poi.name;
|
||||
pill.append(key, label);
|
||||
this.bind(
|
||||
pill,
|
||||
'click',
|
||||
() => this.onPoi(cityId, index),
|
||||
this.poiRemovers,
|
||||
);
|
||||
this.elements.poiRow.appendChild(pill);
|
||||
});
|
||||
this.frame = this.requestFrame(() => {
|
||||
if (this.destroyed || generation !== this.rowGeneration) return;
|
||||
this.frame = null;
|
||||
this.elements.poiRow.classList.add('expanded');
|
||||
this.elements.divider.classList.add('visible');
|
||||
});
|
||||
}
|
||||
hidePois() {
|
||||
if (this.destroyed) return;
|
||||
this.cancelExpansion();
|
||||
this.elements.poiRow.classList.remove('expanded');
|
||||
this.elements.divider.classList.remove('visible');
|
||||
}
|
||||
highlightPoi(index) {
|
||||
if (this.destroyed) return;
|
||||
for (const pill of this.elements.poiRow.querySelectorAll('.poi-pill'))
|
||||
pill.classList.toggle('active', Number(pill.dataset.poiIndex) === index);
|
||||
}
|
||||
highlightCity(id) {
|
||||
if (this.destroyed) return;
|
||||
for (const pill of this.elements.pills.querySelectorAll('.location-pill'))
|
||||
pill.classList.toggle('active', pill.dataset.locationId === id);
|
||||
}
|
||||
renderStatus(state) {
|
||||
if (this.destroyed || !this.elements.statusCity || !this.elements.statusPoi)
|
||||
return;
|
||||
const lines = locationMiniStatus(state);
|
||||
this.elements.statusCity.textContent = lines.city;
|
||||
this.elements.statusPoi.textContent = lines.poi;
|
||||
}
|
||||
createOrbitIndicator() {
|
||||
if (this.destroyed) return null;
|
||||
if (!this.orbitIndicator) {
|
||||
this.orbitIndicator = this.doc.createElement('div');
|
||||
this.orbitIndicator.id = 'orbit-indicator';
|
||||
const icon = this.doc.createElement('span');
|
||||
icon.className = 'orbit-icon';
|
||||
icon.textContent = '↻';
|
||||
this.orbitIndicator.append(icon, ' ORBIT');
|
||||
this.doc.body.appendChild(this.orbitIndicator);
|
||||
}
|
||||
return this.orbitIndicator;
|
||||
}
|
||||
destroy() {
|
||||
if (this.destroyed) return;
|
||||
this.destroyed = true;
|
||||
this.cancelExpansion();
|
||||
for (const remove of [
|
||||
...this.poiRemovers.splice(0),
|
||||
...this.removers.splice(0),
|
||||
])
|
||||
remove();
|
||||
this.orbitIndicator?.remove();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { LocationControls } from './locationControls.js';
|
||||
|
||||
function node() {
|
||||
const classes = new Set();
|
||||
return {
|
||||
children: [],
|
||||
dataset: {},
|
||||
listeners: new Map(),
|
||||
className: '',
|
||||
textContent: '',
|
||||
classList: {
|
||||
add: (...names) => names.forEach((name) => classes.add(name)),
|
||||
remove: (...names) => names.forEach((name) => classes.delete(name)),
|
||||
contains: (name) => classes.has(name),
|
||||
toggle(name, enabled = !classes.has(name)) {
|
||||
if (enabled) classes.add(name);
|
||||
else classes.delete(name);
|
||||
},
|
||||
},
|
||||
addEventListener(name, callback) {
|
||||
if (!this.listeners.has(name)) this.listeners.set(name, new Set());
|
||||
this.listeners.get(name).add(callback);
|
||||
},
|
||||
removeEventListener(name, callback) {
|
||||
this.listeners.get(name)?.delete(callback);
|
||||
},
|
||||
fire(name, event = {}) {
|
||||
for (const callback of this.listeners.get(name) || []) callback(event);
|
||||
},
|
||||
appendChild(child) {
|
||||
this.children.push(child);
|
||||
child.parent = this;
|
||||
},
|
||||
append(...children) {
|
||||
for (const child of children)
|
||||
if (typeof child !== 'string') this.appendChild(child);
|
||||
},
|
||||
replaceChildren() {
|
||||
this.children = [];
|
||||
},
|
||||
remove() {
|
||||
if (this.parent)
|
||||
this.parent.children = this.parent.children.filter(
|
||||
(child) => child !== this,
|
||||
);
|
||||
},
|
||||
querySelectorAll(selector) {
|
||||
return this.children.flatMap((child) => [
|
||||
...(child.className === selector.slice(1) ? [child] : []),
|
||||
...child.querySelectorAll(selector),
|
||||
]);
|
||||
},
|
||||
focus() {
|
||||
this.focused = true;
|
||||
},
|
||||
};
|
||||
}
|
||||
function fixture() {
|
||||
const elements = {
|
||||
pills: node(),
|
||||
poiRow: node(),
|
||||
divider: node(),
|
||||
search: node(),
|
||||
searchToggle: node(),
|
||||
resetButtons: [node(), node()],
|
||||
statusCity: node(),
|
||||
statusPoi: node(),
|
||||
};
|
||||
const doc = node();
|
||||
doc.createElement = node;
|
||||
doc.body = node();
|
||||
const cities = {
|
||||
a: { name: 'City A', pois: [{ name: 'First' }, { name: 'Second' }] },
|
||||
b: { name: 'City B', pois: [{ name: 'Elsewhere' }] },
|
||||
};
|
||||
const calls = [];
|
||||
const frames = new Map();
|
||||
const cancelled = [];
|
||||
let next = 0;
|
||||
const controls = new LocationControls({
|
||||
elements,
|
||||
cities,
|
||||
getExpandedCity: () => 'a',
|
||||
onCity: (id) => calls.push(['city', id]),
|
||||
onPoi: (id, index) => calls.push(['poi', id, index]),
|
||||
onSearch: (query) => calls.push(['search', query]),
|
||||
onReset: () => calls.push(['reset']),
|
||||
doc,
|
||||
requestFrame: (fn) => {
|
||||
const id = next++;
|
||||
frames.set(id, fn);
|
||||
return id;
|
||||
},
|
||||
cancelFrame: (id) => cancelled.push(id),
|
||||
});
|
||||
return { elements, doc, controls, calls, frames, cancelled };
|
||||
}
|
||||
test('hiding a POI row cancels frame zero and rejects an already queued expansion', () => {
|
||||
const f = fixture();
|
||||
f.controls.showPois('a');
|
||||
const callback = f.frames.get(0);
|
||||
f.controls.hidePois();
|
||||
callback();
|
||||
assert.deepEqual(f.cancelled, [0]);
|
||||
assert.equal(f.elements.poiRow.classList.contains('expanded'), false);
|
||||
assert.equal(f.elements.divider.classList.contains('visible'), false);
|
||||
});
|
||||
test('replacing POIs removes old click actions and presents the final row', () => {
|
||||
const f = fixture();
|
||||
f.controls.showPois('a');
|
||||
const old = f.elements.poiRow.children[0];
|
||||
f.controls.showPois('b');
|
||||
old.fire('click');
|
||||
assert.deepEqual(f.calls, []);
|
||||
f.elements.poiRow.children[0].fire('click');
|
||||
assert.deepEqual(f.calls, [['poi', 'b', 0]]);
|
||||
f.frames.get(0)();
|
||||
assert.equal(f.elements.poiRow.classList.contains('expanded'), false);
|
||||
f.frames.get(1)();
|
||||
assert.equal(f.elements.poiRow.classList.contains('expanded'), true);
|
||||
});
|
||||
test('destruction revokes document, search, reset, city and POI actions and removes orbit UI', () => {
|
||||
const f = fixture();
|
||||
f.controls.showPois('a');
|
||||
f.controls.createOrbitIndicator();
|
||||
assert.equal(f.doc.body.children.length, 1);
|
||||
const city = f.elements.pills.children[0];
|
||||
const poi = f.elements.poiRow.children[0];
|
||||
f.controls.destroy();
|
||||
f.controls.destroy();
|
||||
city.fire('click');
|
||||
poi.fire('click');
|
||||
f.elements.search.value = 'Place';
|
||||
f.elements.search.fire('keydown', { key: 'Enter' });
|
||||
f.doc.fire('keydown', { key: 'Q' });
|
||||
f.elements.resetButtons[0].fire('click');
|
||||
f.frames.get(0)();
|
||||
assert.deepEqual(f.calls, []);
|
||||
assert.equal(f.doc.body.children.length, 0);
|
||||
});
|
||||
test('location and POI keys route once while form controls retain typing', () => {
|
||||
const f = fixture();
|
||||
f.doc.fire('keydown', { key: 'W', target: { matches: () => false } });
|
||||
f.doc.fire('keydown', { key: 'Q', target: { matches: () => true } });
|
||||
f.elements.resetButtons[1].fire('click');
|
||||
assert.deepEqual(f.calls, [['poi', 'a', 1], ['reset']]);
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
/** One cancellable place lookup at a time, under the caller's camera authority. */
|
||||
export class LocationSearch {
|
||||
constructor({
|
||||
input,
|
||||
begin,
|
||||
isCurrent,
|
||||
beforeFly,
|
||||
search,
|
||||
onStart,
|
||||
onResult,
|
||||
onMissing,
|
||||
onError,
|
||||
onSettled,
|
||||
}) {
|
||||
Object.assign(this, {
|
||||
input,
|
||||
begin,
|
||||
isCurrent,
|
||||
beforeFly,
|
||||
search,
|
||||
onStart,
|
||||
onResult,
|
||||
onMissing,
|
||||
onError,
|
||||
onSettled,
|
||||
});
|
||||
this.controller = null;
|
||||
this.generation = 0;
|
||||
this.destroyed = false;
|
||||
}
|
||||
async run(query) {
|
||||
query = String(query || '').trim();
|
||||
if (!query || this.destroyed) return;
|
||||
const authority = this.begin();
|
||||
if (authority === false) {
|
||||
this.input.classList.remove('searching');
|
||||
this.input.blur();
|
||||
return;
|
||||
}
|
||||
this.controller?.abort();
|
||||
const controller = new AbortController();
|
||||
this.controller = controller;
|
||||
const generation = ++this.generation;
|
||||
const current = () =>
|
||||
!this.destroyed &&
|
||||
generation === this.generation &&
|
||||
this.isCurrent(authority);
|
||||
this.onStart(authority);
|
||||
this.input.classList.add('searching');
|
||||
try {
|
||||
const destination = await this.search(query, {
|
||||
signal: controller.signal,
|
||||
beforeFly: () => current() && this.beforeFly(authority),
|
||||
});
|
||||
if (!current() || controller.signal.aborted) return;
|
||||
if (destination?.cancelled) return;
|
||||
if (destination) this.onResult(destination, query);
|
||||
else this.onMissing();
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted || !current()) return;
|
||||
this.onError(error);
|
||||
} finally {
|
||||
if (this.controller === controller) this.controller = null;
|
||||
if (!this.destroyed) this.onSettled(authority);
|
||||
}
|
||||
}
|
||||
destroy() {
|
||||
if (this.destroyed) return;
|
||||
this.destroyed = true;
|
||||
this.generation++;
|
||||
this.controller?.abort();
|
||||
this.controller = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { LocationSearch } from './locationSearch.js';
|
||||
|
||||
function fixture() {
|
||||
const calls = [];
|
||||
const requests = [];
|
||||
let authority = 0;
|
||||
const input = {
|
||||
classList: {
|
||||
add: (name) => calls.push(['add', name]),
|
||||
remove: (name) => calls.push(['remove', name]),
|
||||
},
|
||||
blur: () => calls.push(['blur']),
|
||||
};
|
||||
const search = new LocationSearch({
|
||||
input,
|
||||
begin: () => ++authority,
|
||||
isCurrent: (ticket) => ticket === authority,
|
||||
beforeFly: (ticket) => {
|
||||
calls.push(['fly', ticket]);
|
||||
return true;
|
||||
},
|
||||
search: (query, options) =>
|
||||
new Promise((resolve, reject) =>
|
||||
requests.push({ query, options, resolve, reject }),
|
||||
),
|
||||
onStart: (ticket) => calls.push(['start', ticket]),
|
||||
onResult: (result, query) => calls.push(['result', result, query]),
|
||||
onMissing: () => calls.push(['missing']),
|
||||
onError: (error) => calls.push(['error', error.message]),
|
||||
onSettled: (ticket) => calls.push(['settled', ticket]),
|
||||
});
|
||||
return { search, requests, calls, takeCamera: () => ++authority };
|
||||
}
|
||||
|
||||
test('a newer lookup aborts its predecessor and only its result is presented', async () => {
|
||||
const f = fixture();
|
||||
const first = f.search.run(' First ');
|
||||
const second = f.search.run('Second');
|
||||
assert.equal(f.requests[0].query, 'First');
|
||||
assert.equal(f.requests[0].options.signal.aborted, true);
|
||||
assert.equal(f.requests[0].options.beforeFly(), false);
|
||||
assert.equal(f.requests[1].options.beforeFly(), true);
|
||||
f.requests[1].resolve({ label: 'Second' });
|
||||
await second;
|
||||
f.requests[0].resolve({ label: 'First' });
|
||||
await first;
|
||||
assert.deepEqual(
|
||||
f.calls.filter((x) => x[0] === 'result'),
|
||||
[['result', { label: 'Second' }, 'Second']],
|
||||
);
|
||||
assert.equal(f.search.controller, null);
|
||||
});
|
||||
test('another camera action revokes a pending lookup without presenting an error', async () => {
|
||||
const f = fixture();
|
||||
const pending = f.search.run('Place');
|
||||
f.takeCamera();
|
||||
assert.equal(f.requests[0].options.beforeFly(), false);
|
||||
f.requests[0].reject(new Error('old failure'));
|
||||
await pending;
|
||||
assert.equal(
|
||||
f.calls.some((x) => x[0] === 'error' || x[0] === 'result'),
|
||||
false,
|
||||
);
|
||||
});
|
||||
test('missing and failed current lookups settle the exact owning generation', async () => {
|
||||
const f = fixture();
|
||||
let pending = f.search.run('Missing');
|
||||
f.requests[0].resolve(null);
|
||||
await pending;
|
||||
assert.ok(f.calls.some((x) => x[0] === 'missing'));
|
||||
pending = f.search.run('Failure');
|
||||
f.requests[1].reject(new Error('unavailable'));
|
||||
await pending;
|
||||
assert.deepEqual(
|
||||
f.calls.filter((x) => x[0] === 'error'),
|
||||
[['error', 'unavailable']],
|
||||
);
|
||||
assert.deepEqual(
|
||||
f.calls.filter((x) => x[0] === 'settled'),
|
||||
[
|
||||
['settled', 1],
|
||||
['settled', 2],
|
||||
],
|
||||
);
|
||||
});
|
||||
test('destruction cancels work and rejects late presentation and future requests', async () => {
|
||||
const f = fixture();
|
||||
const pending = f.search.run('Place');
|
||||
f.search.destroy();
|
||||
f.search.destroy();
|
||||
assert.equal(f.requests[0].options.signal.aborted, true);
|
||||
f.requests[0].resolve({ label: 'Place' });
|
||||
await pending;
|
||||
await f.search.run('Later');
|
||||
assert.equal(f.requests.length, 1);
|
||||
assert.equal(
|
||||
f.calls.some((x) => x[0] === 'result' || x[0] === 'settled'),
|
||||
false,
|
||||
);
|
||||
});
|
||||
test('empty queries and refused navigation do not issue a lookup', async () => {
|
||||
const f = fixture();
|
||||
await f.search.run(' ');
|
||||
f.search.begin = () => false;
|
||||
await f.search.run('Place');
|
||||
assert.equal(f.requests.length, 0);
|
||||
assert.ok(f.calls.some((x) => x[0] === 'blur'));
|
||||
});
|
||||
Reference in new issue
Block a user