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;
|
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;
|
||||||
|
|||||||
+594
-73
@@ -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
|
||||||
<thead>
|
horizontally; the inner .utci-thead-track is shifted
|
||||||
<tr>
|
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.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,11 +1580,22 @@ 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">UTCI−Air</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.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>
|
||||||
<tbody>
|
</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) => {
|
${visible.map((r) => {
|
||||||
const cat = utciCategory(r.utci);
|
const cat = utciCategory(r.utci);
|
||||||
const isNight = r.elev < 0;
|
const isNight = r.elev < 0;
|
||||||
@@ -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,20 +1658,33 @@ 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>`}
|
||||||
</tr>`;
|
</tr>`;
|
||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="utci-legend">
|
<div class="utci-legend">
|
||||||
|
|||||||
+2
-2
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user