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
This commit is contained in:
Graham McIntire 2026-03-31 16:47:52 -05:00
parent 11af9b966f
commit 6145f80c22
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
4 changed files with 95 additions and 2 deletions

View file

@ -119,6 +119,55 @@ function buildLoadingHTML(detail) {
</div>`
}
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 = `<span style="color:#00ffa3;">&#9650; Improving</span>`
else if (diff < -5) trend = `<span style="color:#ff4f4f;">&#9660; Declining</span>`
else trend = `<span style="color:#ffe566;">&#8594; Steady</span>`
// Score labels at start and end
const startLabel = `<text x="${pad}" y="${h - 2}" font-size="9" fill="rgba(255,255,255,0.5)">${firstScore}</text>`
const endLabel = `<text x="${w - pad - 12}" y="${h - 2}" font-size="9" fill="rgba(255,255,255,0.5)">${lastScore}</text>`
return `
<div style="padding:6px 10px;border-top:1px solid rgba(255,255,255,0.15);">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px;">
<span style="font-size:10px;font-weight:700;opacity:0.5;text-transform:uppercase;letter-spacing:0.5px;">Forecast (${n}h)</span>
<span style="font-size:11px;">${trend}</span>
</div>
<svg width="${w}" height="${h}" style="display:block;">
<polyline points="${polyline}" fill="none" stroke="#00ffa3" stroke-width="2" stroke-linejoin="round"/>
<circle cx="${nowPt.x}" cy="${nowPt.y}" r="3" fill="#fff" stroke="#00ffa3" stroke-width="1.5"/>
${startLabel}
${endLabel}
</svg>
</div>`
}
function buildPopupHTML(detail, viewshedLoading) {
const tier = scoreTier(detail.score)
const range = rangeEstimate(detail.score, detail)
@ -167,6 +216,7 @@ function buildPopupHTML(detail, viewshedLoading) {
<div style="font-size:10px;font-weight:700;opacity:0.5;margin-bottom:4px;text-transform:uppercase;letter-spacing:0.5px;">Analysis</div>
${explanations}
</div>
${detail.forecast ? buildForecastSvg(detail.forecast) : ""}
${viewshedStatus}
</div>`
}

View file

@ -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()

View file

@ -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

View file

@ -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