Fix mobile table issues
This commit is contained in:
fraxle
2026-05-13 02:51:10 +01:00
parent 044b4a59a9
commit ced7ddeba7
2 changed files with 147 additions and 77 deletions
+100 -62
View File
@@ -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>