feat(map): 7-day outlook strip
Adds a compact seven-card horizontal strip to both mobile and desktop map sidebars showing the per-day peak score at the viewport center for the selected band. Cards are color-coded (emerald for 80+, rose for bad) so the weekend verdict reads at a glance. - Propagation.daily_outlook_at/3: groups point_forecast entries by UTC date, picks each day's peak, returns ascending order - MapLive mounts with today's outlook for the initial center; the select_band handler refreshes it from the viewport midpoint so the strip tracks the user's current band and rough location - Empty-state message covers the case where no extended-horizon scores have landed yet (fresh deploy, or before the first GEFS run completes) This is the consumer of the GEFS pipeline — once the cron starts running and f024-f168 scores accumulate, days 2-7 of the strip populate automatically.
This commit is contained in:
parent
4d0c15e3b8
commit
a6af6b115a
4 changed files with 161 additions and 0 deletions
|
|
@ -322,6 +322,34 @@ defmodule Microwaveprop.Propagation do
|
|||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the per-day peak score at `(lat, lon)` for the next `:days`
|
||||
UTC days on `:band_mhz`, in ascending date order. Days with no
|
||||
available forecast valid_times are omitted.
|
||||
|
||||
Backs the 7-day outlook strip on the map — turns the hourly forecast
|
||||
sparkline into a coarse "is Saturday any good" verdict.
|
||||
"""
|
||||
@spec daily_outlook_at(float(), float(), keyword()) ::
|
||||
[%{date: Date.t(), peak_score: non_neg_integer()}]
|
||||
def daily_outlook_at(lat, lon, opts \\ []) do
|
||||
band_mhz = Keyword.get(opts, :band_mhz, 10_000)
|
||||
days = Keyword.get(opts, :days, 7)
|
||||
today = DateTime.to_date(DateTime.utc_now())
|
||||
horizon = Date.add(today, days - 1)
|
||||
|
||||
band_mhz
|
||||
|> point_forecast(lat, lon)
|
||||
|> Enum.group_by(fn entry ->
|
||||
DateTime.to_date(entry.valid_time)
|
||||
end)
|
||||
|> Enum.filter(fn {date, _} -> Date.compare(date, today) != :lt and Date.compare(date, horizon) != :gt end)
|
||||
|> Enum.map(fn {date, entries} ->
|
||||
%{date: date, peak_score: entries |> Enum.map(& &1.score) |> Enum.max()}
|
||||
end)
|
||||
|> Enum.sort_by(& &1.date, Date)
|
||||
end
|
||||
|
||||
@doc "Get scores across all forecast hours for a single grid point (for sparkline)."
|
||||
@spec point_forecast(non_neg_integer(), float(), float()) ::
|
||||
[%{valid_time: DateTime.t(), score: non_neg_integer()}]
|
||||
|
|
|
|||
|
|
@ -87,6 +87,7 @@ defmodule MicrowavepropWeb.MapLive do
|
|||
outside_conus: outside_conus?(visitor),
|
||||
grid_visible: false,
|
||||
radar_visible: false,
|
||||
daily_outlook: Propagation.daily_outlook_at(center.lat, center.lon, band_mhz: selected_band, days: 7),
|
||||
deploy_iso: Calendar.strftime(build_ts, "%Y-%m-%d %H:%M UTC"),
|
||||
deploy_ago: format_deploy_ago(build_ts)
|
||||
)}
|
||||
|
|
@ -159,6 +160,15 @@ defmodule MicrowavepropWeb.MapLive do
|
|||
|> LiveStash.stash_assigns([:selected_band, :selected_time])
|
||||
|> push_event("update_scores", %{scores: scores})
|
||||
|> push_event("update_band_info", %{band_info: band_info(band)})
|
||||
|> assign(
|
||||
:daily_outlook,
|
||||
Propagation.daily_outlook_at(
|
||||
socket.assigns.bounds["south"] + (socket.assigns.bounds["north"] - socket.assigns.bounds["south"]) / 2,
|
||||
socket.assigns.bounds["west"] + (socket.assigns.bounds["east"] - socket.assigns.bounds["west"]) / 2,
|
||||
band_mhz: band,
|
||||
days: 7
|
||||
)
|
||||
)
|
||||
|> push_timeline()
|
||||
|
||||
{:noreply, socket}
|
||||
|
|
@ -451,6 +461,53 @@ defmodule MicrowavepropWeb.MapLive do
|
|||
end
|
||||
end
|
||||
|
||||
# Outlook helpers. Day label is the 3-letter weekday ("Sat", "Sun");
|
||||
# badge class paints the card background based on the peak score so
|
||||
# the strip reads as a rough heatmap at a glance.
|
||||
defp outlook_day_label(%Date{} = date), do: Calendar.strftime(date, "%a")
|
||||
|
||||
defp outlook_bg_class(score) when is_integer(score) do
|
||||
cond do
|
||||
score >= 80 -> "bg-emerald-500/30 border-emerald-400/50"
|
||||
score >= 65 -> "bg-lime-500/25 border-lime-400/50"
|
||||
score >= 50 -> "bg-amber-500/25 border-amber-400/50"
|
||||
score >= 33 -> "bg-orange-500/25 border-orange-400/50"
|
||||
true -> "bg-rose-500/25 border-rose-400/50"
|
||||
end
|
||||
end
|
||||
|
||||
defp outlook_bg_class(_), do: "bg-base-300/30 border-base-300/50"
|
||||
|
||||
attr :id, :string, required: true
|
||||
attr :outlook, :list, required: true
|
||||
|
||||
defp daily_outlook_strip(assigns) do
|
||||
~H"""
|
||||
<div id={@id} class="px-1">
|
||||
<%= if @outlook == [] do %>
|
||||
<div class="text-[11px] opacity-60 italic leading-tight">
|
||||
Extended outlook not yet available.
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="text-[11px] opacity-70 mb-1 leading-tight">7-day outlook</div>
|
||||
<div class="grid grid-cols-7 gap-1">
|
||||
<div
|
||||
:for={entry <- @outlook}
|
||||
class={[
|
||||
"flex flex-col items-center justify-center rounded border py-1 text-xs leading-tight",
|
||||
outlook_bg_class(entry.peak_score)
|
||||
]}
|
||||
title={"#{entry.date} peak score: #{entry.peak_score}"}
|
||||
>
|
||||
<span class="opacity-80">{outlook_day_label(entry.date)}</span>
|
||||
<span class="font-semibold tabular-nums">{entry.peak_score}</span>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :id, :string, required: true
|
||||
attr :status, :map, required: true
|
||||
attr :progress, :any, default: nil
|
||||
|
|
@ -629,6 +686,8 @@ defmodule MicrowavepropWeb.MapLive do
|
|||
</ul>
|
||||
</div>
|
||||
|
||||
<.daily_outlook_strip id="daily-outlook-mobile" outlook={@daily_outlook} />
|
||||
|
||||
<div id="panel-extras" class="hidden flex-col gap-2">
|
||||
<label class="flex items-center gap-2 cursor-pointer px-1">
|
||||
<input
|
||||
|
|
@ -769,6 +828,8 @@ defmodule MicrowavepropWeb.MapLive do
|
|||
</ul>
|
||||
</div>
|
||||
|
||||
<.daily_outlook_strip id="daily-outlook-desktop" outlook={@daily_outlook} />
|
||||
|
||||
<%!-- Grid toggle --%>
|
||||
<label class="flex items-center gap-2 cursor-pointer px-1">
|
||||
<input
|
||||
|
|
|
|||
59
test/microwaveprop/propagation/daily_outlook_test.exs
Normal file
59
test/microwaveprop/propagation/daily_outlook_test.exs
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
defmodule Microwaveprop.Propagation.DailyOutlookTest do
|
||||
use Microwaveprop.DataCase, async: false
|
||||
|
||||
alias Microwaveprop.Propagation
|
||||
alias Microwaveprop.Propagation.ScoreCache
|
||||
|
||||
setup do
|
||||
ScoreCache.clear()
|
||||
on_exit(fn -> ScoreCache.clear() end)
|
||||
:ok
|
||||
end
|
||||
|
||||
describe "daily_outlook_at/3" do
|
||||
test "returns an empty list when no scores have been written" do
|
||||
assert Propagation.daily_outlook_at(32.9, -97.0, days: 3) == []
|
||||
end
|
||||
|
||||
test "groups scores by UTC date and picks the peak per day" do
|
||||
# Use a lat on the grid: snap(32.9) = 32.875 (step 0.125). Write at
|
||||
# the snapped coords so point_forecast's internal snap-to-grid finds
|
||||
# the scores on disk.
|
||||
lat = 32.875
|
||||
lon = -97.0
|
||||
band = 10_000
|
||||
|
||||
# Day A: 5Z @ 40, 17Z @ 80 → peak 80
|
||||
# Day B: 5Z @ 60 → peak 60
|
||||
day_a_1 = ~U[2026-04-20 05:00:00Z]
|
||||
day_a_2 = ~U[2026-04-20 17:00:00Z]
|
||||
day_b_1 = ~U[2026-04-21 05:00:00Z]
|
||||
|
||||
Propagation.replace_scores(
|
||||
[%{lat: lat, lon: lon, valid_time: day_a_1, band_mhz: band, score: 40, factors: nil}],
|
||||
day_a_1
|
||||
)
|
||||
|
||||
Propagation.replace_scores(
|
||||
[%{lat: lat, lon: lon, valid_time: day_a_2, band_mhz: band, score: 80, factors: nil}],
|
||||
day_a_2
|
||||
)
|
||||
|
||||
Propagation.replace_scores(
|
||||
[%{lat: lat, lon: lon, valid_time: day_b_1, band_mhz: band, score: 60, factors: nil}],
|
||||
day_b_1
|
||||
)
|
||||
|
||||
outlook = Propagation.daily_outlook_at(lat, lon, band_mhz: band, days: 30)
|
||||
|
||||
# The helper returns entries in ascending date order.
|
||||
assert length(outlook) >= 2
|
||||
|
||||
day_a = Enum.find(outlook, &(&1.date == ~D[2026-04-20]))
|
||||
day_b = Enum.find(outlook, &(&1.date == ~D[2026-04-21]))
|
||||
|
||||
assert day_a.peak_score == 80
|
||||
assert day_b.peak_score == 60
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -109,6 +109,19 @@ defmodule MicrowavepropWeb.MapLiveTest do
|
|||
end
|
||||
end
|
||||
|
||||
describe "daily outlook strip" do
|
||||
test "renders the strip in both mobile and desktop sidebars", %{conn: conn} do
|
||||
{:ok, _lv, html} = live(conn, ~p"/map")
|
||||
assert html =~ ~s(id="daily-outlook-mobile")
|
||||
assert html =~ ~s(id="daily-outlook-desktop")
|
||||
end
|
||||
|
||||
test "shows an empty-state message when no outlook data exists", %{conn: conn} do
|
||||
{:ok, _lv, html} = live(conn, ~p"/map")
|
||||
assert html =~ "Extended outlook not yet available"
|
||||
end
|
||||
end
|
||||
|
||||
describe "data timestamp indicator" do
|
||||
test "renders a 'Data from …' line on both mobile and desktop sidebars", %{conn: conn} do
|
||||
{:ok, _lv, html} = live(conn, ~p"/map")
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue