diff --git a/ARCHITECTURE-EXPLORATION.md b/ARCHITECTURE-EXPLORATION.md new file mode 100644 index 0000000000..96f4c10f9c --- /dev/null +++ b/ARCHITECTURE-EXPLORATION.md @@ -0,0 +1,924 @@ +# WorldMonitor Architecture Exploration + +## 1. Panel Component Base Structure & Lifecycle + +### Panel Base Class (`src/components/Panel.ts`) + +The `Panel` class is the foundational abstract class for all 86+ UI components. It's a class-based component system (not React/Preact based, despite the Preact build) that manages its own DOM lifecycle. + +**Constructor & Key Properties:** +```typescript +interface PanelOptions { + id: string; // Unique identifier (e.g., 'economic', 'news') + title: string; // Display title + showCount?: boolean; // Show item count badge + className?: string; // CSS classes + trackActivity?: boolean; // Track panel activity/novelty + infoTooltip?: string; // Methodology tooltip (HTML) + premium?: 'locked' | 'enhanced'; // Paywall gate + closable?: boolean; // User can close panel + collapsible?: boolean; // User can minimize panel + defaultRowSpan?: number; // Grid height (1-4 rows) +} +``` + +**Key Properties & State:** +- `element`: Root HTMLElement +- `content`: HTMLElement container for panel body +- `header`: Panel header with title, badges, controls +- `countEl`: Optional count badge (e.g., "News: 24") +- `statusBadgeEl`: Data freshness badge ("live", "cached", "unavailable") +- `_fetching`: Boolean flag tracking load state +- `_locked`: Boolean for paywall lock state +- `_collapsed`: Boolean for minimized state + +**Core Lifecycle Methods:** + +1. **`showLoading()`** — Renders spinner, called by panel subclasses during initial fetch +2. **`setContent(html: string, debounceMs?: number)`** — Debounced DOM update (150ms default); subclasses call this to render results +3. **`setDataBadge(state, detail)`** — Sets "live · 2s ago", "cached", or "unavailable" badge +4. **`setSeverity(level)`** — Sets colored severity dot: 'critical', 'high', 'medium', 'low', 'none' +5. **`showGatedCta(reason)`** — Renders "Sign In to Unlock" / "Upgrade to Pro" overlay +6. **`unlockPanel()`** — Removes gate overlay, restores original content +7. **`showRetry(callback, attemptNum)`** — Retry button with countdown timer (backoff) +8. **`destroy()`** — Cleanup: abort pending fetches, remove DOM, clear listeners + +**Sizing & Persistence:** +- Panels persist user-resized dimensions to localStorage (`worldmonitor-panel-spans`, `worldmonitor-panel-col-spans`) +- Supports 1-4 row spans and 1-3 column spans +- Resize handles use mouse/touch with debounced frame updates +- Double-click resize handle resets to default size +- Closed/minimized state stored in `worldmonitor-panel-collapsed` + +**Premium & Gating:** +- Checks `PanelGateReason` at render time (web-specific `WEB_PREMIUM_PANELS`, Clerk-only `WEB_CLERK_PRO_ONLY_PANELS`) +- Saves content snapshot (`_savedContent`) when showing lock, restores on unlock (preserves subclass DOM references) + +**Common Subclass Pattern:** +```typescript +export class StatusPanel extends Panel { + constructor() { + super({ id: 'status', title: 'Status', showCount: true }); + this.init(); + } + + private init(): void { + // Set up initial state, event listeners + } + + async load(): Promise { + this.showLoading(); + try { + const data = await fetchStatus(); + this.renderStatus(data); + this.setDataBadge('live', '5s ago'); + } catch (e) { + this.showRetry(() => this.load(), 1); + } + } + + private renderStatus(data: StatusData): void { + const html = `
${data.items.map(item => + `
${escapeHtml(item.name)}
` + ).join('')}
`; + this.setContent(html); + } +} +``` + +--- + +## 2. Data-Loader Architecture + +### DataLoaderManager (`src/app/data-loader.ts`) + +The data-loader is the orchestration layer that coordinates async data fetching for all panels. It's a state machine that manages panel callbacks, retry logic, circuit breakers, and hierarchical refresh scheduling. + +**Design Principles:** +- **No blocking**: All fetches are non-blocking async; UI remains interactive +- **Coalescing**: Multiple concurrent requests to same endpoint use a single network call +- **Circuit breaking**: Failed data sources get cooldown periods (5–30 min depending on tier) +- **Fallback cascades**: When a primary feed fails, fall back to per-feed backups, then cached digests +- **Debouncing**: Heavy operations like news clustering debounced to prevent thrashing + +**Data Flow:** + +``` +App.ts + ↓ +DataLoaderManager.init() + ├─ Registers panel load callbacks + ├─ Sets up refresh scheduler (60s interval default) + └─ Subscribes to framework changes (market, brief) + ↓ + loadAllData() / loadPanel(key) + ├─ Calls panel.load() or enqueuePanelCall() + ├─ Each panel fetches via services + ├─ Services use cachedFetchJson() + Redis memoization + └─ Panel.setContent() + setDataBadge() +``` + +**Key Methods:** + +1. **`loadAllData(): Promise`** — Master orchestrator; loads all visible panels in dependency order + - News digest (base feed resolution) + - Markets (stocks, commodities, crypto) + - Intelligence signals (conflicts, protests, military) + - Specialized panels (economic, climate, etc.) + - Runs ~60s on startup, repeats on interval + +2. **`loadPanel(key: string): Promise`** — Load a single panel by ID + - Used for on-demand opens, targeted refresh + - Example: `loadPanel('economic')` → calls `panels['economic'].load()` + +3. **`tryFetchDigest()`** — Central news aggregator + - Fetches unified feed digest from `/api/news/v1/list-feed-digest` + - Circuit breaker with 5m cooldown on failure + - Cascades to cached digest if network fails + - Result memoized for news panels to avoid duplicate fetches + +4. **`loadNews()`** — Renders news into NewsPanel + - Clusters headlines with ML worker (`clusterNewsHybrid()`) + - Enriches with threat classification, geolocation + - Dedupes against OREF breaking alerts + - Supports time-range filtering (1h, 24h, 7d) + +5. **`loadMarkets()`** — Stocks, commodities, crypto + - Fetches from user's watchlist + - Calls `fetchMultipleStocks()`, `fetchCommodityQuotes()`, etc. + - Each has own circuit breaker + +6. **`loadIntelligence()`** — Military, conflicts, protests + - Aggregates conflicts (UCDP, ACLED), protests, military flights/vessels + - Runs geo-convergence analysis (detects clusters near critical infrastructure) + - Updates CII (Country Instability Index) scores + - Calls `ingestProtests()`, `ingestFlights()`, etc. to update country-instability service + +7. **Panel Call Enqueueing** — Asynchronous panel mounting + - If panel not yet instantiated, calls are queued in `pending-panel-data` + - When panel mounts, queued calls replay automatically + - Example: NewsPanel.load() queued before panel constructor runs + +**Circuit Breaker Pattern:** +```typescript +private digestBreaker = { + state: 'closed' | 'open' | 'half-open', + failures: 0, + cooldownUntil: 0 +}; + +// On failure count ≥ 2: state='open', cooldown 5min +// During cooldown: return cached result +// After cooldown: state='half-open', try once +// On success: state='closed', reset failures +``` + +**Caching Tiers:** +- **Fast cache (5m)**: Real-time data (markets, outages, flights) +- **Medium cache (10m)**: Feed aggregation, analysis +- **Slow cache (30m)**: Economic indicators, climate anomalies +- **Static cache (2h)**: Geometry, ports, bases +- **Daily cache (24h)**: IMF WEO, JODI, historical data + +--- + +## 3. Main UI Layout & State Management + +### PanelLayoutManager (`src/app/panel-layout.ts`) + +The layout system manages panel instantiation, grid rendering, user interactions (drag, resize, close), and dynamic access control based on entitlements. + +**Panel Registry:** +- 86+ panels defined in `src/config/panels.ts` +- Each variant (full, tech, finance, etc.) has a subset in `VARIANT_DEFAULTS` +- Free tier limited to `FREE_MAX_PANELS=12`, `FREE_MAX_SOURCES=3` + +**Panel Gating (Paywall):** +```typescript +const WEB_PREMIUM_PANELS = new Set([ + 'stock-analysis', + 'stock-backtest', + 'daily-market-brief', + 'market-implications', + 'deduction', + 'chat-analyst', + 'wsb-ticker-scanner', + 'latest-brief', + 'regional-intelligence', + 'trade-policy', +]); + +const WEB_CLERK_PRO_ONLY_PANELS = new Set([ + 'latest-brief', // Must have Clerk auth (not just API key) +]); +``` + +**Layout Rendering:** + +1. **`renderLayout(): Promise`** — Master init + - Instantiates all visible panels for current variant + - Injects into grid DOM + - Applies saved user preferences (spans, collapsed, custom order) + - Mounts critical warning banner (e.g., OREF alerts) + +2. **Grid Structure:** + ```html +
+
+
+
+ News + +
+ live · 3s ago + 24 + +
+
+
+
+
+
+ ``` + +3. **Panel Lifecycle in Layout:** + - `mountPanel(panelId)` → new Panel() → injects into grid + - User opens Country Brief → `openCountryBrief(code)` → modal overlay (CountryDeepDivePanel) + - User closes panel → `closePanel(panelId)` → removes from DOM, cleans up listeners + - Panel unmounts on variant switch → destroyAllPanels() + +**State Management & Context:** + +`AppContext` (src/app/app-context.ts) is the global state tree: + +```typescript +export interface AppContext { + map: MapContainer | null; // Deck.gl map instance + readonly isMobile: boolean; + readonly isDesktopApp: boolean; // Tauri desktop or browser + readonly container: HTMLElement; // Root mount point + + panels: Record; // panel[id] = instance + newsPanels: Record; // Convenience subset + panelSettings: Record; // Saved prefs (size, order, hidden) + + mapLayers: MapLayers; // CII choropleth, conflict zones, etc. + + allNews: NewsItem[]; // Aggregated headlines + newsByCategory: Record; // Partitioned by feed + latestMarkets: MarketData[]; // Stock/commodity quotes + latestPredictions: PredictionMarket[]; // Polymarket data + latestClusters: ClusteredEvent[]; // Geo-clustered events (conflicts, protests) + intelligenceCache: IntelligenceCache; // Flights, vessels, earthquakes, etc. + + disabledSources: Set; // User-disabled feeds + currentTimeRange: TimeRange; // 1h | 24h | 7d +} +``` + +**Event Delegation:** + +Panels communicate via custom events: + +```typescript +// Panel A: Emit +window.dispatchEvent(new CustomEvent('wm-market-watchlist-changed', { + detail: { symbol: 'NVDA', action: 'add' } +})); + +// DataLoaderManager: Listen +this.boundMarketWatchlistHandler = () => { + void this.loadMarkets().then(async () => { + if (hasPremiumAccess()) { + await this.loadStockAnalysis(); + } + }); +}; +window.addEventListener('wm-market-watchlist-changed', this.boundMarketWatchlistHandler); +``` + +**User Interaction Handlers:** + +1. **Drag/Reorder** (not yet fully implemented for panels): + - Grid uses CSS Grid layout; manual drag would require custom JS + - Currently panels have fixed order per variant + +2. **Resize** (vertical & horizontal): + - Mouse/touch handlers on resize handles + - Converts pixel deltas to grid span deltas (80px per span) + - Persists to localStorage on mouseUp + +3. **Close**: + - Remove from DOM + - Emit `wm-panel-closed` event (optional) + - Update panelSettings + +4. **Collapse**: + - Toggle `_collapsed` flag + - Hide content, show only header + - Persist to localStorage + +5. **Search/Filter**: + - SearchManager opens modal + - User types query + - Dispatches `wm-search-input` event + - Panels that support search receive callbacks (e.g., NewsPanel filters by keyword) + +--- + +## 4. Country/Geo Data Structures & ISO Codes + +### Country Geometry Service (`src/services/country-geometry.ts`) + +Core geo-lookup service using **ISO 3166-1 alpha-2** codes (2-letter: US, CN, RU, etc.). + +**Data Structure:** + +```typescript +interface IndexedCountryGeometry { + code: string; // ISO-2: "US", "GB", "JP" + name: string; // Full name: "United States" + bbox: [number, number, number, number]; // [minLon, minLat, maxLon, maxLat] + polygons: [number, number][][][]; // GeoJSON coordinates +} +``` + +**Loaded from:** +- Primary: `public/data/countries.geojson` (shipped with build) +- Overrides: `https://maps.worldmonitor.app/country-boundary-overrides.geojson` (Natural Earth, 3s timeout) +- Political override: `POLITICAL_OVERRIDES['CN-TW'] = 'TW'` (Taiwan as separate) + +**Key Functions:** + +1. **`preloadCountryGeometry(): Promise`** — Load and index all countries + - Called at App startup + - Normalizes codes (extracts `ISO3166-1-Alpha-2` property from GeoJSON features) + - Builds `iso3ToIso2` map: ISO-3 → ISO-2 (e.g., "USA" → "US") + - Builds `nameToIso2` map: Country name → ISO-2 (with aliases: "DR Congo" → "CD") + - Indexes by bbox for point-in-polygon queries + +2. **`getCountryAtCoordinates(lon: number, lat: number): {code, name} | null`** + - Ray-casting point-in-polygon test + - Used for reverse geocoding in panels (e.g., click on map → open country brief) + +3. **`iso3ToIso2Code(iso3: string): string | null`** — Normalize "USA" → "US" +4. **`nameToCountryCode(name: string): string | null`** — Lookup "Russia" → "RU" +5. **`getCountryNameByCode(code: string): string | null`** — Lookup "JP" → "Japan" +6. **`matchCountryNamesInText(text: string): string[]`** — Extract country codes from article text + +**Political Boundary Handling:** + +```typescript +const POLITICAL_OVERRIDES: Record = { + 'CN-TW': 'TW', // Taiwan treated as separate country +}; + +function normalizeCode(properties): string | null { + const rawCode = properties['ISO3166-1-Alpha-2'] ?? properties.ISO_A2; + const trimmed = rawCode.trim().toUpperCase(); + return POLITICAL_OVERRIDES[trimmed] ?? trimmed; +} +``` + +**Name Aliases (Common Misspellings):** + +```typescript +const NAME_ALIASES: Record = { + 'dr congo': 'CD', + 'czech republic': 'CZ', + 'ivory coast': 'CI', + 'uae': 'AE', + 'uk': 'GB', + 'usa': 'US', + 'south korea': 'KR', + 'north korea': 'KP', +}; +``` + +### Country Instability Index (`src/services/country-instability.ts`) + +Real-time risk scoring system keyed by ISO-2 country code. + +**CountryScore Type:** + +```typescript +export interface CountryScore { + code: string; // ISO-2: "RU", "UA", "IL" + name: string; + score: number; // 0–100 (0=peaceful, 100=critical) + level: 'low' | 'normal' | 'elevated' | 'high' | 'critical'; + trend: 'rising' | 'stable' | 'falling'; + change24h: number; // Score delta in last 24h + components: ComponentScores; + lastUpdated: Date | null; +} + +export interface ComponentScores { + unrest: number; // Protests, strikes (0–25) + conflict: number; // Armed conflict, UCDP events (0–25) + security: number; // Cyber, advisories, sanctions (0–25) + information: number; // Internet outages, GPS jamming (0–25) +} +``` + +**Data Ingest Pipeline:** + +The `ingestXxx()` functions consume raw events and update per-country totals: + +```typescript +// Military flights in country +ingestFlights(militaryFlights: MilitaryFlight[]) + // Count by country, weighted by operator/type + +// Conflict events (UCDP, ACLED) +ingestConflicts(events: ConflictEvent[]) + // Tally deaths, casualties, severity + +// Protests & strikes +ingestProtests(events: SocialUnrestEvent[]) + // Count by country + +// Internet outages +ingestOutagesForCII(outages: InternetOutage[]) + // Mark outages per country + +// GPS jamming, cyber threats, advisories +ingestGpsJammingForCII() +ingestCyberThreatsForCII() +ingestAdvisoriesForCII() +``` + +**Scoring Model:** + +```typescript +score = baselineRisk[country] + + (conflicts.length * eventMultiplier[country]) + + (protests.length * 2) + + (militaryFlights.length * 0.5) + + (outages.length * 3) + + ...other factors +``` + +**Curated Country Config:** + +`src/config/countries.ts` defines per-country baseline risk and event weighting: + +```typescript +export const CURATED_COUNTRIES: Record = { + RU: { + name: 'Russia', + scoringKeywords: ['russia', 'moscow', 'kremlin', 'putin'], + baselineRisk: 35, // Always 35 before events + eventMultiplier: 2.0, // Each event worth 2× weight + }, + CN: { + name: 'China', + baselineRisk: 25, + eventMultiplier: 2.5, + }, + UA: { + name: 'Ukraine', + baselineRisk: 50, // Sustained conflict → high baseline + eventMultiplier: 0.8, // Less sensitive to daily events + }, + // ... 20+ more +}; +``` + +**Hotspot & Conflict Zone Configs:** + +`src/config/geo.ts` defines critical regions and their focal points: + +```typescript +export const INTEL_HOTSPOTS = [ + { code: 'UA', region: 'donbas', lat: 48.0, lon: 38.0, radiusKm: 150 }, + { code: 'IL', region: 'gaza', lat: 31.9, lon: 34.5, radiusKm: 50 }, + { code: 'IR', region: 'strait-hormuz', lat: 26.5, lon: 56.5, radiusKm: 100 }, + // ... +]; + +export const STRATEGIC_WATERWAYS = [ + { name: 'Strait of Hormuz', lat: 26.5, lon: 56.5, code: 'IR/OM' }, + { name: 'Taiwan Strait', lat: 24.5, lon: 119.5, code: 'TW/CN' }, + // ... +]; +``` + +### Country Brief Panel Data Structures + +**CountryBriefPanel** opens a detail modal with multi-tab data: + +```typescript +export interface CountryIntelData { + brief: string; // LLM-generated intel summary + country: string; // Full name: "Ukraine" + code: string; // ISO-2: "UA" + cached?: boolean; + generatedAt?: string; + error?: string; + reason?: string; // Why unavailable (e.g., "gated content") +} + +export interface CountryDeepDiveSignalItem { + type: 'MILITARY' | 'PROTEST' | 'CYBER' | 'DISASTER' | 'OUTAGE' | 'OTHER'; + severity: 'critical' | 'high' | 'medium' | 'low' | 'info'; + description: string; + timestamp: Date; +} + +export interface CountryEnergyProfileData { + // Energy mix: coal%, gas%, oil%, nuclear%, renewables% + coalShare: number; + gasShare: number; + // ... 30+ fields for electricity, storage, imports/exports +} +``` + +**Economic Indicators Service:** + +`src/services/imf-country-data.ts` fetches IMF WEO data keyed by ISO-2: + +```typescript +export interface ImfCountryBundle { + macro: ImfMacroEntry | null; // Inflation, debt, reserves + growth: ImfGrowthEntry | null; // GDP growth, investment + labor: ImfLaborEntry | null; // Unemployment, population + external: ImfExternalEntry | null; // Trade, current account + fetchedAt: number; +} + +// Data indexed by ISO-2 code in Redis: +// imfMacro:{code} → { inflationPct, govRevenuePct, ... } +``` + +--- + +## 5. Panel Data Fetching & Caching Example: StrategicPosturePanel + +A complex panel that aggregates military flights, vessels, and bases to assess escalation risk. + +### Design: + +StrategicPosturePanel fetches from 4+ services, each with independent circuit breakers: + +```typescript +export class StrategicPosturePanel extends Panel { + async load(): Promise { + this.showLoading(); + try { + // Parallel fetch: all 4 services at once + const [flights, vessels, bases, posture] = await Promise.all([ + this.fetchFlights(), + this.fetchVessels(), + this.fetchBases(), + this.fetchPosture(), + ]); + + this.renderTheaterView(flights, vessels, bases, posture); + this.setDataBadge('live', '12s ago'); + } catch (e) { + this.showRetry(() => this.load(), 1); + } + } + + private async fetchFlights(): Promise { + // Calls service which uses circuit breaker + return fetchMilitaryFlights({ + signal: this.abortController.signal + }); + } + + private async fetchVessels(): Promise { + return fetchMilitaryVessels({ + signal: this.abortController.signal + }); + } + + private async fetchBases(): Promise { + // Cached for 24h: geo never changes + return MILITARY_BASES_EXPANDED; + } + + private async fetchPosture(): Promise { + // Aggregates flights/vessels by theater, compares to baseline + return fetchCachedTheaterPosture({ + signal: this.abortController.signal + }); + } + + private renderTheaterView( + flights: MilitaryFlight[], + vessels: MilitaryVessel[], + bases: MilitaryBase[], + posture: TheaterPostureSummary[] + ): void { + const html = posture.map(theater => ` +
+

${escapeHtml(theater.name)}

+
+ Flights: ${theater.flightCount} + ${theater.trend} +
+ ${flights.filter(f => isInTheater(f, theater)).map(f => ` +
+ ${escapeHtml(f.operator)} + ${f.type} +
+ `).join('')} +
+ `).join(''); + + this.setContent(html); + } +} +``` + +### Service-Level Caching: + +**fetchMilitaryFlights** (src/services/military-flights.ts): + +```typescript +const MILITARY_FLIGHTS_CACHE_MS = 5 * 60 * 1000; // 5 min + +export async function fetchMilitaryFlights(opts?: FetchOpts): Promise { + const rpcUrl = toApiUrl('/api/intelligence/v1/list-military-flights'); + + // Circuit breaker + deduping + return cachedFetchJson( + rpcUrl, + { signal: opts?.signal }, + MILITARY_FLIGHTS_CACHE_MS, // TTL + (data) => data.flights ?? [] // Result selector + ); +} +``` + +**cachedFetchJson** (src/services/cached-*.ts): + +```typescript +export async function cachedFetchJson( + url: string, + init: RequestInit, + ttlMs: number, + selector: (data: unknown) => unknown +): Promise { + // 1. Check Redis cache key: md5(url) + const cacheKey = `fetch:${md5(url)}`; + const cached = await redis.get(cacheKey); + if (cached && !isStale(cached.storedAt, ttlMs)) { + console.log('[Cache HIT]', url); + return selector(cached.data); + } + + // 2. Coalesce: if request already in-flight, wait for it + if (inFlightRequests.has(url)) { + return inFlightRequests.get(url)!; + } + + // 3. New request + const promise = (async () => { + try { + const resp = await fetch(url, init); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + const data = await resp.json(); + + // 4. Store in Redis + await redis.set(cacheKey, { data, storedAt: Date.now() }, { ex: Math.ceil(ttlMs / 1000) }); + return selector(data); + } finally { + inFlightRequests.delete(url); + } + })(); + + inFlightRequests.set(url, promise); + return promise; +} +``` + +### Circuit Breaker in Data-Loader: + +If `fetchMilitaryFlights()` fails 2×, cooldown 30m: + +```typescript +export class DataLoaderManager { + private militaryFlightsBreaker = { + state: 'closed', + failures: 0, + cooldownUntil: 0 + }; + + private async loadIntelligence(): Promise { + // Check breaker state + const now = Date.now(); + if (this.militaryFlightsBreaker.state === 'open') { + if (now < this.militaryFlightsBreaker.cooldownUntil) { + console.warn('[Circuit Open] Military flights cooling down'); + return; // Skip fetch, leave stale data on screen + } + this.militaryFlightsBreaker.state = 'half-open'; + } + + try { + const flights = await fetchMilitaryFlights(); + // Update country-instability + ingestFlights(flights); + this.militaryFlightsBreaker = { state: 'closed', failures: 0, cooldownUntil: 0 }; + } catch (e) { + this.militaryFlightsBreaker.failures++; + if (this.militaryFlightsBreaker.failures >= 2) { + this.militaryFlightsBreaker.state = 'open'; + this.militaryFlightsBreaker.cooldownUntil = now + 30 * 60 * 1000; + } + console.error('[Fetch Failed] Military flights:', e); + } + } +} +``` + +### Aggregation Example: Military Surge Analysis + +**military-surge.ts** combines flights + vessels + bases to detect escalations: + +```typescript +export interface TheaterPostureSummary { + id: string; // 'middle-east' + name: string; // 'Middle East / Persian Gulf' + baselineFlights: number; // Historical avg + currentFlights: number; + surgeFactor: number; // currentFlights / baselineFlights + flightTrend: 'rising' | 'stable' | 'falling'; + nearbyBases: string[]; + timeSeriesDataPoints: TheaterActivity[]; // Last 7 days +} + +export async function analyzeFlightsForSurge( + flights: MilitaryFlight[] +): Promise<{ surgeAlerts: SurgeAlert[]; theaters: Map }> { + const byTheater = new Map(); + + // Cluster flights by theater (geographic region) + for (const flight of flights) { + const theater = findTheaterForFlight(flight); + if (!theater) continue; + + const activity = byTheater.get(theater.id) ?? { + theaterId: theater.id, + timestamp: Date.now(), + transportCount: 0, + fighterCount: 0, + reconCount: 0, + totalMilitary: 0, + flightIds: [], + }; + + if (flight.type === 'transport') activity.transportCount++; + else if (flight.type === 'fighter') activity.fighterCount++; + else if (flight.type === 'reconnaissance') activity.reconCount++; + + activity.totalMilitary++; + activity.flightIds.push(flight.id); + byTheater.set(theater.id, activity); + } + + // Detect surges vs. historical baseline + const surgeAlerts: SurgeAlert[] = []; + for (const [theaterId, activity] of byTheater) { + const baseline = calculateBaselineForTheater(theaterId); + if (activity.totalMilitary > baseline * 1.5) { + surgeAlerts.push({ + id: `surge-${theaterId}-${Date.now()}`, + theater: THEATERS.find(t => t.id === theaterId)!, + type: activity.fighterCount > activity.transportCount ? 'fighter' : 'airlift', + currentCount: activity.totalMilitary, + baselineCount: baseline, + surgeMultiple: activity.totalMilitary / baseline, + aircraftTypes: new Map(), + nearbyBases: findNearbyBases(activity), + firstDetected: new Date(), + lastUpdated: new Date(), + }); + } + } + + return { surgeAlerts, theaters: byTheater }; +} + +function findTheaterForFlight(flight: MilitaryFlight): MilitaryTheater | null { + // Geo-spatial search: which theater contains this lat/lon? + for (const theater of THEATERS) { + const dist = haversineDistanceKm(flight.lat, flight.lon, theater.centerLat, theater.centerLon); + if (dist < 1000) return theater; // Rough radius, actual impl more sophisticated + } + return null; +} +``` + +--- + +## Architecture Summary Table + +| Component | Type | Key Files | Responsibility | +|---|---|---|---| +| **Panel** | Base Class | `src/components/Panel.ts` | DOM lifecycle, resize, paywall gates | +| **Panel Subclass** | Subclass | `src/components/EconomicPanel.ts` | Fetch data, render UI, respond to user input | +| **DataLoaderManager** | Orchestrator | `src/app/data-loader.ts` | Schedule panel loads, circuit breakers, cascading fallbacks | +| **RefreshScheduler** | Scheduler | `src/app/refresh-scheduler.ts` | 60s interval loop, refresh all panels | +| **PanelLayoutManager** | Layout Engine | `src/app/panel-layout.ts` | Grid layout, panel gating, user interactions | +| **Service** | Data Layer | `src/services/market/`, `src/services/military-`, etc. | Fetch from RPC, Redis cache, circuit break | +| **AppContext** | State Container | `src/app/app-context.ts` | Global singleton: panels, news, markets, cache | +| **CountryGeometry** | Geo Library | `src/services/country-geometry.ts` | ISO-2 lookup, point-in-polygon, normalize names | +| **CountryInstability** | Scoring | `src/services/country-instability.ts` | Real-time CII scores, ingest multi-source events | +| **API Layer** | Edge Functions | `api/*.js`, `api/*/index.js` | Vercel serverless, no imports from `src/` | + +--- + +## Key Design Patterns + +### 1. **Abort Controller Cleanup** +Every async panel operation stores an AbortController: +```typescript +private abortController: AbortController = new AbortController(); + +async load() { + const resp = await fetch(url, { signal: this.abortController.signal }); +} + +destroy() { + this.abortController.abort(); // Cancel all pending fetches on unmount +} +``` + +### 2. **Debounced Content Rendering** +Panels batch rapid updates to avoid thrashing: +```typescript +setContent(html: string, debounceMs = 150) { + this.pendingContentHtml = html; + clearTimeout(this.contentDebounceTimer); + this.contentDebounceTimer = setTimeout(() => { + replaceChildren(this.content, safeHtml(this.pendingContentHtml)); + }, debounceMs); +} +``` + +### 3. **Coalesced Caching** +Multiple concurrent requests to the same URL merge into one network call: +```typescript +const inFlightRequests = new Map>(); + +if (inFlightRequests.has(url)) { + return inFlightRequests.get(url)!; // Reuse pending request +} +``` + +### 4. **Enqueued Panel Calls** +Before a panel instance exists, data-loader queues method calls: +```typescript +enqueuePanelCall('news', 'load', []); +// Later, when NewsPanel mounts: +replayPendingCalls('news'); // Plays back load() +``` + +### 5. **Multi-Service Aggregation** +Complex panels orchestrate multiple independent services in parallel: +```typescript +const [flights, vessels, bases, posture] = await Promise.all([ + fetchMilitaryFlights(), + fetchMilitaryVessels(), + getBases(), // Static + fetchCachedTheaterPosture(), +]); +``` + +--- + +## Data Flow Diagram + +``` +User Action (load page / click country) + ↓ +App.ts: initAppContext() → DataLoaderManager.init() + ↓ +PanelLayoutManager.renderLayout() → mount 12+ panels + ↓ +DataLoaderManager.loadAllData() + ├─ Panel A: load() → fetchService1() → cachedFetchJson() → Redis hit/miss → setContent() + ├─ Panel B: load() → fetchService2,3,4() → all in parallel → aggregate → setContent() + ├─ Panel C: load() → fetchService5() → circuit breaker open → use cached result + └─ ... + ↓ +AppContext.panels[id] / allNews / latestMarkets / intelligenceCache updated + ↓ +RefreshScheduler: every 60s → loadAllData() again + ↓ +User drag/resize panel → localStorage persist → next load restores +User click map → countryGeometry.getCountryAtCoordinates() → open CountryBriefPanel +User search → SearchManager → broadcast to panels → NewsPanel.filterByKeyword() +``` + +This architecture supports: +- **86+ concurrently-loadable panels** with independent lifecycle +- **30+ external data sources** aggregated via services +- **Real-time geo-risk scoring** (CII) across 195 countries +- **Circuit breaker cascade** preventing cascade failures +- **Browser-local & Redis caching** tiers +- **Fully responsive UI** with user-persisted preferences diff --git a/COUNTRY_PROFILE_FEATURE.md b/COUNTRY_PROFILE_FEATURE.md new file mode 100644 index 0000000000..0033b7f818 --- /dev/null +++ b/COUNTRY_PROFILE_FEATURE.md @@ -0,0 +1,290 @@ +# Country Profile Feature Implementation + +## Overview + +A comprehensive country-focused intelligence dashboard feature has been added to WorldMonitor, enabling users to: +- Search for and select countries from a searchable database +- View a modal overlay with country-specific intelligence across all domains +- Track real-time alerts and updates filtered by the selected country +- Quickly switch between different countries without losing the main dashboard + +## Components Created + +### 1. **CountrySelector** (`src/components/CountrySelector.ts`) +A unified country selection interface featuring: +- **Search Box**: Filter countries by name or ISO code +- **Quick-Select Buttons**: Fast access to 20+ frequently monitored countries (US, CN, RU, UA, IL, IR, etc.) +- **Full Country List Dropdown**: Scrollable list of 195+ countries with flags and ISO codes +- Keyboard navigation (Escape to close) +- Click-outside to close + +**Usage:** +```typescript +const selector = new CountrySelector({ + onCountrySelected: (code, name) => handleCountrySelect(code, name), + onClose: () => handleClose(), +}); +selector.updateStyles(); // Inject CSS +``` + +### 2. **CountryProfileView** (`src/components/CountryProfileView.ts`) +Modal overlay displaying country-focused intelligence with: +- **Header Section**: Country flag, name, ISO code, real-time status indicator +- **Mini-Panels Grid**: Real-time data cards for: + - Military Presence (flights, vessels) + - Economic Indicators + - Energy Profile + - Cyber Threats + - Health & Humanitarian Status +- **Main Panel Area**: CountryDeepDivePanel for comprehensive country analysis +- Responsive design (95% width, adapts to mobile) +- Smooth animations and visual hierarchy + +**Key Features:** +- Real-time data updates +- Blue status indicator with pulsing animation +- Full-screen modal with backdrop blur +- Close button and Escape key support +- Integration with existing CountryDeepDivePanel + +### 3. **CountryProfilePanel** (`src/components/CountryProfilePanel.ts`) +A panel component extending the base Panel class: +- Aggregates country intelligence across multiple domains +- Displays in the main dashboard grid (can be enabled/disabled) +- Shows a prompt when no country is selected +- Renders risk score badge (critical/high/medium/low) +- Lists recent headline events +- Connects to the CountryProfileManager + +### 4. **CountryProfileManager** (`src/app/country-profile-manager.ts`) +Orchestrator managing: +- Country selector modal lifecycle +- Country profile view creation/destruction +- Data loading and filtering by country +- Real-time subscription management +- Integration with AppContext + +**Key Methods:** +```typescript +manager.openCountrySelector(); // Show country picker +manager.selectCountry(code, name); // Select and display country +manager.closeCountryProfile(); // Close profile overlay +manager.getSelectedCountryCode(); // Get current selection +manager.updateCountryProfile(); // Refresh data +``` + +## Integration with App State + +### App.ts Changes +1. Import added: `import { CountryProfileManager } from '@/app/country-profile-manager';` +2. Property added: `private countryProfileManager: CountryProfileManager | null = null;` +3. Initialization in constructor: Creates CountryProfileManager instance +4. Module tracking: Added to modules array for proper lifecycle management +5. Public API methods: + - `openCountrySelector()`: Trigger country selector + - `closeCountryProfile()`: Close the profile view + - `getSelectedCountryCode()`: Query selected country + +### Panel Layout Changes +1. CountryProfilePanel imported and registered +2. Panel creation logic added with AppContext binding +3. Conditional creation based on variant configuration + +### Configuration Changes (`src/config/panels.ts`) +Added to FULL_PANELS: +```typescript +'country-profile': { name: 'Country Profile', enabled: false, priority: 1 } +``` + +Note: Panel is disabled by default but available in full variant for users to enable if desired. + +## Data Flow + +``` +User → CountrySelector (search/select) + ↓ +CountryProfileManager.selectCountry() + ↓ +CountryProfileView (modal created) + ↓ +Load country-specific data: + - Military activity (flights, vessels) + - Economic indicators (markets, indices) + - Cyber threats + - Energy profile + - Recent news/events + ↓ +Real-time updates via data-loader integration +``` + +## Usage Examples + +### Opening Country Selector +```typescript +// From App instance +app.openCountrySelector(); +``` + +### Handling Country Selection +The CountrySelector automatically: +1. Opens the country selection modal +2. On selection, creates CountryProfileView +3. Loads country-specific data +4. Displays in full-screen overlay +5. Closes selector and shows profile + +### Accessing from UI +Users can: +1. Add the CountryProfilePanel to their dashboard (enable in variant config) +2. Click a country on the map (if integrated with map click handlers) +3. Search for countries via main SearchModal integration +4. Use WebMCP tools if available + +## Styling Features + +### CountrySelector +- Dark theme with accent colors +- Flag emojis for visual identification +- Quick-select grid layout (auto-fill) +- Smooth hover effects +- Monospace font for country codes + +### CountryProfileView +- Large flag display (48px) +- Color-coded risk badge (red/orange/yellow/green) +- Grid layout for mini-panels (responsive 350px min-width) +- Pulsing status indicator +- Full-screen modal with backdrop blur + +### CountryProfilePanel +- Compact indicator cards with icons (🛡️🔒📡) +- Event list with accent-colored left border +- Risk badge with status-specific colors +- Responsive grid layout + +## Real-time Data Integration + +The system is designed to work with WorldMonitor's existing data-loader: + +1. **News Filtering**: `__COUNTRY_PROFILE_NEWS` window variable stores filtered news +2. **Risk Scores**: `__COUNTRY_RISK_SCORES` stores country risk calculations +3. **Military Data**: Filters existing military flights/vessels by country code +4. **Cyber Threats**: Filters cyber threat cache by country code +5. **Markets**: Filters market data by country code + +## Translation Support + +All user-facing strings use the i18n system: +- `country_selector.search_placeholder` +- `country_selector.quick_select` +- `country_profile.realtime` +- `country_profile.viewing` +- `panels.countryProfile` +- `panels.riskScore` +- `panels.militaryActivity` +- And more... + +## Future Enhancements + +### Phase 2: Advanced Features +1. **Map Integration**: Click on map to open country profile +2. **Bookmark Countries**: Save favorite countries for quick access +3. **Country Comparison**: Compare 2-3 countries side-by-side +4. **Historical Timeline**: View country intelligence over time +5. **Export Functionality**: Download country profile as PDF/CSV + +### Phase 3: Deep Analytics +1. **Correlation Analysis**: See how events in one country affect others +2. **Supply Chain Mapping**: Trace dependencies from selected country +3. **Economic Shock Scenarios**: Model impact of disruptions +4. **Predictive Alerts**: ML-based risk forecasting + +### Phase 4: API Integration +1. **Country-scoped Endpoints**: `/api/country/{code}/intelligence` +2. **Batch Country Profiles**: `/api/countries/batch` +3. **Country Risk Forecasts**: `/api/country/{code}/forecast` +4. **Historical Data**: `/api/country/{code}/history` + +## Testing Checklist + +- [ ] CountrySelector opens and displays countries +- [ ] Search filters countries correctly +- [ ] Quick-select buttons work +- [ ] Country selection triggers CountryProfileView +- [ ] CountryProfileView displays correct country info +- [ ] Close button and Escape key close the modal +- [ ] CountryProfilePanel can be enabled in variant config +- [ ] Real-time data updates when new intelligence arrives +- [ ] Multiple countries can be sequenced (select → close → select different) +- [ ] Mobile responsiveness (overlay fits screen) +- [ ] Keyboard navigation works +- [ ] Flag emojis render correctly +- [ ] Styling matches dark theme + +## File Locations + +``` +src/ +├── components/ +│ ├── CountrySelector.ts ← Country search/select UI +│ ├── CountryProfileView.ts ← Modal overlay +│ ├── CountryProfilePanel.ts ← Dashboard panel +│ └── index.ts ← Updated exports +├── app/ +│ ├── country-profile-manager.ts ← Orchestrator +│ ├── panel-layout.ts ← Updated with panel creation +│ └── app-context.ts ← (no changes needed) +├── config/ +│ └── panels.ts ← Added to ALL_PANELS +└── App.ts ← Integration point +``` + +## Dependencies + +- No new external dependencies required +- Uses existing WorldMonitor services: + - country-geometry (ISO codes, country lookups) + - i18n (translations) + - data-loader (real-time updates) + - auth-state (premium gating) +- DOM utilities: `h()`, `replaceChildren()`, `safeHtml()` +- Flag emojis via `toFlagEmoji()` utility + +## Architecture Notes + +### Separation of Concerns +1. **CountrySelector**: Pure UI for selection +2. **CountryProfileView**: Pure UI for display +3. **CountryProfileManager**: State and lifecycle management +4. **CountryProfilePanel**: Integration with dashboard +5. **App.ts**: Wiring and public API + +### Memory Management +- Event listeners properly cleaned up +- AbortController patterns for fetch cancellation +- Module destruction in reverse order (lifecycle) +- Window event listeners removed on destroy + +### Accessibility +- ARIA labels on inputs +- Keyboard navigation (Escape) +- Semantic HTML (buttons, lists) +- Color-coded but not color-only (icons + text) + +## Next Steps for Developers + +1. **Enable Country Selection UI**: Add a button to the main menu or header to trigger `app.openCountrySelector()` +2. **Implement Map Integration**: Wire up map click handler to country codes +3. **Add to Search**: Integrate with SearchModal for country lookup +4. **Backend Endpoints**: Create `/api/country/{code}/*` endpoints for aggregated data +5. **Real-time Subscriptions**: Wire WebSocket updates for selected country +6. **Mobile Optimization**: Further refinement of mobile UI +7. **Analytics**: Track which countries users are viewing + +## Performance Considerations + +- CountrySelector renders full country list (195+) but uses efficient DOM manipulation +- CountryProfileView lazy-loads panels as needed +- Mini-panels update independently for responsive feedback +- Grid layout uses CSS Grid for optimal layout performance +- Styles injected once and cached in document head diff --git a/package-lock.json b/package-lock.json index 7ef0e10be1..4a91334c15 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6425,21 +6425,6 @@ "node": ">= 20.19.4" } }, - "node_modules/@react-native/dev-middleware/node_modules/utf-8-validate": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", - "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=6.14.2" - } - }, "node_modules/@react-native/dev-middleware/node_modules/ws": { "version": "7.5.10", "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", @@ -14799,9 +14784,9 @@ } }, "node_modules/hls.js": { - "version": "1.6.15", - "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.15.tgz", - "integrity": "sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA==", + "version": "1.6.16", + "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.16.tgz", + "integrity": "sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==", "license": "Apache-2.0" }, "node_modules/hoist-non-react-statics": { @@ -15860,21 +15845,6 @@ "license": "MIT", "peer": true }, - "node_modules/jayson/node_modules/utf-8-validate": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", - "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=6.14.2" - } - }, "node_modules/jayson/node_modules/ws": { "version": "7.5.10", "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", @@ -17542,21 +17512,6 @@ "node": ">=8" } }, - "node_modules/metro/node_modules/utf-8-validate": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", - "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=6.14.2" - } - }, "node_modules/metro/node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -19530,21 +19485,6 @@ "ws": "^7" } }, - "node_modules/react-devtools-core/node_modules/utf-8-validate": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", - "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=6.14.2" - } - }, "node_modules/react-devtools-core/node_modules/ws": { "version": "7.5.10", "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", @@ -19741,21 +19681,6 @@ "node": ">=8" } }, - "node_modules/react-native/node_modules/utf-8-validate": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", - "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=6.14.2" - } - }, "node_modules/react-native/node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -22320,21 +22245,6 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/utf-8-validate": { - "version": "6.0.6", - "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-6.0.6.tgz", - "integrity": "sha512-q3l3P9UtEEiAHcsgsqTgf9PPjctrDWoIXW3NpOHFdRDbLvu4DLIcxHangJ4RLrWkBcKjmcs/6NkerI8T/rE4LA==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=6.14.2" - } - }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/package.json b/package.json index 94408c3225..7e3a69eb4b 100644 --- a/package.json +++ b/package.json @@ -130,7 +130,7 @@ "dompurify": "^3.1.7", "fast-xml-parser": "^5.3.7", "globe.gl": "^2.45.0", - "hls.js": "^1.6.15", + "hls.js": "^1.8.0", "i18next": "^25.8.10", "i18next-browser-languagedetector": "^8.2.1", "jmespath": "^0.16.0", diff --git a/pro-test/package.json b/pro-test/package.json index d7e4875944..a5bc390330 100644 --- a/pro-test/package.json +++ b/pro-test/package.json @@ -15,7 +15,7 @@ "@tailwindcss/vite": "^4.1.14", "@vitejs/plugin-react": "^5.0.4", "dodopayments-checkout": "^1.8.0", - "hls.js": "^1.6.15", + "hls.js": "^1.8.0", "i18next": "^25.8.14", "i18next-browser-languagedetector": "^8.2.1", "lucide-react": "^0.546.0", diff --git a/src/App.ts b/src/App.ts index f3e282f10c..1e54f9bfa0 100644 --- a/src/App.ts +++ b/src/App.ts @@ -72,6 +72,7 @@ import { ensureWmSession, installWmSessionFetchInterceptor } from '@/services/wm import { describeFreshness } from '@/services/persistent-cache'; import { DesktopUpdater } from '@/app/desktop-updater'; import { CountryIntelManager } from '@/app/country-intel'; +import { CountryProfileManager } from '@/app/country-profile-manager'; import { registerWebMcpTools } from '@/services/webmcp'; import { SearchManager } from '@/app/search-manager'; import { RefreshScheduler } from '@/app/refresh-scheduler'; @@ -115,6 +116,7 @@ export class App { private eventHandlers: EventHandlerManager; private searchManager: SearchManager; private countryIntel: CountryIntelManager; + private countryProfileManager: CountryProfileManager | null = null; private refreshScheduler: RefreshScheduler; private desktopUpdater: DesktopUpdater; @@ -821,6 +823,7 @@ export class App { // Instantiate modules (callbacks wired after all modules exist) this.refreshScheduler = new RefreshScheduler(this.state); this.countryIntel = new CountryIntelManager(this.state); + this.countryProfileManager = new CountryProfileManager(this.state); this.desktopUpdater = new DesktopUpdater(this.state); this.dataLoader = new DataLoaderManager(this.state, { @@ -866,6 +869,7 @@ export class App { this.desktopUpdater, this.panelLayout, this.countryIntel, + this.countryProfileManager, this.searchManager, this.dataLoader, this.refreshScheduler, @@ -1444,6 +1448,22 @@ export class App { } } + public openCountrySelector(): void { + if (this.countryProfileManager) { + this.countryProfileManager.openCountrySelector(); + } + } + + public closeCountryProfile(): void { + if (this.countryProfileManager) { + this.countryProfileManager.closeCountryProfile(); + } + } + + public getSelectedCountryCode(): string | null { + return this.countryProfileManager?.getSelectedCountryCode() ?? null; + } + private handleDeepLinks(): void { const url = new URL(window.location.href); const DEEP_LINK_INITIAL_DELAY_MS = 1500; diff --git a/src/app/country-profile-manager.ts b/src/app/country-profile-manager.ts new file mode 100644 index 0000000000..2d95f7e413 --- /dev/null +++ b/src/app/country-profile-manager.ts @@ -0,0 +1,303 @@ +import type { AppContext } from '@/app/app-context'; +import { CountrySelector } from '@/components/CountrySelector'; +import { CountryProfileView } from '@/components/CountryProfileView'; + +/** + * CountryProfileManager handles the country selection UI and profile view orchestration. + * Manages: + * - Country selector modal (search, map, dropdown) + * - Country profile overlay with focused panels + * - Integration with AppContext for data filtering + */ +export class CountryProfileManager { + private appContext: AppContext; + private countrySelector: CountrySelector | null = null; + private countryProfileView: CountryProfileView | null = null; + private selectedCountryCode: string | null = null; + private selectedCountryName: string | null = null; + private selectorContainer: HTMLElement; + + constructor(appContext: AppContext) { + this.appContext = appContext; + this.selectorContainer = document.createElement('div'); + this.selectorContainer.id = 'country-selector-container'; + } + + /** + * Opens the country selector modal for the user to choose a country + */ + public openCountrySelector(): void { + if (this.countrySelector) { + return; // Already open + } + + this.countrySelector = new CountrySelector({ + container: this.selectorContainer, + onCountrySelected: (code, name) => this.selectCountry(code, name), + onClose: () => this.closeCountrySelector(), + }); + + this.countrySelector.updateStyles(); + } + + /** + * Closes the country selector modal + */ + public closeCountrySelector(): void { + if (this.countrySelector) { + this.countrySelector.destroy(); + this.countrySelector = null; + } + } + + /** + * Handles country selection - opens the country profile view + */ + public selectCountry(countryCode: string, countryName: string): void { + this.selectedCountryCode = countryCode; + this.selectedCountryName = countryName; + + // Close selector if open + this.closeCountrySelector(); + + // Close existing profile if any + if (this.countryProfileView) { + this.countryProfileView.destroy(); + } + + // Create and show new profile + this.countryProfileView = new CountryProfileView({ + appContext: this.appContext, + countryCode: countryCode, + countryName: countryName, + onClose: () => this.closeCountryProfile(), + }); + + // Load country-specific data + this.loadCountryData(countryCode, countryName); + } + + /** + * Loads and displays country-specific data in the profile view + */ + private loadCountryData(countryCode: string, countryName: string): void { + if (!this.countryProfileView) return; + + // Update news filtering for this country + this.filterNewsByCountry(countryCode); + + // Load country-specific panels data + this.loadCountryPanelData(countryCode, countryName); + + // Load country-specific military intelligence + this.loadCountryMilitaryData(countryCode); + + // Load country-specific economic data + this.loadCountryEconomicData(countryCode); + + // Load country-specific energy profile + this.loadCountryEnergyData(countryCode); + + // Subscribe to real-time updates for this country + this.subscribeToCountryUpdates(countryCode); + } + + /** + * Filters news items by country + */ + private filterNewsByCountry(countryCode: string): void { + // Create a filtered view of news relevant to this country + const countryRelevantNews = this.appContext.allNews.filter(news => { + // Check if news mentions the country in title or countries field + const title = (news.title || '').toLowerCase(); + const countries = (news.countries || []).map(c => c.toLowerCase()); + + return title.includes(countryCode.toLowerCase()) || + title.includes(countryCode.toUpperCase()) || + countries.includes(countryCode.toLowerCase()); + }); + + // Store filtered news for use in panels + (window as any).__COUNTRY_PROFILE_NEWS = countryRelevantNews; + } + + /** + * Loads country-specific panel data (uses CountryDeepDivePanel) + */ + private loadCountryPanelData(countryCode: string, countryName: string): void { + if (!this.countryProfileView) return; + + const container = this.countryProfileView.getPanelContainer('CountryDeepDive'); + if (!container) return; + + // Trigger the CountryDeepDivePanel to load this country's data + const countryDeepDivePanel = this.appContext.panels['CountryDeepDive']; + if (countryDeepDivePanel && typeof (countryDeepDivePanel as any).loadCountry === 'function') { + (countryDeepDivePanel as any).loadCountry(countryCode, countryName); + } + } + + /** + * Loads military presence and activity for the country + */ + private loadCountryMilitaryData(countryCode: string): void { + if (!this.countryProfileView) return; + + const container = this.countryProfileView.getPanelContainer('CountryMilitaryProfile'); + if (!container) return; + + // Filter military flights and vessels by country proximity/basing + const militaryFlights = (this.appContext.intelligenceCache.military?.flights || []).filter( + flight => flight.countryCode === countryCode || flight.originCountry === countryCode + ); + + const militaryVessels = (this.appContext.intelligenceCache.military?.vessels || []).filter( + vessel => vessel.countryCode === countryCode || vessel.homePort?.includes(countryCode) + ); + + const html = ` +
+
+ Military Flights: ${militaryFlights.length} +
+
+ Naval Vessels: ${militaryVessels.length} +
+

+ ${militaryFlights.length + militaryVessels.length > 0 + ? 'Active military operations detected.' + : 'No significant military activity detected.'} +

+
+ `; + + container.innerHTML = html; + } + + /** + * Loads economic indicators for the country + */ + private loadCountryEconomicData(countryCode: string): void { + if (!this.countryProfileView) return; + + const container = this.countryProfileView.getPanelContainer('CountryEconomyProfile'); + if (!container) return; + + // Fetch economic data from markets and other sources + const countryMarkets = this.appContext.latestMarkets.filter(market => + market.countryCode === countryCode + ); + + const html = ` +
+
+ Market Indices: ${countryMarkets.length} +
+
+ Data Sources: IMF, World Bank, Central Banks +
+

+ Economic indicators loading... +

+
+ `; + + container.innerHTML = html; + } + + /** + * Loads energy profile and disruption risks for the country + */ + private loadCountryEnergyData(countryCode: string): void { + if (!this.countryProfileView) return; + + const container = this.countryProfileView.getPanelContainer('CountryEnergyProfile'); + if (!container) return; + + const html = ` +
+
+ Energy Status: Loading... +
+
+ Production: Analyzing... +
+

+ Pipeline and supply status loading... +

+
+ `; + + container.innerHTML = html; + } + + /** + * Subscribes to real-time updates for the selected country + */ + private subscribeToCountryUpdates(countryCode: string): void { + // This would connect to WebSocket or Server-Sent Events for real-time updates + // For now, we'll set up polling at the data-loader level to refresh country-specific data + + // Store the selected country in window for data-loader to use + (window as any).__SELECTED_COUNTRY_CODE = countryCode; + + // Trigger a data refresh for country-specific services + // This could integrate with the existing RefreshScheduler + } + + /** + * Closes the country profile view + */ + public closeCountryProfile(): void { + if (this.countryProfileView) { + this.countryProfileView.destroy(); + this.countryProfileView = null; + } + + this.selectedCountryCode = null; + this.selectedCountryName = null; + + // Clear country-specific state + delete (window as any).__SELECTED_COUNTRY_CODE; + delete (window as any).__COUNTRY_PROFILE_NEWS; + } + + /** + * Gets the currently selected country code + */ + public getSelectedCountryCode(): string | null { + return this.selectedCountryCode; + } + + /** + * Gets the currently selected country name + */ + public getSelectedCountryName(): string | null { + return this.selectedCountryName; + } + + /** + * Checks if a country profile is currently open + */ + public isCountryProfileOpen(): boolean { + return this.countryProfileView !== null; + } + + /** + * Updates the country profile view (e.g., after data refresh) + */ + public updateCountryProfile(): void { + if (this.countryProfileView && this.selectedCountryCode) { + this.loadCountryData(this.selectedCountryCode, this.selectedCountryName!); + } + } + + /** + * Cleans up resources + */ + public destroy(): void { + this.closeCountrySelector(); + this.closeCountryProfile(); + this.selectorContainer.remove(); + } +} diff --git a/src/app/panel-layout.ts b/src/app/panel-layout.ts index b80d04b98a..247fd0928d 100644 --- a/src/app/panel-layout.ts +++ b/src/app/panel-layout.ts @@ -32,6 +32,7 @@ import { LiveWebcamsPanel, PinnedWebcamsPanel, CIIPanel, + CountryProfilePanel, CascadePanel, StrategicRiskPanel, StrategicPosturePanel, @@ -1095,6 +1096,12 @@ export class PanelLayoutManager implements AppModule { this.ctx.panels['cii'] = ciiPanel; } + if (this.shouldCreatePanel('country-profile')) { + const countryProfilePanel = new CountryProfilePanel(); + countryProfilePanel.setAppContext(this.ctx); + this.ctx.panels['country-profile'] = countryProfilePanel; + } + this.createPanel('cascade', () => new CascadePanel()); this.createPanel('satellite-fires', () => new SatelliteFiresPanel()); diff --git a/src/app/search-manager.ts b/src/app/search-manager.ts index 563735780a..cae0099607 100644 --- a/src/app/search-manager.ts +++ b/src/app/search-manager.ts @@ -13,6 +13,7 @@ import { LAYER_PRESETS, LAYER_KEY_MAP } from '@/config/commands'; import { calculateCII, TIER1_COUNTRIES } from '@/services/country-instability'; import { CURATED_COUNTRIES } from '@/config/countries'; import { getCountryBbox } from '@/services/country-geometry'; +import { getCitySearchItems } from '@/services/city-geometry'; import { INTEL_HOTSPOTS, CONFLICT_ZONES, MILITARY_BASES, UNDERSEA_CABLES, NUCLEAR_FACILITIES } from '@/config/geo'; import { PIPELINES } from '@/config/pipelines'; import { AI_DATA_CENTERS } from '@/config/ai-datacenters'; @@ -208,6 +209,7 @@ export class SearchManager implements AppModule { } this.ctx.searchModal.registerSource('country', this.buildCountrySearchItems()); + this.ctx.searchModal.registerSource('city', getCitySearchItems()); this.ctx.searchModal.setActivePanels( Object.entries(this.ctx.panelSettings).filter(([, v]) => v.enabled).map(([k]) => k) @@ -453,6 +455,12 @@ export class SearchManager implements AppModule { this.callbacks.openCountryBriefByCode(code, name); break; } + case 'city': { + const city = result.data as { city: string; lat: number; lng: number; country: string }; + this.ctx.map?.setView('global'); + setTimeout(() => { this.ctx.map?.setCenter(city.lat, city.lng, 5); }, 300); + break; + } case 'flight': { const { lat, lon, layer } = result.data as { kind: string; lat: number; lon: number; layer: keyof MapLayers }; this.ctx.map?.enableLayer(layer); @@ -703,6 +711,8 @@ export class SearchManager implements AppModule { data: m, }))); } + + this.ctx.searchModal.registerSource('city', getCitySearchItems()); } private buildCountrySearchItems(): { id: string; title: string; subtitle: string; data: { code: string; name: string } }[] { diff --git a/src/components/CountryProfilePanel.ts b/src/components/CountryProfilePanel.ts new file mode 100644 index 0000000000..7c04fa4819 --- /dev/null +++ b/src/components/CountryProfilePanel.ts @@ -0,0 +1,408 @@ +import { Panel } from './Panel'; +import { t } from '@/services/i18n'; +import { escapeHtml } from '@/utils/sanitize'; +import { toFlagEmoji } from '@/utils/country-flag'; +import { getCountryNameByCode } from '@/services/country-geometry'; +import type { AppContext } from '@/app/app-context'; +import { safeHtml } from '@/utils/dom-utils'; + +export interface CountryProfileData { + countryCode: string; + countryName: string; + riskScore?: number; + recentEvents?: string[]; + militaryActivity?: number; + economicIndicators?: Record; + energyStatus?: string; + cyberThreats?: number; + humanitarianSituation?: string; +} + +/** + * CountryProfilePanel displays aggregated country intelligence across all domains + * Sources: Conflict data, Economic indicators, Military activity, Energy, Cyber threats, Health + */ +export class CountryProfilePanel extends Panel { + private appContext: AppContext | null = null; + private currentCountryCode: string | null = null; + private countryData: CountryProfileData | null = null; + + constructor() { + super({ + id: 'country-profile', + title: t('panels.countryProfile'), + infoTooltip: t('panels.countryProfile_tooltip'), + }); + } + + public setAppContext(appContext: AppContext): void { + this.appContext = appContext; + } + + /** + * Load and display data for a specific country + */ + public async loadCountryData(countryCode: string): Promise { + this.currentCountryCode = countryCode; + const countryName = getCountryNameByCode(countryCode) || countryCode; + + try { + this.showLoading(); + + // Build aggregated country profile + this.countryData = await this.aggregateCountryData(countryCode, countryName); + + if (!this.element?.isConnected) return; + this.renderCountryProfile(this.countryData); + } catch (err) { + if (this.isAbortError(err)) return; + if (!this.element?.isConnected) return; + this.showError( + t('common.failedLoadingData'), + () => void this.loadCountryData(countryCode) + ); + } + } + + private async aggregateCountryData( + countryCode: string, + countryName: string + ): Promise { + const data: CountryProfileData = { + countryCode, + countryName, + }; + + if (!this.appContext) { + return data; + } + + // Aggregate military activity + const militaryFlights = (this.appContext.intelligenceCache.military?.flights || []).filter( + f => f.countryCode === countryCode + ).length; + + const militaryVessels = (this.appContext.intelligenceCache.military?.vessels || []).filter( + v => v.countryCode === countryCode + ).length; + + data.militaryActivity = militaryFlights + militaryVessels; + + // Aggregate cyber threats + const cyberThreats = (this.appContext.cyberThreatsCache || []).filter( + ct => ct.countryCode === countryCode + ).length; + + data.cyberThreats = cyberThreats; + + // Get risk score from country instability service if available + // (This would be populated by the data-loader) + const riskData = (window as any).__COUNTRY_RISK_SCORES?.[countryCode]; + if (riskData) { + data.riskScore = riskData.score; + } + + // Aggregate news events by country + const countryNews = (window as any).__COUNTRY_PROFILE_NEWS || []; + if (countryNews.length > 0) { + data.recentEvents = countryNews + .slice(0, 5) + .map((n: any) => n.title || n.description || ''); + } + + return data; + } + + private renderCountryProfile(data: CountryProfileData): void { + const flag = toFlagEmoji(data.countryCode); + const riskClass = data.riskScore + ? data.riskScore > 0.7 + ? 'cp-risk-critical' + : data.riskScore > 0.4 + ? 'cp-risk-high' + : 'cp-risk-medium' + : 'cp-risk-low'; + + const riskLabel = data.riskScore + ? `${(data.riskScore * 100).toFixed(0)}%` + : 'N/A'; + + let html = ` +
+
+ ${flag} +
+

${escapeHtml(data.countryName)}

+ ${data.countryCode} +
+
+ ${t('panels.riskScore')}: + ${riskLabel} +
+
+ +
+
+
🛡️
+
+
${t('panels.militaryActivity')}
+
${data.militaryActivity || 0}
+
+ ${data.militaryActivity + ? t('panels.militaryActivityDetected') + : t('panels.noMilitaryActivity')} +
+
+
+ +
+
🔒
+
+
${t('panels.cyberThreats')}
+
${data.cyberThreats || 0}
+
+ ${data.cyberThreats + ? t('panels.cyberThreatsDetected') + : t('panels.noCyberThreats')} +
+
+
+ +
+
📡
+
+
${t('panels.recentEvents')}
+
${(data.recentEvents || []).length}
+
+ ${(data.recentEvents || []).length > 0 + ? t('panels.eventsDetected') + : t('panels.noRecentEvents')} +
+
+
+
+ `; + + if ((data.recentEvents || []).length > 0) { + html += ` +
+

${t('panels.recentHeadlines')}

+
    + ${data.recentEvents + ?.slice(0, 5) + .map( + event => `
  • ${escapeHtml(event.substring(0, 100))}
  • ` + ) + .join('')} +
+
+ `; + } + + html += ` + +
+ `; + + this.setContent(html); + + // Inject CSS if not already present + this.injectStyles(); + } + + private injectStyles(): void { + if (document.head.querySelector('style[data-cp-panel]')) { + return; + } + + const style = document.createElement('style'); + style.setAttribute('data-cp-panel', 'true'); + style.textContent = ` + .country-profile-container { + padding: 16px; + color: var(--text-primary, #fff); + } + + .cp-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 20px; + padding-bottom: 16px; + border-bottom: 1px solid var(--border-color, #333); + } + + .cp-flag { + font-size: 40px; + line-height: 1; + } + + .cp-title { + flex: 1; + } + + .cp-title h2 { + margin: 0; + font-size: 20px; + font-weight: 600; + } + + .cp-code { + font-size: 12px; + color: var(--text-secondary, #999); + font-family: monospace; + text-transform: uppercase; + } + + .cp-risk-badge { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: 8px 12px; + border-radius: 6px; + font-size: 12px; + font-weight: 600; + } + + .cp-risk-critical { + background: rgba(255, 0, 0, 0.15); + color: #ff4444; + border: 1px solid #ff4444; + } + + .cp-risk-high { + background: rgba(255, 165, 0, 0.15); + color: #ffa500; + border: 1px solid #ffa500; + } + + .cp-risk-medium { + background: rgba(255, 255, 0, 0.15); + color: #ffff00; + border: 1px solid #ffff00; + } + + .cp-risk-low { + background: rgba(0, 255, 0, 0.15); + color: #00ff00; + border: 1px solid #00ff00; + } + + .cp-risk-label { + opacity: 0.8; + } + + .cp-risk-value { + font-size: 16px; + } + + .cp-indicators { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 12px; + margin-bottom: 20px; + } + + .cp-indicator-card { + display: flex; + gap: 12px; + padding: 12px; + background: var(--bg-secondary, #2a2a2a); + border: 1px solid var(--border-color, #333); + border-radius: 6px; + } + + .cp-indicator-icon { + font-size: 24px; + flex-shrink: 0; + } + + .cp-indicator-content { + flex: 1; + } + + .cp-indicator-label { + font-size: 11px; + font-weight: 600; + color: var(--text-secondary, #999); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 4px; + } + + .cp-indicator-value { + font-size: 20px; + font-weight: 700; + color: var(--accent-color, #00aaff); + margin-bottom: 4px; + } + + .cp-indicator-detail { + font-size: 11px; + color: var(--text-secondary, #999); + line-height: 1.3; + } + + .cp-recent-events { + margin-bottom: 16px; + } + + .cp-recent-events h3 { + margin: 0 0 12px 0; + font-size: 13px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-secondary, #999); + } + + .cp-event-list { + list-style: none; + margin: 0; + padding: 0; + } + + .cp-event-item { + padding: 8px; + margin-bottom: 6px; + background: var(--bg-secondary, #2a2a2a); + border-left: 3px solid var(--accent-color, #00aaff); + border-radius: 3px; + font-size: 12px; + line-height: 1.4; + } + + .cp-footer { + padding-top: 12px; + border-top: 1px solid var(--border-color, #333); + } + + .cp-footer-note { + margin: 0; + font-size: 11px; + color: var(--text-secondary, #999); + font-style: italic; + } + `; + + document.head.appendChild(style); + } + + public async fetchData(): Promise { + // This panel is driven by external country selection + // Default behavior: show a prompt to select a country + if (!this.currentCountryCode) { + const html = ` +
+

${t('panels.countryProfilePlaceholder', 'Select a country to view its profile')}

+
+ `; + this.setContent(html); + } + } +} diff --git a/src/components/CountryProfileView.ts b/src/components/CountryProfileView.ts new file mode 100644 index 0000000000..c214b81bcf --- /dev/null +++ b/src/components/CountryProfileView.ts @@ -0,0 +1,481 @@ +import { h, replaceChildren, safeHtml } from '@/utils/dom-utils'; +import { toFlagEmoji } from '@/utils/country-flag'; +import { t } from '@/services/i18n'; +import type { AppContext } from '@/app/app-context'; + +interface CountryProfileViewOptions { + appContext: AppContext; + countryCode: string; + countryName: string; + onClose?: () => void; +} + +/** + * CountryProfileView renders a modal overlay showing country-focused intelligence. + * This is the main wrapper that presents the country profile with a shrunken main menu. + */ +export class CountryProfileView { + private appContext: AppContext; + private countryCode: string; + private countryName: string; + private container: HTMLElement; + private overlay: HTMLElement; + private modal: HTMLElement; + private header: HTMLElement; + private body: HTMLElement; + private closeButton: HTMLButtonElement; + private openButton: HTMLElement | null = null; + private onClose?: () => void; + private countryPanels: Map = new Map(); + + constructor(options: CountryProfileViewOptions) { + this.appContext = options.appContext; + this.countryCode = options.countryCode; + this.countryName = options.countryName; + this.onClose = options.onClose; + + this.container = document.createElement('div'); + this.overlay = h('div', { className: 'country-profile-overlay' }); + this.modal = h('div', { className: 'country-profile-modal' }); + this.header = h('div', { className: 'country-profile-header' }); + this.body = h('div', { className: 'country-profile-body' }); + this.closeButton = h('button', { className: 'country-profile-close' }) as HTMLButtonElement; + + this.setupEventListeners(); + this.render(); + this.injectStyles(); + } + + private setupEventListeners(): void { + // Close button + this.closeButton.addEventListener('click', () => this.close()); + + // Escape key + const keyHandler = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + this.close(); + } + }; + document.addEventListener('keydown', keyHandler); + + // Click outside modal + this.overlay.addEventListener('click', (e) => { + if (e.target === this.overlay) { + this.close(); + } + }); + } + + private renderHeader(): void { + replaceChildren(this.header); + + const titleSection = h('div', { className: 'country-profile-title-section' }); + const flagEl = h('span', { className: 'country-profile-flag' }); + flagEl.textContent = toFlagEmoji(this.countryCode); + + const titleText = h('div', { className: 'country-profile-title-text' }); + const countryTitle = h('h1', { className: 'country-profile-country-name' }); + countryTitle.textContent = this.countryName; + + const countryCode = h('span', { className: 'country-profile-country-code' }); + countryCode.textContent = this.countryCode; + + titleText.appendChild(countryTitle); + titleText.appendChild(countryCode); + titleSection.appendChild(flagEl); + titleSection.appendChild(titleText); + + const rightSection = h('div', { className: 'country-profile-header-right' }); + + // Real-time status badge + const statusBadge = h('div', { className: 'country-profile-status-badge' }); + statusBadge.innerHTML = safeHtml( + '' + + `${t('country_profile.realtime', 'Real-time Updates')}` + ); + rightSection.appendChild(statusBadge); + + this.closeButton.innerHTML = '✕'; + this.closeButton.title = t('common.close', 'Close'); + rightSection.appendChild(this.closeButton); + + this.header.appendChild(titleSection); + this.header.appendChild(rightSection); + } + + private renderBody(): void { + replaceChildren(this.body); + + // Create a grid container for country-focused panels + const panelGrid = h('div', { className: 'country-profile-panel-grid' }); + + // Add the CountryDeepDivePanel if available + const countryDeepDivePanel = this.appContext.panels['CountryDeepDive']; + if (countryDeepDivePanel) { + const panelWrapper = h('div', { className: 'country-profile-panel-wrapper' }); + // The panel will be managed separately, we just create a container for it + panelWrapper.id = 'country-deep-dive-container'; + panelGrid.appendChild(panelWrapper); + this.countryPanels.set('CountryDeepDive', panelWrapper); + } + + // Add mini versions of critical panels filtered by country + const miniPanels = [ + { id: 'CountryMilitaryProfile', title: 'Military Presence' }, + { id: 'CountryEconomyProfile', title: 'Economic Indicators' }, + { id: 'CountryEnergyProfile', title: 'Energy Profile' }, + { id: 'CountryCyberProfile', title: 'Cyber Threats' }, + { id: 'CountryHealthProfile', title: 'Health & Humanitarian' }, + ]; + + for (const panelDef of miniPanels) { + const miniPanel = h('div', { className: 'country-profile-mini-panel' }); + miniPanel.id = `country-profile-${panelDef.id}`; + + const title = h('h3', { className: 'country-profile-mini-panel-title' }); + title.textContent = panelDef.title; + + const content = h('div', { className: 'country-profile-mini-panel-content' }); + content.innerHTML = `

${t('common.loading', 'Loading...')}

`; + + miniPanel.appendChild(title); + miniPanel.appendChild(content); + panelGrid.appendChild(miniPanel); + this.countryPanels.set(panelDef.id, content); + } + + this.body.appendChild(panelGrid); + } + + private render(): void { + this.renderHeader(); + this.renderBody(); + + this.modal.appendChild(this.header); + this.modal.appendChild(this.body); + this.overlay.appendChild(this.modal); + + replaceChildren(this.container); + this.container.appendChild(this.overlay); + + if (this.appContext.container) { + this.appContext.container.appendChild(this.container); + } else { + document.body.appendChild(this.container); + } + + // Add a button to the main menu to open this profile + this.addMainMenuButton(); + } + + private addMainMenuButton(): void { + // This creates a visual indicator in the main menu that a country is selected + const mainMenu = document.querySelector('.sidebar, .main-menu, [role="navigation"]'); + if (mainMenu) { + let countryIndicator = document.querySelector('.country-profile-indicator'); + if (!countryIndicator) { + countryIndicator = h('div', { className: 'country-profile-indicator' }); + const header = mainMenu.querySelector('[role="banner"], .menu-header'); + if (header) { + header.parentElement?.insertBefore(countryIndicator, header.nextSibling); + } else { + mainMenu.insertBefore(countryIndicator, mainMenu.firstChild); + } + } + + countryIndicator.innerHTML = safeHtml( + `
` + + `${toFlagEmoji(this.countryCode)}` + + `${this.countryName}` + + `${t('country_profile.viewing', 'Viewing Country Profile')}` + + `
` + ); + } + } + + private injectStyles(): void { + if (document.head.querySelector('style[data-country-profile]')) { + return; + } + + const style = document.createElement('style'); + style.setAttribute('data-country-profile', 'true'); + style.textContent = ` + .country-profile-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.6); + z-index: 10000; + display: flex; + align-items: center; + justify-content: center; + animation: fadeIn 0.2s ease-out; + backdrop-filter: blur(4px); + } + + @keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } + } + + .country-profile-modal { + background: var(--bg-primary, #1a1a1a); + border: 1px solid var(--border-color, #333); + border-radius: 12px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4); + width: 95%; + max-width: 1400px; + height: 95vh; + max-height: 95vh; + display: flex; + flex-direction: column; + animation: slideUp 0.3s ease-out; + overflow: hidden; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + } + + @keyframes slideUp { + from { + opacity: 0; + transform: translateY(30px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + + .country-profile-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 24px; + border-bottom: 1px solid var(--border-color, #333); + background: var(--bg-secondary, #242424); + flex-shrink: 0; + } + + .country-profile-title-section { + display: flex; + align-items: center; + gap: 16px; + } + + .country-profile-flag { + font-size: 48px; + line-height: 1; + } + + .country-profile-title-text { + display: flex; + flex-direction: column; + gap: 4px; + } + + .country-profile-country-name { + margin: 0; + font-size: 28px; + font-weight: 600; + color: var(--text-primary, #fff); + } + + .country-profile-country-code { + font-size: 12px; + font-weight: 600; + color: var(--text-secondary, #999); + text-transform: uppercase; + letter-spacing: 1px; + font-family: monospace; + } + + .country-profile-header-right { + display: flex; + align-items: center; + gap: 16px; + } + + .country-profile-status-badge { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background: var(--bg-primary, #1a1a1a); + border: 1px solid var(--border-color, #333); + border-radius: 6px; + font-size: 12px; + font-weight: 600; + color: var(--text-secondary, #999); + } + + .status-dot { + display: inline-block; + width: 6px; + height: 6px; + border-radius: 50%; + background: #00ff00; + animation: pulse 2s infinite; + } + + @keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } + } + + .country-profile-close { + background: transparent; + border: none; + font-size: 24px; + color: var(--text-primary, #fff); + cursor: pointer; + padding: 4px 8px; + transition: color 0.15s; + } + + .country-profile-close:hover { + color: var(--accent-color, #00aaff); + } + + .country-profile-body { + flex: 1; + overflow-y: auto; + padding: 16px; + } + + .country-profile-panel-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(350px, 1fr)); + gap: 16px; + } + + .country-profile-panel-wrapper { + grid-column: 1 / -1; + min-height: 400px; + border: 1px solid var(--border-color, #333); + border-radius: 8px; + background: var(--bg-secondary, #242424); + padding: 16px; + } + + .country-profile-mini-panel { + border: 1px solid var(--border-color, #333); + border-radius: 8px; + background: var(--bg-secondary, #242424); + overflow: hidden; + } + + .country-profile-mini-panel-title { + margin: 0; + padding: 12px 16px; + background: var(--bg-primary, #1a1a1a); + border-bottom: 1px solid var(--border-color, #333); + font-size: 13px; + font-weight: 600; + color: var(--text-primary, #fff); + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .country-profile-mini-panel-content { + padding: 16px; + color: var(--text-primary, #fff); + font-size: 13px; + line-height: 1.6; + } + + .country-profile-indicator { + padding: 12px 16px; + margin: 12px; + background: var(--accent-color, #00aaff); + color: #000; + border-radius: 6px; + font-weight: 600; + font-size: 12px; + } + + .country-profile-indicator-content { + display: flex; + align-items: center; + gap: 8px; + } + + .country-profile-indicator .flag { + font-size: 16px; + } + + .country-profile-indicator .name { + flex: 1; + } + + .country-profile-indicator .action { + opacity: 0.8; + font-size: 11px; + } + + @media (max-width: 768px) { + .country-profile-modal { + max-width: 100%; + height: 100%; + max-height: 100vh; + border-radius: 0; + } + + .country-profile-country-name { + font-size: 20px; + } + + .country-profile-flag { + font-size: 32px; + } + + .country-profile-header-right { + gap: 8px; + } + + .country-profile-status-badge { + display: none; + } + + .country-profile-panel-grid { + grid-template-columns: 1fr; + } + } + `; + document.head.appendChild(style); + } + + public updateCountry(countryCode: string, countryName: string): void { + this.countryCode = countryCode; + this.countryName = countryName; + this.renderHeader(); + } + + public getCountryCode(): string { + return this.countryCode; + } + + public getCountryName(): string { + return this.countryName; + } + + public getPanelContainer(panelId: string): HTMLElement | undefined { + return this.countryPanels.get(panelId); + } + + public close(): void { + this.container.remove(); + const indicator = document.querySelector('.country-profile-indicator'); + if (indicator) indicator.remove(); + this.onClose?.(); + } + + public destroy(): void { + this.close(); + } +} diff --git a/src/components/CountrySelector.ts b/src/components/CountrySelector.ts new file mode 100644 index 0000000000..8cd3001d69 --- /dev/null +++ b/src/components/CountrySelector.ts @@ -0,0 +1,336 @@ +import { h, replaceChildren, safeHtml } from '@/utils/dom-utils'; +import { getCountryNameByCode, isValidCountryCode } from '@/services/country-geometry'; +import { toFlagEmoji } from '@/utils/country-flag'; +import { t } from '@/services/i18n'; + +// Curated list of countries for quick access (alphabetically sorted) +const QUICK_SELECT_COUNTRIES = [ + 'US', 'CN', 'RU', 'UA', 'IL', 'IR', 'KR', 'JP', 'IN', 'BR', + 'GB', 'FR', 'DE', 'EU', 'MX', 'SA', 'AE', 'SY', 'PK', 'NG', +]; + +interface CountrySelectorOptions { + onCountrySelected?: (code: string, name: string) => void; + onClose?: () => void; + container?: HTMLElement; +} + +/** + * CountrySelector provides a unified interface for selecting countries: + * 1. Search box for finding countries by name + * 2. Quick-select buttons for top countries + * 3. Full country list dropdown + */ +export class CountrySelector { + private container: HTMLElement; + private wrapper: HTMLElement; + private searchInput: HTMLInputElement; + private quickSelectContainer: HTMLElement; + private countryListDropdown: HTMLElement; + private listItems: Map = new Map(); + private onCountrySelected?: (code: string, name: string) => void; + private onClose?: () => void; + private allCountryCodes: string[] = []; + private filteredCountryCodes: string[] = []; + private searchTimeout: ReturnType | null = null; + + constructor(options: CountrySelectorOptions) { + this.onCountrySelected = options.onCountrySelected; + this.onClose = options.onClose; + + if (options.container) { + this.container = options.container; + } else { + this.container = document.createElement('div'); + document.body.appendChild(this.container); + } + + this.wrapper = h('div', { className: 'country-selector' }); + this.searchInput = h('input', { className: 'country-selector-search' }) as HTMLInputElement; + this.quickSelectContainer = h('div', { className: 'country-selector-quick-select' }); + this.countryListDropdown = h('div', { className: 'country-selector-list' }); + + this.initializeCountryList(); + this.setupEventListeners(); + this.render(); + } + + private initializeCountryList(): void { + // Get all valid country codes - using ISO 3166-1 alpha-2 codes + const allCodes = [ + 'AF', 'AL', 'DZ', 'AS', 'AD', 'AO', 'AI', 'AQ', 'AG', 'AR', 'AM', 'AW', 'AU', 'AT', 'AZ', + 'BS', 'BH', 'BD', 'BB', 'BY', 'BE', 'BZ', 'BJ', 'BM', 'BT', 'BO', 'BA', 'BW', 'BV', 'BR', + 'BN', 'BG', 'BF', 'BI', 'KH', 'CM', 'CA', 'CV', 'KY', 'CF', 'TD', 'CL', 'CN', 'CX', 'CC', + 'CO', 'KM', 'CG', 'CD', 'CK', 'CR', 'HR', 'CU', 'CY', 'CZ', 'DK', 'DJ', 'DM', 'DO', 'EC', + 'EG', 'SV', 'GQ', 'ER', 'EE', 'ET', 'FK', 'FO', 'FJ', 'FI', 'FR', 'GF', 'PF', 'TF', 'GA', + 'GM', 'GE', 'DE', 'GH', 'GI', 'GR', 'GL', 'GD', 'GP', 'GU', 'GT', 'GG', 'GN', 'GW', 'GY', + 'HT', 'HM', 'VA', 'HN', 'HK', 'HU', 'IS', 'IN', 'ID', 'IR', 'IQ', 'IE', 'IM', 'IL', 'IT', + 'CI', 'JM', 'JP', 'JE', 'JO', 'KZ', 'KE', 'KI', 'KP', 'KR', 'KW', 'KG', 'LA', 'LV', 'LB', + 'LS', 'LR', 'LY', 'LI', 'LT', 'LU', 'MO', 'MK', 'MG', 'MW', 'MY', 'MV', 'ML', 'MT', 'MH', + 'MQ', 'MR', 'MU', 'YT', 'MX', 'FM', 'MD', 'MC', 'MN', 'ME', 'MA', 'MZ', 'MM', 'NA', 'NR', + 'NP', 'NL', 'AN', 'NC', 'NZ', 'NI', 'NE', 'NG', 'NU', 'NF', 'MP', 'NO', 'OM', 'PK', 'PW', + 'PS', 'PA', 'PG', 'PY', 'PE', 'PH', 'PN', 'PL', 'PT', 'PR', 'QA', 'RE', 'RO', 'RU', 'RW', + 'SH', 'KN', 'LC', 'PM', 'VC', 'WS', 'SM', 'ST', 'SA', 'SN', 'RS', 'SC', 'SL', 'SG', 'SK', + 'SI', 'SB', 'SO', 'ZA', 'SS', 'ES', 'LK', 'SD', 'SR', 'SJ', 'SZ', 'SE', 'CH', 'SY', 'TW', + 'TJ', 'TZ', 'TH', 'TL', 'TG', 'TK', 'TO', 'TT', 'TN', 'TR', 'TM', 'TC', 'TV', 'UG', 'UA', + 'AE', 'GB', 'US', 'UY', 'UZ', 'VU', 'VE', 'VN', 'VG', 'VI', 'WF', 'EH', 'YE', 'ZM', 'ZW', + 'EU', // European Union (for regional analysis) + ]; + + this.allCountryCodes = allCodes.filter(code => isValidCountryCode(code)); + this.filteredCountryCodes = [...this.allCountryCodes]; + } + + private setupEventListeners(): void { + // Search input + this.searchInput.addEventListener('input', (e) => { + const query = (e.target as HTMLInputElement).value.toLowerCase().trim(); + this.filterCountries(query); + }); + + this.searchInput.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + this.onClose?.(); + } + }); + + // Outside click to close + document.addEventListener('click', (e) => { + if (!this.wrapper.contains(e.target as Node) && e.target !== this.searchInput) { + this.onClose?.(); + } + }, true); + } + + private filterCountries(query: string): void { + if (this.searchTimeout) clearTimeout(this.searchTimeout); + + this.searchTimeout = setTimeout(() => { + if (!query) { + this.filteredCountryCodes = [...this.allCountryCodes]; + } else { + this.filteredCountryCodes = this.allCountryCodes.filter(code => { + const name = getCountryNameByCode(code)?.toLowerCase() || code.toLowerCase(); + return name.includes(query) || code.toLowerCase().includes(query); + }); + } + + this.updateCountryList(); + }, 100); + } + + private updateCountryList(): void { + replaceChildren(this.countryListDropdown); + this.listItems.clear(); + + if (this.filteredCountryCodes.length === 0) { + const noResults = h('div', { className: 'country-selector-no-results' }); + noResults.textContent = t('country_selector.no_results', 'No countries found'); + this.countryListDropdown.appendChild(noResults); + return; + } + + const listUl = h('ul', { className: 'country-selector-list-ul' }); + + for (const code of this.filteredCountryCodes) { + const name = getCountryNameByCode(code) || code; + const li = h('li', { className: 'country-selector-list-item' }); + li.innerHTML = safeHtml(`${toFlagEmoji(code)}${name}${code}`); + li.addEventListener('click', () => { + this.selectCountry(code, name); + }); + listUl.appendChild(li); + this.listItems.set(code, li); + } + + this.countryListDropdown.appendChild(listUl); + } + + private selectCountry(code: string, name: string): void { + this.onCountrySelected?.(code, name); + this.onClose?.(); + } + + private renderQuickSelect(): void { + replaceChildren(this.quickSelectContainer); + + const label = h('div', { className: 'country-selector-quick-label' }); + label.textContent = t('country_selector.quick_select', 'Quick select:'); + this.quickSelectContainer.appendChild(label); + + const buttonContainer = h('div', { className: 'country-selector-quick-buttons' }); + + for (const code of QUICK_SELECT_COUNTRIES) { + if (!isValidCountryCode(code)) continue; + + const name = getCountryNameByCode(code) || code; + const btn = h('button', { className: 'country-selector-quick-btn' }); + btn.innerHTML = safeHtml(`${toFlagEmoji(code)}${code}`); + btn.title = name; + btn.addEventListener('click', () => this.selectCountry(code, name)); + buttonContainer.appendChild(btn); + } + + this.quickSelectContainer.appendChild(buttonContainer); + } + + private render(): void { + this.searchInput.type = 'text'; + this.searchInput.placeholder = t('country_selector.search_placeholder', 'Search countries...'); + this.searchInput.setAttribute('aria-label', 'Search countries'); + + this.wrapper.appendChild(this.searchInput); + this.renderQuickSelect(); + this.wrapper.appendChild(this.quickSelectContainer); + this.wrapper.appendChild(this.countryListDropdown); + + replaceChildren(this.container); + this.container.appendChild(this.wrapper); + + // Initialize list with all countries + this.updateCountryList(); + + // Focus search input + setTimeout(() => this.searchInput.focus(), 100); + } + + public updateStyles(): void { + const style = document.createElement('style'); + style.textContent = ` + .country-selector { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + z-index: 9999; + background: var(--bg-primary, #1a1a1a); + border: 1px solid var(--border-color, #333); + border-radius: 8px; + box-shadow: 0 10px 40px rgba(0,0,0,0.3); + width: 90%; + max-width: 500px; + max-height: 80vh; + display: flex; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + } + + .country-selector-search { + padding: 12px 16px; + border: none; + border-bottom: 1px solid var(--border-color, #333); + background: var(--bg-primary, #1a1a1a); + color: var(--text-primary, #fff); + font-size: 14px; + border-radius: 8px 8px 0 0; + } + + .country-selector-search:focus { + outline: none; + box-shadow: inset 0 0 0 2px var(--accent-color, #00aaff); + } + + .country-selector-quick-label { + padding: 12px 16px 4px 16px; + font-size: 12px; + font-weight: 600; + color: var(--text-secondary, #999); + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .country-selector-quick-buttons { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(60px, 1fr)); + gap: 8px; + padding: 0 16px 12px 16px; + } + + .country-selector-quick-btn { + padding: 8px; + background: var(--bg-secondary, #2a2a2a); + border: 1px solid var(--border-color, #333); + border-radius: 4px; + color: var(--text-primary, #fff); + cursor: pointer; + font-size: 12px; + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + transition: all 0.2s; + } + + .country-selector-quick-btn:hover { + background: var(--accent-color, #00aaff); + border-color: var(--accent-color, #00aaff); + color: #000; + } + + .country-selector-quick-btn .flag { + font-size: 20px; + } + + .country-selector-list { + overflow-y: auto; + flex: 1; + border-top: 1px solid var(--border-color, #333); + } + + .country-selector-list-ul { + list-style: none; + margin: 0; + padding: 0; + } + + .country-selector-list-item { + padding: 10px 16px; + display: flex; + align-items: center; + gap: 12px; + cursor: pointer; + border-bottom: 1px solid var(--border-color, #333); + transition: background 0.15s; + } + + .country-selector-list-item:hover { + background: var(--bg-secondary, #2a2a2a); + } + + .country-selector-list-item .flag { + font-size: 20px; + flex-shrink: 0; + } + + .country-selector-list-item .name { + flex: 1; + color: var(--text-primary, #fff); + font-size: 13px; + } + + .country-selector-list-item .code { + color: var(--text-secondary, #999); + font-size: 11px; + font-weight: 600; + font-family: monospace; + } + + .country-selector-no-results { + padding: 32px 16px; + text-align: center; + color: var(--text-secondary, #999); + font-size: 14px; + } + `; + + if (!document.head.querySelector('style[data-country-selector]')) { + style.setAttribute('data-country-selector', 'true'); + document.head.appendChild(style); + } + } + + public destroy(): void { + if (this.searchTimeout) clearTimeout(this.searchTimeout); + this.container.remove(); + } +} diff --git a/src/components/LiveNewsPanel.ts b/src/components/LiveNewsPanel.ts index c10f4177d2..0b72cea11f 100644 --- a/src/components/LiveNewsPanel.ts +++ b/src/components/LiveNewsPanel.ts @@ -1224,7 +1224,7 @@ export class LiveNewsPanel extends Panel { video.addEventListener('error', onHlsFatalError); } else { // Chrome / Firefox: lazy-load hls.js only when needed - const { default: Hls } = await import('hls.js'); + const { default: Hls } = await import('hls.js' as any); if (this.activeChannel.id !== failedChannel.id || !this.element?.isConnected) return; if (!Hls.isSupported()) { // No HLS support at all — fall through to YouTube diff --git a/src/components/SearchModal.ts b/src/components/SearchModal.ts index 32b4c3a30d..6f67585d40 100644 --- a/src/components/SearchModal.ts +++ b/src/components/SearchModal.ts @@ -54,7 +54,7 @@ function resolveCategoryLabel(cmd: Command): string { return key ? t(key, { defaultValue: cmd.category }) : cmd.category; } -export type SearchResultType = 'country' | 'news' | 'hotspot' | 'market' | 'prediction' | 'conflict' | 'base' | 'pipeline' | 'cable' | 'datacenter' | 'earthquake' | 'outage' | 'nuclear' | 'irradiator' | 'techcompany' | 'ailab' | 'startup' | 'techevent' | 'techhq' | 'accelerator' | 'exchange' | 'financialcenter' | 'centralbank' | 'commodityhub' | 'flight'; +export type SearchResultType = 'country' | 'city' | 'news' | 'hotspot' | 'market' | 'prediction' | 'conflict' | 'base' | 'pipeline' | 'cable' | 'datacenter' | 'earthquake' | 'outage' | 'nuclear' | 'irradiator' | 'techcompany' | 'ailab' | 'startup' | 'techevent' | 'techhq' | 'accelerator' | 'exchange' | 'financialcenter' | 'centralbank' | 'commodityhub' | 'flight'; export interface SearchResult { type: SearchResultType; @@ -384,7 +384,7 @@ export class SearchModal { const priority: SearchResultType[] = [ 'flight', 'news', 'prediction', 'market', 'earthquake', 'outage', - 'conflict', 'hotspot', 'country', + 'conflict', 'hotspot', 'country', 'city', 'base', 'pipeline', 'cable', 'datacenter', 'nuclear', 'irradiator', 'techcompany', 'ailab', 'startup', 'techevent', 'techhq', 'accelerator' ]; @@ -617,6 +617,7 @@ export class SearchModal { const icons: Record = { country: '\u{1F3F3}\uFE0F', + city: '🏙️', news: '\u{1F4F0}', hotspot: '\u{1F4CD}', market: '\u{1F4C8}', diff --git a/src/components/index.ts b/src/components/index.ts index d84553731e..c5cebdc614 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -104,3 +104,6 @@ export * from './SocialVelocityPanel'; export * from './WsbTickerScannerPanel'; export * from './ResilienceWidget'; export * from './EnergyCrisisPanel'; +export * from './CountrySelector'; +export * from './CountryProfileView'; +export * from './CountryProfilePanel'; diff --git a/src/config/panels.ts b/src/config/panels.ts index ca6c8a4379..e516e52f3f 100644 --- a/src/config/panels.ts +++ b/src/config/panels.ts @@ -23,6 +23,7 @@ const FULL_PANELS: Record = { 'strategic-posture': { name: 'AI Strategic Posture', enabled: true, priority: 1 }, forecast: { name: 'AI Forecasts', enabled: true, priority: 1, ...(_desktop && { premium: 'locked' as const }) }, // trial: unlocked on web, locked on desktop cii: { name: 'Country Instability', enabled: true, priority: 1, ...(_desktop && { premium: 'enhanced' as const }) }, + 'country-profile': { name: 'Country Profile', enabled: false, priority: 1 }, 'strategic-risk': { name: 'Strategic Risk Overview', enabled: true, priority: 1, ...(_desktop && { premium: 'enhanced' as const }) }, intel: { name: 'Intel Feed', enabled: true, priority: 1 }, 'gdelt-intel': { name: 'Live Intelligence', enabled: true, priority: 1, ...(_desktop && { premium: 'enhanced' as const }) }, diff --git a/src/locales/en.json b/src/locales/en.json index 03bece3499..fa5c01da34 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -511,6 +511,7 @@ "close": "close", "types": { "country": "Country", + "city": "City", "news": "News", "hotspot": "Hotspot", "market": "Market", diff --git a/src/services/city-geometry.ts b/src/services/city-geometry.ts new file mode 100644 index 0000000000..12b0fd47f0 --- /dev/null +++ b/src/services/city-geometry.ts @@ -0,0 +1,32 @@ +import type { CityCoord } from '../../api/data/city-coords'; +import { CITY_COORDS } from '../../api/data/city-coords'; + +export interface CitySearchData extends CityCoord { + city: string; +} + +function normalizeCityName(city: string): string { + return city.trim().toLowerCase(); +} + +function titleCase(value: string): string { + return value.replace(/\b\w/g, (char) => char.toUpperCase()); +} + +export function getCityCoords(city: string): CityCoord | null { + return CITY_COORDS[normalizeCityName(city)] ?? null; +} + +export function getCitySearchItems(): Array<{ + id: string; + title: string; + subtitle: string; + data: CitySearchData; +}> { + return Object.entries(CITY_COORDS).map(([key, coords]) => ({ + id: key, + title: titleCase(key), + subtitle: coords.country, + data: { ...coords, city: key }, + })); +} diff --git a/src/services/country-geometry.ts b/src/services/country-geometry.ts index 7b913e54e2..d9fa663303 100644 --- a/src/services/country-geometry.ts +++ b/src/services/country-geometry.ts @@ -300,6 +300,13 @@ export function hasCountryGeometry(code: string): boolean { return countryIndex.has(code.toUpperCase()); } +export function isValidCountryCode(code: string): boolean { + if (typeof code !== 'string') return false; + const normalized = code.trim().toUpperCase(); + if (!/^[A-Z]{2}$/.test(normalized)) return false; + return countryIndex.has(normalized); +} + export function getCountryAtCoordinates(lat: number, lon: number, candidateCodes?: string[]): CountryHit | null { if (!loadedGeoJson) return null; const candidates = Array.isArray(candidateCodes) && candidateCodes.length > 0 diff --git a/vite.config.ts b/vite.config.ts index 3eef495f09..5928508f27 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -915,11 +915,15 @@ export default defineConfig(({ mode }) => { __dirname, 'src/shims/child-process-proxy.ts' ), + 'three-globe': resolve(__dirname, 'node_modules/three-globe/dist/three-globe.min.js'), }, }, worker: { format: 'es', }, + optimizeDeps: { + include: ['hls.js', 'three-globe'], + }, build: { // Geospatial bundles (maplibre/deck) are expected to be large even when split. // Raise warning threshold to reduce noisy false alarms in CI.