New big and small scope sunset and sunrise simulation
New calculations for the day tabs
Added missing icons to simple view
New record popups
Week ahead description popups
Camping profile
Tweak to some of the indoor tems
This commit is contained in:
fraxle
2026-10-08 09:53:41 +01:00
parent 71860b9dd9
commit 862f631beb
22 changed files with 2169 additions and 429 deletions
+17 -7
View File
@@ -25,7 +25,11 @@ header {
justify-content: flex-end;
padding: 18px 32px;
gap: 28px;
border-bottom: 1px solid #e0d0a8;
/* Mirrors .utci-topnav in css/base.css: a shade deeper than the parchment
page beneath, so the bar reads as chrome rather than as more page. */
background: #ebdcb4;
border-bottom: 1px solid #c9b08a;
box-shadow: 0 3px 8px rgba(30, 18, 8, 0.10);
position: relative;
z-index: 100;
}
@@ -80,8 +84,8 @@ header a.active { color: #c8922a; }
top: calc(100% - 1px);
left: 50%;
margin-top: 0;
background: #f5edd6;
border: 1px solid #e0d0a8;
background: #fffcf2;
border: 1px solid #d4c0a0;
border-radius: 8px;
box-shadow: 0 8px 24px rgba(100, 70, 20, 0.12);
display: flex;
@@ -229,7 +233,7 @@ header a.nav-logo:hover { border-bottom-color: transparent; }
right: 0;
padding: 10px 20px;
height: 72px;
background: #f5edd6;
background: #ebdcb4;
}
body {
@@ -277,8 +281,8 @@ header a.nav-logo:hover { border-bottom-color: transparent; }
right: -100%;
width: 220px;
height: 100vh;
background: #f5edd6;
border-left: 1px solid #e0d0a8;
background: #ebdcb4;
border-left: 1px solid #c9b08a;
display: flex;
flex-direction: column;
align-items: flex-start;
@@ -524,12 +528,18 @@ em { color: #7a5c2a; font-style: italic; }
vertical-align: middle;
}
/* Bookends the header bar above: same deep-honey surface, warm border and
shadow, with the shadow pointing up instead of down. Mirrors
.utci-site-footer in css/ui.css. */
footer {
text-align: center;
padding: 2rem 1.5rem;
font-size: 0.82rem;
color: #b09870;
border-top: 1px solid #e0d0a8;
position: relative; /* keeps the upward shadow over the content it covers */
background: #ebdcb4;
border-top: 1px solid #c9b08a;
box-shadow: 0 -3px 8px rgba(30, 18, 8, 0.10);
}
@media (max-width: 600px) {
+88 -14
View File
@@ -16,7 +16,8 @@
#f5edd6 parchment page background
#fffcf2 soft cream cards, search box, table
#fdf8ee warm cream toolbars, legend, about block
#f0e4c4 pale honey table heading row
#f0e4c4 pale honey table heading row, priority banner
#ebdcb4 deep honey site header bar, footer bar, nav drawer
#fef3dc warm hover buttons on hover
#fef8ea row hover table row on hover
#fff8e4 "now" row the currently-active hour
@@ -109,8 +110,14 @@ body {
justify-content: flex-end; /* .nav-logo's margin-right:auto splits the row */
padding: 18px var(--app-pad);
gap: 28px;
background: #f5edd6;
border-bottom: 1px solid #e0d0a8;
/* Two shades deeper than the parchment page beneath, so the bar reads as
chrome rather than as more page — it is the topmost surface, so it sits a
step below the table heading row (#f0e4c4) rather than matching it.
The shadow is espresso at low alpha, not black, to stay in the warm family.
The mobile bar and drawer below repeat these three values. */
background: #ebdcb4;
border-bottom: 1px solid #c9b08a;
box-shadow: 0 3px 8px rgba(30, 18, 8, 0.10);
}
.utci-topnav a {
@@ -166,8 +173,8 @@ body {
top: calc(100% - 1px);
left: 50%;
margin-top: 0;
background: #f5edd6;
border: 1px solid #e0d0a8;
background: #fffcf2;
border: 1px solid #d4c0a0;
border-radius: 8px;
box-shadow: 0 8px 24px rgba(100, 70, 20, 0.12);
display: flex;
@@ -403,6 +410,63 @@ body {
transform: scale(1.3);
}
/* Close + Quick Record stack in the strip's top-right corner, Close above
Quick Record, rather than running side by side in the row. */
.event-note-actions {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
flex-shrink: 0;
align-self: flex-start;
margin: -2px -6px 0 10px;
}
/* Quick Record — the banner's one action: sends the event showing to the
journal (see quickRecord in app.js). Reads as an affordance rather than
chrome, so unlike the close pill above it it carries its brass tint
standing still. */
.event-note-record {
display: inline-flex;
align-items: center;
gap: 5px;
flex-shrink: 0;
padding: 4px 9px;
border: 1px solid rgba(200, 146, 42, 0.45);
border-radius: 999px;
background: rgba(200, 146, 42, 0.13);
font-family: Manrope, sans-serif;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #8a6414;
line-height: 1;
cursor: pointer;
transition: background 0.2s, color 0.2s, border-color 0.2s;
}
.event-note-record:hover {
background: rgba(200, 146, 42, 0.28);
border-color: rgba(200, 146, 42, 0.75);
color: #6b4a0c;
}
.event-note-record:focus-visible {
outline: 2px solid #c8922a;
outline-offset: 1px;
}
/* On a tinted warning the brass disappears into the hue, so the pill drops
to the same neutral darks the rest of the tinted copy uses. */
.event-note.is-tinted .event-note-record {
background: rgba(0, 0, 0, 0.08);
border-color: rgba(0, 0, 0, 0.24);
color: #3f3222;
}
.event-note.is-tinted .event-note-record:hover {
background: rgba(0, 0, 0, 0.16);
border-color: rgba(0, 0, 0, 0.4);
color: #2a1a08;
}
/* Close control — a small brass "Close ×" pill in the top-right corner. It
sits at the end of the strip's flex row rather than being absolutely
positioned, so the dates line can never run underneath it. */
@@ -411,8 +475,6 @@ body {
align-items: center;
gap: 4px;
flex-shrink: 0;
align-self: flex-start;
margin: -2px -6px 0 2px; /* pull into the padding so it hugs the corner */
padding: 3px 7px;
border: 1px solid transparent;
border-radius: 999px;
@@ -491,15 +553,26 @@ body {
.event-note-dates {
margin-left: 0; /* no room to right-align on a phone */
}
/* Phone: the cross alone, pushed to the right-hand end of the row and
padded out to a ~26px touch target. */
.event-note-close {
/* Phone: both pills shrink to their icon alone, on a ~26px touch target
each, and the stack is pushed to the right-hand end of the row. */
.event-note-actions {
margin: -4px -6px 0 auto;
gap: 2px;
}
.event-note-close {
padding: 8px;
}
.event-note-close-label {
display: none;
}
/* Same treatment for Quick Record — the notebook glyph alone. It keeps its
tint, so it still reads as the button of the two. */
.event-note-record {
padding: 7px;
}
.event-note-record-label {
display: none;
}
}
/* ── Note attached to the header ─────────────────────────────────────
@@ -679,8 +752,9 @@ body {
margin: 0;
padding: 10px 20px;
height: 72px;
background: #f5edd6;
border-bottom: 1px solid #e0d0a8;
background: #ebdcb4;
border-bottom: 1px solid #c9b08a;
box-shadow: 0 3px 8px rgba(30, 18, 8, 0.10);
}
.utci-burger {
@@ -694,8 +768,8 @@ body {
right: -100%;
width: 220px;
height: 100vh;
background: #f5edd6;
border-left: 1px solid #e0d0a8;
background: #ebdcb4;
border-left: 1px solid #c9b08a;
display: flex;
flex-direction: column;
align-items: flex-start;
+102 -8
View File
@@ -735,10 +735,12 @@
}
.utci-thead-sticky {
position: sticky;
/* Clears the profile row pinned above it. DayTabs measures that row and
publishes its height (+ its 4px margin) as --profile-row-h — it is one
line or two depending on the labels, and the old fixed 28px left the
table header tucked under it. */
/* Sits flush under the profile row pinned above it. DayTabs measures that
row and publishes its height as --profile-row-h — it is one line or two
depending on the labels, and the old fixed 28px left the table header
tucked under it. The offset is the row's exact height, so the two bands
butt together; any padding here shows as a strip of the scrolling page
between them. */
top: var(--profile-row-h, 52px) !important;
z-index: 10;
}
@@ -1749,6 +1751,36 @@
animation: popup-open-below 0.22s cubic-bezier(0.34, 1.4, 0.64, 1) both;
}
/* ── WEEK AHEAD ROW POPUP ───────────────────────────────────────────────
Replaces the desktop-only `title=` tooltips the Week Ahead rows used to
carry: tap/click a row to open this. Reuses the .col-info-popup shell. */
.col-info-popup--week { width: 240px; }
.col-info-desc--lead {
margin-bottom: 8px;
font-weight: 600;
color: #4a3420;
}
.week-popup-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
overflow-y: auto;
}
.week-popup-list li {
display: grid;
grid-template-columns: 18px 1fr;
column-gap: 8px;
align-items: start;
font-family: Manrope, sans-serif;
font-size: 12px;
line-height: 1.45;
color: #6b4f2a;
}
.week-popup-icon { text-align: center; }
/* ── EVENT TAG POPUP — SLIDESHOW ────────────────────────────────────────
The stage holds the current slide. On transition, evTransition is set
to 'exiting' (fade out) then 'entering' (fade in) via the hook. */
@@ -1757,6 +1789,12 @@
.ev-popup-stage {
transition: opacity 0.38s ease;
/* Shrinkable and scrollable, so a long message runs out of room inside the
stage rather than pushing the Quick Record button below it out of the
card's 260px cap. min-height:0 because a flex item won't otherwise
shrink past its content. */
min-height: 0;
overflow-y: auto;
}
.ev-popup-entering {
animation: ev-fade-in 0.38s ease both;
@@ -1765,6 +1803,43 @@
animation: ev-fade-out 0.38s ease both;
}
/* Quick Record — sends the event showing to the journal (see quickRecord in
app.js). Same pill as the event banner's, sized for this popup: full-width
so it reads as the panel's action rather than a stray chip, and outside the
crossfading stage so it stays put while the events cycle. Never shrinks, so
it survives a long message scrolling inside .col-info-desc. */
.ev-popup-record {
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
width: 100%;
flex: 0 0 auto;
margin-top: 10px;
padding: 6px 10px;
border: 1px solid rgba(200, 146, 42, 0.45);
border-radius: 999px;
background: rgba(200, 146, 42, 0.13);
font-family: Manrope, sans-serif;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #8a6414;
line-height: 1;
cursor: pointer;
transition: background 0.2s, color 0.2s, border-color 0.2s;
}
.ev-popup-record:hover {
background: rgba(200, 146, 42, 0.28);
border-color: rgba(200, 146, 42, 0.75);
color: #6b4a0c;
}
.ev-popup-record:focus-visible {
outline: 2px solid #c8922a;
outline-offset: 1px;
}
/* Slideshow navigation dots */
.ev-popup-dots {
display: flex;
@@ -2623,6 +2698,17 @@
.fsc-card--now .fsc-time {
color: #c8922a;
}
/* Event emojis: centred row under the time. Fixed height so cards without
events (which still render an empty row) match those with them. */
.fsc-ev-row {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
height: 16px;
margin: -2px 0;
white-space: nowrap;
}
.fsc-feel {
font-family: Manrope, sans-serif;
@@ -2694,9 +2780,16 @@
88px. It must not be flexible: the connector lines are drawn from the SVG's
top edge and have to stay flush under the cards, so any height the box does
not get is height the cold end loses off the bottom. */
/* Width: 0 + min-width: 100% rather than width: 100%. The SVG's viewBox gives
it an intrinsic width of height × 1000/80 (1100px at 88px), and inside the
max-content .forecast-simple-inner that set the row's minimum width — so at
3h/4h, where the cards need far less, they were stretched apart to 1100px
and the panel scrolled. This way only the cards size the row; the curve
just fills whatever width they settle on. */
.fsc-temp-curve {
display: block;
width: 100%;
width: 0;
min-width: 100%;
flex: 0 0 auto;
height: 88px;
}
@@ -2915,8 +3008,9 @@
.utci-table.utci-table-rotated tbody td.grp-label-fill {
padding: 5px 0;
min-width: 0;
background-color: #f0e4c4;
background-image: linear-gradient(var(--grp-tint, transparent), var(--grp-tint, transparent));
/* White across the hour columns - only the pinned group label keeps the
category shade. */
background: #ffffff;
/* One rule, along the very top of the category block — the same 2px
rgba(74,50,24,0.18) the unrotated table draws down the left of each new
column group (.col-group-start), so a group boundary looks the same
@@ -2925,7 +3019,7 @@
as a double rule around the label. Every cell in the row carries the edge
— under border-collapse:separate a <tr> border never paints, so the row's
rule has to be drawn cell by cell (see the row-separator rule above). */
border-top: 2px solid rgba(74, 50, 24, 0.18);
border-top: 2px solid #ffffff; /* white under the hour columns; the label keeps its shaded rule */
border-bottom: 0;
}
+70 -4
View File
@@ -119,9 +119,17 @@
color: #4a3420;
line-height: 1.55;
}
/* Footer row of a card — the visibility note (when there is one) and the
Quick Record pill share a line, and wrap onto two in a narrow column. */
.almanac-entry-foot {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
margin-top: 6px;
}
.almanac-entry-visibility {
display: inline-block;
margin-top: 5px;
font-family: Manrope, sans-serif;
font-size: 10px;
font-weight: 700;
@@ -133,6 +141,38 @@
border-radius: 3px;
padding: 2px 7px;
}
/* Quick Record — files this event under its peak date in the journal (see
quickRecord in app.js). Same brass pill as the event banner's and the
table popup's, sized down for the card. */
.almanac-entry-record {
display: inline-flex;
align-items: center;
gap: 5px;
flex-shrink: 0;
padding: 3px 8px;
border: 1px solid rgba(200, 146, 42, 0.45);
border-radius: 999px;
background: rgba(200, 146, 42, 0.13);
font-family: Manrope, sans-serif;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #8a6414;
line-height: 1;
cursor: pointer;
transition: background 0.2s, color 0.2s, border-color 0.2s;
}
.almanac-entry-record:hover {
background: rgba(200, 146, 42, 0.28);
border-color: rgba(200, 146, 42, 0.75);
color: #6b4a0c;
}
.almanac-entry-record:focus-visible {
outline: 2px solid #c8922a;
outline-offset: 1px;
}
.almanac-entry-accent {
width: 3px;
align-self: stretch;
@@ -683,7 +723,6 @@
border-color: #c8922a;
color: #c8922a;
background: #f5edd6;
font-weight: 600;
}
.cs-vent.on:hover {
background: #fef3dc;
@@ -1331,12 +1370,21 @@
}
/* Site-wide footer - matches about and faq pages */
/* Bookends .utci-topnav at the other end of the page, with the same deep-honey
bar, warm border and shadow — the shadow just points up instead of down.
Full-bleed the same way the header is: cancel .utci-app's side padding and
its 32px bottom (base.css) so the bar reaches the left, right and bottom
edges, then pad itself back so the text lines up with the content above. */
.utci-site-footer {
margin: 32px calc(var(--app-pad) * -1) -32px;
position: relative; /* keeps the upward shadow over the content it covers */
text-align: center;
padding: 10px 1.5rem;
padding: 18px var(--app-pad);
font-size: 0.82rem;
color: #b09870;
border-top: 1px solid #e0d0a8;
background: #ebdcb4;
border-top: 1px solid #c9b08a;
box-shadow: 0 -3px 8px rgba(30, 18, 8, 0.10);
font-family: Manrope, sans-serif;
font-weight: 600;
text-transform: none;
@@ -1690,6 +1738,24 @@
}
.insight-panel--week .insight-row--week-note .insight-value::before { content: none; }
/* Score rows open an explanatory popup on click/tap (the icon and score
used to lean on `title=` tooltips, which never show on touch). */
.insight-panel--week .insight-row--clickable {
cursor: pointer;
border-radius: 5px;
margin: 0 -6px;
padding: 4px 6px;
transition: background 0.12s;
}
.insight-panel--week .insight-row--clickable:hover,
.insight-panel--week .insight-row--clickable.is-open {
background: #f3ead2;
}
.insight-panel--week .insight-row--clickable:focus-visible {
outline: 2px solid #b07d1e;
outline-offset: 1px;
}
/* How good the day is out of 100 — the bar does the comparing, the number
settles close calls. */
.week-score {
Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

+186 -12
View File
@@ -47,6 +47,21 @@ import { exportDayXls } from './export.js';
const html = htm.bind(h);
// ── Quick Record hand-off ───────────────────────────────────────────────
// The event banner's "Record" button doesn't save anything itself — the
// journal lives behind the dashboard's own session (api/journal-save.php),
// which this page has no form for. It parks the entry it wants in
// localStorage and sends you to the dashboard, which picks it up on the
// first sign-in-complete load and opens its wizard on the review page,
// filled in. See "Quick Record hand-off" in dashboard.html for the other
// half, and keep the key and shape below in step with it.
const QUICK_RECORD_KEY = 'sunscope_journal_prefill';
// The journal can only compute a reading for the four profiles its own
// API accepts (see api/journal-save.php). Anything else — Farming, an
// Outdoors variant, Custom — hands over no profile at all, and the
// dashboard keeps whichever one it already had.
const QUICK_RECORD_PROFILES = new Set(['basic', 'home', 'vehicle', 'pets']);
// ── Scope time-lapse playback ───────────────────────────────────────────
// Drives the big scope through the next 24h so you can watch the sky/ground
// gradients and weather evolve. Speed: 15 simulated minutes every 1 real
@@ -246,6 +261,7 @@ export function UTCIForecast() {
handleEventTagClick, handleEventTagEnter, handleEventTagLeave,
handleEventTagPopupEnter, handleEventTagPopupLeave,
closePopup, closeEventTagPopup,
weekPopup, weekPopupRef, handleWeekRowClick, closeWeekPopup,
tableCanScrollLeft, tableCanScrollRight, handleBodyScroll,
hourlyRows, days, utcOffsetMs,
visible, tableRows, nowLocalISO, currentRow, currentCat,
@@ -397,6 +413,45 @@ export function UTCIForecast() {
};
}, [activeEvents.length]);
// ── Quick Record ─────────────────────────────────────────────────────
// Sends an event to the journal, along with the location, profile and
// solar model it was read under, so the reading the dashboard works out
// is the same one this page is showing. Three ways in: the banner, which
// files under today; an event tag in the table, which files under the day
// that row belongs to — the table can be showing any of the 14, and a
// meteor shower tagged on Thursday is a Thursday entry; and a "What's
// Coming" card, which files under that event's peak date. A peak years out
// is fine — the dashboard finds no forecast for it and saves the entry
// note-only, which is exactly what planning a night out under the Perseids
// wants.
//
// Today here is today in the *location's* local time, not the browser's —
// a stargazing note for a place eight hours away belongs on that place's
// date. See QUICK_RECORD_KEY at the top of this file.
const quickRecord = (ev, dateISO) => {
if (!ev) return;
const today = (nowLocalISO || new Date().toISOString()).slice(0, 10);
const blurb = ev.message || ev.desc || '';
const payload = {
savedAt: Date.now(),
eventId: ev.id,
title: ev.title,
date: /^\d{4}-\d{2}-\d{2}$/.test(dateISO || '') ? dateISO : today,
// Title and message both, as one line: the entries list renders a note
// as a single run of text, so a wrapped-in paragraph adds nothing.
// Banner and tag events call their copy `message`; the almanac entries
// behind "What's Coming" call theirs `desc` — either way it's the one
// sentence describing the event.
note: `${ev.emoji} ${ev.title}${blurb ? ` — ${blurb}` : ''}`,
location: { name: location.name, lat: location.lat, lon: location.lon },
profile: QUICK_RECORD_PROFILES.has(activeProfile) ? activeProfile : null,
solarEnv: utciEnv,
};
try { localStorage.setItem(QUICK_RECORD_KEY, JSON.stringify(payload)); } catch (e) { /* ignore, e.g. private mode */ }
// `location` is the forecast location in this scope, not the browser's.
window.location.href = './dashboard.html';
};
const [colTogglesOpen, setColTogglesOpen] = useState(false);
// 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
@@ -493,6 +548,14 @@ export function UTCIForecast() {
}
}, [activeProfile, outdoorsVariant]);
// Keep Quick view's Indoors selection in step with the table: follow the
// Managed toggle, and fall back to SunSoak if Indoors is switched off.
useEffect(() => {
if (simpleTemp !== 'indoorT' && simpleTemp !== 'managedT') return;
if (indoorMode !== 'on') setSimpleTemp('utciAdj');
else setSimpleTemp(indoorManaged ? 'managedT' : 'indoorT');
}, [indoorMode, indoorManaged, simpleTemp]);
// Focus the input the moment the search opens.
useEffect(() => {
if (searchOpen) searchInputRef.current?.focus();
@@ -583,7 +646,7 @@ export function UTCIForecast() {
// columns are skipped entirely. Hour always gets a blank lead cell.
const groupLabelRow = () => {
const groups = Object.keys(GROUP_ORDER);
const cells = [html`<th class="grp-label-hour" scope="col"><span class="grp-label-date">${glanceDate}</span></th>`];
const cells = [html`<th class="grp-label-hour" scope="col"><span class="grp-label-date">${glanceDateShort}</span></th>`];
for (const g of groups) {
const span = GROUP_ORDER[g].filter(isColVisible).length;
if (span === 0) continue;
@@ -956,6 +1019,11 @@ export function UTCIForecast() {
onClick=${() => noteGoTo(i)}
/>`)}
</div>`}
${/* Close sits above Quick Record so the two stack in the strip's
top-right corner rather than running side by side. Quick Record
acts on whatever slide is showing — it sits outside the stack
so the strip keeps one button rather than one per event. */''}
<div class="event-note-actions">
<button
type="button"
class="event-note-close"
@@ -969,6 +1037,21 @@ export function UTCIForecast() {
fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
</svg>
</button>
<button
type="button"
class="event-note-record"
aria-label=${`Record “${ev.title}” in your journal`}
title="Add this to your weather journal"
onClick=${() => quickRecord(ev)}
>
<svg viewBox="0 0 24 24" width="11" height="11" aria-hidden="true">
<rect x="4" y="3" width="16" height="18" rx="2" fill="none" stroke="currentColor" stroke-width="2.4" />
<line x1="12" y1="8" x2="12" y2="16" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" />
<line x1="8" y1="12" x2="16" y2="12" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" />
</svg>
<span class="event-note-record-label">Quick Record</span>
</button>
</div>
</div>
</div>`;
})()}
@@ -1167,6 +1250,7 @@ export function UTCIForecast() {
wind=${scopeRow?.va ?? 0}
gust=${scopeRow?.gust ?? 0}
wd=${scopeRow?.wd ?? null}
rh=${scopeRow?.RH ?? null}
/>
${hourlyRows.length > 0 && html`
<div class="scope-daycycle-wrap">
@@ -1381,7 +1465,9 @@ export function UTCIForecast() {
const showSolar = visibleCols.utciP;
const showShade = visibleCols.shadeT;
const showVehicle = visibleCols.vehicleT;
const showIndoor = visibleCols.indoorT || visibleCols.managedT;
// Same rule as the table's Indoors/Managed columns (indoorMode),
// so the tab and the column always appear together.
const showIndoor = indoorMode === 'on';
if (!showFur && !showSolar && !showShade && !showVehicle && !showIndoor) return null;
const furOn = simpleTemp === 'furSurfaceT';
const solarOn = simpleTemp === 'utciAdj';
@@ -1506,7 +1592,8 @@ export function UTCIForecast() {
return null;
})();
const windMph = Math.round((r.gust ?? r.va) * 2.237);
// Tag shade = the exact gradient stop the curve is painted
// Same event emojis the table's hour cell shows, for this card's hours.
const cardEvents = getCellTagEvents(selectedDayEvents, r); // Tag shade = the exact gradient stop the curve is painted
// with at this card's landing hour (fsc-grad, whiteMix 0.42),
// so the connector line lands on the colour the tag is
// wearing. Band label and weight still come from the band.
@@ -1532,9 +1619,20 @@ export function UTCIForecast() {
}}>
<div class="fsc-time">${localHHMMs}</div>
<div class="fsc-scope-wrap">
<${SkyScope} elev=${r.elev} dt=${r.dt} glob=${r.glob} size=${42} />
<${SkyScope} elev=${r.elev} dt=${r.dt} glob=${r.glob} size=${42}
cc=${r.cc} ccLow=${r.ccLow} ccMid=${r.ccMid} ccHigh=${r.ccHigh} visKm=${r.visKm} rh=${r.RH} />
${domIcon && html`<div class="fsc-wx-overlay">${domIcon}</div>`}
</div>
<div class="fsc-ev-row">
${cardEvents.map((ev, idx) => html`
<span key=${ev.id} class="event-cell-tag"
onClick=${(e) => handleEventTagClick(cardEvents, idx, r.iso.slice(0, 10), e)}
onMouseEnter=${(e) => handleEventTagEnter(cardEvents, idx, r.iso.slice(0, 10), e)}
onMouseLeave=${handleEventTagLeave}
role="button" tabIndex="0" aria-label=${ev.title}
>${ev.emoji}</span>
`)}
</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${r.compass ? html` <span class="fsc-meta-dir">${r.compass.label}</span>` : ''}</span>
@@ -1656,14 +1754,15 @@ export function UTCIForecast() {
const rowEvents = getCellTagEvents(selectedDayEvents, r);
return html`
<span class="utci-time-inner">
<${SkyScope} elev=${r.elev} dt=${r.dt} glob=${r.glob} size=${38} />
<${SkyScope} elev=${r.elev} dt=${r.dt} glob=${r.glob} size=${38}
cc=${r.cc} ccLow=${r.ccLow} ccMid=${r.ccMid} ccHigh=${r.ccHigh} visKm=${r.visKm} rh=${r.RH} />
<span>${localHHMM}</span>
${rowEvents.length > 0 && html`
<span class="utci-time-tags">
${rowEvents.map((ev, idx) => html`
<span key=${ev.id} class="event-cell-tag"
onClick=${(e) => handleEventTagClick(rowEvents, idx, e)}
onMouseEnter=${(e) => handleEventTagEnter(rowEvents, idx, e)}
onClick=${(e) => handleEventTagClick(rowEvents, idx, r.iso.slice(0, 10), e)}
onMouseEnter=${(e) => handleEventTagEnter(rowEvents, idx, r.iso.slice(0, 10), e)}
onMouseLeave=${handleEventTagLeave}
role="button" tabIndex="0" aria-label=${ev.title}
>${ev.emoji}</span>
@@ -1874,19 +1973,39 @@ 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, 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>
${weekAhead.map(({ icon, iconTitle, label, value, score }) => {
const clickable = score != null;
const popupPayload = clickable ? {
key: label,
title: titleCaseText(label),
sub: titleCaseText(value),
rows: [
...(iconTitle ? [{ icon, text: iconTitle }] : []),
{ icon: '📊', text: bestDaysHint(activeProfile, outdoorsVariant, score) },
],
} : null;
return html`
<div
class=${`insight-row${score == null ? ' insight-row--week-note' : ''}${clickable ? ' insight-row--clickable' : ''}${weekPopup?.key === label ? ' is-open' : ''}`}
key=${label}
role=${clickable ? 'button' : null}
tabIndex=${clickable ? 0 : null}
aria-expanded=${clickable ? (weekPopup?.key === label ? 'true' : 'false') : null}
onClick=${clickable ? (e) => handleWeekRowClick(popupPayload, e) : null}
onKeyDown=${clickable ? (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleWeekRowClick(popupPayload, e); } } : null}
>
<span class="insight-icon">${icon}</span>
<span class="insight-label">${titleCaseText(label)}</span>
<span class="insight-value">${titleCaseText(value)}</span>
${score != null && html`
<span class="week-score" title=${bestDaysHint(activeProfile, outdoorsVariant, score)}>
<span class="week-score">
<span class="week-score__track">
<span class="week-score__fill" style=${`width:${score}%;background:${scoreFillColor(score)}`}></span>
</span>
<span class="week-score__pct">${score}%</span>
</span>`}
</div>`)}
</div>`;
})}
</div>`}
${visible.length > 0 && html`
<div class="export-panel">
@@ -1954,6 +2073,23 @@ export function UTCIForecast() {
<strong class="col-info-title">${ev.emoji} ${ev.title}</strong>
<p class="col-info-desc">${ev.message}</p>
</div>
${/* Files under the day the tag sits on, not today — see
quickRecord. Outside the stage, so the crossfade
between events doesn't take the button with it. */''}
<button
type="button"
class="ev-popup-record"
aria-label=${`Record “${ev.title}” in your journal`}
title="Add this to your weather journal"
onClick=${() => quickRecord(ev, eventTagPopup.date)}
>
<svg viewBox="0 0 24 24" width="11" height="11" aria-hidden="true">
<rect x="4" y="3" width="16" height="18" rx="2" fill="none" stroke="currentColor" stroke-width="2.4" />
<line x1="12" y1="8" x2="12" y2="16" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" />
<line x1="8" y1="12" x2="16" y2="12" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" />
</svg>
Quick Record
</button>
${evs.length > 1 && html`
<div class="ev-popup-dots">
${evs.map((_, i) => html`
@@ -1968,6 +2104,27 @@ export function UTCIForecast() {
</div>`;
})()}
${weekPopup && html`
<div
ref=${weekPopupRef}
class=${`col-info-popup col-info-popup--week${weekPopup.below ? ' col-info-popup--below' : ''}`}
style=${{
position: 'fixed',
left: `${weekPopup.x}px`,
top: `${weekPopup.y}px`,
transform: weekPopup.below ? 'translateX(-50%)' : 'translateX(-50%) translateY(-100%)',
'--arrow-left': `${weekPopup.arrowLeft}px`,
}}
>
<button class="col-info-close" onClick=${closeWeekPopup} aria-label="Close">×</button>
<strong class="col-info-title">${weekPopup.title}</strong>
${weekPopup.sub && html`<p class="col-info-desc col-info-desc--lead">${weekPopup.sub}</p>`}
<ul class="week-popup-list">
${weekPopup.rows.map((r, i) => html`
<li key=${i}><span class="week-popup-icon" aria-hidden="true">${r.icon}</span>${r.text}</li>`)}
</ul>
</div>`}
<div class="utci-legend">
<span class="utci-legend-label">Thermal stress bands</span>
<div class="utci-legend-row">
@@ -2065,9 +2222,26 @@ export function UTCIForecast() {
<span class="almanac-entry-countdown">${countdownLabel(ev.daysUntil)}</span>
</div>
<div class="almanac-entry-desc">${ev.desc}</div>
${/* Files under the event's peak, not today — see quickRecord. */''}
<div class="almanac-entry-foot">
${ev.visibilityNote && html`
<span class="almanac-entry-visibility">📍 ${ev.visibilityNote}</span>
`}
<button
type="button"
class="almanac-entry-record"
aria-label=${`Record “${ev.title}” in your journal`}
title="Add this to your weather journal"
onClick=${() => quickRecord(ev, ev.peak)}
>
<svg viewBox="0 0 24 24" width="10" height="10" aria-hidden="true">
<rect x="4" y="3" width="16" height="18" rx="2" fill="none" stroke="currentColor" stroke-width="2.4" />
<line x1="12" y1="8" x2="12" y2="16" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" />
<line x1="8" y1="12" x2="16" y2="12" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" />
</svg>
Quick Record
</button>
</div>
</div>
</div>
`)
+116 -40
View File
@@ -21,7 +21,7 @@ import { useState, useEffect, useLayoutEffect, useRef } from '../vendor/preact-h
import htm from '../vendor/htm.js';
import {
skyGradientForElev, skyFillForElev, grassFillForElev,
moonPhaseFraction,
moonPhaseFraction, sunLightState,
} from './utils.js';
import { getLensOverlaySVG } from './events.js';
@@ -38,6 +38,17 @@ function blendHex(a, b, t) {
const A = _hexRgb(a), B = _hexRgb(b);
return '#' + A.map((v, i) => Math.round(v + (B[i] - v) * t).toString(16).padStart(2, '0')).join('');
}
// Sun centre Y for a scope. The disk sits fully above the horizon at
// +0.27 deg, is half-set at -0.28 deg and fully gone at -0.83 deg (standard
// sunset incl. refraction), then keeps sinking so its clipped halo still
// lights the sky above the horizon through twilight.
// horizonY - y of the horizon line, sunR - disk radius,
// span - extra travel from horizon-resting to the zenith
function sunCentreY(elev, horizonY, sunR, span) {
const rim = Math.max(-1, Math.min(1, (elev + 0.28) / 0.55));
const arc = Math.sin(Math.max(-12, Math.min(90, elev)) * Math.PI / 180);
return horizonY - sunR * rim - span * arc;
}
// Tiny seeded PRNG (mulberry32). Deterministic so the scattered ground
// objects keep the same positions on every re-render (no jitter).
function seededRng(seed) {
@@ -265,8 +276,7 @@ export function VentPill({ checked, onChange, label, title, grpClass }) {
// value picks the clock-aligned bucket size used by aggregateRows()
// (1h = every hour, 4h = quarter-day blocks).
//
// "-" steps toward 1h (finer, more rows), "+" toward 4h (coarser).
// Direction follows the readout, so + always raises the number shown.
// Zoom-style: "+" zooms in toward 1h (finer, more rows), "-" zooms out toward 4h (coarser).
//
// props:
// value - 1..4
@@ -283,14 +293,14 @@ export function RowStepper({ value, onChange, showLabel = true, label = 'Rows',
return html`
<span class="row-stepper" role="group" aria-label="Row interval">
${showLabel && html`<span class="row-stepper-label">${label}</span>`}
<button type="button" class="row-stepper-btn" disabled=${v <= min}
title="Finer rows" aria-label="Finer rows - fewer hours per row"
onClick=${() => step(v - 1)}>−</button>
<span class="row-stepper-value" aria-live="polite"
title=${v === 1 ? 'Every hour' : `Every ${v} hours`}>${v}h</span>
<button type="button" class="row-stepper-btn" disabled=${v >= max}
title="Coarser rows" aria-label="Coarser rows - more hours per row"
onClick=${() => step(v + 1)}>+</button>
onClick=${() => step(v + 1)}>−</button>
<span class="row-stepper-value" aria-live="polite"
title=${v === 1 ? 'Every hour' : `Every ${v} hours`}>${v}h</span>
<button type="button" class="row-stepper-btn" disabled=${v <= min}
title="Finer rows" aria-label="Finer rows - fewer hours per row"
onClick=${() => step(v - 1)}>+</button>
</span>`;
}
@@ -311,7 +321,8 @@ export function RowStepper({ value, onChange, showLabel = true, label = 'Rows',
// - sunR - the sun's drawn radius
// - the stroke colour #c8922a is the brass - change for a different metal
// -------------------------------------------------------------------
export function SkyScope({ elev, dt, glob = 0, size = 26 }) {
export function SkyScope({ elev, dt, glob = 0, size = 26,
cc = null, ccLow = null, ccMid = null, ccHigh = null, visKm = null, rh = null }) {
const r = size / 2;
const innerR = r - 1.2;
@@ -319,23 +330,28 @@ export function SkyScope({ elev, dt, glob = 0, size = 26 }) {
const localHour = dt ? dt.getUTCHours() : 12;
const isRising = localHour < 12;
const skyGrad = skyGradientForElev(elev, isRising);
// Sun colour / glow / afterglow from elevation + atmosphere (see utils.js)
const light = sunLightState({ elev, isRising, cc, ccLow, ccMid, ccHigh, visKm, rh });
const skyGradBase = skyGradientForElev(elev, isRising);
// Horizon end of the sky picks up the glow colour so sky and sun agree
const skyGrad = { top: skyGradBase.top, bot: blendHex(skyGradBase.bot, light.glow, light.skyGlow * 0.35) };
const isDay = elev > 0; // sun only drawn when actually above horizon
const isDay = elev > 0;
const isTwil = elev > -8 && !isDay; // moon shown in twilight too
const isNight = elev <= -8;
const showSun = elev > -6; // halo still lights the sky just after sunset
// Sun position - clamped so it never goes below the horizon line (y = r)
const elevClamped = Math.max(0, Math.min(90, elev));
const sunY = r - Math.sin((elevClamped * Math.PI) / 180) * (innerR - 2.5);
const sunR = Math.max(2.2, innerR * 0.32);
// Sun position - sinks behind the horizon (y = r) instead of overlapping the land
const sunR = Math.max(1.8, innerR * 0.24); // smaller disk; glow carries the strength
const sunY = sunCentreY(elev, r, sunR, innerR - 2.5 - sunR);
// Radial glow: strength driven by glob (W/m-). Uses a radialGradient
// so it's intense at the sun centre and fades smoothly to transparent.
const radFrac = Math.min(1, (glob || 0) / 900); // 0 (dark) - 1 (blazing)
const horizFrac = Math.max(0, 1 - elev / 20); // extra warmth near horizon
const glowR = sunR + 4 + radFrac * 6; // total glow radius
const glowPeak = 0.35 + radFrac * 0.45 + horizFrac * 0.15; // centre opacity
const glowR = (sunR + 5 + radFrac * 10) * (1 + light.haze * 0.3); // total glow radius (haze spreads it)
const glowPeak = Math.min(1, 0.35 + radFrac * 0.45 + horizFrac * 0.15); // centre opacity
const haloFade = Math.max(0, Math.min(1, (elev + 6) / 6.3)); // halo dims as the sun sinks below
const phase = moonPhaseFraction(dt);
const moonR = innerR * 0.55;
@@ -343,8 +359,15 @@ export function SkyScope({ elev, dt, glob = 0, size = 26 }) {
const shadowCx = r + (phase < 0.5 ? -shadowOffset : shadowOffset);
const grass = grassFillForElev(elev);
const uid = `ss-${size}-${Math.round(elev * 10)}-${Math.round(phase * 1000)}-${isRising ? 'r' : 's'}`;
// uid must include every input that changes a gradient, or cells sharing an
// elevation would reuse each other's sun colours
const uid = `ss-${size}-${Math.round(elev * 10)}-${Math.round(phase * 1000)}-${isRising ? 'r' : 's'}` +
`-${light.glow.slice(1)}-${light.core.slice(1)}-${Math.round(light.skyGlow * 50)}` +
`-${Math.round(light.groundGlow * 50)}-${Math.round(light.diskAlpha * 20)}-${Math.round(radFrac * 50)}` +
`-${Math.round(light.haze * 20)}`;
const clipId = `clip-${uid}`;
const skyHalfId = `skyhalf-${uid}`;
const groundHalfId = `groundhalf-${uid}`;
const skyId = `sky-${uid}`;
const grassId = `grass-${uid}`;
@@ -355,6 +378,19 @@ export function SkyScope({ elev, dt, glob = 0, size = 26 }) {
<clipPath id=${clipId}>
<circle cx=${r} cy=${r} r=${innerR} />
</clipPath>
<!-- Sky / ground halves — the sun and its glow are cut at the horizon -->
<clipPath id=${skyHalfId}><rect x="0" y="0" width=${size} height=${r} /></clipPath>
<clipPath id=${groundHalfId}><rect x="0" y=${r} width=${size} height=${r} /></clipPath>
<!-- Horizon afterglow (sky) and warm wash (ground), stretched by the ellipse -->
<radialGradient id=${`skyglow-${uid}`}>
<stop offset="0%" stop-color=${light.glow} stop-opacity=${light.skyGlow} />
<stop offset="55%" stop-color=${light.glow} stop-opacity=${light.skyGlow * 0.4} />
<stop offset="100%" stop-color=${light.glow} stop-opacity="0" />
</radialGradient>
<radialGradient id=${`groundglow-${uid}`}>
<stop offset="0%" stop-color=${light.glow} stop-opacity=${light.groundGlow} />
<stop offset="100%" stop-color=${light.glow} stop-opacity="0" />
</radialGradient>
<!-- Sky gradient — top of disk to bottom (zenith → horizon) -->
<linearGradient id=${skyId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color=${skyGrad.top} />
@@ -368,10 +404,10 @@ export function SkyScope({ elev, dt, glob = 0, size = 26 }) {
<!-- Sun glow — same colour as sun disk, fades from opaque at centre to transparent -->
<radialGradient id=${`glow-${uid}`} gradientUnits="userSpaceOnUse"
cx=${r} cy=${sunY} r=${glowR}>
<stop offset="0%" stop-color="#fff8c0" stop-opacity="1" />
<stop offset=${`${Math.round(sunR / glowR * 100)}%`} stop-color="#fff8c0" stop-opacity="1" />
<stop offset="70%" stop-color="#ffe060" stop-opacity=${glowPeak * 0.5} />
<stop offset="100%" stop-color="#ffcc30" stop-opacity="0" />
<stop offset="0%" stop-color=${light.core} stop-opacity=${light.diskAlpha} />
<stop offset=${`${Math.round(sunR / glowR * 100)}%`} stop-color=${light.core} stop-opacity=${light.diskAlpha} />
<stop offset="70%" stop-color=${light.sun} stop-opacity=${glowPeak * 0.5} />
<stop offset="100%" stop-color=${light.glow} stop-opacity="0" />
</radialGradient>
<!-- Moon phase mask: lit area = white, shadow = black punched out -->
<mask id=${`moon-mask-${uid}`}>
@@ -388,15 +424,30 @@ export function SkyScope({ elev, dt, glob = 0, size = 26 }) {
<!-- Sky disk -->
<circle cx=${r} cy=${r} r=${innerR} fill=${`url(#${skyId})`} />
<!-- Horizon afterglow in the sky -->
${light.skyGlow > 0.01 && html`
<g clip-path=${`url(#${clipId})`}>
<ellipse cx=${r} cy=${r} rx=${innerR} ry=${innerR * (0.55 + 0.25 * light.haze)}
fill=${`url(#skyglow-${uid})`} clip-path=${`url(#${skyHalfId})`} />
</g>`}
<!-- Ground half (lower semicircle) -->
<path d=${`M ${r - innerR} ${r} A ${innerR} ${innerR} 0 0 0 ${r + innerR} ${r} Z`}
fill=${`url(#${grassId})`}
clip-path=${`url(#${clipId})`} />
${isDay && html`
<!-- Warm low-sun wash on the ground -->
${light.groundGlow > 0.01 && html`
<g clip-path=${`url(#${clipId})`}>
<!-- Glow + sun disk as one seamless radial gradient circle -->
<circle cx=${r} cy=${sunY} r=${glowR} fill=${`url(#glow-${uid})`} />
<ellipse cx=${r} cy=${r} rx=${innerR * 0.9} ry=${innerR * 0.35}
fill=${`url(#groundglow-${uid})`} clip-path=${`url(#${groundHalfId})`} />
</g>`}
${showSun && html`
<g clip-path=${`url(#${clipId})`}>
<!-- Glow + sun disk as one seamless radial gradient circle, cut at the horizon -->
<circle cx=${r} cy=${sunY} r=${glowR} fill=${`url(#glow-${uid})`}
opacity=${haloFade} clip-path=${`url(#${skyHalfId})`} />
</g>`}
${(isTwil || isNight) && html`
@@ -593,7 +644,7 @@ export function CloudIcon({ category, size = 30, elev = 90, dt = new Date(), fil
// -------------------------------------------------------------------
export function ScopeReticle({ value, cat, loading, elev = 0, dt = new Date(), glob = 0, activeEvent = null, utciEnvShort = null,
cc = null, ccLow = null, ccMid = null, ccHigh = null, precip = 0, snow = 0, visKm = null,
env = 'open', wind = 0, gust = 0, wd = null }) {
env = 'open', wind = 0, gust = 0, wd = null, rh = null }) {
// -- Day/night scene behind the readout -----------------------------
// The whole back of the lens is a SkyScope-style scene: sky on top,
// grass on the bottom, sun positioned by elevation (or moon at night
@@ -630,8 +681,11 @@ export function ScopeReticle({ value, cat, loading, elev = 0, dt = new Date(), g
let tintAmt = cloudF * 0.72;
if (wet) tintAmt = Math.min(0.88, tintAmt + 0.14);
if (heavy) tintAmt = Math.min(0.93, tintAmt + 0.12);
// Sun colour / glow / afterglow from elevation + atmosphere (see utils.js).
// The horizon end of the sky picks up the glow colour before weather grey.
const light = sunLightState({ elev, isRising, cc, ccLow, ccMid, ccHigh, visKm, rh });
const skyTopTint = blendHex(skyGrad.top, isDay ? greyTarget : blendHex(greyTarget, '#0e1018', 0.4), tintAmt * 0.8);
const skyBotTint = blendHex(skyGrad.bot, greyTarget, tintAmt);
const skyBotTint = blendHex(blendHex(skyGrad.bot, light.glow, light.skyGlow * 0.35), greyTarget, tintAmt);
// Drifting cloud sprites, one strip per coverage layer. Each strip holds a
// few sprites duplicated +200px so the CSS drift wraps seamlessly.
@@ -910,16 +964,18 @@ export function ScopeReticle({ value, cat, loading, elev = 0, dt = new Date(), g
const phase = moonPhaseFraction(dt);
const lensR = 95;
const elevClamped = Math.max(0, Math.min(90, elev)); // clamp to above horizon
const sunY = cy - Math.sin((elevClamped * Math.PI) / 180) * (lensR - 18);
const sunDrawR = 14;
const sunDrawR = 11; // smaller disk; glow carries the strength
// Sun sinks behind the horizon (y = cy) instead of overlapping the land
const sunY = sunCentreY(elev, cy, sunDrawR, lensR - 18 - sunDrawR);
const showSun = elev > -6; // halo still lights the sky just after sunset
// Radial glow for the big dial - sun is always at least faintly glowing
// when above the horizon; scales up with radiation strength.
const radFrac = Math.min(1, (glob || 0) / 900);
const horizFrac = Math.max(0, 1 - elev / 20);
const sunGlowCol = isRising ? '#ffb040' : '#ffc060';
const sunGlowR = sunDrawR + 20 + radFrac * 30; // total glow radius
const sunGlowPeak = 0.30 + radFrac * 0.45 + horizFrac * 0.15; // centre opacity
const sunGlowR = (sunDrawR + 26 + radFrac * 44) * (1 + light.haze * 0.3); // total glow radius (haze spreads it)
const sunGlowPeak = Math.min(1, 0.30 + radFrac * 0.45 + horizFrac * 0.15); // centre opacity
const haloFade = Math.max(0, Math.min(1, (elev + 6) / 6.3)); // halo dims as the sun sinks below
const moonDrawR = 15;
const moonY = cy - 45;
const shadowOffset = (1 - Math.abs(2 * phase - 1)) * 2 * moonDrawR;
@@ -1129,10 +1185,23 @@ export function ScopeReticle({ value, cat, loading, elev = 0, dt = new Date(), g
<!-- Sun glow — same colour as sun disk, fades from opaque at centre to transparent -->
<radialGradient id="scope-sun-glow" gradientUnits="userSpaceOnUse"
cx=${cx} cy=${sunY} r=${sunGlowR}>
<stop offset="0%" stop-color="#fff8c0" stop-opacity="1" />
<stop offset=${`${Math.round(sunDrawR / sunGlowR * 100)}%`} stop-color="#fff8c0" stop-opacity="1" />
<stop offset="70%" stop-color="#ffe060" stop-opacity=${sunGlowPeak * 0.5} />
<stop offset="100%" stop-color="#ffcc30" stop-opacity="0" />
<stop offset="0%" stop-color=${light.core} stop-opacity=${light.diskAlpha} />
<stop offset=${`${Math.round(sunDrawR / sunGlowR * 100)}%`} stop-color=${light.core} stop-opacity=${light.diskAlpha} />
<stop offset="70%" stop-color=${light.sun} stop-opacity=${sunGlowPeak * 0.5} />
<stop offset="100%" stop-color=${light.glow} stop-opacity="0" />
</radialGradient>
<!-- Sky / ground halves — the sun and its glow are cut at the horizon -->
<clipPath id="scope-sky-half"><rect x="0" y="0" width="200" height=${cy} /></clipPath>
<clipPath id="scope-ground-half"><rect x="0" y=${cy} width="200" height=${200 - cy} /></clipPath>
<!-- Horizon afterglow (sky) and warm wash (ground), stretched by the ellipse -->
<radialGradient id="scope-sky-afterglow">
<stop offset="0%" stop-color=${light.glow} stop-opacity=${light.skyGlow} />
<stop offset="55%" stop-color=${light.glow} stop-opacity=${light.skyGlow * 0.4} />
<stop offset="100%" stop-color=${light.glow} stop-opacity="0" />
</radialGradient>
<radialGradient id="scope-ground-glow">
<stop offset="0%" stop-color=${light.glow} stop-opacity=${light.groundGlow} />
<stop offset="100%" stop-color=${light.glow} stop-opacity="0" />
</radialGradient>
<!-- Moon phase mask: white = lit area, black = shadow punched out -->
<mask id="scope-moon-mask">
@@ -1150,11 +1219,18 @@ export function ScopeReticle({ value, cat, loading, elev = 0, dt = new Date(), g
<!-- Full sky + grass + sun/moon scene behind the readout -->
<g clip-path="url(#scope-lens-clip)">
<circle cx=${cx} cy=${cy} r=${lensR} fill="url(#scope-lens-sky)" />
<!-- Horizon afterglow in the sky -->
<ellipse cx=${cx} cy=${cy} rx=${lensR} ry=${lensR * (0.55 + 0.25 * light.haze)}
fill="url(#scope-sky-afterglow)" clip-path="url(#scope-sky-half)" />
<path d=${`M ${cx - lensR} ${cy} A ${lensR} ${lensR} 0 0 0 ${cx + lensR} ${cy} Z`}
fill="url(#scope-lens-grass)" />
${isDay && html`<${Fragment}>
<!-- Glow + sun disk as one seamless radial gradient circle -->
<circle cx=${cx} cy=${sunY} r=${sunGlowR} fill="url(#scope-sun-glow)" />
<!-- Warm low-sun wash on the ground -->
<ellipse cx=${cx} cy=${cy} rx=${lensR * 0.9} ry=${lensR * 0.35}
fill="url(#scope-ground-glow)" clip-path="url(#scope-ground-half)" />
${showSun && html`<${Fragment}>
<!-- Glow + sun disk as one seamless radial gradient circle, cut at the horizon -->
<circle cx=${cx} cy=${sunY} r=${sunGlowR} fill="url(#scope-sun-glow)"
opacity=${haloFade} clip-path="url(#scope-sky-half)" />
</>`}
${(isTwil || (!isDay && !isTwil)) && html`<${Fragment}>
<!-- Lit crescent — full moon disk clipped by the phase mask -->
-6
View File
@@ -323,8 +323,6 @@ export function ConfigPanel({
setActiveProfile('outdoors');
setOutdoorsVariantAndSave(opt.value);
setVisibleCols({ ...OUTDOORS_VARIANTS[opt.value].cols });
setIndoorMode('off');
setIndoorManaged(false);
}}
>
<span class="profile-btn-scene" style=${{ background: opt.scene, filter: opt.disabled ? 'grayscale(100%)' : 'none' }}></span>
@@ -344,8 +342,6 @@ export function ConfigPanel({
setActiveProfile('outdoors');
setOutdoorsVariantAndSave(opt.value);
setVisibleCols({ ...OUTDOORS_VARIANTS[opt.value].cols });
setIndoorMode('off');
setIndoorManaged(false);
}}
>
<span class="profile-btn-scene" style=${{ background: opt.scene, filter: opt.disabled ? 'grayscale(100%)' : 'none' }}></span>
@@ -366,8 +362,6 @@ export function ConfigPanel({
setActiveProfile('outdoors');
setOutdoorsVariantAndSave(opt.value);
setVisibleCols({ ...OUTDOORS_VARIANTS[opt.value].cols });
if (opt.value === 'office') { setBuildingType('office'); setIndoorMode('on'); setIndoorManaged(false); }
else { setIndoorMode('off'); setIndoorManaged(false); }
}}
>
<span class="profile-btn-scene" style=${{ background: opt.scene, filter: opt.disabled ? 'grayscale(100%)' : 'none' }}></span>
+42 -9
View File
@@ -176,7 +176,28 @@ function cloudDim(rgb, w) {
// pale out the same way.
const lum = 0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2];
const neutral = Math.min(255, lum + 14);
return mixRgb(rgb, [neutral, neutral, neutral], w);
// Channels BELOW the neutral are brought up faster than channels above it
// are brought down. This is the actual cure for the khaki, and the earlier
// fixes missed it: mixing all three channels at the same rate leaves gold's
// blue channel lagging far behind its red and green, and "red and green high,
// blue low" is the definition of khaki — so every partially clouded gold tab
// had to pass through it no matter how the darkening was tuned.
// Filling the deficient channel first routes gold -> cream -> grey instead,
// which never has all three channels spread apart. It also happens to be
// right at the cold end: for a pale blue the deficient channel is red, so
// blue is the one held back, and a clouded cold day pales toward grey while
// staying recognisably blue.
const fast = Math.sqrt(w);
const dulled = rgb.map(c => c + (neutral - c) * (c < neutral ? fast : w));
// Overcast is grey AND darker, but darkening can only be spent once the
// colour is actually neutral: darkening a HALF-desaturated yellow is exactly
// how olive is made, while darkening a true grey can only ever give a darker
// grey. So it is cubed against the same w that drives the desaturation —
// near-nothing through the middle where hue is still present (0.06 at w=0.5),
// arriving in force only once the colour has already gone neutral (0.31 at
// full cloud). That is what lets 100% cloud land on a genuinely darkish grey
// without putting khaki back on the warm bands.
return dulled.map(c => Math.round(c * (1 - 0.50 * w * w * w)));
}
// Weather weights are pushed through this before they are used.
@@ -258,16 +279,21 @@ export function DayTabs({
// The row pins to the top of the viewport on wide screens, and the table's
// sticky hour header has to stop just below it. Publish the measured height
// (plus its 4px margin) as --profile-row-h rather than hard-coding one in
// table.css: the row is one line or two depending on how long the profile
// and thermal-basis labels are, so a fixed offset leaves the header tucked
// under it.
// as --profile-row-h rather than hard-coding one in table.css: the row is
// one line or two depending on how long the profile and thermal-basis
// labels are, so a fixed offset leaves the header tucked under it.
// Nothing is added on top of that height. The row's 4px bottom margin is
// transparent and hangs below its border edge, so counting it only pushed
// the header 4px past the bottom of the band — a strip of the scrolling
// page showing through between the profile row and the column titles.
// Floored rather than rounded so a fractional height has the header
// overlap by a hair (the row outranks it on z-index) instead of gapping.
const profileRowRef = useRef(null);
useEffect(() => {
const el = profileRowRef.current;
if (!el) return;
const publish = () => {
const h = Math.round(el.getBoundingClientRect().height) + 4;
const h = Math.floor(el.getBoundingClientRect().height);
document.documentElement.style.setProperty('--profile-row-h', `${h}px`);
};
publish();
@@ -484,7 +510,9 @@ export function DayTabs({
// daylight repRows) since fog and frost often hit overnight or on
// an early commute, then picks the worst hazard by priority.
let vehicleHazard = null;
let hazPrecip = 0, hazSnow = 0;
let hazPrecip = 0, hazSnow = 0, hazDrops = 1;
const hazProbs = d.rows.map(r => r.precipProb).filter(v => isFinite(v));
const hazMaxProb = hazProbs.length ? Math.max(...hazProbs) : 0;
if (isVehicleProfile) {
const allSnow = d.rows.reduce((s, r) => s + (r.snow || 0), 0);
const allPrecip = d.rows.reduce((s, r) => s + (r.precip || 0), 0);
@@ -498,8 +526,13 @@ export function DayTabs({
vehicleHazard = 'ice';
} else if (minVis !== null && minVis < 2) {
vehicleHazard = 'fog';
} else if (allPrecip >= 0.3) {
} else if (allPrecip >= 0.3 && hazMaxProb >= 50) {
// Same probability gate as the main rain icon: a 20% chance
// day must not show rain just because the modelled total is >0.3mm.
vehicleHazard = 'rain'; hazPrecip = allPrecip;
// Medium (2 drops) needs >1mm AND >=50% chance; otherwise light.
// PrecipIcon would otherwise pick drops from the amount alone.
hazDrops = allPrecip > 1 ? 2 : 1;
}
}
@@ -902,7 +935,7 @@ export function DayTabs({
${tempOnly
? (isVehicleProfile
? (vehicleHazard === 'snow' ? html`<${PrecipIcon} precip=${0} snow=${hazSnow} size=${30} />`
: vehicleHazard === 'rain' ? html`<${PrecipIcon} precip=${hazPrecip} snow=${0} size=${30} />`
: vehicleHazard === 'rain' ? html`<${PrecipIcon} precip=${hazPrecip} snow=${0} drops=${hazDrops} size=${30} />`
: vehicleHazard === 'fog' ? html`<${FogIcon} size=${30} />`
: vehicleHazard === 'ice' ? html`<${IceIcon} size=${30} />`
: html`<${CarIcon} size=${30} />`)
+4 -2
View File
@@ -207,8 +207,10 @@ export function buildHourlyRows({ forecast, airQuality, location, vehicleType, v
);
hourlyRows.forEach((r, i) => { r.concreteT = concreteTemps[i]; r.pawT = concreteTemps[i]; });
const indoorTemps = calcIndoorTempPass(TaArr, globArr, elevArr, buildingType);
const managedTemps = calcManagedIndoorTempPass(TaArr, globArr, elevArr, buildingType);
// First hour's date sets the seasonal ground temperature under the floor.
const startIso = hourlyRows[0]?.iso;
const indoorTemps = calcIndoorTempPass(TaArr, globArr, elevArr, buildingType, startIso);
const managedTemps = calcManagedIndoorTempPass(TaArr, globArr, elevArr, buildingType, startIso);
hourlyRows.forEach((r, i) => { r.indoorT = indoorTemps[i]; r.managedT = managedTemps[i]; });
// Vehicle cabin temperature with thermal lag. A parked vehicle climbs
+5 -3
View File
@@ -106,6 +106,7 @@ export const OUTDOORS_VARIANTS = {
// Places - free
park: { name: 'Park / Picnic', scene: "url('assets/images/profiles/park.png') center / cover no-repeat, linear-gradient(160deg,#c2d4cc,#e2ece6)", cols: { hour: true, air: true, shadeT: true, rh: false, dew: false, wind: true, dir: false, cloud: true, sun: false, direct: false, diffuse: false, utci: false, uvA: false, uvB: false, burn: true, utciP: true, precip: true, precipProb: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: false, aqi: false, pollen: true } },
forest: { name: 'Forest / Woodland', scene: "url('assets/images/profiles/forest.png') center / cover no-repeat, linear-gradient(160deg,#c2d4cc,#e2ece6)", cols: { hour: true, air: true, shadeT: false, rh: false, dew: false, wind: true, dir: false, cloud: true, sun: false, direct: false, diffuse: false, utci: false, uvA: false, uvB: false, burn: true, utciP: true, precip: true, precipProb: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: true, pollen: true } },
camping: { name: 'Camping', scene: "url('assets/images/profiles/camping.png') center / cover no-repeat, linear-gradient(160deg,#c2d4cc,#e2ece6)", cols: { hour: true, air: true, shadeT: false, rh: false, dew: true, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, utci: false, uvA: false, uvB: false, burn: true, utciP: true, precip: true, precipProb: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: true, managedT: false, vis: true, aqi: false, pollen: true } },
airport: { name: 'Airport / Travel', scene: "url('assets/images/profiles/airport.png') center / cover no-repeat, linear-gradient(160deg,#c2d4cc,#e2ece6)", cols: { hour: true, air: true, shadeT: false, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, utci: false, uvA: false, uvB: false, burn: false, utciP: false, precip: true, precipProb: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false } },
// Places - Pro
construction: { name: 'Construction', proOnly: true, scene: "url('assets/images/profiles/construction.png') center / cover no-repeat, linear-gradient(160deg,#c2d4cc,#e2ece6)", cols: { hour: true, air: true, shadeT: true, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, utci: true, uvA: false, uvB: false, burn: false, utciP: true, precip: true, precipProb: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: true, pollen: false, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false } },
@@ -234,6 +235,7 @@ export const variantIcons = {
events: '🎪',
park: '🌳',
forest: '🌲',
camping: '🏕️',
airport: '✈️',
festival: '⛺',
construction: '🏗️',
@@ -256,7 +258,7 @@ export const variantIcons = {
export const activityVariantKeys = ['dogwalk', 'running', 'cycling', 'hiking', 'fishing', 'sailing', 'wintersports', 'photography', 'naturist'];
// Which variant keys appear in the Places dropdown.
export const placeVariantKeys = ['urban', 'park', 'forest', 'beach', 'events', 'festival', 'airport'];
export const placeVariantKeys = ['urban', 'park', 'forest', 'camping', 'beach', 'events', 'festival', 'airport'];
// Which variant keys appear in the Work dropdown.
export const workVariantKeys = ['farming', 'construction', 'market', 'windowcleaning', 'driver', 'office'];
@@ -291,8 +293,8 @@ export const COL_DESCRIPTIONS = {
soilM: { title: 'Soil Moisture', desc: 'Water saturation of the top 1 cm of soil as a percentage. Above 40% suggests saturated ground; below 20% indicates dry conditions.', short: "Water saturation of the top 1 cm of soil. Above 40% is saturated ground; below 20% is dry.", link: "./columns.html#col-soilM" },
concreteT: { title: 'Concrete Surface', desc: 'Estimated temperature of sun-exposed urban concrete or paving. Concrete absorbs more solar energy than grass and cannot cool itself through evaporation — surface temps can run 15–25 °C above air temperature on sunny days.', short: "Estimated temperature of sun-exposed paving, which can run 15–25 °C above air temperature.", link: "./columns.html#col-concreteT" },
vehicleT: { title: 'Interior', desc: 'Estimated ambient cabin temperature inside a parked vehicle. Cars heat rapidly through thin body panels and glass; motorhomes and caravans are modelled as insulated occupied living spaces that warm more slowly but hold heat longer, with gain through the windscreen and rooflights and a little retained living-space warmth. Accounts for ambient wind over the bodywork, road speed, and whether the windows are open. Dangerous for children and pets above 35 °C; potentially fatal above 45 °C.', short: "Estimated cabin temperature inside a parked vehicle. Dangerous for children and pets above 35 °C.", link: "./columns.html#col-vehicleT" },
indoorT: { title: 'Indoors', desc: 'Estimated ambient temperature inside a selected building type with windows closed and no air conditioning. Accounts for retained warmth, window solar gain, internal gains, and thermal lag without repeatedly accumulating solar heat hour after hour.', short: "Estimated temperature inside your selected building type with windows closed and no cooling.", link: "./columns.html#col-indoorT" },
managedT: { title: 'Managed Indoors', desc: 'Estimated indoor temperature with curtains closed and windows opened when outdoor air is cooler than inside — the standard UK heatwave advice. Curtains block most direct solar gain; smart ventilation pulls the temperature down during cooler periods.', short: "The same building with curtains closed and windows opened when outside air is cooler — the standard heatwave advice.", link: "./columns.html#col-managedT" },
indoorT: { title: 'Indoors', desc: 'Estimated temperature inside the selected building type when it is empty — windows closed, no heating, no cooling, no occupants. Accounts for window solar gain, the thermal mass of walls and floors (which smooths the daily swing and builds up over a heatwave or cold snap), heat exchange with the ground, and warmth from heated neighbours through party walls in flats and terraces.', short: "Estimated temperature inside your selected building type when empty — windows closed, no heating or cooling.", link: "./columns.html#col-indoorT" },
managedT: { title: 'Managed Indoors', desc: 'Estimated indoor temperature when the building is managed sensibly for the weather. On warm days (24 °C+ outside, or 23 °C+ inside) it follows UK heatwave advice: curtains closed against the sun and windows opened whenever outdoor air is cooler, never ventilating below 20 °C. On cooler days windows stay shut, curtains are open by day to let the sun in and closed at night to hold the warmth.', short: "The same building managed for the weather — heatwave advice on warm days, curtains used to hold warmth on cool days.", link: "./columns.html#col-managedT" },
vis: { title: 'Visibility', desc: 'Horizontal visibility in kilometres, sourced from the CAMS air quality model. Values below 1 km indicate fog or very thick haze; below 10 km suggests mist, smoke, or significant pollution. Relevant for driving, flying, and photography.', short: "Horizontal visibility in km. Below 1 km means fog; below 10 km means mist, smoke or pollution.", link: "./columns.html#col-vis" },
aqi: { title: 'Air Quality Index', desc: 'European Air Quality Index (0–100+), combining PM2.5, PM10, ozone, nitrogen dioxide, and sulphur dioxide. 0–20 = Good; 20–40 = Fair; 40–60 = Moderate; 60–80 = Poor; 80–100 = Very Poor; 100+ = Extremely Poor. Sourced from Copernicus CAMS.', short: "European Air Quality Index combining PM2.5, PM10, ozone, NO₂ and SO₂. 0–20 is Good, 100+ Extremely Poor.", link: "./columns.html#col-aqi" },
pollen: { title: 'Pollen', desc: 'Pollen concentration in grains per cubic metre for the selected pollen type, sourced from the Copernicus CAMS pollen forecast. Low = <10; Moderate = 10–50; High = 50–200; Very High = 200+. Values vary by species and season.', short: "Pollen concentration in grains per m³ for the selected type. Low is under 10; Very High is 200+.", link: "./columns.html#col-pollen" },
+34
View File
@@ -504,8 +504,11 @@ export function useAppState() {
const variantCols = OUTDOORS_VARIANTS[v]?.cols;
if (variantCols) {
setVisibleCols({ ...variantCols });
// Indoors state is owned here (and persisted) so Quick, Detailed and
// Table all agree - callers must not override it afterwards.
const hasIndoor = variantCols['indoorT'] || variantCols['managedT'];
setIndoorModeAndSave(hasIndoor ? 'on' : 'off');
setIndoorManagedAndSave(false);
}
// Auto-set UTCI environment modifier to match the selected variant.
const defaultEnv = VARIANT_DEFAULT_ENV[v] ?? 'open';
@@ -516,6 +519,14 @@ export function useAppState() {
setVehicleTypeAndSave('truck');
setVehicleSpeedAndSave('aroad');
}
// Camping shows the Indoor column as the tent's overnight temperature.
if (v === 'camping') {
setBuildingTypeAndSave('tent');
}
// Office models the Indoor column on an office building.
if (v === 'office') {
setBuildingTypeAndSave('office');
}
};
const activeCols = activeProfile === 'outdoors'
@@ -621,11 +632,21 @@ export function useAppState() {
}
}, []);
const closeWelcome = () => {
// First visit only: hand the new user straight on to the profile picker
// so they start from a profile that matches them rather than the default.
// Reopening the popup later from the footer link doesn't re-trigger it.
let firstVisit = false;
try { firstVisit = localStorage.getItem('sunscope_welcome_seen') !== '1'; } catch (e) { /* ignore */ }
try { localStorage.setItem('sunscope_welcome_seen', '1'); } catch (e) { /* ignore */ }
setWelcomeOpen(false);
if (firstVisit) setProfilePickPending(true);
};
const openWelcome = () => setWelcomeOpen(true);
// Set when the welcome popup is dismissed on a first visit; cleared by the
// effect below that actually opens the picker.
const [profilePickPending, setProfilePickPending] = useState(false);
// ── Weekly subscribe nudge (free users only) ─────────────────────────
// At most once every 7 days, and never on the first visit - the welcome
// popup owns that moment. The last-shown date lives in localStorage;
@@ -660,6 +681,16 @@ export function useAppState() {
const openPanel = () => setPanelOpen(true);
const closePanel = () => setPanelOpen(false);
// Completes the first-visit hand-off from the welcome popup, but only once
// the forecast is in: the panel anchors itself to the active-profile row,
// which doesn't exist until the forecast has rendered.
useEffect(() => {
if (!profilePickPending || !forecast) return;
setProfilePickPending(false);
setPanelOpen(true);
track('profile_picker_first_visit');
}, [profilePickPending, forecast]);
const [showUnits, setShowUnits] = useState(() => {
try {
const s = localStorage.getItem('sunscope_show_units');
@@ -778,6 +809,7 @@ export function useAppState() {
handleEventTagClick, handleEventTagEnter, handleEventTagLeave,
handleEventTagPopupEnter, handleEventTagPopupLeave,
closePopup, closeEventTagPopup,
weekPopup, weekPopupRef, handleWeekRowClick, closeWeekPopup,
} = useColumnPopup();
const {
@@ -791,6 +823,7 @@ export function useAppState() {
// The rotated table is a single table with CSS-only sticky edges, so
// none of this hook's dual-table syncing applies to it.
rotated: tableRotated,
tableInterval,
});
// ── 8. GEOCODING SEARCH ───────────────────────────────────────────────
@@ -953,6 +986,7 @@ export function useAppState() {
handleEventTagClick, handleEventTagEnter, handleEventTagLeave,
handleEventTagPopupEnter, handleEventTagPopupLeave,
closePopup, closeEventTagPopup,
weekPopup, weekPopupRef, handleWeekRowClick, closeWeekPopup,
// table scroll
tableCanScrollLeft, tableCanScrollRight, handleBodyScroll,
// computation
+68 -9
View File
@@ -23,7 +23,10 @@ export function useColumnPopup() {
const closeTimerRef = useRef(null);
// --- Event tag popup ------------------------------------------------
// Holds { events[], slideIndex, x, y, arrowLeft, below }
// Holds { events[], date, slideIndex, x, y, arrowLeft, below }
// `date` is the YYYY-MM-DD of the row the tag was on - the popup's Quick
// Record button dates its journal entry to the day being looked at, which
// is not necessarily today (the table can be showing any of the 14 days).
// When multiple events are in the popup they auto-cycle with a crossfade.
const [eventTagPopup, setEventTagPopup] = useState(null);
const [evSlideIndex, setEvSlideIndex] = useState(0);
@@ -168,12 +171,12 @@ export function useColumnPopup() {
}, [eventTagPopup, evSlideTo]);
// --- Event tag popup handlers ----------------------------------------
const openEventTagPopup = (events, spanEl, startIndex = 0) => {
const openEventTagPopup = (events, spanEl, startIndex = 0, date = null) => {
clearInterval(evSlideTimerRef.current);
setEvSlideIndex(startIndex);
prevSlideIndexRef.current = startIndex;
setEvTransition(null);
setEventTagPopup({ events, ...calcEventPopupPos(spanEl) });
setEventTagPopup({ events, date, ...calcEventPopupPos(spanEl) });
};
const closeEventTagPopup = () => {
@@ -185,8 +188,9 @@ export function useColumnPopup() {
clearInterval(evSlideTimerRef.current);
};
// rowEvents = all events for that row (passed in from app.js)
const handleEventTagClick = (rowEvents, startIndex, e) => {
// rowEvents = all events for that row, dateISO = that row's YYYY-MM-DD
// (both passed in from app.js)
const handleEventTagClick = (rowEvents, startIndex, dateISO, e) => {
e.stopPropagation();
clearTimeout(evHoverTimerRef.current);
clearTimeout(evCloseTimerRef.current);
@@ -194,19 +198,27 @@ export function useColumnPopup() {
if (eventTagPopup && JSON.stringify(eventTagPopup.events.map(ev => ev.id)) === JSON.stringify(rowEvents.map(ev => ev.id))) {
closeEventTagPopup(); return;
}
openEventTagPopup(rowEvents, e.currentTarget, startIndex);
openEventTagPopup(rowEvents, e.currentTarget, startIndex, dateISO);
};
const handleEventTagEnter = (rowEvents, startIndex, e) => {
const handleEventTagEnter = (rowEvents, startIndex, dateISO, e) => {
clearTimeout(evHoverTimerRef.current);
clearTimeout(evCloseTimerRef.current);
const spanEl = e.currentTarget;
evHoverTimerRef.current = setTimeout(() => openEventTagPopup(rowEvents, spanEl, startIndex), 700);
evHoverTimerRef.current = setTimeout(() => openEventTagPopup(rowEvents, spanEl, startIndex, dateISO), 700);
};
const handleEventTagLeave = () => clearTimeout(evHoverTimerRef.current);
const handleEventTagPopupEnter = () => clearTimeout(evCloseTimerRef.current);
// Moving into the popup stops the carousel for good (leaving it closes the
// popup anyway, so there is nothing to restart). It has a Quick Record
// button in it now: an event that slides out from under the cursor mid-reach
// would file the wrong one.
const handleEventTagPopupEnter = () => {
clearTimeout(evCloseTimerRef.current);
clearInterval(evSlideTimerRef.current);
};
const handleEventTagPopupLeave = () => { evCloseTimerRef.current = setTimeout(closeEventTagPopup, 200); };
useEffect(() => {
@@ -239,12 +251,59 @@ export function useColumnPopup() {
}
}, [eventTagPopup]);
// --- Week Ahead row popup ------------------------------------------
// Click-driven only: the Week Ahead rows used to carry their
// explanation in `title=` tooltips, which never surface on touch.
// Holds { key, title, sub, rows:[{icon,text}], ...pos }.
const [weekPopup, setWeekPopup] = useState(null);
const weekPopupRef = useRef(null);
const closeWeekPopup = () => setWeekPopup(null);
const handleWeekRowClick = (payload, e) => {
e.stopPropagation();
if (weekPopup?.key === payload.key) { closeWeekPopup(); return; }
setWeekPopup({ ...payload, ...calcPopupPos(e.currentTarget) });
};
useEffect(() => {
if (!weekPopup) return;
const onClickOutside = (e) => {
if (weekPopupRef.current && !weekPopupRef.current.contains(e.target)) closeWeekPopup();
};
const onScrollOrResize = () => closeWeekPopup();
document.addEventListener('mousedown', onClickOutside);
window.addEventListener('scroll', onScrollOrResize, { passive: true, capture: true });
window.addEventListener('resize', onScrollOrResize, { passive: true });
return () => {
document.removeEventListener('mousedown', onClickOutside);
window.removeEventListener('scroll', onScrollOrResize, { capture: true });
window.removeEventListener('resize', onScrollOrResize);
};
}, [weekPopup]);
// Post-render clamp (same logic as the col popup above).
useEffect(() => {
if (!weekPopup || !weekPopupRef.current) return;
const rect = weekPopupRef.current.getBoundingClientRect();
const MARGIN = 8;
if (!weekPopup.below && rect.top < MARGIN) {
const delta = MARGIN - rect.top;
if (delta > 1) setWeekPopup(prev => prev ? { ...prev, y: prev.y + delta } : prev);
} else if (weekPopup.below && rect.bottom > window.innerHeight - MARGIN) {
const delta = rect.bottom - (window.innerHeight - MARGIN);
if (delta > 1) setWeekPopup(prev => prev ? { ...prev, y: prev.y - delta } : prev);
}
}, [weekPopup]);
return {
// column-header popup
colPopup, colPopupRef,
handleThClick, handleThEnter, handleThLeave,
handlePopupEnter, handlePopupLeave,
closePopup,
// week-ahead row popup
weekPopup, weekPopupRef, handleWeekRowClick, closeWeekPopup,
// event-tag popup
eventTagPopup, eventTagPopupRef,
evSlideIndex, evTransition, evSlideTo,
+43 -3
View File
@@ -44,6 +44,8 @@ export function useTableScroll({
// are a sticky header table over a horizontally scrolling body table.
// It is a dependency purely so the widths re-sync on the flip.
rotated = false,
// Row bucket size (1-4h). Changes the number of rows/columns, so widths must re-sync.
tableInterval = 1,
}) {
// --- SCROLL INDICATORS ---------------------------------------------
const [tableCanScrollLeft, setTableCanScrollLeft] = useState(false);
@@ -167,6 +169,10 @@ export function useTableScroll({
bodyCells.forEach(c => { c.style.width = ''; c.style.minWidth = ''; c.style.maxWidth = ''; });
headTable.style.width = 'max-content';
bodyTable.style.width = 'max-content';
// Drop the CSS min-width:100% while measuring, or auto layout stretches the
// columns to fill the container and the "natural" widths come out inflated.
headTable.style.minWidth = '0';
bodyTable.style.minWidth = '0';
headTable.style.tableLayout = 'auto';
bodyTable.style.tableLayout = 'auto';
@@ -176,7 +182,15 @@ export function useTableScroll({
let naturalTotal = 0;
for (let i = 0; i < n; i++) {
const headW = headCells[i].getBoundingClientRect().width;
const bodyW = bodyCells[i].getBoundingClientRect().width;
let bodyW = bodyCells[i].getBoundingClientRect().width;
// Rotated: the first body row is an empty group-label row, so take
// the widest cell in this column across every body row instead.
if (rotated) {
bodyTable.querySelectorAll('tbody tr').forEach(tr => {
const c = tr.children[i];
if (c) bodyW = Math.max(bodyW, c.getBoundingClientRect().width);
});
}
const w = Math.max(Math.ceil(headW), Math.ceil(bodyW));
naturalW[i] = w;
naturalTotal += w;
@@ -186,15 +200,41 @@ export function useTableScroll({
const containerW = bodyScroll.clientWidth;
const finalW = new Array(n);
let totalWidth;
if (naturalTotal > 0 && naturalTotal < containerW) {
// Rotated: pinned label column keeps its natural width and the hour
// columns are sized EQUALLY to exactly fill the scroller - both
// spreading out when there are few and shrinking (down to the 92px
// cell minimum) so they never run off the right edge. Only when even
// that floor doesn't fit does it fall through to natural widths + scroll.
const eqW = rotated && n > 1 ? Math.floor((containerW - naturalW[0]) / (n - 1)) : 0;
if (rotated && n > 1 && eqW >= 92) {
let running = naturalW[0];
finalW[0] = naturalW[0];
for (let i = 1; i < n - 1; i++) { finalW[i] = eqW; running += eqW; }
finalW[n - 1] = containerW - running; // absorb rounding
totalWidth = containerW;
} else if (naturalTotal > 0 && naturalTotal < containerW) {
// Spare space - distribute proportionally across columns so they
// fan out to fill the scroller (no awkward right-hand gap).
const scale = containerW / naturalTotal;
let running = 0;
if (rotated && n > 1) {
// Rotated: keep the pinned label column at its natural width and
// share the spare space EQUALLY across the hour columns, so 2h/3h/4h
// spread evenly instead of scaling each column by its own width.
const spare = containerW - naturalTotal;
const share = Math.floor(spare / (n - 1));
finalW[0] = naturalW[0];
running = finalW[0];
for (let i = 1; i < n - 1; i++) {
finalW[i] = naturalW[i] + share;
running += finalW[i];
}
} else {
for (let i = 0; i < n - 1; i++) {
finalW[i] = Math.floor(naturalW[i] * scale);
running += finalW[i];
}
}
// Absorb sub-pixel rounding into the last column so the total
// exactly matches the container width.
finalW[n - 1] = containerW - running;
@@ -251,7 +291,7 @@ export function useTableScroll({
if (ro) ro.disconnect();
window.removeEventListener('resize', sync);
};
}, [forecast, visibleCols, selectedDay, skinType, vehicleType, indoorMode, indoorManaged, showDecimals, showUnits, rotated]);
}, [forecast, visibleCols, selectedDay, skinType, vehicleType, indoorMode, indoorManaged, showDecimals, showUnits, rotated, tableInterval]);
return {
tableCanScrollLeft,
+193 -106
View File
@@ -233,39 +233,58 @@ export function calcConcreteTempPass(TaArr, radArr, vaArr, elevArr, uvArr, cloud
}
// -------------------------------------------------------------------
// UK HOUSE INDOOR TEMPERATURE (windows closed, no active cooling)
// UK HOUSE INDOOR TEMPERATURE - TWO-NODE THERMAL MODEL
// -------------------------------------------------------------------
// Estimates the ambient indoor air temperature of a typical UK brick
// house with windows closed and no air conditioning.
// Estimates the free-running indoor air temperature of the selected
// BUILDING_TYPES preset when EMPTY: no heating, no air conditioning, no
// occupants - only standby appliances (fridge, router) as internal gain.
//
// Two heat pathways are modelled:
// Each hour builds an outdoor-driven TARGET - where the room would settle
// if this hour's conditions held forever:
//
// 1. WALL CONDUCTION
// Heat conducts through brick cavity walls and roof. UK Part L
// compliant walls have a U-value around 0.28-0.45 W/m-K; older
// solid-brick stock runs higher. A representative mid-stock value
// is used. This drives a slow, steady heat transfer proportional
// to the difference between outdoor and indoor air temperature.
// target = outdoor ref + solar lift + standby gain
//
// 2. WINDOW SOLAR GAIN
// A typical UK semi has ~15-18% glazing ratio. Solar energy
// transmits through glass, is absorbed by floors and furniture,
// and heats the indoor air. Gain is averaged across orientations
// (not all windows face south). Diffuse radiation contributes
// regardless of sun angle.
// outdoor ref blend of outside air, the ground and heated neighbours:
// Ta x (1 - groundCoupling - neighbourCoupling)
// + ground temp x groundCoupling
// + neighbour temp x neighbourCoupling
// Heat lost through the floor goes to the ground, not the
// air, and UK ground under a building sits near 6.5-14.5 C
// through the year (ukGroundTempC). This is what keeps an
// empty house above freezing in a cold snap and a little
// cooler in a heatwave - with a FIXED gap per degree, not a
// thermostat-like one.
// Neighbours (party walls/floors of terraces and flats) are
// occupied homes: never below NEIGHBOUR_MIN_C (they heat), and
// otherwise assumed the same as this building, so no flow.
// solar lift glob x glazingRatio x gValue x orientFactor x solarScale
// (tent: glazingRatio stands in for the sunlit fabric)
// standby gain fridge/router/standby loads in an empty building (C)
//
// THERMAL LAG
// Brick and concrete have high thermal mass - the house responds
// slowly to outdoor temperature swings. Each hour builds a realistic
// target temperature from outdoor air, window solar gain, retained
// warmth, and internal gains, then the room temperature lags toward
// that target. This avoids runaway accumulation while still giving
// the characteristic late-day indoor peak.
// Call calcIndoorTempPass() on the full hourly arrays after
// building rows - it returns a per-hour indoor temp array.
// The room is then modelled as TWO coupled nodes rather than one lag:
//
// No mechanical cooling. Minimal infiltration (windows closed).
// Internal heat gains (people, appliances) are not modelled.
// 1. ROOM AIR (Ti) - fast node, time constant lagHours. Its equilibrium is
// a blend of the outdoor-driven target and the thermal mass:
// airEq = (1 - massCoupling) x target + massCoupling x Tm
// massCoupling is how strongly the walls/floors hold the air compared
// with the outdoor pull through glass, fabric and leakage.
//
// 2. THERMAL MASS (Tm) - slow node, time constant massHours. Walls, floors
// and furniture exchange heat with the room air.
//
// Why two nodes: a single exponential lag lets ~70% of the outdoor daily
// swing through for a 4 h brick constant, so a brick house swung 21-34 C on
// an 18-32 C day. Monitored UK homes swing roughly a third of the outdoor
// range and instead RATCHET UP over a multi-day heatwave as the mass warms -
// the slow node reproduces both. The effective outdoor time constant of the
// mass is massHours / (1 - massCoupling) - about 1.5 days for brick.
//
// The forecast starts at midnight with no history, so the model is spun up
// by cycling the first day SPIN_UP_DAYS times (assumes the days before the
// forecast resembled the first one) instead of starting the mass cold.
//
// Indoor CAN be cooler than outdoor (a stone cottage on a hot afternoon);
// the only clamp is a 55 C physical cap.
//
// Colour thresholds:
// < 20 -C - cool, may need heating
@@ -274,94 +293,162 @@ export function calcConcreteTempPass(TaArr, radArr, vaArr, elevArr, uvArr, cloud
// > 32 -C - hot; risk for elderly and vulnerable occupants
// -------------------------------------------------------------------
// Two-pass function: call with the full arrays of hourly Ta and globalRad.
// Days of the first forecast day replayed to warm up the thermal mass.
const SPIN_UP_DAYS = 5;
// Managed mode switches to the heatwave routine on any day forecast to reach
// this outdoor temperature, or whenever the room itself passes HOT_INDOOR_C.
const HOT_DAY_OUTDOOR_C = 24;
const HOT_INDOOR_C = 23;
// Windows are only opened to cool while the room is above this, so night
// ventilation never chills the house below a comfortable sleeping temp.
const VENT_FLOOR_C = 20;
// UK ground temperature at ~1 m under a building for a day of year.
// Annual mean ~10.5 C, swing +/-4 C, coldest early March, warmest early
// September (the ground lags the air by ~6 weeks). UK/northern-hemisphere
// climate assumed, matching the rest of the building model.
const GROUND_MEAN_C = 10.5;
const GROUND_SWING_C = 4;
const GROUND_COLDEST_DOY = 65;
// Occupied neighbours keep their homes at least this warm in cold weather.
const NEIGHBOUR_MIN_C = 18;
function ukGroundTempC(dayOfYear) {
return GROUND_MEAN_C - GROUND_SWING_C * Math.cos(2 * Math.PI * (dayOfYear - GROUND_COLDEST_DOY) / 365);
}
// One hour of the indoor model. Mutates and returns state { Ti, Tm }.
// managed - false: windows closed, curtains open all the time (passive)
// true : occupant follows the routine for the conditions -
// HEAT (hotDay): curtains closed while the sun is up,
// windows opened whenever outdoor air is cooler
// COOL: windows shut, curtains open by day to let the sun
// in, closed after dark to cut window heat loss
function indoorStep(state, Ta, glob, preset, managed, hotDay, groundTemp) {
const { lagHours, massHours, massCoupling, glazingRatio, gValue, orientFactor,
curtainBlock, curtainInsul, ventAlpha, solarScale, groundCoupling,
neighbourCoupling, internalGain } = preset;
const heatMode = managed && (hotDay || state.Ti >= HOT_INDOOR_C);
const coolMode = managed && !heatMode;
// Solar gain - only blocked when managing for heat and the sun is up.
const block = heatMode ? curtainBlock : 0;
const solarLift = glob * glazingRatio * gValue * orientFactor * solarScale * (1 - block);
// Outdoor pull is split between outside air, the ground under the floor
// and any heated neighbours through party walls/floors.
const neighbourTemp = Math.max(state.Ti, NEIGHBOUR_MIN_C);
const outdoorRef = (1 - groundCoupling - neighbourCoupling) * Ta
+ groundCoupling * groundTemp
+ neighbourCoupling * neighbourTemp;
let target = outdoorRef + solarLift + internalGain;
// Cool routine after dark: closed curtains cut window heat loss, so the
// room is pulled less hard toward outdoor - curtainInsul is the share of
// the outdoor heat loss they save.
if (coolMode && glob <= 0 && target < state.Ti) {
target += (state.Ti - target) * curtainInsul;
}
// Room air relaxes toward a blend of the outdoor pull and the mass.
const airEq = (1 - massCoupling) * target + massCoupling * state.Tm;
const aAir = 1 - Math.exp(-1 / lagHours);
const aMass = 1 - Math.exp(-1 / massHours);
state.Ti += aAir * (airEq - state.Ti);
// Heat routine: open windows only when outside is cooler and the room is
// still above the comfort floor; never ventilate below that floor.
if (heatMode && Ta < state.Ti && state.Ti > VENT_FLOOR_C) {
const vented = state.Ti + ventAlpha * (Ta - state.Ti);
state.Ti = Math.max(vented, VENT_FLOOR_C, Ta);
}
// Mass exchanges with the (possibly ventilated) room air - this is how
// night purging cools the fabric for the next day.
state.Tm += aMass * (state.Ti - state.Tm);
return state;
}
// Shared driver for the passive and managed passes.
// Hourly arrays are assumed to start at local midnight (forecast_days fetch),
// so hour i belongs to forecast day floor(i / 24).
// startIso is the first hour's ISO time (for the seasonal ground temp); if
// missing the annual mean ground temperature is used.
function runIndoorModel(TaArr, globArr, buildingType, managed, startIso) {
const preset = BUILDING_TYPES[buildingType] || BUILDING_TYPES.brick;
const n = TaArr.length;
const result = new Array(n);
// Ground changes only ~0.5 C over a 14-day forecast - one value is enough.
const start = startIso ? new Date(startIso.slice(0, 10) + 'T00:00:00Z') : null;
const doy = start && !isNaN(start)
? Math.floor((start - Date.UTC(start.getUTCFullYear(), 0, 1)) / 86400000) + 1
: null;
const groundTemp = doy != null ? ukGroundTempC(doy) : GROUND_MEAN_C;
// Daily outdoor maxima decide the managed routine for each forecast day.
const dayMax = [];
for (let i = 0; i < n; i++) {
const d = Math.floor(i / 24);
const Ta = TaArr[i];
if (Ta != null) dayMax[d] = Math.max(dayMax[d] ?? -Infinity, Ta);
}
const isHotDay = i => (dayMax[Math.floor(i / 24)] ?? -Infinity) >= HOT_DAY_OUTDOOR_C;
// Seed both nodes at the first hour's air temp, then spin up on day one.
const seed = TaArr[0] ?? groundTemp;
const state = { Ti: seed, Tm: seed };
const spinHours = Math.min(24, n);
for (let rep = 0; rep < SPIN_UP_DAYS; rep++) {
for (let i = 0; i < spinHours; i++) {
indoorStep(state, TaArr[i] ?? state.Ti, globArr[i] ?? 0, preset, managed, isHotDay(i), groundTemp);
}
}
for (let i = 0; i < n; i++) {
const Ta = TaArr[i] ?? state.Ti;
indoorStep(state, Ta, globArr[i] ?? 0, preset, managed, isHotDay(i), groundTemp);
result[i] = Math.min(state.Ti, 55);
}
return result;
}
// PASSIVE: empty building, windows closed, curtains open, no heating or cooling.
// Returns an array of indoor temperatures, one per hour.
// buildingType must be a key of BUILDING_TYPES; defaults to 'brick'.
export function calcIndoorTempPass(TaArr, globArr, elevArr, buildingType = 'brick') {
const preset = BUILDING_TYPES[buildingType] || BUILDING_TYPES.brick;
const { lagHours, glazingRatio, gValue, orientFactor, solarScale, baseTemp, internalGain, retainedScale } = preset;
const n = TaArr.length;
const result = new Array(n);
const alpha = 1 - Math.exp(-1 / lagHours); // per-hour blending weight
// Seed to a plausible occupied indoor baseline rather than outdoor air.
let Ti = Math.max(TaArr[0] ?? 15, baseTemp ?? 16);
for (let i = 0; i < n; i++) {
const Ta = TaArr[i] ?? Ti;
const glob = globArr[i] ?? 0;
// Solar gain through windows (W/m- effective)
const solarGain = glob * glazingRatio * gValue * orientFactor;
const retainedWarmth = Math.max(0, (baseTemp ?? 16) - Ta) * (retainedScale ?? 0.35);
const target = Ta + solarGain * (solarScale ?? 0.1) + retainedWarmth + (internalGain ?? 0.7);
// Apply thermal lag: blend toward the hour's target instead of adding
// solar gain repeatedly onto the previous indoor temperature.
Ti = Ti + alpha * (target - Ti);
// Can't be colder than outdoor (house doesn't actively cool)
result[i] = Math.max(Math.min(Ti, 55), Math.min(Ta, Ti));
}
return result;
// elevArr is accepted for signature compatibility but not needed - the
// orientFactor average already covers sun angle on the glazing.
// startIso (optional) - first hour's ISO time, sets the seasonal ground temp.
export function calcIndoorTempPass(TaArr, globArr, elevArr, buildingType = 'brick', startIso) {
return runIndoorModel(TaArr, globArr, buildingType, false, startIso);
}
// -------------------------------------------------------------------
// UK HOUSE INDOOR TEMPERATURE - MANAGED (curtains closed, windows open)
// UK HOUSE INDOOR TEMPERATURE - MANAGED
// -------------------------------------------------------------------
// Models the same UK brick house as calcIndoorTempPass but with two
// behavioural interventions that reflect standard heatwave advice:
// The same empty building, but with its curtains and windows set sensibly
// for the conditions (by a visiting owner or smart blinds - see indoorStep):
//
// 1. CURTAINS CLOSED
// Thick curtains block ~80% of window solar gain before it enters
// the room. The small remaining fraction is diffuse light through
// the curtain fabric. Wall conduction is unchanged.
// HEAT routine (day forecast >= HOT_DAY_OUTDOOR_C, or room >= HOT_INDOOR_C)
// Standard UK heatwave advice. Curtains/blinds closed while the sun is up
// (curtainBlock - internal curtains only stop ~40-55% because the heat
// is already through the glass; external shutters would be ~80%+).
// Windows opened whenever outdoor air is cooler than inside, down to a
// VENT_FLOOR_C comfort floor - so it never makes things worse.
//
// 2. WINDOWS OPEN (smart ventilation)
// When outdoor air is cooler than the indoor air, windows are open
// and a ventilation heat exchange pulls the indoor temp toward
// outdoor. When outdoor is hotter than indoor, windows are kept
// shut - so this strategy never makes things worse, only better.
// Ventilation rate ~2 air changes/hour for a well-opened house.
//
// Same thermal lag model as calcIndoorTempPass (4 h brick time constant).
// COOL routine (all other days)
// Windows shut. Curtains open in daylight to bank free solar gain,
// closed after dark to cut window heat loss (curtainInsul). Managed is
// therefore slightly WARMER than passive on cool days, not colder.
// -------------------------------------------------------------------
// buildingType must be a key of BUILDING_TYPES; defaults to 'brick'.
export function calcManagedIndoorTempPass(TaArr, globArr, elevArr, buildingType = 'brick') {
const preset = BUILDING_TYPES[buildingType] || BUILDING_TYPES.brick;
const { lagHours, glazingRatio, gValue, orientFactor, curtainBlock, ventAlpha, solarScale, baseTemp, internalGain, retainedScale } = preset;
const n = TaArr.length;
const result = new Array(n);
const alpha = 1 - Math.exp(-1 / lagHours);
let Ti = Math.max(TaArr[0] ?? 15, baseTemp ?? 16);
for (let i = 0; i < n; i++) {
const Ta = TaArr[i] ?? Ti;
const glob = globArr[i] ?? 0;
// Solar gain - curtains block curtainBlock fraction
const solarGain = glob * glazingRatio * gValue * orientFactor * (1 - curtainBlock);
const retainedWarmth = Math.max(0, (baseTemp ?? 16) - Ta) * (retainedScale ?? 0.35);
const target = Ta + solarGain * (solarScale ?? 0.1) + retainedWarmth + (internalGain ?? 0.7);
// Apply thermal lag toward the managed target.
Ti = Ti + alpha * (target - Ti);
// Smart ventilation: only open windows when outside is cooler
if (Ta < Ti) {
Ti = Ti + ventAlpha * (Ta - Ti);
}
result[i] = Math.max(Math.min(Ti, 55), Math.min(Ta, Ti));
}
return result;
export function calcManagedIndoorTempPass(TaArr, globArr, elevArr, buildingType = 'brick', startIso) {
return runIndoorModel(TaArr, globArr, buildingType, true, startIso);
}
// -------------------------------------------------------------------
+157 -34
View File
@@ -197,7 +197,20 @@ export function petCategory(t) {
// alarm - is shared with the table palettes above, so the two can never
// drift apart on anything but this one deliberate colour.
// -------------------------------------------------------------------
const DAY_TAB_COMFORTABLE = '#edd449'; // deep gold, one step under Warm's #f8e554
// bandRampRgb() draws a straight line between each pair of neighbouring
// anchors, so three anchors only look like one smooth ramp if the middle one
// sits ON the line between the other two. A hand-picked "one step under
// Warm" gold does not: it already carries Warm's full saturation, so Cool
// (15C, oatmeal) to Comfortable (19C) is an abrupt jump to solid gold and
// Comfortable to Warm (24C) is then almost flat - which is exactly the "very
// yellow" plateau a mild week (19-24C highs) sits in. Setting Comfortable to
// the actual RGB midpoint between Cool's oatmeal and Warm's yellow removes
// the kink, so the same mild week instead reads as a continuous fade.
const DAY_TAB_HUMAN_COMFORTABLE = '#eade92';
// Same fix, recomputed for the pet scale's own Cool (11C, #b5dee9) and Warm
// (32C, #f7cd54) anchors - the pet ramp spans different thresholds, so the
// human midpoint would land in the wrong place on it.
const DAY_TAB_PET_COMFORTABLE = '#e1d386';
// The same reasoning, carried down the rest of the HUMAN scale.
//
@@ -217,18 +230,18 @@ const DAY_TAB_COMFORTABLE = '#edd449'; // deep gold, one step under Warm's #f8
// (pet Cool is #b5dee9, not a green), and its thresholds sit at completely
// different temperatures, so it keeps the Comfortable override alone.
const DAY_TAB_HUMAN = {
Comfortable: DAY_TAB_COMFORTABLE,
Comfortable: DAY_TAB_HUMAN_COMFORTABLE,
Cool: '#ded9c3', // warm oatmeal, one step under Comfortable's gold
Chilly: '#d3d5d2', // the neutral pivot between the warm and cold halves
Cold: '#c2d3dd', // first step back into blue
};
const withTabComfort = (bands) =>
bands.map(b => (b.label === 'Comfortable' ? { ...b, bg: DAY_TAB_COMFORTABLE } : b));
const withTabComfort = (bands, comfortableBg) =>
bands.map(b => (b.label === 'Comfortable' ? { ...b, bg: comfortableBg } : b));
export const DAY_TAB_UTCI_BANDS =
UTCI_BANDS.map(b => (DAY_TAB_HUMAN[b.label] ? { ...b, bg: DAY_TAB_HUMAN[b.label] } : b));
export const DAY_TAB_PET_BANDS = withTabComfort(PET_BANDS);
export const DAY_TAB_PET_BANDS = withTabComfort(PET_BANDS, DAY_TAB_PET_COMFORTABLE);
// Diagonal sweep gradient for a band background hex colour.
// Pre-blends with 50% white to sit harmoniously alongside lighter table cells,
@@ -392,40 +405,66 @@ export const VEHICLE_SPEEDS = {
};
// -------------------------------------------------------------------
// BUILDING TYPES - presets for the indoor temperature model.
// BUILDING TYPES - presets for the two-node indoor temperature model.
// -------------------------------------------------------------------
// Each preset drives both calcIndoorTempPass and calcManagedIndoorTempPass.
// Each preset drives both calcIndoorTempPass and calcManagedIndoorTempPass
// (see physics.js). Every building is modelled EMPTY - no heating, no
// occupants, only standby appliances. Heavy masonry swings ~3-4 C a day and
// ratchets up over a heatwave; lightweight timber and top-floor flats run
// hottest; in a prolonged hard frost empty houses settle near 0 C, with
// terraces and flats held up by heated neighbours.
//
// uWall W/m-K Effective envelope U-value. Higher = faster response
// to outdoor swings, less insulation.
// lagHours h Thermal mass time constant. Heavier construction = longer
// lag before indoor temp follows outdoor changes.
// glazingRatio - Fraction of floor area that is window. More glass = more
// solar gain in summer, more heat loss in winter.
// gValue - Solar heat gain coefficient of glazing. 0.63 = standard
// double glazing; 0.3 = modern low-e triple.
// lagHours h Room AIR response time - how fast the air follows its
// equilibrium. Short for every building; the slow part of
// the response is the mass node below.
// massHours h Air-to-mass exchange time constant of walls/floors.
// massCoupling - How strongly the mass holds the room air (0-1) versus the
// outdoor pull through glass, fabric and leakage. Higher =
// heavier/better insulated = smaller daily swing. Effective
// outdoor time constant is massHours / (1 - massCoupling):
// ~17 h timber ... ~30 h brick ... ~53 h modern and stone.
// glazingRatio - Fraction of floor area that is window (tent: sunlit fabric).
// gValue - Solar heat gain coefficient of glazing. ~0.63 older double
// glazing; ~0.50 typical UK low-e double; ~0.30 solar-control.
// orientFactor - Fraction of windows facing the sun at any given time.
// 0.5 = random orientation; 0.8 = south-facing conservatory.
// curtainBlock - Fraction of solar gain blocked when managed (curtains
// closed). Thick lined curtains - 0.80; blinds - 0.50.
// ventAlpha - Blending weight per hour when smart ventilation is open.
// Higher = more air changes per hour.
// solarScale - Converts effective window solar gain into an indoor
// temperature lift. Lower values mean more thermal mass.
// baseTemp - Occupied/retained warmth baseline for normal homes.
// internalGain - Small heat gain from people, appliances, and background use.
// retainedScale - How strongly the building holds above-outdoor warmth in
// cool conditions. Higher = better retained warmth.
// 0.5 = random orientation; 0.7 = south-facing conservatory.
// curtainBlock - Fraction of solar gain blocked by the managed heat routine.
// Internal curtains/blinds only stop ~40-55% because the heat
// is already through the glass (external shutters ~80%+).
// curtainInsul - Share of OUTDOOR heat loss saved by closing curtains after
// dark in the managed cool routine (windows carry ~25-40% of
// it; lined curtains cut that by about a third).
// ventAlpha - Per-hour blend toward outdoor air when windows are opened
// in the managed heat routine. Higher = more air changes.
// solarScale - Converts effective window solar gain into a temperature
// lift on the room's outdoor-driven target. Higher for
// better-insulated shells (same gain, less heat loss).
// groundCoupling - Share of the outdoor heat loss that goes through the
// floor to the ground (seasonal UK ground temp) instead of
// to outside air. 0 for flats above ground and tents.
// neighbourCoupling - Share lost through party walls/floors to occupied
// neighbours, who never let their homes fall below 18 C.
// Highest for flats and terraces; 0 for detached/tents.
// internalGain - Standby gain of the EMPTY building - fridge, router,
// standby loads (C). Larger where the shell loses less heat.
// -------------------------------------------------------------------
export const BUILDING_TYPES = {
brick: { name: 'Brick (typical)', uWall: 0.35, lagHours: 4, glazingRatio: 0.16, gValue: 0.63, orientFactor: 0.50, curtainBlock: 0.80, ventAlpha: 0.25, solarScale: 0.10, baseTemp: 16.5, internalGain: 0.8, retainedScale: 0.35 },
modern: { name: 'Modern / Well insulated', uWall: 0.18, lagHours: 5, glazingRatio: 0.20, gValue: 0.30, orientFactor: 0.50, curtainBlock: 0.70, ventAlpha: 0.20, solarScale: 0.08, baseTemp: 17.0, internalGain: 0.8, retainedScale: 0.55 },
victorian: { name: 'Victorian Terrace', uWall: 0.55, lagHours: 5, glazingRatio: 0.12, gValue: 0.63, orientFactor: 0.50, curtainBlock: 0.80, ventAlpha: 0.30, solarScale: 0.10, baseTemp: 16.0, internalGain: 0.7, retainedScale: 0.25 },
stone: { name: 'Stone / Granite Cottage', uWall: 0.45, lagHours: 7, glazingRatio: 0.10, gValue: 0.63, orientFactor: 0.50, curtainBlock: 0.75, ventAlpha: 0.25, solarScale: 0.08, baseTemp: 15.5, internalGain: 0.6, retainedScale: 0.40 },
timber: { name: 'Timber Frame / New Build', uWall: 0.22, lagHours: 2, glazingRatio: 0.22, gValue: 0.35, orientFactor: 0.50, curtainBlock: 0.70, ventAlpha: 0.35, solarScale: 0.08, baseTemp: 17.0, internalGain: 0.8, retainedScale: 0.45 },
flat: { name: 'Top-floor Flat', uWall: 0.40, lagHours: 3, glazingRatio: 0.18, gValue: 0.63, orientFactor: 0.50, curtainBlock: 0.75, ventAlpha: 0.20, solarScale: 0.11, baseTemp: 17.0, internalGain: 0.9, retainedScale: 0.45 },
conservatory:{ name: 'Conservatory / Sun Room', uWall: 1.20, lagHours: 1, glazingRatio: 0.70, gValue: 0.72, orientFactor: 0.70, curtainBlock: 0.50, ventAlpha: 0.50, solarScale: 0.045, baseTemp: 12.0, internalGain: 0.2, retainedScale: 0.05 },
office: { name: 'Office Block', uWall: 0.25, lagHours: 2, glazingRatio: 0.45, gValue: 0.55, orientFactor: 0.65, curtainBlock: 0.50, ventAlpha: 0.15, solarScale: 0.055, baseTemp: 18.5, internalGain: 2.5, retainedScale: 0.30 },
brick: { name: 'Brick (typical)', lagHours: 1.5, massHours: 6, massCoupling: 0.80, glazingRatio: 0.16, gValue: 0.63, orientFactor: 0.50, curtainBlock: 0.55, curtainInsul: 0.12, ventAlpha: 0.25, solarScale: 0.10, groundCoupling: 0.15, neighbourCoupling: 0.10, internalGain: 0.4 },
// Modern: airtight and insulated, typical low-e double glazing - loses heat
// slowly, so it holds warmth in a cold snap and accumulates heat over a
// long heatwave.
modern: { name: 'Modern / Well insulated', lagHours: 1.5, massHours: 8, massCoupling: 0.85, glazingRatio: 0.20, gValue: 0.50, orientFactor: 0.50, curtainBlock: 0.50, curtainInsul: 0.10, ventAlpha: 0.20, solarScale: 0.14, groundCoupling: 0.12, neighbourCoupling: 0.10, internalGain: 1.0 },
victorian: { name: 'Victorian Terrace', lagHours: 1.5, massHours: 6, massCoupling: 0.75, glazingRatio: 0.12, gValue: 0.63, orientFactor: 0.50, curtainBlock: 0.55, curtainInsul: 0.15, ventAlpha: 0.30, solarScale: 0.10, groundCoupling: 0.08, neighbourCoupling: 0.20, internalGain: 0.3 },
stone: { name: 'Stone / Granite Cottage', lagHours: 2, massHours: 8, massCoupling: 0.85, glazingRatio: 0.10, gValue: 0.63, orientFactor: 0.50, curtainBlock: 0.50, curtainInsul: 0.10, ventAlpha: 0.25, solarScale: 0.08, groundCoupling: 0.20, neighbourCoupling: 0.00, internalGain: 0.3 },
timber: { name: 'Timber Frame / New Build', lagHours: 1, massHours: 6, massCoupling: 0.65, glazingRatio: 0.22, gValue: 0.50, orientFactor: 0.50, curtainBlock: 0.50, curtainInsul: 0.12, ventAlpha: 0.35, solarScale: 0.08, groundCoupling: 0.12, neighbourCoupling: 0.05, internalGain: 0.6 },
flat: { name: 'Top-floor Flat', lagHours: 1, massHours: 5, massCoupling: 0.70, glazingRatio: 0.18, gValue: 0.63, orientFactor: 0.50, curtainBlock: 0.50, curtainInsul: 0.12, ventAlpha: 0.20, solarScale: 0.11, groundCoupling: 0.00, neighbourCoupling: 0.25, internalGain: 0.6 },
conservatory:{ name: 'Conservatory / Sun Room', lagHours: 0.5, massHours: 3, massCoupling: 0.30, glazingRatio: 0.70, gValue: 0.72, orientFactor: 0.70, curtainBlock: 0.50, curtainInsul: 0.25, ventAlpha: 0.50, solarScale: 0.045, groundCoupling: 0.10, neighbourCoupling: 0.00, internalGain: 0.0 },
office: { name: 'Office Block', lagHours: 1, massHours: 8, massCoupling: 0.70, glazingRatio: 0.45, gValue: 0.55, orientFactor: 0.65, curtainBlock: 0.40, curtainInsul: 0.05, ventAlpha: 0.15, solarScale: 0.055, groundCoupling: 0.08, neighbourCoupling: 0.00, internalGain: 1.0 },
// Tent: glazingRatio stands in for the sun-absorbing fabric area, not windows,
// giving ~+12 °C over ambient at 800 W/m² when closed up. Almost no mass;
// "managed" means doors open / shaded, curtainInsul 0 (nothing to close).
// Empty tent: no body heat, so at night it sits near outdoor air temp.
tent: { name: 'Tent', lagHours: 0.5, massHours: 1, massCoupling: 0.05, glazingRatio: 0.20, gValue: 0.85, orientFactor: 0.60, curtainBlock: 0.30, curtainInsul: 0.00, ventAlpha: 0.60, solarScale: 0.15, groundCoupling: 0.00, neighbourCoupling: 0.00, internalGain: 0.0 },
};
// -------------------------------------------------------------------
@@ -602,6 +641,90 @@ export function skyFillForElev(e, isRising = false) {
return skyGradientForElev(e, isRising).bot;
}
// -------------------------------------------------------------------
// SUN LIGHT STATE - physics-lite sun colour, glow and afterglow.
// -------------------------------------------------------------------
// Shared by SkyScope (hourly mini scopes) and ScopeReticle (big scope)
// so both rise/set identically. Inputs are one forecast row's values;
// hourly rows are computed at the top of the hour (17:00, not 17:10).
//
// Colour: the lower the sun, the more air its light crosses (air mass,
// Kasten-Young). Each RGB channel is dimmed by Rayleigh scattering (blue
// far more than red) plus haze aerosol estimated from visibility (or
// humidity when visibility is missing), so the sun runs white -> yellow
// -> orange -> red, and redder still on hazy evenings.
//
// Returns:
// core - sun disk colour (hex)
// glow - halo / afterglow colour (hex)
// diskAlpha - disk opacity (cloud in front of the sun dims it)
// skyGlow - 0..1 strength of the horizon glow in the sky
// groundGlow - 0..1 strength of the warm wash on the ground
// haze - 0..1, widens and softens the glow
// -------------------------------------------------------------------
const SUN_TAU_RAYLEIGH = [0.045, 0.09, 0.19]; // R,G,B optical depth at air mass 1 (~650/550/450nm)
const SUN_AEROSOL_SPECTRAL = [0.80, 1.0, 1.30]; // aerosol depth per channel relative to 550nm (Angstrom ~1.3)
const AEROSOL_LAYER_KM = 1.2; // haze layer depth used to turn visibility into optical depth
const SUN_DISPLAY_GAMMA = 0.5; // perceptual compression so low sun reads orange, not pure red
// Smooth 0..1 ramp between a and b.
function smoothstep(a, b, x) {
const t = Math.max(0, Math.min(1, (x - a) / (b - a)));
return t * t * (3 - 2 * t);
}
// Relative path length through the atmosphere (1 overhead, ~38 at the horizon).
function airMass(elev) {
const h = Math.max(0, elev);
return 1 / (Math.sin(h * Math.PI / 180) + 0.50572 * Math.pow(h + 6.07995, -1.6364));
}
export function sunLightState({ elev, isRising = false, cc = null, ccLow = null, ccMid = null,
ccHigh = null, visKm = null, rh = null }) {
// Haze: visibility -> aerosol optical depth (Koschmieder 3.912/vis).
// Without visibility, guess it from humidity (moist air = hazier).
let vis = visKm;
if (vis == null) vis = rh != null ? Math.max(4, Math.min(40, 40 - Math.max(0, rh - 50) * 0.75)) : 25;
const aod = Math.max(0.05, Math.min(1.5, AEROSOL_LAYER_KM * 3.912 / Math.max(0.5, vis)));
const haze = Math.max(0, Math.min(1, (aod - 0.15) / 0.8));
// Transmitted sunlight per channel, normalised so the brightest is 255.
const am = airMass(elev);
const trans = SUN_TAU_RAYLEIGH.map((tau, i) => Math.exp(-(tau + aod * SUN_AEROSOL_SPECTRAL[i]) * am));
const sunRgb = trans.map(t => 255 * Math.pow(t / trans[0], SUN_DISPLAY_GAMMA));
// Cloud: low cloud blocks the sun and the horizon light; mid/high cloud
// over a clear low sky catches the colour ("fire sky").
const low = ccLow != null ? ccLow : (cc != null ? cc * 0.6 : 0);
const mid = ccMid != null ? ccMid : 0;
const high = ccHigh != null ? ccHigh : 0;
const lowBlock = Math.min(1, low / 100);
const block = Math.min(1, (low * 0.8 + mid * 0.45 + high * 0.15) / 100);
const fire = Math.min(1, (mid + high) / 80) * (1 - lowBlock);
// Elevation envelopes: the sky glow builds as the sun drops below ~14 deg,
// peaks at the horizon and lingers through civil twilight (to -9 deg).
// The ground only gets direct warm light while the sun is up, so its
// wash fades out quickly once the sun has gone (by -4 deg).
const lowSun = 1 - smoothstep(2, 14, elev);
const skyEnv = lowSun * smoothstep(-9, -1, elev);
const groundEnv = (1 - smoothstep(3, 14, elev)) * smoothstep(-4, 0.5, elev);
const skyGlow = Math.min(1, 0.75 * skyEnv * (1 - 0.7 * lowBlock) * (1 + 0.45 * fire));
const groundGlow = Math.min(1, 0.6 * groundEnv * (1 - 0.85 * lowBlock));
// Glow colour: the sun's own colour, shifting rose (dawn) or violet (dusk)
// as the sun sinks deeper, and pinker under a fire sky.
const sunHex = rgbToHex(sunRgb);
const depth = Math.max(0, Math.min(1, -elev / 8));
let glow = rgbToHex(lerpRgb(sunRgb, hexToRgb(isRising ? '#c8607a' : '#a85a9e'), depth * 0.8));
glow = rgbToHex(lerpRgb(hexToRgb(glow), hexToRgb('#ff5f6d'), fire * skyEnv * 0.35));
// Disk: overexposed (near white) high up, true colour near the horizon.
const core = rgbToHex(lerpRgb(sunRgb, [255, 255, 255], 0.15 + 0.45 * (1 - lowSun)));
return { core, glow, sun: sunHex, diskAlpha: 1 - 0.8 * block, skyGlow, groundGlow, haze };
}
// -------------------------------------------------------------------
// GRASS FILL - ground-strip colour keyed to solar elevation.
// -------------------------------------------------------------------
+23 -2
View File
@@ -239,9 +239,30 @@ async function build() {
// dashboard.html is copied as-is, not run through rewriteHTML - it is a
// standalone page (inline CSS/JS, no #root) and has no use for the
// Preact app bundle the other pages get.
// secrets.local.php is deliberately NOT copied here - it's gitignored
// per-environment config (DB creds, Stripe key). Copying the local dev
// copy into dist/ and syncing dist/ to the VPS would overwrite the
// live secrets file with local (127.0.0.1/root/blank) DB credentials.
for (const f of ['robots.txt', 'sitemap.xml', 'og-image.png', 'favicon.svg', 'restore.php', 'track.php', 'stats.php',
'verify-session.php', 'check-subscription.php', 'dev-unlock.php', 'secrets.local.php', 'dashboard.html',
'assets/js/nav-account.js']) {
'verify-session.php', 'check-subscription.php', 'dev-unlock.php', 'dashboard.html',
'assets/js/nav-account.js', 'assets/about.css']) {
const src = path.join(ROOT, f);
if (fs.existsSync(src)) {
copyFile(src, path.join(DIST, f));
console.log(' -> ' + f);
}
}
// 8b. Copy the raw JS modules dashboard.html imports directly (it's not
// run through esbuild like the Preact pages are, so its import chain -
// compute.js -> physics.js/utils.js/config.js, plus the event calendars -
// has to exist as real files under dist/assets/js/).
console.log(' Copying dashboard JS modules...');
for (const f of ['assets/js/compute.js', 'assets/js/physics.js', 'assets/js/config.js', 'assets/js/utils.js',
'assets/js/events/cosmic-calendar.js', 'assets/js/events/almanac-calendar.js',
'assets/js/events/calendar/2026.js', 'assets/js/events/calendar/2027.js',
'assets/js/events/calendar/2028.js', 'assets/js/events/calendar/2029.js',
'assets/js/events/calendar/2030.js']) {
const src = path.join(ROOT, f);
if (fs.existsSync(src)) {
copyFile(src, path.join(DIST, f));
+2 -2
View File
@@ -119,8 +119,8 @@
<tr id="col-soilM"><td><strong>Soil moisture</strong></td><td>Water saturation of the top 1 cm of soil as a percentage. Above 40% indicates saturated ground; below 20% is dry. Useful for assessing whether ground is workable, and for motorhome or caravan pitch suitability.</td></tr>
<tr id="col-concreteT"><td><strong>Concrete °C</strong></td><td>Estimated temperature of sun-exposed urban paving. Concrete absorbs more solar energy than grass and cannot cool itself through evaporation — surface temps can run 15–25 °C above air temperature on sunny days. See <a href="./temperatures.html">derived temperatures</a>.</td></tr>
<tr id="col-vehicleT"><td><strong>Vehicle °C</strong></td><td>Estimated ambient cabin temperature inside a sealed, parked vehicle — adjusted for your chosen vehicle type. Values above 35 °C are dangerous for children and pets; above 45 °C potentially fatal within minutes. See <a href="./temperatures.html">derived temperatures</a>.</td></tr>
<tr id="col-indoorT"><td><strong>Indoors °C</strong></td><td>Estimated temperature inside a building with windows closed and no active cooling. Select your building type from the dropdown — brick, modern insulated, Victorian terrace, stone cottage, timber frame, top-floor flat, conservatory, or office block. Indoor peak typically lags the outdoor peak by 2–4 hours due to thermal mass.</td></tr>
<tr id="col-managedT"><td><strong>Managed °C</strong></td><td>The same building model with two interventions: curtains closed by day to block solar gain, and windows opened whenever outdoor air is cooler than inside — the standard UK heatwave advice. Toggle with the Managed tick next to the building dropdown.</td></tr>
<tr id="col-indoorT"><td><strong>Indoors °C</strong></td><td>Estimated temperature inside an empty building — windows closed, no heating, no cooling and no occupants. Select your building type from the dropdown — brick, modern insulated, Victorian terrace, stone cottage, timber frame, top-floor flat, conservatory, office block, or tent. Indoor peak typically lags the outdoor peak by 2–4 hours due to thermal mass.</td></tr>
<tr id="col-managedT"><td><strong>Managed °C</strong></td><td>The same building model, managed sensibly for the weather. On warm days (24 °C+ outside, or 23 °C+ inside) it follows the standard UK heatwave advice: curtains closed against the sun and windows opened whenever outdoor air is cooler than inside, but never ventilated below 20 °C. On cooler days windows stay shut and curtains are opened by day to let the sun in, then closed at night to hold the warmth. Toggle with the Managed tick next to the building dropdown.</td></tr>
<tr id="col-furSurfaceT"><td><strong>Fur Temp °C</strong></td><td>Estimated fur surface temperature a cat or small dog experiences in direct sun, calibrated for a thin/short coat. Choose your pet's fur colour — Black/Dark, Brown/Tabby, Golden/Tan, or White/Pale — from the dropdown; darker coats absorb more solar heat and run hotter.</td></tr>
<tr id="col-pawT"><td><strong>Paw Temp °C</strong></td><td>Estimated ground/pavement surface temperature for paw contact, colour-coded by burn risk: green below 40 °C (safe), amber 40–52 °C (discomfort or possible burn on prolonged contact), red above 52 °C (can burn within 60 seconds). If you can't hold the back of your hand on the surface for 7 seconds, it's too hot to walk your pet.</td></tr>
<tr id="col-petShadeT"><td><strong>Pet Shade °C</strong></td><td>Shaded air temperature for a resting pet, using the same shade microclimate model as the Shade column but kept as an air temperature and with a lower, pet-specific alert threshold — small pets have far less capacity to dump heat than humans.</td></tr>
+946 -154
View File
File diff suppressed because it is too large Load Diff
+42
View File
@@ -235,5 +235,47 @@
"profiles": {
"showall": 1
}
},
"2026-09-01": {
"visits": 37,
"profiles": {
"farming": 2,
"basic": 9,
"home": 13,
"vehicle": 14,
"pets": 10
}
},
"2026-09-03": {
"visits": 1,
"profiles": []
},
"2026-09-04": {
"visits": 4,
"profiles": {
"vehicle": 3,
"basic": 2,
"home": 1
}
},
"2026-09-11": {
"visits": 1,
"profiles": []
},
"2026-09-16": {
"visits": 2,
"profiles": []
},
"2026-10-04": {
"visits": 17,
"profiles": []
},
"2026-10-08": {
"visits": 4,
"profiles": {
"vehicle": 2,
"home": 1,
"basic": 1
}
}
}
+6
View File
@@ -15,6 +15,12 @@ function db(): PDO {
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
// rowCount() on an UPDATE reports rows *matched*, not rows
// changed. journal-save.php leans on that to tell "not yours /
// no such row" apart from "saved, but nothing actually
// differed" - without it, re-saving an entry you didn't edit
// comes back as "Entry not found."
PDO::MYSQL_ATTR_FOUND_ROWS => true,
]
);
return $pdo;
+12 -1
View File
@@ -200,7 +200,7 @@
<h2>🌤️ Places</h2>
<p>
The Places dropdown gives access to location-specific views, each with its own tailored column set.
Urban, Park/Picnic, Forest/Woodland, Beach, Events and Airport/Travel are free; Festival is part of
Urban, Park/Picnic, Forest/Woodland, Camping, Beach, Events and Airport/Travel are free; Festival is part of
SunScope Extra.
</p>
@@ -234,6 +234,17 @@
</p>
</div>
<div class="profile-block">
<p class="profile-name">🏕️ Camping</p>
<p>
For pitching up overnight. Shows the Indoor column set to the Tent building type, estimating the
inside-canvas temperature swing from a thin, poorly-insulated shelter — useful for judging how cold
it'll get once the sun's down and how quickly it warms once it's back up. Also shows air temperature,
wind speed and direction for guy-line and pitch decisions, cloud cover, dew point for overnight
condensation, burn time, SunSoak, precipitation with rain probability, visibility, and pollen.
</p>
</div>
<div class="profile-block">
<p class="profile-name">🏖️ Beach</p>
<p>