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