3.5.1
Fix simple/quick card positions to accurate locations on the graph
This commit is contained in:
@@ -1939,6 +1939,10 @@
|
|||||||
width: max-content;
|
width: max-content;
|
||||||
min-width: 100%;
|
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 {
|
.forecast-simple-cards {
|
||||||
display: grid;
|
display: grid;
|
||||||
padding: 12px 0 0;
|
padding: 12px 0 0;
|
||||||
|
|||||||
+79
-35
@@ -573,6 +573,61 @@ export function UTCIForecast() {
|
|||||||
alert: false,
|
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 ───────────────────────────────────────────────────
|
// ─── 4. JSX RETURN ───────────────────────────────────────────────────
|
||||||
// Everything below is the actual page markup, written as one big HTM
|
// Everything below is the actual page markup, written as one big HTM
|
||||||
// template. Search tips:
|
// template. Search tips:
|
||||||
@@ -1092,8 +1147,8 @@ export function UTCIForecast() {
|
|||||||
<div class="forecast-simple-wrap">
|
<div class="forecast-simple-wrap">
|
||||||
<div class="forecast-simple-scroll" ref=${fscScrollRef}>
|
<div class="forecast-simple-scroll" ref=${fscScrollRef}>
|
||||||
<div class="forecast-simple-inner">
|
<div class="forecast-simple-inner">
|
||||||
<div class="forecast-simple-cards" style=${{ gridTemplateColumns: `repeat(${tableRows.length}, minmax(55px, 1fr))` }}>
|
<div class="forecast-simple-cards" style=${{ gridTemplateColumns: fscPlot?.cols }}>
|
||||||
${tableRows.map(r => {
|
${tableRows.map((r, ri) => {
|
||||||
const dispTemp = r[simpleTemp] ?? r.utciAdj;
|
const dispTemp = r[simpleTemp] ?? r.utciAdj;
|
||||||
const cat = simpleTemp === 'furSurfaceT' ? petCategory(dispTemp) : utciCategory(dispTemp);
|
const cat = simpleTemp === 'furSurfaceT' ? petCategory(dispTemp) : utciCategory(dispTemp);
|
||||||
const h24s = parseInt(r.iso.slice(11, 13), 10);
|
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);
|
const windMph = Math.round((r.gust ?? r.va) * 2.237);
|
||||||
return html`
|
return html`
|
||||||
<div key=${r.iso} class=${'fsc-card' + (isNow ? ' fsc-card--now' : '')}>
|
<div key=${r.iso} class=${'fsc-card' + (isNow ? ' fsc-card--now' : '')}
|
||||||
|
style=${fscPlot && { gridColumn: fscPlot.cardCol(fscPlot.landing[ri]) }}>
|
||||||
<div class="fsc-time">${localHHMMs}</div>
|
<div class="fsc-time">${localHHMMs}</div>
|
||||||
<div class="fsc-scope-wrap">
|
<div class="fsc-scope-wrap">
|
||||||
<${SkyScope} elev=${r.elev} dt=${r.dt} glob=${r.glob} size=${42} />
|
<${SkyScope} elev=${r.elev} dt=${r.dt} glob=${r.glob} size=${42} />
|
||||||
@@ -1126,42 +1182,30 @@ export function UTCIForecast() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
${(() => {
|
${(() => {
|
||||||
if (!tableRows.length) return null;
|
if (!fscPlot) return null;
|
||||||
const fscN = tableRows.length;
|
|
||||||
const fscSvgW = 1000, fscSvgH = 80;
|
const fscSvgW = 1000, fscSvgH = 80;
|
||||||
const fscCardW = fscSvgW / fscN;
|
const fscSrc = fscPlot.hourly;
|
||||||
// Use full 1-hour data for the smooth curve, tableRows for connectors/gradient
|
|
||||||
const fscSrc = visible.length > fscN ? visible : tableRows;
|
|
||||||
// Fixed scale: bottom = Freezing band bottom (−10) − 10, top = Danger start (44) + 10
|
// Fixed scale: bottom = Freezing band bottom (−10) − 10, top = Danger start (44) + 10
|
||||||
const fscMin = -15, fscMax = 45;
|
const fscMin = -15, fscMax = 45;
|
||||||
const toY = t => fscSvgH - ((t - fscMin) / (fscMax - fscMin)) * fscSvgH;
|
const toY = t => fscSvgH - ((t - fscMin) / (fscMax - fscMin)) * fscSvgH;
|
||||||
const getT = r => r[simpleTemp] ?? r.utciAdj;
|
const getT = r => r[simpleTemp] ?? r.utciAdj;
|
||||||
// 1-hour points for the smooth curve — evenly spread across SVG width
|
// Same hour->x mapping the cards grid uses (see fscPlot above), so a
|
||||||
const fscAllPts = fscSrc.map((r, j) => ({
|
// card's centre and its hour's data point are the same x by
|
||||||
x: (j + 0.5) * fscSvgW / fscSrc.length,
|
// construction — every hour of the day stays on screen.
|
||||||
y: toY(getT(r)),
|
const fscHourX = j => fscPlot.hourAt(j) * fscSvgW;
|
||||||
}));
|
const fscStopPct = j => (fscPlot.hourAt(j) * 100).toFixed(1);
|
||||||
// Connector points — x centred under card column, y at the exact point
|
const fscAllPts = fscSrc.map((r, j) => ({ x: fscHourX(j), y: toY(getT(r)) }));
|
||||||
// the 1-hour bezier curve passes through at that x.
|
// Connector points — one per card, planted on its landing hour's data
|
||||||
// For a bucket of size n starting at visible index jFirst, the card
|
// point, which is exactly where that card is centred.
|
||||||
// centre x lands at the bezier midpoint between visible[jFirst+(n-1)/2 floor]
|
const fscPts = fscPlot.landing.map(j => fscAllPts[Math.min(j, fscAllPts.length - 1)]);
|
||||||
// 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) };
|
|
||||||
});
|
|
||||||
const fscNowFlags = tableRows.map(r => r.isoHours ? r.isoHours.includes(nowLocalISO) : r.iso.slice(0, 13) === nowLocalISO);
|
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
|
// Flat runs from each edge into the first/last hour, so the fill still
|
||||||
const fscFillPts = [{ x: 0, y: fscAllPts[0].y }, ...fscAllPts, { x: fscSvgW, y: fscAllPts[fscAllPts.length - 1].y }];
|
// 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}`;
|
let fscFillLine = `M ${fscFillPts[0].x},${fscFillPts[0].y}`;
|
||||||
for (let i = 1; i < fscFillPts.length; i++) {
|
for (let i = 1; i < fscFillPts.length; i++) {
|
||||||
const p0 = fscFillPts[i - 1], p1 = fscFillPts[i];
|
const p0 = fscFillPts[i - 1], p1 = fscFillPts[i];
|
||||||
@@ -1176,14 +1220,14 @@ export function UTCIForecast() {
|
|||||||
${fscSrc.map((r, j) => {
|
${fscSrc.map((r, j) => {
|
||||||
const rgb = airTempRgbStrong(getT(r)) || [200, 200, 200];
|
const rgb = airTempRgbStrong(getT(r)) || [200, 200, 200];
|
||||||
const fc = `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`;
|
const fc = `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`;
|
||||||
return html`<stop key=${j} offset=${`${((j + 0.5) / fscSrc.length * 100).toFixed(1)}%`} stop-color=${fc} />`;
|
return html`<stop key=${j} offset=${`${fscStopPct(j)}%`} stop-color=${fc} />`;
|
||||||
})}
|
})}
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
<linearGradient id="fsc-grad-strong" x1="0" y1="0" x2="1" y2="0" gradientUnits="objectBoundingBox">
|
<linearGradient id="fsc-grad-strong" x1="0" y1="0" x2="1" y2="0" gradientUnits="objectBoundingBox">
|
||||||
${fscSrc.map((r, j) => {
|
${fscSrc.map((r, j) => {
|
||||||
const rgb = airTempRgbVeryStrong(getT(r)) || [200, 200, 200];
|
const rgb = airTempRgbVeryStrong(getT(r)) || [200, 200, 200];
|
||||||
const fc = `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`;
|
const fc = `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`;
|
||||||
return html`<stop key=${`s${j}`} offset=${`${((j + 0.5) / fscSrc.length * 100).toFixed(1)}%`} stop-color=${fc} />`;
|
return html`<stop key=${`s${j}`} offset=${`${fscStopPct(j)}%`} stop-color=${fc} />`;
|
||||||
})}
|
})}
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
|
|||||||
Reference in New Issue
Block a user