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
315 lines
13 KiB
JavaScript
315 lines
13 KiB
JavaScript
// ------------------------------------------------------------------------
|
|
// 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[], date, slideIndex, x, y, arrowLeft, below }
|
|
// `date` is the YYYY-MM-DD of the row the tag was on - the popup's Quick
|
|
// Record button dates its journal entry to the day being looked at, which
|
|
// is not necessarily today (the table can be showing any of the 14 days).
|
|
// 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();
|
|
// Use a generous height estimate so tall (text-heavy) popups prefer going
|
|
// below rather than overflowing the top. The post-render clamp effect
|
|
// catches any remaining overflow after the real height is known.
|
|
const popupW = 260, popupH = 220, 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]);
|
|
|
|
// Post-render clamp: after the popup actually paints, measure its real
|
|
// bounding rect and nudge y if it overflows the viewport top or bottom.
|
|
// The >1 guard prevents floating-point micro-loops.
|
|
useEffect(() => {
|
|
if (!colPopup || !colPopupRef.current) return;
|
|
const rect = colPopupRef.current.getBoundingClientRect();
|
|
const MARGIN = 8;
|
|
if (!colPopup.below && rect.top < MARGIN) {
|
|
const delta = MARGIN - rect.top;
|
|
if (delta > 1) setColPopup(prev => prev ? { ...prev, y: prev.y + delta } : prev);
|
|
} else if (colPopup.below && rect.bottom > window.innerHeight - MARGIN) {
|
|
const delta = rect.bottom - (window.innerHeight - MARGIN);
|
|
if (delta > 1) setColPopup(prev => prev ? { ...prev, y: prev.y - delta } : prev);
|
|
}
|
|
}, [colPopup]);
|
|
|
|
// --- Event tag popup helpers -----------------------------------------
|
|
const calcEventPopupPos = (spanEl) => {
|
|
const rect = spanEl.getBoundingClientRect();
|
|
const popupW = 260, popupH = 160, 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, date = null) => {
|
|
clearInterval(evSlideTimerRef.current);
|
|
setEvSlideIndex(startIndex);
|
|
prevSlideIndexRef.current = startIndex;
|
|
setEvTransition(null);
|
|
setEventTagPopup({ events, date, ...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, dateISO = that row's YYYY-MM-DD
|
|
// (both passed in from app.js)
|
|
const handleEventTagClick = (rowEvents, startIndex, dateISO, 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, dateISO);
|
|
};
|
|
|
|
const handleEventTagEnter = (rowEvents, startIndex, dateISO, e) => {
|
|
clearTimeout(evHoverTimerRef.current);
|
|
clearTimeout(evCloseTimerRef.current);
|
|
const spanEl = e.currentTarget;
|
|
evHoverTimerRef.current = setTimeout(() => openEventTagPopup(rowEvents, spanEl, startIndex, dateISO), 700);
|
|
};
|
|
|
|
const handleEventTagLeave = () => clearTimeout(evHoverTimerRef.current);
|
|
|
|
// Moving into the popup stops the carousel for good (leaving it closes the
|
|
// popup anyway, so there is nothing to restart). It has a Quick Record
|
|
// button in it now: an event that slides out from under the cursor mid-reach
|
|
// would file the wrong one.
|
|
const handleEventTagPopupEnter = () => {
|
|
clearTimeout(evCloseTimerRef.current);
|
|
clearInterval(evSlideTimerRef.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]);
|
|
|
|
// Post-render clamp for event-tag popup (same logic as col popup above).
|
|
useEffect(() => {
|
|
if (!eventTagPopup || !eventTagPopupRef.current) return;
|
|
const rect = eventTagPopupRef.current.getBoundingClientRect();
|
|
const MARGIN = 8;
|
|
if (!eventTagPopup.below && rect.top < MARGIN) {
|
|
const delta = MARGIN - rect.top;
|
|
if (delta > 1) setEventTagPopup(prev => prev ? { ...prev, y: prev.y + delta } : prev);
|
|
} else if (eventTagPopup.below && rect.bottom > window.innerHeight - MARGIN) {
|
|
const delta = rect.bottom - (window.innerHeight - MARGIN);
|
|
if (delta > 1) setEventTagPopup(prev => prev ? { ...prev, y: prev.y - delta } : prev);
|
|
}
|
|
}, [eventTagPopup]);
|
|
|
|
// --- Week Ahead row popup ------------------------------------------
|
|
// Click-driven only: the Week Ahead rows used to carry their
|
|
// explanation in `title=` tooltips, which never surface on touch.
|
|
// Holds { key, title, sub, rows:[{icon,text}], ...pos }.
|
|
const [weekPopup, setWeekPopup] = useState(null);
|
|
const weekPopupRef = useRef(null);
|
|
|
|
const closeWeekPopup = () => setWeekPopup(null);
|
|
|
|
const handleWeekRowClick = (payload, e) => {
|
|
e.stopPropagation();
|
|
if (weekPopup?.key === payload.key) { closeWeekPopup(); return; }
|
|
setWeekPopup({ ...payload, ...calcPopupPos(e.currentTarget) });
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!weekPopup) return;
|
|
const onClickOutside = (e) => {
|
|
if (weekPopupRef.current && !weekPopupRef.current.contains(e.target)) closeWeekPopup();
|
|
};
|
|
const onScrollOrResize = () => closeWeekPopup();
|
|
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);
|
|
};
|
|
}, [weekPopup]);
|
|
|
|
// Post-render clamp (same logic as the col popup above).
|
|
useEffect(() => {
|
|
if (!weekPopup || !weekPopupRef.current) return;
|
|
const rect = weekPopupRef.current.getBoundingClientRect();
|
|
const MARGIN = 8;
|
|
if (!weekPopup.below && rect.top < MARGIN) {
|
|
const delta = MARGIN - rect.top;
|
|
if (delta > 1) setWeekPopup(prev => prev ? { ...prev, y: prev.y + delta } : prev);
|
|
} else if (weekPopup.below && rect.bottom > window.innerHeight - MARGIN) {
|
|
const delta = rect.bottom - (window.innerHeight - MARGIN);
|
|
if (delta > 1) setWeekPopup(prev => prev ? { ...prev, y: prev.y - delta } : prev);
|
|
}
|
|
}, [weekPopup]);
|
|
|
|
return {
|
|
// column-header popup
|
|
colPopup, colPopupRef,
|
|
handleThClick, handleThEnter, handleThLeave,
|
|
handlePopupEnter, handlePopupLeave,
|
|
closePopup,
|
|
// week-ahead row popup
|
|
weekPopup, weekPopupRef, handleWeekRowClick, closeWeekPopup,
|
|
// event-tag popup
|
|
eventTagPopup, eventTagPopupRef,
|
|
evSlideIndex, evTransition, evSlideTo,
|
|
handleEventTagClick, handleEventTagEnter, handleEventTagLeave,
|
|
handleEventTagPopupEnter, handleEventTagPopupLeave,
|
|
closeEventTagPopup,
|
|
};
|
|
}
|