diff --git a/src/app/country-intel.ts b/src/app/country-intel.ts index ee64cad0e..fde03769f 100644 --- a/src/app/country-intel.ts +++ b/src/app/country-intel.ts @@ -134,8 +134,12 @@ export class CountryIntelManager implements AppModule { const geo = await reverseGeocode(lat, lon); if (token !== this.briefRequestToken) return; if (!geo) { - this.ctx.countryBriefPage.hide(); - this.ctx.map?.setRenderPaused(false); + if (this.ctx.countryBriefPage.showGeoError) { + this.ctx.countryBriefPage.showGeoError(() => this.openCountryBrief(lat, lon)); + } else { + this.ctx.countryBriefPage.hide(); + this.ctx.map?.setRenderPaused(false); + } return; } @@ -343,7 +347,7 @@ export class CountryIntelManager implements AppModule { const page = this.ctx.countryBriefPage; if (!page?.isVisible()) return; const code = page.getCode(); - if (!code || code === '__loading__') return; + if (!code || code === '__loading__' || code === '__error__') return; const name = TIER1_COUNTRIES[code] ?? CountryIntelManager.resolveCountryName(code); const scores = calculateCII(); let score = scores.find((s) => s.code === code) ?? null; diff --git a/src/components/CountryBriefPage.ts b/src/components/CountryBriefPage.ts index 73a26963e..465494d3e 100644 --- a/src/components/CountryBriefPage.ts +++ b/src/components/CountryBriefPage.ts @@ -310,6 +310,56 @@ export class CountryBriefPage implements CountryBriefPanel { this.overlay.classList.add('active'); } + public showGeoError(onRetry: () => void): void { + this.currentCode = '__error__'; + this.overlay.textContent = ''; + + const page = document.createElement('div'); + page.className = 'country-brief-page'; + + const header = document.createElement('div'); + header.className = 'cb-header'; + const headerLeft = document.createElement('div'); + headerLeft.className = 'cb-header-left'; + const flag = document.createElement('span'); + flag.className = 'cb-flag'; + flag.textContent = '\u26A0\uFE0F'; + const title = document.createElement('span'); + title.className = 'cb-country-name'; + title.textContent = t('countryBrief.geocodeFailed'); + headerLeft.append(flag, title); + const headerRight = document.createElement('div'); + headerRight.className = 'cb-header-right'; + const closeX = document.createElement('button'); + closeX.className = 'cb-close'; + closeX.setAttribute('aria-label', t('components.newsPanel.close')); + closeX.textContent = '\u00D7'; + headerRight.append(closeX); + header.append(headerLeft, headerRight); + + const body = document.createElement('div'); + body.className = 'cb-body'; + const errorWrap = document.createElement('div'); + errorWrap.className = 'cb-geo-error'; + const actions = document.createElement('div'); + actions.className = 'cb-geo-error-actions'; + const retryBtn = document.createElement('button'); + retryBtn.className = 'cb-geo-retry-btn'; + retryBtn.textContent = t('countryBrief.retryBtn'); + retryBtn.addEventListener('click', () => onRetry(), { once: true }); + const closeBtn = document.createElement('button'); + closeBtn.className = 'cb-geo-close-btn'; + closeBtn.textContent = t('countryBrief.closeBtn'); + closeBtn.addEventListener('click', () => this.hide(), { once: true }); + actions.append(retryBtn, closeBtn); + errorWrap.append(actions); + body.append(errorWrap); + + page.append(header, body); + this.overlay.append(page); + this.overlay.classList.add('active'); + } + public get signal(): AbortSignal { return this.abortController.signal; } diff --git a/src/components/CountryBriefPanel.ts b/src/components/CountryBriefPanel.ts index 91e5f7ac8..7eca04224 100644 --- a/src/components/CountryBriefPanel.ts +++ b/src/components/CountryBriefPanel.ts @@ -83,6 +83,7 @@ export interface CountryBriefPanel { updateMarkets(markets: PredictionMarket[]): void; updateStock(data: StockIndexData): void; updateInfrastructure(code: string): void; + showGeoError?(onRetry: () => void): void; updateScore?(score: CountryScore | null, signals: CountryBriefSignals): void; updateSignalDetails?(details: CountryDeepDiveSignalDetails): void; updateMilitaryActivity?(summary: CountryDeepDiveMilitarySummary): void; diff --git a/src/components/CountryDeepDivePanel.ts b/src/components/CountryDeepDivePanel.ts index 29e5a005f..27404bba1 100644 --- a/src/components/CountryDeepDivePanel.ts +++ b/src/components/CountryDeepDivePanel.ts @@ -148,6 +148,32 @@ export class CountryDeepDivePanel implements CountryBriefPanel { this.open(); } + public showGeoError(onRetry: () => void): void { + this.currentCode = '__error__'; + this.currentName = null; + this.content.replaceChildren(); + + const wrapper = this.el('div', 'cdp-geo-error'); + wrapper.append( + this.el('div', 'cdp-geo-error-icon', '\u26A0\uFE0F'), + this.el('div', 'cdp-geo-error-msg', t('countryBrief.geocodeFailed')), + ); + + const actions = this.el('div', 'cdp-geo-error-actions'); + + const retryBtn = this.el('button', 'cdp-geo-error-retry', t('countryBrief.retryBtn')) as HTMLButtonElement; + retryBtn.type = 'button'; + retryBtn.addEventListener('click', () => onRetry(), { once: true }); + + const closeBtn = this.el('button', 'cdp-geo-error-close', t('countryBrief.closeBtn')) as HTMLButtonElement; + closeBtn.type = 'button'; + closeBtn.addEventListener('click', () => this.hide(), { once: true }); + + actions.append(retryBtn, closeBtn); + wrapper.append(actions); + this.content.append(wrapper); + } + public show(country: string, code: string, score: CountryScore | null, signals: CountryBriefSignals): void { this.abortController.abort(); this.abortController = new AbortController(); diff --git a/src/locales/ar.json b/src/locales/ar.json index aaf4d9261..7a2a142f6 100644 --- a/src/locales/ar.json +++ b/src/locales/ar.json @@ -6,6 +6,9 @@ "countryBrief": { "identifying": "جارٍ تحديد الدولة...", "locating": "جارٍ تحديد المنطقة...", + "geocodeFailed": "تعذّر تحديد دولة في هذا الموقع", + "retryBtn": "إعادة المحاولة", + "closeBtn": "إغلاق", "limitedCoverage": "تغطية محدودة", "instabilityIndex": "مؤشر عدم الاستقرار", "notTracked": "غير مُتتبَّع — {{country}} ليست في قائمة CII من المستوى الأول", diff --git a/src/locales/bg.json b/src/locales/bg.json index e98edd8ac..8505610ba 100644 --- a/src/locales/bg.json +++ b/src/locales/bg.json @@ -6,6 +6,9 @@ "countryBrief": { "identifying": "Идентифициране на държава...", "locating": "Локализиране на регион...", + "geocodeFailed": "Не може да се определи държава на това местоположение", + "retryBtn": "Опитай отново", + "closeBtn": "Затвори", "limitedCoverage": "Ограничено покритие", "instabilityIndex": "Индекс на нестабилност", "notTracked": "Не се проследява — {{country}} не е в списъка на CII tier-1", diff --git a/src/locales/cs.json b/src/locales/cs.json index c21f2a117..896658b82 100644 --- a/src/locales/cs.json +++ b/src/locales/cs.json @@ -6,6 +6,9 @@ "countryBrief": { "identifying": "Identifikuji zemi...", "locating": "Lokalizuji region...", + "geocodeFailed": "Nepodařilo se identifikovat zemi na tomto místě", + "retryBtn": "Zkusit znovu", + "closeBtn": "Zavřít", "limitedCoverage": "Omezené pokrytí", "instabilityIndex": "Index nestability", "notTracked": "Nesledováno — {{country}} není v seznamu CII tier-1", diff --git a/src/locales/de.json b/src/locales/de.json index 0dd069536..87f5c28d8 100644 --- a/src/locales/de.json +++ b/src/locales/de.json @@ -6,6 +6,9 @@ "countryBrief": { "identifying": "Land wird identifiziert...", "locating": "Region wird gesucht...", + "geocodeFailed": "Land an diesem Standort konnte nicht identifiziert werden", + "retryBtn": "Erneut versuchen", + "closeBtn": "Schließen", "limitedCoverage": "Begrenzte Abdeckung", "instabilityIndex": "Instabilitätsindex", "notTracked": "Nicht verfolgt – {{country}} ist nicht in der CII-Tier-1-Liste", diff --git a/src/locales/el.json b/src/locales/el.json index fd0b9b62e..43cb3abec 100644 --- a/src/locales/el.json +++ b/src/locales/el.json @@ -6,6 +6,9 @@ "countryBrief": { "identifying": "Αναγνώριση χώρας...", "locating": "Εντοπισμός περιοχής...", + "geocodeFailed": "Δεν ήταν δυνατή η αναγνώριση χώρας σε αυτή τη θέση", + "retryBtn": "Επανάληψη", + "closeBtn": "Κλείσιμο", "limitedCoverage": "Περιορισμένη κάλυψη", "instabilityIndex": "Δείκτης Αστάθειας", "notTracked": "Δεν παρακολουθείται — η {{country}} δεν είναι στη λίστα CII tier-1", diff --git a/src/locales/en.json b/src/locales/en.json index 1e115e765..12980b805 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -6,6 +6,9 @@ "countryBrief": { "identifying": "Identifying country...", "locating": "Locating region...", + "geocodeFailed": "Could not identify a country at this location", + "retryBtn": "Retry", + "closeBtn": "Close", "limitedCoverage": "Limited coverage", "instabilityIndex": "Instability Index", "notTracked": "Not tracked — {{country}} is not in the CII tier-1 list", diff --git a/src/locales/es.json b/src/locales/es.json index 11df114e8..09e08efe9 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -6,6 +6,9 @@ "countryBrief": { "identifying": "Identificando el país...", "locating": "Localizando región...", + "geocodeFailed": "No se pudo identificar un país en esta ubicación", + "retryBtn": "Reintentar", + "closeBtn": "Cerrar", "limitedCoverage": "Cobertura limitada", "instabilityIndex": "Índice de inestabilidad", "notTracked": "Sin seguimiento: {{country}} no está en la lista de nivel 1 de CII", diff --git a/src/locales/fr.json b/src/locales/fr.json index 0512e00a6..b3c59370f 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -6,6 +6,9 @@ "countryBrief": { "identifying": "Identification du pays...", "locating": "Localisation de la région...", + "geocodeFailed": "Impossible d'identifier un pays à cet emplacement", + "retryBtn": "Réessayer", + "closeBtn": "Fermer", "limitedCoverage": "Couverture limitée", "instabilityIndex": "Indice d'instabilité", "notTracked": "Non suivi — {{country}} n'est pas dans la liste CII tier-1", diff --git a/src/locales/it.json b/src/locales/it.json index b507fca6c..9e1e42d30 100644 --- a/src/locales/it.json +++ b/src/locales/it.json @@ -6,6 +6,9 @@ "countryBrief": { "identifying": "Identificazione del paese...", "locating": "Localizzazione della regione...", + "geocodeFailed": "Impossibile identificare un paese in questa posizione", + "retryBtn": "Riprova", + "closeBtn": "Chiudi", "limitedCoverage": "Copertura limitata", "instabilityIndex": "Indice di instabilità", "notTracked": "Non monitorato — {{country}} non è nella lista CII tier-1", diff --git a/src/locales/ja.json b/src/locales/ja.json index a321298a4..d62b4d25a 100644 --- a/src/locales/ja.json +++ b/src/locales/ja.json @@ -6,6 +6,9 @@ "countryBrief": { "identifying": "国を特定中...", "locating": "地域を特定中...", + "geocodeFailed": "この場所の国を特定できませんでした", + "retryBtn": "再試行", + "closeBtn": "閉じる", "limitedCoverage": "限定的なカバレッジ", "instabilityIndex": "不安定性指数", "notTracked": "未追跡 — {{country}}はCII第1階層リストに含まれていない", diff --git a/src/locales/ko.json b/src/locales/ko.json index 6b1fbebeb..3edca5bae 100644 --- a/src/locales/ko.json +++ b/src/locales/ko.json @@ -6,6 +6,9 @@ "countryBrief": { "identifying": "국가 식별 중...", "locating": "지역 탐색 중...", + "geocodeFailed": "이 위치의 국가를 식별할 수 없습니다", + "retryBtn": "다시 시도", + "closeBtn": "닫기", "limitedCoverage": "제한된 커버리지", "instabilityIndex": "불안정 지수", "notTracked": "추적 대상 아님 — {{country}}은(는) CII 1등급 목록에 포함되지 않습니다", diff --git a/src/locales/nl.json b/src/locales/nl.json index e9f06cc1b..b6019fe8e 100644 --- a/src/locales/nl.json +++ b/src/locales/nl.json @@ -304,6 +304,9 @@ "countryIntel": { "identifying": "Land identificeren...", "locating": "Regio lokaliseren...", + "geocodeFailed": "Kan geen land identificeren op deze locatie", + "retryBtn": "Opnieuw proberen", + "closeBtn": "Sluiten", "instabilityIndex": "Instabiliteitsindex", "protests": "protesten", "militaryAircraft": "miljoen vliegtuigen", diff --git a/src/locales/pl.json b/src/locales/pl.json index 8c5fe2dbb..ed19e7b99 100644 --- a/src/locales/pl.json +++ b/src/locales/pl.json @@ -6,6 +6,9 @@ "countryBrief": { "identifying": "Identyfikacja kraju...", "locating": "Lokalizowanie regionu...", + "geocodeFailed": "Nie udało się zidentyfikować kraju w tej lokalizacji", + "retryBtn": "Ponów", + "closeBtn": "Zamknij", "limitedCoverage": "Ograniczony zasięg", "instabilityIndex": "Indeks niestabilności", "notTracked": "Nie monitorowane — {{country}} nie znajduje się na liście CII poziomu 1", diff --git a/src/locales/pt.json b/src/locales/pt.json index f187dbe55..d5e5eaba0 100644 --- a/src/locales/pt.json +++ b/src/locales/pt.json @@ -304,6 +304,9 @@ "countryIntel": { "identifying": "Identificando país...", "locating": "Localizando região...", + "geocodeFailed": "Não foi possível identificar um país nesta localização", + "retryBtn": "Tentar novamente", + "closeBtn": "Fechar", "instabilityIndex": "Índice de Instabilidade", "protests": "protestos", "militaryAircraft": "mil. aeronave", diff --git a/src/locales/ro.json b/src/locales/ro.json index 826f68d28..a3cecd1da 100644 --- a/src/locales/ro.json +++ b/src/locales/ro.json @@ -6,6 +6,9 @@ "countryBrief": { "identifying": "Se identifică țara...", "locating": "Se localizează regiunea...", + "geocodeFailed": "Nu s-a putut identifica o țară la această locație", + "retryBtn": "Reîncearcă", + "closeBtn": "Închide", "limitedCoverage": "Acoperire limitată", "instabilityIndex": "Indicele de instabilitate", "notTracked": "Nu este urmărită — {{country}} nu se află în lista CII de nivel 1", diff --git a/src/locales/ru.json b/src/locales/ru.json index 0fd866980..40c401023 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -6,6 +6,9 @@ "countryBrief": { "identifying": "Определение страны...", "locating": "Определение региона...", + "geocodeFailed": "Не удалось определить страну в этом местоположении", + "retryBtn": "Повторить", + "closeBtn": "Закрыть", "limitedCoverage": "Ограниченное покрытие", "instabilityIndex": "Индекс нестабильности", "notTracked": "Не отслеживается — {{country}} отсутствует в списке CII первого уровня", diff --git a/src/locales/sv.json b/src/locales/sv.json index f6cea1760..c5f264cbf 100644 --- a/src/locales/sv.json +++ b/src/locales/sv.json @@ -304,6 +304,9 @@ "countryIntel": { "identifying": "Identifierar land...", "locating": "Hittar region...", + "geocodeFailed": "Kunde inte identifiera ett land på denna plats", + "retryBtn": "Försök igen", + "closeBtn": "Stäng", "instabilityIndex": "Instabilitetsindex", "protests": "protester", "militaryAircraft": "mil. flygplan", diff --git a/src/locales/th.json b/src/locales/th.json index 65dd90ed8..45f71ca00 100644 --- a/src/locales/th.json +++ b/src/locales/th.json @@ -6,6 +6,9 @@ "countryBrief": { "identifying": "กำลังระบุประเทศ...", "locating": "กำลังค้นหาภูมิภาค...", + "geocodeFailed": "ไม่สามารถระบุประเทศที่ตำแหน่งนี้ได้", + "retryBtn": "ลองอีกครั้ง", + "closeBtn": "ปิด", "limitedCoverage": "ครอบคลุมจำกัด", "instabilityIndex": "ดัชนีความไม่มั่นคง", "notTracked": "ไม่ได้ติดตาม — {{country}} ไม่อยู่ในรายการ CII ระดับ 1", diff --git a/src/locales/tr.json b/src/locales/tr.json index 851d5bf62..ac183f7f5 100644 --- a/src/locales/tr.json +++ b/src/locales/tr.json @@ -6,6 +6,9 @@ "countryBrief": { "identifying": "Ulke belirleniyor...", "locating": "Bolge tespit ediliyor...", + "geocodeFailed": "Bu konumda bir ülke belirlenemedi", + "retryBtn": "Tekrar dene", + "closeBtn": "Kapat", "limitedCoverage": "Sinirli kapsam", "instabilityIndex": "Istikrarsizlik Endeksi", "notTracked": "Takip edilmiyor — {{country}} CII seviye-1 listesinde degil", diff --git a/src/locales/vi.json b/src/locales/vi.json index df1ec4ce6..d1f76258b 100644 --- a/src/locales/vi.json +++ b/src/locales/vi.json @@ -6,6 +6,9 @@ "countryBrief": { "identifying": "Đang xác định quốc gia...", "locating": "Đang định vị khu vực...", + "geocodeFailed": "Không thể xác định quốc gia tại vị trí này", + "retryBtn": "Thử lại", + "closeBtn": "Đóng", "limitedCoverage": "Phạm vi theo dõi hạn chế", "instabilityIndex": "Chỉ số Bất ổn", "notTracked": "Không theo dõi — {{country}} không nằm trong danh sách CII cấp 1", diff --git a/src/locales/zh.json b/src/locales/zh.json index 201452f6b..78cf4256e 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -6,6 +6,9 @@ "countryBrief": { "identifying": "正在识别国家...", "locating": "正在定位区域...", + "geocodeFailed": "无法识别此位置的国家", + "retryBtn": "重试", + "closeBtn": "关闭", "limitedCoverage": "覆盖范围有限", "instabilityIndex": "不稳定指数", "notTracked": "未跟踪 — {{country}}不在CII一级监控名单中", diff --git a/src/styles/country-deep-dive.css b/src/styles/country-deep-dive.css index 3baf8d634..d0dd25bc6 100644 --- a/src/styles/country-deep-dive.css +++ b/src/styles/country-deep-dive.css @@ -694,6 +694,55 @@ } } +.cdp-geo-error { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + padding: 32px 16px; + text-align: center; +} + +.cdp-geo-error-icon { + font-size: 28px; +} + +.cdp-geo-error-msg { + color: var(--text-muted); + font-size: 13px; + line-height: 1.4; +} + +.cdp-geo-error-actions { + display: flex; + gap: 8px; + margin-top: 4px; +} + +.cdp-geo-error-retry, +.cdp-geo-error-close { + padding: 6px 16px; + border: 1px solid var(--border); + border-radius: 6px; + background: transparent; + color: var(--text-primary); + font-size: 12px; + cursor: pointer; + transition: background 0.15s; +} + +.cdp-geo-error-retry:hover, +.cdp-geo-error-close:hover { + background: color-mix(in srgb, var(--text-faint) 15%, transparent); +} + +.cdp-geo-error-retry { + background: color-mix(in srgb, var(--accent) 15%, transparent); + border-color: var(--accent); + color: var(--accent); +} + .cdp-timeline-mount { min-height: 80px; } diff --git a/src/styles/main.css b/src/styles/main.css index 00bf3aa74..8af497f1d 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -17044,6 +17044,45 @@ body.has-breaking-alert .panels-grid { padding: 40px 24px; } +.cb-geo-error { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + padding: 40px 24px; + text-align: center; +} + +.cb-geo-error-actions { + display: flex; + gap: 8px; + margin-top: 4px; +} + +.cb-geo-retry-btn, +.cb-geo-close-btn { + padding: 6px 16px; + border: 1px solid var(--border); + border-radius: 6px; + background: transparent; + color: var(--text-primary); + font-size: 12px; + cursor: pointer; + transition: background 0.15s; +} + +.cb-geo-retry-btn:hover, +.cb-geo-close-btn:hover { + background: color-mix(in srgb, var(--text-faint) 15%, transparent); +} + +.cb-geo-retry-btn { + background: color-mix(in srgb, var(--accent) 15%, transparent); + border-color: var(--accent); + color: var(--accent); +} + .cb-empty { color: var(--text-faint); font-size: 12px; diff --git a/src/utils/reverse-geocode.ts b/src/utils/reverse-geocode.ts index 50c8349eb..cf0a8495a 100644 --- a/src/utils/reverse-geocode.ts +++ b/src/utils/reverse-geocode.ts @@ -18,7 +18,9 @@ function cacheKey(lat: number, lon: number): string { return `${lat.toFixed(1)},${lon.toFixed(1)}`; } -export async function reverseGeocode(lat: number, lon: number): Promise { +const TIMEOUT_MS = 8000; + +export async function reverseGeocode(lat: number, lon: number, signal?: AbortSignal): Promise { const key = cacheKey(lat, lon); if (cache.has(key)) return cache.get(key) ?? null; @@ -28,10 +30,16 @@ export async function reverseGeocode(lat: number, lon: number): Promise 0) await new Promise((r) => setTimeout(r, wait)); lastRequestTime = Date.now(); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS); + const onExternalAbort = () => controller.abort(); + signal?.addEventListener('abort', onExternalAbort, { once: true }); + try { const url = `https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lon}&format=json&zoom=3&accept-language=en`; const res = await fetch(url, { headers: { 'User-Agent': 'WorldMonitor/2.0 (https://worldmonitor.app)' }, + signal: controller.signal, }); if (!res.ok) { cache.set(key, null); @@ -51,8 +59,15 @@ export async function reverseGeocode(lat: number, lon: number): Promise