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:
+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>`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user