more liveview and add procfile

This commit is contained in:
Graham McIntire 2025-06-15 21:43:40 -05:00
parent f9fa765e32
commit c480aa7eb1
No known key found for this signature in database
5 changed files with 871 additions and 429 deletions

2
Procfile Normal file
View file

@ -0,0 +1,2 @@
release: mix ecto.migrate
web: mix phx.server

View file

@ -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:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> 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: "<div><span>" + childCount + "</span></div>",
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 = `
<div style="min-width: 200px;">
<h4 style="margin: 0 0 5px 0; font-weight: bold;">${callsign} ${isHistorical ? "(Historical)" : ""}</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> ${timestamp}
</p>
</div>
`;
// 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: `<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: 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,

278
assets/js/minimal_map.js Normal file
View file

@ -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: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> 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: `<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);
opacity: ${opacity};
"></div>`,
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;

View file

@ -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"""
<!-- Leaflet CSS -->
<link
rel="stylesheet"
href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin=""
/>
<!-- Leaflet JS -->
<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-controls {
position: absolute;
left: 10px;
top: 10px;
z-index: 1000;
display: flex;
flex-direction: column;
gap: 10px;
}
.control-button {
background: white;
border: 2px solid rgba(0,0,0,0.2);
border-radius: 4px;
padding: 8px 12px;
cursor: pointer;
font-size: 14px;
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
}
.control-button:hover {
background: #f4f4f4;
}
.control-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.map-info {
position: absolute;
right: 10px;
top: 10px;
z-index: 1000;
background: white;
padding: 10px;
border-radius: 4px;
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
font-size: 12px;
}
.replay-controls {
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
z-index: 1000;
background: white;
padding: 10px;
border-radius: 4px;
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
display: flex;
gap: 10px;
align-items: center;
}
.aprs-marker {
background: transparent !important;
border: none !important;
}
.historical-marker {
opacity: 0.7;
}
</style>
<!-- Map Container -->
<div
id="aprs-map"
phx-hook="APRSMap"
phx-update="ignore"
data-center={Jason.encode!(@map_center)}
data-zoom={@map_zoom}
>
</div>
<!-- Map Controls -->
<div class="map-controls">
<button class="control-button" phx-click="locate_me" title="Find my location">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="16" />
<line x1="8" y1="12" x2="16" y2="12" />
</svg>
Locate Me
</button>
<button class="control-button" phx-click="toggle_replay">
<%= if @replay_active do %>
Stop Replay
<% else %>
Start Replay
<% end %>
</button>
<%= if @replay_active do %>
<button class="control-button" phx-click="pause_replay" disabled={@replay_paused}>
<%= if @replay_paused do %>
Resume
<% else %>
Pause
<% end %>
</button>
<% end %>
</div>
<!-- Map Info Panel -->
<div class="map-info">
<div><strong>Active Markers:</strong> {map_size(@active_markers)}</div>
<div><strong>Historical:</strong> {map_size(@historical_markers)}</div>
<div><strong>Zoom:</strong> {@map_zoom}</div>
<div>
<strong>Center:</strong> {Float.round(@map_center.lat, 4)}, {Float.round(@map_center.lng, 4)}
</div>
<%= if @geolocation_error do %>
<div style="color: red; margin-top: 5px;">
<strong>Location Error:</strong> {@geolocation_error}
</div>
<% end %>
<%= if @loading do %>
<div style="color: blue; margin-top: 5px;">Loading markers...</div>
<% end %>
</div>
<!-- Replay Controls -->
<%= if @replay_active do %>
<div class="replay-controls">
<label>Speed:</label>
<select phx-change="adjust_replay_speed" name="speed">
<option value="2000" selected={@replay_speed == 2000}>Slow</option>
<option value="1000" selected={@replay_speed == 1000}>Normal</option>
<option value="500" selected={@replay_speed == 500}>Fast</option>
<option value="200" selected={@replay_speed == 200}>Very Fast</option>
</select>
<span>Progress: {@replay_index}/{length(@replay_packets)}</span>
</div>
<% 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
"""
<div style="min-width: 200px;">
<h4 style="margin: 0 0 5px 0; font-weight: bold;">#{callsign} #{if historical, do: "(Historical)", else: ""}</h4>
<p style="margin: 2px 0; font-size: 12px;">
<strong>Position:</strong> #{Float.round(packet.lat, 4)}°, #{Float.round(packet.lon, 4)}°<br>
<strong>Type:</strong> #{packet.data_type}<br>
#{if data_extended["comment"], do: "<strong>Comment:</strong> #{data_extended["comment"]}<br>", else: ""}
<strong>Path:</strong> #{packet.path}<br>
<strong>Time:</strong> #{timestamp}
</p>
</div>
"""
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

View file

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