From e18e128a342966b908803e122a8126795fb2cf0f Mon Sep 17 00:00:00 2001 From: fraxle Date: Wed, 13 May 2026 15:16:45 +0100 Subject: [PATCH] Beta5 New code structure Added profiles Fixed skin pulldown Added rain images --- assets/css/base.css | 132 +++ assets/css/header.css | 197 +++++ assets/css/table.css | 431 ++++++++++ assets/css/ui.css | 170 ++++ assets/js/app.js | 1005 ++++++++++++++++++++++ assets/js/components.js | 496 +++++++++++ assets/js/main.js | 17 + assets/js/physics.js | 214 +++++ assets/js/utils.js | 247 ++++++ assets/sunscope.css | 897 +------------------- assets/sunscope.js | 1793 --------------------------------------- index.html | 4 +- 12 files changed, 2935 insertions(+), 2668 deletions(-) create mode 100644 assets/css/base.css create mode 100644 assets/css/header.css create mode 100644 assets/css/table.css create mode 100644 assets/css/ui.css create mode 100644 assets/js/app.js create mode 100644 assets/js/components.js create mode 100644 assets/js/main.js create mode 100644 assets/js/physics.js create mode 100644 assets/js/utils.js delete mode 100644 assets/sunscope.js diff --git a/assets/css/base.css b/assets/css/base.css new file mode 100644 index 0000000..0a61df0 --- /dev/null +++ b/assets/css/base.css @@ -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 ) 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 */ +} diff --git a/assets/css/header.css b/assets/css/header.css new file mode 100644 index 0000000..3cad73a --- /dev/null +++ b/assets/css/header.css @@ -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; +} diff --git a/assets/css/table.css b/assets/css/table.css new file mode 100644 index 0000000..c7d320e --- /dev/null +++ b/assets/css/table.css @@ -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 setSearchQuery(e.currentTarget.value)} + /> + ${searchResults.length > 0 && html` +
+ ${searchResults.map((r) => html` +
{ + setLocation({ + name: `${r.name}${r.admin1 ? ', ' + r.admin1 : ''}`, + lat: r.latitude, + lon: r.longitude, + country: r.country_code, + }); + setSearchQuery(''); + setSearchResults([]); + setSelectedDay(0); + }} + > +
${r.name}${r.admin1 ? `, ${r.admin1}` : ''}
+
+ ${r.country} · ${r.latitude.toFixed(2)}°, ${r.longitude.toFixed(2)}° +
+
`)} +
`} + ${searching && html`
Searching…
`} + + + + + ${error && html` +
+ ⚠ ${error} +
`} + ${loading && !error && html` +
Acquiring forecast data…
`} + + ${forecast && days.length > 0 && html` + <${Fragment}> + +
+ + +
+ ${days.map((d, i) => { + const band = confidenceBand(i); + const locked = !isPro && i >= FREE_DAYS; + const isActive = i === selectedDay; + const dayName = i === 0 ? 'Today' + : i === 1 ? 'Tomorrow' + : d.date.toLocaleDateString('en-GB', { weekday: 'short' }); + return html` + `; + })} +
+
+ + + ${proPromptDay !== null && days[proPromptDay] && (() => { + const promptDate = days[proPromptDay].date; + const dayName = promptDate.toLocaleDateString('en-GB', { weekday: 'long' }); + const dayLong = promptDate.toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'short' }); + return html` +
+
+
+ 🔒 ${proPromptSource === 'custom' + ? 'Custom columns are part of SunScope Extra' + : `${dayName}'s forecast is part of SunScope Extra`} +
+
+ ${proPromptSource === 'custom' + ? 'Pro lets you choose exactly which columns appear — mix and match Air, Dew, Soil, UV, UTCI and more to build your perfect view.' + : 'Pro unlocks the full 14-day forecast, customisable columns, and an ad-free view.'} +
+
+ £2 / month · launching soon +
+
+
+ + Notify me at launch + + +
+
`; + })()} + + ${(() => { + const band = confidenceBand(selectedDay); + const isOutlook = selectedDay >= 7; + return html` +
+ + Day ${selectedDay + 1} of 14 · Forecast clarity: ${band.label} + + ${isOutlook && html` + + forecast skill is reduced — treat hourly detail as trend, not precision + `} +
`; + })()} + + +
+ Profile: + ${Object.entries(FILTER_PROFILES).map(([key, profile]) => { + const isCustom = key === 'custom'; + const locked = isCustom && !isPro; + return html` + `; + })} +
+ + + ${isPro && html` +
+ Columns: + ${[ + // Hour and UTCI+P are always-on — no toggle button for them. + { key: 'air', label: 'Air' }, + { key: 'rh', label: 'RH' }, + { key: 'dew', label: 'Dew' }, + { key: 'soilT', label: 'Soil °C' }, + { key: 'soilT6', label: 'Soil 6cm' }, + { key: 'soilM', label: 'Soil moist' }, + { key: 'wind', label: 'Wind' }, + { key: 'dir', label: 'Dir' }, + { key: 'cloud', label: 'Cloud' }, + { key: 'sun', label: 'Sun' }, + { key: 'direct', label: 'Direct' }, + { key: 'diffuse', label: 'Diffuse' }, + { key: 'tmrt', label: 'Tmrt' }, + { key: 'delta', label: 'Δ' }, + { key: 'utci', label: 'UTCI' }, + { key: 'uvA', label: 'UV-A' }, + { key: 'uvB', label: 'UV-B' }, + ].filter(c => + activeProfile === 'custom' || !!FILTER_PROFILES[activeProfile].cols[c.key] + ).map(c => html` + `)} + + ${(activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['burn']) && html` + `} + + ${(activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['precip']) && html` + `} +
`} + + +
+ +
+
+ + + + + ${visibleCols.air && html``} + ${visibleCols.rh && html``} + ${visibleCols.dew && html``} + ${visibleCols.soilT && html``} + ${visibleCols.soilT6 && html``} + ${visibleCols.soilM && html``} + ${visibleCols.wind && html``} + ${visibleCols.dir && html``} + ${visibleCols.cloud && html``} + ${visibleCols.sun && html``} + ${visibleCols.direct && html``} + ${visibleCols.diffuse && html``} + ${visibleCols.tmrt && html``} + ${visibleCols.delta && html``} + ${visibleCols.utci && html``} + ${visibleCols.uvA && html``} + ${visibleCols.uvB && html``} + ${visibleCols.burn && html``} + ${visibleCols.precip && html``} + + + +
HourAir °CRH %Dew °CSoil °C surfaceSoil 6cm °C rootSoil moist m³/m³Wind m/s (gust)Dir -Cloud %Sun elev°Direct W/m²Diffuse W/m²Tmrt °CΔ UTCI−AirUTCI °C feltUV-A est. idxUV-B est. idxBurn to MEDPcpt mm/hUTCI+P °C adj.
+
+
+ +
+ + + ${visible.map((r) => { + const cat = utciCategory(r.utci); + const isNight = r.elev < 0; + const isNow = + now.toDateString() === r.dt.toDateString() && + now.getHours() === r.dt.getHours(); + const delta = r.utci - r.Ta; + const adjCat = utciCategory(r.utciAdj); + return html` + + + ${visibleCols.air && html``} + ${visibleCols.rh && html``} + ${visibleCols.dew && html``} + ${visibleCols.soilT && html` + `} + ${visibleCols.soilT6 && html` + `} + ${visibleCols.soilM && html` + `} + ${visibleCols.wind && html``} + ${visibleCols.dir && html``} + ${visibleCols.cloud && html``} + ${visibleCols.sun && html``} + ${visibleCols.direct && html``} + ${visibleCols.diffuse && html``} + ${visibleCols.tmrt && html``} + ${visibleCols.delta && html` + `} + ${visibleCols.utci && html` + `} + ${visibleCols.uvA && html` + `} + ${visibleCols.uvB && html` + `} + ${visibleCols.burn && html` + `} + ${visibleCols.precip && html` + `} + + `; + })} + +
+ + <${SkyScope} elev=${r.elev} dt=${r.dt} size=${30} /> + ${r.dt.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })} + + ${r.Ta.toFixed(1)}${Math.round(r.RH)}${r.dew != null ? r.dew.toFixed(1) : '—'}${r.soilT0 != null ? r.soilT0.toFixed(1) : '—'}${r.soilT6 != null ? r.soilT6.toFixed(1) : '—'}${r.soilM != null ? r.soilM.toFixed(3) : '—'} + ${r.va.toFixed(1)}${r.gust != null && r.gust > r.va + 0.5 + ? html`(${r.gust.toFixed(1)})` + : ''} + + + <${WindVane} bearing=${r.wd} size=${28} /> + ${r.compass.label} + + + + <${CloudIcon} category=${r.cloudCat} elev=${r.elev} dt=${r.dt} size=${28} /> + ${Math.round(r.cc)} + + ${r.elev > 0 ? r.elev.toFixed(1) : '—'}${Math.round(r.dir)}${Math.round(r.dif)}${r.Tmrt.toFixed(1)} 3 ? '#c8601a' : delta < -3 ? '#3f73c4' : '#9a7d5a', + fontWeight: 600, + }}> + ${delta > 0 ? '+' : ''}${delta.toFixed(1)} + + + ${r.utci.toFixed(1)} + + 0 ? '#c8922a' : '#5a4228' }}> + ${r.uvA > 0 ? r.uvA.toFixed(1) : '—'} + 0 ? '#c44a3a' : '#5a4228', fontWeight: 600 }}> + ${r.uvB > 0 ? r.uvB.toFixed(2) : '—'} + 0 ? (sunburnMinutes(r.uv, skinType) < 30 ? '#c44a3a' : '#c8601a') : '#5a4228' }}> + ${burnLabel(sunburnMinutes(r.uv, skinType))} + + + <${PrecipIcon} precip=${r.precip} snow=${r.snow} size=${28} /> + 0 ? '#6090c8' : r.precip > 0 ? '#5090b0' : '#c0a880' }}> + ${r.snow > 0 ? r.snow.toFixed(1) + 'cm' : r.precip > 0 ? r.precip.toFixed(1) : '—'} + + + + + ${r.utciAdj.toFixed(1)} + +
+
+
+ +
+ Thermal stress bands +
+ ${[ + { l: '-27 to -13 Arctic', bg: '#3f73c4', fg: '#fff' }, + { l: '-13 to 0 Freezing', bg: '#7eb0e0', fg: '#111' }, + { l: '0 to 9 Cold', bg: '#bcd9ec', fg: '#111' }, + { l: '9 to 18 Chilled', bg: '#c8dcc0', fg: '#111' }, + { l: '18 to 26 Comfortable', bg: '#6ab05a', fg: '#fff' }, + { l: '26 to 32 Mod heat', bg: '#e8c547', fg: '#111' }, + { l: '32 to 38 Strong heat', bg: '#dc8a3a', fg: '#111' }, + { l: '38 to 46 V. strong', bg: '#c44a3a', fg: '#fff' }, + { l: '> 46 Extreme heat', bg: '#7a1a1a', fg: '#fff' }, + ].map((b, i) => html` + + ${b.l} + `)} +
+
+ `} + +
+

What is SunScope?

+

+ SunScope shows how the weather will actually feel on your body — not just the air + temperature. It uses the Universal Thermal Climate Index (UTCI), a + peer-reviewed biometeorological standard developed by Bröde et al. (2012) that combines + air temperature, humidity, wind speed, and solar radiation into a single felt + temperature. On a calm, sunny winter day UTCI can read several degrees warmer than + the thermometer; on a grey, blustery day it can read far colder. Forecast data is + sourced in real time from Open-Meteo, a free and open-source weather API, + and solar radiation is used to calculate Mean Radiant Temperature — the heat your skin + absorbs from the sun — making SunScope one of the most complete outdoor comfort forecasts + available for free. +

+

+ The UTCI+P column adds the SunScope soak-factor: an + original precipitation penalty that accounts for the extra chill of rain and snow on + exposed skin and wet clothing. Light drizzle reduces the felt temperature by around + 1–2 °C; heavy rain combined with wind can push it down by 7–8 °C. Snow carries an + additional penalty on top. The result is an honest, real-world comfort score for any + location worldwide — simply search for your town or city and compare the hourly + forecast across the next 3 days (and up to 14 days with SunScope Extra). +

+
+ + + + + `; +} + diff --git a/assets/js/components.js b/assets/js/components.js new file mode 100644 index 0000000..74b346b --- /dev/null +++ b/assets/js/components.js @@ -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` + + + + + + + + + + + + + ${isDay + ? html` + + + + ` + : html` + + + + + `} + + + `; +} + +// ═══════════════════════════════════════════════════════════════════ +// 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``; + } + const r = size / 2; + // The shaft is drawn pointing DOWN by default (head at the bottom). + // To place the head at the bearing direction, rotate by bearing + 180. + const rot = (bearing + 180) % 360; + const arrowColor = '#2a1a08'; + const headColor = '#c44a3a'; + const nColor = '#9a7d5a'; + // Geometry of the single-ended arrow (drawn pointing DOWN by default). + // After rotation by (bearing + 180), the head lands on the bearing + // direction — i.e. the side the wind is coming FROM. + const tipY = r * 0.15; // arrowhead tip + const headBase = r * 0.58; // bottom of the triangle head + const headW = r * 0.42; + const tailEndY = r * 1.78; // shaft's tail end + const tailDotR = Math.max(1.0, size * 0.06); + return html` + + + N + + + + + + + + + + `; +} + +// ═══════════════════════════════════════════════════════════════════ +// CLOUDICON — clouds + sun/moon on a fully transparent background. +// No rim, no disc. Day/night aware: when elev < 0, the sun is +// replaced with a phased moon (same logic as SkyScope). +// ─────────────────────────────────────────────────────────────────── +// category: 'clear' | 'wispy' | 'scattered' | 'overcast' +// elev: solar elevation in degrees (negative = night) +// dt: Date used for moon phase +// ═══════════════════════════════════════════════════════════════════ +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 with the moon + shadow. + // Lit fraction direction matches SkyScope (waxing = lit from right). + const renderMoon = (cx, cy, moonR) => { + const phase = moonPhaseFraction(dt); + const phaseOffset = Math.cos(2 * Math.PI * phase) * moonR; + const moonLitFromRight = phase < 0.5; + const shadowCx = cx + (moonLitFromRight ? -phaseOffset : phaseOffset); + const clipId = `cmoon-${size}-${Math.round(cx)}-${Math.round(cy)}-${Math.round(phase * 1000)}`; + return html` + + + + + + + + + + `; + }; + return html` + + ${category === 'clear' && (isNight + ? html` + ${renderMoon(r, r, u*0.50)}` + : html` + + ${[0,45,90,135,180,225,270,315].map(a => { + const x1 = r + Math.sin(a*Math.PI/180) * u*0.65; + const y1 = r - Math.cos(a*Math.PI/180) * u*0.65; + const x2 = r + Math.sin(a*Math.PI/180) * u*0.95; + const y2 = r - Math.cos(a*Math.PI/180) * u*0.95; + return html``; + })} + + `)} + ${category === 'wispy' && html` + + ${isNight + ? renderMoon(r, r*0.95, u*0.42) + : html``} + + `} + ${category === 'scattered' && html` + + ${isNight + ? renderMoon(r+u*0.45, r-u*0.40, u*0.32) + : html``} + + + + + + + `} + ${category === 'overcast' && html` + + + + + + + `} + `; +} + +// ═══════════════════════════════════════════════════════════════════ +// SCOPERETICLE — the big circular UTCI dial in the page header. +// ─────────────────────────────────────────────────────────────────── +// 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` + + + + + + + + + + + + + + + + + + + + + ${isDay + ? html`<${Fragment}> + + + ` + : html`<${Fragment}> + + + + `} + + ${stressBands.map((b, i) => html` + `)} + + ${ticks.map((t, i) => html` + `)} + + + + + + + + ${[[-1,-1],[1,-1],[-1,1],[1,1]].map(([sx, sy], i) => html` + + + + `)} + ${value != null && html` + <${Fragment}> + + + `} + + + ${loading + ? html`· · ·` + : value != null + ? html`<${Fragment}> + + ${value.toFixed(1)}° + + + UTCI NOW + + + ${cat.label.toUpperCase()} + + ` + : html` + AWAITING + `} + `; +} + +// ═══════════════════════════════════════════════════════════════════ +// 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` + + + + + + ${[-1, 1].map(sx => [-1, 1].map(sy => html` + `))} + `; + + // Dry — just a faint dash + if (!hasRain && !hasSnow) { + return html` + + + `; + } + + // 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` + + ${mixed + ? html` + ${Array.from({ length: rainDrops }, (_, i) => html` + `)} + ${Array.from({ length: snowFlakes }, (_, i) => + flake(spacing * (rainDrops + i + 1), r, fr) + )}` + : hasRain + ? Array.from({ length: rainDrops }, (_, i) => html` + `) + : Array.from({ length: snowFlakes }, (_, i) => + flake(spacing * (i + 1), r, fr) + )} + `; +} diff --git a/assets/js/main.js b/assets/js/main.js new file mode 100644 index 0000000..1fbe550 --- /dev/null +++ b/assets/js/main.js @@ -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 +