import type { AppContext, AppModule } from '@/app/app-context'; import type { SearchResult } from '@/components/SearchModal'; import type { NewsItem, MapLayers, MilitaryBase, MilitaryFlight } from '@/types'; import type { MapView, TimeRange } from '@/components/MapContainer'; import type { Command } from '@/config/commands'; import { SearchModal } from '@/components/SearchModal'; import type { CIIPanel } from '@/components/CIIPanel'; import { SITE_VARIANT, STORAGE_KEYS, ALL_PANELS, getEffectivePanelConfig, isPanelEntitled } from '@/config'; import { getAllowedLayerKeys, isLayerExecutable } from '@/config/map-layer-definitions'; import type { MapRenderer } from '@/config/map-layer-definitions'; import type { MapVariant } from '@/config/map-layer-definitions'; import { LAYER_PRESETS, LAYER_KEY_MAP } from '@/config/commands'; import { TIER1_COUNTRIES } from '@/services/country-instability'; import { getCachedCountryScores } from '@/services/cached-risk-scores'; import { CURATED_COUNTRIES } from '@/config/countries'; import { getCountryBbox } from '@/services/country-geometry'; import { INTEL_HOTSPOTS, CONFLICT_ZONES } from '@/config/geo'; import { getCachedMilitaryBases, preloadMilitaryBases } from '@/services/military-base-config'; import { UNDERSEA_CABLES, NUCLEAR_FACILITIES } from '@/config/geo-map'; import { PIPELINES } from '@/config/pipelines'; import { AI_DATA_CENTERS } from '@/config/ai-datacenters'; import { GAMMA_IRRADIATORS } from '@/config/irradiators'; import { TECH_COMPANIES } from '@/config/tech-companies'; import { AI_RESEARCH_LABS } from '@/config/ai-research-labs'; import { STARTUP_ECOSYSTEMS } from '@/config/startup-ecosystems'; import { TECH_HQS, ACCELERATORS } from '@/config/tech-geo'; import { STOCK_EXCHANGES, FINANCIAL_CENTERS, CENTRAL_BANKS, COMMODITY_HUBS } from '@/config/finance-geo'; import { trackSearchResultSelected, trackCountrySelected } from '@/services/analytics'; import { t } from '@/services/i18n'; import { saveToStorage, setTheme } from '@/utils'; import { CountryIntelManager } from '@/app/country-intel'; import type { PositionSample } from '@/services/aviation'; import { fetchAircraftPositions } from '@/services/aviation'; import { isProUser } from '@/services/widget-store'; import { getAuthState } from '@/services/auth-state'; export interface SearchManagerCallbacks { openCountryBriefByCode: (code: string, country: string) => void; /** Enables a currently-disabled panel (CMD+K "Add"). Returns false if blocked (unknown / free-tier cap). */ enablePanel: (panelId: string) => boolean; } export class SearchManager implements AppModule { private ctx: AppContext; private callbacks: SearchManagerCallbacks; private highlightTimers = new WeakMap>(); constructor(ctx: AppContext, callbacks: SearchManagerCallbacks) { this.ctx = ctx; this.callbacks = callbacks; } init(): void { this.setupSearchModal(); } destroy(): void {} private setupSearchModal(): void { const searchOptions = SITE_VARIANT === 'tech' ? { placeholder: t('modals.search.placeholderTech') } : SITE_VARIANT === 'happy' ? { placeholder: 'Search or type a command...' } : SITE_VARIANT === 'finance' ? { placeholder: t('modals.search.placeholderFinance') } : { placeholder: t('modals.search.placeholder') }; this.ctx.searchModal = new SearchModal(this.ctx.container, searchOptions); if (SITE_VARIANT === 'happy') { // Happy variant: no geopolitical/military/infrastructure sources } else if (SITE_VARIANT === 'tech') { this.ctx.searchModal.registerSource('techcompany', TECH_COMPANIES.map(c => ({ id: c.id, title: c.name, subtitle: `${c.sector} ${c.city} ${c.keyProducts?.join(' ') || ''}`.trim(), data: c, }))); this.ctx.searchModal.registerSource('ailab', AI_RESEARCH_LABS.map(l => ({ id: l.id, title: l.name, subtitle: `${l.type} ${l.city} ${l.focusAreas?.join(' ') || ''}`.trim(), data: l, }))); this.ctx.searchModal.registerSource('startup', STARTUP_ECOSYSTEMS.map(s => ({ id: s.id, title: s.name, subtitle: `${s.ecosystemTier} ${s.topSectors?.join(' ') || ''} ${s.notableStartups?.join(' ') || ''}`.trim(), data: s, }))); this.ctx.searchModal.registerSource('datacenter', AI_DATA_CENTERS.map(d => ({ id: d.id, title: d.name, subtitle: `${d.owner} ${d.chipType || ''}`.trim(), data: d, }))); this.ctx.searchModal.registerSource('cable', UNDERSEA_CABLES.map(c => ({ id: c.id, title: c.name, subtitle: c.major ? 'Major internet backbone' : 'Undersea cable', data: c, }))); this.ctx.searchModal.registerSource('techhq', TECH_HQS.map(h => ({ id: h.id, title: h.company, subtitle: `${h.type === 'faang' ? 'Big Tech' : h.type === 'unicorn' ? 'Unicorn' : 'Public'} • ${h.city}, ${h.country}`, data: h, }))); this.ctx.searchModal.registerSource('accelerator', ACCELERATORS.map(a => ({ id: a.id, title: a.name, subtitle: `${a.type} • ${a.city}, ${a.country}${a.notable ? ` • ${a.notable.slice(0, 2).join(', ')}` : ''}`, data: a, }))); } else { this.ctx.searchModal.registerSource('hotspot', INTEL_HOTSPOTS.map(h => ({ id: h.id, title: h.name, subtitle: `${h.subtext || ''} ${h.keywords?.join(' ') || ''} ${h.description || ''}`.trim(), data: h, }))); this.ctx.searchModal.registerSource('conflict', CONFLICT_ZONES.map(c => ({ id: c.id, title: c.name, subtitle: `${c.parties?.join(' ') || ''} ${c.keywords?.join(' ') || ''} ${c.description || ''}`.trim(), data: c, }))); this.registerBaseSearchSource(); this.ctx.searchModal.registerSource('pipeline', PIPELINES.map(p => ({ id: p.id, title: p.name, subtitle: `${p.type} ${p.operator || ''} ${p.countries?.join(' ') || ''}`.trim(), data: p, }))); this.ctx.searchModal.registerSource('cable', UNDERSEA_CABLES.map(c => ({ id: c.id, title: c.name, subtitle: c.major ? 'Major cable' : '', data: c, }))); this.ctx.searchModal.registerSource('datacenter', AI_DATA_CENTERS.map(d => ({ id: d.id, title: d.name, subtitle: `${d.owner} ${d.chipType || ''}`.trim(), data: d, }))); this.ctx.searchModal.registerSource('nuclear', NUCLEAR_FACILITIES.map(n => ({ id: n.id, title: n.name, subtitle: `${n.type} ${n.operator || ''}`.trim(), data: n, }))); this.ctx.searchModal.registerSource('irradiator', GAMMA_IRRADIATORS.map(g => ({ id: g.id, title: `${g.city}, ${g.country}`, subtitle: g.organization || '', data: g, }))); } if (SITE_VARIANT === 'finance') { this.ctx.searchModal.registerSource('exchange', STOCK_EXCHANGES.map(e => ({ id: e.id, title: `${e.shortName} - ${e.name}`, subtitle: `${e.tier} • ${e.city}, ${e.country}${e.marketCap ? ` • $${e.marketCap}T` : ''}`, data: e, }))); this.ctx.searchModal.registerSource('financialcenter', FINANCIAL_CENTERS.map(f => ({ id: f.id, title: f.name, subtitle: `${f.type} financial center${f.gfciRank ? ` • GFCI #${f.gfciRank}` : ''}${f.specialties ? ` • ${f.specialties.slice(0, 3).join(', ')}` : ''}`, data: f, }))); this.ctx.searchModal.registerSource('centralbank', CENTRAL_BANKS.map(b => ({ id: b.id, title: `${b.shortName} - ${b.name}`, subtitle: `${b.type}${b.currency ? ` • ${b.currency}` : ''} • ${b.city}, ${b.country}`, data: b, }))); this.ctx.searchModal.registerSource('commodityhub', COMMODITY_HUBS.map(h => ({ id: h.id, title: h.name, subtitle: `${h.type} • ${h.city}, ${h.country}${h.commodities ? ` • ${h.commodities.slice(0, 3).join(', ')}` : ''}`, data: h, }))); } this.ctx.searchModal.registerSource('country', this.buildCountrySearchItems()); this.syncPanelSearchIndex(); // Filter CMD+K layer commands by (a) variant-allowed, (b) renderer // compatibility, (c) DeckGL state for deckGLOnly layers. Without this, // layer commands surface in CMD+K on variants where they'd silently // fail the variantAllowed guard in handleCommand (e.g. // `layer:storageFacilities` on tech/finance/commodity/happy), or on // globe / SVG-mobile where they'd hit the renderer guard. Better to // hide the toggle than expose a button that does nothing. this.ctx.searchModal.setLayerExecutableFn((layerKey) => { const key = (LAYER_KEY_MAP[layerKey] || layerKey) as keyof MapLayers; if (!(key in this.ctx.mapLayers)) return false; const variantAllowed = getAllowedLayerKeys((SITE_VARIANT || 'full') as MapVariant); if (!variantAllowed.has(key)) return false; const renderer: MapRenderer = this.ctx.map?.isGlobeMode?.() ? 'globe' : 'flat'; const isDeckGL = this.ctx.map?.isDeckGLActive?.() ?? false; return isLayerExecutable(key, renderer, isDeckGL); }); this.ctx.searchModal.setOnSelect((result) => this.handleSearchResult(result)); this.ctx.searchModal.setOnCommand((cmd) => this.handleCommand(cmd)); // Always wire flight search; check pro status reactively inside the callback // so mid-session sign-ins get the feature without a page reload. this.ctx.searchModal.setOnFlightSearch((callsign) => { if (!isProUser() && getAuthState().user?.role !== 'pro') return; fetchAircraftPositions({ callsign }).then((positions) => { if (!this.ctx.searchModal) return; // Deduplicate by callsign: keep the most recently observed entry per callsign. const seen = new Map(); for (const p of positions) { const key = (p.callsign || p.icao24).trim().toUpperCase(); const existing = seen.get(key); if (!existing || p.observedAt > existing.observedAt) { seen.set(key, p); } } const items = [...seen.values()].map(p => { const fl = Number.isFinite(p.altitudeFt) ? Math.round(p.altitudeFt / 100) : null; const kts = Number.isFinite(p.groundSpeedKts) ? Math.round(p.groundSpeedKts) : null; return { id: p.icao24, title: (p.callsign || p.icao24).trim().toUpperCase(), subtitle: p.onGround ? t('modals.search.flightOnGround') : fl !== null && kts !== null ? t('modals.search.flightAirborne', { fl: String(fl), kts: String(kts) }) : fl !== null ? `FL${fl}` : t('modals.search.flightOnGround'), data: { kind: 'adsb' as const, lat: p.lat, lon: p.lon, layer: 'flights' as const }, }; }); this.ctx.searchModal.registerSource('flight', items); this.ctx.searchModal.refreshSearch(); }).catch(() => {/* silent — show no results */}); }); } private registerBaseSearchSource(): void { const register = (bases: MilitaryBase[]) => { this.ctx.searchModal?.registerSource('base', bases.map(b => ({ id: b.id, title: b.name, subtitle: `${b.type} ${b.description || ''}`.trim(), data: b, }))); }; const cached = getCachedMilitaryBases(); if (cached.length > 0) register(cached); void preloadMilitaryBases().then(register).catch(() => {}); } private handleSearchResult(result: SearchResult): void { trackSearchResultSelected(result.type); switch (result.type) { case 'news': { const item = result.data as NewsItem; // Find which panel contains this item (may not always be 'politics') let targetPanelId = 'politics'; let targetPanel = this.ctx.newsPanels['politics'] ?? null; for (const [panelId, panel] of Object.entries(this.ctx.newsPanels)) { if (panel.hasNewsItem(item.link)) { targetPanelId = panelId; targetPanel = panel; break; } } this.scrollToPanel(targetPanelId); if (targetPanel) { setTimeout(() => targetPanel!.scrollToNewsItem(item.link), 300); } break; } case 'hotspot': { const hotspot = result.data as typeof INTEL_HOTSPOTS[0]; this.ctx.map?.setView('global'); setTimeout(() => { this.ctx.map?.triggerHotspotClick(hotspot.id); }, 300); break; } case 'conflict': { const conflict = result.data as typeof CONFLICT_ZONES[0]; this.ctx.map?.setView('global'); setTimeout(() => { this.ctx.map?.triggerConflictClick(conflict.id); }, 300); break; } case 'market': { this.scrollToPanel('markets'); break; } case 'prediction': { this.scrollToPanel('polymarket'); break; } case 'base': { const base = result.data as MilitaryBase; this.ctx.map?.setView('global'); setTimeout(() => { this.ctx.map?.triggerBaseClick(base.id); }, 300); break; } case 'pipeline': { const pipeline = result.data as typeof PIPELINES[0]; this.ctx.map?.setView('global'); this.ctx.map?.enableLayer('pipelines'); this.ctx.mapLayers.pipelines = true; setTimeout(() => { this.ctx.map?.triggerPipelineClick(pipeline.id); }, 300); break; } case 'cable': { const cable = result.data as typeof UNDERSEA_CABLES[0]; this.ctx.map?.setView('global'); this.ctx.map?.enableLayer('cables'); this.ctx.mapLayers.cables = true; setTimeout(() => { this.ctx.map?.triggerCableClick(cable.id); }, 300); break; } case 'datacenter': { const dc = result.data as typeof AI_DATA_CENTERS[0]; this.ctx.map?.setView('global'); this.ctx.map?.enableLayer('datacenters'); this.ctx.mapLayers.datacenters = true; setTimeout(() => { this.ctx.map?.triggerDatacenterClick(dc.id); }, 300); break; } case 'nuclear': { const nuc = result.data as typeof NUCLEAR_FACILITIES[0]; this.ctx.map?.setView('global'); this.ctx.map?.enableLayer('nuclear'); this.ctx.mapLayers.nuclear = true; setTimeout(() => { this.ctx.map?.triggerNuclearClick(nuc.id); }, 300); break; } case 'irradiator': { const irr = result.data as typeof GAMMA_IRRADIATORS[0]; this.ctx.map?.setView('global'); this.ctx.map?.enableLayer('irradiators'); this.ctx.mapLayers.irradiators = true; setTimeout(() => { this.ctx.map?.triggerIrradiatorClick(irr.id); }, 300); break; } case 'earthquake': case 'outage': this.ctx.map?.setView('global'); break; case 'techcompany': { const company = result.data as typeof TECH_COMPANIES[0]; this.ctx.map?.setView('global'); this.ctx.map?.enableLayer('techHQs'); this.ctx.mapLayers.techHQs = true; setTimeout(() => { this.ctx.map?.setCenter(company.lat, company.lon, 4); }, 300); break; } case 'ailab': { const lab = result.data as typeof AI_RESEARCH_LABS[0]; this.ctx.map?.setView('global'); setTimeout(() => { this.ctx.map?.setCenter(lab.lat, lab.lon, 4); }, 300); break; } case 'startup': { const ecosystem = result.data as typeof STARTUP_ECOSYSTEMS[0]; this.ctx.map?.setView('global'); this.ctx.map?.enableLayer('startupHubs'); this.ctx.mapLayers.startupHubs = true; setTimeout(() => { this.ctx.map?.setCenter(ecosystem.lat, ecosystem.lon, 4); }, 300); break; } case 'techevent': this.ctx.map?.setView('global'); this.ctx.map?.enableLayer('techEvents'); this.ctx.mapLayers.techEvents = true; break; case 'techhq': { const hq = result.data as typeof TECH_HQS[0]; this.ctx.map?.setView('global'); this.ctx.map?.enableLayer('techHQs'); this.ctx.mapLayers.techHQs = true; setTimeout(() => { this.ctx.map?.setCenter(hq.lat, hq.lon, 4); }, 300); break; } case 'accelerator': { const acc = result.data as typeof ACCELERATORS[0]; this.ctx.map?.setView('global'); this.ctx.map?.enableLayer('accelerators'); this.ctx.mapLayers.accelerators = true; setTimeout(() => { this.ctx.map?.setCenter(acc.lat, acc.lon, 4); }, 300); break; } case 'exchange': { const exchange = result.data as typeof STOCK_EXCHANGES[0]; this.ctx.map?.setView('global'); this.ctx.map?.enableLayer('stockExchanges'); this.ctx.mapLayers.stockExchanges = true; setTimeout(() => { this.ctx.map?.setCenter(exchange.lat, exchange.lon, 4); }, 300); break; } case 'financialcenter': { const fc = result.data as typeof FINANCIAL_CENTERS[0]; this.ctx.map?.setView('global'); this.ctx.map?.enableLayer('financialCenters'); this.ctx.mapLayers.financialCenters = true; setTimeout(() => { this.ctx.map?.setCenter(fc.lat, fc.lon, 4); }, 300); break; } case 'centralbank': { const bank = result.data as typeof CENTRAL_BANKS[0]; this.ctx.map?.setView('global'); this.ctx.map?.enableLayer('centralBanks'); this.ctx.mapLayers.centralBanks = true; setTimeout(() => { this.ctx.map?.setCenter(bank.lat, bank.lon, 4); }, 300); break; } case 'commodityhub': { const hub = result.data as typeof COMMODITY_HUBS[0]; this.ctx.map?.setView('global'); this.ctx.map?.enableLayer('commodityHubs'); this.ctx.mapLayers.commodityHubs = true; setTimeout(() => { this.ctx.map?.setCenter(hub.lat, hub.lon, 4); }, 300); break; } case 'country': { const { code, name } = result.data as { code: string; name: string }; trackCountrySelected(code, name, 'search'); this.callbacks.openCountryBriefByCode(code, name); break; } case 'flight': { const { lat, lon, layer } = result.data as { kind: string; lat: number; lon: number; layer: keyof MapLayers }; this.ctx.map?.enableLayer(layer); this.ctx.mapLayers[layer] = true; setTimeout(() => { this.ctx.map?.setCenter(lat, lon, 9); }, 300); break; } } } private handleCommand(cmd: Command): void { const colonIdx = cmd.id.indexOf(':'); if (colonIdx === -1) return; const category = cmd.id.slice(0, colonIdx); const action = cmd.id.slice(colonIdx + 1); switch (category) { case 'nav': this.ctx.map?.setView(action as MapView); { const sel = document.getElementById('regionSelect') as HTMLSelectElement; if (sel) sel.value = action; } break; case 'layers': { const allowed = getAllowedLayerKeys((SITE_VARIANT || 'full') as MapVariant); // Preset paths (`layers:all`, `layers:infra`, …) also need the // renderer + DeckGL gate that per-layer toggles go through. Without // it, a user in globe mode or on the SVG fallback can run // `layers:infra` and silently flip `deckGLOnly` layers on — those // layers set to `true` in state but produce no rendered output, // and since the picker hides them under the current renderer the // user has no way to toggle them back off without switching // modes. Codex P2 on PR #3366. const renderer: MapRenderer = this.ctx.map?.isGlobeMode?.() ? 'globe' : 'flat'; const isDeckGL = this.ctx.map?.isDeckGLActive?.() ?? false; const executable = (k: keyof MapLayers): boolean => allowed.has(k) && isLayerExecutable(k, renderer, isDeckGL); if (action === 'all') { for (const key of Object.keys(this.ctx.mapLayers)) { this.ctx.mapLayers[key as keyof MapLayers] = executable(key as keyof MapLayers); } } else if (action === 'none') { for (const key of Object.keys(this.ctx.mapLayers)) this.ctx.mapLayers[key as keyof MapLayers] = false; } else { const preset = LAYER_PRESETS[action]; if (preset) { for (const key of Object.keys(this.ctx.mapLayers)) this.ctx.mapLayers[key as keyof MapLayers] = false; for (const layer of preset) { if (executable(layer)) this.ctx.mapLayers[layer] = true; } } } saveToStorage(STORAGE_KEYS.mapLayers, this.ctx.mapLayers); this.ctx.map?.setLayers(this.ctx.mapLayers); break; } case 'layer': { const layerKey = (LAYER_KEY_MAP[action] || action) as keyof MapLayers; if (!(layerKey in this.ctx.mapLayers)) return; const variantAllowed = getAllowedLayerKeys((SITE_VARIANT || 'full') as MapVariant); if (!variantAllowed.has(layerKey)) return; // Renderer / DeckGL gate. Mirrors the filter applied in SearchModal // so direct activation paths (keyboard-accelerator, programmatic // dispatch, etc.) don't flip a layer on that can't render. const renderer: MapRenderer = this.ctx.map?.isGlobeMode?.() ? 'globe' : 'flat'; const isDeckGL = this.ctx.map?.isDeckGLActive?.() ?? false; if (!isLayerExecutable(layerKey, renderer, isDeckGL)) return; let newValue = !this.ctx.mapLayers[layerKey]; if (newValue && layerKey === 'resilienceScore' && !this.ctx.map?.isDeckGLActive?.()) { newValue = false; } this.ctx.mapLayers[layerKey] = newValue; saveToStorage(STORAGE_KEYS.mapLayers, this.ctx.mapLayers); if (newValue) { this.ctx.map?.enableLayer(layerKey); } else { this.ctx.map?.setLayers(this.ctx.mapLayers); } break; } case 'panel': { // CMD+K can now surface disabled-but-available panels (Add affordance). // Enable first so the element exists, then scroll once it renders. // An optional `@` suffix deep-links to a specific tab within the // panel (e.g. `consumer-prices@world` → global inflation view). const [panelId, subTab] = action.split('@'); if (!panelId) break; const cfg = this.ctx.panelSettings[panelId]; if (cfg && !cfg.enabled) { if (this.callbacks.enablePanel(panelId)) { this.scrollToPanelWhenReady(panelId); if (subTab) this.dispatchPanelTab(panelId, subTab); break; } } this.scrollToPanel(panelId); if (subTab) this.dispatchPanelTab(panelId, subTab); break; } case 'view': if (action === 'dark' || action === 'light') { setTheme(action); } else if (action === 'fullscreen') { if (document.fullscreenElement) { try { void document.exitFullscreen()?.catch(() => {}); } catch {} } else { const el = document.documentElement as HTMLElement & { webkitRequestFullscreen?: () => void }; if (el.requestFullscreen) { try { void el.requestFullscreen()?.catch(() => {}); } catch {} } else if (el.webkitRequestFullscreen) { try { el.webkitRequestFullscreen(); } catch {} } } } else if (action === 'settings') { this.ctx.unifiedSettings?.open(); } else if (action === 'refresh') { window.location.reload(); } else if (action === 'resilience') { const layerKey = 'resilienceScore' as keyof MapLayers; const variantAllowed = getAllowedLayerKeys((SITE_VARIANT || 'full') as MapVariant); if (!variantAllowed.has(layerKey)) break; let newValue = !this.ctx.mapLayers[layerKey]; if (newValue && !this.ctx.map?.isDeckGLActive?.()) newValue = false; this.ctx.mapLayers[layerKey] = newValue; saveToStorage(STORAGE_KEYS.mapLayers, this.ctx.mapLayers); if (newValue) { this.ctx.map?.enableLayer(layerKey); } else { this.ctx.map?.setLayers(this.ctx.mapLayers); } } else if (action === 'route-explorer') { void import('@/components/RouteExplorer/RouteExplorer').then((m) => { const explorer = m.getRouteExplorer(); explorer.setMap(this.ctx.map); explorer.open(); }); } break; case 'time': this.ctx.map?.setTimeRange(action as TimeRange); break; case 'country': { const name = TIER1_COUNTRIES[action] || CURATED_COUNTRIES[action]?.name || new Intl.DisplayNames(['en'], { type: 'region' }).of(action) || action; trackCountrySelected(action, name, 'command'); this.callbacks.openCountryBriefByCode(action, name); break; } case 'country-map': { const bbox = getCountryBbox(action); if (bbox) { const [minLon, minLat, maxLon, maxLat] = bbox; const lat = (minLat + maxLat) / 2; const lon = (minLon + maxLon) / 2; const span = Math.max(maxLat - minLat, maxLon - minLon); const zoom = span > 40 ? 3 : span > 15 ? 4 : span > 5 ? 5 : 6; this.ctx.map?.setView('global'); setTimeout(() => { this.ctx.map?.setCenter(lat, lon, zoom); }, 300); } break; } } } /** * Scrolls to a panel that may have just been enabled. Async-mounted panels * (e.g. deduction, regional-intelligence mount via dynamic import) aren't in * the DOM on the next tick, so retry over ~1s before giving up. The panel is * already enabled regardless — only the scroll is best-effort. */ private scrollToPanelWhenReady(panelId: string, attemptsLeft = 12): void { if (document.querySelector(`[data-panel="${panelId}"]`)) { this.scrollToPanel(panelId); return; } if (attemptsLeft <= 0) return; setTimeout(() => this.scrollToPanelWhenReady(panelId, attemptsLeft - 1), 80); } /** * Deep-links to a tab inside a panel by dispatching the panel's open-tab * event once it's mounted. Deferred-shell placeholders carry the same * data-panel attribute but no listener — only the REAL panel element (shell * excluded via data-deferred-panel) proves the constructor has run, so we * retry until the shell is replaced in place. The scroll helpers above * intentionally still match shells: a shell occupies the panel's slot, and * scrolling to it is what brings it into the IntersectionObserver margin * that triggers the mount. */ private dispatchPanelTab(panelId: string, tab: string, attemptsLeft = 12): void { // Currently only Consumer Prices exposes a tab deep-link contract. if (panelId !== 'consumer-prices') return; if (document.querySelector(`[data-panel="${panelId}"]:not([data-deferred-panel])`)) { window.dispatchEvent(new CustomEvent('wm-consumer-prices-open-tab', { detail: { tab } })); return; } if (attemptsLeft <= 0) return; setTimeout(() => this.dispatchPanelTab(panelId, tab, attemptsLeft - 1), 80); } private scrollToPanel(panelId: string): void { const panel = document.querySelector(`[data-panel="${panelId}"]`); if (panel) { panel.scrollIntoView({ behavior: 'smooth', block: 'center' }); this.applyHighlight(panel); } } private applyHighlight(el: Element): void { const prev = this.highlightTimers.get(el); if (prev) clearTimeout(prev); el.classList.remove('search-highlight'); void (el as HTMLElement).offsetWidth; el.classList.add('search-highlight'); this.highlightTimers.set(el, setTimeout(() => { el.classList.remove('search-highlight'); this.highlightTimers.delete(el); }, 3100)); } updateFlightSource(adsb: PositionSample[], military: MilitaryFlight[]): void { if (!this.ctx.searchModal || !isProUser()) return; const items = [ ...adsb.map(p => { const fl = Number.isFinite(p.altitudeFt) ? Math.round(p.altitudeFt / 100) : null; const kts = Number.isFinite(p.groundSpeedKts) ? Math.round(p.groundSpeedKts) : null; return { id: p.icao24, title: (p.callsign || p.icao24).trim().toUpperCase(), subtitle: p.onGround ? t('modals.search.flightOnGround') : fl !== null && kts !== null ? t('modals.search.flightAirborne', { fl: String(fl), kts: String(kts) }) : fl !== null ? `FL${fl}` : t('modals.search.flightOnGround'), data: { kind: 'adsb' as const, lat: p.lat, lon: p.lon, layer: 'flights' as const }, }; }), ...military.map(f => { const fl = Number.isFinite(f.altitude) ? Math.round(f.altitude / 100) : null; return { id: f.hexCode, title: (f.callsign || f.hexCode).trim().toUpperCase(), subtitle: f.onGround ? t('modals.search.flightMilitaryOnGround', { type: f.aircraftType }) : fl !== null ? t('modals.search.flightMilitary', { type: f.aircraftType, fl: String(fl) }) : t('modals.search.flightMilitaryOnGround', { type: f.aircraftType }), data: { kind: 'military' as const, lat: f.lat, lon: f.lon, layer: 'military' as const }, }; }), ]; this.ctx.searchModal.registerSource('flight', items); } updateSearchIndex(): void { if (!this.ctx.searchModal) return; this.syncPanelSearchIndex(); this.ctx.searchModal.registerSource('country', this.buildCountrySearchItems()); const newsItems = this.ctx.allNews.slice(0, 500).map(n => ({ id: n.link, title: n.title, subtitle: n.source, data: n, })); console.log(`[Search] Indexing ${newsItems.length} news items (allNews total: ${this.ctx.allNews.length})`); this.ctx.searchModal.registerSource('news', newsItems); if (this.ctx.latestPredictions.length > 0) { this.ctx.searchModal.registerSource('prediction', this.ctx.latestPredictions.map(p => ({ id: p.title, title: p.title, subtitle: `${Math.round(p.yesPrice)}% probability`, data: p, }))); } if (this.ctx.latestMarkets.length > 0) { this.ctx.searchModal.registerSource('market', this.ctx.latestMarkets.map(m => ({ id: m.symbol, title: `${m.symbol} - ${m.name}`, subtitle: `$${m.price?.toFixed(2) || 'N/A'}`, data: m, }))); } if (SITE_VARIANT === 'tech') { this.ctx.searchModal.registerSource('techevent', this.ctx.latestTechEvents.map((e) => ({ id: e.id, title: e.title, subtitle: `${e.location} • ${new Date(e.startDate).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}`, data: e, }))); } } /** * Feeds CMD+K two panel sets: `active` (currently enabled) and `available` * (every entitled panel the user could cross-enable on this variant — all * of ALL_PANELS merge into panelSettings per App.ts). The modal surfaces * available-but-disabled panels with an "Add" affordance; selecting one * routes through enablePanel(). Without the available set, search could * only jump to panels already on screen — the core discoverability gap. */ private syncPanelSearchIndex(): void { if (!this.ctx.searchModal) return; // isProUser() already folds in getAuthState().user?.role === 'pro'. const isPro = isProUser(); this.ctx.searchModal.setActivePanels( Object.entries(this.ctx.panelSettings).filter(([, v]) => v.enabled).map(([k]) => k) ); this.ctx.searchModal.setAvailablePanels( Object.keys(this.ctx.panelSettings).filter((k) => { // Keep unregistered/dynamic keys out of search; the resolver would // otherwise return a disabled synthetic fallback for unknown keys. const cfg = ALL_PANELS[k] ? getEffectivePanelConfig(k, SITE_VARIANT) : undefined; return cfg ? isPanelEntitled(k, cfg, isPro) : false; }) ); } private buildCountrySearchItems(): { id: string; title: string; subtitle: string; data: { code: string; name: string } }[] { const cachedScores = getCachedCountryScores(); const panelScores = (this.ctx.panels.cii as CIIPanel | undefined)?.getScores() ?? []; const scores = cachedScores.length > 0 ? cachedScores : panelScores; const ciiByCode = new Map(scores.map((score) => [score.code, score])); return Object.entries(TIER1_COUNTRIES).map(([code, name]) => { const score = ciiByCode.get(code); return { id: code, title: `${CountryIntelManager.toFlagEmoji(code)} ${name}`, subtitle: score ? `CII: ${score.score}/100 • ${score.level}` : 'Country Brief', data: { code, name }, }; }); } }