Beta4
Fix mobile table issues
This commit is contained in:
+100
-62
@@ -854,12 +854,12 @@ function ScopeReticle({ value, cat, loading, elev = 0, dt = new Date() }) {
|
||||
font-family="Fraunces, serif" font-weight="700">
|
||||
${value.toFixed(1)}°
|
||||
</text>
|
||||
<text x=${cx} y=${cy + 7} text-anchor="middle"
|
||||
<text x=${cx} y=${cy + 15} text-anchor="middle"
|
||||
fill=${readoutMutedColor} font-size="6.5"
|
||||
font-family="JetBrains Mono, monospace" letter-spacing="2">
|
||||
UTCI NOW
|
||||
</text>
|
||||
<text x=${cx} y=${cy + 19} text-anchor="middle"
|
||||
<text x=${cx} y=${cy + 23} text-anchor="middle"
|
||||
fill=${glowColor} font-size="7"
|
||||
font-family="JetBrains Mono, monospace" letter-spacing="0.8">
|
||||
${cat.label.toUpperCase()}
|
||||
@@ -974,8 +974,8 @@ function UTCIForecast() {
|
||||
// true = visible on first load (and the only ones free users see)
|
||||
// false = hidden by default (Pro users can toggle these on)
|
||||
const [visibleCols, setVisibleCols] = useState({
|
||||
hour: true, air: true, rh: true, dew: true,
|
||||
wind: true, dir: true,
|
||||
hour: true, air: true, rh: false, dew: false,
|
||||
wind: true, dir: false,
|
||||
cloud: false, sun: false, direct: false, diffuse: false,
|
||||
tmrt: false, delta: false, utci: false,
|
||||
uvA: false, uvB: false, burn: false,
|
||||
@@ -1015,47 +1015,84 @@ function UTCIForecast() {
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
// Synchronise the head and body table column widths.
|
||||
// Each column is sized to max(60px, header's natural width, body's
|
||||
// natural width) so neither header labels nor body data ever feel
|
||||
// squashed, and header text never overflows into the next column.
|
||||
const MIN_COL = 60;
|
||||
// Synchronise the head and body table column widths with a
|
||||
// "shrink-to-fit then distribute" strategy:
|
||||
// • Measure each column's true natural (content-fit) width by
|
||||
// temporarily switching both tables to table-layout: auto +
|
||||
// width: max-content. White-space: nowrap on cells stops content
|
||||
// from wrapping, so the measurement is the smallest width that
|
||||
// won't clip the content.
|
||||
// • If the body scroller has spare horizontal space (natural total
|
||||
// < container width), scale every column up proportionally to
|
||||
// fill it — so toggling columns off makes the remaining ones fan
|
||||
// out instead of leaving an awkward gap.
|
||||
// • Otherwise apply the natural widths as-is and let the body
|
||||
// scroller's overflow-x: auto produce a horizontal scrollbar.
|
||||
const sync = () => {
|
||||
const headTable = headTableRef.current;
|
||||
const bodyTable = bodyTableRef.current;
|
||||
if (!headTable || !bodyTable) return;
|
||||
const headTable = headTableRef.current;
|
||||
const bodyTable = bodyTableRef.current;
|
||||
const bodyScroll = bodyScrollRef.current;
|
||||
if (!headTable || !bodyTable || !bodyScroll) return;
|
||||
const bodyRow = bodyTable.querySelector('tbody tr');
|
||||
const headRow = headTable.querySelector('thead tr');
|
||||
if (!bodyRow || !headRow) return;
|
||||
const headCells = Array.from(headRow.children);
|
||||
const bodyCells = Array.from(bodyRow.children);
|
||||
const n = Math.min(headCells.length, bodyCells.length);
|
||||
// Step 1: clear any previously-forced widths so we can read each
|
||||
// cell's *natural* width (the width it'd take with just min-width
|
||||
// and content driving it).
|
||||
headCells.forEach(c => {
|
||||
c.style.width = '';
|
||||
c.style.minWidth = '';
|
||||
c.style.maxWidth = '';
|
||||
});
|
||||
bodyCells.forEach(c => {
|
||||
c.style.width = '';
|
||||
c.style.minWidth = '';
|
||||
c.style.maxWidth = '';
|
||||
});
|
||||
headTable.style.width = '';
|
||||
// Reading getBoundingClientRect forces layout — that's what we want.
|
||||
// Step 2: compute final widths as max(MIN_COL, headNatural, bodyNatural).
|
||||
const finalW = new Array(n);
|
||||
let totalWidth = 0;
|
||||
if (n === 0) return;
|
||||
|
||||
// Step 1: clear any previously-forced cell widths and switch the
|
||||
// tables to natural sizing so the measurement reflects the true
|
||||
// content-fit width — independent of how wide the container is.
|
||||
headCells.forEach(c => { c.style.width = ''; c.style.minWidth = ''; c.style.maxWidth = ''; });
|
||||
bodyCells.forEach(c => { c.style.width = ''; c.style.minWidth = ''; c.style.maxWidth = ''; });
|
||||
headTable.style.width = 'max-content';
|
||||
bodyTable.style.width = 'max-content';
|
||||
headTable.style.tableLayout = 'auto';
|
||||
bodyTable.style.tableLayout = 'auto';
|
||||
|
||||
// Step 2: read each cell's natural width. getBoundingClientRect
|
||||
// forces synchronous layout — that's what we want.
|
||||
const naturalW = new Array(n);
|
||||
let naturalTotal = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const headW = headCells[i].getBoundingClientRect().width;
|
||||
const bodyW = bodyCells[i].getBoundingClientRect().width;
|
||||
const w = Math.max(MIN_COL, Math.ceil(headW), Math.ceil(bodyW));
|
||||
finalW[i] = w;
|
||||
totalWidth += w;
|
||||
const w = Math.max(Math.ceil(headW), Math.ceil(bodyW));
|
||||
naturalW[i] = w;
|
||||
naturalTotal += w;
|
||||
}
|
||||
// Step 3: apply the final width to both head and body cells.
|
||||
|
||||
// Step 3: decide final widths based on available container width.
|
||||
const containerW = bodyScroll.clientWidth;
|
||||
const finalW = new Array(n);
|
||||
let totalWidth;
|
||||
if (naturalTotal > 0 && naturalTotal < containerW) {
|
||||
// Spare space — distribute proportionally across columns so they
|
||||
// fan out to fill the scroller (no awkward right-hand gap).
|
||||
const scale = containerW / naturalTotal;
|
||||
let running = 0;
|
||||
for (let i = 0; i < n - 1; i++) {
|
||||
finalW[i] = Math.floor(naturalW[i] * scale);
|
||||
running += finalW[i];
|
||||
}
|
||||
// Absorb sub-pixel rounding into the last column so the total
|
||||
// exactly matches the container width.
|
||||
finalW[n - 1] = containerW - running;
|
||||
totalWidth = containerW;
|
||||
} else {
|
||||
// Naturals don't fit — use them as-is and let the body scroll.
|
||||
for (let i = 0; i < n; i++) finalW[i] = naturalW[i];
|
||||
totalWidth = naturalTotal;
|
||||
}
|
||||
|
||||
// Step 4: restore the CSS-defined table-layout: fixed so the
|
||||
// explicit cell widths we apply below are honoured by the browser
|
||||
// (not redistributed by the auto-layout algorithm).
|
||||
headTable.style.tableLayout = '';
|
||||
bodyTable.style.tableLayout = '';
|
||||
|
||||
// Step 5: apply the final width to both head and body cells.
|
||||
for (let i = 0; i < n; i++) {
|
||||
const px = `${finalW[i]}px`;
|
||||
headCells[i].style.width = px;
|
||||
@@ -1065,19 +1102,24 @@ function UTCIForecast() {
|
||||
bodyCells[i].style.minWidth = px;
|
||||
bodyCells[i].style.maxWidth = px;
|
||||
}
|
||||
// Make the header table the same total width as the body table
|
||||
// so the inner track has somewhere to translate to.
|
||||
// Make both tables exactly totalWidth wide so they share the same
|
||||
// horizontal extent — column N in the header sits directly above
|
||||
// column N in the body, no drift as you scroll right.
|
||||
headTable.style.width = `${totalWidth}px`;
|
||||
bodyTable.style.width = `${totalWidth}px`;
|
||||
// Re-apply current horizontal offset so column alignment survives.
|
||||
handleBodyScroll();
|
||||
};
|
||||
// Run once after layout
|
||||
sync();
|
||||
// Re-sync if the body table reflows (columns toggle, content changes)
|
||||
// Re-sync when the scroll container's width changes (window resize,
|
||||
// sidebar opens, etc). We observe the scroller — not the body table —
|
||||
// because the body table's width is now driven by sync itself, which
|
||||
// would otherwise create a feedback loop.
|
||||
let ro = null;
|
||||
if (typeof ResizeObserver !== 'undefined' && bodyTableRef.current) {
|
||||
if (typeof ResizeObserver !== 'undefined' && bodyScrollRef.current) {
|
||||
ro = new ResizeObserver(sync);
|
||||
ro.observe(bodyTableRef.current);
|
||||
ro.observe(bodyScrollRef.current);
|
||||
}
|
||||
window.addEventListener('resize', sync);
|
||||
return () => {
|
||||
@@ -1500,7 +1542,7 @@ function UTCIForecast() {
|
||||
<div class="col-toggles">
|
||||
<span class="col-toggles-label">Columns:</span>
|
||||
${[
|
||||
{ key: 'hour', label: 'Hour' },
|
||||
// Hour and UTCI+P are always-on — no toggle button for them.
|
||||
{ key: 'air', label: 'Air' },
|
||||
{ key: 'rh', label: 'RH' },
|
||||
{ key: 'dew', label: 'Dew' },
|
||||
@@ -1520,7 +1562,6 @@ function UTCIForecast() {
|
||||
{ key: 'uvB', label: 'UV-B' },
|
||||
{ key: 'burn', label: 'Burn' },
|
||||
{ key: 'precip', label: 'Precip' },
|
||||
{ key: 'utciP', label: 'UTCI+P' },
|
||||
].map(c => html`
|
||||
<button
|
||||
key=${c.key}
|
||||
@@ -1564,15 +1605,15 @@ function UTCIForecast() {
|
||||
<table class="utci-table utci-table-head" ref=${headTableRef}>
|
||||
<thead>
|
||||
<tr>
|
||||
${visibleCols.hour && html`<th>Hour</th>`}
|
||||
${visibleCols.air && html`<th>Air <span class="col-unit">°C</span></th>`}
|
||||
<th>Hour</th>
|
||||
${visibleCols.air && html`<th class="utci-tight-head">Air <span class="col-unit">°C</span></th>`}
|
||||
${visibleCols.rh && html`<th>RH <span class="col-unit">%</span></th>`}
|
||||
${visibleCols.dew && html`<th>Dew <span class="col-unit">°C</span></th>`}
|
||||
${visibleCols.soilT && html`<th>Soil °C <span class="col-unit">surface</span></th>`}
|
||||
${visibleCols.soilT6 && html`<th>Soil 6cm <span class="col-unit">°C root</span></th>`}
|
||||
${visibleCols.soilM && html`<th>Soil moist <span class="col-unit">m³/m³</span></th>`}
|
||||
${visibleCols.wind && html`<th>Wind <span class="col-unit">m/s (gust)</span></th>`}
|
||||
${visibleCols.dir && html`<th>Dir <span class="col-unit">compass</span></th>`}
|
||||
${visibleCols.dir && html`<th class="utci-dir-cell">Dir <span class="col-unit">-</span></th>`}
|
||||
${visibleCols.cloud && html`<th>Cloud <span class="col-unit">%</span></th>`}
|
||||
${visibleCols.sun && html`<th>Sun <span class="col-unit">elev°</span></th>`}
|
||||
${visibleCols.direct && html`<th>Direct <span class="col-unit">W/m²</span></th>`}
|
||||
@@ -1583,8 +1624,8 @@ function UTCIForecast() {
|
||||
${visibleCols.uvA && html`<th>UV-A <span class="col-unit">est. idx</span></th>`}
|
||||
${visibleCols.uvB && html`<th>UV-B <span class="col-unit">est. idx</span></th>`}
|
||||
${visibleCols.burn && html`<th>Burn <span class="col-unit">to MED</span></th>`}
|
||||
${visibleCols.precip && html`<th>Precip <span class="col-unit">mm/h</span></th>`}
|
||||
${visibleCols.utciP && html`<th>UTCI+P <span class="col-unit">°C adj.</span></th>`}
|
||||
${visibleCols.precip && html`<th class="utci-tight-head">Pcpt <span class="col-unit">mm/h</span></th>`}
|
||||
<th>UTCI+P <span class="col-unit">°C adj.</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
@@ -1607,14 +1648,12 @@ function UTCIForecast() {
|
||||
return html`
|
||||
<tr key=${r.iso}
|
||||
class=${`${isNight ? 'is-night' : ''} ${isNow ? 'is-now' : ''}`.trim()}>
|
||||
${visibleCols.hour && html`
|
||||
<td class="utci-time">
|
||||
${isNow && html`<span class="now-pip"></span>`}
|
||||
<span style=${{ display: 'inline-flex', alignItems: 'center', gap: '7px', verticalAlign: 'middle' }}>
|
||||
<${SkyScope} elev=${r.elev} dt=${r.dt} size=${30} />
|
||||
<span>${r.dt.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })}</span>
|
||||
</span>
|
||||
</td>`}
|
||||
<td class="utci-time">
|
||||
<span style=${{ display: 'inline-flex', alignItems: 'center', gap: '7px', verticalAlign: 'middle' }}>
|
||||
<${SkyScope} elev=${r.elev} dt=${r.dt} size=${30} />
|
||||
<span>${r.dt.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })}</span>
|
||||
</span>
|
||||
</td>
|
||||
${visibleCols.air && html`<td>${r.Ta.toFixed(1)}</td>`}
|
||||
${visibleCols.rh && html`<td>${Math.round(r.RH)}</td>`}
|
||||
${visibleCols.dew && html`<td>${r.dew != null ? r.dew.toFixed(1) : '—'}</td>`}
|
||||
@@ -1629,10 +1668,10 @@ function UTCIForecast() {
|
||||
? html`<span style=${{ opacity: 0.65, marginLeft: '4px' }}>(${r.gust.toFixed(1)})</span>`
|
||||
: ''}
|
||||
</td>`}
|
||||
${visibleCols.dir && html`<td>
|
||||
${visibleCols.dir && html`<td class="utci-dir-cell">
|
||||
<span style=${{ display: 'inline-flex', alignItems: 'center', gap: '6px', verticalAlign: 'middle' }}>
|
||||
<${WindVane} bearing=${r.wd} size=${28} />
|
||||
<span style=${{ fontFamily: 'JetBrains Mono, monospace', fontSize: '11px' }}>${r.compass.label}</span>
|
||||
<span class="wind-dir-label" style=${{ fontFamily: 'JetBrains Mono, monospace', fontSize: '11px' }}>${r.compass.label}</span>
|
||||
</span>
|
||||
</td>`}
|
||||
${visibleCols.cloud && html`<td>
|
||||
@@ -1674,12 +1713,11 @@ function UTCIForecast() {
|
||||
<td style=${{ color: r.snow > 0 ? '#6090c8' : r.precip > 0 ? '#5090b0' : '#c0a880' }}>
|
||||
${r.snow > 0 ? '❅ ' + r.snow.toFixed(1) + 'cm' : r.precip > 0 ? r.precip.toFixed(1) : '—'}
|
||||
</td>`}
|
||||
${visibleCols.utciP && html`
|
||||
<td style=${{ background: 'rgba(180,215,250,0.10)' }}>
|
||||
<span class="utci-cell utci-cell-hero" style=${{ background: adjCat.bg, color: adjCat.fg }}>
|
||||
${r.utciAdj.toFixed(1)}
|
||||
</span>
|
||||
</td>`}
|
||||
<td style=${{ background: 'rgba(180,215,250,0.10)' }}>
|
||||
<span class="utci-cell utci-cell-hero" style=${{ background: adjCat.bg, color: adjCat.fg }}>
|
||||
${r.utciAdj.toFixed(1)}
|
||||
</span>
|
||||
</td>
|
||||
</tr>`;
|
||||
})}
|
||||
</tbody>
|
||||
|
||||
Reference in New Issue
Block a user