From 3746d21def6ef3332f26844b0c88f4519bcf6d8b Mon Sep 17 00:00:00 2001 From: Nicolas Gomes Ferreira Dos Santos Date: Fri, 6 Mar 2026 08:32:57 -0800 Subject: [PATCH] fix: prevent Country Brief modal from getting stuck on geocode failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a user clicks on the map where no country geometry exists (ocean, ambiguous border), reverse geocoding via Nominatim is used. If this request fails, times out, or is aborted, the modal could remain stuck on "Identifying…" / "Locating…" with no feedback and no way to dismiss. Three layers of defense: 1. Add 8-second timeout to the reverse geocode fetch so it cannot hang indefinitely. Abort/timeout errors are not cached so retry works. 2. Replace silent modal close with an error state showing a clear message ("Could not identify a country at this location") plus Retry and Close buttons. 3. Guard refreshOpenBrief() against the __error__ sentinel code. i18n strings added for all 21 locales. Closes #1133 Co-Authored-By: Claude Opus 4.6 --- src/app/country-intel.ts | 10 ++++-- src/components/CountryBriefPage.ts | 50 ++++++++++++++++++++++++++ src/components/CountryBriefPanel.ts | 1 + src/components/CountryDeepDivePanel.ts | 26 ++++++++++++++ src/locales/ar.json | 3 ++ src/locales/bg.json | 3 ++ src/locales/cs.json | 3 ++ src/locales/de.json | 3 ++ src/locales/el.json | 3 ++ src/locales/en.json | 3 ++ src/locales/es.json | 3 ++ src/locales/fr.json | 3 ++ src/locales/it.json | 3 ++ src/locales/ja.json | 3 ++ src/locales/ko.json | 3 ++ src/locales/nl.json | 3 ++ src/locales/pl.json | 3 ++ src/locales/pt.json | 3 ++ src/locales/ro.json | 3 ++ src/locales/ru.json | 3 ++ src/locales/sv.json | 3 ++ src/locales/th.json | 3 ++ src/locales/tr.json | 3 ++ src/locales/vi.json | 3 ++ src/locales/zh.json | 3 ++ src/styles/country-deep-dive.css | 49 +++++++++++++++++++++++++ src/styles/main.css | 39 ++++++++++++++++++++ src/utils/reverse-geocode.ts | 17 ++++++++- 28 files changed, 251 insertions(+), 4 deletions(-) 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