perf(world): defer the map off the first-paint critical path (eager JS 4.1MB→1.4MB)
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.
This commit is contained in:
+1
-1
@@ -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'",
|
||||
|
||||
+63
-34
@@ -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<void> = Promise.resolve();
|
||||
private mapResizeObserver: ResizeObserver | null = null;
|
||||
private immersive: ImmersiveController | null = null;
|
||||
private panels: Record<string, Panel> = {};
|
||||
@@ -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<void> {
|
||||
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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
+31
-53
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user