Files
sunscope/assets/js/hooks/useTableScroll.js
T
fraxle 60eee8e4c1 5.0
New table rotation flip
2026-08-06 00:48:07 +01:00

267 lines
11 KiB
JavaScript

// ------------------------------------------------------------------------
// 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,
rotatedScrollRef,
headTrackRef,
tableWrapRef,
forecast,
visibleCols,
selectedDay,
skinType,
vehicleType,
indoorMode,
indoorManaged,
showDecimals,
showUnits,
// When the table is rotated it renders as ONE table whose sticky header
// row and sticky metric column are handled entirely in CSS. There is no
// second table to keep in step, so the width-sync and scroll-indicator
// effects short-circuit — but drag-to-scroll still applies, just to the
// rotated pane (which scrolls in both axes) instead of the body table.
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(() => {
// Whichever element is actually the scroller in the current
// orientation. Rotated, it scrolls vertically too, so the drag
// follows both axes.
const el = rotated ? (rotatedScrollRef && rotatedScrollRef.current) : bodyScrollRef.current;
if (!el) return;
let isDown = false;
let startX = 0;
let startY = 0;
let startScroll = 0;
let startScrollTop = 0;
const onMouseDown = (e) => {
// Only act on clicks that land inside the 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;
startY = e.clientY;
startScroll = el.scrollLeft;
startScrollTop = el.scrollTop;
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;
if (rotated) el.scrollTop = startScrollTop - (e.clientY - startY);
};
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(() => {
if (rotated) {
// Nothing is scrolling under our control — clear the fades so they
// don't linger over the rotated table.
setTableCanScrollLeft(false);
setTableCanScrollRight(false);
return;
}
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(() => {
if (rotated) return;
const sync = () => {
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: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,
};
}