Add the news report model, markers and source

The pure half of a news layer: validated records for a news server's
`/api/news/hotspots|feed|outline` contract, a presentation model and canvas
markers and picture cards, and a fetch client.

- Colour carries severity only (0 no physical event, 1 minor, 2 significant,
  3 major), never a side; the category is the icon, the rim is attestation
  (confirmed, a claim, one side's own press), size grows with sources and
  markers fade with age.
- Cards say "Reported by {side} sources" and mark official channels; a
  machine-read English headline is labelled as such.
- `createNewsSource` reads same-origin routes with a 15 s timeout, a 4 MiB cap
  and record-by-record validation; `NEWS_API_PATHS` and `newsQueryAllowed`
  let a host proxy exactly what the client sends. A 503 marked unavailable
  means "not configured": no retries.
- Exported as `./layers/news/source`, with its own package boundary.
This commit is contained in:
Denis Groshev committed 2026-11-06 00:45:22 +01:00
1 parent dd83812a21
commit 2d9d518946
21 files changed
+2653

No files matched your search

+1
View File
@@ -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",
+11
View File
@@ -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": [
+56
View File
@@ -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...": "جارٍ إعداد العارض…",
+52
View File
@@ -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...",
+53
View File
@@ -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...",
+53
View File
@@ -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.",
+53
View File
@@ -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.",
+51
View File
@@ -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.": "ピンを置く場所をクリックしてください。",
+53
View File
@@ -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.",
+54
View File
@@ -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...": "Инициализация систем...",
+54
View File
@@ -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.": "Клацніть там, де має бути мітка.",
+51
View File
@@ -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.": "点击图钉的放置位置。",
+51
View File
@@ -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.": "點擊圖釘的放置位置。",
+22
View File
@@ -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",
+206
View File
@@ -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;
}
+643
View File
@@ -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);
}
+444
View File
@@ -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', '<x>'],
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:[email protected]/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), []);
});
+325
View File
@@ -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) };
}
+162
View File
@@ -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;
},
};
}
+164
View File
@@ -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);
});
+94
View File
@@ -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;
}