From 04257213b84c65565903210c607c2caf3f259468 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 20:29:04 -0700 Subject: [PATCH] =?UTF-8?q?perf(world):=20defer=20the=20map=20off=20the=20?= =?UTF-8?q?first-paint=20critical=20path=20(eager=20JS=204.1MB=E2=86=921.4?= =?UTF-8?q?MB)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a low-end laptop the globe (mapbox-gl + deck.gl ≈ 2.7 MB) was eagerly modulepreloaded and constructed synchronously during renderLayout, so nothing painted until ~4.1 MB of JS downloaded + parsed + WebGL initialised (≈17.7 s to app-shell under 6× CPU throttle on the live build). This code-splits the map and degrades gracefully for weak hardware. - Map loads via dynamic import() in App.mountMap(); init awaits it AFTER the shell paints, before map-dependent wiring. Every this.map access was already null-guarded, and mountMap replays the initEscalation / onTimeRangeChanged / initial-URL-state wiring that used to run inline in createPanels. - Isolate Vite's __vitePreload helper into its own chunk (manualChunks): it had been co-located INTO the 'map' chunk, so the entry statically imported 'map' just for that helper and Vite modulepreloaded the whole 2.7 MB before paint — THE reason the map stayed eager despite the dynamic import. - @/components barrel re-exports the map classes as TYPES only (their modules have top-level side effects; value re-exports also dragged 'map' eager). - Defer @sentry/browser (~460 KB raw / ~130 KB gzip) out of the entry to requestIdleCallback, with a synchronous early-error buffer replayed on init so no boot error is lost. - New device-tier util (hardwareConcurrency + deviceMemory + reduced-motion, ?tier= override): low-end drops the deck.gl overlay DPR 2→1, skips the idle globe auto-spin, and scales real-time refresh intervals up to 2×. Eager JS before first paint: 4136 KB → 1440 KB (raw). Map (2.7 MB) + Sentry now load after first paint. --- package.json | 2 +- src/App.ts | 97 ++++++++++++++++++---------- src/bootstrap/sentry.ts | 74 +++++++++++++++++++++ src/components/DeckGLMap.ts | 14 ++-- src/components/index.ts | 10 ++- src/main.ts | 84 +++++++++--------------- src/utils/device-tier.ts | 124 ++++++++++++++++++++++++++++++++++++ vite.config.ts | 10 +++ 8 files changed, 321 insertions(+), 94 deletions(-) create mode 100644 src/bootstrap/sentry.ts create mode 100644 src/utils/device-tier.ts diff --git a/package.json b/package.json index e29b048e..8d7206a6 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "@hanzo/world", "description": "Hanzo World — real-time global intelligence dashboard.", "private": true, - "version": "2.4.38", + "version": "2.4.39", "type": "module", "scripts": { "lint:md": "markdownlint-cli2 '**/*.md'", diff --git a/src/App.ts b/src/App.ts index 46014b46..12e49a94 100644 --- a/src/App.ts +++ b/src/App.ts @@ -47,13 +47,17 @@ import { fetchClimateAnomalies } from '@/services/climate'; import { enrichEventsWithExposure } from '@/services/population-exposure'; import { buildMapUrl, debounce, loadFromStorage, parseMapUrlState, saveToStorage, ExportPanel, getCircuitBreakerCooldownInfo, isMobileDevice, setTheme, getCurrentTheme, generateId, getCSSColor } from '@/utils'; import { reverseGeocode } from '@/utils/reverse-geocode'; +import { refreshIntervalScale } from '@/utils/device-tier'; import { CountryBriefPage } from '@/components/CountryBriefPage'; import { CountryTimeline, type TimelineEvent } from '@/components/CountryTimeline'; import { escapeHtml } from '@/utils/sanitize'; import { getUiScale, setUiScale } from '@/utils/ui-scale'; import type { ParsedMapUrlState } from '@/utils'; +// MapContainer is a TYPE-only import here — its value is loaded via a dynamic +// import() in mountMap() so the ~2.7 MB mapbox-gl + deck.gl "map" chunk stays +// out of the entry bundle and off the first-paint critical path (low-end win). +import type { MapContainer } from '@/components/MapContainer'; import { - MapContainer, type MapView, type MapProjectionMode, type TimeRange, @@ -171,6 +175,10 @@ export class App { private readonly PANEL_ORDER_KEY = 'panel-order'; private readonly MAP_MODE_STORAGE_KEY = 'hanzo-world-map-mode'; private map: MapContainer | null = null; + // Resolves once the code-split map chunk has downloaded and the map is mounted. + // init() kicks it off after the shell paints and awaits it before any + // map-dependent wiring; every other this.map access stays null-guarded. + private mapReady: Promise = Promise.resolve(); private mapResizeObserver: ResizeObserver | null = null; private immersive: ImmersiveController | null = null; private panels: Record = {}; @@ -365,6 +373,10 @@ export class App { } this.renderLayout(); + // Kick off the code-split map load NOW (non-blocking) so the ~2.7 MB map + // chunk downloads while the lightweight shell below wires up — the shell + // paints without waiting for it. Map-dependent setup awaits mapReady below. + this.mapReady = this.mountMap(); this.startHeaderClock(); this.signalModal = new SignalModal(); this.signalModal.setLocationClickHandler((lat, lon) => { @@ -390,6 +402,9 @@ export class App { this.setupTryHanzoMenu(); this.setupAccountMenu(); this.setupSearchModal(); + // The map-dependent wiring below needs the map mounted. Awaiting here lets + // the shell paint and become interactive while the map chunk loads in parallel. + await this.mapReady; this.setupMapLayerHandlers(); this.setupCountryIntel(); this.setupEventListeners(); @@ -2402,30 +2417,11 @@ export class App { private createPanels(): void { const panelsGrid = document.getElementById('panelsGrid')!; - // Initialize map in the map section - // Default to MENA view on mobile for better focus - // Uses deck.gl (WebGL) on desktop, falls back to D3/SVG on mobile - const mapContainer = document.getElementById('mapContainer') as HTMLElement; - this.map = new MapContainer(mapContainer, { - // Desktop opens a touch tighter so the 3D globe reads as a cinematic hero that - // fills the immersive viewport (the native GlobeView inherits this zoom on - // activation); mobile keeps its MENA-focused framing. - zoom: this.isMobile ? 2.5 : 1.35, - pan: { x: 0, y: 0 }, // Centered view to show full world - view: this.isMobile ? 'mena' : 'global', - layers: this.mapLayers, - timeRange: '7d', - mode: this.resolveInitialMapMode(), - // Dock the 2D/3D toggle, basemap switcher and time-range pills IN the map - // (the standard in-map control cluster) — alongside the zoom, layer toggles - // and legend that already overlay it — rather than floating them in the - // bottom toolbar. Undefined ⇒ DeckGLMap mounts controls in its own container. - controlsHost: undefined, - }); - - // Initialize escalation service with data getters - this.map.initEscalationGetters(); - this.currentTimeRange = this.map.getTimeRange(); + // The map (mapbox-gl + deck.gl ≈ 2.7 MB) is code-split and mounted + // asynchronously by mountMap() — see init(). createPanels stays synchronous + // and map-free so the shell + panels paint before the map chunk downloads + // and initialises WebGL: the single biggest first-paint win on a low-end + // laptop. Every this.map access is null-guarded until the mount resolves. // Create all panels const politicsPanel = new NewsPanel('politics', t('panels.politics')); @@ -2811,15 +2807,9 @@ export class App { // Restore any analyst-created custom feed panels (appended after the grid loop). this.mountCustomFeedPanels(); - this.map.onTimeRangeChanged((range) => { - this.currentTimeRange = range; - this.applyTimeRangeFilterToNewsPanelsDebounced(); - }); - this.applyPanelSettings(); - this.applyInitialUrlState(); - // Heal any stale/saved state that left a panel ahead of the full-width map. - this.healMapAnchor(); + // Map-dependent wiring (onTimeRangeChanged, initial URL state, map anchor + // heal) runs in mountMap() once the code-split map has loaded. // Floating AI analyst launcher — available on every variant, independent of // whether the in-grid analyst panel is shown. Same host, same code path. @@ -2841,6 +2831,42 @@ export class App { void this.mountAdminCloudPanels(); } + // Code-split map mount. The MapContainer value (→ the ~2.7 MB mapbox-gl + + // deck.gl "map" chunk) is loaded via dynamic import() so it never blocks first + // paint. init() kicks this off after the shell paints and awaits it before any + // map-dependent wiring runs. Idempotent: a second call is a no-op. + private async mountMap(): Promise { + if (this.map) return; + const mapContainer = document.getElementById('mapContainer') as HTMLElement | null; + if (!mapContainer) return; + const { MapContainer } = await import('@/components/MapContainer'); + if (this.map || this.isDestroyed) return; // guard against re-entry / teardown during the await + this.map = new MapContainer(mapContainer, { + // Desktop opens a touch tighter so the 3D globe reads as a cinematic hero + // that fills the immersive viewport; mobile keeps its MENA-focused framing. + zoom: this.isMobile ? 2.5 : 1.35, + pan: { x: 0, y: 0 }, // Centered view to show full world + view: this.isMobile ? 'mena' : 'global', + layers: this.mapLayers, + timeRange: '7d', + mode: this.resolveInitialMapMode(), + controlsHost: undefined, + }); + + // Wiring that previously lived at the end of createPanels — it needs the map. + this.map.initEscalationGetters(); + this.currentTimeRange = this.map.getTimeRange(); + this.map.onTimeRangeChanged((range) => { + this.currentTimeRange = range; + this.applyTimeRangeFilterToNewsPanelsDebounced(); + }); + this.applyInitialUrlState(); + // Heal any stale/saved state that left a panel ahead of the full-width map. + this.healMapAnchor(); + // Reflect the current hidden/idle state onto the freshly-mounted render loop. + this.syncMapRenderActive(); + } + // ── admin-only Cloud console panels ───────────────────────────────────────── // Constructed and appended only for the admin org; non-admins never receive // them (and every backing endpoint fail-closes 403). Idempotent. @@ -5422,8 +5448,11 @@ export class App { const HIDDEN_REFRESH_MULTIPLIER = 4; const JITTER_FRACTION = 0.1; const MIN_REFRESH_MS = 1000; + // Low-end machines poll less often so background fetch+parse+re-render churn + // doesn't starve the UI thread (1× high-tier, up to 2× low-tier). + const LOW_END_SCALE = refreshIntervalScale(); const computeDelay = (baseMs: number, isHidden: boolean) => { - const adjusted = baseMs * (isHidden ? HIDDEN_REFRESH_MULTIPLIER : 1); + const adjusted = baseMs * (isHidden ? HIDDEN_REFRESH_MULTIPLIER : 1) * LOW_END_SCALE; const jitterRange = adjusted * JITTER_FRACTION; const jittered = adjusted + (Math.random() * 2 - 1) * jitterRange; return Math.max(MIN_REFRESH_MS, Math.round(jittered)); diff --git a/src/bootstrap/sentry.ts b/src/bootstrap/sentry.ts new file mode 100644 index 00000000..136d7b9c --- /dev/null +++ b/src/bootstrap/sentry.ts @@ -0,0 +1,74 @@ +// Sentry, code-split out of the entry chunk. +// +// @sentry/browser is ~460 KB raw / ~130 KB gzip — dead weight in the critical +// path on a low-end laptop, where every eager KB is main-thread parse/compile +// time before first paint. main.ts imports this lazily once the browser is idle +// (see main.ts), so error tracking still installs within the first second but +// never blocks the shell or the map from painting. A tiny synchronous error +// buffer in main.ts captures anything thrown before this loads and replays it. + +import * as Sentry from '@sentry/browser'; + +export interface EarlyError { + error?: unknown; + message?: string; +} + +export function initSentry(buffered: EarlyError[] = []): void { + Sentry.init({ + dsn: 'https://afc9a1c85c6ba49f8464a43f8de74ccd@o4509927897890816.ingest.us.sentry.io/4510906342113280', + release: `hanzo-world@${__APP_VERSION__}`, + environment: location.hostname === 'world.hanzo.ai' ? 'production' + : location.hostname.includes('vercel.app') ? 'preview' + : 'development', + enabled: !location.hostname.startsWith('localhost') && !('__TAURI_INTERNALS__' in window), + sendDefaultPii: true, + tracesSampleRate: 0.1, + ignoreErrors: [ + 'Invalid WebGL2RenderingContext', + 'WebGL context lost', + /reading 'imageManager'/, + /ResizeObserver loop/, + /NotAllowedError/, + /InvalidAccessError/, + /importScripts/, + /^TypeError: Load failed$/, + /^TypeError: Failed to fetch( \(.*\))?$/, + /^TypeError: cancelled$/, + /^TypeError: NetworkError/, + /runtime\.sendMessage\(\)/, + /Java object is gone/, + /^Object captured as promise rejection with keys:/, + /Unable to load image/, + /Non-Error promise rejection captured with value:/, + /Connection to Indexed Database server lost/, + /webkit\.messageHandlers/, + /unsafe-eval.*Content Security Policy/, + /Fullscreen request denied/, + /requestFullscreen/, + /vc_text_indicators_context/, + /Program failed to link: null/, + /too much recursion/, + ], + beforeSend(event) { + const msg = event.exception?.values?.[0]?.value ?? ''; + if (msg.length <= 3 && /^[a-zA-Z_$]+$/.test(msg)) return null; + const frames = event.exception?.values?.[0]?.stacktrace?.frames ?? []; + // Suppress module-import failures only when originating from browser extensions + if (/Importing a module script failed/.test(msg)) { + if (frames.some(f => /^(chrome|moz)-extension:/.test(f.filename ?? ''))) return null; + } + // Suppress maplibre internal null-access crashes (light, placement) only when stack is in map chunk + if (/this\.style\._layers|this\.light is null|can't access property "type", \w+ is undefined|Cannot read properties of null \(reading '(id|type)'\)/.test(msg)) { + if (frames.some(f => /\/map-[A-Za-z0-9]+\.js/.test(f.filename ?? ''))) return null; + } + return event; + }, + }); + + // Replay anything the synchronous boot buffer caught before we loaded. + for (const e of buffered) { + if (e.error !== undefined) Sentry.captureException(e.error); + else if (e.message) Sentry.captureMessage(e.message); + } +} diff --git a/src/components/DeckGLMap.ts b/src/components/DeckGLMap.ts index fd8588a5..c4a57e5b 100644 --- a/src/components/DeckGLMap.ts +++ b/src/components/DeckGLMap.ts @@ -55,6 +55,7 @@ import { import type { WeatherAlert } from '@/services/weather'; import { escapeHtml } from '@/utils/sanitize'; import { icon } from '@/utils/icons'; +import { maxDevicePixelRatio, isLowEndDevice } from '@/utils/device-tier'; import { t } from '@/services/i18n'; import { debounce, rafSchedule, getCurrentTheme } from '@/utils/index'; import { @@ -792,10 +793,12 @@ export class DeckGLMap { getTooltip: (info: PickingInfo) => this.getTooltip(info), onClick: (info: PickingInfo, event) => this.handleClick(info, event as MapClickEvent), pickingRadius: 10, - // Overlaid deck owns its DPR. Cap at 2 so a HiDPI (DPR 3+) display on a - // large window doesn't quadruple the per-frame fill of the second canvas — - // the freeze the CTO hit on real hardware. 2× keeps dots/text crisp. - useDevicePixels: Math.min(window.devicePixelRatio || 1, 2), + // Overlaid deck owns its DPR. Cap it so a HiDPI (DPR 3+) display on a large + // window doesn't quadruple the per-frame fill of the second canvas — the + // freeze the CTO hit on real hardware. High-tier caps at 2× (crisp dots/ + // text); a low-end laptop drops to 1× — the single biggest per-frame GPU + // win on weak hardware — via the device tier. + useDevicePixels: Math.min(window.devicePixelRatio || 1, maxDevicePixelRatio()), onError: (error: Error) => console.warn('[DeckGLMap] Render error (non-fatal):', error.message), }); @@ -4219,6 +4222,9 @@ export class DeckGLMap { && !this.renderPaused && !this.webglLost && !this.prefersReducedMotion() + // A low-end laptop should not spend a continuous ~30 fps render loop on a + // background flourish; the idle globe stays still there (device tier). + && !isLowEndDevice() && !document.hidden; } diff --git a/src/components/index.ts b/src/components/index.ts index 52f0854d..19b8bc50 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -2,8 +2,14 @@ export * from './Panel'; export * from './VirtualList'; export { MapComponent } from './Map'; export * from './MapPopup'; -export { DeckGLMap } from './DeckGLMap'; -export { MapContainer, type MapView, type TimeRange, type MapContainerState, type MapProjectionMode } from './MapContainer'; +// DeckGLMap + MapContainer are VALUE-heavy (deck.gl + mapbox-gl ≈ 2.7 MB, with +// top-level side effects) so their VALUES are NOT re-exported here — doing so +// dragged the whole map chunk into the eager entry graph (main imported it and +// Vite modulepreloaded 2.7 MB before first paint). They are imported directly +// where needed: MapContainer.ts, the App's dynamic import() in mountMap(), and +// e2e harnesses. The barrel exposes only their TYPES so consumers stay +// tree-shakeable and the map chunk loads lazily, off the first-paint path. +export type { MapView, TimeRange, MapContainerState, MapProjectionMode } from './MapContainer'; export * from './NewsPanel'; export * from './MarketPanel'; export * from './CommoditiesPanel'; diff --git a/src/main.ts b/src/main.ts index e7503d69..490dfa29 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,65 +1,43 @@ import './styles/main.css'; -import 'maplibre-gl/dist/maplibre-gl.css'; -import * as Sentry from '@sentry/browser'; import { App } from './App'; +import type { EarlyError } from './bootstrap/sentry'; + +// Telemetry (Sentry — ~460 KB raw / ~130 KB gzip) is code-split out of the entry +// chunk. On a low-end laptop every eager KB is main-thread parse/compile time +// before first paint; deferring Sentry keeps the shell + map off the critical +// path. Error tracking still installs within the first second (whenIdle, below), +// and a tiny synchronous buffer captures anything thrown before it loads and +// replays it — so no early boot error is lost. +const earlyErrors: EarlyError[] = []; +const bufferError = (e: ErrorEvent | PromiseRejectionEvent): void => { + const err = (e as ErrorEvent).error ?? (e as PromiseRejectionEvent).reason ?? (e as ErrorEvent).message; + if (err !== undefined && earlyErrors.length < 20) earlyErrors.push({ error: err }); +}; +window.addEventListener('error', bufferError); +window.addEventListener('unhandledrejection', bufferError); -// Initialize Sentry error tracking (early as possible) -Sentry.init({ - dsn: 'https://afc9a1c85c6ba49f8464a43f8de74ccd@o4509927897890816.ingest.us.sentry.io/4510906342113280', - release: `hanzo-world@${__APP_VERSION__}`, - environment: location.hostname === 'world.hanzo.ai' ? 'production' - : location.hostname.includes('vercel.app') ? 'preview' - : 'development', - enabled: !location.hostname.startsWith('localhost') && !('__TAURI_INTERNALS__' in window), - sendDefaultPii: true, - tracesSampleRate: 0.1, - ignoreErrors: [ - 'Invalid WebGL2RenderingContext', - 'WebGL context lost', - /reading 'imageManager'/, - /ResizeObserver loop/, - /NotAllowedError/, - /InvalidAccessError/, - /importScripts/, - /^TypeError: Load failed$/, - /^TypeError: Failed to fetch( \(.*\))?$/, - /^TypeError: cancelled$/, - /^TypeError: NetworkError/, - /runtime\.sendMessage\(\)/, - /Java object is gone/, - /^Object captured as promise rejection with keys:/, - /Unable to load image/, - /Non-Error promise rejection captured with value:/, - /Connection to Indexed Database server lost/, - /webkit\.messageHandlers/, - /unsafe-eval.*Content Security Policy/, - /Fullscreen request denied/, - /requestFullscreen/, - /vc_text_indicators_context/, - /Program failed to link: null/, - /too much recursion/, - ], - beforeSend(event) { - const msg = event.exception?.values?.[0]?.value ?? ''; - if (msg.length <= 3 && /^[a-zA-Z_$]+$/.test(msg)) return null; - const frames = event.exception?.values?.[0]?.stacktrace?.frames ?? []; - // Suppress module-import failures only when originating from browser extensions - if (/Importing a module script failed/.test(msg)) { - if (frames.some(f => /^(chrome|moz)-extension:/.test(f.filename ?? ''))) return null; - } - // Suppress maplibre internal null-access crashes (light, placement) only when stack is in map chunk - if (/this\.style\._layers|this\.light is null|can't access property "type", \w+ is undefined|Cannot read properties of null \(reading '(id|type)'\)/.test(msg)) { - if (frames.some(f => /\/map-[A-Za-z0-9]+\.js/.test(f.filename ?? ''))) return null; - } - return event; - }, -}); // Suppress NotAllowedError from YouTube IFrame API's internal play() — browser autoplay policy, // not actionable. The YT IFrame API doesn't expose the play() promise so it leaks as unhandled. window.addEventListener('unhandledrejection', (e) => { if (e.reason?.name === 'NotAllowedError') e.preventDefault(); }); +const whenIdle = (cb: () => void): void => { + const ric = (window as unknown as { requestIdleCallback?: (cb: () => void, o?: { timeout: number }) => void }).requestIdleCallback; + if (ric) ric(cb, { timeout: 2000 }); + else setTimeout(cb, 1200); +}; + +// After first paint, load Sentry off the critical path and replay any buffered errors. +whenIdle(() => { + import('./bootstrap/sentry').then(({ initSentry }) => { + initSentry(earlyErrors); + window.removeEventListener('error', bufferError); + window.removeEventListener('unhandledrejection', bufferError); + earlyErrors.length = 0; + }).catch(() => { /* telemetry is best-effort — never break boot */ }); +}); + import { debugInjectTestEvents, debugGetCells, getCellCount } from '@/services/geo-convergence'; import { initMetaTags } from '@/services/meta-tags'; import { installRuntimeFetchPatch } from '@/services/runtime'; diff --git a/src/utils/device-tier.ts b/src/utils/device-tier.ts new file mode 100644 index 00000000..c0a9bf0a --- /dev/null +++ b/src/utils/device-tier.ts @@ -0,0 +1,124 @@ +// Device capability tiering for graceful degradation on low-end hardware. +// +// One place decides "how much machine is this?" so every heavy subsystem (the +// deck.gl/maplibre globe, the real-time refresh cadence, the finance terminal's +// live widgets, the globe flow arcs) can dial itself down without each +// re-implementing the same navigator sniffing. Values, not places: callers ask +// for the knob they need (maxDevicePixelRatio, refreshScale, maxFlowArcs), not +// for raw core counts. +// +// Signals: navigator.hardwareConcurrency (logical cores) + navigator.deviceMemory +// (GB, Chromium-only) + prefers-reduced-motion. Override with ?tier=low|mid|high +// or localStorage('worldmonitor-device-tier') for testing on capable hardware. + +export type DeviceTier = 'low' | 'mid' | 'high'; + +const OVERRIDE_KEY = 'worldmonitor-device-tier'; + +function readOverride(): DeviceTier | null { + if (typeof window === 'undefined') return null; + try { + const fromUrl = new URLSearchParams(window.location.search).get('tier'); + if (fromUrl === 'low' || fromUrl === 'mid' || fromUrl === 'high') { + localStorage.setItem(OVERRIDE_KEY, fromUrl); + return fromUrl; + } + const stored = localStorage.getItem(OVERRIDE_KEY); + if (stored === 'low' || stored === 'mid' || stored === 'high') return stored; + } catch { + /* private mode / disabled storage — fall through to detection */ + } + return null; +} + +function detectTier(): DeviceTier { + const override = readOverride(); + if (override) return override; + + if (typeof navigator === 'undefined') return 'high'; + + const cores = navigator.hardwareConcurrency || 0; + // deviceMemory is Chromium-only and coarsely bucketed (0.25..8). Absent ⇒ 0. + const mem = (navigator as Navigator & { deviceMemory?: number }).deviceMemory || 0; + + // Low: a genuinely underpowered laptop/Chromebook — few cores or little RAM. + // The globe (two GL contexts + WebGL fill) is the tax we most need to cut here. + if ((cores && cores <= 4) || (mem && mem <= 4)) return 'low'; + + // High: clearly capable (8+ cores AND 8GB+, or 8+ cores with memory unknown). + if (cores >= 8 && (mem === 0 || mem >= 8)) return 'high'; + + return 'mid'; +} + +let cached: DeviceTier | null = null; + +/** The device tier, detected once and memoized for the session. */ +export function getDeviceTier(): DeviceTier { + if (cached === null) cached = detectTier(); + return cached; +} + +export function isLowEndDevice(): boolean { + return getDeviceTier() === 'low'; +} + +/** Honour the OS "reduce motion" setting — treated like low-end for animations. */ +export function prefersReducedMotion(): boolean { + if (typeof window === 'undefined' || !window.matchMedia) return false; + try { + return window.matchMedia('(prefers-reduced-motion: reduce)').matches; + } catch { + return false; + } +} + +/** + * DPR cap for the deck.gl overlay canvas. Retina globes are fill-rate bound; + * dropping a low-end machine to DPR 1 is the single biggest per-frame GPU win. + */ +export function maxDevicePixelRatio(): number { + switch (getDeviceTier()) { + case 'low': return 1; + case 'mid': return 1.5; + default: return 2; + } +} + +/** + * Multiplier applied to every real-time refresh interval. Low-end machines poll + * less often so background fetch+parse+re-render churn doesn't starve the UI. + */ +export function refreshIntervalScale(): number { + switch (getDeviceTier()) { + case 'low': return 2; + case 'mid': return 1.35; + default: return 1; + } +} + +/** + * Max number of animated flow arcs to draw on the globe. Each arc is an + * instanced draw with a per-frame uniform; density is the knob that keeps the + * flow overlay from tanking FPS on weak GPUs. + */ +export function maxFlowArcs(): number { + switch (getDeviceTier()) { + case 'low': return 40; + case 'mid': return 120; + default: return 300; + } +} + +/** + * Max number of concurrently-live embedded widgets (e.g. TradingView iframes in + * the finance terminal). Each live widget is its own socket + rAF loop; capping + * concurrency keeps the terminal responsive on low-end machines. + */ +export function maxLiveWidgets(): number { + switch (getDeviceTier()) { + case 'low': return 3; + case 'mid': return 8; + default: return 24; + } +} diff --git a/vite.config.ts b/vite.config.ts index 07d8b355..7279b1cb 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -419,6 +419,16 @@ export default defineConfig({ }, output: { manualChunks(id) { + // Vite's __vitePreload helper is a shared runtime module used by every + // dynamic import(). Left alone, Rollup co-locates it into the 'map' + // manual chunk (a dynamic-import target), so the ENTRY statically + // imports 'map' just for that one helper — and Vite then modulepreloads + // the whole ~2.7 MB deck.gl + mapbox chunk before first paint. Pin the + // helper to its own tiny chunk so the entry never drags 'map' eagerly; + // the map then loads lazily via mountMap()'s dynamic import. + if (id.includes('vite/preload-helper')) { + return 'vite-preload'; + } if (id.includes('node_modules')) { if (id.includes('/@xenova/transformers/') || id.includes('/onnxruntime-web/')) { return 'ml';