import type { AppContext, AppModule, UnifiedSettingsController, UnifiedSettingsTabId, } from '@/app/app-context'; import type { UnifiedSettingsConfig } from '@/components/UnifiedSettings'; import type { AirlineIntelPanel } from '@/components/AirlineIntelPanel'; import type { CustomWidgetPanel } from '@/components/CustomWidgetPanel'; import { deleteWidget, getWidget, saveWidget, isProUser } from '@/services/widget-store'; import { FREE_MAX_PANELS, FREE_MAX_SOURCES, countFreePanelCapUsage, isFreePanelCapCounted, } from '@/config/panels'; import type { McpDataPanel } from '@/components/McpDataPanel'; import { deleteMcpPanel, getMcpPanel, saveMcpPanel } from '@/services/mcp-store'; import type { PanelConfig, MapLayers, MilitaryFlight } from '@/types'; import type { MapView } from '@/components/MapContainer'; import type { PositionSample } from '@/services/aviation'; import type { ClusteredEvent } from '@/types'; import type { DashboardSnapshot } from '@/services/storage'; import { PlaybackControl } from '@/components/PlaybackControl'; import { PizzIntIndicator } from '@/components/PizzIntIndicator'; import { LlmStatusIndicator } from '@/components/LlmStatusIndicator'; import type { PredictionPanel } from '@/components/PredictionPanel'; import { buildMapUrl, debounce, saveToStorage, getCurrentTheme, setTheme, showToast, } from '@/utils'; import { clearPanelColSpans, clearPanelSpans } from '@/utils/panel-storage'; import { IDLE_PAUSE_MS, DEFAULT_MAP_LAYERS, MOBILE_DEFAULT_MAP_LAYERS, STORAGE_KEYS, SITE_VARIANT, LAYER_TO_SOURCE, FEEDS, CANONICAL_FEEDS, INTEL_SOURCES, } from '@/config'; import { resolveNewsCategories, enabledNewsCategoryKeys } from '@/config/feed-resolution'; import { VARIANT_META } from '@/config/variant-meta'; import { isDesktopRuntime } from '@/services/runtime'; import { MISSION_PRESETS, applyMissionPresetToState, clearMissionPreset, dismissMissionPresetPrompt, filterMissionLayersForRenderer, isMissionPresetPromptDismissed, loadStoredMissionPreset, resetMissionPresetState, saveMissionPreset, type MissionPreset, type MissionPresetId, } from '@/services/mission-presets'; import { saveSnapshot, initAisStream, disconnectAisStream, isAisConfigured, } from '@/services'; import { track, trackPanelView, trackVariantSwitch, trackThemeChanged, trackMapViewChange, trackMapLayerToggle, trackPanelToggled, trackDownloadClicked, trackGateHit, } from '@/services/analytics'; import { detectPlatform, allButtons, buttonsForPlatform } from '@/components/DownloadBanner'; import type { Platform } from '@/components/DownloadBanner'; import { invokeTauri } from '@/services/tauri-bridge'; import { getCachedGpsInterference } from '@/services/gps-interference'; import { dataFreshness } from '@/services/data-freshness'; import { mlWorker } from '@/services/ml-worker'; import { WM_OPEN_NOTIFICATIONS_FOR_COUNTRY } from '@/utils/notify-country-link'; import { AuthLauncher } from '@/components/AuthLauncher'; import { AuthHeaderWidget } from '@/components/AuthHeaderWidget'; import { t } from '@/services/i18n'; import { TvModeController } from '@/services/tv-mode'; import { getAuthState, subscribeAuthState } from '@/services/auth-state'; import { onEntitlementChange } from '@/services/entitlements'; import { primeExportGateActivation } from '@/services/export-gate'; import { evaluateAvailableExportFormats, evaluateExportGate, evaluatePlaybackGate, exportLockToGateReason, resolveGateAction, type PanelGateReason } from '@/services/panel-gating'; import type { DataExportFormat } from '@/services/export-gate'; import { ExportGateControl } from '@/components/ExportGateControl'; import { h, setTrustedHtml, trustedHtml } from '@/utils/dom-utils'; import { scheduleAfterFirstPaint } from '@/utils/after-paint'; import { escapeHtml } from '@/utils/sanitize'; import { buildEmbedIframeSnippet, buildEmbedMapUrl, type EmbedVariant } from '@/embed/embed-url'; import { createSettingsButton } from '@/components/settings-button'; function readStorageValue(key: string): string | null { try { return localStorage.getItem(key); } catch { return null; } } function writeStorageValue(key: string, value: string): void { try { localStorage.setItem(key, value); } catch { // UI preferences remain in memory for the current page. } } function removeStorageValue(key: string): void { try { localStorage.removeItem(key); } catch { // Storage is optional for UI preferences. } } type RealUnifiedSettings = import('@/components/UnifiedSettings').UnifiedSettings; class LazyUnifiedSettings implements UnifiedSettingsController { private readonly button: HTMLButtonElement; private instance: RealUnifiedSettings | null = null; private loadPromise: Promise | null = null; private destroyed = false; constructor(private readonly config: UnifiedSettingsConfig) { this.button = createSettingsButton(() => this.open()); } getButton(): HTMLButtonElement { return this.button; } open(tab?: UnifiedSettingsTabId): void { void this.load().then((settings) => { if (!this.destroyed) settings.open(tab); }).catch((error) => { // A rejection because the controller was torn down mid-load is a // deliberate unmount, not a failure the user should be toasted about. if (this.destroyed) return; console.warn('[settings] Failed to load settings window:', error); showToast(t('common.error')); }); } refreshPanelToggles(): void { this.instance?.refreshPanelToggles(); } destroy(): void { this.destroyed = true; this.instance?.destroy(); this.instance = null; } private load(): Promise { if (this.destroyed) { return Promise.reject(new Error('Settings controller destroyed')); } if (this.instance) return Promise.resolve(this.instance); if (this.loadPromise) return this.loadPromise; this.loadPromise = import('@/components/UnifiedSettings') .then(({ UnifiedSettings }) => { const settings = new UnifiedSettings(this.config); if (this.destroyed) { settings.destroy(); throw new Error('Settings controller destroyed during load'); } this.instance = settings; return settings; }) .finally(() => { this.loadPromise = null; }); return this.loadPromise; } } export interface EventHandlerCallbacks { openSearch: (options?: { toggle?: boolean }) => void; updateSearchIndex: () => void; updateFlightSource?: (adsb: PositionSample[], military: MilitaryFlight[]) => void; loadAllData: () => Promise; /** * Tell the data loader that the rendered news no longer reflects the last * load, so the next loadAllData() refetches it even though the category set * is unchanged. See DataLoader.invalidateNewsHydration. */ invalidateNewsHydration: () => void; flushStaleRefreshes: () => void; setHiddenSince: (ts: number) => void; loadDataForLayer: (layer: string) => void; waitForAisData: () => void; syncDataFreshnessWithLayers: () => void; ensureCorrectZones: () => void; applySavedPanelOrder?: (panelOrder?: string[]) => void; refreshCiiAfterFocalPointsReady?: () => void; stopLayerActivity?: (layer: keyof MapLayers) => void; mountLiveNewsIfReady?: () => void; } export class EventHandlerManager implements AppModule { private ctx: AppContext; private callbacks: EventHandlerCallbacks; private boundFullscreenHandler: (() => void) | null = null; private boundResizeHandler: (() => void) | null = null; private boundVisibilityHandler: (() => void) | null = null; private boundDesktopExternalLinkHandler: ((e: MouseEvent) => void) | null = null; private boundIdleResetHandler: (() => void) | null = null; private boundStorageHandler: ((e: StorageEvent) => void) | null = null; private boundTvKeydownHandler: ((e: KeyboardEvent) => void) | null = null; private boundFocalPointsReadyHandler: (() => void) | null = null; private boundThemeChangedHandler: (() => void) | null = null; private boundDropdownClickHandler: ((e: MouseEvent) => void) | null = null; private boundDropdownKeydownHandler: ((e: KeyboardEvent) => void) | null = null; private boundMapResizeMoveHandler: ((e: MouseEvent) => void) | null = null; private boundMapEndResizeHandler: (() => void) | null = null; private boundMapResizeVisChangeHandler: (() => void) | null = null; private boundMapWidthResizeMoveHandler: ((e: MouseEvent) => void) | null = null; private boundMapWidthEndResizeHandler: (() => void) | null = null; private boundMapFullscreenEscHandler: ((e: KeyboardEvent) => void) | null = null; private readonly registeredSearchButtons = new Set(); private boundSearchKeyHandler: ((e: KeyboardEvent) => void) | null = null; private boundMobileMenuKeyHandler: ((e: KeyboardEvent) => void) | null = null; private boundPanelCloseHandler: ((e: Event) => void) | null = null; private boundWidgetModifyHandler: ((e: Event) => void) | null = null; private boundUndoHandler: ((e: KeyboardEvent) => void) | null = null; private boundNotifyForCountryHandler: ((e: Event) => void) | null = null; private boundMissionOutsideHandler: ((e: MouseEvent) => void) | null = null; private boundMissionKeydownHandler: ((e: KeyboardEvent) => void) | null = null; private boundEmbedModalKeydownHandler: ((e: KeyboardEvent) => void) | null = null; private missionPresetPopover: HTMLElement | null = null; private missionDataRefreshTimer: number | null = null; private proGateUnsubscribers: Array<() => void> = []; private exportPanelLoad: Promise> | null = null; private closedPanelStack: string[] = []; // max-items: 20 private idleTimeoutId: ReturnType | null = null; private snapshotIntervalId: ReturnType | null = null; private clockIntervalId: ReturnType | null = null; private readonly idlePauseMs = IDLE_PAUSE_MS; private readonly debouncedUrlSync = debounce(() => { const shareUrl = this.getShareUrl(); if (!shareUrl) return; try { history.replaceState(null, '', shareUrl); } catch { } }, 250); private readonly debouncedWebcamReload = debounce(() => { if (this.ctx.mapLayers?.webcams) { this.callbacks.loadDataForLayer('webcams'); } }, 350); constructor(ctx: AppContext, callbacks: EventHandlerCallbacks) { this.ctx = ctx; this.callbacks = callbacks; } init(): void { this.setupSearchControls(); this.setupEventListeners(); this.setupIdleDetection(); this.setupTvMode(); } private performUndo(): void { const panelId = this.closedPanelStack.pop(); if (!panelId) return; this.enablePanelById(panelId); } /** * Enables a registered panel (undo-restore, CMD+K "Add", etc.). Returns * false when the panel is unknown or the free-tier cap blocks it. Already * enabled → true (no-op). Single source of truth for runtime panel-enable * so search-add and undo-restore stay in lockstep. */ enablePanelById(panelId: string): boolean { const config = this.ctx.panelSettings[panelId]; if (!config) return false; if (config.enabled) return true; if (!isProUser() && isFreePanelCapCounted(panelId)) { const enabledCount = countFreePanelCapUsage(this.ctx.panelSettings); if (enabledCount >= FREE_MAX_PANELS) { // Tell the user why nothing happened instead of failing silently. // (Undo-restore can't reach this branch — closing a panel frees a // slot first — so only the CMD+K "Add" path surfaces the toast.) showToast(t('modals.settingsWindow.freePanelLimit', { max: String(FREE_MAX_PANELS) })); return false; } } config.enabled = true; trackPanelToggled(panelId, true); saveToStorage(STORAGE_KEYS.panels, this.ctx.panelSettings); this.applyPanelSettings(); this.ctx.unifiedSettings?.refreshPanelToggles(); // Ensure restored panel fetches fresh data (otherwise it may show no content) const panel = this.ctx.panels[panelId]; if (panel && 'fetchData' in panel && typeof (panel as { fetchData: unknown }).fetchData === 'function') { (panel as { fetchData: () => void }).fetchData(); } return true; } private setupTvMode(): void { if (SITE_VARIANT !== 'happy') return; const tvBtn = document.getElementById('tvModeBtn'); const tvExitBtn = document.getElementById('tvExitBtn'); if (tvBtn) { tvBtn.addEventListener('click', () => this.toggleTvMode()); } if (tvExitBtn) { tvExitBtn.addEventListener('click', () => this.toggleTvMode()); } // Keyboard shortcut: Shift+T this.boundTvKeydownHandler = (e: KeyboardEvent) => { if (e.shiftKey && e.key === 'T' && !e.ctrlKey && !e.metaKey && !e.altKey) { const active = document.activeElement; if (active?.tagName !== 'INPUT' && active?.tagName !== 'TEXTAREA') { e.preventDefault(); this.toggleTvMode(); } } }; document.addEventListener('keydown', this.boundTvKeydownHandler); } private toggleTvMode(): void { const panelKeys = Object.keys(this.ctx.panelSettings).filter( key => this.ctx.panelSettings[key]?.enabled !== false ); if (!this.ctx.tvMode) { this.ctx.tvMode = new TvModeController({ panelKeys, onPanelChange: () => { document.getElementById('tvModeBtn')?.classList.toggle('active', this.ctx.tvMode?.active ?? false); } }); } else { this.ctx.tvMode.updatePanelKeys(panelKeys); } this.ctx.tvMode.toggle(); document.getElementById('tvModeBtn')?.classList.toggle('active', this.ctx.tvMode.active); } destroy(): void { this.closeEmbedDialog(); this.debouncedUrlSync.cancel(); this.debouncedWebcamReload.cancel(); if (this.boundFullscreenHandler) { document.removeEventListener('fullscreenchange', this.boundFullscreenHandler); this.boundFullscreenHandler = null; } if (this.boundResizeHandler) { window.removeEventListener('resize', this.boundResizeHandler); this.boundResizeHandler = null; } if (this.boundVisibilityHandler) { document.removeEventListener('visibilitychange', this.boundVisibilityHandler); this.boundVisibilityHandler = null; } if (this.boundDesktopExternalLinkHandler) { document.removeEventListener('click', this.boundDesktopExternalLinkHandler, true); this.boundDesktopExternalLinkHandler = null; } if (this.idleTimeoutId) { clearTimeout(this.idleTimeoutId); this.idleTimeoutId = null; } if (this.boundIdleResetHandler) { ['mousedown', 'keydown', 'scroll', 'touchstart', 'mousemove'].forEach(event => { document.removeEventListener(event, this.boundIdleResetHandler!); }); this.boundIdleResetHandler = null; } if (this.snapshotIntervalId) { clearInterval(this.snapshotIntervalId); this.snapshotIntervalId = null; } if (this.clockIntervalId) { clearInterval(this.clockIntervalId); this.clockIntervalId = null; } if (this.boundStorageHandler) { window.removeEventListener('storage', this.boundStorageHandler); this.boundStorageHandler = null; } if (this.boundTvKeydownHandler) { document.removeEventListener('keydown', this.boundTvKeydownHandler); this.boundTvKeydownHandler = null; } if (this.boundFocalPointsReadyHandler) { window.removeEventListener('focal-points-ready', this.boundFocalPointsReadyHandler); this.boundFocalPointsReadyHandler = null; } if (this.boundThemeChangedHandler) { window.removeEventListener('theme-changed', this.boundThemeChangedHandler); this.boundThemeChangedHandler = null; } if (this.boundDropdownClickHandler) { document.removeEventListener('click', this.boundDropdownClickHandler); this.boundDropdownClickHandler = null; } if (this.boundDropdownKeydownHandler) { document.removeEventListener('keydown', this.boundDropdownKeydownHandler); this.boundDropdownKeydownHandler = null; } if (this.boundMapResizeMoveHandler) { document.removeEventListener('mousemove', this.boundMapResizeMoveHandler); this.boundMapResizeMoveHandler = null; } if (this.boundMapEndResizeHandler) { document.removeEventListener('mouseup', this.boundMapEndResizeHandler); window.removeEventListener('blur', this.boundMapEndResizeHandler); this.boundMapEndResizeHandler = null; } if (this.boundMapWidthResizeMoveHandler) { document.removeEventListener('mousemove', this.boundMapWidthResizeMoveHandler); this.boundMapWidthResizeMoveHandler = null; } if (this.boundMapWidthEndResizeHandler) { document.removeEventListener('mouseup', this.boundMapWidthEndResizeHandler); window.removeEventListener('blur', this.boundMapWidthEndResizeHandler); this.boundMapWidthEndResizeHandler = null; } if (this.boundMapResizeVisChangeHandler) { document.removeEventListener('visibilitychange', this.boundMapResizeVisChangeHandler); this.boundMapResizeVisChangeHandler = null; } if (this.boundMapFullscreenEscHandler) { document.removeEventListener('keydown', this.boundMapFullscreenEscHandler); this.boundMapFullscreenEscHandler = null; } if (this.boundSearchKeyHandler) { document.removeEventListener('keydown', this.boundSearchKeyHandler); this.boundSearchKeyHandler = null; } if (this.boundMobileMenuKeyHandler) { document.removeEventListener('keydown', this.boundMobileMenuKeyHandler); this.boundMobileMenuKeyHandler = null; } if (this.boundPanelCloseHandler) { this.ctx.container.removeEventListener('wm:panel-close', this.boundPanelCloseHandler); this.boundPanelCloseHandler = null; } if (this.boundWidgetModifyHandler) { this.ctx.container.removeEventListener('wm:widget-modify', this.boundWidgetModifyHandler); this.boundWidgetModifyHandler = null; } if (this.boundUndoHandler) { document.removeEventListener('keydown', this.boundUndoHandler); this.boundUndoHandler = null; } if (this.boundNotifyForCountryHandler) { window.removeEventListener( WM_OPEN_NOTIFICATIONS_FOR_COUNTRY, this.boundNotifyForCountryHandler, ); this.boundNotifyForCountryHandler = null; } this.closeMissionPresetPopover(); if (this.missionDataRefreshTimer) { window.clearTimeout(this.missionDataRefreshTimer); this.missionDataRefreshTimer = null; } for (const unsub of this.proGateUnsubscribers) unsub(); this.proGateUnsubscribers = []; this.ctx.tvMode?.destroy(); this.ctx.tvMode = null; this.ctx.unifiedSettings?.destroy(); this.ctx.unifiedSettings = null; this.ctx.authHeaderWidget?.destroy(); this.ctx.authHeaderWidget = null; this.ctx.authModal?.destroy(); this.ctx.authModal = null; } setupSearchControls(): void { // Wire each button independently and idempotently. setupSearchControls() is // called across several init phases (buttons are injected at different // times); tracking registered IDs in a Set means a button absent at an // early call still gets wired when it appears, instead of being permanently // skipped by a single latched boolean. (#4403 review) const wireSearchButton = (id: string, source: string) => { if (this.registeredSearchButtons.has(id)) return; const el = document.getElementById(id); if (!el) return; el.addEventListener('click', () => { track('search-open', { source }); this.callbacks.openSearch(); }); this.registeredSearchButtons.add(id); }; wireSearchButton('searchBtn', 'desktop'); wireSearchButton('mobileSearchBtn', 'mobile'); wireSearchButton('searchMobileFab', 'fab'); if (!this.boundSearchKeyHandler) { this.boundSearchKeyHandler = (e: KeyboardEvent) => { // !e.shiftKey so Cmd/Ctrl+Shift+K (e.g. Firefox web console) doesn't // also toggle search; .toLowerCase() still tolerates CapsLock. (#4403) if ((e.metaKey || e.ctrlKey) && !e.shiftKey && e.key.toLowerCase() === 'k') { e.preventDefault(); this.callbacks.openSearch({ toggle: true }); } }; document.addEventListener('keydown', this.boundSearchKeyHandler); } } private setupEventListeners(): void { document.getElementById('copyLinkBtn')?.addEventListener('click', async () => { const shareUrl = this.getShareUrl(); if (!shareUrl) return; const button = document.getElementById('copyLinkBtn'); try { await this.copyToClipboard(shareUrl); this.setCopyLinkFeedback(button, 'Copied!'); } catch (error) { console.warn('Failed to copy share link:', error); this.setCopyLinkFeedback(button, 'Copy failed'); } }); document.getElementById('embedLinkBtn')?.addEventListener('click', () => { this.openEmbedDialog(); }); this.initDownloadDropdown(); this.initFooterDownload(); this.boundStorageHandler = (e: StorageEvent) => { if (e.key === STORAGE_KEYS.panels && e.newValue) { try { this.ctx.panelSettings = JSON.parse(e.newValue) as Record; this.applyPanelSettings(); this.ctx.unifiedSettings?.refreshPanelToggles(); } catch (_) { } } if (e.key === STORAGE_KEYS.liveChannels && e.newValue) { const panel = this.ctx.panels['live-news']; if (panel) { if (typeof (panel as unknown as { refreshChannelsFromStorage?: () => void }).refreshChannelsFromStorage === 'function') { (panel as unknown as { refreshChannelsFromStorage: () => void }).refreshChannelsFromStorage(); } } else { this.callbacks.mountLiveNewsIfReady?.(); } } }; window.addEventListener('storage', this.boundStorageHandler); // Handle panel close (X) button clicks this.boundPanelCloseHandler = ((e: CustomEvent<{ panelId: string }>) => { const { panelId } = e.detail; if (panelId.startsWith('cw-')) { if (!window.confirm(t('widgets.confirmDelete'))) return; deleteWidget(panelId); const panel = this.ctx.panels[panelId]; panel?.destroy(); delete this.ctx.panels[panelId]; delete this.ctx.panelSettings[panelId]; saveToStorage(STORAGE_KEYS.panels, this.ctx.panelSettings); panel?.getElement()?.remove(); return; } if (panelId.startsWith('mcp-')) { if (!window.confirm(t('mcp.confirmDelete'))) return; deleteMcpPanel(panelId); const panel = this.ctx.panels[panelId]; panel?.destroy(); delete this.ctx.panels[panelId]; delete this.ctx.panelSettings[panelId]; saveToStorage(STORAGE_KEYS.panels, this.ctx.panelSettings); panel?.getElement()?.remove(); return; } const config = this.ctx.panelSettings[panelId]; if (!config) return; config.enabled = false; // Live-media teardown is handled centrally by applyPanelSettings() below, which // calls stopLiveMediaForClose() on every now-disabled panel. Calling it here too // double-fired the lifecycle hook for live-news / live-webcams. trackPanelToggled(panelId, false); saveToStorage(STORAGE_KEYS.panels, this.ctx.panelSettings); this.applyPanelSettings(); this.ctx.unifiedSettings?.refreshPanelToggles(); // push to undo stack (cap size for memory safety) this.closedPanelStack.push(panelId); if (this.closedPanelStack.length > 20) this.closedPanelStack.shift(); }) as EventListener; this.ctx.container.addEventListener('wm:panel-close', this.boundPanelCloseHandler); this.boundWidgetModifyHandler = ((e: CustomEvent<{ widgetId: string }>) => { const spec = getWidget(e.detail.widgetId); if (!spec) return; void import('@/components/WidgetChatModal').then((m) => m.openWidgetChatModal({ mode: 'modify', existingSpec: spec, onComplete: (updated) => { void saveWidget(updated).then(() => { (this.ctx.panels[updated.id] as CustomWidgetPanel | undefined)?.updateSpec(updated); }).catch((error) => { console.error('[widget-chat] failed to save widget', error); showToast(t('widgets.saveFailed')); }); }, })).catch((err) => console.error('[widget-chat] failed to lazy-load WidgetChatModal', err)); }) as EventListener; this.ctx.container.addEventListener('wm:widget-modify', this.boundWidgetModifyHandler); this.ctx.container.addEventListener('wm:mcp-configure', ((e: CustomEvent<{ panelId: string }>) => { const spec = getMcpPanel(e.detail.panelId); if (!spec) return; void import('@/components/McpConnectModal').then((m) => m.openMcpConnectModal({ existingSpec: spec, onComplete: (updated) => { saveMcpPanel(updated); (this.ctx.panels[updated.id] as McpDataPanel | undefined)?.updateSpec(updated); }, })).catch((err) => console.error('[mcp-connect] failed to lazy-load McpConnectModal', err)); }) as EventListener); // undo via Ctrl/Cmd+Z this.boundUndoHandler = (e: KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'z') { const tag = (e.target as HTMLElement)?.tagName ?? ''; if (tag === 'INPUT' || tag === 'TEXTAREA' || (e.target as HTMLElement)?.isContentEditable) return; e.preventDefault(); this.performUndo(); } }; document.addEventListener('keydown', this.boundUndoHandler); const isLocalDev = location.hostname === 'localhost' || location.hostname === '127.0.0.1'; this.ctx.container.querySelectorAll('.variant-option').forEach(link => { link.addEventListener('click', (e) => { const variant = link.dataset.variant; if (!variant || variant === SITE_VARIANT) return; e.preventDefault(); void this.navigateToVariant(variant, { href: link.href, isLocalDev, }); }); }); const fullscreenBtn = document.getElementById('fullscreenBtn'); if (!this.ctx.isDesktopApp && fullscreenBtn) { fullscreenBtn.addEventListener('click', () => this.toggleFullscreen()); this.boundFullscreenHandler = () => { fullscreenBtn.textContent = document.fullscreenElement ? '\u26F6' : '\u26F6'; fullscreenBtn.classList.toggle('active', !!document.fullscreenElement); this.syncMapAfterLayoutChange(); }; document.addEventListener('fullscreenchange', this.boundFullscreenHandler); } const regionSelect = document.getElementById('regionSelect') as HTMLSelectElement; regionSelect?.addEventListener('change', () => { this.ctx.map?.setView(regionSelect.value as MapView); trackMapViewChange(regionSelect.value); }); this.boundResizeHandler = debounce(() => { this.ctx.map?.setIsResizing(false); this.ctx.map?.render(); }, 150); window.addEventListener('resize', this.boundResizeHandler); this.setupMapResize(); this.setupMapWidthResize(); this.setupMapPin(); this.boundVisibilityHandler = () => { document.body?.classList.toggle('animations-paused', document.hidden); if (this.ctx.isDesktopApp) { this.ctx.map?.setRenderPaused(document.hidden); } if (document.hidden) { this.callbacks.setHiddenSince(Date.now()); mlWorker.unloadOptionalModels(); } else { this.resetIdleTimer(); this.callbacks.flushStaleRefreshes(); } }; document.addEventListener('visibilitychange', this.boundVisibilityHandler); this.boundFocalPointsReadyHandler = () => { this.callbacks.refreshCiiAfterFocalPointsReady?.(); }; window.addEventListener('focal-points-ready', this.boundFocalPointsReadyHandler); this.boundThemeChangedHandler = () => { this.ctx.map?.render(); this.updateMobileMenuThemeItem(); }; window.addEventListener('theme-changed', this.boundThemeChangedHandler); this.setupMobileMenu(); this.setupMissionPresets(); if (this.ctx.isDesktopApp) { if (this.boundDesktopExternalLinkHandler) { document.removeEventListener('click', this.boundDesktopExternalLinkHandler, true); } this.boundDesktopExternalLinkHandler = (e: MouseEvent) => { if (!(e.target instanceof Element)) return; const anchor = e.target.closest('a[href]') as HTMLAnchorElement | null; if (!anchor) return; const href = anchor.href; if (!href || href.startsWith('javascript:') || href === '#' || href.startsWith('#')) return; // Only handle valid http(s) URLs let url: URL; try { url = new URL(href, window.location.href); } catch { // Malformed URL, let browser handle return; } if (url.origin === window.location.origin) return; if (!/^https?:$/.test(url.protocol)) return; // Only allow http(s) links e.preventDefault(); e.stopPropagation(); void invokeTauri('open_url', { url: url.toString() }).catch(() => { window.open(url.toString(), '_blank', 'noopener,noreferrer'); }); }; document.addEventListener('click', this.boundDesktopExternalLinkHandler, true); } } private setupMobileMenu(): void { const hamburger = document.getElementById('hamburgerBtn'); const overlay = document.getElementById('mobileMenuOverlay'); const menu = document.getElementById('mobileMenu'); const closeBtn = document.getElementById('mobileMenuClose'); if (!hamburger || !overlay || !menu || !closeBtn) return; hamburger.addEventListener('click', () => this.openMobileMenu()); overlay.addEventListener('click', () => this.closeMobileMenu()); closeBtn.addEventListener('click', () => this.closeMobileMenu()); const isLocalDev = location.hostname === 'localhost' || location.hostname === '127.0.0.1'; menu.querySelectorAll('.mobile-menu-variant').forEach(btn => { btn.addEventListener('click', () => { const variant = btn.dataset.variant; if (!variant || variant === SITE_VARIANT) return; void this.navigateToVariant(variant, { isLocalDev }); }); }); document.getElementById('mobileMenuRegion')?.addEventListener('click', () => { this.closeMobileMenu(); this.openRegionSheet(); }); document.getElementById('mobileMenuSettings')?.addEventListener('click', () => { this.closeMobileMenu(); this.ctx.unifiedSettings?.open(); }); document.getElementById('mobileMenuTheme')?.addEventListener('click', () => { this.closeMobileMenu(); const next = getCurrentTheme() === 'dark' ? 'light' : 'dark'; setTheme(next); trackThemeChanged(next); }); const sheetBackdrop = document.getElementById('regionSheetBackdrop'); sheetBackdrop?.addEventListener('click', () => this.closeRegionSheet()); const sheet = document.getElementById('regionBottomSheet'); sheet?.querySelectorAll('.region-sheet-option').forEach(opt => { opt.addEventListener('click', () => { const region = opt.dataset.region; if (!region) return; this.ctx.map?.setView(region as MapView); trackMapViewChange(region); const regionSelect = document.getElementById('regionSelect') as HTMLSelectElement; if (regionSelect) regionSelect.value = region; sheet.querySelectorAll('.region-sheet-option').forEach(o => { o.classList.toggle('active', o === opt); const check = o.querySelector('.region-sheet-check'); if (check) check.textContent = o === opt ? '✓' : ''; }); const menuRegionLabel = document.getElementById('mobileMenuRegion')?.querySelector('.mobile-menu-item-label'); if (menuRegionLabel) menuRegionLabel.textContent = opt.querySelector('span')?.textContent ?? ''; this.closeRegionSheet(); }); }); this.boundMobileMenuKeyHandler = (e: KeyboardEvent) => { if (e.key === 'Escape') { if (sheet?.classList.contains('open')) { this.closeRegionSheet(); } else if (menu.classList.contains('open')) { this.closeMobileMenu(); } } }; document.addEventListener('keydown', this.boundMobileMenuKeyHandler); } private setupMissionPresets(): void { this.renderMissionPresetControl(); document.getElementById('mobileMenuMission')?.addEventListener('click', () => { this.closeMobileMenu(); this.openMissionPresetPopover(document.getElementById('hamburgerBtn'), true); }); const shouldPrompt = !this.ctx.isMobile && !window.location.search && !loadStoredMissionPreset() && !isMissionPresetPromptDismissed(); if (shouldPrompt) { // Defer the onboarding auto-open to browser idle after first paint so it // never competes with load or first-interaction work. This replaced a // fixed 700ms timeout that forced layout reads (getBoundingClientRect + // offsetHeight) on the post-load path. Re-check state at fire time since // the idle wait can outlast an early user choice. scheduleAfterFirstPaint(() => { if (this.ctx.isDestroyed) return; if (loadStoredMissionPreset() || isMissionPresetPromptDismissed()) return; this.openMissionPresetPopover(document.getElementById('missionPresetBtn'), false); }); } } private renderMissionPresetControl(): void { const mount = document.getElementById('missionPresetMount'); if (!mount) return; const active = loadStoredMissionPreset(); const label = active?.shortLabel ?? 'Mission'; const icon = active?.icon ?? '◎'; const activeClass = active ? ' mission-preset-button--active' : ''; const suggestedClass = !active && !isMissionPresetPromptDismissed() ? ' mission-preset-button--suggested' : ''; setTrustedHtml(mount, trustedHtml(` `, 'Mission preset control renders static preset metadata with escaped values')); document.getElementById('missionPresetBtn')?.addEventListener('click', () => { this.toggleMissionPresetPopover(document.getElementById('missionPresetBtn'), false); }); this.updateMobileMissionLabel(active); } private updateMobileMissionLabel(active: MissionPreset | null = loadStoredMissionPreset()): void { const item = document.getElementById('mobileMenuMission'); const label = item?.querySelector('.mobile-menu-item-label'); if (label) label.textContent = active ? `Mission: ${active.shortLabel}` : 'Mission'; } private toggleMissionPresetPopover(anchor: HTMLElement | null, mobile: boolean): void { if (this.missionPresetPopover) { this.closeMissionPresetPopover(); return; } this.openMissionPresetPopover(anchor, mobile); } private openMissionPresetPopover(anchor: HTMLElement | null, mobile: boolean): void { this.closeMissionPresetPopover(); const active = loadStoredMissionPreset(); const popover = document.createElement('div'); popover.className = `mission-preset-popover${mobile ? ' mission-preset-popover--mobile' : ''}`; popover.setAttribute('role', 'dialog'); popover.setAttribute('aria-label', 'Mission presets'); popover.tabIndex = -1; const cards = MISSION_PRESETS.map((preset) => { const selected = active?.id === preset.id; return ` `; }).join(''); setTrustedHtml(popover, trustedHtml(`
Mission ${escapeHtml(active?.label ?? 'Choose Workspace')}
${cards}
`, 'Mission preset popover renders static preset metadata with escaped values')); document.body.appendChild(popover); this.missionPresetPopover = popover; document.getElementById('missionPresetBtn')?.setAttribute('aria-expanded', 'true'); if (!mobile && anchor) { const rect = anchor.getBoundingClientRect(); const width = 360; const left = Math.min(Math.max(12, rect.left), Math.max(12, window.innerWidth - width - 12)); const height = Math.min(popover.offsetHeight || 620, Math.max(120, window.innerHeight - 24)); const top = Math.min( Math.max(12, rect.bottom + 8), Math.max(12, window.innerHeight - height - 12), ); popover.style.left = `${left}px`; popover.style.top = `${top}px`; } popover.querySelector('[data-mission-close]')?.addEventListener('click', () => { dismissMissionPresetPrompt(); this.renderMissionPresetControl(); this.closeMissionPresetPopover(); }); popover.querySelector('[data-mission-reset]')?.addEventListener('click', () => { this.resetMissionPreset(); }); this.boundMissionKeydownHandler = (e: KeyboardEvent) => { if (e.key !== 'Escape') return; e.stopPropagation(); dismissMissionPresetPrompt(); this.renderMissionPresetControl(); this.closeMissionPresetPopover(); }; popover.addEventListener('keydown', this.boundMissionKeydownHandler); popover.querySelectorAll('[data-mission-id]').forEach((button) => { button.addEventListener('click', () => { const presetId = button.dataset.missionId as MissionPresetId | undefined; if (presetId) this.applyMissionPreset(presetId); }); }); this.boundMissionOutsideHandler = (e: MouseEvent) => { const target = e.target as Node | null; if (!target) return; if (popover.contains(target) || anchor?.contains(target)) return; dismissMissionPresetPrompt(); this.renderMissionPresetControl(); this.closeMissionPresetPopover(); }; window.setTimeout(() => { if (this.missionPresetPopover === popover) { popover.focus({ preventScroll: true }); } if (this.boundMissionOutsideHandler) { document.addEventListener('click', this.boundMissionOutsideHandler); } }, 0); } private closeMissionPresetPopover(): void { if (this.boundMissionOutsideHandler) { document.removeEventListener('click', this.boundMissionOutsideHandler); this.boundMissionOutsideHandler = null; } if (this.boundMissionKeydownHandler && this.missionPresetPopover) { this.missionPresetPopover.removeEventListener('keydown', this.boundMissionKeydownHandler); this.boundMissionKeydownHandler = null; } this.missionPresetPopover?.remove(); this.missionPresetPopover = null; document.getElementById('missionPresetBtn')?.setAttribute('aria-expanded', 'false'); } private getMissionDefaultLayers(): MapLayers { return this.ctx.isMobile ? MOBILE_DEFAULT_MAP_LAYERS : DEFAULT_MAP_LAYERS; } private filterMissionLayersForCurrentRenderer(layers: MapLayers): MapLayers { const renderer = this.ctx.map?.isGlobeMode?.() ? 'globe' : 'flat'; const isDeckGLActive = this.ctx.map?.isDeckGLActive?.() ?? !this.ctx.isMobile; return this.filterMissionLayersForAvailableServices( filterMissionLayersForRenderer(layers, renderer, isDeckGLActive, this.getMissionDefaultLayers()), ); } private filterMissionLayersForAvailableServices(layers: MapLayers): MapLayers { if (layers.ais && !isAisConfigured()) { return { ...layers, ais: false }; } return layers; } private persistMissionPanelOrder(panelOrder: string[]): void { saveToStorage(this.ctx.PANEL_ORDER_KEY, panelOrder); saveToStorage(this.ctx.PANEL_ORDER_KEY + '-bottom-set', []); try { localStorage.removeItem(this.ctx.PANEL_ORDER_KEY + '-bottom'); } catch { // Storage can be unavailable; the current session still applies the in-memory order. } } private scheduleMissionDataRefresh(): void { if (this.missionDataRefreshTimer) { window.clearTimeout(this.missionDataRefreshTimer); } this.missionDataRefreshTimer = window.setTimeout(() => { this.missionDataRefreshTimer = null; void this.callbacks.loadAllData(); }, 150); } private runMapLayerSideEffects(layer: keyof MapLayers, enabled: boolean): void { const sourceIds = LAYER_TO_SOURCE[layer]; if (sourceIds) { for (const sourceId of sourceIds) { dataFreshness.setEnabled(sourceId, enabled); } } if (layer === 'ais') { if (enabled) { this.ctx.map?.setLayerLoading('ais', true); initAisStream(); this.callbacks.waitForAisData(); } else { disconnectAisStream(); } return; } if (layer === 'flights') { const airlineIntel = this.ctx.panels['airline-intel'] as AirlineIntelPanel | undefined; airlineIntel?.setLiveMode(enabled); } if (enabled) { this.callbacks.loadDataForLayer(layer); } else { this.callbacks.stopLayerActivity?.(layer as keyof MapLayers); } } private applyMissionMapLayerTransitions(previousLayers: MapLayers, nextLayers: MapLayers): void { const layerKeys = new Set([ ...Object.keys(previousLayers), ...Object.keys(nextLayers), ] as Array); for (const layer of layerKeys) { const enabled = !!nextLayers[layer]; if (!!previousLayers[layer] === enabled) continue; trackMapLayerToggle(layer, enabled, 'programmatic'); this.runMapLayerSideEffects(layer, enabled); } } private applyMissionPreset(presetId: MissionPresetId): void { const applied = applyMissionPresetToState( presetId, this.ctx.panelSettings, this.getMissionDefaultLayers(), SITE_VARIANT, ); const mapLayers = this.filterMissionLayersForCurrentRenderer(applied.mapLayers); const previousMapLayers = { ...this.ctx.mapLayers }; this.ctx.panelSettings = applied.panelSettings; this.ctx.mapLayers = mapLayers; saveToStorage(STORAGE_KEYS.panels, applied.panelSettings); saveToStorage(STORAGE_KEYS.mapLayers, mapLayers); this.persistMissionPanelOrder(applied.panelOrder); saveMissionPreset(applied.preset.id); this.applyPanelSettings(); this.callbacks.applySavedPanelOrder?.(applied.panelOrder); this.ctx.unifiedSettings?.refreshPanelToggles(); this.ctx.map?.setLayers(mapLayers); this.applyMissionMapLayerTransitions(previousMapLayers, mapLayers); this.ctx.map?.setView(applied.preset.view as MapView, applied.preset.zoom); this.ctx.map?.setTimeRange(applied.preset.timeRange); this.callbacks.mountLiveNewsIfReady?.(); this.callbacks.syncDataFreshnessWithLayers(); this.scheduleMissionDataRefresh(); this.syncUrlState(); showToast(`Mission preset applied: ${applied.preset.label}`); this.renderMissionPresetControl(); this.closeMissionPresetPopover(); } private resetMissionPreset(): void { const reset = resetMissionPresetState( this.ctx.panelSettings, this.getMissionDefaultLayers(), SITE_VARIANT, ); const mapLayers = this.filterMissionLayersForCurrentRenderer(reset.mapLayers); const previousMapLayers = { ...this.ctx.mapLayers }; this.ctx.panelSettings = reset.panelSettings; this.ctx.mapLayers = mapLayers; saveToStorage(STORAGE_KEYS.panels, reset.panelSettings); saveToStorage(STORAGE_KEYS.mapLayers, mapLayers); this.persistMissionPanelOrder(reset.panelOrder); clearMissionPreset(); this.applyPanelSettings(); this.callbacks.applySavedPanelOrder?.(reset.panelOrder); this.ctx.unifiedSettings?.refreshPanelToggles(); this.ctx.map?.setLayers(mapLayers); this.applyMissionMapLayerTransitions(previousMapLayers, mapLayers); this.ctx.map?.setView('global'); this.ctx.map?.setTimeRange('7d'); this.callbacks.mountLiveNewsIfReady?.(); this.callbacks.syncDataFreshnessWithLayers(); this.scheduleMissionDataRefresh(); this.syncUrlState(); showToast('Mission preset reset'); this.renderMissionPresetControl(); this.closeMissionPresetPopover(); } private openMobileMenu(): void { const overlay = document.getElementById('mobileMenuOverlay'); const menu = document.getElementById('mobileMenu'); if (!overlay || !menu) return; overlay.classList.add('open'); requestAnimationFrame(() => menu.classList.add('open')); document.body.style.overflow = 'hidden'; } private closeMobileMenu(): void { const overlay = document.getElementById('mobileMenuOverlay'); const menu = document.getElementById('mobileMenu'); if (!overlay || !menu) return; menu.classList.remove('open'); overlay.classList.remove('open'); const sheetOpen = document.getElementById('regionBottomSheet')?.classList.contains('open'); if (!sheetOpen) document.body.style.overflow = ''; } private openRegionSheet(): void { const backdrop = document.getElementById('regionSheetBackdrop'); const sheet = document.getElementById('regionBottomSheet'); if (!backdrop || !sheet) return; backdrop.classList.add('open'); requestAnimationFrame(() => sheet.classList.add('open')); document.body.style.overflow = 'hidden'; } private closeRegionSheet(): void { const backdrop = document.getElementById('regionSheetBackdrop'); const sheet = document.getElementById('regionBottomSheet'); if (!backdrop || !sheet) return; sheet.classList.remove('open'); backdrop.classList.remove('open'); document.body.style.overflow = ''; } private setupIdleDetection(): void { this.boundIdleResetHandler = () => { if (this.ctx.isIdle) { this.ctx.isIdle = false; document.body?.classList.remove('animations-paused'); } this.resetIdleTimer(); }; ['mousedown', 'keydown', 'scroll', 'touchstart', 'mousemove'].forEach(event => { document.addEventListener(event, this.boundIdleResetHandler!, { passive: true }); }); this.resetIdleTimer(); } resetIdleTimer(): void { if (this.idleTimeoutId) { clearTimeout(this.idleTimeoutId); } this.idleTimeoutId = setTimeout(() => { if (!document.hidden) { this.ctx.isIdle = true; document.body?.classList.add('animations-paused'); console.log('[App] User idle - pausing animations to save resources'); } }, this.idlePauseMs); } setupUrlStateSync(): void { if (!this.ctx.map) return; this.ctx.map.onStateChanged(() => { this.debouncedUrlSync(); const regionSelect = document.getElementById('regionSelect') as HTMLSelectElement; if (regionSelect && this.ctx.map) { const state = this.ctx.map.getState(); if (regionSelect.value !== state.view) { regionSelect.value = state.view; } } this.debouncedWebcamReload(); }); // Skip the immediate sync only when applyInitialUrlState() will start an // async flyTo that makes getCenter() return stale intermediate coordinates. // Two cases qualify: // (a) lat+lon pair → setCenter() flyTo; both must be present since // applyInitialUrlState only calls setCenter when both exist. // (b) bare zoom → setZoom() animated zoom (no view preset). // // view is intentionally excluded: all renderers set this.state.view // synchronously at the top of setView(), so the debounced read is always // correct regardless of renderer. GlobeMap.onStateChanged is a no-op and // SVG Map fires emitStateChange before the listener is installed — neither // can rely on a later onStateChanged to drive the URL write, so they must // use the immediate debounce path. const { view, lat, lon, zoom, chokepoint } = this.ctx.initialUrlState ?? {}; const urlHasAsyncFlyTo = (lat !== undefined && lon !== undefined) || // setCenter → flyTo (requires both) (!view && zoom !== undefined) || // zoom-only → setZoom animated chokepoint !== undefined; // chokepoint opens after renderer readiness if (!urlHasAsyncFlyTo) { this.debouncedUrlSync(); } } syncUrlState(): void { this.debouncedUrlSync(); } applyMapLayerChange(layer: keyof MapLayers, enabled: boolean, source: 'user' | 'programmatic'): void { console.log(`[App.onLayerChange] ${layer}: ${enabled} (${source})`); trackMapLayerToggle(layer, enabled, source); this.ctx.mapLayers[layer] = enabled; saveToStorage(STORAGE_KEYS.mapLayers, this.ctx.mapLayers); this.syncUrlState(); const sourceIds = LAYER_TO_SOURCE[layer]; if (sourceIds) { for (const sourceId of sourceIds) { dataFreshness.setEnabled(sourceId, enabled); } } if (layer === 'ais') { if (enabled) { this.ctx.map?.setLayerLoading('ais', true); initAisStream(); this.callbacks.waitForAisData(); } else { disconnectAisStream(); } return; } if (layer === 'flights') { const airlineIntel = this.ctx.panels['airline-intel'] as AirlineIntelPanel | undefined; airlineIntel?.setLiveMode(enabled); } if (enabled) { this.callbacks.loadDataForLayer(layer); } else { this.callbacks.stopLayerActivity?.(layer); } } getShareUrl(): string | null { if (!this.ctx.map) return null; const state = this.ctx.map.getState(); const center = this.ctx.map.getCenter(); const baseUrl = `${window.location.origin}${window.location.pathname}`; const briefPage = this.ctx.countryBriefPage; const isCountryVisible = briefPage?.isVisible() ?? false; return buildMapUrl(baseUrl, { view: state.view, zoom: state.zoom, center, timeRange: state.timeRange, layers: state.layers, country: isCountryVisible ? (briefPage?.getCode() ?? undefined) : undefined, expanded: isCountryVisible && briefPage?.getIsMaximized?.() ? true : undefined, chokepoint: !isCountryVisible ? (this.ctx.activeChokepoint ?? undefined) : undefined, }); } private getEmbedUrl(): string | null { if (!this.ctx.map) return null; const state = this.ctx.map.getState(); return buildEmbedMapUrl(`${window.location.origin}/embed`, { layers: state.layers, center: this.ctx.map.getCenter(), zoom: state.zoom, theme: getCurrentTheme(), variant: SITE_VARIANT as EmbedVariant, }); } private openEmbedDialog(): void { const embedUrl = this.getEmbedUrl(); if (!embedUrl) return; const snippet = buildEmbedIframeSnippet(embedUrl); this.closeEmbedDialog(); const overlay = document.createElement('div'); overlay.className = 'embed-modal-overlay active'; overlay.id = 'embedModalOverlay'; overlay.setAttribute('role', 'presentation'); const dialog = document.createElement('div'); dialog.className = 'embed-modal'; dialog.setAttribute('role', 'dialog'); dialog.setAttribute('aria-modal', 'true'); dialog.setAttribute('aria-labelledby', 'embedModalTitle'); const header = document.createElement('div'); header.className = 'embed-modal-header'; const title = document.createElement('h2'); title.id = 'embedModalTitle'; title.textContent = 'Embed this map'; const closeButton = document.createElement('button'); closeButton.className = 'embed-modal-close'; closeButton.type = 'button'; closeButton.setAttribute('aria-label', 'Close embed dialog'); closeButton.textContent = 'x'; header.append(title, closeButton); const preview = document.createElement('iframe'); preview.className = 'embed-preview-frame'; preview.title = 'World Monitor live map preview'; preview.loading = 'lazy'; preview.referrerPolicy = 'strict-origin-when-cross-origin'; preview.src = embedUrl; const label = document.createElement('label'); label.className = 'embed-snippet-label'; label.htmlFor = 'embedSnippetTextarea'; label.textContent = 'Iframe snippet'; const textarea = document.createElement('textarea'); textarea.className = 'embed-snippet-textarea'; textarea.id = 'embedSnippetTextarea'; textarea.readOnly = true; textarea.value = snippet; const actions = document.createElement('div'); actions.className = 'embed-modal-actions'; const copyButton = document.createElement('button'); copyButton.className = 'embed-copy-btn'; copyButton.type = 'button'; copyButton.textContent = 'Copy snippet'; actions.append(copyButton); dialog.append(header, preview, label, textarea, actions); overlay.appendChild(dialog); document.body.appendChild(overlay); closeButton.addEventListener('click', () => this.closeEmbedDialog()); overlay.addEventListener('click', (event) => { if (event.target === overlay) this.closeEmbedDialog(); }); copyButton.addEventListener('click', async () => { try { await this.copyToClipboard(snippet); copyButton.textContent = 'Copied!'; } catch (error) { console.warn('Failed to copy embed snippet:', error); copyButton.textContent = 'Copy failed'; } }); this.boundEmbedModalKeydownHandler = (event: KeyboardEvent) => { if (event.key === 'Escape') this.closeEmbedDialog(); }; document.addEventListener('keydown', this.boundEmbedModalKeydownHandler); textarea.focus(); textarea.select(); } private closeEmbedDialog(): void { document.getElementById('embedModalOverlay')?.remove(); if (this.boundEmbedModalKeydownHandler) { document.removeEventListener('keydown', this.boundEmbedModalKeydownHandler); this.boundEmbedModalKeydownHandler = null; } } private async copyToClipboard(text: string): Promise { if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(text); return; } const textarea = document.createElement('textarea'); textarea.value = text; textarea.style.position = 'fixed'; textarea.style.opacity = '0'; document.body.appendChild(textarea); textarea.select(); document.execCommand('copy'); document.body.removeChild(textarea); } private platformLabel(p: Platform): string { switch (p) { case 'macos-arm64': return '\uF8FF Silicon'; case 'macos-x64': return '\uF8FF Intel'; case 'macos': return '\uF8FF macOS'; case 'windows': return 'Windows'; case 'linux': return 'Linux'; default: return t('header.downloadApp'); } } private initDownloadDropdown(): void { const btn = document.getElementById('downloadBtn'); const dropdown = document.getElementById('downloadDropdown'); const label = document.getElementById('downloadBtnLabel'); if (!btn || !dropdown) return; const platform = detectPlatform(); if (label) label.textContent = this.platformLabel(platform); const primary = buttonsForPlatform(platform); const all = allButtons(); const others = all.filter(b => !primary.some(p => p.href === b.href)); const renderDropdown = () => { const primaryHtml = primary.map(b => `${b.label}` ).join(''); const othersHtml = others.map(b => `${b.label}` ).join(''); setTrustedHtml(dropdown, trustedHtml(`
${t('modals.downloadBanner.description')}
${primaryHtml}
${others.length ? `
${othersHtml}
` : ''} `, "legacy direct innerHTML migration")); dropdown.querySelectorAll('.dl-dd-btn').forEach(a => { a.addEventListener('click', (e) => { e.preventDefault(); const plat = new URL(a.href, location.origin).searchParams.get('platform') || 'unknown'; trackDownloadClicked(plat); window.open(a.href, '_blank', 'noopener,noreferrer'); dropdown.classList.remove('open'); }); }); const toggle = dropdown.querySelector('#dlDdToggle'); const othersEl = dropdown.querySelector('#dlDdOthers') as HTMLElement | null; if (toggle && othersEl) { toggle.addEventListener('click', () => { const showing = othersEl.classList.toggle('show'); toggle.textContent = showing ? t('modals.downloadBanner.showLess') : t('modals.downloadBanner.showAllPlatforms'); }); } }; renderDropdown(); btn.addEventListener('click', (e) => { e.stopPropagation(); dropdown.classList.toggle('open'); }); this.boundDropdownClickHandler = (e: MouseEvent) => { if (!dropdown.contains(e.target as Node) && !btn.contains(e.target as Node)) { dropdown.classList.remove('open'); } }; document.addEventListener('click', this.boundDropdownClickHandler); this.boundDropdownKeydownHandler = (e: KeyboardEvent) => { if (e.key === 'Escape') dropdown.classList.remove('open'); }; document.addEventListener('keydown', this.boundDropdownKeydownHandler); } private initFooterDownload(): void { const mount = document.getElementById('footerDownloadMount'); if (!mount) return; const platform = detectPlatform(); const primary = buttonsForPlatform(platform); const btn = primary[0]; if (!btn) return; const a = document.createElement('a'); a.href = btn.href; a.textContent = t('header.downloadApp'); a.className = 'site-footer-download-link'; a.target = '_blank'; a.rel = 'noopener'; a.addEventListener('click', (e) => { e.preventDefault(); const plat = new URL(btn.href, location.origin).searchParams.get('platform') || 'unknown'; trackDownloadClicked(plat); window.open(btn.href, '_blank', 'noopener,noreferrer'); }); mount.replaceWith(a); } private setCopyLinkFeedback(button: HTMLElement | null, message: string): void { if (!button) return; const originalText = button.textContent ?? ''; button.textContent = message; button.classList.add('copied'); window.setTimeout(() => { button.textContent = originalText; button.classList.remove('copied'); }, 1500); } private getFullscreenDocument(): Document & { webkitFullscreenElement?: Element | null; webkitExitFullscreen?: () => Promise | void; } { return document as Document & { webkitFullscreenElement?: Element | null; webkitExitFullscreen?: () => Promise | void; }; } private syncMapAfterLayoutChange(delayMs = 320): void { const sync = () => { this.ctx.map?.setIsResizing(false); this.ctx.map?.resize(); }; requestAnimationFrame(sync); window.setTimeout(sync, delayMs); } private async exitFullscreenForNavigation(): Promise { const fullscreenDocument = this.getFullscreenDocument(); if (!fullscreenDocument.fullscreenElement && !fullscreenDocument.webkitFullscreenElement) return; try { if (typeof fullscreenDocument.exitFullscreen === 'function') { await fullscreenDocument.exitFullscreen(); return; } await fullscreenDocument.webkitExitFullscreen?.(); } catch { /* proceed with navigation regardless */ } } private async navigateToVariant( variant: string, options: { href?: string; isLocalDev: boolean }, ): Promise { trackVariantSwitch(SITE_VARIANT, variant); await this.exitFullscreenForNavigation(); if (this.ctx.isDesktopApp || options.isLocalDev) { writeStorageValue('worldmonitor-variant', variant); window.location.reload(); return; } const target = options.href || VARIANT_META[variant]?.url; if (!target) return; try { const parsed = new URL(target, window.location.href); if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return; window.location.href = parsed.toString(); } catch { return; } } toggleFullscreen(): void { const fullscreenDocument = this.getFullscreenDocument(); if (fullscreenDocument.fullscreenElement || fullscreenDocument.webkitFullscreenElement) { try { const exitResult = typeof fullscreenDocument.exitFullscreen === 'function' ? fullscreenDocument.exitFullscreen() : fullscreenDocument.webkitExitFullscreen?.(); void Promise.resolve(exitResult).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 { } } } } private updateMobileMenuThemeItem(): void { const btn = document.getElementById('mobileMenuTheme'); if (!btn) return; const isDark = getCurrentTheme() === 'dark'; const icon = btn.querySelector('.mobile-menu-item-icon'); const label = btn.querySelector('.mobile-menu-item-label'); if (icon) icon.textContent = isDark ? '☀️' : '🌙'; if (label) label.textContent = isDark ? 'Light Mode' : 'Dark Mode'; } startHeaderClock(): void { const el = document.getElementById('headerClock'); if (!el) return; const tick = () => { el.textContent = new Date().toUTCString().replace('GMT', 'UTC'); }; tick(); this.clockIntervalId = setInterval(tick, 1000); } setupStatusPanel(): void { void import('@/components/StatusPanel') .then(({ StatusPanel }) => { if (this.ctx.isDestroyed) return; this.ctx.statusPanel = new StatusPanel(); }) .catch((err) => { console.error('[status-panel] failed to lazy-load StatusPanel', err); }); } setupPizzIntIndicator(): void { if (SITE_VARIANT !== 'full') return; this.ctx.pizzintIndicator = new PizzIntIndicator(); const headerLeft = this.ctx.container.querySelector('.header-left'); if (headerLeft) { headerLeft.appendChild(this.ctx.pizzintIndicator.getElement()); } } setupLlmStatusIndicator(): void { if (!isDesktopRuntime()) return; this.ctx.llmStatusIndicator = new LlmStatusIndicator(); const headerRight = this.ctx.container.querySelector('.header-right'); if (headerRight) { headerRight.appendChild(this.ctx.llmStatusIndicator.getElement()); } } setupExportPanel(): void { const getExportData = () => { const allCards = this.ctx.correlationEngine?.getAllCards() ?? []; const disabledCount = this.ctx.disabledSources.size; return { meta: { exportedAt: new Date().toISOString(), note: disabledCount > 0 ? `Export reflects currently enabled sources only. ${disabledCount} source(s) are disabled and not included.` : 'Export reflects all active sources.', }, timestamp: Date.now(), news: this.ctx.allNews, newsClusters: this.ctx.latestClusters.length > 0 ? this.ctx.latestClusters : undefined, newsByCategory: this.ctx.newsByCategory, markets: this.ctx.latestMarkets, predictions: this.ctx.latestPredictions, intelligence: this.ctx.intelligenceCache, cyberThreats: this.ctx.cyberThreatsCache ?? undefined, gpsJamming: getCachedGpsInterference() ?? undefined, convergenceCards: allCards.map(({ assessment: _a, ...card }) => card), monitors: this.ctx.monitors.length > 0 ? this.ctx.monitors : undefined, }; }; const attachExportPanel = (panel: NonNullable): void => { const el = panel.getElement(); if (el.parentElement) return; const headerRight = this.ctx.container.querySelector('.header-right'); if (headerRight) { headerRight.insertBefore(el, headerRight.firstChild); } }; let currentExportFormats: readonly DataExportFormat[] = []; const ensureExportPanel = (): Promise> => { if (this.ctx.exportPanel) { this.ctx.exportPanel.setAvailableFormats(currentExportFormats); attachExportPanel(this.ctx.exportPanel); return Promise.resolve(this.ctx.exportPanel); } if (this.exportPanelLoad) return this.exportPanelLoad; this.exportPanelLoad = import('@/utils/export') .then(({ ExportPanel }) => { if (this.ctx.isDestroyed) { throw new Error('EventHandlerManager destroyed before export panel loaded'); } const panel = new ExportPanel(getExportData, currentExportFormats); this.ctx.exportPanel = panel; attachExportPanel(panel); return panel; }) .catch((err) => { this.exportPanelLoad = null; throw err; }); return this.exportPanelLoad; }; // --- Data-export gate (plan 2026-07-25-001, U5) ------------------------- // Replaces the old Clerk-role check plus display:none toggle. The `role` // field is written by nothing in our webhook pipeline, so that check hid // the export button from every paying subscriber. The control is now // visible to everyone and only its MENU changes: format rows when // entitled, a single locked row (reason + CTA) otherwise. let lockedControl: ExportGateControl | null = null; let lockedReason: PanelGateReason | null = null; let isUnlocked = false; // Created up front and empty: an aria-live region only announces content // injected AFTER it is in the accessibility tree. const liveRegion = h('span', { className: 'wm-visually-hidden', role: 'status' }); liveRegion.setAttribute('aria-live', 'polite'); const initialHeaderRight = this.ctx.container.querySelector('.header-right'); initialHeaderRight?.appendChild(liveRegion); const removeLockedControl = (): void => { lockedControl?.destroy(); lockedControl = null; lockedReason = null; }; const showLocked = (reason: PanelGateReason): void => { isUnlocked = false; const panelEl = this.ctx.exportPanel?.getElement(); if (panelEl) panelEl.style.display = 'none'; lockedReason = reason; if (lockedControl) { lockedControl.update(reason); return; } lockedControl = new ExportGateControl({ reason, onOpen: () => trackGateHit('export'), onAction: () => { if (lockedReason === null) return; resolveGateAction(lockedReason, { openAuthModal: () => this.ctx.authModal?.open() })(); }, }); const headerRight = this.ctx.container.querySelector('.header-right'); headerRight?.insertBefore(lockedControl.getElement(), headerRight.firstChild); }; const unlock = (formats: readonly DataExportFormat[]): void => { currentExportFormats = formats; const wasLocked = lockedControl !== null; // Change-detection guard: gating re-fires on every auth AND entitlement // emission, most with an unchanged verdict — skip the re-import/DOM // write when already unlocked (same pattern as Panel.showGatedCta's // repeat-verdict skip). if (!wasLocked && isUnlocked) { this.ctx.exportPanel?.setAvailableFormats(currentExportFormats); return; } isUnlocked = true; removeLockedControl(); void ensureExportPanel() .then((panel) => { if (this.ctx.isDestroyed) return; // The verdict can flip back while the chunk is in flight (sign-out // mid-load); the locked control winning is the safe resolution. if (lockedControl) { panel.getElement().style.display = 'none'; return; } panel.setAvailableFormats(currentExportFormats); panel.getElement().style.display = ''; if (wasLocked) liveRegion.textContent = t('components.exportGate.unlockedAnnouncement'); }) .catch((err) => { // Allow the next emission to retry the import — the guard above // must not latch an unlocked state the chunk never delivered. isUnlocked = false; console.warn('[export-panel] Failed to lazy-load ExportPanel:', err); }); }; const applyGate = (): void => { if (this.ctx.isDestroyed) return; const authState = getAuthState(); const verdict = evaluateExportGate(authState); if (verdict.locked) { showLocked(exportLockToGateReason(verdict.reason)); return; } // Only a would-be-locked user pays for the catalog probe; the gate stays // inactive (export available) until it proves Pro Business is // purchasable, so the takeaway and the tier flip together (R10). if (verdict.pendingActivation) { void primeExportGateActivation().then((active) => { if (active) applyGate(); }); } unlock(evaluateAvailableExportFormats(authState)); }; applyGate(); // BOTH subscriptions: auth alone misses the entitlement snapshot landing // after sign-in (documented at src/app/panel-layout.ts:2470-2485), which is // exactly the post-checkout unlock path. this.proGateUnsubscribers.push(subscribeAuthState(() => applyGate())); this.proGateUnsubscribers.push(onEntitlementChange(() => applyGate())); this.proGateUnsubscribers.push(() => { removeLockedControl(); liveRegion.remove(); }); } setupUnifiedSettings(): void { this.ctx.unifiedSettings = new LazyUnifiedSettings({ getPanelSettings: () => this.ctx.panelSettings, savePanelSettings: (panels: Record) => { Object.entries(panels).forEach(([key, nextConfig]) => { const current = this.ctx.panelSettings[key]; if (!current) { this.ctx.panelSettings[key] = { ...nextConfig }; trackPanelToggled(key, nextConfig.enabled); return; } if (current.enabled !== nextConfig.enabled) { trackPanelToggled(key, nextConfig.enabled); } Object.assign(current, nextConfig); }); saveToStorage(STORAGE_KEYS.panels, this.ctx.panelSettings); this.applyPanelSettings(); this.callbacks.updateSearchIndex(); }, getDisabledSources: () => this.ctx.disabledSources, toggleSource: (name: string) => { const reenabling = this.ctx.disabledSources.has(name); if (reenabling && !isProUser()) { const allSources = this.getAllSourceNames(); const currentlyEnabled = allSources.filter(n => !this.ctx.disabledSources.has(n)).length; if (currentlyEnabled + 1 > FREE_MAX_SOURCES) { this.showToast(t('modals.settingsWindow.freeSourceLimit', { max: String(FREE_MAX_SOURCES) })); return; } } if (reenabling) { this.ctx.disabledSources.delete(name); } else { this.ctx.disabledSources.add(name); } saveToStorage(STORAGE_KEYS.disabledFeeds, Array.from(this.ctx.disabledSources)); }, setSourcesEnabled: (names: string[], enabled: boolean) => { if (enabled && !isProUser()) { const allSources = this.getAllSourceNames(); const currentlyEnabled = allSources.filter(n => !this.ctx.disabledSources.has(n)).length; const wouldEnable = names.filter(n => this.ctx.disabledSources.has(n) && allSources.includes(n)).length; if (currentlyEnabled + wouldEnable > FREE_MAX_SOURCES) { this.showToast(t('modals.settingsWindow.freeSourceLimit', { max: String(FREE_MAX_SOURCES) })); return; } } for (const name of names) { if (enabled) this.ctx.disabledSources.delete(name); else this.ctx.disabledSources.add(name); } saveToStorage(STORAGE_KEYS.disabledFeeds, Array.from(this.ctx.disabledSources)); }, getAllSourceNames: () => this.getAllSourceNames(), getLocalizedPanelName: (key: string, fallback: string) => this.getLocalizedPanelName(key, fallback), resetLayout: () => { clearPanelSpans(); clearPanelColSpans(); removeStorageValue(this.ctx.PANEL_ORDER_KEY); removeStorageValue(this.ctx.PANEL_ORDER_KEY + '-bottom'); removeStorageValue(this.ctx.PANEL_ORDER_KEY + '-bottom-set'); removeStorageValue('map-height'); window.location.reload(); }, isDesktopApp: this.ctx.isDesktopApp, onMapProviderChange: () => { this.ctx.map?.reloadBasemap(); }, }); const mount = document.getElementById('unifiedSettingsMount'); if (mount) { mount.appendChild(this.ctx.unifiedSettings.getButton()); } const mobileBtn = document.getElementById('mobileSettingsBtn'); if (mobileBtn) { mobileBtn.addEventListener('click', () => this.ctx.unifiedSettings?.open()); } // U8 (degraded path) — listen for the deep-dive "Notify me about this // country" sub-action and open the notifications tab. Today the // event detail.country is informational only; when the alertRules // schema PR lands, the future PR will read it here and forward to // a pre-filled create-form open. See plan U8 R9 + the TODO inside // src/utils/notify-country-link.ts. // // Stored on a bound handler field so `destroy()` can remove it. // Same-document reinit (HMR, test harnesses, multiple App instances) // would otherwise accumulate anonymous listeners that retain the // stale AppContext closure — every click would fire all of them. this.boundNotifyForCountryHandler = (_e: Event) => { this.ctx.unifiedSettings?.open('notifications'); }; window.addEventListener( WM_OPEN_NOTIFICATIONS_FOR_COUNTRY, this.boundNotifyForCountryHandler, ); } setupAuthWidget(): void { const modal = new AuthLauncher(); this.ctx.authModal = modal; // The settings gear is rendered once by the standalone unifiedSettings // button (#unifiedSettingsMount), which is mounted regardless of auth state // (so signed-out users keep it too). Passing onSettingsClick here makes // AuthHeaderWidget render a second gear next to the avatar for signed-in // users — a duplicate. Leave it unset. const widget = new AuthHeaderWidget(() => modal.open()); this.ctx.authHeaderWidget = widget; const mount = document.getElementById('authWidgetMount'); if (mount) { mount.appendChild(widget.getElement()); } } setupPlaybackControl(): void { // Always create — show/hide reactively via auth state subscription below. this.ctx.playbackControl = new PlaybackControl(); this.ctx.playbackControl.onSnapshot((snapshot) => { if (snapshot) { this.ctx.isPlaybackMode = true; this.restoreSnapshot(snapshot); } else { this.ctx.isPlaybackMode = false; this.callbacks.loadAllData(); } }); const el = this.ctx.playbackControl.getElement(); const headerRight = this.ctx.container.querySelector('.header-right'); if (headerRight) { headerRight.insertBefore(el, headerRight.firstChild); } // #5632: gate on the entitlement chain, NOT `user.role === 'pro'` — nothing // writes Clerk publicMetadata, so that field read 'free' for paying // subscribers and the control rendered for nobody. let gateHitTracked = false; const applyGate = (): void => { if (this.ctx.isDestroyed) return; const verdict = evaluatePlaybackGate(getAuthState()); const visible = verdict === 'visible'; el.style.display = visible ? '' : 'none'; // Losing access mid-replay must also LEAVE playback. `display: none` // alone strands the dashboard on historical data — the "Live" button is // inside the element we just hid. No-ops unless playback is active. if (!visible) this.ctx.playbackControl?.exitPlayback(); // Affirmative denials only, once per session. 'pending' also hides, but // counting it would tick the funnel on every page load — including for // subscribers whose control appears a moment later. if (verdict === 'denied' && !gateHitTracked) { gateHitTracked = true; trackGateHit('playback'); } }; applyGate(); // BOTH subscriptions, same as setupExportPanel above: the Convex // entitlement watcher (services/entitlements.ts) is a separate emitter from // Clerk's, so an auth-only subscription never re-runs when a snapshot lands // after sign-in — exactly the post-checkout unlock path. this.proGateUnsubscribers.push(subscribeAuthState(() => applyGate())); this.proGateUnsubscribers.push(onEntitlementChange(() => applyGate())); } setupSnapshotSaving(): void { const saveCurrentSnapshot = async () => { if (this.ctx.isPlaybackMode || this.ctx.isDestroyed) return; const marketPrices: Record = {}; this.ctx.latestMarkets.forEach(m => { if (m.price !== null) marketPrices[m.symbol] = m.price; }); await saveSnapshot({ timestamp: Date.now(), events: this.ctx.latestClusters, marketPrices, predictions: this.ctx.latestPredictions.map(p => ({ title: p.title, yesPrice: p.yesPrice })), hotspotLevels: this.ctx.map?.getHotspotLevels() ?? {} }); }; void saveCurrentSnapshot().catch((e) => console.warn('[Snapshot] save failed:', e)); this.snapshotIntervalId = setInterval(() => void saveCurrentSnapshot().catch((e) => console.warn('[Snapshot] save failed:', e)), 15 * 60 * 1000); } restoreSnapshot(snapshot: DashboardSnapshot): void { // Replay parks every news panel on a loading state and never refills it — // leaving playback calls loadAllData() to do that. Its news task is skipped // when the category set is unchanged (#5376), which replay does not touch, // so drop the record here and the exit reload happens. this.callbacks.invalidateNewsHydration(); for (const panel of Object.values(this.ctx.newsPanels)) { panel.showLoading(); } const events = snapshot.events as ClusteredEvent[]; this.ctx.latestClusters = events; const predictions = snapshot.predictions.map((p, i) => ({ id: `snap-${i}`, title: p.title, yesPrice: p.yesPrice, noPrice: 100 - p.yesPrice, volume24h: 0, liquidity: 0, })); this.ctx.latestPredictions = predictions; (this.ctx.panels.polymarket as PredictionPanel | undefined)?.renderPredictions(predictions); this.ctx.map?.setHotspotLevels(snapshot.hotspotLevels); } setupMapLayerHandlers(): void { this.ctx.map?.setOnLayerChange((layer, enabled, source) => { this.applyMapLayerChange(layer, enabled, source); }); // Forward live aircraft positions from map to AirlineIntelPanel + cache + search index this.ctx.map?.setOnAircraftPositionsUpdate((positions) => { this.ctx.intelligenceCache.aircraftPositions = positions; const airlineIntel = this.ctx.panels['airline-intel'] as AirlineIntelPanel | undefined; airlineIntel?.updateLivePositions(positions); const military = this.ctx.intelligenceCache.military?.flights ?? []; this.callbacks.updateFlightSource?.(positions, military); }); } setupPanelViewTracking(): void { const viewedPanels = new Set(); const observer = new IntersectionObserver((entries) => { for (const entry of entries) { if (entry.isIntersecting && entry.intersectionRatio >= 0.3) { const id = (entry.target as HTMLElement).dataset.panel; if (id && !viewedPanels.has(id)) { viewedPanels.add(id); trackPanelView(id); } } } }, { threshold: 0.3 }); const grid = document.getElementById('panelsGrid'); if (grid) { for (const child of Array.from(grid.children)) { if ((child as HTMLElement).dataset.panel) { observer.observe(child); } } } } showToast(msg: string): void { document.querySelector('.toast-notification')?.remove(); const el = document.createElement('div'); el.className = 'toast-notification'; el.textContent = msg; document.body.appendChild(el); requestAnimationFrame(() => el.classList.add('visible')); setTimeout(() => { el.classList.remove('visible'); setTimeout(() => el.remove(), 300); }, 3000); } shouldShowIntelligenceNotifications(): boolean { return !this.ctx.isMobile && !!this.ctx.findingsBadge?.isPopupEnabled(); } setupMapResize(): void { const mapSection = document.getElementById('mapSection'); const mapContainer = document.getElementById('mapContainer'); const resizeHandle = document.getElementById('mapResizeHandle'); if (!mapSection || !resizeHandle || !mapContainer) return; const getMinHeight = () => (window.innerWidth >= 1600 ? 280 : 350); const getMaxHeight = () => { if (window.innerWidth < 1600) return Math.max(getMinHeight(), window.innerHeight - 150); const bottomGrid = document.getElementById('mapBottomGrid'); const isEmpty = !bottomGrid || bottomGrid.children.length === 0; const headerHeight = 60; const totalAvailable = window.innerHeight - headerHeight; if (isEmpty) { return totalAvailable - 25; } else { return totalAvailable - 300; } }; const savedHeight = readStorageValue('map-height'); if (savedHeight) { const numeric = Number.parseInt(savedHeight, 10); if (Number.isFinite(numeric)) { const clamped = Math.max(getMinHeight(), Math.min(numeric, getMaxHeight())); if (window.innerWidth >= 1600) { mapContainer.style.flex = 'none'; mapContainer.style.height = `${clamped}px`; } else { mapSection.style.height = `${clamped}px`; } if (clamped !== numeric) { writeStorageValue('map-height', `${clamped}px`); } } else { removeStorageValue('map-height'); } } let isResizing = false; let startY = 0; let startHeight = 0; const getTarget = () => (window.innerWidth >= 1600 ? mapContainer : mapSection); this.boundMapEndResizeHandler = () => { if (!isResizing) return; isResizing = false; this.ctx.map?.setIsResizing(false); this.ctx.map?.resize(); mapSection.classList.remove('resizing'); document.body.style.cursor = ''; writeStorageValue('map-height', getTarget().style.height); }; const endResize = this.boundMapEndResizeHandler; resizeHandle.addEventListener('mousedown', (e) => { isResizing = true; startY = e.clientY; const target = getTarget(); startHeight = target.offsetHeight; this.ctx.map?.setIsResizing(true); mapSection.classList.add('resizing'); document.body.style.cursor = 'ns-resize'; e.preventDefault(); }); resizeHandle.addEventListener('dblclick', () => { const isWide = window.innerWidth >= 1600; const target = isWide ? mapContainer : mapSection; const targetHeight = window.innerHeight * 0.5; const finalHeight = Math.max(getMinHeight(), Math.min(targetHeight, getMaxHeight())); this.ctx.map?.setIsResizing(true); target.classList.add('map-section-smooth'); if (isWide) target.style.flex = 'none'; target.style.height = `${finalHeight}px`; let fired = false; const onEnd = () => { if (fired) return; fired = true; target.classList.remove('map-section-smooth'); target.removeEventListener('transitionend', onEnd); writeStorageValue('map-height', `${finalHeight}px`); this.ctx.map?.setIsResizing(false); this.ctx.map?.resize(); }; target.addEventListener('transitionend', onEnd); this.ctx.map?.resize(); setTimeout(onEnd, 500); }); this.boundMapResizeMoveHandler = (e: MouseEvent) => { if (!isResizing) return; const isWide = window.innerWidth >= 1600; const target = isWide ? mapContainer : mapSection; const deltaY = e.clientY - startY; const newHeight = Math.max(getMinHeight(), Math.min(startHeight + deltaY, getMaxHeight())); if (isWide) target.style.flex = 'none'; target.style.height = `${newHeight}px`; this.ctx.map?.resize(); }; document.addEventListener('mousemove', this.boundMapResizeMoveHandler); document.addEventListener('mouseup', endResize); window.addEventListener('blur', endResize); this.boundMapResizeVisChangeHandler = () => { if (document.hidden) endResize(); }; document.addEventListener('visibilitychange', this.boundMapResizeVisChangeHandler); } setupMapWidthResize(): void { const mainContent = document.querySelector('.main-content'); const widthHandle = document.getElementById('mapWidthResizeHandle'); if (!mainContent || !widthHandle) return; const saved = readStorageValue('map-col-width'); if (saved) mainContent.style.setProperty('--map-col-width', saved); let isResizing = false; let startX = 0; let startTotalWidth = 0; let startColPx = 0; this.boundMapWidthEndResizeHandler = () => { if (!isResizing) return; isResizing = false; this.ctx.map?.setIsResizing(false); this.ctx.map?.resize(); document.body.classList.remove('map-width-resizing'); widthHandle.classList.remove('resizing'); const current = mainContent.style.getPropertyValue('--map-col-width'); if (current) writeStorageValue('map-col-width', current); }; widthHandle.addEventListener('mousedown', (e) => { isResizing = true; startX = e.clientX; startTotalWidth = mainContent.offsetWidth; const raw = mainContent.style.getPropertyValue('--map-col-width') || '60%'; startColPx = startTotalWidth * (parseFloat(raw) / 100); this.ctx.map?.setIsResizing(true); document.body.classList.add('map-width-resizing'); widthHandle.classList.add('resizing'); e.preventDefault(); }); this.boundMapWidthResizeMoveHandler = (e: MouseEvent) => { if (!isResizing) return; const delta = e.clientX - startX; const newPct = Math.max(25, Math.min(75, ((startColPx + delta) / startTotalWidth) * 100)); mainContent.style.setProperty('--map-col-width', `${newPct.toFixed(1)}%`); this.ctx.map?.resize(); }; document.addEventListener('mousemove', this.boundMapWidthResizeMoveHandler); document.addEventListener('mouseup', this.boundMapWidthEndResizeHandler); window.addEventListener('blur', this.boundMapWidthEndResizeHandler); } setupMapPin(): void { const mapSection = document.getElementById('mapSection'); const pinBtn = document.getElementById('mapPinBtn'); if (!mapSection || !pinBtn) return; const isPinned = readStorageValue('map-pinned') === 'true'; if (isPinned) { mapSection.classList.add('pinned'); pinBtn.classList.add('active'); } pinBtn.addEventListener('click', () => { const nowPinned = mapSection.classList.toggle('pinned'); pinBtn.classList.toggle('active', nowPinned); writeStorageValue('map-pinned', String(nowPinned)); }); this.setupMapFullscreen(mapSection); this.setupMapDimensionToggle(); } private setupMapDimensionToggle(): void { const toggle = document.getElementById('mapDimensionToggle'); if (!toggle) return; toggle.querySelectorAll('.map-dim-btn').forEach(btn => { btn.addEventListener('click', () => { const mode = btn.dataset.mode; if (!mode) return; const isGlobe = mode === 'globe'; const alreadyGlobe = this.ctx.map?.isGlobeMode() ?? false; if (isGlobe === alreadyGlobe) return; toggle.querySelectorAll('.map-dim-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); saveToStorage(STORAGE_KEYS.mapMode, isGlobe ? 'globe' : 'flat'); if (isGlobe) { this.ctx.map?.switchToGlobe(); } else { this.ctx.map?.switchToFlat(); } if (this.ctx.mapLayers.resilienceScore && !this.ctx.map?.isDeckGLActive?.()) { this.ctx.mapLayers = { ...this.ctx.mapLayers, resilienceScore: false }; saveToStorage(STORAGE_KEYS.mapLayers, this.ctx.mapLayers); } }); }); } private setupMapFullscreen(mapSection: HTMLElement): void { const btn = document.getElementById('mapFullscreenBtn'); if (!btn) return; const expandSvg = ''; const shrinkSvg = ''; let isFullscreen = false; const toggle = () => { isFullscreen = !isFullscreen; mapSection.classList.toggle('live-news-fullscreen', isFullscreen); document.body.classList.toggle('live-news-fullscreen-active', isFullscreen); setTrustedHtml(btn, trustedHtml(isFullscreen ? shrinkSvg : expandSvg, "legacy direct innerHTML migration")); btn.title = isFullscreen ? 'Exit fullscreen' : 'Fullscreen'; this.syncMapAfterLayoutChange(); }; btn.addEventListener('click', toggle); this.boundMapFullscreenEscHandler = (e: KeyboardEvent) => { if (e.key === 'Escape' && isFullscreen) toggle(); }; document.addEventListener('keydown', this.boundMapFullscreenEscHandler); } getLocalizedPanelName(panelKey: string, fallback: string): string { if (panelKey === 'runtime-config') { return t('modals.runtimeConfig.title'); } const key = panelKey.replace(/-([a-z])/g, (_match, group: string) => group.toUpperCase()); const lookup = `panels.${key}`; const localized = t(lookup); return localized === lookup ? fallback : localized; } getAllSourceNames(): string[] { const sources = new Set(); // Preset feeds + sources from any custom news panels the user added, so // the source manager stays in sync with what loadNews() actually fetches. const categories = resolveNewsCategories(FEEDS, CANONICAL_FEEDS, enabledNewsCategoryKeys(this.ctx.newsCategoryPanelKeys, this.ctx.panelSettings)); categories.forEach(({ feeds }) => feeds.forEach(f => sources.add(f.name))); INTEL_SOURCES.forEach(f => sources.add(f.name)); return Array.from(sources).sort((a, b) => a.localeCompare(b)); } applyPanelSettings(): void { Object.entries(this.ctx.panelSettings).forEach(([key, config]) => { if (key === 'map') { const mapSection = document.getElementById('mapSection'); if (mapSection) { mapSection.classList.toggle('hidden', !config.enabled); const mainContent = document.querySelector('.main-content'); if (mainContent) { mainContent.classList.toggle('map-hidden', !config.enabled); } this.callbacks.ensureCorrectZones(); } return; } const panel = this.ctx.panels[key]; const liveMediaPanel = panel as { stopLiveMediaForClose?: () => void; resumeLiveMediaForShow?: () => void } | undefined; if (!config.enabled) { liveMediaPanel?.stopLiveMediaForClose?.(); } panel?.toggle(config.enabled); if (config.enabled) { liveMediaPanel?.resumeLiveMediaForShow?.(); } }); } }