Beta3
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:
+133
-7
@@ -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 <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.
|
||||
@@ -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 <th> 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;
|
||||
|
||||
+588
-67
@@ -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`
|
||||
<svg width=${size} height=${size} viewBox=${`0 0 ${size} ${size}`}
|
||||
style=${{ flexShrink: 0, display: 'inline-block', verticalAlign: 'middle' }}>
|
||||
@@ -428,11 +526,14 @@ function SkyScope({ elev, dt, size = 22 }) {
|
||||
<clipPath id=${clipId}>
|
||||
<circle cx=${r} cy=${r} r=${innerR} />
|
||||
</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>
|
||||
<circle cx=${r} cy=${r} r=${innerR} fill=${skyFill} />
|
||||
<line x1=${r - innerR} y1=${r} x2=${r + innerR} y2=${r}
|
||||
stroke="rgba(0,0,0,0.22)" stroke-width="0.5"
|
||||
stroke-dasharray="1.2,1.2"
|
||||
<path d=${`M ${r - innerR} ${r} A ${innerR} ${innerR} 0 0 0 ${r + innerR} ${r} Z`}
|
||||
fill=${`url(#${grassId})`}
|
||||
clip-path=${`url(#${clipId})`} />
|
||||
${isDay
|
||||
? html`
|
||||
@@ -451,6 +552,150 @@ function SkyScope({ elev, dt, size = 22 }) {
|
||||
</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.
|
||||
// ───────────────────────────────────────────────────────────────────
|
||||
@@ -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() }) {
|
||||
<stop offset="0%" stop-color=${glowColor} stop-opacity="0.12" />
|
||||
<stop offset="100%" stop-color=${glowColor} stop-opacity="0" />
|
||||
</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>
|
||||
<circle cx=${cx} cy=${cy} r="96" fill="url(#scope-bg-glow)" />
|
||||
|
||||
<!-- Sky-colour wash behind the readout (very subtle) -->
|
||||
<circle cx=${cx} cy=${cy} r="58" fill=${skyFill} opacity="0.16" />
|
||||
<!-- Full sky + grass + sun/moon scene behind the readout -->
|
||||
<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`
|
||||
<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" />
|
||||
${ticks.map((t, i) => html`
|
||||
<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="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`
|
||||
<g key=${i}>
|
||||
<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`
|
||||
<${Fragment}>
|
||||
<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}
|
||||
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="2.5" fill="#c8922a" />
|
||||
${loading
|
||||
? 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
|
||||
? html`<${Fragment}>
|
||||
<text x=${cx} y=${cy - 8} text-anchor="middle"
|
||||
fill="#1e1208" font-size="26"
|
||||
<text x=${cx} y="152" text-anchor="middle"
|
||||
fill=${readoutColor} font-size="26"
|
||||
font-family="Fraunces, serif" font-weight="700">
|
||||
${value.toFixed(1)}°
|
||||
</text>
|
||||
<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">
|
||||
UTCI NOW
|
||||
</text>
|
||||
@@ -611,7 +866,7 @@ function ScopeReticle({ value, cat, loading, elev = 0, dt = new Date() }) {
|
||||
</text>
|
||||
</>`
|
||||
: 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">
|
||||
AWAITING
|
||||
</text>`}
|
||||
@@ -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.
|
||||
-->
|
||||
<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) => {
|
||||
const band = confidenceBand(i);
|
||||
const locked = !isPro && i >= FREE_DAYS;
|
||||
@@ -914,6 +1358,7 @@ function UTCIForecast() {
|
||||
</button>`;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
PRO UPSELL CARD — shown when a locked day is clicked.
|
||||
@@ -1058,7 +1503,12 @@ function UTCIForecast() {
|
||||
{ key: 'hour', label: 'Hour' },
|
||||
{ key: 'air', label: 'Air' },
|
||||
{ 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: 'dir', label: 'Dir' },
|
||||
{ key: 'cloud', label: 'Cloud' },
|
||||
{ key: 'sun', label: 'Sun' },
|
||||
{ key: 'direct', label: 'Direct' },
|
||||
@@ -1066,6 +1516,9 @@ function UTCIForecast() {
|
||||
{ key: 'tmrt', label: 'Tmrt' },
|
||||
{ key: 'delta', label: 'Δ' },
|
||||
{ key: 'utci', label: 'UTCI' },
|
||||
{ key: 'uvA', label: 'UV-A' },
|
||||
{ key: 'uvB', label: 'UV-B' },
|
||||
{ key: 'burn', label: 'Burn' },
|
||||
{ key: 'precip', label: 'Precip' },
|
||||
{ key: 'utciP', label: 'UTCI+P' },
|
||||
].map(c => html`
|
||||
@@ -1074,6 +1527,17 @@ function UTCIForecast() {
|
||||
class=${`col-toggle${visibleCols[c.key] ? ' on' : ''}`}
|
||||
onClick=${() => toggleCol(c.key)}
|
||||
>${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>`
|
||||
: html`
|
||||
<div class="col-toggles" style=${{ opacity: 0.85 }}>
|
||||
@@ -1091,13 +1555,24 @@ function UTCIForecast() {
|
||||
heading, edit the text inside the matching <th>.
|
||||
-->
|
||||
<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>
|
||||
<tr>
|
||||
${visibleCols.hour && html`<th>Hour</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.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.sun && html`<th>Sun <span class="col-unit">elev°</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.delta && html`<th>Δ <span class="col-unit">UTCI−Air</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.utciP && html`<th>UTCI+P <span class="col-unit">°C adj.</span></th>`}
|
||||
</tr>
|
||||
</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>
|
||||
${visible.map((r) => {
|
||||
const cat = utciCategory(r.utci);
|
||||
@@ -1125,14 +1611,36 @@ function UTCIForecast() {
|
||||
<td class="utci-time">
|
||||
${isNow && html`<span class="now-pip"></span>`}
|
||||
<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>
|
||||
</td>`}
|
||||
${visibleCols.air && html`<td>${r.Ta.toFixed(1)}</td>`}
|
||||
${visibleCols.rh && html`<td>${Math.round(r.RH)}</td>`}
|
||||
${visibleCols.wind && html`<td>${r.va.toFixed(1)}</td>`}
|
||||
${visibleCols.cloud && html`<td>${Math.round(r.cc)}</td>`}
|
||||
${visibleCols.dew && html`<td>${r.dew != null ? r.dew.toFixed(1) : '—'}</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.direct && html`<td>${Math.round(r.dir)}</td>`}
|
||||
${visibleCols.diffuse && html`<td>${Math.round(r.dif)}</td>`}
|
||||
@@ -1150,13 +1658,25 @@ function UTCIForecast() {
|
||||
${r.utci.toFixed(1)}
|
||||
</span>
|
||||
</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`
|
||||
<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) : '—'}
|
||||
</td>`}
|
||||
${visibleCols.utciP && html`
|
||||
<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)}
|
||||
</span>
|
||||
</td>`}
|
||||
@@ -1165,6 +1685,7 @@ function UTCIForecast() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="utci-legend">
|
||||
<span class="utci-legend-label">Thermal stress bands</span>
|
||||
|
||||
+2
-2
@@ -6,10 +6,10 @@
|
||||
<meta name="description" content="SunScope - UTCI weather forecast showing real thermal comfort, not just air temperature." />
|
||||
<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="stylesheet" href="./assets/sunscope.css?v=14d-local9">
|
||||
<link rel="stylesheet" href="./assets/sunscope.css?v=14k">
|
||||
</head>
|
||||
<body>
|
||||
<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>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user