5.6
Cycling hazards (new feature) Added cyclingRisk() in compute.js, which scores each hour 0, 1 or 2 (fine, care, risky) from gusts, wind, heavy rain, snow, ice, fog and lightning. Shows a ⚠ tag beside the hour in the table and the forecast cards, amber for care and red for risky, and it opens the event-tag popup with advice. At a glance now has a “Ride with care / Risky to ride” row, and the best cycling window leaves out hazard hours. When hours are grouped into 2h/3h/4h rows, bikeRisk takes the worst hour, and the reasons come from that same hour. Felt wind Added feltWindMs() in utils.js, which compresses strong wind on a log curve so SunSoak and Shade (felt) no longer over-chill in gales. “Why it feels like” uses the same softened wind, so its parts still add up to the shown value. Week Ahead / best day scoring Every profile is now judged over waking hours (7am–10pm) instead of daylight only, and home, vehicle, pets, airport, camping, festival and driver run round the clock. Cycling, urban walking and airport each get their own scoring. Airport weights wind and fog most, and urban walking uses a cooler comfort range. Leisure variants such as picnic, beach and dog walk now get a sunshine weight from cloud cover, in daylight only. Gusts over 31 mph count three times as much in the score and in the choice of the day’s icon. Days with no good window are still listed, with a low score and the reason shown in place of a time range. Each day’s popup now shows its top three influences instead of one. Simple view (cards + curve) The temperature curve always plots hourly data now, so it keeps its shape at 2h/3h/4h. Cards that cover several hours are centred over their whole block, and the curve draws a bracket and dashed guides over the hours each card covers. Detailed (rotated) table styling Cells are tighter (smaller padding and minimum widths), so more hours fit across. The hour row has a white background with a lighter rule under the header, and the time label gets extra top padding. Metric names and their units sit on one line, and gusts stack under the wind speed. Toolbar / profile row The “Quick” view button is renamed “Simple”, and the view toggle is bigger, with Edit columns dropping onto a new line on mobile. The profile row now shows a scene thumbnail of the active profile (DayTabs.js). In that row, the “Profile” and “Viewing” labels stack above their values, and Change is a solid 40px button in the row’s own colour. Config / data Dog Walking now shows the pet columns: fur surface, paw temperature and pet shade (config.js). data/tracking.json has 14 new lines of tracking data.
This commit is contained in:
+125
-31
@@ -753,11 +753,14 @@
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Breathing room between each "PROFILE" / "VIEWING" label and its value. */
|
||||
/* Each "PROFILE" / "VIEWING" label sits on its own line, with its value
|
||||
stacked underneath. */
|
||||
.active-profile-row-item {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 7px;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.active-profile-row-label {
|
||||
font-family: Manrope, sans-serif;
|
||||
@@ -776,28 +779,42 @@
|
||||
color: #1e1208;
|
||||
}
|
||||
.active-profile-row-sep { color: #c0a880; font-size: 16px; }
|
||||
/* Small scene picture of the active profile — same image as its card in
|
||||
the profile picker, so the row shows the choice, not just names it. */
|
||||
.active-profile-row-thumb {
|
||||
flex-shrink: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.18);
|
||||
}
|
||||
.active-profile-row-edit {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-family: Manrope, sans-serif;
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
font-size: 13.5px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.04em;
|
||||
color: #9a6a1a;
|
||||
margin-left: 8px;
|
||||
padding: 4px 10px;
|
||||
background: rgba(200,146,42,0.12);
|
||||
border: 1px solid rgba(200,146,42,0.35);
|
||||
border-radius: 999px;
|
||||
transition: background 0.2s, border-color 0.2s, color 0.2s, transform 0.2s;
|
||||
/* Solid fill in the row's own accent (--row-accent, set per thermal-model
|
||||
group below) — reads as a real button, and always matches the value
|
||||
text colour however the row's background changes. */
|
||||
color: #fffcf2;
|
||||
/* Same 40px height, 8px rounding and row gap as the thumbnail on the
|
||||
left, so the two bookend the row symmetrically. */
|
||||
box-sizing: border-box;
|
||||
height: 40px;
|
||||
padding: 0 16px;
|
||||
background: var(--row-accent, #9a6a1a);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.18);
|
||||
transition: filter 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
.active-profile-row:hover .active-profile-row-edit,
|
||||
.active-profile-row:focus-visible .active-profile-row-edit {
|
||||
color: #c8922a;
|
||||
background: rgba(200,146,42,0.2);
|
||||
border-color: #c8922a;
|
||||
transform: translateY(-1px);
|
||||
filter: brightness(1.15);
|
||||
box-shadow: 0 4px 10px rgba(0,0,0,0.22);
|
||||
}
|
||||
|
||||
/* Border + text accent per thermal-model group, reusing the same colour
|
||||
@@ -812,6 +829,13 @@
|
||||
.active-profile-row.grp-surface { border-color: rgba(11,62,114,0.30); }
|
||||
.active-profile-row.grp-pets { border-color: rgba(10,81,88,0.30); }
|
||||
|
||||
/* Same accents as a variable, for the Change button's fill. */
|
||||
.active-profile-row.grp-felt { --row-accent: #075c3a; }
|
||||
.active-profile-row.grp-wind { --row-accent: #6a2210; }
|
||||
.active-profile-row.grp-ambient { --row-accent: #3c3a35; }
|
||||
.active-profile-row.grp-surface { --row-accent: #0b3e72; }
|
||||
.active-profile-row.grp-pets { --row-accent: #0a5158; }
|
||||
|
||||
.active-profile-row.grp-felt .active-profile-row-value { color: #075c3a; }
|
||||
.active-profile-row.grp-wind .active-profile-row-value { color: #6a2210; }
|
||||
.active-profile-row.grp-ambient .active-profile-row-value { color: #3c3a35; }
|
||||
@@ -1926,6 +1950,21 @@
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
/* Cycling hazard marker beside the hour (time cell + forecast cards).
|
||||
Text-style ⚠ so it can take an amber "care" or red "risky" colour. */
|
||||
.bike-risk-tag {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
cursor: help;
|
||||
}
|
||||
.fsc-time .bike-risk-tag {
|
||||
margin-left: 3px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.bike-risk-tag--caution { color: #c98a12; }
|
||||
.bike-risk-tag--danger { color: #c0392b; }
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.utci-time {
|
||||
font-size: 15px;
|
||||
@@ -2354,9 +2393,11 @@
|
||||
|
||||
/* ── SIMPLE / TABLE VIEW TOGGLE ─────────────────────────────────────────── */
|
||||
|
||||
/* View label and segments are sized up from the 11px toolbar default -
|
||||
switching view is one of the main features, so it should read as one. */
|
||||
.forecast-view-toggle-label {
|
||||
font-family: Manrope, sans-serif;
|
||||
font-size: 11px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
@@ -2373,12 +2414,12 @@
|
||||
.fvt-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
gap: 5px;
|
||||
font-family: Manrope, sans-serif;
|
||||
font-size: 11px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
padding: 6px 8px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
border: 1.5px solid #c9b08a;
|
||||
background: #f5edd6;
|
||||
@@ -2391,7 +2432,7 @@
|
||||
/* Only the outer corners are rounded; every segment after the first drops
|
||||
its left border so neighbours share one 1.5px rule rather than stacking
|
||||
two. Keyed off the sibling rather than :last-child so it still holds with
|
||||
three segments (Quick / Detailed / Table). */
|
||||
three segments (Simple / Detailed / Table). */
|
||||
.fvt-btn + .fvt-btn {
|
||||
border-left: none;
|
||||
}
|
||||
@@ -2410,6 +2451,8 @@
|
||||
}
|
||||
|
||||
.fvt-btn-icon {
|
||||
width: 14px; /* up from the 12px set on the svg, to match the larger text */
|
||||
height: 14px;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
flex-shrink: 0;
|
||||
@@ -2463,6 +2506,11 @@
|
||||
grid-row: 1;
|
||||
justify-self: center;
|
||||
padding-bottom: 0;
|
||||
/* View toggle + Edit columns no longer fit one 375px line at the
|
||||
larger toggle size - let Edit columns drop beneath. */
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
row-gap: 6px;
|
||||
}
|
||||
/* Quick view needs the row flush so its thermal tabs attach to the box
|
||||
below. Detailed and Table have no tabs, so the View buttons would
|
||||
@@ -2892,10 +2940,12 @@
|
||||
/* ── SPACING ──────────────────────────────────────────────────────────
|
||||
Cells carry icons alongside their numbers (sky scope, cloud, precip,
|
||||
wind vane), so they need noticeably more room than a bare figure. */
|
||||
/* Kept tight so more hours fit across — the cells were reading as wide,
|
||||
airy tiles at 11px/16px with a 92px floor. */
|
||||
.utci-table-rotated td,
|
||||
.utci-table-rotated th {
|
||||
padding: 11px 16px;
|
||||
min-width: 92px;
|
||||
padding: 7px 8px;
|
||||
min-width: 64px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -2915,7 +2965,39 @@
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
color: #1e1208;
|
||||
min-width: 76px;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
/* Hour row on white rather than the table-head cream — reads as a clean
|
||||
time axis over the shaded data. Over-qualified to beat `.utci-table th`.
|
||||
The hairline keeps it separated from the first metric row. */
|
||||
.utci-table.utci-table-rotated thead th.rot-hour-th,
|
||||
.utci-table.utci-table-rotated thead th.rot-corner {
|
||||
background: #ffffff;
|
||||
border-bottom: 1px solid rgba(74, 50, 24, 0.06);
|
||||
}
|
||||
.utci-table.utci-table-rotated thead th.rot-hour-th.is-now {
|
||||
background: #fff8e4;
|
||||
}
|
||||
|
||||
/* Gust value in the wind cell — inline after the speed in Table view,
|
||||
stacked underneath it in Detailed so the hour columns stay narrow. */
|
||||
.wind-gust {
|
||||
margin-left: 4px;
|
||||
}
|
||||
.utci-table-rotated .wind-gust {
|
||||
display: block;
|
||||
margin-left: 0;
|
||||
font-size: 0.85em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
/* The shared sticky strip draws a heavy 2px tan rule + drop shadow under
|
||||
the head — right for the cream Table header, too dark under the white
|
||||
hour row. Lighten it here only; the Table view keeps its own. */
|
||||
.utci-table-wrap.is-rotated .utci-thead-sticky {
|
||||
border-bottom: 1px solid rgba(74, 50, 24, 0.10);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Top-aligned so every scope sits on the same line. The header row is as
|
||||
@@ -2923,6 +3005,9 @@
|
||||
would push the emptier hours down out of step with it. */
|
||||
.utci-table-rotated thead th.rot-hour-th {
|
||||
vertical-align: top;
|
||||
/* Extra top padding to balance the space below the time label, which
|
||||
its serif line-height already pads out. */
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
/* Stacked, not inline: rotated, every hour is its own COLUMN, so sitting
|
||||
@@ -3037,6 +3122,14 @@
|
||||
box-shadow: 1px 0 0 rgba(74, 50, 24, 0.18);
|
||||
}
|
||||
|
||||
/* Name and unit on ONE line — stacked, the unit doubled every row's
|
||||
height. Same colour/size as before, just inline after the name. */
|
||||
.utci-table-rotated th.rot-metric-label .col-unit,
|
||||
.utci-table-rotated th.rot-metric-label .col-env-badge {
|
||||
display: inline;
|
||||
margin: 0 0 0 5px;
|
||||
}
|
||||
|
||||
/* The corner cell sits in both sticky tracks, so it needs to win over
|
||||
each of them. */
|
||||
/* The corner pins alongside the metric names below it. Over-qualified so
|
||||
@@ -3050,7 +3143,7 @@
|
||||
translated track, not in the scroller. */
|
||||
position: relative;
|
||||
z-index: 4;
|
||||
background: #f0e4c4;
|
||||
background: #f0e4c4; /* keeps the cream of the metric-name column below */
|
||||
text-align: left;
|
||||
box-shadow: 1px 0 0 rgba(74, 50, 24, 0.18);
|
||||
}
|
||||
@@ -3093,8 +3186,9 @@
|
||||
/* Tighter segments on narrow screens — the toolbar is already tight,
|
||||
and there are three of them now. */
|
||||
.fvt-btn {
|
||||
padding: 6px 6px;
|
||||
gap: 2px;
|
||||
font-size: 12px;
|
||||
padding: 7px 8px;
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3132,7 +3226,7 @@
|
||||
it regardless of font-size differences. */
|
||||
.fvt-btn,
|
||||
.col-toggles-edit-btn {
|
||||
min-height: 29px;
|
||||
min-height: 34px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
@@ -3143,12 +3237,12 @@
|
||||
}
|
||||
.utci-table-rotated td,
|
||||
.utci-table-rotated th {
|
||||
padding: 9px 11px;
|
||||
min-width: 80px;
|
||||
padding: 6px 6px;
|
||||
min-width: 56px;
|
||||
}
|
||||
.utci-table-rotated .rot-hour-th {
|
||||
font-size: 14px;
|
||||
min-width: 68px;
|
||||
min-width: 52px;
|
||||
}
|
||||
.utci-table-rotated .rot-metric-label {
|
||||
min-width: 96px;
|
||||
|
||||
+111
-28
@@ -739,6 +739,53 @@ export function UTCIForecast() {
|
||||
// I drive / when is the house bearable", not always "when is it nice out".
|
||||
const weekAhead = computeBestDay(days, nowLocalISO, activeProfile, outdoorsVariant);
|
||||
|
||||
// Cycling: a ⚠ beside the hour when it is risky (red) or needs care (amber)
|
||||
// on a bike - see cyclingRisk() in compute.js. Shared by the forecast
|
||||
// cards and both table orientations.
|
||||
const isCycling = activeProfile === 'outdoors' && outdoorsVariant === 'cycling';
|
||||
|
||||
// One line of advice per hazard, keyed by the start of its cyclingRisk() text.
|
||||
const BIKE_ADVICE = [
|
||||
['Gusts', 'sudden gusts can shove you across the lane'],
|
||||
['Wind', 'strong wind makes steering and climbs hard work'],
|
||||
['Heavy rain', 'spray and longer braking distances'],
|
||||
['Snow', 'snow on the road cuts grip'],
|
||||
['Ice likely', 'icy patches are likely, especially on bridges and shaded lanes'],
|
||||
['Possible ice', 'watch for icy patches on bridges and shaded lanes'],
|
||||
['Fog', 'drivers may not see you, so run lights'],
|
||||
['Lightning', 'thunderstorms about, so stay near shelter'],
|
||||
];
|
||||
|
||||
// The hour's hazard as an event-tag event, so the ⚠ opens the same animated
|
||||
// popup (with Quick Record) as the other cell emojis. null when no hazard.
|
||||
const bikeRiskEvent = (r) => {
|
||||
if (!isCycling || !(r.bikeRisk >= 1)) return null;
|
||||
const why = r.bikeRiskWhy ?? [];
|
||||
const advice = why
|
||||
.map(w => BIKE_ADVICE.find(([k]) => w.startsWith(k))?.[1])
|
||||
.filter(Boolean);
|
||||
const lead = advice.join('; ');
|
||||
return {
|
||||
id: `bike-risk-${r.iso}`,
|
||||
emoji: '⚠️',
|
||||
title: r.bikeRisk >= 2 ? 'Risky to ride' : 'Ride with care',
|
||||
message: `${why.join(', ')}${lead ? ` — ${lead.charAt(0).toUpperCase()}${lead.slice(1)}.` : ''}`,
|
||||
};
|
||||
};
|
||||
|
||||
// The ⚠ tag itself. tagEvents is the hour's full tag list with the hazard
|
||||
// first, so the popup can crossfade on to the hour's other events.
|
||||
const bikeRiskTag = (r, tagEvents) => {
|
||||
const ev = tagEvents[0];
|
||||
if (!ev || !ev.id.startsWith('bike-risk-')) return null;
|
||||
const date = r.iso.slice(0, 10);
|
||||
return html`<span class=${`event-cell-tag bike-risk-tag bike-risk-tag--${r.bikeRisk >= 2 ? 'danger' : 'caution'}`}
|
||||
onClick=${(e) => handleEventTagClick(tagEvents, 0, date, e)}
|
||||
onMouseEnter=${(e) => handleEventTagEnter(tagEvents, 0, date, e)}
|
||||
onMouseLeave=${handleEventTagLeave}
|
||||
role="button" tabIndex="0" aria-label=${ev.title}>⚠︎</span>`;
|
||||
};
|
||||
|
||||
// Human-readable date for the "Day at a glance" heading, e.g. "Mon 2nd June 2026".
|
||||
const glanceDate = (() => {
|
||||
const key = days[selectedDay]?.key;
|
||||
@@ -865,6 +912,12 @@ export function UTCIForecast() {
|
||||
hourAt: j2 => (bucket + 2 * j2 + 1) / total,
|
||||
// grid-column for the card whose landing hour is j (lines are 1-based).
|
||||
cardCol: j2 => `${2 * j2 + 2} / span ${2 * bucket}`,
|
||||
// Range mode (multi-hour cards over the hourly curve): the curve shows a
|
||||
// band across each card's hours, so the card is centred over that whole
|
||||
// block instead of its landing hour. Hour j occupies tracks
|
||||
// bucket+2j+1 and bucket+2j+2, so a block of `span` hours from j is:
|
||||
rangeMode: bucket > 1 && hourly !== tableRows,
|
||||
blockCol: (j2, span) => `${bucket + 2 * j2 + 1} / span ${2 * span}`,
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -1423,18 +1476,18 @@ export function UTCIForecast() {
|
||||
<div class="table-toolbar-row">
|
||||
<span class="fvt-toolbar-left">
|
||||
<span class="forecast-view-toggle-label">View:</span>
|
||||
${/* One three-way control. Quick is the card layout; the other
|
||||
${/* One three-way control. Simple is the card layout; the other
|
||||
two are the same hourly table on opposite axes — Detailed
|
||||
runs hours along the top, Table runs them down the side.
|
||||
Rotation used to be a separate toggle that only appeared
|
||||
once you were already in the table, which hid the layout
|
||||
people wanted behind a mode they had to find first. */''}
|
||||
<span class="forecast-view-toggle">
|
||||
<button type="button" title="Quick view – visual card layout" class=${'fvt-btn' + (forecastView === 'simple' ? ' on' : '')}
|
||||
<button type="button" title="Simple view – visual card layout" class=${'fvt-btn' + (forecastView === 'simple' ? ' on' : '')}
|
||||
aria-pressed=${forecastView === 'simple' ? 'true' : 'false'}
|
||||
onClick=${() => setForecastView('simple')}>
|
||||
<svg class="fvt-btn-icon" viewBox="0 0 12 12" width="12" height="12"><rect x=".5" y=".5" width="4.5" height="4.5" rx=".8" fill="currentColor"/><rect x="7" y=".5" width="4.5" height="4.5" rx=".8" fill="currentColor"/><rect x=".5" y="7" width="4.5" height="4.5" rx=".8" fill="currentColor"/><rect x="7" y="7" width="4.5" height="4.5" rx=".8" fill="currentColor"/></svg>
|
||||
Quick
|
||||
Simple
|
||||
</button>
|
||||
<button type="button" title="Detailed view – hours along the top, metrics down the side" class=${'fvt-btn' + (forecastView === 'table' && tableRotated ? ' on' : '')}
|
||||
aria-pressed=${forecastView === 'table' && tableRotated ? 'true' : 'false'}
|
||||
@@ -1593,7 +1646,8 @@ export function UTCIForecast() {
|
||||
})();
|
||||
const windMph = Math.round((r.gust ?? r.va) * 2.237);
|
||||
// Same event emojis the table's hour cell shows, for this card's hours.
|
||||
const cardEvents = getCellTagEvents(selectedDayEvents, r); // Tag shade = the exact gradient stop the curve is painted
|
||||
const cardBikeEv = bikeRiskEvent(r);
|
||||
const cardEvents = [cardBikeEv, ...getCellTagEvents(selectedDayEvents, r)].filter(Boolean); // Tag shade = the exact gradient stop the curve is painted
|
||||
// with at this card's landing hour (fsc-grad, whiteMix 0.42),
|
||||
// so the connector line lands on the colour the tag is
|
||||
// wearing. Band label and weight still come from the band.
|
||||
@@ -1615,16 +1669,18 @@ export function UTCIForecast() {
|
||||
style=${{
|
||||
background: `rgb(${cardRgb[0]},${cardRgb[1]},${cardRgb[2]})`,
|
||||
...(isNow ? {} : { border: `1px solid rgb(${edgeRgb[0]},${edgeRgb[1]},${edgeRgb[2]})` }),
|
||||
...(fscPlot ? { gridColumn: fscPlot.cardCol(fscPlot.landing[ri]) } : {}),
|
||||
...(fscPlot ? { gridColumn: fscPlot.rangeMode
|
||||
? fscPlot.blockCol(fscPlot.landing[ri], r.isoHours ? r.isoHours.length : 1)
|
||||
: fscPlot.cardCol(fscPlot.landing[ri]) } : {}),
|
||||
}}>
|
||||
<div class="fsc-time">${localHHMMs}</div>
|
||||
<div class="fsc-time">${localHHMMs}${bikeRiskTag(r, cardEvents)}</div>
|
||||
<div class="fsc-scope-wrap">
|
||||
<${SkyScope} elev=${r.elev} dt=${r.dt} glob=${r.glob} size=${42}
|
||||
cc=${r.cc} ccLow=${r.ccLow} ccMid=${r.ccMid} ccHigh=${r.ccHigh} visKm=${r.visKm} rh=${r.RH} />
|
||||
${domIcon && html`<div class="fsc-wx-overlay">${domIcon}</div>`}
|
||||
</div>
|
||||
<div class="fsc-ev-row">
|
||||
${cardEvents.map((ev, idx) => html`
|
||||
${cardEvents.map((ev, idx) => (cardBikeEv && idx === 0) ? null : html`
|
||||
<span key=${ev.id} class="event-cell-tag"
|
||||
onClick=${(e) => handleEventTagClick(cardEvents, idx, r.iso.slice(0, 10), e)}
|
||||
onMouseEnter=${(e) => handleEventTagEnter(cardEvents, idx, r.iso.slice(0, 10), e)}
|
||||
@@ -1646,13 +1702,12 @@ export function UTCIForecast() {
|
||||
${(() => {
|
||||
if (!fscPlot) return null;
|
||||
const fscSvgW = 1000, fscSvgH = 80;
|
||||
// The curve plots the SAME rows the cards do. It used to plot the
|
||||
// raw hourly series instead, which agrees with the cards at 1h and
|
||||
// diverges at every longer interval: a card shows its bucket's
|
||||
// aggregate, so an "8am 24°" card (the 8–11am mean) sat above a
|
||||
// curve drawn at 8am's own 14.5°, and its connector pointed at a
|
||||
// height the card never claimed. One series, one number.
|
||||
const fscSrc = tableRows;
|
||||
// The curve always plots the full-resolution hourly rows, so it
|
||||
// keeps its shape at 2h/3h/4h instead of flattening to one point
|
||||
// per card. Each card's connector then drops to the curve at its
|
||||
// landing hour. (At longer intervals the card prints its bucket's
|
||||
// aggregate, so its number can differ from the curve at that hour.)
|
||||
const fscSrc = fscPlot.hours === visible.length ? visible : tableRows;
|
||||
// Fixed scale: bottom = Freezing band bottom (−10) − 10, top = Danger start (44) + 10
|
||||
const fscMin = -15, fscMax = 45;
|
||||
const toY = t => fscSvgH - ((t - fscMin) / (fscMax - fscMin)) * fscSvgH;
|
||||
@@ -1660,11 +1715,14 @@ export function UTCIForecast() {
|
||||
// x of card i — the landing hour it is centred on, via the same
|
||||
// mapping that placed the card in the grid, so a point and its
|
||||
// card share an x by construction.
|
||||
const fscCardX = i => fscPlot.hourAt(fscPlot.landing[i]) * fscSvgW;
|
||||
const fscStopPct = i => (fscPlot.hourAt(fscPlot.landing[i]) * 100).toFixed(1);
|
||||
const fscAllPts = fscSrc.map((r, i) => ({ x: fscCardX(i), y: toY(getT(r)) }));
|
||||
// One point per card now, so the connectors ARE the data points.
|
||||
const fscPts = fscAllPts;
|
||||
// Hour j of the curve sits at hourAt(j); with no hourly series
|
||||
// (fscSrc === tableRows) the index is the card's landing hour instead.
|
||||
const fscHourIdx = j => fscSrc === tableRows ? fscPlot.landing[j] : j;
|
||||
const fscStopPct = j => (fscPlot.hourAt(fscHourIdx(j)) * 100).toFixed(1);
|
||||
const fscAllPts = fscSrc.map((r, j) => ({ x: fscPlot.hourAt(fscHourIdx(j)) * fscSvgW, y: toY(getT(r)) }));
|
||||
// Connector for card i: the curve point at its landing hour.
|
||||
const fscPts = tableRows.map((r, i) =>
|
||||
fscSrc === tableRows ? fscAllPts[i] : fscAllPts[fscPlot.landing[i]]);
|
||||
const fscNowFlags = tableRows.map(r => r.isoHours ? r.isoHours.includes(nowLocalISO) : r.iso.slice(0, 13) === nowLocalISO);
|
||||
// Flat runs from each edge into the first/last hour, so the fill still
|
||||
// covers the inset spacer strips at both ends.
|
||||
@@ -1679,8 +1737,7 @@ export function UTCIForecast() {
|
||||
const cpx = (p0.x + p1.x) / 2;
|
||||
fscFillLine += ` C ${cpx},${p0.y} ${cpx},${p1.y} ${p1.x},${p1.y}`;
|
||||
}
|
||||
const fscFill = fscFillLine + ` L ${fscSvgW},${fscSvgH} L 0,${fscSvgH} Z`;
|
||||
return html`
|
||||
const fscFill = fscFillLine + ` L ${fscSvgW},${fscSvgH} L 0,${fscSvgH} Z`; return html`
|
||||
<svg class="fsc-temp-curve" viewBox="0 0 ${fscSvgW} ${fscSvgH}" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="fsc-grad" x1="0" y1="0" x2="1" y2="0" gradientUnits="objectBoundingBox">
|
||||
@@ -1708,6 +1765,29 @@ export function UTCIForecast() {
|
||||
})}
|
||||
${fscPts.map((pt, i) => {
|
||||
const isNow = fscNowFlags[i];
|
||||
const stroke = isNow ? '#c8922a' : '#b09870';
|
||||
// Multi-hour card over the hourly curve: show the hours its
|
||||
// value is drawn from as a band, edged down to the curve at
|
||||
// its first and last hour, with a bracket along the top.
|
||||
const span = tableRows[i].isoHours ? tableRows[i].isoHours.length : 1;
|
||||
if (fscSrc !== tableRows && span > 1) {
|
||||
const a = fscAllPts[fscPlot.landing[i]];
|
||||
const b = fscAllPts[fscPlot.landing[i] + span - 1] || a;
|
||||
const op = isNow ? '1' : '0.55';
|
||||
// |———| bracket at the top, with dashed guides from its ends
|
||||
// down to the curve.
|
||||
const tick = 8;
|
||||
return html`
|
||||
<g key=${'c'+i} stroke=${stroke} fill="none" vector-effect="non-scaling-stroke">
|
||||
<path d=${`M ${a.x},${tick} L ${a.x},1 L ${b.x},1 L ${b.x},${tick}`}
|
||||
stroke-width="1.5" opacity=${op} vector-effect="non-scaling-stroke" />
|
||||
<line x1=${a.x} y1=${tick} x2=${a.x} y2=${a.y} stroke-width="1" stroke-dasharray="2 3"
|
||||
opacity=${isNow ? '0.7' : '0.35'} vector-effect="non-scaling-stroke" />
|
||||
<line x1=${b.x} y1=${tick} x2=${b.x} y2=${b.y} stroke-width="1" stroke-dasharray="2 3"
|
||||
opacity=${isNow ? '0.7' : '0.35'} vector-effect="non-scaling-stroke" />
|
||||
</g>
|
||||
`;
|
||||
}
|
||||
return html`
|
||||
<line key=${'c'+i} x1=${pt.x} y1="0" x2=${pt.x} y2=${pt.y}
|
||||
stroke=${isNow ? '#c8922a' : '#b09870'}
|
||||
@@ -1718,8 +1798,7 @@ export function UTCIForecast() {
|
||||
`;
|
||||
})}
|
||||
<path d=${fscFill} fill="url(#fsc-grad)" opacity="0.45" />
|
||||
<path d=${fscFill} fill="none" stroke="url(#fsc-grad-strong)" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" vector-effect="non-scaling-stroke" />
|
||||
</svg>
|
||||
<path d=${fscFill} fill="none" stroke="url(#fsc-grad-strong)" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" vector-effect="non-scaling-stroke" /> </svg>
|
||||
`;
|
||||
})()}
|
||||
</div>
|
||||
@@ -1751,15 +1830,18 @@ export function UTCIForecast() {
|
||||
// r.iso is the local wall-clock string from the API — slice it directly.
|
||||
const h24 = parseInt(r.iso.slice(11, 13), 10);
|
||||
const localHHMM = h24 === 0 ? '12am' : h24 < 12 ? `${h24}am` : h24 === 12 ? '12pm' : `${h24 - 12}pm`;
|
||||
const rowEvents = getCellTagEvents(selectedDayEvents, r);
|
||||
const bikeEv = bikeRiskEvent(r);
|
||||
// Hazard first, so its ⚠ and the emojis share one popup list.
|
||||
const rowEvents = [bikeEv, ...getCellTagEvents(selectedDayEvents, r)].filter(Boolean);
|
||||
return html`
|
||||
<span class="utci-time-inner">
|
||||
<${SkyScope} elev=${r.elev} dt=${r.dt} glob=${r.glob} size=${38}
|
||||
cc=${r.cc} ccLow=${r.ccLow} ccMid=${r.ccMid} ccHigh=${r.ccHigh} visKm=${r.visKm} rh=${r.RH} />
|
||||
<span>${localHHMM}</span>
|
||||
${rowEvents.length > 0 && html`
|
||||
${bikeRiskTag(r, rowEvents)}
|
||||
${rowEvents.length > (bikeEv ? 1 : 0) && html`
|
||||
<span class="utci-time-tags">
|
||||
${rowEvents.map((ev, idx) => html`
|
||||
${rowEvents.map((ev, idx) => (bikeEv && idx === 0) ? null : html`
|
||||
<span key=${ev.id} class="event-cell-tag"
|
||||
onClick=${(e) => handleEventTagClick(rowEvents, idx, r.iso.slice(0, 10), e)}
|
||||
onMouseEnter=${(e) => handleEventTagEnter(rowEvents, idx, r.iso.slice(0, 10), e)}
|
||||
@@ -1973,14 +2055,15 @@ export function UTCIForecast() {
|
||||
<div class="insight-panel insight-panel--week">
|
||||
<div class="insight-panel-title">The Week Ahead</div>
|
||||
<div class="insight-panel-sub">${titleCaseText(bestDaysLabel(activeProfile, outdoorsVariant))}</div>
|
||||
${weekAhead.map(({ icon, iconTitle, label, value, score }) => {
|
||||
${weekAhead.map(({ icon, iconTitle, factors, label, value, score }) => {
|
||||
const clickable = score != null;
|
||||
const popupPayload = clickable ? {
|
||||
key: label,
|
||||
title: titleCaseText(label),
|
||||
sub: titleCaseText(value),
|
||||
rows: [
|
||||
...(iconTitle ? [{ icon, text: iconTitle }] : []),
|
||||
// Top three influences, falling back to the single icon title.
|
||||
...(factors?.length ? factors : iconTitle ? [{ icon, text: iconTitle }] : []),
|
||||
{ icon: '📊', text: bestDaysHint(activeProfile, outdoorsVariant, score) },
|
||||
],
|
||||
} : null;
|
||||
|
||||
@@ -35,7 +35,7 @@ import { h, Fragment } from '../../vendor/preact.js';
|
||||
import { useEffect, useRef } from '../../vendor/preact-hooks.js';
|
||||
import htm from '../../vendor/htm.js';
|
||||
import { confidenceBand, utciCategory, petCategory, DAY_TAB_UTCI_BANDS, DAY_TAB_PET_BANDS, bandRampRgb, hexToRgb, mixRgb, rainTint, snowTint, VEHICLE_TYPES, BUILDING_TYPES, FUR_COLORS } from '../utils.js';
|
||||
import { FREE_DAYS, UTCI_ENVIRONMENTS, deriveProfileMain, FILTER_PROFILES, variantIcons } from '../config.js';
|
||||
import { FREE_DAYS, UTCI_ENVIRONMENTS, deriveProfileMain, FILTER_PROFILES, OUTDOORS_VARIANTS, variantIcons } from '../config.js';
|
||||
import { CloudIcon, PrecipIcon, HouseIcon, CarIcon, FogIcon, IceIcon, WindIcon } from '../components.js';
|
||||
import { SubscribeModal } from './SubscribeModal.js';
|
||||
|
||||
@@ -277,6 +277,14 @@ export function DayTabs({
|
||||
: utciEnv;
|
||||
const rowGradient = ROW_GRADIENTS[mainConfigKey]?.[rowOptionKey];
|
||||
|
||||
// Scene thumbnail for the row — the same picture as the card the user
|
||||
// tapped in the profile picker, so the main page visibly matches the
|
||||
// choice. Places/Activities/Work variants carry their own scene; every
|
||||
// other profile uses its FILTER_PROFILES one.
|
||||
const profileScene = activeProfile === 'outdoors'
|
||||
? (OUTDOORS_VARIANTS[outdoorsVariant]?.scene || FILTER_PROFILES.outdoors.scene)
|
||||
: FILTER_PROFILES[activeProfile]?.scene;
|
||||
|
||||
// The row pins to the top of the viewport on wide screens, and the table's
|
||||
// sticky hour header has to stop just below it. Publish the measured height
|
||||
// as --profile-row-h rather than hard-coding one in table.css: the row is
|
||||
@@ -313,6 +321,8 @@ export function DayTabs({
|
||||
onKeyDown=${(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openPanel(); } }}
|
||||
style=${rowGradient ? { '--row-bg': rowGradient } : undefined}
|
||||
title="Change profile & settings">
|
||||
${profileScene && html`
|
||||
<span class="active-profile-row-thumb" aria-hidden="true" style=${{ background: profileScene }}></span>`}
|
||||
<span class="active-profile-row-item">
|
||||
<span class="active-profile-row-label">Profile</span>
|
||||
<span class="active-profile-row-value">
|
||||
|
||||
+287
-67
@@ -17,7 +17,7 @@ import {
|
||||
calcIndoorTempPass, calcManagedIndoorTempPass, calcShadeAirTemp, calcShadeFeltTemp,
|
||||
calcFurSurfaceTempPass,
|
||||
} from './physics.js';
|
||||
import { windCompass8, uvSplit, cloudCategory, precipPenalty, sunburnMinutes, burnLabel, FUR_COLORS, UTCI_BANDS, PET_BANDS } from './utils.js';
|
||||
import { windCompass8, uvSplit, cloudCategory, precipPenalty, feltWindMs, sunburnMinutes, burnLabel, FUR_COLORS, UTCI_BANDS, PET_BANDS } from './utils.js';
|
||||
import { UTCI_ENVIRONMENTS, CROP_CALENDAR, deriveProfileMain } from './config.js';
|
||||
|
||||
export function buildHourlyRows({ forecast, airQuality, location, vehicleType, vehicleVent, vehicleSpeed, buildingType, furColor, utciEnv }) {
|
||||
@@ -96,9 +96,11 @@ export function buildHourlyRows({ forecast, airQuality, location, vehicleType, v
|
||||
const utci = utciApprox(Ta, TmrtStd, va, eh);
|
||||
// SunSoak: same peer-reviewed polynomial, every input shifted by the
|
||||
// active Solar Model (air temp via taOffset, radiation via dir/dif/glob
|
||||
// factors), then the rain/snow soak penalty on top.
|
||||
// factors), then the rain/snow soak penalty on top. Wind is softened
|
||||
// via feltWindMs() so strong mast winds don't over-chill (see utils.js).
|
||||
const vaFelt = feltWindMs(va);
|
||||
const Tmrt = calcTmrt(TaEnv, dirEnv, difEnv, globEnv, elev);
|
||||
const utciEnv = utciApprox(TaEnv, Tmrt, va, eh);
|
||||
const utciEnv = utciApprox(TaEnv, Tmrt, vaFelt, eh);
|
||||
const utciAdj = utciEnv + precipPenalty(precip, snow, va);
|
||||
// Shade air temp: what a thermometer in this Solar Model's typical shade
|
||||
// (building shadow, beach umbrella, canopy...) would read. Shares
|
||||
@@ -109,7 +111,7 @@ export function buildHourlyRows({ forecast, airQuality, location, vehicleType, v
|
||||
// Shade (felt): SunSoak with the direct beam taken away — still outdoors,
|
||||
// so the same wind, humidity and rain penalty apply. See
|
||||
// calcShadeFeltTemp() for why this is a felt number and not the air temp.
|
||||
const shadeT = calcShadeFeltTemp(shadeAirT, difEnv, va, eh, elev, env.shade)
|
||||
const shadeT = calcShadeFeltTemp(shadeAirT, difEnv, vaFelt, eh, elev, env.shade)
|
||||
+ precipPenalty(precip, snow, va);
|
||||
// Derived
|
||||
const compass = windCompass8(wd);
|
||||
@@ -226,6 +228,13 @@ export function buildHourlyRows({ forecast, airQuality, location, vehicleType, v
|
||||
const furAlbedo = (FUR_COLORS[furColor] || FUR_COLORS.brown).albedo;
|
||||
const furTemps = calcFurSurfaceTempPass(TaArr, radArr, vaArr, elevArr, furAlbedo, uvArr, cloudCatArr);
|
||||
hourlyRows.forEach((r, i) => { r.furSurfaceT = furTemps[i]; });
|
||||
|
||||
// Cycling hazard per hour - needs the previous hours' precip for ice.
|
||||
hourlyRows.forEach((r, i) => {
|
||||
const { level, why } = cyclingRisk(r, hourlyRows.slice(Math.max(0, i - 3), i));
|
||||
r.bikeRisk = level;
|
||||
r.bikeRiskWhy = why;
|
||||
});
|
||||
}
|
||||
|
||||
// Group those hourly rows into days for the day tabs.
|
||||
@@ -280,6 +289,57 @@ export function buildHourlyRows({ forecast, airQuality, location, vehicleType, v
|
||||
return { hourlyRows, days, utcOffsetMs };
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// cyclingRisk(r, prevRows) - How risky this hour is to take a bike out.
|
||||
//
|
||||
// Returns { level, why }: level 0 = fine, 1 = caution, 2 = risky; why is
|
||||
// the list of hazards that set it, worst first, e.g. ['Gusts 42 mph'].
|
||||
//
|
||||
// Thresholds (mph = m/s x 2.237):
|
||||
// Gusts >= 30 caution (crosswind shove) >= 40 risky
|
||||
// Wind >= 20 caution (steering effort) >= 28 risky
|
||||
// Rain >= 2 mm/h caution (spray, braking) >= 6 mm/h risky
|
||||
// Snow any caution >= 0.2 cm/h risky
|
||||
// Ice surface <= 2.5 C and wet surface <= 0.5 C and wet
|
||||
// Fog visibility < 1 km caution < 0.2 km risky
|
||||
// Lightning LPI >= 5 caution >= 25 risky
|
||||
//
|
||||
// "Surface" is the colder of air and bare-soil skin temperature - on a
|
||||
// clear night the road chills well below the air. "Wet" is any rain or snow
|
||||
// this hour or the three before it, or near-saturated air (frost / rime).
|
||||
// ------------------------------------------------------------------------
|
||||
const BIKE_GUST_MPH = [30, 40];
|
||||
const BIKE_WIND_MPH = [20, 28];
|
||||
const BIKE_RAIN_MMH = [2, 6];
|
||||
const BIKE_SNOW_CMH = 0.2;
|
||||
const BIKE_ICE_C = [2.5, 0.5];
|
||||
const BIKE_FOG_KM = [1, 0.2];
|
||||
const BIKE_LPI = [5, 25];
|
||||
|
||||
function cyclingRisk(r, prevRows = []) {
|
||||
const hits = []; // [level, text]
|
||||
const gust = (r.gust ?? r.va ?? 0) * 2.237;
|
||||
const wind = (r.va ?? 0) * 2.237;
|
||||
if (gust >= BIKE_GUST_MPH[0]) hits.push([gust >= BIKE_GUST_MPH[1] ? 2 : 1, `Gusts ${Math.round(gust)} mph`]);
|
||||
else if (wind >= BIKE_WIND_MPH[0]) hits.push([wind >= BIKE_WIND_MPH[1] ? 2 : 1, `Wind ${Math.round(wind)} mph`]);
|
||||
|
||||
const precip = r.precip ?? 0;
|
||||
const snow = r.snow ?? 0;
|
||||
if (snow > 0) hits.push([snow >= BIKE_SNOW_CMH ? 2 : 1, 'Snow']);
|
||||
else if (precip >= BIKE_RAIN_MMH[0]) hits.push([precip >= BIKE_RAIN_MMH[1] ? 2 : 1, 'Heavy rain']);
|
||||
|
||||
const surf = Math.min(r.Ta ?? 99, r.soilT0 ?? 99);
|
||||
const wet = precip > 0 || snow > 0 || (r.RH ?? 0) >= 95
|
||||
|| prevRows.some(p => (p.precip ?? 0) > 0 || (p.snow ?? 0) > 0);
|
||||
if (wet && surf <= BIKE_ICE_C[0]) hits.push([surf <= BIKE_ICE_C[1] ? 2 : 1, surf <= BIKE_ICE_C[1] ? 'Ice likely' : 'Possible ice']);
|
||||
|
||||
if (r.visKm != null && r.visKm < BIKE_FOG_KM[0]) hits.push([r.visKm < BIKE_FOG_KM[1] ? 2 : 1, 'Fog']);
|
||||
if ((r.lightning ?? 0) >= BIKE_LPI[0]) hits.push([r.lightning >= BIKE_LPI[1] ? 2 : 1, 'Lightning']);
|
||||
|
||||
hits.sort((a, b) => b[0] - a[0]);
|
||||
return { level: hits.length ? hits[0][0] : 0, why: hits.map(h => h[1]) };
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// aggregateRows(rows, interval) - Compress a day's hourly rows into
|
||||
// multi-hour buckets for the table view (1h / 2h / 3h / 4h).
|
||||
@@ -316,7 +376,7 @@ const AGG_MEAN = [
|
||||
'furSurfaceT', 'pawT', 'petShadeT',
|
||||
'grassPollen', 'birchPollen', 'alderPollen', 'mugwortPollen', 'olivePollen', 'ragweedPollen',
|
||||
];
|
||||
const AGG_MAX = ['precipProb', 'uv', 'uvA', 'uvB', 'gust'];
|
||||
const AGG_MAX = ['precipProb', 'uv', 'uvA', 'uvB', 'gust', 'bikeRisk'];
|
||||
const AGG_SUM = ['precip', 'snow'];
|
||||
// Every temperature the user reads as "how hot/cold does this actually get"
|
||||
// shares one rule, so a 3h row never disagrees with the columns beside it.
|
||||
@@ -362,6 +422,8 @@ export function aggregateRows(rows, interval) {
|
||||
AGG_MAX.forEach(f => { out[f] = maxV(nums(group, f)); });
|
||||
AGG_SUM.forEach(f => { out[f] = group.reduce((a, r) => a + (r[f] || 0), 0); });
|
||||
AGG_FELT.forEach(f => { out[f] = felt(nums(group, f)); });
|
||||
// Cycling hazard reasons follow the bucket's worst hour.
|
||||
out.bikeRiskWhy = group.reduce((w, r) => ((r.bikeRisk ?? 0) > (w.bikeRisk ?? 0) ? r : w), group[0]).bikeRiskWhy;
|
||||
// Bucket span markers used by the table for "now" highlighting and for
|
||||
// matching event cell-tags that fall on any hour within the bucket.
|
||||
out.isoHours = group.map(r => r.iso.slice(0, 13));
|
||||
@@ -392,6 +454,8 @@ 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;
|
||||
// Same softened wind SunSoak uses, so the deltas still sum to utciAdj.
|
||||
const vaFelt = feltWindMs(va);
|
||||
|
||||
// Environment-adjusted air temp (same as used in buildHourlyRows).
|
||||
const TaEnv = Ta + (env ? env.taOffset : 0);
|
||||
@@ -410,10 +474,10 @@ export function computeWhyFeelsLike(row, env) {
|
||||
const sunAndSky = r1(utciApprox(TaEnv, Tmrt, 0, ehNeutral) - base);
|
||||
|
||||
// Wind: cooling effect of actual wind with solar present (RH still neutral).
|
||||
const wind = r1(utciApprox(TaEnv, Tmrt, va, ehNeutral) - utciApprox(TaEnv, Tmrt, 0, ehNeutral));
|
||||
const wind = r1(utciApprox(TaEnv, Tmrt, vaFelt, ehNeutral) - utciApprox(TaEnv, Tmrt, 0, ehNeutral));
|
||||
|
||||
// Humidity: actual vapour pressure vs neutral RH 50%, with solar and wind.
|
||||
const humidity = r1(utciApprox(TaEnv, Tmrt, va, eh) - utciApprox(TaEnv, Tmrt, va, ehNeutral));
|
||||
const humidity = r1(utciApprox(TaEnv, Tmrt, vaFelt, eh) - utciApprox(TaEnv, Tmrt, vaFelt, ehNeutral));
|
||||
|
||||
// Environment modifier - explicit temperature offset from the env config.
|
||||
const environment = env ? r1(env.taOffset) : 0;
|
||||
@@ -815,9 +879,23 @@ export function computeGlanceSummary(todayRows, profile, variant, skinType, cols
|
||||
|
||||
// ── Activities - running / cycling ─────────────────────────────────────
|
||||
if (variant === 'running' || variant === 'cycling') {
|
||||
const isBike = variant === 'cycling';
|
||||
// Any hazard hour (care or risky) is excluded from the "best" cycling
|
||||
// window, so it can never overlap the hazard row below.
|
||||
const coolWins = allWindows(dayRows, r =>
|
||||
r.utciAdj >= 5 && r.utciAdj <= 22 && r.precipProb < 30
|
||||
r.utciAdj >= 5 && r.utciAdj <= 22 && r.precipProb < 30 && !(isBike && r.bikeRisk >= 1)
|
||||
);
|
||||
// Cycling hazards across the whole day - commutes run in the dark too.
|
||||
const bikeWins = isBike ? allWindows(todayRows, r => r.bikeRisk >= 1) : [];
|
||||
const worstBike = isBike ? peakRow('bikeRisk') : null;
|
||||
const bikeItem = isBike ? [{
|
||||
icon: '⚠️',
|
||||
label: worstBike?.bikeRisk >= 2 ? 'Risky to ride' : worstBike?.bikeRisk >= 1 ? 'Ride with care' : 'Ride hazards',
|
||||
value: formatWindows(bikeWins) ?? 'No hazards today',
|
||||
sub: worstBike?.bikeRisk >= 1 ? worstBike.bikeRiskWhy.join(', ') : null,
|
||||
alert: worstBike?.bikeRisk >= 2,
|
||||
grp: 'felt',
|
||||
}] : [];
|
||||
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));
|
||||
@@ -830,10 +908,11 @@ export function computeGlanceSummary(todayRows, profile, variant, skinType, cols
|
||||
{
|
||||
icon: '⏱',
|
||||
label: `Best ${variant} window`,
|
||||
value: formatWindows(coolWins) ?? 'No cool window today',
|
||||
value: formatWindows(coolWins) ?? (isBike && bikeWins.length ? 'No safe window today' : 'No cool window today'),
|
||||
alert: coolWins.length === 0,
|
||||
grp: 'felt',
|
||||
},
|
||||
...bikeItem,
|
||||
...(show('precipProb') ? [rainItem] : []),
|
||||
...lightningItem,
|
||||
...(show('burn') && burnMins ? [{
|
||||
@@ -1000,7 +1079,9 @@ function bestRun(arr, pred) {
|
||||
// actually answers "would I enjoy being outside" - unlike vehicleT or
|
||||
// indoorT, where a "best day" framing would be meaningless.
|
||||
//
|
||||
// Daylight hours only, and hours already past today are skipped, so a warm
|
||||
// Waking hours only (WAKING_HOURS, 7am-10pm, dark or not - the winter
|
||||
// commute and evening walk count; isAllDay profiles run 24 hours), and
|
||||
// hours already past today are skipped, so a warm
|
||||
// morning that has been and gone can't win.
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
@@ -1044,9 +1125,60 @@ const SUNSOAK = plateau(UTCI_BANDS);
|
||||
// in the table - reading the plateau off the same array keeps the panel and
|
||||
// the cell colours in step there too.
|
||||
const PET_PLATEAU = plateau(PET_BANDS);
|
||||
const BAND_GUST = { ideal: 15, limit: 25 }; // mph
|
||||
// Walking in town (to work, to the shops) is not lounging outside: people are
|
||||
// dressed for the weather and moving, so a cool morning is fine. Full marks
|
||||
// from 10 °C (bottom of Chilly) to the top of Warm, ramping to zero at 0 °C
|
||||
// and at the top of Caution. Heat keeps the SunSoak ceiling - hot pavements
|
||||
// are as bad for a walk as for a day out.
|
||||
const URBAN_WALK = {
|
||||
min: 0,
|
||||
flatLo: bandMax(UTCI_BANDS, 'Cold'), // 10 - score 1
|
||||
flatHi: SUNSOAK.flatHi,
|
||||
max: SUNSOAK.max,
|
||||
};
|
||||
// Hours every profile is judged over, as local clock hours [from, to):
|
||||
// 7am-10pm covers the commute, the day and the evening, dark or not.
|
||||
// Daylight alone dropped winter mornings and evenings people are out in;
|
||||
// round the clock let 3am windows win.
|
||||
const WAKING_HOURS = { from: 7, to: 22 };
|
||||
// Profiles and outdoors variants judged round the clock instead: travel,
|
||||
// driving, the house, pets and overnight stays all happen at any hour.
|
||||
const ALL_DAY_PROFILES = ['home', 'vehicle', 'pets'];
|
||||
const ALL_DAY_VARIANTS = ['airport', 'camping', 'festival', 'driver'];
|
||||
const isAllDay = (profile, variant) =>
|
||||
ALL_DAY_PROFILES.includes(profile) ||
|
||||
(profile === 'outdoors' && ALL_DAY_VARIANTS.includes(variant));
|
||||
const BAND_GUST = { ideal: 15, limit: 25 }; // mph
|
||||
const BAND_RAIN = { ideal: 0, limit: 25 }; // % chance
|
||||
const BAND_SOIL = { ideal: 0.10, limit: 0.25 }; // m3/m3 - dry ground to soggy
|
||||
const BAND_BIKE = { ideal: 0, limit: 2 }; // cyclingRisk level - fine to risky
|
||||
const BAND_FOG = { ideal: 5, limit: 1 }; // visibility km - clear to fog (<1 km)
|
||||
const BAND_SUN = { ideal: 20, limit: 90 }; // cloud cover % - sunny to overcast
|
||||
|
||||
// Sunshine. Thermal already credits the sun's warmth, but inside the comfort
|
||||
// plateau a grey 18 °C and a bright 18 °C scored the same - and for a picnic,
|
||||
// a festival or a dog walk the bright one is the better day. Leisure variants
|
||||
// listed here move SUN_WEIGHT from thermal onto cloud cover. Quality only:
|
||||
// cloud never breaks a window, it just costs it marks. Running is left out
|
||||
// (overcast is often better for it), as are the travel, vehicle, indoor and
|
||||
// pet profiles, where sunshine is no part of the answer.
|
||||
const SUNNY_VARIANTS = ['dogwalk', 'cycling', 'hiking', 'photography', 'naturist',
|
||||
'park', 'beach', 'events', 'festival', 'camping'];
|
||||
const SUN_WEIGHT = 0.15;
|
||||
|
||||
// Severe gusts. Past BAND_GUST.limit an hour already scores zero on gust, but
|
||||
// at its normal weight a gale cost a day less than being one degree under the
|
||||
// SunSoak floor. Above GUST_SEVERE (31 mph, near-gale) the wind is THE story,
|
||||
// so its weight in the score and its vote for the day's icon are multiplied.
|
||||
const GUST_SEVERE = 31; // mph
|
||||
const GUST_SEVERE_MULT = 3; // weight / icon-vote multiplier above it
|
||||
const gustMph = (r) => (r.gust ?? r.va ?? 0) * 2.237;
|
||||
const isSevere = (r) => gustMph(r) > GUST_SEVERE;
|
||||
|
||||
// The profile's measure weights for one hour, with gust boosted when severe.
|
||||
function partWeights(r, crit) {
|
||||
return isSevere(r) ? { ...crit.w, gust: crit.w.gust * GUST_SEVERE_MULT } : crit.w;
|
||||
}
|
||||
|
||||
// 1 at the ideal end, 0 at the limit, clamped. Direction is taken from the
|
||||
// two numbers, so it reads the same whether lower or higher is better.
|
||||
@@ -1084,25 +1216,58 @@ function plateauScore(v, pl) {
|
||||
// answer to "when should I drive", so vehicle and indoor windows run around
|
||||
// the clock.
|
||||
function bestDayCriteria(profile, variant) {
|
||||
// Cycling adds the bike-hazard score (bikeRisk): any hazard hour breaks
|
||||
// the window, matching "Best cycling window" in At a glance. Inside a
|
||||
// window bike always scores full, so it only weighs on no-window days -
|
||||
// kept small (soil's share) so it doesn't inflate window scores.
|
||||
if (profile === 'outdoors' && variant === 'cycling') {
|
||||
return { field: 'utciAdj', pl: SUNSOAK, daylightOnly: false, hours: WAKING_HOURS,
|
||||
w: { thermal: 0.40, rain: 0.25, gust: 0.20, soil: 0, bike: 0.15 } };
|
||||
}
|
||||
// Town walking: paving drains and dries fast, so wet ground is dropped;
|
||||
// rain and wind are what spoil a walk, so they carry more of the score.
|
||||
if (profile === 'outdoors' && variant === 'urban') {
|
||||
return { field: 'utciAdj', pl: URBAN_WALK, daylightOnly: false, hours: WAKING_HOURS,
|
||||
w: { thermal: 0.35, rain: 0.40, gust: 0.25, soil: 0 } };
|
||||
}
|
||||
// Airport / travel: wind is what delays and diverts flights and makes
|
||||
// ferries and trains unpleasant, so it carries the most weight, with fog
|
||||
// (low-visibility delays) next. Ground state is irrelevant on runways and
|
||||
// platforms, and travellers are dressed for the trip, so the walking
|
||||
// comfort range is used rather than SunSoak.
|
||||
if (profile === 'outdoors' && variant === 'airport') {
|
||||
return { field: 'utciAdj', pl: URBAN_WALK, daylightOnly: false, hours: WAKING_HOURS,
|
||||
w: { thermal: 0.15, rain: 0.20, gust: 0.40, soil: 0, fog: 0.25 } };
|
||||
}
|
||||
// Every profile is judged over WAKING_HOURS; daylightOnly stays available
|
||||
// but is off everywhere.
|
||||
const { mainField } = deriveProfileMain(profile, variant);
|
||||
switch (mainField) {
|
||||
case 'vehicleT':
|
||||
return { field: 'vehicleT', pl: SUNSOAK, daylightOnly: false,
|
||||
return { field: 'vehicleT', pl: SUNSOAK, daylightOnly: false, hours: WAKING_HOURS,
|
||||
w: { thermal: 0.55, rain: 0.25, gust: 0.20, soil: 0 } };
|
||||
case 'indoorT':
|
||||
return { field: 'indoorT', pl: SUNSOAK, daylightOnly: false,
|
||||
return { field: 'indoorT', pl: SUNSOAK, daylightOnly: false, hours: WAKING_HOURS,
|
||||
w: { thermal: 1, rain: 0, gust: 0, soil: 0 } };
|
||||
case 'furSurfaceT':
|
||||
return { field: 'furSurfaceT', pl: PET_PLATEAU, daylightOnly: true,
|
||||
return { field: 'furSurfaceT', pl: PET_PLATEAU, daylightOnly: false, hours: WAKING_HOURS,
|
||||
w: { thermal: 0.55, rain: 0.30, gust: 0.15, soil: 0 } };
|
||||
default:
|
||||
return { field: 'utciAdj', pl: SUNSOAK, daylightOnly: true,
|
||||
return { field: 'utciAdj', pl: SUNSOAK, daylightOnly: false, hours: WAKING_HOURS,
|
||||
w: { thermal: 0.40, rain: 0.25, gust: 0.20, soil: 0.15 } };
|
||||
}
|
||||
}
|
||||
|
||||
// True when the hour falls inside the profile's clock-hour range, if it has one.
|
||||
function inHours(r, crit) {
|
||||
if (!crit.hours) return true;
|
||||
const h = parseInt(r.iso.slice(11, 13), 10);
|
||||
return h >= crit.hours.from && h < crit.hours.to;
|
||||
}
|
||||
|
||||
function comfortableHour(r, crit) {
|
||||
if (crit.daylightOnly && !(r.elev > 0)) return false;
|
||||
if (!inHours(r, crit)) return false;
|
||||
const t = r[crit.field];
|
||||
if (t == null) return false;
|
||||
if (t < crit.pl.min || t > crit.pl.max) return false;
|
||||
@@ -1115,6 +1280,9 @@ function comfortableHour(r, crit) {
|
||||
// would silently empty the panel everywhere. Only the wet end excludes: dust
|
||||
// -dry ground is a poor day out for the garden, not for the person on it.
|
||||
if (crit.w.soil && r.soilM != null && r.soilM > BAND_SOIL.limit) return false;
|
||||
if (crit.w.bike && r.bikeRisk >= 1) return false;
|
||||
// Visibility can be missing too - treat that as passing, as with soil.
|
||||
if (crit.w.fog && r.visKm != null && r.visKm < BAND_FOG.limit) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1127,6 +1295,11 @@ function hourParts(r, crit) {
|
||||
rain: bandScore(r.precipProb ?? 0, BAND_RAIN),
|
||||
gust: bandScore((r.gust ?? r.va ?? 0) * 2.237, BAND_GUST),
|
||||
soil: bandScore(r.soilM, BAND_SOIL),
|
||||
bike: bandScore(r.bikeRisk ?? 0, BAND_BIKE),
|
||||
fog: bandScore(r.visKm, BAND_FOG),
|
||||
// Daylight only - null after dark drops it from the average, so evening
|
||||
// hours aren't marked down for a sky nobody can see.
|
||||
sun: r.elev > 0 ? bandScore(r.cc, BAND_SUN) : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1134,8 +1307,9 @@ function hourParts(r, crit) {
|
||||
// measure actually decides whether the day was worth going out for.
|
||||
function hourQuality(r, crit) {
|
||||
const p = hourParts(r, crit);
|
||||
const w = partWeights(r, crit);
|
||||
const parts = Object.keys(p)
|
||||
.map(k => [p[k], crit.w[k]])
|
||||
.map(k => [p[k], w[k]])
|
||||
.filter(([v, w]) => v != null && w > 0);
|
||||
const wsum = parts.reduce((s, [, w]) => s + w, 0);
|
||||
return wsum > 0 ? parts.reduce((s, [v, w]) => s + v * w, 0) / wsum : 0;
|
||||
@@ -1162,6 +1336,9 @@ const SHORTFALL_ICON = {
|
||||
soil: { icon: '💧', name: 'Wet ground' },
|
||||
hot: { icon: '🔥', name: 'Heat' },
|
||||
cold: { icon: '🥶', name: 'Cold' },
|
||||
bike: { icon: '⚠️', name: 'Unsafe to ride' },
|
||||
fog: { icon: '🌫️', name: 'Fog' },
|
||||
sun: { icon: '☁️', name: 'Cloudy' },
|
||||
none: { icon: '✅', name: 'Nothing much against it' },
|
||||
};
|
||||
|
||||
@@ -1183,6 +1360,8 @@ function failReasons(r, crit) {
|
||||
if (crit.w.rain && (r.precipProb ?? 0) > BAND_RAIN.limit) out.push('rain');
|
||||
if (crit.w.gust && (r.gust ?? r.va ?? 0) * 2.237 > BAND_GUST.limit) out.push('gust');
|
||||
if (crit.w.soil && r.soilM != null && r.soilM > BAND_SOIL.limit) out.push('soil');
|
||||
if (crit.w.bike && r.bikeRisk >= 1) out.push('bike');
|
||||
if (crit.w.fog && r.visKm != null && r.visKm < BAND_FOG.limit) out.push('fog');
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -1195,7 +1374,11 @@ function shortBecause(c, crit) {
|
||||
const tally = {};
|
||||
for (const r of c.usableRows) {
|
||||
if (inRun.has(r.iso)) continue;
|
||||
for (const k of failReasons(r, crit)) tally[k] = (tally[k] ?? 0) + 1;
|
||||
// A severe-gust hour outvotes a marginal cold or hot one.
|
||||
for (const k of failReasons(r, crit)) {
|
||||
const vote = (k === 'gust' && isSevere(r)) ? GUST_SEVERE_MULT : 1;
|
||||
tally[k] = (tally[k] ?? 0) + vote;
|
||||
}
|
||||
}
|
||||
// Cost is attached by the caller: those hours were ruled out entirely, so
|
||||
// what this influence cost is the whole of the length shortfall.
|
||||
@@ -1213,55 +1396,69 @@ const SHORTFALL_FLOOR = 0.05;
|
||||
// the tooltip's number and the bar's number are in the same units.
|
||||
const pts = (frac) => Math.round(frac * 100);
|
||||
|
||||
function limitingFactor(c, crit, span) {
|
||||
const run = c.run;
|
||||
// Mean score per measure across the run, so one bad hour in nine can't
|
||||
// name the day.
|
||||
const sums = {}, counts = {};
|
||||
// Returns up to three influences ({ icon, name, cost }), worst first.
|
||||
function limitingFactors(c, crit, span) {
|
||||
// A day with no window has no run to read the measures from, so its whole
|
||||
// usable day stands in for it.
|
||||
const run = c.run.length ? c.run : c.usableRows;
|
||||
// Marks each measure lost per hour, averaged across the run so one bad hour
|
||||
// in nine can't name the day. Per-hour weights (partWeights) so a severe
|
||||
// gust costs here exactly what it cost in hourQuality.
|
||||
const lostSum = {};
|
||||
let hours = 0;
|
||||
for (const r of run) {
|
||||
const p = hourParts(r, crit);
|
||||
for (const k of Object.keys(p)) {
|
||||
if (p[k] == null || !crit.w[k]) continue;
|
||||
sums[k] = (sums[k] ?? 0) + p[k];
|
||||
counts[k] = (counts[k] ?? 0) + 1;
|
||||
}
|
||||
const w = partWeights(r, crit);
|
||||
const keys = Object.keys(p).filter(k => p[k] != null && w[k]);
|
||||
const wsum = keys.reduce((s, k) => s + w[k], 0);
|
||||
if (!wsum) continue;
|
||||
hours++;
|
||||
for (const k of keys) lostSum[k] = (lostSum[k] ?? 0) + (w[k] / wsum) * (1 - p[k]);
|
||||
}
|
||||
const wsum = Object.keys(counts).reduce((s, k) => s + crit.w[k], 0);
|
||||
|
||||
// Marks lost, in points of the final score: quality is half of it, split
|
||||
// between the measures by weight; window length is the other half.
|
||||
const lost = [['short', 0.5 * (1 - span)]];
|
||||
for (const k of Object.keys(counts)) {
|
||||
const mean = sums[k] / counts[k];
|
||||
lost.push([k, 0.5 * (crit.w[k] / wsum) * (1 - mean)]);
|
||||
}
|
||||
for (const k of Object.keys(lostSum)) lost.push([k, 0.5 * lostSum[k] / hours]);
|
||||
lost.sort((a, b) => b[1] - a[1]);
|
||||
if (lost[0][1] < SHORTFALL_FLOOR) return SHORTFALL_ICON.none;
|
||||
|
||||
// Length lost the most marks: name what ruled the other hours out. If they
|
||||
// can't say, drop through to whichever measure was weakest inside the
|
||||
// window - still an influence, which is the whole point of the icon.
|
||||
if (lost[0][0] === 'short') {
|
||||
const because = shortBecause(c, crit);
|
||||
if (because) return { ...because, cost: pts(lost[0][1]) };
|
||||
lost.shift();
|
||||
if (lost.length === 0) return SHORTFALL_ICON.none;
|
||||
}
|
||||
const [worst, amount] = lost[0];
|
||||
const cost = pts(amount);
|
||||
|
||||
// Thermal has two opposite causes and they want opposite icons - a cold
|
||||
// morning and a scorching afternoon are not the same warning.
|
||||
if (worst === 'thermal') {
|
||||
const meanT = run.reduce((s, r) => s + (r[crit.field] ?? 0), 0) / run.length;
|
||||
return { ...(meanT > crit.pl.flatHi ? SHORTFALL_ICON.hot : SHORTFALL_ICON.cold), cost };
|
||||
const meanT = run.reduce((s, r) => s + (r[crit.field] ?? 0), 0) / run.length;
|
||||
const thermalIcon = meanT > crit.pl.flatHi ? SHORTFALL_ICON.hot : SHORTFALL_ICON.cold;
|
||||
|
||||
// Turn each measure's lost marks into a named influence. Length lost names
|
||||
// what ruled the other hours out; if they can't say it is dropped, so the
|
||||
// list only ever holds weather influences.
|
||||
const byName = {};
|
||||
for (const [k, amount] of lost) {
|
||||
if (amount < SHORTFALL_FLOOR) continue;
|
||||
const f = k === 'short' ? shortBecause(c, crit)
|
||||
: k === 'thermal' ? thermalIcon
|
||||
: SHORTFALL_ICON[k];
|
||||
if (!f) continue;
|
||||
// Length blamed on wind and wind inside the window are one influence.
|
||||
byName[f.name] = byName[f.name]
|
||||
? { ...byName[f.name], amount: byName[f.name].amount + amount }
|
||||
: { ...f, amount };
|
||||
}
|
||||
return { ...SHORTFALL_ICON[worst], cost };
|
||||
|
||||
// Top three by marks lost, worst first - the first is the row's icon.
|
||||
const top = Object.values(byName)
|
||||
.sort((a, b) => b.amount - a.amount)
|
||||
.slice(0, 3)
|
||||
.map(({ amount, ...f }) => ({ ...f, cost: pts(amount) }));
|
||||
return top.length ? top : [SHORTFALL_ICON.none];
|
||||
}
|
||||
|
||||
export function computeBestDay(weekDays, nowLocalISO, profile, variant) {
|
||||
if (!weekDays || weekDays.length === 0) return [];
|
||||
const crit = bestDayCriteria(profile, variant);
|
||||
// Round-the-clock profiles drop the waking-hours limit.
|
||||
if (isAllDay(profile, variant)) crit.hours = null;
|
||||
// Sun-seeking leisure variants trade some thermal weight for sunshine.
|
||||
if (profile === 'outdoors' && SUNNY_VARIANTS.includes(variant)) {
|
||||
crit.w = { ...crit.w, thermal: crit.w.thermal - SUN_WEIGHT, sun: SUN_WEIGHT };
|
||||
}
|
||||
|
||||
const candidates = [];
|
||||
for (const day of weekDays.slice(0, 7)) {
|
||||
@@ -1281,17 +1478,29 @@ export function computeBestDay(weekDays, nowLocalISO, profile, variant) {
|
||||
start = -1; len = 0;
|
||||
}
|
||||
}
|
||||
if (!dayBest || dayBest.len < PICK_MIN_HOURS) continue;
|
||||
// Hours the day is judged against (daylight only for outdoor profiles,
|
||||
// waking hours for town walking).
|
||||
const usableRows = (crit.daylightOnly ? rows.filter(r => r.elev > 0) : rows)
|
||||
.filter(r => inHours(r, crit));
|
||||
// Nothing left to judge (e.g. today after dark) - no honest score exists.
|
||||
if (usableRows.length === 0) continue;
|
||||
|
||||
// A day with no window of PICK_MIN_HOURS is still shown, with a low score
|
||||
// and the reason it failed - hiding it made the week look shorter than it is.
|
||||
const hasWindow = dayBest && dayBest.len >= PICK_MIN_HOURS;
|
||||
|
||||
// Average quality across the run. Length alone can't separate days: in a
|
||||
// temperate summer week half of them qualify dawn to dusk, and picking
|
||||
// arbitrarily among those is a coin toss.
|
||||
const run = rows.slice(dayBest.start, dayBest.end + 1);
|
||||
const quality = run.reduce((s, r) => s + hourQuality(r, crit), 0) / run.length;
|
||||
// arbitrarily among those is a coin toss. With no window the whole
|
||||
// usable day is averaged instead.
|
||||
const run = hasWindow ? rows.slice(dayBest.start, dayBest.end + 1) : [];
|
||||
const qRows = hasWindow ? run : usableRows;
|
||||
const quality = qRows.reduce((s, r) => s + hourQuality(r, crit), 0) / qRows.length;
|
||||
|
||||
candidates.push({
|
||||
len: dayBest.len, quality, key: day.key, run,
|
||||
from: rows[dayBest.start], to: rows[dayBest.end],
|
||||
len: hasWindow ? dayBest.len : 0, quality, key: day.key, run, hasWindow,
|
||||
from: hasWindow ? rows[dayBest.start] : null,
|
||||
to: hasWindow ? rows[dayBest.end] : null,
|
||||
// What the window is measured AGAINST - and, for the hours outside the
|
||||
// run, the evidence for WHY it stopped where it did: the daylight for a
|
||||
// profile that
|
||||
@@ -1299,11 +1508,11 @@ export function computeBestDay(weekDays, nowLocalISO, profile, variant) {
|
||||
// for a round-the-clock profile would let a 14-hour overnight window
|
||||
// score over 100% of a 16-hour day and print "all day" for a spell that
|
||||
// ends at breakfast.
|
||||
usableRows: crit.daylightOnly ? rows.filter(r => r.elev > 0) : rows,
|
||||
usableRows,
|
||||
});
|
||||
}
|
||||
// Nothing qualifying is itself worth saying - an empty panel reads as a bug,
|
||||
// and "don't bother this week" is a real answer to the question being asked.
|
||||
// No day left anything to judge (e.g. only dark hours remain) - an empty
|
||||
// panel reads as a bug, so say so.
|
||||
if (candidates.length === 0) {
|
||||
return [{
|
||||
icon: crit.field === 'utciAdj' ? '🌧️' : '🚫',
|
||||
@@ -1330,13 +1539,14 @@ export function computeBestDay(weekDays, nowLocalISO, profile, variant) {
|
||||
c.score = Math.round(100 * (0.5 * span + 0.5 * c.quality));
|
||||
// Same span the score used, so the icon can never blame the weather for
|
||||
// marks that were actually lost to a short window.
|
||||
c.cause = limitingFactor(c, crit, span);
|
||||
c.causes = limitingFactors(c, crit, span);
|
||||
c.cause = c.causes[0];
|
||||
}
|
||||
|
||||
// A settled week really can have seven good days, and cutting it to a
|
||||
// shortlist there would say the opposite of what the forecast shows. Only
|
||||
// days that actually qualify get a row, so the panel still stays short in
|
||||
// an unsettled week - the cap is the week itself, not an arbitrary four.
|
||||
// shortlist there would say the opposite of what the forecast shows. Every
|
||||
// day of the week gets a row - poor days with a low bar and their reason -
|
||||
// so the cap is the week itself, not an arbitrary four.
|
||||
const shown = [...candidates]
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, PICK_MAX_DAYS)
|
||||
@@ -1355,19 +1565,29 @@ export function computeBestDay(weekDays, nowLocalISO, profile, variant) {
|
||||
|
||||
// The end row is the last comfortable hour, so the window runs to the end
|
||||
// of it - 9am-12pm means 9:00 up to 12:59.
|
||||
const endIso = `${c.to.iso.slice(0, 11)}${String((parseInt(c.to.iso.slice(11, 13), 10) + 1) % 24).padStart(2, '0')}:00`;
|
||||
// c.to is null on a day with no window, which has no end time to build.
|
||||
const endIso = c.to
|
||||
? `${c.to.iso.slice(0, 11)}${String((parseInt(c.to.iso.slice(11, 13), 10) + 1) % 24).padStart(2, '0')}:00`
|
||||
: null;
|
||||
|
||||
// A run spanning nearly all the daylight is better said than shown: printing
|
||||
// "6am - 9pm" makes the reader parse a time range to learn "all day".
|
||||
const window = c.usableRows.length > 0 && c.len >= c.usableRows.length - 1
|
||||
? 'all day'
|
||||
: `${hh(c.from.iso)} – ${hh(endIso)}`;
|
||||
// A day with no window shows its reason in words instead of a time range.
|
||||
// Kept to the cause name alone: anything longer than "10am – 4pm" wraps
|
||||
// onto a second line in the narrow rail.
|
||||
const window = !c.hasWindow
|
||||
? c.cause.name
|
||||
: c.len >= c.usableRows.length - 1
|
||||
? 'all day'
|
||||
: `${hh(c.from.iso)} – ${hh(endIso)}`;
|
||||
|
||||
return {
|
||||
// Not a calendar - the row already says which day. The icon carries the
|
||||
// one thing the score cannot: what is holding the day back.
|
||||
icon: c.cause.icon,
|
||||
iconTitle: causeTip(c.cause),
|
||||
// The top three influences for the row's popup, worst first.
|
||||
factors: c.causes.map(f => ({ icon: f.icon, text: causeTip(f) })),
|
||||
label: dayName,
|
||||
value: window,
|
||||
// Rows stay in date order - the order you plan in - so the ranking is
|
||||
|
||||
+1
-1
@@ -113,7 +113,7 @@ export const OUTDOORS_VARIANTS = {
|
||||
// Activities - free
|
||||
cycling: { name: 'Cycling', scene: "url('assets/images/profiles/cycling.png') center / cover no-repeat, linear-gradient(160deg,#c2d4cc,#e2ece6)", cols: { hour: true, air: true, shadeT: false, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, utci: false, uvA: false, uvB: false, burn: true, utciP: true, precip: true, precipProb: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false } },
|
||||
running: { name: 'Running', scene: "url('assets/images/profiles/running.png') center / cover no-repeat, linear-gradient(160deg,#c2d4cc,#e2ece6)", cols: { hour: true, air: true, shadeT: false, rh: true, dew: false, wind: true, dir: false, cloud: true, sun: false, direct: false, diffuse: false, utci: true, uvA: false, uvB: false, burn: true, utciP: true, precip: true, precipProb: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: false, aqi: false, pollen: true } },
|
||||
dogwalk: { name: 'Dog Walking', scene: "url('assets/images/profiles/walking.png') center / cover no-repeat, linear-gradient(160deg,#c2d4cc,#e2ece6)", cols: { hour: true, air: true, shadeT: false, rh: false, dew: false, wind: true, dir: false, cloud: true, sun: false, direct: false, diffuse: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, precipProb: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: false, aqi: false, pollen: true } },
|
||||
dogwalk: { name: 'Dog Walking', scene: "url('assets/images/profiles/walking.png') center / cover no-repeat, linear-gradient(160deg,#c2d4cc,#e2ece6)", cols: { hour: true, air: true, shadeT: false, rh: false, dew: false, wind: true, dir: false, cloud: true, sun: false, direct: false, diffuse: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, precipProb: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: false, aqi: false, pollen: true, furSurfaceT: true, pawT: true, petShadeT: true, petHomeT: false } },
|
||||
// Activities - Pro
|
||||
hiking: { name: 'Hiking', proOnly: true, scene: "url('assets/images/profiles/hiking.png') center / cover no-repeat, linear-gradient(160deg,#c2d4cc,#e2ece6)", cols: { hour: true, air: true, shadeT: false, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: true, direct: false, diffuse: false, utci: true, uvA: true, uvB: true, burn: true, utciP: true, precip: true, precipProb: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false } },
|
||||
photography: { name: 'Photography', proOnly: true, scene: "url('assets/images/profiles/photography.png') center / cover no-repeat, linear-gradient(160deg,#c2d4cc,#e2ece6)", cols: { hour: true, air: true, shadeT: true, rh: false, dew: false, wind: false, dir: false, cloud: true, sun: true, direct: true, diffuse: true, utci: false, uvA: false, uvB: false, burn: false, utciP: false, precip: true, precipProb: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false } },
|
||||
|
||||
@@ -357,7 +357,7 @@ export function buildColumnDefs(ctx) {
|
||||
render: (r) => ({
|
||||
style: { background: scaleBg((r.gust ?? r.va) * 2.237, 0, 40, '85,130,180') },
|
||||
content: html`${Math.round(r.va * 2.237)}${u('mph')}${r.gust != null && r.gust > r.va + 0.5
|
||||
? (gm => html`<span style=${{ marginLeft: '4px', fontWeight: gm >= 25 ? 700 : 'normal', opacity: gm >= 25 ? 1 : 0.65, color: gm >= 55 ? '#b81010' : gm >= 40 ? '#d44010' : gm >= 25 ? '#c47a00' : 'inherit' }}>(${Math.round(gm)}${u('mph')})</span>`)(r.gust * 2.237)
|
||||
? (gm => html`<span class="wind-gust" style=${{ fontWeight: gm >= 25 ? 700 : 'normal', opacity: gm >= 25 ? 1 : 0.65, color: gm >= 55 ? '#b81010' : gm >= 40 ? '#d44010' : gm >= 25 ? '#c47a00' : 'inherit' }}>(${Math.round(gm)}${u('mph')})</span>`)(r.gust * 2.237)
|
||||
: ''}`,
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -279,6 +279,27 @@ export function precipPenalty(precipMm, snowCmH, windMs) {
|
||||
return -Math.round(penalty * 10) / 10;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// FELT WIND - softened 10 m wind for the SunSoak family only.
|
||||
// -------------------------------------------------------------------
|
||||
// The UTCI polynomial takes the 10 m mast wind, and its cooling climbs
|
||||
// steeply: at 12 °C a 27 mph mean wind (typical of 50 mph gusts) took
|
||||
// ~17 °C off. People at ground level, in town or behind a hedge,
|
||||
// rarely feel the full mast wind, and each extra mph chills less than
|
||||
// the last. So the first FELT_WIND_FULL m/s counts in full and the
|
||||
// rest is compressed logarithmically:
|
||||
// felt = FULL + SCALE * ln(1 + (wind - FULL) / SCALE)
|
||||
// e.g. 10 mph -> 7.7, 20 mph -> 10.3, 30 mph -> 11.7, 50 mph -> 13.5 mph.
|
||||
// Smaller SCALE = flatter top. Plain UTCI keeps the raw wind as the
|
||||
// unmodified benchmark.
|
||||
// -------------------------------------------------------------------
|
||||
const FELT_WIND_FULL = 2; // m/s (~4.5 mph) passed through unchanged
|
||||
const FELT_WIND_SCALE = 1.5; // m/s - how fast the log curve flattens
|
||||
export function feltWindMs(windMs) {
|
||||
if (windMs == null || windMs <= FELT_WIND_FULL) return windMs;
|
||||
return FELT_WIND_FULL + FELT_WIND_SCALE * Math.log(1 + (windMs - FELT_WIND_FULL) / FELT_WIND_SCALE);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// WIND COMPASS - meteorological bearing (deg FROM) - 8-point label.
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
@@ -277,5 +277,19 @@
|
||||
"home": 1,
|
||||
"basic": 1
|
||||
}
|
||||
},
|
||||
"2026-10-12": {
|
||||
"visits": 12,
|
||||
"profiles": {
|
||||
"farming": 1,
|
||||
"basic": 5,
|
||||
"home": 2,
|
||||
"vehicle": 4,
|
||||
"pets": 3
|
||||
}
|
||||
},
|
||||
"2026-10-26": {
|
||||
"visits": 4,
|
||||
"profiles": []
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user