Compare commits

...
10 Commits
Author SHA1 Message Date
fraxle 47818fba33 Update .gitignore 2026-08-25 12:24:26 +01:00
fraxle 24cdd9c2f0 Update .gitignore 2026-08-25 12:24:04 +01:00
fraxle 30bf9102ea 5.2.2
Update day tabs colours
2026-08-25 12:23:32 +01:00
fraxle 7c604ae2be 5.2.1
New welcome note & subscribe reminders
2026-08-16 11:37:28 +01:00
fraxle ff8a9950f4 5.1.1
Tidy simple view card spacing and locations.
Make simple view fonts bigger
Fix quick view memory
2026-08-16 09:40:54 +01:00
fraxle df3ecdae35 5.1
Combine and relabel table view switch.
Remove and re-arrange some table columns
Group Pets together
2026-08-16 09:13:15 +01:00
Fraxle dd0ce5b44c 5.0.8
Day tab colour update
Cleanup event banner and add close
2026-08-14 10:00:28 +01:00
fraxle db537411fe Merge branch 'main' of https://git.fraxle.net/gitadmin/sunscope 2026-08-12 13:57:18 +01:00
fraxle 96078f3584 5.0.7
Stronger Extreme Day Tab Shades
2026-08-12 13:57:14 +01:00
Fraxle 15a64501a9 5.0.8
Adaptive week ahead
2026-08-09 17:27:45 +01:00
18 changed files with 846 additions and 302 deletions
+4
View File
@@ -30,3 +30,7 @@ dist/
*.ps1 *.ps1
*.py *.py
secrets.local.php secrets.local.php
/data
/.fraxai
/data
data/tracking.json
+1 -1
View File
@@ -147,7 +147,7 @@
<p class="profile-name"><a href="./columns.html">📊 Column reference →</a></p> <p class="profile-name"><a href="./columns.html">📊 Column reference →</a></p>
<p> <p>
Every column in the forecast table explained in plain language, from Air and Shade through Every column in the forecast table explained in plain language, from Air and Shade through
to Lightning Potential, air quality, pollen, and SunSoak. to lightning warnings, air quality, pollen, and SunSoak.
</p> </p>
</div> </div>
+81 -3
View File
@@ -272,15 +272,27 @@ body {
.event-note-slide { .event-note-slide {
grid-area: 1 / 1; /* stack all slides on top of each other */ grid-area: 1 / 1; /* stack all slides on top of each other */
display: flex; display: flex;
align-items: baseline; flex-direction: column;
flex-wrap: wrap; gap: 2px;
gap: 4px 10px;
min-width: 0; min-width: 0;
opacity: 0; /* only .is-active is visible */ opacity: 0; /* only .is-active is visible */
pointer-events: none; pointer-events: none;
user-select: none; /* keeps hidden copy out of a drag-selection */ user-select: none; /* keeps hidden copy out of a drag-selection */
transition: opacity 0.35s ease; transition: opacity 0.35s ease;
} }
.event-note-header {
display: flex;
align-items: baseline;
gap: 6px;
}
.event-note-body {
display: flex;
align-items: baseline;
gap: 10px;
min-width: 0;
}
.event-note-slide.is-active { .event-note-slide.is-active {
opacity: 1; opacity: 1;
pointer-events: auto; pointer-events: auto;
@@ -351,6 +363,63 @@ body {
transform: scale(1.3); transform: scale(1.3);
} }
/* 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. */
.event-note-close {
display: inline-flex;
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;
background: transparent;
font-family: Manrope, sans-serif;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #9a7d5a;
line-height: 1;
cursor: pointer;
transition: background 0.2s, color 0.2s, border-color 0.2s;
}
.event-note-close:hover {
background: rgba(200, 146, 42, 0.14);
border-color: rgba(200, 146, 42, 0.45);
color: #7a5a1e;
}
.event-note-close:focus-visible {
outline: 2px solid #c8922a;
outline-offset: 1px;
}
.event-note.is-priority .event-note-close {
color: #a06e18;
}
.event-note.is-tinted .event-note-close {
color: #56452c;
}
.event-note.is-tinted .event-note-close:hover {
background: rgba(0, 0, 0, 0.09);
border-color: rgba(0, 0, 0, 0.22);
color: #2a1a08;
}
/* Collapse on the way out — height and vertical spacing animate to zero so
the page closes the gap smoothly instead of snapping upward. */
.event-note.is-closing {
animation: event-note-out 0.26s ease forwards;
pointer-events: none;
}
@keyframes event-note-out {
from { opacity: 1; transform: translateY(0); max-height: 200px; }
to { opacity: 0; transform: translateY(-4px); max-height: 0;
margin-bottom: 0; padding-top: 0; padding-bottom: 0; border-width: 0; }
}
/* Above true-mobile: the nav drops into the flow beneath the note. Its own /* Above true-mobile: the nav drops into the flow beneath the note. Its own
32px side padding is dropped so the links line up with the note and the 32px side padding is dropped so the links line up with the note and the
content rather than sitting 32px inside them. */ content rather than sitting 32px inside them. */
@@ -376,6 +445,15 @@ body {
.event-note-dates { .event-note-dates {
margin-left: 0; /* no room to right-align on a phone */ 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 {
margin: -4px -6px 0 auto;
padding: 8px;
}
.event-note-close-label {
display: none;
}
} }
/* Nav logo — hidden on desktop, shown on mobile */ /* Nav logo — hidden on desktop, shown on mobile */
+56 -64
View File
@@ -765,11 +765,13 @@
.active-profile-row.grp-wind { border-color: rgba(106,34,16,0.30); } .active-profile-row.grp-wind { border-color: rgba(106,34,16,0.30); }
.active-profile-row.grp-ambient { border-color: rgba(60,58,53,0.30); } .active-profile-row.grp-ambient { border-color: rgba(60,58,53,0.30); }
.active-profile-row.grp-surface { border-color: rgba(11,62,114,0.30); } .active-profile-row.grp-surface { border-color: rgba(11,62,114,0.30); }
.active-profile-row.grp-pets { border-color: rgba(10,81,88,0.30); }
.active-profile-row.grp-felt .active-profile-row-value { color: #075c3a; } .active-profile-row.grp-felt .active-profile-row-value { color: #075c3a; }
.active-profile-row.grp-wind .active-profile-row-value { color: #6a2210; } .active-profile-row.grp-wind .active-profile-row-value { color: #6a2210; }
.active-profile-row.grp-ambient .active-profile-row-value { color: #3c3a35; } .active-profile-row.grp-ambient .active-profile-row-value { color: #3c3a35; }
.active-profile-row.grp-surface .active-profile-row-value { color: #0b3e72; } .active-profile-row.grp-surface .active-profile-row-value { color: #0b3e72; }
.active-profile-row.grp-pets .active-profile-row-value { color: #0a5158; }
/* Vertical rule separating general-use profiles from technical ones */ /* Vertical rule separating general-use profiles from technical ones */
.profile-divider { .profile-divider {
@@ -1085,6 +1087,7 @@
.col-toggle.grp-felt { background: rgba(223,243,236,0.50); color: #075c3a; border-color: rgba(7,92,58,0.35); } .col-toggle.grp-felt { background: rgba(223,243,236,0.50); color: #075c3a; border-color: rgba(7,92,58,0.35); }
.col-toggle.grp-surface { background: rgba(221,234,248,0.50); color: #0b3e72; border-color: rgba(11,62,114,0.30); } .col-toggle.grp-surface { background: rgba(221,234,248,0.50); color: #0b3e72; border-color: rgba(11,62,114,0.30); }
.col-toggle.grp-pets { background: rgba(214,238,240,0.50); color: #0a5158; border-color: rgba(10,81,88,0.30); }
.col-toggle.grp-ambient { background: rgba(236,234,227,0.50); color: #3c3a35; border-color: rgba(60,58,53,0.30); } .col-toggle.grp-ambient { background: rgba(236,234,227,0.50); color: #3c3a35; border-color: rgba(60,58,53,0.30); }
.col-toggle.grp-precip { background: rgba(247,228,238,0.50); color: #6b1e3a; border-color: rgba(107,30,58,0.30); } .col-toggle.grp-precip { background: rgba(247,228,238,0.50); color: #6b1e3a; border-color: rgba(107,30,58,0.30); }
.col-toggle.grp-sky { background: rgba(234,232,252,0.50); color: #352e7a; border-color: rgba(53,46,122,0.30); } .col-toggle.grp-sky { background: rgba(234,232,252,0.50); color: #352e7a; border-color: rgba(53,46,122,0.30); }
@@ -1094,6 +1097,7 @@
.col-toggle.grp-felt:hover { background: rgba(213,233,226,0.80); color: #075c3a; border-color: rgba(7,92,58,0.55); } .col-toggle.grp-felt:hover { background: rgba(213,233,226,0.80); color: #075c3a; border-color: rgba(7,92,58,0.55); }
.col-toggle.grp-surface:hover { background: rgba(211,224,238,0.80); color: #0b3e72; border-color: rgba(11,62,114,0.50); } .col-toggle.grp-surface:hover { background: rgba(211,224,238,0.80); color: #0b3e72; border-color: rgba(11,62,114,0.50); }
.col-toggle.grp-pets:hover { background: rgba(204,228,230,0.80); color: #0a5158; border-color: rgba(10,81,88,0.50); }
.col-toggle.grp-ambient:hover { background: rgba(226,224,217,0.80); color: #3c3a35; border-color: rgba(60,58,53,0.50); } .col-toggle.grp-ambient:hover { background: rgba(226,224,217,0.80); color: #3c3a35; border-color: rgba(60,58,53,0.50); }
.col-toggle.grp-precip:hover { background: rgba(237,218,228,0.80); color: #6b1e3a; border-color: rgba(107,30,58,0.50); } .col-toggle.grp-precip:hover { background: rgba(237,218,228,0.80); color: #6b1e3a; border-color: rgba(107,30,58,0.50); }
.col-toggle.grp-sky:hover { background: rgba(224,222,242,0.80); color: #352e7a; border-color: rgba(53,46,122,0.50); } .col-toggle.grp-sky:hover { background: rgba(224,222,242,0.80); color: #352e7a; border-color: rgba(53,46,122,0.50); }
@@ -1103,6 +1107,7 @@
.col-toggle.grp-felt.on { background: #0f9e63; border-color: #0f9e63; color: #fff; } .col-toggle.grp-felt.on { background: #0f9e63; border-color: #0f9e63; color: #fff; }
.col-toggle.grp-surface.on { background: #2672c8; border-color: #2672c8; color: #fff; } .col-toggle.grp-surface.on { background: #2672c8; border-color: #2672c8; color: #fff; }
.col-toggle.grp-pets.on { background: #17868f; border-color: #17868f; color: #fff; }
.col-toggle.grp-ambient.on { background: #7a7870; border-color: #7a7870; color: #fff; } .col-toggle.grp-ambient.on { background: #7a7870; border-color: #7a7870; color: #fff; }
.col-toggle.grp-precip.on { background: #b03060; border-color: #b03060; color: #fff; } .col-toggle.grp-precip.on { background: #b03060; border-color: #b03060; color: #fff; }
.col-toggle.grp-sky.on { background: #6058c8; border-color: #6058c8; color: #fff; } .col-toggle.grp-sky.on { background: #6058c8; border-color: #6058c8; color: #fff; }
@@ -1112,6 +1117,7 @@
.col-toggle.grp-felt.on:hover { background: #0c7d4e; border-color: #0c7d4e; color: #fff; } .col-toggle.grp-felt.on:hover { background: #0c7d4e; border-color: #0c7d4e; color: #fff; }
.col-toggle.grp-surface.on:hover { background: #1e5aa0; border-color: #1e5aa0; color: #fff; } .col-toggle.grp-surface.on:hover { background: #1e5aa0; border-color: #1e5aa0; color: #fff; }
.col-toggle.grp-pets.on:hover { background: #116b73; border-color: #116b73; color: #fff; }
.col-toggle.grp-ambient.on:hover { background: #5e5c56; border-color: #5e5c56; color: #fff; } .col-toggle.grp-ambient.on:hover { background: #5e5c56; border-color: #5e5c56; color: #fff; }
.col-toggle.grp-precip.on:hover { background: #8c264c; border-color: #8c264c; color: #fff; } .col-toggle.grp-precip.on:hover { background: #8c264c; border-color: #8c264c; color: #fff; }
.col-toggle.grp-sky.on:hover { background: #4c48a8; border-color: #4c48a8; color: #fff; } .col-toggle.grp-sky.on:hover { background: #4c48a8; border-color: #4c48a8; color: #fff; }
@@ -1816,6 +1822,7 @@
.utci-table th.grp-felt { background: rgba(223,243,236,0.50); color: #075c3a; } .utci-table th.grp-felt { background: rgba(223,243,236,0.50); color: #075c3a; }
.utci-table th.grp-surface { background: rgba(221,234,248,0.50); color: #0b3e72; } .utci-table th.grp-surface { background: rgba(221,234,248,0.50); color: #0b3e72; }
.utci-table th.grp-pets { background: rgba(214,238,240,0.50); color: #0a5158; }
.utci-table th.grp-ambient { background: rgba(236,234,227,0.50); color: #3c3a35; } .utci-table th.grp-ambient { background: rgba(236,234,227,0.50); color: #3c3a35; }
.utci-table th.grp-precip { background: rgba(247,228,238,0.50); color: #6b1e3a; } .utci-table th.grp-precip { background: rgba(247,228,238,0.50); color: #6b1e3a; }
.utci-table th.grp-sky { background: rgba(234,232,252,0.50); color: #352e7a; } .utci-table th.grp-sky { background: rgba(234,232,252,0.50); color: #352e7a; }
@@ -1825,6 +1832,7 @@
.utci-table th.grp-felt.col-info-th:hover { background: rgba(213,233,226,0.80); } .utci-table th.grp-felt.col-info-th:hover { background: rgba(213,233,226,0.80); }
.utci-table th.grp-surface.col-info-th:hover { background: rgba(211,224,238,0.80); } .utci-table th.grp-surface.col-info-th:hover { background: rgba(211,224,238,0.80); }
.utci-table th.grp-pets.col-info-th:hover { background: rgba(204,228,230,0.80); }
.utci-table th.grp-ambient.col-info-th:hover { background: rgba(226,224,217,0.80); } .utci-table th.grp-ambient.col-info-th:hover { background: rgba(226,224,217,0.80); }
.utci-table th.grp-precip.col-info-th:hover { background: rgba(237,218,228,0.80); } .utci-table th.grp-precip.col-info-th:hover { background: rgba(237,218,228,0.80); }
.utci-table th.grp-sky.col-info-th:hover { background: rgba(224,222,242,0.80); } .utci-table th.grp-sky.col-info-th:hover { background: rgba(224,222,242,0.80); }
@@ -1834,6 +1842,7 @@
.utci-table th.grp-felt .col-unit { color: #0f9e63; } .utci-table th.grp-felt .col-unit { color: #0f9e63; }
.utci-table th.grp-surface .col-unit { color: #2672c8; } .utci-table th.grp-surface .col-unit { color: #2672c8; }
.utci-table th.grp-pets .col-unit { color: #17868f; }
.utci-table th.grp-ambient .col-unit { color: #7a7870; } .utci-table th.grp-ambient .col-unit { color: #7a7870; }
.utci-table th.grp-precip .col-unit { color: #b03060; } .utci-table th.grp-precip .col-unit { color: #b03060; }
.utci-table th.grp-sky .col-unit { color: #6058c8; } .utci-table th.grp-sky .col-unit { color: #6058c8; }
@@ -1859,6 +1868,7 @@
.utci-table .grp-label-row th.grp-felt { background: rgba(223,243,236,0.50); border-left: 2px solid rgba(74,50,24,0.18); border-bottom: 1px solid rgba(74,50,24,0.18);} .utci-table .grp-label-row th.grp-felt { background: rgba(223,243,236,0.50); border-left: 2px solid rgba(74,50,24,0.18); border-bottom: 1px solid rgba(74,50,24,0.18);}
.utci-table .grp-label-row th.grp-surface { background: rgba(221,234,248,0.50); border-left: 2px solid rgba(74,50,24,0.18); border-bottom: 1px solid rgba(74,50,24,0.18);} .utci-table .grp-label-row th.grp-surface { background: rgba(221,234,248,0.50); border-left: 2px solid rgba(74,50,24,0.18); border-bottom: 1px solid rgba(74,50,24,0.18);}
.utci-table .grp-label-row th.grp-pets { background: rgba(214,238,240,0.50); border-left: 2px solid rgba(74,50,24,0.18); border-bottom: 1px solid rgba(74,50,24,0.18);}
.utci-table .grp-label-row th.grp-ambient { background: rgba(236,234,227,0.50); border-left: 2px solid rgba(74,50,24,0.18); border-bottom: 1px solid rgba(74,50,24,0.18);} .utci-table .grp-label-row th.grp-ambient { background: rgba(236,234,227,0.50); border-left: 2px solid rgba(74,50,24,0.18); border-bottom: 1px solid rgba(74,50,24,0.18);}
.utci-table .grp-label-row th.grp-precip { background: rgba(247,228,238,0.50); border-left: 2px solid rgba(74,50,24,0.18); border-bottom: 1px solid rgba(74,50,24,0.18);} .utci-table .grp-label-row th.grp-precip { background: rgba(247,228,238,0.50); border-left: 2px solid rgba(74,50,24,0.18); border-bottom: 1px solid rgba(74,50,24,0.18);}
.utci-table .grp-label-row th.grp-sky { background: rgba(234,232,252,0.50); border-left: 2px solid rgba(74,50,24,0.18); border-bottom: 1px solid rgba(74,50,24,0.18);} .utci-table .grp-label-row th.grp-sky { background: rgba(234,232,252,0.50); border-left: 2px solid rgba(74,50,24,0.18); border-bottom: 1px solid rgba(74,50,24,0.18);}
@@ -2201,8 +2211,14 @@
.fvt-btn:first-child { .fvt-btn:first-child {
border-radius: 3px 0 0 3px; border-radius: 3px 0 0 3px;
} }
.fvt-btn:last-child { /* Only the outer corners are rounded; every segment after the first drops
its left border so neighbours share one 1.5px rule rather than stacking
two. Keyed off the sibling rather than :last-child so it still holds with
three segments (Quick / Detailed / Table). */
.fvt-btn + .fvt-btn {
border-left: none; border-left: none;
}
.fvt-btn:last-child {
border-radius: 0 3px 3px 0; border-radius: 0 3px 3px 0;
} }
.fvt-btn:hover { .fvt-btn:hover {
@@ -2394,15 +2410,23 @@
} }
.fsc-card { .fsc-card {
/* One fixed width at every hour interval — a 4h card is not a wider card,
just a card with more space either side of it. It spans 2*bucket grid
tracks (see fscPlot in app.js) and is centred within that span, so it
stays over its landing hour and keeps lining up with the curve below
however wide the span gets. Must match FSC_CARD_W in app.js. */
width: 92px;
/* Kept alongside the fixed width: .fsc-feel is nowrap, so a long band label
("Very Strong Heat Stress") gives the card a ~146px min-content. Left to
the automatic minimum that can push the tracks it spans wider than their
neighbours', and the grid stops being uniform — which is the one thing
the connector-line maths (hourAt in app.js) assumes. */
min-width: 0; min-width: 0;
justify-self: center;
text-align: center; text-align: center;
border: none; border: none;
background: transparent; background: transparent;
padding: 10px 8px 12px; padding: 10px 8px 12px;
/* 2px each side = 4px between neighbouring cards. Done with margin rather
than a grid gap so the cards stay centred on their landing hour and keep
lining up with the curve below. */
margin: 0 2px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@@ -2417,11 +2441,14 @@
z-index: 1; z-index: 1;
} }
/* Same display serif as the hour cell in the Detailed and Table views
(.utci-time) — the time reads as the same thing in all three. Now the
cards are a fixed 92px this can carry the full 16px too. */
.fsc-time { .fsc-time {
font-family: JetBrains Mono, monospace; font-family: Fraunces, serif;
font-size: 12px; font-size: 16px;
font-weight: 700; font-weight: 500;
color: #4a3218; color: #1e1208;
line-height: 1; line-height: 1;
} }
.fsc-card--now .fsc-time { .fsc-card--now .fsc-time {
@@ -2466,11 +2493,14 @@
} }
.fsc-meta-row { .fsc-meta-row {
font-family: Manrope, sans-serif; font-family: Manrope, sans-serif;
font-size: 10px; font-size: 12px;
font-weight: 600; font-weight: 600;
line-height: 1.2; line-height: 1.25;
color: #6b5230; color: #6b5230;
opacity: 0.8; /* Was 0.8 at 10px, which read as grey-on-cream mush. The wider cards have
the room, and these three lines are the only numbers on the card besides
the temperature — they should be legible, not decorative. */
opacity: 0.9;
} }
.fsc-card--now .fsc-meta-row { opacity: 1; color: #4a3218; } .fsc-card--now .fsc-meta-row { opacity: 1; color: #4a3218; }
.fsc-meta-rain { color: #2a6a90; } .fsc-meta-rain { color: #2a6a90; }
@@ -2522,7 +2552,9 @@
@media (max-width: 600px) { @media (max-width: 600px) {
.fsc-card { .fsc-card {
min-width: 55px; /* Width holds at 92px on mobile too — the row scrolls horizontally, so
narrowing the cards buys nothing and costs legibility. Only the inner
padding tightens. */
padding: 8px 4px 10px; padding: 8px 4px 10px;
gap: 5px; gap: 5px;
} }
@@ -2650,6 +2682,7 @@
two different backgrounds is what makes them drift apart. */ two different backgrounds is what makes them drift apart. */
.utci-table-rotated .grp-felt { --grp-tint: rgba(223,243,236,0.50); } .utci-table-rotated .grp-felt { --grp-tint: rgba(223,243,236,0.50); }
.utci-table-rotated .grp-surface { --grp-tint: rgba(221,234,248,0.50); } .utci-table-rotated .grp-surface { --grp-tint: rgba(221,234,248,0.50); }
.utci-table-rotated .grp-pets { --grp-tint: rgba(214,238,240,0.50); }
.utci-table-rotated .grp-ambient { --grp-tint: rgba(236,234,227,0.50); } .utci-table-rotated .grp-ambient { --grp-tint: rgba(236,234,227,0.50); }
.utci-table-rotated .grp-precip { --grp-tint: rgba(247,228,238,0.50); } .utci-table-rotated .grp-precip { --grp-tint: rgba(247,228,238,0.50); }
.utci-table-rotated .grp-sky { --grp-tint: rgba(234,232,252,0.50); } .utci-table-rotated .grp-sky { --grp-tint: rgba(234,232,252,0.50); }
@@ -2734,11 +2767,11 @@
} }
@media (max-width: 640px) { @media (max-width: 640px) {
/* Icon-only on narrow screens — the toolbar is already tight. */ /* Tighter segments on narrow screens — the toolbar is already tight,
.table-rotate-btn { and there are three of them now. */
font-size: 0; .fvt-btn {
gap: 0; padding: 6px 6px;
padding: 6px 7px; gap: 2px;
} }
} }
@@ -2769,58 +2802,17 @@
} }
/* ── TOOLBAR BUTTON HEIGHTS ─────────────────────────────────────────── /* ── TOOLBAR BUTTON HEIGHTS ───────────────────────────────────────────
Quick/Detailed, Rotate and Edit columns sit on one row and have to The view segments and Edit columns sit on one row and have to agree on
agree on height. They didn't: the first two set line-height:1 while height. They didn't: the segments set line-height:1 while Edit columns
Edit columns leaves it at normal, so the same 6px padding produced a leaves it at normal, so the same 6px padding produced a box ~3px
box ~3px taller. Pinning one line-height and one min-height across all taller. Pinning one line-height and one min-height across both settles
three settles it regardless of font-size differences. */ it regardless of font-size differences. */
.fvt-btn, .fvt-btn,
.table-rotate-btn,
.col-toggles-edit-btn { .col-toggles-edit-btn {
min-height: 29px; min-height: 29px;
line-height: 1; line-height: 1;
} }
/* ── ROTATE BUTTON ────────────────────────────────────────────────────
Sits beside the Quick/Detailed view toggle and only while the detailed
table is showing. Deliberately mirrors .fvt-btn — same type, padding,
border and states — so it reads as part of that control group rather
than a stray button. Standalone, so it keeps a radius on all corners
where .fvt-btn splits its radius across the segmented pair. */
.table-rotate-btn {
display: inline-flex;
align-items: center;
gap: 4px;
font-family: Manrope, sans-serif;
font-size: 11px;
font-weight: 700;
line-height: 1;
padding: 6px 8px;
cursor: pointer;
border: 1.5px solid #c9b08a;
border-radius: 3px;
background: #f5edd6;
color: #6b4228;
transition: background 0.12s, border-color 0.12s, color 0.12s;
}
.table-rotate-btn:hover {
border-color: #c8922a;
background: #fef3dc;
}
.table-rotate-btn.on {
background: #c8922a;
border-color: #c8922a;
color: #fff;
}
/* The icon turns with the state it describes — flat when hours run down
the side, quarter-turned when they run along the top. */
.table-rotate-btn .fvt-btn-icon {
transition: transform 0.18s ease;
}
.table-rotate-btn.on .fvt-btn-icon {
transform: rotate(90deg);
}
/* ── MOBILE ───────────────────────────────────────────────────────── */ /* ── MOBILE ───────────────────────────────────────────────────────── */
@media (max-width: 640px) { @media (max-width: 640px) {
.utci-rotated-scroll { .utci-rotated-scroll {
+89
View File
@@ -234,6 +234,7 @@
.cs-btn.grp-felt { background: rgba(223,243,236,0.50); color: #075c3a; border-color: rgba(7,92,58,0.35); } .cs-btn.grp-felt { background: rgba(223,243,236,0.50); color: #075c3a; border-color: rgba(7,92,58,0.35); }
.cs-btn.grp-surface { background: rgba(221,234,248,0.50); color: #0b3e72; border-color: rgba(11,62,114,0.30); } .cs-btn.grp-surface { background: rgba(221,234,248,0.50); color: #0b3e72; border-color: rgba(11,62,114,0.30); }
.cs-btn.grp-pets { background: rgba(214,238,240,0.50); color: #0a5158; border-color: rgba(10,81,88,0.30); }
.cs-btn.grp-ambient { background: rgba(236,234,227,0.50); color: #3c3a35; border-color: rgba(60,58,53,0.30); } .cs-btn.grp-ambient { background: rgba(236,234,227,0.50); color: #3c3a35; border-color: rgba(60,58,53,0.30); }
.cs-btn.grp-precip { background: rgba(247,228,238,0.50); color: #6b1e3a; border-color: rgba(107,30,58,0.30); } .cs-btn.grp-precip { background: rgba(247,228,238,0.50); color: #6b1e3a; border-color: rgba(107,30,58,0.30); }
.cs-btn.grp-sky { background: rgba(234,232,252,0.50); color: #352e7a; border-color: rgba(53,46,122,0.30); } .cs-btn.grp-sky { background: rgba(234,232,252,0.50); color: #352e7a; border-color: rgba(53,46,122,0.30); }
@@ -243,6 +244,7 @@
.cs-btn.grp-felt:hover { background: rgba(213,233,226,0.80); color: #075c3a; border-color: rgba(7,92,58,0.55); } .cs-btn.grp-felt:hover { background: rgba(213,233,226,0.80); color: #075c3a; border-color: rgba(7,92,58,0.55); }
.cs-btn.grp-surface:hover { background: rgba(211,224,238,0.80); color: #0b3e72; border-color: rgba(11,62,114,0.50); } .cs-btn.grp-surface:hover { background: rgba(211,224,238,0.80); color: #0b3e72; border-color: rgba(11,62,114,0.50); }
.cs-btn.grp-pets:hover { background: rgba(204,228,230,0.80); color: #0a5158; border-color: rgba(10,81,88,0.50); }
.cs-btn.grp-ambient:hover { background: rgba(226,224,217,0.80); color: #3c3a35; border-color: rgba(60,58,53,0.50); } .cs-btn.grp-ambient:hover { background: rgba(226,224,217,0.80); color: #3c3a35; border-color: rgba(60,58,53,0.50); }
.cs-btn.grp-precip:hover { background: rgba(237,218,228,0.80); color: #6b1e3a; border-color: rgba(107,30,58,0.50); } .cs-btn.grp-precip:hover { background: rgba(237,218,228,0.80); color: #6b1e3a; border-color: rgba(107,30,58,0.50); }
.cs-btn.grp-sky:hover { background: rgba(224,222,242,0.80); color: #352e7a; border-color: rgba(53,46,122,0.50); } .cs-btn.grp-sky:hover { background: rgba(224,222,242,0.80); color: #352e7a; border-color: rgba(53,46,122,0.50); }
@@ -253,6 +255,7 @@
/* Dropdown buttons - lighter palette to match toggle pills */ /* Dropdown buttons - lighter palette to match toggle pills */
.cs-btn.grp-felt.on { background: #2ab578; border-color: #2ab578; color: #fff; } .cs-btn.grp-felt.on { background: #2ab578; border-color: #2ab578; color: #fff; }
.cs-btn.grp-surface.on { background: #4088d4; border-color: #4088d4; color: #fff; } .cs-btn.grp-surface.on { background: #4088d4; border-color: #4088d4; color: #fff; }
.cs-btn.grp-pets.on { background: #34a0a8; border-color: #34a0a8; color: #fff; }
.cs-btn.grp-ambient.on { background: #929088; border-color: #929088; color: #fff; } .cs-btn.grp-ambient.on { background: #929088; border-color: #929088; color: #fff; }
.cs-btn.grp-precip.on { background: #c24e6c; border-color: #c24e6c; color: #fff; } .cs-btn.grp-precip.on { background: #c24e6c; border-color: #c24e6c; color: #fff; }
.cs-btn.grp-sky.on { background: #786ec8; border-color: #786ec8; color: #fff; } .cs-btn.grp-sky.on { background: #786ec8; border-color: #786ec8; color: #fff; }
@@ -262,6 +265,7 @@
.cs-btn.grp-felt.on:hover { background: #1e9e64; border-color: #1e9e64; color: #fff; } .cs-btn.grp-felt.on:hover { background: #1e9e64; border-color: #1e9e64; color: #fff; }
.cs-btn.grp-surface.on:hover { background: #2e72bc; border-color: #2e72bc; color: #fff; } .cs-btn.grp-surface.on:hover { background: #2e72bc; border-color: #2e72bc; color: #fff; }
.cs-btn.grp-pets.on:hover { background: #17868f; border-color: #17868f; color: #fff; }
.cs-btn.grp-ambient.on:hover { background: #7a7870; border-color: #7a7870; color: #fff; } .cs-btn.grp-ambient.on:hover { background: #7a7870; border-color: #7a7870; color: #fff; }
.cs-btn.grp-precip.on:hover { background: #a83c5a; border-color: #a83c5a; color: #fff; } .cs-btn.grp-precip.on:hover { background: #a83c5a; border-color: #a83c5a; color: #fff; }
.cs-btn.grp-sky.on:hover { background: #6258b0; border-color: #6258b0; color: #fff; } .cs-btn.grp-sky.on:hover { background: #6258b0; border-color: #6258b0; color: #fff; }
@@ -272,6 +276,7 @@
/* Toggle pills - lighter palette */ /* Toggle pills - lighter palette */
.col-toggle.grp-felt.on { background: #2ab578; border-color: #2ab578; color: #fff; } .col-toggle.grp-felt.on { background: #2ab578; border-color: #2ab578; color: #fff; }
.col-toggle.grp-surface.on { background: #4088d4; border-color: #4088d4; color: #fff; } .col-toggle.grp-surface.on { background: #4088d4; border-color: #4088d4; color: #fff; }
.col-toggle.grp-pets.on { background: #34a0a8; border-color: #34a0a8; color: #fff; }
.col-toggle.grp-ambient.on { background: #929088; border-color: #929088; color: #fff; } .col-toggle.grp-ambient.on { background: #929088; border-color: #929088; color: #fff; }
.col-toggle.grp-precip.on { background: #c24e6c; border-color: #c24e6c; color: #fff; } .col-toggle.grp-precip.on { background: #c24e6c; border-color: #c24e6c; color: #fff; }
.col-toggle.grp-sky.on { background: #786ec8; border-color: #786ec8; color: #fff; } .col-toggle.grp-sky.on { background: #786ec8; border-color: #786ec8; color: #fff; }
@@ -281,6 +286,7 @@
.col-toggle.grp-felt.on:hover { background: #1e9e64; border-color: #1e9e64; color: #fff; } .col-toggle.grp-felt.on:hover { background: #1e9e64; border-color: #1e9e64; color: #fff; }
.col-toggle.grp-surface.on:hover { background: #2e72bc; border-color: #2e72bc; color: #fff; } .col-toggle.grp-surface.on:hover { background: #2e72bc; border-color: #2e72bc; color: #fff; }
.col-toggle.grp-pets.on:hover { background: #17868f; border-color: #17868f; color: #fff; }
.col-toggle.grp-ambient.on:hover { background: #7a7870; border-color: #7a7870; color: #fff; } .col-toggle.grp-ambient.on:hover { background: #7a7870; border-color: #7a7870; color: #fff; }
.col-toggle.grp-precip.on:hover { background: #a83c5a; border-color: #a83c5a; color: #fff; } .col-toggle.grp-precip.on:hover { background: #a83c5a; border-color: #a83c5a; color: #fff; }
.col-toggle.grp-sky.on:hover { background: #6258b0; border-color: #6258b0; color: #fff; } .col-toggle.grp-sky.on:hover { background: #6258b0; border-color: #6258b0; color: #fff; }
@@ -893,8 +899,67 @@
background: #f0e4c4; background: #f0e4c4;
border-radius: 8px; border-radius: 8px;
padding: 10px 14px; padding: 10px 14px;
margin: 0 0 10px;
}
.welcome-remember strong {
color: #c8922a;
}
.welcome-note {
font-family: Manrope, sans-serif;
font-size: 0.76rem;
line-height: 1.35;
color: #8a7250;
text-align: center;
margin: 0 0 14px;
}
/* Weekly free-tier nudge (WeeklyNudge.js) - reuses the .welcome-* card. */
.nudge-fact {
text-align: center;
margin: 0 0 18px; margin: 0 0 18px;
} }
.nudge-fact-icon {
display: block;
font-size: 2.2rem;
line-height: 1;
margin-bottom: 8px;
}
.nudge-fact .welcome-title {
font-size: 1.25rem;
margin-bottom: 8px;
}
.nudge-fact-body {
font-family: Manrope, sans-serif;
font-size: 0.88rem;
line-height: 1.5;
color: #4a3420;
margin: 0;
}
.nudge-links {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
}
.nudge-links a,
.nudge-links button {
font-family: Manrope, sans-serif;
font-weight: 700;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.07em;
text-decoration: none;
background: none;
border: none;
padding: 0 0 1px;
cursor: pointer;
color: #c8922a;
border-bottom: 1px solid rgba(200, 146, 42, 0.4);
}
.nudge-links .nudge-dismiss {
color: #b09870;
border-bottom-color: rgba(176, 152, 112, 0.4);
}
.welcome-steps { .welcome-steps {
list-style: none; list-style: none;
margin: 0 0 22px; margin: 0 0 22px;
@@ -996,6 +1061,30 @@
.welcome-step-num { .welcome-step-num {
font-size: 0.75rem; font-size: 0.75rem;
} }
.welcome-steps {
gap: 13px;
margin-bottom: 16px;
}
.welcome-intro {
margin-bottom: 16px;
}
.welcome-remember {
font-size: 0.78rem;
padding: 8px 12px;
}
.welcome-note {
font-size: 0.72rem;
margin-bottom: 12px;
}
.nudge-fact-icon {
font-size: 1.8rem;
}
.nudge-fact .welcome-title {
font-size: 1.05rem;
}
.nudge-fact-body {
font-size: 0.82rem;
}
} }
.utci-fetch-time { .utci-fetch-time {
text-align: right; text-align: right;
+126 -58
View File
@@ -39,8 +39,9 @@ import { DayTabs } from './components/DayTabs.js';
import { ConfigPanel } from './components/ConfigPanel.js'; import { ConfigPanel } from './components/ConfigPanel.js';
import { WelcomeModal } from './components/WelcomeModal.js'; import { WelcomeModal } from './components/WelcomeModal.js';
import { RestoreModal } from './components/RestoreModal.js'; import { RestoreModal } from './components/RestoreModal.js';
import { WeeklyNudge } from './components/WeeklyNudge.js';
import { buildColumnDefs, airTempRgb, petAirTempRgb } from './tableColumns.js'; import { buildColumnDefs, airTempRgb, petAirTempRgb } from './tableColumns.js';
import { computeWhyFeelsLike, computeGlanceSummary, computeBestDay, bestDaysLabel } from './compute.js'; import { computeWhyFeelsLike, computeGlanceSummary, computeBestDay, bestDaysLabel, bestDaysHint } from './compute.js';
import { solarElevationDeg } from './physics.js'; import { solarElevationDeg } from './physics.js';
import { exportDayXls } from './export.js'; import { exportDayXls } from './export.js';
@@ -215,6 +216,7 @@ export function UTCIForecast() {
visibleCols, setVisibleCols, toggleCol, visibleCols, setVisibleCols, toggleCol,
showDecimals, toggleShowDecimals, showDecimals, toggleShowDecimals,
welcomeOpen, closeWelcome, openWelcome, welcomeOpen, closeWelcome, openWelcome,
weeklyNudgeOpen, closeWeeklyNudge,
restoreOpen, openRestore, closeRestore, restoreOpen, openRestore, closeRestore,
panelOpen, openPanel, closePanel, panelOpen, openPanel, closePanel,
showUnits, toggleShowUnits, showUnits, toggleShowUnits,
@@ -233,7 +235,7 @@ export function UTCIForecast() {
indoorMode, setIndoorMode, indoorMode, setIndoorMode,
pollenType, setPollenTypeAndSave, pollenType, setPollenTypeAndSave,
utciEnv, setUtciEnv, utciEnv, setUtciEnv,
tableRotated, toggleTableRotated, tableRotated, setTableRotated,
headStickyRef, headTrackRef, headTableRef, headStickyRef, headTrackRef, headTableRef,
bodyScrollRef, bodyTableRef, tableWrapRef, bodyScrollRef, bodyTableRef, tableWrapRef,
colPopup, colPopupRef, colPopup, colPopupRef,
@@ -276,6 +278,31 @@ export function UTCIForecast() {
setTimeout(() => { setNoteIndex(next); setNoteFading(false); }, NOTE_FADE_MS); setTimeout(() => { setNoteIndex(next); setNoteFading(false); }, NOTE_FADE_MS);
}; };
// ── Dismissal ────────────────────────────────────────────────────────
// Closing the strip hides it for the rest of the browser session, but only
// for the events that were showing at the time: the dismissal is stored
// against a signature of the active ids, so a new event (or a fresh weather
// warning) brings the strip back rather than staying silently suppressed.
const NOTE_CLOSE_KEY = 'sunscope_event_note_closed';
const NOTE_CLOSE_MS = 260;
const noteSig = activeEvents.map(e => e.id).sort().join('|');
const [noteClosedSig, setNoteClosedSig] = useState(() => {
try { return sessionStorage.getItem(NOTE_CLOSE_KEY) || ''; } catch (e) { return ''; }
});
// Kept mounted for the collapse animation, then dropped.
const [noteClosing, setNoteClosing] = useState(false);
const noteDismissed = !!noteSig && noteSig === noteClosedSig;
const noteClose = () => {
if (noteClosing) return;
setNoteClosing(true);
setTimeout(() => {
try { sessionStorage.setItem(NOTE_CLOSE_KEY, noteSig); } catch (e) { /* ignore */ }
setNoteClosedSig(noteSig);
setNoteClosing(false);
}, NOTE_CLOSE_MS);
};
// Keep the index in range if the event list shrinks between forecasts. // Keep the index in range if the event list shrinks between forecasts.
useEffect(() => { useEffect(() => {
if (noteIndex >= activeEvents.length) setNoteIndex(0); if (noteIndex >= activeEvents.length) setNoteIndex(0);
@@ -346,12 +373,18 @@ export function UTCIForecast() {
}, []); }, []);
const [simpleTemp, setSimpleTemp] = useState('utciAdj'); const [simpleTemp, setSimpleTemp] = useState('utciAdj');
// Skips the very first run so a page refresh keeps the saved Simple /
// Table / Detailed choice — the profile-driven jump to the table below
// is meant for an actual profile *switch*, not for restoring state.
const profileViewFirstRun = useRef(true);
useEffect(() => { useEffect(() => {
if (activeProfile === 'vehicle' || (activeProfile === 'outdoors' && outdoorsVariant === 'driver')) setSimpleTemp('vehicleT'); if (activeProfile === 'vehicle' || (activeProfile === 'outdoors' && outdoorsVariant === 'driver')) setSimpleTemp('vehicleT');
else if (activeProfile === 'home' || (activeProfile === 'outdoors' && outdoorsVariant === 'office')) setSimpleTemp(indoorManaged ? 'managedT' : 'indoorT'); else if (activeProfile === 'home' || (activeProfile === 'outdoors' && outdoorsVariant === 'office')) setSimpleTemp(indoorManaged ? 'managedT' : 'indoorT');
else if (activeProfile === 'pets') setSimpleTemp('furSurfaceT'); else if (activeProfile === 'pets') setSimpleTemp('furSurfaceT');
else setSimpleTemp('utciAdj'); else setSimpleTemp('utciAdj');
if (['alltemps', 'showall', 'custom', 'farming', 'construction', 'market', 'windowcleaning', 'office'].includes(activeProfile)) { if (profileViewFirstRun.current) {
profileViewFirstRun.current = false;
} else if (['alltemps', 'showall', 'custom', 'farming', 'construction', 'market', 'windowcleaning', 'office'].includes(activeProfile)) {
setForecastView('table'); setForecastView('table');
} }
}, [activeProfile, outdoorsVariant]); }, [activeProfile, outdoorsVariant]);
@@ -405,10 +438,11 @@ export function UTCIForecast() {
// Columns that mark the start of a logical group - used to draw a faint // Columns that mark the start of a logical group - used to draw a faint
// vertical border separating groups in the forecast table. // vertical border separating groups in the forecast table.
const GROUP_ORDER = { const GROUP_ORDER = {
felt: ['utciP', 'shadeT', 'vehicleT', 'indoorT', 'managedT', 'burn', 'utci', 'delta', 'tmrt'], felt: ['utciP', 'shadeT', 'vehicleT', 'indoorT', 'managedT', 'utci', 'burn'],
surface: ['concreteT', 'soilT', 'soilT6', 'soilM', 'furSurfaceT', 'pawT'], surface: ['concreteT', 'soilT', 'soilT6', 'soilM'],
ambient: ['petShadeT', 'petHomeT', 'air', 'rh', 'dew'], pets: ['furSurfaceT', 'pawT', 'petShadeT', 'petHomeT'],
precip: ['precip', 'precipProb', 'lightning'], ambient: ['air', 'rh', 'dew'],
precip: ['precip', 'precipProb'],
sky: ['cloud', 'vis'], sky: ['cloud', 'vis'],
wind: ['wind', 'dir'], wind: ['wind', 'dir'],
airqual: ['aqi', 'pollen'], airqual: ['aqi', 'pollen'],
@@ -439,7 +473,7 @@ export function UTCIForecast() {
}; };
// Display names for the column groups — used for the spanning labels // Display names for the column groups — used for the spanning labels
// above the columns normally, and for the divider rows when rotated. // above the columns normally, and for the divider rows when rotated.
const GROUP_LABELS = { felt: 'Felt', surface: 'Surface', ambient: 'Ambient', precip: 'Precip', sky: 'Sky', wind: 'Wind', airqual: 'Air quality', solar: 'Solar' }; const GROUP_LABELS = { felt: 'Felt', surface: 'Surface', pets: 'Pets', ambient: 'Ambient', precip: 'Precip', sky: 'Sky', wind: 'Wind', airqual: 'Air quality', solar: 'Solar' };
// Builds the group label row above the column headers. // Builds the group label row above the column headers.
// Each visible group gets one spanning cell; groups with no visible // Each visible group gets one spanning cell; groups with no visible
// columns are skipped entirely. Hour always gets a blank lead cell. // columns are skipped entirely. Hour always gets a blank lead cell.
@@ -534,7 +568,9 @@ export function UTCIForecast() {
); );
// Week-scoped, so it is kept out of the day-scoped "At a glance" panel and // Week-scoped, so it is kept out of the day-scoped "At a glance" panel and
// rendered in its own "The Week Ahead" box beneath it. // rendered in its own "The Week Ahead" box beneath it.
const weekAhead = computeBestDay(days, nowLocalISO); // Scored on the profile's own main field, so the panel answers "when should
// I drive / when is the house bearable", not always "when is it nice out".
const weekAhead = computeBestDay(days, nowLocalISO, activeProfile, outdoorsVariant);
// Human-readable date for the "Day at a glance" heading, e.g. "Mon 2nd June 2026". // Human-readable date for the "Day at a glance" heading, e.g. "Mon 2nd June 2026".
const glanceDate = (() => { const glanceDate = (() => {
@@ -611,15 +647,18 @@ export function UTCIForecast() {
// The grid is measured in HALF-hour tracks, because a card centred on its // The grid is measured in HALF-hour tracks, because a card centred on its
// landing hour starts on a half-hour boundary. All tracks are identical, // landing hour starts on a half-hour boundary. All tracks are identical,
// and each card SPANS 2*bucket of them (rather than sitting in one track // and each card SPANS 2*bucket of them (rather than sitting in one track
// at width:400%) — spanning divides a card's intrinsic width across the // at width:400%). The card itself is a fixed width centred in that span,
// tracks it covers, so the grid's max-content width stays close to what // so a longer bucket buys spacing between cards rather than a fatter card.
// it was and mobile doesn't gain a load of extra horizontal scroll.
// //
// tracks = [ pad ][ hour 0 ][ hour 1 ] … [ hour H-1 ][ pad ] // tracks = [ pad ][ hour 0 ][ hour 1 ] … [ hour H-1 ][ pad ]
// pad = bucket half-hours (>= the (bucket-1)/2 h overhang, + breathing room) // pad = bucket half-hours (>= the (bucket-1)/2 h overhang, + breathing room)
// hour j -> centre at (bucket + 2j + 1) / total // hour j -> centre at (bucket + 2j + 1) / total
// card -> spans 2*bucket tracks, starting one half-hour after 2j // card -> spans 2*bucket tracks, starting one half-hour after 2j
// => centre = 2j + 1 + bucket == hour j's centre ✓ // => centre = 2j + 1 + bucket == hour j's centre ✓
// Quick-view card width, and the minimum clearance between two cards. Kept
// here rather than only in CSS because the grid track sizing is derived from
// them; .fsc-card sets the same width.
const FSC_CARD_W = 92, FSC_CARD_GAP = 4;
const fscPlot = (() => { const fscPlot = (() => {
if (!tableRows.length) return null; if (!tableRows.length) return null;
const cards = tableRows.length; const cards = tableRows.length;
@@ -632,10 +671,16 @@ export function UTCIForecast() {
const landing = []; const landing = [];
let j = 0; let j = 0;
for (const r of tableRows) { landing.push(j); j += r.isoHours ? r.isoHours.length : 1; } for (const r of tableRows) { landing.push(j); j += r.isoHours ? r.isoHours.length : 1; }
// A card spans 2*bucket tracks, so this keeps its 55px minimum. // Every card is a fixed FSC_CARD_W wide at every interval (see .fsc-card),
const unit = 55 / (2 * bucket); // centred in the 2*bucket tracks it spans. So the tracks only have to be
// wide enough that neighbouring cards clear each other: a span of at least
// card + gutter. At 1h that is the tight case; at 3h/4h the same rule
// leaves a wider gap between cards, which is what the extra hours per card
// should look like. The 1fr half of the minmax lets the gaps grow further
// on a wide screen — the cards never do.
const unit = (FSC_CARD_W + FSC_CARD_GAP) / (2 * bucket);
return { return {
hourly, hours, bucket, landing, hours, bucket, landing,
cols: `repeat(${total}, minmax(${unit.toFixed(2)}px, 1fr))`, cols: `repeat(${total}, minmax(${unit.toFixed(2)}px, 1fr))`,
// Fraction of the track width at which hour j's data point sits. // Fraction of the track width at which hour j's data point sits.
hourAt: j2 => (bucket + 2 * j2 + 1) / total, hourAt: j2 => (bucket + 2 * j2 + 1) / total,
@@ -667,8 +712,8 @@ export function UTCIForecast() {
// • "utci-about" — the explainer paragraphs at the bottom // • "utci-about" — the explainer paragraphs at the bottom
// • "utci-footer" — the "reading the table" note // • "utci-footer" — the "reading the table" note
return html` return html`
<div class=${`utci-app${activeEvents.length > 0 ? ' has-event-note' : ''}`}> <div class=${`utci-app${activeEvents.length > 0 && !noteDismissed ? ' has-event-note' : ''}`}>
${activeEvents.length > 0 && (() => { ${activeEvents.length > 0 && !noteDismissed && (() => {
const ev = activeEvents[noteIndex] || activeEvents[0]; const ev = activeEvents[noteIndex] || activeEvents[0];
const isPriority = PRIORITY_WEATHER_IDS.has(ev.id); const isPriority = PRIORITY_WEATHER_IDS.has(ev.id);
const fmtDate = (iso) => iso const fmtDate = (iso) => iso
@@ -701,7 +746,7 @@ export function UTCIForecast() {
}; };
})() : undefined; })() : undefined;
return html` return html`
<div class=${`event-note${isPriority ? ' is-priority' : ''}${noteTintStyle ? ' is-tinted' : ''}`} style=${noteTintStyle}> <div class=${`event-note${isPriority ? ' is-priority' : ''}${noteTintStyle ? ' is-tinted' : ''}${noteClosing ? ' is-closing' : ''}`} style=${noteTintStyle}>
<span class="event-note-accent" style=${{ background: ev.color === '#fdf8ee' ? '#c8922a' : ev.color }} /> <span class="event-note-accent" style=${{ background: ev.color === '#fdf8ee' ? '#c8922a' : ev.color }} />
${/* Every slide is rendered, all stacked in one CSS grid cell, so the ${/* Every slide is rendered, all stacked in one CSS grid cell, so the
strip is always as tall as the LONGEST event's copy. Rotating to strip is always as tall as the LONGEST event's copy. Rotating to
@@ -718,10 +763,14 @@ export function UTCIForecast() {
class=${`event-note-slide${isActive ? ' is-active' : ''}`} class=${`event-note-slide${isActive ? ' is-active' : ''}`}
aria-hidden=${isActive ? undefined : 'true'} aria-hidden=${isActive ? undefined : 'true'}
> >
<span class="event-note-emoji">${e.emoji}</span> <div class="event-note-header">
<span class="event-note-title">${e.title}</span> <span class="event-note-emoji">${e.emoji}</span>
<span class="event-note-msg">${e.message}</span> <span class="event-note-title">${e.title}</span>
${dateLine && html`<span class="event-note-dates">${dateLine}</span>`} </div>
<div class="event-note-body">
<span class="event-note-msg">${e.message}</span>
${dateLine && html`<span class="event-note-dates">${dateLine}</span>`}
</div>
</div>`; </div>`;
})} })}
</div> </div>
@@ -736,6 +785,19 @@ export function UTCIForecast() {
onClick=${() => noteGoTo(i)} onClick=${() => noteGoTo(i)}
/>`)} />`)}
</div>`} </div>`}
<button
type="button"
class="event-note-close"
aria-label="Close event banner"
title="Close"
onClick=${noteClose}
>
<span class="event-note-close-label">Close</span>
<svg viewBox="0 0 12 12" width="10" height="10" aria-hidden="true">
<path d="M1.5 1.5 L10.5 10.5 M10.5 1.5 L1.5 10.5"
fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
</svg>
</button>
</div>`; </div>`;
})()} })()}
<nav class="utci-topnav" id="site-nav"> <nav class="utci-topnav" id="site-nav">
@@ -1054,6 +1116,8 @@ export function UTCIForecast() {
${welcomeOpen && html`<${WelcomeModal} onClose=${closeWelcome} />`} ${welcomeOpen && html`<${WelcomeModal} onClose=${closeWelcome} />`}
${restoreOpen && html`<${RestoreModal} onClose=${closeRestore} setIsPro=${setIsPro} />`} ${restoreOpen && html`<${RestoreModal} onClose=${closeRestore} setIsPro=${setIsPro} />`}
${weeklyNudgeOpen && !welcomeOpen && html`
<${WeeklyNudge} onClose=${closeWeeklyNudge} openRestore=${openRestore} />`}
${forecast && days.length > 0 && (() => { ${forecast && days.length > 0 && (() => {
const { mainLabel, mainConfigKey } = deriveProfileMain(activeProfile, outdoorsVariant); const { mainLabel, mainConfigKey } = deriveProfileMain(activeProfile, outdoorsVariant);
@@ -1124,30 +1188,32 @@ export function UTCIForecast() {
<div class="table-toolbar-row"> <div class="table-toolbar-row">
<span class="fvt-toolbar-left"> <span class="fvt-toolbar-left">
<span class="forecast-view-toggle-label">View:</span> <span class="forecast-view-toggle-label">View:</span>
${/* One three-way control. Quick is the card layout; the other
two are the same hourly table on opposite axes — Detailed
runs hours along the top, Table runs them down the side.
Rotation used to be a separate toggle that only appeared
once you were already in the table, which hid the layout
people wanted behind a mode they had to find first. */''}
<span class="forecast-view-toggle"> <span class="forecast-view-toggle">
<button type="button" title="Quick view visual card layout" class=${'fvt-btn' + (forecastView === 'simple' ? ' on' : '')} onClick=${() => setForecastView('simple')}> <button type="button" title="Quick view visual card layout" class=${'fvt-btn' + (forecastView === 'simple' ? ' on' : '')}
aria-pressed=${forecastView === 'simple' ? 'true' : 'false'}
onClick=${() => setForecastView('simple')}>
<svg class="fvt-btn-icon" viewBox="0 0 12 12" width="12" height="12"><rect x=".5" y=".5" width="4.5" height="4.5" rx=".8" fill="currentColor"/><rect x="7" y=".5" width="4.5" height="4.5" rx=".8" fill="currentColor"/><rect x=".5" y="7" width="4.5" height="4.5" rx=".8" fill="currentColor"/><rect x="7" y="7" width="4.5" height="4.5" rx=".8" fill="currentColor"/></svg> <svg class="fvt-btn-icon" viewBox="0 0 12 12" width="12" height="12"><rect x=".5" y=".5" width="4.5" height="4.5" rx=".8" fill="currentColor"/><rect x="7" y=".5" width="4.5" height="4.5" rx=".8" fill="currentColor"/><rect x=".5" y="7" width="4.5" height="4.5" rx=".8" fill="currentColor"/><rect x="7" y="7" width="4.5" height="4.5" rx=".8" fill="currentColor"/></svg>
Quick Quick
</button> </button>
<button type="button" title="Detailed view full hourly table" class=${'fvt-btn' + (forecastView === 'table' ? ' on' : '')} onClick=${() => setForecastView('table')}> <button type="button" title="Detailed view hours along the top, metrics down the side" class=${'fvt-btn' + (forecastView === 'table' && tableRotated ? ' on' : '')}
<svg class="fvt-btn-icon" viewBox="0 0 12 12" width="12" height="12"><rect x=".5" y="1.5" width="11" height="3" rx=".6" fill="currentColor"/><rect x=".5" y="5.5" width="11" height="2.5" rx=".5" fill="currentColor" opacity=".35"/><rect x=".5" y="8.8" width="11" height="2.5" rx=".5" fill="currentColor" opacity=".25"/></svg> aria-pressed=${forecastView === 'table' && tableRotated ? 'true' : 'false'}
onClick=${() => { setForecastView('table'); setTableRotated(true); }}>
<svg class="fvt-btn-icon" viewBox="0 0 12 12" width="12" height="12" aria-hidden="true"><rect x=".5" y="1.5" width="3" height="10" rx=".6" fill="currentColor"/><rect x="4.5" y="1.5" width="2.5" height="10" rx=".5" fill="currentColor" opacity=".35"/><rect x="8" y="1.5" width="2.5" height="10" rx=".5" fill="currentColor" opacity=".25"/></svg>
Detailed Detailed
</button> </button>
<button type="button" title="Table view hours down the side, metrics along the top" class=${'fvt-btn' + (forecastView === 'table' && !tableRotated ? ' on' : '')}
aria-pressed=${forecastView === 'table' && !tableRotated ? 'true' : 'false'}
onClick=${() => { setForecastView('table'); setTableRotated(false); }}>
<svg class="fvt-btn-icon" viewBox="0 0 12 12" width="12" height="12"><rect x=".5" y="1.5" width="11" height="3" rx=".6" fill="currentColor"/><rect x=".5" y="5.5" width="11" height="2.5" rx=".5" fill="currentColor" opacity=".35"/><rect x=".5" y="8.8" width="11" height="2.5" rx=".5" fill="currentColor" opacity=".25"/></svg>
Table
</button>
</span> </span>
${forecastView === 'table' && html`
<button type="button"
class=${'table-rotate-btn' + (tableRotated ? ' on' : '')}
title=${tableRotated ? 'Hours run along the top — click for hours down the side' : 'Hours run down the side — click for hours along the top'}
aria-pressed=${tableRotated ? 'true' : 'false'}
onClick=${toggleTableRotated}>
<svg class="fvt-btn-icon" viewBox="0 0 12 12" width="12" height="12" aria-hidden="true">
<path d="M2.4 6.6a3.7 3.7 0 0 1 6-3.2" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<path d="M9.6 5.4a3.7 3.7 0 0 1-6 3.2" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<path d="M6.3 1.1l2.4 2.2-2.4 1.8" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5.7 10.9L3.3 8.7l2.4-1.8" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
Rotate
</button>`}
<button <button
class=${'col-toggles-edit-btn col-toggles-edit-btn--toolbar' + (colTogglesOpen ? ' col-toggles-edit-btn--open' : '') + (isPro ? '' : ' col-toggles-edit-btn--locked')} class=${'col-toggles-edit-btn col-toggles-edit-btn--toolbar' + (colTogglesOpen ? ' col-toggles-edit-btn--open' : '') + (isPro ? '' : ' col-toggles-edit-btn--locked')}
title=${isPro ? '' : 'Custom columns are part of SunScope Extra'} title=${isPro ? '' : 'Custom columns are part of SunScope Extra'}
@@ -1198,25 +1264,22 @@ export function UTCIForecast() {
${colOffered('utciP') && html`<button class=${`col-toggle grp-felt${visibleCols.utciP ? ' on' : ''}`} onClick=${() => toggleCol('utciP')}>SunSoak</button>`} ${colOffered('utciP') && html`<button class=${`col-toggle grp-felt${visibleCols.utciP ? ' on' : ''}`} onClick=${() => toggleCol('utciP')}>SunSoak</button>`}
${colOffered('vehicleT') && html`<button class=${`col-toggle grp-felt${visibleCols.vehicleT ? ' on' : ''}`} onClick=${() => { if (visibleCols.vehicleT) setVehicleVent(false); toggleCol('vehicleT'); }}>Vehicle</button>`} ${colOffered('vehicleT') && html`<button class=${`col-toggle grp-felt${visibleCols.vehicleT ? ' on' : ''}`} onClick=${() => { if (visibleCols.vehicleT) setVehicleVent(false); toggleCol('vehicleT'); }}>Vehicle</button>`}
${(colOffered('indoorT') || colOffered('managedT')) && html`<button class=${`col-toggle grp-felt${indoorMode === 'on' ? ' on' : ''}`} onClick=${() => { if (indoorMode === 'on') { setIndoorMode('off'); setIndoorManaged(false); } else { setIndoorMode('on'); } }}>Indoors</button>`} ${(colOffered('indoorT') || colOffered('managedT')) && html`<button class=${`col-toggle grp-felt${indoorMode === 'on' ? ' on' : ''}`} onClick=${() => { if (indoorMode === 'on') { setIndoorMode('off'); setIndoorManaged(false); } else { setIndoorMode('on'); } }}>Indoors</button>`}
${colOffered('furSurfaceT') && html`<button class=${`col-toggle grp-surface${visibleCols.furSurfaceT ? ' on' : ''}`} onClick=${() => toggleCol('furSurfaceT')}>Fur</button>`}
${colOffered('pawT') && html`<button class=${`col-toggle grp-surface${visibleCols.pawT ? ' on' : ''}`} onClick=${() => toggleCol('pawT')}>Paw</button>`}
${colOffered('petShadeT') && html`<button class=${`col-toggle grp-ambient${visibleCols.petShadeT ? ' on' : ''}`} onClick=${() => toggleCol('petShadeT')}>Pet Shade</button>`}
${colOffered('petHomeT') && html`<button class=${`col-toggle grp-ambient${visibleCols.petHomeT ? ' on' : ''}`} onClick=${() => toggleCol('petHomeT')}>Pet Home</button>`}
${colOffered('burn') && html`<button class=${`col-toggle grp-felt${visibleCols.burn ? ' on' : ''}`} onClick=${() => toggleCol('burn')}>Burn</button>`}
${colOffered('utci') && html`<button class=${`col-toggle grp-felt${visibleCols.utci ? ' on' : ''}`} onClick=${() => toggleCol('utci')}>UTCI</button>`} ${colOffered('utci') && html`<button class=${`col-toggle grp-felt${visibleCols.utci ? ' on' : ''}`} onClick=${() => toggleCol('utci')}>UTCI</button>`}
${colOffered('delta') && html`<button class=${`col-toggle grp-felt${visibleCols.delta ? ' on' : ''}`} onClick=${() => toggleCol('delta')}>Δ</button>`} ${colOffered('burn') && html`<button class=${`col-toggle grp-felt${visibleCols.burn ? ' on' : ''}`} onClick=${() => toggleCol('burn')}>Burn</button>`}
${colOffered('tmrt') && html`<button class=${`col-toggle grp-felt${visibleCols.tmrt ? ' on' : ''}`} onClick=${() => toggleCol('tmrt')}>Tmrt</button>`}
${colOffered('concreteT') && html`<button class=${`col-toggle grp-surface${visibleCols.concreteT ? ' on' : ''}`} onClick=${() => toggleCol('concreteT')}>Concrete</button>`} ${colOffered('concreteT') && html`<button class=${`col-toggle grp-surface${visibleCols.concreteT ? ' on' : ''}`} onClick=${() => toggleCol('concreteT')}>Concrete</button>`}
${colOffered('soilT') && html`<button class=${`col-toggle grp-surface${visibleCols.soilT ? ' on' : ''}`} onClick=${() => toggleCol('soilT')}>Soil °C</button>`} ${colOffered('soilT') && html`<button class=${`col-toggle grp-surface${visibleCols.soilT ? ' on' : ''}`} onClick=${() => toggleCol('soilT')}>Soil °C</button>`}
${colOffered('soilT6') && html`<button class=${`col-toggle grp-surface${visibleCols.soilT6 ? ' on' : ''}`} onClick=${() => toggleCol('soilT6')}>Soil 6cm</button>`} ${colOffered('soilT6') && html`<button class=${`col-toggle grp-surface${visibleCols.soilT6 ? ' on' : ''}`} onClick=${() => toggleCol('soilT6')}>Soil 6cm</button>`}
${colOffered('soilM') && html`<button class=${`col-toggle grp-surface${visibleCols.soilM ? ' on' : ''}`} onClick=${() => toggleCol('soilM')}>Soil moist</button>`} ${colOffered('soilM') && html`<button class=${`col-toggle grp-surface${visibleCols.soilM ? ' on' : ''}`} onClick=${() => toggleCol('soilM')}>Soil moist</button>`}
${colOffered('furSurfaceT') && html`<button class=${`col-toggle grp-pets${visibleCols.furSurfaceT ? ' on' : ''}`} onClick=${() => toggleCol('furSurfaceT')}>Fur</button>`}
${colOffered('pawT') && html`<button class=${`col-toggle grp-pets${visibleCols.pawT ? ' on' : ''}`} onClick=${() => toggleCol('pawT')}>Paw</button>`}
${colOffered('petShadeT') && html`<button class=${`col-toggle grp-pets${visibleCols.petShadeT ? ' on' : ''}`} onClick=${() => toggleCol('petShadeT')}>Pet Shade</button>`}
${colOffered('petHomeT') && html`<button class=${`col-toggle grp-pets${visibleCols.petHomeT ? ' on' : ''}`} onClick=${() => toggleCol('petHomeT')}>Pet Home</button>`}
${colOffered('shadeT') && html`<button class=${`col-toggle grp-ambient${visibleCols.shadeT ? ' on' : ''}`} onClick=${() => toggleCol('shadeT')}>Shade</button>`} ${colOffered('shadeT') && html`<button class=${`col-toggle grp-ambient${visibleCols.shadeT ? ' on' : ''}`} onClick=${() => toggleCol('shadeT')}>Shade</button>`}
${colOffered('air') && html`<button class=${`col-toggle grp-ambient${visibleCols.air ? ' on' : ''}`} onClick=${() => toggleCol('air')}>Air</button>`} ${colOffered('air') && html`<button class=${`col-toggle grp-ambient${visibleCols.air ? ' on' : ''}`} onClick=${() => toggleCol('air')}>Air</button>`}
${colOffered('rh') && html`<button class=${`col-toggle grp-ambient${visibleCols.rh ? ' on' : ''}`} onClick=${() => toggleCol('rh')}>RH</button>`} ${colOffered('rh') && html`<button class=${`col-toggle grp-ambient${visibleCols.rh ? ' on' : ''}`} onClick=${() => toggleCol('rh')}>RH</button>`}
${colOffered('dew') && html`<button class=${`col-toggle grp-ambient${visibleCols.dew ? ' on' : ''}`} onClick=${() => toggleCol('dew')}>Dew</button>`} ${colOffered('dew') && html`<button class=${`col-toggle grp-ambient${visibleCols.dew ? ' on' : ''}`} onClick=${() => toggleCol('dew')}>Dew</button>`}
${colOffered('precip') && html`<button class=${`col-toggle grp-precip${visibleCols.precip ? ' on' : ''}`} onClick=${() => toggleCol('precip')}>Precip</button>`} ${colOffered('precip') && html`<button class=${`col-toggle grp-precip${visibleCols.precip ? ' on' : ''}`} onClick=${() => toggleCol('precip')}>Precip</button>`}
${colOffered('precipProb') && html`<button class=${`col-toggle grp-precip${visibleCols.precipProb ? ' on' : ''}`} onClick=${() => toggleCol('precipProb')}>Rain%</button>`} ${colOffered('precipProb') && html`<button class=${`col-toggle grp-precip${visibleCols.precipProb ? ' on' : ''}`} onClick=${() => toggleCol('precipProb')}>Rain%</button>`}
${colOffered('lightning') && html`<button class=${`col-toggle grp-precip${visibleCols.lightning ? ' on' : ''}`} onClick=${() => toggleCol('lightning')}>Lightning</button>`}
${colOffered('cloud') && html`<button class=${`col-toggle grp-sky${visibleCols.cloud ? ' on' : ''}`} onClick=${() => toggleCol('cloud')}>Cloud</button>`} ${colOffered('cloud') && html`<button class=${`col-toggle grp-sky${visibleCols.cloud ? ' on' : ''}`} onClick=${() => toggleCol('cloud')}>Cloud</button>`}
${colOffered('vis') && html`<button class=${`col-toggle grp-sky${visibleCols.vis ? ' on' : ''}`} onClick=${() => toggleCol('vis')}>Visibility</button>`} ${colOffered('vis') && html`<button class=${`col-toggle grp-sky${visibleCols.vis ? ' on' : ''}`} onClick=${() => toggleCol('vis')}>Visibility</button>`}
${colOffered('wind') && html`<button class=${`col-toggle grp-wind${visibleCols.wind ? ' on' : ''}`} onClick=${() => toggleCol('wind')}>Wind</button>`} ${colOffered('wind') && html`<button class=${`col-toggle grp-wind${visibleCols.wind ? ' on' : ''}`} onClick=${() => toggleCol('wind')}>Wind</button>`}
@@ -1305,20 +1368,25 @@ export function UTCIForecast() {
${(() => { ${(() => {
if (!fscPlot) return null; if (!fscPlot) return null;
const fscSvgW = 1000, fscSvgH = 80; const fscSvgW = 1000, fscSvgH = 80;
const fscSrc = fscPlot.hourly; // The curve plots the SAME rows the cards do. It used to plot the
// raw hourly series instead, which agrees with the cards at 1h and
// diverges at every longer interval: a card shows its bucket's
// aggregate, so an "8am 24°" card (the 811am mean) sat above a
// curve drawn at 8am's own 14.5°, and its connector pointed at a
// height the card never claimed. One series, one number.
const fscSrc = tableRows;
// Fixed scale: bottom = Freezing band bottom (10) 10, top = Danger start (44) + 10 // Fixed scale: bottom = Freezing band bottom (10) 10, top = Danger start (44) + 10
const fscMin = -15, fscMax = 45; const fscMin = -15, fscMax = 45;
const toY = t => fscSvgH - ((t - fscMin) / (fscMax - fscMin)) * fscSvgH; const toY = t => fscSvgH - ((t - fscMin) / (fscMax - fscMin)) * fscSvgH;
const getT = r => r[simpleTemp] ?? r.utciAdj; const getT = r => r[simpleTemp] ?? r.utciAdj;
// Same hour->x mapping the cards grid uses (see fscPlot above), so a // x of card i — the landing hour it is centred on, via the same
// card's centre and its hour's data point are the same x by // mapping that placed the card in the grid, so a point and its
// construction — every hour of the day stays on screen. // card share an x by construction.
const fscHourX = j => fscPlot.hourAt(j) * fscSvgW; const fscCardX = i => fscPlot.hourAt(fscPlot.landing[i]) * fscSvgW;
const fscStopPct = j => (fscPlot.hourAt(j) * 100).toFixed(1); const fscStopPct = i => (fscPlot.hourAt(fscPlot.landing[i]) * 100).toFixed(1);
const fscAllPts = fscSrc.map((r, j) => ({ x: fscHourX(j), y: toY(getT(r)) })); const fscAllPts = fscSrc.map((r, i) => ({ x: fscCardX(i), y: toY(getT(r)) }));
// Connector points — one per card, planted on its landing hour's data // One point per card now, so the connectors ARE the data points.
// point, which is exactly where that card is centred. const fscPts = fscAllPts;
const fscPts = fscPlot.landing.map(j => fscAllPts[Math.min(j, fscAllPts.length - 1)]);
const fscNowFlags = tableRows.map(r => r.isoHours ? r.isoHours.includes(nowLocalISO) : r.iso.slice(0, 13) === nowLocalISO); const fscNowFlags = tableRows.map(r => r.isoHours ? r.isoHours.includes(nowLocalISO) : r.iso.slice(0, 13) === nowLocalISO);
// Flat runs from each edge into the first/last hour, so the fill still // Flat runs from each edge into the first/last hour, so the fill still
// covers the inset spacer strips at both ends. // covers the inset spacer strips at both ends.
@@ -1619,7 +1687,7 @@ export function UTCIForecast() {
<span class="insight-label">${titleCaseText(label)}</span> <span class="insight-label">${titleCaseText(label)}</span>
<span class="insight-value">${titleCaseText(value)}</span> <span class="insight-value">${titleCaseText(value)}</span>
${score != null && html` ${score != null && html`
<span class="week-score" title=${`${score}% of an ideal day out — longer comfortable stretches and temperatures nearer 20 °C score higher`}> <span class="week-score" title=${bestDaysHint(activeProfile, outdoorsVariant, score)}>
<span class="week-score__track"> <span class="week-score__track">
<span class="week-score__fill" style=${`width:${score}%;background:${scoreFillColor(score)}`}></span> <span class="week-score__fill" style=${`width:${score}%;background:${scoreFillColor(score)}`}></span>
</span> </span>
+1 -1
View File
@@ -224,7 +224,7 @@ export function ConfigPanel({
{ key: 'fur', show: showFur, node: html` { key: 'fur', show: showFur, node: html`
<span class="config-item-label">Fur Colour</span> <span class="config-item-label">Fur Colour</span>
<${CustomSelect} <${CustomSelect}
value=${furColor} isOn=${true} noHide=${true} grpClass="grp-surface" value=${furColor} isOn=${true} noHide=${true} grpClass="grp-pets"
hideLabel="Fur Colour" hideLabel="Fur Colour"
options=${Object.entries(FUR_COLORS).map(([k, v]) => ({ value: k, label: v.name }))} options=${Object.entries(FUR_COLORS).map(([k, v]) => ({ value: k, label: v.name }))}
onChange=${(v) => setFurColor(v)} onChange=${(v) => setFurColor(v)}
+82 -21
View File
@@ -33,7 +33,7 @@
import { h, Fragment } from '../../vendor/preact.js'; import { h, Fragment } from '../../vendor/preact.js';
import htm from '../../vendor/htm.js'; import htm from '../../vendor/htm.js';
import { confidenceBand, utciCategory, petCategory, hexToRgb, mixRgb, rainTint, cloudTint, snowTint, VEHICLE_TYPES, BUILDING_TYPES, FUR_COLORS } from '../utils.js'; import { confidenceBand, utciCategory, petCategory, UTCI_BANDS, PET_BANDS, bandRampRgb, hexToRgb, mixRgb, rainTint, cloudTint, snowTint, VEHICLE_TYPES, BUILDING_TYPES, FUR_COLORS } from '../utils.js';
import { FREE_DAYS, UTCI_ENVIRONMENTS, deriveProfileMain, FILTER_PROFILES, variantIcons } from '../config.js'; import { FREE_DAYS, UTCI_ENVIRONMENTS, deriveProfileMain, FILTER_PROFILES, variantIcons } from '../config.js';
import { CloudIcon, PrecipIcon, HouseIcon, CarIcon, FogIcon, IceIcon } from '../components.js'; import { CloudIcon, PrecipIcon, HouseIcon, CarIcon, FogIcon, IceIcon } from '../components.js';
import { SubscribeModal } from './SubscribeModal.js'; import { SubscribeModal } from './SubscribeModal.js';
@@ -102,6 +102,18 @@ function weatherGradientNeutral(r, g, b, edgeBlend = 0.50, centerBlend = null) {
return `radial-gradient(circle at 50% 50%, ${center} 0%, ${center} 28%, ${edge} 100%)`; return `radial-gradient(circle at 50% 50%, ${center} 0%, ${center} 28%, ${edge} 100%)`;
} }
// Colour-driving "sustained high": the day's high with brief spikes ignored, so a
// single hot hour on an otherwise cool, damp day doesn't paint the whole tab warm.
// Drops the top ~10% of hours (always at least the single hottest) and takes the next
// value down — a temperature that actually persisted. The PRINTED hi number and the
// Danger alarm still key off the true peak, not this.
function sustainedHigh(vals) {
if (!vals.length) return null;
const sorted = [...vals].sort((a, b) => b - a);
const drop = Math.max(1, Math.round(sorted.length * 0.1));
return sorted[Math.min(drop, sorted.length - 1)];
}
function weatherGradientActive(r, g, b, edgeBlend = 0.42, centerBlend = null) { function weatherGradientActive(r, g, b, edgeBlend = 0.42, centerBlend = null) {
const blend = (c, amt) => Math.round(c + (255 - c) * amt); const blend = (c, amt) => Math.round(c + (255 - c) * amt);
const lighten = (c) => Math.min(255, Math.round(c + (255 - c) * 0.38)); const lighten = (c) => Math.min(255, Math.round(c + (255 - c) * 0.38));
@@ -155,7 +167,7 @@ export function DayTabs({
// those controls rather than a separate, unrelated style. // those controls rather than a separate, unrelated style.
const rowGrpClass = mainConfigKey === 'vehicle' ? 'grp-wind' const rowGrpClass = mainConfigKey === 'vehicle' ? 'grp-wind'
: mainConfigKey === 'indoor' ? 'grp-ambient' : mainConfigKey === 'indoor' ? 'grp-ambient'
: mainConfigKey === 'fur' ? 'grp-surface' : mainConfigKey === 'fur' ? 'grp-pets'
: 'grp-felt'; : 'grp-felt';
// Within that group, shade further by the exact option chosen — same idea // Within that group, shade further by the exact option chosen — same idea
@@ -232,6 +244,17 @@ export function DayTabs({
const dayHi = mainVals.length ? Math.round(Math.max(...mainVals)) : null; const dayHi = mainVals.length ? Math.round(Math.max(...mainVals)) : null;
const dayLo = mainVals.length ? Math.round(Math.min(...mainVals)) : null; const dayLo = mainVals.length ? Math.round(Math.min(...mainVals)) : null;
// The tab COLOUR is keyed to a spike-resistant "sustained high" rather than
// the raw max, so a single warm hour on a cool/damp day doesn't paint the
// whole tab warm. The Danger tier is the exception — it keeps alarming on
// the true peak — and colourHi collapses back to dayHi there, so a real
// Danger day still gets its full colouring. The printed hi/lo numbers below
// always show the true dayHi/dayLo.
const catFn = isPetsProfile ? petCategory : utciCategory;
const isDanger = dayHi !== null && catFn(dayHi).solid === true;
const colourHi = dayHi === null ? null
: (isDanger ? dayHi : Math.round(sustainedHigh(mainVals)));
// Secondary row under each hi/lo: Shade for SunSoak, Managed for // Secondary row under each hi/lo: Shade for SunSoak, Managed for
// indoor, Pet Shade for pets, and plain air temperature for // indoor, Pet Shade for pets, and plain air temperature for
// Vehicle/Driver so the outside-vs-cabin gap is visible. // Vehicle/Driver so the outside-vs-cabin gap is visible.
@@ -290,8 +313,15 @@ export function DayTabs({
// • Otherwise rain wins, then cloud, then a clear "sunny"/cold colour. // • Otherwise rain wins, then cloud, then a clear "sunny"/cold colour.
// Shade ramps live in utils.js (WEATHER TINT PALETTE) so the warning // Shade ramps live in utils.js (WEATHER TINT PALETTE) so the warning
// event banner can colour itself from exactly the same source. // event banner can colour itself from exactly the same source.
const catFn = isPetsProfile ? petCategory : utciCategory; const tBand = colourHi !== null ? catFn(colourHi) : { bg: '#f8e554' };
const tBand = dayHi !== null ? catFn(dayHi) : { bg: '#f8e554' }; // Band colour, but ramped: bandRampRgb() interpolates between the
// this band's colour and the next one's, so 30° isn't the same
// swatch as 27° while 32° still lands on the Extreme orange
// (see BAND COLOUR RAMP in utils.js). Danger stays flat — it's an
// alarm colour, not a point on the gradient.
const tBandRgb = tBand.solid
? hexToRgb(tBand.bg)
: bandRampRgb(colourHi, isPetsProfile ? PET_BANDS : UTCI_BANDS);
const ccVals = repRows.map(r => r.cc).filter(v => isFinite(v)); const ccVals = repRows.map(r => r.cc).filter(v => isFinite(v));
const avgCloud = ccVals.length ? ccVals.reduce((s, v) => s + v, 0) / ccVals.length : 0; const avgCloud = ccVals.length ? ccVals.reduce((s, v) => s + v, 0) / ccVals.length : 0;
@@ -301,8 +331,10 @@ export function DayTabs({
// the same cutoffs are re-projected onto the pet scale so the // the same cutoffs are re-projected onto the pet scale so the
// "hot hue" / "plain cold" branches kick in at the same relative // "hot hue" / "plain cold" branches kick in at the same relative
// point in each band, not the same absolute degree. // point in each band, not the same absolute degree.
const isHot = dayHi !== null && dayHi >= (isPetsProfile ? 35 : 27); // Hot band and above const isHot = colourHi !== null && colourHi >= (isPetsProfile ? 35 : 27); // Hot band and above
const isDanger = tBand.solid === true; // Extreme band gets a less pastelised gradient so it reads as a
// clear step between Caution and the pulsing Danger tier.
const isExtreme = tBand.label === 'Extreme';
// Home/indoor/vehicle tabs show a modelled temperature, not the // Home/indoor/vehicle tabs show a modelled temperature, not the
// outdoor sky, so the tab shade should track that temperature only // outdoor sky, so the tab shade should track that temperature only
@@ -311,15 +343,15 @@ export function DayTabs({
let rgb; let rgb;
if (tempOnly) { if (tempOnly) {
rgb = hexToRgb(tBand.bg); rgb = tBandRgb;
} else if (isHot) { } else if (isHot) {
// Keep the heat hue; cloud only mutes it a touch (orange/red never // Keep the heat hue; cloud only mutes it a touch (orange/red never
// greens) and rain does not override heat. // greens) and rain does not override heat.
const heatFactor = isPetsProfile const heatFactor = isPetsProfile
? (dayHi >= 49 ? 0.15 : dayHi >= 43 ? 0.30 : 0.45) ? (colourHi >= 49 ? 0.15 : colourHi >= 43 ? 0.30 : 0.45)
: (dayHi >= 39 ? 0.15 : dayHi >= 34 ? 0.30 : 0.45); : (colourHi >= 39 ? 0.15 : colourHi >= 34 ? 0.30 : 0.45);
const t = Math.max(0, Math.min(1, (avgCloud - 30) / 70)) * heatFactor; const t = Math.max(0, Math.min(1, (avgCloud - 30) / 70)) * heatFactor;
rgb = mixRgb(hexToRgb(tBand.bg), [170, 162, 152], t); rgb = mixRgb(tBandRgb, [170, 162, 152], t);
} else if (daySnow >= 0.1) { } else if (daySnow >= 0.1) {
// Snow: pale → icy blue with depth. // Snow: pale → icy blue with depth.
rgb = snowTint(daySnow); rgb = snowTint(daySnow);
@@ -329,24 +361,53 @@ export function DayTabs({
} else if (avgCloud >= 30) { } else if (avgCloud >= 30) {
// Cloud: light → dark grey with cover (pure grey, no temperature hue). // Cloud: light → dark grey with cover (pure grey, no temperature hue).
rgb = cloudTint(avgCloud); rgb = cloudTint(avgCloud);
} else if (dayHi !== null && dayHi < (isPetsProfile ? 2 : 10)) { } else if (colourHi !== null && colourHi < (isPetsProfile ? 2 : 10)) {
// Clear & cold: keep the cold temperature blue. // Clear & cold: keep the cold temperature blue.
rgb = hexToRgb(tBand.bg); rgb = tBandRgb;
} else { } else {
// Clear & mild/warm: a plain sunny yellow. // Clear & mild/warm: a plain sunny yellow.
rgb = hexToRgb('#f8e554'); rgb = hexToRgb('#f8e554');
} }
// Extreme tabs lean toward Danger's crimson as the daily high climbs
// through the band (32→41°C human, 40→52°C pet) — so a day peaking
// near 40° visibly edges into danger-red without becoming Danger.
if (isExtreme) {
const floor = isPetsProfile ? 40 : 32;
const ceil = isPetsProfile ? 52 : 41;
const t = Math.max(0, Math.min(1, (colourHi - floor) / (ceil - floor)));
rgb = mixRgb(rgb, [136, 0, 0], t * 0.55);
}
const [wR, wG, wB] = rgb; const [wR, wG, wB] = rgb;
const wBgNeutral = isDanger // How pastel the tab is drawn also carries heat: the tint is blended
? weatherGradientNeutral(wR, wG, wB, 0.15, 0.72) // toward white, and the hotter the day the less white goes in. This
: weatherGradientNeutral(wR, wG, wB); // used to step at the band edges (0.50 for everything up to Caution,
const wBgActive = isDanger // then 0.20 at Extreme), which made 30° look like a washed-out yellow
? weatherGradientActive(wR, wG, wB, 0.12, 0.72) // while 32° snapped to a solid orange. Ramp the blend continuously
: weatherGradientActive(wR, wG, wB); // instead — the endpoints still land on the old Extreme (heatT = 1)
// and Danger (both = 1) values, so only the in-between days change.
const heatFloor = isPetsProfile ? 35 : 27; // start of the hot half
const heatMid = isPetsProfile ? 40 : 32; // Extreme threshold
const heatTop = isPetsProfile ? 52 : 41; // Danger threshold
const clamp01 = (v) => Math.max(0, Math.min(1, v));
const heatT = colourHi === null ? 0 : clamp01((colourHi - heatFloor) / (heatMid - heatFloor));
const overT = colourHi === null ? 0 : clamp01((colourHi - heatMid) / (heatTop - heatMid));
const wBgNeutral = weatherGradientNeutral(
wR, wG, wB,
0.50 - 0.30 * heatT - 0.05 * overT, // edge: 0.50 → 0.20 → 0.15
0.69 - 0.03 * heatT + 0.06 * overT, // centre: 0.69 → 0.66 → 0.72
);
const wBgActive = weatherGradientActive(
wR, wG, wB,
0.42 - 0.26 * heatT - 0.04 * overT, // edge: 0.42 → 0.16 → 0.12
0.64 + 0.02 * heatT + 0.06 * overT, // centre: 0.64 → 0.66 → 0.72
);
const wText = '#2a1d10'; const wText = '#2a1d10';
// Active-tab outline: the day's weather colour, 20% darker. // Active-tab outline: the day's weather colour, 20% darker.
const wOutline = `rgb(${Math.round(wR * 0.8)},${Math.round(wG * 0.8)},${Math.round(wB * 0.8)})`; const wOutline = `rgb(${Math.round(wR * 0.8)},${Math.round(wG * 0.8)},${Math.round(wB * 0.8)})`;
// Extreme tabs get a strong red border so the tier pops even when
// the tab is not selected — a firmer warning than the tan default.
const wBorder = isExtreme ? '#c22f1a' : '#c9b08a';
return html` return html`
<button <button
@@ -367,10 +428,10 @@ export function DayTabs({
style=${{ style=${{
background: isActive ? wBgActive : wBgNeutral, background: isActive ? wBgActive : wBgNeutral,
color: wText, color: wText,
borderTop: '1px solid #c9b08a', borderTop: `1px solid ${wBorder}`,
borderRight: 'none', borderRight: 'none',
borderBottom: '1px solid #c9b08a', borderBottom: `1px solid ${wBorder}`,
borderLeft: i === 0 ? '1px solid #c9b08a' : 'none', borderLeft: i === 0 ? `1px solid ${wBorder}` : 'none',
opacity: locked ? 0.5 : 1, opacity: locked ? 0.5 : 1,
cursor: locked ? 'not-allowed' : 'pointer', cursor: locked ? 'not-allowed' : 'pointer',
position: 'relative', position: 'relative',
+3 -3
View File
@@ -22,9 +22,9 @@ import { Wordmark } from './Wordmark.js';
const html = htm.bind(h); const html = htm.bind(h);
const SUBSCRIBE_URL = 'https://buy.stripe.com/9B63cw7vl8k15Ei9DQd7q00'; export const SUBSCRIBE_URL = 'https://buy.stripe.com/9B63cw7vl8k15Ei9DQd7q00';
const SUBSCRIBE_URL_ONEOFF = 'https://buy.stripe.com/14A14odTJ6bT0jY4jwd7q02'; export const SUBSCRIBE_URL_ONEOFF = 'https://buy.stripe.com/14A14odTJ6bT0jY4jwd7q02';
const MANAGE_URL = 'https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00'; export const MANAGE_URL = 'https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00';
export function SubscribeModal({ title, detail, onClose, openRestore }) { export function SubscribeModal({ title, detail, onClose, openRestore }) {
// Dismiss on Esc. // Dismiss on Esc.
+112
View File
@@ -0,0 +1,112 @@
// ------------------------------------------------------------------------
// components/WeeklyNudge.js - Friendly weekly reminder for free users.
//
// Shown at most once every 7 days to non-subscribers (never on the very
// first visit - the WelcomeModal owns that moment). Leads with a small
// sun-themed fact so it reads as a bit of sunshine trivia rather than a
// nag, then offers SunScope Extra.
//
// Reuses the .welcome-* overlay/card styling. Purely presentational - the
// open/close state lives in useAppState (weeklyNudgeOpen).
//
// Props:
// onClose - dismiss and stamp the "shown" date (7-day cooldown)
// openRestore - opens the "Already subscribed?" restore modal
// ------------------------------------------------------------------------
import { h } from '../../vendor/preact.js';
import { useEffect, useMemo } from '../../vendor/preact-hooks.js';
import htm from '../../vendor/htm.js';
import { Wordmark } from './Wordmark.js';
import { SUBSCRIBE_URL, SUBSCRIBE_URL_ONEOFF } from './SubscribeModal.js';
const html = htm.bind(h);
// One of these is picked at random each time the nudge appears, so a
// regular user gets a different bit of sun trivia week to week.
const SUN_FACTS = [
{
icon: '☀️',
title: 'Sunlight is eight minutes old',
body: html`Every ray that warms your face left the Sun eight and a half minutes ago and
crossed 93 million miles to get here. The least you can do is dress for it.`,
},
{
icon: '🌡️',
title: 'Shade can feel 15°C cooler',
body: html`Air temperature barely changes when you step into shade — but what your body
feels can drop by ten or fifteen degrees. That gap is exactly what SunSoak measures.`,
},
{
icon: '🧴',
title: 'Clouds are not sunscreen',
body: html`Up to 80% of UV sails straight through thin cloud, which is why overcast days
catch people out. SunScope's UV and sunburn-time columns keep score for you.`,
},
{
icon: '🚗',
title: 'A parked car beats the desert',
body: html`On a mild 22°C afternoon a car's interior can pass 50°C in under an hour —
hotter than the Sahara. SunScope can show you that number before you park.`,
},
{
icon: '💨',
title: 'Wind steals your warmth',
body: html`A brisk breeze can strip several degrees off how cold you feel without moving
the thermometer at all. Wind chill is baked into every SunSoak reading.`,
},
{
icon: '🌅',
title: 'The golden hour is real',
body: html`Low sun travels through far more atmosphere, so early and late light is gentler
on skin — a good reason to check the hourly view before heading out.`,
},
];
export function WeeklyNudge({ onClose, openRestore }) {
const fact = useMemo(() => SUN_FACTS[Math.floor(Math.random() * SUN_FACTS.length)], []);
// Dismiss on Esc.
useEffect(() => {
const onKey = (e) => { if (e.key === 'Escape') onClose(); };
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [onClose]);
return html`
<div class="welcome-overlay" onClick=${onClose}>
<div class="welcome-card" role="dialog" aria-modal="true" aria-labelledby="nudge-title"
onClick=${(e) => e.stopPropagation()} style=${{ maxWidth: '440px' }}>
<button class="welcome-close" aria-label="Close" onClick=${onClose}>×</button>
<div class="welcome-head">
<${Wordmark} />
<div class="welcome-kicker" style=${{ marginTop: '10px' }}>Your weekly ray of sun</div>
</div>
<div class="nudge-fact">
<span class="nudge-fact-icon" aria-hidden="true">${fact.icon}</span>
<h2 class="welcome-title" id="nudge-title">${fact.title}</h2>
<p class="nudge-fact-body">${fact.body}</p>
</div>
<p class="welcome-remember">
✨ <strong>SunScope Extra</strong> unlocks the 🔒 profiles — hiking, sailing, fishing,
photography, winter sports and the whole Work set — plus Show All and custom columns,
so you can build your own view. £2 a month.
</p>
<a class="welcome-cta" href=${SUBSCRIBE_URL} target="_blank" rel="noopener noreferrer"
style=${{ textDecoration: 'none', textAlign: 'center', marginBottom: '10px' }}>
Subscribe — £2/month
</a>
<div class="nudge-links">
<a href=${SUBSCRIBE_URL_ONEOFF} target="_blank" rel="noopener noreferrer">
Or make a one-off payment →
</a>
<button type="button" onClick=${() => { onClose(); openRestore(); }}>
Already subscribed? Restore access →
</button>
<button type="button" class="nudge-dismiss" onClick=${onClose}>
Maybe next week
</button>
</div>
</div>
</div>`;
}
+15 -14
View File
@@ -21,23 +21,21 @@ const html = htm.bind(h);
const STEPS = [ const STEPS = [
{ {
icon: '☀️', icon: '☀️',
title: 'SunSoak is the number to watch', title: 'Watch SunSoak',
body: html`Pick a <strong>Solar model</strong> (open, urban, beach…) in the SunSoak dropdown body: html`It's how the sun actually feels, not just air temperature. Pick the
to match where you are. It shows what your body actually feels in the sun — not just the <strong>Solar model</strong> that matches where you are.`,
air temperature.`,
}, },
{ {
icon: '🎯', icon: '🎯',
title: 'Choose a profile', title: 'Pick a profile',
body: html`Tap <strong>Places</strong>, <strong>Activities</strong> or <strong>Work</strong> body: html`<strong>Places</strong>, <strong>Activities</strong> or <strong>Work</strong>
to instantly load the columns that matter for your situation — like Beach, Cycling or Beach, Cycling, Farming and more — loads the right columns instantly.`,
Farming.`,
}, },
{ {
icon: '⚙️', icon: '⚙️',
title: 'Make it yours', title: 'Make it yours',
body: html`Hit <strong>Edit columns</strong> to add or remove any data you like — UV and body: html`Every profile brings its own columns — UV, sunburn time, vehicle interior, soil,
sunburn time, vehicle interior, soil, air quality and more.`, air quality. <strong>Edit columns</strong> (Extra) lets you pick your own.`,
}, },
]; ];
@@ -59,9 +57,8 @@ export function WelcomeModal({ onClose }) {
<h2 class="welcome-title" id="welcome-title">How to read the sky in 3 steps</h2> <h2 class="welcome-title" id="welcome-title">How to read the sky in 3 steps</h2>
</div> </div>
<p class="welcome-intro"> <p class="welcome-intro">
This isn't your usual weather forecast. SunScope goes deeper than temperature alone — Not your usual forecast SunScope shows how the weather affects
it shows how the environment affects you: sun exposure, wind chill, UV intensity, and more. <strong>you</strong>: sun, wind chill, UV and more.
For the best results, pick the <strong>environment</strong> that matches where you'll be.
</p> </p>
<ol class="welcome-steps"> <ol class="welcome-steps">
${STEPS.map((s, i) => html` ${STEPS.map((s, i) => html`
@@ -74,7 +71,11 @@ export function WelcomeModal({ onClose }) {
</span> </span>
</li>`)} </li>`)}
</ol> </ol>
<p class="welcome-remember">💾 Don't worry — SunScope remembers your choices for next time.</p> <p class="welcome-remember">
✨ <strong>SunScope Extra</strong> unlocks the 🔒 profiles and custom columns,
£2/month — tap any locked one to subscribe.
</p>
<p class="welcome-note">💾 SunScope remembers your choices for next time.</p>
<button class="welcome-cta" onClick=${onClose}>Got it</button> <button class="welcome-cta" onClick=${onClose}>Got it</button>
</div> </div>
</div>`; </div>`;
+116 -36
View File
@@ -17,7 +17,7 @@ import {
calcIndoorTempPass, calcManagedIndoorTempPass, calcShadeAirTemp, calcShadeFeltTemp, calcIndoorTempPass, calcManagedIndoorTempPass, calcShadeAirTemp, calcShadeFeltTemp,
calcFurSurfaceTempPass, calcFurSurfaceTempPass,
} from './physics.js'; } from './physics.js';
import { windCompass8, uvSplit, cloudCategory, precipPenalty, sunburnMinutes, burnLabel, FUR_COLORS, UTCI_BANDS } from './utils.js'; import { windCompass8, uvSplit, cloudCategory, precipPenalty, sunburnMinutes, burnLabel, FUR_COLORS, UTCI_BANDS, PET_BANDS } from './utils.js';
import { UTCI_ENVIRONMENTS, CROP_CALENDAR, deriveProfileMain } from './config.js'; import { UTCI_ENVIRONMENTS, CROP_CALENDAR, deriveProfileMain } from './config.js';
export function buildHourlyRows({ forecast, airQuality, location, vehicleType, vehicleVent, vehicleSpeed, buildingType, furColor, utciEnv }) { export function buildHourlyRows({ forecast, airQuality, location, vehicleType, vehicleVent, vehicleSpeed, buildingType, furColor, utciEnv }) {
@@ -1005,6 +1005,16 @@ function bestRun(arr, pred) {
const PICK_MIN_HOURS = 3; // shorter than this isn't worth naming a day for const PICK_MIN_HOURS = 3; // shorter than this isn't worth naming a day for
const PICK_MAX_DAYS = 4; // beyond this the shortlist stops being a shortlist const PICK_MAX_DAYS = 4; // beyond this the shortlist stops being a shortlist
// Said when no day qualifies, phrased for what was actually being judged -
// "no settled outdoor windows" is meaningless when the panel was reading a
// car cabin.
const EMPTY_VALUE = {
utciAdj: 'no settled outdoor windows',
vehicleT: 'no comfortable cabin windows',
indoorT: 'no comfortable indoor windows',
furSurfaceT: 'no safe windows for a pet outside',
};
// ── The four bands ─────────────────────────────────────────────────────── // ── The four bands ───────────────────────────────────────────────────────
// Each is [ideal, limit]: at the ideal end an hour scores full marks on that // Each is [ideal, limit]: at the ideal end an hour scores full marks on that
// measure, at the limit it scores zero, and past the limit the hour is not a // measure, at the limit it scores zero, and past the limit the hour is not a
@@ -1020,13 +1030,18 @@ const PICK_MAX_DAYS = 4; // beyond this the shortlist stops being a shortlist
// Read off UTCI_BANDS rather than written as numbers, so the panel and the // Read off UTCI_BANDS rather than written as numbers, so the panel and the
// table's colour bands can never drift apart: today that is a 19-27 plateau // table's colour bands can never drift apart: today that is a 19-27 plateau
// ramping out to 15 and 32. // ramping out to 15 and 32.
const bandMax = (label) => UTCI_BANDS.find(b => b.label === label).max; const bandMax = (bands, label) => bands.find(b => b.label === label).max;
const SUNSOAK = { const plateau = (bands) => ({
min: bandMax('Chilly'), // bottom of Cool - score 0 min: bandMax(bands, 'Chilly'), // bottom of Cool - score 0
flatLo: bandMax('Cool'), // bottom of Comfortable - score 1 flatLo: bandMax(bands, 'Cool'), // bottom of Comfortable - score 1
flatHi: bandMax('Warm'), // top of Warm - score 1 flatHi: bandMax(bands, 'Warm'), // top of Warm - score 1
max: bandMax('Caution'), // top of Caution - score 0 max: bandMax(bands, 'Caution'), // top of Caution - score 0
}; });
const SUNSOAK = plateau(UTCI_BANDS);
// Pets are judged on fur-surface temperature, which is coloured off PET_BANDS
// in the table - reading the plateau off the same array keeps the panel and
// the cell colours in step there too.
const PET_PLATEAU = plateau(PET_BANDS);
const BAND_GUST = { ideal: 15, limit: 25 }; // mph const BAND_GUST = { ideal: 15, limit: 25 }; // mph
const BAND_RAIN = { ideal: 0, limit: 25 }; // % chance const BAND_RAIN = { ideal: 0, limit: 25 }; // % chance
const BAND_SOIL = { ideal: 0.10, limit: 0.25 }; // m3/m3 - dry ground to soggy const BAND_SOIL = { ideal: 0.10, limit: 0.25 }; // m3/m3 - dry ground to soggy
@@ -1039,44 +1054,84 @@ function bandScore(v, { ideal, limit }) {
return Math.max(0, Math.min(1, f)); return Math.max(0, Math.min(1, f));
} }
function sunsoakScore(v) { function plateauScore(v, pl) {
if (v == null) return null; if (v == null) return null;
if (v >= SUNSOAK.flatLo && v <= SUNSOAK.flatHi) return 1; if (v >= pl.flatLo && v <= pl.flatHi) return 1;
const f = v < SUNSOAK.flatLo const f = v < pl.flatLo
? (v - SUNSOAK.min) / (SUNSOAK.flatLo - SUNSOAK.min) ? (v - pl.min) / (pl.flatLo - pl.min)
: (SUNSOAK.max - v) / (SUNSOAK.max - SUNSOAK.flatHi); : (pl.max - v) / (pl.max - pl.flatHi);
return Math.max(0, Math.min(1, f)); return Math.max(0, Math.min(1, f));
} }
function comfortableHour(r) { // ── What "a good window" means for the active profile ────────────────────
if (!(r.elev > 0)) return false; // daylight only // The panel used to answer one question - "would I enjoy standing outside"
if (r.utciAdj == null) return false; // - whatever profile was selected, which made it useless to the very users
if (r.utciAdj < SUNSOAK.min || r.utciAdj > SUNSOAK.max) return false; // who had told the app what they were doing. It now scores the SAME field the
if ((r.precipProb ?? 0) > BAND_RAIN.limit) return false; // rest of the app is built around for that profile (the day tabs, the dial,
if ((r.gust ?? r.va ?? 0) * 2.237 > BAND_GUST.limit) return false; // the main table column), so "the week ahead" answers the question the
// profile implies: when to drive, when the house is bearable, when to walk
// the dog.
//
// thermal - weight on the profile's main temperature field
// rain / gust / soil - the outdoor nuisances, only where they can matter
//
// Driving keeps rain and gust (both are what makes a journey unpleasant or
// unsafe) and drops soil, which says nothing about a road. Indoors drops all
// three: rain on the roof does not change whether the bedroom is sleepable.
// Daylight-only is likewise a solar concern - a cool cabin at 6am is a real
// answer to "when should I drive", so vehicle and indoor windows run around
// the clock.
function bestDayCriteria(profile, variant) {
const { mainField } = deriveProfileMain(profile, variant);
switch (mainField) {
case 'vehicleT':
return { field: 'vehicleT', pl: SUNSOAK, daylightOnly: false,
w: { thermal: 0.55, rain: 0.25, gust: 0.20, soil: 0 } };
case 'indoorT':
return { field: 'indoorT', pl: SUNSOAK, daylightOnly: false,
w: { thermal: 1, rain: 0, gust: 0, soil: 0 } };
case 'furSurfaceT':
return { field: 'furSurfaceT', pl: PET_PLATEAU, daylightOnly: true,
w: { thermal: 0.55, rain: 0.30, gust: 0.15, soil: 0 } };
default:
return { field: 'utciAdj', pl: SUNSOAK, daylightOnly: true,
w: { thermal: 0.40, rain: 0.25, gust: 0.20, soil: 0.15 } };
}
}
function comfortableHour(r, crit) {
if (crit.daylightOnly && !(r.elev > 0)) return false;
const t = r[crit.field];
if (t == null) return false;
if (t < crit.pl.min || t > crit.pl.max) return false;
// A measure that carries no weight for this profile can't exclude an hour
// either, or an indoor window would still be cut short by a shower.
if (crit.w.rain && (r.precipProb ?? 0) > BAND_RAIN.limit) return false;
if (crit.w.gust && (r.gust ?? r.va ?? 0) * 2.237 > BAND_GUST.limit) return false;
// Soil comes from a separate feed and is null wherever that fetch fails or // Soil comes from a separate feed and is null wherever that fetch fails or
// the model has no coverage - treat missing as passing, or a soil outage // the model has no coverage - treat missing as passing, or a soil outage
// would silently empty the panel everywhere. Only the wet end excludes: dust // would silently empty the panel everywhere. Only the wet end excludes: dust
// -dry ground is a poor day out for the garden, not for the person on it. // -dry ground is a poor day out for the garden, not for the person on it.
if (r.soilM != null && r.soilM > BAND_SOIL.limit) return false; if (crit.w.soil && r.soilM != null && r.soilM > BAND_SOIL.limit) return false;
return true; return true;
} }
// How good an hour is WITHIN the bands, 0-1. Weighted by how much each // How good an hour is WITHIN the bands, 0-1. Weighted by how much each
// measure actually decides whether the day was worth going out for. // measure actually decides whether the day was worth going out for.
function hourQuality(r) { function hourQuality(r, crit) {
const parts = [ const parts = [
[sunsoakScore(r.utciAdj), 0.40], [plateauScore(r[crit.field], crit.pl), crit.w.thermal],
[bandScore(r.precipProb ?? 0, BAND_RAIN), 0.25], [bandScore(r.precipProb ?? 0, BAND_RAIN), crit.w.rain],
[bandScore((r.gust ?? r.va ?? 0) * 2.237, BAND_GUST), 0.20], [bandScore((r.gust ?? r.va ?? 0) * 2.237, BAND_GUST), crit.w.gust],
[bandScore(r.soilM, BAND_SOIL), 0.15], [bandScore(r.soilM, BAND_SOIL), crit.w.soil],
].filter(([v]) => v != null); ].filter(([v, w]) => v != null && w > 0);
const wsum = parts.reduce((s, [, w]) => s + w, 0); const wsum = parts.reduce((s, [, w]) => s + w, 0);
return wsum > 0 ? parts.reduce((s, [v, w]) => s + v * w, 0) / wsum : 0; return wsum > 0 ? parts.reduce((s, [v, w]) => s + v * w, 0) / wsum : 0;
} }
export function computeBestDay(weekDays, nowLocalISO) { export function computeBestDay(weekDays, nowLocalISO, profile, variant) {
if (!weekDays || weekDays.length === 0) return []; if (!weekDays || weekDays.length === 0) return [];
const crit = bestDayCriteria(profile, variant);
const candidates = []; const candidates = [];
for (const day of weekDays.slice(0, 7)) { for (const day of weekDays.slice(0, 7)) {
@@ -1088,7 +1143,7 @@ export function computeBestDay(weekDays, nowLocalISO) {
let start = -1, len = 0, dayBest = null; let start = -1, len = 0, dayBest = null;
for (let i = 0; i < rows.length; i++) { for (let i = 0; i < rows.length; i++) {
if (comfortableHour(rows[i])) { if (comfortableHour(rows[i], crit)) {
if (start < 0) start = i; if (start < 0) start = i;
len++; len++;
if (!dayBest || len > dayBest.len) dayBest = { start, len, end: i }; if (!dayBest || len > dayBest.len) dayBest = { start, len, end: i };
@@ -1102,21 +1157,26 @@ export function computeBestDay(weekDays, nowLocalISO) {
// temperate summer week half of them qualify dawn to dusk, and picking // temperate summer week half of them qualify dawn to dusk, and picking
// arbitrarily among those is a coin toss. // arbitrarily among those is a coin toss.
const run = rows.slice(dayBest.start, dayBest.end + 1); const run = rows.slice(dayBest.start, dayBest.end + 1);
const quality = run.reduce((s, r) => s + hourQuality(r), 0) / run.length; const quality = run.reduce((s, r) => s + hourQuality(r, crit), 0) / run.length;
candidates.push({ candidates.push({
len: dayBest.len, quality, key: day.key, len: dayBest.len, quality, key: day.key,
from: rows[dayBest.start], to: rows[dayBest.end], from: rows[dayBest.start], to: rows[dayBest.end],
daylight: rows.filter(r => r.elev > 0).length, // What the window is measured AGAINST: the daylight for a profile that
// only counts daylight hours, otherwise the whole day. Using daylight
// for a round-the-clock profile would let a 14-hour overnight window
// score over 100% of a 16-hour day and print "all day" for a spell that
// ends at breakfast.
usable: crit.daylightOnly ? rows.filter(r => r.elev > 0).length : rows.length,
}); });
} }
// Nothing qualifying is itself worth saying - an empty panel reads as a bug, // Nothing qualifying is itself worth saying - an empty panel reads as a bug,
// and "don't bother this week" is a real answer to the question being asked. // and "don't bother this week" is a real answer to the question being asked.
if (candidates.length === 0) { if (candidates.length === 0) {
return [{ return [{
icon: '🌧️', icon: crit.field === 'utciAdj' ? '🌧️' : '🚫',
label: 'Next 7 days', label: 'Next 7 days',
value: 'no settled outdoor windows', value: EMPTY_VALUE[crit.field] ?? EMPTY_VALUE.utciAdj,
alert: false, alert: false,
}]; }];
} }
@@ -1132,7 +1192,7 @@ export function computeBestDay(weekDays, nowLocalISO) {
// window of perfect ones are both worth knowing about, and letting either // window of perfect ones are both worth knowing about, and letting either
// half dominate hides one of them. // half dominate hides one of them.
for (const c of candidates) { for (const c of candidates) {
const span = c.daylight > 0 ? Math.min(c.len / c.daylight, 1) : 0; const span = c.usable > 0 ? Math.min(c.len / c.usable, 1) : 0;
c.score = Math.round(100 * (0.5 * span + 0.5 * c.quality)); c.score = Math.round(100 * (0.5 * span + 0.5 * c.quality));
} }
@@ -1160,7 +1220,7 @@ export function computeBestDay(weekDays, nowLocalISO) {
// A run spanning nearly all the daylight is better said than shown: printing // A run spanning nearly all the daylight is better said than shown: printing
// "6am - 9pm" makes the reader parse a time range to learn "all day". // "6am - 9pm" makes the reader parse a time range to learn "all day".
const window = c.daylight > 0 && c.len >= c.daylight - 1 const window = c.usable > 0 && c.len >= c.usable - 1
? 'all day' ? 'all day'
: `${hh(c.from.iso)} ${hh(endIso)}`; : `${hh(c.from.iso)} ${hh(endIso)}`;
@@ -1182,9 +1242,29 @@ export function computeBestDay(weekDays, nowLocalISO) {
// Header line for the panel: names what is being judged when a variant makes // Header line for the panel: names what is being judged when a variant makes
// that non-obvious, otherwise the plain framing. // that non-obvious, otherwise the plain framing.
export function bestDaysLabel(profile, variant) { export function bestDaysLabel(profile, variant) {
const { mainField, mainLabel } = deriveProfileMain(profile, variant);
// The vehicle and home profiles have no variant to name, but "best times
// out" would be plainly wrong for them - the panel is now reading the cabin
// and the room, not the pavement.
if (profile === 'vehicle') return 'Best times to drive';
if (profile === 'home') return 'Best times indoors';
if (profile === 'pets') return 'Best times for pets';
return profile === 'outdoors' && variant return profile === 'outdoors' && variant
? `Best times for ${deriveProfileMain(profile, variant).mainLabel}` ? `Best times for ${mainLabel}`
: 'Best times out'; : (mainField === 'utciAdj' ? 'Best times out' : `Best times for ${mainLabel}`);
}
// Tooltip on the score bar. It has to name the field being scored, or a
// driver reads "temperatures nearer 20 °C" against the outside air and
// concludes the panel is broken.
export function bestDaysHint(profile, variant, score) {
const { mainField } = deriveProfileMain(profile, variant);
const what = {
vehicleT: 'an ideal driving day — longer stretches with a comfortable cabin score higher',
indoorT: 'an ideal day indoors — longer stretches with the building in comfortable range score higher',
furSurfaceT: 'an ideal day for a pet outside — longer stretches with fur and ground temperatures in safe range score higher',
}[mainField] ?? 'an ideal day out — longer comfortable stretches and temperatures nearer 20 °C score higher';
return `${score}% of ${what}`;
} }
export function computeCropAdvice(weekDays, lat) { export function computeCropAdvice(weekDays, lat) {
+31 -34
View File
@@ -32,25 +32,25 @@ export const FILTER_PROFILES = {
// Basic is the "just tell me" preset - it opens in Quick view (see `view` // Basic is the "just tell me" preset - it opens in Quick view (see `view`
// below) with the smallest useful column set behind it. // below) with the smallest useful column set behind it.
view: 'simple', view: 'simple',
cols: { hour: true, air: false, shadeT: true, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, precipProb: false, lightning: false, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: false, aqi: false, pollen: false, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false }, cols: { hour: true, air: false, shadeT: true, 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: true, precip: true, precipProb: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: false, aqi: false, pollen: false, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false },
}, },
home: { home: {
label: 'Home', label: 'Home',
icon: '🏠', icon: '🏠',
scene: "url('assets/images/profiles/home.png') center / cover no-repeat, linear-gradient(160deg,#b0d890,#d0ecb8)", scene: "url('assets/images/profiles/home.png') center / cover no-repeat, linear-gradient(160deg,#b0d890,#d0ecb8)",
cols: { hour: true, air: true, shadeT: false, rh: true, dew: true, wind: true, dir: false, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: false, precip: true, precipProb: true, lightning: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: true, managedT: true, vis: false, aqi: true, pollen: true, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false }, cols: { hour: true, air: true, shadeT: false, rh: true, dew: true, wind: true, dir: false, 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: true, managedT: true, vis: false, aqi: true, pollen: true, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false },
}, },
vehicle: { vehicle: {
label: 'Vehicle', label: 'Vehicle',
icon: '🚗', icon: '🚗',
scene: "url('assets/images/profiles/vehicle.png') center / cover no-repeat, linear-gradient(160deg,#a8bcc8,#ccd8e4)", scene: "url('assets/images/profiles/vehicle.png') center / cover no-repeat, linear-gradient(160deg,#a8bcc8,#ccd8e4)",
cols: { hour: true, air: true, shadeT: false, rh: false, dew: false, wind: true, dir: false, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, precipProb: true, lightning: true, soilT: false, soilT6: false, soilM: true, concreteT: false, vehicleT: true, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false }, 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: false, utciP: true, precip: true, precipProb: true, soilT: false, soilT6: false, soilM: true, concreteT: false, vehicleT: true, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false },
}, },
pets: { pets: {
label: 'Pets', label: 'Pets',
icon: '🐾', icon: '🐾',
scene: "url('assets/images/profiles/pets.png') center / cover no-repeat, linear-gradient(160deg,#e0c0a0,#f0ddc8)", scene: "url('assets/images/profiles/pets.png') center / cover no-repeat, linear-gradient(160deg,#e0c0a0,#f0ddc8)",
cols: { hour: true, air: true, shadeT: false, rh: false, dew: false, wind: true, dir: false, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: false, precip: true, precipProb: true, lightning: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: true, indoorT: false, managedT: false, vis: false, aqi: true, pollen: true, furSurfaceT: true, pawT: true, petShadeT: true, petHomeT: true }, 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: false, utciP: false, precip: true, precipProb: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: true, indoorT: false, managedT: false, vis: false, aqi: true, pollen: true, furSurfaceT: true, pawT: true, petShadeT: true, petHomeT: true },
}, },
farming: { farming: {
label: 'Farming', label: 'Farming',
@@ -59,7 +59,7 @@ export const FILTER_PROFILES = {
// Farming sits in the Work menu alongside the outdoors variants, so it // Farming sits in the Work menu alongside the outdoors variants, so it
// opens the detailed table the same way they do. // opens the detailed table the same way they do.
view: 'table', view: 'table',
cols: { hour: true, air: true, shadeT: true, rh: true, dew: true, wind: true, dir: true, cloud: true, sun: true, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, precipProb: true, lightning: true, soilT: true, soilT6: true, soilM: true, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: false, aqi: true, pollen: true, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false }, cols: { hour: true, air: true, shadeT: true, rh: true, dew: true, wind: true, dir: true, cloud: true, sun: true, direct: false, diffuse: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, precipProb: true, soilT: true, soilT6: true, soilM: true, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: false, aqi: true, pollen: true, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false },
}, },
outdoors: { outdoors: {
label: 'Places', label: 'Places',
@@ -67,21 +67,21 @@ export const FILTER_PROFILES = {
scene: "url('assets/images/profiles/places.png') center / cover no-repeat, linear-gradient(160deg,#98ccc0,#c4e4dc)", scene: "url('assets/images/profiles/places.png') center / cover no-repeat, linear-gradient(160deg,#98ccc0,#c4e4dc)",
view: 'table', view: 'table',
// Default cols match the first variant (urban). Switching variant updates visibleCols. // Default cols match the first variant (urban). Switching variant updates visibleCols.
cols: { hour: true, air: true, shadeT: true, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: false, burn: true, utciP: true, precip: true, precipProb: true, lightning: true, soilT: false, soilT6: false, soilM: false, concreteT: true, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: true, pollen: false, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false }, cols: { hour: true, air: true, shadeT: true, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, utci: false, uvA: true, uvB: false, burn: true, utciP: true, precip: true, precipProb: true, soilT: false, soilT6: false, soilM: false, concreteT: true, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: true, pollen: false, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false },
}, },
alltemps: { alltemps: {
label: 'Temps', label: 'Temps',
icon: '🌡️', icon: '🌡️',
scene: "url('assets/images/profiles/temps.png') center / cover no-repeat, linear-gradient(160deg,#d8b0b0,#ecd0c8)", scene: "url('assets/images/profiles/temps.png') center / cover no-repeat, linear-gradient(160deg,#d8b0b0,#ecd0c8)",
proOnly: true, proOnly: true,
cols: { hour: true, air: true, shadeT: true, rh: false, dew: false, wind: false, dir: false, cloud: false, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: false, precipProb: false, lightning: false, soilT: true, soilT6: false, soilM: false, concreteT: true, vehicleT: true, indoorT: true, managedT: true, vis: false, aqi: false, pollen: false, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false }, cols: { hour: true, air: true, shadeT: true, rh: false, dew: false, wind: false, dir: false, cloud: false, sun: false, direct: false, diffuse: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: false, precipProb: false, soilT: true, soilT6: false, soilM: false, concreteT: true, vehicleT: true, indoorT: true, managedT: true, vis: false, aqi: false, pollen: false, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false },
}, },
showall: { showall: {
label: 'Show All', label: 'Show All',
icon: '⚙️', icon: '⚙️',
scene: "url('assets/images/profiles/all.png') center / cover no-repeat, linear-gradient(160deg,#b4b4d4,#d4d4ec)", scene: "url('assets/images/profiles/all.png') center / cover no-repeat, linear-gradient(160deg,#b4b4d4,#d4d4ec)",
proOnly: true, proOnly: true,
cols: { hour: true, air: true, shadeT: true, rh: true, dew: true, wind: true, dir: true, cloud: true, sun: true, direct: true, diffuse: true, tmrt: true, delta: true, utci: true, uvA: true, uvB: true, burn: true, utciP: true, precip: true, precipProb: true, lightning: true, soilT: true, soilT6: true, soilM: true, concreteT: true, vehicleT: true, indoorT: true, managedT: true, vis: true, aqi: true, pollen: true, furSurfaceT: true, pawT: true, petShadeT: true, petHomeT: true }, cols: { hour: true, air: true, shadeT: true, rh: true, dew: true, wind: true, dir: true, cloud: true, sun: true, direct: true, diffuse: true, utci: true, uvA: true, uvB: true, burn: true, utciP: true, precip: true, precipProb: true, soilT: true, soilT6: true, soilM: true, concreteT: true, vehicleT: true, indoorT: true, managedT: true, vis: true, aqi: true, pollen: true, furSurfaceT: true, pawT: true, petShadeT: true, petHomeT: true },
}, },
custom: { custom: {
label: 'Custom', label: 'Custom',
@@ -89,39 +89,39 @@ export const FILTER_PROFILES = {
scene: "url('assets/images/profiles/custom.png') center / cover no-repeat, linear-gradient(160deg,#d8b0cc,#ecd0e4)", scene: "url('assets/images/profiles/custom.png') center / cover no-repeat, linear-gradient(160deg,#d8b0cc,#ecd0e4)",
proOnly: true, proOnly: true,
// Blank slate - only Hour is on. User enables whichever columns they want. // Blank slate - only Hour is on. User enables whichever columns they want.
cols: { hour: true, air: false, shadeT: false, rh: false, dew: false, wind: false, dir: false, cloud: false, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: false, precip: false, precipProb: false, lightning: false, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: false, aqi: false, pollen: false, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false }, cols: { hour: true, air: false, shadeT: false, rh: false, dew: false, wind: false, dir: false, cloud: false, sun: false, direct: false, diffuse: false, utci: false, uvA: false, uvB: false, burn: false, utciP: false, precip: false, precipProb: false, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: false, aqi: false, pollen: false, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false },
}, },
}; };
// Places sub-variants - each has its own column set. // Places sub-variants - each has its own column set.
// Selecting a variant applies its cols to visibleCols. // Selecting a variant applies its cols to visibleCols.
export const OUTDOORS_VARIANTS = { export const OUTDOORS_VARIANTS = {
urban: { name: 'Urban', scene: "url('assets/images/profiles/urban.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, tmrt: false, delta: false, utci: false, uvA: true, uvB: false, burn: true, utciP: true, precip: true, precipProb: true, lightning: true, soilT: false, soilT6: false, soilM: false, concreteT: true, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: true, pollen: false, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false } }, urban: { name: 'Urban', scene: "url('assets/images/profiles/urban.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: false, uvA: true, uvB: false, burn: true, utciP: true, precip: true, precipProb: true, soilT: false, soilT6: false, soilM: false, concreteT: true, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: true, pollen: false, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false } },
beach: { name: 'Beach', scene: "url('assets/images/profiles/beach.png') center / cover no-repeat, linear-gradient(160deg,#c2d4cc,#e2ece6)", cols: { hour: true, air: true, shadeT: true, rh: false, dew: true, wind: true, dir: true, cloud: true, sun: true, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: true, burn: true, utciP: true, precip: true, precipProb: true, lightning: 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 } }, beach: { name: 'Beach', scene: "url('assets/images/profiles/beach.png') center / cover no-repeat, linear-gradient(160deg,#c2d4cc,#e2ece6)", cols: { hour: true, air: true, shadeT: true, rh: false, dew: true, wind: true, dir: true, cloud: true, sun: true, direct: false, diffuse: false, utci: false, uvA: true, uvB: true, 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: false, pollen: false, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false } },
events: { name: 'Events', scene: "url('assets/images/profiles/events.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, tmrt: false, delta: false, utci: false, uvA: true, uvB: false, burn: true, utciP: true, precip: true, precipProb: true, lightning: true, soilT: false, soilT6: false, soilM: false, concreteT: true, vehicleT: false, indoorT: false, managedT: false, vis: false, aqi: false, pollen: true } }, events: { name: 'Events', scene: "url('assets/images/profiles/events.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: false, uvA: true, uvB: false, burn: true, utciP: true, precip: true, precipProb: true, soilT: false, soilT6: false, soilM: false, concreteT: true, vehicleT: false, indoorT: false, managedT: false, vis: false, aqi: false, pollen: true } },
festival: { name: 'Festival', scene: "url('assets/images/profiles/festival.png') center / cover no-repeat, linear-gradient(160deg,#c2d4cc,#e2ece6)", cols: { hour: true, air: true, shadeT: true, rh: false, dew: true, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: false, burn: true, utciP: true, precip: true, precipProb: true, lightning: true, soilT: false, soilT6: false, soilM: true, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: false, aqi: true, pollen: true } }, festival: { name: 'Festival', scene: "url('assets/images/profiles/festival.png') center / cover no-repeat, linear-gradient(160deg,#c2d4cc,#e2ece6)", cols: { hour: true, air: true, shadeT: true, rh: false, dew: true, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, utci: false, uvA: true, uvB: false, burn: true, utciP: true, precip: true, precipProb: true, soilT: false, soilT6: false, soilM: true, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: false, aqi: true, pollen: true } },
wintersports: { name: 'Winter Sports', proOnly: true, scene: "url('assets/images/profiles/winter.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: true, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: true, burn: true, utciP: true, precip: true, precipProb: true, lightning: 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 } }, wintersports: { name: 'Winter Sports', proOnly: true, scene: "url('assets/images/profiles/winter.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: true, direct: false, diffuse: false, utci: false, uvA: true, uvB: true, 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: false, pollen: false, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false } },
naturist: { name: 'Naturist', proOnly: true, scene: "url('assets/images/profiles/naturist.png') center / cover no-repeat, linear-gradient(160deg,#c2d4cc,#e2ece6)", cols: { hour: true, air: true, shadeT: true, rh: false, dew: true, wind: true, dir: false, cloud: true, sun: true, direct: false, diffuse: false, tmrt: false, delta: false, utci: true, uvA: true, uvB: true, burn: true, utciP: true, precip: true, precipProb: true, lightning: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: false, aqi: true, pollen: true } }, naturist: { name: 'Naturist', proOnly: true, scene: "url('assets/images/profiles/naturist.png') center / cover no-repeat, linear-gradient(160deg,#c2d4cc,#e2ece6)", cols: { hour: true, air: true, shadeT: true, rh: false, dew: true, wind: true, dir: false, cloud: true, sun: true, direct: false, diffuse: false, utci: true, uvA: true, uvB: true, 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: true, pollen: true } },
sailing: { name: 'Sailing', proOnly: true, scene: "url('assets/images/profiles/sailing.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: true, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: false, burn: true, utciP: true, precip: true, precipProb: true, lightning: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false } }, sailing: { name: 'Sailing', proOnly: true, scene: "url('assets/images/profiles/sailing.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: true, direct: false, diffuse: false, utci: false, uvA: true, 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: false, pollen: false, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false } },
// Places - free // 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, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: true, utciP: true, precip: true, precipProb: true, lightning: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: false, aqi: false, pollen: true } }, 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, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: true, utciP: true, precip: true, precipProb: true, lightning: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: true, 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 } },
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, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: false, precip: true, precipProb: true, lightning: 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 } }, 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 // 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, tmrt: false, delta: false, utci: true, uvA: false, uvB: false, burn: false, utciP: true, precip: true, precipProb: true, lightning: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: true, pollen: false, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false } }, 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 } },
// Activities - free // Activities - free
cycling: { name: 'Cycling', scene: "url('assets/images/profiles/cycling.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, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: true, utciP: true, precip: true, precipProb: true, lightning: 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 } }, cycling: { name: 'Cycling', scene: "url('assets/images/profiles/cycling.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: true, utciP: true, 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 } },
running: { name: 'Running', scene: "url('assets/images/profiles/running.png') center / cover no-repeat, linear-gradient(160deg,#c2d4cc,#e2ece6)", cols: { hour: true, air: true, shadeT: false, rh: true, dew: false, wind: true, dir: false, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: true, uvA: false, uvB: false, burn: true, utciP: true, precip: true, precipProb: true, lightning: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: false, aqi: false, pollen: true } }, running: { name: 'Running', scene: "url('assets/images/profiles/running.png') center / cover no-repeat, linear-gradient(160deg,#c2d4cc,#e2ece6)", cols: { hour: true, air: true, shadeT: false, rh: true, dew: false, wind: true, dir: false, cloud: true, sun: false, direct: false, diffuse: false, utci: true, 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 } },
dogwalk: { name: 'Dog Walking', scene: "url('assets/images/profiles/walking.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, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, precipProb: true, lightning: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: false, aqi: false, pollen: true } }, dogwalk: { name: 'Dog Walking', scene: "url('assets/images/profiles/walking.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: false, 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 } },
// Activities - Pro // Activities - Pro
hiking: { name: 'Hiking', proOnly: true, scene: "url('assets/images/profiles/hiking.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: true, direct: false, diffuse: false, tmrt: false, delta: false, utci: true, uvA: true, uvB: true, burn: true, utciP: true, precip: true, precipProb: true, lightning: 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 } }, hiking: { name: 'Hiking', proOnly: true, scene: "url('assets/images/profiles/hiking.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: true, direct: false, diffuse: false, utci: true, uvA: true, uvB: true, 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: false, pollen: false, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false } },
photography: { name: 'Photography', proOnly: true, scene: "url('assets/images/profiles/photography.png') center / cover no-repeat, linear-gradient(160deg,#c2d4cc,#e2ece6)", cols: { hour: true, air: true, shadeT: true, rh: false, dew: false, wind: false, dir: false, cloud: true, sun: true, direct: true, diffuse: true, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: false, precip: true, precipProb: true, lightning: 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 } }, photography: { name: 'Photography', proOnly: true, scene: "url('assets/images/profiles/photography.png') center / cover no-repeat, linear-gradient(160deg,#c2d4cc,#e2ece6)", cols: { hour: true, air: true, shadeT: true, rh: false, dew: false, wind: false, dir: false, cloud: true, sun: true, direct: true, diffuse: true, 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 } },
fishing: { name: 'Fishing', proOnly: true, scene: "url('assets/images/profiles/fishing.png') center / cover no-repeat, linear-gradient(160deg,#c2d4cc,#e2ece6)", cols: { hour: true, air: true, shadeT: true, rh: false, dew: true, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, precipProb: true, lightning: 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 } }, fishing: { name: 'Fishing', proOnly: true, scene: "url('assets/images/profiles/fishing.png') center / cover no-repeat, linear-gradient(160deg,#c2d4cc,#e2ece6)", cols: { hour: true, air: true, shadeT: true, rh: false, dew: true, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, utci: false, 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: false, pollen: false, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false } },
// Work variants // Work variants
market: { name: 'Market Trading', proOnly: true, scene: "url('assets/images/profiles/market.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, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, precipProb: true, lightning: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false } }, market: { name: 'Market Trading', proOnly: true, scene: "url('assets/images/profiles/market.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: false, 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: false, pollen: false, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false } },
windowcleaning: { name: 'Window Cleaning', proOnly: true, scene: "url('assets/images/profiles/window.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, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, precipProb: true, lightning: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false } }, windowcleaning: { name: 'Window Cleaning', proOnly: true, scene: "url('assets/images/profiles/window.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: true, 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 } },
office: { name: 'Office', proOnly: true, scene: "url('assets/images/profiles/office.png') center / cover no-repeat, linear-gradient(160deg,#c2d4cc,#e2ece6)", cols: { hour: true, air: true, shadeT: false, rh: true, dew: false, wind: false, dir: false, cloud: true, sun: true, direct: true, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, precipProb: true, lightning: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: true, vis: false, aqi: true, pollen: false, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false } }, office: { name: 'Office', proOnly: true, scene: "url('assets/images/profiles/office.png') center / cover no-repeat, linear-gradient(160deg,#c2d4cc,#e2ece6)", cols: { hour: true, air: true, shadeT: false, rh: true, dew: false, wind: false, dir: false, cloud: true, sun: true, direct: true, diffuse: false, utci: false, 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: true, vis: false, aqi: true, pollen: false, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false } },
driver: { name: 'Driver / Trucker', proOnly: true, scene: "url('assets/images/profiles/vehicle.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, tmrt: false, delta: false, utci: false, uvA: true, uvB: false, burn: false, utciP: false, precip: true, precipProb: true, lightning: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: true, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false } }, driver: { name: 'Driver / Trucker', proOnly: true, scene: "url('assets/images/profiles/vehicle.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: true, uvB: false, burn: false, utciP: false, precip: true, precipProb: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: true, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false } },
}; };
// Pollen type options shown in the pulldown selector. // Pollen type options shown in the pulldown selector.
@@ -279,16 +279,13 @@ export const COL_DESCRIPTIONS = {
sun: { title: 'Sun Elevation', desc: 'The angle of the sun above the horizon in degrees. Below 0° the sun has set. The higher the elevation, the more intense the solar radiation reaching the ground.', short: "The angle of the sun above the horizon. Below 0° the sun has set; higher angles mean stronger radiation.", link: "./columns.html#col-sun" }, sun: { title: 'Sun Elevation', desc: 'The angle of the sun above the horizon in degrees. Below 0° the sun has set. The higher the elevation, the more intense the solar radiation reaching the ground.', short: "The angle of the sun above the horizon. Below 0° the sun has set; higher angles mean stronger radiation.", link: "./columns.html#col-sun" },
direct: { title: 'Direct Radiation', desc: 'Shortwave solar radiation arriving in a direct beam from the sun (W/m²). The primary driver of skin heating on sunny days.', short: "Solar radiation arriving in a direct beam from the sun (W/m²) — the main driver of skin heating on sunny days.", link: "./columns.html#col-direct" }, direct: { title: 'Direct Radiation', desc: 'Shortwave solar radiation arriving in a direct beam from the sun (W/m²). The primary driver of skin heating on sunny days.', short: "Solar radiation arriving in a direct beam from the sun (W/m²) — the main driver of skin heating on sunny days.", link: "./columns.html#col-direct" },
diffuse: { title: 'Diffuse Radiation', desc: 'Scattered solar radiation arriving from all directions across the sky (W/m²). Present even under cloud; contributes to overall solar load.', short: "Solar radiation scattered across the sky (W/m²). Present even under cloud, and adds to the total solar load.", link: "./columns.html#col-diffuse" }, diffuse: { title: 'Diffuse Radiation', desc: 'Scattered solar radiation arriving from all directions across the sky (W/m²). Present even under cloud; contributes to overall solar load.', short: "Solar radiation scattered across the sky (W/m²). Present even under cloud, and adds to the total solar load.", link: "./columns.html#col-diffuse" },
tmrt: { title: 'Mean Radiant Temp', desc: 'The combined heat from direct sun, scattered sky light, and ground reflection expressed as a temperature. Typically well above air temperature even on overcast days, and is one of the inputs used to calculate UTCI.', short: "Heat from direct sun, sky light and ground reflection expressed as a temperature. One of the inputs to UTCI.", link: "./columns.html#col-tmrt" },
delta: { title: 'UTCI Air Delta', desc: 'The difference between the UTCI felt temperature and the plain air temperature. A large positive value means solar radiation is adding significant heat stress beyond what the thermometer shows.', short: "The gap between UTCI felt temperature and plain air temperature. A big positive value means the sun is adding real heat stress.", link: "./columns.html#col-delta" },
utci: { title: 'UTCI', desc: 'Universal Thermal Climate Index — the peer-reviewed standard felt temperature, combining air temp, humidity, wind, and solar radiation. Always computed from the raw open-ground reading: it does NOT move when you change the Solar Model, and it does not include precipitation. That is the point of it — a fixed benchmark to read SunSoak against, so the gap between the two columns shows exactly what your surroundings and the weather are adding.', short: "The peer-reviewed standard felt temperature. Always taken from the raw open-ground reading, so it stays fixed as a benchmark to read SunSoak against.", link: "./columns.html#col-utci" }, utci: { title: 'UTCI', desc: 'Universal Thermal Climate Index — the peer-reviewed standard felt temperature, combining air temp, humidity, wind, and solar radiation. Always computed from the raw open-ground reading: it does NOT move when you change the Solar Model, and it does not include precipitation. That is the point of it — a fixed benchmark to read SunSoak against, so the gap between the two columns shows exactly what your surroundings and the weather are adding.', short: "The peer-reviewed standard felt temperature. Always taken from the raw open-ground reading, so it stays fixed as a benchmark to read SunSoak against.", link: "./columns.html#col-utci" },
uvA: { title: 'UV-A Index', desc: 'Estimated UV-A radiation index. UV-A penetrates deeper into the skin and contributes to long-term ageing and some skin cancers, even through glass.', short: "Estimated UV-A index. UV-A reaches deeper into the skin and drives long-term ageing, even through glass.", link: "./columns.html#col-uvA" }, uvA: { title: 'UV-A Index', desc: 'Estimated UV-A radiation index. UV-A penetrates deeper into the skin and contributes to long-term ageing and some skin cancers, even through glass.', short: "Estimated UV-A index. UV-A reaches deeper into the skin and drives long-term ageing, even through glass.", link: "./columns.html#col-uvA" },
uvB: { title: 'UV-B Index', desc: 'Estimated UV-B radiation index. UV-B causes sunburn and is the main driver of vitamin D production. Intensity depends strongly on solar elevation and cloud cover.', short: "Estimated UV-B index. UV-B causes sunburn and drives vitamin D production.", link: "./columns.html#col-uvB" }, uvB: { title: 'UV-B Index', desc: 'Estimated UV-B radiation index. UV-B causes sunburn and is the main driver of vitamin D production. Intensity depends strongly on solar elevation and cloud cover.', short: "Estimated UV-B index. UV-B causes sunburn and drives vitamin D production.", link: "./columns.html#col-uvB" },
burn: { title: 'Burn Time', desc: 'Estimated time to reach one Minimal Erythemal Dose (MED) — the threshold for sunburn — based on the UV index and your selected skin type. This is a guide, not a medical measurement.', short: "Estimated time to sunburn (one MED) for your selected skin type. A guide, not a medical measurement.", link: "./columns.html#col-burn" }, burn: { title: 'Burn Time', desc: 'Estimated time to reach one Minimal Erythemal Dose (MED) — the threshold for sunburn — based on the UV index and your selected skin type. This is a guide, not a medical measurement.', short: "Estimated time to sunburn (one MED) for your selected skin type. A guide, not a medical measurement.", link: "./columns.html#col-burn" },
utciP: { title: 'SunSoak', desc: 'SunScope\'s felt-temperature index. Based on UTCI — the standard combining air temp, humidity, wind, and solar radiation — with added adjustments for rain/snow and your chosen surroundings.', short: "SunScope's felt-temperature index: UTCI plus adjustments for rain or snow and your chosen surroundings.", link: "./columns.html#col-utciP" }, utciP: { title: 'SunSoak', desc: 'SunScope\'s felt-temperature index. Based on UTCI — the standard combining air temp, humidity, wind, and solar radiation — with added adjustments for rain/snow and your chosen surroundings.', short: "SunScope's felt-temperature index: UTCI plus adjustments for rain or snow and your chosen surroundings.", link: "./columns.html#col-utciP" },
precip: { title: 'Precipitation', desc: 'Expected rainfall or snowfall in mm per hour. Snow is shown in cm. Even light drizzle meaningfully reduces felt temperature when combined with wind.', short: "Expected rainfall in mm per hour, or snow in cm. Even drizzle cuts felt temperature noticeably in wind.", link: "./columns.html#col-precip" }, precip: { title: 'Precipitation', desc: 'Expected rainfall or snowfall in mm per hour. Snow is shown in cm. Even light drizzle meaningfully reduces felt temperature when combined with wind.', short: "Expected rainfall in mm per hour, or snow in cm. Even drizzle cuts felt temperature noticeably in wind.", link: "./columns.html#col-precip" },
precipProb: { title: 'Rain Probability', desc: 'Probability of precipitation for this hour as a percentage (0-100%). Derived from Open-Meteo ensemble model runs. A high value means rain is likely even if the expected rate is low - useful for spotting showers that the deterministic forecast may miss.', short: "Chance of precipitation this hour, from ensemble model runs. Useful for spotting showers the main forecast misses.", link: "./columns.html#col-precipProb" }, precipProb: { title: 'Rain Probability', desc: 'Probability of precipitation for this hour as a percentage (0-100%). Derived from Open-Meteo ensemble model runs. A high value means rain is likely even if the expected rate is low - useful for spotting showers that the deterministic forecast may miss. A ⚡ marks a thundery hour: it appears once the Lightning Potential Index passes 5 J/kg (moderate risk) and turns bold above 25 (high risk), tinting the cell amber.', short: "Chance of precipitation this hour, from ensemble model runs. A ⚡ marks hours with lightning potential.", link: "./columns.html#col-precipProb" },
lightning: { title: 'Lightning Potential', desc: 'Lightning Potential Index (LPI) from Open-Meteo in J/kg. A convective energy index — values above 5 suggest moderate lightning risk, above 25 suggest high risk.', short: "Lightning Potential Index in J/kg. Above 5 suggests moderate risk, above 25 high risk.", link: "./columns.html#col-lightning" },
soilT: { title: 'Soil Temperature', desc: 'Temperature of the soil at the surface (0 cm depth). Useful for planting decisions — most seeds germinate above 710 °C.', short: "Soil temperature at the surface. Most seeds germinate above 710 °C.", link: "./columns.html#col-soilT" }, soilT: { title: 'Soil Temperature', desc: 'Temperature of the soil at the surface (0 cm depth). Useful for planting decisions — most seeds germinate above 710 °C.', short: "Soil temperature at the surface. Most seeds germinate above 710 °C.", link: "./columns.html#col-soilT" },
soilT6: { title: 'Soil Temp 6 cm', desc: 'Temperature of the soil at 6 cm depth, the root zone for many crops and seedlings. Lags behind surface temperature by several hours.', short: "Soil temperature at 6 cm — the root zone for many crops. Lags the surface by several hours.", link: "./columns.html#col-soilT6" }, soilT6: { title: 'Soil Temp 6 cm', desc: 'Temperature of the soil at 6 cm depth, the root zone for many crops and seedlings. Lags behind surface temperature by several hours.', short: "Soil temperature at 6 cm — the root zone for many crops. Lags the surface by several hours.", link: "./columns.html#col-soilT6" },
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" }, 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" },
+50 -14
View File
@@ -442,8 +442,12 @@ export function useAppState() {
if (key !== 'custom') { if (key !== 'custom') {
setVisibleCols({ ...profile.cols }); setVisibleCols({ ...profile.cols });
const hasIndoor = profile.cols['indoorT'] || profile.cols['managedT']; const hasIndoor = profile.cols['indoorT'] || profile.cols['managedT'];
setIndoorMode(hasIndoor ? 'on' : 'off'); // Must go through the persisting setters: the Indoors pill and column
setIndoorManaged(false); // read indoorMode, and its initialiser reads it back from localStorage
// on the next load. Setting only the React state left the stored value
// behind, so a reload restored the previous profile's Indoors state.
setIndoorModeAndSave(hasIndoor ? 'on' : 'off');
setIndoorManagedAndSave(false);
if (hasIndoor) setBuildingTypeAndSave('brick'); if (hasIndoor) setBuildingTypeAndSave('brick');
const defaultEnv = VARIANT_DEFAULT_ENV[key]; const defaultEnv = VARIANT_DEFAULT_ENV[key];
if (defaultEnv) setUtciEnvAndSave(defaultEnv); if (defaultEnv) setUtciEnvAndSave(defaultEnv);
@@ -453,8 +457,8 @@ export function useAppState() {
if (customCols) { if (customCols) {
setVisibleCols({ ...customCols }); setVisibleCols({ ...customCols });
const hasIndoor = customCols['indoorT'] || customCols['managedT']; const hasIndoor = customCols['indoorT'] || customCols['managedT'];
setIndoorMode(hasIndoor ? 'on' : 'off'); setIndoorModeAndSave(hasIndoor ? 'on' : 'off');
setIndoorManaged(false); setIndoorManagedAndSave(false);
} }
} }
}; };
@@ -501,7 +505,7 @@ export function useAppState() {
if (variantCols) { if (variantCols) {
setVisibleCols({ ...variantCols }); setVisibleCols({ ...variantCols });
const hasIndoor = variantCols['indoorT'] || variantCols['managedT']; const hasIndoor = variantCols['indoorT'] || variantCols['managedT'];
setIndoorMode(hasIndoor ? 'on' : 'off'); setIndoorModeAndSave(hasIndoor ? 'on' : 'off');
} }
// Auto-set UTCI environment modifier to match the selected variant. // Auto-set UTCI environment modifier to match the selected variant.
const defaultEnv = VARIANT_DEFAULT_ENV[v] ?? 'open'; const defaultEnv = VARIANT_DEFAULT_ENV[v] ?? 'open';
@@ -548,11 +552,19 @@ export function useAppState() {
}; };
const [indoorMode, setIndoorMode] = useState(() => { const [indoorMode, setIndoorMode] = useState(() => {
try { try {
const saved = localStorage.getItem('sunscope_indoor_mode');
if (saved) return saved;
const profile = localStorage.getItem('sunscope_profile') || 'basic'; const profile = localStorage.getItem('sunscope_profile') || 'basic';
const cols = FILTER_PROFILES[profile]?.cols ?? FILTER_PROFILES.basic.cols; const cols = profile === 'outdoors'
return (cols['indoorT'] || cols['managedT']) ? 'on' : 'off'; ? (OUTDOORS_VARIANTS[localStorage.getItem('sunscope_outdoors_variant') || 'urban']?.cols
?? FILTER_PROFILES.outdoors.cols)
: (FILTER_PROFILES[profile]?.cols ?? FILTER_PROFILES.basic.cols);
const offered = !!(cols['indoorT'] || cols['managedT']);
// A profile that has no Indoors columns has no Indoors pill either, so
// an 'on' left over from a profile that did would show the column with
// no way to turn it off. The profile decides whether it's available;
// the saved value only decides the state within a profile that offers it.
if (!offered) return 'off';
const saved = localStorage.getItem('sunscope_indoor_mode');
return saved || 'on';
} catch (e) { return 'off'; } } catch (e) { return 'off'; }
}); });
const setIndoorModeAndSave = (v) => { const setIndoorModeAndSave = (v) => {
@@ -614,6 +626,30 @@ export function useAppState() {
}; };
const openWelcome = () => setWelcomeOpen(true); const openWelcome = () => setWelcomeOpen(true);
// ── 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;
// subscribing (isPro) suppresses it entirely.
const NUDGE_KEY = 'sunscope_nudge_last';
const NUDGE_COOLDOWN = 7 * 24 * 60 * 60 * 1000;
const [weeklyNudgeOpen, setWeeklyNudgeOpen] = useState(false);
useEffect(() => {
if (isPro || welcomeOpen) return;
let last = 0;
try {
last = Number(localStorage.getItem(NUDGE_KEY)) || 0;
// First run for an existing user: start the clock, don't nudge yet.
if (!last) { localStorage.setItem(NUDGE_KEY, String(Date.now())); return; }
} catch (e) { return; }
if (Date.now() - last < NUDGE_COOLDOWN) return;
setWeeklyNudgeOpen(true);
track('weekly_nudge_shown');
}, [isPro, welcomeOpen]);
const closeWeeklyNudge = () => {
try { localStorage.setItem(NUDGE_KEY, String(Date.now())); } catch (e) { /* ignore */ }
setWeeklyNudgeOpen(false);
};
// Restore-access modal ("Already subscribed?"). // Restore-access modal ("Already subscribed?").
const [restoreOpen, setRestoreOpen] = useState(false); const [restoreOpen, setRestoreOpen] = useState(false);
const openRestore = () => setRestoreOpen(true); const openRestore = () => setRestoreOpen(true);
@@ -643,11 +679,10 @@ export function useAppState() {
const [tableRotated, setTableRotated] = useState(() => { const [tableRotated, setTableRotated] = useState(() => {
try { return localStorage.getItem('sunscope_table_rotated') === '1'; } catch (e) { return false; } try { return localStorage.getItem('sunscope_table_rotated') === '1'; } catch (e) { return false; }
}); });
const toggleTableRotated = () => setTableRotated(v => { const setTableRotatedAndSave = (next) => {
const next = !v;
try { localStorage.setItem('sunscope_table_rotated', next ? '1' : '0'); } catch (e) { /* ignore */ } try { localStorage.setItem('sunscope_table_rotated', next ? '1' : '0'); } catch (e) { /* ignore */ }
return next; setTableRotated(!!next);
}); };
// Table row interval: 1 = every hour, 2/3/4 = clock-aligned buckets. Default 2. // Table row interval: 1 = every hour, 2/3/4 = clock-aligned buckets. Default 2.
const [tableInterval, setTableIntervalState] = useState(() => { const [tableInterval, setTableIntervalState] = useState(() => {
@@ -899,11 +934,12 @@ export function useAppState() {
utciEnv, setUtciEnv: setUtciEnvAndSave, utciEnv, setUtciEnv: setUtciEnvAndSave,
showDecimals, toggleShowDecimals, showDecimals, toggleShowDecimals,
welcomeOpen, closeWelcome, openWelcome, welcomeOpen, closeWelcome, openWelcome,
weeklyNudgeOpen, closeWeeklyNudge,
restoreOpen, openRestore, closeRestore, restoreOpen, openRestore, closeRestore,
panelOpen, openPanel, closePanel, panelOpen, openPanel, closePanel,
showUnits, toggleShowUnits, showUnits, toggleShowUnits,
tableInterval, setTableInterval, tableInterval, setTableInterval,
tableRotated, toggleTableRotated, tableRotated, setTableRotated: setTableRotatedAndSave,
forecastView, setForecastView, forecastView, setForecastView,
// table refs // table refs
headStickyRef, headTrackRef, headTableRef, headStickyRef, headTrackRef, headTableRef,
+21 -44
View File
@@ -18,7 +18,8 @@
// label heading text // label heading text
// unit the small print under the heading ("°C", "mm/h", …) // unit the small print under the heading ("°C", "mm/h", …)
// unitNone true → render the unit muted+italic (no data for this day) // unitNone true → render the unit muted+italic (no data for this day)
// group felt | surface | ambient | precip | sky | wind | airqual | solar // group felt | surface | pets | ambient | precip | sky | wind |
// airqual | solar
// visible resolved from visibleCols / indoorMode // visible resolved from visibleCols / indoorMode
// headClass extra class on the heading cell ('utci-tight-head', …) // headClass extra class on the heading cell ('utci-tight-head', …)
// cellClass extra class on the data cell ('utci-dir-cell', …) // cellClass extra class on the data cell ('utci-dir-cell', …)
@@ -163,14 +164,6 @@ const soilMoistureBg = (v) => {
return { bg: `rgba(${r},${g},${b},0.18)`, fg: '#3a2a10' }; return { bg: `rgba(${r},${g},${b},0.18)`, fg: '#3a2a10' };
}; };
const deltaBg = (v) => {
if (v == null || isNaN(v)) return 'transparent';
if (Math.abs(v) < 1) return 'transparent';
const x = Math.min(1, Math.abs(v) / 12);
const a = 0.035 + x * 0.13;
return v > 0 ? `rgba(230,140,50,${a.toFixed(3)})` : `rgba(80,135,210,${a.toFixed(3)})`;
};
const burnBg = (mins, uv) => { const burnBg = (mins, uv) => {
if (!isFinite(mins) || uv <= 0) return 'transparent'; if (!isFinite(mins) || uv <= 0) return 'transparent';
if (mins >= 240) return 'transparent'; if (mins >= 240) return 'transparent';
@@ -229,6 +222,7 @@ export function buildColumnDefs(ctx) {
...tempCol('managedT', 'Managed', '°C est.', 'felt', r => r.managedT), ...tempCol('managedT', 'Managed', '°C est.', 'felt', r => r.managedT),
visible: indoorMode === 'on' && indoorManaged, visible: indoorMode === 'on' && indoorManaged,
}, },
tempCol('utci', 'UTCI', '°C felt', 'felt', r => r.utci),
{ {
key: 'burn', label: 'Burn', unit: 'to MED', group: 'felt', key: 'burn', label: 'Burn', unit: 'to MED', group: 'felt',
visible: !!visibleCols.burn, visible: !!visibleCols.burn,
@@ -243,25 +237,7 @@ export function buildColumnDefs(ctx) {
}; };
}, },
}, },
tempCol('utci', 'UTCI', '°C felt', 'felt', r => r.utci),
{
key: 'delta', label: 'Δ', unit: 'UTCIAir', group: 'felt',
visible: !!visibleCols.delta,
render: (r) => {
const d = r.utci - r.Ta;
return {
content: `${d > 0 ? '+' : ''}${fmt(d)}${u('°C')}`,
style: {
color: d > 3 ? '#c8601a' : d < -3 ? '#3f73c4' : '#4a3218',
background: deltaBg(d),
},
};
},
},
tempCol('tmrt', 'Tmrt', '°C', 'felt', r => r.Tmrt),
tempCol('concreteT', 'Concrete', '°C surface', 'surface', r => r.concreteT), tempCol('concreteT', 'Concrete', '°C surface', 'surface', r => r.concreteT),
tempCol('furSurfaceT', 'Fur', '°C surface', 'surface', r => r.furSurfaceT, { pet: true, petTitle: true }),
tempCol('pawT', 'Paw', '°C surface', 'surface', r => r.pawT, { pet: true, petTitle: true }),
tempCol('soilT', 'Soil °C', 'surface', 'surface', r => r.soilT0), tempCol('soilT', 'Soil °C', 'surface', 'surface', r => r.soilT0),
tempCol('soilT6', 'Soil 6cm', '°C root', 'surface', r => r.soilT6), tempCol('soilT6', 'Soil 6cm', '°C root', 'surface', r => r.soilT6),
{ {
@@ -275,10 +251,12 @@ export function buildColumnDefs(ctx) {
}; };
}, },
}, },
tempCol('petShadeT', 'Pet Shade', '°C', 'ambient', r => r.petShadeT, { pet: true, petTitle: true, headClass: 'utci-tight-head' }), tempCol('furSurfaceT', 'Fur', '°C surface', 'pets', r => r.furSurfaceT, { pet: true, petTitle: true }),
tempCol('pawT', 'Paw', '°C surface', 'pets', r => r.pawT, { pet: true, petTitle: true }),
tempCol('petShadeT', 'Pet Shade', '°C', 'pets', r => r.petShadeT, { pet: true, petTitle: true, headClass: 'utci-tight-head' }),
// Pet Home reads the same indoorT figure as the Indoors column, just on // Pet Home reads the same indoorT figure as the Indoors column, just on
// the pet-calibrated colour scale. // the pet-calibrated colour scale.
tempCol('petHomeT', 'Pet Home', '°C est.', 'ambient', r => r.indoorT, { pet: true, petTitle: true }), tempCol('petHomeT', 'Pet Home', '°C est.', 'pets', r => r.indoorT, { pet: true, petTitle: true }),
tempCol('air', 'Air', '°C', 'ambient', r => r.Ta, { headClass: 'utci-tight-head' }), tempCol('air', 'Air', '°C', 'ambient', r => r.Ta, { headClass: 'utci-tight-head' }),
{ {
key: 'rh', label: 'RH', unit: '%', group: 'ambient', key: 'rh', label: 'RH', unit: '%', group: 'ambient',
@@ -306,27 +284,26 @@ export function buildColumnDefs(ctx) {
}), }),
}, },
{ {
// Rain % carries the storm warning too: rather than spend a whole
// column on the Lightning Potential Index, an amber ⚡ rides alongside
// the percentage whenever LPI clears the moderate-risk threshold (5),
// going bold at the high-risk one (25). Same thresholds the old
// Lightning column and the Lightning-risk insight use.
key: 'precipProb', label: 'Rain', unit: '%', group: 'precip', key: 'precipProb', label: 'Rain', unit: '%', group: 'precip',
visible: !!visibleCols.precipProb, headClass: 'utci-tight-head', visible: !!visibleCols.precipProb, headClass: 'utci-tight-head',
render: (r) => ({
content: `${r.precipProb}${u('%')}`,
style: {
background: scaleBg(r.precipProb, 0, 100, '70,145,200'),
color: r.precipProb >= 50 ? '#1a4a70' : r.precipProb > 0 ? '#2a6a90' : '#7a8a90',
},
}),
},
{
key: 'lightning', label: '⚡', unit: 'LPI', group: 'precip',
visible: !!visibleCols.lightning, headClass: 'utci-tight-head',
render: (r) => { render: (r) => {
const lpi = r.lightning ?? 0; const lpi = r.lightning ?? 0;
const storm = lpi >= 25 ? 'high' : lpi >= 5 ? 'mod' : null;
return { return {
content: lpi > 0 ? `${Math.round(lpi)}` : '', title: storm ? `Lightning potential ${Math.round(lpi)} J/kg — ${storm === 'high' ? 'high' : 'moderate'} risk` : undefined,
content: html`${r.precipProb}${u('%')}${storm
? html`<span style=${{ marginLeft: '4px', fontWeight: storm === 'high' ? 700 : 'normal', opacity: storm === 'high' ? 1 : 0.75, color: storm === 'high' ? '#7a4800' : '#6a5800' }}>⚡</span>`
: ''}`,
style: { style: {
background: lpi >= 25 ? 'rgba(255,180,0,0.25)' : lpi >= 5 ? 'rgba(255,210,0,0.15)' : 'transparent', background: storm === 'high' ? 'rgba(255,180,0,0.25)'
color: lpi >= 25 ? '#7a4800' : lpi >= 5 ? '#6a5800' : '#7a8a90', : storm === 'mod' ? 'rgba(255,210,0,0.15)'
textAlign: 'center', : scaleBg(r.precipProb, 0, 100, '70,145,200'),
color: r.precipProb >= 50 ? '#1a4a70' : r.precipProb > 0 ? '#2a6a90' : '#7a8a90',
}, },
}; };
}, },
+52
View File
@@ -56,6 +56,58 @@ export function utciCategory(u) {
} }
} }
// -------------------------------------------------------------------
// BAND COLOUR RAMP - continuous version of the banded palette.
// -------------------------------------------------------------------
// utciCategory()/petCategory() hand back ONE flat colour per band, which
// is right for table cells (a cell states which band you are in) but wrong
// for anything that should read as a temperature gradient: every day from
// 27 -C to 31.9 -C is the same "Caution" swatch, so the only visible change
// is the hard jump at the next threshold.
//
// bandRampRgb() anchors each band's colour at the BOTTOM of that band's
// range and interpolates toward the next band's colour across the band. So
// a temperature sitting exactly on a threshold still gets that band's own
// published colour (32 -C is still the Extreme orange), and every degree
// above it slides proportionally toward the next tier - 30 -C lands 60% of
// the way from Caution to Extreme instead of being identical to 27 -C.
//
// The solid Danger band is left out of the ramp: it's a flat stop-everything
// alarm colour, not part of the gradient, so the ramp clamps at the last
// graded band and callers keep handling Danger separately.
// -------------------------------------------------------------------
const rampAnchorCache = new WeakMap();
function rampAnchors(bands) {
let anchors = rampAnchorCache.get(bands);
if (anchors) return anchors;
anchors = [];
let lower = null;
for (const band of bands) {
if (band.solid) break;
// The first band is open-ended downward; give it a nominal 10 -C span so
// it still has an anchor below its own threshold.
anchors.push({ at: lower === null ? band.max - 10 : lower, rgb: hexToRgb(band.bg) });
if (band.max === undefined) break;
lower = band.max;
}
rampAnchorCache.set(bands, anchors);
return anchors;
}
export function bandRampRgb(t, bands) {
const anchors = rampAnchors(bands);
if (t === null || !isFinite(t)) return anchors[0].rgb.slice();
if (t <= anchors[0].at) return anchors[0].rgb.slice();
const last = anchors[anchors.length - 1];
if (t >= last.at) return last.rgb.slice();
for (let i = 1; i < anchors.length; i++) {
const a = anchors[i - 1], b = anchors[i];
if (t <= b.at) return mixRgb(a.rgb, b.rgb, (t - a.at) / (b.at - a.at));
}
return last.rgb.slice();
}
// ------------------------------------------------------------------- // -------------------------------------------------------------------
// WEATHER TINT PALETTE - single source of truth for "what colour is // WEATHER TINT PALETTE - single source of truth for "what colour is
// this weather", shared by the day tabs (components/DayTabs.js) and the // this weather", shared by the day tabs (components/DayTabs.js) and the
+2 -5
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Column reference · SunScope</title> <title>Column reference · SunScope</title>
<meta name="description" content="Every column in the SunScope forecast table explained in plain language — from Air and Shade through wind, UV, rain probability, lightning potential, air quality, pollen, and SunSoak." /> <meta name="description" content="Every column in the SunScope forecast table explained in plain language — from Air and Shade through wind, UV, rain probability with lightning warnings, air quality, pollen, and SunSoak." />
<meta name="robots" content="index, follow" /> <meta name="robots" content="index, follow" />
<link rel="canonical" href="https://sunscope.net/columns.html" /> <link rel="canonical" href="https://sunscope.net/columns.html" />
@@ -95,8 +95,6 @@
<tr id="col-sun"><td><strong>Sun °</strong></td><td>The angle of the sun above the horizon in degrees. Below 0° the sun has set. The higher the elevation, the more intense the radiation reaching the ground.</td></tr> <tr id="col-sun"><td><strong>Sun °</strong></td><td>The angle of the sun above the horizon in degrees. Below 0° the sun has set. The higher the elevation, the more intense the radiation reaching the ground.</td></tr>
<tr id="col-direct"><td><strong>Direct W/m²</strong></td><td>Shortwave solar radiation arriving as a direct beam from the sun. The primary driver of skin heating on sunny days.</td></tr> <tr id="col-direct"><td><strong>Direct W/m²</strong></td><td>Shortwave solar radiation arriving as a direct beam from the sun. The primary driver of skin heating on sunny days.</td></tr>
<tr id="col-diffuse"><td><strong>Diffuse W/m²</strong></td><td>Solar radiation scattered from all directions across the sky. Present even under cloud cover; contributes to the overall solar heat load.</td></tr> <tr id="col-diffuse"><td><strong>Diffuse W/m²</strong></td><td>Solar radiation scattered from all directions across the sky. Present even under cloud cover; contributes to the overall solar heat load.</td></tr>
<tr id="col-tmrt"><td><strong>Tmrt °C</strong></td><td>Mean Radiant Temperature — the combined heat from direct sun, scattered sky light, and ground reflection expressed as a temperature. Can exceed air temperature by 2030 °C on a sunny day; this is why shade feels so much cooler, and it is one of the inputs to UTCI.</td></tr>
<tr id="col-delta"><td><strong>UTCIAir Δ</strong></td><td>The gap between the UTCI felt temperature and the plain air temperature. A large positive value means solar radiation is adding significant heat stress beyond what the thermometer shows.</td></tr>
<tr id="col-utci"><td><strong>UTCI °C</strong></td><td>Universal Thermal Climate Index — the peer-reviewed standard felt temperature, combining air temp, humidity, wind, and solar radiation. Always computed from the raw open-ground reading: it does not move when you change the Solar Model, and it excludes precipitation. That is deliberate — it is the fixed benchmark to read SunSoak against, so the gap between the two shows exactly what your surroundings and the weather are adding.</td></tr> <tr id="col-utci"><td><strong>UTCI °C</strong></td><td>Universal Thermal Climate Index — the peer-reviewed standard felt temperature, combining air temp, humidity, wind, and solar radiation. Always computed from the raw open-ground reading: it does not move when you change the Solar Model, and it excludes precipitation. That is deliberate — it is the fixed benchmark to read SunSoak against, so the gap between the two shows exactly what your surroundings and the weather are adding.</td></tr>
<tr id="col-uvA"><td><strong>UV-A</strong></td><td>Estimated UV-A radiation index. UV-A penetrates deeper into the skin and contributes to long-term ageing and some skin cancers, even through glass. Present throughout daylight hours.</td></tr> <tr id="col-uvA"><td><strong>UV-A</strong></td><td>Estimated UV-A radiation index. UV-A penetrates deeper into the skin and contributes to long-term ageing and some skin cancers, even through glass. Present throughout daylight hours.</td></tr>
<tr id="col-uvB"><td><strong>UV-B</strong></td><td>Estimated UV-B radiation index. Causes sunburn and drives vitamin D production. Intensity depends strongly on solar elevation and cloud cover, falling sharply at low sun angles.</td></tr> <tr id="col-uvB"><td><strong>UV-B</strong></td><td>Estimated UV-B radiation index. Causes sunburn and drives vitamin D production. Intensity depends strongly on solar elevation and cloud cover, falling sharply at low sun angles.</td></tr>
@@ -104,8 +102,7 @@
<tr id="col-utciP"><td><strong>SunSoak °C</strong></td><td>SunScope's signature felt-temperature index. Starts from UTCI then adds three layers: a rain and snow penalty (wet clothing can drop felt temperature by up to 8 °C), a Solar Model for where you actually are (forest, alpine, beach, river, open water, desert), and full mean radiant temperature from surrounding surfaces. The most honest single answer to: what will it actually feel like out there?</td></tr> <tr id="col-utciP"><td><strong>SunSoak °C</strong></td><td>SunScope's signature felt-temperature index. Starts from UTCI then adds three layers: a rain and snow penalty (wet clothing can drop felt temperature by up to 8 °C), a Solar Model for where you actually are (forest, alpine, beach, river, open water, desert), and full mean radiant temperature from surrounding surfaces. The most honest single answer to: what will it actually feel like out there?</td></tr>
<tr id="col-shadeT"><td><strong>Shade °C felt</strong></td><td>SunSoak with the sun taken away — the felt temperature standing in the typical shade of the selected Solar Model (building shadow, beach umbrella, tree canopy, boat panels, and so on). You are still outdoors, so wind, humidity and rain affect you exactly as they do in the open; only the direct beam and part of the sky are blocked. Expect several degrees below SunSoak in daytime sun, and near-identical readings after dark, when there is no sun to stand out of.</td></tr> <tr id="col-shadeT"><td><strong>Shade °C felt</strong></td><td>SunSoak with the sun taken away — the felt temperature standing in the typical shade of the selected Solar Model (building shadow, beach umbrella, tree canopy, boat panels, and so on). You are still outdoors, so wind, humidity and rain affect you exactly as they do in the open; only the direct beam and part of the sky are blocked. Expect several degrees below SunSoak in daytime sun, and near-identical readings after dark, when there is no sun to stand out of.</td></tr>
<tr id="col-precip"><td><strong>Precip mm</strong></td><td>Expected rainfall in mm per hour; snowfall is shown in cm. Even light drizzle meaningfully reduces felt temperature when combined with wind.</td></tr> <tr id="col-precip"><td><strong>Precip mm</strong></td><td>Expected rainfall in mm per hour; snowfall is shown in cm. Even light drizzle meaningfully reduces felt temperature when combined with wind.</td></tr>
<tr id="col-precipProb"><td><strong>Rain %</strong></td><td>Probability of precipitation for this hour (0100%), derived from Open-Meteo ensemble model runs. A high value means rain is likely even if the expected rate is low — useful for spotting showers the deterministic forecast may miss.</td></tr> <tr id="col-precipProb"><td><strong>Rain %</strong></td><td>Probability of precipitation for this hour (0100%), derived from Open-Meteo ensemble model runs. A high value means rain is likely even if the expected rate is low — useful for spotting showers the deterministic forecast may miss. A ⚡ beside the percentage marks a thundery hour, driven by the Lightning Potential Index (LPI) from Open-Meteo: it appears above 5 J/kg (moderate risk) and turns bold above 25 (high risk), with the cell tinted amber either way.</td></tr>
<tr id="col-lightning"><td><strong>Lightning</strong></td><td>Lightning Potential Index (LPI) from Open-Meteo in J/kg — a convective energy index. Values above 5 suggest moderate lightning risk, above 25 suggest high risk.</td></tr>
<tr id="col-soilT"><td><strong>Soil °C surface</strong></td><td>Temperature of the soil at the surface (0 cm depth). Most seeds germinate above 710 °C. Useful for sowing decisions and frost assessment.</td></tr> <tr id="col-soilT"><td><strong>Soil °C surface</strong></td><td>Temperature of the soil at the surface (0 cm depth). Most seeds germinate above 710 °C. Useful for sowing decisions and frost assessment.</td></tr>
<tr id="col-soilT6"><td><strong>Soil °C 6 cm</strong></td><td>Soil temperature at 6 cm depth — the root zone for many crops and seedlings. Lags behind the surface by several hours.</td></tr> <tr id="col-soilT6"><td><strong>Soil °C 6 cm</strong></td><td>Soil temperature at 6 cm depth — the root zone for many crops and seedlings. Lags behind the surface by several hours.</td></tr>
<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-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>
+4 -4
View File
@@ -179,10 +179,10 @@
<p> <p>
Full control over which columns appear in the table, starting from a blank slate. Toggle any Full control over which columns appear in the table, starting from a blank slate. Toggle any
combination of air temperature, Shade Air, dew point, relative humidity, wind, direction, cloud, combination of air temperature, Shade Air, dew point, relative humidity, wind, direction, cloud,
visibility, air quality index, pollen, sun elevation, direct and diffuse radiation, mean radiant visibility, air quality index, pollen, sun elevation, direct and diffuse radiation,
temperature, UTCI delta, UTCI, UV-A, UV-B, burn time, vehicle interior, indoor, managed indoor, UTCI, UV-A, UV-B, burn time, vehicle interior, indoor, managed indoor,
soil temperatures, soil moisture, concrete surface, precipitation, rain probability, lightning soil temperatures, soil moisture, concrete surface, precipitation, rain probability,
potential, and SunSoak. Build exactly the view that suits your situation. and SunSoak. Build exactly the view that suits your situation.
</p> </p>
</div> </div>