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>`
|
||||
}
|
||||
|
||||
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) {
|
||||
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>`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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