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;