From 2fc434df5690ffe67e577b563002f47384181841 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sat, 25 Apr 2026 15:33:04 -0500 Subject: [PATCH] feat(path): hover-on-dot factor breakdown + tag fallback factors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- assets/js/app.ts | 3 +- assets/js/path_forecast_hook.ts | 207 ++++++++++++++++++++++++ assets/js/propagation_map_hook.ts | 20 ++- lib/microwaveprop/propagation.ex | 53 ++++-- lib/microwaveprop_web/live/path_live.ex | 56 ++++++- 5 files changed, 314 insertions(+), 25 deletions(-) create mode 100644 assets/js/path_forecast_hook.ts diff --git a/assets/js/app.ts b/assets/js/app.ts index 3b03abee..9e88fa67 100644 --- a/assets/js/app.ts +++ b/assets/js/app.ts @@ -13,6 +13,7 @@ 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" @@ -310,7 +311,7 @@ const csrfToken = document.querySelector("meta[name='csrf-token']")!.getAttribut const liveSocket = new LiveSocket("/live", Socket, { longPollFallbackMs: 2500, params: initLiveStash({_csrf_token: csrfToken}), - hooks: {...colocatedHooks, PropagationMap, UtcClock, ContactMap, ContactsMap, ElevationProfile, WeatherMap, LocateMe, CopyLink, RoverMap, BeaconMap, BeaconsListMap, CommaNumber, ImportConfetti, EmeGlobe, Skewt}, + 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 diff --git a/assets/js/path_forecast_hook.ts b/assets/js/path_forecast_hook.ts new file mode 100644 index 00000000..2272225d --- /dev/null +++ b/assets/js/path_forecast_hook.ts @@ -0,0 +1,207 @@ +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` + } +} diff --git a/assets/js/propagation_map_hook.ts b/assets/js/propagation_map_hook.ts index b0f8a3a4..bf3846af 100644 --- a/assets/js/propagation_map_hook.ts +++ b/assets/js/propagation_map_hook.ts @@ -22,13 +22,13 @@ function kmCeilToMi(highKm: number): string { // --- Interfaces --- -interface FactorMeta { +export interface FactorMeta { label: string weight: number unit: string } -interface ScoreTier { +export interface ScoreTier { label: string color: string } @@ -245,7 +245,7 @@ class ScoreGrid { // Weights must match BandConfig.weights() in band_config.ex // Recalibrated 2026-04-11 via gradient descent (loss 0.42 → 0.12) -const FACTOR_META: Record = { +export const FACTOR_META: Record = { rain: { label: "Rain", weight: 0.1362, unit: "" }, humidity: { label: "Humidity", weight: 0.1243, unit: "g/m\u00b3" }, pwat: { label: "PWAT", weight: 0.1128, unit: "mm" }, @@ -258,14 +258,14 @@ const FACTOR_META: Record = { time_of_day: { label: "Time of Day", weight: 0.0496, unit: "" } } -const FACTOR_ORDER: string[] = [ +export const FACTOR_ORDER: string[] = [ "rain", "humidity", "pwat", "season", "refractivity", "pressure", "td_depression", "sky", "wind", "time_of_day" ] // --- Helper functions --- -function scoreTier(score: number): ScoreTier { +export function scoreTier(score: number): ScoreTier { if (score >= 80) return { label: "EXCELLENT", color: "#00ffa3" } if (score >= 65) return { label: "GOOD", color: "#7dffd4" } if (score >= 50) return { label: "MARGINAL", color: "#ffe566" } @@ -273,15 +273,19 @@ function scoreTier(score: number): ScoreTier { return { label: "NEGLIGIBLE", color: "#ff4f4f" } } -function factorBar(score: number): string { +export function factorBar(score: number): string { const filled = Math.round(score / 5) const empty = 20 - filled const tier = scoreTier(score) return `${"\u2588".repeat(filled)}${"\u2591".repeat(empty)}` } -function factorExplanation(key: string, score: number, detail: PointDetail): string { - const beneficial = detail.humidity_effect === "beneficial" +export function factorExplanation( + key: string, + score: number, + ctx: { humidity_effect?: string } +): string { + const beneficial = ctx.humidity_effect === "beneficial" switch (key) { case "humidity": diff --git a/lib/microwaveprop/propagation.ex b/lib/microwaveprop/propagation.ex index 6979208b..7d628cab 100644 --- a/lib/microwaveprop/propagation.ex +++ b/lib/microwaveprop/propagation.ex @@ -474,13 +474,25 @@ defmodule Microwaveprop.Propagation do {Float.round(Float.round(lat / step) * step, 3), Float.round(Float.round(lon / step) * step, 3)} end - @doc "Get the full score and factors for a specific grid point, snapped to nearest grid." + @doc """ + Get the full score and factors for a specific grid point, snapped to + the nearest grid cell. + + `:profile_source` describes where the factor breakdown came from: + + * `:exact` — rescored from this `valid_time`'s own profile file. + * `{:fallback, fallback_valid_time}` — the requested hour's profile + was missing, so we rescored from the most recent analysis profile + within the lookback window. Treat as approximate. + * `:unavailable` — no profile available; `factors` is `%{}`. + """ @spec point_detail(non_neg_integer(), float(), float(), DateTime.t() | nil) :: %{ lat: float(), lon: float(), score: non_neg_integer(), factors: map(), + profile_source: :exact | {:fallback, DateTime.t()} | :unavailable, valid_time: DateTime.t() } | nil @@ -504,11 +516,14 @@ defmodule Microwaveprop.Propagation do nil score -> + {factors, source} = factors_for(band_mhz, time, snapped_lat, snapped_lon) + %{ lat: snapped_lat, lon: snapped_lon, score: score, - factors: factors_for(band_mhz, time, snapped_lat, snapped_lon), + factors: factors, + profile_source: source, valid_time: time } end @@ -516,30 +531,40 @@ defmodule Microwaveprop.Propagation do end # Rebuild the factor breakdown for a clicked grid cell by rescoring - # the persisted HRRR profile. Only analysis hours (f00) persist - # profiles, so forecast hours fall back to the most recent analysis - # profile within `@fallback_profile_lookback_hours` — the atmospheric - # breakdown still explains what's driving the score even if sampled - # an hour or two earlier. Outside the lookback we prefer to return - # an empty map so the UI shows "breakdown unavailable" rather than - # silently attributing scores to a day-old synoptic pattern. + # the persisted HRRR profile. + # + # The Rust pipeline (`prop_grid_rs`) writes a per-cell profile file + # for every chain step (f00..f18 since Phase 2 cutover), so the + # `:exact` branch covers a healthy production state. The fallback to + # a recent analysis profile remains as a safety net for missed + # cycles or partial chain runs — when it kicks in we tag the result + # `{:fallback, profile_valid_time}` so the UI can label the + # breakdown as approximated rather than silently misrepresent it. @fallback_profile_lookback_hours 24 + @spec factors_for(non_neg_integer(), DateTime.t(), float(), float()) :: + {map(), :exact | {:fallback, DateTime.t()} | :unavailable} defp factors_for(band_mhz, valid_time, lat, lon) do case ProfilesFile.read_point(valid_time, lat, lon) do - nil -> factors_from_fallback_profile(band_mhz, valid_time, lat, lon) - profile -> factors_from_profile(band_mhz, valid_time, profile, lat, lon) + nil -> + factors_from_fallback_profile(band_mhz, valid_time, lat, lon) + + profile -> + {factors_from_profile(band_mhz, valid_time, profile, lat, lon), :exact} end end defp factors_from_fallback_profile(band_mhz, valid_time, lat, lon) do case latest_profile_time_within_lookback(valid_time) do nil -> - %{} + {%{}, :unavailable} fallback_time -> case ProfilesFile.read_point(fallback_time, lat, lon) do - nil -> %{} - profile -> factors_from_profile(band_mhz, fallback_time, profile, lat, lon) + nil -> + {%{}, :unavailable} + + profile -> + {factors_from_profile(band_mhz, fallback_time, profile, lat, lon), {:fallback, fallback_time}} end end end diff --git a/lib/microwaveprop_web/live/path_live.ex b/lib/microwaveprop_web/live/path_live.ex index acde7422..9d442a0b 100644 --- a/lib/microwaveprop_web/live/path_live.ex +++ b/lib/microwaveprop_web/live/path_live.ex @@ -178,6 +178,39 @@ defmodule MicrowavepropWeb.PathLive do {:noreply, push_patch(socket, to: ~p"/path?#{url_params}", replace: true)} end + # Hover on a forecast-chart dot — fetch and push back the per-factor + # breakdown for that hour at the path's midpoint, mirroring the + # weighted-criteria panel the main /map popup builds when the user + # clicks a grid cell. Cached client-side so the server only does the + # ProfilesFile read once per hour per session. + def handle_event("path_forecast_detail", %{"iso" => iso}, socket) do + with %{result: %{band_mhz: band_mhz, source: src, destination: dst, band_config: band_config}} <- + socket.assigns, + {:ok, valid_time, _} <- DateTime.from_iso8601(iso) do + midlat = (src.lat + dst.lat) / 2 + midlon = (src.lon + dst.lon) / 2 + + payload = + case Propagation.point_detail(band_mhz, midlat, midlon, valid_time) do + %{score: score, factors: factors} when factors != %{} -> + %{ + iso: iso, + score: score, + factors: factors, + band_label: band_config.label, + humidity_effect: humidity_effect_string(band_config) + } + + _ -> + %{iso: iso, unavailable: true} + end + + {:noreply, push_event(socket, "path_forecast_detail", payload)} + else + _ -> {:noreply, socket} + end + end + def handle_event("gps_location", %{"lat" => lat, "lon" => lon}, socket) do coords = "#{Float.round(lat / 1, 6)},#{Float.round(lon / 1, 6)}" @@ -405,6 +438,10 @@ defmodule MicrowavepropWeb.PathLive do Regex.match?(~r/^[A-Ra-r]{2}[0-9]{2}/i, input) end + defp humidity_effect_string(%{humidity_effect: :beneficial}), do: "beneficial" + defp humidity_effect_string(%{humidity_effect: :harmful}), do: "harmful" + defp humidity_effect_string(_), do: "neutral" + defp label_hrrr_point({label, lat, lon}, now, profile_valid_time) do case profile_valid_time && ProfilesFile.read_point(profile_valid_time, lat, lon) do %{} = cell -> @@ -1263,7 +1300,11 @@ defmodule MicrowavepropWeb.PathLive do |> assign(:x_labels, forecast_x_labels(points)) ~H""" -
+
{length(@forecast)}-Hour Propagation Forecast · {@band_config.label} @@ -1304,7 +1345,14 @@ defmodule MicrowavepropWeb.PathLive do stroke-linecap="round" /> <%= for pt <- @points do %> - + <% end %>