3.7
Updated event banner to fit top screen Updated table thermal colours to max hot and min cold for 2h, 3h & 4h Ground Lab preview
This commit is contained in:
+1
-1
@@ -162,7 +162,7 @@
|
|||||||
<div class="profile-block">
|
<div class="profile-block">
|
||||||
<p class="profile-name"><a href="./features.html">🔭 On-screen features →</a></p>
|
<p class="profile-name"><a href="./features.html">🔭 On-screen features →</a></p>
|
||||||
<p>
|
<p>
|
||||||
The sky scope, day-strip weather icons, weather-event banners and popups, the "Today at a
|
The sky scope, day-strip weather icons, weather-event alerts and popups, the "Today at a
|
||||||
Glance" summary with its seasonal-average comparison, and the "What's Coming" cosmic events
|
Glance" summary with its seasonal-average comparison, and the "What's Coming" cosmic events
|
||||||
almanac.
|
almanac.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
+153
-1
@@ -94,7 +94,7 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
padding: 18px 32px;
|
padding: 0px 32px;
|
||||||
gap: 28px;
|
gap: 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,6 +188,158 @@ body {
|
|||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Event note ─────────────────────────────────────────────────────── */
|
||||||
|
/* Active cosmic/weather events shown as a strip across the content width,
|
||||||
|
above the nav. It is IN FLOW — it pushes the page down instead of
|
||||||
|
covering anything, which is the whole point of it not being a flyout.
|
||||||
|
One event at a time with its full message, cycling with a fade.
|
||||||
|
|
||||||
|
The nav is normally position:absolute and .utci-app reserves 52px of top
|
||||||
|
padding for it. When a note is present the nav joins the flow instead so
|
||||||
|
the note can sit above it, and that padding is no longer needed. On true
|
||||||
|
mobile (≤640px) the nav is position:fixed and stays that way — there the
|
||||||
|
note simply becomes the first thing under it, so no override applies. */
|
||||||
|
.event-note {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 11px;
|
||||||
|
padding: 10px 15px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
background: #fdf8ee;
|
||||||
|
border: 1.5px solid #c9b08a;
|
||||||
|
border-radius: 4px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
animation: event-note-in 0.35s cubic-bezier(0.34, 1.25, 0.64, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes event-note-in {
|
||||||
|
from { opacity: 0; transform: translateY(-5px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Per-event colour, kept to a hairline so the strip stays parchment —
|
||||||
|
same device as .almanac-entry-accent. */
|
||||||
|
.event-note-accent {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 3px;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Safety-relevant weather (heat, frost, storm, wind, wet) — brass emphasis */
|
||||||
|
.event-note.is-priority {
|
||||||
|
background: #f0e4c4;
|
||||||
|
border-color: #c8922a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-note-slide {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px 10px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity 0.35s ease;
|
||||||
|
}
|
||||||
|
.event-note-slide.is-fading {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-note-emoji {
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-note-title {
|
||||||
|
font-family: Fraunces, serif;
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #2a1a08;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-note-msg {
|
||||||
|
font-family: Manrope, sans-serif;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: #6b4f2a;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-note-dates {
|
||||||
|
font-family: Manrope, sans-serif;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #9a7d5a;
|
||||||
|
white-space: nowrap;
|
||||||
|
margin-left: auto; /* push the dates to the right-hand end */
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.event-note.is-priority .event-note-dates {
|
||||||
|
color: #a06e18;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-note-dots {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
.event-note-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #d9c39b;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s, transform 0.2s;
|
||||||
|
}
|
||||||
|
.event-note-dot:hover {
|
||||||
|
background: #c0a880;
|
||||||
|
}
|
||||||
|
.event-note-dot.active {
|
||||||
|
background: #c8922a;
|
||||||
|
transform: scale(1.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Above true-mobile: the nav drops into the flow beneath the note. Its own
|
||||||
|
32px side padding is dropped so the links line up with the note and the
|
||||||
|
content rather than sitting 32px inside them. */
|
||||||
|
@media (min-width: 641px) {
|
||||||
|
.utci-app.has-event-note {
|
||||||
|
padding-top: 18px;
|
||||||
|
}
|
||||||
|
/* `relative`, not `static` — the nav keeps its z-index:100 that way, so
|
||||||
|
the About dropdown still paints over .utci-shell (which is z-index:1). */
|
||||||
|
.utci-app.has-event-note .utci-topnav {
|
||||||
|
position: relative;
|
||||||
|
padding-left: 0;
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.event-note {
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 9px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
.event-note-dates {
|
||||||
|
margin-left: 0; /* no room to right-align on a phone */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Nav logo — hidden on desktop, shown on mobile */
|
/* Nav logo — hidden on desktop, shown on mobile */
|
||||||
.nav-logo {
|
.nav-logo {
|
||||||
display: none;
|
display: none;
|
||||||
|
|||||||
@@ -2,136 +2,6 @@
|
|||||||
ui.css — Status banners, legend, about block, footer, and responsive.
|
ui.css — Status banners, legend, about block, footer, and responsive.
|
||||||
════════════════════════════════════════════════════════════════════════ */
|
════════════════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
/* ── EVENT BANNER ───────────────────────────────────────────────────── */
|
|
||||||
/* Animated slide-down banner for cosmic/weather/promo events. */
|
|
||||||
|
|
||||||
/* Flyout: a fixed panel pinned to the very top of the screen that slides
|
|
||||||
down over everything (including the nav) and retracts upward on close. */
|
|
||||||
.event-banner-wrap {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
z-index: 9999;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 0 14px;
|
|
||||||
transform: translateY(-130%);
|
|
||||||
transition: transform 0.5s cubic-bezier(0.34, 1.25, 0.64, 1);
|
|
||||||
/* The wrap is full-width but the panel is centred — keep the wrap itself
|
|
||||||
click-through so its transparent sides never block the nav beneath it.
|
|
||||||
Only the visible banner panel (.event-banner) captures clicks. */
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
.event-banner-wrap.visible {
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Slide crossfade: both banners rendered simultaneously, fading into each other */
|
|
||||||
@keyframes banner-fade-in {
|
|
||||||
from { opacity: 0; }
|
|
||||||
to { opacity: 1; }
|
|
||||||
}
|
|
||||||
@keyframes banner-fade-out {
|
|
||||||
from { opacity: 1; }
|
|
||||||
to { opacity: 0; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Container that holds both slides during a crossfade. Constrains the
|
|
||||||
flyout to a centred panel width and clips the absolute outgoing slide. */
|
|
||||||
.event-banner-stage {
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 720px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* True crossfade — outgoing slide sits absolutely on top and fades out */
|
|
||||||
.event-banner {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 14px;
|
|
||||||
padding: 14px 18px;
|
|
||||||
border-radius: 0 0 12px 12px; /* rounded bottom — it hangs from the top edge */
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
border-left: 4px solid rgba(255,255,255,0.25);
|
|
||||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.3);
|
|
||||||
cursor: pointer; /* whole banner is click-to-close */
|
|
||||||
pointer-events: auto; /* re-enable clicks on the panel itself */
|
|
||||||
}
|
|
||||||
.event-banner--outgoing {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
z-index: 2;
|
|
||||||
pointer-events: none;
|
|
||||||
animation: banner-fade-out 0.45s ease both;
|
|
||||||
}
|
|
||||||
.event-banner-emoji {
|
|
||||||
font-size: 24px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
.event-banner-body {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
.event-banner-title {
|
|
||||||
font-family: Fraunces, serif;
|
|
||||||
font-style: italic;
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: 16px;
|
|
||||||
line-height: 1.2;
|
|
||||||
margin-bottom: 3px;
|
|
||||||
}
|
|
||||||
.event-banner-msg {
|
|
||||||
font-family: Manrope, sans-serif;
|
|
||||||
font-size: 12.5px;
|
|
||||||
line-height: 1.55;
|
|
||||||
opacity: 0.88;
|
|
||||||
}
|
|
||||||
.event-banner-dismiss {
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 18px;
|
|
||||||
opacity: 0.55;
|
|
||||||
padding: 4px 6px;
|
|
||||||
line-height: 1;
|
|
||||||
flex-shrink: 0;
|
|
||||||
transition: opacity 0.15s;
|
|
||||||
}
|
|
||||||
.event-banner-dismiss:hover {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Slideshow dots */
|
|
||||||
.event-banner-dots {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
margin-top: 7px;
|
|
||||||
align-items: center;
|
|
||||||
padding: 5px 0;
|
|
||||||
}
|
|
||||||
.event-banner-dot {
|
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
border-radius: 50%;
|
|
||||||
opacity: 0.35;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: opacity 0.2s, transform 0.2s;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.event-banner-dot.active {
|
|
||||||
opacity: 1;
|
|
||||||
transform: scale(1.3);
|
|
||||||
}
|
|
||||||
.event-banner-dot:hover {
|
|
||||||
opacity: 0.75;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Cell tag — event icon in the hour column */
|
/* Cell tag — event icon in the hour column */
|
||||||
.event-cell-tag {
|
.event-cell-tag {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
@@ -312,46 +182,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── EVENT BANNER — mobile fixes ────────────────────────────────────── */
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.event-banner-wrap {
|
|
||||||
padding: 0; /* full-bleed flyout on small screens */
|
|
||||||
}
|
|
||||||
|
|
||||||
.event-banner {
|
|
||||||
align-items: flex-start; /* stop dismiss ✕ drifting downward */
|
|
||||||
gap: 10px;
|
|
||||||
padding: 18px 12px 14px 14px;
|
|
||||||
margin-bottom: 0;
|
|
||||||
border-radius: 0 0 12px 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.event-banner-emoji {
|
|
||||||
font-size: 20px;
|
|
||||||
margin-top: 3px; /* visually balance the dates-line margin-top below */
|
|
||||||
}
|
|
||||||
|
|
||||||
.event-banner-title {
|
|
||||||
font-size: 15px;
|
|
||||||
line-height: 1.25;
|
|
||||||
margin-bottom: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.event-banner-msg {
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.event-banner-dismiss {
|
|
||||||
margin-top: 0; /* keep ✕ pinned flush to the top-right */
|
|
||||||
padding: 0 2px;
|
|
||||||
font-size: 16px;
|
|
||||||
line-height: 1.4;
|
|
||||||
flex-shrink: 0;
|
|
||||||
align-self: flex-start;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── CUSTOM SELECT DROPDOWN ─────────────────────────────────────────── */
|
/* ── CUSTOM SELECT DROPDOWN ─────────────────────────────────────────── */
|
||||||
/* Cross-platform replacement for native <select> on the col-toggles bar */
|
/* Cross-platform replacement for native <select> on the col-toggles bar */
|
||||||
|
|
||||||
|
|||||||
+79
-80
@@ -32,7 +32,7 @@ import {
|
|||||||
confidenceBand, moonGlyph, skyFillForElev,
|
confidenceBand, moonGlyph, skyFillForElev,
|
||||||
} from './utils.js';
|
} from './utils.js';
|
||||||
import { SkyScope, WindVane, CloudIcon, ScopeReticle, PrecipIcon, CustomSelect, VentPill, RowStepper } from './components.js';
|
import { SkyScope, WindVane, CloudIcon, ScopeReticle, PrecipIcon, CustomSelect, VentPill, RowStepper } from './components.js';
|
||||||
import { getCellTagEvents, getUpcomingEvents } from './events.js';
|
import { getCellTagEvents, getUpcomingEvents, PRIORITY_WEATHER_IDS } from './events.js';
|
||||||
import { POLLEN_TYPES, COL_DESCRIPTIONS, UTCI_ENVIRONMENTS, FILTER_PROFILES, variantIcons, deriveProfileMain } from './config.js';
|
import { POLLEN_TYPES, COL_DESCRIPTIONS, UTCI_ENVIRONMENTS, FILTER_PROFILES, variantIcons, deriveProfileMain } from './config.js';
|
||||||
import { useAppState } from './hooks/useAppState.js';
|
import { useAppState } from './hooks/useAppState.js';
|
||||||
import { DayTabs } from './components/DayTabs.js';
|
import { DayTabs } from './components/DayTabs.js';
|
||||||
@@ -223,9 +223,6 @@ export function UTCIForecast() {
|
|||||||
visible, tableRows, nowLocalISO, currentRow, currentCat,
|
visible, tableRows, nowLocalISO, currentRow, currentCat,
|
||||||
liveElev,
|
liveElev,
|
||||||
activeEvents, lensEvent, selectedDayEvents,
|
activeEvents, lensEvent, selectedDayEvents,
|
||||||
bannerIndex, bannerTransition, bannerPrevIndex,
|
|
||||||
bannerVisible, bannerStageRef,
|
|
||||||
bannerSlideTo, dismissBanner,
|
|
||||||
} = useAppState();
|
} = useAppState();
|
||||||
|
|
||||||
// Search is hidden by default and revealed via the magnifier next to the
|
// Search is hidden by default and revealed via the magnifier next to the
|
||||||
@@ -237,6 +234,39 @@ export function UTCIForecast() {
|
|||||||
const openSearch = () => { setSearchClosing(false); setSearchOpen(true); };
|
const openSearch = () => { setSearchClosing(false); setSearchOpen(true); };
|
||||||
const closeSearch = () => { setSearchOpen(false); setSearchClosing(true); };
|
const closeSearch = () => { setSearchOpen(false); setSearchClosing(true); };
|
||||||
const searchWrapRef = useRef(null);
|
const searchWrapRef = useRef(null);
|
||||||
|
|
||||||
|
// ── Event note ───────────────────────────────────────────────────────
|
||||||
|
// A content-width strip above the nav showing one active event at a time
|
||||||
|
// with its full message, cycling through them with a fade. In flow, so it
|
||||||
|
// pushes the page down rather than covering anything (see .event-note).
|
||||||
|
const [noteIndex, setNoteIndex] = useState(0);
|
||||||
|
const [noteFading, setNoteFading] = useState(false);
|
||||||
|
const NOTE_MS = 8000;
|
||||||
|
const NOTE_FADE_MS = 350;
|
||||||
|
|
||||||
|
const noteGoTo = (next) => {
|
||||||
|
if (next === noteIndex) return;
|
||||||
|
setNoteFading(true);
|
||||||
|
setTimeout(() => { setNoteIndex(next); setNoteFading(false); }, NOTE_FADE_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Keep the index in range if the event list shrinks between forecasts.
|
||||||
|
useEffect(() => {
|
||||||
|
if (noteIndex >= activeEvents.length) setNoteIndex(0);
|
||||||
|
}, [activeEvents.length]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeEvents.length <= 1) return;
|
||||||
|
const id = setInterval(() => {
|
||||||
|
setNoteFading(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
setNoteIndex(i => (i + 1) % activeEvents.length);
|
||||||
|
setNoteFading(false);
|
||||||
|
}, NOTE_FADE_MS);
|
||||||
|
}, NOTE_MS);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [activeEvents.length]);
|
||||||
|
|
||||||
const [colTogglesOpen, setColTogglesOpen] = useState(false);
|
const [colTogglesOpen, setColTogglesOpen] = useState(false);
|
||||||
// Which column pills are offered in the Columns bar. The bar as a whole is
|
// Which column pills are offered in the Columns bar. The bar as a whole is
|
||||||
// Extra-only (see the isPro gate around col-toggles-body), so this just asks
|
// Extra-only (see the isPro gate around col-toggles-body), so this just asks
|
||||||
@@ -645,7 +675,45 @@ export function UTCIForecast() {
|
|||||||
// • "utci-about" — the explainer paragraphs at the bottom
|
// • "utci-about" — the explainer paragraphs at the bottom
|
||||||
// • "utci-footer" — the "reading the table" note
|
// • "utci-footer" — the "reading the table" note
|
||||||
return html`
|
return html`
|
||||||
<div class="utci-app">
|
<div class=${`utci-app${activeEvents.length > 0 ? ' has-event-note' : ''}`}>
|
||||||
|
${activeEvents.length > 0 && (() => {
|
||||||
|
const ev = activeEvents[noteIndex] || activeEvents[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' })
|
||||||
|
: null;
|
||||||
|
const startFmt = fmtDate(ev.start);
|
||||||
|
const peakFmt = fmtDate(ev.peak);
|
||||||
|
const endFmt = fmtDate(ev.end);
|
||||||
|
const dateLine = (startFmt && endFmt)
|
||||||
|
? (startFmt === endFmt
|
||||||
|
? (peakFmt ? `Peak ${peakFmt}` : startFmt)
|
||||||
|
: (peakFmt && peakFmt !== startFmt && peakFmt !== endFmt
|
||||||
|
? `${startFmt} – ${endFmt} · Peak ${peakFmt}`
|
||||||
|
: `${startFmt} – ${endFmt}`))
|
||||||
|
: null;
|
||||||
|
return html`
|
||||||
|
<div class=${`event-note${isPriority ? ' is-priority' : ''}`}>
|
||||||
|
<span class="event-note-accent" style=${{ background: ev.color === '#fdf8ee' ? '#c8922a' : ev.color }} />
|
||||||
|
<div class=${`event-note-slide${noteFading ? ' is-fading' : ''}`}>
|
||||||
|
<span class="event-note-emoji">${ev.emoji}</span>
|
||||||
|
<span class="event-note-title">${ev.title}</span>
|
||||||
|
<span class="event-note-msg">${ev.message}</span>
|
||||||
|
${dateLine && html`<span class="event-note-dates">${dateLine}</span>`}
|
||||||
|
</div>
|
||||||
|
${activeEvents.length > 1 && html`
|
||||||
|
<div class="event-note-dots">
|
||||||
|
${activeEvents.map((_, i) => html`
|
||||||
|
<button
|
||||||
|
key=${i}
|
||||||
|
type="button"
|
||||||
|
class=${`event-note-dot${i === noteIndex ? ' active' : ''}`}
|
||||||
|
aria-label=${`Show event ${i + 1} of ${activeEvents.length}`}
|
||||||
|
onClick=${() => noteGoTo(i)}
|
||||||
|
/>`)}
|
||||||
|
</div>`}
|
||||||
|
</div>`;
|
||||||
|
})()}
|
||||||
<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">
|
||||||
@@ -685,75 +753,6 @@ export function UTCIForecast() {
|
|||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- Event flyout — a direct child of .utci-app (sibling of the nav) so its
|
|
||||||
position:fixed / z-index can sit above the nav. Keeping it inside
|
|
||||||
.utci-shell would trap it in the shell's stacking context. -->
|
|
||||||
<div class=${`event-banner-wrap${bannerVisible ? ' visible' : ''}`}>
|
|
||||||
${activeEvents.length > 0 && (() => {
|
|
||||||
const renderSlide = (idx, isOutgoing) => {
|
|
||||||
const ev = activeEvents[idx] || activeEvents[0];
|
|
||||||
const fmtDate = (iso) => iso
|
|
||||||
? new Date(iso + 'T00:00Z').toLocaleDateString('en-GB', { day: 'numeric', month: 'short', timeZone: 'UTC' })
|
|
||||||
: null;
|
|
||||||
const startFmt = fmtDate(ev.start);
|
|
||||||
const peakFmt = fmtDate(ev.peak);
|
|
||||||
const endFmt = fmtDate(ev.end);
|
|
||||||
const showDates = startFmt && endFmt;
|
|
||||||
const dateLine = showDates
|
|
||||||
? (startFmt === endFmt
|
|
||||||
? peakFmt ? `Peak: ${peakFmt}` : `Date: ${startFmt}`
|
|
||||||
: peakFmt && peakFmt !== startFmt && peakFmt !== endFmt
|
|
||||||
? `Active ${startFmt} – ${endFmt} · Peak: ${peakFmt}`
|
|
||||||
: `Active ${startFmt} – ${endFmt}`)
|
|
||||||
: null;
|
|
||||||
return html`
|
|
||||||
<div class=${isOutgoing ? 'event-banner event-banner--outgoing' : 'event-banner'}
|
|
||||||
style=${{ background: ev.color, color: ev.textColor }}
|
|
||||||
onClick=${isOutgoing ? undefined : () => dismissBanner(ev.id)}
|
|
||||||
role=${isOutgoing ? undefined : 'button'}
|
|
||||||
title=${isOutgoing ? undefined : 'Click to dismiss'}
|
|
||||||
>
|
|
||||||
<span class="event-banner-emoji">${ev.emoji}</span>
|
|
||||||
<div class="event-banner-body">
|
|
||||||
<div class="event-banner-title">${ev.title}</div>
|
|
||||||
<div class="event-banner-msg">${ev.message}</div>
|
|
||||||
${dateLine && html`
|
|
||||||
<div class="event-banner-dates" style=${{ opacity: 0.75, fontSize: '11px', fontFamily: 'Manrope, sans-serif', fontWeight: 700, letterSpacing: '0.05em', textTransform: 'uppercase', marginTop: '5px' }}>
|
|
||||||
${dateLine}
|
|
||||||
</div>
|
|
||||||
`}
|
|
||||||
${activeEvents.length > 1 && html`
|
|
||||||
<div class="event-banner-dots" onClick=${(e) => e.stopPropagation()}>
|
|
||||||
${activeEvents.map((_, i) => html`
|
|
||||||
<span
|
|
||||||
key=${i}
|
|
||||||
class=${`event-banner-dot${i === bannerIndex ? ' active' : ''}`}
|
|
||||||
onClick=${(e) => { e.stopPropagation(); bannerSlideTo(i); }}
|
|
||||||
style=${{ background: ev.textColor }}
|
|
||||||
/>
|
|
||||||
`)}
|
|
||||||
</div>
|
|
||||||
`}
|
|
||||||
</div>
|
|
||||||
${!isOutgoing && html`<button
|
|
||||||
class="event-banner-dismiss"
|
|
||||||
style=${{ color: ev.textColor }}
|
|
||||||
onClick=${(e) => { e.stopPropagation(); dismissBanner(ev.id); }}
|
|
||||||
aria-label="Dismiss"
|
|
||||||
title="Dismiss this event"
|
|
||||||
>✕</button>`}
|
|
||||||
</div>`;
|
|
||||||
};
|
|
||||||
return html`
|
|
||||||
<div class="event-banner-stage" ref=${bannerStageRef}>
|
|
||||||
${bannerTransition === 'crossfading' && bannerPrevIndex !== null
|
|
||||||
? renderSlide(bannerPrevIndex, true)
|
|
||||||
: null}
|
|
||||||
${renderSlide(bannerIndex, false)}
|
|
||||||
</div>`;
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<main class="utci-shell">
|
<main class="utci-shell">
|
||||||
<div class="utci-header">
|
<div class="utci-header">
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
@@ -1106,7 +1105,7 @@ export function UTCIForecast() {
|
|||||||
<div key=${forecastView} class=${'col-toggles-wrap' + (colTogglesOpen ? ' col-toggles-wrap--open' : '')}>
|
<div key=${forecastView} class=${'col-toggles-wrap' + (colTogglesOpen ? ' col-toggles-wrap--open' : '')}>
|
||||||
<div class=${'col-toggles' + (colTogglesOpen ? ' col-toggles--open' : '')}>
|
<div class=${'col-toggles' + (colTogglesOpen ? ' col-toggles--open' : '')}>
|
||||||
<span class="fvt-interval">
|
<span class="fvt-interval">
|
||||||
<${RowStepper} value=${tableInterval} onChange=${setTableInterval} />
|
<${RowStepper} value=${tableInterval} onChange=${setTableInterval} label="Hours" />
|
||||||
</span>
|
</span>
|
||||||
<div class="col-toggles-body">
|
<div class="col-toggles-body">
|
||||||
${isPro && html`<${Fragment}>
|
${isPro && html`<${Fragment}>
|
||||||
@@ -1290,7 +1289,7 @@ export function UTCIForecast() {
|
|||||||
${groupLabelRow()}
|
${groupLabelRow()}
|
||||||
<tr>
|
<tr>
|
||||||
<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('hour', e)} onMouseEnter=${(e) => handleThEnter('hour', e)} onMouseLeave=${handleThLeave}>
|
<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('hour', e)} onMouseEnter=${(e) => handleThEnter('hour', e)} onMouseLeave=${handleThLeave}>
|
||||||
<span>Hour</span>
|
<span>Rows</span>
|
||||||
<span class="hour-interval" onClick=${(e) => e.stopPropagation()}>
|
<span class="hour-interval" onClick=${(e) => e.stopPropagation()}>
|
||||||
<${RowStepper} value=${tableInterval} onChange=${setTableInterval} showLabel=${false} />
|
<${RowStepper} value=${tableInterval} onChange=${setTableInterval} showLabel=${false} />
|
||||||
</span>
|
</span>
|
||||||
@@ -1507,8 +1506,8 @@ export function UTCIForecast() {
|
|||||||
${fmt(r.furSurfaceT)}${u('°C')}
|
${fmt(r.furSurfaceT)}${u('°C')}
|
||||||
</td>`}
|
</td>`}
|
||||||
${visibleCols.pawT && html`
|
${visibleCols.pawT && html`
|
||||||
<td class=${groupStart('pawT')} title=${petCategory(r.concreteT)?.label ?? ''} style=${{ color: airTempFontColor(), background: petAirTempBg(r.concreteT, rPrev?.concreteT, rNext?.concreteT) }}>
|
<td class=${groupStart('pawT')} title=${petCategory(r.pawT)?.label ?? ''} style=${{ color: airTempFontColor(), background: petAirTempBg(r.pawT, rPrev?.pawT, rNext?.pawT) }}>
|
||||||
${fmt(r.concreteT)}${u('°C')}
|
${fmt(r.pawT)}${u('°C')}
|
||||||
</td>`}
|
</td>`}
|
||||||
${visibleCols.soilT && html`
|
${visibleCols.soilT && html`
|
||||||
<td class=${groupStart('soilT')} style=${{ color: airTempFontColor(), background: airTempBg(r.soilT0, rPrev?.soilT0, rNext?.soilT0) }}>${r.soilT0 != null ? fmt(r.soilT0) + u('°C') : '—'}</td>`}
|
<td class=${groupStart('soilT')} style=${{ color: airTempFontColor(), background: airTempBg(r.soilT0, rPrev?.soilT0, rNext?.soilT0) }}>${r.soilT0 != null ? fmt(r.soilT0) + u('°C') : '—'}</td>`}
|
||||||
@@ -1518,8 +1517,8 @@ export function UTCIForecast() {
|
|||||||
<td class=${groupStart('soilM')} style=${(() => { const sm = soilMoistureBg(r.soilM); return { color: sm.fg, background: sm.bg }; })()}>${r.soilM != null ? fmt(r.soilM * 100, 1) + u('%') : '—'}</td>`}
|
<td class=${groupStart('soilM')} style=${(() => { const sm = soilMoistureBg(r.soilM); return { color: sm.fg, background: sm.bg }; })()}>${r.soilM != null ? fmt(r.soilM * 100, 1) + u('%') : '—'}</td>`}
|
||||||
${visibleCols.shadeT && html`<td class=${groupStart('shadeT')} style=${{ background: airTempBg(r.shadeT, rPrev?.shadeT, rNext?.shadeT), color: airTempFontColor() }}>${r.shadeT != null ? fmt(r.shadeT) + u('°C') : '—'}</td>`}
|
${visibleCols.shadeT && html`<td class=${groupStart('shadeT')} style=${{ background: airTempBg(r.shadeT, rPrev?.shadeT, rNext?.shadeT), color: airTempFontColor() }}>${r.shadeT != null ? fmt(r.shadeT) + u('°C') : '—'}</td>`}
|
||||||
${visibleCols.petShadeT && html`
|
${visibleCols.petShadeT && html`
|
||||||
<td class=${groupStart('petShadeT')} title=${r.shadeT != null ? (petCategory(r.shadeT)?.label ?? '') : ''} style=${{ color: airTempFontColor(), background: petAirTempBg(r.shadeT, rPrev?.shadeT, rNext?.shadeT) }}>
|
<td class=${groupStart('petShadeT')} title=${r.petShadeT != null ? (petCategory(r.petShadeT)?.label ?? '') : ''} style=${{ color: airTempFontColor(), background: petAirTempBg(r.petShadeT, rPrev?.petShadeT, rNext?.petShadeT) }}>
|
||||||
${r.shadeT != null ? fmt(r.shadeT) + u('°C') : '—'}
|
${r.petShadeT != null ? fmt(r.petShadeT) + u('°C') : '—'}
|
||||||
</td>`}
|
</td>`}
|
||||||
${visibleCols.air && html`<td class=${groupStart('air')} style=${{ background: airTempBg(r.Ta, rPrev?.Ta, rNext?.Ta), color: airTempFontColor() }}>${fmt(r.Ta)}${u('°C')}</td>`}
|
${visibleCols.air && html`<td class=${groupStart('air')} style=${{ background: airTempBg(r.Ta, rPrev?.Ta, rNext?.Ta), color: airTempFontColor() }}>${fmt(r.Ta)}${u('°C')}</td>`}
|
||||||
${visibleCols.rh && html`<td class=${groupStart('rh')} style=${{ background: scaleBg(r.RH, 30, 100, '70,145,200') }}>${Math.round(r.RH)}${u('%')}</td>`}
|
${visibleCols.rh && html`<td class=${groupStart('rh')} style=${{ background: scaleBg(r.RH, 30, 100, '70,145,200') }}>${Math.round(r.RH)}${u('%')}</td>`}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
// Exports (in order of appearance):
|
// Exports (in order of appearance):
|
||||||
// CustomSelect({ value, options, onChange, ... }) styled dropdown pill
|
// CustomSelect({ value, options, onChange, ... }) styled dropdown pill
|
||||||
// VentPill({ checked, onChange, label, title }) checkbox fused to pill
|
// VentPill({ checked, onChange, label, title }) checkbox fused to pill
|
||||||
// RowStepper({ value, onChange, showLabel }) table row-interval stepper
|
// RowStepper({ value, onChange, showLabel, label }) table row-interval stepper
|
||||||
// SkyScope({ elev, dt, glob, size }) small porthole per hour
|
// SkyScope({ elev, dt, glob, size }) small porthole per hour
|
||||||
// WindVane({ bearing, size }) compass-arrow widget
|
// WindVane({ bearing, size }) compass-arrow widget
|
||||||
// CloudIcon({ category, size, elev, dt }) cloud/sun glyph
|
// CloudIcon({ category, size, elev, dt }) cloud/sun glyph
|
||||||
@@ -271,16 +271,18 @@ export function VentPill({ checked, onChange, label, title, grpClass }) {
|
|||||||
// props:
|
// props:
|
||||||
// value - 1..4
|
// value - 1..4
|
||||||
// onChange - fn(n)
|
// onChange - fn(n)
|
||||||
// showLabel - draw the "Rows" caption (off inside the Hour header,
|
// showLabel - draw the caption (off inside the table's Rows header,
|
||||||
// where the column title already supplies the context)
|
// where the column title already supplies the context)
|
||||||
|
// label - caption text: "Rows" by default, "Hours" in quick view,
|
||||||
|
// where each card is an hour rather than a table row
|
||||||
// min/max - bounds, so 6h/12h can be added without a redesign
|
// min/max - bounds, so 6h/12h can be added without a redesign
|
||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
export function RowStepper({ value, onChange, showLabel = true, min = 1, max = 4 }) {
|
export function RowStepper({ value, onChange, showLabel = true, label = 'Rows', min = 1, max = 4 }) {
|
||||||
const v = Math.min(max, Math.max(min, Number(value) || min));
|
const v = Math.min(max, Math.max(min, Number(value) || min));
|
||||||
const step = (n) => { const next = Math.min(max, Math.max(min, n)); if (next !== v) onChange(next); };
|
const step = (n) => { const next = Math.min(max, Math.max(min, n)); if (next !== v) onChange(next); };
|
||||||
return html`
|
return html`
|
||||||
<span class="row-stepper" role="group" aria-label="Row interval">
|
<span class="row-stepper" role="group" aria-label="Row interval">
|
||||||
${showLabel && html`<span class="row-stepper-label">Rows</span>`}
|
${showLabel && html`<span class="row-stepper-label">${label}</span>`}
|
||||||
<button type="button" class="row-stepper-btn" disabled=${v <= min}
|
<button type="button" class="row-stepper-btn" disabled=${v <= min}
|
||||||
title="Finer rows" aria-label="Finer rows - fewer hours per row"
|
title="Finer rows" aria-label="Finer rows - fewer hours per row"
|
||||||
onClick=${() => step(v - 1)}>−</button>
|
onClick=${() => step(v - 1)}>−</button>
|
||||||
|
|||||||
+29
-12
@@ -154,6 +154,10 @@ export function buildHourlyRows({ forecast, airQuality, location, vehicleType, v
|
|||||||
uv, uvA, uvB,
|
uv, uvA, uvB,
|
||||||
precip, precipProb, lightning, cape, snow,
|
precip, precipProb, lightning, cape, snow,
|
||||||
soilT0, soilT6, soilM, shadeT,
|
soilT0, soilT6, soilM, shadeT,
|
||||||
|
// Pet Shade reads the same hourly value as Shade but aggregates as a
|
||||||
|
// MEAN rather than a worst case, so it needs its own field to diverge
|
||||||
|
// from shadeT at 2h/3h/4h. Same for pawT vs concreteT below.
|
||||||
|
petShadeT: shadeT,
|
||||||
effectiveRad,
|
effectiveRad,
|
||||||
elev, Tmrt, utci, utciAdj, eh, compass,
|
elev, Tmrt, utci, utciAdj, eh, compass,
|
||||||
visKm, aqi,
|
visKm, aqi,
|
||||||
@@ -182,7 +186,7 @@ export function buildHourlyRows({ forecast, airQuality, location, vehicleType, v
|
|||||||
const concreteTemps = calcConcreteTempPass(
|
const concreteTemps = calcConcreteTempPass(
|
||||||
TaArr, radArr, vaArr, elevArr, uvArr, cloudCatArr, soilMArr, precipArr, snowArr
|
TaArr, radArr, vaArr, elevArr, uvArr, cloudCatArr, soilMArr, precipArr, snowArr
|
||||||
);
|
);
|
||||||
hourlyRows.forEach((r, i) => { r.concreteT = concreteTemps[i]; });
|
hourlyRows.forEach((r, i) => { r.concreteT = concreteTemps[i]; r.pawT = concreteTemps[i]; });
|
||||||
|
|
||||||
const indoorTemps = calcIndoorTempPass(TaArr, globArr, elevArr, buildingType);
|
const indoorTemps = calcIndoorTempPass(TaArr, globArr, elevArr, buildingType);
|
||||||
const managedTemps = calcManagedIndoorTempPass(TaArr, globArr, elevArr, buildingType);
|
const managedTemps = calcManagedIndoorTempPass(TaArr, globArr, elevArr, buildingType);
|
||||||
@@ -267,10 +271,15 @@ export function buildHourlyRows({ forecast, airQuality, location, vehicleType, v
|
|||||||
//
|
//
|
||||||
// Every other column is aggregated with the rule that fits its meaning:
|
// Every other column is aggregated with the rule that fits its meaning:
|
||||||
// - SUM : precipitation totals (1mm/h x 3h = 3mm)
|
// - SUM : precipitation totals (1mm/h x 3h = 3mm)
|
||||||
// - MAX : risk/peak columns (rain %, UV, gusts, cabin/surface/indoor)
|
// - MAX : risk/peak columns (rain %, UV, gusts, managed indoor)
|
||||||
// - MEAN : smooth continuous quantities (air temp, RH, wind, cloud, ...)
|
// - MEAN : smooth continuous quantities (RH, wind, cloud, Tmrt, ...)
|
||||||
// - FELT : worst-case felt temp - the warmest hour if it reaches >=20C
|
// - FELT : worst-case temperature - the warmest hour if the block reaches
|
||||||
// (heat is the concern), otherwise the coldest (cold concern)
|
// >=20C (heat is the concern), otherwise the coldest hour (cold
|
||||||
|
// is the concern). Applies to EVERY temperature column the user
|
||||||
|
// judges comfort or risk by - SunSoak, Cabin, Indoors, Concrete,
|
||||||
|
// Soil, Shade and Air - so they never disagree with each other
|
||||||
|
// at 2h/3h/4h. Note Air is therefore a block worst case, not a
|
||||||
|
// block average, and will read hotter than a mean on a warm day.
|
||||||
//
|
//
|
||||||
// Derived render values (Delta, Burn) are recomputed downstream from the
|
// Derived render values (Delta, Burn) are recomputed downstream from the
|
||||||
// aggregated Air / UTCI / UV, so they stay consistent automatically.
|
// aggregated Air / UTCI / UV, so they stay consistent automatically.
|
||||||
@@ -278,16 +287,24 @@ export function buildHourlyRows({ forecast, airQuality, location, vehicleType, v
|
|||||||
// interval <= 1 returns the input untouched (zero behaviour change).
|
// interval <= 1 returns the input untouched (zero behaviour change).
|
||||||
// ------------------------------------------------------------------------
|
// ------------------------------------------------------------------------
|
||||||
const AGG_MEAN = [
|
const AGG_MEAN = [
|
||||||
'Ta', 'shadeT', 'RH', 'dew', 'va', 'dir', 'dif', 'cc', 'ccLow', 'ccMid', 'ccHigh',
|
'RH', 'dew', 'va', 'dir', 'dif', 'cc', 'ccLow', 'ccMid', 'ccHigh',
|
||||||
'soilT0', 'soilT6', 'soilM', 'Tmrt', 'eh', 'visKm', 'aqi', 'effectiveRad',
|
'soilM', 'Tmrt', 'eh', 'visKm', 'aqi', 'effectiveRad',
|
||||||
|
// Thermal columns that read as a plain block average rather than a worst
|
||||||
|
// case: Tmrt and Dew above, plus the pet trio. pawT/petShadeT mirror
|
||||||
|
// concreteT/shadeT hourly but must NOT inherit their FELT aggregation.
|
||||||
|
'furSurfaceT', 'pawT', 'petShadeT',
|
||||||
'grassPollen', 'birchPollen', 'alderPollen', 'mugwortPollen', 'olivePollen', 'ragweedPollen',
|
'grassPollen', 'birchPollen', 'alderPollen', 'mugwortPollen', 'olivePollen', 'ragweedPollen',
|
||||||
];
|
];
|
||||||
const AGG_MAX = [
|
const AGG_MAX = ['precipProb', 'uv', 'uvA', 'uvB', 'gust'];
|
||||||
'precipProb', 'uv', 'uvA', 'uvB', 'gust',
|
|
||||||
'vehicleT', 'concreteT', 'indoorT', 'managedT',
|
|
||||||
];
|
|
||||||
const AGG_SUM = ['precip', 'snow'];
|
const AGG_SUM = ['precip', 'snow'];
|
||||||
const AGG_FELT = ['utci', 'utciAdj'];
|
// Every temperature the user reads as "how hot/cold does this actually get"
|
||||||
|
// shares one rule, so a 3h row never disagrees with the columns beside it.
|
||||||
|
const AGG_FELT = [
|
||||||
|
'utci', 'utciAdj', // SunSoak
|
||||||
|
'vehicleT', 'concreteT', // Cabin, Concrete
|
||||||
|
'indoorT', 'managedT', // Indoors + Managed Indoors (+ Pet Home)
|
||||||
|
'soilT0', 'soilT6', 'shadeT', 'Ta', // Soil surface + root, Shade, Air
|
||||||
|
];
|
||||||
|
|
||||||
export function aggregateRows(rows, interval) {
|
export function aggregateRows(rows, interval) {
|
||||||
if (!rows || rows.length === 0 || !interval || interval <= 1) return rows;
|
if (!rows || rows.length === 0 || !interval || interval <= 1) return rows;
|
||||||
|
|||||||
+10
-3
@@ -29,7 +29,10 @@ export const FILTER_PROFILES = {
|
|||||||
label: 'Basic',
|
label: 'Basic',
|
||||||
icon: '🌡️',
|
icon: '🌡️',
|
||||||
scene: "url('assets/images/profiles/basic.png') center / cover no-repeat, linear-gradient(160deg,#b8d8e8,#daeef8)",
|
scene: "url('assets/images/profiles/basic.png') center / cover no-repeat, linear-gradient(160deg,#b8d8e8,#daeef8)",
|
||||||
cols: { hour: true, air: true, shadeT: true, rh: true, dew: false, wind: true, dir: false, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, precipProb: true, lightning: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: false, aqi: false, pollen: true, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false },
|
// Basic is the "just tell me" preset - it opens in Quick view (see `view`
|
||||||
|
// below) with the smallest useful column set behind it.
|
||||||
|
view: 'simple',
|
||||||
|
cols: { hour: true, air: false, shadeT: false, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, precipProb: false, lightning: false, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: false, aqi: false, pollen: false, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false },
|
||||||
},
|
},
|
||||||
home: {
|
home: {
|
||||||
label: 'Home',
|
label: 'Home',
|
||||||
@@ -53,12 +56,16 @@ export const FILTER_PROFILES = {
|
|||||||
label: 'Farming',
|
label: 'Farming',
|
||||||
icon: '🌾',
|
icon: '🌾',
|
||||||
scene: "url('assets/images/profiles/farming.png') center / cover no-repeat, linear-gradient(160deg,#d8bc90,#ece0b8)",
|
scene: "url('assets/images/profiles/farming.png') center / cover no-repeat, linear-gradient(160deg,#d8bc90,#ece0b8)",
|
||||||
|
// Farming sits in the Work menu alongside the outdoors variants, so it
|
||||||
|
// opens the detailed table the same way they do.
|
||||||
|
view: 'table',
|
||||||
cols: { hour: true, air: true, shadeT: true, rh: true, dew: true, wind: true, dir: true, cloud: true, sun: true, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, precipProb: true, lightning: true, soilT: true, soilT6: true, soilM: true, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: false, aqi: true, pollen: true, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false },
|
cols: { hour: true, air: true, shadeT: true, rh: true, dew: true, wind: true, dir: true, cloud: true, sun: true, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, precipProb: true, lightning: true, soilT: true, soilT6: true, soilM: true, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: false, aqi: true, pollen: true, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false },
|
||||||
},
|
},
|
||||||
outdoors: {
|
outdoors: {
|
||||||
label: 'Places',
|
label: 'Places',
|
||||||
icon: '🌤️',
|
icon: '🌤️',
|
||||||
scene: "url('assets/images/profiles/places.png') center / cover no-repeat, linear-gradient(160deg,#98ccc0,#c4e4dc)",
|
scene: "url('assets/images/profiles/places.png') center / cover no-repeat, linear-gradient(160deg,#98ccc0,#c4e4dc)",
|
||||||
|
view: 'table',
|
||||||
// Default cols match the first variant (urban). Switching variant updates visibleCols.
|
// Default cols match the first variant (urban). Switching variant updates visibleCols.
|
||||||
cols: { hour: true, air: true, shadeT: true, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: false, burn: true, utciP: true, precip: true, precipProb: true, lightning: true, soilT: false, soilT6: false, soilM: false, concreteT: true, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: true, pollen: false, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false },
|
cols: { hour: true, air: true, shadeT: true, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: false, burn: true, utciP: true, precip: true, precipProb: true, lightning: true, soilT: false, soilT6: false, soilM: false, concreteT: true, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: true, pollen: false, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false },
|
||||||
},
|
},
|
||||||
@@ -251,9 +258,9 @@ export const workVariantKeys = ['farming', 'construction', 'market', 'windowclea
|
|||||||
|
|
||||||
// Tooltip text shown when the user clicks/hovers a table column header.
|
// Tooltip text shown when the user clicks/hovers a table column header.
|
||||||
export const COL_DESCRIPTIONS = {
|
export const COL_DESCRIPTIONS = {
|
||||||
hour: { title: 'Hour', desc: 'Local wall-clock time for this forecast row. Each row covers the interval set by the Rows stepper, clock-aligned from midnight — at 3h, a row labelled 09:00 spans 09:00–11:59. Totals like rainfall are summed across the block, risk figures show the block\'s peak, and smooth readings like air temperature are averaged.' },
|
hour: { title: 'Rows', desc: 'Local wall-clock time for this forecast row. Each row covers the interval set by the stepper in this header, clock-aligned from midnight — at 3h, a row labelled 09:00 spans 09:00–11:59. Totals like rainfall are summed across the block, risk figures show the block\'s peak, and smooth readings like air temperature are averaged.' },
|
||||||
air: { title: 'Air Temperature', desc: 'Measured air temperature at 2 m above the ground. This is the standard thermometer reading — it does not account for sun, wind, or humidity.' },
|
air: { title: 'Air Temperature', desc: 'Measured air temperature at 2 m above the ground. This is the standard thermometer reading — it does not account for sun, wind, or humidity.' },
|
||||||
shadeT: { title: 'Shade Air', desc: 'Estimated air temperature sitting in the typical shade of the selected Solar Model — building shadow, beach umbrella, tree canopy, boat panels, and so on. Sheltered, sun-warmed surroundings push it above the official open-ground Air figure on calm sunny hours; wind and cloud pull it back toward it. This is an estimate, not a measurement, and differs from the standardised 2 m Air reading.' },
|
shadeT: { title: 'Shade', desc: 'Estimated air temperature sitting in the typical shade of the selected Solar Model — building shadow, beach umbrella, tree canopy, boat panels, and so on. Sheltered, sun-warmed surroundings push it above the official open-ground Air figure on calm sunny hours; wind and cloud pull it back toward it. This is an estimate, not a measurement, and differs from the standardised 2 m Air reading.' },
|
||||||
rh: { title: 'Relative Humidity', desc: 'How much moisture the air holds relative to its maximum capacity at that temperature. High RH makes warm days feel stickier and cold days feel rawer.' },
|
rh: { title: 'Relative Humidity', desc: 'How much moisture the air holds relative to its maximum capacity at that temperature. High RH makes warm days feel stickier and cold days feel rawer.' },
|
||||||
dew: { title: 'Dew Point', desc: 'The temperature at which air becomes saturated and moisture begins to condense. A useful measure of absolute humidity — above 16 °C it starts to feel muggy; above 21 °C it feels oppressive.' },
|
dew: { title: 'Dew Point', desc: 'The temperature at which air becomes saturated and moisture begins to condense. A useful measure of absolute humidity — above 16 °C it starts to feel muggy; above 21 °C it feels oppressive.' },
|
||||||
wind: { title: 'Wind Speed', desc: 'Mean wind speed at 10 m in mph, with peak gust in brackets where significantly higher. Gust colour indicates Beaufort scale severity: amber = Near Gale (32+ mph), orange = Gale (39+ mph), red = Severe Gale or above (55+ mph). Banners above the table classify conditions from Strong Breeze through to Violent Storm using standard wind categories. Wind dramatically increases heat loss from exposed skin — the basis of wind chill.' },
|
wind: { title: 'Wind Speed', desc: 'Mean wind speed at 10 m in mph, with peak gust in brackets where significantly higher. Gust colour indicates Beaufort scale severity: amber = Near Gale (32+ mph), orange = Gale (39+ mph), red = Severe Gale or above (55+ mph). Banners above the table classify conditions from Strong Breeze through to Violent Storm using standard wind categories. Wind dramatically increases heat loss from exposed skin — the basis of wind chill.' },
|
||||||
|
|||||||
+1
-1
@@ -86,7 +86,7 @@ export function getCellTagEvents(events, row) {
|
|||||||
// --- BANNER PRIORITY -----------------------------------------------------
|
// --- BANNER PRIORITY -----------------------------------------------------
|
||||||
// Weather event IDs that are safety-relevant and should always appear as the
|
// Weather event IDs that are safety-relevant and should always appear as the
|
||||||
// first banner slide, ahead of cosmic events and lower-priority weather items.
|
// first banner slide, ahead of cosmic events and lower-priority weather items.
|
||||||
const PRIORITY_WEATHER_IDS = new Set(['heat-spike', 'frost', 'storm', 'wind', 'wet']);
|
export const PRIORITY_WEATHER_IDS = new Set(['heat-spike', 'frost', 'storm', 'wind', 'wet']);
|
||||||
|
|
||||||
// --- MAIN EXPORT ---------------------------------------------------------
|
// --- MAIN EXPORT ---------------------------------------------------------
|
||||||
// Returns ALL active events for the given rows/date as an array.
|
// Returns ALL active events for the given rows/date as an array.
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
// 7. Column popup + table scroll hooks
|
// 7. Column popup + table scroll hooks
|
||||||
// 8. Geocoding search effect
|
// 8. Geocoding search effect
|
||||||
// 9. Computation - rows, days, current row
|
// 9. Computation - rows, days, current row
|
||||||
// 10. Banner - events, snooze, slideshow
|
// 10. Events - active, lens, selected-day
|
||||||
// ------------------------------------------------------------------------
|
// ------------------------------------------------------------------------
|
||||||
|
|
||||||
import { useState, useEffect, useRef, useCallback } from '../../vendor/preact-hooks.js';
|
import { useState, useEffect, useRef, useCallback } from '../../vendor/preact-hooks.js';
|
||||||
@@ -336,6 +336,8 @@ export function useAppState() {
|
|||||||
const profile = FILTER_PROFILES[key];
|
const profile = FILTER_PROFILES[key];
|
||||||
try { localStorage.setItem('sunscope_profile', key); } catch (e) { /* ignore */ }
|
try { localStorage.setItem('sunscope_profile', key); } catch (e) { /* ignore */ }
|
||||||
setActiveProfile(key);
|
setActiveProfile(key);
|
||||||
|
// Profiles can declare a preferred forecast view (Basic opens in Quick).
|
||||||
|
if (profile?.view) setForecastView(profile.view);
|
||||||
if (key !== 'custom') {
|
if (key !== 'custom') {
|
||||||
setVisibleCols({ ...profile.cols });
|
setVisibleCols({ ...profile.cols });
|
||||||
const hasIndoor = profile.cols['indoorT'] || profile.cols['managedT'];
|
const hasIndoor = profile.cols['indoorT'] || profile.cols['managedT'];
|
||||||
@@ -387,6 +389,10 @@ export function useAppState() {
|
|||||||
const setOutdoorsVariantAndSave = (v) => {
|
const setOutdoorsVariantAndSave = (v) => {
|
||||||
try { localStorage.setItem('sunscope_outdoors_variant', v); } catch (e) { /* ignore */ }
|
try { localStorage.setItem('sunscope_outdoors_variant', v); } catch (e) { /* ignore */ }
|
||||||
setOutdoorsVariant(v);
|
setOutdoorsVariant(v);
|
||||||
|
// Every Places / Activities / Work variant carries a wide, specific column
|
||||||
|
// set - Quick view can only show a handful of those, so open the detailed
|
||||||
|
// table. (Basic goes the other way; see FILTER_PROFILES.basic.view.)
|
||||||
|
setForecastView('table');
|
||||||
// Sync the column toggle buttons to the variants own cols so the UI matches
|
// Sync the column toggle buttons to the variants own cols so the UI matches
|
||||||
// the autoloaded selection - eg Beach selects Dew. Sun. UV-B and Running selects RH. Pollen. UTCI.
|
// the autoloaded selection - eg Beach selects Dew. Sun. UV-B and Running selects RH. Pollen. UTCI.
|
||||||
const variantCols = OUTDOORS_VARIANTS[v]?.cols;
|
const variantCols = OUTDOORS_VARIANTS[v]?.cols;
|
||||||
@@ -675,99 +681,15 @@ export function useAppState() {
|
|||||||
? utciCategory(currentRow.utciAdj)
|
? utciCategory(currentRow.utciAdj)
|
||||||
: { bg: '#4a4228', fg: '#ede4cc', label: 'No data' };
|
: { bg: '#4a4228', fg: '#ede4cc', label: 'No data' };
|
||||||
|
|
||||||
// ── 10. BANNER + EVENTS ───────────────────────────────────────────────
|
// ── 10. EVENTS ────────────────────────────────────────────────────────
|
||||||
const [dismissedEventIds, setDismissedEventIds] = useState([]);
|
// Events surface as the .event-note strip above the nav (see app.js). It
|
||||||
|
// is in flow rather than an overlay, so there is no dismiss or snooze
|
||||||
const BANNER_SNOOZE_HOURS = 6;
|
// state to track.
|
||||||
const BANNER_SNOOZE_MS = BANNER_SNOOZE_HOURS * 60 * 60 * 1000;
|
|
||||||
const BANNER_SNOOZE_KEY = id => `sunscope.banner.snoozed.${id}`;
|
|
||||||
|
|
||||||
const [bannerIndex, setBannerIndex] = useState(0);
|
|
||||||
const [bannerTransition, setBannerTransition] = useState(null);
|
|
||||||
const [bannerPrevIndex, setBannerPrevIndex] = useState(null);
|
|
||||||
const [bannerVisible, setBannerVisible] = useState(false);
|
|
||||||
const bannerIndexRef = useRef(0);
|
|
||||||
const bannerStageRef = useRef(null);
|
|
||||||
|
|
||||||
useEffect(() => { bannerIndexRef.current = bannerIndex; }, [bannerIndex]);
|
|
||||||
|
|
||||||
const isSnoozed = id => {
|
|
||||||
try {
|
|
||||||
const ts = localStorage.getItem(BANNER_SNOOZE_KEY(id));
|
|
||||||
return ts && (Date.now() - Number(ts)) < BANNER_SNOOZE_MS;
|
|
||||||
} catch (e) { return false; }
|
|
||||||
};
|
|
||||||
|
|
||||||
const todayRows = days[0]?.rows || [];
|
const todayRows = days[0]?.rows || [];
|
||||||
const activeEvents = getActiveEvents(todayRows, location)
|
const activeEvents = getActiveEvents(todayRows, location);
|
||||||
.filter(ev => !dismissedEventIds.includes(ev.id) && !isSnoozed(ev.id));
|
|
||||||
const lensEvent = getLensEvent(activeEvents);
|
const lensEvent = getLensEvent(activeEvents);
|
||||||
const selectedDayEvents = getActiveEvents(visible, location);
|
const selectedDayEvents = getActiveEvents(visible, location);
|
||||||
|
|
||||||
// Only show banner when events first appear - not on every re-render
|
|
||||||
// Using a ref to track previous length avoids re-showing during dismiss animation
|
|
||||||
const prevActiveEventsLenRef = useRef(0);
|
|
||||||
useEffect(() => {
|
|
||||||
const prev = prevActiveEventsLenRef.current;
|
|
||||||
prevActiveEventsLenRef.current = activeEvents.length;
|
|
||||||
if (activeEvents.length > 0 && prev === 0) setBannerVisible(true);
|
|
||||||
}, [activeEvents.length]);
|
|
||||||
|
|
||||||
const bannerSlideTo = useCallback((next) => {
|
|
||||||
const stage = bannerStageRef.current;
|
|
||||||
if (stage) {
|
|
||||||
stage.style.height = stage.offsetHeight + 'px';
|
|
||||||
stage.style.transition = 'height 0.45s cubic-bezier(0.4,0,0.2,1)';
|
|
||||||
}
|
|
||||||
setBannerPrevIndex(bannerIndexRef.current);
|
|
||||||
setBannerIndex(next);
|
|
||||||
setBannerTransition('crossfading');
|
|
||||||
setTimeout(() => {
|
|
||||||
if (stage) {
|
|
||||||
const incoming = stage.querySelector('.event-banner:not(.event-banner--outgoing)');
|
|
||||||
if (incoming) stage.style.height = incoming.offsetHeight + 'px';
|
|
||||||
}
|
|
||||||
}, 16);
|
|
||||||
setTimeout(() => {
|
|
||||||
setBannerTransition(null);
|
|
||||||
setBannerPrevIndex(null);
|
|
||||||
if (stage) { stage.style.height = ''; stage.style.transition = ''; }
|
|
||||||
}, 460);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const dismissBanner = useCallback((evId) => {
|
|
||||||
// Do NOT write the snooze to localStorage yet. activeEvents filters out snoozed events
|
|
||||||
// synchronously on the very next render. so writing here would cause the banner to be
|
|
||||||
// unmounted before the fade animation can start - which is what made it snap closed.
|
|
||||||
// We snooze AFTER the animation finishes inside the setTimeout below.
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
setBannerVisible(false);
|
|
||||||
setTimeout(() => {
|
|
||||||
try { localStorage.setItem(BANNER_SNOOZE_KEY(evId), String(Date.now())); } catch (e) {}
|
|
||||||
setDismissedEventIds(ids => [...ids, evId]);
|
|
||||||
setBannerIndex(0);
|
|
||||||
}, 500);
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (activeEvents.length <= 1) { setBannerIndex(0); return; }
|
|
||||||
const id = setInterval(() => {
|
|
||||||
const next = (bannerIndexRef.current + 1) % activeEvents.length;
|
|
||||||
bannerSlideTo(next);
|
|
||||||
}, 10000);
|
|
||||||
return () => clearInterval(id);
|
|
||||||
}, [activeEvents.length, activeEvents.map(e => e.id).join(','), bannerSlideTo]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (bannerIndex >= activeEvents.length) setBannerIndex(0);
|
|
||||||
}, [activeEvents.length]);
|
|
||||||
|
|
||||||
// The banner is now a position:fixed flyout pinned to the very top of the
|
|
||||||
// screen (see .event-banner-wrap in ui.css). It floats over all content and
|
|
||||||
// slides in/out via a transform, so no JS nav-pinning or shell padding is
|
|
||||||
// needed.
|
|
||||||
|
|
||||||
// ── RETURN ALL STATE + HANDLERS ───────────────────────────────────────
|
// ── RETURN ALL STATE + HANDLERS ───────────────────────────────────────
|
||||||
return {
|
return {
|
||||||
// location
|
// location
|
||||||
@@ -831,10 +753,7 @@ export function useAppState() {
|
|||||||
hourlyRows, days, utcOffsetMs,
|
hourlyRows, days, utcOffsetMs,
|
||||||
visible, tableRows, nowLocalISO, currentRow, currentCat,
|
visible, tableRows, nowLocalISO, currentRow, currentCat,
|
||||||
liveElev,
|
liveElev,
|
||||||
// banner + events
|
// events
|
||||||
activeEvents, lensEvent, selectedDayEvents,
|
activeEvents, lensEvent, selectedDayEvents,
|
||||||
bannerIndex, bannerTransition, bannerPrevIndex,
|
|
||||||
bannerVisible, bannerStageRef,
|
|
||||||
bannerSlideTo, dismissBanner,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -89,7 +89,7 @@
|
|||||||
<tr><td><strong>Shade Air °C</strong></td><td>Estimated air temperature sitting in the typical shade of the selected Solar Model — building shadow, beach umbrella, tree canopy, boat panels, and so on. Sheltered, sun-warmed surroundings push it above the official open-ground Air figure on calm sunny hours; wind and cloud pull it back toward it. An estimate, not a measurement, and different from the standardised 2 m Air reading.</td></tr>
|
<tr><td><strong>Shade Air °C</strong></td><td>Estimated air temperature sitting in the typical shade of the selected Solar Model — building shadow, beach umbrella, tree canopy, boat panels, and so on. Sheltered, sun-warmed surroundings push it above the official open-ground Air figure on calm sunny hours; wind and cloud pull it back toward it. An estimate, not a measurement, and different from the standardised 2 m Air reading.</td></tr>
|
||||||
<tr><td><strong>RH %</strong></td><td>Relative humidity: how much moisture the air holds as a percentage of its maximum capacity at that temperature. High RH makes warm days feel stickier and cold days feel rawer.</td></tr>
|
<tr><td><strong>RH %</strong></td><td>Relative humidity: how much moisture the air holds as a percentage of its maximum capacity at that temperature. High RH makes warm days feel stickier and cold days feel rawer.</td></tr>
|
||||||
<tr><td><strong>Dew Point</strong></td><td>The temperature at which air becomes saturated and moisture begins to condense. Above 16 °C it starts to feel muggy; above 21 °C it feels oppressive. A better measure of absolute humidity than RH.</td></tr>
|
<tr><td><strong>Dew Point</strong></td><td>The temperature at which air becomes saturated and moisture begins to condense. Above 16 °C it starts to feel muggy; above 21 °C it feels oppressive. A better measure of absolute humidity than RH.</td></tr>
|
||||||
<tr><td><strong>Wind</strong></td><td>Mean wind speed at 10 m in mph, with peak gust in brackets where significantly higher. Gust colour follows the Beaufort scale: amber = Near Gale (32+ mph), orange = Gale (39+ mph), red = Severe Gale or above (55+ mph). Banners above the table classify conditions from Strong Breeze through to Violent Storm. Wind is the primary driver of wind chill.</td></tr>
|
<tr><td><strong>Wind</strong></td><td>Mean wind speed at 10 m in mph, with peak gust in brackets where significantly higher. Gust colour follows the Beaufort scale: amber = Near Gale (32+ mph), orange = Gale (39+ mph), red = Severe Gale or above (55+ mph). Event alerts classify conditions from Strong Breeze through to Violent Storm. Wind is the primary driver of wind chill.</td></tr>
|
||||||
<tr><td><strong>Dir</strong></td><td>The compass direction the wind is blowing from, shown as an arrow and abbreviated label. Useful for route planning and spray timing.</td></tr>
|
<tr><td><strong>Dir</strong></td><td>The compass direction the wind is blowing from, shown as an arrow and abbreviated label. Useful for route planning and spray timing.</td></tr>
|
||||||
<tr><td><strong>Cloud</strong></td><td>Total cloud cover as a percentage of sky. High cloud blocks solar radiation, reducing daytime heating and overnight cooling.</td></tr>
|
<tr><td><strong>Cloud</strong></td><td>Total cloud cover as a percentage of sky. High cloud blocks solar radiation, reducing daytime heating and overnight cooling.</td></tr>
|
||||||
<tr><td><strong>Sun °</strong></td><td>The angle of the sun above the horizon in degrees. Below 0° the sun has set. The higher the elevation, the more intense the radiation reaching the ground.</td></tr>
|
<tr><td><strong>Sun °</strong></td><td>The angle of the sun above the horizon in degrees. Below 0° the sun has set. The higher the elevation, the more intense the radiation reaching the ground.</td></tr>
|
||||||
|
|||||||
@@ -55,7 +55,7 @@
|
|||||||
{"@type":"Question","name":"How can I tell if the ground is too wet for a motorhome or caravan?","acceptedAnswer":{"@type":"Answer","text":"SunScope soil moisture shows volumetric water content in cubic metres per cubic metre. Above 0.4 is saturated and a heavy vehicle will likely sink. 0.3-0.4 is borderline. Below 0.3 is usually firm enough. Below 0.2 is dry and solid. The Vehicle profile includes soil moisture by default for motorhome and caravan users checking pitch suitability before arrival."}},
|
{"@type":"Question","name":"How can I tell if the ground is too wet for a motorhome or caravan?","acceptedAnswer":{"@type":"Answer","text":"SunScope soil moisture shows volumetric water content in cubic metres per cubic metre. Above 0.4 is saturated and a heavy vehicle will likely sink. 0.3-0.4 is borderline. Below 0.3 is usually firm enough. Below 0.2 is dry and solid. The Vehicle profile includes soil moisture by default for motorhome and caravan users checking pitch suitability before arrival."}},
|
||||||
{"@type":"Question","name":"Does SunScope show air quality and pollen forecasts?","acceptedAnswer":{"@type":"Answer","text":"Yes. Three air environment columns sit after Cloud cover in the table: Visibility (horizontal visibility in km), AQI (European Air Quality Index combining PM2.5, PM10, ozone, nitrogen dioxide, and sulphur dioxide), and Pollen (grains per cubic metre with a type picker for grass, birch, alder, mugwort, olive, ragweed, or total). All three are sourced from Copernicus CAMS via Open-Meteo. Toggle them using the column buttons above the table."}},
|
{"@type":"Question","name":"Does SunScope show air quality and pollen forecasts?","acceptedAnswer":{"@type":"Answer","text":"Yes. Three air environment columns sit after Cloud cover in the table: Visibility (horizontal visibility in km), AQI (European Air Quality Index combining PM2.5, PM10, ozone, nitrogen dioxide, and sulphur dioxide), and Pollen (grains per cubic metre with a type picker for grass, birch, alder, mugwort, olive, ragweed, or total). All three are sourced from Copernicus CAMS via Open-Meteo. Toggle them using the column buttons above the table."}},
|
||||||
{"@type":"Question","name":"What does the weather icon on each day tab mean?","acceptedAnswer":{"@type":"Answer","text":"Each day tab shows a weather icon for dominant daytime sky conditions - a sun disc for clear days, cloud variants for cloudy days, or a precipitation icon for rainy or snowy days. Below the icon are the day high and low SunSoak felt temperatures in warm and cool colours. The icon is derived from actual solar elevation and cloud data, not a fixed set of generic symbols."}},
|
{"@type":"Question","name":"What does the weather icon on each day tab mean?","acceptedAnswer":{"@type":"Answer","text":"Each day tab shows a weather icon for dominant daytime sky conditions - a sun disc for clear days, cloud variants for cloudy days, or a precipitation icon for rainy or snowy days. Below the icon are the day high and low SunSoak felt temperatures in warm and cool colours. The icon is derived from actual solar elevation and cloud data, not a fixed set of generic symbols."}},
|
||||||
{"@type":"Question","name":"What are the weather event banners above the forecast table?","acceptedAnswer":{"@type":"Answer","text":"When notable conditions are forecast - ground frost, strong wind warning, fog, or a heat event - a coloured banner appears above the table. Tapping or clicking the event icon opens a popup with more detail about what the condition means and what to watch out for. Multiple events can appear at once."}},
|
{"@type":"Question","name":"What are the weather event alerts at the top of the page?","acceptedAnswer":{"@type":"Answer","text":"When notable conditions are forecast - ground frost, strong wind warning, fog, or a heat event - a note appears across the top of the page, above the navigation, with the full message and the dates it applies to. When several events are active it cycles between them. Tapping or clicking the event icon in the hour column opens a popup with the same detail for that hour."}},
|
||||||
{"@type":"Question","name":"What is the Today at a Glance panel and what does vs seasonal average mean?","acceptedAnswer":{"@type":"Answer","text":"Today at a Glance is a summary panel above the table that pulls together the 4-5 numbers that matter most for the day and your active profile, so you don't have to scan the full hourly table. It also shows a vs seasonal average comparison on every profile: the day's forecast high and 24-hour mean against the 1991-2020 climate normal for that date and location, sourced from Open-Meteo's historical archive. A reading of plus 5 degrees or more against the normal high is flagged - an honest anomaly check on top of the raw forecast."}},
|
{"@type":"Question","name":"What is the Today at a Glance panel and what does vs seasonal average mean?","acceptedAnswer":{"@type":"Answer","text":"Today at a Glance is a summary panel above the table that pulls together the 4-5 numbers that matter most for the day and your active profile, so you don't have to scan the full hourly table. It also shows a vs seasonal average comparison on every profile: the day's forecast high and 24-hour mean against the 1991-2020 climate normal for that date and location, sourced from Open-Meteo's historical archive. A reading of plus 5 degrees or more against the normal high is flagged - an honest anomaly check on top of the raw forecast."}},
|
||||||
{"@type":"Question","name":"Can I see how the sky and weather will look later today?","acceptedAnswer":{"@type":"Answer","text":"Yes. By default the sky scope shows the current moment, with sun position, sky colour, glow, cloud, rain, snow and haze all calculated from real forecast data. You can also travel through the day: press the Day cycle play button to run a 24-hour time-lapse that animates the sky and weather through the next day in about a minute and a half, or drag the thumb along the timeline bar beneath the scope to scrub to any instant. The timeline shows the day's changing sky colours with sunrise and sunset markers at their real times."}},
|
{"@type":"Question","name":"Can I see how the sky and weather will look later today?","acceptedAnswer":{"@type":"Answer","text":"Yes. By default the sky scope shows the current moment, with sun position, sky colour, glow, cloud, rain, snow and haze all calculated from real forecast data. You can also travel through the day: press the Day cycle play button to run a 24-hour time-lapse that animates the sky and weather through the next day in about a minute and a half, or drag the thumb along the timeline bar beneath the scope to scrub to any instant. The timeline shows the day's changing sky colours with sunrise and sunset markers at their real times."}},
|
||||||
{"@type":"Question","name":"How far ahead does the forecast go?","acceptedAnswer":{"@type":"Answer","text":"All 14 days of hourly data are free for everyone. The forecast auto-refreshes while the page is open so the current hour always reflects the latest data from Open-Meteo. No manual reload needed."}},
|
{"@type":"Question","name":"How far ahead does the forecast go?","acceptedAnswer":{"@type":"Answer","text":"All 14 days of hourly data are free for everyone. The forecast auto-refreshes while the page is open so the current hour always reflects the latest data from Open-Meteo. No manual reload needed."}},
|
||||||
@@ -155,8 +155,8 @@
|
|||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details class="faq-block">
|
<details class="faq-block">
|
||||||
<summary class="faq-q">What are the weather event banners above the forecast table?</summary>
|
<summary class="faq-q">What are the weather event alerts at the top of the page?</summary>
|
||||||
<p class="faq-a">When notable weather conditions are forecast - such as a ground frost, strong wind warning, fog, or a heat event - a coloured banner with a brief alert appears above the table. Tapping or clicking the event icon opens a popup with more detail about what the condition means and what to watch out for. Multiple events can appear at once if conditions warrant it.</p>
|
<p class="faq-a">When notable weather conditions are forecast - such as a ground frost, strong wind warning, fog, or a heat event - a note appears across the top of the page, above the navigation, with the full message and the dates it applies to. Multiple events can appear at once if conditions warrant it, in which case the note cycles between them and you can click the dots to jump straight to one. Tapping or clicking the event icon in the hour column opens a popup with the same detail for that particular hour.</p>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details class="faq-block">
|
<details class="faq-block">
|
||||||
|
|||||||
+11
-10
@@ -4,18 +4,18 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>On-screen features · SunScope</title>
|
<title>On-screen features · SunScope</title>
|
||||||
<meta name="description" content="The interactive parts of SunScope beyond the table — the sky scope, day-strip weather icons, weather-event banners and popups, and the What's Coming cosmic events almanac." />
|
<meta name="description" content="The interactive parts of SunScope beyond the table — the sky scope, day-strip weather icons, weather-event alerts and popups, and the What's Coming cosmic events almanac." />
|
||||||
<meta name="robots" content="index, follow" />
|
<meta name="robots" content="index, follow" />
|
||||||
<link rel="canonical" href="https://sunscope.net/features.html" />
|
<link rel="canonical" href="https://sunscope.net/features.html" />
|
||||||
|
|
||||||
<meta property="og:title" content="On-screen features · SunScope" />
|
<meta property="og:title" content="On-screen features · SunScope" />
|
||||||
<meta property="og:description" content="The sky scope, day-strip weather icons, weather-event banners and popups, and the What's Coming cosmic events almanac." />
|
<meta property="og:description" content="The sky scope, day-strip weather icons, weather-event alerts and popups, and the What's Coming cosmic events almanac." />
|
||||||
<meta property="og:url" content="https://sunscope.net/features.html" />
|
<meta property="og:url" content="https://sunscope.net/features.html" />
|
||||||
<meta property="og:type" content="article" />
|
<meta property="og:type" content="article" />
|
||||||
<meta property="og:image" content="https://sunscope.net/og-image.png" />
|
<meta property="og:image" content="https://sunscope.net/og-image.png" />
|
||||||
<meta name="twitter:card" content="summary_large_image" />
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
<meta name="twitter:title" content="On-screen features · SunScope" />
|
<meta name="twitter:title" content="On-screen features · SunScope" />
|
||||||
<meta name="twitter:description" content="The sky scope, day-strip weather icons, weather-event banners and popups, and the What's Coming cosmic events almanac." />
|
<meta name="twitter:description" content="The sky scope, day-strip weather icons, weather-event alerts and popups, and the What's Coming cosmic events almanac." />
|
||||||
<meta name="twitter:image" content="https://sunscope.net/og-image.png" />
|
<meta name="twitter:image" content="https://sunscope.net/og-image.png" />
|
||||||
|
|
||||||
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
|
||||||
@@ -134,13 +134,14 @@
|
|||||||
an honest anomaly check on top of the raw forecast numbers.
|
an honest anomaly check on top of the raw forecast numbers.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2>Weather event banners and popups</h2>
|
<h2>Weather event alerts and popups</h2>
|
||||||
<p>
|
<p>
|
||||||
When notable conditions are forecast — a frost, strong winds, fog, or a heat event — a banner appears
|
When notable conditions are forecast — a frost, strong winds, fog, or a heat event — a note appears
|
||||||
above the forecast table with a brief alert. Wind banners classify conditions from Strong Breeze
|
across the top of the page, above the navigation, with the full message and the dates it applies to.
|
||||||
through to Violent Storm using standard Beaufort categories. Tapping or clicking the event icon opens
|
Wind alerts classify conditions from Strong Breeze through to Violent Storm using standard Beaufort
|
||||||
a detailed popup explaining what the condition means and what to watch out for. Multiple events can
|
categories. When several events are active the note cycles between them, and you can click the dots to
|
||||||
appear at once.
|
jump straight to one. Tapping or clicking the event icon in the hour column opens a popup with the same
|
||||||
|
detail for that particular hour.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2>What's Coming — cosmic events almanac</h2>
|
<h2>What's Coming — cosmic events almanac</h2>
|
||||||
@@ -149,7 +150,7 @@
|
|||||||
next 90 days: meteor showers, lunar and solar eclipses, planetary conjunctions, and oppositions. Each
|
next 90 days: meteor showers, lunar and solar eclipses, planetary conjunctions, and oppositions. Each
|
||||||
entry shows the event name, peak date, a countdown, a short description, and a visibility note where
|
entry shows the event name, peak date, a countdown, a short description, and a visibility note where
|
||||||
the event is better from a particular hemisphere or location. When a cosmic event is currently active,
|
the event is better from a particular hemisphere or location. When a cosmic event is currently active,
|
||||||
it also appears as a banner above the forecast table alongside the weather alerts.
|
it also appears in the note at the top of the page alongside the weather alerts.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>scope cell</title>
|
||||||
|
<link rel="stylesheet" href="./assets/css/header.css" />
|
||||||
|
<style>
|
||||||
|
html,body { margin:0; height:100%; background: transparent; overflow: hidden; }
|
||||||
|
#cell { display:flex; align-items:center; justify-content:center; height:100%; }
|
||||||
|
.scope-ring { width: 200px; height: 200px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="cell"></div>
|
||||||
|
<script type="module">
|
||||||
|
import { h, render } from './assets/vendor/preact.js';
|
||||||
|
const { ScopeReticle } = await import('./assets/js/components.js?v=' + Date.now());
|
||||||
|
|
||||||
|
const q = new URLSearchParams(location.search);
|
||||||
|
const n = (k, d) => { const v = q.get(k); return v == null ? d : Number(v); };
|
||||||
|
|
||||||
|
// hour drives the rising/setting palette (UTC hour < 12 => sunrise tints).
|
||||||
|
const dt = new Date(Date.UTC(2026, 5, 6, n('hour', 12), 0, 0));
|
||||||
|
const cat = { label: 'Comfortable', bg: '#90d090', fg: '#157a15' };
|
||||||
|
const storm = q.get('storm') === '1';
|
||||||
|
|
||||||
|
render(h(ScopeReticle, {
|
||||||
|
value: 12.4, cat, loading: false,
|
||||||
|
elev: n('elev', 30), dt, glob: n('glob', 400),
|
||||||
|
activeEvent: storm ? { id: 'storm' } : null,
|
||||||
|
cc: n('cc', 0), ccLow: n('ccLow', 0), ccMid: n('ccMid', 0), ccHigh: n('ccHigh', 0),
|
||||||
|
precip: n('precip', 0), snow: n('snow', 0), visKm: n('vis', 30),
|
||||||
|
env: q.get('env') || 'open',
|
||||||
|
wind: n('wind', 0), gust: n('gust', 0), wd: q.get('wd') != null ? n('wd', 0) : null,
|
||||||
|
}), document.getElementById('cell'));
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>SunScope — Ground Lab (dev)</title>
|
||||||
|
<style>
|
||||||
|
body { margin: 0; background: #1a140c; font-family: Manrope, system-ui, sans-serif; color: #f5edd6; }
|
||||||
|
h1 { text-align: center; font-weight: 700; font-size: 18px; padding: 14px 0 2px; opacity: 0.8; }
|
||||||
|
p.note { text-align:center; opacity:0.45; font-size:12px; margin:0 0 10px; }
|
||||||
|
.row-label { text-align:center; font-size:12px; font-weight:700; opacity:0.6; margin:14px 0 2px; letter-spacing:1px; text-transform:uppercase; }
|
||||||
|
.grid { display: grid; grid-template-columns: repeat(8, 150px); gap: 6px 8px; justify-content: center; padding: 0 12px; }
|
||||||
|
.cell { display: flex; flex-direction: column; align-items: center; }
|
||||||
|
.label { font-size: 11px; font-weight: 700; opacity: 0.85; }
|
||||||
|
iframe { width: 150px; height: 150px; border: 0; background: transparent; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>SunScope — Ground Lab</h1>
|
||||||
|
<p class="note">dev preview · per-environment grounds across the cycle</p>
|
||||||
|
<div id="lab"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const envs = ['open','forest','alpine','urban','beach','openwater','desert','river'];
|
||||||
|
const envName = { open:'Grass', forest:'Forest', alpine:'Alpine', urban:'City', beach:'Beach', openwater:'Open Water', desert:'Desert', river:'River' };
|
||||||
|
|
||||||
|
// [rowLabel, base query]
|
||||||
|
const rows = [
|
||||||
|
['Midday · clear', 'elev=45&hour=12&glob=800&cc=0&vis=30'],
|
||||||
|
['Sunset · clear', 'elev=2&hour=19&glob=110&cc=10&vis=30'],
|
||||||
|
['Night · clear', 'elev=-15&hour=23&glob=0&cc=10&vis=30'],
|
||||||
|
['Midday · overcast','elev=45&hour=12&glob=300&cc=95&ccLow=90&ccMid=70&vis=18'],
|
||||||
|
['Day · rain', 'elev=40&hour=12&glob=220&cc=98&ccLow=92&ccMid=75&precip=5&vis=6'],
|
||||||
|
['Day · snow', 'elev=30&hour=12&glob=200&cc=95&ccLow=90&snow=1.2&vis=4'],
|
||||||
|
];
|
||||||
|
|
||||||
|
const lab = document.getElementById('lab');
|
||||||
|
for (const [rowLabel, q] of rows) {
|
||||||
|
const rl = document.createElement('div');
|
||||||
|
rl.className = 'row-label';
|
||||||
|
rl.textContent = rowLabel;
|
||||||
|
lab.appendChild(rl);
|
||||||
|
const grid = document.createElement('div');
|
||||||
|
grid.className = 'grid';
|
||||||
|
for (const env of envs) {
|
||||||
|
const cell = document.createElement('div');
|
||||||
|
cell.className = 'cell';
|
||||||
|
const lbl = document.createElement('span');
|
||||||
|
lbl.className = 'label';
|
||||||
|
lbl.textContent = envName[env];
|
||||||
|
const ifr = document.createElement('iframe');
|
||||||
|
ifr.src = `./scope-cell?${q}&env=${env}&_=${Date.now()}`;
|
||||||
|
ifr.loading = 'eager';
|
||||||
|
cell.append(lbl, ifr);
|
||||||
|
grid.append(cell);
|
||||||
|
}
|
||||||
|
lab.appendChild(grid);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user