From 5bb489d1fb2c2460a3567029393745056a3c324e Mon Sep 17 00:00:00 2001 From: Denis Groshev Date: Thu, 5 Nov 2026 20:03:44 +0100 Subject: [PATCH] Put news on the globe, with a feed panel in the right rail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Events ▸ News draws the last 24 hours of reports from curated hotspots (Ukraine–Russia, Israel–Gaza–Lebanon, Iran–Hormuz, Yemen–Red Sea, Sudan, Sahel) and developing situations elsewhere, from any server that answers the `/api/news` read routes. - Globe: one marker per merged event, clamped to the ground and culled at the horizon; one beacon per hotspot when zoomed out and a pulsing beacon for a developing situation; hotspot outlines with disputed areas dotted and labelled by who administers and who claims them; picture cards for at most ten major or fast-growing events with two or more sources, never overlapping, switchable off. - Panel: hotspot chips, a severity filter (significant and above by default), categories, minor news off by default, and a scrolling list of event cards with picture, headline, place, sources, age and the sides that reported it. "In view" lists what is near the map below 3,000 km once the camera settles. A card flies the globe to its event and opens it with a link to the publisher. Reduced motion stops the pulse and the flights. - Core serves no news itself: the local server proxies GEV_NEWS_ORIGIN when it is set, and answers 503 unavailable otherwise, so the layer reads "not configured" once instead of retrying. - Share token 9 (enabled-only); voice stays off for this layer. --- .env.example | 5 + DATA_SOURCES.md | 1 + changelog.d/1-news-layer.md | 6 + docs/CURRENT-STATE.md | 8 + scripts/package-boundaries.json | 29 + server/providers/local.js | 2 + server/providers/news.js | 109 ++ server/providers/news.test.mjs | 135 +++ src/app/constructCatalog.js | 2 + src/app/constructCatalog.test.mjs | 5 +- src/app/layers/news.js | 6 + src/app/sourceComposition.js | 1 + src/app/sourceComposition.test.mjs | 31 +- src/data/dataCredits.js | 8 + src/data/layerState.js | 1 + src/data/layerState.test.mjs | 34 +- src/data/layerStateTokenLedger.test.mjs | 64 +- src/data/layerStateTokenReservations.json | 1 + src/i18n/locales/ar.json | 46 + src/i18n/locales/de.json | 38 + src/i18n/locales/es.json | 40 + src/i18n/locales/fr.json | 40 + src/i18n/locales/it.json | 40 + src/i18n/locales/ja.json | 36 + src/i18n/locales/pt.json | 40 + src/i18n/locales/ru.json | 42 + src/i18n/locales/uk.json | 42 + src/i18n/locales/zh-CN.json | 36 + src/i18n/locales/zh-Hant.json | 36 + src/layers/news/index.js | 1240 +++++++++++++++++++++ src/layers/news/layer.test.mjs | 255 +++++ src/navigationPolicy.js | 2 + src/navigationPolicy.test.mjs | 1 + src/standalone/layerSources.js | 5 + src/ui/layerBindings.js | 9 + src/ui/layerNames.js | 1 + src/ui/layerPanel.js | 20 + src/ui/news/NewsCard.vue | 127 +++ src/ui/news/NewsFeed.vue | 217 ++++ src/ui/newsPanel.js | 73 ++ src/ui/newsPanel.test.mjs | 223 ++++ src/ui/panelChrome.js | 2 + src/ui/panelLayoutController.js | 3 + src/ui/panelRailCatalog.js | 1 + src/ui/styles/news.css | 378 +++++++ src/ui/views/ContextView.vue | 4 + src/voice/layerManifest.js | 1 + style.css | 1 + 48 files changed, 3394 insertions(+), 53 deletions(-) create mode 100644 changelog.d/1-news-layer.md create mode 100644 server/providers/news.js create mode 100644 server/providers/news.test.mjs create mode 100644 src/app/layers/news.js create mode 100644 src/layers/news/index.js create mode 100644 src/layers/news/layer.test.mjs create mode 100644 src/ui/news/NewsCard.vue create mode 100644 src/ui/news/NewsFeed.vue create mode 100644 src/ui/newsPanel.js create mode 100644 src/ui/newsPanel.test.mjs create mode 100644 src/ui/styles/news.css diff --git a/.env.example b/.env.example index b3de4cefa..d026e35e2 100644 --- a/.env.example +++ b/.env.example @@ -317,3 +317,8 @@ GEMINI_CHAT_MODEL=gemini-3.5-flash-lite # Each reserves up to 40 stations within the existing 750-station directory. # Restart after changing. Leave blank for the existing global selection. # RADIO_PRIORITY_COUNTRIES=IN,PL +# Optional: a news server for the News layer (its /api/news/hotspots, /feed +# and /outline routes), such as https://news.example. The dev server forwards +# only the layer's own GET requests there, with a 15 s timeout. Unset, Core +# serves no news and the layer shows itself unavailable. SERVER-SIDE ONLY. +# GEV_NEWS_ORIGIN= diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index ed0895b0a..5bddb2349 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -39,6 +39,7 @@ How to read this: | **NASA GIBS** (WMTS tiles + Worldview Snapshots) and **NASA CMR** (granule catalog) | Recent Imagery layer: HLS S30/L30 (Sentinel-2, Landsat 8/9) and VIIRS true-colour tiles draped on the map, per-day thumbnails and PNG exports; the HLS granule catalog (dates, cloud, footprints) for a selected box | U.S. public domain (NASA data); keyless, browser-direct (CORS `*`); no bulk use — one 30-day catalog query per box, tiles bounded to the box, six tile requests in flight | "We acknowledge the use of imagery provided by services from NASA's Global Imagery Browse Services (GIBS), part of NASA's Earth Science Data and Information System (ESDIS)." — [gibs.earthdata.nasa.gov](https://gibs.earthdata.nasa.gov), [HLS](https://lpdaac.usgs.gov/products/hlss30v002/) | | **NIFC WFIGS** (Wildland Fire Interagency Geospatial Services, ArcGIS feature service) | Fire Perimeters layer: current interagency wildfire incident perimeters, containment, and incident facts | U.S. public domain (interagency wildland-fire data published through the [NIFC Open Data portal](https://data-nifc.opendata.arcgis.com/)); fetched keyless through `/api/fire-perimeters` (5-minute server cache) with `exceededTransferLimit` paging | "Wildfire perimeters: National Interagency Fire Center (WFIGS)" | | **S2 Underground GhostMaps** (`github.com/s2underground/GhostMaps`) | S2 GhostMaps layer: the current edition of S2's KMZ intel products (Common Intelligence Picture, 30-day kinetic pull, Border Crisis Map, Iran and Yemen products, newest incident drop). Keyless. `/api/ghostmaps` reads the branch head from git's ref advertisement every 15 s (`GHOSTMAPS_POLL_SECONDS`, minimum 5 s), lists the tree through the GitHub REST API only when the head changes (optional `GITHUB_TOKEN`), downloads files by commit-pinned raw URLs into `.gev-cache/ghostmaps/` and pushes new catalogs to browsers over server-sent events | [CC BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/) (repository README: "S2 Underground © 2025 by S2A1"); non-commercial, attribution required; files are fetched at runtime, never stored in this repository | "S2 GhostMaps intel products: S2 Underground © by S2A1, CC BY-NC-SA 4.0" — shown in-app | +| **News server** (`GEV_NEWS_ORIGIN`, optional; none by default) | News layer: curated hotspots and the last 24 h of reports from many publishers, merged into events. Core serves no news itself; the local server forwards the layer's `/api/news/hotspots`, `/feed` and `/outline` GET requests to the configured server | Each report stays its publisher's: headlines and summaries link to the original article, and pictures are hotlinked from the publisher's site (no referrer), never stored | Per report: publisher name and article link on every card; picture cards name the picture's publisher. No single provider is credited for the feed | | **InciWeb** (inciweb.wildfire.gov) | Per-incident "InciWeb ↗" card links to official incident pages (narratives, evacuation/closure notices) | U.S. government public incident information. Links are matched best-effort by incident name and state, with incident page origin and update times checked on selection. Same-origin `/api/fire-perimeters/inciweb` routes cache the catalog for 1 hour and publication metadata for 30 minutes; linked pages remain InciWeb content | "Incident information: InciWeb (inciweb.wildfire.gov)" | | **FAA Special Use Airspace** (`services6.arcgis.com` FeatureServer `Special_Use_Airspace`) | Prohibited & Restricted Airspace layer: US prohibited (P) and restricted (R) special-use airspace polygons (not the UAS Facility Map or UAS flight restrictions), altitude limits from the `*_VAL`/`*_UOM`/`*_CODE` fields, times of use, and controlling agency | U.S. public domain (FAA aeronautical data published through the [FAA AIS Open Data portal](https://adds-faa.opendata.arcgis.com/)); fetched keyless through `/api/drone-airspace` (1-hour server cache). Informational only — not a substitute for official UAS flight-planning tools | "Prohibited & restricted airspace: FAA Special Use Airspace" | | **OpenFreeMap / OpenMapTiles** | Vector tiles | OpenStreetMap data under ODbL 1.0 | OpenFreeMap © OpenMapTiles | diff --git a/changelog.d/1-news-layer.md b/changelog.d/1-news-layer.md new file mode 100644 index 000000000..081316b9d --- /dev/null +++ b/changelog.d/1-news-layer.md @@ -0,0 +1,6 @@ +- Add the News layer (Events ▸ News) and its right-rail NEWS panel: the last + 24 hours from curated hotspots, coloured by severity only, with hotspot + beacons, outlines with disputed areas, picture cards for major events, an + "In view" list below 3,000 km and a continuous feed whose cards fly the globe + to the event. Core serves no news itself: set `GEV_NEWS_ORIGIN` to a news + server and the local server forwards the layer's three read routes there. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 084ad4f83..4a52c0d74 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4216,6 +4216,7 @@ its criteria cannot be silently ignored. | Ski Pistes ⛷ (`ski-pistes`) | OpenSnowMap piste raster tiles (keyless; CC BY-SA tiles, OSM data ODbL; no bulk download) | `src/data/osmOverlays.js` | — (browser-direct tiles) | on view (Cesium tile requests; no polling) | | Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) | | Prohibited & Restricted Airspace 🚫 | FAA Special Use Airspace prohibited (P) and restricted (R) zones (keyless FeatureServer, simplified geometry) | `src/layers/droneAirspace/` via `src/app/layers/droneAirspace.js` | `/api/drone-airspace` | 1 h (server cache 1 h; stale-on-error) | +| News ◆ (`news`) | A news server's `/api/news` read routes (curated hotspots, last 24 h of reports merged into events, outlines); Core serves none itself | `src/layers/news/` via `src/app/layers/news.js`; panel `src/ui/newsPanel.js` | `/api/news/hotspots`, `/api/news/feed`, `/api/news/outline` (local dev proxies `GEV_NEWS_ORIGIN`; unset answers "not configured") | 2 min map refresh; In view re-read when the camera settles 0.8 s in a new half-degree cell below 3,000 km | GNSS Integrity samples a 250 nm circle around the Earth point under the screen centre. Camera reads debounce for 600 ms with a monotonic 2.5 s minimum gap; @@ -5846,6 +5847,13 @@ request viewbox and continues searching without that geographic preference. - Pure half: `src/annotations/drawMode.js` (session, minimum vertices, double-click de-dup at 0.5 m, vertex ceiling, degenerate-geometry refusal, ring closing, dateline-safe length/area/centroid, hint text). Regression surface: `src/annotations/drawMode.test.mjs` and `src/data/inputOwnership.test.mjs` (pure), `src/annotations/drawToolBehaviour.test.mjs` (the tool driven against DOM and viewer doubles — leases, borrowed input actions, teardown, the Enter key, preview heights), `src/annotations/drawTool.test.mjs` (structural: where files live, which ids the markup carries, what the docs may claim), and the rendered harness `scripts/qa-draw-tool.mjs`. - The annotation tree is a declared ownership group: `./annotations` → `src/annotations/index.js` in `package.json`, with its module graph listed in `scripts/package-boundaries.json`, so an annotation module reaching outside that graph is a gate failure rather than a surprise in a layer's bundle. `drawMode.js`, `drawTool.js` and their tests are in `scripts/format-scope.json`. +### News (November 2026) + +- **What it shows.** Events ▸ **News**: reports from curated hotspots (Ukraine–Russia, Israel–Gaza–Lebanon, Iran–Hormuz, Yemen–Red Sea, Sudan, Sahel, and developing situations elsewhere), the last 24 hours only. Zoomed out, one beacon per hotspot with its daily count and trend, and a breathing beacon per developing situation (still under reduced motion). Below 2,500 km (7,500 km with a hotspot chosen), the hotspot outlines with disputed areas dotted and named, and every geocoded report as a ground-clamped marker: fill is severity (0 grey no physical event, 1 yellow minor, 2 orange significant, 3 red major), the icon is the category, the rim is attestation (solid confirmed, dashed claim, thin single), size grows with sources and the marker fades over the day. Colour never marks a side. Up to ten picture cards (physical events with two or more sources, major or pressing) stand over their markers without overlapping; "Show event cards" off leaves only the selected one. A selected report's card reads "Source: host ↗" and opens the article. +- **Panel.** The right-rail NEWS panel (`src/ui/newsPanel.js`, `src/ui/news/`) shows "Last 24 h", hotspot chips, severity (significant and above by default), category, "Include minor news" (off) and "Show event cards" (on), "In view" (reports within 500 km of the view centre while the camera is below 3,000 km) and a continuous feed of event cards: the publisher's picture when the event has one (hotlinked, no referrer, publisher credited), the headline (the model's English headline marked as such), a mono meta line (severity marker, category, place, sources, age), "Reported by UA sources · RU sources", attestation and media chips. A click flies the camera straight down from 150 km and selects the report. +- **Source.** `sources.news` (`createNewsSource`, export `./layers/news/source`) reads same-origin `/api/news/*` with a 15 s timeout, a 4 MiB cap and record-by-record validation; `newsQueryAllowed` lets a host admit exactly the client's requests. Core's local server (`server/providers/news.js`) forwards only those GET requests to `GEV_NEWS_ORIGIN`; unset, every route answers 503 `news_not_configured` and the layer reads unavailable without retrying until it is turned off and on. The feed has no area query, so In view filters the day's geocoded reports by distance. +- **State.** Share/restore token `6`, enabled-only. Voice is off for this layer (`VOICE_OFF_LAYERS`). + ### S2 GhostMaps (October 2026) - **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. diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 328112590..8e3591938 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -727,6 +727,11 @@ "src/ui/rail/RailTimeline.vue", "src/ui/rail/rangeValue.js", "src/ui/weather/WeatherReadout.vue", + "src/ui/newsPanel.js", + "src/ui/news/NewsFeed.vue", + "src/ui/news/NewsCard.vue", + "src/ui/kit/UiEmpty.vue", + "src/ui/kit/UiSelect.js", "src/ui/kit/UiButton.vue", "src/ui/kit/UiBadge.vue", "src/ui/rail/fieldValue.js", @@ -1131,6 +1136,11 @@ "src/ui/rail/RailTimeline.vue", "src/ui/rail/rangeValue.js", "src/ui/weather/WeatherReadout.vue", + "src/ui/newsPanel.js", + "src/ui/news/NewsFeed.vue", + "src/ui/news/NewsCard.vue", + "src/ui/kit/UiEmpty.vue", + "src/ui/kit/UiSelect.js", "src/ui/stores/streetLevel.js", "src/ui/stores/cctv.js", "src/ui/stores/radio.js", @@ -1190,6 +1200,7 @@ "src/ui/styles/first-run.css", "src/ui/styles/provider-settings.css", "src/ui/styles/weather.css", + "src/ui/styles/news.css", "src/ui/styles/recent-imagery.css" ], "external": [] @@ -2960,6 +2971,13 @@ "src/layers/droneAirspace/source.js", "src/layers/droneAirspace/records.js", "src/layers/droneAirspace/cards.js", + "src/app/layers/news.js", + "src/layers/news/index.js", + "src/layers/news/model.js", + "src/layers/news/records.js", + "src/layers/news/source.js", + "src/layers/news/sprites.js", + "src/layers/news/cards.js", "src/layers/cctv/sourcePolicy.js", "src/sources/reference.js", "src/search/nominatim.js", @@ -3212,6 +3230,10 @@ "src/ui/rail/RailTimeline.vue", "src/ui/rail/rangeValue.js", "src/ui/weather/WeatherReadout.vue", + "src/ui/newsPanel.js", + "src/ui/news/NewsFeed.vue", + "src/ui/news/NewsCard.vue", + "src/ui/kit/UiSelect.js", "src/ui/recentImagery/RecentImageryCard.vue", "src/ui/recentImagery/RecentImageryReadout.vue", "src/ui/stores/streetLevel.js", @@ -3714,6 +3736,13 @@ "src/layers/droneAirspace/source.js", "src/layers/droneAirspace/records.js", "src/layers/droneAirspace/cards.js", + "src/app/layers/news.js", + "src/layers/news/index.js", + "src/layers/news/model.js", + "src/layers/news/records.js", + "src/layers/news/source.js", + "src/layers/news/sprites.js", + "src/layers/news/cards.js", "src/layers/cctv/sourcePolicy.js", "src/search/nominatim.js", "src/nominatimGeocode.js", diff --git a/server/providers/local.js b/server/providers/local.js index 7067f3ec5..a7005db23 100644 --- a/server/providers/local.js +++ b/server/providers/local.js @@ -29,6 +29,7 @@ import { firePerimetersProxy } from './firePerimeters.js'; import { gnssIntegrityProxy } from './gnssIntegrity.js'; import { ghostMapsProxy } from './ghostMaps.js'; import { droneAirspaceProxy } from './droneAirspace.js'; +import { newsProxy } from './news.js'; import { cycloneProxy } from './cyclones.js'; import { windProxy } from './wind.js'; @@ -81,6 +82,7 @@ function localProviderPlugins({ gnssIntegrityProxy(), ghostMapsProxy(), droneAirspaceProxy(), + newsProxy(), keySetupEndpoint(), ]; } diff --git a/server/providers/news.js b/server/providers/news.js new file mode 100644 index 000000000..5d10ab2b1 --- /dev/null +++ b/server/providers/news.js @@ -0,0 +1,109 @@ +import { newsQueryAllowed } from '../../src/layers/news/source.js'; +import { readResponseTextCapped } from './common/http.js'; + +// News (/api/news/*): Core serves no news of its own. With GEV_NEWS_ORIGIN +// set (a news server such as https://news.example), this forwards exactly +// the GET requests the news layer sends there and relays the JSON; without +// it, every route answers "not configured" and the layer shows itself +// unavailable without retrying. + +const TIMEOUT_MS = 15_000; +const MAX_BYTES = 4 * 1024 * 1024; + +/** + * The configured news server's origin, or null: http(s) only, no + * credentials, query or fragment; a path is ignored. + * @param {string|undefined} raw + */ +export function newsOrigin(raw = process.env.GEV_NEWS_ORIGIN) { + const text = String(raw ?? '').trim(); + if (!text) return null; + try { + const url = new URL(text); + if ( + !['http:', 'https:'].includes(url.protocol) || + url.username || + url.password || + url.search || + url.hash + ) + return null; + return url.origin; + } catch { + return null; + } +} + +/** + * A thin, validated GET proxy for the news layer's three read routes. + * @param {{origin?: () => string|null, fetchImpl?: typeof fetch, timeoutMs?: number}} [options] + */ +export function newsProxy({ + origin = () => newsOrigin(), + fetchImpl = (...args) => globalThis.fetch(...args), + timeoutMs = TIMEOUT_MS, +} = {}) { + const json = (res, status, value) => { + if (res.destroyed) return; + res.writeHead(status, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + ...(status === 405 ? { Allow: 'GET' } : {}), + }); + res.end(typeof value === 'string' ? value : JSON.stringify(value)); + }; + + async function handler(req, res) { + if (req.method !== 'GET') + return json(res, 405, { error: 'method_not_allowed' }); + const local = new URL(req.url || '/', 'http://local'); + const target = new URL( + `/api/news${local.pathname}${local.search}`, + 'http://local', + ); + if (!newsQueryAllowed(target)) + return json(res, 400, { error: 'invalid_news_request' }); + const base = origin(); + if (!base) + return json(res, 503, { + error: 'news_not_configured', + unavailable: true, + retryable: false, + }); + try { + const upstream = await fetchImpl( + new URL(`${target.pathname}${target.search}`, base), + { + headers: { Accept: 'application/json' }, + redirect: 'error', + signal: AbortSignal.timeout(timeoutMs), + }, + ); + if (!upstream.ok) { + await upstream.body?.cancel().catch(() => {}); + return json(res, 502, { + error: 'news_upstream_error', + status: upstream.status, + }); + } + const text = await readResponseTextCapped(upstream, MAX_BYTES); + // Relayed only as JSON; the browser validates every record. + JSON.parse(text); + return json(res, 200, text); + } catch (error) { + return json(res, error?.name === 'TimeoutError' ? 504 : 502, { + error: 'news_unavailable', + }); + } + } + + return { + name: 'news', + configureServer({ middlewares }) { + middlewares.use('/api/news', handler); + }, + configurePreviewServer({ middlewares }) { + middlewares.use('/api/news', handler); + }, + }; +} diff --git a/server/providers/news.test.mjs b/server/providers/news.test.mjs new file mode 100644 index 000000000..31e3ad4bb --- /dev/null +++ b/server/providers/news.test.mjs @@ -0,0 +1,135 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { newsOrigin, newsProxy } from './news.js'; + +/** Mount the proxy on a fake middleware stack and call it like Connect does. */ +function mount(options) { + let handler = null; + newsProxy(options).configureServer({ + middlewares: { + use(prefix, fn) { + assert.equal(prefix, '/api/news'); + handler = fn; + }, + }, + }); + return async (url, method = 'GET') => { + const res = { + destroyed: false, + status: 0, + headers: {}, + body: '', + writeHead(status, headers) { + this.status = status; + this.headers = headers; + }, + end(body) { + this.body = body; + }, + }; + await handler({ url, method }, res); + return { ...res, json: res.body ? JSON.parse(res.body) : null }; + }; +} + +test('the origin must be a plain http(s) origin', () => { + assert.equal(newsOrigin('https://news.example'), 'https://news.example'); + assert.equal( + newsOrigin('https://news.example/base/'), + 'https://news.example', + ); + for (const bad of [ + undefined, + '', + ' ', + 'ftp://x', + 'http://u:p@x', + 'http://x/?a=1', + 'http://x/#f', + 'not a url', + ]) + assert.equal(newsOrigin(bad), null, String(bad)); +}); + +test('without GEV_NEWS_ORIGIN every route answers not configured and nothing is fetched', async () => { + let fetches = 0; + const call = mount({ + origin: () => null, + fetchImpl: async () => { + fetches++; + throw new Error('unexpected'); + }, + }); + const response = await call('/hotspots'); + assert.equal(response.status, 503); + assert.deepEqual(response.json, { + error: 'news_not_configured', + unavailable: true, + retryable: false, + }); + assert.equal(fetches, 0); +}); + +test('forwards only the client routes, by GET, and relays the JSON', async () => { + const calls = []; + const call = mount({ + origin: () => 'https://news.example', + fetchImpl: async (url, init) => { + calls.push({ url: String(url), init }); + return new Response('{"reports":[],"nextBefore":null}', { status: 200 }); + }, + }); + const ok = await call('/feed?hotspot=all&limit=60&today=1&events=1'); + assert.equal(ok.status, 200); + assert.deepEqual(ok.json, { reports: [], nextBefore: null }); + assert.equal(ok.headers['Cache-Control'], 'no-store'); + assert.equal( + calls[0].url, + 'https://news.example/api/news/feed?hotspot=all&limit=60&today=1&events=1', + ); + assert.equal(calls[0].init.redirect, 'error'); + assert.ok(calls[0].init.signal instanceof AbortSignal); + + for (const url of [ + '/air-alerts', + '/feed?side=UA', + '/feed?limit=9999', + '/../admin', + '/hotspots?x=1', + ]) { + const refused = await call(url); + assert.equal(refused.status, 400, url); + } + const post = await call('/hotspots', 'POST'); + assert.equal(post.status, 405); + assert.equal(post.headers.Allow, 'GET'); + assert.equal(calls.length, 1); +}); + +test('upstream failures, bad JSON and timeouts become bounded errors', async () => { + const failing = mount({ + origin: () => 'https://news.example', + fetchImpl: async () => new Response('down', { status: 500 }), + }); + const down = await failing('/hotspots'); + assert.equal(down.status, 502); + assert.deepEqual(down.json, { error: 'news_upstream_error', status: 500 }); + + const garbled = mount({ + origin: () => 'https://news.example', + fetchImpl: async () => new Response('', { status: 200 }), + }); + assert.equal((await garbled('/hotspots')).status, 502); + + const slow = mount({ + origin: () => 'https://news.example', + timeoutMs: 10, + fetchImpl: (_url, { signal }) => + new Promise((_, reject) => + signal.addEventListener('abort', () => reject(signal.reason)), + ), + }); + const timedOut = await slow('/hotspots'); + assert.equal(timedOut.status, 504); + assert.deepEqual(timedOut.json, { error: 'news_unavailable' }); +}); diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index 488a5e9ab..2b1313ba9 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -30,6 +30,7 @@ import { createApplicationFirePerimeters } from './layers/perimeters.js'; import { createApplicationGnssIntegrity } from './layers/gnss.js'; import { createApplicationGhostMaps } from './layers/ghostMaps.js'; import { createApplicationDroneAirspace } from './layers/droneAirspace.js'; +import { createApplicationNews } from './layers/news.js'; import { createApplicationCables } from './layers/submarineCables.js'; import { createOsmOverlayLayers } from '../data/osmOverlays.js'; import { createApplicationStreetLevel } from './layers/streetLevel.js'; @@ -137,6 +138,7 @@ export function createApplicationCatalog({ createApplicationDroneAirspace({ source: sources['drone-airspace'], }), + createApplicationNews({ source: sources.news }), createApplicationAlpr({ surface, source: sources.alpr }), createApplicationStreetLevel({ surface, diff --git a/src/app/constructCatalog.test.mjs b/src/app/constructCatalog.test.mjs index 82a1dbcf0..1600f5499 100644 --- a/src/app/constructCatalog.test.mjs +++ b/src/app/constructCatalog.test.mjs @@ -40,7 +40,7 @@ test('catalogs construct distinct layers and classification from their supplied signal: b.signal, surface: fixtureSurface(b.signal), }); - assert.equal(first.layers.length, 37); + assert.equal(first.layers.length, 38); assert.ok(first.get('local-adsb'), 'Local ADS-B is registered'); assert.deepEqual( first.metadata.find(({ id }) => id === 'osm-places'), @@ -68,6 +68,7 @@ test('catalogs construct distinct layers and classification from their supplied assert.ok(first.get('gnss-integrity')); assert.ok(first.get('s2-ghostmaps')); assert.ok(first.get('drone-airspace')); + assert.ok(first.get('news')); assert.ok(first.get('transit')); const order = first.layers.map(({ id }) => id); assert.deepEqual( @@ -161,7 +162,7 @@ test('Mapillary can be omitted or replaced without changing catalog membership', surface: fixtureSurface(lifetime.signal), }; const catalog = createApplicationCatalog(options); - assert.equal(catalog.layers.length, 37); + assert.equal(catalog.layers.length, 38); assert.deepEqual(catalog.get('street-level').providerIds, []); assert.equal( catalogControlServices(catalog).streetLevelLayer, diff --git a/src/app/layers/news.js b/src/app/layers/news.js new file mode 100644 index 000000000..552ad20db --- /dev/null +++ b/src/app/layers/news.js @@ -0,0 +1,6 @@ +import { createNewsLayer } from '../../layers/news/index.js'; +import { overlayHost } from './overlayHost.js'; +/** Wire the news layer to the application overlay host. */ +export function createApplicationNews(options) { + return createNewsLayer({ overlayHost, ...options }); +} diff --git a/src/app/sourceComposition.js b/src/app/sourceComposition.js index a12378260..9cf47b3a9 100644 --- a/src/app/sourceComposition.js +++ b/src/app/sourceComposition.js @@ -50,6 +50,7 @@ export const CATALOG_SOURCE_CONTRACTS = Object.freeze({ 'gnss-integrity': contract(['gnss-integrity'], ['getSnapshot']), 's2-ghostmaps': contract(['s2-ghostmaps'], ['getCatalog', 'getFile']), 'drone-airspace': contract(['drone-airspace'], ['getSnapshot']), + news: contract(['news'], ['getHotspots', 'getFeed', 'getOutline']), cables: contract(['telegeography-submarine-cables'], ['fetch']), transit: contract(['transit'], ['requestSnapshot', 'getHistory']), }); diff --git a/src/app/sourceComposition.test.mjs b/src/app/sourceComposition.test.mjs index cee9c1246..28f2d3984 100644 --- a/src/app/sourceComposition.test.mjs +++ b/src/app/sourceComposition.test.mjs @@ -37,7 +37,7 @@ test('every acquisition source can be omitted without losing catalog membership' test('all acquisition sources may be absent, and enable fails before initializing or fetching', async (t) => { const actual = catalog({}, t); - assert.equal(actual.layers.length, 37); + assert.equal(actual.layers.length, 38); const manager = new LayerLifecycle( {}, { getSourceAvailability: actual.getSourceAvailability }, @@ -150,6 +150,35 @@ test('GhostMaps obeys source omission, replacement and explicit removal', async assert.equal(fetches, 0); }); +test('News is unavailable without a source and the standalone default reads only same-origin /api/news', async (t) => { + const missing = catalog({}, t); + assert.equal(missing.getSourceAvailability('news')?.available, false); + const manager = new LayerLifecycle( + {}, + { getSourceAvailability: missing.getSourceAvailability }, + ); + manager.register(missing.get('news')); + assert.equal(await manager.setEnabled('news', true), false); + assert.equal(manager.layers.get('news').initialized, false); + assert.equal(createStandaloneLayerSources({ news: null }).news, null); + + const urls = []; + t.mock.method(globalThis, 'fetch', async (url) => { + urls.push(url); + return new Response( + JSON.stringify({ + error: 'news_not_configured', + unavailable: true, + retryable: false, + }), + { status: 503 }, + ); + }); + const standalone = createStandaloneLayerSources().news; + await assert.rejects(standalone.getHotspots(), { unavailable: true }); + assert.deepEqual(urls, ['/api/news/hotspots']); +}); + test('empty Street Level uses the shared unavailable lifecycle without initialization', async (t) => { const actual = catalog({}, t); const manager = new LayerLifecycle( diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index 29075550c..ede5dc669 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -492,6 +492,14 @@ export const DATA_CREDITS = [ 'licensed under CC BY-NC-SA 4.0. ' + 'Shown as published; reports are S2 assessments, not verified fact.', }, + { + key: 'news', + html: + 'News: every report is credited to its own publisher and links to the original article; ' + + 'pictures are the publishers’ own, shown from their sites with the publisher named. ' + + 'No single provider stands behind the feed. Places, categories, severity and English ' + + 'headlines are derived automatically by the news server and can be wrong.', + }, { key: 'drivebc-cctv', html: diff --git a/src/data/layerState.js b/src/data/layerState.js index 46dadf9a0..2c149a192 100644 --- a/src/data/layerState.js +++ b/src/data/layerState.js @@ -662,6 +662,7 @@ export const LAYER_STATE_REGISTRY = Object.freeze([ token: 'i', disposition: 'enabled-only', }), + Object.freeze({ id: 'news', token: '9', disposition: 'enabled-only' }), Object.freeze({ id: 'radio', token: 'r', diff --git a/src/data/layerState.test.mjs b/src/data/layerState.test.mjs index 9d30e8973..9a8f824d8 100644 --- a/src/data/layerState.test.mjs +++ b/src/data/layerState.test.mjs @@ -203,8 +203,8 @@ function encode(state) { test('production registry is exact, canonical, and rejects incomplete contracts', async () => { assert.equal(validateLayerStateRegistry(), true); - assert.equal(REGISTERED_LAYER_IDS.length, 35); - assert.equal(new Set(REGISTERED_LAYER_IDS).size, 35); + assert.equal(REGISTERED_LAYER_IDS.length, 36); + assert.equal(new Set(REGISTERED_LAYER_IDS).size, 36); assert.ok(REGISTERED_LAYER_IDS.includes('transit')); assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort()); assert.deepEqual(LEGACY_LAYER_STATE_TOKENS, { @@ -243,19 +243,17 @@ test('production registry is exact, canonical, and rejects incomplete contracts' for (const [id, token] of Object.entries(LEGACY_LAYER_STATE_TOKENS)) { assert.equal(LAYER_STATE_TOKEN_RESERVATIONS[id], token); } - assert.equal(nextLayerStateToken(), '9'); + // News took the last single digit: the next tokens are pairs. + assert.equal(nextLayerStateToken(), '00'); assert.equal( nextLayerStateToken({ ...LAYER_STATE_TOKEN_RESERVATIONS, - alpha: '9', - bravo: '00', + alpha: '00', + bravo: '01', }), - '01', + '02', ); - const digitsExhausted = { - ...LAYER_STATE_TOKEN_RESERVATIONS, - ...Object.fromEntries([...'9'].map((digit) => [`prior-${digit}`, digit])), - }; + const digitsExhausted = { ...LAYER_STATE_TOKEN_RESERVATIONS }; assert.equal(nextLayerStateToken(digitsExhausted), '00'); assert.equal( nextLayerStateToken({ ...digitsExhausted, retired: '00', used: '01' }), @@ -306,8 +304,8 @@ test('production registry is exact, canonical, and rejects incomplete contracts' assert.equal( validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, { ...LAYER_STATE_TOKEN_RESERVATIONS, - future: '9', - next: '00', + future: '00', + next: '01', }), true, ); @@ -315,12 +313,12 @@ test('production registry is exact, canonical, and rejects incomplete contracts' () => validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, { ...LAYER_STATE_TOKEN_RESERVATIONS, - future: '00', + future: '01', }), - /next free token 9/, + /next free token 00/, ); const beforeLastDigit = { ...digitsExhausted }; - delete beforeLastDigit['prior-9']; + delete beforeLastDigit.news; assert.equal( validateLayerStateAllocations(beforeLastDigit, { ...beforeLastDigit, @@ -340,10 +338,10 @@ test('production registry is exact, canonical, and rejects incomplete contracts' assert.throws( () => validateLayerStateAllocations( - { ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '9' }, - { ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '9', competing: '9' }, + { ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '00' }, + { ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '00', competing: '00' }, ), - /next free token 00/, + /next free token 01/, ); assert.throws( () => validateLayerStateAllocations({ future: '00' }, { future: '01' }), diff --git a/src/data/layerStateTokenLedger.test.mjs b/src/data/layerStateTokenLedger.test.mjs index d46782378..04d25dce6 100644 --- a/src/data/layerStateTokenLedger.test.mjs +++ b/src/data/layerStateTokenLedger.test.mjs @@ -128,7 +128,7 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed { ...parseLayerStateTokenReservations(reservationRows) }, { ...LAYER_STATE_TOKEN_RESERVATIONS }, ); - assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 35); + assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 36); assert.deepEqual( { ...LAYER_STATE_TOKEN_RESERVATIONS }, { @@ -140,6 +140,7 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed 'sea-marks': '6', 'ski-pistes': '7', 'drone-airspace': '8', + news: '9', }, ); assert.throws( @@ -165,9 +166,9 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed /Duplicate layer-state token reservation/, ); assert.equal( - parseLayerStateTokenReservations([...reservationRows, ['future', '9']]) + parseLayerStateTokenReservations([...reservationRows, ['future', '00']]) .future, - '9', + '00', ); assert.throws( () => @@ -185,14 +186,14 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed test('published retired digits and competing PRs advance the merge-time allocation', () => { withPublishedBase( - { rows: [...reservationRows, ['retired-layer', '9']] }, + { rows: [...reservationRows, ['retired-layer', '00']] }, (cwd) => { const published = readPublishedLayerStateReservations('HEAD', cwd); - assert.equal(nextLayerStateToken(published), '00'); + assert.equal(nextLayerStateToken(published), '01'); assert.equal( validateLayerStateAllocations(published, { ...published, - newcomer: '00', + newcomer: '01', }), true, ); @@ -200,15 +201,15 @@ test('published retired digits and competing PRs advance the merge-time allocati () => validateLayerStateAllocations(published, { ...published, - competing: '9', + competing: '00', }), - /next free token 00/, + /next free token 01/, ); assert.throws( () => validateLayerStateAllocations(published, { ...LAYER_STATE_TOKEN_RESERVATIONS, - newcomer: '9', + newcomer: '00', }), /changed or removed: retired-layer/, ); @@ -216,8 +217,8 @@ test('published retired digits and competing PRs advance the merge-time allocati ); }); -test('PR B manually replaces provisional 9 with 00 after PR A publishes 9', () => { - withPublishedBase({ rows: [...reservationRows, ['pr-a', '9']] }, (cwd) => { +test('PR B manually replaces provisional 00 with 01 after PR A publishes 00', () => { + withPublishedBase({ rows: [...reservationRows, ['pr-a', '00']] }, (cwd) => { mkdirSync(path.join(cwd, 'scripts')); const fixtureChecker = path.join( cwd, @@ -238,8 +239,8 @@ test('PR B manually replaces provisional 9 with 00 after PR A publishes 9', () = writeCodecFixture( cwd, - [...reservationRows, ['pr-b', '9']], - [{ id: 'pr-b', token: '9' }], + [...reservationRows, ['pr-b', '00']], + [{ id: 'pr-b', token: '00' }], ); const beforeRebase = runCheck(); assert.equal(beforeRebase.status, 1); @@ -247,10 +248,10 @@ test('PR B manually replaces provisional 9 with 00 after PR A publishes 9', () = writeCodecFixture( cwd, - [...reservationRows, ['pr-a', '9'], ['pr-b', '9']], + [...reservationRows, ['pr-a', '00'], ['pr-b', '00']], [ - { id: 'pr-a', token: '9' }, - { id: 'pr-b', token: '9' }, + { id: 'pr-a', token: '00' }, + { id: 'pr-b', token: '00' }, ], ); const staleRebase = runCheck(); @@ -258,16 +259,16 @@ test('PR B manually replaces provisional 9 with 00 after PR A publishes 9', () = assert.match(staleRebase.stderr, /Duplicate layer-state token reservation/); const published = readPublishedLayerStateReservations('HEAD', cwd); - assert.equal(nextLayerStateToken(published), '00'); - const validBaselineRows = [...reservationRows, ['pr-a', '9']]; - writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '9' }]); + assert.equal(nextLayerStateToken(published), '01'); + const validBaselineRows = [...reservationRows, ['pr-a', '00']]; + writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '00' }]); const helper = spawnSync( process.execPath, [realpathSync(fixtureNext), 'pr-b'], { cwd, encoding: 'utf8' }, ); assert.equal(helper.status, 0, helper.stderr); - assert.match(helper.stdout, /pr-b: 00/); + assert.match(helper.stdout, /pr-b: 01/); assert.deepEqual( JSON.parse(readFileSync(path.join(cwd, LEDGER_PATH), 'utf8')), validBaselineRows, @@ -275,26 +276,27 @@ test('PR B manually replaces provisional 9 with 00 after PR A publishes 9', () = ); writeCodecFixture( cwd, - [...reservationRows, ['pr-a', '9'], ['pr-b', '00']], + [...reservationRows, ['pr-a', '00'], ['pr-b', '01']], [ - { id: 'pr-a', token: '9' }, - { id: 'pr-b', token: '00' }, + { id: 'pr-a', token: '00' }, + { id: 'pr-b', token: '01' }, ], ); const corrected = runCheck(); assert.equal(corrected.status, 0, corrected.stderr); - assert.match(corrected.stdout, /36 published, 1 new/); + assert.match(corrected.stdout, /37 published, 1 new/); }); }); test('allocation batches cross the last digit and base-36 pair boundaries in order', () => { - const digitRows = [...'9'].map((digit) => [`retired-${digit}`, digit]); + // News holds the last digit, 9: the ledger has no free digit left. + const digitRows = []; const allDigits = parseLayerStateTokenReservations([ ...reservationRows, ...digitRows, ]); const beforeNine = { ...allDigits }; - delete beforeNine['retired-9']; + delete beforeNine.news; assert.equal( validateLayerStateAllocations(beforeNine, { ...beforeNine, @@ -340,10 +342,8 @@ test('allocation batches cross the last digit and base-36 pair boundaries in ord test('an isolated valid two-character fixture round-trips an l field beyond the old 64-character cap', async () => { const cwd = mkdtempSync(path.join(tmpdir(), 'gev-layer-token-width-')); try { - const priorDigits = [...'9'].map((digit) => [ - `qa-prior-digit-${digit}`, - digit, - ]); + // News holds the last digit: no prior digit to fill. + const priorDigits = []; const fixtureLayers = ['00', '01', '02', '03'].map((token) => ({ id: `qa-layer-${token}`, token, @@ -377,7 +377,7 @@ test('an isolated valid two-character fixture round-trips an l field beyond the const params = new URLSearchParams([['v', '2']]); codec.encodeLayerStateParams(params, state); assert.ok(params.get('l').length > 64, 'fixture must cross the old cap'); - assert.equal(params.get('l').length, 81); + assert.equal(params.get('l').length, 83); const restored = codec.decodeLayerStateParams(params); assert.deepEqual(restored?.enabledLayerIds, expectedLayerIds); assert.deepEqual(restored?.options, state.options); @@ -395,7 +395,7 @@ test('checker reads a complete future base ledger and rejects retired-token reus }, (cwd) => { const published = readPublishedLayerStateReservations('HEAD', cwd); - assert.equal(Object.keys(published).length, 36); + assert.equal(Object.keys(published).length, 37); assert.equal(published['retired-layer'], '00'); assert.throws( () => diff --git a/src/data/layerStateTokenReservations.json b/src/data/layerStateTokenReservations.json index 836112713..03c977426 100644 --- a/src/data/layerStateTokenReservations.json +++ b/src/data/layerStateTokenReservations.json @@ -18,6 +18,7 @@ ["military", "m"], ["military-awareness", "g"], ["military-installations", "i"], + ["news", "9"], ["radio", "r"], ["recent-imagery", "1"], ["rocket-launches", "x"], diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index 59cd4caa6..f779fc9e9 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -1,5 +1,23 @@ { "News feed unavailable": "موجز الأخبار غير متاح", + "Developing: {name}": "تطورات: {name}", + "{count} events today": { + "zero": "{count} حدث اليوم", + "one": "حدث واحد اليوم ({count})", + "two": "حدثان اليوم ({count})", + "few": "{count} أحداث اليوم", + "many": "{count} حدثًا اليوم", + "other": "{count} حدث اليوم" + }, + "{count} reports today": { + "zero": "{count} تقرير اليوم", + "one": "تقرير واحد اليوم ({count})", + "two": "تقريران اليوم ({count})", + "few": "{count} تقارير اليوم", + "many": "{count} تقريرًا اليوم", + "other": "{count} تقرير اليوم" + }, + "Last 24 h from curated hotspots. Each report links to its publisher; places and categories are read from the text automatically and can be wrong. Side labels describe the source, not whether a report is true.": "آخر 24 ساعة من بؤر مختارة. يرتبط كل تقرير بناشره؛ تُستخرج الأماكن والفئات من النص تلقائيًا وقد تكون خاطئة. تصف تسميات الأطراف المصدر، لا صحة التقرير.", "Disputed: {name}": "منطقة متنازع عليها: {name}", "Administered by {holder} · claimed by {claimant}": "تديرها {holder} · تطالب بها {claimant}", "Self-administered · claimed by {claimant}": "تدير نفسها · تطالب بها {claimant}", @@ -55,6 +73,34 @@ "News feed not configured on this server": "موجز الأخبار غير مهيأ على هذا الخادم", "Malformed news hotspots response": "استجابة بؤر الأخبار غير صالحة", "Malformed news feed response": "استجابة موجز الأخبار غير صالحة", + "{place} (placed by model)": "{place} (حدده النموذج)", + "English headline by the model": "عنوان إنجليزي من النموذج", + "Not placed on the map": "غير موضوع على الخريطة", + "Close": "إغلاق", + "Any severity": "أي درجة خطورة", + "{severity} and above": "{severity} فأعلى", + "{severity} only": "{severity} فقط", + "All categories": "كل الفئات", + "Reading the news…": "جارٍ قراءة الأخبار…", + "News, last 24 hours": "الأخبار، آخر 24 ساعة", + "Last 24 h": "آخر 24 ساعة", + "Hotspots": "بؤر ساخنة", + "Severity": "الخطورة", + "Category": "الفئة", + "Include minor news": "تضمين الأخبار الثانوية", + "Show event cards": "إظهار بطاقات الأحداث", + "In view": "في مجال الرؤية", + "Nothing reported near here in the last 24 h": "لم يُبلَّغ عن شيء قريب من هنا في آخر 24 ساعة", + "Latest news": "آخر الأخبار", + "No news matches these filters in the last 24 h": "لا أخبار تطابق هذه المرشحات في آخر 24 ساعة", + "Load older news": "تحميل أخبار أقدم", + "How to read the map": "كيف تقرأ الخريطة", + "Colour: severity · icon: type of event": "اللون: الخطورة · الرمز: نوع الحدث", + "Solid rim: reported by both sides or independently. Dashed rim: a claim. Thin rim: one source, or one side’s own press.": "إطار متصل: أورده الطرفان أو مصدر مستقل. إطار متقطع: ادعاء. إطار رفيع: مصدر واحد، أو إعلام أحد الطرفين.", + "Larger: more sources · fainter: older. Picture card: a major or fast-growing event with two or more sources.": "أكبر: مصادر أكثر · أبهت: أقدم. بطاقة بصورة: حدث جسيم أو متسارع من مصدرين أو أكثر.", + "Side labels describe the source, not whether a report is true.": "تصف تسميات الأطراف المصدر، لا صحة التقرير.", + "Exit cockpit to fly to a news report": "اخرج من القمرة للانتقال إلى خبر", + "News items in the feed": "عدد الأخبار في الموجز", "NO PLACE LEFT BEHIND": "لا مكان يُترك خلفنا", "Initializing photorealistic world...": "جارٍ تهيئة العالم الواقعي…", "Configuring viewer...": "جارٍ إعداد العارض…", diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 9421361bc..43b913524 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -1,5 +1,15 @@ { "News feed unavailable": "Nachrichtenfeed nicht verfügbar", + "Developing: {name}": "In Entwicklung: {name}", + "{count} events today": { + "one": "{count} Ereignis heute", + "other": "{count} Ereignisse heute" + }, + "{count} reports today": { + "one": "{count} Meldung heute", + "other": "{count} Meldungen heute" + }, + "Last 24 h from curated hotspots. Each report links to its publisher; places and categories are read from the text automatically and can be wrong. Side labels describe the source, not whether a report is true.": "Letzte 24 h aus ausgewählten Brennpunkten. Jede Meldung verlinkt auf ihren Herausgeber; Orte und Kategorien werden automatisch aus dem Text gelesen und können falsch sein. Seitenangaben beschreiben die Quelle, nicht ob eine Meldung stimmt.", "Disputed: {name}": "Umstritten: {name}", "Administered by {holder} · claimed by {claimant}": "Verwaltet von {holder} · beansprucht von {claimant}", "Self-administered · claimed by {claimant}": "Selbstverwaltet · beansprucht von {claimant}", @@ -51,6 +61,34 @@ "News feed not configured on this server": "Nachrichtenfeed auf diesem Server nicht eingerichtet", "Malformed news hotspots response": "Fehlerhafte Antwort zu Nachrichten-Brennpunkten", "Malformed news feed response": "Fehlerhafte Antwort des Nachrichtenfeeds", + "{place} (placed by model)": "{place} (vom Modell verortet)", + "English headline by the model": "Englische Schlagzeile vom Modell", + "Not placed on the map": "Nicht auf der Karte verortet", + "Close": "Schließen", + "Any severity": "Jeder Schweregrad", + "{severity} and above": "{severity} und höher", + "{severity} only": "Nur {severity}", + "All categories": "Alle Kategorien", + "Reading the news…": "Nachrichten werden gelesen…", + "News, last 24 hours": "Nachrichten der letzten 24 Stunden", + "Last 24 h": "Letzte 24 Std.", + "Hotspots": "Brennpunkte", + "Severity": "Schweregrad", + "Category": "Kategorie", + "Include minor news": "Nebensächliche Meldungen einbeziehen", + "Show event cards": "Ereigniskarten zeigen", + "In view": "Im Blick", + "Nothing reported near here in the last 24 h": "In den letzten 24 h nichts in der Nähe gemeldet", + "Latest news": "Neueste Nachrichten", + "No news matches these filters in the last 24 h": "Keine Nachrichten der letzten 24 h passen zu diesen Filtern", + "Load older news": "Ältere Nachrichten laden", + "How to read the map": "So liest man die Karte", + "Colour: severity · icon: type of event": "Farbe: Schweregrad · Symbol: Art des Ereignisses", + "Solid rim: reported by both sides or independently. Dashed rim: a claim. Thin rim: one source, or one side’s own press.": "Durchgehender Rand: von beiden Seiten oder unabhängig gemeldet. Gestrichelter Rand: eine Behauptung. Dünner Rand: eine Quelle oder die eigene Presse einer Seite.", + "Larger: more sources · fainter: older. Picture card: a major or fast-growing event with two or more sources.": "Größer: mehr Quellen · blasser: älter. Bildkarte: ein schweres oder schnell wachsendes Ereignis mit zwei oder mehr Quellen.", + "Side labels describe the source, not whether a report is true.": "Seitenangaben beschreiben die Quelle, nicht ob eine Meldung stimmt.", + "Exit cockpit to fly to a news report": "Cockpit verlassen, um zu einer Meldung zu fliegen", + "News items in the feed": "Meldungen im Feed", "Flying to Austin, TX...": "Flug nach Austin, TX...", "Restoring shared view...": "Geteilte Ansicht wird wiederhergestellt...", "Configuring viewer...": "Viewer wird konfiguriert...", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 914a85171..ba4fa99a9 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -1,5 +1,17 @@ { "News feed unavailable": "Noticias no disponibles", + "Developing: {name}": "En desarrollo: {name}", + "{count} events today": { + "one": "{count} suceso hoy", + "many": "{count} sucesos hoy", + "other": "{count} sucesos hoy" + }, + "{count} reports today": { + "one": "{count} noticia hoy", + "many": "{count} noticias hoy", + "other": "{count} noticias hoy" + }, + "Last 24 h from curated hotspots. Each report links to its publisher; places and categories are read from the text automatically and can be wrong. Side labels describe the source, not whether a report is true.": "Últimas 24 h de focos seleccionados. Cada noticia enlaza a su medio; los lugares y categorías se leen del texto automáticamente y pueden ser erróneos. Las etiquetas de bando describen la fuente, no si la noticia es cierta.", "Disputed: {name}": "En disputa: {name}", "Administered by {holder} · claimed by {claimant}": "Administrado por {holder} · reclamado por {claimant}", "Self-administered · claimed by {claimant}": "Autogobernado · reclamado por {claimant}", @@ -52,6 +64,34 @@ "News feed not configured on this server": "Noticias no configuradas en este servidor", "Malformed news hotspots response": "Respuesta de focos de noticias mal formada", "Malformed news feed response": "Respuesta de noticias mal formada", + "{place} (placed by model)": "{place} (ubicado por el modelo)", + "English headline by the model": "Titular en inglés del modelo", + "Not placed on the map": "Sin ubicar en el mapa", + "Close": "Cerrar", + "Any severity": "Cualquier gravedad", + "{severity} and above": "{severity} o más", + "{severity} only": "Solo {severity}", + "All categories": "Todas las categorías", + "Reading the news…": "Leyendo las noticias…", + "News, last 24 hours": "Noticias de las últimas 24 horas", + "Last 24 h": "Últimas 24 h", + "Hotspots": "Focos", + "Severity": "Gravedad", + "Category": "Categoría", + "Include minor news": "Incluir noticias menores", + "Show event cards": "Mostrar tarjetas de sucesos", + "In view": "A la vista", + "Nothing reported near here in the last 24 h": "Nada informado cerca de aquí en las últimas 24 h", + "Latest news": "Últimas noticias", + "No news matches these filters in the last 24 h": "Ninguna noticia de las últimas 24 h coincide con estos filtros", + "Load older news": "Cargar noticias anteriores", + "How to read the map": "Cómo leer el mapa", + "Colour: severity · icon: type of event": "Color: gravedad · icono: tipo de suceso", + "Solid rim: reported by both sides or independently. Dashed rim: a claim. Thin rim: one source, or one side’s own press.": "Borde continuo: informado por ambos bandos o de forma independiente. Borde discontinuo: una afirmación. Borde fino: una fuente, o la prensa de un bando.", + "Larger: more sources · fainter: older. Picture card: a major or fast-growing event with two or more sources.": "Más grande: más fuentes · más tenue: más antiguo. Tarjeta con imagen: un suceso grave o en rápido crecimiento con dos o más fuentes.", + "Side labels describe the source, not whether a report is true.": "Las etiquetas de bando describen la fuente, no si la noticia es cierta.", + "Exit cockpit to fly to a news report": "Sal de la cabina para volar a una noticia", + "News items in the feed": "Noticias en la lista", "NO PLACE LEFT BEHIND": "NINGÚN RINCÓN SIN VIGILAR", "Initializing photorealistic world...": "Inicializando el mundo fotorrealista...", "Configuring viewer...": "Configurando el visor...", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index f01ca2956..aae0bbb7d 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -1,5 +1,17 @@ { "News feed unavailable": "Fil d’actualités indisponible", + "Developing: {name}": "En cours : {name}", + "{count} events today": { + "one": "{count} événement aujourd’hui", + "many": "{count} événements aujourd’hui", + "other": "{count} événements aujourd’hui" + }, + "{count} reports today": { + "one": "{count} article aujourd’hui", + "many": "{count} articles aujourd’hui", + "other": "{count} articles aujourd’hui" + }, + "Last 24 h from curated hotspots. Each report links to its publisher; places and categories are read from the text automatically and can be wrong. Side labels describe the source, not whether a report is true.": "Dernières 24 h des foyers retenus. Chaque article renvoie à son éditeur ; lieux et catégories sont lus automatiquement dans le texte et peuvent être faux. Les étiquettes de camp décrivent la source, pas la véracité de l’article.", "Disputed: {name}": "Contesté : {name}", "Administered by {holder} · claimed by {claimant}": "Administré par {holder} · revendiqué par {claimant}", "Self-administered · claimed by {claimant}": "Autoadministré · revendiqué par {claimant}", @@ -52,6 +64,34 @@ "News feed not configured on this server": "Fil d’actualités non configuré sur ce serveur", "Malformed news hotspots response": "Réponse des foyers d’actualité mal formée", "Malformed news feed response": "Réponse du fil d’actualités mal formée", + "{place} (placed by model)": "{place} (placé par le modèle)", + "English headline by the model": "Titre anglais du modèle", + "Not placed on the map": "Non placé sur la carte", + "Close": "Fermer", + "Any severity": "Toute gravité", + "{severity} and above": "{severity} et plus", + "{severity} only": "{severity} uniquement", + "All categories": "Toutes les catégories", + "Reading the news…": "Lecture des actualités…", + "News, last 24 hours": "Actualités des dernières 24 heures", + "Last 24 h": "Dernières 24 h", + "Hotspots": "Foyers", + "Severity": "Gravité", + "Category": "Catégorie", + "Include minor news": "Inclure les actualités secondaires", + "Show event cards": "Afficher les cartes d’événement", + "In view": "Dans la vue", + "Nothing reported near here in the last 24 h": "Rien de signalé près d’ici ces dernières 24 h", + "Latest news": "Dernières actualités", + "No news matches these filters in the last 24 h": "Aucune actualité des dernières 24 h ne correspond à ces filtres", + "Load older news": "Charger des actualités plus anciennes", + "How to read the map": "Comment lire la carte", + "Colour: severity · icon: type of event": "Couleur : gravité · icône : type d’événement", + "Solid rim: reported by both sides or independently. Dashed rim: a claim. Thin rim: one source, or one side’s own press.": "Bord plein : rapporté par les deux camps ou de façon indépendante. Bord en tirets : une affirmation. Bord fin : une source, ou la presse d’un camp.", + "Larger: more sources · fainter: older. Picture card: a major or fast-growing event with two or more sources.": "Plus grand : plus de sources · plus pâle : plus ancien. Carte illustrée : un événement majeur ou en forte croissance, avec au moins deux sources.", + "Side labels describe the source, not whether a report is true.": "Les étiquettes de camp décrivent la source, pas la véracité de l’article.", + "Exit cockpit to fly to a news report": "Quittez le cockpit pour aller à un article", + "News items in the feed": "Articles dans le fil", "Pick a shape, then click the map.": "Choisissez une forme, puis cliquez sur la carte.", "Enter to place the pin, Esc to cancel.": "Enter pour poser l'épingle, Esc pour annuler.", "Click where the pin goes.": "Cliquez à l'endroit où poser l'épingle.", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index dcaf4855c..cb9c27098 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -1,5 +1,17 @@ { "News feed unavailable": "Feed delle notizie non disponibile", + "Developing: {name}": "In sviluppo: {name}", + "{count} events today": { + "one": "{count} evento oggi", + "many": "{count} eventi oggi", + "other": "{count} eventi oggi" + }, + "{count} reports today": { + "one": "{count} notizia oggi", + "many": "{count} notizie oggi", + "other": "{count} notizie oggi" + }, + "Last 24 h from curated hotspots. Each report links to its publisher; places and categories are read from the text automatically and can be wrong. Side labels describe the source, not whether a report is true.": "Ultime 24 h dai focolai selezionati. Ogni notizia rimanda al suo editore; luoghi e categorie sono letti automaticamente dal testo e possono essere errati. Le etichette di parte descrivono la fonte, non se la notizia è vera.", "Disputed: {name}": "Conteso: {name}", "Administered by {holder} · claimed by {claimant}": "Amministrato da {holder} · rivendicato da {claimant}", "Self-administered · claimed by {claimant}": "Autogovernato · rivendicato da {claimant}", @@ -52,6 +64,34 @@ "News feed not configured on this server": "Feed delle notizie non configurato su questo server", "Malformed news hotspots response": "Risposta dei focolai di notizie non valida", "Malformed news feed response": "Risposta del feed delle notizie non valida", + "{place} (placed by model)": "{place} (collocato dal modello)", + "English headline by the model": "Titolo inglese del modello", + "Not placed on the map": "Non collocato sulla mappa", + "Close": "Chiudi", + "Any severity": "Qualsiasi gravità", + "{severity} and above": "{severity} e oltre", + "{severity} only": "Solo {severity}", + "All categories": "Tutte le categorie", + "Reading the news…": "Lettura delle notizie…", + "News, last 24 hours": "Notizie delle ultime 24 ore", + "Last 24 h": "Ultime 24 h", + "Hotspots": "Focolai", + "Severity": "Gravità", + "Category": "Categoria", + "Include minor news": "Includi notizie minori", + "Show event cards": "Mostra le schede degli eventi", + "In view": "In vista", + "Nothing reported near here in the last 24 h": "Nulla segnalato qui vicino nelle ultime 24 h", + "Latest news": "Ultime notizie", + "No news matches these filters in the last 24 h": "Nessuna notizia delle ultime 24 h corrisponde a questi filtri", + "Load older news": "Carica notizie precedenti", + "How to read the map": "Come leggere la mappa", + "Colour: severity · icon: type of event": "Colore: gravità · icona: tipo di evento", + "Solid rim: reported by both sides or independently. Dashed rim: a claim. Thin rim: one source, or one side’s own press.": "Bordo pieno: riportato da entrambe le parti o in modo indipendente. Bordo tratteggiato: un’affermazione. Bordo sottile: una fonte, o la stampa di una parte.", + "Larger: more sources · fainter: older. Picture card: a major or fast-growing event with two or more sources.": "Più grande: più fonti · più tenue: più vecchio. Scheda con immagine: un evento grave o in rapida crescita con due o più fonti.", + "Side labels describe the source, not whether a report is true.": "Le etichette di parte descrivono la fonte, non se la notizia è vera.", + "Exit cockpit to fly to a news report": "Esci dalla cabina per volare a una notizia", + "News items in the feed": "Notizie nel feed", "Pick a shape, then click the map.": "Scegli una forma, poi fai clic sulla mappa.", "Enter to place the pin, Esc to cancel.": "Premi Enter per posizionare il segnaposto, Esc per annullare.", "Click where the pin goes.": "Fai clic dove va il segnaposto.", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 743fcb71c..fc62c1703 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1,5 +1,13 @@ { "News feed unavailable": "ニュースフィードを利用できません", + "Developing: {name}": "進行中: {name}", + "{count} events today": { + "other": "本日の出来事 {count}件" + }, + "{count} reports today": { + "other": "本日の報道 {count}件" + }, + "Last 24 h from curated hotspots. Each report links to its publisher; places and categories are read from the text automatically and can be wrong. Side labels describe the source, not whether a report is true.": "厳選したホットスポットの直近24時間。各報道は発信元にリンクします。場所とカテゴリは本文から自動で読み取るため誤りがあり得ます。陣営ラベルは情報源を示すもので、報道の真偽ではありません。", "Disputed: {name}": "係争地: {name}", "Administered by {holder} · claimed by {claimant}": "{holder}が施政 · {claimant}が領有を主張", "Self-administered · claimed by {claimant}": "自治 · {claimant}が領有を主張", @@ -50,6 +58,34 @@ "News feed not configured on this server": "このサーバーではニュースフィードが設定されていません", "Malformed news hotspots response": "ニュースのホットスポット応答の形式が不正です", "Malformed news feed response": "ニュースフィードの応答の形式が不正です", + "{place} (placed by model)": "{place}(モデルが推定)", + "English headline by the model": "モデルによる英語の見出し", + "Not placed on the map": "地図上に配置されていません", + "Close": "閉じる", + "Any severity": "すべての深刻度", + "{severity} and above": "{severity}以上", + "{severity} only": "{severity}のみ", + "All categories": "すべてのカテゴリ", + "Reading the news…": "ニュースを読み込み中…", + "News, last 24 hours": "直近24時間のニュース", + "Last 24 h": "直近24時間", + "Hotspots": "ホットスポット", + "Severity": "深刻度", + "Category": "カテゴリ", + "Include minor news": "軽微なニュースも含める", + "Show event cards": "イベントカードを表示", + "In view": "表示範囲内", + "Nothing reported near here in the last 24 h": "直近24時間、この付近の報道はありません", + "Latest news": "最新ニュース", + "No news matches these filters in the last 24 h": "直近24時間で条件に合うニュースはありません", + "Load older news": "以前のニュースを読み込む", + "How to read the map": "地図の見方", + "Colour: severity · icon: type of event": "色: 深刻度 · アイコン: 出来事の種類", + "Solid rim: reported by both sides or independently. Dashed rim: a claim. Thin rim: one source, or one side’s own press.": "実線の縁: 双方または独立した情報源が報道。破線の縁: 主張。細い縁: 単一の情報源、または一方の陣営のメディア。", + "Larger: more sources · fainter: older. Picture card: a major or fast-growing event with two or more sources.": "大きい: 情報源が多い · 薄い: 古い。画像カード: 情報源が2件以上ある深刻な、または急拡大中の出来事。", + "Side labels describe the source, not whether a report is true.": "陣営ラベルは情報源を示すもので、報道の真偽ではありません。", + "Exit cockpit to fly to a news report": "ニュースへ移動するにはコックピットを終了してください", + "News items in the feed": "フィード内のニュース数", "Pick a shape, then click the map.": "図形を選んでから、地図をクリックしてください。", "Enter to place the pin, Esc to cancel.": "Enterでピンを配置、Escでキャンセル。", "Click where the pin goes.": "ピンを置く場所をクリックしてください。", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 2068c08b6..a5da05da8 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -1,5 +1,17 @@ { "News feed unavailable": "Feed de notícias indisponível", + "Developing: {name}": "Em desenvolvimento: {name}", + "{count} events today": { + "one": "{count} evento hoje", + "many": "{count} eventos hoje", + "other": "{count} eventos hoje" + }, + "{count} reports today": { + "one": "{count} notícia hoje", + "many": "{count} notícias hoje", + "other": "{count} notícias hoje" + }, + "Last 24 h from curated hotspots. Each report links to its publisher; places and categories are read from the text automatically and can be wrong. Side labels describe the source, not whether a report is true.": "Últimas 24 h dos focos selecionados. Cada notícia leva ao seu editor; locais e categorias são lidos do texto automaticamente e podem estar errados. Os rótulos de lado descrevem a fonte, não se a notícia é verdadeira.", "Disputed: {name}": "Em disputa: {name}", "Administered by {holder} · claimed by {claimant}": "Administrado por {holder} · reivindicado por {claimant}", "Self-administered · claimed by {claimant}": "Autogovernado · reivindicado por {claimant}", @@ -52,6 +64,34 @@ "News feed not configured on this server": "Feed de notícias não configurado neste servidor", "Malformed news hotspots response": "Resposta de focos de notícias malformada", "Malformed news feed response": "Resposta do feed de notícias malformada", + "{place} (placed by model)": "{place} (localizado pelo modelo)", + "English headline by the model": "Manchete em inglês do modelo", + "Not placed on the map": "Não localizado no mapa", + "Close": "Fechar", + "Any severity": "Qualquer gravidade", + "{severity} and above": "{severity} ou mais", + "{severity} only": "Apenas {severity}", + "All categories": "Todas as categorias", + "Reading the news…": "Lendo as notícias…", + "News, last 24 hours": "Notícias das últimas 24 horas", + "Last 24 h": "Últimas 24 h", + "Hotspots": "Focos", + "Severity": "Gravidade", + "Category": "Categoria", + "Include minor news": "Incluir notícias menores", + "Show event cards": "Mostrar cartões de eventos", + "In view": "Na vista", + "Nothing reported near here in the last 24 h": "Nada noticiado aqui perto nas últimas 24 h", + "Latest news": "Últimas notícias", + "No news matches these filters in the last 24 h": "Nenhuma notícia das últimas 24 h corresponde a estes filtros", + "Load older news": "Carregar notícias anteriores", + "How to read the map": "Como ler o mapa", + "Colour: severity · icon: type of event": "Cor: gravidade · ícone: tipo de evento", + "Solid rim: reported by both sides or independently. Dashed rim: a claim. Thin rim: one source, or one side’s own press.": "Borda contínua: noticiado por ambos os lados ou de forma independente. Borda tracejada: uma alegação. Borda fina: uma fonte, ou a imprensa de um lado.", + "Larger: more sources · fainter: older. Picture card: a major or fast-growing event with two or more sources.": "Maior: mais fontes · mais tênue: mais antigo. Cartão com imagem: um evento grave ou em rápido crescimento com duas ou mais fontes.", + "Side labels describe the source, not whether a report is true.": "Os rótulos de lado descrevem a fonte, não se a notícia é verdadeira.", + "Exit cockpit to fly to a news report": "Saia da cabine para voar até uma notícia", + "News items in the feed": "Notícias no feed", "Pick a shape, then click the map.": "Escolha uma forma e clique no mapa.", "Enter to place the pin, Esc to cancel.": "Enter para colocar o marcador, Esc para cancelar.", "Click where the pin goes.": "Clique onde o marcador deve ficar.", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index e6df84b68..2725ef272 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -1,5 +1,19 @@ { "News feed unavailable": "Лента новостей недоступна", + "Developing: {name}": "Развивается: {name}", + "{count} events today": { + "one": "{count} событие сегодня", + "few": "{count} события сегодня", + "many": "{count} событий сегодня", + "other": "{count} события сегодня" + }, + "{count} reports today": { + "one": "{count} сообщение сегодня", + "few": "{count} сообщения сегодня", + "many": "{count} сообщений сегодня", + "other": "{count} сообщения сегодня" + }, + "Last 24 h from curated hotspots. Each report links to its publisher; places and categories are read from the text automatically and can be wrong. Side labels describe the source, not whether a report is true.": "Последние 24 ч по выбранным горячим точкам. Каждое сообщение ведёт к своему изданию; места и категории определяются по тексту автоматически и могут быть ошибочны. Метки сторон описывают источник, а не достоверность сообщения.", "Disputed: {name}": "Спорная территория: {name}", "Administered by {holder} · claimed by {claimant}": "Под управлением: {holder} · претендует: {claimant}", "Self-administered · claimed by {claimant}": "Самоуправляемая · претендует: {claimant}", @@ -53,6 +67,34 @@ "News feed not configured on this server": "Лента новостей не настроена на этом сервере", "Malformed news hotspots response": "Некорректный ответ горячих точек новостей", "Malformed news feed response": "Некорректный ответ ленты новостей", + "{place} (placed by model)": "{place} (место определила модель)", + "English headline by the model": "Английский заголовок от модели", + "Not placed on the map": "Не отмечено на карте", + "Close": "Закрыть", + "Any severity": "Любая тяжесть", + "{severity} and above": "{severity} и выше", + "{severity} only": "Только {severity}", + "All categories": "Все категории", + "Reading the news…": "Читаем новости…", + "News, last 24 hours": "Новости за последние 24 часа", + "Last 24 h": "За 24 ч", + "Hotspots": "Горячие точки", + "Severity": "Тяжесть", + "Category": "Категория", + "Include minor news": "Показывать второстепенные новости", + "Show event cards": "Показывать карточки событий", + "In view": "В поле зрения", + "Nothing reported near here in the last 24 h": "За последние 24 ч поблизости ничего не сообщалось", + "Latest news": "Последние новости", + "No news matches these filters in the last 24 h": "За последние 24 ч нет новостей по этим фильтрам", + "Load older news": "Загрузить более ранние новости", + "How to read the map": "Как читать карту", + "Colour: severity · icon: type of event": "Цвет: тяжесть · значок: тип события", + "Solid rim: reported by both sides or independently. Dashed rim: a claim. Thin rim: one source, or one side’s own press.": "Сплошной обод: сообщают обе стороны или независимые источники. Пунктирный обод: заявление. Тонкий обод: один источник или пресса одной стороны.", + "Larger: more sources · fainter: older. Picture card: a major or fast-growing event with two or more sources.": "Крупнее: больше источников · бледнее: старше. Карточка с фото: крупное или быстро растущее событие от двух и более источников.", + "Side labels describe the source, not whether a report is true.": "Метки сторон описывают источник, а не достоверность сообщения.", + "Exit cockpit to fly to a news report": "Выйдите из кабины, чтобы перелететь к сообщению", + "News items in the feed": "Новостей в ленте", "Flying to Austin, TX...": "Перелёт в Остин, Техас...", "Restoring shared view...": "Восстановление общего вида...", "Initializing systems...": "Инициализация систем...", diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index 41f6f1207..77aa0f593 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -1,5 +1,19 @@ { "News feed unavailable": "Стрічка новин недоступна", + "Developing: {name}": "Розвивається: {name}", + "{count} events today": { + "one": "{count} подія сьогодні", + "few": "{count} події сьогодні", + "many": "{count} подій сьогодні", + "other": "{count} події сьогодні" + }, + "{count} reports today": { + "one": "{count} повідомлення сьогодні", + "few": "{count} повідомлення сьогодні", + "many": "{count} повідомлень сьогодні", + "other": "{count} повідомлення сьогодні" + }, + "Last 24 h from curated hotspots. Each report links to its publisher; places and categories are read from the text automatically and can be wrong. Side labels describe the source, not whether a report is true.": "Останні 24 год за вибраними гарячими точками. Кожне повідомлення веде до свого видання; місця й категорії визначаються з тексту автоматично і можуть бути хибними. Позначки сторін описують джерело, а не правдивість повідомлення.", "Disputed: {name}": "Спірна територія: {name}", "Administered by {holder} · claimed by {claimant}": "Під управлінням: {holder} · претендує: {claimant}", "Self-administered · claimed by {claimant}": "Самоврядна · претендує: {claimant}", @@ -53,6 +67,34 @@ "News feed not configured on this server": "Стрічку новин не налаштовано на цьому сервері", "Malformed news hotspots response": "Некоректна відповідь гарячих точок новин", "Malformed news feed response": "Некоректна відповідь стрічки новин", + "{place} (placed by model)": "{place} (місце визначила модель)", + "English headline by the model": "Англійський заголовок від моделі", + "Not placed on the map": "Не позначено на мапі", + "Close": "Закрити", + "Any severity": "Будь-яка тяжкість", + "{severity} and above": "{severity} і вище", + "{severity} only": "Лише {severity}", + "All categories": "Усі категорії", + "Reading the news…": "Читаємо новини…", + "News, last 24 hours": "Новини за останні 24 години", + "Last 24 h": "За 24 год", + "Hotspots": "Гарячі точки", + "Severity": "Тяжкість", + "Category": "Категорія", + "Include minor news": "Показувати другорядні новини", + "Show event cards": "Показувати картки подій", + "In view": "У полі зору", + "Nothing reported near here in the last 24 h": "За останні 24 год поблизу нічого не повідомлялося", + "Latest news": "Останні новини", + "No news matches these filters in the last 24 h": "За останні 24 год немає новин за цими фільтрами", + "Load older news": "Завантажити давніші новини", + "How to read the map": "Як читати мапу", + "Colour: severity · icon: type of event": "Колір: тяжкість · значок: тип події", + "Solid rim: reported by both sides or independently. Dashed rim: a claim. Thin rim: one source, or one side’s own press.": "Суцільний обідок: повідомляють обидві сторони або незалежні джерела. Пунктирний обідок: заява. Тонкий обідок: одне джерело або преса однієї сторони.", + "Larger: more sources · fainter: older. Picture card: a major or fast-growing event with two or more sources.": "Більше: більше джерел · блідіше: давніше. Картка з фото: велика або швидко зростаюча подія з двох і більше джерел.", + "Side labels describe the source, not whether a report is true.": "Позначки сторін описують джерело, а не правдивість повідомлення.", + "Exit cockpit to fly to a news report": "Вийдіть із кабіни, щоб перелетіти до повідомлення", + "News items in the feed": "Новин у стрічці", "Pick a shape, then click the map.": "Виберіть фігуру й клацніть на карті.", "Enter to place the pin, Esc to cancel.": "Enter — поставити мітку, Esc — скасувати.", "Click where the pin goes.": "Клацніть там, де має бути мітка.", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 30a4d8807..0b398051d 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1,5 +1,13 @@ { "News feed unavailable": "新闻源不可用", + "Developing: {name}": "事态发展:{name}", + "{count} events today": { + "other": "今日 {count} 起事件" + }, + "{count} reports today": { + "other": "今日 {count} 条报道" + }, + "Last 24 h from curated hotspots. Each report links to its publisher; places and categories are read from the text automatically and can be wrong. Side labels describe the source, not whether a report is true.": "精选热点的最近 24 小时。每条报道都链接到其发布者;地点和类别由文本自动读取,可能有误。阵营标签描述的是来源,而非报道是否属实。", "Disputed: {name}": "争议地区:{name}", "Administered by {holder} · claimed by {claimant}": "由{holder}管辖 · {claimant}主张主权", "Self-administered · claimed by {claimant}": "自治 · {claimant}主张主权", @@ -50,6 +58,34 @@ "News feed not configured on this server": "此服务器未配置新闻源", "Malformed news hotspots response": "新闻热点响应格式错误", "Malformed news feed response": "新闻源响应格式错误", + "{place} (placed by model)": "{place}(由模型定位)", + "English headline by the model": "模型生成的英文标题", + "Not placed on the map": "未在地图上标出", + "Close": "关闭", + "Any severity": "任意严重程度", + "{severity} and above": "{severity}及以上", + "{severity} only": "仅{severity}", + "All categories": "所有类别", + "Reading the news…": "正在读取新闻…", + "News, last 24 hours": "最近 24 小时的新闻", + "Last 24 h": "最近 24 小时", + "Hotspots": "热点", + "Severity": "严重程度", + "Category": "类别", + "Include minor news": "包括次要新闻", + "Show event cards": "显示事件卡片", + "In view": "视野内", + "Nothing reported near here in the last 24 h": "最近 24 小时附近无报道", + "Latest news": "最新新闻", + "No news matches these filters in the last 24 h": "最近 24 小时没有符合这些筛选条件的新闻", + "Load older news": "加载更早的新闻", + "How to read the map": "如何解读地图", + "Colour: severity · icon: type of event": "颜色:严重程度 · 图标:事件类型", + "Solid rim: reported by both sides or independently. Dashed rim: a claim. Thin rim: one source, or one side’s own press.": "实线边框:双方或独立来源均有报道。虚线边框:一方声称。细边框:单一来源,或一方自身的媒体。", + "Larger: more sources · fainter: older. Picture card: a major or fast-growing event with two or more sources.": "越大:来源越多 · 越淡:越早。图片卡片:有两个或以上来源的严重或快速发展的事件。", + "Side labels describe the source, not whether a report is true.": "阵营标签描述的是来源,而非报道是否属实。", + "Exit cockpit to fly to a news report": "退出驾驶舱以飞往新闻报道", + "News items in the feed": "列表中的新闻数", "Pick a shape, then click the map.": "先选择形状,再在地图上点击。", "Enter to place the pin, Esc to cancel.": "按 Enter 放置图钉,按 Esc 取消。", "Click where the pin goes.": "点击图钉的放置位置。", diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index e29d0fff2..a89b6ba22 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -1,5 +1,13 @@ { "News feed unavailable": "新聞來源無法使用", + "Developing: {name}": "事態發展:{name}", + "{count} events today": { + "other": "今日 {count} 起事件" + }, + "{count} reports today": { + "other": "今日 {count} 則報導" + }, + "Last 24 h from curated hotspots. Each report links to its publisher; places and categories are read from the text automatically and can be wrong. Side labels describe the source, not whether a report is true.": "精選熱點的最近 24 小時。每則報導都連結至其發布者;地點與類別由文字自動判讀,可能有誤。陣營標籤描述的是來源,而非報導是否屬實。", "Disputed: {name}": "爭議地區:{name}", "Administered by {holder} · claimed by {claimant}": "由{holder}管轄 · {claimant}主張主權", "Self-administered · claimed by {claimant}": "自治 · {claimant}主張主權", @@ -50,6 +58,34 @@ "News feed not configured on this server": "此伺服器未設定新聞來源", "Malformed news hotspots response": "新聞熱點回應格式錯誤", "Malformed news feed response": "新聞來源回應格式錯誤", + "{place} (placed by model)": "{place}(由模型定位)", + "English headline by the model": "模型產生的英文標題", + "Not placed on the map": "未在地圖上標出", + "Close": "關閉", + "Any severity": "任意嚴重程度", + "{severity} and above": "{severity}及以上", + "{severity} only": "僅{severity}", + "All categories": "所有類別", + "Reading the news…": "正在讀取新聞…", + "News, last 24 hours": "最近 24 小時的新聞", + "Last 24 h": "最近 24 小時", + "Hotspots": "熱點", + "Severity": "嚴重程度", + "Category": "類別", + "Include minor news": "包括次要新聞", + "Show event cards": "顯示事件卡片", + "In view": "視野內", + "Nothing reported near here in the last 24 h": "最近 24 小時附近無報導", + "Latest news": "最新新聞", + "No news matches these filters in the last 24 h": "最近 24 小時沒有符合這些篩選條件的新聞", + "Load older news": "載入更早的新聞", + "How to read the map": "如何解讀地圖", + "Colour: severity · icon: type of event": "顏色:嚴重程度 · 圖示:事件類型", + "Solid rim: reported by both sides or independently. Dashed rim: a claim. Thin rim: one source, or one side’s own press.": "實線邊框:雙方或獨立來源均有報導。虛線邊框:一方聲稱。細邊框:單一來源,或一方自身的媒體。", + "Larger: more sources · fainter: older. Picture card: a major or fast-growing event with two or more sources.": "越大:來源越多 · 越淡:越早。圖片卡片:有兩個或以上來源的嚴重或快速發展的事件。", + "Side labels describe the source, not whether a report is true.": "陣營標籤描述的是來源,而非報導是否屬實。", + "Exit cockpit to fly to a news report": "退出駕駛艙以飛往新聞報導", + "News items in the feed": "列表中的新聞數", "Pick a shape, then click the map.": "先選擇形狀,再在地圖上點擊。", "Enter to place the pin, Esc to cancel.": "按 Enter 放置圖釘,按 Esc 取消。", "Click where the pin goes.": "點擊圖釘的放置位置。", diff --git a/src/layers/news/index.js b/src/layers/news/index.js new file mode 100644 index 000000000..2f8c08f90 --- /dev/null +++ b/src/layers/news/index.js @@ -0,0 +1,1240 @@ +import * as Cesium from 'cesium'; +import { isPointerFree } from '../../data/inputOwnership.js'; +import { hitTestWorldOverlay } from '../../overlays/worldOverlay.js'; +import { + isOwnedByOtherLayer, + registerPickOwner, + resolvePickId, + unregisterPickOwner, +} from '../../data/pickRegistry.js'; +import { + clearSelectedEntityContextForLayer, + registerEntityContext, + removeEntityContextsForLayer, + selectEntityContext, +} from '../../data/contextStore.js'; +import { msg, t, tn } from '../../i18n/index.js'; +import { createHorizonCull, viewCentre } from '../streetLevel/view.js'; +import { + NEWS_CARD_MIN_SOURCES, + NEWS_CATEGORIES, + NEWS_DETAIL_MAX_HEIGHT_M, + NEWS_FEED_PAGE, + NEWS_FOCUS_HEIGHT_M, + NEWS_IN_VIEW_MAX_HEIGHT_M, + NEWS_LAYER_ID, + NEWS_MAP_LIMIT, + NEWS_OVERLAY_SOURCE_ID, + NEWS_PICK_PREFIX, + NEWS_RELEVANT, + NEWS_SETTLE_MS, + NEWS_SEVERITY, + ageWords, + attestation, + beaconPixelSize, + cardEvents, + cardView, + categoryLabel, + disputedLabel, + feedCardView, + feedQueryFor, + hotspotLabel, + hotspotTrend, + isPressing, + markerStyle, + mediaLabels, + mergeFeedPage, + normalizeFilters, + passesFilters, + reportMarkerPosition, + reportsInView, + severityStyle, + shortTitle, + sideLabel, + sidesLine, + sourceLinkLabel, + viewCell, + visibleOnMap, + withinNewsWindow, +} from './model.js'; +import { SPRITE_SCALE, markerSprite } from './sprites.js'; +import { CARD_H, CARD_W, eventCard } from './cards.js'; +export * from './model.js'; +export { createNewsSource } from './source.js'; + +const FEED_UNAVAILABLE = msg('News feed unavailable'); +const REPORT_PREFIX = `${NEWS_PICK_PREFIX}report:`; +const HOTSPOT_PREFIX = `${NEWS_PICK_PREFIX}hotspot:`; +const DEVELOPING_PREFIX = `${NEWS_PICK_PREFIX}developing:`; +const DEVELOPING_COLOR = '#ffb020'; +// Disputed areas: dotted, in their own colour, apart from ordinary borders. +const DISPUTED_COLOR = '#f472b6'; +const HOTSPOT_COLOR = '#00d4ff'; +// A filtered feed page that shows almost nothing reads the next page by +// itself, a few times, so a strict severity floor still fills the list. +const AUTO_PAGES = 3; +const PULSE_MS = 1_600; +const emptyFeed = () => ({ + items: [], + nextBefore: null, + loading: false, + error: null, +}); +const emptyInView = () => ({ + active: false, + cell: null, + centre: null, + reports: [], + loading: false, + error: null, +}); + +/** + * News from curated hotspots, real time only (the last 24 hours). Zoomed out: + * one beacon per hotspot with its daily count and trend, and a breathing + * beacon per developing situation. Close up or with a hotspot chosen: + * outlines (disputed areas dotted) and the geocoded reports as markers, the + * big events as picture cards. The News panel renders the feed this layer + * publishes through getRowControls().feed. + */ +export function createNewsLayer({ + source, + overlayHost, + cesium = Cesium, + matchMedia = globalThis.matchMedia?.bind(globalThis), + now = () => Date.now(), + hitTestOverlay = hitTestWorldOverlay, + openUrl = (url) => globalThis.open?.(url, '_blank', 'noopener,noreferrer'), + getViewCentre = viewCentre, +} = {}) { + if ( + typeof source?.getHotspots !== 'function' || + typeof source?.getFeed !== 'function' + ) + throw new TypeError('News requires a news source'); + if (!overlayHost) throw new TypeError('News requires an overlay host'); + + let viewer = null, + dataSource = null, + outlineSource = null, + clickHandler = null, + listener = null, + runNavigation = null, + request = null, + feedRequest = null, + inViewRequest = null, + settleTimer = null, + pulseTimer = null, + removeCameraListener = null; + let enabled = false, + destroyed = false, + loading = false, + unavailable = false, + error = null, + lastUpdate = null, + lastRunAt = null, + autoPages = 0; + let hotspots = []; + let mapReports = []; + let feed = emptyFeed(); + let inView = emptyInView(); + let filters = normalizeFilters(); + let selectedId = null, + selectedPin = 0; + let shownDetail = null; + let cullTargets = []; + const contextEntities = new Map(); + const notify = () => listener?.(); + const requestRender = () => viewer?.scene?.requestRender?.(); + const reducedMotion = () => + Boolean(matchMedia?.('(prefers-reduced-motion: reduce)')?.matches); + + const findReport = (id) => + mapReports.find((r) => r.id === id) || + feed.items.find((r) => r.id === id) || + inView.reports.find((r) => r.id === id) || + null; + const selected = () => (selectedId ? findReport(selectedId) : null); + const onMap = (report) => visibleOnMap(report, filters, now()); + + function markerCartesian(report, pin = 0) { + const { lat, lon } = reportMarkerPosition(report, pin); + return cesium.Cartesian3.fromDegrees(lon, lat); + } + + const cameraHeight = () => viewer?.camera?.positionCartographic?.height; + + // Global view: one beacon per hotspot. Close up: outlines and individual + // reports. A chosen hotspot keeps the detail out to three times the height + // (its whole area fits on screen). + function detailMode() { + const height = cameraHeight(); + if (!Number.isFinite(height)) return filters.hotspot !== 'all'; + const limit = + filters.hotspot === 'all' + ? NEWS_DETAIL_MAX_HEIGHT_M + : NEWS_DETAIL_MAX_HEIGHT_M * 3; + return height < limit; + } + + // The catch-all ("Elsewhere") has no area to stand for: its reports are + // drawn as markers at every zoom instead of a beacon or an outline. + const catchAllIds = () => + new Set(hotspots.filter((h) => h.catchAll).map((h) => h.id)); + + // Markers draw over terrain (depth test off) so a pin is never swallowed by + // the ground it sits on; the shared horizon cull hides the far side. + const horizon = createHorizonCull({ + getViewer: () => viewer, + items: () => cullTargets, + onChange: () => { + requestRender(); + updatePulse(); + }, + }); + + // A developing situation's beacon breathes, unless motion is reduced. The + // scene renders on demand, so a slow timer asks for frames only while such + // a beacon is in view. + const pulseSize = (size) => + size * + (1 + 0.35 * (0.5 + 0.5 * Math.sin((Date.now() / PULSE_MS) * 2 * Math.PI))); + function updatePulse() { + const pulsing = + enabled && + !reducedMotion() && + cullTargets.some((target) => target.developing && target.show); + if (pulsing && !pulseTimer) pulseTimer = setInterval(requestRender, 250); + else if (!pulsing && pulseTimer) { + clearInterval(pulseTimer); + pulseTimer = null; + } + } + + const ambientLabel = (extra) => ({ + variant: 'label', + collisionGroup: 'ambient-label', + paintLane: 'ambient-label', + interactive: false, + horizonCull: true, + terrainOcclusion: false, + placement: 'above', + ...extra, + }); + + // Several separate events in one country outside every hotspot. + function renderDeveloping(hotspot, entities, labels) { + const still = reducedMotion(); + for (const item of hotspot.developing || []) { + const position = cesium.Cartesian3.fromDegrees( + item.focus.lon, + item.focus.lat, + ); + const size = beaconPixelSize(item.reports); + entities.add({ + id: `${DEVELOPING_PREFIX}${hotspot.id}:${item.key}`, + position, + point: { + pixelSize: still + ? size + : new cesium.CallbackProperty(() => pulseSize(size), false), + color: + cesium.Color.fromCssColorString(DEVELOPING_COLOR).withAlpha(0.35), + outlineColor: cesium.Color.fromCssColorString(DEVELOPING_COLOR), + outlineWidth: 2, + disableDepthTestDistance: Number.POSITIVE_INFINITY, + }, + }); + labels.push( + ambientLabel({ + id: `developing:${item.key}`, + position, + title: t('Developing: {name}', { name: item.name }), + details: [ + tn(item.events, '{count} event today', '{count} events today'), + ], + accent: DEVELOPING_COLOR, + priority: 9_000 + item.reports, + gapPx: size / 2 + 4, + }), + ); + } + } + + function renderBeacons(entities, labels) { + for (const hotspot of hotspots) { + if (hotspot.catchAll) { + renderDeveloping(hotspot, entities, labels); + continue; + } + const { lat, lon } = hotspot.focus; + const position = cesium.Cartesian3.fromDegrees(lon, lat); + entities.add({ + id: `${HOTSPOT_PREFIX}${hotspot.id}`, + position, + point: { + pixelSize: beaconPixelSize(hotspot.last24h), + color: cesium.Color.fromCssColorString(HOTSPOT_COLOR).withAlpha(0.35), + outlineColor: cesium.Color.fromCssColorString(HOTSPOT_COLOR), + outlineWidth: 2, + disableDepthTestDistance: Number.POSITIVE_INFINITY, + }, + }); + const trend = hotspotTrend(hotspot.last24h, hotspot.prev24h); + labels.push( + ambientLabel({ + id: `hotspot:${hotspot.id}`, + position, + title: hotspotLabel(hotspot), + details: [ + `${tn(hotspot.last24h, '{count} report today', '{count} reports today')}${trend ? ` ${trend}` : ''}`, + ], + accent: HOTSPOT_COLOR, + priority: 10_000 + hotspot.last24h, + gapPx: beaconPixelSize(hotspot.last24h) / 2 + 4, + }), + ); + } + } + + function renderHotspotOutlines(entities, labels) { + for (const hotspot of hotspots) { + if (hotspot.catchAll) continue; + const [w, s, e, n] = hotspot.bbox; + const active = filters.hotspot === hotspot.id; + // The bounding box only until the real outline has loaded. + if (!outlineParts.has(hotspot.id)) + entities.add({ + id: `${HOTSPOT_PREFIX}${hotspot.id}`, + polyline: { + positions: cesium.Cartesian3.fromDegreesArray([ + w, + s, + e, + s, + e, + n, + w, + n, + w, + s, + ]), + width: active ? 3 : 2, + clampToGround: true, + material: new cesium.PolylineDashMaterialProperty({ + color: cesium.Color.fromCssColorString(HOTSPOT_COLOR).withAlpha( + active ? 0.95 : 0.55, + ), + dashLength: 18, + }), + }, + }); + labels.push( + ambientLabel({ + id: `hotspot:${hotspot.id}`, + position: outlineParts.has(hotspot.id) + ? cesium.Cartesian3.fromDegrees( + hotspot.focus.lon, + hotspot.focus.lat, + ) + : cesium.Cartesian3.fromDegrees(w, n), + title: hotspotLabel(hotspot), + details: [ + tn( + hotspot.last24h, + '{count} report today', + '{count} reports today', + ), + ], + accent: HOTSPOT_COLOR, + priority: 10_000 + hotspot.last24h, + }), + ); + // Each disputed area is named, with who holds it and who claims it. + for (const area of outlineDisputed.get(hotspot.id) || []) { + const ring = area.rings[0]; + const lon = ring.reduce((sum, p) => sum + p[0], 0) / ring.length; + const lat = ring.reduce((sum, p) => sum + p[1], 0) / ring.length; + const label = disputedLabel(area); + labels.push( + ambientLabel({ + id: `disputed:${hotspot.id}:${area.name}`, + position: cesium.Cartesian3.fromDegrees(lon, lat), + title: label.title, + details: [label.detail], + accent: DISPUTED_COLOR, + priority: 8_000, + }), + ); + } + } + } + + function renderMarkers(entities, drawn) { + for (const report of drawn) { + const isSelected = report.id === selectedId; + if (!report.pins.length || (!isSelected && !onMap(report))) continue; + // One marker per pin; the pin index rides on the pick id. Fill is + // severity, the icon the category, the rim attestation; a marker grows + // with the sources reporting its event and fades with age. A fresh, + // widely reported or major event also gets a static halo. + const look = markerStyle(report, now()); + const color = cesium.Color.fromCssColorString( + severityStyle(report).color, + ); + const size = isSelected ? look.size + 4 : look.size; + const sprite = markerSprite({ ...look, selected: isSelected }); + const pressing = isPressing(report, now()); + for (const [pin] of report.pins.entries()) { + const position = markerCartesian(report, pin); + if (pressing) + entities.add({ + id: `${REPORT_PREFIX}${report.id}~${pin}~halo`, + position, + point: { + pixelSize: look.size * 2.4, + color: color.withAlpha(0.22), + outlineColor: color.withAlpha(0.6), + outlineWidth: 1, + heightReference: cesium.HeightReference.CLAMP_TO_GROUND, + disableDepthTestDistance: Number.POSITIVE_INFINITY, + }, + }); + entities.add({ + id: `${REPORT_PREFIX}${report.id}~${pin}`, + position, + ...(sprite + ? { + billboard: { + image: sprite, + width: Math.round(size * SPRITE_SCALE), + height: Math.round(size * SPRITE_SCALE), + color: cesium.Color.WHITE.withAlpha( + isSelected ? 1 : look.alpha, + ), + heightReference: cesium.HeightReference.CLAMP_TO_GROUND, + disableDepthTestDistance: Number.POSITIVE_INFINITY, + }, + } + : { + point: { + pixelSize: size, + color, + outlineColor: isSelected + ? cesium.Color.WHITE + : cesium.Color.BLACK.withAlpha(0.7), + outlineWidth: isSelected ? 3 : 1.5, + heightReference: cesium.HeightReference.CLAMP_TO_GROUND, + disableDepthTestDistance: Number.POSITIVE_INFINITY, + }, + }), + }); + } + } + } + + // The big events stand as picture cards over their marker, unless the + // reader has turned them off; never two on top of each other. The clicked + // event shows its picture card either way, under its marker, with its text + // card above. + function pictureCards(drawn, current, labels) { + const cardable = filters.cards + ? cardEvents( + drawn.filter((r) => r.id !== selectedId && onMap(r)), + now(), + ) + : []; + const pictured = + current?.pins.length && + current.event?.size >= NEWS_CARD_MIN_SOURCES && + current.pins[selectedPin] + ? [current] + : []; + for (const [rank, report] of [...pictured, ...cardable].entries()) { + const isSelected = report.id === selectedId; + const look = markerStyle(report, now()); + const view = cardView(report, now()); + const image = eventCard( + report.event?.id || report.id, + view, + look, + requestRender, + ); + if (!image) continue; + const color = severityStyle(report).color; + labels.push({ + id: `event-card:${report.id}`, + position: markerCartesian(report, isSelected ? selectedPin : 0), + variant: 'thumbnail', + paintLane: 'thumbnail', + title: '', + details: [], + image, + accent: color, + priority: isSelected ? Number.MAX_SAFE_INTEGER - 1 : 500_000 - rank, + collisionGroup: isSelected ? 'news-selected-picture' : 'ambient-card', + interactive: true, + accessibilityLabel: `${view.title}. ${view.meta}`, + activate: () => layer.setParams({ reportId: report.id, pin: 0 }), + horizonCull: true, + terrainOcclusion: false, + placement: isSelected ? 'below' : 'above', + verticalOnly: true, + gapPx: look.size / 2 + 12, + leaderOffsetPx: look.size / 2 + 2, + viewportPadding: 24, + // The collision box: rectangle overlap alone lets the cards of nearby + // anchors stack. + minAnchorSeparationPx: isSelected ? 0 : CARD_W * 0.85, + thumbnailWidth: CARD_W, + thumbnailHeight: CARD_H, + thumbnailPadX: 0, + thumbnailPadTop: 0, + thumbnailPadBottom: 0, + thumbnailTitleGap: 0, + thumbnailTitleHeight: 0, + thumbnailTitleChars: 0, + thumbnailBackground: 'rgba(0, 0, 0, 0)', + thumbnailLeaderColor: color, + thumbnailRuleHeight: 0, + thumbnailRadius: 4, + }); + } + } + + // The selected report's card: what, where, how attested, and the link to + // the original article ("Source: host ↗"), which a click on it opens. + function selectedCard(report, labels) { + if (!report?.pins.length) return; + const pin = report.pins[selectedPin] ? selectedPin : 0; + labels.push({ + id: `selected-report:${report.id}`, + position: markerCartesian(report, pin), + variant: 'card', + title: shortTitle(report.titleEn || report.title), + details: [ + [ + report.publisher, + sideLabel(report.side), + ageWords(report.publishedAt, now()), + ] + .filter(Boolean) + .join(' · '), + [ + categoryLabel(report.category), + t(severityStyle(report).label), + report.pins[pin].place, + ] + .filter(Boolean) + .join(' · '), + sidesLine(report), + attestation(report)?.label || '', + mediaLabels(report).join(' · '), + sourceLinkLabel(report.url), + ].filter(Boolean), + accent: severityStyle(report).color, + selected: true, + stateless: true, + priority: Number.MAX_SAFE_INTEGER, + interactive: true, + accessibilityLabel: sourceLinkLabel(report.url), + activate: () => { + openUrl(report.url); + return true; + }, + horizonCull: true, + terrainOcclusion: false, + placement: 'above', + gapPx: 12, + }); + } + + function render() { + if (!dataSource) return; + const entities = dataSource.entities; + // Removed before events are suspended: a marker removed and re-added + // under the same id in one suspended batch reaches Cesium's visualizers + // as "changed", and they keep drawing the removed entity (i.e. nothing). + entities.removeAll(); + entities.suspendEvents(); + const labels = []; + const detail = detailMode(); + shownDetail = detail; + if (detail) renderHotspotOutlines(entities, labels); + else renderBeacons(entities, labels); + // A report picked in the feed or In view still gets its marker. + const current = selected(); + const elsewhere = catchAllIds(); + const shown = detail + ? mapReports + : mapReports.filter((r) => elsewhere.has(r.hotspotId)); + const drawn = + current && !shown.some((r) => r.id === current.id) + ? [...shown, current] + : shown; + renderMarkers(entities, drawn); + entities.resumeEvents(); + showOutlines(detail); + const at = cesium.JulianDate.now(); + cullTargets = entities.values + .filter((entity) => (entity.point || entity.billboard) && entity.position) + .map((entity) => ({ + position: entity.position.getValue(at), + developing: String(entity.id).startsWith(DEVELOPING_PREFIX), + get show() { + return entity.show; + }, + set show(value) { + entity.show = value; + }, + })) + .filter((target) => target.position); + horizon.update(); + updatePulse(); + pictureCards(drawn, current, labels); + selectedCard(current, labels); + if (enabled) + overlayHost.setEntries(NEWS_OVERLAY_SOURCE_ID, labels, { moving: false }); + requestRender(); + } + + function registerContext(report) { + const place = report?.pins[selectedPin] || report?.geo; + if (!place) return null; + let entity = contextEntities.get(report.id); + if (!entity) { + entity = { show: true }; + contextEntities.set(report.id, entity); + } + const { lat, lon } = reportMarkerPosition( + report, + report.pins[selectedPin] ? selectedPin : 0, + ); + entity.__localBaseCartesian = cesium.Cartesian3.fromDegrees(lon, lat); + registerEntityContext(entity, { + id: `news-report:${report.id}`, + layerId: NEWS_LAYER_ID, + layerName: 'News', + source: report.publisher, + dataSource, + label: report.title, // i18n-ignore: publisher headline (data) + latitude: place.lat, + longitude: place.lon, + properties: { + publisher: report.publisher, + side: report.side, + category: report.category, + place: place.place, + publishedAt: report.publishedAt, + url: report.url, + }, + }); + return entity; + } + + function select(id, pin = 0) { + selectedId = id; + selectedPin = pin; + const report = selected(); + if (report) { + const entity = registerContext(report); + if (entity) selectEntityContext(entity); + } else clearSelectedEntityContextForLayer(NEWS_LAYER_ID); + render(); + notify(); + } + + function flyTo(navigate) { + if (!runNavigation || !viewer) return; + runNavigation(() => (enabled && !destroyed ? navigate() : undefined)); + } + + // A card's click: straight down over the event from 150 km. + function focusReport(report) { + if (!report?.pins.length) return; + const { lat, lon } = report.pins[selectedPin] || report.pins[0]; + flyTo(() => + viewer.camera.flyTo({ + destination: cesium.Cartesian3.fromDegrees( + lon, + lat, + NEWS_FOCUS_HEIGHT_M, + ), + duration: reducedMotion() ? 0 : 1.4, + }), + ); + } + + function focusRectangle([w, s, e, n]) { + flyTo(() => + viewer.camera.flyTo({ + destination: cesium.Rectangle.fromDegrees(w, s, e, n), + duration: reducedMotion() ? 0 : 1.6, + }), + ); + } + + // Hotspot extents (and developing countries) are drawn in their own data + // source, built once per outline and only shown or hidden afterwards: + // ground polygons over 3D tiles are slow to build, so they are never + // rebuilt on a re-render. A hotspot's fill clears when it is the chosen + // one so its border stays readable over the reports inside. + const outlineParts = new Map(); + const outlineRequests = new Set(); + const outlineDisputed = new Map(); + function addOutline( + key, + { polygons, borders, disputed = [] }, + { color, fillAlpha, pickId }, + ) { + if (!outlineSource || outlineParts.has(key)) return; + const css = cesium.Color.fromCssColorString(color); + const parts = { fills: [], borders: [] }; + const ring = (points) => cesium.Cartesian3.fromDegreesArray(points.flat()); + const closed = (points) => ring([...points, points[0]]); + const line = (id, positions, width, material) => + parts.borders.push( + outlineSource.entities.add({ + id, + show: false, + polyline: { positions, width, clampToGround: true, material }, + }), + ); + for (const [index, polygon] of polygons.entries()) { + if (fillAlpha > 0) + parts.fills.push( + outlineSource.entities.add({ + id: `${pickId}~fill${index}`, + show: false, + polygon: { + hierarchy: new cesium.PolygonHierarchy( + ring(polygon.outer), + polygon.holes.map( + (hole) => new cesium.PolygonHierarchy(ring(hole)), + ), + ), + material: css.withAlpha(fillAlpha), + classificationType: cesium.ClassificationType.BOTH, + }, + }), + ); + for (const [k, points] of [polygon.outer, ...polygon.holes].entries()) + line( + `${pickId}~line${index}.${k}`, + closed(points), + 2, + css.withAlpha(0.85), + ); + } + // National borders inside a multi-country hotspot: thin and dashed. + for (const [index, points] of borders.entries()) + line( + `${pickId}~border${index}`, + closed(points), + 1.5, + new cesium.PolylineDashMaterialProperty({ + color: css.withAlpha(0.6), + dashLength: 12, + }), + ); + const dotted = new cesium.PolylineDashMaterialProperty({ + color: cesium.Color.fromCssColorString(DISPUTED_COLOR).withAlpha(0.9), + dashLength: 6, + }); + for (const [index, area] of disputed.entries()) + for (const [k, points] of area.rings.entries()) + line(`${pickId}~disputed${index}.${k}`, closed(points), 2, dotted); + outlineDisputed.set(key, disputed); + outlineParts.set(key, parts); + } + function loadOutline(key, fetchOutline, style) { + if (outlineParts.has(key) || outlineRequests.has(key) || !fetchOutline) + return; + outlineRequests.add(key); + fetchOutline() + .then((outline) => { + if (destroyed) return; + addOutline(key, outline, style); + if (enabled) render(); + }) + // An outline is an extra: the bounding box stands in for it. + .catch(() => {}) + .finally(() => outlineRequests.delete(key)); + } + function loadOutlines() { + for (const hotspot of hotspots) { + if (!hotspot.catchAll) + loadOutline( + hotspot.id, + source.getOutline && (() => source.getOutline(hotspot.id)), + { + color: HOTSPOT_COLOR, + fillAlpha: 0.08, + pickId: `${HOTSPOT_PREFIX}${hotspot.id}`, + }, + ); + for (const item of hotspot.developing || []) + loadOutline( + `developing:${item.key}`, + source.getCountryOutline && + (() => source.getCountryOutline(item.key)), + { + color: DEVELOPING_COLOR, + fillAlpha: 0, + pickId: `${DEVELOPING_PREFIX}${hotspot.id}:${item.key}`, + }, + ); + } + } + function showOutlines(detail) { + const developing = new Set( + hotspots.flatMap((h) => (h.developing || []).map((d) => d.key)), + ); + for (const [key, parts] of outlineParts) { + const country = key.startsWith('developing:') ? key.slice(11) : null; + const visible = enabled && (country ? developing.has(country) : detail); + const fill = visible && !country && filters.hotspot !== key; + for (const entity of parts.fills) + if (entity.show !== fill) entity.show = fill; + for (const entity of parts.borders) + if (entity.show !== visible) entity.show = visible; + } + } + + const shownCount = () => + feed.items.filter((r) => passesFilters(r, filters, now())).length; + + async function loadFeed(mode) { + if (!enabled || unavailable) return; + if (mode === 'append' && (!feed.nextBefore || feed.loading)) return; + if (mode === 'replace') autoPages = 0; + feedRequest?.abort(); + const controller = new AbortController(); + feedRequest = controller; + feed = { ...feed, loading: true, error: null }; + notify(); + try { + const page = await source.getFeed( + feedQueryFor( + filters, + mode === 'append' ? { before: feed.nextBefore } : {}, + ), + { signal: controller.signal }, + ); + if (controller.signal.aborted || feedRequest !== controller || !enabled) + return; + feed = { + ...mergeFeedPage(feed, page, mode), + loading: false, + error: null, + }; + } catch (cause) { + if (controller.signal.aborted || feedRequest !== controller) return; + feed = { + ...feed, + loading: false, + error: cause?.message || FEED_UNAVAILABLE, + }; + } finally { + if (feedRequest === controller) feedRequest = null; + notify(); + } + if ( + enabled && + !feed.error && + feed.nextBefore && + shownCount() < NEWS_FEED_PAGE / 3 && + autoPages < AUTO_PAGES + ) { + autoPages++; + void loadFeed('append'); + } + } + + // "In view": below 3,000 km, the reports within 500 km of the view centre, + // read again once the camera has settled for 0.8 s in another half-degree + // cell. The feed has no area query, so the last day's geocoded reports are + // read and kept by distance here. + function scheduleInView() { + clearTimeout(settleTimer); + settleTimer = setTimeout(() => { + settleTimer = null; + void refreshInView(); + }, NEWS_SETTLE_MS); + } + async function refreshInView({ force = false } = {}) { + if (!enabled || unavailable) return; + const height = cameraHeight(); + if (!(height < NEWS_IN_VIEW_MAX_HEIGHT_M)) { + if (inView.active) { + inViewRequest?.abort(); + inViewRequest = null; + inView = emptyInView(); + notify(); + } + return; + } + const centre = getViewCentre(viewer); + if (!centre) return; + const cell = viewCell(centre); + if (!force && inView.active && cell === inView.cell) return; + inViewRequest?.abort(); + const controller = new AbortController(); + inViewRequest = controller; + inView = { ...inView, active: true, cell, centre, loading: true }; + notify(); + try { + const page = await source.getFeed( + { + hotspot: 'all', + geo: true, + today: true, + events: true, + relevance: filters.includeMinor ? '' : NEWS_RELEVANT, + limit: NEWS_MAP_LIMIT, + }, + { signal: controller.signal }, + ); + if (controller.signal.aborted || inViewRequest !== controller) return; + inView = { + ...inView, + reports: page.reports, + loading: false, + error: null, + }; + } catch (cause) { + if (controller.signal.aborted || inViewRequest !== controller) return; + inView = { + ...inView, + loading: false, + error: cause?.message || FEED_UNAVAILABLE, + }; + } finally { + if (inViewRequest === controller) inViewRequest = null; + notify(); + } + } + + function installSelection() { + if (clickHandler || !viewer?.scene?.canvas) return; + clickHandler = new cesium.ScreenSpaceEventHandler(viewer.scene.canvas); + clickHandler.setInputAction((click) => { + if (!enabled || destroyed || !isPointerFree() || !click?.position) return; + const card = hitTestOverlay(click.position.x, click.position.y, { + sourceId: NEWS_OVERLAY_SOURCE_ID, + }); + if (card?.entry?.activate) { + card.entry.activate(); + return; + } + const picked = viewer.scene.pick(click.position); + const id = picked ? resolvePickId(picked) : null; + if (typeof id === 'string' && id.startsWith(REPORT_PREFIX)) { + const [reportId, pin] = id.slice(REPORT_PREFIX.length).split('~'); + layer.setParams({ reportId, pin: Number(pin) || 0 }); + return; + } + if (typeof id === 'string' && id.startsWith(DEVELOPING_PREFIX)) { + const [hotspotId, key] = id + .slice(DEVELOPING_PREFIX.length) + .split('~')[0] + .split(':'); + const item = hotspots + .find((h) => h.id === hotspotId) + ?.developing?.find((d) => d.key === key); + layer.setParams({ filters: { hotspot: hotspotId } }); + if (item) { + const { lat, lon } = item.focus; + focusRectangle([ + lon - 5, + Math.max(-85, lat - 3.5), + lon + 5, + Math.min(85, lat + 3.5), + ]); + } + return; + } + if (typeof id === 'string' && id.startsWith(HOTSPOT_PREFIX)) { + const hotspotId = id.slice(HOTSPOT_PREFIX.length).split('~')[0]; + // Clicking the hotspot already in focus steps out of a report. + if (filters.hotspot === hotspotId) { + if (selectedId) layer.setParams({ clear: true }); + return; + } + layer.setParams({ filters: { hotspot: hotspotId } }); + return; + } + if (id && isOwnedByOtherLayer(NEWS_LAYER_ID, id)) return; + if (selectedId && !picked?.id) layer.setParams({ clear: true }); + }, cesium.ScreenSpaceEventType.LEFT_CLICK); + } + + function removeSelection() { + if (clickHandler && !clickHandler.isDestroyed?.()) clickHandler.destroy(); + clickHandler = null; + } + + function feedView() { + const at = now(); + const report = selected(); + const total = hotspots.reduce((sum, h) => sum + h.last24h, 0); + return { + filters, + hotspots: [ + { id: 'all', label: t('All'), count: total }, + ...hotspots.map((hotspot) => ({ + id: hotspot.id, + label: hotspotLabel(hotspot), + count: hotspot.last24h, + })), + ].map((chip) => ({ ...chip, active: filters.hotspot === chip.id })), + categories: Object.keys(NEWS_CATEGORIES).map((id) => ({ + value: id, + label: categoryLabel(id), + })), + severities: NEWS_SEVERITY.map((level, index) => ({ + value: index, + label: t(level.label), + color: level.color, + })), + items: feed.items + .filter((r) => passesFilters(r, filters, at)) + .map((r) => feedCardView(r, at)), + inView: { + active: inView.active, + loading: inView.loading, + error: inView.error ? t(FEED_UNAVAILABLE) : null, + items: reportsInView(inView.reports, inView.centre, filters, at).map( + (r) => feedCardView(r, at), + ), + }, + selected: + report && withinNewsWindow(report, at) + ? feedCardView(report, at) + : null, + hasMore: Boolean(feed.nextBefore), + loading: feed.loading || loading, + // Read failures are English data; the panel shows them translated. + error: error || feed.error ? t(error || feed.error) : null, + lastRunAt, + }; + } + + const layer = { + id: NEWS_LAYER_ID, + name: 'News', + icon: '◆', + source: 'GEV news', + updateInterval: 120_000, + + init(nextViewer) { + viewer = nextViewer; + dataSource = new cesium.CustomDataSource(NEWS_LAYER_ID); + outlineSource = new cesium.CustomDataSource(`${NEWS_LAYER_ID}-outlines`); + dataSource.show = false; + outlineSource.show = false; + viewer.dataSources?.add(outlineSource); + viewer.dataSources?.add(dataSource); + overlayHost.setVisible(NEWS_OVERLAY_SOURCE_ID, false); + }, + attachShellServices(services) { + runNavigation = + typeof services?.runNavigation === 'function' + ? services.runNavigation + : null; + }, + enable() { + if (destroyed || enabled) return; + enabled = true; + if (dataSource) dataSource.show = true; + if (outlineSource) outlineSource.show = true; + overlayHost.setVisible(NEWS_OVERLAY_SOURCE_ID, true); + registerPickOwner( + NEWS_LAYER_ID, + (id) => enabled && String(id).startsWith(NEWS_PICK_PREFIX), + ); + installSelection(); + // Re-render only when a camera move crosses the detail threshold. + removeCameraListener = + viewer?.camera?.moveEnd?.addEventListener?.(() => { + if (!enabled) return; + if (detailMode() !== shownDetail) render(); + scheduleInView(); + }) || null; + }, + disable() { + enabled = false; + unavailable = false; + error = null; + request?.abort(); + feedRequest?.abort(); + inViewRequest?.abort(); + request = feedRequest = inViewRequest = null; + clearTimeout(settleTimer); + settleTimer = null; + loading = false; + unregisterPickOwner(NEWS_LAYER_ID); + removeSelection(); + removeCameraListener?.(); + removeCameraListener = null; + horizon.stop(); + cullTargets = []; + clearInterval(pulseTimer); + pulseTimer = null; + if (outlineSource) outlineSource.show = false; + selectedId = null; + clearSelectedEntityContextForLayer(NEWS_LAYER_ID); + removeEntityContextsForLayer(NEWS_LAYER_ID); + contextEntities.clear(); + feed = emptyFeed(); + inView = emptyInView(); + mapReports = []; + if (dataSource) { + dataSource.entities.removeAll(); + dataSource.show = false; + } + overlayHost.clearSource(NEWS_OVERLAY_SOURCE_ID); + overlayHost.setVisible(NEWS_OVERLAY_SOURCE_ID, false); + requestRender(); + notify(); + }, + async update(_viewer, { signal } = {}) { + if (!enabled || destroyed) return false; + // A server without the news routes says so once; no background retries. + if (unavailable) return true; + request?.abort(); + const controller = new AbortController(); + const abort = () => controller.abort(signal.reason); + signal?.addEventListener('abort', abort, { once: true }); + request = controller; + loading = true; + notify(); + try { + const [index, todays] = await Promise.all([ + source.getHotspots({ signal: controller.signal }), + source.getFeed( + { + hotspot: 'all', + geo: true, + today: true, + events: true, + relevance: NEWS_RELEVANT, + limit: NEWS_MAP_LIMIT, + }, + { signal: controller.signal }, + ), + ]); + if (!enabled || controller.signal.aborted || request !== controller) + return false; + hotspots = index.hotspots; + loadOutlines(); + lastRunAt = index.lastRunAt; + mapReports = todays.reports.filter((r) => withinNewsWindow(r, now())); + filters = normalizeFilters({}, hotspots, filters); + error = null; + lastUpdate = now(); + render(); + void loadFeed(feed.items.length ? 'head' : 'replace'); + void refreshInView({ force: inView.active }); + return true; + } catch (cause) { + if (controller.signal.aborted || request !== controller) return false; + unavailable = cause?.unavailable === true; + error = cause?.message || FEED_UNAVAILABLE; + return true; + } finally { + signal?.removeEventListener('abort', abort); + if (request === controller) { + request = null; + loading = false; + notify(); + } + } + }, + setParams(params = {}) { + if (!enabled || destroyed) return; + if (params.clear === true) { + select(null); + return; + } + if (params.filters && typeof params.filters === 'object') { + const next = normalizeFilters(params.filters, hotspots, filters); + // The severity floor and the cards change what is shown, not what is read. + const readKey = ({ hotspot, category, includeMinor }) => + `${hotspot}|${category}|${includeMinor}`; + const reread = readKey(next) !== readKey(filters); + const minorChanged = next.includeMinor !== filters.includeMinor; + filters = next; + if ( + Object.hasOwn(params.filters, 'hotspot') && + next.hotspot !== 'all' + ) { + const hotspot = hotspots.find((h) => h.id === next.hotspot); + // The catch-all spans the world: choosing it filters, it does not fly. + if (hotspot && !hotspot.catchAll) focusRectangle(hotspot.bbox); + } + if (reread) feed = emptyFeed(); + render(); + if (reread) void loadFeed('replace'); + if (minorChanged) void refreshInView({ force: true }); + notify(); + } + if (params.loadMore === true) void loadFeed('append'); + if (typeof params.hotspotId === 'string') + layer.setParams({ filters: { hotspot: params.hotspotId } }); + if (typeof params.reportId === 'string' && findReport(params.reportId)) { + select( + params.reportId, + Number.isInteger(params.pin) && params.pin > 0 ? params.pin : 0, + ); + if (params.focus === true) focusReport(selected()); + } + }, + getRowControls() { + return { + chips: [], + legend: [], + info: t( + 'Last 24 h from curated hotspots. Each report links to its publisher; places and categories are read from the text automatically and can be wrong. Side labels describe the source, not whether a report is true.', + ), + // A ready-to-render view for the News panel (src/ui/newsPanel.js), + // which takes no layer imports: labels and colours are resolved here. + feed: feedView(), + }; + }, + setRowControlsListener(value) { + listener = typeof value === 'function' ? value : null; + }, + getAnalystRecords() { + return []; + }, + getStats() { + return { + count: mapReports.filter((r) => r.pins.length && onMap(r)).length, + lastUpdate, + loading, + error, + ...(unavailable + ? { status: 'unavailable', sourceUnavailable: true } + : {}), + source: 'GEV news', + }; + }, + destroy() { + if (destroyed) return; + layer.disable(); + destroyed = true; + if (viewer && dataSource) viewer.dataSources?.remove(dataSource, true); + if (viewer && outlineSource) + viewer.dataSources?.remove(outlineSource, true); + dataSource = null; + outlineSource = null; + outlineParts.clear(); + outlineDisputed.clear(); + viewer = null; + listener = null; + runNavigation = null; + }, + }; + return layer; +} diff --git a/src/layers/news/layer.test.mjs b/src/layers/news/layer.test.mjs new file mode 100644 index 000000000..5cc49938c --- /dev/null +++ b/src/layers/news/layer.test.mjs @@ -0,0 +1,255 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { createNewsLayer } from './index.js'; + +const NOW = Date.parse('2026-10-28T10:00:00Z'); +const hotspotsPayload = { + hotspots: [ + { + id: 'ua-ru-war', + name: 'Russia–Ukraine war', + bbox: [22, 44, 42, 56], + focus: { lat: 48.6, lon: 35.5 }, + last24h: 3, + parties: ['UA', 'RU'], + }, + ], + categories: [], + lastRunAt: null, +}; +const report = (id, extra = {}) => ({ + id, + title: `Report ${id}`, + url: `https://example.com/${id}`, + publishedAt: '2026-10-28T09:00:00.000Z', + publisher: 'Example', + side: 'UA', + category: 'strike', + hotspotId: 'ua-ru-war', + hotspotIds: ['ua-ru-war'], + severity: 2, + geo: { lat: 50, lon: 36, place: 'Kharkiv', mappable: true }, + pins: [{ lat: 50, lon: 36, place: 'Kharkiv', mappable: true }], + event: null, + media: [], + ...extra, +}); + +function fixture( + t, + { source: overrides = {}, height = 20_000_000, reduced = false } = {}, +) { + const calls = { feed: [], hotspots: 0, outline: 0 }; + const reports = [ + report('major', { + severity: 3, + event: { + size: 3, + severity: 3, + status: 'both-sides', + lastAt: '2026-10-28T09:30:00.000Z', + sides: [], + image: null, + }, + }), + report('minor', { severity: 1 }), + report('old', { publishedAt: '2026-10-26T09:00:00.000Z', severity: 3 }), + report('far', { + severity: 2, + pins: [{ lat: 31.5, lon: 34.4, place: 'Gaza', mappable: true }], + }), + ]; + const source = { + getHotspots: async () => { + calls.hotspots++; + return hotspotsPayload; + }, + getFeed: async (query) => { + calls.feed.push(query); + return { reports, nextBefore: null }; + }, + getOutline: async () => { + calls.outline++; + return { polygons: [], borders: [], disputed: [] }; + }, + ...overrides, + }; + const entries = []; + const overlayHost = { + setEntries: (_id, list) => entries.push(list), + setVisible() {}, + clearSource: () => entries.push([]), + }; + const flights = []; + const camera = { + positionCartographic: { height }, + positionWC: Cesium.Cartesian3.fromDegrees(36, 49, height), + moveEnd: new Cesium.Event(), + flyTo: (options) => flights.push(options), + }; + const viewer = { + camera, + scene: { requestRender() {}, preRender: new Cesium.Event() }, + dataSources: { add() {}, remove() {} }, + }; + const layer = createNewsLayer({ + source, + overlayHost, + now: () => NOW, + matchMedia: () => ({ matches: reduced }), + getViewCentre: () => ({ lat: 49, lon: 36 }), + }); + // The context store announces selections on `window`, which Node lacks. + const originalWindow = globalThis.window; + globalThis.window = new EventTarget(); + layer.init(viewer); + layer.attachShellServices({ runNavigation: (navigate) => navigate() }); + t.after(() => { + layer.destroy(); + if (originalWindow === undefined) delete globalThis.window; + else globalThis.window = originalWindow; + }); + return { layer, viewer, camera, calls, entries, flights }; +} + +const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); + +test('the feed shows the last 24 h at significant and above, beacons when zoomed out', async (t) => { + const { layer, calls, entries } = fixture(t); + layer.enable(); + assert.equal(await layer.update(), true); + await settle(); + assert.equal(calls.hotspots, 1); + // The map read and the panel's feed read both ask for the last day only. + assert.ok(calls.feed.every((query) => query.today === true)); + const view = layer.getRowControls().feed; + assert.deepEqual( + view.items.map((item) => item.id), + ['major', 'far'], + ); + assert.deepEqual( + view.hotspots.map((chip) => [chip.id, chip.label, chip.active]), + [ + ['all', 'All', true], + ['ua-ru-war', 'Ukraine–Russia', false], + ], + ); + const labels = entries.at(-1); + assert.ok(labels.some((entry) => entry.id === 'hotspot:ua-ru-war')); + assert.equal(layer.getStats().error, null); + // Zoomed out, the camera is too high for "In view". + assert.equal(view.inView.active, false); +}); + +test('choosing a card selects it and flies straight down from 150 km; reduced motion jumps', async (t) => { + const { layer, flights, entries } = fixture(t, { reduced: true }); + layer.enable(); + await layer.update(); + layer.setParams({ reportId: 'major', focus: true }); + assert.equal(flights.length, 1); + assert.equal(flights[0].duration, 0); + const at = Cesium.Cartographic.fromCartesian(flights[0].destination); + assert.ok(Math.abs(at.height - 150_000) < 1); + assert.ok(Math.abs(Cesium.Math.toDegrees(at.latitude) - 50) < 1e-6); + const card = entries.at(-1).find((e) => e.id === 'selected-report:major'); + assert.ok(card.details.includes('Source: example.com ↗')); + assert.equal(layer.getRowControls().feed.selected.id, 'major'); + layer.setParams({ clear: true }); + assert.equal(layer.getRowControls().feed.selected, null); +}); + +test('the card toggle and the severity floor change what is shown, not what is read', async (t) => { + const { layer, entries, calls } = fixture(t, { height: 500_000 }); + layer.enable(); + await layer.update(); + const cards = () => + entries.at(-1).filter((entry) => entry.id.startsWith('event-card:')); + // No canvas in Node: cards need one, so none are painted, and nothing throws. + assert.deepEqual(cards(), []); + await settle(); + const reads = calls.feed.length; + layer.setParams({ filters: { cards: false, minSeverity: 0 } }); + assert.equal(calls.feed.length, reads); + assert.equal(layer.getRowControls().feed.filters.cards, false); + assert.deepEqual( + layer.getRowControls().feed.items.map((item) => item.id), + ['major', 'minor', 'far'], + ); +}); + +test('In view reads again only when the camera settles low in a new cell', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const { layer, camera, calls } = fixture(t, { height: 1_000_000 }); + layer.enable(); + await layer.update(); + const reads = () => calls.feed.filter((q) => q.limit === 500).length; + const before = reads(); + camera.moveEnd.raiseEvent(); + t.mock.timers.tick(799); + assert.equal(reads(), before); + t.mock.timers.tick(1); + await Promise.resolve(); + await Promise.resolve(); + const view = layer.getRowControls().feed.inView; + assert.equal(view.active, true); + // Gaza is beyond 500 km of the view centre; the old report is past 24 h. + assert.deepEqual( + view.items.map((item) => item.id), + ['major'], + ); + // Same cell: no new read. + const settled = reads(); + camera.moveEnd.raiseEvent(); + t.mock.timers.tick(800); + assert.equal(reads(), settled); + // Up above 3,000 km, In view goes away. + camera.positionCartographic.height = 5_000_000; + camera.moveEnd.raiseEvent(); + t.mock.timers.tick(800); + assert.equal(layer.getRowControls().feed.inView.active, false); +}); + +test('a server without news is unavailable once and is not asked again', async (t) => { + const failure = Object.assign( + new Error('News feed not configured on this server'), + { + unavailable: true, + }, + ); + let reads = 0; + const { layer } = fixture(t, { + source: { + getHotspots: async () => { + reads++; + throw failure; + }, + }, + }); + layer.enable(); + assert.equal(await layer.update(), true); + assert.equal(await layer.update(), true); + assert.equal(reads, 1); + const stats = layer.getStats(); + assert.equal(stats.sourceUnavailable, true); + assert.equal(stats.status, 'unavailable'); + assert.equal(layer.getRowControls().feed.error, failure.message); + // Turning the layer off and on asks again. + layer.disable(); + layer.enable(); + await layer.update(); + assert.equal(reads, 2); +}); + +test('a failed read keeps the layer on with an error; disable clears the overlay', async (t) => { + const { layer, entries } = fixture(t, { + source: { getFeed: async () => Promise.reject(new Error('boom')) }, + }); + layer.enable(); + assert.equal(await layer.update(), true); + assert.equal(layer.getStats().error, 'boom'); + assert.equal(layer.getStats().sourceUnavailable, undefined); + layer.disable(); + assert.deepEqual(entries.at(-1), []); + assert.deepEqual(layer.getRowControls().feed.items, []); +}); diff --git a/src/navigationPolicy.js b/src/navigationPolicy.js index f69cb54c0..d22d3e50b 100644 --- a/src/navigationPolicy.js +++ b/src/navigationPolicy.js @@ -31,6 +31,8 @@ const COCKPIT_REFUSALS = Object.freeze({ // «View coverage» button); the cyclone layer flies to the selected storm. weather: msg('Exit cockpit to view weather coverage'), cyclone: msg('Exit cockpit to fly to a cyclone'), + // The News panel's cards and the news layer's hotspots fly to the event. + news: msg('Exit cockpit to fly to a news report'), // Voice track_entity (TRACKABLE_FAMILIES kinds in gevActions.js) and // frame_overhead, which frames the contacts around the current view. aircraft: msg('Exit cockpit to fly to an aircraft'), diff --git a/src/navigationPolicy.test.mjs b/src/navigationPolicy.test.mjs index 9826846ca..661ea7918 100644 --- a/src/navigationPolicy.test.mjs +++ b/src/navigationPolicy.test.mjs @@ -136,6 +136,7 @@ test('weather layers name their destination in the Cockpit refusal', () => { const expected = { weather: 'Exit cockpit to view weather coverage', cyclone: 'Exit cockpit to fly to a cyclone', + news: 'Exit cockpit to fly to a news report', }; for (const [noun, text] of Object.entries(expected)) { const s = spy(); diff --git a/src/standalone/layerSources.js b/src/standalone/layerSources.js index 2e0534a3a..d1a273455 100644 --- a/src/standalone/layerSources.js +++ b/src/standalone/layerSources.js @@ -20,6 +20,7 @@ import { createCycloneSource } from '../layers/cyclones/source.js'; import { createWindSource } from '../layers/wind/source.js'; import { createFirmsSource } from '../layers/firms/source.js'; import { createGhostMapsSource } from '../layers/ghostMaps/source.js'; +import { createNewsSource } from '../layers/news/source.js'; import { createMapillarySource } from '../layers/streetLevel/providers/mapillary/source.js'; import { createReferenceSources } from '../sources/reference.js'; export { createReferenceSources as createStandaloneReferenceSources } from '../sources/reference.js'; @@ -60,6 +61,10 @@ export function createStandaloneLayerSources( alpr: createAlprTileSource(), firms: createFirmsSource(), 's2-ghostmaps': createGhostMapsSource(), + // Same-origin /api/news: the local server proxies GEV_NEWS_ORIGIN and, + // without it, answers "not configured", which the layer shows as + // unavailable without retrying. + news: createNewsSource(), wind: createWindSource(), weather: createWeatherSource(), cyclones: createCycloneSource(), diff --git a/src/ui/layerBindings.js b/src/ui/layerBindings.js index 60128f8e0..1ac398c38 100644 --- a/src/ui/layerBindings.js +++ b/src/ui/layerBindings.js @@ -147,6 +147,15 @@ export class LayerBindings { fires.attachShellServices({ imageryHost: this.services.imageryHost }); this._weatherShellModules.push(fires); } + // News flies to a chosen report or hotspot through the same release. + const news = this._dataManager?.layers?.get('news')?.module; + if (typeof news?.attachShellServices === 'function') { + news.attachShellServices({ + runNavigation: (navigate) => + this.runImmediateNavigation('news', navigate), + }); + this._weatherShellModules.push(news); + } } _persistAwarenessSelection(event, cleared = false) { diff --git a/src/ui/layerNames.js b/src/ui/layerNames.js index 694d57066..83f4e7833 100644 --- a/src/ui/layerNames.js +++ b/src/ui/layerNames.js @@ -37,6 +37,7 @@ export const LAYER_NAME_KEYS = Object.freeze({ 'gnss-integrity': msg('GNSS Integrity'), 'drone-airspace': msg('Prohibited & Restricted Airspace'), 's2-ghostmaps': msg('S2 GhostMaps'), + news: msg('News'), wind: msg('Wind'), 'weather-radar': msg('Rain radar'), 'weather-satellite': msg('Satellite clouds'), diff --git a/src/ui/layerPanel.js b/src/ui/layerPanel.js index b24e8be7b..39a840ab2 100644 --- a/src/ui/layerPanel.js +++ b/src/ui/layerPanel.js @@ -11,6 +11,7 @@ import { } from './layerNames.js'; export { localizeLayerName } from './layerNames.js'; import { createWeatherPanel } from './weatherPanel.js'; +import { createNewsPanel } from './newsPanel.js'; import { translateFeedError, translateStatusLine } from './statusText.js'; import { formatNumber, @@ -78,6 +79,7 @@ const PANEL_GROUPS = [ 'fire-perimeters', 'gnss-integrity', 's2-ghostmaps', + 'news', ], }, { @@ -310,6 +312,11 @@ export class LayerPanel { container?.ownerDocument?.getElementById?.('weather-panel-body'), setLayerParams: this.setLayerParams, }); + this._newsPanel?.destroy(); + this._newsPanel = createNewsPanel({ + container: container?.ownerDocument?.getElementById?.('news-panel-body'), + setLayerParams: this.setLayerParams, + }); this._mountRecentImagery(); this._renderToggles(); } @@ -345,6 +352,8 @@ export class LayerPanel { this._releaseBindings(); this._weatherPanel?.destroy(); this._weatherPanel = null; + this._newsPanel?.destroy(); + this._newsPanel = null; this._recentImageryPanel?.destroy(); this._recentImageryPanel = null; this._recentImageryFactory = null; @@ -409,10 +418,12 @@ export class LayerPanel { onLocaleChange(() => { this._paint(); this._refreshWeatherPanel(); + this._refreshNewsPanel(); }), ); this._paint(); this._refreshWeatherPanel(); + this._refreshNewsPanel(); } /** Present every row from the layers' current state and render them. */ @@ -641,6 +652,15 @@ export class LayerPanel { } this._paint(); this._refreshWeatherPanel(); + this._refreshNewsPanel(); + } + + /** Feed the News rail panel the view the news layer publishes while on. */ + _refreshNewsPanel() { + const layer = this.getAll().find(({ id }) => id === 'news'); + this._newsPanel?.update( + layer?.enabled ? this._rowControlsFor(layer.id)?.feed || null : null, + ); } _buildMetaText(layer) { diff --git a/src/ui/news/NewsCard.vue b/src/ui/news/NewsCard.vue new file mode 100644 index 000000000..46dbe5fb7 --- /dev/null +++ b/src/ui/news/NewsCard.vue @@ -0,0 +1,127 @@ + + + diff --git a/src/ui/news/NewsFeed.vue b/src/ui/news/NewsFeed.vue new file mode 100644 index 000000000..5d6fc8bce --- /dev/null +++ b/src/ui/news/NewsFeed.vue @@ -0,0 +1,217 @@ + + + diff --git a/src/ui/newsPanel.js b/src/ui/newsPanel.js new file mode 100644 index 000000000..78fd6ea24 --- /dev/null +++ b/src/ui/newsPanel.js @@ -0,0 +1,73 @@ +import { h, render as renderVue } from 'vue'; +import NewsFeed from './news/NewsFeed.vue'; + +const LAYER_ID = 'news'; +const LOAD_MORE_MARGIN_PX = 160; +// Remounting must not reopen a panel the reader already collapsed. +const appearedPanels = new WeakSet(); +const set = (node, key, value) => { + if (node[key] !== value) node[key] = value; +}; + +/** + * The News rail panel (`#news-panel`): it renders the `feed` view the news + * layer publishes through its row controls, shows itself only while the + * layer is on, and acts only through setLayerParams. Near the bottom of its + * scroller it asks for the next older page, once per page. + */ +export function createNewsPanel({ container, setLayerParams = () => {} } = {}) { + const document = container?.ownerDocument; + if (!document?.createElement) return null; + const panel = container.closest?.('#news-panel'); + const count = panel?.querySelector('#news-panel-count'); + const onParams = (params) => + setLayerParams(LAYER_ID, params, { origin: 'user' }); + let view = null; + let destroyed = false; + let requestedAt = -1; + + function render() { + if (destroyed) return; + renderVue(h(NewsFeed, { view, onParams }), container); + const hidden = !view; + if (panel) set(panel, 'hidden', hidden); + if (count) set(count, 'textContent', String(view?.items.length ?? 0)); + if (!hidden && panel && !appearedPanels.has(panel)) { + appearedPanels.add(panel); + // First appearance opens the panel unless a stored or shared collapse + // choice exists (marked by the panel chrome when it restored the state). + const preference = panel.dataset?.collapsedPreference; + if ( + panel.classList?.contains('collapsed') && + (preference === undefined || preference === 'default') + ) + panel.querySelector('[data-collapse-target="news-panel"]')?.click(); + } + } + + const onScroll = () => { + if (!view?.hasMore || view.loading) return; + const remaining = + container.scrollHeight - container.scrollTop - container.clientHeight; + if (remaining > LOAD_MORE_MARGIN_PX) return; + if (requestedAt === view.items.length) return; + requestedAt = view.items.length; + onParams({ loadMore: true }); + }; + container.addEventListener?.('scroll', onScroll, { passive: true }); + + return { + /** Show the layer's feed view, or hide the panel with null. */ + update(next) { + view = next || null; + render(); + }, + destroy() { + destroyed = true; + container.removeEventListener?.('scroll', onScroll); + renderVue(null, container); + if (panel) set(panel, 'hidden', true); + if (count) set(count, 'textContent', '0'); + }, + }; +} diff --git a/src/ui/newsPanel.test.mjs b/src/ui/newsPanel.test.mjs new file mode 100644 index 000000000..71346fdc3 --- /dev/null +++ b/src/ui/newsPanel.test.mjs @@ -0,0 +1,223 @@ +import '../testSupport/registerDom.mjs'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createNewsPanel } from './newsPanel.js'; +import { + NEWS_CATEGORIES, + NEWS_SEVERITY, + categoryLabel, + feedCardView, + normalizeFilters, +} from '../layers/news/model.js'; + +const NOW = Date.parse('2026-10-28T10:00:00Z'); +const report = (id, extra = {}) => ({ + id, + title: `Report ${id}`, + url: `https://www.example.com/${id}`, + publishedAt: '2026-10-28T09:00:00.000Z', + publisher: 'Example', + side: 'UA', + category: 'strike', + severity: 2, + geo: { lat: 50, lon: 36, place: 'Kharkiv', mappable: true }, + pins: [{ lat: 50, lon: 36, place: 'Kharkiv', mappable: true }], + event: null, + media: [], + ...extra, +}); +const big = report('big', { + severity: 3, + titleEn: 'Strike on Kharkiv', + title: 'Удар по Харкову', + media: ['video'], + event: { + size: 4, + severity: 3, + status: 'both-sides', + lastAt: '2026-10-28T09:50:00.000Z', + sides: [ + { side: 'UA', title: 'a', url: 'https://a.test/', publisher: 'A' }, + { side: 'RU', title: 'b', url: 'https://b.test/', publisher: 'B' }, + ], + image: { url: 'https://a.test/p.jpg', publisher: 'A' }, + }, +}); + +function view(extra = {}) { + return { + filters: normalizeFilters(), + hotspots: [ + { id: 'all', label: 'All', count: 7, active: true }, + { id: 'ua-ru-war', label: 'Ukraine–Russia', count: 7, active: false }, + ], + categories: Object.keys(NEWS_CATEGORIES).map((id) => ({ + value: id, + label: categoryLabel(id), + })), + severities: NEWS_SEVERITY.map((level, index) => ({ + value: index, + label: level.label, + color: level.color, + })), + items: [feedCardView(big, NOW), feedCardView(report('lone'), NOW)], + inView: { active: false, loading: false, error: null, items: [] }, + selected: null, + hasMore: true, + loading: false, + error: null, + ...extra, + }; +} + +function fixture() { + document.body.innerHTML = ` + `; + const panel = document.getElementById('news-panel'); + let opened = 0; + panel + .querySelector('[data-collapse-target]') + .addEventListener('click', () => { + opened++; + panel.classList.remove('collapsed'); + }); + const calls = []; + const news = createNewsPanel({ + container: document.getElementById('news-panel-body'), + setLayerParams: (id, params, options) => calls.push([id, params, options]), + }); + return { news, panel, calls, opened: () => opened }; +} + +test('the panel shows the last 24 h as event cards and opens once', () => { + const { news, panel, opened } = fixture(); + news.update(null); + assert.equal(panel.hidden, true); + news.update(view()); + assert.equal(panel.hidden, false); + assert.equal(opened(), 1); + assert.equal(document.getElementById('news-panel-count').textContent, '2'); + assert.equal( + panel.querySelector('.news-readout__window').textContent, + 'Last 24 h', + ); + assert.deepEqual( + [...panel.querySelectorAll('.data-toggle-chip[data-chip-id]')].map( + (chip) => chip.textContent, + ), + ['All 7', 'Ukraine–Russia 7'], + ); + const [first, second] = panel.querySelectorAll( + '.news-readout__feed .news-card', + ); + assert.equal( + first.querySelector('.news-card__title').textContent, + 'Strike on Kharkiv', + ); + assert.equal( + first.querySelector('.news-card__note').textContent, + 'English headline by the model', + ); + assert.equal( + first.querySelector('.news-card__meta').textContent, + 'Strike · Kharkiv · 4 sources · 10 min', + ); + assert.equal( + first.querySelector('.news-card__marker').getAttribute('aria-label'), + 'Major', + ); + assert.equal( + first.querySelector('.news-card__sides').textContent, + 'Reported by UA sources · RU sources', + ); + assert.deepEqual( + [...first.querySelectorAll('.news-chip')].map((c) => c.textContent), + ['Reported by both sides', 'Video'], + ); + const img = first.querySelector('.news-card__thumb img'); + assert.equal(img.getAttribute('src'), 'https://a.test/p.jpg'); + assert.equal(img.getAttribute('referrerpolicy'), 'no-referrer'); + assert.equal(first.querySelector('.news-card__credit').textContent, 'A'); + // A lone report has no picture. + assert.equal(second.querySelector('.news-card__thumb'), null); + // A later update does not reopen a panel the reader collapsed. + panel.classList.add('collapsed'); + news.update(view()); + assert.equal(opened(), 1); + news.destroy(); + assert.equal(panel.hidden, true); +}); + +test('a card click flies there; open, it links the source and closes', () => { + const { news, panel, calls } = fixture(); + news.update(view()); + panel.querySelector('.news-card__main').click(); + assert.deepEqual(calls.at(-1), [ + 'news', + { reportId: 'big', focus: true }, + { origin: 'user' }, + ]); + news.update(view({ selected: feedCardView(big, NOW) })); + const open = panel.querySelector('.news-card.is-open'); + const link = open.querySelector('.news-card__link'); + assert.equal(link.textContent, 'Source: example.com ↗'); + assert.equal(link.getAttribute('href'), 'https://www.example.com/big'); + assert.equal(link.getAttribute('rel'), 'noopener noreferrer'); + assert.equal( + open.querySelector('.news-card__original').textContent, + 'Удар по Харкову', + ); + open.querySelector('.news-card__main').click(); + assert.deepEqual(calls.at(-1)[1], { clear: true }); + news.destroy(); +}); + +test('filters, In view and empty or failed reads', () => { + const { news, panel, calls } = fixture(); + news.update(view()); + assert.equal(panel.querySelector('[aria-label="In view"]'), null); + const severity = panel.querySelector('select[aria-label="Severity"]'); + assert.equal(severity.value, '2'); + severity.value = '3'; + severity.dispatchEvent(new Event('change')); + assert.deepEqual(calls.at(-1)[1], { filters: { minSeverity: 3 } }); + const [minor, cards] = panel.querySelectorAll('.news-toggle input'); + assert.equal(minor.checked, false); + assert.equal(cards.checked, true); + cards.checked = false; + cards.dispatchEvent(new Event('change')); + assert.deepEqual(calls.at(-1)[1], { filters: { cards: false } }); + panel.querySelector('[data-chip-id="ua-ru-war"]').click(); + assert.deepEqual(calls.at(-1)[1], { filters: { hotspot: 'ua-ru-war' } }); + + news.update( + view({ + inView: { active: true, loading: false, error: null, items: [] }, + items: [], + hasMore: false, + }), + ); + const section = panel.querySelector('[aria-label="In view"]'); + assert.match( + section.textContent, + /Nothing reported near here in the last 24 h/, + ); + assert.match( + panel.textContent, + /No news matches these filters in the last 24 h/, + ); + assert.equal(panel.querySelector('.news-readout__footer button'), null); + + news.update( + view({ items: [], error: 'News feed unavailable', hasMore: false }), + ); + assert.equal( + panel.querySelector('.news-readout__status').textContent, + 'News feed unavailable', + ); + news.destroy(); +}); diff --git a/src/ui/panelChrome.js b/src/ui/panelChrome.js index 439c1d77c..c5da55c43 100644 --- a/src/ui/panelChrome.js +++ b/src/ui/panelChrome.js @@ -17,6 +17,7 @@ const SHARE_PANEL_STATE_SPECS = Object.freeze([ { id: 'data-panel' }, { id: 'cctv-panel' }, { id: 'weather-panel' }, + { id: 'news-panel' }, { id: 'recent-imagery-panel' }, { id: 'street-level-panel' }, { id: 'radio-panel' }, @@ -41,6 +42,7 @@ const COCKPIT_ENTRY_COLLAPSE_PANEL_IDS = Object.freeze([ 'data-panel', 'cctv-panel', 'weather-panel', + 'news-panel', 'recent-imagery-panel', 'street-level-panel', 'scene-panel', diff --git a/src/ui/panelLayoutController.js b/src/ui/panelLayoutController.js index dddedf575..0c525a758 100644 --- a/src/ui/panelLayoutController.js +++ b/src/ui/panelLayoutController.js @@ -95,6 +95,7 @@ export class PanelLayoutController { this._ppToggles = document.getElementById('pp-toggles'); this._cctvPanel = document.getElementById('cctv-panel'); this._weatherPanel = document.getElementById('weather-panel'); + this._newsPanel = document.getElementById('news-panel'); this._recentImageryPanel = document.getElementById('recent-imagery-panel'); this._streetLevelPanel = document.getElementById('street-level-panel'); } @@ -191,6 +192,7 @@ export class PanelLayoutController { for (const panel of [ this._cctvPanel, this._weatherPanel, + this._newsPanel, this._recentImageryPanel, this._streetLevelPanel, ]) { @@ -206,6 +208,7 @@ export class PanelLayoutController { this._ppToggles, this._cctvPanel, this._weatherPanel, + this._newsPanel, this._recentImageryPanel, this._streetLevelPanel, globalContextPanel, diff --git a/src/ui/panelRailCatalog.js b/src/ui/panelRailCatalog.js index 7309c7f21..dbda77dcd 100644 --- a/src/ui/panelRailCatalog.js +++ b/src/ui/panelRailCatalog.js @@ -22,6 +22,7 @@ export const PANEL_RAIL_ITEMS = Object.freeze({ 'pp-toggles': { icon: 'tune', label: msg('DISPLAY') }, 'cctv-panel': { icon: 'videocam', label: msg('CCTV') }, 'weather-panel': { icon: 'partly_cloudy_day', label: msg('WEATHER') }, + 'news-panel': { icon: 'public', label: msg('NEWS') }, 'recent-imagery-panel': { icon: 'satellite_alt', label: msg('RECENT IMAGERY'), diff --git a/src/ui/styles/news.css b/src/ui/styles/news.css new file mode 100644 index 000000000..9b70f2004 --- /dev/null +++ b/src/ui/styles/news.css @@ -0,0 +1,378 @@ +/* News rail panel (#news-panel): the last 24 hours as a doom-scroll of event + cards drawn with GEV's own tokens. Colour is + severity only (the layer sets --news-color per card); never a side. The + rail owns placement and height; only the body scrolls. */ +.news-panel-inner { + position: relative; + display: flex; + flex-direction: column; + gap: 8px; + min-height: 0; + max-height: 100%; + box-sizing: border-box; + padding: 14px; + border: 1px solid var(--glass-border); + border-radius: var(--panel-radius); + background: var(--glass-bg); + backdrop-filter: blur(24px) saturate(1.4); + -webkit-backdrop-filter: blur(24px) saturate(1.4); + box-shadow: + 0 8px 32px rgba(0, 0, 0, 0.5), + 0 0 0 1px rgba(255, 255, 255, 0.03) inset, + 0 1px 0 rgba(255, 255, 255, 0.06) inset; +} +#news-panel .panel-glow { + position: absolute; + inset: -18px; + background: radial-gradient( + ellipse at center, + rgba(0, 212, 255, 0.16) 0%, + transparent 65% + ); + filter: blur(16px); + opacity: 0.25; + pointer-events: none; +} +#news-panel .panel-header { + flex: 0 0 auto; +} +#news-panel-count { + color: var(--text-secondary); + font: 9px var(--font-mono); +} +#news-panel-body { + min-height: 0; + max-height: calc(var(--right-stack-max-height, 50svh) - 64px); + overflow-y: auto; + overscroll-behavior: contain; +} +#news-panel.collapsed #news-panel-body, +#news-panel[hidden] { + display: none !important; +} +#right-context-rail > #news-panel { + position: relative; + inset: auto; + flex: 0 0 auto; + width: 100%; + max-height: 100%; + pointer-events: auto; +} +#right-context-rail > #news-panel:not(.collapsed) { + display: flex; + flex-direction: column; + flex-shrink: 1; + min-height: 0; +} +#right-context-rail.layout-focus > #news-panel:not(.collapsed) { + flex: 0 1 var(--right-panel-allocated-height); + height: var(--right-panel-allocated-height); + min-height: 0; + max-height: var(--right-panel-allocated-height); +} +#right-context-rail.layout-focus + > #news-panel:not(.collapsed) + .news-panel-inner { + height: 100%; +} +#right-context-rail[data-rail-measuring] + > #news-panel:not(.collapsed) + > .news-panel-inner { + height: auto !important; +} +#right-context-rail #news-panel.collapsed { + width: var(--right-display-collapsed-width); + margin-inline-start: auto; +} +#right-context-rail #news-panel.collapsed .news-panel-inner { + height: 50px; + min-height: 50px; + padding-bottom: 10px; +} +@media (max-width: 720px) { + #right-context-rail #news-panel.collapsed { + width: 100%; + } +} +:root[data-ui-theme='cyber'] .news-panel-inner { + border-radius: 0; + clip-path: var(--cyber-panel-frame); + box-shadow: var(--cyber-panel-shadow); +} + +.news-readout { + display: flex; + flex-direction: column; + gap: 10px; + min-width: 0; + color: var(--text-primary); + font: 11px/1.4 var(--font-sans); +} +.news-readout__window, +.news-readout__head { + margin: 0; + color: var(--text-secondary); + font: 600 9px var(--font-mono); + letter-spacing: 0.12em; + text-transform: uppercase; +} +.news-readout__chips { + display: flex; + flex-wrap: wrap; + gap: 4px; +} +.news-readout__filters { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 6px; +} +.news-filter { + min-width: 0; +} +.news-toggle { + display: flex; + align-items: center; + gap: 6px; + color: var(--text-secondary); + font-size: 10px; + cursor: pointer; +} +.news-toggle input { + accent-color: var(--accent); +} +.news-readout__section { + display: flex; + flex-direction: column; + gap: 6px; +} +.news-readout__list { + display: flex; + flex-direction: column; + gap: 6px; + margin: 0; + padding: 0; + list-style: none; +} +.news-readout__list.is-stale { + opacity: 0.5; +} +.news-readout__status, +.news-readout__empty { + color: var(--text-dim); + font: 10px var(--font-mono); +} +.news-readout__footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.news-card { + border: 1px solid var(--glass-border); + border-inline-start: 3px solid var(--news-color, var(--glass-border)); + border-radius: 6px; + background: rgba(255, 255, 255, 0.02); +} +.news-card:hover, +.news-card.is-open { + border-color: var(--glass-border-hover); + border-inline-start-color: var(--news-color); + background: rgba(0, 212, 255, 0.06); +} +.news-card.is-unmapped .news-card__title { + color: var(--text-secondary); +} +.news-card__main { + display: grid; + grid-template-columns: 96px 1fr; + column-gap: 8px; + row-gap: 3px; + width: 100%; + padding: 6px; + border: 0; + background: transparent; + color: inherit; + font: inherit; + text-align: start; + cursor: pointer; +} +.news-card__main > :not(.news-card__thumb) { + grid-column: 1 / -1; +} +.news-card__thumb ~ * { + grid-column: 2 !important; +} +.news-card__thumb { + position: relative; + grid-row: 1 / span 5; + width: 96px; + height: 64px; + overflow: hidden; + border-radius: 4px; + background: var(--bg-dark); +} +.news-card__thumb img { + width: 100%; + height: 100%; + object-fit: cover; +} +.news-card__credit { + position: absolute; + inset-inline-end: 0; + bottom: 0; + max-width: 100%; + overflow: hidden; + padding: 0 4px; + background: rgba(0, 0, 0, 0.6); + color: var(--text-primary); + font-size: 8px; + text-overflow: ellipsis; + white-space: nowrap; +} +.news-card__title { + display: -webkit-box; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + font-size: 12px; + font-weight: 600; + line-height: 1.3; +} +.news-card__note, +.news-card__sides { + color: var(--text-secondary); + font-size: 10px; +} +.news-card__meta { + display: flex; + align-items: center; + gap: 5px; + min-width: 0; + color: var(--text-secondary); + font: 10px var(--font-mono); +} +.news-card__marker { + flex: none; + display: inline-grid; + place-items: center; + width: 14px; + height: 14px; + border: 1px solid rgba(0, 0, 0, 0.8); + border-radius: 50%; + background: var(--news-color); +} +.news-card__marker svg { + width: 9px; + height: 9px; + fill: none; + stroke: var(--bg-dark); + stroke-width: 1.8; + stroke-linecap: round; + stroke-linejoin: round; +} +.news-card__marker.solid svg { + fill: var(--bg-dark); + stroke: none; +} +.news-card__chips { + display: flex; + flex-wrap: wrap; + gap: 4px; +} +.news-chip { + padding: 1px 6px; + border: 1px solid var(--glass-border-hover); + border-radius: 999px; + color: var(--text-secondary); + font: 9px var(--font-mono); +} +.news-chip--official, +.news-chip--confirmed { + border-color: rgba(0, 212, 255, 0.45); + color: var(--text-primary); +} +.news-chip--claimed { + border-style: dashed; +} +.news-card__open { + display: flex; + flex-direction: column; + gap: 6px; + padding: 0 8px 8px; +} +.news-card__original, +.news-card__snippet { + margin: 0; + color: var(--text-primary); + font-size: 11px; +} +.news-card__original { + color: var(--text-secondary); +} +.news-card__others { + display: flex; + flex-direction: column; + gap: 3px; + margin: 0; + padding: 0; + list-style: none; + font-size: 10px; +} +.news-card__others a, +.news-card__link { + color: var(--accent); + text-decoration: none; + overflow-wrap: anywhere; +} +.news-card__others a:hover, +.news-card__link:hover { + text-decoration: underline; +} +.news-card__side { + margin-inline-end: 6px; + color: var(--text-secondary); +} +.news-card__actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + font: 10px var(--font-mono); +} +.news-legend { + color: var(--text-secondary); + font-size: 10px; +} +.news-legend summary { + cursor: pointer; +} +.news-legend p { + margin: 4px 0; +} +.news-legend__severity { + display: flex; + flex-wrap: wrap; + gap: 4px 10px; + margin: 4px 0; + padding: 0; + list-style: none; +} +.news-legend__severity li::before { + content: ''; + display: inline-block; + width: 8px; + height: 8px; + margin-inline-end: 4px; + border-radius: 50%; + background: var(--news-color); +} +@media (prefers-reduced-motion: no-preference) { + .news-card, + .news-readout__list { + transition: + background var(--transition-fast), + border-color var(--transition-fast), + opacity var(--transition-fast); + } +} diff --git a/src/ui/views/ContextView.vue b/src/ui/views/ContextView.vue index c40665fe4..a0236afb2 100644 --- a/src/ui/views/ContextView.vue +++ b/src/ui/views/ContextView.vue @@ -36,6 +36,10 @@ const sceneRuntime = computed(() => scenePanel.value?.runtime ?? '');
+