Line-chart forecast + unit fix on /path

Replace the stacked-bar 18-hour forecast on the path calculator with
an SVG line chart that mirrors the look of the point-detail sparkline
on /map:

- polyline through every hour with per-tier stroke color
- score gridlines at 0 / 50 / 100
- white "now" marker outlined in the tier color
- highlighted best-hour dot with score label
- first / middle / last time labels along the x-axis
- Improving / Steady / Declining trend chip in the header

Rendered server-side in HEEx as a viewBox="0 0 300 96" SVG so it
scales responsively and inherits the daisyUI text color for axes.
The Best / Worst summary line under the chart is preserved.

Also fixes the Path Weather Avg card: the "Refrac Gradient" cell was
the only stat that split its units out onto a second line in a
smaller muted row. Move "N/km" up beside the value so the cell reads
the same as Pressure (mb), PWAT (mm), BL Depth (m), etc.
This commit is contained in:
Graham McIntire 2026-04-12 17:49:16 -05:00
parent 547b8451c0
commit 8121257c59
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59

View file

@ -825,9 +825,8 @@ defmodule MicrowavepropWeb.PathLive do
<div>
<span class="text-xs opacity-60">Refrac Gradient</span>
<div class="font-mono text-xl">
{format_number(@result.conditions.min_refractivity_gradient || 0)}
{format_number(@result.conditions.min_refractivity_gradient || 0)} N/km
</div>
<div class="text-xs opacity-50">N/km</div>
</div>
</div>
</div>
@ -846,46 +845,7 @@ defmodule MicrowavepropWeb.PathLive do
<%!-- 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>
<.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"""
<div class="bg-base-200 rounded-box p-4 mb-6">
<div class="flex items-center justify-between mb-2">
<div class="text-xs opacity-60">
{length(@forecast)}-Hour Propagation Forecast &middot; {@band_config.label}
</div>
<div class="text-xs font-bold">
{Phoenix.HTML.raw(@trend)}
</div>
</div>
<svg viewBox="0 0 300 96" class="w-full h-24">
<%= for s <- [0, 50, 100] do %>
<% y = 6 + (100 - s) / 100 * 70 %>
<line
x1="28"
y1={y}
x2="294"
y2={y}
stroke="currentColor"
class="opacity-10"
stroke-width="1"
/>
<text
x="24"
y={y + 3}
font-size="9"
text-anchor="end"
fill="currentColor"
class="opacity-40"
>
{s}
</text>
<% end %>
<polyline
points={@polyline}
fill="none"
stroke={tier_color(@best_pt.score)}
stroke-width="2"
stroke-linejoin="round"
stroke-linecap="round"
/>
<%= for pt <- @points do %>
<circle cx={pt.x} cy={pt.y} r="2" fill={tier_color(pt.score)} />
<% end %>
<circle
cx={@now_pt.x}
cy={@now_pt.y}
r="4"
fill="white"
stroke={tier_color(@now_pt.score)}
stroke-width="2"
/>
<circle
cx={@best_pt.x}
cy={@best_pt.y}
r="4"
fill={tier_color(@best_pt.score)}
stroke="white"
stroke-width="1.5"
/>
<text
x={@best_pt.x}
y={@best_pt.y - 6}
font-size="9"
text-anchor="middle"
font-weight="700"
fill={tier_color(@best_pt.score)}
>
{@best_pt.score}
</text>
<%= for {x, label} <- @x_labels do %>
<text
x={x}
y="92"
font-size="9"
text-anchor="middle"
fill="currentColor"
class="opacity-40"
>
{label}
</text>
<% end %>
</svg>
<div class="flex justify-between text-xs mt-2">
<span>
Best:
<span class="font-bold" style={"color: #{tier_color(@best_pt.score)}"}>
{@best_pt.score}
</span>
at {@best_pt.label} UTC
</span>
<span>
Worst:
<span class="font-bold" style={"color: #{tier_color(@worst_pt.score)}"}>
{@worst_pt.score}
</span>
at {@worst_pt.label} UTC
</span>
</div>
</div>
"""
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(<span class="text-success">▲ Improving</span>)
diff < -5 -> ~s(<span class="text-error">▼ Declining</span>)
true -> ~s(<span class="text-warning">→ Steady</span>)
end
_ ->
~s(<span class="opacity-50">→ Steady</span>)
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