mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
Merge branch 'main' into german/hal-721-provider-labels
This commit is contained in:
61 files changed
+3346
-439
No files matched your search
+9
-6
@@ -224,7 +224,7 @@ The [Bhote Koshi event pack](public/events/bhote-koshi-2026/README.md), under `p
|
||||
| **Yale Bright Star Catalogue** (9,096 stars, sky background) | `sky/` | **Public domain** | ✅ (no restrictions) | "Yale Bright Star Catalogue (Hoffleit & Warren, via CDS)" (courtesy credit) |
|
||||
| **ESO Milky Way panorama** (eso0932a, blurred cube map, sky background) | `sky/` | **CC BY 4.0** | ✅ (attribution; changes stated) | "ESO/S. Brunier" |
|
||||
| **CCTV ground heights** (3,445 cameras) | `cctv_ground_heights/` | Precomputed camera placement heights, aligned to work with Google Photorealistic 3D Tiles (folder README) | — | — |
|
||||
| **CCTV Camera Database** model-enrichment extract v2.26.0 (17 camera-model records) | `src/layers/cctv/modelSpecs.js` | **CC0-1.0** (public domain dedication) | ✅ (no restrictions) | "Specs: cctv-database.com" (courtesy — not legally required) |
|
||||
| **CCTV Camera Database** model-enrichment extract v2.26.0 + v2.29.0 (20 camera-model records) | `src/layers/cctv/modelSpecs.js` | **CC0-1.0** (public domain dedication) | ✅ (no restrictions) | "Specs: cctv-database.com" (courtesy — not legally required) |
|
||||
|
||||
### ⚠️ TeleGeography is bundled but NonCommercial
|
||||
|
||||
@@ -252,13 +252,16 @@ fixtures retain their provenance in `src/data/fixtures/README.md`.
|
||||
|
||||
### CCTV Camera Database (`src/layers/cctv/modelSpecs.js`)
|
||||
|
||||
The CCTV layer's hardware-model specs are a small verbatim extract (v2.26.0) of the
|
||||
The CCTV layer's hardware-model specs are a small extract (v2.26.0, plus the Bosch MIC
|
||||
records from v2.29.0) of the
|
||||
[CCTV Camera Database](https://github.com/ch-bas/cctv-camera-database), an open dataset of
|
||||
camera models transcribed from manufacturer datasheets, dedicated to the public domain under
|
||||
**CC0-1.0**. Only the records for hardware that the integrated public catalogs name (King
|
||||
County, Sarasota County and Sioux Falls) are bundled, compiled into the module. Nothing is
|
||||
fetched at runtime. The records describe camera products (optics, resolution, night vision)
|
||||
only, never endpoints, stream paths or access data. CC0 requires no attribution; the CCTV HUD
|
||||
**CC0-1.0**. Only records for hardware named by the integrated public catalogs (King County,
|
||||
Sarasota County and Sioux Falls) or by the City of Amsterdam register proposed in
|
||||
[#251](https://github.com/bilawalsidhu/gods-eye-view/pull/251) are bundled, compiled into the
|
||||
module. Nothing is fetched at runtime. The MIC records keep their specification fields; their
|
||||
titles are shortened to model codes and colour aliases are added. The records describe camera
|
||||
products (optics, resolution, night vision) only, never endpoints, stream paths or access data. CC0 requires no attribution; the CCTV HUD
|
||||
credits it anyway as `SPECS: CCTV-DATABASE.COM` beside any spec line it renders.
|
||||
|
||||
### Bundled OpenStreetMap data
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
- Vendor the Bosch MIC PTZ datasheet records that the City of Amsterdam camera
|
||||
register names (MIC-7522, MIC-7502, MIC-7130), with the register's `x` colour
|
||||
wildcard as an alias, from the CCTV Camera Database v2.29.0 (ch-bas, #1319).
|
||||
@@ -0,0 +1,12 @@
|
||||
- Color every Active Fires detection by fire radiative power on a six-stop
|
||||
ember → white-hot ramp (ember · red · flame · orange · gold · white-hot at
|
||||
0 · 5 · 20 · 50 · 100 · 300 MW, brighter as power rises), and add a
|
||||
log-scale minimum-FRP slider whose track is the ramp, an All / Nominal+ /
|
||||
High confidence switch and an "N of M shown" counter to the layer row. Far
|
||||
zoom shows a Snap Map–style heatmap in the same colors instead of squares,
|
||||
one distinct spot per fire, empty where nothing burns, and cross-fades with
|
||||
the markers around 4,500 km with no gap, keeping weak fires as markers. The filter applies once before
|
||||
rendering, so markers, cards, the heatmap, context and the top-fires query
|
||||
agree; share links keep it (`w` token: `f`, `c`), and `get_active_fires`
|
||||
takes matching `min_frp_mw` and `confidence` arguments. Default 0 MW / All
|
||||
(gekh, #1426).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Ground-sample Data Centers and Dams markers only when the camera sees the
|
||||
ground under them. Unseen ground read through the Earth and retried every
|
||||
2 s; over Ashburn, Virginia on the Esri map, slow frames reached 2 s
|
||||
(quanticsoul4772, #1464).
|
||||
@@ -0,0 +1,2 @@
|
||||
- Keep retained Active Fires heat imagery hidden when an empty filtered layer is
|
||||
re-enabled while a new snapshot is pending (radianx, #1535).
|
||||
@@ -0,0 +1,5 @@
|
||||
- Finish the typed AI chat translations. The "Reset to globe view" and "Show
|
||||
earthquakes" quick commands are now translated, Traditional Chinese follows
|
||||
Simplified Chinese again, Arabic docks the panel to the side it actually
|
||||
lands on, and the new strings use each language's glossary terms
|
||||
(German, @gekh, #1545).
|
||||
@@ -0,0 +1,3 @@
|
||||
- Chat autocomplete finds Arabic suggestions typed without short vowels or
|
||||
shadda, so «تتبع» now suggests «اعرض تتبّع الرحلات المباشر»
|
||||
(German, @gekh, #1561).
|
||||
+66
-1
@@ -2191,7 +2191,12 @@ during continuous motion. Disable/re-enable resets selection cleanly.
|
||||
Close-up stem sizing uses camera-to-feature distance without a 5 km minimum.
|
||||
Unchanged overlay entries do not request another frame; moved positions,
|
||||
changed membership and re-enabling still publish. Ground sampling waits for
|
||||
visible globe tiles to settle and uses valid loaded terrain as a floor while
|
||||
visible globe tiles to settle, and skips markers whose ground the camera
|
||||
cannot see (unseen ground reads through the Earth and would retry every 2 s).
|
||||
Ground is tested at loaded globe terrain, or across -9,000 to 9,000 m when the
|
||||
globe is hidden, so photoreal views skip only ground well out of view. Until it is
|
||||
seen, a stem keeps its unsampled height, which can show for a stem whose base
|
||||
is just off screen. Sampling uses valid loaded terrain as a floor while
|
||||
retaining rooftop and below-sea-level elevations. A hidden globe does not
|
||||
constrain photoreal geometry, and retry work remains bounded. Already sampled
|
||||
nearby markers follow higher settled terrain on existing bounded walks, without
|
||||
@@ -2318,6 +2323,62 @@ Generated style sliders use the same name as their visible parameter label.
|
||||
The first-run suppression checkbox keeps its native wrapping label, so its
|
||||
accessible name remains "Don't show this again". Control behavior is unchanged.
|
||||
|
||||
## Active Fires intensity filter and color ramp
|
||||
|
||||
Every FIRMS detection is colored by fire radiative power on a six-stop ember →
|
||||
white-hot ramp: dark ember from 0 MW, red from 5, flame from 20, orange from
|
||||
50, gold from 100 and pale white-hot from 300 MW (`FRP_RAMP` in
|
||||
`src/data/firmsIntensity.js`). Each stop is lighter than the one below and
|
||||
none is a cold hue, so the brightest marks are the strongest fires; ~90% of
|
||||
detections are under 20 MW and stay dim red. Glow sprites and fire-card
|
||||
accents read that ramp; confidence and brightness do not affect the color.
|
||||
|
||||
At far zoom (camera above ~4,500 km) the layer drapes a Snap Map–style
|
||||
heatmap instead of individual markers: the shown detections are linearly binned
|
||||
(each detection's weight shared between the four nearest pixel centres, so spots
|
||||
sit at the true position and stay round) on a
|
||||
global 4096 × 2048 grid (≈10 km per pixel), each weighted by its FRP in megawatts (so heat is
|
||||
radiated power, and raising the FRP slider does not dim areas where strong fires
|
||||
burn, and a cluster of detections reads brighter than one), Gaussian-blurred by one
|
||||
pixel so each fire stays a distinct spot about the size of its marker,
|
||||
normalized on a log scale against the 99.5th percentile and mapped through the
|
||||
same hues, from a bright red edge through orange and gold to a pale
|
||||
white-hot core. The edge starts more than half opaque, so regions far below
|
||||
the hottest ones still read over forest and savanna imagery. Nothing is painted where
|
||||
nothing burns (`src/data/firmsHeatRaster.js`). It is one full-globe imagery
|
||||
tile on the active imagery host (`src/layers/firms/heatImagery.js`), rebuilt
|
||||
on each data refresh or filter change (the first far-band paint at once,
|
||||
later ones 150 ms after the last change). Heatmap and markers are both kept
|
||||
built at every zoom and cross-fade by camera height between 3,600 and
|
||||
5,400 km, every frame, so a fast zoom never shows a gap: the heatmap fades by
|
||||
imagery `alpha` (a hidden Cesium imagery layer is unmapped from the globe
|
||||
tiles and re-mapped late when shown), the markers by sprite alpha, and the
|
||||
marker collection is hidden once the heatmap is whole. The 750–4,500 km
|
||||
markers band draws up to 40,000 detections (the strongest in the padded
|
||||
view), so the weak fires the heatmap shows stay on screen as markers when it
|
||||
fades out; it reads each marker's already-built anchor instead of the ground
|
||||
floor cache, which only the close band warms. "N FIRES" cards from a 2° / 1° aggregation still label the hottest
|
||||
areas; their accent is the strongest fire's FRP color.
|
||||
Re-enabling Active Fires keeps the retained heat imagery hidden when the current
|
||||
filtered set is empty, including while a new snapshot is pending.
|
||||
|
||||
The Active Fires row carries a "Fire intensity (FRP)" slider on a log scale
|
||||
whose track is painted with the ramp and ticked at the same six values (its
|
||||
value reads "All" or "≥ N MW"), a "Confidence" switch with All / Nominal+ /
|
||||
High, and an "N of M shown" counter. The default is 0 MW and
|
||||
All, because many of the strongest detections are rated low confidence. The
|
||||
filter runs once in `ingestion.applyFilter()` before rendering, so markers,
|
||||
cards, the heatmap, the shared context store, the top-fires query and the
|
||||
analyst records all see one filtered set; a selected fire that no longer passes
|
||||
is deselected. The layer state keeps both options under the `w` (`local-firms`)
|
||||
share token as `f` (whole megawatts, 0–10,000) and `c` (`a`, `n`, `h`),
|
||||
including the short share codec; a link without them restores the defaults.
|
||||
|
||||
The `get_active_fires` tool accepts its own `min_frp_mw` and `confidence`
|
||||
(`all`, `nominal`, `high`) arguments, independent of the panel's setting, and
|
||||
names the applied criteria in its notes. Stale-snapshot and missing-source
|
||||
reporting are unchanged.
|
||||
|
||||
## FIRMS source status
|
||||
|
||||
The FIRMS CSV parser rejects impossible acquisition calendar dates before the proxy filters records to the trailing 24 hours, while retaining valid UTC acquisition times.
|
||||
@@ -5156,6 +5217,10 @@ silently demoting every later lookup for the session.
|
||||
multi-sensor datasheets publish a capability range, not the lens's current zoom, so those
|
||||
cameras keep their estimate and show `SPEC FOV min–max° (CAPABILITY)` beside it. Public
|
||||
camera state carries `fovSource`, `specFovMinDeg`, `specFovMaxDeg` and `spec` (#452).
|
||||
The table also has Bosch MIC-7130, MIC-7502 and MIC-7522 records for the Amsterdam
|
||||
register proposed in #251, with its `x` colour aliases (#1319). Their datasheet FOV names
|
||||
no axis, so it neither gives a capability range nor replaces the estimate. The records
|
||||
alone do not enable that source pack.
|
||||
- **FIRMS**: no ground clamping (zero 3D-tiles height sampling), ≤18 screen-decluttered ambient labels, click-to-inspect detail card, 2.5k/3k sprite budgets viewport-clipped by FRP. Fire-power labels share display rounding before localization: one decimal below the rounded 10 MW boundary, then whole megawatts (for example, 9.96 MW reads 10 MW).
|
||||
- **CCTV v2 foundation:** a pitched
|
||||
frustum wireframe (4 corner rays + far-cap rectangle) with a monitor plane at the frustum's
|
||||
|
||||
@@ -420,6 +420,7 @@
|
||||
"src/layers/firms/cards.js",
|
||||
"src/layers/firms/index.js",
|
||||
"src/layers/firms/ingestion.js",
|
||||
"src/layers/firms/heatVisibility.test.mjs",
|
||||
"src/layers/firms/lifecycle.js",
|
||||
"src/layers/firms/model.js",
|
||||
"src/layers/firms/policy.js",
|
||||
@@ -1210,6 +1211,10 @@
|
||||
"src/data/headingDegrees.test.mjs",
|
||||
"src/data/distanceFormat.test.mjs",
|
||||
"src/sources/adsbFields.test.mjs",
|
||||
"src/data/firmsIntensity.js",
|
||||
"src/data/firmsIntensity.test.mjs",
|
||||
"src/ui/layerPanel/RowSwitch.vue",
|
||||
"src/ui/layerPanel/RowSlider.vue",
|
||||
"src/sources/phaseTiming.test.mjs",
|
||||
"scripts/build-census-places.mjs",
|
||||
"src/data/placeBoundaries.test.mjs",
|
||||
|
||||
@@ -685,6 +685,8 @@
|
||||
"src/ui/rail/classToggle.js",
|
||||
"src/ui/rail/focusSafeChildren.js",
|
||||
"src/ui/layerPanel/RowText.vue",
|
||||
"src/ui/layerPanel/RowSwitch.vue",
|
||||
"src/ui/layerPanel/RowSlider.vue",
|
||||
"src/ui/railTimeline.js",
|
||||
"src/ui/clearLayersControl.js",
|
||||
"src/loadingFeedback.js",
|
||||
@@ -722,7 +724,8 @@
|
||||
"src/ui/kit/UiButton.vue",
|
||||
"src/ui/kit/UiBadge.vue",
|
||||
"src/ui/rail/fieldValue.js",
|
||||
"src/ui/kit/UiSlider.js"
|
||||
"src/ui/kit/UiSlider.js",
|
||||
"src/ui/kit/UiSegmented.vue"
|
||||
],
|
||||
"external": ["vue"]
|
||||
},
|
||||
@@ -947,6 +950,7 @@
|
||||
"src/data/detectionPolicy.js",
|
||||
"src/data/installationFeedback.js",
|
||||
"src/data/idSwitches.js",
|
||||
"src/data/firmsIntensity.js",
|
||||
"src/data/layerState.js",
|
||||
"src/data/layerStateCoordinator.js",
|
||||
"src/data/layerStateTokenReservations.json",
|
||||
@@ -1019,6 +1023,8 @@
|
||||
"src/ui/rail/classToggle.js",
|
||||
"src/ui/rail/focusSafeChildren.js",
|
||||
"src/ui/layerPanel/RowText.vue",
|
||||
"src/ui/layerPanel/RowSwitch.vue",
|
||||
"src/ui/layerPanel/RowSlider.vue",
|
||||
"src/ui/railTimeline.js",
|
||||
"src/ui/layers.js",
|
||||
"src/ui/leftPanelRail.js",
|
||||
@@ -1142,7 +1148,8 @@
|
||||
"src/ui/kit/UiBadge.vue",
|
||||
"src/ui/stores/globeActions.js",
|
||||
"src/ui/stores/recording.js",
|
||||
"src/ui/i18n.js"
|
||||
"src/ui/i18n.js",
|
||||
"src/ui/kit/UiSegmented.vue"
|
||||
],
|
||||
"external": ["cesium", "vue", "pinia", "vue-i18n"]
|
||||
},
|
||||
@@ -1311,6 +1318,7 @@
|
||||
"src/data/contactMatch.js",
|
||||
"src/data/iconOrientation.js",
|
||||
"src/data/idSwitches.js",
|
||||
"src/data/firmsIntensity.js",
|
||||
"src/data/layerState.js",
|
||||
"src/data/layerStateTokenReservations.json",
|
||||
"src/data/modelVisualAnchor.js",
|
||||
@@ -1373,6 +1381,7 @@
|
||||
"src/data/contactMatch.js",
|
||||
"src/data/iconOrientation.js",
|
||||
"src/data/idSwitches.js",
|
||||
"src/data/firmsIntensity.js",
|
||||
"src/data/layerState.js",
|
||||
"src/data/layerStateTokenReservations.json",
|
||||
"src/data/modelVisualAnchor.js",
|
||||
@@ -1426,6 +1435,7 @@
|
||||
"src/data/contactMatch.js",
|
||||
"src/data/iconOrientation.js",
|
||||
"src/data/idSwitches.js",
|
||||
"src/data/firmsIntensity.js",
|
||||
"src/data/layerState.js",
|
||||
"src/data/layerStateCoordinator.js",
|
||||
"src/data/layerStateTokenReservations.json",
|
||||
@@ -1527,6 +1537,8 @@
|
||||
"exports": ["./layers/firms"],
|
||||
"modules": [
|
||||
"src/data/firmsAdapt.js",
|
||||
"src/data/firmsIntensity.js",
|
||||
"src/data/firmsHeatRaster.js",
|
||||
"src/data/firmsLabels.js",
|
||||
"src/data/iconOrientation.js",
|
||||
"src/layers/firms/anchors.js",
|
||||
@@ -1538,6 +1550,7 @@
|
||||
"src/layers/firms/policy.js",
|
||||
"src/layers/firms/queries.js",
|
||||
"src/layers/firms/rendering.js",
|
||||
"src/layers/firms/heatImagery.js",
|
||||
"src/layers/firms/selection.js",
|
||||
"src/layers/firms/source.js",
|
||||
"src/layers/firms/state.js",
|
||||
@@ -2005,6 +2018,7 @@
|
||||
"modules": [
|
||||
"src/data/iconOrientation.js",
|
||||
"src/data/idSwitches.js",
|
||||
"src/data/firmsIntensity.js",
|
||||
"src/data/layerState.js",
|
||||
"src/data/layerStateTokenReservations.json",
|
||||
"src/data/radioCountry.js",
|
||||
@@ -2377,6 +2391,8 @@
|
||||
"src/data/fireAnchors.js",
|
||||
"src/data/firmsAdapt.js",
|
||||
"src/data/firmsHeatmap.js",
|
||||
"src/data/firmsIntensity.js",
|
||||
"src/data/firmsHeatRaster.js",
|
||||
"src/data/firmsLabels.js",
|
||||
"src/data/flights.js",
|
||||
"src/data/flowMatch.js",
|
||||
@@ -2538,6 +2554,7 @@
|
||||
"src/layers/firms/policy.js",
|
||||
"src/layers/firms/queries.js",
|
||||
"src/layers/firms/rendering.js",
|
||||
"src/layers/firms/heatImagery.js",
|
||||
"src/layers/firms/selection.js",
|
||||
"src/layers/firms/source.js",
|
||||
"src/layers/firms/state.js",
|
||||
@@ -2799,6 +2816,8 @@
|
||||
"src/ui/rail/classToggle.js",
|
||||
"src/ui/rail/focusSafeChildren.js",
|
||||
"src/ui/layerPanel/RowText.vue",
|
||||
"src/ui/layerPanel/RowSwitch.vue",
|
||||
"src/ui/layerPanel/RowSlider.vue",
|
||||
"src/ui/railTimeline.js",
|
||||
"src/ui/layers.js",
|
||||
"src/ui/leftPanelRail.js",
|
||||
@@ -3340,6 +3359,8 @@
|
||||
"src/data/detectionDraw.js",
|
||||
"src/data/detectionPolicy.js",
|
||||
"src/data/firmsAdapt.js",
|
||||
"src/data/firmsIntensity.js",
|
||||
"src/data/firmsHeatRaster.js",
|
||||
"src/data/firmsLabels.js",
|
||||
"src/data/flowMatch.js",
|
||||
"src/data/focusDeemphasis.js",
|
||||
@@ -3471,6 +3492,7 @@
|
||||
"src/layers/firms/policy.js",
|
||||
"src/layers/firms/queries.js",
|
||||
"src/layers/firms/rendering.js",
|
||||
"src/layers/firms/heatImagery.js",
|
||||
"src/layers/firms/selection.js",
|
||||
"src/layers/firms/source.js",
|
||||
"src/layers/firms/state.js",
|
||||
@@ -3994,6 +4016,7 @@
|
||||
"src/i18n/marker.js",
|
||||
"src/data/installationFeedback.js",
|
||||
"src/data/idSwitches.js",
|
||||
"src/data/firmsIntensity.js",
|
||||
"src/data/layerState.js",
|
||||
"src/data/layerStateCoordinator.js",
|
||||
"src/data/layerStateTokenReservations.json",
|
||||
@@ -4711,6 +4734,7 @@
|
||||
"src/styles/surveillance.js",
|
||||
"src/styles/thermal.js",
|
||||
"src/view/index.js",
|
||||
"src/data/firmsIntensity.js",
|
||||
"src/data/layerState.js",
|
||||
"src/data/layerStateTokenReservations.json",
|
||||
"src/data/idSwitches.js",
|
||||
@@ -4832,6 +4856,8 @@
|
||||
"src/tools/queries/brief.js",
|
||||
"src/tools/queries/maritime.js",
|
||||
"src/tools/queries/share.js",
|
||||
"src/data/firmsIntensity.js",
|
||||
"src/data/firmsAdapt.js",
|
||||
"src/data/idSwitches.js",
|
||||
"src/data/vesselTypes.js",
|
||||
"src/data/layerState.js",
|
||||
@@ -4966,6 +4992,7 @@
|
||||
"modules": [
|
||||
"src/view/index.js",
|
||||
"src/data/idSwitches.js",
|
||||
"src/data/firmsIntensity.js",
|
||||
"src/data/layerState.js",
|
||||
"src/data/layerStateTokenReservations.json",
|
||||
"src/visualStylePolicy.js"
|
||||
|
||||
@@ -45,7 +45,7 @@ test('buildFireCard: title carries FRP, detail carries conf/age/satellite', () =
|
||||
assert.equal(card.details.length, 1);
|
||||
assert.equal(card.details[0], 'high · 2h · N20');
|
||||
assert.equal(card.selected, false);
|
||||
assert.equal(card.accent, accentForSeverity('red'), 'FRP 1520 is red-hot');
|
||||
assert.equal(card.accent, accentForSeverity('white-hot'), 'FRP 1520 is white-hot');
|
||||
assert.deepEqual(card.position, { x: 1, y: 2, z: 3 }, 'uses the candidate position untouched');
|
||||
});
|
||||
|
||||
@@ -69,11 +69,27 @@ test('buildFireCard: power that rounds to 10 MW drops the decimal', () => {
|
||||
assert.equal(selected.title, 'FIRE · 10 MW');
|
||||
});
|
||||
|
||||
test('buildFireCard: SNPP satellite code renders as SNPP, weak fire is not red', () => {
|
||||
test('buildFireCard: SNPP satellite code renders as SNPP, weak fire is an ember', () => {
|
||||
const card = buildFireCard({ fire: fire({ satellite: 'N', frp: 0.8, confidence: 0.3 }), position: {} }, NOW);
|
||||
assert.match(card.details[0], /SNPP$/);
|
||||
assert.equal(card.title, '▲ 0.8 MW');
|
||||
assert.notEqual(card.accent, accentForSeverity('red'));
|
||||
assert.equal(card.accent, accentForSeverity('ember'));
|
||||
});
|
||||
|
||||
test('buildFireCard: accent steps through the FRP ramp at 5 · 20 · 50 · 100 · 300 MW', () => {
|
||||
const accentAt = (frp) =>
|
||||
buildFireCard({ fire: fire({ frp }), position: {} }, NOW).accent;
|
||||
assert.equal(accentAt(4.9), accentForSeverity('ember'));
|
||||
assert.equal(accentAt(5), accentForSeverity('red'));
|
||||
assert.equal(accentAt(20), accentForSeverity('flame'));
|
||||
assert.equal(accentAt(50), accentForSeverity('orange'));
|
||||
assert.equal(accentAt(100), accentForSeverity('gold'));
|
||||
assert.equal(accentAt(300), accentForSeverity('white-hot'));
|
||||
// Confidence never moves the color: a low-confidence 864 MW fire stays red.
|
||||
assert.equal(
|
||||
buildFireCard({ fire: fire({ frp: 864, confidence: 0.3 }), position: {} }, NOW).accent,
|
||||
accentForSeverity('white-hot'),
|
||||
);
|
||||
});
|
||||
|
||||
test('buildSelectedFireCard: full detail card with coords, selected flag, no fade', () => {
|
||||
@@ -93,12 +109,12 @@ test('buildCellCard: plural noun, max FRP and newest age, accent passthrough', (
|
||||
const candidate = {
|
||||
cell: { count: 14, maxFrp: 210.4, newestAcqMs: NOW - 3 * H, night: 2 },
|
||||
position: { x: 0, y: 0, z: 0 },
|
||||
accent: accentForSeverity('orange'),
|
||||
accent: accentForSeverity('gold'),
|
||||
};
|
||||
const card = buildCellCard(candidate, NOW);
|
||||
assert.equal(card.title, '14 FIRES');
|
||||
assert.equal(card.details[0], 'max 210 MW · new 3h');
|
||||
assert.equal(card.accent, accentForSeverity('orange'));
|
||||
assert.equal(card.accent, accentForSeverity('gold'));
|
||||
});
|
||||
|
||||
test('buildCellCard: the count picks the plural form of the language', () => {
|
||||
@@ -150,7 +166,7 @@ test('buildCellCard: singular noun and missing-age omission', () => {
|
||||
const card = buildCellCard(candidate, NOW);
|
||||
assert.equal(card.title, '1 FIRE');
|
||||
assert.equal(card.details[0], 'max 9.9 MW');
|
||||
assert.equal(card.accent, accentForSeverity('yellow'), 'missing accent defaults to yellow');
|
||||
assert.equal(card.accent, accentForSeverity('ember'), 'missing accent defaults to the weakest stop');
|
||||
});
|
||||
|
||||
test('FIRMS host policy keeps ambient cards bounded and selected cards protected', () => {
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* Snap Map–style heat raster for the Active Fires far-zoom bands: detections
|
||||
* are linearly binned on an equirectangular grid (row 0 = north, column
|
||||
* 0 = 180°W), blurred, normalized on a log scale and mapped through
|
||||
* HEAT_PALETTE. Pure: no Cesium, no DOM, so the whole pipeline is unit-tested
|
||||
* in node.
|
||||
*/
|
||||
|
||||
/** Below this normalized heat nothing is painted: empty land stays empty. */
|
||||
export const HEAT_FLOOR = 0.03;
|
||||
|
||||
/**
|
||||
* The FRP marker ramp as a continuous glow, from a red edge through orange
|
||||
* and gold to a pale white-hot core. The edge is a bright red, not the
|
||||
* markers' dark ember, and starts more than half opaque: most of the world
|
||||
* sits low on a scale set by the hottest regions, and a dark translucent red
|
||||
* vanishes into forest and savanna imagery.
|
||||
*/
|
||||
export const HEAT_PALETTE = Object.freeze([
|
||||
{ at: 0.0, rgb: [232, 58, 30], alpha: 0 },
|
||||
{ at: 0.08, rgb: [232, 58, 30], alpha: 0.58 },
|
||||
{ at: 0.25, rgb: [242, 86, 34], alpha: 0.7 },
|
||||
{ at: 0.45, rgb: [248, 122, 40], alpha: 0.76 },
|
||||
{ at: 0.65, rgb: [252, 166, 52], alpha: 0.82 },
|
||||
{ at: 0.82, rgb: [254, 208, 92], alpha: 0.87 },
|
||||
{ at: 1.0, rgb: [255, 245, 190], alpha: 0.92 },
|
||||
]);
|
||||
|
||||
export function heatColorAt(t) {
|
||||
if (!(t > HEAT_FLOOR)) return [0, 0, 0, 0];
|
||||
const v = Math.min(1, t);
|
||||
let i = 1;
|
||||
while (i < HEAT_PALETTE.length - 1 && HEAT_PALETTE[i].at < v) i += 1;
|
||||
const a = HEAT_PALETTE[i - 1];
|
||||
const b = HEAT_PALETTE[i];
|
||||
const k = (v - a.at) / (b.at - a.at || 1);
|
||||
const mix = (p, q) => Math.round(p + (q - p) * k);
|
||||
return [
|
||||
mix(a.rgb[0], b.rgb[0]),
|
||||
mix(a.rgb[1], b.rgb[1]),
|
||||
mix(a.rgb[2], b.rgb[2]),
|
||||
Math.round((a.alpha + (b.alpha - a.alpha) * k) * 255),
|
||||
];
|
||||
}
|
||||
|
||||
/** Weight of a detection with no or zero FRP, in megawatts. */
|
||||
export const HEAT_MIN_WEIGHT_MW = 0.5;
|
||||
|
||||
/**
|
||||
* Heat is radiated power, not a head count: each detection adds its FRP in
|
||||
* megawatts. Weak fires carry little power, so raising the minimum-FRP slider
|
||||
* removes most detections without dimming the areas where strong fires burn
|
||||
* (a count-based heat collapsed there and the hot spots faded).
|
||||
*/
|
||||
const defaultWeight = (fire) =>
|
||||
Math.max(HEAT_MIN_WEIGHT_MW, Number(fire.frp) || 0);
|
||||
|
||||
/**
|
||||
* Linear binning (Wand 1994): each detection's weight is shared between the
|
||||
* four pixel centres around it in proportion to closeness, instead of
|
||||
* dropping whole into one pixel. Spots then sit at the true position rather
|
||||
* than snapping by up to half a pixel (~10 km), and a lone weak fire blurs
|
||||
* into a round spot instead of a square or a plus sign. Longitude wraps;
|
||||
* rows beyond the poles fold back onto the edge row.
|
||||
*/
|
||||
export function binFires(fires, { width, height, weight = defaultWeight }) {
|
||||
const grid = new Float32Array(width * height);
|
||||
const row = (y) => Math.min(height - 1, Math.max(0, y)) * width;
|
||||
const col = (x) => ((x % width) + width) % width;
|
||||
for (const fire of fires) {
|
||||
if (!Number.isFinite(fire.lat) || !Number.isFinite(fire.lon)) continue;
|
||||
const fx = ((fire.lon + 180) / 360) * width - 0.5;
|
||||
const fy = ((90 - fire.lat) / 180) * height - 0.5;
|
||||
const x0 = Math.floor(fx);
|
||||
const y0 = Math.floor(fy);
|
||||
const tx = fx - x0;
|
||||
const ty = fy - y0;
|
||||
const w = weight(fire);
|
||||
const top = row(y0);
|
||||
const bottom = row(y0 + 1);
|
||||
const left = col(x0);
|
||||
const right = col(x0 + 1);
|
||||
grid[top + left] += w * (1 - tx) * (1 - ty);
|
||||
grid[top + right] += w * tx * (1 - ty);
|
||||
grid[bottom + left] += w * (1 - tx) * ty;
|
||||
grid[bottom + right] += w * tx * ty;
|
||||
}
|
||||
return grid;
|
||||
}
|
||||
|
||||
function kernel(sigma) {
|
||||
const radius = Math.max(1, Math.ceil(sigma * 3));
|
||||
const k = new Float32Array(radius * 2 + 1);
|
||||
let sum = 0;
|
||||
for (let i = -radius; i <= radius; i++) {
|
||||
k[i + radius] = Math.exp(-(i * i) / (2 * sigma * sigma));
|
||||
sum += k[i + radius];
|
||||
}
|
||||
for (let i = 0; i < k.length; i++) k[i] /= sum;
|
||||
return { k, radius };
|
||||
}
|
||||
|
||||
export function blurGaussian(grid, width, height, sigma) {
|
||||
const { k, radius } = kernel(sigma);
|
||||
const tmp = new Float32Array(grid.length);
|
||||
const out = new Float32Array(grid.length);
|
||||
for (let y = 0; y < height; y++) {
|
||||
const row = y * width;
|
||||
for (let x = 0; x < width; x++) {
|
||||
const v = grid[row + x];
|
||||
if (v === 0) continue;
|
||||
for (let i = -radius; i <= radius; i++) {
|
||||
const xx = (((x + i) % width) + width) % width; // longitude wraps
|
||||
tmp[row + xx] += v * k[i + radius];
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const v = tmp[y * width + x];
|
||||
if (v === 0) continue;
|
||||
for (let i = -radius; i <= radius; i++) {
|
||||
const yy = Math.min(height - 1, Math.max(0, y + i)); // poles clamp
|
||||
out[yy * width + x] += v * k[i + radius];
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Bins of the log-density histogram used to find the reference quantile. */
|
||||
const QUANTILE_BINS = 4096;
|
||||
|
||||
/**
|
||||
* The p-quantile of the non-zero values, from a histogram of log1p(value):
|
||||
* one linear pass instead of sorting ~350k burning pixels (a sort cost
|
||||
* 200 ms+ and dominated the build). Bin width on the log axis is
|
||||
* max/4096, far finer than the 256-step palette it feeds.
|
||||
*/
|
||||
function percentileOfNonZero(values, p) {
|
||||
let count = 0;
|
||||
let max = 0;
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
const v = values[i];
|
||||
if (v > 0) {
|
||||
count += 1;
|
||||
if (v > max) max = v;
|
||||
}
|
||||
}
|
||||
if (!count) return 0;
|
||||
const logMax = Math.log1p(max);
|
||||
const bins = new Uint32Array(QUANTILE_BINS);
|
||||
const toBin = (QUANTILE_BINS - 1) / logMax;
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
const v = values[i];
|
||||
if (v > 0) bins[Math.floor(Math.log1p(v) * toBin)] += 1;
|
||||
}
|
||||
const target = Math.floor(p * (count - 1));
|
||||
let seen = 0;
|
||||
for (let b = 0; b < QUANTILE_BINS; b++) {
|
||||
seen += bins[b];
|
||||
if (seen > target) return Math.expm1((b + 1) / toBin);
|
||||
}
|
||||
return max;
|
||||
}
|
||||
|
||||
/** HEAT_PALETTE sampled at 256 steps, so the per-pixel walk is one lookup. */
|
||||
const LUT_STEPS = 256;
|
||||
let paletteLut = null;
|
||||
function heatLut() {
|
||||
if (!paletteLut) {
|
||||
paletteLut = new Uint8ClampedArray(LUT_STEPS * 4);
|
||||
for (let i = 0; i < LUT_STEPS; i++) {
|
||||
paletteLut.set(heatColorAt(i / (LUT_STEPS - 1)), i * 4);
|
||||
}
|
||||
}
|
||||
return paletteLut;
|
||||
}
|
||||
|
||||
export function buildHeatRaster(
|
||||
fires,
|
||||
{ width, height, sigmaPx, percentile = 0.995 },
|
||||
) {
|
||||
const density = blurGaussian(
|
||||
binFires(fires, { width, height }),
|
||||
width,
|
||||
height,
|
||||
sigmaPx,
|
||||
);
|
||||
const reference = percentileOfNonZero(density, percentile);
|
||||
const rgba = new Uint8ClampedArray(width * height * 4);
|
||||
if (reference > 0) {
|
||||
const lut = heatLut();
|
||||
const scale = (LUT_STEPS - 1) / Math.log1p(reference);
|
||||
for (let i = 0; i < density.length; i++) {
|
||||
const d = density[i];
|
||||
if (d <= 0) continue;
|
||||
const step = Math.min(LUT_STEPS - 1, Math.round(Math.log1p(d) * scale));
|
||||
const l = step * 4;
|
||||
if (lut[l + 3] === 0) continue;
|
||||
const o = i * 4;
|
||||
rgba[o] = lut[l];
|
||||
rgba[o + 1] = lut[l + 1];
|
||||
rgba[o + 2] = lut[l + 2];
|
||||
rgba[o + 3] = lut[l + 3];
|
||||
}
|
||||
}
|
||||
return { width, height, rgba, reference };
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
HEAT_MIN_WEIGHT_MW,
|
||||
HEAT_PALETTE,
|
||||
heatColorAt,
|
||||
binFires,
|
||||
blurGaussian,
|
||||
buildHeatRaster,
|
||||
} from './firmsHeatRaster.js';
|
||||
|
||||
const fire = (lat, lon, frp = 10) => ({ lat, lon, frp });
|
||||
|
||||
test('palette runs ember to white-hot and starts fully transparent', () => {
|
||||
assert.equal(HEAT_PALETTE[0].at, 0);
|
||||
assert.equal(HEAT_PALETTE.at(-1).at, 1);
|
||||
assert.deepEqual(heatColorAt(0), [0, 0, 0, 0]);
|
||||
assert.equal(heatColorAt(0.01)[3], 0, 'below the floor nothing is painted');
|
||||
const low = heatColorAt(0.15);
|
||||
const high = heatColorAt(1);
|
||||
assert.ok(low[0] > low[1] && low[1] > low[2], 'low heat is red');
|
||||
assert.ok(low[3] >= 0.5 * 255, 'low heat stays visible on any imagery');
|
||||
assert.ok(high[1] > low[1] && high[2] > low[2], 'the core is paler');
|
||||
assert.ok(high[3] > low[3], 'hotter is more opaque');
|
||||
for (let i = 1; i <= 100; i++) {
|
||||
const [r, , b] = heatColorAt(i / 100);
|
||||
assert.ok(r >= b, `no cold hue at ${i}%`);
|
||||
}
|
||||
});
|
||||
|
||||
test('binFires puts a detection at a pixel centre wholly in that pixel', () => {
|
||||
// 4×2 grid: pixel centres at lon −135/−45/45/135, lat ±45.
|
||||
const grid = binFires([fire(45, -135, 1), fire(-45, 135, 1)], {
|
||||
width: 4,
|
||||
height: 2,
|
||||
});
|
||||
assert.equal(grid[0], 1, 'north-west pixel');
|
||||
assert.equal(grid[7], 1, 'south-east pixel');
|
||||
assert.equal(
|
||||
grid.reduce((a, b) => a + b, 0),
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
test('binFires shares a detection between the pixels around it', () => {
|
||||
const between = binFires([fire(45, 0, 1)], { width: 4, height: 2 });
|
||||
assert.equal(between[1], 0.5, 'half to the pixel west of it');
|
||||
assert.equal(between[2], 0.5, 'half to the pixel east of it');
|
||||
const quarter = binFires([fire(45, -112.5, 1)], { width: 4, height: 2 });
|
||||
assert.equal(quarter[0], 0.75, 'closer centre takes more');
|
||||
assert.equal(quarter[1], 0.25);
|
||||
const wrapped = binFires([fire(45, 180, 1)], { width: 4, height: 2 });
|
||||
assert.equal(wrapped[3], 0.5, 'the antimeridian wraps');
|
||||
assert.equal(wrapped[0], 0.5);
|
||||
const polar = binFires([fire(90, -135, 1)], { width: 4, height: 2 });
|
||||
assert.equal(polar[0], 1, 'beyond the last row folds back onto it');
|
||||
});
|
||||
|
||||
test('binFires weights each detection by its radiative power', () => {
|
||||
const grid = binFires(
|
||||
[fire(45, 45, 250), fire(45, 45, 0), fire(45, 45, Number.NaN)],
|
||||
{ width: 4, height: 2 },
|
||||
);
|
||||
assert.equal(grid[2], 250 + 2 * HEAT_MIN_WEIGHT_MW);
|
||||
});
|
||||
|
||||
test('filtering out weak fires keeps a strong fire hot', () => {
|
||||
const strong = fire(0.1, 0.1, 300);
|
||||
const weak = Array.from({ length: 40 }, () => fire(0.1, 0.1, 2));
|
||||
const reference = Array.from({ length: 10 }, () => fire(20.1, 20.1, 100));
|
||||
const options = { width: 360, height: 180, sigmaPx: 2 };
|
||||
const pixel = ((90 - 0.1) | 0) * 360 + ((0.1 + 180) | 0);
|
||||
const red = (r) => r.rgba[pixel * 4];
|
||||
const alpha = (r) => r.rgba[pixel * 4 + 3];
|
||||
const unfiltered = buildHeatRaster([strong, ...weak, ...reference], options);
|
||||
const filtered = buildHeatRaster([strong, ...reference], options);
|
||||
assert.ok(alpha(filtered) > 0);
|
||||
assert.ok(
|
||||
alpha(filtered) >= alpha(unfiltered) * 0.9,
|
||||
`alpha ${alpha(unfiltered)} -> ${alpha(filtered)}`,
|
||||
);
|
||||
assert.ok(
|
||||
red(filtered) >= red(unfiltered) * 0.9,
|
||||
`red ${red(unfiltered)} -> ${red(filtered)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('blurGaussian keeps mass and wraps across the antimeridian', () => {
|
||||
const w = 16;
|
||||
const h = 8;
|
||||
const grid = new Float32Array(w * h);
|
||||
grid[3 * w + 0] = 1;
|
||||
const out = blurGaussian(grid, w, h, 1.5);
|
||||
const sum = out.reduce((a, b) => a + b, 0);
|
||||
assert.ok(Math.abs(sum - 1) < 1e-3, `mass ${sum}`);
|
||||
assert.ok(out[3 * w + (w - 1)] > 0, 'spills to the east edge');
|
||||
});
|
||||
|
||||
test('buildHeatRaster is transparent where there are no fires', () => {
|
||||
const r = buildHeatRaster([fire(40, -120), fire(40.1, -120.1)], {
|
||||
width: 360,
|
||||
height: 180,
|
||||
sigmaPx: 2,
|
||||
});
|
||||
assert.equal(r.rgba.length, 360 * 180 * 4);
|
||||
const alphaAt = (lat, lon) => {
|
||||
const x = Math.floor(((lon + 180) / 360) * 360);
|
||||
const y = Math.floor(((90 - lat) / 180) * 180);
|
||||
return r.rgba[(y * 360 + x) * 4 + 3];
|
||||
};
|
||||
assert.ok(alphaAt(40, -120) > 0, 'blob over the fires');
|
||||
assert.equal(alphaAt(-30, 60), 0, 'empty far away');
|
||||
});
|
||||
|
||||
test('buildHeatRaster with no fires is fully transparent', () => {
|
||||
const r = buildHeatRaster([], { width: 8, height: 4, sigmaPx: 1 });
|
||||
assert.ok(r.rgba.every((v) => v === 0));
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* @module firmsIntensity
|
||||
* @description Fire intensity policy shared by the Active Fires layer and the
|
||||
* agent fire tool: the six-stop ember → white-hot FRP color ramp, the log-scale
|
||||
* slider that doubles as its legend, and the one filter every consumer
|
||||
* (markers, cards, heat cells, the top-fires query, `get_active_fires`) runs
|
||||
* before anything is rendered or selected. Pure — no Cesium, no DOM.
|
||||
*
|
||||
* The ramp (`FRP_RAMP`, stepped, not interpolated) runs like a fire cooling
|
||||
* down: a dim ember for weak fires through red, orange and gold to a pale
|
||||
* white-hot core, lightness rising with power, so the brightest marks are the
|
||||
* strongest fires. No cold hues: ~90% of detections are under 20 MW and a
|
||||
* blue start painted most of the map blue. The thresholds are GEV's own, set
|
||||
* on a real week's percentiles so every color appears: Portolan's
|
||||
* 0/10/50/200/500/1000 MW leaves one hottest fire a week (HAL-675).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Stepped FRP ramp, weakest first. A detection takes the LAST stop whose
|
||||
* `minFrp` it reaches. Names key the glow-sprite cache and the card accents.
|
||||
*/
|
||||
export const FRP_RAMP = Object.freeze([
|
||||
Object.freeze({ name: 'ember', minFrp: 0, hex: '#9e2a1b' }),
|
||||
Object.freeze({ name: 'red', minFrp: 5, hex: '#d2401f' }),
|
||||
Object.freeze({ name: 'flame', minFrp: 20, hex: '#f06a28' }),
|
||||
Object.freeze({ name: 'orange', minFrp: 50, hex: '#fb9a2e' }),
|
||||
Object.freeze({ name: 'gold', minFrp: 100, hex: '#fdc84a' }),
|
||||
Object.freeze({ name: 'white-hot', minFrp: 300, hex: '#fff3b0' }),
|
||||
]);
|
||||
|
||||
/** Slider ticks: the ramp thresholds, so slider, legend and markers agree. */
|
||||
export const FRP_SLIDER_TICKS = Object.freeze(
|
||||
FRP_RAMP.map((stop) => stop.minFrp),
|
||||
);
|
||||
|
||||
/** Minimum-FRP values the share codec accepts, in whole megawatts. */
|
||||
export const MIN_FRP_MW_MIN = 0;
|
||||
export const MIN_FRP_MW_MAX = 10_000;
|
||||
|
||||
/** Confidence switch positions; VIIRS reports only low / nominal / high. */
|
||||
export const FIRMS_CONFIDENCE_MODES = Object.freeze(['all', 'nominal', 'high']);
|
||||
|
||||
/**
|
||||
* Normalized-confidence floor for a switch position. The thresholds match
|
||||
* `confidenceBucket` in the layer model (0.3 / 0.6 / 0.9 after firmsAdapt).
|
||||
*/
|
||||
const CONFIDENCE_FLOOR = Object.freeze({ all: 0, nominal: 0.45, high: 0.75 });
|
||||
|
||||
export const DEFAULT_FIRE_FILTER = Object.freeze({
|
||||
minFrp: 0,
|
||||
confidence: 'all',
|
||||
});
|
||||
|
||||
/**
|
||||
* The ramp stop for a fire radiative power.
|
||||
* @param {number} frp Megawatts; non-finite or negative reads as 0.
|
||||
* @returns {{name: string, minFrp: number, hex: string}}
|
||||
*/
|
||||
export function frpRampStop(frp) {
|
||||
const value = Number.isFinite(frp) ? Math.max(0, frp) : 0;
|
||||
let stop = FRP_RAMP[0];
|
||||
for (const candidate of FRP_RAMP) {
|
||||
if (value >= candidate.minFrp) stop = candidate;
|
||||
else break;
|
||||
}
|
||||
return stop;
|
||||
}
|
||||
|
||||
/** `#rrggbb` → `[r, g, b]` in 0..255. */
|
||||
export function hexToRgb(hex) {
|
||||
const text = String(hex).replace('#', '');
|
||||
return [0, 2, 4].map((offset) =>
|
||||
Number.parseInt(text.slice(offset, offset + 2), 16),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log-scale slider axis: position 0 means "no minimum" (0 MW), and the rest
|
||||
* of the track runs 1 MW → SLIDER_MAX_MW logarithmically. The axis floor is 1
|
||||
* MW because the median detection is ~6 MW and a sub-megawatt floor hides
|
||||
* nothing worth a tick.
|
||||
*/
|
||||
export const SLIDER_MAX_MW = 1000;
|
||||
const LOG_MAX = Math.log10(SLIDER_MAX_MW);
|
||||
|
||||
/**
|
||||
* Track position in 0..1 for a minimum FRP.
|
||||
* @param {number} minFrp Megawatts.
|
||||
* @returns {number}
|
||||
*/
|
||||
export function sliderPositionForFrp(minFrp) {
|
||||
if (!Number.isFinite(minFrp) || minFrp < 1) return 0;
|
||||
return Math.min(1, Math.log10(minFrp) / LOG_MAX);
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimum FRP for a track position, rounded to whole megawatts so a slider
|
||||
* value and its share-link form are the same number.
|
||||
* @param {number} position 0..1.
|
||||
* @returns {number}
|
||||
*/
|
||||
export function frpForSliderPosition(position) {
|
||||
const p = Number.isFinite(position) ? Math.max(0, Math.min(1, position)) : 0;
|
||||
if (p <= 0) return 0;
|
||||
const frp = Math.pow(10, p * LOG_MAX);
|
||||
return Math.max(1, Math.round(frp));
|
||||
}
|
||||
|
||||
/**
|
||||
* CSS `linear-gradient` painting the stepped ramp along the log axis, so the
|
||||
* slider track IS the legend: the color under the thumb is the color of the
|
||||
* weakest fire still shown.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function rampTrackGradient() {
|
||||
const parts = [];
|
||||
FRP_RAMP.forEach((stop, index) => {
|
||||
const start = sliderPositionForFrp(stop.minFrp) * 100;
|
||||
const next = FRP_RAMP[index + 1];
|
||||
const end = next ? sliderPositionForFrp(next.minFrp) * 100 : 100;
|
||||
parts.push(`${stop.hex} ${start.toFixed(2)}% ${end.toFixed(2)}%`);
|
||||
});
|
||||
return `linear-gradient(90deg, ${parts.join(', ')})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimum FRP → whole megawatts inside the codec bounds, or null.
|
||||
* @param {*} value
|
||||
* @returns {?number}
|
||||
*/
|
||||
export function normalizeMinFrp(value) {
|
||||
const number =
|
||||
typeof value === 'number'
|
||||
? value
|
||||
: typeof value === 'string' && value.trim() !== ''
|
||||
? Number(value)
|
||||
: NaN;
|
||||
if (!Number.isFinite(number)) return null;
|
||||
const rounded = Math.round(number);
|
||||
return rounded >= MIN_FRP_MW_MIN && rounded <= MIN_FRP_MW_MAX
|
||||
? rounded
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Confidence mode → one of FIRMS_CONFIDENCE_MODES, or null.
|
||||
* @param {*} value
|
||||
* @returns {?string}
|
||||
*/
|
||||
export function normalizeConfidenceMode(value) {
|
||||
const text = String(value ?? '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return FIRMS_CONFIDENCE_MODES.includes(text) ? text : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a partial filter against the current one; unknown or invalid
|
||||
* fields keep their current value.
|
||||
* @param {Object} [params]
|
||||
* @param {{minFrp: number, confidence: string}} [current]
|
||||
* @returns {{minFrp: number, confidence: string}}
|
||||
*/
|
||||
export function normalizeFireFilter(
|
||||
params = {},
|
||||
current = DEFAULT_FIRE_FILTER,
|
||||
) {
|
||||
const minFrp = normalizeMinFrp(params?.minFrp);
|
||||
const confidence = normalizeConfidenceMode(params?.confidence);
|
||||
return {
|
||||
minFrp: minFrp === null ? current.minFrp : minFrp,
|
||||
confidence: confidence === null ? current.confidence : confidence,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether one fire passes the filter. `confidence` is the normalized 0..1
|
||||
* value firmsAdapt produces; `frp` is megawatts.
|
||||
* @param {{frp: number, confidence: number}} fire
|
||||
* @param {{minFrp: number, confidence: string}} filter
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function firePassesFilter(fire, filter = DEFAULT_FIRE_FILTER) {
|
||||
const frp = Number.isFinite(fire?.frp) ? fire.frp : 0;
|
||||
if (frp < (filter.minFrp || 0)) return false;
|
||||
const floor = CONFIDENCE_FLOOR[filter.confidence] ?? 0;
|
||||
if (floor <= 0) return true;
|
||||
const confidence = Number.isFinite(fire?.confidence) ? fire.confidence : 0;
|
||||
return confidence >= floor;
|
||||
}
|
||||
|
||||
/**
|
||||
* The fires that pass the filter, in input order. Returns the SAME array when
|
||||
* the filter is the default, so callers can tell "nothing hidden" cheaply.
|
||||
* @template T
|
||||
* @param {Array<T>} fires Internal records (normalized confidence).
|
||||
* @param {{minFrp: number, confidence: string}} filter
|
||||
* @returns {Array<T>}
|
||||
*/
|
||||
export function filterFires(fires, filter = DEFAULT_FIRE_FILTER) {
|
||||
if (!Array.isArray(fires)) return [];
|
||||
if (isDefaultFireFilter(filter)) return fires;
|
||||
return fires.filter((fire) => firePassesFilter(fire, filter));
|
||||
}
|
||||
|
||||
/** True when the filter hides nothing. */
|
||||
export function isDefaultFireFilter(filter) {
|
||||
return (
|
||||
!(filter?.minFrp > 0) &&
|
||||
(filter?.confidence === 'all' || filter?.confidence == null)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
DEFAULT_FIRE_FILTER,
|
||||
FIRMS_CONFIDENCE_MODES,
|
||||
FRP_RAMP,
|
||||
FRP_SLIDER_TICKS,
|
||||
filterFires,
|
||||
firePassesFilter,
|
||||
frpForSliderPosition,
|
||||
frpRampStop,
|
||||
hexToRgb,
|
||||
isDefaultFireFilter,
|
||||
normalizeConfidenceMode,
|
||||
normalizeFireFilter,
|
||||
normalizeMinFrp,
|
||||
rampTrackGradient,
|
||||
sliderPositionForFrp,
|
||||
} from './firmsIntensity.js';
|
||||
|
||||
test('the ramp has six ember → white-hot stops at 0 · 5 · 20 · 50 · 100 · 300 MW', () => {
|
||||
assert.deepEqual(
|
||||
FRP_RAMP.map((stop) => [stop.name, stop.minFrp, stop.hex]),
|
||||
[
|
||||
['ember', 0, '#9e2a1b'],
|
||||
['red', 5, '#d2401f'],
|
||||
['flame', 20, '#f06a28'],
|
||||
['orange', 50, '#fb9a2e'],
|
||||
['gold', 100, '#fdc84a'],
|
||||
['white-hot', 300, '#fff3b0'],
|
||||
],
|
||||
);
|
||||
assert.deepEqual([...FRP_SLIDER_TICKS], [0, 5, 20, 50, 100, 300]);
|
||||
assert.ok(Object.isFrozen(FRP_RAMP));
|
||||
});
|
||||
|
||||
test('every stop is warm and each is lighter than the one below', () => {
|
||||
const luma = ([r, g, b]) => 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
let previous = -1;
|
||||
for (const stop of FRP_RAMP) {
|
||||
const rgb = hexToRgb(stop.hex);
|
||||
assert.ok(rgb[0] >= rgb[2], `${stop.name} has no cold hue`);
|
||||
assert.ok(
|
||||
luma(rgb) > previous,
|
||||
`${stop.name} is lighter than the stop below`,
|
||||
);
|
||||
previous = luma(rgb);
|
||||
}
|
||||
});
|
||||
|
||||
test('a detection takes the last stop it reaches; garbage reads as 0 MW', () => {
|
||||
assert.equal(frpRampStop(0).name, 'ember');
|
||||
assert.equal(frpRampStop(4.99).name, 'ember');
|
||||
assert.equal(frpRampStop(5).name, 'red');
|
||||
assert.equal(frpRampStop(19.9).name, 'red');
|
||||
assert.equal(frpRampStop(20).name, 'flame');
|
||||
assert.equal(frpRampStop(50).name, 'orange');
|
||||
assert.equal(frpRampStop(100).name, 'gold');
|
||||
assert.equal(frpRampStop(299.9).name, 'gold');
|
||||
assert.equal(frpRampStop(300).name, 'white-hot');
|
||||
assert.equal(frpRampStop(1246).name, 'white-hot');
|
||||
assert.equal(frpRampStop(NaN).name, 'ember');
|
||||
assert.equal(frpRampStop(-3).name, 'ember');
|
||||
assert.equal(frpRampStop(undefined).name, 'ember');
|
||||
});
|
||||
|
||||
test('hex stops convert to the "r, g, b" accents the cards paint', () => {
|
||||
assert.deepEqual(hexToRgb('#9e2a1b'), [158, 42, 27]);
|
||||
assert.deepEqual(hexToRgb('d1382a'), [209, 56, 42]);
|
||||
});
|
||||
|
||||
test('the log slider pins 0 MW at the left and runs 1 → 1000 MW; values round-trip', () => {
|
||||
assert.equal(sliderPositionForFrp(0), 0);
|
||||
assert.equal(sliderPositionForFrp(0.5), 0);
|
||||
assert.equal(sliderPositionForFrp(1), 0);
|
||||
assert.ok(Math.abs(sliderPositionForFrp(10) - 1 / 3) < 1e-9);
|
||||
assert.ok(Math.abs(sliderPositionForFrp(100) - 2 / 3) < 1e-9);
|
||||
assert.equal(sliderPositionForFrp(1000), 1);
|
||||
assert.equal(sliderPositionForFrp(5000), 1, 'clamped past the axis end');
|
||||
assert.equal(frpForSliderPosition(0), 0);
|
||||
assert.equal(frpForSliderPosition(1), 1000);
|
||||
assert.equal(frpForSliderPosition(-1), 0);
|
||||
assert.equal(frpForSliderPosition(2), 1000);
|
||||
for (const frp of FRP_SLIDER_TICKS) {
|
||||
assert.equal(
|
||||
frpForSliderPosition(sliderPositionForFrp(frp)),
|
||||
frp,
|
||||
`tick ${frp} MW round-trips through the axis`,
|
||||
);
|
||||
}
|
||||
// Ticks spread along the track instead of bunching at one end.
|
||||
const positions = FRP_SLIDER_TICKS.map(sliderPositionForFrp);
|
||||
for (let i = 1; i < positions.length; i += 1) {
|
||||
assert.ok(positions[i] - positions[i - 1] >= 0.09, `tick gap ${i}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('the track gradient is the stepped ramp on the log axis, so it doubles as the legend', () => {
|
||||
const gradient = rampTrackGradient();
|
||||
assert.match(gradient, /^linear-gradient\(90deg, /);
|
||||
assert.match(gradient, /#9e2a1b 0\.00% 23\.3\d%/);
|
||||
assert.match(gradient, /#d2401f 23\.3\d% 43\.3\d%/);
|
||||
assert.match(gradient, /#fff3b0 82\.5\d% 100\.00%\)$/);
|
||||
assert.equal(gradient.match(/#/g).length, FRP_RAMP.length);
|
||||
});
|
||||
|
||||
test('minimum FRP normalizes to whole megawatts inside the codec bounds', () => {
|
||||
assert.equal(normalizeMinFrp(0), 0);
|
||||
assert.equal(normalizeMinFrp(19.6), 20);
|
||||
assert.equal(normalizeMinFrp('100'), 100);
|
||||
assert.equal(normalizeMinFrp(10000), 10000);
|
||||
assert.equal(normalizeMinFrp(10001), null);
|
||||
assert.equal(normalizeMinFrp(-1), null);
|
||||
assert.equal(normalizeMinFrp('abc'), null);
|
||||
assert.equal(normalizeMinFrp(''), null);
|
||||
assert.equal(normalizeMinFrp(null), null);
|
||||
assert.equal(normalizeMinFrp(NaN), null);
|
||||
});
|
||||
|
||||
test('confidence modes are exactly all / nominal / high', () => {
|
||||
assert.deepEqual([...FIRMS_CONFIDENCE_MODES], ['all', 'nominal', 'high']);
|
||||
assert.equal(normalizeConfidenceMode(' High '), 'high');
|
||||
assert.equal(normalizeConfidenceMode('nominal'), 'nominal');
|
||||
assert.equal(normalizeConfidenceMode('low'), null);
|
||||
assert.equal(normalizeConfidenceMode(0.9), null);
|
||||
assert.equal(normalizeConfidenceMode(undefined), null);
|
||||
});
|
||||
|
||||
test('a partial filter patches the current one and invalid fields keep their value', () => {
|
||||
assert.deepEqual(normalizeFireFilter(), DEFAULT_FIRE_FILTER);
|
||||
assert.deepEqual(normalizeFireFilter({ minFrp: 20 }), {
|
||||
minFrp: 20,
|
||||
confidence: 'all',
|
||||
});
|
||||
assert.deepEqual(
|
||||
normalizeFireFilter(
|
||||
{ confidence: 'high' },
|
||||
{ minFrp: 50, confidence: 'all' },
|
||||
),
|
||||
{ minFrp: 50, confidence: 'high' },
|
||||
);
|
||||
assert.deepEqual(
|
||||
normalizeFireFilter(
|
||||
{ minFrp: 'nope', confidence: 'medium' },
|
||||
{ minFrp: 50, confidence: 'nominal' },
|
||||
),
|
||||
{ minFrp: 50, confidence: 'nominal' },
|
||||
);
|
||||
assert.equal(isDefaultFireFilter(DEFAULT_FIRE_FILTER), true);
|
||||
assert.equal(isDefaultFireFilter({ minFrp: 0, confidence: 'all' }), true);
|
||||
assert.equal(isDefaultFireFilter({ minFrp: 1, confidence: 'all' }), false);
|
||||
assert.equal(isDefaultFireFilter({ minFrp: 0, confidence: 'high' }), false);
|
||||
});
|
||||
|
||||
test('the filter keeps a fire at or above the floor and at or above the confidence mode', () => {
|
||||
const low = { frp: 864, confidence: 0.3 };
|
||||
const nominal = { frp: 20, confidence: 0.6 };
|
||||
const high = { frp: 2, confidence: 0.9 };
|
||||
assert.equal(firePassesFilter(low), true, 'default shows everything');
|
||||
assert.equal(
|
||||
firePassesFilter(nominal, { minFrp: 20, confidence: 'all' }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
firePassesFilter(nominal, { minFrp: 21, confidence: 'all' }),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
firePassesFilter(low, { minFrp: 0, confidence: 'nominal' }),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
firePassesFilter(nominal, { minFrp: 0, confidence: 'nominal' }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
firePassesFilter(high, { minFrp: 0, confidence: 'nominal' }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
firePassesFilter(nominal, { minFrp: 0, confidence: 'high' }),
|
||||
false,
|
||||
);
|
||||
assert.equal(firePassesFilter(high, { minFrp: 0, confidence: 'high' }), true);
|
||||
assert.equal(
|
||||
firePassesFilter(high, { minFrp: 5, confidence: 'high' }),
|
||||
false,
|
||||
);
|
||||
// Missing numbers read as 0, never as a pass-through.
|
||||
assert.equal(firePassesFilter({}, { minFrp: 1, confidence: 'all' }), false);
|
||||
assert.equal(
|
||||
firePassesFilter({ frp: 10 }, { minFrp: 0, confidence: 'nominal' }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('filterFires returns the same array for the default filter and preserves order otherwise', () => {
|
||||
const fires = [
|
||||
{ frp: 864, confidence: 0.3 },
|
||||
{ frp: 20, confidence: 0.6 },
|
||||
{ frp: 2, confidence: 0.9 },
|
||||
{ frp: 120, confidence: 0.9 },
|
||||
];
|
||||
assert.equal(filterFires(fires), fires);
|
||||
assert.equal(filterFires(fires, { minFrp: 0, confidence: 'all' }), fires);
|
||||
assert.deepEqual(
|
||||
filterFires(fires, { minFrp: 20, confidence: 'all' }).map((f) => f.frp),
|
||||
[864, 20, 120],
|
||||
);
|
||||
assert.deepEqual(
|
||||
filterFires(fires, { minFrp: 0, confidence: 'high' }).map((f) => f.frp),
|
||||
[2, 120],
|
||||
);
|
||||
assert.deepEqual(
|
||||
filterFires(fires, { minFrp: 100, confidence: 'nominal' }).map(
|
||||
(f) => f.frp,
|
||||
),
|
||||
[120],
|
||||
);
|
||||
assert.deepEqual(filterFires(null, { minFrp: 1, confidence: 'all' }), []);
|
||||
});
|
||||
@@ -15,6 +15,7 @@ import { WORLD_FOCUS_REQUEST_EVENT } from '../worldFocus.js';
|
||||
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { HEAT_REBUILD_MS } from '../layers/firms/policy.js';
|
||||
|
||||
const LAYER_SOURCE = readLayerSource(
|
||||
path.join(path.dirname(fileURLToPath(import.meta.url)), 'firmsHeatmap.js'),
|
||||
@@ -392,3 +393,153 @@ test('disable cancels a pending source and a late response cannot repopulate the
|
||||
assert.equal(h.layer.getStats().loading, false);
|
||||
} finally { h.layer.destroy(h.viewer); h.cleanup(); }
|
||||
});
|
||||
|
||||
test('the heatmap is preloaded under the markers before the first zoom-out', (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const priorDocument = globalThis.document;
|
||||
globalThis.document = {
|
||||
createElement: () => ({
|
||||
getContext: () => ({
|
||||
createImageData: (w, hh) => ({ data: new Uint8ClampedArray(w * hh * 4) }),
|
||||
putImageData() {},
|
||||
createRadialGradient: () => ({ addColorStop() {} }),
|
||||
fillRect() {},
|
||||
}),
|
||||
toDataURL: () => 'data:image/png;base64,AAAA',
|
||||
}),
|
||||
};
|
||||
const h = harness({
|
||||
withDataSource: true,
|
||||
cameraPosition: Cesium.Cartesian3.fromDegrees(-98, 30, 1_000_000),
|
||||
});
|
||||
h.viewer.camera = { positionCartographic: { height: 1_000_000 } };
|
||||
h.viewer.scene.primitives = { add() {}, remove() {}, raiseToTop() {}, contains: () => true };
|
||||
h.viewer.scene.frameState = { mode: Cesium.SceneMode.SCENE3D };
|
||||
const added = [];
|
||||
try {
|
||||
h.layer.attachShellServices({
|
||||
imageryHost: () => ({
|
||||
kind: 'globe',
|
||||
collection: {
|
||||
addImageryProvider: (provider) => {
|
||||
const layer = { provider, show: true, alpha: 1 };
|
||||
added.push(layer);
|
||||
return layer;
|
||||
},
|
||||
remove() {},
|
||||
raiseToTop() {},
|
||||
},
|
||||
}),
|
||||
});
|
||||
h.layer._renderLodForTest('close');
|
||||
assert.equal(added.length, 0, 'the markers paint first; the build trails');
|
||||
t.mock.timers.tick(HEAT_REBUILD_MS);
|
||||
assert.equal(added.length, 1, 'then the heatmap is draped');
|
||||
assert.equal(added[0].alpha, 0, 'invisible until the hand-off');
|
||||
assert.equal(added[0].show, true, 'but mapped onto the globe tiles');
|
||||
} finally {
|
||||
h.layer.destroy(h.viewer);
|
||||
h.cleanup();
|
||||
if (priorDocument === undefined) delete globalThis.document;
|
||||
else globalThis.document = priorDocument;
|
||||
}
|
||||
});
|
||||
|
||||
test('far zoom drapes one heatmap and the hand-off cross-fades it with the markers', () => {
|
||||
// A canvas stub for the heat drape and the detection glow sprites.
|
||||
const priorDocument = globalThis.document;
|
||||
const restoreDocument = () => {
|
||||
if (priorDocument === undefined) delete globalThis.document;
|
||||
else globalThis.document = priorDocument;
|
||||
};
|
||||
globalThis.document = {
|
||||
createElement: () => ({
|
||||
getContext: () => ({
|
||||
createImageData: (w, hh) => ({
|
||||
data: new Uint8ClampedArray(w * hh * 4),
|
||||
}),
|
||||
putImageData() {},
|
||||
createRadialGradient: () => ({ addColorStop() {} }),
|
||||
fillRect() {},
|
||||
}),
|
||||
toDataURL: () => 'data:image/png;base64,AAAA',
|
||||
}),
|
||||
};
|
||||
const h = harness({
|
||||
fires: [makeFire(), makeFire({ index: 8, lat: 30.6, lon: -98.3, frp: 12 })],
|
||||
withDataSource: true,
|
||||
cameraPosition: Cesium.Cartesian3.fromDegrees(-98, 30, 12_000_000),
|
||||
});
|
||||
const added = [];
|
||||
const removed = [];
|
||||
let markers = null;
|
||||
// The fade reads the camera height; the cull reads scene.camera.
|
||||
const setHeight = (height) => {
|
||||
h.viewer.camera = { positionCartographic: { height } };
|
||||
};
|
||||
setHeight(12_000_000);
|
||||
h.viewer.scene.primitives = {
|
||||
add(primitive) {
|
||||
markers = primitive;
|
||||
},
|
||||
remove() {},
|
||||
raiseToTop() {},
|
||||
contains: () => true,
|
||||
};
|
||||
h.viewer.scene.frameState = { mode: Cesium.SceneMode.SCENE3D };
|
||||
try {
|
||||
const collection = {
|
||||
addImageryProvider: (provider) => {
|
||||
const layer = { provider, show: true, alpha: 1 };
|
||||
added.push(layer);
|
||||
return layer;
|
||||
},
|
||||
remove: (layer, destroy) => removed.push({ layer, destroy }),
|
||||
raiseToTop() {},
|
||||
};
|
||||
h.layer.attachShellServices({
|
||||
imageryHost: () => ({ kind: 'globe', collection }),
|
||||
});
|
||||
h.layer._renderLodForTest('global');
|
||||
assert.equal(added.length, 1, 'one heat imagery layer');
|
||||
assert.equal(added[0].show, true);
|
||||
assert.equal(added[0].alpha, 1);
|
||||
assert.equal(h.layer._entityCountForTest(), 0, 'no rectangle entities');
|
||||
assert.ok(h.published.length > 0, 'the far band still paints cards');
|
||||
assert.equal(markers.length, 2, 'the markers are built under the heatmap');
|
||||
assert.equal(markers.show, false, 'and hidden while the heatmap is whole');
|
||||
|
||||
h.layer._renderLodForTest('regional');
|
||||
assert.equal(added.length, 1, 'switching far bands reuses the raster');
|
||||
|
||||
setHeight(1_000_000);
|
||||
h.layer._renderLodForTest('close');
|
||||
assert.equal(added.length, 1, 'the markers band keeps the same heat layer');
|
||||
assert.equal(added[0].show, true, 'still mapped onto the globe tiles');
|
||||
assert.equal(added[0].alpha, 0, 'faded out under the markers');
|
||||
assert.equal(markers.show, true);
|
||||
assert.equal(markers.get(0).color.alpha, 1);
|
||||
|
||||
// Mid hand-off both are drawn, half each, in either band.
|
||||
setHeight(4_500_000);
|
||||
h.layer._applyHandoffFadeForTest();
|
||||
assert.equal(added[0].alpha, 0.5);
|
||||
assert.equal(markers.show, true);
|
||||
assert.equal(markers.get(0).color.alpha, 0.5);
|
||||
h.layer._renderLodForTest('regional');
|
||||
assert.equal(added[0].alpha, 0.5);
|
||||
assert.equal(markers.get(1).color.alpha, 0.5, 'rebuilt markers get the fade');
|
||||
|
||||
h.layer.disable();
|
||||
assert.equal(added[0].show, false, 'disable hides the heatmap');
|
||||
|
||||
h.layer.destroy(h.viewer);
|
||||
assert.deepEqual(
|
||||
removed.map((entry) => [entry.layer, entry.destroy]),
|
||||
[[added[0], true]],
|
||||
);
|
||||
} finally {
|
||||
h.cleanup();
|
||||
restoreDocument();
|
||||
}
|
||||
});
|
||||
+14
-9
@@ -4,6 +4,8 @@
|
||||
* dedicated card canvas moved into the shared world-overlay host.
|
||||
*/
|
||||
|
||||
import { FRP_RAMP, hexToRgb } from './firmsIntensity.js';
|
||||
|
||||
/** Shipped ambient FIRMS card ceiling. Selected fires bypass this cohort. */
|
||||
export const FIRMS_AMBIENT_COHORT_LIMIT = 18;
|
||||
export const FIRMS_OVERLAY_SOURCE_ID = 'firms';
|
||||
@@ -23,20 +25,23 @@ export function fireDetectionKey(fire) {
|
||||
return `firms:${lat}:${lon}:${acq}:${source}`;
|
||||
}
|
||||
|
||||
/** Severity accent palette — matches the FIRMS glow-sprite color stops. */
|
||||
const ACCENT_RGB = Object.freeze({
|
||||
red: '224, 82, 82',
|
||||
orange: '240, 178, 62',
|
||||
yellow: '244, 227, 108',
|
||||
});
|
||||
/**
|
||||
* Severity accent palette — the FIRMS glow-sprite color stops (the six-stop
|
||||
* FRP ramp in firmsIntensity.js) as "r, g, b" strings for card accents.
|
||||
*/
|
||||
const ACCENT_RGB = Object.freeze(
|
||||
Object.fromEntries(
|
||||
FRP_RAMP.map((stop) => [stop.name, hexToRgb(stop.hex).join(', ')]),
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* Severity stop name → "r, g, b" accent string (defaults to yellow).
|
||||
* @param {string} stopName - 'red' | 'orange' | 'yellow'.
|
||||
* Severity stop name → "r, g, b" accent string (defaults to the weakest stop).
|
||||
* @param {string} stopName - A FRP_RAMP stop name ('ember' … 'white-hot').
|
||||
* @returns {string}
|
||||
*/
|
||||
export function accentForSeverity(stopName) {
|
||||
return ACCENT_RGB[stopName] || ACCENT_RGB.yellow;
|
||||
return ACCENT_RGB[stopName] || ACCENT_RGB[FRP_RAMP[0].name];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,11 +9,16 @@ import {
|
||||
formatFrpMw,
|
||||
} from './firmsLabels.js';
|
||||
|
||||
test('FIRMS formatting helpers retain the shipped severity palette', () => {
|
||||
assert.equal(accentForSeverity('red'), '224, 82, 82');
|
||||
assert.equal(accentForSeverity('orange'), '240, 178, 62');
|
||||
assert.equal(accentForSeverity('yellow'), '244, 227, 108');
|
||||
assert.equal(accentForSeverity('chartreuse'), accentForSeverity('yellow'));
|
||||
test('FIRMS card accents are the six FRP ramp stops, weakest as the fallback', () => {
|
||||
// Ember #9e2a1b … white-hot #fff3b0, lightness rising with power.
|
||||
assert.equal(accentForSeverity('ember'), '158, 42, 27');
|
||||
assert.equal(accentForSeverity('red'), '210, 64, 31');
|
||||
assert.equal(accentForSeverity('flame'), '240, 106, 40');
|
||||
assert.equal(accentForSeverity('orange'), '251, 154, 46');
|
||||
assert.equal(accentForSeverity('gold'), '253, 200, 74');
|
||||
assert.equal(accentForSeverity('white-hot'), '255, 243, 176');
|
||||
assert.equal(accentForSeverity('blue'), accentForSeverity('ember'));
|
||||
assert.equal(accentForSeverity('chartreuse'), accentForSeverity('ember'));
|
||||
});
|
||||
|
||||
test('FIRMS power that rounds to 10 MW drops the decimal', () => {
|
||||
|
||||
+20
-1
@@ -298,6 +298,20 @@ function satelliteFilterOption() {
|
||||
}
|
||||
|
||||
const OPTION_GROUPS = Object.freeze({
|
||||
// Active Fires intensity filter (HAL-676). `minFrp` is whole megawatts on
|
||||
// the codec side; the panel's log slider rounds to it. Both default to
|
||||
// "show everything": many of the strongest fires are rated low confidence.
|
||||
// The bounds and modes mirror MIN_FRP_MW_MAX / FIRMS_CONFIDENCE_MODES in
|
||||
// firmsIntensity.js (pinned by layerState.test); spelled out here because
|
||||
// this file runs in isolation in the token-ledger tests.
|
||||
'local-firms': Object.freeze([
|
||||
boundedIntegerOption('minFrp', 'f', 0, { min: 0, max: 10000 }),
|
||||
enumOption('confidence', 'c', 'all', ['all', 'nominal', 'high'], {
|
||||
all: 'a',
|
||||
nominal: 'n',
|
||||
high: 'h',
|
||||
}),
|
||||
]),
|
||||
traffic: Object.freeze([
|
||||
enumOption('roadMode', 'r', null, ['tomtom', 'osm', 'hybrid'], {
|
||||
tomtom: 't',
|
||||
@@ -626,7 +640,12 @@ export const LAYER_STATE_REGISTRY = Object.freeze([
|
||||
token: 'd',
|
||||
disposition: 'enabled-only',
|
||||
}),
|
||||
Object.freeze({ id: 'local-firms', token: 'w', disposition: 'enabled-only' }),
|
||||
Object.freeze({
|
||||
id: 'local-firms',
|
||||
token: 'w',
|
||||
disposition: 'enabled+options',
|
||||
optionOwner: 'local-firms',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'military',
|
||||
token: 'm',
|
||||
|
||||
@@ -29,6 +29,7 @@ import radioLayer from './radio.js';
|
||||
import { stampInitialShareGesture } from '../navigationPolicy.js';
|
||||
import { MAX_SINCE_DAYS } from '../layers/streetLevel/policy.js';
|
||||
import { SINCE_STOPS } from '../ui/streetLevelPresentation.js';
|
||||
import { FIRMS_CONFIDENCE_MODES, MIN_FRP_MW_MAX } from './firmsIntensity.js';
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
@@ -3007,9 +3008,89 @@ test('Clear All cancels a saved restore still waiting to start', async () => {
|
||||
gate.resolve();
|
||||
const results = await restore;
|
||||
for (const id of ['earthquakes', 'flights']) {
|
||||
assert.equal(results.find((result) => result.layerId === id).succeeded, false);
|
||||
assert.equal(
|
||||
results.find((result) => result.layerId === id).succeeded,
|
||||
false,
|
||||
);
|
||||
}
|
||||
assert.deepEqual([...manager.getEnabledLayerIds()], []);
|
||||
assert.equal(enabledLayerParam(sink), '');
|
||||
coordinator.destroy();
|
||||
});
|
||||
|
||||
/** Active Fires options decoded from a v2 link with these `lo` assignments. */
|
||||
function firmsOptions(lo) {
|
||||
const params = new URLSearchParams([
|
||||
['v', '2'],
|
||||
['l', 'w'],
|
||||
]);
|
||||
if (lo) params.set('lo', lo);
|
||||
return decodeLayerStateParams(params).options['local-firms'];
|
||||
}
|
||||
|
||||
test('Active Fires: the intensity filter rides share token w and round-trips', () => {
|
||||
const state = createDefaultLayerState();
|
||||
assert.deepEqual(state.options['local-firms'], {
|
||||
minFrp: 0,
|
||||
confidence: 'all',
|
||||
});
|
||||
state.enabledLayerIds = ['local-firms'];
|
||||
state.options['local-firms'] = { minFrp: 20, confidence: 'high' };
|
||||
const encoded = encode(state);
|
||||
assert.equal(new URLSearchParams(encoded).get('l'), 'w');
|
||||
// Other owners write their own explicit defaults into `lo` too (flights 3D,
|
||||
// wind overlay); the fire assignments ride beside them.
|
||||
const assignments = new URLSearchParams(encoded).get('lo').split('_');
|
||||
assert.ok(assignments.includes('w.f.20'), assignments.join(' '));
|
||||
assert.ok(assignments.includes('w.c.h'), assignments.join(' '));
|
||||
const decoded = decodeLayerStateParams(new URLSearchParams(encoded));
|
||||
assert.deepEqual(decoded.options['local-firms'], {
|
||||
minFrp: 20,
|
||||
confidence: 'high',
|
||||
});
|
||||
assert.deepEqual(
|
||||
parseStoredLayerState(serializeStoredLayerState(state)).options[
|
||||
'local-firms'
|
||||
],
|
||||
state.options['local-firms'],
|
||||
);
|
||||
// Defaults are omitted from the link: a plain `l=w` is "show everything".
|
||||
const plain = createDefaultLayerState();
|
||||
plain.enabledLayerIds = ['local-firms'];
|
||||
assert.equal(
|
||||
new URLSearchParams(encode(plain))
|
||||
.get('lo')
|
||||
.split('_')
|
||||
.filter((assignment) => assignment.startsWith('w.')).length,
|
||||
0,
|
||||
);
|
||||
assert.deepEqual(firmsOptions(''), { minFrp: 0, confidence: 'all' });
|
||||
});
|
||||
|
||||
test('Active Fires: the codec accepts whole megawatts in bounds and the three confidence codes', () => {
|
||||
// The codec spells its bounds out (it runs in isolation in the ledger
|
||||
// tests); they must stay the layer's.
|
||||
assert.equal(MIN_FRP_MW_MAX, 10000);
|
||||
assert.deepEqual([...FIRMS_CONFIDENCE_MODES], ['all', 'nominal', 'high']);
|
||||
assert.equal(firmsOptions('w.f.300').minFrp, 300);
|
||||
assert.equal(firmsOptions('w.f.10000').minFrp, 10000);
|
||||
assert.equal(firmsOptions('w.f.10001').minFrp, 0, 'out of bounds → default');
|
||||
assert.equal(firmsOptions('w.f.-5').minFrp, 0);
|
||||
assert.equal(
|
||||
firmsOptions('w.f.2.5').minFrp,
|
||||
0,
|
||||
'a third segment is rejected',
|
||||
);
|
||||
assert.equal(firmsOptions('w.c.a').confidence, 'all');
|
||||
assert.equal(firmsOptions('w.c.n').confidence, 'nominal');
|
||||
assert.equal(firmsOptions('w.c.h').confidence, 'high');
|
||||
assert.equal(
|
||||
firmsOptions('w.c.x').confidence,
|
||||
'all',
|
||||
'unknown code → default',
|
||||
);
|
||||
assert.deepEqual(firmsOptions('w.c.n_w.f.5'), {
|
||||
minFrp: 5,
|
||||
confidence: 'nominal',
|
||||
});
|
||||
});
|
||||
@@ -90,6 +90,8 @@ async function createRealLocalLayerHarness({
|
||||
globalThis.window = { dispatchEvent() {} };
|
||||
let sampleHeightImpl = sampleHeight;
|
||||
const sampleCalls = { count: 0 };
|
||||
// The camera frustum, reduced to the one answer ground sampling asks of it.
|
||||
let baseVisibility = Cesium.Intersect.INSIDE;
|
||||
const overlayHost = {
|
||||
setVisible: (...args) => hostCalls.push(['visible', ...args]),
|
||||
setEntries: (...args) => hostCalls.push(['entries', ...args]),
|
||||
@@ -107,7 +109,19 @@ async function createRealLocalLayerHarness({
|
||||
},
|
||||
camera: {
|
||||
positionWC: Cesium.Cartesian3.fromDegrees(-97.695, 30.205, 100_000),
|
||||
frustum: { fov: Math.PI / 3 },
|
||||
directionWC: new Cesium.Cartesian3(),
|
||||
upWC: new Cesium.Cartesian3(),
|
||||
frustum: {
|
||||
fov: Math.PI / 3,
|
||||
// No planes: everything is in view. One plane no point satisfies:
|
||||
// nothing is.
|
||||
computeCullingVolume: () => ({
|
||||
planes:
|
||||
baseVisibility === Cesium.Intersect.OUTSIDE
|
||||
? [new Cesium.Cartesian4(0, 0, 0, -1)]
|
||||
: [],
|
||||
}),
|
||||
},
|
||||
moveEnd,
|
||||
flyTo() {},
|
||||
},
|
||||
@@ -151,6 +165,10 @@ async function createRealLocalLayerHarness({
|
||||
sampleCalls,
|
||||
/** Swap scene.sampleHeight mid-test (e.g. tiles finally arrive). */
|
||||
setSampleHeight(next) { sampleHeightImpl = next; },
|
||||
/** Put the feature's base inside (true) or outside the camera frustum. */
|
||||
setBaseInView(inView) {
|
||||
baseVisibility = inView ? Cesium.Intersect.INSIDE : Cesium.Intersect.OUTSIDE;
|
||||
},
|
||||
cleanup() {
|
||||
if (originalWindow === undefined) delete globalThis.window;
|
||||
else globalThis.window = originalWindow;
|
||||
@@ -736,6 +754,136 @@ test('ground sampling waits for visible globe tiles before caching a height', as
|
||||
assert.ok(Math.abs(baseHeightM(env) - 117) < 0.01);
|
||||
});
|
||||
|
||||
test('an unseen base is not sampled and arms no retry until it is in view', async (t) => {
|
||||
// Out of view the scene has no surface under the base, so the depth sample
|
||||
// would pass through the Earth (about -6,344 km) and fail on every retry.
|
||||
const env = await createRealLocalLayerHarness({
|
||||
sampleHeightSupported: true, sampleHeight: () => 117,
|
||||
});
|
||||
_resetRenderGovernorForTest();
|
||||
const governorScene = { renders: 0, requestRender() { this.renders += 1; } };
|
||||
installRenderGovernor({ scene: governorScene });
|
||||
governorScene.renders = 0; // discard the governor's own install settling frame
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const clock = installFakeClock(t);
|
||||
t.after(() => {
|
||||
env.layer.destroy(env.viewer);
|
||||
env.cleanup();
|
||||
_resetRenderGovernorForTest();
|
||||
});
|
||||
env.viewer.scene.globe = { show: true, tilesLoaded: true, getHeight: () => 117 };
|
||||
setCameraAltitude(env, 20_000);
|
||||
env.setBaseInView(false);
|
||||
runArmedRetryChain(env, t, clock, 5);
|
||||
assert.equal(env.sampleCalls.count, 0, 'an off-screen base must not be sampled');
|
||||
assert.equal(governorScene.renders, 0, 'an off-screen base must not keep a parked camera rendering');
|
||||
assert.ok(Math.abs(baseHeightM(env)) < 0.01);
|
||||
|
||||
// In the frustum but over the horizon: 50 m up, about 67 km away.
|
||||
env.setBaseInView(true);
|
||||
env.viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees(-97.0, 30.205, 50);
|
||||
runArmedRetryChain(env, t, clock, 5);
|
||||
assert.equal(env.sampleCalls.count, 0, 'a base over the horizon must not be sampled');
|
||||
assert.equal(governorScene.renders, 0);
|
||||
|
||||
setCameraAltitude(env, 20_000);
|
||||
env.preRender.raise();
|
||||
assert.equal(env.sampleCalls.count, 1, 'the base is sampled on the first walk that sees it');
|
||||
assert.ok(Math.abs(baseHeightM(env) - 117) < 0.01);
|
||||
});
|
||||
|
||||
/**
|
||||
* Give the harness a real Cesium camera `southM` metres south of the base at
|
||||
* `cameraM` metres above the ellipsoid, looking at the base's ground at
|
||||
* `groundM` (or directly away from it).
|
||||
*/
|
||||
function aimRealCamera(env, { groundM, cameraM, southM, away = false, fov = Math.PI / 3 }) {
|
||||
const entity = env.dataSources[0].entities.values[0];
|
||||
const base = Cesium.Cartographic.fromCartesian(entity.__localBaseCartesian);
|
||||
const ground = Cesium.Cartesian3.fromRadians(base.longitude, base.latitude, groundM);
|
||||
const position = Cesium.Cartesian3.fromRadians(
|
||||
base.longitude,
|
||||
base.latitude - southM / 6_371_000,
|
||||
cameraM,
|
||||
);
|
||||
const direction = Cesium.Cartesian3.normalize(
|
||||
away
|
||||
? Cesium.Cartesian3.subtract(position, ground, new Cesium.Cartesian3())
|
||||
: Cesium.Cartesian3.subtract(ground, position, new Cesium.Cartesian3()),
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
const right = Cesium.Cartesian3.normalize(
|
||||
Cesium.Cartesian3.cross(
|
||||
direction,
|
||||
Cesium.Ellipsoid.WGS84.geodeticSurfaceNormal(position),
|
||||
new Cesium.Cartesian3(),
|
||||
),
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
const up = Cesium.Cartesian3.cross(right, direction, new Cesium.Cartesian3());
|
||||
const frustum = new Cesium.PerspectiveFrustum({
|
||||
fov,
|
||||
aspectRatio: 800 / 600,
|
||||
near: 1,
|
||||
far: 1e8,
|
||||
});
|
||||
Object.assign(env.viewer.camera, {
|
||||
positionWC: position,
|
||||
directionWC: direction,
|
||||
upWC: up,
|
||||
frustum,
|
||||
});
|
||||
return frustum
|
||||
.computeCullingVolume(position, direction, up)
|
||||
.computeVisibility(new Cesium.BoundingSphere(entity.__localBaseCartesian, 1));
|
||||
}
|
||||
|
||||
const loadedTerrain = (height) => ({ show: true, tilesLoaded: true, getHeight: () => height });
|
||||
const hiddenGlobe = { show: false, tilesLoaded: true, getHeight: () => undefined };
|
||||
|
||||
for (const { label, globe, groundM, cameraM, southM, fov, baseOffScreen } of [
|
||||
// The Dead Sea (about -430 m) under a hidden globe, seen steeply down
|
||||
// through a narrow view: both 0 m and 9,000 m above it are off screen.
|
||||
{ label: 'hidden-globe depression', globe: hiddenGlobe, groundM: -430, cameraM: 20, southM: 180, fov: Math.PI / 12, baseOffScreen: true },
|
||||
// 2,500 m terrain from 3 km away at a shallow angle: the ellipsoid-height
|
||||
// base is far below the bottom of the screen.
|
||||
{ label: 'high ground with loaded terrain', globe: loadedTerrain(2_500), groundM: 2_500, cameraM: 3_500, southM: 3_000, baseOffScreen: true },
|
||||
{ label: 'high ground under a hidden globe', globe: hiddenGlobe, groundM: 2_500, cameraM: 3_500, southM: 3_000, baseOffScreen: true },
|
||||
// A low-geoid coast (about -63 m near Mumbai) from a camera 50 m above the
|
||||
// ellipsoid: the ellipsoidal horizon would hide the ground itself.
|
||||
{ label: 'ground below the ellipsoid', globe: loadedTerrain(-63), groundM: -63, cameraM: 50, southM: 2_000, baseOffScreen: false },
|
||||
]) {
|
||||
test(`${label} in view is sampled`, async (t) => {
|
||||
const env = await createRealLocalLayerHarness({
|
||||
sampleHeightSupported: true, sampleHeight: () => groundM,
|
||||
});
|
||||
const clock = installFakeClock(t);
|
||||
t.after(() => { env.layer.destroy(env.viewer); env.cleanup(); });
|
||||
env.viewer.scene.globe = globe;
|
||||
const baseVisibility = aimRealCamera(env, { groundM, cameraM, southM, fov });
|
||||
if (baseOffScreen)
|
||||
assert.equal(baseVisibility, Cesium.Intersect.OUTSIDE, 'precondition: the ellipsoid-height base is off screen');
|
||||
|
||||
clock.advance(2_100);
|
||||
env.preRender.raise();
|
||||
assert.equal(env.sampleCalls.count, 1, 'the ground in view is sampled');
|
||||
assert.ok(Math.abs(baseHeightM(env) - groundM) < 0.01);
|
||||
});
|
||||
}
|
||||
|
||||
test('under a hidden globe, ground behind the camera is not sampled', async (t) => {
|
||||
const env = await createRealLocalLayerHarness({
|
||||
sampleHeightSupported: true, sampleHeight: () => 2_500,
|
||||
});
|
||||
const clock = installFakeClock(t);
|
||||
t.after(() => { env.layer.destroy(env.viewer); env.cleanup(); });
|
||||
env.viewer.scene.globe = hiddenGlobe;
|
||||
aimRealCamera(env, { groundM: 2_500, cameraM: 3_500, southM: 3_000, away: true });
|
||||
clock.advance(2_100);
|
||||
env.preRender.raise();
|
||||
assert.equal(env.sampleCalls.count, 0);
|
||||
});
|
||||
|
||||
test('settled terrain refinement lifts an already sampled stem without another GPU sample', async (t) => {
|
||||
const env = await createRealLocalLayerHarness({
|
||||
sampleHeightSupported: true, sampleHeight: () => 117,
|
||||
|
||||
@@ -1000,9 +1000,9 @@ export function createLocalGeoJsonLayer(
|
||||
// governor the retry's preRender never arrives on a parked camera
|
||||
// and the stem stays at ellipsoid height (buried/floating) until
|
||||
// the user happens to move. Schedule the frame the retry needs.
|
||||
// Gated on a sampleable scene and in-range records only, so a far
|
||||
// camera (or a keyless scene) stays fully idle; the distance is
|
||||
// only computed for still-unsampled stems.
|
||||
// Gated on a sampleable scene and in-range, in-view records only,
|
||||
// so a far camera (or a keyless scene) stays fully idle; the
|
||||
// distance is only computed for still-unsampled stems.
|
||||
if (
|
||||
canSampleGround &&
|
||||
!record.groundSampled &&
|
||||
@@ -1010,7 +1010,8 @@ export function createLocalGeoJsonLayer(
|
||||
Cesium.Cartesian3.distance(
|
||||
viewer.camera.positionWC,
|
||||
record.base,
|
||||
) < GROUND_SAMPLE_MAX_DISTANCE_M
|
||||
) < GROUND_SAMPLE_MAX_DISTANCE_M &&
|
||||
groundSampleInView(viewer, record)
|
||||
) {
|
||||
groundRetryPending = true;
|
||||
}
|
||||
@@ -1130,9 +1131,87 @@ function refreshLocalTerrainFloor(viewer, record) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const groundSampleOccluder = new Cesium.EllipsoidalOccluder(
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
);
|
||||
|
||||
const groundSampleCarto = new Cesium.Cartographic();
|
||||
const groundSampleLow = new Cesium.Cartesian3();
|
||||
const groundSampleHigh = new Cesium.Cartesian3();
|
||||
|
||||
/** The record's vertical at `height` metres above the ellipsoid. */
|
||||
function groundSamplePoint(record, height, result) {
|
||||
groundSampleCarto.longitude = record.carto.longitude;
|
||||
groundSampleCarto.latitude = record.carto.latitude;
|
||||
groundSampleCarto.height = height;
|
||||
return Cesium.Cartographic.toCartesian(
|
||||
groundSampleCarto,
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
result,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the camera can see the ground under the record: inside the view
|
||||
* frustum and above the horizon. scene.sampleHeight reads only surface
|
||||
* rendered for the current view, so a sample of unseen ground passes through
|
||||
* the Earth (about -6,344 km near Ashburn on Esri) and fails on every 2 s
|
||||
* retry, each one a full offscreen render; 110 unseen data centres there held
|
||||
* rest frames at 58-589 ms. Unsampled bases sit on the ellipsoid, so the test
|
||||
* uses loaded globe terrain when there is some. Otherwise (a hidden globe) it
|
||||
* tests the vertical across +/-GROUND_SAMPLE_MAX_ABS_HEIGHT_M, every height a
|
||||
* sample can accept, so high or below-ellipsoid ground in view is never
|
||||
* mistaken for an unseen base. Unseen ground is sampled on the first walk that
|
||||
* sees it.
|
||||
*/
|
||||
function groundSampleInView(viewer, record) {
|
||||
const camera = viewer.camera;
|
||||
const globe = viewer.scene.globe;
|
||||
const terrain = globe?.show ? globe.getHeight?.(record.carto) : undefined;
|
||||
const known =
|
||||
Number.isFinite(terrain) &&
|
||||
Math.abs(terrain) <= GROUND_SAMPLE_MAX_ABS_HEIGHT_M;
|
||||
const low = groundSamplePoint(
|
||||
record,
|
||||
known ? terrain : -GROUND_SAMPLE_MAX_ABS_HEIGHT_M,
|
||||
groundSampleLow,
|
||||
);
|
||||
const high = known
|
||||
? low
|
||||
: groundSamplePoint(
|
||||
record,
|
||||
GROUND_SAMPLE_MAX_ABS_HEIGHT_M,
|
||||
groundSampleHigh,
|
||||
);
|
||||
// The vertical is unseen only when both ends are outside the same plane.
|
||||
const { planes } = camera.frustum.computeCullingVolume(
|
||||
camera.positionWC,
|
||||
camera.directionWC,
|
||||
camera.upWC,
|
||||
);
|
||||
for (const plane of planes) {
|
||||
if (
|
||||
Cesium.Cartesian3.dot(plane, low) + plane.w < 0 &&
|
||||
Cesium.Cartesian3.dot(plane, high) + plane.w < 0
|
||||
)
|
||||
return false;
|
||||
}
|
||||
// The ellipsoidal occluder hides every point below the ellipsoid from a
|
||||
// camera near it, so ground below 0 m (low-geoid coasts, the Dead Sea) is
|
||||
// tested at 0 m; unknown ground at the top of the vertical.
|
||||
const horizonHeight = known
|
||||
? Math.max(terrain, 0)
|
||||
: GROUND_SAMPLE_MAX_ABS_HEIGHT_M;
|
||||
groundSampleOccluder.cameraPosition = camera.positionWC;
|
||||
return groundSampleOccluder.isPointVisible(
|
||||
groundSamplePoint(record, horizonHeight, groundSampleHigh),
|
||||
);
|
||||
}
|
||||
|
||||
function sampleLocalGroundHeight(viewer, record, now) {
|
||||
if (record.groundSampled || !viewer.scene.sampleHeightSupported) return false;
|
||||
if (now - record.lastGroundSampleMs < GROUND_SAMPLE_RETRY_MS) return false;
|
||||
if (!groundSampleInView(viewer, record)) return false;
|
||||
record.lastGroundSampleMs = now;
|
||||
const globe = viewer.scene.globe;
|
||||
if (globe?.show && globe.tilesLoaded === false) return false;
|
||||
|
||||
+35
-20
@@ -3214,6 +3214,19 @@
|
||||
"Download the timing log of the last play": "تنزيل سجل توقيت آخر تشغيل",
|
||||
"Edit anchors, data packs and shot details": "تعديل نقاط الارتكاز وحزم البيانات وتفاصيل اللقطة",
|
||||
"Download this scene as JSON or an asset bundle": "تنزيل هذا المشهد بصيغة JSON أو حزمة موارد",
|
||||
"fire confidence filter|All": "الكل",
|
||||
"fire confidence filter|Nominal+": "متوسطة فأعلى",
|
||||
"fire confidence filter|High": "عالية",
|
||||
"Every detection — many of the strongest fires are rated low confidence": "كل عمليات الرصد — كثير من أقوى الحرائق مصنّفة بثقة منخفضة",
|
||||
"Nominal and high confidence detections": "عمليات الرصد ذات الثقة المتوسطة والعالية",
|
||||
"High confidence detections only": "عمليات الرصد ذات الثقة العالية فقط",
|
||||
"Fire intensity (FRP)": "الشدة (FRP)",
|
||||
"≥ {value} MW": "{value} MW على الأقل",
|
||||
"Confidence": "الثقة",
|
||||
"Minimum fire radiative power, megawatts": "الحد الأدنى للقدرة الإشعاعية للحريق، بالميغاواط",
|
||||
"fire intensity filter|All": "الكل",
|
||||
"Detection confidence": "ثقة الرصد",
|
||||
"{shown} of {total} shown": "المعروضة: {shown} من {total}",
|
||||
"Name of {name}": "اسم {name}",
|
||||
"Change the colour of {name}": "تغيير لون {name}",
|
||||
"Colour of {name}": "لون {name}",
|
||||
@@ -3307,15 +3320,17 @@
|
||||
"Search cancelled": "أُلغي البحث",
|
||||
"Sea Marks": "علامات بحرية",
|
||||
"Ski Pistes": "مسارات التزلج",
|
||||
"Fly to Tokyo": "الانتقال إلى طوكيو",
|
||||
"Show live flights": "عرض الرحلات المباشرة",
|
||||
"Fly to Tokyo": "انتقل إلى طوكيو",
|
||||
"Show live flights": "اعرض تتبّع الرحلات المباشر",
|
||||
"Reset to globe view": "ارجع إلى عرض الكرة الأرضية",
|
||||
"Show earthquakes": "اعرض الزلازل",
|
||||
"AI chat": "دردشة الذكاء الاصطناعي",
|
||||
"GEV CHAT": "دردشة GEV",
|
||||
"Ask about the map. Tell it where to go.": "اسأل عن الخريطة. أخبرها إلى أين تذهب.",
|
||||
"Ask about the map. Tell it where to go.": "اسأل عن الخريطة، ووجّهها إلى حيث تريد.",
|
||||
"Close chat": "إغلاق الدردشة",
|
||||
"Conversation": "المحادثة",
|
||||
"Explore with a question": "استكشف بسؤال",
|
||||
"Find a place, explore live data, or change your view.": "ابحث عن مكان، أو استكشف بيانات مباشرة، أو غيّر العرض.",
|
||||
"Find a place, explore live data, or change your view.": "ابحث عن مكان، أو استكشف البيانات المباشرة، أو غيّر العرض الحالي.",
|
||||
"Attachments": "المرفقات",
|
||||
"Remove attachment": "إزالة المرفق",
|
||||
"Attach a map layer": "إرفاق طبقة خريطة",
|
||||
@@ -3332,36 +3347,36 @@
|
||||
"Stop reply": "إيقاف الرد",
|
||||
"Send message": "إرسال الرسالة",
|
||||
"Panel position": "موضع اللوحة",
|
||||
"Move above the dock": "النقل فوق الشريط",
|
||||
"Dock to the right": "تثبيت إلى اليمين",
|
||||
"Move above the dock": "النقل فوق الشريط السفلي",
|
||||
"Dock to the right": "تثبيت على اليسار",
|
||||
"Expand panel": "توسيع اللوحة",
|
||||
"Minimize panel": "تصغير اللوحة",
|
||||
"You can attach up to four items.": "يمكنك إرفاق أربعة عناصر كحد أقصى.",
|
||||
"Attachments are too large together. Use smaller files.": "المرفقات كبيرة جدًا معًا. استخدم ملفات أصغر.",
|
||||
"Attachments are too large together. Use smaller files.": "الحجم الإجمالي للمرفقات كبير جدًا. استخدم ملفات أصغر.",
|
||||
"Choose a TXT, Markdown, CSV, JSON or GeoJSON file.": "اختر ملف TXT أو Markdown أو CSV أو JSON أو GeoJSON.",
|
||||
"Each file must be 10 KB or smaller.": "يجب ألا يتجاوز حجم كل ملف 10 كيلوبايت.",
|
||||
"Choose a non-empty UTF-8 text file.": "اختر ملفًا نصيًا بترميز UTF-8 غير فارغ.",
|
||||
"Choose a non-empty UTF-8 text file.": "اختر ملفًا نصيًا غير فارغ بترميز UTF-8.",
|
||||
"Connecting…": "جارٍ الاتصال…",
|
||||
"Thinking…": "جارٍ التفكير…",
|
||||
"Working on the map…": "جارٍ العمل على الخريطة…",
|
||||
"Silent chat · microphone off": "دردشة صامتة · الميكروفون متوقف",
|
||||
"Reply interrupted. You can try again.": "توقف الرد. يمكنك المحاولة مرة أخرى.",
|
||||
"Could not connect. Try again.": "تعذّر الاتصال. حاول مرة أخرى.",
|
||||
"Reply interrupted. You can try again.": "انقطع الرد. يمكنك إعادة المحاولة.",
|
||||
"Could not connect. Try again.": "تعذّر الاتصال. أعد المحاولة.",
|
||||
"Session ended. Send a message to start a new conversation.": "انتهت الجلسة. أرسل رسالة لبدء محادثة جديدة.",
|
||||
"Connection timed out. Try again.": "انتهت مهلة الاتصال. حاول مرة أخرى.",
|
||||
"Connection timed out. Try again.": "انتهت مهلة الاتصال. أعد المحاولة.",
|
||||
"Chat closed.": "أُغلقت الدردشة.",
|
||||
"Connection stopped.": "توقف الاتصال.",
|
||||
"Message and attachments are too large. Remove an attachment or shorten your message.": "الرسالة والمرفقات كبيرة جدًا. أزل مرفقًا أو اختصر رسالتك.",
|
||||
"Message was not sent. Try again.": "لم تُرسل الرسالة. حاول مرة أخرى.",
|
||||
"Message was not sent. Try again.": "لم تُرسل الرسالة. أعد المحاولة.",
|
||||
"Reply stopped.": "أُوقف الرد.",
|
||||
"Suggestions": "اقتراحات",
|
||||
"Suggestions": "الاقتراحات",
|
||||
"Recent": "الأخيرة",
|
||||
"Quick commands": "أوامر سريعة",
|
||||
"Quick commands": "الأوامر السريعة",
|
||||
"Open AI chat": "فتح دردشة الذكاء الاصطناعي",
|
||||
"CHAT": "دردشة",
|
||||
"Back to chat": "العودة إلى الدردشة",
|
||||
"Back to voice": "العودة إلى الصوت",
|
||||
"Latest voice exchange": "آخر تبادل صوتي",
|
||||
"Latest voice exchange": "آخر حوار صوتي",
|
||||
"Try saying": "جرّب أن تقول",
|
||||
"What am I looking at?": "ما الذي أنظر إليه؟",
|
||||
"AI Provider": "مزوّد الذكاء الاصطناعي",
|
||||
@@ -3370,13 +3385,13 @@
|
||||
"Chat replies are silent. No microphone access is needed.": "ردود الدردشة صامتة. لا حاجة إلى الوصول إلى الميكروفون.",
|
||||
"Chat": "الدردشة",
|
||||
"Attach layer": "إرفاق الطبقة",
|
||||
"Typed chat uses your OpenAI API key. ChatGPT sign-in covers voice only.": "تستخدم الدردشة الكتابية مفتاح OpenAI API الخاص بك. تسجيل الدخول إلى ChatGPT يشمل الصوت فقط.",
|
||||
"Typed chat is unavailable while GEV_PREFER_CODEX_OAUTH requires ChatGPT OAuth.": "الدردشة الكتابية غير متاحة بينما يفرض GEV_PREFER_CODEX_OAUTH استخدام ChatGPT OAuth.",
|
||||
"Typed chat uses your OpenAI API key. ChatGPT sign-in covers voice only.": "تستخدم الدردشة النصية مفتاح OpenAI API الخاص بك. تسجيل الدخول إلى ChatGPT يشمل الصوت فقط.",
|
||||
"Typed chat is unavailable while GEV_PREFER_CODEX_OAUTH requires ChatGPT OAuth.": "الدردشة النصية غير متاحة ما دام GEV_PREFER_CODEX_OAUTH يفرض استخدام ChatGPT OAuth.",
|
||||
"That request needed too many steps. Changes already made to the map stay in place.": "تطلّب هذا الطلب خطوات كثيرة جدًا. تبقى التغييرات التي أُجريت على الخريطة كما هي.",
|
||||
"That request needed too many steps. Try a simpler request.": "تطلّب هذا الطلب خطوات كثيرة جدًا. جرّب طلبًا أبسط.",
|
||||
"The provider rejected the API key. Check it in Provider Settings.": "رفض المزوّد مفتاح API. تحقّق منه في إعدادات المزوّد.",
|
||||
"Too many chat requests. Try again in {seconds} s.": "طلبات دردشة كثيرة جدًا. حاول مجددًا بعد {seconds} ث.",
|
||||
"The provider refused access for this key. Check the key's project, region or permissions.": "رفض المزوّد الوصول لهذا المفتاح. تحقّق من مشروع المفتاح أو منطقته أو أذوناته.",
|
||||
"The provider rejected the API key. Check it in Provider Settings.": "رفض المزوّد مفتاح API. تحقّق منه في «إعدادات المزوّدين».",
|
||||
"Too many chat requests. Try again in {seconds} s.": "طلبات دردشة كثيرة جدًا. أعد المحاولة بعد {seconds} ث.",
|
||||
"The provider refused access for this key. Check the key's project, region or permissions.": "رفض المزوّد الوصول باستخدام هذا المفتاح. تحقّق من مشروع المفتاح أو منطقته أو أذوناته.",
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "نفد رصيد حساب المزوّد. تحقّق من الفوترة، أو اختر مزوّد ذكاء اصطناعي آخر في الإعدادات.",
|
||||
"{lower} to {upper}": "{lower} إلى {upper}",
|
||||
"Up to {upper}": "حتى {upper}",
|
||||
|
||||
+46
-31
@@ -2946,6 +2946,19 @@
|
||||
"Right panels": "Rechte Panels",
|
||||
"CONFIRMED": "BESTÄTIGT",
|
||||
"PAD / RTLS": "STARTPLATZ / RTLS",
|
||||
"fire confidence filter|All": "Alle",
|
||||
"fire confidence filter|Nominal+": "Mittel+",
|
||||
"fire confidence filter|High": "Hoch",
|
||||
"Every detection — many of the strongest fires are rated low confidence": "Alle Erkennungen — viele der stärksten Brände haben eine niedrige Konfidenz",
|
||||
"Nominal and high confidence detections": "Erkennungen mit mittlerer und hoher Konfidenz",
|
||||
"High confidence detections only": "Nur Erkennungen mit hoher Konfidenz",
|
||||
"Fire intensity (FRP)": "Intensität (FRP)",
|
||||
"≥ {value} MW": "≥ {value} MW",
|
||||
"Confidence": "Konfidenz",
|
||||
"Minimum fire radiative power, megawatts": "Minimale Strahlungsleistung des Brandes in Megawatt",
|
||||
"fire intensity filter|All": "Alle",
|
||||
"Detection confidence": "Konfidenz der Erkennung",
|
||||
"{shown} of {total} shown": "{shown} von {total} angezeigt",
|
||||
"Name of {name}": "Name von {name}",
|
||||
"Change the colour of {name}": "Farbe von {name} ändern",
|
||||
"Colour of {name}": "Farbe von {name}",
|
||||
@@ -3021,73 +3034,75 @@
|
||||
"Ski Pistes": "Skipisten",
|
||||
"Fly to Tokyo": "Nach Tokio fliegen",
|
||||
"Show live flights": "Live-Flüge anzeigen",
|
||||
"Reset to globe view": "Zur Globusansicht zurückkehren",
|
||||
"Show earthquakes": "Erdbeben anzeigen",
|
||||
"AI chat": "KI-Chat",
|
||||
"GEV CHAT": "GEV-CHAT",
|
||||
"Ask about the map. Tell it where to go.": "Frag zur Karte. Sag ihr, wohin es geht.",
|
||||
"Ask about the map. Tell it where to go.": "Fragen zur Karte stellen und Ziele nennen.",
|
||||
"Close chat": "Chat schließen",
|
||||
"Conversation": "Unterhaltung",
|
||||
"Conversation": "Gespräch",
|
||||
"Explore with a question": "Mit einer Frage erkunden",
|
||||
"Find a place, explore live data, or change your view.": "Finde einen Ort, erkunde Live-Daten oder ändere deine Ansicht.",
|
||||
"Find a place, explore live data, or change your view.": "Einen Ort finden, Live-Daten erkunden oder die Ansicht ändern.",
|
||||
"Attachments": "Anhänge",
|
||||
"Remove attachment": "Anhang entfernen",
|
||||
"Attach a map layer": "Kartenebene anhängen",
|
||||
"Choose a layer…": "Ebene wählen…",
|
||||
"Attach a file": "Datei anhängen",
|
||||
"TXT, Markdown, CSV, JSON or GeoJSON · up to 10 KB in total. Attachments are sent to your AI provider with your message.": "TXT, Markdown, CSV, JSON oder GeoJSON · insgesamt bis zu 10 KB. Anhänge werden mit deiner Nachricht an deinen KI-Anbieter gesendet.",
|
||||
"TXT, Markdown, CSV, JSON or GeoJSON · up to 10 KB in total. Attachments are sent to your AI provider with your message.": "TXT, Markdown, CSV, JSON oder GeoJSON · insgesamt bis zu 10 KB. Anhänge werden mit der Nachricht an den gewählten KI-Anbieter gesendet.",
|
||||
"Latest AI response": "Neueste KI-Antwort",
|
||||
"Expand conversation": "Unterhaltung erweitern",
|
||||
"Expand conversation": "Gespräch ausklappen",
|
||||
"Command suggestions": "Befehlsvorschläge",
|
||||
"Expand chat": "Chat erweitern",
|
||||
"Expand chat": "Chat ausklappen",
|
||||
"Add attachment": "Anhang hinzufügen",
|
||||
"Message GEV": "Nachricht an GEV",
|
||||
"Ask GEV anything about the map…": "Frag GEV alles zur Karte…",
|
||||
"Ask GEV anything about the map…": "GEV etwas zur Karte fragen…",
|
||||
"Stop reply": "Antwort stoppen",
|
||||
"Send message": "Nachricht senden",
|
||||
"Panel position": "Panelposition",
|
||||
"Panel position": "Bereichsposition",
|
||||
"Move above the dock": "Über das Dock verschieben",
|
||||
"Dock to the right": "Rechts andocken",
|
||||
"Expand panel": "Panel erweitern",
|
||||
"Minimize panel": "Panel minimieren",
|
||||
"You can attach up to four items.": "Du kannst bis zu vier Elemente anhängen.",
|
||||
"Attachments are too large together. Use smaller files.": "Die Anhänge sind zusammen zu groß. Verwende kleinere Dateien.",
|
||||
"Choose a TXT, Markdown, CSV, JSON or GeoJSON file.": "Wähle eine TXT-, Markdown-, CSV-, JSON- oder GeoJSON-Datei.",
|
||||
"Expand panel": "Bereich ausklappen",
|
||||
"Minimize panel": "Bereich minimieren",
|
||||
"You can attach up to four items.": "Es können höchstens vier Elemente angehängt werden.",
|
||||
"Attachments are too large together. Use smaller files.": "Die Anhänge sind zusammen zu groß. Kleinere Dateien verwenden.",
|
||||
"Choose a TXT, Markdown, CSV, JSON or GeoJSON file.": "Eine TXT-, Markdown-, CSV-, JSON- oder GeoJSON-Datei wählen.",
|
||||
"Each file must be 10 KB or smaller.": "Jede Datei darf höchstens 10 KB groß sein.",
|
||||
"Choose a non-empty UTF-8 text file.": "Wähle eine nicht leere UTF-8-Textdatei.",
|
||||
"Connecting…": "Verbinde…",
|
||||
"Choose a non-empty UTF-8 text file.": "Eine nicht leere UTF-8-Textdatei wählen.",
|
||||
"Connecting…": "Verbindet…",
|
||||
"Thinking…": "Denkt nach…",
|
||||
"Working on the map…": "Arbeitet an der Karte…",
|
||||
"Silent chat · microphone off": "Stiller Chat · Mikrofon aus",
|
||||
"Reply interrupted. You can try again.": "Antwort unterbrochen. Du kannst es erneut versuchen.",
|
||||
"Could not connect. Try again.": "Verbindung fehlgeschlagen. Versuch es erneut.",
|
||||
"Session ended. Send a message to start a new conversation.": "Sitzung beendet. Sende eine Nachricht, um eine neue Unterhaltung zu beginnen.",
|
||||
"Connection timed out. Try again.": "Zeitüberschreitung der Verbindung. Versuch es erneut.",
|
||||
"Silent chat · microphone off": "Chat ohne Ton · Mikrofon aus",
|
||||
"Reply interrupted. You can try again.": "Antwort unterbrochen. Erneut versuchen.",
|
||||
"Could not connect. Try again.": "Verbindung fehlgeschlagen. Erneut versuchen.",
|
||||
"Session ended. Send a message to start a new conversation.": "Sitzung beendet. Für ein neues Gespräch eine Nachricht senden.",
|
||||
"Connection timed out. Try again.": "Zeitüberschreitung bei der Verbindung. Erneut versuchen.",
|
||||
"Chat closed.": "Chat geschlossen.",
|
||||
"Connection stopped.": "Verbindung beendet.",
|
||||
"Message and attachments are too large. Remove an attachment or shorten your message.": "Nachricht und Anhänge sind zu groß. Entferne einen Anhang oder kürze deine Nachricht.",
|
||||
"Message was not sent. Try again.": "Nachricht wurde nicht gesendet. Versuch es erneut.",
|
||||
"Message and attachments are too large. Remove an attachment or shorten your message.": "Nachricht und Anhänge sind zu groß. Einen Anhang entfernen oder die Nachricht kürzen.",
|
||||
"Message was not sent. Try again.": "Nachricht wurde nicht gesendet. Erneut versuchen.",
|
||||
"Reply stopped.": "Antwort gestoppt.",
|
||||
"Suggestions": "Vorschläge",
|
||||
"Recent": "Zuletzt",
|
||||
"Recent": "Zuletzt verwendet",
|
||||
"Quick commands": "Schnellbefehle",
|
||||
"Open AI chat": "KI-Chat öffnen",
|
||||
"CHAT": "CHAT",
|
||||
"Back to chat": "Zurück zum Chat",
|
||||
"Back to voice": "Zurück zur Sprachsteuerung",
|
||||
"Latest voice exchange": "Letzter Sprachaustausch",
|
||||
"Try saying": "Sag zum Beispiel",
|
||||
"Latest voice exchange": "Letzter Sprachdialog",
|
||||
"Try saying": "Zum Beispiel",
|
||||
"What am I looking at?": "Was sehe ich gerade?",
|
||||
"AI Provider": "KI-Anbieter",
|
||||
"The selected AI provider is shared by voice and chat.": "Der gewählte KI-Anbieter gilt für Sprache und Chat.",
|
||||
"The selected AI provider is shared by voice and chat.": "Der gewählte KI-Anbieter gilt für Sprachsteuerung und Chat.",
|
||||
"Show quick commands": "Schnellbefehle anzeigen",
|
||||
"Chat replies are silent. No microphone access is needed.": "Chat-Antworten sind stumm. Kein Mikrofonzugriff nötig.",
|
||||
"Chat": "Chat",
|
||||
"Attach layer": "Ebene anhängen",
|
||||
"Typed chat uses your OpenAI API key. ChatGPT sign-in covers voice only.": "Der Text-Chat nutzt deinen OpenAI-API-Schlüssel. Die ChatGPT-Anmeldung gilt nur für Sprache.",
|
||||
"Typed chat uses your OpenAI API key. ChatGPT sign-in covers voice only.": "Der Text-Chat nutzt den OpenAI-API-Schlüssel. Die ChatGPT-Anmeldung gilt nur für die Sprachsteuerung.",
|
||||
"Typed chat is unavailable while GEV_PREFER_CODEX_OAUTH requires ChatGPT OAuth.": "Der Text-Chat ist nicht verfügbar, solange GEV_PREFER_CODEX_OAUTH ChatGPT OAuth vorschreibt.",
|
||||
"That request needed too many steps. Changes already made to the map stay in place.": "Diese Anfrage brauchte zu viele Schritte. Bereits vorgenommene Kartenänderungen bleiben erhalten.",
|
||||
"That request needed too many steps. Try a simpler request.": "Diese Anfrage brauchte zu viele Schritte. Versuche eine einfachere Anfrage.",
|
||||
"The provider rejected the API key. Check it in Provider Settings.": "Der Anbieter hat den API-Schlüssel abgelehnt. Prüfe ihn in den Anbieter-Einstellungen.",
|
||||
"That request needed too many steps. Try a simpler request.": "Diese Anfrage brauchte zu viele Schritte. Eine einfachere Anfrage versuchen.",
|
||||
"The provider rejected the API key. Check it in Provider Settings.": "Der Anbieter hat den API-Schlüssel abgelehnt. Den Schlüssel in den Anbieter-Einstellungen prüfen.",
|
||||
"Too many chat requests. Try again in {seconds} s.": "Zu viele Chat-Anfragen. In {seconds} s erneut versuchen.",
|
||||
"The provider refused access for this key. Check the key's project, region or permissions.": "Der Anbieter hat den Zugriff für diesen Schlüssel verweigert. Prüfe Projekt, Region oder Berechtigungen des Schlüssels.",
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "Das Anbieterkonto hat kein Guthaben mehr. Prüfe die Abrechnung oder wähle in den Einstellungen einen anderen KI-Anbieter."
|
||||
"The provider refused access for this key. Check the key's project, region or permissions.": "Der Anbieter hat den Zugriff für diesen Schlüssel verweigert. Projekt, Region oder Berechtigungen des Schlüssels prüfen.",
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "Das Anbieterkonto hat kein Guthaben mehr. Die Abrechnung prüfen oder in den Einstellungen einen anderen KI-Anbieter wählen."
|
||||
}
|
||||
+28
-13
@@ -2983,6 +2983,19 @@
|
||||
"Voice replies appear here.": "Las respuestas de voz aparecen aquí.",
|
||||
"Preview is off.": "La vista previa está desactivada.",
|
||||
"Automatic stop is unavailable for this voice connection.": "La parada automática no está disponible para esta conexión de voz.",
|
||||
"fire confidence filter|All": "Todas",
|
||||
"fire confidence filter|Nominal+": "Nominal+",
|
||||
"fire confidence filter|High": "Alta",
|
||||
"Every detection — many of the strongest fires are rated low confidence": "Todas las detecciones — muchos de los incendios más intensos tienen confianza baja",
|
||||
"Nominal and high confidence detections": "Detecciones de confianza nominal y alta",
|
||||
"High confidence detections only": "Solo detecciones de confianza alta",
|
||||
"Fire intensity (FRP)": "Intensidad (FRP)",
|
||||
"≥ {value} MW": "≥ {value} MW",
|
||||
"Confidence": "Confianza",
|
||||
"Minimum fire radiative power, megawatts": "Potencia radiativa mínima del fuego, en megavatios",
|
||||
"fire intensity filter|All": "Todo",
|
||||
"Detection confidence": "Confianza de la detección",
|
||||
"{shown} of {total} shown": "{shown} de {total} mostrados",
|
||||
"{lower} to {upper}": "{lower} a {upper}",
|
||||
"Up to {upper}": "Hasta {upper}",
|
||||
"From {lower}": "Desde {lower}",
|
||||
@@ -3094,8 +3107,10 @@
|
||||
"Search cancelled": "Búsqueda cancelada",
|
||||
"Sea Marks": "Señales marítimas",
|
||||
"Ski Pistes": "Pistas de esquí",
|
||||
"Fly to Tokyo": "Volar a Tokio",
|
||||
"Show live flights": "Mostrar vuelos en vivo",
|
||||
"Fly to Tokyo": "Vuela a Tokio",
|
||||
"Show live flights": "Muestra los vuelos en vivo",
|
||||
"Reset to globe view": "Vuelve a la vista del globo",
|
||||
"Show earthquakes": "Muestra los terremotos",
|
||||
"AI chat": "Chat de IA",
|
||||
"GEV CHAT": "CHAT DE GEV",
|
||||
"Ask about the map. Tell it where to go.": "Pregunta sobre el mapa. Indica adónde ir.",
|
||||
@@ -3109,12 +3124,12 @@
|
||||
"Choose a layer…": "Elige una capa…",
|
||||
"Attach a file": "Adjuntar un archivo",
|
||||
"TXT, Markdown, CSV, JSON or GeoJSON · up to 10 KB in total. Attachments are sent to your AI provider with your message.": "TXT, Markdown, CSV, JSON o GeoJSON · hasta 10 KB en total. Los adjuntos se envían a tu proveedor de IA con el mensaje.",
|
||||
"Latest AI response": "Última respuesta de IA",
|
||||
"Latest AI response": "Última respuesta de la IA",
|
||||
"Expand conversation": "Expandir conversación",
|
||||
"Command suggestions": "Sugerencias de comandos",
|
||||
"Expand chat": "Expandir chat",
|
||||
"Add attachment": "Añadir adjunto",
|
||||
"Message GEV": "Enviar mensaje a GEV",
|
||||
"Message GEV": "Mensaje para GEV",
|
||||
"Ask GEV anything about the map…": "Pregunta a GEV lo que quieras sobre el mapa…",
|
||||
"Stop reply": "Detener respuesta",
|
||||
"Send message": "Enviar mensaje",
|
||||
@@ -3124,7 +3139,7 @@
|
||||
"Expand panel": "Expandir panel",
|
||||
"Minimize panel": "Minimizar panel",
|
||||
"You can attach up to four items.": "Puedes adjuntar hasta cuatro elementos.",
|
||||
"Attachments are too large together. Use smaller files.": "Los adjuntos juntos son demasiado grandes. Usa archivos más pequeños.",
|
||||
"Attachments are too large together. Use smaller files.": "El tamaño total de los adjuntos es demasiado grande. Usa archivos más pequeños.",
|
||||
"Choose a TXT, Markdown, CSV, JSON or GeoJSON file.": "Elige un archivo TXT, Markdown, CSV, JSON o GeoJSON.",
|
||||
"Each file must be 10 KB or smaller.": "Cada archivo debe tener como máximo 10 KB.",
|
||||
"Choose a non-empty UTF-8 text file.": "Elige un archivo de texto UTF-8 que no esté vacío.",
|
||||
@@ -3135,7 +3150,7 @@
|
||||
"Reply interrupted. You can try again.": "Respuesta interrumpida. Puedes intentarlo de nuevo.",
|
||||
"Could not connect. Try again.": "No se pudo conectar. Inténtalo de nuevo.",
|
||||
"Session ended. Send a message to start a new conversation.": "Sesión finalizada. Envía un mensaje para iniciar una nueva conversación.",
|
||||
"Connection timed out. Try again.": "La conexión agotó el tiempo de espera. Inténtalo de nuevo.",
|
||||
"Connection timed out. Try again.": "Se agotó el tiempo de conexión. Inténtalo de nuevo.",
|
||||
"Chat closed.": "Chat cerrado.",
|
||||
"Connection stopped.": "Conexión detenida.",
|
||||
"Message and attachments are too large. Remove an attachment or shorten your message.": "El mensaje y los adjuntos son demasiado grandes. Quita un adjunto o acorta el mensaje.",
|
||||
@@ -3147,9 +3162,9 @@
|
||||
"Open AI chat": "Abrir chat de IA",
|
||||
"CHAT": "CHAT",
|
||||
"Back to chat": "Volver al chat",
|
||||
"Back to voice": "Volver a voz",
|
||||
"Back to voice": "Volver a la voz",
|
||||
"Latest voice exchange": "Último intercambio de voz",
|
||||
"Try saying": "Prueba a decir",
|
||||
"Try saying": "Intenta decir",
|
||||
"What am I looking at?": "¿Qué estoy viendo?",
|
||||
"AI Provider": "Proveedor de IA",
|
||||
"The selected AI provider is shared by voice and chat.": "El proveedor de IA seleccionado se usa tanto para voz como para chat.",
|
||||
@@ -3157,11 +3172,11 @@
|
||||
"Chat replies are silent. No microphone access is needed.": "Las respuestas del chat son silenciosas. No se necesita acceso al micrófono.",
|
||||
"Chat": "Chat",
|
||||
"Attach layer": "Adjuntar capa",
|
||||
"Typed chat uses your OpenAI API key. ChatGPT sign-in covers voice only.": "El chat escrito usa tu clave de la API de OpenAI. El inicio de sesión de ChatGPT solo cubre la voz.",
|
||||
"Typed chat is unavailable while GEV_PREFER_CODEX_OAUTH requires ChatGPT OAuth.": "El chat escrito no está disponible mientras GEV_PREFER_CODEX_OAUTH exija ChatGPT OAuth.",
|
||||
"That request needed too many steps. Changes already made to the map stay in place.": "Esa solicitud necesitó demasiados pasos. Los cambios ya hechos en el mapa se mantienen.",
|
||||
"That request needed too many steps. Try a simpler request.": "Esa solicitud necesitó demasiados pasos. Prueba una solicitud más sencilla.",
|
||||
"The provider rejected the API key. Check it in Provider Settings.": "El proveedor rechazó la clave de API. Revísala en Ajustes del proveedor.",
|
||||
"Typed chat uses your OpenAI API key. ChatGPT sign-in covers voice only.": "El chat escrito usa tu clave de API de OpenAI. El inicio de sesión de ChatGPT solo sirve para la voz.",
|
||||
"Typed chat is unavailable while GEV_PREFER_CODEX_OAUTH requires ChatGPT OAuth.": "El chat escrito no está disponible mientras GEV_PREFER_CODEX_OAUTH exija OAuth de ChatGPT.",
|
||||
"That request needed too many steps. Changes already made to the map stay in place.": "Esa solicitud requirió demasiados pasos. Los cambios ya hechos en el mapa se mantienen.",
|
||||
"That request needed too many steps. Try a simpler request.": "Esa solicitud requirió demasiados pasos. Prueba una solicitud más sencilla.",
|
||||
"The provider rejected the API key. Check it in Provider Settings.": "El proveedor rechazó la clave de API. Revísala en Ajustes de proveedores.",
|
||||
"Too many chat requests. Try again in {seconds} s.": "Demasiadas solicitudes de chat. Vuelve a intentarlo en {seconds} s.",
|
||||
"The provider refused access for this key. Check the key's project, region or permissions.": "El proveedor denegó el acceso a esta clave. Revisa el proyecto, la región o los permisos de la clave.",
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "La cuenta del proveedor no tiene crédito. Revisa la facturación o elige otro proveedor de IA en Ajustes."
|
||||
|
||||
+41
-26
@@ -3016,6 +3016,19 @@
|
||||
"Right panels": "Panneaux de droite",
|
||||
"CONFIRMED": "CONFIRMÉE",
|
||||
"PAD / RTLS": "PAS DE TIR / RTLS",
|
||||
"fire confidence filter|All": "Toutes",
|
||||
"fire confidence filter|Nominal+": "Nominale+",
|
||||
"fire confidence filter|High": "Élevée",
|
||||
"Every detection — many of the strongest fires are rated low confidence": "Toutes les détections — beaucoup des feux les plus intenses ont une confiance faible",
|
||||
"Nominal and high confidence detections": "Détections de confiance nominale et élevée",
|
||||
"High confidence detections only": "Détections de confiance élevée uniquement",
|
||||
"Fire intensity (FRP)": "Intensité (FRP)",
|
||||
"≥ {value} MW": "≥ {value} MW",
|
||||
"Confidence": "Confiance",
|
||||
"Minimum fire radiative power, megawatts": "Puissance radiative minimale du feu, en mégawatts",
|
||||
"fire intensity filter|All": "Toutes",
|
||||
"Detection confidence": "Confiance de la détection",
|
||||
"{shown} of {total} shown": "{shown} sur {total} affichées",
|
||||
"Name of {name}": "Nom de {name}",
|
||||
"Change the colour of {name}": "Changer la couleur de {name}",
|
||||
"Colour of {name}": "Couleur de {name}",
|
||||
@@ -3096,41 +3109,43 @@
|
||||
"Ski Pistes": "Pistes de ski",
|
||||
"Fly to Tokyo": "Aller à Tokyo",
|
||||
"Show live flights": "Afficher les vols en direct",
|
||||
"Reset to globe view": "Revenir à la vue du globe",
|
||||
"Show earthquakes": "Afficher les séismes",
|
||||
"AI chat": "Discussion IA",
|
||||
"GEV CHAT": "DISCUSSION GEV",
|
||||
"Ask about the map. Tell it where to go.": "Posez une question sur la carte. Dites-lui où aller.",
|
||||
"Close chat": "Fermer la discussion",
|
||||
"Conversation": "Conversation",
|
||||
"Explore with a question": "Explorer avec une question",
|
||||
"Explore with a question": "Explorez en posant une question",
|
||||
"Find a place, explore live data, or change your view.": "Trouvez un lieu, explorez des données en direct ou changez de vue.",
|
||||
"Attachments": "Pièces jointes",
|
||||
"Remove attachment": "Retirer la pièce jointe",
|
||||
"Attach a map layer": "Joindre une couche de carte",
|
||||
"Attach a map layer": "Joindre une couche de la carte",
|
||||
"Choose a layer…": "Choisir une couche…",
|
||||
"Attach a file": "Joindre un fichier",
|
||||
"TXT, Markdown, CSV, JSON or GeoJSON · up to 10 KB in total. Attachments are sent to your AI provider with your message.": "TXT, Markdown, CSV, JSON ou GeoJSON · 10 Ko max. au total. Les pièces jointes sont envoyées à votre fournisseur d’IA avec votre message.",
|
||||
"Latest AI response": "Dernière réponse de l’IA",
|
||||
"Expand conversation": "Agrandir la conversation",
|
||||
"TXT, Markdown, CSV, JSON or GeoJSON · up to 10 KB in total. Attachments are sent to your AI provider with your message.": "TXT, Markdown, CSV, JSON ou GeoJSON · 10 Ko max. au total. Les pièces jointes sont envoyées à votre fournisseur d'IA avec votre message.",
|
||||
"Latest AI response": "Dernière réponse de l'IA",
|
||||
"Expand conversation": "Développer la conversation",
|
||||
"Command suggestions": "Suggestions de commandes",
|
||||
"Expand chat": "Agrandir la discussion",
|
||||
"Expand chat": "Développer la discussion",
|
||||
"Add attachment": "Ajouter une pièce jointe",
|
||||
"Message GEV": "Message à GEV",
|
||||
"Ask GEV anything about the map…": "Demandez à GEV ce que vous voulez sur la carte…",
|
||||
"Ask GEV anything about the map…": "Posez à GEV toutes vos questions sur la carte…",
|
||||
"Stop reply": "Arrêter la réponse",
|
||||
"Send message": "Envoyer le message",
|
||||
"Panel position": "Position du panneau",
|
||||
"Move above the dock": "Placer au-dessus du dock",
|
||||
"Dock to the right": "Ancrer à droite",
|
||||
"Expand panel": "Agrandir le panneau",
|
||||
"Expand panel": "Développer le panneau",
|
||||
"Minimize panel": "Réduire le panneau",
|
||||
"You can attach up to four items.": "Vous pouvez joindre jusqu’à quatre éléments.",
|
||||
"Attachments are too large together. Use smaller files.": "Les pièces jointes sont trop volumineuses ensemble. Utilisez des fichiers plus petits.",
|
||||
"You can attach up to four items.": "Vous pouvez joindre jusqu'à quatre éléments.",
|
||||
"Attachments are too large together. Use smaller files.": "La taille totale des pièces jointes est trop importante. Utilisez des fichiers plus petits.",
|
||||
"Choose a TXT, Markdown, CSV, JSON or GeoJSON file.": "Choisissez un fichier TXT, Markdown, CSV, JSON ou GeoJSON.",
|
||||
"Each file must be 10 KB or smaller.": "Chaque fichier doit faire 10 Ko ou moins.",
|
||||
"Choose a non-empty UTF-8 text file.": "Choisissez un fichier texte UTF-8 non vide.",
|
||||
"Connecting…": "Connexion…",
|
||||
"Thinking…": "Réflexion…",
|
||||
"Working on the map…": "Travail sur la carte…",
|
||||
"Working on the map…": "Mise à jour de la carte…",
|
||||
"Silent chat · microphone off": "Discussion silencieuse · micro coupé",
|
||||
"Reply interrupted. You can try again.": "Réponse interrompue. Vous pouvez réessayer.",
|
||||
"Could not connect. Try again.": "Connexion impossible. Réessayez.",
|
||||
@@ -3139,30 +3154,30 @@
|
||||
"Chat closed.": "Discussion fermée.",
|
||||
"Connection stopped.": "Connexion arrêtée.",
|
||||
"Message and attachments are too large. Remove an attachment or shorten your message.": "Le message et les pièces jointes sont trop volumineux. Retirez une pièce jointe ou raccourcissez votre message.",
|
||||
"Message was not sent. Try again.": "Le message n’a pas été envoyé. Réessayez.",
|
||||
"Message was not sent. Try again.": "Le message n'a pas été envoyé. Réessayez.",
|
||||
"Reply stopped.": "Réponse arrêtée.",
|
||||
"Suggestions": "Suggestions",
|
||||
"Recent": "Récents",
|
||||
"Quick commands": "Commandes rapides",
|
||||
"Open AI chat": "Ouvrir la discussion IA",
|
||||
"CHAT": "DISCUSSION",
|
||||
"CHAT": "CHAT",
|
||||
"Back to chat": "Retour à la discussion",
|
||||
"Back to voice": "Retour à la voix",
|
||||
"Back to voice": "Retour au mode vocal",
|
||||
"Latest voice exchange": "Dernier échange vocal",
|
||||
"Try saying": "Essayez de dire",
|
||||
"What am I looking at?": "Qu’est-ce que je regarde ?",
|
||||
"AI Provider": "Fournisseur d’IA",
|
||||
"The selected AI provider is shared by voice and chat.": "Le fournisseur d’IA choisi est partagé par la voix et la discussion.",
|
||||
"What am I looking at?": "Qu'est-ce que je regarde ?",
|
||||
"AI Provider": "Fournisseur d'IA",
|
||||
"The selected AI provider is shared by voice and chat.": "Le fournisseur d'IA choisi est commun à la voix et à la discussion.",
|
||||
"Show quick commands": "Afficher les commandes rapides",
|
||||
"Chat replies are silent. No microphone access is needed.": "Les réponses de la discussion sont silencieuses. Aucun accès au micro n’est nécessaire.",
|
||||
"Chat replies are silent. No microphone access is needed.": "Les réponses de la discussion sont silencieuses. Aucun accès au microphone n'est nécessaire.",
|
||||
"Chat": "Discussion",
|
||||
"Attach layer": "Joindre la couche",
|
||||
"Typed chat uses your OpenAI API key. ChatGPT sign-in covers voice only.": "Le chat écrit utilise votre clé d’API OpenAI. La connexion ChatGPT ne couvre que la voix.",
|
||||
"Typed chat is unavailable while GEV_PREFER_CODEX_OAUTH requires ChatGPT OAuth.": "Le chat écrit est indisponible tant que GEV_PREFER_CODEX_OAUTH impose ChatGPT OAuth.",
|
||||
"That request needed too many steps. Changes already made to the map stay in place.": "Cette demande a nécessité trop d’étapes. Les modifications déjà apportées à la carte sont conservées.",
|
||||
"That request needed too many steps. Try a simpler request.": "Cette demande a nécessité trop d’étapes. Essayez une demande plus simple.",
|
||||
"The provider rejected the API key. Check it in Provider Settings.": "Le fournisseur a refusé la clé d’API. Vérifiez-la dans les paramètres du fournisseur.",
|
||||
"Too many chat requests. Try again in {seconds} s.": "Trop de requêtes de chat. Réessayez dans {seconds} s.",
|
||||
"The provider refused access for this key. Check the key's project, region or permissions.": "Le fournisseur a refusé l’accès pour cette clé. Vérifiez le projet, la région ou les autorisations de la clé.",
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "Le compte du fournisseur n’a plus de crédit. Vérifiez la facturation ou choisissez un autre fournisseur d’IA dans les paramètres."
|
||||
"Typed chat uses your OpenAI API key. ChatGPT sign-in covers voice only.": "La discussion écrite utilise votre clé API OpenAI. La connexion ChatGPT ne couvre que la voix.",
|
||||
"Typed chat is unavailable while GEV_PREFER_CODEX_OAUTH requires ChatGPT OAuth.": "La discussion écrite est indisponible tant que GEV_PREFER_CODEX_OAUTH impose l'OAuth ChatGPT.",
|
||||
"That request needed too many steps. Changes already made to the map stay in place.": "Cette demande a nécessité trop d'étapes. Les modifications déjà apportées à la carte sont conservées.",
|
||||
"That request needed too many steps. Try a simpler request.": "Cette demande a nécessité trop d'étapes. Essayez une demande plus simple.",
|
||||
"The provider rejected the API key. Check it in Provider Settings.": "Le fournisseur a refusé la clé API. Vérifiez-la dans les paramètres des fournisseurs.",
|
||||
"Too many chat requests. Try again in {seconds} s.": "Trop de requêtes de discussion. Réessayez dans {seconds} s.",
|
||||
"The provider refused access for this key. Check the key's project, region or permissions.": "Le fournisseur a refusé l'accès pour cette clé. Vérifiez le projet, la région ou les autorisations de la clé.",
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "Le compte du fournisseur n'a plus de crédit. Vérifiez la facturation ou choisissez un autre fournisseur d'IA dans les paramètres."
|
||||
}
|
||||
+43
-28
@@ -3016,6 +3016,19 @@
|
||||
"Right panels": "Pannelli a destra",
|
||||
"CONFIRMED": "CONFERMATA",
|
||||
"PAD / RTLS": "SITO DI LANCIO / RTLS",
|
||||
"fire confidence filter|All": "Tutti",
|
||||
"fire confidence filter|Nominal+": "Nominale+",
|
||||
"fire confidence filter|High": "Alta",
|
||||
"Every detection — many of the strongest fires are rated low confidence": "Tutti i rilevamenti — molti degli incendi più intensi hanno confidenza bassa",
|
||||
"Nominal and high confidence detections": "Rilevamenti con confidenza nominale e alta",
|
||||
"High confidence detections only": "Solo rilevamenti con confidenza alta",
|
||||
"Fire intensity (FRP)": "Intensità (FRP)",
|
||||
"≥ {value} MW": "≥ {value} MW",
|
||||
"Confidence": "Confidenza",
|
||||
"Minimum fire radiative power, megawatts": "Potenza radiativa minima dell'incendio, in megawatt",
|
||||
"fire intensity filter|All": "Tutti",
|
||||
"Detection confidence": "Confidenza del rilevamento",
|
||||
"{shown} of {total} shown": "{shown} di {total} mostrati",
|
||||
"Name of {name}": "Nome di {name}",
|
||||
"Change the colour of {name}": "Cambia il colore di {name}",
|
||||
"Colour of {name}": "Colore di {name}",
|
||||
@@ -3095,47 +3108,49 @@
|
||||
"Sea Marks": "Segnalamenti marittimi",
|
||||
"Ski Pistes": "Piste da sci",
|
||||
"Fly to Tokyo": "Vola a Tokyo",
|
||||
"Show live flights": "Mostra voli in diretta",
|
||||
"Show live flights": "Mostra i voli live",
|
||||
"Reset to globe view": "Ripristina la vista del globo",
|
||||
"Show earthquakes": "Mostra i terremoti",
|
||||
"AI chat": "Chat IA",
|
||||
"GEV CHAT": "CHAT GEV",
|
||||
"Ask about the map. Tell it where to go.": "Chiedi della mappa. Dille dove andare.",
|
||||
"Close chat": "Chiudi chat",
|
||||
"Ask about the map. Tell it where to go.": "Fai domande sulla mappa. Digli dove andare.",
|
||||
"Close chat": "Chiudi la chat",
|
||||
"Conversation": "Conversazione",
|
||||
"Explore with a question": "Esplora con una domanda",
|
||||
"Find a place, explore live data, or change your view.": "Trova un luogo, esplora dati in diretta o cambia vista.",
|
||||
"Find a place, explore live data, or change your view.": "Trova un luogo, esplora i dati live o cambia vista.",
|
||||
"Attachments": "Allegati",
|
||||
"Remove attachment": "Rimuovi allegato",
|
||||
"Remove attachment": "Rimuovi l'allegato",
|
||||
"Attach a map layer": "Allega un livello della mappa",
|
||||
"Choose a layer…": "Scegli un livello…",
|
||||
"Attach a file": "Allega un file",
|
||||
"TXT, Markdown, CSV, JSON or GeoJSON · up to 10 KB in total. Attachments are sent to your AI provider with your message.": "TXT, Markdown, CSV, JSON o GeoJSON · fino a 10 KB in totale. Gli allegati vengono inviati al tuo fornitore di IA insieme al messaggio.",
|
||||
"Latest AI response": "Ultima risposta dell’IA",
|
||||
"Expand conversation": "Espandi conversazione",
|
||||
"TXT, Markdown, CSV, JSON or GeoJSON · up to 10 KB in total. Attachments are sent to your AI provider with your message.": "TXT, Markdown, CSV, JSON o GeoJSON · fino a 10 KB in totale. Gli allegati vengono inviati al tuo provider IA insieme al messaggio.",
|
||||
"Latest AI response": "Ultima risposta dell'IA",
|
||||
"Expand conversation": "Espandi la conversazione",
|
||||
"Command suggestions": "Suggerimenti di comandi",
|
||||
"Expand chat": "Espandi chat",
|
||||
"Expand chat": "Espandi la chat",
|
||||
"Add attachment": "Aggiungi allegato",
|
||||
"Message GEV": "Messaggio a GEV",
|
||||
"Ask GEV anything about the map…": "Chiedi a GEV qualsiasi cosa sulla mappa…",
|
||||
"Stop reply": "Interrompi risposta",
|
||||
"Send message": "Invia messaggio",
|
||||
"Stop reply": "Interrompi la risposta",
|
||||
"Send message": "Invia il messaggio",
|
||||
"Panel position": "Posizione del pannello",
|
||||
"Move above the dock": "Sposta sopra il dock",
|
||||
"Dock to the right": "Aggancia a destra",
|
||||
"Expand panel": "Espandi pannello",
|
||||
"Minimize panel": "Riduci pannello",
|
||||
"Expand panel": "Espandi il pannello",
|
||||
"Minimize panel": "Riduci il pannello",
|
||||
"You can attach up to four items.": "Puoi allegare fino a quattro elementi.",
|
||||
"Attachments are too large together. Use smaller files.": "Gli allegati insieme sono troppo grandi. Usa file più piccoli.",
|
||||
"Attachments are too large together. Use smaller files.": "Nel complesso gli allegati sono troppo grandi. Usa file più piccoli.",
|
||||
"Choose a TXT, Markdown, CSV, JSON or GeoJSON file.": "Scegli un file TXT, Markdown, CSV, JSON o GeoJSON.",
|
||||
"Each file must be 10 KB or smaller.": "Ogni file deve essere di 10 KB o meno.",
|
||||
"Each file must be 10 KB or smaller.": "Ogni file non deve superare i 10 KB.",
|
||||
"Choose a non-empty UTF-8 text file.": "Scegli un file di testo UTF-8 non vuoto.",
|
||||
"Connecting…": "Connessione…",
|
||||
"Thinking…": "Sto pensando…",
|
||||
"Working on the map…": "Lavoro sulla mappa…",
|
||||
"Working on the map…": "Sto lavorando sulla mappa…",
|
||||
"Silent chat · microphone off": "Chat silenziosa · microfono spento",
|
||||
"Reply interrupted. You can try again.": "Risposta interrotta. Puoi riprovare.",
|
||||
"Could not connect. Try again.": "Impossibile connettersi. Riprova.",
|
||||
"Session ended. Send a message to start a new conversation.": "Sessione terminata. Invia un messaggio per iniziare una nuova conversazione.",
|
||||
"Connection timed out. Try again.": "Connessione scaduta. Riprova.",
|
||||
"Connection timed out. Try again.": "La connessione è andata in timeout. Riprova.",
|
||||
"Chat closed.": "Chat chiusa.",
|
||||
"Connection stopped.": "Connessione interrotta.",
|
||||
"Message and attachments are too large. Remove an attachment or shorten your message.": "Messaggio e allegati sono troppo grandi. Rimuovi un allegato o accorcia il messaggio.",
|
||||
@@ -3144,25 +3159,25 @@
|
||||
"Suggestions": "Suggerimenti",
|
||||
"Recent": "Recenti",
|
||||
"Quick commands": "Comandi rapidi",
|
||||
"Open AI chat": "Apri chat IA",
|
||||
"Open AI chat": "Apri la chat IA",
|
||||
"CHAT": "CHAT",
|
||||
"Back to chat": "Torna alla chat",
|
||||
"Back to voice": "Torna alla voce",
|
||||
"Latest voice exchange": "Ultimo scambio vocale",
|
||||
"Try saying": "Prova a dire",
|
||||
"What am I looking at?": "Cosa sto guardando?",
|
||||
"AI Provider": "Fornitore di IA",
|
||||
"The selected AI provider is shared by voice and chat.": "Il fornitore di IA scelto è condiviso da voce e chat.",
|
||||
"Show quick commands": "Mostra comandi rapidi",
|
||||
"Chat replies are silent. No microphone access is needed.": "Le risposte della chat sono silenziose. Non serve l’accesso al microfono.",
|
||||
"AI Provider": "Provider IA",
|
||||
"The selected AI provider is shared by voice and chat.": "Il provider IA selezionato è condiviso tra voce e chat.",
|
||||
"Show quick commands": "Mostra i comandi rapidi",
|
||||
"Chat replies are silent. No microphone access is needed.": "Le risposte della chat sono silenziose. Non serve l'accesso al microfono.",
|
||||
"Chat": "Chat",
|
||||
"Attach layer": "Allega livello",
|
||||
"Typed chat uses your OpenAI API key. ChatGPT sign-in covers voice only.": "La chat scritta usa la tua chiave API OpenAI. L’accesso a ChatGPT copre solo la voce.",
|
||||
"Typed chat uses your OpenAI API key. ChatGPT sign-in covers voice only.": "La chat scritta usa la tua chiave API OpenAI. L'accesso a ChatGPT vale solo per la voce.",
|
||||
"Typed chat is unavailable while GEV_PREFER_CODEX_OAUTH requires ChatGPT OAuth.": "La chat scritta non è disponibile finché GEV_PREFER_CODEX_OAUTH richiede ChatGPT OAuth.",
|
||||
"That request needed too many steps. Changes already made to the map stay in place.": "Questa richiesta ha richiesto troppi passaggi. Le modifiche già fatte alla mappa restano.",
|
||||
"That request needed too many steps. Try a simpler request.": "Questa richiesta ha richiesto troppi passaggi. Prova una richiesta più semplice.",
|
||||
"The provider rejected the API key. Check it in Provider Settings.": "Il provider ha rifiutato la chiave API. Controllala nelle impostazioni del provider.",
|
||||
"That request needed too many steps. Changes already made to the map stay in place.": "Per questa richiesta sono serviti troppi passaggi. Le modifiche già apportate alla mappa restano.",
|
||||
"That request needed too many steps. Try a simpler request.": "Per questa richiesta sono serviti troppi passaggi. Prova con una richiesta più semplice.",
|
||||
"The provider rejected the API key. Check it in Provider Settings.": "Il provider ha rifiutato la chiave API. Controllala in Impostazioni provider.",
|
||||
"Too many chat requests. Try again in {seconds} s.": "Troppe richieste di chat. Riprova tra {seconds} s.",
|
||||
"The provider refused access for this key. Check the key's project, region or permissions.": "Il provider ha negato l’accesso per questa chiave. Controlla progetto, regione o autorizzazioni della chiave.",
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "L’account del provider ha esaurito il credito. Controlla la fatturazione o scegli un altro provider IA nelle impostazioni."
|
||||
"The provider refused access for this key. Check the key's project, region or permissions.": "Il provider ha negato l'accesso per questa chiave. Controlla progetto, regione o autorizzazioni della chiave.",
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "L'account del provider ha esaurito il credito. Controlla la fatturazione o scegli un altro provider IA in Impostazioni."
|
||||
}
|
||||
+40
-25
@@ -2876,6 +2876,19 @@
|
||||
"Right panels": "右パネル",
|
||||
"CONFIRMED": "確認済み",
|
||||
"PAD / RTLS": "射場 / RTLS",
|
||||
"fire confidence filter|All": "すべて",
|
||||
"fire confidence filter|Nominal+": "中以上",
|
||||
"fire confidence filter|High": "高",
|
||||
"Every detection — many of the strongest fires are rated low confidence": "すべての検知 — 最も強い火災の多くは信頼度が低と判定されています",
|
||||
"Nominal and high confidence detections": "信頼度が中・高の検知",
|
||||
"High confidence detections only": "信頼度が高の検知のみ",
|
||||
"Fire intensity (FRP)": "強度(FRP)",
|
||||
"≥ {value} MW": "≥ {value} MW",
|
||||
"Confidence": "信頼度",
|
||||
"Minimum fire radiative power, megawatts": "火災放射強度の最小値(メガワット)",
|
||||
"fire intensity filter|All": "すべて",
|
||||
"Detection confidence": "検知の信頼度",
|
||||
"{shown} of {total} shown": "{total}件中{shown}件を表示",
|
||||
"Name of {name}": "{name}の名前",
|
||||
"Change the colour of {name}": "{name}の色を変更",
|
||||
"Colour of {name}": "{name}の色",
|
||||
@@ -2944,28 +2957,30 @@
|
||||
"Search cancelled": "検索をキャンセルしました",
|
||||
"Sea Marks": "航路標識",
|
||||
"Ski Pistes": "スキーゲレンデ",
|
||||
"Fly to Tokyo": "東京へ移動",
|
||||
"Show live flights": "ライブのフライトを表示",
|
||||
"Fly to Tokyo": "東京へ移動して",
|
||||
"Show live flights": "ライブフライトを表示して",
|
||||
"Reset to globe view": "地球全体ビューに戻して",
|
||||
"Show earthquakes": "地震を表示して",
|
||||
"AI chat": "AIチャット",
|
||||
"GEV CHAT": "GEV チャット",
|
||||
"GEV CHAT": "GEVチャット",
|
||||
"Ask about the map. Tell it where to go.": "地図について質問できます。行き先を伝えてください。",
|
||||
"Close chat": "チャットを閉じる",
|
||||
"Conversation": "会話",
|
||||
"Explore with a question": "質問から探索",
|
||||
"Find a place, explore live data, or change your view.": "場所を探したり、ライブデータを見たり、表示を変えたりできます。",
|
||||
"Attachments": "添付ファイル",
|
||||
"Attachments": "添付",
|
||||
"Remove attachment": "添付を削除",
|
||||
"Attach a map layer": "地図レイヤーを添付",
|
||||
"Choose a layer…": "レイヤーを選択…",
|
||||
"Attach a file": "ファイルを添付",
|
||||
"TXT, Markdown, CSV, JSON or GeoJSON · up to 10 KB in total. Attachments are sent to your AI provider with your message.": "TXT、Markdown、CSV、JSON、GeoJSON · 合計 10 KB まで。添付はメッセージとともに AI プロバイダーへ送信されます。",
|
||||
"Latest AI response": "最新の AI の応答",
|
||||
"TXT, Markdown, CSV, JSON or GeoJSON · up to 10 KB in total. Attachments are sent to your AI provider with your message.": "TXT、Markdown、CSV、JSON、GeoJSON · 合計10 KBまで。添付はメッセージとともにAIプロバイダーへ送信されます。",
|
||||
"Latest AI response": "AIの最新の応答",
|
||||
"Expand conversation": "会話を展開",
|
||||
"Command suggestions": "コマンドの候補",
|
||||
"Expand chat": "チャットを展開",
|
||||
"Add attachment": "添付を追加",
|
||||
"Message GEV": "GEV へのメッセージ",
|
||||
"Ask GEV anything about the map…": "地図について GEV に何でも質問…",
|
||||
"Message GEV": "GEVへのメッセージ",
|
||||
"Ask GEV anything about the map…": "地図についてGEVに何でも質問…",
|
||||
"Stop reply": "応答を停止",
|
||||
"Send message": "メッセージを送信",
|
||||
"Panel position": "パネルの位置",
|
||||
@@ -2973,15 +2988,15 @@
|
||||
"Dock to the right": "右側にドッキング",
|
||||
"Expand panel": "パネルを展開",
|
||||
"Minimize panel": "パネルを最小化",
|
||||
"You can attach up to four items.": "添付できるのは 4 件までです。",
|
||||
"Attachments are too large together. Use smaller files.": "添付ファイルの合計が大きすぎます。小さいファイルを使ってください。",
|
||||
"Choose a TXT, Markdown, CSV, JSON or GeoJSON file.": "TXT、Markdown、CSV、JSON、GeoJSON のファイルを選んでください。",
|
||||
"Each file must be 10 KB or smaller.": "各ファイルは 10 KB 以下にしてください。",
|
||||
"Choose a non-empty UTF-8 text file.": "空でない UTF-8 のテキストファイルを選んでください。",
|
||||
"You can attach up to four items.": "添付できるのは4件までです。",
|
||||
"Attachments are too large together. Use smaller files.": "添付の合計サイズが大きすぎます。小さいファイルを使ってください。",
|
||||
"Choose a TXT, Markdown, CSV, JSON or GeoJSON file.": "TXT、Markdown、CSV、JSON、GeoJSONのいずれかのファイルを選んでください。",
|
||||
"Each file must be 10 KB or smaller.": "各ファイルは10 KB以下にしてください。",
|
||||
"Choose a non-empty UTF-8 text file.": "空でないUTF-8のテキストファイルを選んでください。",
|
||||
"Connecting…": "接続中…",
|
||||
"Thinking…": "考え中…",
|
||||
"Working on the map…": "地図を操作中…",
|
||||
"Silent chat · microphone off": "サイレントチャット · マイクオフ",
|
||||
"Silent chat · microphone off": "テキストチャット · マイク オフ",
|
||||
"Reply interrupted. You can try again.": "応答が中断されました。もう一度お試しください。",
|
||||
"Could not connect. Try again.": "接続できませんでした。もう一度お試しください。",
|
||||
"Session ended. Send a message to start a new conversation.": "セッションが終了しました。メッセージを送ると新しい会話を始められます。",
|
||||
@@ -2992,7 +3007,7 @@
|
||||
"Message was not sent. Try again.": "メッセージを送信できませんでした。もう一度お試しください。",
|
||||
"Reply stopped.": "応答を停止しました。",
|
||||
"Suggestions": "候補",
|
||||
"Recent": "最近",
|
||||
"Recent": "履歴",
|
||||
"Quick commands": "クイックコマンド",
|
||||
"Open AI chat": "AIチャットを開く",
|
||||
"CHAT": "チャット",
|
||||
@@ -3000,19 +3015,19 @@
|
||||
"Back to voice": "音声に戻る",
|
||||
"Latest voice exchange": "直近の音声のやり取り",
|
||||
"Try saying": "話しかけてみましょう",
|
||||
"What am I looking at?": "何を見ていますか?",
|
||||
"AI Provider": "AI プロバイダー",
|
||||
"The selected AI provider is shared by voice and chat.": "選んだ AI プロバイダーは音声とチャットで共通です。",
|
||||
"What am I looking at?": "いま見えているのは何?",
|
||||
"AI Provider": "AIプロバイダー",
|
||||
"The selected AI provider is shared by voice and chat.": "選んだAIプロバイダーは音声とチャットで共通です。",
|
||||
"Show quick commands": "クイックコマンドを表示",
|
||||
"Chat replies are silent. No microphone access is needed.": "チャットの応答は音声なしです。マイクへのアクセスは不要です。",
|
||||
"Chat replies are silent. No microphone access is needed.": "チャットの応答は読み上げられません。マイクへのアクセスは不要です。",
|
||||
"Chat": "チャット",
|
||||
"Attach layer": "レイヤーを添付",
|
||||
"Typed chat uses your OpenAI API key. ChatGPT sign-in covers voice only.": "テキストチャットは OpenAI API キーを使用します。ChatGPT のサインインは音声のみが対象です。",
|
||||
"Typed chat is unavailable while GEV_PREFER_CODEX_OAUTH requires ChatGPT OAuth.": "GEV_PREFER_CODEX_OAUTH が ChatGPT OAuth を必須にしている間、テキストチャットは使えません。",
|
||||
"Typed chat uses your OpenAI API key. ChatGPT sign-in covers voice only.": "テキストチャットはOpenAI APIキーを使用します。ChatGPTのサインインは音声のみが対象です。",
|
||||
"Typed chat is unavailable while GEV_PREFER_CODEX_OAUTH requires ChatGPT OAuth.": "GEV_PREFER_CODEX_OAUTHがChatGPT OAuthを必須にしている間、テキストチャットは使えません。",
|
||||
"That request needed too many steps. Changes already made to the map stay in place.": "このリクエストは手順が多すぎました。地図に加えた変更はそのまま残ります。",
|
||||
"That request needed too many steps. Try a simpler request.": "このリクエストは手順が多すぎました。より簡単なリクエストを試してください。",
|
||||
"The provider rejected the API key. Check it in Provider Settings.": "プロバイダーが API キーを拒否しました。プロバイダー設定で確認してください。",
|
||||
"Too many chat requests. Try again in {seconds} s.": "チャットのリクエストが多すぎます。{seconds} 秒後にもう一度お試しください。",
|
||||
"The provider refused access for this key. Check the key's project, region or permissions.": "プロバイダーがこのキーへのアクセスを拒否しました。キーのプロジェクト、地域、権限を確認してください。",
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "プロバイダーのアカウントのクレジットが不足しています。請求を確認するか、設定で別の AI プロバイダーを選んでください。"
|
||||
"The provider rejected the API key. Check it in Provider Settings.": "プロバイダーがAPIキーを拒否しました。プロバイダー設定で確認してください。",
|
||||
"Too many chat requests. Try again in {seconds} s.": "チャットのリクエストが多すぎます。{seconds}秒後にもう一度お試しください。",
|
||||
"The provider refused access for this key. Check the key's project, region or permissions.": "プロバイダーがこのキーでのアクセスを拒否しました。キーのプロジェクト、リージョン、権限を確認してください。",
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "プロバイダーアカウントのクレジットが不足しています。課金状況を確認するか、設定で別のAIプロバイダーを選んでください。"
|
||||
}
|
||||
+32
-17
@@ -3016,6 +3016,19 @@
|
||||
"Right panels": "Painéis à direita",
|
||||
"CONFIRMED": "CONFIRMADA",
|
||||
"PAD / RTLS": "PLATAFORMA / RTLS",
|
||||
"fire confidence filter|All": "Todas",
|
||||
"fire confidence filter|Nominal+": "Nominal+",
|
||||
"fire confidence filter|High": "Alta",
|
||||
"Every detection — many of the strongest fires are rated low confidence": "Todas as detecções — muitos dos incêndios mais intensos têm confiança baixa",
|
||||
"Nominal and high confidence detections": "Detecções de confiança nominal e alta",
|
||||
"High confidence detections only": "Somente detecções de confiança alta",
|
||||
"Fire intensity (FRP)": "Intensidade (FRP)",
|
||||
"≥ {value} MW": "≥ {value} MW",
|
||||
"Confidence": "Confiança",
|
||||
"Minimum fire radiative power, megawatts": "Potência radiativa mínima do fogo, em megawatts",
|
||||
"fire intensity filter|All": "Todas",
|
||||
"Detection confidence": "Confiança da detecção",
|
||||
"{shown} of {total} shown": "{shown} de {total} exibidas",
|
||||
"Name of {name}": "Nome de {name}",
|
||||
"Change the colour of {name}": "Mudar a cor de {name}",
|
||||
"Colour of {name}": "Cor de {name}",
|
||||
@@ -3094,15 +3107,17 @@
|
||||
"Search cancelled": "Busca cancelada",
|
||||
"Sea Marks": "Sinais marítimos",
|
||||
"Ski Pistes": "Pistas de esqui",
|
||||
"Fly to Tokyo": "Voar para Tóquio",
|
||||
"Show live flights": "Mostrar voos ao vivo",
|
||||
"Fly to Tokyo": "Voe para Tóquio",
|
||||
"Show live flights": "Mostre os voos ao vivo",
|
||||
"Reset to globe view": "Volte à vista do globo",
|
||||
"Show earthquakes": "Mostre os terremotos",
|
||||
"AI chat": "Chat de IA",
|
||||
"GEV CHAT": "CHAT GEV",
|
||||
"Ask about the map. Tell it where to go.": "Pergunte sobre o mapa. Diga para onde ir.",
|
||||
"Close chat": "Fechar chat",
|
||||
"Conversation": "Conversa",
|
||||
"Explore with a question": "Explore com uma pergunta",
|
||||
"Find a place, explore live data, or change your view.": "Encontre um lugar, explore dados ao vivo ou mude sua visão.",
|
||||
"Find a place, explore live data, or change your view.": "Encontre um lugar, explore dados ao vivo ou mude a vista do mapa.",
|
||||
"Attachments": "Anexos",
|
||||
"Remove attachment": "Remover anexo",
|
||||
"Attach a map layer": "Anexar uma camada do mapa",
|
||||
@@ -3119,8 +3134,8 @@
|
||||
"Stop reply": "Parar resposta",
|
||||
"Send message": "Enviar mensagem",
|
||||
"Panel position": "Posição do painel",
|
||||
"Move above the dock": "Mover para cima do dock",
|
||||
"Dock to the right": "Encaixar à direita",
|
||||
"Move above the dock": "Posicionar acima do dock",
|
||||
"Dock to the right": "Acoplar à direita",
|
||||
"Expand panel": "Expandir painel",
|
||||
"Minimize panel": "Minimizar painel",
|
||||
"You can attach up to four items.": "Você pode anexar até quatro itens.",
|
||||
@@ -3132,14 +3147,14 @@
|
||||
"Thinking…": "Pensando…",
|
||||
"Working on the map…": "Trabalhando no mapa…",
|
||||
"Silent chat · microphone off": "Chat silencioso · microfone desligado",
|
||||
"Reply interrupted. You can try again.": "Resposta interrompida. Você pode tentar de novo.",
|
||||
"Could not connect. Try again.": "Não foi possível conectar. Tente de novo.",
|
||||
"Reply interrupted. You can try again.": "Resposta interrompida. Você pode tentar novamente.",
|
||||
"Could not connect. Try again.": "Não foi possível conectar. Tente novamente.",
|
||||
"Session ended. Send a message to start a new conversation.": "Sessão encerrada. Envie uma mensagem para iniciar uma nova conversa.",
|
||||
"Connection timed out. Try again.": "A conexão expirou. Tente de novo.",
|
||||
"Connection timed out. Try again.": "A conexão expirou. Tente novamente.",
|
||||
"Chat closed.": "Chat fechado.",
|
||||
"Connection stopped.": "Conexão interrompida.",
|
||||
"Message and attachments are too large. Remove an attachment or shorten your message.": "A mensagem e os anexos são grandes demais. Remova um anexo ou encurte sua mensagem.",
|
||||
"Message was not sent. Try again.": "A mensagem não foi enviada. Tente de novo.",
|
||||
"Message was not sent. Try again.": "A mensagem não foi enviada. Tente novamente.",
|
||||
"Reply stopped.": "Resposta interrompida.",
|
||||
"Suggestions": "Sugestões",
|
||||
"Recent": "Recentes",
|
||||
@@ -3148,21 +3163,21 @@
|
||||
"CHAT": "CHAT",
|
||||
"Back to chat": "Voltar ao chat",
|
||||
"Back to voice": "Voltar à voz",
|
||||
"Latest voice exchange": "Última troca de voz",
|
||||
"Latest voice exchange": "Última interação por voz",
|
||||
"Try saying": "Experimente dizer",
|
||||
"What am I looking at?": "O que estou vendo?",
|
||||
"AI Provider": "Provedor de IA",
|
||||
"The selected AI provider is shared by voice and chat.": "O provedor de IA escolhido é compartilhado por voz e chat.",
|
||||
"The selected AI provider is shared by voice and chat.": "O provedor de IA selecionado é usado tanto pela voz quanto pelo chat.",
|
||||
"Show quick commands": "Mostrar comandos rápidos",
|
||||
"Chat replies are silent. No microphone access is needed.": "As respostas do chat são silenciosas. Não é preciso acesso ao microfone.",
|
||||
"Chat": "Chat",
|
||||
"Attach layer": "Anexar camada",
|
||||
"Typed chat uses your OpenAI API key. ChatGPT sign-in covers voice only.": "O chat por texto usa sua chave da API da OpenAI. O login do ChatGPT cobre apenas a voz.",
|
||||
"Typed chat is unavailable while GEV_PREFER_CODEX_OAUTH requires ChatGPT OAuth.": "O chat por texto fica indisponível enquanto GEV_PREFER_CODEX_OAUTH exigir o ChatGPT OAuth.",
|
||||
"That request needed too many steps. Changes already made to the map stay in place.": "Esse pedido precisou de etapas demais. As alterações já feitas no mapa continuam.",
|
||||
"Typed chat uses your OpenAI API key. ChatGPT sign-in covers voice only.": "O chat por texto usa sua chave de API da OpenAI. O login do ChatGPT vale apenas para a voz.",
|
||||
"Typed chat is unavailable while GEV_PREFER_CODEX_OAUTH requires ChatGPT OAuth.": "O chat por texto fica indisponível enquanto GEV_PREFER_CODEX_OAUTH exigir o OAuth do ChatGPT.",
|
||||
"That request needed too many steps. Changes already made to the map stay in place.": "Esse pedido precisou de etapas demais. As alterações já feitas no mapa são mantidas.",
|
||||
"That request needed too many steps. Try a simpler request.": "Esse pedido precisou de etapas demais. Tente um pedido mais simples.",
|
||||
"The provider rejected the API key. Check it in Provider Settings.": "O provedor recusou a chave de API. Verifique-a nas configurações do provedor.",
|
||||
"Too many chat requests. Try again in {seconds} s.": "Pedidos de chat demais. Tente de novo em {seconds} s.",
|
||||
"The provider rejected the API key. Check it in Provider Settings.": "O provedor recusou a chave de API. Verifique-a em \"Configurações de provedores\".",
|
||||
"Too many chat requests. Try again in {seconds} s.": "Chat: limite de requisições atingido. Tente novamente em {seconds} s.",
|
||||
"The provider refused access for this key. Check the key's project, region or permissions.": "O provedor negou o acesso para esta chave. Verifique o projeto, a região ou as permissões da chave.",
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "A conta do provedor está sem crédito. Verifique o faturamento ou escolha outro provedor de IA nas configurações."
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "A conta do provedor está sem crédito. Verifique o faturamento ou escolha outro provedor de IA em \"Configurações\"."
|
||||
}
|
||||
+31
-16
@@ -3053,6 +3053,19 @@
|
||||
"Voice replies appear here.": "Здесь появляются голосовые ответы.",
|
||||
"Preview is off.": "Предпросмотр отключён.",
|
||||
"Automatic stop is unavailable for this voice connection.": "Автоматическая остановка недоступна для этого голосового подключения.",
|
||||
"fire confidence filter|All": "Все",
|
||||
"fire confidence filter|Nominal+": "Средняя+",
|
||||
"fire confidence filter|High": "Высокая",
|
||||
"Every detection — many of the strongest fires are rated low confidence": "Все детекции — у многих сильнейших пожаров достоверность низкая",
|
||||
"Nominal and high confidence detections": "Детекции средней и высокой достоверности",
|
||||
"High confidence detections only": "Только детекции высокой достоверности",
|
||||
"Fire intensity (FRP)": "Интенсивность (FRP)",
|
||||
"≥ {value} MW": "≥ {value} МВт",
|
||||
"Confidence": "Достоверность",
|
||||
"Minimum fire radiative power, megawatts": "Минимальная мощность излучения пожара, мегаватты",
|
||||
"fire intensity filter|All": "Все",
|
||||
"Detection confidence": "Достоверность детекции",
|
||||
"{shown} of {total} shown": "показано {shown} из {total}",
|
||||
"{lower} to {upper}": "от {lower} до {upper}",
|
||||
"Up to {upper}": "До {upper}",
|
||||
"From {lower}": "От {lower}",
|
||||
@@ -3169,14 +3182,16 @@
|
||||
"Search cancelled": "Поиск отменён",
|
||||
"Sea Marks": "Морские знаки",
|
||||
"Ski Pistes": "Горнолыжные трассы",
|
||||
"Fly to Tokyo": "Перелететь в Токио",
|
||||
"Show live flights": "Показать рейсы в реальном времени",
|
||||
"Fly to Tokyo": "Перелети в Токио",
|
||||
"Show live flights": "Покажи рейсы в реальном времени",
|
||||
"Reset to globe view": "Вернись к виду всего глобуса",
|
||||
"Show earthquakes": "Покажи землетрясения",
|
||||
"AI chat": "Чат с ИИ",
|
||||
"GEV CHAT": "ЧАТ GEV",
|
||||
"Ask about the map. Tell it where to go.": "Задайте вопрос о карте. Укажите, куда перейти.",
|
||||
"Close chat": "Закрыть чат",
|
||||
"Conversation": "Разговор",
|
||||
"Explore with a question": "Начните исследование с вопроса",
|
||||
"Explore with a question": "Начните с вопроса",
|
||||
"Find a place, explore live data, or change your view.": "Найдите место, изучите данные в реальном времени или измените вид.",
|
||||
"Attachments": "Вложения",
|
||||
"Remove attachment": "Удалить вложение",
|
||||
@@ -3185,7 +3200,7 @@
|
||||
"Attach a file": "Прикрепить файл",
|
||||
"TXT, Markdown, CSV, JSON or GeoJSON · up to 10 KB in total. Attachments are sent to your AI provider with your message.": "TXT, Markdown, CSV, JSON или GeoJSON · до 10 КБ в сумме. Вложения отправляются провайдеру ИИ вместе с сообщением.",
|
||||
"Latest AI response": "Последний ответ ИИ",
|
||||
"Expand conversation": "Развернуть разговор",
|
||||
"Expand conversation": "Развернуть чат",
|
||||
"Command suggestions": "Подсказки команд",
|
||||
"Expand chat": "Развернуть чат",
|
||||
"Add attachment": "Добавить вложение",
|
||||
@@ -3194,7 +3209,7 @@
|
||||
"Stop reply": "Остановить ответ",
|
||||
"Send message": "Отправить сообщение",
|
||||
"Panel position": "Положение панели",
|
||||
"Move above the dock": "Переместить над панелью",
|
||||
"Move above the dock": "Переместить над доком",
|
||||
"Dock to the right": "Закрепить справа",
|
||||
"Expand panel": "Развернуть панель",
|
||||
"Minimize panel": "Свернуть панель",
|
||||
@@ -3204,15 +3219,15 @@
|
||||
"Each file must be 10 KB or smaller.": "Размер каждого файла не должен превышать 10 КБ.",
|
||||
"Choose a non-empty UTF-8 text file.": "Выберите непустой текстовый файл UTF-8.",
|
||||
"Connecting…": "Подключение…",
|
||||
"Thinking…": "Обдумывание…",
|
||||
"Working on the map…": "Работа с картой…",
|
||||
"Thinking…": "Думаю…",
|
||||
"Working on the map…": "Работаю с картой…",
|
||||
"Silent chat · microphone off": "Текстовый чат · микрофон выключен",
|
||||
"Reply interrupted. You can try again.": "Ответ прерван. Попробуйте ещё раз.",
|
||||
"Could not connect. Try again.": "Не удалось подключиться. Попробуйте ещё раз.",
|
||||
"Session ended. Send a message to start a new conversation.": "Сеанс завершён. Отправьте сообщение, чтобы начать новый разговор.",
|
||||
"Session ended. Send a message to start a new conversation.": "Сессия завершена. Отправьте сообщение, чтобы начать новый разговор.",
|
||||
"Connection timed out. Try again.": "Время ожидания подключения истекло. Попробуйте ещё раз.",
|
||||
"Chat closed.": "Чат закрыт.",
|
||||
"Connection stopped.": "Соединение остановлено.",
|
||||
"Connection stopped.": "Соединение прервано.",
|
||||
"Message and attachments are too large. Remove an attachment or shorten your message.": "Сообщение и вложения слишком велики. Удалите вложение или сократите сообщение.",
|
||||
"Message was not sent. Try again.": "Сообщение не отправлено. Попробуйте ещё раз.",
|
||||
"Reply stopped.": "Ответ остановлен.",
|
||||
@@ -3221,9 +3236,9 @@
|
||||
"Quick commands": "Быстрые команды",
|
||||
"Open AI chat": "Открыть чат с ИИ",
|
||||
"CHAT": "ЧАТ",
|
||||
"Back to chat": "Вернуться в чат",
|
||||
"Back to chat": "Назад в чат",
|
||||
"Back to voice": "Назад к голосу",
|
||||
"Latest voice exchange": "Последний голосовой обмен",
|
||||
"Latest voice exchange": "Последние реплики голосового диалога",
|
||||
"Try saying": "Попробуйте сказать",
|
||||
"What am I looking at?": "Что я вижу?",
|
||||
"AI Provider": "Провайдер ИИ",
|
||||
@@ -3234,10 +3249,10 @@
|
||||
"Attach layer": "Прикрепить слой",
|
||||
"Typed chat uses your OpenAI API key. ChatGPT sign-in covers voice only.": "Текстовый чат использует ваш ключ API OpenAI. Вход в ChatGPT действует только для голоса.",
|
||||
"Typed chat is unavailable while GEV_PREFER_CODEX_OAUTH requires ChatGPT OAuth.": "Текстовый чат недоступен, пока GEV_PREFER_CODEX_OAUTH требует ChatGPT OAuth.",
|
||||
"That request needed too many steps. Changes already made to the map stay in place.": "Этот запрос потребовал слишком много шагов. Уже внесённые изменения карты сохранятся.",
|
||||
"That request needed too many steps. Changes already made to the map stay in place.": "Этот запрос потребовал слишком много шагов. Изменения, уже внесённые на карту, остаются.",
|
||||
"That request needed too many steps. Try a simpler request.": "Этот запрос потребовал слишком много шагов. Попробуйте запрос попроще.",
|
||||
"The provider rejected the API key. Check it in Provider Settings.": "Провайдер отклонил ключ API. Проверьте его в настройках провайдера.",
|
||||
"Too many chat requests. Try again in {seconds} s.": "Слишком много запросов чата. Повторите через {seconds} с.",
|
||||
"The provider refused access for this key. Check the key's project, region or permissions.": "Провайдер запретил доступ для этого ключа. Проверьте проект, регион или разрешения ключа.",
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "На счёте провайдера закончились средства. Проверьте оплату или выберите другого провайдера ИИ в настройках."
|
||||
"The provider rejected the API key. Check it in Provider Settings.": "Провайдер отклонил ключ API. Проверьте его в «Настройках провайдеров».",
|
||||
"Too many chat requests. Try again in {seconds} s.": "Слишком много запросов в чате. Повторите попытку через {seconds} с.",
|
||||
"The provider refused access for this key. Check the key's project, region or permissions.": "Провайдер отказал этому ключу в доступе. Проверьте проект, регион или разрешения ключа.",
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "На вашем счёте у провайдера закончились средства. Проверьте оплату или выберите другого провайдера ИИ в настройках."
|
||||
}
|
||||
+34
-19
@@ -3045,6 +3045,19 @@
|
||||
"Don't show this again": "Більше не показувати",
|
||||
"ESC to dismiss": "ESC — приховати",
|
||||
"Tip: the GEV MIC button in the dock lets you talk to the map.": "Порада: кнопка МІК у доку дає змогу говорити з картою.",
|
||||
"fire confidence filter|All": "Усі",
|
||||
"fire confidence filter|Nominal+": "Середня+",
|
||||
"fire confidence filter|High": "Висока",
|
||||
"Every detection — many of the strongest fires are rated low confidence": "Усі виявлення — багато найсильніших пожеж мають низьку достовірність",
|
||||
"Nominal and high confidence detections": "Виявлення середньої та високої достовірності",
|
||||
"High confidence detections only": "Лише виявлення високої достовірності",
|
||||
"Fire intensity (FRP)": "Інтенсивність (FRP)",
|
||||
"≥ {value} MW": "≥ {value} МВт",
|
||||
"Confidence": "Достовірність",
|
||||
"Minimum fire radiative power, megawatts": "Мінімальна потужність випромінювання пожежі, мегавати",
|
||||
"fire intensity filter|All": "Усі",
|
||||
"Detection confidence": "Достовірність виявлення",
|
||||
"{shown} of {total} shown": "показано {shown} з {total}",
|
||||
"{lower} to {upper}": "від {lower} до {upper}",
|
||||
"Up to {upper}": "До {upper}",
|
||||
"From {lower}": "Від {lower}",
|
||||
@@ -3169,18 +3182,20 @@
|
||||
"Search cancelled": "Пошук скасовано",
|
||||
"Sea Marks": "Морські знаки",
|
||||
"Ski Pistes": "Гірськолижні траси",
|
||||
"Fly to Tokyo": "Летіти до Токіо",
|
||||
"Show live flights": "Показати рейси наживо",
|
||||
"Fly to Tokyo": "Полети до Токіо",
|
||||
"Show live flights": "Покажи рейси онлайн",
|
||||
"Reset to globe view": "Поверни вигляд глобуса",
|
||||
"Show earthquakes": "Покажи землетруси",
|
||||
"AI chat": "ШІ-чат",
|
||||
"GEV CHAT": "ЧАТ GEV",
|
||||
"Ask about the map. Tell it where to go.": "Питайте про мапу. Скажіть, куди перейти.",
|
||||
"Ask about the map. Tell it where to go.": "Питайте про карту. Скажіть, куди перейти.",
|
||||
"Close chat": "Закрити чат",
|
||||
"Conversation": "Розмова",
|
||||
"Explore with a question": "Досліджуйте за допомогою запитання",
|
||||
"Find a place, explore live data, or change your view.": "Знайдіть місце, перегляньте дані наживо або змініть вигляд.",
|
||||
"Explore with a question": "Почніть із запитання",
|
||||
"Find a place, explore live data, or change your view.": "Знайдіть місце, перегляньте дані в реальному часі або змініть вигляд.",
|
||||
"Attachments": "Вкладення",
|
||||
"Remove attachment": "Вилучити вкладення",
|
||||
"Attach a map layer": "Прикріпити шар мапи",
|
||||
"Remove attachment": "Видалити вкладення",
|
||||
"Attach a map layer": "Прикріпити шар карти",
|
||||
"Choose a layer…": "Виберіть шар…",
|
||||
"Attach a file": "Прикріпити файл",
|
||||
"TXT, Markdown, CSV, JSON or GeoJSON · up to 10 KB in total. Attachments are sent to your AI provider with your message.": "TXT, Markdown, CSV, JSON або GeoJSON · до 10 КБ загалом. Вкладення надсилаються вашому постачальнику ШІ разом із повідомленням.",
|
||||
@@ -3190,30 +3205,30 @@
|
||||
"Expand chat": "Розгорнути чат",
|
||||
"Add attachment": "Додати вкладення",
|
||||
"Message GEV": "Повідомлення для GEV",
|
||||
"Ask GEV anything about the map…": "Запитайте GEV будь-що про мапу…",
|
||||
"Ask GEV anything about the map…": "Запитайте GEV будь-що про карту…",
|
||||
"Stop reply": "Зупинити відповідь",
|
||||
"Send message": "Надіслати повідомлення",
|
||||
"Panel position": "Положення панелі",
|
||||
"Panel position": "Розташування панелі",
|
||||
"Move above the dock": "Перемістити над доком",
|
||||
"Dock to the right": "Закріпити праворуч",
|
||||
"Expand panel": "Розгорнути панель",
|
||||
"Minimize panel": "Згорнути панель",
|
||||
"You can attach up to four items.": "Можна прикріпити до чотирьох елементів.",
|
||||
"Attachments are too large together. Use smaller files.": "Вкладення разом завеликі. Використайте менші файли.",
|
||||
"Attachments are too large together. Use smaller files.": "Загальний розмір вкладень завеликий. Використайте менші файли.",
|
||||
"Choose a TXT, Markdown, CSV, JSON or GeoJSON file.": "Виберіть файл TXT, Markdown, CSV, JSON або GeoJSON.",
|
||||
"Each file must be 10 KB or smaller.": "Кожен файл має бути не більшим за 10 КБ.",
|
||||
"Choose a non-empty UTF-8 text file.": "Виберіть непорожній текстовий файл UTF-8.",
|
||||
"Connecting…": "Підключення…",
|
||||
"Thinking…": "Думаю…",
|
||||
"Working on the map…": "Працюю з мапою…",
|
||||
"Silent chat · microphone off": "Тихий чат · мікрофон вимкнено",
|
||||
"Working on the map…": "Працюю з картою…",
|
||||
"Silent chat · microphone off": "Беззвучний чат · мікрофон вимкнено",
|
||||
"Reply interrupted. You can try again.": "Відповідь перервано. Можна спробувати ще раз.",
|
||||
"Could not connect. Try again.": "Не вдалося підключитися. Спробуйте ще раз.",
|
||||
"Session ended. Send a message to start a new conversation.": "Сеанс завершено. Надішліть повідомлення, щоб почати нову розмову.",
|
||||
"Connection timed out. Try again.": "Час підключення вичерпано. Спробуйте ще раз.",
|
||||
"Connection timed out. Try again.": "Минув час очікування підключення. Спробуйте ще раз.",
|
||||
"Chat closed.": "Чат закрито.",
|
||||
"Connection stopped.": "Підключення зупинено.",
|
||||
"Message and attachments are too large. Remove an attachment or shorten your message.": "Повідомлення й вкладення завеликі. Вилучіть вкладення або скоротіть повідомлення.",
|
||||
"Message and attachments are too large. Remove an attachment or shorten your message.": "Повідомлення й вкладення завеликі. Видаліть вкладення або скоротіть повідомлення.",
|
||||
"Message was not sent. Try again.": "Повідомлення не надіслано. Спробуйте ще раз.",
|
||||
"Reply stopped.": "Відповідь зупинено.",
|
||||
"Suggestions": "Підказки",
|
||||
@@ -3223,7 +3238,7 @@
|
||||
"CHAT": "ЧАТ",
|
||||
"Back to chat": "Назад до чату",
|
||||
"Back to voice": "Назад до голосу",
|
||||
"Latest voice exchange": "Останній голосовий обмін",
|
||||
"Latest voice exchange": "Остання голосова репліка й відповідь",
|
||||
"Try saying": "Спробуйте сказати",
|
||||
"What am I looking at?": "Що я бачу?",
|
||||
"AI Provider": "Постачальник ШІ",
|
||||
@@ -3234,10 +3249,10 @@
|
||||
"Attach layer": "Прикріпити шар",
|
||||
"Typed chat uses your OpenAI API key. ChatGPT sign-in covers voice only.": "Текстовий чат використовує ваш ключ API OpenAI. Вхід у ChatGPT діє лише для голосу.",
|
||||
"Typed chat is unavailable while GEV_PREFER_CODEX_OAUTH requires ChatGPT OAuth.": "Текстовий чат недоступний, поки GEV_PREFER_CODEX_OAUTH вимагає ChatGPT OAuth.",
|
||||
"That request needed too many steps. Changes already made to the map stay in place.": "Цей запит потребував забагато кроків. Уже внесені зміни на мапі залишаються.",
|
||||
"That request needed too many steps. Changes already made to the map stay in place.": "Цей запит потребував забагато кроків. Уже внесені зміни на карті залишаються.",
|
||||
"That request needed too many steps. Try a simpler request.": "Цей запит потребував забагато кроків. Спробуйте простіший запит.",
|
||||
"The provider rejected the API key. Check it in Provider Settings.": "Провайдер відхилив ключ API. Перевірте його в налаштуваннях провайдера.",
|
||||
"The provider rejected the API key. Check it in Provider Settings.": "Постачальник відхилив ключ API. Перевірте його в «Налаштуваннях постачальників».",
|
||||
"Too many chat requests. Try again in {seconds} s.": "Забагато запитів до чату. Спробуйте ще раз через {seconds} с.",
|
||||
"The provider refused access for this key. Check the key's project, region or permissions.": "Провайдер заборонив доступ для цього ключа. Перевірте проєкт, регіон або дозволи ключа.",
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "На рахунку провайдера закінчилися кошти. Перевірте оплату або виберіть іншого провайдера ШІ в налаштуваннях."
|
||||
"The provider refused access for this key. Check the key's project, region or permissions.": "Постачальник відмовив цьому ключу в доступі. Перевірте проєкт, регіон або дозволи ключа.",
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "На вашому рахунку в постачальника закінчилися кошти. Перевірте оплату або виберіть іншого постачальника ШІ в налаштуваннях."
|
||||
}
|
||||
@@ -2843,6 +2843,19 @@
|
||||
"other": "{count} 个地标"
|
||||
},
|
||||
"S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.": "S2 Underground 在 GitHub 发布这些 KMZ 产品。新上传的内容会在数秒内被检测到,并自动替换旧版本。点击标记查看报告。",
|
||||
"fire confidence filter|All": "全部",
|
||||
"fire confidence filter|Nominal+": "标称+",
|
||||
"fire confidence filter|High": "高",
|
||||
"Every detection — many of the strongest fires are rated low confidence": "所有检测——许多最强的火点置信度为低",
|
||||
"Nominal and high confidence detections": "标称和高置信度的检测",
|
||||
"High confidence detections only": "仅高置信度的检测",
|
||||
"Fire intensity (FRP)": "火势强度 (FRP)",
|
||||
"≥ {value} MW": "≥ {value} MW",
|
||||
"Confidence": "置信度",
|
||||
"Minimum fire radiative power, megawatts": "最小火辐射功率(兆瓦)",
|
||||
"fire intensity filter|All": "全部",
|
||||
"Detection confidence": "检测置信度",
|
||||
"{shown} of {total} shown": "显示 {shown} / {total}",
|
||||
"{lower} to {upper}": "{lower} 至 {upper}",
|
||||
"Up to {upper}": "最高 {upper}",
|
||||
"From {lower}": "自 {lower} 起",
|
||||
@@ -2946,6 +2959,8 @@
|
||||
"Ski Pistes": "滑雪道",
|
||||
"Fly to Tokyo": "飞往东京",
|
||||
"Show live flights": "显示实时航班",
|
||||
"Reset to globe view": "重置为地球视图",
|
||||
"Show earthquakes": "显示地震",
|
||||
"AI chat": "AI 聊天",
|
||||
"GEV CHAT": "GEV 聊天",
|
||||
"Ask about the map. Tell it where to go.": "询问地图信息,或指定前往的位置。",
|
||||
@@ -2958,7 +2973,7 @@
|
||||
"Attach a map layer": "附加地图图层",
|
||||
"Choose a layer…": "选择图层…",
|
||||
"Attach a file": "附加文件",
|
||||
"TXT, Markdown, CSV, JSON or GeoJSON · up to 10 KB in total. Attachments are sent to your AI provider with your message.": "TXT、Markdown、CSV、JSON 或 GeoJSON · 总计最多 10 KB。附件会随消息发送给您的 AI 服务商。",
|
||||
"TXT, Markdown, CSV, JSON or GeoJSON · up to 10 KB in total. Attachments are sent to your AI provider with your message.": "TXT、Markdown、CSV、JSON 或 GeoJSON · 总计最多 10 KB。附件会随消息发送给你的 AI 服务商。",
|
||||
"Latest AI response": "最新 AI 回复",
|
||||
"Expand conversation": "展开对话",
|
||||
"Command suggestions": "命令建议",
|
||||
@@ -2969,18 +2984,18 @@
|
||||
"Stop reply": "停止回复",
|
||||
"Send message": "发送消息",
|
||||
"Panel position": "面板位置",
|
||||
"Move above the dock": "移到底栏上方",
|
||||
"Move above the dock": "移到停靠栏上方",
|
||||
"Dock to the right": "停靠到右侧",
|
||||
"Expand panel": "展开面板",
|
||||
"Minimize panel": "最小化面板",
|
||||
"You can attach up to four items.": "最多可添加四个附件。",
|
||||
"Attachments are too large together. Use smaller files.": "附件总大小过大。请使用较小的文件。",
|
||||
"Attachments are too large together. Use smaller files.": "附件总大小超出上限。请使用较小的文件。",
|
||||
"Choose a TXT, Markdown, CSV, JSON or GeoJSON file.": "请选择 TXT、Markdown、CSV、JSON 或 GeoJSON 文件。",
|
||||
"Each file must be 10 KB or smaller.": "每个文件不得超过 10 KB。",
|
||||
"Choose a non-empty UTF-8 text file.": "请选择非空的 UTF-8 文本文件。",
|
||||
"Connecting…": "正在连接…",
|
||||
"Thinking…": "正在思考…",
|
||||
"Working on the map…": "正在处理地图…",
|
||||
"Working on the map…": "正在操作地图…",
|
||||
"Silent chat · microphone off": "静默聊天 · 麦克风已关闭",
|
||||
"Reply interrupted. You can try again.": "回复已中断。请重试。",
|
||||
"Could not connect. Try again.": "无法连接。请重试。",
|
||||
@@ -3011,8 +3026,8 @@
|
||||
"Typed chat is unavailable while GEV_PREFER_CODEX_OAUTH requires ChatGPT OAuth.": "GEV_PREFER_CODEX_OAUTH 要求使用 ChatGPT OAuth 时,文字聊天不可用。",
|
||||
"That request needed too many steps. Changes already made to the map stay in place.": "此请求所需步骤过多。已对地图做出的更改会保留。",
|
||||
"That request needed too many steps. Try a simpler request.": "此请求所需步骤过多。请尝试更简单的请求。",
|
||||
"The provider rejected the API key. Check it in Provider Settings.": "提供商拒绝了 API 密钥。请在提供商设置中检查。",
|
||||
"The provider rejected the API key. Check it in Provider Settings.": "服务商拒绝了 API 密钥。请在服务商设置中检查。",
|
||||
"Too many chat requests. Try again in {seconds} s.": "聊天请求过多。请在 {seconds} 秒后重试。",
|
||||
"The provider refused access for this key. Check the key's project, region or permissions.": "提供商拒绝了此密钥的访问。请检查该密钥的项目、地区或权限。",
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "提供商账户余额不足。请检查账单,或在设置中选择其他 AI 提供商。"
|
||||
"The provider refused access for this key. Check the key's project, region or permissions.": "服务商拒绝了此密钥的访问请求。请检查该密钥所属的项目、区域或权限。",
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "服务商账户余额不足。请检查账户的计费信息,或在设置中选择其他 AI 服务商。"
|
||||
}
|
||||
@@ -2882,6 +2882,19 @@
|
||||
"Download this scene as JSON or an asset bundle": "將此場景下載為 JSON 或資源包",
|
||||
"Left panels": "左側面板",
|
||||
"Right panels": "右側面板",
|
||||
"fire confidence filter|All": "全部",
|
||||
"fire confidence filter|Nominal+": "標稱+",
|
||||
"fire confidence filter|High": "高",
|
||||
"Every detection — many of the strongest fires are rated low confidence": "所有檢測——許多最強的火點置信度為低",
|
||||
"Nominal and high confidence detections": "標稱和高置信度的檢測",
|
||||
"High confidence detections only": "僅高置信度的檢測",
|
||||
"Fire intensity (FRP)": "火勢強度 (FRP)",
|
||||
"≥ {value} MW": "≥ {value} MW",
|
||||
"Confidence": "置信度",
|
||||
"Minimum fire radiative power, megawatts": "最小火輻射功率(兆瓦)",
|
||||
"fire intensity filter|All": "全部",
|
||||
"Detection confidence": "檢測置信度",
|
||||
"{shown} of {total} shown": "顯示 {shown} / {total}",
|
||||
"UTILITIES": "工具",
|
||||
"Exit cockpit to fly to a drawing": "請先退出座艙,再飛往繪製位置",
|
||||
"Directions, drawing and your own files": "路線導航、繪製和你自己的文件",
|
||||
@@ -2945,74 +2958,76 @@
|
||||
"Sea Marks": "航標",
|
||||
"Ski Pistes": "滑雪道",
|
||||
"Fly to Tokyo": "飛往東京",
|
||||
"Show live flights": "顯示即時航班",
|
||||
"Show live flights": "顯示實時航班",
|
||||
"Reset to globe view": "重置為地球視圖",
|
||||
"Show earthquakes": "顯示地震",
|
||||
"AI chat": "AI 聊天",
|
||||
"GEV CHAT": "GEV 聊天",
|
||||
"Ask about the map. Tell it where to go.": "詢問地圖相關問題,告訴它要去哪裡。",
|
||||
"Ask about the map. Tell it where to go.": "詢問地圖信息,或指定前往的位置。",
|
||||
"Close chat": "關閉聊天",
|
||||
"Conversation": "對話",
|
||||
"Explore with a question": "用一個問題開始探索",
|
||||
"Find a place, explore live data, or change your view.": "尋找地點、探索即時資料,或變更檢視。",
|
||||
"Explore with a question": "從提問開始探索",
|
||||
"Find a place, explore live data, or change your view.": "查找地點、探索實時數據或更改視圖。",
|
||||
"Attachments": "附件",
|
||||
"Remove attachment": "移除附件",
|
||||
"Attach a map layer": "附加地圖圖層",
|
||||
"Choose a layer…": "選擇圖層…",
|
||||
"Attach a file": "附加檔案",
|
||||
"TXT, Markdown, CSV, JSON or GeoJSON · up to 10 KB in total. Attachments are sent to your AI provider with your message.": "TXT、Markdown、CSV、JSON 或 GeoJSON · 總計最多 10 KB。附件會隨訊息傳送給你的 AI 供應商。",
|
||||
"Attach a file": "附加文件",
|
||||
"TXT, Markdown, CSV, JSON or GeoJSON · up to 10 KB in total. Attachments are sent to your AI provider with your message.": "TXT、Markdown、CSV、JSON 或 GeoJSON · 總計最多 10 KB。附件會隨消息發送給你的 AI 服務商。",
|
||||
"Latest AI response": "最新 AI 回覆",
|
||||
"Expand conversation": "展開對話",
|
||||
"Command suggestions": "指令建議",
|
||||
"Command suggestions": "命令建議",
|
||||
"Expand chat": "展開聊天",
|
||||
"Add attachment": "新增附件",
|
||||
"Message GEV": "傳訊息給 GEV",
|
||||
"Ask GEV anything about the map…": "問 GEV 任何關於地圖的問題…",
|
||||
"Add attachment": "添加附件",
|
||||
"Message GEV": "向 GEV 發送消息",
|
||||
"Ask GEV anything about the map…": "向 GEV 詢問地圖相關問題…",
|
||||
"Stop reply": "停止回覆",
|
||||
"Send message": "傳送訊息",
|
||||
"Send message": "發送消息",
|
||||
"Panel position": "面板位置",
|
||||
"Move above the dock": "移到底欄上方",
|
||||
"Move above the dock": "移到停靠欄上方",
|
||||
"Dock to the right": "停靠到右側",
|
||||
"Expand panel": "展開面板",
|
||||
"Minimize panel": "最小化面板",
|
||||
"You can attach up to four items.": "最多可附加四個項目。",
|
||||
"Attachments are too large together. Use smaller files.": "附件合計太大,請使用較小的檔案。",
|
||||
"Choose a TXT, Markdown, CSV, JSON or GeoJSON file.": "請選擇 TXT、Markdown、CSV、JSON 或 GeoJSON 檔案。",
|
||||
"Each file must be 10 KB or smaller.": "每個檔案不得超過 10 KB。",
|
||||
"Choose a non-empty UTF-8 text file.": "請選擇非空白的 UTF-8 文字檔。",
|
||||
"Connecting…": "連線中…",
|
||||
"Thinking…": "思考中…",
|
||||
"You can attach up to four items.": "最多可添加四個附件。",
|
||||
"Attachments are too large together. Use smaller files.": "附件總大小超出上限。請使用較小的文件。",
|
||||
"Choose a TXT, Markdown, CSV, JSON or GeoJSON file.": "請選擇 TXT、Markdown、CSV、JSON 或 GeoJSON 文件。",
|
||||
"Each file must be 10 KB or smaller.": "每個文件不得超過 10 KB。",
|
||||
"Choose a non-empty UTF-8 text file.": "請選擇非空的 UTF-8 文本文件。",
|
||||
"Connecting…": "正在連接…",
|
||||
"Thinking…": "正在思考…",
|
||||
"Working on the map…": "正在操作地圖…",
|
||||
"Silent chat · microphone off": "靜音聊天 · 麥克風關閉",
|
||||
"Reply interrupted. You can try again.": "回覆已中斷,你可以再試一次。",
|
||||
"Could not connect. Try again.": "無法連線,請再試一次。",
|
||||
"Session ended. Send a message to start a new conversation.": "工作階段已結束。傳送訊息即可開始新的對話。",
|
||||
"Connection timed out. Try again.": "連線逾時,請再試一次。",
|
||||
"Silent chat · microphone off": "靜默聊天 · 麥克風已關閉",
|
||||
"Reply interrupted. You can try again.": "回覆已中斷。請重試。",
|
||||
"Could not connect. Try again.": "無法連接。請重試。",
|
||||
"Session ended. Send a message to start a new conversation.": "會話已結束。發送消息以開始新對話。",
|
||||
"Connection timed out. Try again.": "連接超時。請重試。",
|
||||
"Chat closed.": "聊天已關閉。",
|
||||
"Connection stopped.": "連線已停止。",
|
||||
"Message and attachments are too large. Remove an attachment or shorten your message.": "訊息與附件太大。請移除一個附件或縮短訊息。",
|
||||
"Message was not sent. Try again.": "訊息未送出,請再試一次。",
|
||||
"Reply stopped.": "已停止回覆。",
|
||||
"Connection stopped.": "連接已停止。",
|
||||
"Message and attachments are too large. Remove an attachment or shorten your message.": "消息和附件過大。請移除附件或縮短消息。",
|
||||
"Message was not sent. Try again.": "消息未發送。請重試。",
|
||||
"Reply stopped.": "回覆已停止。",
|
||||
"Suggestions": "建議",
|
||||
"Recent": "最近",
|
||||
"Quick commands": "快速指令",
|
||||
"Open AI chat": "開啟 AI 聊天",
|
||||
"Quick commands": "快捷命令",
|
||||
"Open AI chat": "打開 AI 聊天",
|
||||
"CHAT": "聊天",
|
||||
"Back to chat": "返回聊天",
|
||||
"Back to voice": "返回語音",
|
||||
"Latest voice exchange": "最近一次語音對話",
|
||||
"Try saying": "試著說",
|
||||
"What am I looking at?": "我在看什麼?",
|
||||
"AI Provider": "AI 供應商",
|
||||
"The selected AI provider is shared by voice and chat.": "所選的 AI 供應商由語音與聊天共用。",
|
||||
"Show quick commands": "顯示快速指令",
|
||||
"Chat replies are silent. No microphone access is needed.": "聊天回覆不會發出聲音,不需要麥克風權限。",
|
||||
"AI Provider": "AI 服務商",
|
||||
"The selected AI provider is shared by voice and chat.": "語音和聊天共用所選的 AI 服務商。",
|
||||
"Show quick commands": "顯示快捷命令",
|
||||
"Chat replies are silent. No microphone access is needed.": "聊天回覆不播放聲音,無需訪問麥克風。",
|
||||
"Chat": "聊天",
|
||||
"Attach layer": "附加圖層",
|
||||
"Typed chat uses your OpenAI API key. ChatGPT sign-in covers voice only.": "文字聊天使用你的 OpenAI API 金鑰。ChatGPT 登入僅適用於語音。",
|
||||
"Typed chat is unavailable while GEV_PREFER_CODEX_OAUTH requires ChatGPT OAuth.": "GEV_PREFER_CODEX_OAUTH 要求使用 ChatGPT OAuth 時,文字聊天無法使用。",
|
||||
"That request needed too many steps. Changes already made to the map stay in place.": "此請求所需步驟過多。已對地圖做出的變更會保留。",
|
||||
"Typed chat uses your OpenAI API key. ChatGPT sign-in covers voice only.": "文字聊天使用你的 OpenAI API 密鑰。ChatGPT 登錄僅適用於語音。",
|
||||
"Typed chat is unavailable while GEV_PREFER_CODEX_OAUTH requires ChatGPT OAuth.": "GEV_PREFER_CODEX_OAUTH 要求使用 ChatGPT OAuth 時,文字聊天不可用。",
|
||||
"That request needed too many steps. Changes already made to the map stay in place.": "此請求所需步驟過多。已對地圖做出的更改會保留。",
|
||||
"That request needed too many steps. Try a simpler request.": "此請求所需步驟過多。請嘗試更簡單的請求。",
|
||||
"The provider rejected the API key. Check it in Provider Settings.": "供應商拒絕了 API 金鑰。請在供應商設定中檢查。",
|
||||
"The provider rejected the API key. Check it in Provider Settings.": "服務商拒絕了 API 密鑰。請在服務商設置中檢查。",
|
||||
"Too many chat requests. Try again in {seconds} s.": "聊天請求過多。請在 {seconds} 秒後重試。",
|
||||
"The provider refused access for this key. Check the key's project, region or permissions.": "供應商拒絕了此金鑰的存取。請檢查該金鑰的專案、地區或權限。",
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "供應商帳戶餘額不足。請檢查帳單,或在設定中選擇其他 AI 供應商。"
|
||||
"The provider refused access for this key. Check the key's project, region or permissions.": "服務商拒絕了此密鑰的訪問請求。請檢查該密鑰所屬的項目、區域或權限。",
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "服務商賬戶餘額不足。請檢查賬戶的計費信息,或在設置中選擇其他 AI 服務商。"
|
||||
}
|
||||
@@ -201,6 +201,7 @@
|
||||
"{value} KM/S",
|
||||
"ΔN",
|
||||
"Ⅱ PAUSE",
|
||||
"≥ {value} MW",
|
||||
"▲ {frp} MW"
|
||||
],
|
||||
"es": [
|
||||
@@ -307,6 +308,7 @@
|
||||
"WX",
|
||||
"audio {state}",
|
||||
"dB",
|
||||
"fire confidence filter|Nominal+",
|
||||
"fire confidence|nominal",
|
||||
"ft",
|
||||
"hPa",
|
||||
@@ -329,6 +331,7 @@
|
||||
"{value} KM",
|
||||
"{value} KM/H",
|
||||
"{value} KM/S",
|
||||
"≥ {value} MW",
|
||||
"ΔE",
|
||||
"ΔN",
|
||||
"▲ {frp} MW"
|
||||
@@ -361,6 +364,7 @@
|
||||
"CCTV",
|
||||
"CCTV OFF",
|
||||
"CCTV ON",
|
||||
"CHAT",
|
||||
"COCKPIT",
|
||||
"COMMERCIAL",
|
||||
"CONTACT",
|
||||
@@ -514,6 +518,7 @@
|
||||
"ΔE",
|
||||
"ΔN",
|
||||
"Ⅱ PAUSE",
|
||||
"≥ {value} MW",
|
||||
"▲ {frp} MW"
|
||||
],
|
||||
"it": [
|
||||
@@ -686,6 +691,7 @@
|
||||
"{value} KM/H",
|
||||
"{value} KM/S",
|
||||
"{value} conf",
|
||||
"≥ {value} MW",
|
||||
"ΔE",
|
||||
"ΔN",
|
||||
"▲ {frp} MW"
|
||||
@@ -737,6 +743,7 @@
|
||||
"{type} SPD: {speed} HDG: {heading}",
|
||||
"ΔE",
|
||||
"ΔN",
|
||||
"≥ {value} MW",
|
||||
"▲ {frp} MW"
|
||||
],
|
||||
"pt": [
|
||||
@@ -850,6 +857,7 @@
|
||||
"WHOT/BHOT",
|
||||
"WX",
|
||||
"dB",
|
||||
"fire confidence filter|Nominal+",
|
||||
"fire confidence|nominal",
|
||||
"ft",
|
||||
"hPa",
|
||||
@@ -868,7 +876,8 @@
|
||||
"{value} KM/H",
|
||||
"{value} KM/S",
|
||||
"ΔN",
|
||||
"▲ {frp} MW"
|
||||
"▲ {frp} MW",
|
||||
"≥ {value} MW"
|
||||
],
|
||||
"ru": [
|
||||
"AIS: --",
|
||||
@@ -927,9 +936,9 @@
|
||||
"MM",
|
||||
"NVG",
|
||||
"REC",
|
||||
"SRC {value}",
|
||||
"S2 GhostMaps",
|
||||
"S2 · {name}",
|
||||
"SRC {value}",
|
||||
"Sentinel-2",
|
||||
"Techno",
|
||||
"Trance",
|
||||
@@ -954,6 +963,7 @@
|
||||
"{type} SPD: {speed} HDG: {heading}",
|
||||
"{value} KM",
|
||||
"{value} KM/S",
|
||||
"≥ {value} MW",
|
||||
"ΔE",
|
||||
"ΔN",
|
||||
"▲ {frp} MW"
|
||||
@@ -1011,6 +1021,7 @@
|
||||
"{type} SPD: {speed} HDG: {heading}",
|
||||
"{value} KM",
|
||||
"{value} KM/S",
|
||||
"≥ {value} MW",
|
||||
"ΔE",
|
||||
"ΔN",
|
||||
"▲ {frp} MW"
|
||||
|
||||
@@ -12,20 +12,28 @@
|
||||
* Cameras without an identified model are untouched — unknown never implies
|
||||
* anything.
|
||||
*
|
||||
* Spec records are vendored verbatim from the CCTV Camera Database
|
||||
* Specification fields are vendored from the CCTV Camera Database
|
||||
* (https://github.com/ch-bas/cctv-camera-database, CC0-1.0) — an open dataset
|
||||
* of 28,000+ camera models transcribed from manufacturer datasheets (84% with
|
||||
* FOV, as of v2.26.0). This module vendors the records that the currently integrated public
|
||||
* of 29,000+ camera models transcribed from manufacturer datasheets (76% with
|
||||
* FOV, as of v2.29.0). This module vendors records that the integrated public
|
||||
* catalogs publish hardware for — King County WA (Cohu, Bosch AUTODOME, Axis
|
||||
* Q6135-LE), plus the Bosch AUTODOME and Axis PTZ/multisensor models that
|
||||
* appear in Sarasota County FL and Sioux Falls SD open data; packs that
|
||||
* appear in Sarasota County FL and Sioux Falls SD open data, plus the Bosch MIC
|
||||
* PTZs for the City of Amsterdam camera register proposed in #251; packs that
|
||||
* publish other hardware models can grow it, or vendor the full extract.
|
||||
*
|
||||
* Scope guardrail: records describe camera PRODUCTS (optics, resolution,
|
||||
* night vision) — never endpoints, stream paths, or access/config data.
|
||||
*/
|
||||
|
||||
/** Verbatim records from the dataset's model-enrichment extract (v2.26.0). */
|
||||
/**
|
||||
* Records from the dataset's model-enrichment extract: v2.26.0, plus the Bosch
|
||||
* MIC records from v2.29.0 with titles shortened to model codes.
|
||||
* A Bosch MIC order code ends in a colour letter (B, G, W; registries write
|
||||
* `x`) on one shared datasheet. The colours are aliases of one record whose
|
||||
* `model` leaves the letter out, so the HUD never names a variant the source
|
||||
* did not.
|
||||
*/
|
||||
const RECORDS = [
|
||||
{
|
||||
id: 'axis-q6135-le',
|
||||
@@ -264,6 +272,67 @@ const RECORDS = [
|
||||
autotracking: true,
|
||||
environment: ['outdoor'],
|
||||
},
|
||||
{
|
||||
id: 'bosch-mic-7130-pb4',
|
||||
brand: 'Bosch',
|
||||
model: 'MIC-7130-P_4',
|
||||
type: 'ptz',
|
||||
url: 'https://www.cctv-database.com/camera/bosch-mic-7130-pb4/',
|
||||
aliases: [
|
||||
'MIC IP starlight 7000 HD',
|
||||
'MIC-7130-PB4',
|
||||
'MIC-7130-PG4',
|
||||
'MIC-7130-PW4',
|
||||
'MIC-7130-Px4',
|
||||
],
|
||||
fov_deg: '2.1-59',
|
||||
resolution: { mp: 1, w: 1280, h: 720, label: '720p' },
|
||||
ptz: true,
|
||||
autotracking: true,
|
||||
varifocal: true,
|
||||
environment: ['outdoor'],
|
||||
},
|
||||
{
|
||||
id: 'bosch-mic-7502-z30b',
|
||||
brand: 'Bosch',
|
||||
model: 'MIC-7502-Z30',
|
||||
type: 'ptz',
|
||||
url: 'https://www.cctv-database.com/camera/bosch-mic-7502-z30b/',
|
||||
aliases: [
|
||||
'MIC-7502-Z30B',
|
||||
'MIC-7502-Z30G',
|
||||
'MIC-7502-Z30W',
|
||||
'MIC-7502-Z30x',
|
||||
],
|
||||
fov_deg: '2.3-63.7',
|
||||
resolution: { mp: 2.13, w: 1920, h: 1080, label: '1080p' },
|
||||
ptz: true,
|
||||
autotracking: true,
|
||||
varifocal: true,
|
||||
night_vision: { type: 'color' },
|
||||
environment: ['outdoor'],
|
||||
},
|
||||
{
|
||||
id: 'bosch-mic-7522-z30b',
|
||||
brand: 'Bosch',
|
||||
model: 'MIC-7522-Z30',
|
||||
type: 'ptz',
|
||||
url: 'https://www.cctv-database.com/camera/bosch-mic-7522-z30b/',
|
||||
aliases: [
|
||||
'MIC-7522-Z30B',
|
||||
'MIC-7522-Z30G',
|
||||
'MIC-7522-Z30W',
|
||||
'MIC-7522-Z30x',
|
||||
],
|
||||
fov_deg: '2.1-58.3',
|
||||
// i18n-ignore: datasheet label
|
||||
resolution: { mp: 2, w: 1920, h: 1080, label: '1080p HD' },
|
||||
ptz: true,
|
||||
varifocal: true,
|
||||
night_vision: { type: 'color' },
|
||||
environment: ['outdoor'],
|
||||
ndaa_compliant: true,
|
||||
},
|
||||
{
|
||||
id: 'cohu-3920-idome',
|
||||
brand: 'Cohu',
|
||||
@@ -431,7 +500,7 @@ const NAME_INDEX = RECORDS.flatMap((record) => {
|
||||
* guesses).
|
||||
* @param {string} model - Hardware model as named by a source pack or seed.
|
||||
* @param {string} [manufacturer] - The source's manufacturer, when published.
|
||||
* @returns {Object|null} The verbatim spec record, or null when unknown.
|
||||
* @returns {Object|null} The matched spec record, or null when unknown.
|
||||
*/
|
||||
export function lookupModelSpec(model, manufacturer) {
|
||||
const query = words(model).map(stripVariantSuffix);
|
||||
|
||||
@@ -245,6 +245,95 @@ test('King County snapshot model strings still resolve', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('Amsterdam camera register model strings resolve', () => {
|
||||
// The register writes the Bosch MIC colour letter as `x`.
|
||||
assert.equal(
|
||||
lookupModelSpec('Bosch MIC-7522-Z30x')?.id,
|
||||
'bosch-mic-7522-z30b',
|
||||
);
|
||||
assert.equal(
|
||||
lookupModelSpec('Bosch MIC-7502-Z30x')?.id,
|
||||
'bosch-mic-7502-z30b',
|
||||
);
|
||||
assert.equal(lookupModelSpec('Bosch MIC-7130-Px4')?.id, 'bosch-mic-7130-pb4');
|
||||
assert.equal(lookupModelSpec('MIC-7522-Z30W')?.id, 'bosch-mic-7522-z30b');
|
||||
// Its ANPR units are not in the table: unknown, never a guess.
|
||||
assert.equal(lookupModelSpec('CSG-8800'), null);
|
||||
// The MIC and AUTODOME starlight 7000 HD (code-less names, so the brand
|
||||
// is needed) stay apart.
|
||||
assert.equal(
|
||||
lookupModelSpec('MIC IP starlight 7000 HD', 'Bosch')?.id,
|
||||
'bosch-mic-7130-pb4',
|
||||
);
|
||||
assert.equal(
|
||||
lookupModelSpec('AUTODOME IP starlight 7000 HD', 'Bosch')?.id,
|
||||
'bosch-autodome-ip-starlight-7000-hd',
|
||||
);
|
||||
// The MIC datasheets state FOV without an axis, so no horizontal range.
|
||||
assert.equal(horizontalFovRangeDeg(lookupModelSpec('MIC-7522-Z30B')), null);
|
||||
});
|
||||
|
||||
test('Bosch MIC colour aliases resolve without accepting unknown variants', () => {
|
||||
for (const [pattern, id] of [
|
||||
['MIC-7130-P?4', 'bosch-mic-7130-pb4'],
|
||||
['MIC-7502-Z30?', 'bosch-mic-7502-z30b'],
|
||||
['MIC-7522-Z30?', 'bosch-mic-7522-z30b'],
|
||||
]) {
|
||||
for (const colour of ['B', 'G', 'W', 'x']) {
|
||||
const model = pattern.replace('?', colour);
|
||||
assert.equal(lookupModelSpec(model)?.id, id, model);
|
||||
assert.equal(lookupModelSpec(`bosch ${model.toLowerCase()}`)?.id, id);
|
||||
assert.equal(lookupModelSpec(model, 'Bosch')?.id, id);
|
||||
assert.equal(lookupModelSpec(model, 'Axis'), null);
|
||||
assert.equal(lookupModelSpec(`${model}-UNKNOWN`), null);
|
||||
}
|
||||
assert.equal(lookupModelSpec(pattern.replace('?', 'R')), null);
|
||||
}
|
||||
});
|
||||
|
||||
test('Bosch MIC HUD summary never names a colour the source did not', () => {
|
||||
assert.equal(
|
||||
specSummary(lookupModelSpec('MIC-7522-Z30W')),
|
||||
'Bosch MIC-7522-Z30 · 1080p HD · COLOR · PTZ',
|
||||
);
|
||||
assert.equal(
|
||||
specSummary(lookupModelSpec('Bosch MIC-7522-Z30x')),
|
||||
'Bosch MIC-7522-Z30 · 1080p HD · COLOR · PTZ',
|
||||
);
|
||||
for (const pattern of ['MIC-7130-P?4', 'MIC-7502-Z30?', 'MIC-7522-Z30?']) {
|
||||
for (const colour of ['B', 'G', 'W', 'x']) {
|
||||
const spec = lookupModelSpec(pattern.replace('?', colour));
|
||||
const summary = specSummary(spec);
|
||||
for (const other of ['B', 'G', 'W', 'x']) {
|
||||
assert.ok(!summary.includes(pattern.replace('?', other)), summary);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('Bosch MIC catalog enrichment preserves estimated and explicit optics', () => {
|
||||
for (const model of [
|
||||
'Bosch MIC-7130-Px4',
|
||||
'Bosch MIC-7502-Z30x',
|
||||
'Bosch MIC-7522-Z30x',
|
||||
]) {
|
||||
for (const pose of [{}, { fovDeg: 44 }, { poseSource: 'curated' }]) {
|
||||
const [camera] = browserCatalog().buildCatalogFromSources([
|
||||
{ id: 'amsterdam-mic', lat: 52.37, lon: 4.9, model, ...pose },
|
||||
]);
|
||||
assert.ok(camera.spec, model);
|
||||
assert.equal(camera.fovDeg, pose.fovDeg ?? 74, model);
|
||||
assert.equal(camera.fovSource, undefined);
|
||||
assert.equal(camera.specFovMinDeg, undefined);
|
||||
assert.equal(camera.specFovMaxDeg, undefined);
|
||||
assert.equal(horizontalFovRangeDeg(camera.spec), null);
|
||||
assert.equal(fixedHorizontalFovDeg(camera.spec), null);
|
||||
assert.equal(specFovCapabilityToken(camera), null);
|
||||
assert.match(specSummary(camera.spec), /^Bosch MIC-.* · PTZ$/);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('catalog never overwrites an explicit FOV or a curated pose', () => {
|
||||
const [explicit, curated, cohu, pelco] =
|
||||
browserCatalog().buildCatalogFromSources([
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Drapes the Active Fires heat raster over the globe as one full-extent
|
||||
* imagery tile on the active imagery host (globe or 3D-tiles), like the wind
|
||||
* layer's scalar field. Owns the canvas and the imagery layer; nothing else
|
||||
* in the fires layer touches imagery.
|
||||
*
|
||||
* `show` is only for the layer being on or off. The zoom hand-off fades
|
||||
* `alpha` instead: Cesium unmaps a hidden imagery layer from the globe
|
||||
* tiles and re-maps it when shown, so a show toggle at the band edge left
|
||||
* a beat with neither markers nor heat. At alpha 0 the layer stays mapped
|
||||
* and costs no draw.
|
||||
*/
|
||||
|
||||
const defaultCanvas = (width, height) => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
return canvas;
|
||||
};
|
||||
|
||||
export function createHeatImagery({
|
||||
cesium,
|
||||
getHost,
|
||||
createCanvas = defaultCanvas,
|
||||
}) {
|
||||
let raster = null;
|
||||
let layer = null;
|
||||
let collection = null;
|
||||
let show = true;
|
||||
let opacity = 1;
|
||||
|
||||
function detach() {
|
||||
if (layer && collection) collection.remove(layer, true);
|
||||
layer = null;
|
||||
collection = null;
|
||||
}
|
||||
|
||||
function attach() {
|
||||
detach();
|
||||
if (!raster) return;
|
||||
const host = getHost?.() ?? { collection: null, kind: 'none' };
|
||||
if (!host.collection) return;
|
||||
const canvas = createCanvas(raster.width, raster.height);
|
||||
const ctx = canvas.getContext('2d');
|
||||
const image = ctx.createImageData(raster.width, raster.height);
|
||||
image.data.set(raster.rgba);
|
||||
ctx.putImageData(image, 0, 0);
|
||||
const provider = new cesium.SingleTileImageryProvider({
|
||||
url: canvas.toDataURL('image/png'),
|
||||
tileWidth: raster.width,
|
||||
tileHeight: raster.height,
|
||||
rectangle: cesium.Rectangle.MAX_VALUE,
|
||||
});
|
||||
collection = host.collection;
|
||||
layer = collection.addImageryProvider(provider);
|
||||
layer.show = show;
|
||||
layer.alpha = opacity;
|
||||
collection.raiseToTop?.(layer);
|
||||
}
|
||||
|
||||
return {
|
||||
setRaster(next) {
|
||||
raster = next;
|
||||
attach();
|
||||
},
|
||||
/** True once a raster was handed over (painted or waiting for a host). */
|
||||
hasRaster() {
|
||||
return raster !== null;
|
||||
},
|
||||
setShow(value) {
|
||||
show = Boolean(value);
|
||||
if (layer) layer.show = show;
|
||||
},
|
||||
/** Hand-off fade, 0..1; the layer stays mapped at 0. */
|
||||
setOpacity(value) {
|
||||
opacity = Math.min(1, Math.max(0, Number(value) || 0));
|
||||
if (layer) layer.alpha = opacity;
|
||||
},
|
||||
rehome() {
|
||||
const host = getHost?.();
|
||||
if ((host?.collection ?? null) !== collection) attach();
|
||||
},
|
||||
destroy() {
|
||||
detach();
|
||||
raster = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHeatImagery } from './heatImagery.js';
|
||||
import { buildHeatRaster } from '../../data/firmsHeatRaster.js';
|
||||
import { HEAT_RASTER } from './policy.js';
|
||||
|
||||
function harness() {
|
||||
const layers = [];
|
||||
const removed = [];
|
||||
const collection = {
|
||||
addImageryProvider(provider) {
|
||||
const layer = { provider, show: true, alpha: 1 };
|
||||
layers.push(layer);
|
||||
return layer;
|
||||
},
|
||||
remove(layer, destroy) {
|
||||
layers.splice(layers.indexOf(layer), 1);
|
||||
removed.push({ layer, destroy });
|
||||
},
|
||||
raiseToTop() {},
|
||||
};
|
||||
let host = { collection, kind: 'globe' };
|
||||
const cesium = {
|
||||
Rectangle: { MAX_VALUE: { max: true } },
|
||||
SingleTileImageryProvider: class {
|
||||
constructor(options) {
|
||||
this.options = options;
|
||||
}
|
||||
},
|
||||
};
|
||||
const createCanvas = (w, h) => ({
|
||||
width: w,
|
||||
height: h,
|
||||
getContext: () => ({
|
||||
createImageData: (cw, ch) => ({
|
||||
data: new Uint8ClampedArray(cw * ch * 4),
|
||||
}),
|
||||
putImageData() {},
|
||||
}),
|
||||
toDataURL: () => 'data:image/png;base64,AAAA',
|
||||
});
|
||||
const imagery = createHeatImagery({
|
||||
cesium,
|
||||
getHost: () => host,
|
||||
createCanvas,
|
||||
});
|
||||
return { imagery, layers, removed, collection, setHost: (h) => (host = h) };
|
||||
}
|
||||
|
||||
const raster = { width: 4, height: 2, rgba: new Uint8ClampedArray(32) };
|
||||
|
||||
test('setRaster drapes one full-globe tile and replaces it on the next build', () => {
|
||||
const h = harness();
|
||||
h.imagery.setRaster(raster);
|
||||
assert.equal(h.layers.length, 1);
|
||||
assert.deepEqual(h.layers[0].provider.options.rectangle, { max: true });
|
||||
assert.equal(h.layers[0].provider.options.tileWidth, 4);
|
||||
h.imagery.setRaster(raster);
|
||||
assert.equal(h.layers.length, 1, 'never two heat layers');
|
||||
assert.equal(h.removed[0].destroy, true);
|
||||
});
|
||||
|
||||
test('setShow toggles without rebuilding', () => {
|
||||
const h = harness();
|
||||
h.imagery.setRaster(raster);
|
||||
const layer = h.layers[0];
|
||||
h.imagery.setShow(false);
|
||||
assert.equal(layer.show, false);
|
||||
h.imagery.setShow(true);
|
||||
assert.equal(h.layers[0], layer);
|
||||
assert.equal(layer.show, true);
|
||||
});
|
||||
|
||||
test('setOpacity fades the same layer and survives a rebuild', () => {
|
||||
const h = harness();
|
||||
h.imagery.setRaster(raster);
|
||||
const layer = h.layers[0];
|
||||
h.imagery.setOpacity(0);
|
||||
assert.equal(layer.alpha, 0);
|
||||
assert.equal(layer.show, true, 'a faded layer stays mapped');
|
||||
h.imagery.setOpacity(0.25);
|
||||
assert.equal(h.layers[0], layer);
|
||||
assert.equal(layer.alpha, 0.25);
|
||||
h.imagery.setRaster(raster);
|
||||
assert.equal(h.layers[0].alpha, 0.25, 'a rebuilt layer keeps the fade');
|
||||
h.imagery.setOpacity(7);
|
||||
assert.equal(h.layers[0].alpha, 1);
|
||||
});
|
||||
|
||||
test('no imagery host keeps nothing draped; rehome brings it back', () => {
|
||||
const h = harness();
|
||||
h.setHost({ collection: null, kind: 'none' });
|
||||
h.imagery.setRaster(raster);
|
||||
assert.equal(h.layers.length, 0);
|
||||
h.setHost({ collection: h.collection, kind: 'globe' });
|
||||
h.imagery.rehome();
|
||||
assert.equal(h.layers.length, 1);
|
||||
});
|
||||
|
||||
test('destroy removes the layer', () => {
|
||||
const h = harness();
|
||||
h.imagery.setRaster(raster);
|
||||
h.imagery.destroy();
|
||||
assert.equal(h.layers.length, 0);
|
||||
});
|
||||
|
||||
test('the shipped raster paints a lone weak fire as a distinct spot', () => {
|
||||
// A hot cluster elsewhere sets the scale, as Africa does on real data.
|
||||
const fires = [{ lat: 0, lon: 0, frp: 5 }];
|
||||
for (let i = 0; i < 40; i++) {
|
||||
fires.push({ lat: -10 + (i % 5) * 0.1, lon: 25 + i * 0.05, frp: 300 });
|
||||
}
|
||||
const r = buildHeatRaster(fires, HEAT_RASTER);
|
||||
const y = Math.floor((90 / 180) * r.height);
|
||||
const painted = [];
|
||||
for (let x = 0; x < r.width; x++) {
|
||||
const lon = ((x + 0.5) / r.width) * 360 - 180;
|
||||
if (Math.abs(lon) < 5 && r.rgba[(y * r.width + x) * 4 + 3] > 0) {
|
||||
painted.push(x);
|
||||
}
|
||||
}
|
||||
const kmPerPx = 40075 / r.width;
|
||||
assert.ok(painted.length > 0, 'the weak fire still shows');
|
||||
assert.ok(
|
||||
painted.length * kmPerPx <= 100,
|
||||
`spot ${Math.round(painted.length * kmPerPx)} km wide`,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createLifecycle } from './lifecycle.js';
|
||||
|
||||
test('reenabling an empty filtered layer never reveals the retained pre-filter heat raster', async () => {
|
||||
let shown = false;
|
||||
const noop = () => {};
|
||||
const layerState = {
|
||||
_enabled: false,
|
||||
_viewer: {},
|
||||
_dataSource: {},
|
||||
_fires: [],
|
||||
_loading: true,
|
||||
_pickIndexById: new Map(),
|
||||
_heatImagery: {
|
||||
setShow(v) {
|
||||
shown = v;
|
||||
},
|
||||
},
|
||||
};
|
||||
const lifecycle = createLifecycle({
|
||||
layerState,
|
||||
services: {
|
||||
picking: { registerPickOwner: noop, unregisterPickOwner: noop },
|
||||
sprites: { restoreSpriteOrderOnEnable: noop },
|
||||
context: { clearSelectedEntityContextForLayer: noop },
|
||||
},
|
||||
config: { id: 'local-firms', overlayHost: { setVisible: noop } },
|
||||
components: {
|
||||
rendering: { applyHandoffFade: noop },
|
||||
viewport: { installLodWatcher: noop, installMoveEndWatcher: noop },
|
||||
selection: { installClickHandler: noop },
|
||||
},
|
||||
});
|
||||
await lifecycle.methods.enable(layerState._viewer);
|
||||
assert.equal(
|
||||
shown,
|
||||
false,
|
||||
'empty current set must keep the old raster hidden while refresh is pending',
|
||||
);
|
||||
layerState._fires = [{ frp: null, confidence: null }];
|
||||
await lifecycle.methods.enable(layerState._viewer);
|
||||
assert.equal(shown, true, 'nonempty sets still resume their raster');
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { adaptFirmsRecords } from '../../data/firmsAdapt.js';
|
||||
import { fireDetectionKey } from '../../data/firmsLabels.js';
|
||||
import { filterFires } from '../../data/firmsIntensity.js';
|
||||
import { msg } from '../../i18n/index.js';
|
||||
|
||||
export function createIngestion({
|
||||
@@ -40,12 +41,13 @@ export function createIngestion({
|
||||
layerState._stale = Boolean(payload?.stale);
|
||||
const previousSelection = layerState._selectedFire;
|
||||
layerState._selectedFire = null;
|
||||
layerState._fires = adaptFirmsRecords(payload?.fires);
|
||||
layerState._cellCacheByGrid.clear(); // aggregation is per-dataset — new fires, new cells
|
||||
layerState._firesByFrp = [...layerState._fires].sort(
|
||||
layerState._allFires = adaptFirmsRecords(payload?.fires);
|
||||
// Sorted once per payload; applyFilter only filters, so a slider drag
|
||||
// over 200k detections never re-sorts.
|
||||
layerState._allFiresByFrp = [...layerState._allFires].sort(
|
||||
(a, b) => b.frp - a.frp,
|
||||
);
|
||||
layerState._count = layerState._fires.length;
|
||||
applyFilter();
|
||||
// Data age, not response age: a stale proxy payload truthfully reads old.
|
||||
layerState._lastUpdate = Number.isFinite(payload?.fetchedAt)
|
||||
? payload.fetchedAt
|
||||
@@ -65,6 +67,7 @@ export function createIngestion({
|
||||
}
|
||||
components.rendering.renderCurrentLod(true);
|
||||
if (reselected) components.selection.selectFire(reselected, false);
|
||||
layerState._rowControlsListener?.();
|
||||
} catch (error) {
|
||||
if (
|
||||
request.signal.aborted ||
|
||||
@@ -83,6 +86,26 @@ export function createIngestion({
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the intensity filter over the loaded payload. This is the ONE place
|
||||
* the shown set is derived: `_fires` / `_firesByFrp` feed markers, cards,
|
||||
* the heatmap, the context store, the top-fires query and the analyst
|
||||
* records, so none of them can disagree about which fires exist. Cells and
|
||||
* the heat raster are built per shown set, so their caches go with it.
|
||||
*/
|
||||
|
||||
function applyFilter() {
|
||||
layerState._fires = filterFires(layerState._allFires, layerState._filter);
|
||||
layerState._cellCacheByGrid.clear();
|
||||
layerState._heatRaster = null;
|
||||
layerState._firesByFrp = filterFires(
|
||||
layerState._allFiresByFrp,
|
||||
layerState._filter,
|
||||
);
|
||||
layerState._count = layerState._fires.length;
|
||||
layerState._totalCount = layerState._allFires.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the record in the freshly-loaded set matching a previous selection.
|
||||
* Identity is the detection itself (lat/lon/acquisition time) — indices
|
||||
@@ -109,5 +132,5 @@ export function createIngestion({
|
||||
},
|
||||
};
|
||||
|
||||
return { loadHeatmap, findMatchingFire, methods };
|
||||
return { loadHeatmap, applyFilter, findMatchingFire, methods };
|
||||
}
|
||||
@@ -14,6 +14,20 @@ export function createLifecycle({
|
||||
const { id, overlayHost } = config;
|
||||
|
||||
const methods = {
|
||||
/**
|
||||
* Shell services: `imageryHost()` resolves where draped imagery goes on
|
||||
* the active map stack (globe or 3D tiles, src/maps/imageryHost.js). The
|
||||
* heatmap moves with it; without a host it drapes on viewer.imageryLayers.
|
||||
* @param {{imageryHost?: () => {collection: ?Object, kind: string}}|null} services
|
||||
*/
|
||||
attachShellServices(services) {
|
||||
layerState._imageryHost =
|
||||
typeof services?.imageryHost === 'function'
|
||||
? services.imageryHost
|
||||
: null;
|
||||
layerState._heatImagery?.rehome();
|
||||
},
|
||||
|
||||
init(viewer) {
|
||||
if (layerState._destroyed) return;
|
||||
layerState._viewer = viewer;
|
||||
@@ -40,6 +54,10 @@ export function createLifecycle({
|
||||
layerState._billboards.show = true;
|
||||
}
|
||||
overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, true);
|
||||
// A filter can leave an old raster cached while the current set is empty.
|
||||
layerState._heatImagery?.setShow(layerState._fires.length > 0);
|
||||
// The camera can have crossed the hand-off while the layer was off.
|
||||
components.rendering.applyHandoffFade(true);
|
||||
components.viewport.installLodWatcher();
|
||||
components.viewport.installMoveEndWatcher();
|
||||
components.selection.installClickHandler();
|
||||
@@ -64,6 +82,7 @@ export function createLifecycle({
|
||||
components.selection.clearFireSelection();
|
||||
if (layerState._dataSource) layerState._dataSource.show = false;
|
||||
if (layerState._billboards) layerState._billboards.show = false;
|
||||
layerState._heatImagery?.setShow(false);
|
||||
overlayHost.clearSource(FIRMS_OVERLAY_SOURCE_ID);
|
||||
overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, false);
|
||||
clearSelectedEntityContextForLayer(id);
|
||||
@@ -91,10 +110,21 @@ export function createLifecycle({
|
||||
components.rendering.removeDetectionCollections(viewer);
|
||||
components.selection.clearContextRegistrations();
|
||||
clearSelectedEntityContextForLayer(id);
|
||||
layerState._allFires = [];
|
||||
layerState._allFiresByFrp = [];
|
||||
layerState._fires = [];
|
||||
layerState._firesByFrp = [];
|
||||
layerState._cellCacheByGrid.clear();
|
||||
clearTimeout(layerState._heatRebuildTimer);
|
||||
layerState._heatRebuildTimer = null;
|
||||
layerState._heatImagery?.destroy();
|
||||
layerState._heatImagery = null;
|
||||
layerState._heatRaster = null;
|
||||
layerState._handoffHeat = -1;
|
||||
layerState._imageryHost = null;
|
||||
layerState._count = 0;
|
||||
layerState._totalCount = 0;
|
||||
layerState._rowControlsListener = null;
|
||||
layerState._cellCount = 0;
|
||||
layerState._lastUpdate = null;
|
||||
layerState._keyRequired = false;
|
||||
|
||||
+43
-27
@@ -6,6 +6,7 @@ import {
|
||||
accentForSeverity,
|
||||
formatFrpMw,
|
||||
} from '../../data/firmsLabels.js';
|
||||
import { FRP_RAMP, frpRampStop } from '../../data/firmsIntensity.js';
|
||||
import {
|
||||
VIEW_PADDING,
|
||||
DETECTION_COLOR_STOPS,
|
||||
@@ -119,27 +120,30 @@ export function createModel({
|
||||
);
|
||||
}
|
||||
|
||||
function heatColor(value, alpha) {
|
||||
if (value > 0.72) return Cesium.Color.RED.withAlpha(alpha);
|
||||
if (value > 0.42) return Cesium.Color.ORANGE.withAlpha(alpha);
|
||||
return Cesium.Color.YELLOW.withAlpha(alpha);
|
||||
/**
|
||||
* The ramp stop for a fire radiative power (stepped, weakest first).
|
||||
* Intensity alone drives the color: confidence feeds the card text and the
|
||||
* switch, brightness is unreliable (saturates at 367 K) — see HAL-675.
|
||||
* @param {number} frp - Megawatts.
|
||||
* @returns {{name: string, minFrp: number, color: Cesium.Color}}
|
||||
*/
|
||||
|
||||
function frpColorStop(frp) {
|
||||
const name = frpRampStop(frp).name;
|
||||
return (
|
||||
DETECTION_COLOR_STOPS.find((stop) => stop.name === name) ||
|
||||
DETECTION_COLOR_STOPS[0]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick a sprite color stop from FRP + confidence, reusing the same
|
||||
* yellow → orange → red thresholds as the aggregated heat cells.
|
||||
* Pick a sprite color stop for one detection from its FRP on the shared ramp.
|
||||
* @param {Object} fire - Detection record.
|
||||
* @returns {{name: string, color: Cesium.Color}}
|
||||
*/
|
||||
|
||||
function detectionColorStop(fire) {
|
||||
const heat = Math.min(
|
||||
1,
|
||||
Math.sqrt(Math.max(0, fire.frp) / 150) * 0.85 + fire.confidence * 0.15,
|
||||
);
|
||||
if (heat > 0.72) return DETECTION_COLOR_STOPS[0];
|
||||
if (heat > 0.42) return DETECTION_COLOR_STOPS[1];
|
||||
return DETECTION_COLOR_STOPS[2];
|
||||
return frpColorStop(fire?.frp);
|
||||
}
|
||||
|
||||
/** FRP → core marker pixel size, clamped to 8..28px. */
|
||||
@@ -227,6 +231,19 @@ export function createModel({
|
||||
return fire.position;
|
||||
}
|
||||
|
||||
/**
|
||||
* The anchor already built for a detection, without the floor lookup;
|
||||
* for the far marker band, where floors are never warmed and the height
|
||||
* difference is invisible, so tens of thousands of markers skip a cache
|
||||
* read each per rebuild. Builds one through {@link firePosition} once.
|
||||
* @param {Object} fire - Detection record.
|
||||
* @returns {Cesium.Cartesian3}
|
||||
*/
|
||||
|
||||
function fireBuiltPosition(fire) {
|
||||
return fire.position ?? firePosition(fire);
|
||||
}
|
||||
|
||||
/**
|
||||
* Occlusion-test anchor for one detection. Returns the render position
|
||||
* outright for anchors comfortably above the ellipsoid (the common case) and
|
||||
@@ -234,11 +251,11 @@ export function createModel({
|
||||
* NEVER feeds rendering: the datum-correct anchor from {@link firePosition}
|
||||
* is what the sprite and the card are drawn at.
|
||||
* @param {Object} fire - Detection record.
|
||||
* @param {Cesium.Cartesian3} [position] Its render anchor, if at hand.
|
||||
* @returns {Cesium.Cartesian3}
|
||||
*/
|
||||
|
||||
function fireCullPosition(fire) {
|
||||
const position = firePosition(fire);
|
||||
function fireCullPosition(fire, position = firePosition(fire)) {
|
||||
if (fire.positionHeight >= CULL_LIFT_THRESHOLD_M) return position;
|
||||
if (!fire.cullPosition) {
|
||||
fire.cullPosition = Cesium.Cartesian3.fromDegrees(
|
||||
@@ -398,8 +415,8 @@ export function createModel({
|
||||
|
||||
/**
|
||||
* Card model for an aggregated heat cell, e.g. title "14 FIRES", detail
|
||||
* "max 210 MW · new 3h". Accent comes from the candidate (heat-normalized
|
||||
* score is only known at renderCells time). Exported for unit tests.
|
||||
* "max 210 MW · new 3h". Accent comes from the candidate (renderHeat sets
|
||||
* it with {@link cellCardAccent}). Exported for unit tests.
|
||||
* @param {{cell: Object, position: Cesium.Cartesian3, accent: string}} candidate
|
||||
* @param {number} nowMs - Current epoch milliseconds.
|
||||
* @returns {Object} firmsLabels entry.
|
||||
@@ -417,7 +434,7 @@ export function createModel({
|
||||
position: candidate.position,
|
||||
cullPosition: candidate.cullPosition || candidate.position,
|
||||
gapPx: 10,
|
||||
accent: candidate.accent || accentForSeverity('yellow'),
|
||||
accent: candidate.accent || accentForSeverity(FRP_RAMP[0].name),
|
||||
// The count picks the plural form of the language ("حريقان (2)",
|
||||
// "14 حريقًا", "14 ПОЖАРОВ"); the card shows it in capitals like the
|
||||
// other card titles.
|
||||
@@ -464,16 +481,14 @@ export function createModel({
|
||||
}
|
||||
|
||||
/**
|
||||
* Heat-normalized cell score → severity accent, mirroring heatColor's
|
||||
* red/orange/yellow thresholds.
|
||||
* @param {number} normalized - 0..1 normalized heat score.
|
||||
* Cell card accent: the cell's strongest fire on the FRP ramp, the same
|
||||
* color its markers take once the camera is close enough to show them.
|
||||
* @param {{maxFrp?: number}} cell - Aggregated cell.
|
||||
* @returns {string} "r, g, b" accent string.
|
||||
*/
|
||||
|
||||
function cellAccent(normalized) {
|
||||
if (normalized > 0.72) return accentForSeverity('red');
|
||||
if (normalized > 0.42) return accentForSeverity('orange');
|
||||
return accentForSeverity('yellow');
|
||||
function cellCardAccent(cell) {
|
||||
return accentForSeverity(frpColorStop(cell?.maxFrp ?? 0).name);
|
||||
}
|
||||
|
||||
/** Coordinate line like "30.512°N 75.831°E". */
|
||||
@@ -579,12 +594,13 @@ export function createModel({
|
||||
boundsContainPoint,
|
||||
cellIntersectsBounds,
|
||||
heatScore,
|
||||
heatColor,
|
||||
frpColorStop,
|
||||
detectionColorStop,
|
||||
frpPixelSize,
|
||||
sizeBucket,
|
||||
glowSprite,
|
||||
firePosition,
|
||||
fireBuiltPosition,
|
||||
fireCullPosition,
|
||||
cellCullPosition,
|
||||
applyHorizonCull,
|
||||
@@ -593,7 +609,7 @@ export function createModel({
|
||||
buildFireCard,
|
||||
buildCellCard,
|
||||
applyFirmsOverlayPolicy,
|
||||
cellAccent,
|
||||
cellCardAccent,
|
||||
formatLatLon,
|
||||
formatFrp,
|
||||
formatAge,
|
||||
|
||||
+57
-13
@@ -1,4 +1,5 @@
|
||||
import { FIRMS_AMBIENT_COHORT_LIMIT } from '../../data/firmsLabels.js';
|
||||
import { FRP_RAMP } from '../../data/firmsIntensity.js';
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
/** Client poll interval; the proxy's 30 min TTL is what guards upstream quota. */
|
||||
@@ -6,9 +7,10 @@ import * as Cesium from 'cesium';
|
||||
export const REFRESH_INTERVAL_MS = 600_000;
|
||||
|
||||
/**
|
||||
* LOD bands keyed by camera height. `cells` bands render aggregated
|
||||
* ground-clamped heat rectangles; `detections` bands (< ~750km plus the
|
||||
* local band) render individual fire detections as glow-sprite billboards.
|
||||
* LOD bands keyed by camera height. `heat` bands (above ~4,500 km) drape a
|
||||
* Snap Map–style heatmap over the globe (firmsHeatRaster.js); their
|
||||
* `gridDegrees` aggregation only feeds the "N FIRES" cards. `detections`
|
||||
* bands render individual fire detections as glow-sprite billboards.
|
||||
* Labels in every band go through the shared screen-space greedy declutter
|
||||
* (hard cap MAX_AMBIENT_LABELS) — there are no per-band label knobs.
|
||||
*/
|
||||
@@ -17,24 +19,28 @@ export const LOD_LEVELS = [
|
||||
{
|
||||
id: 'global',
|
||||
minHeight: 9000000,
|
||||
mode: 'cells',
|
||||
mode: 'heat',
|
||||
gridDegrees: 2.0,
|
||||
maxCells: 1800,
|
||||
labelDistance: 12000000,
|
||||
},
|
||||
{
|
||||
id: 'regional',
|
||||
minHeight: 3000000,
|
||||
mode: 'cells',
|
||||
minHeight: 4500000,
|
||||
mode: 'heat',
|
||||
gridDegrees: 1.0,
|
||||
maxCells: 3600,
|
||||
labelDistance: 8500000,
|
||||
},
|
||||
{
|
||||
// Up to 40,000 markers: at the 3,600 km end of the hand-off the padded
|
||||
// view over Africa, the densest region, holds ~40,000 detections, and a
|
||||
// tighter cap left the heatmap's weak spots dissolving into nothing.
|
||||
// A rebuild of that many costs ~60 ms, only there.
|
||||
id: 'local',
|
||||
minHeight: 750000,
|
||||
mode: 'detections',
|
||||
maxDetections: 2500,
|
||||
maxDetections: 40000,
|
||||
labelDistance: 4500000,
|
||||
},
|
||||
{
|
||||
@@ -46,6 +52,38 @@ export const LOD_LEVELS = [
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Global heat raster for the `heat` bands: ≈0.09° (≈10 km) per pixel and a
|
||||
* one-pixel Gaussian σ, so a lone fire is a spot about the size of its
|
||||
* marker near the hand-off and the two cross-fade without a jump;
|
||||
* neighbours merge only when they nearly touch (σ 3 on a 2048 grid smeared
|
||||
* each fire over ~200 km). Built once per data refresh or filter change,
|
||||
* never per camera move.
|
||||
*/
|
||||
|
||||
export const HEAT_RASTER = Object.freeze({
|
||||
width: 4096,
|
||||
height: 2048,
|
||||
sigmaPx: 1,
|
||||
});
|
||||
|
||||
/**
|
||||
* Camera heights, in metres, over which the heatmap and the markers
|
||||
* cross-fade around the 4,500 km band edge: markers alone below `from`,
|
||||
* heatmap alone above `to`. Wider than the ±10 % band hysteresis, so
|
||||
* whichever band is active inside it, both layers are already built.
|
||||
*/
|
||||
|
||||
export const HANDOFF_FADE = Object.freeze({ from: 3_600_000, to: 5_400_000 });
|
||||
|
||||
/**
|
||||
* Trailing delay before a stale heatmap is rebuilt while one is already on
|
||||
* screen: a slider drag fires many filter changes, and a build plus PNG
|
||||
* encode costs a few hundred milliseconds. The first paint is immediate.
|
||||
*/
|
||||
|
||||
export const HEAT_REBUILD_MS = 150;
|
||||
|
||||
export const LOD_CHECK_MS = 650;
|
||||
|
||||
/** +/-10% hysteresis on LOD band edges so slow zooms don't thrash rebuilds. */
|
||||
@@ -88,10 +126,16 @@ export const CULL_LIFT_THRESHOLD_M = 10;
|
||||
|
||||
export const CULL_LIFT_M = 12;
|
||||
|
||||
/** Color stops shared by cell heat fills and detection glow sprites. */
|
||||
/**
|
||||
* Color stops for detection glow sprites and card accents: the
|
||||
* six-stop ember → white-hot FRP ramp (firmsIntensity.js), weakest first, as Cesium
|
||||
* colors. Stop names key the glow-sprite cache and the card accents.
|
||||
*/
|
||||
|
||||
export const DETECTION_COLOR_STOPS = [
|
||||
{ name: 'red', color: Cesium.Color.RED },
|
||||
{ name: 'orange', color: Cesium.Color.ORANGE },
|
||||
{ name: 'yellow', color: Cesium.Color.YELLOW },
|
||||
];
|
||||
export const DETECTION_COLOR_STOPS = FRP_RAMP.map((stop) =>
|
||||
Object.freeze({
|
||||
name: stop.name,
|
||||
minFrp: stop.minFrp,
|
||||
color: Cesium.Color.fromCssColorString(stop.hex),
|
||||
}),
|
||||
);
|
||||
+161
-2
@@ -1,6 +1,18 @@
|
||||
import { fireDetectionKey } from '../../data/firmsLabels.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { REFRESH_INTERVAL_MS } from './policy.js';
|
||||
import {
|
||||
FIRMS_CONFIDENCE_MODES,
|
||||
FRP_SLIDER_TICKS,
|
||||
firePassesFilter,
|
||||
frpForSliderPosition,
|
||||
normalizeFireFilter,
|
||||
rampTrackGradient,
|
||||
sliderPositionForFrp,
|
||||
} from '../../data/firmsIntensity.js';
|
||||
import { formatNumber, t } from '../../i18n/index.js';
|
||||
import { LOD_LEVELS, REFRESH_INTERVAL_MS } from './policy.js';
|
||||
|
||||
/** Slider resolution: whole-percent positions along the log axis. */
|
||||
const SLIDER_STEPS = 1000;
|
||||
|
||||
export function createQueries({
|
||||
layerState,
|
||||
@@ -11,6 +23,25 @@ export function createQueries({
|
||||
}) {
|
||||
const { id, name, icon, source } = config;
|
||||
|
||||
/** Whole-megawatt label for a slider value or tick ("0", "20", "1 000"). */
|
||||
function frpLabel(frp) {
|
||||
return formatNumber(frp, { maximumFractionDigits: 0 });
|
||||
}
|
||||
|
||||
const confidenceLabels = () => ({
|
||||
all: t('All', { context: 'fire confidence filter' }),
|
||||
nominal: t('Nominal+', { context: 'fire confidence filter' }),
|
||||
high: t('High', { context: 'fire confidence filter' }),
|
||||
});
|
||||
|
||||
const confidenceTitles = () => ({
|
||||
all: t(
|
||||
'Every detection — many of the strongest fires are rated low confidence',
|
||||
),
|
||||
nominal: t('Nominal and high confidence detections'),
|
||||
high: t('High confidence detections only'),
|
||||
});
|
||||
|
||||
const methods = {
|
||||
/** Return the selected detection as a plain record for application consumers. */
|
||||
getSelectedInfo() {
|
||||
@@ -51,6 +82,110 @@ export function createQueries({
|
||||
// not this interval — is what protects the upstream FIRMS quota).
|
||||
updateInterval: REFRESH_INTERVAL_MS,
|
||||
|
||||
/**
|
||||
* The intensity filter as layer options (share token `w`, owner
|
||||
* `local-firms` in layerState.js).
|
||||
* @returns {{minFrp: number, confidence: string}}
|
||||
*/
|
||||
getParams() {
|
||||
return { ...layerState._filter };
|
||||
},
|
||||
|
||||
/**
|
||||
* Apply a (partial) intensity filter. Invalid fields keep their current
|
||||
* value. The filter runs in one place (ingestion.applyFilter) and the LOD
|
||||
* rebuild re-derives markers, cells, cards and context registrations from
|
||||
* the shown set; a selected fire that no longer passes is deselected.
|
||||
* @param {{minFrp?: number, confidence?: string}} params
|
||||
* @returns {boolean} True — the request is always accepted.
|
||||
*/
|
||||
setParams(params = {}) {
|
||||
const next = normalizeFireFilter(params, layerState._filter);
|
||||
if (
|
||||
next.minFrp === layerState._filter.minFrp &&
|
||||
next.confidence === layerState._filter.confidence
|
||||
)
|
||||
return true;
|
||||
layerState._filter = next;
|
||||
if (!layerState._allFires.length) return true;
|
||||
components.ingestion.applyFilter();
|
||||
if (
|
||||
layerState._selectedFire &&
|
||||
!firePassesFilter(layerState._selectedFire, next)
|
||||
) {
|
||||
components.selection.clearFireSelection();
|
||||
}
|
||||
if (layerState._enabled) components.rendering.renderCurrentLod(true);
|
||||
layerState._rowControlsListener?.();
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Row controls under the Active Fires row: the log-scale minimum-FRP
|
||||
* slider whose track is painted with the intensity ramp (so it doubles as
|
||||
* the legend), the All / Nominal+ / High confidence switch, and the
|
||||
* "N of M shown" counter.
|
||||
*/
|
||||
getRowControls() {
|
||||
const { minFrp, confidence } = layerState._filter;
|
||||
const labels = confidenceLabels();
|
||||
const titles = confidenceTitles();
|
||||
const shown = layerState._count;
|
||||
const total = layerState._totalCount;
|
||||
return {
|
||||
slider: {
|
||||
id: 'min-frp',
|
||||
label: t('Fire intensity (FRP)'),
|
||||
ariaLabel: t('Minimum fire radiative power, megawatts'),
|
||||
valueText:
|
||||
minFrp > 0
|
||||
? t('≥ {value} MW', { value: frpLabel(minFrp) })
|
||||
: t('All', { context: 'fire intensity filter' }),
|
||||
min: 0,
|
||||
max: SLIDER_STEPS,
|
||||
step: 1,
|
||||
value: Math.round(sliderPositionForFrp(minFrp) * SLIDER_STEPS),
|
||||
track: rampTrackGradient(),
|
||||
ticks: FRP_SLIDER_TICKS.map((frp) => ({
|
||||
at: sliderPositionForFrp(frp),
|
||||
label: frpLabel(frp),
|
||||
})),
|
||||
params: (value) => ({
|
||||
minFrp: frpForSliderPosition(Number(value) / SLIDER_STEPS),
|
||||
}),
|
||||
},
|
||||
segmented: {
|
||||
id: 'confidence',
|
||||
label: t('Confidence'),
|
||||
ariaLabel: t('Detection confidence'),
|
||||
options: FIRMS_CONFIDENCE_MODES.map((mode) => ({
|
||||
id: `confidence-${mode}`,
|
||||
label: labels[mode],
|
||||
title: titles[mode],
|
||||
active: mode === confidence,
|
||||
params: { confidence: mode },
|
||||
})),
|
||||
},
|
||||
info: total
|
||||
? t('{shown} of {total} shown', {
|
||||
shown: formatNumber(shown),
|
||||
total: formatNumber(total),
|
||||
})
|
||||
: '',
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Install the panel's "row controls changed" callback: a load or a filter
|
||||
* change moves the counter, and nothing else repaints the row before the
|
||||
* next scheduled refresh.
|
||||
* @param {(() => void)|null} listener
|
||||
*/
|
||||
setRowControlsListener(listener) {
|
||||
layerState._rowControlsListener =
|
||||
typeof listener === 'function' ? listener : null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Layer stats for the data panel. Degraded feed states surface through
|
||||
* `error` (established qa-failstate pattern: a dead feed must never look
|
||||
@@ -84,7 +219,9 @@ export function createQueries({
|
||||
});
|
||||
}
|
||||
return {
|
||||
// Shown (filtered) detections; `total` is the loaded payload.
|
||||
count: layerState._count,
|
||||
total: layerState._totalCount,
|
||||
cells: layerState._cellCount,
|
||||
lastUpdate: layerState._lastUpdate,
|
||||
loading: layerState._loading,
|
||||
@@ -172,12 +309,34 @@ export function createQueries({
|
||||
return result;
|
||||
},
|
||||
|
||||
/** Test seam: render one named LOD band against a near-global view. */
|
||||
_renderLodForTest(lodId) {
|
||||
components.rendering.renderBand(
|
||||
LOD_LEVELS.find((level) => level.id === lodId),
|
||||
null,
|
||||
);
|
||||
},
|
||||
|
||||
/** Test seam: one frame of the hand-off fade at the current camera. */
|
||||
_applyHandoffFadeForTest() {
|
||||
components.rendering.applyHandoffFade();
|
||||
},
|
||||
|
||||
/** Test seam: entities drawn by the layer's data source. */
|
||||
_entityCountForTest() {
|
||||
return layerState._dataSource?.entities?.values?.length ?? 0;
|
||||
},
|
||||
|
||||
/** Test seam that binds the production click path and indexes. */
|
||||
_bindInteractionForTest(viewer, fires = []) {
|
||||
layerState._viewer = viewer;
|
||||
layerState._enabled = true;
|
||||
layerState._allFires = fires;
|
||||
layerState._allFiresByFrp = [...fires];
|
||||
layerState._fires = fires;
|
||||
layerState._firesByFrp = [...fires];
|
||||
layerState._count = fires.length;
|
||||
layerState._totalCount = fires.length;
|
||||
layerState._pickIndexById.clear();
|
||||
layerState._labelLodDistance = 1e7;
|
||||
layerState._labelCandidates = fires.map((fire) => {
|
||||
|
||||
+191
-83
@@ -1,7 +1,21 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { FIRMS_OVERLAY_SOURCE_ID } from '../../data/firmsLabels.js';
|
||||
import { horizonOccluder } from '../../data/iconOrientation.js';
|
||||
import { LOD_LEVELS, CONTEXT_TOP_N } from './policy.js';
|
||||
import { buildHeatRaster } from '../../data/firmsHeatRaster.js';
|
||||
import {
|
||||
LOD_LEVELS,
|
||||
CONTEXT_TOP_N,
|
||||
HANDOFF_FADE,
|
||||
HEAT_RASTER,
|
||||
HEAT_REBUILD_MS,
|
||||
} from './policy.js';
|
||||
import { createHeatImagery } from './heatImagery.js';
|
||||
|
||||
/** The markers drawn under the far bands' heatmap for the hand-off fade. */
|
||||
const HANDOFF_MARKER_LOD = LOD_LEVELS.find((lod) => lod.mode === 'detections');
|
||||
|
||||
/** Fade steps: a moving camera rewrites marker colors at most this often. */
|
||||
const FADE_STEPS = 32;
|
||||
|
||||
export function createRendering({
|
||||
layerState,
|
||||
@@ -29,8 +43,11 @@ export function createRendering({
|
||||
!layerState._dataSource ||
|
||||
!layerState._fires.length ||
|
||||
!layerState._viewer
|
||||
)
|
||||
) {
|
||||
// A filter that leaves nothing shown must not leave the old heat behind.
|
||||
if (!layerState._fires.length) layerState._heatImagery?.setShow(false);
|
||||
return false;
|
||||
}
|
||||
const lodIndex = components.viewport.selectLodIndex(
|
||||
components.viewport.cameraHeight(),
|
||||
);
|
||||
@@ -51,14 +68,54 @@ export function createRendering({
|
||||
? components.model.paddedDegreeBounds(viewRect)
|
||||
: null;
|
||||
|
||||
if (lod.mode === 'detections') {
|
||||
renderDetections(lod, bounds);
|
||||
} else {
|
||||
renderCells(aggregateFires(lod, bounds), lod, bounds);
|
||||
}
|
||||
renderBand(lod, bounds);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw one band. Markers and heatmap stay built in every band; the
|
||||
* hand-off only fades between them.
|
||||
* @param {Object} lod - Band descriptor.
|
||||
* @param {?Object} bounds - Padded view bounds in degrees, or null.
|
||||
*/
|
||||
|
||||
function renderBand(lod, bounds) {
|
||||
prepareHeat(lod.mode === 'heat');
|
||||
if (lod.mode === 'detections') renderDetections(lod, bounds);
|
||||
else renderHeat(lod, bounds);
|
||||
applyHandoffFade(true);
|
||||
}
|
||||
|
||||
const fadeColor = new Cesium.Color();
|
||||
|
||||
/**
|
||||
* Cross-fade between markers and heatmap by camera height (HANDOFF_FADE),
|
||||
* on every rendered frame, so a fast zoom never outruns the throttled band
|
||||
* check. Cheap when nothing changed: one height read and a compare.
|
||||
* @param {boolean} [force] Reapply, e.g. to freshly added markers.
|
||||
*/
|
||||
|
||||
function applyHandoffFade(force = false) {
|
||||
const height = components.viewport.cameraHeight();
|
||||
const t = Math.min(
|
||||
1,
|
||||
Math.max(
|
||||
0,
|
||||
(height - HANDOFF_FADE.from) / (HANDOFF_FADE.to - HANDOFF_FADE.from),
|
||||
),
|
||||
);
|
||||
const heat = Math.round(t * t * (3 - 2 * t) * FADE_STEPS) / FADE_STEPS;
|
||||
if (!force && heat === layerState._handoffHeat) return;
|
||||
layerState._handoffHeat = heat;
|
||||
layerState._heatImagery?.setOpacity(heat);
|
||||
const billboards = layerState._billboards;
|
||||
if (!billboards) return;
|
||||
billboards.show = layerState._enabled && heat < 1;
|
||||
const color = Cesium.Color.WHITE.withAlpha(1 - heat, fadeColor);
|
||||
for (let i = 0; i < billboards.length; i += 1)
|
||||
billboards.get(i).color = color;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bin all detections into grid cells, then viewport-clip BEFORE the
|
||||
* top-N cap so fires in view never lose their slot to higher-scoring
|
||||
@@ -118,65 +175,96 @@ export function createRendering({
|
||||
return clipped;
|
||||
}
|
||||
|
||||
/** The heat imagery adapter, created on first use. */
|
||||
|
||||
function heatImagery() {
|
||||
if (!layerState._heatImagery) {
|
||||
layerState._heatImagery = createHeatImagery({
|
||||
cesium: Cesium,
|
||||
getHost: () =>
|
||||
layerState._imageryHost?.() ?? {
|
||||
collection: layerState._viewer?.imageryLayers ?? null,
|
||||
kind: 'globe',
|
||||
},
|
||||
});
|
||||
}
|
||||
return layerState._heatImagery;
|
||||
}
|
||||
|
||||
/** Build the heat raster from the shown set and drape it. */
|
||||
|
||||
function rebuildHeatRaster() {
|
||||
clearTimeout(layerState._heatRebuildTimer);
|
||||
layerState._heatRebuildTimer = null;
|
||||
if (layerState._destroyed) return;
|
||||
layerState._heatRaster = buildHeatRaster(layerState._fires, HEAT_RASTER);
|
||||
heatImagery().setRaster(layerState._heatRaster);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render aggregated heat cells (global/regional bands) as ground-clamped
|
||||
* rectangles. Cell labels do NOT live on the entities anymore — they go
|
||||
* through the same unclamped, screen-space-decluttered label pipeline as
|
||||
* detections (see {@link rebuildAmbientLabels}).
|
||||
* @param {Array<Object>} cells - Aggregated cells, heat-sorted descending.
|
||||
* Make sure the draped heatmap matches the shown set. The first paint in a
|
||||
* far band is immediate; other builds (filter drags, refreshes, the
|
||||
* preload under the markers) trail by HEAT_REBUILD_MS and keep the
|
||||
* previous heatmap on screen meanwhile.
|
||||
* @param {boolean} now Build at once when nothing is draped yet.
|
||||
*/
|
||||
|
||||
function ensureHeatRaster(now) {
|
||||
if (layerState._heatRaster) return;
|
||||
if (now && !heatImagery().hasRaster()) {
|
||||
rebuildHeatRaster();
|
||||
return;
|
||||
}
|
||||
clearTimeout(layerState._heatRebuildTimer);
|
||||
layerState._heatRebuildTimer = setTimeout(
|
||||
rebuildHeatRaster,
|
||||
HEAT_REBUILD_MS,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the heatmap draped on the current map stack and current with the
|
||||
* shown set in every band; the hand-off fade decides how much of it shows.
|
||||
* @param {boolean} now The camera is in a far band.
|
||||
*/
|
||||
|
||||
function prepareHeat(now) {
|
||||
const imagery = heatImagery();
|
||||
// The map stack may have changed since the last render.
|
||||
imagery.rehome();
|
||||
ensureHeatRaster(now);
|
||||
imagery.setShow(layerState._enabled);
|
||||
}
|
||||
|
||||
/**
|
||||
* Far bands (global/regional): a Snap Map–style heatmap draped over the
|
||||
* globe, transparent where nothing burns, plus the decluttered "N FIRES"
|
||||
* cards from the coarse aggregation. The nearest band's markers are kept
|
||||
* built underneath for the hand-off fade and hidden once it completes;
|
||||
* cards go through the same screen-space label pipeline as detections
|
||||
* (see {@link rebuildAmbientLabels}).
|
||||
* @param {Object} lod - Active LOD descriptor.
|
||||
* @param {?Object} bounds - Padded view bounds in degrees, or null.
|
||||
*/
|
||||
|
||||
function renderCells(cells, lod, bounds) {
|
||||
ensureDetectionCollections();
|
||||
layerState._dataSource.entities.removeAll();
|
||||
if (layerState._billboards) layerState._billboards.removeAll();
|
||||
layerState._pickIndexById.clear();
|
||||
layerState._cullPositions.length = 0;
|
||||
layerState._cellCount = cells.length;
|
||||
const maxScore = Math.max(1, ...cells.map(components.model.heatScore));
|
||||
function renderHeat(lod, bounds) {
|
||||
paintMarkers(HANDOFF_MARKER_LOD, bounds);
|
||||
|
||||
const cells = aggregateFires(lod, bounds);
|
||||
layerState._cellCount = cells.length;
|
||||
layerState._labelCandidates = [];
|
||||
layerState._labelLodDistance = lod.labelDistance;
|
||||
|
||||
// Cells arrive heat-sorted, so candidate order doubles as label priority.
|
||||
for (const cell of cells) {
|
||||
const score = components.model.heatScore(cell);
|
||||
const normalized = Math.min(1, Math.sqrt(score / maxScore));
|
||||
const alpha = 0.16 + normalized * 0.5;
|
||||
const color = components.model.heatColor(normalized, alpha);
|
||||
const centerLon = cell.lonCell + lod.gridDegrees / 2;
|
||||
const centerLat = cell.latCell + lod.gridDegrees / 2;
|
||||
const position = Cesium.Cartesian3.fromDegrees(centerLon, centerLat, 0);
|
||||
|
||||
layerState._dataSource.entities.add({
|
||||
rectangle: {
|
||||
coordinates: Cesium.Rectangle.fromDegrees(
|
||||
cell.lonCell,
|
||||
cell.latCell,
|
||||
cell.lonCell + lod.gridDegrees,
|
||||
cell.latCell + lod.gridDegrees,
|
||||
),
|
||||
material: new Cesium.ColorMaterialProperty(color),
|
||||
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
|
||||
},
|
||||
position,
|
||||
properties: {
|
||||
count: cell.count,
|
||||
intensity: score,
|
||||
maxFrp: cell.maxFrp,
|
||||
night: cell.night,
|
||||
},
|
||||
});
|
||||
|
||||
// Cells arrive heat-sorted, so candidate order doubles as label priority.
|
||||
// Accent is resolved here because `normalized` only exists in this walk.
|
||||
layerState._labelCandidates.push({
|
||||
position,
|
||||
position: Cesium.Cartesian3.fromDegrees(centerLon, centerLat, 0),
|
||||
cullPosition: components.model.cellCullPosition(centerLon, centerLat),
|
||||
fire: null,
|
||||
cell,
|
||||
accent: components.model.cellAccent(normalized),
|
||||
accent: components.model.cellCardAccent(cell),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -202,9 +290,48 @@ export function createRendering({
|
||||
*/
|
||||
|
||||
function renderDetections(lod, bounds) {
|
||||
const markers = paintMarkers(lod, bounds);
|
||||
if (!markers) return;
|
||||
const candidates = markers.map((marker) => marker.fire);
|
||||
|
||||
layerState._cellCount = candidates.length;
|
||||
// FRP-sorted walk: marker order doubles as label priority.
|
||||
layerState._labelCandidates = markers;
|
||||
layerState._labelLodDistance = lod.labelDistance;
|
||||
|
||||
components.cards.rebuildAmbientLabels();
|
||||
components.selection.refreshContextRegistrations(
|
||||
candidates.slice(0, CONTEXT_TOP_N),
|
||||
);
|
||||
|
||||
if (lod.id === 'close') {
|
||||
// Batched (chunked ≤200, sequential, session-cached) DEM warm for the
|
||||
// rendered subset; fires are static so each coarse cell resolves once
|
||||
// ever. Re-render ONLY when a floor actually landed — a failed resolve
|
||||
// reports false, so this chain terminates instead of looping against a
|
||||
// down proxy (the next camera-driven rebuild retries).
|
||||
warmFireAnchorFloors(candidates).then((warmed) => {
|
||||
if (!warmed || !layerState._enabled || !layerState._viewer) return;
|
||||
const currentLod = LOD_LEVELS[layerState._currentLodIndex];
|
||||
if (!currentLod || currentLod.mode !== 'detections') return;
|
||||
renderCurrentLod(true);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The glow-sprite markers for a detections band: the strongest fires in
|
||||
* view, capped at the band's maxDetections, horizon-culled.
|
||||
* @param {Object} lod - Detections band descriptor.
|
||||
* @param {?Object} bounds - Padded view bounds in degrees, or null.
|
||||
* @returns {?Array<Object>} Label candidates for the fires drawn,
|
||||
* strongest first; null before the sprite collection exists.
|
||||
*/
|
||||
|
||||
function paintMarkers(lod, bounds) {
|
||||
ensureDetectionCollections();
|
||||
layerState._dataSource.entities.removeAll();
|
||||
if (!layerState._billboards) return;
|
||||
if (!layerState._billboards) return null;
|
||||
layerState._billboards.removeAll();
|
||||
layerState._pickIndexById.clear();
|
||||
layerState._cullPositions.length = 0;
|
||||
@@ -226,17 +353,20 @@ export function createRendering({
|
||||
candidates = layerState._firesByFrp.slice(0, lod.maxDetections);
|
||||
}
|
||||
|
||||
layerState._cellCount = candidates.length;
|
||||
layerState._labelCandidates = [];
|
||||
layerState._labelLodDistance = lod.labelDistance;
|
||||
|
||||
const markers = [];
|
||||
// Only the close band warms terrain floors (renderDetections).
|
||||
const anchor =
|
||||
lod.id === 'close'
|
||||
? components.model.firePosition
|
||||
: components.model.fireBuiltPosition;
|
||||
for (const fire of candidates) {
|
||||
const coreSize = components.model.frpPixelSize(fire.frp);
|
||||
const position = components.model.firePosition(fire);
|
||||
const cullPosition = components.model.fireCullPosition(fire);
|
||||
const position = anchor(fire);
|
||||
const cullPosition = components.model.fireCullPosition(fire, position);
|
||||
const pickId = `firms-${fire.index}`;
|
||||
layerState._pickIndexById.set(pickId, fire);
|
||||
layerState._cullPositions.push(cullPosition);
|
||||
markers.push({ position, cullPosition, fire, cell: null });
|
||||
layerState._billboards.add({
|
||||
id: pickId,
|
||||
position,
|
||||
@@ -248,37 +378,13 @@ export function createRendering({
|
||||
horizontalOrigin: Cesium.HorizontalOrigin.CENTER,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
});
|
||||
// FRP-sorted walk: candidate order doubles as label priority.
|
||||
layerState._labelCandidates.push({
|
||||
position,
|
||||
cullPosition,
|
||||
fire,
|
||||
cell: null,
|
||||
});
|
||||
}
|
||||
|
||||
// Freshly-added sprites default to show=true; cull the far side now so a
|
||||
// rebuild never flashes through-the-planet detections while the camera
|
||||
// sits still (the moveEnd/preRender hooks only fire on camera motion).
|
||||
refreshHorizonCulling();
|
||||
components.cards.rebuildAmbientLabels();
|
||||
components.selection.refreshContextRegistrations(
|
||||
candidates.slice(0, CONTEXT_TOP_N),
|
||||
);
|
||||
|
||||
if (lod.id === 'close') {
|
||||
// Batched (chunked ≤200, sequential, session-cached) DEM warm for the
|
||||
// rendered subset; fires are static so each coarse cell resolves once
|
||||
// ever. Re-render ONLY when a floor actually landed — a failed resolve
|
||||
// reports false, so this chain terminates instead of looping against a
|
||||
// down proxy (the next camera-driven rebuild retries).
|
||||
warmFireAnchorFloors(candidates).then((warmed) => {
|
||||
if (!warmed || !layerState._enabled || !layerState._viewer) return;
|
||||
const currentLod = LOD_LEVELS[layerState._currentLodIndex];
|
||||
if (!currentLod || currentLod.mode !== 'detections') return;
|
||||
renderCurrentLod(true);
|
||||
});
|
||||
}
|
||||
return markers;
|
||||
}
|
||||
|
||||
function ensureDetectionCollections() {
|
||||
@@ -344,9 +450,11 @@ export function createRendering({
|
||||
}
|
||||
return {
|
||||
renderCurrentLod,
|
||||
renderBand,
|
||||
aggregateFires,
|
||||
renderCells,
|
||||
renderHeat,
|
||||
renderDetections,
|
||||
applyHandoffFade,
|
||||
ensureDetectionCollections,
|
||||
removeDetectionCollections,
|
||||
fireHorizonOccluder,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { DEFAULT_FIRE_FILTER } from '../../data/firmsIntensity.js';
|
||||
|
||||
export function createFirmsState({ services, config }) {
|
||||
const layerState = {};
|
||||
@@ -39,12 +40,39 @@ export function createFirmsState({ services, config }) {
|
||||
|
||||
layerState._error = null;
|
||||
|
||||
/** Every adapted detection in the current payload, before the filter. */
|
||||
|
||||
layerState._allFires = [];
|
||||
|
||||
/** `_allFires` sorted by FRP descending, once per payload. */
|
||||
|
||||
layerState._allFiresByFrp = [];
|
||||
|
||||
/**
|
||||
* The detections that pass the intensity filter — the ONLY set rendering,
|
||||
* selection, cells, the top-fires query and the analyst records ever walk.
|
||||
*/
|
||||
|
||||
layerState._fires = [];
|
||||
|
||||
layerState._firesByFrp = [];
|
||||
|
||||
/** Shown (filtered) detection count. */
|
||||
|
||||
layerState._count = 0;
|
||||
|
||||
/** Loaded detection count before the filter ("N of M shown"). */
|
||||
|
||||
layerState._totalCount = 0;
|
||||
|
||||
/** Minimum FRP (MW) and confidence mode; see firmsIntensity.js. */
|
||||
|
||||
layerState._filter = { ...DEFAULT_FIRE_FILTER };
|
||||
|
||||
/** Panel callback: repaint the row controls (counter) after a load. */
|
||||
|
||||
layerState._rowControlsListener = null;
|
||||
|
||||
layerState._cellCount = 0;
|
||||
|
||||
layerState._lastUpdate = null;
|
||||
@@ -103,6 +131,29 @@ export function createFirmsState({ services, config }) {
|
||||
|
||||
layerState._cellCacheByGrid = new Map();
|
||||
|
||||
/**
|
||||
* Heat raster for the far bands (firmsHeatRaster.js); null = stale, rebuilt
|
||||
* on the next far render. Cleared with the cell cache on every new shown set.
|
||||
*/
|
||||
|
||||
layerState._heatRaster = null;
|
||||
|
||||
/** Pending debounced heat rebuild (see HEAT_REBUILD_MS). */
|
||||
|
||||
layerState._heatRebuildTimer = null;
|
||||
|
||||
/** Last applied hand-off fade (0 markers … 1 heatmap); -1 forces a reapply. */
|
||||
|
||||
layerState._handoffHeat = -1;
|
||||
|
||||
/** Imagery adapter (heatImagery.js), created on the first far render. */
|
||||
|
||||
layerState._heatImagery = null;
|
||||
|
||||
/** Shell-provided imagery host resolver; falls back to viewer.imageryLayers. */
|
||||
|
||||
layerState._imageryHost = null;
|
||||
|
||||
/** Camera idle snapshot so the throttled LOD check is ~free when parked. */
|
||||
|
||||
layerState._camSnapValid = false;
|
||||
|
||||
@@ -151,6 +151,9 @@ export function createViewport({
|
||||
// on screen throughout it — so the horizon pass below must keep running
|
||||
// while a fetch is in flight. Only the rebuild is gated on fresh data.
|
||||
if (!layerState._enabled) return;
|
||||
// Every frame, ahead of the throttle: a fast zoom crosses the whole
|
||||
// hand-off between two band checks.
|
||||
components.rendering.applyHandoffFade();
|
||||
const now = performance.now();
|
||||
if (now - layerState._lastLodCheck < LOD_CHECK_MS) return;
|
||||
layerState._lastLodCheck = now;
|
||||
|
||||
@@ -252,6 +252,7 @@ export const SHARE_VIEW_FLAGS = freezeAll([
|
||||
* written explicitly.
|
||||
*/
|
||||
export const SHARE_VIEW_LAYER_OPTION_ABSENT = Object.freeze({
|
||||
'local-firms': Object.freeze({ minFrp: 0, confidence: 'all' }),
|
||||
traffic: Object.freeze({ roadMode: null }),
|
||||
'weather-lightning': Object.freeze({ opacity: 'strong' }),
|
||||
'weather-radar': Object.freeze({ opacity: 'strong' }),
|
||||
|
||||
@@ -4,6 +4,14 @@ import { suggestView } from '../views.js';
|
||||
import { defineTool, ToolError } from '../catalog.js';
|
||||
import { AREA_SCHEMA, areaContains, resolveArea } from '../area.js';
|
||||
import { LIMIT_SCHEMA, capRows, countNoun, isoTime } from '../results.js';
|
||||
import {
|
||||
FIRMS_CONFIDENCE_MODES,
|
||||
MIN_FRP_MW_MAX,
|
||||
MIN_FRP_MW_MIN,
|
||||
firePassesFilter,
|
||||
normalizeFireFilter,
|
||||
} from '../../data/firmsIntensity.js';
|
||||
import { normalizeConfidence } from '../../data/firmsAdapt.js';
|
||||
|
||||
function acquiredMsUtc(acqDate, acqTime) {
|
||||
if (typeof acqDate !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(acqDate))
|
||||
@@ -87,10 +95,30 @@ export const getActiveFires = defineTool({
|
||||
title: 'Active fires',
|
||||
description:
|
||||
'Satellite fire detections from NASA FIRMS in the last 24 hours within an ' +
|
||||
'area, most intense (fire radiative power) first.',
|
||||
'area, most intense (fire radiative power) first. Optionally limited to a ' +
|
||||
'minimum fire radiative power in megawatts and a confidence level — the ' +
|
||||
"same filter as the Active Fires layer's intensity controls.",
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { area: AREA_SCHEMA, limit: LIMIT_SCHEMA },
|
||||
properties: {
|
||||
area: AREA_SCHEMA,
|
||||
min_frp_mw: {
|
||||
type: 'number',
|
||||
minimum: MIN_FRP_MW_MIN,
|
||||
maximum: MIN_FRP_MW_MAX,
|
||||
description:
|
||||
'Hide detections weaker than this fire radiative power (MW). ' +
|
||||
'A typical detection is ~6 MW; 20 MW keeps the top tenth, 100 MW the top percent.',
|
||||
},
|
||||
confidence: {
|
||||
type: 'string',
|
||||
enum: [...FIRMS_CONFIDENCE_MODES],
|
||||
description:
|
||||
'"all" (default), "nominal" (nominal and high), or "high". Many of the ' +
|
||||
'strongest fires are rated low confidence, so prefer "all" unless asked.',
|
||||
},
|
||||
limit: LIMIT_SCHEMA,
|
||||
},
|
||||
required: ['area'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
@@ -103,9 +131,31 @@ export const getActiveFires = defineTool({
|
||||
'unavailable',
|
||||
'Fire detections need a NASA FIRMS key configured for this server',
|
||||
);
|
||||
const filter = normalizeFireFilter({
|
||||
minFrp: args.min_frp_mw,
|
||||
confidence: args.confidence,
|
||||
});
|
||||
const fires = snapshot.fires
|
||||
.filter((fire) => areaContains(area, fire))
|
||||
.filter(
|
||||
(fire) =>
|
||||
areaContains(area, fire) &&
|
||||
firePassesFilter(
|
||||
{
|
||||
frp: Number(fire.frp),
|
||||
confidence: normalizeConfidence(fire.confidence),
|
||||
},
|
||||
filter,
|
||||
),
|
||||
)
|
||||
.sort((a, b) => (b.frp ?? 0) - (a.frp ?? 0));
|
||||
const filterNotes = [
|
||||
...(filter.minFrp > 0 ? [`≥ ${filter.minFrp} MW`] : []),
|
||||
...(filter.confidence !== 'all'
|
||||
? [
|
||||
`${filter.confidence === 'high' ? 'high' : 'nominal or high'} confidence`,
|
||||
]
|
||||
: []),
|
||||
];
|
||||
const result = capRows(fires, args.limit);
|
||||
// The proxy serves its last good snapshot when a refresh fails, and marks
|
||||
// satellites whose download failed.
|
||||
@@ -119,10 +169,16 @@ export const getActiveFires = defineTool({
|
||||
];
|
||||
return {
|
||||
summary:
|
||||
`${countNoun(fires.length, 'fire detection')} in ${area.label} in the last 24 hours` +
|
||||
`${countNoun(fires.length, 'fire detection')}` +
|
||||
(filterNotes.length ? ` (${filterNotes.join(', ')})` : '') +
|
||||
` in ${area.label} in the last 24 hours` +
|
||||
(notes.length ? ` (${notes.join('; ')}).` : '.'),
|
||||
data: {
|
||||
view: suggestView(services, { area, layers: ['local-firms'] }),
|
||||
filter: {
|
||||
min_frp_mw: filter.minFrp,
|
||||
confidence: filter.confidence,
|
||||
},
|
||||
stale,
|
||||
missing_sources: missing,
|
||||
fetched_at: isoTime(snapshot.fetchedAt),
|
||||
|
||||
@@ -44,3 +44,39 @@ test('fire acquisition timestamps preserve valid leap days and UTC times', async
|
||||
assert.equal(result.data.rows[0].acquired, expected);
|
||||
}
|
||||
});
|
||||
|
||||
const WEEK = [
|
||||
{ lat: 24.1, lon: 121.1, frp: 864, confidence: 'l', acqDate: '2026-10-01', acqTime: '0100' },
|
||||
{ lat: 24.2, lon: 121.2, frp: 120, confidence: 'h', acqDate: '2026-10-01', acqTime: '0100' },
|
||||
{ lat: 24.3, lon: 121.3, frp: 20, confidence: 'n', acqDate: '2026-10-01', acqTime: '0100' },
|
||||
{ lat: 24.4, lon: 121.4, frp: 2, confidence: 'h', acqDate: '2026-10-01', acqTime: '0100' },
|
||||
{ lat: 30, lon: 100, frp: 500, confidence: 'h', acqDate: '2026-10-01', acqTime: '0100' },
|
||||
];
|
||||
const TAIWAN = { area: { bbox: [120, 23, 122, 25] } };
|
||||
|
||||
test('get_active_fires shows every detection by default and reports the filter it used', async () => {
|
||||
const result = await catalogWith(WEEK).call('get_active_fires', TAIWAN);
|
||||
assert.deepEqual(result.data.rows.map((row) => row.frp_mw), [864, 120, 20, 2]);
|
||||
assert.deepEqual(result.data.filter, { min_frp_mw: 0, confidence: 'all' });
|
||||
assert.match(result.summary, /^4 fire detections in /);
|
||||
});
|
||||
|
||||
test('get_active_fires applies the same minimum-FRP and confidence filter as the layer', async () => {
|
||||
const catalog = catalogWith(WEEK);
|
||||
const strong = await catalog.call('get_active_fires', { ...TAIWAN, min_frp_mw: 20 });
|
||||
assert.deepEqual(strong.data.rows.map((row) => row.frp_mw), [864, 120, 20]);
|
||||
assert.match(strong.summary, /^3 fire detections \(≥ 20 MW\) in /);
|
||||
|
||||
const nominal = await catalog.call('get_active_fires', { ...TAIWAN, confidence: 'nominal' });
|
||||
assert.deepEqual(nominal.data.rows.map((row) => row.frp_mw), [120, 20, 2], 'the 864 MW low-confidence fire drops');
|
||||
assert.match(nominal.summary, /\(nominal or high confidence\)/);
|
||||
|
||||
const both = await catalog.call('get_active_fires', {
|
||||
...TAIWAN,
|
||||
min_frp_mw: 19.6,
|
||||
confidence: 'high',
|
||||
});
|
||||
assert.deepEqual(both.data.rows.map((row) => row.frp_mw), [120]);
|
||||
assert.deepEqual(both.data.filter, { min_frp_mw: 20, confidence: 'high' });
|
||||
assert.match(both.summary, /\(≥ 20 MW, high confidence\)/);
|
||||
});
|
||||
@@ -117,3 +117,18 @@ test('a radio group moves from the focused option, an arrows group from the sele
|
||||
assert.deepEqual(arrows.emitted('update:modelValue'), [['a']]);
|
||||
arrows.unmount();
|
||||
});
|
||||
|
||||
test("an option's own tip wins; an icon option without one is titled by its label", () => {
|
||||
const wrapper = mountGroup({
|
||||
options: [
|
||||
{ value: 'a', label: 'Alpha', attrs: { title: 'Alpha tip' } },
|
||||
{ value: 'b', label: 'Beta', icon: 'star' },
|
||||
{ value: 'c', label: 'Gamma' },
|
||||
],
|
||||
});
|
||||
const titles = wrapper
|
||||
.findAll('button')
|
||||
.map((b) => b.attributes('title') ?? null);
|
||||
assert.deepEqual(titles, ['Alpha tip', 'Beta', null]);
|
||||
wrapper.unmount();
|
||||
});
|
||||
@@ -8,7 +8,8 @@ import { computed } from 'vue';
|
||||
// End). Each option may carry `attrs` (an id, data attributes, a tip), and
|
||||
// Core's classes sit beside the kit's: `gev-segmented`, `gev-segment` and the
|
||||
// selected option's `activeClass`. An option with an `icon` shows the glyph,
|
||||
// and its label becomes the button's accessible name and tooltip.
|
||||
// and its label becomes the button's accessible name and tooltip, unless its
|
||||
// `attrs` carry a tip of their own.
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: undefined },
|
||||
/** `{value, label, icon?, disabled?, attrs?}`, labels already translated. */
|
||||
@@ -119,7 +120,7 @@ function onKey(event) {
|
||||
]"
|
||||
:aria-checked="String(option.value === props.modelValue)"
|
||||
:aria-label="option.icon ? option.label : undefined"
|
||||
:title="option.icon ? option.label : undefined"
|
||||
:title="option.attrs?.title ?? (option.icon ? option.label : undefined)"
|
||||
:disabled="optionOff(option)"
|
||||
:tabindex="tabindex(index)"
|
||||
@click="choose(index)"
|
||||
|
||||
@@ -141,6 +141,12 @@ export class LayerBindings {
|
||||
});
|
||||
this._weatherShellModules.push(layer);
|
||||
}
|
||||
// Active Fires drapes its far-zoom heatmap on the same imagery host.
|
||||
const fires = this._dataManager?.layers?.get('local-firms')?.module;
|
||||
if (typeof fires?.attachShellServices === 'function') {
|
||||
fires.attachShellServices({ imageryHost: this.services.imageryHost });
|
||||
this._weatherShellModules.push(fires);
|
||||
}
|
||||
}
|
||||
|
||||
_persistAwarenessSelection(event, cleared = false) {
|
||||
|
||||
+35
-5
@@ -442,6 +442,8 @@ export class LayerPanel {
|
||||
this._selectChip(layerId, chipId, generation),
|
||||
onListItem: (layerId, itemId) =>
|
||||
this._selectListItem(layerId, itemId, generation),
|
||||
onSlider: (layerId, sliderId, value) =>
|
||||
this._moveSlider(layerId, sliderId, value, generation),
|
||||
}),
|
||||
id === null ? this._toggleContainer : this._groupContainers.get(id),
|
||||
);
|
||||
@@ -471,7 +473,8 @@ export class LayerPanel {
|
||||
}
|
||||
|
||||
/**
|
||||
* A layer's row chips, color legend, info line and ordered list. The block
|
||||
* A layer's row chips, one-of-many switch, slider, color legend, info line
|
||||
* and ordered list. The block
|
||||
* is hidden while the layer is off (or while a dependency owner has
|
||||
* surrendered it), so a quiet row stays quiet. Chips and list items are
|
||||
* keyed, so the refresh a chip's own click triggers keeps keyboard focus.
|
||||
@@ -495,11 +498,20 @@ export class LayerPanel {
|
||||
: `${item.label} ${formatCount(item.count)}`,
|
||||
}));
|
||||
const infoText = controls?.info ? String(controls.info) : '';
|
||||
const segmented = controls?.segmented || null;
|
||||
const slider = controls?.slider || null;
|
||||
// The info line, once shown, stays in place and hides when empty.
|
||||
if (infoText) row.info = true;
|
||||
return {
|
||||
hidden: chips.length === 0 && legend.length === 0 && !infoText,
|
||||
hidden:
|
||||
chips.length === 0 &&
|
||||
legend.length === 0 &&
|
||||
!infoText &&
|
||||
!segmented &&
|
||||
!slider,
|
||||
chips,
|
||||
segmented,
|
||||
slider,
|
||||
legend,
|
||||
info: row.info
|
||||
? { text: infoText, title: controls?.infoTitle || '' }
|
||||
@@ -537,15 +549,33 @@ export class LayerPanel {
|
||||
// listeners, so a click from a released generation is dropped.
|
||||
_selectChip(id, chipId, generation = this._generation) {
|
||||
if (this._destroyed || this._generation !== generation) return;
|
||||
const chip = this._rowControlsFor(id)?.chips?.find(
|
||||
(entry) => entry.id === chipId,
|
||||
);
|
||||
// A switch option carries the same fields as a chip.
|
||||
const live = this._rowControlsFor(id);
|
||||
const chip = [
|
||||
...(live?.chips || []),
|
||||
...(live?.segmented?.options || []),
|
||||
].find((entry) => entry.id === chipId);
|
||||
if (!chip || chip.disabled || !this.isEnabled(id)) return;
|
||||
if (typeof chip.onClick === 'function') chip.onClick();
|
||||
else if (chip.params)
|
||||
this.setLayerParams(id, chip.params, { origin: 'user' });
|
||||
}
|
||||
|
||||
// A row slider (e.g. the Active Fires minimum-FRP filter) maps its position
|
||||
// to params through the live descriptor on every tick.
|
||||
_moveSlider(id, sliderId, value, generation = this._generation) {
|
||||
if (this._destroyed || this._generation !== generation) return;
|
||||
const slider = this._rowControlsFor(id)?.slider;
|
||||
if (
|
||||
!slider ||
|
||||
slider.id !== sliderId ||
|
||||
typeof slider.params !== 'function' ||
|
||||
!this.isEnabled(id)
|
||||
)
|
||||
return;
|
||||
this.setLayerParams(id, slider.params(value), { origin: 'user' });
|
||||
}
|
||||
|
||||
_selectListItem(id, itemId, generation = this._generation) {
|
||||
if (this._destroyed || this._generation !== generation) return;
|
||||
const item = this._rowControlsFor(id)?.list?.items?.find(
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup>
|
||||
import RailChips from '../rail/RailChips.vue';
|
||||
import RailRowList from '../rail/RailRowList.vue';
|
||||
import RowSlider from './RowSlider.vue';
|
||||
import RowSwitch from './RowSwitch.vue';
|
||||
import RowText from './RowText.vue';
|
||||
import { vClassToggle } from '../rail/classToggle.js';
|
||||
|
||||
@@ -12,6 +14,7 @@ defineProps({
|
||||
onToggle: { type: Function, default: () => {} },
|
||||
onChip: { type: Function, default: () => {} },
|
||||
onListItem: { type: Function, default: () => {} },
|
||||
onSlider: { type: Function, default: () => {} },
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -55,6 +58,16 @@ defineProps({
|
||||
><RailChips
|
||||
:chips="item.controls.chips"
|
||||
:on-select="(chip) => onChip(item.id, chip.id)"
|
||||
/><RowSwitch
|
||||
v-if="item.controls.segmented"
|
||||
:segmented="item.controls.segmented"
|
||||
:on-select="(optionId) => onChip(item.id, optionId)"
|
||||
/><RowSlider
|
||||
v-if="item.controls.slider"
|
||||
:slider="item.controls.slider"
|
||||
:on-input="
|
||||
(value) => onSlider(item.id, item.controls.slider.id, value)
|
||||
"
|
||||
/><span
|
||||
v-for="(entry, index) in item.controls.legend"
|
||||
:key="index"
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<script setup>
|
||||
import UiSlider from '../kit/UiSlider.js';
|
||||
|
||||
// A row's quantitative slider (e.g. the Active Fires minimum-FRP filter).
|
||||
// `track` is a CSS background painting the runnable track, so a color ramp
|
||||
// doubles as the legend; each tick sits at `at` (0..1) along it. The input
|
||||
// holds its position while focused, so a refresh from its own tick never
|
||||
// moves the thumb under the pointer.
|
||||
defineProps({
|
||||
slider: { type: Object, required: true },
|
||||
onInput: { type: Function, default: () => {} },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="data-toggle-slider" :data-slider-id="slider.id"
|
||||
><div class="data-toggle-slider-head"
|
||||
><span class="data-toggle-slider-label">{{ slider.label }}</span
|
||||
><span class="data-toggle-slider-value gev-slider-value">{{
|
||||
slider.valueText
|
||||
}}</span></div
|
||||
><UiSlider
|
||||
class="data-toggle-slider-input"
|
||||
:data-slider-id="slider.id"
|
||||
:min="slider.min"
|
||||
:max="slider.max"
|
||||
:step="slider.step ?? 1"
|
||||
:model-value="slider.value"
|
||||
hold
|
||||
:aria-label="slider.ariaLabel || undefined"
|
||||
:aria-valuetext="slider.valueText || undefined"
|
||||
:style="slider.track ? { '--slider-track': slider.track } : undefined"
|
||||
@update:model-value="onInput"
|
||||
/><div class="data-toggle-slider-ticks"
|
||||
><span
|
||||
v-for="tick in slider.ticks || []"
|
||||
:key="tick.at"
|
||||
class="data-toggle-slider-tick"
|
||||
:style="{ '--at': `${(tick.at * 100).toFixed(2)}%` }"
|
||||
>{{ tick.label }}</span
|
||||
></div
|
||||
></div
|
||||
>
|
||||
</template>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup>
|
||||
import UiSegmented from '../kit/UiSegmented.vue';
|
||||
|
||||
// A row's one-of-many switch (e.g. Active Fires confidence). Options carry
|
||||
// the same fields as chips, so the owner resolves a choice the way it
|
||||
// resolves a chip; `label` is an optional caption beside the group.
|
||||
defineProps({
|
||||
segmented: { type: Object, required: true },
|
||||
onSelect: { type: Function, default: () => {} },
|
||||
});
|
||||
|
||||
const options = (segmented) =>
|
||||
(segmented.options || []).map((option) => ({
|
||||
value: option.id,
|
||||
label: option.label,
|
||||
disabled: Boolean(option.disabled),
|
||||
attrs: { 'data-chip-id': option.id, title: option.title || undefined },
|
||||
}));
|
||||
const activeId = (segmented) =>
|
||||
(segmented.options || []).find((option) => option.active)?.id;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
v-if="segmented.label"
|
||||
class="data-toggle-segmented-label"
|
||||
:data-segmented-caption-for="segmented.id"
|
||||
>{{ segmented.label }}</span
|
||||
><UiSegmented
|
||||
class="data-toggle-segmented"
|
||||
:data-segmented-id="segmented.id"
|
||||
:model-value="activeId(segmented)"
|
||||
:options="options(segmented)"
|
||||
:label="segmented.ariaLabel || segmented.label || ''"
|
||||
item-class="data-toggle-segment"
|
||||
@update:model-value="onSelect"
|
||||
/>
|
||||
</template>
|
||||
@@ -39,8 +39,123 @@ test('the count-line class toggles without dropping the voice highlight', () =>
|
||||
assert.equal(element.classList.contains('gev-voice-focus'), true);
|
||||
|
||||
render(h(LayerRows, { items: [row(false)] }), container);
|
||||
assert.equal(element.classList.contains('data-toggle-row--count-line'), false);
|
||||
assert.equal(
|
||||
element.classList.contains('data-toggle-row--count-line'),
|
||||
false,
|
||||
);
|
||||
assert.equal(element.classList.contains('gev-voice-focus'), true);
|
||||
assert.equal(element.classList.contains('data-toggle-row'), true);
|
||||
render(null, container);
|
||||
});
|
||||
|
||||
const fireRow = (value, active = 'all') => ({
|
||||
...row(false),
|
||||
key: 'fires',
|
||||
id: 'fires',
|
||||
controls: {
|
||||
hidden: false,
|
||||
chips: [],
|
||||
segmented: {
|
||||
id: 'confidence',
|
||||
label: 'Confidence',
|
||||
ariaLabel: 'Detection confidence',
|
||||
options: ['all', 'high'].map((mode) => ({
|
||||
id: `confidence-${mode}`,
|
||||
label: mode.toUpperCase(),
|
||||
title: `${mode} title`,
|
||||
active: mode === active,
|
||||
})),
|
||||
},
|
||||
slider: {
|
||||
id: 'min-frp',
|
||||
label: 'Fire intensity (FRP)',
|
||||
ariaLabel: 'Minimum fire radiative power, megawatts',
|
||||
valueText: value ? `≥ ${value} MW` : 'All',
|
||||
min: 0,
|
||||
max: 1000,
|
||||
step: 1,
|
||||
value,
|
||||
track: 'linear-gradient(90deg, #9e2a1b, #fff3b0)',
|
||||
ticks: [
|
||||
{ at: 0, label: '0' },
|
||||
{ at: 0.5, label: '30' },
|
||||
],
|
||||
},
|
||||
legend: [],
|
||||
info: null,
|
||||
list: null,
|
||||
},
|
||||
});
|
||||
|
||||
test('a row switch and slider render their markup and report choices', () => {
|
||||
document.body.replaceChildren();
|
||||
const container = document.body.appendChild(document.createElement('div'));
|
||||
const chips = [];
|
||||
const moves = [];
|
||||
const props = {
|
||||
onChip: (id, chipId) => chips.push([id, chipId]),
|
||||
onSlider: (id, sliderId, value) => moves.push([id, sliderId, value]),
|
||||
};
|
||||
render(h(LayerRows, { items: [fireRow(0)], ...props }), container);
|
||||
|
||||
const caption = container.querySelector('.data-toggle-segmented-label');
|
||||
assert.equal(caption.textContent, 'Confidence');
|
||||
const group = container.querySelector('.gev-segmented.data-toggle-segmented');
|
||||
assert.equal(group.getAttribute('role'), 'radiogroup');
|
||||
assert.equal(group.getAttribute('aria-label'), 'Detection confidence');
|
||||
const segments = [
|
||||
...group.querySelectorAll('.gev-segment.data-toggle-segment'),
|
||||
];
|
||||
assert.deepEqual(
|
||||
segments.map((button) => button.dataset.chipId),
|
||||
['confidence-all', 'confidence-high'],
|
||||
);
|
||||
assert.equal(segments[0].classList.contains('active'), true);
|
||||
assert.equal(segments[1].title, 'high title');
|
||||
segments[1].click();
|
||||
assert.deepEqual(chips, [['fires', 'confidence-high']]);
|
||||
|
||||
const block = container.querySelector('.data-toggle-slider');
|
||||
assert.equal(block.dataset.sliderId, 'min-frp');
|
||||
assert.equal(
|
||||
block.querySelector('.data-toggle-slider-label').textContent,
|
||||
'Fire intensity (FRP)',
|
||||
);
|
||||
assert.equal(
|
||||
block.querySelector('.data-toggle-slider-value').textContent,
|
||||
'All',
|
||||
);
|
||||
const input = block.querySelector('input.data-toggle-slider-input');
|
||||
assert.equal(input.classList.contains('gev-quantitative-slider'), true);
|
||||
assert.equal(input.type, 'range');
|
||||
assert.equal(input.max, '1000');
|
||||
assert.equal(
|
||||
input.getAttribute('aria-label'),
|
||||
'Minimum fire radiative power, megawatts',
|
||||
);
|
||||
assert.equal(input.getAttribute('aria-valuetext'), 'All');
|
||||
assert.match(
|
||||
input.style.getPropertyValue('--slider-track'),
|
||||
/^linear-gradient/,
|
||||
);
|
||||
const ticks = [...block.querySelectorAll('.data-toggle-slider-tick')];
|
||||
assert.deepEqual(
|
||||
ticks.map((tick) => tick.textContent),
|
||||
['0', '30'],
|
||||
);
|
||||
assert.equal(ticks[1].style.getPropertyValue('--at'), '50.00%');
|
||||
|
||||
input.value = '120';
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
assert.deepEqual(moves, [['fires', 'min-frp', 120]]);
|
||||
|
||||
// The refresh that tick triggers keeps the same nodes and updates the text.
|
||||
render(h(LayerRows, { items: [fireRow(120, 'high')], ...props }), container);
|
||||
assert.equal(
|
||||
container.querySelector('input.data-toggle-slider-input'),
|
||||
input,
|
||||
);
|
||||
assert.equal(input.getAttribute('aria-valuetext'), '≥ 120 MW');
|
||||
assert.equal(segments[1].classList.contains('active'), true);
|
||||
render(null, container);
|
||||
});
|
||||
@@ -1042,6 +1042,118 @@ html:not([lang='en']) .data-row-list-text {
|
||||
padding-top: 3px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* Optional per-layer one-of-many switch (Active Fires confidence). The shared
|
||||
.gev-segmented surface, sized to the chip row. */
|
||||
.data-toggle-controls .data-toggle-segmented {
|
||||
flex-basis: 100%;
|
||||
max-width: 220px;
|
||||
}
|
||||
|
||||
.data-toggle-segmented-label {
|
||||
flex-basis: 100%;
|
||||
padding-top: 2px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 8px;
|
||||
letter-spacing: 1.2px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.data-toggle-controls .data-toggle-segment {
|
||||
min-height: 20px;
|
||||
padding: 3px 6px;
|
||||
font-size: 8px;
|
||||
letter-spacing: 0.8px;
|
||||
}
|
||||
|
||||
/* Optional per-layer quantitative slider (Active Fires minimum FRP). The
|
||||
track is painted by the layer (`--slider-track`, a stepped color ramp), so
|
||||
the slider doubles as the marker legend; ticks sit under their thresholds. */
|
||||
.data-toggle-slider {
|
||||
flex-basis: 100%;
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.data-toggle-slider-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 8px;
|
||||
letter-spacing: 1.2px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.data-toggle-slider-value {
|
||||
min-width: 0;
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.data-toggle-slider-input {
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.data-toggle-slider-input::-webkit-slider-runnable-track {
|
||||
height: 5px;
|
||||
border-radius: 3px;
|
||||
background: var(--slider-track, rgba(255, 255, 255, 0.08));
|
||||
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.data-toggle-slider-input::-moz-range-track,
|
||||
.data-toggle-slider-input::-moz-range-progress {
|
||||
height: 5px;
|
||||
border-radius: 3px;
|
||||
background: var(--slider-track, rgba(255, 255, 255, 0.08));
|
||||
}
|
||||
|
||||
.data-toggle-slider-input::-webkit-slider-thumb {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
margin-top: -3px;
|
||||
background: var(--text-primary);
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(0, 0, 0, 0.6),
|
||||
0 0 6px rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
.data-toggle-slider-input::-moz-range-thumb {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
background: var(--text-primary);
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(0, 0, 0, 0.6),
|
||||
0 0 6px rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
.data-toggle-slider-ticks {
|
||||
position: relative;
|
||||
height: 10px;
|
||||
margin: 0 5px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 7px;
|
||||
letter-spacing: 0.4px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.data-toggle-slider-tick {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: var(--at);
|
||||
transform: translateX(-50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.data-toggle-slider-tick:first-child {
|
||||
transform: none;
|
||||
}
|
||||
/* ── Local RTL-SDR card (FM + Local ADS-B receiver) ── */
|
||||
.sdr-radio-card {
|
||||
display: grid;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { t } from '../i18n/index.js';
|
||||
import { msg, t } from '../i18n/index.js';
|
||||
|
||||
/**
|
||||
* Chat suggestions and the one chat preference (quick commands), written to
|
||||
@@ -17,11 +17,15 @@ export function bindChatPreferences({ ui, getDraft, setDraft }) {
|
||||
};
|
||||
const quickCommands = () =>
|
||||
[
|
||||
'Fly to Tokyo',
|
||||
'Show live flights',
|
||||
'Reset to globe view',
|
||||
'Show earthquakes',
|
||||
msg('Fly to Tokyo'),
|
||||
msg('Show live flights'),
|
||||
msg('Reset to globe view'),
|
||||
msg('Show earthquakes'),
|
||||
].map((text) => t(text));
|
||||
// Arabic short vowels and shadda are optional in writing: typing "تتبع"
|
||||
// still finds "تتبّع".
|
||||
const fold = (text) =>
|
||||
text.toLowerCase().replace(/[\u064B-\u065F\u0670]/g, '');
|
||||
try {
|
||||
const stored = JSON.parse(localStorage.getItem(key));
|
||||
if (typeof stored?.quick === 'boolean') quick = stored.quick;
|
||||
@@ -31,18 +35,14 @@ export function bindChatPreferences({ ui, getDraft, setDraft }) {
|
||||
function render() {
|
||||
const draft = getDraft();
|
||||
if (historyIndex >= 0 && draft !== recent[historyIndex]) resetHistory();
|
||||
const query = draft.trim().toLowerCase();
|
||||
const query = fold(draft.trim());
|
||||
const groups = [];
|
||||
if (query) {
|
||||
const candidates = [...recent, ...(quick ? quickCommands() : [])];
|
||||
groups.push({
|
||||
label: t('Suggestions'),
|
||||
items: [...new Set(candidates)]
|
||||
.filter(
|
||||
(text) =>
|
||||
text.toLowerCase().includes(query) &&
|
||||
text.toLowerCase() !== query,
|
||||
)
|
||||
.filter((text) => fold(text).includes(query) && fold(text) !== query)
|
||||
.slice(0, 4),
|
||||
});
|
||||
} else {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { setLocale } from '../i18n/index.js';
|
||||
import { bindChatPreferences } from './chatPreferences.js';
|
||||
import { createTestVoiceUi } from '../testSupport/voiceUi.mjs';
|
||||
|
||||
@@ -99,3 +100,21 @@ test('editing a recalled message ends recall, and sending resets it', (t) => {
|
||||
press('ArrowUp');
|
||||
assert.equal(field.value, 'Show live flights now');
|
||||
});
|
||||
|
||||
test('autocomplete ignores Arabic short vowels and shadda', (t) => {
|
||||
setLocale('ar');
|
||||
t.after(() => setLocale('en'));
|
||||
const ui = createTestVoiceUi();
|
||||
let draft = '';
|
||||
const prefs = bindChatPreferences({ ui, getDraft: () => draft });
|
||||
t.after(() => prefs.destroy());
|
||||
const suggested = (text) => {
|
||||
draft = text;
|
||||
prefs.render();
|
||||
return ui.state.chat.suggestions.flatMap((group) => group.items);
|
||||
};
|
||||
// The quick command is written "تتبّع", with a shadda.
|
||||
assert.deepEqual(suggested('تتبع'), ['اعرض تتبّع الرحلات المباشر']);
|
||||
prefs.remember('انتقل إلى طوكيو');
|
||||
assert.deepEqual(suggested('طُوكيو'), ['انتقل إلى طوكيو']);
|
||||
});
|
||||
Reference in new issue
Block a user