3.2.2
Updated logo Added average seasonal temps compare
This commit is contained in:
+9
-4
@@ -176,7 +176,7 @@ export function UTCIForecast() {
|
||||
// useAppState. See hooks/useAppState.js for the full reading order.
|
||||
const {
|
||||
location, setLocationAndSave, recentLocations,
|
||||
forecast, airQuality, loading, error, now, fetchedAt,
|
||||
forecast, airQuality, loading, error, now, fetchedAt, normals,
|
||||
searchQuery, setSearchQuery, searchResults, setSearchResults, searching,
|
||||
selectedDay, setSelectedDay,
|
||||
proPromptDay, setProPromptDay,
|
||||
@@ -469,6 +469,9 @@ export function UTCIForecast() {
|
||||
// Farming uses these for seasonal sow/harvest advice (week's dates + weather).
|
||||
activeProfile === 'farming' ? days : null,
|
||||
location?.lat,
|
||||
// Climate normals + selected day's date drive the "vs seasonal average" row.
|
||||
normals,
|
||||
days[selectedDay]?.key,
|
||||
);
|
||||
|
||||
// Human-readable date for the "Day at a glance" heading, e.g. "Mon 2nd June 2026".
|
||||
@@ -539,7 +542,7 @@ export function UTCIForecast() {
|
||||
<nav class="utci-topnav" id="site-nav">
|
||||
<div class="nav-overlay" onClick=${() => { const n = document.getElementById('site-nav'); n.classList.remove('nav-open'); document.getElementById('nav-drawer').classList.remove('is-open'); }}></div>
|
||||
<div class="nav-logo" aria-hidden="true">
|
||||
<span class="nav-logo-wordmark"><span class="nav-logo-sun">Sun</span><svg class="nav-logo-icon" viewBox="0 0 174 173" aria-hidden="true" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;"><g transform="matrix(0.986226,0,0,0.986226,-585.724252,-110.041209)"><clipPath id="ss-clip-n"><circle cx="681.9838" cy="199.4502" r="83.2987"/></clipPath><g clip-path="url(#ss-clip-n)"><g transform="matrix(0.948836,0,0,0.948836,19.787839,-2.49381)"><path d="M666.992,233.0768C666.3949,230.6581 666.1785,228.6317 666.1785,226.0296C666.1785,208.6786 680.2653,194.5919 697.6163,194.5919C714.9672,194.5919 729.054,208.6786 729.054,226.0296C729.054,228.4125 728.9892,230.9346 728.4859,233.1663" fill="none" stroke="#c8922a" stroke-width="7.48"/></g><g transform="matrix(0.520349,0,0,0.520349,304.766642,64.226716)"><path d="M724.9315,291.8131C777.359,291.8131 826.5862,305.6245 869.1698,329.8044" fill="none" stroke="currentColor" stroke-width="13.64"/></g><g transform="matrix(0.520349,0,0,0.520349,304.766642,64.226716)"><path d="M580.8436,329.719C623.3927,305.592 672.5657,291.8131 724.9315,291.8131" fill="none" stroke="currentColor" stroke-width="13.64"/></g></g><circle cx="681.9838" cy="199.4502" r="83.2987" fill="none" stroke="currentColor" stroke-width="9.13"/></g></svg><span class="nav-logo-scope">Scope</span></span>
|
||||
<span class="nav-logo-wordmark"><span class="nav-logo-sun">Sun</span><svg class="nav-logo-icon" viewBox="0 0 174 173" aria-hidden="true" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;"><g transform="matrix(0.986226,0,0,0.986226,-585.724252,-110.041209)"><clipPath id="ss-clip-n"><circle cx="681.9838" cy="199.4502" r="83.2987"/></clipPath><g clip-path="url(#ss-clip-n)"><g transform="matrix(0.948836,0,0,0.948836,19.748711,-12.198562)"><path d="M666.992,233.0768C666.3949,230.6581 666.1785,228.6317 666.1785,226.0296C666.1785,208.6786 680.2653,194.5919 697.6163,194.5919C714.9672,194.5919 729.054,208.6786 729.054,226.0296C729.054,228.4125 728.9892,230.9346 728.4859,233.1663" fill="#f4b047" stroke="#f4b047" stroke-width="7.48" stroke-linecap="round"/></g><g transform="matrix(0.520349,0,0,0.520349,304.727514,54.521964)"><path d="M724.9315,291.8131C777.359,291.8131 826.5862,305.6245 869.1698,329.8044" fill="none" stroke="currentColor" stroke-width="13.64"/></g><g transform="matrix(0.520349,0,0,0.520349,304.727514,54.521964)"><path d="M580.8436,329.719C623.3927,305.592 672.5657,291.8131 724.9315,291.8131" fill="none" stroke="currentColor" stroke-width="13.64"/></g></g><circle cx="681.9838" cy="199.4502" r="83.2987" fill="none" stroke="currentColor" stroke-width="9.13"/></g></svg><span class="nav-logo-scope">Scope</span></span>
|
||||
<span class="nav-logo-tag">See the world the way your skin does.</span>
|
||||
</div>
|
||||
<div class="nav-links" id="nav-drawer">
|
||||
@@ -648,7 +651,7 @@ export function UTCIForecast() {
|
||||
<div class="utci-header">
|
||||
<div class="header-left">
|
||||
<h1 class="utci-title">
|
||||
<span class="title-sun">Sun</span><svg class="title-icon" viewBox="0 0 174 173" aria-hidden="true" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;"><g transform="matrix(0.986226,0,0,0.986226,-585.724252,-110.041209)"><clipPath id="ss-clip-t"><circle cx="681.9838" cy="199.4502" r="83.2987"/></clipPath><g clip-path="url(#ss-clip-t)"><g transform="matrix(0.948836,0,0,0.948836,19.787839,-2.49381)"><path d="M666.992,233.0768C666.3949,230.6581 666.1785,228.6317 666.1785,226.0296C666.1785,208.6786 680.2653,194.5919 697.6163,194.5919C714.9672,194.5919 729.054,208.6786 729.054,226.0296C729.054,228.4125 728.9892,230.9346 728.4859,233.1663" fill="none" stroke="#c8922a" stroke-width="7.48"/></g><g transform="matrix(0.520349,0,0,0.520349,304.766642,64.226716)"><path d="M724.9315,291.8131C777.359,291.8131 826.5862,305.6245 869.1698,329.8044" fill="none" stroke="currentColor" stroke-width="13.64"/></g><g transform="matrix(0.520349,0,0,0.520349,304.766642,64.226716)"><path d="M580.8436,329.719C623.3927,305.592 672.5657,291.8131 724.9315,291.8131" fill="none" stroke="currentColor" stroke-width="13.64"/></g></g><circle cx="681.9838" cy="199.4502" r="83.2987" fill="none" stroke="currentColor" stroke-width="9.13"/></g></svg><span class="title-scope">Scope</span>
|
||||
<span class="title-sun">Sun</span><svg class="title-icon" viewBox="0 0 174 173" aria-hidden="true" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;"><g transform="matrix(0.986226,0,0,0.986226,-585.724252,-110.041209)"><clipPath id="ss-clip-t"><circle cx="681.9838" cy="199.4502" r="83.2987"/></clipPath><g clip-path="url(#ss-clip-t)"><g transform="matrix(0.948836,0,0,0.948836,19.748711,-12.198562)"><path d="M666.992,233.0768C666.3949,230.6581 666.1785,228.6317 666.1785,226.0296C666.1785,208.6786 680.2653,194.5919 697.6163,194.5919C714.9672,194.5919 729.054,208.6786 729.054,226.0296C729.054,228.4125 728.9892,230.9346 728.4859,233.1663" fill="#f4b047" stroke="#f4b047" stroke-width="7.48" stroke-linecap="round"/></g><g transform="matrix(0.520349,0,0,0.520349,304.727514,54.521964)"><path d="M724.9315,291.8131C777.359,291.8131 826.5862,305.6245 869.1698,329.8044" fill="none" stroke="currentColor" stroke-width="13.64"/></g><g transform="matrix(0.520349,0,0,0.520349,304.727514,54.521964)"><path d="M580.8436,329.719C623.3927,305.592 672.5657,291.8131 724.9315,291.8131" fill="none" stroke="currentColor" stroke-width="13.64"/></g></g><circle cx="681.9838" cy="199.4502" r="83.2987" fill="none" stroke="currentColor" stroke-width="9.13"/></g></svg><span class="title-scope">Scope</span>
|
||||
</h1>
|
||||
<div class="utci-tagline">See the world the way your skin does.</div>
|
||||
<div class="utci-loc-search" ref=${searchWrapRef}>
|
||||
@@ -1646,7 +1649,8 @@ export function UTCIForecast() {
|
||||
const hasFeltAnchor = glanceSummary.some(i => i.label === 'Peak felt temp');
|
||||
const comfortItem = hasFeltAnchor ? glanceSummary.find(i => i.label === 'Comfortable window') : null;
|
||||
const drivingItem = hasFeltAnchor ? glanceSummary.find(i => i.label === 'Good driving time') : null;
|
||||
const restSummary = glanceSummary.filter(i => !(hasFeltAnchor && (i.label === 'Comfortable window' || i.label === 'Good driving time')));
|
||||
const climateItem = hasFeltAnchor ? glanceSummary.find(i => i.label === '1991-2020 Average') : null;
|
||||
const restSummary = glanceSummary.filter(i => !(hasFeltAnchor && (i.label === 'Comfortable window' || i.label === 'Good driving time' || i.label === '1991-2020 Average')));
|
||||
return [
|
||||
...restSummary.flatMap(item => [
|
||||
renderRow(item),
|
||||
@@ -1655,6 +1659,7 @@ export function UTCIForecast() {
|
||||
? [
|
||||
...heatEvents.map(e => renderRow(e, 'ev-')),
|
||||
...(drivingItem ? [renderRow(drivingItem)] : []),
|
||||
...(climateItem ? [renderRow(climateItem)] : []),
|
||||
...(comfortItem ? [renderRow(comfortItem)] : []),
|
||||
]
|
||||
: []),
|
||||
|
||||
@@ -29,6 +29,6 @@ export function Wordmark({ fontSize = '34px' }) {
|
||||
letterSpacing: '-0.01em',
|
||||
color: '#1e1208',
|
||||
}}>
|
||||
<span>Sun</span><svg viewBox="0 0 174 173" aria-hidden="true" style=${{ width: '0.82em', height: '0.82em', flexShrink: 0 }}><g transform="matrix(0.986226,0,0,0.986226,-585.724252,-110.041209)"><clipPath id=${clipId}><circle cx="681.9838" cy="199.4502" r="83.2987"/></clipPath><g clip-path=${`url(#${clipId})`}><g transform="matrix(0.948836,0,0,0.948836,19.787839,-2.49381)"><path d="M666.992,233.0768C666.3949,230.6581 666.1785,228.6317 666.1785,226.0296C666.1785,208.6786 680.2653,194.5919 697.6163,194.5919C714.9672,194.5919 729.054,208.6786 729.054,226.0296C729.054,228.4125 728.9892,230.9346 728.4859,233.1663" fill="none" stroke="#c8922a" stroke-width="7.48"/></g><g transform="matrix(0.520349,0,0,0.520349,304.766642,64.226716)"><path d="M724.9315,291.8131C777.359,291.8131 826.5862,305.6245 869.1698,329.8044" fill="none" stroke="currentColor" stroke-width="13.64"/></g><g transform="matrix(0.520349,0,0,0.520349,304.766642,64.226716)"><path d="M580.8436,329.719C623.3927,305.592 672.5657,291.8131 724.9315,291.8131" fill="none" stroke="currentColor" stroke-width="13.64"/></g></g><circle cx="681.9838" cy="199.4502" r="83.2987" fill="none" stroke="currentColor" stroke-width="9.13"/></g></svg><span>Scope</span>
|
||||
<span>Sun</span><svg viewBox="0 0 174 173" aria-hidden="true" style=${{ width: '0.82em', height: '0.82em', flexShrink: 0 }}><g transform="matrix(0.986226,0,0,0.986226,-585.724252,-110.041209)"><clipPath id=${clipId}><circle cx="681.9838" cy="199.4502" r="83.2987"/></clipPath><g clip-path=${`url(#${clipId})`}><g transform="matrix(0.948836,0,0,0.948836,19.748711,-12.198562)"><path d="M666.992,233.0768C666.3949,230.6581 666.1785,228.6317 666.1785,226.0296C666.1785,208.6786 680.2653,194.5919 697.6163,194.5919C714.9672,194.5919 729.054,208.6786 729.054,226.0296C729.054,228.4125 728.9892,230.9346 728.4859,233.1663" fill="#f4b047" stroke="#f4b047" stroke-width="7.48" stroke-linecap="round"/></g><g transform="matrix(0.520349,0,0,0.520349,304.727514,54.521964)"><path d="M724.9315,291.8131C777.359,291.8131 826.5862,305.6245 869.1698,329.8044" fill="none" stroke="currentColor" stroke-width="13.64"/></g><g transform="matrix(0.520349,0,0,0.520349,304.727514,54.521964)"><path d="M580.8436,329.719C623.3927,305.592 672.5657,291.8131 724.9315,291.8131" fill="none" stroke="currentColor" stroke-width="13.64"/></g></g><circle cx="681.9838" cy="199.4502" r="83.2987" fill="none" stroke="currentColor" stroke-width="9.13"/></g></svg><span>Scope</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
+33
-1
@@ -381,7 +381,7 @@ export function computeWhyFeelsLike(row, env) {
|
||||
// variant - active sub-variant key e.g. "running", "beach" (or null)
|
||||
// skinType - Fitzpatrick skin type key for UV burn time
|
||||
// ------------------------------------------------------------------------
|
||||
export function computeGlanceSummary(todayRows, profile, variant, skinType, cols = null, vehicleSpeed = 'static', weekDays = null, lat = null) {
|
||||
export function computeGlanceSummary(todayRows, profile, variant, skinType, cols = null, vehicleSpeed = 'static', weekDays = null, lat = null, normals = null, dayKey = null) {
|
||||
if (!todayRows || todayRows.length === 0) return [];
|
||||
const show = (key) => !cols || !!cols[key];
|
||||
|
||||
@@ -491,6 +491,33 @@ export function computeGlanceSummary(todayRows, profile, variant, skinType, cols
|
||||
alert: maxRainProb >= 60,
|
||||
};
|
||||
|
||||
// ── Vs seasonal average (climate anomaly) ──────────────────────────────
|
||||
// Shown across every profile: how far this day's mean air temp sits above or
|
||||
// below the 1991-2020 normal for the date. A small, honest climate-change cue.
|
||||
const climateItem = (() => {
|
||||
if (!normals) return [];
|
||||
const key = dayKey || todayRows[0]?.iso?.slice(0, 10);
|
||||
if (!key) return [];
|
||||
const LEAP_CUM = [0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335];
|
||||
const doy = LEAP_CUM[parseInt(key.slice(5, 7), 10) - 1] + (parseInt(key.slice(8, 10), 10) - 1);
|
||||
const normal = normals[doy];
|
||||
if (normal == null) return [];
|
||||
const taVals = todayRows.map(r => r.Ta).filter(v => v != null);
|
||||
if (taVals.length === 0) return [];
|
||||
const dayMean = taVals.reduce((a, b) => a + b, 0) / taVals.length;
|
||||
const anomaly = dayMean - normal;
|
||||
const mag = Math.abs(anomaly);
|
||||
const value = mag < 0.5
|
||||
? 'About average'
|
||||
: `${anomaly >= 0 ? '+' : '−'}${mag.toFixed(1)}° ${anomaly >= 0 ? 'warmer' : 'cooler'}`;
|
||||
return [{
|
||||
icon: '🌡',
|
||||
label: '1991-2020 Average',
|
||||
value,
|
||||
alert: anomaly >= 5,
|
||||
}];
|
||||
})();
|
||||
|
||||
// ── Good Driving Time ──────────────────────────────────────────────────
|
||||
// Shown whenever a road speed (not Static) is selected and the vehicle cabin
|
||||
// column is active - so it appears in both the Vehicle and Driver profiles.
|
||||
@@ -527,6 +554,7 @@ export function computeGlanceSummary(todayRows, profile, variant, skinType, cols
|
||||
const maxPollen = Math.max(0, ...todayRows.map(r => r.grassPollen ?? 0));
|
||||
|
||||
return [
|
||||
...climateItem,
|
||||
{
|
||||
icon: '⏱',
|
||||
label: 'Best field work',
|
||||
@@ -563,6 +591,7 @@ export function computeGlanceSummary(todayRows, profile, variant, skinType, cols
|
||||
const dangerFrom = todayRows.find(r => r.vehicleT != null && r.vehicleT >= 29);
|
||||
|
||||
return [
|
||||
...climateItem,
|
||||
...(show('vehicleT') ? [{
|
||||
icon: '🌡',
|
||||
label: 'Peak cabin temp',
|
||||
@@ -601,6 +630,7 @@ export function computeGlanceSummary(todayRows, profile, variant, skinType, cols
|
||||
const maxPollen = Math.max(0, ...todayRows.map(r => r.grassPollen ?? 0));
|
||||
|
||||
return [
|
||||
...climateItem,
|
||||
...(show('indoorT') ? [{
|
||||
icon: '🌡',
|
||||
label: 'Peak indoor (unmanaged)',
|
||||
@@ -648,6 +678,7 @@ export function computeGlanceSummary(todayRows, profile, variant, skinType, cols
|
||||
: null;
|
||||
|
||||
return [
|
||||
...climateItem,
|
||||
{
|
||||
icon: '⏱',
|
||||
label: `Best ${variant} window`,
|
||||
@@ -690,6 +721,7 @@ export function computeGlanceSummary(todayRows, profile, variant, skinType, cols
|
||||
: null;
|
||||
|
||||
return [
|
||||
...climateItem,
|
||||
...(drivingShown ? [drivingItem] : []),
|
||||
{
|
||||
icon: '🌤',
|
||||
|
||||
@@ -100,7 +100,7 @@ export function useAppState() {
|
||||
return localStorage.getItem('sunscope_pro') === '1';
|
||||
});
|
||||
|
||||
const { forecast, airQuality, loading, error, now, fetchedAt, liveElev } = useForecast(location, isPro);
|
||||
const { forecast, airQuality, loading, error, now, fetchedAt, liveElev, normals } = useForecast(location, isPro);
|
||||
|
||||
useEffect(() => { track('visit'); }, []);
|
||||
|
||||
@@ -617,7 +617,7 @@ export function useAppState() {
|
||||
// location
|
||||
location, setLocationAndSave, recentLocations,
|
||||
// forecast
|
||||
forecast, airQuality, loading, error, now, fetchedAt,
|
||||
forecast, airQuality, loading, error, now, fetchedAt, normals,
|
||||
// search
|
||||
searchQuery, setSearchQuery,
|
||||
searchResults, setSearchResults,
|
||||
|
||||
@@ -39,6 +39,7 @@ const FIFTEEN_MIN_MS = 15 * 60 * 1000;
|
||||
const THIRTY_MIN_MS = 30 * 60 * 1000;
|
||||
const SIX_HOURS_MS = 6 * 60 * 60 * 1000;
|
||||
const ONE_MIN_MS = 1 * 60 * 1000;
|
||||
const SIXTY_DAYS_MS = 60 * 24 * 60 * 60 * 1000;
|
||||
|
||||
function buildForecastUrl(loc) {
|
||||
return `https://api.open-meteo.com/v1/forecast` +
|
||||
@@ -85,6 +86,85 @@ function soilCacheKey(loc) {
|
||||
return `sunscope_soil_${loc.lat.toFixed(4)}_${loc.lon.toFixed(4)}`;
|
||||
}
|
||||
|
||||
// ─── CLIMATE NORMALS (ERA5 archive) ───────────────────────────────────────
|
||||
// Open-Meteo's Historical Archive gives daily mean temps back to 1940. We pull
|
||||
// the WMO reference period (1991-2020) once per location and reduce it into a
|
||||
// 366-entry day-of-year table of "normal" daily-mean temperature, so the app
|
||||
// can show how far the forecast for a day sits above/below its seasonal norm.
|
||||
// Normals don't change, so this caches for a long time.
|
||||
|
||||
function buildClimateUrl(loc) {
|
||||
return `https://archive-api.open-meteo.com/v1/archive` +
|
||||
`?latitude=${loc.lat}&longitude=${loc.lon}` +
|
||||
`&start_date=1991-01-01&end_date=2020-12-31` +
|
||||
`&daily=temperature_2m_mean&timezone=auto`;
|
||||
}
|
||||
|
||||
// Coarser key (~2 dp ≈ 1 km) so nearby lookups share one cached climatology.
|
||||
function climateCacheKey(loc) {
|
||||
return `sunscope_normals_${loc.lat.toFixed(2)}_${loc.lon.toFixed(2)}`;
|
||||
}
|
||||
|
||||
// Cumulative days before each month on a leap-year calendar, so every date
|
||||
// maps to a stable day-of-year slot (Mar 1 is always 60, Feb 29 is 59).
|
||||
const LEAP_CUM = [0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335];
|
||||
function doyFromKey(key) {
|
||||
const m = parseInt(key.slice(5, 7), 10);
|
||||
const d = parseInt(key.slice(8, 10), 10);
|
||||
return LEAP_CUM[m - 1] + (d - 1); // 0..365
|
||||
}
|
||||
|
||||
// Reduce raw daily means into a smoothed 366-entry day-of-year normals table.
|
||||
function reduceNormals(daily) {
|
||||
const times = daily?.time;
|
||||
const means = daily?.temperature_2m_mean;
|
||||
if (!times || !means) return null;
|
||||
const sums = new Array(366).fill(0);
|
||||
const counts = new Array(366).fill(0);
|
||||
for (let i = 0; i < times.length; i++) {
|
||||
const v = means[i];
|
||||
if (v == null) continue;
|
||||
const doy = doyFromKey(times[i]);
|
||||
sums[doy] += v;
|
||||
counts[doy] += 1;
|
||||
}
|
||||
const raw = sums.map((s, i) => (counts[i] > 0 ? s / counts[i] : null));
|
||||
// ±7-day circular smoothing to remove single-day sampling noise.
|
||||
const WIN = 7;
|
||||
const smooth = new Array(366).fill(null);
|
||||
for (let i = 0; i < 366; i++) {
|
||||
let s = 0, n = 0;
|
||||
for (let k = -WIN; k <= WIN; k++) {
|
||||
const v = raw[(i + k + 366) % 366];
|
||||
if (v != null) { s += v; n += 1; }
|
||||
}
|
||||
if (n > 0) smooth[i] = s / n;
|
||||
}
|
||||
return smooth;
|
||||
}
|
||||
|
||||
// Fetches (or reads cached) climate normals for a location. Returns a
|
||||
// 366-entry array of daily-mean °C, or null on any failure (feature hides).
|
||||
async function fetchClimateNormals(loc) {
|
||||
const key = climateCacheKey(loc);
|
||||
try {
|
||||
const cached = localStorage.getItem(key);
|
||||
if (cached) {
|
||||
const { ts, normals } = JSON.parse(cached);
|
||||
if (normals && Date.now() - ts < SIXTY_DAYS_MS) return normals;
|
||||
}
|
||||
} catch (e) { /* ignore bad cache */ }
|
||||
try {
|
||||
const r = await fetch(buildClimateUrl(loc));
|
||||
if (!r.ok) return null;
|
||||
const json = await r.json();
|
||||
const normals = reduceNormals(json.daily);
|
||||
if (!normals) return null;
|
||||
try { localStorage.setItem(key, JSON.stringify({ ts: Date.now(), normals })); } catch (e) {}
|
||||
return normals;
|
||||
} catch (e) { return null; } // network failure — feature just hides
|
||||
}
|
||||
|
||||
// Fetches ICON soil hourly data with its own cache.
|
||||
// Returns stale cache of any age if the fetch fails — caller uses this as last resort.
|
||||
async function fetchSoilHourly(loc) {
|
||||
@@ -130,6 +210,7 @@ export function useForecast(location, isPro = false) {
|
||||
const [error, setError] = useState(null);
|
||||
const [now, setNow] = useState(new Date());
|
||||
const [fetchedAt, setFetchedAt] = useState(null);
|
||||
const [normals, setNormals] = useState(null);
|
||||
|
||||
// Mirrors the latest forecast so the loader's catch block can tell whether
|
||||
// data is already on screen, even inside stale setInterval closures.
|
||||
@@ -236,6 +317,12 @@ export function useForecast(location, isPro = false) {
|
||||
useEffect(() => {
|
||||
loadForecast(location);
|
||||
loadAirQuality(location);
|
||||
// Climate normals are supplementary — load them without blocking. Clear
|
||||
// first so a previous location's normals don't linger if this fetch fails.
|
||||
setNormals(null);
|
||||
if (location?.lat != null) {
|
||||
fetchClimateNormals(location).then(n => { if (n) setNormals(n); });
|
||||
}
|
||||
}, [location]);
|
||||
|
||||
// ─── 1-MINUTE CLOCK TICK (current-row highlight + live scope) ─────
|
||||
@@ -276,5 +363,5 @@ export function useForecast(location, isPro = false) {
|
||||
return () => clearInterval(id);
|
||||
}, [location, isPro]);
|
||||
|
||||
return { forecast, airQuality, loading, error, now, fetchedAt, liveElev };
|
||||
return { forecast, airQuality, loading, error, now, fetchedAt, liveElev, normals };
|
||||
}
|
||||
@@ -143,5 +143,9 @@
|
||||
"profiles": {
|
||||
"basic": 1
|
||||
}
|
||||
},
|
||||
"2026-07-07": {
|
||||
"visits": 10,
|
||||
"profiles": []
|
||||
}
|
||||
}
|
||||
+14
-15
@@ -2,22 +2,21 @@
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="100%" height="100%" viewBox="0 0 174 173" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;">
|
||||
<g transform="matrix(0.986226,0,0,0.986226,-585.724252,-110.041209)">
|
||||
<circle cx="681.984" cy="199.45" r="83.299" style="fill:rgb(245,237,214);stroke:rgb(30,18,8);stroke-width:9.13px;"/>
|
||||
<g>
|
||||
<clipPath id="_clip1">
|
||||
<circle cx="681.984" cy="199.45" r="83.299"/>
|
||||
</clipPath>
|
||||
<g clip-path="url(#_clip1)">
|
||||
<g transform="matrix(0.948836,0,0,0.948836,19.787839,-2.49381)">
|
||||
<path d="M666.992,233.077C666.395,230.658 666.178,228.632 666.178,226.03C666.178,208.679 680.265,194.592 697.616,194.592C714.967,194.592 729.054,208.679 729.054,226.03C729.054,228.412 728.989,230.935 728.486,233.166" style="fill:none;stroke:rgb(200,146,42);stroke-width:7.48px;"/>
|
||||
</g>
|
||||
<g transform="matrix(0.520349,0,0,0.520349,304.766642,64.226716)">
|
||||
<path d="M724.932,291.813C777.359,291.813 826.586,305.625 869.17,329.804" style="fill:none;stroke:rgb(30,18,8);stroke-width:13.64px;"/>
|
||||
</g>
|
||||
<g transform="matrix(0.520349,0,0,0.520349,304.766642,64.226716)">
|
||||
<path d="M580.844,329.719C623.393,305.592 672.566,291.813 724.932,291.813" style="fill:none;stroke:rgb(30,18,8);stroke-width:13.64px;"/>
|
||||
</g>
|
||||
<circle cx="681.984" cy="199.45" r="83.299" style="fill:rgb(245,237,214);"/>
|
||||
<clipPath id="_clip1">
|
||||
<circle cx="681.984" cy="199.45" r="83.299"/>
|
||||
</clipPath>
|
||||
<g clip-path="url(#_clip1)">
|
||||
<g id="Sun" transform="matrix(0.948836,0,0,0.948836,19.748711,-12.198562)">
|
||||
<path d="M666.992,233.077C666.395,230.658 666.178,228.632 666.178,226.03C666.178,208.679 680.265,194.592 697.616,194.592C714.967,194.592 729.054,208.679 729.054,226.03C729.054,228.412 728.989,230.935 728.486,233.166" style="fill:rgb(244,176,71);stroke:rgb(244,176,71);stroke-width:7.48px;"/>
|
||||
</g>
|
||||
<g id="Ar-right" transform="matrix(0.520349,0,0,0.520349,304.727514,54.521964)">
|
||||
<path d="M724.932,291.813C777.359,291.813 826.586,305.625 869.17,329.804" style="fill:none;stroke:black;stroke-width:13.64px;"/>
|
||||
</g>
|
||||
<g id="Arc-left" transform="matrix(0.520349,0,0,0.520349,304.727514,54.521964)">
|
||||
<path d="M580.844,329.719C623.393,305.592 672.566,291.813 724.932,291.813" style="fill:none;stroke:black;stroke-width:13.64px;"/>
|
||||
</g>
|
||||
</g>
|
||||
<circle cx="681.984" cy="199.45" r="83.299" style="fill:none;stroke:black;stroke-width:9.13px;"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.8 KiB |
Reference in New Issue
Block a user