Add forecast timeline, shareable URLs, and real-time form updates to /path

- 18-hour propagation forecast bar chart from grid scores at path midpoint
  Shows best/worst score and time, color-coded bars
- URL query params: all form fields reflected in URL, auto-calculates
  when source+destination present. Bookmarkable/shareable links.
- phx-change on form updates assigns in real-time as user types
- Removed LiveStash in favor of URL-based state persistence
This commit is contained in:
Graham McIntire 2026-04-07 13:45:56 -05:00
parent 069af0103b
commit 4ec4cd348b

View file

@ -1,8 +1,8 @@
defmodule MicrowavepropWeb.PathLive do
@moduledoc false
use MicrowavepropWeb, :live_view
use LiveStash
alias Microwaveprop.Propagation
alias Microwaveprop.Propagation.BandConfig
alias Microwaveprop.Propagation.Scorer
alias Microwaveprop.Radio.CallsignClient
@ -22,56 +22,93 @@ defmodule MicrowavepropWeb.PathLive do
{"241 GHz", "241000"}
]
@stash_fields [
:source,
:destination,
:band,
:src_height_ft,
:dst_height_ft,
:tx_power_dbm,
:src_gain_dbi,
:dst_gain_dbi
]
@url_params ~w(source destination band src_height_ft dst_height_ft tx_power_dbm src_gain_dbi dst_gain_dbi)
@defaults %{
"source" => "",
"destination" => "",
"band" => "10000",
"src_height_ft" => "30",
"dst_height_ft" => "30",
"tx_power_dbm" => "20",
"src_gain_dbi" => "30",
"dst_gain_dbi" => "30"
}
@impl true
def mount(_params, _session, socket) do
defaults = %{
source: "",
destination: "",
band: "10000",
src_height_ft: "30",
dst_height_ft: "30",
tx_power_dbm: "20",
src_gain_dbi: "30",
dst_gain_dbi: "30"
}
recovered =
case LiveStash.recover_state(socket) do
{:recovered, socket} ->
socket.assigns
|> Map.take(@stash_fields)
|> then(&Map.merge(defaults, &1))
_ ->
defaults
end
{:ok,
assign(
socket,
[
{:page_title, "Path Calculator"},
{:band_options, @band_options},
{:result, nil},
{:error, nil},
{:computing, false}
] ++
Map.to_list(recovered)
assign(socket,
page_title: "Path Calculator",
band_options: @band_options,
result: nil,
error: nil,
computing: false,
source: "",
destination: "",
band: "10000",
src_height_ft: "30",
dst_height_ft: "30",
tx_power_dbm: "20",
src_gain_dbi: "30",
dst_gain_dbi: "30"
)}
end
@impl true
def handle_params(params, _uri, socket) do
p = Map.merge(@defaults, Map.take(params, @url_params))
socket =
assign(socket,
source: p["source"],
destination: p["destination"],
band: p["band"],
src_height_ft: p["src_height_ft"],
dst_height_ft: p["dst_height_ft"],
tx_power_dbm: p["tx_power_dbm"],
src_gain_dbi: p["src_gain_dbi"],
dst_gain_dbi: p["dst_gain_dbi"]
)
# Auto-calculate if source and destination are in the URL
if p["source"] != "" and p["destination"] != "" and not socket.assigns.computing and is_nil(socket.assigns.result) do
src_ht = parse_float(p["src_height_ft"], 30.0)
dst_ht = parse_float(p["dst_height_ft"], 30.0)
tx_dbm = parse_float(p["tx_power_dbm"], 20.0)
station_params = %{
src_height_ft: src_ht,
dst_height_ft: dst_ht,
src_height_m: src_ht * 0.3048,
dst_height_m: dst_ht * 0.3048,
tx_power_dbm: tx_dbm,
tx_power_mw: :math.pow(10, tx_dbm / 10),
src_gain_dbi: parse_float(p["src_gain_dbi"], 30.0),
dst_gain_dbi: parse_float(p["dst_gain_dbi"], 30.0)
}
send(self(), {:compute_path, p["source"], p["destination"], String.to_integer(p["band"]), station_params})
{:noreply, assign(socket, computing: true)}
else
{:noreply, socket}
end
end
@impl true
def handle_event("update_form", params, socket) do
{:noreply,
assign(socket,
source: params["source"] || "",
destination: params["destination"] || "",
band: params["band"] || "10000",
src_height_ft: params["src_height_ft"] || "30",
dst_height_ft: params["dst_height_ft"] || "30",
tx_power_dbm: params["tx_power_dbm"] || "20",
src_gain_dbi: params["src_gain_dbi"] || "30",
dst_gain_dbi: params["dst_gain_dbi"] || "30"
)}
end
def handle_event("calculate", params, socket) do
src_height_ft = parse_float(params["src_height_ft"], 30.0)
dst_height_ft = parse_float(params["dst_height_ft"], 30.0)
@ -110,7 +147,8 @@ defmodule MicrowavepropWeb.PathLive do
{:compute_path, params["source"], params["destination"], String.to_integer(params["band"]), station_params}
)
{:noreply, LiveStash.stash_assigns(socket, @stash_fields)}
url_params = params |> Map.take(@url_params) |> Enum.reject(fn {_k, v} -> v == "" end) |> Map.new()
{:noreply, push_patch(socket, to: ~p"/path?#{url_params}", replace: true)}
end
def handle_event("gps_location", %{"lat" => lat, "lon" => lon}, socket) do
@ -172,6 +210,9 @@ defmodule MicrowavepropWeb.PathLive do
loss_budget = compute_loss_budget(dist_km, freq_ghz, band_config, terrain_result, conditions)
power_budget = compute_power_budget(station_params, loss_budget)
# 18-hour forecast from propagation grid (midpoint of path)
forecast = Propagation.point_forecast(band_mhz, midlat, midlon)
{:ok,
%{
source: src,
@ -187,6 +228,7 @@ defmodule MicrowavepropWeb.PathLive do
scoring: scoring,
loss_budget: loss_budget,
power_budget: power_budget,
forecast: forecast,
hrrr_count: length(hrrr_profiles)
}}
end
@ -399,6 +441,9 @@ defmodule MicrowavepropWeb.PathLive do
defp format_number(n) when is_float(n), do: :erlang.float_to_binary(n, decimals: 1)
defp format_number(n), do: to_string(n)
defp format_utc_hour(%DateTime{} = dt), do: Calendar.strftime(dt, "%H:%M")
defp format_utc_hour(_), do: "?"
defp format_mw(mw) when mw >= 1000, do: "#{format_number(mw / 1000)} W"
defp format_mw(mw) when mw >= 1, do: "#{format_number(mw)} mW"
defp format_mw(mw), do: "#{format_number(mw * 1000)} uW"
@ -414,7 +459,7 @@ defmodule MicrowavepropWeb.PathLive do
<:subtitle>Analyze microwave propagation between two points</:subtitle>
</.header>
<form phx-submit="calculate" class="bg-base-200 rounded-box p-4 mb-6">
<form phx-submit="calculate" phx-change="update_form" class="bg-base-200 rounded-box p-4 mb-6">
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 items-end">
<div class="fieldset">
<label class="label text-sm font-medium">Source (callsign or grid)</label>
@ -781,6 +826,50 @@ defmodule MicrowavepropWeb.PathLive do
</div>
<% end %>
</div>
<%!-- 18-Hour Forecast --%>
<%= if @result.forecast != [] do %>
<div class="bg-base-200 rounded-box p-4 mb-6">
<div class="text-xs opacity-60 mb-3">
18-Hour Propagation Forecast &middot; {@result.band_config.label}
</div>
<div class="flex items-end gap-0.5 h-24">
<%= for point <- @result.forecast do %>
<% pct = max(point.score, 2) %>
<div
class="flex-1 rounded-t-sm relative group cursor-default"
style={"height: #{pct}%; background-color: #{tier_color(point.score)}; min-width: 4px"}
title={"#{format_utc_hour(point.valid_time)} UTC: #{point.score}/100 #{tier_label(point.score)}"}
>
</div>
<% end %>
</div>
<div class="flex justify-between text-xs opacity-50 mt-1">
<%= if length(@result.forecast) > 0 do %>
<span>{format_utc_hour(List.first(@result.forecast).valid_time)} UTC</span>
<span>{format_utc_hour(List.last(@result.forecast).valid_time)} UTC</span>
<% end %>
</div>
<div class="flex justify-between text-xs mt-2">
<% best = Enum.max_by(@result.forecast, & &1.score) %>
<span>
Best:
<span class="font-bold" style={"color: #{tier_color(best.score)}"}>
{best.score}
</span>
at {format_utc_hour(best.valid_time)} UTC
</span>
<% worst = Enum.min_by(@result.forecast, & &1.score) %>
<span>
Worst:
<span class="font-bold" style={"color: #{tier_color(worst.score)}"}>
{worst.score}
</span>
at {format_utc_hour(worst.valid_time)} UTC
</span>
</div>
</div>
<% end %>
"""
end