map improvements

This commit is contained in:
Graham McIntire 2025-06-13 16:17:35 -05:00
parent 4a6665ac76
commit c5b5d0762a
18 changed files with 1072 additions and 269 deletions

View file

@ -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%;
}

View file

@ -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:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> 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 = `
<div style="min-width: 200px;">
<h4 style="margin: 0 0 5px 0; font-weight: bold;">${callsign}</h4>
<p style="margin: 2px 0; font-size: 12px;">
<strong>Position:</strong> ${lat.toFixed(4)}°, ${lng.toFixed(4)}°<br>
<strong>Type:</strong> ${packet["data_type"]}<br>
${packet["data_extended"]["comment"] ? `<strong>Comment:</strong> ${packet["data_extended"]["comment"]}<br>` : ""}
<strong>Path:</strong> ${packet["path"]}<br>
<strong>Time:</strong> ${new Date().toLocaleTimeString()}
</p>
</div>
`;
// 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: `<div style="background-color: ${color}; width: 12px; height: 12px; border-radius: 50%; border: 2px solid white; box-shadow: 0 1px 3px rgba(0,0,0,0.4);"></div>`,
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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -11,7 +11,7 @@
<script defer phx-track-static type="text/javascript" src={~p"/assets/app.js"}>
</script>
</head>
<body class="bg-white antialiased">
<body class={body_class(assigns)}>
<ul>
<%= if @current_user do %>
<li>

View file

@ -2,13 +2,11 @@ defmodule AprsWeb.PageController do
use AprsWeb, :controller
def home(conn, _params) do
# The home page is often custom made,
# so skip the default app layout.
render(conn, :home, layout: false)
render(conn, :home)
end
def map(conn, _params) do
render(conn, :map, layout: false)
render(conn, :map)
end
def packets(conn, _params) do

View file

@ -1,236 +0,0 @@
<div class="fixed inset-y-0 right-0 left-[40rem] hidden lg:block xl:left-[50rem]">
<svg
viewBox="0 0 1480 957"
fill="none"
aria-hidden="true"
class="absolute inset-0 h-full w-full"
preserveAspectRatio="xMinYMid slice"
>
<path fill="#EE7868" d="M0 0h1480v957H0z" />
<path
d="M137.542 466.27c-582.851-48.41-988.806-82.127-1608.412 658.2l67.39 810 3083.15-256.51L1535.94-49.622l-98.36 8.183C1269.29 281.468 734.115 515.799 146.47 467.012l-8.928-.742Z"
fill="#FF9F92"
/>
<path
d="M371.028 528.664C-169.369 304.988-545.754 149.198-1361.45 665.565l-182.58 792.025 3014.73 694.98 389.42-1689.25-96.18-22.171C1505.28 697.438 924.153 757.586 379.305 532.09l-8.277-3.426Z"
fill="#FA8372"
/>
<path
d="M359.326 571.714C-104.765 215.795-428.003-32.102-1349.55 255.554l-282.3 1224.596 3047.04 722.01 312.24-1354.467C1411.25 1028.3 834.355 935.995 366.435 577.166l-7.109-5.452Z"
fill="#E96856"
fill-opacity=".6"
/>
<path
d="M1593.87 1236.88c-352.15 92.63-885.498-145.85-1244.602-613.557l-5.455-7.105C-12.347 152.31-260.41-170.8-1225-131.458l-368.63 1599.048 3057.19 704.76 130.31-935.47Z"
fill="#C42652"
fill-opacity=".2"
/>
<path
d="M1411.91 1526.93c-363.79 15.71-834.312-330.6-1085.883-863.909l-3.822-8.102C72.704 125.95-101.074-242.476-1052.01-408.907l-699.85 1484.267 2837.75 1338.01 326.02-886.44Z"
fill="#A41C42"
fill-opacity=".2"
/>
<path
d="M1116.26 1863.69c-355.457-78.98-720.318-535.27-825.287-1115.521l-1.594-8.816C185.286 163.833 112.786-237.016-762.678-643.898L-1822.83 608.665 571.922 2635.55l544.338-771.86Z"
fill="#A41C42"
fill-opacity=".2"
/>
</svg>
</div>
<div class="px-4 py-10 sm:py-28 sm:px-6 lg:px-8 xl:py-32 xl:px-28">
<div class="mx-auto max-w-xl lg:mx-0">
<svg viewBox="0 0 71 48" class="h-12" aria-hidden="true">
<path
d="m26.371 33.477-.552-.1c-3.92-.729-6.397-3.1-7.57-6.829-.733-2.324.597-4.035 3.035-4.148 1.995-.092 3.362 1.055 4.57 2.39 1.557 1.72 2.984 3.558 4.514 5.305 2.202 2.515 4.797 4.134 8.347 3.634 3.183-.448 5.958-1.725 8.371-3.828.363-.316.761-.592 1.144-.886l-.241-.284c-2.027.63-4.093.841-6.205.735-3.195-.16-6.24-.828-8.964-2.582-2.486-1.601-4.319-3.746-5.19-6.611-.704-2.315.736-3.934 3.135-3.6.948.133 1.746.56 2.463 1.165.583.493 1.143 1.015 1.738 1.493 2.8 2.25 6.712 2.375 10.265-.068-5.842-.026-9.817-3.24-13.308-7.313-1.366-1.594-2.7-3.216-4.095-4.785-2.698-3.036-5.692-5.71-9.79-6.623C12.8-.623 7.745.14 2.893 2.361 1.926 2.804.997 3.319 0 4.149c.494 0 .763.006 1.032 0 2.446-.064 4.28 1.023 5.602 3.024.962 1.457 1.415 3.104 1.761 4.798.513 2.515.247 5.078.544 7.605.761 6.494 4.08 11.026 10.26 13.346 2.267.852 4.591 1.135 7.172.555ZM10.751 3.852c-.976.246-1.756-.148-2.56-.962 1.377-.343 2.592-.476 3.897-.528-.107.848-.607 1.306-1.336 1.49Zm32.002 37.924c-.085-.626-.62-.901-1.04-1.228-1.857-1.446-4.03-1.958-6.333-2-1.375-.026-2.735-.128-4.031-.61-.595-.22-1.26-.505-1.244-1.272.015-.78.693-1 1.31-1.184.505-.15 1.026-.247 1.6-.382-1.46-.936-2.886-1.065-4.787-.3-2.993 1.202-5.943 1.06-8.926-.017-1.684-.608-3.179-1.563-4.735-2.408l-.043.03a2.96 2.96 0 0 0 .04-.029c-.038-.117-.107-.12-.197-.054l.122.107c1.29 2.115 3.034 3.817 5.004 5.271 3.793 2.8 7.936 4.471 12.784 3.73A66.714 66.714 0 0 1 37 40.877c1.98-.16 3.866.398 5.753.899Zm-9.14-30.345c-.105-.076-.206-.266-.42-.069 1.745 2.36 3.985 4.098 6.683 5.193 4.354 1.767 8.773 2.07 13.293.51 3.51-1.21 6.033-.028 7.343 3.38.19-3.955-2.137-6.837-5.843-7.401-2.084-.318-4.01.373-5.962.94-5.434 1.575-10.485.798-15.094-2.553Zm27.085 15.425c.708.059 1.416.123 2.124.185-1.6-1.405-3.55-1.517-5.523-1.404-3.003.17-5.167 1.903-7.14 3.972-1.739 1.824-3.31 3.87-5.903 4.604.043.078.054.117.066.117.35.005.699.021 1.047.005 3.768-.17 7.317-.965 10.14-3.7.89-.86 1.685-1.817 2.544-2.71.716-.746 1.584-1.159 2.645-1.07Zm-8.753-4.67c-2.812.246-5.254 1.409-7.548 2.943-1.766 1.18-3.654 1.738-5.776 1.37-.374-.066-.75-.114-1.124-.17l-.013.156c.135.07.265.151.405.207.354.14.702.308 1.07.395 4.083.971 7.992.474 11.516-1.803 2.221-1.435 4.521-1.707 7.013-1.336.252.038.503.083.756.107.234.022.479.255.795.003-2.179-1.574-4.526-2.096-7.094-1.872Zm-10.049-9.544c1.475.051 2.943-.142 4.486-1.059-.452.04-.643.04-.827.076-2.126.424-4.033-.04-5.733-1.383-.623-.493-1.257-.974-1.889-1.457-2.503-1.914-5.374-2.555-8.514-2.5.05.154.054.26.108.315 3.417 3.455 7.371 5.836 12.369 6.008Zm24.727 17.731c-2.114-2.097-4.952-2.367-7.578-.537 1.738.078 3.043.632 4.101 1.728.374.388.763.768 1.182 1.106 1.6 1.29 4.311 1.352 5.896.155-1.861-.726-1.861-.726-3.601-2.452Zm-21.058 16.06c-1.858-3.46-4.981-4.24-8.59-4.008a9.667 9.667 0 0 1 2.977 1.39c.84.586 1.547 1.311 2.243 2.055 1.38 1.473 3.534 2.376 4.962 2.07-.656-.412-1.238-.848-1.592-1.507Zm17.29-19.32c0-.023.001-.045.003-.068l-.006.006.006-.006-.036-.004.021.018.012.053Zm-20 14.744a7.61 7.61 0 0 0-.072-.041.127.127 0 0 0 .015.043c.005.008.038 0 .058-.002Zm-.072-.041-.008-.034-.008.01.008-.01-.022-.006.005.026.024.014Z"
fill="#FD4F00"
/>
</svg>
<h1 class="mt-10 flex items-center text-sm font-semibold leading-6 text-brand">
Phoenix Framework
<small class="ml-3 rounded-full bg-brand/5 px-2 text-[0.8125rem] font-medium leading-6">
v1.7
</small>
</h1>
<p class="mt-4 text-[2rem] font-semibold leading-10 tracking-tighter text-zinc-900">
Peace of mind from prototype to production.
</p>
<p class="mt-4 text-base leading-7 text-zinc-600">
Build rich, interactive web applications quickly, with less code and fewer moving parts. Join our growing community of developers using Phoenix to craft APIs, HTML5 apps and more, for fun or at scale.
</p>
<div class="flex">
<div class="w-full sm:w-auto">
<div class="mt-10 grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-3">
<a
href="https://hexdocs.pm/phoenix/overview.html"
class="group relative rounded-2xl px-6 py-4 text-sm font-semibold leading-6 text-zinc-900 sm:py-6"
>
<span class="absolute inset-0 rounded-2xl bg-zinc-50 transition group-hover:bg-zinc-100 sm:group-hover:scale-105">
</span>
<span class="relative flex items-center gap-4 sm:flex-col">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" class="h-6 w-6">
<path d="m12 4 10-2v18l-10 2V4Z" fill="#18181B" fill-opacity=".15" />
<path
d="M12 4 2 2v18l10 2m0-18v18m0-18 10-2v18l-10 2"
stroke="#18181B"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
Guides &amp; Docs
</span>
</a>
<a
href="https://github.com/phoenixframework/phoenix"
class="group relative rounded-2xl px-6 py-4 text-sm font-semibold leading-6 text-zinc-900 sm:py-6"
>
<span class="absolute inset-0 rounded-2xl bg-zinc-50 transition group-hover:bg-zinc-100 sm:group-hover:scale-105">
</span>
<span class="relative flex items-center gap-4 sm:flex-col">
<svg viewBox="0 0 24 24" aria-hidden="true" class="h-6 w-6">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M12 0C5.37 0 0 5.506 0 12.303c0 5.445 3.435 10.043 8.205 11.674.6.107.825-.262.825-.585 0-.292-.015-1.261-.015-2.291C6 21.67 5.22 20.346 4.98 19.654c-.135-.354-.72-1.446-1.23-1.738-.42-.23-1.02-.8-.015-.815.945-.015 1.62.892 1.845 1.261 1.08 1.86 2.805 1.338 3.495 1.015.105-.8.42-1.338.765-1.645-2.67-.308-5.46-1.37-5.46-6.075 0-1.338.465-2.446 1.23-3.307-.12-.308-.54-1.569.12-3.26 0 0 1.005-.323 3.3 1.26.96-.276 1.98-.415 3-.415s2.04.139 3 .416c2.295-1.6 3.3-1.261 3.3-1.261.66 1.691.24 2.952.12 3.26.765.861 1.23 1.953 1.23 3.307 0 4.721-2.805 5.767-5.475 6.075.435.384.81 1.122.81 2.276 0 1.645-.015 2.968-.015 3.383 0 .323.225.707.825.585a12.047 12.047 0 0 0 5.919-4.489A12.536 12.536 0 0 0 24 12.304C24 5.505 18.63 0 12 0Z"
fill="#18181B"
/>
</svg>
Source Code
</span>
</a>
<a
href="https://github.com/phoenixframework/phoenix/blob/v1.7/CHANGELOG.md"
class="group relative rounded-2xl px-6 py-4 text-sm font-semibold leading-6 text-zinc-900 sm:py-6"
>
<span class="absolute inset-0 rounded-2xl bg-zinc-50 transition group-hover:bg-zinc-100 sm:group-hover:scale-105">
</span>
<span class="relative flex items-center gap-4 sm:flex-col">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" class="h-6 w-6">
<path
d="M12 1v6M12 17v6"
stroke="#18181B"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle
cx="12"
cy="12"
r="4"
fill="#18181B"
fill-opacity=".15"
stroke="#18181B"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
Changelog
</span>
</a>
</div>
<div class="mt-10 grid grid-cols-1 gap-y-4 text-sm leading-6 text-zinc-700 sm:grid-cols-2">
<div>
<a
href="https://twitter.com/elixirphoenix"
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-zinc-50 hover:text-zinc-900"
>
<svg
viewBox="0 0 16 16"
aria-hidden="true"
class="h-4 w-4 fill-zinc-400 group-hover:fill-zinc-600"
>
<path d="M5.403 14c5.283 0 8.172-4.617 8.172-8.62 0-.131 0-.262-.008-.391A6.033 6.033 0 0 0 15 3.419a5.503 5.503 0 0 1-1.65.477 3.018 3.018 0 0 0 1.263-1.676 5.579 5.579 0 0 1-1.824.736 2.832 2.832 0 0 0-1.63-.916 2.746 2.746 0 0 0-1.821.319A2.973 2.973 0 0 0 8.076 3.78a3.185 3.185 0 0 0-.182 1.938 7.826 7.826 0 0 1-3.279-.918 8.253 8.253 0 0 1-2.64-2.247 3.176 3.176 0 0 0-.315 2.208 3.037 3.037 0 0 0 1.203 1.836A2.739 2.739 0 0 1 1.56 6.22v.038c0 .7.23 1.377.65 1.919.42.54 1.004.912 1.654 1.05-.423.122-.866.14-1.297.052.184.602.541 1.129 1.022 1.506a2.78 2.78 0 0 0 1.662.598 5.656 5.656 0 0 1-2.007 1.074A5.475 5.475 0 0 1 1 12.64a7.827 7.827 0 0 0 4.403 1.358" />
</svg>
Follow on Twitter
</a>
</div>
<div>
<a
href="https://elixirforum.com"
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-zinc-50 hover:text-zinc-900"
>
<svg
viewBox="0 0 16 16"
aria-hidden="true"
class="h-4 w-4 fill-zinc-400 group-hover:fill-zinc-600"
>
<path d="M8 13.833c3.866 0 7-2.873 7-6.416C15 3.873 11.866 1 8 1S1 3.873 1 7.417c0 1.081.292 2.1.808 2.995.606 1.05.806 2.399.086 3.375l-.208.283c-.285.386-.01.905.465.85.852-.098 2.048-.318 3.137-.81a3.717 3.717 0 0 1 1.91-.318c.263.027.53.041.802.041Z" />
</svg>
Discuss on the Elixir forum
</a>
</div>
<div>
<a
href="https://elixir-slackin.herokuapp.com"
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-zinc-50 hover:text-zinc-900"
>
<svg
viewBox="0 0 16 16"
aria-hidden="true"
class="h-4 w-4 fill-zinc-400 group-hover:fill-zinc-600"
>
<path d="M3.95 9.85a1.47 1.47 0 1 1-2.94 0 1.47 1.47 0 0 1 1.47-1.472h1.47v1.471Zm.735 0a1.47 1.47 0 1 1 2.94 0v3.678a1.47 1.47 0 1 1-2.94 0V9.85ZM6.156 3.942a1.47 1.47 0 0 1-1.47-1.472 1.47 1.47 0 1 1 2.94 0v1.472h-1.47Zm0 .747c.813 0 1.47.658 1.47 1.471a1.47 1.47 0 0 1-1.47 1.472H2.47A1.47 1.47 0 0 1 1 6.16 1.47 1.47 0 0 1 2.47 4.69h3.686ZM12.048 6.16a1.47 1.47 0 1 1 2.94 0 1.47 1.47 0 0 1-1.47 1.472h-1.47V6.16Zm-.735 0a1.47 1.47 0 1 1-2.94 0V2.47a1.47 1.47 0 1 1 2.94 0v3.69ZM9.843 12.057c.813 0 1.47.657 1.47 1.471a1.47 1.47 0 1 1-2.94 0v-1.471h1.47Zm0-.736a1.47 1.47 0 0 1-1.47-1.472 1.47 1.47 0 0 1 1.47-1.471h3.686c.813 0 1.47.658 1.47 1.471a1.47 1.47 0 0 1-1.47 1.472H9.843Z" />
</svg>
Join our Slack channel
</a>
</div>
<div>
<a
href="https://web.libera.chat/#elixir"
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-zinc-50 hover:text-zinc-900"
>
<svg
viewBox="0 0 16 16"
aria-hidden="true"
class="h-4 w-4 fill-zinc-400 group-hover:fill-zinc-600"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M6.356 2.007a.75.75 0 0 1 .637.849l-1.5 10.5a.75.75 0 1 1-1.485-.212l1.5-10.5a.75.75 0 0 1 .848-.637ZM11.356 2.008a.75.75 0 0 1 .637.848l-1.5 10.5a.75.75 0 0 1-1.485-.212l1.5-10.5a.75.75 0 0 1 .848-.636Z"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M14 5.25a.75.75 0 0 1-.75.75h-9.5a.75.75 0 0 1 0-1.5h9.5a.75.75 0 0 1 .75.75ZM13 10.75a.75.75 0 0 1-.75.75h-9.5a.75.75 0 0 1 0-1.5h9.5a.75.75 0 0 1 .75.75Z"
/>
</svg>
Chat on Libera IRC
</a>
</div>
<div>
<a
href="https://discord.gg/elixir"
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-zinc-50 hover:text-zinc-900"
>
<svg
viewBox="0 0 16 16"
aria-hidden="true"
class="h-4 w-4 fill-zinc-400 group-hover:fill-zinc-600"
>
<path d="M13.545 2.995c-1.02-.46-2.114-.8-3.257-.994a.05.05 0 0 0-.052.024c-.141.246-.297.567-.406.82a12.377 12.377 0 0 0-3.658 0 8.238 8.238 0 0 0-.412-.82.052.052 0 0 0-.052-.024 13.315 13.315 0 0 0-3.257.994.046.046 0 0 0-.021.018C.356 6.063-.213 9.036.066 11.973c.001.015.01.029.02.038a13.353 13.353 0 0 0 3.996 1.987.052.052 0 0 0 .056-.018c.308-.414.582-.85.818-1.309a.05.05 0 0 0-.028-.069 8.808 8.808 0 0 1-1.248-.585.05.05 0 0 1-.005-.084c.084-.062.168-.126.248-.191a.05.05 0 0 1 .051-.007c2.619 1.176 5.454 1.176 8.041 0a.05.05 0 0 1 .053.006c.08.065.164.13.248.192a.05.05 0 0 1-.004.084c-.399.23-.813.423-1.249.585a.05.05 0 0 0-.027.07c.24.457.514.893.817 1.307a.051.051 0 0 0 .056.019 13.31 13.31 0 0 0 4.001-1.987.05.05 0 0 0 .021-.037c.334-3.396-.559-6.345-2.365-8.96a.04.04 0 0 0-.021-.02Zm-8.198 7.19c-.789 0-1.438-.712-1.438-1.587 0-.874.637-1.586 1.438-1.586.807 0 1.45.718 1.438 1.586 0 .875-.637 1.587-1.438 1.587Zm5.316 0c-.788 0-1.438-.712-1.438-1.587 0-.874.637-1.586 1.438-1.586.807 0 1.45.718 1.438 1.586 0 .875-.63 1.587-1.438 1.587Z" />
</svg>
Join our Discord server
</a>
</div>
<div>
<a
href="https://fly.io/docs/getting-started/elixir/"
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-zinc-50 hover:text-zinc-900"
>
<svg
viewBox="0 0 20 20"
aria-hidden="true"
class="h-4 w-4 fill-zinc-400 group-hover:fill-zinc-600"
>
<path d="M1 12.5A4.5 4.5 0 005.5 17H15a4 4 0 001.866-7.539 3.504 3.504 0 00-4.504-4.272A4.5 4.5 0 004.06 8.235 4.502 4.502 0 001 12.5z" />
</svg>
Deploy your application
</a>
</div>
</div>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,274 @@
defmodule AprsWeb.MapLive.Index do
@moduledoc """
LiveView for displaying real-time APRS packets on a map
"""
use AprsWeb, :live_view
alias Aprs.EncodingUtils
alias AprsWeb.Endpoint
alias Parser.Types.MicE
@impl true
def mount(_params, _session, socket) do
if connected?(socket) do
Endpoint.subscribe("aprs_messages")
end
{:ok,
assign(socket,
packets: [],
packet_count: 0,
page_title: "APRS Map",
# Track visible packets by callsign
visible_packets: %{},
# Default bounds for USA
map_bounds: %{
north: 49.0,
south: 24.0,
east: -66.0,
west: -125.0
}
)}
end
@impl true
def handle_event("update_bounds", %{"bounds" => bounds}, socket) do
# Update the map bounds from the client
map_bounds = %{
north: bounds["north"],
south: bounds["south"],
east: bounds["east"],
west: bounds["west"]
}
# Don't clear markers - let the client preserve those still in view
# Recalculate packet count based on new bounds
visible_packets =
socket.assigns.visible_packets
|> Enum.filter(fn {_callsign, packet} ->
within_bounds?(packet, map_bounds)
end)
|> Map.new()
packet_count = map_size(visible_packets)
{:noreply, assign(socket, map_bounds: map_bounds, visible_packets: visible_packets, packet_count: packet_count)}
end
@impl true
def handle_event("update_packet_count", %{"count" => count}, socket) do
# Update packet count from client after markers are removed
{:noreply, assign(socket, packet_count: count)}
end
@impl true
def handle_info(%{event: "packet", payload: payload}, socket) do
# Sanitize the packet to prevent encoding errors
sanitized_packet = EncodingUtils.sanitize_packet(payload)
# Log packet type for debugging
IO.inspect(sanitized_packet.data_type, label: "Packet type")
if sanitized_packet.data_extended do
# Check if data_extended is a struct before accessing __struct__
case sanitized_packet.data_extended do
%{__struct__: module} -> IO.inspect(module, label: "Data extended type")
_ -> IO.inspect("Plain map", label: "Data extended type")
end
end
# Only process packets with position data that are within current map bounds
if has_position_data?(sanitized_packet) && within_bounds?(sanitized_packet, socket.assigns.map_bounds) do
# Convert to a simple map structure for JSON encoding
packet_data = build_packet_data(sanitized_packet)
# Only push if we have valid packet data
if packet_data do
# Generate a unique key for this packet
callsign_key =
"#{sanitized_packet.base_callsign}#{if sanitized_packet.ssid, do: "-#{sanitized_packet.ssid}", else: ""}"
# Update visible packets tracking
visible_packets = Map.put(socket.assigns.visible_packets, callsign_key, sanitized_packet)
packet_count = map_size(visible_packets)
# Push the packet to the client-side JavaScript
socket =
socket
|> push_event("new_packet", packet_data)
|> assign(visible_packets: visible_packets, packet_count: packet_count)
{:noreply, socket}
else
# Invalid packet data, skip it
{:noreply, socket}
end
else
# Ignore packets without position data or outside bounds
{:noreply, socket}
end
end
@impl true
def render(assigns) do
~H"""
<link
rel="stylesheet"
href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin=""
/>
<script
src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
crossorigin=""
>
</script>
<style>
#aprs-map {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100vh;
z-index: 1;
}
.map-overlay {
position: absolute;
top: 10px;
right: 10px;
background: rgba(255, 255, 255, 0.9);
padding: 10px 15px;
border-radius: 5px;
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
z-index: 1000;
}
.packet-counter {
font-size: 14px;
font-weight: 600;
color: #333;
}
.aprs-marker {
background: transparent !important;
border: none !important;
}
</style>
<div id="aprs-map" phx-hook="APRSMap" phx-update="ignore"></div>
<div class="map-overlay">
<div class="packet-counter">
<span id="packet-count">{@packet_count}</span> packets in view
</div>
</div>
"""
end
# Helper functions
defp has_position_data?(packet) do
case packet.data_extended do
%MicE{} = mic_e ->
# MicE packets have lat/lon in separate components
is_number(mic_e.lat_degrees) && is_number(mic_e.lat_minutes) &&
is_number(mic_e.lon_degrees) && is_number(mic_e.lon_minutes)
%{latitude: lat, longitude: lon} ->
# Regular position packets have decimal lat/lon
is_number(lat) && is_number(lon)
_ ->
false
end
end
defp within_bounds?(packet, bounds) do
{lat, lng} = get_coordinates(packet)
lat && lng &&
lat >= bounds.south && lat <= bounds.north &&
lng >= bounds.west && lng <= bounds.east
end
defp get_coordinates(packet) do
case packet.data_extended do
%MicE{} = mic_e ->
# Convert MicE components to decimal degrees
lat = mic_e.lat_degrees + mic_e.lat_minutes / 60.0 + mic_e.lat_fractional / 6000.0
lat = if mic_e.lat_direction == :south, do: -lat, else: lat
lng = mic_e.lon_degrees + mic_e.lon_minutes / 60.0 + mic_e.lon_fractional / 6000.0
lng = if mic_e.lon_direction == :west, do: -lng, else: lng
# Validate coordinates are within valid ranges
if lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180 do
{lat, lng}
else
IO.puts("Invalid MicE coordinates: lat=#{lat}, lng=#{lng}")
{nil, nil}
end
%{latitude: lat, longitude: lon} ->
{lat, lon}
_ ->
{nil, nil}
end
end
defp build_packet_data(packet) do
data_extended = build_data_extended(packet.data_extended)
if data_extended do
%{
"base_callsign" => packet.base_callsign || "",
"ssid" => packet.ssid || "",
"data_type" => to_string(packet.data_type || "unknown"),
"path" => packet.path || "",
"data_extended" => data_extended
}
end
end
defp build_data_extended(nil), do: nil
defp build_data_extended(data_extended) do
case data_extended do
%MicE{} = mic_e ->
# Convert MicE components to decimal degrees
lat = mic_e.lat_degrees + mic_e.lat_minutes / 60.0 + mic_e.lat_fractional / 6000.0
lat = if mic_e.lat_direction == :south, do: -lat, else: lat
lng = mic_e.lon_degrees + mic_e.lon_minutes / 60.0 + mic_e.lon_fractional / 6000.0
lng = if mic_e.lon_direction == :west, do: -lng, else: lng
# Validate coordinates are within valid ranges
if lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180 do
%{
"latitude" => lat,
"longitude" => lng,
"comment" => mic_e.message || "",
"symbol_table_id" => "/",
"symbol_code" => ">",
"aprs_messaging" => false
}
end
_ ->
%{
"latitude" => data_extended[:latitude],
"longitude" => data_extended[:longitude],
"comment" => data_extended[:comment] || "",
"symbol_table_id" => data_extended[:symbol_table_id] || "/",
"symbol_code" => data_extended[:symbol_code] || ">",
"aprs_messaging" => data_extended[:aprs_messaging] || false
}
end
end
end

View file

@ -0,0 +1,248 @@
defmodule AprsWeb.MapLive.Index do
@moduledoc """
LiveView for displaying real-time APRS packets on a map
"""
use AprsWeb, :live_view
alias Aprs.EncodingUtils
alias AprsWeb.Endpoint
alias Parser.Types.MicE
@impl true
def mount(_params, _session, socket) do
if connected?(socket) do
Endpoint.subscribe("aprs_messages")
end
{:ok,
assign(socket,
packets: [],
packet_count: 0,
page_title: "APRS Map",
# Default bounds for USA
map_bounds: %{
north: 49.0,
south: 24.0,
east: -66.0,
west: -125.0
}
)}
end
@impl true
def handle_event("update_bounds", %{"bounds" => bounds}, socket) do
# Update the map bounds from the client
map_bounds = %{
north: bounds["north"],
south: bounds["south"],
east: bounds["east"],
west: bounds["west"]
}
# Clear existing markers when bounds change
socket = push_event(socket, "clear_markers", %{})
{:noreply, assign(socket, map_bounds: map_bounds, packet_count: 0)}
end
@impl true
def handle_info(%{event: "packet", payload: payload}, socket) do
# Sanitize the packet to prevent encoding errors
sanitized_packet = EncodingUtils.sanitize_packet(payload)
# Log packet type for debugging
IO.inspect(sanitized_packet.data_type, label: "Packet type")
if sanitized_packet.data_extended do
# Check if data_extended is a struct before accessing __struct__
case sanitized_packet.data_extended do
%{__struct__: module} -> IO.inspect(module, label: "Data extended type")
_ -> IO.inspect("Plain map", label: "Data extended type")
end
end
# Only process packets with position data that are within current map bounds
if has_position_data?(sanitized_packet) && within_bounds?(sanitized_packet, socket.assigns.map_bounds) do
# Convert to a simple map structure for JSON encoding
packet_data = build_packet_data(sanitized_packet)
# Only push if we have valid packet data
if packet_data do
# Push the packet to the client-side JavaScript and increment count
socket =
socket
|> push_event("new_packet", packet_data)
|> update(:packet_count, &(&1 + 1))
{:noreply, socket}
else
# Invalid packet data, skip it
{:noreply, socket}
end
else
# Ignore packets without position data or outside bounds
{:noreply, socket}
end
end
@impl true
def render(assigns) do
~H"""
<link
rel="stylesheet"
href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin=""
/>
<script
src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
crossorigin=""
>
</script>
<style>
#aprs-map {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1;
}
.map-overlay {
position: absolute;
top: 10px;
right: 10px;
background: rgba(255, 255, 255, 0.9);
padding: 10px 15px;
border-radius: 5px;
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
z-index: 1000;
}
.packet-counter {
font-size: 14px;
font-weight: 600;
color: #333;
}
.aprs-marker {
background: transparent !important;
border: none !important;
}
</style>
<div id="aprs-map" phx-hook="APRSMap"></div>
<div class="map-overlay">
<div class="packet-counter">
<span id="packet-count">{@packet_count}</span> packets in view
</div>
</div>
"""
end
# Helper functions
defp has_position_data?(packet) do
case packet.data_extended do
%MicE{} = mic_e ->
# MicE packets have lat/lon in separate components
is_number(mic_e.lat_degrees) && is_number(mic_e.lat_minutes) &&
is_number(mic_e.lon_degrees) && is_number(mic_e.lon_minutes)
%{latitude: lat, longitude: lon} ->
# Regular position packets have decimal lat/lon
is_number(lat) && is_number(lon)
_ ->
false
end
end
defp within_bounds?(packet, bounds) do
{lat, lng} = get_coordinates(packet)
lat && lng &&
lat >= bounds.south && lat <= bounds.north &&
lng >= bounds.west && lng <= bounds.east
end
defp get_coordinates(packet) do
case packet.data_extended do
%MicE{} = mic_e ->
# Convert MicE components to decimal degrees
lat = mic_e.lat_degrees + mic_e.lat_minutes / 60.0 + mic_e.lat_fractional / 6000.0
lat = if mic_e.lat_direction == :south, do: -lat, else: lat
lng = mic_e.lon_degrees + mic_e.lon_minutes / 60.0 + mic_e.lon_fractional / 6000.0
lng = if mic_e.lon_direction == :west, do: -lng, else: lng
# Validate coordinates are within valid ranges
if lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180 do
{lat, lng}
else
IO.puts("Invalid MicE coordinates: lat=#{lat}, lng=#{lng}")
{nil, nil}
end
%{latitude: lat, longitude: lon} ->
{lat, lon}
_ ->
{nil, nil}
end
end
defp build_packet_data(packet) do
data_extended = build_data_extended(packet.data_extended)
if data_extended do
%{
"base_callsign" => packet.base_callsign || "",
"ssid" => packet.ssid || "",
"data_type" => to_string(packet.data_type || "unknown"),
"path" => packet.path || "",
"data_extended" => data_extended
}
end
end
defp build_data_extended(nil), do: nil
defp build_data_extended(data_extended) do
case data_extended do
%MicE{} = mic_e ->
# Convert MicE components to decimal degrees
lat = mic_e.lat_degrees + mic_e.lat_minutes / 60.0 + mic_e.lat_fractional / 6000.0
lat = if mic_e.lat_direction == :south, do: -lat, else: lat
lng = mic_e.lon_degrees + mic_e.lon_minutes / 60.0 + mic_e.lon_fractional / 6000.0
lng = if mic_e.lon_direction == :west, do: -lng, else: lng
# Validate coordinates are within valid ranges
if lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180 do
%{
"latitude" => lat,
"longitude" => lng,
"comment" => mic_e.message || "",
"symbol_table_id" => "/",
"symbol_code" => ">",
"aprs_messaging" => false
}
end
_ ->
%{
"latitude" => data_extended[:latitude],
"longitude" => data_extended[:longitude],
"comment" => data_extended[:comment] || "",
"symbol_table_id" => data_extended[:symbol_table_id] || "/",
"symbol_code" => data_extended[:symbol_code] || ">",
"aprs_messaging" => data_extended[:aprs_messaging] || false
}
end
end
end

View file

@ -20,7 +20,7 @@ defmodule AprsWeb.Router do
scope "/", AprsWeb do
pipe_through :browser
get "/", PageController, :home
live "/", MapLive.Index, :index
get "/map", PageController, :map
live "/packets", PacketsLive.Index, :index

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View file

@ -0,0 +1,5 @@
# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file
#
# To ban all spiders from the entire site uncomment the next two lines:
# User-agent: *
# Disallow: /

BIN
priv/static/robots.txt.gz Normal file

Binary file not shown.

View file

@ -1,8 +0,0 @@
defmodule AprsWeb.PageControllerTest do
use AprsWeb.ConnCase
test "GET /", %{conn: conn} do
conn = get(conn, ~p"/")
assert html_response(conn, 200) =~ "Peace of mind from prototype to production"
end
end

248
tmp/index.ex.backup Normal file
View file

@ -0,0 +1,248 @@
defmodule AprsWeb.MapLive.Index do
@moduledoc """
LiveView for displaying real-time APRS packets on a map
"""
use AprsWeb, :live_view
alias Aprs.EncodingUtils
alias AprsWeb.Endpoint
alias Parser.Types.MicE
@impl true
def mount(_params, _session, socket) do
if connected?(socket) do
Endpoint.subscribe("aprs_messages")
end
{:ok,
assign(socket,
packets: [],
packet_count: 0,
page_title: "APRS Map",
# Default bounds for USA
map_bounds: %{
north: 49.0,
south: 24.0,
east: -66.0,
west: -125.0
}
)}
end
@impl true
def handle_event("update_bounds", %{"bounds" => bounds}, socket) do
# Update the map bounds from the client
map_bounds = %{
north: bounds["north"],
south: bounds["south"],
east: bounds["east"],
west: bounds["west"]
}
# Clear existing markers when bounds change
socket = push_event(socket, "clear_markers", %{})
{:noreply, assign(socket, map_bounds: map_bounds, packet_count: 0)}
end
@impl true
def handle_info(%{event: "packet", payload: payload}, socket) do
# Sanitize the packet to prevent encoding errors
sanitized_packet = EncodingUtils.sanitize_packet(payload)
# Log packet type for debugging
IO.inspect(sanitized_packet.data_type, label: "Packet type")
if sanitized_packet.data_extended do
# Check if data_extended is a struct before accessing __struct__
case sanitized_packet.data_extended do
%{__struct__: module} -> IO.inspect(module, label: "Data extended type")
_ -> IO.inspect("Plain map", label: "Data extended type")
end
end
# Only process packets with position data that are within current map bounds
if has_position_data?(sanitized_packet) && within_bounds?(sanitized_packet, socket.assigns.map_bounds) do
# Convert to a simple map structure for JSON encoding
packet_data = build_packet_data(sanitized_packet)
# Only push if we have valid packet data
if packet_data do
# Push the packet to the client-side JavaScript and increment count
socket =
socket
|> push_event("new_packet", packet_data)
|> update(:packet_count, &(&1 + 1))
{:noreply, socket}
else
# Invalid packet data, skip it
{:noreply, socket}
end
else
# Ignore packets without position data or outside bounds
{:noreply, socket}
end
end
@impl true
def render(assigns) do
~H"""
<link
rel="stylesheet"
href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin=""
/>
<script
src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
crossorigin=""
>
</script>
<style>
#aprs-map {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1;
}
.map-overlay {
position: absolute;
top: 10px;
right: 10px;
background: rgba(255, 255, 255, 0.9);
padding: 10px 15px;
border-radius: 5px;
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
z-index: 1000;
}
.packet-counter {
font-size: 14px;
font-weight: 600;
color: #333;
}
.aprs-marker {
background: transparent !important;
border: none !important;
}
</style>
<div id="aprs-map" phx-hook="APRSMap"></div>
<div class="map-overlay">
<div class="packet-counter">
<span id="packet-count">{@packet_count}</span> packets in view
</div>
</div>
"""
end
# Helper functions
defp has_position_data?(packet) do
case packet.data_extended do
%MicE{} = mic_e ->
# MicE packets have lat/lon in separate components
is_number(mic_e.lat_degrees) && is_number(mic_e.lat_minutes) &&
is_number(mic_e.lon_degrees) && is_number(mic_e.lon_minutes)
%{latitude: lat, longitude: lon} ->
# Regular position packets have decimal lat/lon
is_number(lat) && is_number(lon)
_ ->
false
end
end
defp within_bounds?(packet, bounds) do
{lat, lng} = get_coordinates(packet)
lat && lng &&
lat >= bounds.south && lat <= bounds.north &&
lng >= bounds.west && lng <= bounds.east
end
defp get_coordinates(packet) do
case packet.data_extended do
%MicE{} = mic_e ->
# Convert MicE components to decimal degrees
lat = mic_e.lat_degrees + mic_e.lat_minutes / 60.0 + mic_e.lat_fractional / 6000.0
lat = if mic_e.lat_direction == :south, do: -lat, else: lat
lng = mic_e.lon_degrees + mic_e.lon_minutes / 60.0 + mic_e.lon_fractional / 6000.0
lng = if mic_e.lon_direction == :west, do: -lng, else: lng
# Validate coordinates are within valid ranges
if lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180 do
{lat, lng}
else
IO.puts("Invalid MicE coordinates: lat=#{lat}, lng=#{lng}")
{nil, nil}
end
%{latitude: lat, longitude: lon} ->
{lat, lon}
_ ->
{nil, nil}
end
end
defp build_packet_data(packet) do
data_extended = build_data_extended(packet.data_extended)
if data_extended do
%{
"base_callsign" => packet.base_callsign || "",
"ssid" => packet.ssid || "",
"data_type" => to_string(packet.data_type || "unknown"),
"path" => packet.path || "",
"data_extended" => data_extended
}
end
end
defp build_data_extended(nil), do: nil
defp build_data_extended(data_extended) do
case data_extended do
%MicE{} = mic_e ->
# Convert MicE components to decimal degrees
lat = mic_e.lat_degrees + mic_e.lat_minutes / 60.0 + mic_e.lat_fractional / 6000.0
lat = if mic_e.lat_direction == :south, do: -lat, else: lat
lng = mic_e.lon_degrees + mic_e.lon_minutes / 60.0 + mic_e.lon_fractional / 6000.0
lng = if mic_e.lon_direction == :west, do: -lng, else: lng
# Validate coordinates are within valid ranges
if lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180 do
%{
"latitude" => lat,
"longitude" => lng,
"comment" => mic_e.message || "",
"symbol_table_id" => "/",
"symbol_code" => ">",
"aprs_messaging" => false
}
end
_ ->
%{
"latitude" => data_extended[:latitude],
"longitude" => data_extended[:longitude],
"comment" => data_extended[:comment] || "",
"symbol_table_id" => data_extended[:symbol_table_id] || "/",
"symbol_code" => data_extended[:symbol_code] || ">",
"aprs_messaging" => data_extended[:aprs_messaging] || false
}
end
end
end