diff --git a/assets/sunscope.css b/assets/sunscope.css index cca2ab6..38df579 100644 --- a/assets/sunscope.css +++ b/assets/sunscope.css @@ -84,7 +84,11 @@ body { min-height: 100vh; padding: 52px 28px 32px; position: relative; - overflow-x: hidden; + /* overflow-x: clip (not hidden) — `clip` clips horizontal overflow + WITHOUT making this a scroll container, so `position: sticky` on + descendants (table ) can still stick relative to the viewport. + `hidden` would create a containing block that breaks sticky. */ + overflow-x: clip; } /* Two big soft glows in the corners — pure decoration. @@ -353,13 +357,83 @@ body { sunscope.js (the confidence bands). This file only handles spacing, typography, and the active-tab underline. */ +/* Wrapper that hosts the scrollable strip, edge fades, and chevrons. */ +.utci-day-tabs-wrap { + position: relative; + margin-bottom: 18px; +} + +/* Edge fade gradients — only show when there's content to scroll to. */ +.utci-day-tabs-wrap::before, +.utci-day-tabs-wrap::after { + content: ''; + position: absolute; + top: 0; + bottom: 2px; /* leave the underline border visible */ + width: 40px; + pointer-events: none; + z-index: 1; + opacity: 0; + transition: opacity 0.2s; +} +.utci-day-tabs-wrap::before { + left: 0; + background: linear-gradient(to right, #f5edd6 0%, rgba(245,237,214,0) 100%); +} +.utci-day-tabs-wrap::after { + right: 0; + background: linear-gradient(to left, #f5edd6 0%, rgba(245,237,214,0) 100%); +} +.utci-day-tabs-wrap.fade-left::before { opacity: 1; } +.utci-day-tabs-wrap.fade-right::after { opacity: 1; } + +/* Chevron buttons — sit on top of the fades. */ +.utci-day-scroll { + position: absolute; + top: 50%; + transform: translateY(-50%); + width: 30px; + height: 30px; + border-radius: 50%; + border: 1px solid #d4c0a0; + background: #fffcf2; + color: #6b4f2a; + font-size: 20px; + font-family: Fraunces, serif; + line-height: 1; + cursor: pointer; + z-index: 2; + display: flex; + align-items: center; + justify-content: center; + padding: 0 0 2px 0; + box-shadow: 0 1px 4px rgba(80,55,20,0.12); + transition: opacity 0.2s, background 0.15s, transform 0.15s; +} +.utci-day-scroll:hover { + background: #fef3dc; +} +.utci-day-scroll.left { left: -2px; } +.utci-day-scroll.right { right: -2px; } +.utci-day-scroll.hidden { + opacity: 0; + pointer-events: none; +} + .utci-day-tabs { display: flex; - margin-bottom: 18px; border-bottom: 2px solid #d4c0a0; + overflow-x: auto; + scroll-behavior: smooth; + scrollbar-width: none; /* Firefox */ + -ms-overflow-style: none; /* old Edge */ +} +.utci-day-tabs::-webkit-scrollbar { + display: none; /* Chrome/Safari */ } .utci-day-tab { + flex-shrink: 0; padding: 12px 24px; cursor: pointer; font-family: JetBrains Mono, monospace; @@ -452,18 +526,49 @@ body { /* ── 7. HOURLY TABLE ────────────────────────────────────────────────── */ .utci-table-wrap { - overflow-x: auto; + /* No overflow on this wrapper — having any overflow here would make + the wrapper a scroll container, which would break sticky-to-viewport + on the .utci-thead-sticky strip inside. + The body scroller (.utci-tbody-scroll) owns the horizontal scrollbar. */ border: 1.5px solid #c9b08a; background: #fffcf2; } +/* ── STICKY HEADER STRIP ──────────────────────────────────────────────── + Sits above the body and locks to the top of the viewport while the + page scrolls. Has overflow:hidden so the inner .utci-thead-track can + be translateX'd by JS to follow the body's scrollLeft. */ +.utci-thead-sticky { + position: sticky; + top: 0; + z-index: 5; + overflow: hidden; + background: #f0e4c4; + border-bottom: 2px solid #c9b08a; + box-shadow: 0 2px 5px rgba(74, 52, 32, 0.10); +} + +/* The shifted track inside the sticky header — JS sets transform:translate3d + to keep its columns aligned with the body scroller's scrollLeft. */ +.utci-thead-track { + will-change: transform; +} + +/* Body scroller — owns the horizontal scrollbar for the whole table. */ +.utci-tbody-scroll { + overflow-x: auto; + overflow-y: visible; +} + .utci-table { width: 100%; border-collapse: collapse; font-size: 14px; } -/* Column headings (Hour, Air, RH, Wind...) */ +/* Column headings (Hour, Air, RH, Wind...). + NOTE: stickiness is handled by the .utci-thead-sticky wrapper, not + the itself — see the new wrapper rules higher up. */ .utci-table th { background: #f0e4c4; color: #6b4f2a; @@ -475,9 +580,6 @@ body { padding: 14px 12px; text-align: right; white-space: nowrap; - position: sticky; - top: 0; /* sticks to the top while scrolling */ - border-bottom: 2px solid #c9b08a; } /* The Hour column aligns left, the rest align right */ @@ -510,6 +612,19 @@ body { font-variant-numeric: tabular-nums; /* stops digits jiggling between rows */ white-space: nowrap; color: #4a3420; + /* Baseline minimum column width — the JS width-sync uses max(60, head, + body) so very-narrow data columns ("Air 5.0") still feel comfortable + to read instead of being squashed. */ + min-width: 60px; +} + +/* Subtle vertical column dividers — same pale cream as the row + dividers so the table reads as a soft grid, not a hard one. The + last column has no divider so it doesn't double-up with the + container border. */ +.utci-table th:not(:last-child), +.utci-table td:not(:last-child) { + border-right: 1px solid #ede4cc; } @@ -564,6 +679,17 @@ body { font-size: 14px; } +/* Hero variant used for the UTCI+P column — the headline number. + Bigger, bolder, more breathing room so the eye lands here first. */ +.utci-cell-hero { + padding: 7px 16px; + font-size: 19px; + font-weight: 700; + min-width: 76px; + letter-spacing: 0.01em; + box-shadow: 0 1px 0 rgba(0,0,0,0.15) inset, 0 0 0 1px rgba(255,255,255,0.08) inset; +} + .utci-band-label { font-family: JetBrains Mono, monospace; font-size: 10px; diff --git a/assets/sunscope.js b/assets/sunscope.js index c63910f..54a84ce 100644 --- a/assets/sunscope.js +++ b/assets/sunscope.js @@ -42,7 +42,7 @@ // Tiny framework imports — these are the only three "outside" dependencies. // All three live in assets/vendor/ so the site has no external requests. import { h, render, 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'; // `html` is the magic tagged-template function. Use it like: @@ -296,6 +296,89 @@ function precipPenalty(precipMm, snowCmH, windMs) { return -Math.round(penalty * 10) / 10; } +// ═══════════════════════════════════════════════════════════════════ +// WIND COMPASS — meteorological bearing (deg FROM) → 8-point label. +// ─────────────────────────────────────────────────────────────────── +// 0/360 = wind FROM north. The pointer in WindVane should rotate so +// the arrow's tail points to this bearing (i.e. shows where the wind +// comes from), matching how real weather vanes behave. +// ═══════════════════════════════════════════════════════════════════ +function windCompass8(deg) { + if (deg == null || isNaN(deg)) return { label: '—', snapped: 0 }; + const dirs = ['N','NE','E','SE','S','SW','W','NW']; + const idx = Math.round(((deg % 360) + 360) % 360 / 45) % 8; + return { label: dirs[idx], snapped: idx * 45 }; +} + +// ═══════════════════════════════════════════════════════════════════ +// UV-A / UV-B SPLIT (estimate, not measurement). +// ─────────────────────────────────────────────────────────────────── +// Open-Meteo gives a total erythemal UV index. UV-A reaches the +// surface much more reliably than UV-B; UV-B is far more sensitive +// to solar elevation because of atmospheric path length. +// +// Cheap model: +// At noon (sun overhead) the UV-B share of total UV index is ~15%, +// UV-A about 85%. Below ~10° solar elevation, UV-B falls off fast. +// We return two pseudo-"index" numbers so the columns are in the +// same units the user already understands. +// Label these as estimates in the UI. +// ═══════════════════════════════════════════════════════════════════ +function uvSplit(uv, elevDeg) { + if (!uv || uv <= 0 || elevDeg <= 0) return { uvA: 0, uvB: 0 }; + const sinE = Math.sin(elevDeg * Math.PI / 180); + const uvbFr = Math.max(0.005, Math.min(0.15, 0.15 * Math.pow(sinE, 1.6))); + const uvaFr = 1 - uvbFr; + return { uvA: uv * uvaFr, uvB: uv * uvbFr }; +} + +// ═══════════════════════════════════════════════════════════════════ +// SUNBURN TIME — minutes to MED for the chosen Fitzpatrick skin type. +// ─────────────────────────────────────────────────────────────────── +// Standard erythemal model: time_min ≈ base_minutes[type] / UV_index. +// Numbers are the well-known "unprotected, midday, no sunscreen" +// reference times at UV = 1. Returns Infinity when UV is 0 (night). +// ═══════════════════════════════════════════════════════════════════ +const SKIN_TYPES = { + I: { name: 'I · Very fair', base: 67 }, + II: { name: 'II · Fair', base: 100 }, + III: { name: 'III · Light', base: 200 }, + IV: { name: 'IV · Mid', base: 300 }, + V: { name: 'V · Dark', base: 400 }, + VI: { name: 'VI · Very dark', base: 500 }, +}; +function sunburnMinutes(uv, skinType = 'II') { + if (!uv || uv <= 0) return Infinity; + const base = (SKIN_TYPES[skinType] || SKIN_TYPES.II).base; + return base / uv; +} +function burnLabel(mins) { + if (!isFinite(mins)) return '—'; + if (mins >= 480) return '8h+'; + if (mins >= 60) return `${(mins/60).toFixed(1)}h`; + return `${Math.round(mins)}m`; +} + +// ═══════════════════════════════════════════════════════════════════ +// CLOUD CATEGORY — pick one of 4 icon styles from low/mid/high split. +// ─────────────────────────────────────────────────────────────────── +// Returns: 'clear' | 'wispy' | 'scattered' | 'overcast' +// Uses total cover for headline level, but biases towards 'wispy' +// when only high cloud is present (cirrus barely blocks the sun). +// ═══════════════════════════════════════════════════════════════════ +function cloudCategory(total, low, mid, high) { + const t = total ?? 0; + const l = low ?? 0; + const m = mid ?? 0; + const h = high ?? 0; + if (t < 10) return 'clear'; + // Mostly high cloud with little low/mid → wispy regardless of % total + if (h > 40 && l < 25 && m < 25) return 'wispy'; + if (t < 40) return 'wispy'; + if (t < 75) return 'scattered'; + return 'overcast'; +} + // ═══════════════════════════════════════════════════════════════════ // CONFIDENCE BANDS — smooth high-noon → sunset gradient on day tabs. // ─────────────────────────────────────────────────────────────────── @@ -390,6 +473,18 @@ function skyFillForElev(e) { return '#1a1538'; } +// Grass palette — mirrors skyFillForElev but for the ground. +// `top` = grass blade tips (catches sky light), `bot` = soil shadow. +function grassFillForElev(e) { + if (e > 30) return { top: '#86c44a', bot: '#558a2e' }; // bright noon grass + if (e > 10) return { top: '#7ab846', bot: '#4d802c' }; // mid-day + if (e > 3) return { top: '#b8a83e', bot: '#7a6a26' }; // golden-hour glow + if (e > -3) return { top: '#c08048', bot: '#7a4a2a' }; // sunrise/sunset embers + if (e > -8) return { top: '#665884', bot: '#3e3556' }; // civil twilight purple + if (e > -14) return { top: '#363356', bot: '#1c1a34' }; // nautical night + return { top: '#201d3a', bot: '#0d0b20' }; // astronomical night +} + // ═══════════════════════════════════════════════════════════════════ // SKYSCOPE — the little porthole circle next to each hour. // ─────────────────────────────────────────────────────────────────── @@ -407,7 +502,7 @@ function skyFillForElev(e) { // • sunR — the sun's drawn radius // • the stroke colour #c8922a is the brass — change for a different metal // ═══════════════════════════════════════════════════════════════════ -function SkyScope({ elev, dt, size = 22 }) { +function SkyScope({ elev, dt, size = 26 }) { const r = size / 2; const innerR = r - 1.2; const skyFill = skyFillForElev(elev); @@ -420,7 +515,10 @@ function SkyScope({ elev, dt, size = 22 }) { const phaseOffset = Math.cos(2 * Math.PI * phase) * moonR; const moonLitFromRight = phase < 0.5; const shadowCx = r + (moonLitFromRight ? -phaseOffset : phaseOffset); - const clipId = `scope-clip-${size}-${Math.round(elev * 10)}-${Math.round(phase * 1000)}`; + const grass = grassFillForElev(elev); + const uid = `${size}-${Math.round(elev * 10)}-${Math.round(phase * 1000)}`; + const clipId = `scope-clip-${uid}`; + const grassId = `scope-grass-${uid}`; return html` @@ -428,11 +526,14 @@ function SkyScope({ elev, dt, size = 22 }) { + + + + - ${isDay ? html` @@ -451,6 +552,150 @@ function SkyScope({ elev, dt, size = 22 }) { `; } +// ═══════════════════════════════════════════════════════════════════ +// WINDVANE — clean compass arrow on transparent background. +// ─────────────────────────────────────────────────────────────────── +// Traditional weather-vane behaviour: the ARROWHEAD points INTO the +// wind (toward the source). Wind from the north → head points north. +// +// props: +// bearing — degrees, 0 = wind FROM north +// size — pixel diameter (default 30 to match SkyScope) +// +// Tweakable bits: +// • arrowColor / nMarkerColor — line/fill colours +// • Tiny N letter sits just above the arrow tail for orientation +// ═══════════════════════════════════════════════════════════════════ +function WindVane({ bearing, size = 30 }) { + if (bearing == null || isNaN(bearing)) { + return html``; + } + const r = size / 2; + // The shaft is drawn pointing DOWN by default (head at the bottom). + // To place the head at the bearing direction, rotate by bearing + 180. + const rot = (bearing + 180) % 360; + const arrowColor = '#2a1a08'; + const headColor = '#c44a3a'; + const nColor = '#9a7d5a'; + // Geometry of the single-ended arrow (drawn pointing DOWN by default). + // After rotation by (bearing + 180), the head lands on the bearing + // direction — i.e. the side the wind is coming FROM. + const tipY = r * 0.15; // arrowhead tip + const headBase = r * 0.58; // bottom of the triangle head + const headW = r * 0.42; + const tailEndY = r * 1.78; // shaft's tail end + const tailDotR = Math.max(1.0, size * 0.06); + return html` + + + N + + + + + + + + + + `; +} + +// ═══════════════════════════════════════════════════════════════════ +// CLOUDICON — clouds + sun/moon on a fully transparent background. +// No rim, no disc. Day/night aware: when elev < 0, the sun is +// replaced with a phased moon (same logic as SkyScope). +// ─────────────────────────────────────────────────────────────────── +// category: 'clear' | 'wispy' | 'scattered' | 'overcast' +// elev: solar elevation in degrees (negative = night) +// dt: Date used for moon phase +// ═══════════════════════════════════════════════════════════════════ +function CloudIcon({ category, size = 30, elev = 90, dt = new Date() }) { + const r = size / 2; + const u = r; // half-extent for layout + const isNight = elev < -3; + // Phased moon helper — returns an SVG with the moon + shadow. + // Lit fraction direction matches SkyScope (waxing = lit from right). + const renderMoon = (cx, cy, moonR) => { + const phase = moonPhaseFraction(dt); + const phaseOffset = Math.cos(2 * Math.PI * phase) * moonR; + const moonLitFromRight = phase < 0.5; + const shadowCx = cx + (moonLitFromRight ? -phaseOffset : phaseOffset); + const clipId = `cmoon-${size}-${Math.round(cx)}-${Math.round(cy)}-${Math.round(phase * 1000)}`; + return html` + + + + + + + + + + `; + }; + return html` + + ${category === 'clear' && (isNight + ? html` + ${renderMoon(r, r, u*0.50)}` + : html` + + ${[0,45,90,135,180,225,270,315].map(a => { + const x1 = r + Math.sin(a*Math.PI/180) * u*0.65; + const y1 = r - Math.cos(a*Math.PI/180) * u*0.65; + const x2 = r + Math.sin(a*Math.PI/180) * u*0.95; + const y2 = r - Math.cos(a*Math.PI/180) * u*0.95; + return html``; + })} + + `)} + ${category === 'wispy' && html` + + ${isNight + ? renderMoon(r, r*0.95, u*0.42) + : html``} + + `} + ${category === 'scattered' && html` + + ${isNight + ? renderMoon(r+u*0.45, r-u*0.40, u*0.32) + : html``} + + + + + + + `} + ${category === 'overcast' && html` + + + + + + + `} + `; +} + // ═══════════════════════════════════════════════════════════════════ // SCOPERETICLE — the big circular UTCI dial in the page header. // ─────────────────────────────────────────────────────────────────── @@ -467,26 +712,27 @@ function SkyScope({ elev, dt, size = 22 }) { // widen the dial range by changing those numbers // ═══════════════════════════════════════════════════════════════════ function ScopeReticle({ value, cat, loading, elev = 0, dt = new Date() }) { - // ── Day/night indicator (new) ────────────────────────────────────── - // The sun-or-moon glyph orbits the dial like an hour hand: - // 12 o'clock = noon · 3 o'clock = morning (east/sunrise) - // 6 o'clock = midnight · 9 o'clock = evening (west/sunset) - // A subtle sky-colour wash sits behind the readout for atmosphere. - const hour = dt.getHours() + dt.getMinutes() / 60; - const orbitAngle = -((hour - 6) / 24) * 2 * Math.PI; // radians - const orbitR = 80; // outside the temperature arcs, riding the tick band - const dnX = 100 + orbitR * Math.cos(orbitAngle); - const dnY = 100 + orbitR * Math.sin(orbitAngle); + // ── Day/night scene behind the readout ───────────────────────────── + // The whole back of the lens is a SkyScope-style scene: sky on top, + // grass on the bottom, sun positioned by elevation (or moon at night + // with its phase shadow). Mirrors the little hourly SkyScopes. + const cx = 100, cy = 100, R = 86; const isDay = elev > -3; const skyFill = skyFillForElev(elev); + const grass = grassFillForElev(elev); const phase = moonPhaseFraction(dt); - // For the moon, use the same shadow-ellipse technique as SkyScope. - const moonR = 6; // slightly smaller so it fits the outer tick band - const phaseOffset = Math.cos(2 * Math.PI * phase) * moonR; + const lensR = 95; // full back of the dial + const elevClamped = Math.max(-30, Math.min(90, elev)); + const sunY = cy - Math.sin((elevClamped * Math.PI) / 180) * (lensR - 18); + const sunDrawR = 14; + const moonDrawR = 15; + const moonY = cy - 45; // float in the upper sky, away from the readout + const phaseOffset = Math.cos(2 * Math.PI * phase) * moonDrawR; const moonLitFromRight = phase < 0.5; - const moonShadowCx = dnX + (moonLitFromRight ? -phaseOffset : phaseOffset); - - const cx = 100, cy = 100, R = 86; + const moonShadowCx = cx + (moonLitFromRight ? -phaseOffset : phaseOffset); + // Readout text colours flip with day/night for legibility against the sky. + const readoutColor = isDay ? '#1e1208' : '#f5edd6'; + const readoutMutedColor = isDay ? '#9a7d5a' : '#d4c5a8'; const ticks = Array.from({ length: 36 }, (_, i) => { const deg = i * 10 - 90; const rad = (deg * Math.PI) / 180; @@ -541,14 +787,35 @@ function ScopeReticle({ value, cat, loading, elev = 0, dt = new Date() }) { + + + + + + + - - + + + + + ${isDay + ? html`<${Fragment}> + + + ` + : html`<${Fragment}> + + + + `} + ${stressBands.map((b, i) => html` `)} + stroke=${b.color} stroke-width="4" stroke-linecap="butt" />`)} ${ticks.map((t, i) => html` - - ${isDay - ? html`<${Fragment}> - - - ` - : html`<${Fragment}> - - - - - `} ${[[-1,-1],[1,-1],[-1,1],[1,1]].map(([sx, sy], i) => html` + stroke="#c8922a" stroke-width="5" stroke-linecap="round" opacity="0.18" /> + stroke="#c8922a" stroke-width="2.5" stroke-linecap="round" opacity="0.95" /> `} ${loading ? html`· · ·` + fill=${readoutMutedColor} font-size="11" font-family="monospace">· · ·` : value != null ? html`<${Fragment}> - ${value.toFixed(1)}° UTCI NOW @@ -611,7 +866,7 @@ function ScopeReticle({ value, cat, loading, elev = 0, dt = new Date() }) { ` : html` AWAITING `} @@ -656,6 +911,55 @@ function UTCIForecast() { const [selectedDay, setSelectedDay] = useState(0); // which day tab is active const [proPromptDay, setProPromptDay] = useState(null); // locked day clicked → show upsell card + // Day-tabs horizontal scrolling — chevrons show only when there's more + // content to reveal in that direction. Auto-scrolls active tab into view. + const dayTabsRef = useRef(null); + const [canScrollLeft, setCanScrollLeft] = useState(false); + const [canScrollRight, setCanScrollRight] = useState(false); + // Re-runs whenever the number of day tabs changes (e.g. when the + // forecast finishes loading and the tabs first appear). Also re-measures + // on scroll, on window resize, and via ResizeObserver if the element's + // own width changes (e.g. layout shifts when sidebar opens). + useEffect(() => { + const el = dayTabsRef.current; + if (!el) return; + const update = () => { + setCanScrollLeft(el.scrollLeft > 1); + setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1); + }; + update(); + el.addEventListener('scroll', update, { passive: true }); + window.addEventListener('resize', update); + let ro = null; + if (typeof ResizeObserver !== 'undefined') { + ro = new ResizeObserver(update); + ro.observe(el); + } + return () => { + el.removeEventListener('scroll', update); + window.removeEventListener('resize', update); + if (ro) ro.disconnect(); + }; + }, [forecast]); + useEffect(() => { + const el = dayTabsRef.current; + if (!el) return; + const activeTab = el.querySelector('.utci-day-tab.active'); + if (!activeTab) return; + const elRect = el.getBoundingClientRect(); + const tabRect = activeTab.getBoundingClientRect(); + if (tabRect.left < elRect.left + 8) { + el.scrollBy({ left: tabRect.left - elRect.left - 24, behavior: 'smooth' }); + } else if (tabRect.right > elRect.right - 8) { + el.scrollBy({ left: tabRect.right - elRect.right + 24, behavior: 'smooth' }); + } + }, [selectedDay]); + const scrollDayTabs = (dir) => { + const el = dayTabsRef.current; + if (!el) return; + el.scrollBy({ left: dir * 200, behavior: 'smooth' }); + }; + // ─── PRO TIER STUB ──────────────────────────────────────────────────── // FLIP THE `false` BELOW TO `true` TO PREVIEW THE PRO EXPERIENCE. // When this is wired to real billing/auth, replace `useState(false)` @@ -670,14 +974,118 @@ function UTCIForecast() { // true = visible on first load (and the only ones free users see) // false = hidden by default (Pro users can toggle these on) const [visibleCols, setVisibleCols] = useState({ - hour: true, air: true, rh: true, wind: true, + hour: true, air: true, rh: true, dew: true, + wind: true, dir: true, cloud: false, sun: false, direct: false, diffuse: false, - tmrt: false, delta: false, utci: false, utciP: true, - precip: true, + tmrt: false, delta: false, utci: false, + uvA: false, uvB: false, burn: false, + utciP: true, precip: true, + soilT: false, soilT6: false, soilM: false, }); const toggleCol = (col) => setVisibleCols(prev => ({ ...prev, [col]: !prev[col] })); + + // Skin type for the sunburn-time column. Fitzpatrick II is typical UK fair. + const [skinType, setSkinType] = useState('II'); const searchTimeout = useRef(null); + // Refs for the two-scroller table layout (sticky-to-viewport header + + // horizontally-scrolling body). The header is clipped (overflow:hidden) + // and its inner "track" gets translateX'd via JS to follow the body's + // scrollLeft. See the useLayoutEffect just below where the JS sync + // happens, and the .utci-thead-sticky / .utci-tbody-scroll CSS rules. + const headStickyRef = useRef(null); + const headTrackRef = useRef(null); + const headTableRef = useRef(null); + const bodyScrollRef = useRef(null); + const bodyTableRef = useRef(null); + + // ─── TABLE SCROLL SYNC ─────────────────────────────────────────────── + // The hourly table is rendered as two stacked scroll areas: + // • Sticky header strip (locked to viewport top, clipped) + // • Body scroller (overflow-x: auto — owns the horizontal scrollbar) + // We need to (a) keep the header track shifted horizontally to match + // the body's scrollLeft, and (b) keep the header cells the same pixel + // width as the body cells even as columns toggle or the window resizes. + // ───────────────────────────────────────────────────────────────────── + const handleBodyScroll = () => { + const track = headTrackRef.current; + const body = bodyScrollRef.current; + if (!track || !body) return; + track.style.transform = `translate3d(${-body.scrollLeft}px, 0, 0)`; + }; + + useLayoutEffect(() => { + // Synchronise the head and body table column widths. + // Each column is sized to max(60px, header's natural width, body's + // natural width) so neither header labels nor body data ever feel + // squashed, and header text never overflows into the next column. + const MIN_COL = 60; + const sync = () => { + const headTable = headTableRef.current; + const bodyTable = bodyTableRef.current; + if (!headTable || !bodyTable) return; + const bodyRow = bodyTable.querySelector('tbody tr'); + const headRow = headTable.querySelector('thead tr'); + if (!bodyRow || !headRow) return; + const headCells = Array.from(headRow.children); + const bodyCells = Array.from(bodyRow.children); + const n = Math.min(headCells.length, bodyCells.length); + // Step 1: clear any previously-forced widths so we can read each + // cell's *natural* width (the width it'd take with just min-width + // and content driving it). + headCells.forEach(c => { + c.style.width = ''; + c.style.minWidth = ''; + c.style.maxWidth = ''; + }); + bodyCells.forEach(c => { + c.style.width = ''; + c.style.minWidth = ''; + c.style.maxWidth = ''; + }); + headTable.style.width = ''; + // Reading getBoundingClientRect forces layout — that's what we want. + // Step 2: compute final widths as max(MIN_COL, headNatural, bodyNatural). + const finalW = new Array(n); + let totalWidth = 0; + for (let i = 0; i < n; i++) { + const headW = headCells[i].getBoundingClientRect().width; + const bodyW = bodyCells[i].getBoundingClientRect().width; + const w = Math.max(MIN_COL, Math.ceil(headW), Math.ceil(bodyW)); + finalW[i] = w; + totalWidth += w; + } + // Step 3: apply the final width to both head and body cells. + for (let i = 0; i < n; i++) { + const px = `${finalW[i]}px`; + headCells[i].style.width = px; + headCells[i].style.minWidth = px; + headCells[i].style.maxWidth = px; + bodyCells[i].style.width = px; + bodyCells[i].style.minWidth = px; + bodyCells[i].style.maxWidth = px; + } + // Make the header table the same total width as the body table + // so the inner track has somewhere to translate to. + headTable.style.width = `${totalWidth}px`; + // Re-apply current horizontal offset so column alignment survives. + handleBodyScroll(); + }; + // Run once after layout + sync(); + // Re-sync if the body table reflows (columns toggle, content changes) + let ro = null; + if (typeof ResizeObserver !== 'undefined' && bodyTableRef.current) { + ro = new ResizeObserver(sync); + ro.observe(bodyTableRef.current); + } + window.addEventListener('resize', sync); + return () => { + if (ro) ro.disconnect(); + window.removeEventListener('resize', sync); + }; + }, [forecast, visibleCols, selectedDay, skinType]); + // Geocoding search useEffect(() => { if (searchQuery.length < 2) { setSearchResults([]); return; } @@ -708,9 +1116,12 @@ function UTCIForecast() { const url = `https://api.open-meteo.com/v1/forecast` + `?latitude=${location.lat}&longitude=${location.lon}` + - `&hourly=temperature_2m,relative_humidity_2m,wind_speed_10m,` + - `direct_radiation,diffuse_radiation,shortwave_radiation,cloud_cover,` + - `precipitation,snowfall` + + `&hourly=temperature_2m,relative_humidity_2m,dew_point_2m,` + + `wind_speed_10m,wind_direction_10m,wind_gusts_10m,` + + `direct_radiation,diffuse_radiation,shortwave_radiation,` + + `cloud_cover,cloud_cover_low,cloud_cover_mid,cloud_cover_high,` + + `uv_index,precipitation,snowfall,` + + `soil_temperature_0cm,soil_temperature_6cm,soil_moisture_0_to_1cm` + `&wind_speed_unit=ms&timezone=auto&forecast_days=14`; const r = await fetch(url); if (!r.ok) throw new Error(`Open-Meteo HTTP ${r.status}`); @@ -726,22 +1137,44 @@ function UTCIForecast() { // calculating UTCI + soak-factor for each row. This is what gets // displayed in the table. const hourlyRows = forecast ? forecast.hourly.time.map((iso, i) => { - const Ta = forecast.hourly.temperature_2m[i]; - const RH = forecast.hourly.relative_humidity_2m[i]; - const va = forecast.hourly.wind_speed_10m[i]; - const dir = forecast.hourly.direct_radiation[i] || 0; - const dif = forecast.hourly.diffuse_radiation[i] || 0; - const glob = forecast.hourly.shortwave_radiation[i] || 0; - const cc = forecast.hourly.cloud_cover[i]; - const precip = forecast.hourly.precipitation[i] || 0; - const snow = forecast.hourly.snowfall[i] || 0; + const h = forecast.hourly; + const Ta = h.temperature_2m[i]; + const RH = h.relative_humidity_2m[i]; + const dew = h.dew_point_2m ? h.dew_point_2m[i] : null; + const va = h.wind_speed_10m[i]; + const wd = h.wind_direction_10m ? h.wind_direction_10m[i] : null; + const gust = h.wind_gusts_10m ? h.wind_gusts_10m[i] : null; + const dir = h.direct_radiation[i] || 0; + const dif = h.diffuse_radiation[i] || 0; + const glob = h.shortwave_radiation[i] || 0; + const cc = h.cloud_cover[i]; + const ccLow = h.cloud_cover_low ? h.cloud_cover_low[i] : null; + const ccMid = h.cloud_cover_mid ? h.cloud_cover_mid[i] : null; + const ccHigh = h.cloud_cover_high ? h.cloud_cover_high[i] : null; + const uv = h.uv_index ? (h.uv_index[i] || 0) : 0; + const precip = h.precipitation[i] || 0; + const snow = h.snowfall[i] || 0; + const soilT0 = h.soil_temperature_0cm ? h.soil_temperature_0cm[i] : null; + const soilT6 = h.soil_temperature_6cm ? h.soil_temperature_6cm[i] : null; + const soilM = h.soil_moisture_0_to_1cm ? h.soil_moisture_0_to_1cm[i] : null; const dt = new Date(iso); const elev = solarElevationDeg(location.lat, location.lon, dt); const eh = vaporPressureHpa(Ta, RH); const Tmrt = calcTmrt(Ta, dir, dif, glob, elev); const utci = utciApprox(Ta, Tmrt, va, eh); const utciAdj = utci + precipPenalty(precip, snow, va); - return { iso, dt, Ta, RH, va, dir, dif, glob, cc, precip, snow, elev, Tmrt, utci, utciAdj, eh }; + // Derived + const compass = windCompass8(wd); + const { uvA, uvB } = uvSplit(uv, elev); + const cloudCat = cloudCategory(cc, ccLow, ccMid, ccHigh); + return { + iso, dt, Ta, RH, dew, va, wd, gust, dir, dif, glob, + cc, ccLow, ccMid, ccHigh, cloudCat, + uv, uvA, uvB, + precip, snow, + soilT0, soilT6, soilM, + elev, Tmrt, utci, utciAdj, eh, compass, + }; }) : []; // Group those hourly rows into days for the day tabs. @@ -870,7 +1303,18 @@ function UTCIForecast() { behaviour (e.g. open a paywall modal instead of doing nothing), edit the onClick handler below. --> -
+
+ + +
${days.map((d, i) => { const band = confidenceBand(i); const locked = !isPro && i >= FREE_DAYS; @@ -914,6 +1358,7 @@ function UTCIForecast() { `; })}
+
- - - + +
+
+
+ + ${visibleCols.hour && html``} ${visibleCols.air && html``} ${visibleCols.rh && html``} - ${visibleCols.wind && html``} + ${visibleCols.dew && html``} + ${visibleCols.soilT && html``} + ${visibleCols.soilT6 && html``} + ${visibleCols.soilM && html``} + ${visibleCols.wind && html``} + ${visibleCols.dir && html``} ${visibleCols.cloud && html``} ${visibleCols.sun && html``} ${visibleCols.direct && html``} @@ -1105,11 +1580,22 @@ function UTCIForecast() { ${visibleCols.tmrt && html``} ${visibleCols.delta && html``} ${visibleCols.utci && html``} + ${visibleCols.uvA && html``} + ${visibleCols.uvB && html``} + ${visibleCols.burn && html``} ${visibleCols.precip && html``} ${visibleCols.utciP && html``} - - + +
HourAir °CRH %Wind m/sDew °CSoil °C surfaceSoil 6cm °C rootSoil moist m³/m³Wind m/s (gust)Dir compassCloud %Sun elev°Direct W/m²Tmrt °CΔ UTCI−AirUTCI °C feltUV-A est. idxUV-B est. idxBurn to MEDPrecip mm/hUTCI+P °C adj.
+
+
+ +
+ + ${visible.map((r) => { const cat = utciCategory(r.utci); const isNight = r.elev < 0; @@ -1125,14 +1611,36 @@ function UTCIForecast() { `} ${visibleCols.air && html``} ${visibleCols.rh && html``} - ${visibleCols.wind && html``} - ${visibleCols.cloud && html``} + ${visibleCols.dew && html``} + ${visibleCols.soilT && html` + `} + ${visibleCols.soilT6 && html` + `} + ${visibleCols.soilM && html` + `} + ${visibleCols.wind && html``} + ${visibleCols.dir && html``} + ${visibleCols.cloud && html``} ${visibleCols.sun && html``} ${visibleCols.direct && html``} ${visibleCols.diffuse && html``} @@ -1150,20 +1658,33 @@ function UTCIForecast() { ${r.utci.toFixed(1)} `} + ${visibleCols.uvA && html` + `} + ${visibleCols.uvB && html` + `} + ${visibleCols.burn && html` + `} ${visibleCols.precip && html` `} ${visibleCols.utciP && html` `} `; })} - -
${isNow && html``} - <${SkyScope} elev=${r.elev} dt=${r.dt} size=${22} /> + <${SkyScope} elev=${r.elev} dt=${r.dt} size=${30} /> ${r.dt.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })} ${r.Ta.toFixed(1)}${Math.round(r.RH)}${r.va.toFixed(1)}${Math.round(r.cc)}${r.dew != null ? r.dew.toFixed(1) : '—'}${r.soilT0 != null ? r.soilT0.toFixed(1) : '—'}${r.soilT6 != null ? r.soilT6.toFixed(1) : '—'}${r.soilM != null ? r.soilM.toFixed(3) : '—'} + ${r.va.toFixed(1)}${r.gust != null && r.gust > r.va + 0.5 + ? html`(${r.gust.toFixed(1)})` + : ''} + + + <${WindVane} bearing=${r.wd} size=${28} /> + ${r.compass.label} + + + + <${CloudIcon} category=${r.cloudCat} elev=${r.elev} dt=${r.dt} size=${28} /> + ${Math.round(r.cc)} + + ${r.elev > 0 ? r.elev.toFixed(1) : '—'}${Math.round(r.dir)}${Math.round(r.dif)} 0 ? '#c8922a' : '#5a4228' }}> + ${r.uvA > 0 ? r.uvA.toFixed(1) : '—'} + 0 ? '#c44a3a' : '#5a4228', fontWeight: 600 }}> + ${r.uvB > 0 ? r.uvB.toFixed(2) : '—'} + 0 ? (sunburnMinutes(r.uv, skinType) < 30 ? '#c44a3a' : '#c8601a') : '#5a4228' }}> + ${burnLabel(sunburnMinutes(r.uv, skinType))} + 0 ? '#6090c8' : r.precip > 0 ? '#5090b0' : '#c0a880' }}> ${r.snow > 0 ? '❅ ' + r.snow.toFixed(1) + 'cm' : r.precip > 0 ? r.precip.toFixed(1) : '—'} - + ${r.utciAdj.toFixed(1)}
+ + +
diff --git a/index.html b/index.html index d4fccea..656c12b 100644 --- a/index.html +++ b/index.html @@ -6,10 +6,10 @@ SunScope · The True Temperature - +
- +