Files
sunscope/assets/js/components/ConfigPanel.js
T
fraxle b10e0da25f 3.5.9
Updated pet calcs to be more accurate
Fixed some layout issues
Updated profile popup
2026-07-30 00:01:51 +01:00

398 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ------------------------------------------------------------------------
// 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 dropdownRef = 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]);
// From tablet up the panel drops *out of* the active-profile-row rather
// than sliding up from the floating button (which is mobile-only), so it
// has to be anchored to that row's live bottom edge — it's sticky on
// desktop and scrolls away on tablet, hence remeasuring on scroll/resize.
// Mobile (<=640px) ignores the var entirely and keeps the bottom sheet.
useEffect(() => {
const el = dropdownRef.current;
if (!el) return;
const measure = () => {
const row = document.querySelector('.active-profile-row');
const top = row ? Math.max(0, row.getBoundingClientRect().bottom) : 0;
el.style.setProperty('--profile-anchor-top', `${Math.round(top)}px`);
// Not enough room below the row for the panel's content? Fall back to
// the mobile bottom-sheet layout, which gets the full viewport height.
const body = el.querySelector('.profile-dropdown-body');
const content = body ? body.scrollHeight : 0;
el.classList.toggle('is-bottom-sheet', content > window.innerHeight - top - 16);
};
measure();
if (!open) return;
window.addEventListener('scroll', measure, true);
window.addEventListener('resize', measure);
return () => {
window.removeEventListener('scroll', measure, true);
window.removeEventListener('resize', measure);
};
}, [open, activeTab, activeProfile, outdoorsVariant]);
// 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 ref=${dropdownRef} 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>
<!-- Invisible, always-rendered copy of the Activities cards. It has no
visual presence (height:0, hidden) but stays in normal flow so its
natural width feeds the dropdown's fit-content sizing — this keeps
the popup a constant width across all four tabs instead of
resizing as the user switches tabs, using Activities (the widest
tab) as that fixed reference size. -->
<div class="profile-scroll-sizer" aria-hidden="true">
<div class="profile-scroll">
${activityOptions.map((opt) => html`
<span key=${'sizer-' + opt.value} class="profile-btn" tabIndex="-1">
<span class="profile-btn-scene"></span>
<span class="profile-btn-label">${opt.name}</span>
</span>
`)}
</div>
</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}>`;
}