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:
parent
069af0103b
commit
4ec4cd348b
1 changed files with 134 additions and 45 deletions
|
|
@ -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 · {@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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue