v1.42 - Fix Cosmic Events + Popups
This commit is contained in:
@@ -93,6 +93,12 @@
|
|||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
margin-left: 3px;
|
margin-left: 3px;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.event-cell-tag:hover {
|
||||||
|
opacity: 0.75;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── ALMANAC PANEL ──────────────────────────────────────────────────── */
|
/* ── ALMANAC PANEL ──────────────────────────────────────────────────── */
|
||||||
|
|||||||
+86
-1
@@ -244,6 +244,67 @@ export function UTCIForecast() {
|
|||||||
const hoverTimerRef = useRef(null);
|
const hoverTimerRef = useRef(null);
|
||||||
const closeTimerRef = 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 = {
|
const COL_DESCRIPTIONS = {
|
||||||
hour: { title: 'Hour', desc: 'Local wall-clock time for this forecast row. Each row covers one hour.' },
|
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.' },
|
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} />
|
<${SkyScope} elev=${r.elev} dt=${r.dt} glob=${r.glob} size=${30} />
|
||||||
<span>${localHHMM}</span>
|
<span>${localHHMM}</span>
|
||||||
${getCellTagEvents(selectedDayEvents, r).map(ev => html`
|
${getCellTagEvents(selectedDayEvents, r).map(ev => html`
|
||||||
<span key=${ev.id} class="event-cell-tag" title=${ev.title}>${ev.emoji}</span>
|
<span key=${ev.id} class="event-cell-tag"
|
||||||
|
onClick=${(e) => handleEventTagClick(ev, e)}
|
||||||
|
onMouseEnter=${(e) => handleEventTagEnter(ev, e)}
|
||||||
|
onMouseLeave=${handleEventTagLeave}
|
||||||
|
role="button" tabIndex="0" aria-label=${ev.title}
|
||||||
|
>${ev.emoji}</span>
|
||||||
`)}
|
`)}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
@@ -1623,6 +1689,25 @@ ${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['a
|
|||||||
<p class="col-info-desc">${COL_DESCRIPTIONS[colPopup.key].desc}</p>
|
<p class="col-info-desc">${COL_DESCRIPTIONS[colPopup.key].desc}</p>
|
||||||
</div>`}
|
</div>`}
|
||||||
|
|
||||||
|
${eventTagPopup && html`
|
||||||
|
<div
|
||||||
|
ref=${eventTagPopupRef}
|
||||||
|
class=${`col-info-popup${eventTagPopup.below ? ' col-info-popup--below' : ''}`}
|
||||||
|
style=${{
|
||||||
|
position: 'fixed',
|
||||||
|
left: `${eventTagPopup.x}px`,
|
||||||
|
top: `${eventTagPopup.y}px`,
|
||||||
|
transform: eventTagPopup.below ? 'translateX(-50%)' : 'translateX(-50%) translateY(-100%)',
|
||||||
|
'--arrow-left': `${eventTagPopup.arrowLeft}px`,
|
||||||
|
}}
|
||||||
|
onMouseEnter=${handleEventTagPopupEnter}
|
||||||
|
onMouseLeave=${handleEventTagPopupLeave}
|
||||||
|
>
|
||||||
|
<button class="col-info-close" onClick=${closeEventTagPopup} aria-label="Close">×</button>
|
||||||
|
<strong class="col-info-title">${eventTagPopup.ev.emoji} ${eventTagPopup.ev.title}</strong>
|
||||||
|
<p class="col-info-desc">${eventTagPopup.ev.message}</p>
|
||||||
|
</div>`}
|
||||||
|
|
||||||
<div class="utci-legend">
|
<div class="utci-legend">
|
||||||
<span class="utci-legend-label">Thermal stress bands</span>
|
<span class="utci-legend-label">Thermal stress bands</span>
|
||||||
<div class="utci-legend-row">
|
<div class="utci-legend-row">
|
||||||
|
|||||||
+41
-6
@@ -331,23 +331,54 @@ function checkStargazing(rows) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function checkSunset(rows, location) {
|
function checkSunset(rows, location) {
|
||||||
// Perfect sunset: clear low cloud near sunset hour, good visibility
|
// Find the actual sunset window: the LAST contiguous run of rows where
|
||||||
const sunsetRows = rows.filter(r => r.elev > -3 && r.elev < 8);
|
// solar elevation is in the golden/civil-twilight band (-3° to 8°).
|
||||||
if (sunsetRows.length === 0) return null;
|
// 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;
|
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;
|
const avgLowCloud = sunsetRows.reduce((s, r) => s + (r.ccLow || 0), 0) / sunsetRows.length;
|
||||||
if (avgLowCloud > 25) return null;
|
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 {
|
return {
|
||||||
id: 'perfect-sunset',
|
id: 'perfect-sunset',
|
||||||
emoji: '🌅',
|
emoji: '🌅',
|
||||||
title: 'Spectacular Sunset Conditions',
|
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',
|
color: '#3a1a00',
|
||||||
textColor: '#ffe0b0',
|
textColor: '#ffe0b0',
|
||||||
type: 'weather',
|
type: 'weather',
|
||||||
nightOnly: false,
|
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.type === 'promo') return false;
|
||||||
if (ev.nightOnly && row.elev >= -5) return false;
|
if (ev.nightOnly && row.elev >= -5) return false;
|
||||||
if (ev.id === 'heat-spike' && row.elev < 0) 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;
|
return true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user