Update desktop profile hover button
Recalibrated vehicle ventilation calcs
This commit is contained in:
fraxle
2026-07-26 16:03:53 +01:00
parent 84d035d903
commit dbadb1d410
10 changed files with 271 additions and 81 deletions
+60 -9
View File
@@ -455,6 +455,43 @@
} }
.profile-dropdown-close:hover { color: #1e1208; background: rgba(176, 152, 112, 0.16); } .profile-dropdown-close:hover { color: #1e1208; background: rgba(176, 152, 112, 0.16); }
/* Tablet + desktop: the floating bottom button is mobile-only, so the panel
hangs off the bottom edge of the active-profile-row instead — dropping
down out of that row and shrinking back into it on close.
--profile-anchor-top is the row's live viewport bottom, set by
ConfigPanel.js. Phones (<=640px) keep the bottom sheet untouched. */
@media (min-width: 641px) {
.profile-dropdown {
top: var(--profile-anchor-top, 0px);
bottom: auto;
max-height: calc(100vh - var(--profile-anchor-top, 0px) - 16px);
border: 1.5px solid #c9b08a;
border-radius: 12px;
overflow: hidden; /* keeps the scrolling body inside the rounded corners */
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.18);
transform-origin: 50% 0;
}
.profile-dropdown-body {
max-height: calc(100vh - var(--profile-anchor-top, 0px) - 16px);
}
/* Too little room under the row for the content — revert to the mobile
bottom sheet (set by ConfigPanel.js) so the panel gets the full height. */
.profile-dropdown.is-bottom-sheet {
top: auto;
bottom: 0;
max-height: calc(100vh - 24px);
border: none;
border-top: 1.5px solid #c9b08a;
border-radius: 12px 12px 0 0;
box-shadow: 0 -10px 28px rgba(0, 0, 0, 0.18);
transform-origin: 50% 100%;
}
.profile-dropdown.is-bottom-sheet .profile-dropdown-body {
max-height: calc(100vh - 24px);
}
}
/* Read-only "what am I looking at" row above the day tabs — states the /* Read-only "what am I looking at" row above the day tabs — states the
active profile and the thermal basis (SunSoak env / vehicle / building / active profile and the thermal basis (SunSoak env / vehicle / building /
fur colour) driving the numbers below. Clicking/Enter opens the same fur colour) driving the numbers below. Clicking/Enter opens the same
@@ -465,15 +502,16 @@
justify-content: center; justify-content: center;
flex-wrap: wrap; flex-wrap: wrap;
gap: 12px; gap: 12px;
padding: 2px 16px; padding: 8px 16px;
margin-bottom: 6px; margin-bottom: 4px;
/* --row-bg is set inline per the exact selected option (see ROW_GRADIENTS /* --row-bg is set inline per the exact selected option (see ROW_GRADIENTS
in DayTabs.js) — falls back to a flat parchment tone if unset. */ in DayTabs.js) — falls back to a flat parchment tone if unset. */
background: var(--row-bg, #fdf8ee); background: var(--row-bg, #fdf8ee);
border: 1.5px solid #d4c0a0; border: 2px solid #d4c0a0;
border-radius: 6px; border-radius: 9px;
cursor: pointer; cursor: pointer;
transition: filter 0.15s, border-color 0.15s; box-shadow: inset 0 -1px 0 rgba(0,0,0,0.05), 0 1px 3px rgba(0,0,0,0.04);
transition: filter 0.2s, border-color 0.2s, box-shadow 0.2s;
} }
/* Remove alpha from the default fallback color and any potential inherited /* Remove alpha from the default fallback color and any potential inherited
semi-transparent backgrounds while keeping the hue. */ semi-transparent backgrounds while keeping the hue. */
@@ -494,13 +532,16 @@
} }
.active-profile-row:hover, .active-profile-row:hover,
.active-profile-row:focus-visible { .active-profile-row:focus-visible {
filter: brightness(1.06); filter: brightness(1.04);
border-color: #c8922a; border-color: #c8922a;
box-shadow: inset 0 -1px 0 rgba(0,0,0,0.05), 0 3px 8px rgba(200,146,42,0.18);
outline: none; outline: none;
} }
/* Breathing room between each "PROFILE" / "VIEWING" label and its value. */
.active-profile-row-item { .active-profile-row-item {
display: inline-flex; display: inline-flex;
align-items: center; align-items: baseline;
gap: 7px; gap: 7px;
} }
.active-profile-row-label { .active-profile-row-label {
@@ -521,12 +562,22 @@
} }
.active-profile-row-sep { color: #c0a880; font-size: 16px; } .active-profile-row-sep { color: #c0a880; font-size: 16px; }
.active-profile-row-edit { .active-profile-row-edit {
display: inline-flex;
align-items: center;
gap: 5px;
font-family: Manrope, sans-serif; font-family: Manrope, sans-serif;
font-size: 13px; font-size: 12.5px;
font-weight: 700; font-weight: 700;
letter-spacing: 0.04em; letter-spacing: 0.04em;
color: #9a6a1a; color: #9a6a1a;
margin-left: 4px; margin-left: 8px;
opacity: 0.7;
transition: opacity 0.2s, transform 0.2s, color 0.2s;
}
.active-profile-row:hover .active-profile-row-edit,
.active-profile-row:focus-visible .active-profile-row-edit {
opacity: 1;
color: #c8922a;
} }
/* Border + text accent per thermal-model group, reusing the same colour /* Border + text accent per thermal-model group, reusing the same colour
+30 -1
View File
@@ -55,6 +55,7 @@ export function ConfigPanel({
}) { }) {
const profileScrollRef = useRef(null); const profileScrollRef = useRef(null);
const profileWrapRef = useRef(null); const profileWrapRef = useRef(null);
const dropdownRef = useRef(null);
const { mainConfigKey, mainLabel } = deriveProfileMain(activeProfile, outdoorsVariant); const { mainConfigKey, mainLabel } = deriveProfileMain(activeProfile, outdoorsVariant);
@@ -77,6 +78,34 @@ export function ConfigPanel({
return () => document.removeEventListener('keydown', onKey); return () => document.removeEventListener('keydown', onKey);
}, [open, onClose]); }, [open, onClose]);
// From tablet up the panel drops *out of* the active-profile-row rather
// than sliding up from the floating button (which is mobile-only), so it
// has to be anchored to that row's live bottom edge — it's sticky on
// desktop and scrolls away on tablet, hence remeasuring on scroll/resize.
// Mobile (<=640px) ignores the var entirely and keeps the bottom sheet.
useEffect(() => {
const el = dropdownRef.current;
if (!el) return;
const measure = () => {
const row = document.querySelector('.active-profile-row');
const top = row ? Math.max(0, row.getBoundingClientRect().bottom) : 0;
el.style.setProperty('--profile-anchor-top', `${Math.round(top)}px`);
// Not enough room below the row for the panel's content? Fall back to
// the mobile bottom-sheet layout, which gets the full viewport height.
const body = el.querySelector('.profile-dropdown-body');
const content = body ? body.scrollHeight : 0;
el.classList.toggle('is-bottom-sheet', content > window.innerHeight - top - 16);
};
measure();
if (!open) return;
window.addEventListener('scroll', measure, true);
window.addEventListener('resize', measure);
return () => {
window.removeEventListener('scroll', measure, true);
window.removeEventListener('resize', measure);
};
}, [open, activeTab, activeProfile, outdoorsVariant]);
// Drag-to-scroll + fade edges for the horizontal profile card row. // Drag-to-scroll + fade edges for the horizontal profile card row.
useEffect(() => { useEffect(() => {
const el = profileScrollRef.current; const el = profileScrollRef.current;
@@ -226,7 +255,7 @@ export function ConfigPanel({
return html` return html`
<${Fragment}> <${Fragment}>
<div class=${`profile-dropdown-overlay${open ? ' is-open' : ''}`} onClick=${onClose}></div> <div class=${`profile-dropdown-overlay${open ? ' is-open' : ''}`} onClick=${onClose}></div>
<div class=${`profile-dropdown${open ? ' is-open' : ''}`} role="dialog" aria-modal="true" aria-label="Profile and configuration" aria-hidden=${!open}> <div ref=${dropdownRef} class=${`profile-dropdown${open ? ' is-open' : ''}`} role="dialog" aria-modal="true" aria-label="Profile and configuration" aria-hidden=${!open}>
<button class="profile-dropdown-close" aria-label="Close" onClick=${onClose}>×</button> <button class="profile-dropdown-close" aria-label="Close" onClick=${onClose}>×</button>
<div class="profile-dropdown-body"> <div class="profile-dropdown-body">
+7 -4
View File
@@ -35,7 +35,7 @@ import { h, Fragment } from '../../vendor/preact.js';
import htm from '../../vendor/htm.js'; import htm from '../../vendor/htm.js';
import { confidenceBand, utciCategory, VEHICLE_SPEEDS, VEHICLE_TYPES, BUILDING_TYPES, FUR_COLORS } from '../utils.js'; import { confidenceBand, utciCategory, VEHICLE_SPEEDS, VEHICLE_TYPES, BUILDING_TYPES, FUR_COLORS } from '../utils.js';
import { FREE_DAYS, UTCI_ENVIRONMENTS, deriveProfileMain, FILTER_PROFILES, variantIcons } from '../config.js'; import { FREE_DAYS, UTCI_ENVIRONMENTS, deriveProfileMain, FILTER_PROFILES, variantIcons } from '../config.js';
import { calcVehicleInteriorTemp } from '../physics.js'; import { calcVehicleInteriorTempPass } from '../physics.js';
import { CloudIcon, PrecipIcon, HouseIcon, CarIcon, FogIcon, IceIcon } from '../components.js'; import { CloudIcon, PrecipIcon, HouseIcon, CarIcon, FogIcon, IceIcon } from '../components.js';
import { SubscribeModal } from './SubscribeModal.js'; import { SubscribeModal } from './SubscribeModal.js';
@@ -222,9 +222,12 @@ export function DayTabs({
let shadeHi = null, shadeLo = null; let shadeHi = null, shadeLo = null;
if (isVehicleProfile) { if (isVehicleProfile) {
const speedMph = (VEHICLE_SPEEDS[vehicleSpeed] ?? VEHICLE_SPEEDS.static).mph; const speedMph = (VEHICLE_SPEEDS[vehicleSpeed] ?? VEHICLE_SPEEDS.static).mph;
const ventVals = d.rows // Run the lagged pass over the whole day, not per-hour equilibrium,
.map(r => calcVehicleInteriorTemp(r.Ta, r.glob, r.elev, vehicleType, true, speedMph)) // so this hi/lo is directly comparable with the vehicleT column.
.filter(v => isFinite(v)); const ventVals = calcVehicleInteriorTempPass(
d.rows.map(r => r.Ta), d.rows.map(r => r.glob), d.rows.map(r => r.elev),
d.rows.map(r => r.va), vehicleType, true, speedMph
).filter(v => v != null && isFinite(v));
shadeHi = ventVals.length ? Math.round(Math.max(...ventVals)) : null; shadeHi = ventVals.length ? Math.round(Math.max(...ventVals)) : null;
shadeLo = ventVals.length ? Math.round(Math.min(...ventVals)) : null; shadeLo = ventVals.length ? Math.round(Math.min(...ventVals)) : null;
} else if (secondaryField) { } else if (secondaryField) {
+1 -7
View File
@@ -23,13 +23,7 @@ import { Wordmark } from './Wordmark.js';
const html = htm.bind(h); const html = htm.bind(h);
const SUBSCRIBE_URL = 'https://buy.stripe.com/9B63cw7vl8k15Ei9DQd7q00'; const SUBSCRIBE_URL = 'https://buy.stripe.com/9B63cw7vl8k15Ei9DQd7q00';
// TODO: replace with the one-time Payment Link created in the Stripe const SUBSCRIBE_URL_ONEOFF = 'https://buy.stripe.com/14A14odTJ6bT0jY4jwd7q02';
// Dashboard (see plan Part 3) - a flat-rate product with a few selectable
// price options (e.g. £3 / £5 / £10), since Stripe Payment Links don't
// support true customer-chosen amounts. Configure its after-payment
// redirect to https://sunscope.net/?session_id={CHECKOUT_SESSION_ID}
// the same way the monthly link should be.
const SUBSCRIBE_URL_ONEOFF = 'https://buy.stripe.com/REPLACE_WITH_ONEOFF_PAYMENT_LINK';
const MANAGE_URL = 'https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00'; const MANAGE_URL = 'https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00';
export function SubscribeModal({ title, detail, onClose, openRestore }) { export function SubscribeModal({ title, detail, onClose, openRestore }) {
+14 -5
View File
@@ -13,7 +13,7 @@
import { import {
vaporPressureHpa, solarElevationDeg, calcTmrt, utciApprox, vaporPressureHpa, solarElevationDeg, calcTmrt, utciApprox,
calcConcreteTempPass, calcVehicleInteriorTemp, calcConcreteTempPass, calcVehicleInteriorTempPass,
calcIndoorTempPass, calcManagedIndoorTempPass, calcShadeAirTemp, calcIndoorTempPass, calcManagedIndoorTempPass, calcShadeAirTemp,
calcFurSurfaceTempPass, calcFurSurfaceTempPass,
} from './physics.js'; } from './physics.js';
@@ -88,7 +88,7 @@ export function buildHourlyRows({ forecast, airQuality, location, vehicleType, v
// (building shadow, beach umbrella, canopy...). Shares SunSoak's TaEnv // (building shadow, beach umbrella, canopy...). Shares SunSoak's TaEnv
// baseline and env-reduced radiation, so the two columns stay consistent. // baseline and env-reduced radiation, so the two columns stay consistent.
const shadeT = calcShadeAirTemp(TaEnv, dirEnv + difEnv * 0.2, va, elev, env.shade); const shadeT = calcShadeAirTemp(TaEnv, dirEnv + difEnv * 0.2, va, elev, env.shade);
const vehicleT = calcVehicleInteriorTemp(Ta, glob, elev, vehicleType, vehicleVent, vehicleSpeed); // vehicleT is stamped in the two-pass section below (thermal lag).
const eh = vaporPressureHpa(Ta, RH); const eh = vaporPressureHpa(Ta, RH);
const Tmrt = calcTmrt(TaEnv, dirEnv, difEnv, globEnv, elev); const Tmrt = calcTmrt(TaEnv, dirEnv, difEnv, globEnv, elev);
const utci = utciApprox(TaEnv, Tmrt, va, eh); const utci = utciApprox(TaEnv, Tmrt, va, eh);
@@ -153,7 +153,7 @@ export function buildHourlyRows({ forecast, airQuality, location, vehicleType, v
cc, ccLow, ccMid, ccHigh, cloudCat, cc, ccLow, ccMid, ccHigh, cloudCat,
uv, uvA, uvB, uv, uvA, uvB,
precip, precipProb, lightning, cape, snow, precip, precipProb, lightning, cape, snow,
soilT0, soilT6, soilM, vehicleT, shadeT, soilT0, soilT6, soilM, shadeT,
effectiveRad, effectiveRad,
elev, Tmrt, utci, utciAdj, eh, compass, elev, Tmrt, utci, utciAdj, eh, compass,
visKm, aqi, visKm, aqi,
@@ -161,8 +161,8 @@ export function buildHourlyRows({ forecast, airQuality, location, vehicleType, v
}; };
}) : []; }) : [];
// Two-pass calculations: concrete thermal lag + indoor temperature. // Two-pass calculations: concrete thermal lag + indoor + vehicle cabin.
// Both need the full hourly arrays so they can look back at previous // All need the full hourly arrays so they can look back at previous
// hours. Run after hourlyRows is built, then stamp each row. // hours. Run after hourlyRows is built, then stamp each row.
if (hourlyRows.length > 0) { if (hourlyRows.length > 0) {
const TaArr = hourlyRows.map(r => r.Ta); const TaArr = hourlyRows.map(r => r.Ta);
@@ -188,6 +188,15 @@ export function buildHourlyRows({ forecast, airQuality, location, vehicleType, v
const managedTemps = calcManagedIndoorTempPass(TaArr, globArr, elevArr, buildingType); const managedTemps = calcManagedIndoorTempPass(TaArr, globArr, elevArr, buildingType);
hourlyRows.forEach((r, i) => { r.indoorT = indoorTemps[i]; r.managedT = managedTemps[i]; }); hourlyRows.forEach((r, i) => { r.indoorT = indoorTemps[i]; r.managedT = managedTemps[i]; });
// Vehicle cabin temperature with thermal lag. A parked vehicle climbs
// toward the hour's equilibrium rather than jumping to it, so a van that
// has been in the sun since morning reads hotter than the same van an
// hour after parking. vaArr feeds ambient wind into shell convection.
const vehicleTemps = calcVehicleInteriorTempPass(
TaArr, globArr, elevArr, vaArr, vehicleType, vehicleVent, vehicleSpeed
);
hourlyRows.forEach((r, i) => { r.vehicleT = vehicleTemps[i]; });
// Fur surface temperature with thermal lag - Pets profile. // Fur surface temperature with thermal lag - Pets profile.
const furAlbedo = (FUR_COLORS[furColor] || FUR_COLORS.brown).albedo; const furAlbedo = (FUR_COLORS[furColor] || FUR_COLORS.brown).albedo;
const furTemps = calcFurSurfaceTempPass(TaArr, radArr, vaArr, elevArr, furAlbedo); const furTemps = calcFurSurfaceTempPass(TaArr, radArr, vaArr, elevArr, furAlbedo);
+1 -1
View File
@@ -256,7 +256,7 @@ export const COL_DESCRIPTIONS = {
soilT6: { title: 'Soil Temp 6 cm', desc: 'Temperature of the soil at 6 cm depth, the root zone for many crops and seedlings. Lags behind surface temperature by several hours.' }, soilT6: { title: 'Soil Temp 6 cm', desc: 'Temperature of the soil at 6 cm depth, the root zone for many crops and seedlings. Lags behind surface temperature by several hours.' },
soilM: { title: 'Soil Moisture', desc: 'Water saturation of the top 1 cm of soil as a percentage. Above 40% suggests saturated ground; below 20% indicates dry conditions.' }, soilM: { title: 'Soil Moisture', desc: 'Water saturation of the top 1 cm of soil as a percentage. Above 40% suggests saturated ground; below 20% indicates dry conditions.' },
concreteT: { title: 'Concrete Surface', desc: 'Estimated temperature of sun-exposed urban concrete or paving. Concrete absorbs more solar energy than grass and cannot cool itself through evaporation — surface temps can run 1525 °C above air temperature on sunny days.' }, concreteT: { title: 'Concrete Surface', desc: 'Estimated temperature of sun-exposed urban concrete or paving. Concrete absorbs more solar energy than grass and cannot cool itself through evaporation — surface temps can run 1525 °C above air temperature on sunny days.' },
vehicleT: { title: 'Vehicle Interior', desc: 'Estimated ambient cabin temperature inside a parked vehicle. Cars heat rapidly through thin body panels and glass; motorhomes and caravans are modelled as insulated occupied living spaces with retained warmth, lower glass gain, and slower heat response. Dangerous for children and pets above 35 °C; potentially fatal above 45 °C.' }, vehicleT: { title: 'Vehicle Interior', desc: 'Estimated ambient cabin temperature inside a parked vehicle. Cars heat rapidly through thin body panels and glass; motorhomes and caravans are modelled as insulated occupied living spaces that warm more slowly but hold heat longer, with gain through the windscreen and rooflights and a little retained living-space warmth. Accounts for ambient wind over the bodywork, road speed, and whether the windows are open. Dangerous for children and pets above 35 °C; potentially fatal above 45 °C.' },
indoorT: { title: 'Indoors', desc: 'Estimated ambient temperature inside a selected building type with windows closed and no air conditioning. Accounts for retained warmth, window solar gain, internal gains, and thermal lag without repeatedly accumulating solar heat hour after hour.' }, indoorT: { title: 'Indoors', desc: 'Estimated ambient temperature inside a selected building type with windows closed and no air conditioning. Accounts for retained warmth, window solar gain, internal gains, and thermal lag without repeatedly accumulating solar heat hour after hour.' },
managedT: { title: 'Managed Indoors', desc: 'Estimated indoor temperature with curtains closed and windows opened when outdoor air is cooler than inside — the standard UK heatwave advice. Curtains block most direct solar gain; smart ventilation pulls the temperature down during cooler periods.' }, managedT: { title: 'Managed Indoors', desc: 'Estimated indoor temperature with curtains closed and windows opened when outdoor air is cooler than inside — the standard UK heatwave advice. Curtains block most direct solar gain; smart ventilation pulls the temperature down during cooler periods.' },
vis: { title: 'Visibility', desc: 'Horizontal visibility in kilometres, sourced from the CAMS air quality model. Values below 1 km indicate fog or very thick haze; below 10 km suggests mist, smoke, or significant pollution. Relevant for driving, flying, and photography.' }, vis: { title: 'Visibility', desc: 'Horizontal visibility in kilometres, sourced from the CAMS air quality model. Values below 1 km indicate fog or very thick haze; below 10 km suggests mist, smoke, or significant pollution. Relevant for driving, flying, and photography.' },
+121 -45
View File
@@ -11,7 +11,8 @@
// calcConcreteTempPass(arrays...) thermal-lag pass over full hourly arrays // calcConcreteTempPass(arrays...) thermal-lag pass over full hourly arrays
// calcIndoorTempPass(TaArr, globArr, elevArr, buildingType) passive indoor temp // calcIndoorTempPass(TaArr, globArr, elevArr, buildingType) passive indoor temp
// calcManagedIndoorTempPass(TaArr, globArr, elevArr, buildingType) managed indoor temp // calcManagedIndoorTempPass(TaArr, globArr, elevArr, buildingType) managed indoor temp
// calcVehicleInteriorTemp(Ta, globalRad, solElev, vehicleType, ventilated, speedMph) // calcVehicleInteriorTemp(Ta, globalRad, solElev, vehicleType, ventilated, speedMph, windMps) equilibrium cabin temp
// calcVehicleInteriorTempPass(TaArr, globArr, elevArr, vaArr, vehicleType, ventilated, speedMph) lagged cabin temp
// calcShadeAirTemp(TaEnv, effRadEnv, va, elev, shade) per-env shade air temp // calcShadeAirTemp(TaEnv, effRadEnv, va, elev, shade) per-env shade air temp
// vaporPressureHpa(Ta, RH) Magnus formula - hPa // vaporPressureHpa(Ta, RH) Magnus formula - hPa
// solarElevationDeg(lat, lon, dateUTC) NOAA simplified solar position (-) // solarElevationDeg(lat, lon, dateUTC) NOAA simplified solar position (-)
@@ -375,92 +376,115 @@ export function calcManagedIndoorTempPass(TaArr, globArr, elevArr, buildingType
// fleet; dark paint ~0.10, silver/white ~0.40). // fleet; dark paint ~0.10, silver/white ~0.40).
// Panel surface temp - conductive gain into cabin air. // Panel surface temp - conductive gain into cabin air.
// //
// 2. SIDE-WINDOW GLAZING GAIN (sun-angle dependent) // 2. VERTICAL GLAZING GAIN (sun-angle dependent)
// When solar elevation is between ~10- and ~60-, the sun's rays // When solar elevation is between ~10- and ~60-, the sun's rays
// cut through the side glass at an angle that allows significant // cut through the side glass and windscreen at an angle that allows
// transmission into the cabin (rather than hitting the roof or // significant transmission into the cabin. This warms the cabin air
// reflecting off at a shallow angle). This warms the cabin air
// but the occupant is modelled as NOT sitting in the beam - // but the occupant is modelled as NOT sitting in the beam -
// so it adds to ambient cabin temp, not direct radiant load. // so it adds to ambient cabin temp, not direct radiant load.
// Above 60- the sun mostly hits the roof; below 10- it reflects. // Outside that window a diffuse floor still applies: scattered sky
// light enters the glass at any sun angle, so the tent function is
// clamped rather than switched off (an unclamped tent produced a
// discontinuity where a LOWER sun gave MORE glazing gain).
// //
// Wind is ignored unless ventilation is enabled. No evaporative cooling. // 3. HORIZONTAL GLAZING GAIN (rooflights / panoramic roofs)
// Rooflights collect most strongly when the sun is high - the exact
// condition under which the vertical-glass term is tailing off. Scales
// with sin(elevation) and uses a lower transmission (0.40) because
// rooflights are typically smoked/tinted acrylic rather than clear glass.
//
// Ambient wind scrubs the bodywork whether or not the windows are open, so
// it feeds the shell convection coefficient in every case; road speed does
// the same job and the larger of the two wins. No evaporative cooling.
// Cars warm quickly; motorhomes and caravans are treated as insulated living // Cars warm quickly; motorhomes and caravans are treated as insulated living
// spaces with 25-35 mm sandwich panels, so panel heat gain is much smaller // spaces with 25-35 mm sandwich panels, so panel heat gain is much smaller.
// and the interior response is slower than a car cabin. Because they are // Their interiors are SLOWER, not COOLER - the insulation that keeps heat out
// occupied living spaces, they also retain warmth from previous hours, people, // also keeps it in, so a closed-up van ends up about as hot as a closed-up car
// appliances, and background heating; without that, cool sunny days are // once it has had a few hours to get there. Because they are occupied living
// under-estimated badly. // spaces, they also retain a little warmth from previous hours, people,
// appliances, and background heating.
// //
// Colour thresholds in the table: // Colour thresholds in the table:
// < 35 -C - warm but tolerable for short periods // < 35 -C - warm but tolerable for short periods
// 35-45 -C - dangerous for children/pets (hyperthermia risk) // 35-45 -C - dangerous for children/pets (hyperthermia risk)
// > 45 -C - potentially fatal within minutes // > 45 -C - potentially fatal within minutes
// ------------------------------------------------------------------- // -------------------------------------------------------------------
export function calcVehicleInteriorTemp(Ta, globalRad, solElev, vehicleType = 'car', ventilated = false, speedMph = 0) { export function calcVehicleInteriorTemp(Ta, globalRad, solElev, vehicleType = 'car', ventilated = false, speedMph = 0, windMps = 0) {
if (globalRad == null || Ta == null) return null; if (globalRad == null || Ta == null) return null;
// Look up vehicle preset; fall back to a standard car if key unknown. // Look up vehicle preset; fall back to a standard car if key unknown.
const preset = VEHICLE_TYPES[vehicleType] || VEHICLE_TYPES.car; const preset = VEHICLE_TYPES[vehicleType] || VEHICLE_TYPES.car;
// Road speed in m/s. speedMph = 0 is "Static" (parked) and reproduces the // Road speed in m/s. speedMph = 0 is "Static" (parked); higher speeds scrub
// original stationary model exactly; higher speeds scrub the shell with // the shell with forced airflow and (windows down) flush the cabin toward
// forced airflow and (windows down) flush the cabin toward ambient. // ambient.
const vMs = Math.max(0, speedMph) * 0.447; // mph -> m/s const vMs = Math.max(0, speedMph) * 0.447; // mph -> m/s
// -- 1. Panel conduction ------------------------------------------ // -- 1. Panel conduction ------------------------------------------
const panelAbsorbed = globalRad * (1 - preset.albedo); // W/m- absorbed by bodywork const panelAbsorbed = globalRad * (1 - preset.albedo); // W/m- absorbed by bodywork
// Panel surface temp: absorbed solar / convective loss to outside air. // Panel surface temp: absorbed solar / convective loss to outside air.
// Parked, a light breeze over the panels gives hOut ~10 W/m-K. Once moving, // Convection over the shell is driven by whichever airflow is stronger -
// forced convection rises with road speed (same sqrt form as the surface // the ambient wind or the car's own road speed. A parked vehicle in a
// model), so the bodywork runs progressively closer to ambient. // 15 mph wind has bodywork far closer to ambient than one in still air,
const hOut = 10 + 5.5 * Math.sqrt(vMs); // so ambient wind must not be ignored just because the windows are shut.
// The 1.5 m/s floor is not fudge: hOut is steep near calm, and a reported
// wind of 0 m/s does not mean still air at the bodywork - thermal plumes off
// hot panels and ordinary gustiness keep it moving. Without the floor a dead
// calm hour sent a sealed car to ~67 -C. It only bites below ~3 mph, so it
// leaves the calibration anchors untouched.
const vShell = Math.max(1.5, vMs, Math.max(0, windMps || 0));
const hOut = 10 + 5.5 * Math.sqrt(vShell);
const panelSurfaceTemp = Ta + panelAbsorbed / hOut; const panelSurfaceTemp = Ta + panelAbsorbed / hOut;
// Conductive gain into cabin. Cars are thin metal + trim; motorhomes and // Conductive gain into cabin. Cars are thin metal + trim; motorhomes and
// caravans use insulated sandwich panels, so their bodyU is much lower. // caravans use insulated sandwich panels, so their bodyU is much lower.
const hCabin = preset.bodyU ?? 4; const hCabin = preset.bodyU ?? 4;
const conductionGain = hCabin * (panelSurfaceTemp - Ta); // W/m- const conductionGain = hCabin * (panelSurfaceTemp - Ta); // W/m-
// -- 2. Side-window glazing gain (angle-dependent) ----------------- // -- 2. Vertical glazing gain (angle-dependent) --------------------
// Glazing transmission for auto glass ~0.70; scaled by vehicle glazing area. // Glazing transmission for auto glass ~0.70; scaled by vehicle glazing area.
// Tent function peaks at 35- elevation, where the sun cuts squarely through
// side glass and windscreen, and tapers either side. It is clamped at 0.30
// (= the 0.15 diffuse floor once the 0.5 not-in-beam factor is applied) so
// scattered sky light always gets in. Without that clamp the gain fell to
// zero at 10- and 60- and then jumped back up outside the range.
const tau = 0.70 * preset.glazingArea; const tau = 0.70 * preset.glazingArea;
let glazingGain = 0; const tent = (solElev != null && solElev > 0)
if (solElev != null && solElev > 10 && solElev < 60) { ? Math.max(0, 1 - Math.abs(solElev - 35) / 25)
// Scale factor: peaks around 30-40- elevation (sun cuts squarely : 0;
// through side glass), tapers off toward 10- (shallow/reflected) const glazingGain = tau * globalRad * Math.max(0.30, tent) * 0.5; // occupant not in beam
// and 60- (sun increasingly hitting roof not side glass).
// Use a simple tent function peaking at 35-. // -- 3. Horizontal glazing gain (rooflights, panoramic roof) -------
const peak = 35; // Rooflights collect in proportion to sin(elevation), so they peak at midday
const halfWidth = 25; // degrees either side // just as the vertical-glass term is falling away. Smoked acrylic transmits
const factor = Math.max(0, 1 - Math.abs(solElev - peak) / halfWidth); // roughly 0.40 rather than the 0.70 of clear auto glass.
// Diffuse radiation also enters through glass regardless of angle const roofGain = (solElev != null && solElev > 0)
glazingGain = tau * globalRad * factor * 0.5; // occupant not in beam - 50% ambient ? 0.40 * (preset.roofGlazing ?? 0) * globalRad * Math.sin(solElev * Math.PI / 180)
} else { : 0;
// Outside the side-window zone: diffuse only (scattered sky light)
glazingGain = tau * (globalRad * 0.15); // ~15% diffuse fraction
}
// -- Combine into cabin air temperature --------------------------- // -- Combine into cabin air temperature ---------------------------
// Total heat input per m- of cabin surface // Total heat input per m- of cabin surface
const totalGain = conductionGain + glazingGain; const totalGain = conductionGain + glazingGain + roofGain;
// Cabin heat rejection: an effective blend of leakage, internal air volume, // Cabin heat rejection: an effective blend of leakage, internal air volume,
// and surfaces exchanging heat with the outside. With windows open it is // and surfaces exchanging heat with the outside. Opening the windows when
// roughly 5x higher when parked - air moves freely through the cabin, // parked multiplies it by the preset's ventMult - a car with every window
// flushing heat out and capping interior temperature much closer to ambient. // down flushes far harder per unit volume than a van with two windows and a
// rooflight open, so that multiplier is per-vehicle, not a shared constant.
// On the move the through-draught multiplies this further (a 70 mph open // On the move the through-draught multiplies this further (a 70 mph open
// window flushes the cabin almost to ambient). Sealed but moving rejects a // window flushes the cabin almost to ambient). Sealed but moving rejects a
// little faster too, because the cooler shell pulls cabin heat out. // little faster too, because the cooler shell pulls cabin heat out.
const speedFactor = 1 + vMs / 12; // grows with road speed (windows-open draught) const speedFactor = 1 + vMs / 12; // grows with road speed (windows-open draught)
const lossMult = ventilated ? 5 * speedFactor : 1 + vMs / 40; const lossMult = ventilated ? (preset.ventMult ?? 4) * speedFactor : 1 + vMs / 40;
const effectiveHLoss = preset.hCabinLoss * lossMult; const effectiveHLoss = preset.hCabinLoss * lossMult;
const thermalMass = preset.thermalMass ?? 1; const solarRise = totalGain / effectiveHLoss;
const solarRise = (totalGain / effectiveHLoss) * thermalMass;
// Motorhomes/caravans behave more like small insulated rooms than parked // Motorhomes/caravans behave more like small insulated rooms than parked
// cars. This term captures retained living-space warmth: strongest on cool // cars. This term captures retained living-space warmth: strongest on cool
// days, tapering away as outdoor air warms, and reduced when ventilated. // days, tapering away as outdoor air warms, and reduced when ventilated.
// It used to be much larger (8 - 0.25*Ta) because it was silently standing
// in for solar gain the model was throwing away; now that the glazing terms
// are right it only has to cover occupancy and residual warmth.
const retainedWarmth = preset.retainedWarmth const retainedWarmth = preset.retainedWarmth
? Math.max(0, 8 - 0.25 * Ta) * (ventilated ? 0.35 : 1) ? Math.max(0, 5 - 0.20 * Ta) * (ventilated ? 0.35 : 1)
: 0; : 0;
const internalGain = (preset.internalGain ?? 0) * (ventilated ? 0.35 : 1); const internalGain = (preset.internalGain ?? 0) * (ventilated ? 0.35 : 1);
@@ -470,6 +494,57 @@ export function calcVehicleInteriorTemp(Ta, globalRad, solElev, vehicleType = 'c
return Math.max(Ta, Math.min(Ti, 90)); return Math.max(Ta, Math.min(Ti, 90));
} }
// -------------------------------------------------------------------
// VEHICLE INTERIOR TEMPERATURE - TWO-PASS (thermal lag)
// -------------------------------------------------------------------
// calcVehicleInteriorTemp above returns the EQUILIBRIUM cabin temperature
// for one hour's conditions - where the interior would settle if those
// conditions held. Real cabins take time to get there, so this pass relaxes
// toward that target with a per-vehicle time constant, exactly as
// calcConcreteTempPass and calcIndoorTempPass do.
//
// This replaces an earlier 'thermalMass' multiplier that scaled the
// equilibrium rise down (motorhomes ran at 0.35x). That conflated two
// different things: thermal mass delays how fast you reach equilibrium, it
// does not lower the equilibrium itself. A van parked in the sun since
// breakfast is close to equilibrium by mid-afternoon, so the multiplier
// under-predicted every long parked spell - badly enough that a ventilated
// motorhome came out barely a degree above ambient at peak sun.
//
// Cars use a short constant (~0.5 h - a car is hot within the half hour);
// motorhomes and caravans a longer one (~1.5-1.7 h) reflecting their larger
// air volume and heavier interior fit-out.
//
// vaArr is ambient wind in m/s (Open-Meteo wind_speed_10m with
// wind_speed_unit=ms) and may be null/omitted, in which case still air is
// assumed. speedMph is the selected road-speed class, not per-hour data.
// -------------------------------------------------------------------
export function calcVehicleInteriorTempPass(TaArr, globArr, elevArr, vaArr, vehicleType = 'car', ventilated = false, speedMph = 0) {
const preset = VEHICLE_TYPES[vehicleType] || VEHICLE_TYPES.car;
const n = TaArr.length;
const result = new Array(n);
const alpha = 1 - Math.exp(-1 / (preset.lagHours ?? 0.5));
// Seed at the first hour's air temp - a vehicle left overnight has
// equalised with the outside air.
let Ti = TaArr[0] ?? 15;
for (let i = 0; i < n; i++) {
const Ta = TaArr[i];
if (Ta == null) { result[i] = null; continue; }
const target = calcVehicleInteriorTemp(
Ta, globArr[i] ?? 0, elevArr[i], vehicleType, ventilated, speedMph,
vaArr ? (vaArr[i] ?? 0) : 0
);
if (target == null) { result[i] = null; continue; }
Ti = Ti + alpha * (target - Ti);
// Can't be cooler than outside air; physical cap at 90 -C.
result[i] = Math.max(Ta, Math.min(Ti, 90));
}
return result;
}
// ------------------------------------------------------------------- // -------------------------------------------------------------------
// SHADE AIR TEMPERATURE (per-environment microclimate) // SHADE AIR TEMPERATURE (per-environment microclimate)
// ------------------------------------------------------------------- // -------------------------------------------------------------------
@@ -576,7 +651,8 @@ export function calcFurSurfaceTempPass(TaArr, radArr, vaArr, elevArr, furAlbedo
// calcVehicleInteriorTemp now takes a speedMph argument (Static / 20 / 50 / 70 // calcVehicleInteriorTemp now takes a speedMph argument (Static / 20 / 50 / 70
// mph in the UI). Forced convection over the shell scales with road speed, and // mph in the UI). Forced convection over the shell scales with road speed, and
// windows-open through-flow scales further with speed, so a moving cabin runs // windows-open through-flow scales further with speed, so a moving cabin runs
// cooler than the same parked car. speedMph = 0 reproduces the static model. // cooler than the same parked car. speedMph = 0 is the parked case - which is
// no longer "no airflow", since ambient wind now drives shell convection too.
// //
// -- CYCLIST AT SPEED (FUTURE) ------------------------------------------------ // -- CYCLIST AT SPEED (FUTURE) ------------------------------------------------
// A cyclist generates their own headwind, so the felt temperature (UTCI) is // A cyclist generates their own headwind, so the felt temperature (UTCI) is
+22 -8
View File
@@ -209,23 +209,37 @@ export function burnLabel(mins) {
// ------------------------------------------------------------------- // -------------------------------------------------------------------
// Each entry tweaks the physical levers in calcVehicleInteriorTemp: // Each entry tweaks the physical levers in calcVehicleInteriorTemp:
// albedo - how much solar the bodywork reflects (0 = black, 1 = mirror) // albedo - how much solar the bodywork reflects (0 = black, 1 = mirror)
// glazingArea - relative sun-exposed glass area (1.0 = typical car) // glazingArea - relative VERTICAL glass area (1.0 = typical car). A coachbuilt
// motorhome has a huge near-vertical windscreen plus cab side
// windows, so it is far from the 0.25 once assumed here.
// roofGlazing - relative HORIZONTAL glass area (rooflights / Heki hatches /
// panoramic glass roof). Gains from these peak at high sun,
// which is exactly when the vertical-glass term is tailing off.
// bodyU - effective body/panel conductance into the cabin. Cars are // bodyU - effective body/panel conductance into the cabin. Cars are
// thin metal/glass boxes; motorhomes/caravans have insulated // thin metal/glass boxes; motorhomes/caravans have insulated
// sandwich panels, commonly around 25-35 mm thick. // sandwich panels, commonly around 25-35 mm thick.
// hCabinLoss - effective heat rejection/infiltration from the cabin air. // hCabinLoss - effective heat rejection/infiltration from the cabin air.
// thermalMass - lower values mean the interior warms more slowly in the hour. // Motorhomes/caravans reject heat more SLOWLY than cars (better
// sealed, smaller aperture per unit volume), which is why a
// closed-up van gets as hot as a car despite its insulation.
// ventMult - how much opening the windows multiplies hCabinLoss when
// parked. A car with all windows down flushes far more
// effectively per unit volume than a van with two windows and
// a rooflight open, so this is not a shared constant.
// lagHours - interior thermal time constant. This is a DELAY on reaching
// the hour's equilibrium, not a reduction of it - see the note
// in calcVehicleInteriorTempPass.
// retainedWarmth - occupied insulated living spaces hold heat from previous // retainedWarmth - occupied insulated living spaces hold heat from previous
// hours, people, appliances, and background heating. // hours, people, appliances, and background heating.
// internalGain - small living-space warmth boost when closed up. // internalGain - small living-space warmth boost when closed up.
// ------------------------------------------------------------------- // -------------------------------------------------------------------
export const VEHICLE_TYPES = { export const VEHICLE_TYPES = {
car: { name: 'Car / Hatchback', albedo: 0.25, glazingArea: 1.0, bodyU: 4.0, hCabinLoss: 20, thermalMass: 1.00, retainedWarmth: false, internalGain: 0.0 }, car: { name: 'Car / Hatchback', albedo: 0.25, glazingArea: 1.00, roofGlazing: 0.00, bodyU: 4.0, hCabinLoss: 11.0, ventMult: 4.0, lagHours: 0.5, retainedWarmth: false, internalGain: 0.0 },
mpv: { name: 'MPV / People Carrier', albedo: 0.25, glazingArea: 1.3, bodyU: 4.0, hCabinLoss: 20, thermalMass: 1.00, retainedWarmth: false, internalGain: 0.0 }, mpv: { name: 'MPV / People Carrier', albedo: 0.25, glazingArea: 1.30, roofGlazing: 0.02, bodyU: 4.0, hCabinLoss: 11.5, ventMult: 4.0, lagHours: 0.6, retainedWarmth: false, internalGain: 0.0 },
suv: { name: 'SUV / 4x4', albedo: 0.22, glazingArea: 1.1, bodyU: 3.8, hCabinLoss: 19, thermalMass: 0.95, retainedWarmth: false, internalGain: 0.0 }, suv: { name: 'SUV / 4x4', albedo: 0.22, glazingArea: 1.10, roofGlazing: 0.02, bodyU: 3.8, hCabinLoss: 11.0, ventMult: 4.0, lagHours: 0.6, retainedWarmth: false, internalGain: 0.0 },
truck: { name: 'Truck / HGV Cab', albedo: 0.30, glazingArea: 1.2, bodyU: 3.5, hCabinLoss: 18, thermalMass: 0.90, retainedWarmth: false, internalGain: 0.0 }, truck: { name: 'Truck / HGV Cab', albedo: 0.30, glazingArea: 1.20, roofGlazing: 0.00, bodyU: 3.5, hCabinLoss: 10.5, ventMult: 4.0, lagHours: 0.7, retainedWarmth: false, internalGain: 0.0 },
motorhome: { name: 'Motorhome / Campervan', albedo: 0.55, glazingArea: 0.25, bodyU: 0.9, hCabinLoss: 12, thermalMass: 0.35, retainedWarmth: true, internalGain: 1.2 }, motorhome: { name: 'Motorhome / Campervan', albedo: 0.55, glazingArea: 0.85, roofGlazing: 0.06, bodyU: 0.9, hCabinLoss: 6.5, ventMult: 2.5, lagHours: 1.5, retainedWarmth: true, internalGain: 1.2 },
caravan: { name: 'Caravan (towed)', albedo: 0.60, glazingArea: 0.22, bodyU: 0.8, hCabinLoss: 11, thermalMass: 0.35, retainedWarmth: true, internalGain: 1.2 }, caravan: { name: 'Caravan (towed)', albedo: 0.60, glazingArea: 0.55, roofGlazing: 0.07, bodyU: 0.8, hCabinLoss: 6.2, ventMult: 2.4, lagHours: 1.7, retainedWarmth: true, internalGain: 1.2 },
}; };
// ------------------------------------------------------------------- // -------------------------------------------------------------------
+2 -1
View File
@@ -232,7 +232,8 @@ async function build() {
// 8. Copy static files // 8. Copy static files
console.log(' Copying static files...'); console.log(' Copying static files...');
for (const f of ['robots.txt', 'sitemap.xml', 'og-image.png', 'favicon.svg', 'restore.php', 'track.php', 'stats.php']) { for (const f of ['robots.txt', 'sitemap.xml', 'og-image.png', 'favicon.svg', 'restore.php', 'track.php', 'stats.php',
'verify-session.php', 'check-subscription.php', 'dev-unlock.php', 'secrets.local.php']) {
const src = path.join(ROOT, f); const src = path.join(ROOT, f);
if (fs.existsSync(src)) { if (fs.existsSync(src)) {
copyFile(src, path.join(DIST, f)); copyFile(src, path.join(DIST, f));
+13
View File
@@ -158,5 +158,18 @@
"alltemps": 2, "alltemps": 2,
"showall": 2 "showall": 2
} }
},
"2026-07-25": {
"visits": 7,
"profiles": {
"home": 10,
"vehicle": 7,
"pets": 5,
"basic": 5,
"alltemps": 3,
"showall": 1,
"custom": 1,
"farming": 1
}
} }
} }