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>
|
<div>
|
||||||
<span class="text-xs opacity-60">Refrac Gradient</span>
|
<span class="text-xs opacity-60">Refrac Gradient</span>
|
||||||
<div class="font-mono text-xl">
|
<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>
|
||||||
<div class="text-xs opacity-50">N/km</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -846,46 +845,7 @@ defmodule MicrowavepropWeb.PathLive do
|
||||||
|
|
||||||
<%!-- 18-Hour Forecast --%>
|
<%!-- 18-Hour Forecast --%>
|
||||||
<%= if @result.forecast != [] do %>
|
<%= if @result.forecast != [] do %>
|
||||||
<div class="bg-base-200 rounded-box p-4 mb-6">
|
<.forecast_chart forecast={@result.forecast} band_config={@result.band_config} />
|
||||||
<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 %>
|
||||||
|
|
||||||
<%!-- Share link --%>
|
<%!-- Share link --%>
|
||||||
|
|
@ -902,6 +862,195 @@ defmodule MicrowavepropWeb.PathLive do
|
||||||
"""
|
"""
|
||||||
end
|
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 :label, :string, required: true
|
||||||
attr :value, :any, required: true
|
attr :value, :any, required: true
|
||||||
attr :unit, :string, required: true
|
attr :unit, :string, required: true
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue