UI Overhaul
Profiles now in popup
Current profile always on screen
Hidden columns only when edit neeeded
Add pulldowns in to profile config
Customised the day tabs for vehicle, indoor and pet
This commit is contained in:
fraxle
2026-07-18 15:45:20 +01:00
parent f92e191a44
commit 916efca55e
13 changed files with 1594 additions and 610 deletions
+161 -17
View File
@@ -16,7 +16,7 @@
// ------------------------------------------------------------------------
import { h, Fragment } from '../vendor/preact.js';
import { useState, useEffect, useRef } from '../vendor/preact-hooks.js';
import { useState, useEffect, useLayoutEffect, useRef } from '../vendor/preact-hooks.js';
import htm from '../vendor/htm.js';
import {
skyGradientForElev, skyFillForElev, grassFillForElev,
@@ -99,9 +99,25 @@ const MOON_TEX = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADD
// groupedLeft - bool - fuse right side with a VentPill
// isLastChild - bool - restore right border-radius when no sibling follows
// -------------------------------------------------------------------
// Finds the nearest scrollable ancestor (e.g. the profile/config bottom
// sheet's scrolling body) so dropdown panels can size themselves to the
// space actually available before that ancestor clips them, rather than
// guessing against the full viewport and getting cropped invisibly.
function getScrollParent(el) {
let node = el && el.parentElement;
while (node && node !== document.body) {
const style = getComputedStyle(node);
if (/(auto|scroll)/.test(style.overflowY)) return node;
node = node.parentElement;
}
return document.documentElement;
}
export function CustomSelect({ value, options, onChange, hideLabel, hidingLabel, buttonLabel, isOn, noHide, groupedLeft, isLastChild, grpClass, sceneStyle, sceneContent }) {
const [open, setOpen] = useState(false);
const [flipRight, setFlipRight] = useState(false);
const [flipUp, setFlipUp] = useState(false);
const [maxPanelHeight, setMaxPanelHeight] = useState(null);
const [panelLeft, setPanelLeft] = useState(null);
const wrapRef = useRef(null);
const panelRef = useRef(null);
@@ -122,13 +138,37 @@ export function CustomSelect({ value, options, onChange, hideLabel, hidingLabel,
};
}, [open]);
// After the panel renders, check if it overflows the right viewport edge.
// If so, flip it to right-align; otherwise keep it left-aligned.
useEffect(() => {
if (!open) { setFlipRight(false); return; }
if (!panelRef.current) return;
const rect = panelRef.current.getBoundingClientRect();
setFlipRight(rect.right > window.innerWidth - 8);
// After the panel renders, centre it under/over the button, then clamp
// that position so it never spills past the real clipping ancestor's
// edges (e.g. the profile/config bottom sheet's scrolling body, which
// also clips horizontally whenever it clips vertically — see
// getScrollParent above — so comparing against the browser viewport alone
// isn't enough). Also flip to whichever side (up/down) has more room, and
// cap the panel's height to whatever space is actually available on that
// side so a long list scrolls internally instead of being cropped
// invisibly.
// useLayoutEffect (not useEffect) so this measure-and-position happens
// before the browser paints the first frame — otherwise the panel visibly
// flashes in its unclamped spot for one frame before correcting itself.
useLayoutEffect(() => {
if (!open) { setFlipUp(false); setMaxPanelHeight(null); setPanelLeft(null); return; }
if (!panelRef.current || !wrapRef.current) return;
const rect = panelRef.current.getBoundingClientRect();
const wrapRect = wrapRef.current.getBoundingClientRect();
const bound = getScrollParent(wrapRef.current).getBoundingClientRect();
const desiredLeft = wrapRect.left + wrapRect.width / 2 - rect.width / 2;
const minLeft = bound.left + 4;
const maxLeft = bound.right - 4 - rect.width;
const clampedLeft = Math.max(minLeft, Math.min(desiredLeft, maxLeft));
setPanelLeft(clampedLeft - wrapRect.left);
const spaceBelow = bound.bottom - wrapRect.bottom;
const spaceAbove = wrapRect.top - bound.top;
const overflowsDown = rect.bottom > bound.bottom - 8;
const flip = overflowsDown && spaceAbove > spaceBelow;
setFlipUp(flip);
setMaxPanelHeight(Math.max(80, Math.floor((flip ? spaceAbove : spaceBelow) - 12)));
}, [open]);
// Build the label shown on the button
@@ -145,6 +185,11 @@ export function CustomSelect({ value, options, onChange, hideLabel, hidingLabel,
(groupedLeft && isLastChild) ? 'last-child' : '',
].filter(Boolean).join(' ');
// Longer lists (SunSoak, Indoors, ...) lay out as two columns so every
// option is visible at a glance instead of relying on people to notice
// they need to scroll a cropped list.
const useColumns = options.length > 5;
return html`
<span class=${`cs-wrap${open ? ' open' : ''}`} ref=${wrapRef}>
<button
@@ -158,7 +203,11 @@ export function CustomSelect({ value, options, onChange, hideLabel, hidingLabel,
<span class="cs-arrow"></span>
</button>
${open && html`
<div class=${`cs-panel${flipRight ? ' cs-panel--right' : ''}`} ref=${panelRef} role="listbox">
<div class=${`cs-panel${flipUp ? ' cs-panel--up' : ''}${useColumns ? ' cs-panel--cols' : ''}`} ref=${panelRef} role="listbox"
style=${{
...(panelLeft != null ? { left: panelLeft + 'px' } : {}),
...(maxPanelHeight != null ? { maxHeight: maxPanelHeight + 'px' } : {}),
}}>
${isOn && !noHide && html`
<span
class="cs-option"
@@ -405,7 +454,7 @@ export function WindVane({ bearing, size = 30 }) {
// elev: solar elevation in degrees (negative = night)
// dt: Date used for moon phase
// -------------------------------------------------------------------
export function CloudIcon({ category, size = 30, elev = 90, dt = new Date(), filled = false }) {
export function CloudIcon({ category, size = 30, elev = 90, dt = new Date(), filled = false, showSun = true }) {
const r = size / 2;
const brass = '#c8922a';
const ink = '#2a1a08';
@@ -451,7 +500,7 @@ export function CloudIcon({ category, size = 30, elev = 90, dt = new Date(), fil
return html`
<svg width=${size} height=${size} viewBox=${`0 0 ${size} ${size}`}
style=${{ flexShrink: 0, display: 'inline-block', verticalAlign: 'middle', overflow: 'visible' }}>
${category === 'clear' && elev >= 0 && html`
${category === 'clear' && elev >= 0 && showSun && html`
${sun(size * 0.50, size * 0.38, size * 0.16)}`}
${category === 'clear' && elev < 0 && html`
<path d=${`M ${size*0.45} ${size*0.15}
@@ -464,17 +513,20 @@ export function CloudIcon({ category, size = 30, elev = 90, dt = new Date(), fil
<circle cx=${size*0.18} cy=${size*0.66} r=${size*0.018} fill=${brass} opacity="0.55" />
<circle cx=${size*0.88} cy=${size*0.48} r=${size*0.018} fill=${brass} opacity="0.55" />`}
${category === 'wispy' && elev >= 0 && html`
${sun(size * 0.66, size * 0.30, size * 0.13)}
${showSun && sun(size * 0.66, size * 0.30, size * 0.13)}
${line(size * 0.14, size * 0.58, size * 0.64, size * 0.58, brass, sw * 0.85, 0.85)}
${line(size * 0.24, size * 0.74, size * 0.78, size * 0.74, ink, sw * 0.78, 0.68)}`}
${category === 'wispy' && elev < 0 && html`
${mist(size * 0.18, brass)}
${line(size * 0.18, size * 0.56, size * 0.60, size * 0.56, muted, sw * 0.7, 0.6)}`}
${category === 'scattered' && elev >= 0 && html`
${sun(size * 0.72, size * 0.24, size * 0.11)}
<path d=${cloudPath(-size * 0.12, size * 0.02, 0.64)} fill=${filled ? 'rgba(255,255,255,0.88)' : 'none'} stroke=${filled ? 'none' : ink}
stroke-width=${sw} stroke-linecap=${cap} stroke-linejoin=${join} />
${!filled && line(size * 0.12, size * 0.60, size * 0.38, size * 0.60, brass, sw * 0.72, 0.78)}`}
${showSun && sun(size * 0.72, size * 0.24, size * 0.11)}
<path d=${cloudPath(-size * 0.20, -size * 0.32, 0.36)} fill=${filled ? 'rgba(255,255,255,0.88)' : 'none'} stroke=${filled ? 'none' : muted}
stroke-width=${sw * 0.65} stroke-linecap=${cap} stroke-linejoin=${join} opacity=${filled ? 1 : 0.85} />
<path d=${cloudPath(size * 0.18, -size * 0.38, 0.30)} fill=${filled ? 'rgba(255,255,255,0.88)' : 'none'} stroke=${filled ? 'none' : muted}
stroke-width=${sw * 0.6} stroke-linecap=${cap} stroke-linejoin=${join} opacity=${filled ? 1 : 0.85} />
<path d=${cloudPath(0, -size * 0.20, 0.28)} fill=${filled ? 'rgba(255,255,255,0.88)' : 'none'} stroke=${filled ? 'none' : muted}
stroke-width=${sw * 0.55} stroke-linecap=${cap} stroke-linejoin=${join} opacity=${filled ? 1 : 0.85} />`}
${category === 'scattered' && elev < 0 && html`
<path d=${cloudPath(0, -size * 0.2425, 0.82)} fill=${filled ? 'rgba(255,255,255,0.88)' : 'none'} stroke=${filled ? 'none' : ink}
stroke-width=${sw} stroke-linecap=${cap} stroke-linejoin=${join} />
@@ -1265,3 +1317,95 @@ export function PrecipIcon({ precip = 0, snow = 0, size = 28, filled = false })
</svg>`
}
// HOUSEICON / CARICON - Brass Line glyphs for the day-tab shade when a
// modelled indoor or vehicle-cabin temperature stands in for the outdoor
// weather icon.
// -------------------------------------------------------------------
export function HouseIcon({ size = 30 }) {
const sw = Math.max(1.25, size * 0.058);
const brass = '#c8922a';
const ink = '#2a1a08';
return html`
<svg width=${size} height=${size} viewBox=${`0 0 ${size} ${size}`}
style=${{ flexShrink: 0, display: 'inline-block', verticalAlign: 'middle', overflow: 'visible' }}>
<path d=${`M ${size*0.13} ${size*0.52}
L ${size*0.50} ${size*0.15}
L ${size*0.87} ${size*0.52}`}
fill="none" stroke=${brass} stroke-width=${sw} stroke-linecap="round" stroke-linejoin="round" />
<path d=${`M ${size*0.22} ${size*0.46}
V ${size*0.85}
H ${size*0.78}
V ${size*0.46}`}
fill="none" stroke=${ink} stroke-width=${sw} stroke-linecap="round" stroke-linejoin="round" />
<path d=${`M ${size*0.42} ${size*0.85}
V ${size*0.60}
H ${size*0.58}
V ${size*0.85}`}
fill="none" stroke=${ink} stroke-width=${sw*0.8} stroke-linecap="round" stroke-linejoin="round" />
</svg>`;
}
// Body path is Lucide's "car" icon (ISC licence), used verbatim on its
// native 24x24 grid rather than hand-plotted, since freehand coordinates
// kept coming out lopsided.
export function CarIcon({ size = 30 }) {
const brass = '#c8922a';
const ink = '#2a1a08';
return html`
<svg width=${size} height=${size} viewBox="0 0 24 24" fill="none"
style=${{ flexShrink: 0, display: 'inline-block', verticalAlign: 'middle', overflow: 'visible' }}>
<path d="M19 17h2c.6 0 1-.4 1-1v-3c0-.9-.7-1.7-1.5-1.9C18.7 10.6 16 10 16 10s-1.3-1.4-2.2-2.3c-.5-.4-1.1-.7-1.8-.7H5c-.6 0-1.1.4-1.4.9l-1.4 2.9A3.7 3.7 0 0 0 2 12v4c0 .6.4 1 1 1h2"
stroke=${brass} stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
<path d="M9 17h6" stroke=${ink} stroke-width="1.8" stroke-linecap="round" />
<circle cx="7" cy="17" r="2" fill="#fdf6e8" stroke=${ink} stroke-width="1.6" />
<circle cx="17" cy="17" r="2" fill="#fdf6e8" stroke=${ink} stroke-width="1.6" />
</svg>`;
}
// FOGICON - reuses the same closed puffy-cloud outline as PrecipIcon's
// cloud (rather than a foreign icon-set shape) so it sits consistently
// alongside the rain/snow glyphs, with mist bands underneath.
export function FogIcon({ size = 30 }) {
const brass = '#c8922a';
const ink = '#2a1a08';
const sw = Math.max(1.25, size * 0.055);
return html`
<svg width=${size} height=${size} viewBox=${`0 0 ${size} ${size}`}
style=${{ flexShrink: 0, display: 'inline-block', verticalAlign: 'middle', overflow: 'visible' }}>
<path d=${`M ${size*0.18} ${size*0.36}
H ${size*0.72}
C ${size*0.86} ${size*0.36}, ${size*0.88} ${size*0.20}, ${size*0.72} ${size*0.19}
C ${size*0.66} ${size*0.04}, ${size*0.42} ${size*0.02}, ${size*0.34} ${size*0.17}
C ${size*0.22} ${size*0.14}, ${size*0.14} ${size*0.24}, ${size*0.18} ${size*0.36} Z`}
fill="none" stroke=${brass} stroke-width=${sw} stroke-linecap="round" stroke-linejoin="round" />
<line x1=${size*0.14} y1=${size*0.46} x2=${size*0.86} y2=${size*0.46}
stroke=${ink} stroke-width=${sw*0.85} stroke-linecap="round" />
<line x1=${size*0.22} y1=${size*0.58} x2=${size*0.78} y2=${size*0.58}
stroke=${ink} stroke-width=${sw*0.85} stroke-linecap="round" />
<line x1=${size*0.10} y1=${size*0.70} x2=${size*0.90} y2=${size*0.70}
stroke=${ink} stroke-width=${sw*0.85} stroke-linecap="round" opacity="0.7" />
</svg>`;
}
export function IceIcon({ size = 30 }) {
const icy = '#3f73c4';
return html`
<svg width=${size} height=${size} viewBox="0 0 24 24" fill="none"
style=${{ flexShrink: 0, display: 'inline-block', verticalAlign: 'middle', overflow: 'visible' }}>
<g stroke=${icy} stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="m10 20-1.25-2.5L6 18" />
<path d="M10 4 8.75 6.5 6 6" />
<path d="m14 20 1.25-2.5L18 18" />
<path d="m14 4 1.25 2.5L18 6" />
<path d="m17 21-3-6h-4" />
<path d="m17 3-3 6 1.5 3" />
<path d="M2 12h6.5L10 9" />
<path d="m20 10-1.5 2 1.5 2" />
<path d="M22 12h-6.5L14 15" />
<path d="m4 10 1.5 2L4 14" />
<path d="m7 21 3-6-1.5-3" />
<path d="m7 3 3 6h4" />
</g>
</svg>`;
}