feat(map): auto-advance cursor, full forecast timeline, panel spacing

Three UI fixes:

1. MapLive now ticks every 60s and, when the selected_time is still
   the "Now" slot, advances to the newer latest-past-or-now hour as
   the clock crosses into it. The URL is patched in place so shared
   links stay accurate without polluting history. Manually picking a
   specific past/future hour turns tracking off.

2. point_forecast/3 now reads its valid_times list straight from the
   on-disk .ntms files (same source the main-map timeline uses), then
   consults the cache per-hour for a fast score lookup. The old cache-
   or-store branch could leave the click-to-detail sparkline at 13
   hours while the bottom timeline showed 14+; both now line up.

3. Point-detail panel moves from bottom-14 → bottom-24 on desktop so
   its lower edge clears the forecast-timeline bar (+ max-height tuned
   to match), fixing the overlap visible in screenshots.
This commit is contained in:
Graham McIntire 2026-04-19 12:10:35 -05:00
parent d3a1e93587
commit ffb14cb64f
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
4 changed files with 233 additions and 37 deletions

View file

@ -355,40 +355,31 @@ defmodule Microwaveprop.Propagation do
{snapped_lat, snapped_lon} = snap_to_grid(lat, lon)
now = DateTime.utc_now()
case ScoreCache.valid_times(band_mhz) do
[] -> point_forecast_from_store(band_mhz, snapped_lat, snapped_lon, now)
cached -> point_forecast_from_cache(band_mhz, snapped_lat, snapped_lon, now, cached)
end
# Use the on-disk .ntms file list as the authoritative timeline so
# the chart never falls behind the main-map timeline (which also
# reads the disk). The cache is still consulted per-hour for a
# fast score lookup; a miss falls through to the file.
band_mhz
|> ScoresFile.list_valid_times()
|> forecast_window(now)
|> Enum.map(&point_forecast_entry(band_mhz, &1, snapped_lat, snapped_lon))
|> Enum.reject(&is_nil/1)
end)
end
defp point_forecast_from_store(band_mhz, lat, lon, now) do
band_mhz
|> ScoresFile.list_valid_times()
|> forecast_window(now)
|> Enum.map(&point_forecast_entry(&1, band_mhz, lat, lon))
|> Enum.reject(&is_nil/1)
end
defp point_forecast_entry(band_mhz, valid_time, lat, lon) do
case ScoreCache.fetch_point(band_mhz, valid_time, lat, lon) do
{:ok, score} ->
%{valid_time: valid_time, score: score}
defp point_forecast_entry(valid_time, band_mhz, lat, lon) do
case ScoresFile.read_point(band_mhz, valid_time, lat, lon) do
nil -> nil
score -> %{valid_time: valid_time, score: score}
:miss ->
case ScoresFile.read_point(band_mhz, valid_time, lat, lon) do
nil -> nil
score -> %{valid_time: valid_time, score: score}
end
end
end
defp point_forecast_from_cache(band_mhz, lat, lon, now, cached_times) do
cached_times
|> forecast_window(now)
|> Enum.map(fn t ->
case ScoreCache.fetch_point(band_mhz, t, lat, lon) do
{:ok, score} -> %{valid_time: t, score: score}
:miss -> nil
end
end)
|> Enum.reject(&is_nil/1)
end
# Select the set of valid_times the forecast chart should render.
# Mirrors `available_valid_times`: keep everything from one hour
# before now onward so the most recent analysis hour (typically

View file

@ -28,12 +28,18 @@ defmodule MicrowavepropWeb.MapLive do
# visit for an in-progress hourly run, long enough to be cheap.
@pipeline_status_refresh_ms 15_000
# How often to check whether the wall clock has ticked into a new
# forecast hour. 60s is frequent enough to advance within a minute
# of top-of-hour and cheap enough to run for every connected map.
@advance_now_cursor_ms 60_000
@impl true
def mount(params, session, socket) do
if connected?(socket) do
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "propagation:updated")
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "propagation:pipeline")
Process.send_after(self(), :refresh_pipeline_status, @pipeline_status_refresh_ms)
Process.send_after(self(), :advance_now_cursor, @advance_now_cursor_ms)
end
socket =
@ -81,6 +87,7 @@ defmodule MicrowavepropWeb.MapLive do
initial_scores_json: Jason.encode!(initial_scores),
valid_times: valid_times,
selected_time: selected_time,
tracking_now?: tracking_now?(selected_time, valid_times, DateTime.utc_now()),
bounds: bounds,
initial_center: center,
initial_zoom: zoom,
@ -200,6 +207,7 @@ defmodule MicrowavepropWeb.MapLive do
socket =
socket
|> assign(selected_band: band, valid_times: valid_times, selected_time: selected_time)
|> assign(:tracking_now?, tracking_now?(selected_time, valid_times, DateTime.utc_now()))
|> LiveStash.stash_assigns([:selected_band, :selected_time])
|> push_event("update_scores", %{scores: scores})
|> push_event("update_band_info", %{band_info: band_info(band)})
@ -217,6 +225,7 @@ defmodule MicrowavepropWeb.MapLive do
socket =
socket
|> assign(:selected_time, time)
|> assign(:tracking_now?, tracking_now?(time, socket.assigns.valid_times, DateTime.utc_now()))
|> push_event("update_scores", %{scores: scores})
|> patch_map_url()
@ -233,7 +242,13 @@ defmodule MicrowavepropWeb.MapLive do
def handle_event("set_selected_time", %{"time" => time_str}, socket) do
case DateTime.from_iso8601(time_str) do
{:ok, time, _} ->
{:noreply, socket |> assign(:selected_time, time) |> patch_map_url()}
socket =
socket
|> assign(:selected_time, time)
|> assign(:tracking_now?, tracking_now?(time, socket.assigns.valid_times, DateTime.utc_now()))
|> patch_map_url()
{:noreply, socket}
_ ->
{:noreply, socket}
@ -378,6 +393,32 @@ defmodule MicrowavepropWeb.MapLive do
{:noreply, socket}
end
def handle_info(:advance_now_cursor, socket) do
Process.send_after(self(), :advance_now_cursor, @advance_now_cursor_ms)
if socket.assigns.tracking_now? do
new_time = closest_to_now(socket.assigns.valid_times)
if new_time && socket.assigns.selected_time &&
DateTime.after?(new_time, socket.assigns.selected_time) do
scores = Propagation.scores_at(socket.assigns.selected_band, new_time, socket.assigns.bounds)
socket =
socket
|> assign(:selected_time, new_time)
|> push_event("update_scores", %{scores: scores})
|> push_timeline()
|> patch_map_url(replace: true)
{:noreply, socket}
else
{:noreply, socket}
end
else
{:noreply, socket}
end
end
def handle_info(:refresh_pipeline_status, socket) do
Process.send_after(self(), :refresh_pipeline_status, @pipeline_status_refresh_ms)
@ -563,6 +604,22 @@ defmodule MicrowavepropWeb.MapLive do
end
end
@doc false
# True when the map's current selected_time is still the "Now" slot,
# i.e. the user hasn't scrubbed to a specific past/future hour. Used
# by :advance_now_cursor to decide whether to auto-follow the wall
# clock when it ticks into the next hour.
@spec tracking_now?(DateTime.t() | nil, [DateTime.t()], DateTime.t()) :: boolean()
def tracking_now?(nil, _times, _now), do: false
def tracking_now?(_selected, [], _now), do: false
def tracking_now?(%DateTime{} = selected, times, %DateTime{} = now) do
case cursor_for_now(times, now) do
nil -> false
cursor -> DateTime.compare(selected, cursor) == :eq
end
end
defp score_range_km(score, config) do
cond do
score >= 80 -> config.exceptional_range_km
@ -830,7 +887,7 @@ defmodule MicrowavepropWeb.MapLive do
class={[
"bg-neutral text-neutral-content shadow-lg border border-base-300 overflow-hidden overflow-y-auto z-[1001]",
"fixed bottom-0 left-0 right-0 max-h-[50vh] rounded-t-2xl",
"md:absolute md:top-auto md:right-auto md:bottom-14 md:left-3 md:max-h-[70vh] md:w-80 md:rounded-box"
"md:absolute md:top-auto md:right-auto md:bottom-24 md:left-3 md:max-h-[calc(100vh-8rem)] md:w-80 md:rounded-box"
]}
style="display:none;"
>

View file

@ -341,12 +341,24 @@ defmodule Microwaveprop.PropagationTest do
end
describe "point_forecast/3 with cache" do
# Write a score to both disk and cache. Production always writes
# the store first, then warms the cache — nothing produces a
# cache-only record, so the tests mirror that invariant.
defp seed_point(band_mhz, valid_time, lat, lon, score) do
Propagation.replace_scores(
[%{lat: lat, lon: lon, valid_time: valid_time, band_mhz: band_mhz, score: score, factors: nil}],
valid_time
)
ScoreCache.put(band_mhz, valid_time, [%{lat: lat, lon: lon, score: score}])
end
test "returns forecast timeline from cached scores when warm" do
t1 = DateTime.truncate(DateTime.add(DateTime.utc_now(), 3600, :second), :second)
t2 = DateTime.add(t1, 3600, :second)
ScoreCache.put(10_000, t1, [%{lat: 32.875, lon: -97.0, score: 70}])
ScoreCache.put(10_000, t2, [%{lat: 32.875, lon: -97.0, score: 75}])
seed_point(10_000, t1, 32.875, -97.0, 70)
seed_point(10_000, t2, 32.875, -97.0, 75)
result = Propagation.point_forecast(10_000, 32.875, -97.0)
@ -363,9 +375,9 @@ defmodule Microwaveprop.PropagationTest do
recent = DateTime.utc_now() |> DateTime.add(-1800, :second) |> DateTime.truncate(:second)
future = DateTime.utc_now() |> DateTime.add(3600, :second) |> DateTime.truncate(:second)
ScoreCache.put(10_000, stale, [%{lat: 32.875, lon: -97.0, score: 40}])
ScoreCache.put(10_000, recent, [%{lat: 32.875, lon: -97.0, score: 60}])
ScoreCache.put(10_000, future, [%{lat: 32.875, lon: -97.0, score: 80}])
seed_point(10_000, stale, 32.875, -97.0, 40)
seed_point(10_000, recent, 32.875, -97.0, 60)
seed_point(10_000, future, 32.875, -97.0, 80)
result = Propagation.point_forecast(10_000, 32.875, -97.0)
@ -382,8 +394,8 @@ defmodule Microwaveprop.PropagationTest do
older = DateTime.utc_now() |> DateTime.add(-6 * 3600, :second) |> DateTime.truncate(:second)
newer = DateTime.utc_now() |> DateTime.add(-3 * 3600, :second) |> DateTime.truncate(:second)
ScoreCache.put(10_000, older, [%{lat: 32.875, lon: -97.0, score: 30}])
ScoreCache.put(10_000, newer, [%{lat: 32.875, lon: -97.0, score: 45}])
seed_point(10_000, older, 32.875, -97.0, 30)
seed_point(10_000, newer, 32.875, -97.0, 45)
result = Propagation.point_forecast(10_000, 32.875, -97.0)
assert [%{valid_time: ^newer, score: 45}] = result
@ -391,7 +403,7 @@ defmodule Microwaveprop.PropagationTest do
test "snaps coordinates to the nearest grid point" do
valid_time = DateTime.truncate(DateTime.add(DateTime.utc_now(), 3600, :second), :second)
ScoreCache.put(10_000, valid_time, [%{lat: 32.875, lon: -97.0, score: 65}])
seed_point(10_000, valid_time, 32.875, -97.0, 65)
# Off-grid query snaps to 32.875, -97.0
result = Propagation.point_forecast(10_000, 32.88, -96.98)
@ -403,6 +415,34 @@ defmodule Microwaveprop.PropagationTest do
end
end
describe "point_forecast/3 authoritative timeline" do
test "includes hours present on disk but not yet in the cache" do
# Cache has the two earliest valid_times, but a newer forecast
# hour has just landed on disk. The forecast chart should include
# that third hour so it matches the main-map timeline.
t1 = DateTime.utc_now() |> DateTime.truncate(:second) |> Map.merge(%{minute: 0, second: 0})
t2 = DateTime.add(t1, 3600, :second)
t3 = DateTime.add(t1, 2 * 3600, :second)
for {t, score} <- [{t1, 50}, {t2, 60}, {t3, 70}] do
Propagation.replace_scores(
[%{lat: 32.875, lon: -97.0, valid_time: t, band_mhz: 10_000, score: score, factors: nil}],
t
)
end
ScoreCache.clear()
ScoreCache.put(10_000, t1, [%{lat: 32.875, lon: -97.0, score: 50}])
ScoreCache.put(10_000, t2, [%{lat: 32.875, lon: -97.0, score: 60}])
result = Propagation.point_forecast(10_000, 32.875, -97.0)
times = Enum.map(result, & &1.valid_time)
assert t3 in times
assert length(result) == 3
end
end
describe "point_forecast/3 from store" do
test "includes recent past valid_times and falls back to the latest when stale" do
# Three .ntms files on disk: one 2h old (dropped by cutoff),

View file

@ -5,6 +5,7 @@ defmodule MicrowavepropWeb.MapLiveTest do
alias Microwaveprop.Propagation
alias Microwaveprop.Propagation.ScoreCache
alias Phoenix.LiveView.Utils
describe "mount" do
test "renders full-page map", %{conn: conn} do
@ -224,6 +225,113 @@ defmodule MicrowavepropWeb.MapLiveTest do
end
end
describe "tracking_now?/2" do
alias MicrowavepropWeb.MapLive
test "true when selected time equals the latest at-or-before-now slot" do
times = [~U[2026-04-18 18:00:00Z], ~U[2026-04-18 19:00:00Z], ~U[2026-04-18 20:00:00Z]]
now = ~U[2026-04-18 19:35:00Z]
# closest_to_now at 19:35 with these slots is 19:00.
assert MapLive.tracking_now?(~U[2026-04-18 19:00:00Z], times, now) == true
end
test "false when selected time is an earlier historical slot" do
times = [~U[2026-04-18 18:00:00Z], ~U[2026-04-18 19:00:00Z], ~U[2026-04-18 20:00:00Z]]
now = ~U[2026-04-18 19:35:00Z]
assert MapLive.tracking_now?(~U[2026-04-18 18:00:00Z], times, now) == false
end
test "false when selected time is a future forecast slot" do
times = [~U[2026-04-18 18:00:00Z], ~U[2026-04-18 19:00:00Z], ~U[2026-04-18 20:00:00Z]]
now = ~U[2026-04-18 19:35:00Z]
assert MapLive.tracking_now?(~U[2026-04-18 20:00:00Z], times, now) == false
end
test "false when valid_times is empty" do
assert MapLive.tracking_now?(~U[2026-04-18 19:00:00Z], [], ~U[2026-04-18 19:00:00Z]) == false
end
test "false when selected_time is nil" do
times = [~U[2026-04-18 19:00:00Z]]
assert MapLive.tracking_now?(nil, times, ~U[2026-04-18 19:00:00Z]) == false
end
end
describe "advance_now_cursor handler" do
setup do
ScoreCache.clear()
on_exit(fn -> ScoreCache.clear() end)
:ok
end
test "advances selected_time to the newly-past hour when tracking now", %{conn: conn} do
# Seed two adjacent hours so there's somewhere to advance to. The
# later of the two is the one we want the cursor to snap to once
# the clock ticks past it.
previous_hour =
DateTime.utc_now() |> DateTime.truncate(:second) |> Map.merge(%{minute: 0, second: 0}) |> DateTime.add(-1, :hour)
current_hour = DateTime.add(previous_hour, 3600, :second)
for t <- [previous_hour, current_hour] do
Propagation.replace_scores(
[%{lat: 33.0, lon: -97.0, valid_time: t, band_mhz: 10_000, score: 50, factors: nil}],
t
)
end
{:ok, lv, _html} = live(conn, ~p"/map")
# Force the LV onto the previous hour and mark it as tracking_now?
# so the tick handler has a clear advance opportunity.
:sys.replace_state(lv.pid, fn state ->
socket =
state.socket
|> Utils.assign(:selected_time, previous_hour)
|> Utils.assign(:tracking_now?, true)
%{state | socket: socket}
end)
send(lv.pid, :advance_now_cursor)
_ = render(lv)
assigns = :sys.get_state(lv.pid).socket.assigns
assert assigns.selected_time == current_hour
end
test "leaves selected_time alone when the user is on a specific hour", %{conn: conn} do
previous_hour =
DateTime.utc_now() |> DateTime.truncate(:second) |> Map.merge(%{minute: 0, second: 0}) |> DateTime.add(-1, :hour)
current_hour = DateTime.add(previous_hour, 3600, :second)
for t <- [previous_hour, current_hour] do
Propagation.replace_scores(
[%{lat: 33.0, lon: -97.0, valid_time: t, band_mhz: 10_000, score: 50, factors: nil}],
t
)
end
{:ok, lv, _html} = live(conn, ~p"/map")
:sys.replace_state(lv.pid, fn state ->
socket =
state.socket
|> Utils.assign(:selected_time, previous_hour)
|> Utils.assign(:tracking_now?, false)
%{state | socket: socket}
end)
send(lv.pid, :advance_now_cursor)
_ = render(lv)
assigns = :sys.get_state(lv.pid).socket.assigns
assert assigns.selected_time == previous_hour
end
end
describe "format_data_timestamp/1" do
alias MicrowavepropWeb.MapLive