try vector tiles

This commit is contained in:
Graham McIntire 2025-07-24 10:54:31 -05:00
parent 688fc6ba95
commit ae8c74afbe
No known key found for this signature in database
8 changed files with 577 additions and 494 deletions

View file

@ -5,13 +5,30 @@ import type {
BoundsData, BoundsData,
CenterData, CenterData,
MarkerState, MarkerState,
MapEventData MapEventData,
} from './types/map'; } from "./types/map";
import type { Map as LeafletMap, Marker, TileLayer, LayerGroup, DivIcon, LatLngBounds, Polyline } from 'leaflet'; import type {
import type { LeafletTouchEvent, LeafletPopupEvent } from './types/leaflet-events'; Map as LeafletMap,
import type { HeatLayer, MarkerClusterGroup, OverlappingMarkerSpiderfier, MarkerClusterGroupOptions, HeatLayerOptions, MarkerCluster, HeatLatLng } from './types/leaflet-plugins'; Marker,
import type { APRSMarker } from './types/marker-extensions'; TileLayer,
import type { BaseEventPayload } from './types/events'; LayerGroup,
DivIcon,
LatLngBounds,
Polyline,
} from "leaflet";
import type { LeafletTouchEvent, LeafletPopupEvent } from "./types/leaflet-events";
import type {
HeatLayer,
MarkerClusterGroup,
OverlappingMarkerSpiderfier,
MarkerClusterGroupOptions,
HeatLayerOptions,
MarkerCluster,
HeatLatLng,
} from "./types/leaflet-plugins";
import type { APRSMarker } from "./types/marker-extensions";
import type { BaseEventPayload } from "./types/events";
// Vector grid types are included via the vendor bundle
// Leaflet and plugins are loaded globally from vendor bundle // Leaflet and plugins are loaded globally from vendor bundle
const L = window.L; const L = window.L;
@ -26,7 +43,7 @@ declare global {
} }
// Add plugin types to Leaflet // Add plugin types to Leaflet
declare module 'leaflet' { declare module "leaflet" {
export function heatLayer(latlngs: HeatLatLng[], options?: HeatLayerOptions): HeatLayer; export function heatLayer(latlngs: HeatLatLng[], options?: HeatLayerOptions): HeatLayer;
export function markerClusterGroup(options?: MarkerClusterGroupOptions): MarkerClusterGroup; export function markerClusterGroup(options?: MarkerClusterGroupOptions): MarkerClusterGroup;
} }
@ -34,12 +51,17 @@ declare module 'leaflet' {
// Import trail management functionality // Import trail management functionality
import { TrailManager } from "./features/trail_manager"; import { TrailManager } from "./features/trail_manager";
// Import helper functions // Import helper functions
import { parseTimestamp, getTrailId, saveMapState, safePushEvent, getLiveSocket } from "./map_helpers"; import {
parseTimestamp,
getTrailId,
saveMapState,
safePushEvent,
getLiveSocket,
} from "./map_helpers";
// APRS Map Hook - handles only basic map interaction // APRS Map Hook - handles only basic map interaction
// All data logic handled by LiveView // All data logic handled by LiveView
let MapAPRSMap = { let MapAPRSMap = {
mounted() { mounted() {
const self = this as unknown as LiveViewHookContext; const self = this as unknown as LiveViewHookContext;
@ -81,7 +103,6 @@ let MapAPRSMap = {
initialCenter = JSON.parse(centerData); initialCenter = JSON.parse(centerData);
initialZoom = parseInt(zoomData); initialZoom = parseInt(zoomData);
if ( if (
!initialCenter || !initialCenter ||
typeof initialCenter.lat !== "number" || typeof initialCenter.lat !== "number" ||
@ -93,7 +114,7 @@ let MapAPRSMap = {
// Check if URL has explicit parameters (not default values) // Check if URL has explicit parameters (not default values)
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
useUrlParams = urlParams.has('lat') || urlParams.has('lng') || urlParams.has('z'); useUrlParams = urlParams.has("lat") || urlParams.has("lng") || urlParams.has("z");
} catch (error) { } catch (error) {
console.error("Error parsing map data attributes:", error); console.error("Error parsing map data attributes:", error);
initialCenter = { lat: 39.8283, lng: -98.5795 }; initialCenter = { lat: 39.8283, lng: -98.5795 };
@ -186,7 +207,9 @@ let MapAPRSMap = {
} }
// Detect if mobile device // Detect if mobile device
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent); const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
navigator.userAgent,
);
self.map = L.map(self.el, { self.map = L.map(self.el, {
zoomControl: !isMobile, // Hide default zoom control on mobile, we'll add a better one zoomControl: !isMobile, // Hide default zoom control on mobile, we'll add a better one
@ -200,14 +223,16 @@ let MapAPRSMap = {
preferCanvas: isMobile, // Use canvas renderer on mobile for better performance preferCanvas: isMobile, // Use canvas renderer on mobile for better performance
zoomAnimation: !isMobile, // Disable zoom animations on mobile for performance zoomAnimation: !isMobile, // Disable zoom animations on mobile for performance
fadeAnimation: !isMobile, // Disable fade animations on mobile fadeAnimation: !isMobile, // Disable fade animations on mobile
markerZoomAnimation: !isMobile // Disable marker animations on mobile markerZoomAnimation: !isMobile, // Disable marker animations on mobile
}).setView([initialCenter.lat, initialCenter.lng], initialZoom); }).setView([initialCenter.lat, initialCenter.lng], initialZoom);
// Add mobile-friendly zoom control if on mobile // Add mobile-friendly zoom control if on mobile
if (isMobile) { if (isMobile) {
L.control.zoom({ L.control
position: 'bottomright' .zoom({
}).addTo(self.map); position: "bottomright",
})
.addTo(self.map);
// Add touch gesture handling // Add touch gesture handling
self.setupMobileGestures(); self.setupMobileGestures();
@ -233,25 +258,144 @@ let MapAPRSMap = {
const tileProviders = { const tileProviders = {
osm: { osm: {
url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors | APRS.me', attribution:
subdomains: ['a', 'b', 'c'] '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors | APRS.me',
subdomains: ["a", "b", "c"],
}, },
osmDE: { osmDE: {
url: "https://{s}.tile.openstreetmap.de/{z}/{x}/{y}.png", url: "https://{s}.tile.openstreetmap.de/{z}/{x}/{y}.png",
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors | APRS.me', attribution:
subdomains: ['a', 'b', 'c'] '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors | APRS.me',
subdomains: ["a", "b", "c"],
}, },
carto: { carto: {
url: "https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png", url: "https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png",
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <a href="https://carto.com/attributions">CARTO</a> | APRS.me', attribution:
subdomains: ['a', 'b', 'c', 'd'] '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <a href="https://carto.com/attributions">CARTO</a> | APRS.me',
subdomains: ["a", "b", "c", "d"],
},
osmVector: {
// Using official OpenStreetMap vector tiles
url: "https://vector.openstreetmap.org/shortbread_v1/{z}/{x}/{y}.mvt",
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors | APRS.me',
isVector: true,
maxZoom: 14, // OSM vector tiles only go to zoom 14
},
};
// Try to use vector tiles if L.vectorGrid is available (loaded from CDN)
let tileLayer: L.Layer;
// Check if L.vectorGrid is available
console.log('Checking for L.vectorGrid:', typeof (L as any).vectorGrid);
console.log('Checking for L.vectorGrid.protobuf:', typeof (L as any).vectorGrid?.protobuf);
if ((L as any).vectorGrid && (L as any).vectorGrid.protobuf) {
try {
console.log('L.vectorGrid.protobuf is available, attempting to use OpenStreetMap vector tiles');
const vectorProvider = tileProviders.osmVector;
// Style configuration for OpenStreetMap Shortbread schema
const vectorTileStyles = {
// Water features
water_polygons: {
fill: true,
fillColor: '#a0c8f0',
fillOpacity: 1,
weight: 0
},
water_lines: {
weight: 2,
color: '#a0c8f0',
opacity: 1
},
water_areas: {
fill: true,
fillColor: '#a0c8f0',
fillOpacity: 1,
weight: 0
},
// Landuse and parks
landuse_areas: {
fill: true,
fillColor: '#f0f0f0',
fillOpacity: 0.6,
weight: 0
},
parks: {
fill: true,
fillColor: '#c8facc',
fillOpacity: 0.8,
weight: 0
},
// Buildings
buildings: {
fill: true,
fillColor: '#e0e0e0',
fillOpacity: 0.8,
weight: 1,
color: '#cccccc'
},
// Transportation (roads)
transport_lines: function(properties: any) {
const kind = properties.kind || properties.highway;
if (kind === 'motorway' || kind === 'trunk') {
return {
weight: 4,
color: '#e9ac77',
opacity: 1
};
} else if (kind === 'primary' || kind === 'secondary') {
return {
weight: 3,
color: '#ffd380',
opacity: 1
};
} else {
return {
weight: 2,
color: '#ffffff',
opacity: 0.8
};
}
},
// Places
places: function(properties: any, zoom: number) {
if (zoom < 10) return { weight: 0 }; // Hide at low zoom
return {
weight: 0,
fillOpacity: 0,
interactive: false
};
},
// Default style for unmatched layers
_default: {
weight: 1,
color: '#cccccc',
opacity: 0.5
} }
}; };
// Use OSM by default, but could be configured // Create vector grid layer using L.vectorGrid.protobuf
const provider = tileProviders.osm; tileLayer = (L as any).vectorGrid.protobuf(vectorProvider.url, {
attribution: vectorProvider.attribution,
maxZoom: vectorProvider.maxZoom,
vectorTileLayerStyles: vectorTileStyles,
interactive: true,
getFeatureId: function(feature: any) {
return feature.properties.osm_id || feature.properties.id;
}
});
const tileLayer = L.tileLayer(provider.url, { console.log('Leaflet VectorGrid layer created successfully');
console.log('Vector tile layer:', tileLayer);
} catch (error) {
console.error('Failed to create vector tile layer with Leaflet.VectorGrid:', error);
// Fall back to raster tiles on error
const provider = tileProviders.osm;
tileLayer = L.tileLayer(provider.url, {
attribution: provider.attribution, attribution: provider.attribution,
maxZoom: 19, maxZoom: 19,
subdomains: provider.subdomains, subdomains: provider.subdomains,
@ -259,12 +403,29 @@ let MapAPRSMap = {
keepBuffer: 2, keepBuffer: 2,
updateWhenZooming: false, updateWhenZooming: false,
updateInterval: 200, updateInterval: 200,
errorTileUrl: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=' errorTileUrl:
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=",
});
}
} else {
console.log('Leaflet.VectorGrid not available, falling back to raster tiles');
// Fallback to raster tiles
const provider = tileProviders.osm;
tileLayer = L.tileLayer(provider.url, {
attribution: provider.attribution,
maxZoom: 19,
subdomains: provider.subdomains,
tileSize: 256,
keepBuffer: 2,
updateWhenZooming: false,
updateInterval: 200,
errorTileUrl:
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=",
}); });
// Add event handler for tile errors with retry logic // Add event handler for tile errors with retry logic
let retryCount = new Map<string, number>(); let retryCount = new Map<string, number>();
tileLayer.on('tileerror', function(error: any) { tileLayer.on("tileerror", function (error: any) {
const src = error.tile.src; const src = error.tile.src;
const count = retryCount.get(src) || 0; const count = retryCount.get(src) || 0;
@ -273,14 +434,18 @@ let MapAPRSMap = {
retryCount.set(src, count + 1); retryCount.set(src, count + 1);
// Exponential backoff // Exponential backoff
setTimeout(() => { setTimeout(
error.tile.src = src + (src.includes('?') ? '&' : '?') + '_retry=' + Date.now(); () => {
}, Math.pow(2, count) * 500); error.tile.src = src + (src.includes("?") ? "&" : "?") + "_retry=" + Date.now();
},
Math.pow(2, count) * 500,
);
} else { } else {
console.error('Tile failed after 3 attempts:', src); console.error("Tile failed after 3 attempts:", src);
retryCount.delete(src); retryCount.delete(src);
} }
}); });
}
tileLayer.addTo(self.map); tileLayer.addTo(self.map);
} catch (error) { } catch (error) {
@ -301,24 +466,24 @@ let MapAPRSMap = {
maxClusterRadius: 80, maxClusterRadius: 80,
iconCreateFunction: function (cluster: MarkerCluster) { iconCreateFunction: function (cluster: MarkerCluster) {
const count = cluster.getChildCount(); const count = cluster.getChildCount();
let size = 'small'; let size = "small";
let className = 'marker-cluster-small'; let className = "marker-cluster-small";
if (count > 10) { if (count > 10) {
size = 'medium'; size = "medium";
className = 'marker-cluster-medium'; className = "marker-cluster-medium";
} }
if (count > 50) { if (count > 50) {
size = 'large'; size = "large";
className = 'marker-cluster-large'; className = "marker-cluster-large";
} }
return L.divIcon({ return L.divIcon({
html: '<div><span>' + count + '</span></div>', html: "<div><span>" + count + "</span></div>",
className: 'marker-cluster ' + className, className: "marker-cluster " + className,
iconSize: [40, 40] iconSize: [40, 40],
}); });
} },
}); });
self.markerClusterGroup.addTo(self.map); self.markerClusterGroup.addTo(self.map);
self.markerLayer = self.markerClusterGroup; self.markerLayer = self.markerClusterGroup;
@ -335,11 +500,11 @@ let MapAPRSMap = {
blur: 15, blur: 15,
maxZoom: 8, maxZoom: 8,
gradient: { gradient: {
0.4: 'blue', 0.4: "blue",
0.65: 'lime', 0.65: "lime",
0.85: 'yellow', 0.85: "yellow",
1.0: 'red' 1.0: "red",
} },
}); });
} }
} catch (error) { } catch (error) {
@ -369,10 +534,10 @@ let MapAPRSMap = {
self.lastZoom = self.map!.getZoom(); self.lastZoom = self.map!.getZoom();
// Ensure we have a valid pushEvent function before using it // Ensure we have a valid pushEvent function before using it
if (self.pushEvent && typeof self.pushEvent === 'function') { if (self.pushEvent && typeof self.pushEvent === "function") {
self.pushEvent("map_ready", {}); self.pushEvent("map_ready", {});
// Send initial bounds to trigger historical loading // Send initial bounds to trigger historical loading
if (self.map && self.pushEvent && typeof self.pushEvent === 'function') { if (self.map && self.pushEvent && typeof self.pushEvent === "function") {
saveMapState(self.map, self.pushEvent.bind(self)); saveMapState(self.map, self.pushEvent.bind(self));
} }
@ -387,7 +552,7 @@ let MapAPRSMap = {
console.warn("pushEvent not available in whenReady callback"); console.warn("pushEvent not available in whenReady callback");
// Retry after a short delay // Retry after a short delay
setTimeout(() => { setTimeout(() => {
if (self.pushEvent && typeof self.pushEvent === 'function' && !self.isDestroyed) { if (self.pushEvent && typeof self.pushEvent === "function" && !self.isDestroyed) {
self.pushEvent("map_ready", {}); self.pushEvent("map_ready", {});
if (self.map) { if (self.map) {
saveMapState(self.map, self.pushEvent.bind(self)); saveMapState(self.map, self.pushEvent.bind(self));
@ -429,7 +594,12 @@ let MapAPRSMap = {
if (self.boundsTimer) clearTimeout(self.boundsTimer); if (self.boundsTimer) clearTimeout(self.boundsTimer);
self.boundsTimer = setTimeout(() => { self.boundsTimer = setTimeout(() => {
if (self.map && !self.isDestroyed && self.pushEvent && typeof self.pushEvent === 'function') { if (
self.map &&
!self.isDestroyed &&
self.pushEvent &&
typeof self.pushEvent === "function"
) {
saveMapState(self.map, self.pushEvent.bind(self)); saveMapState(self.map, self.pushEvent.bind(self));
} }
}, 300); }, 300);
@ -468,7 +638,12 @@ let MapAPRSMap = {
self.lastZoom = currentZoom; self.lastZoom = currentZoom;
// Save map state and update URL // Save map state and update URL
if (self.map && !self.isDestroyed && self.pushEvent && typeof self.pushEvent === 'function') { if (
self.map &&
!self.isDestroyed &&
self.pushEvent &&
typeof self.pushEvent === "function"
) {
saveMapState(self.map, self.pushEvent.bind(self)); saveMapState(self.map, self.pushEvent.bind(self));
} }
}, 300); }, 300);
@ -514,20 +689,20 @@ let MapAPRSMap = {
keepSpiderfied: true, keepSpiderfied: true,
nearbyDistance: 40, nearbyDistance: 40,
circleSpiralSwitchover: 9, circleSpiralSwitchover: 9,
legWeight: 2 legWeight: 2,
}); });
// Add click handler for spiderfied markers // Add click handler for spiderfied markers
self.oms.addListener('click', function(marker: Marker) { self.oms.addListener("click", function (marker: Marker) {
if (marker.openPopup) marker.openPopup(); if (marker.openPopup) marker.openPopup();
}); });
// Style the spider legs // Style the spider legs
self.oms.addListener('spiderfy', function(markers: Marker[]) { self.oms.addListener("spiderfy", function (markers: Marker[]) {
self.map.closePopup(); self.map.closePopup();
}); });
self.oms.addListener('unspiderfy', function(markers: Marker[]) { self.oms.addListener("unspiderfy", function (markers: Marker[]) {
// Markers return to normal positions // Markers return to normal positions
}); });
} }
@ -587,7 +762,8 @@ let MapAPRSMap = {
self.markers.forEach((marker) => { self.markers.forEach((marker) => {
const markerLatLng = marker.getLatLng(); const markerLatLng = marker.getLatLng();
const distance = latlng.distanceTo(markerLatLng); const distance = latlng.distanceTo(markerLatLng);
if (distance < minDistance && distance < 50) { // Within 50 meters if (distance < minDistance && distance < 50) {
// Within 50 meters
minDistance = distance; minDistance = distance;
nearestMarker = marker; nearestMarker = marker;
} }
@ -598,7 +774,7 @@ let MapAPRSMap = {
} }
}; };
self.map!.on('touchstart', (e: LeafletTouchEvent) => { self.map!.on("touchstart", (e: LeafletTouchEvent) => {
if (e.originalEvent.touches.length !== 1) return; if (e.originalEvent.touches.length !== 1) return;
const touch = e.originalEvent.touches[0]; const touch = e.originalEvent.touches[0];
@ -610,7 +786,7 @@ let MapAPRSMap = {
}, longPressDuration); }, longPressDuration);
}); });
self.map!.on('touchmove', (e: LeafletTouchEvent) => { self.map!.on("touchmove", (e: LeafletTouchEvent) => {
if (!longPressTimer) return; if (!longPressTimer) return;
const touch = e.originalEvent.touches[0]; const touch = e.originalEvent.touches[0];
@ -624,14 +800,14 @@ let MapAPRSMap = {
} }
}); });
self.map!.on('touchend', () => { self.map!.on("touchend", () => {
if (longPressTimer) { if (longPressTimer) {
clearTimeout(longPressTimer); clearTimeout(longPressTimer);
longPressTimer = null; longPressTimer = null;
} }
}); });
self.map!.on('touchcancel', () => { self.map!.on("touchcancel", () => {
if (longPressTimer) { if (longPressTimer) {
clearTimeout(longPressTimer); clearTimeout(longPressTimer);
longPressTimer = null; longPressTimer = null;
@ -647,7 +823,7 @@ let MapAPRSMap = {
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
// Check if clicked element or its parent is a LiveView navigation link // Check if clicked element or its parent is a LiveView navigation link
const navLink = target.closest('.aprs-lv-link') as HTMLAnchorElement; const navLink = target.closest(".aprs-lv-link") as HTMLAnchorElement;
if (navLink && navLink.href) { if (navLink && navLink.href) {
e.preventDefault(); e.preventDefault();
@ -665,7 +841,7 @@ let MapAPRSMap = {
}; };
// Use event delegation to handle clicks on popup navigation links // Use event delegation to handle clicks on popup navigation links
document.addEventListener('click', self.popupNavigationHandler); document.addEventListener("click", self.popupNavigationHandler);
}, },
setupLiveViewHandlers() { setupLiveViewHandlers() {
@ -987,7 +1163,9 @@ let MapAPRSMap = {
}); });
// Handle progressive loading of historical packets (batch processing) // Handle progressive loading of historical packets (batch processing)
self.handleEvent("add_historical_packets_batch", (data: { packets: MarkerData[], batch: number, is_final: boolean }) => { self.handleEvent(
"add_historical_packets_batch",
(data: { packets: MarkerData[]; batch: number; is_final: boolean }) => {
try { try {
if (data.packets && Array.isArray(data.packets)) { if (data.packets && Array.isArray(data.packets)) {
// Process all packets immediately for maximum speed // Process all packets immediately for maximum speed
@ -1024,7 +1202,8 @@ let MapAPRSMap = {
console.error("Error processing historical packets batch:", error); console.error("Error processing historical packets batch:", error);
// Continue processing other batches even if one fails // Continue processing other batches even if one fails
} }
}); },
);
// Handle refresh markers event // Handle refresh markers event
self.handleEvent("refresh_markers", () => { self.handleEvent("refresh_markers", () => {
@ -1070,16 +1249,18 @@ let MapAPRSMap = {
}); });
// Handle drawing RF path lines when hovering over a station // Handle drawing RF path lines when hovering over a station
self.handleEvent("draw_rf_path", (data: { self.handleEvent(
station_lat: number, "draw_rf_path",
station_lng: number, (data: {
path_stations: Array<{callsign: string, lat: number, lng: number}> station_lat: number;
station_lng: number;
path_stations: Array<{ callsign: string; lat: number; lng: number }>;
}) => { }) => {
if (!self.map || !data.path_stations || data.path_stations.length === 0) return; if (!self.map || !data.path_stations || data.path_stations.length === 0) return;
// Clear any existing path lines // Clear any existing path lines
if (self.rfPathLines) { if (self.rfPathLines) {
self.rfPathLines.forEach(line => self.map!.removeLayer(line)); self.rfPathLines.forEach((line) => self.map!.removeLayer(line));
} }
self.rfPathLines = []; self.rfPathLines = [];
@ -1090,13 +1271,16 @@ let MapAPRSMap = {
data.path_stations.forEach((station, index) => { data.path_stations.forEach((station, index) => {
// Draw line from previous position to this station // Draw line from previous position to this station
const line = L.polyline( const line = L.polyline(
[[prevLat, prevLng], [station.lat, station.lng]], [
[prevLat, prevLng],
[station.lat, station.lng],
],
{ {
color: '#FF6B6B', color: "#FF6B6B",
weight: 3, weight: 3,
opacity: 0.8, opacity: 0.8,
dashArray: index === 0 ? null : '5, 10', // Solid line for first hop, dashed for subsequent dashArray: index === 0 ? null : "5, 10", // Solid line for first hop, dashed for subsequent
} },
); );
line.addTo(self.map!); line.addTo(self.map!);
@ -1105,17 +1289,17 @@ let MapAPRSMap = {
// Add a small circle marker at the digipeater/igate location // Add a small circle marker at the digipeater/igate location
const circle = L.circleMarker([station.lat, station.lng], { const circle = L.circleMarker([station.lat, station.lng], {
radius: 6, radius: 6,
fillColor: '#2563eb', fillColor: "#2563eb",
color: '#fff', color: "#fff",
weight: 2, weight: 2,
opacity: 1, opacity: 1,
fillOpacity: 0.8 fillOpacity: 0.8,
}); });
circle.bindTooltip(station.callsign, { circle.bindTooltip(station.callsign, {
permanent: false, permanent: false,
direction: 'top', direction: "top",
offset: [0, -10] offset: [0, -10],
}); });
circle.addTo(self.map!); circle.addTo(self.map!);
@ -1125,12 +1309,13 @@ let MapAPRSMap = {
prevLat = station.lat; prevLat = station.lat;
prevLng = station.lng; prevLng = station.lng;
}); });
}); },
);
// Handle clearing RF path lines // Handle clearing RF path lines
self.handleEvent("clear_rf_path", () => { self.handleEvent("clear_rf_path", () => {
if (self.rfPathLines) { if (self.rfPathLines) {
self.rfPathLines.forEach(line => { self.rfPathLines.forEach((line) => {
if (self.map && self.map.hasLayer(line)) { if (self.map && self.map.hasLayer(line)) {
self.map.removeLayer(line); self.map.removeLayer(line);
} }
@ -1170,7 +1355,6 @@ let MapAPRSMap = {
// Handle heat map data for low zoom levels // Handle heat map data for low zoom levels
self.handleEvent("show_heat_map", (data: { heat_points: HeatLatLng[] }) => { self.handleEvent("show_heat_map", (data: { heat_points: HeatLatLng[] }) => {
try { try {
if (!self.map || self.isDestroyed) { if (!self.map || self.isDestroyed) {
console.warn("Map not ready or destroyed, skipping heat map update"); console.warn("Map not ready or destroyed, skipping heat map update");
return; return;
@ -1188,11 +1372,11 @@ let MapAPRSMap = {
blur: 15, blur: 15,
maxZoom: 8, maxZoom: 8,
gradient: { gradient: {
0.4: 'blue', 0.4: "blue",
0.65: 'lime', 0.65: "lime",
0.85: 'yellow', 0.85: "yellow",
1.0: 'red' 1.0: "red",
} },
}); });
} catch (error) { } catch (error) {
console.error("Failed to create heat layer:", error); console.error("Failed to create heat layer:", error);
@ -1201,12 +1385,14 @@ let MapAPRSMap = {
} }
// Convert heat points to format expected by Leaflet.heat // Convert heat points to format expected by Leaflet.heat
const heatData = data.heat_points.map(point => [ const heatData = data.heat_points.map(
(point) =>
[
point.lat, point.lat,
point.lng, point.lng,
Math.min(point.intensity / 50.0, 1.0) // Normalize intensity to 0-1 range, cap at 1 Math.min(point.intensity / 50.0, 1.0), // Normalize intensity to 0-1 range, cap at 1
] as [number, number, number]); ] as [number, number, number],
);
// Update heat layer data // Update heat layer data
self.heatLayer.setLatLngs(heatData); self.heatLayer.setLatLngs(heatData);
@ -1226,7 +1412,6 @@ let MapAPRSMap = {
// Handle switching back to markers // Handle switching back to markers
self.handleEvent("show_markers", () => { self.handleEvent("show_markers", () => {
try { try {
if (!self.map || self.isDestroyed) { if (!self.map || self.isDestroyed) {
console.warn("Map not ready or destroyed, skipping marker display"); console.warn("Map not ready or destroyed, skipping marker display");
return; return;
@ -1259,7 +1444,7 @@ let MapAPRSMap = {
// self.removeMarkersOutsideBounds(bounds); // self.removeMarkersOutsideBounds(bounds);
// Use direct pushEvent call with proper context // Use direct pushEvent call with proper context
if (self.pushEvent && typeof self.pushEvent === 'function') { if (self.pushEvent && typeof self.pushEvent === "function") {
const boundsData = { const boundsData = {
bounds: { bounds: {
north: bounds.getNorth(), north: bounds.getNorth(),
@ -1351,7 +1536,7 @@ let MapAPRSMap = {
// Handle popup close events - check if hook is still connected // Handle popup close events - check if hook is still connected
marker.on("popupclose", () => { marker.on("popupclose", () => {
// Only send event if not destroyed and pushEvent is still the original function // Only send event if not destroyed and pushEvent is still the original function
if (!self.isDestroyed && self.pushEvent && typeof self.pushEvent === 'function') { if (!self.isDestroyed && self.pushEvent && typeof self.pushEvent === "function") {
try { try {
self.pushEvent("popup_closed", {}); self.pushEvent("popup_closed", {});
} catch (e) { } catch (e) {
@ -1381,8 +1566,8 @@ let MapAPRSMap = {
if (element) { if (element) {
// Find the highest z-index among all markers // Find the highest z-index among all markers
let maxZIndex = 1000; let maxZIndex = 1000;
document.querySelectorAll('.leaflet-marker-icon').forEach((el) => { document.querySelectorAll(".leaflet-marker-icon").forEach((el) => {
const zIndex = parseInt((el as HTMLElement).style.zIndex || '0', 10); const zIndex = parseInt((el as HTMLElement).style.zIndex || "0", 10);
if (zIndex > maxZIndex) maxZIndex = zIndex; if (zIndex > maxZIndex) maxZIndex = zIndex;
}); });
@ -1392,7 +1577,7 @@ let MapAPRSMap = {
} }
// Use bound pushEvent function to preserve context // Use bound pushEvent function to preserve context
if (self.pushEvent && typeof self.pushEvent === 'function' && !self.isDestroyed) { if (self.pushEvent && typeof self.pushEvent === "function" && !self.isDestroyed) {
safePushEvent(self.pushEvent.bind(self), "marker_clicked", { safePushEvent(self.pushEvent.bind(self), "marker_clicked", {
id: data.id, id: data.id,
callsign: data.callsign, callsign: data.callsign,
@ -1407,7 +1592,7 @@ let MapAPRSMap = {
if (data.path && data.path.trim() !== "" && !data.path.includes("TCPIP")) { if (data.path && data.path.trim() !== "" && !data.path.includes("TCPIP")) {
marker.on("mouseover", () => { marker.on("mouseover", () => {
// Check if LiveView is still connected before sending event // Check if LiveView is still connected before sending event
if (self.pushEvent && typeof self.pushEvent === 'function' && !self.isDestroyed) { if (self.pushEvent && typeof self.pushEvent === "function" && !self.isDestroyed) {
try { try {
self.pushEvent.call(self, "marker_hover_start", { self.pushEvent.call(self, "marker_hover_start", {
id: data.id, id: data.id,
@ -1426,7 +1611,7 @@ let MapAPRSMap = {
marker.on("mouseout", () => { marker.on("mouseout", () => {
// Check if LiveView is still connected before sending event // Check if LiveView is still connected before sending event
if (self.pushEvent && typeof self.pushEvent === 'function' && !self.isDestroyed) { if (self.pushEvent && typeof self.pushEvent === "function" && !self.isDestroyed) {
try { try {
self.pushEvent.call(self, "marker_hover_end", { self.pushEvent.call(self, "marker_hover_end", {
id: data.id, id: data.id,
@ -1438,7 +1623,6 @@ let MapAPRSMap = {
}); });
} }
// Mark historical markers for identification // Mark historical markers for identification
if (data.historical) { if (data.historical) {
(marker as APRSMarker)._isHistorical = true; (marker as APRSMarker)._isHistorical = true;
@ -1569,7 +1753,8 @@ let MapAPRSMap = {
// Identify historical markers and most recent markers to preserve // Identify historical markers and most recent markers to preserve
self.markers!.forEach((marker, id) => { self.markers!.forEach((marker, id) => {
const markerState = self.markerStates!.get(String(id)); const markerState = self.markerStates!.get(String(id));
const isHistorical = (marker as APRSMarker)._isHistorical || (markerState && markerState.historical); const isHistorical =
(marker as APRSMarker)._isHistorical || (markerState && markerState.historical);
const isMostRecent = markerState && markerState.is_most_recent_for_callsign; const isMostRecent = markerState && markerState.is_most_recent_for_callsign;
// Keep historical markers and current position markers // Keep historical markers and current position markers
@ -1616,7 +1801,8 @@ let MapAPRSMap = {
self.markers!.forEach((marker: L.Marker, id: string) => { self.markers!.forEach((marker: L.Marker, id: string) => {
// Check if this is a historical marker or the most recent marker for a callsign // Check if this is a historical marker or the most recent marker for a callsign
const markerState = self.markerStates!.get(String(id)); const markerState = self.markerStates!.get(String(id));
const isHistorical = (marker as APRSMarker)._isHistorical || (markerState && markerState.historical); const isHistorical =
(marker as APRSMarker)._isHistorical || (markerState && markerState.historical);
const isMostRecent = markerState && markerState.is_most_recent_for_callsign; const isMostRecent = markerState && markerState.is_most_recent_for_callsign;
// Always preserve historical markers and the most recent marker for a callsign // Always preserve historical markers and the most recent marker for a callsign
@ -1809,7 +1995,7 @@ let MapAPRSMap = {
// Remove popup navigation event listener // Remove popup navigation event listener
if (self.popupNavigationHandler) { if (self.popupNavigationHandler) {
document.removeEventListener('click', self.popupNavigationHandler); document.removeEventListener("click", self.popupNavigationHandler);
self.popupNavigationHandler = undefined; self.popupNavigationHandler = undefined;
} }
@ -1860,9 +2046,9 @@ let MapAPRSMap = {
if (self.oms !== undefined) { if (self.oms !== undefined) {
try { try {
self.oms.clearMarkers(); self.oms.clearMarkers();
self.oms.clearListeners('click'); self.oms.clearListeners("click");
self.oms.clearListeners('spiderfy'); self.oms.clearListeners("spiderfy");
self.oms.clearListeners('unspiderfy'); self.oms.clearListeners("unspiderfy");
self.oms = undefined; self.oms = undefined;
} catch (e) { } catch (e) {
console.debug("Error cleaning up OMS:", e); console.debug("Error cleaning up OMS:", e);
@ -1907,83 +2093,6 @@ let MapAPRSMap = {
// Restore original pushEvent (though it won't be used since we're destroyed) // Restore original pushEvent (though it won't be used since we're destroyed)
self.pushEvent = originalPushEvent; self.pushEvent = originalPushEvent;
}, },
setupMobileGestures() {
const self = this as unknown as LiveViewHookContext;
if (!self.map) return;
// Long press to show station info
let longPressTimer: NodeJS.Timeout | null = null;
let touchStartPos: { x: number; y: number } | null = null;
self.map.on('touchstart', (e: LeafletTouchEvent) => {
touchStartPos = { x: e.originalEvent.touches[0].pageX, y: e.originalEvent.touches[0].pageY };
longPressTimer = setTimeout(() => {
// Get the closest marker to the touch point
const point = e.containerPoint;
let closestMarker: APRSMarker | null = null;
let closestDistance = Infinity;
self.markers.forEach((marker) => {
const markerPoint = self.map.latLngToContainerPoint(marker.getLatLng());
const distance = Math.sqrt(
Math.pow(markerPoint.x - point.x, 2) +
Math.pow(markerPoint.y - point.y, 2)
);
if (distance < closestDistance && distance < 50) { // 50px tolerance
closestDistance = distance;
closestMarker = marker;
}
});
if (closestMarker) {
closestMarker.openPopup();
}
}, 600); // 600ms for long press
});
self.map.on('touchmove', (e: LeafletTouchEvent) => {
if (longPressTimer && touchStartPos) {
const moveThreshold = 10; // pixels
const currentPos = { x: e.originalEvent.touches[0].pageX, y: e.originalEvent.touches[0].pageY };
const distance = Math.sqrt(
Math.pow(currentPos.x - touchStartPos.x, 2) +
Math.pow(currentPos.y - touchStartPos.y, 2)
);
if (distance > moveThreshold) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
}
});
self.map.on('touchend touchcancel', () => {
if (longPressTimer) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
touchStartPos = null;
});
// Improve popup behavior on mobile
self.map.on('popupopen', (e: LeafletPopupEvent) => {
// Ensure popup is visible on mobile
const popup = e.popup;
const px = self.map.project(popup.getLatLng());
const popupHeight = popup.getElement()?.offsetHeight || 200;
const mapHeight = self.map.getContainer().offsetHeight;
// If popup would be cut off at bottom, pan the map
const containerPoint = self.map.latLngToContainerPoint(popup.getLatLng());
if (containerPoint.y + popupHeight > mapHeight - 50) {
px.y -= (popupHeight / 2);
self.map.panTo(self.map.unproject(px), { animate: true });
}
});
},
}; };
// Helper to validate and fallback symbol code per aprs.fi logic // Helper to validate and fallback symbol code per aprs.fi logic

37
assets/js/types/leaflet-vectorgrid.d.ts vendored Normal file
View file

@ -0,0 +1,37 @@
// Type definitions for leaflet.vectorgrid
import * as L from 'leaflet';
declare module 'leaflet' {
namespace vectorGrid {
interface VectorGridOptions extends L.GridLayerOptions {
rendererFactory?: L.Renderer;
vectorTileLayerStyles?: any;
interactive?: boolean;
getFeatureId?: (feature: any) => string | number;
}
interface ProtobufOptions extends VectorGridOptions {
subdomains?: string | string[];
key?: string;
token?: string;
maxNativeZoom?: number;
}
class VectorGrid extends L.GridLayer {
constructor(options?: VectorGridOptions);
setFeatureStyle(id: string | number, style: L.PathOptions): void;
resetFeatureStyle(id: string | number): void;
clearHighlight(): void;
}
class Protobuf extends VectorGrid {
constructor(url: string, options?: ProtobufOptions);
}
function protobuf(url: string, options?: ProtobufOptions): Protobuf;
}
function vectorGrid(options?: any): any;
}
export {};

View file

@ -1,28 +1,2 @@
// Vendor bundle for all third-party dependencies // This file is now empty since all vendor libraries are loaded from CDN
// This file bundles all npm packages to avoid resolution issues during Docker builds // See root.html.heex for CDN script tags
// Import CSS files first
import 'leaflet/dist/leaflet.css';
import 'leaflet.markercluster/dist/MarkerCluster.css';
import 'leaflet.markercluster/dist/MarkerCluster.Default.css';
// Export Leaflet and plugins
import * as L from 'leaflet';
import 'leaflet.heat';
import 'leaflet.markercluster';
import 'overlapping-marker-spiderfier-leaflet';
// Export Chart.js and adapter
import Chart from 'chart.js/auto';
import 'chartjs-adapter-date-fns';
// Export topbar
import topbar from 'topbar';
// Make libraries available globally
window.L = L;
window.Chart = Chart;
window.topbar = topbar;
// Export for ES modules
export { L, Chart, topbar };

View file

@ -1,13 +1,5 @@
{ {
"dependencies": { "dependencies": {},
"chart.js": "^4.5.0",
"chartjs-adapter-date-fns": "^3.0.0",
"leaflet": "^1.9.4",
"leaflet.heat": "^0.2.0",
"leaflet.markercluster": "^1.5.3",
"overlapping-marker-spiderfier-leaflet": "^0.2.7",
"topbar": "^3.0.0"
},
"name": "assets", "name": "assets",
"version": "1.0.0", "version": "1.0.0",
"main": "index.js", "main": "index.js",

View file

@ -109,7 +109,8 @@ if config_env() == :prod do
"http://10.0.19.222:33897", "http://10.0.19.222:33897",
"https://s.aprs.me", "https://s.aprs.me",
"https://js.sentry-cdn.com", "https://js.sentry-cdn.com",
"https://*.sentry.io" "https://*.sentry.io",
"https://*.openstreetmap.org"
] ]
# Optional: Set the default "from" email address # Optional: Set the default "from" email address

View file

@ -34,9 +34,29 @@
{assigns[:page_title] || "Aprs"} {assigns[:page_title] || "Aprs"}
</.live_title> </.live_title>
<link phx-track-static rel="stylesheet" href={~p"/assets/css/app.css"} /> <link phx-track-static rel="stylesheet" href={~p"/assets/css/app.css"} />
<link phx-track-static rel="stylesheet" href={~p"/assets/vendor.css"} />
<script defer phx-track-static type="text/javascript" src={~p"/assets/vendor.js"}> <!-- Leaflet CSS -->
</script> <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.css" />
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.Default.css" />
<!-- Core libraries -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://unpkg.com/leaflet.heat@0.2.0/dist/leaflet-heat.js"></script>
<script src="https://unpkg.com/leaflet.markercluster@1.5.3/dist/leaflet.markercluster.js"></script>
<script src="https://unpkg.com/overlapping-marker-spiderfier-leaflet@0.2.7/oms.min.js"></script>
<!-- Chart.js -->
<script src="https://unpkg.com/chart.js@4.5.0/dist/chart.umd.js"></script>
<script src="https://unpkg.com/chartjs-adapter-date-fns@3.0.0/dist/chartjs-adapter-date-fns.bundle.min.js"></script>
<!-- Topbar -->
<script src="https://unpkg.com/topbar@3.0.0/topbar.min.js"></script>
<!-- Leaflet VectorGrid for vector tiles -->
<script src="https://unpkg.com/leaflet.vectorgrid@1.3.0/dist/Leaflet.VectorGrid.bundled.js"></script>
<!-- App scripts -->
<script defer phx-track-static type="text/javascript" src={~p"/assets/app.js"}> <script defer phx-track-static type="text/javascript" src={~p"/assets/app.js"}>
</script> </script>
<script> <script>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long