Skip to content

Commit 5fb45af

Browse files
esbanarangoclaude
andcommitted
ui(hour-card): wind arrow + cloudbase glyph + fill bars per metric
Replace the bare wind/base 2-column grid with iconic rows so each metric reads at a glance: - Wind row: directional arrow (rotates with wind_direction_10m_deg) + value + fill bar toned green/amber/red against a 30 km/h ceiling. - Cloudbase row: cloud-with-baseline glyph (new cloud_base/1) + value + neutral fill bar against a 2000 m AGL ceiling. - Low-cloud row (conditional): cloud icon with proportional fill + percentage + cloud-tone fill bar — only when cloud_cover_low_pct is present in the driving hour's inputs_used. LiveView hour_row/2 now extracts wind_direction_10m_deg and cloud_cover_low_pct alongside the existing wind/base values, and HourTimeline forwards them. Existing data-role contracts ("wind", "cloudbase", "verdict", "flight-type", "confidence-badge") preserved so the T099/T104/T105/T106 LiveView tests stay green (262/262). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 816bec7 commit 5fb45af

4 files changed

Lines changed: 112 additions & 12 deletions

File tree

lib/volable_web/components/hour_card.ex

Lines changed: 85 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,22 @@ defmodule VolableWeb.Components.HourCard do
1212
use Phoenix.Component
1313

1414
alias Volable.Flyability.{FlightTypePrediction, Verdict}
15-
alias VolableWeb.Components.{ConfidenceBadge, DangerAlerts, Explanation}
15+
alias VolableWeb.Components.{ConfidenceBadge, DangerAlerts, Explanation, WeatherIcons}
16+
17+
# Visual scale ceilings for the inline bars. Anything above the ceiling
18+
# clamps to a full bar — pilots reading "wind > 30 km/h" already know
19+
# they don't need a finer-grained number on the timeline.
20+
@wind_bar_max_kmh 30
21+
@cloudbase_bar_max_m 2_000
1622

1723
attr :hour, :integer, required: true
1824
attr :level, :atom, required: true
1925
attr :flight_type, :atom, required: true
2026
attr :confidence, :atom, required: true
2127
attr :wind_kmh, :any, default: nil
28+
attr :wind_direction_deg, :any, default: nil
2229
attr :cloudbase_m, :any, default: nil
30+
attr :cloud_low_pct, :any, default: nil
2331
attr :is_driving_hour, :boolean, default: false
2432
attr :explanation, :string, default: ""
2533
attr :danger_alerts, :list, default: []
@@ -53,16 +61,57 @@ defmodule VolableWeb.Components.HourCard do
5361
{FlightTypePrediction.display_label(@flight_type)}
5462
</span>
5563
56-
<dl class="grid grid-cols-2 gap-x-2 gap-y-0.5 text-[0.7rem] text-[var(--color-ink-soft)] numerals">
57-
<dt class="label-eyebrow text-[0.55rem] text-[var(--color-ink-quiet)]">wind</dt>
58-
<dt class="label-eyebrow text-[0.55rem] text-[var(--color-ink-quiet)]">base</dt>
59-
<dd data-role="wind" data-value={@wind_kmh} class="font-semibold">
60-
{format_wind(@wind_kmh)}
61-
</dd>
62-
<dd data-role="cloudbase" data-value={@cloudbase_m} class="font-semibold">
63-
{format_cloudbase(@cloudbase_m)}
64-
</dd>
65-
</dl>
64+
<div class="mt-1 space-y-1.5 text-[0.7rem] numerals">
65+
<div class="flex items-center gap-1.5">
66+
<span class="shrink-0 text-[var(--color-ink-soft)]">
67+
<WeatherIcons.wind_arrow degrees={int_or(@wind_direction_deg, 0)} class="h-3.5 w-3.5" />
68+
</span>
69+
<span
70+
data-role="wind"
71+
data-value={@wind_kmh}
72+
class="font-semibold text-[var(--color-ink)] tabular-nums w-12 shrink-0"
73+
>
74+
{format_wind(@wind_kmh)}
75+
</span>
76+
<WeatherIcons.fill_bar
77+
value={wind_bar_pct(@wind_kmh)}
78+
tone={wind_tone(@wind_kmh)}
79+
class="h-1 flex-1"
80+
/>
81+
</div>
82+
83+
<div class="flex items-center gap-1.5">
84+
<span class="shrink-0 text-[var(--color-ink-soft)]">
85+
<WeatherIcons.cloud_base class="h-3.5 w-4" />
86+
</span>
87+
<span
88+
data-role="cloudbase"
89+
data-value={@cloudbase_m}
90+
class="font-semibold text-[var(--color-ink)] tabular-nums w-12 shrink-0"
91+
>
92+
{format_cloudbase(@cloudbase_m)}
93+
</span>
94+
<WeatherIcons.fill_bar
95+
value={cloudbase_bar_pct(@cloudbase_m)}
96+
tone={:neutral}
97+
class="h-1 flex-1"
98+
/>
99+
</div>
100+
101+
<div :if={is_number(@cloud_low_pct)} class="flex items-center gap-1.5">
102+
<span class="shrink-0 text-[var(--color-ink-soft)]">
103+
<WeatherIcons.cloud fill_pct={trunc(@cloud_low_pct)} class="h-3.5 w-4" />
104+
</span>
105+
<span class="font-semibold text-[var(--color-ink-soft)] tabular-nums w-12 shrink-0">
106+
{trunc(@cloud_low_pct)} %
107+
</span>
108+
<WeatherIcons.fill_bar
109+
value={trunc(@cloud_low_pct)}
110+
tone={:cloud}
111+
class="h-1 flex-1"
112+
/>
113+
</div>
114+
</div>
66115
67116
<DangerAlerts.danger_alerts alerts={@danger_alerts} />
68117
@@ -95,4 +144,28 @@ defmodule VolableWeb.Components.HourCard do
95144
do: :erlang.float_to_binary(n * 1.0, decimals: 0) <> " m"
96145

97146
defp format_cloudbase(other), do: to_string(other) <> " m"
98-
end
147+
148+
defp int_or(n, _default) when is_number(n), do: trunc(n)
149+
defp int_or(_, default), do: default
150+
151+
defp wind_bar_pct(n) when is_number(n) do
152+
pct = n / @wind_bar_max_kmh * 100
153+
pct |> max(0) |> min(100) |> trunc()
154+
end
155+
156+
defp wind_bar_pct(_), do: 0
157+
158+
# Sweet spot is < 18 km/h sustained (the §8.3 soaring band lower half).
159+
# 18–25 is intermediate territory; > 25 is expert-only or worse.
160+
defp wind_tone(n) when is_number(n) and n < 18, do: :go
161+
defp wind_tone(n) when is_number(n) and n < 25, do: :wind
162+
defp wind_tone(n) when is_number(n), do: :warn
163+
defp wind_tone(_), do: :neutral
164+
165+
defp cloudbase_bar_pct(n) when is_number(n) do
166+
pct = n / @cloudbase_bar_max_m * 100
167+
pct |> max(0) |> min(100) |> trunc()
168+
end
169+
170+
defp cloudbase_bar_pct(_), do: 0
171+
end

lib/volable_web/components/hour_timeline.ex

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,9 @@ defmodule VolableWeb.Components.HourTimeline do
4747
flight_type={h.flight_type}
4848
confidence={h.confidence}
4949
wind_kmh={h.wind_kmh}
50+
wind_direction_deg={Map.get(h, :wind_direction_deg)}
5051
cloudbase_m={h.cloudbase_m}
52+
cloud_low_pct={Map.get(h, :cloud_low_pct)}
5153
is_driving_hour={h.is_driving_hour}
5254
explanation={h.explanation}
5355
danger_alerts={h.danger_alerts}

lib/volable_web/components/weather_icons.ex

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,29 @@ defmodule VolableWeb.Components.WeatherIcons do
9494
"""
9595
end
9696

97+
@doc """
98+
Cloudbase glyph — a small cloud with a horizontal underline marking the
99+
*base* of the cloud deck. Pairs naturally with a height value. Pure
100+
silhouette; pass `:class` to size.
101+
"""
102+
attr :class, :string, default: "h-5 w-5"
103+
attr :rest, :global
104+
105+
def cloud_base(assigns) do
106+
~H"""
107+
<svg viewBox="0 0 24 18" fill="none" class={@class} aria-hidden="true" {@rest}>
108+
<path
109+
d="M7 13 C3.5 13 1.5 11 1.5 8.5 C1.5 6 4 4 6.5 4 C7 2 9 1 11 1 C13 1 15 2 15.5 4 C18.5 4.2 21.5 6 21.5 8.5 C21.5 11 19 13 16.5 13 Z"
110+
stroke="currentColor"
111+
stroke-width="1.5"
112+
fill="currentColor"
113+
fill-opacity="0.18"
114+
/>
115+
<line x1="1" y1="16" x2="23" y2="16" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
116+
</svg>
117+
"""
118+
end
119+
97120
@doc """
98121
Wind direction arrow — points in the direction the wind is BLOWING TO
99122
(meteorological convention is "from", so we add 180°). Pass `:degrees`

lib/volable_web/live/flyability_live.ex

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -451,7 +451,9 @@ defmodule VolableWeb.FlyabilityLive do
451451
flight_type: atom(agg["hourly_flight_type_category"], :none),
452452
confidence: atom(agg["confidence"], :none),
453453
wind_kmh: Map.get(inputs, "wind_speed_10m_kmh"),
454+
wind_direction_deg: Map.get(inputs, "wind_direction_10m_deg"),
454455
cloudbase_m: Map.get(inputs, "derived_cloud_base_m_agl"),
456+
cloud_low_pct: Map.get(inputs, "cloud_cover_low_pct"),
455457
is_driving_hour: agg["hour"] == driving_hour,
456458
explanation: Map.get(agg, "explanation", ""),
457459
danger_alerts: danger_alerts

0 commit comments

Comments
 (0)