/**
* Leaflet Map Hook for Phoenix LiveView
* Displays queries with geospatial data and real-time vehicle positions on an interactive map
* Optimized for handling thousands of markers with Canvas renderer and data compression
*/
// L itself comes from the global the Leaflet UMD bundle installs; this one is
// ours, so it has to be imported.
import { addBasemap } from '../leaflet/basemap'
import { addFullscreenControl } from '../leaflet/fullscreen'
const LeafletMap = {
mounted() {
this.isInitialized = false;
this.isInitializing = false;
this.isUpdating = false;
this.vehicleMarkers = new Map(); // Track vehicle markers for smooth updates
// Initialize canvas renderer for performance
this.canvasRenderer = L.canvas({ padding: 0.5 });
// Ensure the container has proper dimensions before initializing
setTimeout(() => {
this.initializeMap();
this.updateMarkers();
}, 100);
// Add event handlers for streaming data
this.setupStreamHandlers();
},
updated() {
// Only update if map is already initialized and we're not currently initializing
if (this.map && this.isInitialized && !this.isUpdating) {
// Debounce updates to prevent flashing
if (this.updateTimeout) {
clearTimeout(this.updateTimeout);
}
this.updateTimeout = setTimeout(() => {
this.isUpdating = true;
this.map.invalidateSize();
this.updateMarkers();
this.isUpdating = false;
}, 150);
} else if (!this.map && !this.isInitializing) {
// Only initialize if we don't already have a map and aren't currently initializing
this.isInitializing = true;
setTimeout(() => {
this.initializeMap();
this.updateMarkers();
this.isInitializing = false;
}, 100);
}
},
destroyed() {
this.releaseBasemap();
if (this.map) {
this.map.remove();
}
},
// The tint holds a module-level theme-change callback, so it has to be
// released explicitly on every path that drops the map.
releaseBasemap() {
if (this.basemap) {
this.basemap.remove();
this.basemap = null;
}
// Same story: it holds document-level listeners of its own.
if (this.fullscreen) {
this.fullscreen.remove();
this.fullscreen = null;
}
},
initializeMap() {
// Only clear if we don't already have a map instance
if (this.map) {
console.log('Map already exists, removing...');
this.releaseBasemap();
this.map.remove();
this.map = null;
}
// Clean up any existing map containers to prevent duplicates
const existingMapContainers = this.el.querySelectorAll('.leaflet-map-container');
existingMapContainers.forEach(container => container.remove());
// Create a dedicated map container div
const mapContainer = document.createElement('div');
mapContainer.className = 'leaflet-map-container';
mapContainer.style.cssText = 'width: 100%; height: 100%; position: absolute; top: 0; left: 0; z-index: 10;';
// Hide the loading state by setting it behind the map
const loadingDiv = this.el.querySelector('.flex.items-center.justify-center');
if (loadingDiv) {
loadingDiv.style.zIndex = '1';
}
// Add the map container on top
this.el.appendChild(mapContainer);
// Initialize the Leaflet map in the new container
this.map = L.map(mapContainer, {
center: [39.8283, -98.5795], // Center of USA as default
zoom: 4,
zoomControl: true,
scrollWheelZoom: true
});
// Monochrome tiles, washed in the active theme colour.
this.basemap = addBasemap(this.map, { maxZoom: 18 });
// this.el, not the map container: the container is absolutely positioned
// inside it, so fullscreening the container alone would leave it sized to
// an element still laid out at its old size.
this.fullscreen = addFullscreenControl(this.map, { target: this.el });
// Initialize separate layer groups for queries, vehicles, free bikes, and stations
this.queriesLayer = L.layerGroup().addTo(this.map);
this.vehiclesLayer = L.layerGroup().addTo(this.map);
this.freeBikesLayer = L.layerGroup().addTo(this.map);
this.stationsLayer = L.layerGroup().addTo(this.map);
// Mark as initialized
this.isInitialized = true;
console.log('Map initialized successfully');
},
updateMarkers() {
// Skip if map isn't properly initialized
if (!this.map || !this.isInitialized || !this.queriesLayer || !this.vehiclesLayer || !this.freeBikesLayer || !this.stationsLayer) {
console.log('Skipping updateMarkers - map not ready');
return;
}
// Prevent concurrent updates that can cause flashing
if (this.isUpdating) {
console.log('Already updating markers, skipping...');
return;
}
this.isUpdating = true;
// Get data from data attributes with error handling
let queriesData, vehiclesData, freeBikesData, stationsData, selectedTint, useStreaming;
try {
queriesData = JSON.parse(this.el.dataset.queries || '[]');
vehiclesData = JSON.parse(this.el.dataset.vehicles || '[]');
freeBikesData = JSON.parse(this.el.dataset.freeBikes || '[]');
stationsData = JSON.parse(this.el.dataset.stations || '[]');
selectedTint = this.el.dataset.selectedTint;
useStreaming = this.el.dataset.useStreaming === "true";
} catch (e) {
console.error('Error parsing map data:', e);
this.isUpdating = false;
return;
}
console.log('updateMarkers called with:', {
queries: queriesData.length,
vehicles: vehiclesData.length,
freeBikes: freeBikesData.length,
stations: stationsData.length,
selectedTint,
useStreaming
});
// Only update query markers from data attributes if not using streaming
// When streaming is enabled, query markers are added via streaming events
if (!useStreaming && queriesData.length > 0) {
this.updateQueryMarkers(queriesData, selectedTint);
} else if (useStreaming) {
console.log('Query markers will be added via streaming events');
}
// Always update vehicle markers (these aren't streamed)
this.updateVehicleMarkers(vehiclesData);
// Always update free bike markers (these aren't streamed)
this.updateFreeBikeMarkers(freeBikesData);
// Always update station markers (these aren't streamed)
this.updateStationMarkers(stationsData);
this.isUpdating = false;
},
updateQueryMarkers(queriesData, selectedTint) {
// Clear existing query markers first
if (this.queriesLayer) {
// Store current map state before clearing
const currentCenter = this.map.getCenter();
const currentZoom = this.map.getZoom();
console.log('Clearing', this.queriesLayer.getLayers().length, 'query markers');
this.queriesLayer.clearLayers();
}
if (queriesData.length === 0) {
console.log('No queries data found');
return;
}
// Filter queries by selected tint if specified
const filteredQueries = selectedTint ?
queriesData.filter(q => q.tint === selectedTint) :
queriesData;
console.log('filteredQueries:', filteredQueries.length);
// Create bounds for auto-fitting the map
const group = new L.featureGroup();
// Add markers for each query
filteredQueries.forEach(query => {
if (query.lat && query.lng && query.lat !== 0 && query.lng !== 0) {
const marker = this.createQueryMarker(query);
this.queriesLayer.addLayer(marker);
group.addLayer(marker);
}
});
console.log('Query markers created:', group.getLayers().length);
// Fit map to show all markers if we have any (and no vehicles to also consider)
const vehiclesData = JSON.parse(this.el.dataset.vehicles || '[]');
const hasVehicles = vehiclesData.length > 0;
if (group.getLayers().length > 0 && !hasVehicles) {
try {
this.map.fitBounds(group.getBounds(), {
padding: [20, 20],
maxZoom: 15
});
} catch (e) {
console.warn('Error fitting map bounds:', e);
}
}
},
updateVehicleMarkers(vehiclesData) {
if (vehiclesData.length === 0) {
// Clear all vehicle markers if no vehicles
// Store current map state before clearing
const currentCenter = this.map.getCenter();
const currentZoom = this.map.getZoom();
console.log('Clearing', this.vehiclesLayer.getLayers().length, 'vehicle markers');
this.vehiclesLayer.clearLayers();
this.vehicleMarkers.clear();
return;
}
console.log('Updating vehicle markers:', vehiclesData.length);
// Keep track of current vehicle IDs
const currentVehicleIds = new Set(vehiclesData.map(v => v.id));
// Remove markers for vehicles that no longer exist
for (const [vehicleId, marker] of this.vehicleMarkers.entries()) {
if (!currentVehicleIds.has(vehicleId)) {
this.vehiclesLayer.removeLayer(marker);
this.vehicleMarkers.delete(vehicleId);
}
}
// Create bounds that include both queries and vehicles
const allMarkers = new L.featureGroup();
// Add existing query markers to bounds
this.queriesLayer.eachLayer(layer => allMarkers.addLayer(layer));
// Update or create vehicle markers
vehiclesData.forEach(vehicle => {
if (vehicle.lat && vehicle.lng) {
const existingMarker = this.vehicleMarkers.get(vehicle.id);
if (existingMarker) {
// Update existing marker position with smooth animation
this.animateVehicleMarker(existingMarker, vehicle);
} else {
// Create new marker
const newMarker = this.createVehicleMarker(vehicle);
this.vehiclesLayer.addLayer(newMarker);
this.vehicleMarkers.set(vehicle.id, newMarker);
}
// Add to bounds group
const markerToAdd = this.vehicleMarkers.get(vehicle.id);
if (markerToAdd) {
allMarkers.addLayer(markerToAdd);
}
}
});
// Fit map to show all markers (queries + vehicles) on first load
if (allMarkers.getLayers().length > 0 && !this.hasSetInitialBounds) {
this.map.fitBounds(allMarkers.getBounds(), {
padding: [20, 20],
maxZoom: 15
});
this.hasSetInitialBounds = true;
}
},
updateFreeBikeMarkers(freeBikesData) {
// Clear existing free bike markers
if (this.freeBikesLayer) {
// Store current map state before clearing
const currentCenter = this.map.getCenter();
const currentZoom = this.map.getZoom();
console.log('Clearing', this.freeBikesLayer.getLayers().length, 'free bike markers');
this.freeBikesLayer.clearLayers();
}
if (freeBikesData.length === 0) {
console.log('No free bikes data found');
return;
}
console.log('Updating free bike markers:', freeBikesData.length);
// Create bounds that include queries, vehicles, and free bikes
const allMarkers = new L.featureGroup();
// Add existing query and vehicle markers to bounds
this.queriesLayer.eachLayer(layer => allMarkers.addLayer(layer));
this.vehiclesLayer.eachLayer(layer => allMarkers.addLayer(layer));
// Add markers for each free bike
freeBikesData.forEach(bike => {
if (bike.lat && bike.lng && bike.lat !== 0 && bike.lng !== 0) {
const marker = this.createFreeBikeMarker(bike);
this.freeBikesLayer.addLayer(marker);
allMarkers.addLayer(marker);
}
});
console.log('Free bike markers created:', freeBikesData.length);
// Fit map to show all markers (queries + vehicles + bikes) on first load
if (allMarkers.getLayers().length > 0 && !this.hasSetInitialBounds) {
this.map.fitBounds(allMarkers.getBounds(), {
padding: [20, 20],
maxZoom: 15
});
this.hasSetInitialBounds = true;
}
},
updateStationMarkers(stationsData) {
// Clear existing station markers
if (this.stationsLayer) {
// Store current map state before clearing
const currentCenter = this.map.getCenter();
const currentZoom = this.map.getZoom();
console.log('Clearing', this.stationsLayer.getLayers().length, 'station markers');
this.stationsLayer.clearLayers();
}
if (stationsData.length === 0) {
console.log('No stations data found');
return;
}
console.log('Updating station markers:', stationsData.length);
// Create bounds that include all markers
const allMarkers = new L.featureGroup();
// Add existing markers to bounds
this.queriesLayer.eachLayer(layer => allMarkers.addLayer(layer));
this.vehiclesLayer.eachLayer(layer => allMarkers.addLayer(layer));
this.freeBikesLayer.eachLayer(layer => allMarkers.addLayer(layer));
// Add markers for each station
stationsData.forEach(station => {
if (station.lat && station.lng && station.lat !== 0 && station.lng !== 0) {
const marker = this.createStationMarker(station);
this.stationsLayer.addLayer(marker);
allMarkers.addLayer(marker);
}
});
console.log('Station markers created:', stationsData.length);
// Fit map to show all markers on first load
if (allMarkers.getLayers().length > 0 && !this.hasSetInitialBounds) {
this.map.fitBounds(allMarkers.getBounds(), {
padding: [20, 20],
maxZoom: 15
});
this.hasSetInitialBounds = true;
}
},
animateVehicleMarker(marker, vehicle) {
// Smooth transition to new position
const currentPos = marker.getLatLng();
const newPos = L.latLng(vehicle.lat, vehicle.lng);
// Only animate if position actually changed
if (currentPos.lat !== newPos.lat || currentPos.lng !== newPos.lng) {
// Use Leaflet's built-in smooth panning
marker.setLatLng(newPos);
// Update bearing/rotation if provided
if (vehicle.bearing !== null && vehicle.bearing !== undefined) {
this.updateVehicleBearing(marker, vehicle.bearing);
}
// Update popup content with latest data
const popupContent = this.createVehiclePopupContent(vehicle);
marker.getPopup().setContent(popupContent);
}
},
updateVehicleBearing(marker, bearing) {
// Rotate the vehicle icon to show direction
const iconElement = marker.getElement();
if (iconElement) {
const vehicleIcon = iconElement.querySelector('.vehicle-icon');
if (vehicleIcon) {
vehicleIcon.style.transform = `rotate(${bearing}deg)`;
}
}
},
createQueryMarker(query) {
// Create custom icon based on source type and tint
const icon = this.createCustomIcon(query);
// Create marker
const marker = L.marker([query.lat, query.lng], { icon });
// Create popup content
const popupContent = this.createPopupContent(query);
marker.bindPopup(popupContent);
// Add event listener for the duplicate query button after popup opens
marker.on('popupopen', (e) => {
const popup = e.popup;
const duplicateQueryBtn = popup.getElement().querySelector('.duplicate-query-btn');
if (duplicateQueryBtn) {
duplicateQueryBtn.addEventListener('click', () => {
this.pushEvent('add-query-from-map', {
station_id: query.id.toString(),
name: query.name,
type: 'query'
});
});
}
});
// Add click handler for query navigation
marker.on('click', () => {
// You can emit events back to LiveView if needed
// this.pushEvent('marker_clicked', { query_id: query.id });
});
return marker;
},
createVehicleMarker(vehicle) {
// Create custom vehicle icon
const icon = this.createVehicleIcon(vehicle);
// Create marker
const marker = L.marker([vehicle.lat, vehicle.lng], { icon });
// Create popup content
const popupContent = this.createVehiclePopupContent(vehicle);
marker.bindPopup(popupContent);
// Add event listener for vehicle query button after popup opens (if it's a GTFS vehicle)
marker.on('popupopen', (e) => {
const popup = e.popup;
const addVehicleQueryBtn = popup.getElement().querySelector('.add-vehicle-query-btn');
if (addVehicleQueryBtn) {
addVehicleQueryBtn.addEventListener('click', () => {
this.pushEvent('add-query-from-map', {
station_id: vehicle.vehicle_id,
name: `Vehicle ${vehicle.vehicle_id} Position Query`,
type: 'vehicle'
});
});
}
});
return marker;
},
createFreeBikeMarker(bike) {
// Create custom free bike icon
const icon = this.createFreeBikeIcon(bike);
// Create marker
const marker = L.marker([bike.lat, bike.lng], { icon });
// Create popup content
const popupContent = this.createFreeBikePopupContent(bike);
marker.bindPopup(popupContent);
// Add event listener for the add query button after popup opens
marker.on('popupopen', (e) => {
const popup = e.popup;
const addQueryBtn = popup.getElement().querySelector('.add-area-query-btn');
if (addQueryBtn) {
addQueryBtn.addEventListener('click', () => {
this.pushEvent('add-query-from-map', {
station_id: bike.bike_id,
name: 'Free Bike Area Query',
type: 'area'
});
});
}
});
return marker;
},
createStationMarker(station) {
// Create custom station icon
const icon = this.createStationIcon(station);
// Create marker
const marker = L.marker([station.lat, station.lng], { icon });
// Create popup content
const popupContent = this.createStationPopupContent(station);
marker.bindPopup(popupContent);
// Add event listener for the add query button after popup opens
marker.on('popupopen', (e) => {
const popup = e.popup;
const addQueryBtn = popup.getElement().querySelector('.add-query-btn');
if (addQueryBtn) {
addQueryBtn.addEventListener('click', () => {
this.pushEvent('add-query-from-map', {
station_id: station.station_id,
name: station.name || 'Station',
type: 'station'
});
});
}
});
return marker;
},
createVehicleIcon(vehicle) {
// The line's own colour where the static feed publishes one, so a source
// carrying a dozen routes reads as a dozen routes. The orange-red is the
// fallback for a feed that colours nothing, and is what every vehicle used
// to be.
const vehicleColor = vehicle.color || '#FF6B35';
// route_text_color exists precisely because a glyph has to sit on top of
// the line colour and stay readable -- white on a yellow bus does not.
const glyphColor = vehicle.text_color || '#FFFFFF';
const bearing = vehicle.bearing || 0;
// Create HTML for the vehicle icon with rotation
const iconHtml = `
<div class="vehicle-marker" style="
background-color: ${vehicleColor};
width: 32px;
height: 32px;
border-radius: 8px;
border: 2px solid white;
box-shadow: 0 2px 6px rgba(0,0,0,0.4), inset 0 0 0 1px rgba(0,0,0,0.25);
display: flex;
align-items: center;
justify-content: center;
color: ${glyphColor};
font-size: 14px;
position: relative;
transform: rotate(${bearing}deg);
transition: transform 0.3s ease;
">
<i class="fas ${vehicle.icon} vehicle-icon" style="transform: rotate(${-bearing}deg);"></i>
<div style="
position: absolute;
top: -2px;
right: -2px;
width: 8px;
height: 8px;
background: #22c55e;
border-radius: 50%;
border: 1px solid white;
"></div>
</div>
`;
return L.divIcon({
html: iconHtml,
iconSize: [32, 32],
iconAnchor: [16, 16],
popupAnchor: [0, -16],
className: 'custom-vehicle-icon'
});
},
createVehiclePopupContent(vehicle) {
const timestamp = vehicle.timestamp ?
new Date(vehicle.timestamp * 1000).toLocaleTimeString() : 'Unknown';
return `
<div class="p-2 min-w-48">
<div class="flex items-center gap-2 mb-2">
<i class="fas ${vehicle.icon} text-orange-600"></i>
<h3 class="font-semibold text-sm">Vehicle ${vehicle.vehicle_id}</h3>
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">
<div class="w-2 h-2 bg-green-400 rounded-full mr-1"></div>
Live
</span>
</div>
${vehicle.trip_id ? `
<div class="text-xs text-gray-600 mb-1">
<strong>Trip:</strong> ${vehicle.trip_id}
</div>
` : ''}
${vehicle.route_id ? `
<div class="text-xs text-gray-600 mb-1">
<strong>Route:</strong> ${vehicle.route_id}
</div>
` : ''}
<div class="text-xs text-gray-600 mb-1">
<strong>Last Update:</strong> ${timestamp}
</div>
${vehicle.bearing !== null && vehicle.bearing !== undefined ? `
<div class="text-xs text-gray-600 mb-2">
<strong>Heading:</strong> ${Math.round(vehicle.bearing)}°
</div>
` : ''}
<div class="text-xs text-gray-500 mb-2">
<i class="fa-solid fa-location-dot mr-1"></i>
${vehicle.lat.toFixed(4)}, ${vehicle.lng.toFixed(4)}
</div>
<div class="flex justify-center mt-2">
<button
class="add-vehicle-query-btn btn btn-sm btn-primary text-xs px-3 py-1 rounded-md bg-orange-600 hover:bg-orange-700 text-white transition-colors"
data-vehicle-id="${vehicle.vehicle_id}"
data-query-type="vehicle"
title="Create a vehicle position query for this vehicle"
>
<i class="fa-solid fa-plus mr-1"></i>
Add Vehicle Query
</button>
</div>
</div>
`;
},
createCustomIcon(query) {
// Get color based on tint or default
const tintColor = query.tint ? this.getTintColor(query.tint) : '#6B7280';
// Create HTML for the icon
const iconHtml = `
<div class="custom-marker" style="
background-color: ${tintColor};
width: 30px;
height: 30px;
border-radius: 50%;
border: 3px solid white;
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 12px;
">
<i class="fas ${query.icon}"></i>
</div>
`;
return L.divIcon({
html: iconHtml,
iconSize: [30, 30],
iconAnchor: [15, 15],
popupAnchor: [0, -15],
className: 'custom-div-icon'
});
},
createFreeBikeIcon(bike) {
// Use green colors for available bikes, yellow for reserved, gray for disabled
let bikeColor = '#22C55E'; // Green for available
if (bike.is_disabled) {
bikeColor = '#6B7280'; // Gray for disabled
} else if (bike.is_reserved) {
bikeColor = '#F59E0B'; // Yellow for reserved
}
// Create battery indicator if battery info is available
const batteryIndicator = bike.battery_icon && bike.battery_color ? `
<div style="
position: absolute;
top: -4px;
right: -4px;
width: 12px;
height: 12px;
background: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 1px 2px rgba(0,0,0,0.2);
">
<i class="fas ${bike.battery_icon}" style="
font-size: 6px;
color: ${bike.battery_color};
"></i>
</div>
` : '';
// Create status indicator for disabled/reserved
const statusIndicator = (bike.is_disabled || bike.is_reserved) ? `
<div style="
position: absolute;
bottom: -2px;
right: -2px;
width: 8px;
height: 8px;
background: ${bike.is_disabled ? '#EF4444' : '#F59E0B'};
border-radius: 50%;
border: 1px solid white;
box-shadow: 0 1px 2px rgba(0,0,0,0.2);
"></div>
` : '';
// Create HTML for the bike icon
const iconHtml = `
<div class="bike-marker" style="
background-color: ${bikeColor};
width: 20px;
height: 20px;
border-radius: 50%;
border: 2px solid white;
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 8px;
position: relative;
">
<i class="fas ${bike.icon}"></i>
${batteryIndicator}
${statusIndicator}
</div>
`;
return L.divIcon({
html: iconHtml,
iconSize: [20, 20],
iconAnchor: [10, 10],
popupAnchor: [0, -10],
className: 'custom-bike-icon'
});
},
createFreeBikePopupContent(bike) {
const statusBadge = bike.is_disabled ?
'<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-800">Disabled</span>' :
bike.is_reserved ?
'<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">Reserved</span>' :
'<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">Available</span>';
// Create battery display if available
const batteryDisplay = bike.battery_level !== null && bike.battery_level !== undefined ? `
<div class="text-xs text-gray-600 mb-1">
<i class="fas ${bike.battery_icon}" style="color: ${bike.battery_color};"></i>
<strong>Battery:</strong> ${bike.battery_level.toFixed(1)}%
</div>
` : '';
return `
<div class="p-2 min-w-48">
<div class="flex items-center gap-2 mb-2">
<i class="fas ${bike.icon} text-green-600"></i>
<h3 class="font-semibold text-sm">Bike ${bike.bike_id}</h3>
${statusBadge}
</div>
${bike.vehicle_type_id ? `
<div class="text-xs text-gray-600 mb-1">
<strong>Type:</strong> ${bike.vehicle_type_id}
</div>
` : ''}
${batteryDisplay}
${bike.current_range_meters ? `
<div class="text-xs text-gray-600 mb-1">
<strong>Range:</strong> ${bike.current_range_meters}m
</div>
` : ''}
<div class="text-xs text-gray-500 mb-2">
<i class="fa-solid fa-location-dot mr-1"></i>
${bike.lat.toFixed(4)}, ${bike.lng.toFixed(4)}
</div>
<div class="flex justify-center mt-2">
<button
class="add-area-query-btn btn btn-sm btn-secondary text-xs px-3 py-1 rounded-md bg-green-600 hover:bg-green-700 text-white transition-colors"
data-bike-id="${bike.bike_id}"
data-query-type="area"
title="Create an area query around this bike location"
>
<i class="fa-solid fa-plus mr-1"></i>
Add Area Query
</button>
</div>
</div>
`;
},
createStationIcon(station) {
// Get the tint color for the station
const tintColor = station.tint ? this.getTintColor(station.tint) : '#4338ca';
const iconHtml = `
<div style="
width: 26px;
height: 26px;
border-radius: 50%;
background-color: ${tintColor};
border: 2px solid #fff;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 12px;
position: relative;
">
<i class="fas fa-bicycle"></i>
</div>
`;
return L.divIcon({
html: iconHtml,
iconSize: [26, 26],
iconAnchor: [13, 13],
popupAnchor: [0, -13],
className: 'custom-station-icon'
});
},
createStationPopupContent(station) {
return `
<div class="p-2 min-w-48">
<div class="flex items-center gap-2 mb-2">
<i class="fas fa-bicycle text-indigo-600"></i>
<h3 class="font-semibold text-sm">${station.name || 'Station'}</h3>
</div>
${station.short_name ? `
<div class="text-xs text-gray-600 mb-1">
<strong>ID:</strong> ${station.short_name}
</div>
` : ''}
${station.address ? `
<div class="text-xs text-gray-600 mb-1">
<strong>Address:</strong> ${station.address}
</div>
` : ''}
${station.capacity !== null && station.capacity !== undefined ? `
<div class="text-xs text-gray-600 mb-1">
<strong>Capacity:</strong> ${station.capacity} bikes
</div>
` : ''}
${station.num_bikes_available !== null && station.num_bikes_available !== undefined ? `
<div class="text-xs text-gray-600 mb-1">
<strong>Regular Bikes Available:</strong> ${station.num_bikes_available}
</div>
` : ''}
${station.num_ebikes_available !== null && station.num_ebikes_available !== undefined ? `
<div class="text-xs text-gray-600 mb-1">
<strong>E-bikes Available:</strong> ${station.num_ebikes_available}
</div>
` : ''}
${station.num_docks_available !== null && station.num_docks_available !== undefined ? `
<div class="text-xs text-gray-600 mb-1">
<strong>Docks Available:</strong> ${station.num_docks_available}
</div>
` : ''}
${station.is_installed !== null && station.is_installed !== undefined ? `
<div class="text-xs text-gray-600 mb-1">
<strong>Status:</strong> ${station.is_installed ? 'Installed' : 'Not Installed'}
${station.is_renting !== null ? (station.is_renting ? ' • Renting' : ' • Not Renting') : ''}
${station.is_returning !== null ? (station.is_returning ? ' • Returning' : ' • Not Returning') : ''}
</div>
` : ''}
<div class="text-xs text-gray-500 mb-2">
<i class="fa-solid fa-location-dot mr-1"></i>
${station.lat.toFixed(4)}, ${station.lng.toFixed(4)}
</div>
<div class="flex justify-center mt-2">
<button
class="add-query-btn btn btn-sm btn-primary text-xs px-3 py-1 rounded-md bg-blue-600 hover:bg-blue-700 text-white transition-colors"
data-station-id="${station.station_id}"
data-station-name="${(station.name || 'Station').replace(/"/g, '"')}"
data-query-type="station"
>
<i class="fa-solid fa-plus mr-1"></i>
Add Query
</button>
</div>
</div>
`;
},
createPopupContent(query) {
const tintBadge = query.tint ?
`<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium" style="background-color: ${this.getTintColor(query.tint)}20; color: ${this.getTintColor(query.tint)};">
<i class="fa-solid fa-circle mr-1" style="color: ${this.getTintColor(query.tint)};"></i>
${query.tint}
</span>` : '';
return `
<div class="p-2 min-w-48">
<div class="flex items-center gap-2 mb-2">
<i class="fas ${query.icon} text-gray-600"></i>
<h3 class="font-semibold text-sm">${query.name}</h3>
</div>
<div class="text-xs text-gray-600 mb-2">
<strong>Source:</strong> ${query.source_name}
</div>
${query.notes ? `
<div class="text-xs text-gray-600 mb-2">
<strong>Notes:</strong> ${query.notes}
</div>
` : ''}
${tintBadge ? `
<div class="mb-2">
${tintBadge}
</div>
` : ''}
<div class="text-xs text-gray-500 mb-2">
<i class="fa-solid fa-location-dot mr-1"></i>
${query.lat.toFixed(4)}, ${query.lng.toFixed(4)}
</div>
<div class="flex justify-center mt-2">
<button
class="duplicate-query-btn btn btn-sm btn-primary text-xs px-3 py-1 rounded-md bg-blue-600 hover:bg-blue-700 text-white transition-colors"
data-query-id="${query.id}"
data-query-name="${query.name.replace(/"/g, '"')}"
data-query-type="query"
>
<i class="fa-solid fa-plus mr-1"></i>
Duplicate Query
</button>
</div>
</div>
`;
},
getTintColor(tint) {
// Map tint names to colors - adjust based on your Tailwind color scheme
const colorMap = {
'amber': '#F59E0B',
'lime': '#84CC16',
'emerald': '#10B981',
'sky': '#0EA5E9',
'violet': '#8B5CF6',
'fuchsia': '#D946EF',
'rose': '#F43F5E',
'stone': '#78716C',
'slate': '#64748B',
'red': '#EF4444',
'orange': '#F97316',
'yellow': '#EAB308',
'green': '#22C55E',
'teal': '#14B8A6',
'cyan': '#06B6D4',
'blue': '#3B82F6',
'indigo': '#6366F1',
'purple': '#A855F7',
'pink': '#EC4899',
'gray': '#6B7280'
};
return colorMap[tint] || '#6B7280';
},
// High-performance streaming data handlers
setupStreamHandlers() {
// Handle streaming marker data from server
this.handleEvent("add_markers_batch", (data) => {
this.addMarkersBatch(this.decompressData(data.compressed_data));
});
this.handleEvent("add_project", (project) => {
this.addProjectMarker(project);
});
this.handleEvent("clear_markers", () => {
this.clearAllMarkers();
});
},
// Decompress zlib compressed + base64 encoded data from server
decompressData(compressedData) {
try {
if (typeof compressedData === 'string') {
// Import compression libraries dynamically
const pako = window.pako;
const Base64 = window.Base64;
if (pako && Base64) {
// Full decompression with pako library
const base64Decoded = Base64.atob(compressedData);
const charCodes = [];
for (let i = 0; i < base64Decoded.length; i++) {
charCodes.push(base64Decoded.charCodeAt(i));
}
const inflatedData = pako.inflate(charCodes, { to: 'string' });
return JSON.parse(inflatedData);
} else {
// Fallback: assume it's plain JSON
return JSON.parse(compressedData);
}
}
return compressedData;
} catch (error) {
console.error('Error decompressing data:', error);
return [];
}
},
// Optimized batch marker addition using canvas renderer
addMarkersBatch(projects) {
if (!this.map || !projects || projects.length === 0) {
return;
}
console.log(`Adding ${projects.length} markers in batch`);
// Use requestAnimationFrame to prevent UI blocking
const addBatch = (startIndex) => {
const batchSize = 100; // Process 100 markers at a time
const endIndex = Math.min(startIndex + batchSize, projects.length);
for (let i = startIndex; i < endIndex; i++) {
const project = projects[i];
if (project.lat && project.lng && project.lat !== 0 && project.lng !== 0) {
this.addOptimizedMarker(project);
}
}
// Continue with next batch if there are more markers
if (endIndex < projects.length) {
requestAnimationFrame(() => addBatch(endIndex));
} else {
console.log(`Finished adding ${projects.length} markers`);
}
};
// Start the batch processing
requestAnimationFrame(() => addBatch(0));
},
// Create high-performance marker using Canvas renderer
addOptimizedMarker(project) {
const marker = L.circleMarker(
[project.lat, project.lng],
{
renderer: this.canvasRenderer,
radius: 2,
weight: 0,
color: this.getTintColor(project.tint) || '#ef4444',
fillColor: this.getTintColor(project.tint) || '#ef4444',
fillOpacity: 0.8,
fill: true,
}
).addTo(this.queriesLayer);
// Add popup if needed (but keep it lightweight)
if (project.name) {
marker.bindPopup(`
<div class="p-2">
<h4 class="font-semibold">${project.name}</h4>
<p class="text-sm text-gray-600">${project.lat.toFixed(4)}, ${project.lng.toFixed(4)}</p>
</div>
`);
}
return marker;
},
// Add single project marker (for real-time streaming)
addProjectMarker(project) {
if (!this.map || !project.lat || !project.lng) {
return;
}
this.addOptimizedMarker(project);
},
// Clear all markers for performance
clearAllMarkers() {
if (this.queriesLayer) {
this.queriesLayer.clearLayers();
}
if (this.vehiclesLayer) {
this.vehiclesLayer.clearLayers();
}
if (this.freeBikesLayer) {
this.freeBikesLayer.clearLayers();
}
if (this.stationsLayer) {
this.stationsLayer.clearLayers();
}
// Clear vehicle markers map
this.vehicleMarkers.clear();
},
// Utility method to convert array data to object (for compressed data)
objectifyProject(projectArray) {
// Convert [name, source, type, lat, lng] to object
// Adjust indices based on your data structure
return {
name: projectArray[0],
source_name: projectArray[1],
type: projectArray[2],
lat: projectArray[3],
lng: projectArray[4]
};
}
};
export default LeafletMap;
neiam
/apollos_crib
It's Apollo's Crib
public · Issues · Pulls 1 · Labels · Forks · Compare · Actions failure · Badges · Packages
⭐
Log in to mark this repository.
37.3 KiB · text
History
4eee00d