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:
parent
11af9b966f
commit
6145f80c22
4 changed files with 95 additions and 2 deletions
|
|
@ -119,6 +119,55 @@ function buildLoadingHTML(detail) {
|
||||||
</div>`
|
</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;">▲ Improving</span>`
|
||||||
|
else if (diff < -5) trend = `<span style="color:#ff4f4f;">▼ Declining</span>`
|
||||||
|
else trend = `<span style="color:#ffe566;">→ 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) {
|
function buildPopupHTML(detail, viewshedLoading) {
|
||||||
const tier = scoreTier(detail.score)
|
const tier = scoreTier(detail.score)
|
||||||
const range = rangeEstimate(detail.score, detail)
|
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>
|
<div style="font-size:10px;font-weight:700;opacity:0.5;margin-bottom:4px;text-transform:uppercase;letter-spacing:0.5px;">Analysis</div>
|
||||||
${explanations}
|
${explanations}
|
||||||
</div>
|
</div>
|
||||||
|
${detail.forecast ? buildForecastSvg(detail.forecast) : ""}
|
||||||
${viewshedStatus}
|
${viewshedStatus}
|
||||||
</div>`
|
</div>`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)))
|
Repo.one(from(gs in GridScore, where: gs.band_mhz == ^band_mhz, select: min(gs.valid_time)))
|
||||||
end
|
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."
|
@doc "Get the full score and factors for a specific grid point, snapped to nearest grid."
|
||||||
def point_detail(band_mhz, lat, lon) do
|
def point_detail(band_mhz, lat, lon) do
|
||||||
step = Grid.step()
|
step = Grid.step()
|
||||||
|
|
|
||||||
|
|
@ -98,8 +98,17 @@ defmodule MicrowavepropWeb.MapLive do
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("point_detail", %{"lat" => lat, "lon" => lon}, socket) do
|
def handle_event("point_detail", %{"lat" => lat, "lon" => lon}, socket) do
|
||||||
detail = Propagation.point_detail(socket.assigns.selected_band, lat, lon)
|
band = socket.assigns.selected_band
|
||||||
{:noreply, push_event(socket, "point_detail", detail || %{})}
|
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
|
end
|
||||||
|
|
||||||
def handle_event("map_bounds", bounds, socket) do
|
def handle_event("map_bounds", bounds, socket) do
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
Loading…
Add table
Reference in a new issue