mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-12 06:12:18 +00:00
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.
This commit is contained in:
1 parent
4817d2a648
commit
f72f79f48b
20 files changed
+280
-189
No files matched your search
@@ -2,7 +2,8 @@
|
|||||||
24 hours from curated hotspots (or the fewer hours a server names), narrowed
|
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
|
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
|
that window, coloured by severity only, with hotspot
|
||||||
beacons, outlines with disputed areas, picture cards for major events, an
|
beacons, outlines with disputed areas, picture cards for major events and a
|
||||||
"In view" list below 3,000 km and a continuous feed whose cards fly the globe
|
continuous feed whose cards fly the globe to the event. The panel's Local
|
||||||
to the event. Core serves no news itself: set `GEV_NEWS_ORIGIN` to a news
|
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.
|
server and the local server forwards the layer's three read routes there.
|
||||||
@@ -85,12 +85,13 @@
|
|||||||
"News, last {hours} hours": "الأخبار، آخر {hours} ساعة",
|
"News, last {hours} hours": "الأخبار، آخر {hours} ساعة",
|
||||||
"Last {hours} h": "آخر {hours} ساعة",
|
"Last {hours} h": "آخر {hours} ساعة",
|
||||||
"Time window": "الفترة الزمنية",
|
"Time window": "الفترة الزمنية",
|
||||||
|
"Conflicts": "النزاعات",
|
||||||
|
"Local": "محلي",
|
||||||
|
"News sections": "أقسام الأخبار",
|
||||||
|
"Zoom in to see local news": "قرّب الخريطة لرؤية الأخبار المحلية",
|
||||||
"Hotspots": "بؤر ساخنة",
|
"Hotspots": "بؤر ساخنة",
|
||||||
"Severity": "الخطورة",
|
"Severity": "الخطورة",
|
||||||
"Category": "الفئة",
|
"Category": "الفئة",
|
||||||
"Include minor news": "تضمين الأخبار الثانوية",
|
|
||||||
"Show event cards": "إظهار بطاقات الأحداث",
|
|
||||||
"In view": "في مجال الرؤية",
|
|
||||||
"Nothing reported near here in the last {hours} h": "لم يُبلَّغ عن شيء قريب من هنا في آخر {hours} ساعة",
|
"Nothing reported near here in the last {hours} h": "لم يُبلَّغ عن شيء قريب من هنا في آخر {hours} ساعة",
|
||||||
"Latest news": "آخر الأخبار",
|
"Latest news": "آخر الأخبار",
|
||||||
"No news matches these filters in the last {hours} h": "لا أخبار تطابق هذه المرشحات في آخر {hours} ساعة",
|
"No news matches these filters in the last {hours} h": "لا أخبار تطابق هذه المرشحات في آخر {hours} ساعة",
|
||||||
|
|||||||
@@ -73,12 +73,13 @@
|
|||||||
"News, last {hours} hours": "Nachrichten der letzten {hours} Stunden",
|
"News, last {hours} hours": "Nachrichten der letzten {hours} Stunden",
|
||||||
"Last {hours} h": "Letzte {hours} Std.",
|
"Last {hours} h": "Letzte {hours} Std.",
|
||||||
"Time window": "Zeitraum",
|
"Time window": "Zeitraum",
|
||||||
|
"Conflicts": "Konflikte",
|
||||||
|
"Local": "Lokal",
|
||||||
|
"News sections": "Nachrichtenbereiche",
|
||||||
|
"Zoom in to see local news": "Hineinzoomen, um lokale Nachrichten zu sehen",
|
||||||
"Hotspots": "Brennpunkte",
|
"Hotspots": "Brennpunkte",
|
||||||
"Severity": "Schweregrad",
|
"Severity": "Schweregrad",
|
||||||
"Category": "Kategorie",
|
"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",
|
"Nothing reported near here in the last {hours} h": "In den letzten {hours} h nichts in der Nähe gemeldet",
|
||||||
"Latest news": "Neueste Nachrichten",
|
"Latest news": "Neueste Nachrichten",
|
||||||
"No news matches these filters in the last {hours} h": "Keine Nachrichten der letzten {hours} h passen zu diesen Filtern",
|
"No news matches these filters in the last {hours} h": "Keine Nachrichten der letzten {hours} h passen zu diesen Filtern",
|
||||||
|
|||||||
@@ -76,12 +76,13 @@
|
|||||||
"News, last {hours} hours": "Noticias de las últimas {hours} horas",
|
"News, last {hours} hours": "Noticias de las últimas {hours} horas",
|
||||||
"Last {hours} h": "Últimas {hours} h",
|
"Last {hours} h": "Últimas {hours} h",
|
||||||
"Time window": "Periodo",
|
"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",
|
"Hotspots": "Focos",
|
||||||
"Severity": "Gravedad",
|
"Severity": "Gravedad",
|
||||||
"Category": "Categoría",
|
"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",
|
"Nothing reported near here in the last {hours} h": "Nada informado cerca de aquí en las últimas {hours} h",
|
||||||
"Latest news": "Últimas noticias",
|
"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",
|
"No news matches these filters in the last {hours} h": "Ninguna noticia de las últimas {hours} h coincide con estos filtros",
|
||||||
|
|||||||
@@ -76,12 +76,13 @@
|
|||||||
"News, last {hours} hours": "Actualités des dernières {hours} heures",
|
"News, last {hours} hours": "Actualités des dernières {hours} heures",
|
||||||
"Last {hours} h": "Dernières {hours} h",
|
"Last {hours} h": "Dernières {hours} h",
|
||||||
"Time window": "Période",
|
"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",
|
"Hotspots": "Foyers",
|
||||||
"Severity": "Gravité",
|
"Severity": "Gravité",
|
||||||
"Category": "Catégorie",
|
"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",
|
"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",
|
"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",
|
"No news matches these filters in the last {hours} h": "Aucune actualité des dernières {hours} h ne correspond à ces filtres",
|
||||||
|
|||||||
@@ -76,12 +76,13 @@
|
|||||||
"News, last {hours} hours": "Notizie delle ultime {hours} ore",
|
"News, last {hours} hours": "Notizie delle ultime {hours} ore",
|
||||||
"Last {hours} h": "Ultime {hours} h",
|
"Last {hours} h": "Ultime {hours} h",
|
||||||
"Time window": "Periodo",
|
"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",
|
"Hotspots": "Focolai",
|
||||||
"Severity": "Gravità",
|
"Severity": "Gravità",
|
||||||
"Category": "Categoria",
|
"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",
|
"Nothing reported near here in the last {hours} h": "Nulla segnalato qui vicino nelle ultime {hours} h",
|
||||||
"Latest news": "Ultime notizie",
|
"Latest news": "Ultime notizie",
|
||||||
"No news matches these filters in the last {hours} h": "Nessuna notizia delle ultime {hours} h corrisponde a questi filtri",
|
"No news matches these filters in the last {hours} h": "Nessuna notizia delle ultime {hours} h corrisponde a questi filtri",
|
||||||
|
|||||||
@@ -70,12 +70,13 @@
|
|||||||
"News, last {hours} hours": "直近{hours}時間のニュース",
|
"News, last {hours} hours": "直近{hours}時間のニュース",
|
||||||
"Last {hours} h": "直近{hours}時間",
|
"Last {hours} h": "直近{hours}時間",
|
||||||
"Time window": "期間",
|
"Time window": "期間",
|
||||||
|
"Conflicts": "紛争",
|
||||||
|
"Local": "ローカル",
|
||||||
|
"News sections": "ニュースの区分",
|
||||||
|
"Zoom in to see local news": "拡大するとローカルニュースが表示されます",
|
||||||
"Hotspots": "ホットスポット",
|
"Hotspots": "ホットスポット",
|
||||||
"Severity": "深刻度",
|
"Severity": "深刻度",
|
||||||
"Category": "カテゴリ",
|
"Category": "カテゴリ",
|
||||||
"Include minor news": "軽微なニュースも含める",
|
|
||||||
"Show event cards": "イベントカードを表示",
|
|
||||||
"In view": "表示範囲内",
|
|
||||||
"Nothing reported near here in the last {hours} h": "直近{hours}時間、この付近の報道はありません",
|
"Nothing reported near here in the last {hours} h": "直近{hours}時間、この付近の報道はありません",
|
||||||
"Latest news": "最新ニュース",
|
"Latest news": "最新ニュース",
|
||||||
"No news matches these filters in the last {hours} h": "直近{hours}時間で条件に合うニュースはありません",
|
"No news matches these filters in the last {hours} h": "直近{hours}時間で条件に合うニュースはありません",
|
||||||
|
|||||||
@@ -76,12 +76,13 @@
|
|||||||
"News, last {hours} hours": "Notícias das últimas {hours} horas",
|
"News, last {hours} hours": "Notícias das últimas {hours} horas",
|
||||||
"Last {hours} h": "Últimas {hours} h",
|
"Last {hours} h": "Últimas {hours} h",
|
||||||
"Time window": "Período",
|
"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",
|
"Hotspots": "Focos",
|
||||||
"Severity": "Gravidade",
|
"Severity": "Gravidade",
|
||||||
"Category": "Categoria",
|
"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",
|
"Nothing reported near here in the last {hours} h": "Nada noticiado aqui perto nas últimas {hours} h",
|
||||||
"Latest news": "Últimas notícias",
|
"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",
|
"No news matches these filters in the last {hours} h": "Nenhuma notícia das últimas {hours} h corresponde a estes filtros",
|
||||||
|
|||||||
@@ -79,12 +79,13 @@
|
|||||||
"News, last {hours} hours": "Новости за последние {hours} ч",
|
"News, last {hours} hours": "Новости за последние {hours} ч",
|
||||||
"Last {hours} h": "За {hours} ч",
|
"Last {hours} h": "За {hours} ч",
|
||||||
"Time window": "Период",
|
"Time window": "Период",
|
||||||
|
"Conflicts": "Конфликты",
|
||||||
|
"Local": "Местные",
|
||||||
|
"News sections": "Разделы новостей",
|
||||||
|
"Zoom in to see local news": "Приблизьте карту, чтобы увидеть местные новости",
|
||||||
"Hotspots": "Горячие точки",
|
"Hotspots": "Горячие точки",
|
||||||
"Severity": "Тяжесть",
|
"Severity": "Тяжесть",
|
||||||
"Category": "Категория",
|
"Category": "Категория",
|
||||||
"Include minor news": "Показывать второстепенные новости",
|
|
||||||
"Show event cards": "Показывать карточки событий",
|
|
||||||
"In view": "В поле зрения",
|
|
||||||
"Nothing reported near here in the last {hours} h": "За последние {hours} ч поблизости ничего не сообщалось",
|
"Nothing reported near here in the last {hours} h": "За последние {hours} ч поблизости ничего не сообщалось",
|
||||||
"Latest news": "Последние новости",
|
"Latest news": "Последние новости",
|
||||||
"No news matches these filters in the last {hours} h": "За последние {hours} ч нет новостей по этим фильтрам",
|
"No news matches these filters in the last {hours} h": "За последние {hours} ч нет новостей по этим фильтрам",
|
||||||
|
|||||||
@@ -79,12 +79,13 @@
|
|||||||
"News, last {hours} hours": "Новини за останні {hours} год",
|
"News, last {hours} hours": "Новини за останні {hours} год",
|
||||||
"Last {hours} h": "За {hours} год",
|
"Last {hours} h": "За {hours} год",
|
||||||
"Time window": "Період",
|
"Time window": "Період",
|
||||||
|
"Conflicts": "Конфлікти",
|
||||||
|
"Local": "Місцеві",
|
||||||
|
"News sections": "Розділи новин",
|
||||||
|
"Zoom in to see local news": "Наблизьте мапу, щоб побачити місцеві новини",
|
||||||
"Hotspots": "Гарячі точки",
|
"Hotspots": "Гарячі точки",
|
||||||
"Severity": "Тяжкість",
|
"Severity": "Тяжкість",
|
||||||
"Category": "Категорія",
|
"Category": "Категорія",
|
||||||
"Include minor news": "Показувати другорядні новини",
|
|
||||||
"Show event cards": "Показувати картки подій",
|
|
||||||
"In view": "У полі зору",
|
|
||||||
"Nothing reported near here in the last {hours} h": "За останні {hours} год поблизу нічого не повідомлялося",
|
"Nothing reported near here in the last {hours} h": "За останні {hours} год поблизу нічого не повідомлялося",
|
||||||
"Latest news": "Останні новини",
|
"Latest news": "Останні новини",
|
||||||
"No news matches these filters in the last {hours} h": "За останні {hours} год немає новин за цими фільтрами",
|
"No news matches these filters in the last {hours} h": "За останні {hours} год немає новин за цими фільтрами",
|
||||||
|
|||||||
@@ -70,12 +70,13 @@
|
|||||||
"News, last {hours} hours": "最近 {hours} 小时的新闻",
|
"News, last {hours} hours": "最近 {hours} 小时的新闻",
|
||||||
"Last {hours} h": "最近 {hours} 小时",
|
"Last {hours} h": "最近 {hours} 小时",
|
||||||
"Time window": "时间范围",
|
"Time window": "时间范围",
|
||||||
|
"Conflicts": "冲突",
|
||||||
|
"Local": "本地",
|
||||||
|
"News sections": "新闻分区",
|
||||||
|
"Zoom in to see local news": "放大地图以查看本地新闻",
|
||||||
"Hotspots": "热点",
|
"Hotspots": "热点",
|
||||||
"Severity": "严重程度",
|
"Severity": "严重程度",
|
||||||
"Category": "类别",
|
"Category": "类别",
|
||||||
"Include minor news": "包括次要新闻",
|
|
||||||
"Show event cards": "显示事件卡片",
|
|
||||||
"In view": "视野内",
|
|
||||||
"Nothing reported near here in the last {hours} h": "最近 {hours} 小时附近无报道",
|
"Nothing reported near here in the last {hours} h": "最近 {hours} 小时附近无报道",
|
||||||
"Latest news": "最新新闻",
|
"Latest news": "最新新闻",
|
||||||
"No news matches these filters in the last {hours} h": "最近 {hours} 小时没有符合这些筛选条件的新闻",
|
"No news matches these filters in the last {hours} h": "最近 {hours} 小时没有符合这些筛选条件的新闻",
|
||||||
|
|||||||
@@ -70,12 +70,13 @@
|
|||||||
"News, last {hours} hours": "最近 {hours} 小時的新聞",
|
"News, last {hours} hours": "最近 {hours} 小時的新聞",
|
||||||
"Last {hours} h": "最近 {hours} 小時",
|
"Last {hours} h": "最近 {hours} 小時",
|
||||||
"Time window": "時間範圍",
|
"Time window": "時間範圍",
|
||||||
|
"Conflicts": "衝突",
|
||||||
|
"Local": "本地",
|
||||||
|
"News sections": "新聞分區",
|
||||||
|
"Zoom in to see local news": "放大地圖以查看本地新聞",
|
||||||
"Hotspots": "熱點",
|
"Hotspots": "熱點",
|
||||||
"Severity": "嚴重程度",
|
"Severity": "嚴重程度",
|
||||||
"Category": "類別",
|
"Category": "類別",
|
||||||
"Include minor news": "包括次要新聞",
|
|
||||||
"Show event cards": "顯示事件卡片",
|
|
||||||
"In view": "視野內",
|
|
||||||
"Nothing reported near here in the last {hours} h": "最近 {hours} 小時附近無報導",
|
"Nothing reported near here in the last {hours} h": "最近 {hours} 小時附近無報導",
|
||||||
"Latest news": "最新新聞",
|
"Latest news": "最新新聞",
|
||||||
"No news matches these filters in the last {hours} h": "最近 {hours} 小時沒有符合這些篩選條件的新聞",
|
"No news matches these filters in the last {hours} h": "最近 {hours} 小時沒有符合這些篩選條件的新聞",
|
||||||
|
|||||||
@@ -262,6 +262,7 @@
|
|||||||
"Jazz",
|
"Jazz",
|
||||||
"LOCAL",
|
"LOCAL",
|
||||||
"Landsat 8/9",
|
"Landsat 8/9",
|
||||||
|
"Local",
|
||||||
"MIC",
|
"MIC",
|
||||||
"MIL",
|
"MIL",
|
||||||
"MINI",
|
"MINI",
|
||||||
@@ -421,6 +422,7 @@
|
|||||||
"LOCAL",
|
"LOCAL",
|
||||||
"Landsat 8/9",
|
"Landsat 8/9",
|
||||||
"Landsat 8/9 via HLS",
|
"Landsat 8/9 via HLS",
|
||||||
|
"Local",
|
||||||
"Los Angeles",
|
"Los Angeles",
|
||||||
"MIC",
|
"MIC",
|
||||||
"MIL",
|
"MIL",
|
||||||
@@ -822,6 +824,7 @@
|
|||||||
"Landsat 8/9",
|
"Landsat 8/9",
|
||||||
"Landsat 8/9 via HLS",
|
"Landsat 8/9 via HLS",
|
||||||
"Layout",
|
"Layout",
|
||||||
|
"Local",
|
||||||
"Los Angeles",
|
"Los Angeles",
|
||||||
"MIC",
|
"MIC",
|
||||||
"MIL",
|
"MIL",
|
||||||
|
|||||||
+55
-32
@@ -22,6 +22,7 @@ import {
|
|||||||
NEWS_FEED_PAGE,
|
NEWS_FEED_PAGE,
|
||||||
NEWS_FOCUS_HEIGHT_M,
|
NEWS_FOCUS_HEIGHT_M,
|
||||||
NEWS_IN_VIEW_MAX_HEIGHT_M,
|
NEWS_IN_VIEW_MAX_HEIGHT_M,
|
||||||
|
NEWS_LOCAL_LIMIT,
|
||||||
NEWS_LAYER_ID,
|
NEWS_LAYER_ID,
|
||||||
NEWS_MAP_LIMIT,
|
NEWS_MAP_LIMIT,
|
||||||
NEWS_OVERLAY_SOURCE_ID,
|
NEWS_OVERLAY_SOURCE_ID,
|
||||||
@@ -47,6 +48,7 @@ import {
|
|||||||
mergeFeedPage,
|
mergeFeedPage,
|
||||||
newsWindowChoices,
|
newsWindowChoices,
|
||||||
newsWindowMs,
|
newsWindowMs,
|
||||||
|
localFilters,
|
||||||
normalizeFilters,
|
normalizeFilters,
|
||||||
passesFilters,
|
passesFilters,
|
||||||
reportMarkerPosition,
|
reportMarkerPosition,
|
||||||
@@ -85,8 +87,9 @@ const emptyFeed = () => ({
|
|||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
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
|
// `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 = () => ({
|
const emptyInView = () => ({
|
||||||
active: false,
|
active: false,
|
||||||
cell: null,
|
cell: null,
|
||||||
@@ -167,7 +170,26 @@ export function createNewsLayer({
|
|||||||
inView.minor?.find((r) => r.id === id) ||
|
inView.minor?.find((r) => r.id === id) ||
|
||||||
null;
|
null;
|
||||||
const selected = () => (selectedId ? findReport(selectedId) : 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 = () =>
|
const shownWindowMs = () =>
|
||||||
Math.min(maxWindowMs, (filters.hours ?? Infinity) * 3_600_000);
|
Math.min(maxWindowMs, (filters.hours ?? Infinity) * 3_600_000);
|
||||||
// The hours to ask the hotspot counts for, when shorter than the server's.
|
// 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
|
// The big events stand as picture cards over their marker, never two on
|
||||||
// reader has turned them off; never two on top of each other. The clicked
|
// top of each other. The clicked event shows its picture card under its
|
||||||
// event shows its picture card either way, under its marker, with its text
|
// marker, with its text card above.
|
||||||
// card above.
|
|
||||||
function pictureCards(drawn, current, labels) {
|
function pictureCards(drawn, current, labels) {
|
||||||
const cardable = filters.cards
|
const cardable = cardEvents(
|
||||||
? cardEvents(
|
drawn.filter((r) => r.id !== selectedId && onMap(r)),
|
||||||
drawn.filter((r) => r.id !== selectedId && onMap(r)),
|
now(),
|
||||||
now(),
|
);
|
||||||
)
|
|
||||||
: [];
|
|
||||||
const pictured =
|
const pictured =
|
||||||
current?.pins.length &&
|
current?.pins.length &&
|
||||||
current.event?.size >= NEWS_CARD_MIN_SOURCES &&
|
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.
|
// A report picked in the feed or In view still gets its marker.
|
||||||
const current = selected();
|
const current = selected();
|
||||||
const elsewhere = catchAllIds();
|
const elsewhere = catchAllIds();
|
||||||
const shown = detail
|
// Local: what is near the view centre instead of the hotspots' news.
|
||||||
? mapReports
|
const shown = localMode()
|
||||||
: mapReports.filter((r) => elsewhere.has(r.hotspotId));
|
? localReports(NEWS_MAP_LIMIT)
|
||||||
|
: detail
|
||||||
|
? mapReports
|
||||||
|
: mapReports.filter((r) => elsewhere.has(r.hotspotId));
|
||||||
const drawn =
|
const drawn =
|
||||||
current && !shown.some((r) => r.id === current.id)
|
current && !shown.some((r) => r.id === current.id)
|
||||||
? [...shown, current]
|
? [...shown, current]
|
||||||
@@ -888,6 +910,7 @@ export function createNewsLayer({
|
|||||||
inViewRequest?.abort();
|
inViewRequest?.abort();
|
||||||
inViewRequest = null;
|
inViewRequest = null;
|
||||||
inView = emptyInView();
|
inView = emptyInView();
|
||||||
|
if (local()) render();
|
||||||
notify();
|
notify();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -897,7 +920,7 @@ export function createNewsLayer({
|
|||||||
const cell = viewCell(centre);
|
const cell = viewCell(centre);
|
||||||
if (!force && inView.active && cell === inView.cell) return;
|
if (!force && inView.active && cell === inView.cell) return;
|
||||||
inView = { ...inView, active: true, cell, centre };
|
inView = { ...inView, active: true, cell, centre };
|
||||||
if (!filters.includeMinor) {
|
if (!local()) {
|
||||||
inViewRequest?.abort();
|
inViewRequest?.abort();
|
||||||
inViewRequest = null;
|
inViewRequest = null;
|
||||||
inView = { ...inView, minor: null, loading: false, error: null };
|
inView = { ...inView, minor: null, loading: false, error: null };
|
||||||
@@ -905,6 +928,7 @@ export function createNewsLayer({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!force && (inView.minor || inViewRequest)) {
|
if (!force && (inView.minor || inViewRequest)) {
|
||||||
|
render();
|
||||||
notify();
|
notify();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -942,7 +966,10 @@ export function createNewsLayer({
|
|||||||
error: cause?.message || FEED_UNAVAILABLE,
|
error: cause?.message || FEED_UNAVAILABLE,
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
if (inViewRequest === controller) inViewRequest = null;
|
if (inViewRequest === controller) {
|
||||||
|
inViewRequest = null;
|
||||||
|
render();
|
||||||
|
}
|
||||||
notify();
|
notify();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1036,20 +1063,14 @@ export function createNewsLayer({
|
|||||||
active: inView.active,
|
active: inView.active,
|
||||||
loading: inView.loading,
|
loading: inView.loading,
|
||||||
error: inView.error ? t(FEED_UNAVAILABLE) : null,
|
error: inView.error ? t(FEED_UNAVAILABLE) : null,
|
||||||
items: reportsInView(
|
items: localReports(NEWS_LOCAL_LIMIT).map((r) => feedCardView(r, at)),
|
||||||
filters.includeMinor ? inView.minor || [] : mapReports,
|
|
||||||
inView.centre,
|
|
||||||
filters,
|
|
||||||
at,
|
|
||||||
undefined,
|
|
||||||
windowMs,
|
|
||||||
).map((r) => feedCardView(r, at)),
|
|
||||||
},
|
},
|
||||||
selected:
|
selected:
|
||||||
report && withinNewsWindow(report, at, windowMs)
|
report && withinNewsWindow(report, at, windowMs)
|
||||||
? feedCardView(report, at)
|
? feedCardView(report, at)
|
||||||
: null,
|
: 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,
|
loading: feed.loading || loading,
|
||||||
// Read failures are English data; the panel shows them translated.
|
// Read failures are English data; the panel shows them translated.
|
||||||
error: error || feed.error ? t(error || feed.error) : null,
|
error: error || feed.error ? t(error || feed.error) : null,
|
||||||
@@ -1211,11 +1232,11 @@ export function createNewsLayer({
|
|||||||
}
|
}
|
||||||
if (params.filters && typeof params.filters === 'object') {
|
if (params.filters && typeof params.filters === 'object') {
|
||||||
const next = normalizeFilters(params.filters, hotspots, filters);
|
const next = normalizeFilters(params.filters, hotspots, filters);
|
||||||
// The severity floor and the cards change what is shown, not what is read.
|
// The severity floor and the section change what is shown, not what
|
||||||
const readKey = ({ hotspot, category, includeMinor }) =>
|
// the Conflicts feed reads.
|
||||||
`${hotspot}|${category}|${includeMinor}`;
|
const readKey = ({ hotspot, category }) => `${hotspot}|${category}`;
|
||||||
const reread = readKey(next) !== readKey(filters);
|
const reread = readKey(next) !== readKey(filters);
|
||||||
const minorChanged = next.includeMinor !== filters.includeMinor;
|
const sectionChanged = next.section !== filters.section;
|
||||||
filters = next;
|
filters = next;
|
||||||
const previousWindowMs = windowMs;
|
const previousWindowMs = windowMs;
|
||||||
windowMs = shownWindowMs();
|
windowMs = shownWindowMs();
|
||||||
@@ -1230,7 +1251,7 @@ export function createNewsLayer({
|
|||||||
if (reread) feed = emptyFeed();
|
if (reread) feed = emptyFeed();
|
||||||
render();
|
render();
|
||||||
if (reread) void loadFeed('replace');
|
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.
|
// The hotspot counts are the server's, so a new window reads them again.
|
||||||
if (windowMs !== previousWindowMs) void layer.update(viewer);
|
if (windowMs !== previousWindowMs) void layer.update(viewer);
|
||||||
notify();
|
notify();
|
||||||
@@ -1266,7 +1287,9 @@ export function createNewsLayer({
|
|||||||
},
|
},
|
||||||
getStats() {
|
getStats() {
|
||||||
return {
|
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,
|
lastUpdate,
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
|
|||||||
@@ -96,7 +96,13 @@ function fixture(
|
|||||||
const viewer = {
|
const viewer = {
|
||||||
camera,
|
camera,
|
||||||
scene: { requestRender() {}, preRender: new Cesium.Event() },
|
scene: { requestRender() {}, preRender: new Cesium.Event() },
|
||||||
dataSources: { add() {}, remove() {} },
|
added: [],
|
||||||
|
dataSources: {
|
||||||
|
add(source) {
|
||||||
|
viewer.added.push(source);
|
||||||
|
},
|
||||||
|
remove() {},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
const layer = createNewsLayer({
|
const layer = createNewsLayer({
|
||||||
source,
|
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);
|
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 });
|
const { layer, entries, calls } = fixture(t, { height: 500_000 });
|
||||||
layer.enable();
|
layer.enable();
|
||||||
await layer.update();
|
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(), []);
|
assert.deepEqual(cards(), []);
|
||||||
await settle();
|
await settle();
|
||||||
const reads = calls.feed.length;
|
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(calls.feed.length, reads);
|
||||||
assert.equal(layer.getRowControls().feed.filters.cards, false);
|
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
layer.getRowControls().feed.items.map((item) => item.id),
|
layer.getRowControls().feed.items.map((item) => item.id),
|
||||||
['major', 'minor', 'far'],
|
['major', 'minor', 'far'],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('In view places the held reports when the camera settles low, with no new read', 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 };
|
|
||||||
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) => {
|
|
||||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||||
let centre = { lat: 49, lon: 36 };
|
let centre = { lat: 49, lon: 36 };
|
||||||
let fail = false;
|
let fail = false;
|
||||||
const minorReads = [];
|
const localReads = [];
|
||||||
const { layer, camera } = fixture(t, {
|
const { layer, camera, viewer } = fixture(t, {
|
||||||
height: 1_000_000,
|
height: 1_000_000,
|
||||||
centre: () => centre,
|
centre: () => centre,
|
||||||
source: {
|
source: {
|
||||||
getFeed: async (query) => {
|
getFeed: async (query) => {
|
||||||
if (query.relevance === '' && query.limit === 500) {
|
if (query.relevance === '' && query.limit === 500) {
|
||||||
minorReads.push(query);
|
localReads.push(query);
|
||||||
if (fail) throw new Error('boom');
|
if (fail) throw new Error('boom');
|
||||||
return {
|
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,
|
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();
|
layer.enable();
|
||||||
await layer.update();
|
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();
|
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(
|
assert.deepEqual(
|
||||||
layer.getRowControls().feed.inView.items.map((item) => item.id),
|
view.inView.items.map((item) => item.id),
|
||||||
['broad'],
|
['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();
|
camera.moveEnd.raiseEvent();
|
||||||
t.mock.timers.tick(800);
|
t.mock.timers.tick(800);
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
assert.equal(minorReads.length, 1);
|
assert.equal(localReads.length, 1);
|
||||||
// Turned off and on again with the server failing: the error is shown.
|
assert.deepEqual(
|
||||||
layer.setParams({ filters: { includeMinor: false } });
|
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;
|
fail = true;
|
||||||
layer.setParams({ filters: { includeMinor: true } });
|
layer.setParams({ filters: { section: 'local' } });
|
||||||
for (let i = 0; i < 4; i++) await Promise.resolve();
|
for (let i = 0; i < 4; i++) await Promise.resolve();
|
||||||
assert.equal(minorReads.length, 2);
|
assert.equal(localReads.length, 2);
|
||||||
const view = layer.getRowControls().feed.inView;
|
view = layer.getRowControls().feed;
|
||||||
assert.equal(view.error, 'News feed unavailable');
|
assert.equal(view.inView.error, 'News feed unavailable');
|
||||||
assert.deepEqual(view.items, []);
|
assert.deepEqual(view.inView.items, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a shorter window narrows the feed and reads the hotspot counts for it', async (t) => {
|
test('a shorter window narrows the feed and reads the hotspot counts for it', async (t) => {
|
||||||
|
|||||||
@@ -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_MAX_HEIGHT_M = 3_000_000;
|
||||||
export const NEWS_IN_VIEW_RADIUS_KM = 500;
|
export const NEWS_IN_VIEW_RADIUS_KM = 500;
|
||||||
export const NEWS_IN_VIEW_LIMIT = 12;
|
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_SETTLE_MS = 800;
|
||||||
export const NEWS_CELL_DEG = 0.5;
|
export const NEWS_CELL_DEG = 0.5;
|
||||||
/** A feed card's click flies the camera to this height over the event. */
|
/** 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,
|
hotspot,
|
||||||
category,
|
category,
|
||||||
minSeverity,
|
minSeverity,
|
||||||
includeMinor: merged.includeMinor === true,
|
section: NEWS_SECTIONS.includes(merged.section)
|
||||||
// Event cards on the map; off leaves only the clicked event's card.
|
? merged.section
|
||||||
cards: merged.cards !== false,
|
: 'conflicts',
|
||||||
// A shorter window than the server's; null shows all it serves.
|
// A shorter window than the server's; null shows all it serves.
|
||||||
hours: NEWS_WINDOW_CHOICES.includes(merged.hours) ? merged.hours : null,
|
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 = {}) {
|
export function feedQueryFor(filters, extra = {}) {
|
||||||
return {
|
return {
|
||||||
events: true,
|
events: true,
|
||||||
today: true,
|
today: true,
|
||||||
relevance: filters.includeMinor ? '' : NEWS_RELEVANT,
|
relevance: NEWS_RELEVANT,
|
||||||
hotspot: filters.hotspot,
|
hotspot: filters.hotspot,
|
||||||
category: filters.category === 'all' ? '' : filters.category,
|
category: filters.category === 'all' ? '' : filters.category,
|
||||||
limit: NEWS_FEED_PAGE,
|
limit: NEWS_FEED_PAGE,
|
||||||
|
|||||||
@@ -286,11 +286,17 @@ test('filters default to significant news and map to a last-24-hours query', ()
|
|||||||
hotspot: 'ua',
|
hotspot: 'ua',
|
||||||
category: 'all',
|
category: 'all',
|
||||||
minSeverity: 2,
|
minSeverity: 2,
|
||||||
includeMinor: false,
|
section: 'conflicts',
|
||||||
cards: true,
|
|
||||||
hours: null,
|
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: 6 }, hotspots).hours, 6);
|
||||||
assert.equal(normalizeFilters({ hours: 5 }, hotspots).hours, null);
|
assert.equal(normalizeFilters({ hours: 5 }, hotspots).hours, null);
|
||||||
assert.equal(normalizeFilters({ minSeverity: 0 }, hotspots).minSeverity, 0);
|
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',
|
'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.
|
// The Conflicts feed reads the same whichever section is chosen.
|
||||||
assert.doesNotMatch(
|
assert.equal(
|
||||||
newsFeedQuery(feedQueryFor({ ...filters, includeMinor: true })),
|
newsFeedQuery(feedQueryFor({ ...filters, section: 'local' })),
|
||||||
/relevance/,
|
newsFeedQuery(feedQueryFor(filters)),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+47
-27
@@ -9,9 +9,10 @@ import NewsCard from './NewsCard.vue';
|
|||||||
|
|
||||||
// The News panel: the window the server serves only (the last 24 hours, or
|
// 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
|
// 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
|
// top (24/12/6/3/1 h), and two sections. Conflicts: hotspot chips, the
|
||||||
// event, "In view" (reports near the view centre while the camera is low)
|
// severity floor, the chosen event and a continuous feed, newest first.
|
||||||
// and a continuous feed, newest first. It renders the `feed` view the news
|
// 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`.
|
// layer publishes and acts only through `onParams`.
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
view: { type: Object, default: null },
|
view: { type: Object, default: null },
|
||||||
@@ -19,6 +20,16 @@ const props = defineProps({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const filters = computed(() => props.view?.filters ?? {});
|
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(() =>
|
const chips = computed(() =>
|
||||||
(props.view?.hotspots ?? []).map((chip) => ({
|
(props.view?.hotspots ?? []).map((chip) => ({
|
||||||
id: chip.id,
|
id: chip.id,
|
||||||
@@ -59,10 +70,9 @@ const selectedId = computed(() => props.view?.selected?.id ?? null);
|
|||||||
const selectedApart = computed(() => {
|
const selectedApart = computed(() => {
|
||||||
const chosen = props.view?.selected;
|
const chosen = props.view?.selected;
|
||||||
if (!chosen) return null;
|
if (!chosen) return null;
|
||||||
const listed =
|
const listed = local.value
|
||||||
props.view.items.some((item) => item.id === chosen.id) ||
|
? props.view.inView.items.some((item) => item.id === chosen.id)
|
||||||
(props.view.inView.active &&
|
: props.view.items.some((item) => item.id === chosen.id);
|
||||||
props.view.inView.items.some((item) => item.id === chosen.id));
|
|
||||||
return listed ? null : chosen;
|
return listed ? null : chosen;
|
||||||
});
|
});
|
||||||
const status = computed(() => {
|
const status = computed(() => {
|
||||||
@@ -99,7 +109,22 @@ const clear = () => props.onParams({ clear: true });
|
|||||||
<p v-else class="news-readout__window ui-mono">{{
|
<p v-else class="news-readout__window ui-mono">{{
|
||||||
t('Last {hours} h', { hours: view.windowHours })
|
t('Last {hours} h', { hours: view.windowHours })
|
||||||
}}</p>
|
}}</p>
|
||||||
<div class="news-readout__chips" role="group" :aria-label="t('Hotspots')">
|
<div
|
||||||
|
class="news-readout__sections"
|
||||||
|
role="group"
|
||||||
|
:aria-label="t('News sections')"
|
||||||
|
>
|
||||||
|
<RailChips
|
||||||
|
:chips="sections"
|
||||||
|
:on-select="(chip) => setFilter('section', chip.id)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="!local"
|
||||||
|
class="news-readout__chips"
|
||||||
|
role="group"
|
||||||
|
:aria-label="t('Hotspots')"
|
||||||
|
>
|
||||||
<RailChips
|
<RailChips
|
||||||
:chips="chips"
|
:chips="chips"
|
||||||
:on-select="(chip) => setFilter('hotspot', chip.id)"
|
:on-select="(chip) => setFilter('hotspot', chip.id)"
|
||||||
@@ -107,6 +132,7 @@ const clear = () => props.onParams({ clear: true });
|
|||||||
</div>
|
</div>
|
||||||
<div class="news-readout__filters">
|
<div class="news-readout__filters">
|
||||||
<UiSelect
|
<UiSelect
|
||||||
|
v-if="!local"
|
||||||
class="gev-field news-filter"
|
class="gev-field news-filter"
|
||||||
:aria-label="t('Severity')"
|
:aria-label="t('Severity')"
|
||||||
:model-value="String(filters.minSeverity ?? 2)"
|
:model-value="String(filters.minSeverity ?? 2)"
|
||||||
@@ -120,20 +146,6 @@ const clear = () => props.onParams({ clear: true });
|
|||||||
:options="categoryOptions"
|
:options="categoryOptions"
|
||||||
@update:model-value="setFilter('category', $event)"
|
@update:model-value="setFilter('category', $event)"
|
||||||
/>
|
/>
|
||||||
<label class="news-toggle"
|
|
||||||
><input
|
|
||||||
type="checkbox"
|
|
||||||
:checked="filters.includeMinor === true"
|
|
||||||
@change="setFilter('includeMinor', $event.target.checked)"
|
|
||||||
/><span>{{ t('Include minor news') }}</span></label
|
|
||||||
>
|
|
||||||
<label class="news-toggle"
|
|
||||||
><input
|
|
||||||
type="checkbox"
|
|
||||||
:checked="filters.cards !== false"
|
|
||||||
@change="setFilter('cards', $event.target.checked)"
|
|
||||||
/><span>{{ t('Show event cards') }}</span></label
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<NewsCard
|
<NewsCard
|
||||||
@@ -146,12 +158,16 @@ const clear = () => props.onParams({ clear: true });
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<section
|
<section
|
||||||
v-if="view.inView.active"
|
v-if="local"
|
||||||
class="news-readout__section"
|
class="news-readout__section"
|
||||||
:aria-label="t('In view')"
|
:aria-label="t('Local')"
|
||||||
>
|
>
|
||||||
<h3 class="news-readout__head ui-mono">{{ t('In view') }}</h3>
|
<UiEmpty
|
||||||
<p v-if="view.inView.error" class="news-readout__status">
|
v-if="!view.inView.active"
|
||||||
|
class="news-readout__empty"
|
||||||
|
:title="t('Zoom in to see local news')"
|
||||||
|
/>
|
||||||
|
<p v-else-if="view.inView.error" class="news-readout__status">
|
||||||
{{ view.inView.error }}
|
{{ view.inView.error }}
|
||||||
</p>
|
</p>
|
||||||
<UiEmpty
|
<UiEmpty
|
||||||
@@ -180,7 +196,11 @@ const clear = () => props.onParams({ clear: true });
|
|||||||
</ol>
|
</ol>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="news-readout__section" :aria-label="t('Latest news')">
|
<section
|
||||||
|
v-else
|
||||||
|
class="news-readout__section"
|
||||||
|
:aria-label="t('Latest news')"
|
||||||
|
>
|
||||||
<h3 class="news-readout__head ui-mono">{{ t('Latest') }}</h3>
|
<h3 class="news-readout__head ui-mono">{{ t('Latest') }}</h3>
|
||||||
<ol
|
<ol
|
||||||
v-if="view.items.length"
|
v-if="view.items.length"
|
||||||
|
|||||||
+25
-15
@@ -119,7 +119,7 @@ test('the panel shows the last 24 h as event cards and opens once', () => {
|
|||||||
'Last 24 h',
|
'Last 24 h',
|
||||||
);
|
);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
[...panel.querySelectorAll('.data-toggle-chip[data-chip-id]')].map(
|
[...panel.querySelectorAll('.news-readout__chips [data-chip-id]')].map(
|
||||||
(chip) => chip.textContent,
|
(chip) => chip.textContent,
|
||||||
),
|
),
|
||||||
['All 7', 'Ukraine–Russia 7'],
|
['All 7', 'Ukraine–Russia 7'],
|
||||||
@@ -189,42 +189,52 @@ test('a card click flies there; open, it links the source and closes', () => {
|
|||||||
news.destroy();
|
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();
|
const { news, panel, calls } = fixture();
|
||||||
news.update(view());
|
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"]');
|
const severity = panel.querySelector('select[aria-label="Severity"]');
|
||||||
assert.equal(severity.value, '2');
|
assert.equal(severity.value, '2');
|
||||||
severity.value = '3';
|
severity.value = '3';
|
||||||
severity.dispatchEvent(new Event('change'));
|
severity.dispatchEvent(new Event('change'));
|
||||||
assert.deepEqual(calls.at(-1)[1], { filters: { minSeverity: 3 } });
|
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();
|
panel.querySelector('[data-chip-id="ua-ru-war"]').click();
|
||||||
assert.deepEqual(calls.at(-1)[1], { filters: { hotspot: 'ua-ru-war' } });
|
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(
|
news.update(
|
||||||
view({
|
view({
|
||||||
inView: { active: true, loading: false, error: null, items: [] },
|
|
||||||
items: [],
|
items: [],
|
||||||
hasMore: false,
|
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(
|
assert.match(
|
||||||
panel.textContent,
|
panel.textContent,
|
||||||
/No news matches these filters in the last 24 h/,
|
/No news matches these filters in the last 24 h/,
|
||||||
);
|
);
|
||||||
assert.equal(panel.querySelector('.news-readout__footer button'), null);
|
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(
|
news.update(
|
||||||
view({ items: [], error: 'News feed unavailable', hasMore: false }),
|
view({ items: [], error: 'News feed unavailable', hasMore: false }),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -116,6 +116,7 @@
|
|||||||
letter-spacing: 0.12em;
|
letter-spacing: 0.12em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
.news-readout__sections,
|
||||||
.news-readout__chips {
|
.news-readout__chips {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
Reference in new issue
Block a user