1.9.3
Updated scope temperature arc to gradient Fix banner scaling Add Pulldown to scope
This commit is contained in:
@@ -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 */
|
||||
}
|
||||
|
||||
+21
-2
@@ -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 ----------------------
|
||||
|
||||
+15
-2
@@ -287,7 +287,7 @@ export function UTCIForecast() {
|
||||
<div class="utci-fetch-time">Last update: ${fetchedAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</div>`}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="scope-col">
|
||||
<${ScopeReticle}
|
||||
value=${currentRow?.utciAdj ?? null}
|
||||
cat=${currentCat}
|
||||
@@ -298,7 +298,20 @@ export function UTCIForecast() {
|
||||
activeEvent=${lensEvent}
|
||||
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 class="header-right">
|
||||
|
||||
+44
-33
@@ -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"
|
||||
>
|
||||
<span class="cs-btn-overlay"></span>
|
||||
<span class="cs-btn-label">${btnLabel}</span>
|
||||
<span class="cs-arrow"></span>
|
||||
</button>
|
||||
@@ -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`<g dangerouslySetInnerHTML=${{ __html: overlaySVG }} />`;
|
||||
})()}
|
||||
${stressBands.map((b, i) => html`
|
||||
<path key=${i} d=${bandArcPath(b)} fill="none"
|
||||
stroke=${b.color} stroke-width="4" stroke-linecap="butt" />`)}
|
||||
${gradArc.map((seg, i) => html`
|
||||
<path key=${i} d=${seg.d} fill="none"
|
||||
stroke=${seg.color} stroke-width="4" stroke-linecap="butt" />`)}
|
||||
<circle cx=${cx} cy=${cy} r=${R} fill="none" stroke="#c9b08a" stroke-width="1.5" />
|
||||
${ticks.map((t, i) => html`
|
||||
<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">
|
||||
${value.toFixed(1)}°
|
||||
</text>
|
||||
<text x=${cx} y=${cy + 15} text-anchor="middle"
|
||||
fill=${readoutMutedColor} font-size="6.5"
|
||||
<text x=${cx} y=${cy + 14} text-anchor="middle"
|
||||
fill=${readoutColor} font-size="8"
|
||||
font-family="Manrope, sans-serif" font-weight="700" letter-spacing="1.2">
|
||||
UTCI NOW
|
||||
SUNSOAK INDEX
|
||||
</text>
|
||||
<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">
|
||||
${cat.label.toUpperCase()}
|
||||
</text>
|
||||
${utciEnvShort && html`
|
||||
<text x=${cx} y="163" text-anchor="middle"
|
||||
fill=${readoutMutedColor} font-size="5.5"
|
||||
font-family="Manrope, sans-serif" font-weight="700" letter-spacing="0.8"
|
||||
opacity="0.85">
|
||||
${utciEnvShort.toUpperCase()} EST.
|
||||
</text>`}
|
||||
<${Fragment}>
|
||||
<rect x=${cx - 34} y="158" width="68" height="11" rx="3"
|
||||
fill=${glowColor} opacity="0.22" />
|
||||
<text x=${cx} y="166" text-anchor="middle"
|
||||
fill=${readoutColor} font-size="7"
|
||||
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"
|
||||
fill=${readoutMutedColor} font-size="8"
|
||||
|
||||
@@ -485,36 +485,63 @@ export function useAppState() {
|
||||
if (bannerIndex >= 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]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user