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
+47 -15
View File
@@ -82,7 +82,7 @@ body {
color: #1e1208;
background: #f5edd6;
min-height: 100vh;
padding: 52px 28px 32px;
padding: 52px 10px 32px;
position: relative;
/* overflow-x: clip (not hidden) — `clip` clips horizontal overflow
WITHOUT making this a scroll container, so `position: sticky` on
@@ -564,6 +564,12 @@ body {
width: 100%;
border-collapse: collapse;
font-size: 14px;
/* Fixed layout so the explicit per-cell widths set by the JS width-sync
(see useLayoutEffect in sunscope.js) are actually enforced. Without
this the browser's auto layout treats those widths as suggestions and
redistributes space, which makes the header and body columns drift
apart the further right you scroll. */
table-layout: fixed;
}
/* Column headings (Hour, Air, RH, Wind...).
@@ -627,29 +633,40 @@ body {
border-right: 1px solid #ede4cc;
}
/* The Dir column is centered on both desktop and mobile so the
compass vane sits with even padding either side — especially
important on mobile where the compass label is hidden and there's
no text to balance the right-aligned content. */
.utci-table th.utci-dir-cell,
.utci-table td.utci-dir-cell {
text-align: center;
}
/* "Tight" header — for columns whose body content is narrower than
the default tracked-out header would suggest (Air, Pcpt). Reducing
the letter-spacing shrinks the heading's natural width so it stops
driving the column wider than the body needs. Right-aligned values
then sit with balanced visual padding on either side. */
.utci-table th.utci-tight-head {
letter-spacing: 0.04em;
}
/* ── 8. NOW-ROW & NIGHT-ROW DECORATIONS ─────────────────────────────── */
/* "Currently this hour" — the highlighted row */
/* "Currently this hour" — the highlighted row.
The row gets a cream-yellow background and darker text. No left
stripe or pip — the colour shift alone signals "this is now". */
.utci-table tbody tr.is-now {
background: #fff8e4 !important;
box-shadow: inset 3px 0 #c8922a; /* brass stripe down the left */
}
.utci-table tbody tr.is-now td {
color: #1e1208;
}
/* The little brass pip in the time cell of the current hour */
.now-pip {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background: #c8922a;
margin-right: 7px;
vertical-align: middle;
box-shadow: 0 0 8px #c8922a73;
/* Brass bracket framing the current-hour row top and bottom — clean,
definitive, no left stripe needed. */
border-top: 2px solid #c8922a;
border-bottom: 2px solid #c8922a;
}
/* Night-time hours get a slightly muted text colour */
@@ -843,4 +860,19 @@ body {
margin: 0 auto;
}
/* Halve the hourly-table cell padding on mobile so more columns fit
in the viewport before the horizontal scrollbar kicks in. */
.utci-table th {
padding: 7px 6px;
}
.utci-table td {
padding: 5px 6px;
}
/* Drop the compass-label text ("N", "NW"...) next to the wind vane on
mobile — the vane arrow alone is enough at small sizes. */
.wind-dir-label {
display: none;
}
}
+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>