Beta 6
Now with concrete, vehicle and home temps and profiles
This commit is contained in:
+48
-1
@@ -10,7 +10,7 @@
|
|||||||
|
|
||||||
<!-- Open Graph -->
|
<!-- Open Graph -->
|
||||||
<meta property="og:title" content="About SunScope · The True Temperature" />
|
<meta property="og:title" content="About SunScope · The True Temperature" />
|
||||||
<meta property="og:description" content="What is UTCI? How does the SunScope soak-factor work? Learn how SunScope gives you a more honest outdoor comfort forecast than standard air temperature." />
|
<meta property="og:description" content="What is UTCI? How does the SunScope soak-factor work? Learn how SunScope calculates urban concrete heat, vehicle interior temperatures, and gives you a more honest outdoor comfort forecast." />
|
||||||
<meta property="og:url" content="https://sunscope.net/about.html" />
|
<meta property="og:url" content="https://sunscope.net/about.html" />
|
||||||
<meta property="og:type" content="website" />
|
<meta property="og:type" content="website" />
|
||||||
|
|
||||||
@@ -104,6 +104,53 @@
|
|||||||
a more honest, real-world comfort score for anyone heading outdoors.
|
a more honest, real-world comfort score for anyone heading outdoors.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<h2>Urban heat: concrete surface temperature</h2>
|
||||||
|
<p>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
SunScope's <strong>Concrete surface</strong> 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.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
The column is enabled by default in the <strong>Urban</strong> profile and colour-coded:
|
||||||
|
values above 30 °C are shown in amber, above 40 °C in red.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Vehicle interior temperature</h2>
|
||||||
|
<p>
|
||||||
|
A sealed, parked vehicle heats up far faster than most people expect. SunScope's
|
||||||
|
<strong>Vehicle</strong> 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.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
The calculation combines two physical processes. The first is <strong>body panel
|
||||||
|
conduction</strong>: 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
|
||||||
|
<strong>side-window solar gain</strong>: 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.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
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).
|
||||||
|
</p>
|
||||||
|
|
||||||
<h2>Reading the stress bands</h2>
|
<h2>Reading the stress bands</h2>
|
||||||
<p>SunScope maps every UTCI+P value to a thermal stress band so you can read conditions at a glance:</p>
|
<p>SunScope maps every UTCI+P value to a thermal stress band so you can read conditions at a glance:</p>
|
||||||
|
|
||||||
|
|||||||
+78
-4
@@ -248,6 +248,69 @@
|
|||||||
The body scroller (.utci-tbody-scroll) owns the horizontal scrollbar. */
|
The body scroller (.utci-tbody-scroll) owns the horizontal scrollbar. */
|
||||||
border: 1.5px solid #c9b08a;
|
border: 1.5px solid #c9b08a;
|
||||||
background: #fffcf2;
|
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 ────────────────────────────────────────────────
|
/* ── STICKY HEADER STRIP ────────────────────────────────────────────────
|
||||||
@@ -387,7 +450,7 @@
|
|||||||
|
|
||||||
/* Night-time hours get a slightly muted text colour */
|
/* Night-time hours get a slightly muted text colour */
|
||||||
.utci-table tbody tr.is-night td {
|
.utci-table tbody tr.is-night td {
|
||||||
color: #c0a880;
|
color: #7a5c30;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The time cell — uses the display serif instead of monospace */
|
/* The time cell — uses the display serif instead of monospace */
|
||||||
@@ -398,6 +461,17 @@
|
|||||||
color: #1e1208 !important;
|
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 coloured pill inside UTCI / UTCI+P cells.
|
||||||
The pill's BACKGROUND comes from inline styles in app.js
|
The pill's BACKGROUND comes from inline styles in app.js
|
||||||
(the utciCategory() function decides which thermal-stress colour
|
(the utciCategory() function decides which thermal-stress colour
|
||||||
@@ -415,10 +489,10 @@
|
|||||||
/* Hero variant used for the UTCI+P column — the headline number.
|
/* Hero variant used for the UTCI+P column — the headline number.
|
||||||
Bigger, bolder, more breathing room so the eye lands here first. */
|
Bigger, bolder, more breathing room so the eye lands here first. */
|
||||||
.utci-cell-hero {
|
.utci-cell-hero {
|
||||||
padding: 7px 16px;
|
padding: 6px 4px;
|
||||||
font-size: 19px;
|
font-size: 17px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
min-width: 76px;
|
min-width: 63px;
|
||||||
letter-spacing: 0.01em;
|
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;
|
box-shadow: 0 1px 0 rgba(0,0,0,0.15) inset, 0 0 0 1px rgba(255,255,255,0.08) inset;
|
||||||
}
|
}
|
||||||
|
|||||||
+144
-28
@@ -23,7 +23,7 @@
|
|||||||
import { h, render, Fragment } from '../vendor/preact.js';
|
import { h, render, Fragment } from '../vendor/preact.js';
|
||||||
import { useState, useEffect, useLayoutEffect, useRef } from '../vendor/preact-hooks.js';
|
import { useState, useEffect, useLayoutEffect, useRef } from '../vendor/preact-hooks.js';
|
||||||
import htm from '../vendor/htm.js';
|
import htm from '../vendor/htm.js';
|
||||||
import { vaporPressureHpa, solarElevationDeg, calcTmrt, utciApprox } from './physics.js';
|
import { vaporPressureHpa, solarElevationDeg, calcTmrt, utciApprox, calcConcreteTemp, calcVehicleInteriorTemp, calcIndoorTempPass } from './physics.js';
|
||||||
import {
|
import {
|
||||||
utciCategory, precipPenalty, windCompass8, uvSplit,
|
utciCategory, precipPenalty, windCompass8, uvSplit,
|
||||||
SKIN_TYPES, sunburnMinutes, burnLabel,
|
SKIN_TYPES, sunburnMinutes, burnLabel,
|
||||||
@@ -111,7 +111,7 @@ export function UTCIForecast() {
|
|||||||
// FLIP THE `false` BELOW TO `true` TO PREVIEW THE PRO EXPERIENCE.
|
// FLIP THE `false` BELOW TO `true` TO PREVIEW THE PRO EXPERIENCE.
|
||||||
// When this is wired to real billing/auth, replace `useState(false)`
|
// When this is wired to real billing/auth, replace `useState(false)`
|
||||||
// with a check against the logged-in user.
|
// 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 🔒.
|
// How many days the free tier shows. Days beyond this get a 🔒.
|
||||||
// Bump this number if you want to give free users more access.
|
// Bump this number if you want to give free users more access.
|
||||||
@@ -123,27 +123,37 @@ export function UTCIForecast() {
|
|||||||
basic: {
|
basic: {
|
||||||
label: 'Basic',
|
label: 'Basic',
|
||||||
icon: '🌡️',
|
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: {
|
urban: {
|
||||||
label: 'Urban',
|
label: 'Urban',
|
||||||
icon: '🏙️',
|
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: {
|
farming: {
|
||||||
label: 'Farming',
|
label: 'Farming',
|
||||||
icon: '🌾',
|
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: {
|
sailing: {
|
||||||
label: 'Sailing',
|
label: 'Sailing',
|
||||||
icon: '⛵',
|
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: {
|
custom: {
|
||||||
label: 'Custom',
|
label: 'Custom',
|
||||||
icon: '⚙️',
|
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,
|
tmrt: false, delta: false, utci: false,
|
||||||
uvA: false, uvB: false, burn: false,
|
uvA: false, uvB: false, burn: false,
|
||||||
utciP: true, precip: true,
|
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] }));
|
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
|
// and its inner "track" gets translateX'd via JS to follow the body's
|
||||||
// scrollLeft. See the useLayoutEffect just below where the JS sync
|
// scrollLeft. See the useLayoutEffect just below where the JS sync
|
||||||
// happens, and the .utci-thead-sticky / .utci-tbody-scroll CSS rules.
|
// happens, and the .utci-thead-sticky / .utci-tbody-scroll CSS rules.
|
||||||
const headStickyRef = useRef(null);
|
const headStickyRef = useRef(null);
|
||||||
const headTrackRef = useRef(null);
|
const headTrackRef = useRef(null);
|
||||||
const headTableRef = useRef(null);
|
const headTableRef = useRef(null);
|
||||||
const bodyScrollRef = useRef(null);
|
const bodyScrollRef = useRef(null);
|
||||||
const bodyTableRef = 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 ───────────────────────────────────────────────
|
// ─── TABLE SCROLL SYNC ───────────────────────────────────────────────
|
||||||
// The hourly table is rendered as two stacked scroll areas:
|
// The hourly table is rendered as two stacked scroll areas:
|
||||||
@@ -192,6 +273,7 @@ export function UTCIForecast() {
|
|||||||
const body = bodyScrollRef.current;
|
const body = bodyScrollRef.current;
|
||||||
if (!track || !body) return;
|
if (!track || !body) return;
|
||||||
track.style.transform = `translate3d(${-body.scrollLeft}px, 0, 0)`;
|
track.style.transform = `translate3d(${-body.scrollLeft}px, 0, 0)`;
|
||||||
|
updateTableScrollIndicators();
|
||||||
};
|
};
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
@@ -392,6 +474,7 @@ export function UTCIForecast() {
|
|||||||
const soilT0 = h.soil_temperature_0cm ? h.soil_temperature_0cm[i] : null;
|
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 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 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).
|
// 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 display we slice the string directly — no Date object needed.
|
||||||
// For solarElevationDeg (which uses .getUTC* internally) we need the
|
// 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.
|
// dt kept for SkyScope / backward compat — same as dtUTC.
|
||||||
const dt = dtUTC;
|
const dt = dtUTC;
|
||||||
const elev = solarElevationDeg(location.lat, location.lon, dtUTC);
|
const elev = solarElevationDeg(location.lat, location.lon, dtUTC);
|
||||||
|
const vehicleT = calcVehicleInteriorTemp(Ta, glob, elev);
|
||||||
const eh = vaporPressureHpa(Ta, RH);
|
const eh = vaporPressureHpa(Ta, RH);
|
||||||
const Tmrt = calcTmrt(Ta, dir, dif, glob, elev);
|
const Tmrt = calcTmrt(Ta, dir, dif, glob, elev);
|
||||||
const utci = utciApprox(Ta, Tmrt, va, eh);
|
const utci = utciApprox(Ta, Tmrt, va, eh);
|
||||||
@@ -414,11 +498,22 @@ export function UTCIForecast() {
|
|||||||
cc, ccLow, ccMid, ccHigh, cloudCat,
|
cc, ccLow, ccMid, ccHigh, cloudCat,
|
||||||
uv, uvA, uvB,
|
uv, uvA, uvB,
|
||||||
precip, snow,
|
precip, snow,
|
||||||
soilT0, soilT6, soilM,
|
soilT0, soilT6, soilM, concreteT, vehicleT,
|
||||||
elev, Tmrt, utci, utciAdj, eh, compass,
|
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.
|
// Group those hourly rows into days for the day tabs.
|
||||||
const days = [];
|
const days = [];
|
||||||
hourlyRows.forEach(row => {
|
hourlyRows.forEach(row => {
|
||||||
@@ -783,9 +878,12 @@ export function UTCIForecast() {
|
|||||||
{ key: 'air', label: 'Air' },
|
{ key: 'air', label: 'Air' },
|
||||||
{ key: 'rh', label: 'RH' },
|
{ key: 'rh', label: 'RH' },
|
||||||
{ key: 'dew', label: 'Dew' },
|
{ key: 'dew', label: 'Dew' },
|
||||||
{ key: 'soilT', label: 'Soil °C' },
|
{ key: 'soilT', label: 'Soil °C' },
|
||||||
{ key: 'soilT6', label: 'Soil 6cm' },
|
{ key: 'soilT6', label: 'Soil 6cm' },
|
||||||
{ key: 'soilM', label: 'Soil moist' },
|
{ key: 'soilM', label: 'Soil moist' },
|
||||||
|
{ key: 'concreteT', label: 'Concrete' },
|
||||||
|
{ key: 'vehicleT', label: 'Vehicle' },
|
||||||
|
{ key: 'indoorT', label: 'Indoors' },
|
||||||
{ key: 'wind', label: 'Wind' },
|
{ key: 'wind', label: 'Wind' },
|
||||||
{ key: 'dir', label: 'Dir' },
|
{ key: 'dir', label: 'Dir' },
|
||||||
{ key: 'cloud', label: 'Cloud' },
|
{ key: 'cloud', label: 'Cloud' },
|
||||||
@@ -837,7 +935,9 @@ export function UTCIForecast() {
|
|||||||
show, remove the visibleCols check around it. To rename a
|
show, remove the visibleCols check around it. To rename a
|
||||||
heading, edit the text inside the matching <th>.
|
heading, edit the text inside the matching <th>.
|
||||||
-->
|
-->
|
||||||
<div class="utci-table-wrap">
|
<div class=${`utci-table-wrap${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
|
<!-- Sticky header strip — locks to viewport top. Clipped
|
||||||
horizontally; the inner .utci-thead-track is shifted
|
horizontally; the inner .utci-thead-track is shifted
|
||||||
via translateX from JS to follow the body's scrollLeft.
|
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.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.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.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.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.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>`}
|
${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 delta = r.utci - r.Ta;
|
||||||
const adjCat = utciCategory(r.utciAdj);
|
const adjCat = utciCategory(r.utciAdj);
|
||||||
// r.iso is the local wall-clock string from the API — slice it directly.
|
// 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`
|
return html`
|
||||||
<tr key=${r.iso}
|
<tr key=${r.iso}
|
||||||
class=${`${isNight ? 'is-night' : ''} ${isNow ? 'is-now' : ''}`.trim()}>
|
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.rh && html`<td>${Math.round(r.RH)}</td>`}
|
||||||
${visibleCols.dew && html`<td>${r.dew != null ? r.dew.toFixed(1) : '—'}</td>`}
|
${visibleCols.dew && html`<td>${r.dew != null ? r.dew.toFixed(1) : '—'}</td>`}
|
||||||
${visibleCols.soilT && html`
|
${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`
|
${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`
|
${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>
|
${visibleCols.wind && html`<td>
|
||||||
${r.va.toFixed(1)}${r.gust != null && r.gust > r.va + 0.5
|
${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>`
|
? 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.tmrt && html`<td>${r.Tmrt.toFixed(1)}</td>`}
|
||||||
${visibleCols.delta && html`
|
${visibleCols.delta && html`
|
||||||
<td style=${{
|
<td style=${{
|
||||||
color: delta > 3 ? '#c8601a' : delta < -3 ? '#3f73c4' : '#9a7d5a',
|
color: delta > 3 ? '#c8601a' : delta < -3 ? '#3f73c4' : '#4a3218',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
}}>
|
}}>
|
||||||
${delta > 0 ? '+' : ''}${delta.toFixed(1)}
|
${delta > 0 ? '+' : ''}${delta.toFixed(1)}
|
||||||
@@ -940,22 +1056,22 @@ export function UTCIForecast() {
|
|||||||
</span>
|
</span>
|
||||||
</td>`}
|
</td>`}
|
||||||
${visibleCols.uvA && html`
|
${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) : '—'}
|
${r.uvA > 0 ? r.uvA.toFixed(1) : '—'}
|
||||||
</td>`}
|
</td>`}
|
||||||
${visibleCols.uvB && html`
|
${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) : '—'}
|
${r.uvB > 0 ? r.uvB.toFixed(2) : '—'}
|
||||||
</td>`}
|
</td>`}
|
||||||
${visibleCols.burn && html`
|
${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))}
|
${burnLabel(sunburnMinutes(r.uv, skinType))}
|
||||||
</td>`}
|
</td>`}
|
||||||
${visibleCols.precip && html`
|
${visibleCols.precip && html`
|
||||||
<td>
|
<td>
|
||||||
<span style=${{ display: 'inline-flex', alignItems: 'center', gap: '5px', verticalAlign: 'middle' }}>
|
<span style=${{ display: 'inline-flex', alignItems: 'center', gap: '5px', verticalAlign: 'middle' }}>
|
||||||
<${PrecipIcon} precip=${r.precip} snow=${r.snow} size=${28} />
|
<${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) : '—'}
|
${r.snow > 0 ? r.snow.toFixed(1) + 'cm' : r.precip > 0 ? r.precip.toFixed(1) : '—'}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
+219
-8
@@ -18,15 +18,226 @@
|
|||||||
// These are real-world physics values — don't change them unless you
|
// These are real-world physics values — don't change them unless you
|
||||||
// have a peer-reviewed reason. They're used by calcTmrt() below to
|
// have a peer-reviewed reason. They're used by calcTmrt() below to
|
||||||
// work out how much heat your skin actually absorbs from the sun.
|
// work out how much heat your skin actually absorbs from the sun.
|
||||||
// SIGMA — Stefan–Boltzmann constant (radiates heat)
|
// SIGMA — Stefan–Boltzmann constant (radiates heat)
|
||||||
// EPSILON_P — emissivity of human skin (~0.97)
|
// EPSILON_P — emissivity of human skin (~0.97)
|
||||||
// A_K — short-wave absorption coefficient for clothing
|
// A_K — short-wave absorption coefficient for clothing
|
||||||
// ALBEDO_GRASS — how much sun grass reflects back at you (23%)
|
// ALBEDO_GRASS — how much sun grass reflects back at you (23%)
|
||||||
|
// ALBEDO_CONCRETE — how much sun concrete reflects back (30%)
|
||||||
|
// Concrete absorbs more net solar than grass and has
|
||||||
|
// no evaporative cooling, so its surface runs hot.
|
||||||
// ═══════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
export const SIGMA = 5.670374419e-8;
|
export const SIGMA = 5.670374419e-8;
|
||||||
export const EPSILON_P = 0.97;
|
export const EPSILON_P = 0.97;
|
||||||
export const A_K = 0.7;
|
export const A_K = 0.7;
|
||||||
export const ALBEDO_GRASS = 0.23;
|
export const ALBEDO_GRASS = 0.23;
|
||||||
|
export const ALBEDO_CONCRETE = 0.30;
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
|
// CONCRETE SURFACE TEMPERATURE (Urban profile)
|
||||||
|
// ───────────────────────────────────────────────────────────────────
|
||||||
|
// Estimates the surface temperature of exposed concrete using a
|
||||||
|
// simplified energy-balance approach:
|
||||||
|
// • Absorbed solar = globalRad × (1 − albedo)
|
||||||
|
// • No latent heat (no evaporation — concrete is dry)
|
||||||
|
// • Convective loss to air proportional to wind speed
|
||||||
|
// • Result is clamped to a physically plausible range
|
||||||
|
//
|
||||||
|
// This is what matters for contact heat stress in cities — the UTCI
|
||||||
|
// standard uses grass, which runs ~5–15 °C cooler than urban concrete
|
||||||
|
// on a sunny day because grass sweats (transpires).
|
||||||
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
|
export function calcConcreteTemp(Ta, globalRad, windSpeed) {
|
||||||
|
if (globalRad == null || Ta == null) return null;
|
||||||
|
const absorbed = globalRad * (1 - ALBEDO_CONCRETE); // W/m²
|
||||||
|
// Convective heat transfer coefficient: ~5 W/m²K still air, rises with wind
|
||||||
|
const hc = 5 + 4.5 * Math.sqrt(Math.max(windSpeed || 0, 0));
|
||||||
|
// Surface temp: Ta + solar gain / convective loss
|
||||||
|
const Ts = Ta + absorbed / hc;
|
||||||
|
// Clamp: can't be cooler than air, cap at 85 °C (melting asphalt territory)
|
||||||
|
return Math.max(Ta, Math.min(Ts, 85));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
|
// UK HOUSE INDOOR TEMPERATURE (windows closed, no active cooling)
|
||||||
|
// ───────────────────────────────────────────────────────────────────
|
||||||
|
// Estimates the ambient indoor air temperature of a typical UK brick
|
||||||
|
// house with windows closed and no air conditioning.
|
||||||
|
//
|
||||||
|
// Two heat pathways are modelled:
|
||||||
|
//
|
||||||
|
// 1. WALL CONDUCTION
|
||||||
|
// Heat conducts through brick cavity walls and roof. UK Part L
|
||||||
|
// compliant walls have a U-value around 0.28–0.45 W/m²K; older
|
||||||
|
// solid-brick stock runs higher. A representative mid-stock value
|
||||||
|
// is used. This drives a slow, steady heat transfer proportional
|
||||||
|
// to the difference between outdoor and indoor air temperature.
|
||||||
|
//
|
||||||
|
// 2. WINDOW SOLAR GAIN
|
||||||
|
// A typical UK semi has ~15–18% glazing ratio. Solar energy
|
||||||
|
// transmits through glass, is absorbed by floors and furniture,
|
||||||
|
// and heats the indoor air. Gain is averaged across orientations
|
||||||
|
// (not all windows face south). Diffuse radiation contributes
|
||||||
|
// regardless of sun angle.
|
||||||
|
//
|
||||||
|
// THERMAL LAG
|
||||||
|
// Brick and concrete have high thermal mass — the house responds
|
||||||
|
// slowly to outdoor temperature swings. This function takes a
|
||||||
|
// weighted average of the current hour's heat load and the
|
||||||
|
// previous few hours', giving the characteristic lag where indoor
|
||||||
|
// temperature peaks 2–4 hours after the outdoor peak.
|
||||||
|
// Call calcIndoorTempPass() on the full hourly arrays after
|
||||||
|
// building rows — it returns a per-hour indoor temp array.
|
||||||
|
//
|
||||||
|
// No mechanical cooling. Minimal infiltration (windows closed).
|
||||||
|
// Internal heat gains (people, appliances) are not modelled.
|
||||||
|
//
|
||||||
|
// Colour thresholds:
|
||||||
|
// < 20 °C — cool, may need heating
|
||||||
|
// 20–26 °C — comfortable
|
||||||
|
// 26–32 °C — warm; WHO heatwave advisory threshold for sleeping
|
||||||
|
// > 32 °C — hot; risk for elderly and vulnerable occupants
|
||||||
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// Single-hour instantaneous heat load (W/m² effective, indoor side).
|
||||||
|
// Used internally by calcIndoorTempPass — not exported.
|
||||||
|
function _houseHeatLoad(Ta, globalRad, solElev) {
|
||||||
|
// Wall + roof conduction: U-value ~0.35 W/m²K × effective envelope area ratio
|
||||||
|
// We express as a gain-per-degree-delta — applied against Ti later in the pass.
|
||||||
|
// (See calcIndoorTempPass for how this feeds the lag model.)
|
||||||
|
|
||||||
|
// Window solar gain: glazing ratio 0.16, g-value 0.63 (standard double glazing),
|
||||||
|
// averaged across orientations (0.5 factor — not all windows face the sun).
|
||||||
|
const glazingRatio = 0.16;
|
||||||
|
const gValue = 0.63;
|
||||||
|
const orientFactor = 0.50;
|
||||||
|
const solarGain = globalRad * glazingRatio * gValue * orientFactor;
|
||||||
|
|
||||||
|
return { conductionDelta: Ta, solarGain };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two-pass function: call with the full arrays of hourly Ta and globalRad.
|
||||||
|
// Returns an array of indoor temperatures, one per hour.
|
||||||
|
export function calcIndoorTempPass(TaArr, globArr, elevArr) {
|
||||||
|
const n = TaArr.length;
|
||||||
|
const result = new Array(n);
|
||||||
|
|
||||||
|
// Thermal resistance of the building envelope (°C per W/m² of heat load)
|
||||||
|
// Lower = faster response to outdoor changes. UK brick mid-stock ~0.45.
|
||||||
|
const uWall = 0.35; // W/m²K effective wall U-value
|
||||||
|
// Thermal mass time constant: heavier = longer lag.
|
||||||
|
// ~4 h lag for typical UK brick semi (expressed as exponential decay weight).
|
||||||
|
const lagHours = 4;
|
||||||
|
const alpha = 1 - Math.exp(-1 / lagHours); // per-hour blending weight
|
||||||
|
|
||||||
|
// Seed indoor temp to first outdoor temp
|
||||||
|
let Ti = TaArr[0] ?? 15;
|
||||||
|
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const Ta = TaArr[i] ?? Ti;
|
||||||
|
const glob = globArr[i] ?? 0;
|
||||||
|
const solElev = elevArr[i] ?? 0;
|
||||||
|
|
||||||
|
// Solar gain through windows (W/m² effective)
|
||||||
|
const glazingRatio = 0.16;
|
||||||
|
const gValue = 0.63;
|
||||||
|
const orientFactor = 0.50;
|
||||||
|
const solarGain = glob * glazingRatio * gValue * orientFactor;
|
||||||
|
|
||||||
|
// Conductive heat flow through walls: proportional to (Ta - Ti)
|
||||||
|
// uWall drives how quickly the indoor temp chases outdoor temp.
|
||||||
|
const conductionGain = uWall * (Ta - Ti);
|
||||||
|
|
||||||
|
// Target indoor temp this hour if there were no thermal mass:
|
||||||
|
// Ti_instant = Ti + conduction + solar load / heat capacity proxy
|
||||||
|
// heatCap proxy: how many degrees does 1 W/m² raise the indoor air?
|
||||||
|
// For a typical 90 m² house, ~0.15 °C per W/m² effective.
|
||||||
|
const heatCapProxy = 0.15;
|
||||||
|
const Ti_instant = Ti + (conductionGain + solarGain) * heatCapProxy;
|
||||||
|
|
||||||
|
// Apply thermal lag: blend toward Ti_instant slowly
|
||||||
|
Ti = Ti + alpha * (Ti_instant - Ti);
|
||||||
|
|
||||||
|
// Can't be colder than outdoor (house doesn't actively cool)
|
||||||
|
// Cap at 55 °C (physically implausible above this for a house interior)
|
||||||
|
result[i] = Math.max(Math.min(Ti, 55), Math.min(Ta, Ti));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
|
// VEHICLE INTERIOR CABIN TEMPERATURE (seated occupant, not in sunbeam)
|
||||||
|
// ───────────────────────────────────────────────────────────────────
|
||||||
|
// Models the ambient cabin air temperature experienced by an occupant
|
||||||
|
// seated out of direct sunlight inside a sealed, parked vehicle.
|
||||||
|
// Two heat sources are combined:
|
||||||
|
//
|
||||||
|
// 1. PANEL CONDUCTION
|
||||||
|
// Aluminium body panels absorb solar radiation and conduct heat
|
||||||
|
// into the cabin. Albedo ~0.25 (mid-point for typical mixed-colour
|
||||||
|
// fleet; dark paint ~0.10, silver/white ~0.40).
|
||||||
|
// Panel surface temp → conductive gain into cabin air.
|
||||||
|
//
|
||||||
|
// 2. SIDE-WINDOW GLAZING GAIN (sun-angle dependent)
|
||||||
|
// When solar elevation is between ~10° and ~60°, the sun's rays
|
||||||
|
// cut through the side glass at an angle that allows significant
|
||||||
|
// transmission into the cabin (rather than hitting the roof or
|
||||||
|
// reflecting off at a shallow angle). This warms the cabin air
|
||||||
|
// but the occupant is modelled as NOT sitting in the beam —
|
||||||
|
// so it adds to ambient cabin temp, not direct radiant load.
|
||||||
|
// Above 60° the sun mostly hits the roof; below 10° it reflects.
|
||||||
|
//
|
||||||
|
// Wind is ignored (vehicle is sealed). No evaporative cooling.
|
||||||
|
// Steady-state reached after ~45–60 min of parking.
|
||||||
|
//
|
||||||
|
// Colour thresholds in the table:
|
||||||
|
// < 35 °C — warm but tolerable for short periods
|
||||||
|
// 35–45 °C — dangerous for children/pets (hyperthermia risk)
|
||||||
|
// > 45 °C — potentially fatal within minutes
|
||||||
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
|
export function calcVehicleInteriorTemp(Ta, globalRad, solElev) {
|
||||||
|
if (globalRad == null || Ta == null) return null;
|
||||||
|
|
||||||
|
// ── 1. Panel conduction ──────────────────────────────────────────
|
||||||
|
const albedoPanel = 0.25; // typical mixed fleet
|
||||||
|
const panelAbsorbed = globalRad * (1 - albedoPanel); // W/m² absorbed by bodywork
|
||||||
|
// Panel surface temp: absorbed solar / convective loss to outside air
|
||||||
|
// hOut ~10 W/m²K (light breeze over panel surface even when parked)
|
||||||
|
const hOut = 10;
|
||||||
|
const panelSurfaceTemp = Ta + panelAbsorbed / hOut;
|
||||||
|
// Conductive gain into cabin: panel-to-cabin air, ~4 W/m²K through metal + trim
|
||||||
|
const hCabin = 4;
|
||||||
|
const conductionGain = hCabin * (panelSurfaceTemp - Ta); // W/m²
|
||||||
|
|
||||||
|
// ── 2. Side-window glazing gain (angle-dependent) ─────────────────
|
||||||
|
// Glazing transmission for auto glass ~0.70
|
||||||
|
const tau = 0.70;
|
||||||
|
let glazingGain = 0;
|
||||||
|
if (solElev != null && solElev > 10 && solElev < 60) {
|
||||||
|
// Scale factor: peaks around 30–40° elevation (sun cuts squarely
|
||||||
|
// through side glass), tapers off toward 10° (shallow/reflected)
|
||||||
|
// and 60° (sun increasingly hitting roof not side glass).
|
||||||
|
// Use a simple tent function peaking at 35°.
|
||||||
|
const peak = 35;
|
||||||
|
const halfWidth = 25; // degrees either side
|
||||||
|
const factor = Math.max(0, 1 - Math.abs(solElev - peak) / halfWidth);
|
||||||
|
// Diffuse radiation also enters through glass regardless of angle
|
||||||
|
glazingGain = tau * globalRad * factor * 0.5; // occupant not in beam → 50% ambient
|
||||||
|
} else {
|
||||||
|
// Outside the side-window zone: diffuse only (scattered sky light)
|
||||||
|
glazingGain = tau * (globalRad * 0.15); // ~15% diffuse fraction
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Combine into cabin air temperature ───────────────────────────
|
||||||
|
// Total heat input per m² of cabin surface
|
||||||
|
const totalGain = conductionGain + glazingGain;
|
||||||
|
// Cabin heat loss: poor natural ventilation in sealed car ~2.0 W/m²K
|
||||||
|
const hCabinLoss = 2.0;
|
||||||
|
const Ti = Ta + totalGain / hCabinLoss;
|
||||||
|
|
||||||
|
// Clamp: can't be cooler than outside air; physical cap at 90 °C
|
||||||
|
return Math.max(Ta, Math.min(Ti, 90));
|
||||||
|
}
|
||||||
|
|
||||||
// Vapour pressure (Magnus → hPa)
|
// Vapour pressure (Magnus → hPa)
|
||||||
export function vaporPressureHpa(Ta, RH) {
|
export function vaporPressureHpa(Ta, RH) {
|
||||||
|
|||||||
Reference in New Issue
Block a user