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:
+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;
|
||||
|
||||
Reference in New Issue
Block a user