diff --git a/lib/microwaveprop_web/live/path_live.ex b/lib/microwaveprop_web/live/path_live.ex
index 071a3768..4592b099 100644
--- a/lib/microwaveprop_web/live/path_live.ex
+++ b/lib/microwaveprop_web/live/path_live.ex
@@ -825,9 +825,8 @@ defmodule MicrowavepropWeb.PathLive do
Refrac Gradient
- {format_number(@result.conditions.min_refractivity_gradient || 0)}
+ {format_number(@result.conditions.min_refractivity_gradient || 0)} N/km
-
N/km
@@ -846,46 +845,7 @@ defmodule MicrowavepropWeb.PathLive do
<%!-- 18-Hour Forecast --%>
<%= if @result.forecast != [] do %>
-
-
- 18-Hour Propagation Forecast · {@result.band_config.label}
-
-
- <%= for point <- @result.forecast do %>
- <% pct = max(point.score, 2) %>
-
-
- <% end %>
-
-
- <%= if length(@result.forecast) > 0 do %>
- {format_utc_hour(List.first(@result.forecast).valid_time)} UTC
- {format_utc_hour(List.last(@result.forecast).valid_time)} UTC
- <% end %>
-
-
- <% best = Enum.max_by(@result.forecast, & &1.score) %>
-
- Best:
-
- {best.score}
-
- at {format_utc_hour(best.valid_time)} UTC
-
- <% worst = Enum.min_by(@result.forecast, & &1.score) %>
-
- Worst:
-
- {worst.score}
-
- at {format_utc_hour(worst.valid_time)} UTC
-
-
-
+ <.forecast_chart forecast={@result.forecast} band_config={@result.band_config} />
<% end %>
<%!-- Share link --%>
@@ -902,6 +862,195 @@ defmodule MicrowavepropWeb.PathLive do
"""
end
+ attr :forecast, :list, required: true
+ attr :band_config, :map, required: true
+
+ defp forecast_chart(assigns) do
+ points = build_forecast_points(assigns.forecast)
+
+ assigns =
+ assigns
+ |> assign(:points, points)
+ |> assign(:polyline, Enum.map_join(points, " ", fn p -> "#{p.x},#{p.y}" end))
+ |> assign(:now_pt, Enum.find(points, & &1.now?) || hd(points))
+ |> assign(:best_pt, Enum.max_by(points, & &1.score))
+ |> assign(:worst_pt, Enum.min_by(points, & &1.score))
+ |> assign(:trend, forecast_trend(points))
+ |> assign(:x_labels, forecast_x_labels(points))
+
+ ~H"""
+
+
+
+ {length(@forecast)}-Hour Propagation Forecast · {@band_config.label}
+
+
+ {Phoenix.HTML.raw(@trend)}
+
+
+
+ <%= for s <- [0, 50, 100] do %>
+ <% y = 6 + (100 - s) / 100 * 70 %>
+
+
+ {s}
+
+ <% end %>
+
+ <%= for pt <- @points do %>
+
+ <% end %>
+
+
+
+ {@best_pt.score}
+
+ <%= for {x, label} <- @x_labels do %>
+
+ {label}
+
+ <% end %>
+
+
+
+ Best:
+
+ {@best_pt.score}
+
+ at {@best_pt.label} UTC
+
+
+ Worst:
+
+ {@worst_pt.score}
+
+ at {@worst_pt.label} UTC
+
+
+
+ """
+ end
+
+ defp build_forecast_points(forecast) do
+ n = length(forecast)
+ now = DateTime.utc_now()
+
+ # Map each forecast point to SVG coords (viewBox 0 0 300 96)
+ # plot area: x in [28, 294], y in [6, 76]
+ indexed = Enum.with_index(forecast)
+
+ now_idx =
+ indexed
+ |> Enum.min_by(fn {p, _i} -> abs(DateTime.diff(p.valid_time, now, :second)) end)
+ |> elem(1)
+
+ Enum.map(indexed, fn {p, i} ->
+ x =
+ if n <= 1 do
+ 161
+ else
+ 28 + i / (n - 1) * 266
+ end
+
+ y = 6 + (100 - p.score) / 100 * 70
+
+ %{
+ x: Float.round(x * 1.0, 2),
+ y: Float.round(y * 1.0, 2),
+ score: p.score,
+ valid_time: p.valid_time,
+ label: format_utc_hour(p.valid_time),
+ now?: i == now_idx
+ }
+ end)
+ end
+
+ defp forecast_trend(points) do
+ now_idx = Enum.find_index(points, & &1.now?) || 0
+ future = Enum.drop(points, now_idx)
+
+ case future do
+ [first | _] ->
+ last = List.last(future)
+ diff = last.score - first.score
+
+ cond do
+ diff > 5 -> ~s(▲ Improving )
+ diff < -5 -> ~s(▼ Declining )
+ true -> ~s(→ Steady )
+ end
+
+ _ ->
+ ~s(→ Steady )
+ end
+ end
+
+ defp forecast_x_labels(points) do
+ n = length(points)
+
+ indices =
+ if n <= 3 do
+ Enum.to_list(0..(n - 1))
+ else
+ [0, div(n - 1, 2), n - 1]
+ end
+
+ Enum.map(indices, fn i ->
+ p = Enum.at(points, i)
+ {p.x, p.label}
+ end)
+ end
+
attr :label, :string, required: true
attr :value, :any, required: true
attr :unit, :string, required: true