207 lines
7.1 KiB
TypeScript
207 lines
7.1 KiB
TypeScript
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<string, number>
|
|
band_label: string
|
|
humidity_effect: string
|
|
unavailable?: boolean
|
|
}
|
|
|
|
interface PathForecastHook extends ViewHook {
|
|
tooltip: HTMLDivElement | null
|
|
cache: Map<string, PathForecastDetail>
|
|
pendingIso: string | null
|
|
hideTimer: ReturnType<typeof setTimeout> | 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 `
|
|
<div style="padding:10px 14px;font-size:12px;opacity:0.85;">
|
|
Factor breakdown unavailable for this hour
|
|
<div style="opacity:0.55;font-size:10px;margin-top:2px;">
|
|
(no analysis profile in lookback window)
|
|
</div>
|
|
</div>`
|
|
}
|
|
|
|
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 += `<tr>
|
|
<td style="padding:1px 6px 1px 0;white-space:nowrap;font-size:11px;">${meta.label}</td>
|
|
<td style="padding:1px 4px;font-family:'Cascadia Code',monospace;font-size:11px;letter-spacing:-0.5px;">${factorBar(value)}</td>
|
|
<td style="padding:1px 4px;text-align:right;font-size:11px;font-weight:600;">${value}</td>
|
|
<td style="padding:1px 4px;font-size:10px;opacity:0.5;">(${pct}%)</td>
|
|
</tr>`
|
|
const expl = factorExplanation(key, value, { humidity_effect: detail.humidity_effect })
|
|
if (!expl) continue
|
|
const eTier = scoreTier(value)
|
|
explanations += `<div style="padding:2px 0;font-size:12px;display:flex;gap:6px;">
|
|
<span style="color:${eTier.color};flex-shrink:0;">\u25CF</span>
|
|
<span><strong>${meta.label}:</strong> ${expl}</span>
|
|
</div>`
|
|
}
|
|
|
|
return `
|
|
<div style="background:${tier.color};color:#000;padding:5px 10px;display:flex;justify-content:space-between;align-items:center;">
|
|
<strong style="font-size:14px;">${detail.score}/100</strong>
|
|
<span style="font-size:11px;font-weight:700;">${tier.label}</span>
|
|
</div>
|
|
<div style="padding:6px 10px;">
|
|
<div style="font-size:11px;opacity:0.7;margin-bottom:4px;">${detail.band_label} · ${time}</div>
|
|
<table style="width:100%;border-collapse:collapse;border-top:1px solid rgba(255,255,255,0.15);padding-top:4px;">${rows}</table>
|
|
</div>
|
|
<div style="padding:6px 10px;border-top:1px solid rgba(255,255,255,0.15);">
|
|
<div style="font-size:10px;font-weight:700;opacity:0.5;margin-bottom:3px;text-transform:uppercase;letter-spacing:0.5px;">Analysis</div>
|
|
${explanations}
|
|
</div>`
|
|
}
|
|
|
|
function buildLoadingHTML(iso: string): string {
|
|
const time = new Date(iso).toISOString().replace("T", " ").slice(0, 16) + " UTC"
|
|
return `
|
|
<div style="padding:8px 12px;font-size:12px;display:flex;align-items:center;gap:8px;">
|
|
<span class="loading loading-spinner loading-xs"></span>
|
|
<span>Loading factors for ${time}\u2026</span>
|
|
</div>`
|
|
}
|
|
|
|
export const PathForecast: Partial<PathForecastHook> = {
|
|
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`
|
|
}
|
|
}
|