Updated scope temperature arc to gradient
Fix banner scaling
Add Pulldown to scope
This commit is contained in:
Fraxle
2026-05-27 00:52:24 +01:00
parent d2952cddd2
commit 0c8cb1ba37
5 changed files with 211 additions and 51 deletions
+90
View File
@@ -83,6 +83,92 @@
display: block; 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 */ /* The search box on the right of the header */
.utci-search-wrap { .utci-search-wrap {
position: relative; position: relative;
@@ -192,6 +278,10 @@
margin: 0 auto; /* centre the dial */ margin: 0 auto; /* centre the dial */
} }
.scope-env-row {
width: 160px; /* match dial width on mobile */
}
.utci-current-loc { .utci-current-loc {
margin-top: 4px; /* tighten gap above location on mobile */ margin-top: 4px; /* tighten gap above location on mobile */
} }
+21 -2
View File
@@ -285,8 +285,8 @@
/* ── EVENT BANNER — mobile fixes ────────────────────────────────────── */ /* ── EVENT BANNER — mobile fixes ────────────────────────────────────── */
@media (max-width: 640px) { @media (max-width: 640px) {
.event-banner-wrap { .event-banner-wrap {
margin-top: 8px; /* push banner clear of the fixed nav bar */ margin-top: 0; /* JS pins the banner's top to the nav's bottom edge */
margin-bottom: 0; /* no gap between banner and header on mobile */ margin-bottom: 0;
} }
.event-banner { .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-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; } .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 */ /* The chevron arrow inside the button */
.cs-arrow { .cs-arrow {
display: inline-block; 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 ---------------------- /* -- CUSTOM SELECT BUTTON - mobile label clipping ----------------------
+15 -2
View File
@@ -287,7 +287,7 @@ export function UTCIForecast() {
<div class="utci-fetch-time">Last update: ${fetchedAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</div>`} <div class="utci-fetch-time">Last update: ${fetchedAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</div>`}
</div> </div>
<div> <div class="scope-col">
<${ScopeReticle} <${ScopeReticle}
value=${currentRow?.utciAdj ?? null} value=${currentRow?.utciAdj ?? null}
cat=${currentCat} cat=${currentCat}
@@ -298,7 +298,20 @@ export function UTCIForecast() {
activeEvent=${lensEvent} activeEvent=${lensEvent}
utciEnvShort=${UTCI_ENVIRONMENTS[utciEnv]?.shortLabel ?? null} utciEnvShort=${UTCI_ENVIRONMENTS[utciEnv]?.shortLabel ?? null}
/> />
<div class="scope-env-row" data-env=${utciEnv}>
<span class="scope-env-label">Surroundings</span>
<${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)}
/>
</div>
</div> </div>
<div class="header-right"> <div class="header-right">
+43 -32
View File
@@ -10,7 +10,7 @@
// SkyScope({ elev, dt, glob, size }) small porthole per hour // SkyScope({ elev, dt, glob, size }) small porthole per hour
// WindVane({ bearing, size }) compass-arrow widget // WindVane({ bearing, size }) compass-arrow widget
// CloudIcon({ category, size, elev, dt }) cloud/sun glyph // 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 // large UTCI dial
// PrecipIcon({ precip, snow, size }) rain/snow icon // 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); }} onClick=${(e) => { e.stopPropagation(); setOpen(o => !o); }}
type="button" type="button"
> >
<span class="cs-btn-overlay"></span>
<span class="cs-btn-label">${btnLabel}</span> <span class="cs-btn-label">${btnLabel}</span>
<span class="cs-arrow"></span> <span class="cs-arrow"></span>
</button> </button>
@@ -454,32 +455,39 @@ export function ScopeReticle({ value, cat, loading, elev = 0, dt = new Date(), g
major, medium, 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) { function fracToXY(frac, r) {
const deg = 135 + frac * 270; const deg = 135 + frac * 270;
const rad = (deg * Math.PI) / 180; const rad = (deg * Math.PI) / 180;
return [cx + r * Math.cos(rad), cy + r * Math.sin(rad)]; return [cx + r * Math.cos(rad), cy + r * Math.sin(rad)];
} }
function bandArcPath(band) { // Gradient colour stops: frac 0 = 10 °C, frac 1 = 50 °C
const f1 = Math.min(1, Math.max(0, (band.min + 10) / 60)); const ARC_GRAD = [
const f2 = Math.min(1, Math.max(0, (band.max + 10) / 60)); [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 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 [x1, y1] = fracToXY(f1, arcR);
const [x2, y2] = fracToXY(f2, arcR); const [x2, y2] = fracToXY(f2, arcR);
const large = (f2 - f1) * 270 > 180 ? 1 : 0; return { d: `M ${x1} ${y1} A ${arcR} ${arcR} 0 0 1 ${x2} ${y2}`, color: arcColorAt((f1+f2)/2) };
return `M ${x1} ${y1} A ${arcR} ${arcR} 0 ${large} 1 ${x2} ${y2}`; });
}
let needleX = cx, needleY = cy + 54; let needleX = cx, needleY = cy + 54;
if (value != null) { if (value != null) {
const frac = Math.min(1, Math.max(0, (value + 10) / 60)); 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; if (!overlaySVG) return null;
return html`<g dangerouslySetInnerHTML=${{ __html: overlaySVG }} />`; return html`<g dangerouslySetInnerHTML=${{ __html: overlaySVG }} />`;
})()} })()}
${stressBands.map((b, i) => html` ${gradArc.map((seg, i) => html`
<path key=${i} d=${bandArcPath(b)} fill="none" <path key=${i} d=${seg.d} fill="none"
stroke=${b.color} stroke-width="4" stroke-linecap="butt" />`)} stroke=${seg.color} stroke-width="4" stroke-linecap="butt" />`)}
<circle cx=${cx} cy=${cy} r=${R} fill="none" stroke="#c9b08a" stroke-width="1.5" /> <circle cx=${cx} cy=${cy} r=${R} fill="none" stroke="#c9b08a" stroke-width="1.5" />
${ticks.map((t, i) => html` ${ticks.map((t, i) => html`
<line key=${i} x1=${t.x1} y1=${t.y1} x2=${t.x2} y2=${t.y2} <line key=${i} x1=${t.x1} y1=${t.y1} x2=${t.x2} y2=${t.y2}
@@ -621,23 +629,26 @@ export function ScopeReticle({ value, cat, loading, elev = 0, dt = new Date(), g
font-family="Fraunces, serif" font-weight="700"> font-family="Fraunces, serif" font-weight="700">
${value.toFixed(1)}° ${value.toFixed(1)}°
</text> </text>
<text x=${cx} y=${cy + 15} text-anchor="middle" <text x=${cx} y=${cy + 14} text-anchor="middle"
fill=${readoutMutedColor} font-size="6.5" fill=${readoutColor} font-size="8"
font-family="Manrope, sans-serif" font-weight="700" letter-spacing="1.2"> font-family="Manrope, sans-serif" font-weight="700" letter-spacing="1.2">
UTCI NOW SUNSOAK INDEX
</text> </text>
<text x=${cx} y=${cy + 23} text-anchor="middle" <text x=${cx} y=${cy + 23} text-anchor="middle"
fill=${glowColor} font-size="7" fill=${glowColor} font-size="9"
font-family="Manrope, sans-serif" font-weight="700" letter-spacing="0.5"> font-family="Manrope, sans-serif" font-weight="700" letter-spacing="0.5">
${cat.label.toUpperCase()} ${cat.label.toUpperCase()}
</text> </text>
${utciEnvShort && html` ${utciEnvShort && html`
<text x=${cx} y="163" text-anchor="middle" <${Fragment}>
fill=${readoutMutedColor} font-size="5.5" <rect x=${cx - 34} y="158" width="68" height="11" rx="3"
font-family="Manrope, sans-serif" font-weight="700" letter-spacing="0.8" fill=${glowColor} opacity="0.22" />
opacity="0.85"> <text x=${cx} y="166" text-anchor="middle"
${utciEnvShort.toUpperCase()} EST. fill=${readoutColor} font-size="7"
</text>`} font-family="Manrope, sans-serif" font-weight="700" letter-spacing="0.6">
${utciEnvShort.toUpperCase()}
</text>
</>`}
</>` </>`
: html`<text x=${cx} y=${cy + 5} text-anchor="middle" : html`<text x=${cx} y=${cy + 5} text-anchor="middle"
fill=${readoutMutedColor} font-size="8" fill=${readoutMutedColor} font-size="8"
+40 -13
View File
@@ -485,36 +485,63 @@ export function useAppState() {
if (bannerIndex >= activeEvents.length) setBannerIndex(0); if (bannerIndex >= activeEvents.length) setBannerIndex(0);
}, [activeEvents.length]); }, [activeEvents.length]);
// ── BANNER HEIGHT → shell padding-top ──────────────────────────────── // ── BANNER POSITION + SHELL PADDING ───────────────────────────────────
// Floats the banner over the page. Padding only grows while open so // The banner is position:absolute so it floats over content. We (1) pin its
// shorter slides never cause the page to jump up. Resets on close. // 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); const bannerMaxHeightRef = useRef(0);
useEffect(() => { useEffect(() => {
const shell = document.querySelector('.utci-shell'); 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) { if (!bannerVisible) {
bannerMaxHeightRef.current = 0; // 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 = ''; shell.style.paddingTop = '';
return; bannerMaxHeightRef.current = 0;
}, 500);
return () => clearTimeout(tid);
} }
const applyPadding = () => { const applyPadding = (allowShrink = false) => {
const wrap = document.querySelector('.event-banner-wrap'); const top = bannerTop();
if (!wrap) return; wrap.style.top = top + 'px';
const h = wrap.getBoundingClientRect().height; const h = top + wrap.getBoundingClientRect().height;
if (h > bannerMaxHeightRef.current) { if (allowShrink || h > bannerMaxHeightRef.current) {
bannerMaxHeightRef.current = h; bannerMaxHeightRef.current = h;
shell.style.transition = ''; // no transition while open/resizing
shell.style.paddingTop = h + 'px'; 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); const tid = setTimeout(applyPadding, 460);
window.addEventListener('resize', applyPadding); window.addEventListener('resize', onResize);
return () => { return () => {
clearTimeout(tid); clearTimeout(tid);
window.removeEventListener('resize', applyPadding); window.removeEventListener('resize', onResize);
}; };
}, [bannerVisible, bannerIndex, activeEvents.length]); }, [bannerVisible, bannerIndex, activeEvents.length]);