3.5.5
Update desktop profile hover button Recalibrated vehicle ventilation calcs
This commit is contained in:
+60
-9
@@ -455,6 +455,43 @@
|
||||
}
|
||||
.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
|
||||
active profile and the thermal basis (SunSoak env / vehicle / building /
|
||||
fur colour) driving the numbers below. Clicking/Enter opens the same
|
||||
@@ -465,15 +502,16 @@
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
padding: 2px 16px;
|
||||
margin-bottom: 6px;
|
||||
padding: 8px 16px;
|
||||
margin-bottom: 4px;
|
||||
/* --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. */
|
||||
background: var(--row-bg, #fdf8ee);
|
||||
border: 1.5px solid #d4c0a0;
|
||||
border-radius: 6px;
|
||||
border: 2px solid #d4c0a0;
|
||||
border-radius: 9px;
|
||||
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
|
||||
semi-transparent backgrounds while keeping the hue. */
|
||||
@@ -494,13 +532,16 @@
|
||||
}
|
||||
.active-profile-row:hover,
|
||||
.active-profile-row:focus-visible {
|
||||
filter: brightness(1.06);
|
||||
filter: brightness(1.04);
|
||||
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;
|
||||
}
|
||||
|
||||
/* Breathing room between each "PROFILE" / "VIEWING" label and its value. */
|
||||
.active-profile-row-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
align-items: baseline;
|
||||
gap: 7px;
|
||||
}
|
||||
.active-profile-row-label {
|
||||
@@ -521,12 +562,22 @@
|
||||
}
|
||||
.active-profile-row-sep { color: #c0a880; font-size: 16px; }
|
||||
.active-profile-row-edit {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-family: Manrope, sans-serif;
|
||||
font-size: 13px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
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
|
||||
|
||||
@@ -55,6 +55,7 @@ export function ConfigPanel({
|
||||
}) {
|
||||
const profileScrollRef = useRef(null);
|
||||
const profileWrapRef = useRef(null);
|
||||
const dropdownRef = useRef(null);
|
||||
|
||||
const { mainConfigKey, mainLabel } = deriveProfileMain(activeProfile, outdoorsVariant);
|
||||
|
||||
@@ -77,6 +78,34 @@ export function ConfigPanel({
|
||||
return () => document.removeEventListener('keydown', onKey);
|
||||
}, [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.
|
||||
useEffect(() => {
|
||||
const el = profileScrollRef.current;
|
||||
@@ -226,7 +255,7 @@ export function ConfigPanel({
|
||||
return html`
|
||||
<${Fragment}>
|
||||
<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>
|
||||
<div class="profile-dropdown-body">
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ import { h, Fragment } from '../../vendor/preact.js';
|
||||
import htm from '../../vendor/htm.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 { calcVehicleInteriorTemp } from '../physics.js';
|
||||
import { calcVehicleInteriorTempPass } from '../physics.js';
|
||||
import { CloudIcon, PrecipIcon, HouseIcon, CarIcon, FogIcon, IceIcon } from '../components.js';
|
||||
import { SubscribeModal } from './SubscribeModal.js';
|
||||
|
||||
@@ -222,9 +222,12 @@ export function DayTabs({
|
||||
let shadeHi = null, shadeLo = null;
|
||||
if (isVehicleProfile) {
|
||||
const speedMph = (VEHICLE_SPEEDS[vehicleSpeed] ?? VEHICLE_SPEEDS.static).mph;
|
||||
const ventVals = d.rows
|
||||
.map(r => calcVehicleInteriorTemp(r.Ta, r.glob, r.elev, vehicleType, true, speedMph))
|
||||
.filter(v => isFinite(v));
|
||||
// Run the lagged pass over the whole day, not per-hour equilibrium,
|
||||
// so this hi/lo is directly comparable with the vehicleT column.
|
||||
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;
|
||||
shadeLo = ventVals.length ? Math.round(Math.min(...ventVals)) : null;
|
||||
} else if (secondaryField) {
|
||||
|
||||
@@ -23,13 +23,7 @@ import { Wordmark } from './Wordmark.js';
|
||||
const html = htm.bind(h);
|
||||
|
||||
const SUBSCRIBE_URL = 'https://buy.stripe.com/9B63cw7vl8k15Ei9DQd7q00';
|
||||
// TODO: replace with the one-time Payment Link created in the Stripe
|
||||
// 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 SUBSCRIBE_URL_ONEOFF = 'https://buy.stripe.com/14A14odTJ6bT0jY4jwd7q02';
|
||||
const MANAGE_URL = 'https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00';
|
||||
|
||||
export function SubscribeModal({ title, detail, onClose, openRestore }) {
|
||||
|
||||
+14
-5
@@ -13,7 +13,7 @@
|
||||
|
||||
import {
|
||||
vaporPressureHpa, solarElevationDeg, calcTmrt, utciApprox,
|
||||
calcConcreteTempPass, calcVehicleInteriorTemp,
|
||||
calcConcreteTempPass, calcVehicleInteriorTempPass,
|
||||
calcIndoorTempPass, calcManagedIndoorTempPass, calcShadeAirTemp,
|
||||
calcFurSurfaceTempPass,
|
||||
} from './physics.js';
|
||||
@@ -88,7 +88,7 @@ export function buildHourlyRows({ forecast, airQuality, location, vehicleType, v
|
||||
// (building shadow, beach umbrella, canopy...). Shares SunSoak's TaEnv
|
||||
// baseline and env-reduced radiation, so the two columns stay consistent.
|
||||
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 Tmrt = calcTmrt(TaEnv, dirEnv, difEnv, globEnv, elev);
|
||||
const utci = utciApprox(TaEnv, Tmrt, va, eh);
|
||||
@@ -153,7 +153,7 @@ export function buildHourlyRows({ forecast, airQuality, location, vehicleType, v
|
||||
cc, ccLow, ccMid, ccHigh, cloudCat,
|
||||
uv, uvA, uvB,
|
||||
precip, precipProb, lightning, cape, snow,
|
||||
soilT0, soilT6, soilM, vehicleT, shadeT,
|
||||
soilT0, soilT6, soilM, shadeT,
|
||||
effectiveRad,
|
||||
elev, Tmrt, utci, utciAdj, eh, compass,
|
||||
visKm, aqi,
|
||||
@@ -161,8 +161,8 @@ export function buildHourlyRows({ forecast, airQuality, location, vehicleType, v
|
||||
};
|
||||
}) : [];
|
||||
|
||||
// Two-pass calculations: concrete thermal lag + indoor temperature.
|
||||
// Both need the full hourly arrays so they can look back at previous
|
||||
// Two-pass calculations: concrete thermal lag + indoor + vehicle cabin.
|
||||
// All need the full hourly arrays so they can look back at previous
|
||||
// hours. Run after hourlyRows is built, then stamp each row.
|
||||
if (hourlyRows.length > 0) {
|
||||
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);
|
||||
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.
|
||||
const furAlbedo = (FUR_COLORS[furColor] || FUR_COLORS.brown).albedo;
|
||||
const furTemps = calcFurSurfaceTempPass(TaArr, radArr, vaArr, elevArr, furAlbedo);
|
||||
|
||||
+1
-1
@@ -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.' },
|
||||
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 15–25 °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.' },
|
||||
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.' },
|
||||
|
||||
+121
-45
@@ -11,7 +11,8 @@
|
||||
// calcConcreteTempPass(arrays...) thermal-lag pass over full hourly arrays
|
||||
// calcIndoorTempPass(TaArr, globArr, elevArr, buildingType) passive 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
|
||||
// vaporPressureHpa(Ta, RH) Magnus formula - hPa
|
||||
// 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).
|
||||
// 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
|
||||
// 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
|
||||
// cut through the side glass and windscreen at an angle that allows
|
||||
// significant transmission into the cabin. 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.
|
||||
// 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
|
||||
// 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
|
||||
// occupied living spaces, they also retain warmth from previous hours, people,
|
||||
// appliances, and background heating; without that, cool sunny days are
|
||||
// under-estimated badly.
|
||||
// spaces with 25-35 mm sandwich panels, so panel heat gain is much smaller.
|
||||
// Their interiors are SLOWER, not COOLER - the insulation that keeps heat out
|
||||
// also keeps it in, so a closed-up van ends up about as hot as a closed-up car
|
||||
// once it has had a few hours to get there. Because they are occupied living
|
||||
// spaces, they also retain a little warmth from previous hours, people,
|
||||
// appliances, and background heating.
|
||||
//
|
||||
// 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, 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;
|
||||
|
||||
// Look up vehicle preset; fall back to a standard car if key unknown.
|
||||
const preset = VEHICLE_TYPES[vehicleType] || VEHICLE_TYPES.car;
|
||||
|
||||
// Road speed in m/s. speedMph = 0 is "Static" (parked) and reproduces the
|
||||
// original stationary model exactly; higher speeds scrub the shell with
|
||||
// forced airflow and (windows down) flush the cabin toward ambient.
|
||||
// Road speed in m/s. speedMph = 0 is "Static" (parked); higher speeds scrub
|
||||
// the shell with forced airflow and (windows down) flush the cabin toward
|
||||
// ambient.
|
||||
const vMs = Math.max(0, speedMph) * 0.447; // mph -> m/s
|
||||
|
||||
// -- 1. Panel conduction ------------------------------------------
|
||||
const panelAbsorbed = globalRad * (1 - preset.albedo); // W/m- absorbed by bodywork
|
||||
// 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,
|
||||
// forced convection rises with road speed (same sqrt form as the surface
|
||||
// model), so the bodywork runs progressively closer to ambient.
|
||||
const hOut = 10 + 5.5 * Math.sqrt(vMs);
|
||||
// Convection over the shell is driven by whichever airflow is stronger -
|
||||
// the ambient wind or the car's own road speed. A parked vehicle in a
|
||||
// 15 mph wind has bodywork far closer to ambient than one in still air,
|
||||
// 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;
|
||||
// Conductive gain into cabin. Cars are thin metal + trim; motorhomes and
|
||||
// caravans use insulated sandwich panels, so their bodyU is much lower.
|
||||
const hCabin = preset.bodyU ?? 4;
|
||||
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.
|
||||
// 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;
|
||||
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
|
||||
}
|
||||
const tent = (solElev != null && solElev > 0)
|
||||
? Math.max(0, 1 - Math.abs(solElev - 35) / 25)
|
||||
: 0;
|
||||
const glazingGain = tau * globalRad * Math.max(0.30, tent) * 0.5; // occupant not in beam
|
||||
|
||||
// -- 3. Horizontal glazing gain (rooflights, panoramic roof) -------
|
||||
// Rooflights collect in proportion to sin(elevation), so they peak at midday
|
||||
// just as the vertical-glass term is falling away. Smoked acrylic transmits
|
||||
// roughly 0.40 rather than the 0.70 of clear auto glass.
|
||||
const roofGain = (solElev != null && solElev > 0)
|
||||
? 0.40 * (preset.roofGlazing ?? 0) * globalRad * Math.sin(solElev * Math.PI / 180)
|
||||
: 0;
|
||||
|
||||
// -- Combine into cabin air temperature ---------------------------
|
||||
// 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,
|
||||
// and surfaces exchanging heat with the outside. With windows open it is
|
||||
// roughly 5x higher when parked - air moves freely through the cabin,
|
||||
// flushing heat out and capping interior temperature much closer to ambient.
|
||||
// and surfaces exchanging heat with the outside. Opening the windows when
|
||||
// parked multiplies it by the preset's ventMult - a car with every window
|
||||
// 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
|
||||
// window flushes the cabin almost to ambient). Sealed but moving rejects a
|
||||
// little faster too, because the cooler shell pulls cabin heat out.
|
||||
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 thermalMass = preset.thermalMass ?? 1;
|
||||
const solarRise = (totalGain / effectiveHLoss) * thermalMass;
|
||||
const solarRise = totalGain / effectiveHLoss;
|
||||
|
||||
// Motorhomes/caravans behave more like small insulated rooms than parked
|
||||
// cars. This term captures retained living-space warmth: strongest on cool
|
||||
// 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
|
||||
? Math.max(0, 8 - 0.25 * Ta) * (ventilated ? 0.35 : 1)
|
||||
? Math.max(0, 5 - 0.20 * Ta) * (ventilated ? 0.35 : 1)
|
||||
: 0;
|
||||
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));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// 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)
|
||||
// -------------------------------------------------------------------
|
||||
@@ -576,7 +651,8 @@ export function calcFurSurfaceTempPass(TaArr, radArr, vaArr, elevArr, furAlbedo
|
||||
// calcVehicleInteriorTemp now takes a speedMph argument (Static / 20 / 50 / 70
|
||||
// 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
|
||||
// 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) ------------------------------------------------
|
||||
// A cyclist generates their own headwind, so the felt temperature (UTCI) is
|
||||
|
||||
+22
-8
@@ -209,23 +209,37 @@ export function burnLabel(mins) {
|
||||
// -------------------------------------------------------------------
|
||||
// Each entry tweaks the physical levers in calcVehicleInteriorTemp:
|
||||
// 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
|
||||
// thin metal/glass boxes; motorhomes/caravans have insulated
|
||||
// sandwich panels, commonly around 25-35 mm thick.
|
||||
// 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
|
||||
// hours, people, appliances, and background heating.
|
||||
// internalGain - small living-space warmth boost when closed up.
|
||||
// -------------------------------------------------------------------
|
||||
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 },
|
||||
mpv: { name: 'MPV / People Carrier', albedo: 0.25, glazingArea: 1.3, bodyU: 4.0, hCabinLoss: 20, thermalMass: 1.00, 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 },
|
||||
truck: { name: 'Truck / HGV Cab', albedo: 0.30, glazingArea: 1.2, bodyU: 3.5, hCabinLoss: 18, thermalMass: 0.90, 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 },
|
||||
caravan: { name: 'Caravan (towed)', albedo: 0.60, glazingArea: 0.22, bodyU: 0.8, hCabinLoss: 11, thermalMass: 0.35, retainedWarmth: true, internalGain: 1.2 },
|
||||
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.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.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.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.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.55, roofGlazing: 0.07, bodyU: 0.8, hCabinLoss: 6.2, ventMult: 2.4, lagHours: 1.7, retainedWarmth: true, internalGain: 1.2 },
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
@@ -232,7 +232,8 @@ async function build() {
|
||||
|
||||
// 8. Copy 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);
|
||||
if (fs.existsSync(src)) {
|
||||
copyFile(src, path.join(DIST, f));
|
||||
|
||||
@@ -158,5 +158,18 @@
|
||||
"alltemps": 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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user