diff --git a/package.json b/package.json index 42121974f..c267cef79 100644 --- a/package.json +++ b/package.json @@ -257,6 +257,7 @@ "./layers/gnss/source": "./src/layers/gnss/source.js", "./layers/ghostmaps/source": "./src/layers/ghostMaps/source.js", "./layers/drone-airspace/source": "./src/layers/droneAirspace/source.js", + "./layers/news/source": "./src/layers/news/source.js", "./layers/firms/source": "./src/layers/firms/source.js", "./layers/installations/source": "./src/layers/installations/source.js", "./layers/launches/source": "./src/layers/launches/source.js", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 6ba148306..328112590 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -4817,6 +4817,17 @@ ], "external": [] }, + "news-source": { + "exports": ["./layers/news/source"], + "modules": [ + "src/layers/news/source.js", + "src/layers/news/records.js", + "src/sources/httpBody.js", + "src/sources/capability.js", + "src/i18n/marker.js" + ], + "external": [] + }, "perimeters-source": { "exports": ["./layers/perimeters/source"], "modules": [ diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index 186bd4e1e..59cd4caa6 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -1,4 +1,60 @@ { + "News feed unavailable": "موجز الأخبار غير متاح", + "Disputed: {name}": "منطقة متنازع عليها: {name}", + "Administered by {holder} · claimed by {claimant}": "تديرها {holder} · تطالب بها {claimant}", + "Self-administered · claimed by {claimant}": "تدير نفسها · تطالب بها {claimant}", + "UN-patrolled zone": "منطقة تسيّر فيها الأمم المتحدة دوريات", + "Claimed by neither neighbour": "لا يطالب بها أي من الجارين", + "Video": "فيديو", + "Photos": "صور", + "Claimed by {who}": "ادعاء: {who}", + "Claimed": "ادعاء", + "Not yet confirmed by others": "لم يؤكده آخرون بعد", + "Reported by {side} sources": "أوردته مصادر {side}", + "Reported by {sides}": "أوردته {sides}", + "{side} sources": "مصادر {side}", + "Official": "رسمي", + "Reported by both sides": "أورده الطرفان", + "Independently reported": "أُبلغ عنه بشكل مستقل", + "Single source": "مصدر واحد", + "International": "دولية", + "Independent": "مستقلة", + "Unattributed": "غير منسوب", + "Outside party": "طرف خارجي", + "Ukraine–Russia": "أوكرانيا–روسيا", + "Israel–Gaza–Lebanon": "إسرائيل–غزة–لبنان", + "Iran–Hormuz": "إيران–هرمز", + "Yemen–Red Sea": "اليمن–البحر الأحمر", + "Sudan": "السودان", + "Sahel": "الساحل", + "Elsewhere": "أماكن أخرى", + "Strike": "ضربة", + "Alert": "إنذار", + "Air defence": "دفاع جوي", + "Ground fighting": "قتال بري", + "Casualties": "إصابات", + "Humanitarian": "إنساني", + "Disaster": "كارثة", + "Protest": "احتجاج", + "Political": "سياسي", + "No physical event": "لا حدث ميداني", + "Minor incident": "حادث بسيط", + "Significant": "كبير", + "Major": "جسيم", + "age|now": "الآن", + "{minutes} min": "{minutes} د", + "{hours} h": "{hours} س", + "{count} sources": { + "zero": "{count} مصدر", + "one": "مصدر واحد ({count})", + "two": "مصدران ({count})", + "few": "{count} مصادر", + "many": "{count} مصدرًا", + "other": "{count} مصدر" + }, + "News feed not configured on this server": "موجز الأخبار غير مهيأ على هذا الخادم", + "Malformed news hotspots response": "استجابة بؤر الأخبار غير صالحة", + "Malformed news feed response": "استجابة موجز الأخبار غير صالحة", "NO PLACE LEFT BEHIND": "لا مكان يُترك خلفنا", "Initializing photorealistic world...": "جارٍ تهيئة العالم الواقعي…", "Configuring viewer...": "جارٍ إعداد العارض…", diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 0fa269e20..9421361bc 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -1,4 +1,56 @@ { + "News feed unavailable": "Nachrichtenfeed nicht verfügbar", + "Disputed: {name}": "Umstritten: {name}", + "Administered by {holder} · claimed by {claimant}": "Verwaltet von {holder} · beansprucht von {claimant}", + "Self-administered · claimed by {claimant}": "Selbstverwaltet · beansprucht von {claimant}", + "UN-patrolled zone": "Von der UNO überwachte Zone", + "Claimed by neither neighbour": "Von keinem Nachbarn beansprucht", + "Video": "Video", + "Photos": "Fotos", + "Claimed by {who}": "Behauptet von {who}", + "Claimed": "Behauptung", + "Not yet confirmed by others": "Noch nicht von anderen bestätigt", + "Reported by {side} sources": "Gemeldet von Quellen aus {side}", + "Reported by {sides}": "Gemeldet von {sides}", + "{side} sources": "Quellen aus {side}", + "Official": "Offiziell", + "Reported by both sides": "Von beiden Seiten gemeldet", + "Independently reported": "Unabhängig gemeldet", + "Single source": "Einzelne Quelle", + "International": "International", + "Independent": "Unabhängig", + "Unattributed": "Ohne Zuordnung", + "Outside party": "Außenstehende Partei", + "Ukraine–Russia": "Ukraine–Russland", + "Israel–Gaza–Lebanon": "Israel–Gaza–Libanon", + "Iran–Hormuz": "Iran–Hormus", + "Yemen–Red Sea": "Jemen–Rotes Meer", + "Sudan": "Sudan", + "Sahel": "Sahel", + "Elsewhere": "Anderswo", + "Strike": "Angriff", + "Alert": "Warnung", + "Air defence": "Luftabwehr", + "Ground fighting": "Bodenkämpfe", + "Casualties": "Opfer", + "Humanitarian": "Humanitär", + "Disaster": "Katastrophe", + "Protest": "Protest", + "Political": "Politik", + "No physical event": "Kein physisches Ereignis", + "Minor incident": "Kleiner Vorfall", + "Significant": "Erheblich", + "Major": "Schwer", + "age|now": "jetzt", + "{minutes} min": "{minutes} Min.", + "{hours} h": "{hours} Std.", + "{count} sources": { + "one": "{count} Quelle", + "other": "{count} Quellen" + }, + "News feed not configured on this server": "Nachrichtenfeed auf diesem Server nicht eingerichtet", + "Malformed news hotspots response": "Fehlerhafte Antwort zu Nachrichten-Brennpunkten", + "Malformed news feed response": "Fehlerhafte Antwort des Nachrichtenfeeds", "Flying to Austin, TX...": "Flug nach Austin, TX...", "Restoring shared view...": "Geteilte Ansicht wird wiederhergestellt...", "Configuring viewer...": "Viewer wird konfiguriert...", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 078096514..914a85171 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -1,4 +1,57 @@ { + "News feed unavailable": "Noticias no disponibles", + "Disputed: {name}": "En disputa: {name}", + "Administered by {holder} · claimed by {claimant}": "Administrado por {holder} · reclamado por {claimant}", + "Self-administered · claimed by {claimant}": "Autogobernado · reclamado por {claimant}", + "UN-patrolled zone": "Zona patrullada por la ONU", + "Claimed by neither neighbour": "Ningún vecino lo reclama", + "Video": "Vídeo", + "Photos": "Fotos", + "Claimed by {who}": "Según {who}", + "Claimed": "Afirmación", + "Not yet confirmed by others": "Aún sin confirmar por otros", + "Reported by {side} sources": "Informado por fuentes de {side}", + "Reported by {sides}": "Informado por {sides}", + "{side} sources": "fuentes de {side}", + "Official": "Oficial", + "Reported by both sides": "Informado por ambos bandos", + "Independently reported": "Informado de forma independiente", + "Single source": "Fuente única", + "International": "Internacional", + "Independent": "Independiente", + "Unattributed": "Sin atribuir", + "Outside party": "Parte externa", + "Ukraine–Russia": "Ucrania–Rusia", + "Israel–Gaza–Lebanon": "Israel–Gaza–Líbano", + "Iran–Hormuz": "Irán–Ormuz", + "Yemen–Red Sea": "Yemen–Mar Rojo", + "Sudan": "Sudán", + "Sahel": "Sahel", + "Elsewhere": "En otros lugares", + "Strike": "Ataque", + "Alert": "Alerta", + "Air defence": "Defensa aérea", + "Ground fighting": "Combates terrestres", + "Casualties": "Víctimas", + "Humanitarian": "Humanitario", + "Disaster": "Catástrofe", + "Protest": "Protesta", + "Political": "Político", + "No physical event": "Sin hecho físico", + "Minor incident": "Incidente menor", + "Significant": "Significativo", + "Major": "Grave", + "age|now": "ahora", + "{minutes} min": "{minutes} min", + "{hours} h": "{hours} h", + "{count} sources": { + "one": "{count} fuente", + "many": "{count} fuentes", + "other": "{count} fuentes" + }, + "News feed not configured on this server": "Noticias no configuradas en este servidor", + "Malformed news hotspots response": "Respuesta de focos de noticias mal formada", + "Malformed news feed response": "Respuesta de noticias mal formada", "NO PLACE LEFT BEHIND": "NINGÚN RINCÓN SIN VIGILAR", "Initializing photorealistic world...": "Inicializando el mundo fotorrealista...", "Configuring viewer...": "Configurando el visor...", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 3ce27c53c..f01ca2956 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -1,4 +1,57 @@ { + "News feed unavailable": "Fil d’actualités indisponible", + "Disputed: {name}": "Contesté : {name}", + "Administered by {holder} · claimed by {claimant}": "Administré par {holder} · revendiqué par {claimant}", + "Self-administered · claimed by {claimant}": "Autoadministré · revendiqué par {claimant}", + "UN-patrolled zone": "Zone patrouillée par l’ONU", + "Claimed by neither neighbour": "Revendiqué par aucun voisin", + "Video": "Vidéo", + "Photos": "Photos", + "Claimed by {who}": "Selon {who}", + "Claimed": "Affirmation", + "Not yet confirmed by others": "Pas encore confirmé par d’autres", + "Reported by {side} sources": "Rapporté par des sources {side}", + "Reported by {sides}": "Rapporté par {sides}", + "{side} sources": "sources {side}", + "Official": "Officiel", + "Reported by both sides": "Rapporté par les deux camps", + "Independently reported": "Rapporté de façon indépendante", + "Single source": "Source unique", + "International": "Internationale", + "Independent": "Indépendante", + "Unattributed": "Non attribué", + "Outside party": "Partie extérieure", + "Ukraine–Russia": "Ukraine–Russie", + "Israel–Gaza–Lebanon": "Israël–Gaza–Liban", + "Iran–Hormuz": "Iran–Ormuz", + "Yemen–Red Sea": "Yémen–mer Rouge", + "Sudan": "Soudan", + "Sahel": "Sahel", + "Elsewhere": "Ailleurs", + "Strike": "Frappe", + "Alert": "Alerte", + "Air defence": "Défense aérienne", + "Ground fighting": "Combats au sol", + "Casualties": "Victimes", + "Humanitarian": "Humanitaire", + "Disaster": "Catastrophe", + "Protest": "Manifestation", + "Political": "Politique", + "No physical event": "Aucun événement physique", + "Minor incident": "Incident mineur", + "Significant": "Important", + "Major": "Majeur", + "age|now": "maintenant", + "{minutes} min": "{minutes} min", + "{hours} h": "{hours} h", + "{count} sources": { + "one": "{count} source", + "many": "{count} sources", + "other": "{count} sources" + }, + "News feed not configured on this server": "Fil d’actualités non configuré sur ce serveur", + "Malformed news hotspots response": "Réponse des foyers d’actualité mal formée", + "Malformed news feed response": "Réponse du fil d’actualités mal formée", "Pick a shape, then click the map.": "Choisissez une forme, puis cliquez sur la carte.", "Enter to place the pin, Esc to cancel.": "Enter pour poser l'épingle, Esc pour annuler.", "Click where the pin goes.": "Cliquez à l'endroit où poser l'épingle.", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 36f25a0cf..dcaf4855c 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -1,4 +1,57 @@ { + "News feed unavailable": "Feed delle notizie non disponibile", + "Disputed: {name}": "Conteso: {name}", + "Administered by {holder} · claimed by {claimant}": "Amministrato da {holder} · rivendicato da {claimant}", + "Self-administered · claimed by {claimant}": "Autogovernato · rivendicato da {claimant}", + "UN-patrolled zone": "Zona pattugliata dall’ONU", + "Claimed by neither neighbour": "Non rivendicato da nessun vicino", + "Video": "Video", + "Photos": "Foto", + "Claimed by {who}": "Secondo {who}", + "Claimed": "Affermazione", + "Not yet confirmed by others": "Non ancora confermato da altri", + "Reported by {side} sources": "Riportato da fonti {side}", + "Reported by {sides}": "Riportato da {sides}", + "{side} sources": "fonti {side}", + "Official": "Ufficiale", + "Reported by both sides": "Riportato da entrambe le parti", + "Independently reported": "Riportato in modo indipendente", + "Single source": "Fonte unica", + "International": "Internazionale", + "Independent": "Indipendente", + "Unattributed": "Non attribuito", + "Outside party": "Parte esterna", + "Ukraine–Russia": "Ucraina–Russia", + "Israel–Gaza–Lebanon": "Israele–Gaza–Libano", + "Iran–Hormuz": "Iran–Hormuz", + "Yemen–Red Sea": "Yemen–Mar Rosso", + "Sudan": "Sudan", + "Sahel": "Sahel", + "Elsewhere": "Altrove", + "Strike": "Attacco", + "Alert": "Allerta", + "Air defence": "Difesa aerea", + "Ground fighting": "Combattimenti a terra", + "Casualties": "Vittime", + "Humanitarian": "Umanitario", + "Disaster": "Disastro", + "Protest": "Protesta", + "Political": "Politica", + "No physical event": "Nessun evento fisico", + "Minor incident": "Incidente minore", + "Significant": "Rilevante", + "Major": "Grave", + "age|now": "ora", + "{minutes} min": "{minutes} min", + "{hours} h": "{hours} h", + "{count} sources": { + "one": "{count} fonte", + "many": "{count} fonti", + "other": "{count} fonti" + }, + "News feed not configured on this server": "Feed delle notizie non configurato su questo server", + "Malformed news hotspots response": "Risposta dei focolai di notizie non valida", + "Malformed news feed response": "Risposta del feed delle notizie non valida", "Pick a shape, then click the map.": "Scegli una forma, poi fai clic sulla mappa.", "Enter to place the pin, Esc to cancel.": "Premi Enter per posizionare il segnaposto, Esc per annullare.", "Click where the pin goes.": "Fai clic dove va il segnaposto.", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index e586716f8..743fcb71c 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1,4 +1,55 @@ { + "News feed unavailable": "ニュースフィードを利用できません", + "Disputed: {name}": "係争地: {name}", + "Administered by {holder} · claimed by {claimant}": "{holder}が施政 · {claimant}が領有を主張", + "Self-administered · claimed by {claimant}": "自治 · {claimant}が領有を主張", + "UN-patrolled zone": "国連が巡回する地域", + "Claimed by neither neighbour": "どちらの隣国も領有を主張していない", + "Video": "動画", + "Photos": "写真", + "Claimed by {who}": "{who}の主張", + "Claimed": "主張", + "Not yet confirmed by others": "他からの確認はまだない", + "Reported by {side} sources": "{side}側の情報源が報道", + "Reported by {sides}": "報道: {sides}", + "{side} sources": "{side}側の情報源", + "Official": "公式", + "Reported by both sides": "双方が報道", + "Independently reported": "独立した報道あり", + "Single source": "単一の情報源", + "International": "国際", + "Independent": "独立系", + "Unattributed": "出所不明", + "Outside party": "第三者", + "Ukraine–Russia": "ウクライナ–ロシア", + "Israel–Gaza–Lebanon": "イスラエル–ガザ–レバノン", + "Iran–Hormuz": "イラン–ホルムズ", + "Yemen–Red Sea": "イエメン–紅海", + "Sudan": "スーダン", + "Sahel": "サヘル", + "Elsewhere": "その他の地域", + "Strike": "攻撃", + "Alert": "警報", + "Air defence": "防空", + "Ground fighting": "地上戦", + "Casualties": "死傷者", + "Humanitarian": "人道", + "Disaster": "災害", + "Protest": "抗議", + "Political": "政治", + "No physical event": "物理的な事象なし", + "Minor incident": "小規模な事案", + "Significant": "重大", + "Major": "深刻", + "age|now": "今", + "{minutes} min": "{minutes}分", + "{hours} h": "{hours}時間", + "{count} sources": { + "other": "情報源 {count}件" + }, + "News feed not configured on this server": "このサーバーではニュースフィードが設定されていません", + "Malformed news hotspots response": "ニュースのホットスポット応答の形式が不正です", + "Malformed news feed response": "ニュースフィードの応答の形式が不正です", "Pick a shape, then click the map.": "図形を選んでから、地図をクリックしてください。", "Enter to place the pin, Esc to cancel.": "Enterでピンを配置、Escでキャンセル。", "Click where the pin goes.": "ピンを置く場所をクリックしてください。", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 13d3fbc85..2068c08b6 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -1,4 +1,57 @@ { + "News feed unavailable": "Feed de notícias indisponível", + "Disputed: {name}": "Em disputa: {name}", + "Administered by {holder} · claimed by {claimant}": "Administrado por {holder} · reivindicado por {claimant}", + "Self-administered · claimed by {claimant}": "Autogovernado · reivindicado por {claimant}", + "UN-patrolled zone": "Zona patrulhada pela ONU", + "Claimed by neither neighbour": "Nenhum vizinho o reivindica", + "Video": "Vídeo", + "Photos": "Fotos", + "Claimed by {who}": "Segundo {who}", + "Claimed": "Alegação", + "Not yet confirmed by others": "Ainda não confirmado por outros", + "Reported by {side} sources": "Noticiado por fontes de {side}", + "Reported by {sides}": "Noticiado por {sides}", + "{side} sources": "fontes de {side}", + "Official": "Oficial", + "Reported by both sides": "Noticiado por ambos os lados", + "Independently reported": "Noticiado de forma independente", + "Single source": "Fonte única", + "International": "Internacional", + "Independent": "Independente", + "Unattributed": "Sem atribuição", + "Outside party": "Parte externa", + "Ukraine–Russia": "Ucrânia–Rússia", + "Israel–Gaza–Lebanon": "Israel–Gaza–Líbano", + "Iran–Hormuz": "Irã–Ormuz", + "Yemen–Red Sea": "Iêmen–Mar Vermelho", + "Sudan": "Sudão", + "Sahel": "Sahel", + "Elsewhere": "Outros lugares", + "Strike": "Ataque", + "Alert": "Alerta", + "Air defence": "Defesa aérea", + "Ground fighting": "Combates terrestres", + "Casualties": "Vítimas", + "Humanitarian": "Humanitário", + "Disaster": "Desastre", + "Protest": "Protesto", + "Political": "Político", + "No physical event": "Sem evento físico", + "Minor incident": "Incidente menor", + "Significant": "Significativo", + "Major": "Grave", + "age|now": "agora", + "{minutes} min": "{minutes} min", + "{hours} h": "{hours} h", + "{count} sources": { + "one": "{count} fonte", + "many": "{count} fontes", + "other": "{count} fontes" + }, + "News feed not configured on this server": "Feed de notícias não configurado neste servidor", + "Malformed news hotspots response": "Resposta de focos de notícias malformada", + "Malformed news feed response": "Resposta do feed de notícias malformada", "Pick a shape, then click the map.": "Escolha uma forma e clique no mapa.", "Enter to place the pin, Esc to cancel.": "Enter para colocar o marcador, Esc para cancelar.", "Click where the pin goes.": "Clique onde o marcador deve ficar.", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 766af1005..e6df84b68 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -1,4 +1,58 @@ { + "News feed unavailable": "Лента новостей недоступна", + "Disputed: {name}": "Спорная территория: {name}", + "Administered by {holder} · claimed by {claimant}": "Под управлением: {holder} · претендует: {claimant}", + "Self-administered · claimed by {claimant}": "Самоуправляемая · претендует: {claimant}", + "UN-patrolled zone": "Зона патрулирования ООН", + "Claimed by neither neighbour": "Не претендует ни один сосед", + "Video": "Видео", + "Photos": "Фото", + "Claimed by {who}": "Заявление: {who}", + "Claimed": "Заявление", + "Not yet confirmed by others": "Пока не подтверждено другими", + "Reported by {side} sources": "Сообщают источники {side}", + "Reported by {sides}": "Сообщают: {sides}", + "{side} sources": "источники {side}", + "Official": "Официально", + "Reported by both sides": "Сообщают обе стороны", + "Independently reported": "Подтверждено независимыми источниками", + "Single source": "Один источник", + "International": "Международные", + "Independent": "Независимые", + "Unattributed": "Без атрибуции", + "Outside party": "Внешняя сторона", + "Ukraine–Russia": "Украина–Россия", + "Israel–Gaza–Lebanon": "Израиль–Газа–Ливан", + "Iran–Hormuz": "Иран–Ормуз", + "Yemen–Red Sea": "Йемен–Красное море", + "Sudan": "Судан", + "Sahel": "Сахель", + "Elsewhere": "Другие места", + "Strike": "Удар", + "Alert": "Тревога", + "Air defence": "ПВО", + "Ground fighting": "Наземные бои", + "Casualties": "Жертвы", + "Humanitarian": "Гуманитарное", + "Disaster": "Бедствие", + "Protest": "Протест", + "Political": "Политика", + "No physical event": "Без физического события", + "Minor incident": "Незначительный инцидент", + "Significant": "Значительный", + "Major": "Крупный", + "age|now": "сейчас", + "{minutes} min": "{minutes} мин", + "{hours} h": "{hours} ч", + "{count} sources": { + "one": "{count} источник", + "few": "{count} источника", + "many": "{count} источников", + "other": "{count} источника" + }, + "News feed not configured on this server": "Лента новостей не настроена на этом сервере", + "Malformed news hotspots response": "Некорректный ответ горячих точек новостей", + "Malformed news feed response": "Некорректный ответ ленты новостей", "Flying to Austin, TX...": "Перелёт в Остин, Техас...", "Restoring shared view...": "Восстановление общего вида...", "Initializing systems...": "Инициализация систем...", diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index 388ea566d..41f6f1207 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -1,4 +1,58 @@ { + "News feed unavailable": "Стрічка новин недоступна", + "Disputed: {name}": "Спірна територія: {name}", + "Administered by {holder} · claimed by {claimant}": "Під управлінням: {holder} · претендує: {claimant}", + "Self-administered · claimed by {claimant}": "Самоврядна · претендує: {claimant}", + "UN-patrolled zone": "Зона патрулювання ООН", + "Claimed by neither neighbour": "Не претендує жоден сусід", + "Video": "Відео", + "Photos": "Фото", + "Claimed by {who}": "Заява: {who}", + "Claimed": "Заява", + "Not yet confirmed by others": "Поки не підтверджено іншими", + "Reported by {side} sources": "Повідомляють джерела {side}", + "Reported by {sides}": "Повідомляють: {sides}", + "{side} sources": "джерела {side}", + "Official": "Офіційно", + "Reported by both sides": "Повідомляють обидві сторони", + "Independently reported": "Підтверджено незалежними джерелами", + "Single source": "Одне джерело", + "International": "Міжнародні", + "Independent": "Незалежні", + "Unattributed": "Без атрибуції", + "Outside party": "Зовнішня сторона", + "Ukraine–Russia": "Україна–Росія", + "Israel–Gaza–Lebanon": "Ізраїль–Газа–Ліван", + "Iran–Hormuz": "Іран–Ормуз", + "Yemen–Red Sea": "Ємен–Червоне море", + "Sudan": "Судан", + "Sahel": "Сахель", + "Elsewhere": "Інші місця", + "Strike": "Удар", + "Alert": "Тривога", + "Air defence": "ППО", + "Ground fighting": "Наземні бої", + "Casualties": "Жертви", + "Humanitarian": "Гуманітарне", + "Disaster": "Лихо", + "Protest": "Протест", + "Political": "Політика", + "No physical event": "Без фізичної події", + "Minor incident": "Незначний інцидент", + "Significant": "Значний", + "Major": "Великий", + "age|now": "зараз", + "{minutes} min": "{minutes} хв", + "{hours} h": "{hours} год", + "{count} sources": { + "one": "{count} джерело", + "few": "{count} джерела", + "many": "{count} джерел", + "other": "{count} джерела" + }, + "News feed not configured on this server": "Стрічку новин не налаштовано на цьому сервері", + "Malformed news hotspots response": "Некоректна відповідь гарячих точок новин", + "Malformed news feed response": "Некоректна відповідь стрічки новин", "Pick a shape, then click the map.": "Виберіть фігуру й клацніть на карті.", "Enter to place the pin, Esc to cancel.": "Enter — поставити мітку, Esc — скасувати.", "Click where the pin goes.": "Клацніть там, де має бути мітка.", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 912582289..30a4d8807 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1,4 +1,55 @@ { + "News feed unavailable": "新闻源不可用", + "Disputed: {name}": "争议地区:{name}", + "Administered by {holder} · claimed by {claimant}": "由{holder}管辖 · {claimant}主张主权", + "Self-administered · claimed by {claimant}": "自治 · {claimant}主张主权", + "UN-patrolled zone": "联合国巡逻区", + "Claimed by neither neighbour": "两个邻国均未主张", + "Video": "视频", + "Photos": "照片", + "Claimed by {who}": "{who}声称", + "Claimed": "声称", + "Not yet confirmed by others": "尚未获他方证实", + "Reported by {side} sources": "{side}方来源报道", + "Reported by {sides}": "报道来源:{sides}", + "{side} sources": "{side}方来源", + "Official": "官方", + "Reported by both sides": "双方均有报道", + "Independently reported": "有独立来源报道", + "Single source": "单一来源", + "International": "国际", + "Independent": "独立", + "Unattributed": "未注明来源", + "Outside party": "外部方", + "Ukraine–Russia": "乌克兰–俄罗斯", + "Israel–Gaza–Lebanon": "以色列–加沙–黎巴嫩", + "Iran–Hormuz": "伊朗–霍尔木兹", + "Yemen–Red Sea": "也门–红海", + "Sudan": "苏丹", + "Sahel": "萨赫勒", + "Elsewhere": "其他地区", + "Strike": "打击", + "Alert": "警报", + "Air defence": "防空", + "Ground fighting": "地面战斗", + "Casualties": "伤亡", + "Humanitarian": "人道", + "Disaster": "灾害", + "Protest": "抗议", + "Political": "政治", + "No physical event": "无实际事件", + "Minor incident": "轻微事件", + "Significant": "重大", + "Major": "严重", + "age|now": "刚刚", + "{minutes} min": "{minutes} 分钟", + "{hours} h": "{hours} 小时", + "{count} sources": { + "other": "{count} 个来源" + }, + "News feed not configured on this server": "此服务器未配置新闻源", + "Malformed news hotspots response": "新闻热点响应格式错误", + "Malformed news feed response": "新闻源响应格式错误", "Pick a shape, then click the map.": "先选择形状,再在地图上点击。", "Enter to place the pin, Esc to cancel.": "按 Enter 放置图钉,按 Esc 取消。", "Click where the pin goes.": "点击图钉的放置位置。", diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index 628c71284..e29d0fff2 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -1,4 +1,55 @@ { + "News feed unavailable": "新聞來源無法使用", + "Disputed: {name}": "爭議地區:{name}", + "Administered by {holder} · claimed by {claimant}": "由{holder}管轄 · {claimant}主張主權", + "Self-administered · claimed by {claimant}": "自治 · {claimant}主張主權", + "UN-patrolled zone": "聯合國巡邏區", + "Claimed by neither neighbour": "兩個鄰國均未主張", + "Video": "影片", + "Photos": "照片", + "Claimed by {who}": "{who}聲稱", + "Claimed": "聲稱", + "Not yet confirmed by others": "尚未獲他方證實", + "Reported by {side} sources": "{side}方來源報導", + "Reported by {sides}": "報導來源:{sides}", + "{side} sources": "{side}方來源", + "Official": "官方", + "Reported by both sides": "雙方均有報導", + "Independently reported": "有獨立來源報導", + "Single source": "單一來源", + "International": "國際", + "Independent": "獨立", + "Unattributed": "未註明來源", + "Outside party": "外部方", + "Ukraine–Russia": "烏克蘭–俄羅斯", + "Israel–Gaza–Lebanon": "以色列–加薩–黎巴嫩", + "Iran–Hormuz": "伊朗–荷莫茲", + "Yemen–Red Sea": "葉門–紅海", + "Sudan": "蘇丹", + "Sahel": "薩赫爾", + "Elsewhere": "其他地區", + "Strike": "打擊", + "Alert": "警報", + "Air defence": "防空", + "Ground fighting": "地面戰鬥", + "Casualties": "傷亡", + "Humanitarian": "人道", + "Disaster": "災害", + "Protest": "抗議", + "Political": "政治", + "No physical event": "無實際事件", + "Minor incident": "輕微事件", + "Significant": "重大", + "Major": "嚴重", + "age|now": "剛剛", + "{minutes} min": "{minutes} 分鐘", + "{hours} h": "{hours} 小時", + "{count} sources": { + "other": "{count} 個來源" + }, + "News feed not configured on this server": "此伺服器未設定新聞來源", + "Malformed news hotspots response": "新聞熱點回應格式錯誤", + "Malformed news feed response": "新聞來源回應格式錯誤", "Pick a shape, then click the map.": "先選擇形狀,再在地圖上點擊。", "Enter to place the pin, Esc to cancel.": "按 Enter 放置圖釘,按 Esc 取消。", "Click where the pin goes.": "點擊圖釘的放置位置。", diff --git a/src/i18n/same-as-english.json b/src/i18n/same-as-english.json index 7edaad647..9cc9a667d 100644 --- a/src/i18n/same-as-english.json +++ b/src/i18n/same-as-english.json @@ -85,6 +85,7 @@ "House", "Hybrid", "Indie", + "International", "Ironbow", "Jazz", "LIVE", @@ -129,6 +130,7 @@ "Pin", "Plan", "Pop", + "Protest", "Punk", "RADAR · {units}", "RADIO", @@ -152,6 +154,7 @@ "STD", "SW", "SYNC", + "Sahel", "San Francisco", "Scanlines", "Scope", @@ -160,6 +163,7 @@ "Soul", "Station", "Station {id}", + "Sudan", "T minus {seconds}", "TAGS · {tags}", "TANKER", @@ -171,6 +175,7 @@ "Turboprop", "UPSTREAM", "Upstream HTTP {status}", + "Video", "Vignette", "WHOT/BHOT", "WIND", @@ -295,6 +300,7 @@ "SRC {value}", "SSE", "STD", + "Sahel", "San Francisco", "Sector", "Sonar", @@ -325,6 +331,8 @@ "panel", "view band|GLOBAL", "voice|ERROR", + "{hours} h", + "{minutes} min", "{rate} msg/s", "{speed} kn", "{type} SPD: {speed} HDG: {heading}", @@ -446,6 +454,7 @@ "PROPAGATION", "Paris", "Pause", + "Photos", "Plan", "Pop", "Punk", @@ -466,6 +475,7 @@ "STANDARD", "STATION", "STD", + "Sahel", "San Francisco", "Satellites", "Saturation", @@ -509,6 +519,9 @@ "{count} MSGS", "{count} SOURCES", "{count} messages", + "{count} sources", + "{hours} h", + "{minutes} min", "{n} cyclones", "{n} satellites", "{rate} msg/s", @@ -582,6 +595,7 @@ "Hip-Hop", "House", "Indie", + "Iran–Hormuz", "Ironbow", "Jazz", "KTS", @@ -636,11 +650,13 @@ "STD", "STOP", "SYNC", + "Sahel", "San Francisco", "Scope", "Sentinel-2", "Sonar", "Soul", + "Sudan", "TEMP", "TIMEOUT", "Talk", @@ -657,6 +673,7 @@ "VIEWSHED ON", "VOLUME", "VS", + "Video", "WHOT/BHOT", "Washington DC", "World", @@ -681,6 +698,8 @@ "{count} MSGS", "{count} feeds live", "{count} scenes", + "{hours} h", + "{minutes} min", "{rate} msg/s", "{scene} / {pack}: {status}. File: {path}. {attribution} · {license}", "{source} HTTP {status}", @@ -840,6 +859,7 @@ "SSE", "STATUS", "STATUS · ", + "Sahel", "Sentinel-2", "Sentinel-2 via HLS", "Sonar", @@ -870,6 +890,8 @@ "mph", "nominal", "view band|GLOBAL", + "{hours} h", + "{minutes} min", "{rate} msg/s", "{type} SPD: {speed} HDG: {heading}", "{value} KM", diff --git a/src/layers/news/cards.js b/src/layers/news/cards.js new file mode 100644 index 000000000..75b9932eb --- /dev/null +++ b/src/layers/news/cards.js @@ -0,0 +1,206 @@ +// Event cards on the globe: a picture, the headline in two lines, and "age · +// sources · sides", with the severity colour down the left edge and the +// event's marker as a badge on the picture. Painted on a canvas and handed to +// the world overlay as a thumbnail entry, so the overlay places them, keeps +// them apart and draws their leader to the marker, as it does CCTV cards. +// +// The picture is the publisher's own (hotlinked, never copied, credited on +// the card); until it loads, or without one, the card shows a tile in the +// severity colour with the category icon. + +import { WORLD_OVERLAY_STYLE } from '../../overlays/worldOverlayTokens.js'; +import { NEWS_SEVERITY } from './model.js'; +import { paintMarker } from './sprites.js'; + +/** Card size in CSS pixels. */ +export const CARD_W = 168; +export const CARD_H = 136; +const PAD = 4; +const PIC_H = 84; +const EDGE = 3; +const SCALE = 2; // canvas pixels per CSS pixel +const TITLE_FONT = '600 11px Inter, "Noto Sans Arabic", system-ui, sans-serif'; +const META_FONT = '9px "JetBrains Mono", "Noto Sans Arabic", monospace'; +const CREDIT_FONT = '8px Inter, "Noto Sans Arabic", system-ui, sans-serif'; +const MAX_CARDS = 200; + +const pictures = new Map(); // url → {image, state: 'loading'|'ok'|'failed', waiters} +const cards = new Map(); // key → canvas + +function picture(url, onLoad) { + let entry = pictures.get(url); + if (!entry) { + const image = new Image(); + // No referrer: the publisher sees a plain image request, as from a link preview. + image.referrerPolicy = 'no-referrer'; + image.decoding = 'async'; + entry = { image, state: 'loading', waiters: new Set() }; + pictures.set(url, entry); + image.onload = () => { + entry.state = 'ok'; + for (const done of entry.waiters) done(); + entry.waiters.clear(); + }; + image.onerror = () => { + entry.state = 'failed'; + entry.waiters.clear(); + }; + image.src = url; + } + if (entry.state === 'loading' && onLoad) entry.waiters.add(onLoad); + return entry; +} + +function roundRect(ctx, x, y, w, h, r) { + ctx.beginPath(); + ctx.moveTo(x + r, y); + ctx.arcTo(x + w, y, x + w, y + h, r); + ctx.arcTo(x + w, y + h, x, y + h, r); + ctx.arcTo(x, y + h, x, y, r); + ctx.arcTo(x, y, x + w, y, r); + ctx.closePath(); +} + +/** `text` in at most `lines` lines of `width`, the last cut with an ellipsis. */ +export function wrapLines(ctx, text, width, lines) { + const words = String(text || '') + .split(/\s+/) + .filter(Boolean); + const out = []; + let line = ''; + for (let i = 0; i < words.length; i++) { + const next = line ? `${line} ${words[i]}` : words[i]; + if (ctx.measureText(next).width <= width || !line) { + line = next; + continue; + } + out.push(line); + line = words[i]; + if (out.length === lines - 1) { + line = words.slice(i).join(' '); + break; + } + } + if (line) out.push(line); + const last = out.length - 1; + if (last >= 0 && ctx.measureText(out[last]).width > width) { + let cut = out[last]; + while (cut && ctx.measureText(`${cut}…`).width > width) + cut = cut.slice(0, -1); + out[last] = `${cut.trimEnd()}…`; + } + return out.slice(0, lines); +} + +/** Cover-fit an image into a box. */ +function drawCover(ctx, image, x, y, w, h) { + const iw = image.naturalWidth || image.width; + const ih = image.naturalHeight || image.height; + if (!iw || !ih) return; + const s = Math.max(w / iw, h / ih); + const sw = w / s; + const sh = h / s; + ctx.drawImage(image, (iw - sw) / 2, (ih - sh) / 2, sw, sh, x, y, w, h); +} + +function paint(canvas, view, look, pic) { + const ctx = canvas.getContext('2d'); + ctx.setTransform(SCALE, 0, 0, SCALE, 0, 0); + ctx.clearRect(0, 0, CARD_W, CARD_H); + const color = (NEWS_SEVERITY[look.severity] || NEWS_SEVERITY[0]).color; + + roundRect(ctx, 0.5, 0.5, CARD_W - 1, CARD_H - 1, WORLD_OVERLAY_STYLE.radius); + ctx.fillStyle = WORLD_OVERLAY_STYLE.selectedBackground; + ctx.fill(); + ctx.strokeStyle = WORLD_OVERLAY_STYLE.border; + ctx.lineWidth = 1; + ctx.stroke(); + // Severity down the left edge, where Breaking marks a Live report. + ctx.save(); + roundRect(ctx, 0.5, 0.5, CARD_W - 1, CARD_H - 1, WORLD_OVERLAY_STYLE.radius); + ctx.clip(); + ctx.fillStyle = color; + ctx.fillRect(0, 0, EDGE, CARD_H); + ctx.restore(); + + const px = PAD + EDGE; + const pw = CARD_W - px - PAD; + ctx.save(); + roundRect(ctx, px, PAD, pw, PIC_H, 3); + ctx.clip(); + if (pic?.state === 'ok') { + try { + drawCover(ctx, pic.image, px, PAD, pw, PIC_H); + } catch { + pic.state = 'failed'; + } + } + if (pic?.state !== 'ok') { + // No picture: a tile in the severity colour with the category icon. + const g = ctx.createLinearGradient(px, PAD, px + pw, PAD + PIC_H); + g.addColorStop(0, WORLD_OVERLAY_STYLE.background); + g.addColorStop(1, color); + ctx.fillStyle = g; + ctx.globalAlpha = 0.55; + ctx.fillRect(px, PAD, pw, PIC_H); + ctx.globalAlpha = 1; + paintMarker(ctx, look, px + pw / 2, PAD + PIC_H / 2, 34); + } + ctx.restore(); + // The event's marker as a badge, so the card reads with the map. + if (pic?.state === 'ok') paintMarker(ctx, look, px + 13, PAD + 13, 18); + // The picture's publisher, always named on it. + if (pic?.state === 'ok' && view.credit) { + ctx.font = CREDIT_FONT; + const w = Math.min(pw - 30, ctx.measureText(view.credit).width + 8); + ctx.fillStyle = 'rgba(0, 0, 0, 0.6)'; + ctx.fillRect(px + pw - w, PAD + PIC_H - 12, w, 12); + ctx.fillStyle = WORLD_OVERLAY_STYLE.title; + ctx.textBaseline = 'middle'; + ctx.fillText( + wrapLines(ctx, view.credit, w - 8, 1)[0] || '', + px + pw - w + 4, + PAD + PIC_H - 6, + ); + } + + ctx.textBaseline = 'alphabetic'; + ctx.fillStyle = WORLD_OVERLAY_STYLE.title; + ctx.font = TITLE_FONT; + wrapLines(ctx, view.title, pw, 2).forEach((line, i) => + ctx.fillText(line, px, PAD + PIC_H + 14 + i * 13), + ); + ctx.fillStyle = WORLD_OVERLAY_STYLE.detail; + ctx.font = META_FONT; + ctx.fillText(wrapLines(ctx, view.meta, pw, 1)[0] || '', px, CARD_H - PAD - 2); +} + +/** + * The canvas of an event's card. `onChange` runs when its picture arrives and + * the canvas has been repainted (ask the scene for a frame). + * @returns {HTMLCanvasElement|null} null where there is no canvas + */ +export function eventCard(id, view, look, onChange) { + const key = `${id}|${look.severity}|${look.category}|${look.rim}|${view.image || ''}|${view.title}|${view.meta}`; + if (cards.has(key)) return cards.get(key); + const canvas = globalThis.document?.createElement?.('canvas'); + if ( + !canvas?.getContext?.('2d') || + typeof globalThis.Path2D !== 'function' || + typeof globalThis.Image !== 'function' + ) + return null; + canvas.width = CARD_W * SCALE; + canvas.height = CARD_H * SCALE; + const pic = view.image + ? picture(view.image, () => { + paint(canvas, view, look, pic); + onChange?.(); + }) + : null; + paint(canvas, view, look, pic); + // Old cards go once there are many; the newest keys win. + if (cards.size >= MAX_CARDS) cards.delete(cards.keys().next().value); + cards.set(key, canvas); + return canvas; +} diff --git a/src/layers/news/model.js b/src/layers/news/model.js new file mode 100644 index 000000000..41fce5ab4 --- /dev/null +++ b/src/layers/news/model.js @@ -0,0 +1,643 @@ +// Pure presentation model for the news layer: severity, category glyphs, +// attestation, marker looks and placement, the feed's filters and paging, and +// the event cards the News panel and the globe show. No Cesium, no DOM. + +import { getLocale, msg, t, tn } from '../../i18n/index.js'; + +export const NEWS_LAYER_ID = 'news'; +export const NEWS_OVERLAY_SOURCE_ID = 'news'; +export const NEWS_PICK_PREFIX = 'news:'; +export const NEWS_FEED_PAGE = 60; +export const NEWS_MAP_LIMIT = 500; +/** News is real time only: nothing older than a day is shown anywhere. */ +export const NEWS_WINDOW_MS = 24 * 3_600_000; +// Above this camera height the layer shows one beacon per hotspot; below it +// (or once a hotspot is chosen) the outlines and individual reports. +export const NEWS_DETAIL_MAX_HEIGHT_M = 2_500_000; +// "In view": reports near the view centre, only once the camera is low enough +// for "here" to mean a region, read again when it settles in a new cell. +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; +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. */ +export const NEWS_FOCUS_HEIGHT_M = 150_000; +// Minor news (a broad outlet's prices, sport, health) is left out unless asked for. +export const NEWS_RELEVANT = 'core,related'; + +const SOURCE_LABEL = msg('Source'); +const DISPUTED = msg('Disputed: {name}'); +const DISPUTE_TEXT = { + held: msg('Administered by {holder} · claimed by {claimant}'), + self: msg('Self-administered · claimed by {claimant}'), + un: msg('UN-patrolled zone'), + unclaimed: msg('Claimed by neither neighbour'), +}; +const MEDIA_LABELS = { video: msg('Video'), photo: msg('Photos') }; +const CLAIMED_BY = msg('Claimed by {who}'); +const CLAIMED = msg('Claimed'); +const UNCONFIRMED = msg('Not yet confirmed by others'); +const REPORTED_BY_SIDE = msg('Reported by {side} sources'); +const REPORTED_BY_SIDES = msg('Reported by {sides}'); +const SIDE_SOURCES = msg('{side} sources'); +const OFFICIAL = msg('Official'); +const OFFICIAL_AFFILIATIONS = new Set(['military', 'state']); +const ATTESTATION = { + 'both-sides': msg('Reported by both sides'), + independent: msg('Independently reported'), + single: msg('Single source'), +}; +const NEUTRAL_SIDES = Object.freeze({ + intl: msg('International'), + independent: msg('Independent'), + unknown: msg('Unattributed'), + external: msg('Outside party'), +}); + +/** + * Short chip names for the curated hotspots; the API's own English name is + * the fallback for any other. + */ +export const NEWS_HOTSPOT_LABELS = Object.freeze({ + 'ua-ru-war': msg('Ukraine–Russia'), + 'israel-gaza-lebanon': msg('Israel–Gaza–Lebanon'), + 'iran-hormuz': msg('Iran–Hormuz'), + yemen: msg('Yemen–Red Sea'), + 'sudan-war': msg('Sudan'), + sahel: msg('Sahel'), + elsewhere: msg('Elsewhere'), +}); + +/** A hotspot's chip label in the interface language. */ +export function hotspotLabel(hotspot) { + const key = NEWS_HOTSPOT_LABELS[hotspot?.id]; + if (key) return t(key); + return hotspot?.catchAll ? t(NEWS_HOTSPOT_LABELS.elsewhere) : hotspot?.name; +} + +/** A country's name in the interface language, from its ISO code. */ +export function countryName(code) { + if (!code) return ''; + try { + return ( + new Intl.DisplayNames([getLocale(), 'en'], { type: 'region' }).of(code) || + code + ); + } catch { + return code; + } +} + +/** Label for a disputed area: its name, who administers it, who claims it. */ +export function disputedLabel(area) { + return { + title: t(DISPUTED, { name: area.name }), + detail: t(DISPUTE_TEXT[area.kind] || DISPUTE_TEXT.held, { + holder: countryName(area.heldBy), + claimant: countryName(area.claimedBy), + }), + }; +} + +// A category is told by its icon (a 16-unit SVG path, filled or stroked). On +// the map and in the feed colour is severity (NEWS_SEVERITY), never the type +// of event or a side. +export const NEWS_CATEGORIES = Object.freeze({ + strike: { + label: msg('Strike'), + icon: 'M8 0.8 L9.6 5.2 L14.2 3.6 L11.2 7.6 L15.2 10.6 L10.3 10.6 L8 15.2 L5.7 10.6 L0.8 10.6 L4.8 7.6 L1.8 3.6 L6.4 5.2 Z', + solid: true, + }, + // A threat on its way (a drone's course, an air-raid alert): nothing has + // hit yet. Not a physical event, so never a picture card. + alert: { + label: msg('Alert'), + icon: 'M1.5 8 H11.5 M8 4.5 L11.5 8 L8 11.5 M14.5 3.5 V12.5', + }, + 'air-defence': { + label: msg('Air defence'), + icon: 'M8 1.5 L13.5 3.5 V8 C13.5 11.3 10.8 13.4 8 14.6 C5.2 13.4 2.5 11.3 2.5 8 V3.5 Z M8 4.5 V11', + }, + ground: { + label: msg('Ground fighting'), + icon: 'M3 13 L12 4 M8.5 3.5 H12.5 V7.5 M13 13 L4 4 M3.5 7.5 V3.5 H7.5', + }, + casualties: { + label: msg('Casualties'), + icon: 'M6.2 2 H9.8 V6.2 H14 V9.8 H9.8 V14 H6.2 V9.8 H2 V6.2 H6.2 Z', + solid: true, + }, + humanitarian: { + label: msg('Humanitarian'), + icon: 'M8 2.5 L14.5 13.5 H1.5 Z M8 2.5 V13.5', + }, + infrastructure: { + label: msg('Infrastructure'), + icon: 'M9.2 1 L3.2 9 H7.6 L6.6 15 L12.8 6.8 H8.4 Z', + solid: true, + }, + disaster: { + label: msg('Disaster'), + icon: 'M1.5 6.5 C3.5 4.5 5.5 4.5 7.5 6.5 S11.5 8.5 14.5 6.5 M1.5 10.5 C3.5 8.5 5.5 8.5 7.5 10.5 S11.5 12.5 14.5 10.5', + }, + protest: { + label: msg('Protest'), + icon: 'M2.5 6.2 V9.8 H5 L12.5 13.2 V2.8 L5 6.2 Z', + }, + political: { + label: msg('Political'), + icon: 'M2 14 H14 M2.5 5.5 H13.5 M8 1.8 L14 4.8 H2 Z M4.2 6.5 V12 M8 6.5 V12 M11.8 6.5 V12', + }, + other: { + label: msg('Other'), + icon: 'M8 4.5 A3.5 3.5 0 1 0 8.01 4.5 Z', + solid: true, + }, +}); + +// Severity 0–3. Lightness falls as severity rises, so the ramp still reads +// under colour blindness and in greyscale. +export const NEWS_SEVERITY = Object.freeze([ + { label: msg('No physical event'), color: '#8a9aa8' }, + { label: msg('Minor incident'), color: '#f2d64b' }, + { label: msg('Significant'), color: '#f28c28' }, + { label: msg('Major'), color: '#d7301f' }, +]); +/** The feed's default floor: significant and major events. */ +export const NEWS_DEFAULT_MIN_SEVERITY = 2; +const PHYSICAL = new Set([ + 'strike', + 'air-defence', + 'ground', + 'casualties', + 'infrastructure', + 'disaster', +]); + +export const categoryStyle = (category) => + NEWS_CATEGORIES[category] || NEWS_CATEGORIES.other; +export const categoryLabel = (category) => t(categoryStyle(category).label); +/** Party codes (UA, RU, IL…) are shown as-is; neutral sides are translated. */ +export const sideLabel = (side) => + Object.hasOwn(NEUTRAL_SIDES, side) ? t(NEUTRAL_SIDES[side]) : side; + +/** + * A report's severity, 0–3: the server's (the local model's reading, else + * its rules, raised to its event's), or a fallback from the category. + */ +export function reportSeverity(report) { + const own = Number.isInteger(report?.severity) ? report.severity : null; + const event = Number.isInteger(report?.event?.severity) + ? report.event.severity + : null; + if (own === null && event === null) + return PHYSICAL.has(report?.category) ? 1 : 0; + return Math.max(own ?? 0, event ?? 0); +} +export const severityStyle = (report) => NEWS_SEVERITY[reportSeverity(report)]; +export const severityLabel = (report) => t(severityStyle(report).label); + +/** When a report (or its event) was last heard of, in ms; NaN when unknown. */ +const lastHeard = (report) => + Date.parse(report?.event?.lastAt || report?.publishedAt || ''); + +/** Whether a report is from the last 24 hours (the only news shown). */ +export function withinNewsWindow(report, now = Date.now()) { + const at = Date.parse(report?.publishedAt || ''); + return Number.isFinite(at) && now - at <= NEWS_WINDOW_MS; +} + +/** + * How a report or its event is attested, as a chip: `claimed` (someone's + * claim, named where known), `confirmed` (both sides, or an independent + * source), `side` (one party's own press), `single` (one report), or null. + */ +export function attestation(report) { + const event = report?.event; + const status = event?.status || (report?.claim ? 'claimed' : null); + if (status === 'claimed') { + const who = event?.claimedBy || report?.claim?.by; + return { + kind: 'claimed', + label: who ? t(CLAIMED_BY, { who }) : t(CLAIMED), + }; + } + if (status === 'both-sides' || status === 'independent') + return { kind: 'confirmed', label: t(ATTESTATION[status]) }; + if (status === 'side' && event.reportedSide) + return { + kind: 'side', + label: t(REPORTED_BY_SIDE, { side: sideLabel(event.reportedSide) }), + }; + if (status === 'single') + return { kind: 'single', label: t(ATTESTATION.single) }; + return null; +} + +/** + * The attestation chip under a report. An official channel's own post is + * already marked Official: "Claimed by" itself would read as doubt about an + * alert. It says only that nobody else has reported it yet, and nothing for + * an alert, which is the channel's own notice. + */ +function ownClaimChip(report) { + const chip = attestation(report); + if ( + chip?.kind !== 'claimed' || + !OFFICIAL_AFFILIATIONS.has(report.affiliation) + ) + return chip; + const who = report.event?.claimedBy || report.claim?.by; + if (who && who !== report.sourceName && who !== report.publisher) return chip; + return report.category === 'alert' + ? null + : { kind: 'claimed', label: t(UNCONFIRMED) }; +} + +// The map card is a label, not a reader: long headlines are cut. +export const NEWS_CARD_TITLE_MAX = 90; + +/** A headline cut at a word to fit `max` characters. */ +export function shortTitle(title, max = NEWS_CARD_TITLE_MAX) { + const text = String(title || ''); + if (text.length <= max) return text; + const cut = text.slice(0, max - 1); + const word = cut.lastIndexOf(' '); + return `${word > max * 0.6 ? cut.slice(0, word) : cut}…`; +} + +/** The media a post carries ("Video", "Photos"), translated. */ +export function mediaLabels(report) { + return (report?.media || []) + .map((m) => (MEDIA_LABELS[m] ? t(MEDIA_LABELS[m]) : '')) + .filter(Boolean); +} + +/** + * How long ago, in few words: "now" under a minute, "N min" under an hour, + * "N h" under a day. Older is never shown; it reads + * "24 h". + */ +export function ageWords(at, now = Date.now()) { + const minutes = Math.max(0, Math.floor((now - Date.parse(at)) / 60_000)); + if (!Number.isFinite(minutes)) return ''; + if (minutes < 1) return t('now', { context: 'age' }); + if (minutes < 60) return t('{minutes} min', { minutes }); + return t('{hours} h', { hours: Math.min(24, Math.floor(minutes / 60)) }); +} + +/** + * How a report's marker looks: fill = severity, icon = category, rim = + * attestation (`confirmed` solid, `claimed` dashed, else thin), size = + * sources, opacity = age (fading to 45 % over a day). + */ +export function markerStyle(report, now = Date.now()) { + const kind = attestation(report)?.kind; + const at = lastHeard(report); + const hours = Number.isFinite(at) ? Math.max(0, (now - at) / 3_600_000) : 0; + return { + category: Object.hasOwn(NEWS_CATEGORIES, report?.category) + ? report.category + : 'other', + severity: reportSeverity(report), + rim: kind === 'confirmed' || kind === 'claimed' ? kind : 'thin', + size: eventPixelSize(report?.event?.size), + alpha: Math.max(0.45, 1 - (0.55 * Math.min(hours, 24)) / 24), + }; +} + +function hash(text) { + let h = 2166136261; + for (let i = 0; i < text.length; i++) { + h ^= text.charCodeAt(i); + h = Math.imul(h, 16777619); + } + return h >>> 0; +} + +/** + * Trend glyph for a hotspot: ▲/▼ when the last 24 h differ from the 24 h + * before by more than a fifth, nothing when flat or without a prior day. + */ +export function hotspotTrend(last24h, prev24h) { + if (!(prev24h > 0)) return ''; + if (last24h > prev24h * 1.2) return '▲'; + if (last24h < prev24h * 0.8) return '▼'; + return ''; +} + +/** Beacon size in pixels: grows with the square root of daily activity. */ +export const beaconPixelSize = (last24h) => + Math.round(Math.min(26, 10 + Math.sqrt(Math.max(0, last24h)) * 1.4)); + +/** Marker size for an event: grows with the number of distinct sources. */ +export function eventPixelSize(sources = 1) { + return Math.round( + Math.min(30, 16 + 3.5 * Math.log2(Math.max(1, Number(sources) || 1))), + ); +} + +// An event several sources report within the last few hours gets a halo. +export const NEWS_PRESSING_SOURCES = 5; +const PRESSING_MS = 3 * 3_600_000; + +/** Whether an event is fresh and widely reported, or fresh and major. */ +export function isPressing(report, now = Date.now()) { + const event = report?.event; + const fresh = now - lastHeard(report) <= PRESSING_MS; + return Boolean( + fresh && + ((event && event.size >= NEWS_PRESSING_SOURCES) || + reportSeverity(report) === 3), + ); +} + +// The big events stand as picture cards over their marker: several +// sources, and major or pressing. +export const NEWS_CARD_LIMIT = 10; +export const NEWS_CARD_MIN_SOURCES = 2; + +/** Whether an event gets a card on the map: physical events only, not statements. */ +export function isCardEvent(report, now = Date.now()) { + return Boolean( + report?.event?.size >= NEWS_CARD_MIN_SOURCES && + PHYSICAL.has(report.category) && + (reportSeverity(report) === 3 || isPressing(report, now)), + ); +} + +/** The card events among `reports`, most severe, most reported and newest first. */ +export function cardEvents(reports, now = Date.now(), limit = NEWS_CARD_LIMIT) { + return reports + .filter((r) => r.pins?.length && isCardEvent(r, now)) + .sort( + (a, b) => + reportSeverity(b) - reportSeverity(a) || + b.event.size - a.event.size || + (b.event.lastAt || '').localeCompare(a.event.lastAt || ''), + ) + .slice(0, limit); +} + +/** The distinct sides an event was reported by, lead first, translated. */ +function eventSides(report) { + const sides = (report?.event?.sides || []).map((s) => s.side); + return [...new Set(sides.length ? sides : [report?.side].filter(Boolean))]; +} + +/** "Reported by UA sources · RU sources", or '' when no side is known. */ +export function sidesLine(report) { + const sides = eventSides(report).filter((side) => side !== 'unknown'); + if (!sides.length) return ''; + if (sides.length === 1) + return t(REPORTED_BY_SIDE, { side: sideLabel(sides[0]) }); + return t(REPORTED_BY_SIDES, { + sides: sides + .map((side) => t(SIDE_SOURCES, { side: sideLabel(side) })) + .join(' · '), + }); +} + +/** The event's picture for a card: two or more sources, and the publisher named. */ +export function eventPicture(report) { + const event = report?.event; + if (!event?.image || event.size < NEWS_CARD_MIN_SOURCES) return null; + return { url: event.image.url, credit: event.image.publisher || '' }; +} + +/** What an event's picture card on the globe shows. */ +export function cardView(report, now = Date.now()) { + const event = report.event; + const sides = eventSides(report).map(sideLabel); + const picture = eventPicture(report); + return { + title: report.titleEn || report.title, + meta: [ + ageWords(event?.lastAt || report.publishedAt, now), + tn(event?.size || 1, '{count} source', '{count} sources'), + sides.slice(0, 3).join(', ') + (sides.length > 3 ? '…' : ''), + ] + .filter(Boolean) + .join(' · '), + image: picture?.url || null, + credit: picture?.credit || '', + }; +} + +/** "Source: host ↗" line for a report's card; the card opens the article. */ +export function sourceLinkLabel(url) { + let host = ''; + try { + host = new URL(url).hostname.replace(/^www\./, ''); + } catch { + return ''; + } + return `${t(SOURCE_LABEL)}: ${host} ↗`; +} + +/** The places a report is pinned at, joined, or null. */ +function placeLine(report) { + return ( + (report.pins?.length ? report.pins : [report.geo]) + .map((p) => p?.place) + .filter(Boolean) + .join(' · ') || null + ); +} + +/** + * One event card in the News panel: picture, + * headline, a mono meta line (severity, category, place, sources, age), the + * sides line, then attestation and media chips. Labels and colours are + * resolved here so the panel imports no layer code. + */ +export function feedCardView(report, now = Date.now()) { + const severity = reportSeverity(report); + const style = categoryStyle(report.category); + const size = report.event?.size || 1; + const translated = Boolean(report.titleEn && report.titleEn !== report.title); + return { + id: report.id, + url: report.url, + title: translated ? report.titleEn : report.title, + // The local model's English headline over a post in another language: + // the original stays beside it. + original: translated ? report.title : null, + snippet: report.snippet, + severity, + severityLabel: t(NEWS_SEVERITY[severity].label), + color: NEWS_SEVERITY[severity].color, + icon: style.icon, + solid: Boolean(style.solid), + category: categoryLabel(report.category), + place: placeLine(report), + placedByModel: Boolean(report.geo?.byModel), + mappable: Boolean(report.pins?.length), + sources: tn(size, '{count} source', '{count} sources'), + age: ageWords(report.event?.lastAt || report.publishedAt, now), + publisher: report.publisher, + sides: sidesLine(report), + sourceLink: sourceLinkLabel(report.url), + picture: eventPicture(report), + chips: [ + // An official channel speaking for itself (a ministry, the armed + // forces, a press service), not an outlet that quotes one. + OFFICIAL_AFFILIATIONS.has(report.affiliation) + ? { kind: 'official', label: t(OFFICIAL) } + : null, + ownClaimChip(report), + ...mediaLabels(report).map((label) => ({ kind: 'media', label })), + ].filter(Boolean), + // The event's first report from each other side, under the lead. + others: (report.event?.sides || []).slice(1).map((s) => ({ + side: sideLabel(s.side), + publisher: s.publisher, + title: s.title, + url: s.url, + })), + }; +} + +/** + * Marker position for a report. Many reports name the same city and would + * stack on one centroid, so each is spread on a small deterministic ring + * (about 1.5 km for a place, 6 km for a region, 28 km for a country) to stay + * individually clickable. The spread is display-only; cards show the place. + */ +export function reportMarkerPosition(report, pin = 0) { + const { lat, lon, precision } = report.pins?.[pin] || report.geo; + const h = hash(pin ? `${report.id}~${pin}` : report.id); + const angle = ((h % 3600) / 3600) * 2 * Math.PI; + const radiusDeg = + (precision === 'country' ? 0.25 : precision === 'region' ? 0.055 : 0.014) * + (0.35 + ((h >>> 12) % 65) / 100); + return { + lat: lat + radiusDeg * Math.sin(angle), + lon: lon + (radiusDeg * Math.cos(angle)) / Math.cos((lat * Math.PI) / 180), + }; +} + +/** + * Merge a page into the feed, newest first, one row per report id. + * `mode` 'head' refreshes the top and keeps the older cursor; 'append' adds an + * older page and takes its cursor; 'replace' starts over. + */ +export function mergeFeedPage(feed, page, mode) { + const byId = new Map(); + const items = + mode === 'replace' + ? page.reports + : mode === 'append' + ? [...feed.items, ...page.reports] + : [...page.reports, ...feed.items]; + for (const item of items) if (!byId.has(item.id)) byId.set(item.id, item); + return { + items: [...byId.values()].sort((a, b) => + b.publishedAt.localeCompare(a.publishedAt), + ), + nextBefore: + mode === 'head' && feed.items.length ? feed.nextBefore : page.nextBefore, + }; +} + +/** Normalised filters; anything unknown falls back to its default. */ +export function normalizeFilters(next = {}, hotspots = [], previous = {}) { + const merged = { ...previous, ...next }; + const hotspot = hotspots.some((h) => h.id === merged.hotspot) + ? merged.hotspot + : 'all'; + const category = Object.hasOwn(NEWS_CATEGORIES, merged.category) + ? merged.category + : 'all'; + const minSeverity = + Number.isInteger(merged.minSeverity) && + merged.minSeverity >= 0 && + merged.minSeverity <= 3 + ? merged.minSeverity + : NEWS_DEFAULT_MIN_SEVERITY; + return { + hotspot, + category, + minSeverity, + includeMinor: merged.includeMinor === true, + // Event cards on the map; off leaves only the clicked event's card. + cards: merged.cards !== false, + }; +} + +/** Whether a report passes the filters the server does not apply. */ +export function passesFilters(report, filters, now = Date.now()) { + return ( + withinNewsWindow(report, now) && + reportSeverity(report) >= filters.minSeverity + ); +} + +/** Whether a report belongs on the map under the current filters. */ +export function visibleOnMap(report, filters, now = Date.now()) { + return ( + (filters.hotspot === 'all' || + (report.hotspotIds || [report.hotspotId]).includes(filters.hotspot)) && + (filters.category === 'all' || report.category === filters.category) && + passesFilters(report, filters, now) + ); +} + +/** API query for the feed under the current filters: always the last 24 h. */ +export function feedQueryFor(filters, extra = {}) { + return { + events: true, + today: true, + relevance: filters.includeMinor ? '' : NEWS_RELEVANT, + hotspot: filters.hotspot, + category: filters.category === 'all' ? '' : filters.category, + limit: NEWS_FEED_PAGE, + ...extra, + }; +} + +/** The half-degree cell a point lies in, as a key. */ +export function viewCell({ lat, lon }) { + return `${Math.floor(lat / NEWS_CELL_DEG)}:${Math.floor(lon / NEWS_CELL_DEG)}`; +} + +/** Great-circle distance in kilometres. */ +export function distanceKm(a, b) { + const rad = Math.PI / 180; + const dLat = (b.lat - a.lat) * rad; + const dLon = (b.lon - a.lon) * rad; + const s = + Math.sin(dLat / 2) ** 2 + + Math.cos(a.lat * rad) * Math.cos(b.lat * rad) * Math.sin(dLon / 2) ** 2; + return 2 * 6371 * Math.asin(Math.min(1, Math.sqrt(s))); +} + +/** + * The reports near `centre` (any pin within NEWS_IN_VIEW_RADIUS_KM) that pass + * the filters, most severe and newest first. + */ +export function reportsInView( + reports, + centre, + filters, + now = Date.now(), + limit = NEWS_IN_VIEW_LIMIT, +) { + if (!centre) return []; + return reports + .filter( + (r) => + passesFilters(r, filters, now) && + (filters.category === 'all' || r.category === filters.category) && + r.pins?.some( + (pin) => distanceKm(pin, centre) <= NEWS_IN_VIEW_RADIUS_KM, + ), + ) + .sort( + (a, b) => + reportSeverity(b) - reportSeverity(a) || + b.publishedAt.localeCompare(a.publishedAt), + ) + .slice(0, limit); +} diff --git a/src/layers/news/model.test.mjs b/src/layers/news/model.test.mjs new file mode 100644 index 000000000..39492cc41 --- /dev/null +++ b/src/layers/news/model.test.mjs @@ -0,0 +1,444 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + normalizeFeedPayload, + normalizeHotspotsPayload, + normalizeOutlinePayload, +} from './records.js'; +import { + NEWS_IN_VIEW_RADIUS_KM, + ageWords, + beaconPixelSize, + cardEvents, + cardView, + distanceKm, + eventPixelSize, + feedCardView, + feedQueryFor, + hotspotLabel, + hotspotTrend, + isPressing, + markerStyle, + mergeFeedPage, + normalizeFilters, + passesFilters, + reportMarkerPosition, + reportSeverity, + reportsInView, + sidesLine, + sourceLinkLabel, + viewCell, + visibleOnMap, + withinNewsWindow, +} from './model.js'; +import { newsFeedQuery } from './source.js'; + +const NOW = Date.parse('2026-10-28T10:00:00Z'); +const report = (id, publishedAt, extra = {}) => ({ + id, + title: `Report ${id}`, + url: `https://example.com/${id}`, + publishedAt, + publisher: 'Example', + side: 'UA', + category: 'strike', + hotspotIds: ['ua'], + geo: { + lat: 50, + lon: 36, + place: 'Kharkiv', + precision: 'city', + mappable: true, + }, + pins: [ + { lat: 50, lon: 36, place: 'Kharkiv', precision: 'city', mappable: true }, + ], + ...extra, +}); +const event = (extra = {}) => ({ + status: 'both-sides', + size: 6, + severity: 3, + lastAt: '2026-10-28T09:30:00Z', + sides: [ + { side: 'UA', title: 'a', url: 'https://a.test/', publisher: 'A' }, + { side: 'RU', title: 'b', url: 'https://b.test/', publisher: 'B' }, + ], + image: { url: 'https://a.test/p.jpg', publisher: 'A' }, + ...extra, +}); + +test('payloads are validated record by record; empty and malformed payloads', () => { + const hotspots = normalizeHotspotsPayload({ + hotspots: [ + { + id: 'ua', + name: 'UA', + bbox: [22, 44, 42, 56], + parties: ['UA', 'RU', ''], + counts: { last24h: 3 }, + }, + { id: 'bad', name: 'Bad', bbox: [42, 44, 22, 56] }, + { id: '../x', name: 'Bad id', bbox: [22, 44, 42, 56] }, + ], + ingest: { lastRunAt: '2026-10-28T10:00:00Z' }, + }); + assert.deepEqual( + hotspots.hotspots.map((h) => [h.id, h.parties, h.last24h]), + [['ua', ['UA', 'RU'], 3]], + ); + assert.equal(hotspots.lastRunAt, '2026-10-28T10:00:00.000Z'); + assert.equal(normalizeHotspotsPayload({}), null); + assert.equal(normalizeHotspotsPayload(null), null); + assert.deepEqual(normalizeHotspotsPayload({ hotspots: [] }).hotspots, []); + + const feed = normalizeFeedPayload({ + reports: [ + { + id: 'r_1', + title: ' Strike on Kharkiv ', + url: 'https://x.test/a', + publishedAt: '2026-10-28T09:00:00Z', + geo: { lat: 50, lon: 36, mappable: true }, + media: ['video', 'video', 'audio'], + event: { size: 3, image: { url: 'http://x.test/p.jpg' } }, + }, + { + id: 'r_2', + title: 'Bad link', + url: 'javascript:alert(1)', + publishedAt: '2026-10-28T09:00:00Z', + }, + { + id: 'r_3', + title: 'Bad geo', + url: 'https://user:pw@x.test/c', + publishedAt: '2026-10-28T09:00:00Z', + }, + { + id: 'r_4', + title: 'Off the globe', + url: 'https://x.test/d', + publishedAt: '2026-10-28T09:00:00Z', + geo: { lat: 200, lon: 36 }, + }, + ], + nextBefore: '2026-10-28T08:00:00Z', + }); + assert.deepEqual( + feed.reports.map((r) => [r.id, r.title, Boolean(r.geo), r.pins.length]), + [ + ['r_1', 'Strike on Kharkiv', true, 1], + ['r_4', 'Off the globe', false, 0], + ], + ); + assert.deepEqual(feed.reports[0].media, ['video']); + // A picture must be HTTPS on the publisher's site. + assert.equal(feed.reports[0].event.image, null); + assert.equal(feed.nextBefore, '2026-10-28T08:00:00.000Z'); + assert.equal(normalizeFeedPayload({ reports: 'x' }), null); + assert.deepEqual(normalizeFeedPayload({ reports: [] }), { + reports: [], + nextBefore: null, + }); +}); + +test('outlines keep valid rings, borders and named disputed areas', () => { + const ring = [ + [30, 45], + [31, 45], + [31, 46], + ]; + const outline = normalizeOutlinePayload({ + polygons: [{ outer: ring, holes: [[[0, 0]]] }, { outer: [[1, 2]] }], + borders: [ring, 'x'], + disputed: [ + { + name: 'Crimea', + kind: 'held', + heldBy: 'RU', + claimedBy: 'UA', + rings: [ring], + }, + { name: 'No rings', rings: [] }, + { name: 'Odd kind', kind: 'zzz', heldBy: 'nope', rings: [ring] }, + ], + }); + assert.equal(outline.polygons.length, 1); + assert.deepEqual(outline.polygons[0].holes, []); + assert.equal(outline.borders.length, 1); + assert.deepEqual( + outline.disputed.map((d) => [d.name, d.kind, d.heldBy]), + [ + ['Crimea', 'held', 'RU'], + ['Odd kind', 'held', null], + ], + ); + assert.deepEqual(normalizeOutlinePayload(null), { + polygons: [], + borders: [], + disputed: [], + }); +}); + +test('feed pages merge newest first without duplicates', () => { + const a = report('a', '2026-10-28T10:00:00Z'); + const b = report('b', '2026-10-28T09:00:00Z'); + const c = report('c', '2026-10-28T08:00:00Z'); + let feed = mergeFeedPage( + { items: [] }, + { reports: [a, b], nextBefore: 'B' }, + 'replace', + ); + assert.deepEqual( + [feed.items.map((r) => r.id), feed.nextBefore], + [['a', 'b'], 'B'], + ); + feed = mergeFeedPage(feed, { reports: [b, c], nextBefore: null }, 'append'); + assert.deepEqual( + [feed.items.map((r) => r.id), feed.nextBefore], + [['a', 'b', 'c'], null], + ); + const newer = report('n', '2026-10-28T11:00:00Z'); + feed = mergeFeedPage( + { ...feed, nextBefore: 'C' }, + { reports: [newer, a], nextBefore: 'X' }, + 'head', + ); + assert.deepEqual( + [feed.items.map((r) => r.id), feed.nextBefore], + [['n', 'a', 'b', 'c'], 'C'], + ); + assert.deepEqual( + mergeFeedPage({ items: [] }, { reports: [], nextBefore: null }, 'replace'), + { items: [], nextBefore: null }, + ); +}); + +test('filters default to significant news and map to a last-24-hours query', () => { + const hotspots = [{ id: 'ua' }, { id: 'me' }]; + const filters = normalizeFilters( + { hotspot: 'ua', category: 'nope', minSeverity: 9 }, + hotspots, + ); + assert.deepEqual(filters, { + hotspot: 'ua', + category: 'all', + minSeverity: 2, + includeMinor: false, + cards: true, + }); + assert.equal(normalizeFilters({ cards: false }, hotspots).cards, false); + assert.equal(normalizeFilters({ minSeverity: 0 }, hotspots).minSeverity, 0); + assert.equal(normalizeFilters({ hotspot: 'gone' }, hotspots).hotspot, 'all'); + assert.equal( + newsFeedQuery( + feedQueryFor(filters, { before: '2026-10-28T08:00:00.000Z' }), + ), + '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/, + ); +}); + +test('only the last 24 hours pass, and the severity floor applies', () => { + const filters = normalizeFilters(); + const fresh = report('f', '2026-10-28T09:00:00Z', { severity: 2 }); + const minor = report('m', '2026-10-28T09:00:00Z', { severity: 1 }); + const old = report('o', '2026-10-27T09:00:00Z', { severity: 3 }); + assert.equal(withinNewsWindow(fresh, NOW), true); + assert.equal(withinNewsWindow(old, NOW), false); + assert.equal(withinNewsWindow({ publishedAt: 'x' }, NOW), false); + assert.deepEqual( + [fresh, minor, old] + .filter((r) => passesFilters(r, filters, NOW)) + .map((r) => r.id), + ['f'], + ); + assert.equal(visibleOnMap(minor, { ...filters, minSeverity: 0 }, NOW), true); + assert.equal(visibleOnMap(fresh, { ...filters, hotspot: 'me' }, NOW), false); +}); + +test('ageWords says now, minutes or hours', () => { + assert.equal(ageWords('2026-10-28T09:59:30Z', NOW), 'now'); + assert.equal(ageWords('2026-10-28T09:55:00Z', NOW), '5 min'); + assert.equal(ageWords('2026-10-28T07:10:00Z', NOW), '2 h'); + assert.equal(ageWords('2026-10-26T07:10:00Z', NOW), '24 h'); + assert.equal(ageWords('not a date', NOW), ''); +}); + +test('markers spread deterministically near their place; source link line', () => { + const r = report('r_spread', '2026-10-28T10:00:00Z'); + const p1 = reportMarkerPosition(r); + assert.deepEqual(reportMarkerPosition(r), p1); + assert.ok(Math.abs(p1.lat - 50) < 0.02 && Math.abs(p1.lon - 36) < 0.04); + assert.notDeepEqual( + reportMarkerPosition(report('r_other', r.publishedAt)), + p1, + ); + assert.equal( + sourceLinkLabel('https://www.reliefweb.int/report/x'), + 'Source: reliefweb.int ↗', + ); + assert.equal(sourceLinkLabel('not a url'), ''); +}); + +test('beacons carry a focus, a size by activity and a trend only against a prior day', () => { + const { hotspots } = normalizeHotspotsPayload({ + hotspots: [ + { + id: 'a', + name: 'A', + bbox: [20, 40, 40, 50], + focus: { lat: 45, lon: 30 }, + counts: { last24h: 120, prev24h: 80 }, + }, + { + id: 'b', + name: 'B', + bbox: [20, 40, 40, 50], + focus: { lat: 80, lon: 30 }, + }, + ], + }); + assert.deepEqual( + hotspots.map((h) => h.focus), + [ + { lat: 45, lon: 30 }, + { lat: 45, lon: 30 }, + ], + ); + assert.equal(hotspotTrend(120, 80), '▲'); + assert.equal(hotspotTrend(50, 80), '▼'); + assert.equal(hotspotTrend(85, 80), ''); + assert.equal(hotspotTrend(40, 0), ''); + assert.equal(beaconPixelSize(0), 10); + assert.equal(beaconPixelSize(10_000), 26); + assert.equal( + hotspotLabel({ id: 'ua-ru-war', name: 'Russia–Ukraine war' }), + 'Ukraine–Russia', + ); + assert.equal(hotspotLabel({ id: 'new-one', name: 'New one' }), 'New one'); + assert.equal( + hotspotLabel({ id: 'x', name: 'X', catchAll: true }), + 'Elsewhere', + ); +}); + +test('marker style: severity fill, attestation rim, size by sources, fading with age', () => { + const r = report('r_style', '2026-10-28T10:00:00Z', { + severity: 2, + event: event({ + status: 'claimed', + size: 4, + lastAt: '2026-10-28T10:00:00Z', + }), + }); + const fresh = markerStyle(r, NOW); + assert.equal(fresh.severity, 3); // the event's, when higher than the report's + assert.equal(fresh.rim, 'claimed'); + assert.equal(fresh.size, eventPixelSize(4)); + assert.equal(fresh.alpha, 1); + assert.equal(markerStyle(r, NOW + 48 * 3_600_000).alpha, 0.45); + assert.equal(reportSeverity({ category: 'political' }), 0); + assert.equal(reportSeverity({ category: 'strike' }), 1); +}); + +test('picture cards: physical, two or more sources, major or pressing, at most ten', () => { + const big = report('big', '2026-10-28T09:30:00Z', { event: event() }); + const statement = report('st', '2026-10-28T09:30:00Z', { + category: 'political', + event: event(), + }); + const lone = report('lone', '2026-10-28T09:30:00Z', { + event: event({ size: 1 }), + }); + const stale = report('stale', '2026-10-28T01:00:00Z', { + event: event({ severity: 2, lastAt: '2026-10-28T01:00:00Z' }), + }); + assert.equal(isPressing(big, NOW), true); + assert.deepEqual( + cardEvents([statement, lone, stale, big], NOW).map((r) => r.id), + ['big'], + ); + const many = Array.from({ length: 14 }, (_, i) => + report(`m${i}`, '2026-10-28T09:30:00Z', { event: event() }), + ); + assert.equal(cardEvents(many, NOW).length, 10); + const view = cardView(big, NOW); + assert.equal(view.image, 'https://a.test/p.jpg'); + assert.equal(view.credit, 'A'); + assert.equal(view.meta, '30 min · 6 sources · UA, RU'); + assert.equal(cardView(lone, NOW).image, null); +}); + +test('feed cards read like Breaking: headline, meta parts, sides and chips', () => { + const r = report('fc', '2026-10-28T09:00:00Z', { + title: 'Удар по Харкову', + titleEn: 'Strike on Kharkiv', + affiliation: 'military', + media: ['video', 'photo'], + geo: { lat: 50, lon: 36, place: 'Kharkiv', mappable: true, byModel: true }, + event: event({ lastAt: '2026-10-28T09:50:00Z' }), + }); + const view = feedCardView(r, NOW); + assert.equal(view.title, 'Strike on Kharkiv'); + assert.equal(view.original, 'Удар по Харкову'); + assert.equal(view.placedByModel, true); + assert.equal(view.severityLabel, 'Major'); + assert.equal(view.color, '#d7301f'); + assert.equal(view.category, 'Strike'); + assert.equal(view.place, 'Kharkiv'); + assert.equal(view.sources, '6 sources'); + assert.equal(view.age, '10 min'); + assert.equal(view.sides, 'Reported by UA sources · RU sources'); + assert.deepEqual(view.picture, { url: 'https://a.test/p.jpg', credit: 'A' }); + assert.deepEqual( + view.chips.map((c) => [c.kind, c.label]), + [ + ['official', 'Official'], + ['confirmed', 'Reported by both sides'], + ['media', 'Video'], + ['media', 'Photos'], + ], + ); + assert.deepEqual( + view.others.map((o) => o.side), + ['RU'], + ); + // A lone report: its own side, no picture, one source. + const single = feedCardView(report('s', '2026-10-28T09:00:00Z'), NOW); + assert.equal(single.sides, 'Reported by UA sources'); + assert.equal(single.picture, null); + assert.equal(single.original, null); + assert.equal(sidesLine({ side: 'unknown' }), ''); +}); + +test('in view: half-degree cells and reports within 500 km of the centre', () => { + assert.equal(viewCell({ lat: 50.2, lon: 36.7 }), '100:73'); + assert.equal(viewCell({ lat: 50.4, lon: 36.9 }), '100:73'); + assert.notEqual(viewCell({ lat: 50.6, lon: 36.7 }), '100:73'); + assert.ok( + Math.abs(distanceKm({ lat: 0, lon: 0 }, { lat: 0, lon: 1 }) - 111.2) < 0.5, + ); + const filters = normalizeFilters({ minSeverity: 0 }); + const near = report('near', '2026-10-28T09:00:00Z', { severity: 1 }); + const major = report('major', '2026-10-28T08:00:00Z', { severity: 3 }); + const far = report('far', '2026-10-28T09:00:00Z', { + pins: [{ lat: 31.5, lon: 34.4, place: 'Gaza', mappable: true }], + }); + const old = report('old', '2026-10-27T08:00:00Z'); + const centre = { lat: 49, lon: 36 }; + assert.ok(distanceKm(centre, far.pins[0]) > NEWS_IN_VIEW_RADIUS_KM); + assert.deepEqual( + reportsInView([near, far, old, major], centre, filters, NOW).map( + (r) => r.id, + ), + ['major', 'near'], + ); + assert.deepEqual(reportsInView([near], null, filters, NOW), []); + assert.deepEqual(reportsInView([], centre, filters, NOW), []); +}); diff --git a/src/layers/news/records.js b/src/layers/news/records.js new file mode 100644 index 000000000..266d17f7c --- /dev/null +++ b/src/layers/news/records.js @@ -0,0 +1,325 @@ +// Validate /api/news payloads before they reach the scene or the DOM. Every +// string is bounded, every URL is http(s), every position is finite; a record +// that fails is dropped, a payload that fails is rejected whole. A portable +// export (layers/news/source): no i18n here. + +const CATEGORY = /^[a-z][a-z-]{1,31}$/; +const CODE = /^[A-Za-z][A-Za-z0-9-]{0,31}$/; +const ID = /^[a-z0-9][a-z0-9_-]{0,63}$/i; +const EVENT_STATUSES = Object.freeze([ + 'both-sides', + 'independent', + 'claimed', + 'reported', + 'side', + 'single', +]); + +const text = (value, max) => + typeof value === 'string' && value.trim() + ? value.replace(/\s+/g, ' ').trim().slice(0, max) + : null; +const finite = (value, min, max) => + Number.isFinite(value) && value >= min && value <= max ? value : null; +const iso = (value) => { + const ms = Date.parse(value); + return Number.isFinite(ms) ? new Date(ms).toISOString() : null; +}; +const code = (value) => + typeof value === 'string' && CODE.test(value) ? value : null; +const severity = (value) => + Number.isInteger(value) && value >= 0 && value <= 3 ? value : null; + +/** An http(s) URL without credentials, as its normalised href, or null. */ +export function httpUrl(value) { + try { + const url = new URL(value); + return ['http:', 'https:'].includes(url.protocol) && + !url.username && + !url.password + ? url.href + : null; + } catch { + return null; + } +} + +function normalizeBbox(bbox) { + if (!Array.isArray(bbox) || bbox.length !== 4) return null; + const [west, south, east, north] = bbox; + if ( + finite(west, -180, 180) === null || + finite(east, -180, 180) === null || + finite(south, -90, 90) === null || + finite(north, -90, 90) === null || + west >= east || + south >= north + ) + return null; + return [west, south, east, north]; +} + +/** Beacon position: the curated focus point, else the bbox centre. */ +function normalizeFocus(focus, [west, south, east, north]) { + const lat = finite(focus?.lat, south, north); + const lon = finite(focus?.lon, west, east); + return lat !== null && lon !== null + ? { lat, lon } + : { lat: (south + north) / 2, lon: (west + east) / 2 }; +} + +function normalizeDeveloping(items) { + const out = []; + for (const raw of Array.isArray(items) ? items.slice(0, 16) : []) { + const lat = finite(raw?.focus?.lat, -90, 90); + const lon = finite(raw?.focus?.lon, -180, 180); + const country = + typeof raw?.country === 'string' && /^[A-Z]{2}$/.test(raw.country) + ? raw.country + : null; + if (lat === null || lon === null || !country) continue; + // A state where the catch-all flags states ("US-FL"), else the country. + const area = + typeof raw.area === 'string' && /^[A-Z]{2}-[A-Z0-9]{1,3}$/.test(raw.area) + ? raw.area + : null; + out.push({ + country, + key: area || country, + name: text(raw.name, 80) || country, + events: Math.max(0, Math.floor(Number(raw.events) || 0)), + reports: Math.max(0, Math.floor(Number(raw.reports) || 0)), + focus: { lat, lon }, + }); + } + return out; +} + +/** + * `/api/news/hotspots` → `{hotspots, categories, lastRunAt}`, or null when the + * payload has no hotspot list. + */ +export function normalizeHotspotsPayload(payload) { + if (!Array.isArray(payload?.hotspots)) return null; + const hotspots = []; + for (const raw of payload.hotspots.slice(0, 64)) { + const id = typeof raw?.id === 'string' && ID.test(raw.id) ? raw.id : null; + const name = text(raw?.name, 80); + const bbox = normalizeBbox(raw?.bbox); + if (!id || !name || !bbox) continue; + const counts = raw.counts || {}; + hotspots.push({ + id, + name, + kind: text(raw.kind, 16) || 'conflict', + catchAll: raw.catchAll === true, + developing: normalizeDeveloping(raw.developing), + bbox, + parties: Array.isArray(raw.parties) + ? raw.parties.filter((p) => code(p)) + : [], + last24h: Math.max(0, Math.floor(Number(counts.last24h) || 0)), + mapped24h: Math.max(0, Math.floor(Number(counts.mapped24h) || 0)), + prev24h: Number.isFinite(counts.prev24h) + ? Math.max(0, Math.floor(counts.prev24h)) + : null, + focus: normalizeFocus(raw.focus, bbox), + latestAt: iso(raw.latestAt), + }); + } + return { + hotspots, + categories: Array.isArray(payload.categories) + ? payload.categories.filter( + (c) => typeof c === 'string' && CATEGORY.test(c), + ) + : [], + lastRunAt: iso(payload.ingest?.lastRunAt), + }; +} + +function normalizeGeo(geo) { + if (!geo || typeof geo !== 'object') return null; + const lat = finite(geo.lat, -90, 90); + const lon = finite(geo.lon, -180, 180); + if (lat === null || lon === null) return null; + return { + lat, + lon, + place: text(geo.place, 80), + precision: text(geo.precision, 16) || 'city', + confidence: finite(geo.confidence, 0, 1) ?? 0, + mappable: geo.mappable === true, + byModel: geo.method === 'llm', + }; +} + +/** The event's picture: an HTTPS URL on the publisher's site, and whose it is. */ +function eventImage(image) { + const url = httpUrl(image?.url); + if (!url?.startsWith('https://')) return null; + return { url, publisher: text(image.publisher, 80) || '' }; +} + +/** An event summary from `/feed?events=1`: size and one report per side. */ +function normalizeEvent(event) { + if (!event || typeof event !== 'object') return null; + const count = (value) => Math.max(1, Math.floor(Number(value) || 1)); + const sides = []; + for (const side of Array.isArray(event.sides) + ? event.sides.slice(0, 8) + : []) { + const url = httpUrl(side?.url); + const title = text(side?.title, 300); + if (!url || !title) continue; + sides.push({ + side: code(side.side) || 'unknown', + title, + url, + publisher: text(side.publisher, 80) || '', + publishedAt: iso(side.publishedAt), + }); + } + return { + id: typeof event.id === 'string' && ID.test(event.id) ? event.id : null, + status: EVENT_STATUSES.includes(event.status) ? event.status : null, + claimedBy: text(event.claimedBy, 80), + reportedSide: code(event.reportedSide), + size: count(event.size), + reports: count(event.reports), + severity: severity(event.severity), + firstAt: iso(event.firstAt), + lastAt: iso(event.lastAt), + sides, + image: eventImage(event.image), + }; +} + +function normalizeReport(raw) { + const id = typeof raw?.id === 'string' && ID.test(raw.id) ? raw.id : null; + const title = text(raw?.title, 300); + const url = httpUrl(raw?.url); + const publishedAt = iso(raw?.publishedAt); + if (!id || !title || !url || !publishedAt) return null; + const source = raw.source || {}; + const geo = normalizeGeo(raw.geo); + // Map positions: the report's own place plus any other hotspot its title + // names (a roundup is pinned in each). + const pins = []; + for (const place of [ + geo, + ...(Array.isArray(raw.locations) + ? raw.locations.slice(0, 8).map(normalizeGeo) + : []), + ]) + if ( + place?.mappable && + !pins.some((p) => p.lat === place.lat && p.lon === place.lon) + ) + pins.push(place); + const hotspotId = typeof raw.hotspotId === 'string' ? raw.hotspotId : null; + return { + id, + hotspotId, + hotspotIds: Array.isArray(raw.hotspotIds) + ? raw.hotspotIds.filter((h) => typeof h === 'string').slice(0, 8) + : [hotspotId].filter(Boolean), + title, + url, + snippet: text(raw.snippet, 400), + publisher: text(raw.publisher, 80) || text(source.name, 80) || '', + sourceName: text(source.name, 80) || text(raw.publisher, 80) || '', + kind: text(raw.kind, 16) || 'rss', + side: code(raw.side) || 'unknown', + affiliation: text(raw.affiliation, 32), + attribution: text(source.attribution, 120), + category: + typeof raw.category === 'string' && CATEGORY.test(raw.category) + ? raw.category + : 'other', + publishedAt, + geo, + pins, + event: normalizeEvent(raw.event), + titleEn: text(raw.titleEn, 200), + relevance: ['core', 'related', 'minor'].includes(raw.relevance) + ? raw.relevance + : 'related', + severity: severity(raw.severity), + severityBy: raw.severityBy === 'model' ? 'model' : 'rules', + claim: + raw.claim && typeof raw.claim === 'object' + ? { by: text(raw.claim.by, 80) } + : null, + media: Array.isArray(raw.media) + ? [...new Set(raw.media.filter((m) => m === 'video' || m === 'photo'))] + : [], + }; +} + +function normalizeRing(ring) { + if (!Array.isArray(ring) || ring.length < 3) return null; + const points = []; + for (const point of ring.slice(0, 20_000)) { + const lon = finite(point?.[0], -180, 180); + const lat = finite(point?.[1], -90, 90); + if (lon !== null && lat !== null) points.push([lon, lat]); + } + return points.length >= 3 ? points : null; +} + +/** + * `/api/news/outline` → `{polygons, borders, disputed}`: the dissolved extent + * (`{outer, holes}`), the national borders inside it (rings) and its disputed + * areas, invalid parts dropped. + */ +export function normalizeOutlinePayload(payload) { + const polygons = []; + for (const raw of Array.isArray(payload?.polygons) + ? payload.polygons.slice(0, 200) + : []) { + const outer = normalizeRing(raw?.outer); + if (!outer) continue; + const holes = (Array.isArray(raw.holes) ? raw.holes.slice(0, 200) : []) + .map(normalizeRing) + .filter(Boolean); + polygons.push({ outer, holes }); + } + const borders = ( + Array.isArray(payload?.borders) ? payload.borders.slice(0, 50) : [] + ) + .map(normalizeRing) + .filter(Boolean); + const KINDS = ['held', 'self', 'un', 'unclaimed']; + const country = (value) => + typeof value === 'string' && /^[A-Z]{2}$/.test(value) ? value : null; + const disputed = []; + for (const raw of Array.isArray(payload?.disputed) + ? payload.disputed.slice(0, 30) + : []) { + const rings = (Array.isArray(raw?.rings) ? raw.rings.slice(0, 20) : []) + .map(normalizeRing) + .filter(Boolean); + const name = text(raw?.name, 60); + if (!rings.length || !name) continue; + disputed.push({ + name, + kind: KINDS.includes(raw.kind) ? raw.kind : 'held', + heldBy: country(raw.heldBy), + claimedBy: country(raw.claimedBy), + rings, + }); + } + return { polygons, borders, disputed }; +} + +/** `/api/news/feed` → `{reports, nextBefore}`, or null without a report list. */ +export function normalizeFeedPayload(payload) { + if (!Array.isArray(payload?.reports)) return null; + const reports = []; + for (const raw of payload.reports.slice(0, 500)) { + const report = normalizeReport(raw); + if (report) reports.push(report); + } + return { reports, nextBefore: iso(payload.nextBefore) }; +} diff --git a/src/layers/news/source.js b/src/layers/news/source.js new file mode 100644 index 000000000..8c739584b --- /dev/null +++ b/src/layers/news/source.js @@ -0,0 +1,162 @@ +import { readResponseJsonCapped } from '../../sources/httpBody.js'; +import { isUnavailableCapability } from '../../sources/capability.js'; +import { + normalizeFeedPayload, + normalizeHotspotsPayload, + normalizeOutlinePayload, +} from './records.js'; + +// A portable export (package.json): no i18n here. Failures stay English data; +// the layer shows them translated. +const msg = (text) => text; + +const MAX_BYTES = 4 * 1024 * 1024; +const BASE_PATH = '/api/news'; +// The outline geometry version the client draws (disputed areas included). +const OUTLINE_VERSION = '3'; + +/** The three read routes the news layer calls, for a host's path allowlist. */ +export const NEWS_API_PATHS = Object.freeze([ + `${BASE_PATH}/hotspots`, + `${BASE_PATH}/feed`, + `${BASE_PATH}/outline`, +]); + +const FEED_KEYS = Object.freeze([ + 'hotspot', + 'before', + 'since', + 'limit', + 'category', + 'relevance', +]); +const FLAG_KEYS = Object.freeze(['geo', 'today', 'events']); +const ISO = /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d{1,3})?Z$/; +const LIST = /^[a-z][a-z,-]{0,127}$/; +const FEED_VALUE = { + hotspot: (v) => /^[a-z0-9][a-z0-9_-]{0,63}$/i.test(v), + before: (v) => ISO.test(v), + since: (v) => ISO.test(v), + limit: (v) => /^\d{1,3}$/.test(v) && Number(v) >= 1 && Number(v) <= 500, + category: (v) => LIST.test(v), + relevance: (v) => LIST.test(v), +}; + +/** Feed query string from a plain object; unknown keys and empty values are dropped. */ +export function newsFeedQuery(query = {}) { + const params = new URLSearchParams(); + for (const key of FEED_KEYS) { + const value = query[key]; + if (value !== undefined && value !== null && value !== '') + params.set(key, String(value)); + } + for (const key of FLAG_KEYS) if (query[key]) params.set(key, '1'); + return params.toString(); +} + +/** + * Whether a request URL is one this client sends: a known route, known keys + * only, each value in its form. A host proxying the routes can admit exactly + * these and refuse the rest. + * @param {URL} url + */ +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}/outline`) { + const keys = params.map(([key]) => key).sort(); + const value = url.searchParams; + return ( + params.length === 2 && + keys[1] === 'v' && + value.get('v') === OUTLINE_VERSION && + ((keys[0] === 'hotspot' && FEED_VALUE.hotspot(value.get('hotspot'))) || + (keys[0] === 'country' && + /^[A-Z]{2}(?:-[A-Z0-9]{1,3})?$/.test(value.get('country')))) + ); + } + if (url.pathname !== `${BASE_PATH}/feed`) return false; + return params.every(([key, value]) => + FLAG_KEYS.includes(key) + ? value === '1' + : Object.hasOwn(FEED_VALUE, key) && FEED_VALUE[key](value), + ); +} + +/** A failed read: a server without the news routes is a permanent miss. */ +async function responseError(response) { + let body = null; + try { + body = await readResponseJsonCapped(response, 16 * 1024); + } catch { + await response.body?.cancel?.().catch?.(() => {}); + } + const unavailable = + response.status === 404 || + (response.status === 503 && isUnavailableCapability(body)); + return Object.assign( + new Error( + unavailable + ? msg('News feed not configured on this server') + : msg('News feed unavailable'), + ), + { + status: response.status, + code: unavailable ? 'unavailable' : 'http', + unavailable, + retryable: !unavailable, + }, + ); +} + +/** Read hotspots, reports and outlines from the server's /api/news routes. */ +export function createNewsSource({ + fetchImpl = (...args) => globalThis.fetch(...args), + timeoutMs = 15_000, +} = {}) { + async function get(path, { signal } = {}) { + signal?.throwIfAborted(); + const timeout = AbortSignal.timeout(timeoutMs); + const request = signal ? AbortSignal.any([signal, timeout]) : timeout; + const response = await fetchImpl(`${BASE_PATH}${path}`, { + signal: request, + cache: 'no-store', + redirect: 'error', + }); + if (!response.ok) throw await responseError(response); + return readResponseJsonCapped(response, MAX_BYTES, request); + } + return { + async getHotspots({ signal } = {}) { + const payload = normalizeHotspotsPayload( + await get('/hotspots', { signal }), + ); + if (!payload) throw new Error(msg('Malformed news hotspots response')); + return payload; + }, + async getOutline(hotspotId, { signal } = {}) { + return normalizeOutlinePayload( + await get( + `/outline?hotspot=${encodeURIComponent(hotspotId)}&v=${OUTLINE_VERSION}`, + { signal }, + ), + ); + }, + async getCountryOutline(country, { signal } = {}) { + return normalizeOutlinePayload( + await get( + `/outline?country=${encodeURIComponent(country)}&v=${OUTLINE_VERSION}`, + { signal }, + ), + ); + }, + async getFeed(query = {}, { signal } = {}) { + const payload = normalizeFeedPayload( + await get(`/feed?${newsFeedQuery(query)}`, { signal }), + ); + if (!payload) throw new Error(msg('Malformed news feed response')); + return payload; + }, + }; +} diff --git a/src/layers/news/source.test.mjs b/src/layers/news/source.test.mjs new file mode 100644 index 000000000..3f48093ea --- /dev/null +++ b/src/layers/news/source.test.mjs @@ -0,0 +1,164 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + NEWS_API_PATHS, + createNewsSource, + newsFeedQuery, + newsQueryAllowed, +} from './source.js'; + +const json = (body, status = 200) => + new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); + +function recorder(respond) { + const calls = []; + return { + calls, + fetchImpl: async (url, init) => { + calls.push({ url, init }); + return respond(url, init); + }, + }; +} + +test('reads validated hotspots, feed and outlines from the same-origin routes', async () => { + const { calls, fetchImpl } = recorder((url) => { + if (url.startsWith('/api/news/hotspots')) + return json({ + hotspots: [{ id: 'ua', name: 'UA', bbox: [22, 44, 42, 56] }], + }); + if (url.startsWith('/api/news/feed')) + return json({ + reports: [ + { + id: 'r1', + title: 'T', + url: 'https://x.test/', + publishedAt: '2026-10-28T09:00:00Z', + }, + ], + nextBefore: null, + }); + return json({ polygons: [], borders: [], disputed: [] }); + }); + const source = createNewsSource({ fetchImpl }); + assert.equal((await source.getHotspots()).hotspots[0].id, 'ua'); + assert.equal( + (await source.getFeed({ hotspot: 'all', today: true, limit: 60 })) + .reports[0].id, + 'r1', + ); + assert.deepEqual((await source.getOutline('ua')).polygons, []); + await source.getCountryOutline('SD'); + assert.deepEqual( + calls.map((c) => c.url), + [ + '/api/news/hotspots', + '/api/news/feed?hotspot=all&limit=60&today=1', + '/api/news/outline?hotspot=ua&v=3', + '/api/news/outline?country=SD&v=3', + ], + ); + for (const { url, init } of calls) { + assert.equal(init.redirect, 'error'); + assert.equal(init.cache, 'no-store'); + assert.ok(init.signal instanceof AbortSignal); + // Every URL the client sends is one a host's allowlist admits. + assert.equal(newsQueryAllowed(new URL(url, 'http://h')), true, url); + } +}); + +test('malformed, failed and unconfigured reads reject; a server without news is a permanent miss', async () => { + const malformed = createNewsSource({ fetchImpl: async () => json({}) }); + await assert.rejects(malformed.getHotspots(), /Malformed news hotspots/); + await assert.rejects(malformed.getFeed(), /Malformed news feed/); + + const down = createNewsSource({ + fetchImpl: async () => json({ error: 'upstream' }, 502), + }); + await assert.rejects(down.getHotspots(), (error) => { + assert.equal(error.message, 'News feed unavailable'); + assert.equal(error.retryable, true); + assert.equal(error.unavailable, false); + return true; + }); + + const unconfigured = createNewsSource({ + fetchImpl: async () => + json( + { error: 'news_not_configured', unavailable: true, retryable: false }, + 503, + ), + }); + await assert.rejects(unconfigured.getHotspots(), (error) => { + assert.equal(error.message, 'News feed not configured on this server'); + assert.equal(error.code, 'unavailable'); + assert.equal(error.unavailable, true); + assert.equal(error.retryable, false); + return true; + }); + const missing = createNewsSource({ + fetchImpl: async () => new Response('Not found', { status: 404 }), + }); + await assert.rejects(missing.getFeed(), { unavailable: true }); + const broken = createNewsSource({ + fetchImpl: async () => new Response('{', { status: 200 }), + }); + await assert.rejects(broken.getHotspots(), SyntaxError); +}); + +test('reads time out and follow the caller signal', async () => { + const hang = (_url, { signal }) => + new Promise((_, reject) => + signal.addEventListener('abort', () => reject(signal.reason), { + once: true, + }), + ); + const slow = createNewsSource({ fetchImpl: hang, timeoutMs: 20 }); + await assert.rejects(slow.getHotspots(), { name: 'TimeoutError' }); + const caller = new AbortController(); + const pending = createNewsSource({ fetchImpl: hang }).getFeed( + {}, + { signal: caller.signal }, + ); + caller.abort(new Error('gone')); + await assert.rejects(pending, /gone/); + await assert.rejects( + createNewsSource({ fetchImpl: hang }).getHotspots({ + signal: AbortSignal.abort(new Error('already')), + }), + /already/, + ); +}); + +test('the host allowlist admits the client routes and refuses anything else', () => { + const allowed = (path) => newsQueryAllowed(new URL(path, 'http://h')); + assert.deepEqual(NEWS_API_PATHS, [ + '/api/news/hotspots', + '/api/news/feed', + '/api/news/outline', + ]); + assert.equal( + allowed( + `/api/news/feed?${newsFeedQuery({ hotspot: 'ua-ru-war', before: '2026-10-28T08:00:00.000Z', limit: 500, category: 'strike', relevance: 'core,related', geo: true, today: true, events: true })}`, + ), + true, + ); + for (const path of [ + '/api/news/hotspots?x=1', + '/api/news/feed?limit=501', + '/api/news/feed?limit=0', + '/api/news/feed?hotspot=all&hotspot=ua', + '/api/news/feed?since=yesterday', + '/api/news/feed?geo=yes', + '/api/news/feed?side=UA', + '/api/news/air-alerts', + '/api/news/outline?hotspot=ua', + '/api/news/outline?hotspot=ua&v=2', + '/api/news/outline?country=ukraine&v=3', + ]) + assert.equal(allowed(path), false, path); +}); diff --git a/src/layers/news/sprites.js b/src/layers/news/sprites.js new file mode 100644 index 000000000..ac7c71ac5 --- /dev/null +++ b/src/layers/news/sprites.js @@ -0,0 +1,94 @@ +// Marker sprites for news reports: a disc filled with the severity colour, +// the category's icon in it, and a rim for attestation. Drawn once per look +// on a canvas and handed to Cesium as a data URL, so its texture atlas keeps +// one copy per look however often the layer re-renders. + +import { NEWS_CATEGORIES, NEWS_SEVERITY } from './model.js'; + +const CANVAS = 64; +const BODY = 52; // disc diameter inside the canvas; the rest is rim and ring +const INK = '#0a0a0f'; +const RIM = '#ffffff'; +const SELECTED = '#00d4ff'; + +/** Canvas pixels per marker pixel: the billboard is drawn at this ratio. */ +export const SPRITE_SCALE = CANVAS / BODY; + +const cache = new Map(); + +/** + * Paint one marker look centred at (cx, cy) with a disc of `body` pixels: the + * sprites and the event cards' badges draw the same thing. + */ +export function paintMarker(ctx, { category, severity, rim }, cx, cy, body) { + const k = body / BODY; // the strokes below are tuned for the 52 px body + const r = body / 2; + // A dark edge keeps the lightest fills apart from bright ground. + ctx.beginPath(); + ctx.arc(cx, cy, r + 1.5 * k, 0, 2 * Math.PI); + ctx.fillStyle = 'rgba(0, 0, 0, 0.85)'; + ctx.fill(); + ctx.beginPath(); + ctx.arc(cx, cy, r, 0, 2 * Math.PI); + ctx.fillStyle = (NEWS_SEVERITY[severity] || NEWS_SEVERITY[0]).color; + ctx.fill(); + + // Attestation is the rim's form, never its colour. + if (rim === 'confirmed' || rim === 'claimed') { + ctx.beginPath(); + ctx.arc(cx, cy, r - 3.5 * k, 0, 2 * Math.PI); + ctx.setLineDash(rim === 'claimed' ? [7 * k, 5 * k] : []); + ctx.lineWidth = 7 * k; + ctx.strokeStyle = RIM; + ctx.stroke(); + ctx.setLineDash([]); + } else { + ctx.beginPath(); + ctx.arc(cx, cy, r - 1 * k, 0, 2 * Math.PI); + ctx.lineWidth = 2 * k; + ctx.strokeStyle = 'rgba(0, 0, 0, 0.75)'; + ctx.stroke(); + } + + const style = NEWS_CATEGORIES[category] || NEWS_CATEGORIES.other; + const icon = body * 0.56; + ctx.save(); + ctx.translate(cx - icon / 2, cy - icon / 2); + ctx.scale(icon / 16, icon / 16); + const path = new Path2D(style.icon); + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + ctx.lineWidth = 1.7; + ctx.strokeStyle = INK; + if (style.solid) { + ctx.fillStyle = INK; + ctx.fill(path); + } else ctx.stroke(path); + ctx.restore(); +} + +/** + * Data URL of the sprite for a look, or null where there is no canvas (the + * layer then falls back to a plain point). + * @param {{category: string, severity: number, rim: 'confirmed'|'claimed'|'thin', selected?: boolean}} look + */ +export function markerSprite({ category, severity, rim, selected = false }) { + const key = `${category}|${severity}|${rim}|${selected ? 1 : 0}`; + if (cache.has(key)) return cache.get(key); + const canvas = globalThis.document?.createElement?.('canvas'); + const ctx = canvas?.getContext?.('2d'); + if (!ctx || typeof globalThis.Path2D !== 'function') return null; + canvas.width = canvas.height = CANVAS; + const c = CANVAS / 2; + paintMarker(ctx, { category, severity, rim }, c, c, BODY); + if (selected) { + ctx.beginPath(); + ctx.arc(c, c, CANVAS / 2 - 2, 0, 2 * Math.PI); + ctx.lineWidth = 3.5; + ctx.strokeStyle = SELECTED; + ctx.stroke(); + } + const url = canvas.toDataURL('image/png'); + cache.set(key, url); + return url; +}