Improve info page live updates and fix APRS parser
- Fixed info page not receiving live updates by implementing callsign-specific PubSub topics - Added automatic time counter for "Last Position" and other timestamps using JavaScript hook - Optimized map updates to only move marker instead of reloading entire map - Fixed APRS parser to properly handle [000/000/A=altitude format in comments - Removed packet caching on info page for real-time updates The info page now properly subscribes to updates for the specific callsign being viewed, timestamps automatically count up without page refresh, and the map smoothly animates marker position changes instead of flickering with full reloads. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
dc459b491d
commit
85c3b3d9e7
7 changed files with 187 additions and 59 deletions
|
|
@ -54,6 +54,8 @@ import MapAPRSMap from "./map";
|
|||
import ErrorBoundary from "./hooks/error_boundary";
|
||||
// Import info map hook
|
||||
import { InfoMap } from "./hooks/info_map";
|
||||
// Import time ago hook
|
||||
import TimeAgoHook from "./hooks/time_ago_hook";
|
||||
|
||||
// Responsive Slideover Hook
|
||||
let ResponsiveSlideoverHook = {
|
||||
|
|
@ -218,6 +220,7 @@ Object.keys(WeatherChartHooks).forEach(hookName => {
|
|||
Hooks.ResponsiveSlideoverHook = ResponsiveSlideoverHook;
|
||||
Hooks.BodyClassHook = BodyClassHook;
|
||||
Hooks.ErrorBoundary = ErrorBoundary;
|
||||
Hooks.TimeAgoHook = TimeAgoHook;
|
||||
|
||||
// Helper function to get theme-aware colors
|
||||
const getThemeColors = () => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,51 @@
|
|||
// Simple map hook for displaying a single station on the info page
|
||||
export const InfoMap = {
|
||||
mounted() {
|
||||
this.initializeMap();
|
||||
},
|
||||
|
||||
updated() {
|
||||
// When the element updates, check if we need to update the marker
|
||||
const lat = parseFloat(this.el.dataset.lat);
|
||||
const lon = parseFloat(this.el.dataset.lon);
|
||||
const symbolHtml = this.el.dataset.symbolHtml;
|
||||
|
||||
// Validate coordinates
|
||||
if (isNaN(lat) || isNaN(lon)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If map doesn't exist yet, initialize it
|
||||
if (!this.map) {
|
||||
this.initializeMap();
|
||||
return;
|
||||
}
|
||||
|
||||
// Update marker position if it changed
|
||||
if (this.marker) {
|
||||
const currentPos = this.marker.getLatLng();
|
||||
if (currentPos.lat !== lat || currentPos.lng !== lon) {
|
||||
// Animate the marker to the new position
|
||||
this.marker.setLatLng([lat, lon]);
|
||||
|
||||
// Update the popup content
|
||||
const callsign = this.el.dataset.callsign;
|
||||
this.marker.setPopupContent(`<strong>${callsign}</strong><br/>Lat: ${lat.toFixed(6)}<br/>Lon: ${lon.toFixed(6)}`);
|
||||
|
||||
// Optionally pan the map to the new position with animation
|
||||
this.map.panTo([lat, lon], { animate: true, duration: 1 });
|
||||
}
|
||||
|
||||
// Update marker icon if symbol changed
|
||||
if (symbolHtml !== this.lastSymbolHtml) {
|
||||
const markerIcon = this.createMarkerIcon(symbolHtml);
|
||||
this.marker.setIcon(markerIcon);
|
||||
this.lastSymbolHtml = symbolHtml;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
initializeMap() {
|
||||
// Check if Leaflet is available
|
||||
if (typeof L === "undefined") {
|
||||
console.error("Leaflet not loaded for InfoMap");
|
||||
|
|
@ -37,34 +82,11 @@ export const InfoMap = {
|
|||
}).addTo(this.map);
|
||||
|
||||
// Create marker icon
|
||||
let markerIcon;
|
||||
if (symbolHtml) {
|
||||
// Use the APRS symbol if provided
|
||||
markerIcon = L.divIcon({
|
||||
html: symbolHtml,
|
||||
className: 'aprs-info-marker',
|
||||
iconSize: [32, 32],
|
||||
iconAnchor: [16, 16]
|
||||
});
|
||||
} else {
|
||||
// Default marker
|
||||
markerIcon = L.divIcon({
|
||||
html: `<div style="
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background-color: #3b82f6;
|
||||
border: 3px solid white;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 5px rgba(0,0,0,0.3);
|
||||
"></div>`,
|
||||
className: '',
|
||||
iconSize: [24, 24],
|
||||
iconAnchor: [12, 12]
|
||||
});
|
||||
}
|
||||
const markerIcon = this.createMarkerIcon(symbolHtml);
|
||||
this.lastSymbolHtml = symbolHtml;
|
||||
|
||||
// Add marker for the station
|
||||
const marker = L.marker([lat, lon], { icon: markerIcon })
|
||||
this.marker = L.marker([lat, lon], { icon: markerIcon })
|
||||
.addTo(this.map)
|
||||
.bindPopup(`<strong>${callsign}</strong><br/>Lat: ${lat.toFixed(6)}<br/>Lon: ${lon.toFixed(6)}`);
|
||||
|
||||
|
|
@ -80,6 +102,33 @@ export const InfoMap = {
|
|||
}
|
||||
},
|
||||
|
||||
createMarkerIcon(symbolHtml) {
|
||||
if (symbolHtml) {
|
||||
// Use the APRS symbol if provided
|
||||
return L.divIcon({
|
||||
html: symbolHtml,
|
||||
className: 'aprs-info-marker',
|
||||
iconSize: [32, 32],
|
||||
iconAnchor: [16, 16]
|
||||
});
|
||||
} else {
|
||||
// Default marker
|
||||
return L.divIcon({
|
||||
html: `<div style="
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background-color: #3b82f6;
|
||||
border: 3px solid white;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 5px rgba(0,0,0,0.3);
|
||||
"></div>`,
|
||||
className: '',
|
||||
iconSize: [24, 24],
|
||||
iconAnchor: [12, 12]
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
destroyed() {
|
||||
if (this.map) {
|
||||
this.map.remove();
|
||||
|
|
|
|||
69
assets/js/hooks/time_ago_hook.js
Normal file
69
assets/js/hooks/time_ago_hook.js
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
// Hook to automatically update time ago displays
|
||||
export default {
|
||||
mounted() {
|
||||
this.startTimer();
|
||||
},
|
||||
|
||||
destroyed() {
|
||||
this.stopTimer();
|
||||
},
|
||||
|
||||
updated() {
|
||||
// Restart timer when the element updates
|
||||
this.stopTimer();
|
||||
this.startTimer();
|
||||
},
|
||||
|
||||
startTimer() {
|
||||
const updateTimeAgo = () => {
|
||||
const timestampStr = this.el.dataset.timestamp;
|
||||
if (!timestampStr) return;
|
||||
|
||||
const timestamp = new Date(timestampStr);
|
||||
const now = new Date();
|
||||
const diffMs = now - timestamp;
|
||||
const diffSeconds = Math.floor(diffMs / 1000);
|
||||
|
||||
if (diffSeconds < 60) {
|
||||
this.el.textContent = `${diffSeconds} second${diffSeconds !== 1 ? 's' : ''} ago`;
|
||||
} else if (diffSeconds < 3600) {
|
||||
const minutes = Math.floor(diffSeconds / 60);
|
||||
this.el.textContent = `${minutes} minute${minutes !== 1 ? 's' : ''} ago`;
|
||||
} else if (diffSeconds < 86400) {
|
||||
const hours = Math.floor(diffSeconds / 3600);
|
||||
this.el.textContent = `${hours} hour${hours !== 1 ? 's' : ''} ago`;
|
||||
} else {
|
||||
const days = Math.floor(diffSeconds / 86400);
|
||||
this.el.textContent = `${days} day${days !== 1 ? 's' : ''} ago`;
|
||||
}
|
||||
};
|
||||
|
||||
// Update immediately
|
||||
updateTimeAgo();
|
||||
|
||||
// Update every second for recent timestamps, less frequently for older ones
|
||||
const timestampStr = this.el.dataset.timestamp;
|
||||
if (timestampStr) {
|
||||
const timestamp = new Date(timestampStr);
|
||||
const age = Date.now() - timestamp;
|
||||
|
||||
let interval;
|
||||
if (age < 60000) { // Less than 1 minute old
|
||||
interval = 1000; // Update every second
|
||||
} else if (age < 3600000) { // Less than 1 hour old
|
||||
interval = 60000; // Update every minute
|
||||
} else {
|
||||
interval = 300000; // Update every 5 minutes
|
||||
}
|
||||
|
||||
this.timer = setInterval(updateTimeAgo, interval);
|
||||
}
|
||||
},
|
||||
|
||||
stopTimer() {
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -34,6 +34,14 @@ defmodule Aprsme.PostgresNotifier do
|
|||
# Broadcast to the general packet topic
|
||||
Phoenix.PubSub.broadcast(Aprsme.PubSub, @packet_topic, {:postgres_packet, packet})
|
||||
|
||||
# Broadcast to callsign-specific topic
|
||||
callsign = Map.get(packet, "sender") || Map.get(packet, :sender)
|
||||
|
||||
if is_binary(callsign) and callsign != "" do
|
||||
normalized_callsign = String.upcase(String.trim(callsign))
|
||||
Phoenix.PubSub.broadcast(Aprsme.PubSub, "packets:#{normalized_callsign}", {:postgres_packet, packet})
|
||||
end
|
||||
|
||||
# Also broadcast to callsign-specific weather topic if it's a weather packet
|
||||
broadcast_weather_packet_if_relevant(packet)
|
||||
|
||||
|
|
|
|||
|
|
@ -20,9 +20,9 @@ defmodule AprsmeWeb.InfoLive.Show do
|
|||
def mount(%{"callsign" => callsign}, _session, socket) do
|
||||
normalized_callsign = Callsign.normalize(callsign)
|
||||
|
||||
# Subscribe to Postgres notifications for live updates
|
||||
# Subscribe to callsign-specific topic for live updates
|
||||
if connected?(socket) do
|
||||
Phoenix.PubSub.subscribe(Aprsme.PubSub, "postgres:aprsme_packets")
|
||||
Phoenix.PubSub.subscribe(Aprsme.PubSub, "packets:#{normalized_callsign}")
|
||||
end
|
||||
|
||||
packet = get_latest_packet(normalized_callsign)
|
||||
|
|
@ -52,31 +52,13 @@ defmodule AprsmeWeb.InfoLive.Show do
|
|||
|
||||
@impl true
|
||||
def handle_info({:postgres_packet, packet}, socket) do
|
||||
require Logger
|
||||
|
||||
# Debug log to see if we're receiving packets
|
||||
packet_sender = Map.get(packet, "sender") || Map.get(packet, :sender, "unknown")
|
||||
Logger.debug("InfoLive received packet from sender: #{packet_sender} for callsign: #{socket.assigns.callsign}")
|
||||
|
||||
SharedPacketHandler.handle_packet_update(packet, socket,
|
||||
filter_fn: SharedPacketHandler.callsign_filter(socket.assigns.callsign),
|
||||
process_fn: &process_packet_update/2,
|
||||
enrich_packet: false
|
||||
)
|
||||
# Since we're subscribed to callsign-specific topic, no need to filter
|
||||
process_packet_update(packet, socket)
|
||||
end
|
||||
|
||||
def handle_info(message, socket) do
|
||||
require Logger
|
||||
|
||||
Logger.debug("InfoLive received unknown message: #{inspect(message)}")
|
||||
{:noreply, socket}
|
||||
end
|
||||
def handle_info(_message, socket), do: {:noreply, socket}
|
||||
|
||||
defp process_packet_update(_incoming_packet, socket) do
|
||||
require Logger
|
||||
|
||||
Logger.debug("InfoLive processing packet update for callsign: #{socket.assigns.callsign}")
|
||||
|
||||
# Refresh data when new packet arrives
|
||||
packet = get_latest_packet(socket.assigns.callsign)
|
||||
packet = if packet, do: SharedPacketHandler.enrich_with_device_info(packet)
|
||||
|
|
@ -103,8 +85,7 @@ defmodule AprsmeWeb.InfoLive.Show do
|
|||
defp get_latest_packet(callsign) do
|
||||
# Get the most recent packet for this callsign, regardless of type
|
||||
# This ensures we show the most recent activity, not just position packets
|
||||
# Use cached version for better performance
|
||||
Aprsme.CachedQueries.get_latest_packet_for_callsign_cached(callsign)
|
||||
Packets.get_latest_packet_for_callsign(callsign)
|
||||
end
|
||||
|
||||
defp get_neighbors(nil, _callsign, _locale), do: []
|
||||
|
|
@ -180,18 +161,21 @@ defmodule AprsmeWeb.InfoLive.Show do
|
|||
if timestamp_dt do
|
||||
%{
|
||||
time_ago: AprsmeWeb.TimeHelpers.time_ago_in_words(timestamp_dt),
|
||||
formatted: Calendar.strftime(timestamp_dt, "%Y-%m-%d %H:%M:%S UTC")
|
||||
formatted: Calendar.strftime(timestamp_dt, "%Y-%m-%d %H:%M:%S UTC"),
|
||||
timestamp: DateTime.to_iso8601(timestamp_dt)
|
||||
}
|
||||
else
|
||||
%{
|
||||
time_ago: gettext("Unknown"),
|
||||
formatted: ""
|
||||
formatted: "",
|
||||
timestamp: nil
|
||||
}
|
||||
end
|
||||
else
|
||||
%{
|
||||
time_ago: gettext("Unknown"),
|
||||
formatted: ""
|
||||
formatted: "",
|
||||
timestamp: nil
|
||||
}
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -102,7 +102,12 @@
|
|||
<dt class="text-xs font-medium opacity-70">{gettext("Last Position")}</dt>
|
||||
<dd class="mt-1 text-sm">
|
||||
<%= if @packet.received_at do %>
|
||||
<div class="font-semibold">
|
||||
<div
|
||||
class="font-semibold"
|
||||
phx-hook="TimeAgoHook"
|
||||
id="last-position-time"
|
||||
data-timestamp={DateTime.to_iso8601(@packet.received_at)}
|
||||
>
|
||||
{AprsmeWeb.TimeHelpers.time_ago_in_words(@packet.received_at)}
|
||||
</div>
|
||||
<div class="text-xs opacity-70 font-mono">
|
||||
|
|
@ -381,8 +386,13 @@
|
|||
<% end %>
|
||||
</td>
|
||||
<td class="opacity-70">
|
||||
<%= if ssid_info.last_heard do %>
|
||||
<span class="text-sm">
|
||||
<%= if ssid_info.last_heard && ssid_info.last_heard.timestamp do %>
|
||||
<span
|
||||
class="text-sm"
|
||||
phx-hook="TimeAgoHook"
|
||||
id={"ssid-time-#{ssid_info.ssid}"}
|
||||
data-timestamp={ssid_info.last_heard.timestamp}
|
||||
>
|
||||
{ssid_info.last_heard.time_ago}
|
||||
</span>
|
||||
<% else %>
|
||||
|
|
@ -466,7 +476,12 @@
|
|||
</td>
|
||||
<td class="opacity-70">
|
||||
<%= if neighbor.last_heard do %>
|
||||
<div class="font-semibold">
|
||||
<div
|
||||
class="font-semibold"
|
||||
phx-hook="TimeAgoHook"
|
||||
id={"neighbor-time-#{neighbor.callsign}"}
|
||||
data-timestamp={neighbor.last_heard.timestamp}
|
||||
>
|
||||
{neighbor.last_heard.time_ago}
|
||||
</div>
|
||||
<div class="text-xs opacity-70 font-mono">
|
||||
|
|
|
|||
2
vendor/aprs
vendored
2
vendor/aprs
vendored
|
|
@ -1 +1 @@
|
|||
Subproject commit 7e5e707605cc428d1b45cbf00f0f38278f50f2cd
|
||||
Subproject commit d7d7a86ad31ce5bafa4422fe918745b24eafd639
|
||||
Loading…
Add table
Reference in a new issue