Improved scope
Improved profiles
This commit is contained in:
fraxle
2026-05-15 09:40:42 +01:00
parent f024fd0c2d
commit 5b0e62d1b5
6 changed files with 372 additions and 108 deletions
+96 -49
View File
@@ -27,6 +27,7 @@ import { vaporPressureHpa, solarElevationDeg, calcTmrt, utciApprox, calcConcrete
import {
utciCategory, precipPenalty, windCompass8, uvSplit,
SKIN_TYPES, sunburnMinutes, burnLabel,
VEHICLE_TYPES,
cloudCategory, confidenceBand, moonGlyph,
} from './utils.js';
import { SkyScope, WindVane, CloudIcon, ScopeReticle, PrecipIcon } from './components.js';
@@ -55,6 +56,7 @@ export function UTCIForecast() {
const [searchResults, setSearchResults] = useState([]); // geocoding dropdown
const [searching, setSearching] = useState(false); // search-in-flight flag
const [selectedDay, setSelectedDay] = useState(0); // which day tab is active
const [now, setNow] = useState(new Date()); // ticks every 5 min
const [proPromptDay, setProPromptDay] = useState(null); // locked day clicked → show upsell card
const [proPromptSource, setProPromptSource] = useState('day'); // 'day' | 'custom'
@@ -111,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(false);
const [isPro, setIsPro] = useState(true);
// How many days the free tier shows. Days beyond this get a 🔒.
// Bump this number if you want to give free users more access.
@@ -125,30 +127,30 @@ export function UTCIForecast() {
icon: '🌡️',
cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: false, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false },
},
home: {
label: 'Home',
icon: '🏠',
cols: { hour: true, air: true, rh: true, dew: true, wind: true, dir: false, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: false, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: true, managedT: true },
},
urban: {
label: 'Urban',
icon: '🏙️',
cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: false, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: true, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: true, vehicleT: false, indoorT: false, managedT: false },
},
farming: {
label: 'Farming',
icon: '🌾',
cols: { hour: true, air: true, rh: true, dew: true, wind: true, dir: false, cloud: true, sun: true, direct: true, diffuse: true, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, soilT: true, soilT6: true, soilM: true, concreteT: false, vehicleT: false, indoorT: false, managedT: false },
},
sailing: {
label: 'Sailing',
icon: '⛵',
cols: { hour: true, air: false, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: true, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: true, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false },
cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: false, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: true, vehicleT: false, indoorT: false, managedT: false },
},
vehicle: {
label: 'Vehicle',
icon: '🚗',
cols: { hour: true, air: true, rh: false, dew: false, wind: false, dir: false, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: true, burn: true, utciP: false, precip: false, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: true, indoorT: false, managedT: false },
cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: false, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, soilT: false, soilT6: false, soilM: true, concreteT: false, vehicleT: true, indoorT: false, managedT: false },
},
home: {
label: 'Home',
icon: '🏠',
cols: { hour: true, air: true, rh: true, dew: true, wind: true, dir: false, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: false, precip: false, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: true, managedT: true },
sailing: {
label: 'Sailing',
icon: '',
cols: { hour: true, air: true, rh: false, dew: true, wind: true, dir: true, cloud: true, sun: true, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: false, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false },
},
farming: {
label: 'Farming',
icon: '🌾',
cols: { hour: true, air: true, rh: true, dew: true, wind: true, dir: true, cloud: true, sun: true, direct: false, diffuse: false, tmrt: false, delta: false, utci: true, uvA: false, uvB: false, burn: false, utciP: false, precip: true, soilT: true, soilT6: true, soilM: true, concreteT: false, vehicleT: false, indoorT: false, managedT: false },
},
alltemps: {
label: 'All Temps',
@@ -168,19 +170,14 @@ export function UTCIForecast() {
// Which columns appear in the hourly table by default.
// true = visible on first load (and the only ones free users see)
// false = hidden by default (Pro users can toggle these on)
const [visibleCols, setVisibleCols] = useState({
hour: true, air: true, rh: false, dew: false,
wind: true, dir: false,
cloud: false, sun: false, direct: false, diffuse: false,
tmrt: false, delta: false, utci: false,
uvA: false, uvB: false, burn: false,
utciP: true, precip: true,
soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false,
});
const [visibleCols, setVisibleCols] = useState({ ...FILTER_PROFILES.basic.cols });
const toggleCol = (col) => setVisibleCols(prev => ({ ...prev, [col]: !prev[col] }));
// Skin type for the sunburn-time column. Fitzpatrick II is typical UK fair.
const [skinType, setSkinType] = useState('II');
// Vehicle type for the cabin heat column. 'car' is the default preset.
const [vehicleType, setVehicleType] = useState('car');
const searchTimeout = useRef(null);
// Refs for the two-scroller table layout (sticky-to-viewport header +
@@ -224,7 +221,7 @@ export function UTCIForecast() {
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: '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 1525 °C above air temperature on sunny days.' },
vehicleT: { title: 'Vehicle Interior', desc: 'Estimated peak ambient cabin temperature inside a sealed, parked vehicle. Modelled from solar gain through body panels and side windows. Dangerous for children and pets above 35 °C; potentially fatal above 45 °C.' },
vehicleT: { title: 'Vehicle Interior', desc: 'Estimated peak ambient cabin temperature inside a sealed, parked vehicle. Adjusted for your selected vehicle type — albedo, glazing area, and insulation all affect the result. 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 24 hours after the outdoor peak.' },
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.' },
};
@@ -455,7 +452,7 @@ export function UTCIForecast() {
if (ro) ro.disconnect();
window.removeEventListener('resize', sync);
};
}, [forecast, visibleCols, selectedDay, skinType]);
}, [forecast, visibleCols, selectedDay, skinType, vehicleType]);
// Geocoding search
useEffect(() => {
@@ -503,6 +500,37 @@ export function UTCIForecast() {
load();
}, [location]);
// ─── AUTO-REFRESH — tick every 5 minutes ─────────────────────────────
// Updates `now` so the scope always shows the current hour's data.
// Also refetches the forecast so fresh API data comes in automatically.
useEffect(() => {
const FIVE_MIN = 5 * 60 * 1000;
const id = setInterval(() => {
setNow(new Date());
// Trigger a fresh forecast fetch by nudging location identity.
// We do this via a separate load rather than touching location state
// (which would reset other things), so we call load() directly.
async function refresh() {
try {
const url =
`https://api.open-meteo.com/v1/forecast` +
`?latitude=${location.lat}&longitude=${location.lon}` +
`&hourly=temperature_2m,relative_humidity_2m,dew_point_2m,` +
`wind_speed_10m,wind_direction_10m,wind_gusts_10m,` +
`direct_radiation,diffuse_radiation,shortwave_radiation,` +
`cloud_cover,cloud_cover_low,cloud_cover_mid,cloud_cover_high,` +
`uv_index,precipitation,snowfall,` +
`soil_temperature_0cm,soil_temperature_6cm,soil_moisture_0_to_1cm` +
`&wind_speed_unit=ms&timezone=auto&forecast_days=14`;
const r = await fetch(url);
if (r.ok) setForecast(await r.json());
} catch (_) { /* silently ignore refresh errors */ }
}
refresh();
}, FIVE_MIN);
return () => clearInterval(id);
}, [location]);
// ─── COMPUTATION ─────────────────────────────────────────────────────
// Take the raw API arrays and stitch them into one object per hour,
// calculating UTCI + soak-factor for each row. This is what gets
@@ -551,7 +579,7 @@ export function UTCIForecast() {
// dt kept for SkyScope / backward compat — same as dtUTC.
const dt = dtUTC;
const elev = solarElevationDeg(location.lat, location.lon, dtUTC);
const vehicleT = calcVehicleInteriorTemp(Ta, glob, elev);
const vehicleT = calcVehicleInteriorTemp(Ta, glob, elev, vehicleType);
const eh = vaporPressureHpa(Ta, RH);
const Tmrt = calcTmrt(Ta, dir, dif, glob, elev);
const utci = utciApprox(Ta, Tmrt, va, eh);
@@ -592,7 +620,6 @@ export function UTCIForecast() {
});
const visible = days[selectedDay]?.rows || [];
const now = new Date();
// nowLocalISO: current moment in location-local time as "YYYY-MM-DDTHH"
// Used to match against r.iso (which is already a local wall-clock string).
const nowLocalISO = new Date(now.getTime() + utcOffsetMs)
@@ -653,6 +680,7 @@ export function UTCIForecast() {
loading=${loading}
elev=${currentRow?.elev ?? 0}
dt=${currentRow?.dt ?? new Date()}
glob=${currentRow?.glob ?? 0}
/>
</div>
@@ -938,6 +966,43 @@ export function UTCIForecast() {
COLUMN TOGGLES Pro only. Hidden entirely for free users
(the profile selector above provides enough control for them).
-->
${(FILTER_PROFILES[activeProfile]?.cols['burn'] || FILTER_PROFILES[activeProfile]?.cols['vehicleT']) && html`
<div class="col-toggles">
<span class="col-toggles-label">Columns:</span>
${FILTER_PROFILES[activeProfile]?.cols['burn'] && html`
<select
class=${`col-toggle burn-select${visibleCols.burn ? ' on' : ''}`}
value=${visibleCols.burn ? skinType : 'off'}
onChange=${(e) => {
if (e.target.value === 'off') {
setVisibleCols(prev => ({ ...prev, burn: false }));
} else {
setSkinType(e.target.value);
setVisibleCols(prev => ({ ...prev, burn: true }));
}
}}>
<option value="off">${visibleCols.burn ? 'Hide Burn' : 'Burn'}</option>
${Object.entries(SKIN_TYPES).map(([k, v]) => html`
<option key=${k} value=${k}>${v.name.split(' · ')[1]} skin</option>`)}
</select>`}
${FILTER_PROFILES[activeProfile]?.cols['vehicleT'] && html`
<select
class=${`col-toggle burn-select${visibleCols.vehicleT ? ' on' : ''}`}
value=${visibleCols.vehicleT ? vehicleType : 'off'}
onChange=${(e) => {
if (e.target.value === 'off') {
setVisibleCols(prev => ({ ...prev, vehicleT: false }));
} else {
setVehicleType(e.target.value);
setVisibleCols(prev => ({ ...prev, vehicleT: true }));
}
}}>
<option value="off">${visibleCols.vehicleT ? 'Hide Vehicle' : 'Vehicle'}</option>
${Object.entries(VEHICLE_TYPES).map(([k, v]) => html`
<option key=${k} value=${k}>${v.name}</option>`)}
</select>`}
</div>`}
${isPro && html`
<div class="col-toggles">
<span class="col-toggles-label">Columns:</span>
@@ -950,7 +1015,6 @@ export function UTCIForecast() {
{ key: 'soilT6', label: 'Soil 6cm' },
{ key: 'soilM', label: 'Soil moist' },
{ key: 'concreteT', label: 'Concrete' },
{ key: 'vehicleT', label: 'Vehicle' },
{ key: 'indoorT', label: 'Indoors' },
{ key: 'managedT', label: 'Managed' },
{ key: 'wind', label: 'Wind' },
@@ -973,23 +1037,6 @@ export function UTCIForecast() {
onClick=${() => toggleCol(c.key)}
>${c.label}</button>`)}
${(activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['burn']) && html`
<select
class=${`col-toggle burn-select${visibleCols.burn ? ' on' : ''}`}
value=${visibleCols.burn ? skinType : 'off'}
onChange=${(e) => {
if (e.target.value === 'off') {
setVisibleCols(prev => ({ ...prev, burn: false }));
} else {
setSkinType(e.target.value);
setVisibleCols(prev => ({ ...prev, burn: true }));
}
}}>
<option value="off">${visibleCols.burn ? 'Hide Burn' : 'Burn'}</option>
${Object.entries(SKIN_TYPES).map(([k, v]) => html`
<option key=${k} value=${k}>${v.name.split(' · ')[1]} skin</option>`)}
</select>`}
${(activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['precip']) && html`
<button
class=${`col-toggle${visibleCols.precip ? ' on' : ''}`}
@@ -1079,7 +1126,7 @@ export function UTCIForecast() {
class=${`${isNight ? 'is-night' : ''} ${isNow ? 'is-now' : ''}`.trim()}>
<td class="utci-time">
<span style=${{ display: 'inline-flex', alignItems: 'center', gap: '7px', verticalAlign: 'middle' }}>
<${SkyScope} elev=${r.elev} dt=${r.dt} size=${30} />
<${SkyScope} elev=${r.elev} dt=${r.dt} glob=${r.glob} size=${30} />
<span>${localHHMM}</span>
</span>
</td>