mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
refactor: separate Layers panel presentation from lifecycle actions
This commit is contained in:
1 parent
31f1923633
commit
07cf1695ff
14 files changed
+665
-398
No files matched your search
@@ -13,6 +13,8 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
- Separate Layers panel presentation and clear-control bindings from layer lifecycle operations; revoke listeners and subscriptions on replacement or teardown.
|
||||||
|
|
||||||
- Extract Map Source controls with listener cleanup and protection against obsolete selection feedback.
|
- Extract Map Source controls with listener cleanup and protection against obsolete selection feedback.
|
||||||
|
|
||||||
- Separate visual effects, presets and animation from Display controls, with explicit stage ownership and teardown.
|
- Separate visual effects, presets and animation from Display controls, with explicit stage ownership and teardown.
|
||||||
|
|||||||
@@ -269,3 +269,10 @@ it imports no renderer or application. Source construction and availability poli
|
|||||||
remain with the map controller. Rebuilding controls removes their previous chip
|
remain with the map controller. Rebuilding controls removes their previous chip
|
||||||
listeners, and destruction suppresses late completions without owning or destroying
|
listeners, and destruction suppresses late completions without owning or destroying
|
||||||
the supplied controller.
|
the supplied controller.
|
||||||
|
|
||||||
|
## Layer panel
|
||||||
|
|
||||||
|
`ui/layers` exports the Layers panel and clear-control binding. Callers supply
|
||||||
|
snapshots, row descriptors, subscriptions and actions; the component imports no
|
||||||
|
layer implementation or application bootstrap. Layer transactions remain with
|
||||||
|
the caller. Hidden-page refresh scheduling remains an explicit callback.
|
||||||
@@ -1,5 +1,14 @@
|
|||||||
# God's Eye View Current State
|
# God's Eye View Current State
|
||||||
|
|
||||||
|
## Layer panel ownership
|
||||||
|
|
||||||
|
Layer rows, feed feedback, counts, focus-preserving chips and toggle listeners
|
||||||
|
are owned by a renderer-free panel component. The layer manager supplies current
|
||||||
|
snapshots, lifecycle actions and row descriptors. Remount and teardown remove
|
||||||
|
listeners and row subscriptions; obsolete completions do not repaint old rows.
|
||||||
|
The clear control presents busy state while its existing action owns the transaction.
|
||||||
|
|
||||||
|
|
||||||
## Map Source control ownership
|
## Map Source control ownership
|
||||||
|
|
||||||
Map Source controls own chip listeners, source-state subscriptions and selection
|
Map Source controls own chip listeners, source-state subscriptions and selection
|
||||||
|
|||||||
+3
-2
@@ -2,7 +2,7 @@
|
|||||||
"name": "gods-eye-view",
|
"name": "gods-eye-view",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.1",
|
"version": "0.1.1",
|
||||||
"description": "A real-time intelligence console for planet Earth \u2014 photorealistic 3D globe, live aircraft/ships/satellites/earthquakes/CCTV, and hands-free voice control. Runs in a browser.",
|
"description": "A real-time intelligence console for planet Earth — photorealistic 3D globe, live aircraft/ships/satellites/earthquakes/CCTV, and hands-free voice control. Runs in a browser.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": "Bilawal Sidhu",
|
"author": "Bilawal Sidhu",
|
||||||
@@ -126,6 +126,7 @@
|
|||||||
"./ui/display": "./src/ui/displayControls.js",
|
"./ui/display": "./src/ui/displayControls.js",
|
||||||
"./ui/effects": "./src/ui/effects.js",
|
"./ui/effects": "./src/ui/effects.js",
|
||||||
"./ui/effects/bloom": "./src/bloom.js",
|
"./ui/effects/bloom": "./src/bloom.js",
|
||||||
"./ui/maps": "./src/ui/mapSource.js"
|
"./ui/maps": "./src/ui/mapSource.js",
|
||||||
|
"./ui/layers": "./src/ui/layers.js"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -153,5 +153,8 @@
|
|||||||
"src/ui/mapSourceControls.js",
|
"src/ui/mapSourceControls.js",
|
||||||
"src/ui/mapSourceControls.test.mjs",
|
"src/ui/mapSourceControls.test.mjs",
|
||||||
"src/mapStackChips.js",
|
"src/mapStackChips.js",
|
||||||
"scripts/qa-map-source-controls.mjs"
|
"scripts/qa-map-source-controls.mjs",
|
||||||
|
"src/ui/layers.js",
|
||||||
|
"src/ui/layerPanel.js",
|
||||||
|
"src/ui/clearLayersControl.js"
|
||||||
]
|
]
|
||||||
+166
-50
@@ -10,27 +10,47 @@
|
|||||||
"src/data/localGeojsonCore.js",
|
"src/data/localGeojsonCore.js",
|
||||||
"src/data/localGeojsonLod.js"
|
"src/data/localGeojsonLod.js"
|
||||||
],
|
],
|
||||||
"external": ["cesium"]
|
"external": [
|
||||||
|
"cesium"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"application": {
|
"application": {
|
||||||
"exports": ["./application"],
|
"exports": [
|
||||||
"modules": ["src/app/application.js"],
|
"./application"
|
||||||
|
],
|
||||||
|
"modules": [
|
||||||
|
"src/app/application.js"
|
||||||
|
],
|
||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
"viewer": {
|
"viewer": {
|
||||||
"exports": ["./application/viewer"],
|
"exports": [
|
||||||
"modules": ["src/app/viewer.js"],
|
"./application/viewer"
|
||||||
"external": ["cesium"]
|
],
|
||||||
|
"modules": [
|
||||||
|
"src/app/viewer.js"
|
||||||
|
],
|
||||||
|
"external": [
|
||||||
|
"cesium"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"vite-build": {
|
"vite-build": {
|
||||||
"runtime": "node",
|
"runtime": "node",
|
||||||
"exports": ["./build/vite"],
|
"exports": [
|
||||||
"modules": ["build/vite.js"],
|
"./build/vite"
|
||||||
"external": ["vite-plugin-cesium"]
|
],
|
||||||
|
"modules": [
|
||||||
|
"build/vite.js"
|
||||||
|
],
|
||||||
|
"external": [
|
||||||
|
"vite-plugin-cesium"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"live-providers": {
|
"live-providers": {
|
||||||
"runtime": "node",
|
"runtime": "node",
|
||||||
"exports": ["./server/providers/live"],
|
"exports": [
|
||||||
|
"./server/providers/live"
|
||||||
|
],
|
||||||
"modules": [
|
"modules": [
|
||||||
"server/providers/live.js",
|
"server/providers/live.js",
|
||||||
"server/providers/common/http.js",
|
"server/providers/common/http.js",
|
||||||
@@ -45,16 +65,24 @@
|
|||||||
"src/data/aisStreamAdapter.js",
|
"src/data/aisStreamAdapter.js",
|
||||||
"src/data/aisWatchdog.js"
|
"src/data/aisWatchdog.js"
|
||||||
],
|
],
|
||||||
"external": ["ws"]
|
"external": [
|
||||||
|
"ws"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"adsb-lol-source": {
|
"adsb-lol-source": {
|
||||||
"exports": ["./sources/adsb-lol"],
|
"exports": [
|
||||||
"modules": ["src/data/adsbLolFallback.js"],
|
"./sources/adsb-lol"
|
||||||
|
],
|
||||||
|
"modules": [
|
||||||
|
"src/data/adsbLolFallback.js"
|
||||||
|
],
|
||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
"place-providers": {
|
"place-providers": {
|
||||||
"runtime": "node",
|
"runtime": "node",
|
||||||
"exports": ["./server/providers/places"],
|
"exports": [
|
||||||
|
"./server/providers/places"
|
||||||
|
],
|
||||||
"modules": [
|
"modules": [
|
||||||
"server/providers/places.js",
|
"server/providers/places.js",
|
||||||
"server/providers/places/google.js",
|
"server/providers/places/google.js",
|
||||||
@@ -69,13 +97,19 @@
|
|||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
"place-payloads": {
|
"place-payloads": {
|
||||||
"exports": ["./sources/places"],
|
"exports": [
|
||||||
"modules": ["src/data/placeProviderPayloads.js"],
|
"./sources/places"
|
||||||
|
],
|
||||||
|
"modules": [
|
||||||
|
"src/data/placeProviderPayloads.js"
|
||||||
|
],
|
||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
"space-providers": {
|
"space-providers": {
|
||||||
"runtime": "node",
|
"runtime": "node",
|
||||||
"exports": ["./server/providers/space"],
|
"exports": [
|
||||||
|
"./server/providers/space"
|
||||||
|
],
|
||||||
"modules": [
|
"modules": [
|
||||||
"server/providers/space.js",
|
"server/providers/space.js",
|
||||||
"server/providers/space/celestrak.js",
|
"server/providers/space/celestrak.js",
|
||||||
@@ -86,13 +120,19 @@
|
|||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
"space-requests": {
|
"space-requests": {
|
||||||
"exports": ["./sources/space"],
|
"exports": [
|
||||||
"modules": ["src/data/spaceProviderRequests.js"],
|
"./sources/space"
|
||||||
|
],
|
||||||
|
"modules": [
|
||||||
|
"src/data/spaceProviderRequests.js"
|
||||||
|
],
|
||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
"terrain-provider": {
|
"terrain-provider": {
|
||||||
"runtime": "node",
|
"runtime": "node",
|
||||||
"exports": ["./server/providers/terrain"],
|
"exports": [
|
||||||
|
"./server/providers/terrain"
|
||||||
|
],
|
||||||
"modules": [
|
"modules": [
|
||||||
"server/providers/terrain.js",
|
"server/providers/terrain.js",
|
||||||
"src/data/terrainHeightsProxy.js"
|
"src/data/terrainHeightsProxy.js"
|
||||||
@@ -100,30 +140,50 @@
|
|||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
"terrain-source": {
|
"terrain-source": {
|
||||||
"exports": ["./sources/terrain"],
|
"exports": [
|
||||||
"modules": ["src/data/terrainHeightsProxy.js"],
|
"./sources/terrain"
|
||||||
|
],
|
||||||
|
"modules": [
|
||||||
|
"src/data/terrainHeightsProxy.js"
|
||||||
|
],
|
||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
"traffic-provider": {
|
"traffic-provider": {
|
||||||
"runtime": "node",
|
"runtime": "node",
|
||||||
"exports": ["./server/providers/traffic"],
|
"exports": [
|
||||||
"modules": ["server/providers/traffic.js", "src/data/tomtomTiles.js"],
|
"./server/providers/traffic"
|
||||||
|
],
|
||||||
|
"modules": [
|
||||||
|
"server/providers/traffic.js",
|
||||||
|
"src/data/tomtomTiles.js"
|
||||||
|
],
|
||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
"traffic-source": {
|
"traffic-source": {
|
||||||
"exports": ["./sources/traffic"],
|
"exports": [
|
||||||
"modules": ["src/data/tomtomTiles.js"],
|
"./sources/traffic"
|
||||||
|
],
|
||||||
|
"modules": [
|
||||||
|
"src/data/tomtomTiles.js"
|
||||||
|
],
|
||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
"firms-provider": {
|
"firms-provider": {
|
||||||
"runtime": "node",
|
"runtime": "node",
|
||||||
"exports": ["./server/providers/firms"],
|
"exports": [
|
||||||
"modules": ["server/providers/firms.js", "src/data/firmsCsv.js"],
|
"./server/providers/firms"
|
||||||
|
],
|
||||||
|
"modules": [
|
||||||
|
"server/providers/firms.js",
|
||||||
|
"src/data/firmsCsv.js"
|
||||||
|
],
|
||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
"gbfs-provider": {
|
"gbfs-provider": {
|
||||||
"runtime": "node",
|
"runtime": "node",
|
||||||
"exports": ["./server/providers/gbfs"],
|
"exports": [
|
||||||
|
"./server/providers/gbfs"
|
||||||
|
],
|
||||||
"modules": [
|
"modules": [
|
||||||
"server/providers/gbfs.js",
|
"server/providers/gbfs.js",
|
||||||
"server/providers/common/http.js",
|
"server/providers/common/http.js",
|
||||||
@@ -132,13 +192,19 @@
|
|||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
"gbfs-source": {
|
"gbfs-source": {
|
||||||
"exports": ["./sources/gbfs"],
|
"exports": [
|
||||||
"modules": ["src/data/gbfsSource.js"],
|
"./sources/gbfs"
|
||||||
|
],
|
||||||
|
"modules": [
|
||||||
|
"src/data/gbfsSource.js"
|
||||||
|
],
|
||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
"cctv-provider": {
|
"cctv-provider": {
|
||||||
"runtime": "node",
|
"runtime": "node",
|
||||||
"exports": ["./server/providers/cctv"],
|
"exports": [
|
||||||
|
"./server/providers/cctv"
|
||||||
|
],
|
||||||
"modules": [
|
"modules": [
|
||||||
"server/providers/cctv.js",
|
"server/providers/cctv.js",
|
||||||
"server/providers/cctv/catalog.js",
|
"server/providers/cctv/catalog.js",
|
||||||
@@ -155,7 +221,9 @@
|
|||||||
},
|
},
|
||||||
"radio-provider": {
|
"radio-provider": {
|
||||||
"runtime": "node",
|
"runtime": "node",
|
||||||
"exports": ["./server/providers/radio"],
|
"exports": [
|
||||||
|
"./server/providers/radio"
|
||||||
|
],
|
||||||
"modules": [
|
"modules": [
|
||||||
"server/providers/radio.js",
|
"server/providers/radio.js",
|
||||||
"server/providers/radio/catalog.js",
|
"server/providers/radio/catalog.js",
|
||||||
@@ -169,7 +237,9 @@
|
|||||||
},
|
},
|
||||||
"overpass-provider": {
|
"overpass-provider": {
|
||||||
"runtime": "node",
|
"runtime": "node",
|
||||||
"exports": ["./server/providers/overpass"],
|
"exports": [
|
||||||
|
"./server/providers/overpass"
|
||||||
|
],
|
||||||
"modules": [
|
"modules": [
|
||||||
"server/providers/common/geo.js",
|
"server/providers/common/geo.js",
|
||||||
"server/providers/common/http.js",
|
"server/providers/common/http.js",
|
||||||
@@ -188,7 +258,9 @@
|
|||||||
},
|
},
|
||||||
"military-installations-provider": {
|
"military-installations-provider": {
|
||||||
"runtime": "node",
|
"runtime": "node",
|
||||||
"exports": ["./server/providers/military-installations"],
|
"exports": [
|
||||||
|
"./server/providers/military-installations"
|
||||||
|
],
|
||||||
"modules": [
|
"modules": [
|
||||||
"server/providers/common/http.js",
|
"server/providers/common/http.js",
|
||||||
"server/providers/common/query.js",
|
"server/providers/common/query.js",
|
||||||
@@ -205,7 +277,9 @@
|
|||||||
},
|
},
|
||||||
"regional-provider": {
|
"regional-provider": {
|
||||||
"runtime": "node",
|
"runtime": "node",
|
||||||
"exports": ["./server/providers/regional"],
|
"exports": [
|
||||||
|
"./server/providers/regional"
|
||||||
|
],
|
||||||
"modules": [
|
"modules": [
|
||||||
"server/providers/common/http.js",
|
"server/providers/common/http.js",
|
||||||
"server/providers/common/query.js",
|
"server/providers/common/query.js",
|
||||||
@@ -224,7 +298,9 @@
|
|||||||
},
|
},
|
||||||
"openai-provider": {
|
"openai-provider": {
|
||||||
"runtime": "node",
|
"runtime": "node",
|
||||||
"exports": ["./server/providers/openai"],
|
"exports": [
|
||||||
|
"./server/providers/openai"
|
||||||
|
],
|
||||||
"modules": [
|
"modules": [
|
||||||
"server/providers/common/rate-limit.js",
|
"server/providers/common/rate-limit.js",
|
||||||
"server/providers/common/request.js",
|
"server/providers/common/request.js",
|
||||||
@@ -244,7 +320,9 @@
|
|||||||
},
|
},
|
||||||
"standalone-key-setup": {
|
"standalone-key-setup": {
|
||||||
"runtime": "node",
|
"runtime": "node",
|
||||||
"exports": ["./server/standalone/key-setup"],
|
"exports": [
|
||||||
|
"./server/standalone/key-setup"
|
||||||
|
],
|
||||||
"modules": [
|
"modules": [
|
||||||
"scripts/pinokio-environment.mjs",
|
"scripts/pinokio-environment.mjs",
|
||||||
"server/providers/common/source-root.js",
|
"server/providers/common/source-root.js",
|
||||||
@@ -255,7 +333,9 @@
|
|||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
"place-search": {
|
"place-search": {
|
||||||
"exports": ["./search"],
|
"exports": [
|
||||||
|
"./search"
|
||||||
|
],
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/search/index.js",
|
"src/search/index.js",
|
||||||
"src/search/placeSearch.js",
|
"src/search/placeSearch.js",
|
||||||
@@ -265,17 +345,27 @@
|
|||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
"panel-disclosure": {
|
"panel-disclosure": {
|
||||||
"exports": ["./ui/panels"],
|
"exports": [
|
||||||
"modules": ["src/ui/panelDisclosure.js"],
|
"./ui/panels"
|
||||||
|
],
|
||||||
|
"modules": [
|
||||||
|
"src/ui/panelDisclosure.js"
|
||||||
|
],
|
||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
"surface-keyboard": {
|
"surface-keyboard": {
|
||||||
"exports": ["./ui/surfaces"],
|
"exports": [
|
||||||
"modules": ["src/ui/surfaceKeyboard.js"],
|
"./ui/surfaces"
|
||||||
|
],
|
||||||
|
"modules": [
|
||||||
|
"src/ui/surfaceKeyboard.js"
|
||||||
|
],
|
||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
"panel-rail-layout": {
|
"panel-rail-layout": {
|
||||||
"exports": ["./ui/layout"],
|
"exports": [
|
||||||
|
"./ui/layout"
|
||||||
|
],
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/ui/panelRails.js",
|
"src/ui/panelRails.js",
|
||||||
"src/ui/leftPanelRail.js",
|
"src/ui/leftPanelRail.js",
|
||||||
@@ -287,7 +377,9 @@
|
|||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
"visual-input": {
|
"visual-input": {
|
||||||
"exports": ["./ui/input"],
|
"exports": [
|
||||||
|
"./ui/input"
|
||||||
|
],
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/ui/visualInput.js",
|
"src/ui/visualInput.js",
|
||||||
"src/ui/applicationShortcuts.js",
|
"src/ui/applicationShortcuts.js",
|
||||||
@@ -296,12 +388,19 @@
|
|||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
"display-controls": {
|
"display-controls": {
|
||||||
"exports": ["./ui/display"],
|
"exports": [
|
||||||
"modules": ["src/ui/displayControls.js"],
|
"./ui/display"
|
||||||
|
],
|
||||||
|
"modules": [
|
||||||
|
"src/ui/displayControls.js"
|
||||||
|
],
|
||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
"visual-effects": {
|
"visual-effects": {
|
||||||
"exports": ["./ui/effects", "./ui/effects/bloom"],
|
"exports": [
|
||||||
|
"./ui/effects",
|
||||||
|
"./ui/effects/bloom"
|
||||||
|
],
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/ui/effects.js",
|
"src/ui/effects.js",
|
||||||
"src/ui/visualEffects.js",
|
"src/ui/visualEffects.js",
|
||||||
@@ -314,10 +413,14 @@
|
|||||||
"src/styles/noir.js",
|
"src/styles/noir.js",
|
||||||
"src/styles/snow.js"
|
"src/styles/snow.js"
|
||||||
],
|
],
|
||||||
"external": ["cesium"]
|
"external": [
|
||||||
|
"cesium"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"map-source-controls": {
|
"map-source-controls": {
|
||||||
"exports": ["./ui/maps"],
|
"exports": [
|
||||||
|
"./ui/maps"
|
||||||
|
],
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/ui/mapSource.js",
|
"src/ui/mapSource.js",
|
||||||
"src/ui/mapSourceControls.js",
|
"src/ui/mapSourceControls.js",
|
||||||
@@ -325,5 +428,18 @@
|
|||||||
"src/keySetupCore.mjs"
|
"src/keySetupCore.mjs"
|
||||||
],
|
],
|
||||||
"external": []
|
"external": []
|
||||||
|
},
|
||||||
|
"layer-panel": {
|
||||||
|
"exports": [
|
||||||
|
"./ui/layers"
|
||||||
|
],
|
||||||
|
"modules": [
|
||||||
|
"src/ui/layers.js",
|
||||||
|
"src/ui/layerPanel.js",
|
||||||
|
"src/ui/clearLayersControl.js",
|
||||||
|
"src/loadingFeedback.js",
|
||||||
|
"src/data/installationFeedback.js"
|
||||||
|
],
|
||||||
|
"external": []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -746,7 +746,9 @@ test('Global Context names its mixed contact cycle without changing the stable m
|
|||||||
|
|
||||||
test('Global Context uses its dedicated right rail without a duplicate Data Layers row', () => {
|
test('Global Context uses its dedicated right rail without a duplicate Data Layers row', () => {
|
||||||
assert.match(contextLayer, /id:\s*'military-awareness'[\s\S]*?showInTogglePanel:\s*false/);
|
assert.match(contextLayer, /id:\s*'military-awareness'[\s\S]*?showInTogglePanel:\s*false/);
|
||||||
assert.match(manager, /if \(!layer\.showInTogglePanel\) continue;/);
|
const panel = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'layerPanel.js'), 'utf8');
|
||||||
|
assert.match(panel, /if \(!layer\.showInTogglePanel\) continue;/);
|
||||||
|
assert.match(manager, /getLayers: \(\) => this\.getAll\(\)/);
|
||||||
assert.match(html, /id="global-context-panel"/);
|
assert.match(html, /id="global-context-panel"/);
|
||||||
assert.match(html, /id="global-context-flights-btn"/);
|
assert.match(html, /id="global-context-flights-btn"/);
|
||||||
assert.match(html, /id="global-context-missions-btn"/);
|
assert.match(html, /id="global-context-missions-btn"/);
|
||||||
|
|||||||
@@ -157,5 +157,8 @@ test('Context async action buttons remain focused while busy', () => {
|
|||||||
assert.match(radioSync, new RegExp(`${name}\\.disabled = false`));
|
assert.match(radioSync, new RegExp(`${name}\\.disabled = false`));
|
||||||
}
|
}
|
||||||
assert.doesNotMatch(clear, /_clearSelectedLayersBtn\.disabled\s*=\s*true/);
|
assert.doesNotMatch(clear, /_clearSelectedLayersBtn\.disabled\s*=\s*true/);
|
||||||
assert.match(clear, /_clearSelectedLayersBtn\.setAttribute\('aria-busy', 'true'\)/);
|
assert.match(clear, /_clearLayersControl\?\.setBusy\(true\)/);
|
||||||
|
const control = readFileSync(new URL('./ui/clearLayersControl.js', import.meta.url), 'utf8');
|
||||||
|
assert.match(control, /button\.setAttribute\('aria-busy', String\(busy\)\)/);
|
||||||
|
assert.doesNotMatch(control, /button\.disabled\s*=/);
|
||||||
});
|
});
|
||||||
+25
-330
@@ -1,5 +1,6 @@
|
|||||||
import { governorRequestRender } from '../renderGovernor.js';
|
import { governorRequestRender } from '../renderGovernor.js';
|
||||||
import { GUIDANCE_STATUSES } from '../loadingFeedback.js';
|
import { LayerPanel, layerFeedState } from '../ui/layers.js';
|
||||||
|
export { layerFeedState } from '../ui/layers.js';
|
||||||
import { markDetectionSourcesChanged } from './detection.js';
|
import { markDetectionSourcesChanged } from './detection.js';
|
||||||
function cloneLayerParams(value) {
|
function cloneLayerParams(value) {
|
||||||
if (Array.isArray(value)) return value.map(cloneLayerParams);
|
if (Array.isArray(value)) return value.map(cloneLayerParams);
|
||||||
@@ -11,15 +12,6 @@ function cloneLayerParams(value) {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FEED_STATE_LABELS = Object.freeze({
|
|
||||||
nominal: 'ON',
|
|
||||||
loading: 'LOADING',
|
|
||||||
degraded: 'DEGRADED',
|
|
||||||
stale: 'STALE',
|
|
||||||
fallback: 'FALLBACK',
|
|
||||||
unavailable: 'UNAVAILABLE',
|
|
||||||
});
|
|
||||||
|
|
||||||
const SUPERSEDED_VISIBILITY_INTENT = Symbol('superseded-visibility-intent');
|
const SUPERSEDED_VISIBILITY_INTENT = Symbol('superseded-visibility-intent');
|
||||||
const VALID_LAYER_SERIALIZATION_DISPOSITIONS = new Set([
|
const VALID_LAYER_SERIALIZATION_DISPOSITIONS = new Set([
|
||||||
'enabled-only',
|
'enabled-only',
|
||||||
@@ -65,53 +57,6 @@ function refreshFailureFromStats(stats, label) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalize heterogeneous layer stats into one honest control-chip state.
|
|
||||||
* @param {object|null} stats Layer getStats() result.
|
|
||||||
* @returns {'nominal'|'loading'|'degraded'|'stale'|'fallback'|'unavailable'} Feed state.
|
|
||||||
*/
|
|
||||||
export function layerFeedState(stats = {}) {
|
|
||||||
const state = stats || {};
|
|
||||||
const status = typeof state.status === 'string' ? state.status.toLowerCase() : '';
|
|
||||||
const source = `${state.source || ''} ${state.coverage || ''}`;
|
|
||||||
const hasExplicitFallback = typeof state.fallback === 'boolean';
|
|
||||||
const hasPriorData = Number(state.count) > 0 || Boolean(state.lastUpdate);
|
|
||||||
const presentedError = state.error || state.lastError || state.managerRefreshError;
|
|
||||||
if (['unavailable', 'offline', 'down', 'error'].includes(status)) return 'unavailable';
|
|
||||||
if (
|
|
||||||
(presentedError || state.unavailable === true || state.available === false)
|
|
||||||
&& !hasPriorData
|
|
||||||
&& !GUIDANCE_STATUSES.includes(status)
|
|
||||||
) {
|
|
||||||
return 'unavailable';
|
|
||||||
}
|
|
||||||
if (state.loading) return 'loading';
|
|
||||||
// Guidance states ask the user to act (zoom in, run a search) — normal
|
|
||||||
// operation, not feed faults. One honesty carve-out: layers keep their
|
|
||||||
// rendered records through the guidance state, so a genuinely stale cache
|
|
||||||
// still reads STALE; a guidance prompt alone never reads DEGRADED.
|
|
||||||
if (GUIDANCE_STATUSES.includes(status)) {
|
|
||||||
return state.stale ? 'stale' : 'nominal';
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
state.fallback === true
|
|
||||||
|| status === 'fallback'
|
|
||||||
|| state.mode === 'sim'
|
|
||||||
|| /\bfallback\b/i.test(source)
|
|
||||||
|| (!hasExplicitFallback && /\badsb\.lol\b/i.test(source))
|
|
||||||
) {
|
|
||||||
return 'fallback';
|
|
||||||
}
|
|
||||||
if (state.stale || status === 'stale') return 'stale';
|
|
||||||
if (
|
|
||||||
state.degraded
|
|
||||||
|| presentedError
|
|
||||||
|| state.unavailable === true
|
|
||||||
|| state.available === false
|
|
||||||
) return 'degraded';
|
|
||||||
return 'nominal';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DataLayerManager — Manages registration, toggling, and update loops
|
* DataLayerManager — Manages registration, toggling, and update loops
|
||||||
* for real-time data overlays on the CesiumJS globe.
|
* for real-time data overlays on the CesiumJS globe.
|
||||||
@@ -1894,6 +1839,9 @@ export class DataLayerManager {
|
|||||||
* Should be called when the viewer is being torn down.
|
* Should be called when the viewer is being torn down.
|
||||||
*/
|
*/
|
||||||
async destroyAll() {
|
async destroyAll() {
|
||||||
|
this._layerPanel?.destroy();
|
||||||
|
this._layerPanel = null;
|
||||||
|
this._toggleContainer = null;
|
||||||
for (const layerId of [...this.layers.keys()]) {
|
for (const layerId of [...this.layers.keys()]) {
|
||||||
await this.destroyLayer(layerId);
|
await this.destroyLayer(layerId);
|
||||||
}
|
}
|
||||||
@@ -2019,98 +1967,6 @@ export class DataLayerManager {
|
|||||||
this._renderToggles();
|
this._renderToggles();
|
||||||
}
|
}
|
||||||
|
|
||||||
_renderToggles() {
|
|
||||||
if (!this._toggleContainer) return;
|
|
||||||
this._toggleContainer.innerHTML = '';
|
|
||||||
|
|
||||||
for (const layer of this.getAll()) {
|
|
||||||
if (!layer.showInTogglePanel) continue;
|
|
||||||
const row = document.createElement('div');
|
|
||||||
row.className = 'data-toggle-row';
|
|
||||||
row.dataset.layerId = layer.id;
|
|
||||||
|
|
||||||
const topRow = document.createElement('div');
|
|
||||||
topRow.className = 'data-toggle-top';
|
|
||||||
|
|
||||||
const left = document.createElement('div');
|
|
||||||
left.className = 'data-toggle-left';
|
|
||||||
left.innerHTML = `<span class="data-icon">${layer.icon}</span><span class="data-name">${layer.name}</span>`;
|
|
||||||
|
|
||||||
const right = document.createElement('div');
|
|
||||||
right.className = 'data-toggle-right';
|
|
||||||
|
|
||||||
const count = document.createElement('span');
|
|
||||||
count.className = 'data-count';
|
|
||||||
count.textContent = layer.stats.count ? this._formatCount(layer.stats.count) : '—';
|
|
||||||
|
|
||||||
const toggle = document.createElement('button');
|
|
||||||
toggle.type = 'button';
|
|
||||||
toggle.className = `data-toggle-btn${layer.enabled ? ' active' : ''}`;
|
|
||||||
this._syncToggleButton(toggle, layer);
|
|
||||||
toggle.addEventListener('click', async () => {
|
|
||||||
// Native `disabled` immediately evicts keyboard focus in Chromium. Keep
|
|
||||||
// the lifecycle control focusable while it is busy, and enforce the
|
|
||||||
// same single-flight interaction contract through ARIA instead.
|
|
||||||
if (toggle.getAttribute('aria-disabled') === 'true') return;
|
|
||||||
toggle.setAttribute('aria-disabled', 'true');
|
|
||||||
toggle.setAttribute('aria-busy', 'true');
|
|
||||||
try {
|
|
||||||
await this.setEnabled(layer.id, !this.isEnabled(layer.id), { origin: 'user' });
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(`[Data] ${layer.id} toggle error:`, error);
|
|
||||||
} finally {
|
|
||||||
const current = this.getAll().find(({ id }) => id === layer.id);
|
|
||||||
if (current) this._syncToggleButton(toggle, current);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
right.appendChild(count);
|
|
||||||
right.appendChild(toggle);
|
|
||||||
topRow.appendChild(left);
|
|
||||||
topRow.appendChild(right);
|
|
||||||
|
|
||||||
const bottomRow = document.createElement('div');
|
|
||||||
bottomRow.className = 'data-toggle-meta';
|
|
||||||
bottomRow.textContent = this._buildMetaText(layer);
|
|
||||||
|
|
||||||
row.appendChild(topRow);
|
|
||||||
row.appendChild(bottomRow);
|
|
||||||
|
|
||||||
// Optional per-layer sub-controls (chips + color legend). The click
|
|
||||||
// listener is delegated and attached once here, so it survives
|
|
||||||
// _refreshTogglePanel — which only rewrites the container's contents.
|
|
||||||
const rowModule = this.layers.get(layer.id)?.module;
|
|
||||||
if (typeof rowModule?.getRowControls === 'function') {
|
|
||||||
// A layer whose controls settle asynchronously (a chunked catalog load
|
|
||||||
// that can also fail) pushes a re-render through this; nothing else
|
|
||||||
// would repaint the row before its next scheduled refresh.
|
|
||||||
rowModule.setRowControlsListener?.(() => this._refreshTogglePanel());
|
|
||||||
const controls = document.createElement('div');
|
|
||||||
controls.className = 'data-toggle-controls';
|
|
||||||
controls.addEventListener('click', (event) => {
|
|
||||||
const button = event.target?.closest?.('.data-toggle-chip');
|
|
||||||
if (!button || button.disabled) return;
|
|
||||||
// Re-read the live descriptor rather than trusting the rendered
|
|
||||||
// chip, so a stale row can never apply an inverted toggle.
|
|
||||||
const chip = this._rowControlsFor(layer.id)?.chips
|
|
||||||
?.find((entry) => entry.id === button.dataset.chipId);
|
|
||||||
if (chip?.params) this.setLayerParams(layer.id, chip.params, { origin: 'user' });
|
|
||||||
});
|
|
||||||
row.appendChild(controls);
|
|
||||||
this._syncRowControls(controls, layer);
|
|
||||||
}
|
|
||||||
|
|
||||||
this._toggleContainer.appendChild(row);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read a layer's optional row-control descriptor, tolerating a throw so one
|
|
||||||
* misbehaving layer cannot blank the whole panel. Resolved from the registry
|
|
||||||
* rather than the `getAll()` projection, which deliberately omits `module`.
|
|
||||||
* @param {string} layerId Registered layer id.
|
|
||||||
* @returns {{ chips?: Array<object>, legend?: Array<object> }|null} Descriptor.
|
|
||||||
*/
|
|
||||||
_rowControlsFor(layerId) {
|
_rowControlsFor(layerId) {
|
||||||
const module = this.layers.get(layerId)?.module;
|
const module = this.layers.get(layerId)?.module;
|
||||||
if (typeof module?.getRowControls !== 'function') return null;
|
if (typeof module?.getRowControls !== 'function') return null;
|
||||||
@@ -2122,186 +1978,25 @@ export class DataLayerManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
_getLayerPanel() {
|
||||||
* Render a layer's row chips and color legend, and keep the whole block
|
if (!this._layerPanel) this._layerPanel = new LayerPanel({
|
||||||
* hidden while the layer is off (or while a dependency owner has surrendered
|
getLayers: () => this.getAll(),
|
||||||
* it) so a quiet row stays quiet.
|
isEnabled: (id) => this.isEnabled(id),
|
||||||
*
|
setEnabled: (id, enabled, options) => this.setEnabled(id, enabled, options),
|
||||||
* Chip BUTTONS are reconciled in place, keyed by chip id, rather than
|
setLayerParams: (id, params, options) => this.setLayerParams(id, params, options),
|
||||||
* rebuilt: this runs on every panel refresh — including the one the chip's
|
getRowControls: (id) => this._rowControlsFor(id),
|
||||||
* own click triggers — and replacing the node would drop keyboard focus
|
hasRowControls: (id) => typeof this.layers.get(id)?.module?.getRowControls === 'function',
|
||||||
* mid-interaction. Legend entries hold no focus and no listeners, so they
|
subscribeRowControls: (id, listener) => {
|
||||||
* are replaced freely.
|
const module = this.layers.get(id)?.module;
|
||||||
* @param {HTMLElement|null} container The row's `.data-toggle-controls` node.
|
module?.setRowControlsListener?.(listener);
|
||||||
* @param {object} layer Registered layer entry.
|
return () => module?.setRowControlsListener?.(null);
|
||||||
*/
|
},
|
||||||
_syncRowControls(container, layer) {
|
onHiddenRefresh: () => { this._panelRefreshPendingOnVisible = true; },
|
||||||
if (!container) return;
|
});
|
||||||
const controls = layer.enabled ? this._rowControlsFor(layer.id) : null;
|
return this._layerPanel;
|
||||||
const chips = controls?.chips || [];
|
|
||||||
const legend = controls?.legend || [];
|
|
||||||
container.hidden = chips.length === 0 && legend.length === 0;
|
|
||||||
|
|
||||||
for (const node of [...container.children]) {
|
|
||||||
if (String(node.className).split(/\s+/).includes('data-toggle-legend-item')) node.remove();
|
|
||||||
}
|
|
||||||
|
|
||||||
const stale = new Map();
|
|
||||||
for (const node of [...container.children]) {
|
|
||||||
if (node.dataset?.chipId) stale.set(node.dataset.chipId, node);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const chip of chips) {
|
|
||||||
let button = stale.get(chip.id);
|
|
||||||
stale.delete(chip.id);
|
|
||||||
if (!button) {
|
|
||||||
button = document.createElement('button');
|
|
||||||
button.type = 'button';
|
|
||||||
button.dataset.chipId = chip.id;
|
|
||||||
container.appendChild(button);
|
|
||||||
}
|
|
||||||
const state = chip.state || (chip.active ? 'active' : 'idle');
|
|
||||||
button.className = `data-toggle-chip chip-${state}${chip.active ? ' active' : ''}`;
|
|
||||||
if (button.textContent !== chip.label) button.textContent = chip.label;
|
|
||||||
button.title = chip.title || '';
|
|
||||||
button.disabled = Boolean(chip.disabled);
|
|
||||||
button.setAttribute('aria-pressed', chip.active ? 'true' : 'false');
|
|
||||||
button.setAttribute('aria-busy', chip.busy ? 'true' : 'false');
|
|
||||||
}
|
|
||||||
for (const node of stale.values()) node.remove();
|
|
||||||
|
|
||||||
for (const item of legend) {
|
|
||||||
const entry = document.createElement('span');
|
|
||||||
entry.className = 'data-toggle-legend-item';
|
|
||||||
if (item.blurb) entry.title = item.blurb;
|
|
||||||
const swatch = document.createElement('span');
|
|
||||||
swatch.className = 'data-toggle-legend-swatch';
|
|
||||||
swatch.style.background = item.color;
|
|
||||||
const text = document.createElement('span');
|
|
||||||
text.textContent = `${item.label} ${this._formatCount(item.count)}`;
|
|
||||||
entry.append(swatch, text);
|
|
||||||
container.appendChild(entry);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_refreshTogglePanel() {
|
|
||||||
if (!this._toggleContainer) return;
|
|
||||||
// Skip DOM churn while hidden; visibilitychange (main.js) triggers one
|
|
||||||
// refresh on return. (perf wave 2)
|
|
||||||
if (typeof document !== 'undefined' && document.hidden) {
|
|
||||||
this._panelRefreshPendingOnVisible = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for (const layer of this.getAll()) {
|
|
||||||
const row = this._toggleContainer.querySelector(`[data-layer-id="${layer.id}"]`);
|
|
||||||
if (!row) continue;
|
|
||||||
|
|
||||||
const btn = row.querySelector('.data-toggle-btn');
|
|
||||||
if (btn) {
|
|
||||||
this._syncToggleButton(btn, layer);
|
|
||||||
}
|
|
||||||
|
|
||||||
const count = row.querySelector('.data-count');
|
|
||||||
if (count) {
|
|
||||||
count.textContent = layer.stats.count ? this._formatCount(layer.stats.count) : '—';
|
|
||||||
}
|
|
||||||
|
|
||||||
const meta = row.querySelector('.data-toggle-meta');
|
|
||||||
if (meta) {
|
|
||||||
meta.textContent = this._buildMetaText(layer);
|
|
||||||
}
|
|
||||||
|
|
||||||
this._syncRowControls(row.querySelector('.data-toggle-controls'), layer);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_buildMetaText(layer) {
|
|
||||||
const stats = layer.stats || {};
|
|
||||||
const feedState = layerFeedState(stats);
|
|
||||||
const stateLabel = FEED_STATE_LABELS[feedState];
|
|
||||||
const source = stats.source || layer.source;
|
|
||||||
const lifecycleState = layer.lifecycleState || (layer.enabled ? 'enabled' : 'disabled');
|
|
||||||
if (lifecycleState === 'enabling' || lifecycleState === 'disabling') {
|
|
||||||
return `${lifecycleState.toUpperCase()} · ${source}`;
|
|
||||||
}
|
|
||||||
if (layer.lifecycleUncertain) {
|
|
||||||
return `UNCERTAIN · ${source} · lifecycle state requires reconciliation`;
|
|
||||||
}
|
|
||||||
const presentedError = stats.error || stats.lastError || stats.managerRefreshError;
|
|
||||||
if (presentedError) {
|
|
||||||
if (typeof stats.retryInSec === 'number' && stats.retryInSec > 0) {
|
|
||||||
return `${stateLabel} · ${source} · ${presentedError} · retry ${stats.retryInSec}s`;
|
|
||||||
}
|
|
||||||
return `${stateLabel} · ${source} · ${presentedError}`;
|
|
||||||
}
|
|
||||||
// A guidance status carries its prompt in `statusMessage`, not `error`, so
|
|
||||||
// the row still tells the operator what to do without reporting a fault.
|
|
||||||
if (GUIDANCE_STATUSES.includes(String(stats.status || '').toLowerCase())
|
|
||||||
&& typeof stats.statusMessage === 'string' && stats.statusMessage.trim()) {
|
|
||||||
return `${source} · ${stats.statusMessage.trim()}`;
|
|
||||||
}
|
|
||||||
const ago = stats.lastUpdate ? this._timeAgo(stats.lastUpdate) : 'never';
|
|
||||||
if (stats.loading) {
|
|
||||||
const loadingLabel = typeof stats.loadingLabel === 'string' && stats.loadingLabel.trim()
|
|
||||||
? stats.loadingLabel.trim()
|
|
||||||
: 'loading...';
|
|
||||||
return `${source} · ${loadingLabel}`;
|
|
||||||
}
|
|
||||||
if (feedState === 'fallback') {
|
|
||||||
const detail = typeof stats.loadingLabel === 'string' && stats.loadingLabel.trim()
|
|
||||||
? stats.loadingLabel.trim()
|
|
||||||
: (stats.coverage || ago);
|
|
||||||
return `${stateLabel} · ${source} · ${detail}`;
|
|
||||||
}
|
|
||||||
if (feedState === 'stale') {
|
|
||||||
const retry = typeof stats.retryInSec === 'number' && stats.retryInSec > 0
|
|
||||||
? ` · retrying in ${stats.retryInSec}s`
|
|
||||||
: '';
|
|
||||||
return `${stateLabel} · ${source} · ${ago}${retry}`;
|
|
||||||
}
|
|
||||||
if (typeof stats.loadingLabel === 'string' && stats.loadingLabel.trim()) {
|
|
||||||
return `${source} · ${stats.loadingLabel.trim()}`;
|
|
||||||
}
|
|
||||||
return `${source} · ${ago}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
_syncToggleButton(button, layer) {
|
|
||||||
const feedState = layer.enabled ? layerFeedState(layer.stats) : 'off';
|
|
||||||
const transitioning = layer.lifecycleState === 'enabling' || layer.lifecycleState === 'disabling';
|
|
||||||
const uncertain = Boolean(layer.lifecycleUncertain);
|
|
||||||
button.classList.toggle('active', layer.enabled);
|
|
||||||
button.classList.toggle('transitioning', transitioning);
|
|
||||||
button.classList.toggle('enabling', layer.lifecycleState === 'enabling');
|
|
||||||
button.classList.toggle('disabling', layer.lifecycleState === 'disabling');
|
|
||||||
button.classList.toggle('lifecycle-uncertain', uncertain);
|
|
||||||
for (const state of Object.keys(FEED_STATE_LABELS)) {
|
|
||||||
button.classList.toggle(`feed-${state}`, layer.enabled && !uncertain && feedState === state);
|
|
||||||
}
|
|
||||||
button.dataset.feedState = transitioning
|
|
||||||
? layer.lifecycleState
|
|
||||||
: (uncertain ? 'uncertain' : feedState);
|
|
||||||
// A busy toggle remains the keyboard focus owner. `aria-disabled` plus the
|
|
||||||
// click guard above prevents repeat activation without the focus loss caused
|
|
||||||
// by native `disabled`.
|
|
||||||
button.disabled = false;
|
|
||||||
button.setAttribute('aria-disabled', String(transitioning));
|
|
||||||
button.setAttribute('aria-busy', String(transitioning));
|
|
||||||
button.textContent = transitioning
|
|
||||||
? layer.lifecycleState.toUpperCase()
|
|
||||||
: (uncertain ? 'UNCERTAIN' : (layer.enabled ? FEED_STATE_LABELS[feedState] : 'OFF'));
|
|
||||||
button.setAttribute('aria-label', `${layer.name}: ${button.textContent}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
_formatCount(n) {
|
|
||||||
if (n >= 1000) return `${(n / 1000).toFixed(1)}K`;
|
|
||||||
return String(n);
|
|
||||||
}
|
|
||||||
|
|
||||||
_timeAgo(timestamp) {
|
|
||||||
const diff = Math.floor((Date.now() - timestamp) / 1000);
|
|
||||||
if (diff < 5) return 'just now';
|
|
||||||
if (diff < 60) return `${diff}s ago`;
|
|
||||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
|
||||||
return `${Math.floor(diff / 3600)}h ago`;
|
|
||||||
}
|
}
|
||||||
|
_renderToggles() { this._getLayerPanel().mount(this._toggleContainer); }
|
||||||
|
_refreshTogglePanel() { this._layerPanel?._refreshTogglePanel(); }
|
||||||
|
_buildMetaText(layer) { return this._getLayerPanel()._buildMetaText(layer); }
|
||||||
|
_syncToggleButton(button, layer) { this._getLayerPanel()._syncToggleButton(button, layer); }
|
||||||
}
|
}
|
||||||
@@ -105,6 +105,7 @@ test('renders ordinary layer rows without recreating a panel-hidden coordinator'
|
|||||||
},
|
},
|
||||||
appendChild(child) { this.children.push(child); return child; },
|
appendChild(child) { this.children.push(child); return child; },
|
||||||
addEventListener() {},
|
addEventListener() {},
|
||||||
|
removeEventListener() {},
|
||||||
setAttribute(name, value) { this.attributes[name] = String(value); },
|
setAttribute(name, value) { this.attributes[name] = String(value); },
|
||||||
querySelector(selector) {
|
querySelector(selector) {
|
||||||
if (selector.startsWith('[data-layer-id="')) {
|
if (selector.startsWith('[data-layer-id="')) {
|
||||||
@@ -2756,6 +2757,7 @@ function makeControlElement() {
|
|||||||
},
|
},
|
||||||
focus() { if (globalThis.document) globalThis.document.activeElement = this; },
|
focus() { if (globalThis.document) globalThis.document.activeElement = this; },
|
||||||
addEventListener(name, handler) { this.listeners[name] = handler; },
|
addEventListener(name, handler) { this.listeners[name] = handler; },
|
||||||
|
removeEventListener(name, handler) { if (this.listeners[name] === handler) delete this.listeners[name]; },
|
||||||
setAttribute(name, value) { this.attributes[name] = String(value); },
|
setAttribute(name, value) { this.attributes[name] = String(value); },
|
||||||
getAttribute(name) { return this.attributes[name] ?? null; },
|
getAttribute(name) { return this.attributes[name] ?? null; },
|
||||||
closest(selector) {
|
closest(selector) {
|
||||||
@@ -3267,3 +3269,36 @@ test('layer metadata shows a guidance prompt without reporting it as a fault', (
|
|||||||
assert.match(text, /Zoom in to search mapped installations$/);
|
assert.match(text, /Zoom in to search mapped installations$/);
|
||||||
assert.doesNotMatch(text, /UNAVAILABLE|DEGRADED/);
|
assert.doesNotMatch(text, /UNAVAILABLE|DEGRADED/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test('panel remount releases old listeners and destruction revokes retained controls', async () => {
|
||||||
|
const originalDocument = globalThis.document;
|
||||||
|
globalThis.document = { createElement: makeControlElement, activeElement: null };
|
||||||
|
const manager = new DataLayerManager({});
|
||||||
|
const layer = makeSlowLayer('lifecycle-panel', { updateInterval: -1 });
|
||||||
|
let rowListener;
|
||||||
|
layer.module.getRowControls = () => ({ chips: [] });
|
||||||
|
layer.module.setRowControlsListener = (listener) => { rowListener = listener; };
|
||||||
|
manager.register(layer.module);
|
||||||
|
try {
|
||||||
|
const first = makeControlElement();
|
||||||
|
manager.buildTogglePanel(first);
|
||||||
|
const oldButton = first.querySelector('.data-toggle-btn');
|
||||||
|
const retainedClick = oldButton.listeners.click;
|
||||||
|
const second = makeControlElement();
|
||||||
|
manager.buildTogglePanel(second);
|
||||||
|
assert.equal(oldButton.listeners.click, undefined);
|
||||||
|
await retainedClick();
|
||||||
|
assert.equal(layer.calls.enable, 0, 'a revoked generation cannot issue actions');
|
||||||
|
const currentButton = second.querySelector('.data-toggle-btn');
|
||||||
|
assert.equal(typeof currentButton.listeners.click, 'function');
|
||||||
|
assert.equal(typeof rowListener, 'function');
|
||||||
|
await manager.destroyAll();
|
||||||
|
assert.equal(currentButton.listeners.click, undefined);
|
||||||
|
assert.equal(rowListener, null);
|
||||||
|
} finally {
|
||||||
|
await manager.destroyAll();
|
||||||
|
if (originalDocument === undefined) delete globalThis.document;
|
||||||
|
else globalThis.document = originalDocument;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { bindClearLayersControl } from './ui/layers.js';
|
||||||
import { createMapSourceControls } from './ui/mapSource.js';
|
import { createMapSourceControls } from './ui/mapSource.js';
|
||||||
import { VisualEffects, STYLES, GLOBAL_POST_DEFAULTS, STYLE_PRESET_DEFAULTS, MILITARY_DETECTION_PRESET } from './ui/effects.js';
|
import { VisualEffects, STYLES, GLOBAL_POST_DEFAULTS, STYLE_PRESET_DEFAULTS, MILITARY_DETECTION_PRESET } from './ui/effects.js';
|
||||||
import { bindDisplayControls } from './ui/displayControls.js';
|
import { bindDisplayControls } from './ui/displayControls.js';
|
||||||
@@ -2087,7 +2088,6 @@ export class StyleManager {
|
|||||||
this._globeResetHandler = null;
|
this._globeResetHandler = null;
|
||||||
this._clearSelectedLayersPromise = null;
|
this._clearSelectedLayersPromise = null;
|
||||||
this._clearSelectedLayersManagerPromise = null;
|
this._clearSelectedLayersManagerPromise = null;
|
||||||
this._clearSelectedLayersHandler = null;
|
|
||||||
this._dataManager = null;
|
this._dataManager = null;
|
||||||
this._cctvUnsubscribe = null;
|
this._cctvUnsubscribe = null;
|
||||||
this._radioUnsubscribe = null;
|
this._radioUnsubscribe = null;
|
||||||
@@ -3331,6 +3331,7 @@ export class StyleManager {
|
|||||||
_initMapStackControl() {
|
_initMapStackControl() {
|
||||||
if (!this.mapStackController) return;
|
if (!this.mapStackController) return;
|
||||||
this._mapSourceControls?.destroy();
|
this._mapSourceControls?.destroy();
|
||||||
|
this._clearLayersControl?.destroy();
|
||||||
this._mapSourceControls = createMapSourceControls({
|
this._mapSourceControls = createMapSourceControls({
|
||||||
container: this._mapStackChips,
|
container: this._mapStackChips,
|
||||||
statusElement: this._mapStackStatus,
|
statusElement: this._mapStackStatus,
|
||||||
@@ -8900,8 +8901,8 @@ export class StyleManager {
|
|||||||
/** Wire the top-center action that clears only manager-owned data layers. */
|
/** Wire the top-center action that clears only manager-owned data layers. */
|
||||||
_initClearSelectedLayersButton() {
|
_initClearSelectedLayersButton() {
|
||||||
if (!this._clearSelectedLayersBtn) return;
|
if (!this._clearSelectedLayersBtn) return;
|
||||||
this._clearSelectedLayersHandler = () => { void this.clearSelectedLayers(); };
|
this._clearLayersControl?.destroy();
|
||||||
this._clearSelectedLayersBtn.addEventListener('click', this._clearSelectedLayersHandler);
|
this._clearLayersControl = bindClearLayersControl(this._clearSelectedLayersBtn, () => this.clearSelectedLayers());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -8928,9 +8929,7 @@ export class StyleManager {
|
|||||||
this._preservePanelStateDuringLayerClear = true;
|
this._preservePanelStateDuringLayerClear = true;
|
||||||
this._syncContextModeButtons();
|
this._syncContextModeButtons();
|
||||||
this._userFacingContextNotificationTokens.add(notificationToken);
|
this._userFacingContextNotificationTokens.add(notificationToken);
|
||||||
this._clearSelectedLayersBtn.setAttribute('aria-disabled', 'true');
|
this._clearLayersControl?.setBusy(true);
|
||||||
this._clearSelectedLayersBtn.setAttribute('aria-busy', 'true');
|
|
||||||
this._clearSelectedLayersBtn.setAttribute('aria-label', 'Clearing selected data layers');
|
|
||||||
|
|
||||||
const managerOperation = this._dataManager.clearSelectedLayers({
|
const managerOperation = this._dataManager.clearSelectedLayers({
|
||||||
origin: 'user',
|
origin: 'user',
|
||||||
@@ -8962,9 +8961,7 @@ export class StyleManager {
|
|||||||
this._contextModeChanging = false;
|
this._contextModeChanging = false;
|
||||||
this._syncContextModeButtons();
|
this._syncContextModeButtons();
|
||||||
}
|
}
|
||||||
this._clearSelectedLayersBtn.setAttribute('aria-disabled', 'false');
|
this._clearLayersControl?.setBusy(false);
|
||||||
this._clearSelectedLayersBtn.setAttribute('aria-busy', 'false');
|
|
||||||
this._clearSelectedLayersBtn.setAttribute('aria-label', 'Clear selected data layers');
|
|
||||||
this._preservePanelStateDuringLayerClear = false;
|
this._preservePanelStateDuringLayerClear = false;
|
||||||
this._clearSelectedLayersManagerPromise = null;
|
this._clearSelectedLayersManagerPromise = null;
|
||||||
this._clearSelectedLayersPromise = null;
|
this._clearSelectedLayersPromise = null;
|
||||||
@@ -9377,6 +9374,7 @@ export class StyleManager {
|
|||||||
this._applicationShortcuts?.destroy();
|
this._applicationShortcuts?.destroy();
|
||||||
this._displayControls?.destroy();
|
this._displayControls?.destroy();
|
||||||
this._mapSourceControls?.destroy();
|
this._mapSourceControls?.destroy();
|
||||||
|
this._clearLayersControl?.destroy();
|
||||||
this._visualEffects.stop();
|
this._visualEffects.stop();
|
||||||
this._styleParameters?.destroy();
|
this._styleParameters?.destroy();
|
||||||
for (const control of this._panelDisclosureControls || []) control.destroy();
|
for (const control of this._panelDisclosureControls || []) control.destroy();
|
||||||
@@ -9466,10 +9464,7 @@ export class StyleManager {
|
|||||||
this._cockpitResetGlobeBtn?.removeEventListener('click', this._globeResetHandler);
|
this._cockpitResetGlobeBtn?.removeEventListener('click', this._globeResetHandler);
|
||||||
this._globeResetHandler = null;
|
this._globeResetHandler = null;
|
||||||
}
|
}
|
||||||
if (this._clearSelectedLayersBtn && this._clearSelectedLayersHandler) {
|
|
||||||
this._clearSelectedLayersBtn.removeEventListener('click', this._clearSelectedLayersHandler);
|
|
||||||
this._clearSelectedLayersHandler = null;
|
|
||||||
}
|
|
||||||
this._cctvUnsubscribe?.();
|
this._cctvUnsubscribe?.();
|
||||||
this._cctvUnsubscribe = null;
|
this._cctvUnsubscribe = null;
|
||||||
this._commandDockTrayObserver?.disconnect?.();
|
this._commandDockTrayObserver?.disconnect?.();
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/** Keep the clear action focusable while busy; the caller owns its transaction. */
|
||||||
|
export function bindClearLayersControl(button, clear) {
|
||||||
|
let destroyed = false;
|
||||||
|
const click = () => { if (!destroyed) void clear(); };
|
||||||
|
button?.addEventListener('click', click);
|
||||||
|
return {
|
||||||
|
setBusy(busy) {
|
||||||
|
if (destroyed || !button) return;
|
||||||
|
button.setAttribute('aria-disabled', String(busy));
|
||||||
|
button.setAttribute('aria-busy', String(busy));
|
||||||
|
button.setAttribute('aria-label', busy ? 'Clearing selected data layers' : 'Clear selected data layers');
|
||||||
|
},
|
||||||
|
destroy() {
|
||||||
|
destroyed = true;
|
||||||
|
button?.removeEventListener('click', click);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,379 @@
|
|||||||
|
import { GUIDANCE_STATUSES } from '../loadingFeedback.js';
|
||||||
|
const FEED_STATE_LABELS = Object.freeze({
|
||||||
|
nominal: 'ON',
|
||||||
|
loading: 'LOADING',
|
||||||
|
degraded: 'DEGRADED',
|
||||||
|
stale: 'STALE',
|
||||||
|
fallback: 'FALLBACK',
|
||||||
|
unavailable: 'UNAVAILABLE',
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize heterogeneous layer stats into one honest control-chip state.
|
||||||
|
* @param {object|null} stats Layer getStats() result.
|
||||||
|
* @returns {'nominal'|'loading'|'degraded'|'stale'|'fallback'|'unavailable'} Feed state.
|
||||||
|
*/
|
||||||
|
export function layerFeedState(stats = {}) {
|
||||||
|
const state = stats || {};
|
||||||
|
const status = typeof state.status === 'string' ? state.status.toLowerCase() : '';
|
||||||
|
const source = `${state.source || ''} ${state.coverage || ''}`;
|
||||||
|
const hasExplicitFallback = typeof state.fallback === 'boolean';
|
||||||
|
const hasPriorData = Number(state.count) > 0 || Boolean(state.lastUpdate);
|
||||||
|
const presentedError = state.error || state.lastError || state.managerRefreshError;
|
||||||
|
if (['unavailable', 'offline', 'down', 'error'].includes(status)) return 'unavailable';
|
||||||
|
if (
|
||||||
|
(presentedError || state.unavailable === true || state.available === false)
|
||||||
|
&& !hasPriorData
|
||||||
|
&& !GUIDANCE_STATUSES.includes(status)
|
||||||
|
) {
|
||||||
|
return 'unavailable';
|
||||||
|
}
|
||||||
|
if (state.loading) return 'loading';
|
||||||
|
// Guidance states ask the user to act (zoom in, run a search) — normal
|
||||||
|
// operation, not feed faults. One honesty carve-out: layers keep their
|
||||||
|
// rendered records through the guidance state, so a genuinely stale cache
|
||||||
|
// still reads STALE; a guidance prompt alone never reads DEGRADED.
|
||||||
|
if (GUIDANCE_STATUSES.includes(status)) {
|
||||||
|
return state.stale ? 'stale' : 'nominal';
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
state.fallback === true
|
||||||
|
|| status === 'fallback'
|
||||||
|
|| state.mode === 'sim'
|
||||||
|
|| /\bfallback\b/i.test(source)
|
||||||
|
|| (!hasExplicitFallback && /\badsb\.lol\b/i.test(source))
|
||||||
|
) {
|
||||||
|
return 'fallback';
|
||||||
|
}
|
||||||
|
if (state.stale || status === 'stale') return 'stale';
|
||||||
|
if (
|
||||||
|
state.degraded
|
||||||
|
|| presentedError
|
||||||
|
|| state.unavailable === true
|
||||||
|
|| state.available === false
|
||||||
|
) return 'degraded';
|
||||||
|
return 'nominal';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/** Layer row presentation over supplied state and actions; no layer imports. */
|
||||||
|
export class LayerPanel {
|
||||||
|
constructor({ getLayers, isEnabled, setEnabled, setLayerParams, getRowControls,
|
||||||
|
hasRowControls, subscribeRowControls, onHiddenRefresh = () => {} }) {
|
||||||
|
this.getAll = getLayers;
|
||||||
|
this.isEnabled = isEnabled;
|
||||||
|
this.setEnabled = setEnabled;
|
||||||
|
this.setLayerParams = setLayerParams;
|
||||||
|
this._rowControlsFor = getRowControls;
|
||||||
|
this.hasRowControls = hasRowControls;
|
||||||
|
this.subscribeRowControls = subscribeRowControls;
|
||||||
|
this.onHiddenRefresh = onHiddenRefresh;
|
||||||
|
this._generation = 0;
|
||||||
|
this._removers = [];
|
||||||
|
this._destroyed = false;
|
||||||
|
}
|
||||||
|
mount(container) {
|
||||||
|
if (this._destroyed) return;
|
||||||
|
this._releaseBindings();
|
||||||
|
this._toggleContainer = container;
|
||||||
|
this._renderToggles();
|
||||||
|
}
|
||||||
|
_bind(element, type, listener) {
|
||||||
|
element.addEventListener(type, listener);
|
||||||
|
this._removers.push(() => element.removeEventListener(type, listener));
|
||||||
|
}
|
||||||
|
_releaseBindings() {
|
||||||
|
this._generation++;
|
||||||
|
for (const remove of this._removers.splice(0)) remove();
|
||||||
|
}
|
||||||
|
destroy() {
|
||||||
|
if (this._destroyed) return;
|
||||||
|
this._destroyed = true;
|
||||||
|
this._releaseBindings();
|
||||||
|
this._toggleContainer = null;
|
||||||
|
}
|
||||||
|
_renderToggles() {
|
||||||
|
if (this._destroyed || !this._toggleContainer) return;
|
||||||
|
this._releaseBindings();
|
||||||
|
this._toggleContainer.innerHTML = '';
|
||||||
|
|
||||||
|
const generation = this._generation;
|
||||||
|
for (const layer of this.getAll()) {
|
||||||
|
if (!layer.showInTogglePanel) continue;
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'data-toggle-row';
|
||||||
|
row.dataset.layerId = layer.id;
|
||||||
|
|
||||||
|
const topRow = document.createElement('div');
|
||||||
|
topRow.className = 'data-toggle-top';
|
||||||
|
|
||||||
|
const left = document.createElement('div');
|
||||||
|
left.className = 'data-toggle-left';
|
||||||
|
const icon = document.createElement('span');
|
||||||
|
icon.className = 'data-icon';
|
||||||
|
icon.textContent = layer.icon;
|
||||||
|
const name = document.createElement('span');
|
||||||
|
name.className = 'data-name';
|
||||||
|
name.textContent = layer.name;
|
||||||
|
left.appendChild(icon);
|
||||||
|
left.appendChild(name);
|
||||||
|
|
||||||
|
const right = document.createElement('div');
|
||||||
|
right.className = 'data-toggle-right';
|
||||||
|
|
||||||
|
const count = document.createElement('span');
|
||||||
|
count.className = 'data-count';
|
||||||
|
count.textContent = layer.stats.count ? this._formatCount(layer.stats.count) : '—';
|
||||||
|
|
||||||
|
const toggle = document.createElement('button');
|
||||||
|
toggle.type = 'button';
|
||||||
|
toggle.className = `data-toggle-btn${layer.enabled ? ' active' : ''}`;
|
||||||
|
this._syncToggleButton(toggle, layer);
|
||||||
|
this._bind(toggle, 'click', async () => {
|
||||||
|
// Native `disabled` immediately evicts keyboard focus in Chromium. Keep
|
||||||
|
// the lifecycle control focusable while it is busy, and enforce the
|
||||||
|
// same single-flight interaction contract through ARIA instead.
|
||||||
|
if (this._destroyed || this._generation !== generation || toggle.getAttribute('aria-disabled') === 'true') return;
|
||||||
|
toggle.setAttribute('aria-disabled', 'true');
|
||||||
|
toggle.setAttribute('aria-busy', 'true');
|
||||||
|
try {
|
||||||
|
await this.setEnabled(layer.id, !this.isEnabled(layer.id), { origin: 'user' });
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[Data] ${layer.id} toggle error:`, error);
|
||||||
|
} finally {
|
||||||
|
const current = this.getAll().find(({ id }) => id === layer.id);
|
||||||
|
if (!this._destroyed && current && this._generation === generation) this._syncToggleButton(toggle, current);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
right.appendChild(count);
|
||||||
|
right.appendChild(toggle);
|
||||||
|
topRow.appendChild(left);
|
||||||
|
topRow.appendChild(right);
|
||||||
|
|
||||||
|
const bottomRow = document.createElement('div');
|
||||||
|
bottomRow.className = 'data-toggle-meta';
|
||||||
|
bottomRow.textContent = this._buildMetaText(layer);
|
||||||
|
|
||||||
|
row.appendChild(topRow);
|
||||||
|
row.appendChild(bottomRow);
|
||||||
|
|
||||||
|
// Optional per-layer sub-controls (chips + color legend). The click
|
||||||
|
// listener is delegated and attached once here, so it survives
|
||||||
|
// _refreshTogglePanel — which only rewrites the container's contents.
|
||||||
|
if (this.hasRowControls(layer.id)) {
|
||||||
|
// A layer whose controls settle asynchronously (a chunked catalog load
|
||||||
|
// that can also fail) pushes a re-render through this; nothing else
|
||||||
|
// would repaint the row before its next scheduled refresh.
|
||||||
|
const unsubscribe = this.subscribeRowControls(layer.id, () => this._refreshTogglePanel());
|
||||||
|
if (unsubscribe) this._removers.push(unsubscribe);
|
||||||
|
const controls = document.createElement('div');
|
||||||
|
controls.className = 'data-toggle-controls';
|
||||||
|
this._bind(controls, 'click', (event) => {
|
||||||
|
const button = event.target?.closest?.('.data-toggle-chip');
|
||||||
|
if (!button || button.disabled) return;
|
||||||
|
// Re-read the live descriptor rather than trusting the rendered
|
||||||
|
// chip, so a stale row can never apply an inverted toggle.
|
||||||
|
const chip = this._rowControlsFor(layer.id)?.chips
|
||||||
|
?.find((entry) => entry.id === button.dataset.chipId);
|
||||||
|
if (chip?.params) this.setLayerParams(layer.id, chip.params, { origin: 'user' });
|
||||||
|
});
|
||||||
|
row.appendChild(controls);
|
||||||
|
this._syncRowControls(controls, layer);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._toggleContainer.appendChild(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read a layer's optional row-control descriptor, tolerating a throw so one
|
||||||
|
* misbehaving layer cannot blank the whole panel. Resolved from the registry
|
||||||
|
* rather than the `getAll()` projection, which deliberately omits `module`.
|
||||||
|
* @param {string} layerId Registered layer id.
|
||||||
|
* @returns {{ chips?: Array<object>, legend?: Array<object> }|null} Descriptor.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a layer's row chips and color legend, and keep the whole block
|
||||||
|
* hidden while the layer is off (or while a dependency owner has surrendered
|
||||||
|
* it) so a quiet row stays quiet.
|
||||||
|
*
|
||||||
|
* Chip BUTTONS are reconciled in place, keyed by chip id, rather than
|
||||||
|
* rebuilt: this runs on every panel refresh — including the one the chip's
|
||||||
|
* own click triggers — and replacing the node would drop keyboard focus
|
||||||
|
* mid-interaction. Legend entries hold no focus and no listeners, so they
|
||||||
|
* are replaced freely.
|
||||||
|
* @param {HTMLElement|null} container The row's `.data-toggle-controls` node.
|
||||||
|
* @param {object} layer Registered layer entry.
|
||||||
|
*/
|
||||||
|
_syncRowControls(container, layer) {
|
||||||
|
if (!container) return;
|
||||||
|
const controls = layer.enabled ? this._rowControlsFor(layer.id) : null;
|
||||||
|
const chips = controls?.chips || [];
|
||||||
|
const legend = controls?.legend || [];
|
||||||
|
container.hidden = chips.length === 0 && legend.length === 0;
|
||||||
|
|
||||||
|
for (const node of [...container.children]) {
|
||||||
|
if (String(node.className).split(/\s+/).includes('data-toggle-legend-item')) node.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
const stale = new Map();
|
||||||
|
for (const node of [...container.children]) {
|
||||||
|
if (node.dataset?.chipId) stale.set(node.dataset.chipId, node);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const chip of chips) {
|
||||||
|
let button = stale.get(chip.id);
|
||||||
|
stale.delete(chip.id);
|
||||||
|
if (!button) {
|
||||||
|
button = document.createElement('button');
|
||||||
|
button.type = 'button';
|
||||||
|
button.dataset.chipId = chip.id;
|
||||||
|
container.appendChild(button);
|
||||||
|
}
|
||||||
|
const state = chip.state || (chip.active ? 'active' : 'idle');
|
||||||
|
button.className = `data-toggle-chip chip-${state}${chip.active ? ' active' : ''}`;
|
||||||
|
if (button.textContent !== chip.label) button.textContent = chip.label;
|
||||||
|
button.title = chip.title || '';
|
||||||
|
button.disabled = Boolean(chip.disabled);
|
||||||
|
button.setAttribute('aria-pressed', chip.active ? 'true' : 'false');
|
||||||
|
button.setAttribute('aria-busy', chip.busy ? 'true' : 'false');
|
||||||
|
}
|
||||||
|
for (const node of stale.values()) node.remove();
|
||||||
|
|
||||||
|
for (const item of legend) {
|
||||||
|
const entry = document.createElement('span');
|
||||||
|
entry.className = 'data-toggle-legend-item';
|
||||||
|
if (item.blurb) entry.title = item.blurb;
|
||||||
|
const swatch = document.createElement('span');
|
||||||
|
swatch.className = 'data-toggle-legend-swatch';
|
||||||
|
swatch.style.background = item.color;
|
||||||
|
const text = document.createElement('span');
|
||||||
|
text.textContent = `${item.label} ${this._formatCount(item.count)}`;
|
||||||
|
entry.append(swatch, text);
|
||||||
|
container.appendChild(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_refreshTogglePanel() {
|
||||||
|
if (this._destroyed || !this._toggleContainer) return;
|
||||||
|
// Skip DOM churn while hidden; visibilitychange (main.js) triggers one
|
||||||
|
// refresh on return. (perf wave 2)
|
||||||
|
if (typeof document !== 'undefined' && document.hidden) {
|
||||||
|
this.onHiddenRefresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const layer of this.getAll()) {
|
||||||
|
const row = this._toggleContainer.querySelector(`[data-layer-id="${layer.id}"]`);
|
||||||
|
if (!row) continue;
|
||||||
|
|
||||||
|
const btn = row.querySelector('.data-toggle-btn');
|
||||||
|
if (btn) {
|
||||||
|
this._syncToggleButton(btn, layer);
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = row.querySelector('.data-count');
|
||||||
|
if (count) {
|
||||||
|
count.textContent = layer.stats.count ? this._formatCount(layer.stats.count) : '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = row.querySelector('.data-toggle-meta');
|
||||||
|
if (meta) {
|
||||||
|
meta.textContent = this._buildMetaText(layer);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._syncRowControls(row.querySelector('.data-toggle-controls'), layer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildMetaText(layer) {
|
||||||
|
const stats = layer.stats || {};
|
||||||
|
const feedState = layerFeedState(stats);
|
||||||
|
const stateLabel = FEED_STATE_LABELS[feedState];
|
||||||
|
const source = stats.source || layer.source;
|
||||||
|
const lifecycleState = layer.lifecycleState || (layer.enabled ? 'enabled' : 'disabled');
|
||||||
|
if (lifecycleState === 'enabling' || lifecycleState === 'disabling') {
|
||||||
|
return `${lifecycleState.toUpperCase()} · ${source}`;
|
||||||
|
}
|
||||||
|
if (layer.lifecycleUncertain) {
|
||||||
|
return `UNCERTAIN · ${source} · lifecycle state requires reconciliation`;
|
||||||
|
}
|
||||||
|
const presentedError = stats.error || stats.lastError || stats.managerRefreshError;
|
||||||
|
if (presentedError) {
|
||||||
|
if (typeof stats.retryInSec === 'number' && stats.retryInSec > 0) {
|
||||||
|
return `${stateLabel} · ${source} · ${presentedError} · retry ${stats.retryInSec}s`;
|
||||||
|
}
|
||||||
|
return `${stateLabel} · ${source} · ${presentedError}`;
|
||||||
|
}
|
||||||
|
// A guidance status carries its prompt in `statusMessage`, not `error`, so
|
||||||
|
// the row still tells the operator what to do without reporting a fault.
|
||||||
|
if (GUIDANCE_STATUSES.includes(String(stats.status || '').toLowerCase())
|
||||||
|
&& typeof stats.statusMessage === 'string' && stats.statusMessage.trim()) {
|
||||||
|
return `${source} · ${stats.statusMessage.trim()}`;
|
||||||
|
}
|
||||||
|
const ago = stats.lastUpdate ? this._timeAgo(stats.lastUpdate) : 'never';
|
||||||
|
if (stats.loading) {
|
||||||
|
const loadingLabel = typeof stats.loadingLabel === 'string' && stats.loadingLabel.trim()
|
||||||
|
? stats.loadingLabel.trim()
|
||||||
|
: 'loading...';
|
||||||
|
return `${source} · ${loadingLabel}`;
|
||||||
|
}
|
||||||
|
if (feedState === 'fallback') {
|
||||||
|
const detail = typeof stats.loadingLabel === 'string' && stats.loadingLabel.trim()
|
||||||
|
? stats.loadingLabel.trim()
|
||||||
|
: (stats.coverage || ago);
|
||||||
|
return `${stateLabel} · ${source} · ${detail}`;
|
||||||
|
}
|
||||||
|
if (feedState === 'stale') {
|
||||||
|
const retry = typeof stats.retryInSec === 'number' && stats.retryInSec > 0
|
||||||
|
? ` · retrying in ${stats.retryInSec}s`
|
||||||
|
: '';
|
||||||
|
return `${stateLabel} · ${source} · ${ago}${retry}`;
|
||||||
|
}
|
||||||
|
if (typeof stats.loadingLabel === 'string' && stats.loadingLabel.trim()) {
|
||||||
|
return `${source} · ${stats.loadingLabel.trim()}`;
|
||||||
|
}
|
||||||
|
return `${source} · ${ago}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
_syncToggleButton(button, layer) {
|
||||||
|
const feedState = layer.enabled ? layerFeedState(layer.stats) : 'off';
|
||||||
|
const transitioning = layer.lifecycleState === 'enabling' || layer.lifecycleState === 'disabling';
|
||||||
|
const uncertain = Boolean(layer.lifecycleUncertain);
|
||||||
|
button.classList.toggle('active', layer.enabled);
|
||||||
|
button.classList.toggle('transitioning', transitioning);
|
||||||
|
button.classList.toggle('enabling', layer.lifecycleState === 'enabling');
|
||||||
|
button.classList.toggle('disabling', layer.lifecycleState === 'disabling');
|
||||||
|
button.classList.toggle('lifecycle-uncertain', uncertain);
|
||||||
|
for (const state of Object.keys(FEED_STATE_LABELS)) {
|
||||||
|
button.classList.toggle(`feed-${state}`, layer.enabled && !uncertain && feedState === state);
|
||||||
|
}
|
||||||
|
button.dataset.feedState = transitioning
|
||||||
|
? layer.lifecycleState
|
||||||
|
: (uncertain ? 'uncertain' : feedState);
|
||||||
|
// A busy toggle remains the keyboard focus owner. `aria-disabled` plus the
|
||||||
|
// click guard above prevents repeat activation without the focus loss caused
|
||||||
|
// by native `disabled`.
|
||||||
|
button.disabled = false;
|
||||||
|
button.setAttribute('aria-disabled', String(transitioning));
|
||||||
|
button.setAttribute('aria-busy', String(transitioning));
|
||||||
|
button.textContent = transitioning
|
||||||
|
? layer.lifecycleState.toUpperCase()
|
||||||
|
: (uncertain ? 'UNCERTAIN' : (layer.enabled ? FEED_STATE_LABELS[feedState] : 'OFF'));
|
||||||
|
button.setAttribute('aria-label', `${layer.name}: ${button.textContent}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
_formatCount(n) {
|
||||||
|
if (n >= 1000) return `${(n / 1000).toFixed(1)}K`;
|
||||||
|
return String(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
_timeAgo(timestamp) {
|
||||||
|
const diff = Math.floor((Date.now() - timestamp) / 1000);
|
||||||
|
if (diff < 5) return 'just now';
|
||||||
|
if (diff < 60) return `${diff}s ago`;
|
||||||
|
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||||
|
return `${Math.floor(diff / 3600)}h ago`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { LayerPanel, layerFeedState } from './layerPanel.js';
|
||||||
|
export { bindClearLayersControl } from './clearLayersControl.js';
|
||||||
Reference in new issue
Block a user