1.5
Code refactoring Profile fixes Table fixes Animation additions
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// useColumnPopup — owns the column-header popup AND the event-tag popup.
|
||||
//
|
||||
// Both popups behave identically:
|
||||
// • click an anchor → toggle the popup open
|
||||
// • hover an anchor for N ms → open
|
||||
// • move into the popup → keep it open
|
||||
// • leave the popup → close after 200 ms
|
||||
// • click outside / scroll / resize → close immediately
|
||||
//
|
||||
// Returns everything app.js needs to wire up the th cells and event-tag
|
||||
// spans, plus the popup state objects for rendering the floating panels.
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from '../../vendor/preact-hooks.js';
|
||||
|
||||
export function useColumnPopup() {
|
||||
// ─── Column header popup ────────────────────────────────────────────
|
||||
const [colPopup, setColPopup] = useState(null);
|
||||
const colPopupRef = useRef(null);
|
||||
const colPopupThRef = useRef(null);
|
||||
const hoverTimerRef = useRef(null);
|
||||
const closeTimerRef = useRef(null);
|
||||
|
||||
// ─── Event tag popup ────────────────────────────────────────────────
|
||||
// Holds { events[], slideIndex, x, y, arrowLeft, below }
|
||||
// When multiple events are in the popup they auto-cycle with a crossfade.
|
||||
const [eventTagPopup, setEventTagPopup] = useState(null);
|
||||
const [evSlideIndex, setEvSlideIndex] = useState(0);
|
||||
// 'entering' | 'exiting' | null — drives CSS crossfade classes
|
||||
const [evTransition, setEvTransition] = useState(null);
|
||||
const prevSlideIndexRef = useRef(0);
|
||||
const eventTagPopupRef = useRef(null);
|
||||
const evHoverTimerRef = useRef(null);
|
||||
const evCloseTimerRef = useRef(null);
|
||||
const evSlideTimerRef = useRef(null);
|
||||
|
||||
const calcPopupPos = (thEl) => {
|
||||
const rect = thEl.getBoundingClientRect();
|
||||
const popupW = 260, popupH = 110, margin = 8, gap = 6;
|
||||
let x = rect.left + rect.width / 2;
|
||||
x = Math.max(popupW / 2 + margin, Math.min(x, window.innerWidth - popupW / 2 - margin));
|
||||
const below = rect.top < popupH + gap + margin;
|
||||
const y = below ? rect.bottom + gap : rect.top - gap;
|
||||
const popupLeft = x - popupW / 2;
|
||||
const arrowLeft = Math.max(16, Math.min(rect.left + rect.width / 2 - popupLeft, popupW - 16));
|
||||
return { x, y, arrowLeft, below };
|
||||
};
|
||||
|
||||
const openPopup = (key, thEl) => {
|
||||
colPopupThRef.current = thEl;
|
||||
setColPopup({ key, ...calcPopupPos(thEl) });
|
||||
};
|
||||
|
||||
const closePopup = () => {
|
||||
setColPopup(null);
|
||||
colPopupThRef.current = null;
|
||||
clearTimeout(hoverTimerRef.current);
|
||||
clearTimeout(closeTimerRef.current);
|
||||
};
|
||||
|
||||
const handleThClick = (key, e) => {
|
||||
clearTimeout(hoverTimerRef.current);
|
||||
clearTimeout(closeTimerRef.current);
|
||||
if (colPopup?.key === key) { closePopup(); return; }
|
||||
openPopup(key, e.currentTarget);
|
||||
};
|
||||
|
||||
const handleThEnter = (key, e) => {
|
||||
clearTimeout(hoverTimerRef.current);
|
||||
clearTimeout(closeTimerRef.current);
|
||||
// If a different popup is open, close it immediately and start fresh timer
|
||||
if (colPopup && colPopup.key !== key) closePopup();
|
||||
if (colPopup?.key === key) return; // already showing this one
|
||||
const thEl = e.currentTarget;
|
||||
hoverTimerRef.current = setTimeout(() => openPopup(key, thEl), 1000);
|
||||
};
|
||||
|
||||
// Leaving a th: just cancel the pending open. Don't auto-close —
|
||||
// the user might be moving into the popup, or just passing through.
|
||||
const handleThLeave = () => {
|
||||
clearTimeout(hoverTimerRef.current);
|
||||
};
|
||||
|
||||
// Popup mouse handlers: keep it open while hovering, close on leave.
|
||||
const handlePopupEnter = () => clearTimeout(closeTimerRef.current);
|
||||
const handlePopupLeave = () => { closeTimerRef.current = setTimeout(closePopup, 200); };
|
||||
|
||||
useEffect(() => {
|
||||
if (!colPopup) return;
|
||||
const onClickOutside = (e) => {
|
||||
if (colPopupRef.current && !colPopupRef.current.contains(e.target)) closePopup();
|
||||
};
|
||||
// Close on scroll (avoids scroll-linked jank) or resize
|
||||
const onScrollOrResize = () => closePopup();
|
||||
document.addEventListener('mousedown', onClickOutside);
|
||||
window.addEventListener('scroll', onScrollOrResize, { passive: true, capture: true });
|
||||
window.addEventListener('resize', onScrollOrResize, { passive: true });
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onClickOutside);
|
||||
window.removeEventListener('scroll', onScrollOrResize, { capture: true });
|
||||
window.removeEventListener('resize', onScrollOrResize);
|
||||
};
|
||||
}, [colPopup]);
|
||||
|
||||
// ─── Event tag popup helpers ─────────────────────────────────────────
|
||||
const calcEventPopupPos = (spanEl) => {
|
||||
const rect = spanEl.getBoundingClientRect();
|
||||
const popupW = 260, popupH = 80, margin = 8, gap = 6;
|
||||
let x = rect.left + rect.width / 2;
|
||||
x = Math.max(popupW / 2 + margin, Math.min(x, window.innerWidth - popupW / 2 - margin));
|
||||
const below = rect.top < popupH + gap + margin;
|
||||
const y = below ? rect.bottom + gap : rect.top - gap;
|
||||
const popupLeft = x - popupW / 2;
|
||||
const arrowLeft = Math.max(16, Math.min(rect.left + rect.width / 2 - popupLeft, popupW - 16));
|
||||
return { x, y, arrowLeft, below };
|
||||
};
|
||||
|
||||
// ─── Slideshow advance ───────────────────────────────────────────────
|
||||
// evSlideTo triggers a crossfade to a new slide index.
|
||||
const evSlideTo = useCallback((nextIndex) => {
|
||||
setEvTransition('exiting');
|
||||
// After the exit animation (~400 ms) swap content and fade in
|
||||
setTimeout(() => {
|
||||
prevSlideIndexRef.current = nextIndex;
|
||||
setEvSlideIndex(nextIndex);
|
||||
setEvTransition('entering');
|
||||
// Clear the entering class once the animation finishes
|
||||
setTimeout(() => setEvTransition(null), 420);
|
||||
}, 400);
|
||||
}, []);
|
||||
|
||||
// Auto-advance slideshow when popup is open with multiple events.
|
||||
// We store a ref to evSlideIndex so the interval closure always reads
|
||||
// the latest value without needing to be recreated on every slide change.
|
||||
const evSlideIndexRef = useRef(0);
|
||||
useEffect(() => { evSlideIndexRef.current = evSlideIndex; }, [evSlideIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!eventTagPopup || eventTagPopup.events.length <= 1) {
|
||||
clearInterval(evSlideTimerRef.current);
|
||||
return;
|
||||
}
|
||||
evSlideTimerRef.current = setInterval(() => {
|
||||
const next = (evSlideIndexRef.current + 1) % eventTagPopup.events.length;
|
||||
evSlideTo(next);
|
||||
}, 4000);
|
||||
return () => clearInterval(evSlideTimerRef.current);
|
||||
}, [eventTagPopup, evSlideTo]);
|
||||
|
||||
// ─── Event tag popup handlers ────────────────────────────────────────
|
||||
const openEventTagPopup = (events, spanEl) => {
|
||||
clearInterval(evSlideTimerRef.current);
|
||||
setEvSlideIndex(0);
|
||||
setEvTransition(null);
|
||||
setEventTagPopup({ events, ...calcEventPopupPos(spanEl) });
|
||||
};
|
||||
|
||||
const closeEventTagPopup = () => {
|
||||
setEventTagPopup(null);
|
||||
setEvSlideIndex(0);
|
||||
setEvTransition(null);
|
||||
clearTimeout(evHoverTimerRef.current);
|
||||
clearTimeout(evCloseTimerRef.current);
|
||||
clearInterval(evSlideTimerRef.current);
|
||||
};
|
||||
|
||||
// rowEvents = all events for that row (passed in from app.js)
|
||||
const handleEventTagClick = (rowEvents, e) => {
|
||||
e.stopPropagation();
|
||||
clearTimeout(evHoverTimerRef.current);
|
||||
clearTimeout(evCloseTimerRef.current);
|
||||
// Toggle off if same set already open
|
||||
if (eventTagPopup && JSON.stringify(eventTagPopup.events.map(ev => ev.id)) === JSON.stringify(rowEvents.map(ev => ev.id))) {
|
||||
closeEventTagPopup(); return;
|
||||
}
|
||||
openEventTagPopup(rowEvents, e.currentTarget);
|
||||
};
|
||||
|
||||
const handleEventTagEnter = (rowEvents, e) => {
|
||||
clearTimeout(evHoverTimerRef.current);
|
||||
clearTimeout(evCloseTimerRef.current);
|
||||
const spanEl = e.currentTarget;
|
||||
evHoverTimerRef.current = setTimeout(() => openEventTagPopup(rowEvents, spanEl), 700);
|
||||
};
|
||||
|
||||
const handleEventTagLeave = () => clearTimeout(evHoverTimerRef.current);
|
||||
|
||||
const handleEventTagPopupEnter = () => clearTimeout(evCloseTimerRef.current);
|
||||
const handleEventTagPopupLeave = () => { evCloseTimerRef.current = setTimeout(closeEventTagPopup, 200); };
|
||||
|
||||
useEffect(() => {
|
||||
if (!eventTagPopup) return;
|
||||
const onClickOutside = (e) => {
|
||||
if (eventTagPopupRef.current && !eventTagPopupRef.current.contains(e.target)) closeEventTagPopup();
|
||||
};
|
||||
const onScrollOrResize = () => closeEventTagPopup();
|
||||
document.addEventListener('mousedown', onClickOutside);
|
||||
window.addEventListener('scroll', onScrollOrResize, { passive: true, capture: true });
|
||||
window.addEventListener('resize', onScrollOrResize, { passive: true });
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onClickOutside);
|
||||
window.removeEventListener('scroll', onScrollOrResize, { capture: true });
|
||||
window.removeEventListener('resize', onScrollOrResize);
|
||||
};
|
||||
}, [eventTagPopup]);
|
||||
|
||||
return {
|
||||
// column-header popup
|
||||
colPopup, colPopupRef,
|
||||
handleThClick, handleThEnter, handleThLeave,
|
||||
handlePopupEnter, handlePopupLeave,
|
||||
closePopup,
|
||||
// event-tag popup
|
||||
eventTagPopup, eventTagPopupRef,
|
||||
evSlideIndex, evTransition, evSlideTo,
|
||||
handleEventTagClick, handleEventTagEnter, handleEventTagLeave,
|
||||
handleEventTagPopupEnter, handleEventTagPopupLeave,
|
||||
closeEventTagPopup,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// useForecast — fetches the weather forecast and air quality for the
|
||||
// given location and keeps them fresh.
|
||||
//
|
||||
// Responsibilities:
|
||||
// 1. Fetch /v1/forecast on location change.
|
||||
// 2. Fetch /v1/air-quality on location change, with a 6-hour
|
||||
// localStorage cache (AQI / pollen update slowly).
|
||||
// 3. Auto-refresh both every 5 minutes so the displayed data stays
|
||||
// current as time passes. Air quality only refetches if cache is
|
||||
// stale.
|
||||
//
|
||||
// Inputs:
|
||||
// location — { lat, lon, name, country }
|
||||
//
|
||||
// Outputs:
|
||||
// forecast — raw /v1/forecast response, or null
|
||||
// airQuality — raw /v1/air-quality response, or null
|
||||
// loading — true while the forecast fetch is in flight
|
||||
// error — fetch error message, or null
|
||||
// now — Date that ticks every 5 minutes (drives the "current row")
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
import { useState, useEffect } from '../../vendor/preact-hooks.js';
|
||||
|
||||
const SIX_HOURS_MS = 6 * 60 * 60 * 1000;
|
||||
const FIVE_MIN = 5 * 60 * 1000;
|
||||
|
||||
function buildForecastUrl(loc) {
|
||||
return `https://api.open-meteo.com/v1/forecast` +
|
||||
`?latitude=${loc.lat}&longitude=${loc.lon}` +
|
||||
`&hourly=temperature_2m,relative_humidity_2m,dew_point_2m,` +
|
||||
`wind_speed_10m,wind_direction_10m,wind_gusts_10m,` +
|
||||
`direct_radiation,diffuse_radiation,shortwave_radiation,` +
|
||||
`cloud_cover,cloud_cover_low,cloud_cover_mid,cloud_cover_high,` +
|
||||
`uv_index,precipitation,snowfall,visibility,` +
|
||||
`soil_temperature_0cm,soil_temperature_6cm,soil_moisture_0_to_1cm` +
|
||||
`&wind_speed_unit=ms&timezone=auto&forecast_days=14`;
|
||||
}
|
||||
|
||||
function buildAirQualityUrl(loc) {
|
||||
return `https://air-quality-api.open-meteo.com/v1/air-quality` +
|
||||
`?latitude=${loc.lat}&longitude=${loc.lon}` +
|
||||
`&hourly=european_aqi,` +
|
||||
`grass_pollen,birch_pollen,alder_pollen,` +
|
||||
`mugwort_pollen,olive_pollen,ragweed_pollen` +
|
||||
`&timezone=auto&forecast_days=5`;
|
||||
}
|
||||
|
||||
export function useForecast(location) {
|
||||
const [forecast, setForecast] = useState(null);
|
||||
const [airQuality, setAirQuality] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [now, setNow] = useState(new Date());
|
||||
|
||||
// Air quality loader — also used by the 5-minute refresh below.
|
||||
async function loadAirQuality(loc) {
|
||||
const cacheKey = `sunscope_aq_${loc.lat.toFixed(4)}_${loc.lon.toFixed(4)}`;
|
||||
try {
|
||||
const cached = localStorage.getItem(cacheKey);
|
||||
if (cached) {
|
||||
const { ts, data } = JSON.parse(cached);
|
||||
if (Date.now() - ts < SIX_HOURS_MS) {
|
||||
setAirQuality(data);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (e) { /* ignore bad cache */ }
|
||||
try {
|
||||
const r = await fetch(buildAirQualityUrl(loc));
|
||||
if (!r.ok) return; // silently fail — these columns just show '—'
|
||||
const data = await r.json();
|
||||
setAirQuality(data);
|
||||
try {
|
||||
localStorage.setItem(cacheKey, JSON.stringify({ ts: Date.now(), data }));
|
||||
} catch (e) { /* ignore storage errors */ }
|
||||
} catch (_) { /* silently ignore */ }
|
||||
}
|
||||
|
||||
// ─── INITIAL FETCH on location change ─────────────────────────────
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
setLoading(true); setError(null);
|
||||
try {
|
||||
const r = await fetch(buildForecastUrl(location));
|
||||
if (!r.ok) throw new Error(`Open-Meteo HTTP ${r.status}`);
|
||||
setForecast(await r.json());
|
||||
} catch (e) { setError(e.message); }
|
||||
finally { setLoading(false); }
|
||||
}
|
||||
load();
|
||||
loadAirQuality(location);
|
||||
}, [location]);
|
||||
|
||||
// ─── AUTO-REFRESH every 5 minutes ─────────────────────────────────
|
||||
// Updates `now` (drives the "current hour" highlight) and refetches
|
||||
// the forecast so fresh API data comes in automatically. Air quality
|
||||
// only refetches if its 6-hour cache has expired.
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => {
|
||||
setNow(new Date());
|
||||
async function refresh() {
|
||||
try {
|
||||
const r = await fetch(buildForecastUrl(location));
|
||||
if (r.ok) setForecast(await r.json());
|
||||
} catch (_) { /* silently ignore refresh errors */ }
|
||||
const cacheKey = `sunscope_aq_${location.lat.toFixed(4)}_${location.lon.toFixed(4)}`;
|
||||
try {
|
||||
const cached = localStorage.getItem(cacheKey);
|
||||
if (cached) {
|
||||
const { ts } = JSON.parse(cached);
|
||||
if (Date.now() - ts < SIX_HOURS_MS) return; // still fresh
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
loadAirQuality(location);
|
||||
}
|
||||
refresh();
|
||||
}, FIVE_MIN);
|
||||
return () => clearInterval(id);
|
||||
}, [location]);
|
||||
|
||||
return { forecast, airQuality, loading, error, now };
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user