From f72f79f48b5361fde609b7b006be3fd00f1c0c30 Mon Sep 17 00:00:00 2001 From: Denis Groshev Date: Wed, 4 Nov 2026 23:33:44 +0100 Subject: [PATCH] Split the News panel into Conflicts and Local Conflicts is the hotspots' significant news, on the map whenever the layer is on. Local lists every report within 500 km of the view centre below 3,000 km, and the map shows those instead while it is chosen. The section replaces the "Include minor news" and "Show event cards" toggles: picture cards are always on, and Local is what reads minor news. The Conflicts feed reads the same whichever section is shown. --- changelog.d/1-news-layer.md | 7 +- src/i18n/locales/ar.json | 7 +- src/i18n/locales/de.json | 7 +- src/i18n/locales/es.json | 7 +- src/i18n/locales/fr.json | 7 +- src/i18n/locales/it.json | 7 +- src/i18n/locales/ja.json | 7 +- src/i18n/locales/pt.json | 7 +- src/i18n/locales/ru.json | 7 +- src/i18n/locales/uk.json | 7 +- src/i18n/locales/zh-CN.json | 7 +- src/i18n/locales/zh-Hant.json | 7 +- src/i18n/same-as-english.json | 3 + src/layers/news/index.js | 87 ++++++++++++++-------- src/layers/news/layer.test.mjs | 132 ++++++++++++++++----------------- src/layers/news/model.js | 28 +++++-- src/layers/news/model.test.mjs | 20 +++-- src/ui/news/NewsFeed.vue | 74 +++++++++++------- src/ui/newsPanel.test.mjs | 40 ++++++---- src/ui/styles/news.css | 1 + 20 files changed, 280 insertions(+), 189 deletions(-) diff --git a/changelog.d/1-news-layer.md b/changelog.d/1-news-layer.md index 9fca38575..4e65d9d10 100644 --- a/changelog.d/1-news-layer.md +++ b/changelog.d/1-news-layer.md @@ -2,7 +2,8 @@ 24 hours from curated hotspots (or the fewer hours a server names), narrowed in the panel to the last 12, 6, 3 or 1 hours with the hotspot counts read for that window, 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 + beacons, outlines with disputed areas, picture cards for major events and a + continuous feed whose cards fly the globe to the event. The panel's Local + section lists every report within 500 km of the view below 3,000 km, and the + map shows those instead of the hotspots' news while it is chosen. 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/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index fc186e34c..3311dac2d 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -85,12 +85,13 @@ "News, last {hours} hours": "الأخبار، آخر {hours} ساعة", "Last {hours} h": "آخر {hours} ساعة", "Time window": "الفترة الزمنية", + "Conflicts": "النزاعات", + "Local": "محلي", + "News sections": "أقسام الأخبار", + "Zoom in to see local news": "قرّب الخريطة لرؤية الأخبار المحلية", "Hotspots": "بؤر ساخنة", "Severity": "الخطورة", "Category": "الفئة", - "Include minor news": "تضمين الأخبار الثانوية", - "Show event cards": "إظهار بطاقات الأحداث", - "In view": "في مجال الرؤية", "Nothing reported near here in the last {hours} h": "لم يُبلَّغ عن شيء قريب من هنا في آخر {hours} ساعة", "Latest news": "آخر الأخبار", "No news matches these filters in the last {hours} h": "لا أخبار تطابق هذه المرشحات في آخر {hours} ساعة", diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 6507be847..7f53f1d53 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -73,12 +73,13 @@ "News, last {hours} hours": "Nachrichten der letzten {hours} Stunden", "Last {hours} h": "Letzte {hours} Std.", "Time window": "Zeitraum", + "Conflicts": "Konflikte", + "Local": "Lokal", + "News sections": "Nachrichtenbereiche", + "Zoom in to see local news": "Hineinzoomen, um lokale Nachrichten zu sehen", "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 {hours} h": "In den letzten {hours} h nichts in der Nähe gemeldet", "Latest news": "Neueste Nachrichten", "No news matches these filters in the last {hours} h": "Keine Nachrichten der letzten {hours} h passen zu diesen Filtern", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 421a46e9e..2c92a3bc9 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -76,12 +76,13 @@ "News, last {hours} hours": "Noticias de las últimas {hours} horas", "Last {hours} h": "Últimas {hours} h", "Time window": "Periodo", + "Conflicts": "Conflictos", + "Local": "Local", + "News sections": "Secciones de noticias", + "Zoom in to see local news": "Acerca el mapa para ver noticias locales", "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 {hours} h": "Nada informado cerca de aquí en las últimas {hours} h", "Latest news": "Últimas noticias", "No news matches these filters in the last {hours} h": "Ninguna noticia de las últimas {hours} h coincide con estos filtros", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 479f318bf..ad951d328 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -76,12 +76,13 @@ "News, last {hours} hours": "Actualités des dernières {hours} heures", "Last {hours} h": "Dernières {hours} h", "Time window": "Période", + "Conflicts": "Conflits", + "Local": "Local", + "News sections": "Rubriques d’actualité", + "Zoom in to see local news": "Zoomez pour voir l’actualité locale", "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 {hours} h": "Rien de signalé près d’ici ces dernières {hours} h", "Latest news": "Dernières actualités", "No news matches these filters in the last {hours} h": "Aucune actualité des dernières {hours} h ne correspond à ces filtres", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 05d0cf825..5f5caa805 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -76,12 +76,13 @@ "News, last {hours} hours": "Notizie delle ultime {hours} ore", "Last {hours} h": "Ultime {hours} h", "Time window": "Periodo", + "Conflicts": "Conflitti", + "Local": "Locale", + "News sections": "Sezioni notizie", + "Zoom in to see local news": "Ingrandisci per vedere le notizie locali", "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 {hours} h": "Nulla segnalato qui vicino nelle ultime {hours} h", "Latest news": "Ultime notizie", "No news matches these filters in the last {hours} h": "Nessuna notizia delle ultime {hours} h corrisponde a questi filtri", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 2659d0c93..d73bee402 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -70,12 +70,13 @@ "News, last {hours} hours": "直近{hours}時間のニュース", "Last {hours} h": "直近{hours}時間", "Time window": "期間", + "Conflicts": "紛争", + "Local": "ローカル", + "News sections": "ニュースの区分", + "Zoom in to see local news": "拡大するとローカルニュースが表示されます", "Hotspots": "ホットスポット", "Severity": "深刻度", "Category": "カテゴリ", - "Include minor news": "軽微なニュースも含める", - "Show event cards": "イベントカードを表示", - "In view": "表示範囲内", "Nothing reported near here in the last {hours} h": "直近{hours}時間、この付近の報道はありません", "Latest news": "最新ニュース", "No news matches these filters in the last {hours} h": "直近{hours}時間で条件に合うニュースはありません", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index c1d6619ce..2f9ebcbf9 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -76,12 +76,13 @@ "News, last {hours} hours": "Notícias das últimas {hours} horas", "Last {hours} h": "Últimas {hours} h", "Time window": "Período", + "Conflicts": "Conflitos", + "Local": "Local", + "News sections": "Secções de notícias", + "Zoom in to see local news": "Aproxime para ver notícias locais", "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 {hours} h": "Nada noticiado aqui perto nas últimas {hours} h", "Latest news": "Últimas notícias", "No news matches these filters in the last {hours} h": "Nenhuma notícia das últimas {hours} h corresponde a estes filtros", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index f1312aaeb..8de804c4b 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -79,12 +79,13 @@ "News, last {hours} hours": "Новости за последние {hours} ч", "Last {hours} h": "За {hours} ч", "Time window": "Период", + "Conflicts": "Конфликты", + "Local": "Местные", + "News sections": "Разделы новостей", + "Zoom in to see local news": "Приблизьте карту, чтобы увидеть местные новости", "Hotspots": "Горячие точки", "Severity": "Тяжесть", "Category": "Категория", - "Include minor news": "Показывать второстепенные новости", - "Show event cards": "Показывать карточки событий", - "In view": "В поле зрения", "Nothing reported near here in the last {hours} h": "За последние {hours} ч поблизости ничего не сообщалось", "Latest news": "Последние новости", "No news matches these filters in the last {hours} h": "За последние {hours} ч нет новостей по этим фильтрам", diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index bae636daa..8db82b9f2 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -79,12 +79,13 @@ "News, last {hours} hours": "Новини за останні {hours} год", "Last {hours} h": "За {hours} год", "Time window": "Період", + "Conflicts": "Конфлікти", + "Local": "Місцеві", + "News sections": "Розділи новин", + "Zoom in to see local news": "Наблизьте мапу, щоб побачити місцеві новини", "Hotspots": "Гарячі точки", "Severity": "Тяжкість", "Category": "Категорія", - "Include minor news": "Показувати другорядні новини", - "Show event cards": "Показувати картки подій", - "In view": "У полі зору", "Nothing reported near here in the last {hours} h": "За останні {hours} год поблизу нічого не повідомлялося", "Latest news": "Останні новини", "No news matches these filters in the last {hours} h": "За останні {hours} год немає новин за цими фільтрами", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index e302067af..e4b6c0920 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -70,12 +70,13 @@ "News, last {hours} hours": "最近 {hours} 小时的新闻", "Last {hours} h": "最近 {hours} 小时", "Time window": "时间范围", + "Conflicts": "冲突", + "Local": "本地", + "News sections": "新闻分区", + "Zoom in to see local news": "放大地图以查看本地新闻", "Hotspots": "热点", "Severity": "严重程度", "Category": "类别", - "Include minor news": "包括次要新闻", - "Show event cards": "显示事件卡片", - "In view": "视野内", "Nothing reported near here in the last {hours} h": "最近 {hours} 小时附近无报道", "Latest news": "最新新闻", "No news matches these filters in the last {hours} h": "最近 {hours} 小时没有符合这些筛选条件的新闻", diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index 4af53af9c..976688ef9 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -70,12 +70,13 @@ "News, last {hours} hours": "最近 {hours} 小時的新聞", "Last {hours} h": "最近 {hours} 小時", "Time window": "時間範圍", + "Conflicts": "衝突", + "Local": "本地", + "News sections": "新聞分區", + "Zoom in to see local news": "放大地圖以查看本地新聞", "Hotspots": "熱點", "Severity": "嚴重程度", "Category": "類別", - "Include minor news": "包括次要新聞", - "Show event cards": "顯示事件卡片", - "In view": "視野內", "Nothing reported near here in the last {hours} h": "最近 {hours} 小時附近無報導", "Latest news": "最新新聞", "No news matches these filters in the last {hours} h": "最近 {hours} 小時沒有符合這些篩選條件的新聞", diff --git a/src/i18n/same-as-english.json b/src/i18n/same-as-english.json index 9cc9a667d..36ce019d7 100644 --- a/src/i18n/same-as-english.json +++ b/src/i18n/same-as-english.json @@ -262,6 +262,7 @@ "Jazz", "LOCAL", "Landsat 8/9", + "Local", "MIC", "MIL", "MINI", @@ -421,6 +422,7 @@ "LOCAL", "Landsat 8/9", "Landsat 8/9 via HLS", + "Local", "Los Angeles", "MIC", "MIL", @@ -822,6 +824,7 @@ "Landsat 8/9", "Landsat 8/9 via HLS", "Layout", + "Local", "Los Angeles", "MIC", "MIL", diff --git a/src/layers/news/index.js b/src/layers/news/index.js index 40fd902d9..3223d0e45 100644 --- a/src/layers/news/index.js +++ b/src/layers/news/index.js @@ -22,6 +22,7 @@ import { NEWS_FEED_PAGE, NEWS_FOCUS_HEIGHT_M, NEWS_IN_VIEW_MAX_HEIGHT_M, + NEWS_LOCAL_LIMIT, NEWS_LAYER_ID, NEWS_MAP_LIMIT, NEWS_OVERLAY_SOURCE_ID, @@ -47,6 +48,7 @@ import { mergeFeedPage, newsWindowChoices, newsWindowMs, + localFilters, normalizeFilters, passesFilters, reportMarkerPosition, @@ -85,8 +87,9 @@ const emptyFeed = () => ({ loading: false, error: null, }); +// Local: `active` while the camera is low enough to have a "near here"; // `minor` holds the day's reports with minor news included, read once while -// that filter is on; otherwise In view uses the map's own reports. +// the Local section is chosen. const emptyInView = () => ({ active: false, cell: null, @@ -167,7 +170,26 @@ export function createNewsLayer({ inView.minor?.find((r) => r.id === id) || null; const selected = () => (selectedId ? findReport(selectedId) : null); - const onMap = (report) => visibleOnMap(report, filters, now(), windowMs); + const local = () => filters.section === 'local'; + // Local takes the map over only while there is a "near here" to show. + const localMode = () => local() && inView.active; + const onMap = (report) => + visibleOnMap( + report, + localMode() ? localFilters(filters) : filters, + now(), + windowMs, + ); + // The Local section's reports near the view centre, every severity. + const localReports = (limit) => + reportsInView( + inView.minor || [], + inView.centre, + localFilters(filters), + now(), + limit, + windowMs, + ); const shownWindowMs = () => Math.min(maxWindowMs, (filters.hours ?? Infinity) * 3_600_000); // The hours to ask the hotspot counts for, when shorter than the server's. @@ -450,17 +472,14 @@ export function createNewsLayer({ } } - // 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. + // The big events stand as picture cards over their marker, never two on + // top of each other. The clicked event shows its picture card 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 cardable = cardEvents( + drawn.filter((r) => r.id !== selectedId && onMap(r)), + now(), + ); const pictured = current?.pins.length && current.event?.size >= NEWS_CARD_MIN_SOURCES && @@ -582,9 +601,12 @@ export function createNewsLayer({ // 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)); + // Local: what is near the view centre instead of the hotspots' news. + const shown = localMode() + ? localReports(NEWS_MAP_LIMIT) + : detail + ? mapReports + : mapReports.filter((r) => elsewhere.has(r.hotspotId)); const drawn = current && !shown.some((r) => r.id === current.id) ? [...shown, current] @@ -888,6 +910,7 @@ export function createNewsLayer({ inViewRequest?.abort(); inViewRequest = null; inView = emptyInView(); + if (local()) render(); notify(); } return; @@ -897,7 +920,7 @@ export function createNewsLayer({ const cell = viewCell(centre); if (!force && inView.active && cell === inView.cell) return; inView = { ...inView, active: true, cell, centre }; - if (!filters.includeMinor) { + if (!local()) { inViewRequest?.abort(); inViewRequest = null; inView = { ...inView, minor: null, loading: false, error: null }; @@ -905,6 +928,7 @@ export function createNewsLayer({ return; } if (!force && (inView.minor || inViewRequest)) { + render(); notify(); return; } @@ -942,7 +966,10 @@ export function createNewsLayer({ error: cause?.message || FEED_UNAVAILABLE, }; } finally { - if (inViewRequest === controller) inViewRequest = null; + if (inViewRequest === controller) { + inViewRequest = null; + render(); + } notify(); } } @@ -1036,20 +1063,14 @@ export function createNewsLayer({ active: inView.active, loading: inView.loading, error: inView.error ? t(FEED_UNAVAILABLE) : null, - items: reportsInView( - filters.includeMinor ? inView.minor || [] : mapReports, - inView.centre, - filters, - at, - undefined, - windowMs, - ).map((r) => feedCardView(r, at)), + items: localReports(NEWS_LOCAL_LIMIT).map((r) => feedCardView(r, at)), }, selected: report && withinNewsWindow(report, at, windowMs) ? feedCardView(report, at) : null, - hasMore: Boolean(feed.nextBefore), + // Older pages are the Conflicts feed's; Local has none to scroll to. + hasMore: !local() && 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, @@ -1211,11 +1232,11 @@ export function createNewsLayer({ } 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}`; + // The severity floor and the section change what is shown, not what + // the Conflicts feed reads. + const readKey = ({ hotspot, category }) => `${hotspot}|${category}`; const reread = readKey(next) !== readKey(filters); - const minorChanged = next.includeMinor !== filters.includeMinor; + const sectionChanged = next.section !== filters.section; filters = next; const previousWindowMs = windowMs; windowMs = shownWindowMs(); @@ -1230,7 +1251,7 @@ export function createNewsLayer({ if (reread) feed = emptyFeed(); render(); if (reread) void loadFeed('replace'); - if (minorChanged) void refreshInView({ force: true }); + if (sectionChanged) void refreshInView({ force: true }); // The hotspot counts are the server's, so a new window reads them again. if (windowMs !== previousWindowMs) void layer.update(viewer); notify(); @@ -1266,7 +1287,9 @@ export function createNewsLayer({ }, getStats() { return { - count: mapReports.filter((r) => r.pins.length && onMap(r)).length, + count: (localMode() ? localReports(NEWS_MAP_LIMIT) : mapReports).filter( + (r) => r.pins.length && onMap(r), + ).length, lastUpdate, loading, error, diff --git a/src/layers/news/layer.test.mjs b/src/layers/news/layer.test.mjs index dd567f448..782a7f3d4 100644 --- a/src/layers/news/layer.test.mjs +++ b/src/layers/news/layer.test.mjs @@ -96,7 +96,13 @@ function fixture( const viewer = { camera, scene: { requestRender() {}, preRender: new Cesium.Event() }, - dataSources: { add() {}, remove() {} }, + added: [], + dataSources: { + add(source) { + viewer.added.push(source); + }, + remove() {}, + }, }; const layer = createNewsLayer({ source, @@ -164,7 +170,7 @@ test('choosing a card selects it and flies straight down from 150 km; reduced mo 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) => { +test('the severity floor changes what is shown, not what is read', async (t) => { const { layer, entries, calls } = fixture(t, { height: 500_000 }); layer.enable(); await layer.update(); @@ -174,105 +180,97 @@ test('the card toggle and the severity floor change what is shown, not what is r assert.deepEqual(cards(), []); await settle(); const reads = calls.feed.length; - layer.setParams({ filters: { cards: false, minSeverity: 0 } }); + layer.setParams({ filters: { 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 places the held reports when the camera settles low, with no new read', async (t) => { - t.mock.timers.enable({ apis: ['setTimeout'] }); - let centre = { lat: 49, lon: 36 }; - const { layer, camera, calls } = fixture(t, { - height: 1_000_000, - centre: () => centre, - }); - layer.enable(); - await layer.update(); - const reads = calls.feed.length; - const view = layer.getRowControls().feed.inView; - assert.equal(view.active, true); - assert.equal(view.loading, false); - // 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'], - ); - // Panning to another cell (over Gaza) reads nothing: it reuses the map's - // reports, once the camera has settled. - centre = { lat: 31.5, lon: 34.4 }; - camera.moveEnd.raiseEvent(); - t.mock.timers.tick(799); - assert.deepEqual( - layer.getRowControls().feed.inView.items.map((item) => item.id), - ['major'], - ); - t.mock.timers.tick(1); - await Promise.resolve(); - assert.deepEqual( - layer.getRowControls().feed.inView.items.map((item) => item.id), - ['far'], - ); - assert.equal(calls.feed.length, reads); - // 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('In view with minor news reads once, not per pan; a failed read shows', async (t) => { +test('Local reads every report once, lists those near the view and takes the map over', async (t) => { t.mock.timers.enable({ apis: ['setTimeout'] }); let centre = { lat: 49, lon: 36 }; let fail = false; - const minorReads = []; - const { layer, camera } = fixture(t, { + const localReads = []; + const { layer, camera, viewer } = fixture(t, { height: 1_000_000, centre: () => centre, source: { getFeed: async (query) => { if (query.relevance === '' && query.limit === 500) { - minorReads.push(query); + localReads.push(query); if (fail) throw new Error('boom'); return { - reports: [report('broad', { severity: 2, relevance: 'minor' })], + reports: [ + report('broad', { severity: 1, relevance: 'minor' }), + report('south', { + severity: 2, + pins: [{ lat: 31.5, lon: 34.4, place: 'Gaza', mappable: true }], + }), + ], nextBefore: null, }; } - return { reports: [], nextBefore: null }; + return { + reports: [report('major', { severity: 3 })], + nextBefore: null, + }; }, }, }); + const markers = () => + // The layer adds its outlines' source first, then its markers'. + viewer.added[1].entities.values + .map((entity) => String(entity.id)) + .filter((id) => /~\d+$/.test(id)) + .map((id) => id.replace(/^.*:/, '').replace(/~\d+$/, '')); layer.enable(); await layer.update(); - camera.moveEnd.raiseEvent(); - t.mock.timers.tick(800); - await Promise.resolve(); - assert.equal(minorReads.length, 0); - layer.setParams({ filters: { includeMinor: true } }); for (let i = 0; i < 4; i++) await Promise.resolve(); - assert.equal(minorReads.length, 1); + // Conflicts: no read of minor news, the hotspots' news on the map. + assert.equal(localReads.length, 0); + assert.equal(layer.getRowControls().feed.inView.active, true); + assert.deepEqual(markers(), ['major']); + layer.setParams({ filters: { section: 'local' } }); + for (let i = 0; i < 4; i++) await Promise.resolve(); + assert.equal(localReads.length, 1); + let view = layer.getRowControls().feed; + // Every severity near the centre; Gaza is beyond 500 km; no older pages. assert.deepEqual( - layer.getRowControls().feed.inView.items.map((item) => item.id), + view.inView.items.map((item) => item.id), ['broad'], ); - centre = { lat: 47, lon: 33 }; + assert.equal(view.hasMore, false); + assert.deepEqual(markers(), ['broad']); + // A pan reads nothing more and follows the camera once it settles. + centre = { lat: 31.5, lon: 34.4 }; camera.moveEnd.raiseEvent(); t.mock.timers.tick(800); await Promise.resolve(); - assert.equal(minorReads.length, 1); - // Turned off and on again with the server failing: the error is shown. - layer.setParams({ filters: { includeMinor: false } }); + assert.equal(localReads.length, 1); + assert.deepEqual( + layer.getRowControls().feed.inView.items.map((item) => item.id), + ['south'], + ); + assert.deepEqual(markers(), ['south']); + // Up above 3,000 km there is no "near here": the map shows conflicts again. + camera.positionCartographic.height = 5_000_000; + camera.moveEnd.raiseEvent(); + t.mock.timers.tick(800); + view = layer.getRowControls().feed; + assert.equal(view.inView.active, false); + assert.ok(!markers().includes('south')); + // Back to Conflicts and to Local again with the server failing: it shows. + camera.positionCartographic.height = 1_000_000; + layer.setParams({ filters: { section: 'conflicts' } }); fail = true; - layer.setParams({ filters: { includeMinor: true } }); + layer.setParams({ filters: { section: 'local' } }); for (let i = 0; i < 4; i++) await Promise.resolve(); - assert.equal(minorReads.length, 2); - const view = layer.getRowControls().feed.inView; - assert.equal(view.error, 'News feed unavailable'); - assert.deepEqual(view.items, []); + assert.equal(localReads.length, 2); + view = layer.getRowControls().feed; + assert.equal(view.inView.error, 'News feed unavailable'); + assert.deepEqual(view.inView.items, []); }); test('a shorter window narrows the feed and reads the hotspot counts for it', async (t) => { diff --git a/src/layers/news/model.js b/src/layers/news/model.js index d3c4dc219..64e39e8ff 100644 --- a/src/layers/news/model.js +++ b/src/layers/news/model.js @@ -46,6 +46,14 @@ export const NEWS_DETAIL_MAX_HEIGHT_M = 2_500_000; export const NEWS_IN_VIEW_MAX_HEIGHT_M = 3_000_000; export const NEWS_IN_VIEW_RADIUS_KM = 500; export const NEWS_IN_VIEW_LIMIT = 12; +/** The Local section lists at most this many reports near the view centre. */ +export const NEWS_LOCAL_LIMIT = 40; +/** + * The News panel's sections: Conflicts (the hotspots' significant news, on the + * map whenever the layer is on) and Local (everything near the view centre, + * on the map while the section is chosen). + */ +export const NEWS_SECTIONS = Object.freeze(['conflicts', 'local']); export const NEWS_SETTLE_MS = 800; export const NEWS_CELL_DEG = 0.5; /** A feed card's click flies the camera to this height over the event. */ @@ -625,9 +633,9 @@ export function normalizeFilters(next = {}, hotspots = [], previous = {}) { hotspot, category, minSeverity, - includeMinor: merged.includeMinor === true, - // Event cards on the map; off leaves only the clicked event's card. - cards: merged.cards !== false, + section: NEWS_SECTIONS.includes(merged.section) + ? merged.section + : 'conflicts', // A shorter window than the server's; null shows all it serves. hours: NEWS_WINDOW_CHOICES.includes(merged.hours) ? merged.hours : null, }; @@ -661,12 +669,22 @@ export function visibleOnMap( ); } -/** API query for the feed under the current filters: the window only. */ +/** + * The filters Local applies: every hotspot and every severity, near the view + * centre; the category and the window still hold. + */ +export const localFilters = (filters) => ({ + ...filters, + hotspot: 'all', + minSeverity: 0, +}); + +/** API query for the Conflicts feed under the current filters: the window only. */ export function feedQueryFor(filters, extra = {}) { return { events: true, today: true, - relevance: filters.includeMinor ? '' : NEWS_RELEVANT, + relevance: NEWS_RELEVANT, hotspot: filters.hotspot, category: filters.category === 'all' ? '' : filters.category, limit: NEWS_FEED_PAGE, diff --git a/src/layers/news/model.test.mjs b/src/layers/news/model.test.mjs index dffeb0793..a0ba16b0e 100644 --- a/src/layers/news/model.test.mjs +++ b/src/layers/news/model.test.mjs @@ -286,11 +286,17 @@ test('filters default to significant news and map to a last-24-hours query', () hotspot: 'ua', category: 'all', minSeverity: 2, - includeMinor: false, - cards: true, + section: 'conflicts', hours: null, }); - assert.equal(normalizeFilters({ cards: false }, hotspots).cards, false); + assert.equal( + normalizeFilters({ section: 'local' }, hotspots).section, + 'local', + ); + assert.equal( + normalizeFilters({ section: 'nope' }, hotspots).section, + 'conflicts', + ); assert.equal(normalizeFilters({ hours: 6 }, hotspots).hours, 6); assert.equal(normalizeFilters({ hours: 5 }, hotspots).hours, null); assert.equal(normalizeFilters({ minSeverity: 0 }, hotspots).minSeverity, 0); @@ -301,10 +307,10 @@ test('filters default to significant news and map to a last-24-hours query', () ), 'hotspot=ua&before=2026-10-28T08%3A00%3A00.000Z&limit=60&relevance=core%2Crelated&today=1&events=1', ); - // "Include minor news" drops the relevance filter. - assert.doesNotMatch( - newsFeedQuery(feedQueryFor({ ...filters, includeMinor: true })), - /relevance/, + // The Conflicts feed reads the same whichever section is chosen. + assert.equal( + newsFeedQuery(feedQueryFor({ ...filters, section: 'local' })), + newsFeedQuery(feedQueryFor(filters)), ); }); diff --git a/src/ui/news/NewsFeed.vue b/src/ui/news/NewsFeed.vue index 23cdffaed..86e616ddc 100644 --- a/src/ui/news/NewsFeed.vue +++ b/src/ui/news/NewsFeed.vue @@ -9,9 +9,10 @@ import NewsCard from './NewsCard.vue'; // The News panel: the window the server serves only (the last 24 hours, or // fewer where the host serves less), or a shorter one chosen at the -// top (24/12/6/3/1 h). Hotspot chips, filters, the chosen -// event, "In view" (reports near the view centre while the camera is low) -// and a continuous feed, newest first. It renders the `feed` view the news +// top (24/12/6/3/1 h), and two sections. Conflicts: hotspot chips, the +// severity floor, the chosen event and a continuous feed, newest first. +// Local: every report near the view centre while the camera is low, which the +// map then shows instead of the hotspots' news. It renders the `feed` view the news // layer publishes and acts only through `onParams`. const props = defineProps({ view: { type: Object, default: null }, @@ -19,6 +20,16 @@ const props = defineProps({ }); const filters = computed(() => props.view?.filters ?? {}); +const local = computed(() => filters.value.section === 'local'); +const sections = computed(() => + [ + { id: 'conflicts', label: t('Conflicts') }, + { id: 'local', label: t('Local') }, + ].map((chip) => ({ + ...chip, + active: (filters.value.section ?? 'conflicts') === chip.id, + })), +); const chips = computed(() => (props.view?.hotspots ?? []).map((chip) => ({ id: chip.id, @@ -59,10 +70,9 @@ const selectedId = computed(() => props.view?.selected?.id ?? null); const selectedApart = computed(() => { const chosen = props.view?.selected; if (!chosen) return null; - const listed = - props.view.items.some((item) => item.id === chosen.id) || - (props.view.inView.active && - props.view.inView.items.some((item) => item.id === chosen.id)); + const listed = local.value + ? props.view.inView.items.some((item) => item.id === chosen.id) + : props.view.items.some((item) => item.id === chosen.id); return listed ? null : chosen; }); const status = computed(() => { @@ -99,7 +109,22 @@ const clear = () => props.onParams({ clear: true });

{{ t('Last {hours} h', { hours: view.windowHours }) }}

-
+
+ +
+
props.onParams({ clear: true });
props.onParams({ clear: true }); :options="categoryOptions" @update:model-value="setFilter('category', $event)" /> - -
props.onParams({ clear: true }); />
-

{{ t('In view') }}

-

+ +

{{ view.inView.error }}

props.onParams({ clear: true });
-
+

{{ t('Latest') }}

    { 'Last 24 h', ); assert.deepEqual( - [...panel.querySelectorAll('.data-toggle-chip[data-chip-id]')].map( + [...panel.querySelectorAll('.news-readout__chips [data-chip-id]')].map( (chip) => chip.textContent, ), ['All 7', 'Ukraine–Russia 7'], @@ -189,42 +189,52 @@ test('a card click flies there; open, it links the source and closes', () => { news.destroy(); }); -test('filters, In view and empty or failed reads', () => { +test('filters, the Local section and empty or failed reads', () => { const { news, panel, calls } = fixture(); news.update(view()); - assert.equal(panel.querySelector('[aria-label="In view"]'), null); + assert.equal(panel.querySelector('[aria-label="Local"]'), null); + assert.equal(panel.querySelector('.news-toggle'), 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' } }); + panel.querySelector('[data-chip-id="local"]').click(); + assert.deepEqual(calls.at(-1)[1], { filters: { section: 'local' } }); 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); + // Local: no hotspot chips or severity floor, and its own empty states. + const localView = (inView) => + view({ + filters: normalizeFilters({ section: 'local' }), + inView: { loading: false, error: null, items: [], ...inView }, + }); + news.update(localView({ active: false })); + const section = panel.querySelector('[aria-label="Local"]'); + assert.match(section.textContent, /Zoom in to see local news/); + assert.equal(panel.querySelector('[data-chip-id="ua-ru-war"]'), null); + assert.equal(panel.querySelector('select[aria-label="Severity"]'), null); + assert.equal(panel.querySelector('[aria-label="Latest news"]'), null); + news.update(localView({ active: true })); + assert.match( + panel.querySelector('[aria-label="Local"]').textContent, + /Nothing reported near here in the last 24 h/, + ); + news.update(view()); + news.update( view({ items: [], error: 'News feed unavailable', hasMore: false }), ); diff --git a/src/ui/styles/news.css b/src/ui/styles/news.css index 2baab0245..605ac2fcf 100644 --- a/src/ui/styles/news.css +++ b/src/ui/styles/news.css @@ -116,6 +116,7 @@ letter-spacing: 0.12em; text-transform: uppercase; } +.news-readout__sections, .news-readout__chips { display: flex; flex-wrap: wrap;