v1.03 - Fix table popups

This commit is contained in:
fraxle
2026-05-16 16:15:19 +01:00
parent 6dab145e90
commit a92a437b37
3 changed files with 1724 additions and 52 deletions
+12 -2
View File
@@ -575,7 +575,7 @@
padding: 14px 16px 16px; padding: 14px 16px 16px;
pointer-events: all; pointer-events: all;
} }
/* Arrow points DOWN toward the column header */ /* Arrow points DOWN toward the column header (default — popup is above the th) */
.col-info-popup::after { .col-info-popup::after {
content: ''; content: '';
position: absolute; position: absolute;
@@ -586,7 +586,17 @@
background: #fffcf2; background: #fffcf2;
border-right: 1.5px solid #c9b08a; border-right: 1.5px solid #c9b08a;
border-bottom: 1.5px solid #c9b08a; border-bottom: 1.5px solid #c9b08a;
transform: rotate(45deg); transform: translateX(-50%) rotate(45deg);
}
/* Arrow points UP toward the column header (popup is below the th) */
.col-info-popup.col-info-popup--below::after {
bottom: auto;
top: -7px;
border-right: none;
border-bottom: none;
border-left: 1.5px solid #c9b08a;
border-top: 1.5px solid #c9b08a;
transform: translateX(-50%) rotate(45deg);
} }
.col-info-title { .col-info-title {
display: block; display: block;
File diff suppressed because it is too large Load Diff
+97 -50
View File
@@ -238,7 +238,10 @@ export function UTCIForecast() {
// Clicking a <th> shows a small description popup below it. // Clicking a <th> shows a small description popup below it.
// State holds { key, x, y } or null when closed. // State holds { key, x, y } or null when closed.
const [colPopup, setColPopup] = useState(null); const [colPopup, setColPopup] = useState(null);
const colPopupRef = useRef(null); const colPopupRef = useRef(null);
const colPopupThRef = useRef(null);
const hoverTimerRef = useRef(null);
const closeTimerRef = useRef(null);
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.' },
@@ -268,34 +271,76 @@ export function UTCIForecast() {
managedT: { title: 'Managed Indoors', desc: 'Estimated indoor temperature with curtains closed and windows opened when outdoor air is cooler than inside — the standard UK heatwave advice. Curtains block most direct solar gain; smart ventilation pulls the temperature down during cooler periods.' }, managedT: { title: 'Managed Indoors', desc: 'Estimated indoor temperature with curtains closed and windows opened when outdoor air is cooler than inside — the standard UK heatwave advice. Curtains block most direct solar gain; smart ventilation pulls the temperature down during cooler periods.' },
}; };
// Dismiss popup on click outside 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(() => { useEffect(() => {
if (!colPopup) return; if (!colPopup) return;
const onClickOutside = (e) => { const onClickOutside = (e) => {
if (colPopupRef.current && !colPopupRef.current.contains(e.target)) { if (colPopupRef.current && !colPopupRef.current.contains(e.target)) closePopup();
setColPopup(null); };
} const onScrollOrResize = () => {
if (!colPopupThRef.current) return;
setColPopup(prev => prev ? { ...prev, ...calcPopupPos(colPopupThRef.current) } : null);
}; };
document.addEventListener('mousedown', onClickOutside); document.addEventListener('mousedown', onClickOutside);
return () => document.removeEventListener('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]); }, [colPopup]);
const handleThClick = (key, e) => {
if (colPopup?.key === key) { setColPopup(null); return; }
const rect = e.currentTarget.getBoundingClientRect();
const popupW = 260;
const margin = 8; // min gap from screen edge
// Centre on the th, then clamp so popup stays within viewport
let x = rect.left + rect.width / 2;
x = Math.max(popupW / 2 + margin, Math.min(x, window.innerWidth - popupW / 2 - margin));
// Position popup ABOVE the header; arrow points down toward the th
const y = rect.top - 6; // 6px gap above the th top edge
// Store arrowLeft as offset from popup left edge so arrow stays over the th
const popupLeft = x - popupW / 2;
const arrowLeft = Math.max(16, Math.min(rect.left + rect.width / 2 - popupLeft, popupW - 16));
setColPopup({ key, x, y, arrowLeft });
};
// ─── TABLE SCROLL INDICATORS ───────────────────────────────────────── // ─── TABLE SCROLL INDICATORS ─────────────────────────────────────────
// Track whether the body scroller can scroll left/right so we can show // Track whether the body scroller can scroll left/right so we can show
// fade + chevron indicators on the table edges. // fade + chevron indicators on the table edges.
@@ -1259,31 +1304,31 @@ ${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['a
<table class="utci-table utci-table-head" ref=${headTableRef}> <table class="utci-table utci-table-head" ref=${headTableRef}>
<thead> <thead>
<tr> <tr>
<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('hour', e)}>Hour</th> <th class="col-info-th" scope="col" onClick=${(e) => handleThClick('hour', e)} onMouseEnter=${(e) => handleThEnter('hour', e)} onMouseLeave=${handleThLeave}>Hour</th>
${visibleCols.air && html`<th class="utci-tight-head col-info-th" scope="col" onClick=${(e) => handleThClick('air', e)}>Air <span class="col-unit">°C</span></th>`} ${visibleCols.air && html`<th class="utci-tight-head col-info-th" scope="col" onClick=${(e) => handleThClick('air', e)} onMouseEnter=${(e) => handleThEnter('air', e)} onMouseLeave=${handleThLeave}>Air <span class="col-unit">°C</span></th>`}
${visibleCols.rh && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('rh', e)}>RH <span class="col-unit">%</span></th>`} ${visibleCols.rh && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('rh', e)} onMouseEnter=${(e) => handleThEnter('rh', e)} onMouseLeave=${handleThLeave}>RH <span class="col-unit">%</span></th>`}
${visibleCols.dew && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('dew', e)}>Dew <span class="col-unit">°C</span></th>`} ${visibleCols.dew && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('dew', e)} onMouseEnter=${(e) => handleThEnter('dew', e)} onMouseLeave=${handleThLeave}>Dew <span class="col-unit">°C</span></th>`}
${visibleCols.soilT && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('soilT', e)}>Soil °C <span class="col-unit">surface</span></th>`} ${visibleCols.soilT && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('soilT', e)} onMouseEnter=${(e) => handleThEnter('soilT', e)} onMouseLeave=${handleThLeave}>Soil °C <span class="col-unit">surface</span></th>`}
${visibleCols.soilT6 && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('soilT6', e)}>Soil 6cm <span class="col-unit">°C root</span></th>`} ${visibleCols.soilT6 && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('soilT6', e)} onMouseEnter=${(e) => handleThEnter('soilT6', e)} onMouseLeave=${handleThLeave}>Soil 6cm <span class="col-unit">°C root</span></th>`}
${visibleCols.soilM && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('soilM', e)}>Soil moist <span class="col-unit">m³/m³</span></th>`} ${visibleCols.soilM && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('soilM', e)} onMouseEnter=${(e) => handleThEnter('soilM', e)} onMouseLeave=${handleThLeave}>Soil moist <span class="col-unit">m³/m³</span></th>`}
${visibleCols.concreteT && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('concreteT', e)}>Concrete <span class="col-unit">°C surface</span></th>`} ${visibleCols.concreteT && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('concreteT', e)} onMouseEnter=${(e) => handleThEnter('concreteT', e)} onMouseLeave=${handleThLeave}>Concrete <span class="col-unit">°C surface</span></th>`}
${visibleCols.wind && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('wind', e)}>Wind <span class="col-unit">m/s (gust)</span></th>`} ${visibleCols.wind && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('wind', e)} onMouseEnter=${(e) => handleThEnter('wind', e)} onMouseLeave=${handleThLeave}>Wind <span class="col-unit">m/s (gust)</span></th>`}
${visibleCols.dir && html`<th class="utci-dir-cell col-info-th" scope="col" onClick=${(e) => handleThClick('dir', e)}>Dir <span class="col-unit">-</span></th>`} ${visibleCols.dir && html`<th class="utci-dir-cell col-info-th" scope="col" onClick=${(e) => handleThClick('dir', e)} onMouseEnter=${(e) => handleThEnter('dir', e)} onMouseLeave=${handleThLeave}>Dir <span class="col-unit">-</span></th>`}
${visibleCols.cloud && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('cloud', e)}>Cloud <span class="col-unit">%</span></th>`} ${visibleCols.cloud && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('cloud', e)} onMouseEnter=${(e) => handleThEnter('cloud', e)} onMouseLeave=${handleThLeave}>Cloud <span class="col-unit">%</span></th>`}
${visibleCols.sun && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('sun', e)}>Sun <span class="col-unit">elev°</span></th>`} ${visibleCols.sun && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('sun', e)} onMouseEnter=${(e) => handleThEnter('sun', e)} onMouseLeave=${handleThLeave}>Sun <span class="col-unit">elev°</span></th>`}
${visibleCols.direct && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('direct', e)}>Direct <span class="col-unit">W/m²</span></th>`} ${visibleCols.direct && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('direct', e)} onMouseEnter=${(e) => handleThEnter('direct', e)} onMouseLeave=${handleThLeave}>Direct <span class="col-unit">W/m²</span></th>`}
${visibleCols.diffuse && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('diffuse', e)}>Diffuse <span class="col-unit">W/m²</span></th>`} ${visibleCols.diffuse && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('diffuse', e)} onMouseEnter=${(e) => handleThEnter('diffuse', e)} onMouseLeave=${handleThLeave}>Diffuse <span class="col-unit">W/m²</span></th>`}
${visibleCols.tmrt && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('tmrt', e)}>Tmrt <span class="col-unit">°C</span></th>`} ${visibleCols.tmrt && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('tmrt', e)} onMouseEnter=${(e) => handleThEnter('tmrt', e)} onMouseLeave=${handleThLeave}>Tmrt <span class="col-unit">°C</span></th>`}
${visibleCols.delta && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('delta', e)}>Δ <span class="col-unit">UTCIAir</span></th>`} ${visibleCols.delta && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('delta', e)} onMouseEnter=${(e) => handleThEnter('delta', e)} onMouseLeave=${handleThLeave}>Δ <span class="col-unit">UTCIAir</span></th>`}
${visibleCols.utci && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('utci', e)}>UTCI <span class="col-unit">°C felt</span></th>`} ${visibleCols.utci && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('utci', e)} onMouseEnter=${(e) => handleThEnter('utci', e)} onMouseLeave=${handleThLeave}>UTCI <span class="col-unit">°C felt</span></th>`}
${visibleCols.uvA && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('uvA', e)}>UV-A <span class="col-unit">est. idx</span></th>`} ${visibleCols.uvA && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('uvA', e)} onMouseEnter=${(e) => handleThEnter('uvA', e)} onMouseLeave=${handleThLeave}>UV-A <span class="col-unit">est. idx</span></th>`}
${visibleCols.uvB && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('uvB', e)}>UV-B <span class="col-unit">est. idx</span></th>`} ${visibleCols.uvB && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('uvB', e)} onMouseEnter=${(e) => handleThEnter('uvB', e)} onMouseLeave=${handleThLeave}>UV-B <span class="col-unit">est. idx</span></th>`}
${visibleCols.burn && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('burn', e)}>Burn <span class="col-unit">to MED</span></th>`} ${visibleCols.burn && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('burn', e)} onMouseEnter=${(e) => handleThEnter('burn', e)} onMouseLeave=${handleThLeave}>Burn <span class="col-unit">to MED</span></th>`}
${visibleCols.vehicleT && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('vehicleT', e)}>Vehicle <span class="col-unit">°C peak</span></th>`} ${visibleCols.vehicleT && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('vehicleT', e)} onMouseEnter=${(e) => handleThEnter('vehicleT', e)} onMouseLeave=${handleThLeave}>Vehicle <span class="col-unit">°C peak</span></th>`}
${indoorMode === 'on' && !indoorManaged && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('indoorT', e)}>Indoors <span class="col-unit">°C est.</span></th>`} ${indoorMode === 'on' && !indoorManaged && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('indoorT', e)} onMouseEnter=${(e) => handleThEnter('indoorT', e)} onMouseLeave=${handleThLeave}>Indoors <span class="col-unit">°C est.</span></th>`}
${indoorMode === 'on' && indoorManaged && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('managedT', e)}>Managed <span class="col-unit">°C est.</span></th>`} ${indoorMode === 'on' && indoorManaged && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('managedT', e)} onMouseEnter=${(e) => handleThEnter('managedT', e)} onMouseLeave=${handleThLeave}>Managed <span class="col-unit">°C est.</span></th>`}
${visibleCols.precip && html`<th class="utci-tight-head col-info-th" scope="col" onClick=${(e) => handleThClick('precip', e)}>Pcpt <span class="col-unit">mm/h</span></th>`} ${visibleCols.precip && html`<th class="utci-tight-head col-info-th" scope="col" onClick=${(e) => handleThClick('precip', e)} onMouseEnter=${(e) => handleThEnter('precip', e)} onMouseLeave=${handleThLeave}>Pcpt <span class="col-unit">mm/h</span></th>`}
${visibleCols.utciP && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('utciP', e)}>UTCI+P <span class="col-unit">°C adj.</span></th>`} ${visibleCols.utciP && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('utciP', e)} onMouseEnter=${(e) => handleThEnter('utciP', e)} onMouseLeave=${handleThLeave}>UTCI+P <span class="col-unit">°C adj.</span></th>`}
</tr> </tr>
</thead> </thead>
</table> </table>
@@ -1460,16 +1505,18 @@ ${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['a
${colPopup && COL_DESCRIPTIONS[colPopup.key] && html` ${colPopup && COL_DESCRIPTIONS[colPopup.key] && html`
<div <div
ref=${colPopupRef} ref=${colPopupRef}
class="col-info-popup" class=${`col-info-popup${colPopup.below ? ' col-info-popup--below' : ''}`}
style=${{ style=${{
position: 'fixed', position: 'fixed',
left: `${colPopup.x}px`, left: `${colPopup.x}px`,
top: `${colPopup.y}px`, top: `${colPopup.y}px`,
transform: 'translateX(-50%) translateY(-100%)', transform: colPopup.below ? 'translateX(-50%)' : 'translateX(-50%) translateY(-100%)',
'--arrow-left': `${colPopup.arrowLeft}px`, '--arrow-left': `${colPopup.arrowLeft}px`,
}} }}
onMouseEnter=${handlePopupEnter}
onMouseLeave=${handlePopupLeave}
> >
<button class="col-info-close" onClick=${() => setColPopup(null)} aria-label="Close">×</button> <button class="col-info-close" onClick=${closePopup} aria-label="Close">×</button>
<strong class="col-info-title">${COL_DESCRIPTIONS[colPopup.key].title}</strong> <strong class="col-info-title">${COL_DESCRIPTIONS[colPopup.key].title}</strong>
<p class="col-info-desc">${COL_DESCRIPTIONS[colPopup.key].desc}</p> <p class="col-info-desc">${COL_DESCRIPTIONS[colPopup.key].desc}</p>
</div>`} </div>`}