mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 19:34:01 +00:00
Compare commits
31
Commits
No files matched your search
@@ -485,9 +485,9 @@ _The Space Missions layer replaying a Falcon 9 ascent — labeled `RECONSTRUCTED
|
||||
|
||||
> Street-level photos from any provider in one panel. This build ships **Mapillary** (a free client token), **Google Street View** (your Google Maps key), **KartaView** and **Panoramax** (no key).
|
||||
|
||||
**Setup.** At [mapillary.com/dashboard/developers](https://www.mapillary.com/dashboard/developers), register an application with **Read** access and copy its **Client Token** (`MLY|…`). Paste it into **POWER UP → Mapillary**, or set `MAPILLARY_CLIENT_TOKEN` in `.env` and restart. Without it the panel reads **KEY REQUIRED**. **Google Street View** uses `GOOGLE_MAPS_API_KEY` with the **Maps JavaScript API** enabled for it in Google Cloud; its chip starts off, since every panorama is billed to the key. **KartaView** and **Panoramax** need nothing: their public APIs are keyless.
|
||||
**Setup.** At [mapillary.com/dashboard/developers](https://www.mapillary.com/dashboard/developers), register an application with **Read** access and copy its **Client Token** (`MLY|…`). Paste it into **POWER UP → Mapillary**, or set `MAPILLARY_CLIENT_TOKEN` in `.env` and restart. Without it Street Level starts on **KartaView** instead, and the Mapillary chip says which key to add. **Google Street View** uses `GOOGLE_MAPS_API_KEY` with the **Maps JavaScript API** enabled for it in Google Cloud; its chip starts off, since every panorama is billed to the key. **KartaView** and **Panoramax** need nothing: their public APIs are keyless.
|
||||
|
||||
**Use.** Turn on **Street Level** under DATA LAYERS → Cameras, or with the pill in the STREET LEVEL panel header. Each provider has a chip in its own colour (Mapillary green), and one is lit at a time: lighting one switches the others off. Mapillary, which is free, is lit by default. Zoom in to street level (below 60 km) for the coverage lines; from orbit the panel says to zoom in. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to Mapillary, KartaView and Panoramax; Street View panoramas are all 360°, so with Street View lit the filters are hidden. Click a coverage line for its image cones and a cone to open the photo. Light **KARTAVIEW** (purple) and zoom in below about 6 km: its lines are the streets KartaView has photos of, and a click on one opens the photo nearest the click, flat or 360° (drag to look around), with cones along its drive; **‹ ›** or ← → step through the drive (Shift+← →, ↑ ↓ and + − look around a 360° photo; pinch zooms on a phone). Photos KartaView has archived are skipped, and where all are archived the panel says so. For KartaView, **SINCE** goes by upload date, as KartaView's own map does (it can filter by nothing else), and the panel notes it beside the SINCE value (ANY DATE (by upload date)); KartaView filters its lines on its own server, and a filtered area KartaView has not drawn before can take several seconds. Light **PANORAMAX** (orange) for the open, federated commons (strongest in France and Belgium): below about 60 km its lines are sequences. A click on a line opens that sequence's picture nearest the click without moving the camera, flat or 360°, with cones along the sequence around it; **‹ ›** or ← → step through it, and both filters apply. Its caption names the picture's authors (or the instance holding it) and licence (mostly CC BY-SA 4.0; pictures on the IGN instance are Licence Ouverte 2.0). On the **Google 3D** map (Google's terms keep Street View off other maps, where it does not appear), light **GOOGLE STREET VIEW** and click a street from below 3 km, or press **OPEN STREET VIEW** for the view centre, to open Google's panorama there; step along its arrows. Above the photo: **EXPAND** (full screen; Esc leaves) and **FIT / FILL** (Mapillary, and flat KartaView and Panoramax photos). The caption links to the image on the provider's site, and the globe credits each provider that is on ("© Mapillary contributors, CC BY-SA 4.0", "Street View imagery © Google", "© KartaView contributors, CC BY-SA 4.0", "© Panoramax contributors, licence per picture"). KartaView's own photo pages fail for signed-out visitors, so its link opens the full-size photo.
|
||||
**Use.** Turn on **Street Level** under DATA LAYERS → Cameras, or with the pill in the STREET LEVEL panel header. Each provider has a chip in its own colour (Mapillary green), and one is lit at a time: lighting one switches the others off. Mapillary, which is free, is lit by default when its token is set; otherwise KartaView is. Zoom in to street level (below 60 km) for the coverage lines; from orbit the panel says to zoom in. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to Mapillary, KartaView and Panoramax; Street View panoramas are all 360°, so with Street View lit the filters are hidden. Click a coverage line for its image cones and a cone to open the photo. Light **KARTAVIEW** (purple) and zoom in below about 6 km: its lines are the streets KartaView has photos of, and a click on one opens the photo nearest the click, flat or 360° (drag to look around), with cones along its drive; **‹ ›** or ← → step through the drive (Shift+← →, ↑ ↓ and + − look around a 360° photo; pinch zooms on a phone). Photos KartaView has archived are skipped, and where all are archived the panel says so. For KartaView, **SINCE** goes by upload date, as KartaView's own map does (it can filter by nothing else), and the panel notes it beside the SINCE value (ANY DATE (by upload date)); KartaView filters its lines on its own server, and a filtered area KartaView has not drawn before can take several seconds. Light **PANORAMAX** (orange) for the open, federated commons (strongest in France and Belgium): below about 60 km its lines are sequences. A click on a line opens that sequence's picture nearest the click without moving the camera, flat or 360°, with cones along the sequence around it; **‹ ›** or ← → step through it, and both filters apply. Its caption names the picture's authors (or the instance holding it) and licence (mostly CC BY-SA 4.0; pictures on the IGN instance are Licence Ouverte 2.0). On the **Google 3D** map (Google's terms keep Street View off other maps, where it does not appear), light **GOOGLE STREET VIEW** and click a street from below 3 km, or press **OPEN STREET VIEW** for the view centre, to open Google's panorama there; step along its arrows. Above the photo: **EXPAND** (full screen; Esc leaves) and **FIT / FILL** (Mapillary, and flat KartaView and Panoramax photos). The caption links to the image on the provider's site, and the globe credits each provider that is on ("© Mapillary contributors, CC BY-SA 4.0", "Street View imagery © Google", "© KartaView contributors, CC BY-SA 4.0", "© Panoramax contributors, licence per picture"). KartaView's own photo pages fail for signed-out visitors, so its link opens the full-size photo.
|
||||
|
||||
Share links carry the provider switches and filters (`0.m.0` Mapillary off, `0.m.0_0.r.google-1` Street View on, `0.m.0_0.r.google-0*kartaview-1*panoramax-0` KartaView on and `0.m.0_0.r.google-0*kartaview-0*panoramax-1` Panoramax on, as the app writes them (`0.m.0_0.r.kartaview-1` and `0.m.0_0.r.panoramax-1` work too; a link that leaves out `m` keeps Mapillary, the default, lit), `0.p.p` panoramas only, `0.s.365` the last year). To add a provider, implement the contract in `src/layers/streetLevel/registry.js` and register it in `src/app/layers/streetLevel.js`.
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
- Add an adjustable event-age filter across S2 GhostMaps products, defaulting
|
||||
to seven days with 1-, 3-, 14- and 30-day choices and an All markers option.
|
||||
Preserve undated features, save the selected window and expire old events
|
||||
while the map stays open. Thanks @dylanmaniatakes (#1528).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Space Missions no longer locks the workspace when its mission index fails to
|
||||
load (429, timeout or bad payload): entry rolls back to the previous layers
|
||||
and camera view and a toast says the index could not load (German, @gekh,
|
||||
#1578).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Fix the translations of the voice area cards, the fire confidence filter,
|
||||
the drone airspace layer and Sea Marks: one word for "landmark" per
|
||||
language, the airspace layer names the airspace again, and Traditional
|
||||
Chinese follows Simplified Chinese again (German, @gekh, #1581).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Without a Mapillary token, Street Level starts on the first keyless source
|
||||
(KartaView) instead of opening on Mapillary's `KEY REQUIRED` with nothing on
|
||||
the globe. A share link that turns a source off is respected
|
||||
(asturksever, #1601).
|
||||
@@ -250,7 +250,6 @@ style listeners. Synthetic fixtures cover privacy, acquisition, rendering,
|
||||
selection and card placement. Deterministic operation checks run in `npm test`;
|
||||
wall-clock budgets run in `npm run test:perf`. All 11 locales are supported.
|
||||
|
||||
|
||||
## Prohibited & Restricted Airspace — FAA special-use airspace
|
||||
|
||||
The Infrastructure group includes **Prohibited & Restricted Airspace** (`drone-airspace`,
|
||||
@@ -4349,7 +4348,7 @@ requests use Play; a qualified Play-shaped tool call is normalized to Select so
|
||||
its criteria cannot be silently ignored.
|
||||
|
||||
| Layer | Source | File | Proxy | Update Interval |
|
||||
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | -------------------------------------------------------- | --------------------------------------------------------------------------------- |
|
||||
| ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Live Flights ✈️ | OpenSky Network; bounded adsb.lol regional fallback | `src/data/flights.js` | `/api/flights` (OAuth + fallback) | 30s |
|
||||
| Military Flights 🎖️ | adsb.lol /v2/mil | `src/data/militaryFlights.js` | `/api/military` | 15s |
|
||||
| Live AIS Vessels 🚢 | AISStream websocket | `src/data/aisLiveVessels.js` | `/api/vessels` | 60s (+800ms visibility pass) |
|
||||
@@ -4430,8 +4429,10 @@ provider draws in one colour (`PROVIDER_COLORS` in
|
||||
sequence is GEV cyan. Share options: `m` (Mapillary), `r` (the other providers' switches by id,
|
||||
e.g. `google-1` for Street View), `p` (`a`/`p`/`f` panoramas), `s` (since, days). Mapillary, Google
|
||||
Street View, KartaView and Panoramax are registered. Without
|
||||
`MAPILLARY_CLIENT_TOKEN` the panel reads KEY REQUIRED and the controls are
|
||||
disabled. With it, coverage draws as z11–14 sequence lines below 60 km; the proxy strips the unused `image` layer from z14
|
||||
`MAPILLARY_CLIENT_TOKEN` (and no Graph proxy) Street Level starts on the first
|
||||
keyless provider (KartaView), including a restored "Mapillary on" unless the link
|
||||
switches the keyless providers off; filter-only updates never change the source.
|
||||
With it, coverage draws as z11–14 sequence lines below 60 km; the proxy strips the unused `image` layer from z14
|
||||
tiles (12 MB → ~80 KB) and shares one upstream fetch between concurrent
|
||||
requests for a tile.
|
||||
|
||||
@@ -4445,7 +4446,7 @@ the camera, nearest first, at most 9 (the halves of a view across
|
||||
the date line count a shared tile once). Lines are draped
|
||||
`GroundPolylinePrimitive`s (`ClassificationType.BOTH`), so they follow terrain
|
||||
and Google 3D tiles alike; cones and the photo marker are ground-clamped
|
||||
billboards with a horizon cull. Without `MAPILLARY_CLIENT_TOKEN` the panel
|
||||
billboards with a horizon cull. When Mapillary is lit without `MAPILLARY_CLIENT_TOKEN` the panel
|
||||
reads KEY REQUIRED, says which key to add and disables the filters; a rejected
|
||||
key reads KEY REJECTED and stops tile requests until the layer goes off; a 429
|
||||
pauses them for its Retry-After.
|
||||
@@ -5335,7 +5336,7 @@ silently demoting every later lookup for the session.
|
||||
- Context is set for reading in every theme: mode, standby and action labels are 10 px, and the panel draws no glow halo. Outside Cyber, the two modes form one segmented control, the standby guidance is plain left-aligned text rather than a dashed placeholder, and the panel's section titles share the side panels' dock-label colour. Cyber keeps its separately framed mode buttons, its red panel titles, and in layout-focus a framed, centred standby card over its radar art.
|
||||
- The expanded desktop header omits the redundant `ON` label; the active mode button carries state. Expanded Contact results also omit the duplicate `GLOBAL CONTEXT` / `CONTEXT ONLY` status row and begin with the selected subject and its 250 km scope. Global Context does not fabricate a selected-entity model preview: the provisional hand-authored aircraft wireframe was removed because it was not geometry extracted from the selected entity's actual asset.
|
||||
- Dependency ownership is reversible: disabling Global Context releases only dependencies it enabled, while user-enabled layers remain on. This also removes the Military-layer suppression handoff when Global Context owned Military, allowing an already-enabled civilian Flights layer to resume its normal mixed rendering. If OpenSky is unavailable and has no last-good cache, Flights requests a capped 250 nm adsb.lol point snapshot around the current view anchor and labels that provenance explicitly; it never relabels military-feed rows as civilian data. If both inputs fail, Flights remains `UNKNOWN`.
|
||||
- Space Missions is replay-isolated: Rocket Launches and Satellites are the only Data Layers permitted while the mode is active. Direct UI and voice entry capture the same pre-entry snapshot; internal dependency and restoration enables do not create a user-owned Context session. Entry waits for incompatible layers to shut down, direct incompatible enables are blocked before lifecycle work, and the entry gate remains active through the complete Rocket Launches enable. A newer same-target ON request takes ownership of the pending entry without releasing its isolation snapshot, including when it arrives while the prior request is awaiting the adoption guard. A caller abort, resource cancellation, newer OFF, or layer teardown waits for exact manager settlement and restores that snapshot without resurrecting Rocket Launches. If an abort lands after only part of a restore settles, Context completes the same exact target without the stale caller signal and then replays newer explicit layer intent. Dedicated Voice Context cancellation reports a stable cancelled result plus the current Context state; generic layer visibility additionally exposes manager phase, reason, successor, and lifecycle details. Once an exact voice visibility intent commits, a newer voice turn cannot relabel it as cancelled while Context settlement completes; pre-commit aborts remain cancellable and final lifecycle mismatches remain failures. Clear Selected Layers reserves its complete captured OFF set before sequential teardown; a newer absolute request of any origin supersedes only its layer reservation and remains authoritative. A rejected layer teardown retains the truthful enabled state, rolls already-stopped siblings back to the captured pre-entry set, and aborts replay; rapid exit/re-entry serializes the full Satellite enabled-state and parameter restore before a new snapshot is taken. Contacts remains additive and restores user-enabled layers normally.
|
||||
- Space Missions is replay-isolated: Rocket Launches and Satellites are the only Data Layers permitted while the mode is active. Direct UI and voice entry capture the same pre-entry snapshot; internal dependency and restoration enables do not create a user-owned Context session. Entry waits for incompatible layers to shut down, direct incompatible enables are blocked before lifecycle work, and the entry gate remains active through the complete Rocket Launches enable. That enable includes the first mission-index load: an HTTP error, timeout, network failure or malformed payload before any index has loaded fails the enable, so Context rolls back to the pre-entry layers instead of committing an empty, isolating mode; the camera returns to its pre-entry view unless navigation took it, and a toast says the mission index could not load. A valid empty index still enters, and a refresh failure after an index loaded keeps its records and the mode. Exiting cancels the layer's own camera flights but never one navigation started. A newer same-target ON request takes ownership of the pending entry without releasing its isolation snapshot, including when it arrives while the prior request is awaiting the adoption guard. A caller abort, resource cancellation, newer OFF, or layer teardown waits for exact manager settlement and restores that snapshot without resurrecting Rocket Launches. If an abort lands after only part of a restore settles, Context completes the same exact target without the stale caller signal and then replays newer explicit layer intent. Dedicated Voice Context cancellation reports a stable cancelled result plus the current Context state; generic layer visibility additionally exposes manager phase, reason, successor, and lifecycle details. Once an exact voice visibility intent commits, a newer voice turn cannot relabel it as cancelled while Context settlement completes; pre-commit aborts remain cancellable and final lifecycle mismatches remain failures. Clear Selected Layers reserves its complete captured OFF set before sequential teardown; a newer absolute request of any origin supersedes only its layer reservation and remains authoritative. A rejected layer teardown retains the truthful enabled state, rolls already-stopped siblings back to the captured pre-entry set, and aborts replay; rapid exit/re-entry serializes the full Satellite enabled-state and parameter restore before a new snapshot is taken. Contacts remains additive and restores user-enabled layers normally.
|
||||
- Enabled Data Layer controls report normalized feed health on the button (`LOADING`, `DEGRADED`, `STALE`, `FALLBACK`, or `UNAVAILABLE`) while the metadata line retains the source and reason. A partial CelesTrak group failure keeps the usable catalog and reports `DEGRADED`; a total outage keeps last-good catalog data visible but reports `UNAVAILABLE`.
|
||||
- On activation it focuses the nearest currently observed aircraft across the civilian and military feeds, with military winning an exact distance tie; if none are available, it focuses an observed AIS vessel. The aircraft search is deliberately uncapped and one refresh-tick retry handles initially empty feeds. This is an attention-priority navigation shortcut, not a high-risk, affiliation, or threat classification.
|
||||
- A selected aircraft, AIS vessel, or mapped installation gets a 250 km **context window** with nearby cohort counts, nearest examples, source labels, and stale/unavailable reasons. It emits `NEARBY` or `UNKNOWN`; no detection, engagement, affiliation, or sensor-activity conclusion is calculated.
|
||||
@@ -6014,6 +6015,7 @@ request viewbox and continues searching without that geographic preference.
|
||||
|
||||
- **What it shows.** Events ▸ **S2 GhostMaps** draws the current edition of S2 Underground's KMZ intel products from `github.com/s2underground/GhostMaps`. The products are the Common Intelligence Picture and the 30-day kinetic pull (both on by default), the Border Crisis Map, the Iran Common Intelligence Picture, Iran Kinetic Events, Yemen FLOT, and **Latest Drop**: the newest dated incident file outside those series, `0_Archive`, `Basedata` and `Reference Material`. Each product has a chip in the row. The chip choice is kept in `localStorage` (`gev.ghostmaps.products`) and falls back to the defaults when storage is blocked.
|
||||
- **Editions.** `src/layers/ghostMaps/catalog.js` (pure) matches whole repository paths per product. The newest edition wins by the date written in the file name, then by `Export_N`/`vN`. Undated names, such as the fixed 30-day file, are identified by blob sha. Files over 50 MiB are left out (the 62 MB census KMZ, for example). A renamed folder drops the product rather than matching elsewhere.
|
||||
- **Event age filter.** One **Event age** switch in the S2 GhostMaps row applies to every selected product: 1, 3, 7 (default), 14 or 30 days, or **All** (every marker). The choice is saved separately from product selection in `localStorage` (`gev.ghostmaps.ageDays`); missing, invalid or blocked storage falls back to seven days. Age comes only from the placemark's `Date of Event` (or `EventDate`/`Date`), never from report, prose or KMZ edition dates. Undated, unparseable and future dates stay visible. ArcGIS `M/D/YYYY` dates with optional AM/PM times and ISO dates are read as UTC when no zone is given; a date without a time covers its whole UTC day, so **1d** still shows yesterday's reports. Events age out every minute while the layer is on. Narrowing the window only removes markers; widening restores them from the cached KMZ. An open card stays while its marker is shown.
|
||||
- **Updates.** The repository is not ours, so there is no webhook. `server/providers/ghostMaps.js` starts with the first `/api/ghostmaps` request, so an install that never enables the layer never contacts GitHub.
|
||||
- It reads the branch head from git's smart-HTTP ref advertisement (`/info/refs?service=git-upload-pack`, about 0.5 KB, outside the REST rate limit) every `GHOSTMAPS_POLL_SECONDS` (15 s by default, 5 s minimum). Failures back off from 15 s to 5 min.
|
||||
- Only a new head costs REST calls: one recursive tree and one commit. An optional `GITHUB_TOKEN` raises the REST limit.
|
||||
|
||||
@@ -475,6 +475,7 @@
|
||||
"src/layers/satellites/stale.test.mjs",
|
||||
"src/layers/launches/source.test.mjs",
|
||||
"src/layers/launches/ingestion.test.mjs",
|
||||
"src/layers/launches/failedEntry.test.mjs",
|
||||
"src/layers/installations/lifecycle.js",
|
||||
"src/layers/installations/viewport.js",
|
||||
"src/layers/installations/controls.js",
|
||||
|
||||
@@ -28,6 +28,8 @@
|
||||
* (vii) FAR SIDE — pins skip the depth test, so they are horizon-culled:
|
||||
* seen from over the Indian Ocean no pin clearly behind the planet is
|
||||
* shown; back over North America the near-side pins show again.
|
||||
* (viii) AGE — seven days is the default; shorter/longer windows and All
|
||||
* markers update the globe, and the age choice survives a reload.
|
||||
*
|
||||
* Screenshots go to qa-shots/ghostmaps-*.png (gitignored).
|
||||
*
|
||||
@@ -224,6 +226,77 @@ async function main() {
|
||||
record('LIVE: the /api/ghostmaps/events stream is open', stream);
|
||||
await page.screenshot({ path: path.join(SHOTS_DIR, 'ghostmaps-live.png') });
|
||||
|
||||
const ageChips = await page.evaluate(
|
||||
(id) =>
|
||||
[
|
||||
...document.querySelectorAll(
|
||||
`[data-layer-id="${id}"] [data-segmented-id="age"] [role="radio"]`,
|
||||
),
|
||||
].map((option) => ({
|
||||
id: option.dataset.chipId,
|
||||
active: option.getAttribute('aria-checked') === 'true',
|
||||
})),
|
||||
LAYER_ID,
|
||||
);
|
||||
record(
|
||||
'AGE: seven days is the default with an All markers option',
|
||||
live.ageDays === 7 &&
|
||||
ageChips.length === 6 &&
|
||||
ageChips
|
||||
.filter((chip) => chip.active)
|
||||
.map((chip) => chip.id)
|
||||
.join() === 'age-7' &&
|
||||
ageChips.some((chip) => chip.id === 'age-all'),
|
||||
);
|
||||
const selectAge = async (days) => {
|
||||
await showRow(page);
|
||||
const selector = `[data-layer-id="${LAYER_ID}"] [data-chip-id="age-${days ?? 'all'}"]`;
|
||||
await page.waitForSelector(selector, { timeout: 15000 });
|
||||
// Dispatch through the rendered button, matching the product-chip gate.
|
||||
await page.$eval(selector, (button) => button.click());
|
||||
await page.waitForFunction(
|
||||
(id, age) => {
|
||||
const s = window.__godsEyeView.dataManager.layers
|
||||
.get(id)
|
||||
.module.getStats();
|
||||
return s.ageDays === age && !s.loading;
|
||||
},
|
||||
{ timeout: TIMEOUT_MS },
|
||||
LAYER_ID,
|
||||
days,
|
||||
);
|
||||
return stats(page);
|
||||
};
|
||||
const all = await selectAge(null);
|
||||
record(
|
||||
'AGE: All markers restores old events without duplicate data sources',
|
||||
all.count > live.count &&
|
||||
(await sources(page)).length === live.products.length,
|
||||
`${live.count} → ${all.count}`,
|
||||
);
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => window.__godsEyeView?.dataManager, {
|
||||
timeout: TIMEOUT_MS,
|
||||
});
|
||||
await enableAndSettle(page);
|
||||
const persistedAge = await stats(page);
|
||||
record(
|
||||
'AGE: All markers survives a reload',
|
||||
persistedAge.ageDays === null && persistedAge.count === all.count,
|
||||
);
|
||||
const thirty = await selectAge(30);
|
||||
const one = await selectAge(1);
|
||||
const seven = await selectAge(7);
|
||||
record(
|
||||
'AGE: counts follow the window order and seven days restores the default',
|
||||
one.count <= live.count &&
|
||||
thirty.count >= live.count &&
|
||||
thirty.count <= all.count &&
|
||||
seven.count === live.count &&
|
||||
seven.ageDays === 7,
|
||||
`1d=${one.count}, 7d=${seven.count}, 30d=${thirty.count}, all=${all.count}`,
|
||||
);
|
||||
|
||||
// ---- (ii) PICK ----------------------------------------------------------
|
||||
const target = await page.evaluate((id) => {
|
||||
const gev = window.__godsEyeView;
|
||||
|
||||
@@ -296,6 +296,22 @@ async function expandPanel(page) {
|
||||
);
|
||||
}
|
||||
|
||||
/** A keyless bundle opens on the first keyless source with live controls. */
|
||||
async function assertKeylessDefault(page) {
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.setEnabled('street-level', true, {
|
||||
origin: 'user',
|
||||
}),
|
||||
);
|
||||
await uiUntil(page, litAlone, 'kartaview');
|
||||
const shown = await page.evaluate(() => ({
|
||||
status: document.getElementById('sl-status').textContent,
|
||||
disabled: document.getElementById('sl-controls').disabled,
|
||||
}));
|
||||
assert.notEqual(shown.status, 'KEY REQUIRED');
|
||||
assert.equal(shown.disabled, false, 'controls stay live');
|
||||
}
|
||||
|
||||
async function assertKeyRequired(page) {
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.setEnabled('street-level', true, {
|
||||
@@ -528,11 +544,25 @@ async function main() {
|
||||
);
|
||||
|
||||
await expandPanel(page);
|
||||
if (!configured) {
|
||||
if (!configured && litAlone(await getUI(page), 'mapillary')) {
|
||||
// A keyed bundle behind a server without the token keeps Mapillary.
|
||||
await step('keyless server: KEY REQUIRED gates the controls', () =>
|
||||
assertKeyRequired(page),
|
||||
);
|
||||
skip('no Mapillary key', 'the keyed steps');
|
||||
} else if (!configured) {
|
||||
await step('keyless server: opens on KartaView, not KEY REQUIRED', () =>
|
||||
assertKeylessDefault(page),
|
||||
);
|
||||
await step(
|
||||
'keyless server: the MAPILLARY chip explains its key instead of switching',
|
||||
async () => {
|
||||
await press(page, '#sl-provider-chips [data-chip-id="mapillary"]');
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
assert.ok(litAlone(await getUI(page), 'kartaview'));
|
||||
},
|
||||
);
|
||||
skip('no Mapillary key', 'the keyed steps');
|
||||
} else {
|
||||
await park(page);
|
||||
await step(
|
||||
@@ -751,6 +781,8 @@ async function main() {
|
||||
await park(page);
|
||||
|
||||
await fixtureStep(
|
||||
// The fixture bundle carries a token, so this covers a keyless
|
||||
// server behind a keyed bundle; the keyless default is checked above.
|
||||
'keyless server (second page): KEY REQUIRED gates the controls',
|
||||
async () => {
|
||||
const keyless = await open(
|
||||
|
||||
+29
-20
@@ -301,6 +301,8 @@
|
||||
"AVAILABLE MISSIONS": "المهام المتاحة",
|
||||
"SELECT A MISSION TO INSPECT": "اختر مهمة لفحصها",
|
||||
"LOADING 30-DAY MISSION INDEX": "جارٍ تحميل فهرس مهام 30 يومًا",
|
||||
"Space Missions unavailable: the mission index could not load. Try again later.": "المهام الفضائية غير متاحة: تعذّر تحميل فهرس المهام. حاول مرة أخرى لاحقًا.",
|
||||
"30-DAY MISSION INDEX UNAVAILABLE": "فهرس مهام 30 يومًا غير متاح",
|
||||
"RADIO": "الراديو",
|
||||
"ENABLE": "تفعيل",
|
||||
"Previous filtered radio station": "محطة الراديو السابقة في القائمة المصفّاة",
|
||||
@@ -3226,7 +3228,7 @@
|
||||
"Every detection — many of the strongest fires are rated low confidence": "كل عمليات الرصد — كثير من أقوى الحرائق مصنّفة بثقة منخفضة",
|
||||
"Nominal and high confidence detections": "عمليات الرصد ذات الثقة المتوسطة والعالية",
|
||||
"High confidence detections only": "عمليات الرصد ذات الثقة العالية فقط",
|
||||
"Fire intensity (FRP)": "الشدة (FRP)",
|
||||
"Fire intensity (FRP)": "شدة الحريق (FRP)",
|
||||
"≥ {value} MW": "{value} MW على الأقل",
|
||||
"Confidence": "الثقة",
|
||||
"Minimum fire radiative power, megawatts": "الحد الأدنى للقدرة الإشعاعية للحريق، بالميغاواط",
|
||||
@@ -3244,15 +3246,15 @@
|
||||
"Directions, drawing and your own files": "الاتجاهات والرسم وملفاتك الخاصة",
|
||||
"Voice place search (needs an Overpass server)": "البحث الصوتي عن الأماكن (يتطلب خادم Overpass)",
|
||||
"OSM Places": "أماكن OSM",
|
||||
"state or province": "ولاية أو مقاطعة",
|
||||
"county or district": "محافظة أو قضاء",
|
||||
"state or province": "ولاية أو إقليم",
|
||||
"county or district": "مقاطعة أو قضاء",
|
||||
"neighbourhood": "حي",
|
||||
"natural region": "منطقة طبيعية",
|
||||
"Which {place}?": "أي {place} تقصد؟",
|
||||
"Which place?": "أي مكان تقصد؟",
|
||||
"Not drawn: {parts}": "لم يُرسم: {parts}",
|
||||
"Not drawn: {parts}": "لم يُرسم المخطط: {parts}",
|
||||
"approximate": "تقريبي",
|
||||
"{pct}% cloud": "غيوم {pct}%",
|
||||
"{pct}% cloud": "الغيوم {pct}%",
|
||||
"Over {area}": "فوق {area}",
|
||||
"{count} of {total} acquisitions match": {
|
||||
"few": "الصور المطابقة: {count} من {total}",
|
||||
@@ -3263,12 +3265,12 @@
|
||||
"other": "الصور المطابقة: {count} من {total}"
|
||||
},
|
||||
"{n} acquisitions searched": {
|
||||
"few": "الصور التي جرى البحث فيها: {n}",
|
||||
"many": "الصور التي جرى البحث فيها: {n}",
|
||||
"one": "الصور التي جرى البحث فيها: {n}",
|
||||
"two": "الصور التي جرى البحث فيها: {n}",
|
||||
"zero": "الصور التي جرى البحث فيها: {n}",
|
||||
"other": "الصور التي جرى البحث فيها: {n}"
|
||||
"zero": "الصور المفحوصة: {n}",
|
||||
"one": "الصور المفحوصة: {n}",
|
||||
"two": "الصور المفحوصة: {n}",
|
||||
"few": "الصور المفحوصة: {n}",
|
||||
"many": "الصور المفحوصة: {n}",
|
||||
"other": "الصور المفحوصة: {n}"
|
||||
},
|
||||
"No matching imagery": "لا توجد صور مطابقة",
|
||||
"Unknown number of {kind}": "{kind}: العدد غير معروف",
|
||||
@@ -3276,10 +3278,10 @@
|
||||
"At least {count} {kind}": "{kind}: {count} على الأقل",
|
||||
"{count} {kind}": "{kind}: {count}",
|
||||
"In {area}": "في {area}",
|
||||
"Area around {place}": "المنطقة المحيطة بـ{place}",
|
||||
"Area around landmark": "المنطقة المحيطة بمعلم",
|
||||
"Outline {place}": "تحديد مخطط {place}",
|
||||
"Outline area": "تحديد مخطط منطقة",
|
||||
"Area around {place}": "المنطقة حول {place}",
|
||||
"Area around landmark": "المنطقة حول المعلم",
|
||||
"Outline {place}": "رسم مخطط {place}",
|
||||
"Outline area": "رسم مخطط منطقة",
|
||||
"Find {place}": "البحث عن {place}",
|
||||
"Find area": "البحث عن منطقة",
|
||||
"Find recent imagery": "البحث عن صور حديثة",
|
||||
@@ -3322,9 +3324,9 @@
|
||||
"zero": "التكرارات المدمجة: {count}"
|
||||
},
|
||||
"listed the first {limit}": "عُرض أول {limit}",
|
||||
"counted over the bounding box of {area}, not its outline": "عُدّ ضمن المستطيل المحيط ({area})، لا ضمن الحدود",
|
||||
"counted over the bounding box of {area}, not its outline": "جرى العدّ ضمن المستطيل المحيط بمنطقة {area}، لا ضمن حدودها الفعلية",
|
||||
"Search cancelled": "أُلغي البحث",
|
||||
"Sea Marks": "علامات بحرية",
|
||||
"Sea Marks": "العلامات البحرية",
|
||||
"Ski Pistes": "مسارات التزلج",
|
||||
"Fly to Tokyo": "انتقل إلى طوكيو",
|
||||
"Show live flights": "اعرض تتبّع الرحلات المباشر",
|
||||
@@ -3399,7 +3401,7 @@
|
||||
"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}",
|
||||
"{lower} to {upper}": "من {lower} إلى {upper}",
|
||||
"Up to {upper}": "حتى {upper}",
|
||||
"From {lower}": "من {lower}",
|
||||
"Informational only · not for flight planning": "للعلم فقط · لا يصلح لتخطيط الرحلات",
|
||||
@@ -3407,7 +3409,7 @@
|
||||
"Restricted airspace source unavailable": "مصدر المجال الجوي المقيّد غير متاح",
|
||||
"Prohibited (P)": "محظور (P)",
|
||||
"Restricted (R)": "مقيّد (R)",
|
||||
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "المجال الجوي الخاص المحظور والمقيّد لدى FAA فقط، ولا يشمل UAS Facility Map أو قيود طيران UAS. للعلم فقط — ولا يغني عن الأدوات الرسمية لتخطيط رحلات UAS (B4UFLY / LAANC).",
|
||||
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "المجال الجوي المحظور والمقيّد ذو الاستخدام الخاص لدى FAA فقط، ولا يشمل UAS Facility Map ولا قيود طيران UAS. للعلم فقط — ولا يغني عن الأدوات الرسمية لتخطيط رحلات UAS (B4UFLY / LAANC).",
|
||||
"Prohibited": "محظور",
|
||||
"Restricted": "مقيّد",
|
||||
"Prohibited & Restricted Airspace": "المجال الجوي المحظور والمقيّد",
|
||||
@@ -3466,5 +3468,12 @@
|
||||
"Open {name} on Acartia": "فتح {name} على Acartia",
|
||||
"iNaturalist": "iNaturalist",
|
||||
"iNaturalist contributors · licences per record": "مساهمو iNaturalist · التراخيص لكل سجل",
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "مساهمو iNaturalist · Acartia {count} (بحر ساليش) · التراخيص لكل سجل"
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "مساهمو iNaturalist · Acartia {count} (بحر ساليش) · التراخيص لكل سجل",
|
||||
"{days}d": "{days}ي",
|
||||
"All markers": "كل العلامات",
|
||||
"Show markers of every age": "عرض العلامات من جميع الأعمار",
|
||||
"Maximum event age: {days}d; undated markers are kept": "الحد الأقصى لعمر الحدث: {days} يوم؛ تُحفظ العلامات غير المؤرخة",
|
||||
"event age filter|All": "الكل",
|
||||
"Event age": "عمر الحدث",
|
||||
"Event age: {days}d · undated kept": "عمر الحدث: {days} يوم · الاحتفاظ بغير المؤرخ"
|
||||
}
|
||||
+27
-18
@@ -760,6 +760,8 @@
|
||||
"AVAILABLE MISSIONS": "VERFÜGBARE MISSIONEN",
|
||||
"SELECT A MISSION TO INSPECT": "MISSION ZUM ANSEHEN WÄHLEN",
|
||||
"LOADING 30-DAY MISSION INDEX": "30-TAGE-MISSIONSINDEX WIRD GELADEN",
|
||||
"Space Missions unavailable: the mission index could not load. Try again later.": "Raummissionen nicht verfügbar: Der Missionsindex konnte nicht geladen werden. Später erneut versuchen.",
|
||||
"30-DAY MISSION INDEX UNAVAILABLE": "30-TAGE-MISSIONSINDEX NICHT VERFÜGBAR",
|
||||
"TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB: VORSCHAU · ENTER / LEERTASTE: AUSWAHL",
|
||||
"Internet radio and local SDR": "Internetradio und lokales SDR",
|
||||
"STATION TAG": "KATEGORIE",
|
||||
@@ -2918,7 +2920,7 @@
|
||||
"From {lower}": "Ab {lower}",
|
||||
"Informational only · not for flight planning": "Nur zur Information · nicht für die Flugplanung",
|
||||
"Unnamed zone": "Unbenannte Zone",
|
||||
"Restricted airspace source unavailable": "Quelle für Sperrgebiete nicht verfügbar",
|
||||
"Restricted airspace source unavailable": "Quelle für Sperr- und Flugbeschränkungsgebiete nicht verfügbar",
|
||||
"Prohibited (P)": "Sperrgebiet (P)",
|
||||
"Restricted (R)": "Flugbeschränkungsgebiet (R)",
|
||||
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Nur Sperr- und Flugbeschränkungsgebiete der FAA (Luftraum mit besonderer Nutzung), nicht die UAS Facility Map oder UAS-Flugbeschränkungen. Nur zur Information – kein Ersatz für offizielle UAS-Flugplanungstools (B4UFLY / LAANC).",
|
||||
@@ -2955,13 +2957,13 @@
|
||||
"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",
|
||||
"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",
|
||||
"Minimum fire radiative power, megawatts": "Mindestwert der Strahlungsleistung des Brandes in Megawatt",
|
||||
"fire intensity filter|All": "Alle",
|
||||
"Detection confidence": "Konfidenz der Erkennung",
|
||||
"{shown} of {total} shown": "{shown} von {total} angezeigt",
|
||||
@@ -3001,9 +3003,9 @@
|
||||
"{count} {kind}": "{kind}: {count}",
|
||||
"In {area}": "Gebiet: {area}",
|
||||
"Area around {place}": "Gebiet um {place}",
|
||||
"Area around landmark": "Gebiet um ein Wahrzeichen",
|
||||
"Outline {place}": "{place} umreißen",
|
||||
"Outline area": "Gebiet umreißen",
|
||||
"Area around landmark": "Gebiet um einen Ort",
|
||||
"Outline {place}": "Umriss von {place} zeichnen",
|
||||
"Outline area": "Umriss zeichnen",
|
||||
"Find {place}": "{place} finden",
|
||||
"Find area": "Gebiet finden",
|
||||
"Find recent imagery": "Aktuelle Bilddaten finden",
|
||||
@@ -3014,27 +3016,27 @@
|
||||
"Loading imagery": "Bilddaten werden geladen",
|
||||
"Searching OpenStreetMap": "OpenStreetMap wird durchsucht",
|
||||
"voice area|Area": "Gebiet",
|
||||
"buffer sized to the place": "Puffer in Größe des Ortes",
|
||||
"buffer sized to the place": "Puffer entsprechend der Ortsgröße",
|
||||
"enclosing mapped area": "umschließendes kartiertes Gebiet",
|
||||
"the map was cleared while looking it up": "die Karte wurde während der Suche geleert",
|
||||
"OpenStreetMap coverage varies by place": "Die Abdeckung von OpenStreetMap ist je nach Ort unterschiedlich",
|
||||
"the map was cleared while looking it up": "die Zeichenfläche wurde während der Suche geleert",
|
||||
"OpenStreetMap coverage varies by place": "die Abdeckung von OpenStreetMap ist je nach Ort unterschiedlich",
|
||||
"the catalog covers the last 30 days": "der Katalog umfasst die letzten 30 Tage",
|
||||
"there are more": "es gibt weitere",
|
||||
"{radius} m around the landmark": "{radius} m um das Wahrzeichen",
|
||||
"{radius} m around the landmark": "{radius} m um den Ort",
|
||||
"{count} parts": {
|
||||
"one": "Teile: {count}",
|
||||
"other": "Teile: {count}"
|
||||
"one": "{count} Teil",
|
||||
"other": "{count} Teile"
|
||||
},
|
||||
"{drawn} of {total} parts drawn": {
|
||||
"one": "Gezeichnete Teile: {drawn} von {total}",
|
||||
"other": "Gezeichnete Teile: {drawn} von {total}"
|
||||
"one": "{drawn} von {total} Teil gezeichnet",
|
||||
"other": "{drawn} von {total} Teilen gezeichnet"
|
||||
},
|
||||
"{count} duplicate mappings merged": {
|
||||
"one": "Zusammengeführte Doppelkartierungen: {count}",
|
||||
"other": "Zusammengeführte Doppelkartierungen: {count}"
|
||||
"one": "{count} Duplikat zusammengeführt",
|
||||
"other": "{count} Duplikate zusammengeführt"
|
||||
},
|
||||
"listed the first {limit}": "die ersten {limit} aufgelistet",
|
||||
"counted over the bounding box of {area}, not its outline": "über das umgebende Rechteck gezählt ({area}), nicht über den Umriss",
|
||||
"counted over the bounding box of {area}, not its outline": "im umgebenden Rechteck von {area} gezählt, nicht innerhalb des Umrisses",
|
||||
"Search cancelled": "Suche abgebrochen",
|
||||
"Sea Marks": "Seezeichen",
|
||||
"Ski Pistes": "Skipisten",
|
||||
@@ -3166,5 +3168,12 @@
|
||||
"Open {name} on Acartia": "{name} auf Acartia öffnen",
|
||||
"iNaturalist": "iNaturalist",
|
||||
"iNaturalist contributors · licences per record": "iNaturalist-Beitragende · Lizenzen je Eintrag",
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "iNaturalist-Beitragende · Acartia {count} (Salish-See) · Lizenzen je Eintrag"
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "iNaturalist-Beitragende · Acartia {count} (Salish-See) · Lizenzen je Eintrag",
|
||||
"{days}d": "{days}T",
|
||||
"All markers": "Alle Markierungen",
|
||||
"Show markers of every age": "Markierungen jeden Alters anzeigen",
|
||||
"Maximum event age: {days}d; undated markers are kept": "Maximales Ereignisalter: {days} Tage; undatierte Markierungen bleiben",
|
||||
"event age filter|All": "Alle",
|
||||
"Event age": "Ereignisalter",
|
||||
"Event age: {days}d · undated kept": "Ereignisalter: {days} Tage · undatierte bleiben"
|
||||
}
|
||||
+23
-14
@@ -192,6 +192,8 @@
|
||||
"AVAILABLE MISSIONS": "MISIONES DISPONIBLES",
|
||||
"SELECT A MISSION TO INSPECT": "SELECCIONA UNA MISIÓN PARA INSPECCIONAR",
|
||||
"LOADING 30-DAY MISSION INDEX": "CARGANDO ÍNDICE DE MISIONES DE 30 DÍAS",
|
||||
"Space Missions unavailable: the mission index could not load. Try again later.": "Misiones espaciales no disponibles: no se pudo cargar el índice de misiones. Inténtalo más tarde.",
|
||||
"30-DAY MISSION INDEX UNAVAILABLE": "ÍNDICE DE MISIONES DE 30 DÍAS NO DISPONIBLE",
|
||||
"RADIO": "RADIO",
|
||||
"ENABLE": "ACTIVAR",
|
||||
"Previous filtered radio station": "Emisora de radio filtrada anterior",
|
||||
@@ -2992,7 +2994,7 @@
|
||||
"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",
|
||||
"Every detection — many of the strongest fires are rated low confidence": "Todas las detecciones — muchos de los incendios más intensos se clasifican con 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)",
|
||||
@@ -3001,8 +3003,8 @@
|
||||
"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}",
|
||||
"{shown} of {total} shown": "Mostrando {shown} de {total}",
|
||||
"{lower} to {upper}": "De {lower} a {upper}",
|
||||
"Up to {upper}": "Hasta {upper}",
|
||||
"From {lower}": "Desde {lower}",
|
||||
"Informational only · not for flight planning": "Solo informativo · no apto para planificar vuelos",
|
||||
@@ -3010,9 +3012,9 @@
|
||||
"Restricted airspace source unavailable": "Fuente de espacio aéreo restringido no disponible",
|
||||
"Prohibited (P)": "Prohibido (P)",
|
||||
"Restricted (R)": "Restringido (R)",
|
||||
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Solo espacio aéreo de uso especial prohibido y restringido de la FAA, sin el UAS Facility Map ni las restricciones de vuelo de UAS. Solo informativo: no sustituye a las herramientas oficiales de planificación de vuelos de UAS (B4UFLY / LAANC).",
|
||||
"Prohibited": "Prohibido",
|
||||
"Restricted": "Restringido",
|
||||
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Solo espacio aéreo de uso especial prohibido y restringido de la FAA; no incluye el UAS Facility Map ni las restricciones de vuelo de UAS. Solo informativo — no sustituye a las herramientas oficiales de planificación de vuelos de UAS (B4UFLY / LAANC).",
|
||||
"Prohibited": "Zona prohibida",
|
||||
"Restricted": "Zona restringida",
|
||||
"Prohibited & Restricted Airspace": "Espacio aéreo prohibido y restringido",
|
||||
"routing busy — too many routes at once": "servicio de rutas ocupado: demasiadas rutas a la vez",
|
||||
"route proxy error": "error del proxy de rutas",
|
||||
@@ -3050,10 +3052,10 @@
|
||||
"county or district": "condado o distrito",
|
||||
"neighbourhood": "barrio",
|
||||
"natural region": "región natural",
|
||||
"Which {place}?": "¿Cuál {place}?",
|
||||
"Which {place}?": "¿Qué {place}?",
|
||||
"Which place?": "¿Qué lugar?",
|
||||
"Not drawn: {parts}": "Sin dibujar: {parts}",
|
||||
"approximate": "aproximado",
|
||||
"approximate": "aproximada",
|
||||
"{pct}% cloud": "{pct}% de nubes",
|
||||
"Over {area}": "Zona: {area}",
|
||||
"{count} of {total} acquisitions match": {
|
||||
@@ -3068,7 +3070,7 @@
|
||||
},
|
||||
"No matching imagery": "No hay imágenes que coincidan",
|
||||
"Unknown number of {kind}": "{kind}: número desconocido",
|
||||
"About {count} {kind}": "{kind}: unos {count}",
|
||||
"About {count} {kind}": "{kind}: aproximadamente {count}",
|
||||
"At least {count} {kind}": "{kind}: al menos {count}",
|
||||
"{count} {kind}": "{kind}: {count}",
|
||||
"In {area}": "En {area}",
|
||||
@@ -3087,12 +3089,12 @@
|
||||
"Searching OpenStreetMap": "Buscando en OpenStreetMap",
|
||||
"voice area|Area": "Área",
|
||||
"buffer sized to the place": "zona aproximada del tamaño del lugar",
|
||||
"enclosing mapped area": "zona cartografiada que lo rodea",
|
||||
"enclosing mapped area": "zona mapeada que contiene el punto de interés",
|
||||
"the map was cleared while looking it up": "el mapa se borró durante la búsqueda",
|
||||
"OpenStreetMap coverage varies by place": "La cobertura de OpenStreetMap varía según el lugar",
|
||||
"OpenStreetMap coverage varies by place": "la cobertura de OpenStreetMap varía según el lugar",
|
||||
"the catalog covers the last 30 days": "el catálogo cubre los últimos 30 días",
|
||||
"there are more": "hay más",
|
||||
"{radius} m around the landmark": "{radius} m alrededor del lugar",
|
||||
"{radius} m around the landmark": "{radius} m alrededor del punto de interés",
|
||||
"{count} parts": {
|
||||
"many": "Partes: {count}",
|
||||
"one": "Partes: {count}",
|
||||
@@ -3109,7 +3111,7 @@
|
||||
"other": "Duplicados fusionados: {count}"
|
||||
},
|
||||
"listed the first {limit}": "se listaron los primeros {limit}",
|
||||
"counted over the bounding box of {area}, not its outline": "contado en el rectángulo que lo rodea ({area}), no en su contorno",
|
||||
"counted over the bounding box of {area}, not its outline": "se contó en el rectángulo que abarca {area}, no dentro de su contorno",
|
||||
"Search cancelled": "Búsqueda cancelada",
|
||||
"Sea Marks": "Señales marítimas",
|
||||
"Ski Pistes": "Pistas de esquí",
|
||||
@@ -3241,5 +3243,12 @@
|
||||
"Open {name} on Acartia": "Abrir {name} en Acartia",
|
||||
"iNaturalist": "iNaturalist",
|
||||
"iNaturalist contributors · licences per record": "Colaboradores de iNaturalist · licencias por registro",
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Colaboradores de iNaturalist · Acartia {count} (mar de Salish) · licencias por registro"
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Colaboradores de iNaturalist · Acartia {count} (mar de Salish) · licencias por registro",
|
||||
"{days}d": "{days}d",
|
||||
"All markers": "Todos los marcadores",
|
||||
"Show markers of every age": "Mostrar marcadores de cualquier antigüedad",
|
||||
"Maximum event age: {days}d; undated markers are kept": "Antigüedad máxima: {days} días; se conservan los marcadores sin fecha",
|
||||
"event age filter|All": "Todos",
|
||||
"Event age": "Antigüedad",
|
||||
"Event age: {days}d · undated kept": "Antigüedad: {days} días · se conservan los marcadores sin fecha"
|
||||
}
|
||||
+37
-28
@@ -2805,6 +2805,8 @@
|
||||
"AVAILABLE MISSIONS": "MISSIONS DISPONIBLES",
|
||||
"SELECT A MISSION TO INSPECT": "CHOISIR UNE MISSION À EXAMINER",
|
||||
"LOADING 30-DAY MISSION INDEX": "CHARGEMENT DE L'INDEX DES MISSIONS À 30 JOURS",
|
||||
"Space Missions unavailable: the mission index could not load. Try again later.": "Missions spatiales indisponibles : impossible de charger l’index des missions. Réessayez plus tard.",
|
||||
"30-DAY MISSION INDEX UNAVAILABLE": "INDEX DES MISSIONS À 30 JOURS INDISPONIBLE",
|
||||
"TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB APERÇU · ENTER / SPACE SÉLECTIONNE",
|
||||
"Internet radio and local SDR": "Radio Internet et SDR local",
|
||||
"STATION TAG": "TAG DE STATION",
|
||||
@@ -2983,18 +2985,18 @@
|
||||
"Tip: the GEV MIC button in the dock lets you talk to the map.": "Astuce : le bouton GEV MIC du dock permet de parler à la carte.",
|
||||
"Malformed cyclone snapshot": "Instantané cyclonique mal formé",
|
||||
"Cyclone request timed out": "Délai dépassé pour la requête cyclonique",
|
||||
"{lower} to {upper}": "{lower} à {upper}",
|
||||
"Up to {upper}": "Jusqu’à {upper}",
|
||||
"{lower} to {upper}": "De {lower} à {upper}",
|
||||
"Up to {upper}": "Jusqu'à {upper}",
|
||||
"From {lower}": "À partir de {lower}",
|
||||
"Informational only · not for flight planning": "À titre informatif uniquement · ne pas utiliser pour la planification de vol",
|
||||
"Unnamed zone": "Zone sans nom",
|
||||
"Restricted airspace source unavailable": "Source de l’espace aérien réglementé indisponible",
|
||||
"Informational only · not for flight planning": "À titre indicatif · ne pas utiliser pour la préparation des vols",
|
||||
"Unnamed zone": "Sans nom",
|
||||
"Restricted airspace source unavailable": "Source de l'espace aérien réglementé indisponible",
|
||||
"Prohibited (P)": "Zone interdite (P)",
|
||||
"Restricted (R)": "Zone réglementée (R)",
|
||||
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Uniquement les zones interdites et réglementées de la FAA (espace aérien à usage spécial), sans la UAS Facility Map ni les restrictions de vol des drones. À titre informatif uniquement — ne remplace pas les outils officiels de planification de vol des drones (B4UFLY / LAANC).",
|
||||
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Uniquement les zones interdites et réglementées de la FAA (espace aérien à usage spécial), sans la UAS Facility Map ni les restrictions de vol des drones. À titre indicatif uniquement — ne remplace pas les outils officiels de préparation des vols de drones (B4UFLY / LAANC).",
|
||||
"Prohibited": "Zone interdite",
|
||||
"Restricted": "Zone réglementée",
|
||||
"Prohibited & Restricted Airspace": "Zones interdites et réglementées",
|
||||
"Prohibited & Restricted Airspace": "Espaces aériens interdits et réglementés",
|
||||
"scene|PLAY": "LIRE",
|
||||
"AUTHORING": "CRÉATION",
|
||||
"Create and edit scenes": "Créer et modifier des scènes",
|
||||
@@ -3025,7 +3027,7 @@
|
||||
"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",
|
||||
"Every detection — many of the strongest fires are rated low confidence": "Toutes les détections — de nombreux feux parmi les plus intenses sont classés en 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)",
|
||||
@@ -3034,7 +3036,7 @@
|
||||
"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",
|
||||
"{shown} of {total} shown": "Affichés : {shown} sur {total}",
|
||||
"Name of {name}": "Nom de {name}",
|
||||
"Change the colour of {name}": "Changer la couleur de {name}",
|
||||
"Colour of {name}": "Couleur de {name}",
|
||||
@@ -3044,7 +3046,7 @@
|
||||
"UTILITIES": "OUTILS",
|
||||
"Exit cockpit to fly to a drawing": "Quittez le cockpit pour rejoindre un dessin",
|
||||
"Directions, drawing and your own files": "Itinéraire, dessin et vos propres fichiers",
|
||||
"Voice place search (needs an Overpass server)": "Recherche de lieux par la voix (nécessite un serveur Overpass)",
|
||||
"Voice place search (needs an Overpass server)": "Recherche vocale de lieux (nécessite un serveur Overpass)",
|
||||
"OSM Places": "Lieux OSM",
|
||||
"state or province": "État ou province",
|
||||
"county or district": "comté ou district",
|
||||
@@ -3052,9 +3054,9 @@
|
||||
"natural region": "région naturelle",
|
||||
"Which {place}?": "Quel lieu nommé {place} ?",
|
||||
"Which place?": "Quel lieu ?",
|
||||
"Not drawn: {parts}": "Non dessiné : {parts}",
|
||||
"Not drawn: {parts}": "Contour non tracé : {parts}",
|
||||
"approximate": "approximatif",
|
||||
"{pct}% cloud": "{pct} % de nuages",
|
||||
"{pct}% cloud": "{pct} % de nuages",
|
||||
"Over {area}": "Zone : {area}",
|
||||
"{count} of {total} acquisitions match": {
|
||||
"many": "Acquisitions correspondantes : {count} sur {total}",
|
||||
@@ -3073,43 +3075,43 @@
|
||||
"{count} {kind}": "{kind} : {count}",
|
||||
"In {area}": "Zone : {area}",
|
||||
"Area around {place}": "Environs : {place}",
|
||||
"Area around landmark": "Environs d'un repère",
|
||||
"Area around landmark": "Environs d'un lieu",
|
||||
"Outline {place}": "Délimiter {place}",
|
||||
"Outline area": "Délimiter une zone",
|
||||
"Find {place}": "Trouver {place}",
|
||||
"Find area": "Trouver une zone",
|
||||
"Find recent imagery": "Trouver des images récentes",
|
||||
"Find {what}": "Trouver {what}",
|
||||
"Find {what}": "Trouver : {what}",
|
||||
"Find places": "Trouver des lieux",
|
||||
"Drawing outline": "Dessin du contour",
|
||||
"Searching imagery catalog": "Recherche dans le catalogue d'images",
|
||||
"Loading imagery": "Chargement des images",
|
||||
"Searching OpenStreetMap": "Recherche dans OpenStreetMap",
|
||||
"voice area|Area": "Zone",
|
||||
"buffer sized to the place": "zone tampon à la taille du lieu",
|
||||
"buffer sized to the place": "zone tampon adaptée à la taille du lieu",
|
||||
"enclosing mapped area": "zone cartographiée englobante",
|
||||
"the map was cleared while looking it up": "la carte a été effacée pendant la recherche",
|
||||
"OpenStreetMap coverage varies by place": "La couverture OpenStreetMap varie selon les lieux",
|
||||
"OpenStreetMap coverage varies by place": "la couverture OpenStreetMap varie selon les lieux",
|
||||
"the catalog covers the last 30 days": "le catalogue couvre les 30 derniers jours",
|
||||
"there are more": "il y en a d'autres",
|
||||
"{radius} m around the landmark": "{radius} m autour du lieu",
|
||||
"{count} parts": {
|
||||
"many": "Parties : {count}",
|
||||
"one": "Parties : {count}",
|
||||
"other": "Parties : {count}"
|
||||
"many": "Parties : {count}",
|
||||
"one": "Parties : {count}",
|
||||
"other": "Parties : {count}"
|
||||
},
|
||||
"{drawn} of {total} parts drawn": {
|
||||
"many": "Parties tracées : {drawn} sur {total}",
|
||||
"one": "Parties tracées : {drawn} sur {total}",
|
||||
"other": "Parties tracées : {drawn} sur {total}"
|
||||
"many": "Parties tracées : {drawn} sur {total}",
|
||||
"one": "Parties tracées : {drawn} sur {total}",
|
||||
"other": "Parties tracées : {drawn} sur {total}"
|
||||
},
|
||||
"{count} duplicate mappings merged": {
|
||||
"many": "Doublons fusionnés : {count}",
|
||||
"one": "Doublons fusionnés : {count}",
|
||||
"other": "Doublons fusionnés : {count}"
|
||||
"many": "Doublons fusionnés : {count}",
|
||||
"one": "Doublons fusionnés : {count}",
|
||||
"other": "Doublons fusionnés : {count}"
|
||||
},
|
||||
"listed the first {limit}": "les {limit} premiers sont listés",
|
||||
"counted over the bounding box of {area}, not its outline": "compté sur le rectangle englobant ({area}), pas sur le contour",
|
||||
"listed the first {limit}": "liste limitée à {limit}",
|
||||
"counted over the bounding box of {area}, not its outline": "compté dans le rectangle englobant ({area}), pas dans son contour exact",
|
||||
"Search cancelled": "Recherche annulée",
|
||||
"Sea Marks": "Balisage maritime",
|
||||
"Ski Pistes": "Pistes de ski",
|
||||
@@ -3241,5 +3243,12 @@
|
||||
"Open {name} on Acartia": "Ouvrir {name} sur Acartia",
|
||||
"iNaturalist": "iNaturalist",
|
||||
"iNaturalist contributors · licences per record": "Contributeurs iNaturalist · licences par observation",
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Contributeurs iNaturalist · Acartia {count} (mer des Salish) · licences par observation"
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Contributeurs iNaturalist · Acartia {count} (mer des Salish) · licences par observation",
|
||||
"{days}d": "{days}j",
|
||||
"All markers": "Tous les marqueurs",
|
||||
"Show markers of every age": "Afficher les marqueurs de tout âge",
|
||||
"Maximum event age: {days}d; undated markers are kept": "Ancienneté maximale : {days} j ; marqueurs sans date conservés",
|
||||
"event age filter|All": "Tous",
|
||||
"Event age": "Ancienneté",
|
||||
"Event age: {days}d · undated kept": "Ancienneté : {days} j · marqueurs sans date conservés"
|
||||
}
|
||||
+30
-21
@@ -2805,6 +2805,8 @@
|
||||
"AVAILABLE MISSIONS": "MISSIONI DISPONIBILI",
|
||||
"SELECT A MISSION TO INSPECT": "SELEZIONA UNA MISSIONE DA ESAMINARE",
|
||||
"LOADING 30-DAY MISSION INDEX": "CARICAMENTO INDICE MISSIONI A 30 GIORNI",
|
||||
"Space Missions unavailable: the mission index could not load. Try again later.": "Missioni spaziali non disponibili: impossibile caricare l’indice delle missioni. Riprova più tardi.",
|
||||
"30-DAY MISSION INDEX UNAVAILABLE": "INDICE MISSIONI A 30 GIORNI NON DISPONIBILE",
|
||||
"TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB: ANTEPRIMA · ENTER / SPACE: SELEZIONA",
|
||||
"Internet radio and local SDR": "Radio internet e SDR locale",
|
||||
"STATION TAG": "TAG STAZIONE",
|
||||
@@ -2983,18 +2985,18 @@
|
||||
"Tip: the GEV MIC button in the dock lets you talk to the map.": "Suggerimento: il pulsante GEV MIC nel dock ti permette di parlare con la mappa.",
|
||||
"Malformed cyclone snapshot": "Snapshot dei cicloni non valido",
|
||||
"Cyclone request timed out": "La richiesta dei cicloni è andata in timeout",
|
||||
"{lower} to {upper}": "da {lower} a {upper}",
|
||||
"{lower} to {upper}": "Da {lower} a {upper}",
|
||||
"Up to {upper}": "Fino a {upper}",
|
||||
"From {lower}": "Da {lower}",
|
||||
"Informational only · not for flight planning": "Solo a scopo informativo · non per la pianificazione del volo",
|
||||
"Unnamed zone": "Zona senza nome",
|
||||
"Unnamed zone": "Senza nome",
|
||||
"Restricted airspace source unavailable": "Fonte dello spazio aereo regolamentato non disponibile",
|
||||
"Prohibited (P)": "Zona proibita (P)",
|
||||
"Prohibited (P)": "Zona vietata (P)",
|
||||
"Restricted (R)": "Zona regolamentata (R)",
|
||||
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Solo zone proibite e regolamentate della FAA (spazio aereo a uso speciale), non la UAS Facility Map né le restrizioni di volo UAS. Solo a scopo informativo — non sostituisce gli strumenti ufficiali di pianificazione del volo UAS (B4UFLY / LAANC).",
|
||||
"Prohibited": "Zona proibita",
|
||||
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Solo zone vietate e regolamentate della FAA (spazio aereo a uso speciale), non la UAS Facility Map né le restrizioni di volo UAS. Solo a scopo informativo — non sostituisce gli strumenti ufficiali di pianificazione del volo UAS (B4UFLY / LAANC).",
|
||||
"Prohibited": "Zona vietata",
|
||||
"Restricted": "Zona regolamentata",
|
||||
"Prohibited & Restricted Airspace": "Zone proibite e regolamentate",
|
||||
"Prohibited & Restricted Airspace": "Spazio aereo vietato e regolamentato",
|
||||
"scene|PLAY": "AVVIA",
|
||||
"AUTHORING": "CREAZIONE",
|
||||
"Create and edit scenes": "Crea e modifica le scene",
|
||||
@@ -3025,7 +3027,7 @@
|
||||
"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",
|
||||
"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)",
|
||||
@@ -3034,7 +3036,7 @@
|
||||
"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",
|
||||
"{shown} of {total} shown": "Mostrati: {shown} su {total}",
|
||||
"Name of {name}": "Nome di {name}",
|
||||
"Change the colour of {name}": "Cambia il colore di {name}",
|
||||
"Colour of {name}": "Colore di {name}",
|
||||
@@ -3052,10 +3054,10 @@
|
||||
"natural region": "regione naturale",
|
||||
"Which {place}?": "Quale {place}?",
|
||||
"Which place?": "Quale luogo?",
|
||||
"Not drawn: {parts}": "Non disegnati: {parts}",
|
||||
"approximate": "approssimativo",
|
||||
"Not drawn: {parts}": "Contorno non disegnato: {parts}",
|
||||
"approximate": "approssimata",
|
||||
"{pct}% cloud": "{pct}% di nuvole",
|
||||
"Over {area}": "Zona: {area}",
|
||||
"Over {area}": "Area: {area}",
|
||||
"{count} of {total} acquisitions match": {
|
||||
"many": "Acquisizioni corrispondenti: {count} su {total}",
|
||||
"one": "Acquisizioni corrispondenti: {count} su {total}",
|
||||
@@ -3074,8 +3076,8 @@
|
||||
"In {area}": "Area: {area}",
|
||||
"Area around {place}": "Area intorno a {place}",
|
||||
"Area around landmark": "Area intorno a un punto di interesse",
|
||||
"Outline {place}": "Traccia contorno di {place}",
|
||||
"Outline area": "Traccia contorno area",
|
||||
"Outline {place}": "Traccia il contorno di {place}",
|
||||
"Outline area": "Traccia il contorno dell'area",
|
||||
"Find {place}": "Trova {place}",
|
||||
"Find area": "Trova area",
|
||||
"Find recent imagery": "Trova immagini recenti",
|
||||
@@ -3083,16 +3085,16 @@
|
||||
"Find places": "Trova luoghi",
|
||||
"Drawing outline": "Disegno del contorno",
|
||||
"Searching imagery catalog": "Ricerca nel catalogo immagini",
|
||||
"Loading imagery": "Caricamento immagini",
|
||||
"Loading imagery": "Caricamento delle immagini",
|
||||
"Searching OpenStreetMap": "Ricerca in OpenStreetMap",
|
||||
"voice area|Area": "Area",
|
||||
"buffer sized to the place": "zona tampone delle dimensioni del luogo",
|
||||
"enclosing mapped area": "area mappata circostante",
|
||||
"the map was cleared while looking it up": "la mappa è stata cancellata durante la ricerca",
|
||||
"OpenStreetMap coverage varies by place": "La copertura di OpenStreetMap varia da luogo a luogo",
|
||||
"enclosing mapped area": "area mappata che contiene il punto di interesse",
|
||||
"the map was cleared while looking it up": "la mappa è stata ripulita durante la ricerca",
|
||||
"OpenStreetMap coverage varies by place": "la copertura di OpenStreetMap varia da luogo a luogo",
|
||||
"the catalog covers the last 30 days": "il catalogo copre gli ultimi 30 giorni",
|
||||
"there are more": "ce ne sono altri",
|
||||
"{radius} m around the landmark": "{radius} m attorno al luogo",
|
||||
"{radius} m around the landmark": "{radius} m intorno al punto di interesse",
|
||||
"{count} parts": {
|
||||
"many": "Parti: {count}",
|
||||
"one": "Parti: {count}",
|
||||
@@ -3108,8 +3110,8 @@
|
||||
"one": "Duplicati uniti: {count}",
|
||||
"other": "Duplicati uniti: {count}"
|
||||
},
|
||||
"listed the first {limit}": "elencati i primi {limit}",
|
||||
"counted over the bounding box of {area}, not its outline": "contato sul riquadro di delimitazione ({area}), non sul contorno",
|
||||
"listed the first {limit}": "elenco limitato ai primi risultati: {limit}",
|
||||
"counted over the bounding box of {area}, not its outline": "conteggio nel riquadro di delimitazione di {area}, non entro il suo contorno",
|
||||
"Search cancelled": "Ricerca annullata",
|
||||
"Sea Marks": "Segnalamenti marittimi",
|
||||
"Ski Pistes": "Piste da sci",
|
||||
@@ -3241,5 +3243,12 @@
|
||||
"Open {name} on Acartia": "Apri {name} su Acartia",
|
||||
"iNaturalist": "iNaturalist",
|
||||
"iNaturalist contributors · licences per record": "Contributori di iNaturalist · licenze per osservazione",
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Contributori di iNaturalist · Acartia {count} (Mare dei Salish) · licenze per osservazione"
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Contributori di iNaturalist · Acartia {count} (Mare dei Salish) · licenze per osservazione",
|
||||
"{days}d": "{days}g",
|
||||
"All markers": "Tutti gli indicatori",
|
||||
"Show markers of every age": "Mostra gli indicatori di qualsiasi età",
|
||||
"Maximum event age: {days}d; undated markers are kept": "Età massima degli eventi: {days} giorni; indicatori senza data mantenuti",
|
||||
"event age filter|All": "Tutti",
|
||||
"Event age": "Età degli eventi",
|
||||
"Event age: {days}d · undated kept": "Età degli eventi: {days} giorni · senza data mantenuti"
|
||||
}
|
||||
+41
-32
@@ -2663,6 +2663,8 @@
|
||||
"AVAILABLE MISSIONS": "利用可能なミッション",
|
||||
"SELECT A MISSION TO INSPECT": "ミッションを選択して詳細を表示",
|
||||
"LOADING 30-DAY MISSION INDEX": "30日間のミッション一覧を読込中",
|
||||
"Space Missions unavailable: the mission index could not load. Try again later.": "宇宙ミッションを利用できません:ミッション一覧を読み込めませんでした。後でもう一度お試しください。",
|
||||
"30-DAY MISSION INDEX UNAVAILABLE": "30日間のミッション一覧を取得できません",
|
||||
"TAB PREVIEWS · ENTER / SPACE SELECTS": "Tabでプレビュー · Enter / Spaceで選択",
|
||||
"Internet radio and local SDR": "インターネットラジオとローカルSDR",
|
||||
"STATION TAG": "局タグ",
|
||||
@@ -2843,18 +2845,18 @@
|
||||
"route proxy error": "ルート検索プロキシでエラーが発生しました",
|
||||
"Malformed cyclone snapshot": "サイクロンのスナップショットの形式が不正です",
|
||||
"Cyclone request timed out": "サイクロンのリクエストがタイムアウトしました",
|
||||
"{lower} to {upper}": "{lower}~{upper}",
|
||||
"{lower} to {upper}": "{lower}–{upper}",
|
||||
"Up to {upper}": "{upper}まで",
|
||||
"From {lower}": "{lower}から",
|
||||
"Informational only · not for flight planning": "参考情報のみ · 飛行計画には使用しないでください",
|
||||
"Unnamed zone": "名称なしのゾーン",
|
||||
"Unnamed zone": "名称不明の区域",
|
||||
"Restricted airspace source unavailable": "制限空域のデータソースを利用できません",
|
||||
"Prohibited (P)": "飛行禁止区域 (P)",
|
||||
"Restricted (R)": "制限区域 (R)",
|
||||
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "FAAの飛行禁止区域と制限区域(特別使用空域)のみで、UAS Facility MapやUAS飛行制限は含みません。参考情報のみ — 公式のUAS飛行計画ツール(B4UFLY / LAANC)の代わりにはなりません。",
|
||||
"Prohibited (P)": "飛行禁止区域(P)",
|
||||
"Restricted (R)": "飛行制限区域(R)",
|
||||
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "FAAの特別使用空域のうち、飛行禁止区域と飛行制限区域のみです。UAS Facility MapやUASの飛行制限は含みません。参考情報であり、公式のUAS飛行計画ツール(B4UFLY / LAANC)の代わりにはなりません。",
|
||||
"Prohibited": "飛行禁止区域",
|
||||
"Restricted": "制限区域",
|
||||
"Prohibited & Restricted Airspace": "飛行禁止区域と制限区域",
|
||||
"Restricted": "飛行制限区域",
|
||||
"Prohibited & Restricted Airspace": "飛行禁止・飛行制限区域",
|
||||
"scene|PLAY": "再生",
|
||||
"AUTHORING": "作成",
|
||||
"Create and edit scenes": "シーンを作成・編集",
|
||||
@@ -2885,10 +2887,10 @@
|
||||
"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)",
|
||||
"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": "火災放射強度の最小値(メガワット)",
|
||||
@@ -2906,16 +2908,16 @@
|
||||
"Directions, drawing and your own files": "ルート案内、描画、自分のファイル",
|
||||
"Voice place search (needs an Overpass server)": "音声による場所検索(Overpassサーバーが必要)",
|
||||
"OSM Places": "OSMの場所",
|
||||
"state or province": "州・省",
|
||||
"state or province": "州・県",
|
||||
"county or district": "郡・地区",
|
||||
"neighbourhood": "近隣地区",
|
||||
"natural region": "自然地域",
|
||||
"Which {place}?": "どの{place}ですか?",
|
||||
"Which {place}?": "どの「{place}」ですか?",
|
||||
"Which place?": "どの場所ですか?",
|
||||
"Not drawn: {parts}": "未描画:{parts}",
|
||||
"approximate": "概算",
|
||||
"approximate": "近似",
|
||||
"{pct}% cloud": "雲量 {pct}%",
|
||||
"Over {area}": "{area}上空",
|
||||
"Over {area}": "範囲:{area}",
|
||||
"{count} of {total} acquisitions match": {
|
||||
"other": "一致した画像:{count}/{total}件"
|
||||
},
|
||||
@@ -2924,45 +2926,45 @@
|
||||
},
|
||||
"No matching imagery": "一致する画像はありません",
|
||||
"Unknown number of {kind}": "{kind}:件数不明",
|
||||
"About {count} {kind}": "{kind}:約{count}件",
|
||||
"At least {count} {kind}": "{kind}:{count}件以上",
|
||||
"{count} {kind}": "{kind}:{count}件",
|
||||
"About {count} {kind}": "{kind}:約{count}か所",
|
||||
"At least {count} {kind}": "{kind}:{count}か所以上",
|
||||
"{count} {kind}": "{kind}:{count}か所",
|
||||
"In {area}": "{area}内",
|
||||
"Area around {place}": "{place}周辺のエリア",
|
||||
"Area around landmark": "ランドマーク周辺のエリア",
|
||||
"Outline {place}": "{place}の輪郭を描く",
|
||||
"Outline area": "エリアの輪郭を描く",
|
||||
"Outline {place}": "{place}の輪郭を描画",
|
||||
"Outline area": "エリアの輪郭を描画",
|
||||
"Find {place}": "{place}を検索",
|
||||
"Find area": "エリアを検索",
|
||||
"Find recent imagery": "最近の衛星画像を検索",
|
||||
"Find {what}": "{what}を検索",
|
||||
"Find places": "場所を検索",
|
||||
"Drawing outline": "輪郭を作図中",
|
||||
"Drawing outline": "輪郭を地図に描画中",
|
||||
"Searching imagery catalog": "画像カタログを検索中",
|
||||
"Loading imagery": "画像を読み込み中",
|
||||
"Searching OpenStreetMap": "OpenStreetMapを検索中",
|
||||
"voice area|Area": "エリア",
|
||||
"buffer sized to the place": "場所の大きさに合わせたバッファ",
|
||||
"enclosing mapped area": "周囲の地図上のエリア",
|
||||
"the map was cleared while looking it up": "検索中に地図がクリアされました",
|
||||
"buffer sized to the place": "場所の大きさに合わせたバッファー",
|
||||
"enclosing mapped area": "ランドマークを含むマップ済みのエリア",
|
||||
"the map was cleared while looking it up": "検索中に地図が消去されました",
|
||||
"OpenStreetMap coverage varies by place": "OpenStreetMapの網羅度は場所によって異なります",
|
||||
"the catalog covers the last 30 days": "カタログは過去 30 日分です",
|
||||
"the catalog covers the last 30 days": "カタログの対象は過去30日間です",
|
||||
"there are more": "ほかにもあります",
|
||||
"{radius} m around the landmark": "ランドマークの周囲 {radius} m",
|
||||
"{radius} m around the landmark": "ランドマークから半径{radius} m",
|
||||
"{count} parts": {
|
||||
"other": "パーツ数: {count}"
|
||||
"other": "パーツ数:{count}"
|
||||
},
|
||||
"{drawn} of {total} parts drawn": {
|
||||
"other": "描画したパーツ: {drawn}/{total}"
|
||||
"other": "描画したパーツ:{drawn}/{total}"
|
||||
},
|
||||
"{count} duplicate mappings merged": {
|
||||
"other": "統合した重複: {count}"
|
||||
"other": "重複登録{count}件を統合"
|
||||
},
|
||||
"listed the first {limit}": "最初の{limit}件を表示",
|
||||
"counted over the bounding box of {area}, not its outline": "外接矩形 ({area}) で数えた結果で、輪郭ではありません",
|
||||
"counted over the bounding box of {area}, not its outline": "{area}の輪郭ではなく外接矩形の範囲で数えています",
|
||||
"Search cancelled": "検索をキャンセルしました",
|
||||
"Sea Marks": "航路標識",
|
||||
"Ski Pistes": "スキーゲレンデ",
|
||||
"Ski Pistes": "スキーコース",
|
||||
"Fly to Tokyo": "東京へ移動して",
|
||||
"Show live flights": "ライブフライトを表示して",
|
||||
"Reset to globe view": "地球全体ビューに戻して",
|
||||
@@ -3091,5 +3093,12 @@
|
||||
"Open {name} on Acartia": "Acartiaで{name}を開く",
|
||||
"iNaturalist": "iNaturalist",
|
||||
"iNaturalist contributors · licences per record": "iNaturalist の投稿者 · ライセンスは記録ごと",
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "iNaturalist の投稿者 · Acartia {count}(セイリッシュ海) · ライセンスは記録ごと"
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "iNaturalist の投稿者 · Acartia {count}(セイリッシュ海) · ライセンスは記録ごと",
|
||||
"{days}d": "{days}日",
|
||||
"All markers": "すべてのマーカー",
|
||||
"Show markers of every age": "日付にかかわらずすべてのマーカーを表示",
|
||||
"Maximum event age: {days}d; undated markers are kept": "イベントの最大経過日数:{days}日。日付のないマーカーは保持",
|
||||
"event age filter|All": "すべて",
|
||||
"Event age": "経過日数",
|
||||
"Event age: {days}d · undated kept": "経過日数:{days}日以内 · 日付なしは保持"
|
||||
}
|
||||
+32
-23
@@ -2807,6 +2807,8 @@
|
||||
"AVAILABLE MISSIONS": "MISSÕES DISPONÍVEIS",
|
||||
"SELECT A MISSION TO INSPECT": "SELECIONE UMA MISSÃO PARA INSPECIONAR",
|
||||
"LOADING 30-DAY MISSION INDEX": "CARREGANDO ÍNDICE DE MISSÕES DE 30 DIAS",
|
||||
"Space Missions unavailable: the mission index could not load. Try again later.": "Missões espaciais indisponíveis: não foi possível carregar o índice de missões. Tente novamente mais tarde.",
|
||||
"30-DAY MISSION INDEX UNAVAILABLE": "ÍNDICE DE MISSÕES DE 30 DIAS INDISPONÍVEL",
|
||||
"TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB MOSTRA PRÉVIA · ENTER / SPACE SELECIONA",
|
||||
"Internet radio and local SDR": "Rádio pela internet e SDR local",
|
||||
"STATION TAG": "TAG DA ESTAÇÃO",
|
||||
@@ -2987,14 +2989,14 @@
|
||||
"Up to {upper}": "Até {upper}",
|
||||
"From {lower}": "A partir de {lower}",
|
||||
"Informational only · not for flight planning": "Apenas informativo · não usar para planejamento de voo",
|
||||
"Unnamed zone": "Zona sem nome",
|
||||
"Unnamed zone": "Sem nome",
|
||||
"Restricted airspace source unavailable": "Fonte de espaço aéreo restrito indisponível",
|
||||
"Prohibited (P)": "Área proibida (P)",
|
||||
"Restricted (R)": "Área restrita (R)",
|
||||
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Apenas áreas proibidas e restritas da FAA (espaço aéreo de uso especial), sem o UAS Facility Map ou as restrições de voo de UAS. Apenas informativo — não substitui as ferramentas oficiais de planejamento de voo de UAS (B4UFLY / LAANC).",
|
||||
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Somente o espaço aéreo de uso especial proibido e restrito da FAA; não inclui o UAS Facility Map nem as restrições de voo de UAS. Apenas informativo — não substitui as ferramentas oficiais de planejamento de voo de UAS (B4UFLY / LAANC).",
|
||||
"Prohibited": "Área proibida",
|
||||
"Restricted": "Área restrita",
|
||||
"Prohibited & Restricted Airspace": "Áreas proibidas e restritas",
|
||||
"Prohibited & Restricted Airspace": "Espaço aéreo proibido e restrito",
|
||||
"scene|PLAY": "INICIAR",
|
||||
"AUTHORING": "CRIAÇÃO",
|
||||
"Create and edit scenes": "Criar e editar cenas",
|
||||
@@ -3034,7 +3036,7 @@
|
||||
"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",
|
||||
"{shown} of {total} shown": "Detecções exibidas: {shown} de {total}",
|
||||
"Name of {name}": "Nome de {name}",
|
||||
"Change the colour of {name}": "Mudar a cor de {name}",
|
||||
"Colour of {name}": "Cor de {name}",
|
||||
@@ -3053,18 +3055,18 @@
|
||||
"Which {place}?": "Qual {place}?",
|
||||
"Which place?": "Qual lugar?",
|
||||
"Not drawn: {parts}": "Não desenhado: {parts}",
|
||||
"approximate": "aproximado",
|
||||
"approximate": "aproximada",
|
||||
"{pct}% cloud": "{pct}% de nuvens",
|
||||
"Over {area}": "Área: {area}",
|
||||
"{count} of {total} acquisitions match": {
|
||||
"many": "Aquisições correspondentes: {count} de {total}",
|
||||
"one": "Aquisições correspondentes: {count} de {total}",
|
||||
"other": "Aquisições correspondentes: {count} de {total}"
|
||||
"one": "Imagens correspondentes: {count} de {total}",
|
||||
"many": "Imagens correspondentes: {count} de {total}",
|
||||
"other": "Imagens correspondentes: {count} de {total}"
|
||||
},
|
||||
"{n} acquisitions searched": {
|
||||
"many": "Aquisições pesquisadas: {n}",
|
||||
"one": "Aquisições pesquisadas: {n}",
|
||||
"other": "Aquisições pesquisadas: {n}"
|
||||
"one": "Imagens pesquisadas: {n}",
|
||||
"many": "Imagens pesquisadas: {n}",
|
||||
"other": "Imagens pesquisadas: {n}"
|
||||
},
|
||||
"No matching imagery": "Nenhuma imagem correspondente",
|
||||
"Unknown number of {kind}": "{kind}: número desconhecido",
|
||||
@@ -3076,23 +3078,23 @@
|
||||
"Area around landmark": "Área ao redor de um ponto de referência",
|
||||
"Outline {place}": "Traçar contorno de {place}",
|
||||
"Outline area": "Traçar contorno da área",
|
||||
"Find {place}": "Encontrar {place}",
|
||||
"Find area": "Encontrar área",
|
||||
"Find recent imagery": "Encontrar imagens recentes",
|
||||
"Find {what}": "Encontrar {what}",
|
||||
"Find places": "Encontrar lugares",
|
||||
"Find {place}": "Buscar {place}",
|
||||
"Find area": "Buscar área",
|
||||
"Find recent imagery": "Buscar imagens recentes",
|
||||
"Find {what}": "Buscar {what}",
|
||||
"Find places": "Buscar lugares",
|
||||
"Drawing outline": "Desenhando contorno",
|
||||
"Searching imagery catalog": "Buscando no catálogo de imagens",
|
||||
"Loading imagery": "Carregando imagens",
|
||||
"Searching OpenStreetMap": "Buscando no OpenStreetMap",
|
||||
"voice area|Area": "Área",
|
||||
"buffer sized to the place": "zona de margem do tamanho do local",
|
||||
"enclosing mapped area": "área mapeada envolvente",
|
||||
"buffer sized to the place": "círculo do tamanho aproximado do lugar",
|
||||
"enclosing mapped area": "área mapeada que contém o ponto de referência",
|
||||
"the map was cleared while looking it up": "o mapa foi limpo durante a busca",
|
||||
"OpenStreetMap coverage varies by place": "A cobertura do OpenStreetMap varia de lugar para lugar",
|
||||
"the catalog covers the last 30 days": "o catálogo cobre os últimos 30 dias",
|
||||
"there are more": "há mais",
|
||||
"{radius} m around the landmark": "{radius} m ao redor do local",
|
||||
"{radius} m around the landmark": "{radius} m ao redor do ponto de referência",
|
||||
"{count} parts": {
|
||||
"many": "Partes: {count}",
|
||||
"one": "Partes: {count}",
|
||||
@@ -3108,10 +3110,10 @@
|
||||
"one": "Duplicatas mescladas: {count}",
|
||||
"other": "Duplicatas mescladas: {count}"
|
||||
},
|
||||
"listed the first {limit}": "listados os primeiros {limit}",
|
||||
"counted over the bounding box of {area}, not its outline": "contado na caixa delimitadora ({area}), não no contorno",
|
||||
"listed the first {limit}": "lista limitada a {limit}",
|
||||
"counted over the bounding box of {area}, not its outline": "contagem na caixa delimitadora de {area}, não no contorno",
|
||||
"Search cancelled": "Busca cancelada",
|
||||
"Sea Marks": "Sinais marítimos",
|
||||
"Sea Marks": "Sinalização náutica",
|
||||
"Ski Pistes": "Pistas de esqui",
|
||||
"Fly to Tokyo": "Voe para Tóquio",
|
||||
"Show live flights": "Mostre os voos ao vivo",
|
||||
@@ -3241,5 +3243,12 @@
|
||||
"Open {name} on Acartia": "Abrir {name} no Acartia",
|
||||
"iNaturalist": "iNaturalist",
|
||||
"iNaturalist contributors · licences per record": "Colaboradores do iNaturalist · licenças por registro",
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Colaboradores do iNaturalist · Acartia {count} (mar de Salish) · licenças por registro"
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Colaboradores do iNaturalist · Acartia {count} (mar de Salish) · licenças por registro",
|
||||
"{days}d": "{days}d",
|
||||
"All markers": "Todos os marcadores",
|
||||
"Show markers of every age": "Mostrar marcadores de qualquer idade",
|
||||
"Maximum event age: {days}d; undated markers are kept": "Idade máxima do evento: {days} dias; marcadores sem data mantidos",
|
||||
"event age filter|All": "Todos",
|
||||
"Event age": "Idade do evento",
|
||||
"Event age: {days}d · undated kept": "Idade do evento: {days} dias · sem data mantidos"
|
||||
}
|
||||
+30
-21
@@ -128,7 +128,7 @@
|
||||
"SRC {value}": "ИСТ. {value}",
|
||||
"{value} CONTEXT": "КОНТЕКСТ {value}",
|
||||
"Focus fire detection {title}, {details}": "Фокус на очаге пожара {title}, {details}",
|
||||
"{value} conf": "увер.: {value}",
|
||||
"{value} conf": "дост.: {value}",
|
||||
"{value} ago": "{value} назад",
|
||||
"sensor n/a": "сенсор н/д",
|
||||
"FIRE · {frp} MW": "ПОЖАР · {frp} МВт",
|
||||
@@ -722,6 +722,8 @@
|
||||
"Contact Context actions": "Действия контекста контакта",
|
||||
"Available Space Missions": "Доступные космические миссии",
|
||||
"LOADING 30-DAY MISSION INDEX": "ЗАГРУЗКА ИНДЕКСА МИССИЙ ЗА 30 ДНЕЙ",
|
||||
"Space Missions unavailable: the mission index could not load. Try again later.": "Космические миссии недоступны: не удалось загрузить список миссий. Повторите попытку позже.",
|
||||
"30-DAY MISSION INDEX UNAVAILABLE": "ИНДЕКС МИССИЙ ЗА 30 ДНЕЙ НЕДОСТУПЕН",
|
||||
"Radio playback": "Воспроизведение радио",
|
||||
"Radio volume": "Громкость радио",
|
||||
"STATION TAG": "ТЕГ СТАНЦИИ",
|
||||
@@ -3062,27 +3064,27 @@
|
||||
"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": "Только детекции высокой достоверности",
|
||||
"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}",
|
||||
"Detection confidence": "Достоверность обнаружения",
|
||||
"{shown} of {total} shown": "Показано {shown} из {total}",
|
||||
"{lower} to {upper}": "От {lower} до {upper}",
|
||||
"Up to {upper}": "До {upper}",
|
||||
"From {lower}": "От {lower}",
|
||||
"Informational only · not for flight planning": "Только для информации · не для планирования полётов",
|
||||
"Unnamed zone": "Зона без названия",
|
||||
"Restricted airspace source unavailable": "Источник данных об ограниченном воздушном пространстве недоступен",
|
||||
"Unnamed zone": "Без названия",
|
||||
"Restricted airspace source unavailable": "Источник данных о зонах ограничения полётов недоступен",
|
||||
"Prohibited (P)": "Запретная зона (P)",
|
||||
"Restricted (R)": "Зона ограничения (R)",
|
||||
"Restricted (R)": "Зона ограничения полётов (R)",
|
||||
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Только запретные зоны и зоны ограничения полётов FAA (воздушное пространство особого использования), без UAS Facility Map и ограничений полётов БАС. Только для информации — не заменяет официальные инструменты планирования полётов БАС (B4UFLY / LAANC).",
|
||||
"Prohibited": "Запретная зона",
|
||||
"Restricted": "Зона ограничения",
|
||||
"Restricted": "Зона ограничения полётов",
|
||||
"Prohibited & Restricted Airspace": "Запретные зоны и зоны ограничения",
|
||||
"routing busy — too many routes at once": "сервис маршрутов занят: слишком много маршрутов одновременно",
|
||||
"route proxy error": "ошибка прокси маршрутов",
|
||||
@@ -3116,13 +3118,13 @@
|
||||
"Directions, drawing and your own files": "Маршруты, рисование и ваши файлы",
|
||||
"Voice place search (needs an Overpass server)": "Голосовой поиск мест (нужен сервер Overpass)",
|
||||
"OSM Places": "Места OSM",
|
||||
"state or province": "штат или провинция",
|
||||
"state or province": "штат, провинция или область",
|
||||
"county or district": "округ или район",
|
||||
"neighbourhood": "район города",
|
||||
"natural region": "природный регион",
|
||||
"Which {place}?": "Какой из вариантов «{place}»?",
|
||||
"Which place?": "Какое место?",
|
||||
"Not drawn: {parts}": "Не нарисовано: {parts}",
|
||||
"Not drawn: {parts}": "Контур не нарисован: {parts}",
|
||||
"approximate": "приблизительно",
|
||||
"{pct}% cloud": "Облачность {pct}%",
|
||||
"Over {area}": "Область: {area}",
|
||||
@@ -3143,7 +3145,7 @@
|
||||
"About {count} {kind}": "{kind}: около {count}",
|
||||
"At least {count} {kind}": "{kind}: не менее {count}",
|
||||
"{count} {kind}": "{kind}: {count}",
|
||||
"In {area}": "Территория: {area}",
|
||||
"In {area}": "Область: {area}",
|
||||
"Area around {place}": "Окрестности: {place}",
|
||||
"Area around landmark": "Окрестности ориентира",
|
||||
"Outline {place}": "Контур: {place}",
|
||||
@@ -3159,12 +3161,12 @@
|
||||
"Searching OpenStreetMap": "Поиск в OpenStreetMap",
|
||||
"voice area|Area": "Область",
|
||||
"buffer sized to the place": "буферная зона по размеру места",
|
||||
"enclosing mapped area": "охватывающая область на карте",
|
||||
"enclosing mapped area": "область на карте, включающая ориентир",
|
||||
"the map was cleared while looking it up": "карта была очищена во время поиска",
|
||||
"OpenStreetMap coverage varies by place": "Покрытие OpenStreetMap различается в разных местах",
|
||||
"the catalog covers the last 30 days": "каталог охватывает последние 30 дней",
|
||||
"there are more": "есть ещё",
|
||||
"{radius} m around the landmark": "{radius} м вокруг объекта",
|
||||
"there are more": "есть и другие",
|
||||
"{radius} m around the landmark": "{radius} м вокруг ориентира",
|
||||
"{count} parts": {
|
||||
"few": "Частей: {count}",
|
||||
"many": "Частей: {count}",
|
||||
@@ -3183,11 +3185,11 @@
|
||||
"one": "Объединено дублей: {count}",
|
||||
"other": "Объединено дублей: {count}"
|
||||
},
|
||||
"listed the first {limit}": "показаны первые {limit}",
|
||||
"listed the first {limit}": "показаны первые результаты: {limit}",
|
||||
"counted over the bounding box of {area}, not its outline": "подсчитано по ограничивающему прямоугольнику ({area}), а не по контуру",
|
||||
"Search cancelled": "Поиск отменён",
|
||||
"Sea Marks": "Морские знаки",
|
||||
"Ski Pistes": "Горнолыжные трассы",
|
||||
"Sea Marks": "Навигационные знаки",
|
||||
"Ski Pistes": "Лыжные трассы",
|
||||
"Fly to Tokyo": "Перелети в Токио",
|
||||
"Show live flights": "Покажи рейсы в реальном времени",
|
||||
"Reset to globe view": "Вернись к виду всего глобуса",
|
||||
@@ -3316,5 +3318,12 @@
|
||||
"Open {name} on Acartia": "Открыть {name} в Acartia",
|
||||
"iNaturalist": "iNaturalist",
|
||||
"iNaturalist contributors · licences per record": "Участники iNaturalist · лицензия у каждой записи",
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Участники iNaturalist · Acartia {count} (море Сэлиш) · лицензия у каждой записи"
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Участники iNaturalist · Acartia {count} (море Сэлиш) · лицензия у каждой записи",
|
||||
"{days}d": "{days}д",
|
||||
"All markers": "Все метки",
|
||||
"Show markers of every age": "Показать метки любого возраста",
|
||||
"Maximum event age: {days}d; undated markers are kept": "Максимальный возраст события: {days} дн.; метки без даты сохранены",
|
||||
"event age filter|All": "Все",
|
||||
"Event age": "Возраст события",
|
||||
"Event age: {days}d · undated kept": "Возраст события: {days} дн. · метки без даты сохранены"
|
||||
}
|
||||
+31
-22
@@ -2875,6 +2875,8 @@
|
||||
"AVAILABLE MISSIONS": "ДОСТУПНІ МІСІЇ",
|
||||
"SELECT A MISSION TO INSPECT": "ВИБЕРІТЬ МІСІЮ ДЛЯ ПЕРЕГЛЯДУ",
|
||||
"LOADING 30-DAY MISSION INDEX": "ЗАВАНТАЖЕННЯ ІНДЕКСУ МІСІЙ ЗА 30 ДНІВ",
|
||||
"Space Missions unavailable: the mission index could not load. Try again later.": "Космічні місії недоступні: не вдалося завантажити список місій. Спробуйте пізніше.",
|
||||
"30-DAY MISSION INDEX UNAVAILABLE": "ІНДЕКС МІСІЙ ЗА 30 ДНІВ НЕДОСТУПНИЙ",
|
||||
"TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB — ОГЛЯД · ENTER / SPACE — ВИБІР",
|
||||
"Internet radio and local SDR": "Інтернет-радіо та локальний SDR",
|
||||
"STATION TAG": "ТЕГ СТАНЦІЇ",
|
||||
@@ -3063,19 +3065,19 @@
|
||||
"Minimum fire radiative power, megawatts": "Мінімальна потужність випромінювання пожежі, мегавати",
|
||||
"fire intensity filter|All": "Усі",
|
||||
"Detection confidence": "Достовірність виявлення",
|
||||
"{shown} of {total} shown": "показано {shown} з {total}",
|
||||
"{lower} to {upper}": "від {lower} до {upper}",
|
||||
"{shown} of {total} shown": "Показано {shown} з {total}",
|
||||
"{lower} to {upper}": "Від {lower} до {upper}",
|
||||
"Up to {upper}": "До {upper}",
|
||||
"From {lower}": "Від {lower}",
|
||||
"Informational only · not for flight planning": "Лише для довідки · не для планування польотів",
|
||||
"Unnamed zone": "Зона без назви",
|
||||
"Restricted airspace source unavailable": "Джерело даних про обмежений повітряний простір недоступне",
|
||||
"Unnamed zone": "Без назви",
|
||||
"Restricted airspace source unavailable": "Джерело даних про зони обмеження польотів недоступне",
|
||||
"Prohibited (P)": "Заборонена зона (P)",
|
||||
"Restricted (R)": "Зона обмеження (R)",
|
||||
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Лише заборонені зони та зони обмеження FAA (повітряний простір особливого використання), без UAS Facility Map і обмежень польотів БпЛА. Лише для довідки — не замінює офіційні інструменти планування польотів БпЛА (B4UFLY / LAANC).",
|
||||
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Лише заборонені зони та зони обмеження польотів FAA (повітряний простір особливого використання), без UAS Facility Map і обмежень польотів БпЛА. Лише для довідки — не замінює офіційні інструменти планування польотів БпЛА (B4UFLY / LAANC).",
|
||||
"Prohibited": "Заборонена зона",
|
||||
"Restricted": "Зона обмеження",
|
||||
"Prohibited & Restricted Airspace": "Заборонені зони та зони обмеження",
|
||||
"Prohibited & Restricted Airspace": "Заборонені зони та зони обмеження польотів",
|
||||
"Malformed cyclone snapshot": "Некоректний знімок циклонів",
|
||||
"Cyclone request timed out": "Минув час очікування запиту циклонів",
|
||||
"scene|PLAY": "СТАРТ",
|
||||
@@ -3116,7 +3118,7 @@
|
||||
"Directions, drawing and your own files": "Маршрути, малювання та ваші файли",
|
||||
"Voice place search (needs an Overpass server)": "Голосовий пошук місць (потрібен сервер Overpass)",
|
||||
"OSM Places": "Місця OSM",
|
||||
"state or province": "штат або провінція",
|
||||
"state or province": "область, штат або провінція",
|
||||
"county or district": "округ або район",
|
||||
"neighbourhood": "район міста",
|
||||
"natural region": "природний регіон",
|
||||
@@ -3147,9 +3149,9 @@
|
||||
"Area around {place}": "Околиці: {place}",
|
||||
"Area around landmark": "Околиці орієнтира",
|
||||
"Outline {place}": "Контур: {place}",
|
||||
"Outline area": "Контур області",
|
||||
"Outline area": "Контур території",
|
||||
"Find {place}": "Пошук: {place}",
|
||||
"Find area": "Знайти область",
|
||||
"Find area": "Знайти територію",
|
||||
"Find recent imagery": "Знайти свіжі знімки",
|
||||
"Find {what}": "Пошук: {what}",
|
||||
"Find places": "Знайти місця",
|
||||
@@ -3157,14 +3159,14 @@
|
||||
"Searching imagery catalog": "Пошук у каталозі знімків",
|
||||
"Loading imagery": "Завантаження знімків",
|
||||
"Searching OpenStreetMap": "Пошук в OpenStreetMap",
|
||||
"voice area|Area": "Область",
|
||||
"voice area|Area": "Територія",
|
||||
"buffer sized to the place": "буферна зона за розміром місця",
|
||||
"enclosing mapped area": "охопна область на карті",
|
||||
"enclosing mapped area": "нанесений контур, що охоплює орієнтир",
|
||||
"the map was cleared while looking it up": "карту було очищено під час пошуку",
|
||||
"OpenStreetMap coverage varies by place": "Покриття OpenStreetMap відрізняється залежно від місця",
|
||||
"OpenStreetMap coverage varies by place": "покриття OpenStreetMap різниться залежно від місця",
|
||||
"the catalog covers the last 30 days": "каталог охоплює останні 30 днів",
|
||||
"there are more": "є ще",
|
||||
"{radius} m around the landmark": "{radius} м навколо об’єкта",
|
||||
"there are more": "є й інші",
|
||||
"{radius} m around the landmark": "{radius} м навколо орієнтира",
|
||||
"{count} parts": {
|
||||
"few": "Частин: {count}",
|
||||
"many": "Частин: {count}",
|
||||
@@ -3178,16 +3180,16 @@
|
||||
"other": "Намальовано частин: {drawn} з {total}"
|
||||
},
|
||||
"{count} duplicate mappings merged": {
|
||||
"few": "Об’єднано дублікатів: {count}",
|
||||
"many": "Об’єднано дублікатів: {count}",
|
||||
"one": "Об’єднано дублікатів: {count}",
|
||||
"other": "Об’єднано дублікатів: {count}"
|
||||
"one": "Обʼєднано дублікатів: {count}",
|
||||
"few": "Обʼєднано дублікатів: {count}",
|
||||
"many": "Обʼєднано дублікатів: {count}",
|
||||
"other": "Обʼєднано дублікатів: {count}"
|
||||
},
|
||||
"listed the first {limit}": "показано перші {limit}",
|
||||
"listed the first {limit}": "показано перші результати: {limit}",
|
||||
"counted over the bounding box of {area}, not its outline": "підраховано за обмежувальним прямокутником ({area}), а не за контуром",
|
||||
"Search cancelled": "Пошук скасовано",
|
||||
"Sea Marks": "Морські знаки",
|
||||
"Ski Pistes": "Гірськолижні траси",
|
||||
"Sea Marks": "Навігаційні знаки",
|
||||
"Ski Pistes": "Лижні траси",
|
||||
"Fly to Tokyo": "Полети до Токіо",
|
||||
"Show live flights": "Покажи рейси онлайн",
|
||||
"Reset to globe view": "Поверни вигляд глобуса",
|
||||
@@ -3316,5 +3318,12 @@
|
||||
"Open {name} on Acartia": "Відкрити {name} в Acartia",
|
||||
"iNaturalist": "iNaturalist",
|
||||
"iNaturalist contributors · licences per record": "Учасники iNaturalist · ліцензія в кожного запису",
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Учасники iNaturalist · Acartia {count} (море Селіш) · ліцензія в кожного запису"
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Учасники iNaturalist · Acartia {count} (море Селіш) · ліцензія в кожного запису",
|
||||
"{days}d": "{days}д",
|
||||
"All markers": "Усі позначки",
|
||||
"Show markers of every age": "Показати позначки будь-якого віку",
|
||||
"Maximum event age: {days}d; undated markers are kept": "Максимальний вік події: {days} дн.; позначки без дати збережено",
|
||||
"event age filter|All": "Усі",
|
||||
"Event age": "Вік події",
|
||||
"Event age: {days}d · undated kept": "Вік події: {days} дн. · позначки без дати збережено"
|
||||
}
|
||||
+30
-21
@@ -2562,6 +2562,8 @@
|
||||
"AVAILABLE MISSIONS": "可用任务",
|
||||
"SELECT A MISSION TO INSPECT": "选择一项任务以查看",
|
||||
"LOADING 30-DAY MISSION INDEX": "正在加载 30 天任务索引",
|
||||
"Space Missions unavailable: the mission index could not load. Try again later.": "太空任务不可用:无法加载任务索引。请稍后重试。",
|
||||
"30-DAY MISSION INDEX UNAVAILABLE": "30 天任务索引不可用",
|
||||
"TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB 预览 · ENTER / 空格键选择",
|
||||
"Internet radio and local SDR": "网络电台与本地 SDR",
|
||||
"STATION TAG": "电台标签",
|
||||
@@ -2850,26 +2852,26 @@
|
||||
},
|
||||
"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|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)",
|
||||
"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": "检测置信度",
|
||||
"Detection confidence": "探测置信度",
|
||||
"{shown} of {total} shown": "显示 {shown} / {total}",
|
||||
"{lower} to {upper}": "{lower} 至 {upper}",
|
||||
"Up to {upper}": "最高 {upper}",
|
||||
"From {lower}": "自 {lower} 起",
|
||||
"From {lower}": "最低 {lower}",
|
||||
"Informational only · not for flight planning": "仅供参考 · 不可用于飞行规划",
|
||||
"Unnamed zone": "未命名区域",
|
||||
"Restricted airspace source unavailable": "受限空域数据源不可用",
|
||||
"Prohibited (P)": "禁区 (P)",
|
||||
"Restricted (R)": "限制区 (R)",
|
||||
"Unnamed zone": "未命名空域",
|
||||
"Restricted airspace source unavailable": "禁区与限制区空域数据源不可用",
|
||||
"Prohibited (P)": "禁区(P)",
|
||||
"Restricted (R)": "限制区(R)",
|
||||
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "仅包含 FAA 禁区和限制区特殊用途空域,不含 UAS Facility Map 和无人机飞行限制。仅供参考,不能替代官方无人机飞行规划工具(B4UFLY / LAANC)。",
|
||||
"Prohibited": "禁区",
|
||||
"Restricted": "限制区",
|
||||
@@ -2915,7 +2917,7 @@
|
||||
"Not drawn: {parts}": "未绘制:{parts}",
|
||||
"approximate": "近似",
|
||||
"{pct}% cloud": "云量 {pct}%",
|
||||
"Over {area}": "{area} 上空",
|
||||
"Over {area}": "覆盖 {area}",
|
||||
"{count} of {total} acquisitions match": {
|
||||
"other": "匹配影像:{count}/{total}"
|
||||
},
|
||||
@@ -2930,36 +2932,36 @@
|
||||
"In {area}": "位于 {area}",
|
||||
"Area around {place}": "{place} 周边区域",
|
||||
"Area around landmark": "地标周边区域",
|
||||
"Outline {place}": "勾勒 {place} 轮廓",
|
||||
"Outline area": "勾勒区域轮廓",
|
||||
"Outline {place}": "绘制 {place} 轮廓",
|
||||
"Outline area": "绘制区域轮廓",
|
||||
"Find {place}": "查找 {place}",
|
||||
"Find area": "查找区域",
|
||||
"Find recent imagery": "查找近期影像",
|
||||
"Find {what}": "查找 {what}",
|
||||
"Find places": "查找地点",
|
||||
"Drawing outline": "正在勾勒轮廓",
|
||||
"Drawing outline": "正在地图上绘制轮廓",
|
||||
"Searching imagery catalog": "正在搜索影像目录",
|
||||
"Loading imagery": "正在加载影像",
|
||||
"Searching OpenStreetMap": "正在搜索 OpenStreetMap",
|
||||
"voice area|Area": "区域",
|
||||
"buffer sized to the place": "按地点大小设置的缓冲区",
|
||||
"enclosing mapped area": "包含该地点的地图区域",
|
||||
"enclosing mapped area": "包含该地标的地图标注区域",
|
||||
"the map was cleared while looking it up": "查找期间地图已清空",
|
||||
"OpenStreetMap coverage varies by place": "OpenStreetMap 的覆盖程度因地而异",
|
||||
"the catalog covers the last 30 days": "目录涵盖最近 30 天",
|
||||
"there are more": "还有更多",
|
||||
"{radius} m around the landmark": "地标周围 {radius} 米",
|
||||
"{radius} m around the landmark": "地标周围 {radius} m",
|
||||
"{count} parts": {
|
||||
"other": "部分数:{count}"
|
||||
"other": "{count} 个部分"
|
||||
},
|
||||
"{drawn} of {total} parts drawn": {
|
||||
"other": "已绘制部分:{drawn}/{total}"
|
||||
"other": "已绘制 {drawn}/{total} 个部分"
|
||||
},
|
||||
"{count} duplicate mappings merged": {
|
||||
"other": "已合并重复项:{count}"
|
||||
},
|
||||
"listed the first {limit}": "已列出前 {limit} 个",
|
||||
"counted over the bounding box of {area}, not its outline": "按外接矩形({area})计数,而非其轮廓",
|
||||
"counted over the bounding box of {area}, not its outline": "按 {area} 的外接矩形计数,而非其轮廓",
|
||||
"Search cancelled": "搜索已取消",
|
||||
"Sea Marks": "航标",
|
||||
"Ski Pistes": "滑雪道",
|
||||
@@ -3091,5 +3093,12 @@
|
||||
"Open {name} on Acartia": "在 Acartia 上打开 {name}",
|
||||
"iNaturalist": "iNaturalist",
|
||||
"iNaturalist contributors · licences per record": "iNaturalist 贡献者 · 各记录单独授权",
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "iNaturalist 贡献者 · Acartia {count}(萨利希海) · 各记录单独授权"
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "iNaturalist 贡献者 · Acartia {count}(萨利希海) · 各记录单独授权",
|
||||
"{days}d": "{days}天",
|
||||
"All markers": "所有标记",
|
||||
"Show markers of every age": "显示所有时间的标记",
|
||||
"Maximum event age: {days}d; undated markers are kept": "事件最长保留时间:{days}天;保留无日期标记",
|
||||
"event age filter|All": "全部",
|
||||
"Event age": "事件时间",
|
||||
"Event age: {days}d · undated kept": "事件时间:{days}天内 · 保留无日期标记"
|
||||
}
|
||||
@@ -2550,6 +2550,8 @@
|
||||
"AVAILABLE MISSIONS": "可用任務",
|
||||
"SELECT A MISSION TO INSPECT": "選擇一項任務以查看",
|
||||
"LOADING 30-DAY MISSION INDEX": "正在加載 30 天任務索引",
|
||||
"Space Missions unavailable: the mission index could not load. Try again later.": "太空任務無法使用:無法載入任務索引。請稍後再試。",
|
||||
"30-DAY MISSION INDEX UNAVAILABLE": "30 天任務索引不可用",
|
||||
"TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB 預覽 · ENTER / 空格鍵選擇",
|
||||
"Internet radio and local SDR": "網絡電臺與本地 SDR",
|
||||
"STATION TAG": "電臺標籤",
|
||||
@@ -2853,16 +2855,16 @@
|
||||
"route proxy error": "路線代理出錯",
|
||||
"{lower} to {upper}": "{lower} 至 {upper}",
|
||||
"Up to {upper}": "最高 {upper}",
|
||||
"From {lower}": "自 {lower} 起",
|
||||
"From {lower}": "最低 {lower}",
|
||||
"Informational only · not for flight planning": "僅供參考 · 不可用於飛行規劃",
|
||||
"Unnamed zone": "未命名區域",
|
||||
"Restricted airspace source unavailable": "限制空域資料來源無法使用",
|
||||
"Prohibited (P)": "禁航區 (P)",
|
||||
"Restricted (R)": "限航區 (R)",
|
||||
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "僅包含 FAA 禁航區與限航區特殊用途空域,不含 UAS Facility Map 與無人機飛行限制。僅供參考,不能取代官方無人機飛行規劃工具(B4UFLY / LAANC)。",
|
||||
"Prohibited": "禁航區",
|
||||
"Restricted": "限航區",
|
||||
"Prohibited & Restricted Airspace": "禁航區與限航區空域",
|
||||
"Unnamed zone": "未命名空域",
|
||||
"Restricted airspace source unavailable": "禁區與限制區空域數據源不可用",
|
||||
"Prohibited (P)": "禁區(P)",
|
||||
"Restricted (R)": "限制區(R)",
|
||||
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "僅包含 FAA 禁區和限制區特殊用途空域,不含 UAS Facility Map 和無人機飛行限制。僅供參考,不能替代官方無人機飛行規劃工具(B4UFLY / LAANC)。",
|
||||
"Prohibited": "禁區",
|
||||
"Restricted": "限制區",
|
||||
"Prohibited & Restricted Airspace": "禁區與限制區空域",
|
||||
"AUTHORING": "創作",
|
||||
"Create and edit scenes": "創建和編輯場景",
|
||||
"SCENE": "場景",
|
||||
@@ -2889,17 +2891,17 @@
|
||||
"Left panels": "左側面板",
|
||||
"Right panels": "右側面板",
|
||||
"fire confidence filter|All": "全部",
|
||||
"fire confidence filter|Nominal+": "標稱+",
|
||||
"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)",
|
||||
"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": "檢測置信度",
|
||||
"Detection confidence": "探測置信度",
|
||||
"{shown} of {total} shown": "顯示 {shown} / {total}",
|
||||
"UTILITIES": "工具",
|
||||
"Exit cockpit to fly to a drawing": "請先退出座艙,再飛往繪製位置",
|
||||
@@ -2915,7 +2917,7 @@
|
||||
"Not drawn: {parts}": "未繪製:{parts}",
|
||||
"approximate": "近似",
|
||||
"{pct}% cloud": "雲量 {pct}%",
|
||||
"Over {area}": "{area} 上空",
|
||||
"Over {area}": "覆蓋 {area}",
|
||||
"{count} of {total} acquisitions match": {
|
||||
"other": "匹配影像:{count}/{total}"
|
||||
},
|
||||
@@ -2930,36 +2932,36 @@
|
||||
"In {area}": "位於 {area}",
|
||||
"Area around {place}": "{place} 周邊區域",
|
||||
"Area around landmark": "地標周邊區域",
|
||||
"Outline {place}": "勾勒 {place} 輪廓",
|
||||
"Outline area": "勾勒區域輪廓",
|
||||
"Outline {place}": "繪製 {place} 輪廓",
|
||||
"Outline area": "繪製區域輪廓",
|
||||
"Find {place}": "查找 {place}",
|
||||
"Find area": "查找區域",
|
||||
"Find recent imagery": "查找近期影像",
|
||||
"Find {what}": "查找 {what}",
|
||||
"Find places": "查找地點",
|
||||
"Drawing outline": "正在勾勒輪廓",
|
||||
"Drawing outline": "正在地圖上繪製輪廓",
|
||||
"Searching imagery catalog": "正在搜索影像目錄",
|
||||
"Loading imagery": "正在加載影像",
|
||||
"Searching OpenStreetMap": "正在搜索 OpenStreetMap",
|
||||
"voice area|Area": "區域",
|
||||
"buffer sized to the place": "按地點大小設置的緩衝區",
|
||||
"enclosing mapped area": "包含該地點的地圖區域",
|
||||
"enclosing mapped area": "包含該地標的地圖標註區域",
|
||||
"the map was cleared while looking it up": "查找期間地圖已清空",
|
||||
"OpenStreetMap coverage varies by place": "OpenStreetMap 的覆蓋程度因地而異",
|
||||
"the catalog covers the last 30 days": "目錄涵蓋最近 30 天",
|
||||
"there are more": "還有更多",
|
||||
"{radius} m around the landmark": "地標周圍 {radius} 米",
|
||||
"{radius} m around the landmark": "地標周圍 {radius} m",
|
||||
"{count} parts": {
|
||||
"other": "部分數:{count}"
|
||||
"other": "{count} 個部分"
|
||||
},
|
||||
"{drawn} of {total} parts drawn": {
|
||||
"other": "已繪製部分:{drawn}/{total}"
|
||||
"other": "已繪製 {drawn}/{total} 個部分"
|
||||
},
|
||||
"{count} duplicate mappings merged": {
|
||||
"other": "已合併重複項:{count}"
|
||||
},
|
||||
"listed the first {limit}": "已列出前 {limit} 個",
|
||||
"counted over the bounding box of {area}, not its outline": "按外接矩形({area})計數,而非其輪廓",
|
||||
"counted over the bounding box of {area}, not its outline": "按 {area} 的外接矩形計數,而非其輪廓",
|
||||
"Search cancelled": "搜索已取消",
|
||||
"Sea Marks": "航標",
|
||||
"Ski Pistes": "滑雪道",
|
||||
@@ -3091,5 +3093,12 @@
|
||||
"Open {name} on Acartia": "在 Acartia 上打開 {name}",
|
||||
"iNaturalist": "iNaturalist",
|
||||
"iNaturalist contributors · licences per record": "iNaturalist 貢獻者 · 各紀錄個別授權",
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "iNaturalist 貢獻者 · Acartia {count}(薩利希海) · 各紀錄個別授權"
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "iNaturalist 貢獻者 · Acartia {count}(薩利希海) · 各紀錄個別授權",
|
||||
"{days}d": "{days}天",
|
||||
"All markers": "所有標記",
|
||||
"Show markers of every age": "顯示所有時間的標記",
|
||||
"Maximum event age: {days}d; undated markers are kept": "事件最長保留時間:{days}天;保留無日期標記",
|
||||
"event age filter|All": "全部",
|
||||
"Event age": "事件時間",
|
||||
"Event age: {days}d · undated kept": "事件時間:{days}天內 · 保留無日期標記"
|
||||
}
|
||||
+154
-21
@@ -16,7 +16,15 @@ import {
|
||||
} from '../../i18n/index.js';
|
||||
import { createHorizonCull } from '../streetLevel/view.js';
|
||||
import { readGhostMapsKml } from './kml.js';
|
||||
import { fieldLabel, parseDescriptionFields, wrapCardText } from './records.js';
|
||||
import {
|
||||
fieldLabel,
|
||||
GHOSTMAPS_AGE_OPTIONS,
|
||||
GHOSTMAPS_DEFAULT_AGE_DAYS,
|
||||
ghostMapsEventDate,
|
||||
isGhostMapsEventExpired,
|
||||
parseDescriptionFields,
|
||||
wrapCardText,
|
||||
} from './records.js';
|
||||
export * from './catalog.js';
|
||||
export * from './records.js';
|
||||
export { readGhostMapsKml } from './kml.js';
|
||||
@@ -26,6 +34,7 @@ export const GHOSTMAPS_LAYER_ID = 's2-ghostmaps';
|
||||
export const GHOSTMAPS_OVERLAY_SOURCE_ID = 's2-ghostmaps';
|
||||
const PICK_PREFIX = 'ghostmaps:';
|
||||
const STORAGE_KEY = 'gev.ghostmaps.products';
|
||||
const AGE_STORAGE_KEY = 'gev.ghostmaps.ageDays';
|
||||
const ACCENT = '#c8ff00';
|
||||
const MAX_ICON_BYTES = 512 * 1024;
|
||||
const CARD_HOST_OPTIONS = Object.freeze({
|
||||
@@ -82,6 +91,19 @@ function writeStoredSelection(storage, selected) {
|
||||
}
|
||||
}
|
||||
|
||||
function readStoredAge(storage) {
|
||||
try {
|
||||
const raw = storage?.getItem(AGE_STORAGE_KEY);
|
||||
if (raw !== null && raw !== undefined) {
|
||||
const value = JSON.parse(raw);
|
||||
if (GHOSTMAPS_AGE_OPTIONS.includes(value)) return value;
|
||||
}
|
||||
} catch {
|
||||
/* unavailable or invalid storage uses the default */
|
||||
}
|
||||
return GHOSTMAPS_DEFAULT_AGE_DAYS;
|
||||
}
|
||||
|
||||
/** A round marker for placemarks whose icon is missing or remote. */
|
||||
const markerCache = new Map();
|
||||
function markerImage(color) {
|
||||
@@ -137,6 +159,7 @@ export function createGhostMapsLayer({
|
||||
pointer = null,
|
||||
openExternal = null,
|
||||
storage = defaultStorage(),
|
||||
now = () => Date.now(),
|
||||
} = {}) {
|
||||
if (typeof source?.getCatalog !== 'function')
|
||||
throw new TypeError('GhostMaps layer requires a catalog source');
|
||||
@@ -154,7 +177,9 @@ export function createGhostMapsLayer({
|
||||
let _selectedLink = null;
|
||||
let _lifetime = new AbortController();
|
||||
let _selection = readStoredSelection(storage);
|
||||
/** productId → {version, dataSource, urls, count, features} */
|
||||
let _ageDays = readStoredAge(storage);
|
||||
let _expiryTimer = null;
|
||||
/** productId → {version, dataSource, urls, features: Map<index, record>, pins} */
|
||||
const _loaded = new Map();
|
||||
/** productId → in-flight version */
|
||||
const _loading = new Map();
|
||||
@@ -183,17 +208,40 @@ export function createGhostMapsLayer({
|
||||
return (_catalog?.products || []).filter(wanted).map(({ id }) => id);
|
||||
}
|
||||
|
||||
function unload(id) {
|
||||
function unload(id, keepSelection = false) {
|
||||
const item = _loaded.get(id);
|
||||
if (!item) return;
|
||||
_loaded.delete(id);
|
||||
_viewer?.dataSources?.remove(item.dataSource, true);
|
||||
item.urls.forEach((url) => URL.revokeObjectURL(url));
|
||||
if (_selectedKey?.startsWith(`${id}:`)) clearSelection();
|
||||
if (!keepSelection && _selectedKey?.startsWith(`${id}:`)) clearSelection();
|
||||
}
|
||||
|
||||
// Remove expired entities, rather than hiding them: horizon culling must
|
||||
// never make an old pin visible again. Map keys keep surviving picks stable.
|
||||
function expireEvents() {
|
||||
const at = now();
|
||||
let changed = false;
|
||||
for (const [productId, item] of _loaded) {
|
||||
for (const [index, feature] of item.features) {
|
||||
if (!isGhostMapsEventExpired(feature.dateMs, at, _ageDays)) continue;
|
||||
for (const entity of feature.entities)
|
||||
item.dataSource.entities.remove(entity);
|
||||
item.features.delete(index);
|
||||
if (_selectedKey === `${productId}:${index}`) clearSelection();
|
||||
changed = true;
|
||||
}
|
||||
item.pins = item.pins.filter((pin) => item.features.has(pin.index));
|
||||
}
|
||||
if (changed) {
|
||||
notifyRow();
|
||||
render();
|
||||
}
|
||||
}
|
||||
|
||||
/** Build one product's data source off-scene, then swap it in. */
|
||||
async function load(product, signal) {
|
||||
const ageDays = _ageDays;
|
||||
const bytes = await source.getFile(product.id, product.version, { signal });
|
||||
signal.throwIfAborted();
|
||||
const entries = await readZipEntries(bytes);
|
||||
@@ -223,12 +271,16 @@ export function createGhostMapsLayer({
|
||||
const dataSource = new Cesium.CustomDataSource(
|
||||
`${GHOSTMAPS_LAYER_ID}:${product.id}`,
|
||||
);
|
||||
const features = [];
|
||||
const features = new Map();
|
||||
const pins = [];
|
||||
const at = now();
|
||||
dataSource.entities.suspendEvents();
|
||||
parsed.features.forEach((feature, index) => {
|
||||
const dateMs = ghostMapsEventDate(feature.description);
|
||||
if (isGhostMapsEventExpired(dateMs, at, ageDays)) return;
|
||||
const key = `${product.id}:${index}`;
|
||||
const { style } = feature;
|
||||
const entities = [];
|
||||
feature.geometries.forEach((geometry, part) => {
|
||||
const id = `${PICK_PREFIX}${key}:${part}`;
|
||||
if (geometry.type === 'point') {
|
||||
@@ -255,7 +307,9 @@ export function createGhostMapsLayer({
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
});
|
||||
entities.push(entity);
|
||||
pins.push({
|
||||
index,
|
||||
position,
|
||||
get show() {
|
||||
return entity.show;
|
||||
@@ -265,6 +319,7 @@ export function createGhostMapsLayer({
|
||||
},
|
||||
});
|
||||
} else if (geometry.type === 'line') {
|
||||
entities.push(
|
||||
dataSource.entities.add({
|
||||
id,
|
||||
polyline: {
|
||||
@@ -275,10 +330,12 @@ export function createGhostMapsLayer({
|
||||
material: cesiumColor(style.lineColor, color),
|
||||
clampToGround: true,
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
const ring = (points) =>
|
||||
Cesium.Cartesian3.fromDegreesArray(points.flat());
|
||||
entities.push(
|
||||
dataSource.entities.add({
|
||||
id,
|
||||
polygon: {
|
||||
@@ -298,8 +355,10 @@ export function createGhostMapsLayer({
|
||||
),
|
||||
classificationType: Cesium.ClassificationType.BOTH,
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
if (style.outline !== false)
|
||||
entities.push(
|
||||
dataSource.entities.add({
|
||||
id: `${id}:outline`,
|
||||
polyline: {
|
||||
@@ -308,18 +367,28 @@ export function createGhostMapsLayer({
|
||||
material: cesiumColor(style.lineColor, color),
|
||||
clampToGround: true,
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
features.push({
|
||||
features.set(index, {
|
||||
name: feature.name,
|
||||
folder: feature.folder,
|
||||
description: feature.description,
|
||||
dateMs,
|
||||
entities,
|
||||
anchor: anchorOf(feature.geometries[0]),
|
||||
});
|
||||
});
|
||||
dataSource.entities.resumeEvents();
|
||||
return { version: product.version, dataSource, urls, features, pins };
|
||||
return {
|
||||
version: product.version,
|
||||
ageDays,
|
||||
dataSource,
|
||||
urls,
|
||||
features,
|
||||
pins,
|
||||
};
|
||||
} catch (error) {
|
||||
urls.forEach((url) => URL.revokeObjectURL(url));
|
||||
throw error;
|
||||
@@ -330,6 +399,7 @@ export function createGhostMapsLayer({
|
||||
function sync() {
|
||||
const run = _queue.then(async () => {
|
||||
if (!_enabled || !_viewer || !_catalog) return;
|
||||
expireEvents();
|
||||
const signal = _lifetime.signal;
|
||||
const products = new Map(_catalog.products.map((p) => [p.id, p]));
|
||||
for (const id of [..._loaded.keys()]) {
|
||||
@@ -338,21 +408,33 @@ export function createGhostMapsLayer({
|
||||
}
|
||||
for (const product of _catalog.products) {
|
||||
if (!wanted(product) || signal.aborted) continue;
|
||||
if (_loaded.get(product.id)?.version === product.version) continue;
|
||||
const loaded = _loaded.get(product.id);
|
||||
if (loaded?.version === product.version && loaded.ageDays === _ageDays)
|
||||
continue;
|
||||
_loading.set(product.id, product.version);
|
||||
_failed.delete(product.id);
|
||||
notifyRow();
|
||||
try {
|
||||
const next = await load(product, signal);
|
||||
if (signal.aborted || !_enabled) {
|
||||
if (signal.aborted || !_enabled || next.ageDays !== _ageDays) {
|
||||
next.urls.forEach((url) => URL.revokeObjectURL(url));
|
||||
return;
|
||||
}
|
||||
// Cull before the first frame so far-side pins never flash.
|
||||
_horizon.update(next.pins);
|
||||
await _viewer.dataSources.add(next.dataSource);
|
||||
// The new edition is on the globe before the old one leaves.
|
||||
unload(product.id);
|
||||
// The new edition is on the globe before the old one leaves. The card
|
||||
// stays only if its index still holds the same report: the server
|
||||
// may answer a pinned version with newer bytes.
|
||||
const index = Number(_selectedKey?.split(':')[1]);
|
||||
const before = loaded?.features.get(index);
|
||||
const after = next.features.get(index);
|
||||
unload(
|
||||
product.id,
|
||||
Boolean(before && after) &&
|
||||
before.name === after.name &&
|
||||
before.description === after.description,
|
||||
);
|
||||
_loaded.set(product.id, next);
|
||||
} catch (error) {
|
||||
if (signal.aborted) return;
|
||||
@@ -391,12 +473,38 @@ export function createGhostMapsLayer({
|
||||
void sync();
|
||||
}
|
||||
|
||||
function setAgeDays(ageDays) {
|
||||
if (!GHOSTMAPS_AGE_OPTIONS.includes(ageDays) || ageDays === _ageDays)
|
||||
return;
|
||||
_ageDays = ageDays;
|
||||
try {
|
||||
storage?.setItem(AGE_STORAGE_KEY, JSON.stringify(ageDays));
|
||||
} catch {
|
||||
/* private window or blocked storage: keep the session choice */
|
||||
}
|
||||
expireEvents();
|
||||
// Narrowing only removes markers. A product loaded with a narrower window
|
||||
// keeps its old ageDays, so sync() restores omitted placemarks from the
|
||||
// cached KMZ even though its upstream version has not changed.
|
||||
for (const [id, item] of _loaded)
|
||||
if (
|
||||
item.ageDays === null ||
|
||||
(ageDays !== null && item.ageDays >= ageDays)
|
||||
) {
|
||||
item.ageDays = ageDays;
|
||||
// A failed wider reload no longer matters; a stale edition retries.
|
||||
_failed.delete(id);
|
||||
}
|
||||
notifyRow();
|
||||
return sync();
|
||||
}
|
||||
|
||||
// ---- selection card ----------------------------------------------------
|
||||
function featureFor(key) {
|
||||
const [productId, index] = key.split(':');
|
||||
return {
|
||||
product: _catalog?.products.find((p) => p.id === productId) || null,
|
||||
feature: _loaded.get(productId)?.features[Number(index)] || null,
|
||||
feature: _loaded.get(productId)?.features.get(Number(index)) || null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -490,7 +598,7 @@ export function createGhostMapsLayer({
|
||||
if (typeof pickId !== 'string' || !pickId.startsWith(PICK_PREFIX))
|
||||
return null;
|
||||
const [productId, index] = pickId.slice(PICK_PREFIX.length).split(':');
|
||||
return _loaded.get(productId)?.features[Number(index)]
|
||||
return _loaded.get(productId)?.features.has(Number(index))
|
||||
? `${productId}:${index}`
|
||||
: null;
|
||||
}
|
||||
@@ -542,7 +650,7 @@ export function createGhostMapsLayer({
|
||||
|
||||
function featureCount() {
|
||||
let count = 0;
|
||||
for (const item of _loaded.values()) count += item.features.length;
|
||||
for (const item of _loaded.values()) count += item.features.size;
|
||||
return count;
|
||||
}
|
||||
|
||||
@@ -563,6 +671,8 @@ export function createGhostMapsLayer({
|
||||
enable() {
|
||||
_enabled = true;
|
||||
_lifetime = new AbortController();
|
||||
if (_expiryTimer === null)
|
||||
_expiryTimer = setInterval(expireEvents, 60_000);
|
||||
for (const item of _loaded.values()) item.dataSource.show = true;
|
||||
installClickHandler();
|
||||
if (!_unsubscribe && typeof source.subscribe === 'function')
|
||||
@@ -580,6 +690,8 @@ export function createGhostMapsLayer({
|
||||
|
||||
disable() {
|
||||
_enabled = false;
|
||||
clearInterval(_expiryTimer);
|
||||
_expiryTimer = null;
|
||||
_lifetime.abort();
|
||||
_unsubscribe?.();
|
||||
_unsubscribe = null;
|
||||
@@ -593,6 +705,7 @@ export function createGhostMapsLayer({
|
||||
|
||||
async update(_viewerArg, { signal } = {}) {
|
||||
if (!_enabled || signal?.aborted) return false;
|
||||
expireEvents();
|
||||
try {
|
||||
const catalog = await source.getCatalog({ signal });
|
||||
if (!_enabled) return false;
|
||||
@@ -637,6 +750,25 @@ export function createGhostMapsLayer({
|
||||
})
|
||||
: t('Connecting to the S2 GhostMaps repository…');
|
||||
return {
|
||||
segmented: {
|
||||
id: 'age',
|
||||
label: t('Event age'),
|
||||
options: GHOSTMAPS_AGE_OPTIONS.map((days) => ({
|
||||
id: `age-${days ?? 'all'}`,
|
||||
label:
|
||||
days === null
|
||||
? t('All', { context: 'event age filter' })
|
||||
: t('{days}d', { days: formatNumber(days) }),
|
||||
title:
|
||||
days === null
|
||||
? t('Show markers of every age')
|
||||
: t('Maximum event age: {days}d; undated markers are kept', {
|
||||
days: formatNumber(days),
|
||||
}),
|
||||
active: days === _ageDays,
|
||||
onClick: () => setAgeDays(days),
|
||||
})),
|
||||
},
|
||||
chips: products.map((product) => {
|
||||
const loaded = _loaded.get(product.id);
|
||||
const failed = _failed.get(product.id);
|
||||
@@ -651,11 +783,11 @@ export function createGhostMapsLayer({
|
||||
product.fileTitle,
|
||||
loaded
|
||||
? tn(
|
||||
loaded.features.length,
|
||||
loaded.features.size,
|
||||
'{count} placemark',
|
||||
'{count} placemarks',
|
||||
{
|
||||
count: formatNumber(loaded.features.length),
|
||||
count: formatNumber(loaded.features.size),
|
||||
},
|
||||
)
|
||||
: null,
|
||||
@@ -667,7 +799,7 @@ export function createGhostMapsLayer({
|
||||
};
|
||||
}),
|
||||
legend: [],
|
||||
info,
|
||||
info: `${_ageDays === null ? t('All markers') : t('Event age: {days}d · undated kept', { days: formatNumber(_ageDays) })} · ${info}`,
|
||||
infoTitle: t(
|
||||
'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.',
|
||||
),
|
||||
@@ -677,6 +809,7 @@ export function createGhostMapsLayer({
|
||||
getStats() {
|
||||
return {
|
||||
count: featureCount(),
|
||||
ageDays: _ageDays,
|
||||
products: [..._loaded.keys()],
|
||||
commit: _catalog?.commit || null,
|
||||
lastUpdate: _catalog?.committedAt || _catalog?.changedAt || null,
|
||||
@@ -693,7 +826,7 @@ export function createGhostMapsLayer({
|
||||
if (!_enabled) return [];
|
||||
const out = [];
|
||||
for (const [productId, item] of _loaded) {
|
||||
for (const feature of item.features) {
|
||||
for (const feature of item.features.values()) {
|
||||
if (out.length >= maxCount) return out;
|
||||
out.push({
|
||||
product: productId,
|
||||
|
||||
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createGhostMapsLayer } from './index.js';
|
||||
import { GHOSTMAPS_PRODUCTS, GHOSTMAPS_LATEST_DROP } from './catalog.js';
|
||||
|
||||
function element(localName, children = [], value = '', attributes = {}) {
|
||||
const node = {
|
||||
@@ -22,7 +23,7 @@ function element(localName, children = [], value = '', attributes = {}) {
|
||||
return node;
|
||||
}
|
||||
|
||||
function documentFixture() {
|
||||
function documentFixture(reports = [{ name: 'Test report' }]) {
|
||||
return element('document', [
|
||||
element('kml', [
|
||||
element('Document', [
|
||||
@@ -36,11 +37,14 @@ function documentFixture() {
|
||||
'',
|
||||
{ id: 'pin' },
|
||||
),
|
||||
...reports.map(({ name, description = '', geometry }) =>
|
||||
element('Placemark', [
|
||||
element('name', [], 'Test report'),
|
||||
element('name', [], name),
|
||||
element('description', [], description),
|
||||
element('styleUrl', [], '#pin'),
|
||||
element('Point', [element('coordinates', [], '12,34')]),
|
||||
geometry || element('Point', [element('coordinates', [], '12,34')]),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
]),
|
||||
]);
|
||||
@@ -83,11 +87,21 @@ function fixtureKmz() {
|
||||
return new Uint8Array(Buffer.concat([...local, directory, end]));
|
||||
}
|
||||
|
||||
function fixture(t, { beforeFileReturns, onCreateUrl } = {}) {
|
||||
function fixture(
|
||||
t,
|
||||
{
|
||||
beforeFileReturns,
|
||||
onCreateUrl,
|
||||
reports,
|
||||
now,
|
||||
products,
|
||||
controls = {},
|
||||
} = {},
|
||||
) {
|
||||
const previous = Object.getOwnPropertyDescriptor(globalThis, 'DOMParser');
|
||||
globalThis.DOMParser = class {
|
||||
parseFromString() {
|
||||
return documentFixture();
|
||||
return documentFixture(reports);
|
||||
}
|
||||
};
|
||||
t.after(() => {
|
||||
@@ -119,9 +133,13 @@ function fixture(t, { beforeFileReturns, onCreateUrl } = {}) {
|
||||
};
|
||||
const layer = createGhostMapsLayer({
|
||||
storage: null,
|
||||
now,
|
||||
...controls,
|
||||
source: {
|
||||
getCatalog: async () => ({
|
||||
products: [{ id: 'cip', version: 'a'.repeat(40), defaultOn: true }],
|
||||
products: products ?? [
|
||||
{ id: 'cip', version: 'a'.repeat(40), defaultOn: true },
|
||||
],
|
||||
}),
|
||||
getFile: async () => {
|
||||
beforeFileReturns?.(layer);
|
||||
@@ -178,3 +196,416 @@ test('successful load keeps icons until the product is disabled', async (t) => {
|
||||
assert.deepEqual(revoked, created);
|
||||
assert.equal(mounted.size, 0);
|
||||
});
|
||||
|
||||
const dated = (name, value, label = 'Date') => ({
|
||||
name,
|
||||
description: `<table><tr><td>${label}</td><td>${value}</td></tr></table>`,
|
||||
});
|
||||
|
||||
const ageChip = (layer, days) =>
|
||||
layer
|
||||
.getRowControls()
|
||||
.segmented.options.find(({ id }) => id === `age-${days ?? 'all'}`);
|
||||
|
||||
test('age controls default to seven days and widening restores filtered and expired reports', async (t) => {
|
||||
let at = Date.UTC(2026, 10, 3, 12);
|
||||
const { layer, viewer, mounted } = fixture(t, {
|
||||
now: () => at,
|
||||
reports: [
|
||||
dated('Old', '10/1/2026'),
|
||||
dated('Ten days', '10/24/2026'),
|
||||
dated('Recent', '11/2/2026'),
|
||||
{ name: 'Undated' },
|
||||
],
|
||||
});
|
||||
await layer.update(viewer);
|
||||
assert.equal(layer.getStats().ageDays, 7);
|
||||
assert.equal(ageChip(layer, 7).active, true);
|
||||
assert.equal(layer.getStats().count, 2);
|
||||
await ageChip(layer, 14).onClick();
|
||||
assert.equal(layer.getStats().count, 3);
|
||||
await ageChip(layer, null).onClick();
|
||||
assert.equal(layer.getStats().count, 4);
|
||||
at += 100 * 86400_000;
|
||||
await layer.update(viewer);
|
||||
assert.equal(layer.getStats().count, 4, 'all markers never ages out');
|
||||
await ageChip(layer, 1).onClick();
|
||||
assert.equal(layer.getStats().count, 1);
|
||||
assert.equal(layer.getAnalystRecords()[0].name, 'Undated');
|
||||
await ageChip(layer, null).onClick();
|
||||
assert.equal(layer.getStats().count, 4);
|
||||
assert.equal(mounted.size, 1, 'filter changes never duplicate data sources');
|
||||
const controls = layer.getRowControls();
|
||||
assert.equal(
|
||||
controls.segmented.options.filter(({ active }) => active).length,
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
controls.chips.some(({ id }) => id.startsWith('age-')),
|
||||
false,
|
||||
'age options are one switch, not product chips',
|
||||
);
|
||||
assert.equal(ageChip(layer, null).active, true);
|
||||
});
|
||||
|
||||
test('age preferences persist independently of product choices and reject invalid storage', async (t) => {
|
||||
const values = new Map([['gev.ghostmaps.products', '["cip"]']]);
|
||||
const storage = {
|
||||
getItem: (key) => values.get(key) ?? null,
|
||||
setItem: (key, value) => values.set(key, value),
|
||||
};
|
||||
await t.test('save All markers without changing products', async (t) => {
|
||||
const { layer, viewer } = fixture(t, { controls: { storage } });
|
||||
await layer.update(viewer);
|
||||
await ageChip(layer, null).onClick();
|
||||
assert.equal(values.get('gev.ghostmaps.ageDays'), 'null');
|
||||
assert.equal(values.get('gev.ghostmaps.products'), '["cip"]');
|
||||
});
|
||||
await t.test('restore All markers and save thirty days', async (t) => {
|
||||
const { layer } = fixture(t, { controls: { storage } });
|
||||
assert.equal(layer.getStats().ageDays, null);
|
||||
await ageChip(layer, 30).onClick();
|
||||
assert.equal(values.get('gev.ghostmaps.ageDays'), '30');
|
||||
});
|
||||
await t.test('restore thirty days', (t) => {
|
||||
const { layer } = fixture(t, { controls: { storage } });
|
||||
assert.equal(layer.getStats().ageDays, 30);
|
||||
});
|
||||
for (const value of ['garbage', '"7"', '-1', '0', '2', '{}']) {
|
||||
await t.test(`reject ${value}`, (t) => {
|
||||
values.set('gev.ghostmaps.ageDays', value);
|
||||
const { layer } = fixture(t, { controls: { storage } });
|
||||
assert.equal(layer.getStats().ageDays, 7);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('blocked storage still permits changing the age within the session', async (t) => {
|
||||
const storage = {
|
||||
getItem() {
|
||||
throw new Error('blocked');
|
||||
},
|
||||
setItem() {
|
||||
throw new Error('blocked');
|
||||
},
|
||||
};
|
||||
const { layer, viewer } = fixture(t, { controls: { storage } });
|
||||
await layer.update(viewer);
|
||||
assert.equal(layer.getStats().ageDays, 7);
|
||||
await ageChip(layer, null).onClick();
|
||||
assert.equal(layer.getStats().ageDays, null);
|
||||
});
|
||||
|
||||
test('a filter changed during a pending load never installs the superseded filter', async (t) => {
|
||||
let changed = false;
|
||||
const { layer, viewer, mounted, revoked, created } = fixture(t, {
|
||||
now: () => Date.UTC(2026, 10, 3),
|
||||
reports: [dated('Old', '10/1/2026')],
|
||||
beforeFileReturns: (layer) => {
|
||||
if (changed) return;
|
||||
changed = true;
|
||||
ageChip(layer, null).onClick();
|
||||
},
|
||||
});
|
||||
await layer.update(viewer);
|
||||
await layer.update(viewer);
|
||||
assert.equal(layer.getStats().count, 1);
|
||||
assert.equal(layer.getStats().ageDays, null);
|
||||
assert.equal(mounted.size, 1);
|
||||
assert.equal(
|
||||
revoked.length,
|
||||
created.length - 1,
|
||||
'discarded load releases icons',
|
||||
);
|
||||
});
|
||||
|
||||
test('age filtering and All markers apply to every conflict-map product, including optional maps', async (t) => {
|
||||
for (const product of [...GHOSTMAPS_PRODUCTS, GHOSTMAPS_LATEST_DROP]) {
|
||||
await t.test(product.id, async (t) => {
|
||||
const { layer, viewer, mounted } = fixture(t, {
|
||||
now: () => Date.UTC(2026, 10, 3, 12),
|
||||
products: [{ ...product, version: 'a'.repeat(40) }],
|
||||
reports: [
|
||||
dated(
|
||||
'Old event',
|
||||
'10/1/2026',
|
||||
product.id === 'cip' ? 'Date of Event' : 'Date',
|
||||
),
|
||||
dated('Recent event', '11/2/2026'),
|
||||
{ name: 'Undated reference feature' },
|
||||
],
|
||||
});
|
||||
await layer.update(viewer);
|
||||
if (!product.defaultOn) {
|
||||
layer
|
||||
.getRowControls()
|
||||
.chips.find(({ id }) => id === product.id)
|
||||
.onClick();
|
||||
await layer.update(viewer);
|
||||
}
|
||||
assert.deepEqual(layer.getStats().products, [product.id]);
|
||||
assert.equal(layer.getStats().count, 2);
|
||||
assert.deepEqual(
|
||||
layer.getAnalystRecords().map(({ name }) => name),
|
||||
['Recent event', 'Undated reference feature'],
|
||||
);
|
||||
await ageChip(layer, null).onClick();
|
||||
assert.equal(
|
||||
layer.getStats().count,
|
||||
3,
|
||||
"All markers restores this product's old events",
|
||||
);
|
||||
await ageChip(layer, 7).onClick();
|
||||
assert.equal(layer.getStats().count, 2);
|
||||
assert.equal(mounted.size, 1);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('default products filter old event dates while keeping recent, future and undated markers', async (t) => {
|
||||
const { layer, viewer, mounted } = fixture(t, {
|
||||
now: () => Date.UTC(2026, 10, 3, 12),
|
||||
reports: [
|
||||
dated('Old event', '10/20/2026'),
|
||||
dated('Recent event', '11/2/2026'),
|
||||
dated('At cutoff', '10/27/2026 12:00:00 PM'),
|
||||
dated('Before cutoff', '10/27/2026 11:59:59 AM'),
|
||||
dated('Future event', '11/4/2026'),
|
||||
{ name: 'Undated site' },
|
||||
dated('Blank date', '<Null>'),
|
||||
dated('Invalid date', '2/30/2026'),
|
||||
dated('Only a report date', '10/1/2026', 'Date Of Report'),
|
||||
{
|
||||
name: 'Old event reported recently',
|
||||
description:
|
||||
'<table><tr><td>Date of Event</td><td>10/1/2026</td></tr><tr><td>Date Of Report</td><td>11/2/2026</td></tr></table>',
|
||||
},
|
||||
],
|
||||
});
|
||||
await layer.update(viewer);
|
||||
assert.deepEqual(layer.getStats().products, ['cip']);
|
||||
assert.deepEqual(
|
||||
layer.getAnalystRecords().map((r) => r.name),
|
||||
[
|
||||
'Recent event',
|
||||
'At cutoff',
|
||||
'Future event',
|
||||
'Undated site',
|
||||
'Blank date',
|
||||
'Invalid date',
|
||||
'Only a report date',
|
||||
],
|
||||
);
|
||||
assert.equal(layer.getStats().count, 7);
|
||||
assert.equal([...mounted][0].entities.values.length, 7);
|
||||
// Filtering cannot shift pick ids onto a different report.
|
||||
assert.ok([...mounted][0].entities.getById('ghostmaps:cip:1:0'));
|
||||
assert.equal(
|
||||
[...mounted][0].entities.getById('ghostmaps:cip:0:0'),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test('unchanged cached products age out on the timer, clear selected cards and keep surviving picks', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setInterval'] });
|
||||
let at = Date.UTC(2026, 10, 3, 12);
|
||||
let click;
|
||||
const cards = [];
|
||||
const { layer, viewer, mounted } = fixture(t, {
|
||||
now: () => at,
|
||||
reports: [
|
||||
dated('Expiring', '10/27/2026 12:00:00 PM'),
|
||||
dated('Surviving', '11/2/2026'),
|
||||
],
|
||||
controls: {
|
||||
overlayHost: { setEntries: (_id, entries) => cards.push(entries) },
|
||||
screenSpaceEventHandlerFactory: () => ({
|
||||
setInputAction: (fn) => {
|
||||
click = fn;
|
||||
},
|
||||
destroy() {},
|
||||
}),
|
||||
picking: { resolvePickId: (picked) => picked },
|
||||
},
|
||||
});
|
||||
await layer.update(viewer);
|
||||
const ds = [...mounted][0];
|
||||
viewer.scene.pick = () => 'ghostmaps:cip:0:0';
|
||||
click({ position: { x: 0, y: 0 } });
|
||||
assert.equal(cards.at(-1).length, 1);
|
||||
at += 60_000;
|
||||
t.mock.timers.tick(60_000);
|
||||
assert.equal(layer.getStats().count, 1);
|
||||
assert.equal(ds.entities.values.length, 1);
|
||||
assert.equal(ds.entities.getById('ghostmaps:cip:0:0'), undefined);
|
||||
assert.ok(ds.entities.getById('ghostmaps:cip:1:0'));
|
||||
assert.deepEqual(cards.at(-1), []);
|
||||
await layer.update(viewer);
|
||||
assert.equal([...mounted][0], ds, 'same-version KMZ is not reloaded');
|
||||
viewer.scene.pick = () => 'ghostmaps:cip:1:0';
|
||||
click({ position: { x: 0, y: 0 } });
|
||||
assert.match(cards.at(-1)[0].title, /Surviving/);
|
||||
layer.disable();
|
||||
at += 14 * 86400_000;
|
||||
t.mock.timers.tick(60_000);
|
||||
assert.equal(mounted.size, 0);
|
||||
layer.enable();
|
||||
await layer.update(viewer);
|
||||
assert.equal(layer.getStats().count, 0);
|
||||
});
|
||||
|
||||
test('expiry removes every geometry and polygon outline belonging to an old report', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setInterval'] });
|
||||
// 10/27 is a whole UTC day: at 7d it expires when 11/3 ends.
|
||||
let at = Date.UTC(2026, 10, 4) - 30_000;
|
||||
const polygon = element('Polygon', [
|
||||
element('outerBoundaryIs', [
|
||||
element('LinearRing', [
|
||||
element('coordinates', [], '12,34 13,34 13,35 12,34'),
|
||||
]),
|
||||
]),
|
||||
]);
|
||||
const { layer, viewer, mounted } = fixture(t, {
|
||||
now: () => at,
|
||||
reports: [
|
||||
{
|
||||
...dated('Multi-part', '10/27/2026'),
|
||||
geometry: element('MultiGeometry', [
|
||||
element('Point', [element('coordinates', [], '12,34')]),
|
||||
element('LineString', [element('coordinates', [], '12,34 13,35')]),
|
||||
polygon,
|
||||
]),
|
||||
},
|
||||
],
|
||||
});
|
||||
await layer.update(viewer);
|
||||
const ds = [...mounted][0];
|
||||
assert.equal(ds.entities.values.length, 4);
|
||||
at += 60_000;
|
||||
t.mock.timers.tick(60_000);
|
||||
assert.equal(ds.entities.values.length, 0);
|
||||
assert.equal(layer.getStats().count, 0);
|
||||
});
|
||||
|
||||
test('narrowing then returning to the previous window restores its reports without a duplicate source', async (t) => {
|
||||
const { layer, viewer, mounted } = fixture(t, {
|
||||
now: () => Date.UTC(2026, 10, 3, 12),
|
||||
reports: [dated('Today', '11/3/2026'), dated('Three days', '10/31/2026')],
|
||||
});
|
||||
await layer.update(viewer);
|
||||
assert.equal(layer.getStats().count, 2);
|
||||
await ageChip(layer, 1).onClick();
|
||||
assert.equal(layer.getStats().count, 1);
|
||||
await ageChip(layer, 7).onClick();
|
||||
await layer.update(viewer);
|
||||
assert.equal(layer.getStats().count, 2, '7d must show both reports again');
|
||||
assert.equal(mounted.size, 1);
|
||||
});
|
||||
|
||||
test('two quick clicks, 1d then 7d, restore the report', async (t) => {
|
||||
const at = Date.UTC(2026, 10, 3, 12);
|
||||
const { layer, viewer } = fixture(t, {
|
||||
now: () => at,
|
||||
reports: [dated('Today', '11/3/2026'), dated('Three days', '10/31/2026')],
|
||||
});
|
||||
await layer.update(viewer);
|
||||
const a = ageChip(layer, 1).onClick();
|
||||
const b = ageChip(layer, 7).onClick();
|
||||
await a;
|
||||
await b;
|
||||
await layer.update(viewer);
|
||||
assert.equal(layer.getStats().count, 2);
|
||||
});
|
||||
|
||||
test('narrowing the window keeps the open card of a surviving marker', async (t) => {
|
||||
let click;
|
||||
const cards = [];
|
||||
const { layer, viewer } = fixture(t, {
|
||||
now: () => Date.UTC(2026, 10, 3, 12),
|
||||
reports: [dated('Recent', '11/2/2026'), dated('Older', '10/20/2026')],
|
||||
controls: {
|
||||
overlayHost: { setEntries: (_id, entries) => cards.push(entries) },
|
||||
screenSpaceEventHandlerFactory: () => ({
|
||||
setInputAction: (fn) => {
|
||||
click = fn;
|
||||
},
|
||||
destroy() {},
|
||||
}),
|
||||
picking: { resolvePickId: (picked) => picked },
|
||||
},
|
||||
});
|
||||
await layer.update(viewer);
|
||||
await ageChip(layer, 30).onClick();
|
||||
assert.equal(layer.getStats().count, 2);
|
||||
viewer.scene.pick = () => 'ghostmaps:cip:0:0';
|
||||
click({ position: { x: 0, y: 0 } });
|
||||
assert.equal(cards.at(-1).length, 1);
|
||||
await ageChip(layer, 3).onClick();
|
||||
assert.equal(layer.getStats().count, 1);
|
||||
assert.equal(
|
||||
cards.at(-1).length,
|
||||
1,
|
||||
'card of the surviving marker stays open',
|
||||
);
|
||||
});
|
||||
|
||||
test('a report dated yesterday is shown by 1d one hour into today (UTC)', async (t) => {
|
||||
const { layer, viewer } = fixture(t, {
|
||||
now: () => Date.UTC(2026, 10, 6, 1),
|
||||
reports: [dated('Yesterday', '11/5/2026')],
|
||||
});
|
||||
await layer.update(viewer);
|
||||
await ageChip(layer, 1).onClick();
|
||||
assert.equal(layer.getStats().count, 1);
|
||||
});
|
||||
|
||||
test('a widened reload that returns a different report at the selected index closes its card', async (t) => {
|
||||
let click;
|
||||
const cards = [];
|
||||
const reports = [dated('Recent', '11/2/2026'), dated('Older', '10/20/2026')];
|
||||
const { layer, viewer } = fixture(t, {
|
||||
now: () => Date.UTC(2026, 10, 3, 12),
|
||||
reports,
|
||||
controls: {
|
||||
overlayHost: { setEntries: (_id, entries) => cards.push(entries) },
|
||||
screenSpaceEventHandlerFactory: () => ({
|
||||
setInputAction: (fn) => {
|
||||
click = fn;
|
||||
},
|
||||
destroy() {},
|
||||
}),
|
||||
picking: { resolvePickId: (picked) => picked },
|
||||
},
|
||||
});
|
||||
await layer.update(viewer);
|
||||
viewer.scene.pick = () => 'ghostmaps:cip:0:0';
|
||||
click({ position: { x: 0, y: 0 } });
|
||||
assert.equal(cards.at(-1).length, 1);
|
||||
// The server answers the pinned version with a newer edition's bytes.
|
||||
reports[0] = dated('Replacement', '11/2/2026');
|
||||
await ageChip(layer, 30).onClick();
|
||||
assert.equal(layer.getStats().count, 2);
|
||||
assert.equal(
|
||||
cards.at(-1).length,
|
||||
0,
|
||||
'a different report must not keep the old card',
|
||||
);
|
||||
});
|
||||
|
||||
test('narrowing to a window the loaded data covers clears a failed wider reload', async (t) => {
|
||||
let fail = false;
|
||||
const { layer, viewer } = fixture(t, {
|
||||
now: () => Date.UTC(2026, 10, 3, 12),
|
||||
reports: [dated('Recent', '11/2/2026'), dated('Older', '10/20/2026')],
|
||||
beforeFileReturns: () => {
|
||||
if (fail) throw new Error('network down');
|
||||
},
|
||||
});
|
||||
await layer.update(viewer);
|
||||
fail = true;
|
||||
await ageChip(layer, 30).onClick();
|
||||
assert.ok(layer.getStats().error, 'the wider reload failed');
|
||||
await ageChip(layer, 3).onClick();
|
||||
assert.equal(layer.getStats().error, null);
|
||||
assert.equal(layer.getStats().count, 1);
|
||||
});
|
||||
+103
-11
@@ -1,4 +1,4 @@
|
||||
// Pure helpers for GhostMaps placemarks: description tables to plain fields,
|
||||
// Pure helpers for GhostMaps placemarks: report fields and event dates,
|
||||
// KML colors, and card text wrapping. Strings in, data out — description
|
||||
// markup is only ever read as text, never inserted into the page.
|
||||
|
||||
@@ -51,6 +51,106 @@ const MAX_FIELDS = 24;
|
||||
const MAX_VALUE = 600;
|
||||
const MAX_LINKS = 6;
|
||||
|
||||
// Use the same leaf rows for report cards and event dates, without the card's
|
||||
// field-count/value-length limits affecting the age filter.
|
||||
function* descriptionRows(source) {
|
||||
for (const row of source.matchAll(
|
||||
/<tr\b[^>]*>((?:(?!<tr\b)[\s\S])*?)<\/tr\s*>/gi,
|
||||
)) {
|
||||
const cells = [
|
||||
...row[1].matchAll(/<t[dh]\b[^>]*>([\s\S]*?)<\/t[dh]\s*>/gi),
|
||||
].map((cell) => cell[1]);
|
||||
if (cells.length === 2)
|
||||
yield {
|
||||
label: htmlToText(cells[0]),
|
||||
value: htmlToText(cells[1]),
|
||||
markup: cells[1],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const GHOSTMAPS_DEFAULT_AGE_DAYS = 7;
|
||||
export const GHOSTMAPS_AGE_OPTIONS = Object.freeze([1, 3, 7, 14, 30, null]);
|
||||
|
||||
/** ArcGIS US dates (optionally with a time) and ISO dates, interpreted in UTC
|
||||
* when no zone is supplied. A date without a time means the whole UTC day, so
|
||||
* it resolves to the day's last millisecond: "1d" still shows yesterday's
|
||||
* reports. Reject calendar rollovers instead of guessing. */
|
||||
export function parseGhostMapsEventDate(value) {
|
||||
const input = String(value ?? '').trim();
|
||||
const us =
|
||||
/^(\d{1,2})\/(\d{1,2})\/(\d{4})(?:\s+(\d{1,2}):(\d{2})(?::(\d{2}))?\s*(AM|PM))?$/i.exec(
|
||||
input,
|
||||
);
|
||||
const iso =
|
||||
/^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2})(?:\.\d{1,3})?)?(?:Z|[+-]\d{2}:\d{2})?)?$/.exec(
|
||||
input,
|
||||
);
|
||||
if (!us && !iso) return null;
|
||||
const year = Number(us ? us[3] : iso[1]);
|
||||
const month = Number(us ? us[1] : iso[2]);
|
||||
const day = Number(us ? us[2] : iso[3]);
|
||||
let hour = Number(us?.[4] ?? iso?.[4] ?? 0);
|
||||
const minute = Number(us?.[5] ?? iso?.[5] ?? 0);
|
||||
const second = Number(us?.[6] ?? iso?.[6] ?? 0);
|
||||
if (us?.[7]) {
|
||||
if (hour < 1 || hour > 12) return null;
|
||||
hour = (hour % 12) + (us[7].toUpperCase() === 'PM' ? 12 : 0);
|
||||
}
|
||||
const at = Date.UTC(year, month - 1, day, hour, minute, second);
|
||||
const date = new Date(at);
|
||||
if (
|
||||
year < 1900 ||
|
||||
month < 1 ||
|
||||
month > 12 ||
|
||||
day < 1 ||
|
||||
date.getUTCDate() !== day ||
|
||||
hour > 23 ||
|
||||
minute > 59 ||
|
||||
second > 59
|
||||
)
|
||||
return null;
|
||||
if (iso?.[4]) {
|
||||
const zoned = /(?:Z|[+-]\d{2}:\d{2})$/.test(input) ? input : `${input}Z`;
|
||||
const parsed = Date.parse(zoned);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
return us?.[4] ? at : at + 86400_000 - 1;
|
||||
}
|
||||
|
||||
/** The event's date, never its report/upload date or dates mentioned in prose. */
|
||||
export function ghostMapsEventDate(description) {
|
||||
const rows = [
|
||||
...descriptionRows(
|
||||
String(description ?? '').replace(
|
||||
/<(script|style|head)\b[\s\S]*?<\/\1\s*>/gi,
|
||||
' ',
|
||||
),
|
||||
),
|
||||
];
|
||||
for (const name of ['dateofevent', 'eventdate', 'date']) {
|
||||
for (const { label, value } of rows) {
|
||||
if (label.toLowerCase().replace(/[\s_]+/g, '') !== name) continue;
|
||||
const at = parseGhostMapsEventDate(value);
|
||||
if (at !== null) return at;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Unknown and future dates stay visible; only a confirmed old event expires. */
|
||||
export function isGhostMapsEventExpired(
|
||||
dateMs,
|
||||
now = Date.now(),
|
||||
ageDays = GHOSTMAPS_DEFAULT_AGE_DAYS,
|
||||
) {
|
||||
return (
|
||||
ageDays !== null &&
|
||||
Number.isFinite(dateMs) &&
|
||||
dateMs < now - ageDays * 86400_000
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fields and links from a placemark description. ArcGIS "popup" exports are a
|
||||
* two-column table (label, value); anything else becomes one unlabelled field.
|
||||
@@ -73,18 +173,10 @@ export function parseDescriptionFields(html) {
|
||||
};
|
||||
// Innermost rows only: ArcGIS nests the field table inside an outer row,
|
||||
// and only leaf rows with exactly two cells are label/value pairs.
|
||||
for (const row of source.matchAll(
|
||||
/<tr\b[^>]*>((?:(?!<tr\b)[\s\S])*?)<\/tr\s*>/gi,
|
||||
)) {
|
||||
const cells = [
|
||||
...row[1].matchAll(/<t[dh]\b[^>]*>([\s\S]*?)<\/t[dh]\s*>/gi),
|
||||
].map((cell) => cell[1]);
|
||||
if (cells.length !== 2) continue;
|
||||
const label = htmlToText(cells[0]);
|
||||
const value = htmlToText(cells[1]);
|
||||
for (const { label, value, markup } of descriptionRows(source)) {
|
||||
if (!label || SKIPPED_LABELS.has(label.toLowerCase())) continue;
|
||||
if (EMPTY_VALUES.has(value.toLowerCase())) continue;
|
||||
addLinks(cells[1]);
|
||||
addLinks(markup);
|
||||
// A bare research link is carried by `links`; the card does not need
|
||||
// the URL twice.
|
||||
if (/^https?:\/\/\S+$/i.test(value)) continue;
|
||||
|
||||
@@ -7,8 +7,83 @@ import {
|
||||
kmlColor,
|
||||
parseDescriptionFields,
|
||||
wrapCardText,
|
||||
ghostMapsEventDate,
|
||||
isGhostMapsEventExpired,
|
||||
parseGhostMapsEventDate,
|
||||
} from './records.js';
|
||||
|
||||
test('event dates parse ArcGIS calendar dates and timestamps consistently in UTC', () => {
|
||||
for (const [value, iso] of [
|
||||
['10/28/2026', '2026-10-28T23:59:59.999Z'],
|
||||
['10/28/2026 6:12:00 PM', '2026-10-28T18:12:00Z'],
|
||||
['1/17/2026 9:46:41 AM', '2026-01-17T09:46:41Z'],
|
||||
['10/28/2026 12:00 AM', '2026-10-28T00:00:00Z'],
|
||||
['10/28/2026 12:00 PM', '2026-10-28T12:00:00Z'],
|
||||
['2026-10-28', '2026-10-28T23:59:59.999Z'],
|
||||
['2026-10-28T12:34:56', '2026-10-28T12:34:56Z'],
|
||||
['2026-10-28T12:34:56.123-04:00', '2026-10-28T16:34:56.123Z'],
|
||||
['2/29/2024', '2024-02-29T23:59:59.999Z'],
|
||||
])
|
||||
assert.equal(parseGhostMapsEventDate(value), Date.parse(iso), value);
|
||||
for (const value of [
|
||||
'',
|
||||
'<Null>',
|
||||
'N/A',
|
||||
'unknown',
|
||||
'2/29/2026',
|
||||
'9/31/2026',
|
||||
'2026-02-30',
|
||||
'13/1/2026',
|
||||
'1/0/2026',
|
||||
'10/28/2026 13:00 PM',
|
||||
'2026-10-28T24:00:00Z',
|
||||
'2026-10-28T12:60:00Z',
|
||||
]) {
|
||||
assert.equal(parseGhostMapsEventDate(value), null, value);
|
||||
}
|
||||
});
|
||||
|
||||
test('event date fields take precedence over report dates and prose, even after long report tables', () => {
|
||||
const row = (label, value) => `<tr><td>${label}</td><td>${value}</td></tr>`;
|
||||
const html = `<table><tr><td><table>${
|
||||
row('Date Of Report', '11/2/2026') +
|
||||
row('Description', 'Incident mentioned on 11/1/2026') +
|
||||
Array.from({ length: 30 }, (_, i) => row(`Field${i}`, 'value')).join('') +
|
||||
row('Date', '10/30/2026') +
|
||||
row('Date of Event', '10/28/2026')
|
||||
}</table></td></tr></table>`;
|
||||
assert.equal(ghostMapsEventDate(html), Date.UTC(2026, 9, 29) - 1);
|
||||
assert.equal(
|
||||
ghostMapsEventDate(
|
||||
`<table>${row('Date', '<Null>')}${row('Date Of Report', '11/2/2026')}</table>`,
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
ghostMapsEventDate(`<table>${row('Date_Of_Event', '10/28/2026')}</table>`),
|
||||
Date.UTC(2026, 9, 29) - 1,
|
||||
);
|
||||
assert.equal(ghostMapsEventDate('Occurred 10/28/2026'), null);
|
||||
assert.equal(
|
||||
ghostMapsEventDate(
|
||||
`<script><table>${row('Date', '1/1/2020')}</table></script>`,
|
||||
),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('seven-day expiry keeps the exact boundary, future dates and unknown dates', () => {
|
||||
const at = Date.UTC(2026, 10, 3, 12);
|
||||
assert.equal(isGhostMapsEventExpired(Date.UTC(2026, 9, 27, 12), at), false);
|
||||
assert.equal(
|
||||
isGhostMapsEventExpired(Date.UTC(2026, 9, 27, 12) - 1, at),
|
||||
true,
|
||||
);
|
||||
assert.equal(isGhostMapsEventExpired(Date.UTC(2026, 10, 4), at), false);
|
||||
for (const value of [null, undefined, NaN])
|
||||
assert.equal(isGhostMapsEventExpired(value, at), false);
|
||||
});
|
||||
|
||||
// The shape ArcGIS Pro writes into every S2 placemark description.
|
||||
const ARCGIS = `<html xmlns:fo="http://www.w3.org/1999/XSL/Format"><head>
|
||||
<META http-equiv="Content-Type" content="text/html"></head>
|
||||
@@ -27,6 +102,14 @@ const ARCGIS = `<html xmlns:fo="http://www.w3.org/1999/XSL/Format"><head>
|
||||
function changeImage(a, b) { document.getElementById('x').innerHTML = b; }
|
||||
</script></html>`;
|
||||
|
||||
test('custom ages change the cutoff and all markers disables expiry', () => {
|
||||
const at = Date.UTC(2026, 10, 3, 12);
|
||||
const event = at - 10 * 86400_000;
|
||||
assert.equal(isGhostMapsEventExpired(event, at, 7), true);
|
||||
assert.equal(isGhostMapsEventExpired(event, at, 14), false);
|
||||
assert.equal(isGhostMapsEventExpired(event, at, null), false);
|
||||
});
|
||||
|
||||
test('ArcGIS popup tables become plain label/value fields and http links', () => {
|
||||
const { fields, links } = parseDescriptionFields(ARCGIS);
|
||||
assert.deepEqual(fields, [
|
||||
|
||||
Vendored
+4
@@ -12,6 +12,9 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
|
||||
/** Release only Space Mission camera ownership, preserving layer and selection state. */
|
||||
releaseCameraOwnership() {
|
||||
// Navigation takes the camera; a failed entry must not restore the old view.
|
||||
layerState._entryCameraView = null;
|
||||
layerState._cameraReleased = true;
|
||||
parts.panel.clearMissionRosterHover();
|
||||
parts.replay.stopMissionReplay();
|
||||
parts.selection.stopMissionZoomAnchor();
|
||||
@@ -57,6 +60,7 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
orbitMatches: layerState._orbitMatches,
|
||||
lastUpdate: layerState._lastUpdate,
|
||||
error: layerState._lastError,
|
||||
errorAt: layerState._lastErrorAt,
|
||||
stale: layerState._stale,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -0,0 +1,495 @@
|
||||
// The real manager, Context coordinator and launches ingestion run against a
|
||||
// stubbed /api/launches, so a failed first load takes the browser's path.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { DataLayerManager } from '../../data/manager.js';
|
||||
import { ContextControls } from '../../ui/contextControls.js';
|
||||
import { spaceMissionsIndexFailure } from '../../ui/contextActions.js';
|
||||
import { createLaunchSource } from './source.js';
|
||||
import { createState } from './state.js';
|
||||
import { createModel } from './model.js';
|
||||
import { createPolicyHelpers } from './policyHelpers.js';
|
||||
import { createIngestion } from './ingestion.js';
|
||||
import { createPanel } from './panel.js';
|
||||
import { createControls } from './controls.js';
|
||||
import { createLifecycle } from './lifecycle.js';
|
||||
import { createReplay } from './replay.js';
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
const launchRecord = {
|
||||
id: 'fixture-launch',
|
||||
name: 'Fixture mission',
|
||||
net: new Date(Date.now() - 3600000).toISOString(),
|
||||
status: { name: 'Launch Successful' },
|
||||
pad: { name: 'Fixture pad', latitude: 28.6, longitude: -80.6 },
|
||||
rocket: { payloads: [] },
|
||||
};
|
||||
|
||||
function plainLayer(id, name, params = {}) {
|
||||
let current = { ...params };
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
icon: '',
|
||||
source: 'test',
|
||||
updateInterval: -1,
|
||||
init: noop,
|
||||
enable: noop,
|
||||
disable: noop,
|
||||
update: noop,
|
||||
getParams: () => ({ ...current }),
|
||||
setParams(next) {
|
||||
current = { ...current, ...next };
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function fakeCamera() {
|
||||
const camera = {
|
||||
position: new Cesium.Cartesian3(1, 2, 3),
|
||||
heading: 0.1,
|
||||
pitch: -0.5,
|
||||
roll: 0,
|
||||
flights: 0,
|
||||
cancelled: 0,
|
||||
flight: null,
|
||||
get positionWC() {
|
||||
return camera.position;
|
||||
},
|
||||
// Like Cesium, a new flight cancels the one in progress.
|
||||
flyTo({ destination, complete, cancel }) {
|
||||
camera.flight?.cancel?.();
|
||||
camera.flight = { complete, cancel };
|
||||
camera.flights += 1;
|
||||
camera.position = Cesium.Cartesian3.clone(destination);
|
||||
camera.heading = 0;
|
||||
camera.pitch = -Math.PI / 2;
|
||||
},
|
||||
land() {
|
||||
const flight = camera.flight;
|
||||
camera.flight = null;
|
||||
flight?.complete?.();
|
||||
},
|
||||
cancelFlight() {
|
||||
camera.cancelled += 1;
|
||||
const flight = camera.flight;
|
||||
camera.flight = null;
|
||||
flight?.cancel?.();
|
||||
},
|
||||
lookAtTransform: noop,
|
||||
setView({ destination, orientation }) {
|
||||
camera.position = Cesium.Cartesian3.clone(destination);
|
||||
camera.heading = orientation.heading;
|
||||
camera.pitch = orientation.pitch;
|
||||
camera.roll = orientation.roll;
|
||||
},
|
||||
};
|
||||
return camera;
|
||||
}
|
||||
|
||||
async function fixture(t) {
|
||||
const upstream = { status: 429, results: [launchRecord] };
|
||||
const source = createLaunchSource({
|
||||
async fetchImpl(url) {
|
||||
if (url === '/api/celestrak/active') return new Response('');
|
||||
if (upstream.hold) await upstream.hold;
|
||||
return Response.json(
|
||||
upstream.status === 200 ? { results: upstream.results } : {},
|
||||
{ status: upstream.status },
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const services = {
|
||||
overlays: {
|
||||
setOverlayEntries: noop,
|
||||
setOverlaySourceVisible: noop,
|
||||
clearOverlaySource: noop,
|
||||
},
|
||||
render: { holdContinuousRender: noop, releaseContinuousRender: noop },
|
||||
};
|
||||
const state = createState({ services });
|
||||
const camera = fakeCamera();
|
||||
const canvas = Object.assign(new EventTarget(), {
|
||||
disableRootEvents: true,
|
||||
onwheel: null,
|
||||
style: {},
|
||||
clientWidth: 1200,
|
||||
clientHeight: 800,
|
||||
});
|
||||
const viewer = {
|
||||
camera,
|
||||
dataSources: new Cesium.DataSourceCollection(),
|
||||
scene: { canvas, preRender: new Cesium.Event(), drillPick: () => [] },
|
||||
};
|
||||
// Scene presentation is stubbed; ingestion, lifecycle and panel are real.
|
||||
const parts = {
|
||||
overlays: {
|
||||
shortMissionLabel: (name) => name,
|
||||
createReplayVehicleOverlay: noop,
|
||||
clearMissionOverlaySources: noop,
|
||||
hideReplayVehicleOverlay: noop,
|
||||
destroyReplayVehicleOverlay: noop,
|
||||
syncMissionOverlayEntries: noop,
|
||||
},
|
||||
selection: {
|
||||
focusFullGlobe(target, flight) {
|
||||
target.camera.flyTo({
|
||||
destination: new Cesium.Cartesian3(9, 9, 9),
|
||||
...flight,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
setSelectedMission: noop,
|
||||
focusMission: noop,
|
||||
stopMissionZoomAnchor: noop,
|
||||
entityLaunchId: () => null,
|
||||
},
|
||||
orbitRendering: {
|
||||
removeMissionOrbitPrimitives: noop,
|
||||
syncMissionOrbitPrimitiveVisibility: noop,
|
||||
},
|
||||
launchPad: {
|
||||
initLaunchPadZonePrimitive: noop,
|
||||
hideLaunchPadZone: noop,
|
||||
destroyLaunchPadZonePrimitive: noop,
|
||||
},
|
||||
rendering: {
|
||||
updateMissionFrame: noop,
|
||||
addLaunchEntity(record) {
|
||||
state._dataSource.entities.add({
|
||||
id: record.id,
|
||||
position: Cesium.Cartesian3.fromDegrees(record.lon, record.lat),
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
const context = { state, services, parts, source };
|
||||
parts.policyHelpers = createPolicyHelpers(context);
|
||||
parts.model = createModel(context);
|
||||
parts.panel = createPanel(context);
|
||||
parts.replay = createReplay(context);
|
||||
parts.ingestion = createIngestion(context);
|
||||
parts.controls = createControls(context);
|
||||
parts.lifecycle = createLifecycle(context);
|
||||
const missions = Object.assign(
|
||||
{},
|
||||
parts.controls.methods,
|
||||
parts.lifecycle.methods,
|
||||
parts.ingestion.methods,
|
||||
);
|
||||
const rosters = [];
|
||||
parts.panel.subscribePanel((view) => rosters.push(view.roster));
|
||||
|
||||
const manager = new DataLayerManager(viewer);
|
||||
manager.register(plainLayer('earthquakes', 'Earthquakes (24h)'));
|
||||
manager.register(plainLayer('satellites', 'Satellites'));
|
||||
manager.register(plainLayer('wind', 'Wind'));
|
||||
manager.register(missions);
|
||||
missions.attachDataManager(manager);
|
||||
|
||||
const toasts = [];
|
||||
const controls = new ContextControls({
|
||||
elements: {},
|
||||
installations: {},
|
||||
actions: {
|
||||
getCockpit: () => null,
|
||||
claimVisualAuthority: noop,
|
||||
showToast: (message) => toasts.push(message),
|
||||
setClearBusy: noop,
|
||||
syncDetection: noop,
|
||||
scheduleLayout: noop,
|
||||
refreshRadio: noop,
|
||||
setPanelCollapsed: noop,
|
||||
},
|
||||
});
|
||||
controls.connect(manager);
|
||||
|
||||
t.after(async () => {
|
||||
controls.stop();
|
||||
controls.disconnect();
|
||||
for (const id of ['rocket-launches', 'satellites', 'earthquakes', 'wind'])
|
||||
await manager.setEnabled(id, false);
|
||||
await missions.destroy(viewer);
|
||||
});
|
||||
|
||||
const enabledIds = () => [...manager.getEnabledLayerIds()].sort();
|
||||
const view = () => ({
|
||||
x: camera.position.x,
|
||||
y: camera.position.y,
|
||||
z: camera.position.z,
|
||||
heading: camera.heading,
|
||||
pitch: camera.pitch,
|
||||
});
|
||||
const settle = async () => {
|
||||
await controls._waitForContextLayerSettlement();
|
||||
for (const id of ['rocket-launches', 'satellites', 'earthquakes'])
|
||||
await manager.waitForLayerSettled(id);
|
||||
await controls._waitForContextLayerSettlement();
|
||||
};
|
||||
return {
|
||||
upstream,
|
||||
manager,
|
||||
controls,
|
||||
missions,
|
||||
toasts,
|
||||
state,
|
||||
camera,
|
||||
rosters,
|
||||
enabledIds,
|
||||
view,
|
||||
settle,
|
||||
};
|
||||
}
|
||||
|
||||
const entries = {
|
||||
direct: (f) =>
|
||||
f.manager.setEnabled('rocket-launches', true, { origin: 'user' }),
|
||||
'Context-panel': async (f) =>
|
||||
(
|
||||
await f.controls.setContextMode('space-missions', {
|
||||
claimVisualAuthority: false,
|
||||
})
|
||||
).ok,
|
||||
};
|
||||
|
||||
for (const [path, enter] of Object.entries(entries))
|
||||
test(`a ${path} Space Missions entry that gets HTTP 429 rolls back instead of isolating`, async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.manager.setEnabled('earthquakes', true, { origin: 'user' });
|
||||
const before = f.view();
|
||||
|
||||
const entered = await enter(f);
|
||||
await f.settle();
|
||||
|
||||
assert.equal(entered, false, 'a failed mission index fails the enable');
|
||||
assert.deepEqual(f.enabledIds(), ['earthquakes']);
|
||||
assert.equal(f.controls.getContextModeState().mode, null);
|
||||
assert.equal(f.controls.getContextModeState().active, false);
|
||||
assert.equal(f.missions.getStats().error, 'HTTP 429');
|
||||
assert.deepEqual(f.view(), before, 'the pre-entry view comes back');
|
||||
assert.ok(f.camera.cancelled > 0, 'the full-globe flight is cancelled');
|
||||
|
||||
assert.equal(
|
||||
await f.manager.setEnabled('wind', true, { origin: 'user' }),
|
||||
true,
|
||||
'an unrelated layer is not blocked after the failed entry',
|
||||
);
|
||||
});
|
||||
|
||||
// Holds the /api/launches reply until the returned release is called.
|
||||
function holdReply(f) {
|
||||
let release;
|
||||
f.upstream.hold = new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
return () => {
|
||||
f.upstream.hold = null;
|
||||
release();
|
||||
};
|
||||
}
|
||||
|
||||
async function untilFlown(f) {
|
||||
for (let i = 0; i < 200 && f.camera.flights === 0; i++)
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
assert.ok(f.camera.flights > 0, 'the entry started its full-globe flight');
|
||||
}
|
||||
|
||||
test('a landed entry flight still gives the view back after a failed first load', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.manager.setEnabled('earthquakes', true, { origin: 'user' });
|
||||
const before = f.view();
|
||||
const release = holdReply(f);
|
||||
|
||||
const entering = f.manager.setEnabled('rocket-launches', true, {
|
||||
origin: 'user',
|
||||
});
|
||||
await untilFlown(f);
|
||||
f.camera.land();
|
||||
|
||||
release();
|
||||
assert.equal(await entering, false);
|
||||
await f.settle();
|
||||
assert.deepEqual(f.view(), before);
|
||||
});
|
||||
|
||||
test('a camera move after the entry flight survives a failed first load', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.manager.setEnabled('earthquakes', true, { origin: 'user' });
|
||||
const release = holdReply(f);
|
||||
|
||||
const entering = f.manager.setEnabled('rocket-launches', true, {
|
||||
origin: 'user',
|
||||
});
|
||||
await untilFlown(f);
|
||||
f.camera.land();
|
||||
// The user drags the globe while the mission index is still loading.
|
||||
f.camera.setView({
|
||||
destination: new Cesium.Cartesian3(7, 7, 7),
|
||||
orientation: { heading: 0.3, pitch: -0.2, roll: 0 },
|
||||
});
|
||||
const moved = f.view();
|
||||
|
||||
release();
|
||||
assert.equal(await entering, false);
|
||||
await f.settle();
|
||||
assert.deepEqual(f.view(), moved);
|
||||
});
|
||||
|
||||
test('a navigation during a failed first load is not cancelled by the rollback', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.manager.setEnabled('earthquakes', true, { origin: 'user' });
|
||||
const release = holdReply(f);
|
||||
|
||||
const entering = f.manager.setEnabled('rocket-launches', true, {
|
||||
origin: 'user',
|
||||
});
|
||||
await untilFlown(f);
|
||||
// Search or Home: navigation releases mission camera ownership, then flies.
|
||||
f.missions.releaseCameraOwnership();
|
||||
let navigationCancelled = false;
|
||||
f.camera.flyTo({
|
||||
destination: new Cesium.Cartesian3(5, 5, 5),
|
||||
cancel: () => {
|
||||
navigationCancelled = true;
|
||||
},
|
||||
});
|
||||
const cancelled = f.camera.cancelled;
|
||||
const navigated = f.view();
|
||||
|
||||
release();
|
||||
assert.equal(await entering, false);
|
||||
await f.settle();
|
||||
assert.equal(
|
||||
navigationCancelled,
|
||||
false,
|
||||
'the rollback does not cancel the Search/Home flight',
|
||||
);
|
||||
assert.equal(f.camera.cancelled, cancelled);
|
||||
assert.deepEqual(f.view(), navigated);
|
||||
});
|
||||
|
||||
test('the roster reports a failed mission index instead of loading forever', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.manager.setEnabled('rocket-launches', true, { origin: 'user' });
|
||||
await f.settle();
|
||||
|
||||
const roster = f.rosters.at(-1);
|
||||
assert.ok(roster, 'a failed load publishes a roster view');
|
||||
assert.deepEqual(roster.entries, []);
|
||||
assert.equal(roster.empty, '30-DAY MISSION INDEX UNAVAILABLE');
|
||||
|
||||
// A new attempt starts from the loading state, then shows the index.
|
||||
f.upstream.status = 200;
|
||||
const loadingFrom = f.rosters.length;
|
||||
assert.equal(
|
||||
await f.manager.setEnabled('rocket-launches', true, { origin: 'user' }),
|
||||
true,
|
||||
);
|
||||
await f.settle();
|
||||
assert.equal(f.rosters.slice(loadingFrom).includes(null), true);
|
||||
assert.equal(f.rosters.at(-1).entries.length, 1);
|
||||
assert.equal(f.controls.getContextModeState().mode, 'space-missions');
|
||||
});
|
||||
|
||||
test('a valid empty mission index is a successful entry', async (t) => {
|
||||
const f = await fixture(t);
|
||||
f.upstream.status = 200;
|
||||
f.upstream.results = [];
|
||||
assert.equal(
|
||||
await f.manager.setEnabled('rocket-launches', true, { origin: 'user' }),
|
||||
true,
|
||||
);
|
||||
await f.settle();
|
||||
assert.equal(f.controls.getContextModeState().mode, 'space-missions');
|
||||
assert.equal(
|
||||
f.rosters.at(-1).empty,
|
||||
'NO MISSIONS AVAILABLE IN THE CURRENT 30-DAY WINDOW',
|
||||
);
|
||||
assert.ok(f.camera.flights > 0, 'a successful entry frames the globe');
|
||||
});
|
||||
|
||||
test('a refresh failure after a loaded index keeps the mode and its records', async (t) => {
|
||||
const f = await fixture(t);
|
||||
f.upstream.status = 200;
|
||||
await f.manager.setEnabled('rocket-launches', true, { origin: 'user' });
|
||||
await f.settle();
|
||||
const framed = f.view();
|
||||
|
||||
f.upstream.status = 429;
|
||||
await f.missions.update();
|
||||
await f.settle();
|
||||
|
||||
assert.equal(f.manager.isEnabled('rocket-launches'), true);
|
||||
assert.equal(f.controls.getContextModeState().mode, 'space-missions');
|
||||
assert.equal(f.missions.getStats().count, 1);
|
||||
assert.equal(f.missions.getStats().error, 'HTTP 429');
|
||||
assert.equal(f.rosters.at(-1).entries.length, 1);
|
||||
|
||||
// Leaving a loaded mode does not restore the pre-entry view.
|
||||
await f.manager.setEnabled('rocket-launches', false, { origin: 'user' });
|
||||
await f.settle();
|
||||
assert.deepEqual(f.view(), framed);
|
||||
});
|
||||
test('exiting during a successful entry flight cancels the mission-owned flight', async (t) => {
|
||||
const f = await fixture(t);
|
||||
f.upstream.status = 200;
|
||||
assert.equal(
|
||||
await f.manager.setEnabled('rocket-launches', true, { origin: 'user' }),
|
||||
true,
|
||||
);
|
||||
await f.settle();
|
||||
assert.ok(f.camera.flight, 'the 2.4 s entry flight is still in progress');
|
||||
|
||||
await f.manager.setEnabled('rocket-launches', false, { origin: 'user' });
|
||||
await f.settle();
|
||||
assert.equal(
|
||||
f.camera.flight,
|
||||
null,
|
||||
'leaving Space Missions stops the flight it started (main cancels it)',
|
||||
);
|
||||
});
|
||||
// The rollback hides the roster, so the toast has to name the cause.
|
||||
test('a failed first load tells the user the mission index is unavailable', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.manager.setEnabled('earthquakes', true, { origin: 'user' });
|
||||
|
||||
await f.controls.setContextMode('space-missions', {
|
||||
claimVisualAuthority: false,
|
||||
});
|
||||
await f.settle();
|
||||
assert.equal(f.controls.getContextModeState().mode, null, 'rolled back');
|
||||
assert.ok(
|
||||
f.toasts.some((message) => /mission index/i.test(message)),
|
||||
`visible feedback names the failure, got ${JSON.stringify(f.toasts)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('navigation taking the camera keeps a mission flight it asked to preserve', async (t) => {
|
||||
const f = await fixture(t);
|
||||
f.upstream.status = 200;
|
||||
await f.manager.setEnabled('rocket-launches', true, { origin: 'user' });
|
||||
await f.settle();
|
||||
const flight = f.camera.flight;
|
||||
assert.ok(flight, 'the entry flight is in progress');
|
||||
f.missions.releaseCameraOwnership();
|
||||
assert.equal(f.camera.flight, flight, 'navigation decides about the flight');
|
||||
// After exit an inactive layer never cancels another owner's flight.
|
||||
await f.manager.setEnabled('rocket-launches', false, { origin: 'user' });
|
||||
await f.settle();
|
||||
f.camera.flyTo({ destination: new Cesium.Cartesian3(1, 1, 1) });
|
||||
const other = f.camera.flight;
|
||||
f.missions.releaseCameraOwnership();
|
||||
assert.equal(f.camera.flight, other);
|
||||
});
|
||||
|
||||
test("an earlier attempt's index error is not blamed for a later failure", async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.manager.setEnabled('rocket-launches', true, { origin: 'user' });
|
||||
await f.settle();
|
||||
assert.match(spaceMissionsIndexFailure(f.manager), /mission index/);
|
||||
assert.equal(spaceMissionsIndexFailure(f.manager, Date.now() + 1), null);
|
||||
});
|
||||
@@ -50,8 +50,9 @@ export function createIngestion({
|
||||
layerState._postTleRetryCount++;
|
||||
layerState._retryTimer = setTimeout(() => {
|
||||
layerState._retryTimer = null;
|
||||
// A failed retry is already recorded in the layer stats.
|
||||
if (layerState._enabled && token === layerState._lifecycleToken)
|
||||
requestMissionUpdate();
|
||||
requestMissionUpdate().catch(() => {});
|
||||
}, POST_TLE_RETRY_DELAY_MS);
|
||||
}
|
||||
|
||||
@@ -201,6 +202,10 @@ export function createIngestion({
|
||||
if (layerState._renderedTleText === layerState._activeTleText)
|
||||
clearPostTleRetry();
|
||||
layerState._count = launches.length;
|
||||
// The roster reads these to tell a loaded empty index from loading.
|
||||
layerState._lastUpdate = Date.now();
|
||||
layerState._lastError = null;
|
||||
layerState._stale = stale === true;
|
||||
parts.panel.renderMissionRoster();
|
||||
if (
|
||||
!layerState._selectedLaunchId ||
|
||||
@@ -219,13 +224,18 @@ export function createIngestion({
|
||||
}
|
||||
layerState._focusAfterActiveLookup = false;
|
||||
parts.panel.renderMissionPanel();
|
||||
layerState._lastUpdate = Date.now();
|
||||
layerState._lastError = null;
|
||||
layerState._stale = stale === true;
|
||||
layerState._entryCameraView = null;
|
||||
} catch (error) {
|
||||
if (layerState._enabled && token === layerState._lifecycleToken) {
|
||||
layerState._lastError = error.message;
|
||||
layerState._lastErrorAt = Date.now();
|
||||
console.warn('[Data:RocketLaunches] Fetch error:', error);
|
||||
// No index yet: fail the enable so Context rolls back.
|
||||
// After a loaded index, a failed refresh keeps its records.
|
||||
if (layerState._lastUpdate === null) {
|
||||
parts.panel.renderMissionRoster();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,6 +111,43 @@ export function createLaunchHover({
|
||||
};
|
||||
}
|
||||
|
||||
/** The camera pose to give back, in world coordinates. */
|
||||
function captureCameraView(viewer) {
|
||||
const camera = viewer?.camera;
|
||||
if (!camera?.positionWC) return null;
|
||||
return {
|
||||
destination: Cesium.Cartesian3.clone(camera.positionWC),
|
||||
orientation: {
|
||||
heading: camera.heading,
|
||||
pitch: camera.pitch,
|
||||
roll: camera.roll,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function sameCameraView(camera, view) {
|
||||
return (
|
||||
Cesium.Cartesian3.equalsEpsilon(
|
||||
camera.positionWC,
|
||||
view.destination,
|
||||
0,
|
||||
1,
|
||||
) &&
|
||||
['heading', 'pitch', 'roll'].every((key) =>
|
||||
Cesium.Math.equalsEpsilon(camera[key], view.orientation[key], 0, 1e-6),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** Restore the pre-entry view if the entry flight still holds the camera. */
|
||||
function restoreCameraView(viewer, view, landedView) {
|
||||
const camera = viewer?.camera;
|
||||
if (!camera) return;
|
||||
if (!landedView) camera.cancelFlight?.();
|
||||
else if (!sameCameraView(camera, landedView)) return;
|
||||
camera.setView(view);
|
||||
}
|
||||
|
||||
export function createLifecycle({
|
||||
state: layerState,
|
||||
services,
|
||||
@@ -200,7 +237,27 @@ export function createLifecycle({
|
||||
layerState._updateDirty = false;
|
||||
if (layerState._dataSource) layerState._dataSource.show = true;
|
||||
parts.orbitRendering.syncMissionOrbitPrimitiveVisibility();
|
||||
parts.selection.focusFullGlobe(layerState._viewer);
|
||||
if (layerState._lastUpdate === null) {
|
||||
// A new attempt at an index that never loaded starts from loading.
|
||||
layerState._lastError = null;
|
||||
parts.panel.renderMissionRoster();
|
||||
// Kept until the mission index loads, so an entry that never does
|
||||
// gives the view back. A newer flight takes the camera and drops it.
|
||||
layerState._entryCameraView = captureCameraView(layerState._viewer);
|
||||
layerState._entryLandedView = null;
|
||||
}
|
||||
const token = layerState._lifecycleToken;
|
||||
const flying = parts.selection.focusFullGlobe(layerState._viewer, {
|
||||
complete: () => {
|
||||
if (token === layerState._lifecycleToken)
|
||||
layerState._entryLandedView = captureCameraView(layerState._viewer);
|
||||
},
|
||||
cancel: () => {
|
||||
if (token === layerState._lifecycleToken)
|
||||
layerState._entryCameraView = null;
|
||||
},
|
||||
});
|
||||
if (!flying) layerState._entryCameraView = null;
|
||||
if (layerState._selectedLaunchId)
|
||||
parts.selection.setSelectedMission(
|
||||
layerState._selectedLaunchId,
|
||||
@@ -214,12 +271,21 @@ export function createLifecycle({
|
||||
hover?.reset();
|
||||
layerState._sourceController.abort();
|
||||
layerState._enabled = false;
|
||||
const entryView = layerState._entryCameraView;
|
||||
layerState._entryCameraView = null;
|
||||
if (entryView)
|
||||
restoreCameraView(
|
||||
layerState._viewer,
|
||||
entryView,
|
||||
layerState._entryLandedView,
|
||||
);
|
||||
releaseContinuousRender('rocket-launches');
|
||||
layerState._lifecycleToken++;
|
||||
layerState._updateDirty = false;
|
||||
parts.ingestion.clearPostTleRetry();
|
||||
parts.panel.clearMissionRosterHover();
|
||||
parts.replay.stopMissionReplay();
|
||||
layerState._cameraReleased = true;
|
||||
parts.selection.stopMissionZoomAnchor();
|
||||
if (layerState._dataSource) layerState._dataSource.show = false;
|
||||
parts.orbitRendering.syncMissionOrbitPrimitiveVisibility();
|
||||
@@ -244,6 +310,7 @@ export function createLifecycle({
|
||||
await parts.ingestion.restoreSatelliteDependency();
|
||||
parts.panel.clearMissionRosterHover();
|
||||
parts.replay.stopMissionReplay();
|
||||
layerState._cameraReleased = true;
|
||||
parts.selection.stopMissionZoomAnchor();
|
||||
parts.ingestion.clearPostTleRetry();
|
||||
if (layerState._clickHandler) layerState._clickHandler.destroy();
|
||||
|
||||
@@ -263,8 +263,16 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
||||
publishPanel();
|
||||
}
|
||||
|
||||
/** The roster entries as the list shows them. */
|
||||
/** The roster as listed; null until the first index loads or fails. */
|
||||
function presentRoster() {
|
||||
if (layerState._lastUpdate === null && !layerState._launches.length) {
|
||||
if (!layerState._lastError) return null;
|
||||
return {
|
||||
count: '—',
|
||||
entries: [],
|
||||
empty: t('30-DAY MISSION INDEX UNAVAILABLE'),
|
||||
};
|
||||
}
|
||||
const entries = parts.policyHelpers
|
||||
.missionRosterEntries(layerState._launches)
|
||||
.map(({ launch, index }) => {
|
||||
|
||||
@@ -229,6 +229,7 @@ export function createPolicyHelpers({
|
||||
layerState._viewer.camera.positionCartographic?.height,
|
||||
);
|
||||
const position = Cesium.Cartesian3.fromDegrees(launch.lon, launch.lat);
|
||||
layerState._cameraReleased = false;
|
||||
layerState._viewer.camera.flyToBoundingSphere(
|
||||
new Cesium.BoundingSphere(position, 0),
|
||||
{
|
||||
|
||||
@@ -314,9 +314,15 @@ export function createReplay({ state: layerState, services, parts, source }) {
|
||||
layerState._replayCameraToken++;
|
||||
if (layerState._replayCameraRemover) layerState._replayCameraRemover();
|
||||
layerState._replayCameraRemover = null;
|
||||
layerState._viewer?.camera?.cancelFlight();
|
||||
if (layerState._viewer?.camera)
|
||||
// Cancel the layer's own flights, never one navigation started after
|
||||
// taking the camera.
|
||||
if (
|
||||
(stoppedLaunchId || !layerState._cameraReleased) &&
|
||||
layerState._viewer?.camera
|
||||
) {
|
||||
layerState._viewer.camera.cancelFlight();
|
||||
layerState._viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
|
||||
}
|
||||
layerState._replayCameraLaunchId = null;
|
||||
layerState._replayPaused = false;
|
||||
layerState._replayPausedAtMs = null;
|
||||
|
||||
@@ -9,7 +9,8 @@ export function createSelection({
|
||||
}) {
|
||||
const { getKeyholeGeometry } = services.geometry;
|
||||
|
||||
function focusFullGlobe(viewer, duration = 2.4) {
|
||||
/** @returns {boolean} Whether the flight started. */
|
||||
function focusFullGlobe(viewer, { duration = 2.4, complete, cancel } = {}) {
|
||||
const canvas = viewer?.scene?.canvas;
|
||||
const height = canvas?.clientHeight || canvas?.height;
|
||||
const width = canvas?.clientWidth || canvas?.width;
|
||||
@@ -23,7 +24,7 @@ export function createSelection({
|
||||
fovy <= 0 ||
|
||||
fovy >= Math.PI
|
||||
)
|
||||
return;
|
||||
return false;
|
||||
const earthRadius = Cesium.Ellipsoid.WGS84.maximumRadius;
|
||||
const keyholeRadius = getKeyholeGeometry(width, height).radius;
|
||||
const targetScreenRadius = keyholeRadius * 0.61;
|
||||
@@ -32,6 +33,7 @@ export function createSelection({
|
||||
);
|
||||
const distance = earthRadius / Math.max(Math.sin(angularRadius), 1e-4);
|
||||
const altitude = Math.max(earthRadius * 1.55, distance - earthRadius);
|
||||
layerState._cameraReleased = false;
|
||||
viewer.camera.flyTo({
|
||||
destination: Cesium.Cartesian3.fromRadians(
|
||||
cartographic.longitude,
|
||||
@@ -45,7 +47,10 @@ export function createSelection({
|
||||
},
|
||||
duration,
|
||||
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
|
||||
complete,
|
||||
cancel,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function entityLaunchId(entity) {
|
||||
@@ -187,6 +192,7 @@ export function createSelection({
|
||||
Cesium.Cartesian3.cross(right, direction, new Cesium.Cartesian3()),
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
layerState._cameraReleased = false;
|
||||
layerState._viewer.camera.flyTo({
|
||||
destination,
|
||||
orientation: { direction, up },
|
||||
@@ -209,6 +215,7 @@ export function createSelection({
|
||||
layerState._viewer.selectedEntity = layerState._dataSource.entities.getById(
|
||||
`rocket-launch:${launch.id}`,
|
||||
);
|
||||
layerState._cameraReleased = false;
|
||||
layerState._viewer.camera.flyToBoundingSphere(
|
||||
new Cesium.BoundingSphere(launchPosition, 0),
|
||||
{
|
||||
|
||||
@@ -19,6 +19,15 @@ export function createState({ services }) {
|
||||
|
||||
state._lastError = null;
|
||||
|
||||
state._lastErrorAt = null;
|
||||
|
||||
state._entryCameraView = null;
|
||||
|
||||
state._entryLandedView = null;
|
||||
|
||||
/** Navigation took the camera; set until the layer flies again. */
|
||||
state._cameraReleased = false;
|
||||
|
||||
state._stale = false;
|
||||
|
||||
state._orbitMatches = 0;
|
||||
|
||||
@@ -126,8 +126,17 @@ export function createStreetLevelLayer({
|
||||
});
|
||||
}
|
||||
|
||||
// One source at a time: the first provider that defaults on.
|
||||
const startsOn = defs.find((def) => def.defaultOn !== false);
|
||||
/** `def` if its key is ready, else the first keyless provider not refused. */
|
||||
function keylessInstead(def, refused = () => false) {
|
||||
if (def.keyReady?.() !== false) return def;
|
||||
return defs.find((d) => !d.requiresKeyId && !refused(d.id)) || def;
|
||||
}
|
||||
|
||||
// One source at a time: the first provider that defaults on. Without its
|
||||
// key, the first keyless source instead, so a fresh keyless install opens
|
||||
// on coverage rather than KEY REQUIRED.
|
||||
const preferred = defs.find((def) => def.defaultOn !== false);
|
||||
const startsOn = preferred && keylessInstead(preferred);
|
||||
for (const def of defs) {
|
||||
const entry = {
|
||||
def,
|
||||
@@ -585,7 +594,17 @@ export function createStreetLevelLayer({
|
||||
providerIds: state.providers.keys(),
|
||||
filter: state.filter,
|
||||
});
|
||||
const lit = litByParams(decoded.providers);
|
||||
// A restored "Mapillary on" (the codec default) would light a source a
|
||||
// keyless install cannot draw. Only redirect a provider the params switch
|
||||
// on: a filter-only update keeps whatever the user chose.
|
||||
const asked = litByParams(decoded.providers);
|
||||
const lit =
|
||||
asked && decoded.providers.get(asked) === true
|
||||
? keylessInstead(
|
||||
state.providers.get(asked).def,
|
||||
(id) => decoded.providers.get(id) === false,
|
||||
).id
|
||||
: asked;
|
||||
const before = litIds();
|
||||
for (const [id, entry] of state.providers)
|
||||
if (id !== lit) switchProvider(entry, false);
|
||||
|
||||
@@ -1127,3 +1127,59 @@ test('the panel says why a lit Street View draws nothing off Google 3D', async (
|
||||
const view = presentStreetLevelPanel(layer.getUIState());
|
||||
assert.match(view.meta, /Google Street View needs the Google 3D map/);
|
||||
});
|
||||
|
||||
/** Mapillary (keyless unless `mapillary` says otherwise) plus KartaView. */
|
||||
function keylessLayer(t, mapillary = { hasToken: () => false }) {
|
||||
const layer = createStreetLevelLayer({
|
||||
providers: [
|
||||
createMapillaryProvider({
|
||||
source: fakeMapillarySource(mapillary),
|
||||
photoViewer: fakePhotoViewer(),
|
||||
}),
|
||||
createKartaViewProvider(),
|
||||
],
|
||||
});
|
||||
t.after(() => layer.destroy());
|
||||
return layer;
|
||||
}
|
||||
const litProviders = (layer) =>
|
||||
layer
|
||||
.getUIState()
|
||||
.providers.filter((provider) => provider.on)
|
||||
.map((provider) => provider.id);
|
||||
|
||||
test('without a Mapillary key the first keyless source is lit instead', (t) => {
|
||||
assert.deepEqual(litProviders(keylessLayer(t)), ['kartaview']);
|
||||
assert.deepEqual(litProviders(keylessLayer(t, {})), ['mapillary']);
|
||||
// A token-adding Graph proxy counts as a key.
|
||||
const proxied = keylessLayer(t, {
|
||||
hasToken: () => false,
|
||||
graphEndpoint: '/api/mapillary/graph',
|
||||
});
|
||||
assert.deepEqual(litProviders(proxied), ['mapillary']);
|
||||
});
|
||||
|
||||
test('restoring the default "Mapillary on" without its key keeps a keyless source', (t) => {
|
||||
const layer = keylessLayer(t);
|
||||
layer.setParams({ mapillary: true, providerSwitches: '' });
|
||||
assert.deepEqual(litProviders(layer), ['kartaview']);
|
||||
// A link that switches KartaView off keeps its word.
|
||||
layer.setParams({ mapillary: true, providerSwitches: 'kartaview-0' });
|
||||
assert.deepEqual(litProviders(layer), ['mapillary']);
|
||||
});
|
||||
|
||||
test('filter-only updates keep the lit source, even a chosen keyless Mapillary', (t) => {
|
||||
const layer = keylessLayer(t);
|
||||
layer.setParams({ pano: 'pano' });
|
||||
assert.deepEqual(litProviders(layer), ['kartaview']);
|
||||
// What the MAPILLARY chip sends, and what stored state restores.
|
||||
layer.setParams({ mapillary: true, providerSwitches: 'kartaview-0' });
|
||||
for (const params of [{ pano: 'pano' }, { sinceDays: 365 }, {}]) {
|
||||
layer.setParams(params);
|
||||
assert.deepEqual(
|
||||
litProviders(layer),
|
||||
['mapillary'],
|
||||
JSON.stringify(params),
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -74,6 +74,9 @@ export function createMapillaryProvider({ source, photoViewer = null }) {
|
||||
name: MAPILLARY_NAME,
|
||||
label: MAPILLARY_LABEL,
|
||||
requiresKeyId: MAPILLARY_KEY_ID,
|
||||
/** A browser token, or a Graph proxy that adds one server-side. */
|
||||
keyReady: () =>
|
||||
source.hasToken?.() !== false || Boolean(source.graphEndpoint),
|
||||
pickPrefix: PICK_PREFIX.root,
|
||||
colors: COLORS,
|
||||
credit: Object.freeze({ html: MAPILLARY_CREDIT_HTML }),
|
||||
|
||||
@@ -20,6 +20,8 @@ export function filtersOf(provider) {
|
||||
* @property {boolean} [groundClick] No coverage: a ground click at street zoom opens its nearest image.
|
||||
* @property {string} [requiresMapStack] Usable only on this map stack.
|
||||
* @property {boolean} [defaultOn] Default true; the first that defaults on is lit.
|
||||
* @property {() => boolean} [keyReady] False when its key is missing now; the
|
||||
* first keyless provider is lit instead of a default that cannot draw.
|
||||
* @property {{kind: 'view-cone', color: string}} [marker] Default: GEV's pin.
|
||||
* @property {Array<'pano'|'since'>} [filters] Filters it honours (default both).
|
||||
* @property {{noun: string, action: string}} [coverageLabel] How the panel counts its lines and says what a click does (default: sequences, "click a line for its photos").
|
||||
|
||||
@@ -4,6 +4,19 @@ import {
|
||||
contextModeWord,
|
||||
} from '../contextModePolicy.js';
|
||||
|
||||
/** Why a Space Missions entry failed: its mission index never loaded, and
|
||||
* the error is no older than `since` (an earlier attempt's is not the cause). */
|
||||
export function spaceMissionsIndexFailure(dataManager, since = 0) {
|
||||
const stats = dataManager?.layers
|
||||
?.get('rocket-launches')
|
||||
?.module?.getStats?.();
|
||||
return stats?.error && stats.lastUpdate == null && stats.errorAt >= since
|
||||
? t(
|
||||
'Space Missions unavailable: the mission index could not load. Try again later.',
|
||||
)
|
||||
: null;
|
||||
}
|
||||
|
||||
export async function _runUserFacingContextAction(
|
||||
operation,
|
||||
message = t('Context could not restore every layer; try again'),
|
||||
@@ -19,7 +32,10 @@ export async function _runUserFacingContextAction(
|
||||
onFailure: (error) => {
|
||||
if (this.destroyed) return;
|
||||
console.warn('[Context] user-facing transition failed', error);
|
||||
this.showToast(localizedMessage || message);
|
||||
this.showToast(
|
||||
localizedMessage ||
|
||||
(typeof message === 'function' ? message() : message),
|
||||
);
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { t } from '../i18n/index.js';
|
||||
import { spaceMissionsIndexFailure } from './contextActions.js';
|
||||
import { shouldExpandGlobalContextPanel } from '../rightRailPolicy.js';
|
||||
|
||||
export function _initGlobalContextPanel() {
|
||||
@@ -59,9 +60,12 @@ export function _initGlobalContextPanel() {
|
||||
const nextMode =
|
||||
this._contextMode === 'space-missions' ? null : 'space-missions';
|
||||
this._claimContextVisualAuthority();
|
||||
const startedAt = Date.now();
|
||||
void this._runUserFacingContextAction(
|
||||
(notificationToken) =>
|
||||
this._selectContextMode(nextMode, { notificationToken }),
|
||||
() =>
|
||||
(nextMode && spaceMissionsIndexFailure(this._dataManager, startedAt)) ||
|
||||
t(
|
||||
'Space Missions could not complete the requested transition; try again',
|
||||
),
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
spaceMissionEntryCancellationDisposition,
|
||||
} from '../contextModePolicy.js';
|
||||
import { localizeLayerName } from './layerNames.js';
|
||||
import { spaceMissionsIndexFailure } from './contextActions.js';
|
||||
|
||||
/**
|
||||
* A blocked-visibility reason in the UI language. Reasons stay English (voice
|
||||
@@ -120,7 +121,11 @@ export function _handleContextLayerChange(change) {
|
||||
return;
|
||||
}
|
||||
if (change?.type === 'visibility-failed') {
|
||||
const failureMessage = t('{layerId} could not {action} cleanly', {
|
||||
const failureMessage =
|
||||
(change.layerId === 'rocket-launches' &&
|
||||
change.enabled &&
|
||||
spaceMissionsIndexFailure(this._dataManager)) ||
|
||||
t('{layerId} could not {action} cleanly', {
|
||||
layerId: change.layerId,
|
||||
action: change.enabled ? t('start') : t('stop'),
|
||||
});
|
||||
|
||||
@@ -295,10 +295,10 @@ test('area, imagery and OSM card lines are shown in the UI language', () => {
|
||||
notDrawn: 'the map was cleared while looking it up',
|
||||
};
|
||||
const areaLines = inLocale('de', () => presentResult('resolve_area', area).display.lines);
|
||||
assert.ok(areaLines.includes('250 m um das Wahrzeichen'), areaLines.join(' | '));
|
||||
assert.ok(areaLines.includes('Teile: 3'));
|
||||
assert.ok(areaLines.includes('Gezeichnete Teile: 2 von 3'));
|
||||
assert.ok(areaLines.some((line) => line.includes('die Karte wurde während der Suche geleert')));
|
||||
assert.ok(areaLines.includes('250 m um den Ort'), areaLines.join(' | '));
|
||||
assert.ok(areaLines.includes('3 Teile'));
|
||||
assert.ok(areaLines.includes('2 von 3 Teilen gezeichnet'));
|
||||
assert.ok(areaLines.some((line) => line.includes('die Zeichenfläche wurde während der Suche geleert')));
|
||||
const osm = {
|
||||
ok: true,
|
||||
kind: 'hospitals',
|
||||
@@ -316,7 +316,7 @@ test('area, imagery and OSM card lines are shown in the UI language', () => {
|
||||
assert.match(osmCard.title, /\bhospital\b/);
|
||||
assert.doesNotMatch(osmCard.title, /hospitals/, 'one place takes the singular');
|
||||
assert.equal(presentResult('osm_query', { ...osm, complete: true }).display.title, '1 hospital');
|
||||
assert.ok(osmCard.lines.includes('Zusammengeführte Doppelkartierungen: 2'));
|
||||
assert.ok(osmCard.lines.includes('2 Duplikate zusammengeführt'));
|
||||
assert.deepEqual(osmCard.notes, ['die ersten 500 aufgelistet', 'es gibt weitere']);
|
||||
const imagery = {
|
||||
ok: true,
|
||||
|
||||
Reference in new issue
Block a user