This commit is contained in:
Graham McIntire 2025-06-19 16:49:48 -05:00
parent 66891a89b5
commit 08965143fb
No known key found for this signature in database
9 changed files with 449 additions and 80 deletions

View file

@ -61,7 +61,7 @@ body.home-page main {
height: 100vh;
}
body.home-page main>div {
body.home-page main > div {
max-width: none;
height: 100%;
}
@ -151,20 +151,62 @@ body.home-page main>div {
}
/* High-DPI support for APRS symbols */
@media (-webkit-min-device-pixel-ratio: 2),
(min-resolution: 192dpi) {
@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi) {
.aprs-marker div[style*="aprs-symbols-24-0.png"] {
background-image: url('/aprs-symbols/aprs-symbols-24-0@2x.png') !important;
background-image: url("/aprs-symbols/aprs-symbols-24-0@2x.png") !important;
background-size: 384px 144px !important;
}
.aprs-marker div[style*="aprs-symbols-24-1.png"] {
background-image: url('/aprs-symbols/aprs-symbols-24-1@2x.png') !important;
background-image: url("/aprs-symbols/aprs-symbols-24-1@2x.png") !important;
background-size: 384px 144px !important;
}
.aprs-marker div[style*="aprs-symbols-24-2.png"] {
background-image: url('/aprs-symbols/aprs-symbols-24-2@2x.png') !important;
background-image: url("/aprs-symbols/aprs-symbols-24-2@2x.png") !important;
background-size: 384px 144px !important;
}
}
}
/* Trail visualization styles */
.trail-tooltip {
background-color: rgba(0, 0, 0, 0.8);
border: 1px solid #1e90ff;
color: white;
font-size: 11px;
padding: 2px 6px;
border-radius: 3px;
white-space: nowrap;
}
/* Trail polyline hover effect */
.leaflet-interactive:hover {
stroke-width: 4;
stroke-opacity: 0.8;
}
/* Position dot hover effect */
.leaflet-marker-icon.leaflet-interactive:hover {
transform: scale(1.5);
transition: transform 0.2s ease-out;
}
/* Trail controls styling */
.trail-toggle-control {
background-color: white;
border: 2px solid rgba(0, 0, 0, 0.2);
border-radius: 4px;
padding: 5px 10px;
font-size: 14px;
cursor: pointer;
transition: background-color 0.2s ease;
}
.trail-toggle-control:hover {
background-color: #f0f0f0;
}
.trail-toggle-control.active {
background-color: #1e90ff;
color: white;
}

View file

@ -0,0 +1,139 @@
// Trail management module for APRS position history visualization
export interface PositionHistory {
lat: number;
lng: number;
timestamp: number;
}
export interface TrailState {
positions: PositionHistory[];
trail?: any; // L.Polyline
dots?: any[]; // L.CircleMarker[]
}
export class TrailManager {
private trailLayer: any; // L.LayerGroup
private trails: Map<string, TrailState>;
private showTrails: boolean;
private trailDuration: number; // in milliseconds
constructor(trailLayer: any, trailDuration: number = 60 * 60 * 1000) {
this.trailLayer = trailLayer;
this.trails = new Map();
this.showTrails = true;
this.trailDuration = trailDuration;
}
setShowTrails(show: boolean) {
this.showTrails = show;
if (!show) {
this.clearAllTrails();
}
}
addPosition(markerId: string, lat: number, lng: number, timestamp: number) {
if (!this.showTrails) return;
let trailState = this.trails.get(markerId);
if (!trailState) {
trailState = { positions: [] };
this.trails.set(markerId, trailState);
}
// Only add if position is different from the last one
const lastPos = trailState.positions[trailState.positions.length - 1];
if (!lastPos || Math.abs(lastPos.lat - lat) > 0.0001 || Math.abs(lastPos.lng - lng) > 0.0001) {
trailState.positions.push({ lat, lng, timestamp });
}
// Filter positions to keep only recent ones
const cutoffTime = Date.now() - this.trailDuration;
trailState.positions = trailState.positions.filter((pos) => pos.timestamp >= cutoffTime);
this.updateTrailVisualization(markerId, trailState);
}
private updateTrailVisualization(markerId: string, trailState: TrailState) {
// Remove old trail and dots
if (trailState.trail) {
this.trailLayer.removeLayer(trailState.trail);
}
if (trailState.dots) {
trailState.dots.forEach((dot) => this.trailLayer.removeLayer(dot));
trailState.dots = [];
}
// Create new trail if we have at least 2 positions
if (trailState.positions.length >= 2) {
const L = (window as any).L;
const latLngs = trailState.positions.map((pos) => [pos.lat, pos.lng]);
// Create polyline for the trail
trailState.trail = L.polyline(latLngs, {
color: "#1E90FF",
weight: 3,
opacity: 0.5,
smoothFactor: 1,
}).addTo(this.trailLayer);
// Create dots for each position
trailState.dots = trailState.positions.map((pos, index) => {
const age = (Date.now() - pos.timestamp) / this.trailDuration;
const opacity = Math.max(0.4, 1 - age * 0.6);
const isCurrentPosition = index === trailState.positions.length - 1;
const radius = isCurrentPosition ? 5 : 3;
const dot = L.circleMarker([pos.lat, pos.lng], {
radius: radius,
fillColor: "#FF4500",
color: "#8B0000",
weight: 1,
opacity: opacity,
fillOpacity: opacity * 0.8,
});
// Add tooltip with timestamp
const time = new Date(pos.timestamp);
dot.bindTooltip(time.toLocaleTimeString(), {
permanent: false,
direction: "top",
className: "trail-tooltip",
});
return dot.addTo(this.trailLayer);
});
}
}
removeTrail(markerId: string) {
const trailState = this.trails.get(markerId);
if (trailState) {
if (trailState.trail) {
this.trailLayer.removeLayer(trailState.trail);
}
if (trailState.dots) {
trailState.dots.forEach((dot) => this.trailLayer.removeLayer(dot));
}
this.trails.delete(markerId);
}
}
clearAllTrails() {
this.trails.forEach((_, markerId) => {
this.removeTrail(markerId);
});
}
cleanupOldPositions() {
const cutoffTime = Date.now() - this.trailDuration;
this.trails.forEach((trailState, markerId) => {
trailState.positions = trailState.positions.filter((pos) => pos.timestamp >= cutoffTime);
if (trailState.positions.length === 0) {
this.removeTrail(markerId);
} else {
this.updateTrailVisualization(markerId, trailState);
}
});
}
}

View file

@ -1,15 +1,12 @@
// Declare Leaflet as a global variable
declare const L: any;
// Import trail management functionality
import { TrailManager } from "./features/trail_manager";
// APRS Map Hook - handles only basic map interaction
// All data logic handled by LiveView
declare global {
interface Window {
__aprs_map_mounted?: boolean;
}
}
type LiveViewHookContext = {
el: HTMLElement & { _leaflet_id?: any };
pushEvent: (event: string, payload: any) => void;
@ -18,6 +15,7 @@ type LiveViewHookContext = {
markers?: Map<string, any>;
markerStates?: Map<string, MarkerState>;
markerLayer?: any;
trailManager?: TrailManager;
boundsTimer?: ReturnType<typeof setTimeout>;
resizeHandler?: () => void;
errors?: string[];
@ -40,6 +38,7 @@ interface MarkerData {
popup?: string;
historical?: boolean;
color?: string;
timestamp?: number;
}
interface BoundsData {
@ -76,11 +75,6 @@ interface MapEventData {
let MapAPRSMap = {
mounted() {
if (window.__aprs_map_mounted) {
console.warn("APRS Map already mounted, skipping duplicate mount.");
return;
}
window.__aprs_map_mounted = true;
const self = this as unknown as LiveViewHookContext;
// Initialize error tracking
self.errors = [];
@ -108,37 +102,47 @@ let MapAPRSMap = {
}
}
// Get initial center and zoom from server-provided data attributes
// Try to restore from localStorage
let initialCenter: CenterData, initialZoom: number;
try {
const centerData = self.el.dataset.center;
const zoomData = self.el.dataset.zoom;
if (!centerData || !zoomData) {
throw new Error("Missing map data attributes");
const saved = localStorage.getItem("aprs_map_state");
if (saved) {
const { lat, lng, zoom } = JSON.parse(saved);
if (
typeof lat === "number" &&
typeof lng === "number" &&
typeof zoom === "number" &&
lat >= -90 && lat <= 90 &&
lng >= -180 && lng <= 180 &&
zoom >= 1 && zoom <= 20
) {
initialCenter = { lat, lng };
initialZoom = zoom;
} else {
throw new Error("Invalid saved map state");
}
} else {
throw new Error("No saved map state");
}
initialCenter = JSON.parse(centerData);
initialZoom = parseInt(zoomData);
// Validate parsed data
if (
!initialCenter ||
typeof initialCenter.lat !== "number" ||
typeof initialCenter.lng !== "number"
) {
throw new Error("Invalid center data");
} catch (e) {
// Fallback to server-provided data attributes
try {
const centerData = self.el.dataset.center;
const zoomData = self.el.dataset.zoom;
if (!centerData || !zoomData) throw new Error("Missing map data attributes");
initialCenter = JSON.parse(centerData);
initialZoom = parseInt(zoomData);
if (
!initialCenter ||
typeof initialCenter.lat !== "number" ||
typeof initialCenter.lng !== "number"
) throw new Error("Invalid center data");
if (isNaN(initialZoom) || initialZoom < 1 || initialZoom > 20) throw new Error("Invalid zoom data");
} catch (error) {
console.error("Error parsing map data attributes:", error);
initialCenter = { lat: 39.8283, lng: -98.5795 };
initialZoom = 5;
}
if (isNaN(initialZoom) || initialZoom < 1 || initialZoom > 20) {
throw new Error("Invalid zoom data");
}
} catch (error) {
console.error("Error parsing map data attributes:", error);
// Fallback values
initialCenter = { lat: 39.8283, lng: -98.5795 };
initialZoom = 5;
}
self.initializeMap(initialCenter, initialZoom);
@ -234,6 +238,10 @@ let MapAPRSMap = {
self.markers = new Map<string, any>();
self.markerLayer = L.layerGroup().addTo(self.map);
// Create trail layer and manager
const trailLayer = L.layerGroup().addTo(self.map);
self.trailManager = new TrailManager(trailLayer);
// Track marker states to prevent unnecessary operations
self.markerStates = new Map<string, MarkerState>();
@ -250,6 +258,16 @@ let MapAPRSMap = {
self.lastZoom = self.map!.getZoom();
self.pushEvent("map_ready", {});
self.sendBoundsToServer();
// Start periodic cleanup of old trail positions (every 5 minutes)
setInterval(
() => {
if (self.trailManager) {
self.trailManager.cleanupOldPositions();
}
},
5 * 60 * 1000,
);
} catch (error) {
console.error("Error in map ready callback:", error);
}
@ -260,6 +278,13 @@ let MapAPRSMap = {
if (self.boundsTimer) clearTimeout(self.boundsTimer);
self.boundsTimer = setTimeout(() => {
self.sendBoundsToServer();
// Save map state
const center = self.map.getCenter();
const zoom = self.map.getZoom();
localStorage.setItem(
"aprs_map_state",
JSON.stringify({ lat: center.lat, lng: center.lng, zoom })
);
}, 300);
});
@ -277,6 +302,13 @@ let MapAPRSMap = {
self.sendBoundsToServer();
self.lastZoom = currentZoom;
// Save map state
const center = self.map.getCenter();
const zoom = self.map.getZoom();
localStorage.setItem(
"aprs_map_state",
JSON.stringify({ lat: center.lat, lng: center.lng, zoom })
);
}, 300);
});
@ -449,6 +481,13 @@ let MapAPRSMap = {
}
});
// Toggle trails visibility
self.handleEvent("toggle_trails", (data: { show: boolean }) => {
if (self.trailManager) {
self.trailManager.setShowTrails(data.show);
}
});
// Handle new packets from LiveView
self.handleEvent("new_packet", (data: MarkerData) => {
self.addMarker({
@ -591,6 +630,11 @@ let MapAPRSMap = {
existingState.symbol_code !== data.symbol_code ||
existingState.popup !== data.popup;
if (positionChanged && self.trailManager) {
// Position changed, update trail
self.trailManager.addPosition(data.id, lat, lng, data.timestamp || Date.now());
}
if (!positionChanged && !dataChanged) {
// No changes needed, skip update
// But if openPopup is requested, open it
@ -644,6 +688,11 @@ let MapAPRSMap = {
historical: data.historical,
});
// Initialize trail for new marker
if (self.trailManager) {
self.trailManager.addPosition(data.id, lat, lng, data.timestamp || Date.now());
}
// Open popup if requested
if (data.openPopup && marker.openPopup) {
marker.openPopup();
@ -658,6 +707,11 @@ let MapAPRSMap = {
self.markers!.delete(id);
self.markerStates!.delete(id);
}
// Remove trail
if (self.trailManager) {
self.trailManager.removeTrail(id);
}
},
updateMarker(data: MarkerData) {
@ -671,7 +725,16 @@ let MapAPRSMap = {
const lat = parseFloat(data.lat.toString());
const lng = parseFloat(data.lng.toString());
if (!isNaN(lat) && !isNaN(lng)) {
existingMarker.setLatLng([lat, lng]);
const currentPos = existingMarker.getLatLng();
const positionChanged =
Math.abs(currentPos.lat - lat) > 0.0001 || Math.abs(currentPos.lng - lng) > 0.0001;
if (positionChanged) {
existingMarker.setLatLng([lat, lng]);
if (self.trailManager) {
self.trailManager.addPosition(data.id, lat, lng, data.timestamp || Date.now());
}
}
}
}
@ -690,6 +753,11 @@ let MapAPRSMap = {
self.markerLayer!.clearLayers();
self.markers!.clear();
self.markerStates!.clear();
// Clear all trails
if (self.trailManager) {
self.trailManager.clearAllTrails();
}
},
removeMarkersOutsideBounds(bounds: L.LatLngBounds) {
@ -764,16 +832,6 @@ let MapAPRSMap = {
expected: `Row ${row}, Col ${column} should show symbol '${symbolCode}'`,
});
// Test if sprite image is accessible
const testImg = new Image();
testImg.onerror = () => {
console.error(`Failed to load sprite: ${spriteFile}`);
};
testImg.onload = () => {
console.log(`Sprite loaded successfully: ${spriteFile}`);
};
testImg.src = spriteFile;
// Try adjusting the position to see if we can find the correct icon
// The car symbol ">" should be at position 30 (row 1, col 14)
// But the sprite might have a different arrangement

View file

@ -57,7 +57,10 @@ defmodule AprsWeb.MapLive.CallsignView do
# Last known position for auto-zoom
last_known_position: nil,
# Flag to track if packets have been loaded
packets_loaded: false
packets_loaded: false,
# Latest symbol table ID and code
latest_symbol_table_id: "/",
latest_symbol_code: ">"
)
if connected?(socket) do
@ -451,6 +454,12 @@ defmodule AprsWeb.MapLive.CallsignView do
margin-bottom: 4px;
}
.aprs-symbol-info {
font-size: 11px;
color: #666;
margin-bottom: 2px;
}
.nav-links {
display: flex;
gap: 12px;
@ -559,12 +568,6 @@ defmodule AprsWeb.MapLive.CallsignView do
color: #333;
}
.aprs-symbol-info {
font-size: 11px;
color: #666;
margin-bottom: 2px;
}
.aprs-comment {
font-size: 11px;
color: #444;
@ -618,6 +621,10 @@ defmodule AprsWeb.MapLive.CallsignView do
<div style="position: relative; width: 100vw; height: 100vh;">
<div class="callsign-header">
<div class="callsign-title">📡 {@callsign}</div>
<div class="aprs-symbol-info">
<span>Symbol Table: <code>{@latest_symbol_table_id}</code></span>
<span style="margin-left:8px;">Symbol Code: <code>{@latest_symbol_code}</code></span>
</div>
<div class="nav-links">
<a href="/" class="nav-link"> Back to Map</a>
<a href="/packets" class="nav-link">All Packets</a>
@ -784,11 +791,47 @@ defmodule AprsWeb.MapLive.CallsignView do
end
end
defp get_symbol_table_id(%{symbol_table_id: id}), do: id
defp get_symbol_table_id(_), do: "/"
defp get_symbol_table_id(data) do
cond do
is_map(data) && Map.has_key?(data, :symbol_table_id) && data.symbol_table_id ->
data.symbol_table_id
defp get_symbol_code(%{symbol_code: code}), do: code
defp get_symbol_code(_), do: ">"
is_map(data) && Map.has_key?(data, "symbol_table_id") && data["symbol_table_id"] ->
data["symbol_table_id"]
is_map(data) && Map.has_key?(data, :packet) && Map.has_key?(data.packet, :symbol_table_id) &&
data.packet.symbol_table_id ->
data.packet.symbol_table_id
is_map(data) && Map.has_key?(data, "packet") &&
Map.has_key?(data["packet"], "symbol_table_id") && data["packet"]["symbol_table_id"] ->
data["packet"]["symbol_table_id"]
true ->
"/"
end
end
defp get_symbol_code(data) do
cond do
is_map(data) && Map.has_key?(data, :symbol_code) && data.symbol_code ->
data.symbol_code
is_map(data) && Map.has_key?(data, "symbol_code") && data["symbol_code"] ->
data["symbol_code"]
is_map(data) && Map.has_key?(data, :packet) && Map.has_key?(data.packet, :symbol_code) &&
data.packet.symbol_code ->
data.packet.symbol_code
is_map(data) && Map.has_key?(data, "packet") && Map.has_key?(data["packet"], "symbol_code") &&
data["packet"]["symbol_code"] ->
data["packet"]["symbol_code"]
true ->
">"
end
end
defp get_comment(%{comment: comment}) when is_binary(comment), do: comment
defp get_comment(_), do: ""
@ -865,23 +908,33 @@ defmodule AprsWeb.MapLive.CallsignView do
# Load recent packets (last hour) for this specific callsign
recent_packets = Packets.get_recent_packets(%{callsign: callsign})
# Find the most recent packet with position data for auto-zoom
last_known_position =
# Find the most recent packet with position data for auto-zoom and symbol info
latest_packet =
recent_packets
|> Enum.filter(&has_position_data?/1)
|> Enum.sort_by(& &1.received_at, {:desc, DateTime})
|> List.first()
|> case do
last_known_position =
case latest_packet do
nil ->
nil
packet ->
{lat, lng} = get_coordinates(packet)
if lat && lng, do: %{lat: lat, lng: lng}
end
if lat && lng do
position = %{lat: lat, lng: lng}
position
end
latest_symbol_table_id =
case latest_packet do
%{data_extended: %{symbol_table_id: id}} when is_binary(id) -> id
_ -> "/"
end
latest_symbol_code =
case latest_packet do
%{data_extended: %{symbol_code: code}} when is_binary(code) -> code
_ -> ">"
end
# Convert packets to client-friendly format and send to map
@ -909,7 +962,12 @@ defmodule AprsWeb.MapLive.CallsignView do
socket
end
assign(socket, last_known_position: last_known_position, visible_packets: visible_packets)
assign(socket,
last_known_position: last_known_position,
visible_packets: visible_packets,
latest_symbol_table_id: latest_symbol_table_id,
latest_symbol_code: latest_symbol_code
)
end
defp map_empty?(socket) do

View file

@ -45,7 +45,10 @@ defmodule AprsWeb.MapLive.Enhanced do
replay_paused: false,
replay_timer: nil,
replay_packets: [],
replay_index: 0
replay_index: 0,
# Trail visualization
show_trails: true
)
{:ok, socket}
@ -141,6 +144,13 @@ defmodule AprsWeb.MapLive.Enhanced do
{:noreply, assign(socket, :replay_speed, speed_ms)}
end
def handle_event("toggle_trails", _params, socket) do
show_trails = !Map.get(socket.assigns, :show_trails, true)
socket = assign(socket, :show_trails, show_trails)
socket = push_event(socket, "toggle_trails", %{show: show_trails})
{:noreply, socket}
end
def handle_info(:cleanup_old_markers, socket) do
socket = cleanup_old_markers(socket)
@ -268,6 +278,8 @@ defmodule AprsWeb.MapLive.Enhanced do
phx-update="ignore"
data-center={Jason.encode!(@map_center)}
data-zoom={@map_zoom}
data-lat={@map_center.lat}
data-lng={@map_center.lng}
>
</div>
@ -298,6 +310,28 @@ defmodule AprsWeb.MapLive.Enhanced do
<% end %>
</button>
<button class="control-button" phx-click="toggle_trails" title="Toggle position trails">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M3 12h18m-18 0l3-3m-3 3l3 3" />
<circle cx="6" cy="12" r="1" fill="currentColor" />
<circle cx="12" cy="12" r="1" fill="currentColor" />
<circle cx="18" cy="12" r="1" fill="currentColor" />
</svg>
<%= if assigns[:show_trails] != false do %>
Hide Trails
<% else %>
Show Trails
<% end %>
</button>
<%= if @replay_active do %>
<button class="control-button" phx-click="pause_replay" disabled={@replay_paused}>
<%= if @replay_paused do %>
@ -416,7 +450,8 @@ defmodule AprsWeb.MapLive.Enhanced do
symbol_table: data_extended["symbol_table_id"] || "/",
symbol_code: data_extended["symbol_code"] || ">",
historical: false,
popup: build_popup_content(packet, callsign, false)
popup: build_popup_content(packet, callsign, false),
timestamp: DateTime.to_unix(packet.created_at, :millisecond)
}
end

View file

@ -668,8 +668,6 @@ defmodule AprsWeb.MapLive.Index do
phx-update="ignore"
data-center={Jason.encode!(@map_center)}
data-zoom={@map_zoom}
data-lat={@map_center.lat}
data-lng={@map_center.lng}
>
</div>

View file

@ -27,13 +27,18 @@ defmodule AprsWeb.PacketsLive.CallsignView do
# Get stored packets for this callsign (up to 100)
stored_packets = get_stored_packets(normalized_callsign, 100)
all_packets = stored_packets
latest_packet = List.first(all_packets)
{symbol_table_id, symbol_code} = extract_symbol_info(latest_packet)
socket =
socket
|> assign(:callsign, normalized_callsign)
|> assign(:packets, stored_packets)
|> assign(:live_packets, [])
|> assign(:all_packets, stored_packets)
|> assign(:all_packets, all_packets)
|> assign(:latest_symbol_table_id, symbol_table_id)
|> assign(:latest_symbol_code, symbol_code)
|> assign(:error, nil)
{:ok, socket}
@ -44,6 +49,8 @@ defmodule AprsWeb.PacketsLive.CallsignView do
|> assign(:packets, [])
|> assign(:live_packets, [])
|> assign(:all_packets, [])
|> assign(:latest_symbol_table_id, "/")
|> assign(:latest_symbol_code, ">")
|> assign(:error, "Invalid callsign format")
{:ok, socket}
@ -62,12 +69,16 @@ defmodule AprsWeb.PacketsLive.CallsignView do
update_packet_lists(current_stored, current_live, sanitized_payload)
all_packets = get_all_packets_list(updated_stored, updated_live)
latest_packet = List.first(all_packets)
{symbol_table_id, symbol_code} = extract_symbol_info(latest_packet)
socket =
socket
|> assign(:packets, updated_stored)
|> assign(:live_packets, updated_live)
|> assign(:all_packets, all_packets)
|> assign(:latest_symbol_table_id, symbol_table_id)
|> assign(:latest_symbol_code, symbol_code)
{:noreply, socket}
else
@ -190,4 +201,14 @@ defmodule AprsWeb.PacketsLive.CallsignView do
cs -> Regex.match?(~r/^[A-Z0-9]+(-[A-Z0-9]{1,2})?$/i, cs)
end
end
# Helper to extract symbol table and code from a packet
defp extract_symbol_info(nil), do: {"/", ">"}
defp extract_symbol_info(packet) do
data = Map.get(packet, :data_extended) || %{}
table = Map.get(data, :symbol_table_id) || Map.get(data, "symbol_table_id") || "/"
code = Map.get(data, :symbol_code) || Map.get(data, "symbol_code") || ">"
{table, code}
end
end

View file

@ -71,6 +71,15 @@
{packet.data_type}
</span>
</:col>
<:col :let={packet} label="Symbol">
<span class="text-xs text-gray-700 font-mono">
<% data = Map.get(packet, :data_extended) || %{}
raw_table = Map.get(data, :symbol_table_id) || Map.get(data, "symbol_table_id") || "/"
table = if raw_table in ["/", "\\", "]"], do: raw_table, else: "/"
code = Map.get(data, :symbol_code) || Map.get(data, "symbol_code") || ">" %>
{table}{code}
</span>
</:col>
<:col :let={packet} label="Destination">
<span class="text-sm text-gray-900">{packet.destination}</span>
</:col>

View file

@ -44,6 +44,15 @@
{packet.data_type}
</span>
</:col>
<:col :let={packet} label="Symbol">
<span class="text-xs text-gray-700 font-mono">
<% data = Map.get(packet, :data_extended) || %{}
raw_table = Map.get(data, :symbol_table_id) || Map.get(data, "symbol_table_id") || "/"
table = if raw_table in ["/", "\\", "]"], do: raw_table, else: "/"
code = Map.get(data, :symbol_code) || Map.get(data, "symbol_code") || ">" %>
{table}{code}
</span>
</:col>
<:col :let={packet} label="Destination">
<span class="text-sm text-gray-900">{packet.destination}</span>
</:col>