From 070b8870df23d4db87ce711dfa3958cc2bd217cd Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sun, 12 Oct 2025 10:56:49 -0500 Subject: [PATCH] refactor --- .gitignore | 3 + lib/aprsme/device_parser.ex | 59 ++-- lib/aprsme/packet_consumer.ex | 171 ++++++---- lib/aprsme/packets.ex | 203 +++++++----- lib/aprsme_web/live/map_live/components.ex | 365 +++++++++++++++++++++ lib/aprsme_web/live/map_live/index.ex | 57 +++- 6 files changed, 699 insertions(+), 159 deletions(-) create mode 100644 lib/aprsme_web/live/map_live/components.ex diff --git a/.gitignore b/.gitignore index c75ec5b..26716b9 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,6 @@ gleam@@compile.erl /test/screenshots/ .claudecheckpoints/ + +# Expert language server directory +/.expert diff --git a/lib/aprsme/device_parser.ex b/lib/aprsme/device_parser.ex index b2b331e..3be31a6 100644 --- a/lib/aprsme/device_parser.ex +++ b/lib/aprsme/device_parser.ex @@ -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 diff --git a/lib/aprsme/packet_consumer.ex b/lib/aprsme/packet_consumer.ex index 6f65504..30aa932 100644 --- a/lib/aprsme/packet_consumer.ex +++ b/lib/aprsme/packet_consumer.ex @@ -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 diff --git a/lib/aprsme/packets.ex b/lib/aprsme/packets.ex index 0d516d0..0460046 100644 --- a/lib/aprsme/packets.ex +++ b/lib/aprsme/packets.ex @@ -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. diff --git a/lib/aprsme_web/live/map_live/components.ex b/lib/aprsme_web/live/map_live/components.ex new file mode 100644 index 0000000..1f24e4d --- /dev/null +++ b/lib/aprsme_web/live/map_live/components.ex @@ -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""" +
+
+ """ + 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""" +
+ <.slideover_header {assigns} /> + <.slideover_content {assigns} /> +
+ """ + end + + defp slideover_header(assigns) do + ~H""" +
+

APRS Packets

+ +
+ """ + end + + defp slideover_content(assigns) do + ~H""" +
+ <%= if @loading do %> + <.loading_indicator /> + <% else %> + <.packet_controls {assigns} /> + <.packet_list {assigns} /> + <% end %> +
+ """ + end + + defp loading_indicator(assigns) do + ~H""" +
+
+
+ + + + + + Loading packets... +
+
+
+ """ + end + + defp packet_controls(assigns) do + ~H""" +
+ <.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} + /> +
+ """ + end + + defp connection_indicator(assigns) do + ~H""" +
+
+
+ + <%= case @connection_status do %> + <% "connected" -> %> + Connected to APRS-IS + <% "connecting" -> %> + Connecting to APRS-IS... + <% "disconnected" -> %> + Disconnected from APRS-IS + <% _ -> %> + Initializing... + <% end %> + +
+ """ + end + + defp view_toggle(assigns) do + ~H""" +
+ + +
+ """ + end + + defp callsign_filter(assigns) do + ~H""" +
+
+ + <%= if @tracked_callsign != "" do %> + + <% end %> +
+ <%= if @tracked_callsign_latest_packet do %> +
+ Last seen: {format_time_ago(@tracked_callsign_latest_packet.received_at)} +
+ <% end %> +
+ """ + end + + defp packet_list(assigns) do + ~H""" +
+ <%= if @packets == [] do %> +
+ No packets received yet... +
+ <% else %> +
+ <.packet_card packet={packet} /> +
+ <% end %> +
+ """ + end + + defp packet_card(assigns) do + ~H""" +
+
+
+
+ {@packet.sender} + <%= if @packet.ssid && @packet.ssid != "0" do %> + -{@packet.ssid} + <% end %> +
+ <%= if @packet.has_position do %> +
+ 📍 {format_coordinates(@packet.lat, @packet.lon)} +
+ <% end %> + <%= if @packet.comment do %> +
+ {@packet.comment} +
+ <% end %> +
+ via {@packet.path || "direct"} • {format_time_ago(@packet.received_at)} +
+
+ <%= if @packet.has_position do %> + + <% end %> +
+
+ """ + 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""" + + """ + end +end diff --git a/lib/aprsme_web/live/map_live/index.ex b/lib/aprsme_web/live/map_live/index.ex index 69eb9d6..41e73f2 100644 --- a/lib/aprsme_web/live/map_live/index.ex +++ b/lib/aprsme_web/live/map_live/index.ex @@ -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""" + + """ + end + + defp toggle_button(assigns) do + ~H""" + + """ + end + + defp bottom_controls(assigns) do + ~H""" + <%!-- Existing bottom controls code will go here --%>