// 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 {LocationMap} from "./location_map_hook" import {RoverSlider} from "./rover_slider_hook" import {HorizontalWheelScroll} from "./horizontal_wheel_scroll_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 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 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, LocationMap, RoverSlider, HorizontalWheelScroll, BeaconMap, BeaconsListMap, CommaNumber, ImportConfetti, EmeGlobe, Skewt}, }) // live_table rows are dead on their own — wire up whole-row clicks to // trigger the first 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 | 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) => { 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) }