New data columns for UV, soil and wind direction.
Add icons to some data
Better table formatting and responsiveness
Improved scope background variations
This commit is contained in:
fraxle
2026-05-12 11:24:02 +01:00
parent cce78feea7
commit 044b4a59a9
3 changed files with 729 additions and 82 deletions
+133 -7
View File
@@ -84,7 +84,11 @@ body {
min-height: 100vh; min-height: 100vh;
padding: 52px 28px 32px; padding: 52px 28px 32px;
position: relative; 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 <th>) 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. /* Two big soft glows in the corners — pure decoration.
@@ -353,13 +357,83 @@ body {
sunscope.js (the confidence bands). This file only handles spacing, sunscope.js (the confidence bands). This file only handles spacing,
typography, and the active-tab underline. */ 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 { .utci-day-tabs {
display: flex; display: flex;
margin-bottom: 18px;
border-bottom: 2px solid #d4c0a0; 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 { .utci-day-tab {
flex-shrink: 0;
padding: 12px 24px; padding: 12px 24px;
cursor: pointer; cursor: pointer;
font-family: JetBrains Mono, monospace; font-family: JetBrains Mono, monospace;
@@ -452,18 +526,49 @@ body {
/* ── 7. HOURLY TABLE ────────────────────────────────────────────────── */ /* ── 7. HOURLY TABLE ────────────────────────────────────────────────── */
.utci-table-wrap { .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; border: 1.5px solid #c9b08a;
background: #fffcf2; 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 { .utci-table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
font-size: 14px; 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 <th> itself — see the new wrapper rules higher up. */
.utci-table th { .utci-table th {
background: #f0e4c4; background: #f0e4c4;
color: #6b4f2a; color: #6b4f2a;
@@ -475,9 +580,6 @@ body {
padding: 14px 12px; padding: 14px 12px;
text-align: right; text-align: right;
white-space: nowrap; 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 */ /* The Hour column aligns left, the rest align right */
@@ -510,6 +612,19 @@ body {
font-variant-numeric: tabular-nums; /* stops digits jiggling between rows */ font-variant-numeric: tabular-nums; /* stops digits jiggling between rows */
white-space: nowrap; white-space: nowrap;
color: #4a3420; 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; 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 { .utci-band-label {
font-family: JetBrains Mono, monospace; font-family: JetBrains Mono, monospace;
font-size: 10px; font-size: 10px;
+588 -67
View File
@@ -42,7 +42,7 @@
// Tiny framework imports — these are the only three "outside" dependencies. // Tiny framework imports — these are the only three "outside" dependencies.
// All three live in assets/vendor/ so the site has no external requests. // All three live in assets/vendor/ so the site has no external requests.
import { h, render, Fragment } from './vendor/preact.js'; 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'; import htm from './vendor/htm.js';
// `html` is the magic tagged-template function. Use it like: // `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; 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. // CONFIDENCE BANDS — smooth high-noon → sunset gradient on day tabs.
// ─────────────────────────────────────────────────────────────────── // ───────────────────────────────────────────────────────────────────
@@ -390,6 +473,18 @@ function skyFillForElev(e) {
return '#1a1538'; 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. // SKYSCOPE — the little porthole circle next to each hour.
// ─────────────────────────────────────────────────────────────────── // ───────────────────────────────────────────────────────────────────
@@ -407,7 +502,7 @@ function skyFillForElev(e) {
// • sunR — the sun's drawn radius // • sunR — the sun's drawn radius
// • the stroke colour #c8922a is the brass — change for a different metal // • 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 r = size / 2;
const innerR = r - 1.2; const innerR = r - 1.2;
const skyFill = skyFillForElev(elev); const skyFill = skyFillForElev(elev);
@@ -420,7 +515,10 @@ function SkyScope({ elev, dt, size = 22 }) {
const phaseOffset = Math.cos(2 * Math.PI * phase) * moonR; const phaseOffset = Math.cos(2 * Math.PI * phase) * moonR;
const moonLitFromRight = phase < 0.5; const moonLitFromRight = phase < 0.5;
const shadowCx = r + (moonLitFromRight ? -phaseOffset : phaseOffset); 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` return html`
<svg width=${size} height=${size} viewBox=${`0 0 ${size} ${size}`} <svg width=${size} height=${size} viewBox=${`0 0 ${size} ${size}`}
style=${{ flexShrink: 0, display: 'inline-block', verticalAlign: 'middle' }}> style=${{ flexShrink: 0, display: 'inline-block', verticalAlign: 'middle' }}>
@@ -428,11 +526,14 @@ function SkyScope({ elev, dt, size = 22 }) {
<clipPath id=${clipId}> <clipPath id=${clipId}>
<circle cx=${r} cy=${r} r=${innerR} /> <circle cx=${r} cy=${r} r=${innerR} />
</clipPath> </clipPath>
<linearGradient id=${grassId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color=${grass.top} />
<stop offset="100%" stop-color=${grass.bot} />
</linearGradient>
</defs> </defs>
<circle cx=${r} cy=${r} r=${innerR} fill=${skyFill} /> <circle cx=${r} cy=${r} r=${innerR} fill=${skyFill} />
<line x1=${r - innerR} y1=${r} x2=${r + innerR} y2=${r} <path d=${`M ${r - innerR} ${r} A ${innerR} ${innerR} 0 0 0 ${r + innerR} ${r} Z`}
stroke="rgba(0,0,0,0.22)" stroke-width="0.5" fill=${`url(#${grassId})`}
stroke-dasharray="1.2,1.2"
clip-path=${`url(#${clipId})`} /> clip-path=${`url(#${clipId})`} />
${isDay ${isDay
? html` ? html`
@@ -451,6 +552,150 @@ function SkyScope({ elev, dt, size = 22 }) {
</svg>`; </svg>`;
} }
// ═══════════════════════════════════════════════════════════════════
// 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`<span style=${{ opacity: 0.5, fontSize: '11px' }}>—</span>`;
}
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`
<svg width=${size} height=${size} viewBox=${`0 0 ${size} ${size}`}
style=${{ flexShrink: 0, display: 'inline-block', verticalAlign: 'middle', overflow: 'visible' }}>
<!-- Static N marker — small letter above the centre, doesn't rotate -->
<text x=${r} y=${size * 0.18} text-anchor="middle"
font-family="JetBrains Mono, monospace"
font-size=${size * 0.26} font-weight="700"
fill=${nColor} opacity="0.55">N</text>
<!-- Rotating arrow. Shaft + ONE arrowhead. A small round nub on
the tail end keeps the orientation unambiguous without
looking like a second arrowhead. -->
<g transform=${`rotate(${rot} ${r} ${r})`}>
<!-- shaft -->
<line x1=${r} y1=${headBase} x2=${r} y2=${tailEndY}
stroke=${arrowColor} stroke-width=${Math.max(1.4, size*0.07)} stroke-linecap="round" />
<!-- single arrowhead at the upwind end (head into the wind) -->
<polygon points=${`${r - headW},${headBase} ${r + headW},${headBase} ${r},${tipY}`}
fill=${headColor} stroke=${arrowColor} stroke-width="0.6" stroke-linejoin="round" />
<!-- small dot at the downwind end of the shaft, so orientation
is unambiguous (no chance of reading the tail as a head) -->
<circle cx=${r} cy=${tailEndY} r=${tailDotR} fill=${arrowColor} />
</g>
</svg>`;
}
// ═══════════════════════════════════════════════════════════════════
// 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 <g> 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`
<g>
<defs>
<clipPath id=${clipId}>
<circle cx=${cx} cy=${cy} r=${moonR} />
</clipPath>
</defs>
<circle cx=${cx} cy=${cy} r=${moonR} fill="#f5edd6" stroke="#9a8c6a" stroke-width="0.4" />
<circle cx=${shadowCx} cy=${cy} r=${moonR} fill="#2a2438" clip-path=${`url(#${clipId})`} />
<circle cx=${cx} cy=${cy} r=${moonR} fill="none" stroke="#9a8c6a" stroke-width="0.4" />
</g>`;
};
return html`
<svg width=${size} height=${size} viewBox=${`0 0 ${size} ${size}`}
style=${{ flexShrink: 0, display: 'inline-block', verticalAlign: 'middle', overflow: 'visible' }}>
${category === 'clear' && (isNight
? html`<!-- clear night: phased moon, no rays -->
${renderMoon(r, r, u*0.50)}`
: html`<!-- clear day: bright sun with rays -->
<g>
${[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`<line key=${a} x1=${x1} y1=${y1} x2=${x2} y2=${y2}
stroke="#e6a32a" stroke-width=${Math.max(1, size*0.05)}
stroke-linecap="round" />`;
})}
<circle cx=${r} cy=${r} r=${u*0.45} fill="#ffd84a" stroke="#e6a32a" stroke-width="0.6" />
</g>`)}
${category === 'wispy' && html`
<!-- sun OR moon with thin cirrus streaks across it -->
${isNight
? renderMoon(r, r*0.95, u*0.42)
: html`<circle cx=${r} cy=${r*0.95} r=${u*0.42} fill="#ffd84a" stroke="#e6a32a" stroke-width="0.6" />`}
<path d=${`M ${r-u*0.95} ${r*0.85} Q ${r} ${r*0.65} ${r+u*0.95} ${r*0.95}`}
stroke=${isNight ? '#cfd6dd' : '#ffffff'} stroke-width=${Math.max(1.4, size*0.07)}
fill="none" stroke-linecap="round" opacity="0.92" />
<path d=${`M ${r-u*0.75} ${r*1.25} Q ${r} ${r*1.08} ${r+u*0.85} ${r*1.30}`}
stroke=${isNight ? '#b8c0c8' : '#e6ebf0'} stroke-width=${Math.max(1.1, size*0.055)}
fill="none" stroke-linecap="round" opacity="0.85" />`}
${category === 'scattered' && html`
<!-- sun OR moon behind a fluffy cumulus cloud -->
${isNight
? renderMoon(r+u*0.45, r-u*0.40, u*0.32)
: html`<circle cx=${r+u*0.45} cy=${r-u*0.40} r=${u*0.34} fill="#ffd84a" stroke="#e6a32a" stroke-width="0.6" />`}
<g fill=${isNight ? '#b8c0c8' : '#ffffff'} stroke=${isNight ? '#5a6470' : '#8a96a4'} stroke-width="0.6" stroke-linejoin="round">
<ellipse cx=${r-u*0.35} cy=${r+u*0.10} rx=${u*0.50} ry=${u*0.30} />
<ellipse cx=${r+u*0.10} cy=${r-u*0.05} rx=${u*0.46} ry=${u*0.36} />
<ellipse cx=${r+u*0.40} cy=${r+u*0.20} rx=${u*0.44} ry=${u*0.28} />
<!-- flat-ish base, soft underneath -->
<rect x=${r-u*0.75} y=${r+u*0.18} width=${u*1.55} height=${u*0.20} rx=${u*0.10} />
</g>`}
${category === 'overcast' && html`
<!-- thick layered stratus, no sun OR moon (would be hidden anyway) -->
<g fill=${isNight ? '#7a828c' : '#d8dee5'} stroke=${isNight ? '#3a424c' : '#6e7a88'} stroke-width="0.7" stroke-linejoin="round">
<ellipse cx=${r-u*0.40} cy=${r-u*0.20} rx=${u*0.55} ry=${u*0.32} />
<ellipse cx=${r+u*0.20} cy=${r-u*0.40} rx=${u*0.48} ry=${u*0.30} />
<ellipse cx=${r+u*0.40} cy=${r+u*0.10} rx=${u*0.55} ry=${u*0.34} />
<ellipse cx=${r-u*0.10} cy=${r+u*0.30} rx=${u*0.60} ry=${u*0.32} />
</g>`}
</svg>`;
}
// ═══════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════
// SCOPERETICLE — the big circular UTCI dial in the page header. // 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 // widen the dial range by changing those numbers
// ═══════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════
function ScopeReticle({ value, cat, loading, elev = 0, dt = new Date() }) { function ScopeReticle({ value, cat, loading, elev = 0, dt = new Date() }) {
// ── Day/night indicator (new) ────────────────────────────────────── // ── Day/night scene behind the readout ─────────────────────────────
// The sun-or-moon glyph orbits the dial like an hour hand: // The whole back of the lens is a SkyScope-style scene: sky on top,
// 12 o'clock = noon · 3 o'clock = morning (east/sunrise) // grass on the bottom, sun positioned by elevation (or moon at night
// 6 o'clock = midnight · 9 o'clock = evening (west/sunset) // with its phase shadow). Mirrors the little hourly SkyScopes.
// A subtle sky-colour wash sits behind the readout for atmosphere. const cx = 100, cy = 100, R = 86;
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);
const isDay = elev > -3; const isDay = elev > -3;
const skyFill = skyFillForElev(elev); const skyFill = skyFillForElev(elev);
const grass = grassFillForElev(elev);
const phase = moonPhaseFraction(dt); const phase = moonPhaseFraction(dt);
// For the moon, use the same shadow-ellipse technique as SkyScope. const lensR = 95; // full back of the dial
const moonR = 6; // slightly smaller so it fits the outer tick band const elevClamped = Math.max(-30, Math.min(90, elev));
const phaseOffset = Math.cos(2 * Math.PI * phase) * moonR; 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 moonLitFromRight = phase < 0.5;
const moonShadowCx = dnX + (moonLitFromRight ? -phaseOffset : phaseOffset); const moonShadowCx = cx + (moonLitFromRight ? -phaseOffset : phaseOffset);
// Readout text colours flip with day/night for legibility against the sky.
const cx = 100, cy = 100, R = 86; const readoutColor = isDay ? '#1e1208' : '#f5edd6';
const readoutMutedColor = isDay ? '#9a7d5a' : '#d4c5a8';
const ticks = Array.from({ length: 36 }, (_, i) => { const ticks = Array.from({ length: 36 }, (_, i) => {
const deg = i * 10 - 90; const deg = i * 10 - 90;
const rad = (deg * Math.PI) / 180; const rad = (deg * Math.PI) / 180;
@@ -541,14 +787,35 @@ function ScopeReticle({ value, cat, loading, elev = 0, dt = new Date() }) {
<stop offset="0%" stop-color=${glowColor} stop-opacity="0.12" /> <stop offset="0%" stop-color=${glowColor} stop-opacity="0.12" />
<stop offset="100%" stop-color=${glowColor} stop-opacity="0" /> <stop offset="100%" stop-color=${glowColor} stop-opacity="0" />
</radialGradient> </radialGradient>
<clipPath id="scope-lens-clip">
<circle cx=${cx} cy=${cy} r=${lensR} />
</clipPath>
<linearGradient id="scope-lens-grass" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color=${grass.top} />
<stop offset="100%" stop-color=${grass.bot} />
</linearGradient>
</defs> </defs>
<circle cx=${cx} cy=${cy} r="96" fill="url(#scope-bg-glow)" /> <circle cx=${cx} cy=${cy} r="96" fill="url(#scope-bg-glow)" />
<!-- Sky-colour wash behind the readout (very subtle) --> <!-- Full sky + grass + sun/moon scene behind the readout -->
<circle cx=${cx} cy=${cy} r="58" fill=${skyFill} opacity="0.16" /> <g clip-path="url(#scope-lens-clip)">
<circle cx=${cx} cy=${cy} r=${lensR} fill=${skyFill} />
<path d=${`M ${cx - lensR} ${cy} A ${lensR} ${lensR} 0 0 0 ${cx + lensR} ${cy} Z`}
fill="url(#scope-lens-grass)" />
${isDay
? html`<${Fragment}>
<circle cx=${cx} cy=${sunY} r=${sunDrawR + 4} fill="#ffe9a0" opacity="0.55" />
<circle cx=${cx} cy=${sunY} r=${sunDrawR} fill="#fff3a8" stroke="#e6a32a" stroke-width="0.8" />
</>`
: html`<${Fragment}>
<circle cx=${cx} cy=${moonY} r=${moonDrawR} fill="#f5edd6" />
<circle cx=${moonShadowCx} cy=${moonY} r=${moonDrawR} fill=${skyFill} />
<circle cx=${cx} cy=${moonY} r=${moonDrawR} fill="none" stroke="rgba(245,237,214,0.55)" stroke-width="0.7" />
</>`}
</g>
${stressBands.map((b, i) => html` ${stressBands.map((b, i) => html`
<path key=${i} d=${bandArcPath(b)} fill="none" <path key=${i} d=${bandArcPath(b)} fill="none"
stroke=${b.color} stroke-width="4" opacity="0.4" stroke-linecap="butt" />`)} stroke=${b.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}
@@ -561,18 +828,6 @@ function ScopeReticle({ value, cat, loading, elev = 0, dt = new Date() }) {
<circle cx=${cx} cy=${cy} r="58" fill="none" stroke="#e8d8c0" stroke-width="0.75" /> <circle cx=${cx} cy=${cy} r="58" fill="none" stroke="#e8d8c0" stroke-width="0.75" />
<circle cx=${cx} cy=${cy} r="32" fill="none" stroke="#e8d8c0" stroke-width="0.5" /> <circle cx=${cx} cy=${cy} r="32" fill="none" stroke="#e8d8c0" stroke-width="0.5" />
<!-- Day/night glyph orbiting the dial -->
${isDay
? html`<${Fragment}>
<circle cx=${dnX} cy=${dnY} r="9" fill="#ffe9a0" opacity="0.55" />
<circle cx=${dnX} cy=${dnY} r="6" fill="#fff3a8" stroke="#e6a32a" stroke-width="0.8" />
</>`
: html`<${Fragment}>
<circle cx=${dnX} cy=${dnY} r=${moonR + 1.2} fill="rgba(60,50,90,0.35)" />
<circle cx=${dnX} cy=${dnY} r=${moonR} fill="#f5edd6" />
<circle cx=${moonShadowCx} cy=${dnY} r=${moonR} fill="#1a1538" />
<circle cx=${dnX} cy=${dnY} r=${moonR} fill="none" stroke="rgba(245,237,214,0.55)" stroke-width="0.5" />
</>`}
${[[-1,-1],[1,-1],[-1,1],[1,1]].map(([sx, sy], i) => html` ${[[-1,-1],[1,-1],[-1,1],[1,1]].map(([sx, sy], i) => html`
<g key=${i}> <g key=${i}>
<line x1=${cx + sx*72} y1=${cy + sy*72} x2=${cx + sx*60} y2=${cy + sy*72} <line x1=${cx + sx*72} y1=${cy + sy*72} x2=${cx + sx*60} y2=${cy + sy*72}
@@ -583,24 +838,24 @@ function ScopeReticle({ value, cat, loading, elev = 0, dt = new Date() }) {
${value != null && html` ${value != null && html`
<${Fragment}> <${Fragment}>
<line x1=${cx} y1=${cy} x2=${needleX} y2=${needleY} <line x1=${cx} y1=${cy} x2=${needleX} y2=${needleY}
stroke="#c8922a" stroke-width="3" stroke-linecap="round" opacity="0.18" /> stroke="#c8922a" stroke-width="5" stroke-linecap="round" opacity="0.18" />
<line x1=${cx} y1=${cy} x2=${needleX} y2=${needleY} <line x1=${cx} y1=${cy} x2=${needleX} y2=${needleY}
stroke="#c8922a" stroke-width="1.5" stroke-linecap="round" opacity="0.95" /> stroke="#c8922a" stroke-width="2.5" stroke-linecap="round" opacity="0.95" />
</>`} </>`}
<circle cx=${cx} cy=${cy} r="5.5" fill="#f5edd6" stroke="#c8922a" stroke-width="1.5" /> <circle cx=${cx} cy=${cy} r="5.5" fill="#f5edd6" stroke="#c8922a" stroke-width="1.5" />
<circle cx=${cx} cy=${cy} r="2.5" fill="#c8922a" /> <circle cx=${cx} cy=${cy} r="2.5" fill="#c8922a" />
${loading ${loading
? html`<text x=${cx} y=${cy + 5} text-anchor="middle" ? html`<text x=${cx} y=${cy + 5} text-anchor="middle"
fill="#b09870" font-size="11" font-family="monospace">· · ·</text>` fill=${readoutMutedColor} font-size="11" font-family="monospace">· · ·</text>`
: value != null : value != null
? html`<${Fragment}> ? html`<${Fragment}>
<text x=${cx} y=${cy - 8} text-anchor="middle" <text x=${cx} y="152" text-anchor="middle"
fill="#1e1208" font-size="26" fill=${readoutColor} font-size="26"
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 + 7} text-anchor="middle" <text x=${cx} y=${cy + 7} text-anchor="middle"
fill="#9a7d5a" font-size="6.5" fill=${readoutMutedColor} font-size="6.5"
font-family="JetBrains Mono, monospace" letter-spacing="2"> font-family="JetBrains Mono, monospace" letter-spacing="2">
UTCI NOW UTCI NOW
</text> </text>
@@ -611,7 +866,7 @@ function ScopeReticle({ value, cat, loading, elev = 0, dt = new Date() }) {
</text> </text>
</>` </>`
: html`<text x=${cx} y=${cy + 5} text-anchor="middle" : html`<text x=${cx} y=${cy + 5} text-anchor="middle"
fill="#b09870" font-size="8" fill=${readoutMutedColor} font-size="8"
font-family="JetBrains Mono, monospace" letter-spacing="1.2"> font-family="JetBrains Mono, monospace" letter-spacing="1.2">
AWAITING AWAITING
</text>`} </text>`}
@@ -656,6 +911,55 @@ function UTCIForecast() {
const [selectedDay, setSelectedDay] = useState(0); // which day tab is active const [selectedDay, setSelectedDay] = useState(0); // which day tab is active
const [proPromptDay, setProPromptDay] = useState(null); // locked day clicked → show upsell card 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 ──────────────────────────────────────────────────── // ─── PRO TIER STUB ────────────────────────────────────────────────────
// FLIP THE `false` BELOW TO `true` TO PREVIEW THE PRO EXPERIENCE. // FLIP THE `false` BELOW TO `true` TO PREVIEW THE PRO EXPERIENCE.
// When this is wired to real billing/auth, replace `useState(false)` // 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) // true = visible on first load (and the only ones free users see)
// false = hidden by default (Pro users can toggle these on) // false = hidden by default (Pro users can toggle these on)
const [visibleCols, setVisibleCols] = useState({ 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, cloud: false, sun: false, direct: false, diffuse: false,
tmrt: false, delta: false, utci: false, utciP: true, tmrt: false, delta: false, utci: false,
precip: true, 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] })); 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); 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 // Geocoding search
useEffect(() => { useEffect(() => {
if (searchQuery.length < 2) { setSearchResults([]); return; } if (searchQuery.length < 2) { setSearchResults([]); return; }
@@ -708,9 +1116,12 @@ function UTCIForecast() {
const url = const url =
`https://api.open-meteo.com/v1/forecast` + `https://api.open-meteo.com/v1/forecast` +
`?latitude=${location.lat}&longitude=${location.lon}` + `?latitude=${location.lat}&longitude=${location.lon}` +
`&hourly=temperature_2m,relative_humidity_2m,wind_speed_10m,` + `&hourly=temperature_2m,relative_humidity_2m,dew_point_2m,` +
`direct_radiation,diffuse_radiation,shortwave_radiation,cloud_cover,` + `wind_speed_10m,wind_direction_10m,wind_gusts_10m,` +
`precipitation,snowfall` + `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`; `&wind_speed_unit=ms&timezone=auto&forecast_days=14`;
const r = await fetch(url); const r = await fetch(url);
if (!r.ok) throw new Error(`Open-Meteo HTTP ${r.status}`); 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 // calculating UTCI + soak-factor for each row. This is what gets
// displayed in the table. // displayed in the table.
const hourlyRows = forecast ? forecast.hourly.time.map((iso, i) => { const hourlyRows = forecast ? forecast.hourly.time.map((iso, i) => {
const Ta = forecast.hourly.temperature_2m[i]; const h = forecast.hourly;
const RH = forecast.hourly.relative_humidity_2m[i]; const Ta = h.temperature_2m[i];
const va = forecast.hourly.wind_speed_10m[i]; const RH = h.relative_humidity_2m[i];
const dir = forecast.hourly.direct_radiation[i] || 0; const dew = h.dew_point_2m ? h.dew_point_2m[i] : null;
const dif = forecast.hourly.diffuse_radiation[i] || 0; const va = h.wind_speed_10m[i];
const glob = forecast.hourly.shortwave_radiation[i] || 0; const wd = h.wind_direction_10m ? h.wind_direction_10m[i] : null;
const cc = forecast.hourly.cloud_cover[i]; const gust = h.wind_gusts_10m ? h.wind_gusts_10m[i] : null;
const precip = forecast.hourly.precipitation[i] || 0; const dir = h.direct_radiation[i] || 0;
const snow = forecast.hourly.snowfall[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 dt = new Date(iso);
const elev = solarElevationDeg(location.lat, location.lon, dt); const elev = solarElevationDeg(location.lat, location.lon, dt);
const eh = vaporPressureHpa(Ta, RH); const eh = vaporPressureHpa(Ta, RH);
const Tmrt = calcTmrt(Ta, dir, dif, glob, elev); const Tmrt = calcTmrt(Ta, dir, dif, glob, elev);
const utci = utciApprox(Ta, Tmrt, va, eh); const utci = utciApprox(Ta, Tmrt, va, eh);
const utciAdj = utci + precipPenalty(precip, snow, va); 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. // 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 behaviour (e.g. open a paywall modal instead of doing
nothing), edit the onClick handler below. nothing), edit the onClick handler below.
--> -->
<div class="utci-day-tabs"> <div class=${`utci-day-tabs-wrap${canScrollLeft ? ' fade-left' : ''}${canScrollRight ? ' fade-right' : ''}`}>
<button
class=${`utci-day-scroll left${canScrollLeft ? '' : ' hidden'}`}
onClick=${() => scrollDayTabs(-1)}
aria-label="Scroll days left"
type="button"></button>
<button
class=${`utci-day-scroll right${canScrollRight ? '' : ' hidden'}`}
onClick=${() => scrollDayTabs(1)}
aria-label="Scroll days right"
type="button"></button>
<div class="utci-day-tabs" ref=${dayTabsRef}>
${days.map((d, i) => { ${days.map((d, i) => {
const band = confidenceBand(i); const band = confidenceBand(i);
const locked = !isPro && i >= FREE_DAYS; const locked = !isPro && i >= FREE_DAYS;
@@ -914,6 +1358,7 @@ function UTCIForecast() {
</button>`; </button>`;
})} })}
</div> </div>
</div>
<!-- <!--
PRO UPSELL CARD shown when a locked day is clicked. PRO UPSELL CARD shown when a locked day is clicked.
@@ -1058,7 +1503,12 @@ function UTCIForecast() {
{ key: 'hour', label: 'Hour' }, { key: 'hour', label: 'Hour' },
{ key: 'air', label: 'Air' }, { key: 'air', label: 'Air' },
{ key: 'rh', label: 'RH' }, { key: 'rh', label: 'RH' },
{ key: 'dew', label: 'Dew' },
{ key: 'soilT', label: 'Soil °C' },
{ key: 'soilT6', label: 'Soil 6cm' },
{ key: 'soilM', label: 'Soil moist' },
{ key: 'wind', label: 'Wind' }, { key: 'wind', label: 'Wind' },
{ key: 'dir', label: 'Dir' },
{ key: 'cloud', label: 'Cloud' }, { key: 'cloud', label: 'Cloud' },
{ key: 'sun', label: 'Sun' }, { key: 'sun', label: 'Sun' },
{ key: 'direct', label: 'Direct' }, { key: 'direct', label: 'Direct' },
@@ -1066,6 +1516,9 @@ function UTCIForecast() {
{ key: 'tmrt', label: 'Tmrt' }, { key: 'tmrt', label: 'Tmrt' },
{ key: 'delta', label: 'Δ' }, { key: 'delta', label: 'Δ' },
{ key: 'utci', label: 'UTCI' }, { key: 'utci', label: 'UTCI' },
{ key: 'uvA', label: 'UV-A' },
{ key: 'uvB', label: 'UV-B' },
{ key: 'burn', label: 'Burn' },
{ key: 'precip', label: 'Precip' }, { key: 'precip', label: 'Precip' },
{ key: 'utciP', label: 'UTCI+P' }, { key: 'utciP', label: 'UTCI+P' },
].map(c => html` ].map(c => html`
@@ -1074,6 +1527,17 @@ function UTCIForecast() {
class=${`col-toggle${visibleCols[c.key] ? ' on' : ''}`} class=${`col-toggle${visibleCols[c.key] ? ' on' : ''}`}
onClick=${() => toggleCol(c.key)} onClick=${() => toggleCol(c.key)}
>${c.label}</button>`)} >${c.label}</button>`)}
${visibleCols.burn && html`
<span style=${{ marginLeft: '10px', display: 'inline-flex', alignItems: 'center', gap: '6px', fontFamily: 'JetBrains Mono, monospace', fontSize: '10px', textTransform: 'uppercase', letterSpacing: '0.08em', color: '#9a7d5a' }}>
Skin
<select
value=${skinType}
onChange=${(e) => setSkinType(e.target.value)}
style=${{ background: '#2a1f12', color: '#ede4cc', border: '1px solid #5a4228', borderRadius: '3px', padding: '2px 4px', fontFamily: 'JetBrains Mono, monospace', fontSize: '10px' }}>
${Object.entries(SKIN_TYPES).map(([k, v]) => html`
<option key=${k} value=${k}>${v.name}</option>`)}
</select>
</span>`}
</div>` </div>`
: html` : html`
<div class="col-toggles" style=${{ opacity: 0.85 }}> <div class="col-toggles" style=${{ opacity: 0.85 }}>
@@ -1091,13 +1555,24 @@ function UTCIForecast() {
heading, edit the text inside the matching <th>. heading, edit the text inside the matching <th>.
--> -->
<div class="utci-table-wrap"> <div class="utci-table-wrap">
<table class="utci-table"> <!-- Sticky header strip locks to viewport top. Clipped
horizontally; the inner .utci-thead-track is shifted
via translateX from JS to follow the body's scrollLeft.
See handleBodyScroll + useLayoutEffect above. -->
<div class="utci-thead-sticky" ref=${headStickyRef}>
<div class="utci-thead-track" ref=${headTrackRef}>
<table class="utci-table utci-table-head" ref=${headTableRef}>
<thead> <thead>
<tr> <tr>
${visibleCols.hour && html`<th>Hour</th>`} ${visibleCols.hour && html`<th>Hour</th>`}
${visibleCols.air && html`<th>Air <span class="col-unit">°C</span></th>`} ${visibleCols.air && html`<th>Air <span class="col-unit">°C</span></th>`}
${visibleCols.rh && html`<th>RH <span class="col-unit">%</span></th>`} ${visibleCols.rh && html`<th>RH <span class="col-unit">%</span></th>`}
${visibleCols.wind && html`<th>Wind <span class="col-unit">m/s</span></th>`} ${visibleCols.dew && html`<th>Dew <span class="col-unit">°C</span></th>`}
${visibleCols.soilT && html`<th>Soil °C <span class="col-unit">surface</span></th>`}
${visibleCols.soilT6 && html`<th>Soil 6cm <span class="col-unit">°C root</span></th>`}
${visibleCols.soilM && html`<th>Soil moist <span class="col-unit">m³/m³</span></th>`}
${visibleCols.wind && html`<th>Wind <span class="col-unit">m/s (gust)</span></th>`}
${visibleCols.dir && html`<th>Dir <span class="col-unit">compass</span></th>`}
${visibleCols.cloud && html`<th>Cloud <span class="col-unit">%</span></th>`} ${visibleCols.cloud && html`<th>Cloud <span class="col-unit">%</span></th>`}
${visibleCols.sun && html`<th>Sun <span class="col-unit">elev°</span></th>`} ${visibleCols.sun && html`<th>Sun <span class="col-unit">elev°</span></th>`}
${visibleCols.direct && html`<th>Direct <span class="col-unit">W/m²</span></th>`} ${visibleCols.direct && html`<th>Direct <span class="col-unit">W/m²</span></th>`}
@@ -1105,10 +1580,21 @@ function UTCIForecast() {
${visibleCols.tmrt && html`<th>Tmrt <span class="col-unit">°C</span></th>`} ${visibleCols.tmrt && html`<th>Tmrt <span class="col-unit">°C</span></th>`}
${visibleCols.delta && html`<th>Δ <span class="col-unit">UTCIAir</span></th>`} ${visibleCols.delta && html`<th>Δ <span class="col-unit">UTCIAir</span></th>`}
${visibleCols.utci && html`<th>UTCI <span class="col-unit">°C felt</span></th>`} ${visibleCols.utci && html`<th>UTCI <span class="col-unit">°C felt</span></th>`}
${visibleCols.uvA && html`<th>UV-A <span class="col-unit">est. idx</span></th>`}
${visibleCols.uvB && html`<th>UV-B <span class="col-unit">est. idx</span></th>`}
${visibleCols.burn && html`<th>Burn <span class="col-unit">to MED</span></th>`}
${visibleCols.precip && html`<th>Precip <span class="col-unit">mm/h</span></th>`} ${visibleCols.precip && html`<th>Precip <span class="col-unit">mm/h</span></th>`}
${visibleCols.utciP && html`<th>UTCI+P <span class="col-unit">°C adj.</span></th>`} ${visibleCols.utciP && html`<th>UTCI+P <span class="col-unit">°C adj.</span></th>`}
</tr> </tr>
</thead> </thead>
</table>
</div>
</div>
<!-- Body scroller — owns the horizontal scrollbar. The
onScroll handler translates the header track to keep
columns aligned with the visible body columns. -->
<div class="utci-tbody-scroll" ref=${bodyScrollRef} onScroll=${handleBodyScroll}>
<table class="utci-table utci-table-body" ref=${bodyTableRef}>
<tbody> <tbody>
${visible.map((r) => { ${visible.map((r) => {
const cat = utciCategory(r.utci); const cat = utciCategory(r.utci);
@@ -1125,14 +1611,36 @@ function UTCIForecast() {
<td class="utci-time"> <td class="utci-time">
${isNow && html`<span class="now-pip"></span>`} ${isNow && html`<span class="now-pip"></span>`}
<span style=${{ display: 'inline-flex', alignItems: 'center', gap: '7px', verticalAlign: 'middle' }}> <span style=${{ display: 'inline-flex', alignItems: 'center', gap: '7px', verticalAlign: 'middle' }}>
<${SkyScope} elev=${r.elev} dt=${r.dt} size=${22} /> <${SkyScope} elev=${r.elev} dt=${r.dt} size=${30} />
<span>${r.dt.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })}</span> <span>${r.dt.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })}</span>
</span> </span>
</td>`} </td>`}
${visibleCols.air && html`<td>${r.Ta.toFixed(1)}</td>`} ${visibleCols.air && html`<td>${r.Ta.toFixed(1)}</td>`}
${visibleCols.rh && html`<td>${Math.round(r.RH)}</td>`} ${visibleCols.rh && html`<td>${Math.round(r.RH)}</td>`}
${visibleCols.wind && html`<td>${r.va.toFixed(1)}</td>`} ${visibleCols.dew && html`<td>${r.dew != null ? r.dew.toFixed(1) : ''}</td>`}
${visibleCols.cloud && html`<td>${Math.round(r.cc)}</td>`} ${visibleCols.soilT && html`
<td style=${{ color: '#8a6a3a' }}>${r.soilT0 != null ? r.soilT0.toFixed(1) : ''}</td>`}
${visibleCols.soilT6 && html`
<td style=${{ color: '#8a6a3a' }}>${r.soilT6 != null ? r.soilT6.toFixed(1) : ''}</td>`}
${visibleCols.soilM && html`
<td style=${{ color: '#5090b0' }}>${r.soilM != null ? r.soilM.toFixed(3) : ''}</td>`}
${visibleCols.wind && html`<td>
${r.va.toFixed(1)}${r.gust != null && r.gust > r.va + 0.5
? html`<span style=${{ opacity: 0.65, marginLeft: '4px' }}>(${r.gust.toFixed(1)})</span>`
: ''}
</td>`}
${visibleCols.dir && html`<td>
<span style=${{ display: 'inline-flex', alignItems: 'center', gap: '6px', verticalAlign: 'middle' }}>
<${WindVane} bearing=${r.wd} size=${28} />
<span style=${{ fontFamily: 'JetBrains Mono, monospace', fontSize: '11px' }}>${r.compass.label}</span>
</span>
</td>`}
${visibleCols.cloud && html`<td>
<span style=${{ display: 'inline-flex', alignItems: 'center', gap: '6px', verticalAlign: 'middle' }}>
<${CloudIcon} category=${r.cloudCat} elev=${r.elev} dt=${r.dt} size=${28} />
<span>${Math.round(r.cc)}</span>
</span>
</td>`}
${visibleCols.sun && html`<td>${r.elev > 0 ? r.elev.toFixed(1) : ''}</td>`} ${visibleCols.sun && html`<td>${r.elev > 0 ? r.elev.toFixed(1) : ''}</td>`}
${visibleCols.direct && html`<td>${Math.round(r.dir)}</td>`} ${visibleCols.direct && html`<td>${Math.round(r.dir)}</td>`}
${visibleCols.diffuse && html`<td>${Math.round(r.dif)}</td>`} ${visibleCols.diffuse && html`<td>${Math.round(r.dif)}</td>`}
@@ -1150,13 +1658,25 @@ function UTCIForecast() {
${r.utci.toFixed(1)} ${r.utci.toFixed(1)}
</span> </span>
</td>`} </td>`}
${visibleCols.uvA && html`
<td style=${{ color: r.uvA > 0 ? '#c8922a' : '#5a4228' }}>
${r.uvA > 0 ? r.uvA.toFixed(1) : ''}
</td>`}
${visibleCols.uvB && html`
<td style=${{ color: r.uvB > 0 ? '#c44a3a' : '#5a4228', fontWeight: 600 }}>
${r.uvB > 0 ? r.uvB.toFixed(2) : ''}
</td>`}
${visibleCols.burn && html`
<td style=${{ color: r.uv > 0 ? (sunburnMinutes(r.uv, skinType) < 30 ? '#c44a3a' : '#c8601a') : '#5a4228' }}>
${burnLabel(sunburnMinutes(r.uv, skinType))}
</td>`}
${visibleCols.precip && html` ${visibleCols.precip && html`
<td style=${{ color: r.snow > 0 ? '#6090c8' : r.precip > 0 ? '#5090b0' : '#c0a880' }}> <td style=${{ color: r.snow > 0 ? '#6090c8' : r.precip > 0 ? '#5090b0' : '#c0a880' }}>
${r.snow > 0 ? ' ' + r.snow.toFixed(1) + 'cm' : r.precip > 0 ? r.precip.toFixed(1) : ''} ${r.snow > 0 ? ' ' + r.snow.toFixed(1) + 'cm' : r.precip > 0 ? r.precip.toFixed(1) : ''}
</td>`} </td>`}
${visibleCols.utciP && html` ${visibleCols.utciP && html`
<td style=${{ background: 'rgba(180,215,250,0.10)' }}> <td style=${{ background: 'rgba(180,215,250,0.10)' }}>
<span class="utci-cell" style=${{ background: adjCat.bg, color: adjCat.fg }}> <span class="utci-cell utci-cell-hero" style=${{ background: adjCat.bg, color: adjCat.fg }}>
${r.utciAdj.toFixed(1)} ${r.utciAdj.toFixed(1)}
</span> </span>
</td>`} </td>`}
@@ -1165,6 +1685,7 @@ function UTCIForecast() {
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
<div class="utci-legend"> <div class="utci-legend">
<span class="utci-legend-label">Thermal stress bands</span> <span class="utci-legend-label">Thermal stress bands</span>
+2 -2
View File
@@ -6,10 +6,10 @@
<meta name="description" content="SunScope - UTCI weather forecast showing real thermal comfort, not just air temperature." /> <meta name="description" content="SunScope - UTCI weather forecast showing real thermal comfort, not just air temperature." />
<title>SunScope · The True Temperature</title> <title>SunScope · The True Temperature</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>☀️</text></svg>" /> <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>☀️</text></svg>" />
<link rel="stylesheet" href="./assets/sunscope.css?v=14d-local9"> <link rel="stylesheet" href="./assets/sunscope.css?v=14k">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="./assets/sunscope.js?v=14d-local9"></script> <script type="module" src="./assets/sunscope.js?v=14k"></script>
</body> </body>
</html> </html>