mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
fix(forecast): broaden region classification + decouple status badge from filter
Addresses 1 P1 + 1 P2 review finding on PR #2942. P1: narrow macro map silently dropped valid forecasts. The hardcoded FORECAST_MACRO_REGION_MAP only covered ~50 strings, but seed-forecasts.mjs emits conflict/political/cyber/infrastructure rows with region set to ANY source country name (Algeria, Niger, Kazakhstan, Uruguay, Vietnam...) and market rows with 'Global' or 'Northern Europe'. Any row whose region string was not in the 50-entry map returned null and vanished on every pill click. Fix: replace the narrow map with a 3-stage lookup: 1. Theater / geo-label map for multi-country strings (Red Sea, Baltic Sea, Northern Europe, Sahel, Horn of Africa, South China Sea, etc.) 2. Country name -> ISO2 via a 302-entry normalized dictionary mirroring shared/country-names.json. 3. ISO2 -> region via an inlined 218-entry ISO2_TO_REGION map (the World Bank taxonomy with strategic overrides: AF/PK/LK -> south-asia, TR -> mena, MX -> north-america, TW -> east-asia). Unknown strings return null. 'Global' resolves to a distinct 'global' id so callers can tell "truly global" from "unclassified"; both only appear under the All Regions pill since no specific pill matches them. Pills now use the 7 region IDs from the regional intelligence taxonomy (mena, east-asia, europe, north-america, south-asia, latam, sub-saharan-africa), replacing the old uppercase enum that was tied to the narrow MACRO_REGION_MAP. P2: status badge reflected filter result, not fetch success. Picking a region with zero matches flipped the badge to 'unavailable' even though the RPC succeeded and the feed was healthy. Badge is now tied to this.forecasts.length (fetch success) and the empty-state copy differentiates "No forecasts match the current filter" (filter miss) from "No forecasts available" (fetch miss) so users can tell the two apart without mistaking a filter miss for a broken feed.
This commit is contained in:
16
shared/forecast-macro-regions.d.ts
vendored
16
shared/forecast-macro-regions.d.ts
vendored
@@ -1,3 +1,13 @@
|
||||
export type ForecastMacroRegion = 'MENA' | 'EAST_ASIA' | 'EUROPE' | 'AMERICAS' | 'SOUTH_ASIA' | 'AFRICA';
|
||||
export const FORECAST_MACRO_REGION_MAP: Record<string, ForecastMacroRegion>;
|
||||
export function getForecastMacroRegion(region: string | null | undefined): ForecastMacroRegion | null;
|
||||
export type ForecastMacroRegionId =
|
||||
| 'mena'
|
||||
| 'east-asia'
|
||||
| 'europe'
|
||||
| 'north-america'
|
||||
| 'south-asia'
|
||||
| 'latam'
|
||||
| 'sub-saharan-africa'
|
||||
| 'global';
|
||||
|
||||
export function getForecastMacroRegion(
|
||||
region: string | null | undefined,
|
||||
): ForecastMacroRegionId | null;
|
||||
|
||||
@@ -1,47 +1,537 @@
|
||||
// @ts-check
|
||||
// Mirror of MACRO_REGION_MAP in scripts/seed-forecasts.mjs (lines 8219-8238).
|
||||
// Maps the free-text `Forecast.region` string (as written by the seed) to
|
||||
// a macro region id. Used client-side to filter forecasts when the Forecast
|
||||
// proto does not expose macroRegion directly.
|
||||
// Maps the free-text `Forecast.region` string (as written by the seed) to a
|
||||
// macro region id. Used client-side by ForecastPanel to filter forecasts when
|
||||
// the Forecast proto does not expose macroRegion directly.
|
||||
//
|
||||
// Keep in sync with scripts/seed-forecasts.mjs MACRO_REGION_MAP. If the seed
|
||||
// adds new regions, add them here too.
|
||||
// The seed emits Forecast.region as any of:
|
||||
// 1. A country name via `region: c.name` (conflict / political / cyber /
|
||||
// infrastructure rows). c.name comes from scripts/data/country-codes.json
|
||||
// and covers every ISO country — "Algeria", "Niger", "Kazakhstan",
|
||||
// "Uruguay", "Vietnam", "New Zealand", etc.
|
||||
// 2. A theater or geo label ("Middle East", "Red Sea", "Baltic Sea",
|
||||
// "Northern Europe", "South China Sea", "Sahel", "Horn of Africa", ...).
|
||||
// 3. The literal string "Global" for cross-market / global macro signals.
|
||||
//
|
||||
// The earlier version of this file mirrored scripts/seed-forecasts.mjs's
|
||||
// MACRO_REGION_MAP (~50 entries) 1:1, which silently dropped every row whose
|
||||
// region wasn't explicitly listed — nearly every country feed, every
|
||||
// "Northern Europe" market row, every "Uruguay" conflict row. This version
|
||||
// broadens classification via a 3-stage lookup:
|
||||
//
|
||||
// 1. Lowercase-name lookup against COUNTRY_NAME_TO_ISO2 (302 entries).
|
||||
// 2. ISO2 -> region via ISO2_TO_REGION (218 entries), a copy of the
|
||||
// World Bank taxonomy with strategic overrides (AF/PK/LK -> south-asia,
|
||||
// TR -> mena, MX -> north-america, TW -> east-asia).
|
||||
// 3. Theater / geo-label fallback via THEATER_TO_REGION.
|
||||
//
|
||||
// Unknown strings (including "Global") return null so they only surface under
|
||||
// the "All Regions" pill and never appear under a specific region filter.
|
||||
// Note: "Global" resolves to 'global' explicitly so callers can distinguish
|
||||
// "truly global" from "unknown".
|
||||
|
||||
/** @type {Record<string, 'MENA' | 'EAST_ASIA' | 'EUROPE' | 'AMERICAS' | 'SOUTH_ASIA' | 'AFRICA'>} */
|
||||
export const FORECAST_MACRO_REGION_MAP = {
|
||||
// MENA
|
||||
'Israel': 'MENA', 'Iran': 'MENA', 'Syria': 'MENA', 'Iraq': 'MENA', 'Lebanon': 'MENA',
|
||||
'Gaza': 'MENA', 'Egypt': 'MENA', 'Saudi Arabia': 'MENA', 'Yemen': 'MENA', 'Jordan': 'MENA',
|
||||
'Turkey': 'MENA', 'Libya': 'MENA', 'Middle East': 'MENA', 'Persian Gulf': 'MENA',
|
||||
'Red Sea': 'MENA', 'Strait of Hormuz': 'MENA', 'Eastern Mediterranean': 'MENA',
|
||||
// EAST_ASIA
|
||||
'Taiwan': 'EAST_ASIA', 'China': 'EAST_ASIA', 'Japan': 'EAST_ASIA', 'South Korea': 'EAST_ASIA',
|
||||
'North Korea': 'EAST_ASIA', 'Western Pacific': 'EAST_ASIA', 'South China Sea': 'EAST_ASIA',
|
||||
// AMERICAS
|
||||
'United States': 'AMERICAS', 'Brazil': 'AMERICAS', 'Mexico': 'AMERICAS', 'Cuba': 'AMERICAS',
|
||||
'Canada': 'AMERICAS', 'Colombia': 'AMERICAS', 'Venezuela': 'AMERICAS', 'Argentina': 'AMERICAS',
|
||||
'Peru': 'AMERICAS', 'Chile': 'AMERICAS',
|
||||
// EUROPE
|
||||
'Russia': 'EUROPE', 'Ukraine': 'EUROPE', 'Germany': 'EUROPE', 'France': 'EUROPE',
|
||||
'United Kingdom': 'EUROPE', 'Poland': 'EUROPE', 'Estonia': 'EUROPE', 'Latvia': 'EUROPE',
|
||||
'Lithuania': 'EUROPE', 'Baltic Sea': 'EUROPE', 'Black Sea': 'EUROPE',
|
||||
'Kerch Strait': 'EUROPE', 'Sweden': 'EUROPE', 'Finland': 'EUROPE', 'Norway': 'EUROPE',
|
||||
'Romania': 'EUROPE', 'Bulgaria': 'EUROPE',
|
||||
// SOUTH_ASIA
|
||||
'India': 'SOUTH_ASIA', 'Pakistan': 'SOUTH_ASIA', 'Afghanistan': 'SOUTH_ASIA',
|
||||
'Bangladesh': 'SOUTH_ASIA', 'Myanmar': 'SOUTH_ASIA',
|
||||
// AFRICA
|
||||
'Congo': 'AFRICA', 'Sudan': 'AFRICA', 'Ethiopia': 'AFRICA', 'Nigeria': 'AFRICA',
|
||||
'Somalia': 'AFRICA', 'Mali': 'AFRICA', 'Mozambique': 'AFRICA', 'Sahel': 'AFRICA',
|
||||
};
|
||||
/**
|
||||
* @typedef {'mena'
|
||||
* | 'east-asia'
|
||||
* | 'europe'
|
||||
* | 'north-america'
|
||||
* | 'south-asia'
|
||||
* | 'latam'
|
||||
* | 'sub-saharan-africa'
|
||||
* | 'global'} ForecastMacroRegionId
|
||||
*/
|
||||
|
||||
/** @type {Readonly<Record<string, ForecastMacroRegionId>>} */
|
||||
const ISO2_TO_REGION = Object.freeze({
|
||||
AD: 'europe', AE: 'mena', AF: 'south-asia',
|
||||
AG: 'latam', AL: 'europe', AM: 'europe',
|
||||
AO: 'sub-saharan-africa', AR: 'latam', AS: 'east-asia',
|
||||
AT: 'europe', AU: 'east-asia', AW: 'latam',
|
||||
AZ: 'europe', BA: 'europe', BB: 'latam',
|
||||
BD: 'south-asia', BE: 'europe', BF: 'sub-saharan-africa',
|
||||
BG: 'europe', BH: 'mena', BI: 'sub-saharan-africa',
|
||||
BJ: 'sub-saharan-africa', BM: 'north-america', BN: 'east-asia',
|
||||
BO: 'latam', BR: 'latam', BS: 'latam',
|
||||
BT: 'south-asia', BW: 'sub-saharan-africa', BY: 'europe',
|
||||
BZ: 'latam', CA: 'north-america', CD: 'sub-saharan-africa',
|
||||
CF: 'sub-saharan-africa', CG: 'sub-saharan-africa', CH: 'europe',
|
||||
CI: 'sub-saharan-africa', CL: 'latam', CM: 'sub-saharan-africa',
|
||||
CN: 'east-asia', CO: 'latam', CR: 'latam',
|
||||
CU: 'latam', CV: 'sub-saharan-africa', CW: 'latam',
|
||||
CY: 'europe', CZ: 'europe', DE: 'europe',
|
||||
DJ: 'mena', DK: 'europe', DM: 'latam',
|
||||
DO: 'latam', DZ: 'mena', EC: 'latam',
|
||||
EE: 'europe', EG: 'mena', ER: 'sub-saharan-africa',
|
||||
ES: 'europe', ET: 'sub-saharan-africa', FI: 'europe',
|
||||
FJ: 'east-asia', FM: 'east-asia', FO: 'europe',
|
||||
FR: 'europe', GA: 'sub-saharan-africa', GB: 'europe',
|
||||
GD: 'latam', GE: 'europe', GH: 'sub-saharan-africa',
|
||||
GI: 'europe', GL: 'europe', GM: 'sub-saharan-africa',
|
||||
GN: 'sub-saharan-africa', GQ: 'sub-saharan-africa', GR: 'europe',
|
||||
GT: 'latam', GU: 'east-asia', GW: 'sub-saharan-africa',
|
||||
GY: 'latam', HK: 'east-asia', HN: 'latam',
|
||||
HR: 'europe', HT: 'latam', HU: 'europe',
|
||||
ID: 'east-asia', IE: 'europe', IL: 'mena',
|
||||
IM: 'europe', IN: 'south-asia', IQ: 'mena',
|
||||
IR: 'mena', IS: 'europe', IT: 'europe',
|
||||
JG: 'europe', JM: 'latam', JO: 'mena',
|
||||
JP: 'east-asia', KE: 'sub-saharan-africa', KG: 'europe',
|
||||
KH: 'east-asia', KI: 'east-asia', KM: 'sub-saharan-africa',
|
||||
KN: 'latam', KP: 'east-asia', KR: 'east-asia',
|
||||
KW: 'mena', KY: 'latam', KZ: 'europe',
|
||||
LA: 'east-asia', LB: 'mena', LC: 'latam',
|
||||
LI: 'europe', LK: 'south-asia', LR: 'sub-saharan-africa',
|
||||
LS: 'sub-saharan-africa', LT: 'europe', LU: 'europe',
|
||||
LV: 'europe', LY: 'mena', MA: 'mena',
|
||||
MC: 'europe', MD: 'europe', ME: 'europe',
|
||||
MF: 'latam', MG: 'sub-saharan-africa', MH: 'east-asia',
|
||||
MK: 'europe', ML: 'sub-saharan-africa', MM: 'east-asia',
|
||||
MN: 'east-asia', MO: 'east-asia', MP: 'east-asia',
|
||||
MR: 'sub-saharan-africa', MT: 'mena', MU: 'sub-saharan-africa',
|
||||
MV: 'south-asia', MW: 'sub-saharan-africa', MX: 'north-america',
|
||||
MY: 'east-asia', MZ: 'sub-saharan-africa', NA: 'sub-saharan-africa',
|
||||
NC: 'east-asia', NE: 'sub-saharan-africa', NG: 'sub-saharan-africa',
|
||||
NI: 'latam', NL: 'europe', NO: 'europe',
|
||||
NP: 'south-asia', NR: 'east-asia', NZ: 'east-asia',
|
||||
OM: 'mena', PA: 'latam', PE: 'latam',
|
||||
PF: 'east-asia', PG: 'east-asia', PH: 'east-asia',
|
||||
PK: 'south-asia', PL: 'europe', PR: 'latam',
|
||||
PS: 'mena', PT: 'europe', PW: 'east-asia',
|
||||
PY: 'latam', QA: 'mena', RO: 'europe',
|
||||
RS: 'europe', RU: 'europe', RW: 'sub-saharan-africa',
|
||||
SA: 'mena', SB: 'east-asia', SC: 'sub-saharan-africa',
|
||||
SD: 'sub-saharan-africa', SE: 'europe', SG: 'east-asia',
|
||||
SI: 'europe', SK: 'europe', SL: 'sub-saharan-africa',
|
||||
SM: 'europe', SN: 'sub-saharan-africa', SO: 'sub-saharan-africa',
|
||||
SR: 'latam', SS: 'sub-saharan-africa', ST: 'sub-saharan-africa',
|
||||
SV: 'latam', SX: 'latam', SY: 'mena',
|
||||
SZ: 'sub-saharan-africa', TC: 'latam', TD: 'sub-saharan-africa',
|
||||
TG: 'sub-saharan-africa', TH: 'east-asia', TJ: 'europe',
|
||||
TL: 'east-asia', TM: 'europe', TN: 'mena',
|
||||
TO: 'east-asia', TR: 'mena', TT: 'latam',
|
||||
TV: 'east-asia', TW: 'east-asia', TZ: 'sub-saharan-africa',
|
||||
UA: 'europe', UG: 'sub-saharan-africa', US: 'north-america',
|
||||
UY: 'latam', UZ: 'europe', VC: 'latam',
|
||||
VE: 'latam', VG: 'latam', VI: 'latam',
|
||||
VN: 'east-asia', VU: 'east-asia', WS: 'east-asia',
|
||||
XK: 'europe', YE: 'mena', ZA: 'sub-saharan-africa',
|
||||
ZM: 'sub-saharan-africa', ZW: 'sub-saharan-africa',
|
||||
});
|
||||
|
||||
/** @type {Readonly<Record<string, string>>} */
|
||||
const COUNTRY_NAME_TO_ISO2 = Object.freeze({
|
||||
'afghanistan': 'AF',
|
||||
'aland': 'AX',
|
||||
'albania': 'AL',
|
||||
'algeria': 'DZ',
|
||||
'american samoa': 'AS',
|
||||
'andorra': 'AD',
|
||||
'angola': 'AO',
|
||||
'anguilla': 'AI',
|
||||
'antarctica': 'AQ',
|
||||
'antigua and barbuda': 'AG',
|
||||
'argentina': 'AR',
|
||||
'armenia': 'AM',
|
||||
'aruba': 'AW',
|
||||
'australia': 'AU',
|
||||
'austria': 'AT',
|
||||
'azerbaijan': 'AZ',
|
||||
'bahamas': 'BS',
|
||||
'bahamas the': 'BS',
|
||||
'bahrain': 'BH',
|
||||
'bangladesh': 'BD',
|
||||
'barbados': 'BB',
|
||||
'belarus': 'BY',
|
||||
'belgium': 'BE',
|
||||
'belize': 'BZ',
|
||||
'benin': 'BJ',
|
||||
'bermuda': 'BM',
|
||||
'bhutan': 'BT',
|
||||
'bolivarian republic of venezuela': 'VE',
|
||||
'bolivia': 'BO',
|
||||
'bosnia and herzegovina': 'BA',
|
||||
'botswana': 'BW',
|
||||
'brazil': 'BR',
|
||||
'british indian ocean territory': 'IO',
|
||||
'british virgin islands': 'VG',
|
||||
'brunei': 'BN',
|
||||
'brunei darussalam': 'BN',
|
||||
'bulgaria': 'BG',
|
||||
'burkina faso': 'BF',
|
||||
'burma': 'MM',
|
||||
'burundi': 'BI',
|
||||
'cabo verde': 'CV',
|
||||
'cambodia': 'KH',
|
||||
'cameroon': 'CM',
|
||||
'canada': 'CA',
|
||||
'cape verde': 'CV',
|
||||
'cayman islands': 'KY',
|
||||
'central african republic': 'CF',
|
||||
'chad': 'TD',
|
||||
'chile': 'CL',
|
||||
'china': 'CN',
|
||||
'colombia': 'CO',
|
||||
'comoros': 'KM',
|
||||
'congo': 'CG',
|
||||
'congo brazzaville': 'CG',
|
||||
'congo dem rep': 'CD',
|
||||
'congo kinshasa': 'CD',
|
||||
'congo rep': 'CG',
|
||||
'cook islands': 'CK',
|
||||
'costa rica': 'CR',
|
||||
'cote d ivoire': 'CI',
|
||||
'croatia': 'HR',
|
||||
'cuba': 'CU',
|
||||
'curacao': 'CW',
|
||||
'cyprus': 'CY',
|
||||
'czech republic': 'CZ',
|
||||
'czechia': 'CZ',
|
||||
'democratic peoples republic of korea': 'KP',
|
||||
'democratic republic of the congo': 'CD',
|
||||
'denmark': 'DK',
|
||||
'djibouti': 'DJ',
|
||||
'dominica': 'DM',
|
||||
'dominican republic': 'DO',
|
||||
'dr congo': 'CD',
|
||||
'drc': 'CD',
|
||||
'east timor': 'TL',
|
||||
'ecuador': 'EC',
|
||||
'egypt': 'EG',
|
||||
'egypt arab rep': 'EG',
|
||||
'el salvador': 'SV',
|
||||
'equatorial guinea': 'GQ',
|
||||
'eritrea': 'ER',
|
||||
'estonia': 'EE',
|
||||
'eswatini': 'SZ',
|
||||
'ethiopia': 'ET',
|
||||
'falkland islands': 'FK',
|
||||
'faroe islands': 'FO',
|
||||
'federated states of micronesia': 'FM',
|
||||
'fiji': 'FJ',
|
||||
'finland': 'FI',
|
||||
'france': 'FR',
|
||||
'french polynesia': 'PF',
|
||||
'french southern and antarctic lands': 'TF',
|
||||
'gabon': 'GA',
|
||||
'gambia': 'GM',
|
||||
'gambia the': 'GM',
|
||||
'gaza': 'PS',
|
||||
'georgia': 'GE',
|
||||
'germany': 'DE',
|
||||
'ghana': 'GH',
|
||||
'gibraltar': 'GI',
|
||||
'greece': 'GR',
|
||||
'greenland': 'GL',
|
||||
'grenada': 'GD',
|
||||
'guam': 'GU',
|
||||
'guatemala': 'GT',
|
||||
'guernsey': 'GG',
|
||||
'guinea': 'GN',
|
||||
'guinea bissau': 'GW',
|
||||
'guyana': 'GY',
|
||||
'haiti': 'HT',
|
||||
'heard island and mcdonald islands': 'HM',
|
||||
'honduras': 'HN',
|
||||
'hong kong': 'HK',
|
||||
'hong kong s a r': 'HK',
|
||||
'hong kong sar china': 'HK',
|
||||
'hungary': 'HU',
|
||||
'iceland': 'IS',
|
||||
'india': 'IN',
|
||||
'indonesia': 'ID',
|
||||
'iran': 'IR',
|
||||
'iran islamic rep': 'IR',
|
||||
'iraq': 'IQ',
|
||||
'ireland': 'IE',
|
||||
'isle of man': 'IM',
|
||||
'israel': 'IL',
|
||||
'italy': 'IT',
|
||||
'ivory coast': 'CI',
|
||||
'jamaica': 'JM',
|
||||
'japan': 'JP',
|
||||
'jersey': 'JE',
|
||||
'jordan': 'JO',
|
||||
'kazakhstan': 'KZ',
|
||||
'kenya': 'KE',
|
||||
'kiribati': 'KI',
|
||||
'korea dem peoples rep': 'KP',
|
||||
'korea rep': 'KR',
|
||||
'kosovo': 'XK',
|
||||
'kuwait': 'KW',
|
||||
'kyrgyz republic': 'KG',
|
||||
'kyrgyzstan': 'KG',
|
||||
'lao pdr': 'LA',
|
||||
'laos': 'LA',
|
||||
'latvia': 'LV',
|
||||
'lebanon': 'LB',
|
||||
'lesotho': 'LS',
|
||||
'liberia': 'LR',
|
||||
'libya': 'LY',
|
||||
'liechtenstein': 'LI',
|
||||
'lithuania': 'LT',
|
||||
'luxembourg': 'LU',
|
||||
'macao s a r': 'MO',
|
||||
'macao sar china': 'MO',
|
||||
'madagascar': 'MG',
|
||||
'malawi': 'MW',
|
||||
'malaysia': 'MY',
|
||||
'maldives': 'MV',
|
||||
'mali': 'ML',
|
||||
'malta': 'MT',
|
||||
'marshall islands': 'MH',
|
||||
'mauritania': 'MR',
|
||||
'mauritius': 'MU',
|
||||
'mexico': 'MX',
|
||||
'micronesia': 'FM',
|
||||
'micronesia fed sts': 'FM',
|
||||
'moldova': 'MD',
|
||||
'monaco': 'MC',
|
||||
'mongolia': 'MN',
|
||||
'montenegro': 'ME',
|
||||
'montserrat': 'MS',
|
||||
'morocco': 'MA',
|
||||
'morocco western sahara': 'MA',
|
||||
'mozambique': 'MZ',
|
||||
'myanmar': 'MM',
|
||||
'namibia': 'NA',
|
||||
'nauru': 'NR',
|
||||
'nepal': 'NP',
|
||||
'netherlands': 'NL',
|
||||
'new caledonia': 'NC',
|
||||
'new zealand': 'NZ',
|
||||
'nicaragua': 'NI',
|
||||
'niger': 'NE',
|
||||
'nigeria': 'NG',
|
||||
'niue': 'NU',
|
||||
'norfolk island': 'NF',
|
||||
'north korea': 'KP',
|
||||
'north macedonia': 'MK',
|
||||
'northern mariana islands': 'MP',
|
||||
'norway': 'NO',
|
||||
'occupied palestinian territory': 'PS',
|
||||
'oman': 'OM',
|
||||
'pakistan': 'PK',
|
||||
'palau': 'PW',
|
||||
'palestine': 'PS',
|
||||
'palestine state of': 'PS',
|
||||
'palestinian territories': 'PS',
|
||||
'panama': 'PA',
|
||||
'papua new guinea': 'PG',
|
||||
'paraguay': 'PY',
|
||||
'peru': 'PE',
|
||||
'philippines': 'PH',
|
||||
'pitcairn islands': 'PN',
|
||||
'plurinational state of bolivia': 'BO',
|
||||
'poland': 'PL',
|
||||
'portugal': 'PT',
|
||||
'puerto rico': 'PR',
|
||||
'qatar': 'QA',
|
||||
'republic of korea': 'KR',
|
||||
'republic of serbia': 'RS',
|
||||
'republic of the congo': 'CG',
|
||||
'romania': 'RO',
|
||||
'russia': 'RU',
|
||||
'russian federation': 'RU',
|
||||
'rwanda': 'RW',
|
||||
'saint barthelemy': 'BL',
|
||||
'saint helena': 'SH',
|
||||
'saint kitts and nevis': 'KN',
|
||||
'saint lucia': 'LC',
|
||||
'saint martin': 'MF',
|
||||
'saint pierre and miquelon': 'PM',
|
||||
'saint vincent and the grenadines': 'VC',
|
||||
'samoa': 'WS',
|
||||
'san marino': 'SM',
|
||||
'sao tome': 'ST',
|
||||
'sao tome and principe': 'ST',
|
||||
'saudi arabia': 'SA',
|
||||
'senegal': 'SN',
|
||||
'serbia': 'RS',
|
||||
'seychelles': 'SC',
|
||||
'sierra leone': 'SL',
|
||||
'singapore': 'SG',
|
||||
'sint maarten': 'SX',
|
||||
'slovak republic': 'SK',
|
||||
'slovakia': 'SK',
|
||||
'slovenia': 'SI',
|
||||
'solomon islands': 'SB',
|
||||
'somalia': 'SO',
|
||||
'south africa': 'ZA',
|
||||
'south georgia and the islands': 'GS',
|
||||
'south korea': 'KR',
|
||||
'south sudan': 'SS',
|
||||
'spain': 'ES',
|
||||
'sri lanka': 'LK',
|
||||
'st kitts and nevis': 'KN',
|
||||
'st lucia': 'LC',
|
||||
'st vincent and the grenadines': 'VC',
|
||||
'sudan': 'SD',
|
||||
'suriname': 'SR',
|
||||
'swaziland': 'SZ',
|
||||
'sweden': 'SE',
|
||||
'switzerland': 'CH',
|
||||
'syria': 'SY',
|
||||
'syrian arab republic': 'SY',
|
||||
'taiwan': 'TW',
|
||||
'tajikistan': 'TJ',
|
||||
'tanzania': 'TZ',
|
||||
'thailand': 'TH',
|
||||
'the bahamas': 'BS',
|
||||
'the comoros': 'KM',
|
||||
'the gambia': 'GM',
|
||||
'the maldives': 'MV',
|
||||
'the netherlands': 'NL',
|
||||
'the philippines': 'PH',
|
||||
'the seychelles': 'SC',
|
||||
'timor leste': 'TL',
|
||||
'togo': 'TG',
|
||||
'tonga': 'TO',
|
||||
'trinidad and tobago': 'TT',
|
||||
'tunisia': 'TN',
|
||||
'turkey': 'TR',
|
||||
'turkiye': 'TR',
|
||||
'turkmenistan': 'TM',
|
||||
'turks and caicos': 'TC',
|
||||
'turks and caicos islands': 'TC',
|
||||
'tuvalu': 'TV',
|
||||
'u s virgin islands': 'VI',
|
||||
'uae': 'AE',
|
||||
'uganda': 'UG',
|
||||
'uk': 'GB',
|
||||
'ukraine': 'UA',
|
||||
'united arab emirates': 'AE',
|
||||
'united kingdom': 'GB',
|
||||
'united republic of tanzania': 'TZ',
|
||||
'united states': 'US',
|
||||
'united states minor outlying islands': 'UM',
|
||||
'united states of america': 'US',
|
||||
'united states virgin islands': 'VI',
|
||||
'uruguay': 'UY',
|
||||
'usa': 'US',
|
||||
'uzbekistan': 'UZ',
|
||||
'vanuatu': 'VU',
|
||||
'vatican': 'VA',
|
||||
'venezuela': 'VE',
|
||||
'venezuela rb': 'VE',
|
||||
'viet nam': 'VN',
|
||||
'vietnam': 'VN',
|
||||
'wallis and futuna': 'WF',
|
||||
'west bank': 'PS',
|
||||
'west bank and gaza': 'PS',
|
||||
'western sahara': 'EH',
|
||||
'yemen': 'YE',
|
||||
'yemen rep': 'YE',
|
||||
'zambia': 'ZM',
|
||||
'zimbabwe': 'ZW',
|
||||
});
|
||||
|
||||
// Theater / geo-label fallback. These are free-text strings the seed emits
|
||||
// that do not map to a single country (seas, straits, sub-regions). Keys are
|
||||
// normalized (lowercase, single-spaced). Add sparingly — anything that can be
|
||||
// resolved through a country name should go through the ISO2 path.
|
||||
/** @type {Readonly<Record<string, ForecastMacroRegionId>>} */
|
||||
const THEATER_TO_REGION = Object.freeze({
|
||||
// MENA theaters / corridors
|
||||
'middle east': 'mena',
|
||||
'persian gulf': 'mena',
|
||||
'red sea': 'mena',
|
||||
'strait of hormuz': 'mena',
|
||||
'bab el mandeb': 'mena',
|
||||
'suez': 'mena',
|
||||
'eastern mediterranean': 'mena',
|
||||
'north africa': 'mena',
|
||||
'maghreb': 'mena',
|
||||
'levant': 'mena',
|
||||
// East Asia theaters / corridors
|
||||
'south china sea': 'east-asia',
|
||||
'east china sea': 'east-asia',
|
||||
'western pacific': 'east-asia',
|
||||
'taiwan strait': 'east-asia',
|
||||
'korean peninsula': 'east-asia',
|
||||
'southeast asia': 'east-asia',
|
||||
'indochina': 'east-asia',
|
||||
// Europe theaters / corridors
|
||||
'baltic sea': 'europe',
|
||||
'black sea': 'europe',
|
||||
'kerch strait': 'europe',
|
||||
'nordic': 'europe',
|
||||
'northern europe': 'europe',
|
||||
'western europe': 'europe',
|
||||
'eastern europe': 'europe',
|
||||
'central europe': 'europe',
|
||||
'southern europe': 'europe',
|
||||
'balkans': 'europe',
|
||||
'caucasus': 'europe',
|
||||
// Sub-Saharan Africa theaters / corridors
|
||||
'sahel': 'sub-saharan-africa',
|
||||
'horn of africa': 'sub-saharan-africa',
|
||||
'central africa': 'sub-saharan-africa',
|
||||
'west africa': 'sub-saharan-africa',
|
||||
'southern africa': 'sub-saharan-africa',
|
||||
'east africa': 'sub-saharan-africa',
|
||||
'gulf of guinea': 'sub-saharan-africa',
|
||||
// South Asia theaters
|
||||
'indian subcontinent': 'south-asia',
|
||||
'south asia': 'south-asia',
|
||||
// North America theaters
|
||||
'north america': 'north-america',
|
||||
'north american': 'north-america',
|
||||
// LatAm theaters
|
||||
'latin america': 'latam',
|
||||
'south america': 'latam',
|
||||
'central america': 'latam',
|
||||
'caribbean': 'latam',
|
||||
'andes': 'latam',
|
||||
// Global
|
||||
'global': 'global',
|
||||
'worldwide': 'global',
|
||||
'world': 'global',
|
||||
'international': 'global',
|
||||
});
|
||||
|
||||
/**
|
||||
* Normalize a region string for dictionary lookup: NFKD, strip diacritics,
|
||||
* lowercase, strip punctuation, collapse whitespace. Mirrors
|
||||
* scripts/_country-resolver.mjs::normalizeCountryToken so country names that
|
||||
* round-trip through that path land at the same key.
|
||||
* @param {string} value
|
||||
* @returns {string}
|
||||
*/
|
||||
function normalizeRegionKey(value) {
|
||||
return String(value)
|
||||
.normalize('NFKD')
|
||||
.replace(/\p{Diacritic}/gu, '')
|
||||
.toLowerCase()
|
||||
.replace(/&/g, ' and ')
|
||||
.replace(/[''.(),/-]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a free-text Forecast.region string to its macro region id, or null
|
||||
* if the region is unknown.
|
||||
* if the region is unknown (unknown rows only appear under "All Regions").
|
||||
*
|
||||
* Lookup order:
|
||||
* 1. Theater / geo-label map (covers multi-country strings like "Red Sea")
|
||||
* 2. Country name -> ISO2 -> region (covers any country the seed emits)
|
||||
* 3. null
|
||||
*
|
||||
* @param {string | null | undefined} region
|
||||
* @returns {'MENA' | 'EAST_ASIA' | 'EUROPE' | 'AMERICAS' | 'SOUTH_ASIA' | 'AFRICA' | null}
|
||||
* @returns {ForecastMacroRegionId | null}
|
||||
*/
|
||||
export function getForecastMacroRegion(region) {
|
||||
if (!region) return null;
|
||||
return FORECAST_MACRO_REGION_MAP[region] ?? null;
|
||||
const key = normalizeRegionKey(region);
|
||||
if (!key) return null;
|
||||
|
||||
const theaterHit = THEATER_TO_REGION[key];
|
||||
if (theaterHit) return theaterHit;
|
||||
|
||||
const iso2 = COUNTRY_NAME_TO_ISO2[key];
|
||||
if (iso2) {
|
||||
const regionId = ISO2_TO_REGION[iso2];
|
||||
if (regionId) return regionId;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -7,19 +7,23 @@ import { getForecastMacroRegion } from '../../shared/forecast-macro-regions.js';
|
||||
const DOMAINS = ['all', 'conflict', 'market', 'supply_chain', 'political', 'military', 'cyber', 'infrastructure'] as const;
|
||||
const PANEL_MIN_PROBABILITY = 0.1;
|
||||
|
||||
// Macro region pill values. Each id matches the output of getForecastMacroRegion(),
|
||||
// which mirrors MACRO_REGION_MAP in scripts/seed-forecasts.mjs. Filtering is
|
||||
// entirely client-side: this.forecasts stays the full unfiltered set, and the
|
||||
// render pipeline applies the active macro region on every render so the
|
||||
// filter survives refresh-time updateForecasts() calls.
|
||||
// Macro region pill values. Each non-empty id matches an entry in the
|
||||
// ForecastMacroRegionId union emitted by getForecastMacroRegion() (see
|
||||
// shared/forecast-macro-regions.js). Filtering is entirely client-side:
|
||||
// this.forecasts stays the full unfiltered set, and the render pipeline
|
||||
// applies the active macro region on every render so the filter survives
|
||||
// refresh-time updateForecasts() calls. The '' (empty) id means
|
||||
// "All Regions" — no filter applied. Forecasts whose region does not
|
||||
// classify (unknown or 'global') only appear under "All Regions".
|
||||
const FORECAST_REGIONS = [
|
||||
{ id: '', label: 'All Regions' },
|
||||
{ id: 'MENA', label: 'MENA' },
|
||||
{ id: 'EAST_ASIA', label: 'East Asia' },
|
||||
{ id: 'EUROPE', label: 'Europe' },
|
||||
{ id: 'SOUTH_ASIA', label: 'South Asia' },
|
||||
{ id: 'AFRICA', label: 'Africa' },
|
||||
{ id: 'AMERICAS', label: 'Americas' },
|
||||
{ id: 'mena', label: 'MENA' },
|
||||
{ id: 'east-asia', label: 'East Asia' },
|
||||
{ id: 'europe', label: 'Europe' },
|
||||
{ id: 'south-asia', label: 'South Asia' },
|
||||
{ id: 'sub-saharan-africa', label: 'Africa' },
|
||||
{ id: 'latam', label: 'LatAm' },
|
||||
{ id: 'north-america', label: 'N. America' },
|
||||
] as const;
|
||||
|
||||
const DOMAIN_LABELS: Record<string, string> = {
|
||||
@@ -309,7 +313,12 @@ export class ForecastPanel extends Panel {
|
||||
this.forecasts = forecasts;
|
||||
const visible = this.getVisibleForecasts();
|
||||
this.setCount(visible.length);
|
||||
this.setDataBadge(visible.length > 0 ? 'live' : 'unavailable');
|
||||
// Badge reflects fetch success (this.forecasts.length), not the filtered
|
||||
// result. A user who picks a region with zero matches should still see
|
||||
// the feed as "live" — the empty-state copy inside the panel communicates
|
||||
// the filter miss. Tying the badge to the filter caused the panel to
|
||||
// flip to "unavailable" on any empty region pill.
|
||||
this.setDataBadge(this.forecasts.length > 0 ? 'live' : 'unavailable');
|
||||
this.render();
|
||||
}
|
||||
|
||||
@@ -346,11 +355,21 @@ export class ForecastPanel extends Panel {
|
||||
).join('');
|
||||
|
||||
if (visibleForecasts.length === 0) {
|
||||
// Differentiate fetch-miss (this.forecasts.length === 0) from
|
||||
// filter-miss (this.forecasts has rows but none match the current
|
||||
// region/probability filter). The badge already reflects fetch success
|
||||
// independently; this copy just helps the user understand why the
|
||||
// list is empty so they can adjust the pill without thinking the feed
|
||||
// is broken.
|
||||
const hasAnyForecasts = this.forecasts.length > 0;
|
||||
const emptyCopy = hasAnyForecasts
|
||||
? 'No forecasts match the current filter'
|
||||
: 'No forecasts available';
|
||||
this.setContent(`
|
||||
<div class="fc-panel">
|
||||
<div class="fc-filters">${filtersHtml}</div>
|
||||
<div class="fc-filters">${regionsHtml}</div>
|
||||
<div class="fc-empty">No forecasts available</div>
|
||||
<div class="fc-empty">${escapeHtml(emptyCopy)}</div>
|
||||
</div>
|
||||
`);
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user