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>`}
|
||||
|
||||
+41
-22
@@ -1,16 +1,18 @@
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// components.js — Preact UI components (SVG widgets).
|
||||
//
|
||||
// Exports:
|
||||
// SkyScope({ elev, dt, size }) little porthole circle per hour
|
||||
// WindVane({ bearing, size }) compass arrow
|
||||
// CloudIcon({ category, size, elev, dt }) cloud/sun/moon icon
|
||||
// ScopeReticle({ value, cat, loading, elev, dt }) big UTCI dial
|
||||
// PrecipIcon({ precip, snow, size }) rain/snow icon for precip cell
|
||||
// components.js — Preact UI components (SVG widgets and controls).
|
||||
//
|
||||
// All components use the html`` tagged template from htm + Preact.
|
||||
// They depend on utils.js (skyFillForElev, grassFillForElev,
|
||||
// moonPhaseFraction) but have no other external state.
|
||||
// No external state; they depend only on utils.js and events.js.
|
||||
//
|
||||
// Exports (in order of appearance):
|
||||
// CustomSelect({ value, options, onChange, ... }) styled dropdown pill
|
||||
// VentPill({ checked, onChange, label, title }) checkbox fused to pill
|
||||
// SkyScope({ elev, dt, glob, size }) small porthole per hour
|
||||
// WindVane({ bearing, size }) compass-arrow widget
|
||||
// CloudIcon({ category, size, elev, dt }) cloud/sun glyph
|
||||
// ScopeReticle({ value, cat, loading, elev, dt, glob, activeEvent })
|
||||
// large UTCI dial
|
||||
// PrecipIcon({ precip, snow, size }) rain/snow icon
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
import { h, Fragment } from '../vendor/preact.js';
|
||||
@@ -91,8 +93,9 @@ export function CustomSelect({ value, options, onChange, hideLabel, hidingLabel,
|
||||
>${hidingLabel || ('Hide ' + hideLabel)}</span>
|
||||
<div class="cs-divider"></div>
|
||||
`}
|
||||
${options.map(opt => html`
|
||||
<span
|
||||
${options.map(opt => opt.divider
|
||||
? html`<div key=${opt.value} class="cs-divider"></div>`
|
||||
: html`<span
|
||||
key=${opt.value}
|
||||
class=${`cs-option${opt.value === value && isOn ? ' selected' : ''}${opt.disabled ? ' disabled' : ''}`}
|
||||
role="option"
|
||||
@@ -101,8 +104,8 @@ export function CustomSelect({ value, options, onChange, hideLabel, hidingLabel,
|
||||
onChange(opt.value);
|
||||
setOpen(false);
|
||||
}}
|
||||
>${opt.label}</span>
|
||||
`)}
|
||||
>${opt.label}</span>`
|
||||
)}
|
||||
</div>
|
||||
`}
|
||||
</span>`;
|
||||
@@ -342,23 +345,39 @@ export function CloudIcon({ category, size = 30, elev = 90, dt = new Date() }) {
|
||||
${line(size * 0.30, y0 + size * 0.18, size * 0.92, y0 + size * 0.18, ink, sw * 0.82, 0.72)}
|
||||
</g>`;
|
||||
|
||||
// Sun shape — circle + 8 rays, centred at (cx, cy)
|
||||
const sun = (cx, cy, cr) => {
|
||||
const rays = Array.from({ length: 8 }, (_, k) => {
|
||||
const angle = (k * Math.PI) / 4;
|
||||
const r1 = cr * 1.55;
|
||||
const r2 = cr * 2.1;
|
||||
return html`<line key=${k}
|
||||
x1=${cx + Math.cos(angle) * r1} y1=${cy + Math.sin(angle) * r1}
|
||||
x2=${cx + Math.cos(angle) * r2} y2=${cy + Math.sin(angle) * r2}
|
||||
stroke=${brass} stroke-width=${sw * 0.85} stroke-linecap="round" opacity="0.9" />`;
|
||||
});
|
||||
return html`<g>
|
||||
${rays}
|
||||
<circle cx=${cx} cy=${cy} r=${cr} fill="none" stroke=${brass} stroke-width=${sw} opacity="0.95" />
|
||||
</g>`;
|
||||
};
|
||||
|
||||
return html`
|
||||
<svg width=${size} height=${size} viewBox=${`0 0 ${size} ${size}`}
|
||||
style=${{ flexShrink: 0, display: 'inline-block', verticalAlign: 'middle', overflow: 'visible' }}>
|
||||
${category === 'clear' && html`
|
||||
${line(size * 0.26, size * 0.50, size * 0.74, size * 0.50, brass, sw, 0.9)}
|
||||
${line(size * 0.38, size * 0.64, size * 0.62, size * 0.64, muted, sw * 0.75, 0.7)}`}
|
||||
${sun(size * 0.50, size * 0.38, size * 0.16)}`}
|
||||
${category === 'wispy' && html`
|
||||
${mist(size * 0.38, brass)}
|
||||
${line(size * 0.18, size * 0.78, size * 0.60, size * 0.78, muted, sw * 0.7, 0.6)}`}
|
||||
${mist(size * 0.18, brass)}
|
||||
${line(size * 0.18, size * 0.56, size * 0.60, size * 0.56, muted, sw * 0.7, 0.6)}`}
|
||||
${category === 'scattered' && html`
|
||||
<path d=${cloudPath(0, 0, 0.82)} fill="none" stroke=${ink}
|
||||
<path d=${cloudPath(0, -size * 0.2425, 0.82)} fill="none" stroke=${ink}
|
||||
stroke-width=${sw} stroke-linecap=${cap} stroke-linejoin=${join} />
|
||||
${line(size * 0.20, size * 0.78, size * 0.48, size * 0.78, brass, sw * 0.75, 0.78)}`}
|
||||
${line(size * 0.20, size * 0.58, size * 0.48, size * 0.58, brass, sw * 0.75, 0.78)}`}
|
||||
${category === 'overcast' && html`
|
||||
<path d=${cloudPath(-size * 0.02, -size * 0.10, 0.70)} fill="none" stroke=${muted}
|
||||
<path d=${cloudPath(-size * 0.02, -size * 0.2275, 0.70)} fill="none" stroke=${muted}
|
||||
stroke-width=${sw * 0.9} stroke-linecap=${cap} stroke-linejoin=${join} opacity="0.82" />
|
||||
<path d=${cloudPath(0, size * 0.14, 0.88)} fill="none" stroke=${ink}
|
||||
<path d=${cloudPath(0, -size * 0.25, 0.88)} fill="none" stroke=${ink}
|
||||
stroke-width=${sw} stroke-linecap=${cap} stroke-linejoin=${join} />`}
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
+55
-26
@@ -193,7 +193,7 @@ const COSMIC_CALENDAR = [
|
||||
// ── 2027 METEOR SHOWERS ─────────────────────────────────────────────
|
||||
{
|
||||
id: 'quadrantids-2027',
|
||||
emoji: String.fromCodePoint(0x2604, 0xFE0F),
|
||||
emoji: '☄️',
|
||||
title: 'Quadrantid Meteor Shower',
|
||||
message: 'The Quadrantid meteor shower is active — up to 120 meteors/hour at peak. Best in the hours before dawn on 4 Jan from a dark site.',
|
||||
color: '#2a1a5a', textColor: '#e8d8ff',
|
||||
@@ -202,7 +202,7 @@ const COSMIC_CALENDAR = [
|
||||
},
|
||||
{
|
||||
id: 'lyrids-2027',
|
||||
emoji: String.fromCodePoint(0x2604, 0xFE0F),
|
||||
emoji: '☄️',
|
||||
title: 'Lyrid Meteor Shower',
|
||||
message: 'The Lyrid meteor shower peaks — up to 20 meteors/hour after midnight. Note: bright waning gibbous moon may reduce visibility this year.',
|
||||
color: '#2a1a5a', textColor: '#e8d8ff',
|
||||
@@ -211,7 +211,7 @@ const COSMIC_CALENDAR = [
|
||||
},
|
||||
{
|
||||
id: 'eta-aquariids-2027',
|
||||
emoji: String.fromCodePoint(0x2604, 0xFE0F),
|
||||
emoji: '☄️',
|
||||
title: 'Eta Aquariid Meteor Shower',
|
||||
message: 'The Eta Aquariids peak — fragments of Halley\'s Comet, up to 50/hour before dawn. New moon on 6 May means excellent dark skies this year.',
|
||||
color: '#2a1a5a', textColor: '#e8d8ff',
|
||||
@@ -220,7 +220,7 @@ const COSMIC_CALENDAR = [
|
||||
},
|
||||
{
|
||||
id: 'perseids-2027',
|
||||
emoji: String.fromCodePoint(0x2604, 0xFE0F),
|
||||
emoji: '☄️',
|
||||
title: 'Perseid Meteor Shower',
|
||||
message: 'The Perseids are active — one of the best showers of the year, up to 100 meteors/hour at peak around 13 Aug.',
|
||||
color: '#3a1a00', textColor: '#ffe8c0',
|
||||
@@ -229,7 +229,7 @@ const COSMIC_CALENDAR = [
|
||||
},
|
||||
{
|
||||
id: 'orionids-2027',
|
||||
emoji: String.fromCodePoint(0x2604, 0xFE0F),
|
||||
emoji: '☄️',
|
||||
title: 'Orionid Meteor Shower',
|
||||
message: 'The Orionid shower peaks — fast, bright meteors from Halley\'s Comet debris. Up to 25/hour.',
|
||||
color: '#2a1a5a', textColor: '#e8d8ff',
|
||||
@@ -238,7 +238,7 @@ const COSMIC_CALENDAR = [
|
||||
},
|
||||
{
|
||||
id: 'leonids-2027',
|
||||
emoji: String.fromCodePoint(0x2604, 0xFE0F),
|
||||
emoji: '☄️',
|
||||
title: 'Leonid Meteor Shower',
|
||||
message: 'The Leonid shower peaks — fast meteors from comet Tempel-Tuttle, up to 15/hour.',
|
||||
color: '#2a1a5a', textColor: '#e8d8ff',
|
||||
@@ -247,7 +247,7 @@ const COSMIC_CALENDAR = [
|
||||
},
|
||||
{
|
||||
id: 'geminids-2027',
|
||||
emoji: String.fromCodePoint(0x2604, 0xFE0F),
|
||||
emoji: '☄️',
|
||||
title: 'Geminid Meteor Shower',
|
||||
message: 'The Geminids peak — the finest shower of the year, up to 150 meteors/hour, visible even before midnight.',
|
||||
color: '#3a1a00', textColor: '#ffe8c0',
|
||||
@@ -256,7 +256,7 @@ const COSMIC_CALENDAR = [
|
||||
},
|
||||
{
|
||||
id: 'ursids-2027',
|
||||
emoji: String.fromCodePoint(0x2604, 0xFE0F),
|
||||
emoji: '☄️',
|
||||
title: 'Ursid Meteor Shower',
|
||||
message: 'The Ursid shower peaks near the winter solstice — circumpolar, best from northern latitudes.',
|
||||
color: '#2a1a5a', textColor: '#e8d8ff',
|
||||
@@ -267,7 +267,7 @@ const COSMIC_CALENDAR = [
|
||||
// ── 2027 ECLIPSES ───────────────────────────────────────────────────
|
||||
{
|
||||
id: 'annular-solar-eclipse-2027-feb',
|
||||
emoji: String.fromCodePoint(0x1F311),
|
||||
emoji: '🌑',
|
||||
title: 'Annular Solar Eclipse',
|
||||
message: 'An annular solar eclipse creates a "ring of fire" effect — visible across parts of South America, Africa and the Indian Ocean.',
|
||||
color: '#1a0a2a', textColor: '#d8c8f8',
|
||||
@@ -276,7 +276,7 @@ const COSMIC_CALENDAR = [
|
||||
},
|
||||
{
|
||||
id: 'total-solar-eclipse-2027-aug',
|
||||
emoji: String.fromCodePoint(0x1F311),
|
||||
emoji: '🌑',
|
||||
title: 'Total Solar Eclipse',
|
||||
message: 'A spectacular total solar eclipse — the path of totality crosses Morocco, Spain, Algeria, Libya, Egypt and Saudi Arabia. One of the longest totalities of the century.',
|
||||
color: '#1a0a2a', textColor: '#d8c8f8',
|
||||
@@ -287,7 +287,7 @@ const COSMIC_CALENDAR = [
|
||||
// ── 2027 PLANETARY EVENTS ───────────────────────────────────────────
|
||||
{
|
||||
id: 'mars-opposition-2027',
|
||||
emoji: String.fromCodePoint(0x1F534),
|
||||
emoji: '🔴',
|
||||
title: 'Mars at Opposition',
|
||||
message: 'Mars is at its closest and brightest — a vivid red beacon visible all night long. Good binoculars will reveal its surface colour.',
|
||||
color: '#2a1520', textColor: '#ffc8d8',
|
||||
@@ -296,21 +296,23 @@ const COSMIC_CALENDAR = [
|
||||
},
|
||||
{
|
||||
id: 'venus-jupiter-conjunction-2027',
|
||||
emoji: String.fromCodePoint(0x1FA90),
|
||||
emoji: '🪐',
|
||||
title: 'Venus & Jupiter Conjunction',
|
||||
message: 'Venus and Jupiter appear strikingly close together in the evening sky — a dazzling naked-eye pairing of the two brightest planets.',
|
||||
color: '#1a2a3a', textColor: '#c8e8ff',
|
||||
type: 'cosmic', nightOnly: true,
|
||||
start: '2027-08-23', end: '2027-08-27', peak: '2027-08-25',
|
||||
},
|
||||
|
||||
|
||||
];
|
||||
|
||||
// ─── WEATHER-DERIVED EVENTS ───────────────────────────────────────────────
|
||||
// These are computed from the actual forecast data.
|
||||
// Each checker receives (rows, location) and returns an event object or null.
|
||||
// rows = today's hourlyRows array. location = { lat, lon, name, country }.
|
||||
// Computed in real-time from the forecast data.
|
||||
// Each checker function receives (rows [, location]) and returns an event
|
||||
// object or null. rows = today's hourlyRows array.
|
||||
// location = { lat, lon, name, country }.
|
||||
//
|
||||
// To add a new weather event: write a checkXxx(rows, location) function
|
||||
// below and add it to the checks[] array inside getActiveEvents().
|
||||
|
||||
function checkStargazing(rows) {
|
||||
// Great stargazing: mostly clear night hours with low cloud
|
||||
@@ -748,6 +750,33 @@ export function getCellTagEvents(events, row) {
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ─── DYNAMIC MESSAGE GENERATOR ───────────────────────────────────────────
|
||||
// Rewrites a cosmic event's message based on where today sits vs. the peak.
|
||||
// Before peak : "is active and building — peak on <date>. <detail>"
|
||||
// On peak ±1d : "peaks tonight — <detail>"
|
||||
// After peak : "is past its peak (<date>) but still possibly visible — <detail>"
|
||||
// Single-day events (start === end) keep their static message unchanged.
|
||||
|
||||
function dynamicCosmicMessage(ev, dateStr) {
|
||||
if (!ev.peak || ev.start === ev.end) return ev.message;
|
||||
var today = new Date(dateStr + 'T00:00Z');
|
||||
var peak = new Date(ev.peak + 'T00:00Z');
|
||||
var diffDays = Math.round((today - peak) / 86400000);
|
||||
var peakFmt = peak.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', timeZone: 'UTC' });
|
||||
var base = ev.message
|
||||
.replace(/^.*?(?:peaks tonight|peak tonight|is active tonight|is active|peaks)\s*—\s*/i, '')
|
||||
.trim();
|
||||
var baseCapd = base.charAt(0).toUpperCase() + base.slice(1);
|
||||
if (diffDays < -1) {
|
||||
return ev.title + ' is active and building — peak on ' + peakFmt + '. ' + baseCapd;
|
||||
} else if (diffDays <= 1) {
|
||||
return ev.title + ' peaks tonight — ' + base;
|
||||
} else {
|
||||
return ev.title + ' is past its peak (' + peakFmt + ') but still possibly visible — ' + base;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── MAIN EXPORT ─────────────────────────────────────────────────────────
|
||||
// Returns ALL active events for the given rows/date as an array.
|
||||
// Empty array = nothing active.
|
||||
@@ -765,7 +794,9 @@ export function getActiveEvents(rows, location) {
|
||||
const dateStr = (rows && rows.length > 0)
|
||||
? rows[0].iso.slice(0, 10)
|
||||
: new Date().toISOString().slice(0, 10);
|
||||
const cosmicHits = COSMIC_CALENDAR.filter(ev => dateStr >= ev.start && dateStr <= ev.end);
|
||||
const cosmicHits = COSMIC_CALENDAR
|
||||
.filter(ev => dateStr >= ev.start && dateStr <= ev.end)
|
||||
.map(ev => Object.assign({}, ev, { message: dynamicCosmicMessage(ev, dateStr) }));
|
||||
|
||||
// 3. Weather-derived events (all that match, not just first)
|
||||
const weatherEvents = [];
|
||||
@@ -802,16 +833,14 @@ export function getLensEvent(events) {
|
||||
}
|
||||
|
||||
// ─── LENS OVERLAY RENDERER ───────────────────────────────────────────────
|
||||
// Returns SVG group content for the event overlay in the big ScopeReticle.
|
||||
// Rendered INSIDE the lens clip and BELOW the glass shine/shade layers.
|
||||
// cx, cy = lens centre; lensR = lens radius.
|
||||
|
||||
|
||||
// Returns an SVG string for the event overlay inside the big ScopeReticle.
|
||||
// Rendered INSIDE the lens clip path, BELOW the glass shine/shade layers.
|
||||
// cx, cy = lens centre coords; lensR = lens radius.
|
||||
export function getLensOverlaySVG(event, cx, cy, lensR) {
|
||||
if (!event) return null;
|
||||
const id = event.id;
|
||||
|
||||
if (event.emoji === String.fromCodePoint(0x2604, 0xFE0F)) {
|
||||
if (event.emoji === '☄️') {
|
||||
return [
|
||||
'<g opacity="0.55" clip-path="url(#scope-lens-clip)">',
|
||||
'<line x1="' + (cx-60) + '" y1="' + (cy-50) + '" x2="' + (cx-20) + '" y2="' + (cy-20) + '" stroke="#ffe8a0" stroke-width="1.5" stroke-linecap="round" opacity="0.9"/>',
|
||||
@@ -845,13 +874,13 @@ export function getLensOverlaySVG(event, cx, cy, lensR) {
|
||||
+ '<circle cx="' + cx_ + '" cy="' + cy_ + '" r="16" fill="none" stroke="rgba(220,60,20,0.5)" stroke-width="1.5" /></g>';
|
||||
}
|
||||
|
||||
if (event.emoji === String.fromCodePoint(0x1FA90)) {
|
||||
if (event.emoji === '🪐' && event.id.includes('conjunction')) {
|
||||
return '<g clip-path="url(#scope-lens-clip)" opacity="0.65">'
|
||||
+ '<circle cx="' + (cx+20) + '" cy="' + (cy-35) + '" r="10" fill="rgba(210,185,130,0.7)" />'
|
||||
+ '<ellipse cx="' + (cx+20) + '" cy="' + (cy-35) + '" rx="18" ry="4.5" fill="none" stroke="rgba(200,170,100,0.65)" stroke-width="2.5" /></g>';
|
||||
}
|
||||
|
||||
if (event.emoji === String.fromCodePoint(0x1F534)) {
|
||||
if (event.emoji === '🔴') {
|
||||
return '<g clip-path="url(#scope-lens-clip)" opacity="0.6">'
|
||||
+ '<circle cx="' + (cx+30) + '" cy="' + (cy-30) + '" r="7" fill="rgba(200,80,40,0.75)" />'
|
||||
+ '<circle cx="' + (cx-20) + '" cy="' + (cy-45) + '" r="5" fill="rgba(220,160,80,0.7)" /></g>';
|
||||
|
||||
+40
-31
@@ -1,18 +1,19 @@
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// physics.js — Physical constants and meteorological calculations.
|
||||
//
|
||||
// Exports:
|
||||
// SIGMA, EPSILON_P, A_K, ALBEDO_GRASS (radiation constants)
|
||||
// vaporPressureHpa(Ta, RH) Magnus formula → hPa
|
||||
// solarElevationDeg(lat, lon, dateUTC) NOAA simplified solar position
|
||||
// calcTmrt(Ta, dirRad, diffRad, globalRad, solElev) Mean radiant temp
|
||||
// utciApprox(Ta, Tmrt, va10, ehPa) Bröde et al. 2012 polynomial
|
||||
// calcVehicleInteriorTemp(Ta, globalRad, solElev, vehicleType, ventilated)
|
||||
// calcIndoorTempPass(TaArr, globArr, elevArr, buildingType)
|
||||
// calcManagedIndoorTempPass(TaArr, globArr, elevArr, buildingType)
|
||||
// All numbers are peer-reviewed constants or coefficients. Nothing in
|
||||
// here should need editing unless the underlying science changes.
|
||||
//
|
||||
// Nothing in here should need editing unless the underlying science
|
||||
// changes. All numbers are peer-reviewed constants or coefficients.
|
||||
// Exports (in order of appearance):
|
||||
// SIGMA, EPSILON_P, A_K, ALBEDO_GRASS, ALBEDO_CONCRETE (radiation constants)
|
||||
// calcConcreteTemp(Ta, globalRad, windSpeed) urban surface temp
|
||||
// calcIndoorTempPass(TaArr, globArr, elevArr, buildingType) passive indoor temp
|
||||
// calcManagedIndoorTempPass(TaArr, globArr, elevArr, buildingType) managed indoor temp
|
||||
// calcVehicleInteriorTemp(Ta, globalRad, solElev, vehicleType, ventilated)
|
||||
// vaporPressureHpa(Ta, RH) Magnus formula → hPa
|
||||
// solarElevationDeg(lat, lon, dateUTC) NOAA simplified solar position (°)
|
||||
// calcTmrt(Ta, dirRad, diffRad, globalRad, solElev) Mean radiant temperature
|
||||
// utciApprox(Ta, Tmrt, va10, ehPa) Bröde et al. 2012 UTCI polynomial
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
import { VEHICLE_TYPES, BUILDING_TYPES } from './utils.js';
|
||||
@@ -104,23 +105,6 @@ export function calcConcreteTemp(Ta, globalRad, windSpeed) {
|
||||
// > 32 °C — hot; risk for elderly and vulnerable occupants
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
// Single-hour instantaneous heat load (W/m² effective, indoor side).
|
||||
// Used internally by calcIndoorTempPass — not exported.
|
||||
function _houseHeatLoad(Ta, globalRad, solElev) {
|
||||
// Wall + roof conduction: U-value ~0.35 W/m²K × effective envelope area ratio
|
||||
// We express as a gain-per-degree-delta — applied against Ti later in the pass.
|
||||
// (See calcIndoorTempPass for how this feeds the lag model.)
|
||||
|
||||
// Window solar gain: glazing ratio 0.16, g-value 0.63 (standard double glazing),
|
||||
// averaged across orientations (0.5 factor — not all windows face the sun).
|
||||
const glazingRatio = 0.16;
|
||||
const gValue = 0.63;
|
||||
const orientFactor = 0.50;
|
||||
const solarGain = globalRad * glazingRatio * gValue * orientFactor;
|
||||
|
||||
return { conductionDelta: Ta, solarGain };
|
||||
}
|
||||
|
||||
// Two-pass function: call with the full arrays of hourly Ta and globalRad.
|
||||
// Returns an array of indoor temperatures, one per hour.
|
||||
// buildingType must be a key of BUILDING_TYPES; defaults to 'brick'.
|
||||
@@ -332,13 +316,24 @@ export function calcVehicleInteriorTemp(Ta, globalRad, solElev, vehicleType = 'c
|
||||
// for road-trip planning, dog-in-car safety at a rest stop vs. motorway, etc.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Vapour pressure (Magnus → hPa)
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// VAPOUR PRESSURE — Magnus formula.
|
||||
// ───────────────────────────────────────────────────────────────────
|
||||
// Converts air temperature (°C) and relative humidity (%) to vapour
|
||||
// pressure in hPa. Used as the humidity input to utciApprox().
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
export function vaporPressureHpa(Ta, RH) {
|
||||
const es = 6.105 * Math.exp((17.27 * Ta) / (237.7 + Ta));
|
||||
return es * (RH / 100);
|
||||
}
|
||||
|
||||
// Solar elevation (NOAA simplified, degrees)
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// SOLAR ELEVATION — NOAA simplified algorithm (degrees above horizon).
|
||||
// ───────────────────────────────────────────────────────────────────
|
||||
// Accurate to within ~0.01° for most practical purposes. Returns a
|
||||
// negative value when the sun is below the horizon (civil twilight
|
||||
// starts at −6°, nautical at −12°, astronomical at −18°).
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
export function solarElevationDeg(lat, lon, dateUTC) {
|
||||
const start = Date.UTC(dateUTC.getUTCFullYear(), 0, 0);
|
||||
const diff = dateUTC - start;
|
||||
@@ -374,7 +369,21 @@ export function solarElevationDeg(lat, lon, dateUTC) {
|
||||
return (Math.PI / 2 - zenith) * (180 / Math.PI);
|
||||
}
|
||||
|
||||
// Mean radiant temperature
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// MEAN RADIANT TEMPERATURE (Tmrt)
|
||||
// ───────────────────────────────────────────────────────────────────
|
||||
// Tmrt is the uniform temperature of an imaginary enclosure that would
|
||||
// cause the same net radiation exchange as the actual environment.
|
||||
// It accounts for:
|
||||
// • Direct solar beam (DNI), scaled by the projected-area factor fp
|
||||
// • Diffuse sky radiation (scattered and cloud-reflected)
|
||||
// • Ground-reflected shortwave (albedo × global radiation)
|
||||
// • Longwave thermal emission from surrounding surfaces (≈ blackbody at Ta)
|
||||
//
|
||||
// The fabric index 0.308 (fp formula from ISO 7933) projects the sun
|
||||
// onto a standing person's silhouette as a function of solar elevation.
|
||||
// Output feeds directly into utciApprox() as the Tmrt argument.
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
export function calcTmrt(Ta, dirRad, diffRad, globalRad, solElev) {
|
||||
const TaK = Ta + 273.15;
|
||||
let fp = 0;
|
||||
|
||||
+32
-19
@@ -1,22 +1,26 @@
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// utils.js — Pure helper functions and lookup tables.
|
||||
//
|
||||
// Exports:
|
||||
// utciCategory(u) UTCI stress band → {label,bg,fg}
|
||||
// precipPenalty(precipMm,snowCmH,windMs) SunScope soak-factor
|
||||
// windCompass8(deg) bearing → {label, snapped}
|
||||
// uvSplit(uv, elevDeg) total UV → {uvA, uvB}
|
||||
// SKIN_TYPES Fitzpatrick skin type table
|
||||
// sunburnMinutes(uv, skinType) minutes to MED
|
||||
// burnLabel(mins) formats burn time as "12m"/"1.5h"
|
||||
// VEHICLE_TYPES vehicle presets for cabin heat model
|
||||
// BUILDING_TYPES building presets for indoor heat model
|
||||
// cloudCategory(total,low,mid,high) → 'clear'|'wispy'|'scattered'|'overcast'
|
||||
// confidenceBand(i) day-tab gradient + label
|
||||
// moonPhaseFraction(date) 0..1 synodic phase
|
||||
// moonGlyph(p) phase fraction → emoji
|
||||
// skyGradientForElev(e, isRising) solar elevation + direction → {top,bot} sky gradient
|
||||
// grassFillForElev(e) solar elevation → {top,bot} hex
|
||||
// No side-effects, no DOM access, no API calls. All functions are safe
|
||||
// to call server-side or in tests.
|
||||
//
|
||||
// Exports (in order of appearance):
|
||||
// utciCategory(u) UTCI stress band → {label,bg,fg}
|
||||
// precipPenalty(precipMm,snowCmH,windMs) SunScope soak-factor (°C penalty)
|
||||
// windCompass8(deg) bearing → {label, snapped}
|
||||
// uvSplit(uv, elevDeg) total UV index → {uvA, uvB} estimate
|
||||
// SKIN_TYPES Fitzpatrick I–VI lookup table
|
||||
// sunburnMinutes(uv, skinType) minutes to MED (sunburn threshold)
|
||||
// burnLabel(mins) formats burn time as "12m" / "1.5h"
|
||||
// VEHICLE_TYPES vehicle presets for cabin heat model
|
||||
// BUILDING_TYPES building presets for indoor heat model
|
||||
// cloudCategory(total,low,mid,high) → 'clear'|'wispy'|'scattered'|'overcast'
|
||||
// confidenceBand(i) day-tab gradient colour + label
|
||||
// moonPhaseFraction(date) 0..1 synodic phase fraction
|
||||
// moonGlyph(p) phase fraction → moon emoji
|
||||
// skyGradientForElev(e, isRising) solar elevation → {top,bot} hex pair
|
||||
// skyFillForElev(e, isRising) convenience single-colour sky fill
|
||||
// grassFillForElev(e) solar elevation → {top,bot} ground hex
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
@@ -269,7 +273,8 @@ export function moonPhaseFraction(date) {
|
||||
return p;
|
||||
}
|
||||
export function moonGlyph(p) {
|
||||
return ['🌑','🌒','🌓','🌔','🌕','🌖','🌗','🌘'][Math.floor(p*8 + 0.5) % 8];
|
||||
// +0.5 offset rounds to nearest phase (avoids flickering on exact boundaries)
|
||||
return ['🌑','🌒','🌓','🌔','🌕','🌖','🌗','🌘'][Math.floor(p * 8 + 0.5) % 8];
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
@@ -339,12 +344,20 @@ export function skyGradientForElev(e, isRising) {
|
||||
return interpolateSkyKeys(isRising ? SKY_KEYS_RISING : SKY_KEYS_SETTING, e);
|
||||
}
|
||||
|
||||
// Convenience flat colour for contexts needing a single fill.
|
||||
// ─── skyFillForElev ────────────────────────────────────────────────────────
|
||||
// Convenience single-colour fill for contexts that don't need a gradient
|
||||
// (e.g. solid background chips). Returns the horizon (bottom) colour.
|
||||
export function skyFillForElev(e, isRising = false) {
|
||||
return skyGradientForElev(e, isRising).bot;
|
||||
}
|
||||
|
||||
// Grass palette — mirrors sky but for the ground strip.
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// GRASS FILL — ground-strip colour keyed to solar elevation.
|
||||
// ───────────────────────────────────────────────────────────────────
|
||||
// Mirrors the sky palette so the SkyScope disk reads naturally: vivid
|
||||
// green at noon, amber at golden hour, purple-dark at twilight/night.
|
||||
// Returns {top, bot} for a subtle two-stop ground gradient.
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
export function grassFillForElev(e) {
|
||||
if (e > 60) return { top: '#92cc50', bot: '#5e9630' }; // blazing noon
|
||||
if (e > 30) return { top: '#86c44a', bot: '#558a2e' }; // bright midday
|
||||
|
||||
Reference in New Issue
Block a user