Restructure
New Journal entries
Colour tabs update with wind
This commit is contained in:
fraxle
2026-08-31 15:12:45 +01:00
parent 47818fba33
commit 71860b9dd9
40 changed files with 3430 additions and 443 deletions
+323 -125
View File
@@ -17,7 +17,7 @@
// Preview the Pro view ......... useState(false) on isPro → flip to true
// Starting location ............ useState({...}) on `location` near top
// Default columns shown ........ useState({...}) on visibleCols
// Page tagline / about copy .... search "utci-tagline" or "utci-about-text"
// Page tagline / about copy .... search "nav-logo-tag" or "utci-about-text"
// ════════════════════════════════════════════════════════════════════════
import { h, render, Fragment } from '../vendor/preact.js';
@@ -255,6 +255,24 @@ export function UTCIForecast() {
// Search is hidden by default and revealed via the magnifier next to the
// location. The input then overlays the location line to save space.
// Dashboard sign-in state, purely for the Account menu's Sign out/in
// item - a lightweight session check against the dashboard's own auth
// (see api/me.php), independent of isPro (which is the separate
// localStorage-based Extra flag and has nothing to do with being signed
// in to the journal).
const [dashUser, setDashUser] = useState(null);
useEffect(() => {
fetch('./api/me.php', { credentials: 'same-origin' })
.then((r) => r.json())
.then((d) => setDashUser(d.user ?? null))
.catch(() => {});
}, []);
const signOutDashboard = () => {
fetch('./api/logout.php', { method: 'POST', credentials: 'same-origin' })
.then(() => setDashUser(null))
.catch(() => {});
};
const [searchOpen, setSearchOpen] = useState(false);
// While closing, keep the overlay mounted so it can fade/close out before
// unmounting; cleared when the exit animation ends.
@@ -272,10 +290,30 @@ export function UTCIForecast() {
const NOTE_MS = 8000;
const NOTE_FADE_MS = 350;
// Pending cross-fade timer. Held in a ref so a location change (or the
// strip closing) can cancel it: a fade that lands after the event list has
// been replaced used to set an index past the end of the new list, leaving
// the banner mounted with every slide hidden — a strip with nothing in it.
const noteFadeTimer = useRef(null);
const noteClosingRef = useRef(false);
const cancelNoteFade = () => {
if (noteFadeTimer.current) {
clearTimeout(noteFadeTimer.current);
noteFadeTimer.current = null;
}
setNoteFading(false);
};
const noteGoTo = (next) => {
if (next === noteIndex) return;
setNoteFading(true);
setTimeout(() => { setNoteIndex(next); setNoteFading(false); }, NOTE_FADE_MS);
if (noteFadeTimer.current) clearTimeout(noteFadeTimer.current);
noteFadeTimer.current = setTimeout(() => {
noteFadeTimer.current = null;
setNoteIndex(next);
setNoteFading(false);
}, NOTE_FADE_MS);
};
// ── Dismissal ────────────────────────────────────────────────────────
@@ -293,31 +331,70 @@ export function UTCIForecast() {
const [noteClosing, setNoteClosing] = useState(false);
const noteDismissed = !!noteSig && noteSig === noteClosedSig;
const noteSlotRef = useRef(null);
const noteClose = () => {
if (noteClosing) return;
// Pin the slot to the height it actually has, then run it to zero on the
// next frame. The slot is bottom-anchored (see .event-note-slot), so the
// banner rides its shrinking bottom edge up behind the header while the
// page below closes the gap at exactly the same rate — one property, one
// movement. It is measured here rather than guessed in CSS because every
// stylesheet-side version of this (max-height, a 1fr → 0fr row) had to
// interpolate against an assumed size, which is what stalled part-way.
const el = noteSlotRef.current;
if (el) {
const reduced = window.matchMedia
&& window.matchMedia('(prefers-reduced-motion: reduce)').matches;
el.style.height = `${el.offsetHeight}px`;
// Inline rather than through the is-closing class: that class only lands
// on the next render, which can be after the frame below.
el.style.transition = `height ${reduced ? 0.01 : NOTE_CLOSE_MS / 1000}s linear`;
void el.offsetHeight; // flush, so the next value starts a transition
requestAnimationFrame(() => {
if (noteSlotRef.current) noteSlotRef.current.style.height = '0px';
});
}
noteClosingRef.current = true;
cancelNoteFade();
setNoteClosing(true);
setTimeout(() => {
try { sessionStorage.setItem(NOTE_CLOSE_KEY, noteSig); } catch (e) { /* ignore */ }
setNoteClosedSig(noteSig);
setNoteClosing(false);
noteClosingRef.current = false;
}, NOTE_CLOSE_MS);
};
// Keep the index in range if the event list shrinks between forecasts.
// Start from the first slide whenever the event set itself changes — a new
// location brings a different list, and carrying an index (or a half-run
// fade) across leaves the strip showing nothing.
useEffect(() => {
if (noteIndex >= activeEvents.length) setNoteIndex(0);
}, [activeEvents.length]);
cancelNoteFade();
setNoteIndex(0);
}, [noteSig]);
useEffect(() => {
if (activeEvents.length <= 1) return;
const id = setInterval(() => {
// Nothing to cross-fade to mid-close — the height animation would have
// to re-target a banner whose content just changed under it.
if (noteClosingRef.current) return;
setNoteFading(true);
setTimeout(() => {
if (noteFadeTimer.current) clearTimeout(noteFadeTimer.current);
noteFadeTimer.current = setTimeout(() => {
noteFadeTimer.current = null;
setNoteIndex(i => (i + 1) % activeEvents.length);
setNoteFading(false);
}, NOTE_FADE_MS);
}, NOTE_MS);
return () => clearInterval(id);
return () => {
clearInterval(id);
if (noteFadeTimer.current) {
clearTimeout(noteFadeTimer.current);
noteFadeTimer.current = null;
}
};
}, [activeEvents.length]);
const [colTogglesOpen, setColTogglesOpen] = useState(false);
@@ -329,6 +406,33 @@ export function UTCIForecast() {
activeProfile === 'custom' || activeProfile === 'showall' || !!activeCols[key];
const searchInputRef = useRef(null);
const fscScrollRef = useRef(null);
// Quick view's card strip scrolls sideways like the table does, so it gets
// the same edge fades and chevrons — see .forecast-simple-wrap in table.css.
const [fscCanScrollLeft, setFscCanScrollLeft] = useState(false);
const [fscCanScrollRight, setFscCanScrollRight] = useState(false);
useEffect(() => {
const el = fscScrollRef.current;
if (!el) return;
const update = () => {
setFscCanScrollLeft(el.scrollLeft > 1);
setFscCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1);
};
update();
el.addEventListener('scroll', update, { passive: true });
// The strip's width and its content's width both move it: the viewport on
// resize, the card row when the day, interval or profile changes. Watching
// both keeps the deps list out of it.
const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(update) : null;
if (ro) {
ro.observe(el);
if (el.firstElementChild) ro.observe(el.firstElementChild);
}
return () => {
el.removeEventListener('scroll', update);
if (ro) ro.disconnect();
};
}, []);
useEffect(() => {
const el = fscScrollRef.current;
@@ -585,6 +689,18 @@ export function UTCIForecast() {
return `${wd} ${day}${ord} ${mon} ${yr}`;
})();
// Compact form for the rotated table's corner cell, e.g. "Thu 27 Aug 2026".
// That cell sets the width of the pinned metric-name column, and the long
// form ("Thu 27th August 2026") pushed it wider than the names need.
const glanceDateShort = (() => {
const key = days[selectedDay]?.key;
if (!key) return '';
const d = new Date(key + 'T00:00Z');
const wd = d.toLocaleDateString('en-GB', { weekday: 'short', timeZone: 'UTC' });
const mon = d.toLocaleDateString('en-GB', { month: 'short', timeZone: 'UTC' });
return `${wd} ${d.getUTCDate()} ${mon} ${d.getUTCFullYear()}`;
})();
// Pro: export the selected day's full hourly data as a styled spreadsheet.
const handleExportDay = () => {
exportDayXls(visible, {
@@ -713,8 +829,59 @@ export function UTCIForecast() {
// • "utci-footer" — the "reading the table" note
return html`
<div class=${`utci-app${activeEvents.length > 0 && !noteDismissed ? ' has-event-note' : ''}`}>
<header 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">
<h1 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></h1>
<span class="nav-logo-tag">See the world the way your skin does.</span>
</div>
<nav class="nav-links" id="nav-drawer" aria-label="Main">
<a href="./index.html">Forecast</a>
<div class="nav-dropdown">
<a href="./about.html" class="nav-dropdown-toggle">About<span class="caret">▾</span></a>
<div class="nav-dropdown-menu">
<a href="./about.html">Overview</a>
<a href="./sunsoak.html">SunSoak & the science</a>
<a href="./profiles.html">Forecast profiles</a>
<a href="./columns.html">Column reference</a>
<a href="./temperatures.html">Derived temperatures</a>
<a href="./features.html">On-screen features</a>
<a href="./stress-bands.html">Stress bands</a>
</div>
</div>
<a href="./faq.html">FAQ</a>
<div class="nav-dropdown nav-dropdown--right">
<a href="./dashboard.html" class="nav-dropdown-toggle">Account<span class="caret">▾</span></a>
<div class="nav-dropdown-menu">
<a href="./dashboard.html">Dashboard</a>
<a href=${isPro
? 'https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00'
: 'https://buy.stripe.com/9B63cw7vl8k15Ei9DQd7q00'}
target="_blank" rel="noopener noreferrer">Subscription</a>
${dashUser
? html`<a href="#" onClick=${(e) => { e.preventDefault(); signOutDashboard(); }}>Sign out</a>`
: html`<a href="./dashboard.html">Sign in</a>`}
</div>
</div>
</nav>
<button class="utci-burger" aria-label="Open menu" aria-expanded="false" id="burger-btn"
onClick=${() => {
const nav = document.getElementById('site-nav');
const drawer = document.getElementById('nav-drawer');
const btn = document.getElementById('burger-btn');
const open = nav.classList.toggle('nav-open');
drawer.classList.toggle('is-open', open);
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
}}>
<span></span><span></span><span></span>
</button>
</header>
${activeEvents.length > 0 && !noteDismissed && (() => {
const ev = activeEvents[noteIndex] || activeEvents[0];
// Guards the window between an event list changing and the reset
// effect above running: an index past the end would hide every slide.
const slideIndex = noteIndex < activeEvents.length ? noteIndex : 0;
const isPriority = PRIORITY_WEATHER_IDS.has(ev.id);
const fmtDate = (iso) => iso
? new Date(iso + 'T00:00Z').toLocaleDateString('en-GB', { day: 'numeric', month: 'short', timeZone: 'UTC' })
@@ -746,6 +913,10 @@ export function UTCIForecast() {
};
})() : undefined;
return html`
${/* The slot clips the banner: on close it collapses to nothing while
the note itself slides up inside it, so the strip reads as
tucking back up under the header rather than fading in place. */''}
<div ref=${noteSlotRef} class=${`event-note-slot${noteClosing ? ' is-closing' : ''}`}>
<div class=${`event-note${isPriority ? ' is-priority' : ''}${noteTintStyle ? ' is-tinted' : ''}${noteClosing ? ' is-closing' : ''}`} style=${noteTintStyle}>
<span class="event-note-accent" style=${{ background: ev.color === '#fdf8ee' ? '#c8922a' : ev.color }} />
${/* Every slide is rendered, all stacked in one CSS grid cell, so the
@@ -756,7 +927,7 @@ export function UTCIForecast() {
<div class="event-note-stack">
${activeEvents.map((e, i) => {
const dateLine = dateLineFor(e);
const isActive = i === noteIndex && !noteFading;
const isActive = i === slideIndex && !noteFading;
return html`
<div
key=${e.id || i}
@@ -780,7 +951,7 @@ export function UTCIForecast() {
<button
key=${i}
type="button"
class=${`event-note-dot${i === noteIndex ? ' active' : ''}`}
class=${`event-note-dot${i === slideIndex ? ' active' : ''}`}
aria-label=${`Show event ${i + 1} of ${activeEvents.length}`}
onClick=${() => noteGoTo(i)}
/>`)}
@@ -798,54 +969,13 @@ export function UTCIForecast() {
fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
</svg>
</button>
</div>
</div>`;
})()}
<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.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">
<a href="./index.html">Forecast</a>
<div class="nav-dropdown">
<a href="./about.html" class="nav-dropdown-toggle">About<span class="caret"></span></a>
<div class="nav-dropdown-menu">
<a href="./about.html">Overview</a>
<a href="./sunsoak.html">SunSoak & the science</a>
<a href="./profiles.html">Forecast profiles</a>
<a href="./columns.html">Column reference</a>
<a href="./temperatures.html">Derived temperatures</a>
<a href="./features.html">On-screen features</a>
<a href="./stress-bands.html">Stress bands</a>
</div>
</div>
<a href="./faq.html">FAQ</a>
<a href=${isPro
? 'https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00'
: 'https://buy.stripe.com/9B63cw7vl8k15Ei9DQd7q00'}
target="_blank" rel="noopener noreferrer">Account</a>
</div>
<button class="utci-burger" aria-label="Open menu" aria-expanded="false" id="burger-btn"
onClick=${() => {
const nav = document.getElementById('site-nav');
const drawer = document.getElementById('nav-drawer');
const btn = document.getElementById('burger-btn');
const open = nav.classList.toggle('nav-open');
drawer.classList.toggle('is-open', open);
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
}}>
<span></span><span></span><span></span>
</button>
</nav>
<main class="utci-shell">
<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.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}>
<div class="utci-current-loc">
<button
@@ -873,41 +1003,62 @@ export function UTCIForecast() {
<line x1="15.5" y1="15.5" x2="21" y2="21" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
<button
type="button"
class=${'utci-loc-action' + (locating ? ' is-busy' : '')}
aria-label="Use my current location"
title="Use my current location"
disabled=${locating}
onClick=${useMyLocation}
>
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<circle cx="12" cy="12" r="4" fill="none" stroke="currentColor" stroke-width="2" />
<circle cx="12" cy="12" r="1.6" fill="currentColor" />
<line x1="12" y1="1.5" x2="12" y2="5" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
<line x1="12" y1="19" x2="12" y2="22.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
<line x1="1.5" y1="12" x2="5" y2="12" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
<line x1="19" y1="12" x2="22.5" y2="12" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
<button
type="button"
class="utci-loc-action"
aria-label="Share this forecast"
title="Copy a link to this forecast"
onClick=${shareForecast}
>
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<circle cx="18" cy="5" r="2.6" fill="none" stroke="currentColor" stroke-width="2" />
<circle cx="6" cy="12" r="2.6" fill="none" stroke="currentColor" stroke-width="2" />
<circle cx="18" cy="19" r="2.6" fill="none" stroke="currentColor" stroke-width="2" />
<line x1="8.4" y1="10.8" x2="15.6" y2="6.2" stroke="currentColor" stroke-width="2" />
<line x1="8.4" y1="13.2" x2="15.6" y2="17.8" stroke="currentColor" stroke-width="2" />
</svg>
</button>
<span class="utci-loc-coords">
${location.lat.toFixed(3)}°, ${location.lon.toFixed(3)}°
</span>
</div>
<div class="utci-loc-tools">
<div class="utci-loc-tool">
<button
type="button"
class=${'utci-loc-action' + (locating ? ' is-busy' : '')}
aria-label="Use my current location"
title="Use my current location"
disabled=${locating}
onClick=${useMyLocation}
>
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<circle cx="12" cy="12" r="4" fill="none" stroke="currentColor" stroke-width="2" />
<circle cx="12" cy="12" r="1.6" fill="currentColor" />
<line x1="12" y1="1.5" x2="12" y2="5" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
<line x1="12" y1="19" x2="12" y2="22.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
<line x1="1.5" y1="12" x2="5" y2="12" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
<line x1="19" y1="12" x2="22.5" y2="12" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
<span class="utci-loc-tool-label">My spot</span>
</div>
<div class="utci-loc-tool">
<button
type="button"
class="utci-loc-action"
aria-label="Share this forecast"
title="Copy a link to this forecast"
onClick=${shareForecast}
>
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<circle cx="18" cy="5" r="2.6" fill="none" stroke="currentColor" stroke-width="2" />
<circle cx="6" cy="12" r="2.6" fill="none" stroke="currentColor" stroke-width="2" />
<circle cx="18" cy="19" r="2.6" fill="none" stroke="currentColor" stroke-width="2" />
<line x1="8.4" y1="10.8" x2="15.6" y2="6.2" stroke="currentColor" stroke-width="2" />
<line x1="8.4" y1="13.2" x2="15.6" y2="17.8" stroke="currentColor" stroke-width="2" />
</svg>
</button>
<span class="utci-loc-tool-label">Share</span>
</div>
<div class="utci-loc-tool">
<a
class="utci-loc-action"
href="./dashboard.html"
aria-label="Keep a weather journal"
title="Keep a weather journal — Dashboard"
>
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<rect x="4" y="3" width="16" height="18" rx="2" fill="none" stroke="currentColor" stroke-width="2" />
<line x1="8" y1="8" x2="16" y2="8" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
<line x1="8" y1="12" x2="16" y2="12" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
<line x1="8" y1="16" x2="13" y2="16" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</a>
<span class="utci-loc-tool-label">Journal</span>
</div>
</div>
${(locateError || shareState) && html`
<div class=${'utci-loc-note' + (locateError || shareState === 'failed' ? ' is-warn' : '')} role="status">
@@ -918,10 +1069,6 @@ export function UTCIForecast() {
<button type="button" class="utci-loc-note-x" aria-label="Dismiss"
onClick=${() => setLocateError(null)}>×</button>`}
</div>`}
${fetchedAt && !loading && html`
<div class=${'utci-fetch-time' + (isStale ? ' is-stale' : '')}>
${isStale ? html`<span class="stale-dot" title="Data is older than expected - retrying">● </span>` : ''}Last update: ${fetchedAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</div>`}
${(() => {
const curKey = `${location.lat.toFixed(3)},${location.lon.toFixed(3)}`;
const recents = (recentLocations || [])
@@ -1094,6 +1241,10 @@ export function UTCIForecast() {
</div>
`);
})()}
${fetchedAt && !loading && html`
<div class=${'utci-fetch-time' + (isStale ? ' is-stale' : '')}>
${isStale ? html`<span class="stale-dot" title="Data is older than expected - retrying">● </span>` : ''}Last update: ${fetchedAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</div>`}
</div>`}
</div>
</div>
@@ -1262,6 +1413,7 @@ export function UTCIForecast() {
<div class="col-toggles-body">
${isPro && html`<${Fragment}>
${colOffered('utciP') && html`<button class=${`col-toggle grp-felt${visibleCols.utciP ? ' on' : ''}`} onClick=${() => toggleCol('utciP')}>SunSoak</button>`}
${colOffered('shadeT') && html`<button class=${`col-toggle grp-felt${visibleCols.shadeT ? ' on' : ''}`} onClick=${() => toggleCol('shadeT')}>Shade</button>`}
${colOffered('vehicleT') && html`<button class=${`col-toggle grp-felt${visibleCols.vehicleT ? ' on' : ''}`} onClick=${() => { if (visibleCols.vehicleT) setVehicleVent(false); toggleCol('vehicleT'); }}>Vehicle</button>`}
${(colOffered('indoorT') || colOffered('managedT')) && html`<button class=${`col-toggle grp-felt${indoorMode === 'on' ? ' on' : ''}`} onClick=${() => { if (indoorMode === 'on') { setIndoorMode('off'); setIndoorManaged(false); } else { setIndoorMode('on'); } }}>Indoors</button>`}
${colOffered('utci') && html`<button class=${`col-toggle grp-felt${visibleCols.utci ? ' on' : ''}`} onClick=${() => toggleCol('utci')}>UTCI</button>`}
@@ -1274,7 +1426,6 @@ export function UTCIForecast() {
${colOffered('pawT') && html`<button class=${`col-toggle grp-pets${visibleCols.pawT ? ' on' : ''}`} onClick=${() => toggleCol('pawT')}>Paw</button>`}
${colOffered('petShadeT') && html`<button class=${`col-toggle grp-pets${visibleCols.petShadeT ? ' on' : ''}`} onClick=${() => toggleCol('petShadeT')}>Pet Shade</button>`}
${colOffered('petHomeT') && html`<button class=${`col-toggle grp-pets${visibleCols.petHomeT ? ' on' : ''}`} onClick=${() => toggleCol('petHomeT')}>Pet Home</button>`}
${colOffered('shadeT') && html`<button class=${`col-toggle grp-ambient${visibleCols.shadeT ? ' on' : ''}`} onClick=${() => toggleCol('shadeT')}>Shade</button>`}
${colOffered('air') && html`<button class=${`col-toggle grp-ambient${visibleCols.air ? ' on' : ''}`} onClick=${() => toggleCol('air')}>Air</button>`}
${colOffered('rh') && html`<button class=${`col-toggle grp-ambient${visibleCols.rh ? ' on' : ''}`} onClick=${() => toggleCol('rh')}>RH</button>`}
${colOffered('dew') && html`<button class=${`col-toggle grp-ambient${visibleCols.dew ? ' on' : ''}`} onClick=${() => toggleCol('dew')}>Dew</button>`}
@@ -1307,7 +1458,11 @@ export function UTCIForecast() {
</div>
</div>
<div class="forecast-simple-wrap">
<div class=${'forecast-simple-wrap'
+ (fscCanScrollLeft ? ' scroll-fade-left' : '')
+ (fscCanScrollRight ? ' scroll-fade-right' : '')}>
<span class="fsc-scroll-chevron left" aria-hidden="true"></span>
<span class="fsc-scroll-chevron right" aria-hidden="true"></span>
<div class="forecast-simple-scroll" ref=${fscScrollRef}>
<div class="forecast-simple-inner">
<div class="forecast-simple-cards" style=${{ gridTemplateColumns: fscPlot?.cols }}>
@@ -1318,8 +1473,34 @@ export function UTCIForecast() {
const localHHMMs = h24s === 0 ? '12am' : h24s < 12 ? `${h24s}am` : h24s === 12 ? '12pm' : `${h24s - 12}pm`;
const isNow = r.isoHours ? r.isoHours.includes(nowLocalISO) : r.iso.slice(0, 13) === nowLocalISO;
const domIcon = (() => {
if (r.precipProb > 20 && (r.precip > 0 || r.snow > 0))
return html`<${PrecipIcon} precip=${r.precip} snow=${r.snow} size=${42} filled=${true} />`;
// Two rain numbers, reconciled the same way the day tabs do it
// (see DayTabs). precipProb answers WHETHER it rains, precip
// answers HOW MUCH — and they come from different model runs,
// so a 97%-certain hour can still carry 0.0mm. Gating the icon
// on the amount alone left those hours wearing a cloud while
// the card underneath read 97%. So: probability decides IF the
// icon shows rain, and the two together decide how hard it looks.
// • >= 50% — rain icon, whatever the amount says
// • > 20% with mm/cm — rain icon (a light but real shower)
// • otherwise — cloud
const pProb = r.precipProb ?? 0;
const wet = r.precip > 0 || r.snow > 0;
if (pProb >= 50 || (pProb > 20 && wet)) {
const c01 = v => Math.max(0, Math.min(1, v));
// Confidence half: 0 at the 50% gate, 1 at a dead-certain 100%.
const probT = c01((pProb - 50) / 50);
// Amount half: "heavy" taken as 2mm in a single hour.
const amountT = c01((r.precip || 0) / 2);
// Certainty alone tops out at medium-high; only a genuinely
// wet hour reaches the 3-drop downpour.
const score = probT * 0.55 + amountT * 0.45;
const drops = score < 0.33 ? 1 : score < 0.70 ? 2 : 3;
// PrecipIcon draws its dry dash when precip and snow are both
// 0, so a certain-but-zero-amount hour needs a nominal trace
// to draw drops at all. drops= still sets the real intensity.
const iconPrecip = r.snow > 0 ? (r.precip || 0) : (r.precip > 0 ? r.precip : 0.01);
return html`<${PrecipIcon} precip=${iconPrecip} snow=${r.snow} drops=${drops} size=${42} filled=${true} />`;
}
if (r.cloudCat && r.cloudCat !== 'clear')
return html`<${CloudIcon} category=${r.cloudCat} elev=${r.elev} dt=${r.dt} size=${42} filled=${true} showSun=${false} />`;
return null;
@@ -1356,8 +1537,7 @@ export function UTCIForecast() {
</div>
<div class="fsc-meta">
<span class="fsc-meta-row fsc-meta-rain">${r.precipProb}%</span>
<span class="fsc-meta-row fsc-meta-wind">${windMph}mph</span>
<span class="fsc-meta-row fsc-meta-dir">${r.compass ? r.compass.label : '—'}</span>
<span class="fsc-meta-row fsc-meta-wind">${windMph}mph${r.compass ? html` <span class="fsc-meta-dir">${r.compass.label}</span>` : ''}</span>
</div>
<div class="fsc-temp">${Math.round(dispTemp)}°</div>
<div class="fsc-feel" style=${feelStyle}>${cat.label}</div>
@@ -1512,31 +1692,44 @@ export function UTCIForecast() {
<span class="utci-scroll-chevron left" aria-hidden="true"></span>
<span class="utci-scroll-chevron right" aria-hidden="true"></span>
<div class="utci-tbody-scroll" ref=${bodyScrollRef}>
<table class="utci-table utci-table-rotated" ref=${bodyTableRef}>
<thead>
<tr>
<th class="rot-corner col-info-th" scope="col"
onClick=${(e) => handleThClick('hour', e)}
onMouseEnter=${(e) => handleThEnter('hour', e)}
onMouseLeave=${handleThLeave}>
<span class="rot-corner-date">${glanceDate}</span>
<span class="rot-corner-controls">
<span class="hour-interval" onClick=${(e) => e.stopPropagation()}>
<${RowStepper} value=${tableInterval} onChange=${setTableInterval} showLabel=${false} />
${/* Head split out of the body table, exactly as the normal
orientation does it: the hour row has to pin to the
viewport as the page scrolls, and a <thead> inside the
horizontal scroller can only stick to that scroller.
useTableScroll keeps the two tables' column widths and
horizontal offset in step. */''}
<div class="utci-thead-sticky" ref=${headStickyRef}>
<div class="utci-thead-track" ref=${headTrackRef}>
<table class="utci-table utci-table-rotated utci-table-head" ref=${headTableRef}>
<thead>
<tr>
<th class="rot-corner col-info-th" scope="col"
onClick=${(e) => handleThClick('hour', e)}
onMouseEnter=${(e) => handleThEnter('hour', e)}
onMouseLeave=${handleThLeave}>
<span class="rot-corner-date">${glanceDateShort}</span>
<span class="rot-corner-controls">
<span class="hour-interval" onClick=${(e) => e.stopPropagation()}>
<${RowStepper} value=${tableInterval} onChange=${setTableInterval} showLabel=${false} />
</span>
</span>
</span>
</th>
${tableRows.map(r => {
const f = hourFlags(r);
return html`
<th key=${r.iso} scope="col"
class=${`rot-hour-th ${f.isNight ? 'is-night' : ''} ${f.isNow ? 'is-now' : ''}`.replace(/\s+/g, ' ').trim()}>
${timeCell(r)}
</th>`;
})}
</tr>
</thead>
</th>
${tableRows.map(r => {
const f = hourFlags(r);
return html`
<th key=${r.iso} scope="col"
class=${`rot-hour-th ${f.isNight ? 'is-night' : ''} ${f.isNow ? 'is-now' : ''}`.replace(/\s+/g, ' ').trim()}>
${timeCell(r)}
</th>`;
})}
</tr>
</thead>
</table>
</div>
</div>
<div class="utci-tbody-scroll" ref=${bodyScrollRef} onScroll=${handleBodyScroll}>
<table class="utci-table utci-table-rotated" ref=${bodyTableRef}>
<tbody>
${visibleColumnDefs.map((d, i) => {
// A group heading row is emitted whenever the
@@ -1681,9 +1874,9 @@ export function UTCIForecast() {
<div class="insight-panel insight-panel--week">
<div class="insight-panel-title">The Week Ahead</div>
<div class="insight-panel-sub">${titleCaseText(bestDaysLabel(activeProfile, outdoorsVariant))}</div>
${weekAhead.map(({ icon, label, value, score }) => html`
<div class="insight-row" key=${label}>
<span class="insight-icon">${icon}</span>
${weekAhead.map(({ icon, iconTitle, label, value, score }) => html`
<div class=${`insight-row${score == null ? ' insight-row--week-note' : ''}`} key=${label}>
<span class="insight-icon" title=${iconTitle}>${icon}</span>
<span class="insight-label">${titleCaseText(label)}</span>
<span class="insight-value">${titleCaseText(value)}</span>
${score != null && html`
@@ -1779,7 +1972,9 @@ export function UTCIForecast() {
<span class="utci-legend-label">Thermal stress bands</span>
<div class="utci-legend-row">
${[
{ t: -9, label: 'Freezing', value: '< 0°C' },
{ t: -25, label: 'Extreme cold', value: '< -20°C' },
{ t: -15, label: 'Arctic', value: '-20-10°C' },
{ t: -5, label: 'Freezing', value: '-100°C' },
{ t: 1, label: 'Cold', value: '010°C' },
{ t: 11, label: 'Cool', value: '1019°C' },
{ t: 21, label: 'Comfortable', value: '1924°C', bold: true },
@@ -1806,7 +2001,9 @@ export function UTCIForecast() {
<span class="utci-legend-label utci-legend-label--secondary">Pet thermal stress bands</span>
<div class="utci-legend-row">
${[
{ t: -15, label: 'Freezing', value: '< -8°C' },
{ t: -35, label: 'Extreme cold', value: '< -28°C' },
{ t: -23, label: 'Arctic', value: '-28-18°C' },
{ t: -13, label: 'Freezing', value: '-18-8°C' },
{ t: 0, label: 'Cold', value: '-82°C' },
{ t: 9, label: 'Cool', value: '211°C' },
{ t: 18, label: 'Comfortable', value: '1125°C', bold: true },
@@ -1940,6 +2137,7 @@ export function UTCIForecast() {
· <a href="./index.html">Forecast</a>
· <a href="./about.html">About</a>
· <a href="./faq.html">FAQ</a>
· <a href="./dashboard.html">Dashboard</a>
· <a href=${isPro
? 'https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00'
: 'https://buy.stripe.com/9B63cw7vl8k15Ei9DQd7q00'}