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:
parent
547b8451c0
commit
8121257c59
1 changed files with 191 additions and 42 deletions
|
|
@ -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 · {@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 · {@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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue