mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-11 05:42:22 +00:00
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:
1 parent
dd83812a21
commit
2d9d518946
21 files changed
+2653
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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...": "جارٍ إعداد العارض…",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.": "ピンを置く場所をクリックしてください。",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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...": "Инициализация систем...",
|
||||
|
||||
@@ -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.": "Клацніть там, де має бути мітка.",
|
||||
|
||||
@@ -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.": "点击图钉的放置位置。",
|
||||
|
||||
@@ -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.": "點擊圖釘的放置位置。",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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), []);
|
||||
});
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user