From ced7ddeba753571781efbe9f17f63d84686a7497 Mon Sep 17 00:00:00 2001 From: fraxle Date: Wed, 13 May 2026 02:51:10 +0100 Subject: [PATCH] Beta4 Fix mobile table issues --- assets/sunscope.css | 62 +++++++++++++---- assets/sunscope.js | 162 +++++++++++++++++++++++++++----------------- 2 files changed, 147 insertions(+), 77 deletions(-) diff --git a/assets/sunscope.css b/assets/sunscope.css index 38df579..14c0b78 100644 --- a/assets/sunscope.css +++ b/assets/sunscope.css @@ -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; + } + } diff --git a/assets/sunscope.js b/assets/sunscope.js index 54a84ce..06eb12f 100644 --- a/assets/sunscope.js +++ b/assets/sunscope.js @@ -854,12 +854,12 @@ function ScopeReticle({ value, cat, loading, elev = 0, dt = new Date() }) { font-family="Fraunces, serif" font-weight="700"> ${value.toFixed(1)}° - UTCI NOW - ${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() {
Columns: ${[ - { 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`