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}
+
+
+
`
+}
+
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