Performance: stop iPhone freezes (blur over live map, runaway animation, eager charts)
Publish Container / publish (push) Successful in 2m57s
Publish Container / publish (push) Successful in 2m57s
Main app - No backdrop-filter over the live map, animated sky, or sticky headings (it re-blurs every frame; on iPhone that is a classic stutter/lock-up); top/bottom bars keep a light 8px blur - Sky effect only draws while the hero is on screen and the Now tab is active, ~30fps cap, fewer particles on phones; fixed a stale animation-frame id that could stop restarts Dashboard - Charts are built only when the Charts tab is opened (was ~11 charts on every page load), and the 2-minute refresh no longer rebuilds them in the background - On phones: max 360 points per chart (was 720), no entry animation, device pixel ratio capped at 2 - Lighter header blur, none on small buttons/cards Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
dde59e3865
commit
fda8dca435
5 files changed
+53
-13
No files matched your search
@@ -330,3 +330,15 @@ body.map-fs #tab-unified-map { padding: 0; }
|
|||||||
.geo-status.hidden { display: none; }
|
.geo-status.hidden { display: none; }
|
||||||
.geo-status.ok { color: #22c55e; }
|
.geo-status.ok { color: #22c55e; }
|
||||||
.geo-status.warn { color: var(--ink); background: color-mix(in srgb, #ef4444 14%, var(--paper-darker)); border: 1px solid color-mix(in srgb, #ef4444 45%, transparent); }
|
.geo-status.warn { color: var(--ink); background: color-mix(in srgb, #ef4444 14%, var(--paper-darker)); border: 1px solid color-mix(in srgb, #ef4444 45%, transparent); }
|
||||||
|
|
||||||
|
/* ── Performance on phones ──
|
||||||
|
backdrop-filter re-blurs everything behind it on every frame. Over a live WebGL map, an animated sky
|
||||||
|
or sticky headings that is what makes iPhones stutter and lock up, so those use solid translucent fills. */
|
||||||
|
.hero-hilo, .sticky-head, .glass, .hud-btn, #tab-unified-map .map-readout-chip,
|
||||||
|
.map-layer-menu-panel, .map-timeline-wrap, .map-data-panel, .install-banner {
|
||||||
|
-webkit-backdrop-filter: none !important; backdrop-filter: none !important;
|
||||||
|
}
|
||||||
|
.glass, .hud-btn, #tab-unified-map .map-readout-chip { background: color-mix(in srgb, var(--paper-dark) 92%, transparent) !important; }
|
||||||
|
.sticky-head { background: color-mix(in srgb, var(--paper) 96%, transparent) !important; }
|
||||||
|
.hero-hilo { background: rgba(0, 0, 0, 0.24) !important; }
|
||||||
|
.topbar, .bottom-nav { -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px); }
|
||||||
+16
-7
@@ -53,7 +53,10 @@ function skyFor(code, isDay, stationRaining) {
|
|||||||
return isDay ? 'clear-day' : 'clear-night';
|
return isDay ? 'clear-day' : 'clear-night';
|
||||||
}
|
}
|
||||||
|
|
||||||
const fx = { raf: 0, kind: null, parts: [], w: 0, h: 0, flash: 0 };
|
const fx = { raf: 0, kind: null, parts: [], w: 0, h: 0, flash: 0, last: 0, onScreen: true };
|
||||||
|
const PHONE = window.matchMedia('(max-width: 640px)').matches;
|
||||||
|
const fxShouldRun = () => fx.parts.length && !REDUCED && !document.hidden && fx.onScreen && document.body.dataset.tab === 'conditions';
|
||||||
|
function fxKick() { if (!fx.raf && fxShouldRun()) fx.raf = requestAnimationFrame(fxFrame); }
|
||||||
function fxResize(canvas) {
|
function fxResize(canvas) {
|
||||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||||
fx.w = canvas.clientWidth; fx.h = canvas.clientHeight;
|
fx.w = canvas.clientWidth; fx.h = canvas.clientHeight;
|
||||||
@@ -61,7 +64,8 @@ function fxResize(canvas) {
|
|||||||
canvas.getContext('2d').setTransform(dpr, 0, 0, dpr, 0, 0);
|
canvas.getContext('2d').setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
}
|
}
|
||||||
function fxSeed(kind) {
|
function fxSeed(kind) {
|
||||||
const n = kind === 'storm' ? 130 : kind === 'rain' ? 85 : kind === 'snow' ? 70 : kind === 'clear-night' ? 55 : 0;
|
const base = kind === 'storm' ? 130 : kind === 'rain' ? 85 : kind === 'snow' ? 70 : kind === 'clear-night' ? 55 : 0;
|
||||||
|
const n = Math.round(base * (PHONE ? 0.55 : 1));
|
||||||
fx.parts = Array.from({ length: n }, () => ({
|
fx.parts = Array.from({ length: n }, () => ({
|
||||||
x: Math.random() * fx.w, y: Math.random() * fx.h,
|
x: Math.random() * fx.w, y: Math.random() * fx.h,
|
||||||
v: kind === 'snow' ? 0.4 + Math.random() * 0.9 : kind === 'clear-night' ? 0 : 6 + Math.random() * 7,
|
v: kind === 'snow' ? 0.4 + Math.random() * 0.9 : kind === 'clear-night' ? 0 : 6 + Math.random() * 7,
|
||||||
@@ -70,8 +74,11 @@ function fxSeed(kind) {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
function fxFrame(t) {
|
function fxFrame(t) {
|
||||||
|
fx.raf = 0;
|
||||||
const canvas = $('sky-fx');
|
const canvas = $('sky-fx');
|
||||||
if (!canvas) return;
|
if (!canvas || !fxShouldRun()) return; // hidden tab / scrolled away / app in background: stop drawing entirely
|
||||||
|
if (t - fx.last < 33) { fx.raf = requestAnimationFrame(fxFrame); return; } // cap at ~30 fps
|
||||||
|
fx.last = t;
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
ctx.clearRect(0, 0, fx.w, fx.h);
|
ctx.clearRect(0, 0, fx.w, fx.h);
|
||||||
const kind = fx.kind;
|
const kind = fx.kind;
|
||||||
@@ -100,21 +107,23 @@ function fxFrame(t) {
|
|||||||
ctx.beginPath(); ctx.arc(p.x, p.y * 0.8, p.r, 0, Math.PI * 2); ctx.fill();
|
ctx.beginPath(); ctx.arc(p.x, p.y * 0.8, p.r, 0, Math.PI * 2); ctx.fill();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!REDUCED && !document.hidden) fx.raf = requestAnimationFrame(fxFrame);
|
fx.raf = requestAnimationFrame(fxFrame);
|
||||||
}
|
}
|
||||||
function setSky(kind) {
|
function setSky(kind) {
|
||||||
const hero = $('hero'), canvas = $('sky-fx');
|
const hero = $('hero'), canvas = $('sky-fx');
|
||||||
if (!hero || !canvas) return;
|
if (!hero || !canvas) return;
|
||||||
hero.dataset.sky = kind;
|
hero.dataset.sky = kind;
|
||||||
cancelAnimationFrame(fx.raf);
|
cancelAnimationFrame(fx.raf);
|
||||||
|
fx.raf = 0;
|
||||||
fx.kind = kind;
|
fx.kind = kind;
|
||||||
fxResize(canvas);
|
fxResize(canvas);
|
||||||
fxSeed(kind);
|
fxSeed(kind);
|
||||||
if (fx.parts.length) fxFrame(performance.now());
|
if (fx.parts.length) fxKick();
|
||||||
else canvas.getContext('2d').clearRect(0, 0, fx.w, fx.h);
|
else canvas.getContext('2d').clearRect(0, 0, fx.w, fx.h);
|
||||||
}
|
}
|
||||||
window.addEventListener('resize', () => { const c = $('sky-fx'); if (c && fx.kind) { fxResize(c); fxSeed(fx.kind); } });
|
window.addEventListener('resize', () => { const c = $('sky-fx'); if (c && fx.kind) { fxResize(c); fxSeed(fx.kind); } });
|
||||||
document.addEventListener('visibilitychange', () => { if (!document.hidden && fx.parts.length && !REDUCED) { cancelAnimationFrame(fx.raf); fxFrame(performance.now()); } });
|
document.addEventListener('visibilitychange', fxKick);
|
||||||
|
if ('IntersectionObserver' in window && $('hero')) new IntersectionObserver((es) => { fx.onScreen = es[0].isIntersecting; fxKick(); }).observe($('hero'));
|
||||||
|
|
||||||
// ── Nowcast: next 2 hours from 15-minute precipitation ───────────────────────
|
// ── Nowcast: next 2 hours from 15-minute precipitation ───────────────────────
|
||||||
function nowcastSlots(data) {
|
function nowcastSlots(data) {
|
||||||
@@ -459,7 +468,7 @@ window.AppUI = {
|
|||||||
centerMap,
|
centerMap,
|
||||||
refreshGeoStatus: () => GeoStatus.refresh(),
|
refreshGeoStatus: () => GeoStatus.refresh(),
|
||||||
onAir(air) { renderAqi(air); },
|
onAir(air) { renderAqi(air); },
|
||||||
refreshFx() { const c = $('sky-fx'); if (c && fx.kind) { cancelAnimationFrame(fx.raf); fxResize(c); fxSeed(fx.kind); if (fx.parts.length) fxFrame(performance.now()); } },
|
refreshFx() { const c = $('sky-fx'); if (c && fx.kind) { cancelAnimationFrame(fx.raf); fx.raf = 0; fxResize(c); fxSeed(fx.kind); fxKick(); } },
|
||||||
onStation() { syncTopbarLocation(); renderStationContext(); },
|
onStation() { syncTopbarLocation(); renderStationContext(); },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -151,3 +151,7 @@ a { color: var(--ui); }
|
|||||||
.nws-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: #a78bfa; margin-left: 0.35rem; vertical-align: middle; }
|
.nws-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: #a78bfa; margin-left: 0.35rem; vertical-align: middle; }
|
||||||
.nws-card .kv b { font-size: 1.05rem; }
|
.nws-card .kv b { font-size: 1.05rem; }
|
||||||
.nws-raw { display: block; margin-top: 0.4rem; font-size: 0.7rem; overflow-wrap: anywhere; color: var(--ink-light); background: var(--paper); border: 1px solid var(--border); border-radius: 8px; padding: 0.35rem 0.55rem; }
|
.nws-raw { display: block; margin-top: 0.4rem; font-size: 0.7rem; overflow-wrap: anywhere; color: var(--ink-light); background: var(--paper); border: 1px solid var(--border); border-radius: 8px; padding: 0.35rem 0.55rem; }
|
||||||
|
|
||||||
|
/* Performance on phones: blur only the two big fixed bars, lightly */
|
||||||
|
.logout, .range-btn, .refresh-btn, .chart-card, .stat, .today-bar { -webkit-backdrop-filter: none !important; backdrop-filter: none !important; }
|
||||||
|
.header, .tabbar { -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px); }
|
||||||
@@ -51,6 +51,7 @@ function setTab(name, updateHash = true) {
|
|||||||
}
|
}
|
||||||
function activeTab() { return document.querySelector('.tab.active')?.dataset.tab || 'now'; }
|
function activeTab() { return document.querySelector('.tab.active')?.dataset.tab || 'now'; }
|
||||||
function renderTab(name) {
|
function renderTab(name) {
|
||||||
|
if (name === 'charts' && typeof chartsDirty !== 'undefined' && chartsDirty && typeof loadHistory === 'function') loadHistory(currentRange);
|
||||||
if (name === 'rain' && !rendered.rain) { renderRain(); rendered.rain = true; }
|
if (name === 'rain' && !rendered.rain) { renderRain(); rendered.rain = true; }
|
||||||
if (name === 'records' && !rendered.cal) { renderCalendar(); rendered.cal = true; }
|
if (name === 'records' && !rendered.cal) { renderCalendar(); rendered.cal = true; }
|
||||||
if (name === 'insights' && !rendered.insights) { renderInsights(); rendered.insights = true; }
|
if (name === 'insights' && !rendered.insights) { renderInsights(); rendered.insights = true; }
|
||||||
|
|||||||
@@ -614,6 +614,18 @@ const RANGE_LABELS = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let currentRange = '24h';
|
let currentRange = '24h';
|
||||||
|
|
||||||
|
// ── Phone-friendly charts ──
|
||||||
|
// Fewer points, no entry animation and a capped pixel ratio keep 11 charts cheap on an iPhone, and charts
|
||||||
|
// are only built while the Charts tab is actually on screen.
|
||||||
|
const NARROW_SCREEN = window.innerWidth < 700;
|
||||||
|
const MAX_PTS = NARROW_SCREEN ? 360 : 720;
|
||||||
|
let chartsDirty = true;
|
||||||
|
const chartsPaneActive = () => !!document.querySelector('.tab-pane[data-pane="charts"].active');
|
||||||
|
if (typeof Chart !== 'undefined') {
|
||||||
|
if (window.matchMedia('(pointer: coarse)').matches) Chart.defaults.animation = false;
|
||||||
|
Chart.defaults.devicePixelRatio = Math.min(window.devicePixelRatio || 1, 2);
|
||||||
|
}
|
||||||
let charts = {};
|
let charts = {};
|
||||||
|
|
||||||
// ── Formatting helpers ──────────────────────────────────────────────────────
|
// ── Formatting helpers ──────────────────────────────────────────────────────
|
||||||
@@ -807,7 +819,7 @@ async function drawPresTend(pagePts, pageRange) {
|
|||||||
if (hit && Date.now() - hit.at < 120000) pts = hit.data;
|
if (hit && Date.now() - hit.at < 120000) pts = hit.data;
|
||||||
else {
|
else {
|
||||||
try {
|
try {
|
||||||
const bucket = ['7d', '30d', 'all'].includes(range) ? '&points=720' : '';
|
const bucket = ['7d', '30d', 'all'].includes(range) ? '&points=' + MAX_PTS : '';
|
||||||
const r = await fetch(`/api/station/history?range=${range}${bucket}`);
|
const r = await fetch(`/api/station/history?range=${range}${bucket}`);
|
||||||
pts = r.ok ? await r.json() : [];
|
pts = r.ok ? await r.json() : [];
|
||||||
} catch { pts = []; }
|
} catch { pts = []; }
|
||||||
@@ -815,7 +827,7 @@ async function drawPresTend(pagePts, pageRange) {
|
|||||||
}
|
}
|
||||||
if (document.getElementById('cPresTend') !== canvas) return; // charts were re-rendered meanwhile
|
if (document.getElementById('cPresTend') !== canvas) return; // charts were re-rendered meanwhile
|
||||||
}
|
}
|
||||||
if (pts.length > 900) { const step = Math.ceil(pts.length / 720); pts = pts.filter((_, i) => i % step === 0); }
|
if (pts.length > MAX_PTS * 1.25) { const step = Math.ceil(pts.length / MAX_PTS); pts = pts.filter((_, i) => i % step === 0); }
|
||||||
// Bucketed (long) ranges can't resolve changes shorter than one bucket: widen the window to fit
|
// Bucketed (long) ranges can't resolve changes shorter than one bucket: widen the window to fit
|
||||||
const bucketMs = pts.length > 1 ? (pts[pts.length - 1].dateutc - pts[0].dateutc) / (pts.length - 1) : 0;
|
const bucketMs = pts.length > 1 ? (pts[pts.length - 1].dateutc - pts[0].dateutc) / (pts.length - 1) : 0;
|
||||||
const effHours = Math.max(presTendWin, Math.ceil(bucketMs / 3600000));
|
const effHours = Math.max(presTendWin, Math.ceil(bucketMs / 3600000));
|
||||||
@@ -892,8 +904,8 @@ function renderCharts(readings, range, yoy = null, nws = null) {
|
|||||||
|
|
||||||
// downsample for 30d to keep chart manageable
|
// downsample for 30d to keep chart manageable
|
||||||
let pts = readings;
|
let pts = readings;
|
||||||
if (readings.length > 720) {
|
if (readings.length > MAX_PTS) {
|
||||||
const step = Math.ceil(readings.length / 720);
|
const step = Math.ceil(readings.length / MAX_PTS);
|
||||||
pts = readings.filter((_, i) => i % step === 0);
|
pts = readings.filter((_, i) => i % step === 0);
|
||||||
}
|
}
|
||||||
const lbls = pts.map(r => fmtTime(r.dateutc, range));
|
const lbls = pts.map(r => fmtTime(r.dateutc, range));
|
||||||
@@ -990,7 +1002,7 @@ function renderCharts(readings, range, yoy = null, nws = null) {
|
|||||||
const addNws = (sets, key, suffix, dashed) => { const a = nwsAlign(pts, nws, key); if (a) sets.push(nwsDataset(`${nwsLab} ${suffix}`, a, dashed)); };
|
const addNws = (sets, key, suffix, dashed) => { const a = nwsAlign(pts, nws, key); if (a) sets.push(nwsDataset(`${nwsLab} ${suffix}`, a, dashed)); };
|
||||||
addNws(tempDatasets, 'tempf', 'temp');
|
addNws(tempDatasets, 'tempf', 'temp');
|
||||||
if (yoy?.prior?.length) {
|
if (yoy?.prior?.length) {
|
||||||
const yoyPts = yoy.prior.length > 720 ? yoy.prior.filter((_, i) => i % Math.ceil(yoy.prior.length / 720) === 0) : yoy.prior;
|
const yoyPts = yoy.prior.length > MAX_PTS ? yoy.prior.filter((_, i) => i % Math.ceil(yoy.prior.length / MAX_PTS) === 0) : yoy.prior;
|
||||||
tempDatasets.push({ ...lineDataset('Last Year', yoyPts.map(r => r.tempf), c.accent + '55', true), borderWidth: 1 });
|
tempDatasets.push({ ...lineDataset('Last Year', yoyPts.map(r => r.tempf), c.accent + '55', true), borderWidth: 1 });
|
||||||
}
|
}
|
||||||
makeChart('cTemp', 'line', tempDatasets, lbls, '°F', { plugins: { legend: { labels: { filter: i => !['Max', 'Min'].includes(i.text) } } } });
|
makeChart('cTemp', 'line', tempDatasets, lbls, '°F', { plugins: { legend: { labels: { filter: i => !['Max', 'Min'].includes(i.text) } } } });
|
||||||
@@ -1164,10 +1176,12 @@ async function updateLatest() {
|
|||||||
|
|
||||||
// ── Load history ─────────────────────────────────────────────────────────────
|
// ── Load history ─────────────────────────────────────────────────────────────
|
||||||
async function loadHistory(range) {
|
async function loadHistory(range) {
|
||||||
|
if (!chartsPaneActive()) { chartsDirty = true; return; } // build charts when the Charts tab is opened
|
||||||
|
chartsDirty = false;
|
||||||
const area = document.getElementById('chartsArea');
|
const area = document.getElementById('chartsArea');
|
||||||
area.innerHTML = '<div class="status" id="loadingMsg">Loading…</div>';
|
area.innerHTML = '<div class="status" id="loadingMsg">Loading…</div>';
|
||||||
try {
|
try {
|
||||||
const pts = ['7d','30d','90d','1y','2y','3y','all'].includes(range) ? '&points=720' : '';
|
const pts = ['7d','30d','90d','1y','2y','3y','all'].includes(range) ? '&points=' + MAX_PTS : '';
|
||||||
const nwsP = nwsOverlay ? fetch(`/api/nws/history?range=${range}`).then(x => (x.ok ? x.json() : [])).catch(() => []) : Promise.resolve([]);
|
const nwsP = nwsOverlay ? fetch(`/api/nws/history?range=${range}`).then(x => (x.ok ? x.json() : [])).catch(() => []) : Promise.resolve([]);
|
||||||
const r = await fetch(`/api/station/history?range=${range}${pts}`);
|
const r = await fetch(`/api/station/history?range=${range}${pts}`);
|
||||||
if (r.status === 401) { location.href = '/dashboard/login'; return; }
|
if (r.status === 401) { location.href = '/dashboard/login'; return; }
|
||||||
|
|||||||
Reference in new issue
Block a user