prop/lib/microwaveprop_web/live/skewt_location_resolver.ex
Graham McIntire e40ade3b19
feat(skewt): add /skewt LiveView with HRRR-backed Skew-T-Log-P plot
Single search bar accepts an address, Maidenhead grid square, or
callsign; resolves it to lat/lon via Geocoder / Maidenhead /
CallsignLocation (cheapest classification first), snaps to the HRRR
grid via the existing ProfilesFile reader, and renders an SVG
Skew-T-Log-P with isobars, skewed isotherms, dry/moist adiabats, and
mixing-ratio lines. A button row picks between every available
forecast hour (now → f18); default is the most recent analysis.
Right pane lists derived stability and refractivity stats from
SoundingParams.derive (PWAT, BL depth, K-index, lifted index, min
dN/dh, ducting status + per-duct base/top/ΔM).

Renderer is server-side SVG so the page works without JS and serializes
into the LiveView payload as a single tag. Wind barbs are deliberately
omitted — HRRR's persisted profile carries wind only at 10 m AGL.
2026-04-25 12:13:46 -05:00

91 lines
2.6 KiB
Elixir

defmodule MicrowavepropWeb.SkewtLocationResolver do
@moduledoc """
Turn a single search-bar string ("EM12kp", "W5ISP", or
"Plano, TX") into a `{lat, lon, label}` triple for the Skew-T
page. Tries the cheapest classification first (grid → callsign →
geocoder) so a typo on a grid square doesn't burn a Google API
request before the user has a chance to correct it.
"""
alias Microwaveprop.CallsignLocation
alias Microwaveprop.Geocoder
alias Microwaveprop.Radio.Maidenhead
@grid_regex ~r/^[A-Ra-r]{2}[0-9]{2}([A-Xa-x]{2}([0-9]{2}([A-Xa-x]{2})?)?)?$/
@callsign_regex ~r/^[A-Z0-9]{1,3}[0-9][A-Z]{1,4}$/
@type classification :: :grid | :callsign | :address
@type result :: %{
lat: float(),
lon: float(),
label: String.t(),
source: classification()
}
@spec classify(String.t()) :: classification()
def classify(query) when is_binary(query) do
cleaned = query |> String.trim() |> String.upcase()
cond do
Regex.match?(@grid_regex, cleaned) -> :grid
Regex.match?(@callsign_regex, cleaned) -> :callsign
true -> :address
end
end
@spec resolve(String.t()) :: {:ok, result()} | {:error, String.t()}
def resolve(query) when is_binary(query) do
cleaned = String.trim(query)
case classify(cleaned) do
:grid -> resolve_grid(cleaned)
:callsign -> resolve_callsign(cleaned)
:address -> resolve_address(cleaned)
end
end
defp resolve_grid(query) do
case Maidenhead.to_latlon(query) do
{:ok, {lat, lon}} ->
{:ok, %{lat: lat, lon: lon, label: String.upcase(query), source: :grid}}
:error ->
{:error, "could not parse '#{query}' as a Maidenhead grid square"}
end
end
defp resolve_callsign(query) do
upper = String.upcase(query)
case CallsignLocation.lookup(upper) do
{:ok, %{latitude: lat, longitude: lon, gridsquare: grid, name: name}} ->
label =
[upper, name, grid]
|> Enum.reject(&(is_nil(&1) or &1 == ""))
|> Enum.uniq()
|> Enum.join("")
{:ok, %{lat: lat, lon: lon, label: label, source: :callsign}}
{:error, reason} when is_binary(reason) ->
{:error, "callsign lookup failed: #{reason}"}
{:error, _} ->
{:error, "callsign lookup failed"}
end
end
defp resolve_address(query) do
case Geocoder.geocode(query) do
{:ok, %{lat: lat, lon: lon}} ->
{:ok, %{lat: lat, lon: lon, label: query, source: :address}}
{:error, reason} when is_binary(reason) ->
{:error, "geocode failed: #{reason}"}
{:error, _} ->
{:error, "geocode failed"}
end
end
end