diff --git a/assets/css/table.css b/assets/css/table.css
index 21f6b70..9baef47 100644
--- a/assets/css/table.css
+++ b/assets/css/table.css
@@ -85,7 +85,8 @@
}
.utci-day-tab {
- flex: 1 0 auto;
+ flex: 1 0 0;
+ min-width: 90px;
padding: 10px 20px;
cursor: pointer;
font-family: Manrope, sans-serif;
@@ -96,6 +97,7 @@
background: transparent;
border: 1px solid #c9b08a;
color: #b09870;
+ overflow-wrap: break-word;
transition: color 0.2s, border-color 0.2s, background-color 0.2s, filter 0.15s;
}
@@ -165,6 +167,69 @@
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 */
.profile-scroll {
display: flex;
@@ -241,6 +306,223 @@
.profile-scroll-wrap.fade-left .profile-scroll-chevron.left { 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 */
.profile-divider {
display: inline-block;
@@ -394,8 +676,22 @@
background: #fdf8ee;
border: 1.5px solid #d4c0a0;
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 {
display: flex;
align-items: center;
@@ -406,18 +702,37 @@
}
.col-toggles-label {
- font-family: Manrope, sans-serif;
- font-size: 12px;
- font-weight: 700;
- text-transform: uppercase;
- letter-spacing: 0.08em;
- color: #7a5c2a;
- margin-right: 4px;
- white-space: nowrap;
+ display: none;
}
.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 {
@@ -620,6 +935,15 @@
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
within the viewport before the tab strip needs to scroll. */
.utci-day-tab {
@@ -1128,53 +1452,14 @@
/* ── Column toggles bar ─────────────────────────────────────────────
Same approach — label full row, toggles at ~31% each. */
- .col-toggles {
+ .col-toggles,
+ .col-toggles-config {
flex-wrap: wrap;
gap: 6px;
padding: 10px 10px;
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 {
flex: 0 0 calc((100% - 12px) / 3);
@@ -1204,6 +1489,63 @@
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 */
.col-toggle-group {
flex: 0 0 calc((100% - 12px) / 3);
@@ -1234,6 +1576,37 @@
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 ────────
@@ -1394,12 +1767,106 @@
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 {
display: none;
align-items: center;
gap: 2px;
- margin-right: 6px;
+ margin-left: auto;
flex-shrink: 0;
}
.table-main--simple .fvt-interval {
@@ -1571,14 +2038,6 @@
min-height: 60px;
}
-.fvt-model-sep {
- width: 1px;
- height: 18px;
- background: #d4c0a0;
- margin: 0 4px;
- flex-shrink: 0;
-}
-
.fsc-table-btn-wrap {
padding: 10px 16px 12px;
border-top: 1px solid #d4c0a0;
diff --git a/assets/css/ui.css b/assets/css/ui.css
index d2c79b2..bb5bc13 100644
--- a/assets/css/ui.css
+++ b/assets/css/ui.css
@@ -634,12 +634,14 @@
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 {
position: absolute;
top: calc(100% + 4px);
- left: 0; /* left edge flushes with button's left edge by default */
- right: auto;
+ left: 0;
z-index: 1000;
background: #fffcf2;
border: 1.5px solid #c9b08a;
@@ -647,13 +649,47 @@
box-shadow: 0 4px 16px rgba(30,18,8,0.13);
min-width: 100%;
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 */
-.cs-panel.cs-panel--right {
- left: auto;
- right: 0;
+/* Two-column layout for longer option lists (>5 items) — halves the height
+ needed so lists fit in the available space without scrolling. */
+.cs-panel.cs-panel--cols {
+ 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 */
@@ -665,6 +701,7 @@
min-width: 0;
width: max-content;
max-width: min(320px, 90vw);
+ max-height: none;
white-space: normal;
overflow: visible;
}
@@ -1233,6 +1270,12 @@
font-weight: 600;
}
+.utci-legend-label--secondary {
+ margin-top: 16px;
+ padding-top: 16px;
+ border-top: 1px solid #d4c0a0;
+}
+
.utci-legend-row {
display: flex;
flex-wrap: wrap;
diff --git a/assets/js/app.js b/assets/js/app.js
index fa5c197..490b670 100644
--- a/assets/js/app.js
+++ b/assets/js/app.js
@@ -24,17 +24,19 @@ import { h, render, Fragment } from '../vendor/preact.js';
import { useState, useRef, useEffect } from '../vendor/preact-hooks.js';
import htm from '../vendor/htm.js';
import {
- utciCategory, UTCI_BANDS, bandGradient,
+ utciCategory, UTCI_BANDS,
+ petCategory,
SKIN_TYPES, sunburnMinutes, burnLabel,
VEHICLE_TYPES, VEHICLE_SPEEDS, BUILDING_TYPES,
- FUR_COLORS, pawBurnRiskLabel,
+ FUR_COLORS,
confidenceBand, moonGlyph, skyFillForElev,
} from './utils.js';
import { SkyScope, WindVane, CloudIcon, ScopeReticle, PrecipIcon, CustomSelect, VentPill } from './components.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 { DayTabs } from './components/DayTabs.js';
+import { ConfigPanel } from './components/ConfigPanel.js';
import { WelcomeModal } from './components/WelcomeModal.js';
import { RestoreModal } from './components/RestoreModal.js';
import { computeWhyFeelsLike, computeGlanceSummary } from './compute.js';
@@ -189,6 +191,7 @@ export function UTCIForecast() {
showDecimals, toggleShowDecimals,
welcomeOpen, closeWelcome, openWelcome,
restoreOpen, openRestore, closeRestore,
+ panelOpen, openPanel, closePanel,
showUnits, toggleShowUnits,
tableInterval, setTableInterval,
forecastView, setForecastView,
@@ -282,14 +285,14 @@ export function UTCIForecast() {
const [simpleTemp, setSimpleTemp] = useState('utciAdj');
useEffect(() => {
- if (activeProfile === 'vehicle') setSimpleTemp('vehicleT');
- else if (activeProfile === 'home') setSimpleTemp('indoorT');
+ if (activeProfile === 'vehicle' || (activeProfile === 'outdoors' && outdoorsVariant === 'driver')) setSimpleTemp('vehicleT');
+ else if (activeProfile === 'home' || (activeProfile === 'outdoors' && outdoorsVariant === 'office')) setSimpleTemp(indoorManaged ? 'managedT' : 'indoorT');
else if (activeProfile === 'pets') setSimpleTemp('furSurfaceT');
else setSimpleTemp('utciAdj');
if (['alltemps', 'showall', 'custom', 'farming', 'construction', 'market', 'windowcleaning', 'office'].includes(activeProfile)) {
setForecastView('table');
}
- }, [activeProfile]);
+ }, [activeProfile, outdoorsVariant]);
// Focus the input the moment the search opens.
useEffect(() => {
@@ -421,6 +424,46 @@ export function UTCIForecast() {
const airTempRgbStrong = (t) => airTempRgb(t, 0.42);
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 ──────────────────────────────────────────
// whyFeelsLike is derived below, after the playback rows are resolved, so
// the panel can track the simulated instant during play/scrub (see panelRow).
@@ -883,6 +926,54 @@ export function UTCIForecast() {
${welcomeOpen && html`<${WelcomeModal} onClose=${closeWelcome} />`}
${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`
+ `;
+ })()}
+
+ <${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}
days=${days}
selectedDay=${selectedDay} setSelectedDay=${setSelectedDay}
@@ -890,16 +981,10 @@ export function UTCIForecast() {
openRestore=${openRestore}
proPromptDay=${proPromptDay} setProPromptDay=${setProPromptDay}
proPromptSource=${proPromptSource} setProPromptSource=${setProPromptSource}
- activeProfile=${activeProfile} activateProfile=${activateProfile} activeCols=${activeCols}
- visibleCols=${visibleCols}
- activityOptions=${activityOptions} placeOptions=${placeOptions} workOptions=${workOptions}
- activityValue=${activityValue} activityLabel=${activityLabel}
- placeValue=${placeValue} placeLabel=${placeLabel}
- workValue=${workValue} workLabel=${workLabel}
- outdoorsVariant=${outdoorsVariant} setOutdoorsVariantAndSave=${setOutdoorsVariantAndSave}
- setActiveProfile=${setActiveProfile} setVisibleCols=${setVisibleCols}
- setIndoorMode=${setIndoorMode} setIndoorManaged=${setIndoorManaged}
- setBuildingType=${setBuildingType}
+ activeProfile=${activeProfile} outdoorsVariant=${outdoorsVariant}
+ openPanel=${openPanel}
+ vehicleType=${vehicleType} vehicleSpeed=${vehicleSpeed}
+ buildingType=${buildingType} indoorManaged=${indoorManaged} utciEnv=${utciEnv} furColor=${furColor}
dayTabsRef=${dayTabsRef} canScrollLeft=${canScrollLeft} canScrollRight=${canScrollRight}
scrollDayTabs=${scrollDayTabs}
/>`}
@@ -907,7 +992,8 @@ export function UTCIForecast() {
-
+
+
Hours:
${[1, 2, 3, 4].map(n => html``)}
- ${forecastView === 'simple' && (() => {
- const showFur = visibleCols.furSurfaceT;
- const showSolar = visibleCols.utciP;
- const showVehicle = visibleCols.vehicleT;
- const showIndoor = visibleCols.indoorT || visibleCols.managedT;
- if (!showFur && !showSolar && !showVehicle && !showIndoor) return null;
- const furOn = simpleTemp === 'furSurfaceT';
- const solarOn = simpleTemp === 'utciAdj';
- const vehicleOn = simpleTemp === 'vehicleT';
- const indoorOn = simpleTemp === 'indoorT' || simpleTemp === 'managedT';
- return html`
-
- ${showFur && html`
-
- <${CustomSelect}
- value=${furColor}
- isOn=${furOn}
- 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');
- }
- }}
- />
- `}
- ${showSolar && html`
-
- <${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'); }
- }}
- />
- `}
- ${showVehicle && html`
-
- <${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." />`}
- `}
- ${showIndoor && html`
-
- <${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" />`}
- `}
- `;
- })()}
-
Columns:
-
-
- ${(isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['utciP']) : activeCols['utciP']) && html`
-
- <${CustomSelect}
- value=${utciEnv}
- isOn=${visibleCols.utciP}
- 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 }));
- }
- }}
- />
- `}
- ${(isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['vehicleT']) : activeCols['vehicleT']) && html`
-
- <${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."
- />`}
- `}
- ${(isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['indoorT'] || activeCols['managedT']) : (activeCols['indoorT'] || activeCols['managedT'])) && html`
-
- <${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"
- />`}
- `}
- ${(isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['furSurfaceT']) : activeCols['furSurfaceT']) && html`
-
- <${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 }));
- }
- }}
- />
- `}
+
+ ${(isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['utciP']) : activeCols['utciP']) && html`
`}
+ ${(isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['vehicleT']) : activeCols['vehicleT']) && html`
`}
+ ${(isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['indoorT'] || activeCols['managedT']) : (activeCols['indoorT'] || activeCols['managedT'])) && html`
`}
+ ${(isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['furSurfaceT']) : activeCols['furSurfaceT']) && html`
`}
${(isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['pawT']) : activeCols['pawT']) && html`
`}
${(isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['petShadeT']) : activeCols['petShadeT']) && html`
`}
${(isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['petHomeT']) : activeCols['petHomeT']) && html`
`}
- ${(isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['burn']) : activeCols['burn']) && html`
- <${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['burn']) : activeCols['burn']) && html`
`}
${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['utci']) && html`
`}
${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['delta']) && html`
`}
${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['tmrt']) && html`
`}
@@ -1182,25 +1069,7 @@ export function UTCIForecast() {
${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['wind']) && html`
`}
${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['dir']) && html`
`}
${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['aqi']) && html`
`}
- ${(isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['pollen']) : activeCols['pollen']) && html`<${CustomSelect}
- 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['pollen']) : activeCols['pollen']) && html`
`}
${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['uvA']) && html`
`}
${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['uvB']) && html`
`}
${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['sun']) && html`
`}
@@ -1226,7 +1095,7 @@ export function UTCIForecast() {
${tableRows.map(r => {
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 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;
@@ -1234,7 +1103,7 @@ export function UTCIForecast() {
if (r.precipProb > 20 && (r.precip > 0 || r.snow > 0))
return html`<${PrecipIcon} precip=${r.precip} snow=${r.snow} size=${42} filled=${true} />`;
if (r.cloudCat && r.cloudCat !== 'clear')
- return html`<${CloudIcon} category=${r.cloudCat} elev=${r.elev} dt=${r.dt} size=${42} filled=${true} />`;
+ return html`<${CloudIcon} category=${r.cloudCat} elev=${r.elev} dt=${r.dt} size=${42} filled=${true} showSun=${false} />`;
return null;
})();
const windMph = Math.round((r.gust ?? r.va) * 2.237);
@@ -1462,6 +1331,16 @@ export function UTCIForecast() {
...(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) => {
if (v == null || isNaN(v)) return 'transparent';
if (v <= min) return 'transparent';
@@ -1546,7 +1425,7 @@ export function UTCIForecast() {
${fmt(r.managedT)}${u('°C')}
`}
${visibleCols.petHomeT && html`
-
+ |
${fmt(r.indoorT)}${u('°C')}
| `}
${visibleCols.burn && html`
@@ -1570,15 +1449,13 @@ export function UTCIForecast() {
${fmt(r.concreteT)}${u('°C')}
`}
${visibleCols.furSurfaceT && html`
-
+ |
${fmt(r.furSurfaceT)}${u('°C')}
| `}
- ${visibleCols.pawT && (() => {
- const risk = pawBurnRiskLabel(r.concreteT);
- return html`
+ ${visibleCols.pawT && html`
+ |
${fmt(r.concreteT)}${u('°C')}
- | `;
- })()}
+ `}
${visibleCols.soilT && html`
${r.soilT0 != null ? fmt(r.soilT0) + u('°C') : '—'} | `}
${visibleCols.soilT6 && html`
@@ -1587,7 +1464,7 @@ export function UTCIForecast() {
{ const sm = soilMoistureBg(r.soilM); return { color: sm.fg, background: sm.bg }; })()}>${r.soilM != null ? fmt(r.soilM * 100, 1) + u('%') : '—'} | `}
${visibleCols.shadeT && html`${r.shadeT != null ? fmt(r.shadeT) + u('°C') : '—'} | `}
${visibleCols.petShadeT && html`
-
+ |
${r.shadeT != null ? fmt(r.shadeT) + u('°C') : '—'}
| `}
${visibleCols.air && html`${fmt(r.Ta)}${u('°C')} | `}
@@ -1850,9 +1727,39 @@ export function UTCIForecast() {
`;
})}
+
+ ${(visibleCols.furSurfaceT || visibleCols.petHomeT || visibleCols.petShadeT || visibleCols.pawT) && html`
+
Pet thermal stress bands
+
+ ${[
+ { t: -15, label: 'Freezing', value: '< -8°C' },
+ { t: 0, label: 'Cold', value: '-8–2°C' },
+ { t: 9, label: 'Cool', value: '2–11°C' },
+ { t: 18, label: 'Comfortable', value: '11–25°C', bold: true },
+ { t: 28, label: 'Warm', value: '25–32°C' },
+ { t: 36, label: 'Caution', value: '32–40°C' },
+ { t: 46, label: 'Extreme', value: '40–52°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`
+ ${b.label}
+ ${b.value}
+ `;
+ })}
+
`}
-
+
${(() => {
const upcoming = getUpcomingEvents(location, 3650).slice(0, 4);
const formatPeak = (iso) => {
diff --git a/assets/js/components.js b/assets/js/components.js
index bee4193..acb436e 100644
--- a/assets/js/components.js
+++ b/assets/js/components.js
@@ -16,7 +16,7 @@
// ------------------------------------------------------------------------
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 {
skyGradientForElev, skyFillForElev, grassFillForElev,
@@ -99,9 +99,25 @@ const MOON_TEX = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADD
// groupedLeft - bool - fuse right side with a VentPill
// 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 }) {
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 panelRef = useRef(null);
@@ -122,13 +138,37 @@ export function CustomSelect({ value, options, onChange, hideLabel, hidingLabel,
};
}, [open]);
- // After the panel renders, check if it overflows the right viewport edge.
- // If so, flip it to right-align; otherwise keep it left-aligned.
- useEffect(() => {
- if (!open) { setFlipRight(false); return; }
- if (!panelRef.current) return;
- const rect = panelRef.current.getBoundingClientRect();
- setFlipRight(rect.right > window.innerWidth - 8);
+ // After the panel renders, centre it under/over the button, then clamp
+ // that position so it never spills past the real clipping ancestor's
+ // edges (e.g. the profile/config bottom sheet's scrolling body, which
+ // also clips horizontally whenever it clips vertically — see
+ // 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 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]);
// Build the label shown on the button
@@ -145,6 +185,11 @@ export function CustomSelect({ value, options, onChange, hideLabel, hidingLabel,
(groupedLeft && isLastChild) ? 'last-child' : '',
].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`
${open && html`
-
+
${isOn && !noHide && html`
- ${category === 'clear' && elev >= 0 && html`
+ ${category === 'clear' && elev >= 0 && showSun && html`
${sun(size * 0.50, size * 0.38, size * 0.16)}`}
${category === 'clear' && 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.24, size * 0.74, size * 0.78, size * 0.74, ink, sw * 0.78, 0.68)}`}
${category === 'wispy' && elev < 0 && html`
${mist(size * 0.18, brass)}
${line(size * 0.18, size * 0.56, size * 0.60, size * 0.56, muted, sw * 0.7, 0.6)}`}
${category === 'scattered' && elev >= 0 && html`
- ${sun(size * 0.72, size * 0.24, size * 0.11)}
-
- ${!filled && line(size * 0.12, size * 0.60, size * 0.38, size * 0.60, brass, sw * 0.72, 0.78)}`}
+ ${showSun && sun(size * 0.72, size * 0.24, size * 0.11)}
+
+
+ `}
${category === 'scattered' && elev < 0 && html`
@@ -1265,3 +1317,95 @@ export function PrecipIcon({ precip = 0, snow = 0, size = 28, filled = false })
`
}
+
+// 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`
+ `;
+}
+
+// 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`
+ `;
+}
+
+// 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`
+ `;
+}
+
+export function IceIcon({ size = 30 }) {
+ const icy = '#3f73c4';
+ return html`
+ `;
+}
diff --git a/assets/js/components/ConfigPanel.js b/assets/js/components/ConfigPanel.js
new file mode 100644
index 0000000..6e65d4e
--- /dev/null
+++ b/assets/js/components/ConfigPanel.js
@@ -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`${mainLabel} Config:`;
+
+ 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`
+ SunSoak
+ <${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`
+ Vehicle
+
+ <${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."
+ />
+ ` },
+ { key: 'indoor', show: showIndoor, node: html`
+ Indoors
+
+ <${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"
+ />
+ ` },
+ { key: 'fur', show: showFur, node: html`
+ Fur Colour
+ <${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`
+ Burn
+ <${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`
+ Pollen
+ <${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}>
+
+
+
+
+
+
+
+ ${[['common','Common'],['places','Places'],['activities','Activities'],['work','Work']].map(([key, label]) => html`
+
+ `)}
+
+
+
+
+ ${configItems.length > 0 && html`
+
+ ${configItems.map((item) => html`
+ <${Fragment} key=${item.key}>
+ ${item.key === mainConfigKey && mainBadge}
+
+ ${item.node}
+
+ ${Fragment}>`)}
+
`}
+
+
+
+ ${Fragment}>`;
+}
diff --git a/assets/js/components/DayTabs.js b/assets/js/components/DayTabs.js
index ae00668..209dbf5 100644
--- a/assets/js/components/DayTabs.js
+++ b/assets/js/components/DayTabs.js
@@ -1,57 +1,88 @@
// ------------------------------------------------------------------------
-// components/DayTabs.js - Day-tab strip, pro-prompt card, confidence
-// band bar, and filter-profile row for the UTCIForecast app.
+// components/DayTabs.js - Day-tab strip, pro-prompt card and confidence
+// band bar for the UTCIForecast app.
//
// Extracted from app.js to keep the main component under the AI edit
// safe-zone. All state lives in useAppState - this component is purely
// 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:
// days - array of day objects from buildHourlyRows
-// selectedDay - index of the active day tab
-// setSelectedDay - setter for selectedDay
+// selectedDay/setSelectedDay - active day tab index + setter
// isPro - boolean Pro status
// openRestore - opens the "Already subscribed?" restore modal
-// FREE_DAYS - number of free days
-// proPromptDay - index of locked day that was clicked
-// setProPromptDay - setter for proPromptDay
-// proPromptSource - string key for upsell copy
-// setProPromptSource - setter for proPromptSource
+// proPromptDay/setProPromptDay - locked-day upsell index + setter
+// proPromptSource/setProPromptSource - upsell copy key + setter
// 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
-// setOutdoorsVariantAndSave - setter that also persists to localStorage
-// setActiveProfile - raw setter for activeProfile
-// setVisibleCols - raw setter for visibleCols
-// setIndoorMode - setter for indoorMode
-// setIndoorManaged - setter for indoorManaged
+// openPanel - opens the profile/config nav dropdown
+// vehicleType/vehicleSpeed - current vehicle config (vehicle-cabin day-tab
+// reference calc + row label)
+// buildingType/indoorManaged/utciEnv/furColor - current config values,
+// for the "Viewing" row label
// dayTabsRef - ref for the scrollable tab strip element
-// canScrollLeft - boolean for left-fade chevron
-// canScrollRight - boolean for right-fade chevron
+// canScrollLeft/canScrollRight - booleans for the fade chevrons
// scrollDayTabs - function(dir) to scroll strip left/right
// ------------------------------------------------------------------------
import { h, Fragment } from '../../vendor/preact.js';
-import { useRef, useEffect, useState } from '../../vendor/preact-hooks.js';
import htm from '../../vendor/htm.js';
-import { confidenceBand, utciCategory } from '../utils.js';
-import { FREE_DAYS, FILTER_PROFILES, OUTDOORS_VARIANTS, profileButtonOrder, activityVariantKeys, placeVariantKeys, workVariantKeys } from '../config.js';
-import { CloudIcon, PrecipIcon, CustomSelect } from '../components.js';
+import { confidenceBand, utciCategory, VEHICLE_SPEEDS, VEHICLE_TYPES, BUILDING_TYPES, FUR_COLORS } from '../utils.js';
+import { FREE_DAYS, UTCI_ENVIRONMENTS, deriveProfileMain } from '../config.js';
+import { calcVehicleInteriorTemp } from '../physics.js';
+import { CloudIcon, PrecipIcon, HouseIcon, CarIcon, FogIcon, IceIcon } from '../components.js';
import { SubscribeModal } from './SubscribeModal.js';
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
// 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
@@ -91,190 +122,68 @@ export function DayTabs({
openRestore,
proPromptDay, setProPromptDay,
proPromptSource, setProPromptSource,
- activeProfile, activateProfile, activeCols,
- visibleCols,
- activityOptions, placeOptions, workOptions,
- activityValue, activityLabel,
- placeValue, placeLabel,
- workValue, workLabel,
- outdoorsVariant, setOutdoorsVariantAndSave,
- setActiveProfile, setVisibleCols,
- setIndoorMode, setIndoorManaged, setBuildingType,
+ activeProfile, outdoorsVariant,
+ openPanel,
+ vehicleType, vehicleSpeed, buildingType, indoorManaged, utciEnv, furColor,
dayTabsRef, canScrollLeft, canScrollRight, scrollDayTabs,
}) {
- const profileScrollRef = useRef(null);
- const profileWrapRef = useRef(null);
+ // Which metric drives the day-tab's hi/lo readout depends on the active
+ // 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.
- // 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]);
+ // Read-only label of the current main-config value, for the row above the
+ // day tabs — makes it obvious at a glance which temperature basis (SunSoak
+ // environment, vehicle type, building type, fur colour) the numbers below
+ // are computed from.
+ const mainConfigValue = mainConfigKey === 'vehicle'
+ ? (VEHICLE_TYPES[vehicleType]?.name || '')
+ : mainConfigKey === 'indoor'
+ ? ((BUILDING_TYPES[buildingType]?.name || '') + (indoorManaged ? ' · Managed' : ''))
+ : mainConfigKey === 'fur'
+ ? (FUR_COLORS[furColor]?.name || '')
+ : (UTCI_ENVIRONMENTS[utciEnv]?.label || '');
- useEffect(() => {
- const el = profileScrollRef.current;
- const wrap = profileWrapRef.current;
- if (!el) return;
- let isDown = false, startX = 0, startScroll = 0, hasDragged = false;
+ // Tints the row background to match the thermal model driving it, reusing
+ // the same colour groups the CustomSelect pickers use (e.g. SunSoak's
+ // "Solar model" dropdown is grp-felt) so the row reads as an extension of
+ // those controls rather than a separate, unrelated style.
+ const rowGrpClass = mainConfigKey === 'vehicle' ? 'grp-wind'
+ : mainConfigKey === 'indoor' ? 'grp-ambient'
+ : mainConfigKey === 'fur' ? 'grp-surface'
+ : 'grp-felt';
- 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);
- };
- }, []);
+ // Within that group, shade further by the exact option chosen — same idea
+ // as the "Solar model" pulldown, but keyed to whichever value is actually
+ // driving the numbers right now (environment / vehicle / building / fur).
+ const rowOptionKey = mainConfigKey === 'vehicle' ? vehicleType
+ : mainConfigKey === 'indoor' ? buildingType
+ : mainConfigKey === 'fur' ? furColor
+ : utciEnv;
+ const rowGradient = ROW_GRADIENTS[mainConfigKey]?.[rowOptionKey];
return html`
<${Fragment}>
-
-
- ${[['common','Common'],['places','Places'],['activities','Activities'],['work','Work']].map(([key, label]) => html`
-
- `)}
-
-
+
{ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openPanel(); } }}
+ style=${rowGradient ? { '--row-bg': rowGradient } : undefined}
+ title="Change profile & settings">
+
+ Profile
+ ${mainLabel}
+
+ ${mainConfigValue && html`
+ ·
+
+ Viewing
+ ${mainConfigValue}
+ `}
+ Change ✎
@@ -295,14 +204,29 @@ export function DayTabs({
const isActive = i === selectedDay;
const dDate = new Date(d.key + 'T00:00Z');
const dayName = i === 0 ? 'Today'
- : i === 1 ? 'Tomorrow'
+ : i === 1 ? 'Tom'
: dDate.toLocaleDateString('en-GB', { weekday: 'short', timeZone: 'UTC' });
- const utcipVals = d.rows.map(r => r.utciAdj).filter(v => isFinite(v));
- const dayHi = utcipVals.length ? Math.round(Math.max(...utcipVals)) : null;
- const dayLo = utcipVals.length ? Math.round(Math.min(...utcipVals)) : null;
- const shadeVals = d.rows.map(r => r.shadeT).filter(v => isFinite(v));
- const shadeHi = shadeVals.length ? Math.round(Math.max(...shadeVals)) : null;
- const shadeLo = shadeVals.length ? Math.round(Math.min(...shadeVals)) : null;
+ const mainVals = d.rows.map(r => r[mainField]).filter(v => isFinite(v));
+ const dayHi = mainVals.length ? Math.round(Math.max(...mainVals)) : null;
+ const dayLo = mainVals.length ? Math.round(Math.min(...mainVals)) : null;
+
+ // Vehicle/Driver: no table column exists for the ventilated cabin
+ // 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
// 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 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.
// Mixing a warm yellow with grey/blue passes through green, so rather
// 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 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;
- if (isHot) {
+ if (tempOnly) {
+ rgb = hex2rgb(tBand.bg);
+ } else if (isHot) {
// Keep the heat hue; cloud only mutes it a touch (orange/red never
// greens) and rain does not override heat.
const heatFactor = dayHi >= 39 ? 0.15 : dayHi >= 34 ? 0.30 : 0.45;
@@ -409,9 +364,17 @@ export function DayTabs({
${dDate.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', timeZone: 'UTC' })}
- ${showPrecip
- ? html`<${PrecipIcon} precip=${dayPrecip} snow=${daySnow} size=${30} />`
- : html`<${CloudIcon} category=${modalCloudCat} elev=${repElev} dt=${repDt} size=${30} />`}
+ ${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`<${CloudIcon} category=${modalCloudCat} elev=${repElev} dt=${repDt} size=${30} />`}
${dayHi !== null && html`
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)
const weatherEvents = [];
diff --git a/assets/js/events/activity-profile.js b/assets/js/events/activity-profile.js
new file mode 100644
index 0000000..07c96e3
--- /dev/null
+++ b/assets/js/events/activity-profile.js
@@ -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);
+}
diff --git a/assets/js/events/calendar/2026.js b/assets/js/events/calendar/2026.js
index e7878b8..db97a3d 100644
--- a/assets/js/events/calendar/2026.js
+++ b/assets/js/events/calendar/2026.js
@@ -153,7 +153,7 @@ export const cosmic = [
textColor: '#ffc8d8',
type: 'cosmic',
nightOnly: true,
- start: '2026-06-30', end: '2026-07-02',
+ start: '2026-06-30', end: '2026-07-02', peak: '2026-07-01',
},
];
diff --git a/assets/js/events/dynamic-message.js b/assets/js/events/dynamic-message.js
index e3bf60e..2f16496 100644
--- a/assets/js/events/dynamic-message.js
+++ b/assets/js/events/dynamic-message.js
@@ -2,11 +2,13 @@
// dynamic-message.js - Rewrites a cosmic event's message based on where
// today sits vs. the peak.
//
-// Before peak : "is active and building - peak on . "
-// On peak -1d : "peaks tonight - "
-// After peak : "is past its peak () but still possibly visible - "
+// Before peak : "is active and building - peak on . (~NN% chance...)"
+// On peak -1d : "peaks tonight - (~NN% chance...)"
+// After peak : "is past its peak () but still possibly visible - (~NN% chance...)"
//
// 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) {
@@ -19,11 +21,12 @@ export function dynamicCosmicMessage(ev, dateStr) {
.replace(/^.*?(?:peaks tonight|peak tonight|is active tonight|is active|peaks)\s*—\s*/i, '')
.trim();
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) {
- 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) {
- return ev.title + ' peaks tonight — ' + base;
+ return ev.title + ' peaks tonight — ' + base + chanceSuffix;
} 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;
}
}
diff --git a/assets/js/hooks/useAppState.js b/assets/js/hooks/useAppState.js
index 8c7a525..f761e4a 100644
--- a/assets/js/hooks/useAppState.js
+++ b/assets/js/hooks/useAppState.js
@@ -367,6 +367,11 @@ export function useAppState() {
const openRestore = () => setRestoreOpen(true);
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(() => {
try {
const s = localStorage.getItem('sunscope_show_units');
@@ -660,6 +665,7 @@ export function useAppState() {
showDecimals, toggleShowDecimals,
welcomeOpen, closeWelcome, openWelcome,
restoreOpen, openRestore, closeRestore,
+ panelOpen, openPanel, closePanel,
showUnits, toggleShowUnits,
tableInterval, setTableInterval,
forecastView, setForecastView,
diff --git a/assets/js/utils.js b/assets/js/utils.js
index 18c20f7..cb462da 100644
--- a/assets/js/utils.js
+++ b/assets/js/utils.js
@@ -15,7 +15,7 @@
// VEHICLE_TYPES vehicle presets for cabin heat model
// BUILDING_TYPES building presets for indoor heat 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'
// confidenceBand(i) day-tab gradient colour + label
// 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.
// Pre-blends with 50% white to sit harmoniously alongside lighter table cells,
// then applies a light→mid→dark sweep for depth.
@@ -247,19 +293,6 @@ export const FUR_COLORS = {
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.
// -------------------------------------------------------------------