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:
Claude
2026-07-21 20:30:04 -07:00
committed by zeekay
parent d5e78d6bd6
commit 04257213b8
8 changed files with 321 additions and 94 deletions
+1 -1
View File
@@ -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
View File
@@ -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));
+74
View File
@@ -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);
}
}
+10 -4
View File
@@ -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;
}
+8 -2
View File
@@ -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
View File
@@ -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';
+124
View File
@@ -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;
}
}
+10
View File
@@ -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';