223 lines
9.3 KiB
JavaScript
223 lines
9.3 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 }
|
|
// — 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,
|
|
forecast,
|
|
visibleCols,
|
|
selectedDay,
|
|
skinType,
|
|
vehicleType,
|
|
}) {
|
|
// ─── 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]);
|
|
|
|
// Update indicators after layout sync (columns may have changed width)
|
|
useEffect(() => {
|
|
updateTableScrollIndicators();
|
|
}, [forecast, visibleCols, selectedDay]);
|
|
|
|
// ─── 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)`;
|
|
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 (!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);
|
|
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 = '';
|
|
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`;
|
|
// 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]);
|
|
|
|
return {
|
|
tableCanScrollLeft,
|
|
tableCanScrollRight,
|
|
handleBodyScroll,
|
|
};
|
|
}
|