The 18-hour forecast chart on /path now shows the same weighted-
criteria panel the /map popup builds when the user clicks a grid
cell. Hovering a dot fires a server roundtrip that fetches
Propagation.point_detail at the path's midpoint for that valid_time;
results are cached client-side so re-hovering is instant.
`Propagation.point_detail/4` now returns a `:profile_source` tag —
`:exact`, `{:fallback, fallback_valid_time}`, or `:unavailable` —
distinguishing real per-hour breakdowns (Rust pipeline writes a
profile file for every f00..f18 step) from rare cycle-miss fallbacks
where we approximate from a recent analysis profile. Stale comment
about "only f00 persists profiles" updated.
Map utilities (FACTOR_META, FACTOR_ORDER, factorBar,
factorExplanation, scoreTier, FactorMeta, ScoreTier) are now exported
from propagation_map_hook so the new path hook reuses the exact same
look + copy.
421 lines
16 KiB
TypeScript
421 lines
16 KiB
TypeScript
// Leaflet map library (vendored)
|
|
import * as L from "../vendor/leaflet/leaflet.js"
|
|
window.L = L
|
|
|
|
// Include phoenix_html to handle method=PUT/DELETE in forms and buttons.
|
|
import "phoenix_html"
|
|
// Establish Phoenix Socket and LiveView configuration.
|
|
import {Socket} from "phoenix"
|
|
import {LiveSocket} from "phoenix_live_view"
|
|
import {hooks as colocatedHooks} from "phoenix-colocated/microwaveprop"
|
|
import topbar from "../vendor/topbar"
|
|
import "../vendor/canvas-confetti"
|
|
|
|
import initLiveStash from "../../deps/live_stash/assets/js/live-stash.js"
|
|
import {PropagationMap} from "./propagation_map_hook"
|
|
import {PathForecast} from "./path_forecast_hook"
|
|
import {ContactMap} from "./contact_map_hook"
|
|
import {ContactsMap} from "./contacts_map_hook"
|
|
import {ElevationProfile} from "./elevation_profile_hook"
|
|
import {WeatherMap} from "./weather_map_hook"
|
|
import {LocateMe, CopyLink} from "./locate_me_hook"
|
|
import {RoverMap} from "./rover_map_hook"
|
|
import {BeaconMap} from "./beacon_map_hook"
|
|
import {BeaconsListMap} from "./beacons_list_map_hook"
|
|
|
|
// /eme is the only page that uses Three.js (~600 kB). Keep that out
|
|
// of the main app bundle by dynamic-importing on first hook mount:
|
|
// esbuild's --splitting flag emits a separate chunk that the browser
|
|
// fetches when (and only when) a user lands on /eme.
|
|
interface LazyHook {
|
|
_impl?: any
|
|
_pendingUpdate?: boolean
|
|
}
|
|
|
|
const EmeGlobe = {
|
|
mounted(this: ViewHook & LazyHook) {
|
|
const hook = this
|
|
import("./eme_globe_hook").then((mod) => {
|
|
const impl = mod.EmeGlobe
|
|
hook._impl = impl
|
|
for (const k of Object.keys(impl)) {
|
|
if (k !== "mounted" && k !== "updated" && k !== "destroyed") {
|
|
;(hook as any)[k] = (impl as any)[k]
|
|
}
|
|
}
|
|
impl.mounted.call(hook)
|
|
if (hook._pendingUpdate && impl.updated) impl.updated.call(hook)
|
|
hook._pendingUpdate = false
|
|
})
|
|
},
|
|
updated(this: ViewHook & LazyHook) {
|
|
if (this._impl?.updated) this._impl.updated.call(this)
|
|
else this._pendingUpdate = true
|
|
},
|
|
destroyed(this: ViewHook & LazyHook) {
|
|
this._impl?.destroyed?.call(this)
|
|
}
|
|
}
|
|
|
|
interface UtcClockHook extends ViewHook {
|
|
timer: ReturnType<typeof setInterval>
|
|
tick(): void
|
|
}
|
|
|
|
const UtcClock = {
|
|
mounted(this: UtcClockHook) {
|
|
this.tick()
|
|
this.timer = setInterval(() => this.tick(), 10_000)
|
|
},
|
|
tick(this: UtcClockHook) {
|
|
const now = new Date()
|
|
const h = now.getUTCHours().toString().padStart(2, "0")
|
|
const m = now.getUTCMinutes().toString().padStart(2, "0")
|
|
this.el.textContent = `${h}:${m} UTC`
|
|
},
|
|
destroyed(this: UtcClockHook) {
|
|
clearInterval(this.timer)
|
|
}
|
|
}
|
|
|
|
interface CommaNumberHook extends ViewHook {
|
|
format(): void
|
|
}
|
|
|
|
// Realistic-look recipe from https://confetti.js.org (the "realistic" preset).
|
|
// Fires exactly once when the hook element mounts — we attach the hook to
|
|
// the import-complete banner, which only renders when run.status == "complete".
|
|
const ImportConfetti = {
|
|
mounted(this: ViewHook) {
|
|
const fire = window.confetti
|
|
if (typeof fire !== "function") return
|
|
const count = 200
|
|
const defaults = { origin: { y: 0.7 } }
|
|
const burst = (particleRatio: number, opts: ConfettiOptions) =>
|
|
fire({ ...defaults, ...opts, particleCount: Math.floor(count * particleRatio) })
|
|
burst(0.25, { spread: 26, startVelocity: 55 })
|
|
burst(0.2, { spread: 60 })
|
|
burst(0.35, { spread: 100, decay: 0.91, scalar: 0.8 })
|
|
burst(0.1, { spread: 120, startVelocity: 25, decay: 0.92, scalar: 1.2 })
|
|
burst(0.1, { spread: 120, startVelocity: 45 })
|
|
}
|
|
}
|
|
|
|
const CommaNumber = {
|
|
mounted(this: CommaNumberHook) {
|
|
this.el.addEventListener("input", () => this.format())
|
|
this.format()
|
|
},
|
|
updated(this: CommaNumberHook) { this.format() },
|
|
format(this: CommaNumberHook) {
|
|
const input = (this.el.querySelector("input") || this.el) as HTMLInputElement
|
|
const raw = input.value.replace(/,/g, "")
|
|
if (raw === "" || raw === "-") return
|
|
const cursor = input.selectionStart ?? 0
|
|
const commasBefore = (input.value.slice(0, cursor).match(/,/g) || []).length
|
|
const parts = raw.split(".")
|
|
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ",")
|
|
const formatted = parts.join(".")
|
|
if (formatted !== input.value) {
|
|
input.value = formatted
|
|
const commasAfter = (formatted.slice(0, cursor).match(/,/g) || []).length
|
|
const newCursor = cursor + (commasAfter - commasBefore)
|
|
input.setSelectionRange(newCursor, newCursor)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Skew-T crosshair: tracks mouse Y, inverts pressure_y to find the
|
|
// pressure level under the cursor, interpolates T/Td/height from the
|
|
// profile array (passed in via data-profile JSON) and updates the
|
|
// crosshair line + readout that SkewtSvg emits with display:none.
|
|
interface SkewtHook extends ViewHook {
|
|
svg: SVGSVGElement | null
|
|
cursor: SVGElement | null
|
|
line: SVGLineElement | null
|
|
dotT: SVGCircleElement | null
|
|
dotD: SVGCircleElement | null
|
|
bg: SVGRectElement | null
|
|
text: SVGTextElement | null
|
|
lines: (SVGTSpanElement | null)[]
|
|
geom: SkewtGeom
|
|
normalized: SkewtLevel[]
|
|
onMove(e: MouseEvent): void
|
|
onLeave(): void
|
|
pressureAtY(y: number): number
|
|
temperatureX(t: number, y: number): number
|
|
interp(field: "tmpc" | "dwpc" | "hght", p: number): number | null
|
|
clientToViewBox(e: MouseEvent): {x: number, y: number} | null
|
|
handleMove(e: MouseEvent): void
|
|
}
|
|
|
|
interface SkewtGeom {
|
|
width: number; height: number
|
|
left: number; right: number; top: number; bottom: number
|
|
p_bottom: number; p_top: number
|
|
t_min: number; t_max: number
|
|
skew: number
|
|
}
|
|
|
|
interface SkewtLevel {
|
|
pres: number
|
|
hght: number
|
|
tmpc: number | null
|
|
dwpc: number | null
|
|
}
|
|
|
|
const Skewt = {
|
|
mounted(this: SkewtHook) {
|
|
this.svg = this.el.querySelector("svg")
|
|
this.cursor = this.el.querySelector("[data-cursor]")
|
|
this.line = this.el.querySelector(".skewt-cursor-line")
|
|
this.dotT = this.el.querySelector("[data-cursor-dot-t]")
|
|
this.dotD = this.el.querySelector("[data-cursor-dot-d]")
|
|
this.bg = this.el.querySelector("[data-cursor-readout-bg]")
|
|
this.text = this.el.querySelector("[data-cursor-readout]")
|
|
this.lines = [
|
|
this.el.querySelector("[data-cursor-line-1]"),
|
|
this.el.querySelector("[data-cursor-line-2]"),
|
|
this.el.querySelector("[data-cursor-line-3]"),
|
|
this.el.querySelector("[data-cursor-line-4]"),
|
|
]
|
|
|
|
let rawProfile: any[] = []
|
|
try { rawProfile = JSON.parse(this.el.dataset.profile || "[]") } catch { rawProfile = [] }
|
|
try { this.geom = JSON.parse(this.el.dataset.geometry || "{}") } catch { this.geom = {} as SkewtGeom }
|
|
|
|
this.normalized = []
|
|
for (const lvl of rawProfile) {
|
|
const pres = +(lvl.pres ?? lvl.pres_mb)
|
|
const hght = +(lvl.hght ?? lvl.hght_m)
|
|
const tmpc = lvl.tmpc, dwpc = lvl.dwpc
|
|
if (Number.isFinite(pres) && Number.isFinite(hght)) {
|
|
this.normalized.push({
|
|
pres, hght,
|
|
tmpc: Number.isFinite(tmpc) ? +tmpc : null,
|
|
dwpc: Number.isFinite(dwpc) ? +dwpc : null,
|
|
})
|
|
}
|
|
}
|
|
this.normalized.sort((a, b) => b.pres - a.pres)
|
|
|
|
this.onMove = (e: MouseEvent) => this.handleMove(e)
|
|
this.onLeave = () => { if (this.cursor) (this.cursor as SVGElement).style.display = "none" }
|
|
if (this.svg) {
|
|
this.svg.addEventListener("mousemove", this.onMove)
|
|
this.svg.addEventListener("mouseleave", this.onLeave)
|
|
}
|
|
},
|
|
|
|
destroyed(this: SkewtHook) {
|
|
if (this.svg) {
|
|
this.svg.removeEventListener("mousemove", this.onMove)
|
|
this.svg.removeEventListener("mouseleave", this.onLeave)
|
|
}
|
|
},
|
|
|
|
pressureAtY(this: SkewtHook, y: number) {
|
|
const g = this.geom
|
|
const lnB = Math.log(g.p_bottom)
|
|
const lnT = Math.log(g.p_top)
|
|
const frac = (g.bottom - y) / (g.bottom - g.top)
|
|
return Math.exp(lnB - frac * (lnB - lnT))
|
|
},
|
|
|
|
temperatureX(this: SkewtHook, t: number, y: number) {
|
|
const g = this.geom
|
|
const plotW = g.right - g.left
|
|
const baseX = g.left + ((t - g.t_min) / (g.t_max - g.t_min)) * plotW
|
|
return baseX + g.skew * (g.bottom - y)
|
|
},
|
|
|
|
interp(this: SkewtHook, field: "tmpc" | "dwpc" | "hght", p: number): number | null {
|
|
const prof = this.normalized
|
|
if (prof.length === 0) return null
|
|
if (p >= prof[0].pres) return prof[0][field] as number | null
|
|
if (p <= prof[prof.length - 1].pres) return prof[prof.length - 1][field] as number | null
|
|
for (let i = 0; i < prof.length - 1; i++) {
|
|
const a = prof[i], b = prof[i + 1]
|
|
if (p <= a.pres && p >= b.pres) {
|
|
const av = a[field] as number | null, bv = b[field] as number | null
|
|
if (av == null || bv == null) return av ?? bv
|
|
const f = (a.pres - p) / (a.pres - b.pres)
|
|
return av + f * (bv - av)
|
|
}
|
|
}
|
|
return null
|
|
},
|
|
|
|
clientToViewBox(this: SkewtHook, e: MouseEvent) {
|
|
if (!this.svg) return null
|
|
const ctm = this.svg.getScreenCTM()
|
|
if (!ctm) return null
|
|
const pt = this.svg.createSVGPoint()
|
|
pt.x = e.clientX
|
|
pt.y = e.clientY
|
|
const local = pt.matrixTransform(ctm.inverse())
|
|
return {x: local.x, y: local.y}
|
|
},
|
|
|
|
handleMove(this: SkewtHook, e: MouseEvent) {
|
|
if (this.normalized.length === 0) return
|
|
const v = this.clientToViewBox(e)
|
|
if (!v) return
|
|
const g = this.geom
|
|
if (v.y < g.top || v.y > g.bottom) {
|
|
if (this.cursor) (this.cursor as SVGElement).style.display = "none"
|
|
return
|
|
}
|
|
|
|
const p = this.pressureAtY(v.y)
|
|
const tC = this.interp("tmpc", p)
|
|
const tdC = this.interp("dwpc", p)
|
|
const hgtM = this.interp("hght", p)
|
|
|
|
if (this.line) { this.line.setAttribute("y1", String(v.y)); this.line.setAttribute("y2", String(v.y)) }
|
|
|
|
if (tC != null && this.dotT) {
|
|
this.dotT.setAttribute("cx", String(this.temperatureX(tC, v.y)))
|
|
this.dotT.setAttribute("cy", String(v.y))
|
|
this.dotT.style.display = ""
|
|
} else if (this.dotT) {
|
|
this.dotT.style.display = "none"
|
|
}
|
|
if (tdC != null && this.dotD) {
|
|
this.dotD.setAttribute("cx", String(this.temperatureX(tdC, v.y)))
|
|
this.dotD.setAttribute("cy", String(v.y))
|
|
this.dotD.style.display = ""
|
|
} else if (this.dotD) {
|
|
this.dotD.style.display = "none"
|
|
}
|
|
|
|
const boxX = g.left + 6
|
|
const boxY = g.top + 6
|
|
if (this.bg) { this.bg.setAttribute("x", String(boxX)); this.bg.setAttribute("y", String(boxY)) }
|
|
if (this.text) { this.text.setAttribute("x", String(boxX + 6)); this.text.setAttribute("y", String(boxY + 4)) }
|
|
this.lines.forEach(el => el?.setAttribute("x", String(boxX + 6)))
|
|
|
|
const ftStr = (hgtM != null && Number.isFinite(hgtM)) ? Math.round(hgtM * 3.28084).toLocaleString() : "—"
|
|
const mStr = (hgtM != null && Number.isFinite(hgtM)) ? Math.round(hgtM).toLocaleString() : "—"
|
|
const fmt = (val: number | null) => val == null || !Number.isFinite(val) ? "—" : `${val.toFixed(1)}°C`
|
|
if (this.lines[0]) this.lines[0]!.textContent = `${p.toFixed(1)} mb`
|
|
if (this.lines[1]) this.lines[1]!.textContent = `${mStr} m / ${ftStr} ft`
|
|
if (this.lines[2]) this.lines[2]!.textContent = `T ${fmt(tC)}`
|
|
if (this.lines[3]) this.lines[3]!.textContent = `Td ${fmt(tdC)}`
|
|
|
|
if (this.cursor) (this.cursor as SVGElement).style.display = ""
|
|
}
|
|
} as Partial<SkewtHook>
|
|
|
|
const csrfToken = document.querySelector("meta[name='csrf-token']")!.getAttribute("content")
|
|
const liveSocket = new LiveSocket("/live", Socket, {
|
|
longPollFallbackMs: 2500,
|
|
params: initLiveStash({_csrf_token: csrfToken}),
|
|
hooks: {...colocatedHooks, PropagationMap, PathForecast, UtcClock, ContactMap, ContactsMap, ElevationProfile, WeatherMap, LocateMe, CopyLink, RoverMap, BeaconMap, BeaconsListMap, CommaNumber, ImportConfetti, EmeGlobe, Skewt},
|
|
})
|
|
|
|
// live_table rows are dead on their own — wire up whole-row clicks to
|
|
// trigger the first <a href> inside that row (typically the Actions "View"
|
|
// link). Clicks on interactive elements are ignored so inner links/buttons
|
|
// still win.
|
|
document.addEventListener("click", (e: MouseEvent) => {
|
|
const target = e.target as HTMLElement | null
|
|
if (!target) return
|
|
if (target.closest("a, button, input, select, label, [role='option'], [role='menuitem']")) return
|
|
const tr = target.closest("#live-table tbody tr[id]") as HTMLTableRowElement | null
|
|
if (!tr || tr.id === "empty-placeholder") return
|
|
const link = tr.querySelector("a[href]") as HTMLAnchorElement | null
|
|
if (link) link.click()
|
|
})
|
|
|
|
// Show progress bar on live navigation and form submits
|
|
topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"})
|
|
window.addEventListener("phx:page-loading-start", _info => topbar.show(300))
|
|
window.addEventListener("phx:page-loading-stop", _info => topbar.hide())
|
|
|
|
// Delay disconnect alerts by 3 seconds so brief hiccups don't flash errors
|
|
let disconnectTimer: ReturnType<typeof setTimeout> | null = null
|
|
const DISCONNECT_DELAY_MS = 3000
|
|
|
|
function showDisconnectAlert(id: string) {
|
|
const el = document.getElementById(id)
|
|
if (el) el.removeAttribute("hidden")
|
|
}
|
|
|
|
function hideDisconnectAlert(id: string) {
|
|
const el = document.getElementById(id)
|
|
if (el) el.setAttribute("hidden", "")
|
|
}
|
|
|
|
new MutationObserver(() => {
|
|
const root = document.querySelector("[data-phx-main]")
|
|
if (!root) return
|
|
|
|
const hasError = root.classList.contains("phx-client-error") ||
|
|
root.classList.contains("phx-server-error")
|
|
|
|
if (hasError && !disconnectTimer) {
|
|
disconnectTimer = setTimeout(() => {
|
|
const r = document.querySelector("[data-phx-main]")
|
|
if (r?.classList.contains("phx-client-error")) showDisconnectAlert("client-error")
|
|
if (r?.classList.contains("phx-server-error")) showDisconnectAlert("server-error")
|
|
}, DISCONNECT_DELAY_MS)
|
|
} else if (!hasError && disconnectTimer) {
|
|
clearTimeout(disconnectTimer)
|
|
disconnectTimer = null
|
|
hideDisconnectAlert("client-error")
|
|
hideDisconnectAlert("server-error")
|
|
}
|
|
}).observe(document.body, {subtree: true, attributes: true, attributeFilter: ["class"]})
|
|
|
|
// connect if there are any LiveViews on the page
|
|
liveSocket.connect()
|
|
|
|
// expose liveSocket on window for web console debug logs and latency simulation:
|
|
// >> liveSocket.enableDebug()
|
|
// >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session
|
|
// >> liveSocket.disableLatencySim()
|
|
window.liveSocket = liveSocket
|
|
|
|
// The lines below enable quality of life phoenix_live_reload
|
|
// development features:
|
|
//
|
|
// 1. stream server logs to the browser console
|
|
// 2. click on elements to jump to their definitions in your code editor
|
|
//
|
|
if (process.env.NODE_ENV === "development") {
|
|
// Phoenix.LiveReloader attaches a small API surface to its detached
|
|
// `reloader` instance. Type only what we actually touch instead of `any`.
|
|
type Reloader = {
|
|
enableServerLogs: () => void
|
|
openEditorAtCaller: (target: EventTarget | null) => void
|
|
openEditorAtDef: (target: EventTarget | null) => void
|
|
}
|
|
window.addEventListener("phx:live_reload:attached", ((event: CustomEvent<Reloader>) => {
|
|
const reloader = event.detail
|
|
// Enable server log streaming to client.
|
|
// Disable with reloader.disableServerLogs()
|
|
reloader.enableServerLogs()
|
|
|
|
// Open configured PLUG_EDITOR at file:line of the clicked element's HEEx component
|
|
//
|
|
// * click with "c" key pressed to open at caller location
|
|
// * click with "d" key pressed to open at function component definition location
|
|
let keyDown: string | null
|
|
window.addEventListener("keydown", (e: KeyboardEvent) => keyDown = e.key)
|
|
window.addEventListener("keyup", (_e: KeyboardEvent) => keyDown = null)
|
|
window.addEventListener("click", (e: MouseEvent) => {
|
|
if(keyDown === "c"){
|
|
e.preventDefault()
|
|
e.stopImmediatePropagation()
|
|
reloader.openEditorAtCaller(e.target)
|
|
} else if(keyDown === "d"){
|
|
e.preventDefault()
|
|
e.stopImmediatePropagation()
|
|
reloader.openEditorAtDef(e.target)
|
|
}
|
|
}, true)
|
|
|
|
window.liveReloader = reloader
|
|
}) as EventListener)
|
|
}
|