// ------------------------------------------------------------------------ // 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, startIndex = 0) => { clearInterval(evSlideTimerRef.current); setEvSlideIndex(startIndex); prevSlideIndexRef.current = startIndex; 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, startIndex, 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, startIndex); }; const handleEventTagEnter = (rowEvents, startIndex, e) => { clearTimeout(evHoverTimerRef.current); clearTimeout(evCloseTimerRef.current); const spanEl = e.currentTarget; evHoverTimerRef.current = setTimeout(() => openEventTagPopup(rowEvents, spanEl, startIndex), 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, }; }