UI Overhaul
Profiles now in popup
Current profile always on screen
Hidden columns only when edit neeeded
Add pulldowns in to profile config
Customised the day tabs for vehicle, indoor and pet
This commit is contained in:
fraxle
2026-07-18 15:45:20 +01:00
parent f92e191a44
commit 916efca55e
13 changed files with 1594 additions and 610 deletions
+520 -61
View File
@@ -85,7 +85,8 @@
}
.utci-day-tab {
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;
+51 -8
View File
@@ -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;
+191 -284
View File
@@ -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`
<button
class=${`floating-profile-btn${panelOpen ? ' is-open' : ''}`}
onClick=${() => (panelOpen ? closePanel() : openPanel())}
aria-label="Profile and settings"
aria-expanded=${panelOpen}
title="Profile & settings"
>
<span class="floating-profile-icon" aria-hidden="true">${fabIcon}</span>
<span class="floating-profile-val">${mainLabel}</span>
${fabVal && html`<span class="floating-profile-sub">· ${fabVal}</span>`}
<span class="floating-profile-caret" aria-hidden="true">▾</span>
</button>`;
})()}
<${ConfigPanel}
open=${panelOpen}
onClose=${closePanel}
isPro=${isPro}
activeProfile=${activeProfile} activateProfile=${activateProfile} activeCols=${activeCols}
activityOptions=${activityOptions} placeOptions=${placeOptions} workOptions=${workOptions}
activityValue=${activityValue} placeValue=${placeValue} workValue=${workValue}
outdoorsVariant=${outdoorsVariant} setOutdoorsVariantAndSave=${setOutdoorsVariantAndSave}
setActiveProfile=${setActiveProfile} setVisibleCols=${setVisibleCols} setIndoorMode=${setIndoorMode}
setProPromptSource=${setProPromptSource} setProPromptDay=${setProPromptDay}
indoorManaged=${indoorManaged} setIndoorManaged=${setIndoorManaged}
buildingType=${buildingType} setBuildingType=${setBuildingType}
utciEnv=${utciEnv} setUtciEnv=${setUtciEnv}
vehicleType=${vehicleType} setVehicleType=${setVehicleType}
vehicleSpeed=${vehicleSpeed} setVehicleSpeed=${setVehicleSpeed}
vehicleVent=${vehicleVent} setVehicleVent=${setVehicleVent}
furColor=${furColor} setFurColor=${setFurColor}
skinType=${skinType} setSkinType=${setSkinType}
pollenType=${pollenType} setPollenTypeAndSave=${setPollenTypeAndSave}
/>
${forecast && days.length > 0 && html`<${DayTabs}
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() {
<div class="table-with-rail">
<div class=${'table-main' + (forecastView === 'simple' ? ' table-main--simple' : '')}>
<div class="col-toggles">
<div class="table-toolbar-row">
<span class="fvt-toolbar-left">
<span class="forecast-view-toggle-label">View:</span>
<span class="forecast-view-toggle">
<button type="button" title="Quick view visual card layout" class=${'fvt-btn' + (forecastView === 'simple' ? ' on' : '')} onClick=${() => setForecastView('simple')}>
@@ -919,250 +1005,51 @@ export function UTCIForecast() {
Detailed
</button>
</span>
<button class=${'col-toggles-edit-btn col-toggles-edit-btn--toolbar' + (colTogglesOpen ? ' col-toggles-edit-btn--open' : '')} onClick=${() => setColTogglesOpen(v => !v)}>
<span class="col-toggles-edit-btn-label">${colTogglesOpen ? 'Hide Columns' : 'Edit columns'}</span>
<svg class="col-toggles-edit-btn-icon" viewBox="0 0 12 12" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path d="M2 4l4 4 4-4" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg>
</button>
</span>
${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`
<span class="fvt-thermal-tabs">
${showSolar && html`<button type="button" class=${'fvt-thermal-tab' + (solarOn ? ' on' : '')} onClick=${() => setSimpleTemp('utciAdj')}>SunSoak</button>`}
${showVehicle && html`<button type="button" class=${'fvt-thermal-tab' + (vehicleOn ? ' on' : '')} onClick=${() => setSimpleTemp('vehicleT')}>Vehicle</button>`}
${showIndoor && html`<button type="button" class=${'fvt-thermal-tab' + (indoorOn ? ' on' : '')} onClick=${() => setSimpleTemp(indoorManaged ? 'managedT' : 'indoorT')}>Indoors</button>`}
${showFur && html`<button type="button" class=${'fvt-thermal-tab' + (furOn ? ' on' : '')} onClick=${() => setSimpleTemp('furSurfaceT')}>Fur Colour</button>`}
</span>
`;
})()}
</div>
<div class=${'col-toggles' + (colTogglesOpen ? ' col-toggles--open' : '')}>
<span class="fvt-interval">
<span class="fvt-interval-label">Hours:</span>
${[1, 2, 3, 4].map(n => html`<button key=${n} type="button" class=${'hour-interval-btn' + (tableInterval === n ? ' on' : '')} title=${n === 1 ? 'Every hour' : `Every ${n} hours`} onClick=${() => setTableInterval(n)}>${n}h</button>`)}
</span>
${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`
<span class="fvt-model-sep"></span>
${showFur && html`
<span class=${'col-toggle-group' + (furOn ? ' col-toggle-group--expanded' : '')}>
<${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');
}
}}
/>
</span>`}
${showSolar && html`
<span class=${'col-toggle-group' + (solarOn ? ' col-toggle-group--expanded' : '')}>
<${CustomSelect}
value=${utciEnv} isOn=${solarOn} grpClass="grp-felt"
hideLabel="Solar Model" hidingLabel="Solar Model"
groupedLeft=${true} isLastChild=${true}
options=${Object.entries(UTCI_ENVIRONMENTS).map(([k, v]) => ({ value: k, label: v.label }))}
onChange=${v => {
if (v === 'off') setSimpleTemp(showFur ? 'furSurfaceT' : showVehicle ? 'vehicleT' : showIndoor ? 'indoorT' : 'utciAdj');
else { setUtciEnv(v); setSimpleTemp('utciAdj'); }
}}
/>
</span>`}
${showVehicle && html`
<span class=${'col-toggle-group' + (vehicleOn ? ' col-toggle-group--expanded col-toggle-group--triple' : '')}>
<${CustomSelect}
value=${vehicleType} isOn=${vehicleOn} grpClass="grp-felt"
hideLabel="Vehicle" hidingLabel="Vehicle"
groupedLeft=${true} isLastChild=${!vehicleOn}
options=${Object.entries(VEHICLE_TYPES).map(([k, v]) => ({ value: k, label: v.name }))}
onChange=${v => {
if (v === 'off') setSimpleTemp(showFur ? 'furSurfaceT' : showSolar ? 'utciAdj' : showIndoor ? 'indoorT' : 'vehicleT');
else { setVehicleType(v); setSimpleTemp('vehicleT'); }
}}
/>
${vehicleOn && html`
<${CustomSelect}
value=${vehicleSpeed} isOn=${true} noHide=${true} grpClass="grp-felt"
buttonLabel=${(VEHICLE_SPEEDS[vehicleSpeed] ?? VEHICLE_SPEEDS.static).name}
groupedLeft=${true} isLastChild=${false}
options=${Object.entries(VEHICLE_SPEEDS).map(([k, v]) => ({ value: k, label: v.name }))}
onChange=${v => setVehicleSpeed(v)}
/>
<${VentPill} checked=${vehicleVent} onChange=${() => setVehicleVent(v => !v)}
grpClass="grp-felt" label="Ventilation"
title="Open windows significantly reduce cabin heat build-up. Speed sets cabin airflow from forward motion." />`}
</span>`}
${showIndoor && html`
<span class=${'col-toggle-group' + (indoorOn ? ' col-toggle-group--expanded' : '')}>
<${CustomSelect}
value=${buildingType} isOn=${indoorOn} grpClass="grp-felt"
hideLabel="Indoors" hidingLabel="Indoors"
groupedLeft=${true} isLastChild=${!indoorOn}
options=${Object.entries(BUILDING_TYPES).map(([k, v]) => ({ value: k, label: v.name }))}
onChange=${v => {
if (v === 'off') setSimpleTemp(showFur ? 'furSurfaceT' : showSolar ? 'utciAdj' : showVehicle ? 'vehicleT' : 'indoorT');
else { setBuildingType(v); setIndoorMode('on'); setSimpleTemp(indoorManaged ? 'managedT' : 'indoorT'); }
}}
/>
${indoorOn && html`
<${VentPill} checked=${indoorManaged} onChange=${() => { const next = !indoorManaged; setIndoorManaged(next); setSimpleTemp(next ? 'managedT' : 'indoorT'); }}
grpClass="grp-felt" label="Managed"
title="Curtains closed by day, windows open when cooler outside" />`}
</span>`}
`;
})()}
<span class="col-toggles-label">Columns:</span>
<button class=${'col-toggles-edit-btn' + (colTogglesOpen ? ' col-toggles-edit-btn--open' : '')} onClick=${() => setColTogglesOpen(v => !v)}>
<span class="col-toggles-edit-btn-label">${colTogglesOpen ? 'Done' : 'Edit columns'}</span>
<svg class="col-toggles-edit-btn-icon" viewBox="0 0 12 12" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path d="M2 4l4 4 4-4" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg>
</button>
<div class=${'col-toggles-body' + (colTogglesOpen ? ' col-toggles-body--open' : '')}>
${(isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['utciP']) : activeCols['utciP']) && html`
<span class=${'col-toggle-group' + (visibleCols.utciP ? ' col-toggle-group--expanded' : '')}>
<${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 }));
}
}}
/>
</span>`}
${(isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['vehicleT']) : activeCols['vehicleT']) && html`
<span class=${'col-toggle-group' + (visibleCols.vehicleT ? ' col-toggle-group--expanded col-toggle-group--triple' : '')}>
<${CustomSelect}
value=${vehicleType}
isOn=${visibleCols.vehicleT}
grpClass="grp-felt"
hideLabel="Vehicle"
hidingLabel="Hide Vehicle"
groupedLeft=${true}
isLastChild=${!visibleCols.vehicleT}
options=${Object.entries(VEHICLE_TYPES).map(([k, v]) => ({
value: k,
label: v.name,
}))}
onChange=${(v) => {
if (v === 'off') {
setVisibleCols(prev => ({ ...prev, vehicleT: false }));
setVehicleVent(false);
} else {
setVehicleType(v);
setVisibleCols(prev => ({ ...prev, vehicleT: true }));
}
}}
/>
${visibleCols.vehicleT && html`
<${CustomSelect}
value=${vehicleSpeed}
isOn=${true}
noHide=${true}
grpClass="grp-felt"
buttonLabel=${(VEHICLE_SPEEDS[vehicleSpeed] ?? VEHICLE_SPEEDS.static).name}
groupedLeft=${true}
isLastChild=${false}
options=${Object.entries(VEHICLE_SPEEDS).map(([k, v]) => ({ value: k, label: v.name }))}
onChange=${(v) => setVehicleSpeed(v)}
/>
<${VentPill}
checked=${vehicleVent}
onChange=${() => setVehicleVent(v => !v)}
grpClass="grp-felt"
label="Ventilation"
title="Ventilation — open windows significantly reduce cabin heat build-up. Speed sets cabin airflow from forward motion."
/>`}
</span>`}
${(isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['indoorT'] || activeCols['managedT']) : (activeCols['indoorT'] || activeCols['managedT'])) && html`
<span class=${'col-toggle-group' + (indoorMode === 'on' ? ' col-toggle-group--expanded' : '')}>
<${CustomSelect}
value=${buildingType}
isOn=${indoorMode === 'on'}
grpClass="grp-felt"
hideLabel="Indoors"
hidingLabel="Hide Indoors"
groupedLeft=${true}
isLastChild=${indoorMode !== 'on'}
options=${Object.entries(BUILDING_TYPES).map(([k, v]) => ({
value: k,
label: v.name,
}))}
onChange=${(v) => {
if (v === 'off') {
setIndoorMode('off');
setIndoorManaged(false);
} else {
setBuildingType(v);
setIndoorMode('on');
}
}}
/>
${indoorMode === 'on' && html`
<${VentPill}
checked=${indoorManaged}
onChange=${() => setIndoorManaged(v => !v)}
grpClass="grp-felt"
label="Managed"
title="Managed: curtains closed by day, windows open when cooler outside"
/>`}
</span>`}
${(isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['furSurfaceT']) : activeCols['furSurfaceT']) && html`
<span class=${'col-toggle-group' + (visibleCols.furSurfaceT ? ' col-toggle-group--expanded' : '')}>
<${CustomSelect}
value=${furColor}
isOn=${visibleCols.furSurfaceT}
grpClass="grp-surface"
hideLabel="Fur Colour"
hidingLabel="Hide Fur Colour"
groupedLeft=${true}
isLastChild=${true}
options=${Object.entries(FUR_COLORS).map(([k, v]) => ({ value: k, label: v.name }))}
onChange=${(v) => {
if (v === 'off') {
setVisibleCols(prev => ({ ...prev, furSurfaceT: false }));
} else {
setFurColor(v);
setVisibleCols(prev => ({ ...prev, furSurfaceT: true }));
}
}}
/>
</span>`}
<div class="col-toggles-body">
${(isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['utciP']) : activeCols['utciP']) && html`<button class=${`col-toggle grp-felt${visibleCols.utciP ? ' on' : ''}`} onClick=${() => toggleCol('utciP')}>SunSoak</button>`}
${(isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['vehicleT']) : activeCols['vehicleT']) && html`<button class=${`col-toggle grp-felt${visibleCols.vehicleT ? ' on' : ''}`} onClick=${() => { if (visibleCols.vehicleT) setVehicleVent(false); toggleCol('vehicleT'); }}>Vehicle</button>`}
${(isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['indoorT'] || activeCols['managedT']) : (activeCols['indoorT'] || activeCols['managedT'])) && html`<button class=${`col-toggle grp-felt${indoorMode === 'on' ? ' on' : ''}`} onClick=${() => { if (indoorMode === 'on') { setIndoorMode('off'); setIndoorManaged(false); } else { setIndoorMode('on'); } }}>Indoors</button>`}
${(isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['furSurfaceT']) : activeCols['furSurfaceT']) && html`<button class=${`col-toggle grp-surface${visibleCols.furSurfaceT ? ' on' : ''}`} onClick=${() => toggleCol('furSurfaceT')}>Fur Colour</button>`}
${(isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['pawT']) : activeCols['pawT']) && html`<button class=${`col-toggle grp-surface${visibleCols.pawT ? ' on' : ''}`} onClick=${() => toggleCol('pawT')}>Paw</button>`}
${(isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['petShadeT']) : activeCols['petShadeT']) && html`<button class=${`col-toggle grp-ambient${visibleCols.petShadeT ? ' on' : ''}`} onClick=${() => toggleCol('petShadeT')}>Pet Shade</button>`}
${(isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['petHomeT']) : activeCols['petHomeT']) && html`<button class=${`col-toggle grp-felt${visibleCols.petHomeT ? ' on' : ''}`} onClick=${() => toggleCol('petHomeT')}>Pet Home</button>`}
${(isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['burn']) : activeCols['burn']) && html`
<${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`<button class=${`col-toggle grp-felt${visibleCols.burn ? ' on' : ''}`} onClick=${() => toggleCol('burn')}>Burn</button>`}
${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['utci']) && html`<button class=${`col-toggle grp-felt${visibleCols.utci ? ' on' : ''}`} onClick=${() => toggleCol('utci')}>UTCI</button>`}
${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['delta']) && html`<button class=${`col-toggle grp-felt${visibleCols.delta ? ' on' : ''}`} onClick=${() => toggleCol('delta')}>Δ</button>`}
${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['tmrt']) && html`<button class=${`col-toggle grp-felt${visibleCols.tmrt ? ' on' : ''}`} onClick=${() => toggleCol('tmrt')}>Tmrt</button>`}
@@ -1182,25 +1069,7 @@ export function UTCIForecast() {
${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['wind']) && html`<button class=${`col-toggle grp-wind${visibleCols.wind ? ' on' : ''}`} onClick=${() => toggleCol('wind')}>Wind</button>`}
${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['dir']) && html`<button class=${`col-toggle grp-wind${visibleCols.dir ? ' on' : ''}`} onClick=${() => toggleCol('dir')}>Dir</button>`}
${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['aqi']) && html`<button class=${`col-toggle grp-airqual${visibleCols.aqi ? ' on' : ''}`} onClick=${() => toggleCol('aqi')}>Air Quality</button>`}
${(isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['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`<button class=${`col-toggle grp-airqual${visibleCols.pollen ? ' on' : ''}`} onClick=${() => toggleCol('pollen')}>Pollen</button>`}
${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['uvA']) && html`<button class=${`col-toggle grp-solar${visibleCols.uvA ? ' on' : ''}`} onClick=${() => toggleCol('uvA')}>UV-A</button>`}
${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['uvB']) && html`<button class=${`col-toggle grp-solar${visibleCols.uvB ? ' on' : ''}`} onClick=${() => toggleCol('uvB')}>UV-B</button>`}
${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['sun']) && html`<button class=${`col-toggle grp-solar${visibleCols.sun ? ' on' : ''}`} onClick=${() => toggleCol('sun')}>Sun</button>`}
@@ -1226,7 +1095,7 @@ export function UTCIForecast() {
<div class="forecast-simple-cards" style=${{ gridTemplateColumns: `repeat(${tableRows.length}, minmax(55px, 1fr))` }}>
${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')}
</td>`}
${visibleCols.petHomeT && html`
<td class=${groupStart('petHomeT')} style=${{ color: airTempFontColor(), background: airTempBg(r.indoorT, rPrev?.indoorT, rNext?.indoorT) }}>
<td class=${groupStart('petHomeT')} title=${petCategory(r.indoorT)?.label ?? ''} style=${{ color: airTempFontColor(), background: petAirTempBg(r.indoorT, rPrev?.indoorT, rNext?.indoorT) }}>
${fmt(r.indoorT)}${u('°C')}
</td>`}
${visibleCols.burn && html`
@@ -1570,15 +1449,13 @@ export function UTCIForecast() {
${fmt(r.concreteT)}${u('°C')}
</td>`}
${visibleCols.furSurfaceT && html`
<td class=${groupStart('furSurfaceT')} style=${{ color: airTempFontColor(), background: airTempBg(r.furSurfaceT, rPrev?.furSurfaceT, rNext?.furSurfaceT) }}>
<td class=${groupStart('furSurfaceT')} title=${petCategory(r.furSurfaceT)?.label ?? ''} style=${{ color: airTempFontColor(), background: petAirTempBg(r.furSurfaceT, rPrev?.furSurfaceT, rNext?.furSurfaceT) }}>
${fmt(r.furSurfaceT)}${u('°C')}
</td>`}
${visibleCols.pawT && (() => {
const risk = pawBurnRiskLabel(r.concreteT);
return html`<td class=${groupStart('pawT')} title=${risk ?? ''} style=${{ color: airTempFontColor(), background: airTempBg(r.concreteT, rPrev?.concreteT, rNext?.concreteT) }}>
${visibleCols.pawT && html`
<td class=${groupStart('pawT')} title=${petCategory(r.concreteT)?.label ?? ''} style=${{ color: airTempFontColor(), background: petAirTempBg(r.concreteT, rPrev?.concreteT, rNext?.concreteT) }}>
${fmt(r.concreteT)}${u('°C')}
</td>`;
})()}
</td>`}
${visibleCols.soilT && html`
<td class=${groupStart('soilT')} style=${{ color: airTempFontColor(), background: airTempBg(r.soilT0, rPrev?.soilT0, rNext?.soilT0) }}>${r.soilT0 != null ? fmt(r.soilT0) + u('°C') : '—'}</td>`}
${visibleCols.soilT6 && html`
@@ -1587,7 +1464,7 @@ export function UTCIForecast() {
<td class=${groupStart('soilM')} style=${(() => { const sm = soilMoistureBg(r.soilM); return { color: sm.fg, background: sm.bg }; })()}>${r.soilM != null ? fmt(r.soilM * 100, 1) + u('%') : '—'}</td>`}
${visibleCols.shadeT && html`<td class=${groupStart('shadeT')} style=${{ background: airTempBg(r.shadeT, rPrev?.shadeT, rNext?.shadeT), color: airTempFontColor() }}>${r.shadeT != null ? fmt(r.shadeT) + u('°C') : '—'}</td>`}
${visibleCols.petShadeT && html`
<td class=${groupStart('petShadeT')} style=${{ color: airTempFontColor(), background: airTempBg(r.shadeT, rPrev?.shadeT, rNext?.shadeT) }}>
<td class=${groupStart('petShadeT')} title=${r.shadeT != null ? (petCategory(r.shadeT)?.label ?? '') : ''} style=${{ color: airTempFontColor(), background: petAirTempBg(r.shadeT, rPrev?.shadeT, rNext?.shadeT) }}>
${r.shadeT != null ? fmt(r.shadeT) + u('°C') : '—'}
</td>`}
${visibleCols.air && html`<td class=${groupStart('air')} style=${{ background: airTempBg(r.Ta, rPrev?.Ta, rNext?.Ta), color: airTempFontColor() }}>${fmt(r.Ta)}${u('°C')}</td>`}
@@ -1850,9 +1727,39 @@ export function UTCIForecast() {
</span>`;
})}
</div>
${(visibleCols.furSurfaceT || visibleCols.petHomeT || visibleCols.petShadeT || visibleCols.pawT) && html`
<span class="utci-legend-label utci-legend-label--secondary">Pet thermal stress bands</span>
<div class="utci-legend-row">
${[
{ t: -15, label: 'Freezing', value: '< -8°C' },
{ t: 0, label: 'Cold', value: '-82°C' },
{ t: 9, label: 'Cool', value: '211°C' },
{ t: 18, label: 'Comfortable', value: '1125°C', bold: true },
{ t: 28, label: 'Warm', value: '2532°C' },
{ t: 36, label: 'Caution', value: '3240°C' },
{ t: 46, label: 'Extreme', value: '4052°C' },
{ t: 60, label: 'Danger', value: '52°C+' },
].map((b, i) => {
// Exact same recipe as the human legend above (same 135deg
// light/mid/dark sweep, same luminance-based font colour) -
// just reading from petAirTempRgb instead of airTempRgb.
const rgb = petAirTempRgb(b.t) || [200, 200, 200];
const mid = `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`;
const light = `rgb(${rgb.map(c => Math.min(255, Math.round(c + (255-c)*0.30))).join(',')})`;
const dark = `rgb(${rgb.map(c => Math.round(c * 0.96)).join(',')})`;
const bg = `linear-gradient(135deg, ${light} 0%, ${mid} 60%, ${dark} 100%)`;
const lum = (rgb[0]*299 + rgb[1]*587 + rgb[2]*114) / 1000;
const fg = lum > 165 ? '#1a1a1a' : '#ffffff';
return html`<span key=${i} class="utci-legend-item" style=${{ background: bg, color: fg, ...(b.bold ? { fontWeight: 700 } : {}) }}>
<span class="utci-legend-item-label">${b.label}</span>
<span class="utci-legend-item-value">${b.value}</span>
</span>`;
})}
</div>`}
</div>
${(() => {
const upcoming = getUpcomingEvents(location, 3650).slice(0, 4);
const formatPeak = (iso) => {
+161 -17
View File
@@ -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`
<span class=${`cs-wrap${open ? ' open' : ''}`} ref=${wrapRef}>
<button
@@ -158,7 +203,11 @@ export function CustomSelect({ value, options, onChange, hideLabel, hidingLabel,
<span class="cs-arrow"></span>
</button>
${open && html`
<div class=${`cs-panel${flipRight ? ' cs-panel--right' : ''}`} ref=${panelRef} role="listbox">
<div class=${`cs-panel${flipUp ? ' cs-panel--up' : ''}${useColumns ? ' cs-panel--cols' : ''}`} ref=${panelRef} role="listbox"
style=${{
...(panelLeft != null ? { left: panelLeft + 'px' } : {}),
...(maxPanelHeight != null ? { maxHeight: maxPanelHeight + 'px' } : {}),
}}>
${isOn && !noHide && html`
<span
class="cs-option"
@@ -405,7 +454,7 @@ export function WindVane({ bearing, size = 30 }) {
// elev: solar elevation in degrees (negative = night)
// dt: Date used for moon phase
// -------------------------------------------------------------------
export function CloudIcon({ category, size = 30, elev = 90, dt = new Date(), filled = false }) {
export function CloudIcon({ category, size = 30, elev = 90, dt = new Date(), filled = false, showSun = true }) {
const r = size / 2;
const brass = '#c8922a';
const ink = '#2a1a08';
@@ -451,7 +500,7 @@ export function CloudIcon({ category, size = 30, elev = 90, dt = new Date(), fil
return html`
<svg width=${size} height=${size} viewBox=${`0 0 ${size} ${size}`}
style=${{ flexShrink: 0, display: 'inline-block', verticalAlign: 'middle', overflow: 'visible' }}>
${category === 'clear' && elev >= 0 && html`
${category === 'clear' && elev >= 0 && showSun && html`
${sun(size * 0.50, size * 0.38, size * 0.16)}`}
${category === 'clear' && elev < 0 && html`
<path d=${`M ${size*0.45} ${size*0.15}
@@ -464,17 +513,20 @@ export function CloudIcon({ category, size = 30, elev = 90, dt = new Date(), fil
<circle cx=${size*0.18} cy=${size*0.66} r=${size*0.018} fill=${brass} opacity="0.55" />
<circle cx=${size*0.88} cy=${size*0.48} r=${size*0.018} fill=${brass} opacity="0.55" />`}
${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)}
<path d=${cloudPath(-size * 0.12, size * 0.02, 0.64)} fill=${filled ? 'rgba(255,255,255,0.88)' : 'none'} stroke=${filled ? 'none' : ink}
stroke-width=${sw} stroke-linecap=${cap} stroke-linejoin=${join} />
${!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)}
<path d=${cloudPath(-size * 0.20, -size * 0.32, 0.36)} fill=${filled ? 'rgba(255,255,255,0.88)' : 'none'} stroke=${filled ? 'none' : muted}
stroke-width=${sw * 0.65} stroke-linecap=${cap} stroke-linejoin=${join} opacity=${filled ? 1 : 0.85} />
<path d=${cloudPath(size * 0.18, -size * 0.38, 0.30)} fill=${filled ? 'rgba(255,255,255,0.88)' : 'none'} stroke=${filled ? 'none' : muted}
stroke-width=${sw * 0.6} stroke-linecap=${cap} stroke-linejoin=${join} opacity=${filled ? 1 : 0.85} />
<path d=${cloudPath(0, -size * 0.20, 0.28)} fill=${filled ? 'rgba(255,255,255,0.88)' : 'none'} stroke=${filled ? 'none' : muted}
stroke-width=${sw * 0.55} stroke-linecap=${cap} stroke-linejoin=${join} opacity=${filled ? 1 : 0.85} />`}
${category === 'scattered' && elev < 0 && html`
<path d=${cloudPath(0, -size * 0.2425, 0.82)} fill=${filled ? 'rgba(255,255,255,0.88)' : 'none'} stroke=${filled ? 'none' : ink}
stroke-width=${sw} stroke-linecap=${cap} stroke-linejoin=${join} />
@@ -1265,3 +1317,95 @@ export function PrecipIcon({ precip = 0, snow = 0, size = 28, filled = false })
</svg>`
}
// HOUSEICON / CARICON - Brass Line glyphs for the day-tab shade when a
// modelled indoor or vehicle-cabin temperature stands in for the outdoor
// weather icon.
// -------------------------------------------------------------------
export function HouseIcon({ size = 30 }) {
const sw = Math.max(1.25, size * 0.058);
const brass = '#c8922a';
const ink = '#2a1a08';
return html`
<svg width=${size} height=${size} viewBox=${`0 0 ${size} ${size}`}
style=${{ flexShrink: 0, display: 'inline-block', verticalAlign: 'middle', overflow: 'visible' }}>
<path d=${`M ${size*0.13} ${size*0.52}
L ${size*0.50} ${size*0.15}
L ${size*0.87} ${size*0.52}`}
fill="none" stroke=${brass} stroke-width=${sw} stroke-linecap="round" stroke-linejoin="round" />
<path d=${`M ${size*0.22} ${size*0.46}
V ${size*0.85}
H ${size*0.78}
V ${size*0.46}`}
fill="none" stroke=${ink} stroke-width=${sw} stroke-linecap="round" stroke-linejoin="round" />
<path d=${`M ${size*0.42} ${size*0.85}
V ${size*0.60}
H ${size*0.58}
V ${size*0.85}`}
fill="none" stroke=${ink} stroke-width=${sw*0.8} stroke-linecap="round" stroke-linejoin="round" />
</svg>`;
}
// Body path is Lucide's "car" icon (ISC licence), used verbatim on its
// native 24x24 grid rather than hand-plotted, since freehand coordinates
// kept coming out lopsided.
export function CarIcon({ size = 30 }) {
const brass = '#c8922a';
const ink = '#2a1a08';
return html`
<svg width=${size} height=${size} viewBox="0 0 24 24" fill="none"
style=${{ flexShrink: 0, display: 'inline-block', verticalAlign: 'middle', overflow: 'visible' }}>
<path d="M19 17h2c.6 0 1-.4 1-1v-3c0-.9-.7-1.7-1.5-1.9C18.7 10.6 16 10 16 10s-1.3-1.4-2.2-2.3c-.5-.4-1.1-.7-1.8-.7H5c-.6 0-1.1.4-1.4.9l-1.4 2.9A3.7 3.7 0 0 0 2 12v4c0 .6.4 1 1 1h2"
stroke=${brass} stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
<path d="M9 17h6" stroke=${ink} stroke-width="1.8" stroke-linecap="round" />
<circle cx="7" cy="17" r="2" fill="#fdf6e8" stroke=${ink} stroke-width="1.6" />
<circle cx="17" cy="17" r="2" fill="#fdf6e8" stroke=${ink} stroke-width="1.6" />
</svg>`;
}
// FOGICON - reuses the same closed puffy-cloud outline as PrecipIcon's
// cloud (rather than a foreign icon-set shape) so it sits consistently
// alongside the rain/snow glyphs, with mist bands underneath.
export function FogIcon({ size = 30 }) {
const brass = '#c8922a';
const ink = '#2a1a08';
const sw = Math.max(1.25, size * 0.055);
return html`
<svg width=${size} height=${size} viewBox=${`0 0 ${size} ${size}`}
style=${{ flexShrink: 0, display: 'inline-block', verticalAlign: 'middle', overflow: 'visible' }}>
<path d=${`M ${size*0.18} ${size*0.36}
H ${size*0.72}
C ${size*0.86} ${size*0.36}, ${size*0.88} ${size*0.20}, ${size*0.72} ${size*0.19}
C ${size*0.66} ${size*0.04}, ${size*0.42} ${size*0.02}, ${size*0.34} ${size*0.17}
C ${size*0.22} ${size*0.14}, ${size*0.14} ${size*0.24}, ${size*0.18} ${size*0.36} Z`}
fill="none" stroke=${brass} stroke-width=${sw} stroke-linecap="round" stroke-linejoin="round" />
<line x1=${size*0.14} y1=${size*0.46} x2=${size*0.86} y2=${size*0.46}
stroke=${ink} stroke-width=${sw*0.85} stroke-linecap="round" />
<line x1=${size*0.22} y1=${size*0.58} x2=${size*0.78} y2=${size*0.58}
stroke=${ink} stroke-width=${sw*0.85} stroke-linecap="round" />
<line x1=${size*0.10} y1=${size*0.70} x2=${size*0.90} y2=${size*0.70}
stroke=${ink} stroke-width=${sw*0.85} stroke-linecap="round" opacity="0.7" />
</svg>`;
}
export function IceIcon({ size = 30 }) {
const icy = '#3f73c4';
return html`
<svg width=${size} height=${size} viewBox="0 0 24 24" fill="none"
style=${{ flexShrink: 0, display: 'inline-block', verticalAlign: 'middle', overflow: 'visible' }}>
<g stroke=${icy} stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="m10 20-1.25-2.5L6 18" />
<path d="M10 4 8.75 6.5 6 6" />
<path d="m14 20 1.25-2.5L18 18" />
<path d="m14 4 1.25 2.5L18 6" />
<path d="m17 21-3-6h-4" />
<path d="m17 3-3 6 1.5 3" />
<path d="M2 12h6.5L10 9" />
<path d="m20 10-1.5 2 1.5 2" />
<path d="M22 12h-6.5L14 15" />
<path d="m4 10 1.5 2L4 14" />
<path d="m7 21 3-6-1.5-3" />
<path d="m7 3 3 6h4" />
</g>
</svg>`;
}
+350
View File
@@ -0,0 +1,350 @@
// ------------------------------------------------------------------------
// components/ConfigPanel.js - Dropdown holding the profile picker and the
// context-sensitive config strip, opened from the profile trigger embedded
// in the middle of the (permanently floating) top nav.
//
// Lifted out of DayTabs.js so the profile/config UI no longer occupies a
// permanent full-width band above the forecast — it now expands downward
// from the nav, directly below it, keeping the original full-width
// horizontal layout (profile tabs, horizontally-scrolling card row, config
// strip) intact rather than squeezing it into a side drawer.
//
// Purely presentational - all state lives in useAppState. Dismisses on the
// × button, backdrop click, or Esc.
//
// Props:
// onClose - called on dismiss (×, backdrop, Esc)
// isPro - boolean Pro status
// activeProfile - current filter profile key
// activateProfile - function to switch profile
// activeCols - effective column set for current profile
// activityOptions/placeOptions/workOptions - dropdown options
// activityValue/placeValue/workValue - current variant values
// outdoorsVariant, setOutdoorsVariantAndSave
// setActiveProfile, setVisibleCols, setIndoorMode
// setProPromptSource, setProPromptDay - Pro upsell triggers
// indoorManaged/setIndoorManaged, buildingType/setBuildingType
// utciEnv/setUtciEnv, vehicleType/setVehicleType,
// vehicleSpeed/setVehicleSpeed, vehicleVent/setVehicleVent,
// furColor/setFurColor, skinType/setSkinType, pollenType/setPollenTypeAndSave
// ------------------------------------------------------------------------
import { h, Fragment } from '../../vendor/preact.js';
import { useRef, useEffect, useState } from '../../vendor/preact-hooks.js';
import htm from '../../vendor/htm.js';
import { SKIN_TYPES, VEHICLE_TYPES, VEHICLE_SPEEDS, BUILDING_TYPES, FUR_COLORS } from '../utils.js';
import { FILTER_PROFILES, OUTDOORS_VARIANTS, profileButtonOrder, activityVariantKeys, placeVariantKeys, workVariantKeys, POLLEN_TYPES, UTCI_ENVIRONMENTS, deriveProfileMain } from '../config.js';
import { CustomSelect, VentPill } from '../components.js';
const html = htm.bind(h);
export function ConfigPanel({
open,
onClose,
isPro,
activeProfile, activateProfile, activeCols,
activityOptions, placeOptions, workOptions,
activityValue, placeValue, workValue,
outdoorsVariant, setOutdoorsVariantAndSave,
setActiveProfile, setVisibleCols, setIndoorMode,
setProPromptSource, setProPromptDay,
indoorManaged, setIndoorManaged, buildingType, setBuildingType,
utciEnv, setUtciEnv,
vehicleType, setVehicleType, vehicleSpeed, setVehicleSpeed, vehicleVent, setVehicleVent,
furColor, setFurColor, skinType, setSkinType, pollenType, setPollenTypeAndSave,
}) {
const profileScrollRef = useRef(null);
const profileWrapRef = useRef(null);
const { mainConfigKey, mainLabel } = deriveProfileMain(activeProfile, outdoorsVariant);
// Active tab: common - places - activities - work.
// Auto-derived from the current profile on mount and on change.
const getTab = () => {
if (activeProfile === 'outdoors' && placeVariantKeys.includes(outdoorsVariant)) return 'places';
if (activeProfile === 'outdoors' && activityVariantKeys.includes(outdoorsVariant)) return 'activities';
if (activeProfile === 'farming' || (activeProfile === 'outdoors' && workVariantKeys.includes(outdoorsVariant))) return 'work';
return 'common';
};
const [activeTab, setActiveTab] = useState(getTab);
useEffect(() => { setActiveTab(getTab()); }, [activeProfile, outdoorsVariant]);
// Dismiss on Esc (component stays mounted while closed so the close
// transition can play, so this only acts while actually open).
useEffect(() => {
const onKey = (e) => { if (open && e.key === 'Escape') onClose(); };
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [open, onClose]);
// Drag-to-scroll + fade edges for the horizontal profile card row.
useEffect(() => {
const el = profileScrollRef.current;
const wrap = profileWrapRef.current;
if (!el) return;
let isDown = false, startX = 0, startScroll = 0, hasDragged = false;
const updateFades = () => {
if (!wrap) return;
wrap.classList.toggle('fade-left', el.scrollLeft > 1);
wrap.classList.toggle('fade-right', el.scrollLeft + el.clientWidth < el.scrollWidth - 1);
};
const onMouseDown = (e) => {
if (!el.contains(e.target) || e.button !== 0) return;
isDown = true; hasDragged = false;
startX = e.clientX; startScroll = el.scrollLeft;
document.body.style.userSelect = 'none';
document.body.style.webkitUserSelect = 'none';
};
const onMouseMove = (e) => {
if (!isDown) return;
const dx = e.clientX - startX;
if (Math.abs(dx) > 5) {
hasDragged = true;
el.style.cursor = 'grabbing';
el.scrollLeft = startScroll - dx;
}
};
const onMouseUp = () => {
if (!isDown) return;
isDown = false;
el.style.cursor = '';
document.body.style.userSelect = '';
document.body.style.webkitUserSelect = '';
};
const onClickCapture = (e) => {
if (hasDragged) { e.stopPropagation(); e.preventDefault(); hasDragged = false; }
};
el.addEventListener('scroll', updateFades);
updateFades(); // set initial fade state
document.addEventListener('mousedown', onMouseDown);
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
el.addEventListener('click', onClickCapture, true);
return () => {
el.removeEventListener('scroll', updateFades);
document.removeEventListener('mousedown', onMouseDown);
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
el.removeEventListener('click', onClickCapture, true);
};
}, []);
const mainBadge = html`<span class="config-item-main-badge" title="Main selection — this is what drives the day tab's hi/lo readout">${mainLabel} Config:</span>`;
const showSolar = isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['utciP']) : activeCols['utciP'];
const showVehicle = isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['vehicleT']) : activeCols['vehicleT'];
const showIndoor = isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['indoorT'] || activeCols['managedT']) : (activeCols['indoorT'] || activeCols['managedT']);
const showFur = isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['furSurfaceT']) : activeCols['furSurfaceT'];
const showBurn = isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['burn']) : activeCols['burn'];
const showPollen = isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['pollen']) : activeCols['pollen'];
const configItems = [
{ key: 'solar', show: showSolar, node: html`
<span class="config-item-label">SunSoak</span>
<${CustomSelect}
value=${utciEnv} isOn=${true} noHide=${true} grpClass="grp-felt"
hideLabel="SunSoak"
options=${Object.entries(UTCI_ENVIRONMENTS).map(([k, v]) => ({ value: k, label: v.label }))}
onChange=${(v) => setUtciEnv(v)}
/>` },
{ key: 'vehicle', show: showVehicle, node: html`
<span class="config-item-label">Vehicle</span>
<span class="col-toggle-group col-toggle-group--expanded col-toggle-group--triple">
<${CustomSelect}
value=${vehicleType} isOn=${true} noHide=${true} grpClass="grp-felt"
hideLabel="Vehicle" groupedLeft=${true} isLastChild=${false}
options=${Object.entries(VEHICLE_TYPES).map(([k, v]) => ({ value: k, label: v.name }))}
onChange=${(v) => setVehicleType(v)}
/>
<${CustomSelect}
value=${vehicleSpeed} isOn=${true} noHide=${true} grpClass="grp-felt"
buttonLabel=${(VEHICLE_SPEEDS[vehicleSpeed] ?? VEHICLE_SPEEDS.static).name}
groupedLeft=${true} isLastChild=${false}
options=${Object.entries(VEHICLE_SPEEDS).map(([k, v]) => ({ value: k, label: v.name }))}
onChange=${(v) => setVehicleSpeed(v)}
/>
<${VentPill}
checked=${vehicleVent}
onChange=${() => setVehicleVent(v => !v)}
grpClass="grp-felt"
label="Ventilation"
title="Ventilation — open windows significantly reduce cabin heat build-up. Speed sets cabin airflow from forward motion."
/>
</span>` },
{ key: 'indoor', show: showIndoor, node: html`
<span class="config-item-label">Indoors</span>
<span class="col-toggle-group col-toggle-group--expanded">
<${CustomSelect}
value=${buildingType} isOn=${true} noHide=${true} grpClass="grp-felt"
hideLabel="Indoors" groupedLeft=${true} isLastChild=${false}
options=${Object.entries(BUILDING_TYPES).map(([k, v]) => ({ value: k, label: v.name }))}
onChange=${(v) => setBuildingType(v)}
/>
<${VentPill}
checked=${indoorManaged}
onChange=${() => setIndoorManaged(v => !v)}
grpClass="grp-felt"
label="Managed"
title="Managed: curtains closed by day, windows open when cooler outside"
/>
</span>` },
{ key: 'fur', show: showFur, node: html`
<span class="config-item-label">Fur Colour</span>
<${CustomSelect}
value=${furColor} isOn=${true} noHide=${true} grpClass="grp-surface"
hideLabel="Fur Colour"
options=${Object.entries(FUR_COLORS).map(([k, v]) => ({ value: k, label: v.name }))}
onChange=${(v) => setFurColor(v)}
/>` },
{ key: 'burn', show: showBurn, node: html`
<span class="config-item-label">Burn</span>
<${CustomSelect}
value=${skinType} isOn=${true} noHide=${true} grpClass="grp-felt"
hideLabel="Burn"
options=${Object.entries(SKIN_TYPES).map(([k, v]) => ({ value: k, label: v.name.split(' · ')[1] + ' skin' }))}
onChange=${(v) => setSkinType(v)}
/>` },
{ key: 'pollen', show: showPollen, node: html`
<span class="config-item-label">Pollen</span>
<${CustomSelect}
value=${pollenType} isOn=${true} noHide=${true} grpClass="grp-airqual"
hideLabel="Pollen"
options=${Object.entries(POLLEN_TYPES).flatMap(([k, v], i) => [
{ value: k, label: v.name },
...(i === 0 ? [{ value: '_div', divider: true }] : []),
])}
onChange=${(v) => setPollenTypeAndSave(v)}
/>` },
].filter((item) => item.show);
configItems.sort((a, b) => (a.key === mainConfigKey ? -1 : b.key === mainConfigKey ? 1 : 0));
return html`
<${Fragment}>
<div class=${`profile-dropdown-overlay${open ? ' is-open' : ''}`} onClick=${onClose}></div>
<div class=${`profile-dropdown${open ? ' is-open' : ''}`} role="dialog" aria-modal="true" aria-label="Profile and configuration" aria-hidden=${!open}>
<button class="profile-dropdown-close" aria-label="Close" onClick=${onClose}>×</button>
<div class="profile-dropdown-body">
<div class="filter-profiles">
<div class="profile-tabs">
${[['common','Common'],['places','Places'],['activities','Activities'],['work','Work']].map(([key, label]) => html`
<button
key=${key}
class=${`profile-tab${activeTab === key ? ' active' : ''}`}
onClick=${() => setActiveTab(key)}
>${label}</button>
`)}
</div>
<div class="profile-scroll-wrap" ref=${profileWrapRef}>
<span class="profile-scroll-chevron left" aria-hidden="true"></span>
<span class="profile-scroll-chevron right" aria-hidden="true"></span>
<div class="profile-scroll" ref=${profileScrollRef}>
${activeTab === 'common' && profileButtonOrder.map((key) => {
const profile = FILTER_PROFILES[key];
const locked = profile.proOnly && !isPro;
return html`
<button
key=${key}
class=${`profile-btn${activeProfile === key ? ' active' : ''}${locked ? ' locked' : ''}`}
style=${{ cursor: 'pointer', position: 'relative' }}
title=${locked ? `Profile ${profile.label} is part of SunScope Extra` : ''}
onClick=${() => {
if (locked) { setProPromptSource(`profile:${key}`); setProPromptDay(0); return; }
activateProfile(key);
}}
>
<span class="profile-btn-scene" style=${{ background: profile.scene, filter: locked ? 'grayscale(100%)' : 'none' }}>${profile.scene.includes('url(') ? null : profile.icon}</span>
<span class="profile-btn-label">${locked ? '🔒 ' : ''}${profile.label}</span>
${locked && html`<span class="profile-lock-badge">🔒</span>`}
</button>`;
})}
${activeTab === 'places' && placeOptions.map((opt) => html`
<button
key=${opt.value}
class=${`profile-btn${placeValue === opt.value ? ' active' : ''}${opt.disabled ? ' locked' : ''}`}
style=${{ cursor: 'pointer', position: 'relative' }}
onClick=${() => {
if (opt.disabled) { setProPromptSource(`variant:${opt.value}`); setProPromptDay(0); return; }
try { localStorage.setItem('sunscope_profile', 'outdoors'); } catch(e) {}
setActiveProfile('outdoors');
setOutdoorsVariantAndSave(opt.value);
setVisibleCols({ ...OUTDOORS_VARIANTS[opt.value].cols });
setIndoorMode('off');
setIndoorManaged(false);
}}
>
<span class="profile-btn-scene" style=${{ background: opt.scene, filter: opt.disabled ? 'grayscale(100%)' : 'none' }}></span>
<span class="profile-btn-label">${opt.disabled ? '🔒 ' : ''}${opt.name}</span>
${opt.disabled && html`<span class="profile-lock-badge">🔒</span>`}
</button>
`)}
${activeTab === 'activities' && activityOptions.map((opt) => html`
<button
key=${opt.value}
class=${`profile-btn${activityValue === opt.value ? ' active' : ''}${opt.disabled ? ' locked' : ''}`}
style=${{ cursor: 'pointer', position: 'relative' }}
onClick=${() => {
if (opt.disabled) { setProPromptSource(`variant:${opt.value}`); setProPromptDay(0); return; }
try { localStorage.setItem('sunscope_profile', 'outdoors'); } catch(e) {}
setActiveProfile('outdoors');
setOutdoorsVariantAndSave(opt.value);
setVisibleCols({ ...OUTDOORS_VARIANTS[opt.value].cols });
setIndoorMode('off');
setIndoorManaged(false);
}}
>
<span class="profile-btn-scene" style=${{ background: opt.scene, filter: opt.disabled ? 'grayscale(100%)' : 'none' }}></span>
<span class="profile-btn-label">${opt.disabled ? '🔒 ' : ''}${opt.name}</span>
${opt.disabled && html`<span class="profile-lock-badge">🔒</span>`}
</button>
`)}
${activeTab === 'work' && workOptions.map((opt) => html`
<button
key=${opt.value}
class=${`profile-btn${workValue === opt.value ? ' active' : ''}${opt.disabled ? ' locked' : ''}`}
style=${{ cursor: 'pointer', position: 'relative' }}
onClick=${() => {
if (opt.disabled) { setProPromptSource(`variant:${opt.value}`); setProPromptDay(0); return; }
if (opt.value === 'farming') { activateProfile('farming'); return; }
try { localStorage.setItem('sunscope_profile', 'outdoors'); } catch(e) {}
setActiveProfile('outdoors');
setOutdoorsVariantAndSave(opt.value);
setVisibleCols({ ...OUTDOORS_VARIANTS[opt.value].cols });
if (opt.value === 'office') { setBuildingType('office'); setIndoorMode('on'); setIndoorManaged(false); }
else { setIndoorMode('off'); setIndoorManaged(false); }
}}
>
<span class="profile-btn-scene" style=${{ background: opt.scene, filter: opt.disabled ? 'grayscale(100%)' : 'none' }}></span>
<span class="profile-btn-label">${opt.disabled ? '🔒 ' : ''}${opt.name}</span>
${opt.disabled && html`<span class="profile-lock-badge">🔒</span>`}
</button>
`)}
</div>
</div>
</div>
${configItems.length > 0 && html`
<div class="col-toggles-config">
${configItems.map((item) => html`
<${Fragment} key=${item.key}>
${item.key === mainConfigKey && mainBadge}
<span class=${`col-toggles-config-item${item.key === mainConfigKey ? ' col-toggles-config-item--main' : ''}`}>
${item.node}
</span>
</${Fragment}>`)}
</div>`}
</div>
</div>
</${Fragment}>`;
}
+181 -218
View File
@@ -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}>
<div class="filter-profiles">
<div class="profile-tabs">
${[['common','Common'],['places','Places'],['activities','Activities'],['work','Work']].map(([key, label]) => html`
<button
key=${key}
class=${`profile-tab${activeTab === key ? ' active' : ''}`}
onClick=${() => setActiveTab(key)}
>${label}</button>
`)}
</div>
<div class="profile-scroll-wrap" ref=${profileWrapRef}>
<span class="profile-scroll-chevron left" aria-hidden="true"></span>
<span class="profile-scroll-chevron right" aria-hidden="true"></span>
<div class="profile-scroll" ref=${profileScrollRef}>
${activeTab === 'common' && profileButtonOrder.map((key) => {
const profile = FILTER_PROFILES[key];
const locked = profile.proOnly && !isPro;
return html`
<button
key=${key}
class=${`profile-btn${activeProfile === key ? ' active' : ''}${locked ? ' locked' : ''}`}
style=${{ cursor: 'pointer', position: 'relative' }}
title=${locked ? `Profile ${profile.label} is part of SunScope Extra` : ''}
onClick=${() => {
if (locked) { setProPromptSource(`profile:${key}`); setProPromptDay(0); return; }
activateProfile(key);
}}
>
<span class="profile-btn-scene" style=${{ background: profile.scene, filter: locked ? 'grayscale(100%)' : 'none' }}>${profile.scene.includes('url(') ? null : profile.icon}</span>
<span class="profile-btn-label">${locked ? '🔒 ' : ''}${profile.label}</span>
${locked && html`<span class="profile-lock-badge">🔒</span>`}
</button>`;
})}
${activeTab === 'places' && placeOptions.map((opt) => html`
<button
key=${opt.value}
class=${`profile-btn${placeValue === opt.value ? ' active' : ''}${opt.disabled ? ' locked' : ''}`}
style=${{ cursor: 'pointer', position: 'relative' }}
onClick=${() => {
if (opt.disabled) { setProPromptSource(`variant:${opt.value}`); setProPromptDay(0); return; }
try { localStorage.setItem('sunscope_profile', 'outdoors'); } catch(e) {}
setActiveProfile('outdoors');
setOutdoorsVariantAndSave(opt.value);
setVisibleCols({ ...OUTDOORS_VARIANTS[opt.value].cols });
setIndoorMode('off');
setIndoorManaged(false);
}}
>
<span class="profile-btn-scene" style=${{ background: opt.scene, filter: opt.disabled ? 'grayscale(100%)' : 'none' }}></span>
<span class="profile-btn-label">${opt.disabled ? '🔒 ' : ''}${opt.name}</span>
${opt.disabled && html`<span class="profile-lock-badge">🔒</span>`}
</button>
`)}
${activeTab === 'activities' && activityOptions.map((opt) => html`
<button
key=${opt.value}
class=${`profile-btn${activityValue === opt.value ? ' active' : ''}${opt.disabled ? ' locked' : ''}`}
style=${{ cursor: 'pointer', position: 'relative' }}
onClick=${() => {
if (opt.disabled) { setProPromptSource(`variant:${opt.value}`); setProPromptDay(0); return; }
try { localStorage.setItem('sunscope_profile', 'outdoors'); } catch(e) {}
setActiveProfile('outdoors');
setOutdoorsVariantAndSave(opt.value);
setVisibleCols({ ...OUTDOORS_VARIANTS[opt.value].cols });
setIndoorMode('off');
setIndoorManaged(false);
}}
>
<span class="profile-btn-scene" style=${{ background: opt.scene, filter: opt.disabled ? 'grayscale(100%)' : 'none' }}></span>
<span class="profile-btn-label">${opt.disabled ? '🔒 ' : ''}${opt.name}</span>
${opt.disabled && html`<span class="profile-lock-badge">🔒</span>`}
</button>
`)}
${activeTab === 'work' && workOptions.map((opt) => html`
<button
key=${opt.value}
class=${`profile-btn${workValue === opt.value ? ' active' : ''}${opt.disabled ? ' locked' : ''}`}
style=${{ cursor: 'pointer', position: 'relative' }}
onClick=${() => {
if (opt.disabled) { setProPromptSource(`variant:${opt.value}`); setProPromptDay(0); return; }
if (opt.value === 'farming') { activateProfile('farming'); return; }
try { localStorage.setItem('sunscope_profile', 'outdoors'); } catch(e) {}
setActiveProfile('outdoors');
setOutdoorsVariantAndSave(opt.value);
setVisibleCols({ ...OUTDOORS_VARIANTS[opt.value].cols });
if (opt.value === 'office') { setBuildingType('office'); setIndoorMode('on'); setIndoorManaged(false); }
else { setIndoorMode('off'); setIndoorManaged(false); }
}}
>
<span class="profile-btn-scene" style=${{ background: opt.scene, filter: opt.disabled ? 'grayscale(100%)' : 'none' }}></span>
<span class="profile-btn-label">${opt.disabled ? '🔒 ' : ''}${opt.name}</span>
${opt.disabled && html`<span class="profile-lock-badge">🔒</span>`}
</button>
`)}
</div>
</div>
<div class=${`active-profile-row ${rowGrpClass}`} onClick=${openPanel} role="button" tabIndex="0"
onKeyDown=${(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openPanel(); } }}
style=${rowGradient ? { '--row-bg': rowGradient } : undefined}
title="Change profile & settings">
<span class="active-profile-row-item">
<span class="active-profile-row-label">Profile</span>
<span class="active-profile-row-value">${mainLabel}</span>
</span>
${mainConfigValue && html`
<span class="active-profile-row-sep" aria-hidden="true">·</span>
<span class="active-profile-row-item">
<span class="active-profile-row-label">Viewing</span>
<span class="active-profile-row-value">${mainConfigValue}</span>
</span>`}
<span class="active-profile-row-edit" aria-hidden="true">Change </span>
</div>
<div class=${`utci-day-tabs-wrap${canScrollLeft ? ' fade-left' : ''}${canScrollRight ? ' fade-right' : ''}`}>
@@ -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' })}
</span>
<span style=${{ display: 'block', margin: '6px auto 0px', lineHeight: 1 }}>
${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} />`}
</span>
${dayHi !== null && html`
<span style=${{
+17
View File
@@ -178,6 +178,23 @@ export const VARIANT_DEFAULT_ENV = {
// Left-to-right order of the profile buttons in the top filter bar.
export const profileButtonOrder = ['basic', 'home', 'vehicle', 'pets', 'alltemps', 'showall', 'custom'];
// Derives which config selector / table metric is the "main" thermal
// selection for the active profile — the one whose calc drives the day-tab
// hi/lo readout. Shared by DayTabs (day-tab numbers + summary chip),
// ConfigPanel (the gold "main" badge) and app.js (the edge-tab label).
export function deriveProfileMain(activeProfile, outdoorsVariant) {
const isHomeOrOffice = activeProfile === 'home' || (activeProfile === 'outdoors' && outdoorsVariant === 'office');
const isVehicleProfile = activeProfile === 'vehicle' || (activeProfile === 'outdoors' && outdoorsVariant === 'driver');
const isPetsProfile = activeProfile === 'pets';
const mainConfigKey = isHomeOrOffice ? 'indoor' : isVehicleProfile ? 'vehicle' : isPetsProfile ? 'fur' : 'solar';
const mainField = isHomeOrOffice ? 'indoorT' : isVehicleProfile ? 'vehicleT' : isPetsProfile ? 'furSurfaceT' : 'utciAdj';
const secondaryField = isHomeOrOffice ? 'managedT' : isPetsProfile ? 'shadeT' : isVehicleProfile ? null : 'shadeT';
const mainLabel = activeProfile === 'outdoors'
? (OUTDOORS_VARIANTS[outdoorsVariant]?.name || FILTER_PROFILES.outdoors.label)
: (FILTER_PROFILES[activeProfile]?.label || 'SunSoak');
return { isHomeOrOffice, isVehicleProfile, isPetsProfile, mainConfigKey, mainField, secondaryField, mainLabel };
}
// Emoji glyph per place/activity variant.
export const variantIcons = {
urban: '🏙️',
+8 -1
View File
@@ -37,6 +37,11 @@ import {
checkFrost,
} from './events/weather-checks.js';
import { dynamicCosmicMessage } from './events/dynamic-message.js';
import { activityChance } from './events/activity-profile.js';
// Cosmic events only surface as a banner once estimated viewing conditions
// are at least this good (see ./events/activity-profile.js for the model).
const ACTIVITY_THRESHOLD = 80;
// Re-export so callers that imported these from events.js keep working.
export { getUpcomingEvents } from './events/almanac-calendar.js';
@@ -103,7 +108,9 @@ export function getActiveEvents(rows, location) {
: new Date().toISOString().slice(0, 10);
const cosmicHits = COSMIC_CALENDAR
.filter(ev => dateStr >= ev.start && dateStr <= ev.end)
.map(ev => Object.assign({}, ev, { message: dynamicCosmicMessage(ev, dateStr) }));
.map(ev => Object.assign({}, ev, { chance: activityChance(ev, dateStr) }))
.filter(ev => ev.chance >= ACTIVITY_THRESHOLD)
.map(ev => Object.assign(ev, { message: dynamicCosmicMessage(ev, dateStr) }));
// 3. Weather-derived events (all that match, not just first)
const weatherEvents = [];
+52
View File
@@ -0,0 +1,52 @@
// ------------------------------------------------------------------------
// activity-profile.js - Estimates the % chance of a good viewing on a given
// day for a multi-day cosmic event (meteor shower, conjunction), based on
// how far that day sits from the event's peak.
//
// The calendar data only has start/end/peak dates, not real activity
// curves, so this is a simplified heuristic (not live forecast data):
// chance falls off exponentially from 100% at peak, halving every
// `riseTau`/`decayTau` days on the approach/departure side. Values below
// are rough approximations of each shower's real-world activity profile
// (sharp showers like the Quadrantids get small tau, broad ones like the
// Eta Aquariids get larger tau) - good enough to gate "is this worth
// looking up for" without pretending to be precise astronomy.
//
// Single-day events (start === end) are always 100% - they only ever
// appear in COSMIC_CALENDAR on their one active day anyway.
// ------------------------------------------------------------------------
const ACTIVITY_PROFILES = {
quadrantids: { riseTau: 0.3, decayTau: 0.4 },
lyrids: { riseTau: 0.6, decayTau: 0.7 },
'eta-aquariids': { riseTau: 3.5, decayTau: 3.0 },
perseids: { riseTau: 2.2, decayTau: 1.8 },
orionids: { riseTau: 1.5, decayTau: 1.5 },
leonids: { riseTau: 0.5, decayTau: 0.6 },
geminids: { riseTau: 1.0, decayTau: 1.0 },
ursids: { riseTau: 0.4, decayTau: 0.4 },
'mars-conjunction': { riseTau: 1.0, decayTau: 1.0 },
'venus-jupiter-conjunction': { riseTau: 1.0, decayTau: 1.0 },
};
const DEFAULT_PROFILE = { riseTau: 1.0, decayTau: 1.0 };
function baseKey(id) {
return String(id).replace(/-\d{4}$/, '');
}
export function getActivityProfile(id) {
return ACTIVITY_PROFILES[baseKey(id)] || DEFAULT_PROFILE;
}
// Returns an integer 0-100.
export function activityChance(ev, dateStr) {
if (!ev.peak || ev.start === ev.end) return 100;
const today = new Date(dateStr + 'T00:00Z');
const peak = new Date(ev.peak + 'T00:00Z');
const diffDays = (today - peak) / 86400000;
const { riseTau, decayTau } = getActivityProfile(ev.id);
const tau = diffDays <= 0 ? riseTau : decayTau;
const pct = 100 * Math.pow(0.5, Math.abs(diffDays) / tau);
return Math.round(pct);
}
+1 -1
View File
@@ -153,7 +153,7 @@ export const cosmic = [
textColor: '#ffc8d8',
type: 'cosmic',
nightOnly: true,
start: '2026-06-30', end: '2026-07-02',
start: '2026-06-30', end: '2026-07-02', peak: '2026-07-01',
},
];
+9 -6
View File
@@ -2,11 +2,13 @@
// dynamic-message.js - Rewrites a cosmic event's message based on where
// today sits vs. the peak.
//
// Before peak : "is active and building - peak on <date>. <detail>"
// On peak -1d : "peaks tonight - <detail>"
// After peak : "is past its peak (<date>) but still possibly visible - <detail>"
// Before peak : "is active and building - peak on <date>. <detail> (~NN% chance...)"
// On peak -1d : "peaks tonight - <detail> (~NN% chance...)"
// After peak : "is past its peak (<date>) but still possibly visible - <detail> (~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;
}
}
+6
View File
@@ -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,
+47 -14
View File
@@ -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.
// -------------------------------------------------------------------