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
|
||||||
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)."
|
@doc "Get scores across all forecast hours for a single grid point (for sparkline)."
|
||||||
@spec point_forecast(non_neg_integer(), float(), float()) ::
|
@spec point_forecast(non_neg_integer(), float(), float()) ::
|
||||||
[%{valid_time: DateTime.t(), score: non_neg_integer()}]
|
[%{valid_time: DateTime.t(), score: non_neg_integer()}]
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,7 @@ defmodule MicrowavepropWeb.MapLive do
|
||||||
outside_conus: outside_conus?(visitor),
|
outside_conus: outside_conus?(visitor),
|
||||||
grid_visible: false,
|
grid_visible: false,
|
||||||
radar_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_iso: Calendar.strftime(build_ts, "%Y-%m-%d %H:%M UTC"),
|
||||||
deploy_ago: format_deploy_ago(build_ts)
|
deploy_ago: format_deploy_ago(build_ts)
|
||||||
)}
|
)}
|
||||||
|
|
@ -159,6 +160,15 @@ defmodule MicrowavepropWeb.MapLive do
|
||||||
|> LiveStash.stash_assigns([:selected_band, :selected_time])
|
|> LiveStash.stash_assigns([:selected_band, :selected_time])
|
||||||
|> push_event("update_scores", %{scores: scores})
|
|> push_event("update_scores", %{scores: scores})
|
||||||
|> push_event("update_band_info", %{band_info: band_info(band)})
|
|> 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()
|
|> push_timeline()
|
||||||
|
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
|
|
@ -451,6 +461,53 @@ defmodule MicrowavepropWeb.MapLive do
|
||||||
end
|
end
|
||||||
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 :id, :string, required: true
|
||||||
attr :status, :map, required: true
|
attr :status, :map, required: true
|
||||||
attr :progress, :any, default: nil
|
attr :progress, :any, default: nil
|
||||||
|
|
@ -629,6 +686,8 @@ defmodule MicrowavepropWeb.MapLive do
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<.daily_outlook_strip id="daily-outlook-mobile" outlook={@daily_outlook} />
|
||||||
|
|
||||||
<div id="panel-extras" class="hidden flex-col gap-2">
|
<div id="panel-extras" class="hidden flex-col gap-2">
|
||||||
<label class="flex items-center gap-2 cursor-pointer px-1">
|
<label class="flex items-center gap-2 cursor-pointer px-1">
|
||||||
<input
|
<input
|
||||||
|
|
@ -769,6 +828,8 @@ defmodule MicrowavepropWeb.MapLive do
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<.daily_outlook_strip id="daily-outlook-desktop" outlook={@daily_outlook} />
|
||||||
|
|
||||||
<%!-- Grid toggle --%>
|
<%!-- Grid toggle --%>
|
||||||
<label class="flex items-center gap-2 cursor-pointer px-1">
|
<label class="flex items-center gap-2 cursor-pointer px-1">
|
||||||
<input
|
<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
|
||||||
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
|
describe "data timestamp indicator" do
|
||||||
test "renders a 'Data from …' line on both mobile and desktop sidebars", %{conn: conn} do
|
test "renders a 'Data from …' line on both mobile and desktop sidebars", %{conn: conn} do
|
||||||
{:ok, _lv, html} = live(conn, ~p"/map")
|
{:ok, _lv, html} = live(conn, ~p"/map")
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue