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() {
-
+
+ View: + + + ${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'; + // 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` + + ${showSolar && html``} + ${showVehicle && html``} + ${showIndoor && html``} + ${showFur && html``} + + `; + })()} +
+
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}> +
+ + `; +} 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` - - `)} -
-
- - -
- - ${activeTab === 'common' && profileButtonOrder.map((key) => { - const profile = FILTER_PROFILES[key]; - const locked = profile.proOnly && !isPro; - return html` - `; - })} - - ${activeTab === 'places' && placeOptions.map((opt) => html` - - `)} - - ${activeTab === 'activities' && activityOptions.map((opt) => html` - - `)} - - ${activeTab === 'work' && workOptions.map((opt) => 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} + `} +
@@ -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. // -------------------------------------------------------------------