- ${visible.map((r) => {
+ ${visible.map((r, rowIdx) => {
+ const rPrev = visible[rowIdx - 1];
+ const rNext = visible[rowIdx + 1];
const cat = utciCategory(r.utci);
const isNight = r.elev < 0;
const isNow = r.iso.slice(0, 13) === nowLocalISO;
@@ -570,45 +572,51 @@ ${(isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeC
const fmt = (v, dp = 1) => v == null ? '—' : (showDecimals ? v.toFixed(dp) : String(Math.round(v)));
const u = (unit) => showUnits ? unit : '';
// Returns a background tint based on temperature value - all share the same
- // Continuous temperature colour scale — used for all temp columns
- // (air, dew, indoor, vehicle, concrete, soil, UTCI, SunSoak…).
- // Fully opaque, richly saturated stops so every degree is distinct.
- const airTempBg = (t) => {
- if (t == null) return 'transparent';
- // [temp °C, [r,g,b]]
- const stops = [
- [-10, [ 90, 155, 220]],
- [ 0, [140, 195, 235]],
- [ 10, [155, 215, 195]],
- [ 16, [140, 210, 140]],
- [ 20, [195, 225, 110]],
- [ 24, [240, 225, 80]],
- [ 28, [250, 175, 65]],
- [ 32, [240, 120, 55]],
- [ 36, [220, 70, 50]],
- [ 40, [185, 30, 30]],
- [ 50, [130, 0, 20]],
- ];
+ // Continuous temperature colour scale — used for all temp columns.
+ // airTempRgb(t) returns the whiteblended [r,g,b] for a temperature.
+ // airTempBg(t, tPrev, tNext) returns a top→bottom gradient that
+ // flows from the previous row's colour through this row's colour to
+ // the next row's colour, making the whole column one seamless heatmap.
+ const TEMP_STOPS = [
+ [-10, [ 90, 155, 220]],
+ [ 0, [140, 195, 235]],
+ [ 10, [155, 215, 195]],
+ [ 16, [140, 210, 140]],
+ [ 20, [195, 225, 110]],
+ [ 24, [240, 225, 80]],
+ [ 28, [250, 175, 65]],
+ [ 32, [240, 120, 55]],
+ [ 36, [220, 70, 50]],
+ [ 40, [185, 30, 30]],
+ [ 50, [130, 0, 20]],
+ ];
+ const airTempRgb = (t) => {
+ if (t == null) return null;
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
- let i = stops.findIndex(s => t < s[0]);
- if (i === -1) i = stops.length;
- const baseRgb = i === 0 ? stops[0][1]
- : i >= stops.length ? stops[stops.length-1][1]
+ let i = TEMP_STOPS.findIndex(s => t < s[0]);
+ if (i === -1) i = TEMP_STOPS.length;
+ const raw = i === 0 ? TEMP_STOPS[0][1]
+ : i >= TEMP_STOPS.length ? TEMP_STOPS[TEMP_STOPS.length-1][1]
: (() => {
- const [t0, c0] = stops[i-1];
- const [t1, c1] = stops[i];
+ const [t0, c0] = TEMP_STOPS[i-1];
+ const [t1, c1] = TEMP_STOPS[i];
const x = clamp((t - t0) / (t1 - t0), 0, 1);
const lerp = (a, b) => Math.round(a + (b - a) * x);
return [lerp(c0[0],c1[0]), lerp(c0[1],c1[1]), lerp(c0[2],c1[2])];
})();
- // Blend base colour 25% toward white before applying the sweep.
- const [r,g,b] = baseRgb.map(c => Math.min(255, Math.round(c + (255 - c) * 0.66)));
- const lighten = (c) => Math.min(255, Math.round(c + (255 - c) * 0.30));
- const darken = (c) => Math.round(c * 0.93);
- const light = `rgb(${lighten(r)},${lighten(g)},${lighten(b)})`;
- const mid = `rgb(${r},${g},${b})`;
- const dark = `rgb(${darken(r)},${darken(g)},${darken(b)})`;
- return `linear-gradient(135deg, ${light} 0%, ${mid} 60%, ${dark} 100%)`;
+ // Blend 66% toward white for the pastel look.
+ return raw.map(c => Math.min(255, Math.round(c + (255 - c) * 0.66)));
+ };
+ const toRgb = (rgb) => rgb ? `rgb(${rgb[0]},${rgb[1]},${rgb[2]})` : 'transparent';
+ const airTempBg = (t, tPrev, tNext) => {
+ if (t == null) return 'transparent';
+ // Top colour = midpoint temp between prev row and this row.
+ // Bottom colour = midpoint temp between this row and next row.
+ // This guarantees the boundary colour is identical on both sides
+ // of every cell edge, giving a perfectly seamless column gradient.
+ const tTop = tPrev != null ? (tPrev + t) / 2 : t;
+ const tBot = tNext != null ? (t + tNext) / 2 : t;
+ return `linear-gradient(to bottom, ${toRgb(airTempRgb(tTop))} 0%, ${toRgb(airTempRgb(tBot))} 100%)`;
};
const airTempFontColor = () => '#1a1a1a';
// Returns any extra band styles (fontWeight, textShadow) for a given temp.
@@ -665,19 +673,19 @@ ${(isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeC
${visibleCols.utciP && html`
- |
+ |
${fmt(r.utciAdj)}${u('°C')}
| `}
${visibleCols.vehicleT && html`
-
+ |
${fmt(r.vehicleT)}${u('°C')}
| `}
${indoorMode === 'on' && !indoorManaged && html`
-
+ |
${fmt(r.indoorT)}${u('°C')}
| `}
${indoorMode === 'on' && indoorManaged && html`
-
+ |
${fmt(r.managedT)}${u('°C')}
| `}
${visibleCols.burn && html`
@@ -685,7 +693,7 @@ ${(isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeC
${burnLabel(burnMins)}
`}
${visibleCols.utci && html`
-
+ |
${fmt(r.utci)}${u('°C')}
| `}
${visibleCols.delta && html`
@@ -695,20 +703,20 @@ ${(isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeC
}}>
${delta > 0 ? '+' : ''}${fmt(delta)}${u('°C')}
`}
- ${visibleCols.tmrt && html`${fmt(r.Tmrt)}${u('°C')} | `}
+ ${visibleCols.tmrt && html`${fmt(r.Tmrt)}${u('°C')} | `}
${visibleCols.concreteT && html`
-
+ |
${fmt(r.concreteT)}${u('°C')}
| `}
${visibleCols.soilT && html`
- ${fmt(r.soilT0)}${u('°C')} | `}
+ ${fmt(r.soilT0)}${u('°C')} | `}
${visibleCols.soilT6 && html`
- ${fmt(r.soilT6)}${u('°C')} | `}
+ ${fmt(r.soilT6)}${u('°C')} | `}
${visibleCols.soilM && html`
${r.soilM != null ? fmt(r.soilM * 100, 1) + u('%') : '—'} | `}
- ${visibleCols.air && html`${fmt(r.Ta)}${u('°C')} | `}
+ ${visibleCols.air && html`${fmt(r.Ta)}${u('°C')} | `}
${visibleCols.rh && html`${Math.round(r.RH)}${u('%')} | `}
- ${visibleCols.dew && html`${fmt(r.dew)}${u('°C')} | `}
+ ${visibleCols.dew && html`${fmt(r.dew)}${u('°C')} | `}
${visibleCols.precip && html`
0 ? scaleBg(r.snow, 0, 4, '90,140,210') : scaleBg(r.precip, 0, 8, '70,145,200') }}>
|