more liveview and add procfile
This commit is contained in:
parent
f9fa765e32
commit
c480aa7eb1
5 changed files with 871 additions and 429 deletions
2
Procfile
Normal file
2
Procfile
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
release: mix ecto.migrate
|
||||
web: mix phx.server
|
||||
433
assets/js/app.js
433
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:
|
||||
'© <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
278
assets/js/minimal_map.js
Normal 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: '© <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;
|
||||
586
lib/aprs_web/live/map_live/enhanced.ex
Normal file
586
lib/aprs_web/live/map_live/enhanced.ex
Normal 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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue