refactor: extract ProfileLookup module from weather.ex (2072→1696 lines, -376)
This commit is contained in:
parent
695bbf5cee
commit
5eeac23e83
6 changed files with 940 additions and 1109 deletions
|
|
@ -11,12 +11,12 @@ defmodule Microwaveprop.Weather do
|
||||||
alias Microwaveprop.Repo
|
alias Microwaveprop.Repo
|
||||||
alias Microwaveprop.Weather.GefsProfile
|
alias Microwaveprop.Weather.GefsProfile
|
||||||
alias Microwaveprop.Weather.GridCache
|
alias Microwaveprop.Weather.GridCache
|
||||||
alias Microwaveprop.Weather.HrrrClient
|
|
||||||
alias Microwaveprop.Weather.HrrrNativeProfile
|
alias Microwaveprop.Weather.HrrrNativeProfile
|
||||||
alias Microwaveprop.Weather.HrrrProfile
|
alias Microwaveprop.Weather.HrrrProfile
|
||||||
alias Microwaveprop.Weather.IemClient
|
alias Microwaveprop.Weather.IemClient
|
||||||
alias Microwaveprop.Weather.IemreObservation
|
alias Microwaveprop.Weather.IemreObservation
|
||||||
alias Microwaveprop.Weather.NarrProfile
|
alias Microwaveprop.Weather.NarrProfile
|
||||||
|
alias Microwaveprop.Weather.ProfileLookup
|
||||||
alias Microwaveprop.Weather.ScalarFile
|
alias Microwaveprop.Weather.ScalarFile
|
||||||
alias Microwaveprop.Weather.SolarIndex
|
alias Microwaveprop.Weather.SolarIndex
|
||||||
alias Microwaveprop.Weather.Sounding
|
alias Microwaveprop.Weather.Sounding
|
||||||
|
|
@ -30,6 +30,26 @@ defmodule Microwaveprop.Weather do
|
||||||
# Approximate km per degree latitude
|
# Approximate km per degree latitude
|
||||||
@km_per_deg_lat 111.0
|
@km_per_deg_lat 111.0
|
||||||
|
|
||||||
|
# Profile-lookup delegates — implementations live in ProfileLookup
|
||||||
|
defdelegate find_nearest_hrrr(lat, lon, timestamp), to: ProfileLookup
|
||||||
|
defdelegate hrrr_profiles_for_path(contact), to: ProfileLookup
|
||||||
|
defdelegate hrrr_profiles_for_contacts(contacts), to: ProfileLookup
|
||||||
|
defdelegate find_nearest_native_profile(lat, lon, timestamp), to: ProfileLookup
|
||||||
|
defdelegate narr_profiles_for_path(contact), to: ProfileLookup
|
||||||
|
defdelegate find_nearest_narr(lat, lon, timestamp), to: ProfileLookup
|
||||||
|
defdelegate best_profile_for_contact(contact), to: ProfileLookup
|
||||||
|
defdelegate hrrr_for_contact(contact), to: ProfileLookup
|
||||||
|
defdelegate narr_for_contact(contact), to: ProfileLookup
|
||||||
|
defdelegate hrrr_data_fully_present?(contact), to: ProfileLookup
|
||||||
|
defdelegate has_hrrr_profile?(lat, lon, valid_time), to: ProfileLookup
|
||||||
|
defdelegate hrrr_points_present_batch(points), to: ProfileLookup
|
||||||
|
defdelegate iemre_for_contact(contact), to: ProfileLookup
|
||||||
|
defdelegate iemre_for_path(contact), to: ProfileLookup
|
||||||
|
defdelegate find_nearest_iemre(lat, lon, timestamp), to: ProfileLookup
|
||||||
|
defdelegate round_to_hrrr_grid(lat, lon), to: ProfileLookup
|
||||||
|
defdelegate round_to_iemre_grid(lat, lon), to: ProfileLookup
|
||||||
|
defdelegate purge_grid_point_profiles(), to: ProfileLookup
|
||||||
|
|
||||||
@spec find_or_create_station(map()) :: {:ok, Station.t()} | {:error, Ecto.Changeset.t()}
|
@spec find_or_create_station(map()) :: {:ok, Station.t()} | {:error, Ecto.Changeset.t()}
|
||||||
def find_or_create_station(attrs) do
|
def find_or_create_station(attrs) do
|
||||||
code = attrs[:station_code] || attrs["station_code"]
|
code = attrs[:station_code] || attrs["station_code"]
|
||||||
|
|
@ -1530,68 +1550,6 @@ defmodule Microwaveprop.Weather do
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec has_hrrr_profile?(float(), float(), DateTime.t()) :: boolean()
|
|
||||||
def has_hrrr_profile?(lat, lon, valid_time) do
|
|
||||||
dlat = 0.07
|
|
||||||
dlon = 0.07
|
|
||||||
|
|
||||||
HrrrProfile
|
|
||||||
|> where(
|
|
||||||
[h],
|
|
||||||
h.lat >= ^(lat - dlat) and h.lat <= ^(lat + dlat) and
|
|
||||||
h.lon >= ^(lon - dlon) and h.lon <= ^(lon + dlon) and
|
|
||||||
h.valid_time == ^valid_time
|
|
||||||
)
|
|
||||||
|> Repo.exists?()
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Returns `true` when every path point for `contact` (pos1 → midpoint → pos2,
|
|
||||||
or just pos1 for one-endpoint contacts) already has an HRRR profile at the
|
|
||||||
nearest HRRR hour. Lets the enrichment enqueuer skip :queued → :queued
|
|
||||||
churn when the backing data is already present.
|
|
||||||
|
|
||||||
Returns `false` if `pos1` or `qso_timestamp` is nil — a contact with no
|
|
||||||
position or no timestamp can't be looked up.
|
|
||||||
"""
|
|
||||||
@spec hrrr_data_fully_present?(
|
|
||||||
Contact.t()
|
|
||||||
| %{
|
|
||||||
required(:pos1) => term(),
|
|
||||||
required(:qso_timestamp) => DateTime.t() | nil,
|
|
||||||
optional(:pos2) => term()
|
|
||||||
}
|
|
||||||
) :: boolean()
|
|
||||||
def hrrr_data_fully_present?(%{pos1: nil}), do: false
|
|
||||||
def hrrr_data_fully_present?(%{qso_timestamp: nil}), do: false
|
|
||||||
|
|
||||||
def hrrr_data_fully_present?(contact) do
|
|
||||||
rounded = HrrrClient.nearest_hrrr_hour(contact.qso_timestamp)
|
|
||||||
|
|
||||||
case Radio.contact_path_points(contact) do
|
|
||||||
[] ->
|
|
||||||
false
|
|
||||||
|
|
||||||
points ->
|
|
||||||
Enum.all?(points, fn {lat, lon} ->
|
|
||||||
{rlat, rlon} = round_to_hrrr_grid(lat, lon)
|
|
||||||
has_hrrr_profile?(rlat, rlon, rounded)
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
@spec hrrr_for_contact(map()) :: HrrrProfile.t() | nil
|
|
||||||
def hrrr_for_contact(%{pos1: nil}), do: nil
|
|
||||||
|
|
||||||
def hrrr_for_contact(contact) do
|
|
||||||
lat = contact.pos1["lat"]
|
|
||||||
lon = contact.pos1["lon"]
|
|
||||||
|
|
||||||
if lat && lon do
|
|
||||||
find_nearest_hrrr(lat, lon, contact.qso_timestamp)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Returns just `hrrr_native_profiles.best_duct_band_ghz` near the
|
Returns just `hrrr_native_profiles.best_duct_band_ghz` near the
|
||||||
given cell. Convenience wrapper around `nearest_native_duct_info/3`
|
given cell. Convenience wrapper around `nearest_native_duct_info/3`
|
||||||
|
|
@ -1698,160 +1656,6 @@ defmodule Microwaveprop.Weather do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec find_nearest_hrrr(float(), float(), DateTime.t()) :: HrrrProfile.t() | nil
|
|
||||||
def find_nearest_hrrr(lat, lon, timestamp) do
|
|
||||||
dlat = 0.07
|
|
||||||
dlon = 0.07
|
|
||||||
time_start = DateTime.add(timestamp, -3600, :second)
|
|
||||||
time_end = DateTime.add(timestamp, 3600, :second)
|
|
||||||
|
|
||||||
HrrrProfile
|
|
||||||
|> where(
|
|
||||||
[h],
|
|
||||||
h.lat >= ^(lat - dlat) and h.lat <= ^(lat + dlat) and
|
|
||||||
h.lon >= ^(lon - dlon) and h.lon <= ^(lon + dlon) and
|
|
||||||
h.valid_time >= ^time_start and h.valid_time <= ^time_end
|
|
||||||
)
|
|
||||||
|> order_by([h],
|
|
||||||
asc:
|
|
||||||
fragment(
|
|
||||||
"ABS(? - ?) + ABS(? - ?) + ABS(EXTRACT(EPOCH FROM ? - ?))",
|
|
||||||
h.lat,
|
|
||||||
^lat,
|
|
||||||
h.lon,
|
|
||||||
^lon,
|
|
||||||
h.valid_time,
|
|
||||||
^timestamp
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|> limit(1)
|
|
||||||
|> Repo.one()
|
|
||||||
end
|
|
||||||
|
|
||||||
@spec hrrr_profiles_for_path(map()) :: [HrrrProfile.t()]
|
|
||||||
def hrrr_profiles_for_path(%{pos1: nil}), do: []
|
|
||||||
|
|
||||||
def hrrr_profiles_for_path(contact) do
|
|
||||||
contact
|
|
||||||
|> Radio.contact_path_points()
|
|
||||||
|> Enum.map(fn {lat, lon} -> find_nearest_hrrr(lat, lon, contact.qso_timestamp) end)
|
|
||||||
|> Enum.reject(&is_nil/1)
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Batch version of `hrrr_profiles_for_path/1`. Queries all HRRR profiles
|
|
||||||
for a list of contacts in a single DB round-trip, then maps results
|
|
||||||
back per-contact. Returns a list of `{contact, [profile]}` tuples.
|
|
||||||
"""
|
|
||||||
@spec hrrr_profiles_for_contacts([map()]) :: [{map(), [HrrrProfile.t()]}]
|
|
||||||
def hrrr_profiles_for_contacts(contacts) do
|
|
||||||
for_result =
|
|
||||||
for c <- contacts, c.pos1 != nil, {lat, lon} <- Radio.contact_path_points(c) do
|
|
||||||
{{lat, lon, c.qso_timestamp}, c}
|
|
||||||
end
|
|
||||||
|
|
||||||
points =
|
|
||||||
Enum.uniq_by(for_result, fn {key, _} -> key end)
|
|
||||||
|
|
||||||
{keys, contact_map} =
|
|
||||||
Enum.reduce(points, {[], %{}}, fn {{lat, lon, ts} = key, c}, {ks, cm} ->
|
|
||||||
{[key | ks], Map.update(cm, c, [key], &[key | &1])}
|
|
||||||
end)
|
|
||||||
|
|
||||||
index = find_nearest_hrrrs_batch(keys)
|
|
||||||
|
|
||||||
Enum.map(contacts, fn c ->
|
|
||||||
point_keys = Map.get(contact_map, c, [])
|
|
||||||
profiles = point_keys |> Enum.map(&Map.get(index, &1)) |> Enum.reject(&is_nil/1)
|
|
||||||
{c, profiles}
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Batch nearest-HRRR lookup: query all profiles in the union bounding
|
|
||||||
# box once, then match each point to its nearest profile in Elixir.
|
|
||||||
@spec find_nearest_hrrrs_batch([{float(), float(), DateTime.t()}]) :: %{
|
|
||||||
{float(), float(), DateTime.t()} => HrrrProfile.t()
|
|
||||||
}
|
|
||||||
defp find_nearest_hrrrs_batch([]), do: %{}
|
|
||||||
|
|
||||||
defp find_nearest_hrrrs_batch(points) do
|
|
||||||
d = 0.07
|
|
||||||
margin_s = 3600
|
|
||||||
|
|
||||||
{lats, lons, times} = Enum.unzip(points)
|
|
||||||
lat_min = Enum.min(lats) - d
|
|
||||||
lat_max = Enum.max(lats) + d
|
|
||||||
lon_min = Enum.min(lons) - d
|
|
||||||
lon_max = Enum.max(lons) + d
|
|
||||||
time_min = times |> Enum.min_by(&DateTime.to_unix/1) |> DateTime.add(-margin_s, :second)
|
|
||||||
time_max = times |> Enum.max_by(&DateTime.to_unix/1) |> DateTime.add(margin_s, :second)
|
|
||||||
|
|
||||||
profiles =
|
|
||||||
Repo.all(
|
|
||||||
from(h in HrrrProfile,
|
|
||||||
where:
|
|
||||||
h.lat >= ^lat_min and h.lat <= ^lat_max and
|
|
||||||
h.lon >= ^lon_min and h.lon <= ^lon_max and
|
|
||||||
h.valid_time >= ^time_min and h.valid_time <= ^time_max,
|
|
||||||
select: %{lat: h.lat, lon: h.lon, valid_time: h.valid_time, profile: h}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
Map.new(points, fn {lat, lon, ts} ->
|
|
||||||
nearest =
|
|
||||||
Enum.min_by(
|
|
||||||
profiles,
|
|
||||||
fn p ->
|
|
||||||
abs(p.lat - lat) + abs(p.lon - lon) + abs(DateTime.diff(p.valid_time, ts))
|
|
||||||
end,
|
|
||||||
fn -> nil end
|
|
||||||
)
|
|
||||||
|
|
||||||
{{lat, lon, ts}, nearest && nearest.profile}
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
@spec find_nearest_native_profile(float(), float(), DateTime.t()) ::
|
|
||||||
HrrrNativeProfile.t() | nil
|
|
||||||
def find_nearest_native_profile(lat, lon, timestamp) do
|
|
||||||
dlat = 0.07
|
|
||||||
dlon = 0.07
|
|
||||||
time_start = DateTime.add(timestamp, -3600, :second)
|
|
||||||
time_end = DateTime.add(timestamp, 3600, :second)
|
|
||||||
|
|
||||||
HrrrNativeProfile
|
|
||||||
|> where(
|
|
||||||
[n],
|
|
||||||
n.lat >= ^(lat - dlat) and n.lat <= ^(lat + dlat) and
|
|
||||||
n.lon >= ^(lon - dlon) and n.lon <= ^(lon + dlon) and
|
|
||||||
n.valid_time >= ^time_start and n.valid_time <= ^time_end
|
|
||||||
)
|
|
||||||
|> order_by([n],
|
|
||||||
asc:
|
|
||||||
fragment(
|
|
||||||
"ABS(? - ?) + ABS(? - ?) + ABS(EXTRACT(EPOCH FROM ? - ?))",
|
|
||||||
n.lat,
|
|
||||||
^lat,
|
|
||||||
n.lon,
|
|
||||||
^lon,
|
|
||||||
n.valid_time,
|
|
||||||
^timestamp
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|> limit(1)
|
|
||||||
|> Repo.one()
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Find the best available atmospheric profile for a contact.
|
|
||||||
Tries HRRR first (3 km, hourly), falls back to NARR (32 km, 3-hourly).
|
|
||||||
Returns the profile struct or nil.
|
|
||||||
"""
|
|
||||||
@spec best_profile_for_contact(map()) :: HrrrProfile.t() | NarrProfile.t() | nil
|
|
||||||
def best_profile_for_contact(contact) do
|
|
||||||
hrrr_for_contact(contact) || narr_for_contact(contact)
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc "Find all atmospheric profiles along a contact's path, from any source."
|
@doc "Find all atmospheric profiles along a contact's path, from any source."
|
||||||
@spec profiles_along_path(map()) :: [HrrrProfile.t() | NarrProfile.t()]
|
@spec profiles_along_path(map()) :: [HrrrProfile.t() | NarrProfile.t()]
|
||||||
def profiles_along_path(contact) do
|
def profiles_along_path(contact) do
|
||||||
|
|
@ -1864,61 +1668,6 @@ defmodule Microwaveprop.Weather do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec narr_for_contact(map()) :: NarrProfile.t() | nil
|
|
||||||
def narr_for_contact(%{pos1: nil}), do: nil
|
|
||||||
|
|
||||||
def narr_for_contact(contact) do
|
|
||||||
lat = contact.pos1["lat"]
|
|
||||||
lon = contact.pos1["lon"]
|
|
||||||
|
|
||||||
if lat && lon do
|
|
||||||
find_nearest_narr(lat, lon, contact.qso_timestamp)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
@spec narr_profiles_for_path(map()) :: [NarrProfile.t()]
|
|
||||||
def narr_profiles_for_path(%{pos1: nil}), do: []
|
|
||||||
|
|
||||||
def narr_profiles_for_path(contact) do
|
|
||||||
contact
|
|
||||||
|> Radio.contact_path_points()
|
|
||||||
|> Enum.map(fn {lat, lon} -> find_nearest_narr(lat, lon, contact.qso_timestamp) end)
|
|
||||||
|> Enum.reject(&is_nil/1)
|
|
||||||
end
|
|
||||||
|
|
||||||
@spec find_nearest_narr(float(), float(), DateTime.t()) :: NarrProfile.t() | nil
|
|
||||||
def find_nearest_narr(lat, lon, timestamp) do
|
|
||||||
dlat = 0.15
|
|
||||||
dlon = 0.15
|
|
||||||
time_start = DateTime.add(timestamp, -1800, :second)
|
|
||||||
time_end = DateTime.add(timestamp, 1800, :second)
|
|
||||||
|
|
||||||
NarrProfile
|
|
||||||
|> where(
|
|
||||||
[p],
|
|
||||||
p.lat >= ^(lat - dlat) and p.lat <= ^(lat + dlat) and
|
|
||||||
p.lon >= ^(lon - dlon) and p.lon <= ^(lon + dlon) and
|
|
||||||
p.valid_time >= ^time_start and p.valid_time <= ^time_end
|
|
||||||
)
|
|
||||||
|> order_by([p],
|
|
||||||
asc:
|
|
||||||
fragment(
|
|
||||||
"ABS(? - ?) + ABS(? - ?)",
|
|
||||||
p.lat,
|
|
||||||
^lat,
|
|
||||||
p.lon,
|
|
||||||
^lon
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|> limit(1)
|
|
||||||
|> Repo.one()
|
|
||||||
end
|
|
||||||
|
|
||||||
@spec round_to_hrrr_grid(float(), float()) :: {float(), float()}
|
|
||||||
def round_to_hrrr_grid(lat, lon) do
|
|
||||||
{Float.round(lat / 1.0, 2), Float.round(lon / 1.0, 2)}
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Delete every `is_grid_point = true` row from `hrrr_profiles`, regardless of
|
Delete every `is_grid_point = true` row from `hrrr_profiles`, regardless of
|
||||||
age. Grid-point profiles are historical — the propagation grid now lives in
|
age. Grid-point profiles are historical — the propagation grid now lives in
|
||||||
|
|
@ -1928,51 +1677,6 @@ defmodule Microwaveprop.Weather do
|
||||||
Preserves QSO-linked rows (`is_grid_point = false`), which remain the data
|
Preserves QSO-linked rows (`is_grid_point = false`), which remain the data
|
||||||
path for contact enrichment and the `/path` calculator.
|
path for contact enrichment and the `/path` calculator.
|
||||||
"""
|
"""
|
||||||
@spec purge_grid_point_profiles() :: non_neg_integer()
|
|
||||||
def purge_grid_point_profiles do
|
|
||||||
deleted =
|
|
||||||
Enum.reduce(hrrr_profile_partitions(), 0, fn partition, acc ->
|
|
||||||
%{num_rows: n} =
|
|
||||||
Repo.query!(
|
|
||||||
~s(DELETE FROM "#{partition}" WHERE is_grid_point = true),
|
|
||||||
[],
|
|
||||||
timeout: 600_000
|
|
||||||
)
|
|
||||||
|
|
||||||
if n > 0 do
|
|
||||||
Logger.info("Purged #{n} grid-point rows from #{partition}")
|
|
||||||
end
|
|
||||||
|
|
||||||
acc + n
|
|
||||||
end)
|
|
||||||
|
|
||||||
deleted
|
|
||||||
end
|
|
||||||
|
|
||||||
@spec hrrr_profile_partitions() :: [String.t()]
|
|
||||||
defp hrrr_profile_partitions do
|
|
||||||
{:ok, %{rows: rows}} =
|
|
||||||
Repo.query(
|
|
||||||
"""
|
|
||||||
SELECT child.relname
|
|
||||||
FROM pg_inherits
|
|
||||||
JOIN pg_class parent ON pg_inherits.inhparent = parent.oid
|
|
||||||
JOIN pg_class child ON pg_inherits.inhrelid = child.oid
|
|
||||||
WHERE parent.relname = 'hrrr_profiles'
|
|
||||||
ORDER BY child.relname
|
|
||||||
""",
|
|
||||||
[],
|
|
||||||
timeout: 60_000
|
|
||||||
)
|
|
||||||
|
|
||||||
Enum.map(rows, fn [name] -> name end)
|
|
||||||
end
|
|
||||||
|
|
||||||
@spec round_to_iemre_grid(float(), float()) :: {float(), float()}
|
|
||||||
def round_to_iemre_grid(lat, lon) do
|
|
||||||
{Float.round(lat * 8) / 8, Float.round(lon * 8) / 8}
|
|
||||||
end
|
|
||||||
|
|
||||||
@spec upsert_iemre_observation(map()) :: {:ok, IemreObservation.t()} | {:error, Ecto.Changeset.t()}
|
@spec upsert_iemre_observation(map()) :: {:ok, IemreObservation.t()} | {:error, Ecto.Changeset.t()}
|
||||||
def upsert_iemre_observation(attrs) do
|
def upsert_iemre_observation(attrs) do
|
||||||
%IemreObservation{}
|
%IemreObservation{}
|
||||||
|
|
@ -1989,36 +1693,4 @@ defmodule Microwaveprop.Weather do
|
||||||
|> where([i], i.lat == ^lat and i.lon == ^lon and i.date == ^date)
|
|> where([i], i.lat == ^lat and i.lon == ^lon and i.date == ^date)
|
||||||
|> Repo.exists?()
|
|> Repo.exists?()
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec iemre_for_contact(map()) :: IemreObservation.t() | nil
|
|
||||||
def iemre_for_contact(%{pos1: nil}), do: nil
|
|
||||||
|
|
||||||
def iemre_for_contact(contact) do
|
|
||||||
lat = contact.pos1["lat"]
|
|
||||||
lon = contact.pos1["lon"]
|
|
||||||
|
|
||||||
if lat && lon do
|
|
||||||
find_nearest_iemre(lat, lon, contact.qso_timestamp)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
@spec find_nearest_iemre(float(), float(), DateTime.t()) :: IemreObservation.t() | nil
|
|
||||||
def find_nearest_iemre(lat, lon, timestamp) do
|
|
||||||
{rlat, rlon} = round_to_iemre_grid(lat, lon)
|
|
||||||
date = DateTime.to_date(timestamp)
|
|
||||||
|
|
||||||
IemreObservation
|
|
||||||
|> where([i], i.lat == ^rlat and i.lon == ^rlon and i.date == ^date)
|
|
||||||
|> Repo.one()
|
|
||||||
end
|
|
||||||
|
|
||||||
@spec iemre_for_path(map()) :: [IemreObservation.t()]
|
|
||||||
def iemre_for_path(%{pos1: nil}), do: []
|
|
||||||
|
|
||||||
def iemre_for_path(contact) do
|
|
||||||
contact
|
|
||||||
|> Radio.contact_path_points()
|
|
||||||
|> Enum.map(fn {lat, lon} -> find_nearest_iemre(lat, lon, contact.qso_timestamp) end)
|
|
||||||
|> Enum.reject(&is_nil/1)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
|
||||||
428
lib/microwaveprop/weather/profile_lookup.ex
Normal file
428
lib/microwaveprop/weather/profile_lookup.ex
Normal file
|
|
@ -0,0 +1,428 @@
|
||||||
|
defmodule Microwaveprop.Weather.ProfileLookup do
|
||||||
|
@moduledoc """
|
||||||
|
Profile-lookup functions extracted from `Microwaveprop.Weather` to reduce the
|
||||||
|
size of the main module. Each function is accessed via `defdelegate` on
|
||||||
|
`Microwaveprop.Weather`, so all existing callers continue to work unchanged.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import Ecto.Query
|
||||||
|
|
||||||
|
alias Microwaveprop.Radio
|
||||||
|
alias Microwaveprop.Repo
|
||||||
|
alias Microwaveprop.Weather.HrrrClient
|
||||||
|
alias Microwaveprop.Weather.HrrrNativeProfile
|
||||||
|
alias Microwaveprop.Weather.HrrrProfile
|
||||||
|
alias Microwaveprop.Weather.IemreObservation
|
||||||
|
alias Microwaveprop.Weather.NarrProfile
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
|
@spec has_hrrr_profile?(float(), float(), DateTime.t()) :: boolean()
|
||||||
|
def has_hrrr_profile?(lat, lon, valid_time) do
|
||||||
|
dlat = 0.07
|
||||||
|
dlon = 0.07
|
||||||
|
|
||||||
|
HrrrProfile
|
||||||
|
|> where(
|
||||||
|
[h],
|
||||||
|
h.lat >= ^(lat - dlat) and h.lat <= ^(lat + dlat) and
|
||||||
|
h.lon >= ^(lon - dlon) and h.lon <= ^(lon + dlon) and
|
||||||
|
h.valid_time == ^valid_time
|
||||||
|
)
|
||||||
|
|> Repo.exists?()
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Batch version of `has_hrrr_profile?/3`. Accepts a list of
|
||||||
|
`{{lat, lon}, valid_time}` tuples and returns a `MapSet` of the
|
||||||
|
tuples that have at least one matching HRRR profile. A single query
|
||||||
|
covers the union bounding box of all points — dramatically cheaper
|
||||||
|
than N individual `EXISTS` queries when checking hundreds of contacts.
|
||||||
|
"""
|
||||||
|
@spec hrrr_points_present_batch([{{float(), float()}, DateTime.t()}]) :: MapSet.t()
|
||||||
|
def hrrr_points_present_batch([]), do: MapSet.new()
|
||||||
|
|
||||||
|
def hrrr_points_present_batch(points) do
|
||||||
|
d = 0.07
|
||||||
|
|
||||||
|
{lat_lons, times} = Enum.unzip(points)
|
||||||
|
{lats, lons} = Enum.unzip(lat_lons)
|
||||||
|
|
||||||
|
lat_min = Enum.min(lats) - d
|
||||||
|
lat_max = Enum.max(lats) + d
|
||||||
|
lon_min = Enum.min(lons) - d
|
||||||
|
lon_max = Enum.max(lons) + d
|
||||||
|
time_min = Enum.min_by(times, &DateTime.to_unix/1)
|
||||||
|
time_max = Enum.max_by(times, &DateTime.to_unix/1)
|
||||||
|
|
||||||
|
profiles =
|
||||||
|
Repo.all(
|
||||||
|
from(h in HrrrProfile,
|
||||||
|
where:
|
||||||
|
h.lat >= ^lat_min and h.lat <= ^lat_max and
|
||||||
|
h.lon >= ^lon_min and h.lon <= ^lon_max and
|
||||||
|
h.valid_time >= ^time_min and h.valid_time <= ^time_max,
|
||||||
|
select: {h.lat, h.lon, h.valid_time}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
point_set = for {lat, lon, vt} <- profiles, into: MapSet.new(), do: {lat, lon, vt}
|
||||||
|
|
||||||
|
points
|
||||||
|
|> MapSet.new(fn {{lat, lon}, valid_time} ->
|
||||||
|
profiles
|
||||||
|
|> Enum.any?(fn {pl, pn, pvt} ->
|
||||||
|
abs(pl - lat) <= d and abs(pn - lon) <= d and pvt == valid_time
|
||||||
|
end)
|
||||||
|
|> then(fn found -> if found, do: {{lat, lon}, valid_time} end)
|
||||||
|
end)
|
||||||
|
|> Enum.reject(&is_nil/1)
|
||||||
|
|> MapSet.new()
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns `true` when every path point for `contact` (pos1 → midpoint → pos2,
|
||||||
|
or just pos1 for one-endpoint contacts) already has an HRRR profile at the
|
||||||
|
nearest HRRR hour. Lets the enrichment enqueuer skip :queued → :queued
|
||||||
|
churn when the backing data is already present.
|
||||||
|
|
||||||
|
Returns `false` if `pos1` or `qso_timestamp` is nil — a contact with no
|
||||||
|
position or no timestamp can't be looked up.
|
||||||
|
"""
|
||||||
|
@spec hrrr_data_fully_present?(term()) :: boolean()
|
||||||
|
def hrrr_data_fully_present?(%{pos1: nil}), do: false
|
||||||
|
def hrrr_data_fully_present?(%{qso_timestamp: nil}), do: false
|
||||||
|
|
||||||
|
def hrrr_data_fully_present?(contact) do
|
||||||
|
rounded = HrrrClient.nearest_hrrr_hour(contact.qso_timestamp)
|
||||||
|
|
||||||
|
case Radio.contact_path_points(contact) do
|
||||||
|
[] ->
|
||||||
|
false
|
||||||
|
|
||||||
|
points ->
|
||||||
|
Enum.all?(points, fn {lat, lon} ->
|
||||||
|
{rlat, rlon} = round_to_hrrr_grid(lat, lon)
|
||||||
|
has_hrrr_profile?(rlat, rlon, rounded)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec hrrr_for_contact(map()) :: HrrrProfile.t() | nil
|
||||||
|
def hrrr_for_contact(%{pos1: nil}), do: nil
|
||||||
|
|
||||||
|
def hrrr_for_contact(contact) do
|
||||||
|
lat = contact.pos1["lat"]
|
||||||
|
lon = contact.pos1["lon"]
|
||||||
|
|
||||||
|
if lat && lon do
|
||||||
|
find_nearest_hrrr(lat, lon, contact.qso_timestamp)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Nearest sounding to (`lat`, `lon`) within `radius_km` km and a ±3-hour
|
||||||
|
window around `timestamp`. Joins `weather_stations` to `soundings` so
|
||||||
|
the caller gets the raw sounding row (station_id set, derived duct
|
||||||
|
fields populated) back.
|
||||||
|
|
||||||
|
Returns `{:ok, sounding}` or `{:error, :not_found}`. Use this in the
|
||||||
|
path calculator to surface the nearest RAOB's `ducting_detected` flag
|
||||||
|
as an independent check on HRRR's pressure-level duct signal, which
|
||||||
|
under-reads thin surface ducts.
|
||||||
|
"""
|
||||||
|
@spec find_nearest_hrrr(float(), float(), DateTime.t()) :: HrrrProfile.t() | nil
|
||||||
|
def find_nearest_hrrr(lat, lon, timestamp) do
|
||||||
|
dlat = 0.07
|
||||||
|
dlon = 0.07
|
||||||
|
time_start = DateTime.add(timestamp, -3600, :second)
|
||||||
|
time_end = DateTime.add(timestamp, 3600, :second)
|
||||||
|
|
||||||
|
HrrrProfile
|
||||||
|
|> where(
|
||||||
|
[h],
|
||||||
|
h.lat >= ^(lat - dlat) and h.lat <= ^(lat + dlat) and
|
||||||
|
h.lon >= ^(lon - dlon) and h.lon <= ^(lon + dlon) and
|
||||||
|
h.valid_time >= ^time_start and h.valid_time <= ^time_end
|
||||||
|
)
|
||||||
|
|> order_by([h],
|
||||||
|
asc:
|
||||||
|
fragment(
|
||||||
|
"ABS(? - ?) + ABS(? - ?) + ABS(EXTRACT(EPOCH FROM ? - ?))",
|
||||||
|
h.lat,
|
||||||
|
^lat,
|
||||||
|
h.lon,
|
||||||
|
^lon,
|
||||||
|
h.valid_time,
|
||||||
|
^timestamp
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|> limit(1)
|
||||||
|
|> Repo.one()
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec hrrr_profiles_for_path(map()) :: [HrrrProfile.t()]
|
||||||
|
def hrrr_profiles_for_path(%{pos1: nil}), do: []
|
||||||
|
|
||||||
|
def hrrr_profiles_for_path(contact) do
|
||||||
|
contact
|
||||||
|
|> Radio.contact_path_points()
|
||||||
|
|> Enum.map(fn {lat, lon} -> find_nearest_hrrr(lat, lon, contact.qso_timestamp) end)
|
||||||
|
|> Enum.reject(&is_nil/1)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Batch version of `hrrr_profiles_for_path/1`. Queries all HRRR profiles
|
||||||
|
for a list of contacts in a single DB round-trip, then maps results
|
||||||
|
back per-contact. Returns a list of `{contact, [profile]}` tuples.
|
||||||
|
"""
|
||||||
|
@spec hrrr_profiles_for_contacts([map()]) :: [{map(), [HrrrProfile.t()]}]
|
||||||
|
def hrrr_profiles_for_contacts(contacts) do
|
||||||
|
for_result =
|
||||||
|
for c <- contacts, c.pos1 != nil, {lat, lon} <- Radio.contact_path_points(c) do
|
||||||
|
{{lat, lon, c.qso_timestamp}, c}
|
||||||
|
end
|
||||||
|
|
||||||
|
points =
|
||||||
|
Enum.uniq_by(for_result, fn {key, _} -> key end)
|
||||||
|
|
||||||
|
{keys, contact_map} =
|
||||||
|
Enum.reduce(points, {[], %{}}, fn {{lat, lon, ts} = key, c}, {ks, cm} ->
|
||||||
|
{[key | ks], Map.update(cm, c, [key], &[key | &1])}
|
||||||
|
end)
|
||||||
|
|
||||||
|
index = find_nearest_hrrrs_batch(keys)
|
||||||
|
|
||||||
|
Enum.map(contacts, fn c ->
|
||||||
|
point_keys = Map.get(contact_map, c, [])
|
||||||
|
profiles = point_keys |> Enum.map(&Map.get(index, &1)) |> Enum.reject(&is_nil/1)
|
||||||
|
{c, profiles}
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Batch nearest-HRRR lookup: query all profiles in the union bounding
|
||||||
|
# box once, then match each point to its nearest profile in Elixir.
|
||||||
|
@spec find_nearest_hrrrs_batch([{float(), float(), DateTime.t()}]) :: %{
|
||||||
|
{float(), float(), DateTime.t()} => HrrrProfile.t()
|
||||||
|
}
|
||||||
|
defp find_nearest_hrrrs_batch([]), do: %{}
|
||||||
|
|
||||||
|
defp find_nearest_hrrrs_batch(points) do
|
||||||
|
d = 0.07
|
||||||
|
margin_s = 3600
|
||||||
|
|
||||||
|
{lats, lons, times} = Enum.unzip(points)
|
||||||
|
lat_min = Enum.min(lats) - d
|
||||||
|
lat_max = Enum.max(lats) + d
|
||||||
|
lon_min = Enum.min(lons) - d
|
||||||
|
lon_max = Enum.max(lons) + d
|
||||||
|
time_min = times |> Enum.min_by(&DateTime.to_unix/1) |> DateTime.add(-margin_s, :second)
|
||||||
|
time_max = times |> Enum.max_by(&DateTime.to_unix/1) |> DateTime.add(margin_s, :second)
|
||||||
|
|
||||||
|
profiles =
|
||||||
|
Repo.all(
|
||||||
|
from(h in HrrrProfile,
|
||||||
|
where:
|
||||||
|
h.lat >= ^lat_min and h.lat <= ^lat_max and
|
||||||
|
h.lon >= ^lon_min and h.lon <= ^lon_max and
|
||||||
|
h.valid_time >= ^time_min and h.valid_time <= ^time_max,
|
||||||
|
select: %{lat: h.lat, lon: h.lon, valid_time: h.valid_time, profile: h}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
Map.new(points, fn {lat, lon, ts} ->
|
||||||
|
nearest =
|
||||||
|
Enum.min_by(
|
||||||
|
profiles,
|
||||||
|
fn p ->
|
||||||
|
abs(p.lat - lat) + abs(p.lon - lon) + abs(DateTime.diff(p.valid_time, ts))
|
||||||
|
end,
|
||||||
|
fn -> nil end
|
||||||
|
)
|
||||||
|
|
||||||
|
{{lat, lon, ts}, nearest && nearest.profile}
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec find_nearest_native_profile(float(), float(), DateTime.t()) ::
|
||||||
|
HrrrNativeProfile.t() | nil
|
||||||
|
def find_nearest_native_profile(lat, lon, timestamp) do
|
||||||
|
dlat = 0.07
|
||||||
|
dlon = 0.07
|
||||||
|
time_start = DateTime.add(timestamp, -3600, :second)
|
||||||
|
time_end = DateTime.add(timestamp, 3600, :second)
|
||||||
|
|
||||||
|
HrrrNativeProfile
|
||||||
|
|> where(
|
||||||
|
[n],
|
||||||
|
n.lat >= ^(lat - dlat) and n.lat <= ^(lat + dlat) and
|
||||||
|
n.lon >= ^(lon - dlon) and n.lon <= ^(lon + dlon) and
|
||||||
|
n.valid_time >= ^time_start and n.valid_time <= ^time_end
|
||||||
|
)
|
||||||
|
|> order_by([n],
|
||||||
|
asc:
|
||||||
|
fragment(
|
||||||
|
"ABS(? - ?) + ABS(? - ?) + ABS(EXTRACT(EPOCH FROM ? - ?))",
|
||||||
|
n.lat,
|
||||||
|
^lat,
|
||||||
|
n.lon,
|
||||||
|
^lon,
|
||||||
|
n.valid_time,
|
||||||
|
^timestamp
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|> limit(1)
|
||||||
|
|> Repo.one()
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Find the best available atmospheric profile for a contact.
|
||||||
|
Tries HRRR first (3 km, hourly), falls back to NARR (32 km, 3-hourly).
|
||||||
|
Returns the profile struct or nil.
|
||||||
|
"""
|
||||||
|
@spec best_profile_for_contact(map()) :: HrrrProfile.t() | NarrProfile.t() | nil
|
||||||
|
def best_profile_for_contact(contact) do
|
||||||
|
hrrr_for_contact(contact) || narr_for_contact(contact)
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec narr_for_contact(map()) :: NarrProfile.t() | nil
|
||||||
|
def narr_for_contact(%{pos1: nil}), do: nil
|
||||||
|
|
||||||
|
def narr_for_contact(contact) do
|
||||||
|
lat = contact.pos1["lat"]
|
||||||
|
lon = contact.pos1["lon"]
|
||||||
|
|
||||||
|
if lat && lon do
|
||||||
|
find_nearest_narr(lat, lon, contact.qso_timestamp)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec narr_profiles_for_path(map()) :: [NarrProfile.t()]
|
||||||
|
def narr_profiles_for_path(%{pos1: nil}), do: []
|
||||||
|
|
||||||
|
def narr_profiles_for_path(contact) do
|
||||||
|
contact
|
||||||
|
|> Radio.contact_path_points()
|
||||||
|
|> Enum.map(fn {lat, lon} -> find_nearest_narr(lat, lon, contact.qso_timestamp) end)
|
||||||
|
|> Enum.reject(&is_nil/1)
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec find_nearest_narr(float(), float(), DateTime.t()) :: NarrProfile.t() | nil
|
||||||
|
def find_nearest_narr(lat, lon, timestamp) do
|
||||||
|
dlat = 0.15
|
||||||
|
dlon = 0.15
|
||||||
|
time_start = DateTime.add(timestamp, -1800, :second)
|
||||||
|
time_end = DateTime.add(timestamp, 1800, :second)
|
||||||
|
|
||||||
|
NarrProfile
|
||||||
|
|> where(
|
||||||
|
[p],
|
||||||
|
p.lat >= ^(lat - dlat) and p.lat <= ^(lat + dlat) and
|
||||||
|
p.lon >= ^(lon - dlon) and p.lon <= ^(lon + dlon) and
|
||||||
|
p.valid_time >= ^time_start and p.valid_time <= ^time_end
|
||||||
|
)
|
||||||
|
|> order_by([p],
|
||||||
|
asc:
|
||||||
|
fragment(
|
||||||
|
"ABS(? - ?) + ABS(? - ?)",
|
||||||
|
p.lat,
|
||||||
|
^lat,
|
||||||
|
p.lon,
|
||||||
|
^lon
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|> limit(1)
|
||||||
|
|> Repo.one()
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec round_to_hrrr_grid(float(), float()) :: {float(), float()}
|
||||||
|
def round_to_hrrr_grid(lat, lon) do
|
||||||
|
{Float.round(lat / 1.0, 2), Float.round(lon / 1.0, 2)}
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Delete every `is_grid_point = true` row from `hrrr_profiles`, regardless of
|
||||||
|
age. Grid-point profiles are historical — the propagation grid now lives in
|
||||||
|
`/data/scores` binary files, nothing reads `is_grid_point = true` rows
|
||||||
|
anymore, and forecast runs no longer write them. This purge walks each
|
||||||
|
partition directly so a single DELETE can't scan the whole parent table.
|
||||||
|
Preserves QSO-linked rows (`is_grid_point = false`), which remain the data
|
||||||
|
path for contact enrichment and the `/path` calculator.
|
||||||
|
"""
|
||||||
|
@spec purge_grid_point_profiles() :: non_neg_integer()
|
||||||
|
def purge_grid_point_profiles do
|
||||||
|
deleted =
|
||||||
|
Enum.reduce(hrrr_profile_partitions(), 0, fn partition, acc ->
|
||||||
|
%{num_rows: n} =
|
||||||
|
Repo.query!(
|
||||||
|
~s(DELETE FROM "#{partition}" WHERE is_grid_point = true),
|
||||||
|
[],
|
||||||
|
timeout: 600_000
|
||||||
|
)
|
||||||
|
|
||||||
|
if n > 0 do
|
||||||
|
Logger.info("Purged #{n} grid-point rows from #{partition}")
|
||||||
|
end
|
||||||
|
|
||||||
|
acc + n
|
||||||
|
end)
|
||||||
|
|
||||||
|
deleted
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec hrrr_profile_partitions() :: [String.t()]
|
||||||
|
defp hrrr_profile_partitions do
|
||||||
|
{:ok, %{rows: rows}} =
|
||||||
|
Repo.query(
|
||||||
|
"""
|
||||||
|
SELECT child.relname
|
||||||
|
FROM pg_inherits
|
||||||
|
JOIN pg_class parent ON pg_inherits.inhparent = parent.oid
|
||||||
|
JOIN pg_class child ON pg_inherits.inhrelid = child.oid
|
||||||
|
WHERE parent.relname = 'hrrr_profiles'
|
||||||
|
ORDER BY child.relname
|
||||||
|
""",
|
||||||
|
[],
|
||||||
|
timeout: 60_000
|
||||||
|
)
|
||||||
|
|
||||||
|
Enum.map(rows, fn [name] -> name end)
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec round_to_iemre_grid(float(), float()) :: {float(), float()}
|
||||||
|
def round_to_iemre_grid(lat, lon) do
|
||||||
|
{Float.round(lat * 8) / 8, Float.round(lon * 8) / 8}
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec iemre_for_contact(map()) :: IemreObservation.t() | nil
|
||||||
|
def iemre_for_contact(%{pos1: nil}), do: nil
|
||||||
|
|
||||||
|
def iemre_for_contact(contact) do
|
||||||
|
lat = contact.pos1["lat"]
|
||||||
|
lon = contact.pos1["lon"]
|
||||||
|
|
||||||
|
if lat && lon do
|
||||||
|
find_nearest_iemre(lat, lon, contact.qso_timestamp)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec find_nearest_iemre(float(), float(), DateTime.t()) :: IemreObservation.t() | nil
|
||||||
|
def find_nearest_iemre(lat, lon, timestamp) do
|
||||||
|
{rlat, rlon} = round_to_iemre_grid(lat, lon)
|
||||||
|
date = DateTime.to_date(timestamp)
|
||||||
|
|
||||||
|
IemreObservation
|
||||||
|
|> where([i], i.lat == ^rlat and i.lon == ^rlon and i.date == ^date)
|
||||||
|
|> Repo.one()
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec iemre_for_path(map()) :: [IemreObservation.t()]
|
||||||
|
def iemre_for_path(%{pos1: nil}), do: []
|
||||||
|
|
||||||
|
def iemre_for_path(contact) do
|
||||||
|
contact
|
||||||
|
|> Radio.contact_path_points()
|
||||||
|
|> Enum.map(fn {lat, lon} -> find_nearest_iemre(lat, lon, contact.qso_timestamp) end)
|
||||||
|
|> Enum.reject(&is_nil/1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -98,17 +98,49 @@ defmodule Microwaveprop.Workers.ContactWeatherEnqueueWorker do
|
||||||
# sees 0 missing points, inserts nothing, but the status stays
|
# sees 0 missing points, inserts nothing, but the status stays
|
||||||
# :queued and the contact is picked up again next tick).
|
# :queued and the contact is picked up again next tick).
|
||||||
defp hrrr_placeholder_jobs(contacts) do
|
defp hrrr_placeholder_jobs(contacts) do
|
||||||
|
# Collect all unique HRRR grid points across all contacts, query
|
||||||
|
# presence once, then map back per-contact — avoids N+1 per-contact
|
||||||
|
# queries through hrrr_data_fully_present?/1.
|
||||||
|
{point_map, present_set} = build_hrrr_present_set(contacts)
|
||||||
|
|
||||||
Enum.flat_map(contacts, fn contact ->
|
Enum.flat_map(contacts, fn contact ->
|
||||||
cond do
|
cond do
|
||||||
is_nil(contact.pos1) -> []
|
is_nil(contact.pos1) ->
|
||||||
not Grid.contains?(contact.pos1) -> []
|
[]
|
||||||
NarrClient.in_coverage?(contact.qso_timestamp) -> []
|
|
||||||
Weather.hrrr_data_fully_present?(contact) -> []
|
not Grid.contains?(contact.pos1) ->
|
||||||
true -> [contact.id]
|
[]
|
||||||
|
|
||||||
|
NarrClient.in_coverage?(contact.qso_timestamp) ->
|
||||||
|
[]
|
||||||
|
|
||||||
|
true ->
|
||||||
|
points = Map.get(point_map, contact.id, [])
|
||||||
|
if Enum.all?(points, &MapSet.member?(present_set, &1)), do: [], else: [contact.id]
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp build_hrrr_present_set(contacts) do
|
||||||
|
contacts
|
||||||
|
|> Enum.filter(&(&1.pos1 != nil and not NarrClient.in_coverage?(&1.qso_timestamp)))
|
||||||
|
|> Enum.reduce({%{}, []}, fn c, {pm, pts} ->
|
||||||
|
rounded = HrrrClient.nearest_hrrr_hour(c.qso_timestamp)
|
||||||
|
|
||||||
|
points =
|
||||||
|
c
|
||||||
|
|> Radio.contact_path_points()
|
||||||
|
|> Enum.map(fn {lat, lon} -> {Weather.round_to_hrrr_grid(lat, lon), rounded} end)
|
||||||
|
|
||||||
|
{Map.put(pm, c.id, points), points ++ pts}
|
||||||
|
end)
|
||||||
|
|> then(fn {pm, all_pts} ->
|
||||||
|
unique = Enum.uniq(all_pts)
|
||||||
|
present = Weather.hrrr_points_present_batch(unique)
|
||||||
|
{pm, present}
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
defp mark_enrichment_statuses(contact, types, jobs_by_type) do
|
defp mark_enrichment_statuses(contact, types, jobs_by_type) do
|
||||||
ids = [contact.id]
|
ids = [contact.id]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ defmodule MicrowavepropWeb.WeatherCaMapLive do
|
||||||
|
|
||||||
alias Microwaveprop.Weather
|
alias Microwaveprop.Weather
|
||||||
alias Microwaveprop.Weather.MapLayers
|
alias Microwaveprop.Weather.MapLayers
|
||||||
|
alias MicrowavepropWeb.WeatherMapComponent
|
||||||
|
|
||||||
@layers MapLayers.all()
|
@layers MapLayers.all()
|
||||||
@default_layer MapLayers.default_id()
|
@default_layer MapLayers.default_id()
|
||||||
|
|
@ -51,7 +52,9 @@ defmodule MicrowavepropWeb.WeatherCaMapLive do
|
||||||
current_lon: lon,
|
current_lon: lon,
|
||||||
current_zoom: zoom,
|
current_zoom: zoom,
|
||||||
grid_visible: false,
|
grid_visible: false,
|
||||||
radar_visible: false
|
radar_visible: false,
|
||||||
|
band_filter: nil,
|
||||||
|
cutoff_bands: []
|
||||||
)}
|
)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -243,358 +246,18 @@ defmodule MicrowavepropWeb.WeatherCaMapLive do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@group_order ["Surface", "Upper Air", "Ducting"]
|
|
||||||
|
|
||||||
defp group_layers(layers) do
|
|
||||||
layers
|
|
||||||
|> Enum.group_by(& &1.group)
|
|
||||||
|> Enum.sort_by(fn {group, _} -> Enum.find_index(@group_order, &(&1 == group)) || 99 end)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp layer_description(layers, selected_id) do
|
|
||||||
case Enum.find(layers, &(&1.id == selected_id)) do
|
|
||||||
%{desc: desc} -> desc
|
|
||||||
_ -> nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp selected_layer_label(layers, selected_id) do
|
|
||||||
case Enum.find(layers, &(&1.id == selected_id)) do
|
|
||||||
%{label: label} -> label
|
|
||||||
_ -> ""
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp selected_layer_group_label(layers, selected_id) do
|
|
||||||
case Enum.find(layers, &(&1.id == selected_id)) do
|
|
||||||
%{group: group} -> group
|
|
||||||
_ -> ""
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~H"""
|
assigns =
|
||||||
<div class="flex w-screen h-screen overflow-hidden">
|
assign(assigns,
|
||||||
<div class="relative flex-1 min-w-0">
|
page_subtitle: "Canadian Weather Map",
|
||||||
<div
|
data_source: "hrdps",
|
||||||
id="weather-map"
|
show_cutoff_bands: false,
|
||||||
phx-hook="WeatherMap"
|
show_other_weather_link: true,
|
||||||
phx-update="ignore"
|
other_weather_path: "/weather",
|
||||||
data-layers={Jason.encode!(@layers)}
|
other_weather_label: "US Weather Map"
|
||||||
data-selected-layer={@selected_layer}
|
)
|
||||||
data-valid-times={@initial_valid_times_json}
|
|
||||||
data-selected-time={@initial_selected_time}
|
|
||||||
data-initial-lat={@initial_center_lat}
|
|
||||||
data-initial-lon={@initial_center_lon}
|
|
||||||
data-initial-zoom={@initial_zoom}
|
|
||||||
data-source="hrdps"
|
|
||||||
class="absolute inset-0 z-0"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
WeatherMapComponent.render(assigns)
|
||||||
id="weather-forecast-timeline"
|
|
||||||
phx-update="ignore"
|
|
||||||
class="absolute bottom-2 left-1/2 -translate-x-1/2 z-[1000] max-w-[calc(100vw-1rem)] md:bottom-4"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
id="weather-controls"
|
|
||||||
class="md:hidden absolute top-2 left-12 z-[1000] flex flex-col gap-2 max-w-[calc(100vw-4rem)]"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
data-theme="dark"
|
|
||||||
class="bg-neutral text-neutral-content shadow rounded-box border border-base-300 p-2 flex flex-col gap-2"
|
|
||||||
>
|
|
||||||
<div class="font-bold text-sm leading-tight px-1 flex items-center justify-between gap-2">
|
|
||||||
<div class="min-w-0">
|
|
||||||
<span>NTMS</span>
|
|
||||||
<div class="font-normal text-xs opacity-70">Canadian Weather Map</div>
|
|
||||||
<div class="font-normal text-[10px] opacity-60">
|
|
||||||
<%= if @valid_time do %>
|
|
||||||
{Calendar.strftime(@valid_time, "%Y-%m-%d %H:%M UTC")}
|
|
||||||
<% else %>
|
|
||||||
No data available
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-1.5 shrink-0">
|
|
||||||
<div
|
|
||||||
id="weather-utc-clock-mobile"
|
|
||||||
phx-hook="UtcClock"
|
|
||||||
phx-update="ignore"
|
|
||||||
class="font-mono text-xs opacity-70 tabular-nums"
|
|
||||||
>
|
|
||||||
{@initial_utc_clock}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
id="weather-mobile-panel-toggle"
|
|
||||||
class="btn btn-xs btn-square btn-ghost"
|
|
||||||
onclick="document.getElementById('weather-panel-extras').classList.toggle('hidden')"
|
|
||||||
>
|
|
||||||
<.icon name="hero-bars-3" class="size-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="weather-panel-extras" class="hidden flex-col gap-2">
|
|
||||||
<div class="flex flex-col gap-1.5">
|
|
||||||
<div :for={{group, layers} <- group_layers(@layers)}>
|
|
||||||
<div class="text-[10px] font-semibold opacity-50 uppercase tracking-wider px-1 mb-0.5">
|
|
||||||
{group}
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-wrap gap-1">
|
|
||||||
<button
|
|
||||||
:for={layer <- layers}
|
|
||||||
phx-click="select_layer"
|
|
||||||
phx-value-layer={layer.id}
|
|
||||||
class={[
|
|
||||||
"btn btn-xs rounded-full",
|
|
||||||
if(@selected_layer == layer.id, do: "btn-primary", else: "btn-ghost")
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{layer.label}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="text-[11px] opacity-70 px-1 leading-snug">
|
|
||||||
{layer_description(@layers, @selected_layer)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-1 border-t border-base-300 pt-2">
|
|
||||||
<label class="flex items-center gap-2 cursor-pointer px-1">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
class="toggle toggle-sm toggle-primary"
|
|
||||||
checked={@grid_visible}
|
|
||||||
phx-click="toggle_grid"
|
|
||||||
/>
|
|
||||||
<span class="text-sm">Grid squares</span>
|
|
||||||
</label>
|
|
||||||
<label class="flex items-center gap-2 cursor-pointer px-1">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
class="toggle toggle-sm toggle-primary"
|
|
||||||
checked={@radar_visible}
|
|
||||||
phx-click="toggle_radar"
|
|
||||||
/>
|
|
||||||
<span class="text-sm">Weather radar</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-1 border-t border-base-300 pt-2">
|
|
||||||
<.link navigate="/weather" class="btn btn-xs btn-ghost justify-start">
|
|
||||||
US Weather Map
|
|
||||||
</.link>
|
|
||||||
<.link navigate="/map" class="btn btn-xs btn-ghost justify-start">
|
|
||||||
Propagation Map
|
|
||||||
</.link>
|
|
||||||
<.link navigate="/path" class="btn btn-xs btn-ghost justify-start">
|
|
||||||
Path Calculator
|
|
||||||
</.link>
|
|
||||||
<.link navigate="/eme" class="btn btn-xs btn-ghost justify-start">
|
|
||||||
EME
|
|
||||||
</.link>
|
|
||||||
<.link navigate="/beacons" class="btn btn-xs btn-ghost justify-start">
|
|
||||||
Beacons
|
|
||||||
</.link>
|
|
||||||
<.link navigate="/submit" class="btn btn-xs btn-ghost justify-start">
|
|
||||||
Submit a Contact
|
|
||||||
</.link>
|
|
||||||
<.link navigate="/contacts" class="btn btn-xs btn-ghost justify-start">
|
|
||||||
Contact Training Data
|
|
||||||
</.link>
|
|
||||||
<.link navigate="/contacts/map" class="btn btn-xs btn-ghost justify-start">
|
|
||||||
Contact Map
|
|
||||||
</.link>
|
|
||||||
<.link navigate="/algo" class="btn btn-xs btn-ghost justify-start">
|
|
||||||
Scoring Algorithm
|
|
||||||
</.link>
|
|
||||||
<.link navigate="/about" class="btn btn-xs btn-ghost justify-start">
|
|
||||||
About
|
|
||||||
</.link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
id="weather-detail-panel"
|
|
||||||
phx-update="ignore"
|
|
||||||
class={[
|
|
||||||
"bg-neutral text-neutral-content shadow-lg border border-base-300 overflow-hidden overflow-y-auto z-[1001]",
|
|
||||||
"fixed bottom-0 left-0 right-0 max-h-[50vh] rounded-t-2xl",
|
|
||||||
"md:absolute md:top-auto md:right-auto md:bottom-4 md:left-3 md:max-h-[70vh] md:w-80 md:rounded-box"
|
|
||||||
]}
|
|
||||||
style="display:none;"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
id="weather-sidebar-expand"
|
|
||||||
class="hidden md:hidden absolute top-3 right-3 z-[1000] btn btn-sm btn-neutral shadow-lg"
|
|
||||||
onclick="document.getElementById('weather-sidebar').style.display='flex';this.style.display='none';window.dispatchEvent(new Event('sidebar-toggle'));"
|
|
||||||
>
|
|
||||||
<.icon name="hero-bars-3" class="size-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
id="weather-sidebar"
|
|
||||||
data-theme="dark"
|
|
||||||
class="hidden md:flex flex-col w-56 shrink-0 h-full bg-neutral text-neutral-content border-l border-base-300 overflow-hidden"
|
|
||||||
>
|
|
||||||
<div class="p-3 flex flex-col gap-2 shrink-0 overflow-y-auto flex-1">
|
|
||||||
<div class="font-bold text-sm leading-tight px-1 flex items-center justify-between gap-2">
|
|
||||||
<div class="min-w-0">
|
|
||||||
<span>NTMS</span>
|
|
||||||
<div class="font-normal text-xs opacity-70">Canadian Weather Map</div>
|
|
||||||
<div class="font-normal text-[10px] opacity-60">
|
|
||||||
<%= if @valid_time do %>
|
|
||||||
{Calendar.strftime(@valid_time, "%Y-%m-%d %H:%M UTC")}
|
|
||||||
<% else %>
|
|
||||||
No data available
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-1.5 shrink-0">
|
|
||||||
<div
|
|
||||||
id="weather-utc-clock-desktop"
|
|
||||||
phx-hook="UtcClock"
|
|
||||||
phx-update="ignore"
|
|
||||||
class="font-mono text-xs opacity-70 tabular-nums"
|
|
||||||
>
|
|
||||||
{@initial_utc_clock}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
id="weather-sidebar-collapse"
|
|
||||||
class="btn btn-xs btn-square btn-ghost"
|
|
||||||
title="Collapse sidebar"
|
|
||||||
onclick="document.getElementById('weather-sidebar').style.display='none';document.getElementById('weather-sidebar-expand').style.display='flex';window.dispatchEvent(new Event('sidebar-toggle'));"
|
|
||||||
>
|
|
||||||
<.icon name="hero-chevron-right" class="size-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-1.5">
|
|
||||||
<div :for={{group, layers} <- group_layers(@layers)}>
|
|
||||||
<div class="text-[10px] font-semibold opacity-50 uppercase tracking-wider px-1 mb-0.5">
|
|
||||||
{group}
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-wrap gap-1">
|
|
||||||
<button
|
|
||||||
:for={layer <- layers}
|
|
||||||
phx-click="select_layer"
|
|
||||||
phx-value-layer={layer.id}
|
|
||||||
class={[
|
|
||||||
"btn btn-xs rounded-full",
|
|
||||||
if(@selected_layer == layer.id, do: "btn-primary", else: "btn-ghost")
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{layer.label}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="px-1">
|
|
||||||
<div class="text-[10px] font-semibold uppercase tracking-wider opacity-60 mb-1">
|
|
||||||
{selected_layer_group_label(@layers, @selected_layer)} · {selected_layer_label(
|
|
||||||
@layers,
|
|
||||||
@selected_layer
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div class="text-[11px] opacity-70 leading-snug">
|
|
||||||
{layer_description(@layers, @selected_layer)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-1 border-t border-base-300 pt-2">
|
|
||||||
<label class="flex items-center gap-2 cursor-pointer px-1">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
class="toggle toggle-sm toggle-primary"
|
|
||||||
checked={@grid_visible}
|
|
||||||
phx-click="toggle_grid"
|
|
||||||
/>
|
|
||||||
<span class="text-sm">Grid squares</span>
|
|
||||||
</label>
|
|
||||||
<label class="flex items-center gap-2 cursor-pointer px-1">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
class="toggle toggle-sm toggle-primary"
|
|
||||||
checked={@radar_visible}
|
|
||||||
phx-click="toggle_radar"
|
|
||||||
/>
|
|
||||||
<span class="text-sm">Weather radar</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ul class="menu menu-xs border-t border-base-300 pt-2 px-0">
|
|
||||||
<li>
|
|
||||||
<.link navigate="/weather">US Weather Map</.link>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<.link navigate="/map">Propagation Map</.link>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<.link navigate="/path">Path Calculator</.link>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<.link navigate="/eme">EME</.link>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<.link navigate="/beacons">Beacons</.link>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<.link navigate="/submit">Submit a Contact</.link>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<.link navigate="/contacts">Contact Training Data</.link>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<.link navigate="/contacts/map">Contact Map</.link>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<.link navigate="/algo">Scoring Algorithm</.link>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<.link navigate="/about">About</.link>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<ul class="menu menu-xs border-t border-base-300 pt-2 px-0">
|
|
||||||
<%= if @current_scope && @current_scope.user do %>
|
|
||||||
<li>
|
|
||||||
<.link navigate={~p"/u/#{@current_scope.user.callsign}"} class="font-semibold">
|
|
||||||
{@current_scope.user.callsign}
|
|
||||||
</.link>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<.link href={~p"/users/settings"}>Settings</.link>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<.link href={~p"/users/log-out"} method="delete">Log out</.link>
|
|
||||||
</li>
|
|
||||||
<% else %>
|
|
||||||
<li>
|
|
||||||
<.link href={~p"/users/register"}>Register</.link>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<.link href={~p"/users/log-in"}>Log in</.link>
|
|
||||||
</li>
|
|
||||||
<% end %>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Layouts.flash_group flash={@flash} />
|
|
||||||
"""
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
428
lib/microwaveprop_web/live/weather_map_component.ex
Normal file
428
lib/microwaveprop_web/live/weather_map_component.ex
Normal file
|
|
@ -0,0 +1,428 @@
|
||||||
|
defmodule MicrowavepropWeb.WeatherMapComponent do
|
||||||
|
@moduledoc false
|
||||||
|
use MicrowavepropWeb, :html
|
||||||
|
|
||||||
|
@group_order ["Surface", "Upper Air", "Ducting"]
|
||||||
|
|
||||||
|
attr :bands, :list, required: true
|
||||||
|
attr :selected, :any, default: nil
|
||||||
|
|
||||||
|
defp cutoff_band_picker(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="flex flex-col gap-1 border-t border-base-300 pt-2">
|
||||||
|
<div class="text-[10px] font-semibold opacity-60 uppercase tracking-wider px-1">
|
||||||
|
Filter to band
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
<button
|
||||||
|
phx-click="clear_band_filter"
|
||||||
|
class={[
|
||||||
|
"btn btn-xs rounded-full",
|
||||||
|
if(@selected == nil, do: "btn-primary", else: "btn-ghost")
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
:for={band <- @bands}
|
||||||
|
phx-click="select_band_filter"
|
||||||
|
phx-value-band={band}
|
||||||
|
class={[
|
||||||
|
"btn btn-xs rounded-full font-mono tabular-nums",
|
||||||
|
if(@selected == band, do: "btn-primary", else: "btn-ghost")
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{band}G
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp group_layers(layers) do
|
||||||
|
layers
|
||||||
|
|> Enum.group_by(& &1.group)
|
||||||
|
|> Enum.sort_by(fn {group, _} -> Enum.find_index(@group_order, &(&1 == group)) || 99 end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp layer_description(layers, selected_id) do
|
||||||
|
case Enum.find(layers, &(&1.id == selected_id)) do
|
||||||
|
%{desc: desc} -> desc
|
||||||
|
_ -> nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp selected_layer_label(layers, selected_id) do
|
||||||
|
case Enum.find(layers, &(&1.id == selected_id)) do
|
||||||
|
%{label: label} -> label
|
||||||
|
_ -> ""
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp selected_layer_group_label(layers, selected_id) do
|
||||||
|
case Enum.find(layers, &(&1.id == selected_id)) do
|
||||||
|
%{group: group} -> group
|
||||||
|
_ -> ""
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
def render(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="flex w-screen h-screen overflow-hidden">
|
||||||
|
<%!-- Map container --%>
|
||||||
|
<div class="relative flex-1 min-w-0">
|
||||||
|
<%!-- Full-page map --%>
|
||||||
|
<div
|
||||||
|
id="weather-map"
|
||||||
|
phx-hook="WeatherMap"
|
||||||
|
phx-update="ignore"
|
||||||
|
data-layers={Jason.encode!(@layers)}
|
||||||
|
data-selected-layer={@selected_layer}
|
||||||
|
data-band-filter={@band_filter}
|
||||||
|
data-valid-times={@initial_valid_times_json}
|
||||||
|
data-selected-time={@initial_selected_time}
|
||||||
|
data-source={@data_source}
|
||||||
|
data-initial-lat={@initial_center_lat}
|
||||||
|
data-initial-lon={@initial_center_lon}
|
||||||
|
data-initial-zoom={@initial_zoom}
|
||||||
|
class="absolute inset-0 z-0"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%!-- Bottom forecast timeline --%>
|
||||||
|
<div
|
||||||
|
id="weather-forecast-timeline"
|
||||||
|
phx-update="ignore"
|
||||||
|
class="absolute bottom-2 left-1/2 -translate-x-1/2 z-[1000] max-w-[calc(100vw-1rem)] md:bottom-4"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%!-- Mobile-only floating controls --%>
|
||||||
|
<div
|
||||||
|
id="weather-controls"
|
||||||
|
class="md:hidden absolute top-2 left-12 z-[1000] flex flex-col gap-2 max-w-[calc(100vw-4rem)]"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
data-theme="dark"
|
||||||
|
class="bg-neutral text-neutral-content shadow rounded-box border border-base-300 p-2 flex flex-col gap-2"
|
||||||
|
>
|
||||||
|
<div class="font-bold text-sm leading-tight px-1 flex items-center justify-between gap-2">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<span>NTMS</span>
|
||||||
|
<div class="font-normal text-xs opacity-70">{@page_subtitle}</div>
|
||||||
|
<div class="font-normal text-[10px] opacity-60">
|
||||||
|
<%= if @valid_time do %>
|
||||||
|
{Calendar.strftime(@valid_time, "%Y-%m-%d %H:%M UTC")}
|
||||||
|
<% else %>
|
||||||
|
No data available
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1.5 shrink-0">
|
||||||
|
<div
|
||||||
|
id="weather-utc-clock-mobile"
|
||||||
|
phx-hook="UtcClock"
|
||||||
|
phx-update="ignore"
|
||||||
|
class="font-mono text-xs opacity-70 tabular-nums"
|
||||||
|
>
|
||||||
|
{@initial_utc_clock}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
id="weather-mobile-panel-toggle"
|
||||||
|
class="btn btn-xs btn-square btn-ghost"
|
||||||
|
onclick="document.getElementById('weather-panel-extras').classList.toggle('hidden')"
|
||||||
|
>
|
||||||
|
<.icon name="hero-bars-3" class="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="weather-panel-extras" class="hidden flex-col gap-2">
|
||||||
|
<%!-- Layer pill buttons grouped --%>
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<div :for={{group, layers} <- group_layers(@layers)}>
|
||||||
|
<div class="text-[10px] font-semibold opacity-50 uppercase tracking-wider px-1 mb-0.5">
|
||||||
|
{group}
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
<button
|
||||||
|
:for={layer <- layers}
|
||||||
|
phx-click="select_layer"
|
||||||
|
phx-value-layer={layer.id}
|
||||||
|
class={[
|
||||||
|
"btn btn-xs rounded-full",
|
||||||
|
if(@selected_layer == layer.id, do: "btn-primary", else: "btn-ghost")
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{layer.label}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-[11px] opacity-70 px-1 leading-snug">
|
||||||
|
{layer_description(@layers, @selected_layer)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%= if @show_cutoff_bands do %>
|
||||||
|
<.cutoff_band_picker
|
||||||
|
:if={@selected_layer == "duct_cutoff_ghz"}
|
||||||
|
bands={@cutoff_bands}
|
||||||
|
selected={@band_filter}
|
||||||
|
/>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1 border-t border-base-300 pt-2">
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer px-1">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="toggle toggle-sm toggle-primary"
|
||||||
|
checked={@grid_visible}
|
||||||
|
phx-click="toggle_grid"
|
||||||
|
/>
|
||||||
|
<span class="text-sm">Grid squares</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer px-1">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="toggle toggle-sm toggle-primary"
|
||||||
|
checked={@radar_visible}
|
||||||
|
phx-click="toggle_radar"
|
||||||
|
/>
|
||||||
|
<span class="text-sm">Weather radar</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1 border-t border-base-300 pt-2">
|
||||||
|
<%= if @show_other_weather_link do %>
|
||||||
|
<.link navigate={@other_weather_path} class="btn btn-xs btn-ghost justify-start">
|
||||||
|
{@other_weather_label}
|
||||||
|
</.link>
|
||||||
|
<% end %>
|
||||||
|
<.link navigate="/map" class="btn btn-xs btn-ghost justify-start">
|
||||||
|
Propagation Map
|
||||||
|
</.link>
|
||||||
|
<.link navigate="/path" class="btn btn-xs btn-ghost justify-start">
|
||||||
|
Path Calculator
|
||||||
|
</.link>
|
||||||
|
<.link navigate="/eme" class="btn btn-xs btn-ghost justify-start">
|
||||||
|
EME
|
||||||
|
</.link>
|
||||||
|
<.link navigate="/beacons" class="btn btn-xs btn-ghost justify-start">
|
||||||
|
Beacons
|
||||||
|
</.link>
|
||||||
|
<.link navigate="/submit" class="btn btn-xs btn-ghost justify-start">
|
||||||
|
Submit a Contact
|
||||||
|
</.link>
|
||||||
|
<.link navigate="/contacts" class="btn btn-xs btn-ghost justify-start">
|
||||||
|
Contact Training Data
|
||||||
|
</.link>
|
||||||
|
<.link navigate="/contacts/map" class="btn btn-xs btn-ghost justify-start">
|
||||||
|
Contact Map
|
||||||
|
</.link>
|
||||||
|
<.link navigate="/algo" class="btn btn-xs btn-ghost justify-start">
|
||||||
|
Scoring Algorithm
|
||||||
|
</.link>
|
||||||
|
<.link navigate="/about" class="btn btn-xs btn-ghost justify-start">
|
||||||
|
About
|
||||||
|
</.link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%!-- Point detail panel --%>
|
||||||
|
<div
|
||||||
|
id="weather-detail-panel"
|
||||||
|
phx-update="ignore"
|
||||||
|
class={[
|
||||||
|
"bg-neutral text-neutral-content shadow-lg border border-base-300 overflow-hidden overflow-y-auto z-[1001]",
|
||||||
|
"fixed bottom-0 left-0 right-0 max-h-[50vh] rounded-t-2xl",
|
||||||
|
"md:absolute md:top-auto md:right-auto md:bottom-4 md:left-3 md:max-h-[70vh] md:w-80 md:rounded-box"
|
||||||
|
]}
|
||||||
|
style="display:none;"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%!-- Sidebar expand button (visible when sidebar is collapsed) --%>
|
||||||
|
<button
|
||||||
|
id="weather-sidebar-expand"
|
||||||
|
class="hidden md:hidden absolute top-3 right-3 z-[1000] btn btn-sm btn-neutral shadow-lg"
|
||||||
|
onclick="document.getElementById('weather-sidebar').style.display='flex';this.style.display='none';window.dispatchEvent(new Event('sidebar-toggle'));"
|
||||||
|
>
|
||||||
|
<.icon name="hero-bars-3" class="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%!-- Desktop right sidebar --%>
|
||||||
|
<div
|
||||||
|
id="weather-sidebar"
|
||||||
|
data-theme="dark"
|
||||||
|
class="hidden md:flex flex-col w-56 shrink-0 h-full bg-neutral text-neutral-content border-l border-base-300 overflow-hidden"
|
||||||
|
>
|
||||||
|
<div class="p-3 flex flex-col gap-2 shrink-0 overflow-y-auto flex-1">
|
||||||
|
<div class="font-bold text-sm leading-tight px-1 flex items-center justify-between gap-2">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<span>NTMS</span>
|
||||||
|
<div class="font-normal text-xs opacity-70">{@page_subtitle}</div>
|
||||||
|
<div class="font-normal text-[10px] opacity-60">
|
||||||
|
<%= if @valid_time do %>
|
||||||
|
{Calendar.strftime(@valid_time, "%Y-%m-%d %H:%M UTC")}
|
||||||
|
<% else %>
|
||||||
|
No data available
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1.5 shrink-0">
|
||||||
|
<div
|
||||||
|
id="weather-utc-clock-desktop"
|
||||||
|
phx-hook="UtcClock"
|
||||||
|
phx-update="ignore"
|
||||||
|
class="font-mono text-xs opacity-70 tabular-nums"
|
||||||
|
>
|
||||||
|
{@initial_utc_clock}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
id="weather-sidebar-collapse"
|
||||||
|
class="btn btn-xs btn-square btn-ghost"
|
||||||
|
title="Collapse sidebar"
|
||||||
|
onclick="document.getElementById('weather-sidebar').style.display='none';document.getElementById('weather-sidebar-expand').style.display='flex';window.dispatchEvent(new Event('sidebar-toggle'));"
|
||||||
|
>
|
||||||
|
<.icon name="hero-chevron-right" class="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%!-- Layer pill buttons grouped --%>
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<div :for={{group, layers} <- group_layers(@layers)}>
|
||||||
|
<div class="text-[10px] font-semibold opacity-50 uppercase tracking-wider px-1 mb-0.5">
|
||||||
|
{group}
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
<button
|
||||||
|
:for={layer <- layers}
|
||||||
|
phx-click="select_layer"
|
||||||
|
phx-value-layer={layer.id}
|
||||||
|
class={[
|
||||||
|
"btn btn-xs rounded-full",
|
||||||
|
if(@selected_layer == layer.id, do: "btn-primary", else: "btn-ghost")
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{layer.label}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%!-- Layer description (group · name header + body) --%>
|
||||||
|
<div class="px-1">
|
||||||
|
<div class="text-[10px] font-semibold uppercase tracking-wider opacity-60 mb-1">
|
||||||
|
{selected_layer_group_label(@layers, @selected_layer)} · {selected_layer_label(
|
||||||
|
@layers,
|
||||||
|
@selected_layer
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div class="text-[11px] opacity-70 leading-snug">
|
||||||
|
{layer_description(@layers, @selected_layer)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%= if @show_cutoff_bands do %>
|
||||||
|
<.cutoff_band_picker
|
||||||
|
:if={@selected_layer == "duct_cutoff_ghz"}
|
||||||
|
bands={@cutoff_bands}
|
||||||
|
selected={@band_filter}
|
||||||
|
/>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%!-- Overlay toggles --%>
|
||||||
|
<div class="flex flex-col gap-1 border-t border-base-300 pt-2">
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer px-1">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="toggle toggle-sm toggle-primary"
|
||||||
|
checked={@grid_visible}
|
||||||
|
phx-click="toggle_grid"
|
||||||
|
/>
|
||||||
|
<span class="text-sm">Grid squares</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer px-1">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="toggle toggle-sm toggle-primary"
|
||||||
|
checked={@radar_visible}
|
||||||
|
phx-click="toggle_radar"
|
||||||
|
/>
|
||||||
|
<span class="text-sm">Weather radar</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%!-- Navigation --%>
|
||||||
|
<ul class="menu menu-xs border-t border-base-300 pt-2 px-0">
|
||||||
|
<%= if @show_other_weather_link do %>
|
||||||
|
<li>
|
||||||
|
<.link navigate={@other_weather_path}>{@other_weather_label}</.link>
|
||||||
|
</li>
|
||||||
|
<% end %>
|
||||||
|
<li>
|
||||||
|
<.link navigate="/map">Propagation Map</.link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<.link navigate="/path">Path Calculator</.link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<.link navigate="/eme">EME</.link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<.link navigate="/beacons">Beacons</.link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<.link navigate="/submit">Submit a Contact</.link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<.link navigate="/contacts">Contact Training Data</.link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<.link navigate="/contacts/map">Contact Map</.link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<.link navigate="/algo">Scoring Algorithm</.link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<.link navigate="/about">About</.link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<%!-- Auth --%>
|
||||||
|
<ul class="menu menu-xs border-t border-base-300 pt-2 px-0">
|
||||||
|
<%= if @current_scope && @current_scope.user do %>
|
||||||
|
<li>
|
||||||
|
<.link navigate={~p"/u/#{@current_scope.user.callsign}"} class="font-semibold">
|
||||||
|
{@current_scope.user.callsign}
|
||||||
|
</.link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<.link href={~p"/users/settings"}>Settings</.link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<.link href={~p"/users/log-out"} method="delete">Log out</.link>
|
||||||
|
</li>
|
||||||
|
<% else %>
|
||||||
|
<li>
|
||||||
|
<.link href={~p"/users/register"}>Register</.link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<.link href={~p"/users/log-in"}>Log in</.link>
|
||||||
|
</li>
|
||||||
|
<% end %>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Layouts.flash_group flash={@flash} />
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -4,6 +4,7 @@ defmodule MicrowavepropWeb.WeatherMapLive do
|
||||||
|
|
||||||
alias Microwaveprop.Weather
|
alias Microwaveprop.Weather
|
||||||
alias Microwaveprop.Weather.MapLayers
|
alias Microwaveprop.Weather.MapLayers
|
||||||
|
alias MicrowavepropWeb.WeatherMapComponent
|
||||||
|
|
||||||
@layers MapLayers.all()
|
@layers MapLayers.all()
|
||||||
@default_layer MapLayers.default_id()
|
@default_layer MapLayers.default_id()
|
||||||
|
|
@ -349,412 +350,19 @@ defmodule MicrowavepropWeb.WeatherMapLive do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@group_order ["Surface", "Upper Air", "Ducting"]
|
|
||||||
|
|
||||||
defp group_layers(layers) do
|
|
||||||
layers
|
|
||||||
|> Enum.group_by(& &1.group)
|
|
||||||
|> Enum.sort_by(fn {group, _} -> Enum.find_index(@group_order, &(&1 == group)) || 99 end)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp layer_description(layers, selected_id) do
|
|
||||||
case Enum.find(layers, &(&1.id == selected_id)) do
|
|
||||||
%{desc: desc} -> desc
|
|
||||||
_ -> nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp selected_layer_label(layers, selected_id) do
|
|
||||||
case Enum.find(layers, &(&1.id == selected_id)) do
|
|
||||||
%{label: label} -> label
|
|
||||||
_ -> ""
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp selected_layer_group_label(layers, selected_id) do
|
|
||||||
case Enum.find(layers, &(&1.id == selected_id)) do
|
|
||||||
%{group: group} -> group
|
|
||||||
_ -> ""
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
attr :bands, :list, required: true
|
|
||||||
attr :selected, :any, default: nil
|
|
||||||
|
|
||||||
defp cutoff_band_picker(assigns) do
|
|
||||||
~H"""
|
|
||||||
<div class="flex flex-col gap-1 border-t border-base-300 pt-2">
|
|
||||||
<div class="text-[10px] font-semibold opacity-60 uppercase tracking-wider px-1">
|
|
||||||
Filter to band
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-wrap gap-1">
|
|
||||||
<button
|
|
||||||
phx-click="clear_band_filter"
|
|
||||||
class={[
|
|
||||||
"btn btn-xs rounded-full",
|
|
||||||
if(@selected == nil, do: "btn-primary", else: "btn-ghost")
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
All
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
:for={band <- @bands}
|
|
||||||
phx-click="select_band_filter"
|
|
||||||
phx-value-band={band}
|
|
||||||
class={[
|
|
||||||
"btn btn-xs rounded-full font-mono tabular-nums",
|
|
||||||
if(@selected == band, do: "btn-primary", else: "btn-ghost")
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{band}G
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
"""
|
|
||||||
end
|
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~H"""
|
assigns =
|
||||||
<div class="flex w-screen h-screen overflow-hidden">
|
assign(assigns,
|
||||||
<%!-- Map container --%>
|
page_subtitle: "Weather Map",
|
||||||
<div class="relative flex-1 min-w-0">
|
data_source: nil,
|
||||||
<%!-- Full-page map --%>
|
cutoff_bands: @cutoff_bands,
|
||||||
<div
|
show_cutoff_bands: true,
|
||||||
id="weather-map"
|
show_other_weather_link: false,
|
||||||
phx-hook="WeatherMap"
|
other_weather_path: "/weather-ca",
|
||||||
phx-update="ignore"
|
other_weather_label: "Canadian Weather Map"
|
||||||
data-layers={Jason.encode!(@layers)}
|
)
|
||||||
data-selected-layer={@selected_layer}
|
|
||||||
data-band-filter={@band_filter}
|
|
||||||
data-valid-times={@initial_valid_times_json}
|
|
||||||
data-selected-time={@initial_selected_time}
|
|
||||||
data-initial-lat={@initial_center_lat}
|
|
||||||
data-initial-lon={@initial_center_lon}
|
|
||||||
data-initial-zoom={@initial_zoom}
|
|
||||||
class="absolute inset-0 z-0"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<%!-- Bottom forecast timeline --%>
|
WeatherMapComponent.render(assigns)
|
||||||
<div
|
|
||||||
id="weather-forecast-timeline"
|
|
||||||
phx-update="ignore"
|
|
||||||
class="absolute bottom-2 left-1/2 -translate-x-1/2 z-[1000] max-w-[calc(100vw-1rem)] md:bottom-4"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<%!-- Mobile-only floating controls --%>
|
|
||||||
<div
|
|
||||||
id="weather-controls"
|
|
||||||
class="md:hidden absolute top-2 left-12 z-[1000] flex flex-col gap-2 max-w-[calc(100vw-4rem)]"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
data-theme="dark"
|
|
||||||
class="bg-neutral text-neutral-content shadow rounded-box border border-base-300 p-2 flex flex-col gap-2"
|
|
||||||
>
|
|
||||||
<div class="font-bold text-sm leading-tight px-1 flex items-center justify-between gap-2">
|
|
||||||
<div class="min-w-0">
|
|
||||||
<span>NTMS</span>
|
|
||||||
<div class="font-normal text-xs opacity-70">Weather Map</div>
|
|
||||||
<div class="font-normal text-[10px] opacity-60">
|
|
||||||
<%= if @valid_time do %>
|
|
||||||
{Calendar.strftime(@valid_time, "%Y-%m-%d %H:%M UTC")}
|
|
||||||
<% else %>
|
|
||||||
No data available
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-1.5 shrink-0">
|
|
||||||
<div
|
|
||||||
id="weather-utc-clock-mobile"
|
|
||||||
phx-hook="UtcClock"
|
|
||||||
phx-update="ignore"
|
|
||||||
class="font-mono text-xs opacity-70 tabular-nums"
|
|
||||||
>
|
|
||||||
{@initial_utc_clock}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
id="weather-mobile-panel-toggle"
|
|
||||||
class="btn btn-xs btn-square btn-ghost"
|
|
||||||
onclick="document.getElementById('weather-panel-extras').classList.toggle('hidden')"
|
|
||||||
>
|
|
||||||
<.icon name="hero-bars-3" class="size-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="weather-panel-extras" class="hidden flex-col gap-2">
|
|
||||||
<%!-- Layer pill buttons grouped --%>
|
|
||||||
<div class="flex flex-col gap-1.5">
|
|
||||||
<div :for={{group, layers} <- group_layers(@layers)}>
|
|
||||||
<div class="text-[10px] font-semibold opacity-50 uppercase tracking-wider px-1 mb-0.5">
|
|
||||||
{group}
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-wrap gap-1">
|
|
||||||
<button
|
|
||||||
:for={layer <- layers}
|
|
||||||
phx-click="select_layer"
|
|
||||||
phx-value-layer={layer.id}
|
|
||||||
class={[
|
|
||||||
"btn btn-xs rounded-full",
|
|
||||||
if(@selected_layer == layer.id, do: "btn-primary", else: "btn-ghost")
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{layer.label}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="text-[11px] opacity-70 px-1 leading-snug">
|
|
||||||
{layer_description(@layers, @selected_layer)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<.cutoff_band_picker
|
|
||||||
:if={@selected_layer == "duct_cutoff_ghz"}
|
|
||||||
bands={@cutoff_bands}
|
|
||||||
selected={@band_filter}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-1 border-t border-base-300 pt-2">
|
|
||||||
<label class="flex items-center gap-2 cursor-pointer px-1">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
class="toggle toggle-sm toggle-primary"
|
|
||||||
checked={@grid_visible}
|
|
||||||
phx-click="toggle_grid"
|
|
||||||
/>
|
|
||||||
<span class="text-sm">Grid squares</span>
|
|
||||||
</label>
|
|
||||||
<label class="flex items-center gap-2 cursor-pointer px-1">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
class="toggle toggle-sm toggle-primary"
|
|
||||||
checked={@radar_visible}
|
|
||||||
phx-click="toggle_radar"
|
|
||||||
/>
|
|
||||||
<span class="text-sm">Weather radar</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-1 border-t border-base-300 pt-2">
|
|
||||||
<.link navigate="/map" class="btn btn-xs btn-ghost justify-start">
|
|
||||||
Propagation Map
|
|
||||||
</.link>
|
|
||||||
<.link navigate="/path" class="btn btn-xs btn-ghost justify-start">
|
|
||||||
Path Calculator
|
|
||||||
</.link>
|
|
||||||
<.link navigate="/eme" class="btn btn-xs btn-ghost justify-start">
|
|
||||||
EME
|
|
||||||
</.link>
|
|
||||||
<.link navigate="/beacons" class="btn btn-xs btn-ghost justify-start">
|
|
||||||
Beacons
|
|
||||||
</.link>
|
|
||||||
<.link navigate="/submit" class="btn btn-xs btn-ghost justify-start">
|
|
||||||
Submit a Contact
|
|
||||||
</.link>
|
|
||||||
<.link navigate="/contacts" class="btn btn-xs btn-ghost justify-start">
|
|
||||||
Contact Training Data
|
|
||||||
</.link>
|
|
||||||
<.link navigate="/contacts/map" class="btn btn-xs btn-ghost justify-start">
|
|
||||||
Contact Map
|
|
||||||
</.link>
|
|
||||||
<.link navigate="/algo" class="btn btn-xs btn-ghost justify-start">
|
|
||||||
Scoring Algorithm
|
|
||||||
</.link>
|
|
||||||
<.link navigate="/about" class="btn btn-xs btn-ghost justify-start">
|
|
||||||
About
|
|
||||||
</.link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<%!-- Point detail panel --%>
|
|
||||||
<div
|
|
||||||
id="weather-detail-panel"
|
|
||||||
phx-update="ignore"
|
|
||||||
class={[
|
|
||||||
"bg-neutral text-neutral-content shadow-lg border border-base-300 overflow-hidden overflow-y-auto z-[1001]",
|
|
||||||
"fixed bottom-0 left-0 right-0 max-h-[50vh] rounded-t-2xl",
|
|
||||||
"md:absolute md:top-auto md:right-auto md:bottom-4 md:left-3 md:max-h-[70vh] md:w-80 md:rounded-box"
|
|
||||||
]}
|
|
||||||
style="display:none;"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<%!-- Sidebar expand button (visible when sidebar is collapsed) --%>
|
|
||||||
<button
|
|
||||||
id="weather-sidebar-expand"
|
|
||||||
class="hidden md:hidden absolute top-3 right-3 z-[1000] btn btn-sm btn-neutral shadow-lg"
|
|
||||||
onclick="document.getElementById('weather-sidebar').style.display='flex';this.style.display='none';window.dispatchEvent(new Event('sidebar-toggle'));"
|
|
||||||
>
|
|
||||||
<.icon name="hero-bars-3" class="size-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<%!-- Desktop right sidebar --%>
|
|
||||||
<div
|
|
||||||
id="weather-sidebar"
|
|
||||||
data-theme="dark"
|
|
||||||
class="hidden md:flex flex-col w-56 shrink-0 h-full bg-neutral text-neutral-content border-l border-base-300 overflow-hidden"
|
|
||||||
>
|
|
||||||
<div class="p-3 flex flex-col gap-2 shrink-0 overflow-y-auto flex-1">
|
|
||||||
<div class="font-bold text-sm leading-tight px-1 flex items-center justify-between gap-2">
|
|
||||||
<div class="min-w-0">
|
|
||||||
<span>NTMS</span>
|
|
||||||
<div class="font-normal text-xs opacity-70">Weather Map</div>
|
|
||||||
<div class="font-normal text-[10px] opacity-60">
|
|
||||||
<%= if @valid_time do %>
|
|
||||||
{Calendar.strftime(@valid_time, "%Y-%m-%d %H:%M UTC")}
|
|
||||||
<% else %>
|
|
||||||
No data available
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-1.5 shrink-0">
|
|
||||||
<div
|
|
||||||
id="weather-utc-clock-desktop"
|
|
||||||
phx-hook="UtcClock"
|
|
||||||
phx-update="ignore"
|
|
||||||
class="font-mono text-xs opacity-70 tabular-nums"
|
|
||||||
>
|
|
||||||
{@initial_utc_clock}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
id="weather-sidebar-collapse"
|
|
||||||
class="btn btn-xs btn-square btn-ghost"
|
|
||||||
title="Collapse sidebar"
|
|
||||||
onclick="document.getElementById('weather-sidebar').style.display='none';document.getElementById('weather-sidebar-expand').style.display='flex';window.dispatchEvent(new Event('sidebar-toggle'));"
|
|
||||||
>
|
|
||||||
<.icon name="hero-chevron-right" class="size-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<%!-- Layer pill buttons grouped --%>
|
|
||||||
<div class="flex flex-col gap-1.5">
|
|
||||||
<div :for={{group, layers} <- group_layers(@layers)}>
|
|
||||||
<div class="text-[10px] font-semibold opacity-50 uppercase tracking-wider px-1 mb-0.5">
|
|
||||||
{group}
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-wrap gap-1">
|
|
||||||
<button
|
|
||||||
:for={layer <- layers}
|
|
||||||
phx-click="select_layer"
|
|
||||||
phx-value-layer={layer.id}
|
|
||||||
class={[
|
|
||||||
"btn btn-xs rounded-full",
|
|
||||||
if(@selected_layer == layer.id, do: "btn-primary", else: "btn-ghost")
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{layer.label}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<%!-- Layer description (group · name header + body) --%>
|
|
||||||
<div class="px-1">
|
|
||||||
<div class="text-[10px] font-semibold uppercase tracking-wider opacity-60 mb-1">
|
|
||||||
{selected_layer_group_label(@layers, @selected_layer)} · {selected_layer_label(
|
|
||||||
@layers,
|
|
||||||
@selected_layer
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div class="text-[11px] opacity-70 leading-snug">
|
|
||||||
{layer_description(@layers, @selected_layer)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<.cutoff_band_picker
|
|
||||||
:if={@selected_layer == "duct_cutoff_ghz"}
|
|
||||||
bands={@cutoff_bands}
|
|
||||||
selected={@band_filter}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<%!-- Overlay toggles --%>
|
|
||||||
<div class="flex flex-col gap-1 border-t border-base-300 pt-2">
|
|
||||||
<label class="flex items-center gap-2 cursor-pointer px-1">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
class="toggle toggle-sm toggle-primary"
|
|
||||||
checked={@grid_visible}
|
|
||||||
phx-click="toggle_grid"
|
|
||||||
/>
|
|
||||||
<span class="text-sm">Grid squares</span>
|
|
||||||
</label>
|
|
||||||
<label class="flex items-center gap-2 cursor-pointer px-1">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
class="toggle toggle-sm toggle-primary"
|
|
||||||
checked={@radar_visible}
|
|
||||||
phx-click="toggle_radar"
|
|
||||||
/>
|
|
||||||
<span class="text-sm">Weather radar</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<%!-- Navigation --%>
|
|
||||||
<ul class="menu menu-xs border-t border-base-300 pt-2 px-0">
|
|
||||||
<li>
|
|
||||||
<.link navigate="/map">Propagation Map</.link>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<.link navigate="/path">Path Calculator</.link>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<.link navigate="/eme">EME</.link>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<.link navigate="/beacons">Beacons</.link>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<.link navigate="/submit">Submit a Contact</.link>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<.link navigate="/contacts">Contact Training Data</.link>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<.link navigate="/contacts/map">Contact Map</.link>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<.link navigate="/algo">Scoring Algorithm</.link>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<.link navigate="/about">About</.link>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<%!-- Auth --%>
|
|
||||||
<ul class="menu menu-xs border-t border-base-300 pt-2 px-0">
|
|
||||||
<%= if @current_scope && @current_scope.user do %>
|
|
||||||
<li>
|
|
||||||
<.link navigate={~p"/u/#{@current_scope.user.callsign}"} class="font-semibold">
|
|
||||||
{@current_scope.user.callsign}
|
|
||||||
</.link>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<.link href={~p"/users/settings"}>Settings</.link>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<.link href={~p"/users/log-out"} method="delete">Log out</.link>
|
|
||||||
</li>
|
|
||||||
<% else %>
|
|
||||||
<li>
|
|
||||||
<.link href={~p"/users/register"}>Register</.link>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<.link href={~p"/users/log-in"}>Log in</.link>
|
|
||||||
</li>
|
|
||||||
<% end %>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Layouts.flash_group flash={@flash} />
|
|
||||||
"""
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue