v1.03 - Fix table popups
This commit is contained in:
+97
-50
@@ -238,7 +238,10 @@ export function UTCIForecast() {
|
||||
// Clicking a <th> shows a small description popup below it.
|
||||
// State holds { key, x, y } or null when closed.
|
||||
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 = {
|
||||
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.' },
|
||||
};
|
||||
|
||||
// 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(() => {
|
||||
if (!colPopup) return;
|
||||
const onClickOutside = (e) => {
|
||||
if (colPopupRef.current && !colPopupRef.current.contains(e.target)) {
|
||||
setColPopup(null);
|
||||
}
|
||||
if (colPopupRef.current && !colPopupRef.current.contains(e.target)) closePopup();
|
||||
};
|
||||
const onScrollOrResize = () => {
|
||||
if (!colPopupThRef.current) return;
|
||||
setColPopup(prev => prev ? { ...prev, ...calcPopupPos(colPopupThRef.current) } : null);
|
||||
};
|
||||
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]);
|
||||
|
||||
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 ─────────────────────────────────────────
|
||||
// Track whether the body scroller can scroll left/right so we can show
|
||||
// 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}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('hour', e)}>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.rh && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('rh', e)}>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.soilT && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('soilT', e)}>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.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.concreteT && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('concreteT', e)}>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.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.cloud && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('cloud', e)}>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.direct && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('direct', e)}>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.tmrt && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('tmrt', e)}>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">UTCI−Air</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.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.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.burn && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('burn', e)}>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>`}
|
||||
${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('managedT', e)}>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.utciP && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('utciP', e)}>UTCI+P <span class="col-unit">°C adj.</span></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)} 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)} 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)} 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)} 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)} 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)} 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)} 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)} 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)} 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)} 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)} 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)} 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)} 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)} 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)} onMouseEnter=${(e) => handleThEnter('delta', e)} onMouseLeave=${handleThLeave}>Δ <span class="col-unit">UTCI−Air</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)} 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)} 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)} 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)} 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)} 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)} 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)} 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)} onMouseEnter=${(e) => handleThEnter('utciP', e)} onMouseLeave=${handleThLeave}>UTCI+P <span class="col-unit">°C adj.</span></th>`}
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
@@ -1460,16 +1505,18 @@ ${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['a
|
||||
${colPopup && COL_DESCRIPTIONS[colPopup.key] && html`
|
||||
<div
|
||||
ref=${colPopupRef}
|
||||
class="col-info-popup"
|
||||
class=${`col-info-popup${colPopup.below ? ' col-info-popup--below' : ''}`}
|
||||
style=${{
|
||||
position: 'fixed',
|
||||
left: `${colPopup.x}px`,
|
||||
top: `${colPopup.y}px`,
|
||||
transform: 'translateX(-50%) translateY(-100%)',
|
||||
transform: colPopup.below ? 'translateX(-50%)' : 'translateX(-50%) translateY(-100%)',
|
||||
'--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>
|
||||
<p class="col-info-desc">${COL_DESCRIPTIONS[colPopup.key].desc}</p>
|
||||
</div>`}
|
||||
|
||||
Reference in New Issue
Block a user