From c5b5d0762ae0d216385955818487349dfc78d154 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Fri, 13 Jun 2025 16:17:35 -0500 Subject: [PATCH] map improvements --- assets/css/app.css | 31 +- assets/js/app.js | 202 ++++++++++++- lib/aprs/is/is.ex | 57 +++- lib/aprs/is/is_supervisor.ex | 6 +- lib/aprs_web/components/layouts.ex | 16 + .../components/layouts/root.html.heex | 2 +- lib/aprs_web/controllers/page_controller.ex | 6 +- .../controllers/page_html/home.html.heex | 236 --------------- lib/aprs_web/live/map_live/index.ex | 274 ++++++++++++++++++ lib/aprs_web/live/map_live/index.old | 248 ++++++++++++++++ lib/aprs_web/router.ex | 2 +- ...vicon-a8ca4e3a2bb8fea46a9ee9e102e7d3eb.ico | Bin 0 -> 1258 bytes ...oenix-5bd99a0d17dd41bc9d9bf6840abcc089.png | Bin 0 -> 13900 bytes ...obots-9e2c81b0855bbff2baa8371bc4a78186.txt | 5 + ...ts-9e2c81b0855bbff2baa8371bc4a78186.txt.gz | Bin 0 -> 164 bytes priv/static/robots.txt.gz | Bin 0 -> 164 bytes .../controllers/page_controller_test.exs | 8 - tmp/index.ex.backup | 248 ++++++++++++++++ 18 files changed, 1072 insertions(+), 269 deletions(-) delete mode 100644 lib/aprs_web/controllers/page_html/home.html.heex create mode 100644 lib/aprs_web/live/map_live/index.ex create mode 100644 lib/aprs_web/live/map_live/index.old create mode 100644 priv/static/favicon-a8ca4e3a2bb8fea46a9ee9e102e7d3eb.ico create mode 100644 priv/static/images/phoenix-5bd99a0d17dd41bc9d9bf6840abcc089.png create mode 100644 priv/static/robots-9e2c81b0855bbff2baa8371bc4a78186.txt create mode 100644 priv/static/robots-9e2c81b0855bbff2baa8371bc4a78186.txt.gz create mode 100644 priv/static/robots.txt.gz delete mode 100644 test/aprs_web/controllers/page_controller_test.exs create mode 100644 tmp/index.ex.backup diff --git a/assets/css/app.css b/assets/css/app.css index be6d80e..289d6ee 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -5,5 +5,34 @@ /* This file is for your main application CSS */ #map { - height: 500px; + height: calc(100vh - 60px); /* Adjust based on header height */ + width: 100%; +} + +/* Full page map for APRS home page */ +#aprs-map { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + height: 100vh; + width: 100%; +} + +/* Hide header on home page */ +body.home-page header { + display: none; +} + +/* Adjust main content area for full page map */ +body.home-page main { + padding: 0; + max-width: none; + height: 100vh; +} + +body.home-page main > div { + max-width: none; + height: 100%; } diff --git a/assets/js/app.js b/assets/js/app.js index 9a01059..e4efb23 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -22,18 +22,208 @@ import { Socket } from "phoenix"; import { LiveSocket } from "phoenix_live_view"; import topbar from "../vendor/topbar"; -let csrfToken = document - .querySelector("meta[name='csrf-token']") - .getAttribute("content"); +let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content"); + +// APRS Map Hook +let Hooks = {}; +Hooks.APRSMap = { + mounted() { + console.log("APRSMap hook mounted"); + // Initialize the map centered on the United States + const map = L.map(this.el).setView([39.8283, -98.5795], 5); + console.log("Map initialized:", map); + + // Add OpenStreetMap tile layer + L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { + attribution: + '© OpenStreetMap contributors | APRS.me', + maxZoom: 19, + }).addTo(map); + + // Store markers to avoid duplicates + this.markers = new Map(); + this.packetCount = 0; + + // Store map instance + this.map = map; + + // Send initial bounds to server + this.sendBoundsToServer(); + + // Listen for new packets from the server + this.handleEvent("new_packet", (packet) => { + console.log("Received new packet:", packet); + this.addPacketMarker(packet); + }); + + // Listen for clear markers event + this.handleEvent("clear_markers", () => { + this.clearAllMarkers(); + }); + + // Update bounds when map moves or zooms + map.on("moveend", () => { + this.sendBoundsToServer(); + }); + + // Handle map resize when window is resized + window.addEventListener("resize", () => { + map.invalidateSize(); + }); + }, + + sendBoundsToServer() { + const bounds = this.map.getBounds(); + this.pushEvent("update_bounds", { + bounds: { + north: bounds.getNorth(), + south: bounds.getSouth(), + east: bounds.getEast(), + west: bounds.getWest(), + }, + }); + + // Remove markers that are now outside the visible bounds + this.removeMarkersOutsideBounds(bounds); + }, + + clearAllMarkers() { + // Remove all markers from the map + this.markers.forEach((marker) => { + this.map.removeLayer(marker); + }); + this.markers.clear(); + this.packetCount = 0; + const counterElement = document.getElementById("packet-count"); + if (counterElement) { + counterElement.textContent = this.packetCount; + } + }, + + removeMarkersOutsideBounds(bounds) { + // Remove markers that are outside the current bounds + const markersToRemove = []; + + this.markers.forEach((marker, callsign) => { + const latLng = marker.getLatLng(); + if (!bounds.contains(latLng)) { + this.map.removeLayer(marker); + markersToRemove.push(callsign); + } + }); + + // Remove from our tracking map + markersToRemove.forEach((callsign) => { + this.markers.delete(callsign); + this.packetCount--; + }); + + // Update counter + const counterElement = document.getElementById("packet-count"); + if (counterElement) { + counterElement.textContent = this.packetCount; + } + + // Notify server of the updated packet count + this.pushEvent("update_packet_count", { count: this.packetCount }); + }, + + addPacketMarker(packet) { + console.log("addPacketMarker called with:", packet); + if ( + !packet["data_extended"] || + !packet["data_extended"]["latitude"] || + !packet["data_extended"]["longitude"] + ) { + console.warn("Packet missing required location data:", packet); + return; + } + + const lat = parseFloat(packet["data_extended"]["latitude"]); + const lng = parseFloat(packet["data_extended"]["longitude"]); + + // Validate coordinates are within valid ranges + if (isNaN(lat) || isNaN(lng) || lat < -90 || lat > 90 || lng < -180 || lng > 180) { + console.error("Invalid coordinates:", { lat, lng, packet }); + return; + } + console.log("Valid coordinates:", { lat, lng, callsign: packet["base_callsign"] }); + const callsign = packet["base_callsign"] + (packet["ssid"] ? "-" + packet["ssid"] : ""); + + // Create popup content + const popupContent = ` +
+

${callsign}

+

+ Position: ${lat.toFixed(4)}°, ${lng.toFixed(4)}°
+ Type: ${packet["data_type"]}
+ ${packet["data_extended"]["comment"] ? `Comment: ${packet["data_extended"]["comment"]}
` : ""} + Path: ${packet["path"]}
+ Time: ${new Date().toLocaleTimeString()} +

+
+ `; + + // Check if marker already exists + if (this.markers.has(callsign)) { + // Update existing marker + const existingMarker = this.markers.get(callsign); + existingMarker.setLatLng([lat, lng]); + existingMarker.setPopupContent(popupContent); + } else { + // Create new marker + const icon = this.createAPRSIcon( + packet["data_extended"]["symbol_table_id"] || "/", + packet["data_extended"]["symbol_code"] || ">", + ); + + const marker = L.marker([lat, lng], { icon: icon }).addTo(this.map).bindPopup(popupContent); + + this.markers.set(callsign, marker); + console.log("New marker added for:", callsign, "Total markers:", this.markers.size); + + // Update packet counter + this.packetCount++; + const counterElement = document.getElementById("packet-count"); + if (counterElement) { + counterElement.textContent = this.packetCount; + } + } + }, + + createAPRSIcon(symbolTable, symbolCode) { + // Default icon color based on symbol table + const color = symbolTable === "/" ? "#2563eb" : "#dc2626"; + + return L.divIcon({ + html: `
`, + className: "aprs-marker", + iconSize: [16, 16], + iconAnchor: [8, 8], + popupAnchor: [0, -8], + }); + }, + + destroyed() { + console.log("APRSMap hook destroyed"); + if (this.map) { + console.log("Removing map instance"); + this.map.remove(); + this.map = null; + } + this.markers.clear(); + }, +}; + let liveSocket = new LiveSocket("/live", Socket, { + longPollFallbackMs: 2500, params: { _csrf_token: csrfToken }, + hooks: Hooks, }); // Show progress bar on live navigation and form submits topbar.config({ barColors: { 0: "#29d" }, shadowColor: "rgba(0, 0, 0, .3)" }); -window.addEventListener("phx:page-loading-start", (info) => - topbar.delayedShow(200) -); +window.addEventListener("phx:page-loading-start", (info) => topbar.delayedShow(200)); window.addEventListener("phx:page-loading-stop", (info) => topbar.hide()); // connect if there are any LiveViews on the page diff --git a/lib/aprs/is/is.ex b/lib/aprs/is/is.ex index 76ace59..a8e450e 100644 --- a/lib/aprs/is/is.ex +++ b/lib/aprs/is/is.ex @@ -4,7 +4,8 @@ defmodule Aprs.Is do require Logger - @aprs_timeout 30 * 1000 + @aprs_timeout 60 * 1000 + @keepalive_interval 20 * 1000 def start_link(opts \\ []) do GenServer.start_link(__MODULE__, opts, name: __MODULE__) @@ -15,11 +16,14 @@ defmodule Aprs.Is do # Trap exits so we can gracefully shut down Process.flag(:trap_exit, true) + # Add a small delay to prevent rapid reconnection attempts + Process.sleep(2000) + # Get startup parameters server = Application.get_env(:aprs, :aprs_is_server, ~c"rotate.aprs2.net") port = Application.get_env(:aprs, :aprs_is_port, 14_580) default_filter = Application.get_env(:aprs, :aprs_is_default_filter, "r/33/-96/100") - aprs_user_id = Application.get_env(:aprs, :aprs_is_login_id, "w5isp") + aprs_user_id = Application.get_env(:aprs, :aprs_is_login_id, "W5ISP") aprs_passcode = Application.get_env(:aprs, :aprs_is_password, "-1") # Set up ets tables @@ -44,13 +48,20 @@ defmodule Aprs.Is do with {:ok, socket} <- connect_to_aprs_is(server, port), :ok <- send_login_string(socket, aprs_user_id, aprs_passcode, default_filter) do timer = create_timer(@aprs_timeout) + keepalive_timer = create_keepalive_timer(@keepalive_interval) {:ok, %{ server: server, port: port, socket: socket, - timer: timer + timer: timer, + keepalive_timer: keepalive_timer, + login_params: %{ + user_id: aprs_user_id, + passcode: aprs_passcode, + filter: default_filter + } }} else _ -> @@ -88,9 +99,9 @@ defmodule Aprs.Is do defp send_login_string(socket, aprs_user_id, aprs_passcode, filter) do login_string = - "user #{aprs_user_id} pass #{aprs_passcode} vers aprs.me 0.1 filter #{filter} \n" + "user #{aprs_user_id} pass #{aprs_passcode} vers aprs.me 0.1 filter #{filter}\r\n" - Logger.debug("Sending login string: #{login_string}") + Logger.info("Sending login string: user #{aprs_user_id} pass ***** vers aprs.me 0.1 filter #{filter}") :gen_tcp.send(socket, login_string) end @@ -99,6 +110,10 @@ defmodule Aprs.Is do Process.send_after(self(), :aprs_no_message_timeout, timeout) end + defp create_keepalive_timer(interval) do + Process.send_after(self(), :send_keepalive, interval) + end + @impl true def handle_call({:send_message, message}, _from, state) do next_ack_number = :ets.update_counter(:aprs, :message_number, 1) @@ -116,6 +131,20 @@ defmodule Aprs.Is do {:stop, :aprs_timeout, state} end + def handle_info(:send_keepalive, state) do + # Send a comment line as keepalive (APRS-IS standard) + case :gen_tcp.send(state.socket, "# keepalive\r\n") do + :ok -> + Logger.debug("Sent keepalive") + keepalive_timer = create_keepalive_timer(@keepalive_interval) + {:noreply, %{state | keepalive_timer: keepalive_timer}} + + {:error, reason} -> + Logger.error("Failed to send keepalive: #{inspect(reason)}") + {:stop, :normal, state} + end + end + def handle_info({:tcp, _socket, packet}, state) do # Cancel the previous timer Process.cancel_timer(state.timer) @@ -139,21 +168,29 @@ defmodule Aprs.Is do end def handle_info({:tcp_closed, _socket}, state) do - Logger.info("Socket has been closed") + Logger.warning("Socket has been closed by remote server - will reconnect") {:stop, :normal, state} end def handle_info({:tcp_error, _socket, reason}, state) do - Logger.error("Connection closed due to #{inspect(reason)}") + Logger.error("Connection error: #{inspect(reason)}") {:stop, :normal, state} end @impl true def terminate(reason, state) do # Do Shutdown Stuff - Logger.info("Going Down: #{inspect(reason)} - #{inspect(state)}") - Logger.info("Closing socket") - :gen_tcp.close(state.socket) + Logger.info("Terminating APRS-IS connection: #{inspect(reason)}") + + # Cancel timers + if Map.has_key?(state, :timer), do: Process.cancel_timer(state.timer) + if Map.has_key?(state, :keepalive_timer), do: Process.cancel_timer(state.keepalive_timer) + + # Close socket + if Map.has_key?(state, :socket) do + Logger.info("Closing socket") + :gen_tcp.close(state.socket) + end :normal end diff --git a/lib/aprs/is/is_supervisor.ex b/lib/aprs/is/is_supervisor.ex index 9cca041..681a40d 100644 --- a/lib/aprs/is/is_supervisor.ex +++ b/lib/aprs/is/is_supervisor.ex @@ -9,9 +9,11 @@ defmodule Aprs.Is.IsSupervisor do @impl true def init(:ok) do children = [ - Aprs.Is + {Aprs.Is, []} ] - Supervisor.init(children, strategy: :one_for_one) + # Supervisor will restart children max 3 times in 5 seconds + # This prevents rapid reconnection attempts + Supervisor.init(children, strategy: :one_for_one, max_restarts: 3, max_seconds: 5) end end diff --git a/lib/aprs_web/components/layouts.ex b/lib/aprs_web/components/layouts.ex index 294e433..e905149 100644 --- a/lib/aprs_web/components/layouts.ex +++ b/lib/aprs_web/components/layouts.ex @@ -3,4 +3,20 @@ defmodule AprsWeb.Layouts do use AprsWeb, :html embed_templates "layouts/*" + + def body_class(assigns) do + classes = ["bg-white antialiased"] + + # Check if this is the home page (map) + is_home = + cond do + # For regular controller requests + assigns[:conn] && assigns.conn.request_path == "/" -> true + # For LiveView requests + assigns[:socket] && assigns.socket.view == AprsWeb.MapLive.Index -> true + true -> false + end + + if is_home, do: ["home-page" | classes], else: classes + end end diff --git a/lib/aprs_web/components/layouts/root.html.heex b/lib/aprs_web/components/layouts/root.html.heex index 2f1424c..f86de4a 100644 --- a/lib/aprs_web/components/layouts/root.html.heex +++ b/lib/aprs_web/components/layouts/root.html.heex @@ -11,7 +11,7 @@ - +