refactor
This commit is contained in:
parent
265c58b9ff
commit
070b8870df
6 changed files with 699 additions and 159 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -54,3 +54,6 @@ gleam@@compile.erl
|
||||||
/test/screenshots/
|
/test/screenshots/
|
||||||
|
|
||||||
.claudecheckpoints/
|
.claudecheckpoints/
|
||||||
|
|
||||||
|
# Expert language server directory
|
||||||
|
/.expert
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
defmodule Aprsme.DeviceParser do
|
defmodule Aprsme.DeviceParser do
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
Extracts device identifiers from APRS packet data.
|
Extracts device identifiers from APRS packet data using pattern matching.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
|
|
@ -8,35 +8,48 @@ defmodule Aprsme.DeviceParser do
|
||||||
Returns the device identifier string or nil if not found.
|
Returns the device identifier string or nil if not found.
|
||||||
"""
|
"""
|
||||||
@spec extract_device_identifier(map()) :: String.t() | nil
|
@spec extract_device_identifier(map()) :: String.t() | nil
|
||||||
def extract_device_identifier(packet_data) do
|
def extract_device_identifier(packet_data) when is_map(packet_data) do
|
||||||
extract_from_destination(packet_data) ||
|
find_device_identifier(packet_data)
|
||||||
extract_from_device_identifier(packet_data) ||
|
|
||||||
extract_from_data_extended(packet_data)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
defp extract_from_destination(packet_data) do
|
def extract_device_identifier(_), do: nil
|
||||||
get_field_value(packet_data, :destination) || get_field_value(packet_data, "destination")
|
|
||||||
|
# Pattern matching for different data structures
|
||||||
|
defp find_device_identifier(%{device_identifier: id}) when is_binary(id) and id != "", do: id
|
||||||
|
defp find_device_identifier(%{"device_identifier" => id}) when is_binary(id) and id != "", do: id
|
||||||
|
defp find_device_identifier(%{destination: dest}) when is_binary(dest) and dest != "", do: dest
|
||||||
|
defp find_device_identifier(%{"destination" => dest}) when is_binary(dest) and dest != "", do: dest
|
||||||
|
|
||||||
|
defp find_device_identifier(%{data_extended: data_ext} = packet) when is_map(data_ext) do
|
||||||
|
extract_from_symbol_data(data_ext) || find_device_identifier(Map.delete(packet, :data_extended))
|
||||||
end
|
end
|
||||||
|
|
||||||
defp extract_from_device_identifier(packet_data) do
|
defp find_device_identifier(%{"data_extended" => data_ext} = packet) when is_map(data_ext) do
|
||||||
get_field_value(packet_data, :device_identifier) || get_field_value(packet_data, "device_identifier")
|
extract_from_symbol_data(data_ext) || find_device_identifier(Map.delete(packet, "data_extended"))
|
||||||
end
|
end
|
||||||
|
|
||||||
defp get_field_value(packet_data, key) do
|
defp find_device_identifier(_), do: nil
|
||||||
if Map.has_key?(packet_data, key) do
|
|
||||||
Map.get(packet_data, key)
|
# Pattern matching for symbol data extraction
|
||||||
end
|
defp extract_from_symbol_data(%{symbol_table_id: table, symbol_code: code})
|
||||||
|
when is_binary(table) and is_binary(code) do
|
||||||
|
"#{table}#{code}"
|
||||||
end
|
end
|
||||||
|
|
||||||
defp extract_from_data_extended(packet_data) do
|
defp extract_from_symbol_data(%{"symbol_table_id" => table, "symbol_code" => code})
|
||||||
data_extended = Map.get(packet_data, :data_extended) || %{}
|
when is_binary(table) and is_binary(code) do
|
||||||
|
"#{table}#{code}"
|
||||||
# Try to extract from symbol information
|
|
||||||
symbol_table_id = Map.get(data_extended, :symbol_table_id) || Map.get(data_extended, "symbol_table_id")
|
|
||||||
symbol_code = Map.get(data_extended, :symbol_code) || Map.get(data_extended, "symbol_code")
|
|
||||||
|
|
||||||
if symbol_table_id && symbol_code do
|
|
||||||
"#{symbol_table_id}#{symbol_code}"
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp extract_from_symbol_data(_), do: nil
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Normalizes a device identifier to a canonical form.
|
||||||
|
Handles both string and atom inputs with pattern matching.
|
||||||
|
"""
|
||||||
|
@spec normalize_device_identifier(String.t() | atom() | nil) :: String.t() | nil
|
||||||
|
def normalize_device_identifier(nil), do: nil
|
||||||
|
def normalize_device_identifier(id) when is_atom(id), do: Atom.to_string(id)
|
||||||
|
def normalize_device_identifier(id) when is_binary(id), do: String.trim(id)
|
||||||
|
def normalize_device_identifier(_), do: nil
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -46,76 +46,129 @@ defmodule Aprsme.PacketConsumer do
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_events(
|
def handle_events(events, _from, state) do
|
||||||
events,
|
handle_batch_update(events, state)
|
||||||
_from,
|
end
|
||||||
%{batch: batch, batch_size: batch_size, max_batch_size: max_batch_size, timer: timer} = state
|
|
||||||
) do
|
|
||||||
# More efficient: prepend events to batch (O(n) where n = length of events)
|
|
||||||
new_batch = events ++ batch
|
|
||||||
new_batch_length = length(new_batch)
|
|
||||||
|
|
||||||
cond do
|
# Pattern matching for batch handling with optimized list operations
|
||||||
new_batch_length >= max_batch_size ->
|
defp handle_batch_update(events, %{batch: batch} = state) do
|
||||||
# If batch exceeds maximum size, process immediately and drop excess
|
# Optimize: Keep batch in reverse order for O(1) prepending
|
||||||
{process_batch, drop_batch} = Enum.split(new_batch, max_batch_size)
|
# Only reverse when processing
|
||||||
process_batch(process_batch)
|
new_batch = Enum.reverse(events, batch)
|
||||||
|
new_batch_length = batch_length(batch) + length(events)
|
||||||
|
|
||||||
# Log warning about dropping packets (sanitized)
|
handle_batch_by_size(new_batch, new_batch_length, state)
|
||||||
if length(drop_batch) > 0 do
|
end
|
||||||
Logger.warning("Dropped #{length(drop_batch)} packets due to batch size limit",
|
|
||||||
batch_info:
|
|
||||||
LogSanitizer.log_data(
|
|
||||||
dropped_count: length(drop_batch),
|
|
||||||
processed_count: length(process_batch),
|
|
||||||
reason: "batch_size_limit_exceeded"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Cancel and restart timer
|
# Pattern matching for different batch size scenarios
|
||||||
if timer, do: Process.cancel_timer(timer)
|
defp handle_batch_by_size(batch, length, %{max_batch_size: max} = state) when length >= max do
|
||||||
new_timer = Process.send_after(self(), :process_batch, state.batch_timeout)
|
handle_oversized_batch(batch, max, state)
|
||||||
{:noreply, [], %{state | batch: [], timer: new_timer}}
|
end
|
||||||
|
|
||||||
new_batch_length >= batch_size ->
|
defp handle_batch_by_size(batch, length, %{batch_size: size} = state) when length >= size do
|
||||||
# Process the batch immediately
|
handle_full_batch(batch, state)
|
||||||
process_batch(new_batch)
|
end
|
||||||
|
|
||||||
# Cancel and restart timer
|
defp handle_batch_by_size(batch, _length, state) do
|
||||||
if timer, do: Process.cancel_timer(timer)
|
handle_partial_batch(batch, state)
|
||||||
new_timer = Process.send_after(self(), :process_batch, state.batch_timeout)
|
end
|
||||||
{:noreply, [], %{state | batch: [], timer: new_timer}}
|
|
||||||
|
|
||||||
true ->
|
# Handle oversized batch with pattern matching
|
||||||
# Add to batch and wait for more
|
defp handle_oversized_batch(batch, max_size, state) do
|
||||||
{:noreply, [], %{state | batch: new_batch}}
|
# Reverse once for processing
|
||||||
end
|
reversed_batch = Enum.reverse(batch)
|
||||||
|
{process_batch, drop_batch} = Enum.split(reversed_batch, max_size)
|
||||||
|
|
||||||
|
process_batch(process_batch)
|
||||||
|
log_dropped_packets(drop_batch, process_batch)
|
||||||
|
|
||||||
|
new_state = reset_batch_timer(state)
|
||||||
|
{:noreply, [], new_state}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Handle full batch
|
||||||
|
defp handle_full_batch(batch, state) do
|
||||||
|
# Reverse once for processing
|
||||||
|
process_batch(Enum.reverse(batch))
|
||||||
|
|
||||||
|
new_state = reset_batch_timer(state)
|
||||||
|
{:noreply, [], new_state}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Handle partial batch
|
||||||
|
defp handle_partial_batch(batch, state) do
|
||||||
|
# Keep batch in reverse order
|
||||||
|
{:noreply, [], %{state | batch: batch}}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Helper functions with pattern matching
|
||||||
|
defp reset_batch_timer(%{timer: nil} = state) do
|
||||||
|
new_timer = Process.send_after(self(), :process_batch, state.batch_timeout)
|
||||||
|
%{state | batch: [], timer: new_timer}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp reset_batch_timer(%{timer: timer} = state) do
|
||||||
|
Process.cancel_timer(timer)
|
||||||
|
new_timer = Process.send_after(self(), :process_batch, state.batch_timeout)
|
||||||
|
%{state | batch: [], timer: new_timer}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Efficient batch length calculation (cached if possible)
|
||||||
|
defp batch_length([]), do: 0
|
||||||
|
defp batch_length(batch), do: length(batch)
|
||||||
|
|
||||||
|
# Pattern matching for logging
|
||||||
|
defp log_dropped_packets([], _), do: :ok
|
||||||
|
|
||||||
|
defp log_dropped_packets(dropped, processed) do
|
||||||
|
Logger.warning("Dropped #{length(dropped)} packets due to batch size limit",
|
||||||
|
batch_info:
|
||||||
|
LogSanitizer.log_data(
|
||||||
|
dropped_count: length(dropped),
|
||||||
|
processed_count: length(processed),
|
||||||
|
reason: "batch_size_limit_exceeded"
|
||||||
|
)
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_info(:process_batch, %{batch: batch, batch_timeout: timeout, max_batch_size: max_batch_size} = state) do
|
# Pattern matching for empty batch
|
||||||
if length(batch) > 0 do
|
def handle_info(:process_batch, %{batch: []} = state) do
|
||||||
# Check if batch size is concerning
|
# Just restart the timer for empty batch
|
||||||
if length(batch) > max_batch_size * 0.8 do
|
new_state = start_batch_timer(state)
|
||||||
Logger.warning("Batch size approaching limit",
|
{:noreply, [], new_state}
|
||||||
batch_status:
|
|
||||||
LogSanitizer.log_data(
|
|
||||||
current_size: length(batch),
|
|
||||||
max_size: max_batch_size,
|
|
||||||
utilization_percent: trunc(length(batch) / max_batch_size * 100)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
process_batch(batch)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Start a new timer
|
|
||||||
timer = Process.send_after(self(), :process_batch, timeout)
|
|
||||||
{:noreply, [], %{state | batch: [], timer: timer}}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Pattern matching for non-empty batch
|
||||||
|
def handle_info(:process_batch, %{batch: batch} = state) when is_list(batch) do
|
||||||
|
check_batch_utilization(batch, state.max_batch_size)
|
||||||
|
# Remember to reverse the batch since we keep it in reverse order
|
||||||
|
process_batch(Enum.reverse(batch))
|
||||||
|
|
||||||
|
new_state = start_batch_timer(%{state | batch: []})
|
||||||
|
{:noreply, [], new_state}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Helper to start batch timer
|
||||||
|
defp start_batch_timer(%{batch_timeout: timeout} = state) do
|
||||||
|
timer = Process.send_after(self(), :process_batch, timeout)
|
||||||
|
%{state | timer: timer}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Pattern matching for batch utilization check
|
||||||
|
defp check_batch_utilization(batch, max_size) when length(batch) > max_size * 0.8 do
|
||||||
|
Logger.warning("Batch size approaching limit",
|
||||||
|
batch_status:
|
||||||
|
LogSanitizer.log_data(
|
||||||
|
current_size: length(batch),
|
||||||
|
max_size: max_size,
|
||||||
|
utilization_percent: trunc(length(batch) / max_size * 100)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp check_batch_utilization(_, _), do: :ok
|
||||||
|
|
||||||
defp process_batch(packets) do
|
defp process_batch(packets) do
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,44 +26,66 @@ defmodule Aprsme.Packets do
|
||||||
def store_packet(packet_data) do
|
def store_packet(packet_data) do
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
try do
|
with {:ok, sanitized_data} <- sanitize_packet_data(packet_data),
|
||||||
# First sanitize the input data
|
{:ok, packet_attrs} <- build_packet_attrs(sanitized_data),
|
||||||
sanitized_packet_data = Aprsme.EncodingUtils.sanitize_packet(packet_data)
|
{:ok, packet} <- insert_packet(packet_attrs, packet_data) do
|
||||||
|
{:ok, packet}
|
||||||
|
else
|
||||||
|
{:error, reason} = error ->
|
||||||
|
Logger.error("Failed to store packet for #{inspect(packet_data[:sender])}: #{inspect(reason)}")
|
||||||
|
store_bad_packet(packet_data, reason)
|
||||||
|
error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
packet_attrs =
|
# Pattern matching for sanitization
|
||||||
sanitized_packet_data
|
defp sanitize_packet_data(packet_data) do
|
||||||
|> Packet.extract_additional_data(
|
{:ok, Aprsme.EncodingUtils.sanitize_packet(packet_data)}
|
||||||
sanitized_packet_data[:raw_packet] || sanitized_packet_data["raw_packet"] || ""
|
rescue
|
||||||
)
|
error -> {:error, {:sanitization_failed, error}}
|
||||||
|> normalize_packet_attrs()
|
end
|
||||||
|> set_received_at()
|
|
||||||
|> patch_lat_lon_from_data_extended()
|
|
||||||
|> then(fn attrs ->
|
|
||||||
{lat, lon} = extract_position(attrs)
|
|
||||||
set_lat_lon(attrs, lat, lon)
|
|
||||||
end)
|
|
||||||
|> normalize_ssid()
|
|
||||||
|> then(fn attrs ->
|
|
||||||
device_identifier = Aprsme.DeviceParser.extract_device_identifier(packet_data)
|
|
||||||
canonical_identifier = get_canonical_device_identifier(device_identifier)
|
|
||||||
Map.put(attrs, :device_identifier, canonical_identifier)
|
|
||||||
end)
|
|
||||||
|
|
||||||
# require Logger
|
# Build packet attributes with pipeline
|
||||||
# Logger.debug("Sanitized packet_attrs before insert: #{inspect(packet_attrs)}")
|
defp build_packet_attrs(sanitized_data) do
|
||||||
# Set device_identifier to parsed value, fallback to destination if nil
|
raw_packet = get_raw_packet(sanitized_data)
|
||||||
parsed_device_id = Aprsme.DeviceParser.extract_device_identifier(packet_data)
|
|
||||||
device_id = parsed_device_id || Map.get(packet_attrs, :destination)
|
|
||||||
packet_attrs = Map.put(packet_attrs, :device_identifier, device_id)
|
|
||||||
|
|
||||||
# Logger.debug("Inserting packet with device_identifier=#{inspect(device_id)}, destination=#{inspect(Map.get(packet_attrs, :destination))}")
|
attrs =
|
||||||
insert_packet(packet_attrs, packet_data)
|
sanitized_data
|
||||||
rescue
|
|> Packet.extract_additional_data(raw_packet)
|
||||||
error ->
|
|> normalize_packet_attrs()
|
||||||
Logger.error("Exception in store_packet for #{inspect(packet_data[:sender])}: #{inspect(error)}")
|
|> set_received_at()
|
||||||
|
|> patch_lat_lon_from_data_extended()
|
||||||
|
|> apply_position_data()
|
||||||
|
|> normalize_ssid()
|
||||||
|
|> apply_device_identifier(sanitized_data)
|
||||||
|
|
||||||
store_bad_packet(packet_data, error)
|
{:ok, attrs}
|
||||||
{:error, :storage_exception}
|
rescue
|
||||||
|
error -> {:error, {:build_attrs_failed, error}}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Pattern matching for raw packet extraction
|
||||||
|
defp get_raw_packet(%{raw_packet: raw}) when is_binary(raw), do: raw
|
||||||
|
defp get_raw_packet(%{"raw_packet" => raw}) when is_binary(raw), do: raw
|
||||||
|
defp get_raw_packet(_), do: ""
|
||||||
|
|
||||||
|
# Apply position data using pattern matching
|
||||||
|
defp apply_position_data(attrs) do
|
||||||
|
case extract_position(attrs) do
|
||||||
|
{nil, nil} -> attrs
|
||||||
|
{lat, lon} -> set_lat_lon(attrs, lat, lon)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Apply device identifier with pattern matching
|
||||||
|
defp apply_device_identifier(attrs, original_data) do
|
||||||
|
case Aprsme.DeviceParser.extract_device_identifier(original_data) do
|
||||||
|
nil ->
|
||||||
|
# Fallback to destination
|
||||||
|
Map.put(attrs, :device_identifier, Map.get(attrs, :destination))
|
||||||
|
|
||||||
|
device_id ->
|
||||||
|
Map.put(attrs, :device_identifier, get_canonical_device_identifier(device_id))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -93,47 +115,73 @@ defmodule Aprsme.Packets do
|
||||||
Map.put(attrs, :received_at, current_time)
|
Map.put(attrs, :received_at, current_time)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp patch_lat_lon_from_data_extended(attrs) do
|
# Pattern matching with guards for cleaner code
|
||||||
case attrs[:data_extended] do
|
defp patch_lat_lon_from_data_extended(%{data_extended: %{} = ext} = attrs) do
|
||||||
%{} = ext ->
|
ext_map = normalize_to_map(ext)
|
||||||
ext_map = if Map.has_key?(ext, :__struct__), do: Map.from_struct(ext), else: ext
|
|
||||||
lat = extract_lat_from_ext_map(ext_map)
|
|
||||||
lon = extract_lon_from_ext_map(ext_map)
|
|
||||||
latd = to_decimal(lat)
|
|
||||||
lond = to_decimal(lon)
|
|
||||||
|
|
||||||
if not is_nil(latd) and not is_nil(lond) do
|
with lat when not is_nil(lat) <- extract_lat_from_ext_map(ext_map),
|
||||||
attrs
|
lon when not is_nil(lon) <- extract_lon_from_ext_map(ext_map),
|
||||||
|> Map.put(:lat, latd)
|
{:ok, lat_decimal} <- to_decimal_safe(lat),
|
||||||
|> Map.put(:lon, lond)
|
{:ok, lon_decimal} <- to_decimal_safe(lon) do
|
||||||
else
|
attrs
|
||||||
attrs
|
|> Map.put(:lat, lat_decimal)
|
||||||
end
|
|> Map.put(:lon, lon_decimal)
|
||||||
|
else
|
||||||
_ ->
|
_ -> attrs
|
||||||
attrs
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp extract_lat_from_ext_map(ext_map) when is_map(ext_map) and not is_struct(ext_map) do
|
defp patch_lat_lon_from_data_extended(attrs), do: attrs
|
||||||
ext_map[:latitude] || ext_map["latitude"] ||
|
|
||||||
(Map.has_key?(ext_map, :position) &&
|
# Helper to normalize struct or map
|
||||||
(ext_map[:position][:latitude] || ext_map[:position]["latitude"])) ||
|
defp normalize_to_map(%{__struct__: _} = struct), do: Map.from_struct(struct)
|
||||||
(Map.has_key?(ext_map, "position") &&
|
defp normalize_to_map(map) when is_map(map), do: map
|
||||||
(ext_map["position"][:latitude] || ext_map["position"]["latitude"]))
|
|
||||||
|
# Safe decimal conversion with pattern matching
|
||||||
|
defp to_decimal_safe(nil), do: {:error, :nil_value}
|
||||||
|
|
||||||
|
defp to_decimal_safe(value) do
|
||||||
|
case to_decimal(value) do
|
||||||
|
nil -> {:error, :conversion_failed}
|
||||||
|
decimal -> {:ok, decimal}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp extract_lat_from_ext_map(_), do: nil
|
# Pattern matching for coordinate extraction
|
||||||
|
defp extract_lat_from_ext_map(ext_map) do
|
||||||
defp extract_lon_from_ext_map(ext_map) when is_map(ext_map) and not is_struct(ext_map) do
|
extract_coordinate(ext_map, :latitude)
|
||||||
ext_map[:longitude] || ext_map["longitude"] ||
|
|
||||||
(Map.has_key?(ext_map, :position) &&
|
|
||||||
(ext_map[:position][:longitude] || ext_map[:position]["longitude"])) ||
|
|
||||||
(Map.has_key?(ext_map, "position") &&
|
|
||||||
(ext_map["position"][:longitude] || ext_map["position"]["longitude"]))
|
|
||||||
end
|
end
|
||||||
|
|
||||||
defp extract_lon_from_ext_map(_), do: nil
|
defp extract_lon_from_ext_map(ext_map) do
|
||||||
|
extract_coordinate(ext_map, :longitude)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Unified coordinate extraction with pattern matching
|
||||||
|
defp extract_coordinate(%{} = map, coord_type) when coord_type in [:latitude, :longitude] do
|
||||||
|
coord_string = Atom.to_string(coord_type)
|
||||||
|
|
||||||
|
find_coordinate_value(map, coord_type) || find_coordinate_value(map, coord_string)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp extract_coordinate(_, _), do: nil
|
||||||
|
|
||||||
|
# Pattern matching for direct coordinate access
|
||||||
|
defp find_coordinate_value(%{latitude: lat}, :latitude) when not is_nil(lat), do: lat
|
||||||
|
defp find_coordinate_value(%{longitude: lon}, :longitude) when not is_nil(lon), do: lon
|
||||||
|
defp find_coordinate_value(%{"latitude" => lat}, "latitude") when not is_nil(lat), do: lat
|
||||||
|
defp find_coordinate_value(%{"longitude" => lon}, "longitude") when not is_nil(lon), do: lon
|
||||||
|
|
||||||
|
# Pattern matching for nested position access
|
||||||
|
defp find_coordinate_value(%{position: %{latitude: lat}}, :latitude) when not is_nil(lat), do: lat
|
||||||
|
defp find_coordinate_value(%{position: %{longitude: lon}}, :longitude) when not is_nil(lon), do: lon
|
||||||
|
defp find_coordinate_value(%{position: %{"latitude" => lat}}, :latitude) when not is_nil(lat), do: lat
|
||||||
|
defp find_coordinate_value(%{position: %{"longitude" => lon}}, :longitude) when not is_nil(lon), do: lon
|
||||||
|
defp find_coordinate_value(%{"position" => %{latitude: lat}}, "latitude") when not is_nil(lat), do: lat
|
||||||
|
defp find_coordinate_value(%{"position" => %{longitude: lon}}, "longitude") when not is_nil(lon), do: lon
|
||||||
|
defp find_coordinate_value(%{"position" => %{"latitude" => lat}}, "latitude") when not is_nil(lat), do: lat
|
||||||
|
defp find_coordinate_value(%{"position" => %{"longitude" => lon}}, "longitude") when not is_nil(lon), do: lon
|
||||||
|
|
||||||
|
defp find_coordinate_value(_, _), do: nil
|
||||||
|
|
||||||
defp set_lat_lon(attrs, lat, lon) do
|
defp set_lat_lon(attrs, lat, lon) do
|
||||||
# Optimize: avoid creating anonymous function on each call
|
# Optimize: avoid creating anonymous function on each call
|
||||||
|
|
@ -157,12 +205,11 @@ defmodule Aprsme.Packets do
|
||||||
|
|
||||||
defp round_coordinate(_), do: nil
|
defp round_coordinate(_), do: nil
|
||||||
|
|
||||||
defp normalize_ssid(attrs) do
|
# Pattern matching for SSID normalization
|
||||||
case Map.get(attrs, :ssid) do
|
defp normalize_ssid(%{ssid: nil} = attrs), do: attrs
|
||||||
nil -> attrs
|
defp normalize_ssid(%{ssid: ssid} = attrs) when is_binary(ssid), do: attrs
|
||||||
ssid -> Map.put(attrs, :ssid, to_string(ssid))
|
defp normalize_ssid(%{ssid: ssid} = attrs), do: Map.put(attrs, :ssid, to_string(ssid))
|
||||||
end
|
defp normalize_ssid(attrs), do: attrs
|
||||||
end
|
|
||||||
|
|
||||||
defp insert_packet(attrs, packet_data) do
|
defp insert_packet(attrs, packet_data) do
|
||||||
# Ensure data_extended is properly sanitized before insertion
|
# Ensure data_extended is properly sanitized before insertion
|
||||||
|
|
@ -349,13 +396,17 @@ defmodule Aprsme.Packets do
|
||||||
defp apply_direction(value, direction, negative_direction) when direction == negative_direction, do: -value
|
defp apply_direction(value, direction, negative_direction) when direction == negative_direction, do: -value
|
||||||
defp apply_direction(value, _direction, _negative_direction), do: value
|
defp apply_direction(value, _direction, _negative_direction), do: value
|
||||||
|
|
||||||
defp get_canonical_device_identifier(device_identifier) do
|
# Pattern matching for canonical device identifier lookup
|
||||||
|
defp get_canonical_device_identifier(device_identifier) when is_binary(device_identifier) do
|
||||||
case Aprsme.DeviceIdentification.lookup_device_by_identifier(device_identifier) do
|
case Aprsme.DeviceIdentification.lookup_device_by_identifier(device_identifier) do
|
||||||
%{identifier: canonical_id} -> canonical_id
|
%{identifier: canonical_id} when is_binary(canonical_id) -> canonical_id
|
||||||
nil -> device_identifier
|
_ -> device_identifier
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp get_canonical_device_identifier(nil), do: nil
|
||||||
|
defp get_canonical_device_identifier(device_identifier), do: to_string(device_identifier)
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Gets packets for replay.
|
Gets packets for replay.
|
||||||
|
|
||||||
|
|
|
||||||
365
lib/aprsme_web/live/map_live/components.ex
Normal file
365
lib/aprsme_web/live/map_live/components.ex
Normal file
|
|
@ -0,0 +1,365 @@
|
||||||
|
defmodule AprsmeWeb.MapLive.Components do
|
||||||
|
@moduledoc """
|
||||||
|
Reusable function components for the Map LiveView.
|
||||||
|
Extracted to improve maintainability and reduce the main LiveView module size.
|
||||||
|
"""
|
||||||
|
use AprsmeWeb, :html
|
||||||
|
|
||||||
|
attr :flash, :map, default: %{}
|
||||||
|
attr :slideover_open, :boolean, default: false
|
||||||
|
attr :rest, :global
|
||||||
|
|
||||||
|
def map_container(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div
|
||||||
|
id="aprs-map"
|
||||||
|
phx-update="ignore"
|
||||||
|
phx-hook="LeafletMap"
|
||||||
|
class={[
|
||||||
|
"slideover-open": @slideover_open,
|
||||||
|
"slideover-closed": !@slideover_open
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
attr :slideover_open, :boolean, required: true
|
||||||
|
attr :loading, :boolean, default: false
|
||||||
|
attr :connection_status, :string, default: "pending"
|
||||||
|
attr :packets, :list, default: []
|
||||||
|
attr :show_all_packets, :boolean, default: true
|
||||||
|
attr :tracked_callsign, :string, default: ""
|
||||||
|
attr :tracked_callsign_latest_packet, :map, default: nil
|
||||||
|
attr :rest, :global
|
||||||
|
|
||||||
|
def slideover_panel(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class={[
|
||||||
|
"slideover-panel",
|
||||||
|
"slideover-open": @slideover_open,
|
||||||
|
"slideover-closed": !@slideover_open
|
||||||
|
]}>
|
||||||
|
<.slideover_header {assigns} />
|
||||||
|
<.slideover_content {assigns} />
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp slideover_header(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="flex items-center justify-between p-4 border-b bg-gray-50">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900">APRS Packets</h2>
|
||||||
|
<button
|
||||||
|
phx-click="toggle_slideover"
|
||||||
|
class="p-2 text-gray-400 hover:text-gray-600 transition-colors"
|
||||||
|
>
|
||||||
|
<.icon name="hero-x-mark" class="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp slideover_content(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="flex-1 overflow-hidden flex flex-col">
|
||||||
|
<%= if @loading do %>
|
||||||
|
<.loading_indicator />
|
||||||
|
<% else %>
|
||||||
|
<.packet_controls {assigns} />
|
||||||
|
<.packet_list {assigns} />
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp loading_indicator(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="flex items-center justify-center h-full">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="inline-flex items-center">
|
||||||
|
<svg class="animate-spin h-8 w-8 text-blue-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path
|
||||||
|
class="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
>
|
||||||
|
</path>
|
||||||
|
</svg>
|
||||||
|
<span class="ml-3 text-lg text-gray-700">Loading packets...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp packet_controls(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="p-4 border-b space-y-3">
|
||||||
|
<.connection_indicator connection_status={@connection_status} />
|
||||||
|
<.view_toggle show_all_packets={@show_all_packets} />
|
||||||
|
<.callsign_filter
|
||||||
|
tracked_callsign={@tracked_callsign}
|
||||||
|
tracked_callsign_latest_packet={@tracked_callsign_latest_packet}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp connection_indicator(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<div class={[
|
||||||
|
"h-3 w-3 rounded-full",
|
||||||
|
"bg-green-500": @connection_status == "connected",
|
||||||
|
"bg-yellow-500": @connection_status == "connecting",
|
||||||
|
"bg-red-500": @connection_status == "disconnected",
|
||||||
|
"bg-gray-400": @connection_status == "pending"
|
||||||
|
]}>
|
||||||
|
</div>
|
||||||
|
<span class="text-sm text-gray-700">
|
||||||
|
<%= case @connection_status do %>
|
||||||
|
<% "connected" -> %>
|
||||||
|
Connected to APRS-IS
|
||||||
|
<% "connecting" -> %>
|
||||||
|
Connecting to APRS-IS...
|
||||||
|
<% "disconnected" -> %>
|
||||||
|
Disconnected from APRS-IS
|
||||||
|
<% _ -> %>
|
||||||
|
Initializing...
|
||||||
|
<% end %>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp view_toggle(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<label class="inline-flex items-center">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="view_mode"
|
||||||
|
value="all"
|
||||||
|
checked={@show_all_packets}
|
||||||
|
phx-click="toggle_view_mode"
|
||||||
|
phx-value-mode="all"
|
||||||
|
class="text-blue-600"
|
||||||
|
/>
|
||||||
|
<span class="ml-2 text-sm">All Packets</span>
|
||||||
|
</label>
|
||||||
|
<label class="inline-flex items-center">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="view_mode"
|
||||||
|
value="with_position"
|
||||||
|
checked={!@show_all_packets}
|
||||||
|
phx-click="toggle_view_mode"
|
||||||
|
phx-value-mode="with_position"
|
||||||
|
class="text-blue-600"
|
||||||
|
/>
|
||||||
|
<span class="ml-2 text-sm">With Position</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp callsign_filter(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div>
|
||||||
|
<form phx-submit="filter_callsign" class="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="callsign"
|
||||||
|
value={@tracked_callsign}
|
||||||
|
placeholder="Filter by callsign..."
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md pr-8 text-sm focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
phx-debounce="300"
|
||||||
|
phx-change="filter_callsign"
|
||||||
|
/>
|
||||||
|
<%= if @tracked_callsign != "" do %>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
phx-click="clear_callsign_filter"
|
||||||
|
class="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
<.icon name="hero-x-mark" class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<% end %>
|
||||||
|
</form>
|
||||||
|
<%= if @tracked_callsign_latest_packet do %>
|
||||||
|
<div class="mt-2 text-xs text-gray-600">
|
||||||
|
Last seen: {format_time_ago(@tracked_callsign_latest_packet.received_at)}
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp packet_list(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div id="packets" class="flex-1 overflow-y-auto p-4 space-y-3" phx-update="stream">
|
||||||
|
<%= if @packets == [] do %>
|
||||||
|
<div class="text-center text-gray-500 py-8">
|
||||||
|
No packets received yet...
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<div :for={{id, packet} <- @packets} id={id}>
|
||||||
|
<.packet_card packet={packet} />
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp packet_card(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="bg-white border border-gray-200 rounded-lg p-4 hover:border-blue-300 transition-colors">
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="font-semibold text-gray-900">
|
||||||
|
{@packet.sender}
|
||||||
|
<%= if @packet.ssid && @packet.ssid != "0" do %>
|
||||||
|
<span class="text-gray-500">-{@packet.ssid}</span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<%= if @packet.has_position do %>
|
||||||
|
<div class="text-sm text-gray-600 mt-1">
|
||||||
|
📍 {format_coordinates(@packet.lat, @packet.lon)}
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<%= if @packet.comment do %>
|
||||||
|
<div class="text-sm text-gray-700 mt-2">
|
||||||
|
{@packet.comment}
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<div class="text-xs text-gray-500 mt-2">
|
||||||
|
via {@packet.path || "direct"} • {format_time_ago(@packet.received_at)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<%= if @packet.has_position do %>
|
||||||
|
<button
|
||||||
|
phx-click="center_on_packet"
|
||||||
|
phx-value-lat={@packet.lat}
|
||||||
|
phx-value-lon={@packet.lon}
|
||||||
|
phx-value-sender={@packet.sender}
|
||||||
|
class="ml-2 p-2 text-blue-600 hover:bg-blue-50 rounded transition-colors"
|
||||||
|
title="Center map on this location"
|
||||||
|
>
|
||||||
|
<.icon name="hero-map-pin" class="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
# Helper functions
|
||||||
|
defp format_time_ago(datetime) do
|
||||||
|
# Implement time ago formatting
|
||||||
|
case DateTime.diff(DateTime.utc_now(), datetime, :second) do
|
||||||
|
seconds when seconds < 60 -> "#{seconds}s ago"
|
||||||
|
seconds when seconds < 3600 -> "#{div(seconds, 60)}m ago"
|
||||||
|
seconds when seconds < 86_400 -> "#{div(seconds, 3600)}h ago"
|
||||||
|
seconds -> "#{div(seconds, 86_400)}d ago"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp format_coordinates(lat, lon) when is_number(lat) and is_number(lon) do
|
||||||
|
"#{Float.round(lat * 1.0, 4)}, #{Float.round(lon * 1.0, 4)}"
|
||||||
|
end
|
||||||
|
|
||||||
|
defp format_coordinates(_, _), do: "Unknown location"
|
||||||
|
|
||||||
|
# Style component for better organization
|
||||||
|
def map_styles(assigns) do
|
||||||
|
~H"""
|
||||||
|
<style>
|
||||||
|
#aprs-map {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
height: 100vh;
|
||||||
|
z-index: 1;
|
||||||
|
transition: right 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Desktop styles */
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
#aprs-map.slideover-open {
|
||||||
|
right: 352px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#aprs-map.slideover-closed {
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Slideover panel base styles */
|
||||||
|
.slideover-panel {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 352px;
|
||||||
|
background: white;
|
||||||
|
box-shadow: -2px 0 8px rgba(0, 0, 0, 0.1);
|
||||||
|
z-index: 50;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
transition: transform 0.3s ease-in-out;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slideover-panel.slideover-open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slideover-panel.slideover-closed {
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile responsiveness */
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
#aprs-map {
|
||||||
|
right: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slideover-panel {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading spinner animation */
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-spin {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom scrollbar for packet list */
|
||||||
|
#packets::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#packets::-webkit-scrollbar-track {
|
||||||
|
background: #f1f1f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#packets::-webkit-scrollbar-thumb {
|
||||||
|
background: #888;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#packets::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #555;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -5,9 +5,11 @@ defmodule AprsmeWeb.MapLive.Index do
|
||||||
use AprsmeWeb, :live_view
|
use AprsmeWeb, :live_view
|
||||||
|
|
||||||
import AprsmeWeb.Live.Shared.PacketUtils, only: [get_callsign_key: 1]
|
import AprsmeWeb.Live.Shared.PacketUtils, only: [get_callsign_key: 1]
|
||||||
|
import AprsmeWeb.MapLive.Components
|
||||||
import AprsmeWeb.TimeHelpers, only: [time_ago_in_words: 1]
|
import AprsmeWeb.TimeHelpers, only: [time_ago_in_words: 1]
|
||||||
import Phoenix.LiveView, only: [connected?: 1, push_event: 3, push_patch: 2, put_flash: 3]
|
import Phoenix.LiveView, only: [connected?: 1, push_event: 3, push_patch: 2, put_flash: 3]
|
||||||
|
|
||||||
|
# Import the new components module
|
||||||
alias Aprsme.Packets
|
alias Aprsme.Packets
|
||||||
alias Aprsme.Packets.Clustering
|
alias Aprsme.Packets.Clustering
|
||||||
alias AprsmeWeb.Endpoint
|
alias AprsmeWeb.Endpoint
|
||||||
|
|
@ -995,8 +997,61 @@ defmodule AprsmeWeb.MapLive.Index do
|
||||||
@impl true
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<%!-- All vendor libraries are now loaded from vendor.js bundle --%>
|
<.map_styles />
|
||||||
|
|
||||||
|
<.map_container slideover_open={@slideover_open} />
|
||||||
|
|
||||||
|
<.locate_button />
|
||||||
|
|
||||||
|
<.slideover_panel
|
||||||
|
slideover_open={@slideover_open}
|
||||||
|
loading={@loading}
|
||||||
|
connection_status={@connection_status}
|
||||||
|
packets={@streams.packets}
|
||||||
|
show_all_packets={@show_all_packets}
|
||||||
|
tracked_callsign={@tracked_callsign}
|
||||||
|
tracked_callsign_latest_packet={@tracked_callsign_latest_packet}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<.toggle_button slideover_open={@slideover_open} />
|
||||||
|
|
||||||
|
<.bottom_controls {assigns} />
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
# Additional component functions that weren't extracted yet
|
||||||
|
defp locate_button(assigns) do
|
||||||
|
~H"""
|
||||||
|
<button class="locate-button" phx-click="locate_me" title="Locate me">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp toggle_button(assigns) do
|
||||||
|
~H"""
|
||||||
|
<button
|
||||||
|
phx-click="toggle_slideover"
|
||||||
|
class={[
|
||||||
|
"fixed right-4 top-4 z-40 bg-white rounded-lg shadow-lg p-3",
|
||||||
|
"hover:bg-gray-50 transition-colors lg:hidden",
|
||||||
|
hidden: @slideover_open
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<.icon name="hero-bars-3" class="w-6 h-6 text-gray-700" />
|
||||||
|
</button>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp bottom_controls(assigns) do
|
||||||
|
~H"""
|
||||||
|
<%!-- Existing bottom controls code will go here --%>
|
||||||
<style>
|
<style>
|
||||||
#aprs-map {
|
#aprs-map {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue