Beta 8
Improved scope Improved profiles
This commit is contained in:
+96
-49
@@ -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 15–25 °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 2–4 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>
|
||||
|
||||
Reference in New Issue
Block a user