Beta 6
Now with concrete, vehicle and home temps and profiles
This commit is contained in:
+144
-28
@@ -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 <th>.
|
||||
-->
|
||||
<div class="utci-table-wrap">
|
||||
<div class=${`utci-table-wrap${tableCanScrollLeft ? ' scroll-fade-left' : ''}${tableCanScrollRight ? ' scroll-fade-right' : ''}`} ref=${tableWrapRef}>
|
||||
<span class="utci-scroll-chevron left" aria-hidden="true">‹</span>
|
||||
<span class="utci-scroll-chevron right" aria-hidden="true">›</span>
|
||||
<!-- Sticky header strip — locks to viewport top. Clipped
|
||||
horizontally; the inner .utci-thead-track is shifted
|
||||
via translateX from JS to follow the body's scrollLeft.
|
||||
@@ -853,7 +953,10 @@ export function UTCIForecast() {
|
||||
${visibleCols.dew && html`<th>Dew <span class="col-unit">°C</span></th>`}
|
||||
${visibleCols.soilT && html`<th>Soil °C <span class="col-unit">surface</span></th>`}
|
||||
${visibleCols.soilT6 && html`<th>Soil 6cm <span class="col-unit">°C root</span></th>`}
|
||||
${visibleCols.soilM && html`<th>Soil moist <span class="col-unit">m³/m³</span></th>`}
|
||||
${visibleCols.soilM && html`<th>Soil moist <span class="col-unit">m³/m³</span></th>`}
|
||||
${visibleCols.concreteT && html`<th>Concrete <span class="col-unit">°C surface</span></th>`}
|
||||
${visibleCols.vehicleT && html`<th>Vehicle <span class="col-unit">°C peak</span></th>`}
|
||||
${visibleCols.indoorT && html`<th>Indoors <span class="col-unit">°C est.</span></th>`}
|
||||
${visibleCols.wind && html`<th>Wind <span class="col-unit">m/s (gust)</span></th>`}
|
||||
${visibleCols.dir && html`<th class="utci-dir-cell">Dir <span class="col-unit">-</span></th>`}
|
||||
${visibleCols.cloud && html`<th>Cloud <span class="col-unit">%</span></th>`}
|
||||
@@ -886,7 +989,8 @@ export function UTCIForecast() {
|
||||
const delta = r.utci - r.Ta;
|
||||
const adjCat = utciCategory(r.utciAdj);
|
||||
// r.iso is the local wall-clock string from the API — slice it directly.
|
||||
const localHHMM = r.iso.slice(11, 16);
|
||||
const h24 = parseInt(r.iso.slice(11, 13), 10);
|
||||
const localHHMM = h24 === 0 ? '12am' : h24 < 12 ? `${h24}am` : h24 === 12 ? '12pm' : `${h24 - 12}pm`;
|
||||
return html`
|
||||
<tr key=${r.iso}
|
||||
class=${`${isNight ? 'is-night' : ''} ${isNow ? 'is-now' : ''}`.trim()}>
|
||||
@@ -900,11 +1004,23 @@ export function UTCIForecast() {
|
||||
${visibleCols.rh && html`<td>${Math.round(r.RH)}</td>`}
|
||||
${visibleCols.dew && html`<td>${r.dew != null ? r.dew.toFixed(1) : '—'}</td>`}
|
||||
${visibleCols.soilT && html`
|
||||
<td style=${{ color: '#8a6a3a' }}>${r.soilT0 != null ? r.soilT0.toFixed(1) : '—'}</td>`}
|
||||
<td style=${{ color: '#6b4a1c' }}>${r.soilT0 != null ? r.soilT0.toFixed(1) : '—'}</td>`}
|
||||
${visibleCols.soilT6 && html`
|
||||
<td style=${{ color: '#8a6a3a' }}>${r.soilT6 != null ? r.soilT6.toFixed(1) : '—'}</td>`}
|
||||
<td style=${{ color: '#6b4a1c' }}>${r.soilT6 != null ? r.soilT6.toFixed(1) : '—'}</td>`}
|
||||
${visibleCols.soilM && html`
|
||||
<td style=${{ color: '#5090b0' }}>${r.soilM != null ? r.soilM.toFixed(3) : '—'}</td>`}
|
||||
<td style=${{ color: '#2a6a90' }}>${r.soilM != null ? r.soilM.toFixed(3) : '—'}</td>`}
|
||||
${visibleCols.concreteT && html`
|
||||
<td style=${{ color: r.concreteT != null && r.concreteT > 40 ? '#c0392b' : r.concreteT != null && r.concreteT > 30 ? '#e67e22' : '#7f8c8d', fontWeight: 'bold' }}>
|
||||
${r.concreteT != null ? r.concreteT.toFixed(1) : '—'}
|
||||
</td>`}
|
||||
${visibleCols.vehicleT && html`
|
||||
<td style=${{ color: r.vehicleT != null && r.vehicleT > 45 ? '#c0392b' : r.vehicleT != null && r.vehicleT > 35 ? '#e67e22' : '#7f8c8d', fontWeight: 'bold' }}>
|
||||
${r.vehicleT != null ? r.vehicleT.toFixed(1) : '—'}
|
||||
</td>`}
|
||||
${visibleCols.indoorT && html`
|
||||
<td style=${{ color: r.indoorT != null && r.indoorT > 32 ? '#c0392b' : r.indoorT != null && r.indoorT > 26 ? '#e67e22' : '#4a7a4a', fontWeight: 'bold' }}>
|
||||
${r.indoorT != null ? r.indoorT.toFixed(1) : '—'}
|
||||
</td>`}
|
||||
${visibleCols.wind && html`<td>
|
||||
${r.va.toFixed(1)}${r.gust != null && r.gust > r.va + 0.5
|
||||
? html`<span style=${{ opacity: 0.65, marginLeft: '4px' }}>(${r.gust.toFixed(1)})</span>`
|
||||
@@ -928,7 +1044,7 @@ export function UTCIForecast() {
|
||||
${visibleCols.tmrt && html`<td>${r.Tmrt.toFixed(1)}</td>`}
|
||||
${visibleCols.delta && html`
|
||||
<td style=${{
|
||||
color: delta > 3 ? '#c8601a' : delta < -3 ? '#3f73c4' : '#9a7d5a',
|
||||
color: delta > 3 ? '#c8601a' : delta < -3 ? '#3f73c4' : '#4a3218',
|
||||
fontWeight: 600,
|
||||
}}>
|
||||
${delta > 0 ? '+' : ''}${delta.toFixed(1)}
|
||||
@@ -940,22 +1056,22 @@ export function UTCIForecast() {
|
||||
</span>
|
||||
</td>`}
|
||||
${visibleCols.uvA && html`
|
||||
<td style=${{ color: r.uvA > 0 ? '#c8922a' : '#5a4228' }}>
|
||||
<td style=${{ color: r.uvA > 0 ? '#c8922a' : '#4a3218' }}>
|
||||
${r.uvA > 0 ? r.uvA.toFixed(1) : '—'}
|
||||
</td>`}
|
||||
${visibleCols.uvB && html`
|
||||
<td style=${{ color: r.uvB > 0 ? '#c44a3a' : '#5a4228', fontWeight: 600 }}>
|
||||
<td style=${{ color: r.uvB > 0 ? '#c44a3a' : '#4a3218' }}>
|
||||
${r.uvB > 0 ? r.uvB.toFixed(2) : '—'}
|
||||
</td>`}
|
||||
${visibleCols.burn && html`
|
||||
<td style=${{ color: r.uv > 0 ? (sunburnMinutes(r.uv, skinType) < 30 ? '#c44a3a' : '#c8601a') : '#5a4228' }}>
|
||||
<td style=${{ color: r.uv > 0 ? (sunburnMinutes(r.uv, skinType) < 30 ? '#c44a3a' : '#c8601a') : '#4a3218' }}>
|
||||
${burnLabel(sunburnMinutes(r.uv, skinType))}
|
||||
</td>`}
|
||||
${visibleCols.precip && html`
|
||||
<td>
|
||||
<span style=${{ display: 'inline-flex', alignItems: 'center', gap: '5px', verticalAlign: 'middle' }}>
|
||||
<${PrecipIcon} precip=${r.precip} snow=${r.snow} size=${28} />
|
||||
<span style=${{ color: r.snow > 0 ? '#6090c8' : r.precip > 0 ? '#5090b0' : '#c0a880' }}>
|
||||
<span style=${{ color: r.snow > 0 ? '#2a5fa8' : r.precip > 0 ? '#2a6a90' : '#7a5c30' }}>
|
||||
${r.snow > 0 ? r.snow.toFixed(1) + 'cm' : r.precip > 0 ? r.precip.toFixed(1) : '—'}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
Reference in New Issue
Block a user