mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
refactor: isolate Location controls and cancellable search
This commit is contained in:
1 parent
20c1611354
commit
ea77b49482
16 files changed
+604
-245
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"
|
||||
]
|
||||
+169
-51
@@ -10,27 +10,47 @@
|
||||
"src/data/localGeojsonCore.js",
|
||||
"src/data/localGeojsonLod.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
"external": [
|
||||
"cesium"
|
||||
]
|
||||
},
|
||||
"application": {
|
||||
"exports": ["./application"],
|
||||
"modules": ["src/app/application.js"],
|
||||
"exports": [
|
||||
"./application"
|
||||
],
|
||||
"modules": [
|
||||
"src/app/application.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"viewer": {
|
||||
"exports": ["./application/viewer"],
|
||||
"modules": ["src/app/viewer.js"],
|
||||
"external": ["cesium"]
|
||||
"exports": [
|
||||
"./application/viewer"
|
||||
],
|
||||
"modules": [
|
||||
"src/app/viewer.js"
|
||||
],
|
||||
"external": [
|
||||
"cesium"
|
||||
]
|
||||
},
|
||||
"vite-build": {
|
||||
"runtime": "node",
|
||||
"exports": ["./build/vite"],
|
||||
"modules": ["build/vite.js"],
|
||||
"external": ["vite-plugin-cesium"]
|
||||
"exports": [
|
||||
"./build/vite"
|
||||
],
|
||||
"modules": [
|
||||
"build/vite.js"
|
||||
],
|
||||
"external": [
|
||||
"vite-plugin-cesium"
|
||||
]
|
||||
},
|
||||
"live-providers": {
|
||||
"runtime": "node",
|
||||
"exports": ["./server/providers/live"],
|
||||
"exports": [
|
||||
"./server/providers/live"
|
||||
],
|
||||
"modules": [
|
||||
"server/providers/live.js",
|
||||
"server/providers/common/http.js",
|
||||
@@ -45,16 +65,24 @@
|
||||
"src/data/aisStreamAdapter.js",
|
||||
"src/data/aisWatchdog.js"
|
||||
],
|
||||
"external": ["ws"]
|
||||
"external": [
|
||||
"ws"
|
||||
]
|
||||
},
|
||||
"adsb-lol-source": {
|
||||
"exports": ["./sources/adsb-lol"],
|
||||
"modules": ["src/data/adsbLolFallback.js"],
|
||||
"exports": [
|
||||
"./sources/adsb-lol"
|
||||
],
|
||||
"modules": [
|
||||
"src/data/adsbLolFallback.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"place-providers": {
|
||||
"runtime": "node",
|
||||
"exports": ["./server/providers/places"],
|
||||
"exports": [
|
||||
"./server/providers/places"
|
||||
],
|
||||
"modules": [
|
||||
"server/providers/places.js",
|
||||
"server/providers/places/google.js",
|
||||
@@ -69,13 +97,19 @@
|
||||
"external": []
|
||||
},
|
||||
"place-payloads": {
|
||||
"exports": ["./sources/places"],
|
||||
"modules": ["src/data/placeProviderPayloads.js"],
|
||||
"exports": [
|
||||
"./sources/places"
|
||||
],
|
||||
"modules": [
|
||||
"src/data/placeProviderPayloads.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"space-providers": {
|
||||
"runtime": "node",
|
||||
"exports": ["./server/providers/space"],
|
||||
"exports": [
|
||||
"./server/providers/space"
|
||||
],
|
||||
"modules": [
|
||||
"server/providers/space.js",
|
||||
"server/providers/space/celestrak.js",
|
||||
@@ -86,13 +120,19 @@
|
||||
"external": []
|
||||
},
|
||||
"space-requests": {
|
||||
"exports": ["./sources/space"],
|
||||
"modules": ["src/data/spaceProviderRequests.js"],
|
||||
"exports": [
|
||||
"./sources/space"
|
||||
],
|
||||
"modules": [
|
||||
"src/data/spaceProviderRequests.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"terrain-provider": {
|
||||
"runtime": "node",
|
||||
"exports": ["./server/providers/terrain"],
|
||||
"exports": [
|
||||
"./server/providers/terrain"
|
||||
],
|
||||
"modules": [
|
||||
"server/providers/terrain.js",
|
||||
"src/data/terrainHeightsProxy.js"
|
||||
@@ -100,30 +140,50 @@
|
||||
"external": []
|
||||
},
|
||||
"terrain-source": {
|
||||
"exports": ["./sources/terrain"],
|
||||
"modules": ["src/data/terrainHeightsProxy.js"],
|
||||
"exports": [
|
||||
"./sources/terrain"
|
||||
],
|
||||
"modules": [
|
||||
"src/data/terrainHeightsProxy.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"traffic-provider": {
|
||||
"runtime": "node",
|
||||
"exports": ["./server/providers/traffic"],
|
||||
"modules": ["server/providers/traffic.js", "src/data/tomtomTiles.js"],
|
||||
"exports": [
|
||||
"./server/providers/traffic"
|
||||
],
|
||||
"modules": [
|
||||
"server/providers/traffic.js",
|
||||
"src/data/tomtomTiles.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"traffic-source": {
|
||||
"exports": ["./sources/traffic"],
|
||||
"modules": ["src/data/tomtomTiles.js"],
|
||||
"exports": [
|
||||
"./sources/traffic"
|
||||
],
|
||||
"modules": [
|
||||
"src/data/tomtomTiles.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"firms-provider": {
|
||||
"runtime": "node",
|
||||
"exports": ["./server/providers/firms"],
|
||||
"modules": ["server/providers/firms.js", "src/data/firmsCsv.js"],
|
||||
"exports": [
|
||||
"./server/providers/firms"
|
||||
],
|
||||
"modules": [
|
||||
"server/providers/firms.js",
|
||||
"src/data/firmsCsv.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"gbfs-provider": {
|
||||
"runtime": "node",
|
||||
"exports": ["./server/providers/gbfs"],
|
||||
"exports": [
|
||||
"./server/providers/gbfs"
|
||||
],
|
||||
"modules": [
|
||||
"server/providers/gbfs.js",
|
||||
"server/providers/common/http.js",
|
||||
@@ -132,13 +192,19 @@
|
||||
"external": []
|
||||
},
|
||||
"gbfs-source": {
|
||||
"exports": ["./sources/gbfs"],
|
||||
"modules": ["src/data/gbfsSource.js"],
|
||||
"exports": [
|
||||
"./sources/gbfs"
|
||||
],
|
||||
"modules": [
|
||||
"src/data/gbfsSource.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"cctv-provider": {
|
||||
"runtime": "node",
|
||||
"exports": ["./server/providers/cctv"],
|
||||
"exports": [
|
||||
"./server/providers/cctv"
|
||||
],
|
||||
"modules": [
|
||||
"server/providers/cctv.js",
|
||||
"server/providers/cctv/catalog.js",
|
||||
@@ -155,7 +221,9 @@
|
||||
},
|
||||
"radio-provider": {
|
||||
"runtime": "node",
|
||||
"exports": ["./server/providers/radio"],
|
||||
"exports": [
|
||||
"./server/providers/radio"
|
||||
],
|
||||
"modules": [
|
||||
"server/providers/radio.js",
|
||||
"server/providers/radio/catalog.js",
|
||||
@@ -169,7 +237,9 @@
|
||||
},
|
||||
"overpass-provider": {
|
||||
"runtime": "node",
|
||||
"exports": ["./server/providers/overpass"],
|
||||
"exports": [
|
||||
"./server/providers/overpass"
|
||||
],
|
||||
"modules": [
|
||||
"server/providers/common/geo.js",
|
||||
"server/providers/common/http.js",
|
||||
@@ -188,7 +258,9 @@
|
||||
},
|
||||
"military-installations-provider": {
|
||||
"runtime": "node",
|
||||
"exports": ["./server/providers/military-installations"],
|
||||
"exports": [
|
||||
"./server/providers/military-installations"
|
||||
],
|
||||
"modules": [
|
||||
"server/providers/common/http.js",
|
||||
"server/providers/common/query.js",
|
||||
@@ -205,7 +277,9 @@
|
||||
},
|
||||
"regional-provider": {
|
||||
"runtime": "node",
|
||||
"exports": ["./server/providers/regional"],
|
||||
"exports": [
|
||||
"./server/providers/regional"
|
||||
],
|
||||
"modules": [
|
||||
"server/providers/common/http.js",
|
||||
"server/providers/common/query.js",
|
||||
@@ -224,7 +298,9 @@
|
||||
},
|
||||
"openai-provider": {
|
||||
"runtime": "node",
|
||||
"exports": ["./server/providers/openai"],
|
||||
"exports": [
|
||||
"./server/providers/openai"
|
||||
],
|
||||
"modules": [
|
||||
"server/providers/common/rate-limit.js",
|
||||
"server/providers/common/request.js",
|
||||
@@ -244,7 +320,9 @@
|
||||
},
|
||||
"standalone-key-setup": {
|
||||
"runtime": "node",
|
||||
"exports": ["./server/standalone/key-setup"],
|
||||
"exports": [
|
||||
"./server/standalone/key-setup"
|
||||
],
|
||||
"modules": [
|
||||
"scripts/pinokio-environment.mjs",
|
||||
"server/providers/common/source-root.js",
|
||||
@@ -255,7 +333,9 @@
|
||||
"external": []
|
||||
},
|
||||
"place-search": {
|
||||
"exports": ["./search"],
|
||||
"exports": [
|
||||
"./search"
|
||||
],
|
||||
"modules": [
|
||||
"src/search/index.js",
|
||||
"src/search/placeSearch.js",
|
||||
@@ -265,17 +345,27 @@
|
||||
"external": []
|
||||
},
|
||||
"panel-disclosure": {
|
||||
"exports": ["./ui/panels"],
|
||||
"modules": ["src/ui/panelDisclosure.js"],
|
||||
"exports": [
|
||||
"./ui/panels"
|
||||
],
|
||||
"modules": [
|
||||
"src/ui/panelDisclosure.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"surface-keyboard": {
|
||||
"exports": ["./ui/surfaces"],
|
||||
"modules": ["src/ui/surfaceKeyboard.js"],
|
||||
"exports": [
|
||||
"./ui/surfaces"
|
||||
],
|
||||
"modules": [
|
||||
"src/ui/surfaceKeyboard.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"panel-rail-layout": {
|
||||
"exports": ["./ui/layout"],
|
||||
"exports": [
|
||||
"./ui/layout"
|
||||
],
|
||||
"modules": [
|
||||
"src/ui/panelRails.js",
|
||||
"src/ui/leftPanelRail.js",
|
||||
@@ -287,7 +377,9 @@
|
||||
"external": []
|
||||
},
|
||||
"visual-input": {
|
||||
"exports": ["./ui/input"],
|
||||
"exports": [
|
||||
"./ui/input"
|
||||
],
|
||||
"modules": [
|
||||
"src/ui/visualInput.js",
|
||||
"src/ui/applicationShortcuts.js",
|
||||
@@ -296,12 +388,19 @@
|
||||
"external": []
|
||||
},
|
||||
"display-controls": {
|
||||
"exports": ["./ui/display"],
|
||||
"modules": ["src/ui/displayControls.js"],
|
||||
"exports": [
|
||||
"./ui/display"
|
||||
],
|
||||
"modules": [
|
||||
"src/ui/displayControls.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"visual-effects": {
|
||||
"exports": ["./ui/effects", "./ui/effects/bloom"],
|
||||
"exports": [
|
||||
"./ui/effects",
|
||||
"./ui/effects/bloom"
|
||||
],
|
||||
"modules": [
|
||||
"src/ui/effects.js",
|
||||
"src/ui/visualEffects.js",
|
||||
@@ -314,10 +413,14 @@
|
||||
"src/styles/noir.js",
|
||||
"src/styles/snow.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
"external": [
|
||||
"cesium"
|
||||
]
|
||||
},
|
||||
"map-source-controls": {
|
||||
"exports": ["./ui/maps"],
|
||||
"exports": [
|
||||
"./ui/maps"
|
||||
],
|
||||
"modules": [
|
||||
"src/ui/mapSource.js",
|
||||
"src/ui/mapSourceControls.js",
|
||||
@@ -327,7 +430,10 @@
|
||||
"external": []
|
||||
},
|
||||
"layer-panel": {
|
||||
"exports": ["./ui/layers", "./ui/layers/feedback"],
|
||||
"exports": [
|
||||
"./ui/layers",
|
||||
"./ui/layers/feedback"
|
||||
],
|
||||
"modules": [
|
||||
"src/ui/layers.js",
|
||||
"src/ui/layerPanel.js",
|
||||
@@ -336,5 +442,17 @@
|
||||
"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,54 @@
|
||||
#!/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._locationMiniPoi.textContent.includes('Second landmark')&&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});
|
||||
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', () => {
|
||||
|
||||
@@ -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: --/);
|
||||
});
|
||||
|
||||
@@ -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 { locationMiniStatus } from '../locationStatus.js';
|
||||
@@ -0,0 +1,119 @@
|
||||
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 = requestAnimationFrame, cancelFrame = cancelAnimationFrame }) {
|
||||
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,44 @@
|
||||
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,49 @@
|
||||
/** 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,57 @@
|
||||
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