From 0c8cb1ba375bd9865abae159e755616c6dc702ec Mon Sep 17 00:00:00 2001 From: Fraxle Date: Wed, 27 May 2026 00:52:24 +0100 Subject: [PATCH] 1.9.3 Updated scope temperature arc to gradient Fix banner scaling Add Pulldown to scope --- assets/css/header.css | 90 ++++++++++++++++++++++++++++++++++ assets/css/ui.css | 23 ++++++++- assets/js/app.js | 17 ++++++- assets/js/components.js | 77 ++++++++++++++++------------- assets/js/hooks/useAppState.js | 55 +++++++++++++++------ 5 files changed, 211 insertions(+), 51 deletions(-) diff --git a/assets/css/header.css b/assets/css/header.css index ab28d57..846ecf1 100644 --- a/assets/css/header.css +++ b/assets/css/header.css @@ -83,6 +83,92 @@ display: block; } +.scope-col { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; +} + +.scope-env-row { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + border-top: 1px solid #c9b08a; + width: 220px; /* match desktop/tablet dial width */ +} + +.scope-env-label { + font-family: Manrope, sans-serif; + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.1em; + color: #b09870; +} + +.scope-env-row .cs-wrap, +.scope-env-row .cs-btn { + width: 100%; +} + +.scope-env-row .cs-btn { + justify-content: center; + position: relative; + isolation: isolate; + border-radius: 999px; + box-shadow: inset 0 1px 3px rgba(255,255,255,0.30), 0 2px 8px rgba(0,0,0,0.18); + transition: color 0.35s ease; +} + +.scope-env-row .cs-btn .cs-btn-overlay { + display: block; + position: absolute; + inset: 0; + border-radius: inherit; + background: #c8922a; + opacity: 0; + transition: opacity 0.35s ease; + pointer-events: none; + z-index: 0; +} + +.scope-env-row .cs-btn.on:hover .cs-btn-overlay { + opacity: 1; +} + +/* Label stays above the overlay */ +.scope-env-row .cs-btn .cs-btn-label { + position: relative; + z-index: 1; +} + +/* Arrow pinned right, above the overlay */ +.scope-env-row .cs-arrow { + position: absolute; + right: 12px; + z-index: 1; +} + +.scope-env-row .cs-panel, +.scope-env-row .cs-option { + text-align: center; +} + +/* Per-environment gradient themes — !important keeps the gradient fixed + under the brass hover overlay (ui.css :hover rules load later). */ +.scope-env-row[data-env="open"] .cs-btn.on { background: linear-gradient(135deg, #7ec8e3 0%, #c8922a 100%) !important; border-color: #a07020 !important; color: #fff; } +.scope-env-row[data-env="forest"] .cs-btn.on { background: linear-gradient(135deg, #1b5e3b 0%, #57bf8a 100%) !important; border-color: #1b5e3b !important; color: #fff; } +.scope-env-row[data-env="alpine"] .cs-btn.on { background: linear-gradient(135deg, #a8cfe8 0%, #dff0fa 100%) !important; border-color: #6aaedd !important; color: #1a3a58; } +.scope-env-row[data-env="beach"] .cs-btn.on { background: linear-gradient(135deg, #2a8fc8 0%, #e8d87a 100%) !important; border-color: #1e78b0 !important; color: #fff; } +.scope-env-row[data-env="river"] .cs-btn.on { background: linear-gradient(135deg, #2e6e5e 0%, #78c4a8 100%) !important; border-color: #2e6e5e !important; color: #fff; } +.scope-env-row[data-env="desert"] .cs-btn.on { background: linear-gradient(135deg, #b86820 0%, #e8c458 100%) !important; border-color: #a05818 !important; color: #fff; } +.scope-env-row[data-env="openwater"] .cs-btn.on { background: linear-gradient(135deg, #0c4878 0%, #2898d8 100%) !important; border-color: #0c4878 !important; color: #fff; } + +/* On hover the brass overlay covers the interior — force white text for contrast */ +.scope-env-row .cs-btn.on:hover { color: #fff; } + /* The search box on the right of the header */ .utci-search-wrap { position: relative; @@ -192,6 +278,10 @@ margin: 0 auto; /* centre the dial */ } + .scope-env-row { + width: 160px; /* match dial width on mobile */ + } + .utci-current-loc { margin-top: 4px; /* tighten gap above location on mobile */ } diff --git a/assets/css/ui.css b/assets/css/ui.css index d2e4e41..a2699bb 100644 --- a/assets/css/ui.css +++ b/assets/css/ui.css @@ -285,8 +285,8 @@ /* ── EVENT BANNER — mobile fixes ────────────────────────────────────── */ @media (max-width: 640px) { .event-banner-wrap { - margin-top: 8px; /* push banner clear of the fixed nav bar */ - margin-bottom: 0; /* no gap between banner and header on mobile */ + margin-top: 0; /* JS pins the banner's top to the nav's bottom edge */ + margin-bottom: 0; } .event-banner { @@ -442,6 +442,11 @@ .cs-vent.grp-felt.on:hover { background: rgba(213,233,226,0.80); color: #0c7d4e; border-color: #0c7d4e; } .cs-vent.grp-surface.on:hover { background: rgba(211,224,238,0.80); color: #1e5aa0; border-color: #1e5aa0; } +/* Hover overlay — invisible by default, only activated in specific contexts */ +.cs-btn-overlay { + display: none; +} + /* The chevron arrow inside the button */ .cs-arrow { display: inline-block; @@ -832,6 +837,20 @@ } +/* At ≤640px the top nav becomes position:fixed (72px tall, see base.css), + so the app needs 96px of top padding to clear it. This must come AFTER the + max-width:800px rule above — which sets padding-top:52px for the tablet + range where the nav is NOT fixed — so it wins the cascade on true mobile. */ +@media (max-width: 640px) { + .utci-app { + padding-top: 96px; + } + + .utci-fetch-time { + text-align: center; /* override the default left-align on mobile */ + } +} + /* -- CUSTOM SELECT BUTTON - mobile label clipping ---------------------- diff --git a/assets/js/app.js b/assets/js/app.js index bd663e4..03ef04c 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -287,7 +287,7 @@ export function UTCIForecast() {
Last update: ${fetchedAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
`} -
+
<${ScopeReticle} value=${currentRow?.utciAdj ?? null} cat=${currentCat} @@ -298,7 +298,20 @@ export function UTCIForecast() { activeEvent=${lensEvent} utciEnvShort=${UTCI_ENVIRONMENTS[utciEnv]?.shortLabel ?? null} /> - +
+ Surroundings + <${CustomSelect} + value=${utciEnv} + isOn=${true} + noHide=${true} + grpClass="grp-felt" + options=${Object.entries(UTCI_ENVIRONMENTS).map(([k, v]) => ({ + value: k, + label: v.label, + }))} + onChange=${(v) => setUtciEnv(v)} + /> +
diff --git a/assets/js/components.js b/assets/js/components.js index 950b8aa..2495fb8 100644 --- a/assets/js/components.js +++ b/assets/js/components.js @@ -10,7 +10,7 @@ // SkyScope({ elev, dt, glob, size }) small porthole per hour // WindVane({ bearing, size }) compass-arrow widget // CloudIcon({ category, size, elev, dt }) cloud/sun glyph -// ScopeReticle({ value, cat, loading, elev, dt, glob, activeEvent }) +// ScopeReticle({ value, cat, loading, elev, dt, glob, activeEvent, utciEnvShort }) // large UTCI dial // PrecipIcon({ precip, snow, size }) rain/snow icon // ------------------------------------------------------------------------ @@ -92,6 +92,7 @@ export function CustomSelect({ value, options, onChange, hideLabel, hidingLabel, onClick=${(e) => { e.stopPropagation(); setOpen(o => !o); }} type="button" > + ${btnLabel} @@ -454,32 +455,39 @@ export function ScopeReticle({ value, cat, loading, elev = 0, dt = new Date(), g major, medium, }; }); - const stressBands = [ - { min: -40, max: -27, color: '#5a9bdc' }, - { min: -27, max: -13, color: '#5a9bdc' }, - { min: -13, max: 0, color: '#6ca9e1' }, - { min: 0, max: 9, color: '#93ccd9' }, - { min: 9, max: 18, color: '#92d4a3' }, - { min: 18, max: 26, color: '#dae15f' }, - { min: 26, max: 32, color: '#f8a13f' }, - { min: 32, max: 38, color: '#e15333' }, - { min: 38, max: 46, color: '#ae181c' }, - { min: 46, max: 50, color: '#8d0616' }, - ]; function fracToXY(frac, r) { const deg = 135 + frac * 270; const rad = (deg * Math.PI) / 180; return [cx + r * Math.cos(rad), cy + r * Math.sin(rad)]; } - function bandArcPath(band) { - const f1 = Math.min(1, Math.max(0, (band.min + 10) / 60)); - const f2 = Math.min(1, Math.max(0, (band.max + 10) / 60)); - const arcR = R - 18; + // Gradient colour stops: frac 0 = −10 °C, frac 1 = 50 °C + const ARC_GRAD = [ + [0, [106, 169, 225]], + [0.167, [147, 204, 217]], + [0.317, [146, 212, 163]], + [0.467, [218, 225, 95]], + [0.600, [248, 161, 63]], + [0.700, [225, 83, 51]], + [0.800, [174, 24, 28]], + [1.000, [141, 6, 22]], + ]; + function arcColorAt(f) { + for (let i = 0; i < ARC_GRAD.length - 1; i++) { + const [f1, c1] = ARC_GRAD[i], [f2, c2] = ARC_GRAD[i + 1]; + if (f <= f2) { + const t = (f - f1) / (f2 - f1); + return `rgb(${Math.round(c1[0]+(c2[0]-c1[0])*t)},${Math.round(c1[1]+(c2[1]-c1[1])*t)},${Math.round(c1[2]+(c2[2]-c1[2])*t)})`; + } + } + return 'rgb(141,6,22)'; + } + const arcR = R - 18; + const gradArc = Array.from({ length: 90 }, (_, i) => { + const f1 = i / 90, f2 = (i + 1) / 90; const [x1, y1] = fracToXY(f1, arcR); const [x2, y2] = fracToXY(f2, arcR); - const large = (f2 - f1) * 270 > 180 ? 1 : 0; - return `M ${x1} ${y1} A ${arcR} ${arcR} 0 ${large} 1 ${x2} ${y2}`; - } + return { d: `M ${x1} ${y1} A ${arcR} ${arcR} 0 0 1 ${x2} ${y2}`, color: arcColorAt((f1+f2)/2) }; + }); let needleX = cx, needleY = cy + 54; if (value != null) { const frac = Math.min(1, Math.max(0, (value + 10) / 60)); @@ -569,9 +577,9 @@ export function ScopeReticle({ value, cat, loading, elev = 0, dt = new Date(), g if (!overlaySVG) return null; return html``; })()} - ${stressBands.map((b, i) => html` - `)} + ${gradArc.map((seg, i) => html` + `)} ${ticks.map((t, i) => html` ${value.toFixed(1)}° - - UTCI NOW + SUNSOAK INDEX ${cat.label.toUpperCase()} ${utciEnvShort && html` - - ${utciEnvShort.toUpperCase()} EST. - `} + <${Fragment}> + + + ${utciEnvShort.toUpperCase()} + + `} ` : html`= activeEvents.length) setBannerIndex(0); }, [activeEvents.length]); - // ── BANNER HEIGHT → shell padding-top ──────────────────────────────── - // Floats the banner over the page. Padding only grows while open so - // shorter slides never cause the page to jump up. Resets on close. + // ── BANNER POSITION + SHELL PADDING ─────────────────────────────────── + // The banner is position:absolute so it floats over content. We (1) pin its + // top to the bottom edge of the site-nav so it sits flush below the nav on + // every screen size, and (2) add matching padding-top to the shell so the + // page content sits just below the banner. + // On close: keep the banner pinned to the nav and animate the padding back + // to 0 so content slides up smoothly as the banner collapses. const bannerMaxHeightRef = useRef(0); useEffect(() => { const shell = document.querySelector('.utci-shell'); - if (!shell) return; + const wrap = document.querySelector('.event-banner-wrap'); + if (!shell || !wrap) return; + + // The site-nav's outer height is the banner's resting top edge. The banner + // lives inside the shell, so translate that into the shell's coordinate + // space by subtracting the shell's own offset from the page top. + const bannerTop = () => { + const navEl = document.getElementById('site-nav'); + const navOuterHeight = navEl ? navEl.offsetHeight : 0; + return navOuterHeight - shell.offsetTop; + }; if (!bannerVisible) { - bannerMaxHeightRef.current = 0; - shell.style.paddingTop = ''; - return; + // Keep the collapsing banner pinned to the nav's bottom edge, and animate + // padding back to zero matching the banner collapse duration. + wrap.style.top = bannerTop() + 'px'; + shell.style.transition = 'padding-top 0.45s cubic-bezier(0.4, 0, 0.2, 1)'; + shell.style.paddingTop = '0px'; + const tid = setTimeout(() => { + shell.style.transition = ''; + shell.style.paddingTop = ''; + bannerMaxHeightRef.current = 0; + }, 500); + return () => clearTimeout(tid); } - const applyPadding = () => { - const wrap = document.querySelector('.event-banner-wrap'); - if (!wrap) return; - const h = wrap.getBoundingClientRect().height; - if (h > bannerMaxHeightRef.current) { + const applyPadding = (allowShrink = false) => { + const top = bannerTop(); + wrap.style.top = top + 'px'; + const h = top + wrap.getBoundingClientRect().height; + if (allowShrink || h > bannerMaxHeightRef.current) { bannerMaxHeightRef.current = h; + shell.style.transition = ''; // no transition while open/resizing shell.style.paddingTop = h + 'px'; } }; + // Pin the banner immediately so it appears flush under the nav, then size + // the content padding once the open animation settles. + wrap.style.top = bannerTop() + 'px'; + const onResize = () => applyPadding(true); const tid = setTimeout(applyPadding, 460); - window.addEventListener('resize', applyPadding); + window.addEventListener('resize', onResize); return () => { clearTimeout(tid); - window.removeEventListener('resize', applyPadding); + window.removeEventListener('resize', onResize); }; }, [bannerVisible, bannerIndex, activeEvents.length]);