diff --git a/src/components/MapContainer.ts b/src/components/MapContainer.ts index e4a25e7bad..755daef9a1 100644 --- a/src/components/MapContainer.ts +++ b/src/components/MapContainer.ts @@ -8,10 +8,10 @@ // paint anyway. Keep this import at the top of the file so Vite associates it // with this module's chunk, not whichever sibling pulls it in first. import 'maplibre-gl/dist/maplibre-gl.css'; -import { isMobileDevice } from '@/utils'; +import { isMobileDevice, showToast } from '@/utils'; import { MapComponent } from './Map'; import { DeckGLMap, type DeckMapView, type CountryClickPayload } from './DeckGLMap'; -import { GlobeMap } from './GlobeMap'; +import type { GlobeMap } from './GlobeMap'; import type { MapLayers, Hotspot, @@ -87,6 +87,7 @@ interface TechEventMarker { type FireMarker = { lat: number; lon: number; brightness: number; frp: number; confidence: number; region: string; acq_date: string; daynight: string }; type NewsLocationMarker = { lat: number; lon: number; title: string; threatLevel: string; timestamp?: Date }; type CIIScore = { code: string; score: number; level: string }; +type GlobeMapCtor = typeof import('./GlobeMap').GlobeMap; /** * Unified map interface that delegates to either DeckGLMap or MapComponent @@ -98,10 +99,13 @@ export class MapContainer { private deckGLMap: DeckGLMap | null = null; private svgMap: MapComponent | null = null; private globeMap: GlobeMap | null = null; + private globeMapCtorPromise: Promise | null = null; + private globeActivationId = 0; private supplyChainPanel: import('@/components/SupplyChainPanel').SupplyChainPanel | null = null; private initialState: MapContainerState; private useDeckGL: boolean; private useGlobe: boolean; + private destroyed = false; private isResizingInternal = false; private resizeObserver: ResizeObserver | null = null; @@ -205,10 +209,68 @@ export class MapContainer { this.svgMap = new MapComponent(this.container, this.initialState); } + private loadGlobeMapCtor(): Promise { + if (!this.globeMapCtorPromise) { + this.globeMapCtorPromise = import('./GlobeMap') + .then(({ GlobeMap }) => GlobeMap) + .catch((error) => { + this.globeMapCtorPromise = null; + throw error; + }); + } + return this.globeMapCtorPromise; + } + + private markFlatModeActiveAfterGlobeFailure(): void { + document.querySelectorAll('#mapDimensionToggle .map-dim-btn').forEach((btn) => { + btn.classList.toggle('active', btn.dataset.mode === 'flat'); + }); + } + + private observeContainerResize(): void { + if (typeof ResizeObserver === 'undefined' || this.resizeObserver) return; + this.resizeObserver = new ResizeObserver(() => { + // Skip if we are already handling resize manually via drag handlers + if (this.isResizingInternal) return; + this.resize(); + }); + this.resizeObserver.observe(this.container); + } + + private async initGlobeMap( + snapshot?: MapContainerState, + center?: { lat: number; lon: number } | null, + ): Promise { + const activationId = ++this.globeActivationId; + console.log('[MapContainer] Initializing 3D globe (globe.gl mode)'); + + try { + const GlobeMapCtor = await this.loadGlobeMapCtor(); + if (this.destroyed || !this.useGlobe || activationId !== this.globeActivationId) return; + + this.globeMap = new GlobeMapCtor(this.container, this.initialState); + } catch (error) { + if (this.destroyed || !this.useGlobe || activationId !== this.globeActivationId) return; + + console.warn('[MapContainer] Globe initialization failed, falling back to flat map', error); + this.useGlobe = false; + this.useDeckGL = this.shouldUseDeckGL(); + this.markFlatModeActiveAfterGlobeFailure(); + this.init(); + if (snapshot) this.restoreViewport(snapshot, center ?? null); + this.rehydrateActiveMap(); + showToast('3D globe failed to load. Showing 2D map instead.'); + return; + } + + this.observeContainerResize(); + if (snapshot) this.restoreViewport(snapshot, center ?? null); + this.rehydrateActiveMap(); + } + private init(): void { if (this.useGlobe) { - console.log('[MapContainer] Initializing 3D globe (globe.gl mode)'); - this.globeMap = new GlobeMap(this.container, this.initialState); + void this.initGlobeMap(); } else if (this.useDeckGL) { console.log('[MapContainer] Initializing deck.gl map (desktop mode)'); try { @@ -226,14 +288,7 @@ export class MapContainer { } // Automatic resize on container change (fixes gaps on load/layout shift) - if (typeof ResizeObserver !== 'undefined') { - this.resizeObserver = new ResizeObserver(() => { - // Skip if we are already handling resize manually via drag handlers - if (this.isResizingInternal) return; - this.resize(); - }); - this.resizeObserver.observe(this.container); - } + this.observeContainerResize(); } /** Switch to 3D globe mode at runtime (called from Settings). */ @@ -246,9 +301,7 @@ export class MapContainer { this.destroyFlatMap(); this.useGlobe = true; this.useDeckGL = false; - this.globeMap = new GlobeMap(this.container, this.initialState); - this.restoreViewport(snapshot, center); - this.rehydrateActiveMap(); + void this.initGlobeMap(snapshot, center); } /** Reload basemap style (called when map provider changes in Settings). */ @@ -266,6 +319,7 @@ export class MapContainer { this.globeMap?.destroy(); this.globeMap = null; this.useGlobe = false; + this.globeActivationId++; this.useDeckGL = this.shouldUseDeckGL(); this.init(); this.restoreViewport(snapshot, center); @@ -1048,6 +1102,8 @@ export class MapContainer { } public destroy(): void { + this.destroyed = true; + this.globeActivationId++; this.resizeObserver?.disconnect(); this.globeMap?.destroy(); this.deckGLMap?.destroy(); diff --git a/vite.config.ts b/vite.config.ts index e0543683d8..43ad1927ae 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -20,11 +20,16 @@ const BROTLI_EXTENSIONS = new Set(['.js', '.mjs', '.css', '.html', '.svg', '.jso // silent-breakage failure mode where renaming a chunk in `manualChunks` // re-eagerises the WebGL stack without any build-time error. // - maplibre, deck-stack: heavy WebGL deps, only reachable via MapContainer +// - globe-stack: globe.gl + three.js, only reachable when globe mode loads // - MapContainer: the dynamic-import target itself -const LAZY_HTML_PRELOAD_CHUNKS = ['maplibre', 'deck-stack', 'MapContainer'] as const; +const LAZY_HTML_PRELOAD_CHUNKS = ['maplibre', 'deck-stack', 'globe-stack', 'MapContainer'] as const; const LAZY_HTML_PRELOAD_RE = new RegExp( `/(${LAZY_HTML_PRELOAD_CHUNKS.join('|')})-[A-Za-z0-9_-]+\\.js$`, ); +const HTML_MODULEPRELOAD_TAG_RE = /]*rel=["']modulepreload["'][^>]*>/g; +const LAZY_HTML_CHUNK_REF_RE = new RegExp( + `\\bhref=["'][^"']*/(${LAZY_HTML_PRELOAD_CHUNKS.join('|')})-[A-Za-z0-9_-]+\\.js["']`, +); // Panel-cluster manualChunks map. Splits the previously monolithic ~2.3MB // `panels` chunk into per-domain chunks so cache invalidation is local to @@ -124,6 +129,37 @@ function brotliPrecompressPlugin(): Plugin { }; } +function lazyHtmlPreloadGuardPlugin(): Plugin { + return { + name: 'lazy-html-preload-guard', + apply: 'build', + enforce: 'post', + async writeBundle(outputOptions, bundle) { + const outDir = outputOptions.dir; + if (!outDir) return; + + const violations: string[] = []; + + await Promise.all(Object.entries(bundle).map(async ([fileName, asset]) => { + if (asset.type !== 'asset' || !fileName.endsWith('.html')) return; + + const html = await readFile(resolve(outDir, fileName), 'utf8'); + const preloadTags = html.match(HTML_MODULEPRELOAD_TAG_RE) ?? []; + const matches = preloadTags.filter((tag) => LAZY_HTML_CHUNK_REF_RE.test(tag)); + if (matches.length > 0) { + violations.push(`${fileName}: ${matches.join(', ')}`); + } + })); + + if (violations.length > 0) { + this.error( + `Lazy chunks were emitted as entry HTML modulepreload dependencies:\n${violations.join('\n')}`, + ); + } + }, + }; +} + function htmlVariantPlugin(activeMeta: VariantMeta, activeVariant: string, isDesktopBuild: boolean): Plugin { return { name: 'html-variant', @@ -778,6 +814,7 @@ export default defineConfig(({ mode }) => { youtubeLivePlugin(), gpsjamDevPlugin(), sebufApiPlugin(), + lazyHtmlPreloadGuardPlugin(), brotliPrecompressPlugin(), VitePWA({ registerType: 'autoUpdate', @@ -987,6 +1024,9 @@ export default defineConfig(({ mode }) => { ) { return 'deck-stack'; } + if (id.includes('/globe.gl/') || id.includes('/three/')) { + return 'globe-stack'; + } if (id.includes('/d3/')) { return 'd3'; }