// ------------------------------------------------------------------------ // useTableScroll - owns the horizontal scroll behaviour of the hourly // table (sticky header + body scroller layout). // // Responsibilities: // 1. SCROLL INDICATORS - track whether the body can scroll left/right so // the table edges can show fade + chevron indicators. // 2. DRAG-TO-SCROLL - pointer-event drag on the body scroller for // desktop users. // 3. SCROLL SYNC - keep the sticky header track shifted horizontally to // match the body's scrollLeft, and keep header cell widths in lock- // step with body cell widths even as columns toggle / window resizes. // // Inputs (passed by app.js): // refs: { headTableRef, bodyTableRef, bodyScrollRef, headTrackRef } // deps: { forecast, visibleCols, selectedDay, skinType, vehicleType, // indoorMode, indoorManaged } // - anything that should cause a re-sync when it changes. // // Outputs: // tableCanScrollLeft, tableCanScrollRight - drive the fade/chevron CSS // handleBodyScroll - attach to body onScroll // ------------------------------------------------------------------------ import { useState, useEffect, useLayoutEffect } from '../../vendor/preact-hooks.js'; export function useTableScroll({ headTableRef, bodyTableRef, bodyScrollRef, headTrackRef, tableWrapRef, forecast, visibleCols, selectedDay, skinType, vehicleType, indoorMode, indoorManaged, showDecimals, showUnits, // Rotated only changes what the head/body tables CONTAIN (hours across // the top instead of metrics), not the structure — both orientations // are a sticky header table over a horizontally scrolling body table. // It is a dependency purely so the widths re-sync on the flip. rotated = false, }) { // --- SCROLL INDICATORS --------------------------------------------- const [tableCanScrollLeft, setTableCanScrollLeft] = useState(false); const [tableCanScrollRight, setTableCanScrollRight] = useState(false); const updateTableScrollIndicators = () => { const el = bodyScrollRef.current; if (!el) return; setTableCanScrollLeft(el.scrollLeft > 1); setTableCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1); }; // --- DRAG-TO-SCROLL ------------------------------------------------ useEffect(() => { const el = bodyScrollRef.current; if (!el) return; let isDown = false; let startX = 0; let startScroll = 0; const onMouseDown = (e) => { // Only act on clicks that land inside the body scroller if (!el.contains(e.target)) return; if (e.button !== 0) return; if (e.target.closest('button, a, input, select')) return; isDown = true; startX = e.clientX; startScroll = el.scrollLeft; el.style.cursor = 'grabbing'; document.body.style.userSelect = 'none'; document.body.style.webkitUserSelect = 'none'; }; const onMouseMove = (e) => { if (!isDown) return; const dx = e.clientX - startX; el.scrollLeft = startScroll - dx; }; const onMouseUp = () => { if (!isDown) return; isDown = false; el.style.cursor = ''; document.body.style.userSelect = ''; document.body.style.webkitUserSelect = ''; }; // Attach everything to document so Preact's synthetic event system // cannot intercept or swallow the events before we see them. document.addEventListener('mousedown', onMouseDown); document.addEventListener('mousemove', onMouseMove); document.addEventListener('mouseup', onMouseUp); // Also update indicators on scroll el.addEventListener('scroll', updateTableScrollIndicators); return () => { document.removeEventListener('mousedown', onMouseDown); document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('mouseup', onMouseUp); el.removeEventListener('scroll', updateTableScrollIndicators); }; }, [forecast, rotated]); // Update indicators after layout sync (columns may have changed width) useEffect(() => { updateTableScrollIndicators(); }, [forecast, visibleCols, selectedDay, rotated]); // --- BODY SCROLL HANDLER (called from JSX onScroll) --------------- const handleBodyScroll = () => { const track = headTrackRef.current; const body = bodyScrollRef.current; if (!track || !body) return; track.style.transform = `translate3d(${-body.scrollLeft}px, 0, 0)`; // The Hour column's header cells (one per thead row) need to stay // pinned to the left edge even though the track they live in is // being shifted above - cancel that shift with an equal-and-opposite // translate so they hold still while the rest of the header scrolls. const headTable = headTableRef.current; if (headTable) { const pinnedCells = headTable.querySelectorAll('thead th:first-child'); pinnedCells.forEach(c => { c.style.transform = `translate3d(${body.scrollLeft}px, 0, 0)`; }); } updateTableScrollIndicators(); }; // --- COLUMN-WIDTH SCROLL SYNC (layout effect) --------------------- // Synchronise the head and body table column widths with a // "shrink-to-fit then distribute" strategy. See the comments inside // sync() for the algorithm. useLayoutEffect(() => { const sync = () => { const headTable = headTableRef.current; const bodyTable = bodyTableRef.current; const bodyScroll = bodyScrollRef.current; if (!bodyTable || !bodyScroll) return; // Rotated renders a SINGLE table, so there is no head table and // nothing to keep in step. The scroll-fade still needs to know how // wide the pinned column is, though — and it can't be assumed from // CSS, because auto layout widens that column to fit the longest // metric name. Measure it, then stop. if (!headTable) { const firstCell = bodyTable.querySelector('tbody tr > *'); if (firstCell && tableWrapRef && tableWrapRef.current) { const w = Math.round(firstCell.getBoundingClientRect().width); tableWrapRef.current.style.setProperty('--hour-col-w', `${w}px`); } return; } const bodyRow = bodyTable.querySelector('tbody tr'); const headRow = headTable.querySelector('thead tr:last-child'); if (!bodyRow || !headRow) return; const headCells = Array.from(headRow.children); const bodyCells = Array.from(bodyRow.children); const n = Math.min(headCells.length, bodyCells.length); 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(Math.ceil(headW), Math.ceil(bodyW)); naturalW[i] = w; naturalTotal += w; } // 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 = 'auto'; 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; headCells[i].style.minWidth = px; headCells[i].style.maxWidth = px; bodyCells[i].style.width = px; bodyCells[i].style.minWidth = px; bodyCells[i].style.maxWidth = px; } // 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`; // Expose the pinned Hour column's width as a CSS var so the left // scroll-fade indicator can start after it, over the first column // that actually scrolls, rather than over the pinned column itself. if (tableWrapRef && tableWrapRef.current) { tableWrapRef.current.style.setProperty('--hour-col-w', `${finalW[0]}px`); } // Re-apply current horizontal offset so column alignment survives. handleBodyScroll(); }; // Run once after layout sync(); // 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' && bodyScrollRef.current) { ro = new ResizeObserver(sync); ro.observe(bodyScrollRef.current); } window.addEventListener('resize', sync); return () => { if (ro) ro.disconnect(); window.removeEventListener('resize', sync); }; }, [forecast, visibleCols, selectedDay, skinType, vehicleType, indoorMode, indoorManaged, showDecimals, showUnits, rotated]); return { tableCanScrollLeft, tableCanScrollRight, handleBodyScroll, }; }