Beta 10
New thermal charts
This commit is contained in:
+40
-19
@@ -113,7 +113,7 @@ export function UTCIForecast() {
|
||||
// FLIP THE `false` BELOW TO `true` TO PREVIEW THE PRO EXPERIENCE.
|
||||
// When this is wired to real billing/auth, replace `useState(false)`
|
||||
// with a check against the logged-in user.
|
||||
const [isPro, setIsPro] = useState(true);
|
||||
const [isPro, setIsPro] = useState(false);
|
||||
|
||||
// How many days the free tier shows. Days beyond this get a 🔒.
|
||||
// Bump this number if you want to give free users more access.
|
||||
@@ -250,7 +250,7 @@ export function UTCIForecast() {
|
||||
soilM: { title: 'Soil Moisture', desc: 'Volumetric water content of the top 1 cm of soil (m³/m³). Values above 0.4 suggest saturated ground; below 0.2 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.' },
|
||||
indoorT: { title: 'Indoors', desc: 'Estimated ambient temperature inside a typical UK brick house with windows closed and no air conditioning. Accounts for wall conduction, window solar gain, and thermal mass — indoor temperatures typically peak 2–4 hours after the outdoor peak.' },
|
||||
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.' },
|
||||
};
|
||||
|
||||
@@ -1233,9 +1233,29 @@ export function UTCIForecast() {
|
||||
if (t < 50) return 'rgba(210, 70, 50,0.10)';
|
||||
return 'rgba(160, 30, 30,0.10)';
|
||||
};
|
||||
const scaleBg = (v, min, max, rgb, maxAlpha = 0.14) => {
|
||||
if (v == null || isNaN(v)) return 'transparent';
|
||||
const x = Math.max(0, Math.min(1, (v - min) / (max - min)));
|
||||
const a = 0.035 + x * maxAlpha;
|
||||
return `rgba(${rgb},${a.toFixed(3)})`;
|
||||
};
|
||||
const deltaBg = (v) => {
|
||||
if (v == null || isNaN(v)) return 'transparent';
|
||||
if (Math.abs(v) < 1) return 'transparent';
|
||||
const x = Math.min(1, Math.abs(v) / 12);
|
||||
const a = 0.035 + x * 0.13;
|
||||
return v > 0 ? `rgba(230,140,50,${a.toFixed(3)})` : `rgba(80,135,210,${a.toFixed(3)})`;
|
||||
};
|
||||
const burnBg = (mins) => {
|
||||
if (!isFinite(mins)) return 'transparent';
|
||||
if (mins >= 240) return 'rgba(110,180,110,0.07)';
|
||||
const x = Math.max(0, Math.min(1, (240 - mins) / 220));
|
||||
return `rgba(210,70,50,${(0.04 + x * 0.15).toFixed(3)})`;
|
||||
};
|
||||
// r.iso is the local wall-clock string from the API — slice it directly.
|
||||
const h24 = parseInt(r.iso.slice(11, 13), 10);
|
||||
const localHHMM = h24 === 0 ? '12am' : h24 < 12 ? `${h24}am` : h24 === 12 ? '12pm' : `${h24 - 12}pm`;
|
||||
const burnMins = sunburnMinutes(r.uv, skinType);
|
||||
return html`
|
||||
<tr key=${r.iso}
|
||||
class=${`${isNight ? 'is-night' : ''} ${isNow ? 'is-now' : ''}`.trim()}>
|
||||
@@ -1246,19 +1266,19 @@ export function UTCIForecast() {
|
||||
</span>
|
||||
</td>
|
||||
${visibleCols.air && html`<td style=${{ background: tempBg(r.Ta) }}>${r.Ta.toFixed(1)}</td>`}
|
||||
${visibleCols.rh && html`<td>${Math.round(r.RH)}</td>`}
|
||||
${visibleCols.dew && html`<td>${r.dew != null ? r.dew.toFixed(1) : '—'}</td>`}
|
||||
${visibleCols.rh && html`<td style=${{ background: scaleBg(r.RH, 30, 100, '70,145,200') }}>${Math.round(r.RH)}</td>`}
|
||||
${visibleCols.dew && html`<td style=${{ background: tempBg(r.dew) }}>${r.dew != null ? r.dew.toFixed(1) : '—'}</td>`}
|
||||
${visibleCols.soilT && html`
|
||||
<td style=${{ color: '#6b4a1c', background: tempBg(r.soilT0) }}>${r.soilT0 != null ? r.soilT0.toFixed(1) : '—'}</td>`}
|
||||
${visibleCols.soilT6 && html`
|
||||
<td style=${{ color: '#6b4a1c' }}>${r.soilT6 != null ? r.soilT6.toFixed(1) : '—'}</td>`}
|
||||
<td style=${{ color: '#6b4a1c', background: tempBg(r.soilT6) }}>${r.soilT6 != null ? r.soilT6.toFixed(1) : '—'}</td>`}
|
||||
${visibleCols.soilM && html`
|
||||
<td style=${{ color: '#2a6a90' }}>${r.soilM != null ? r.soilM.toFixed(3) : '—'}</td>`}
|
||||
<td style=${{ color: '#2a6a90', background: scaleBg(r.soilM, 0.12, 0.45, '50,125,190') }}>${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', background: tempBg(r.concreteT) }}>
|
||||
${r.concreteT != null ? r.concreteT.toFixed(1) : '—'}
|
||||
</td>`}
|
||||
${visibleCols.wind && html`<td>
|
||||
${visibleCols.wind && html`<td style=${{ background: scaleBg(r.gust ?? r.va, 0, 18, '85,130,180') }}>
|
||||
${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>`
|
||||
: ''}
|
||||
@@ -1269,40 +1289,41 @@ export function UTCIForecast() {
|
||||
<span class="wind-dir-label" style=${{ fontFamily: 'JetBrains Mono, monospace', fontSize: '11px' }}>${r.compass.label}</span>
|
||||
</span>
|
||||
</td>`}
|
||||
${visibleCols.cloud && html`<td>
|
||||
${visibleCols.cloud && html`<td style=${{ background: scaleBg(r.cc, 0, 100, '110,130,150') }}>
|
||||
<span style=${{ display: 'inline-flex', alignItems: 'center', gap: '6px', verticalAlign: 'middle' }}>
|
||||
<${CloudIcon} category=${r.cloudCat} elev=${r.elev} dt=${r.dt} size=${28} />
|
||||
<span>${Math.round(r.cc)}</span>
|
||||
</span>
|
||||
</td>`}
|
||||
${visibleCols.sun && html`<td>${r.elev > 0 ? r.elev.toFixed(1) : '—'}</td>`}
|
||||
${visibleCols.direct && html`<td>${Math.round(r.dir)}</td>`}
|
||||
${visibleCols.diffuse && html`<td>${Math.round(r.dif)}</td>`}
|
||||
${visibleCols.tmrt && html`<td>${r.Tmrt.toFixed(1)}</td>`}
|
||||
${visibleCols.sun && html`<td style=${{ background: scaleBg(Math.max(0, r.elev), 0, 70, '225,160,45') }}>${r.elev > 0 ? r.elev.toFixed(1) : '—'}</td>`}
|
||||
${visibleCols.direct && html`<td style=${{ background: scaleBg(r.dir, 0, 850, '230,155,35') }}>${Math.round(r.dir)}</td>`}
|
||||
${visibleCols.diffuse && html`<td style=${{ background: scaleBg(r.dif, 0, 450, '230,190,70') }}>${Math.round(r.dif)}</td>`}
|
||||
${visibleCols.tmrt && html`<td style=${{ background: tempBg(r.Tmrt) }}>${r.Tmrt.toFixed(1)}</td>`}
|
||||
${visibleCols.delta && html`
|
||||
<td style=${{
|
||||
color: delta > 3 ? '#c8601a' : delta < -3 ? '#3f73c4' : '#4a3218',
|
||||
fontWeight: 600,
|
||||
background: deltaBg(delta),
|
||||
}}>
|
||||
${delta > 0 ? '+' : ''}${delta.toFixed(1)}
|
||||
</td>`}
|
||||
${visibleCols.utci && html`
|
||||
<td>
|
||||
<td style=${{ background: tempBg(r.utci) }}>
|
||||
<span class="utci-cell" style=${{ background: cat.bg, color: cat.fg }}>
|
||||
${r.utci.toFixed(1)}
|
||||
</span>
|
||||
</td>`}
|
||||
${visibleCols.uvA && html`
|
||||
<td style=${{ color: r.uvA > 0 ? '#c8922a' : '#4a3218' }}>
|
||||
<td style=${{ color: r.uvA > 0 ? '#c8922a' : '#4a3218', background: scaleBg(r.uvA, 0, 8, '220,155,40') }}>
|
||||
${r.uvA > 0 ? r.uvA.toFixed(1) : '—'}
|
||||
</td>`}
|
||||
${visibleCols.uvB && html`
|
||||
<td style=${{ color: r.uvB > 0 ? '#c44a3a' : '#4a3218' }}>
|
||||
<td style=${{ color: r.uvB > 0 ? '#c44a3a' : '#4a3218', background: scaleBg(r.uvB, 0, 1.2, '210,70,50') }}>
|
||||
${r.uvB > 0 ? r.uvB.toFixed(2) : '—'}
|
||||
</td>`}
|
||||
${visibleCols.burn && html`
|
||||
<td style=${{ color: r.uv > 0 ? (sunburnMinutes(r.uv, skinType) < 30 ? '#c44a3a' : '#c8601a') : '#4a3218' }}>
|
||||
${burnLabel(sunburnMinutes(r.uv, skinType))}
|
||||
<td style=${{ color: r.uv > 0 ? (burnMins < 30 ? '#c44a3a' : '#c8601a') : '#4a3218', background: burnBg(burnMins) }}>
|
||||
${burnLabel(burnMins)}
|
||||
</td>`}
|
||||
${visibleCols.vehicleT && html`
|
||||
<td style=${{ color: r.vehicleT != null && r.vehicleT > 45 ? '#c0392b' : r.vehicleT != null && r.vehicleT > 35 ? '#e67e22' : '#7f8c8d', fontWeight: 'bold', background: tempBg(r.vehicleT) }}>
|
||||
@@ -1317,7 +1338,7 @@ export function UTCIForecast() {
|
||||
${r.managedT != null ? r.managedT.toFixed(1) : '—'}
|
||||
</td>`}
|
||||
${visibleCols.precip && html`
|
||||
<td>
|
||||
<td style=${{ background: r.snow > 0 ? scaleBg(r.snow, 0, 4, '90,140,210') : scaleBg(r.precip, 0, 8, '70,145,200') }}>
|
||||
<span style=${{ display: 'inline-flex', alignItems: 'center', gap: '5px', verticalAlign: 'middle' }}>
|
||||
<${PrecipIcon} precip=${r.precip} snow=${r.snow} size=${28} />
|
||||
<span style=${{ color: r.snow > 0 ? '#2a5fa8' : r.precip > 0 ? '#2a6a90' : '#7a5c30' }}>
|
||||
@@ -1326,7 +1347,7 @@ export function UTCIForecast() {
|
||||
</span>
|
||||
</td>`}
|
||||
${visibleCols.utciP && html`
|
||||
<td style=${{ background: 'rgba(180,215,250,0.10)' }}>
|
||||
<td style=${{ background: tempBg(r.utciAdj) }}>
|
||||
<span class="utci-cell utci-cell-hero" style=${{ background: adjCat.bg, color: adjCat.fg }}>
|
||||
${r.utciAdj.toFixed(1)}
|
||||
</span>
|
||||
|
||||
+19
-25
@@ -86,10 +86,11 @@ export function calcConcreteTemp(Ta, globalRad, windSpeed) {
|
||||
//
|
||||
// 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.
|
||||
// slowly to outdoor temperature swings. Each hour builds a realistic
|
||||
// target temperature from outdoor air, window solar gain, retained
|
||||
// warmth, and internal gains, then the room temperature lags toward
|
||||
// that target. This avoids runaway accumulation while still giving
|
||||
// the characteristic late-day indoor peak.
|
||||
// Call calcIndoorTempPass() on the full hourly arrays after
|
||||
// building rows — it returns a per-hour indoor temp array.
|
||||
//
|
||||
@@ -125,14 +126,14 @@ function _houseHeatLoad(Ta, globalRad, solElev) {
|
||||
// buildingType must be a key of BUILDING_TYPES; defaults to 'brick'.
|
||||
export function calcIndoorTempPass(TaArr, globArr, elevArr, buildingType = 'brick') {
|
||||
const preset = BUILDING_TYPES[buildingType] || BUILDING_TYPES.brick;
|
||||
const { uWall, lagHours, glazingRatio, gValue, orientFactor } = preset;
|
||||
const { lagHours, glazingRatio, gValue, orientFactor, solarScale, baseTemp, internalGain, retainedScale } = preset;
|
||||
|
||||
const n = TaArr.length;
|
||||
const result = new Array(n);
|
||||
const alpha = 1 - Math.exp(-1 / lagHours); // per-hour blending weight
|
||||
|
||||
// Seed indoor temp to first outdoor temp
|
||||
let Ti = TaArr[0] ?? 15;
|
||||
// Seed to a plausible occupied indoor baseline rather than outdoor air.
|
||||
let Ti = Math.max(TaArr[0] ?? 15, baseTemp ?? 16);
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const Ta = TaArr[i] ?? Ti;
|
||||
@@ -141,16 +142,12 @@ export function calcIndoorTempPass(TaArr, globArr, elevArr, buildingType = 'bric
|
||||
// Solar gain through windows (W/m² effective)
|
||||
const solarGain = glob * glazingRatio * gValue * orientFactor;
|
||||
|
||||
// Conductive heat flow through walls: proportional to (Ta - Ti)
|
||||
const conductionGain = uWall * (Ta - Ti);
|
||||
const retainedWarmth = Math.max(0, (baseTemp ?? 16) - Ta) * (retainedScale ?? 0.35);
|
||||
const target = Ta + solarGain * (solarScale ?? 0.1) + retainedWarmth + (internalGain ?? 0.7);
|
||||
|
||||
// Heat capacity proxy: how many °C does 1 W/m² raise the indoor air?
|
||||
// Typical 90 m² house ≈ 0.15; conservatory much lower (less thermal mass).
|
||||
const heatCapProxy = 0.15;
|
||||
const Ti_instant = Ti + (conductionGain + solarGain) * heatCapProxy;
|
||||
|
||||
// Apply thermal lag: blend toward Ti_instant at the building's time constant
|
||||
Ti = Ti + alpha * (Ti_instant - Ti);
|
||||
// Apply thermal lag: blend toward the hour's target instead of adding
|
||||
// solar gain repeatedly onto the previous indoor temperature.
|
||||
Ti = Ti + alpha * (target - Ti);
|
||||
|
||||
// Can't be colder than outdoor (house doesn't actively cool)
|
||||
result[i] = Math.max(Math.min(Ti, 55), Math.min(Ta, Ti));
|
||||
@@ -182,13 +179,13 @@ export function calcIndoorTempPass(TaArr, globArr, elevArr, buildingType = 'bric
|
||||
// buildingType must be a key of BUILDING_TYPES; defaults to 'brick'.
|
||||
export function calcManagedIndoorTempPass(TaArr, globArr, elevArr, buildingType = 'brick') {
|
||||
const preset = BUILDING_TYPES[buildingType] || BUILDING_TYPES.brick;
|
||||
const { uWall, lagHours, glazingRatio, gValue, orientFactor, curtainBlock, ventAlpha } = preset;
|
||||
const { lagHours, glazingRatio, gValue, orientFactor, curtainBlock, ventAlpha, solarScale, baseTemp, internalGain, retainedScale } = preset;
|
||||
|
||||
const n = TaArr.length;
|
||||
const result = new Array(n);
|
||||
const alpha = 1 - Math.exp(-1 / lagHours);
|
||||
|
||||
let Ti = TaArr[0] ?? 15;
|
||||
let Ti = Math.max(TaArr[0] ?? 15, baseTemp ?? 16);
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const Ta = TaArr[i] ?? Ti;
|
||||
@@ -197,14 +194,11 @@ export function calcManagedIndoorTempPass(TaArr, globArr, elevArr, buildingType
|
||||
// Solar gain — curtains block curtainBlock fraction
|
||||
const solarGain = glob * glazingRatio * gValue * orientFactor * (1 - curtainBlock);
|
||||
|
||||
// Wall conduction (unchanged from unmanaged model)
|
||||
const conductionGain = uWall * (Ta - Ti);
|
||||
const retainedWarmth = Math.max(0, (baseTemp ?? 16) - Ta) * (retainedScale ?? 0.35);
|
||||
const target = Ta + solarGain * (solarScale ?? 0.1) + retainedWarmth + (internalGain ?? 0.7);
|
||||
|
||||
const heatCapProxy = 0.15;
|
||||
const Ti_instant = Ti + (conductionGain + solarGain) * heatCapProxy;
|
||||
|
||||
// Apply thermal lag
|
||||
Ti = Ti + alpha * (Ti_instant - Ti);
|
||||
// Apply thermal lag toward the managed target.
|
||||
Ti = Ti + alpha * (target - Ti);
|
||||
|
||||
// Smart ventilation: only open windows when outside is cooler
|
||||
if (Ta < Ti) {
|
||||
|
||||
+13
-7
@@ -166,15 +166,21 @@ export const VEHICLE_TYPES = {
|
||||
// closed). Thick lined curtains ≈ 0.80; blinds ≈ 0.50.
|
||||
// ventAlpha — Blending weight per hour when smart ventilation is open.
|
||||
// Higher = more air changes per hour.
|
||||
// solarScale — Converts effective window solar gain into an indoor
|
||||
// temperature lift. Lower values mean more thermal mass.
|
||||
// baseTemp — Occupied/retained warmth baseline for normal homes.
|
||||
// internalGain — Small heat gain from people, appliances, and background use.
|
||||
// retainedScale — How strongly the building holds above-outdoor warmth in
|
||||
// cool conditions. Higher = better retained warmth.
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
export const BUILDING_TYPES = {
|
||||
brick: { name: 'Brick (typical)', uWall: 0.35, lagHours: 4, glazingRatio: 0.16, gValue: 0.63, orientFactor: 0.50, curtainBlock: 0.80, ventAlpha: 0.25 },
|
||||
modern: { name: 'Modern / Well insulated', uWall: 0.18, lagHours: 5, glazingRatio: 0.20, gValue: 0.30, orientFactor: 0.50, curtainBlock: 0.70, ventAlpha: 0.20 },
|
||||
victorian: { name: 'Victorian Terrace', uWall: 0.55, lagHours: 5, glazingRatio: 0.12, gValue: 0.63, orientFactor: 0.50, curtainBlock: 0.80, ventAlpha: 0.30 },
|
||||
stone: { name: 'Stone / Granite Cottage', uWall: 0.45, lagHours: 7, glazingRatio: 0.10, gValue: 0.63, orientFactor: 0.50, curtainBlock: 0.75, ventAlpha: 0.25 },
|
||||
timber: { name: 'Timber Frame / New Build', uWall: 0.22, lagHours: 2, glazingRatio: 0.22, gValue: 0.35, orientFactor: 0.50, curtainBlock: 0.70, ventAlpha: 0.35 },
|
||||
flat: { name: 'Top-floor Flat', uWall: 0.40, lagHours: 3, glazingRatio: 0.18, gValue: 0.63, orientFactor: 0.50, curtainBlock: 0.75, ventAlpha: 0.20 },
|
||||
conservatory:{ name: 'Conservatory / Sun Room', uWall: 1.20, lagHours: 1, glazingRatio: 0.70, gValue: 0.72, orientFactor: 0.70, curtainBlock: 0.50, ventAlpha: 0.50 },
|
||||
brick: { name: 'Brick (typical)', uWall: 0.35, lagHours: 4, glazingRatio: 0.16, gValue: 0.63, orientFactor: 0.50, curtainBlock: 0.80, ventAlpha: 0.25, solarScale: 0.10, baseTemp: 16.5, internalGain: 0.8, retainedScale: 0.35 },
|
||||
modern: { name: 'Modern / Well insulated', uWall: 0.18, lagHours: 5, glazingRatio: 0.20, gValue: 0.30, orientFactor: 0.50, curtainBlock: 0.70, ventAlpha: 0.20, solarScale: 0.08, baseTemp: 17.0, internalGain: 0.8, retainedScale: 0.55 },
|
||||
victorian: { name: 'Victorian Terrace', uWall: 0.55, lagHours: 5, glazingRatio: 0.12, gValue: 0.63, orientFactor: 0.50, curtainBlock: 0.80, ventAlpha: 0.30, solarScale: 0.10, baseTemp: 16.0, internalGain: 0.7, retainedScale: 0.25 },
|
||||
stone: { name: 'Stone / Granite Cottage', uWall: 0.45, lagHours: 7, glazingRatio: 0.10, gValue: 0.63, orientFactor: 0.50, curtainBlock: 0.75, ventAlpha: 0.25, solarScale: 0.08, baseTemp: 15.5, internalGain: 0.6, retainedScale: 0.40 },
|
||||
timber: { name: 'Timber Frame / New Build', uWall: 0.22, lagHours: 2, glazingRatio: 0.22, gValue: 0.35, orientFactor: 0.50, curtainBlock: 0.70, ventAlpha: 0.35, solarScale: 0.08, baseTemp: 17.0, internalGain: 0.8, retainedScale: 0.45 },
|
||||
flat: { name: 'Top-floor Flat', uWall: 0.40, lagHours: 3, glazingRatio: 0.18, gValue: 0.63, orientFactor: 0.50, curtainBlock: 0.75, ventAlpha: 0.20, solarScale: 0.11, baseTemp: 17.0, internalGain: 0.9, retainedScale: 0.45 },
|
||||
conservatory:{ name: 'Conservatory / Sun Room', uWall: 1.20, lagHours: 1, glazingRatio: 0.70, gValue: 0.72, orientFactor: 0.70, curtainBlock: 0.50, ventAlpha: 0.50, solarScale: 0.045, baseTemp: 12.0, internalGain: 0.2, retainedScale: 0.05 },
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
Reference in New Issue
Block a user