From 6e116c4d2c35be73ad966d3e097bb517512b9725 Mon Sep 17 00:00:00 2001 From: John Carmack Date: Fri, 26 Jun 2026 21:57:48 -0700 Subject: [PATCH] Gate weather feeds on layer visibility; split heavy vendor chunks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Each weather feed now takes an `enabled` flag tied to its layer's visibility, so a hidden layer costs no fetch or polling. The big win: the 1.8 MB lattice.json no longer loads on every visit — only when temperature (off by default) is switched on. Alerts and the citytile axis still always load (the axis drives the shared timeline). Polling lazy-starts on first enable. - Split the GPU/map vendors (deck.gl/luma/deck-wind-layer, maplibre/react-map-gl) into their own chunks: the app chunk drops from ~1.1 MB to ~286 KB, vendors cache across deploys and download in parallel. (The wind double-decode the audit also flagged is deferred — sharing one texture between the fill raster and the particle layer needs deck-wind-layer to accept a preloaded texture; tracked separately.) --- web/src/App.tsx | 2 +- web/src/weather.ts | 58 ++++++++++++++++++++++++++++------------------ web/vite.config.ts | 27 +++++++++++++++++++-- 3 files changed, 62 insertions(+), 25 deletions(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index 0994d57..60ae22a 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -62,8 +62,8 @@ function useReducedMotion(): boolean { export default function App() { const [zoom, setZoom] = useState(INITIAL_VIEW.zoom); - const data = useWeatherData(); const [visible, setVisible] = useState(loadVisible); + const data = useWeatherData(visible); const [ui, setUi] = useState(seedUi); const [timeState, setTimeState] = useState(null); const [playing, setPlaying] = useState(false); diff --git a/web/src/weather.ts b/web/src/weather.ts index 7905b03..a31e874 100644 --- a/web/src/weather.ts +++ b/web/src/weather.ts @@ -44,9 +44,18 @@ export function age(ms?: number): string { return min <= 0 ? 'just now' : `${min} min ago`; } -function useFeed(path: string, intervalMs: number): T | null { +/** Poll a weather JSON feed. `enabled` gates it on the consuming layer's + * visibility — a feed for a hidden layer never fetches (and stops on hide), + * which keeps the 1.8 MB lattice off the critical path when temperature is off + * (the default). Polling lazy-starts the first time the layer is enabled. */ +function useFeed( + path: string, + intervalMs: number, + enabled = true, +): T | null { const [data, setData] = useState(null); useEffect(() => { + if (!enabled) return; let alive = true; const load = () => fetch(`${WEATHER_BASE}/weather/${path}`, { cache: 'no-cache' }) @@ -64,43 +73,47 @@ function useFeed(path: string, intervalMs: number): T | null { alive = false; clearInterval(timer); }; - }, [path, intervalMs]); + }, [path, intervalMs, enabled]); return data; } export const useAlerts = () => useFeed>('alerts.json', 60_000); -/** The whole-planet temperature lattice (the zoomed-out grid). */ -export const useLattice = () => useFeed('lattice.json', 600_000); +/** The whole-planet temperature lattice (the zoomed-out grid). 1.8 MB, so it's + * gated on the temperature layer being visible (off by default). */ +export const useLattice = (enabled: boolean) => + useFeed('lattice.json', 600_000, enabled); /** The point-forecast tile index (snapshot + hours). The citytile layer's - * TileLayer fetches the actual per-tile JSON on demand. */ + * TileLayer fetches the actual per-tile JSON on demand. Always loaded — it's + * also the map-wide timeline's axis. */ export const useCityTiles = () => useFeed('citytile/latest.json', 600_000); /** The wind u/v texture index (snapshot + forecast hours + m/s bounds). The * wind layer loads the per-step PNG nearest the map-wide timeline. */ -export const useWindTex = () => - useFeed('windtex/latest.json', 600_000); +export const useWindTex = (enabled: boolean) => + useFeed('windtex/latest.json', 600_000, enabled); /** The REFC precip texture index (snapshot + forecast hours + dBZ bounds). The * precipitation layer loads the per-step PNG nearest the map-wide timeline when * scrubbed into the future. */ -export const useRefcTex = () => - useFeed('refctex/latest.json', 600_000); +export const useRefcTex = (enabled: boolean) => + useFeed('refctex/latest.json', 600_000, enabled); /** The surface-CAPE texture index (snapshot + forecast hours + J/kg bounds). The * storm-potential overlay loads the per-step PNG nearest the map-wide timeline. */ -export const useCapeTex = () => - useFeed('capetex/latest.json', 600_000); +export const useCapeTex = (enabled: boolean) => + useFeed('capetex/latest.json', 600_000, enabled); /** * Latest worldwide radar frame from RainViewer. Falls back to the IEM * NEXRAD composite (US only) until — or unless — the API answers. */ -export function useRadarTiles(): RadarSource { +export function useRadarTiles(enabled = true): RadarSource { const [source, setSource] = useState(RADAR_FALLBACK); useEffect(() => { + if (!enabled) return; let alive = true; const load = () => fetch(RAINVIEWER_API) @@ -126,7 +139,7 @@ export function useRadarTiles(): RadarSource { alive = false; clearInterval(timer); }; - }, []); + }, [enabled]); return source; } @@ -146,16 +159,17 @@ export interface WeatherData { capeTex: CapeTexIndex | null; } -/** One hook, all feeds — keeps the layer registry itself hook-free. The - * temperature layer picks lattice vs. city tiles from `ctx.zoom` itself, so - * this hook no longer needs the zoom. */ -export function useWeatherData(): WeatherData { +/** One hook, all feeds — keeps the layer registry itself hook-free. `visible` + * (the layer-id → on/off map) gates each feed on its layer, so a hidden layer + * costs no fetch or polling; alerts and the citytile axis always load (the axis + * drives the shared timeline). */ +export function useWeatherData(visible: Record): WeatherData { const alerts = useAlerts(); - const lattice = useLattice(); - const radar = useRadarTiles(); + const lattice = useLattice(visible.temp); + const radar = useRadarTiles(visible.precip); const cityTiles = useCityTiles(); - const windTex = useWindTex(); - const refcTex = useRefcTex(); - const capeTex = useCapeTex(); + const windTex = useWindTex(visible.wind); + const refcTex = useRefcTex(visible.precip); + const capeTex = useCapeTex(visible.cape); return { alerts, radar, lattice, cityTiles, windTex, refcTex, capeTex }; } diff --git a/web/vite.config.ts b/web/vite.config.ts index ba74244..961ef94 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -1,8 +1,8 @@ import { execSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; -import { defineConfig } from 'vite'; -import react from '@vitejs/plugin-react'; import tailwindcss from '@tailwindcss/vite'; +import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vite'; /** The deployed version: the nearest `vX.Y.Z` release tag, plus `-N-g` * when the build is N commits past it (so the live label is commit-exact @@ -34,4 +34,27 @@ export default defineConfig({ __APP_VERSION__: JSON.stringify(appVersion()), __BUILD_TIME__: JSON.stringify(new Date().toISOString()), }, + build: { + rollupOptions: { + output: { + // Split the heavy GPU/map vendors into their own chunks so they cache + // across deploys (only the app chunk changes on most releases) and load + // in parallel instead of one ~1 MB blob. + manualChunks(id) { + if (!id.includes('node_modules')) return undefined; + if (id.includes('maplibre-gl') || id.includes('react-map-gl')) { + return 'maplibre'; + } + if ( + id.includes('@deck.gl') || + id.includes('@luma.gl') || + id.includes('deck-wind-layer') + ) { + return 'deck'; + } + return undefined; + }, + }, + }, + }, });