diff --git a/assets/css/table.css b/assets/css/table.css index 9baef47..04c7a91 100644 --- a/assets/css/table.css +++ b/assets/css/table.css @@ -1939,6 +1939,10 @@ width: max-content; min-width: 100%; } +/* Half-hour tracks across the whole day, plus a blank inset spacer at each + end (see fscPlot in app.js). Each card spans 2*interval tracks positioned + so it is centred on the hour it is labelled with; the outer cards overhang + into the spacers instead of off the edge. Columns are set inline. */ .forecast-simple-cards { display: grid; padding: 12px 0 0; diff --git a/assets/js/app.js b/assets/js/app.js index 490b670..bfae2da 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -573,6 +573,61 @@ export function UTCIForecast() { alert: false, })); + // ─── 3b. QUICK-VIEW PLOT GEOMETRY ──────────────────────────────────── + // Shared by the simple-view cards and the temperature curve beneath them + // so the two can never drift apart. + // + // The curve is drawn from the full-resolution hourly rows (all 24 hours, + // never resampled), but each card covers a bucket of `tableInterval` + // hours and is LABELLED with that bucket's LANDING hour — a 4h card + // reading "8pm" spans 8–11pm and prints the worst felt temp in that span, + // which on a cooling evening is 8pm's own value. So a card must sit over + // its landing hour, not over the bucket's temporal middle (which is where + // an evenly-divided row of cards puts it: at 4h the "8pm / 28°" card + // landed above 9:30pm, past sunset, and pointed into the cold green tail). + // + // Laying the row out in hour columns fixes that, but a card is `bucket` + // hours wide while its landing hour sits only half an hour in from the + // bucket's start, so the first card would hang off the left edge. Hence + // the inset spacers: a blank half-bucket of track at each end for the + // outer cards to overhang into. + // + // The grid is measured in HALF-hour tracks, because a card centred on its + // landing hour starts on a half-hour boundary. All tracks are identical, + // and each card SPANS 2*bucket of them (rather than sitting in one track + // at width:400%) — spanning divides a card's intrinsic width across the + // tracks it covers, so the grid's max-content width stays close to what + // it was and mobile doesn't gain a load of extra horizontal scroll. + // + // tracks = [ pad ][ hour 0 ][ hour 1 ] … [ hour H-1 ][ pad ] + // pad = bucket half-hours (>= the (bucket-1)/2 h overhang, + breathing room) + // hour j -> centre at (bucket + 2j + 1) / total + // card -> spans 2*bucket tracks, starting one half-hour after 2j + // => centre = 2j + 1 + bucket == hour j's centre ✓ + const fscPlot = (() => { + if (!tableRows.length) return null; + const cards = tableRows.length; + const hourly = visible.length > cards ? visible : tableRows; + const hours = hourly.length; + const bucket = Math.max(1, tableInterval || 1); + const total = 2 * hours + 2 * bucket; // half-hour tracks + // Landing-hour index of each card, accumulated so partial buckets at a + // day boundary stay correct rather than assuming i * bucket. + const landing = []; + let j = 0; + for (const r of tableRows) { landing.push(j); j += r.isoHours ? r.isoHours.length : 1; } + // A card spans 2*bucket tracks, so this keeps its 55px minimum. + const unit = 55 / (2 * bucket); + return { + hourly, hours, bucket, landing, + cols: `repeat(${total}, minmax(${unit.toFixed(2)}px, 1fr))`, + // Fraction of the track width at which hour j's data point sits. + 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}`, + }; + })(); + // ─── 4. JSX RETURN ─────────────────────────────────────────────────── // Everything below is the actual page markup, written as one big HTM // template. Search tips: @@ -1092,8 +1147,8 @@ export function UTCIForecast() {
-
- ${tableRows.map(r => { +
+ ${tableRows.map((r, ri) => { const dispTemp = r[simpleTemp] ?? r.utciAdj; const cat = simpleTemp === 'furSurfaceT' ? petCategory(dispTemp) : utciCategory(dispTemp); const h24s = parseInt(r.iso.slice(11, 13), 10); @@ -1108,7 +1163,8 @@ export function UTCIForecast() { })(); const windMph = Math.round((r.gust ?? r.va) * 2.237); return html` -
+
${localHHMMs}
<${SkyScope} elev=${r.elev} dt=${r.dt} glob=${r.glob} size=${42} /> @@ -1126,42 +1182,30 @@ export function UTCIForecast() { })}
${(() => { - if (!tableRows.length) return null; - const fscN = tableRows.length; + if (!fscPlot) return null; const fscSvgW = 1000, fscSvgH = 80; - const fscCardW = fscSvgW / fscN; - // Use full 1-hour data for the smooth curve, tableRows for connectors/gradient - const fscSrc = visible.length > fscN ? visible : tableRows; + const fscSrc = fscPlot.hourly; // 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; const getT = r => r[simpleTemp] ?? r.utciAdj; - // 1-hour points for the smooth curve — evenly spread across SVG width - const fscAllPts = fscSrc.map((r, j) => ({ - x: (j + 0.5) * fscSvgW / fscSrc.length, - y: toY(getT(r)), - })); - // Connector points — x centred under card column, y at the exact point - // the 1-hour bezier curve passes through at that x. - // For a bucket of size n starting at visible index jFirst, the card - // centre x lands at the bezier midpoint between visible[jFirst+(n-1)/2 floor] - // and visible[jFirst+(n-1)/2 ceil], so y = lerp of those two neighbours. - let fscVj = 0; - const fscPts = tableRows.map((r, i) => { - const bucketLen = r.isoHours ? r.isoHours.length : 1; - const jFirst = fscVj; - fscVj += bucketLen; - const x = (i + 0.5) * fscCardW; - const ctr = (bucketLen - 1) / 2; - const jLow = Math.min(jFirst + Math.floor(ctr), fscSrc.length - 1); - const jHigh = Math.min(jFirst + Math.ceil(ctr), fscSrc.length - 1); - const frac = ctr - Math.floor(ctr); - const tCtr = getT(fscSrc[jLow]) * (1 - frac) + getT(fscSrc[jHigh]) * frac; - return { x, y: toY(tCtr) }; - }); + // Same hour->x mapping the cards grid uses (see fscPlot above), so a + // card's centre and its hour's data point are the same x by + // construction — every hour of the day stays on screen. + const fscHourX = j => fscPlot.hourAt(j) * fscSvgW; + const fscStopPct = j => (fscPlot.hourAt(j) * 100).toFixed(1); + const fscAllPts = fscSrc.map((r, j) => ({ x: fscHourX(j), y: toY(getT(r)) })); + // Connector points — one per card, planted on its landing hour's data + // point, which is exactly where that card is centred. + const fscPts = fscPlot.landing.map(j => fscAllPts[Math.min(j, fscAllPts.length - 1)]); const fscNowFlags = tableRows.map(r => r.isoHours ? r.isoHours.includes(nowLocalISO) : r.iso.slice(0, 13) === nowLocalISO); - // Extended fill points (edge-anchored) for the gradient area - const fscFillPts = [{ x: 0, y: fscAllPts[0].y }, ...fscAllPts, { x: fscSvgW, y: fscAllPts[fscAllPts.length - 1].y }]; + // Flat runs from each edge into the first/last hour, so the fill still + // covers the inset spacer strips at both ends. + const fscFillPts = [ + { x: 0, y: fscAllPts[0].y }, + ...fscAllPts, + { x: fscSvgW, y: fscAllPts[fscAllPts.length - 1].y }, + ]; let fscFillLine = `M ${fscFillPts[0].x},${fscFillPts[0].y}`; for (let i = 1; i < fscFillPts.length; i++) { const p0 = fscFillPts[i - 1], p1 = fscFillPts[i]; @@ -1176,14 +1220,14 @@ export function UTCIForecast() { ${fscSrc.map((r, j) => { const rgb = airTempRgbStrong(getT(r)) || [200, 200, 200]; const fc = `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`; - return html``; + return html``; })} ${fscSrc.map((r, j) => { const rgb = airTempRgbVeryStrong(getT(r)) || [200, 200, 200]; const fc = `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`; - return html``; + return html``; })}