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.
|
// useAppState. See hooks/useAppState.js for the full reading order.
|
||||||
const {
|
const {
|
||||||
location, setLocationAndSave, recentLocations,
|
location, setLocationAndSave, recentLocations,
|
||||||
forecast, airQuality, loading, error, now, fetchedAt,
|
forecast, airQuality, loading, error, now, fetchedAt, normals,
|
||||||
searchQuery, setSearchQuery, searchResults, setSearchResults, searching,
|
searchQuery, setSearchQuery, searchResults, setSearchResults, searching,
|
||||||
selectedDay, setSelectedDay,
|
selectedDay, setSelectedDay,
|
||||||
proPromptDay, setProPromptDay,
|
proPromptDay, setProPromptDay,
|
||||||
@@ -469,6 +469,9 @@ export function UTCIForecast() {
|
|||||||
// Farming uses these for seasonal sow/harvest advice (week's dates + weather).
|
// Farming uses these for seasonal sow/harvest advice (week's dates + weather).
|
||||||
activeProfile === 'farming' ? days : null,
|
activeProfile === 'farming' ? days : null,
|
||||||
location?.lat,
|
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".
|
// 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">
|
<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-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">
|
<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>
|
<span class="nav-logo-tag">See the world the way your skin does.</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="nav-links" id="nav-drawer">
|
<div class="nav-links" id="nav-drawer">
|
||||||
@@ -648,7 +651,7 @@ export function UTCIForecast() {
|
|||||||
<div class="utci-header">
|
<div class="utci-header">
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<h1 class="utci-title">
|
<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>
|
</h1>
|
||||||
<div class="utci-tagline">See the world the way your skin does.</div>
|
<div class="utci-tagline">See the world the way your skin does.</div>
|
||||||
<div class="utci-loc-search" ref=${searchWrapRef}>
|
<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 hasFeltAnchor = glanceSummary.some(i => i.label === 'Peak felt temp');
|
||||||
const comfortItem = hasFeltAnchor ? glanceSummary.find(i => i.label === 'Comfortable window') : null;
|
const comfortItem = hasFeltAnchor ? glanceSummary.find(i => i.label === 'Comfortable window') : null;
|
||||||
const drivingItem = hasFeltAnchor ? glanceSummary.find(i => i.label === 'Good driving time') : 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 [
|
return [
|
||||||
...restSummary.flatMap(item => [
|
...restSummary.flatMap(item => [
|
||||||
renderRow(item),
|
renderRow(item),
|
||||||
@@ -1655,6 +1659,7 @@ export function UTCIForecast() {
|
|||||||
? [
|
? [
|
||||||
...heatEvents.map(e => renderRow(e, 'ev-')),
|
...heatEvents.map(e => renderRow(e, 'ev-')),
|
||||||
...(drivingItem ? [renderRow(drivingItem)] : []),
|
...(drivingItem ? [renderRow(drivingItem)] : []),
|
||||||
|
...(climateItem ? [renderRow(climateItem)] : []),
|
||||||
...(comfortItem ? [renderRow(comfortItem)] : []),
|
...(comfortItem ? [renderRow(comfortItem)] : []),
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
|
|||||||
@@ -29,6 +29,6 @@ export function Wordmark({ fontSize = '34px' }) {
|
|||||||
letterSpacing: '-0.01em',
|
letterSpacing: '-0.01em',
|
||||||
color: '#1e1208',
|
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>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-1
@@ -381,7 +381,7 @@ export function computeWhyFeelsLike(row, env) {
|
|||||||
// variant - active sub-variant key e.g. "running", "beach" (or null)
|
// variant - active sub-variant key e.g. "running", "beach" (or null)
|
||||||
// skinType - Fitzpatrick skin type key for UV burn time
|
// 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 [];
|
if (!todayRows || todayRows.length === 0) return [];
|
||||||
const show = (key) => !cols || !!cols[key];
|
const show = (key) => !cols || !!cols[key];
|
||||||
|
|
||||||
@@ -491,6 +491,33 @@ export function computeGlanceSummary(todayRows, profile, variant, skinType, cols
|
|||||||
alert: maxRainProb >= 60,
|
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 ──────────────────────────────────────────────────
|
// ── Good Driving Time ──────────────────────────────────────────────────
|
||||||
// Shown whenever a road speed (not Static) is selected and the vehicle cabin
|
// 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.
|
// 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));
|
const maxPollen = Math.max(0, ...todayRows.map(r => r.grassPollen ?? 0));
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
...climateItem,
|
||||||
{
|
{
|
||||||
icon: '⏱',
|
icon: '⏱',
|
||||||
label: 'Best field work',
|
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);
|
const dangerFrom = todayRows.find(r => r.vehicleT != null && r.vehicleT >= 29);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
...climateItem,
|
||||||
...(show('vehicleT') ? [{
|
...(show('vehicleT') ? [{
|
||||||
icon: '🌡',
|
icon: '🌡',
|
||||||
label: 'Peak cabin temp',
|
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));
|
const maxPollen = Math.max(0, ...todayRows.map(r => r.grassPollen ?? 0));
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
...climateItem,
|
||||||
...(show('indoorT') ? [{
|
...(show('indoorT') ? [{
|
||||||
icon: '🌡',
|
icon: '🌡',
|
||||||
label: 'Peak indoor (unmanaged)',
|
label: 'Peak indoor (unmanaged)',
|
||||||
@@ -648,6 +678,7 @@ export function computeGlanceSummary(todayRows, profile, variant, skinType, cols
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
...climateItem,
|
||||||
{
|
{
|
||||||
icon: '⏱',
|
icon: '⏱',
|
||||||
label: `Best ${variant} window`,
|
label: `Best ${variant} window`,
|
||||||
@@ -690,6 +721,7 @@ export function computeGlanceSummary(todayRows, profile, variant, skinType, cols
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
...climateItem,
|
||||||
...(drivingShown ? [drivingItem] : []),
|
...(drivingShown ? [drivingItem] : []),
|
||||||
{
|
{
|
||||||
icon: '🌤',
|
icon: '🌤',
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ export function useAppState() {
|
|||||||
return localStorage.getItem('sunscope_pro') === '1';
|
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'); }, []);
|
useEffect(() => { track('visit'); }, []);
|
||||||
|
|
||||||
@@ -617,7 +617,7 @@ export function useAppState() {
|
|||||||
// location
|
// location
|
||||||
location, setLocationAndSave, recentLocations,
|
location, setLocationAndSave, recentLocations,
|
||||||
// forecast
|
// forecast
|
||||||
forecast, airQuality, loading, error, now, fetchedAt,
|
forecast, airQuality, loading, error, now, fetchedAt, normals,
|
||||||
// search
|
// search
|
||||||
searchQuery, setSearchQuery,
|
searchQuery, setSearchQuery,
|
||||||
searchResults, setSearchResults,
|
searchResults, setSearchResults,
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ const FIFTEEN_MIN_MS = 15 * 60 * 1000;
|
|||||||
const THIRTY_MIN_MS = 30 * 60 * 1000;
|
const THIRTY_MIN_MS = 30 * 60 * 1000;
|
||||||
const SIX_HOURS_MS = 6 * 60 * 60 * 1000;
|
const SIX_HOURS_MS = 6 * 60 * 60 * 1000;
|
||||||
const ONE_MIN_MS = 1 * 60 * 1000;
|
const ONE_MIN_MS = 1 * 60 * 1000;
|
||||||
|
const SIXTY_DAYS_MS = 60 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
function buildForecastUrl(loc) {
|
function buildForecastUrl(loc) {
|
||||||
return `https://api.open-meteo.com/v1/forecast` +
|
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)}`;
|
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.
|
// 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.
|
// Returns stale cache of any age if the fetch fails — caller uses this as last resort.
|
||||||
async function fetchSoilHourly(loc) {
|
async function fetchSoilHourly(loc) {
|
||||||
@@ -130,6 +210,7 @@ export function useForecast(location, isPro = false) {
|
|||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [now, setNow] = useState(new Date());
|
const [now, setNow] = useState(new Date());
|
||||||
const [fetchedAt, setFetchedAt] = useState(null);
|
const [fetchedAt, setFetchedAt] = useState(null);
|
||||||
|
const [normals, setNormals] = useState(null);
|
||||||
|
|
||||||
// Mirrors the latest forecast so the loader's catch block can tell whether
|
// Mirrors the latest forecast so the loader's catch block can tell whether
|
||||||
// data is already on screen, even inside stale setInterval closures.
|
// data is already on screen, even inside stale setInterval closures.
|
||||||
@@ -236,6 +317,12 @@ export function useForecast(location, isPro = false) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadForecast(location);
|
loadForecast(location);
|
||||||
loadAirQuality(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]);
|
}, [location]);
|
||||||
|
|
||||||
// ─── 1-MINUTE CLOCK TICK (current-row highlight + live scope) ─────
|
// ─── 1-MINUTE CLOCK TICK (current-row highlight + live scope) ─────
|
||||||
@@ -276,5 +363,5 @@ export function useForecast(location, isPro = false) {
|
|||||||
return () => clearInterval(id);
|
return () => clearInterval(id);
|
||||||
}, [location, isPro]);
|
}, [location, isPro]);
|
||||||
|
|
||||||
return { forecast, airQuality, loading, error, now, fetchedAt, liveElev };
|
return { forecast, airQuality, loading, error, now, fetchedAt, liveElev, normals };
|
||||||
}
|
}
|
||||||
@@ -143,5 +143,9 @@
|
|||||||
"profiles": {
|
"profiles": {
|
||||||
"basic": 1
|
"basic": 1
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"2026-07-07": {
|
||||||
|
"visits": 10,
|
||||||
|
"profiles": []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+8
-9
@@ -2,22 +2,21 @@
|
|||||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
<!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;">
|
<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)">
|
<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;"/>
|
<circle cx="681.984" cy="199.45" r="83.299" style="fill:rgb(245,237,214);"/>
|
||||||
<g>
|
|
||||||
<clipPath id="_clip1">
|
<clipPath id="_clip1">
|
||||||
<circle cx="681.984" cy="199.45" r="83.299"/>
|
<circle cx="681.984" cy="199.45" r="83.299"/>
|
||||||
</clipPath>
|
</clipPath>
|
||||||
<g clip-path="url(#_clip1)">
|
<g clip-path="url(#_clip1)">
|
||||||
<g transform="matrix(0.948836,0,0,0.948836,19.787839,-2.49381)">
|
<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:none;stroke:rgb(200,146,42);stroke-width:7.48px;"/>
|
<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>
|
||||||
<g transform="matrix(0.520349,0,0,0.520349,304.766642,64.226716)">
|
<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:rgb(30,18,8);stroke-width:13.64px;"/>
|
<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 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>
|
</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>
|
||||||
</g>
|
</g>
|
||||||
|
<circle cx="681.984" cy="199.45" r="83.299" style="fill:none;stroke:black;stroke-width:9.13px;"/>
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.8 KiB |
Reference in New Issue
Block a user