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:
+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=${{
|
||||
|
||||
Reference in New Issue
Block a user