mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 03:14:00 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f268efb71 | ||
|
|
2da8ced642 | ||
|
|
a397c94892 | ||
|
|
5a1598fd9e | ||
|
|
bf0e1d7c79 | ||
|
|
415edbcfdf | ||
|
|
4bfee31fdb | ||
|
|
7df8b369e4 | ||
|
|
40394ff76e | ||
|
|
713285b554 | ||
|
|
13b474d302 | ||
|
|
23feb16b0f | ||
|
|
3d75b9e7ee | ||
|
|
da9beb9324 | ||
|
|
803cddd5cb | ||
|
|
5f99cd25ec | ||
|
|
5e9c14db83 | ||
|
|
64fd9ba1c2 | ||
|
|
f1c617ad27 | ||
|
|
f6006358ed | ||
|
|
b48fcc42ae | ||
|
|
b920d5f404 | ||
|
|
b0e0945360 | ||
|
|
a7160c62be | ||
|
|
167aa947ce | ||
|
|
a6f229d870 | ||
|
|
1eded0468f | ||
|
|
3b3895f82e | ||
|
|
d09d1ddad1 | ||
|
|
1763143337 | ||
|
|
a331616a9d | ||
|
|
e20e858ba8 | ||
|
|
9b1967a59e |
No files matched your search
@@ -425,7 +425,7 @@ _The Space Missions layer replaying a Falcon 9 ascent — labeled `RECONSTRUCTED
|
||||
|
||||
**Setup.** At [mapillary.com/dashboard/developers](https://www.mapillary.com/dashboard/developers), register an application with **Read** access and copy its **Client Token** (`MLY|…`). Paste it into **POWER UP → Mapillary**, or set `MAPILLARY_CLIENT_TOKEN` in `.env` and restart. Without it the panel reads **KEY REQUIRED**. **Google Street View** uses `GOOGLE_MAPS_API_KEY` with the **Maps JavaScript API** enabled for it in Google Cloud; its chip starts off, since every panorama is billed to the key. **KartaView** and **Panoramax** need nothing: their public APIs are keyless.
|
||||
|
||||
**Use.** Turn on **Street Level** under DATA LAYERS → Cameras, or with the pill in the STREET LEVEL panel header. Each provider has a chip in its own colour (Mapillary green), and one is lit at a time: lighting one switches the others off. Mapillary, which is free, is lit by default. Zoom in to street level (below 60 km) for the coverage lines; from orbit the panel says to zoom in. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to Mapillary, KartaView and Panoramax; Street View panoramas are all 360°, so with Street View lit the filters are hidden. Click a coverage line for its image cones and a cone to open the photo. Light **KARTAVIEW** (purple) and zoom in below about 6 km: its lines are the streets KartaView has photos of, and a click on one opens the photo nearest the click, flat or 360° (drag to look around), with cones along its drive; **‹ ›** or ← → step through the drive (Shift+← →, ↑ ↓ and + − look around a 360° photo; pinch zooms on a phone). Photos KartaView has archived are skipped, and where all are archived the panel says so. For KartaView, **SINCE** goes by upload date, as KartaView's own map does (it can filter by nothing else), and the panel notes it under the slider; KartaView filters its lines on its own server, and a filtered area KartaView has not drawn before can take several seconds. Light **PANORAMAX** (orange) for the open, federated commons (strongest in France and Belgium): below about 60 km its lines are sequences. A click on a line opens that sequence's picture nearest the click without moving the camera, flat or 360°, with cones along the sequence around it; **‹ ›** or ← → step through it, and both filters apply. Its caption names the picture's authors (or the instance holding it) and licence (mostly CC BY-SA 4.0; pictures on the IGN instance are Licence Ouverte 2.0). On the **Google 3D** map (Google's terms keep Street View off other maps, where it does not appear), light **GOOGLE STREET VIEW** and click a street from below 3 km, or press **OPEN STREET VIEW** for the view centre, to open Google's panorama there; step along its arrows. Above the photo: **EXPAND** (full screen; Esc leaves) and **FIT / FILL** (Mapillary, and flat KartaView and Panoramax photos). The caption links to the image on the provider's site, and the globe credits each provider that is on ("© Mapillary contributors, CC BY-SA 4.0", "Street View imagery © Google", "© KartaView contributors, CC BY-SA 4.0", "© Panoramax contributors, licence per picture"). KartaView's own photo pages fail for signed-out visitors, so its link opens the full-size photo.
|
||||
**Use.** Turn on **Street Level** under DATA LAYERS → Cameras, or with the pill in the STREET LEVEL panel header. Each provider has a chip in its own colour (Mapillary green), and one is lit at a time: lighting one switches the others off. Mapillary, which is free, is lit by default. Zoom in to street level (below 60 km) for the coverage lines; from orbit the panel says to zoom in. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to Mapillary, KartaView and Panoramax; Street View panoramas are all 360°, so with Street View lit the filters are hidden. Click a coverage line for its image cones and a cone to open the photo. Light **KARTAVIEW** (purple) and zoom in below about 6 km: its lines are the streets KartaView has photos of, and a click on one opens the photo nearest the click, flat or 360° (drag to look around), with cones along its drive; **‹ ›** or ← → step through the drive (Shift+← →, ↑ ↓ and + − look around a 360° photo; pinch zooms on a phone). Photos KartaView has archived are skipped, and where all are archived the panel says so. For KartaView, **SINCE** goes by upload date, as KartaView's own map does (it can filter by nothing else), and the panel notes it beside the SINCE value (ANY DATE (by upload date)); KartaView filters its lines on its own server, and a filtered area KartaView has not drawn before can take several seconds. Light **PANORAMAX** (orange) for the open, federated commons (strongest in France and Belgium): below about 60 km its lines are sequences. A click on a line opens that sequence's picture nearest the click without moving the camera, flat or 360°, with cones along the sequence around it; **‹ ›** or ← → step through it, and both filters apply. Its caption names the picture's authors (or the instance holding it) and licence (mostly CC BY-SA 4.0; pictures on the IGN instance are Licence Ouverte 2.0). On the **Google 3D** map (Google's terms keep Street View off other maps, where it does not appear), light **GOOGLE STREET VIEW** and click a street from below 3 km, or press **OPEN STREET VIEW** for the view centre, to open Google's panorama there; step along its arrows. Above the photo: **EXPAND** (full screen; Esc leaves) and **FIT / FILL** (Mapillary, and flat KartaView and Panoramax photos). The caption links to the image on the provider's site, and the globe credits each provider that is on ("© Mapillary contributors, CC BY-SA 4.0", "Street View imagery © Google", "© KartaView contributors, CC BY-SA 4.0", "© Panoramax contributors, licence per picture"). KartaView's own photo pages fail for signed-out visitors, so its link opens the full-size photo.
|
||||
|
||||
Share links carry the provider switches and filters (`0.m.0` Mapillary off, `0.m.0_0.r.google-1` Street View on, `0.m.0_0.r.google-0*kartaview-1*panoramax-0` KartaView on and `0.m.0_0.r.google-0*kartaview-0*panoramax-1` Panoramax on, as the app writes them (`0.m.0_0.r.kartaview-1` and `0.m.0_0.r.panoramax-1` work too; a link that leaves out `m` keeps Mapillary, the default, lit), `0.p.p` panoramas only, `0.s.365` the last year). To add a provider, implement the contract in `src/layers/streetLevel/registry.js` and register it in `src/app/layers/streetLevel.js`.
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
- Report actual outcomes when voice enables or disables CCTV through the shared
|
||||
layer lifecycle, including failed and cancelled changes (manjunath22466,
|
||||
#1205).
|
||||
@@ -0,0 +1,3 @@
|
||||
- Require a geocoded containment anchor for ambiguous analyst region names,
|
||||
preserve boundary provenance and cancellation safety, and retain exact-name
|
||||
precedence for natural outlines (manjunath22466, #1235).
|
||||
@@ -0,0 +1,3 @@
|
||||
- Gate CCTV HLS media requests before session work, bind leases to the direct
|
||||
peer, and bound per-peer leases across shared sessions (asturksever, #1267).
|
||||
- Keep headful CCTV QA rendering active when its window is occluded (asturksever, #1267).
|
||||
@@ -0,0 +1,4 @@
|
||||
- The Street Level SINCE note sits on the value's line, in its type and a
|
||||
size smaller: with KartaView lit it reads ANY DATE (by upload date). It
|
||||
had lost its size variable in the Street Level slim-down and rendered
|
||||
larger than the value, on a line of its own (asturksever, #1286).
|
||||
@@ -0,0 +1,3 @@
|
||||
- Give ordinary sliders a shared native control and consistent track, thumb,
|
||||
value, focus and disabled styling while preserving feature behavior and
|
||||
specialized controls (mazy1998, #1287).
|
||||
@@ -0,0 +1,5 @@
|
||||
- Unify segmented selectors for model coverage, detection allocation, drawing
|
||||
shape, Street View and imagery comparison while preserving their existing
|
||||
behavior. Keep compact labels such as FILL and FLAT on one line, and let
|
||||
collapsed Weather release space to the open rail panel. Reserve the full mode
|
||||
button height in Recent Imagery so keyboard focus is not clipped (mazy1998, #1301).
|
||||
+52
-2
@@ -1,5 +1,21 @@
|
||||
# God's Eye View Current State
|
||||
|
||||
Analyst region queries resolve unique Natural Earth names offline without
|
||||
geocoding, so Gold Coast and Ivory Coast name the West African natural regions.
|
||||
Duplicate names require a geocoded containment anchor; when several entries for
|
||||
the same name contain that anchor, the lookup stays unresolved rather than
|
||||
selecting the largest entry. Natural outlines try exact names before aliases and
|
||||
broader suffix variants. Follow-ups retain boundary provenance, and cancelled
|
||||
lookups preserve the previous analyst cohort and boundary source. Navigation
|
||||
search keeps a successful geocode, with no search or view-based region recovery.
|
||||
|
||||
CCTV live-media requests are same-site gated before catalog or media work, including
|
||||
HLS renewal, segment reads and DELETE cleanup. HLS leases belong to their socket
|
||||
peer (IPv4-mapped IPv6 is normalized), with eight leases per peer across the two
|
||||
global sessions. Peers behind the same NAT share that budget; this is resource
|
||||
admission, not authentication. Native same-origin video and range requests remain
|
||||
supported.
|
||||
|
||||
Shared distance checks use `src/geoDistance.js` with `{ lat, lon }` points and
|
||||
the IUGG mean spherical Earth radius, 6371.0088 km. `greatCircleKm` is
|
||||
canonical; `greatCircleMeters` is its exact metre conversion. Missing,
|
||||
@@ -624,6 +640,14 @@ The standalone app offers OpenAI and Gemini Live through an explicit provider ch
|
||||
|
||||
Voice action argument schemas have one portable owner under `src/voice/actionSchemas.js`. The Realtime provider builds 30 tools using separate description-only metadata. Description customization cannot replace argument types, enum values or required fields.
|
||||
|
||||
`createGevActionRunner` routes `control_cctv` enable/disable through the shared
|
||||
layer visibility lifecycle. Results distinguish successful changes from
|
||||
manager-reported failures or cancellation and report enabled state and lifecycle
|
||||
uncertainty. A caller-supplied abort signal reaches the manager; a late abort
|
||||
preserves an already committed change. Missing-layer errors and other CCTV
|
||||
actions retain their existing behavior. The exported `controlCctv` helper refuses direct
|
||||
enable/disable requests and directs callers to `set_layer_visibility`.
|
||||
|
||||
`src/voice/layerManifest.js` declares each catalog layer's voice capabilities: spoken aliases, `toggle`, in-view `context`, `track`, and analyst `query` fields with type and unit, or `noQuery` with the reason. The `set_layer_visibility`, `show_data_layers_menu`, `get_entity_context` and `analyst_query` layer enums, the runner's alias table and the analyst field hints are generated from it. `src/voice/layerManifest.test.mjs` fails when a catalog layer is neither listed nor named in `VOICE_OFF_LAYERS` (today only the two scene-driven Bhote Koshi layers). Global Context is listed with `toggle: false`; `set_context_mode` reaches it. `set_panel_open` also opens `weather-panel` and `recent-imagery-panel`.
|
||||
|
||||
Portable source exports provide Radio Browser station normalization, CCTV feed types and regional records independently of HTTP middleware. Radio normalization accepts an explicit URL policy; the standalone directory retains its existing HTTPS rules. Tile coordinate validation accepts explicit zoom bounds with unchanged traffic defaults. Cache, request and rendering owners remain unchanged.
|
||||
@@ -1207,6 +1231,32 @@ The application supplies actions and retains effect settings, restore claims and
|
||||
rendering. Native keyboard editing, model modes and current defaults are preserved.
|
||||
Destroying the UI removes these listeners before asynchronous teardown.
|
||||
|
||||
Ordinary sliders share the native range component in `ui/slider.js` and the
|
||||
`gev-quantitative-slider` surface in `ui/styles/slider.css`. Display, shader
|
||||
parameters, volume, replay speed, weather settings/history, imagery opacity and
|
||||
Street View's date filter use the same rail, thumb, value styling, keyboard focus and disabled state.
|
||||
Generated controls use `createSlider`; static templates apply the same class.
|
||||
Feature owners retain bounds, steps, formatted values, input/change timing and
|
||||
listener cleanup. The radio tuning dial, imagery comparison divider and event
|
||||
comparison/progression controls keep their specialized presentation and behavior.
|
||||
|
||||
## Segmented selectors
|
||||
|
||||
Segmented selectors use the controlled DOM adapter in `ui/segmentedControl.js`
|
||||
and the shared `gev-segmented` / `gev-segment` surface. Model coverage, label
|
||||
allocation, drawing shape, Street View fit/panorama filters and imagery mode keep
|
||||
their feature-owned values, actions and persistence. Painting a restored value
|
||||
does not emit a change or move focus. Existing keyboard policies remain explicit:
|
||||
Street View uses arrows/Home/End with one tab stop, imagery mode uses arrows from
|
||||
its controlled selection, and Display keeps its existing native button behavior.
|
||||
Pure value navigation is separate from DOM rendering for reuse by a future view.
|
||||
Segments keep their word minimum width: compact inline groups such as FIT/FILL
|
||||
and ALL/360°/FLAT must not break short labels mid-word. Collapsed Weather follows
|
||||
the same exclusive-rail hiding rule as the other readouts.
|
||||
The imagery mode row reserves 26px for the 24px buttons and group borders so
|
||||
the complete button and inset keyboard-focus outline remain visible.
|
||||
Browser acceptance: `QA_BASE_URL=http://127.0.0.1:4173 node scripts/qa-segmented-controls.mjs`.
|
||||
|
||||
## Application shortcuts and shader parameter controls
|
||||
|
||||
`ui/input` supplies the bubbling application shortcut listener and generated
|
||||
@@ -3668,8 +3718,8 @@ lookup asks the photo API for the projection (`SPHERE`/`PLANE`; it ignores
|
||||
`fieldOfView`) and upload date and checks each answer the same way (when
|
||||
photos are near but none passes, the panel says so), and the cones pass on
|
||||
each photo's projection and upload date. The provider declares
|
||||
`sinceBasis: 'upload'`, so while it is lit the panel shows "KartaView: by
|
||||
upload date" under the SINCE slider (`sinceNote` in the UI state). Tiles come uncompressed and
|
||||
`sinceBasis: 'upload'`, so while it is lit the panel shows "(by
|
||||
upload date)" beside the SINCE value, as ANY DATE (by upload date) (`sinceNote` in the UI state). Tiles come uncompressed and
|
||||
uncacheable, so the last 24 decoded tiles are kept for 10 minutes while
|
||||
KartaView is lit (`createTileCache`, dropped when it is switched off); one
|
||||
that does not answer in 20 s is asked once more, and one that is not a line
|
||||
|
||||
@@ -190,7 +190,10 @@
|
||||
"src/ui/visualInput.js",
|
||||
"src/ui/visualInput.test.mjs",
|
||||
"scripts/qa-visual-input.mjs",
|
||||
"scripts/qa-segmented-controls.mjs",
|
||||
"src/ui/displayControls.js",
|
||||
"src/ui/segmentedControl.test.mjs",
|
||||
"src/ui/styles/segmented-control.css",
|
||||
"src/ui/displayControls.test.mjs",
|
||||
"src/ui/cameraOrientationControls.js",
|
||||
"src/ui/cameraOrientationControls.test.mjs",
|
||||
@@ -301,6 +304,7 @@
|
||||
"src/ui/styles/status.css",
|
||||
"src/ui/styles/layers.css",
|
||||
"src/ui/styles/controls.css",
|
||||
"src/ui/styles/slider.css",
|
||||
"src/ui/styles/cctv.css",
|
||||
"src/ui/styles/foundation.css",
|
||||
"src/ui/styles/first-run.css",
|
||||
|
||||
@@ -187,6 +187,8 @@
|
||||
"exports": ["./server/providers/cctv"],
|
||||
"modules": [
|
||||
"server/providers/cctv.js",
|
||||
"server/providers/common/same-site.js",
|
||||
"src/localRequestGate.mjs",
|
||||
"server/providers/cctv/cap.js",
|
||||
"server/providers/cctv/groundHeights.js",
|
||||
"server/providers/cctv/catalog.js",
|
||||
@@ -422,6 +424,7 @@
|
||||
"src/ui/visualInput.js",
|
||||
"src/ui/applicationShortcuts.js",
|
||||
"src/ui/styleParameters.js",
|
||||
"src/ui/slider.js",
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
@@ -431,7 +434,7 @@
|
||||
},
|
||||
"display-controls": {
|
||||
"exports": ["./ui/display"],
|
||||
"modules": ["src/ui/displayControls.js"],
|
||||
"modules": ["src/ui/displayControls.js", "src/ui/segmentedControl.js"],
|
||||
"external": []
|
||||
},
|
||||
"visual-effects": {
|
||||
@@ -480,6 +483,7 @@
|
||||
"src/ui/weatherPanel.js",
|
||||
"src/ui/railCards.js",
|
||||
"src/ui/railCardBlocks.js",
|
||||
"src/ui/slider.js",
|
||||
"src/ui/chipGroup.js",
|
||||
"src/ui/rowList.js",
|
||||
"src/ui/railTimeline.js",
|
||||
@@ -701,6 +705,7 @@
|
||||
"src/ui/contextSubscriptions.js",
|
||||
"src/ui/contextTransactions.js",
|
||||
"src/ui/displayControls.js",
|
||||
"src/ui/segmentedControl.js",
|
||||
"src/ui/effects.js",
|
||||
"src/ui/frameRateMonitor.js",
|
||||
"src/ui/layerPanel.js",
|
||||
@@ -739,6 +744,7 @@
|
||||
"src/ui/shellElements.js",
|
||||
"src/ui/shellFeedback.js",
|
||||
"src/ui/styleParameters.js",
|
||||
"src/ui/slider.js",
|
||||
"src/ui/surfaceKeyboard.js",
|
||||
"src/ui/uiLifetime.js",
|
||||
"src/ui/visualEffects.js",
|
||||
@@ -2176,6 +2182,7 @@
|
||||
"src/ui/contextSubscriptions.js",
|
||||
"src/ui/contextTransactions.js",
|
||||
"src/ui/displayControls.js",
|
||||
"src/ui/segmentedControl.js",
|
||||
"src/ui/effects.js",
|
||||
"src/ui/frameRateMonitor.js",
|
||||
"src/ui/layerPanel.js",
|
||||
@@ -2214,6 +2221,7 @@
|
||||
"src/ui/shellElements.js",
|
||||
"src/ui/shellFeedback.js",
|
||||
"src/ui/styleParameters.js",
|
||||
"src/ui/slider.js",
|
||||
"src/ui/surfaceKeyboard.js",
|
||||
"src/ui/uiLifetime.js",
|
||||
"src/ui/visualEffects.js",
|
||||
@@ -3207,11 +3215,13 @@
|
||||
"src/ui/applicationShortcuts.js",
|
||||
"src/ui/effects.js",
|
||||
"src/ui/styleParameters.js",
|
||||
"src/ui/slider.js",
|
||||
"src/ui/uiLifetime.js",
|
||||
"src/ui/visualEffects.js",
|
||||
"src/ui/visualInput.js",
|
||||
"src/ui/visualPresets.js",
|
||||
"src/ui/visualSettings.js",
|
||||
"src/ui/segmentedControl.js",
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
|
||||
@@ -454,6 +454,7 @@ async function main() {
|
||||
...(HEADFUL ? [] : ['--use-gl=angle', '--use-angle=swiftshader']),
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-web-security',
|
||||
'--disable-backgrounding-occluded-windows',
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-renderer-backgrounding',
|
||||
'--window-size=1280,800',
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
#!/usr/bin/env node
|
||||
/** Run against a dev server or built preview with QA_BASE_URL (default :4179). */
|
||||
import fs from 'node:fs';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
executablePath:
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH || (await puppeteer.executablePath()),
|
||||
headless: true,
|
||||
args: ['--use-gl=angle', '--use-angle=swiftshader'],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
const errors = [];
|
||||
const failures = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
const check = (name, passed, detail = '') => {
|
||||
console.log(
|
||||
`[${passed ? 'PASS' : 'FAIL'}] ${name}${detail ? ` — ${detail}` : ''}`,
|
||||
);
|
||||
if (!passed) failures.push(name);
|
||||
};
|
||||
const base = process.env.QA_BASE_URL || 'http://localhost:4179';
|
||||
const shots = process.env.QA_SHOTS_DIR || 'qa-shots/segmented-controls';
|
||||
fs.mkdirSync(shots, { recursive: true });
|
||||
|
||||
try {
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
await page.goto(`${base}/?welcome=0`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView?.styleManager?._applicationShortcuts,
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.styleManager.setPanelCollapsed('pp-toggles', false, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
}),
|
||||
);
|
||||
|
||||
const surface = await page.evaluate(() => {
|
||||
const groups = [...document.querySelectorAll('[role="radiogroup"]')];
|
||||
const buttons = groups.flatMap((group) => [
|
||||
...group.querySelectorAll('[role="radio"]'),
|
||||
]);
|
||||
return {
|
||||
groups: groups.length,
|
||||
buttons: buttons.length,
|
||||
shared:
|
||||
groups.every((g) => g.classList.contains('gev-segmented')) &&
|
||||
buttons.every((b) => {
|
||||
const style = getComputedStyle(b);
|
||||
return (
|
||||
b.classList.contains('gev-segment') &&
|
||||
style.fontSize === '9px' &&
|
||||
style.minHeight === '24px' &&
|
||||
style.borderRadius === '0px'
|
||||
);
|
||||
}),
|
||||
};
|
||||
});
|
||||
check(
|
||||
'all six one-of-many groups use the shared surface',
|
||||
surface.groups === 6 && surface.buttons === 15 && surface.shared,
|
||||
JSON.stringify(surface),
|
||||
);
|
||||
|
||||
await page.evaluate(() =>
|
||||
document.getElementById('models3d-mode-all').click(),
|
||||
);
|
||||
check(
|
||||
'model selection reaches both aircraft layers',
|
||||
await page.evaluate(() => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
return (
|
||||
['flights', 'military'].every(
|
||||
(id) => dm.layers.get(id).module.getParams().models3dMode === 'all',
|
||||
) &&
|
||||
document
|
||||
.getElementById('models3d-mode-all')
|
||||
.getAttribute('aria-checked') === 'true'
|
||||
);
|
||||
}),
|
||||
);
|
||||
await page.focus('#models3d-mode-all');
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
check(
|
||||
'Display retains its click-only arrow policy',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
document
|
||||
.getElementById('models3d-mode-all')
|
||||
.getAttribute('aria-checked') === 'true',
|
||||
),
|
||||
);
|
||||
await page.focus('#models3d-mode-proximity');
|
||||
await page.keyboard.press('Space');
|
||||
check(
|
||||
'native Space selects model coverage',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
document
|
||||
.getElementById('models3d-mode-proximity')
|
||||
.getAttribute('aria-checked') === 'true',
|
||||
),
|
||||
);
|
||||
await page.evaluate(() =>
|
||||
document.getElementById('detection-allocation-weighted').click(),
|
||||
);
|
||||
check(
|
||||
'allocation selection still updates application tuning',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
window.__godsEyeView.styleManager._detectionAllocationPreference ===
|
||||
'WEIGHTED' &&
|
||||
document
|
||||
.getElementById('detection-allocation-weighted')
|
||||
.getAttribute('aria-checked') === 'true',
|
||||
),
|
||||
);
|
||||
|
||||
await page.evaluate(() =>
|
||||
document.querySelector('[data-shape="pin"]').click(),
|
||||
);
|
||||
check(
|
||||
'drawing shape still uses its existing feature action',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
window.__gevDrawTool?.shape === 'pin' &&
|
||||
document
|
||||
.querySelector('[data-shape="pin"]')
|
||||
.getAttribute('aria-checked') === 'true',
|
||||
),
|
||||
);
|
||||
|
||||
// Off-layer controls still own params; no catalog/provider request is needed.
|
||||
await page.evaluate(() =>
|
||||
document.querySelector('[data-sl-pano="flat"]').click(),
|
||||
);
|
||||
check(
|
||||
'panorama choice reaches the Street Level layer',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('street-level')
|
||||
.module.getParams().pano === 'flat',
|
||||
),
|
||||
);
|
||||
await page.evaluate(() =>
|
||||
document.querySelector('[data-sl-pano="flat"]').dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: 'Home',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
),
|
||||
);
|
||||
check(
|
||||
'Street Level Home retains selection and roving tab state',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('street-level')
|
||||
.module.getParams().pano === 'all' &&
|
||||
document
|
||||
.querySelector('[data-sl-pano="all"]')
|
||||
.getAttribute('tabindex') === '0',
|
||||
),
|
||||
);
|
||||
await page.evaluate(() =>
|
||||
document.querySelector('[data-sl-render="fill"]').click(),
|
||||
);
|
||||
check(
|
||||
'fit/fill keeps its stored render preference',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
localStorage.getItem('gev:street-level:render-mode') === 'fill' &&
|
||||
document
|
||||
.querySelector('[data-sl-render="fill"]')
|
||||
.getAttribute('aria-checked') === 'true',
|
||||
),
|
||||
);
|
||||
|
||||
await page.evaluate(() =>
|
||||
document.querySelector('.ri-mode-btn[data-mode="ab"]').click(),
|
||||
);
|
||||
check(
|
||||
'imagery mode reaches its controlled layer state',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('recent-imagery')
|
||||
.module.getSnapshot().mode === 'ab',
|
||||
),
|
||||
);
|
||||
await page.evaluate(() =>
|
||||
document.getElementById('ri-mode').dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: 'ArrowRight',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
),
|
||||
);
|
||||
check(
|
||||
'imagery arrows wrap from the current selected value',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('recent-imagery')
|
||||
.module.getSnapshot().mode === 'image' &&
|
||||
document
|
||||
.querySelector('.ri-mode-btn[data-mode="image"]')
|
||||
.getAttribute('aria-checked') === 'true',
|
||||
),
|
||||
);
|
||||
|
||||
await page.evaluate(() => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
manager._setDetectionAllocation('ELASTIC', {
|
||||
syncShare: false,
|
||||
persist: false,
|
||||
});
|
||||
document
|
||||
.getElementById('models3d-mode-proximity')
|
||||
.scrollIntoView({ block: 'center' });
|
||||
});
|
||||
await page.screenshot({ path: `${shots}/desktop.png` });
|
||||
await page.setViewport({ width: 620, height: 900 });
|
||||
await page.evaluate(() =>
|
||||
document
|
||||
.getElementById('models3d-mode-proximity')
|
||||
.scrollIntoView({ block: 'center' }),
|
||||
);
|
||||
await page.screenshot({ path: `${shots}/narrow.png` });
|
||||
check(
|
||||
'narrow selectors stay within their panel',
|
||||
await page.evaluate(() =>
|
||||
[...document.querySelectorAll('.pp-mode-seg')]
|
||||
.filter((g) => g.getClientRects().length)
|
||||
.every((group) => group.scrollWidth <= group.clientWidth + 1),
|
||||
),
|
||||
);
|
||||
const cyber = await page.evaluate(() => {
|
||||
const root = document.documentElement;
|
||||
const previous = root.getAttribute('data-ui-theme');
|
||||
root.setAttribute('data-ui-theme', 'cyber');
|
||||
const selected = [
|
||||
...document.querySelectorAll('.gev-segment[aria-checked="true"]'),
|
||||
];
|
||||
// Check the final theme colors, independent of software-GL frame cadence.
|
||||
for (const button of selected)
|
||||
for (const animation of button.getAnimations()) animation.finish();
|
||||
const backgrounds = selected.map(
|
||||
(button) => getComputedStyle(button).backgroundColor,
|
||||
);
|
||||
const radii = [...document.querySelectorAll('.gev-segmented')].map(
|
||||
(group) => getComputedStyle(group).borderRadius,
|
||||
);
|
||||
const consistent =
|
||||
selected.length === 6 &&
|
||||
backgrounds.every((color) => color === backgrounds[0]) &&
|
||||
radii.every((radius) => radius === '1px');
|
||||
if (previous === null) root.removeAttribute('data-ui-theme');
|
||||
else root.setAttribute('data-ui-theme', previous);
|
||||
return { consistent, count: selected.length, backgrounds, radii };
|
||||
});
|
||||
check(
|
||||
'Cyber theme styles every selected segment consistently',
|
||||
cyber.consistent,
|
||||
JSON.stringify(cyber),
|
||||
);
|
||||
await page.evaluate(async () => {
|
||||
const { dataManager, styleManager } = window.__godsEyeView;
|
||||
await dataManager.setEnabled('recent-imagery', true);
|
||||
styleManager.setPanelCollapsed('recent-imagery-panel', false, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
});
|
||||
});
|
||||
for (const width of [1440, 620, 390]) {
|
||||
await page.setViewport({ width, height: 900 });
|
||||
await page.focus('.ri-mode-btn[aria-checked="true"]');
|
||||
const modeLayout = await page.evaluate(() => {
|
||||
const group = document.getElementById('ri-mode');
|
||||
const bounds = group.getBoundingClientRect();
|
||||
const style = getComputedStyle(group);
|
||||
const top = bounds.top + parseFloat(style.borderTopWidth);
|
||||
const bottom = bounds.bottom - parseFloat(style.borderBottomWidth);
|
||||
const buttons = [...group.querySelectorAll('[role="radio"]')];
|
||||
return {
|
||||
visible: bounds.width > 0 && bounds.height > 0,
|
||||
contained: buttons.every((button) => {
|
||||
const rect = button.getBoundingClientRect();
|
||||
return rect.top >= top - 0.5 && rect.bottom <= bottom + 0.5;
|
||||
}),
|
||||
groupHeight: bounds.height,
|
||||
buttonHeights: buttons.map(
|
||||
(button) => button.getBoundingClientRect().height,
|
||||
),
|
||||
};
|
||||
});
|
||||
check(
|
||||
`imagery mode buttons and focus outline fit their row at ${width}px`,
|
||||
modeLayout.visible && modeLayout.contained,
|
||||
JSON.stringify(modeLayout),
|
||||
);
|
||||
}
|
||||
check('no uncaught browser errors', errors.length === 0, errors.join(' | '));
|
||||
} catch (error) {
|
||||
console.error('Browser errors:', errors);
|
||||
await page.screenshot({ path: `${shots}/failure.png` }).catch(() => {});
|
||||
throw error;
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
console.log(`RESULT: ${failures.length} failures`);
|
||||
process.exitCode = failures.length ? 1 : 0;
|
||||
@@ -661,6 +661,32 @@ async function main() {
|
||||
},
|
||||
);
|
||||
|
||||
await step(
|
||||
'FIT/FILL keeps native selection, keyboard focus and persistence with a photo open',
|
||||
async () => {
|
||||
await page.click('[data-sl-render="fill"]');
|
||||
await uiUntil(page, (u) => u.street.renderMode === 'fill');
|
||||
assert.equal(
|
||||
await page.evaluate(() =>
|
||||
localStorage.getItem('gev:street-level:render-mode'),
|
||||
),
|
||||
'fill',
|
||||
);
|
||||
await page.keyboard.press('Home');
|
||||
await uiUntil(page, (u) => u.street.renderMode === 'letterbox');
|
||||
assert.deepEqual(
|
||||
await page.$eval('[data-sl-render="letterbox"]', (node) => ({
|
||||
selected: node.getAttribute('aria-checked'),
|
||||
tabIndex: node.tabIndex,
|
||||
focused: document.activeElement === node,
|
||||
})),
|
||||
{ selected: 'true', tabIndex: 0, focused: true },
|
||||
);
|
||||
await page.keyboard.press('End');
|
||||
await uiUntil(page, (u) => u.street.renderMode === 'fill');
|
||||
},
|
||||
);
|
||||
|
||||
await step('× closes the photo and deselects its sequence', async () => {
|
||||
await press(page, '#sl-viewer-close');
|
||||
await uiUntil(
|
||||
@@ -673,6 +699,21 @@ async function main() {
|
||||
);
|
||||
});
|
||||
|
||||
await step(
|
||||
'panorama segments keep native arrow and Home navigation',
|
||||
async () => {
|
||||
await page.click('[data-sl-pano="flat"]');
|
||||
await uiUntil(page, (u) => u.filter.pano === 'flat');
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await uiUntil(page, (u) => u.filter.pano === 'all');
|
||||
await page.keyboard.press('End');
|
||||
await uiUntil(page, (u) => u.filter.pano === 'flat');
|
||||
await page.keyboard.press('Home');
|
||||
await uiUntil(page, (u) => u.filter.pano === 'all');
|
||||
await waitForCoverage(page);
|
||||
},
|
||||
);
|
||||
|
||||
await keylessProviderStep({
|
||||
id: 'kartaview',
|
||||
name: 'KartaView',
|
||||
|
||||
@@ -183,6 +183,38 @@ try {
|
||||
return manager.bloomEnabled !== before;
|
||||
}),
|
||||
);
|
||||
const sliders = await page.evaluate(() => {
|
||||
const ordinary = [
|
||||
...document.querySelectorAll('input[type="range"]'),
|
||||
].filter(
|
||||
(input) => !input.matches('#radio-tuner-slider, .bhote-event-range'),
|
||||
);
|
||||
return {
|
||||
count: ordinary.length,
|
||||
shared: ordinary.every((input) =>
|
||||
input.classList.contains('gev-quantitative-slider'),
|
||||
),
|
||||
surface: ordinary.every((input) => {
|
||||
const style = getComputedStyle(input);
|
||||
return (
|
||||
style.height === '18px' &&
|
||||
style.appearance === 'none' &&
|
||||
style.padding === '0px' &&
|
||||
style.borderWidth === '0px' &&
|
||||
(!input.disabled ||
|
||||
(style.opacity === '0.42' && style.cursor === 'not-allowed'))
|
||||
);
|
||||
}),
|
||||
tunerSeparate: !document
|
||||
.getElementById('radio-tuner-slider')
|
||||
.classList.contains('gev-quantitative-slider'),
|
||||
};
|
||||
});
|
||||
check(
|
||||
'ordinary sliders share one native surface',
|
||||
sliders.count > 10 && sliders.shared && sliders.surface,
|
||||
);
|
||||
check('radio tuning keeps its specialized control', sliders.tunerSeparate);
|
||||
fs.mkdirSync('qa-shots/visual-input', { recursive: true });
|
||||
await page.screenshot({ path: 'qa-shots/visual-input/desktop.png' });
|
||||
await page.setViewport({ width: 620, height: 900 });
|
||||
|
||||
@@ -1745,8 +1745,9 @@ async function runBehaviorLayer() {
|
||||
'behavior: analyst follow-up re-filters the remembered set',
|
||||
`count=${r?.count} followUp=${r?.coverage?.followUp}`,
|
||||
);
|
||||
// A marine region resolves from the bundled Natural Earth pack in the page,
|
||||
// without the slower geocode fallback.
|
||||
// Require the bundled boundary source and the existing whole-query limit.
|
||||
// This elapsed time includes record acquisition and filtering; the region
|
||||
// resolver has its own separate fallback budget.
|
||||
const gulfStarted = Date.now();
|
||||
r = await run('analyst_query', {
|
||||
layers: ['flights'],
|
||||
@@ -1757,9 +1758,10 @@ async function runBehaviorLayer() {
|
||||
report(
|
||||
r?.ok === true &&
|
||||
String(r?.coverage?.scope || '').includes('Gulf of Mexico') &&
|
||||
r?.coverage?.scopeSource === 'natural-earth' &&
|
||||
gulfMs < 3000,
|
||||
'behavior: analyst resolves the Gulf of Mexico from the bundled pack',
|
||||
`scope=${r?.coverage?.scope} ms=${gulfMs} err=${r?.error || ''}`,
|
||||
`scope=${r?.coverage?.scope} source=${r?.coverage?.scopeSource} ms=${gulfMs} err=${r?.error || ''}`,
|
||||
);
|
||||
|
||||
// (5) zoom_to_globe is ABSOLUTE full-earth (>12,000 km band)
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
CCTV_MAX_SOURCES_CEILING,
|
||||
} from './cctv/constants.js';
|
||||
import { sanitizeCctvRangeHeader } from './cctv/range.js';
|
||||
import { admitSameSite } from './common/same-site.js';
|
||||
import { createHlsPuller } from './cctv/stream.js';
|
||||
import { googleServerApiKey } from './places/google-key.js';
|
||||
export { CCTV_FRAME_FETCH_TIMEOUT_MS, fetchCctvImageFromUpstream };
|
||||
@@ -135,11 +136,18 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
|
||||
});
|
||||
server.middlewares.use('/api/cctv', async (req, res) => {
|
||||
try {
|
||||
const url = new URL(req.url || '/', 'http://localhost');
|
||||
// Refuse before catalog I/O, session creation, renewal or release.
|
||||
if (url.pathname.startsWith('/media/') && admitSameSite(req, res))
|
||||
return;
|
||||
const clientId = String(req.socket?.remoteAddress || 'unknown').replace(
|
||||
/^::ffff:/,
|
||||
'',
|
||||
);
|
||||
const sources = await getCctvSources();
|
||||
const sourceById = new Map(
|
||||
sources.map((source) => [source.id, source]),
|
||||
);
|
||||
const url = new URL(req.url || '/', 'http://localhost');
|
||||
|
||||
if (url.pathname === '/sources') {
|
||||
const body = {
|
||||
@@ -238,7 +246,7 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
|
||||
return;
|
||||
}
|
||||
if (req.method === 'DELETE') {
|
||||
if (leaseId) puller.release(cameraId, leaseId);
|
||||
if (leaseId) puller.release(cameraId, leaseId, clientId);
|
||||
res.writeHead(204);
|
||||
res.end();
|
||||
return;
|
||||
@@ -268,6 +276,7 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
|
||||
url.searchParams.get('session'),
|
||||
Number(match[3]),
|
||||
leaseId,
|
||||
clientId,
|
||||
);
|
||||
res.writeHead(body ? 200 : 404, {
|
||||
'Content-Type': 'video/mp2t',
|
||||
@@ -279,10 +288,15 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
|
||||
const downstream = watchDownstreamClose(res);
|
||||
let entry;
|
||||
const cancelPending = () => {
|
||||
if (entry) puller.release(cameraId, leaseId);
|
||||
if (entry) puller.release(cameraId, leaseId, clientId);
|
||||
};
|
||||
try {
|
||||
entry = await puller.ensure(cameraId, mediaUrl, leaseId);
|
||||
entry = await puller.ensure(
|
||||
cameraId,
|
||||
mediaUrl,
|
||||
leaseId,
|
||||
clientId,
|
||||
);
|
||||
if (downstream.closed) {
|
||||
cancelPending();
|
||||
return;
|
||||
@@ -296,6 +310,7 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
|
||||
entry,
|
||||
cameraId,
|
||||
leaseId,
|
||||
clientId,
|
||||
);
|
||||
if (downstream.closed) return;
|
||||
if (!playlist) throw new Error('Stream unavailable');
|
||||
|
||||
@@ -4,6 +4,7 @@ import { randomUUID } from 'node:crypto';
|
||||
export const HLS_LIMITS = Object.freeze({
|
||||
sessions: 2,
|
||||
leasesPerSession: 8,
|
||||
leasesPerClient: 8,
|
||||
playlistBytes: 256 * 1024,
|
||||
segmentBytes: 4 * 1024 * 1024,
|
||||
sessionBytes: 24 * 1024 * 1024,
|
||||
@@ -138,25 +139,37 @@ export function createHlsPuller({
|
||||
entry.upstream.clear();
|
||||
entry.bytes = 0;
|
||||
};
|
||||
const release = (cameraId, leaseId) => {
|
||||
const release = (cameraId, leaseId, clientId = 'legacy') => {
|
||||
const entry = active.get(cameraId);
|
||||
const lease = entry?.leases.get(leaseId);
|
||||
if (!lease) return;
|
||||
if (!lease || lease.clientId !== clientId) return;
|
||||
clearTimeout(lease.timer);
|
||||
entry.leases.delete(leaseId);
|
||||
if (!entry.leases.size) stop(cameraId, entry.token);
|
||||
};
|
||||
const touch = (entry, leaseId, create = false) => {
|
||||
const touch = (entry, leaseId, create = false, clientId = 'legacy') => {
|
||||
let lease = entry.leases.get(leaseId);
|
||||
if (lease && lease.clientId !== clientId)
|
||||
throw new Error('HLS lease belongs to another client');
|
||||
if (!lease) {
|
||||
const clientLeases = [...active.values()].reduce(
|
||||
(count, session) =>
|
||||
count +
|
||||
[...session.leases.values()].filter(
|
||||
(item) => item.clientId === clientId,
|
||||
).length,
|
||||
0,
|
||||
);
|
||||
if (clientLeases >= limits.leasesPerClient)
|
||||
throw new Error('HLS client lease capacity reached');
|
||||
if (!create || entry.leases.size >= limits.leasesPerSession)
|
||||
throw new Error('HLS lease capacity reached');
|
||||
lease = {};
|
||||
lease = { clientId };
|
||||
entry.leases.set(leaseId, lease);
|
||||
}
|
||||
clearTimeout(lease.timer);
|
||||
lease.timer = setTimeout(
|
||||
() => release(entry.cameraId, leaseId),
|
||||
() => release(entry.cameraId, leaseId, clientId),
|
||||
limits.idleMs,
|
||||
);
|
||||
lease.timer.unref?.();
|
||||
@@ -248,7 +261,12 @@ export function createHlsPuller({
|
||||
}
|
||||
}
|
||||
};
|
||||
const ensure = async (cameraId, url, leaseId = 'legacy') => {
|
||||
const ensure = async (
|
||||
cameraId,
|
||||
url,
|
||||
leaseId = 'legacy',
|
||||
clientId = 'legacy',
|
||||
) => {
|
||||
if (closed) throw new Error('HLS service closed');
|
||||
sameOriginHlsUrl(url, url);
|
||||
let entry = active.get(cameraId);
|
||||
@@ -257,7 +275,7 @@ export function createHlsPuller({
|
||||
entry = null;
|
||||
}
|
||||
if (entry) {
|
||||
touch(entry, leaseId, true);
|
||||
touch(entry, leaseId, true, clientId);
|
||||
return entry;
|
||||
}
|
||||
// Reserve synchronously, before any await: concurrent creation is singleflight.
|
||||
@@ -279,8 +297,8 @@ export function createHlsPuller({
|
||||
pendingDiscontinuity: false,
|
||||
discontinuitiesRemoved: 0,
|
||||
};
|
||||
touch(entry, leaseId, true, clientId);
|
||||
active.set(cameraId, entry);
|
||||
touch(entry, leaseId, true);
|
||||
entry.polling = poll(entry);
|
||||
return entry;
|
||||
};
|
||||
@@ -292,9 +310,14 @@ export function createHlsPuller({
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const buildPlaylist = async (entry, cameraId, leaseId = 'legacy') => {
|
||||
const buildPlaylist = async (
|
||||
entry,
|
||||
cameraId,
|
||||
leaseId = 'legacy',
|
||||
clientId = 'legacy',
|
||||
) => {
|
||||
if (entry.stopping || entry.segments.size < 2) return null;
|
||||
touch(entry, leaseId);
|
||||
touch(entry, leaseId, false, clientId);
|
||||
const segments = [...entry.segments.values()].sort((a, b) => a.seq - b.seq);
|
||||
const lines = [
|
||||
'#EXTM3U',
|
||||
@@ -318,12 +341,22 @@ export function createHlsPuller({
|
||||
}
|
||||
return lines.join('\n') + '\n';
|
||||
};
|
||||
const getSegment = (cameraId, token, seq, leaseId = 'legacy') => {
|
||||
const getSegment = (
|
||||
cameraId,
|
||||
token,
|
||||
seq,
|
||||
leaseId = 'legacy',
|
||||
clientId = 'legacy',
|
||||
) => {
|
||||
const entry = active.get(cameraId);
|
||||
if (!entry || entry.token !== token || !entry.leases.has(leaseId))
|
||||
if (
|
||||
!entry ||
|
||||
entry.token !== token ||
|
||||
entry.leases.get(leaseId)?.clientId !== clientId
|
||||
)
|
||||
return null;
|
||||
const body = entry.segments.get(seq)?.body;
|
||||
if (body) touch(entry, leaseId);
|
||||
if (body) touch(entry, leaseId, false, clientId);
|
||||
return body || null;
|
||||
};
|
||||
const shutdown = async () => {
|
||||
|
||||
@@ -120,6 +120,93 @@ test('Georgia resolves from camera geography without a geocoder', async () => {
|
||||
assert.deepEqual(geocodes, []);
|
||||
});
|
||||
|
||||
test('Gold Coast in Australia cannot acquire its West African namesake outline', async () => {
|
||||
for (const types of [['locality', 'political'], ['natural_feature']]) {
|
||||
const { resolve, geocodes } = harness({
|
||||
lat: -28.0167,
|
||||
lng: 153.4,
|
||||
label: 'Gold Coast, Australia',
|
||||
name: 'Gold Coast',
|
||||
types,
|
||||
});
|
||||
const resolved = await resolve('Gold Coast', viewerAt(-28.0167, 153.4));
|
||||
assert.equal(resolved?.source, 'geocode');
|
||||
assert.equal(resolved.lat, -28.0167);
|
||||
assert.equal(resolved.lon, 153.4);
|
||||
assert.equal(resolved.naturalRegion, undefined);
|
||||
const outline = await resolved.resolveOutline();
|
||||
assert.equal(outline?.naturalRegion, undefined);
|
||||
assert.deepEqual(geocodes, ['Gold Coast']);
|
||||
}
|
||||
});
|
||||
|
||||
test('natural annotation outlines require a geocoded anchor', async () => {
|
||||
for (const viewer of [AUSTIN, {}, viewerAt(46.5, 10, 248_000)]) {
|
||||
const miss = harness();
|
||||
assert.equal(await miss.resolve('the Alps', viewer), null);
|
||||
assert.ok(miss.geocodes.includes('the Alps'));
|
||||
}
|
||||
const anchored = harness({
|
||||
lat: 46.5,
|
||||
lng: 10,
|
||||
label: 'Alps',
|
||||
name: 'Alps',
|
||||
types: ['natural_feature'],
|
||||
});
|
||||
const resolved = await anchored.resolve('the Alps', {});
|
||||
assert.equal(resolved.source, 'geocode');
|
||||
const outline = await resolved.resolveOutline();
|
||||
assert.equal(outline.naturalRegion, 'Alps');
|
||||
assert.ok(outline.ring.length >= 8);
|
||||
});
|
||||
|
||||
test('overlapping natural candidates continue through geocoding without choosing a ring', async () => {
|
||||
const { resolve, geocodes } = harness({
|
||||
lat: 45.6336,
|
||||
lng: -74.86493,
|
||||
label: 'Canadian Shield',
|
||||
name: 'Canadian Shield',
|
||||
types: ['natural_feature'],
|
||||
});
|
||||
const resolved = await resolve(
|
||||
'Canadian Shield',
|
||||
viewerAt(45.6336, -74.86493),
|
||||
);
|
||||
assert.equal(resolved?.source, 'geocode');
|
||||
assert.equal((await resolved.resolveOutline())?.naturalRegion, undefined);
|
||||
assert.deepEqual(geocodes, ['Canadian Shield']);
|
||||
});
|
||||
|
||||
test('geocoded natural outlines respect district and around intent', async () => {
|
||||
for (const extra of [
|
||||
{ entityKind: 'district' },
|
||||
{ intent: 'around_the_thing' },
|
||||
]) {
|
||||
const { resolve, geocodes } = harness({
|
||||
lat: 46.5,
|
||||
lng: 10,
|
||||
label: 'Alps',
|
||||
name: 'Alps',
|
||||
types:
|
||||
extra.entityKind === 'district' ? ['locality'] : ['natural_feature'],
|
||||
});
|
||||
const resolved = await resolve('the Alps', viewerAt(46.5, 10), extra);
|
||||
assert.ok(geocodes.includes('the Alps'));
|
||||
assert.equal((await resolved.resolveOutline())?.naturalRegion, undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test('cancellation while a bundled admin lookup is pending cannot publish an annotation', async () => {
|
||||
const controller = new AbortController();
|
||||
const { resolve, geocodes } = harness();
|
||||
const pending = resolve('the Alps', viewerAt(46.5, 10), {
|
||||
signal: controller.signal,
|
||||
});
|
||||
controller.abort();
|
||||
await assert.rejects(pending, { name: 'AbortError' });
|
||||
assert.deepEqual(geocodes, []);
|
||||
});
|
||||
|
||||
test('country asks and geocoded country scopes use the bundled outline', async () => {
|
||||
const direct = harness();
|
||||
for (const name of ['Switzerland', 'Iran', 'France', 'Japan']) {
|
||||
|
||||
+10
-10
@@ -30,6 +30,7 @@
|
||||
* at finish, deliberately and in one place — see `finishSpec` in drawMode.js.
|
||||
*/
|
||||
import * as Cesium from 'cesium';
|
||||
import { createSegmentedControl } from '../ui/segmentedControl.js';
|
||||
import { pickWorldFromScreen } from './annotationResolver.js';
|
||||
import {
|
||||
claimPointer,
|
||||
@@ -432,22 +433,21 @@ export function initDrawTool({ viewer, annotations }) {
|
||||
const shapeButtons = [
|
||||
...(modeRow?.querySelectorAll('.pp-mode-btn[data-shape]') || []),
|
||||
];
|
||||
for (const btn of shapeButtons) {
|
||||
listen(btn, 'click', () => {
|
||||
shape = normalizeShape(btn.dataset.shape);
|
||||
for (const other of shapeButtons) {
|
||||
const on = other === btn;
|
||||
other.classList.toggle('active', on);
|
||||
other.setAttribute('aria-checked', String(on));
|
||||
}
|
||||
const shapeControl = createSegmentedControl({
|
||||
buttons: shapeButtons,
|
||||
valueOf: (button) => button.dataset.shape,
|
||||
listen,
|
||||
onChange: (value) => {
|
||||
shape = normalizeShape(value);
|
||||
shapeControl.update(shape);
|
||||
if (active) {
|
||||
generation += 1;
|
||||
session = createDrawSession(shape);
|
||||
cursor = null;
|
||||
syncPreview();
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
listen(colorSelect, 'change', () => {
|
||||
color = COLORS.includes(colorSelect.value) ? colorSelect.value : 'primary';
|
||||
syncPreview();
|
||||
|
||||
@@ -44,10 +44,115 @@ test('region ring: Natural Earth names resolve without a geocoder', async () =>
|
||||
for (const name of ['Gulf of Mexico', 'the Alps']) {
|
||||
const region = await resolveRegionRingForQuery(name);
|
||||
assert.ok(region?.ring?.length >= 3, `${name} resolves to a ring`);
|
||||
assert.equal(region.source, 'natural-earth');
|
||||
assert.equal(region.error, undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test('region ring: duplicate natural names need a containing geocoded anchor', async () => {
|
||||
const { resolveRegionRingForQuery } = createAnnotationResolver({
|
||||
featureSource: featureSource(),
|
||||
});
|
||||
const miss = geocoderFor(null);
|
||||
assert.equal(
|
||||
await resolveRegionRingForQuery('Cordillera Oriental', undefined, miss),
|
||||
null,
|
||||
);
|
||||
assert.deepEqual(miss.calls, ['Cordillera Oriental']);
|
||||
|
||||
const inside = geocoderFor({
|
||||
lat: 5,
|
||||
lng: -73,
|
||||
label: 'Cordillera Oriental',
|
||||
name: 'Cordillera Oriental',
|
||||
types: ['natural_feature'],
|
||||
});
|
||||
const region = await resolveRegionRingForQuery(
|
||||
'Cordillera Oriental',
|
||||
undefined,
|
||||
inside,
|
||||
);
|
||||
assert.equal(region?.source, 'natural-earth');
|
||||
assert.equal(region.name, 'Cordillera Oriental');
|
||||
assert.ok(region.ring.length >= 8);
|
||||
assert.deepEqual(inside.calls, ['Cordillera Oriental']);
|
||||
});
|
||||
|
||||
test('region ring: outside and overlapping geocoded natural anchors stay unresolved', async () => {
|
||||
const { resolveRegionRingForQuery } = createAnnotationResolver({
|
||||
featureSource: featureSource(),
|
||||
});
|
||||
for (const [name, lat, lng] of [
|
||||
['Cordillera Oriental', 30.27, -97.74],
|
||||
['Canadian Shield', 45.6336, -74.86493],
|
||||
]) {
|
||||
const geocoder = geocoderFor({
|
||||
lat,
|
||||
lng,
|
||||
label: name,
|
||||
name,
|
||||
types: ['natural_feature'],
|
||||
});
|
||||
assert.equal(
|
||||
await resolveRegionRingForQuery(name, undefined, geocoder),
|
||||
null,
|
||||
);
|
||||
assert.deepEqual(geocoder.calls, [name]);
|
||||
}
|
||||
});
|
||||
|
||||
test('region ring: geocoded administrative scope wins over duplicate natural geography', async () => {
|
||||
const lookups = [];
|
||||
const { resolveRegionRingForQuery } = createAnnotationResolver({
|
||||
featureSource: featureSource({
|
||||
getAdministrativeAreas: async (point) => {
|
||||
lookups.push(point);
|
||||
return [];
|
||||
},
|
||||
}),
|
||||
});
|
||||
const geo = geocoderFor({
|
||||
lat: 5,
|
||||
lng: -73,
|
||||
label: 'Cordillera Oriental',
|
||||
name: 'Cordillera Oriental',
|
||||
types: ['locality'],
|
||||
});
|
||||
const resolved = await resolveRegionRingForQuery(
|
||||
'Cordillera Oriental',
|
||||
undefined,
|
||||
geo,
|
||||
);
|
||||
assert.equal(
|
||||
resolved,
|
||||
null,
|
||||
'an unavailable city boundary is not a natural-region outline',
|
||||
);
|
||||
assert.deepEqual(lookups, [{ lat: 5, lon: -73 }]);
|
||||
assert.deepEqual(geo.calls, ['Cordillera Oriental']);
|
||||
});
|
||||
|
||||
test('region ring: cancellation during bundled lookup and geocode never returns a region', async () => {
|
||||
const { resolveRegionRingForQuery } = createAnnotationResolver({
|
||||
featureSource: featureSource(),
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const pending = resolveRegionRingForQuery('the Alps', controller.signal);
|
||||
controller.abort();
|
||||
await assert.rejects(pending, { name: 'AbortError' });
|
||||
|
||||
const later = new AbortController();
|
||||
await assert.rejects(
|
||||
resolveRegionRingForQuery('Cordillera Oriental', later.signal, {
|
||||
async geocode() {
|
||||
later.abort();
|
||||
return { place: null };
|
||||
},
|
||||
}),
|
||||
{ name: 'AbortError' },
|
||||
);
|
||||
});
|
||||
|
||||
test('region ring: a slow admin-boundary lookup returns region-timeout within the budget', async () => {
|
||||
let adminSignal;
|
||||
let release;
|
||||
@@ -119,6 +224,7 @@ test('region ring: bundled states and counties resolve without a geocoder', asyn
|
||||
]) {
|
||||
const region = await resolveRegionRingForQuery(query, undefined, geocoder);
|
||||
assert.equal(region?.name, name, query);
|
||||
assert.equal(region.source, 'bundled-admin');
|
||||
assert.ok(region.ring.length >= 8, `${query} has a real ring`);
|
||||
}
|
||||
assert.deepEqual(geocoder.calls, [], 'no geocoding');
|
||||
|
||||
@@ -444,6 +444,7 @@ export function createAnnotationResolver({
|
||||
* AFTER the anchor mark is drawn and upgrade the mark in place.
|
||||
*/
|
||||
const resolveOutline = async () => {
|
||||
signal?.throwIfAborted();
|
||||
let scope = baseScope;
|
||||
const isAdmin =
|
||||
scope === 'country' ||
|
||||
@@ -469,6 +470,7 @@ export function createAnnotationResolver({
|
||||
const ne = await lookupNaturalRegionOutline(target, lat, lon).catch(
|
||||
() => null,
|
||||
);
|
||||
signal?.throwIfAborted();
|
||||
if (ne) {
|
||||
registerDynamicCredit(viewer, NATURAL_EARTH_CREDIT);
|
||||
const neCentroid = ringCentroid(ne.ring);
|
||||
@@ -2004,8 +2006,8 @@ export function createAnnotationResolver({
|
||||
/**
|
||||
* Region ring for ANALYST queries ("how many flights over Texas / the Alps") —
|
||||
* a name-only entry point that reuses this module's boundary machinery
|
||||
* without the annotation pipeline. Natural Earth pack first (offline,
|
||||
* instant; largest-area match is correct for a global name-only ask), then
|
||||
* without the annotation pipeline. Unique Natural Earth names resolve first
|
||||
* (offline); duplicate names require a geocoded containment anchor, then
|
||||
* geocode + admin boundary for states/countries/counties (Tier A disk-cached
|
||||
* Overpass, so repeat asks are instant). Returns null when the name doesn't
|
||||
* resolve to a region-like boundary — the analyst engine reports that
|
||||
@@ -2020,7 +2022,7 @@ export function createAnnotationResolver({
|
||||
* @param {AbortSignal} [signal]
|
||||
* @param {object} [placeSearch]
|
||||
* @param {{budgetMs?: number}} [options] `Infinity` waits for the lookup.
|
||||
* @returns {Promise<{name:string, ring:Array<[number,number]>}|{name:string, ring:null, error:'region-timeout'}|null>}
|
||||
* @returns {Promise<{name:string, ring:Array<[number,number]>, source:string}|{name:string, ring:null, error:'region-timeout'}|null>}
|
||||
*/
|
||||
async function resolveRegionRingForQuery(
|
||||
name,
|
||||
@@ -2037,16 +2039,24 @@ export function createAnnotationResolver({
|
||||
const q = String(name || '').trim();
|
||||
if (!q) return null;
|
||||
const ne = await findNaturalRegion(q).catch(() => null);
|
||||
if (ne?.polygons?.length) {
|
||||
signal?.throwIfAborted();
|
||||
if (ne?.candidates === 1 && ne.polygons?.length) {
|
||||
// Largest ring carries the query scope; multi-ring regions (Andes) keep
|
||||
// their main cordillera — good enough for containment counting.
|
||||
const ring = [...ne.polygons].sort((a, b) => b.length - a.length)[0];
|
||||
if (ring?.length >= 3) return { name: ne.name, ring };
|
||||
if (ring?.length >= 3)
|
||||
return { name: ne.name, ring, source: 'natural-earth' };
|
||||
}
|
||||
// Bundled states/provinces/counties by name (offline); the main part only,
|
||||
// like the Natural Earth rung above.
|
||||
const admin = await findAdminArea(q).catch(() => null);
|
||||
if (admin) return { name: admin.name, ring: [...admin.ring] };
|
||||
signal?.throwIfAborted();
|
||||
if (admin)
|
||||
return {
|
||||
name: admin.name,
|
||||
ring: [...admin.ring],
|
||||
source: 'bundled-admin',
|
||||
};
|
||||
const lookup = resolveAdminRegionRing(q, signal, placeSearch);
|
||||
if (!Number.isFinite(budgetMs)) return lookup;
|
||||
let timer;
|
||||
@@ -2067,8 +2077,26 @@ export function createAnnotationResolver({
|
||||
const geo = await geocodePlace(q, null, signal, placeSearch).catch(
|
||||
() => null,
|
||||
);
|
||||
signal?.throwIfAborted();
|
||||
if (!geo) return null;
|
||||
const scope = scopeFromTypes(geo.types);
|
||||
if (
|
||||
scope === 'auto' ||
|
||||
(scope === 'compound' && geo.types?.includes('natural_feature'))
|
||||
) {
|
||||
const natural = await lookupNaturalRegionOutline(
|
||||
q,
|
||||
geo.lat,
|
||||
geo.lon,
|
||||
).catch(() => null);
|
||||
signal?.throwIfAborted();
|
||||
if (natural)
|
||||
return {
|
||||
name: natural.name,
|
||||
ring: natural.ring,
|
||||
source: 'natural-earth',
|
||||
};
|
||||
}
|
||||
if (!['country', 'state', 'county', 'city'].includes(scope)) return null;
|
||||
if (scope === 'state' || scope === 'county' || scope === 'country') {
|
||||
const admin = await findAdminAreaAt(
|
||||
@@ -2077,12 +2105,16 @@ export function createAnnotationResolver({
|
||||
geo.lon,
|
||||
scope,
|
||||
).catch(() => null);
|
||||
if (admin) return { name: q, ring: [...admin.ring] };
|
||||
signal?.throwIfAborted();
|
||||
if (admin)
|
||||
return { name: q, ring: [...admin.ring], source: 'bundled-admin' };
|
||||
}
|
||||
const fp = await fetchAdminArea(geo.lat, geo.lon, q, scope, signal).catch(
|
||||
() => null,
|
||||
);
|
||||
if (fp?.ring?.length >= 3) return { name: q, ring: fp.ring };
|
||||
signal?.throwIfAborted();
|
||||
if (fp?.ring?.length >= 3)
|
||||
return { name: q, ring: fp.ring, source: 'remote-boundary' };
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -881,6 +881,7 @@ export function createAnalystEngine(providers) {
|
||||
followUp && !spec.scope ? lastResult.distanceOf || null : null;
|
||||
let resolvedScope = null;
|
||||
let scopeNote = rememberedScope?.coverage || 'anywhere';
|
||||
let scopeSource = rememberedScope?.source || null;
|
||||
// Human phrasing for the same scope, so every spoken count can name what it
|
||||
// measured ("8 in view", "about 30 within 250 km of Austin") instead of
|
||||
// arriving as a bare number that contradicts the panel.
|
||||
@@ -890,8 +891,16 @@ export function createAnalystEngine(providers) {
|
||||
// further when a new scope is given.
|
||||
const scope = spec.scope || { kind: followUp ? 'anywhere' : 'view' };
|
||||
if (scope.kind === 'region') {
|
||||
const region = await providers.resolveRegionRing(scope.name, signal);
|
||||
if (!isCurrent()) return cancelled();
|
||||
let region;
|
||||
try {
|
||||
region = await providers.resolveRegionRing(scope.name, signal);
|
||||
} catch (error) {
|
||||
// Cancellation belongs to the query owner, not an error's name: a
|
||||
// resource-local AbortError from a current lookup still propagates.
|
||||
if (signal?.aborted || !isCurrent()) return cancelled();
|
||||
throw error;
|
||||
}
|
||||
if (signal?.aborted || !isCurrent()) return cancelled();
|
||||
if (region?.error === 'region-timeout') {
|
||||
return {
|
||||
ok: false,
|
||||
@@ -910,6 +919,7 @@ export function createAnalystEngine(providers) {
|
||||
}
|
||||
resolvedScope = region;
|
||||
scopeNote = `region:${region.name}`;
|
||||
scopeSource = region.source || null;
|
||||
scopeLabel = `over ${region.name}`;
|
||||
scopeDetail = scopeLabel;
|
||||
} else if (scope.kind === 'radius') {
|
||||
@@ -1049,6 +1059,7 @@ export function createAnalystEngine(providers) {
|
||||
layersQueried,
|
||||
records: totals,
|
||||
scope: scopeNote,
|
||||
...(scopeSource ? { scopeSource } : {}),
|
||||
...(rememberedScope?.note ? { note: rememberedScope.note } : {}),
|
||||
...(queriedSnapshots.length
|
||||
? { feedProvenance: feedProvenanceEnvelope(queriedSnapshots) }
|
||||
@@ -1081,6 +1092,7 @@ export function createAnalystEngine(providers) {
|
||||
label: memoryOverride?.scopeLabel || result.scopeLabel,
|
||||
detail: memoryOverride?.scopeDetail || result.display.scope,
|
||||
coverage: committedCoverage.scope,
|
||||
source: committedCoverage.scopeSource || null,
|
||||
note: committedCoverage.note || null,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createAnalystEngine, applyScope, haversineKm } from './analystEngine.js';
|
||||
import { createAnnotationResolver } from '../annotations/resolver.js';
|
||||
import { FEATURE_SOURCE_METHODS } from '../sources/featureSource.js';
|
||||
|
||||
// Stub world: a square "Texland" region, flights + ships + fires around it.
|
||||
const TEXLAND = { name: 'Texland', ring: [[-100, 28], [-94, 28], [-94, 33], [-100, 33]] };
|
||||
@@ -131,6 +133,175 @@ test('analyst: count flights over a region', async () => {
|
||||
assert.ok(r.coverage.scope.includes('Texland'));
|
||||
});
|
||||
|
||||
test('analyst: region coverage identifies the source of the boundary used for counting', async () => {
|
||||
for (const source of ['natural-earth', 'bundled-admin', 'remote-boundary']) {
|
||||
const engine = createAnalystEngine({
|
||||
getRecords: () => FLIGHTS,
|
||||
resolveRegionRing: async () => ({ ...TEXLAND, source }),
|
||||
});
|
||||
const result = await engine.query({
|
||||
layers: ['flights'],
|
||||
scope: { kind: 'region', name: 'Texland' },
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.count, 3, 'the boundary excludes the Oregon flight');
|
||||
assert.equal(result.coverage.scope, 'region:Texland');
|
||||
assert.equal(result.coverage.scopeSource, source);
|
||||
}
|
||||
});
|
||||
|
||||
test('analyst: filter-only follow-ups retain the region source without resolving again', async () => {
|
||||
const lookups = [];
|
||||
const engine = createAnalystEngine({
|
||||
getRecords: () => FLIGHTS,
|
||||
resolveRegionRing: async (name) => {
|
||||
lookups.push(name);
|
||||
return { ...TEXLAND, source: 'natural-earth' };
|
||||
},
|
||||
});
|
||||
const first = await engine.query({
|
||||
layers: ['flights'],
|
||||
scope: { kind: 'region', name: 'Texland' },
|
||||
});
|
||||
const airborne = await engine.query({
|
||||
followUp: true,
|
||||
filters: [{ field: 'onGround', op: 'eq', value: false }],
|
||||
});
|
||||
const military = await engine.query({
|
||||
followUp: true,
|
||||
filters: [{ field: 'military', op: 'eq', value: true }],
|
||||
});
|
||||
|
||||
assert.equal(first.count, 3);
|
||||
assert.equal(airborne.count, 2);
|
||||
assert.deepEqual(military.items.map((item) => item.id), ['RCH01']);
|
||||
for (const result of [first, airborne, military]) {
|
||||
assert.equal(result.coverage.scope, 'region:Texland');
|
||||
assert.equal(result.scopeLabel, 'over Texland');
|
||||
assert.equal(result.coverage.scopeSource, 'natural-earth');
|
||||
}
|
||||
assert.deepEqual(lookups, ['Texland']);
|
||||
});
|
||||
|
||||
test('analyst: an explicit replacement region replaces retained provenance', async () => {
|
||||
const engine = createAnalystEngine({
|
||||
getRecords: () => FLIGHTS,
|
||||
resolveRegionRing: async (name) => name === 'Texland'
|
||||
? { ...TEXLAND, source: 'natural-earth' }
|
||||
: {
|
||||
name,
|
||||
ring: [[-98, 30.5], [-96, 30.5], [-96, 31.5], [-98, 31.5]],
|
||||
source: 'remote-boundary',
|
||||
},
|
||||
});
|
||||
await engine.query({
|
||||
layers: ['flights'],
|
||||
scope: { kind: 'region', name: 'Texland' },
|
||||
});
|
||||
const replacement = await engine.query({
|
||||
followUp: true,
|
||||
scope: { kind: 'region', name: 'Northern Texland' },
|
||||
});
|
||||
const followUp = await engine.query({ followUp: true });
|
||||
|
||||
for (const result of [replacement, followUp]) {
|
||||
assert.deepEqual(result.items.map((item) => item.id), ['RCH01']);
|
||||
assert.equal(result.coverage.scope, 'region:Northern Texland');
|
||||
assert.equal(result.coverage.scopeSource, 'remote-boundary');
|
||||
}
|
||||
});
|
||||
|
||||
test('analyst: an explicit scope without provenance clears the retained region source', async () => {
|
||||
for (const scope of [
|
||||
{ kind: 'anywhere' },
|
||||
{ kind: 'view' },
|
||||
{ kind: 'radius', km: 150 },
|
||||
{ kind: 'region', name: 'Uncredited Texland' },
|
||||
]) {
|
||||
const engine = createAnalystEngine({
|
||||
getRecords: () => FLIGHTS,
|
||||
resolveRegionRing: async (name) => ({
|
||||
...TEXLAND,
|
||||
name,
|
||||
...(name === 'Texland' ? { source: 'natural-earth' } : {}),
|
||||
}),
|
||||
getViewContext: () => ({ lat: 30.27, lon: -97.74, viewRadiusKm: 150 }),
|
||||
});
|
||||
const first = await engine.query({
|
||||
layers: ['flights'],
|
||||
scope: { kind: 'region', name: 'Texland' },
|
||||
});
|
||||
const replacement = await engine.query({ followUp: true, scope });
|
||||
const followUp = await engine.query({ followUp: true });
|
||||
|
||||
assert.equal(first.coverage.scopeSource, 'natural-earth');
|
||||
for (const result of [replacement, followUp]) {
|
||||
assert.equal(result.ok, true, scope.kind);
|
||||
assert.equal(result.count, 3, scope.kind);
|
||||
assert.equal(Object.hasOwn(result.coverage, 'scopeSource'), false, scope.kind);
|
||||
assert.notEqual(result.coverage.scope, first.coverage.scope, scope.kind);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
for (const [label, committedScope, scopeSource] of [
|
||||
['replacement boundary source', 'region:Presented Texland', 'remote-boundary'],
|
||||
['window without a boundary source', 'window:presented', null],
|
||||
]) {
|
||||
test(`analyst: pre-commit displayed cohort retains ${label} and its feed provenance`, async () => {
|
||||
const committedSnapshot = {
|
||||
id: 'flights', enabled: true, feedState: 'stale',
|
||||
source: 'displayed-cohort-feed', lastUpdate: 1_800_000_000_000,
|
||||
};
|
||||
let reads = 0;
|
||||
let lookups = 0;
|
||||
const engine = createAnalystEngine({
|
||||
getRecords: () => { reads++; return FLIGHTS; },
|
||||
getLayerSnapshot: () => ({
|
||||
id: 'flights', enabled: true, feedState: 'nominal', source: 'current-feed',
|
||||
}),
|
||||
resolveRegionRing: async () => {
|
||||
lookups++;
|
||||
return { ...TEXLAND, source: 'natural-earth' };
|
||||
},
|
||||
});
|
||||
await engine.query(
|
||||
{ layers: ['flights'], scope: { kind: 'region', name: 'Texland' } },
|
||||
{
|
||||
beforeCommit: async (result) => {
|
||||
assert.equal(result.count, 3);
|
||||
assert.equal(result.coverage.scopeSource, 'natural-earth');
|
||||
const { scopeSource: _originalSource, ...coverage } = result.coverage;
|
||||
return {
|
||||
matched: [{ key: 'flights', rows: [FLIGHTS[1]] }],
|
||||
scopeLabel: 'the presented cohort',
|
||||
scopeDetail: 'the presented cohort window',
|
||||
coverage: {
|
||||
...coverage,
|
||||
scope: committedScope,
|
||||
...(scopeSource ? { scopeSource } : {}),
|
||||
feedProvenance: { layers: [committedSnapshot] },
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
);
|
||||
const followUp = await engine.query({ followUp: true });
|
||||
assert.equal(followUp.count, 1);
|
||||
assert.deepEqual(followUp.items.map((item) => item.id), ['RCH01']);
|
||||
assert.equal(followUp.scopeLabel, 'the presented cohort');
|
||||
assert.equal(followUp.display.scope, 'the presented cohort window');
|
||||
assert.equal(followUp.coverage.scope, committedScope);
|
||||
if (scopeSource) assert.equal(followUp.coverage.scopeSource, scopeSource);
|
||||
else assert.equal(Object.hasOwn(followUp.coverage, 'scopeSource'), false);
|
||||
assert.equal(followUp.coverage.feedProvenance.overall, 'stale');
|
||||
assert.deepEqual(followUp.coverage.feedProvenance.layers, [committedSnapshot]);
|
||||
assert.equal(reads, 1, 'displayed cohort is not replaced by another feed read');
|
||||
assert.equal(lookups, 1, 'follow-up retains the committed scope without resolving again');
|
||||
});
|
||||
}
|
||||
|
||||
test('analyst: attribute filter — above 40,000 ft (~12,192 m)', async () => {
|
||||
const r = await makeEngine().query({
|
||||
layers: ['flights'], scope: { kind: 'anywhere' },
|
||||
@@ -317,6 +488,138 @@ test('analyst: region lookup receives and obeys the owning action signal', async
|
||||
assert.equal(result.cancelled, true);
|
||||
});
|
||||
|
||||
for (const [label, reason, trackAuthority] of [
|
||||
['default abort reason', undefined, false],
|
||||
['custom error reason', new Error('operator stopped'), false],
|
||||
['primitive reason and revoked authority', 'operator stopped', true],
|
||||
]) {
|
||||
test(`analyst: real region resolver cancellation preserves memory (${label})`, async () => {
|
||||
const started = Promise.withResolvers();
|
||||
const geocode = Promise.withResolvers();
|
||||
const controller = new AbortController();
|
||||
const lookups = [];
|
||||
const { resolveRegionRingForQuery } = createAnnotationResolver({
|
||||
featureSource: Object.fromEntries(FEATURE_SOURCE_METHODS.map((method) => [
|
||||
method,
|
||||
async () => { throw new Error('offline region query reached feature source'); },
|
||||
])),
|
||||
});
|
||||
const placeSearch = {
|
||||
geocode(name, { signal }) {
|
||||
lookups.push(name);
|
||||
assert.equal(signal, controller.signal);
|
||||
started.resolve();
|
||||
return geocode.promise;
|
||||
},
|
||||
};
|
||||
const engine = createAnalystEngine({
|
||||
getRecords: () => FLIGHTS,
|
||||
resolveRegionRing: (name, signal) => resolveRegionRingForQuery(
|
||||
name, signal, placeSearch, { budgetMs: Infinity },
|
||||
),
|
||||
});
|
||||
const first = await engine.query({
|
||||
layers: ['flights'], scope: { kind: 'region', name: 'Texas' },
|
||||
});
|
||||
assert.equal(first.ok, true);
|
||||
assert.equal(first.coverage.scopeSource, 'bundled-admin');
|
||||
assert.deepEqual(first.items.map((item) => item.id), ['SWA1', 'RCH01', 'GND1']);
|
||||
let commits = 0;
|
||||
const pending = engine.query(
|
||||
{ layers: ['flights'], scope: { kind: 'region', name: 'Cordillera Oriental' } },
|
||||
{
|
||||
signal: controller.signal,
|
||||
...(trackAuthority ? { isCurrent: () => !controller.signal.aborted } : {}),
|
||||
beforeCommit: async () => { commits++; },
|
||||
},
|
||||
);
|
||||
await started.promise;
|
||||
controller.abort(reason);
|
||||
// Let an actual late geocode result cross the resolver's post-await check.
|
||||
geocode.resolve({ place: {
|
||||
lat: 5, lng: -73, label: 'Cordillera Oriental',
|
||||
name: 'Cordillera Oriental', types: ['natural_feature'],
|
||||
} });
|
||||
const result = await pending;
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.code, 'CANCELLED');
|
||||
assert.equal(result.cancelled, true);
|
||||
assert.equal(commits, 0);
|
||||
const followUp = await engine.query({ followUp: true });
|
||||
assert.deepEqual(followUp.items.map((item) => item.id), first.items.map((item) => item.id));
|
||||
assert.equal(followUp.coverage.scope, first.coverage.scope);
|
||||
assert.equal(followUp.coverage.scopeSource, 'bundled-admin');
|
||||
assert.equal(followUp.scopeLabel, first.scopeLabel);
|
||||
assert.deepEqual(lookups, ['Cordillera Oriental'], 'follow-up does not resolve again');
|
||||
});
|
||||
}
|
||||
|
||||
test('analyst: a rejected superseded region lookup returns cancellation and retains its prior cohort', async () => {
|
||||
const lookup = Promise.withResolvers();
|
||||
let current = true;
|
||||
const engine = createAnalystEngine({
|
||||
getRecords: () => FLIGHTS,
|
||||
resolveRegionRing: (name) => name === 'Texland'
|
||||
? { ...TEXLAND, source: 'natural-earth' }
|
||||
: lookup.promise,
|
||||
});
|
||||
const first = await engine.query({ layers: ['flights'], scope: { kind: 'region', name: 'Texland' } });
|
||||
const pending = engine.query(
|
||||
{ layers: ['flights'], scope: { kind: 'region', name: 'Replacement' } },
|
||||
{ isCurrent: () => current },
|
||||
);
|
||||
current = false;
|
||||
lookup.reject(new Error('late lookup failed'));
|
||||
assert.equal((await pending).code, 'CANCELLED');
|
||||
const followUp = await engine.query({ followUp: true });
|
||||
assert.deepEqual(followUp.items.map((item) => item.id), first.items.map((item) => item.id));
|
||||
assert.equal(followUp.coverage.scopeSource, 'natural-earth');
|
||||
assert.equal(followUp.coverage.scope, first.coverage.scope);
|
||||
});
|
||||
|
||||
test('analyst: an aborted owner cannot commit a region provider that returns a late success', async () => {
|
||||
const controller = new AbortController();
|
||||
const lookup = Promise.withResolvers();
|
||||
const engine = createAnalystEngine({
|
||||
getRecords: () => FLIGHTS,
|
||||
resolveRegionRing: () => lookup.promise,
|
||||
});
|
||||
const pending = engine.query(
|
||||
{ layers: ['flights'], scope: { kind: 'region', name: 'Texland' } },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
controller.abort();
|
||||
lookup.resolve({ ...TEXLAND, source: 'natural-earth' });
|
||||
assert.equal((await pending).code, 'CANCELLED');
|
||||
assert.equal(engine.hasMemory(), false);
|
||||
});
|
||||
|
||||
for (const error of [new Error('live lookup failed'), new DOMException('resource stopped', 'AbortError')]) {
|
||||
test(`analyst: live region ${error.name} propagates without changing memory`, async () => {
|
||||
const controller = new AbortController();
|
||||
const engine = createAnalystEngine({
|
||||
getRecords: () => FLIGHTS,
|
||||
resolveRegionRing: async (name) => {
|
||||
if (name === 'Texland') return { ...TEXLAND, source: 'natural-earth' };
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
const first = await engine.query({ layers: ['flights'], scope: { kind: 'region', name: 'Texland' } });
|
||||
await assert.rejects(
|
||||
engine.query(
|
||||
{ layers: ['flights'], scope: { kind: 'region', name: 'Replacement' } },
|
||||
{ signal: controller.signal },
|
||||
),
|
||||
(thrown) => thrown === error,
|
||||
);
|
||||
assert.equal(controller.signal.aborted, false);
|
||||
const followUp = await engine.query({ followUp: true });
|
||||
assert.deepEqual(followUp.items.map((item) => item.id), first.items.map((item) => item.id));
|
||||
assert.equal(followUp.coverage.scope, first.coverage.scope);
|
||||
assert.equal(followUp.coverage.scopeSource, 'natural-earth');
|
||||
});
|
||||
}
|
||||
|
||||
test('analyst: route fields queryable from cached enrichment only', async () => {
|
||||
const r = await makeEngine().query({
|
||||
layers: ['flights'], scope: { kind: 'anywhere' },
|
||||
|
||||
@@ -223,3 +223,40 @@ test('an abandoned consumer expires while a renewed consumer keeps the session',
|
||||
assert.equal(entry.controller.signal.aborted, false);
|
||||
await manager.shutdown();
|
||||
});
|
||||
|
||||
test('client lease ownership covers refresh, segments and release, with a bounded client budget', async (t) => {
|
||||
const manager = createHlsPuller({
|
||||
limits: { ...HLS_LIMITS, leasesPerClient: 1, pollMs: 100000 },
|
||||
fetchImpl: async (url) =>
|
||||
new Response(url.endsWith('.m3u8') ? playlist : 'abc'),
|
||||
});
|
||||
t.after(() => manager.shutdown());
|
||||
const entry = await manager.ensure('a', base, 'lease-a', 'peer-a');
|
||||
await manager.waitReady(entry);
|
||||
await assert.rejects(
|
||||
manager.ensure('a', base, 'lease-a', 'peer-b'),
|
||||
/another client/,
|
||||
);
|
||||
assert.equal(
|
||||
manager.getSegment('a', entry.token, 0, 'lease-a', 'peer-b'),
|
||||
null,
|
||||
);
|
||||
await assert.rejects(
|
||||
manager.buildPlaylist(entry, 'a', 'lease-a', 'peer-b'),
|
||||
/another client/,
|
||||
);
|
||||
manager.release('a', 'lease-a', 'peer-b');
|
||||
assert.equal(entry.leases.size, 1);
|
||||
await assert.rejects(
|
||||
manager.ensure('b', base, 'lease-b', 'peer-a'),
|
||||
/client lease capacity/,
|
||||
);
|
||||
assert.equal(
|
||||
manager.stats().sessions,
|
||||
1,
|
||||
'failed admission reserves no session',
|
||||
);
|
||||
await manager.ensure('b', base, 'lease-b', 'peer-b');
|
||||
manager.release('a', 'lease-a', 'peer-a');
|
||||
assert.equal(entry.stopping, true);
|
||||
});
|
||||
@@ -268,3 +268,46 @@ test('/frame does not follow a still camera off its origin', async (t) => {
|
||||
[STILL.url],
|
||||
);
|
||||
});
|
||||
|
||||
test('cross-site media creation, segment reads and release fail before any catalog or media fetch', async (t) => {
|
||||
let handler;
|
||||
const originalFetch = globalThis.fetch;
|
||||
let calls = 0;
|
||||
globalThis.fetch = async () => {
|
||||
calls++;
|
||||
throw new Error('must not fetch');
|
||||
};
|
||||
t.after(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
cctvProxy().configureServer({
|
||||
middlewares: {
|
||||
use: (_path, fn) => {
|
||||
handler = fn;
|
||||
},
|
||||
},
|
||||
});
|
||||
for (const headers of [
|
||||
{ origin: 'https://foreign.example' },
|
||||
{ origin: 'null' },
|
||||
{ 'sec-fetch-site': 'cross-site' },
|
||||
{ 'sec-fetch-site': 'same-site' },
|
||||
]) {
|
||||
for (const [method, url] of [
|
||||
['GET', '/media/camera?lease=test'],
|
||||
['DELETE', '/media/camera?lease=test'],
|
||||
['GET', '/media/camera/seg_0.ts?lease=test'],
|
||||
]) {
|
||||
const { res } = recordingResponse();
|
||||
res.setHeader = (name, value) => {
|
||||
res.headers[name] = value;
|
||||
};
|
||||
await handler(
|
||||
{ method, url, headers: { host: 'localhost:4173', ...headers } },
|
||||
res,
|
||||
);
|
||||
assert.equal(res.statusCode, 403);
|
||||
}
|
||||
}
|
||||
assert.equal(calls, 0);
|
||||
});
|
||||
@@ -189,7 +189,7 @@ const loadIndex = createRetryableLoader(async () => {
|
||||
return index;
|
||||
});
|
||||
|
||||
function toResult(entry) {
|
||||
function toResult(entry, candidates = 1) {
|
||||
return {
|
||||
name: entry.name,
|
||||
featurecla: entry.featurecla,
|
||||
@@ -197,6 +197,7 @@ function toResult(entry) {
|
||||
polygons: entry.polygons,
|
||||
bboxDiagonalKm: entry.bboxDiagonalKm,
|
||||
areaKm2: entry.areaKm2,
|
||||
candidates,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -209,7 +210,9 @@ function toResult(entry) {
|
||||
* @param {string} query e.g. "the Alps", "Rockies", "Gulf of Mexico"
|
||||
* @returns {Promise<{name:string, featurecla:string, kind:'natural'|'marine',
|
||||
* polygons:Array<Array<[number,number]>>, bboxDiagonalKm:number,
|
||||
* areaKm2:number}|null>} largest-area match, or null when nothing matches.
|
||||
* areaKm2:number, candidates:number}|null>} largest-area match plus the
|
||||
* number of same-name entries, or null when nothing matches. Uniqueness in
|
||||
* this pack alone does not establish the user's intended place.
|
||||
*/
|
||||
export async function findNaturalRegion(query) {
|
||||
const norm = normalizeName(query);
|
||||
@@ -223,7 +226,7 @@ export async function findNaturalRegion(query) {
|
||||
for (const key of candidates) {
|
||||
if (!key) continue;
|
||||
const list = index.get(key);
|
||||
if (list && list.length) return toResult(list[0]);
|
||||
if (list && list.length) return toResult(list[0], list.length);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -270,9 +273,10 @@ export function pointInRing(ring, lat, lon) {
|
||||
/**
|
||||
* Resolve a natural-region OUTLINE ring for the annotation resolver's first
|
||||
* rung. Stricter than `findNaturalRegion`: walks ALL entries sharing the
|
||||
* matched name (duplicate names included — the US and Spanish "Sierra
|
||||
* Nevada" both exist upstream) and returns the single ring that CONTAINS the
|
||||
* geocoded anchor. Containment is simultaneously the disambiguator and the
|
||||
* matched name (duplicate names included), trying exact names, aliases and
|
||||
* broader suffix variants in that order. Returns a ring only when exactly one
|
||||
* entry under the matched name CONTAINS the anchor. Containment is the
|
||||
* disambiguator and the
|
||||
* wrong-place guard: when no ring contains the anchor this returns null and
|
||||
* the resolver's normal ladder continues unchanged.
|
||||
*
|
||||
@@ -291,23 +295,26 @@ export async function lookupNaturalRegionOutline(query, lat, lon) {
|
||||
ALIASES[norm],
|
||||
...suffixVariants(ALIASES[norm] || norm),
|
||||
];
|
||||
const seen = new Set();
|
||||
for (const key of candidates) {
|
||||
if (!key || seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
for (const key of new Set(candidates.filter(Boolean))) {
|
||||
const containing = [];
|
||||
for (const entry of index.get(key) || []) {
|
||||
for (const ring of entry.polygons) {
|
||||
if (pointInRing(ring, lat, lon)) {
|
||||
return {
|
||||
name: entry.name,
|
||||
kind: entry.kind,
|
||||
featurecla: entry.featurecla,
|
||||
ring,
|
||||
areaKm2: entry.areaKm2,
|
||||
};
|
||||
}
|
||||
}
|
||||
const ring = entry.polygons.find((candidate) =>
|
||||
pointInRing(candidate, lat, lon),
|
||||
);
|
||||
if (ring) containing.push({ entry, ring });
|
||||
}
|
||||
// Specific names win over aliases and broader rewrites. Ambiguity belongs
|
||||
// to this name, not the union of every lower-priority candidate name.
|
||||
if (containing.length > 1) return null;
|
||||
if (containing.length === 0) continue;
|
||||
const [{ entry, ring }] = containing;
|
||||
return {
|
||||
name: entry.name,
|
||||
kind: entry.kind,
|
||||
featurecla: entry.featurecla,
|
||||
ring,
|
||||
areaKm2: entry.areaKm2,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -33,7 +33,10 @@ test('marquee ranges resolve with sane areas (owner acceptance: Alps + Rockies)'
|
||||
});
|
||||
|
||||
test('aliases and articles: "the Alps", "Rockies", "Sahara Desert", "Himalaya"', async () => {
|
||||
assert.equal((await findNaturalRegion('the Alps'))?.name, 'Alps');
|
||||
const alps = await findNaturalRegion('the Alps');
|
||||
assert.equal(alps?.name, 'Alps');
|
||||
assert.equal(alps?.candidates, 1);
|
||||
assert.equal((await findNaturalRegion('Alps mountains'))?.name, 'Alps');
|
||||
assert.equal((await findNaturalRegion('Rockies'))?.name, 'Rocky Mountains');
|
||||
assert.equal((await findNaturalRegion('the Rockies'))?.name, 'Rocky Mountains');
|
||||
assert.equal((await findNaturalRegion('Sahara Desert'))?.name, 'Sahara');
|
||||
@@ -41,6 +44,11 @@ test('aliases and articles: "the Alps", "Rockies", "Sahara Desert", "Himalaya"',
|
||||
assert.equal((await findNaturalRegion(' THE ALPS '))?.name, 'Alps');
|
||||
});
|
||||
|
||||
test('duplicate natural-region names expose ambiguity instead of claiming uniqueness', async () => {
|
||||
assert.ok((await findNaturalRegion('Cordillera Oriental'))?.candidates > 1);
|
||||
assert.ok((await findNaturalRegion('Canadian Shield'))?.candidates > 1);
|
||||
});
|
||||
|
||||
test('major deserts/ranges resolve with sane areas', async () => {
|
||||
const sahara = await findNaturalRegion('Sahara');
|
||||
assert.ok(sahara && sahara.areaKm2 > 8_000_000 && sahara.areaKm2 < 12_000_000,
|
||||
@@ -122,24 +130,55 @@ test('listRegions() enumerates both kinds for diagnostics', async () => {
|
||||
});
|
||||
|
||||
// ── lookupNaturalRegionOutline (resolver first-rung contract) ──────
|
||||
test('outline lookup: Alps ring via containment anchor', async () => {
|
||||
test('outline lookup: containing exact names win over broader suffix variants', async () => {
|
||||
const r = await lookupNaturalRegionOutline('the Alps', 46.5, 10.0);
|
||||
assert.ok(r, 'Alps must resolve with an inside anchor');
|
||||
assert.equal(r.name, 'Alps');
|
||||
assert.ok(r.ring.length >= 8, 'range-scale ring');
|
||||
assert.ok(r.areaKm2 > 100000 && r.areaKm2 < 400000, `sane Alps area, got ${r.areaKm2}`);
|
||||
const range = await lookupNaturalRegionOutline('Alaska Range', 63.14, -148.71);
|
||||
assert.equal(range?.name, 'Alaska Range');
|
||||
assert.ok(pointInRing(range.ring, 63.14, -148.71));
|
||||
assert.equal((await lookupNaturalRegionOutline('Alaska', 63.14, -148.71))?.name, 'Alaska');
|
||||
});
|
||||
|
||||
test('outline lookup: duplicate names disambiguate by anchor containment', async () => {
|
||||
const us = await lookupNaturalRegionOutline('Sierra Nevada', 37.2, -119.0);
|
||||
assert.ok(us, 'US anchor must match a Sierra Nevada');
|
||||
assert.ok(pointInRing(us.ring, 37.2, -119.0), 'returned ring contains the US anchor');
|
||||
const colombia = await lookupNaturalRegionOutline('Cordillera Oriental', 5, -73);
|
||||
assert.ok(colombia, 'a single containing candidate resolves a duplicate name');
|
||||
assert.ok(pointInRing(colombia.ring, 5, -73));
|
||||
});
|
||||
|
||||
test('outline lookup: overlapping duplicate candidates remain unresolved', async () => {
|
||||
assert.equal(await lookupNaturalRegionOutline('Canadian Shield', 45.6336, -74.86493), null);
|
||||
});
|
||||
|
||||
test('outline lookup: anchor outside every ring → null (wrong-place guard)', async () => {
|
||||
assert.equal(await lookupNaturalRegionOutline('the Alps', 30.26, -97.77), null);
|
||||
});
|
||||
|
||||
test('outline lookup: a unique physical-region name does not override an outside contemporary place', async () => {
|
||||
for (const [name, lat, lon] of [
|
||||
['Gold Coast', -28, 153.4],
|
||||
['Santa Cruz', 37, -122],
|
||||
['Isabela', 17, 122],
|
||||
['India', 28, 95], // Northeast India lies outside the physical peninsula.
|
||||
['Korea', 33.5, 126.5], // Jeju lies outside the physical peninsula.
|
||||
]) {
|
||||
assert.equal((await findNaturalRegion(name))?.candidates, 1, `${name} has a unique physical namesake`);
|
||||
assert.equal(await lookupNaturalRegionOutline(name, lat, lon), null, `${name} requires containment`);
|
||||
}
|
||||
});
|
||||
|
||||
test('outline lookup: aliases retain containment checks', async () => {
|
||||
const alps = await lookupNaturalRegionOutline('Alps mountains', 46.5, 10);
|
||||
assert.equal(alps?.name, 'Alps');
|
||||
assert.ok(pointInRing(alps.ring, 46.5, 10));
|
||||
assert.equal(await lookupNaturalRegionOutline('Alps mountains', -28, 153.4), null);
|
||||
});
|
||||
|
||||
test('outline lookup: non-region names never match', async () => {
|
||||
assert.equal(await lookupNaturalRegionOutline('Zilker Park', 30.26, -97.77), null);
|
||||
assert.equal(await lookupNaturalRegionOutline('Texas', 31.0, -99.0), null);
|
||||
|
||||
@@ -29,5 +29,5 @@ test('the first-run checkbox keeps its native visible label', () => {
|
||||
|
||||
test('generated style sliders use the visible parameter label as their name', () => {
|
||||
assert.match(parameters, /label\.textContent\s*=\s*t\(metadata\.label\);/);
|
||||
assert.match(parameters, /slider\.setAttribute\(['"]aria-label['"],\s*t\(metadata\.label\)\)/);
|
||||
assert.match(parameters, /ariaLabel:\s*t\(metadata\.label\)/);
|
||||
});
|
||||
@@ -945,7 +945,7 @@
|
||||
"Image by {creator}": "Imagen de {creator}",
|
||||
"zoom in to see KartaView coverage": "acércate para ver la cobertura de KartaView",
|
||||
"no KartaView photos in view": "no hay fotos de KartaView a la vista",
|
||||
"{names}: by upload date": "{names}: por fecha de subida",
|
||||
"(by upload date)": "(por fecha de subida)",
|
||||
"{n} streets in view · click a line for its nearest photo": {
|
||||
"one": "{n} calle a la vista · haz clic en una línea para su foto más cercana",
|
||||
"many": "{n} calles a la vista · haz clic en una línea para su foto más cercana",
|
||||
|
||||
@@ -954,7 +954,7 @@
|
||||
"Image by {creator}": "Автор снимка: {creator}",
|
||||
"zoom in to see KartaView coverage": "приблизьтесь, чтобы увидеть покрытие KartaView",
|
||||
"no KartaView photos in view": "фотографий KartaView в зоне видимости нет",
|
||||
"{names}: by upload date": "{names}: по дате загрузки",
|
||||
"(by upload date)": "(по дате загрузки)",
|
||||
"{n} streets in view · click a line for its nearest photo": {
|
||||
"one": "{n} улица в кадре · щёлкните линию, чтобы открыть ближайшую фотографию",
|
||||
"few": "{n} улицы в кадре · щёлкните линию, чтобы открыть ближайшую фотографию",
|
||||
|
||||
@@ -141,7 +141,7 @@ export const FILTERED_OUT_MESSAGE = msg(
|
||||
'None of KartaView’s photos here pass the imagery filter',
|
||||
);
|
||||
/** Under the SINCE slider while KartaView is lit. */
|
||||
export const SINCE_NOTE = 'KartaView: by upload date';
|
||||
export const SINCE_NOTE = '(by upload date)';
|
||||
|
||||
/** The UTC calendar day of `ms`, as KartaView's startDate reads it. */
|
||||
function utcDay(ms) {
|
||||
|
||||
@@ -23,7 +23,7 @@ export function filtersOf(provider) {
|
||||
* @property {{kind: 'view-cone', color: string}} [marker] Default: GEV's pin.
|
||||
* @property {Array<'pano'|'since'>} [filters] Filters it honours (default both).
|
||||
* @property {{noun: string, action: string}} [coverageLabel] How the panel counts its lines and says what a click does (default: sequences, "click a line for its photos").
|
||||
* @property {'capture'|'upload'} [sinceBasis] What SINCE compares (default 'capture'); 'upload' puts a note under the slider while the provider is lit.
|
||||
* @property {'capture'|'upload'} [sinceBasis] What SINCE compares (default 'capture'); 'upload' notes "(by upload date)" beside the SINCE value while the provider is lit.
|
||||
* @property {(context: ProviderContext) => ProviderInstance} create
|
||||
*
|
||||
* @typedef {object} ProviderContext Handed to `create()` once by the core.
|
||||
|
||||
@@ -73,16 +73,14 @@ export function filterControls(providers) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The note under SINCE when a lit provider dates its imagery by upload,
|
||||
* e.g. "KartaView: by upload date", or null.
|
||||
* The note beside the SINCE value when a lit provider dates its imagery by
|
||||
* upload, "(by upload date)", or null. One source is lit at a time, so the
|
||||
* note needs no provider name.
|
||||
* @param {Array<ProviderSnapshot>} providers
|
||||
*/
|
||||
export function sinceNote(providers) {
|
||||
const names = providers
|
||||
.filter((p) => p.on && p.sinceBasis === 'upload')
|
||||
.map((p) => p.name);
|
||||
return names.length
|
||||
? t('{names}: by upload date', { names: names.join(', ') })
|
||||
return providers.some((p) => p.on && p.sinceBasis === 'upload')
|
||||
? t('(by upload date)')
|
||||
: null;
|
||||
}
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ test('the filter controls show only for a lit provider that honours them', () =>
|
||||
);
|
||||
});
|
||||
|
||||
test('a lit provider that dates by upload puts a note under SINCE', () => {
|
||||
test('a lit provider that dates by upload notes it beside the SINCE value', () => {
|
||||
const kartaView = provider({
|
||||
id: 'kartaview',
|
||||
name: 'KartaView',
|
||||
@@ -75,7 +75,7 @@ test('a lit provider that dates by upload puts a note under SINCE', () => {
|
||||
});
|
||||
const note = (providers) => composeUIState({ ...base, providers }).sinceNote;
|
||||
assert.equal(note([provider()]), null, 'Mapillary: capture date');
|
||||
assert.equal(note([kartaView]), 'KartaView: by upload date');
|
||||
assert.equal(note([kartaView]), '(by upload date)');
|
||||
assert.equal(note([{ ...kartaView, on: false }]), null, 'only while lit');
|
||||
});
|
||||
|
||||
|
||||
@@ -152,6 +152,124 @@ test('regionFramingPlan: E-W elongated region looks along the east axis', () =>
|
||||
assert.equal(plan.headingDeg, 90);
|
||||
});
|
||||
|
||||
// Compatibility guard for inherited geocode authority, not a newly fixed bug.
|
||||
test('search retains main geocode authority for Gold Coast, Australia', async () => {
|
||||
const viewer = stubViewer();
|
||||
const result = await runSearch(viewer, {}, {
|
||||
query: 'Gold Coast',
|
||||
result: {
|
||||
formatted_address: 'Gold Coast, Queensland, Australia',
|
||||
types: ['locality', 'political'],
|
||||
geometry: {
|
||||
location: { lat: -28, lng: 153.4 },
|
||||
viewport: boxOf(-28.2, 153.2, -27.8, 153.6),
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal(result.label, 'Gold Coast, Queensland, Australia');
|
||||
assert.equal(result.navigationMode, 'city-overview');
|
||||
assert.equal(viewer.flights.length, 1);
|
||||
const flown = flownRectangleDegrees(viewer);
|
||||
assert.ok(Math.abs((flown.south + flown.north) / 2 + 28) < 0.000001);
|
||||
assert.ok(Math.abs((flown.west + flown.east) / 2 - 153.4) < 0.000001);
|
||||
});
|
||||
|
||||
function alpsViewer() {
|
||||
const viewer = stubViewer();
|
||||
Object.assign(viewer.camera.positionCartographic, {
|
||||
latitude: Cesium.Math.toRadians(46.5),
|
||||
longitude: Cesium.Math.toRadians(10),
|
||||
height: 248_000,
|
||||
});
|
||||
return viewer;
|
||||
}
|
||||
|
||||
test('a successful nearby recovery resolves after a geocoder miss', async () => {
|
||||
const viewer = alpsViewer();
|
||||
const result = await searchAndFlyTo(viewer, 'the Alps', {
|
||||
placeSearch: { async geocode() { return { place: null, answered: true }; } },
|
||||
recoverNearView: async () => ({
|
||||
lat: 46.6,
|
||||
lon: 10.1,
|
||||
label: 'The Alps local landmark',
|
||||
types: ['locality', 'political'],
|
||||
viewport: { low: { latitude: 46.5, longitude: 10 }, high: { latitude: 46.7, longitude: 10.2 } },
|
||||
}),
|
||||
});
|
||||
assert.equal(result.label, 'The Alps local landmark');
|
||||
assert.equal(result.navigationMode, 'city-overview');
|
||||
assert.equal(viewer.flights.length, 1);
|
||||
});
|
||||
|
||||
test('a geocoder miss returns null even over a namesake region', async () => {
|
||||
for (const answered of [true, false]) {
|
||||
const viewer = alpsViewer();
|
||||
const result = await searchAndFlyTo(viewer, 'the Alps', {
|
||||
placeSearch: { async geocode() { return { place: null, answered }; } },
|
||||
recoverNearView: async () => null,
|
||||
});
|
||||
assert.equal(result, null);
|
||||
assert.equal(viewer.flights.length, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test('a geocoder rejection propagates without nearby recovery or a flight', async () => {
|
||||
const viewer = alpsViewer();
|
||||
const failure = new Error('geocoder failed');
|
||||
let recoveries = 0;
|
||||
await assert.rejects(searchAndFlyTo(viewer, 'the Alps', {
|
||||
placeSearch: { async geocode() { throw failure; } },
|
||||
recoverNearView: async () => { recoveries += 1; return null; },
|
||||
}), (error) => error === failure);
|
||||
assert.equal(recoveries, 0);
|
||||
assert.equal(viewer.flights.length, 0);
|
||||
});
|
||||
|
||||
test('search cancellation before or during geocoding cannot issue a flight', async () => {
|
||||
for (const preAborted of [true, false]) {
|
||||
const viewer = alpsViewer();
|
||||
const controller = new AbortController();
|
||||
if (preAborted) controller.abort();
|
||||
let geocodes = 0;
|
||||
let recoveries = 0;
|
||||
await assert.rejects(searchAndFlyTo(viewer, 'the Alps', {
|
||||
signal: controller.signal,
|
||||
placeSearch: {
|
||||
async geocode(_query, { signal }) {
|
||||
geocodes += 1;
|
||||
assert.equal(signal, controller.signal);
|
||||
controller.abort();
|
||||
return { place: null, answered: true };
|
||||
},
|
||||
},
|
||||
recoverNearView: async () => { recoveries += 1; return null; },
|
||||
}), { name: 'AbortError' });
|
||||
assert.equal(geocodes, preAborted ? 0 : 1);
|
||||
assert.equal(recoveries, 0);
|
||||
assert.equal(viewer.flights.length, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test('cancellation during nearby recovery cannot issue a flight', async () => {
|
||||
const viewer = alpsViewer();
|
||||
const controller = new AbortController();
|
||||
let authorityChecks = 0;
|
||||
await assert.rejects(searchAndFlyTo(viewer, 'the Alps', {
|
||||
signal: controller.signal,
|
||||
placeSearch: { async geocode() { return { place: null, answered: true }; } },
|
||||
recoverNearView: async () => {
|
||||
controller.abort();
|
||||
return {
|
||||
lat: 46.6, lon: 10.1, label: 'Alpine landmark',
|
||||
types: ['locality'],
|
||||
};
|
||||
},
|
||||
beforeFly: () => { authorityChecks += 1; return true; },
|
||||
}), { name: 'AbortError' });
|
||||
assert.equal(authorityChecks, 0);
|
||||
assert.equal(viewer.flights.length, 0);
|
||||
});
|
||||
|
||||
test('regionFramingPlan: park- and lake-scale viewports keep full framing', () => {
|
||||
// Zilker Park (~3 km).
|
||||
assert.equal(regionFramingPlan({
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { syncSegmentedControl } from './segmentedControl.js';
|
||||
|
||||
/** Own the shared aircraft display preference and its controls. */
|
||||
export class AircraftDisplay {
|
||||
constructor({ elements, readDataManager, layout }) {
|
||||
@@ -28,12 +30,9 @@ export class AircraftDisplay {
|
||||
this._models3dMode = options.models3dMode === 'all' ? 'all' : 'proximity';
|
||||
this._syncModels3dButtonState();
|
||||
this._models3dModeRow?.classList.toggle('visible', this._models3dEnabled);
|
||||
for (const button of this._models3dModeBtns || []) {
|
||||
if (!button) continue;
|
||||
const active = button.dataset.mode === this._models3dMode;
|
||||
button.classList.toggle('active', active);
|
||||
button.setAttribute('aria-checked', String(active));
|
||||
}
|
||||
syncSegmentedControl(this._models3dModeBtns, this._models3dMode, {
|
||||
valueOf: (button) => button.dataset.mode,
|
||||
});
|
||||
this._layoutRightPanels();
|
||||
}
|
||||
|
||||
@@ -59,12 +58,9 @@ export class AircraftDisplay {
|
||||
const normalized = mode === 'all' ? 'all' : 'proximity';
|
||||
this._models3dMode = normalized;
|
||||
this._setModels3dParams({ models3dMode: normalized });
|
||||
for (const button of this._models3dModeBtns) {
|
||||
if (!button) continue;
|
||||
const active = button.dataset.mode === normalized;
|
||||
button.classList.toggle('active', active);
|
||||
button.setAttribute('aria-checked', String(active));
|
||||
}
|
||||
syncSegmentedControl(this._models3dModeBtns, normalized, {
|
||||
valueOf: (button) => button.dataset.mode,
|
||||
});
|
||||
this._syncModels3dButtonState();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { createSegmentedControl } from './segmentedControl.js';
|
||||
|
||||
/**
|
||||
* Bind Display controls to application actions. Inputs remain native DOM controls;
|
||||
* the caller owns settings and effect state. Destroy before replacing the view.
|
||||
@@ -44,12 +46,25 @@ export function bindDisplayControls({ elements, actions }) {
|
||||
listen(elements[name], 'input', action, integer);
|
||||
for (const el of elements.styleButtons || [])
|
||||
listen(el, 'click', 'setStyle', (el) => el.dataset.style);
|
||||
for (const el of elements.allocationButtons || [])
|
||||
listen(el, 'click', 'setAllocation', (el) => el.dataset.allocation);
|
||||
for (const el of elements.modelModeButtons || [])
|
||||
listen(el, 'click', 'setModelsMode', (el) =>
|
||||
el.dataset.mode === 'all' ? 'all' : 'proximity',
|
||||
);
|
||||
for (const [buttons, valueOf, action] of [
|
||||
[
|
||||
elements.allocationButtons || [],
|
||||
(el) => el.dataset.allocation,
|
||||
'setAllocation',
|
||||
],
|
||||
[
|
||||
elements.modelModeButtons || [],
|
||||
(el) => (el.dataset.mode === 'all' ? 'all' : 'proximity'),
|
||||
'setModelsMode',
|
||||
],
|
||||
]) {
|
||||
const control = createSegmentedControl({
|
||||
buttons,
|
||||
valueOf,
|
||||
onChange: (value) => actions[action](value),
|
||||
});
|
||||
removers.push(() => control.destroy());
|
||||
}
|
||||
for (const el of elements.fadeSliders || []) listen(el, 'input', 'setFade');
|
||||
return {
|
||||
destroy() {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { t } from '../i18n/index.js';
|
||||
import { syncChipGroup } from './chipGroup.js';
|
||||
import { syncRowList } from './rowList.js';
|
||||
import { formatScaleLabel, unitLabel } from './unitLabels.js';
|
||||
import { createSlider } from './slider.js';
|
||||
|
||||
const set = (node, key, value) => {
|
||||
if (node[key] !== value) node[key] = value;
|
||||
@@ -90,12 +91,11 @@ export function createRailCardBlocks({ container, cardId, onParams }) {
|
||||
'rail-card-setting-range-label',
|
||||
block.rangeRow,
|
||||
);
|
||||
block.rangeInput = make(
|
||||
'input',
|
||||
'rail-card-setting-range',
|
||||
block.rangeRow,
|
||||
);
|
||||
block.rangeInput.type = 'range';
|
||||
block.rangeInput = createSlider({
|
||||
documentRef: document,
|
||||
className: 'rail-card-setting-range',
|
||||
});
|
||||
block.rangeRow.appendChild(block.rangeInput);
|
||||
block.rangeValue = make(
|
||||
'span',
|
||||
'rail-card-setting-range-value',
|
||||
|
||||
+10
-8
@@ -1,4 +1,5 @@
|
||||
import { formatUtcDayMonth, t } from '../i18n/index.js';
|
||||
import { createSlider } from './slider.js';
|
||||
const set = (node, key, value) => {
|
||||
if (node[key] !== value) node[key] = value;
|
||||
};
|
||||
@@ -43,14 +44,15 @@ export function createRailTimeline({
|
||||
return node;
|
||||
};
|
||||
const previous = makeButton('‹', t('Earlier observation'), row);
|
||||
const slider = document.createElement('input');
|
||||
slider.type = 'range';
|
||||
slider.className = `gev-quantitative-slider${sliderClassName ? ` ${sliderClassName}` : ''}`;
|
||||
slider.min = '0';
|
||||
slider.max = '0';
|
||||
slider.step = '1';
|
||||
slider.value = '0';
|
||||
slider.setAttribute('aria-label', t('Observed history'));
|
||||
const slider = createSlider({
|
||||
documentRef: document,
|
||||
className: sliderClassName,
|
||||
min: '0',
|
||||
max: '0',
|
||||
step: '1',
|
||||
value: '0',
|
||||
ariaLabel: t('Observed history'),
|
||||
});
|
||||
row.appendChild(slider);
|
||||
const next = makeButton('›', t('Later observation'), row);
|
||||
const controls = document.createElement('div');
|
||||
|
||||
+20
-33
@@ -12,6 +12,8 @@ import { formatNumber, msg, t, tn, translateKnown } from '../i18n/index.js';
|
||||
import { createRailCardBlocks } from './railCardBlocks.js';
|
||||
import { createRailCards } from './railCards.js';
|
||||
import { createImagerySplit } from './imagerySplit.js';
|
||||
import { createSlider } from './slider.js';
|
||||
import { createSegmentedControl } from './segmentedControl.js';
|
||||
import {
|
||||
PRODUCTS,
|
||||
hhmm,
|
||||
@@ -286,13 +288,13 @@ export function createRecentImageryPanel({
|
||||
const selection = el('section', 'ri-block ri-selection', root);
|
||||
selection.id = 'ri-selection';
|
||||
selection.setAttribute('aria-label', t('On the map'));
|
||||
const modeGroup = el('div', 'ri-mode', selection);
|
||||
const modeGroup = el('div', 'gev-segmented ri-mode', selection);
|
||||
modeGroup.id = 'ri-mode';
|
||||
modeGroup.setAttribute('role', 'radiogroup');
|
||||
modeGroup.setAttribute('aria-label', t('Mode'));
|
||||
const modeButtons = new Map(
|
||||
MODE_LABELS.map(([mode, label]) => {
|
||||
const node = el('button', 'data-toggle-chip ri-mode-btn', modeGroup);
|
||||
const node = el('button', 'gev-segment ri-mode-btn', modeGroup);
|
||||
node.type = 'button';
|
||||
node.textContent = t(label);
|
||||
node.dataset.mode = mode;
|
||||
@@ -300,6 +302,15 @@ export function createRecentImageryPanel({
|
||||
return [mode, node];
|
||||
}),
|
||||
);
|
||||
const modeControl = createSegmentedControl({
|
||||
buttons: modeButtons.values(),
|
||||
valueOf: (button) => button.dataset.mode,
|
||||
onChange: (mode) => layer.setMode(mode),
|
||||
keyboard: 'arrows',
|
||||
keyTarget: modeGroup,
|
||||
readValue: () => snapshot.mode,
|
||||
listen,
|
||||
});
|
||||
const slotRows = {};
|
||||
for (const slotId of ['a', 'b']) {
|
||||
const row = el('div', 'ri-slot', selection);
|
||||
@@ -318,16 +329,17 @@ export function createRecentImageryPanel({
|
||||
const opacityLabel = el('label', 'ri-opacity-label', controls);
|
||||
el('span', '', opacityLabel, t('OPACITY'));
|
||||
const opacityValue = el('span', 'ri-opacity-value', opacityLabel, '100%');
|
||||
const opacity = el('input', 'ri-opacity', controls);
|
||||
opacity.id = 'ri-opacity';
|
||||
Object.assign(opacity, {
|
||||
type: 'range',
|
||||
const opacity = createSlider({
|
||||
documentRef: document,
|
||||
className: 'ri-opacity',
|
||||
min: '0',
|
||||
max: '100',
|
||||
step: '1',
|
||||
value: '100',
|
||||
ariaLabel: t('Imagery opacity'),
|
||||
});
|
||||
opacity.setAttribute('aria-label', t('Imagery opacity'));
|
||||
opacity.id = 'ri-opacity';
|
||||
controls.appendChild(opacity);
|
||||
opacityLabel.htmlFor = 'ri-opacity';
|
||||
// Its slot is always there; it is only usable while a swipe is live.
|
||||
const swap = el('button', 'data-toggle-chip ri-swap', controls, t('SWAP'));
|
||||
@@ -668,12 +680,7 @@ export function createRecentImageryPanel({
|
||||
function renderSelection() {
|
||||
const { mode } = snapshot;
|
||||
set(selection.dataset, 'mode', mode);
|
||||
for (const [id, node] of modeButtons) {
|
||||
const on = id === mode;
|
||||
attribute(node, 'aria-checked', String(on));
|
||||
toggle(node, 'active', on);
|
||||
set(node, 'tabIndex', on ? 0 : -1);
|
||||
}
|
||||
modeControl.update(mode, { rovingFocus: true });
|
||||
const ab = mode === 'ab';
|
||||
for (const slotId of ['a', 'b']) {
|
||||
const { row, tag, value, unpin } = slotRows[slotId];
|
||||
@@ -1065,11 +1072,6 @@ export function createRecentImageryPanel({
|
||||
strip.focus({ preventScroll: true });
|
||||
return;
|
||||
}
|
||||
const mode = target?.closest?.('.ri-mode-btn');
|
||||
if (mode) {
|
||||
layer.setMode(mode.dataset.mode);
|
||||
return;
|
||||
}
|
||||
if (target?.closest?.('.ri-swap') && !swap.disabled) {
|
||||
layer.swapSides?.();
|
||||
return;
|
||||
@@ -1086,21 +1088,6 @@ export function createRecentImageryPanel({
|
||||
if (event.target === opacity) layer.setAlpha(Number(opacity.value) / 100);
|
||||
});
|
||||
|
||||
listen(modeGroup, 'keydown', (event) => {
|
||||
const order = MODE_LABELS.map(([mode]) => mode);
|
||||
const step =
|
||||
{ ArrowLeft: -1, ArrowUp: -1, ArrowRight: 1, ArrowDown: 1 }[event.key] ||
|
||||
0;
|
||||
if (!step) return;
|
||||
event.preventDefault();
|
||||
const next =
|
||||
order[
|
||||
(order.indexOf(snapshot.mode) + step + order.length) % order.length
|
||||
];
|
||||
layer.setMode(next);
|
||||
modeButtons.get(next)?.focus?.();
|
||||
});
|
||||
|
||||
listen(strip, 'keydown', (event) => {
|
||||
const total = snapshot.candidates.length;
|
||||
const index = snapshot.focusIndex || 0;
|
||||
|
||||
@@ -412,7 +412,7 @@ test('the body is a fixed stack of seven blocks in a fixed order, each with a fi
|
||||
assert.match(rule('.recent-imagery-readout .ri-card'), /height: 112px/);
|
||||
assert.match(
|
||||
rule('.recent-imagery-readout .ri-selection'),
|
||||
/grid-template-rows: 20px 20px 20px/,
|
||||
/grid-template-rows: 26px 20px 20px/,
|
||||
);
|
||||
assert.match(rule('.recent-imagery-readout .ri-slot'), /height: 20px/);
|
||||
assert.match(rule('.recent-imagery-readout .ri-controls'), /height: 22px/);
|
||||
@@ -445,7 +445,7 @@ test('no control changes place in the tree, no block is hidden and every fixed-h
|
||||
/**
|
||||
* Every block that must keep its height: the node carrying the class, that
|
||||
* class, and the rule it gets its height from. The mode row is the first
|
||||
* 20 px track of the selection grid. The details header has no height of
|
||||
* 26 px track of the selection grid. The details header has no height of
|
||||
* its own: it is the last block, so it moves nothing below it, and its
|
||||
* title is held to one line.
|
||||
*/
|
||||
@@ -491,7 +491,7 @@ test('no control changes place in the tree, no block is hidden and every fixed-h
|
||||
mode.parentNode.children[0] === mode ? mode.parentNode : null,
|
||||
'ri-selection',
|
||||
readout('.ri-selection'),
|
||||
/grid-template-rows: 20px 20px 20px/,
|
||||
/grid-template-rows: 26px 20px 20px/,
|
||||
],
|
||||
'ri-slot-a': [
|
||||
f.byId('ri-slot-a'),
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
const STEPS = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 };
|
||||
|
||||
/** Pure value navigation, also usable by a future template-based view. */
|
||||
export function nextSegmentValue(values, value, key, { homeEnd = true } = {}) {
|
||||
if (!values.length) return undefined;
|
||||
if (homeEnd && key === 'Home') return values[0];
|
||||
if (homeEnd && key === 'End') return values.at(-1);
|
||||
const step = STEPS[key];
|
||||
if (!step) return undefined;
|
||||
return values[(values.indexOf(value) + step + values.length) % values.length];
|
||||
}
|
||||
|
||||
/** Paint controlled selection without emitting changes or moving focus. */
|
||||
export function syncSegmentedControl(
|
||||
buttons,
|
||||
value,
|
||||
{ valueOf, activeClass = 'active', rovingFocus = false } = {},
|
||||
) {
|
||||
for (const button of buttons || []) {
|
||||
if (!button) continue;
|
||||
const selected = valueOf(button) === value;
|
||||
if (button.classList.contains(activeClass) !== selected)
|
||||
button.classList.toggle(activeClass, selected);
|
||||
const set = (name, content) => {
|
||||
if (button.getAttribute?.(name) !== content)
|
||||
button.setAttribute(name, content);
|
||||
};
|
||||
set('aria-checked', String(selected));
|
||||
if (rovingFocus) set('tabindex', selected ? '0' : '-1');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Controlled segmented-selector DOM adapter. Feature owners supply values and
|
||||
* receive changes; rendering a restored value never triggers the callback.
|
||||
* `keyboard` retains each existing group's policy: none, arrows, or radio
|
||||
* (arrows plus Home/End). Pointer and native Enter/Space share the click path.
|
||||
* An injected listener owner keeps the feature's existing teardown boundary.
|
||||
* @param {object} options
|
||||
* @param {Iterable<HTMLButtonElement>} options.buttons
|
||||
* @param {Function} options.valueOf Maps a button to the feature's value.
|
||||
* @param {Function} options.onChange Receives value and originating button.
|
||||
* @param {'none'|'arrows'|'radio'} [options.keyboard]
|
||||
* @param {HTMLElement} [options.keyTarget] Delegated keyboard target, if any.
|
||||
* @param {Function} [options.readValue] Current controlled value for delegation.
|
||||
* @param {Function} [options.listen] Feature-owned event registration.
|
||||
* @returns {{ update: Function, destroy: Function }}
|
||||
*/
|
||||
export function createSegmentedControl({
|
||||
buttons,
|
||||
valueOf,
|
||||
onChange,
|
||||
keyboard = 'none',
|
||||
keyTarget,
|
||||
readValue,
|
||||
listen,
|
||||
}) {
|
||||
const options = [...buttons].filter(Boolean);
|
||||
const removers = [];
|
||||
let destroyed = false;
|
||||
const bind = (target, type, handler) => {
|
||||
const owned = (event) => {
|
||||
if (!destroyed) handler(event);
|
||||
};
|
||||
if (listen) listen(target, type, owned);
|
||||
else {
|
||||
target.addEventListener(type, owned);
|
||||
removers.push(() => target.removeEventListener(type, owned));
|
||||
}
|
||||
};
|
||||
for (const button of options)
|
||||
bind(button, 'click', () => {
|
||||
if (!button.disabled) onChange(valueOf(button), button);
|
||||
});
|
||||
const navigate = (event, from) => {
|
||||
const enabled = options.filter((button) => !button.disabled);
|
||||
const values = enabled.map(valueOf);
|
||||
const next = nextSegmentValue(values, from, event.key, {
|
||||
homeEnd: keyboard === 'radio',
|
||||
});
|
||||
if (next === undefined) return;
|
||||
const button = enabled.find((option) => valueOf(option) === next);
|
||||
event.preventDefault();
|
||||
if (keyTarget) {
|
||||
button.click();
|
||||
button.focus();
|
||||
} else {
|
||||
button.focus();
|
||||
if (next !== from) button.click();
|
||||
}
|
||||
};
|
||||
if (keyboard !== 'none') {
|
||||
if (keyTarget)
|
||||
bind(keyTarget, 'keydown', (event) => navigate(event, readValue()));
|
||||
else
|
||||
for (const button of options)
|
||||
bind(button, 'keydown', (event) => navigate(event, valueOf(button)));
|
||||
}
|
||||
return {
|
||||
update(value, presentation) {
|
||||
if (!destroyed)
|
||||
syncSegmentedControl(options, value, { valueOf, ...presentation });
|
||||
},
|
||||
destroy() {
|
||||
destroyed = true;
|
||||
for (const remove of removers.splice(0)) remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { railFixture } from './railTestFixture.mjs';
|
||||
import {
|
||||
createSegmentedControl,
|
||||
nextSegmentValue,
|
||||
} from './segmentedControl.js';
|
||||
|
||||
function fixture(options = {}) {
|
||||
let writes = 0;
|
||||
const f = railFixture(() => writes++);
|
||||
const buttons = ['a', 'b', 'c'].map((value) => {
|
||||
const button = f.document.createElement('button');
|
||||
button.dataset.value = value;
|
||||
f.container.appendChild(button);
|
||||
return button;
|
||||
});
|
||||
const calls = [];
|
||||
const control = createSegmentedControl({
|
||||
buttons,
|
||||
valueOf: (button) => button.dataset.value,
|
||||
onChange: (value) => calls.push(value),
|
||||
...options,
|
||||
});
|
||||
const key = (target, value) => {
|
||||
const event = new Event('keydown', { cancelable: true });
|
||||
Object.defineProperty(event, 'key', { value });
|
||||
target.dispatchEvent(event);
|
||||
return event.defaultPrevented;
|
||||
};
|
||||
return { ...f, buttons, calls, control, key, writes: () => writes };
|
||||
}
|
||||
|
||||
test('controlled restoration updates selection without emitting or stealing focus', () => {
|
||||
const f = fixture();
|
||||
f.buttons[0].focus();
|
||||
f.control.update('b', { rovingFocus: true });
|
||||
assert.deepEqual(f.calls, []);
|
||||
assert.equal(f.document.activeElement, f.buttons[0]);
|
||||
assert.deepEqual(
|
||||
f.buttons.map((b) => b.getAttribute('aria-checked')),
|
||||
['false', 'true', 'false'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
f.buttons.map((b) => b.getAttribute('tabindex')),
|
||||
['-1', '0', '-1'],
|
||||
);
|
||||
const before = f.writes();
|
||||
f.control.update('b', { rovingFocus: true });
|
||||
assert.equal(
|
||||
f.writes(),
|
||||
before,
|
||||
'unchanged renders must not retrigger rail observers',
|
||||
);
|
||||
f.control.destroy();
|
||||
});
|
||||
|
||||
test('clicks emit current values once, including reselection; disabled controls do not emit', () => {
|
||||
const f = fixture();
|
||||
f.control.update('a');
|
||||
f.buttons[0].click();
|
||||
f.buttons[0].click();
|
||||
f.buttons[1].dataset.value = 'renamed';
|
||||
f.buttons[1].click();
|
||||
f.buttons[2].disabled = true;
|
||||
f.buttons[2].dispatchEvent(new Event('click'));
|
||||
assert.deepEqual(f.calls, ['a', 'a', 'renamed']);
|
||||
assert.equal(
|
||||
f.buttons[0].getAttribute('aria-checked'),
|
||||
'true',
|
||||
'the owner controls selection',
|
||||
);
|
||||
f.control.destroy();
|
||||
});
|
||||
|
||||
test('radio navigation wraps, skips disabled choices, and retains Home/End semantics', () => {
|
||||
const f = fixture({ keyboard: 'radio' });
|
||||
f.buttons[1].disabled = true;
|
||||
assert.equal(f.key(f.buttons[0], 'ArrowRight'), true);
|
||||
assert.equal(f.document.activeElement, f.buttons[2]);
|
||||
assert.deepEqual(f.calls, ['c']);
|
||||
f.key(f.buttons[2], 'ArrowDown');
|
||||
assert.equal(f.document.activeElement, f.buttons[0]);
|
||||
f.key(f.buttons[0], 'Home');
|
||||
assert.deepEqual(
|
||||
f.calls,
|
||||
['c', 'a'],
|
||||
'Home on the first button does not reselect it',
|
||||
);
|
||||
f.key(f.buttons[0], 'End');
|
||||
assert.equal(f.document.activeElement, f.buttons[2]);
|
||||
assert.deepEqual(f.calls, ['c', 'a', 'c']);
|
||||
f.control.destroy();
|
||||
});
|
||||
|
||||
test('legacy click-only groups leave arrows and tab order to their existing owner', () => {
|
||||
const f = fixture();
|
||||
f.control.update('b');
|
||||
assert.equal(f.key(f.buttons[0], 'ArrowRight'), false);
|
||||
assert.equal(f.key(f.buttons[0], 'Home'), false);
|
||||
assert.deepEqual(f.calls, []);
|
||||
assert.ok(
|
||||
f.buttons.every((button) => button.getAttribute('tabindex') === null),
|
||||
);
|
||||
f.control.destroy();
|
||||
});
|
||||
|
||||
test('delegated imagery keys navigate from the controlled value, not stale focus', () => {
|
||||
const f = railFixture();
|
||||
const buttons = ['a', 'b', 'c'].map((value) => {
|
||||
const button = f.document.createElement('button');
|
||||
button.dataset.value = value;
|
||||
f.container.appendChild(button);
|
||||
return button;
|
||||
});
|
||||
let selected = 'c';
|
||||
const calls = [];
|
||||
const control = createSegmentedControl({
|
||||
buttons,
|
||||
valueOf: (b) => b.dataset.value,
|
||||
onChange: (value) => {
|
||||
calls.push(value);
|
||||
selected = value;
|
||||
},
|
||||
keyboard: 'arrows',
|
||||
keyTarget: f.container,
|
||||
readValue: () => selected,
|
||||
});
|
||||
const event = new Event('keydown', { cancelable: true });
|
||||
Object.defineProperty(event, 'key', { value: 'ArrowRight' });
|
||||
f.container.dispatchEvent(event);
|
||||
assert.deepEqual(calls, ['a']);
|
||||
assert.equal(f.document.activeElement, buttons[0]);
|
||||
const home = new Event('keydown', { cancelable: true });
|
||||
Object.defineProperty(home, 'key', { value: 'Home' });
|
||||
f.container.dispatchEvent(home);
|
||||
assert.equal(home.defaultPrevented, false);
|
||||
assert.deepEqual(calls, ['a']);
|
||||
control.destroy();
|
||||
});
|
||||
|
||||
test('disposal revokes detached controls and replacement subscriptions fire once', () => {
|
||||
const f = fixture({ keyboard: 'radio' });
|
||||
f.control.destroy();
|
||||
const next = [];
|
||||
const replacement = createSegmentedControl({
|
||||
buttons: f.buttons,
|
||||
valueOf: (b) => b.dataset.value,
|
||||
onChange: (value) => next.push(value),
|
||||
keyboard: 'radio',
|
||||
});
|
||||
f.buttons[0].click();
|
||||
f.key(f.buttons[0], 'End');
|
||||
assert.deepEqual(f.calls, []);
|
||||
assert.deepEqual(next, ['a', 'c']);
|
||||
replacement.destroy();
|
||||
replacement.destroy();
|
||||
f.buttons[1].click();
|
||||
f.key(f.buttons[0], 'End');
|
||||
assert.deepEqual(next, ['a', 'c']);
|
||||
});
|
||||
|
||||
test('pure value navigation can be reused without a DOM', () => {
|
||||
assert.equal(nextSegmentValue(['fit', 'fill'], 'fill', 'ArrowRight'), 'fit');
|
||||
assert.equal(nextSegmentValue(['fit', 'fill'], 'fit', 'ArrowUp'), 'fill');
|
||||
assert.equal(nextSegmentValue(['fit', 'fill'], 'fill', 'Home'), 'fit');
|
||||
assert.equal(nextSegmentValue(['fit', 'fill'], 'fit', 'End'), 'fill');
|
||||
assert.equal(
|
||||
nextSegmentValue(['fit', 'fill'], 'fit', 'Home', { homeEnd: false }),
|
||||
undefined,
|
||||
);
|
||||
assert.equal(nextSegmentValue([], 'fit', 'ArrowRight'), undefined);
|
||||
assert.equal(nextSegmentValue(['fit'], 'fit', 'Escape'), undefined);
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Build the shared native slider. Feature owners retain their input/change
|
||||
* listeners, formatting and teardown; this component installs no handlers.
|
||||
* Static sliders use the same gev-quantitative-slider class in their template.
|
||||
* @param {object} options
|
||||
* @param {Document} [options.documentRef] Document that owns the input.
|
||||
* @param {string} [options.className] Additional feature/layout hooks.
|
||||
* @param {number|string} [options.min]
|
||||
* @param {number|string} [options.max]
|
||||
* @param {number|string} [options.step]
|
||||
* @param {number|string} [options.value]
|
||||
* @param {string} [options.ariaLabel] Already translated accessible name.
|
||||
* @returns {HTMLInputElement}
|
||||
*/
|
||||
export function createSlider({
|
||||
documentRef = globalThis.document,
|
||||
className = '',
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
value,
|
||||
ariaLabel,
|
||||
} = {}) {
|
||||
const input = documentRef.createElement('input');
|
||||
input.type = 'range';
|
||||
input.className = `gev-quantitative-slider${className ? ` ${className}` : ''}`;
|
||||
for (const [key, setting] of Object.entries({ min, max, step, value })) {
|
||||
if (setting !== undefined) input[key] = setting;
|
||||
}
|
||||
if (ariaLabel !== undefined) input.setAttribute('aria-label', ariaLabel);
|
||||
return input;
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
import { isExplicitLayerStateOrigin } from '../data/layerState.js';
|
||||
import { syncChipGroup } from './chipGroup.js';
|
||||
import {
|
||||
createSegmentedControl,
|
||||
syncSegmentedControl,
|
||||
} from './segmentedControl.js';
|
||||
import {
|
||||
presentStreetLevelPanel,
|
||||
SINCE_STOPS,
|
||||
@@ -9,12 +13,6 @@ import { t, translateKnown } from '../i18n/index.js';
|
||||
const RENDER_MODE_KEY = 'gev:street-level:render-mode';
|
||||
/** <body> classes of the modes that hide every panel; an expanded photo leaves with them. */
|
||||
const PANEL_HIDING_MODES = ['ui-clean-view', 'recording-mode', 'cockpit-mode'];
|
||||
const RADIO_STEPS = Object.freeze({
|
||||
ArrowRight: 1,
|
||||
ArrowDown: 1,
|
||||
ArrowLeft: -1,
|
||||
ArrowUp: -1,
|
||||
});
|
||||
|
||||
// Renders run on every layer notification, and the rail's MutationObserver
|
||||
// relays out on each attribute write: write only what changed.
|
||||
@@ -106,13 +104,13 @@ export class StreetLevelControls {
|
||||
}
|
||||
this._toggleProvider(button.dataset.chipId);
|
||||
});
|
||||
for (const button of el.panoButtons) {
|
||||
this.listen(button, 'click', () =>
|
||||
this._setParams({ pano: button.dataset.slPano }),
|
||||
);
|
||||
}
|
||||
this._bindRadioGroup(el.panoButtons);
|
||||
this._bindRadioGroup(el.renderButtons);
|
||||
createSegmentedControl({
|
||||
buttons: el.panoButtons,
|
||||
valueOf: (button) => button.dataset.slPano,
|
||||
onChange: (pano) => this._setParams({ pano }),
|
||||
keyboard: 'radio',
|
||||
listen: (target, type, handler) => this.listen(target, type, handler),
|
||||
});
|
||||
// The readout follows the thumb; coverage is rebuilt once, on release.
|
||||
const sinceDays = () =>
|
||||
SINCE_STOPS[Number(el.sinceRange?.value) || 0]?.days ?? 0;
|
||||
@@ -160,17 +158,20 @@ export class StreetLevelControls {
|
||||
this._exitExpanded();
|
||||
});
|
||||
} else setProp(el.viewerExpand, 'hidden', true);
|
||||
for (const button of el.renderButtons) {
|
||||
this.listen(button, 'click', () => {
|
||||
const mode = button.dataset.slRender;
|
||||
createSegmentedControl({
|
||||
buttons: el.renderButtons,
|
||||
valueOf: (button) => button.dataset.slRender,
|
||||
keyboard: 'radio',
|
||||
listen: (target, type, handler) => this.listen(target, type, handler),
|
||||
onChange: (mode) => {
|
||||
this.layer.setViewerRenderMode?.(mode);
|
||||
try {
|
||||
localStorage.setItem(RENDER_MODE_KEY, mode);
|
||||
} catch {
|
||||
/* storage unavailable */
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
try {
|
||||
const stored = localStorage.getItem(RENDER_MODE_KEY);
|
||||
if (stored === 'fill' || stored === 'letterbox')
|
||||
@@ -185,24 +186,6 @@ export class StreetLevelControls {
|
||||
}
|
||||
}
|
||||
|
||||
/** ARIA radio keys. Selecting clicks the radio, so keys and pointer share a path. */
|
||||
_bindRadioGroup(buttons) {
|
||||
buttons.forEach((button, index) => {
|
||||
this.listen(button, 'keydown', (event) => {
|
||||
let next = null;
|
||||
if (event.key in RADIO_STEPS) {
|
||||
const count = buttons.length;
|
||||
next = buttons[(index + RADIO_STEPS[event.key] + count) % count];
|
||||
} else if (event.key === 'Home') next = buttons[0];
|
||||
else if (event.key === 'End') next = buttons[buttons.length - 1];
|
||||
if (!next) return;
|
||||
event.preventDefault();
|
||||
next.focus();
|
||||
if (next !== button) next.click();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Refit the viewer once per frame. Skips a hidden (0×0) viewer, which would
|
||||
* ask for tiles at z=NaN; the observer fires again once it has a size.
|
||||
@@ -416,15 +399,6 @@ export class StreetLevelControls {
|
||||
setProp(el.errorText, 'textContent', view.error || '');
|
||||
}
|
||||
|
||||
_renderRadios(buttons, isChecked) {
|
||||
for (const button of buttons) {
|
||||
const checked = isChecked(button);
|
||||
button.classList.toggle('is-active', checked);
|
||||
setAttr(button, 'aria-checked', String(checked));
|
||||
setAttr(button, 'tabindex', checked ? '0' : '-1');
|
||||
}
|
||||
}
|
||||
|
||||
_renderFilters(view) {
|
||||
const el = this._elements;
|
||||
const shows = view.filterControls || { pano: true, since: true };
|
||||
@@ -434,10 +408,11 @@ export class StreetLevelControls {
|
||||
setProp(el.sinceNote, 'textContent', view.sinceNote || '');
|
||||
setProp(el.sinceNote, 'hidden', !view.sinceNote);
|
||||
setProp(el.controls, 'hidden', !shows.pano && !shows.since);
|
||||
this._renderRadios(
|
||||
el.panoButtons,
|
||||
(button) => button.dataset.slPano === view.filter.pano,
|
||||
);
|
||||
syncSegmentedControl(el.panoButtons, view.filter.pano, {
|
||||
valueOf: (button) => button.dataset.slPano,
|
||||
activeClass: 'is-active',
|
||||
rovingFocus: true,
|
||||
});
|
||||
if (el.sinceRange) {
|
||||
// Never move the thumb under the user's hand; once the committed value
|
||||
// matches the thumb, show the full readout with its cut-off date.
|
||||
@@ -467,10 +442,11 @@ export class StreetLevelControls {
|
||||
setProp(wrap, 'hidden', !viewer.open);
|
||||
}
|
||||
setProp(el.viewerPlaceholder, 'hidden', !viewer.loading);
|
||||
this._renderRadios(
|
||||
el.renderButtons,
|
||||
(button) => button.dataset.slRender === viewer.renderMode,
|
||||
);
|
||||
syncSegmentedControl(el.renderButtons, viewer.renderMode, {
|
||||
valueOf: (button) => button.dataset.slRender,
|
||||
activeClass: 'is-active',
|
||||
rovingFocus: true,
|
||||
});
|
||||
// Google's viewer always fills its frame: no FIT/FILL to offer.
|
||||
const renderGroup = el.renderButtons[0]?.parentElement;
|
||||
if (renderGroup) setProp(renderGroup, 'hidden', !viewer.renderModes);
|
||||
|
||||
@@ -981,9 +981,9 @@ test('a note under SINCE says what date it compares, only while one is given', (
|
||||
const { layer } = stubPanel(dom, state);
|
||||
const note = () => dom.root.querySelector('#sl-since-note');
|
||||
assert.equal(note().hidden, true, 'none for Mapillary');
|
||||
layer.publish({ ...state, sinceNote: 'KartaView: by upload date' });
|
||||
layer.publish({ ...state, sinceNote: '(by upload date)' });
|
||||
assert.equal(note().hidden, false);
|
||||
assert.equal(note().textContent, 'KartaView: by upload date');
|
||||
assert.equal(note().textContent, '(by upload date)');
|
||||
layer.publish(state);
|
||||
assert.equal(note().hidden, true);
|
||||
// The panel's own markup has it, read out with the slider.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { formatNumber, t } from '../i18n/index.js';
|
||||
import { createSlider } from './slider.js';
|
||||
|
||||
/** A slider value in the UI language: 0.85 in English, 0,85 in Spanish. */
|
||||
const formatParam = (value, digits) =>
|
||||
@@ -41,14 +42,15 @@ export function createStyleParameters({
|
||||
const label = documentRef.createElement('span');
|
||||
label.className = 'param-label';
|
||||
label.textContent = t(metadata.label);
|
||||
const slider = documentRef.createElement('input');
|
||||
slider.type = 'range';
|
||||
slider.className = 'param-slider';
|
||||
slider.setAttribute('aria-label', t(metadata.label));
|
||||
slider.min = metadata.min;
|
||||
slider.max = metadata.max;
|
||||
slider.step = metadata.max <= 1 ? '0.01' : '0.1';
|
||||
slider.value = readValue(name);
|
||||
const slider = createSlider({
|
||||
documentRef,
|
||||
className: 'param-slider',
|
||||
ariaLabel: t(metadata.label),
|
||||
min: metadata.min,
|
||||
max: metadata.max,
|
||||
step: metadata.max <= 1 ? '0.01' : '0.1',
|
||||
value: readValue(name),
|
||||
});
|
||||
const digits = metadata.max <= 1 ? 2 : 1;
|
||||
const valueDisplay = documentRef.createElement('span');
|
||||
valueDisplay.className = 'param-value';
|
||||
|
||||
+4
-172
@@ -532,110 +532,7 @@ html:not([lang='en']) .panel-collapse-btn {
|
||||
|
||||
.pp-slider {
|
||||
flex: 1;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
height: 3px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.pp-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 6px var(--accent-glow);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pp-slider::-moz-range-thumb {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
border: none;
|
||||
box-shadow: 0 0 6px var(--accent-glow);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pp-slider-value {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--accent);
|
||||
min-width: 28px;
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Quantitative companion sliders share Display / Sharpen's visible rail,
|
||||
* circular thumb, glow, and value treatment while retaining a taller hit area. */
|
||||
.gev-quantitative-slider {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 18px;
|
||||
margin: 0;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.gev-quantitative-slider::-webkit-slider-runnable-track {
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.gev-quantitative-slider::-webkit-slider-thumb {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
margin-top: -3.5px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 6px var(--accent-glow);
|
||||
cursor: pointer;
|
||||
}
|
||||
.gev-quantitative-slider::-moz-range-track,
|
||||
.gev-quantitative-slider::-moz-range-progress {
|
||||
height: 3px;
|
||||
border: 0;
|
||||
border-radius: 2px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.gev-quantitative-slider::-moz-range-thumb {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 6px var(--accent-glow);
|
||||
cursor: pointer;
|
||||
}
|
||||
.gev-quantitative-slider:focus-visible {
|
||||
border-radius: 4px;
|
||||
outline: 1px solid color-mix(in srgb, var(--accent) 80%, transparent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.gev-quantitative-slider:disabled {
|
||||
opacity: 0.42;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.gev-slider-value {
|
||||
min-width: 28px;
|
||||
color: var(--accent);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.5px;
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pp-slider-mini-label {
|
||||
@@ -677,11 +574,6 @@ html:not([lang='en']) .pp-slider-row > .pp-slider-mini-label {
|
||||
padding: 5px 8px 6px;
|
||||
}
|
||||
|
||||
.detection-allocation-seg .pp-mode-btn {
|
||||
font-size: 8px;
|
||||
letter-spacing: 0.35px;
|
||||
}
|
||||
|
||||
#hud-layout-row {
|
||||
min-width: 200px;
|
||||
}
|
||||
@@ -699,7 +591,7 @@ html:not([lang='en']) .pp-slider-row > .pp-slider-mini-label {
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.cyber-sonar-toggle-row .pp-mode-btn {
|
||||
.cyber-sonar-toggle-row .pp-mode-btn:not(.gev-segment) {
|
||||
margin-left: auto;
|
||||
min-width: 48px;
|
||||
}
|
||||
@@ -851,11 +743,6 @@ html:not([lang='en']) .pp-slider-row > .pp-slider-mini-label {
|
||||
#draw-mode-row {
|
||||
min-width: 184px;
|
||||
}
|
||||
/* Equal thirds with room around the label, so a long translated shape name
|
||||
never runs edge to edge in its segment. */
|
||||
html:not([lang='en']) #draw-mode-row .pp-mode-btn {
|
||||
padding: 0 4px;
|
||||
}
|
||||
#draw-label-row {
|
||||
flex-wrap: wrap;
|
||||
row-gap: 6px;
|
||||
@@ -984,11 +871,8 @@ body.gev-geo-import-drag #cesiumContainer {
|
||||
.pp-mode-seg {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.pp-mode-btn {
|
||||
.pp-mode-btn:not(.gev-segment) {
|
||||
flex: 1;
|
||||
height: 22px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
@@ -1003,14 +887,11 @@ body.gev-geo-import-drag #cesiumContainer {
|
||||
background-color var(--transition-fast),
|
||||
color var(--transition-fast);
|
||||
}
|
||||
.pp-mode-btn + .pp-mode-btn {
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
.pp-mode-btn:hover {
|
||||
.pp-mode-btn:not(.gev-segment):hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.pp-mode-btn.active {
|
||||
.pp-mode-btn:not(.gev-segment).active {
|
||||
background: var(--accent);
|
||||
color: #000;
|
||||
}
|
||||
@@ -1028,12 +909,6 @@ body.gev-geo-import-drag #cesiumContainer {
|
||||
box-shadow: inset 0 0 0 4px #061014;
|
||||
}
|
||||
|
||||
.pp-slider:focus-visible,
|
||||
.param-slider:focus-visible {
|
||||
outline: 2px solid var(--text-primary);
|
||||
outline-offset: 4px;
|
||||
}
|
||||
|
||||
#clean-view-toggle {
|
||||
width: var(--pp-expanded-width);
|
||||
}
|
||||
@@ -1240,47 +1115,4 @@ html:not([lang='en']) .param-slider-row {
|
||||
.param-slider {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
height: 3px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.param-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 6px var(--accent-glow);
|
||||
cursor: pointer;
|
||||
transition: box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.param-slider::-webkit-slider-thumb:hover {
|
||||
box-shadow: 0 0 12px var(--accent-glow);
|
||||
}
|
||||
|
||||
.param-slider::-moz-range-thumb {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
border: none;
|
||||
box-shadow: 0 0 6px var(--accent-glow);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.param-value {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--accent);
|
||||
min-width: 32px;
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -506,11 +506,13 @@
|
||||
0 0 12px rgba(196, 84, 84, 0.14);
|
||||
}
|
||||
|
||||
:root[data-ui-theme='cyber'] .gev-segmented,
|
||||
:root[data-ui-theme='cyber'] .pp-mode-seg {
|
||||
border-color: rgba(206, 222, 226, 0.24);
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
:root[data-ui-theme='cyber'] .gev-segment[aria-checked='true'],
|
||||
:root[data-ui-theme='cyber'] .pp-mode-btn.active {
|
||||
background: var(--cyber-red);
|
||||
color: #f7f8f7;
|
||||
|
||||
@@ -184,8 +184,8 @@
|
||||
max-height: 100%;
|
||||
pointer-events: auto;
|
||||
}
|
||||
#right-context-rail > #weather-panel,
|
||||
/* Collapsed, it must stay hideable by the rail's exclusive layout. */
|
||||
/* Collapsed readouts must stay hideable by the rail's exclusive layout. */
|
||||
#right-context-rail > #weather-panel:not(.collapsed),
|
||||
#right-context-rail > #recent-imagery-panel:not(.collapsed) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -309,10 +309,10 @@ html:not([lang='en'])
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 4. The selection bar: mode, then two rows that are always there. */
|
||||
/* 4. The selection bar: 24px mode buttons plus borders, then two stable rows. */
|
||||
.recent-imagery-readout .ri-selection {
|
||||
display: grid;
|
||||
grid-template-rows: 20px 20px 20px;
|
||||
grid-template-rows: 26px 20px 20px;
|
||||
row-gap: 4px;
|
||||
padding: 5px;
|
||||
border: 1px solid var(--weather-card-border);
|
||||
@@ -321,7 +321,6 @@ html:not([lang='en'])
|
||||
.recent-imagery-readout .ri-mode {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 4px;
|
||||
}
|
||||
.recent-imagery-readout .ri-selection .data-toggle-chip {
|
||||
height: 20px;
|
||||
@@ -423,16 +422,6 @@ html:not([lang='en']) .recent-imagery-readout .ri-opacity-label {
|
||||
}
|
||||
.recent-imagery-readout .ri-opacity-value {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.recent-imagery-readout .ri-opacity {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
.recent-imagery-readout .ri-opacity:disabled {
|
||||
opacity: 0.35;
|
||||
}
|
||||
.recent-imagery-readout .ri-swap {
|
||||
width: 40px;
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/* Shared one-of-many control. Feature classes retain surrounding placement. */
|
||||
.gev-segmented {
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--glass-border-hover);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.gev-segmented[hidden] {
|
||||
display: none;
|
||||
}
|
||||
.gev-segment {
|
||||
flex: 1;
|
||||
min-width: min-content;
|
||||
min-height: 24px;
|
||||
margin: 0;
|
||||
padding: 4px 8px;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--text-secondary);
|
||||
font: 9px/1.25 var(--font-mono);
|
||||
letter-spacing: 0.6px;
|
||||
text-transform: uppercase;
|
||||
overflow-wrap: normal;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color var(--transition-fast),
|
||||
color var(--transition-fast);
|
||||
}
|
||||
.gev-segment + .gev-segment {
|
||||
border-left: 1px solid var(--glass-border);
|
||||
}
|
||||
.gev-segment:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.gev-segment[aria-checked='true'] {
|
||||
background: var(--accent);
|
||||
color: #000;
|
||||
}
|
||||
.gev-segment:focus-visible {
|
||||
position: relative;
|
||||
outline: 2px solid var(--text-primary);
|
||||
outline-offset: -3px;
|
||||
box-shadow: inset 0 0 0 4px #061014;
|
||||
}
|
||||
.gev-segment:disabled {
|
||||
opacity: 0.42;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/* Shared ordinary slider surface; feature classes only own surrounding layout.
|
||||
* Native range inputs retain keyboard, pointer, bounds and step behavior. */
|
||||
.gev-quantitative-slider {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 18px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.gev-quantitative-slider::-webkit-slider-runnable-track {
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.gev-quantitative-slider::-webkit-slider-thumb {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
margin-top: -3.5px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 6px var(--accent-glow);
|
||||
cursor: pointer;
|
||||
}
|
||||
.gev-quantitative-slider::-moz-range-track,
|
||||
.gev-quantitative-slider::-moz-range-progress {
|
||||
height: 3px;
|
||||
border: 0;
|
||||
border-radius: 2px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.gev-quantitative-slider::-moz-range-thumb {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 6px var(--accent-glow);
|
||||
cursor: pointer;
|
||||
}
|
||||
.gev-quantitative-slider:focus-visible {
|
||||
border-radius: 4px;
|
||||
outline: 1px solid color-mix(in srgb, var(--accent) 80%, transparent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.gev-quantitative-slider:disabled {
|
||||
opacity: 0.42;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.pp-slider-value,
|
||||
.param-value,
|
||||
.ri-opacity-value,
|
||||
.rail-card-setting-range-value,
|
||||
.gev-slider-value {
|
||||
min-width: 28px;
|
||||
color: var(--accent);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.5px;
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -222,14 +222,6 @@ html:not([lang='en']) .sl-meta-row .sl-nearest-btn {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* No overflow clipping: it would cut off the buttons' focus ring. The end
|
||||
buttons round themselves instead. */
|
||||
.sl-seg {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--glass-border-hover);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.sl-seg[hidden],
|
||||
.sl-controls[hidden],
|
||||
.sl-filters[hidden],
|
||||
@@ -237,40 +229,6 @@ html:not([lang='en']) .sl-meta-row .sl-nearest-btn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sl-seg-btn {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--sl-text);
|
||||
letter-spacing: 0.08em;
|
||||
padding: 4px 9px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sl-seg-btn:first-child {
|
||||
border-radius: 999px 0 0 999px;
|
||||
}
|
||||
|
||||
.sl-seg-btn:last-child {
|
||||
border-radius: 0 999px 999px 0;
|
||||
}
|
||||
|
||||
.sl-seg-btn + .sl-seg-btn {
|
||||
border-left: 1px solid var(--glass-border);
|
||||
}
|
||||
|
||||
.sl-seg-btn:focus-visible {
|
||||
position: relative;
|
||||
outline: 1px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.sl-seg-btn.is-active {
|
||||
background: rgba(5, 203, 99, 0.22);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.sl-since {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
@@ -291,10 +249,20 @@ html:not([lang='en']) .sl-meta-row .sl-nearest-btn {
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.sl-since-note {
|
||||
/* The value and its note share a line: ANY DATE (by upload date). */
|
||||
.sl-since-value {
|
||||
grid-column: 2;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sl-since-note {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--sl-text-small);
|
||||
font-size: 0.5rem;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
@@ -303,7 +271,6 @@ html:not([lang='en']) .sl-meta-row .sl-nearest-btn {
|
||||
}
|
||||
|
||||
.sl-since-label {
|
||||
grid-column: 2;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--sl-text);
|
||||
letter-spacing: 0.08em;
|
||||
|
||||
@@ -362,22 +362,9 @@ html:not([lang='en']) .rail-card-setting {
|
||||
font: 8px var(--font-mono);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.rail-card-setting-range {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
accent-color: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
.rail-card-setting-range:focus-visible {
|
||||
outline: 2px solid var(--text-primary);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
.rail-card-setting-range-value {
|
||||
flex: 0 0 4ch;
|
||||
white-space: nowrap;
|
||||
color: var(--text-primary);
|
||||
font: 9px var(--font-mono);
|
||||
text-align: right;
|
||||
}
|
||||
.rail-card-result {
|
||||
display: grid;
|
||||
|
||||
@@ -27,27 +27,27 @@
|
||||
<output id="cyber-sonar-status" class="cyber-sonar-control" role="status"></output>
|
||||
<div class="pp-slider-row cyber-sonar-control cyber-sonar-setting" id="cyber-sonar-rings-row">
|
||||
<label class="pp-slider-mini-label" for="cyber-sonar-rings" data-i18n>Rings</label>
|
||||
<input type="range" class="pp-slider" id="cyber-sonar-rings" min="3" max="12" step="1" value="8" aria-label="Sonar ring count" data-i18n-aria-label />
|
||||
<input type="range" class="gev-quantitative-slider pp-slider" id="cyber-sonar-rings" min="3" max="12" step="1" value="8" aria-label="Sonar ring count" data-i18n-aria-label />
|
||||
<span class="pp-slider-value" id="cyber-sonar-rings-value">8</span>
|
||||
</div>
|
||||
<div class="pp-slider-row cyber-sonar-control cyber-sonar-setting" id="cyber-sonar-range-row">
|
||||
<label class="pp-slider-mini-label" for="cyber-sonar-range" data-i18n>Range</label>
|
||||
<input type="range" class="pp-slider" id="cyber-sonar-range" min="60" max="120" step="1" value="100" aria-label="Sonar ring range" data-i18n-aria-label />
|
||||
<input type="range" class="gev-quantitative-slider pp-slider" id="cyber-sonar-range" min="60" max="120" step="1" value="100" aria-label="Sonar ring range" data-i18n-aria-label />
|
||||
<span class="pp-slider-value" id="cyber-sonar-range-value">100%</span>
|
||||
</div>
|
||||
<div class="pp-slider-row cyber-sonar-control cyber-sonar-setting" id="cyber-sonar-intensity-row">
|
||||
<label class="pp-slider-mini-label" for="cyber-sonar-intensity" data-i18n>Power</label>
|
||||
<input type="range" class="pp-slider" id="cyber-sonar-intensity" min="0" max="100" step="1" value="70" aria-label="Sonar intensity" data-i18n-aria-label />
|
||||
<input type="range" class="gev-quantitative-slider pp-slider" id="cyber-sonar-intensity" min="0" max="100" step="1" value="70" aria-label="Sonar intensity" data-i18n-aria-label />
|
||||
<span class="pp-slider-value" id="cyber-sonar-intensity-value">70%</span>
|
||||
</div>
|
||||
<div class="pp-slider-row cyber-sonar-control cyber-sonar-setting" id="cyber-sonar-opacity-row">
|
||||
<label class="pp-slider-mini-label" for="cyber-sonar-opacity" data-i18n>Opacity</label>
|
||||
<input type="range" class="pp-slider" id="cyber-sonar-opacity" min="35" max="100" step="1" value="84" aria-label="Cyber contact and label opacity" title="Minimum opacity for map dots, icons, models, brackets, and labels between sonar passes" data-i18n-aria-label data-i18n-title />
|
||||
<input type="range" class="gev-quantitative-slider pp-slider" id="cyber-sonar-opacity" min="35" max="100" step="1" value="84" aria-label="Cyber contact and label opacity" title="Minimum opacity for map dots, icons, models, brackets, and labels between sonar passes" data-i18n-aria-label data-i18n-title />
|
||||
<span class="pp-slider-value" id="cyber-sonar-opacity-value">84%</span>
|
||||
</div>
|
||||
<div class="pp-slider-row cyber-sonar-control cyber-sonar-setting" id="cyber-sonar-sector-row">
|
||||
<label class="pp-slider-mini-label" for="cyber-sonar-sector" data-i18n>Sector</label>
|
||||
<input type="range" class="pp-slider" id="cyber-sonar-sector" min="8" max="60" step="1" value="24" aria-label="Sonar sector width" data-i18n-aria-label />
|
||||
<input type="range" class="gev-quantitative-slider pp-slider" id="cyber-sonar-sector" min="8" max="60" step="1" value="24" aria-label="Sonar sector width" data-i18n-aria-label />
|
||||
<span class="pp-slider-value" id="cyber-sonar-sector-value">24°</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -58,24 +58,24 @@
|
||||
</button>
|
||||
<div class="pp-slider-row detection-slider-row" id="detection-slider-row">
|
||||
<label class="pp-slider-mini-label" for="detection-density-slider" data-i18n>Density</label>
|
||||
<input type="range" class="pp-slider" id="detection-density-slider" min="0" max="100" step="25" value="50" aria-label="Detection label density" data-i18n-aria-label />
|
||||
<input type="range" class="gev-quantitative-slider pp-slider" id="detection-density-slider" min="0" max="100" step="25" value="50" aria-label="Detection label density" data-i18n-aria-label />
|
||||
<span class="pp-slider-value" id="detection-density-value">50%</span>
|
||||
</div>
|
||||
<div class="pp-slider-row detection-allocation-row" id="detection-allocation-row">
|
||||
<span class="pp-slider-mini-label" data-i18n>Allocation</span>
|
||||
<div class="pp-mode-seg detection-allocation-seg" role="radiogroup" aria-label="Detection label allocation" data-i18n-aria-label>
|
||||
<button class="pp-mode-btn active" id="detection-allocation-elastic" data-allocation="ELASTIC" role="radio" aria-checked="true" data-i18n>Elastic</button>
|
||||
<button class="pp-mode-btn" id="detection-allocation-weighted" data-allocation="WEIGHTED" role="radio" aria-checked="false" data-i18n>Weighted</button>
|
||||
<div class="gev-segmented pp-mode-seg detection-allocation-seg" role="radiogroup" aria-label="Detection label allocation" data-i18n-aria-label>
|
||||
<button class="gev-segment pp-mode-btn active" id="detection-allocation-elastic" data-allocation="ELASTIC" role="radio" aria-checked="true" data-i18n>Elastic</button>
|
||||
<button class="gev-segment pp-mode-btn" id="detection-allocation-weighted" data-allocation="WEIGHTED" role="radio" aria-checked="false" data-i18n>Weighted</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pp-slider-row detection-fade-row" id="detection-fade-row">
|
||||
<label class="pp-slider-mini-label" for="detection-fade-slider" data-i18n>Fade</label>
|
||||
<input type="range" class="pp-slider" id="detection-fade-slider" min="0" max="40" step="1" value="7" aria-label="Detection fade distance" title="World-overlay fade distance outside the keyhole as a percentage of its radius" data-i18n-aria-label data-i18n-title />
|
||||
<input type="range" class="gev-quantitative-slider pp-slider" id="detection-fade-slider" min="0" max="40" step="1" value="7" aria-label="Detection fade distance" title="World-overlay fade distance outside the keyhole as a percentage of its radius" data-i18n-aria-label data-i18n-title />
|
||||
<span class="pp-slider-value" id="detection-fade-value">7%</span>
|
||||
</div>
|
||||
<div class="pp-slider-row detection-opacity-row" id="detection-opacity-row">
|
||||
<label class="pp-slider-mini-label" for="detection-opacity-slider" data-i18n>Outside</label>
|
||||
<input type="range" class="pp-slider" id="detection-opacity-slider" min="0" max="100" step="1" value="1" aria-label="Detection opacity outside the keyhole" title="World-overlay label and card opacity beyond the fade distance" data-i18n-aria-label data-i18n-title />
|
||||
<input type="range" class="gev-quantitative-slider pp-slider" id="detection-opacity-slider" min="0" max="100" step="1" value="1" aria-label="Detection opacity outside the keyhole" title="World-overlay label and card opacity beyond the fade distance" data-i18n-aria-label data-i18n-title />
|
||||
<span class="pp-slider-value" id="detection-opacity-value">1%</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -99,9 +99,9 @@
|
||||
</button>
|
||||
<div class="pp-slider-row visible" id="models3d-mode-row">
|
||||
<span class="pp-slider-mini-label" data-i18n>Models</span>
|
||||
<div class="pp-mode-seg" role="radiogroup" aria-label="3D model coverage" data-i18n-aria-label>
|
||||
<button class="pp-mode-btn active" id="models3d-mode-proximity" data-mode="proximity" role="radio" aria-checked="true" data-i18n>Proximity</button>
|
||||
<button class="pp-mode-btn" id="models3d-mode-all" data-mode="all" role="radio" aria-checked="false" data-i18n>All</button>
|
||||
<div class="gev-segmented pp-mode-seg" role="radiogroup" aria-label="3D model coverage" data-i18n-aria-label>
|
||||
<button class="gev-segment pp-mode-btn active" id="models3d-mode-proximity" data-mode="proximity" role="radio" aria-checked="true" data-i18n>Proximity</button>
|
||||
<button class="gev-segment pp-mode-btn" id="models3d-mode-all" data-mode="all" role="radio" aria-checked="false" data-i18n>All</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -117,7 +117,7 @@
|
||||
mirrors SCOPE_FEATHER_RATIO_DEFAULT in src/scopeMask.js. Feather
|
||||
softens the black mask edge; label fading is the separate Fade
|
||||
slider. -->
|
||||
<input type="range" class="pp-slider" id="scope-feather-slider" min="0" max="100" value="11" aria-label="Scope edge feather" title="Scope edge feather as a percentage of the keyhole radius" data-i18n-aria-label data-i18n-title />
|
||||
<input type="range" class="gev-quantitative-slider pp-slider" id="scope-feather-slider" min="0" max="100" value="11" aria-label="Scope edge feather" title="Scope edge feather as a percentage of the keyhole radius" data-i18n-aria-label data-i18n-title />
|
||||
<span class="pp-slider-value" id="scope-feather-value">11%</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -128,10 +128,10 @@
|
||||
</button>
|
||||
<div class="pp-slider-row" id="draw-mode-row">
|
||||
<span class="pp-slider-mini-label" data-i18n>Shape</span>
|
||||
<div class="pp-mode-seg" role="radiogroup" aria-label="Shape to draw" data-i18n-aria-label>
|
||||
<button class="pp-mode-btn active" data-shape="area" role="radio" aria-checked="true" data-i18n>Area</button>
|
||||
<button class="pp-mode-btn" data-shape="line" role="radio" aria-checked="false" data-i18n>Line</button>
|
||||
<button class="pp-mode-btn" data-shape="pin" role="radio" aria-checked="false" data-i18n>Pin</button>
|
||||
<div class="gev-segmented pp-mode-seg" role="radiogroup" aria-label="Shape to draw" data-i18n-aria-label>
|
||||
<button class="gev-segment pp-mode-btn active" data-shape="area" role="radio" aria-checked="true" data-i18n>Area</button>
|
||||
<button class="gev-segment pp-mode-btn" data-shape="line" role="radio" aria-checked="false" data-i18n>Line</button>
|
||||
<button class="gev-segment pp-mode-btn" data-shape="pin" role="radio" aria-checked="false" data-i18n>Pin</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pp-slider-row" id="draw-label-row">
|
||||
@@ -172,7 +172,7 @@
|
||||
<span class="pp-label" data-i18n>Bloom</span>
|
||||
</button>
|
||||
<div class="pp-slider-row" id="bloom-slider-row">
|
||||
<input type="range" class="pp-slider" id="bloom-intensity-slider" min="0" max="200" value="0" aria-label="Bloom intensity" data-i18n-aria-label />
|
||||
<input type="range" class="gev-quantitative-slider pp-slider" id="bloom-intensity-slider" min="0" max="200" value="0" aria-label="Bloom intensity" data-i18n-aria-label />
|
||||
<span class="pp-slider-value" id="bloom-intensity-value">0%</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -182,7 +182,7 @@
|
||||
<span class="pp-label" data-i18n>Sharpen</span>
|
||||
</button>
|
||||
<div class="pp-slider-row" id="sharpen-slider-row">
|
||||
<input type="range" class="pp-slider" id="sharpen-intensity-slider" min="0" max="100" value="49" aria-label="Sharpen intensity" data-i18n-aria-label />
|
||||
<input type="range" class="gev-quantitative-slider pp-slider" id="sharpen-intensity-slider" min="0" max="100" value="49" aria-label="Sharpen intensity" data-i18n-aria-label />
|
||||
<span class="pp-slider-value" id="sharpen-intensity-value">49%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -87,9 +87,9 @@
|
||||
<div class="sl-viewer-bar">
|
||||
<div class="sl-viewer-tools">
|
||||
<button id="sl-viewer-expand" class="scene-btn" type="button" aria-pressed="false" title="Show the street-level view full screen (Esc shrinks it)" data-i18n-title><span class="sl-btn-icon" aria-hidden="true">⤢</span><span class="sl-btn-text" data-i18n>EXPAND</span></button>
|
||||
<div class="sl-seg" role="radiogroup" aria-label="Image fit" data-i18n-aria-label>
|
||||
<button type="button" class="sl-seg-btn is-active" data-sl-render="letterbox" role="radio" aria-checked="true" title="Fit: show the whole photo, with dark bars where its shape differs from the frame" data-i18n-title data-i18n>FIT</button>
|
||||
<button type="button" class="sl-seg-btn" data-sl-render="fill" role="radio" aria-checked="false" tabindex="-1" title="Fill: fill the frame edge to edge, cropping the photo" data-i18n-title data-i18n>FILL</button>
|
||||
<div class="gev-segmented sl-seg" role="radiogroup" aria-label="Image fit" data-i18n-aria-label>
|
||||
<button type="button" class="gev-segment sl-seg-btn is-active" data-sl-render="letterbox" role="radio" aria-checked="true" title="Fit: show the whole photo, with dark bars where its shape differs from the frame" data-i18n-title data-i18n>FIT</button>
|
||||
<button type="button" class="gev-segment sl-seg-btn" data-sl-render="fill" role="radio" aria-checked="false" tabindex="-1" title="Fill: fill the frame edge to edge, cropping the photo" data-i18n-title data-i18n>FILL</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sl-viewer-tools sl-viewer-tools-right">
|
||||
@@ -118,17 +118,19 @@
|
||||
<fieldset id="sl-controls" class="sl-controls">
|
||||
<div id="sl-pano-row" class="sl-filters" role="group" aria-label="Imagery filters" data-i18n-aria-label>
|
||||
<span class="cctv-summary-label" data-i18n>IMAGERY</span>
|
||||
<div class="sl-seg" role="radiogroup" aria-label="Panorama filter" data-i18n-aria-label>
|
||||
<button type="button" class="sl-seg-btn is-active" data-sl-pano="all" role="radio" aria-checked="true" data-i18n>ALL</button>
|
||||
<button type="button" class="sl-seg-btn" data-sl-pano="pano" role="radio" aria-checked="false" tabindex="-1">360°</button>
|
||||
<button type="button" class="sl-seg-btn" data-sl-pano="flat" role="radio" aria-checked="false" tabindex="-1" data-i18n>FLAT</button>
|
||||
<div class="gev-segmented sl-seg" role="radiogroup" aria-label="Panorama filter" data-i18n-aria-label>
|
||||
<button type="button" class="gev-segment sl-seg-btn is-active" data-sl-pano="all" role="radio" aria-checked="true" data-i18n>ALL</button>
|
||||
<button type="button" class="gev-segment sl-seg-btn" data-sl-pano="pano" role="radio" aria-checked="false" tabindex="-1">360°</button>
|
||||
<button type="button" class="gev-segment sl-seg-btn" data-sl-pano="flat" role="radio" aria-checked="false" tabindex="-1" data-i18n>FLAT</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="sl-since-row" class="sl-since">
|
||||
<label class="cctv-summary-label" for="sl-since" data-i18n>SINCE</label>
|
||||
<input id="sl-since" class="sl-range" type="range" min="0" max="8" step="1" value="0" aria-valuetext="ANY DATE" aria-describedby="sl-since-label sl-since-note" />
|
||||
<output id="sl-since-label" class="sl-since-label" for="sl-since" data-i18n>ANY DATE</output>
|
||||
<small id="sl-since-note" class="sl-since-note" hidden></small>
|
||||
<span class="sl-since-value">
|
||||
<output id="sl-since-label" class="sl-since-label" for="sl-since" data-i18n>ANY DATE</output>
|
||||
<small id="sl-since-note" class="sl-since-note" hidden></small>
|
||||
</span>
|
||||
</div>
|
||||
</fieldset>
|
||||
<!-- The hint says how to open imagery; the button does it without a pointer. -->
|
||||
|
||||
@@ -176,7 +176,7 @@ test('parameter rows preserve labels, bounds, precision and initial values', ()
|
||||
assert.equal(label.textContent, '<Amount>');
|
||||
assert.equal(slider.attributes.get('aria-label'), '<Amount>');
|
||||
assert.equal(slider.type, 'range');
|
||||
assert.equal(slider.className, 'param-slider');
|
||||
assert.equal(slider.className, 'gev-quantitative-slider param-slider');
|
||||
assert.equal(slider.min, 0);
|
||||
assert.equal(slider.max, 1);
|
||||
assert.equal(slider.step, '0.01');
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { STYLE_STATUS_LABELS } from './visualPresets.js';
|
||||
import { syncSegmentedControl } from './segmentedControl.js';
|
||||
import { t } from '../i18n/index.js';
|
||||
import { UiLifetime } from './uiLifetime.js';
|
||||
import {
|
||||
@@ -557,11 +558,9 @@ export class VisualSettings {
|
||||
const normalized = normalizeAllocationStrategy(raw);
|
||||
this._detectionAllocationPreference = normalized;
|
||||
setDetectionTuning({ allocationStrategy: normalized });
|
||||
for (const button of this._detectionAllocationBtns) {
|
||||
const active = button.dataset.allocation === normalized;
|
||||
button.classList.toggle('active', active);
|
||||
button.setAttribute('aria-checked', String(active));
|
||||
}
|
||||
syncSegmentedControl(this._detectionAllocationBtns, normalized, {
|
||||
valueOf: (button) => button.dataset.allocation,
|
||||
});
|
||||
if (persist) {
|
||||
try {
|
||||
localStorage.setItem(DETECTION_ALLOCATION_STORAGE_KEY, normalized);
|
||||
|
||||
@@ -0,0 +1,496 @@
|
||||
import { after, before, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { DataLayerManager } from '../data/manager.js';
|
||||
import { CCTV_FOCUS_RESULT } from '../layers/cctv/index.js';
|
||||
import { controlCctv, createGevActionRunner } from './gevActions.js';
|
||||
|
||||
const originalWindow = globalThis.window;
|
||||
before(() => {
|
||||
globalThis.window ??= { clearTimeout, setTimeout, requestIdleCallback: null };
|
||||
});
|
||||
after(() => {
|
||||
if (originalWindow === undefined) delete globalThis.window;
|
||||
else globalThis.window = originalWindow;
|
||||
});
|
||||
|
||||
function viewerFixture() {
|
||||
return {
|
||||
clock: { onTick: { addEventListener: () => () => {} } },
|
||||
scene: { canvas: { addEventListener() {}, removeEventListener() {} } },
|
||||
camera: { moveEnd: { addEventListener() {} } },
|
||||
};
|
||||
}
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
const promise = new Promise((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function runnerFixture(t, dataManager, options = {}) {
|
||||
const runner = createGevActionRunner({
|
||||
viewer: viewerFixture(),
|
||||
styleManager: {},
|
||||
dataManager,
|
||||
...options,
|
||||
});
|
||||
t.after(() => runner.dispose());
|
||||
return runner;
|
||||
}
|
||||
|
||||
// Only the inert module's lifecycle completion is controlled. The manager's
|
||||
// intent queue, cancellation, compensation, events, and runner are production.
|
||||
async function lifecycleFixture(t, targetEnabled) {
|
||||
const viewer = viewerFixture();
|
||||
const manager = new DataLayerManager(viewer);
|
||||
const calls = [];
|
||||
const gates = [];
|
||||
const pending = [];
|
||||
let nextGate = null;
|
||||
let moduleEnabled = false;
|
||||
const phase = targetEnabled ? 'enable' : 'disable';
|
||||
const lifecycle = async (name, options = {}) => {
|
||||
calls.push(name);
|
||||
if (name === phase && nextGate) {
|
||||
const gate = nextGate;
|
||||
nextGate = null;
|
||||
gate.entered.resolve(options.signal);
|
||||
await gate.release.promise;
|
||||
if (gate.outcome instanceof Error) throw gate.outcome;
|
||||
if (gate.outcome === false) return false;
|
||||
}
|
||||
moduleEnabled = name === 'enable';
|
||||
return true;
|
||||
};
|
||||
manager.register({
|
||||
id: 'cctv',
|
||||
name: 'CCTV',
|
||||
source: 'test',
|
||||
updateInterval: -1,
|
||||
init: async () => true,
|
||||
enable: (_viewer, options) => lifecycle('enable', options),
|
||||
disable: (_viewer, options) => lifecycle('disable', options),
|
||||
update: async () => true,
|
||||
getStats: () => ({ count: 0, lastUpdate: 0 }),
|
||||
});
|
||||
if (!targetEnabled)
|
||||
assert.equal(await manager.setEnabled('cctv', true), true);
|
||||
calls.length = 0;
|
||||
const events = [];
|
||||
const unsubscribe = manager.subscribe((event) => events.push(event));
|
||||
const runner = runnerFixture(t, manager, { viewer });
|
||||
t.after(async () => {
|
||||
for (const gate of gates) gate.release.resolve();
|
||||
await Promise.allSettled(pending);
|
||||
unsubscribe();
|
||||
await manager.destroyAll();
|
||||
});
|
||||
return {
|
||||
manager,
|
||||
events,
|
||||
calls,
|
||||
get moduleEnabled() {
|
||||
return moduleEnabled;
|
||||
},
|
||||
gate(outcome = true) {
|
||||
const gate = { entered: deferred(), release: deferred(), outcome };
|
||||
gates.push(gate);
|
||||
nextGate = gate;
|
||||
return gate;
|
||||
},
|
||||
run(action, options = {}) {
|
||||
const work = runner('control_cctv', { action }, options);
|
||||
pending.push(work);
|
||||
return work;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function assertLifecycle(result, manager, enabled, uncertain = false) {
|
||||
assert.equal(result.action, 'control_cctv');
|
||||
assert.equal(result.layerId, 'cctv');
|
||||
assert.equal(result.enabled, enabled);
|
||||
assert.equal(result.lifecycleState, enabled ? 'enabled' : 'disabled');
|
||||
assert.equal(result.lifecycleUncertain, uncertain);
|
||||
assert.deepEqual(manager.getLayerLifecycleState('cctv'), {
|
||||
enabled,
|
||||
lifecycleState: result.lifecycleState,
|
||||
uncertain,
|
||||
});
|
||||
}
|
||||
|
||||
const terminalTypes = new Set([
|
||||
'visibility',
|
||||
'visibility-cancelled',
|
||||
'visibility-failed',
|
||||
'visibility-blocked',
|
||||
]);
|
||||
|
||||
for (const action of ['enable', 'disable']) {
|
||||
const targetEnabled = action === 'enable';
|
||||
for (const outcome of [
|
||||
'success',
|
||||
'false',
|
||||
'throw',
|
||||
'caller-abort',
|
||||
'resource-abort',
|
||||
'late-abort',
|
||||
]) {
|
||||
test(
|
||||
`CCTV ${action}: ${outcome} preserves the manager outcome`,
|
||||
{ timeout: 2000 },
|
||||
async (t) => {
|
||||
const h = await lifecycleFixture(t, targetEnabled);
|
||||
const controller = new AbortController();
|
||||
const failure =
|
||||
outcome === 'resource-abort'
|
||||
? Object.assign(new Error('fixture resource ended'), {
|
||||
name: 'AbortError',
|
||||
})
|
||||
: outcome === 'throw'
|
||||
? new Error('fixture lifecycle failed')
|
||||
: null;
|
||||
const gate = h.gate(failure || (outcome === 'false' ? false : true));
|
||||
let lateAbortObserved = false;
|
||||
if (outcome === 'late-abort') {
|
||||
t.after(
|
||||
h.manager.subscribe((event) => {
|
||||
if (event.type === 'visibility' && event.origin === 'voice') {
|
||||
lateAbortObserved = true;
|
||||
controller.abort();
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
let settled = false;
|
||||
// Uppercase exercises the existing case-insensitive action contract.
|
||||
const work = h.run(
|
||||
outcome === 'success' ? action.toUpperCase() : action,
|
||||
{
|
||||
signal: controller.signal,
|
||||
isCurrent: () => !controller.signal.aborted,
|
||||
},
|
||||
);
|
||||
void work.then(
|
||||
() => {
|
||||
settled = true;
|
||||
},
|
||||
() => {
|
||||
settled = true;
|
||||
},
|
||||
);
|
||||
const moduleSignal = await gate.entered.promise;
|
||||
assert.equal(
|
||||
settled,
|
||||
false,
|
||||
'the tool must wait for lifecycle completion',
|
||||
);
|
||||
if (outcome === 'caller-abort') {
|
||||
controller.abort();
|
||||
assert.ok(
|
||||
moduleSignal,
|
||||
'the manager must receive a lifecycle signal',
|
||||
);
|
||||
assert.equal(
|
||||
moduleSignal.aborted,
|
||||
true,
|
||||
'caller abort must reach the manager lifecycle',
|
||||
);
|
||||
}
|
||||
gate.release.resolve();
|
||||
const result = await work;
|
||||
const committed = outcome === 'success' || outcome === 'late-abort';
|
||||
const cancelled =
|
||||
outcome === 'caller-abort' || outcome === 'resource-abort';
|
||||
assert.equal(result.ok, committed);
|
||||
assert.equal(result.cancelled, cancelled ? true : undefined);
|
||||
assertLifecycle(
|
||||
result,
|
||||
h.manager,
|
||||
committed ? targetEnabled : !targetEnabled,
|
||||
!targetEnabled && (outcome === 'false' || outcome === 'throw'),
|
||||
);
|
||||
assert.equal(
|
||||
h.moduleEnabled,
|
||||
committed ? targetEnabled : !targetEnabled,
|
||||
'the module must match the committed or compensated manager state',
|
||||
);
|
||||
if (cancelled) {
|
||||
assert.equal(result.cancellationReason, outcome);
|
||||
assert.equal(result.phase, action);
|
||||
assert.equal(result.successorIntentEpoch, null);
|
||||
assert.equal(result.successorEnabled, null);
|
||||
assert.equal(result.successorOrigin, null);
|
||||
} else if (!committed) {
|
||||
assert.equal(result.error, `Could not ${action} the requested layer`);
|
||||
}
|
||||
const terminal = h.events.filter(
|
||||
(event) => event.origin === 'voice' && terminalTypes.has(event.type),
|
||||
);
|
||||
assert.equal(
|
||||
terminal.length,
|
||||
1,
|
||||
'each voice intent has one terminal manager event',
|
||||
);
|
||||
assert.equal(
|
||||
terminal[0].type,
|
||||
committed
|
||||
? 'visibility'
|
||||
: cancelled
|
||||
? 'visibility-cancelled'
|
||||
: 'visibility-failed',
|
||||
);
|
||||
if (outcome === 'resource-abort')
|
||||
assert.equal(controller.signal.aborted, false);
|
||||
if (outcome === 'late-abort') assert.equal(lateAbortObserved, true);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
test(
|
||||
`CCTV ${action}: already-aborted caller cannot enter lifecycle work`,
|
||||
{ timeout: 2000 },
|
||||
async (t) => {
|
||||
const h = await lifecycleFixture(t, targetEnabled);
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
const result = await h.run(action, { signal: controller.signal });
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.cancelled, true);
|
||||
assert.equal(result.cancellationReason, 'caller-abort');
|
||||
assert.equal(result.phase, 'queued');
|
||||
assertLifecycle(result, h.manager, !targetEnabled);
|
||||
assert.equal(h.moduleEnabled, !targetEnabled);
|
||||
assert.deepEqual(h.calls, []);
|
||||
assert.equal(
|
||||
h.events.filter((event) => terminalTypes.has(event.type)).length,
|
||||
1,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
test(
|
||||
'CCTV toggles retain missing-module errors without calling visibility',
|
||||
{ timeout: 2000 },
|
||||
async (t) => {
|
||||
for (const layers of [new Map(), new Map([['cctv', {}]])]) {
|
||||
const runner = runnerFixture(t, {
|
||||
layers,
|
||||
getAll: () => [],
|
||||
setEnabled: () =>
|
||||
assert.fail('missing CCTV must not dispatch visibility'),
|
||||
});
|
||||
for (const action of ['enable', 'disable']) {
|
||||
assert.deepEqual(await runner('control_cctv', { action }), {
|
||||
ok: false,
|
||||
action: 'control_cctv',
|
||||
error: 'CCTV layer unavailable',
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test('exported controlCctv refuses toggles without calling the manager', async () => {
|
||||
const manager = {
|
||||
layers: new Map([['cctv', { module: {} }]]),
|
||||
setEnabled: () => assert.fail('direct helper must not change visibility'),
|
||||
isEnabled: () => assert.fail('direct toggle must not read enabled state'),
|
||||
};
|
||||
for (const action of ['ENABLE', 'disable']) {
|
||||
const result = await controlCctv(manager, { action });
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.action, 'control_cctv');
|
||||
assert.match(result.error, /set_layer_visibility/);
|
||||
}
|
||||
});
|
||||
|
||||
test(
|
||||
'CCTV fallback managers report rejection, transitional state, and uncertainty',
|
||||
{ timeout: 2000 },
|
||||
async (t) => {
|
||||
for (const enabled of [true, false]) {
|
||||
const action = enabled ? 'enable' : 'disable';
|
||||
for (const mode of [
|
||||
'rejection',
|
||||
'mismatch',
|
||||
'transitional',
|
||||
'uncertain',
|
||||
]) {
|
||||
const actualEnabled =
|
||||
mode === 'rejection' || mode === 'mismatch' ? !enabled : enabled;
|
||||
const lifecycleState =
|
||||
mode === 'transitional'
|
||||
? enabled
|
||||
? 'enabling'
|
||||
: 'disabling'
|
||||
: actualEnabled
|
||||
? 'enabled'
|
||||
: 'disabled';
|
||||
const manager = {
|
||||
layers: new Map([['cctv', { module: {} }]]),
|
||||
getAll: () => [{ id: 'cctv', name: 'CCTV' }],
|
||||
isEnabled: () => actualEnabled,
|
||||
getLayerLifecycleState: () => ({
|
||||
enabled: actualEnabled,
|
||||
lifecycleState,
|
||||
uncertain: mode === 'uncertain',
|
||||
}),
|
||||
async setEnabled() {
|
||||
if (mode === 'rejection') throw new Error('adapter rejected');
|
||||
return true;
|
||||
},
|
||||
};
|
||||
const runner = runnerFixture(t, manager);
|
||||
const result = await runner('control_cctv', { action });
|
||||
assert.equal(result.ok, false, `${action}: ${mode}`);
|
||||
assert.equal(result.action, 'control_cctv');
|
||||
assert.equal(result.enabled, actualEnabled);
|
||||
assert.equal(result.lifecycleState, lifecycleState);
|
||||
assert.equal(result.lifecycleUncertain, mode === 'uncertain');
|
||||
assert.equal(
|
||||
result.error,
|
||||
mode === 'rejection'
|
||||
? 'adapter rejected'
|
||||
: `Could not ${action} the requested layer`,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'CCTV visibility composes only its outer speech envelope',
|
||||
{ timeout: 2000 },
|
||||
async (t) => {
|
||||
let enabled = false;
|
||||
const calls = [];
|
||||
const runner = runnerFixture(
|
||||
t,
|
||||
{
|
||||
layers: new Map([['cctv', { module: {} }]]),
|
||||
getAll: () => [{ id: 'cctv', name: 'CCTV' }],
|
||||
isEnabled: () => enabled,
|
||||
async setEnabled(_id, value) {
|
||||
enabled = value;
|
||||
return true;
|
||||
},
|
||||
},
|
||||
{
|
||||
speechBuilders: {
|
||||
control_cctv(result, args) {
|
||||
calls.push(['cctv', result.action, args.action]);
|
||||
return {
|
||||
say: 'CCTV response',
|
||||
display: { title: 'CCTV', lines: [] },
|
||||
};
|
||||
},
|
||||
set_layer_visibility() {
|
||||
calls.push(['generic']);
|
||||
return { say: 'Layer response' };
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
const result = await runner('control_cctv', { action: 'enable' });
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.action, 'control_cctv');
|
||||
assert.equal(result.say, 'CCTV response');
|
||||
assert.deepEqual(result.display, { title: 'CCTV', lines: [] });
|
||||
assert.deepEqual(calls, [['cctv', 'control_cctv', 'enable']]);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'CCTV non-toggle actions retain camera navigation and durable coverage',
|
||||
{ timeout: 2000 },
|
||||
async (t) => {
|
||||
const calls = [];
|
||||
const cameras = [
|
||||
{ id: 'a', name: 'Camera A' },
|
||||
{ id: 'b', name: 'Camera B' },
|
||||
];
|
||||
let activeCameraId = 'a';
|
||||
let coverageMode = 'viewshed';
|
||||
const module = {
|
||||
getUIState: () => ({
|
||||
cameras,
|
||||
activeCameraId,
|
||||
activeCamera: cameras.find((camera) => camera.id === activeCameraId),
|
||||
coverageMode,
|
||||
showCoverage: coverageMode !== 'off',
|
||||
}),
|
||||
selectCamera(id) {
|
||||
calls.push(['select', id]);
|
||||
activeCameraId = id;
|
||||
return true;
|
||||
},
|
||||
cycleCamera(step) {
|
||||
calls.push(['cycle', step]);
|
||||
activeCameraId = step > 0 ? 'b' : 'a';
|
||||
return activeCameraId;
|
||||
},
|
||||
focusNearest(options) {
|
||||
calls.push(['nearest', options]);
|
||||
activeCameraId = 'a';
|
||||
return 'a';
|
||||
},
|
||||
focusCamera(id, duration) {
|
||||
calls.push(['focus', id, duration]);
|
||||
return CCTV_FOCUS_RESULT.TRACKING_HOLDS_VIEW;
|
||||
},
|
||||
};
|
||||
let superseded = 0;
|
||||
const runner = runnerFixture(
|
||||
t,
|
||||
{
|
||||
layers: new Map([['cctv', { module }]]),
|
||||
isEnabled: () => true,
|
||||
setEnabled: () =>
|
||||
assert.fail('non-toggle action must not dispatch visibility'),
|
||||
setLayerParams(id, params, options) {
|
||||
calls.push(['params', id, params, options]);
|
||||
coverageMode = params.coverageMode;
|
||||
},
|
||||
},
|
||||
{
|
||||
styleManager: {
|
||||
supersedeDeferredNavigation() {
|
||||
superseded += 1;
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
for (const args of [
|
||||
{ action: 'select', cameraQuery: 'Camera B' },
|
||||
{ action: 'next' },
|
||||
{ action: 'prev' },
|
||||
{ action: 'nearest' },
|
||||
{ action: 'focus' },
|
||||
]) {
|
||||
const result = await runner('control_cctv', args);
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.action, 'control_cctv');
|
||||
assert.match(result.error, /tracking holds the view/);
|
||||
}
|
||||
const coverage = await runner('control_cctv', { action: 'coverage' });
|
||||
assert.equal(coverage.ok, true);
|
||||
assert.equal(coverage.coverageMode, 'off');
|
||||
assert.equal(superseded, 5, 'coverage must not acquire camera navigation');
|
||||
assert.deepEqual(calls, [
|
||||
['select', 'b'],
|
||||
['focus', 'b', 1.8],
|
||||
['cycle', 1],
|
||||
['focus', 'b', 1.8],
|
||||
['cycle', -1],
|
||||
['focus', 'a', 1.8],
|
||||
['nearest', { focus: false }],
|
||||
['focus', 'a', 1.8],
|
||||
['focus', 'a', undefined],
|
||||
['params', 'cctv', { coverageMode: 'off' }, { origin: 'voice' }],
|
||||
]);
|
||||
},
|
||||
);
|
||||
+14
-5
@@ -1092,6 +1092,18 @@ export function createGevActionRunner({
|
||||
}
|
||||
|
||||
if (name === 'control_cctv') {
|
||||
const action = String(args.action || '').toLowerCase();
|
||||
if (
|
||||
(action === 'enable' || action === 'disable') &&
|
||||
dataManager.layers.get('cctv')?.module
|
||||
) {
|
||||
const result = await runGevAction(
|
||||
'set_layer_visibility',
|
||||
{ layerId: 'cctv', enabled: action === 'enable' },
|
||||
runOptions,
|
||||
);
|
||||
return { ...result, action: 'control_cctv' };
|
||||
}
|
||||
return controlCctv(dataManager, args, styleManager);
|
||||
}
|
||||
|
||||
@@ -1432,13 +1444,10 @@ export async function controlCctv(dataManager, args = {}, styleManager = null) {
|
||||
}
|
||||
|
||||
if (action === 'enable' || action === 'disable') {
|
||||
await dataManager.setEnabled('cctv', action === 'enable', {
|
||||
origin: 'voice',
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
ok: false,
|
||||
action: 'control_cctv',
|
||||
enabled: dataManager.isEnabled('cctv'),
|
||||
error: 'Enable or disable CCTV through set_layer_visibility',
|
||||
};
|
||||
}
|
||||
if (!dataManager.isEnabled('cctv')) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/* Preserve component and responsive rule order in one stylesheet entry. */
|
||||
@import './src/ui/styles/foundation.css';
|
||||
@import './src/ui/styles/slider.css';
|
||||
@import './src/ui/styles/controls.css';
|
||||
@import './src/ui/styles/location.css';
|
||||
@import './src/ui/styles/status.css';
|
||||
@@ -27,4 +28,5 @@
|
||||
|
||||
@import './src/ui/styles/weather.css';
|
||||
@import './src/ui/styles/panel-surfaces.css';
|
||||
@import './src/ui/styles/segmented-control.css';
|
||||
@import './src/ui/styles/cyber.css';
|
||||
Reference in new issue
Block a user