From 6145f80c224c220470f49697d5a04368c4ecff1a Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Tue, 31 Mar 2026 16:47:52 -0500 Subject: [PATCH] Add forecast sparkline graph to point detail panel - point_detail response includes forecast array (score per valid_time) - SVG sparkline shows score trend across all forecast hours - Trend indicator: Improving/Declining/Steady based on first vs last score - Add covering index (band_mhz, lat, lon, valid_time) INCLUDE (score) for point_forecast queries --- assets/js/propagation_map_hook.js | 50 +++++++++++++++++++ lib/microwaveprop/propagation.ex | 18 +++++++ lib/microwaveprop_web/live/map_live.ex | 13 ++++- ...0260331214714_add_point_forecast_index.exs | 16 ++++++ 4 files changed, 95 insertions(+), 2 deletions(-) create mode 100644 priv/repo/migrations/20260331214714_add_point_forecast_index.exs diff --git a/assets/js/propagation_map_hook.js b/assets/js/propagation_map_hook.js index 433a43ef..f8118768 100644 --- a/assets/js/propagation_map_hook.js +++ b/assets/js/propagation_map_hook.js @@ -119,6 +119,55 @@ function buildLoadingHTML(detail) { ` } +function buildForecastSvg(forecast) { + if (!forecast || forecast.length < 2) return "" + + const w = 240, h = 48, pad = 2 + const n = forecast.length + const now = new Date() + const stepX = (w - pad * 2) / (n - 1) + + const points = forecast.map((f, i) => { + const x = pad + i * stepX + const y = pad + (h - pad * 2) * (1 - f.score / 100) + return { x, y, score: f.score, time: new Date(f.time) } + }) + + const polyline = points.map(p => `${p.x},${p.y}`).join(" ") + + // Find the "now" index + const nowIdx = points.reduce((best, p, i) => + Math.abs(p.time - now) < Math.abs(points[best].time - now) ? i : best, 0) + const nowPt = points[nowIdx] + + // Trend arrow + const firstScore = points[0].score + const lastScore = points[points.length - 1].score + const diff = lastScore - firstScore + let trend = "" + if (diff > 5) trend = `▲ Improving` + else if (diff < -5) trend = `▼ Declining` + else trend = `→ Steady` + + // Score labels at start and end + const startLabel = `${firstScore}` + const endLabel = `${lastScore}` + + return ` +
+
+ Forecast (${n}h) + ${trend} +
+ + + + ${startLabel} + ${endLabel} + +
` +} + function buildPopupHTML(detail, viewshedLoading) { const tier = scoreTier(detail.score) const range = rangeEstimate(detail.score, detail) @@ -167,6 +216,7 @@ function buildPopupHTML(detail, viewshedLoading) {
Analysis
${explanations} + ${detail.forecast ? buildForecastSvg(detail.forecast) : ""} ${viewshedStatus} ` } diff --git a/lib/microwaveprop/propagation.ex b/lib/microwaveprop/propagation.ex index b3ffc5cd..f259d22c 100644 --- a/lib/microwaveprop/propagation.ex +++ b/lib/microwaveprop/propagation.ex @@ -164,6 +164,24 @@ defmodule Microwaveprop.Propagation do Repo.one(from(gs in GridScore, where: gs.band_mhz == ^band_mhz, select: min(gs.valid_time))) end + @doc "Get scores across all forecast hours for a single grid point (for sparkline)." + def point_forecast(band_mhz, lat, lon) do + step = Grid.step() + snapped_lat = Float.round(Float.round(lat / step) * step, 3) + snapped_lon = Float.round(Float.round(lon / step) * step, 3) + + Repo.all( + from(gs in GridScore, + where: + gs.band_mhz == ^band_mhz and + gs.lat == ^snapped_lat and + gs.lon == ^snapped_lon, + select: %{valid_time: gs.valid_time, score: gs.score}, + order_by: [asc: gs.valid_time] + ) + ) + end + @doc "Get the full score and factors for a specific grid point, snapped to nearest grid." def point_detail(band_mhz, lat, lon) do step = Grid.step() diff --git a/lib/microwaveprop_web/live/map_live.ex b/lib/microwaveprop_web/live/map_live.ex index cb95d76f..f7241644 100644 --- a/lib/microwaveprop_web/live/map_live.ex +++ b/lib/microwaveprop_web/live/map_live.ex @@ -98,8 +98,17 @@ defmodule MicrowavepropWeb.MapLive do end def handle_event("point_detail", %{"lat" => lat, "lon" => lon}, socket) do - detail = Propagation.point_detail(socket.assigns.selected_band, lat, lon) - {:noreply, push_event(socket, "point_detail", detail || %{})} + band = socket.assigns.selected_band + detail = Propagation.point_detail(band, lat, lon) + forecast = Propagation.point_forecast(band, lat, lon) + + forecast_data = + Enum.map(forecast, fn f -> + %{time: DateTime.to_iso8601(f.valid_time), score: f.score} + end) + + payload = if detail, do: Map.put(detail, :forecast, forecast_data), else: %{} + {:noreply, push_event(socket, "point_detail", payload)} end def handle_event("map_bounds", bounds, socket) do diff --git a/priv/repo/migrations/20260331214714_add_point_forecast_index.exs b/priv/repo/migrations/20260331214714_add_point_forecast_index.exs new file mode 100644 index 00000000..bbbca29b --- /dev/null +++ b/priv/repo/migrations/20260331214714_add_point_forecast_index.exs @@ -0,0 +1,16 @@ +defmodule Microwaveprop.Repo.Migrations.AddPointForecastIndex do + use Ecto.Migration + + def change do + # Covers point_forecast query: WHERE band_mhz AND lat AND lon ORDER BY valid_time + # INCLUDE score for index-only scan + create index(:propagation_scores, [:band_mhz, :lat, :lon, :valid_time], + include: [:score], + name: :propagation_scores_point_forecast_idx + ) + + # Covers available_valid_times query: WHERE band_mhz SELECT DISTINCT valid_time + # Already covered by propagation_scores_band_mhz_valid_time_index, but adding + # the prune query: DELETE WHERE valid_time < cutoff (covered by valid_time_index) + end +end