2.2
Add new quick summary panels and update layout to make space for them
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "sunscope",
|
||||
"runtimeExecutable": "npx",
|
||||
"runtimeArgs": ["--yes", "serve", "-l", "8099", "."],
|
||||
"port": 8099
|
||||
}
|
||||
]
|
||||
}
|
||||
+47
-4
@@ -6,17 +6,41 @@
|
||||
|
||||
.utci-header {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr; /* title | dial | search */
|
||||
gap: 32px;
|
||||
grid-template-columns: 1fr auto 1fr; /* left | dial | why */
|
||||
grid-template-areas:
|
||||
"hleft dial hright"
|
||||
". env .";
|
||||
column-gap: 32px;
|
||||
row-gap: 6px;
|
||||
align-items: center;
|
||||
padding-bottom: 6px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
/* Side columns occupy only the dial's row, so they vertically centre against
|
||||
the dial itself; the environment selector drops to its own row beneath. */
|
||||
.header-left { grid-area: hleft; }
|
||||
.scope-col { grid-area: dial; }
|
||||
.scope-env-bar { grid-area: env; }
|
||||
.header-right { grid-area: hright; }
|
||||
|
||||
/* Left column — wordmark, tagline, search and location stacked together */
|
||||
.header-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.header-left .utci-search-wrap {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
/* Right column — now hosts the "Why it feels like this" panel,
|
||||
sitting close to the dial it explains. */
|
||||
.header-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@@ -61,7 +85,7 @@
|
||||
margin-top: 8px;
|
||||
margin-bottom: 0;
|
||||
color: #1e1208;
|
||||
text-align: right;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* The lat/lon below the location */
|
||||
@@ -91,6 +115,16 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* The environment selector sits just below the dial, centred under it.
|
||||
Kept out of .scope-col so the dial alone defines that column's height
|
||||
and stays vertically centred against the side columns. */
|
||||
.scope-env-bar {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 2px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.scope-env-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -261,11 +295,20 @@
|
||||
@media (max-width: 640px) {
|
||||
.utci-header {
|
||||
grid-template-columns: 1fr; /* single column — stack title, dial, search */
|
||||
grid-template-areas: none; /* flow in DOM order: left, dial, selector, why */
|
||||
gap: 20px;
|
||||
padding-bottom: 24px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
/* Clear the desktop area placement so children stack in DOM order */
|
||||
.header-left,
|
||||
.scope-col,
|
||||
.scope-env-bar,
|
||||
.header-right {
|
||||
grid-area: auto;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
align-items: flex-start; /* left-align location on mobile */
|
||||
}
|
||||
|
||||
@@ -158,8 +158,8 @@
|
||||
justify-content: safe center; /* fall back to flex-start if it overflows */
|
||||
overflow-x: auto;
|
||||
overflow-y: auto; /* forced by browser when overflow-x is non-visible */
|
||||
padding-bottom: 260px; /* extends clip boundary so dropdown panels aren't cut off */
|
||||
margin-bottom: -260px; /* cancel that extra space in the layout */
|
||||
padding-bottom: 400px; /* extends clip boundary so the tallest dropdown (Activities) isn't cut off */
|
||||
margin-bottom: -400px; /* cancel that extra space in the layout */
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
padding-right: 4px; /* breathing room at right edge */
|
||||
@@ -210,6 +210,7 @@
|
||||
.profile-btn {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
flex: none; /* never shrink below 80px — the row scrolls instead */
|
||||
padding: 0;
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
|
||||
@@ -1127,3 +1127,164 @@
|
||||
transform: translateY(-50%) rotate(45deg);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════════════
|
||||
INSIGHT PANELS - Why it feels like this + Today at a glance.
|
||||
Sits between the header dial and the day tabs.
|
||||
════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* "Why it feels like this" — sits in the header's right column, beside the dial */
|
||||
.insight-panel--why {
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
/* Hourly table paired with the "Today at a glance" rail on its right */
|
||||
.table-with-rail {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
/* Left column: the columns-toggle bar stacked above the table, both
|
||||
sharing the table's width so the rail can top-align with the toggles. */
|
||||
.table-main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.glance-rail {
|
||||
width: 240px;
|
||||
position: sticky;
|
||||
top: 16px;
|
||||
}
|
||||
|
||||
.insight-panel--glance {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.insight-panel {
|
||||
background: #fffcf2;
|
||||
border: 1px solid #c9b08a;
|
||||
border-radius: 4px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
/* Section heading - "WHY IT FEELS LIKE THIS" */
|
||||
.insight-panel-title {
|
||||
font-family: Manrope, sans-serif;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: #b09870;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* Each factor row */
|
||||
.insight-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 5px 0;
|
||||
border-bottom: 1px solid #ede4cc;
|
||||
font-family: Manrope, sans-serif;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.insight-row:last-child {
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.insight-icon {
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.insight-label {
|
||||
flex: 1;
|
||||
color: #4a3420;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Delta values for "Why it feels like this" */
|
||||
.insight-delta {
|
||||
font-family: JetBrains Mono, monospace;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
min-width: 44px;
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.delta-pos {
|
||||
color: #c8522a; /* warm orange-red - heat contribution */
|
||||
}
|
||||
|
||||
.delta-neg {
|
||||
color: #3a78a8; /* cool blue - cooling contribution */
|
||||
}
|
||||
|
||||
/* Values for "Today at a glance" */
|
||||
.insight-value {
|
||||
font-family: JetBrains Mono, monospace;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #4a3420;
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Alert state - draws attention without being aggressive */
|
||||
.insight-row--alert .insight-value {
|
||||
color: #b84020;
|
||||
}
|
||||
|
||||
.insight-row--alert .insight-label {
|
||||
color: #1e1208;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* ── Wide screens - the rail is narrow (240px), so stack each value
|
||||
beneath its label instead of letting long values overflow sideways.
|
||||
Mobile keeps the inline label/value layout (plenty of width there). */
|
||||
@media (min-width: 901px) {
|
||||
.insight-panel--glance .insight-row {
|
||||
display: grid;
|
||||
grid-template-columns: 20px 1fr;
|
||||
grid-template-areas:
|
||||
"icon label"
|
||||
"icon value";
|
||||
column-gap: 8px;
|
||||
row-gap: 1px;
|
||||
align-items: start;
|
||||
}
|
||||
.insight-panel--glance .insight-icon {
|
||||
grid-area: icon;
|
||||
padding-top: 1px;
|
||||
}
|
||||
.insight-panel--glance .insight-label {
|
||||
grid-area: label;
|
||||
}
|
||||
.insight-panel--glance .insight-value {
|
||||
grid-area: value;
|
||||
text-align: left; /* value sits left-aligned under its label */
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Narrow screens - drop the rail below the table ─────────────────── */
|
||||
@media (max-width: 900px) {
|
||||
.table-with-rail {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.glance-rail {
|
||||
width: 100%;
|
||||
position: static;
|
||||
order: -1; /* show the summary above the filters + table */
|
||||
}
|
||||
}
|
||||
|
||||
+110
-56
@@ -33,6 +33,7 @@ import { getCellTagEvents, getUpcomingEvents } from './events.js';
|
||||
import { POLLEN_TYPES, COL_DESCRIPTIONS, UTCI_ENVIRONMENTS } from './config.js';
|
||||
import { useAppState } from './hooks/useAppState.js';
|
||||
import { DayTabs } from './components/DayTabs.js';
|
||||
import { computeWhyFeelsLike, computeGlanceSummary } from './compute.js';
|
||||
|
||||
const html = htm.bind(h);
|
||||
|
||||
@@ -166,6 +167,16 @@ export function UTCIForecast() {
|
||||
return raw.map(c => Math.min(255, Math.round(c + (255 - c) * 0.66)));
|
||||
};
|
||||
|
||||
// ─── 3b. PANEL COMPUTATIONS ──────────────────────────────────────────
|
||||
// Derived from currentRow and the active day - no side effects.
|
||||
const whyFeelsLike = computeWhyFeelsLike(currentRow ?? null, UTCI_ENVIRONMENTS[utciEnv]);
|
||||
const glanceSummary = computeGlanceSummary(
|
||||
days[selectedDay]?.rows ?? [],
|
||||
activeProfile,
|
||||
outdoorsVariant,
|
||||
skinType,
|
||||
);
|
||||
|
||||
// ─── 4. JSX RETURN ───────────────────────────────────────────────────
|
||||
// Everything below is the actual page markup, written as one big HTM
|
||||
// template. Search tips:
|
||||
@@ -272,43 +283,13 @@ export function UTCIForecast() {
|
||||
</div>
|
||||
|
||||
<div class="utci-header">
|
||||
<div>
|
||||
<div class="header-left">
|
||||
<h1 class="utci-title">
|
||||
<span class="title-sun">SUN</span><span class="title-scope">Scope</span>
|
||||
</h1>
|
||||
<div class="utci-tagline">See the sun the way your skin does.</div>
|
||||
</div>
|
||||
|
||||
<div class="scope-col">
|
||||
<${ScopeReticle}
|
||||
value=${currentRow?.utciAdj ?? null}
|
||||
cat=${currentCat}
|
||||
loading=${loading}
|
||||
elev=${currentRow?.elev ?? 0}
|
||||
dt=${currentRow?.dt ?? new Date()}
|
||||
glob=${currentRow?.glob ?? 0}
|
||||
activeEvent=${lensEvent}
|
||||
utciEnvShort=${UTCI_ENVIRONMENTS[utciEnv]?.shortLabel ?? null}
|
||||
/>
|
||||
<div class="scope-env-row" data-env=${utciEnv}>
|
||||
<span class="scope-env-label">Environment</span>
|
||||
<${CustomSelect}
|
||||
value=${utciEnv}
|
||||
isOn=${true}
|
||||
noHide=${true}
|
||||
grpClass="grp-felt"
|
||||
options=${Object.entries(UTCI_ENVIRONMENTS).map(([k, v]) => ({
|
||||
value: k,
|
||||
label: v.label,
|
||||
}))}
|
||||
onChange=${(v) => setUtciEnv(v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<div class="utci-search-wrap">
|
||||
<input
|
||||
<input
|
||||
class="utci-search"
|
||||
type="text"
|
||||
placeholder="Search any town or city…"
|
||||
@@ -350,7 +331,63 @@ export function UTCIForecast() {
|
||||
${fetchedAt && !loading && html`
|
||||
<div class="utci-fetch-time">Last update: ${fetchedAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</div>`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="scope-col">
|
||||
<${ScopeReticle}
|
||||
value=${currentRow?.utciAdj ?? null}
|
||||
cat=${currentCat}
|
||||
loading=${loading}
|
||||
elev=${currentRow?.elev ?? 0}
|
||||
dt=${currentRow?.dt ?? new Date()}
|
||||
glob=${currentRow?.glob ?? 0}
|
||||
activeEvent=${lensEvent}
|
||||
utciEnvShort=${UTCI_ENVIRONMENTS[utciEnv]?.shortLabel ?? null}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="scope-env-bar">
|
||||
<div class="scope-env-row" data-env=${utciEnv}>
|
||||
<span class="scope-env-label">Environment</span>
|
||||
<${CustomSelect}
|
||||
value=${utciEnv}
|
||||
isOn=${true}
|
||||
noHide=${true}
|
||||
grpClass="grp-felt"
|
||||
options=${Object.entries(UTCI_ENVIRONMENTS).map(([k, v]) => ({
|
||||
value: k,
|
||||
label: v.label,
|
||||
}))}
|
||||
onChange=${(v) => setUtciEnv(v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
${forecast && currentRow && whyFeelsLike && html`
|
||||
<div class="insight-panel insight-panel--why">
|
||||
<div class="insight-panel-title">Why it feels like this</div>
|
||||
${[
|
||||
{ label: 'Sun and sky', icon: '☀', value: whyFeelsLike.sunAndSky },
|
||||
{ label: 'Wind', icon: '🌬', value: whyFeelsLike.wind },
|
||||
{ label: 'Humidity', icon: '💧', value: whyFeelsLike.humidity },
|
||||
...(whyFeelsLike.environment !== 0 ? [
|
||||
{ label: UTCI_ENVIRONMENTS[utciEnv]?.label ?? 'Environment', icon: '🌍', value: whyFeelsLike.environment }
|
||||
] : []),
|
||||
...(whyFeelsLike.precipitation !== 0 ? [
|
||||
{ label: 'Precipitation', icon: '🌧', value: whyFeelsLike.precipitation }
|
||||
] : []),
|
||||
].map(({ label, icon, value }) => html`
|
||||
<div class="insight-row" key=${label}>
|
||||
<span class="insight-icon">${icon}</span>
|
||||
<span class="insight-label">${label}</span>
|
||||
<span class=${`insight-delta ${value >= 0 ? 'delta-pos' : 'delta-neg'}`}>
|
||||
${value >= 0 ? '+' : ''}${value.toFixed(1)}°
|
||||
</span>
|
||||
</div>
|
||||
`)}
|
||||
</div>`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${error && html`
|
||||
<div class="utci-status" style=${{ borderColor: '#3a1010', color: '#c44a3a' }}>
|
||||
@@ -385,32 +422,34 @@ export function UTCIForecast() {
|
||||
/>`}
|
||||
|
||||
|
||||
<div class="table-with-rail">
|
||||
<div class="table-main">
|
||||
<div class="col-toggles">
|
||||
<span class="col-toggles-label">Columns:</span>
|
||||
${(isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['utciP']) : activeCols['utciP']) && html`
|
||||
<span class=${'col-toggle-group' + (visibleCols.utciP ? ' col-toggle-group--expanded' : '')}>
|
||||
<${CustomSelect}
|
||||
value=${utciEnv}
|
||||
isOn=${visibleCols.utciP}
|
||||
grpClass="grp-felt"
|
||||
hideLabel="SunSoak"
|
||||
hidingLabel="Hide SunSoak"
|
||||
groupedLeft=${true}
|
||||
isLastChild=${true}
|
||||
options=${Object.entries(UTCI_ENVIRONMENTS).map(([k, v]) => ({
|
||||
value: k,
|
||||
label: v.label,
|
||||
}))}
|
||||
onChange=${(v) => {
|
||||
if (v === 'off') {
|
||||
setVisibleCols(prev => ({ ...prev, utciP: false }));
|
||||
} else {
|
||||
setUtciEnv(v);
|
||||
setVisibleCols(prev => ({ ...prev, utciP: true }));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</span>`}
|
||||
<span class=${'col-toggle-group' + (visibleCols.utciP ? ' col-toggle-group--expanded' : '')}>
|
||||
<${CustomSelect}
|
||||
value=${utciEnv}
|
||||
isOn=${visibleCols.utciP}
|
||||
grpClass="grp-felt"
|
||||
hideLabel="SunSoak"
|
||||
hidingLabel="Hide SunSoak"
|
||||
groupedLeft=${true}
|
||||
isLastChild=${true}
|
||||
options=${Object.entries(UTCI_ENVIRONMENTS).map(([k, v]) => ({
|
||||
value: k,
|
||||
label: v.label,
|
||||
}))}
|
||||
onChange=${(v) => {
|
||||
if (v === 'off') {
|
||||
setVisibleCols(prev => ({ ...prev, utciP: false }));
|
||||
} else {
|
||||
setUtciEnv(v);
|
||||
setVisibleCols(prev => ({ ...prev, utciP: true }));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</span>`}
|
||||
${(isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['vehicleT']) : activeCols['vehicleT']) && html`
|
||||
<span class=${'col-toggle-group' + (visibleCols.vehicleT ? ' col-toggle-group--expanded' : '')}>
|
||||
<${CustomSelect}
|
||||
@@ -862,6 +901,21 @@ export function UTCIForecast() {
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
${forecast && glanceSummary && glanceSummary.length > 0 && html`
|
||||
<aside class="glance-rail">
|
||||
<div class="insight-panel insight-panel--glance">
|
||||
<div class="insight-panel-title">Today at a glance</div>
|
||||
${glanceSummary.map(({ icon, label, value, alert }) => html`
|
||||
<div class=${`insight-row${alert ? ' insight-row--alert' : ''}`} key=${label}>
|
||||
<span class="insight-icon">${icon}</span>
|
||||
<span class="insight-label">${label}</span>
|
||||
<span class="insight-value">${value}</span>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</aside>`}
|
||||
</div>
|
||||
|
||||
${colPopup && COL_DESCRIPTIONS[colPopup.key] && html`
|
||||
<div
|
||||
|
||||
+316
-1
@@ -15,7 +15,7 @@ import {
|
||||
calcConcreteTempPass, calcVehicleInteriorTemp,
|
||||
calcIndoorTempPass, calcManagedIndoorTempPass,
|
||||
} from './physics.js';
|
||||
import { windCompass8, uvSplit, cloudCategory, precipPenalty } from './utils.js';
|
||||
import { windCompass8, uvSplit, cloudCategory, precipPenalty, sunburnMinutes, burnLabel } from './utils.js';
|
||||
import { UTCI_ENVIRONMENTS } from './config.js';
|
||||
|
||||
export function buildHourlyRows({ forecast, airQuality, location, vehicleType, vehicleVent, buildingType, utciEnv }) {
|
||||
@@ -230,3 +230,318 @@ export function buildHourlyRows({ forecast, airQuality, location, vehicleType, v
|
||||
|
||||
return { hourlyRows, days, utcOffsetMs };
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// computeWhyFeelsLike(row, env) - Break down the felt-temp delta into its
|
||||
// contributing factors for the "Why it feels like this" panel.
|
||||
//
|
||||
// Returns contributions in degrees C relative to plain air temperature.
|
||||
// Positive = warmer than air temp, negative = cooler.
|
||||
//
|
||||
// Attribution method - sequential isolation using utciApprox:
|
||||
// sunAndSky - Tmrt vs Ta. Mean radiant temp captures net heat from
|
||||
// direct sun, sky scatter, and ground reflection combined.
|
||||
// wind - UTCI with wind vs without (Tmrt = Ta, neutral RH 50%).
|
||||
// Almost always negative - wind cools.
|
||||
// humidity - UTCI at actual vapour pressure vs neutral RH 50%.
|
||||
// Positive when muggy, near-zero when dry.
|
||||
// environment - taOffset from the active UTCI environment modifier.
|
||||
// e.g. urban +2.5, forest -2.0, desert +3.5.
|
||||
// precipitation - precipPenalty() - always 0 or negative.
|
||||
// ------------------------------------------------------------------------
|
||||
export function computeWhyFeelsLike(row, env) {
|
||||
if (!row) return null;
|
||||
const { Ta, Tmrt, va, eh, precip, snow } = row;
|
||||
const r1 = (v) => Math.round(v * 10) / 10;
|
||||
|
||||
// Radiation - mean radiant temp above or below air temp.
|
||||
const sunAndSky = r1(Tmrt - Ta);
|
||||
|
||||
// Wind - UTCI with actual wind vs calm, holding Tmrt = Ta and RH neutral.
|
||||
const ehNeutral = vaporPressureHpa(Ta, 50);
|
||||
const wind = r1(utciApprox(Ta, Ta, va, ehNeutral) - utciApprox(Ta, Ta, 0, ehNeutral));
|
||||
|
||||
// Humidity - actual vapour pressure vs neutral RH 50%, no wind or sun.
|
||||
const humidity = r1(utciApprox(Ta, Ta, 0, eh) - utciApprox(Ta, Ta, 0, ehNeutral));
|
||||
|
||||
// Environment modifier - explicit temperature offset from the env config.
|
||||
const environment = env ? r1(env.taOffset) : 0;
|
||||
|
||||
// Precipitation soak penalty - negative or zero.
|
||||
const precipitation = precipPenalty(precip, snow, va);
|
||||
|
||||
return { sunAndSky, wind, humidity, environment, precipitation };
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// computeGlanceSummary(todayRows, profile, variant, skinType) - Build the
|
||||
// environment-aware "Today at a Glance" items for the current day.
|
||||
//
|
||||
// Returns an array of { icon, label, value, alert } objects (4-5 items).
|
||||
// Content varies by profile and sub-variant so it stays relevant to
|
||||
// whatever the user is actually doing.
|
||||
//
|
||||
// Parameters:
|
||||
// todayRows - hourlyRows for the selected day
|
||||
// profile - active profile key e.g. "farming", "vehicle", "home"
|
||||
// variant - active sub-variant key e.g. "running", "beach" (or null)
|
||||
// skinType - Fitzpatrick skin type key for UV burn time
|
||||
// ------------------------------------------------------------------------
|
||||
export function computeGlanceSummary(todayRows, profile, variant, skinType) {
|
||||
if (!todayRows || todayRows.length === 0) return [];
|
||||
|
||||
const hhmm = (iso) => iso ? iso.slice(11, 16) : null;
|
||||
const dayRows = todayRows.filter(r => r.elev > 0);
|
||||
|
||||
const peakRow = (field) => todayRows.reduce((best, r) =>
|
||||
(r[field] != null && (best == null || r[field] > best[field])) ? r : best, null);
|
||||
|
||||
const maxRainProb = Math.max(0, ...todayRows.map(r => r.precipProb ?? 0));
|
||||
|
||||
// Longest run of rows that satisfy a predicate.
|
||||
const longestWindow = (rows, cond) => {
|
||||
let best = null, cur = null;
|
||||
for (const r of rows) {
|
||||
if (cond(r)) {
|
||||
cur = cur ? { start: cur.start, end: r, len: cur.len + 1 } : { start: r, end: r, len: 1 };
|
||||
if (!best || cur.len > best.len) best = { ...cur };
|
||||
} else {
|
||||
cur = null;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
};
|
||||
|
||||
const pollenLabel = (v) => {
|
||||
if (v == null || v < 0) return null;
|
||||
if (v < 10) return 'Low';
|
||||
if (v < 50) return 'Moderate';
|
||||
if (v < 200) return 'High';
|
||||
return 'Very High';
|
||||
};
|
||||
|
||||
const aqiLabel = (v) => {
|
||||
if (v == null) return null;
|
||||
if (v < 20) return 'Good';
|
||||
if (v < 40) return 'Fair';
|
||||
if (v < 60) return 'Moderate';
|
||||
return 'Poor';
|
||||
};
|
||||
|
||||
const moistureLabel = (v) => {
|
||||
if (v == null) return null;
|
||||
if (v < 0.15) return 'Dry';
|
||||
if (v < 0.30) return 'Slightly dry';
|
||||
if (v < 0.45) return 'Moist';
|
||||
return 'Saturated';
|
||||
};
|
||||
|
||||
const rainItem = {
|
||||
icon: '🌧',
|
||||
label: 'Rain risk',
|
||||
value: `${Math.round(maxRainProb)}%`,
|
||||
alert: maxRainProb >= 60,
|
||||
};
|
||||
|
||||
// ── Farming ────────────────────────────────────────────────────────────
|
||||
if (profile === 'farming') {
|
||||
const fieldWindow = longestWindow(dayRows, r =>
|
||||
r.utciAdj >= 8 && r.utciAdj <= 32 && r.precipProb < 30 && r.va < 12
|
||||
);
|
||||
const soilWarmRow = todayRows.find(r => r.soilT0 != null && r.soilT0 >= 10);
|
||||
const soilM = todayRows.find(r => r.soilM != null)?.soilM ?? null;
|
||||
const maxPollen = Math.max(0, ...todayRows.map(r => r.grassPollen ?? 0));
|
||||
|
||||
return [
|
||||
{
|
||||
icon: '⏱',
|
||||
label: 'Best field work',
|
||||
value: fieldWindow
|
||||
? `${hhmm(fieldWindow.start.iso)} – ${hhmm(fieldWindow.end.iso)}`
|
||||
: 'No suitable window',
|
||||
alert: !fieldWindow,
|
||||
},
|
||||
{
|
||||
icon: '🌱',
|
||||
label: 'Soil warms to 10°C by',
|
||||
value: soilWarmRow ? hhmm(soilWarmRow.iso) : 'Not today',
|
||||
alert: !soilWarmRow,
|
||||
},
|
||||
rainItem,
|
||||
{
|
||||
icon: '💧',
|
||||
label: 'Soil moisture',
|
||||
value: moistureLabel(soilM) ?? '-',
|
||||
alert: soilM != null && (soilM < 0.10 || soilM > 0.50),
|
||||
},
|
||||
...(maxPollen >= 10 ? [{
|
||||
icon: '🌿',
|
||||
label: 'Grass pollen',
|
||||
value: pollenLabel(maxPollen),
|
||||
alert: maxPollen >= 50,
|
||||
}] : []),
|
||||
];
|
||||
}
|
||||
|
||||
// ── Vehicle ────────────────────────────────────────────────────────────
|
||||
if (profile === 'vehicle') {
|
||||
const peakCabin = peakRow('vehicleT');
|
||||
const dangerFrom = todayRows.find(r => r.vehicleT != null && r.vehicleT >= 35);
|
||||
|
||||
return [
|
||||
{
|
||||
icon: '🌡',
|
||||
label: 'Peak cabin temp',
|
||||
value: peakCabin ? `${Math.round(peakCabin.vehicleT)}° at ${hhmm(peakCabin.iso)}` : '-',
|
||||
alert: !!(peakCabin && peakCabin.vehicleT >= 35),
|
||||
},
|
||||
{
|
||||
icon: '🧒',
|
||||
label: 'Children/pets in car',
|
||||
value: dangerFrom ? `Unsafe from ${hhmm(dangerFrom.iso)}` : 'Safe all day',
|
||||
alert: !!dangerFrom,
|
||||
},
|
||||
rainItem,
|
||||
{
|
||||
icon: '🌤',
|
||||
label: 'Best travel comfort',
|
||||
value: (() => {
|
||||
const best = todayRows.reduce((b, r) =>
|
||||
(b == null || r.utciAdj < b.utciAdj) ? r : b, null);
|
||||
return best ? `${hhmm(best.iso)} (${Math.round(best.utciAdj)}° felt)` : '-';
|
||||
})(),
|
||||
alert: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ── Home ───────────────────────────────────────────────────────────────
|
||||
if (profile === 'home') {
|
||||
const peakIndoor = peakRow('indoorT');
|
||||
const peakManaged = peakRow('managedT');
|
||||
const ventWindow = longestWindow(todayRows, r =>
|
||||
r.Ta != null && r.indoorT != null && r.Ta < r.indoorT && r.precipProb < 20
|
||||
);
|
||||
const peakAqi = Math.max(0, ...todayRows.map(r => r.aqi ?? 0));
|
||||
const maxPollen = Math.max(0, ...todayRows.map(r => r.grassPollen ?? 0));
|
||||
|
||||
return [
|
||||
{
|
||||
icon: '🌡',
|
||||
label: 'Peak indoor (unmanaged)',
|
||||
value: peakIndoor ? `${Math.round(peakIndoor.indoorT)}° at ${hhmm(peakIndoor.iso)}` : '-',
|
||||
alert: !!(peakIndoor && peakIndoor.indoorT >= 28),
|
||||
},
|
||||
{
|
||||
icon: '🌡',
|
||||
label: 'Peak indoor (managed)',
|
||||
value: peakManaged ? `${Math.round(peakManaged.managedT)}°` : '-',
|
||||
alert: !!(peakManaged && peakManaged.managedT >= 28),
|
||||
},
|
||||
{
|
||||
icon: '🪟',
|
||||
label: 'Open windows',
|
||||
value: ventWindow
|
||||
? `${hhmm(ventWindow.start.iso)} – ${hhmm(ventWindow.end.iso)}`
|
||||
: 'Keep closed',
|
||||
alert: false,
|
||||
},
|
||||
{
|
||||
icon: '💨',
|
||||
label: 'Air quality',
|
||||
value: peakAqi > 0 ? aqiLabel(peakAqi) : '-',
|
||||
alert: peakAqi >= 60,
|
||||
},
|
||||
...(maxPollen >= 10 ? [{
|
||||
icon: '🌼',
|
||||
label: 'Pollen',
|
||||
value: pollenLabel(maxPollen),
|
||||
alert: maxPollen >= 50,
|
||||
}] : []),
|
||||
];
|
||||
}
|
||||
|
||||
// ── Activities - running / cycling ─────────────────────────────────────
|
||||
if (variant === 'running' || variant === 'cycling') {
|
||||
const coolWindow = longestWindow(dayRows, r =>
|
||||
r.utciAdj >= 5 && r.utciAdj <= 22 && r.precipProb < 30
|
||||
);
|
||||
const peakUvRow = peakRow('uv');
|
||||
const peakAqi = Math.max(0, ...todayRows.map(r => r.aqi ?? 0));
|
||||
const maxPollen = Math.max(0, ...todayRows.map(r => r.grassPollen ?? 0));
|
||||
const burnMins = peakUvRow && peakUvRow.uv > 0
|
||||
? burnLabel(sunburnMinutes(peakUvRow.uv, skinType))
|
||||
: null;
|
||||
|
||||
return [
|
||||
{
|
||||
icon: '⏱',
|
||||
label: `Best ${variant} window`,
|
||||
value: coolWindow
|
||||
? `${hhmm(coolWindow.start.iso)} – ${hhmm(coolWindow.end.iso)}`
|
||||
: 'No cool window today',
|
||||
alert: !coolWindow,
|
||||
},
|
||||
rainItem,
|
||||
...(burnMins ? [{
|
||||
icon: '☀',
|
||||
label: 'UV burn time (peak)',
|
||||
value: burnMins,
|
||||
alert: !!(peakUvRow && peakUvRow.uv >= 6),
|
||||
}] : []),
|
||||
{
|
||||
icon: '💨',
|
||||
label: 'Air quality',
|
||||
value: peakAqi > 0 ? aqiLabel(peakAqi) : '-',
|
||||
alert: peakAqi >= 60,
|
||||
},
|
||||
...(maxPollen >= 10 ? [{
|
||||
icon: '🌿',
|
||||
label: 'Pollen',
|
||||
value: pollenLabel(maxPollen),
|
||||
alert: maxPollen >= 50,
|
||||
}] : []),
|
||||
];
|
||||
}
|
||||
|
||||
// ── Outdoors - beach, park, events etc. (and fallback) ─────────────────
|
||||
const comfortWindow = longestWindow(dayRows, r =>
|
||||
r.utciAdj >= 9 && r.utciAdj <= 26 && r.precipProb < 30
|
||||
);
|
||||
const peakFelt = peakRow('utciAdj');
|
||||
const peakUvRow = peakRow('uv');
|
||||
const peakAqi = Math.max(0, ...todayRows.map(r => r.aqi ?? 0));
|
||||
const burnMins = peakUvRow && peakUvRow.uv > 0
|
||||
? burnLabel(sunburnMinutes(peakUvRow.uv, skinType))
|
||||
: null;
|
||||
|
||||
return [
|
||||
{
|
||||
icon: '🌤',
|
||||
label: 'Comfortable window',
|
||||
value: comfortWindow
|
||||
? `${hhmm(comfortWindow.start.iso)} – ${hhmm(comfortWindow.end.iso)}`
|
||||
: 'No comfortable window',
|
||||
alert: !comfortWindow,
|
||||
},
|
||||
{
|
||||
icon: '🌡',
|
||||
label: 'Peak felt temp',
|
||||
value: peakFelt ? `${Math.round(peakFelt.utciAdj)}° at ${hhmm(peakFelt.iso)}` : '-',
|
||||
alert: !!(peakFelt && (peakFelt.utciAdj >= 32 || peakFelt.utciAdj < 0)),
|
||||
},
|
||||
...(burnMins ? [{
|
||||
icon: '☀',
|
||||
label: 'UV burn time (peak)',
|
||||
value: burnMins,
|
||||
alert: !!(peakUvRow && peakUvRow.uv >= 6),
|
||||
}] : []),
|
||||
rainItem,
|
||||
{
|
||||
icon: '💨',
|
||||
label: 'Air quality',
|
||||
value: peakAqi > 0 ? aqiLabel(peakAqi) : '-',
|
||||
alert: peakAqi >= 60,
|
||||
},
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user