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
|
defmodule MicrowavepropWeb.PathLive do
|
||||||
@moduledoc false
|
@moduledoc false
|
||||||
use MicrowavepropWeb, :live_view
|
use MicrowavepropWeb, :live_view
|
||||||
use LiveStash
|
|
||||||
|
|
||||||
|
alias Microwaveprop.Propagation
|
||||||
alias Microwaveprop.Propagation.BandConfig
|
alias Microwaveprop.Propagation.BandConfig
|
||||||
alias Microwaveprop.Propagation.Scorer
|
alias Microwaveprop.Propagation.Scorer
|
||||||
alias Microwaveprop.Radio.CallsignClient
|
alias Microwaveprop.Radio.CallsignClient
|
||||||
|
|
@ -22,56 +22,93 @@ defmodule MicrowavepropWeb.PathLive do
|
||||||
{"241 GHz", "241000"}
|
{"241 GHz", "241000"}
|
||||||
]
|
]
|
||||||
|
|
||||||
@stash_fields [
|
@url_params ~w(source destination band src_height_ft dst_height_ft tx_power_dbm src_gain_dbi dst_gain_dbi)
|
||||||
:source,
|
@defaults %{
|
||||||
:destination,
|
"source" => "",
|
||||||
:band,
|
"destination" => "",
|
||||||
:src_height_ft,
|
"band" => "10000",
|
||||||
:dst_height_ft,
|
"src_height_ft" => "30",
|
||||||
:tx_power_dbm,
|
"dst_height_ft" => "30",
|
||||||
:src_gain_dbi,
|
"tx_power_dbm" => "20",
|
||||||
:dst_gain_dbi
|
"src_gain_dbi" => "30",
|
||||||
]
|
"dst_gain_dbi" => "30"
|
||||||
|
}
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(_params, _session, socket) do
|
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,
|
{:ok,
|
||||||
assign(
|
assign(socket,
|
||||||
socket,
|
page_title: "Path Calculator",
|
||||||
[
|
band_options: @band_options,
|
||||||
{:page_title, "Path Calculator"},
|
result: nil,
|
||||||
{:band_options, @band_options},
|
error: nil,
|
||||||
{:result, nil},
|
computing: false,
|
||||||
{:error, nil},
|
source: "",
|
||||||
{:computing, false}
|
destination: "",
|
||||||
] ++
|
band: "10000",
|
||||||
Map.to_list(recovered)
|
src_height_ft: "30",
|
||||||
|
dst_height_ft: "30",
|
||||||
|
tx_power_dbm: "20",
|
||||||
|
src_gain_dbi: "30",
|
||||||
|
dst_gain_dbi: "30"
|
||||||
)}
|
)}
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@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
|
def handle_event("calculate", params, socket) do
|
||||||
src_height_ft = parse_float(params["src_height_ft"], 30.0)
|
src_height_ft = parse_float(params["src_height_ft"], 30.0)
|
||||||
dst_height_ft = parse_float(params["dst_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}
|
{: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
|
end
|
||||||
|
|
||||||
def handle_event("gps_location", %{"lat" => lat, "lon" => lon}, socket) do
|
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)
|
loss_budget = compute_loss_budget(dist_km, freq_ghz, band_config, terrain_result, conditions)
|
||||||
power_budget = compute_power_budget(station_params, loss_budget)
|
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,
|
{:ok,
|
||||||
%{
|
%{
|
||||||
source: src,
|
source: src,
|
||||||
|
|
@ -187,6 +228,7 @@ defmodule MicrowavepropWeb.PathLive do
|
||||||
scoring: scoring,
|
scoring: scoring,
|
||||||
loss_budget: loss_budget,
|
loss_budget: loss_budget,
|
||||||
power_budget: power_budget,
|
power_budget: power_budget,
|
||||||
|
forecast: forecast,
|
||||||
hrrr_count: length(hrrr_profiles)
|
hrrr_count: length(hrrr_profiles)
|
||||||
}}
|
}}
|
||||||
end
|
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) when is_float(n), do: :erlang.float_to_binary(n, decimals: 1)
|
||||||
defp format_number(n), do: to_string(n)
|
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 >= 1000, do: "#{format_number(mw / 1000)} W"
|
||||||
defp format_mw(mw) when mw >= 1, do: "#{format_number(mw)} mW"
|
defp format_mw(mw) when mw >= 1, do: "#{format_number(mw)} mW"
|
||||||
defp format_mw(mw), do: "#{format_number(mw * 1000)} uW"
|
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>
|
<:subtitle>Analyze microwave propagation between two points</:subtitle>
|
||||||
</.header>
|
</.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="grid grid-cols-1 md:grid-cols-4 gap-4 items-end">
|
||||||
<div class="fieldset">
|
<div class="fieldset">
|
||||||
<label class="label text-sm font-medium">Source (callsign or grid)</label>
|
<label class="label text-sm font-medium">Source (callsign or grid)</label>
|
||||||
|
|
@ -781,6 +826,50 @@ defmodule MicrowavepropWeb.PathLive do
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</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
|
end
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue