From 40f651eb98310001e1e583c11eb5fc6160d430cf Mon Sep 17 00:00:00 2001 From: fraxle Date: Sat, 16 May 2026 22:15:21 +0100 Subject: [PATCH] v1.42 - Fix Cosmic Events + Popups --- assets/css/ui.css | 6 ++++ assets/js/app.js | 87 ++++++++++++++++++++++++++++++++++++++++++++- assets/js/events.js | 47 ++++++++++++++++++++---- 3 files changed, 133 insertions(+), 7 deletions(-) diff --git a/assets/css/ui.css b/assets/css/ui.css index cf494a4..0c97a42 100644 --- a/assets/css/ui.css +++ b/assets/css/ui.css @@ -93,6 +93,12 @@ vertical-align: middle; margin-left: 3px; display: inline-block; + cursor: pointer; + border-radius: 3px; + transition: opacity 0.15s; +} +.event-cell-tag:hover { + opacity: 0.75; } /* ── ALMANAC PANEL ──────────────────────────────────────────────────── */ diff --git a/assets/js/app.js b/assets/js/app.js index f3e89c9..c0fad3d 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -244,6 +244,67 @@ export function UTCIForecast() { const hoverTimerRef = useRef(null); const closeTimerRef = useRef(null); + // Event tag popup — same behaviour as column popups but for cell emoji icons. + const [eventTagPopup, setEventTagPopup] = useState(null); // { ev, x, y, arrowLeft, below } + const eventTagPopupRef = useRef(null); + const evHoverTimerRef = useRef(null); + const evCloseTimerRef = useRef(null); + + const openEventTagPopup = (ev, 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)); + setEventTagPopup({ ev, x, y, arrowLeft, below }); + }; + + const closeEventTagPopup = () => { + setEventTagPopup(null); + clearTimeout(evHoverTimerRef.current); + clearTimeout(evCloseTimerRef.current); + }; + + const handleEventTagClick = (ev, e) => { + e.stopPropagation(); + clearTimeout(evHoverTimerRef.current); + clearTimeout(evCloseTimerRef.current); + if (eventTagPopup?.ev?.id === ev.id) { closeEventTagPopup(); return; } + openEventTagPopup(ev, e.currentTarget); + }; + + const handleEventTagEnter = (ev, e) => { + clearTimeout(evHoverTimerRef.current); + clearTimeout(evCloseTimerRef.current); + if (eventTagPopup?.ev?.id === ev.id) return; + const spanEl = e.currentTarget; + evHoverTimerRef.current = setTimeout(() => openEventTagPopup(ev, 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]); + const COL_DESCRIPTIONS = { hour: { title: 'Hour', desc: 'Local wall-clock time for this forecast row. Each row covers one hour.' }, air: { title: 'Air Temperature', desc: 'Measured air temperature at 2 m above the ground. This is the standard thermometer reading — it does not account for sun, wind, or humidity.' }, @@ -1510,7 +1571,12 @@ ${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['a <${SkyScope} elev=${r.elev} dt=${r.dt} glob=${r.glob} size=${30} /> ${localHHMM} ${getCellTagEvents(selectedDayEvents, r).map(ev => html` - ${ev.emoji} + handleEventTagClick(ev, e)} + onMouseEnter=${(e) => handleEventTagEnter(ev, e)} + onMouseLeave=${handleEventTagLeave} + role="button" tabIndex="0" aria-label=${ev.title} + >${ev.emoji} `)} @@ -1623,6 +1689,25 @@ ${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['a

${COL_DESCRIPTIONS[colPopup.key].desc}

`} + ${eventTagPopup && html` +
+ + ${eventTagPopup.ev.emoji} ${eventTagPopup.ev.title} +

${eventTagPopup.ev.message}

+
`} +
Thermal stress bands
diff --git a/assets/js/events.js b/assets/js/events.js index 1b8daf6..97bd550 100644 --- a/assets/js/events.js +++ b/assets/js/events.js @@ -331,23 +331,54 @@ function checkStargazing(rows) { } function checkSunset(rows, location) { - // Perfect sunset: clear low cloud near sunset hour, good visibility - const sunsetRows = rows.filter(r => r.elev > -3 && r.elev < 8); - if (sunsetRows.length === 0) return null; + // Find the actual sunset window: the LAST contiguous run of rows where + // solar elevation is in the golden/civil-twilight band (-3° to 8°). + // This distinguishes sunset from sunrise (which is the first such run). + const twilightIndices = rows + .map((r, i) => ({ r, i })) + .filter(({ r }) => r.elev > -3 && r.elev < 8); + if (twilightIndices.length === 0) return null; + + // Split into runs separated by gaps (midday gap separates sunrise from sunset) + const runs = []; + let run = [twilightIndices[0]]; + for (let k = 1; k < twilightIndices.length; k++) { + if (twilightIndices[k].i === twilightIndices[k - 1].i + 1) { + run.push(twilightIndices[k]); + } else { + runs.push(run); + run = [twilightIndices[k]]; + } + } + runs.push(run); + + // Use the LAST run (sunset). If only one run exists it's either sunrise-only + // or a single dusk window — use it but we'll label generically. + const sunsetRun = runs[runs.length - 1]; + const sunsetRows = sunsetRun.map(({ r }) => r); + const isSunrise = runs.length === 1 && sunsetRows[0].elev < sunsetRows[sunsetRows.length - 1].elev; + // If sun is rising through the band this is a sunrise window, not sunset — skip. + if (isSunrise) return null; + const avgCloud = sunsetRows.reduce((s, r) => s + r.cc, 0) / sunsetRows.length; - // We want some high cloud (colour) but not low cloud (obstruction) const avgLowCloud = sunsetRows.reduce((s, r) => s + (r.ccLow || 0), 0) / sunsetRows.length; if (avgLowCloud > 25) return null; - if (avgCloud < 5 || avgCloud > 75) return null; // pure clear = less dramatic + if (avgCloud < 5 || avgCloud > 75) return null; + + // Store the ISO time range so getCellTagEvents can limit the icon to those hours + const firstISO = sunsetRun[0].r.iso; + const lastISO = sunsetRun[sunsetRun.length - 1].r.iso; + return { id: 'perfect-sunset', emoji: '🌅', title: 'Spectacular Sunset Conditions', - message: `Low cloud is clear near the horizon but high cloud will scatter the light — conditions are ideal for a vivid sunset near ${location.name}.`, + message: `Low cloud is clear near the horizon but high cloud will scatter the light — conditions look ideal for a vivid sunset near ${location.name}.`, color: '#3a1a00', textColor: '#ffe0b0', type: 'weather', nightOnly: false, + isoRange: [firstISO, lastISO], // only show cell icon during these hours }; } @@ -709,6 +740,10 @@ export function getCellTagEvents(events, row) { if (ev.type === 'promo') return false; if (ev.nightOnly && row.elev >= -5) return false; if (ev.id === 'heat-spike' && row.elev < 0) return false; + // Sunset/sunrise events: only show icon during the actual twilight window + if (ev.isoRange) { + if (row.iso < ev.isoRange[0] || row.iso > ev.isoRange[1]) return false; + } return true; }); }