1.44 - New Filters, profiles & Mobile fixes
This commit is contained in:
+236
-45
@@ -58,6 +58,7 @@ export function UTCIForecast() {
|
||||
};
|
||||
|
||||
const [forecast, setForecast] = useState(null); // raw Open-Meteo response
|
||||
const [airQuality, setAirQuality] = useState(null); // raw Open-Meteo air quality response
|
||||
const [loading, setLoading] = useState(false); // true while fetching
|
||||
const [error, setError] = useState(null); // fetch error message
|
||||
const [searchQuery, setSearchQuery] = useState(''); // text in the search box
|
||||
@@ -117,11 +118,10 @@ export function UTCIForecast() {
|
||||
el.scrollBy({ left: dir * 200, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
// ─── PRO TIER STUB ────────────────────────────────────────────────────
|
||||
// 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);
|
||||
// ─── PRO TIER ─────────────────────────────────────────────────────────
|
||||
// Initialise from URL param (?pro=1, set by Stripe after checkout) or
|
||||
// from localStorage (returning subscriber). Cleans the URL param
|
||||
// immediately so it doesn't stay visible in the address bar.
|
||||
const [isPro, setIsPro] = useState(() => {
|
||||
// Check URL param first (just returned from Stripe checkout)
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
@@ -145,53 +145,53 @@ export function UTCIForecast() {
|
||||
basic: {
|
||||
label: 'Basic',
|
||||
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 },
|
||||
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, vis: true, aqi: false, pollen: 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 },
|
||||
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, vis: false, aqi: true, pollen: true },
|
||||
},
|
||||
vehicle: {
|
||||
label: 'Vehicle',
|
||||
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: true, 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, vis: true, aqi: false, pollen: 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: 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 },
|
||||
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: 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, vis: false, aqi: true, pollen: true },
|
||||
},
|
||||
outdoors: {
|
||||
label: 'Places',
|
||||
icon: '🌤️',
|
||||
// Default cols match the first variant (urban). Switching variant updates visibleCols.
|
||||
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 },
|
||||
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, vis: true, aqi: true, pollen: false },
|
||||
},
|
||||
alltemps: {
|
||||
label: 'Temps',
|
||||
icon: '🌡️',
|
||||
proOnly: true,
|
||||
cols: { hour: true, air: true, rh: false, dew: false, wind: false, 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: false, soilT: true, soilT6: false, soilM: false, concreteT: true, vehicleT: true, indoorT: true, managedT: true },
|
||||
cols: { hour: true, air: true, rh: false, dew: false, wind: false, 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: false, soilT: true, soilT6: false, soilM: false, concreteT: true, vehicleT: true, indoorT: true, managedT: true, vis: false, aqi: false, pollen: false },
|
||||
},
|
||||
custom: {
|
||||
label: 'Custom',
|
||||
icon: '⚙️',
|
||||
proOnly: true,
|
||||
cols: { 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 },
|
||||
cols: { 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, vis: false, aqi: false, pollen: false },
|
||||
},
|
||||
};
|
||||
|
||||
// Places sub-variants — each has its own column set.
|
||||
// Selecting a variant applies its cols to visibleCols.
|
||||
const OUTDOORS_VARIANTS = {
|
||||
urban: { name: 'Urban', 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 } },
|
||||
beach: { name: 'Beach', 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: true, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false } },
|
||||
events: { name: 'Events', 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 } },
|
||||
festival: { name: 'Festival', proOnly: true, cols: { hour: true, air: true, rh: false, dew: true, 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: true, concreteT: false, vehicleT: false, indoorT: false, managedT: false } },
|
||||
wintersports: { name: 'Winter Sports', proOnly: true, cols: { hour: true, air: true, 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 } },
|
||||
naturist: { name: 'Naturist', proOnly: true, cols: { hour: true, air: true, rh: false, dew: true, wind: true, dir: false, cloud: true, sun: true, direct: false, diffuse: false, tmrt: false, delta: false, utci: true, uvA: true, uvB: true, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false } },
|
||||
sailing: { name: 'Sailing', proOnly: true, 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 } },
|
||||
urban: { name: 'Urban', 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, vis: true, aqi: true, pollen: false } },
|
||||
beach: { name: 'Beach', 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: true, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false } },
|
||||
events: { name: 'Events', 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, vis: false, aqi: false, pollen: true } },
|
||||
festival: { name: 'Festival', proOnly: true, cols: { hour: true, air: true, rh: false, dew: true, 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: true, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: false, aqi: true, pollen: true } },
|
||||
wintersports: { name: 'Winter Sports', proOnly: true, cols: { hour: true, air: true, 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, vis: true, aqi: false, pollen: false } },
|
||||
naturist: { name: 'Naturist', proOnly: true, cols: { hour: true, air: true, rh: false, dew: true, wind: true, dir: false, cloud: true, sun: true, direct: false, diffuse: false, tmrt: false, delta: false, utci: true, uvA: true, uvB: true, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: false, aqi: true, pollen: true } },
|
||||
sailing: { name: 'Sailing', proOnly: true, 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, vis: true, aqi: false, pollen: false } },
|
||||
};
|
||||
|
||||
// Current active filter profile
|
||||
@@ -200,7 +200,7 @@ 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({ ...FILTER_PROFILES.basic.cols });
|
||||
const [visibleCols, setVisibleCols] = useState({ ...FILTER_PROFILES.basic.cols, vis: false, aqi: false, pollen: false });
|
||||
const toggleCol = (col) => setVisibleCols(prev => ({ ...prev, [col]: !prev[col] }));
|
||||
|
||||
// Skin type for the sunburn-time column. Fitzpatrick II is typical UK fair.
|
||||
@@ -221,6 +221,26 @@ export function UTCIForecast() {
|
||||
const [buildingType, setBuildingType] = useState('brick');
|
||||
const [indoorManaged, setIndoorManaged] = useState(false);
|
||||
const [indoorMode, setIndoorMode] = useState('off');
|
||||
|
||||
// Pollen type for the pollen column. Persisted in localStorage.
|
||||
const [pollenType, setPollenType] = useState(() => {
|
||||
try { return localStorage.getItem('sunscope_pollen_type') || 'all_pollen'; } catch (e) { return 'all_pollen'; }
|
||||
});
|
||||
const setPollenTypeAndSave = (v) => {
|
||||
try { localStorage.setItem('sunscope_pollen_type', v); } catch (e) { /* ignore */ }
|
||||
setPollenType(v);
|
||||
};
|
||||
|
||||
// Pollen type options shown in the pulldown selector.
|
||||
const POLLEN_TYPES = {
|
||||
all_pollen: { name: 'All pollen', unit: 'grains/m³' },
|
||||
grass_pollen: { name: 'Grass pollen', unit: 'grains/m³' },
|
||||
birch_pollen: { name: 'Birch pollen', unit: 'grains/m³' },
|
||||
alder_pollen: { name: 'Alder pollen', unit: 'grains/m³' },
|
||||
mugwort_pollen: { name: 'Mugwort pollen', unit: 'grains/m³' },
|
||||
olive_pollen: { name: 'Olive pollen', unit: 'grains/m³' },
|
||||
ragweed_pollen: { name: 'Ragweed pollen', unit: 'grains/m³' },
|
||||
};
|
||||
const searchTimeout = useRef(null);
|
||||
|
||||
const activateProfile = (key) => {
|
||||
@@ -385,6 +405,9 @@ export function UTCIForecast() {
|
||||
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 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.' },
|
||||
aqi: { title: 'Air Quality Index', desc: 'European Air Quality Index (0–100+), combining PM2.5, PM10, ozone, nitrogen dioxide, and sulphur dioxide. 0–20 = Good; 20–40 = Fair; 40–60 = Moderate; 60–80 = Poor; 80–100 = Very Poor; 100+ = Extremely Poor. Sourced from Copernicus CAMS.' },
|
||||
pollen: { title: 'Pollen', desc: 'Pollen concentration in grains per cubic metre for the selected pollen type, sourced from the Copernicus CAMS pollen forecast. Low = <10; Moderate = 10–50; High = 50–200; Very High = 200+. Values vary by species and season.' },
|
||||
};
|
||||
|
||||
const calcPopupPos = (thEl) => {
|
||||
@@ -689,7 +712,7 @@ export function UTCIForecast() {
|
||||
`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,` +
|
||||
`uv_index,precipitation,snowfall,visibility,` +
|
||||
`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);
|
||||
@@ -699,8 +722,45 @@ export function UTCIForecast() {
|
||||
finally { setLoading(false); }
|
||||
}
|
||||
load();
|
||||
loadAirQuality(location);
|
||||
}, [location]);
|
||||
|
||||
// ─── AIR QUALITY FETCH (with 6-hour localStorage cache) ──────────────
|
||||
// Fetches visibility, European AQI, and pollen from the Open-Meteo
|
||||
// Air Quality API. Cached per location for 6 hours — pollen and AQI
|
||||
// data updates at most a couple of times per day so there's no need
|
||||
// to hit the API every 5 minutes with the weather refresh.
|
||||
const SIX_HOURS_MS = 6 * 60 * 60 * 1000;
|
||||
async function loadAirQuality(loc) {
|
||||
const cacheKey = `sunscope_aq_${loc.lat.toFixed(4)}_${loc.lon.toFixed(4)}`;
|
||||
try {
|
||||
const cached = localStorage.getItem(cacheKey);
|
||||
if (cached) {
|
||||
const { ts, data } = JSON.parse(cached);
|
||||
if (Date.now() - ts < SIX_HOURS_MS) {
|
||||
setAirQuality(data);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (e) { /* ignore bad cache */ }
|
||||
try {
|
||||
const url =
|
||||
`https://air-quality-api.open-meteo.com/v1/air-quality` +
|
||||
`?latitude=${loc.lat}&longitude=${loc.lon}` +
|
||||
`&hourly=european_aqi,` +
|
||||
`grass_pollen,birch_pollen,alder_pollen,` +
|
||||
`mugwort_pollen,olive_pollen,ragweed_pollen` +
|
||||
`&timezone=auto&forecast_days=5`;
|
||||
const r = await fetch(url);
|
||||
if (!r.ok) return; // silently fail — these columns just show '—'
|
||||
const data = await r.json();
|
||||
setAirQuality(data);
|
||||
try {
|
||||
localStorage.setItem(cacheKey, JSON.stringify({ ts: Date.now(), data }));
|
||||
} catch (e) { /* ignore storage errors */ }
|
||||
} catch (_) { /* silently ignore */ }
|
||||
}
|
||||
|
||||
// ─── 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.
|
||||
@@ -720,12 +780,22 @@ export function UTCIForecast() {
|
||||
`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,` +
|
||||
`uv_index,precipitation,snowfall,visibility,` +
|
||||
`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 */ }
|
||||
// Air quality: only refetch if cache has expired (6-hour TTL)
|
||||
const cacheKey = `sunscope_aq_${location.lat.toFixed(4)}_${location.lon.toFixed(4)}`;
|
||||
try {
|
||||
const cached = localStorage.getItem(cacheKey);
|
||||
if (cached) {
|
||||
const { ts } = JSON.parse(cached);
|
||||
if (Date.now() - ts < SIX_HOURS_MS) return; // still fresh
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
loadAirQuality(location);
|
||||
}
|
||||
refresh();
|
||||
}, FIVE_MIN);
|
||||
@@ -736,19 +806,27 @@ export function UTCIForecast() {
|
||||
// Take the raw API arrays and stitch them into one object per hour,
|
||||
// calculating UTCI + soak-factor for each row. This is what gets
|
||||
// displayed in the table.
|
||||
//
|
||||
// Open-Meteo with timezone=auto returns local wall-clock strings like
|
||||
// "2026-05-13T14:00" — no Z, no offset suffix. We need two things:
|
||||
// 1. The wall-clock hour for display & day grouping (just slice the string)
|
||||
// 2. The true UTC instant for solarElevationDeg (which uses .getUTC* internally)
|
||||
// Strategy: treat the ISO string as UTC (append Z), which gives a Date whose
|
||||
// UTC hours equal the local wall-clock hour. Then ADD the utc_offset_seconds
|
||||
// to shift it to the real UTC instant. e.g. Brisbane UTC+10: "14:00" local
|
||||
// → parse as UTC 14:00 → add 10h → UTC 00:00 next day? No — subtract.
|
||||
// Brisbane local 14:00 = UTC 04:00, offset = +10h, so UTC = local - offset.
|
||||
// Date.parse("2026-05-13T14:00Z") = ms for UTC 14:00
|
||||
// Subtract offset (+10h = 36000000ms) → UTC 04:00. Correct.
|
||||
// "2026-05-13T14:00" — no Z suffix. We use two forms:
|
||||
// • String slices (iso.slice(...)) for display & day grouping
|
||||
// • A true UTC Date for solarElevationDeg (see per-row comment below)
|
||||
const utcOffsetMs = (forecast?.utc_offset_seconds ?? 0) * 1000;
|
||||
|
||||
// Build a fast lookup map from the air quality hourly data: ISO string → index.
|
||||
// Normalise to "YYYY-MM-DDTHH" (13 chars) so forecast timestamps like
|
||||
// "2026-05-17T14:00" match AQ timestamps like "2026-05-17T14".
|
||||
const aqTimeMap = {};
|
||||
if (airQuality?.hourly?.time) {
|
||||
airQuality.hourly.time.forEach((t, i) => { aqTimeMap[t.slice(0, 13)] = i; });
|
||||
}
|
||||
const getAq = (field, iso) => {
|
||||
if (!airQuality?.hourly?.[field]) return null;
|
||||
const i = aqTimeMap[iso.slice(0, 13)];
|
||||
if (i === undefined) return null;
|
||||
return airQuality.hourly[field][i] ?? null;
|
||||
};
|
||||
|
||||
const hourlyRows = forecast ? forecast.hourly.time.map((iso, i) => {
|
||||
const h = forecast.hourly;
|
||||
const Ta = h.temperature_2m[i];
|
||||
@@ -776,19 +854,28 @@ export function UTCIForecast() {
|
||||
// For solarElevationDeg (which uses .getUTC* internally) we need the
|
||||
// true UTC instant: treat the local time as UTC then subtract the offset.
|
||||
// e.g. Brisbane UTC+10: local 14:00 → parse as UTC 14:00 → subtract 10h → UTC 04:00 ✓
|
||||
const dtUTC = new Date(Date.parse(iso + 'Z') - utcOffsetMs);
|
||||
// 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, vehicleType, vehicleVent);
|
||||
const eh = vaporPressureHpa(Ta, RH);
|
||||
const Tmrt = calcTmrt(Ta, dir, dif, glob, elev);
|
||||
const utci = utciApprox(Ta, Tmrt, va, eh);
|
||||
// True UTC instant: treat the local wall-clock string as UTC, then
|
||||
// subtract the offset. e.g. Brisbane UTC+10, local 14:00 → UTC 04:00.
|
||||
const dt = new Date(Date.parse(iso + 'Z') - utcOffsetMs);
|
||||
const elev = solarElevationDeg(location.lat, location.lon, dt);
|
||||
const vehicleT = calcVehicleInteriorTemp(Ta, glob, elev, vehicleType, vehicleVent);
|
||||
const eh = vaporPressureHpa(Ta, RH);
|
||||
const Tmrt = calcTmrt(Ta, dir, dif, glob, elev);
|
||||
const utci = utciApprox(Ta, Tmrt, va, eh);
|
||||
const utciAdj = utci + precipPenalty(precip, snow, va);
|
||||
// Derived
|
||||
const compass = windCompass8(wd);
|
||||
const { uvA, uvB } = uvSplit(uv, elev);
|
||||
const cloudCat = cloudCategory(cc, ccLow, ccMid, ccHigh);
|
||||
// Visibility from the main forecast API (metres → km).
|
||||
const visKm = (() => { const v = h.visibility ? h.visibility[i] : null; return v != null ? v / 1000 : null; })();
|
||||
const aqi = getAq('european_aqi', iso);
|
||||
const grassPollen = getAq('grass_pollen', iso);
|
||||
const birchPollen = getAq('birch_pollen', iso);
|
||||
const alderPollen = getAq('alder_pollen', iso);
|
||||
const mugwortPollen= getAq('mugwort_pollen', iso);
|
||||
const olivePollen = getAq('olive_pollen', iso);
|
||||
const ragweedPollen= getAq('ragweed_pollen', iso);
|
||||
return {
|
||||
iso, dt, Ta, RH, dew, va, wd, gust, dir, dif, glob,
|
||||
cc, ccLow, ccMid, ccHigh, cloudCat,
|
||||
@@ -796,6 +883,8 @@ export function UTCIForecast() {
|
||||
precip, snow,
|
||||
soilT0, soilT6, soilM, concreteT, vehicleT,
|
||||
elev, Tmrt, utci, utciAdj, eh, compass,
|
||||
visKm, aqi,
|
||||
grassPollen, birchPollen, alderPollen, mugwortPollen, olivePollen, ragweedPollen,
|
||||
};
|
||||
}) : [];
|
||||
|
||||
@@ -921,7 +1010,6 @@ export function UTCIForecast() {
|
||||
return html`
|
||||
<div class="event-banner"
|
||||
style=${{ background: ev.color, color: ev.textColor }}
|
||||
onMouseEnter=${() => {}}
|
||||
>
|
||||
<span class="event-banner-emoji">${ev.emoji}</span>
|
||||
<div class="event-banner-body">
|
||||
@@ -1061,6 +1149,26 @@ export function UTCIForecast() {
|
||||
const utcipVals = d.rows.map(r => r.utciAdj).filter(v => isFinite(v));
|
||||
const dayHi = utcipVals.length ? Math.round(Math.max(...utcipVals)) : null;
|
||||
const dayLo = utcipVals.length ? Math.round(Math.min(...utcipVals)) : null;
|
||||
|
||||
// ── Day-tab weather icon ───────────────────────────────────
|
||||
// Use daytime rows (elev > 0) where available, else all rows.
|
||||
// Pick the modal cloud category and sum precip/snow to decide
|
||||
// whether to show a PrecipIcon or a CloudIcon.
|
||||
const dayRows = d.rows.filter(r => r.elev > 0);
|
||||
const repRows = dayRows.length > 0 ? dayRows : d.rows;
|
||||
const dayPrecip = repRows.reduce((s, r) => s + (r.precip || 0), 0);
|
||||
const daySnow = repRows.reduce((s, r) => s + (r.snow || 0), 0);
|
||||
// Modal cloud category (most frequent among daytime hours)
|
||||
const catCounts = {};
|
||||
repRows.forEach(r => { if (r.cloudCat) catCounts[r.cloudCat] = (catCounts[r.cloudCat] || 0) + 1; });
|
||||
const modalCloudCat = Object.keys(catCounts).sort((a, b) => catCounts[b] - catCounts[a])[0] || 'clear';
|
||||
// Representative solar elevation: midday row or median of daytime rows
|
||||
const midRow = repRows[Math.floor(repRows.length / 2)];
|
||||
const repElev = midRow ? midRow.elev : 45;
|
||||
const repDt = midRow ? midRow.dt : dDate;
|
||||
// Show PrecipIcon when total daytime precip ≥ 0.3 mm or snow ≥ 0.1 cm
|
||||
const showPrecip = dayPrecip >= 0.3 || daySnow >= 0.1;
|
||||
|
||||
return html`
|
||||
<button
|
||||
key=${d.key}
|
||||
@@ -1095,13 +1203,18 @@ export function UTCIForecast() {
|
||||
<span class="utci-day-date" style=${{ color: '#5a3f24' }}>
|
||||
${dDate.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', timeZone: 'UTC' })}
|
||||
</span>
|
||||
<span style=${{ display: 'block', margin: '3px auto 0', lineHeight: 1 }}>
|
||||
${showPrecip
|
||||
? html`<${PrecipIcon} precip=${dayPrecip} snow=${daySnow} size=${22} />`
|
||||
: html`<${CloudIcon} category=${modalCloudCat} elev=${repElev} dt=${repDt} size=${22} />`}
|
||||
</span>
|
||||
${dayHi !== null && html`
|
||||
<span style=${{
|
||||
display: 'block',
|
||||
fontFamily: 'Fraunces, serif',
|
||||
fontStyle: 'italic',
|
||||
fontSize: '11px',
|
||||
marginTop: '3px',
|
||||
marginTop: '2px',
|
||||
letterSpacing: 0,
|
||||
textTransform: 'none',
|
||||
}}>
|
||||
@@ -1209,8 +1322,8 @@ export function UTCIForecast() {
|
||||
}}>
|
||||
<a
|
||||
href="https://buy.stripe.com/9B63cw7vl8k15Ei9DQd7q00"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style=${{
|
||||
display: 'inline-block',
|
||||
padding: '10px 18px',
|
||||
@@ -1340,7 +1453,7 @@ export function UTCIForecast() {
|
||||
}}
|
||||
>${profile.icon} ${profile.label}${locked ? ' 🔒' : ''}</button>`;
|
||||
})}
|
||||
<span class="profile-divider" aria-hidden="true"></span>
|
||||
<span class="profile-divider" aria-hidden="true"></span>
|
||||
<${CustomSelect}
|
||||
key="activities"
|
||||
value=${activityValue}
|
||||
@@ -1439,6 +1552,26 @@ ${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['a
|
||||
${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['wind']) && html`<button class=${`col-toggle${visibleCols.wind ? ' on' : ''}`} onClick=${() => toggleCol('wind')}>Wind</button>`}
|
||||
${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['dir']) && html`<button class=${`col-toggle${visibleCols.dir ? ' on' : ''}`} onClick=${() => toggleCol('dir')}>Dir</button>`}
|
||||
${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['cloud']) && html`<button class=${`col-toggle${visibleCols.cloud ? ' on' : ''}`} onClick=${() => toggleCol('cloud')}>Cloud</button>`}
|
||||
<button class=${`col-toggle${visibleCols.vis ? ' on' : ''}`} onClick=${() => toggleCol('vis')}>Visibility</button>
|
||||
<button class=${`col-toggle${visibleCols.aqi ? ' on' : ''}`} onClick=${() => toggleCol('aqi')}>Air Quality</button>
|
||||
<${CustomSelect}
|
||||
value=${pollenType}
|
||||
isOn=${visibleCols.pollen}
|
||||
hideLabel="Pollen"
|
||||
hidingLabel="Hide Pollen"
|
||||
options=${Object.entries(POLLEN_TYPES).flatMap(([k, v], i) => [
|
||||
{ value: k, label: v.name },
|
||||
...(i === 0 ? [{ value: '_div', divider: true }] : []),
|
||||
])}
|
||||
onChange=${(v) => {
|
||||
if (v === 'off') {
|
||||
setVisibleCols(prev => ({ ...prev, pollen: false }));
|
||||
} else {
|
||||
setPollenTypeAndSave(v);
|
||||
setVisibleCols(prev => ({ ...prev, pollen: true }));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['sun']) && html`<button class=${`col-toggle${visibleCols.sun ? ' on' : ''}`} onClick=${() => toggleCol('sun')}>Sun</button>`}
|
||||
${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['direct']) && html`<button class=${`col-toggle${visibleCols.direct ? ' on' : ''}`} onClick=${() => toggleCol('direct')}>Direct</button>`}
|
||||
${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['diffuse']) && html`<button class=${`col-toggle${visibleCols.diffuse ? ' on' : ''}`} onClick=${() => toggleCol('diffuse')}>Diffuse</button>`}
|
||||
@@ -1565,6 +1698,9 @@ ${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['a
|
||||
${visibleCols.wind && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('wind', e)} onMouseEnter=${(e) => handleThEnter('wind', e)} onMouseLeave=${handleThLeave}>Wind <span class="col-unit">m/s (gust)</span></th>`}
|
||||
${visibleCols.dir && html`<th class="utci-dir-cell col-info-th" scope="col" onClick=${(e) => handleThClick('dir', e)} onMouseEnter=${(e) => handleThEnter('dir', e)} onMouseLeave=${handleThLeave}>Dir <span class="col-unit">-</span></th>`}
|
||||
${visibleCols.cloud && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('cloud', e)} onMouseEnter=${(e) => handleThEnter('cloud', e)} onMouseLeave=${handleThLeave}>Cloud <span class="col-unit">%</span></th>`}
|
||||
${visibleCols.vis && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('vis', e)} onMouseEnter=${(e) => handleThEnter('vis', e)} onMouseLeave=${handleThLeave}>Visibility <span class="col-unit">km</span></th>`}
|
||||
${visibleCols.aqi && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('aqi', e)} onMouseEnter=${(e) => handleThEnter('aqi', e)} onMouseLeave=${handleThLeave}>AQI <span class="col-unit">EU idx</span></th>`}
|
||||
${visibleCols.pollen && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('pollen', e)} onMouseEnter=${(e) => handleThEnter('pollen', e)} onMouseLeave=${handleThLeave}>${pollenType === 'all_pollen' ? 'Pollen' : POLLEN_TYPES[pollenType]?.name.replace(' pollen','') ?? 'Pollen'} <span class="col-unit">grains/m³</span></th>`}
|
||||
${visibleCols.sun && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('sun', e)} onMouseEnter=${(e) => handleThEnter('sun', e)} onMouseLeave=${handleThLeave}>Sun <span class="col-unit">elev°</span></th>`}
|
||||
${visibleCols.direct && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('direct', e)} onMouseEnter=${(e) => handleThEnter('direct', e)} onMouseLeave=${handleThLeave}>Direct <span class="col-unit">W/m²</span></th>`}
|
||||
${visibleCols.diffuse && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('diffuse', e)} onMouseEnter=${(e) => handleThEnter('diffuse', e)} onMouseLeave=${handleThLeave}>Diffuse <span class="col-unit">W/m²</span></th>`}
|
||||
@@ -1700,6 +1836,61 @@ ${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['a
|
||||
<span>${Math.round(r.cc)}</span>
|
||||
</span>
|
||||
</td>`}
|
||||
${visibleCols.vis && (() => {
|
||||
const v = r.visKm;
|
||||
const bg = v == null ? 'transparent'
|
||||
: v < 1 ? 'rgba(180,80,80,0.18)'
|
||||
: v < 4 ? 'rgba(210,140,50,0.15)'
|
||||
: v < 10 ? 'rgba(200,190,80,0.12)'
|
||||
: 'transparent';
|
||||
const color = v != null && v < 4 ? '#8a3a1a' : '#4a3218';
|
||||
return html`<td style=${{ background: bg, color }}>
|
||||
${v != null ? (v < 10 ? v.toFixed(1) : Math.round(v)) : '—'}
|
||||
</td>`;
|
||||
})()}
|
||||
${visibleCols.aqi && (() => {
|
||||
const v = r.aqi;
|
||||
const bg = v == null ? 'transparent'
|
||||
: v < 20 ? 'rgba(80,180,100,0.15)'
|
||||
: v < 40 ? 'rgba(140,200,100,0.13)'
|
||||
: v < 60 ? 'rgba(220,200,60,0.15)'
|
||||
: v < 80 ? 'rgba(220,130,50,0.18)'
|
||||
: v < 100 ? 'rgba(200,70,50,0.18)'
|
||||
: 'rgba(160,30,100,0.20)';
|
||||
const color = v != null && v >= 60 ? '#7a2010' : v != null && v >= 40 ? '#7a4a10' : '#2a4a20';
|
||||
const label = v == null ? '—'
|
||||
: v < 20 ? `${v} Good`
|
||||
: v < 40 ? `${v} Fair`
|
||||
: v < 60 ? `${v} Mod`
|
||||
: v < 80 ? `${v} Poor`
|
||||
: v < 100 ? `${v} V.Poor`
|
||||
: `${v} Hazard`;
|
||||
return html`<td style=${{ background: bg, color, fontWeight: v != null && v >= 60 ? 600 : 400 }}>${label}</td>`;
|
||||
})()}
|
||||
${visibleCols.pollen && (() => {
|
||||
const pollenMap = {
|
||||
all_pollen: [r.grassPollen, r.birchPollen, r.alderPollen, r.mugwortPollen, r.olivePollen, r.ragweedPollen].reduce((s, x) => x != null ? s + x : s, null),
|
||||
grass_pollen: r.grassPollen,
|
||||
birch_pollen: r.birchPollen,
|
||||
alder_pollen: r.alderPollen,
|
||||
mugwort_pollen: r.mugwortPollen,
|
||||
olive_pollen: r.olivePollen,
|
||||
ragweed_pollen: r.ragweedPollen,
|
||||
};
|
||||
const v = pollenMap[pollenType] ?? null;
|
||||
const bg = v == null ? 'transparent'
|
||||
: v < 10 ? 'transparent'
|
||||
: v < 50 ? 'rgba(180,200,80,0.13)'
|
||||
: v < 200 ? 'rgba(210,150,50,0.16)'
|
||||
: 'rgba(200,70,50,0.18)';
|
||||
const color = v != null && v >= 200 ? '#8a2010' : v != null && v >= 50 ? '#7a4a10' : '#4a3218';
|
||||
const label = v == null ? '—'
|
||||
: v < 10 ? `${Math.round(v)} Low`
|
||||
: v < 50 ? `${Math.round(v)} Mod`
|
||||
: v < 200 ? `${Math.round(v)} High`
|
||||
: `${Math.round(v)} V.High`;
|
||||
return html`<td style=${{ background: bg, color, fontWeight: v != null && v >= 50 ? 600 : 400 }}>${label}</td>`;
|
||||
})()}
|
||||
${visibleCols.sun && html`<td style=${{ background: scaleBg(r.elev > 0 ? r.elev : null, 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>`}
|
||||
|
||||
Reference in New Issue
Block a user