diff --git a/changelog.d/1-news-layer.md b/changelog.d/1-news-layer.md index 8915beafa..9fca38575 100644 --- a/changelog.d/1-news-layer.md +++ b/changelog.d/1-news-layer.md @@ -1,6 +1,7 @@ - Add the News layer (Events ▸ News) and its right-rail NEWS panel: the last - 24 hours from curated hotspots (or the fewer hours a server names, shown in - the panel), coloured by severity only, with hotspot + 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 diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index a03ffc9b2..fc186e34c 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -84,6 +84,7 @@ "Reading the news…": "جارٍ قراءة الأخبار…", "News, last {hours} hours": "الأخبار، آخر {hours} ساعة", "Last {hours} h": "آخر {hours} ساعة", + "Time window": "الفترة الزمنية", "Hotspots": "بؤر ساخنة", "Severity": "الخطورة", "Category": "الفئة", diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index e773191b7..6507be847 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -72,6 +72,7 @@ "Reading the news…": "Nachrichten werden gelesen…", "News, last {hours} hours": "Nachrichten der letzten {hours} Stunden", "Last {hours} h": "Letzte {hours} Std.", + "Time window": "Zeitraum", "Hotspots": "Brennpunkte", "Severity": "Schweregrad", "Category": "Kategorie", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index f0cbb5cbd..421a46e9e 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -75,6 +75,7 @@ "Reading the news…": "Leyendo las noticias…", "News, last {hours} hours": "Noticias de las últimas {hours} horas", "Last {hours} h": "Últimas {hours} h", + "Time window": "Periodo", "Hotspots": "Focos", "Severity": "Gravedad", "Category": "Categoría", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 60329833c..479f318bf 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -75,6 +75,7 @@ "Reading the news…": "Lecture des actualités…", "News, last {hours} hours": "Actualités des dernières {hours} heures", "Last {hours} h": "Dernières {hours} h", + "Time window": "Période", "Hotspots": "Foyers", "Severity": "Gravité", "Category": "Catégorie", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 92a597e40..05d0cf825 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -75,6 +75,7 @@ "Reading the news…": "Lettura delle notizie…", "News, last {hours} hours": "Notizie delle ultime {hours} ore", "Last {hours} h": "Ultime {hours} h", + "Time window": "Periodo", "Hotspots": "Focolai", "Severity": "Gravità", "Category": "Categoria", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 67a102e67..2659d0c93 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -69,6 +69,7 @@ "Reading the news…": "ニュースを読み込み中…", "News, last {hours} hours": "直近{hours}時間のニュース", "Last {hours} h": "直近{hours}時間", + "Time window": "期間", "Hotspots": "ホットスポット", "Severity": "深刻度", "Category": "カテゴリ", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index d66890fa9..c1d6619ce 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -75,6 +75,7 @@ "Reading the news…": "Lendo as notícias…", "News, last {hours} hours": "Notícias das últimas {hours} horas", "Last {hours} h": "Últimas {hours} h", + "Time window": "Período", "Hotspots": "Focos", "Severity": "Gravidade", "Category": "Categoria", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 74a39f5be..f1312aaeb 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -78,6 +78,7 @@ "Reading the news…": "Читаем новости…", "News, last {hours} hours": "Новости за последние {hours} ч", "Last {hours} h": "За {hours} ч", + "Time window": "Период", "Hotspots": "Горячие точки", "Severity": "Тяжесть", "Category": "Категория", diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index 7125396c0..bae636daa 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -78,6 +78,7 @@ "Reading the news…": "Читаємо новини…", "News, last {hours} hours": "Новини за останні {hours} год", "Last {hours} h": "За {hours} год", + "Time window": "Період", "Hotspots": "Гарячі точки", "Severity": "Тяжкість", "Category": "Категорія", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 654189a6f..e302067af 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -69,6 +69,7 @@ "Reading the news…": "正在读取新闻…", "News, last {hours} hours": "最近 {hours} 小时的新闻", "Last {hours} h": "最近 {hours} 小时", + "Time window": "时间范围", "Hotspots": "热点", "Severity": "严重程度", "Category": "类别", diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index f18e75d07..4af53af9c 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -69,6 +69,7 @@ "Reading the news…": "正在讀取新聞…", "News, last {hours} hours": "最近 {hours} 小時的新聞", "Last {hours} h": "最近 {hours} 小時", + "Time window": "時間範圍", "Hotspots": "熱點", "Severity": "嚴重程度", "Category": "類別", diff --git a/src/layers/news/index.js b/src/layers/news/index.js index 21450bd55..40fd902d9 100644 --- a/src/layers/news/index.js +++ b/src/layers/news/index.js @@ -45,6 +45,7 @@ import { markerStyle, mediaLabels, mergeFeedPage, + newsWindowChoices, newsWindowMs, normalizeFilters, passesFilters, @@ -142,7 +143,9 @@ export function createNewsLayer({ autoPages = 0; let hotspots = []; // The window the server serves (newsWindowMs): a host may serve - // fewer hours than the day. + // fewer hours than the day. `windowMs` is the one shown: that window, or a + // shorter one chosen in the panel (filters.hours). + let maxWindowMs = NEWS_WINDOW_MS; let windowMs = NEWS_WINDOW_MS; let mapReports = []; let feed = emptyFeed(); @@ -165,6 +168,11 @@ export function createNewsLayer({ null; const selected = () => (selectedId ? findReport(selectedId) : null); const onMap = (report) => visibleOnMap(report, filters, now(), 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. + const askedHours = () => + windowMs < maxWindowMs ? windowMs / 3_600_000 : undefined; function markerCartesian(report, pin = 0) { const { lat, lon } = reportMarkerPosition(report, pin); @@ -920,7 +928,9 @@ export function createNewsLayer({ if (controller.signal.aborted || inViewRequest !== controller) return; inView = { ...inView, - minor: page.reports.filter((r) => withinNewsWindow(r, now(), windowMs)), + minor: page.reports.filter((r) => + withinNewsWindow(r, now(), maxWindowMs), + ), loading: false, error: null, }; @@ -1045,6 +1055,7 @@ export function createNewsLayer({ error: error || feed.error ? t(error || feed.error) : null, lastRunAt, windowHours: windowMs / 3_600_000, + windowChoices: newsWindowChoices(maxWindowMs / 3_600_000), }; } @@ -1139,7 +1150,10 @@ export function createNewsLayer({ notify(); try { const [index, todays] = await Promise.all([ - source.getHotspots({ signal: controller.signal }), + source.getHotspots({ + signal: controller.signal, + hours: askedHours(), + }), source.getFeed( { hotspot: 'all', @@ -1157,9 +1171,16 @@ export function createNewsLayer({ hotspots = index.hotspots; loadOutlines(); lastRunAt = index.lastRunAt; - windowMs = newsWindowMs(index.windowHours); + // A server that names no limit serves the window its counts cover + // when asked for none. + maxWindowMs = newsWindowMs( + index.maxWindowHours ?? + (askedHours() ? maxWindowMs / 3_600_000 : index.windowHours), + ); + windowMs = shownWindowMs(); + // Kept for the server's whole window, so a longer choice needs no read. mapReports = todays.reports.filter((r) => - withinNewsWindow(r, now(), windowMs), + withinNewsWindow(r, now(), maxWindowMs), ); filters = normalizeFilters({}, hotspots, filters); error = null; @@ -1196,6 +1217,8 @@ export function createNewsLayer({ const reread = readKey(next) !== readKey(filters); const minorChanged = next.includeMinor !== filters.includeMinor; filters = next; + const previousWindowMs = windowMs; + windowMs = shownWindowMs(); if ( Object.hasOwn(params.filters, 'hotspot') && next.hotspot !== 'all' @@ -1208,6 +1231,8 @@ export function createNewsLayer({ render(); if (reread) void loadFeed('replace'); if (minorChanged) 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(); } if (params.loadMore === true) void loadFeed('append'); diff --git a/src/layers/news/layer.test.mjs b/src/layers/news/layer.test.mjs index 92ebef968..dd567f448 100644 --- a/src/layers/news/layer.test.mjs +++ b/src/layers/news/layer.test.mjs @@ -275,6 +275,53 @@ test('In view with minor news reads once, not per pan; a failed read shows', asy assert.deepEqual(view.items, []); }); +test('a shorter window narrows the feed and reads the hotspot counts for it', async (t) => { + const asked = []; + const { layer } = fixture(t, { + source: { + getHotspots: async ({ hours } = {}) => { + asked.push(hours); + return { + ...hotspotsPayload, + windowHours: hours ?? 12, + maxWindowHours: 12, + }; + }, + getFeed: async () => ({ + reports: [ + report('recent', { publishedAt: '2026-10-28T09:30:00.000Z' }), + report('earlier', { publishedAt: '2026-10-28T05:00:00.000Z' }), + ], + nextBefore: null, + }), + }, + }); + layer.enable(); + await layer.update(); + await settle(); + let view = layer.getRowControls().feed; + assert.deepEqual(view.windowChoices, [12, 6, 3, 1]); + assert.equal(view.windowHours, 12); + assert.deepEqual( + view.items.map((item) => item.id), + ['recent', 'earlier'], + ); + layer.setParams({ filters: { hours: 3 } }); + view = layer.getRowControls().feed; + assert.equal(view.windowHours, 3); + assert.deepEqual( + view.items.map((item) => item.id), + ['recent'], + ); + for (let i = 0; i < 3; i++) await settle(); + assert.deepEqual(asked, [undefined, 3]); + // The server's own window still bounds the choices. + assert.deepEqual(layer.getRowControls().feed.windowChoices, [12, 6, 3, 1]); + // A choice longer than the server serves shows all it serves. + layer.setParams({ filters: { hours: 24 } }); + assert.equal(layer.getRowControls().feed.windowHours, 12); +}); + 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'), diff --git a/src/layers/news/model.js b/src/layers/news/model.js index 075329227..d3c4dc219 100644 --- a/src/layers/news/model.js +++ b/src/layers/news/model.js @@ -21,6 +21,18 @@ export function newsWindowMs(hours) { ? hours * 3_600_000 : NEWS_WINDOW_MS; } + +/** The windows the News panel offers, longest first. */ +export const NEWS_WINDOW_CHOICES = Object.freeze([24, 12, 6, 3, 1]); + +/** + * The windows offered under a server's own window of `maxHours`: that window + * itself, then each shorter choice. + */ +export function newsWindowChoices(maxHours) { + const cap = newsWindowMs(maxHours) / 3_600_000; + return [cap, ...NEWS_WINDOW_CHOICES.filter((hours) => hours < cap)]; +} // A publish time this far ahead of the clock is skew, not a future story // (as data/regionalNewsWindow.js). const NEWS_CLOCK_SKEW_MS = 15 * 60_000; @@ -616,6 +628,8 @@ export function normalizeFilters(next = {}, hotspots = [], previous = {}) { includeMinor: merged.includeMinor === true, // Event cards on the map; off leaves only the clicked event's card. cards: merged.cards !== false, + // A shorter window than the server's; null shows all it serves. + hours: NEWS_WINDOW_CHOICES.includes(merged.hours) ? merged.hours : null, }; } diff --git a/src/layers/news/model.test.mjs b/src/layers/news/model.test.mjs index e603a1cd2..dffeb0793 100644 --- a/src/layers/news/model.test.mjs +++ b/src/layers/news/model.test.mjs @@ -20,6 +20,7 @@ import { isPressing, markerStyle, mergeFeedPage, + newsWindowChoices, newsWindowMs, normalizeFilters, passesFilters, @@ -287,8 +288,11 @@ test('filters default to significant news and map to a last-24-hours query', () minSeverity: 2, includeMinor: false, cards: true, + hours: null, }); assert.equal(normalizeFilters({ cards: false }, hotspots).cards, false); + assert.equal(normalizeFilters({ hours: 6 }, hotspots).hours, 6); + assert.equal(normalizeFilters({ hours: 5 }, hotspots).hours, null); assert.equal(normalizeFilters({ minSeverity: 0 }, hotspots).minSeverity, 0); assert.equal(normalizeFilters({ hotspot: 'gone' }, hotspots).hotspot, 'all'); assert.equal( @@ -599,6 +603,21 @@ test('the hotspots answer carries the window it was counted over', () => { normalizeHotspotsPayload({ ...base, windowHours: 48 }).windowHours, null, ); + const shorter = normalizeHotspotsPayload({ + ...base, + windowHours: 3, + maxWindowHours: 12, + }); + assert.deepEqual([shorter.windowHours, shorter.maxWindowHours], [3, 12]); + assert.equal(normalizeHotspotsPayload(base).maxWindowHours, null); +}); + +test('the window choices stop at the window the server serves', () => { + assert.deepEqual(newsWindowChoices(24), [24, 12, 6, 3, 1]); + assert.deepEqual(newsWindowChoices(12), [12, 6, 3, 1]); + assert.deepEqual(newsWindowChoices(8), [8, 6, 3, 1]); + assert.deepEqual(newsWindowChoices(1), [1]); + assert.deepEqual(newsWindowChoices(null), [24, 12, 6, 3, 1]); }); test('a checked severity keeps saying who set it', () => { diff --git a/src/layers/news/records.js b/src/layers/news/records.js index afedc1337..a4ec22462 100644 --- a/src/layers/news/records.js +++ b/src/layers/news/records.js @@ -95,10 +95,15 @@ function normalizeDeveloping(items) { return out; } +const hoursOrNull = (hours) => + Number.isInteger(hours) && hours >= 1 && hours <= 24 ? hours : null; + /** - * `/api/news/hotspots` → `{hotspots, categories, lastRunAt, windowHours}` - * (`windowHours` null when the server names none), or null when the - * payload has no hotspot list. + * `/api/news/hotspots` → `{hotspots, categories, lastRunAt, windowHours, + * maxWindowHours}`, or null when the payload has no hotspot list. + * `windowHours` is the window the counts cover and `maxWindowHours` the + * longest the server serves this reader; each is null when the server names + * none. */ export function normalizeHotspotsPayload(payload) { if (!Array.isArray(payload?.hotspots)) return null; @@ -136,12 +141,8 @@ export function normalizeHotspotsPayload(payload) { ) : [], lastRunAt: iso(payload.ingest?.lastRunAt), - windowHours: - Number.isInteger(payload.windowHours) && - payload.windowHours >= 1 && - payload.windowHours <= 24 - ? payload.windowHours - : null, + windowHours: hoursOrNull(payload.windowHours), + maxWindowHours: hoursOrNull(payload.maxWindowHours), }; } diff --git a/src/layers/news/source.js b/src/layers/news/source.js index be1df6846..b08941510 100644 --- a/src/layers/news/source.js +++ b/src/layers/news/source.js @@ -63,7 +63,15 @@ export function newsFeedQuery(query = {}) { export function newsQueryAllowed(url) { const params = [...url.searchParams]; if (new Set(params.map(([key]) => key)).size !== params.length) return false; - if (url.pathname === `${BASE_PATH}/hotspots`) return params.length === 0; + if (url.pathname === `${BASE_PATH}/hotspots`) + return ( + params.length === 0 || + (params.length === 1 && + params[0][0] === 'hours' && + /^\d{1,2}$/.test(params[0][1]) && + Number(params[0][1]) >= 1 && + Number(params[0][1]) <= 24) + ); if (url.pathname === `${BASE_PATH}/outline`) { const keys = params.map(([key]) => key).sort(); const value = url.searchParams; @@ -128,9 +136,12 @@ export function createNewsSource({ return readResponseJsonCapped(response, MAX_BYTES, request); } return { - async getHotspots({ signal } = {}) { + // `hours` asks for counts over a shorter window than the server's own. + async getHotspots({ signal, hours } = {}) { const payload = normalizeHotspotsPayload( - await get('/hotspots', { signal }), + await get(hours ? `/hotspots?hours=${hours}` : '/hotspots', { + signal, + }), ); if (!payload) throw new Error(msg('Malformed news hotspots response')); return payload; diff --git a/src/layers/news/source.test.mjs b/src/layers/news/source.test.mjs index 4024b88f4..d421e5725 100644 --- a/src/layers/news/source.test.mjs +++ b/src/layers/news/source.test.mjs @@ -53,6 +53,7 @@ test('reads validated hotspots, feed and outlines from the same-origin routes', ); assert.deepEqual((await source.getOutline('ua')).polygons, []); await source.getCountryOutline('SD'); + await source.getHotspots({ hours: 6 }); assert.deepEqual( calls.map((c) => c.url), [ @@ -60,6 +61,7 @@ test('reads validated hotspots, feed and outlines from the same-origin routes', '/api/news/feed?hotspot=all&limit=60&today=1', '/api/news/outline?hotspot=ua&v=4', '/api/news/outline?country=SD&v=4', + '/api/news/hotspots?hours=6', ], ); for (const { url, init } of calls) { @@ -155,7 +157,12 @@ test('the host allowlist admits the client routes and refuses anything else', () ), true, ); + assert.equal(allowed('/api/news/hotspots?hours=6'), true); for (const path of [ + '/api/news/hotspots?hours=0', + '/api/news/hotspots?hours=25', + '/api/news/hotspots?hours=6&hours=3', + '/api/news/hotspots?hours=6&x=1', '/api/news/hotspots?x=1', '/api/news/feed?limit=501', '/api/news/feed?limit=0', diff --git a/src/ui/news/NewsFeed.vue b/src/ui/news/NewsFeed.vue index a7709d33b..23cdffaed 100644 --- a/src/ui/news/NewsFeed.vue +++ b/src/ui/news/NewsFeed.vue @@ -8,7 +8,8 @@ import RailChips from '../rail/RailChips.vue'; 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). Hotspot chips, filters, the chosen +// 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 // layer publishes and acts only through `onParams`. @@ -44,6 +45,15 @@ const categoryOptions = computed(() => [ { value: 'all', label: t('All categories') }, ...(props.view?.categories ?? []), ]); +const windowOptions = computed(() => + (props.view?.windowChoices ?? []).map((hours) => ({ + value: String(hours), + label: t('Last {hours} h', { hours }), + })), +); +// The longest choice is the server's whole window, kept as no choice. +const chooseWindow = (hours) => + setFilter('hours', hours === props.view?.windowChoices?.[0] ? null : hours); const selectedId = computed(() => props.view?.selected?.id ?? null); // The chosen event leads the panel unless the feed already shows it open. const selectedApart = computed(() => { @@ -78,7 +88,15 @@ const clear = () => props.onParams({ clear: true }); class="news-readout" :aria-label="t('News, last {hours} hours', { hours: view.windowHours })" > -
{{
+
{{ t('Last {hours} h', { hours: view.windowHours }) }}