New big and small scope sunset and sunrise simulation New calculations for the day tabs Added missing icons to simple view New record popups Week ahead description popups Camping profile Tweak to some of the indoor tems
302 lines
13 KiB
JavaScript
302 lines
13 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,
|
|
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,
|
|
// Row bucket size (1-4h). Changes the number of rows/columns, so widths must re-sync.
|
|
tableInterval = 1,
|
|
}) {
|
|
// --- 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;
|
|
|
|
// Fallback for any layout rendered as a single table (no head to
|
|
// keep in step). Both orientations split head from body now, so this
|
|
// is a safety net rather than a path — the scroll-fade still needs
|
|
// the pinned column's width, so measure that and 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';
|
|
// Drop the CSS min-width:100% while measuring, or auto layout stretches the
|
|
// columns to fill the container and the "natural" widths come out inflated.
|
|
headTable.style.minWidth = '0';
|
|
bodyTable.style.minWidth = '0';
|
|
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;
|
|
let bodyW = bodyCells[i].getBoundingClientRect().width;
|
|
// Rotated: the first body row is an empty group-label row, so take
|
|
// the widest cell in this column across every body row instead.
|
|
if (rotated) {
|
|
bodyTable.querySelectorAll('tbody tr').forEach(tr => {
|
|
const c = tr.children[i];
|
|
if (c) bodyW = Math.max(bodyW, c.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;
|
|
// Rotated: pinned label column keeps its natural width and the hour
|
|
// columns are sized EQUALLY to exactly fill the scroller - both
|
|
// spreading out when there are few and shrinking (down to the 92px
|
|
// cell minimum) so they never run off the right edge. Only when even
|
|
// that floor doesn't fit does it fall through to natural widths + scroll.
|
|
const eqW = rotated && n > 1 ? Math.floor((containerW - naturalW[0]) / (n - 1)) : 0;
|
|
if (rotated && n > 1 && eqW >= 92) {
|
|
let running = naturalW[0];
|
|
finalW[0] = naturalW[0];
|
|
for (let i = 1; i < n - 1; i++) { finalW[i] = eqW; running += eqW; }
|
|
finalW[n - 1] = containerW - running; // absorb rounding
|
|
totalWidth = containerW;
|
|
} else 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;
|
|
if (rotated && n > 1) {
|
|
// Rotated: keep the pinned label column at its natural width and
|
|
// share the spare space EQUALLY across the hour columns, so 2h/3h/4h
|
|
// spread evenly instead of scaling each column by its own width.
|
|
const spare = containerW - naturalTotal;
|
|
const share = Math.floor(spare / (n - 1));
|
|
finalW[0] = naturalW[0];
|
|
running = finalW[0];
|
|
for (let i = 1; i < n - 1; i++) {
|
|
finalW[i] = naturalW[i] + share;
|
|
running += finalW[i];
|
|
}
|
|
} else {
|
|
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, tableInterval]);
|
|
|
|
return {
|
|
tableCanScrollLeft,
|
|
tableCanScrollRight,
|
|
handleBodyScroll,
|
|
};
|
|
}
|