UI Overhaul
Profiles now in popup
Current profile always on screen
Hidden columns only when edit neeeded
Add pulldowns in to profile config
Customised the day tabs for vehicle, indoor and pet
This commit is contained in:
fraxle
2026-07-18 15:45:20 +01:00
parent f92e191a44
commit 916efca55e
13 changed files with 1594 additions and 610 deletions
+520 -61
View File
@@ -85,7 +85,8 @@
} }
.utci-day-tab { .utci-day-tab {
flex: 1 0 auto; flex: 1 0 0;
min-width: 90px;
padding: 10px 20px; padding: 10px 20px;
cursor: pointer; cursor: pointer;
font-family: Manrope, sans-serif; font-family: Manrope, sans-serif;
@@ -96,6 +97,7 @@
background: transparent; background: transparent;
border: 1px solid #c9b08a; border: 1px solid #c9b08a;
color: #b09870; color: #b09870;
overflow-wrap: break-word;
transition: color 0.2s, border-color 0.2s, background-color 0.2s, filter 0.15s; transition: color 0.2s, border-color 0.2s, background-color 0.2s, filter 0.15s;
} }
@@ -165,6 +167,69 @@
text-align: center; text-align: center;
} }
/* Config strip - value-only pickers for the felt-model settings (vehicle
type, building type, fur colour, skin type, pollen type, SunSoak env).
Sits directly under the profile picker, always visible (not tucked away
in the collapsible Columns accordion) so these settings stay as
prominent as the profile choice that they belong to. */
.col-toggles-config {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 12px;
align-items: center;
margin-top: 0;
padding: 10px 14px;
background: #fffcf2;
border: 1.5px solid #c9b08a;
border-top: none;
}
.col-toggles-config-item {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
background: rgba(255,255,255,0.6);
border: 1px solid #e6d7b8;
border-radius: 5px;
}
.config-item-label {
font-family: Manrope, sans-serif;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #9a7a4a;
white-space: nowrap;
}
/* Main thermal selection - the config item whose value also drives the
day-tab hi/lo readout. Always sorted first (see DayTabs.js) and marked
with a badge so it reads as distinct from the other config pickers. */
.col-toggles-config-item--main {
padding: 8px 8px;
border: 1.5px solid #c8922a;
border-radius: 4px;
background: rgba(200,146,42,0.08);
}
.config-item-main-badge {
display: block;
flex: 0 0 100%;
text-align: center;
font-family: Manrope, sans-serif;
font-size: 13px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #9a6a1a;
padding: 0;
white-space: nowrap;
cursor: help;
}
/* Single scrollable row of profile cards */ /* Single scrollable row of profile cards */
.profile-scroll { .profile-scroll {
display: flex; display: flex;
@@ -241,6 +306,223 @@
.profile-scroll-wrap.fade-left .profile-scroll-chevron.left { opacity: 1; } .profile-scroll-wrap.fade-left .profile-scroll-chevron.left { opacity: 1; }
.profile-scroll-wrap.fade-right .profile-scroll-chevron.right { opacity: 1; } .profile-scroll-wrap.fade-right .profile-scroll-chevron.right { opacity: 1; }
/* ── Profile & config nav dropdown ──────────────────────────────────────
The profile picker + config strip used to sit in a permanent full-width
band above the day tabs. They now live in a dropdown that expands
downward directly under the (now permanently floating) top nav, opened by
the profile trigger embedded in the middle of that nav. Keeps the
original full-width horizontal layout (profile tabs, horizontally
scrolling card row, config strip) - just collapsed by default. */
/* Floating trigger, fixed to the bottom of the viewport (the dropdown itself
now slides up from the bottom too — see .profile-dropdown below — so the
button sits right where the panel will appear from). Always visible,
independent of the nav/header, which has gone back to scrolling away
normally instead of floating. */
.floating-profile-btn {
position: fixed;
left: 50%;
bottom: 8px;
transform: translateX(-50%);
z-index: 98;
display: flex;
align-items: center;
gap: 8px;
max-width: 92vw;
padding: 10px 18px;
background: #fdf8ee;
border: 1.5px solid #c9b08a;
border-radius: 999px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.10);
cursor: pointer;
color: #6a4c1a;
transition: background 0.15s, border-color 0.15s, box-shadow 0.15s;
}
.floating-profile-btn:hover { background: #fff; border-color: #c8922a; box-shadow: 0 3px 10px rgba(0, 0, 0, 0.14); }
.floating-profile-btn.is-open { background: #fff; border-color: #c8922a; }
.floating-profile-icon { font-size: 16px; line-height: 1; flex: none; }
/* No fixed max-width — the pill grows to fit the label. Ellipsis only
kicks in once the whole button hits its 92vw cap (min-width:0 lets these
flex children actually shrink instead of forcing the button to overflow). */
.floating-profile-val {
font-family: Manrope, sans-serif;
font-size: 13px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.02em;
color: #1e1208;
min-width: 0;
flex: 0 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.floating-profile-sub {
font-family: Manrope, sans-serif;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.02em;
color: #6b4f2a;
min-width: 0;
flex: 0 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.floating-profile-caret {
font-size: 10px;
transition: transform 0.2s ease;
}
.floating-profile-btn.is-open .floating-profile-caret { transform: rotate(180deg); }
/* Backdrop + dropdown panel — always mounted once opened for the first time
(see ConfigPanel.js), visibility/position driven by the .is-open class so
both the open AND close transitions can animate (a conditionally-mounted
element only ever gets an entrance animation, never an exit one). */
.profile-dropdown-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.35);
z-index: 99;
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: opacity 0.2s ease, visibility 0s linear 0.2s;
}
.profile-dropdown-overlay.is-open {
opacity: 1;
visibility: visible;
pointer-events: auto;
transition: opacity 0.2s ease, visibility 0s linear 0s;
}
.profile-dropdown {
position: fixed;
left: 0;
right: 0;
bottom: 0;
max-height: calc(100vh - 24px);
z-index: 101;
background: #f5edd6;
border-top: 1.5px solid #c9b08a;
box-shadow: 0 -10px 28px rgba(0, 0, 0, 0.18);
opacity: 0;
visibility: hidden;
pointer-events: none;
/* Scales up from the floating-profile-btn's position (bottom-centre) so
the panel reads as growing out of that button, and shrinks back into
it on close, instead of a generic slide. */
transform-origin: 50% 100%;
transform: scale(0.85);
transition: opacity 0.22s cubic-bezier(0.22, 1, 0.36, 1),
transform 0.22s cubic-bezier(0.22, 1, 0.36, 1),
visibility 0s linear 0.22s;
}
.profile-dropdown.is-open {
opacity: 1;
visibility: visible;
pointer-events: auto;
transform: scale(1);
transition: opacity 0.22s cubic-bezier(0.22, 1, 0.36, 1),
transform 0.22s cubic-bezier(0.22, 1, 0.36, 1),
visibility 0s linear 0s;
}
.profile-dropdown-body {
max-width: 900px;
margin: 0 auto;
max-height: calc(100vh - 24px);
overflow-y: auto;
padding: 12px 24px 16px;
}
.profile-dropdown-close {
position: absolute;
top: 10px;
right: 14px;
background: none;
border: none;
font-size: 24px;
line-height: 1;
color: #b09870;
cursor: pointer;
padding: 2px 8px;
border-radius: 6px;
transition: color 0.15s, background 0.15s;
}
.profile-dropdown-close:hover { color: #1e1208; background: rgba(176, 152, 112, 0.16); }
/* Read-only "what am I looking at" row above the day tabs — states the
active profile and the thermal basis (SunSoak env / vehicle / building /
fur colour) driving the numbers below. Clicking/Enter opens the same
profile & config dropdown as the nav trigger. */
.active-profile-row {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: 12px;
padding: 2px 16px;
margin-bottom: 6px;
/* --row-bg is set inline per the exact selected option (see ROW_GRADIENTS
in DayTabs.js) — falls back to a flat parchment tone if unset. */
background: var(--row-bg, #fdf8ee);
border: 1.5px solid #d4c0a0;
border-radius: 6px;
cursor: pointer;
transition: filter 0.15s, border-color 0.15s;
}
.active-profile-row:hover,
.active-profile-row:focus-visible {
filter: brightness(1.06);
border-color: #c8922a;
outline: none;
}
.active-profile-row-item {
display: inline-flex;
align-items: center;
gap: 7px;
}
.active-profile-row-label {
font-family: Manrope, sans-serif;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #9a7d5a;
}
.active-profile-row-value {
font-family: Manrope, sans-serif;
font-size: 15px;
font-weight: 800;
letter-spacing: 0.02em;
text-transform: uppercase;
color: #1e1208;
}
.active-profile-row-sep { color: #c0a880; font-size: 16px; }
.active-profile-row-edit {
font-family: Manrope, sans-serif;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.04em;
color: #9a6a1a;
margin-left: 4px;
}
/* Border + text accent per thermal-model group, reusing the same colour
groups the CustomSelect pickers use (see .cs-btn.grp-* in ui.css) — e.g.
SunSoak's "Solar model" dropdown is grp-felt. The background itself comes
from --row-bg (inline, per exact selection — see ROW_GRADIENTS); these
classes only set the border/text so the row still reads as belonging to
its category even though the gradient varies option-to-option. */
.active-profile-row.grp-felt { border-color: rgba(7,92,58,0.35); }
.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-surface { border-color: rgba(11,62,114,0.30); }
.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-ambient .active-profile-row-value { color: #3c3a35; }
.active-profile-row.grp-surface .active-profile-row-value { color: #0b3e72; }
/* Vertical rule separating general-use profiles from technical ones */ /* Vertical rule separating general-use profiles from technical ones */
.profile-divider { .profile-divider {
display: inline-block; display: inline-block;
@@ -394,8 +676,22 @@
background: #fdf8ee; background: #fdf8ee;
border: 1.5px solid #d4c0a0; border: 1.5px solid #d4c0a0;
border-bottom: none; border-bottom: none;
position: relative;
} }
/* Whole Columns bar (Hours + column pills) stays collapsed behind the
Edit columns / Hide Columns toggle in the toolbar row until opened.
Scoped to detailed (table) view — quick view keeps its own reduced
col-toggles bar (Hours only) visible regardless of this state. */
.table-main:not(.table-main--simple) .col-toggles {
display: none;
}
.table-main:not(.table-main--simple) .col-toggles.col-toggles--open {
display: flex;
}
.col-toggles-display-opts { .col-toggles-display-opts {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -406,18 +702,37 @@
} }
.col-toggles-label { .col-toggles-label {
font-family: Manrope, sans-serif; display: none;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #7a5c2a;
margin-right: 4px;
white-space: nowrap;
} }
.col-toggles-edit-btn { .col-toggles-edit-btn {
display: none; display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
width: 150px;
padding: 6px 12px;
background: #f5edd6;
border: 1.5px solid #c9b08a;
color: #7a5c2a;
font-family: Manrope, sans-serif;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
cursor: pointer;
text-align: left;
}
.col-toggles-edit-btn-icon {
width: 12px;
height: 12px;
flex-shrink: 0;
transition: transform 0.2s ease;
}
.col-toggles-edit-btn--open .col-toggles-edit-btn-icon {
transform: rotate(180deg);
} }
.col-toggles-body { .col-toggles-body {
@@ -620,6 +935,15 @@
top: 72px; top: 72px;
} }
/* Profile/config dropdown - full width and scrollable on phones since it
can run taller than the viewport once the config strip wraps. */
.profile-dropdown-body {
max-height: calc(100vh - 16px);
overflow-y: auto;
}
.floating-profile-val { max-width: 90px; }
.floating-profile-sub { max-width: 90px; }
/* Shrink the day-tab labels on phones so more days stay readable /* Shrink the day-tab labels on phones so more days stay readable
within the viewport before the tab strip needs to scroll. */ within the viewport before the tab strip needs to scroll. */
.utci-day-tab { .utci-day-tab {
@@ -1128,53 +1452,14 @@
/* ── Column toggles bar ───────────────────────────────────────────── /* ── Column toggles bar ─────────────────────────────────────────────
Same approach — label full row, toggles at ~31% each. */ Same approach — label full row, toggles at ~31% each. */
.col-toggles { .col-toggles,
.col-toggles-config {
flex-wrap: wrap; flex-wrap: wrap;
gap: 6px; gap: 6px;
padding: 10px 10px; padding: 10px 10px;
align-items: stretch; align-items: stretch;
} }
.col-toggles-label {
display: none;
}
.col-toggles-edit-btn {
display: flex;
align-items: center;
justify-content: space-between;
flex: 0 0 100%;
padding: 8px 12px;
background: #f5edd6;
border: 1.5px solid #c9b08a;
color: #7a5c2a;
font-family: Manrope, sans-serif;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
cursor: pointer;
text-align: left;
}
.col-toggles-edit-btn-icon {
width: 12px;
height: 12px;
flex-shrink: 0;
transition: transform 0.2s ease;
}
.col-toggles-edit-btn--open .col-toggles-edit-btn-icon {
transform: rotate(180deg);
}
.col-toggles-body {
display: none;
}
.col-toggles-body--open {
display: contents;
}
.col-toggle { .col-toggle {
flex: 0 0 calc((100% - 12px) / 3); flex: 0 0 calc((100% - 12px) / 3);
@@ -1204,6 +1489,63 @@
overflow: hidden; overflow: hidden;
} }
/* Config strip - each labelled item becomes its own stacked card on
mobile (label above control) rather than a label crammed next to a
squeezed pill, so multiple pickers read as separate rows, not a
jumbled wall of controls. */
.col-toggles-config {
gap: 8px;
}
.col-toggles-config-item {
flex: 0 0 100%;
min-width: 0;
flex-direction: column;
align-items: stretch;
gap: 5px;
padding: 8px 10px;
background: rgba(255,255,255,0.55);
border: 1px solid #e6d7b8;
border-radius: 5px;
}
/* Re-affirm the gold emphasis for the "main" item so it still stands
out after the generic card border above. */
.col-toggles-config-item--main {
border-color: #c8922a;
background: rgba(200,146,42,0.10);
}
.config-item-label {
text-align: center;
}
/* The "SunSoak Config:" badge sits above the main item as its own
centred header row, instead of squeezed inline before the pill. */
.config-item-main-badge {
display: block;
flex: 0 0 100%;
width: 100%;
text-align: center;
padding: 0 0 2px;
}
.col-toggles-config-item .cs-wrap,
.col-toggles-config-item .col-toggle-group {
flex: 1 1 auto;
min-width: 0;
width: 100%;
}
.col-toggles-config-item .cs-btn {
width: 100%;
min-width: 0;
padding: 8px 6px;
font-size: 12px;
letter-spacing: 0.03em;
justify-content: center;
overflow: hidden;
}
/* Fused group: 1 slot by default, 2 slots when VentPill is active */ /* Fused group: 1 slot by default, 2 slots when VentPill is active */
.col-toggle-group { .col-toggle-group {
flex: 0 0 calc((100% - 12px) / 3); flex: 0 0 calc((100% - 12px) / 3);
@@ -1234,6 +1576,37 @@
justify-content: center; justify-content: center;
} }
/* Vehicle's triple group (type + speed + vent) doesn't fit fused on one
line at phone widths — the vent pill won't shrink below its text, so
it either overflows or forces the row taller. Give the type select its
own full-width row and let speed+vent share the row below, still
fused to each other, so nothing has to fight for space. */
.col-toggle-group--triple {
flex-wrap: wrap;
align-content: flex-start;
row-gap: 6px;
}
.col-toggle-group--triple .cs-wrap:first-child {
flex: 0 0 100%;
}
.col-toggle-group--triple .cs-wrap:first-child .grouped-left {
border-right: 1.5px solid #c9b08a !important;
border-radius: 3px !important;
}
/* The base .col-toggle-group .cs-wrap rule sets min-width:0, which lets
this select shrink to nothing and get squeezed onto row 1 next to the
now-full-width type select instead of wrapping. Restore a real min
width so it's forced onto row 2 with the vent pill instead. */
.col-toggle-group--triple .cs-wrap:nth-child(2) {
flex: 1 1 0;
min-width: 80px;
width: auto;
}
.col-toggle-group--triple .cs-vent {
flex: 1 1 0;
min-width: 0;
}
} }
/* ── MOBILE TABLE DENSITY — squeeze all basic profile columns in ──────── /* ── MOBILE TABLE DENSITY — squeeze all basic profile columns in ────────
@@ -1394,12 +1767,106 @@
flex-shrink: 0; flex-shrink: 0;
} }
/* Interval buttons shown in the col-toggles bar in simple mode */ /* Toolbar row sitting directly above col-toggles (zero gap, normal flow):
View toggle on the left, and — in Quick view — the thermal-model tabs
centered, touching the col-toggles top border so they read as folder
tabs attached to it. Kept as a 3-column grid (with an empty right
track) so the center tabs stay genuinely centered on the row rather
than just sitting flush after the View toggle. */
.table-toolbar-row {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: end;
padding: 0 14px;
}
.fvt-toolbar-left {
grid-column: 1;
display: flex;
align-items: center;
gap: 8px;
justify-self: start;
padding-bottom: 8px;
}
.fvt-thermal-tabs {
grid-column: 2;
justify-self: center;
display: flex;
width: 360px;
align-items: center;
flex-shrink: 0;
}
/* Mobile: stack instead of two columns — View on top, thermal tabs
below it so they still sit flush against the col-toggles border
underneath. Placed after the base rules above so it wins the cascade
(equal specificity, later source order) regardless of where in the
file the @media block lives. */
@media (max-width: 640px) {
.table-toolbar-row {
grid-template-columns: 1fr;
justify-items: center;
row-gap: 6px;
padding: 6px 10px 0;
}
.fvt-toolbar-left {
grid-column: 1;
grid-row: 1;
justify-self: center;
padding-bottom: 0;
}
.fvt-thermal-tabs {
grid-column: 1;
grid-row: 2;
justify-self: stretch;
width: 100%;
}
}
.fvt-thermal-tab {
flex: 1;
display: inline-flex;
align-items: center;
justify-content: center;
font-family: Manrope, sans-serif;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 9px 2px;
cursor: pointer;
background: transparent;
border: 1.5px solid #f5edd6;
border-left: none;
border-bottom: 1.5px solid transparent;
color: #9a7a4a;
transition: color 0.15s, border-color 0.15s, background 0.15s;
border-radius: 7px 7px 0 0;
z-index: 1;
}
.fvt-thermal-tab:first-child {
border-left: 1.5px solid #f5edd6;
}
.fvt-thermal-tab:hover {
color: #c8922a;
background: #f0e4c4;
}
.fvt-thermal-tab.on {
color: #c8922a;
background-color: #fdf8ee;
border-bottom-color: #fdf8ee;
border-left: 1.5px solid #d4c0a0;
border-right: 1.5px solid #d4c0a0;
border-top: 1.5px solid #d4c0a0;
margin-bottom: -1.5px;
}
/* Interval buttons shown in the col-toggles bar in simple mode, pushed
to the far right since Columns/Edit controls are hidden there. */
.fvt-interval { .fvt-interval {
display: none; display: none;
align-items: center; align-items: center;
gap: 2px; gap: 2px;
margin-right: 6px; margin-left: auto;
flex-shrink: 0; flex-shrink: 0;
} }
.table-main--simple .fvt-interval { .table-main--simple .fvt-interval {
@@ -1571,14 +2038,6 @@
min-height: 60px; min-height: 60px;
} }
.fvt-model-sep {
width: 1px;
height: 18px;
background: #d4c0a0;
margin: 0 4px;
flex-shrink: 0;
}
.fsc-table-btn-wrap { .fsc-table-btn-wrap {
padding: 10px 16px 12px; padding: 10px 16px 12px;
border-top: 1px solid #d4c0a0; border-top: 1px solid #d4c0a0;
+51 -8
View File
@@ -634,12 +634,14 @@
border-radius: 0; border-radius: 0;
} }
/* The floating dropdown panel */ /* The floating dropdown panel. Centred under/over its button and clamped to
the nearest scrollable ancestor's edges via an inline `left` (px) set in
JS (see the useLayoutEffect in CustomSelect) — `left: 0` here is only the
pre-measurement fallback for the very first paint. */
.cs-panel { .cs-panel {
position: absolute; position: absolute;
top: calc(100% + 4px); top: calc(100% + 4px);
left: 0; /* left edge flushes with button's left edge by default */ left: 0;
right: auto;
z-index: 1000; z-index: 1000;
background: #fffcf2; background: #fffcf2;
border: 1.5px solid #c9b08a; border: 1.5px solid #c9b08a;
@@ -647,13 +649,47 @@
box-shadow: 0 4px 16px rgba(30,18,8,0.13); box-shadow: 0 4px 16px rgba(30,18,8,0.13);
min-width: 100%; min-width: 100%;
white-space: nowrap; white-space: nowrap;
overflow: hidden; /* Safety net only — long lists (SunSoak, Indoors, ...) lay out as two
columns instead (see .cs-panel--cols) so every option is visible at a
glance rather than relying on people to notice a cropped list needs
scrolling. This cap only bites in the rare case even that doesn't fit
the space available (see the JS-computed inline max-height). */
max-height: min(300px, 60vh);
overflow-y: auto;
overflow-x: hidden;
transform-origin: top center;
animation: cs-panel-in 0.15s ease-out;
} }
/* Flip panel to right-align when left-align would overflow the viewport */ /* Two-column layout for longer option lists (>5 items) — halves the height
.cs-panel.cs-panel--right { needed so lists fit in the available space without scrolling. */
left: auto; .cs-panel.cs-panel--cols {
right: 0; column-count: 2;
column-gap: 0;
width: max-content;
max-width: 90vw;
}
.cs-panel.cs-panel--cols .cs-option {
break-inside: avoid;
}
@keyframes cs-panel-in {
from { opacity: 0; transform: translateY(-6px) scaleY(0.92); }
to { opacity: 1; transform: translateY(0) scaleY(1); }
}
/* Flip panel to open upward when it would overflow the bottom of the
viewport — e.g. these selectors sitting inside the profile/config bottom
sheet, which is anchored to the bottom of the screen. Animates in from
the opposite direction so it still reads as growing toward the button. */
.cs-panel.cs-panel--up {
top: auto;
bottom: calc(100% + 4px);
transform-origin: bottom center;
animation: cs-panel-in-up 0.15s ease-out;
}
@keyframes cs-panel-in-up {
from { opacity: 0; transform: translateY(6px) scaleY(0.92); }
to { opacity: 1; transform: translateY(0) scaleY(1); }
} }
/* Card-grid panel — wrapping flex grid of scene cards */ /* Card-grid panel — wrapping flex grid of scene cards */
@@ -665,6 +701,7 @@
min-width: 0; min-width: 0;
width: max-content; width: max-content;
max-width: min(320px, 90vw); max-width: min(320px, 90vw);
max-height: none;
white-space: normal; white-space: normal;
overflow: visible; overflow: visible;
} }
@@ -1233,6 +1270,12 @@
font-weight: 600; font-weight: 600;
} }
.utci-legend-label--secondary {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid #d4c0a0;
}
.utci-legend-row { .utci-legend-row {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
+180 -273
View File
@@ -24,17 +24,19 @@ import { h, render, Fragment } from '../vendor/preact.js';
import { useState, useRef, useEffect } from '../vendor/preact-hooks.js'; import { useState, useRef, useEffect } from '../vendor/preact-hooks.js';
import htm from '../vendor/htm.js'; import htm from '../vendor/htm.js';
import { import {
utciCategory, UTCI_BANDS, bandGradient, utciCategory, UTCI_BANDS,
petCategory,
SKIN_TYPES, sunburnMinutes, burnLabel, SKIN_TYPES, sunburnMinutes, burnLabel,
VEHICLE_TYPES, VEHICLE_SPEEDS, BUILDING_TYPES, VEHICLE_TYPES, VEHICLE_SPEEDS, BUILDING_TYPES,
FUR_COLORS, pawBurnRiskLabel, FUR_COLORS,
confidenceBand, moonGlyph, skyFillForElev, confidenceBand, moonGlyph, skyFillForElev,
} from './utils.js'; } from './utils.js';
import { SkyScope, WindVane, CloudIcon, ScopeReticle, PrecipIcon, CustomSelect, VentPill } from './components.js'; import { SkyScope, WindVane, CloudIcon, ScopeReticle, PrecipIcon, CustomSelect, VentPill } from './components.js';
import { getCellTagEvents, getUpcomingEvents } from './events.js'; import { getCellTagEvents, getUpcomingEvents } from './events.js';
import { POLLEN_TYPES, COL_DESCRIPTIONS, UTCI_ENVIRONMENTS } from './config.js'; import { POLLEN_TYPES, COL_DESCRIPTIONS, UTCI_ENVIRONMENTS, FILTER_PROFILES, variantIcons, deriveProfileMain } from './config.js';
import { useAppState } from './hooks/useAppState.js'; import { useAppState } from './hooks/useAppState.js';
import { DayTabs } from './components/DayTabs.js'; import { DayTabs } from './components/DayTabs.js';
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 { computeWhyFeelsLike, computeGlanceSummary } from './compute.js'; import { computeWhyFeelsLike, computeGlanceSummary } from './compute.js';
@@ -189,6 +191,7 @@ export function UTCIForecast() {
showDecimals, toggleShowDecimals, showDecimals, toggleShowDecimals,
welcomeOpen, closeWelcome, openWelcome, welcomeOpen, closeWelcome, openWelcome,
restoreOpen, openRestore, closeRestore, restoreOpen, openRestore, closeRestore,
panelOpen, openPanel, closePanel,
showUnits, toggleShowUnits, showUnits, toggleShowUnits,
tableInterval, setTableInterval, tableInterval, setTableInterval,
forecastView, setForecastView, forecastView, setForecastView,
@@ -282,14 +285,14 @@ export function UTCIForecast() {
const [simpleTemp, setSimpleTemp] = useState('utciAdj'); const [simpleTemp, setSimpleTemp] = useState('utciAdj');
useEffect(() => { useEffect(() => {
if (activeProfile === 'vehicle') setSimpleTemp('vehicleT'); if (activeProfile === 'vehicle' || (activeProfile === 'outdoors' && outdoorsVariant === 'driver')) setSimpleTemp('vehicleT');
else if (activeProfile === 'home') setSimpleTemp('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 (['alltemps', 'showall', 'custom', 'farming', 'construction', 'market', 'windowcleaning', 'office'].includes(activeProfile)) {
setForecastView('table'); setForecastView('table');
} }
}, [activeProfile]); }, [activeProfile, outdoorsVariant]);
// Focus the input the moment the search opens. // Focus the input the moment the search opens.
useEffect(() => { useEffect(() => {
@@ -421,6 +424,46 @@ export function UTCIForecast() {
const airTempRgbStrong = (t) => airTempRgb(t, 0.42); const airTempRgbStrong = (t) => airTempRgb(t, 0.42);
const airTempRgbVeryStrong = (t) => airTempRgb(t, 0.25); const airTempRgbVeryStrong = (t) => airTempRgb(t, 0.25);
// Pet columns (Fur Colour, Pet Shade, Pet Home, Paw) reuse airTempRgb
// exactly as-is - identical stops, identical whiteMix blend, identical
// per-row top/bottom cell blending (petAirTempBg mirrors airTempBg
// below). The only thing that differs is which temperature gets handed
// to it: petEquivHumanTemp() remaps a pet reading to "the human felt-temp
// this severity is equivalent to" first, using the exact same anchor
// pairs PET_BANDS was calibrated against (same tier, same ordinal
// position in UTCI_BANDS vs PET_BANDS - see utils.js). So a -2 -C pet
// reading (mild "Cold", not "Freezing") gets looked up as if it were a
// few degrees warmer on the human scale, and a 46 -C paw reading (mid
// "Extreme", not "Danger") looks up around human "Extreme" too - never a
// different colour-computation, just a different input to the same one.
const PET_TO_HUMAN_TEMP = [
[-28, -20], [-18, -10], [-8, 0], [-3, 5], [2, 10], [7, 15], [11, 19],
[25, 24], [32, 27], [40, 32], [52, 41],
];
const petEquivHumanTemp = (t) => {
if (t == null) return null;
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
const pts = PET_TO_HUMAN_TEMP;
const slopeBetween = (a, b) => (b[1] - a[1]) / (b[0] - a[0]);
if (t <= pts[0][0]) {
const slope = slopeBetween(pts[0], pts[1]);
return pts[0][1] + (t - pts[0][0]) * slope;
}
if (t >= pts[pts.length - 1][0]) {
const last = pts[pts.length - 1], prev = pts[pts.length - 2];
const slope = slopeBetween(prev, last);
return last[1] + (t - last[0]) * slope;
}
for (let i = 1; i < pts.length; i++) {
const [p0, h0] = pts[i - 1], [p1, h1] = pts[i];
if (t <= p1) {
const x = clamp((t - p0) / (p1 - p0), 0, 1);
return h0 + (h1 - h0) * x;
}
}
};
const petAirTempRgb = (t, whiteMix = 0.58) => airTempRgb(petEquivHumanTemp(t), whiteMix);
// ─── 3b. PANEL COMPUTATIONS ────────────────────────────────────────── // ─── 3b. PANEL COMPUTATIONS ──────────────────────────────────────────
// whyFeelsLike is derived below, after the playback rows are resolved, so // whyFeelsLike is derived below, after the playback rows are resolved, so
// the panel can track the simulated instant during play/scrub (see panelRow). // the panel can track the simulated instant during play/scrub (see panelRow).
@@ -883,6 +926,54 @@ 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} />`}
${forecast && days.length > 0 && (() => {
const { mainLabel, mainConfigKey } = deriveProfileMain(activeProfile, outdoorsVariant);
const fabIcon = activeProfile === 'outdoors'
? (variantIcons[outdoorsVariant] || '🎯')
: (FILTER_PROFILES[activeProfile]?.icon || '🎯');
const fabVal = mainConfigKey === 'vehicle'
? (VEHICLE_TYPES[vehicleType]?.name || '')
: mainConfigKey === 'indoor'
? (BUILDING_TYPES[buildingType]?.name || '')
: mainConfigKey === 'fur'
? (FUR_COLORS[furColor]?.name || '')
: (UTCI_ENVIRONMENTS[utciEnv]?.label || '');
return html`
<button
class=${`floating-profile-btn${panelOpen ? ' is-open' : ''}`}
onClick=${() => (panelOpen ? closePanel() : openPanel())}
aria-label="Profile and settings"
aria-expanded=${panelOpen}
title="Profile & settings"
>
<span class="floating-profile-icon" aria-hidden="true">${fabIcon}</span>
<span class="floating-profile-val">${mainLabel}</span>
${fabVal && html`<span class="floating-profile-sub">· ${fabVal}</span>`}
<span class="floating-profile-caret" aria-hidden="true">▾</span>
</button>`;
})()}
<${ConfigPanel}
open=${panelOpen}
onClose=${closePanel}
isPro=${isPro}
activeProfile=${activeProfile} activateProfile=${activateProfile} activeCols=${activeCols}
activityOptions=${activityOptions} placeOptions=${placeOptions} workOptions=${workOptions}
activityValue=${activityValue} placeValue=${placeValue} workValue=${workValue}
outdoorsVariant=${outdoorsVariant} setOutdoorsVariantAndSave=${setOutdoorsVariantAndSave}
setActiveProfile=${setActiveProfile} setVisibleCols=${setVisibleCols} setIndoorMode=${setIndoorMode}
setProPromptSource=${setProPromptSource} setProPromptDay=${setProPromptDay}
indoorManaged=${indoorManaged} setIndoorManaged=${setIndoorManaged}
buildingType=${buildingType} setBuildingType=${setBuildingType}
utciEnv=${utciEnv} setUtciEnv=${setUtciEnv}
vehicleType=${vehicleType} setVehicleType=${setVehicleType}
vehicleSpeed=${vehicleSpeed} setVehicleSpeed=${setVehicleSpeed}
vehicleVent=${vehicleVent} setVehicleVent=${setVehicleVent}
furColor=${furColor} setFurColor=${setFurColor}
skinType=${skinType} setSkinType=${setSkinType}
pollenType=${pollenType} setPollenTypeAndSave=${setPollenTypeAndSave}
/>
${forecast && days.length > 0 && html`<${DayTabs} ${forecast && days.length > 0 && html`<${DayTabs}
days=${days} days=${days}
selectedDay=${selectedDay} setSelectedDay=${setSelectedDay} selectedDay=${selectedDay} setSelectedDay=${setSelectedDay}
@@ -890,16 +981,10 @@ export function UTCIForecast() {
openRestore=${openRestore} openRestore=${openRestore}
proPromptDay=${proPromptDay} setProPromptDay=${setProPromptDay} proPromptDay=${proPromptDay} setProPromptDay=${setProPromptDay}
proPromptSource=${proPromptSource} setProPromptSource=${setProPromptSource} proPromptSource=${proPromptSource} setProPromptSource=${setProPromptSource}
activeProfile=${activeProfile} activateProfile=${activateProfile} activeCols=${activeCols} activeProfile=${activeProfile} outdoorsVariant=${outdoorsVariant}
visibleCols=${visibleCols} openPanel=${openPanel}
activityOptions=${activityOptions} placeOptions=${placeOptions} workOptions=${workOptions} vehicleType=${vehicleType} vehicleSpeed=${vehicleSpeed}
activityValue=${activityValue} activityLabel=${activityLabel} buildingType=${buildingType} indoorManaged=${indoorManaged} utciEnv=${utciEnv} furColor=${furColor}
placeValue=${placeValue} placeLabel=${placeLabel}
workValue=${workValue} workLabel=${workLabel}
outdoorsVariant=${outdoorsVariant} setOutdoorsVariantAndSave=${setOutdoorsVariantAndSave}
setActiveProfile=${setActiveProfile} setVisibleCols=${setVisibleCols}
setIndoorMode=${setIndoorMode} setIndoorManaged=${setIndoorManaged}
setBuildingType=${setBuildingType}
dayTabsRef=${dayTabsRef} canScrollLeft=${canScrollLeft} canScrollRight=${canScrollRight} dayTabsRef=${dayTabsRef} canScrollLeft=${canScrollLeft} canScrollRight=${canScrollRight}
scrollDayTabs=${scrollDayTabs} scrollDayTabs=${scrollDayTabs}
/>`} />`}
@@ -907,7 +992,8 @@ export function UTCIForecast() {
<div class="table-with-rail"> <div class="table-with-rail">
<div class=${'table-main' + (forecastView === 'simple' ? ' table-main--simple' : '')}> <div class=${'table-main' + (forecastView === 'simple' ? ' table-main--simple' : '')}>
<div class="col-toggles"> <div class="table-toolbar-row">
<span class="fvt-toolbar-left">
<span class="forecast-view-toggle-label">View:</span> <span class="forecast-view-toggle-label">View:</span>
<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' : '')} onClick=${() => setForecastView('simple')}>
@@ -919,9 +1005,10 @@ export function UTCIForecast() {
Detailed Detailed
</button> </button>
</span> </span>
<span class="fvt-interval"> <button class=${'col-toggles-edit-btn col-toggles-edit-btn--toolbar' + (colTogglesOpen ? ' col-toggles-edit-btn--open' : '')} onClick=${() => setColTogglesOpen(v => !v)}>
<span class="fvt-interval-label">Hours:</span> <span class="col-toggles-edit-btn-label">${colTogglesOpen ? 'Hide Columns' : 'Edit columns'}</span>
${[1, 2, 3, 4].map(n => html`<button key=${n} type="button" class=${'hour-interval-btn' + (tableInterval === n ? ' on' : '')} title=${n === 1 ? 'Every hour' : `Every ${n} hours`} onClick=${() => setTableInterval(n)}>${n}h</button>`)} <svg class="col-toggles-edit-btn-icon" viewBox="0 0 12 12" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path d="M2 4l4 4 4-4" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg>
</button>
</span> </span>
${forecastView === 'simple' && (() => { ${forecastView === 'simple' && (() => {
const showFur = visibleCols.furSurfaceT; const showFur = visibleCols.furSurfaceT;
@@ -933,236 +1020,36 @@ export function UTCIForecast() {
const solarOn = simpleTemp === 'utciAdj'; const solarOn = simpleTemp === 'utciAdj';
const vehicleOn = simpleTemp === 'vehicleT'; const vehicleOn = simpleTemp === 'vehicleT';
const indoorOn = simpleTemp === 'indoorT' || simpleTemp === 'managedT'; const indoorOn = simpleTemp === 'indoorT' || simpleTemp === 'managedT';
// Values (fur colour, vehicle type/speed, building type,
// ventilation) are set in the config strip above the day
// tabs now — this row is just a tab switcher for which
// thermal model drives the quick-view cards below. Sits
// directly above col-toggles in normal flow (touching, zero
// gap) so it reads as a folder tab attached to that box.
return html` return html`
<span class="fvt-model-sep"></span> <span class="fvt-thermal-tabs">
${showFur && html` ${showSolar && html`<button type="button" class=${'fvt-thermal-tab' + (solarOn ? ' on' : '')} onClick=${() => setSimpleTemp('utciAdj')}>SunSoak</button>`}
<span class=${'col-toggle-group' + (furOn ? ' col-toggle-group--expanded' : '')}> ${showVehicle && html`<button type="button" class=${'fvt-thermal-tab' + (vehicleOn ? ' on' : '')} onClick=${() => setSimpleTemp('vehicleT')}>Vehicle</button>`}
<${CustomSelect} ${showIndoor && html`<button type="button" class=${'fvt-thermal-tab' + (indoorOn ? ' on' : '')} onClick=${() => setSimpleTemp(indoorManaged ? 'managedT' : 'indoorT')}>Indoors</button>`}
value=${furColor} ${showFur && html`<button type="button" class=${'fvt-thermal-tab' + (furOn ? ' on' : '')} onClick=${() => setSimpleTemp('furSurfaceT')}>Fur Colour</button>`}
isOn=${furOn} </span>
grpClass="grp-surface"
hideLabel="Fur Colour"
hidingLabel="Hide Fur Colour"
groupedLeft=${true}
isLastChild=${true}
options=${Object.entries(FUR_COLORS).map(([k, v]) => ({ value: k, label: v.name }))}
onChange=${(v) => {
if (v === 'off') {
setSimpleTemp(showVehicle ? 'vehicleT' : showSolar ? 'utciAdj' : showIndoor ? 'indoorT' : 'furSurfaceT');
} else {
setFurColor(v);
setSimpleTemp('furSurfaceT');
}
}}
/>
</span>`}
${showSolar && html`
<span class=${'col-toggle-group' + (solarOn ? ' col-toggle-group--expanded' : '')}>
<${CustomSelect}
value=${utciEnv} isOn=${solarOn} grpClass="grp-felt"
hideLabel="Solar Model" hidingLabel="Solar Model"
groupedLeft=${true} isLastChild=${true}
options=${Object.entries(UTCI_ENVIRONMENTS).map(([k, v]) => ({ value: k, label: v.label }))}
onChange=${v => {
if (v === 'off') setSimpleTemp(showFur ? 'furSurfaceT' : showVehicle ? 'vehicleT' : showIndoor ? 'indoorT' : 'utciAdj');
else { setUtciEnv(v); setSimpleTemp('utciAdj'); }
}}
/>
</span>`}
${showVehicle && html`
<span class=${'col-toggle-group' + (vehicleOn ? ' col-toggle-group--expanded col-toggle-group--triple' : '')}>
<${CustomSelect}
value=${vehicleType} isOn=${vehicleOn} grpClass="grp-felt"
hideLabel="Vehicle" hidingLabel="Vehicle"
groupedLeft=${true} isLastChild=${!vehicleOn}
options=${Object.entries(VEHICLE_TYPES).map(([k, v]) => ({ value: k, label: v.name }))}
onChange=${v => {
if (v === 'off') setSimpleTemp(showFur ? 'furSurfaceT' : showSolar ? 'utciAdj' : showIndoor ? 'indoorT' : 'vehicleT');
else { setVehicleType(v); setSimpleTemp('vehicleT'); }
}}
/>
${vehicleOn && html`
<${CustomSelect}
value=${vehicleSpeed} isOn=${true} noHide=${true} grpClass="grp-felt"
buttonLabel=${(VEHICLE_SPEEDS[vehicleSpeed] ?? VEHICLE_SPEEDS.static).name}
groupedLeft=${true} isLastChild=${false}
options=${Object.entries(VEHICLE_SPEEDS).map(([k, v]) => ({ value: k, label: v.name }))}
onChange=${v => setVehicleSpeed(v)}
/>
<${VentPill} checked=${vehicleVent} onChange=${() => setVehicleVent(v => !v)}
grpClass="grp-felt" label="Ventilation"
title="Open windows significantly reduce cabin heat build-up. Speed sets cabin airflow from forward motion." />`}
</span>`}
${showIndoor && html`
<span class=${'col-toggle-group' + (indoorOn ? ' col-toggle-group--expanded' : '')}>
<${CustomSelect}
value=${buildingType} isOn=${indoorOn} grpClass="grp-felt"
hideLabel="Indoors" hidingLabel="Indoors"
groupedLeft=${true} isLastChild=${!indoorOn}
options=${Object.entries(BUILDING_TYPES).map(([k, v]) => ({ value: k, label: v.name }))}
onChange=${v => {
if (v === 'off') setSimpleTemp(showFur ? 'furSurfaceT' : showSolar ? 'utciAdj' : showVehicle ? 'vehicleT' : 'indoorT');
else { setBuildingType(v); setIndoorMode('on'); setSimpleTemp(indoorManaged ? 'managedT' : 'indoorT'); }
}}
/>
${indoorOn && html`
<${VentPill} checked=${indoorManaged} onChange=${() => { const next = !indoorManaged; setIndoorManaged(next); setSimpleTemp(next ? 'managedT' : 'indoorT'); }}
grpClass="grp-felt" label="Managed"
title="Curtains closed by day, windows open when cooler outside" />`}
</span>`}
`; `;
})()} })()}
<span class="col-toggles-label">Columns:</span> </div>
<button class=${'col-toggles-edit-btn' + (colTogglesOpen ? ' col-toggles-edit-btn--open' : '')} onClick=${() => setColTogglesOpen(v => !v)}> <div class=${'col-toggles' + (colTogglesOpen ? ' col-toggles--open' : '')}>
<span class="col-toggles-edit-btn-label">${colTogglesOpen ? 'Done' : 'Edit columns'}</span> <span class="fvt-interval">
<svg class="col-toggles-edit-btn-icon" viewBox="0 0 12 12" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path d="M2 4l4 4 4-4" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg> <span class="fvt-interval-label">Hours:</span>
</button> ${[1, 2, 3, 4].map(n => html`<button key=${n} type="button" class=${'hour-interval-btn' + (tableInterval === n ? ' on' : '')} title=${n === 1 ? 'Every hour' : `Every ${n} hours`} onClick=${() => setTableInterval(n)}>${n}h</button>`)}
<div class=${'col-toggles-body' + (colTogglesOpen ? ' col-toggles-body--open' : '')}> </span>
${(isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['utciP']) : activeCols['utciP']) && html` <div class="col-toggles-body">
<span class=${'col-toggle-group' + (visibleCols.utciP ? ' col-toggle-group--expanded' : '')}> ${(isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['utciP']) : activeCols['utciP']) && html`<button class=${`col-toggle grp-felt${visibleCols.utciP ? ' on' : ''}`} onClick=${() => toggleCol('utciP')}>SunSoak</button>`}
<${CustomSelect} ${(isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['vehicleT']) : activeCols['vehicleT']) && html`<button class=${`col-toggle grp-felt${visibleCols.vehicleT ? ' on' : ''}`} onClick=${() => { if (visibleCols.vehicleT) setVehicleVent(false); toggleCol('vehicleT'); }}>Vehicle</button>`}
value=${utciEnv} ${(isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['indoorT'] || activeCols['managedT']) : (activeCols['indoorT'] || activeCols['managedT'])) && html`<button class=${`col-toggle grp-felt${indoorMode === 'on' ? ' on' : ''}`} onClick=${() => { if (indoorMode === 'on') { setIndoorMode('off'); setIndoorManaged(false); } else { setIndoorMode('on'); } }}>Indoors</button>`}
isOn=${visibleCols.utciP} ${(isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['furSurfaceT']) : activeCols['furSurfaceT']) && html`<button class=${`col-toggle grp-surface${visibleCols.furSurfaceT ? ' on' : ''}`} onClick=${() => toggleCol('furSurfaceT')}>Fur Colour</button>`}
grpClass="grp-felt"
hideLabel="SunSoak"
hidingLabel="Hide SunSoak"
groupedLeft=${true}
isLastChild=${true}
options=${Object.entries(UTCI_ENVIRONMENTS).map(([k, v]) => ({
value: k,
label: v.label,
}))}
onChange=${(v) => {
if (v === 'off') {
setVisibleCols(prev => ({ ...prev, utciP: false }));
} else {
setUtciEnv(v);
setVisibleCols(prev => ({ ...prev, utciP: true }));
}
}}
/>
</span>`}
${(isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['vehicleT']) : activeCols['vehicleT']) && html`
<span class=${'col-toggle-group' + (visibleCols.vehicleT ? ' col-toggle-group--expanded col-toggle-group--triple' : '')}>
<${CustomSelect}
value=${vehicleType}
isOn=${visibleCols.vehicleT}
grpClass="grp-felt"
hideLabel="Vehicle"
hidingLabel="Hide Vehicle"
groupedLeft=${true}
isLastChild=${!visibleCols.vehicleT}
options=${Object.entries(VEHICLE_TYPES).map(([k, v]) => ({
value: k,
label: v.name,
}))}
onChange=${(v) => {
if (v === 'off') {
setVisibleCols(prev => ({ ...prev, vehicleT: false }));
setVehicleVent(false);
} else {
setVehicleType(v);
setVisibleCols(prev => ({ ...prev, vehicleT: true }));
}
}}
/>
${visibleCols.vehicleT && html`
<${CustomSelect}
value=${vehicleSpeed}
isOn=${true}
noHide=${true}
grpClass="grp-felt"
buttonLabel=${(VEHICLE_SPEEDS[vehicleSpeed] ?? VEHICLE_SPEEDS.static).name}
groupedLeft=${true}
isLastChild=${false}
options=${Object.entries(VEHICLE_SPEEDS).map(([k, v]) => ({ value: k, label: v.name }))}
onChange=${(v) => setVehicleSpeed(v)}
/>
<${VentPill}
checked=${vehicleVent}
onChange=${() => setVehicleVent(v => !v)}
grpClass="grp-felt"
label="Ventilation"
title="Ventilation — open windows significantly reduce cabin heat build-up. Speed sets cabin airflow from forward motion."
/>`}
</span>`}
${(isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['indoorT'] || activeCols['managedT']) : (activeCols['indoorT'] || activeCols['managedT'])) && html`
<span class=${'col-toggle-group' + (indoorMode === 'on' ? ' col-toggle-group--expanded' : '')}>
<${CustomSelect}
value=${buildingType}
isOn=${indoorMode === 'on'}
grpClass="grp-felt"
hideLabel="Indoors"
hidingLabel="Hide Indoors"
groupedLeft=${true}
isLastChild=${indoorMode !== 'on'}
options=${Object.entries(BUILDING_TYPES).map(([k, v]) => ({
value: k,
label: v.name,
}))}
onChange=${(v) => {
if (v === 'off') {
setIndoorMode('off');
setIndoorManaged(false);
} else {
setBuildingType(v);
setIndoorMode('on');
}
}}
/>
${indoorMode === 'on' && html`
<${VentPill}
checked=${indoorManaged}
onChange=${() => setIndoorManaged(v => !v)}
grpClass="grp-felt"
label="Managed"
title="Managed: curtains closed by day, windows open when cooler outside"
/>`}
</span>`}
${(isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['furSurfaceT']) : activeCols['furSurfaceT']) && html`
<span class=${'col-toggle-group' + (visibleCols.furSurfaceT ? ' col-toggle-group--expanded' : '')}>
<${CustomSelect}
value=${furColor}
isOn=${visibleCols.furSurfaceT}
grpClass="grp-surface"
hideLabel="Fur Colour"
hidingLabel="Hide Fur Colour"
groupedLeft=${true}
isLastChild=${true}
options=${Object.entries(FUR_COLORS).map(([k, v]) => ({ value: k, label: v.name }))}
onChange=${(v) => {
if (v === 'off') {
setVisibleCols(prev => ({ ...prev, furSurfaceT: false }));
} else {
setFurColor(v);
setVisibleCols(prev => ({ ...prev, furSurfaceT: true }));
}
}}
/>
</span>`}
${(isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['pawT']) : activeCols['pawT']) && html`<button class=${`col-toggle grp-surface${visibleCols.pawT ? ' on' : ''}`} onClick=${() => toggleCol('pawT')}>Paw</button>`} ${(isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['pawT']) : activeCols['pawT']) && html`<button class=${`col-toggle grp-surface${visibleCols.pawT ? ' on' : ''}`} onClick=${() => toggleCol('pawT')}>Paw</button>`}
${(isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['petShadeT']) : activeCols['petShadeT']) && html`<button class=${`col-toggle grp-ambient${visibleCols.petShadeT ? ' on' : ''}`} onClick=${() => toggleCol('petShadeT')}>Pet Shade</button>`} ${(isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['petShadeT']) : activeCols['petShadeT']) && html`<button class=${`col-toggle grp-ambient${visibleCols.petShadeT ? ' on' : ''}`} onClick=${() => toggleCol('petShadeT')}>Pet Shade</button>`}
${(isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['petHomeT']) : activeCols['petHomeT']) && html`<button class=${`col-toggle grp-felt${visibleCols.petHomeT ? ' on' : ''}`} onClick=${() => toggleCol('petHomeT')}>Pet Home</button>`} ${(isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['petHomeT']) : activeCols['petHomeT']) && html`<button class=${`col-toggle grp-felt${visibleCols.petHomeT ? ' on' : ''}`} onClick=${() => toggleCol('petHomeT')}>Pet Home</button>`}
${(isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['burn']) : activeCols['burn']) && html` ${(isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['burn']) : activeCols['burn']) && html`<button class=${`col-toggle grp-felt${visibleCols.burn ? ' on' : ''}`} onClick=${() => toggleCol('burn')}>Burn</button>`}
<${CustomSelect}
value=${skinType}
isOn=${visibleCols.burn}
grpClass="grp-felt"
hideLabel="Burn"
hidingLabel="Hide Burn"
options=${Object.entries(SKIN_TYPES).map(([k, v]) => ({
value: k,
label: v.name.split(' · ')[1] + ' skin',
}))}
onChange=${(v) => {
if (v === 'off') {
setVisibleCols(prev => ({ ...prev, burn: false }));
} else {
setSkinType(v);
setVisibleCols(prev => ({ ...prev, burn: true }));
}
}}
/>`}
${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['utci']) && html`<button class=${`col-toggle grp-felt${visibleCols.utci ? ' on' : ''}`} onClick=${() => toggleCol('utci')}>UTCI</button>`} ${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['utci']) && html`<button class=${`col-toggle grp-felt${visibleCols.utci ? ' on' : ''}`} onClick=${() => toggleCol('utci')}>UTCI</button>`}
${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['delta']) && html`<button class=${`col-toggle grp-felt${visibleCols.delta ? ' on' : ''}`} onClick=${() => toggleCol('delta')}>Δ</button>`} ${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['delta']) && html`<button class=${`col-toggle grp-felt${visibleCols.delta ? ' on' : ''}`} onClick=${() => toggleCol('delta')}>Δ</button>`}
${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['tmrt']) && html`<button class=${`col-toggle grp-felt${visibleCols.tmrt ? ' on' : ''}`} onClick=${() => toggleCol('tmrt')}>Tmrt</button>`} ${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['tmrt']) && html`<button class=${`col-toggle grp-felt${visibleCols.tmrt ? ' on' : ''}`} onClick=${() => toggleCol('tmrt')}>Tmrt</button>`}
@@ -1182,25 +1069,7 @@ export function UTCIForecast() {
${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['wind']) && html`<button class=${`col-toggle grp-wind${visibleCols.wind ? ' on' : ''}`} onClick=${() => toggleCol('wind')}>Wind</button>`} ${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['wind']) && html`<button class=${`col-toggle grp-wind${visibleCols.wind ? ' on' : ''}`} onClick=${() => toggleCol('wind')}>Wind</button>`}
${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['dir']) && html`<button class=${`col-toggle grp-wind${visibleCols.dir ? ' on' : ''}`} onClick=${() => toggleCol('dir')}>Dir</button>`} ${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['dir']) && html`<button class=${`col-toggle grp-wind${visibleCols.dir ? ' on' : ''}`} onClick=${() => toggleCol('dir')}>Dir</button>`}
${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['aqi']) && html`<button class=${`col-toggle grp-airqual${visibleCols.aqi ? ' on' : ''}`} onClick=${() => toggleCol('aqi')}>Air Quality</button>`} ${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['aqi']) && html`<button class=${`col-toggle grp-airqual${visibleCols.aqi ? ' on' : ''}`} onClick=${() => toggleCol('aqi')}>Air Quality</button>`}
${(isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['pollen']) : activeCols['pollen']) && html`<${CustomSelect} ${(isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['pollen']) : activeCols['pollen']) && html`<button class=${`col-toggle grp-airqual${visibleCols.pollen ? ' on' : ''}`} onClick=${() => toggleCol('pollen')}>Pollen</button>`}
value=${pollenType}
isOn=${visibleCols.pollen}
grpClass="grp-airqual"
hideLabel="Pollen"
hidingLabel="Hide Pollen"
options=${Object.entries(POLLEN_TYPES).flatMap(([k, v], i) => [
{ value: k, label: v.name },
...(i === 0 ? [{ value: '_div', divider: true }] : []),
])}
onChange=${(v) => {
if (v === 'off') {
setVisibleCols(prev => ({ ...prev, pollen: false }));
} else {
setPollenTypeAndSave(v);
setVisibleCols(prev => ({ ...prev, pollen: true }));
}
}}
/>`}
${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['uvA']) && html`<button class=${`col-toggle grp-solar${visibleCols.uvA ? ' on' : ''}`} onClick=${() => toggleCol('uvA')}>UV-A</button>`} ${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['uvA']) && html`<button class=${`col-toggle grp-solar${visibleCols.uvA ? ' on' : ''}`} onClick=${() => toggleCol('uvA')}>UV-A</button>`}
${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['uvB']) && html`<button class=${`col-toggle grp-solar${visibleCols.uvB ? ' on' : ''}`} onClick=${() => toggleCol('uvB')}>UV-B</button>`} ${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['uvB']) && html`<button class=${`col-toggle grp-solar${visibleCols.uvB ? ' on' : ''}`} onClick=${() => toggleCol('uvB')}>UV-B</button>`}
${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['sun']) && html`<button class=${`col-toggle grp-solar${visibleCols.sun ? ' on' : ''}`} onClick=${() => toggleCol('sun')}>Sun</button>`} ${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['sun']) && html`<button class=${`col-toggle grp-solar${visibleCols.sun ? ' on' : ''}`} onClick=${() => toggleCol('sun')}>Sun</button>`}
@@ -1226,7 +1095,7 @@ export function UTCIForecast() {
<div class="forecast-simple-cards" style=${{ gridTemplateColumns: `repeat(${tableRows.length}, minmax(55px, 1fr))` }}> <div class="forecast-simple-cards" style=${{ gridTemplateColumns: `repeat(${tableRows.length}, minmax(55px, 1fr))` }}>
${tableRows.map(r => { ${tableRows.map(r => {
const dispTemp = r[simpleTemp] ?? r.utciAdj; const dispTemp = r[simpleTemp] ?? r.utciAdj;
const cat = utciCategory(dispTemp); const cat = simpleTemp === 'furSurfaceT' ? petCategory(dispTemp) : utciCategory(dispTemp);
const h24s = parseInt(r.iso.slice(11, 13), 10); const h24s = parseInt(r.iso.slice(11, 13), 10);
const localHHMMs = h24s === 0 ? '12am' : h24s < 12 ? `${h24s}am` : h24s === 12 ? '12pm' : `${h24s - 12}pm`; const localHHMMs = h24s === 0 ? '12am' : h24s < 12 ? `${h24s}am` : h24s === 12 ? '12pm' : `${h24s - 12}pm`;
const isNow = r.isoHours ? r.isoHours.includes(nowLocalISO) : r.iso.slice(0, 13) === nowLocalISO; const isNow = r.isoHours ? r.isoHours.includes(nowLocalISO) : r.iso.slice(0, 13) === nowLocalISO;
@@ -1234,7 +1103,7 @@ export function UTCIForecast() {
if (r.precipProb > 20 && (r.precip > 0 || r.snow > 0)) if (r.precipProb > 20 && (r.precip > 0 || r.snow > 0))
return html`<${PrecipIcon} precip=${r.precip} snow=${r.snow} size=${42} filled=${true} />`; return html`<${PrecipIcon} precip=${r.precip} snow=${r.snow} size=${42} filled=${true} />`;
if (r.cloudCat && r.cloudCat !== 'clear') if (r.cloudCat && r.cloudCat !== 'clear')
return html`<${CloudIcon} category=${r.cloudCat} elev=${r.elev} dt=${r.dt} size=${42} filled=${true} />`; return html`<${CloudIcon} category=${r.cloudCat} elev=${r.elev} dt=${r.dt} size=${42} filled=${true} showSun=${false} />`;
return null; return null;
})(); })();
const windMph = Math.round((r.gust ?? r.va) * 2.237); const windMph = Math.round((r.gust ?? r.va) * 2.237);
@@ -1462,6 +1331,16 @@ export function UTCIForecast() {
...(band.textShadow ? { textShadow: band.textShadow } : {}), ...(band.textShadow ? { textShadow: band.textShadow } : {}),
}; };
}; };
// Pet-specific columns (Fur Colour, Pet Shade, Pet Home, Paw) use
// petAirTempRgb instead of airTempRgb - identical gradient/blend
// mechanics to every other temp column, just a pet-calibrated
// scale so the same degree reading lands on a different shade.
const petAirTempBg = (t, tPrev, tNext) => {
if (t == null) return 'transparent';
const tTop = tPrev != null ? (tPrev + t) / 2 : t;
const tBot = tNext != null ? (t + tNext) / 2 : t;
return `linear-gradient(to bottom, ${toRgb(petAirTempRgb(tTop))} 0%, ${toRgb(petAirTempRgb(tBot))} 100%)`;
};
const scaleBg = (v, min, max, rgb, maxAlpha = 0.14) => { const scaleBg = (v, min, max, rgb, maxAlpha = 0.14) => {
if (v == null || isNaN(v)) return 'transparent'; if (v == null || isNaN(v)) return 'transparent';
if (v <= min) return 'transparent'; if (v <= min) return 'transparent';
@@ -1546,7 +1425,7 @@ export function UTCIForecast() {
${fmt(r.managedT)}${u('°C')} ${fmt(r.managedT)}${u('°C')}
</td>`} </td>`}
${visibleCols.petHomeT && html` ${visibleCols.petHomeT && html`
<td class=${groupStart('petHomeT')} style=${{ color: airTempFontColor(), background: airTempBg(r.indoorT, rPrev?.indoorT, rNext?.indoorT) }}> <td class=${groupStart('petHomeT')} title=${petCategory(r.indoorT)?.label ?? ''} style=${{ color: airTempFontColor(), background: petAirTempBg(r.indoorT, rPrev?.indoorT, rNext?.indoorT) }}>
${fmt(r.indoorT)}${u('°C')} ${fmt(r.indoorT)}${u('°C')}
</td>`} </td>`}
${visibleCols.burn && html` ${visibleCols.burn && html`
@@ -1570,15 +1449,13 @@ export function UTCIForecast() {
${fmt(r.concreteT)}${u('°C')} ${fmt(r.concreteT)}${u('°C')}
</td>`} </td>`}
${visibleCols.furSurfaceT && html` ${visibleCols.furSurfaceT && html`
<td class=${groupStart('furSurfaceT')} style=${{ color: airTempFontColor(), background: airTempBg(r.furSurfaceT, rPrev?.furSurfaceT, rNext?.furSurfaceT) }}> <td class=${groupStart('furSurfaceT')} title=${petCategory(r.furSurfaceT)?.label ?? ''} style=${{ color: airTempFontColor(), background: petAirTempBg(r.furSurfaceT, rPrev?.furSurfaceT, rNext?.furSurfaceT) }}>
${fmt(r.furSurfaceT)}${u('°C')} ${fmt(r.furSurfaceT)}${u('°C')}
</td>`} </td>`}
${visibleCols.pawT && (() => { ${visibleCols.pawT && html`
const risk = pawBurnRiskLabel(r.concreteT); <td class=${groupStart('pawT')} title=${petCategory(r.concreteT)?.label ?? ''} style=${{ color: airTempFontColor(), background: petAirTempBg(r.concreteT, rPrev?.concreteT, rNext?.concreteT) }}>
return html`<td class=${groupStart('pawT')} title=${risk ?? ''} style=${{ color: airTempFontColor(), background: airTempBg(r.concreteT, rPrev?.concreteT, rNext?.concreteT) }}>
${fmt(r.concreteT)}${u('°C')} ${fmt(r.concreteT)}${u('°C')}
</td>`; </td>`}
})()}
${visibleCols.soilT && html` ${visibleCols.soilT && html`
<td class=${groupStart('soilT')} style=${{ color: airTempFontColor(), background: airTempBg(r.soilT0, rPrev?.soilT0, rNext?.soilT0) }}>${r.soilT0 != null ? fmt(r.soilT0) + u('°C') : '—'}</td>`} <td class=${groupStart('soilT')} style=${{ color: airTempFontColor(), background: airTempBg(r.soilT0, rPrev?.soilT0, rNext?.soilT0) }}>${r.soilT0 != null ? fmt(r.soilT0) + u('°C') : '—'}</td>`}
${visibleCols.soilT6 && html` ${visibleCols.soilT6 && html`
@@ -1587,7 +1464,7 @@ export function UTCIForecast() {
<td class=${groupStart('soilM')} style=${(() => { const sm = soilMoistureBg(r.soilM); return { color: sm.fg, background: sm.bg }; })()}>${r.soilM != null ? fmt(r.soilM * 100, 1) + u('%') : '—'}</td>`} <td class=${groupStart('soilM')} style=${(() => { const sm = soilMoistureBg(r.soilM); return { color: sm.fg, background: sm.bg }; })()}>${r.soilM != null ? fmt(r.soilM * 100, 1) + u('%') : '—'}</td>`}
${visibleCols.shadeT && html`<td class=${groupStart('shadeT')} style=${{ background: airTempBg(r.shadeT, rPrev?.shadeT, rNext?.shadeT), color: airTempFontColor() }}>${r.shadeT != null ? fmt(r.shadeT) + u('°C') : '—'}</td>`} ${visibleCols.shadeT && html`<td class=${groupStart('shadeT')} style=${{ background: airTempBg(r.shadeT, rPrev?.shadeT, rNext?.shadeT), color: airTempFontColor() }}>${r.shadeT != null ? fmt(r.shadeT) + u('°C') : '—'}</td>`}
${visibleCols.petShadeT && html` ${visibleCols.petShadeT && html`
<td class=${groupStart('petShadeT')} style=${{ color: airTempFontColor(), background: airTempBg(r.shadeT, rPrev?.shadeT, rNext?.shadeT) }}> <td class=${groupStart('petShadeT')} title=${r.shadeT != null ? (petCategory(r.shadeT)?.label ?? '') : ''} style=${{ color: airTempFontColor(), background: petAirTempBg(r.shadeT, rPrev?.shadeT, rNext?.shadeT) }}>
${r.shadeT != null ? fmt(r.shadeT) + u('°C') : '—'} ${r.shadeT != null ? fmt(r.shadeT) + u('°C') : '—'}
</td>`} </td>`}
${visibleCols.air && html`<td class=${groupStart('air')} style=${{ background: airTempBg(r.Ta, rPrev?.Ta, rNext?.Ta), color: airTempFontColor() }}>${fmt(r.Ta)}${u('°C')}</td>`} ${visibleCols.air && html`<td class=${groupStart('air')} style=${{ background: airTempBg(r.Ta, rPrev?.Ta, rNext?.Ta), color: airTempFontColor() }}>${fmt(r.Ta)}${u('°C')}</td>`}
@@ -1850,6 +1727,36 @@ export function UTCIForecast() {
</span>`; </span>`;
})} })}
</div> </div>
${(visibleCols.furSurfaceT || visibleCols.petHomeT || visibleCols.petShadeT || visibleCols.pawT) && html`
<span class="utci-legend-label utci-legend-label--secondary">Pet thermal stress bands</span>
<div class="utci-legend-row">
${[
{ t: -15, label: 'Freezing', value: '< -8°C' },
{ t: 0, label: 'Cold', value: '-82°C' },
{ t: 9, label: 'Cool', value: '211°C' },
{ t: 18, label: 'Comfortable', value: '1125°C', bold: true },
{ t: 28, label: 'Warm', value: '2532°C' },
{ t: 36, label: 'Caution', value: '3240°C' },
{ t: 46, label: 'Extreme', value: '4052°C' },
{ t: 60, label: 'Danger', value: '52°C+' },
].map((b, i) => {
// Exact same recipe as the human legend above (same 135deg
// light/mid/dark sweep, same luminance-based font colour) -
// just reading from petAirTempRgb instead of airTempRgb.
const rgb = petAirTempRgb(b.t) || [200, 200, 200];
const mid = `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`;
const light = `rgb(${rgb.map(c => Math.min(255, Math.round(c + (255-c)*0.30))).join(',')})`;
const dark = `rgb(${rgb.map(c => Math.round(c * 0.96)).join(',')})`;
const bg = `linear-gradient(135deg, ${light} 0%, ${mid} 60%, ${dark} 100%)`;
const lum = (rgb[0]*299 + rgb[1]*587 + rgb[2]*114) / 1000;
const fg = lum > 165 ? '#1a1a1a' : '#ffffff';
return html`<span key=${i} class="utci-legend-item" style=${{ background: bg, color: fg, ...(b.bold ? { fontWeight: 700 } : {}) }}>
<span class="utci-legend-item-label">${b.label}</span>
<span class="utci-legend-item-value">${b.value}</span>
</span>`;
})}
</div>`}
</div> </div>
+160 -16
View File
@@ -16,7 +16,7 @@
// ------------------------------------------------------------------------ // ------------------------------------------------------------------------
import { h, Fragment } from '../vendor/preact.js'; import { h, Fragment } from '../vendor/preact.js';
import { useState, useEffect, useRef } from '../vendor/preact-hooks.js'; import { useState, useEffect, useLayoutEffect, useRef } from '../vendor/preact-hooks.js';
import htm from '../vendor/htm.js'; import htm from '../vendor/htm.js';
import { import {
skyGradientForElev, skyFillForElev, grassFillForElev, skyGradientForElev, skyFillForElev, grassFillForElev,
@@ -99,9 +99,25 @@ const MOON_TEX = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADD
// groupedLeft - bool - fuse right side with a VentPill // groupedLeft - bool - fuse right side with a VentPill
// isLastChild - bool - restore right border-radius when no sibling follows // isLastChild - bool - restore right border-radius when no sibling follows
// ------------------------------------------------------------------- // -------------------------------------------------------------------
// Finds the nearest scrollable ancestor (e.g. the profile/config bottom
// sheet's scrolling body) so dropdown panels can size themselves to the
// space actually available before that ancestor clips them, rather than
// guessing against the full viewport and getting cropped invisibly.
function getScrollParent(el) {
let node = el && el.parentElement;
while (node && node !== document.body) {
const style = getComputedStyle(node);
if (/(auto|scroll)/.test(style.overflowY)) return node;
node = node.parentElement;
}
return document.documentElement;
}
export function CustomSelect({ value, options, onChange, hideLabel, hidingLabel, buttonLabel, isOn, noHide, groupedLeft, isLastChild, grpClass, sceneStyle, sceneContent }) { export function CustomSelect({ value, options, onChange, hideLabel, hidingLabel, buttonLabel, isOn, noHide, groupedLeft, isLastChild, grpClass, sceneStyle, sceneContent }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [flipRight, setFlipRight] = useState(false); const [flipUp, setFlipUp] = useState(false);
const [maxPanelHeight, setMaxPanelHeight] = useState(null);
const [panelLeft, setPanelLeft] = useState(null);
const wrapRef = useRef(null); const wrapRef = useRef(null);
const panelRef = useRef(null); const panelRef = useRef(null);
@@ -122,13 +138,37 @@ export function CustomSelect({ value, options, onChange, hideLabel, hidingLabel,
}; };
}, [open]); }, [open]);
// After the panel renders, check if it overflows the right viewport edge. // After the panel renders, centre it under/over the button, then clamp
// If so, flip it to right-align; otherwise keep it left-aligned. // that position so it never spills past the real clipping ancestor's
useEffect(() => { // edges (e.g. the profile/config bottom sheet's scrolling body, which
if (!open) { setFlipRight(false); return; } // also clips horizontally whenever it clips vertically — see
if (!panelRef.current) return; // getScrollParent above — so comparing against the browser viewport alone
// isn't enough). Also flip to whichever side (up/down) has more room, and
// cap the panel's height to whatever space is actually available on that
// side so a long list scrolls internally instead of being cropped
// invisibly.
// useLayoutEffect (not useEffect) so this measure-and-position happens
// before the browser paints the first frame — otherwise the panel visibly
// flashes in its unclamped spot for one frame before correcting itself.
useLayoutEffect(() => {
if (!open) { setFlipUp(false); setMaxPanelHeight(null); setPanelLeft(null); return; }
if (!panelRef.current || !wrapRef.current) return;
const rect = panelRef.current.getBoundingClientRect(); const rect = panelRef.current.getBoundingClientRect();
setFlipRight(rect.right > window.innerWidth - 8); const wrapRect = wrapRef.current.getBoundingClientRect();
const bound = getScrollParent(wrapRef.current).getBoundingClientRect();
const desiredLeft = wrapRect.left + wrapRect.width / 2 - rect.width / 2;
const minLeft = bound.left + 4;
const maxLeft = bound.right - 4 - rect.width;
const clampedLeft = Math.max(minLeft, Math.min(desiredLeft, maxLeft));
setPanelLeft(clampedLeft - wrapRect.left);
const spaceBelow = bound.bottom - wrapRect.bottom;
const spaceAbove = wrapRect.top - bound.top;
const overflowsDown = rect.bottom > bound.bottom - 8;
const flip = overflowsDown && spaceAbove > spaceBelow;
setFlipUp(flip);
setMaxPanelHeight(Math.max(80, Math.floor((flip ? spaceAbove : spaceBelow) - 12)));
}, [open]); }, [open]);
// Build the label shown on the button // Build the label shown on the button
@@ -145,6 +185,11 @@ export function CustomSelect({ value, options, onChange, hideLabel, hidingLabel,
(groupedLeft && isLastChild) ? 'last-child' : '', (groupedLeft && isLastChild) ? 'last-child' : '',
].filter(Boolean).join(' '); ].filter(Boolean).join(' ');
// Longer lists (SunSoak, Indoors, ...) lay out as two columns so every
// option is visible at a glance instead of relying on people to notice
// they need to scroll a cropped list.
const useColumns = options.length > 5;
return html` return html`
<span class=${`cs-wrap${open ? ' open' : ''}`} ref=${wrapRef}> <span class=${`cs-wrap${open ? ' open' : ''}`} ref=${wrapRef}>
<button <button
@@ -158,7 +203,11 @@ export function CustomSelect({ value, options, onChange, hideLabel, hidingLabel,
<span class="cs-arrow"></span> <span class="cs-arrow"></span>
</button> </button>
${open && html` ${open && html`
<div class=${`cs-panel${flipRight ? ' cs-panel--right' : ''}`} ref=${panelRef} role="listbox"> <div class=${`cs-panel${flipUp ? ' cs-panel--up' : ''}${useColumns ? ' cs-panel--cols' : ''}`} ref=${panelRef} role="listbox"
style=${{
...(panelLeft != null ? { left: panelLeft + 'px' } : {}),
...(maxPanelHeight != null ? { maxHeight: maxPanelHeight + 'px' } : {}),
}}>
${isOn && !noHide && html` ${isOn && !noHide && html`
<span <span
class="cs-option" class="cs-option"
@@ -405,7 +454,7 @@ export function WindVane({ bearing, size = 30 }) {
// elev: solar elevation in degrees (negative = night) // elev: solar elevation in degrees (negative = night)
// dt: Date used for moon phase // dt: Date used for moon phase
// ------------------------------------------------------------------- // -------------------------------------------------------------------
export function CloudIcon({ category, size = 30, elev = 90, dt = new Date(), filled = false }) { export function CloudIcon({ category, size = 30, elev = 90, dt = new Date(), filled = false, showSun = true }) {
const r = size / 2; const r = size / 2;
const brass = '#c8922a'; const brass = '#c8922a';
const ink = '#2a1a08'; const ink = '#2a1a08';
@@ -451,7 +500,7 @@ export function CloudIcon({ category, size = 30, elev = 90, dt = new Date(), fil
return html` return html`
<svg width=${size} height=${size} viewBox=${`0 0 ${size} ${size}`} <svg width=${size} height=${size} viewBox=${`0 0 ${size} ${size}`}
style=${{ flexShrink: 0, display: 'inline-block', verticalAlign: 'middle', overflow: 'visible' }}> style=${{ flexShrink: 0, display: 'inline-block', verticalAlign: 'middle', overflow: 'visible' }}>
${category === 'clear' && elev >= 0 && html` ${category === 'clear' && elev >= 0 && showSun && html`
${sun(size * 0.50, size * 0.38, size * 0.16)}`} ${sun(size * 0.50, size * 0.38, size * 0.16)}`}
${category === 'clear' && elev < 0 && html` ${category === 'clear' && elev < 0 && html`
<path d=${`M ${size*0.45} ${size*0.15} <path d=${`M ${size*0.45} ${size*0.15}
@@ -464,17 +513,20 @@ export function CloudIcon({ category, size = 30, elev = 90, dt = new Date(), fil
<circle cx=${size*0.18} cy=${size*0.66} r=${size*0.018} fill=${brass} opacity="0.55" /> <circle cx=${size*0.18} cy=${size*0.66} r=${size*0.018} fill=${brass} opacity="0.55" />
<circle cx=${size*0.88} cy=${size*0.48} r=${size*0.018} fill=${brass} opacity="0.55" />`} <circle cx=${size*0.88} cy=${size*0.48} r=${size*0.018} fill=${brass} opacity="0.55" />`}
${category === 'wispy' && elev >= 0 && html` ${category === 'wispy' && elev >= 0 && html`
${sun(size * 0.66, size * 0.30, size * 0.13)} ${showSun && sun(size * 0.66, size * 0.30, size * 0.13)}
${line(size * 0.14, size * 0.58, size * 0.64, size * 0.58, brass, sw * 0.85, 0.85)} ${line(size * 0.14, size * 0.58, size * 0.64, size * 0.58, brass, sw * 0.85, 0.85)}
${line(size * 0.24, size * 0.74, size * 0.78, size * 0.74, ink, sw * 0.78, 0.68)}`} ${line(size * 0.24, size * 0.74, size * 0.78, size * 0.74, ink, sw * 0.78, 0.68)}`}
${category === 'wispy' && elev < 0 && html` ${category === 'wispy' && elev < 0 && html`
${mist(size * 0.18, brass)} ${mist(size * 0.18, brass)}
${line(size * 0.18, size * 0.56, size * 0.60, size * 0.56, muted, sw * 0.7, 0.6)}`} ${line(size * 0.18, size * 0.56, size * 0.60, size * 0.56, muted, sw * 0.7, 0.6)}`}
${category === 'scattered' && elev >= 0 && html` ${category === 'scattered' && elev >= 0 && html`
${sun(size * 0.72, size * 0.24, size * 0.11)} ${showSun && sun(size * 0.72, size * 0.24, size * 0.11)}
<path d=${cloudPath(-size * 0.12, size * 0.02, 0.64)} fill=${filled ? 'rgba(255,255,255,0.88)' : 'none'} stroke=${filled ? 'none' : ink} <path d=${cloudPath(-size * 0.20, -size * 0.32, 0.36)} fill=${filled ? 'rgba(255,255,255,0.88)' : 'none'} stroke=${filled ? 'none' : muted}
stroke-width=${sw} stroke-linecap=${cap} stroke-linejoin=${join} /> stroke-width=${sw * 0.65} stroke-linecap=${cap} stroke-linejoin=${join} opacity=${filled ? 1 : 0.85} />
${!filled && line(size * 0.12, size * 0.60, size * 0.38, size * 0.60, brass, sw * 0.72, 0.78)}`} <path d=${cloudPath(size * 0.18, -size * 0.38, 0.30)} fill=${filled ? 'rgba(255,255,255,0.88)' : 'none'} stroke=${filled ? 'none' : muted}
stroke-width=${sw * 0.6} stroke-linecap=${cap} stroke-linejoin=${join} opacity=${filled ? 1 : 0.85} />
<path d=${cloudPath(0, -size * 0.20, 0.28)} fill=${filled ? 'rgba(255,255,255,0.88)' : 'none'} stroke=${filled ? 'none' : muted}
stroke-width=${sw * 0.55} stroke-linecap=${cap} stroke-linejoin=${join} opacity=${filled ? 1 : 0.85} />`}
${category === 'scattered' && elev < 0 && html` ${category === 'scattered' && elev < 0 && html`
<path d=${cloudPath(0, -size * 0.2425, 0.82)} fill=${filled ? 'rgba(255,255,255,0.88)' : 'none'} stroke=${filled ? 'none' : ink} <path d=${cloudPath(0, -size * 0.2425, 0.82)} fill=${filled ? 'rgba(255,255,255,0.88)' : 'none'} stroke=${filled ? 'none' : ink}
stroke-width=${sw} stroke-linecap=${cap} stroke-linejoin=${join} /> stroke-width=${sw} stroke-linecap=${cap} stroke-linejoin=${join} />
@@ -1265,3 +1317,95 @@ export function PrecipIcon({ precip = 0, snow = 0, size = 28, filled = false })
</svg>` </svg>`
} }
// HOUSEICON / CARICON - Brass Line glyphs for the day-tab shade when a
// modelled indoor or vehicle-cabin temperature stands in for the outdoor
// weather icon.
// -------------------------------------------------------------------
export function HouseIcon({ size = 30 }) {
const sw = Math.max(1.25, size * 0.058);
const brass = '#c8922a';
const ink = '#2a1a08';
return html`
<svg width=${size} height=${size} viewBox=${`0 0 ${size} ${size}`}
style=${{ flexShrink: 0, display: 'inline-block', verticalAlign: 'middle', overflow: 'visible' }}>
<path d=${`M ${size*0.13} ${size*0.52}
L ${size*0.50} ${size*0.15}
L ${size*0.87} ${size*0.52}`}
fill="none" stroke=${brass} stroke-width=${sw} stroke-linecap="round" stroke-linejoin="round" />
<path d=${`M ${size*0.22} ${size*0.46}
V ${size*0.85}
H ${size*0.78}
V ${size*0.46}`}
fill="none" stroke=${ink} stroke-width=${sw} stroke-linecap="round" stroke-linejoin="round" />
<path d=${`M ${size*0.42} ${size*0.85}
V ${size*0.60}
H ${size*0.58}
V ${size*0.85}`}
fill="none" stroke=${ink} stroke-width=${sw*0.8} stroke-linecap="round" stroke-linejoin="round" />
</svg>`;
}
// Body path is Lucide's "car" icon (ISC licence), used verbatim on its
// native 24x24 grid rather than hand-plotted, since freehand coordinates
// kept coming out lopsided.
export function CarIcon({ size = 30 }) {
const brass = '#c8922a';
const ink = '#2a1a08';
return html`
<svg width=${size} height=${size} viewBox="0 0 24 24" fill="none"
style=${{ flexShrink: 0, display: 'inline-block', verticalAlign: 'middle', overflow: 'visible' }}>
<path d="M19 17h2c.6 0 1-.4 1-1v-3c0-.9-.7-1.7-1.5-1.9C18.7 10.6 16 10 16 10s-1.3-1.4-2.2-2.3c-.5-.4-1.1-.7-1.8-.7H5c-.6 0-1.1.4-1.4.9l-1.4 2.9A3.7 3.7 0 0 0 2 12v4c0 .6.4 1 1 1h2"
stroke=${brass} stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
<path d="M9 17h6" stroke=${ink} stroke-width="1.8" stroke-linecap="round" />
<circle cx="7" cy="17" r="2" fill="#fdf6e8" stroke=${ink} stroke-width="1.6" />
<circle cx="17" cy="17" r="2" fill="#fdf6e8" stroke=${ink} stroke-width="1.6" />
</svg>`;
}
// FOGICON - reuses the same closed puffy-cloud outline as PrecipIcon's
// cloud (rather than a foreign icon-set shape) so it sits consistently
// alongside the rain/snow glyphs, with mist bands underneath.
export function FogIcon({ size = 30 }) {
const brass = '#c8922a';
const ink = '#2a1a08';
const sw = Math.max(1.25, size * 0.055);
return html`
<svg width=${size} height=${size} viewBox=${`0 0 ${size} ${size}`}
style=${{ flexShrink: 0, display: 'inline-block', verticalAlign: 'middle', overflow: 'visible' }}>
<path d=${`M ${size*0.18} ${size*0.36}
H ${size*0.72}
C ${size*0.86} ${size*0.36}, ${size*0.88} ${size*0.20}, ${size*0.72} ${size*0.19}
C ${size*0.66} ${size*0.04}, ${size*0.42} ${size*0.02}, ${size*0.34} ${size*0.17}
C ${size*0.22} ${size*0.14}, ${size*0.14} ${size*0.24}, ${size*0.18} ${size*0.36} Z`}
fill="none" stroke=${brass} stroke-width=${sw} stroke-linecap="round" stroke-linejoin="round" />
<line x1=${size*0.14} y1=${size*0.46} x2=${size*0.86} y2=${size*0.46}
stroke=${ink} stroke-width=${sw*0.85} stroke-linecap="round" />
<line x1=${size*0.22} y1=${size*0.58} x2=${size*0.78} y2=${size*0.58}
stroke=${ink} stroke-width=${sw*0.85} stroke-linecap="round" />
<line x1=${size*0.10} y1=${size*0.70} x2=${size*0.90} y2=${size*0.70}
stroke=${ink} stroke-width=${sw*0.85} stroke-linecap="round" opacity="0.7" />
</svg>`;
}
export function IceIcon({ size = 30 }) {
const icy = '#3f73c4';
return html`
<svg width=${size} height=${size} viewBox="0 0 24 24" fill="none"
style=${{ flexShrink: 0, display: 'inline-block', verticalAlign: 'middle', overflow: 'visible' }}>
<g stroke=${icy} stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="m10 20-1.25-2.5L6 18" />
<path d="M10 4 8.75 6.5 6 6" />
<path d="m14 20 1.25-2.5L18 18" />
<path d="m14 4 1.25 2.5L18 6" />
<path d="m17 21-3-6h-4" />
<path d="m17 3-3 6 1.5 3" />
<path d="M2 12h6.5L10 9" />
<path d="m20 10-1.5 2 1.5 2" />
<path d="M22 12h-6.5L14 15" />
<path d="m4 10 1.5 2L4 14" />
<path d="m7 21 3-6-1.5-3" />
<path d="m7 3 3 6h4" />
</g>
</svg>`;
}
+350
View File
@@ -0,0 +1,350 @@
// ------------------------------------------------------------------------
// components/ConfigPanel.js - Dropdown holding the profile picker and the
// context-sensitive config strip, opened from the profile trigger embedded
// in the middle of the (permanently floating) top nav.
//
// Lifted out of DayTabs.js so the profile/config UI no longer occupies a
// permanent full-width band above the forecast — it now expands downward
// from the nav, directly below it, keeping the original full-width
// horizontal layout (profile tabs, horizontally-scrolling card row, config
// strip) intact rather than squeezing it into a side drawer.
//
// Purely presentational - all state lives in useAppState. Dismisses on the
// × button, backdrop click, or Esc.
//
// Props:
// onClose - called on dismiss (×, backdrop, Esc)
// isPro - boolean Pro status
// activeProfile - current filter profile key
// activateProfile - function to switch profile
// activeCols - effective column set for current profile
// activityOptions/placeOptions/workOptions - dropdown options
// activityValue/placeValue/workValue - current variant values
// outdoorsVariant, setOutdoorsVariantAndSave
// setActiveProfile, setVisibleCols, setIndoorMode
// setProPromptSource, setProPromptDay - Pro upsell triggers
// indoorManaged/setIndoorManaged, buildingType/setBuildingType
// utciEnv/setUtciEnv, vehicleType/setVehicleType,
// vehicleSpeed/setVehicleSpeed, vehicleVent/setVehicleVent,
// furColor/setFurColor, skinType/setSkinType, pollenType/setPollenTypeAndSave
// ------------------------------------------------------------------------
import { h, Fragment } from '../../vendor/preact.js';
import { useRef, useEffect, useState } from '../../vendor/preact-hooks.js';
import htm from '../../vendor/htm.js';
import { SKIN_TYPES, VEHICLE_TYPES, VEHICLE_SPEEDS, BUILDING_TYPES, FUR_COLORS } from '../utils.js';
import { FILTER_PROFILES, OUTDOORS_VARIANTS, profileButtonOrder, activityVariantKeys, placeVariantKeys, workVariantKeys, POLLEN_TYPES, UTCI_ENVIRONMENTS, deriveProfileMain } from '../config.js';
import { CustomSelect, VentPill } from '../components.js';
const html = htm.bind(h);
export function ConfigPanel({
open,
onClose,
isPro,
activeProfile, activateProfile, activeCols,
activityOptions, placeOptions, workOptions,
activityValue, placeValue, workValue,
outdoorsVariant, setOutdoorsVariantAndSave,
setActiveProfile, setVisibleCols, setIndoorMode,
setProPromptSource, setProPromptDay,
indoorManaged, setIndoorManaged, buildingType, setBuildingType,
utciEnv, setUtciEnv,
vehicleType, setVehicleType, vehicleSpeed, setVehicleSpeed, vehicleVent, setVehicleVent,
furColor, setFurColor, skinType, setSkinType, pollenType, setPollenTypeAndSave,
}) {
const profileScrollRef = useRef(null);
const profileWrapRef = useRef(null);
const { mainConfigKey, mainLabel } = deriveProfileMain(activeProfile, outdoorsVariant);
// Active tab: common - places - activities - work.
// Auto-derived from the current profile on mount and on change.
const getTab = () => {
if (activeProfile === 'outdoors' && placeVariantKeys.includes(outdoorsVariant)) return 'places';
if (activeProfile === 'outdoors' && activityVariantKeys.includes(outdoorsVariant)) return 'activities';
if (activeProfile === 'farming' || (activeProfile === 'outdoors' && workVariantKeys.includes(outdoorsVariant))) return 'work';
return 'common';
};
const [activeTab, setActiveTab] = useState(getTab);
useEffect(() => { setActiveTab(getTab()); }, [activeProfile, outdoorsVariant]);
// Dismiss on Esc (component stays mounted while closed so the close
// transition can play, so this only acts while actually open).
useEffect(() => {
const onKey = (e) => { if (open && e.key === 'Escape') onClose(); };
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [open, onClose]);
// Drag-to-scroll + fade edges for the horizontal profile card row.
useEffect(() => {
const el = profileScrollRef.current;
const wrap = profileWrapRef.current;
if (!el) return;
let isDown = false, startX = 0, startScroll = 0, hasDragged = false;
const updateFades = () => {
if (!wrap) return;
wrap.classList.toggle('fade-left', el.scrollLeft > 1);
wrap.classList.toggle('fade-right', el.scrollLeft + el.clientWidth < el.scrollWidth - 1);
};
const onMouseDown = (e) => {
if (!el.contains(e.target) || e.button !== 0) return;
isDown = true; hasDragged = false;
startX = e.clientX; startScroll = el.scrollLeft;
document.body.style.userSelect = 'none';
document.body.style.webkitUserSelect = 'none';
};
const onMouseMove = (e) => {
if (!isDown) return;
const dx = e.clientX - startX;
if (Math.abs(dx) > 5) {
hasDragged = true;
el.style.cursor = 'grabbing';
el.scrollLeft = startScroll - dx;
}
};
const onMouseUp = () => {
if (!isDown) return;
isDown = false;
el.style.cursor = '';
document.body.style.userSelect = '';
document.body.style.webkitUserSelect = '';
};
const onClickCapture = (e) => {
if (hasDragged) { e.stopPropagation(); e.preventDefault(); hasDragged = false; }
};
el.addEventListener('scroll', updateFades);
updateFades(); // set initial fade state
document.addEventListener('mousedown', onMouseDown);
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
el.addEventListener('click', onClickCapture, true);
return () => {
el.removeEventListener('scroll', updateFades);
document.removeEventListener('mousedown', onMouseDown);
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
el.removeEventListener('click', onClickCapture, true);
};
}, []);
const mainBadge = html`<span class="config-item-main-badge" title="Main selection — this is what drives the day tab's hi/lo readout">${mainLabel} Config:</span>`;
const showSolar = isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['utciP']) : activeCols['utciP'];
const showVehicle = isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['vehicleT']) : activeCols['vehicleT'];
const showIndoor = isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['indoorT'] || activeCols['managedT']) : (activeCols['indoorT'] || activeCols['managedT']);
const showFur = isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['furSurfaceT']) : activeCols['furSurfaceT'];
const showBurn = isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['burn']) : activeCols['burn'];
const showPollen = isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['pollen']) : activeCols['pollen'];
const configItems = [
{ key: 'solar', show: showSolar, node: html`
<span class="config-item-label">SunSoak</span>
<${CustomSelect}
value=${utciEnv} isOn=${true} noHide=${true} grpClass="grp-felt"
hideLabel="SunSoak"
options=${Object.entries(UTCI_ENVIRONMENTS).map(([k, v]) => ({ value: k, label: v.label }))}
onChange=${(v) => setUtciEnv(v)}
/>` },
{ key: 'vehicle', show: showVehicle, node: html`
<span class="config-item-label">Vehicle</span>
<span class="col-toggle-group col-toggle-group--expanded col-toggle-group--triple">
<${CustomSelect}
value=${vehicleType} isOn=${true} noHide=${true} grpClass="grp-felt"
hideLabel="Vehicle" groupedLeft=${true} isLastChild=${false}
options=${Object.entries(VEHICLE_TYPES).map(([k, v]) => ({ value: k, label: v.name }))}
onChange=${(v) => setVehicleType(v)}
/>
<${CustomSelect}
value=${vehicleSpeed} isOn=${true} noHide=${true} grpClass="grp-felt"
buttonLabel=${(VEHICLE_SPEEDS[vehicleSpeed] ?? VEHICLE_SPEEDS.static).name}
groupedLeft=${true} isLastChild=${false}
options=${Object.entries(VEHICLE_SPEEDS).map(([k, v]) => ({ value: k, label: v.name }))}
onChange=${(v) => setVehicleSpeed(v)}
/>
<${VentPill}
checked=${vehicleVent}
onChange=${() => setVehicleVent(v => !v)}
grpClass="grp-felt"
label="Ventilation"
title="Ventilation — open windows significantly reduce cabin heat build-up. Speed sets cabin airflow from forward motion."
/>
</span>` },
{ key: 'indoor', show: showIndoor, node: html`
<span class="config-item-label">Indoors</span>
<span class="col-toggle-group col-toggle-group--expanded">
<${CustomSelect}
value=${buildingType} isOn=${true} noHide=${true} grpClass="grp-felt"
hideLabel="Indoors" groupedLeft=${true} isLastChild=${false}
options=${Object.entries(BUILDING_TYPES).map(([k, v]) => ({ value: k, label: v.name }))}
onChange=${(v) => setBuildingType(v)}
/>
<${VentPill}
checked=${indoorManaged}
onChange=${() => setIndoorManaged(v => !v)}
grpClass="grp-felt"
label="Managed"
title="Managed: curtains closed by day, windows open when cooler outside"
/>
</span>` },
{ key: 'fur', show: showFur, node: html`
<span class="config-item-label">Fur Colour</span>
<${CustomSelect}
value=${furColor} isOn=${true} noHide=${true} grpClass="grp-surface"
hideLabel="Fur Colour"
options=${Object.entries(FUR_COLORS).map(([k, v]) => ({ value: k, label: v.name }))}
onChange=${(v) => setFurColor(v)}
/>` },
{ key: 'burn', show: showBurn, node: html`
<span class="config-item-label">Burn</span>
<${CustomSelect}
value=${skinType} isOn=${true} noHide=${true} grpClass="grp-felt"
hideLabel="Burn"
options=${Object.entries(SKIN_TYPES).map(([k, v]) => ({ value: k, label: v.name.split(' · ')[1] + ' skin' }))}
onChange=${(v) => setSkinType(v)}
/>` },
{ key: 'pollen', show: showPollen, node: html`
<span class="config-item-label">Pollen</span>
<${CustomSelect}
value=${pollenType} isOn=${true} noHide=${true} grpClass="grp-airqual"
hideLabel="Pollen"
options=${Object.entries(POLLEN_TYPES).flatMap(([k, v], i) => [
{ value: k, label: v.name },
...(i === 0 ? [{ value: '_div', divider: true }] : []),
])}
onChange=${(v) => setPollenTypeAndSave(v)}
/>` },
].filter((item) => item.show);
configItems.sort((a, b) => (a.key === mainConfigKey ? -1 : b.key === mainConfigKey ? 1 : 0));
return html`
<${Fragment}>
<div class=${`profile-dropdown-overlay${open ? ' is-open' : ''}`} onClick=${onClose}></div>
<div class=${`profile-dropdown${open ? ' is-open' : ''}`} role="dialog" aria-modal="true" aria-label="Profile and configuration" aria-hidden=${!open}>
<button class="profile-dropdown-close" aria-label="Close" onClick=${onClose}>×</button>
<div class="profile-dropdown-body">
<div class="filter-profiles">
<div class="profile-tabs">
${[['common','Common'],['places','Places'],['activities','Activities'],['work','Work']].map(([key, label]) => html`
<button
key=${key}
class=${`profile-tab${activeTab === key ? ' active' : ''}`}
onClick=${() => setActiveTab(key)}
>${label}</button>
`)}
</div>
<div class="profile-scroll-wrap" ref=${profileWrapRef}>
<span class="profile-scroll-chevron left" aria-hidden="true"></span>
<span class="profile-scroll-chevron right" aria-hidden="true"></span>
<div class="profile-scroll" ref=${profileScrollRef}>
${activeTab === 'common' && profileButtonOrder.map((key) => {
const profile = FILTER_PROFILES[key];
const locked = profile.proOnly && !isPro;
return html`
<button
key=${key}
class=${`profile-btn${activeProfile === key ? ' active' : ''}${locked ? ' locked' : ''}`}
style=${{ cursor: 'pointer', position: 'relative' }}
title=${locked ? `Profile ${profile.label} is part of SunScope Extra` : ''}
onClick=${() => {
if (locked) { setProPromptSource(`profile:${key}`); setProPromptDay(0); return; }
activateProfile(key);
}}
>
<span class="profile-btn-scene" style=${{ background: profile.scene, filter: locked ? 'grayscale(100%)' : 'none' }}>${profile.scene.includes('url(') ? null : profile.icon}</span>
<span class="profile-btn-label">${locked ? '🔒 ' : ''}${profile.label}</span>
${locked && html`<span class="profile-lock-badge">🔒</span>`}
</button>`;
})}
${activeTab === 'places' && placeOptions.map((opt) => html`
<button
key=${opt.value}
class=${`profile-btn${placeValue === opt.value ? ' active' : ''}${opt.disabled ? ' locked' : ''}`}
style=${{ cursor: 'pointer', position: 'relative' }}
onClick=${() => {
if (opt.disabled) { setProPromptSource(`variant:${opt.value}`); setProPromptDay(0); return; }
try { localStorage.setItem('sunscope_profile', 'outdoors'); } catch(e) {}
setActiveProfile('outdoors');
setOutdoorsVariantAndSave(opt.value);
setVisibleCols({ ...OUTDOORS_VARIANTS[opt.value].cols });
setIndoorMode('off');
setIndoorManaged(false);
}}
>
<span class="profile-btn-scene" style=${{ background: opt.scene, filter: opt.disabled ? 'grayscale(100%)' : 'none' }}></span>
<span class="profile-btn-label">${opt.disabled ? '🔒 ' : ''}${opt.name}</span>
${opt.disabled && html`<span class="profile-lock-badge">🔒</span>`}
</button>
`)}
${activeTab === 'activities' && activityOptions.map((opt) => html`
<button
key=${opt.value}
class=${`profile-btn${activityValue === opt.value ? ' active' : ''}${opt.disabled ? ' locked' : ''}`}
style=${{ cursor: 'pointer', position: 'relative' }}
onClick=${() => {
if (opt.disabled) { setProPromptSource(`variant:${opt.value}`); setProPromptDay(0); return; }
try { localStorage.setItem('sunscope_profile', 'outdoors'); } catch(e) {}
setActiveProfile('outdoors');
setOutdoorsVariantAndSave(opt.value);
setVisibleCols({ ...OUTDOORS_VARIANTS[opt.value].cols });
setIndoorMode('off');
setIndoorManaged(false);
}}
>
<span class="profile-btn-scene" style=${{ background: opt.scene, filter: opt.disabled ? 'grayscale(100%)' : 'none' }}></span>
<span class="profile-btn-label">${opt.disabled ? '🔒 ' : ''}${opt.name}</span>
${opt.disabled && html`<span class="profile-lock-badge">🔒</span>`}
</button>
`)}
${activeTab === 'work' && workOptions.map((opt) => html`
<button
key=${opt.value}
class=${`profile-btn${workValue === opt.value ? ' active' : ''}${opt.disabled ? ' locked' : ''}`}
style=${{ cursor: 'pointer', position: 'relative' }}
onClick=${() => {
if (opt.disabled) { setProPromptSource(`variant:${opt.value}`); setProPromptDay(0); return; }
if (opt.value === 'farming') { activateProfile('farming'); return; }
try { localStorage.setItem('sunscope_profile', 'outdoors'); } catch(e) {}
setActiveProfile('outdoors');
setOutdoorsVariantAndSave(opt.value);
setVisibleCols({ ...OUTDOORS_VARIANTS[opt.value].cols });
if (opt.value === 'office') { setBuildingType('office'); setIndoorMode('on'); setIndoorManaged(false); }
else { setIndoorMode('off'); setIndoorManaged(false); }
}}
>
<span class="profile-btn-scene" style=${{ background: opt.scene, filter: opt.disabled ? 'grayscale(100%)' : 'none' }}></span>
<span class="profile-btn-label">${opt.disabled ? '🔒 ' : ''}${opt.name}</span>
${opt.disabled && html`<span class="profile-lock-badge">🔒</span>`}
</button>
`)}
</div>
</div>
</div>
${configItems.length > 0 && html`
<div class="col-toggles-config">
${configItems.map((item) => html`
<${Fragment} key=${item.key}>
${item.key === mainConfigKey && mainBadge}
<span class=${`col-toggles-config-item${item.key === mainConfigKey ? ' col-toggles-config-item--main' : ''}`}>
${item.node}
</span>
</${Fragment}>`)}
</div>`}
</div>
</div>
</${Fragment}>`;
}
+179 -216
View File
@@ -1,57 +1,88 @@
// ------------------------------------------------------------------------ // ------------------------------------------------------------------------
// components/DayTabs.js - Day-tab strip, pro-prompt card, confidence // components/DayTabs.js - Day-tab strip, pro-prompt card and confidence
// band bar, and filter-profile row for the UTCIForecast app. // band bar for the UTCIForecast app.
// //
// Extracted from app.js to keep the main component under the AI edit // Extracted from app.js to keep the main component under the AI edit
// safe-zone. All state lives in useAppState - this component is purely // safe-zone. All state lives in useAppState - this component is purely
// presentational and receives everything it needs as props. // presentational and receives everything it needs as props.
// //
// The profile picker and config strip that used to live here now sit in the
// ConfigPanel nav dropdown; DayTabs keeps the derived "main" metric needed
// to compute the day-tab hi/lo numbers, and renders a read-only row above
// the day tabs stating the active profile + thermal basis so it's always
// clear what temperatures are being shown (click/Enter opens the dropdown).
//
// Props: // Props:
// days - array of day objects from buildHourlyRows // days - array of day objects from buildHourlyRows
// selectedDay - index of the active day tab // selectedDay/setSelectedDay - active day tab index + setter
// setSelectedDay - setter for selectedDay
// isPro - boolean Pro status // isPro - boolean Pro status
// openRestore - opens the "Already subscribed?" restore modal // openRestore - opens the "Already subscribed?" restore modal
// FREE_DAYS - number of free days // proPromptDay/setProPromptDay - locked-day upsell index + setter
// proPromptDay - index of locked day that was clicked // proPromptSource/setProPromptSource - upsell copy key + setter
// setProPromptDay - setter for proPromptDay
// proPromptSource - string key for upsell copy
// setProPromptSource - setter for proPromptSource
// activeProfile - current filter profile key // activeProfile - current filter profile key
// activateProfile - function to switch profile
// activeCols - effective column set for current profile
// visibleCols - object of col-key -> boolean visibility
// activityOptions - dropdown options for Activities selector
// placeOptions - dropdown options for Places selector
// workOptions - dropdown options for Work selector
// activityValue - current activity dropdown value
// activityLabel - current activity dropdown label
// placeValue - current place dropdown value
// placeLabel - current place dropdown label
// workValue - current work dropdown value
// workLabel - current work dropdown label
// outdoorsVariant - current outdoors sub-variant key // outdoorsVariant - current outdoors sub-variant key
// setOutdoorsVariantAndSave - setter that also persists to localStorage // openPanel - opens the profile/config nav dropdown
// setActiveProfile - raw setter for activeProfile // vehicleType/vehicleSpeed - current vehicle config (vehicle-cabin day-tab
// setVisibleCols - raw setter for visibleCols // reference calc + row label)
// setIndoorMode - setter for indoorMode // buildingType/indoorManaged/utciEnv/furColor - current config values,
// setIndoorManaged - setter for indoorManaged // for the "Viewing" row label
// dayTabsRef - ref for the scrollable tab strip element // dayTabsRef - ref for the scrollable tab strip element
// canScrollLeft - boolean for left-fade chevron // canScrollLeft/canScrollRight - booleans for the fade chevrons
// canScrollRight - boolean for right-fade chevron
// scrollDayTabs - function(dir) to scroll strip left/right // scrollDayTabs - function(dir) to scroll strip left/right
// ------------------------------------------------------------------------ // ------------------------------------------------------------------------
import { h, Fragment } from '../../vendor/preact.js'; import { h, Fragment } from '../../vendor/preact.js';
import { useRef, useEffect, useState } from '../../vendor/preact-hooks.js';
import htm from '../../vendor/htm.js'; import htm from '../../vendor/htm.js';
import { confidenceBand, utciCategory } from '../utils.js'; import { confidenceBand, utciCategory, VEHICLE_SPEEDS, VEHICLE_TYPES, BUILDING_TYPES, FUR_COLORS } from '../utils.js';
import { FREE_DAYS, FILTER_PROFILES, OUTDOORS_VARIANTS, profileButtonOrder, activityVariantKeys, placeVariantKeys, workVariantKeys } from '../config.js'; import { FREE_DAYS, UTCI_ENVIRONMENTS, deriveProfileMain } from '../config.js';
import { CloudIcon, PrecipIcon, CustomSelect } from '../components.js'; import { calcVehicleInteriorTemp } from '../physics.js';
import { CloudIcon, PrecipIcon, HouseIcon, CarIcon, FogIcon, IceIcon } from '../components.js';
import { SubscribeModal } from './SubscribeModal.js'; import { SubscribeModal } from './SubscribeModal.js';
const html = htm.bind(h); const html = htm.bind(h);
// Per-selection gradients for the active-profile-row background — one entry
// per possible value of each thermal-model category, so the row's shade
// reflects not just "which model" (SunSoak/Vehicle/Indoor/Pets) but the
// specific option currently picked within it (e.g. Beach vs Forest), the
// same way the profile cards use a distinct "scene" per preset.
const ROW_GRADIENTS = {
solar: {
open: 'linear-gradient(120deg, rgba(150,200,120,0.55), rgba(222,236,196,0.30))',
urban: 'linear-gradient(120deg, rgba(160,168,178,0.55), rgba(218,222,228,0.30))',
beach: 'linear-gradient(120deg, rgba(240,208,140,0.55), rgba(182,220,236,0.30))',
river: 'linear-gradient(120deg, rgba(120,196,182,0.55), rgba(202,236,228,0.30))',
forest: 'linear-gradient(120deg, rgba(66,124,70,0.55), rgba(170,202,156,0.30))',
openwater: 'linear-gradient(120deg, rgba(96,150,204,0.55), rgba(180,214,238,0.30))',
alpine: 'linear-gradient(120deg, rgba(180,210,230,0.55), rgba(238,246,250,0.30))',
desert: 'linear-gradient(120deg, rgba(220,164,96,0.55), rgba(246,220,172,0.30))',
},
vehicle: {
car: 'linear-gradient(120deg, rgba(180,192,202,0.55), rgba(222,228,234,0.30))',
mpv: 'linear-gradient(120deg, rgba(172,186,200,0.55), rgba(216,224,232,0.30))',
suv: 'linear-gradient(120deg, rgba(154,172,190,0.55), rgba(206,216,228,0.30))',
truck: 'linear-gradient(120deg, rgba(136,158,180,0.55), rgba(196,208,222,0.30))',
motorhome: 'linear-gradient(120deg, rgba(200,180,148,0.55), rgba(232,220,196,0.30))',
caravan: 'linear-gradient(120deg, rgba(208,188,158,0.55), rgba(236,224,204,0.30))',
},
indoor: {
brick: 'linear-gradient(120deg, rgba(190,120,92,0.50), rgba(226,178,158,0.28))',
modern: 'linear-gradient(120deg, rgba(162,172,180,0.50), rgba(212,218,224,0.28))',
victorian: 'linear-gradient(120deg, rgba(160,76,54,0.50), rgba(210,142,122,0.28))',
stone: 'linear-gradient(120deg, rgba(146,138,124,0.50), rgba(202,196,184,0.28))',
timber: 'linear-gradient(120deg, rgba(182,142,92,0.50), rgba(222,192,150,0.28))',
flat: 'linear-gradient(120deg, rgba(162,162,168,0.50), rgba(212,212,218,0.28))',
conservatory: 'linear-gradient(120deg, rgba(140,198,220,0.50), rgba(202,232,242,0.28))',
office: 'linear-gradient(120deg, rgba(122,148,184,0.50), rgba(188,204,226,0.28))',
},
fur: {
black: 'linear-gradient(120deg, rgba(52,48,44,0.55), rgba(112,106,100,0.30))',
brown: 'linear-gradient(120deg, rgba(126,84,48,0.55), rgba(178,140,98,0.30))',
golden: 'linear-gradient(120deg, rgba(208,160,80,0.55),rgba(236,204,144,0.30))',
white: 'linear-gradient(120deg, rgba(222,214,198,0.55),rgba(248,246,238,0.30))',
},
};
// Active-tab variant: a more solid (less pastelised) version of the weather // Active-tab variant: a more solid (less pastelised) version of the weather
// colour, drawn as a radial gradient whose strong colour sits at the outer // colour, drawn as a radial gradient whose strong colour sits at the outer
// edge and softens toward a lighter centre — so the hue reads as radiating // edge and softens toward a lighter centre — so the hue reads as radiating
@@ -91,190 +122,68 @@ export function DayTabs({
openRestore, openRestore,
proPromptDay, setProPromptDay, proPromptDay, setProPromptDay,
proPromptSource, setProPromptSource, proPromptSource, setProPromptSource,
activeProfile, activateProfile, activeCols, activeProfile, outdoorsVariant,
visibleCols, openPanel,
activityOptions, placeOptions, workOptions, vehicleType, vehicleSpeed, buildingType, indoorManaged, utciEnv, furColor,
activityValue, activityLabel,
placeValue, placeLabel,
workValue, workLabel,
outdoorsVariant, setOutdoorsVariantAndSave,
setActiveProfile, setVisibleCols,
setIndoorMode, setIndoorManaged, setBuildingType,
dayTabsRef, canScrollLeft, canScrollRight, scrollDayTabs, dayTabsRef, canScrollLeft, canScrollRight, scrollDayTabs,
}) { }) {
const profileScrollRef = useRef(null); // Which metric drives the day-tab's hi/lo readout depends on the active
const profileWrapRef = useRef(null); // profile — default is SunSoak (felt) + Shade, but profiles with their own
// dedicated calc show their own numbers instead. The profile picker and the
// full config strip now live in the nav dropdown (ConfigPanel); here we
// only need the derived main metric to compute the day-tab numbers and to
// label the "what temps am I looking at" row below.
const { isHomeOrOffice, isVehicleProfile, mainConfigKey, mainField, secondaryField, mainLabel } =
deriveProfileMain(activeProfile, outdoorsVariant);
// Active tab: common - places - activities - work. // Read-only label of the current main-config value, for the row above the
// Auto-derived from the current profile on mount and on change. // day tabs — makes it obvious at a glance which temperature basis (SunSoak
const getTab = () => { // environment, vehicle type, building type, fur colour) the numbers below
if (activeProfile === 'outdoors' && placeVariantKeys.includes(outdoorsVariant)) return 'places'; // are computed from.
if (activeProfile === 'outdoors' && activityVariantKeys.includes(outdoorsVariant)) return 'activities'; const mainConfigValue = mainConfigKey === 'vehicle'
if (activeProfile === 'farming' || (activeProfile === 'outdoors' && workVariantKeys.includes(outdoorsVariant))) return 'work'; ? (VEHICLE_TYPES[vehicleType]?.name || '')
return 'common'; : mainConfigKey === 'indoor'
}; ? ((BUILDING_TYPES[buildingType]?.name || '') + (indoorManaged ? ' · Managed' : ''))
const [activeTab, setActiveTab] = useState(getTab); : mainConfigKey === 'fur'
useEffect(() => { setActiveTab(getTab()); }, [activeProfile, outdoorsVariant]); ? (FUR_COLORS[furColor]?.name || '')
: (UTCI_ENVIRONMENTS[utciEnv]?.label || '');
useEffect(() => { // Tints the row background to match the thermal model driving it, reusing
const el = profileScrollRef.current; // the same colour groups the CustomSelect pickers use (e.g. SunSoak's
const wrap = profileWrapRef.current; // "Solar model" dropdown is grp-felt) so the row reads as an extension of
if (!el) return; // those controls rather than a separate, unrelated style.
let isDown = false, startX = 0, startScroll = 0, hasDragged = false; const rowGrpClass = mainConfigKey === 'vehicle' ? 'grp-wind'
: mainConfigKey === 'indoor' ? 'grp-ambient'
: mainConfigKey === 'fur' ? 'grp-surface'
: 'grp-felt';
const updateFades = () => { // Within that group, shade further by the exact option chosen — same idea
if (!wrap) return; // as the "Solar model" pulldown, but keyed to whichever value is actually
wrap.classList.toggle('fade-left', el.scrollLeft > 1); // driving the numbers right now (environment / vehicle / building / fur).
wrap.classList.toggle('fade-right', el.scrollLeft + el.clientWidth < el.scrollWidth - 1); const rowOptionKey = mainConfigKey === 'vehicle' ? vehicleType
}; : mainConfigKey === 'indoor' ? buildingType
: mainConfigKey === 'fur' ? furColor
const onMouseDown = (e) => { : utciEnv;
if (!el.contains(e.target) || e.button !== 0) return; const rowGradient = ROW_GRADIENTS[mainConfigKey]?.[rowOptionKey];
isDown = true; hasDragged = false;
startX = e.clientX; startScroll = el.scrollLeft;
document.body.style.userSelect = 'none';
document.body.style.webkitUserSelect = 'none';
};
const onMouseMove = (e) => {
if (!isDown) return;
const dx = e.clientX - startX;
if (Math.abs(dx) > 5) {
hasDragged = true;
el.style.cursor = 'grabbing';
el.scrollLeft = startScroll - dx;
}
};
const onMouseUp = () => {
if (!isDown) return;
isDown = false;
el.style.cursor = '';
document.body.style.userSelect = '';
document.body.style.webkitUserSelect = '';
};
const onClickCapture = (e) => {
if (hasDragged) { e.stopPropagation(); e.preventDefault(); hasDragged = false; }
};
el.addEventListener('scroll', updateFades);
updateFades(); // set initial fade state
document.addEventListener('mousedown', onMouseDown);
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
el.addEventListener('click', onClickCapture, true);
return () => {
el.removeEventListener('scroll', updateFades);
document.removeEventListener('mousedown', onMouseDown);
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
el.removeEventListener('click', onClickCapture, true);
};
}, []);
return html` return html`
<${Fragment}> <${Fragment}>
<div class="filter-profiles"> <div class=${`active-profile-row ${rowGrpClass}`} onClick=${openPanel} role="button" tabIndex="0"
<div class="profile-tabs"> onKeyDown=${(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openPanel(); } }}
${[['common','Common'],['places','Places'],['activities','Activities'],['work','Work']].map(([key, label]) => html` style=${rowGradient ? { '--row-bg': rowGradient } : undefined}
<button title="Change profile & settings">
key=${key} <span class="active-profile-row-item">
class=${`profile-tab${activeTab === key ? ' active' : ''}`} <span class="active-profile-row-label">Profile</span>
onClick=${() => setActiveTab(key)} <span class="active-profile-row-value">${mainLabel}</span>
>${label}</button> </span>
`)} ${mainConfigValue && html`
</div> <span class="active-profile-row-sep" aria-hidden="true">·</span>
<div class="profile-scroll-wrap" ref=${profileWrapRef}> <span class="active-profile-row-item">
<span class="profile-scroll-chevron left" aria-hidden="true"></span> <span class="active-profile-row-label">Viewing</span>
<span class="profile-scroll-chevron right" aria-hidden="true"></span> <span class="active-profile-row-value">${mainConfigValue}</span>
<div class="profile-scroll" ref=${profileScrollRef}> </span>`}
<span class="active-profile-row-edit" aria-hidden="true">Change </span>
${activeTab === 'common' && profileButtonOrder.map((key) => {
const profile = FILTER_PROFILES[key];
const locked = profile.proOnly && !isPro;
return html`
<button
key=${key}
class=${`profile-btn${activeProfile === key ? ' active' : ''}${locked ? ' locked' : ''}`}
style=${{ cursor: 'pointer', position: 'relative' }}
title=${locked ? `Profile ${profile.label} is part of SunScope Extra` : ''}
onClick=${() => {
if (locked) { setProPromptSource(`profile:${key}`); setProPromptDay(0); return; }
activateProfile(key);
}}
>
<span class="profile-btn-scene" style=${{ background: profile.scene, filter: locked ? 'grayscale(100%)' : 'none' }}>${profile.scene.includes('url(') ? null : profile.icon}</span>
<span class="profile-btn-label">${locked ? '🔒 ' : ''}${profile.label}</span>
${locked && html`<span class="profile-lock-badge">🔒</span>`}
</button>`;
})}
${activeTab === 'places' && placeOptions.map((opt) => html`
<button
key=${opt.value}
class=${`profile-btn${placeValue === opt.value ? ' active' : ''}${opt.disabled ? ' locked' : ''}`}
style=${{ cursor: 'pointer', position: 'relative' }}
onClick=${() => {
if (opt.disabled) { setProPromptSource(`variant:${opt.value}`); setProPromptDay(0); return; }
try { localStorage.setItem('sunscope_profile', 'outdoors'); } catch(e) {}
setActiveProfile('outdoors');
setOutdoorsVariantAndSave(opt.value);
setVisibleCols({ ...OUTDOORS_VARIANTS[opt.value].cols });
setIndoorMode('off');
setIndoorManaged(false);
}}
>
<span class="profile-btn-scene" style=${{ background: opt.scene, filter: opt.disabled ? 'grayscale(100%)' : 'none' }}></span>
<span class="profile-btn-label">${opt.disabled ? '🔒 ' : ''}${opt.name}</span>
${opt.disabled && html`<span class="profile-lock-badge">🔒</span>`}
</button>
`)}
${activeTab === 'activities' && activityOptions.map((opt) => html`
<button
key=${opt.value}
class=${`profile-btn${activityValue === opt.value ? ' active' : ''}${opt.disabled ? ' locked' : ''}`}
style=${{ cursor: 'pointer', position: 'relative' }}
onClick=${() => {
if (opt.disabled) { setProPromptSource(`variant:${opt.value}`); setProPromptDay(0); return; }
try { localStorage.setItem('sunscope_profile', 'outdoors'); } catch(e) {}
setActiveProfile('outdoors');
setOutdoorsVariantAndSave(opt.value);
setVisibleCols({ ...OUTDOORS_VARIANTS[opt.value].cols });
setIndoorMode('off');
setIndoorManaged(false);
}}
>
<span class="profile-btn-scene" style=${{ background: opt.scene, filter: opt.disabled ? 'grayscale(100%)' : 'none' }}></span>
<span class="profile-btn-label">${opt.disabled ? '🔒 ' : ''}${opt.name}</span>
${opt.disabled && html`<span class="profile-lock-badge">🔒</span>`}
</button>
`)}
${activeTab === 'work' && workOptions.map((opt) => html`
<button
key=${opt.value}
class=${`profile-btn${workValue === opt.value ? ' active' : ''}${opt.disabled ? ' locked' : ''}`}
style=${{ cursor: 'pointer', position: 'relative' }}
onClick=${() => {
if (opt.disabled) { setProPromptSource(`variant:${opt.value}`); setProPromptDay(0); return; }
if (opt.value === 'farming') { activateProfile('farming'); return; }
try { localStorage.setItem('sunscope_profile', 'outdoors'); } catch(e) {}
setActiveProfile('outdoors');
setOutdoorsVariantAndSave(opt.value);
setVisibleCols({ ...OUTDOORS_VARIANTS[opt.value].cols });
if (opt.value === 'office') { setBuildingType('office'); setIndoorMode('on'); setIndoorManaged(false); }
else { setIndoorMode('off'); setIndoorManaged(false); }
}}
>
<span class="profile-btn-scene" style=${{ background: opt.scene, filter: opt.disabled ? 'grayscale(100%)' : 'none' }}></span>
<span class="profile-btn-label">${opt.disabled ? '🔒 ' : ''}${opt.name}</span>
${opt.disabled && html`<span class="profile-lock-badge">🔒</span>`}
</button>
`)}
</div>
</div>
</div> </div>
<div class=${`utci-day-tabs-wrap${canScrollLeft ? ' fade-left' : ''}${canScrollRight ? ' fade-right' : ''}`}> <div class=${`utci-day-tabs-wrap${canScrollLeft ? ' fade-left' : ''}${canScrollRight ? ' fade-right' : ''}`}>
@@ -295,14 +204,29 @@ export function DayTabs({
const isActive = i === selectedDay; const isActive = i === selectedDay;
const dDate = new Date(d.key + 'T00:00Z'); const dDate = new Date(d.key + 'T00:00Z');
const dayName = i === 0 ? 'Today' const dayName = i === 0 ? 'Today'
: i === 1 ? 'Tomorrow' : i === 1 ? 'Tom'
: dDate.toLocaleDateString('en-GB', { weekday: 'short', timeZone: 'UTC' }); : dDate.toLocaleDateString('en-GB', { weekday: 'short', timeZone: 'UTC' });
const utcipVals = d.rows.map(r => r.utciAdj).filter(v => isFinite(v)); const mainVals = d.rows.map(r => r[mainField]).filter(v => isFinite(v));
const dayHi = utcipVals.length ? Math.round(Math.max(...utcipVals)) : null; const dayHi = mainVals.length ? Math.round(Math.max(...mainVals)) : null;
const dayLo = utcipVals.length ? Math.round(Math.min(...utcipVals)) : null; const dayLo = mainVals.length ? Math.round(Math.min(...mainVals)) : null;
const shadeVals = d.rows.map(r => r.shadeT).filter(v => isFinite(v));
const shadeHi = shadeVals.length ? Math.round(Math.max(...shadeVals)) : null; // Vehicle/Driver: no table column exists for the ventilated cabin
const shadeLo = shadeVals.length ? Math.round(Math.min(...shadeVals)) : null; // temp (ventilation is just an input toggle on the main vehicleT
// calc), so run the physics model again with vent forced on, to
// show "if you opened the windows" as a reference hi/lo.
let shadeHi = null, shadeLo = null;
if (isVehicleProfile) {
const speedMph = (VEHICLE_SPEEDS[vehicleSpeed] ?? VEHICLE_SPEEDS.static).mph;
const ventVals = d.rows
.map(r => calcVehicleInteriorTemp(r.Ta, r.glob, r.elev, vehicleType, true, speedMph))
.filter(v => isFinite(v));
shadeHi = ventVals.length ? Math.round(Math.max(...ventVals)) : null;
shadeLo = ventVals.length ? Math.round(Math.min(...ventVals)) : null;
} else if (secondaryField) {
const secondaryVals = d.rows.map(r => r[secondaryField]).filter(v => isFinite(v));
shadeHi = secondaryVals.length ? Math.round(Math.max(...secondaryVals)) : null;
shadeLo = secondaryVals.length ? Math.round(Math.min(...secondaryVals)) : null;
}
// Day-tab weather icon - use core daylight rows (elev > 10°) where // Day-tab weather icon - use core daylight rows (elev > 10°) where
// available, falling back to any above-horizon rows, then all rows. // available, falling back to any above-horizon rows, then all rows.
@@ -320,6 +244,30 @@ export function DayTabs({
const repDt = noonRow ? noonRow.dt : dDate; const repDt = noonRow ? noonRow.dt : dDate;
const showPrecip = dayPrecip >= 0.3 || daySnow >= 0.1; const showPrecip = dayPrecip >= 0.3 || daySnow >= 0.1;
// Vehicle tab: swap the default car glyph for a hazard icon when
// conditions could affect driving. Uses the full day (not just
// daylight repRows) since fog and frost often hit overnight or on
// an early commute, then picks the worst hazard by priority.
let vehicleHazard = null;
let hazPrecip = 0, hazSnow = 0;
if (isVehicleProfile) {
const allSnow = d.rows.reduce((s, r) => s + (r.snow || 0), 0);
const allPrecip = d.rows.reduce((s, r) => s + (r.precip || 0), 0);
const visVals = d.rows.map(r => r.visKm).filter(v => isFinite(v));
const minVis = visVals.length ? Math.min(...visVals) : null;
const taVals = d.rows.map(r => r.Ta).filter(v => isFinite(v));
const minTa = taVals.length ? Math.min(...taVals) : null;
if (allSnow >= 0.1) {
vehicleHazard = 'snow'; hazSnow = allSnow;
} else if (minTa !== null && minTa <= 0 && allPrecip < 0.3) {
vehicleHazard = 'ice';
} else if (minVis !== null && minVis < 2) {
vehicleHazard = 'fog';
} else if (allPrecip >= 0.3) {
vehicleHazard = 'rain'; hazPrecip = allPrecip;
}
}
// Day-tab colour — PRIORITY model, no hue blending. // Day-tab colour — PRIORITY model, no hue blending.
// Mixing a warm yellow with grey/blue passes through green, so rather // Mixing a warm yellow with grey/blue passes through green, so rather
// than blend temperature with sky we pick ONE dimension by priority and // than blend temperature with sky we pick ONE dimension by priority and
@@ -336,8 +284,15 @@ export function DayTabs({
const isHot = dayHi !== null && dayHi >= 29; // Hot band and above const isHot = dayHi !== null && dayHi >= 29; // Hot band and above
const isDanger = tBand.solid === true; const isDanger = tBand.solid === true;
// Home/indoor/vehicle tabs show a modelled temperature, not the
// outdoor sky, so the tab shade should track that temperature only
// — no rain/snow/cloud tinting, and no weather icon.
const tempOnly = isHomeOrOffice || isVehicleProfile;
let rgb; let rgb;
if (isHot) { if (tempOnly) {
rgb = hex2rgb(tBand.bg);
} 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 = dayHi >= 39 ? 0.15 : dayHi >= 34 ? 0.30 : 0.45; const heatFactor = dayHi >= 39 ? 0.15 : dayHi >= 34 ? 0.30 : 0.45;
@@ -409,7 +364,15 @@ export function DayTabs({
${dDate.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', timeZone: 'UTC' })} ${dDate.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', timeZone: 'UTC' })}
</span> </span>
<span style=${{ display: 'block', margin: '6px auto 0px', lineHeight: 1 }}> <span style=${{ display: 'block', margin: '6px auto 0px', lineHeight: 1 }}>
${showPrecip ${tempOnly
? (isVehicleProfile
? (vehicleHazard === 'snow' ? html`<${PrecipIcon} precip=${0} snow=${hazSnow} size=${30} />`
: vehicleHazard === 'rain' ? html`<${PrecipIcon} precip=${hazPrecip} snow=${0} size=${30} />`
: vehicleHazard === 'fog' ? html`<${FogIcon} size=${30} />`
: vehicleHazard === 'ice' ? html`<${IceIcon} size=${30} />`
: html`<${CarIcon} size=${30} />`)
: html`<${HouseIcon} size=${30} />`)
: showPrecip
? html`<${PrecipIcon} precip=${dayPrecip} snow=${daySnow} size=${30} />` ? html`<${PrecipIcon} precip=${dayPrecip} snow=${daySnow} size=${30} />`
: html`<${CloudIcon} category=${modalCloudCat} elev=${repElev} dt=${repDt} size=${30} />`} : html`<${CloudIcon} category=${modalCloudCat} elev=${repElev} dt=${repDt} size=${30} />`}
</span> </span>
+17
View File
@@ -178,6 +178,23 @@ export const VARIANT_DEFAULT_ENV = {
// Left-to-right order of the profile buttons in the top filter bar. // Left-to-right order of the profile buttons in the top filter bar.
export const profileButtonOrder = ['basic', 'home', 'vehicle', 'pets', 'alltemps', 'showall', 'custom']; export const profileButtonOrder = ['basic', 'home', 'vehicle', 'pets', 'alltemps', 'showall', 'custom'];
// Derives which config selector / table metric is the "main" thermal
// selection for the active profile — the one whose calc drives the day-tab
// hi/lo readout. Shared by DayTabs (day-tab numbers + summary chip),
// ConfigPanel (the gold "main" badge) and app.js (the edge-tab label).
export function deriveProfileMain(activeProfile, outdoorsVariant) {
const isHomeOrOffice = activeProfile === 'home' || (activeProfile === 'outdoors' && outdoorsVariant === 'office');
const isVehicleProfile = activeProfile === 'vehicle' || (activeProfile === 'outdoors' && outdoorsVariant === 'driver');
const isPetsProfile = activeProfile === 'pets';
const mainConfigKey = isHomeOrOffice ? 'indoor' : isVehicleProfile ? 'vehicle' : isPetsProfile ? 'fur' : 'solar';
const mainField = isHomeOrOffice ? 'indoorT' : isVehicleProfile ? 'vehicleT' : isPetsProfile ? 'furSurfaceT' : 'utciAdj';
const secondaryField = isHomeOrOffice ? 'managedT' : isPetsProfile ? 'shadeT' : isVehicleProfile ? null : 'shadeT';
const mainLabel = activeProfile === 'outdoors'
? (OUTDOORS_VARIANTS[outdoorsVariant]?.name || FILTER_PROFILES.outdoors.label)
: (FILTER_PROFILES[activeProfile]?.label || 'SunSoak');
return { isHomeOrOffice, isVehicleProfile, isPetsProfile, mainConfigKey, mainField, secondaryField, mainLabel };
}
// Emoji glyph per place/activity variant. // Emoji glyph per place/activity variant.
export const variantIcons = { export const variantIcons = {
urban: '🏙️', urban: '🏙️',
+8 -1
View File
@@ -37,6 +37,11 @@ import {
checkFrost, checkFrost,
} from './events/weather-checks.js'; } from './events/weather-checks.js';
import { dynamicCosmicMessage } from './events/dynamic-message.js'; import { dynamicCosmicMessage } from './events/dynamic-message.js';
import { activityChance } from './events/activity-profile.js';
// Cosmic events only surface as a banner once estimated viewing conditions
// are at least this good (see ./events/activity-profile.js for the model).
const ACTIVITY_THRESHOLD = 80;
// Re-export so callers that imported these from events.js keep working. // Re-export so callers that imported these from events.js keep working.
export { getUpcomingEvents } from './events/almanac-calendar.js'; export { getUpcomingEvents } from './events/almanac-calendar.js';
@@ -103,7 +108,9 @@ export function getActiveEvents(rows, location) {
: new Date().toISOString().slice(0, 10); : new Date().toISOString().slice(0, 10);
const cosmicHits = COSMIC_CALENDAR const cosmicHits = COSMIC_CALENDAR
.filter(ev => dateStr >= ev.start && dateStr <= ev.end) .filter(ev => dateStr >= ev.start && dateStr <= ev.end)
.map(ev => Object.assign({}, ev, { message: dynamicCosmicMessage(ev, dateStr) })); .map(ev => Object.assign({}, ev, { chance: activityChance(ev, dateStr) }))
.filter(ev => ev.chance >= ACTIVITY_THRESHOLD)
.map(ev => Object.assign(ev, { message: dynamicCosmicMessage(ev, dateStr) }));
// 3. Weather-derived events (all that match, not just first) // 3. Weather-derived events (all that match, not just first)
const weatherEvents = []; const weatherEvents = [];
+52
View File
@@ -0,0 +1,52 @@
// ------------------------------------------------------------------------
// activity-profile.js - Estimates the % chance of a good viewing on a given
// day for a multi-day cosmic event (meteor shower, conjunction), based on
// how far that day sits from the event's peak.
//
// The calendar data only has start/end/peak dates, not real activity
// curves, so this is a simplified heuristic (not live forecast data):
// chance falls off exponentially from 100% at peak, halving every
// `riseTau`/`decayTau` days on the approach/departure side. Values below
// are rough approximations of each shower's real-world activity profile
// (sharp showers like the Quadrantids get small tau, broad ones like the
// Eta Aquariids get larger tau) - good enough to gate "is this worth
// looking up for" without pretending to be precise astronomy.
//
// Single-day events (start === end) are always 100% - they only ever
// appear in COSMIC_CALENDAR on their one active day anyway.
// ------------------------------------------------------------------------
const ACTIVITY_PROFILES = {
quadrantids: { riseTau: 0.3, decayTau: 0.4 },
lyrids: { riseTau: 0.6, decayTau: 0.7 },
'eta-aquariids': { riseTau: 3.5, decayTau: 3.0 },
perseids: { riseTau: 2.2, decayTau: 1.8 },
orionids: { riseTau: 1.5, decayTau: 1.5 },
leonids: { riseTau: 0.5, decayTau: 0.6 },
geminids: { riseTau: 1.0, decayTau: 1.0 },
ursids: { riseTau: 0.4, decayTau: 0.4 },
'mars-conjunction': { riseTau: 1.0, decayTau: 1.0 },
'venus-jupiter-conjunction': { riseTau: 1.0, decayTau: 1.0 },
};
const DEFAULT_PROFILE = { riseTau: 1.0, decayTau: 1.0 };
function baseKey(id) {
return String(id).replace(/-\d{4}$/, '');
}
export function getActivityProfile(id) {
return ACTIVITY_PROFILES[baseKey(id)] || DEFAULT_PROFILE;
}
// Returns an integer 0-100.
export function activityChance(ev, dateStr) {
if (!ev.peak || ev.start === ev.end) return 100;
const today = new Date(dateStr + 'T00:00Z');
const peak = new Date(ev.peak + 'T00:00Z');
const diffDays = (today - peak) / 86400000;
const { riseTau, decayTau } = getActivityProfile(ev.id);
const tau = diffDays <= 0 ? riseTau : decayTau;
const pct = 100 * Math.pow(0.5, Math.abs(diffDays) / tau);
return Math.round(pct);
}
+1 -1
View File
@@ -153,7 +153,7 @@ export const cosmic = [
textColor: '#ffc8d8', textColor: '#ffc8d8',
type: 'cosmic', type: 'cosmic',
nightOnly: true, nightOnly: true,
start: '2026-06-30', end: '2026-07-02', start: '2026-06-30', end: '2026-07-02', peak: '2026-07-01',
}, },
]; ];
+9 -6
View File
@@ -2,11 +2,13 @@
// dynamic-message.js - Rewrites a cosmic event's message based on where // dynamic-message.js - Rewrites a cosmic event's message based on where
// today sits vs. the peak. // today sits vs. the peak.
// //
// Before peak : "is active and building - peak on <date>. <detail>" // Before peak : "is active and building - peak on <date>. <detail> (~NN% chance...)"
// On peak -1d : "peaks tonight - <detail>" // On peak -1d : "peaks tonight - <detail> (~NN% chance...)"
// After peak : "is past its peak (<date>) but still possibly visible - <detail>" // After peak : "is past its peak (<date>) but still possibly visible - <detail> (~NN% chance...)"
// //
// Single-day events (start === end) keep their static message unchanged. // Single-day events (start === end) keep their static message unchanged.
// `ev.chance` (see ../events/activity-profile.js), when present, is
// appended as an estimated viewing-chance figure.
// ------------------------------------------------------------------------ // ------------------------------------------------------------------------
export function dynamicCosmicMessage(ev, dateStr) { export function dynamicCosmicMessage(ev, dateStr) {
@@ -19,11 +21,12 @@ export function dynamicCosmicMessage(ev, dateStr) {
.replace(/^.*?(?:peaks tonight|peak tonight|is active tonight|is active|peaks)\s*—\s*/i, '') .replace(/^.*?(?:peaks tonight|peak tonight|is active tonight|is active|peaks)\s*—\s*/i, '')
.trim(); .trim();
var baseCapd = base.charAt(0).toUpperCase() + base.slice(1); var baseCapd = base.charAt(0).toUpperCase() + base.slice(1);
var chanceSuffix = typeof ev.chance === 'number' ? ' (~' + ev.chance + '% chance of a good show tonight)' : '';
if (diffDays < -1) { if (diffDays < -1) {
return ev.title + ' is active and building — peak on ' + peakFmt + '. ' + baseCapd; return ev.title + ' is active and building — peak on ' + peakFmt + '. ' + baseCapd + chanceSuffix;
} else if (diffDays <= 1) { } else if (diffDays <= 1) {
return ev.title + ' peaks tonight — ' + base; return ev.title + ' peaks tonight — ' + base + chanceSuffix;
} else { } else {
return ev.title + ' is past its peak (' + peakFmt + ') but still possibly visible — ' + base; return ev.title + ' is past its peak (' + peakFmt + ') but still possibly visible — ' + base + chanceSuffix;
} }
} }
+6
View File
@@ -367,6 +367,11 @@ export function useAppState() {
const openRestore = () => setRestoreOpen(true); const openRestore = () => setRestoreOpen(true);
const closeRestore = () => setRestoreOpen(false); const closeRestore = () => setRestoreOpen(false);
// Profile & config flyout panel (right-side drawer).
const [panelOpen, setPanelOpen] = useState(false);
const openPanel = () => setPanelOpen(true);
const closePanel = () => setPanelOpen(false);
const [showUnits, setShowUnits] = useState(() => { const [showUnits, setShowUnits] = useState(() => {
try { try {
const s = localStorage.getItem('sunscope_show_units'); const s = localStorage.getItem('sunscope_show_units');
@@ -660,6 +665,7 @@ export function useAppState() {
showDecimals, toggleShowDecimals, showDecimals, toggleShowDecimals,
welcomeOpen, closeWelcome, openWelcome, welcomeOpen, closeWelcome, openWelcome,
restoreOpen, openRestore, closeRestore, restoreOpen, openRestore, closeRestore,
panelOpen, openPanel, closePanel,
showUnits, toggleShowUnits, showUnits, toggleShowUnits,
tableInterval, setTableInterval, tableInterval, setTableInterval,
forecastView, setForecastView, forecastView, setForecastView,
+47 -14
View File
@@ -15,7 +15,7 @@
// VEHICLE_TYPES vehicle presets for cabin heat model // VEHICLE_TYPES vehicle presets for cabin heat model
// BUILDING_TYPES building presets for indoor heat model // BUILDING_TYPES building presets for indoor heat model
// FUR_COLORS fur presets for the fur surface temp model // FUR_COLORS fur presets for the fur surface temp model
// pawBurnRiskLabel(concreteT) - 'Safe'|'Caution'|'Danger' paw contact risk // PET_BANDS / petCategory(t) pet skin/surface stress band - {label,bg,fg}
// cloudCategory(total,low,mid,high) - 'clear'|'wispy'|'scattered'|'overcast' // cloudCategory(total,low,mid,high) - 'clear'|'wispy'|'scattered'|'overcast'
// confidenceBand(i) day-tab gradient colour + label // confidenceBand(i) day-tab gradient colour + label
// moonPhaseFraction(date) 0..1 synodic phase fraction // moonPhaseFraction(date) 0..1 synodic phase fraction
@@ -56,6 +56,52 @@ export function utciCategory(u) {
} }
} }
// -------------------------------------------------------------------
// PET_BANDS - same shape as UTCI_BANDS, recalibrated for pet skin/fur
// instead of bare human skin.
// -------------------------------------------------------------------
// Covers the pet-specific columns (Fur Colour, Pet Shade, Pet Home, Paw)
// in the table and simple view. Fur, a thicker dermis, and natural skin
// oils give real extra insulation at both ends of the scale, so every
// threshold is shifted from UTCI_BANDS, not just the warm half:
// - Warm half shifted up - calibrated against the pavement-burn guidance
// this app already used ("7-second hand test": safe below 40 -C,
// caution to 52 -C, danger above) and the furSurfaceT glance-card
// alert (>=45 -C, which now falls inside "Extreme" rather than
// jumping straight to "Danger").
// - Cold half shifted down ~8 -C - a healthy cat or dog's coat copes
// with a frosty night (e.g. -2 -C) that would be "Freezing" on the
// bare-skin human scale; here that same reading lands in "Cold".
//
// Colours are re-interpolated (not copy-pasted from UTCI_BANDS) against
// the same purple->blue->cyan->green->yellow->orange->red family, sampled
// at each band's new threshold so the shade actually reflects its shifted
// position on the pet scale, rather than reusing a human band's colour at
// a different absolute temperature. Danger keeps the human scale's flat
// alarm red unchanged - it's a deliberate stop-everything colour, not
// part of the smooth gradient.
// -------------------------------------------------------------------
export const PET_BANDS = [
{ max: -28, label: 'Extreme cold', bg: '#7f61ab', fg: '#ffffff' },
{ max: -18, label: 'Arctic', bg: '#957abe', fg: '#ffffff' },
{ max: -8, label: 'Freezing', bg: '#84a0d4', fg: '#ffffff' },
{ max: -3, label: 'Very cold', bg: '#7eb6e2', fg: '#1a1200' },
{ max: 2, label: 'Cold', bg: '#7ec0e8', fg: '#1a1200' },
{ max: 7, label: 'Chilly', bg: '#a8d4ee', fg: '#1a1200' },
{ max: 11, label: 'Cool', bg: '#b5dee9', fg: '#1a1200' },
{ max: 25, label: 'Comfortable', bg: '#a5daa3', fg: '#157a15', themedFg: '#157a15', fontWeight: 700 },
{ max: 32, label: 'Warm', bg: '#f7cd54', fg: '#1a1200' },
{ max: 40, label: 'Caution', bg: '#f5974e', fg: '#1a1200' },
{ max: 52, label: 'Extreme', bg: '#df6f41', fg: '#ffffff' },
{ label: 'Danger', bg: '#880000', fg: '#ffffff', fontWeight: 700, darkenAmt: 0.18, textShadow: '-1px -1px 0 #3a0000, 1px -1px 0 #3a0000, -1px 1px 0 #3a0000, 1px 1px 0 #3a0000', solid: true },
];
export function petCategory(t) {
for (const band of PET_BANDS) {
if (band.max === undefined || t < band.max) return band;
}
}
// Diagonal sweep gradient for a band background hex colour. // Diagonal sweep gradient for a band background hex colour.
// Pre-blends with 50% white to sit harmoniously alongside lighter table cells, // Pre-blends with 50% white to sit harmoniously alongside lighter table cells,
// then applies a light→mid→dark sweep for depth. // then applies a light→mid→dark sweep for depth.
@@ -247,19 +293,6 @@ export const FUR_COLORS = {
white: { name: 'White / Pale', albedo: 0.40 }, white: { name: 'White / Pale', albedo: 0.40 },
}; };
// -------------------------------------------------------------------
// PAW BURN RISK - traffic-light label from ground/pavement surface temp.
// -------------------------------------------------------------------
// Uses concreteT directly - the "7-second hand test" pavement-burn
// guidance already documented on calcConcreteTemp in physics.js.
// -------------------------------------------------------------------
export function pawBurnRiskLabel(concreteT) {
if (concreteT == null) return null;
if (concreteT < 40) return 'Safe';
if (concreteT < 52) return 'Caution';
return 'Danger';
}
// ------------------------------------------------------------------- // -------------------------------------------------------------------
// CLOUD CATEGORY - pick one of 4 icon styles from low/mid/high split. // CLOUD CATEGORY - pick one of 4 icon styles from low/mid/high split.
// ------------------------------------------------------------------- // -------------------------------------------------------------------