diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js
index ede5dc669..0b42c949a 100644
--- a/src/data/dataCredits.js
+++ b/src/data/dataCredits.js
@@ -497,8 +497,24 @@ export const DATA_CREDITS = [
html:
'News: every report is credited to its own publisher and links to the original article; ' +
'pictures are the publishers’ own, shown from their sites with the publisher named. ' +
- 'No single provider stands behind the feed. Places, categories, severity and English ' +
- 'headlines are derived automatically by the news server and can be wrong.',
+ 'Places, categories, severity and English headlines are derived automatically, by ' +
+ 'bundled rules or by the configured news server, and can be wrong.',
+ },
+ {
+ // GDELT's terms ask for a citation and a link.
+ key: 'gdelt-news',
+ html:
+ 'News, Conflicts without a news server: events and page titles from the ' +
+ 'GDELT Project ' +
+ '(GDELT 2.0 events and GKG; places and categories are machine-coded)',
+ },
+ {
+ // World News API's free plan asks for a visible "World News API" link to
+ // its site; always listed, so a free-plan key is never shown without it.
+ key: 'worldnewsapi',
+ html:
+ 'News, Local without a news server: headlines from ' +
+ 'World News API',
},
{
key: 'drivebc-cctv',
@@ -566,6 +582,15 @@ export const TOMTOM_CREDIT = {
'TomTom',
};
+/** Registered when the News layer shows a Liveuamap report (only with
+ * LIVEUAMAP_API_KEY set and no news server). */
+export const LIVEUAMAP_CREDIT = {
+ key: 'liveuamap',
+ html:
+ 'News, Conflicts: mapped reports from ' +
+ 'Liveuamap',
+};
+
/** Registered when the first Natural Earth region or state/province outline
* resolves (public domain — no attribution required; credited as a courtesy). */
export const NATURAL_EARTH_CREDIT = {
diff --git a/src/data/dataCredits.test.mjs b/src/data/dataCredits.test.mjs
index 023c79671..c65c0a48f 100644
--- a/src/data/dataCredits.test.mjs
+++ b/src/data/dataCredits.test.mjs
@@ -1,6 +1,10 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
-import { DATA_CREDITS, NYC_DOT_DISCLAIMER } from './dataCredits.js';
+import {
+ DATA_CREDITS,
+ LIVEUAMAP_CREDIT,
+ NYC_DOT_DISCLAIMER,
+} from './dataCredits.js';
test('every credit carries a unique key and some markup to render', () => {
const keys = DATA_CREDITS.map((entry) => entry.key);
@@ -153,3 +157,23 @@ test('every Street Level imagery source is in the attribution lightbox', () => {
`${key} has no credit`,
);
});
+
+test('the News layer credits its built-in sources', () => {
+ const credit = (key) => DATA_CREDITS.find((entry) => entry.key === key);
+ // GDELT's terms ask for a citation and a link.
+ assert.match(
+ credit('gdelt-news').html,
+ /href="https:\/\/www\.gdeltproject\.org\/"[^>]*>GDELT Project,
+ );
+ // World News API's free plan asks for this exact link.
+ assert.match(
+ credit('worldnewsapi').html,
+ /]*>World News API<\/a>/,
+ );
+ assert.match(LIVEUAMAP_CREDIT.html, />Liveuamap);
+ assert.ok(
+ !DATA_CREDITS.some((entry) => entry.key === LIVEUAMAP_CREDIT.key),
+ 'Liveuamap is credited only once its key is in use',
+ );
+ assert.doesNotMatch(credit('news').html, /No single provider/);
+});
diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json
index 3311dac2d..795976393 100644
--- a/src/i18n/locales/ar.json
+++ b/src/i18n/locales/ar.json
@@ -17,12 +17,7 @@
"many": "{count} تقريرًا اليوم",
"other": "{count} تقرير اليوم"
},
- "Last 24 h from curated hotspots. Each report links to its publisher; places and categories are read from the text automatically and can be wrong. Side labels describe the source, not whether a report is true.": "آخر 24 ساعة من بؤر مختارة. يرتبط كل تقرير بناشره؛ تُستخرج الأماكن والفئات من النص تلقائيًا وقد تكون خاطئة. تصف تسميات الأطراف المصدر، لا صحة التقرير.",
"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}",
@@ -3512,5 +3507,20 @@
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "المجال الجوي الخاص المحظور والمقيّد لدى FAA فقط، ولا يشمل UAS Facility Map أو قيود طيران UAS. للعلم فقط — ولا يغني عن الأدوات الرسمية لتخطيط رحلات UAS (B4UFLY / LAANC).",
"Prohibited": "محظور",
"Restricted": "مقيّد",
- "Prohibited & Restricted Airspace": "المجال الجوي المحظور والمقيّد"
+ "Prohibited & Restricted Airspace": "المجال الجوي المحظور والمقيّد",
+ "Last 24 h of news. Each report links to its publisher; places and categories are read from the text and data automatically and can be wrong. Side labels, where shown, describe the source, not whether a report is true.": "آخر 24 ساعة من الأخبار. يرتبط كل تقرير بناشره؛ تُستخرج الأماكن والفئات من النص والبيانات تلقائيًا وقد تكون خاطئة. تصف تسميات الأطراف، حيث تظهر، المصدر لا صحة التقرير.",
+ "Economy": "اقتصاد",
+ "News from {area}": "أخبار: {area}",
+ "Within {km} km of the view centre": "ضمن {km} كم من مركز العرض",
+ "{region}, {country}": "{region}، {country}",
+ "via {source}": "عبر {source}",
+ "Local in the News layer: the day’s news from the outlets of the country in view": "المحلي في طبقة الأخبار: أخبار اليوم من وسائل إعلام البلد المعروض",
+ "{provider} refused its key": "رفض {provider} مفتاحه",
+ "Check {envVars} in Provider Settings": "تحقق من {envVars} في «إعدادات المزوّدين»",
+ "{provider} key required": "مفتاح {provider} مطلوب",
+ "{provider} limit reached for today": "بلغ {provider} حدّه لهذا اليوم",
+ "News resumes tomorrow, or with a paid plan": "تُستأنف الأخبار غدًا، أو مع خطة مدفوعة",
+ "News key required": "مفتاح الأخبار مطلوب",
+ "Precise conflict points in the News layer, beside GDELT’s articles": "نقاط نزاع دقيقة في طبقة الأخبار، إلى جانب مقالات GDELT",
+ "Worldwide": "العالم"
}
diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json
index 7f53f1d53..785c90693 100644
--- a/src/i18n/locales/de.json
+++ b/src/i18n/locales/de.json
@@ -9,12 +9,7 @@
"one": "{count} Meldung heute",
"other": "{count} Meldungen heute"
},
- "Last 24 h from curated hotspots. Each report links to its publisher; places and categories are read from the text automatically and can be wrong. Side labels describe the source, not whether a report is true.": "Letzte 24 h aus ausgewählten Brennpunkten. Jede Meldung verlinkt auf ihren Herausgeber; Orte und Kategorien werden automatisch aus dem Text gelesen und können falsch sein. Seitenangaben beschreiben die Quelle, nicht ob eine Meldung stimmt.",
"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}",
@@ -3200,5 +3195,20 @@
"The provider rejected the API key. Check it in Provider Settings.": "Der Anbieter hat den API-Schlüssel abgelehnt. Den Schlüssel in den Anbieter-Einstellungen prüfen.",
"Too many chat requests. Try again in {seconds} s.": "Zu viele Chat-Anfragen. In {seconds} s erneut versuchen.",
"The provider refused access for this key. Check the key's project, region or permissions.": "Der Anbieter hat den Zugriff für diesen Schlüssel verweigert. Projekt, Region oder Berechtigungen des Schlüssels prüfen.",
- "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "Das Anbieterkonto hat kein Guthaben mehr. Die Abrechnung prüfen oder in den Einstellungen einen anderen KI-Anbieter wählen."
+ "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "Das Anbieterkonto hat kein Guthaben mehr. Die Abrechnung prüfen oder in den Einstellungen einen anderen KI-Anbieter wählen.",
+ "Last 24 h of news. Each report links to its publisher; places and categories are read from the text and data automatically and can be wrong. Side labels, where shown, describe the source, not whether a report is true.": "Letzte 24 h Nachrichten. Jede Meldung verlinkt auf ihren Herausgeber; Orte und Kategorien werden automatisch aus Text und Daten gelesen und können falsch sein. Seitenangaben, wo vorhanden, beschreiben die Quelle, nicht ob eine Meldung stimmt.",
+ "Economy": "Wirtschaft",
+ "News from {area}": "Nachrichten: {area}",
+ "Within {km} km of the view centre": "Im Umkreis von {km} km um die Bildmitte",
+ "{region}, {country}": "{region}, {country}",
+ "via {source}": "über {source}",
+ "Local in the News layer: the day’s news from the outlets of the country in view": "Lokal in der Nachrichtenebene: die Nachrichten des Tages aus den Medien des Landes im Blick",
+ "{provider} refused its key": "{provider} hat den Schlüssel abgelehnt",
+ "Check {envVars} in Provider Settings": "{envVars} in den Anbieter-Einstellungen prüfen",
+ "{provider} key required": "{provider}-Schlüssel erforderlich",
+ "{provider} limit reached for today": "{provider}: Tageslimit erreicht",
+ "News resumes tomorrow, or with a paid plan": "Nachrichten gibt es morgen wieder, oder mit einem kostenpflichtigen Tarif",
+ "News key required": "Nachrichtenschlüssel erforderlich",
+ "Precise conflict points in the News layer, beside GDELT’s articles": "Genaue Konfliktpunkte in der Nachrichtenebene, neben den Artikeln von GDELT",
+ "Worldwide": "Weltweit"
}
diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json
index 2c92a3bc9..ad578986d 100644
--- a/src/i18n/locales/es.json
+++ b/src/i18n/locales/es.json
@@ -11,12 +11,7 @@
"many": "{count} noticias hoy",
"other": "{count} noticias hoy"
},
- "Last 24 h from curated hotspots. Each report links to its publisher; places and categories are read from the text automatically and can be wrong. Side labels describe the source, not whether a report is true.": "Últimas 24 h de focos seleccionados. Cada noticia enlaza a su medio; los lugares y categorías se leen del texto automáticamente y pueden ser erróneos. Las etiquetas de bando describen la fuente, no si la noticia es cierta.",
"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}",
@@ -3278,5 +3273,20 @@
"The provider rejected the API key. Check it in Provider Settings.": "El proveedor rechazó la clave de API. Revísala en Ajustes de proveedores.",
"Too many chat requests. Try again in {seconds} s.": "Demasiadas solicitudes de chat. Vuelve a intentarlo en {seconds} s.",
"The provider refused access for this key. Check the key's project, region or permissions.": "El proveedor denegó el acceso a esta clave. Revisa el proyecto, la región o los permisos de la clave.",
- "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "La cuenta del proveedor no tiene crédito. Revisa la facturación o elige otro proveedor de IA en Ajustes."
+ "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "La cuenta del proveedor no tiene crédito. Revisa la facturación o elige otro proveedor de IA en Ajustes.",
+ "Last 24 h of news. Each report links to its publisher; places and categories are read from the text and data automatically and can be wrong. Side labels, where shown, describe the source, not whether a report is true.": "Últimas 24 h de noticias. Cada noticia enlaza a su medio; los lugares y categorías se leen del texto y los datos automáticamente y pueden ser erróneos. Las etiquetas de bando, donde aparecen, describen la fuente, no si la noticia es cierta.",
+ "Economy": "Economía",
+ "News from {area}": "Noticias: {area}",
+ "Within {km} km of the view centre": "A menos de {km} km del centro de la vista",
+ "{region}, {country}": "{region}, {country}",
+ "via {source}": "vía {source}",
+ "Local in the News layer: the day’s news from the outlets of the country in view": "Local en la capa de noticias: las noticias del día de los medios del país a la vista",
+ "{provider} refused its key": "{provider} rechazó su clave",
+ "Check {envVars} in Provider Settings": "Revisa {envVars} en Ajustes de proveedores",
+ "{provider} key required": "Se necesita una clave de {provider}",
+ "{provider} limit reached for today": "{provider} alcanzó su límite de hoy",
+ "News resumes tomorrow, or with a paid plan": "Las noticias vuelven mañana, o con un plan de pago",
+ "News key required": "Se necesita una clave de noticias",
+ "Precise conflict points in the News layer, beside GDELT’s articles": "Puntos de conflicto precisos en la capa de noticias, junto a los artículos de GDELT",
+ "Worldwide": "Mundial"
}
diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json
index ad951d328..e594e4d42 100644
--- a/src/i18n/locales/fr.json
+++ b/src/i18n/locales/fr.json
@@ -11,12 +11,7 @@
"many": "{count} articles aujourd’hui",
"other": "{count} articles aujourd’hui"
},
- "Last 24 h from curated hotspots. Each report links to its publisher; places and categories are read from the text automatically and can be wrong. Side labels describe the source, not whether a report is true.": "Dernières 24 h des foyers retenus. Chaque article renvoie à son éditeur ; lieux et catégories sont lus automatiquement dans le texte et peuvent être faux. Les étiquettes de camp décrivent la source, pas la véracité de l’article.",
"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}",
@@ -3278,5 +3273,20 @@
"The provider rejected the API key. Check it in Provider Settings.": "Le fournisseur a refusé la clé API. Vérifiez-la dans les paramètres des fournisseurs.",
"Too many chat requests. Try again in {seconds} s.": "Trop de requêtes de discussion. Réessayez dans {seconds} s.",
"The provider refused access for this key. Check the key's project, region or permissions.": "Le fournisseur a refusé l'accès pour cette clé. Vérifiez le projet, la région ou les autorisations de la clé.",
- "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "Le compte du fournisseur n'a plus de crédit. Vérifiez la facturation ou choisissez un autre fournisseur d'IA dans les paramètres."
+ "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "Le compte du fournisseur n'a plus de crédit. Vérifiez la facturation ou choisissez un autre fournisseur d'IA dans les paramètres.",
+ "Last 24 h of news. Each report links to its publisher; places and categories are read from the text and data automatically and can be wrong. Side labels, where shown, describe the source, not whether a report is true.": "Dernières 24 h d’actualité. Chaque article renvoie à son éditeur ; lieux et catégories sont lus automatiquement dans le texte et les données et peuvent être faux. Les étiquettes de camp, là où elles figurent, décrivent la source, pas la véracité de l’article.",
+ "Economy": "Économie",
+ "News from {area}": "Actualités : {area}",
+ "Within {km} km of the view centre": "À moins de {km} km du centre de la vue",
+ "{region}, {country}": "{region}, {country}",
+ "via {source}": "via {source}",
+ "Local in the News layer: the day’s news from the outlets of the country in view": "Local dans la couche Actualités : l’actualité du jour des médias du pays affiché",
+ "{provider} refused its key": "{provider} a refusé sa clé",
+ "Check {envVars} in Provider Settings": "Vérifiez {envVars} dans les paramètres des fournisseurs",
+ "{provider} key required": "Clé {provider} requise",
+ "{provider} limit reached for today": "{provider} a atteint sa limite du jour",
+ "News resumes tomorrow, or with a paid plan": "Les actualités reprennent demain, ou avec une offre payante",
+ "News key required": "Clé d’actualités requise",
+ "Precise conflict points in the News layer, beside GDELT’s articles": "Points de conflit précis dans la couche Actualités, à côté des articles de GDELT",
+ "Worldwide": "Monde entier"
}
diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json
index 5f5caa805..b837788bc 100644
--- a/src/i18n/locales/it.json
+++ b/src/i18n/locales/it.json
@@ -11,12 +11,7 @@
"many": "{count} notizie oggi",
"other": "{count} notizie oggi"
},
- "Last 24 h from curated hotspots. Each report links to its publisher; places and categories are read from the text automatically and can be wrong. Side labels describe the source, not whether a report is true.": "Ultime 24 h dai focolai selezionati. Ogni notizia rimanda al suo editore; luoghi e categorie sono letti automaticamente dal testo e possono essere errati. Le etichette di parte descrivono la fonte, non se la notizia è vera.",
"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}",
@@ -3278,5 +3273,20 @@
"The provider rejected the API key. Check it in Provider Settings.": "Il provider ha rifiutato la chiave API. Controllala in Impostazioni provider.",
"Too many chat requests. Try again in {seconds} s.": "Troppe richieste di chat. Riprova tra {seconds} s.",
"The provider refused access for this key. Check the key's project, region or permissions.": "Il provider ha negato l'accesso per questa chiave. Controlla progetto, regione o autorizzazioni della chiave.",
- "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "L'account del provider ha esaurito il credito. Controlla la fatturazione o scegli un altro provider IA in Impostazioni."
+ "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "L'account del provider ha esaurito il credito. Controlla la fatturazione o scegli un altro provider IA in Impostazioni.",
+ "Last 24 h of news. Each report links to its publisher; places and categories are read from the text and data automatically and can be wrong. Side labels, where shown, describe the source, not whether a report is true.": "Ultime 24 h di notizie. Ogni notizia rimanda al suo editore; luoghi e categorie sono letti automaticamente dal testo e dai dati e possono essere errati. Le etichette di parte, dove presenti, descrivono la fonte, non se la notizia è vera.",
+ "Economy": "Economia",
+ "News from {area}": "Notizie: {area}",
+ "Within {km} km of the view centre": "Entro {km} km dal centro della vista",
+ "{region}, {country}": "{region}, {country}",
+ "via {source}": "tramite {source}",
+ "Local in the News layer: the day’s news from the outlets of the country in view": "Locale nel livello Notizie: le notizie del giorno dalle testate del paese inquadrato",
+ "{provider} refused its key": "{provider} ha rifiutato la chiave",
+ "Check {envVars} in Provider Settings": "Controlla {envVars} in Impostazioni provider",
+ "{provider} key required": "Chiave {provider} richiesta",
+ "{provider} limit reached for today": "{provider} ha raggiunto il limite di oggi",
+ "News resumes tomorrow, or with a paid plan": "Le notizie riprendono domani, o con un piano a pagamento",
+ "News key required": "Chiave per le notizie richiesta",
+ "Precise conflict points in the News layer, beside GDELT’s articles": "Punti di conflitto precisi nel livello Notizie, accanto agli articoli di GDELT",
+ "Worldwide": "Mondo"
}
diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json
index d73bee402..49859f864 100644
--- a/src/i18n/locales/ja.json
+++ b/src/i18n/locales/ja.json
@@ -7,12 +7,7 @@
"{count} reports today": {
"other": "本日の報道 {count}件"
},
- "Last 24 h from curated hotspots. Each report links to its publisher; places and categories are read from the text automatically and can be wrong. Side labels describe the source, not whether a report is true.": "厳選したホットスポットの直近24時間。各報道は発信元にリンクします。場所とカテゴリは本文から自動で読み取るため誤りがあり得ます。陣営ラベルは情報源を示すもので、報道の真偽ではありません。",
"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}の主張",
@@ -3122,5 +3117,20 @@
"The provider rejected the API key. Check it in Provider Settings.": "プロバイダーがAPIキーを拒否しました。プロバイダー設定で確認してください。",
"Too many chat requests. Try again in {seconds} s.": "チャットのリクエストが多すぎます。{seconds}秒後にもう一度お試しください。",
"The provider refused access for this key. Check the key's project, region or permissions.": "プロバイダーがこのキーでのアクセスを拒否しました。キーのプロジェクト、リージョン、権限を確認してください。",
- "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "プロバイダーアカウントのクレジットが不足しています。課金状況を確認するか、設定で別のAIプロバイダーを選んでください。"
+ "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "プロバイダーアカウントのクレジットが不足しています。課金状況を確認するか、設定で別のAIプロバイダーを選んでください。",
+ "Last 24 h of news. Each report links to its publisher; places and categories are read from the text and data automatically and can be wrong. Side labels, where shown, describe the source, not whether a report is true.": "直近24時間のニュース。各報道は発信元にリンクします。場所とカテゴリは本文とデータから自動で読み取るため誤りがあり得ます。陣営ラベルは、表示される場合、情報源を示すもので、報道の真偽ではありません。",
+ "Economy": "経済",
+ "News from {area}": "{area}のニュース",
+ "Within {km} km of the view centre": "表示中心から{km} km以内",
+ "{region}, {country}": "{country}・{region}",
+ "via {source}": "{source}経由",
+ "Local in the News layer: the day’s news from the outlets of the country in view": "ニュースレイヤーのローカル:表示中の国のメディアによる今日のニュース",
+ "{provider} refused its key": "{provider}がキーを拒否しました",
+ "Check {envVars} in Provider Settings": "プロバイダー設定で{envVars}を確認してください",
+ "{provider} key required": "{provider}のキーが必要です",
+ "{provider} limit reached for today": "{provider}の本日の上限に達しました",
+ "News resumes tomorrow, or with a paid plan": "ニュースは明日、または有料プランで再開します",
+ "News key required": "ニュースのキーが必要です",
+ "Precise conflict points in the News layer, beside GDELT’s articles": "ニュースレイヤーの正確な紛争地点(GDELTの記事に加えて)",
+ "Worldwide": "世界"
}
diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json
index 2f9ebcbf9..785e9a02e 100644
--- a/src/i18n/locales/pt.json
+++ b/src/i18n/locales/pt.json
@@ -11,12 +11,7 @@
"many": "{count} notícias hoje",
"other": "{count} notícias hoje"
},
- "Last 24 h from curated hotspots. Each report links to its publisher; places and categories are read from the text automatically and can be wrong. Side labels describe the source, not whether a report is true.": "Últimas 24 h dos focos selecionados. Cada notícia leva ao seu editor; locais e categorias são lidos do texto automaticamente e podem estar errados. Os rótulos de lado descrevem a fonte, não se a notícia é verdadeira.",
"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}",
@@ -3278,5 +3273,20 @@
"The provider rejected the API key. Check it in Provider Settings.": "O provedor recusou a chave de API. Verifique-a em \"Configurações de provedores\".",
"Too many chat requests. Try again in {seconds} s.": "Chat: limite de requisições atingido. Tente novamente em {seconds} s.",
"The provider refused access for this key. Check the key's project, region or permissions.": "O provedor negou o acesso para esta chave. Verifique o projeto, a região ou as permissões da chave.",
- "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "A conta do provedor está sem crédito. Verifique o faturamento ou escolha outro provedor de IA em \"Configurações\"."
+ "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "A conta do provedor está sem crédito. Verifique o faturamento ou escolha outro provedor de IA em \"Configurações\".",
+ "Last 24 h of news. Each report links to its publisher; places and categories are read from the text and data automatically and can be wrong. Side labels, where shown, describe the source, not whether a report is true.": "Últimas 24 h de notícias. Cada notícia leva ao seu editor; locais e categorias são lidos do texto e dos dados automaticamente e podem estar errados. Os rótulos de lado, quando exibidos, descrevem a fonte, não se a notícia é verdadeira.",
+ "Economy": "Economia",
+ "News from {area}": "Notícias: {area}",
+ "Within {km} km of the view centre": "Até {km} km do centro da vista",
+ "{region}, {country}": "{region}, {country}",
+ "via {source}": "via {source}",
+ "Local in the News layer: the day’s news from the outlets of the country in view": "Local na camada de notícias: as notícias do dia dos veículos do país em vista",
+ "{provider} refused its key": "{provider} recusou a chave",
+ "Check {envVars} in Provider Settings": "Verifique {envVars} em \"Configurações de provedores\"",
+ "{provider} key required": "Chave do {provider} necessária",
+ "{provider} limit reached for today": "{provider} atingiu o limite de hoje",
+ "News resumes tomorrow, or with a paid plan": "As notícias voltam amanhã, ou com um plano pago",
+ "News key required": "Chave de notícias necessária",
+ "Precise conflict points in the News layer, beside GDELT’s articles": "Pontos de conflito precisos na camada de notícias, ao lado dos artigos do GDELT",
+ "Worldwide": "Mundo"
}
diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json
index 8de804c4b..87300c17a 100644
--- a/src/i18n/locales/ru.json
+++ b/src/i18n/locales/ru.json
@@ -13,12 +13,7 @@
"many": "{count} сообщений сегодня",
"other": "{count} сообщения сегодня"
},
- "Last 24 h from curated hotspots. Each report links to its publisher; places and categories are read from the text automatically and can be wrong. Side labels describe the source, not whether a report is true.": "Последние 24 ч по выбранным горячим точкам. Каждое сообщение ведёт к своему изданию; места и категории определяются по тексту автоматически и могут быть ошибочны. Метки сторон описывают источник, а не достоверность сообщения.",
"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}",
@@ -3356,5 +3351,20 @@
"The provider rejected the API key. Check it in Provider Settings.": "Провайдер отклонил ключ API. Проверьте его в «Настройках провайдеров».",
"Too many chat requests. Try again in {seconds} s.": "Слишком много запросов в чате. Повторите попытку через {seconds} с.",
"The provider refused access for this key. Check the key's project, region or permissions.": "Провайдер отказал этому ключу в доступе. Проверьте проект, регион или разрешения ключа.",
- "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "На вашем счёте у провайдера закончились средства. Проверьте оплату или выберите другого провайдера ИИ в настройках."
+ "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "На вашем счёте у провайдера закончились средства. Проверьте оплату или выберите другого провайдера ИИ в настройках.",
+ "Last 24 h of news. Each report links to its publisher; places and categories are read from the text and data automatically and can be wrong. Side labels, where shown, describe the source, not whether a report is true.": "Новости за последние 24 ч. Каждое сообщение ведёт к своему изданию; места и категории определяются по тексту и данным автоматически и могут быть ошибочны. Метки сторон, где они есть, описывают источник, а не достоверность сообщения.",
+ "Economy": "Экономика",
+ "News from {area}": "Новости: {area}",
+ "Within {km} km of the view centre": "В радиусе {km} км от центра карты",
+ "{region}, {country}": "{region}, {country}",
+ "via {source}": "через {source}",
+ "Local in the News layer: the day’s news from the outlets of the country in view": "Местное в слое новостей: новости дня от СМИ страны в кадре",
+ "{provider} refused its key": "{provider} отклонил ключ",
+ "Check {envVars} in Provider Settings": "Проверьте {envVars} в «Настройках провайдеров»",
+ "{provider} key required": "Нужен ключ {provider}",
+ "{provider} limit reached for today": "{provider}: дневной лимит исчерпан",
+ "News resumes tomorrow, or with a paid plan": "Новости вернутся завтра или с платным тарифом",
+ "News key required": "Нужен ключ новостей",
+ "Precise conflict points in the News layer, beside GDELT’s articles": "Точные точки конфликтов в слое новостей, вдобавок к статьям GDELT",
+ "Worldwide": "Весь мир"
}
diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json
index 8db82b9f2..262a6b965 100644
--- a/src/i18n/locales/uk.json
+++ b/src/i18n/locales/uk.json
@@ -13,12 +13,7 @@
"many": "{count} повідомлень сьогодні",
"other": "{count} повідомлення сьогодні"
},
- "Last 24 h from curated hotspots. Each report links to its publisher; places and categories are read from the text automatically and can be wrong. Side labels describe the source, not whether a report is true.": "Останні 24 год за вибраними гарячими точками. Кожне повідомлення веде до свого видання; місця й категорії визначаються з тексту автоматично і можуть бути хибними. Позначки сторін описують джерело, а не правдивість повідомлення.",
"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}",
@@ -3356,5 +3351,20 @@
"The provider rejected the API key. Check it in Provider Settings.": "Постачальник відхилив ключ API. Перевірте його в «Налаштуваннях постачальників».",
"Too many chat requests. Try again in {seconds} s.": "Забагато запитів до чату. Спробуйте ще раз через {seconds} с.",
"The provider refused access for this key. Check the key's project, region or permissions.": "Постачальник відмовив цьому ключу в доступі. Перевірте проєкт, регіон або дозволи ключа.",
- "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "На вашому рахунку в постачальника закінчилися кошти. Перевірте оплату або виберіть іншого постачальника ШІ в налаштуваннях."
+ "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "На вашому рахунку в постачальника закінчилися кошти. Перевірте оплату або виберіть іншого постачальника ШІ в налаштуваннях.",
+ "Last 24 h of news. Each report links to its publisher; places and categories are read from the text and data automatically and can be wrong. Side labels, where shown, describe the source, not whether a report is true.": "Новини за останні 24 год. Кожне повідомлення веде до свого видання; місця й категорії визначаються з тексту й даних автоматично і можуть бути хибними. Позначки сторін, де вони є, описують джерело, а не правдивість повідомлення.",
+ "Economy": "Економіка",
+ "News from {area}": "Новини: {area}",
+ "Within {km} km of the view centre": "У радіусі {km} км від центру мапи",
+ "{region}, {country}": "{region}, {country}",
+ "via {source}": "через {source}",
+ "Local in the News layer: the day’s news from the outlets of the country in view": "Місцеве в шарі новин: новини дня від медіа країни в кадрі",
+ "{provider} refused its key": "{provider} відхилив ключ",
+ "Check {envVars} in Provider Settings": "Перевірте {envVars} у «Налаштуваннях постачальників»",
+ "{provider} key required": "Потрібен ключ {provider}",
+ "{provider} limit reached for today": "{provider}: денний ліміт вичерпано",
+ "News resumes tomorrow, or with a paid plan": "Новини повернуться завтра або з платним тарифом",
+ "News key required": "Потрібен ключ новин",
+ "Precise conflict points in the News layer, beside GDELT’s articles": "Точні точки конфліктів у шарі новин, на додачу до статей GDELT",
+ "Worldwide": "Увесь світ"
}
diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json
index e4b6c0920..e45820bce 100644
--- a/src/i18n/locales/zh-CN.json
+++ b/src/i18n/locales/zh-CN.json
@@ -7,12 +7,7 @@
"{count} reports today": {
"other": "今日 {count} 条报道"
},
- "Last 24 h from curated hotspots. Each report links to its publisher; places and categories are read from the text automatically and can be wrong. Side labels describe the source, not whether a report is true.": "精选热点的最近 24 小时。每条报道都链接到其发布者;地点和类别由文本自动读取,可能有误。阵营标签描述的是来源,而非报道是否属实。",
"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}声称",
@@ -3122,5 +3117,20 @@
"The provider rejected the API key. Check it in Provider Settings.": "服务商拒绝了 API 密钥。请在服务商设置中检查。",
"Too many chat requests. Try again in {seconds} s.": "聊天请求过多。请在 {seconds} 秒后重试。",
"The provider refused access for this key. Check the key's project, region or permissions.": "服务商拒绝了此密钥的访问请求。请检查该密钥所属的项目、区域或权限。",
- "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "服务商账户余额不足。请检查账户的计费信息,或在设置中选择其他 AI 服务商。"
+ "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "服务商账户余额不足。请检查账户的计费信息,或在设置中选择其他 AI 服务商。",
+ "Last 24 h of news. Each report links to its publisher; places and categories are read from the text and data automatically and can be wrong. Side labels, where shown, describe the source, not whether a report is true.": "最近 24 小时的新闻。每条报道都链接到其发布者;地点和类别由文本和数据自动读取,可能有误。阵营标签(如有显示)描述的是来源,而非报道是否属实。",
+ "Economy": "经济",
+ "News from {area}": "{area}新闻",
+ "Within {km} km of the view centre": "视图中心 {km} 公里范围内",
+ "{region}, {country}": "{country}{region}",
+ "via {source}": "来自 {source}",
+ "Local in the News layer: the day’s news from the outlets of the country in view": "新闻图层的本地:视野中国家的媒体当天的新闻",
+ "{provider} refused its key": "{provider} 拒绝了密钥",
+ "Check {envVars} in Provider Settings": "请在服务商设置中检查 {envVars}",
+ "{provider} key required": "需要 {provider} 密钥",
+ "{provider} limit reached for today": "{provider} 已达今日上限",
+ "News resumes tomorrow, or with a paid plan": "新闻将于明天恢复,或升级付费套餐",
+ "News key required": "需要新闻密钥",
+ "Precise conflict points in the News layer, beside GDELT’s articles": "新闻图层中精确的冲突地点,与 GDELT 的文章并列",
+ "Worldwide": "全球"
}
diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json
index 976688ef9..6828c3d41 100644
--- a/src/i18n/locales/zh-Hant.json
+++ b/src/i18n/locales/zh-Hant.json
@@ -7,12 +7,7 @@
"{count} reports today": {
"other": "今日 {count} 則報導"
},
- "Last 24 h from curated hotspots. Each report links to its publisher; places and categories are read from the text automatically and can be wrong. Side labels describe the source, not whether a report is true.": "精選熱點的最近 24 小時。每則報導都連結至其發布者;地點與類別由文字自動判讀,可能有誤。陣營標籤描述的是來源,而非報導是否屬實。",
"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}聲稱",
@@ -3122,5 +3117,20 @@
"The provider rejected the API key. Check it in Provider Settings.": "服務商拒絕了 API 密鑰。請在服務商設置中檢查。",
"Too many chat requests. Try again in {seconds} s.": "聊天請求過多。請在 {seconds} 秒後重試。",
"The provider refused access for this key. Check the key's project, region or permissions.": "服務商拒絕了此密鑰的訪問請求。請檢查該密鑰所屬的項目、區域或權限。",
- "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "服務商賬戶餘額不足。請檢查賬戶的計費信息,或在設置中選擇其他 AI 服務商。"
+ "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "服務商賬戶餘額不足。請檢查賬戶的計費信息,或在設置中選擇其他 AI 服務商。",
+ "Last 24 h of news. Each report links to its publisher; places and categories are read from the text and data automatically and can be wrong. Side labels, where shown, describe the source, not whether a report is true.": "最近 24 小時的新聞。每則報導都連結至其發布者;地點與類別由文字與資料自動判讀,可能有誤。陣營標籤(如有顯示)描述的是來源,而非報導是否屬實。",
+ "Economy": "經濟",
+ "News from {area}": "{area}新聞",
+ "Within {km} km of the view centre": "視圖中心 {km} 公里範圍內",
+ "{region}, {country}": "{country}{region}",
+ "via {source}": "來自 {source}",
+ "Local in the News layer: the day’s news from the outlets of the country in view": "新聞圖層的本地:視野中國家的媒體當天的新聞",
+ "{provider} refused its key": "{provider} 拒絕了金鑰",
+ "Check {envVars} in Provider Settings": "請在服務商設置中檢查 {envVars}",
+ "{provider} key required": "需要 {provider} 金鑰",
+ "{provider} limit reached for today": "{provider} 已達今日上限",
+ "News resumes tomorrow, or with a paid plan": "新聞將於明天恢復,或升級付費方案",
+ "News key required": "需要新聞金鑰",
+ "Precise conflict points in the News layer, beside GDELT’s articles": "新聞圖層中精確的衝突地點,與 GDELT 的文章並列",
+ "Worldwide": "全球"
}
diff --git a/src/i18n/same-as-english.json b/src/i18n/same-as-english.json
index 36ce019d7..3bec7f43f 100644
--- a/src/i18n/same-as-english.json
+++ b/src/i18n/same-as-english.json
@@ -197,6 +197,7 @@
"view band|GLOBAL",
"{city} CCTV",
"{county} County",
+ "{region}, {country}",
"{source} HTTP {status}",
"{speed} kn",
"{type} SPD: {speed} HDG: {heading}",
@@ -335,6 +336,7 @@
"{hours} h",
"{minutes} min",
"{rate} msg/s",
+ "{region}, {country}",
"{speed} kn",
"{type} SPD: {speed} HDG: {heading}",
"{value} KM",
@@ -517,6 +519,7 @@
"satellite",
"standard",
"station",
+ "via {source}",
"voice|OFF",
"{count} MSGS",
"{count} SOURCES",
@@ -527,6 +530,7 @@
"{n} cyclones",
"{n} satellites",
"{rate} msg/s",
+ "{region}, {country}",
"{value} KM",
"{value} KM/H",
"{value} KM/S",
@@ -703,6 +707,7 @@
"{hours} h",
"{minutes} min",
"{rate} msg/s",
+ "{region}, {country}",
"{scene} / {pack}: {status}. File: {path}. {attribution} · {license}",
"{source} HTTP {status}",
"{speed} kn",
@@ -892,10 +897,12 @@
"mini",
"mph",
"nominal",
+ "via {source}",
"view band|GLOBAL",
"{hours} h",
"{minutes} min",
"{rate} msg/s",
+ "{region}, {country}",
"{type} SPD: {speed} HDG: {heading}",
"{value} KM",
"{value} KM/H",
@@ -920,7 +927,8 @@
"S2 GhostMaps",
"S2 · {name}",
"VS",
- "WHOT/BHOT"
+ "WHOT/BHOT",
+ "{region}, {country}"
],
"uk": [
"AIS: --",
@@ -933,7 +941,8 @@
"NVG",
"REC",
"S2 GhostMaps",
- "WHOT/BHOT"
+ "WHOT/BHOT",
+ "{region}, {country}"
],
"zh-CN": [
"1090 MHz",
diff --git a/src/keySetupCore.mjs b/src/keySetupCore.mjs
index d8ccc048d..2ef722cd5 100644
--- a/src/keySetupCore.mjs
+++ b/src/keySetupCore.mjs
@@ -89,6 +89,26 @@ export const KEY_SETUP_KEYS = Object.freeze([
envVars: Object.freeze(['FIRMS_MAP_KEY']),
tier: 'free',
}),
+ Object.freeze({
+ id: 'liveuamap',
+ title: 'LIVEUAMAP', // i18n-ignore: provider product name
+ unlocks: msg(
+ 'Precise conflict points in the News layer, beside GDELT’s articles',
+ ),
+ getUrl: 'https://liveuamap.com/promo/api',
+ envVars: Object.freeze(['LIVEUAMAP_API_KEY']),
+ tier: 'metered',
+ }),
+ Object.freeze({
+ id: 'worldnewsapi',
+ title: 'WORLD NEWS API', // i18n-ignore: provider product name
+ unlocks: msg(
+ 'Local in the News layer: the day’s news from the outlets of the country in view',
+ ),
+ getUrl: 'https://worldnewsapi.com/console/',
+ envVars: Object.freeze(['WORLDNEWSAPI_KEY']),
+ tier: 'free',
+ }),
Object.freeze({
id: 'tomtom',
title: 'TOMTOM', // i18n-ignore: provider product name
diff --git a/src/layers/news/cards.js b/src/layers/news/cards.js
index 25a355445..c7a450e26 100644
--- a/src/layers/news/cards.js
+++ b/src/layers/news/cards.js
@@ -128,7 +128,7 @@ function paint(canvas, view, look, pic) {
ctx.strokeStyle = WORLD_OVERLAY_STYLE.border;
ctx.lineWidth = 1;
ctx.stroke();
- // Severity down the left edge, where Breaking marks a Live report.
+ // Severity down the left edge.
ctx.save();
roundRect(ctx, 0.5, 0.5, CARD_W - 1, CARD_H - 1, WORLD_OVERLAY_STYLE.radius);
ctx.clip();
diff --git a/src/layers/news/index.js b/src/layers/news/index.js
index 3223d0e45..fcd5434fe 100644
--- a/src/layers/news/index.js
+++ b/src/layers/news/index.js
@@ -13,6 +13,11 @@ import {
removeEntityContextsForLayer,
selectEntityContext,
} from '../../data/contextStore.js';
+import {
+ LIVEUAMAP_CREDIT,
+ NATURAL_EARTH_CREDIT,
+ registerDynamicCredit,
+} from '../../data/dataCredits.js';
import { msg, t, tn } from '../../i18n/index.js';
import { createHorizonCull, viewCentre } from '../streetLevel/view.js';
import {
@@ -22,6 +27,7 @@ import {
NEWS_FEED_PAGE,
NEWS_FOCUS_HEIGHT_M,
NEWS_IN_VIEW_MAX_HEIGHT_M,
+ NEWS_IN_VIEW_RADIUS_KM,
NEWS_LOCAL_LIMIT,
NEWS_LAYER_ID,
NEWS_MAP_LIMIT,
@@ -32,7 +38,9 @@ import {
NEWS_SEVERITY,
NEWS_WINDOW_MS,
ageWords,
+ areaLabel,
attestation,
+ attributionLabel,
beaconPixelSize,
cardEvents,
cardView,
@@ -42,6 +50,7 @@ import {
feedQueryFor,
hotspotLabel,
hotspotTrend,
+ inPolygons,
isPressing,
markerStyle,
mediaLabels,
@@ -68,15 +77,54 @@ export * from './model.js';
export { createNewsSource } from './source.js';
const FEED_UNAVAILABLE = msg('News feed unavailable');
+// The providers a server without a news pipeline reads each section from,
+// by the key registry's ids (keySetupCore.mjs): name and variable. A copy, so
+// the layer pulls in no registry; a test holds the two together.
+export const NEWS_KEY_PROVIDERS = Object.freeze({
+ liveuamap: { name: 'Liveuamap', envVar: 'LIVEUAMAP_API_KEY' }, // i18n-ignore: provider product name
+ worldnewsapi: { name: 'World News API', envVar: 'WORLDNEWSAPI_KEY' }, // i18n-ignore: provider product name
+});
+// A read refused for want of a key, or its day's allowance spent:
+// `{id, rejected, quota}`, else null.
+const keyNeed = (cause) =>
+ cause?.keyRequired && Object.hasOwn(NEWS_KEY_PROVIDERS, cause.keyId)
+ ? {
+ id: cause.keyId,
+ rejected: cause.keyRejected === true,
+ quota: cause.keyQuota === true,
+ }
+ : null;
+/** What the News panel says of a section's missing, refused or spent key. */
+function keyNotice(need) {
+ if (!need) return null;
+ const { name: provider, envVar: envVars } = NEWS_KEY_PROVIDERS[need.id];
+ if (need.quota)
+ return {
+ title: t('{provider} limit reached for today', { provider }),
+ hint: t('News resumes tomorrow, or with a paid plan'),
+ };
+ return need.rejected
+ ? {
+ title: t('{provider} refused its key', { provider }),
+ hint: t('Check {envVars} in Provider Settings', { envVars }),
+ }
+ : {
+ title: t('{provider} key required', { provider }),
+ hint: t('Needs {envVars} — add it in Provider Settings', { envVars }),
+ };
+}
const REPORT_PREFIX = `${NEWS_PICK_PREFIX}report:`;
const HOTSPOT_PREFIX = `${NEWS_PICK_PREFIX}hotspot:`;
const DEVELOPING_PREFIX = `${NEWS_PICK_PREFIX}developing:`;
+const LOCAL_PREFIX = `${NEWS_PICK_PREFIX}local:`;
// Outlines in GEV's own colours, apart from the severity ramp: hotspots in
// the accent, developing places in its lighter tint, disputed areas dotted
-// in the primary text colour.
+// in the primary text colour, the Local section's area a faint line of it.
const DEVELOPING_COLOR = '#9defff';
const DISPUTED_COLOR = '#e8eaed';
const HOTSPOT_COLOR = '#00d4ff';
+// Disputed areas' names are context, not news.
+const DISPUTED_LABEL_SCALE = 0.75;
// A filtered feed page that shows almost nothing reads the next page by
// itself, a few times, so a strict severity floor still fills the list.
const AUTO_PAGES = 3;
@@ -88,8 +136,11 @@ const emptyFeed = () => ({
error: null,
});
// Local: `active` while the camera is low enough to have a "near here";
-// `minor` holds the day's reports with minor news included, read once while
-// the Local section is chosen.
+// `minor` holds the day's reports for the area, minor news included, read
+// once per area while the Local section is chosen (and with each refresh);
+// `area` is the area named at the view centre (`{key, name, kind, country}`,
+// null at sea), `areaPolygons` its outline and `areaAt` the point it was
+// named at; `readKey` the area `minor` is of.
const emptyInView = () => ({
active: false,
cell: null,
@@ -97,15 +148,20 @@ const emptyInView = () => ({
minor: null,
loading: false,
error: null,
+ area: null,
+ areaPolygons: null,
+ areaAt: null,
+ areaLoading: false,
+ readKey: null,
});
/**
- * News from curated hotspots, real time only (the last 24 hours). Zoomed out:
- * one beacon per hotspot with its daily count and trend, and a breathing
- * beacon per developing situation. Close up or with a hotspot chosen:
- * outlines (disputed areas dotted) and the geocoded reports as markers, the
- * big events as picture cards. The News panel renders the feed this layer
- * publishes through getRowControls().feed.
+ * News from the hotspots a news server names, real time only (the last 24
+ * hours). Zoomed out: one beacon per hotspot with its daily count and trend,
+ * and a breathing beacon per developing situation. Close up or with a hotspot
+ * chosen: outlines (disputed areas dotted) and the geocoded reports as
+ * markers, the big events as picture cards. The News panel renders the feed
+ * this layer publishes through getRowControls().feed.
*/
export function createNewsLayer({
source,
@@ -116,6 +172,7 @@ export function createNewsLayer({
hitTestOverlay = hitTestWorldOverlay,
openUrl = (url) => globalThis.open?.(url, '_blank', 'noopener,noreferrer'),
getViewCentre = viewCentre,
+ registerCredit = registerDynamicCredit,
} = {}) {
if (
typeof source?.getHotspots !== 'function' ||
@@ -133,6 +190,7 @@ export function createNewsLayer({
request = null,
feedRequest = null,
inViewRequest = null,
+ areaRequest = null,
settleTimer = null,
pulseTimer = null,
removeCameraListener = null;
@@ -143,8 +201,13 @@ export function createNewsLayer({
error = null,
lastUpdate = null,
lastRunAt = null,
- autoPages = 0;
+ autoPages = 0,
+ // A server without the area route (/outline?at=) answers so once; Local
+ // then keeps to the radius until the layer is turned off.
+ noAreaRoute = false;
let hotspots = [];
+ // Each section's missing or refused key (keyNeed), while its reads say so.
+ let needs = { conflicts: null, local: null };
// The window the server serves (newsWindowMs): a host may serve
// fewer hours than the day. `windowMs` is the one shown: that window, or a
// shorter one chosen in the panel (filters.hours).
@@ -159,6 +222,19 @@ export function createNewsLayer({
let shownDetail = null;
let cullTargets = [];
const contextEntities = new Map();
+ // Credits are registered once what they credit first shows; the registry
+ // keeps them for the session (as TomTom's) and has no removal.
+ const credited = new Set();
+ function credit(entry, shown) {
+ if (shown && !credited.has(entry.key) && registerCredit(viewer, entry))
+ credited.add(entry.key);
+ }
+ // Liveuamap asks to be credited wherever its mapped reports show.
+ const creditReports = (reports) =>
+ credit(
+ LIVEUAMAP_CREDIT,
+ reports.some((r) => r?.kind === 'liveuamap'),
+ );
const notify = () => listener?.();
const requestRender = () => viewer?.scene?.requestRender?.();
const reducedMotion = () =>
@@ -189,6 +265,7 @@ export function createNewsLayer({
now(),
limit,
windowMs,
+ inView.areaPolygons,
);
const shownWindowMs = () =>
Math.min(maxWindowMs, (filters.hours ?? Infinity) * 3_600_000);
@@ -389,20 +466,21 @@ export function createNewsLayer({
priority: 10_000 + hotspot.last24h,
}),
);
- // Each disputed area is named, with who holds it and who claims it.
+ // Each disputed area is named, smaller than the news labels and below
+ // them in priority. Its title only: a label paints no detail line, yet
+ // the overlay sizes the label for one, which left a wide empty box.
for (const area of outlineDisputed.get(hotspot.id) || []) {
const ring = area.rings[0];
const lon = ring.reduce((sum, p) => sum + p[0], 0) / ring.length;
const lat = ring.reduce((sum, p) => sum + p[1], 0) / ring.length;
- const label = disputedLabel(area);
labels.push(
ambientLabel({
id: `disputed:${hotspot.id}:${area.name}`,
position: cesium.Cartesian3.fromDegrees(lon, lat),
- title: label.title,
- details: [label.detail],
+ title: disputedLabel(area),
accent: DISPUTED_COLOR,
priority: 8_000,
+ presentationScale: DISPUTED_LABEL_SCALE,
}),
);
}
@@ -538,8 +616,9 @@ export function createNewsLayer({
}
}
- // The selected report's card: what, where, how attested, and the link to
- // the original article ("Source: host ↗"), which a click on it opens.
+ // The selected report's card: what, where, how attested, the link to the
+ // original article ("Source: host ↗"), which a click on it opens, and the
+ // service it came through.
function selectedCard(report, labels) {
if (!report?.pins.length) return;
const pin = report.pins[selectedPin] ? selectedPin : 0;
@@ -567,6 +646,7 @@ export function createNewsLayer({
attestation(report)?.label || '',
mediaLabels(report).join(' · '),
sourceLinkLabel(report.url),
+ attributionLabel(report),
].filter(Boolean),
accent: severityStyle(report).color,
selected: true,
@@ -612,6 +692,7 @@ export function createNewsLayer({
? [...shown, current]
: shown;
renderMarkers(entities, drawn);
+ if (enabled) creditReports(drawn.filter((r) => r.pins.length && onMap(r)));
entities.resumeEvents();
showOutlines(detail);
const at = cesium.JulianDate.now();
@@ -724,7 +805,7 @@ export function createNewsLayer({
function addOutline(
key,
{ polygons, borders, disputed = [] },
- { color, fillAlpha, pickId },
+ { color, fillAlpha, pickId, lineAlpha = 0.85, width = 2 },
) {
if (!outlineSource || outlineParts.has(key)) return;
const css = cesium.Color.fromCssColorString(color);
@@ -761,8 +842,8 @@ export function createNewsLayer({
line(
`${pickId}~line${index}.${k}`,
closed(points),
- 2,
- css.withAlpha(0.85),
+ width,
+ css.withAlpha(lineAlpha),
);
}
// National borders inside a multi-country hotspot: thin and dashed.
@@ -829,10 +910,21 @@ export function createNewsLayer({
const developing = new Set(
hotspots.flatMap((h) => (h.developing || []).map((d) => d.key)),
);
+ const localKey =
+ localMode() && inView.area ? `local:${inView.area.key}` : null;
+ // The built-in server's area outlines are Natural Earth's.
+ credit(NATURAL_EARTH_CREDIT, enabled && outlineParts.has(localKey));
for (const [key, parts] of outlineParts) {
const country = key.startsWith('developing:') ? key.slice(11) : null;
- const visible = enabled && (country ? developing.has(country) : detail);
- const fill = visible && !country && filters.hotspot !== key;
+ const isLocal = key.startsWith('local:');
+ const visible =
+ enabled &&
+ (isLocal
+ ? key === localKey
+ : country
+ ? developing.has(country)
+ : detail);
+ const fill = visible && !country && !isLocal && filters.hotspot !== key;
for (const entity of parts.fills)
if (entity.show !== fill) entity.show = fill;
for (const entity of parts.borders)
@@ -862,6 +954,9 @@ export function createNewsLayer({
);
if (controller.signal.aborted || feedRequest !== controller || !enabled)
return;
+ // Any news server may refuse a section for a key; a read that passes
+ // clears it, not only the next refresh.
+ needs = { ...needs, conflicts: null };
feed = {
...mergeFeedPage(feed, page, mode, now(), undefined, windowMs),
loading: false,
@@ -869,10 +964,12 @@ export function createNewsLayer({
};
} catch (cause) {
if (controller.signal.aborted || feedRequest !== controller) return;
+ const need = keyNeed(cause);
+ if (need) needs = { ...needs, conflicts: need };
feed = {
...feed,
loading: false,
- error: cause?.message || FEED_UNAVAILABLE,
+ error: need ? null : cause?.message || FEED_UNAVAILABLE,
};
} finally {
if (feedRequest === controller) feedRequest = null;
@@ -890,11 +987,11 @@ export function createNewsLayer({
}
}
- // "In view": below 3,000 km, the reports within 500 km of the view centre,
- // placed again once the camera has settled for 0.8 s in another half-degree
- // cell. The feed has no area query: the layer already holds the last day's
- // geocoded reports (the map's), kept here by distance. Only "Include minor
- // news" needs its own read, once per refresh, never per pan.
+ // "In view" (the Local section): below 3,000 km, the reports in the area
+ // named at the view centre (else, at sea, within 500 km of it), placed
+ // again once the camera has settled for 0.8 s in another half-degree cell.
+ // Each area is read once (its reports, minor news included, by the `at`
+ // query) and again with each refresh; a pan inside it reads nothing.
function scheduleInView() {
clearTimeout(settleTimer);
settleTimer = setTimeout(() => {
@@ -908,7 +1005,8 @@ export function createNewsLayer({
if (!(height < NEWS_IN_VIEW_MAX_HEIGHT_M)) {
if (inView.active) {
inViewRequest?.abort();
- inViewRequest = null;
+ areaRequest?.abort();
+ inViewRequest = areaRequest = null;
inView = emptyInView();
if (local()) render();
notify();
@@ -921,13 +1019,31 @@ export function createNewsLayer({
if (!force && inView.active && cell === inView.cell) return;
inView = { ...inView, active: true, cell, centre };
if (!local()) {
+ // Conflicts reads neither the area nor its reports.
inViewRequest?.abort();
- inViewRequest = null;
- inView = { ...inView, minor: null, loading: false, error: null };
+ areaRequest?.abort();
+ inViewRequest = areaRequest = null;
+ inView = {
+ ...inView,
+ minor: null,
+ loading: false,
+ error: null,
+ areaLoading: false,
+ };
notify();
return;
}
- if (!force && (inView.minor || inViewRequest)) {
+ // The area first: the read asks for its reports (and wakes its
+ // country's national feeds on the server).
+ if ((await refreshArea(centre)) === 'superseded') return;
+ // The section changed, or the layer went off, while the area was read.
+ if (!local() || !enabled) return;
+ const readKey = inView.area?.key ?? '';
+ if (
+ !force &&
+ inView.readKey === readKey &&
+ (inView.minor || inViewRequest)
+ ) {
render();
notify();
return;
@@ -946,12 +1062,15 @@ export function createNewsLayer({
events: true,
relevance: '',
limit: NEWS_MAP_LIMIT,
+ at: inView.area ? inView.areaAt : undefined,
},
{ signal: controller.signal },
);
if (controller.signal.aborted || inViewRequest !== controller) return;
+ needs = { ...needs, local: null };
inView = {
...inView,
+ readKey,
minor: page.reports.filter((r) =>
withinNewsWindow(r, now(), maxWindowMs),
),
@@ -960,10 +1079,15 @@ export function createNewsLayer({
};
} catch (cause) {
if (controller.signal.aborted || inViewRequest !== controller) return;
+ const need = keyNeed(cause);
+ needs = { ...needs, local: need };
inView = {
...inView,
+ // A missing key is read again with the next refresh, not each pan.
+ readKey: need ? readKey : inView.readKey,
+ minor: need ? [] : inView.minor,
loading: false,
- error: cause?.message || FEED_UNAVAILABLE,
+ error: need ? null : cause?.message || FEED_UNAVAILABLE,
};
} finally {
if (inViewRequest === controller) {
@@ -974,6 +1098,67 @@ export function createNewsLayer({
}
}
+ // The area named at the view centre, read again only once the centre has
+ // left the one shown. Without one (at sea, or a server that names none)
+ // Local keeps to the radius. 'superseded' when a later look took over.
+ async function refreshArea(centre) {
+ if (!source.getAreaOutline || noAreaRoute) return 'none';
+ if (inView.areaPolygons && inPolygons(inView.areaPolygons, centre)) {
+ // Back inside the area shown: a read still out for where the camera
+ // was must not land over it.
+ if (areaRequest) {
+ areaRequest.abort();
+ areaRequest = null;
+ inView = { ...inView, areaLoading: false };
+ }
+ return 'same';
+ }
+ areaRequest?.abort();
+ const controller = new AbortController();
+ areaRequest = controller;
+ inView = { ...inView, areaLoading: true };
+ notify();
+ let found = null;
+ try {
+ found = await source.getAreaOutline(centre, {
+ signal: controller.signal,
+ });
+ } catch (cause) {
+ // No area: the radius stands in for it. A server without the route
+ // (or one that refuses the query) is not asked again.
+ if (cause?.unavailable === true || cause?.status === 400)
+ noAreaRoute = true;
+ }
+ if (controller.signal.aborted || areaRequest !== controller)
+ return 'superseded';
+ areaRequest = null;
+ // The camera moved on while this was read, and no later read replaced it.
+ if (inView.centre !== centre) {
+ inView = { ...inView, areaLoading: false };
+ notify();
+ return 'superseded';
+ }
+ const area = found?.area && found.polygons.length ? found.area : null;
+ if (area)
+ addOutline(`local:${area.key}`, found, {
+ color: DISPUTED_COLOR,
+ fillAlpha: 0,
+ lineAlpha: 0.45,
+ width: 1.5,
+ pickId: `${LOCAL_PREFIX}${area.key}`,
+ });
+ inView = {
+ ...inView,
+ area,
+ areaPolygons: area ? found.polygons : null,
+ areaAt: area ? centre : null,
+ areaLoading: false,
+ };
+ render();
+ notify();
+ return 'read';
+ }
+
function installSelection() {
if (clickHandler || !viewer?.scene?.canvas) return;
clickHandler = new cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
@@ -1037,6 +1222,11 @@ export function createNewsLayer({
const at = now();
const report = selected();
const total = hotspots.reduce((sum, h) => sum + h.last24h, 0);
+ const listed = feed.items.filter((r) =>
+ passesFilters(r, filters, at, windowMs),
+ );
+ const nearby = localReports(NEWS_LOCAL_LIMIT);
+ if (enabled) creditReports([...(local() ? nearby : listed), report]);
return {
filters,
hotspots: [
@@ -1056,14 +1246,19 @@ export function createNewsLayer({
label: t(level.label),
color: level.color,
})),
- items: feed.items
- .filter((r) => passesFilters(r, filters, at, windowMs))
- .map((r) => feedCardView(r, at)),
+ items: listed.map((r) => feedCardView(r, at)),
inView: {
active: inView.active,
- loading: inView.loading,
+ loading: inView.loading || inView.areaLoading,
+ // What Local reads: the named area, else the radius at sea.
+ area: inView.area ? areaLabel(inView.area) : null,
+ // Until the first area is named, the panel names neither it nor the radius.
+ areaLoading: inView.areaLoading,
+ radiusKm: NEWS_IN_VIEW_RADIUS_KM,
error: inView.error ? t(FEED_UNAVAILABLE) : null,
- items: localReports(NEWS_LOCAL_LIMIT).map((r) => feedCardView(r, at)),
+ // Local's key, when the server reads it from one and has none.
+ key: keyNotice(needs.local),
+ items: nearby.map((r) => feedCardView(r, at)),
},
selected:
report && withinNewsWindow(report, at, windowMs)
@@ -1074,6 +1269,8 @@ export function createNewsLayer({
loading: feed.loading || loading,
// Read failures are English data; the panel shows them translated.
error: error || feed.error ? t(error || feed.error) : null,
+ // Conflicts' key, likewise.
+ key: keyNotice(needs.conflicts),
lastRunAt,
windowHours: windowMs / 3_600_000,
windowChoices: newsWindowChoices(maxWindowMs / 3_600_000),
@@ -1125,11 +1322,13 @@ export function createNewsLayer({
disable() {
enabled = false;
unavailable = false;
+ noAreaRoute = false;
error = null;
request?.abort();
feedRequest?.abort();
inViewRequest?.abort();
- request = feedRequest = inViewRequest = null;
+ areaRequest?.abort();
+ request = feedRequest = inViewRequest = areaRequest = null;
clearTimeout(settleTimer);
settleTimer = null;
loading = false;
@@ -1148,6 +1347,7 @@ export function createNewsLayer({
contextEntities.clear();
feed = emptyFeed();
inView = emptyInView();
+ needs = { conflicts: null, local: null };
mapReports = [];
if (dataSource) {
dataSource.entities.removeAll();
@@ -1190,6 +1390,7 @@ export function createNewsLayer({
if (!enabled || controller.signal.aborted || request !== controller)
return false;
hotspots = index.hotspots;
+ needs = { ...needs, conflicts: null };
loadOutlines();
lastRunAt = index.lastRunAt;
// A server that names no limit serves the window its counts cover
@@ -1212,6 +1413,19 @@ export function createNewsLayer({
return true;
} catch (cause) {
if (controller.signal.aborted || request !== controller) return false;
+ const need = keyNeed(cause);
+ if (need) {
+ // Conflicts has no key: nothing of its own, and Local still reads.
+ needs = { ...needs, conflicts: need };
+ hotspots = [];
+ mapReports = [];
+ feed = emptyFeed();
+ filters = normalizeFilters({}, hotspots, filters);
+ error = null;
+ render();
+ void refreshInView({ force: inView.active });
+ return true;
+ }
unavailable = cause?.unavailable === true;
error = cause?.message || FEED_UNAVAILABLE;
return true;
@@ -1272,7 +1486,7 @@ export function createNewsLayer({
chips: [],
legend: [],
info: t(
- 'Last 24 h from curated hotspots. Each report links to its publisher; places and categories are read from the text automatically and can be wrong. Side labels describe the source, not whether a report is true.',
+ 'Last 24 h of news. Each report links to its publisher; places and categories are read from the text and data automatically and can be wrong. Side labels, where shown, describe the source, not whether a report is true.',
),
// A ready-to-render view for the News panel (src/ui/newsPanel.js),
// which takes no layer imports: labels and colours are resolved here.
diff --git a/src/layers/news/layer.test.mjs b/src/layers/news/layer.test.mjs
index 782a7f3d4..97314a6c4 100644
--- a/src/layers/news/layer.test.mjs
+++ b/src/layers/news/layer.test.mjs
@@ -1,7 +1,8 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
-import { createNewsLayer } from './index.js';
+import { KEY_SETUP_KEYS } from '../../keySetupCore.mjs';
+import { NEWS_KEY_PROVIDERS, createNewsLayer } from './index.js';
const NOW = Date.parse('2026-10-28T10:00:00Z');
const hotspotsPayload = {
@@ -43,12 +44,14 @@ function fixture(
height = 20_000_000,
reduced = false,
centre = () => ({ lat: 49, lon: 36 }),
+ credits = [],
} = {},
) {
const calls = { feed: [], hotspots: 0, outline: 0 };
const reports = [
report('major', {
severity: 3,
+ attribution: 'GDELT Project',
event: {
size: 3,
severity: 3,
@@ -110,6 +113,10 @@ function fixture(
now: () => NOW,
matchMedia: () => ({ matches: reduced }),
getViewCentre: () => centre(),
+ registerCredit: (_viewer, credit) => {
+ credits.push(credit.key);
+ return true;
+ },
});
// The context store announces selections on `window`, which Node lacks.
const originalWindow = globalThis.window;
@@ -125,6 +132,34 @@ function fixture(
}
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
+const flush = async () => {
+ for (let i = 0; i < 6; i++) await Promise.resolve();
+};
+const keyError = (key, extra = {}) =>
+ Object.assign(new Error('News key required'), {
+ keyRequired: true,
+ keyRejected: false,
+ keyId: key,
+ retryable: true,
+ ...extra,
+ });
+const ukraine = {
+ area: { key: 'UA', name: 'Ukraine', kind: 'country', country: 'UA' },
+ polygons: [
+ {
+ outer: [
+ [35, 48],
+ [37, 48],
+ [37, 50.5],
+ [35, 50.5],
+ ],
+ holes: [],
+ },
+ ],
+ borders: [],
+ disputed: [],
+};
+const noArea = { area: null, polygons: [], borders: [], disputed: [] };
test('the feed shows the last 24 h at significant and above, beacons when zoomed out', async (t) => {
const { layer, calls, entries } = fixture(t);
@@ -165,6 +200,9 @@ test('choosing a card selects it and flies straight down from 150 km; reduced mo
assert.ok(Math.abs(Cesium.Math.toDegrees(at.latitude) - 50) < 1e-6);
const card = entries.at(-1).find((e) => e.id === 'selected-report:major');
assert.ok(card.details.includes('Source: example.com ↗'));
+ // The service the report came through is credited where it shows.
+ assert.ok(card.details.includes('via GDELT Project'));
+ assert.equal(layer.getRowControls().feed.selected.via, 'via GDELT Project');
assert.equal(layer.getRowControls().feed.selected.id, 'major');
layer.setParams({ clear: true });
assert.equal(layer.getRowControls().feed.selected, null);
@@ -273,6 +311,106 @@ test('Local reads every report once, lists those near the view and takes the map
assert.deepEqual(view.inView.items, []);
});
+test('Local reads the area at the view centre, lists its reports and outlines it faintly', async (t) => {
+ t.mock.timers.enable({ apis: ['setTimeout'] });
+ let centre = { lat: 49, lon: 36 };
+ const areaReads = [];
+ const feedReads = [];
+ const box = [
+ [35, 48],
+ [37, 48],
+ [37, 50.5],
+ [35, 50.5],
+ ];
+ const { layer, camera, viewer } = fixture(t, {
+ height: 1_000_000,
+ centre: () => centre,
+ source: {
+ getFeed: async (query) => {
+ feedReads.push(query);
+ return {
+ reports: [
+ report('inside', { severity: 1, relevance: 'minor' }),
+ report('beyond', {
+ severity: 2,
+ pins: [{ lat: 49, lon: 39, place: 'Luhansk', mappable: true }],
+ }),
+ ],
+ nextBefore: null,
+ };
+ },
+ getAreaOutline: async (point) => {
+ areaReads.push(point);
+ return point.lat > 45
+ ? {
+ area: {
+ key: 'UA',
+ name: 'Ukraine',
+ kind: 'country',
+ country: 'UA',
+ },
+ polygons: [{ outer: box, holes: [] }],
+ borders: [],
+ disputed: [],
+ }
+ : { area: null, polygons: [], borders: [], disputed: [] };
+ },
+ },
+ });
+ const outline = () =>
+ viewer.added[0].entities.values.filter((e) =>
+ String(e.id).includes('local:UA'),
+ );
+ layer.enable();
+ await layer.update();
+ await flush();
+ // Conflicts reads no area.
+ assert.equal(areaReads.length, 0);
+ layer.setParams({ filters: { section: 'local' } });
+ await flush();
+ assert.equal(areaReads.length, 1);
+ // The read asks the server for the area's reports, at the point named.
+ const localRead = () => feedReads.filter((q) => q.relevance === '').at(-1);
+ assert.deepEqual(localRead().at, { lat: 49, lon: 36 });
+ let view = layer.getRowControls().feed;
+ assert.equal(view.inView.area, 'Ukraine');
+ // Luhansk is within 500 km, outside the area.
+ assert.deepEqual(
+ view.inView.items.map((item) => item.id),
+ ['inside'],
+ );
+ assert.ok(outline().length > 0);
+ assert.ok(
+ outline().every((e) => e.show && e.polyline.width.getValue() === 1.5),
+ );
+ // A pan inside the area reads it again only once the centre leaves it.
+ centre = { lat: 50, lon: 36.5 };
+ camera.moveEnd.raiseEvent();
+ t.mock.timers.tick(800);
+ await flush();
+ assert.equal(areaReads.length, 1);
+ // At sea: no area, the radius, and the outline hidden.
+ centre = { lat: 43, lon: 34 };
+ camera.moveEnd.raiseEvent();
+ t.mock.timers.tick(800);
+ await flush();
+ assert.equal(areaReads.length, 2);
+ assert.equal(localRead().at, undefined);
+ view = layer.getRowControls().feed;
+ assert.equal(view.inView.area, null);
+ assert.equal(view.inView.radiusKm, 500);
+ assert.ok(outline().every((e) => !e.show));
+ // Conflicts hides the area's outline too.
+ centre = { lat: 49, lon: 36 };
+ camera.moveEnd.raiseEvent();
+ t.mock.timers.tick(800);
+ await flush();
+ assert.ok(outline().every((e) => e.show));
+ layer.setParams({ filters: { section: 'conflicts' } });
+ await flush();
+ assert.ok(outline().every((e) => !e.show));
+});
+
test('a shorter window narrows the feed and reads the hotspot counts for it', async (t) => {
const asked = [];
const { layer } = fixture(t, {
@@ -351,6 +489,316 @@ test('a server without news is unavailable once and is not asked again', async (
assert.equal(reads, 2);
});
+test('a section read from a key the server lacks says which key, and the other still reads', async (t) => {
+ t.mock.timers.enable({ apis: ['setTimeout'] });
+ let liveuamap = keyError('liveuamap');
+ let worldNews = keyError('worldnewsapi');
+ let hotspotReads = 0;
+ const { layer } = fixture(t, {
+ height: 1_000_000,
+ source: {
+ getHotspots: async () => {
+ hotspotReads++;
+ if (liveuamap) throw liveuamap;
+ return hotspotsPayload;
+ },
+ getFeed: async (query) => {
+ if (query.at && worldNews) throw worldNews;
+ if (!query.at && liveuamap) throw liveuamap;
+ return {
+ reports: [report('inside', { relevance: 'minor' })],
+ nextBefore: null,
+ };
+ },
+ getAreaOutline: async () => ukraine,
+ },
+ });
+ layer.enable();
+ assert.equal(await layer.update(), true);
+ await flush();
+ let view = layer.getRowControls().feed;
+ assert.equal(view.key.title, 'Liveuamap key required');
+ assert.match(view.key.hint, /LIVEUAMAP_API_KEY/);
+ assert.equal(view.error, null, 'a missing key is no failure');
+ assert.equal(layer.getStats().sourceUnavailable, undefined);
+
+ layer.setParams({ filters: { section: 'local' } });
+ await flush();
+ view = layer.getRowControls().feed;
+ assert.equal(view.inView.key.title, 'World News API key required');
+ assert.match(view.inView.key.hint, /WORLDNEWSAPI_KEY/);
+ assert.equal(view.inView.error, null);
+
+ // A key set meanwhile is picked up by the next refresh.
+ worldNews = null;
+ await layer.update();
+ await flush();
+ view = layer.getRowControls().feed;
+ assert.equal(view.inView.key, null);
+ assert.deepEqual(
+ view.inView.items.map((item) => item.id),
+ ['inside'],
+ );
+ assert.equal(view.key.title, 'Liveuamap key required');
+
+ liveuamap = keyError('liveuamap', { keyRejected: true });
+ await layer.update();
+ view = layer.getRowControls().feed;
+ assert.equal(view.key.title, 'Liveuamap refused its key');
+ assert.equal(view.key.hint, 'Check LIVEUAMAP_API_KEY in Provider Settings');
+
+ liveuamap = null;
+ await layer.update();
+ await flush();
+ view = layer.getRowControls().feed;
+ assert.equal(view.key, null);
+ assert.equal(hotspotReads, 4, 'a missing key is asked again each refresh');
+});
+
+test("Local says when its provider's allowance for the day is spent, until a read passes", async (t) => {
+ let quota = true;
+ const { layer } = fixture(t, {
+ height: 1_000_000,
+ source: {
+ getFeed: async (query) => {
+ if (query.at && quota)
+ throw keyError('worldnewsapi', { keyQuota: true });
+ return {
+ reports: [report('inside', { relevance: 'minor' })],
+ nextBefore: null,
+ };
+ },
+ getAreaOutline: async () => ukraine,
+ },
+ });
+ layer.enable();
+ await layer.update();
+ await flush();
+ layer.setParams({ filters: { section: 'local' } });
+ await flush();
+ let view = layer.getRowControls().feed;
+ assert.deepEqual(view.inView.key, {
+ title: 'World News API limit reached for today',
+ hint: 'News resumes tomorrow, or with a paid plan',
+ });
+ assert.equal(view.inView.error, null, 'a spent allowance is no failure');
+ quota = false;
+ await layer.update();
+ await flush();
+ view = layer.getRowControls().feed;
+ assert.equal(view.inView.key, null);
+ assert.deepEqual(
+ view.inView.items.map((item) => item.id),
+ ['inside'],
+ );
+});
+
+test('a Conflicts feed read that passes clears its key notice', async (t) => {
+ let refuse = true;
+ const { layer } = fixture(t, {
+ source: {
+ getFeed: async (query) => {
+ // The panel's feed page, not the map's read.
+ if (query.limit === 60 && refuse) throw keyError('liveuamap');
+ return {
+ reports: [report('major', { severity: 3 })],
+ nextBefore: null,
+ };
+ },
+ },
+ });
+ layer.enable();
+ await layer.update();
+ await settle();
+ assert.equal(layer.getRowControls().feed.key.title, 'Liveuamap key required');
+ refuse = false;
+ layer.setParams({ filters: { category: 'strike' } });
+ await settle();
+ const view = layer.getRowControls().feed;
+ assert.equal(view.key, null);
+ assert.deepEqual(
+ view.items.map((item) => item.id),
+ ['major'],
+ );
+});
+
+test('the key providers the layer names match the key registry', () => {
+ for (const [id, { envVar }] of Object.entries(NEWS_KEY_PROVIDERS)) {
+ const entry = KEY_SETUP_KEYS.find((key) => key.id === id);
+ assert.ok(entry, `${id} is in the key registry`);
+ assert.deepEqual([...entry.envVars], [envVar]);
+ }
+});
+
+test('a slower area read for where the camera was never lands over the area it came back to', async (t) => {
+ t.mock.timers.enable({ apis: ['setTimeout'] });
+ let centre = { lat: 49, lon: 36 };
+ const pending = [];
+ const { layer, camera } = fixture(t, {
+ height: 1_000_000,
+ centre: () => centre,
+ source: {
+ getAreaOutline: (point) =>
+ point.lat > 45
+ ? Promise.resolve(ukraine)
+ : new Promise((resolve) => pending.push(resolve)),
+ },
+ });
+ const look = async (at) => {
+ centre = at;
+ camera.moveEnd.raiseEvent();
+ t.mock.timers.tick(800);
+ await flush();
+ };
+ layer.enable();
+ await layer.update();
+ await flush();
+ layer.setParams({ filters: { section: 'local' } });
+ await flush();
+ assert.equal(layer.getRowControls().feed.inView.area, 'Ukraine');
+ // Out to sea, where the area read is slow, and back before it answers.
+ await look({ lat: 43, lon: 34 });
+ assert.equal(pending.length, 1);
+ assert.equal(layer.getRowControls().feed.inView.areaLoading, true);
+ await look({ lat: 49.6, lon: 36.2 });
+ pending[0](noArea);
+ await flush();
+ const view = layer.getRowControls().feed;
+ assert.equal(view.inView.area, 'Ukraine');
+ assert.equal(view.inView.areaLoading, false);
+ assert.equal(view.inView.loading, false);
+});
+
+test('leaving Local while its area is read sends no Local read', async (t) => {
+ let answer = null;
+ const localReads = [];
+ // The camera stays put: only the section changes.
+ const here = { lat: 49, lon: 36 };
+ const { layer } = fixture(t, {
+ height: 1_000_000,
+ centre: () => here,
+ source: {
+ getFeed: async (query) => {
+ if (query.relevance === '') localReads.push(query);
+ return { reports: [], nextBefore: null };
+ },
+ getAreaOutline: () =>
+ new Promise((resolve) => {
+ answer = resolve;
+ }),
+ },
+ });
+ layer.enable();
+ await layer.update();
+ await flush();
+ layer.setParams({ filters: { section: 'local' } });
+ await flush();
+ assert.equal(layer.getRowControls().feed.inView.loading, true);
+ layer.setParams({ filters: { section: 'conflicts' } });
+ await flush();
+ assert.equal(layer.getRowControls().feed.inView.loading, false);
+ answer(ukraine);
+ await flush();
+ assert.equal(localReads.length, 0);
+ assert.equal(layer.getRowControls().feed.inView.loading, false);
+});
+
+test('a server without the area route keeps Local to the radius and is asked once', async (t) => {
+ t.mock.timers.enable({ apis: ['setTimeout'] });
+ let centre = { lat: 49, lon: 36 };
+ let areaReads = 0;
+ const localReads = [];
+ const { layer, camera } = fixture(t, {
+ height: 1_000_000,
+ centre: () => centre,
+ source: {
+ getFeed: async (query) => {
+ if (query.relevance === '') localReads.push(query);
+ return { reports: [report('near')], nextBefore: null };
+ },
+ getAreaOutline: async () => {
+ areaReads++;
+ throw Object.assign(
+ new Error('News feed not configured on this server'),
+ { status: 404, code: 'unavailable', unavailable: true },
+ );
+ },
+ },
+ });
+ layer.enable();
+ await layer.update();
+ await flush();
+ layer.setParams({ filters: { section: 'local' } });
+ await flush();
+ assert.equal(areaReads, 1);
+ assert.equal(localReads.length, 1);
+ assert.equal(localReads[0].at, undefined);
+ let view = layer.getRowControls().feed;
+ assert.equal(view.inView.area, null);
+ assert.equal(view.inView.areaLoading, false);
+ assert.deepEqual(
+ view.inView.items.map((item) => item.id),
+ ['near'],
+ );
+ // The settles that follow do not ask for an area again.
+ centre = { lat: 47, lon: 31 };
+ camera.moveEnd.raiseEvent();
+ t.mock.timers.tick(800);
+ await flush();
+ assert.equal(areaReads, 1);
+ // Turning the layer off and on asks again.
+ layer.disable();
+ layer.enable();
+ await layer.update();
+ await flush();
+ assert.equal(areaReads, 2);
+ view = layer.getRowControls().feed;
+ assert.equal(view.inView.error, null);
+});
+
+test('Liveuamap and Natural Earth are credited once what they credit shows', async (t) => {
+ const credits = [];
+ const { layer } = fixture(t, {
+ height: 1_000_000,
+ credits,
+ source: {
+ getFeed: async (query) => ({
+ reports:
+ query.relevance === ''
+ ? [report('near', { kind: 'rss' })]
+ : [report('mapped', { kind: 'liveuamap', severity: 3 })],
+ nextBefore: null,
+ }),
+ getAreaOutline: async () => ukraine,
+ },
+ });
+ layer.enable();
+ await layer.update();
+ await flush();
+ layer.getRowControls();
+ assert.deepEqual(credits, ['liveuamap']);
+ // The Local area's outline (the built-in server's are Natural Earth's).
+ layer.setParams({ filters: { section: 'local' } });
+ await flush();
+ assert.deepEqual(credits, ['liveuamap', 'natural-earth']);
+ // Shown again: registered once.
+ layer.setParams({ filters: { section: 'conflicts' } });
+ await layer.update();
+ await flush();
+ layer.getRowControls();
+ assert.deepEqual(credits, ['liveuamap', 'natural-earth']);
+});
+
+test('without Liveuamap reports or a Local area, nothing is credited', async (t) => {
+ const credits = [];
+ const { layer } = fixture(t, { height: 1_000_000, credits });
+ layer.enable();
+ await layer.update();
+ await flush();
+ layer.getRowControls();
+ assert.deepEqual(credits, []);
+});
+
test('a failed read keeps the layer on with an error; disable clears the overlay', async (t) => {
const { layer, entries } = fixture(t, {
source: { getFeed: async () => Promise.reject(new Error('boom')) },
diff --git a/src/layers/news/model.js b/src/layers/news/model.js
index 686040e1b..22eb2a1f3 100644
--- a/src/layers/news/model.js
+++ b/src/layers/news/model.js
@@ -43,6 +43,8 @@ export const NEWS_FEED_MAX = 600;
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.
+// Near is inside the area the server names there (a country, a large
+// country's state, a disputed area), else, at sea, within the radius.
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;
@@ -63,12 +65,7 @@ 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 VIA = msg('via {source}');
const MEDIA_LABELS = { video: msg('Video'), photo: msg('Photos') };
const CLAIMED_BY = msg('Claimed by {who}');
const CLAIMED = msg('Claimed');
@@ -102,6 +99,8 @@ export const NEWS_HOTSPOT_LABELS = Object.freeze({
'sudan-war': msg('Sudan'),
sahel: msg('Sahel'),
elsewhere: msg('Elsewhere'),
+ // Core's own news (no news server): the world is one hotspot.
+ worldwide: msg('Worldwide'),
});
/** A hotspot's chip label in the interface language. */
@@ -124,17 +123,25 @@ export function countryName(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),
- }),
- };
+const AREA_IN_COUNTRY = msg('{region}, {country}');
+
+/**
+ * The Local section's area in the interface language: a country by its code,
+ * a state with its country, a disputed area by its own name only.
+ */
+export function areaLabel(area) {
+ if (!area) return '';
+ if (area.kind === 'disputed' || !area.country) return area.name;
+ if (area.kind === 'country') return countryName(area.country);
+ return t(AREA_IN_COUNTRY, {
+ region: area.name,
+ country: countryName(area.country),
+ });
}
+/** Label for a disputed area on the map: its name, marked as disputed. */
+export const disputedLabel = (area) => t(DISPUTED, { name: area.name });
+
// 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.
@@ -184,6 +191,11 @@ export const NEWS_CATEGORIES = Object.freeze({
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',
},
+ // Business, trade and industry: a national feed's news in Local.
+ economy: {
+ label: msg('Economy'),
+ icon: 'M2 13.5 H14 M2.5 11 L6 7.5 L8.5 9.5 L13.5 4.5 M10.5 4.5 H13.5 V7.5',
+ },
other: {
label: msg('Other'),
icon: 'M8 4.5 A3.5 3.5 0 1 0 8.01 4.5 Z',
@@ -507,6 +519,14 @@ export function sourceLinkLabel(url) {
return `${t(SOURCE_LABEL)}: ${host} ↗`;
}
+/**
+ * "via GDELT Project": the service a report was read through, which asks to
+ * be credited where its reports show; '' when the server names none.
+ */
+export function attributionLabel(report) {
+ return report?.attribution ? t(VIA, { source: report.attribution }) : '';
+}
+
/** The places a report is pinned at, joined, or null. */
function placeLine(report) {
return (
@@ -519,8 +539,8 @@ function placeLine(report) {
/**
* 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
+ * headline, a mono meta line (severity, category, place, sources, age, the
+ * service it came through), 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()) {
@@ -548,6 +568,7 @@ export function feedCardView(report, now = Date.now()) {
sources: tn(size, '{count} source', '{count} sources'),
age: ageWords(report.event?.lastAt || report.publishedAt, now),
publisher: report.publisher,
+ via: attributionLabel(report),
sides: sidesLine(report, now),
sourceLink: sourceLinkLabel(report.url),
picture: eventPicture(report),
@@ -718,9 +739,32 @@ export function distanceKm(a, b) {
return 2 * 6371 * Math.asin(Math.min(1, Math.sqrt(s)));
}
+function inRing(ring, { lat, lon }) {
+ let inside = false;
+ for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
+ const [xi, yi] = ring[i];
+ const [xj, yj] = ring[j];
+ if (
+ yi > lat !== yj > lat &&
+ lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi
+ )
+ inside = !inside;
+ }
+ return inside;
+}
+
+/** Whether a point lies in an outline's polygons (`{outer, holes}`). */
+export function inPolygons(polygons, point) {
+ return polygons.some(
+ ({ outer, holes }) =>
+ inRing(outer, point) && !holes.some((hole) => inRing(hole, point)),
+ );
+}
+
/**
- * The reports near `centre` (any pin within NEWS_IN_VIEW_RADIUS_KM) that pass
- * the filters, most severe and newest first.
+ * The reports near `centre` that pass the filters, most severe and newest
+ * first: any pin inside `area` (outline polygons), or without one within
+ * NEWS_IN_VIEW_RADIUS_KM.
*/
export function reportsInView(
reports,
@@ -729,16 +773,18 @@ export function reportsInView(
now = Date.now(),
limit = NEWS_IN_VIEW_LIMIT,
windowMs = NEWS_WINDOW_MS,
+ area = null,
) {
if (!centre) return [];
+ const near = area?.length
+ ? (pin) => inPolygons(area, pin)
+ : (pin) => distanceKm(pin, centre) <= NEWS_IN_VIEW_RADIUS_KM;
return reports
.filter(
(r) =>
passesFilters(r, filters, now, windowMs) &&
(filters.category === 'all' || r.category === filters.category) &&
- r.pins?.some(
- (pin) => distanceKm(pin, centre) <= NEWS_IN_VIEW_RADIUS_KM,
- ),
+ r.pins?.some(near),
)
.sort(
(a, b) =>
diff --git a/src/layers/news/model.test.mjs b/src/layers/news/model.test.mjs
index ed439ebe5..ee8bb781e 100644
--- a/src/layers/news/model.test.mjs
+++ b/src/layers/news/model.test.mjs
@@ -8,6 +8,7 @@ import {
import {
NEWS_IN_VIEW_RADIUS_KM,
ageWords,
+ areaLabel,
beaconPixelSize,
cardEvents,
cardView,
@@ -17,6 +18,7 @@ import {
feedQueryFor,
hotspotLabel,
hotspotTrend,
+ inPolygons,
isPressing,
markerStyle,
mergeFeedPage,
@@ -218,7 +220,61 @@ test('outlines keep valid rings, borders and named disputed areas', () => {
polygons: [],
borders: [],
disputed: [],
+ area: null,
});
+ // Indonesia's 208 islands are all kept.
+ const islands = Array.from({ length: 208 }, () => ({ outer: ring }));
+ assert.equal(
+ normalizeOutlinePayload({ polygons: islands }).polygons.length,
+ 208,
+ );
+});
+
+test('a point outline names its area, and Local keeps to it', () => {
+ const box = [
+ [35, 48],
+ [37, 48],
+ [37, 50.5],
+ [35, 50.5],
+ ];
+ const { area, polygons } = normalizeOutlinePayload({
+ area: { key: 'US-TX', name: 'Texas', kind: 'region', country: 'US' },
+ polygons: [{ outer: box, holes: [] }],
+ });
+ assert.deepEqual(area, {
+ key: 'US-TX',
+ name: 'Texas',
+ kind: 'region',
+ country: 'US',
+ });
+ assert.equal(areaLabel(area), 'Texas, United States');
+ assert.equal(
+ areaLabel({ ...area, kind: 'country', country: 'UA' }),
+ 'Ukraine',
+ );
+ // A disputed area is named as itself, never as either side's.
+ assert.equal(
+ areaLabel({ key: 'disputed:Crimea', name: 'Crimea', kind: 'disputed' }),
+ 'Crimea',
+ );
+ assert.equal(normalizeOutlinePayload({ area: { key: 'X' } }).area, null);
+ const at = (id, lat, lon) =>
+ report(id, '2026-10-28T09:00:00Z', {
+ pins: [{ lat, lon, mappable: true }],
+ });
+ const reports = [at('inside', 50, 36), at('beyond', 49, 39)];
+ const filters = normalizeFilters({ minSeverity: 0 });
+ const centre = { lat: 49, lon: 36 };
+ const now = Date.parse('2026-10-28T10:00:00Z');
+ const ids = (area) =>
+ reportsInView(reports, centre, filters, now, 10, undefined, area).map(
+ (r) => r.id,
+ );
+ // Within 500 km both; inside the area only the one.
+ assert.deepEqual(ids(null), ['inside', 'beyond']);
+ assert.deepEqual(ids(polygons), ['inside']);
+ assert.ok(inPolygons(polygons, { lat: 49, lon: 36 }));
+ assert.ok(!inPolygons([{ outer: box, holes: [box] }], { lat: 49, lon: 36 }));
});
test('feed pages merge newest first without duplicates', () => {
@@ -427,6 +483,10 @@ test('beacons carry a focus, a size by activity and a trend only against a prior
hotspotLabel({ id: 'x', name: 'X', catchAll: true }),
'Elsewhere',
);
+ assert.equal(
+ hotspotLabel({ id: 'worldwide', name: 'Worldwide', catchAll: true }),
+ 'Worldwide',
+ );
});
test('marker style: severity fill, attestation rim, size by sources, fading with age', () => {
@@ -476,7 +536,7 @@ test('picture cards: physical, two or more sources, major or pressing, at most t
assert.equal(cardView(lone, NOW).image, null);
});
-test('feed cards read like Breaking: headline, meta parts, sides and chips', () => {
+test('feed cards: headline, meta parts, sides and chips', () => {
const r = report('fc', '2026-10-28T09:00:00Z', {
title: 'Удар по Харкову',
titleEn: 'Strike on Kharkiv',
diff --git a/src/layers/news/records.js b/src/layers/news/records.js
index 557216f22..bf4bd5749 100644
--- a/src/layers/news/records.js
+++ b/src/layers/news/records.js
@@ -291,14 +291,17 @@ function normalizeRing(ring) {
}
/**
- * `/api/news/outline` → `{polygons, borders, disputed}`: the dissolved extent
- * (`{outer, holes}`), the national borders inside it (rings) and its disputed
- * areas, invalid parts dropped.
+ * `/api/news/outline` → `{polygons, borders, disputed, area}`: the dissolved
+ * extent (`{outer, holes}`), the national borders inside it (rings), its
+ * disputed areas and, answering a point, the area named there (else null),
+ * invalid parts dropped.
*/
export function normalizeOutlinePayload(payload) {
const polygons = [];
+ // Above every country's island count (Indonesia's is 208): an outline cut
+ // short leaves islands outside the area, read again on each settle there.
for (const raw of Array.isArray(payload?.polygons)
- ? payload.polygons.slice(0, 200)
+ ? payload.polygons.slice(0, 300)
: []) {
const outer = normalizeRing(raw?.outer);
if (!outer) continue;
@@ -332,7 +335,23 @@ export function normalizeOutlinePayload(payload) {
rings,
});
}
- return { polygons, borders, disputed };
+ return { polygons, borders, disputed, area: outlineArea(payload?.area) };
+}
+
+const AREA_KINDS = ['country', 'region', 'disputed'];
+function outlineArea(raw) {
+ const key = text(raw?.key, 80);
+ const name = text(raw?.name, 80);
+ if (!key || !name) return null;
+ return {
+ key,
+ name,
+ kind: AREA_KINDS.includes(raw.kind) ? raw.kind : 'country',
+ country:
+ typeof raw.country === 'string' && /^[A-Z]{2}$/.test(raw.country)
+ ? raw.country
+ : null,
+ };
}
/** `/api/news/feed` → `{reports, nextBefore}`, or null without a report list. */
diff --git a/src/layers/news/source.js b/src/layers/news/source.js
index b08941510..26c9f3492 100644
--- a/src/layers/news/source.js
+++ b/src/layers/news/source.js
@@ -29,10 +29,15 @@ const FEED_KEYS = Object.freeze([
'limit',
'category',
'relevance',
+ 'at',
]);
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}$/;
+// A point to two decimals (~1 km), as areaPoint writes it.
+const AT = /^-?\d{1,2}(?:\.\d{1,2})?,-?\d{1,3}(?:\.\d{1,2})?$/;
+// A coordinate to two decimals, without trailing zeros ("50.4", "-97.74").
+const areaPoint = (value) => String(Math.round(value * 100) / 100);
const FEED_VALUE = {
hotspot: (v) => /^[a-z0-9][a-z0-9_-]{0,63}$/i.test(v),
before: (v) => ISO.test(v),
@@ -40,13 +45,20 @@ const FEED_VALUE = {
limit: (v) => /^\d{1,3}$/.test(v) && Number(v) >= 1 && Number(v) <= 500,
category: (v) => LIST.test(v),
relevance: (v) => LIST.test(v),
+ at: (v) => AT.test(v),
};
-/** Feed query string from a plain object; unknown keys and empty values are dropped. */
+/**
+ * Feed query string from a plain object; unknown keys and empty values are
+ * dropped. `at` is a point (`{lat, lon}`), the Local section's area there.
+ */
export function newsFeedQuery(query = {}) {
const params = new URLSearchParams();
for (const key of FEED_KEYS) {
- const value = query[key];
+ const value =
+ key === 'at' && query.at
+ ? `${areaPoint(query.at.lat)},${areaPoint(query.at.lon)}`
+ : query[key];
if (value !== undefined && value !== null && value !== '')
params.set(key, String(value));
}
@@ -81,7 +93,8 @@ export function newsQueryAllowed(url) {
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'))))
+ /^[A-Z]{2}(?:-[A-Z0-9]{1,3})?$/.test(value.get('country'))) ||
+ (keys[0] === 'at' && AT.test(value.get('at'))))
);
}
if (url.pathname !== `${BASE_PATH}/feed`) return false;
@@ -92,7 +105,12 @@ export function newsQueryAllowed(url) {
);
}
-/** A failed read: a server without the news routes is a permanent miss. */
+/**
+ * A failed read: a server without the news routes is a permanent miss; one
+ * that reads a section from a key it lacks (503 `no_key`, `key_rejected` or,
+ * its allowance for the day spent, `key_quota`, naming the key registry's id)
+ * says which, and is asked again later.
+ */
async function responseError(response) {
let body = null;
try {
@@ -100,6 +118,22 @@ async function responseError(response) {
} catch {
await response.body?.cancel?.().catch?.(() => {});
}
+ if (
+ response.status === 503 &&
+ ['no_key', 'key_rejected', 'key_quota'].includes(body?.error) &&
+ typeof body.key === 'string' &&
+ /^[a-z][a-z0-9-]{0,31}$/.test(body.key)
+ )
+ return Object.assign(new Error(msg('News key required')), {
+ status: 503,
+ code: body.error,
+ keyRequired: true,
+ keyRejected: body.error === 'key_rejected',
+ keyQuota: body.error === 'key_quota',
+ keyId: body.key,
+ unavailable: false,
+ retryable: true,
+ });
const unavailable =
response.status === 404 ||
(response.status === 503 && isUnavailableCapability(body));
@@ -162,6 +196,15 @@ export function createNewsSource({
),
);
},
+ // The area the Local section reads at a point, with its outline.
+ async getAreaOutline({ lat, lon }, { signal } = {}) {
+ return normalizeOutlinePayload(
+ await get(
+ `/outline?at=${areaPoint(lat)},${areaPoint(lon)}&v=${OUTLINE_VERSION}`,
+ { signal },
+ ),
+ );
+ },
async getFeed(query = {}, { signal } = {}) {
const payload = normalizeFeedPayload(
await get(`/feed?${newsFeedQuery(query)}`, { signal }),
diff --git a/src/layers/news/source.test.mjs b/src/layers/news/source.test.mjs
index d421e5725..774d7eb3a 100644
--- a/src/layers/news/source.test.mjs
+++ b/src/layers/news/source.test.mjs
@@ -54,6 +54,8 @@ test('reads validated hotspots, feed and outlines from the same-origin routes',
assert.deepEqual((await source.getOutline('ua')).polygons, []);
await source.getCountryOutline('SD');
await source.getHotspots({ hours: 6 });
+ await source.getAreaOutline({ lat: 30.26666, lon: -97.7 });
+ await source.getFeed({ hotspot: 'all', at: { lat: 30.26666, lon: -97.7 } });
assert.deepEqual(
calls.map((c) => c.url),
[
@@ -62,6 +64,8 @@ test('reads validated hotspots, feed and outlines from the same-origin routes',
'/api/news/outline?hotspot=ua&v=4',
'/api/news/outline?country=SD&v=4',
'/api/news/hotspots?hours=6',
+ '/api/news/outline?at=30.27,-97.7&v=4',
+ '/api/news/feed?hotspot=all&at=30.27%2C-97.7',
],
);
for (const { url, init } of calls) {
@@ -112,6 +116,50 @@ test('malformed, failed and unconfigured reads reject; a server without news is
await assert.rejects(broken.getHotspots(), SyntaxError);
});
+test('a section read from a key the server lacks names the key and is asked again', async () => {
+ const answer = (body) =>
+ createNewsSource({
+ fetchImpl: async () =>
+ new Response(JSON.stringify(body), {
+ status: 503,
+ headers: { 'Content-Type': 'application/json' },
+ }),
+ });
+ await assert.rejects(
+ answer({ error: 'no_key', key: 'liveuamap' }).getHotspots(),
+ (error) => {
+ assert.equal(error.keyRequired, true);
+ assert.equal(error.keyRejected, false);
+ assert.equal(error.keyId, 'liveuamap');
+ assert.equal(error.unavailable, false);
+ assert.equal(error.retryable, true);
+ return true;
+ },
+ );
+ await assert.rejects(
+ answer({ error: 'key_rejected', key: 'worldnewsapi' }).getFeed({
+ at: { lat: 51.13, lon: 71.43 },
+ }),
+ { keyRequired: true, keyRejected: true, keyId: 'worldnewsapi' },
+ );
+ // The day's allowance spent: still the key's matter, back tomorrow.
+ await assert.rejects(
+ answer({ error: 'key_quota', key: 'worldnewsapi' }).getFeed(),
+ {
+ keyRequired: true,
+ keyRejected: false,
+ keyQuota: true,
+ keyId: 'worldnewsapi',
+ unavailable: false,
+ },
+ );
+ // A key name not in the registry's form is an ordinary failure.
+ await assert.rejects(
+ answer({ error: 'no_key', key: '