defmodule MicrowavepropWeb.SkewtSvg do
@moduledoc """
Render an HRRR vertical profile as a Skew-T-Log-P diagram in SVG.
HRRR's persisted profile stores `pres`, `hght`, `tmpc`, `dwpc` per
pressure level — same canonical shape `Microwaveprop.Weather.SoundingParams`
consumes. This renderer ignores anything else.
Wind barbs are deliberately omitted: HRRR persists wind only at 10 m
AGL, not on the pressure-level profile, so a per-level barb column
would be misleading.
"""
alias Microwaveprop.Weather.SkewtParams
# Plot canvas (SVG user units). The right margin (right edge of the
# plot box → SVG edge) is sized to fit the longest critical-level
# label rail entry, e.g. `LCL 925 mb`, without clipping.
@width 820
@height 640
@left 60
@right 660
@top 30
@bottom 580
@p_bottom 1050.0
@p_top 100.0
@t_min -40.0
@t_max 40.0
# Minimum vertical gap between two stacked critical-level label
# baselines (px in SVG user units). 14 leaves the 10 px text plus a
# hair of breathing room — anything tighter starts overlapping
# descenders.
@critical_label_min_dy 14
# Skew factor — pixels of horizontal offset added per pixel of
# height. 1.0 puts isotherms at 45°.
@skew 1.0
@doc """
Render a Skew-T diagram for `profile` (a list of `%{"pres", "hght",
"tmpc", "dwpc"}` maps) and return an inline SVG string suitable for
`Phoenix.HTML.raw/1`.
When `:parcel_trace` is supplied (typically from
`Microwaveprop.Weather.SkewtParams.derive/1`) a dashed grey parcel-
ascent line is overlaid on the temperature trace, matching the
surface-parcel rendition the SPC sounding viewer publishes.
"""
@spec render([map()], keyword()) :: iodata()
def render(profile, opts \\ []) when is_list(profile) do
cleaned = clean_profile(profile)
parcel = Keyword.get(opts, :parcel_trace, [])
critical = Keyword.get(opts, :critical_levels, [])
plot_w = @right - @left
plot_h = @bottom - @top
[
~s|"
]
end
# ── Wet-bulb / virtual-T traces (NOAA SPC supplementary lines) ─────
defp wet_bulb_trace([]), do: ""
defp wet_bulb_trace(profile) do
pts =
profile
|> Enum.sort_by(& &1.pres, :desc)
|> Enum.flat_map(fn lvl ->
case SkewtParams.wet_bulb_c(%{
tmpc: lvl.tmpc,
dwpc: lvl.dwpc,
pres: lvl.pres
}) do
nil -> []
t_w -> [{temperature_x(t_w, pressure_y(lvl.pres)), pressure_y(lvl.pres)}]
end
end)
|> clip_polyline()
polyline_or_empty(pts, "wet-bulb-line")
end
defp virtual_temp_trace([]), do: ""
defp virtual_temp_trace(profile) do
pts =
profile
|> Enum.sort_by(& &1.pres, :desc)
|> Enum.map(fn lvl ->
t_v = SkewtParams.virtual_temp_c(lvl.tmpc, lvl.dwpc, lvl.pres)
y = pressure_y(lvl.pres)
{temperature_x(t_v, y), y}
end)
|> clip_polyline()
polyline_or_empty(pts, "virtual-temp-line")
end
# Parcel ascent uses the saturated mixing ratio at its own
# temperature above the LCL — that's the virtual-T trajectory used
# in the actual CAPE integration. SPC draws this as a thin red
# dashed line slightly to the right of the dry-bulb parcel trace.
defp parcel_virtual_trace([]), do: ""
defp parcel_virtual_trace(trace) do
pts =
trace
|> Enum.sort_by(& &1.pres, :desc)
|> Enum.map(fn %{pres: p, tmpc: t} ->
t_v = SkewtParams.virtual_temp_c(t, p)
y = pressure_y(p)
{temperature_x(t_v, y), y}
end)
|> clip_polyline()
polyline_or_empty(pts, "parcel-virtual-line")
end
# Critical-level annotations on the right edge — each entry is a
# `%{label, pressure_mb}` map; the label appears outside the frame
# at the right so it doesn't obscure the trace, with a short
# in-plot tick. Mirrors SPC's `143 mb`, `158 mb (mix)`, etc. rail.
defp critical_level_markers([]), do: ""
# Walk the levels in y order and bump any text whose baseline would
# land within @critical_label_min_dy of the previous one. The tick
# always sits at the level's true y; only the label gets nudged with
# a thin leader so close levels (e.g. LCL/LFC, 0 °C/WBZ) read cleanly.
defp critical_level_markers(levels) do
levels
|> Enum.flat_map(fn
%{label: label, pressure_mb: p} when is_number(p) ->
y = pressure_y(p)
if y >= @top and y <= @bottom, do: [{label, p, y}], else: []
_ ->
[]
end)
|> Enum.sort_by(fn {_lbl, _p, y} -> y end)
|> Enum.map_reduce(@top * 1.0, fn {label, p, y}, last_text_y ->
text_y = max(y, last_text_y + @critical_label_min_dy)
iodata = render_critical_marker(label, p, y, text_y)
{iodata, text_y}
end)
|> elem(0)
end
defp render_critical_marker(label, pressure_mb, y, text_y) do
leader =
if abs(text_y - y) > 0.5 do
~s||
else
""
end
[
~s||,
leader,
~s|#{label} #{round(pressure_mb)} mb|
]
end
# Surface-parcel ascent line (dashed grey), matching the SPC viewer's
# convention. The trace is a list of `%{pres, tmpc}` from surface up
# through every level above the LCL.
defp parcel_trace_line([]), do: ""
defp parcel_trace_line(trace) do
pts =
trace
|> Enum.sort_by(& &1.pres, :desc)
|> Enum.map(fn %{pres: p, tmpc: t} ->
y = pressure_y(p)
{temperature_x(t, y), y}
end)
|> clip_polyline()
case pts do
[] -> ""
_ -> ~s||
end
end
defp points_str(points) do
Enum.map_join(points, " ", fn {x, y} -> "#{r(x)},#{r(y)}" end)
end
# Crosshair layer the JS hook (`assets/js/skewt_hook.js`) drives.
# Initially `display: none`; the hook flips display + updates element
# coordinates on `mousemove`.
defp cursor_layer do
"""
"""
end
# NOAA SPC Skew-T-Log-P colour set, copied from the SPC sounding viewer
# (https://www.spc.noaa.gov/exper/soundings/) so the chart reads the
# same way as the public reference product:
#
# * Temperature trace .................. solid red (#ff0000)
# * Dewpoint trace ..................... solid green (#00aa00)
# * Isotherms .......................... dashed teal (#22d3a4-ish)
# * Dry adiabats ....................... solid orange (#f59e0b)
# * Moist (saturated) adiabats ......... dashed sky-blue (#38bdf8)
# * Mixing-ratio lines ................. dotted darker green (#16a34a),
# values 0.4–20 g/kg, drawn only
# in the lower atmosphere
# * Isobars ............................ light grey horizontal lines
defp style_block do
"""
"""
end
# ── Geometry exposed to the hover hook ──────────────────────────────
@doc """
Plot geometry constants the JS hover hook needs to invert pixel-Y
back to pressure and to project temperature values onto the skewed
diagram. Returned as a plain map so it serializes to JSON via
`Jason.encode!/1` straight onto a `data-` attribute.
"""
@spec geometry() :: map()
def geometry do
%{
width: @width,
height: @height,
left: @left,
right: @right,
top: @top,
bottom: @bottom,
p_bottom: @p_bottom,
p_top: @p_top,
t_min: @t_min,
t_max: @t_max,
skew: @skew
}
end
# ── Coordinate transforms ───────────────────────────────────────────
@doc false
def pressure_y(p) when is_number(p) do
# High pressure → bottom of plot, low pressure → top.
@bottom -
(@bottom - @top) * (:math.log(@p_bottom) - :math.log(p)) /
(:math.log(@p_bottom) - :math.log(@p_top))
end
@doc false
def temperature_x(t, y) when is_number(t) and is_number(y) do
plot_w = @right - @left
base_x = @left + (t - @t_min) / (@t_max - @t_min) * plot_w
base_x + @skew * (@bottom - y)
end
defp clamp_x(x), do: x |> max(@left) |> min(@right)
defp clip_segment({x1, y1}, {x2, y2}) do
cond do
x1 < @left and x2 < @left -> nil
x1 > @right and x2 > @right -> nil
true -> {{clamp_x(x1), y1}, {clamp_x(x2), y2}}
end
end
# ── Frame & axes ────────────────────────────────────────────────────
defp frame do
~s||
end
defp axis_labels do
pressures = [1000, 850, 700, 500, 400, 300, 200, 100]
pres_labels =
for p <- pressures do
y = pressure_y(p)
~s|#{p}|
end
temps = -40..40//10
temp_labels =
for t <- temps do
x = temperature_x(t, @bottom)
if x >= @left and x <= @right do
~s|#{t}|
else
""
end
end
[
pres_labels,
temp_labels,
~s|Pressure (mb)|,
~s|Temperature (°C)|
]
end
# ── Background grid ─────────────────────────────────────────────────
defp isobars do
pressures = [1000, 925, 850, 700, 500, 400, 300, 250, 200, 150, 100]
for p <- pressures do
y = pressure_y(p)
cls = if p in [1000, 850, 700, 500, 300, 200, 100], do: "grid-major", else: "grid-minor"
~s||
end
end
defp isotherms do
# Skewed isotherms every 10°C across the plot. Each line goes from
# (T at bottom) to (T at top), with skew already baked into
# temperature_x. Clip to the plot box.
for t <- -100..40//10 do
render_isotherm(t)
end
end
defp render_isotherm(t) do
p1 = {temperature_x(t, @bottom), @bottom}
p2 = {temperature_x(t, @top), @top}
case clip_segment(p1, p2) do
nil -> ""
clipped -> isotherm_with_label(t, clipped)
end
end
defp isotherm_with_label(t, {{x1, y1}, {x2, y2}}) do
line = ~s||
[line, isotherm_label(t, x1)]
end
defp isotherm_label(t, x1) when t in [-40, -20, 0, 20, 40] do
if x1 > @left + 2 and x1 < @right - 12 do
~s|#{t}°|
else
""
end
end
defp isotherm_label(_t, _x1), do: ""
defp dry_adiabats do
# Potential-temperature curves: T_K(p) = θ * (p / 1000)^(R/cp).
# Sample every theta = -30..150°C every 10° at 50-mb pressure steps.
for theta_c <- -30..150//10 do
theta_k = theta_c + 273.15
pts =
sample_pressures()
|> Enum.map(fn p ->
t_k = theta_k * :math.pow(p / 1000.0, 0.2854)
t_c = t_k - 273.15
y = pressure_y(p)
x = temperature_x(t_c, y)
{x, y}
end)
|> clip_polyline()
polyline_or_empty(pts, "grid-dry")
end
end
defp moist_adiabats do
# Saturated pseudo-adiabats — quick approximation by integrating
# dT/dp via the moist-adiabatic lapse rate. Good enough to put
# green dashes in the right place; not used for parcel ascent.
for theta_e_c <- 0..40//4 do
pts =
(theta_e_c + 273.15)
|> moist_adiabat_curve()
|> Enum.map(fn {p, t_c} ->
y = pressure_y(p)
x = temperature_x(t_c, y)
{x, y}
end)
|> clip_polyline()
polyline_or_empty(pts, "grid-moist")
end
end
defp moist_adiabat_curve(theta_e_k) do
# Integrate from 1000 mb upward using dT/dz = Γ_m and dp/dz from
# hydrostatic. Cheap approximation good enough for plotting.
starting_t = theta_e_k - 273.15
sample_pressures()
|> Enum.reduce({[], starting_t, 1000.0}, fn p, {acc, t, prev_p} ->
dp = p - prev_p
# Δz from hydrostatic with mean T_K ≈ t + 273
mean_t_k = max(t + 273.15, 200.0)
dz = -dp * 287.0 * mean_t_k / (9.81 * p)
gamma_m = moist_lapse_c_per_m(t, p)
new_t = t + dz * gamma_m
{[{p, new_t} | acc], new_t, p}
end)
|> elem(0)
|> Enum.reverse()
end
defp moist_lapse_c_per_m(t_c, p_mb) do
t_k = t_c + 273.15
# Saturation mixing ratio (g/g) via Buck's eq.
es = 6.1121 * :math.exp((18.678 - t_c / 234.5) * (t_c / (257.14 + t_c)))
qs = 0.622 * es / (p_mb - es)
lv = 2.5e6
cp = 1004.0
rd = 287.0
rv = 461.0
g = 9.81
num = g * (1.0 + lv * qs / (rd * t_k))
den = cp + lv * lv * qs / (rv * t_k * t_k)
-num / den
end
# NOAA SPC mixing-ratio set, in g/kg. Same values quoted along the
# bottom of the SPC sounding charts (0.4 / 0.7 / 1 / 2 / 3 / 5 / 8 /
# 12 / 16 / 20). Lines drawn from the surface up to 600 mb only —
# above that the saturation values get unhelpfully close together.
@mixing_ratios [0.4, 0.7, 1, 2, 3, 5, 8, 12, 16, 20]
@mix_top_pres 600.0
@mix_label_pres 700.0
defp mixing_ratio_lines do
Enum.map(@mixing_ratios, fn w ->
pts =
sample_pressures()
|> Enum.filter(&(&1 >= @mix_top_pres))
|> Enum.map(fn p ->
# Solve for T from w = 0.622 * e_s(T) / (p - e_s).
e = w / 1000.0 * p / (0.622 + w / 1000.0)
t_d = dewpoint_from_vapor(e)
y = pressure_y(p)
x = temperature_x(t_d, y)
{x, y}
end)
|> clip_polyline()
label = mix_label(w)
[polyline_or_empty(pts, "grid-mix"), label]
end)
end
# Anchor each mixing-ratio line with its g/kg value at ~700 mb,
# mirroring SPC's row of green numbers along the diagram's lower band.
defp mix_label(w) do
e = w / 1000.0 * @mix_label_pres / (0.622 + w / 1000.0)
t_d = dewpoint_from_vapor(e)
y = pressure_y(@mix_label_pres)
x = temperature_x(t_d, y)
if x >= @left and x <= @right do
label =
if w >= 1 do
w |> trunc() |> Integer.to_string()
else
:erlang.float_to_binary(w * 1.0, decimals: 1)
end
~s|#{label}|
else
""
end
end
# Inverse Buck — find T given vapor pressure e (hPa).
defp dewpoint_from_vapor(e) when e <= 0, do: -80.0
defp dewpoint_from_vapor(e) do
# Magnus form: T = 243.5 * ln(e/6.112) / (17.67 - ln(e/6.112))
ratio = :math.log(e / 6.112)
243.5 * ratio / (17.67 - ratio)
end
defp sample_pressures do
# 1050 down to 100 in 25-mb steps.
100..1050//25 |> Enum.to_list() |> Enum.reverse()
end
defp clip_polyline(points) do
Enum.filter(points, fn {x, y} -> x >= @left - 200 and x <= @right + 200 and y >= @top and y <= @bottom end)
end
defp polyline_or_empty([], _cls), do: ""
defp polyline_or_empty(points, cls) do
pts =
Enum.map_join(points, " ", fn {x, y} -> "#{r(x)},#{r(y)}" end)
~s||
end
# ── Profile traces ──────────────────────────────────────────────────
defp profile_traces([]), do: ""
defp profile_traces(profile) do
sorted = Enum.sort_by(profile, & &1.pres, :desc)
temp_pts =
sorted
|> Enum.map(fn p ->
y = pressure_y(p.pres)
x = temperature_x(p.tmpc, y)
{x, y}
end)
|> Enum.filter(fn {_x, y} -> y >= @top and y <= @bottom end)
dew_pts =
sorted
|> Enum.filter(& &1.dwpc)
|> Enum.map(fn p ->
y = pressure_y(p.pres)
x = temperature_x(p.dwpc, y)
{x, y}
end)
|> Enum.filter(fn {_x, y} -> y >= @top and y <= @bottom end)
[
polyline_or_empty(dew_pts, "dew-line"),
polyline_or_empty(temp_pts, "temp-line")
]
end
defp clean_profile(profile) do
profile
|> Enum.map(&normalize_level/1)
|> Enum.filter(&(&1.pres && &1.tmpc))
end
defp normalize_level(%{} = level) do
%{
pres: pick_num(level, ["pres", "pres_mb", :pres, :pres_mb]),
hght: pick_num(level, ["hght", "hght_m", :hght, :hght_m]),
tmpc: pick_num(level, ["tmpc", :tmpc]),
dwpc: pick_num(level, ["dwpc", :dwpc])
}
end
defp pick_num(map, keys) do
keys
|> Enum.find_value(fn k -> Map.get(map, k) end)
|> coerce_num()
end
defp coerce_num(n) when is_number(n), do: n * 1.0
defp coerce_num(_), do: nil
defp r(n) when is_float(n), do: Float.round(n, 1)
defp r(n), do: n
end