3.5
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:
+191
-284
@@ -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: '-8–2°C' },
|
||||
{ t: 9, label: 'Cool', value: '2–11°C' },
|
||||
{ t: 18, label: 'Comfortable', value: '11–25°C', bold: true },
|
||||
{ t: 28, label: 'Warm', value: '25–32°C' },
|
||||
{ t: 36, label: 'Caution', value: '32–40°C' },
|
||||
{ t: 46, label: 'Extreme', value: '40–52°C' },
|
||||
{ t: 60, label: 'Danger', value: '52°C+' },
|
||||
].map((b, i) => {
|
||||
// Exact same recipe as the human legend above (same 135deg
|
||||
// light/mid/dark sweep, same luminance-based font colour) -
|
||||
// just reading from petAirTempRgb instead of airTempRgb.
|
||||
const rgb = petAirTempRgb(b.t) || [200, 200, 200];
|
||||
const mid = `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`;
|
||||
const light = `rgb(${rgb.map(c => Math.min(255, Math.round(c + (255-c)*0.30))).join(',')})`;
|
||||
const dark = `rgb(${rgb.map(c => Math.round(c * 0.96)).join(',')})`;
|
||||
const bg = `linear-gradient(135deg, ${light} 0%, ${mid} 60%, ${dark} 100%)`;
|
||||
const lum = (rgb[0]*299 + rgb[1]*587 + rgb[2]*114) / 1000;
|
||||
const fg = lum > 165 ? '#1a1a1a' : '#ffffff';
|
||||
return html`<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) => {
|
||||
|
||||
Reference in New Issue
Block a user