import type { ViewHook } from "phoenix_live_view" import { FACTOR_META, FACTOR_ORDER, factorBar, factorExplanation, scoreTier } from "./propagation_map_hook" interface PathForecastDetail { iso: string score: number factors: Record band_label: string humidity_effect: string unavailable?: boolean } interface PathForecastHook extends ViewHook { tooltip: HTMLDivElement | null cache: Map pendingIso: string | null hideTimer: ReturnType | null enterHandler: (e: MouseEvent) => void leaveHandler: (e: MouseEvent) => void moveHandler: (e: MouseEvent) => void showFor(this: PathForecastHook, target: SVGCircleElement, e: MouseEvent): void hideSoon(this: PathForecastHook): void cancelHide(this: PathForecastHook): void position(this: PathForecastHook, e: MouseEvent): void render(this: PathForecastHook, iso: string): void } function buildHTML(detail: PathForecastDetail): string { if (detail.unavailable) { return `
Factor breakdown unavailable for this hour
(no analysis profile in lookback window)
` } const tier = scoreTier(detail.score) const time = new Date(detail.iso).toISOString().replace("T", " ").slice(0, 16) + " UTC" let rows = "" let explanations = "" for (const key of FACTOR_ORDER) { const meta = FACTOR_META[key] const value = detail.factors[key] if (value === undefined || value === null) continue const pct = Math.round(meta.weight * 100) rows += ` ${meta.label} ${factorBar(value)} ${value} (${pct}%) ` const expl = factorExplanation(key, value, { humidity_effect: detail.humidity_effect }) if (!expl) continue const eTier = scoreTier(value) explanations += `
\u25CF ${meta.label}: ${expl}
` } return `
${detail.score}/100 ${tier.label}
${detail.band_label} · ${time}
${rows}
Analysis
${explanations}
` } function buildLoadingHTML(iso: string): string { const time = new Date(iso).toISOString().replace("T", " ").slice(0, 16) + " UTC" return `
Loading factors for ${time}\u2026
` } export const PathForecast: Partial = { mounted(this: PathForecastHook) { this.cache = new Map() this.pendingIso = null this.hideTimer = null const tooltip = document.createElement("div") tooltip.style.cssText = [ "position:absolute", "z-index:50", "min-width:280px", "max-width:340px", "background:#1f2937", "color:#f1f5f9", "border:1px solid rgba(255,255,255,0.12)", "border-radius:6px", "box-shadow:0 4px 18px rgba(0,0,0,0.45)", "pointer-events:none", "display:none", "transition:opacity 80ms" ].join(";") document.body.appendChild(tooltip) this.tooltip = tooltip this.enterHandler = (e: MouseEvent) => { const target = (e.target as Element)?.closest("circle[data-iso]") as SVGCircleElement | null if (target) this.showFor(target, e) } this.leaveHandler = (e: MouseEvent) => { const related = e.relatedTarget as Element | null if (related && related.closest && related.closest("circle[data-iso]")) return this.hideSoon() } this.moveHandler = (e: MouseEvent) => { if (this.tooltip && this.tooltip.style.display !== "none") this.position(e) } this.el.addEventListener("mouseover", this.enterHandler) this.el.addEventListener("mouseout", this.leaveHandler) this.el.addEventListener("mousemove", this.moveHandler) this.handleEvent("path_forecast_detail", (payload: PathForecastDetail) => { this.cache.set(payload.iso, payload) if (this.pendingIso === payload.iso) this.render(payload.iso) }) }, destroyed(this: PathForecastHook) { if (this.tooltip) this.tooltip.remove() if (this.hideTimer) clearTimeout(this.hideTimer) this.el.removeEventListener("mouseover", this.enterHandler) this.el.removeEventListener("mouseout", this.leaveHandler) this.el.removeEventListener("mousemove", this.moveHandler) }, showFor(this: PathForecastHook, target: SVGCircleElement, e: MouseEvent) { const iso = target.dataset.iso if (!iso || !this.tooltip) return this.cancelHide() this.pendingIso = iso this.tooltip.style.display = "block" this.position(e) const cached = this.cache.get(iso) if (cached) { this.tooltip.innerHTML = buildHTML(cached) } else { this.tooltip.innerHTML = buildLoadingHTML(iso) this.pushEvent("path_forecast_detail", { iso }) } }, render(this: PathForecastHook, iso: string) { const cached = this.cache.get(iso) if (cached && this.tooltip) this.tooltip.innerHTML = buildHTML(cached) }, hideSoon(this: PathForecastHook) { this.cancelHide() this.hideTimer = setTimeout(() => { if (this.tooltip) this.tooltip.style.display = "none" this.pendingIso = null }, 120) }, cancelHide(this: PathForecastHook) { if (this.hideTimer) { clearTimeout(this.hideTimer) this.hideTimer = null } }, position(this: PathForecastHook, e: MouseEvent) { if (!this.tooltip) return const pad = 14 const rect = this.tooltip.getBoundingClientRect() const vw = window.innerWidth const vh = window.innerHeight let x = e.clientX + pad let y = e.clientY + pad if (x + rect.width > vw - 8) x = e.clientX - rect.width - pad if (y + rect.height > vh - 8) y = e.clientY - rect.height - pad if (x < 8) x = 8 if (y < 8) y = 8 this.tooltip.style.left = `${x + window.scrollX}px` this.tooltip.style.top = `${y + window.scrollY}px` } }