This commit is contained in:
Graham McIntire 2025-10-12 10:56:49 -05:00
parent 265c58b9ff
commit 070b8870df
No known key found for this signature in database
6 changed files with 699 additions and 159 deletions

3
.gitignore vendored
View file

@ -54,3 +54,6 @@ gleam@@compile.erl
/test/screenshots/
.claudecheckpoints/
# Expert language server directory
/.expert

View file

@ -1,6 +1,6 @@
defmodule Aprsme.DeviceParser do
@moduledoc """
Extracts device identifiers from APRS packet data.
Extracts device identifiers from APRS packet data using pattern matching.
"""
@doc """
@ -8,35 +8,48 @@ defmodule Aprsme.DeviceParser do
Returns the device identifier string or nil if not found.
"""
@spec extract_device_identifier(map()) :: String.t() | nil
def extract_device_identifier(packet_data) do
extract_from_destination(packet_data) ||
extract_from_device_identifier(packet_data) ||
extract_from_data_extended(packet_data)
def extract_device_identifier(packet_data) when is_map(packet_data) do
find_device_identifier(packet_data)
end
defp extract_from_destination(packet_data) do
get_field_value(packet_data, :destination) || get_field_value(packet_data, "destination")
def extract_device_identifier(_), do: nil
# 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
defp extract_from_device_identifier(packet_data) do
get_field_value(packet_data, :device_identifier) || get_field_value(packet_data, "device_identifier")
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
defp get_field_value(packet_data, key) do
if Map.has_key?(packet_data, key) do
Map.get(packet_data, key)
end
defp find_device_identifier(_), do: nil
# Pattern matching for symbol data extraction
defp extract_from_symbol_data(%{symbol_table_id: table, symbol_code: code})
when is_binary(table) and is_binary(code) do
"#{table}#{code}"
end
defp extract_from_data_extended(packet_data) do
data_extended = Map.get(packet_data, :data_extended) || %{}
# 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
defp extract_from_symbol_data(%{"symbol_table_id" => table, "symbol_code" => code})
when is_binary(table) and is_binary(code) do
"#{table}#{code}"
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

View file

@ -46,76 +46,129 @@ defmodule Aprsme.PacketConsumer do
end
@impl true
def handle_events(
events,
_from,
%{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)
def handle_events(events, _from, state) do
handle_batch_update(events, state)
end
cond do
new_batch_length >= max_batch_size ->
# If batch exceeds maximum size, process immediately and drop excess
{process_batch, drop_batch} = Enum.split(new_batch, max_batch_size)
process_batch(process_batch)
# Pattern matching for batch handling with optimized list operations
defp handle_batch_update(events, %{batch: batch} = state) do
# Optimize: Keep batch in reverse order for O(1) prepending
# Only reverse when processing
new_batch = Enum.reverse(events, batch)
new_batch_length = batch_length(batch) + length(events)
# Log warning about dropping packets (sanitized)
if length(drop_batch) > 0 do
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
handle_batch_by_size(new_batch, new_batch_length, state)
end
# Cancel and restart timer
if timer, do: Process.cancel_timer(timer)
new_timer = Process.send_after(self(), :process_batch, state.batch_timeout)
{:noreply, [], %{state | batch: [], timer: new_timer}}
# Pattern matching for different batch size scenarios
defp handle_batch_by_size(batch, length, %{max_batch_size: max} = state) when length >= max do
handle_oversized_batch(batch, max, state)
end
new_batch_length >= batch_size ->
# Process the batch immediately
process_batch(new_batch)
defp handle_batch_by_size(batch, length, %{batch_size: size} = state) when length >= size do
handle_full_batch(batch, state)
end
# Cancel and restart timer
if timer, do: Process.cancel_timer(timer)
new_timer = Process.send_after(self(), :process_batch, state.batch_timeout)
{:noreply, [], %{state | batch: [], timer: new_timer}}
defp handle_batch_by_size(batch, _length, state) do
handle_partial_batch(batch, state)
end
true ->
# Add to batch and wait for more
{:noreply, [], %{state | batch: new_batch}}
end
# Handle oversized batch with pattern matching
defp handle_oversized_batch(batch, max_size, state) do
# Reverse once for processing
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
@impl true
def handle_info(:process_batch, %{batch: batch, batch_timeout: timeout, max_batch_size: max_batch_size} = state) do
if length(batch) > 0 do
# Check if batch size is concerning
if length(batch) > max_batch_size * 0.8 do
Logger.warning("Batch size approaching limit",
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}}
# Pattern matching for empty batch
def handle_info(:process_batch, %{batch: []} = state) do
# Just restart the timer for empty batch
new_state = start_batch_timer(state)
{:noreply, [], new_state}
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
require Logger

View file

@ -26,44 +26,66 @@ defmodule Aprsme.Packets do
def store_packet(packet_data) do
require Logger
try do
# First sanitize the input data
sanitized_packet_data = Aprsme.EncodingUtils.sanitize_packet(packet_data)
with {:ok, sanitized_data} <- sanitize_packet_data(packet_data),
{:ok, packet_attrs} <- build_packet_attrs(sanitized_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 =
sanitized_packet_data
|> Packet.extract_additional_data(
sanitized_packet_data[:raw_packet] || sanitized_packet_data["raw_packet"] || ""
)
|> normalize_packet_attrs()
|> 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)
# Pattern matching for sanitization
defp sanitize_packet_data(packet_data) do
{:ok, Aprsme.EncodingUtils.sanitize_packet(packet_data)}
rescue
error -> {:error, {:sanitization_failed, error}}
end
# require Logger
# Logger.debug("Sanitized packet_attrs before insert: #{inspect(packet_attrs)}")
# Set device_identifier to parsed value, fallback to destination if nil
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)
# Build packet attributes with pipeline
defp build_packet_attrs(sanitized_data) do
raw_packet = get_raw_packet(sanitized_data)
# Logger.debug("Inserting packet with device_identifier=#{inspect(device_id)}, destination=#{inspect(Map.get(packet_attrs, :destination))}")
insert_packet(packet_attrs, packet_data)
rescue
error ->
Logger.error("Exception in store_packet for #{inspect(packet_data[:sender])}: #{inspect(error)}")
attrs =
sanitized_data
|> Packet.extract_additional_data(raw_packet)
|> normalize_packet_attrs()
|> 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)
{:error, :storage_exception}
{:ok, attrs}
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
@ -93,47 +115,73 @@ defmodule Aprsme.Packets do
Map.put(attrs, :received_at, current_time)
end
defp patch_lat_lon_from_data_extended(attrs) do
case attrs[:data_extended] do
%{} = 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)
# Pattern matching with guards for cleaner code
defp patch_lat_lon_from_data_extended(%{data_extended: %{} = ext} = attrs) do
ext_map = normalize_to_map(ext)
if not is_nil(latd) and not is_nil(lond) do
attrs
|> Map.put(:lat, latd)
|> Map.put(:lon, lond)
else
attrs
end
_ ->
attrs
with lat when not is_nil(lat) <- extract_lat_from_ext_map(ext_map),
lon when not is_nil(lon) <- extract_lon_from_ext_map(ext_map),
{:ok, lat_decimal} <- to_decimal_safe(lat),
{:ok, lon_decimal} <- to_decimal_safe(lon) do
attrs
|> Map.put(:lat, lat_decimal)
|> Map.put(:lon, lon_decimal)
else
_ -> attrs
end
end
defp extract_lat_from_ext_map(ext_map) when is_map(ext_map) and not is_struct(ext_map) do
ext_map[:latitude] || ext_map["latitude"] ||
(Map.has_key?(ext_map, :position) &&
(ext_map[:position][:latitude] || ext_map[:position]["latitude"])) ||
(Map.has_key?(ext_map, "position") &&
(ext_map["position"][:latitude] || ext_map["position"]["latitude"]))
defp patch_lat_lon_from_data_extended(attrs), do: attrs
# Helper to normalize struct or map
defp normalize_to_map(%{__struct__: _} = struct), do: Map.from_struct(struct)
defp normalize_to_map(map) when is_map(map), do: map
# 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
defp extract_lat_from_ext_map(_), do: nil
defp extract_lon_from_ext_map(ext_map) when is_map(ext_map) and not is_struct(ext_map) do
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"]))
# Pattern matching for coordinate extraction
defp extract_lat_from_ext_map(ext_map) do
extract_coordinate(ext_map, :latitude)
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
# Optimize: avoid creating anonymous function on each call
@ -157,12 +205,11 @@ defmodule Aprsme.Packets do
defp round_coordinate(_), do: nil
defp normalize_ssid(attrs) do
case Map.get(attrs, :ssid) do
nil -> attrs
ssid -> Map.put(attrs, :ssid, to_string(ssid))
end
end
# Pattern matching for SSID normalization
defp normalize_ssid(%{ssid: nil} = attrs), do: attrs
defp normalize_ssid(%{ssid: ssid} = attrs) when is_binary(ssid), do: attrs
defp normalize_ssid(%{ssid: ssid} = attrs), do: Map.put(attrs, :ssid, to_string(ssid))
defp normalize_ssid(attrs), do: attrs
defp insert_packet(attrs, packet_data) do
# 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), 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
%{identifier: canonical_id} -> canonical_id
nil -> device_identifier
%{identifier: canonical_id} when is_binary(canonical_id) -> canonical_id
_ -> device_identifier
end
end
defp get_canonical_device_identifier(nil), do: nil
defp get_canonical_device_identifier(device_identifier), do: to_string(device_identifier)
@doc """
Gets packets for replay.

View 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

View file

@ -5,9 +5,11 @@ defmodule AprsmeWeb.MapLive.Index do
use AprsmeWeb, :live_view
import AprsmeWeb.Live.Shared.PacketUtils, only: [get_callsign_key: 1]
import AprsmeWeb.MapLive.Components
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 the new components module
alias Aprsme.Packets
alias Aprsme.Packets.Clustering
alias AprsmeWeb.Endpoint
@ -995,8 +997,61 @@ defmodule AprsmeWeb.MapLive.Index do
@impl true
def render(assigns) do
~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>
#aprs-map {
position: absolute;