try vector tiles
This commit is contained in:
parent
688fc6ba95
commit
ae8c74afbe
8 changed files with 577 additions and 494 deletions
475
assets/js/map.ts
475
assets/js/map.ts
|
|
@ -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: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors | APRS.me',
|
attribution:
|
||||||
subdomains: ['a', 'b', 'c']
|
'© <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: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors | APRS.me',
|
attribution:
|
||||||
subdomains: ['a', 'b', 'c']
|
'© <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: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a> | APRS.me',
|
attribution:
|
||||||
subdomains: ['a', 'b', 'c', 'd']
|
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <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:
|
||||||
|
'© <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
37
assets/js/types/leaflet-vectorgrid.d.ts
vendored
Normal 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 {};
|
||||||
|
|
@ -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 };
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
Loading…
Add table
Reference in a new issue