diff --git a/Procfile b/Procfile
new file mode 100644
index 0000000..f3e764e
--- /dev/null
+++ b/Procfile
@@ -0,0 +1,2 @@
+release: mix ecto.migrate
+web: mix phx.server
\ No newline at end of file
diff --git a/assets/js/app.js b/assets/js/app.js
index db57cd4..3948c69 100644
--- a/assets/js/app.js
+++ b/assets/js/app.js
@@ -24,437 +24,12 @@ import topbar from "../vendor/topbar";
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content");
+// Import minimal APRS map hook
+import MinimalAPRSMap from "./minimal_map.js";
+
// APRS Map Hook
let Hooks = {};
-Hooks.APRSMap = {
- mounted() {
- console.log("APRSMap hook mounted");
- // Get initial center and zoom from server-provided data attributes
- const initialCenter = JSON.parse(this.el.dataset.center);
- const initialZoom = parseInt(this.el.dataset.zoom);
-
- console.log("Initializing map with center:", initialCenter, "and zoom:", initialZoom);
- console.log("Map container element:", this.el);
- console.log("Container dimensions:", this.el.offsetWidth, "x", this.el.offsetHeight);
- console.log("Parsed coordinates:", initialCenter.lat, initialCenter.lng, "zoom:", initialZoom);
-
- // Initialize the map with the server-provided location
- const map = L.map(this.el, {
- zoomControl: true,
- attributionControl: true,
- closePopupOnClick: true,
- }).setView([initialCenter.lat, initialCenter.lng], initialZoom);
-
- console.log("Map setView called with:", [initialCenter.lat, initialCenter.lng], initialZoom);
- console.log("Map object created:", map);
-
- // Validate container size and force refresh if needed
- if (this.el.offsetWidth === 0 || this.el.offsetHeight === 0) {
- console.warn("Map container has zero dimensions, forcing size refresh");
- setTimeout(() => {
- map.invalidateSize();
- map.setView([initialCenter.lat, initialCenter.lng], initialZoom);
- console.log("Map size refreshed and view reset");
- }, 100);
- }
-
- // Force initial size calculation
- map.invalidateSize();
-
- // Track when map is ready
- map.whenReady(() => {
- console.log("Map is fully ready and rendered");
- console.log("Map center after ready:", map.getCenter());
- console.log("Map zoom after ready:", map.getZoom());
- console.log("Map bounds after ready:", map.getBounds());
- this.mapReady = true;
- this.pushEvent("map_ready", {});
- });
-
- console.log("Map initialized");
-
- // Add OpenStreetMap tile layer
- L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
- attribution:
- '© OpenStreetMap contributors | APRS.me',
- maxZoom: 19,
- }).addTo(map);
-
- // Check if MarkerCluster plugin is available
- if (typeof L.markerClusterGroup === "function") {
- // Create marker cluster group
- this.markerClusterGroup = L.markerClusterGroup({
- chunkedLoading: true,
- maxClusterRadius: function (zoom) {
- // Adjust cluster radius based on zoom level
- // Tighter clustering when zoomed out, looser when zoomed in
- if (zoom <= 5) return 120;
- if (zoom <= 8) return 80;
- if (zoom <= 11) return 60;
- if (zoom <= 14) return 40;
- return 20;
- },
- spiderfyOnMaxZoom: true,
- showCoverageOnHover: true,
- zoomToBoundsOnClick: true,
- removeOutsideVisibleBounds: true,
- animate: true,
- animateAddingMarkers: true,
- disableClusteringAtZoom: 16,
- iconCreateFunction: function (cluster) {
- const childCount = cluster.getChildCount();
- let c = " marker-cluster-";
- if (childCount < 10) {
- c += "small";
- } else if (childCount < 100) {
- c += "medium";
- } else {
- c += "large";
- }
- return new L.DivIcon({
- html: "
" + childCount + "
",
- className: "marker-cluster" + c,
- iconSize: new L.Point(40, 40),
- });
- },
- });
-
- // Add cluster group to map
- map.addLayer(this.markerClusterGroup);
- } else {
- console.warn("Leaflet MarkerCluster plugin not loaded, falling back to regular markers");
- this.markerClusterGroup = null;
- }
-
- // Store markers to avoid duplicates
- this.markers = new Map();
- this.historicalMarkers = new Map();
-
- // Store map instance and initialize state
- this.map = map;
- this.mapReady = false;
-
- // Send initial bounds to server
- this.sendBoundsToServer();
-
- // Listen for new packets from the server
- this.handleEvent("new_packet", (packet) => {
- this.addPacketMarker(packet);
- });
-
- // Listen for historical packets
- this.handleEvent("historical_packet", (packet) => {
- this.addPacketMarker(packet, true);
- });
-
- // Listen for zoom to location event
- this.handleEvent("zoom_to_location", (data) => {
- console.log("Received zoom_to_location event:", data);
-
- if (data.lat && data.lng) {
- // Convert to numbers to ensure proper handling
- const lat = parseFloat(data.lat);
- const lng = parseFloat(data.lng);
- const zoom = parseInt(data.zoom || 12);
-
- console.log(`Setting map view to [${lat}, ${lng}] with zoom ${zoom}`);
-
- // Force map invalidation before setting view
- this.map.invalidateSize();
-
- // Check container dimensions before zoom
- if (this.el.offsetWidth === 0 || this.el.offsetHeight === 0) {
- console.warn(
- "Container has zero dimensions during zoom, width:",
- this.el.offsetWidth,
- "height:",
- this.el.offsetHeight,
- );
- }
-
- // Small delay to ensure map is ready
- setTimeout(() => {
- // Force another size refresh right before setView
- this.map.invalidateSize();
- console.log("About to call setView with:", [lat, lng], zoom);
- this.map.setView([lat, lng], zoom, {
- animate: true,
- duration: 1,
- });
-
- // Force final size refresh after setView
- setTimeout(() => {
- this.map.invalidateSize();
- }, 100);
- console.log("Map view updated after delay");
- console.log("New map center:", this.map.getCenter());
- console.log("New map zoom:", this.map.getZoom());
- }, 300);
- } else {
- console.error("Invalid coordinates in zoom_to_location event:", data);
- }
- });
-
- // Listen for clearing historical packets
- this.handleEvent("clear_historical_packets", () => {
- this.clearHistoricalMarkers();
- });
-
- // Listen for clear markers event
- this.handleEvent("clear_markers", () => {
- this.clearAllMarkers();
- });
-
- // Listen for refresh markers event
- this.handleEvent("refresh_markers", () => {
- this.refreshMarkers();
- });
-
- // Handle geolocation button clicks
- this.handleEvent("request_geolocation", () => {
- if ("geolocation" in navigator) {
- navigator.geolocation.getCurrentPosition(
- (position) => {
- const { latitude, longitude } = position.coords;
- // Send location back to server
- this.pushEvent("set_location", { lat: latitude, lng: longitude });
- },
- (error) => {
- console.warn("Geolocation error:", error.message);
- },
- );
- } else {
- console.warn("Geolocation not available in this browser");
- }
- });
-
- // Update bounds when map moves or zooms
- map.on("moveend", () => {
- // Debounce the bounds update to avoid too many server calls
- if (this.boundsTimer) clearTimeout(this.boundsTimer);
- this.boundsTimer = setTimeout(() => {
- this.sendBoundsToServer();
- }, 300);
- });
-
- // 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 cluster group or map
- if (this.markerClusterGroup) {
- this.markerClusterGroup.clearLayers();
- } else {
- // Fallback: remove markers directly from map
- this.markers.forEach((marker) => {
- this.map.removeLayer(marker);
- });
- this.historicalMarkers.forEach((marker) => {
- this.map.removeLayer(marker);
- });
- }
- this.markers.clear();
- this.historicalMarkers.clear();
- },
-
- refreshMarkers() {
- // More efficient refresh that doesn't require clearing all markers
- // Filter markers to keep only those added in the last hour
- const now = new Date();
- const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
-
- // First, collect markers to remove
- const markersToRemove = [];
- this.markers.forEach((marker, callsign) => {
- if (marker.addedAt && marker.addedAt < oneHourAgo) {
- markersToRemove.push(callsign);
- }
- });
-
- // Then remove them from the map and collection
- markersToRemove.forEach((callsign) => {
- const marker = this.markers.get(callsign);
- if (marker) {
- if (this.markerClusterGroup) {
- this.markerClusterGroup.removeLayer(marker);
- } else {
- this.map.removeLayer(marker);
- }
- this.markers.delete(callsign);
- }
- });
- },
-
- clearHistoricalMarkers() {
- // Remove only historical markers
- if (this.markerClusterGroup) {
- this.historicalMarkers.forEach((marker) => {
- this.markerClusterGroup.removeLayer(marker);
- });
- } else {
- // Fallback: remove markers directly from map
- this.historicalMarkers.forEach((marker) => {
- this.map.removeLayer(marker);
- });
- }
- this.historicalMarkers.clear();
- },
-
- removeMarkersOutsideBounds(bounds) {
- // The cluster handles marker visibility automatically
- },
-
- addPacketMarker(packet, isHistorical = false) {
- // Skip packets without required data
- if (
- !packet["data_extended"] ||
- !packet["data_extended"]["latitude"] ||
- !packet["data_extended"]["longitude"]
- ) {
- 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) {
- return;
- }
-
- // Generate a unique ID for the marker
- const callsign = packet["base_callsign"] + (packet["ssid"] ? "-" + packet["ssid"] : "");
- // For historical packets, add a timestamp or unique ID to distinguish them
- const markerId = isHistorical ? `hist_${callsign}_${Date.now()}` : callsign;
-
- // Create popup content with historical indicator if needed
- const timestamp =
- isHistorical && packet["timestamp"]
- ? new Date(packet["timestamp"]).toLocaleString()
- : new Date().toLocaleTimeString();
-
- const popupContent = `
-
-
${callsign} ${isHistorical ? "(Historical)" : ""}
-
- Position: ${lat.toFixed(4)}°, ${lng.toFixed(4)}°
- Type: ${packet["data_type"]}
- ${packet["data_extended"]["comment"] ? `Comment: ${packet["data_extended"]["comment"]}
` : ""}
- Path: ${packet["path"]}
- Time: ${timestamp}
-
-
- `;
-
- // Determine which marker collection to use
- const markerCollection = isHistorical ? this.historicalMarkers : this.markers;
-
- // Check if marker already exists (only for non-historical markers)
- if (!isHistorical && this.markers.has(markerId)) {
- try {
- // Update existing marker
- const existingMarker = this.markers.get(markerId);
- if (this.markerClusterGroup) {
- // Remove and re-add to cluster group
- this.markerClusterGroup.removeLayer(existingMarker);
- existingMarker.setLatLng([lat, lng]);
- existingMarker.setPopupContent(popupContent);
- this.markerClusterGroup.addLayer(existingMarker);
- } else {
- // Fallback: just update position
- existingMarker.setLatLng([lat, lng]);
- existingMarker.setPopupContent(popupContent);
- }
- } catch (e) {
- // If there's an error updating, proceed with creating a new marker
- this.markers.delete(markerId);
- }
- }
-
- // Create new marker if it doesn't exist or if we're handling a historical packet
- if (isHistorical || !this.markers.has(markerId)) {
- // Create new marker
- const icon = this.createAPRSIcon(
- packet["data_extended"]["symbol_table_id"] || "/",
- packet["data_extended"]["symbol_code"] || ">",
- isHistorical,
- );
-
- const marker = L.marker([lat, lng], { icon: icon }).bindPopup(popupContent);
- marker.addedAt = new Date(); // Track when the marker was added
-
- // Add CSS class for historical markers
- if (isHistorical) {
- marker.options.className = "historical-marker";
- }
-
- // Add to cluster group or directly to map
- try {
- if (this.markerClusterGroup) {
- this.markerClusterGroup.addLayer(marker);
- } else {
- marker.addTo(this.map);
- }
- } catch (e) {
- // Silently ignore errors when adding markers
- }
-
- // Store the marker
- markerCollection.set(markerId, marker);
- }
- },
-
- createAPRSIcon(symbolTable, symbolCode, isHistorical = false) {
- // Default icon color based on symbol table
- let color = symbolTable === "/" ? "#2563eb" : "#dc2626";
-
- // Use a different color for historical markers
- if (isHistorical) {
- color = symbolTable === "/" ? "#90b4ed" : "#e98a84";
- }
-
- return L.divIcon({
- html: ``,
- className: isHistorical ? "aprs-marker historical-marker" : "aprs-marker",
- iconSize: [16, 16],
- iconAnchor: [8, 8],
- popupAnchor: [0, -8],
- });
- },
-
- destroyed() {
- if (this.boundsTimer) {
- clearTimeout(this.boundsTimer);
- }
- if (this.markerClusterGroup) {
- this.markerClusterGroup.clearLayers();
- }
- if (this.map) {
- this.map.remove();
- this.map = null;
- }
- this.markers.clear();
- this.historicalMarkers.clear();
- },
-};
+Hooks.APRSMap = MinimalAPRSMap;
let liveSocket = new LiveSocket("/live", Socket, {
longPollFallbackMs: 2500,
diff --git a/assets/js/minimal_map.js b/assets/js/minimal_map.js
new file mode 100644
index 0000000..60b52b8
--- /dev/null
+++ b/assets/js/minimal_map.js
@@ -0,0 +1,278 @@
+// Minimal APRS Map Hook - handles only basic map interaction
+// All data logic handled by LiveView
+
+let MinimalAPRSMap = {
+ mounted() {
+ console.log("MinimalAPRSMap hook mounted");
+
+ // Get initial center and zoom from server-provided data attributes
+ const initialCenter = JSON.parse(this.el.dataset.center);
+ const initialZoom = parseInt(this.el.dataset.zoom);
+
+ console.log("Initializing minimal map with center:", initialCenter, "and zoom:", initialZoom);
+
+ // Initialize basic map
+ this.map = L.map(this.el, {
+ zoomControl: true,
+ attributionControl: true,
+ closePopupOnClick: true,
+ }).setView([initialCenter.lat, initialCenter.lng], initialZoom);
+
+ // Add OpenStreetMap tile layer
+ L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
+ attribution: '© OpenStreetMap contributors | APRS.me',
+ maxZoom: 19,
+ }).addTo(this.map);
+
+ // Store markers for management
+ this.markers = new Map();
+ this.markerLayer = L.layerGroup().addTo(this.map);
+
+ // Force initial size calculation
+ this.map.invalidateSize();
+
+ // Track when map is ready
+ this.map.whenReady(() => {
+ console.log("Minimal map is ready");
+ this.pushEvent("map_ready", {});
+ this.sendBoundsToServer();
+ });
+
+ // Send bounds to LiveView when map moves
+ this.map.on('moveend', () => {
+ if (this.boundsTimer) clearTimeout(this.boundsTimer);
+ this.boundsTimer = setTimeout(() => {
+ this.sendBoundsToServer();
+ }, 300);
+ });
+
+ // Handle resize
+ window.addEventListener("resize", () => {
+ this.map.invalidateSize();
+ });
+
+ // LiveView event handlers
+ this.setupLiveViewHandlers();
+ },
+
+ setupLiveViewHandlers() {
+ // Add single marker
+ this.handleEvent("add_marker", (data) => {
+ this.addMarker(data);
+ });
+
+ // Add multiple markers at once
+ this.handleEvent("add_markers", (data) => {
+ if (data.markers && Array.isArray(data.markers)) {
+ data.markers.forEach(marker => this.addMarker(marker));
+ }
+ });
+
+ // Remove marker
+ this.handleEvent("remove_marker", (data) => {
+ this.removeMarker(data.id);
+ });
+
+ // Clear all markers
+ this.handleEvent("clear_markers", () => {
+ this.clearAllMarkers();
+ });
+
+ // Update marker
+ this.handleEvent("update_marker", (data) => {
+ this.updateMarker(data);
+ });
+
+ // Zoom to location
+ this.handleEvent("zoom_to_location", (data) => {
+ if (data.lat && data.lng) {
+ const lat = parseFloat(data.lat);
+ const lng = parseFloat(data.lng);
+ const zoom = parseInt(data.zoom || 12);
+
+ this.map.setView([lat, lng], zoom, {
+ animate: true,
+ duration: 1
+ });
+ }
+ });
+
+ // Handle geolocation requests
+ this.handleEvent("request_geolocation", () => {
+ if ("geolocation" in navigator) {
+ navigator.geolocation.getCurrentPosition(
+ (position) => {
+ const { latitude, longitude } = position.coords;
+ this.pushEvent("set_location", { lat: latitude, lng: longitude });
+ },
+ (error) => {
+ console.warn("Geolocation error:", error.message);
+ this.pushEvent("geolocation_error", { error: error.message });
+ }
+ );
+ } else {
+ console.warn("Geolocation not available");
+ this.pushEvent("geolocation_error", { error: "Geolocation not supported" });
+ }
+ });
+ },
+
+ sendBoundsToServer() {
+ if (!this.map) return;
+
+ const bounds = this.map.getBounds();
+ const center = this.map.getCenter();
+ const zoom = this.map.getZoom();
+
+ this.pushEvent("bounds_changed", {
+ bounds: {
+ north: bounds.getNorth(),
+ south: bounds.getSouth(),
+ east: bounds.getEast(),
+ west: bounds.getWest(),
+ },
+ center: {
+ lat: center.lat,
+ lng: center.lng
+ },
+ zoom: zoom
+ });
+ },
+
+ addMarker(data) {
+ if (!data.id || !data.lat || !data.lng) {
+ console.warn("Invalid marker data:", data);
+ return;
+ }
+
+ const lat = parseFloat(data.lat);
+ const lng = parseFloat(data.lng);
+
+ // Validate coordinates
+ if (isNaN(lat) || isNaN(lng) || lat < -90 || lat > 90 || lng < -180 || lng > 180) {
+ console.warn("Invalid coordinates:", lat, lng);
+ return;
+ }
+
+ // Remove existing marker if it exists
+ this.removeMarker(data.id);
+
+ // Create marker icon
+ const icon = this.createMarkerIcon(data);
+
+ // Create marker
+ const marker = L.marker([lat, lng], { icon: icon });
+
+ // Add popup if content provided
+ if (data.popup) {
+ marker.bindPopup(data.popup);
+ }
+
+ // Handle marker click
+ marker.on('click', () => {
+ this.pushEvent("marker_clicked", {
+ id: data.id,
+ callsign: data.callsign,
+ lat: lat,
+ lng: lng
+ });
+ });
+
+ // Add to map and store reference
+ marker.addTo(this.markerLayer);
+ this.markers.set(data.id, marker);
+ },
+
+ removeMarker(id) {
+ const marker = this.markers.get(id);
+ if (marker) {
+ this.markerLayer.removeLayer(marker);
+ this.markers.delete(id);
+ }
+ },
+
+ updateMarker(data) {
+ if (!data.id) return;
+
+ const existingMarker = this.markers.get(data.id);
+ if (existingMarker) {
+ // Update position if provided
+ if (data.lat && data.lng) {
+ const lat = parseFloat(data.lat);
+ const lng = parseFloat(data.lng);
+ if (!isNaN(lat) && !isNaN(lng)) {
+ existingMarker.setLatLng([lat, lng]);
+ }
+ }
+
+ // Update popup if provided
+ if (data.popup) {
+ existingMarker.setPopupContent(data.popup);
+ }
+
+ // Update icon if data changed
+ if (data.symbol_table || data.symbol_code || data.color) {
+ const newIcon = this.createMarkerIcon(data);
+ existingMarker.setIcon(newIcon);
+ }
+ } else {
+ // Marker doesn't exist, create it
+ this.addMarker(data);
+ }
+ },
+
+ clearAllMarkers() {
+ this.markerLayer.clearLayers();
+ this.markers.clear();
+ },
+
+ createMarkerIcon(data) {
+ // Determine color based on symbol table or provided color
+ let color = data.color || (data.symbol_table === "/" ? "#2563eb" : "#dc2626");
+
+ // Use different opacity for historical markers
+ const opacity = data.historical ? 0.6 : 1.0;
+
+ return L.divIcon({
+ html: ``,
+ className: data.historical ? "aprs-marker historical-marker" : "aprs-marker",
+ iconSize: [16, 16],
+ iconAnchor: [8, 8],
+ popupAnchor: [0, -8],
+ });
+ },
+
+ destroyed() {
+ console.log("MinimalAPRSMap hook destroyed");
+
+ // Clean up timers
+ if (this.boundsTimer) {
+ clearTimeout(this.boundsTimer);
+ }
+
+ // Clean up markers
+ if (this.markerLayer) {
+ this.markerLayer.clearLayers();
+ }
+
+ if (this.markers) {
+ this.markers.clear();
+ }
+
+ // Clean up map
+ if (this.map) {
+ this.map.remove();
+ this.map = null;
+ }
+ }
+};
+
+export default MinimalAPRSMap;
diff --git a/lib/aprs_web/live/map_live/enhanced.ex b/lib/aprs_web/live/map_live/enhanced.ex
new file mode 100644
index 0000000..574e883
--- /dev/null
+++ b/lib/aprs_web/live/map_live/enhanced.ex
@@ -0,0 +1,586 @@
+defmodule AprsWeb.MapLive.Enhanced do
+ @moduledoc false
+ use AprsWeb, :live_view
+
+ import Ecto.Query
+ import Phoenix.Component, except: [update: 3]
+
+ alias Aprs.Packet
+ alias Aprs.Repo
+
+ # Default map settings
+ @default_center %{lat: 39.8283, lng: -98.5795}
+ @default_zoom 4
+ @max_markers 500
+ # 30 seconds
+ @cleanup_interval 30_000
+ @packet_retention_minutes 60
+
+ def mount(_params, _session, socket) do
+ # Schedule periodic cleanup
+ if connected?(socket) do
+ Process.send_after(self(), :cleanup_old_markers, @cleanup_interval)
+ end
+
+ socket =
+ assign(socket,
+ # Map state
+ map_center: @default_center,
+ map_zoom: @default_zoom,
+ map_bounds: default_bounds(),
+
+ # Marker management
+ active_markers: %{},
+ historical_markers: %{},
+ marker_count: 0,
+
+ # UI state
+ page_title: "APRS Map - Enhanced",
+ loading: false,
+ geolocation_error: nil,
+
+ # Replay functionality
+ replay_active: false,
+ replay_speed: 1000,
+ replay_paused: false,
+ replay_timer: nil,
+ replay_packets: [],
+ replay_index: 0
+ )
+
+ {:ok, socket}
+ end
+
+ def handle_event("map_ready", _params, socket) do
+ # Map is ready, load initial markers
+ {:noreply, load_markers_in_bounds(socket)}
+ end
+
+ def handle_event("bounds_changed", params, socket) do
+ %{
+ "bounds" => bounds,
+ "center" => center,
+ "zoom" => zoom
+ } = params
+
+ socket =
+ socket
+ |> assign(:map_bounds, bounds)
+ |> assign(:map_center, center)
+ |> assign(:map_zoom, zoom)
+ |> load_markers_in_bounds()
+
+ {:noreply, socket}
+ end
+
+ def handle_event("marker_clicked", params, socket) do
+ %{"id" => marker_id, "callsign" => callsign} = params
+
+ # You could add marker click logic here
+ # For example, show detailed info, center map, etc.
+ IO.puts("Marker clicked: #{callsign} (#{marker_id})")
+
+ {:noreply, socket}
+ end
+
+ def handle_event("locate_me", _params, socket) do
+ # Request geolocation from client
+ {:noreply, push_event(socket, "request_geolocation", %{})}
+ end
+
+ def handle_event("set_location", %{"lat" => lat, "lng" => lng}, socket) do
+ center = %{lat: lat, lng: lng}
+ zoom = 12
+
+ socket =
+ socket
+ |> assign(:map_center, center)
+ |> assign(:map_zoom, zoom)
+ |> assign(:geolocation_error, nil)
+
+ # Tell client to zoom to location
+ socket =
+ push_event(socket, "zoom_to_location", %{
+ lat: lat,
+ lng: lng,
+ zoom: zoom
+ })
+
+ {:noreply, socket}
+ end
+
+ def handle_event("geolocation_error", %{"error" => error}, socket) do
+ {:noreply, assign(socket, :geolocation_error, error)}
+ end
+
+ def handle_event("toggle_replay", _params, socket) do
+ if socket.assigns.replay_active do
+ stop_replay(socket)
+ else
+ start_replay(socket)
+ end
+ end
+
+ def handle_event("pause_replay", _params, socket) do
+ socket =
+ if socket.assigns.replay_timer do
+ Process.cancel_timer(socket.assigns.replay_timer)
+ assign(socket, replay_timer: nil, replay_paused: true)
+ else
+ socket
+ end
+
+ {:noreply, socket}
+ end
+
+ def handle_event("adjust_replay_speed", %{"speed" => speed}, socket) do
+ speed_ms = String.to_integer(speed)
+ {:noreply, assign(socket, :replay_speed, speed_ms)}
+ end
+
+ def handle_info(:cleanup_old_markers, socket) do
+ socket = cleanup_old_markers(socket)
+
+ # Schedule next cleanup
+ Process.send_after(self(), :cleanup_old_markers, @cleanup_interval)
+
+ {:noreply, socket}
+ end
+
+ def handle_info(:replay_next, socket) do
+ socket = process_next_replay_packet(socket)
+ {:noreply, socket}
+ end
+
+ def handle_info({:new_packet, packet}, socket) do
+ # Handle real-time packet updates
+ if has_position_data?(packet) and within_bounds?(packet, socket.assigns.map_bounds) do
+ socket = add_packet_marker(socket, packet, false)
+ {:noreply, socket}
+ else
+ {:noreply, socket}
+ end
+ end
+
+ def render(assigns) do
+ ~H"""
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ <%= if @replay_active do %>
+
+ <% end %>
+
+
+
+
+
Active Markers: {map_size(@active_markers)}
+
Historical: {map_size(@historical_markers)}
+
Zoom: {@map_zoom}
+
+ Center: {Float.round(@map_center.lat, 4)}, {Float.round(@map_center.lng, 4)}
+
+
+ <%= if @geolocation_error do %>
+
+ Location Error: {@geolocation_error}
+
+ <% end %>
+
+ <%= if @loading do %>
+
Loading markers...
+ <% end %>
+
+
+
+ <%= if @replay_active do %>
+
+
+
+
+ Progress: {@replay_index}/{length(@replay_packets)}
+
+ <% end %>
+ """
+ end
+
+ # Private helper functions
+
+ defp default_bounds do
+ %{
+ north: 49.0,
+ south: 24.0,
+ east: -66.0,
+ west: -125.0
+ }
+ end
+
+ defp load_markers_in_bounds(socket) do
+ bounds = socket.assigns.map_bounds
+
+ # Don't load if we don't have proper bounds yet
+ if bounds == default_bounds() do
+ socket
+ else
+ socket = assign(socket, :loading, true)
+
+ # Fetch recent packets within bounds
+ packets = fetch_packets_in_bounds(bounds, @max_markers)
+
+ # Convert packets to marker data and send to client
+ markers = Enum.map(packets, &packet_to_marker_data/1)
+
+ # Clear existing markers and add new ones
+ socket =
+ socket
+ |> push_event("clear_markers", %{})
+ |> push_event("add_markers", %{markers: markers})
+ |> update_active_markers(packets)
+ |> assign(:loading, false)
+
+ socket
+ end
+ end
+
+ defp fetch_packets_in_bounds(bounds, limit) do
+ cutoff_time = DateTime.add(DateTime.utc_now(), -@packet_retention_minutes * 60, :second)
+
+ Repo.all(
+ from(p in Packet,
+ where: p.has_position == true,
+ where: p.received_at >= ^cutoff_time,
+ where: p.lat >= ^bounds["south"],
+ where: p.lat <= ^bounds["north"],
+ where: p.lon >= ^bounds["west"],
+ where: p.lon <= ^bounds["east"],
+ order_by: [desc: p.received_at],
+ limit: ^limit
+ )
+ )
+ end
+
+ defp packet_to_marker_data(packet) do
+ data_extended = packet.data_extended || %{}
+ callsign = packet.base_callsign <> if packet.ssid, do: "-#{packet.ssid}", else: ""
+
+ %{
+ id: callsign,
+ callsign: callsign,
+ lat: packet.lat,
+ lng: packet.lon,
+ symbol_table: data_extended["symbol_table_id"] || "/",
+ symbol_code: data_extended["symbol_code"] || ">",
+ historical: false,
+ popup: build_popup_content(packet, callsign, false)
+ }
+ end
+
+ defp build_popup_content(packet, callsign, historical) do
+ data_extended = packet.data_extended || %{}
+
+ timestamp =
+ if historical do
+ DateTime.to_string(packet.received_at)
+ else
+ packet.received_at |> DateTime.to_time() |> Time.to_string()
+ end
+
+ """
+
+
#{callsign} #{if historical, do: "(Historical)", else: ""}
+
+ Position: #{Float.round(packet.lat, 4)}°, #{Float.round(packet.lon, 4)}°
+ Type: #{packet.data_type}
+ #{if data_extended["comment"], do: "Comment: #{data_extended["comment"]}
", else: ""}
+ Path: #{packet.path}
+ Time: #{timestamp}
+
+
+ """
+ end
+
+ defp update_active_markers(socket, packets) do
+ active_markers =
+ Map.new(packets, fn packet ->
+ callsign = packet.base_callsign <> if packet.ssid, do: "-#{packet.ssid}", else: ""
+ {callsign, %{packet: packet, added_at: DateTime.utc_now()}}
+ end)
+
+ assign(socket, :active_markers, active_markers)
+ end
+
+ defp add_packet_marker(socket, packet, historical) do
+ if has_position_data?(packet) do
+ marker_data = packet_to_marker_data(packet)
+ marker_data = Map.put(marker_data, :historical, historical)
+
+ socket = push_event(socket, "add_marker", marker_data)
+
+ # Update marker tracking
+ callsign = marker_data.id
+ marker_info = %{packet: packet, added_at: DateTime.utc_now()}
+
+ if historical do
+ Phoenix.Component.update(socket, :historical_markers, &Map.put(&1, callsign, marker_info))
+ else
+ Phoenix.Component.update(socket, :active_markers, &Map.put(&1, callsign, marker_info))
+ end
+ else
+ socket
+ end
+ end
+
+ defp cleanup_old_markers(socket) do
+ cutoff_time = DateTime.add(DateTime.utc_now(), -@packet_retention_minutes * 60, :second)
+
+ # Find old markers
+ old_markers =
+ socket.assigns.active_markers
+ |> Enum.filter(fn {_callsign, %{added_at: added_at}} ->
+ DateTime.before?(added_at, cutoff_time)
+ end)
+ |> Enum.map(fn {callsign, _} -> callsign end)
+
+ # Remove old markers from client
+ socket =
+ Enum.reduce(old_markers, socket, fn callsign, acc_socket ->
+ push_event(acc_socket, "remove_marker", %{id: callsign})
+ end)
+
+ # Update server state
+ active_markers =
+ socket.assigns.active_markers
+ |> Enum.reject(fn {callsign, _} -> callsign in old_markers end)
+ |> Map.new()
+
+ assign(socket, :active_markers, active_markers)
+ end
+
+ defp start_replay(socket) do
+ # Fetch historical packets for replay
+ bounds = socket.assigns.map_bounds
+ one_hour_ago = DateTime.add(DateTime.utc_now(), -3600, :second)
+ two_hours_ago = DateTime.add(DateTime.utc_now(), -7200, :second)
+
+ packets = fetch_historical_packets(bounds, two_hours_ago, one_hour_ago)
+
+ socket =
+ socket
+ |> assign(:replay_active, true)
+ |> assign(:replay_packets, packets)
+ |> assign(:replay_index, 0)
+ |> assign(:replay_paused, false)
+
+ # Start replay timer
+ timer = Process.send_after(self(), :replay_next, socket.assigns.replay_speed)
+ assign(socket, :replay_timer, timer)
+ end
+
+ defp stop_replay(socket) do
+ # Cancel timer if running
+ if socket.assigns.replay_timer do
+ Process.cancel_timer(socket.assigns.replay_timer)
+ end
+
+ # Clear historical markers
+ socket = push_event(socket, "clear_markers", %{})
+
+ socket
+ |> assign(:replay_active, false)
+ |> assign(:replay_timer, nil)
+ |> assign(:replay_packets, [])
+ |> assign(:replay_index, 0)
+ |> assign(:historical_markers, %{})
+ # Reload current markers
+ |> load_markers_in_bounds()
+ end
+
+ defp process_next_replay_packet(socket) do
+ packets = socket.assigns.replay_packets
+ index = socket.assigns.replay_index
+
+ if index < length(packets) do
+ packet = Enum.at(packets, index)
+ socket = add_packet_marker(socket, packet, true)
+
+ # Schedule next packet
+ timer = Process.send_after(self(), :replay_next, socket.assigns.replay_speed)
+
+ socket
+ |> assign(:replay_index, index + 1)
+ |> assign(:replay_timer, timer)
+ else
+ # Replay finished
+ assign(socket, :replay_timer, nil)
+ end
+ end
+
+ defp fetch_historical_packets(bounds, start_time, end_time) do
+ Repo.all(
+ from(p in Packet,
+ where: p.has_position == true,
+ where: p.received_at >= ^start_time,
+ where: p.received_at <= ^end_time,
+ where: p.lat >= ^bounds["south"],
+ where: p.lat <= ^bounds["north"],
+ where: p.lon >= ^bounds["west"],
+ where: p.lon <= ^bounds["east"],
+ order_by: [asc: p.received_at],
+ limit: 1000
+ )
+ )
+ end
+
+ defp has_position_data?(packet) do
+ packet.has_position == true and
+ packet.lat != nil and
+ packet.lon != nil
+ end
+
+ defp within_bounds?(packet, bounds) do
+ lat = packet.lat
+ lng = packet.lon
+
+ lat >= bounds["south"] and lat <= bounds["north"] and
+ lng >= bounds["west"] and lng <= bounds["east"]
+ end
+end
diff --git a/lib/aprs_web/router.ex b/lib/aprs_web/router.ex
index 3c209ae..b4ccfd1 100644
--- a/lib/aprs_web/router.ex
+++ b/lib/aprs_web/router.ex
@@ -28,6 +28,7 @@ defmodule AprsWeb.Router do
pipe_through :browser
live "/", MapLive.Index, :index
+ live "/enhanced", MapLive.Enhanced, :index
get "/map", PageController, :map
live "/status", StatusLive.Index, :index