From e0712300e5d6b6d182a4caca9f5cd4c65ed48132 Mon Sep 17 00:00:00 2001
From: fraxle
+ The UTCI standard was developed with a natural grass surface as its reference ground — the + assumption being that you're standing in a park or open field. In a city, that assumption + breaks down. Concrete and tarmac absorb more solar energy than grass, and unlike grass they + cannot cool themselves through evaporation. On a sunny summer afternoon, exposed concrete + can run 15–25 °C hotter than the surrounding air. +
++ SunScope's Concrete surface column estimates the temperature of sun-exposed + urban paving using an energy-balance model that accounts for how much solar radiation the + surface absorbs, how efficiently the air above it carries that heat away (which depends on + wind speed), and the reflective properties of typical urban concrete. The result gives you + a sense of the contact heat stress you'd experience sitting, standing, or walking barefoot + on city surfaces — something the standard UTCI value alone won't tell you. +
++ The column is enabled by default in the Urban profile and colour-coded: + values above 30 °C are shown in amber, above 40 °C in red. +
+ ++ A sealed, parked vehicle heats up far faster than most people expect. SunScope's + Vehicle column models the ambient cabin air temperature experienced by an + occupant who is seated out of direct sunlight — the classic scenario of a child or pet left + inside a parked car. +
++ The calculation combines two physical processes. The first is body panel + conduction: metal body panels (roof, doors, bonnet) absorb solar radiation and + conduct heat into the cabin regardless of the sun's position in the sky. This is the slow, + relentless background heat that builds even on overcast days. The second is + side-window solar gain: when the sun is at an elevation where its rays + cut through the side glass rather than striking the roof or reflecting off at a shallow + angle, additional heat enters the cabin. Because the occupant is modelled as sitting out of + the direct beam, this energy goes into raising the ambient cabin air temperature rather than + heating the person directly — which is precisely what makes it so dangerous. The air around + you heats up steadily while you remain unaware of how hot the environment has become. +
++ Wind speed has almost no effect on a sealed vehicle and is not factored in. The figure shown + represents a steady-state cabin temperature reached after approximately 45–60 minutes of + parking. Values above 35 °C are shown in amber (dangerous for children and pets); above + 45 °C in red (potentially fatal within minutes). +
+SunScope maps every UTCI+P value to a thermal stress band so you can read conditions at a glance:
diff --git a/assets/css/table.css b/assets/css/table.css index c7d320e..0654ef5 100644 --- a/assets/css/table.css +++ b/assets/css/table.css @@ -248,6 +248,69 @@ The body scroller (.utci-tbody-scroll) owns the horizontal scrollbar. */ border: 1.5px solid #c9b08a; background: #fffcf2; + position: relative; /* needed for ::before / ::after pseudo-element indicators */ +} + +/* ── SCROLL INDICATORS ────────────────────────────────────────────────── + Fade + chevron overlays on the left/right edges of the table wrap. + They appear only when there is content to scroll in that direction, + driven by the scroll-fade-left / scroll-fade-right classes toggled by JS. */ +.utci-table-wrap::before, +.utci-table-wrap::after { + content: ''; + position: absolute; + top: 0; + bottom: 0; + width: 48px; + pointer-events: none; + opacity: 0; + transition: opacity 0.2s ease; + z-index: 6; /* above sticky header (z-index 5) */ +} + +/* Left fade — warm cream fading to transparent rightward */ +.utci-table-wrap::before { + left: 0; + background: linear-gradient(to right, rgba(240,228,196,0.92) 0%, transparent 100%); +} + +/* Right fade — warm cream fading to transparent leftward */ +.utci-table-wrap::after { + right: 0; + background: linear-gradient(to left, rgba(240,228,196,0.92) 0%, transparent 100%); +} + +.utci-table-wrap.scroll-fade-left::before { opacity: 1; } +.utci-table-wrap.scroll-fade-right::after { opacity: 1; } + +/* Chevron arrows — sit on top of the fade, centred vertically */ +.utci-table-wrap .utci-scroll-chevron { + position: absolute; + top: 50%; + transform: translateY(-50%); + z-index: 7; + pointer-events: none; + color: #8a6a3a; + font-size: 18px; + line-height: 1; + opacity: 0; + transition: opacity 0.2s ease; + user-select: none; +} +.utci-table-wrap .utci-scroll-chevron.left { left: 6px; } +.utci-table-wrap .utci-scroll-chevron.right { right: 6px; } + +.utci-table-wrap.scroll-fade-left .utci-scroll-chevron.left { opacity: 1; } +.utci-table-wrap.scroll-fade-right .utci-scroll-chevron.right { opacity: 1; } + +/* Grab cursor on the body scroller to hint drag-to-scroll */ +.utci-tbody-scroll { + cursor: grab; + user-select: none; /* prevent text highlighting during drag */ + -webkit-user-select: none; +} +.utci-tbody-scroll:active { + cursor: grabbing; } /* ── STICKY HEADER STRIP ──────────────────────────────────────────────── @@ -387,7 +450,7 @@ /* Night-time hours get a slightly muted text colour */ .utci-table tbody tr.is-night td { - color: #c0a880; + color: #7a5c30; } /* The time cell — uses the display serif instead of monospace */ @@ -398,6 +461,17 @@ color: #1e1208 !important; } +@media (max-width: 800px) { + .utci-time { + font-size: 15px; + } + .utci-cell-hero { + font-size: 15px !important; + padding: 5px 12px; + min-width: 60px; + } +} + /* 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 @@ -415,10 +489,10 @@ /* 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; + padding: 6px 4px; + font-size: 17px; font-weight: 700; - min-width: 76px; + min-width: 63px; 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; } diff --git a/assets/js/app.js b/assets/js/app.js index 90a7aac..32fa94f 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -23,7 +23,7 @@ import { h, render, Fragment } from '../vendor/preact.js'; import { useState, useEffect, useLayoutEffect, useRef } from '../vendor/preact-hooks.js'; import htm from '../vendor/htm.js'; -import { vaporPressureHpa, solarElevationDeg, calcTmrt, utciApprox } from './physics.js'; +import { vaporPressureHpa, solarElevationDeg, calcTmrt, utciApprox, calcConcreteTemp, calcVehicleInteriorTemp, calcIndoorTempPass } from './physics.js'; import { utciCategory, precipPenalty, windCompass8, uvSplit, SKIN_TYPES, sunburnMinutes, burnLabel, @@ -111,7 +111,7 @@ export function UTCIForecast() { // FLIP THE `false` BELOW TO `true` TO PREVIEW THE PRO EXPERIENCE. // When this is wired to real billing/auth, replace `useState(false)` // with a check against the logged-in user. - const [isPro, setIsPro] = useState(false); + const [isPro, setIsPro] = useState(true); // How many days the free tier shows. Days beyond this get a 🔒. // Bump this number if you want to give free users more access. @@ -123,27 +123,37 @@ export function UTCIForecast() { basic: { label: 'Basic', icon: '🌡️', - cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: false, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false }, + cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: false, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false }, }, urban: { label: 'Urban', icon: '🏙️', - cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: false, cloud: true, sun: true, direct: true, diffuse: true, tmrt: true, delta: true, utci: true, uvA: true, uvB: true, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false }, + cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: false, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: true, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: true, vehicleT: false, indoorT: false }, }, farming: { label: 'Farming', icon: '🌾', - cols: { hour: true, air: true, rh: true, dew: true, wind: true, dir: false, cloud: true, sun: true, direct: true, diffuse: true, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, soilT: true, soilT6: true, soilM: true }, + cols: { hour: true, air: true, rh: true, dew: true, wind: true, dir: false, cloud: true, sun: true, direct: true, diffuse: true, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, soilT: true, soilT6: true, soilM: true, concreteT: false, vehicleT: false, indoorT: false }, }, sailing: { label: 'Sailing', icon: '⛵', - cols: { hour: true, air: false, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: true, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: true, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false }, + cols: { hour: true, air: false, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: true, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: true, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false }, + }, + vehicle: { + label: 'Vehicle', + icon: '🚗', + cols: { hour: true, air: true, rh: false, dew: false, wind: false, dir: false, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: true, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: true, indoorT: false }, + }, + home: { + label: 'Home', + icon: '🏠', + cols: { hour: true, air: true, rh: false, dew: false, wind: false, dir: false, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: true }, }, custom: { label: 'Custom', icon: '⚙️', - cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: false, cloud: false, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false }, + cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: false, cloud: false, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false }, }, }; @@ -160,7 +170,7 @@ export function UTCIForecast() { tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, - soilT: false, soilT6: false, soilM: false, + soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, }); const toggleCol = (col) => setVisibleCols(prev => ({ ...prev, [col]: !prev[col] })); @@ -173,11 +183,82 @@ export function UTCIForecast() { // and its inner "track" gets translateX'd via JS to follow the body's // scrollLeft. See the useLayoutEffect just below where the JS sync // happens, and the .utci-thead-sticky / .utci-tbody-scroll CSS rules. - const headStickyRef = useRef(null); - const headTrackRef = useRef(null); - const headTableRef = useRef(null); - const bodyScrollRef = useRef(null); - const bodyTableRef = useRef(null); + const headStickyRef = useRef(null); + const headTrackRef = useRef(null); + const headTableRef = useRef(null); + const bodyScrollRef = useRef(null); + const bodyTableRef = useRef(null); + const tableWrapRef = useRef(null); + + // ─── TABLE SCROLL INDICATORS ───────────────────────────────────────── + // Track whether the body scroller can scroll left/right so we can show + // fade + chevron indicators on the table edges. + const [tableCanScrollLeft, setTableCanScrollLeft] = useState(false); + const [tableCanScrollRight, setTableCanScrollRight] = useState(false); + + const updateTableScrollIndicators = () => { + const el = bodyScrollRef.current; + if (!el) return; + setTableCanScrollLeft(el.scrollLeft > 1); + setTableCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1); + }; + + // ─── DRAG-TO-SCROLL ────────────────────────────────────────────────── + // Attach pointer-event drag scrolling to the body scroller so desktop + // users can click-drag the table horizontally. + useEffect(() => { + const el = bodyScrollRef.current; + if (!el) return; + let isDown = false; + let startX = 0; + let startScroll = 0; + + const onMouseDown = (e) => { + // Only act on clicks that land inside the body scroller + if (!el.contains(e.target)) return; + if (e.button !== 0) return; + if (e.target.closest('button, a, input, select')) return; + isDown = true; + startX = e.clientX; + startScroll = el.scrollLeft; + el.style.cursor = 'grabbing'; + document.body.style.userSelect = 'none'; + document.body.style.webkitUserSelect = 'none'; + }; + const onMouseMove = (e) => { + if (!isDown) return; + const dx = e.clientX - startX; + el.scrollLeft = startScroll - dx; + }; + const onMouseUp = () => { + if (!isDown) return; + isDown = false; + el.style.cursor = ''; + document.body.style.userSelect = ''; + document.body.style.webkitUserSelect = ''; + }; + + // Attach everything to document so Preact's synthetic event system + // cannot intercept or swallow the events before we see them. + document.addEventListener('mousedown', onMouseDown); + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + + // Also update indicators on scroll + el.addEventListener('scroll', updateTableScrollIndicators); + + return () => { + document.removeEventListener('mousedown', onMouseDown); + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + el.removeEventListener('scroll', updateTableScrollIndicators); + }; + }, [forecast]); + + // Update indicators after layout sync (columns may have changed width) + useEffect(() => { + updateTableScrollIndicators(); + }, [forecast, visibleCols, selectedDay]); // ─── TABLE SCROLL SYNC ─────────────────────────────────────────────── // The hourly table is rendered as two stacked scroll areas: @@ -192,6 +273,7 @@ export function UTCIForecast() { const body = bodyScrollRef.current; if (!track || !body) return; track.style.transform = `translate3d(${-body.scrollLeft}px, 0, 0)`; + updateTableScrollIndicators(); }; useLayoutEffect(() => { @@ -392,6 +474,7 @@ export function UTCIForecast() { const soilT0 = h.soil_temperature_0cm ? h.soil_temperature_0cm[i] : null; const soilT6 = h.soil_temperature_6cm ? h.soil_temperature_6cm[i] : null; const soilM = h.soil_moisture_0_to_1cm ? h.soil_moisture_0_to_1cm[i] : null; + const concreteT = calcConcreteTemp(Ta, glob, va); // iso is a local wall-clock string e.g. "2026-05-13T14:00" (no Z). // For display we slice the string directly — no Date object needed. // For solarElevationDeg (which uses .getUTC* internally) we need the @@ -401,6 +484,7 @@ export function UTCIForecast() { // dt kept for SkyScope / backward compat — same as dtUTC. const dt = dtUTC; const elev = solarElevationDeg(location.lat, location.lon, dtUTC); + const vehicleT = calcVehicleInteriorTemp(Ta, glob, elev); const eh = vaporPressureHpa(Ta, RH); const Tmrt = calcTmrt(Ta, dir, dif, glob, elev); const utci = utciApprox(Ta, Tmrt, va, eh); @@ -414,11 +498,22 @@ export function UTCIForecast() { cc, ccLow, ccMid, ccHigh, cloudCat, uv, uvA, uvB, precip, snow, - soilT0, soilT6, soilM, + soilT0, soilT6, soilM, concreteT, vehicleT, elev, Tmrt, utci, utciAdj, eh, compass, }; }) : []; + // Two-pass indoor temperature: needs the full hourly arrays so thermal + // lag can look back at previous hours. Run after hourlyRows is built, + // then stamp each row with its indoorT value. + if (hourlyRows.length > 0) { + const TaArr = hourlyRows.map(r => r.Ta); + const globArr = hourlyRows.map(r => r.glob); + const elevArr = hourlyRows.map(r => r.elev); + const indoorTemps = calcIndoorTempPass(TaArr, globArr, elevArr); + hourlyRows.forEach((r, i) => { r.indoorT = indoorTemps[i]; }); + } + // Group those hourly rows into days for the day tabs. const days = []; hourlyRows.forEach(row => { @@ -783,9 +878,12 @@ export function UTCIForecast() { { 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: 'soilT', label: 'Soil °C' }, + { key: 'soilT6', label: 'Soil 6cm' }, + { key: 'soilM', label: 'Soil moist' }, + { key: 'concreteT', label: 'Concrete' }, + { key: 'vehicleT', label: 'Vehicle' }, + { key: 'indoorT', label: 'Indoors' }, { key: 'wind', label: 'Wind' }, { key: 'dir', label: 'Dir' }, { key: 'cloud', label: 'Cloud' }, @@ -837,7 +935,9 @@ export function UTCIForecast() { show, remove the visibleCols check around it. To rename a heading, edit the text inside the matching