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) => {
|
||||
|
||||
+161
-17
@@ -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>`;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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=${{
|
||||
|
||||
@@ -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
@@ -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 = [];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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.
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user