New code structure
Added profiles
Fixed skin pulldown
Added rain images
This commit is contained in:
fraxle
2026-05-13 15:16:45 +01:00
parent 8d1d691942
commit e18e128a34
12 changed files with 2935 additions and 2668 deletions
+132
View File
@@ -0,0 +1,132 @@
/* ════════════════════════════════════════════════════════════════════════
base.css — Fonts, reset, page shell, and top navigation.
════════════════════════════════════════════════════════════════════════
COLOUR PALETTE (the "warm parchment & brass" theme)
──────────────────────────────────────────────────────────────────────
#f5edd6 parchment page background
#fffcf2 soft cream cards, search box, table
#fdf8ee warm cream toolbars, legend, about block
#f0e4c4 pale honey table heading row
#fef3dc warm hover buttons on hover
#fef8ea row hover table row on hover
#fff8e4 "now" row the currently-active hour
#1e1208 espresso headings + emphasis
#4a3420 warm dark body text in the table
#6b4f2a muted bronze subtle emphasis
#7a5c2a tagline brown italic intro line
#9a7d5a soft brown column toggle text
#b09870 warm grey muted labels
#c0a880 pale dust night-row text, footer
#c8922a BRASS brand accent (links, dial, focus)
#a06e18 deep brass hover state for some links
#c9b08a warm border panel borders
#d4c0a0 soft border toolbar/legend borders
#ede4cc line table row dividers
FONTS
──────────────────────────────────────────────────────────────────────
Fraunces serif display titles, day labels, hour cell
Manrope body sans-serif paragraphs
JetBrains Mono numeric monospace table data, small labels
════════════════════════════════════════════════════════════════════════ */
/* ── 1. FONTS + RESET ───────────────────────────────────────────────── */
@import "https://fonts.bunny.net/css2?family=Fraunces:opsz,wght@9..144,300;9..144,500;9..144,700;9..144,900&family=JetBrains+Mono:wght@400;600&family=Manrope:wght@400;500;600;700&display=swap";
*,
*:before,
*:after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
/* ── 2. PAGE BACKGROUND & SHELL ─────────────────────────────────────── */
body {
background: #f5edd6; /* parchment */
}
.utci-app {
font-family: Manrope, sans-serif;
color: #1e1208;
background: #f5edd6;
min-height: 100vh;
padding: 52px 36px 32px;
position: relative;
/* 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.
Comment one out to remove that side's glow. */
.lens-bloom-a {
position: fixed;
top: -200px;
left: -200px;
width: 700px;
height: 700px;
border-radius: 50%;
background: radial-gradient(ellipse, rgba(255, 210, 60, 0.18) 0%, transparent 65%);
pointer-events: none;
z-index: 0;
}
.lens-bloom-b {
position: fixed;
top: -80px;
right: -140px;
width: 520px;
height: 420px;
background: radial-gradient(ellipse, rgba(200, 146, 42, 0.13) 0%, transparent 68%);
pointer-events: none;
z-index: 0;
}
/* The centred container that holds everything. */
.utci-shell {
position: relative;
z-index: 1;
max-width: 1400px;
margin: 0 auto;
}
/* ── 3. TOP NAVIGATION (Home / About links in the corner) ──────────── */
.utci-topnav {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 10;
display: flex;
align-items: center;
justify-content: flex-end;
padding: 18px 32px;
gap: 28px;
}
.utci-topnav a {
font-family: JetBrains Mono, monospace;
font-size: 16px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: #b09870;
text-decoration: none;
border-bottom: 1px solid transparent;
padding-bottom: 2px;
transition: color 0.2s, border-color 0.2s;
}
.utci-topnav a:hover {
color: #c8922a; /* brass on hover */
border-bottom-color: #c8922a80; /* faint underline */
}
+197
View File
@@ -0,0 +1,197 @@
/* ════════════════════════════════════════════════════════════════════════
header.css — Page header: title wordmark, UTCI dial, and search box.
════════════════════════════════════════════════════════════════════════ */
/* ── 4. HEADER (title + dial + search, three-column grid) ──────────── */
.utci-header {
display: grid;
grid-template-columns: 1fr auto 1fr; /* title | dial | search */
gap: 32px;
align-items: center;
padding-bottom: 32px;
border-bottom: 2px solid #c9b08a;
margin-bottom: 32px;
}
.header-right {
display: flex;
flex-direction: column;
align-items: flex-end;
justify-content: center;
}
/* The big "SUNScope" wordmark */
.utci-title {
font-family: Fraunces, serif;
font-weight: 900;
font-variation-settings: "opsz" 144;
font-size: clamp(48px, 6vw, 80px); /* responsive — scales with viewport */
line-height: 0.9;
letter-spacing: -0.03em;
color: #1e1208;
}
.utci-title .title-sun { /* "SUN" — bold + brass */
color: #c8922a;
font-weight: 900;
font-style: normal;
}
.utci-title .title-scope { /* "Scope" — italic */
font-style: italic;
font-weight: 900;
color: #1e1208;
}
.utci-title .title-beta { /* the small "beta" subscript */
font-size: 0.38em;
text-transform: none;
font-weight: 400;
letter-spacing: 0.05em;
vertical-align: super;
color: #b09870;
}
/* "See the sun the way..." */
.utci-tagline {
font-family: Fraunces, serif;
font-style: italic;
font-size: 17px;
color: #7a5c2a;
margin-top: 14px;
}
/* The credits line under the tagline */
.utci-subtitle {
font-family: JetBrains Mono, monospace;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.18em;
margin-top: 10px;
color: #b09870;
}
.utci-subtitle-link { /* The "UTCI" link in the credits */
color: #c8922a;
text-decoration: none;
border-bottom: 1px solid rgba(200, 146, 42, 0.4);
transition: border-color 0.2s, color 0.2s;
}
.utci-subtitle-link:hover {
color: #a06e18;
border-bottom-color: #a06e18;
}
/* "↳ Pangbourne, Berkshire" */
.utci-current-loc {
font-family: Fraunces, serif;
font-style: italic;
font-size: 18px;
margin-top: 12px;
color: #1e1208;
}
/* The lat/lon next to the location */
.utci-loc-coords {
font-family: JetBrains Mono, monospace;
font-style: normal;
font-size: 11px;
color: #b09870;
margin-left: 10px;
letter-spacing: 0.08em;
}
/* The big UTCI dial in the middle of the header.
Change width/height to resize it. */
.scope-ring {
width: 220px;
height: 220px;
display: block;
}
/* The search box on the right of the header */
.utci-search-wrap {
position: relative;
width: 300px;
}
.utci-search-label {
font-family: JetBrains Mono, monospace;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.18em;
color: #b09870;
display: block;
margin-bottom: 7px;
}
.utci-search {
width: 100%;
padding: 12px 16px;
border: 1.5px solid #c9b08a;
background: #fffcf2;
font-family: Manrope, sans-serif;
font-size: 14px;
color: #1e1208;
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
}
.utci-search::placeholder {
color: #c0a880;
}
.utci-search:focus {
border-color: #c8922a;
box-shadow: 0 0 0 3px #c8922a24; /* brass glow when focused */
}
/* The dropdown list of city results */
.utci-results {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: #fffcf2;
border: 1.5px solid #c9b08a;
border-top: none;
max-height: 260px;
overflow-y: auto;
z-index: 50;
box-shadow: 0 8px 24px #6446141f;
}
.utci-result {
padding: 11px 16px;
cursor: pointer;
border-bottom: 1px solid #ede4cc;
font-size: 14px;
transition: background 0.12s;
border-left: 3px solid transparent;
}
.utci-result:hover {
background: #fef3dc;
border-left-color: #c8922a; /* brass marker on hover */
}
/* The "GB · 51.48°, -1.07°" line under each city */
.utci-result-meta {
font-size: 11px;
color: #b09870;
font-family: JetBrains Mono, monospace;
margin-top: 3px;
letter-spacing: 0.06em;
}
.utci-searching {
margin-top: 7px;
font-size: 10px;
color: #b09870;
font-family: JetBrains Mono, monospace;
letter-spacing: 0.14em;
text-transform: uppercase;
}
+431
View File
@@ -0,0 +1,431 @@
/* ════════════════════════════════════════════════════════════════════════
table.css — Day tabs, filter profiles, column toggles, hourly table,
now-row and night-row decorations.
════════════════════════════════════════════════════════════════════════ */
/* ── 5. DAY TABS (today / tomorrow / etc.) ─────────────────────────── */
/* Note: each tab's background colour comes from inline styles in
app.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;
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;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.14em;
background: transparent;
border: none;
color: #b09870;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
transition: color 0.2s, border-color 0.2s;
}
.utci-day-tab:hover {
color: #6b4f2a;
}
.utci-day-tab.active {
color: #c8922a;
border-bottom-color: #c8922a;
}
/* The "Mon 12 May" date underneath each tab name */
.utci-day-date {
font-family: Fraunces, serif;
font-style: italic;
font-size: 13px;
text-transform: none;
letter-spacing: 0;
display: block;
margin-top: 3px;
color: inherit;
}
/* ── 5b. FILTER PROFILES (the "Profile: Basic Urban …" bar) ───────── */
.filter-profiles {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
margin-bottom: 0;
padding: 10px 14px;
background: #fdf8ee;
border: 1.5px solid #d4c0a0;
border-bottom: none; /* merges visually with the col-toggles bar below */
}
.filter-profiles-label {
font-family: JetBrains Mono, monospace;
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.18em;
color: #b09870;
margin-right: 4px;
white-space: nowrap;
}
.profile-btn {
padding: 4px 11px;
cursor: pointer;
border: 1.5px solid #c9b08a;
background: #f5edd6;
color: #9a7d5a;
font-family: JetBrains Mono, monospace;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.1em;
transition: all 0.15s;
-webkit-user-select: none;
user-select: none;
}
.profile-btn:hover {
border-color: #c8922a;
color: #6b4f2a;
background: #fef3dc;
}
.profile-btn.active {
background: #c8922a;
border-color: #c8922a;
color: #fff;
}
/* ── 6. COLUMN TOGGLES (the "Columns: Hour Air RH ..." bar) ────────── */
.col-toggles {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
margin-bottom: 10px;
padding: 10px 14px;
background: #fdf8ee;
border: 1.5px solid #d4c0a0;
}
.col-toggles-label {
font-family: JetBrains Mono, monospace;
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.18em;
color: #b09870;
margin-right: 4px;
white-space: nowrap;
}
/* The little pill buttons */
.col-toggle {
padding: 4px 11px;
cursor: pointer;
border: 1.5px solid #c9b08a;
background: #f5edd6;
color: #9a7d5a;
font-family: JetBrains Mono, monospace;
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.1em;
transition: all 0.15s;
-webkit-user-select: none;
user-select: none;
}
.col-toggle:hover {
border-color: #c8922a;
color: #6b4f2a;
background: #fef3dc;
}
/* The "pressed-in" active look */
.col-toggle.on {
background: #c8922a;
border-color: #c8922a;
color: #fff;
}
/* Burn select — looks like a col-toggle pill but is a <select>.
The native dropdown arrow is kept (no appearance:none) so users
know it's a dropdown. Active (burn on) state uses the same brass
as .col-toggle.on and the label updates to show the chosen skin. */
.burn-select {
cursor: pointer;
outline: none;
}
.burn-select:focus {
border-color: #c8922a;
}
/* ── 7. HOURLY TABLE ────────────────────────────────────────────────── */
.utci-table-wrap {
/* 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;
/* Fixed layout so the explicit per-cell widths set by the JS width-sync
(see useLayoutEffect in app.js) are actually enforced. Without
this the browser's auto layout treats those widths as suggestions and
redistributes space, which makes the header and body columns drift
apart the further right you scroll. */
table-layout: fixed;
}
/* 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;
font-family: JetBrains Mono, monospace;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.14em;
font-weight: 600;
padding: 14px 12px;
text-align: right;
white-space: nowrap;
}
/* The Hour column aligns left, the rest align right */
.utci-table th:first-child,
.utci-table td:first-child {
text-align: left;
}
/* The "°C" / "m/s" etc. unit underneath each heading */
.utci-table th .col-unit {
display: block;
color: #c8922a;
font-size: 9px;
margin-top: 2px;
}
.utci-table tbody tr {
border-bottom: 1px solid #ede4cc;
transition: background 0.1s;
}
.utci-table tbody tr:hover {
background: #fef8ea;
}
.utci-table td {
padding: 10px 12px;
text-align: right;
font-family: JetBrains Mono, monospace;
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;
}
/* The Dir column is centered on both desktop and mobile so the
compass vane sits with even padding either side — especially
important on mobile where the compass label is hidden and there's
no text to balance the right-aligned content. */
.utci-table th.utci-dir-cell,
.utci-table td.utci-dir-cell {
text-align: center;
}
/* "Tight" header — for columns whose body content is narrower than
the default tracked-out header would suggest (Air, Pcpt). Reducing
the letter-spacing shrinks the heading's natural width so it stops
driving the column wider than the body needs. Right-aligned values
then sit with balanced visual padding on either side. */
.utci-table th.utci-tight-head {
letter-spacing: 0.04em;
}
/* ── 8. NOW-ROW & NIGHT-ROW DECORATIONS ─────────────────────────────── */
/* "Currently this hour" — the highlighted row.
The row gets a cream-yellow background and darker text. No left
stripe or pip — the colour shift alone signals "this is now". */
.utci-table tbody tr.is-now {
background: #fff8e4 !important;
}
.utci-table tbody tr.is-now td {
color: #1e1208;
/* Brass bracket framing the current-hour row top and bottom — clean,
definitive, no left stripe needed. */
border-top: 2px solid #c8922a;
border-bottom: 2px solid #c8922a;
}
/* Night-time hours get a slightly muted text colour */
.utci-table tbody tr.is-night td {
color: #c0a880;
}
/* The time cell — uses the display serif instead of monospace */
.utci-time {
font-family: Fraunces, serif !important;
font-weight: 500;
font-size: 16px;
color: #1e1208 !important;
}
/* The coloured pill inside UTCI / UTCI+P cells.
The pill's BACKGROUND comes from inline styles in app.js
(the utciCategory() function decides which thermal-stress colour
to apply). This rule just handles the pill shape. */
.utci-cell {
display: inline-block;
padding: 4px 12px;
border-radius: 3px;
font-weight: 600;
min-width: 60px;
text-align: center;
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;
text-transform: uppercase;
letter-spacing: 0.08em;
}
+170
View File
@@ -0,0 +1,170 @@
/* ════════════════════════════════════════════════════════════════════════
ui.css — Status banners, legend, about block, footer, and responsive.
════════════════════════════════════════════════════════════════════════ */
/* ── 9. STATUS & ERROR BANNERS ("Acquiring forecast data…") ────────── */
.utci-status {
padding: 14px 18px;
background: #f0e4c4;
color: #7a5c2a;
font-family: JetBrains Mono, monospace;
font-size: 11px;
text-align: center;
letter-spacing: 0.12em;
text-transform: uppercase;
border: 1.5px solid #c9b08a;
margin-bottom: 18px;
}
/* ── 10. LEGEND (thermal-stress band colours at the bottom) ────────── */
.utci-legend {
margin-top: 22px;
padding: 18px 20px;
background: #fdf8ee;
border: 1.5px solid #c9b08a;
}
.utci-legend-label {
font-family: JetBrains Mono, monospace;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.18em;
color: #b09870;
margin-bottom: 12px;
display: block;
font-weight: 600;
}
.utci-legend-row {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.utci-legend-item {
padding: 6px 12px;
font-family: JetBrains Mono, monospace;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.06em;
flex: 1;
min-width: 110px;
text-align: center;
opacity: 0.9;
}
/* ── 11. ABOUT + FOOTER ─────────────────────────────────────────────── */
.utci-about {
margin-top: 28px;
padding: 24px 28px;
background: #fdf8ee;
border: 1.5px solid #c9b08a;
}
.utci-about-heading {
font-family: Fraunces, serif;
font-size: 18px;
font-weight: 700;
color: #1e1208;
margin-bottom: 14px;
letter-spacing: -0.01em;
}
.utci-about-text {
font-family: Manrope, sans-serif;
font-size: 14px;
line-height: 1.75;
color: #4a3420;
margin-bottom: 12px;
max-width: 860px; /* keeps line length readable */
}
.utci-about-text:last-child {
margin-bottom: 0;
}
.utci-about-text strong {
color: #1e1208;
font-weight: 600;
}
.utci-about-text em {
font-style: italic;
color: #6b4f2a;
}
.utci-footer {
margin-top: 36px;
padding-top: 20px;
border-top: 1px solid #d4c0a0;
font-family: JetBrains Mono, monospace;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.12em;
color: #c0a880;
line-height: 2.2;
}
/* The "Reading the table." intro snippet */
.utci-footer em {
font-family: Fraunces, serif;
font-style: italic;
text-transform: none;
letter-spacing: 0;
font-size: 14px;
color: #7a5c2a;
}
/* ── 12. RESPONSIVE (mobile) ───────────────────────────────────────── */
/* On screens narrower than 800px, stack the header vertically and
shrink the dial. Add more rules in this block to tweak mobile
layout further. */
@media (max-width: 800px) {
.utci-app {
padding: 52px 10px 32px;
}
.utci-header {
grid-template-columns: 1fr; /* stack the 3 header columns */
text-align: center;
}
.header-right {
align-items: center;
}
.utci-search-wrap {
width: 100%;
}
.scope-ring {
width: 180px; /* smaller dial on mobile */
height: 180px;
margin: 0 auto;
}
/* Halve the hourly-table cell padding on mobile so more columns fit
in the viewport before the horizontal scrollbar kicks in. */
.utci-table th {
padding: 7px 6px;
}
.utci-table td {
padding: 5px 6px;
}
/* Drop the compass-label text ("N", "NW"...) next to the wind vane on
mobile — the vane arrow alone is enough at small sizes. */
.wind-dir-label {
display: none;
}
}
+1005
View File
File diff suppressed because it is too large Load Diff
+496
View File
@@ -0,0 +1,496 @@
// ════════════════════════════════════════════════════════════════════════
// components.js — Preact UI components (SVG widgets).
//
// Exports:
// SkyScope({ elev, dt, size }) little porthole circle per hour
// WindVane({ bearing, size }) compass arrow
// CloudIcon({ category, size, elev, dt }) cloud/sun/moon icon
// ScopeReticle({ value, cat, loading, elev, dt }) big UTCI dial
// PrecipIcon({ precip, snow, size }) rain/snow icon for precip cell
//
// All components use the html`` tagged template from htm + Preact.
// They depend on utils.js (skyFillForElev, grassFillForElev,
// moonPhaseFraction) but have no other external state.
// ════════════════════════════════════════════════════════════════════════
import { h, Fragment } from '../vendor/preact.js';
import htm from '../vendor/htm.js';
import {
skyFillForElev, grassFillForElev,
moonPhaseFraction,
} from './utils.js';
const html = htm.bind(h);
// ═══════════════════════════════════════════════════════════════════
// SKYSCOPE — the little porthole circle next to each hour.
// ───────────────────────────────────────────────────────────────────
// What gets drawn (back to front):
// 1. Sky disk — colour from skyFillForElev() above
// 2. Horizon line — faint dashed line at the middle
// 3. Sun OR moon — sun positioned vertically by elevation;
// moon shows real phase (synodic period)
// 4. Brass ring — the telescope/scope edge (#c8922a)
// 5. Lens highlight — subtle inner glint
//
// Tweakable bits inside this function:
// • size — pass a different size= when calling for bigger/smaller
// • innerR — how thick the brass ring looks
// • sunR — the sun's drawn radius
// • the stroke colour #c8922a is the brass — change for a different metal
// ═══════════════════════════════════════════════════════════════════
export function SkyScope({ elev, dt, size = 26 }) {
const r = size / 2;
const innerR = r - 1.2;
const skyFill = skyFillForElev(elev);
const isDay = elev > -3;
const elevClamped = Math.max(-30, Math.min(90, elev));
const sunY = r - Math.sin((elevClamped * Math.PI) / 180) * (innerR - 2.5);
const sunR = Math.max(2.2, innerR * 0.32);
const phase = moonPhaseFraction(dt);
const moonR = innerR * 0.55;
const phaseOffset = Math.cos(2 * Math.PI * phase) * moonR;
const moonLitFromRight = phase < 0.5;
const shadowCx = r + (moonLitFromRight ? -phaseOffset : phaseOffset);
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' }}>
<defs>
<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} />
<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`
<g clip-path=${`url(#${clipId})`}>
<circle cx=${r} cy=${sunY} r=${sunR + 1.4} fill="#ffe9a0" opacity="0.55" />
<circle cx=${r} cy=${sunY} r=${sunR} fill="#fff3a8" stroke="#e6a32a" stroke-width="0.4" />
</g>`
: html`
<g clip-path=${`url(#${clipId})`}>
<circle cx=${r} cy=${r} r=${moonR} fill="#f5edd6" />
<circle cx=${shadowCx} cy=${r} r=${moonR} fill=${skyFill} />
<circle cx=${r} cy=${r} r=${moonR} fill="none" stroke="rgba(245,237,214,0.45)" stroke-width="0.4" />
</g>`}
<circle cx=${r} cy=${r} r=${innerR} fill="none" stroke="#c8922a" stroke-width="0.9" />
<circle cx=${r} cy=${r} r=${innerR - 0.5} fill="none" stroke="rgba(255,255,255,0.22)" stroke-width="0.4" />
</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
// ═══════════════════════════════════════════════════════════════════
export 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
// ═══════════════════════════════════════════════════════════════════
export 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.
// ───────────────────────────────────────────────────────────────────
// This is the one with tick marks, the swept colour band, and the
// needle pointing at the current felt-temperature.
//
// Tweakable bits:
// • R = 86 outer ring radius (changes overall size)
// • cx, cy = 100 centre point (leave alone unless you also
// change the viewBox="0 0 200 200" below)
// • { length: 36 } number of tick marks (1 every 10°)
// • stressBands[] the colour ramp around the rim (matches UTCI)
// • (value + 10) / 60 maps UTCI -10..50 onto the 270° sweep —
// widen the dial range by changing those numbers
// ═══════════════════════════════════════════════════════════════════
export function ScopeReticle({ value, cat, loading, elev = 0, dt = new Date() }) {
// ── 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);
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 = 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;
const major = i % 9 === 0;
const medium = i % 3 === 0;
const r2 = major ? R - 14 : medium ? R - 8 : R - 4;
return {
x1: cx + R * Math.cos(rad), y1: cy + R * Math.sin(rad),
x2: cx + r2 * Math.cos(rad), y2: cy + r2 * Math.sin(rad),
major, medium,
};
});
const stressBands = [
{ min: -40, max: -27, color: '#23408f' },
{ min: -27, max: -13, color: '#3f73c4' },
{ min: -13, max: 0, color: '#7eb0e0' },
{ min: 0, max: 9, color: '#bcd9ec' },
{ min: 9, max: 18, color: '#c8dcc0' },
{ min: 18, max: 26, color: '#6ab05a' },
{ min: 26, max: 32, color: '#e8c547' },
{ min: 32, max: 38, color: '#dc8a3a' },
{ min: 38, max: 46, color: '#c44a3a' },
{ min: 46, max: 50, color: '#7a1a1a' },
];
function fracToXY(frac, r) {
const deg = 135 + frac * 270;
const rad = (deg * Math.PI) / 180;
return [cx + r * Math.cos(rad), cy + r * Math.sin(rad)];
}
function bandArcPath(band) {
const f1 = Math.min(1, Math.max(0, (band.min + 10) / 60));
const f2 = Math.min(1, Math.max(0, (band.max + 10) / 60));
const arcR = R - 18;
const [x1, y1] = fracToXY(f1, arcR);
const [x2, y2] = fracToXY(f2, arcR);
const large = (f2 - f1) * 270 > 180 ? 1 : 0;
return `M ${x1} ${y1} A ${arcR} ${arcR} 0 ${large} 1 ${x2} ${y2}`;
}
let needleX = cx, needleY = cy + 54;
if (value != null) {
const frac = Math.min(1, Math.max(0, (value + 10) / 60));
const deg = 135 + frac * 270;
const rad = (deg * Math.PI) / 180;
needleX = cx + 54 * Math.cos(rad);
needleY = cy + 54 * Math.sin(rad);
}
const glowColor = cat ? cat.bg : '#c8922a';
return html`
<svg viewBox="0 0 200 200" class="scope-ring" aria-label="Current UTCI scope readout">
<defs>
<radialGradient id="scope-bg-glow" cx="50%" cy="50%" r="50%">
<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)" />
<!-- 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" 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}
stroke=${t.major ? '#c8922a' : t.medium ? '#c9b08a' : '#e0d0b0'}
stroke-width=${t.major ? 1.5 : 0.75} />`)}
<line x1=${cx - R + 3} y1=${cy} x2=${cx - 32} y2=${cy} stroke="#d4b896" stroke-width="0.75" />
<line x1=${cx + 32} y1=${cy} x2=${cx + R - 3} y2=${cy} stroke="#d4b896" stroke-width="0.75" />
<line x1=${cx} y1=${cy - R + 3} x2=${cx} y2=${cy - 32} stroke="#d4b896" stroke-width="0.75" />
<line x1=${cx} y1=${cy + 32} x2=${cx} y2=${cy + R - 3} stroke="#d4b896" 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" />
${[[-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}
stroke="#c9b08a" stroke-width="1" />
<line x1=${cx + sx*72} y1=${cy + sy*72} x2=${cx + sx*72} y2=${cy + sy*60}
stroke="#c9b08a" stroke-width="1" />
</g>`)}
${value != null && html`
<${Fragment}>
<line x1=${cx} y1=${cy} x2=${needleX} y2=${needleY}
stroke="#c8922a" stroke-width="5" stroke-linecap="round" opacity="0.18" />
<line x1=${cx} y1=${cy} x2=${needleX} y2=${needleY}
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=${readoutMutedColor} font-size="11" font-family="monospace">· · ·</text>`
: value != null
? html`<${Fragment}>
<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 + 15} text-anchor="middle"
fill=${readoutMutedColor} font-size="6.5"
font-family="JetBrains Mono, monospace" letter-spacing="2">
UTCI NOW
</text>
<text x=${cx} y=${cy + 23} text-anchor="middle"
fill=${glowColor} font-size="7"
font-family="JetBrains Mono, monospace" letter-spacing="0.8">
${cat.label.toUpperCase()}
</text>
</>`
: html`<text x=${cx} y=${cy + 5} text-anchor="middle"
fill=${readoutMutedColor} font-size="8"
font-family="JetBrains Mono, monospace" letter-spacing="1.2">
AWAITING
</text>`}
</svg>`;
}
// ═══════════════════════════════════════════════════════════════════
// PRECIPICON — rain drop or snowflake SVG for the precip cell.
// ───────────────────────────────────────────────────────────────────
// precip — mm/h rainfall
// snow — cm/h snowfall
// size — pixel size (default 28 to match CloudIcon)
//
// Intensity bands:
// dry → faint dash
// light rain (< 1 mm/h) → 1 drop
// moderate rain(< 4 mm/h) → 2 drops
// heavy rain (≥ 4 mm/h) → 3 drops
// light snow (< 0.5cm/h) → 1 flake
// heavy snow (≥ 0.5cm/h) → 2 flakes
// mixed → 1 drop + 1 flake
// ═══════════════════════════════════════════════════════════════════
export function PrecipIcon({ precip = 0, snow = 0, size = 28 }) {
const r = size / 2;
const hasRain = precip > 0;
const hasSnow = snow > 0;
const mixed = hasRain && hasSnow;
// Drop shape: teardrop path centred on (cx, cy), radius dr
const drop = (cx, cy, dr) => {
const tip = cy + dr * 1.55;
const cw = dr * 0.72;
return `M ${cx} ${tip} C ${cx - cw} ${cy + dr * 0.6}, ${cx - dr} ${cy - dr * 0.3}, ${cx} ${cy - dr} C ${cx + dr} ${cy - dr * 0.3}, ${cx + cw} ${cy + dr * 0.6}, ${cx} ${tip} Z`;
};
// Six-pointed snowflake: centre lines + diagonal lines
const flake = (cx, cy, fr) => html`
<g stroke="#6090c8" stroke-width=${Math.max(1, size * 0.055)} stroke-linecap="round">
<line x1=${cx - fr} y1=${cy} x2=${cx + fr} y2=${cy} />
<line x1=${cx} y1=${cy - fr} x2=${cx} y2=${cy + fr} />
<line x1=${cx - fr * 0.7} y1=${cy - fr * 0.7} x2=${cx + fr * 0.7} y2=${cy + fr * 0.7} />
<line x1=${cx + fr * 0.7} y1=${cy - fr * 0.7} x2=${cx - fr * 0.7} y2=${cy + fr * 0.7} />
${[-1, 1].map(sx => [-1, 1].map(sy => html`
<line key=${`${sx}${sy}`}
x1=${cx + sx * fr * 0.55} y1=${cy + sy * 0}
x2=${cx + sx * fr * 0.75} y2=${cy + sy * fr * 0.3} />`))}
</g>`;
// Dry — just a faint dash
if (!hasRain && !hasSnow) {
return html`
<svg width=${size} height=${size} viewBox=${`0 0 ${size} ${size}`}
style=${{ flexShrink: 0, display: 'inline-block', verticalAlign: 'middle' }}>
<line x1=${r - size * 0.2} y1=${r} x2=${r + size * 0.2} y2=${r}
stroke="#c0a880" stroke-width="1.5" stroke-linecap="round" />
</svg>`;
}
// Rain intensity → number of drops
const rainDrops = !hasRain ? 0 : precip < 1 ? 1 : precip < 4 ? 2 : 3;
// Snow intensity → number of flakes
const snowFlakes = !hasSnow ? 0 : snow < 0.5 ? 1 : 2;
const dropColor = '#5090b0';
const dr = size * 0.13; // drop radius
const fr = size * 0.18; // flake arm length
// Layout: spread items evenly across the icon width
const items = (mixed ? rainDrops + snowFlakes : hasRain ? rainDrops : snowFlakes);
const spacing = size / (items + 1);
return html`
<svg width=${size} height=${size} viewBox=${`0 0 ${size} ${size}`}
style=${{ flexShrink: 0, display: 'inline-block', verticalAlign: 'middle', overflow: 'visible' }}>
${mixed
? html`
${Array.from({ length: rainDrops }, (_, i) => html`
<path key=${'d' + i} d=${drop(spacing * (i + 1), r - dr * 0.3, dr)}
fill=${dropColor} opacity="0.9" />`)}
${Array.from({ length: snowFlakes }, (_, i) =>
flake(spacing * (rainDrops + i + 1), r, fr)
)}`
: hasRain
? Array.from({ length: rainDrops }, (_, i) => html`
<path key=${i} d=${drop(spacing * (i + 1), r - dr * 0.3, dr)}
fill=${dropColor} opacity="0.9" />`)
: Array.from({ length: snowFlakes }, (_, i) =>
flake(spacing * (i + 1), r, fr)
)}
</svg>`;
}
+17
View File
@@ -0,0 +1,17 @@
// ════════════════════════════════════════════════════════════════════════
// main.js — Entry point. Imports the app and mounts it into the page.
//
// This is the file loaded by index.html as <script type="module">.
// It does nothing except wire the root component to the DOM node.
//
// If nothing appears on the page, check that:
// 1. index.html contains <div id="root"></div>
// 2. index.html loads THIS file as <script type="module" src="...">
// 3. The browser console (F12) shows no import errors
// ════════════════════════════════════════════════════════════════════════
import { h, render } from '../vendor/preact.js';
import { UTCIForecast } from './app.js';
const rootEl = document.getElementById('root');
if (rootEl) render(h(UTCIForecast, null), rootEl);
+214
View File
@@ -0,0 +1,214 @@
// ════════════════════════════════════════════════════════════════════════
// physics.js — Physical constants and meteorological calculations.
//
// Exports:
// SIGMA, EPSILON_P, A_K, ALBEDO_GRASS (radiation constants)
// vaporPressureHpa(Ta, RH) Magnus formula → hPa
// solarElevationDeg(lat, lon, dateUTC) NOAA simplified solar position
// calcTmrt(Ta, dirRad, diffRad, globalRad, solElev) Mean radiant temp
// utciApprox(Ta, Tmrt, va10, ehPa) Bröde et al. 2012 polynomial
//
// Nothing in here should need editing unless the underlying science
// changes. All numbers are peer-reviewed constants or coefficients.
// ════════════════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════════
// PHYSICAL CONSTANTS
// ───────────────────────────────────────────────────────────────────
// These are real-world physics values — don't change them unless you
// have a peer-reviewed reason. They're used by calcTmrt() below to
// work out how much heat your skin actually absorbs from the sun.
// SIGMA — StefanBoltzmann constant (radiates heat)
// EPSILON_P — emissivity of human skin (~0.97)
// A_K — short-wave absorption coefficient for clothing
// ALBEDO_GRASS — how much sun grass reflects back at you (23%)
// ═══════════════════════════════════════════════════════════════════
export const SIGMA = 5.670374419e-8;
export const EPSILON_P = 0.97;
export const A_K = 0.7;
export const ALBEDO_GRASS = 0.23;
// Vapour pressure (Magnus → hPa)
export function vaporPressureHpa(Ta, RH) {
const es = 6.105 * Math.exp((17.27 * Ta) / (237.7 + Ta));
return es * (RH / 100);
}
// Solar elevation (NOAA simplified, degrees)
export function solarElevationDeg(lat, lon, dateUTC) {
const start = Date.UTC(dateUTC.getUTCFullYear(), 0, 0);
const diff = dateUTC - start;
const DOY = Math.floor(diff / 86400000);
const hourUTC =
dateUTC.getUTCHours() +
dateUTC.getUTCMinutes() / 60 +
dateUTC.getUTCSeconds() / 3600;
const gamma = ((2 * Math.PI) / 365) * (DOY - 1 + (hourUTC - 12) / 24);
const eqtime =
229.18 *
(0.000075 +
0.001868 * Math.cos(gamma) -
0.032077 * Math.sin(gamma) -
0.014615 * Math.cos(2 * gamma) -
0.040849 * Math.sin(2 * gamma));
const decl =
0.006918 -
0.399912 * Math.cos(gamma) +
0.070257 * Math.sin(gamma) -
0.006758 * Math.cos(2 * gamma) +
0.000907 * Math.sin(2 * gamma) -
0.002697 * Math.cos(3 * gamma) +
0.00148 * Math.sin(3 * gamma);
const timeOffset = eqtime + 4 * lon;
const tst = hourUTC * 60 + timeOffset;
const ha = (((tst / 4) - 180) * Math.PI) / 180;
const latRad = (lat * Math.PI) / 180;
const cosZenith =
Math.sin(latRad) * Math.sin(decl) +
Math.cos(latRad) * Math.cos(decl) * Math.cos(ha);
const zenith = Math.acos(Math.max(-1, Math.min(1, cosZenith)));
return (Math.PI / 2 - zenith) * (180 / Math.PI);
}
// Mean radiant temperature
export function calcTmrt(Ta, dirRad, diffRad, globalRad, solElev) {
const TaK = Ta + 273.15;
let fp = 0;
if (solElev > 0) {
const h2 = solElev;
fp = 0.308 * Math.cos((Math.PI / 180) * h2 * (0.998 - (h2 * h2) / 50000));
}
let DNI = 0;
if (solElev > 1) {
DNI = dirRad / Math.sin((solElev * Math.PI) / 180);
DNI = Math.min(DNI, 1100);
}
const Sshort = A_K * (fp * DNI + 0.5 * diffRad + 0.5 * ALBEDO_GRASS * globalRad);
const Slong = EPSILON_P * SIGMA * Math.pow(TaK, 4);
const TmrtK = Math.pow((Sshort + Slong) / (EPSILON_P * SIGMA), 0.25);
return TmrtK - 273.15;
}
// ═══════════════════════════════════════════════════════════════════
// UTCI POLYNOMIAL — DO NOT EDIT (or be VERY careful if you do)
// ───────────────────────────────────────────────────────────────────
// This is the official 210-term Bröde et al. (2012) approximation.
// It takes air temp, mean radiant temp, wind, and humidity and returns
// the "felt" temperature. Every number is a peer-reviewed coefficient.
// Scroll past it — there's nothing here you'll want to change.
// ═══════════════════════════════════════════════════════════════════
export function utciApprox(Ta, Tmrt, va10, ehPa) {
const va = Math.max(0.5, Math.min(17, va10));
const D_Tmrt = Tmrt - Ta;
const Pa = ehPa / 10;
const T = Ta, V = va, D = D_Tmrt, P = Pa;
const T2=T*T, T3=T2*T, T4=T3*T, T5=T4*T, T6=T5*T;
const V2=V*V, V3=V2*V, V4=V3*V, V5=V4*V, V6=V5*V;
const D2=D*D, D3=D2*D, D4=D3*D, D5=D4*D, D6=D5*D;
const P2=P*P, P3=P2*P, P4=P3*P, P5=P4*P, P6=P5*P;
return T +
6.07562052e-1 +
-2.27712343e-2 * T + 8.06470249e-4 * T2 + -1.54271372e-4 * T3 +
-3.24651735e-6 * T4 + 7.32602852e-8 * T5 + 1.35959073e-9 * T6 +
-2.25836520e0 * V + 8.80326035e-2 * T*V + 2.16844454e-3 * T2*V +
-1.53347087e-5 * T3*V + -5.72983704e-7 * T4*V + -2.55090145e-9 * T5*V +
-7.51269505e-1 * V2 + -4.08350271e-3 * T*V2 + -5.21670675e-5 * T2*V2 +
1.94544667e-6 * T3*V2 + 1.14099531e-8 * T4*V2 +
1.58137256e-1 * V3 + -6.57263143e-5 * T*V3 + 2.22697524e-7 * T2*V3 +
-4.16117031e-8 * T3*V3 +
-1.27762753e-2 * V4 + 9.66891875e-6 * T*V4 + 2.52785852e-9 * T2*V4 +
4.56306672e-4 * V5 + -1.74202546e-7 * T*V5 +
-5.91491269e-6 * V6 +
3.98374029e-1 * D + 1.83945314e-4 * T*D + -1.73754510e-4 * T2*D +
-7.60781159e-7 * T3*D + 3.77830287e-8 * T4*D + 5.43079673e-10 * T5*D +
-2.00518269e-2 * V*D + 8.92859837e-4 * T*V*D + 3.45433048e-6 * T2*V*D +
-3.77925774e-7 * T3*V*D + -1.69699377e-9 * T4*V*D +
1.69992415e-4 * V2*D + -4.99204314e-5 * T*V2*D + 2.47417178e-7 * T2*V2*D +
1.07596466e-8 * T3*V2*D +
8.49242932e-5 * V3*D + 1.35191328e-6 * T*V3*D + -6.21531254e-9 * T2*V3*D +
-4.99410301e-6 * V4*D + -1.89489258e-8 * T*V4*D +
8.15300114e-8 * V5*D +
7.55043090e-4 * D2 + -5.65095215e-5 * T*D2 + -4.52166564e-7 * T2*D2 +
2.46688878e-8 * T3*D2 + 2.42674348e-10 * T4*D2 +
1.54547250e-4 * V*D2 + 5.24110970e-6 * T*V*D2 + -8.75874982e-8 * T2*V*D2 +
-1.50743064e-9 * T3*V*D2 +
-1.56236307e-5 * V2*D2 + -1.33895614e-7 * T*V2*D2 + 2.49709824e-9 * T2*V2*D2 +
6.51711721e-7 * V3*D2 + 1.94960053e-9 * T*V3*D2 +
-1.00361113e-8 * V4*D2 +
-1.21206673e-5 * D3 + -2.18203660e-7 * T*D3 + 7.51269482e-9 * T2*D3 +
9.79063848e-11 * T3*D3 +
1.25006734e-6 * V*D3 + -1.81584736e-9 * T*V*D3 + -3.52197671e-10 * T2*V*D3 +
-3.36514630e-8 * V2*D3 + 1.35908359e-10 * T*V2*D3 +
4.17032620e-10 * V3*D3 +
-1.30369025e-9 * D4 + 4.13908461e-10 * T*D4 + 9.22652254e-12 * T2*D4 +
-5.08220384e-9 * V*D4 + -2.24730961e-11 * T*V*D4 +
1.17139133e-10 * V2*D4 +
6.62154879e-10 * D5 + 4.03863260e-13 * T*D5 + 1.95087203e-12 * V*D5 +
-4.73602469e-12 * D6 +
5.12733497e0 * P + -3.12788561e-1 * T*P + -1.96701861e-2 * T2*P +
9.99690870e-4 * T3*P + 9.51738512e-6 * T4*P + -4.66426341e-7 * T5*P +
5.48050612e-1 * V*P + -3.30552823e-3 * T*V*P + -1.64119440e-3 * T2*V*P +
-5.16670694e-6 * T3*V*P + 9.52692432e-7 * T4*V*P +
-4.29223622e-2 * V2*P + 5.00845667e-3 * T*V2*P + 1.00601257e-6 * T2*V2*P +
-1.81748644e-6 * T3*V2*P +
-1.25813502e-3 * V3*P + -1.79330391e-4 * T*V3*P + 2.34994441e-6 * T2*V3*P +
1.29735808e-4 * V4*P + 1.29064870e-6 * T*V4*P +
-2.28558686e-6 * V5*P +
-3.69476348e-2 * D*P + 1.62325322e-3 * T*D*P + -3.14279680e-5 * T2*D*P +
2.59835559e-6 * T3*D*P + -4.77136523e-8 * T4*D*P +
8.64203390e-3 * V*D*P + -6.87405181e-4 * T*V*D*P + -9.13863872e-6 * T2*V*D*P +
5.15916806e-7 * T3*V*D*P +
-3.59217476e-5 * V2*D*P + 3.28696511e-5 * T*V2*D*P + -7.10542454e-7 * T2*V2*D*P +
-1.24382300e-5 * V3*D*P + -7.38584400e-9 * T*V3*D*P +
2.20609296e-7 * V4*D*P +
-7.32469180e-4 * D2*P + -1.87381964e-5 * T*D2*P + 4.80925239e-6 * T2*D2*P +
-8.75492040e-8 * T3*D2*P +
2.77862930e-5 * V*D2*P + -5.06004592e-6 * T*V*D2*P + 1.14325367e-7 * T2*V*D2*P +
2.53016723e-6 * V2*D2*P + -1.72857035e-8 * T*V2*D2*P +
-3.95079398e-8 * V3*D2*P +
-3.59413173e-7 * D3*P + 7.04388046e-7 * T*D3*P + -1.89309167e-8 * T2*D3*P +
-4.79768731e-7 * V*D3*P + 7.96079978e-9 * T*V*D3*P +
1.62897058e-9 * V2*D3*P +
3.94367674e-8 * D4*P + -1.18566247e-9 * T*D4*P +
3.34678041e-10 * V*D4*P +
-1.15606447e-10 * D5*P +
-2.80626406e0 * P2 + 5.48712484e-1 * T*P2 + -3.99428410e-3 * T2*P2 +
-9.54009191e-4 * T3*P2 + 1.93090978e-5 * T4*P2 +
-3.08806365e-1 * V*P2 + 1.16952364e-2 * T*V*P2 + 4.95271903e-4 * T2*V*P2 +
-1.90710882e-5 * T3*V*P2 +
2.10787756e-3 * V2*P2 + -6.98445738e-4 * T*V2*P2 + 2.30109073e-5 * T2*V2*P2 +
4.17856590e-4 * V3*P2 + -1.27043871e-5 * T*V3*P2 +
-3.04620472e-6 * V4*P2 +
5.14507424e-2 * D*P2 + -4.32510997e-3 * T*D*P2 + 8.99281156e-5 * T2*D*P2 +
-7.14663943e-7 * T3*D*P2 +
-2.66016305e-4 * V*D*P2 + 2.63789586e-4 * T*V*D*P2 + -7.01199003e-6 * T2*V*D*P2 +
-1.06823306e-4 * V2*D*P2 + 3.61341136e-6 * T*V2*D*P2 +
2.29748967e-7 * V3*D*P2 +
3.04788893e-4 * D2*P2 + -6.42070836e-5 * T*D2*P2 + 1.16257971e-6 * T2*D2*P2 +
7.68023384e-6 * V*D2*P2 + -5.47446896e-7 * T*V*D2*P2 +
-3.59937910e-8 * V2*D2*P2 +
-4.36497725e-6 * D3*P2 + 1.68737969e-7 * T*D3*P2 +
2.67489271e-8 * V*D3*P2 +
3.23926897e-9 * D4*P2 +
-3.53874123e-2 * P3 + -2.21201190e-1 * T*P3 + 1.55126038e-2 * T2*P3 +
-2.63917279e-4 * T3*P3 +
4.53433455e-2 * V*P3 + -4.32943862e-3 * T*V*P3 + 1.45389826e-4 * T2*V*P3 +
2.17508610e-4 * V2*P3 + -6.66724702e-5 * T*V2*P3 +
3.33217140e-5 * V3*P3 +
-2.26921615e-3 * D*P3 + 3.80261982e-4 * T*D*P3 + -5.45314314e-9 * T2*D*P3 +
-7.96355448e-4 * V*D*P3 + 2.53458034e-5 * T*V*D*P3 +
-6.31223658e-6 * V2*D*P3 +
3.02122035e-4 * D2*P3 + -4.77403547e-6 * T*D2*P3 +
1.73825715e-6 * V*D2*P3 +
-4.09087898e-7 * D3*P3 +
6.14155345e-1 * P4 + -6.16755931e-2 * T*P4 + 1.33374846e-3 * T2*P4 +
3.55375387e-3 * V*P4 + -5.13027851e-4 * T*V*P4 +
1.02449757e-4 * V2*P4 +
-1.48526421e-3 * D*P4 + -4.11469183e-5 * T*D*P4 +
-6.80434415e-6 * V*D*P4 +
-9.77675906e-6 * D2*P4 +
8.82773108e-2 * P5 + -3.01859306e-3 * T*P5 +
1.04452989e-3 * V*P5 +
2.47090539e-4 * D*P5 +
1.48348065e-3 * P6;
}
+247
View File
@@ -0,0 +1,247 @@
// ════════════════════════════════════════════════════════════════════════
// utils.js — Pure helper functions and lookup tables.
//
// Exports:
// utciCategory(u) UTCI stress band → {label,bg,fg}
// precipPenalty(precipMm,snowCmH,windMs) SunScope soak-factor
// windCompass8(deg) bearing → {label, snapped}
// uvSplit(uv, elevDeg) total UV → {uvA, uvB}
// SKIN_TYPES Fitzpatrick skin type table
// sunburnMinutes(uv, skinType) minutes to MED
// burnLabel(mins) formats burn time as "12m"/"1.5h"
// cloudCategory(total,low,mid,high) → 'clear'|'wispy'|'scattered'|'overcast'
// confidenceBand(i) day-tab gradient + label
// moonPhaseFraction(date) 0..1 synodic phase
// moonGlyph(p) phase fraction → emoji
// skyFillForElev(e) solar elevation → sky hex colour
// grassFillForElev(e) solar elevation → {top,bot} hex
// ════════════════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════════
// UTCI THERMAL STRESS BANDS — the coloured pills in the table.
// ───────────────────────────────────────────────────────────────────
// To recolour any band, change its bg/fg hex code. To shift the
// boundary between bands (e.g. make "Comfortable" wider), change the
// `if (u < …)` thresholds. Order matters — they're checked top-down.
// ═══════════════════════════════════════════════════════════════════
export function utciCategory(u) {
if (u < -40) return { label: 'Extreme cold', bg: '#1a1438', fg: '#fff' };
if (u < -27) return { label: 'Very strong cold', bg: '#23408f', fg: '#fff' };
if (u < -13) return { label: 'Arctic', bg: '#3f73c4', fg: '#fff' };
if (u < 0) return { label: 'Freezing', bg: '#7eb0e0', fg: '#1a1612' };
if (u < 9) return { label: 'Cold', bg: '#bcd9ec', fg: '#1a1612' };
if (u < 18) return { label: 'Chilled', bg: '#c8dcc0', fg: '#1a1612' };
if (u < 26) return { label: 'Comfortable', bg: '#6ab05a', fg: '#fff' };
if (u < 32) return { label: 'Moderate heat', bg: '#e8c547', fg: '#1a1612' };
if (u < 38) return { label: 'Strong heat', bg: '#dc8a3a', fg: '#1a1612' };
if (u < 46) return { label: 'Very strong heat', bg: '#c44a3a', fg: '#fff' };
return { label: 'Extreme heat', bg: '#7a1a1a', fg: '#fff' };
}
// ═══════════════════════════════════════════════════════════════════
// SOAK-FACTOR — SunScope's original rain/snow penalty.
// ───────────────────────────────────────────────────────────────────
// This is what makes "UTCI+P" different from plain UTCI. It subtracts
// extra felt-temperature for rain (wet clothing = evaporative chill)
// and snow (wet snow is brutal). Wind amplifies the rain penalty.
// Calibrated by feel — adjust the multipliers if you find it too
// strong/weak. The big number "7" caps the max rain penalty so a
// freak 50mm/h reading can't make UTCI nonsensical.
// ═══════════════════════════════════════════════════════════════════
export function precipPenalty(precipMm, snowCmH, windMs) {
let penalty = 0;
if (precipMm > 0) {
const base = Math.min(7, 1.4 * Math.pow(precipMm, 0.55) + precipMm * 0.28);
const windMult = 1 + Math.min(0.35, windMs * 0.025);
penalty += base * windMult;
}
if (snowCmH > 0) penalty += Math.min(6, 2.2 + snowCmH * 1.6);
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.
// ═══════════════════════════════════════════════════════════════════
export 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.
// ═══════════════════════════════════════════════════════════════════
export 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).
// ═══════════════════════════════════════════════════════════════════
export 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 },
};
export function sunburnMinutes(uv, skinType = 'II') {
if (!uv || uv <= 0) return Infinity;
const base = (SKIN_TYPES[skinType] || SKIN_TYPES.II).base;
return base / uv;
}
export 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).
// ═══════════════════════════════════════════════════════════════════
export 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.
// ───────────────────────────────────────────────────────────────────
// `i` is the day index (0 = today, 13 = day 14).
//
// Each day gets its own shade, interpolated between two endpoint
// colours. To re-skin the gradient (say, blue-to-purple instead of
// sunset), just change the four RGB endpoint arrays below.
//
// bg = tab background (lighter on day 0, darker on day 13)
// edge = active-tab underline + slim border on the banner
// tint = soft wash on the confidence banner above the table
// label = qualitative zone name shown in the banner ("Golden hour")
//
// The label switches in 4 stages so users still see a friendly
// description ("you're in the trustworthy zone" vs "this is an
// outlook"). The tab background itself flows smoothly day to day.
// ═══════════════════════════════════════════════════════════════════
export function confidenceBand(i) {
// Position along the gradient: 0 on day 0, 1 on day 13.
const t = Math.min(1, Math.max(0, i / 13));
// ENDPOINTS — change these four RGB arrays to re-skin the gradient.
const bgStart = [255, 247, 214]; // #fff7d6 pale yellow (high noon)
const bgEnd = [232, 152, 104]; // #e89868 terracotta (sunset)
const edgeStart = [245, 231, 161]; // #f5e7a1 soft golden
const edgeEnd = [196, 115, 64]; // #c47340 burnt umber
// Linear interpolation between the two endpoints.
const lerp = (a, b) => Math.round(a + t * (b - a));
const mix = (s, e) => [lerp(s[0], e[0]), lerp(s[1], e[1]), lerp(s[2], e[2])];
const [br, bg, bb] = mix(bgStart, bgEnd);
const [er, eg, eb] = mix(edgeStart, edgeEnd);
return {
bg: `rgb(${br}, ${bg}, ${bb})`,
edge: `rgb(${er}, ${eg}, ${eb})`,
tint: `rgba(${er}, ${eg}, ${eb}, 0.22)`,
// Qualitative confidence label (camera-focus metaphor —
// on-brand for SunScope, and instantly readable).
label: i < 3 ? 'Pin-sharp' // days 13 highest skill
: i < 7 ? 'Sharp' // days 47 solid
: i < 10 ? 'Soft focus' // days 810 trends only
: 'Blurry', // days 1114 outlook only
};
}
// ═══════════════════════════════════════════════════════════════════
// MOON PHASE — works out which moon emoji to show on night hours.
// ───────────────────────────────────────────────────────────────────
// Returns a fraction 0..1:
// 0.00 = new moon 0.50 = full moon
// 0.25 = first quarter 0.75 = last quarter
// The maths is a simple synodic-period calculation referenced from
// a known new moon (6 Jan 2000). Accurate to within a few hours.
// ═══════════════════════════════════════════════════════════════════
export function moonPhaseFraction(date) {
const JD = date.getTime() / 86400000 + 2440587.5;
const syn = 29.530588;
const ref = 2451550.1;
let p = ((JD - ref) % syn) / syn;
if (p < 0) p += 1;
return p;
}
export function moonGlyph(p) {
return ['🌑','🌒','🌓','🌔','🌕','🌖','🌗','🌘'][Math.floor(p*8 + 0.5) % 8];
}
// ═══════════════════════════════════════════════════════════════════
// SKY FILL — colour inside the little circle, based on sun elevation.
// ───────────────────────────────────────────────────────────────────
// > 30° high noon (bright blue)
// > 10° mid-sky (pale blue)
// > 3° morning/afternoon (warm gold)
// > -3° golden hour / sunset (deep amber)
// > -8° civil twilight (dusky lilac)
// > -14° nautical twilight (deep blue-violet)
// else night (indigo)
// To shift "when sunset starts" visually, tweak the elevation
// thresholds. To recolour: replace the hex codes.
// ═══════════════════════════════════════════════════════════════════
export function skyFillForElev(e) {
if (e > 30) return '#9fd3ef';
if (e > 10) return '#c8e3ee';
if (e > 3) return '#ffd596';
if (e > -3) return '#f59b6e';
if (e > -8) return '#9279b0';
if (e > -14) return '#3a2f60';
return '#1a1538';
}
// Grass palette — mirrors skyFillForElev but for the ground.
// `top` = grass blade tips (catches sky light), `bot` = soil shadow.
export 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
}
+24 -873
View File
@@ -1,882 +1,33 @@
/* ════════════════════════════════════════════════════════════════════════ /* ════════════════════════════════════════════════════════════════════════
SUNSCOPE · styles sunscope.css — Main CSS entry point.
════════════════════════════════════════════════════════════════════════ ════════════════════════════════════════════════════════════════════════
QUICK MAP — where to find and change common things QUICK MAP — where to find and change common things
────────────────────────────────────────────────────────────────────── ──────────────────────────────────────────────────────────────────────
Page background body { background } Page background css/base.css body { background }
Body text colour .utci-app { color } Body text colour css/base.css .utci-app { color }
Brand "brass" colour search file for #c8922a Brand "brass" colour search files for #c8922a
Headline font .utci-title { font-family } Headline font css/header.css .utci-title { font-family }
Body text font .utci-app { font-family } Body text font css/base.css .utci-app { font-family }
Max page width .utci-shell { max-width } Max page width css/base.css .utci-shell { max-width }
Mobile breakpoint @media (max-width: 800px) at the bottom Mobile breakpoint css/ui.css @media (max-width: 800px)
Big UTCI dial size .scope-ring { width / height } Big UTCI dial size css/header.css .scope-ring { width / height }
Profile buttons css/table.css .profile-btn
Column toggles css/table.css .col-toggle
Hourly table css/table.css .utci-table
Legend css/ui.css .utci-legend
About / footer css/ui.css .utci-about / .utci-footer
COLOUR PALETTE (the "warm parchment & brass" theme) FILE STRUCTURE
────────────────────────────────────────────────────────────────────── ──────────────────────────────────────────────────────────────────────
#f5edd6 parchment page background css/base.css Colour palette docs, fonts+reset, page shell, nav
#fffcf2 soft cream cards, search box, table css/header.css Header grid, title, dial, search box
#fdf8ee warm cream toolbars, legend, about block css/table.css Day tabs, profiles, column toggles, hourly table,
#f0e4c4 pale honey table heading row now-row + night-row decorations
#fef3dc warm hover buttons on hover css/ui.css Status banners, legend, about, footer, responsive
#fef8ea row hover table row on hover
#fff8e4 "now" row the currently-active hour
#1e1208 espresso headings + emphasis
#4a3420 warm dark body text in the table
#6b4f2a muted bronze subtle emphasis
#7a5c2a tagline brown italic intro line
#9a7d5a soft brown column toggle text
#b09870 warm grey muted labels
#c0a880 pale dust night-row text, footer
#c8922a BRASS brand accent (links, dial, focus)
#a06e18 deep brass hover state for some links
#c9b08a warm border panel borders
#d4c0a0 soft border toolbar/legend borders
#ede4cc line table row dividers
FONTS
──────────────────────────────────────────────────────────────────────
Fraunces serif display titles, day labels, hour cell
Manrope body sans-serif paragraphs
JetBrains Mono numeric monospace table data, small labels
FILE STRUCTURE (in order below)
──────────────────────────────────────────────────────────────────────
1. Fonts + reset
2. Page background & shell
3. Top navigation
4. Header (title + dial + search)
5. Day tabs
6. Column toggles
7. Hourly table
8. Now-row + night-row decorations
9. Status & error banners
10. Legend
11. About + footer
12. Responsive (mobile)
════════════════════════════════════════════════════════════════════════ */ ════════════════════════════════════════════════════════════════════════ */
@import "./css/base.css";
/* ── 1. FONTS + RESET ───────────────────────────────────────────────── */ @import "./css/header.css";
@import "./css/table.css";
@import "https://fonts.bunny.net/css2?family=Fraunces:opsz,wght@9..144,300;9..144,500;9..144,700;9..144,900&family=JetBrains+Mono:wght@400;600&family=Manrope:wght@400;500;600;700&display=swap"; @import "./css/ui.css";
*,
*:before,
*:after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
/* ── 2. PAGE BACKGROUND & SHELL ─────────────────────────────────────── */
body {
background: #f5edd6; /* parchment */
}
.utci-app {
font-family: Manrope, sans-serif;
color: #1e1208;
background: #f5edd6;
min-height: 100vh;
padding: 52px 36px 32px;
position: relative;
/* 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.
Comment one out to remove that side's glow. */
.lens-bloom-a {
position: fixed;
top: -200px;
left: -200px;
width: 700px;
height: 700px;
border-radius: 50%;
background: radial-gradient(ellipse, rgba(255, 210, 60, 0.18) 0%, transparent 65%);
pointer-events: none;
z-index: 0;
}
.lens-bloom-b {
position: fixed;
top: -80px;
right: -140px;
width: 520px;
height: 420px;
background: radial-gradient(ellipse, rgba(200, 146, 42, 0.13) 0%, transparent 68%);
pointer-events: none;
z-index: 0;
}
/* The centred container that holds everything. */
.utci-shell {
position: relative;
z-index: 1;
max-width: 1400px;
margin: 0 auto;
}
/* ── 3. TOP NAVIGATION (Home / About links in the corner) ──────────── */
.utci-topnav {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 10;
display: flex;
align-items: center;
justify-content: flex-end;
padding: 18px 32px;
gap: 28px;
}
.utci-topnav a {
font-family: JetBrains Mono, monospace;
font-size: 16px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: #b09870;
text-decoration: none;
border-bottom: 1px solid transparent;
padding-bottom: 2px;
transition: color 0.2s, border-color 0.2s;
}
.utci-topnav a:hover {
color: #c8922a; /* brass on hover */
border-bottom-color: #c8922a80; /* faint underline */
}
/* ── 4. HEADER (title + dial + search, three-column grid) ──────────── */
.utci-header {
display: grid;
grid-template-columns: 1fr auto 1fr; /* title | dial | search */
gap: 32px;
align-items: center;
padding-bottom: 32px;
border-bottom: 2px solid #c9b08a;
margin-bottom: 32px;
}
.header-right {
display: flex;
flex-direction: column;
align-items: flex-end;
justify-content: center;
}
/* The big "SUNScope" wordmark */
.utci-title {
font-family: Fraunces, serif;
font-weight: 900;
font-variation-settings: "opsz" 144;
font-size: clamp(48px, 6vw, 80px); /* responsive — scales with viewport */
line-height: 0.9;
letter-spacing: -0.03em;
color: #1e1208;
}
.utci-title .title-sun { /* "SUN" — bold + brass */
color: #c8922a;
font-weight: 900;
font-style: normal;
}
.utci-title .title-scope { /* "Scope" — italic */
font-style: italic;
font-weight: 900;
color: #1e1208;
}
.utci-title .title-beta { /* the small "beta" subscript */
font-size: 0.38em;
text-transform: none;
font-weight: 400;
letter-spacing: 0.05em;
vertical-align: super;
color: #b09870;
}
/* "See the sun the way..." */
.utci-tagline {
font-family: Fraunces, serif;
font-style: italic;
font-size: 17px;
color: #7a5c2a;
margin-top: 14px;
}
/* The credits line under the tagline */
.utci-subtitle {
font-family: JetBrains Mono, monospace;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.18em;
margin-top: 10px;
color: #b09870;
}
.utci-subtitle-link { /* The "UTCI" link in the credits */
color: #c8922a;
text-decoration: none;
border-bottom: 1px solid rgba(200, 146, 42, 0.4);
transition: border-color 0.2s, color 0.2s;
}
.utci-subtitle-link:hover {
color: #a06e18;
border-bottom-color: #a06e18;
}
/* "↳ Pangbourne, Berkshire" */
.utci-current-loc {
font-family: Fraunces, serif;
font-style: italic;
font-size: 18px;
margin-top: 12px;
color: #1e1208;
}
/* The lat/lon next to the location */
.utci-loc-coords {
font-family: JetBrains Mono, monospace;
font-style: normal;
font-size: 11px;
color: #b09870;
margin-left: 10px;
letter-spacing: 0.08em;
}
/* The big UTCI dial in the middle of the header.
Change width/height to resize it. */
.scope-ring {
width: 220px;
height: 220px;
display: block;
}
/* The search box on the right of the header */
.utci-search-wrap {
position: relative;
width: 300px;
}
.utci-search-label {
font-family: JetBrains Mono, monospace;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.18em;
color: #b09870;
display: block;
margin-bottom: 7px;
}
.utci-search {
width: 100%;
padding: 12px 16px;
border: 1.5px solid #c9b08a;
background: #fffcf2;
font-family: Manrope, sans-serif;
font-size: 14px;
color: #1e1208;
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
}
.utci-search::placeholder {
color: #c0a880;
}
.utci-search:focus {
border-color: #c8922a;
box-shadow: 0 0 0 3px #c8922a24; /* brass glow when focused */
}
/* The dropdown list of city results */
.utci-results {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: #fffcf2;
border: 1.5px solid #c9b08a;
border-top: none;
max-height: 260px;
overflow-y: auto;
z-index: 50;
box-shadow: 0 8px 24px #6446141f;
}
.utci-result {
padding: 11px 16px;
cursor: pointer;
border-bottom: 1px solid #ede4cc;
font-size: 14px;
transition: background 0.12s;
border-left: 3px solid transparent;
}
.utci-result:hover {
background: #fef3dc;
border-left-color: #c8922a; /* brass marker on hover */
}
/* The "GB · 51.48°, -1.07°" line under each city */
.utci-result-meta {
font-size: 11px;
color: #b09870;
font-family: JetBrains Mono, monospace;
margin-top: 3px;
letter-spacing: 0.06em;
}
.utci-searching {
margin-top: 7px;
font-size: 10px;
color: #b09870;
font-family: JetBrains Mono, monospace;
letter-spacing: 0.14em;
text-transform: uppercase;
}
/* ── 5. DAY TABS (today / tomorrow / etc.) ─────────────────────────── */
/* Note: each tab's background colour comes from inline styles in
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;
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;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.14em;
background: transparent;
border: none;
color: #b09870;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
transition: color 0.2s, border-color 0.2s;
}
.utci-day-tab:hover {
color: #6b4f2a;
}
.utci-day-tab.active {
color: #c8922a;
border-bottom-color: #c8922a;
}
/* The "Mon 12 May" date underneath each tab name */
.utci-day-date {
font-family: Fraunces, serif;
font-style: italic;
font-size: 13px;
text-transform: none;
letter-spacing: 0;
display: block;
margin-top: 3px;
color: inherit;
}
/* ── 6. COLUMN TOGGLES (the "Columns: Hour Air RH ..." bar) ────────── */
.col-toggles {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
margin-bottom: 10px;
padding: 10px 14px;
background: #fdf8ee;
border: 1.5px solid #d4c0a0;
}
.col-toggles-label {
font-family: JetBrains Mono, monospace;
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.18em;
color: #b09870;
margin-right: 4px;
white-space: nowrap;
}
/* The little pill buttons */
.col-toggle {
padding: 4px 11px;
cursor: pointer;
border: 1.5px solid #c9b08a;
background: #f5edd6;
color: #9a7d5a;
font-family: JetBrains Mono, monospace;
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.1em;
transition: all 0.15s;
-webkit-user-select: none;
user-select: none;
}
.col-toggle:hover {
border-color: #c8922a;
color: #6b4f2a;
background: #fef3dc;
}
/* The "pressed-in" active look */
.col-toggle.on {
background: #c8922a;
border-color: #c8922a;
color: #fff;
}
/* ── 7. HOURLY TABLE ────────────────────────────────────────────────── */
.utci-table-wrap {
/* 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;
/* Fixed layout so the explicit per-cell widths set by the JS width-sync
(see useLayoutEffect in sunscope.js) are actually enforced. Without
this the browser's auto layout treats those widths as suggestions and
redistributes space, which makes the header and body columns drift
apart the further right you scroll. */
table-layout: fixed;
}
/* 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;
font-family: JetBrains Mono, monospace;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.14em;
font-weight: 600;
padding: 14px 12px;
text-align: right;
white-space: nowrap;
}
/* The Hour column aligns left, the rest align right */
.utci-table th:first-child,
.utci-table td:first-child {
text-align: left;
}
/* The "°C" / "m/s" etc. unit underneath each heading */
.utci-table th .col-unit {
display: block;
color: #c8922a;
font-size: 9px;
margin-top: 2px;
}
.utci-table tbody tr {
border-bottom: 1px solid #ede4cc;
transition: background 0.1s;
}
.utci-table tbody tr:hover {
background: #fef8ea;
}
.utci-table td {
padding: 10px 12px;
text-align: right;
font-family: JetBrains Mono, monospace;
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;
}
/* The Dir column is centered on both desktop and mobile so the
compass vane sits with even padding either side — especially
important on mobile where the compass label is hidden and there's
no text to balance the right-aligned content. */
.utci-table th.utci-dir-cell,
.utci-table td.utci-dir-cell {
text-align: center;
}
/* "Tight" header — for columns whose body content is narrower than
the default tracked-out header would suggest (Air, Pcpt). Reducing
the letter-spacing shrinks the heading's natural width so it stops
driving the column wider than the body needs. Right-aligned values
then sit with balanced visual padding on either side. */
.utci-table th.utci-tight-head {
letter-spacing: 0.04em;
}
/* ── 8. NOW-ROW & NIGHT-ROW DECORATIONS ─────────────────────────────── */
/* "Currently this hour" — the highlighted row.
The row gets a cream-yellow background and darker text. No left
stripe or pip — the colour shift alone signals "this is now". */
.utci-table tbody tr.is-now {
background: #fff8e4 !important;
}
.utci-table tbody tr.is-now td {
color: #1e1208;
/* Brass bracket framing the current-hour row top and bottom — clean,
definitive, no left stripe needed. */
border-top: 2px solid #c8922a;
border-bottom: 2px solid #c8922a;
}
/* Night-time hours get a slightly muted text colour */
.utci-table tbody tr.is-night td {
color: #c0a880;
}
/* The time cell — uses the display serif instead of monospace */
.utci-time {
font-family: Fraunces, serif !important;
font-weight: 500;
font-size: 16px;
color: #1e1208 !important;
}
/* The coloured pill inside UTCI / UTCI+P cells.
The pill's BACKGROUND comes from inline styles in sunscope.js
(the utciCategory() function decides which thermal-stress colour
to apply). This rule just handles the pill shape. */
.utci-cell {
display: inline-block;
padding: 4px 12px;
border-radius: 3px;
font-weight: 600;
min-width: 60px;
text-align: center;
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;
text-transform: uppercase;
letter-spacing: 0.08em;
}
/* ── 9. STATUS & ERROR BANNERS ("Acquiring forecast data…") ────────── */
.utci-status {
padding: 14px 18px;
background: #f0e4c4;
color: #7a5c2a;
font-family: JetBrains Mono, monospace;
font-size: 11px;
text-align: center;
letter-spacing: 0.12em;
text-transform: uppercase;
border: 1.5px solid #c9b08a;
margin-bottom: 18px;
}
/* ── 10. LEGEND (thermal-stress band colours at the bottom) ────────── */
.utci-legend {
margin-top: 22px;
padding: 18px 20px;
background: #fdf8ee;
border: 1.5px solid #c9b08a;
}
.utci-legend-label {
font-family: JetBrains Mono, monospace;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.18em;
color: #b09870;
margin-bottom: 12px;
display: block;
font-weight: 600;
}
.utci-legend-row {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.utci-legend-item {
padding: 6px 12px;
font-family: JetBrains Mono, monospace;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.06em;
flex: 1;
min-width: 110px;
text-align: center;
opacity: 0.9;
}
/* ── 11. ABOUT + FOOTER ─────────────────────────────────────────────── */
.utci-about {
margin-top: 28px;
padding: 24px 28px;
background: #fdf8ee;
border: 1.5px solid #c9b08a;
}
.utci-about-heading {
font-family: Fraunces, serif;
font-size: 18px;
font-weight: 700;
color: #1e1208;
margin-bottom: 14px;
letter-spacing: -0.01em;
}
.utci-about-text {
font-family: Manrope, sans-serif;
font-size: 14px;
line-height: 1.75;
color: #4a3420;
margin-bottom: 12px;
max-width: 860px; /* keeps line length readable */
}
.utci-about-text:last-child {
margin-bottom: 0;
}
.utci-about-text strong {
color: #1e1208;
font-weight: 600;
}
.utci-about-text em {
font-style: italic;
color: #6b4f2a;
}
.utci-footer {
margin-top: 36px;
padding-top: 20px;
border-top: 1px solid #d4c0a0;
font-family: JetBrains Mono, monospace;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.12em;
color: #c0a880;
line-height: 2.2;
}
/* The "Reading the table." intro snippet */
.utci-footer em {
font-family: Fraunces, serif;
font-style: italic;
text-transform: none;
letter-spacing: 0;
font-size: 14px;
color: #7a5c2a;
}
/* ── 12. RESPONSIVE (mobile) ───────────────────────────────────────── */
/* On screens narrower than 800px, stack the header vertically and
shrink the dial. Add more rules in this block to tweak mobile
layout further. */
@media (max-width: 800px) {
.utci-app {
padding: 52px 10px 32px;
}
.utci-header {
grid-template-columns: 1fr; /* stack the 3 header columns */
text-align: center;
}
.header-right {
align-items: center;
}
.utci-search-wrap {
width: 100%;
}
.scope-ring {
width: 180px; /* smaller dial on mobile */
height: 180px;
margin: 0 auto;
}
/* Halve the hourly-table cell padding on mobile so more columns fit
in the viewport before the horizontal scrollbar kicks in. */
.utci-table th {
padding: 7px 6px;
}
.utci-table td {
padding: 5px 6px;
}
/* Drop the compass-label text ("N", "NW"...) next to the wind vane on
mobile — the vane arrow alone is enough at small sizes. */
.wind-dir-label {
display: none;
}
}
-1793
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -6,10 +6,10 @@
<meta name="description" content="SunScope - UTCI weather forecast showing real thermal comfort, not just air temperature." /> <meta name="description" content="SunScope - UTCI weather forecast showing real thermal comfort, not just air temperature." />
<title>SunScope · The True Temperature</title> <title>SunScope · The True Temperature</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>☀️</text></svg>" /> <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>☀️</text></svg>" />
<link rel="stylesheet" href="./assets/sunscope.css?v=14k"> <link rel="stylesheet" href="./assets/sunscope.css?v=15a">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="./assets/sunscope.js?v=14k"></script> <script type="module" src="./assets/js/main.js?v=15a"></script>
</body> </body>
</html> </html>