Merge pull request #667 from Freika/fix/map-performance-improvement-with-canvas

Improve map performance with canvas rendering
This commit is contained in:
Evgenii Burmakin 2025-01-14 23:36:29 +01:00 committed by GitHub
commit b75b8670af
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 209 additions and 98 deletions

View file

@ -1 +1 @@
0.22.2 0.22.3

View file

@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/) The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/). and this project adheres to [Semantic Versioning](http://semver.org/).
# 0.22.3 - 2025-01-14
### Changed
- The Map now uses a canvas to draw polylines, points and fog of war. This should improve performance in browser with a lot of points and polylines.
# 0.22.2 - 2025-01-13 # 0.22.2 - 2025-01-13
✨ The Fancy Routes release ✨ ✨ The Fancy Routes release ✨

File diff suppressed because one or more lines are too long

View file

@ -1,21 +0,0 @@
/* Ensure fog overlay is positioned relative to the map container */
#fog {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.8); /* Adjust the opacity here */
pointer-events: none;
mix-blend-mode: multiply;
z-index: 1000;
}
.unfogged-circle {
position: absolute;
pointer-events: none;
border-radius: 50%;
background: white;
mix-blend-mode: destination-out;
filter: blur(3px); /* Apply no blur to the circles */
}

View file

@ -17,6 +17,6 @@ class Api::V1::Countries::VisitedCitiesController < ApiController
private private
def required_params def required_params
%i[start_at end_at] %i[start_at end_at api_key]
end end
end end

View file

@ -16,33 +16,23 @@ import {
import { fetchAndDrawAreas } from "../maps/areas"; import { fetchAndDrawAreas } from "../maps/areas";
import { handleAreaCreated } from "../maps/areas"; import { handleAreaCreated } from "../maps/areas";
import { showFlashMessage } from "../maps/helpers"; import { showFlashMessage, fetchAndDisplayPhotos, debounce } from "../maps/helpers";
import { fetchAndDisplayPhotos } from '../maps/helpers';
import { osmMapLayer } from "../maps/layers"; import {
import { osmHotMapLayer } from "../maps/layers"; osmMapLayer,
import { OPNVMapLayer } from "../maps/layers"; osmHotMapLayer,
import { openTopoMapLayer } from "../maps/layers"; OPNVMapLayer,
import { cyclOsmMapLayer } from "../maps/layers"; openTopoMapLayer,
import { esriWorldStreetMapLayer } from "../maps/layers"; cyclOsmMapLayer,
import { esriWorldTopoMapLayer } from "../maps/layers"; esriWorldStreetMapLayer,
import { esriWorldImageryMapLayer } from "../maps/layers"; esriWorldTopoMapLayer,
import { esriWorldGrayCanvasMapLayer } from "../maps/layers"; esriWorldImageryMapLayer,
esriWorldGrayCanvasMapLayer
} from "../maps/layers";
import { countryCodesMap } from "../maps/country_codes"; import { countryCodesMap } from "../maps/country_codes";
import "leaflet-draw"; import "leaflet-draw";
import { initializeFogCanvas, drawFogCanvas, createFogOverlay } from "../maps/fog_of_war";
function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
export default class extends Controller { export default class extends Controller {
static targets = ["container"]; static targets = ["container"];
@ -84,7 +74,10 @@ export default class extends Controller {
this.polylinesLayer = createPolylinesLayer(this.markers, this.map, this.timezone, this.routeOpacity, this.userSettings, this.distanceUnit); this.polylinesLayer = createPolylinesLayer(this.markers, this.map, this.timezone, this.routeOpacity, this.userSettings, this.distanceUnit);
this.heatmapLayer = L.heatLayer(this.heatmapMarkers, { radius: 20 }).addTo(this.map); this.heatmapLayer = L.heatLayer(this.heatmapMarkers, { radius: 20 }).addTo(this.map);
this.fogOverlay = L.layerGroup(); // Initialize fog layer
// Create a proper Leaflet layer for fog
this.fogOverlay = createFogOverlay();
this.areasLayer = L.layerGroup(); // Initialize areas layer this.areasLayer = L.layerGroup(); // Initialize areas layer
this.photoMarkers = L.layerGroup(); this.photoMarkers = L.layerGroup();
@ -94,11 +87,12 @@ export default class extends Controller {
this.addSettingsButton(); this.addSettingsButton();
} }
// Initialize layers for the layer control
const controlsLayer = { const controlsLayer = {
Points: this.markersLayer, Points: this.markersLayer,
Routes: this.polylinesLayer, Routes: this.polylinesLayer,
Heatmap: this.heatmapLayer, Heatmap: this.heatmapLayer,
"Fog of War": this.fogOverlay, "Fog of War": new this.fogOverlay(),
"Scratch map": this.scratchLayer, "Scratch map": this.scratchLayer,
Areas: this.areasLayer, Areas: this.areasLayer,
Photos: this.photoMarkers Photos: this.photoMarkers
@ -131,6 +125,7 @@ export default class extends Controller {
maxWidth: 120 maxWidth: 120
}).addTo(this.map) }).addTo(this.map)
// Initialize layer control
this.layerControl = L.control.layers(this.baseMaps(), controlsLayer).addTo(this.map); this.layerControl = L.control.layers(this.baseMaps(), controlsLayer).addTo(this.map);
// Fetch and draw areas when the map is loaded // Fetch and draw areas when the map is loaded
@ -230,6 +225,19 @@ export default class extends Controller {
localStorage.setItem('mapPanelOpen', 'false'); localStorage.setItem('mapPanelOpen', 'false');
} }
} }
// Update event handlers
this.map.on('moveend', () => {
if (document.getElementById('fog')) {
this.updateFog(this.markers, this.clearFogRadius);
}
});
this.map.on('zoomend', () => {
if (document.getElementById('fog')) {
this.updateFog(this.markers, this.clearFogRadius);
}
});
} }
disconnect() { disconnect() {
@ -528,39 +536,11 @@ export default class extends Controller {
} }
updateFog(markers, clearFogRadius) { updateFog(markers, clearFogRadius) {
var fog = document.getElementById('fog'); const fog = document.getElementById('fog');
fog.innerHTML = ''; // Clear previous circles if (!fog) {
markers.forEach((point) => { initializeFogCanvas(this.map);
const radiusInPixels = this.metersToPixels(this.map, clearFogRadius); }
this.clearFog(point[0], point[1], radiusInPixels); requestAnimationFrame(() => drawFogCanvas(this.map, markers, clearFogRadius));
});
}
metersToPixels(map, meters) {
const zoom = map.getZoom();
const latLng = map.getCenter(); // Get map center for correct projection
const metersPerPixel = this.getMetersPerPixel(latLng.lat, zoom);
return meters / metersPerPixel;
}
getMetersPerPixel(latitude, zoom) {
const earthCircumference = 40075016.686; // Earth's circumference in meters
const metersPerPixel = earthCircumference * Math.cos(latitude * Math.PI / 180) / Math.pow(2, zoom + 8);
return metersPerPixel;
}
clearFog(lat, lng, radius) {
var fog = document.getElementById('fog');
var point = this.map.latLngToContainerPoint([lat, lng]);
var size = radius * 2;
var circle = document.createElement('div');
circle.className = 'unfogged-circle';
circle.style.width = size + 'px';
circle.style.height = size + 'px';
circle.style.left = (point.x - radius) + 'px';
circle.style.top = (point.y - radius) + 'px';
circle.style.backdropFilter = 'blur(0px)'; // Remove blur for the circles
fog.appendChild(circle);
} }
initializeDrawControl() { initializeDrawControl() {
@ -822,6 +802,17 @@ export default class extends Controller {
// Debounce the heavy operations // Debounce the heavy operations
const updateLayers = debounce(() => { const updateLayers = debounce(() => {
try { try {
// Store current layer visibility states
const layerStates = {
Points: this.map.hasLayer(this.markersLayer),
Routes: this.map.hasLayer(this.polylinesLayer),
Heatmap: this.map.hasLayer(this.heatmapLayer),
"Fog of War": this.map.hasLayer(this.fogOverlay),
"Scratch map": this.map.hasLayer(this.scratchLayer),
Areas: this.map.hasLayer(this.areasLayer),
Photos: this.map.hasLayer(this.photoMarkers)
};
// Check if speed_colored_routes setting has changed // Check if speed_colored_routes setting has changed
if (newSettings.speed_colored_routes !== this.userSettings.speed_colored_routes) { if (newSettings.speed_colored_routes !== this.userSettings.speed_colored_routes) {
if (this.polylinesLayer) { if (this.polylinesLayer) {
@ -845,19 +836,35 @@ export default class extends Controller {
this.routeOpacity = parseFloat(newSettings.route_opacity) || 0.6; this.routeOpacity = parseFloat(newSettings.route_opacity) || 0.6;
this.clearFogRadius = parseInt(newSettings.fog_of_war_meters) || 50; this.clearFogRadius = parseInt(newSettings.fog_of_war_meters) || 50;
// Update layer control // Remove existing layer control
this.map.removeControl(this.layerControl); if (this.layerControl) {
this.map.removeControl(this.layerControl);
}
// Create new controls layer object with proper initialization
const controlsLayer = { const controlsLayer = {
Points: this.markersLayer, Points: this.markersLayer || L.layerGroup(),
Routes: this.polylinesLayer, Routes: this.polylinesLayer || L.layerGroup(),
Heatmap: this.heatmapLayer, Heatmap: this.heatmapLayer || L.heatLayer([]),
"Fog of War": this.fogOverlay, "Fog of War": new this.fogOverlay(),
"Scratch map": this.scratchLayer, "Scratch map": this.scratchLayer || L.layerGroup(),
Areas: this.areasLayer, Areas: this.areasLayer || L.layerGroup(),
Photos: this.photoMarkers Photos: this.photoMarkers || L.layerGroup()
}; };
// Add new layer control
this.layerControl = L.control.layers(this.baseMaps(), controlsLayer).addTo(this.map); this.layerControl = L.control.layers(this.baseMaps(), controlsLayer).addTo(this.map);
// Restore layer visibility states
Object.entries(layerStates).forEach(([name, wasVisible]) => {
const layer = controlsLayer[name];
if (wasVisible && layer) {
layer.addTo(this.map);
} else if (layer && this.map.hasLayer(layer)) {
this.map.removeLayer(layer);
}
});
} catch (error) { } catch (error) {
console.error('Error updating map settings:', error); console.error('Error updating map settings:', error);
console.error(error.stack); console.error(error.stack);

View file

@ -0,0 +1,94 @@
export function initializeFogCanvas(map) {
// Remove existing fog canvas if it exists
const oldFog = document.getElementById('fog');
if (oldFog) oldFog.remove();
// Create new fog canvas
const fog = document.createElement('canvas');
fog.id = 'fog';
fog.style.position = 'absolute';
fog.style.top = '0';
fog.style.left = '0';
fog.style.pointerEvents = 'none';
fog.style.zIndex = '400';
// Set canvas size to match map container
const mapSize = map.getSize();
fog.width = mapSize.x;
fog.height = mapSize.y;
// Add canvas to map container
map.getContainer().appendChild(fog);
return fog;
}
export function drawFogCanvas(map, markers, clearFogRadius) {
const fog = document.getElementById('fog');
if (!fog) return;
const ctx = fog.getContext('2d');
if (!ctx) return;
const size = map.getSize();
// Clear the canvas
ctx.clearRect(0, 0, size.x, size.y);
// Keep the light fog for unexplored areas
ctx.fillStyle = 'rgba(0, 0, 0, 0.4)';
ctx.fillRect(0, 0, size.x, size.y);
// Set up for "cutting" holes
ctx.globalCompositeOperation = 'destination-out';
// Draw clear circles for each point
markers.forEach(point => {
const latLng = L.latLng(point[0], point[1]);
const pixelPoint = map.latLngToContainerPoint(latLng);
const radiusInPixels = metersToPixels(map, clearFogRadius);
// Make explored areas completely transparent
const gradient = ctx.createRadialGradient(
pixelPoint.x, pixelPoint.y, 0,
pixelPoint.x, pixelPoint.y, radiusInPixels
);
gradient.addColorStop(0, 'rgba(255, 255, 255, 1)'); // 100% transparent
gradient.addColorStop(0.85, 'rgba(255, 255, 255, 1)'); // Still 100% transparent
gradient.addColorStop(1, 'rgba(255, 255, 255, 0)'); // Fade to fog at edge
ctx.fillStyle = gradient;
ctx.beginPath();
ctx.arc(pixelPoint.x, pixelPoint.y, radiusInPixels, 0, Math.PI * 2);
ctx.fill();
});
// Reset composite operation
ctx.globalCompositeOperation = 'source-over';
}
function metersToPixels(map, meters) {
const zoom = map.getZoom();
const latLng = map.getCenter();
const metersPerPixel = getMetersPerPixel(latLng.lat, zoom);
return meters / metersPerPixel;
}
function getMetersPerPixel(latitude, zoom) {
const earthCircumference = 40075016.686;
return earthCircumference * Math.cos(latitude * Math.PI / 180) / Math.pow(2, zoom + 8);
}
export function createFogOverlay() {
return L.Layer.extend({
onAdd: (map) => {
initializeFogCanvas(map);
},
onRemove: (map) => {
const fog = document.getElementById('fog');
if (fog) {
fog.remove();
}
}
});
}

View file

@ -297,3 +297,15 @@ export function createPhotoMarker(photo, userSettings, photoMarkers, apiKey) {
photoMarkers.addLayer(marker); photoMarkers.addLayer(marker);
} }
export function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}

View file

@ -1,15 +1,19 @@
import { createPopupContent } from "./popups"; import { createPopupContent } from "./popups";
export function createMarkersArray(markersData, userSettings) { export function createMarkersArray(markersData, userSettings) {
// Create a canvas renderer
const renderer = L.canvas({ padding: 0.5 });
if (userSettings.pointsRenderingMode === "simplified") { if (userSettings.pointsRenderingMode === "simplified") {
return createSimplifiedMarkers(markersData); return createSimplifiedMarkers(markersData, renderer);
} else { } else {
return markersData.map((marker) => { return markersData.map((marker) => {
const [lat, lon] = marker; const [lat, lon] = marker;
const popupContent = createPopupContent(marker, userSettings.timezone, userSettings.distanceUnit); const popupContent = createPopupContent(marker, userSettings.timezone, userSettings.distanceUnit);
let markerColor = marker[5] < 0 ? "orange" : "blue"; let markerColor = marker[5] < 0 ? "orange" : "blue";
return L.circleMarker([lat, lon], { return L.circleMarker([lat, lon], {
renderer: renderer, // Use canvas renderer
radius: 4, radius: 4,
color: markerColor, color: markerColor,
zIndexOffset: 1000, zIndexOffset: 1000,
@ -19,7 +23,7 @@ export function createMarkersArray(markersData, userSettings) {
} }
} }
export function createSimplifiedMarkers(markersData) { export function createSimplifiedMarkers(markersData, renderer) {
const distanceThreshold = 50; // meters const distanceThreshold = 50; // meters
const timeThreshold = 20000; // milliseconds (3 seconds) const timeThreshold = 20000; // milliseconds (3 seconds)
@ -48,9 +52,15 @@ export function createSimplifiedMarkers(markersData) {
const [lat, lon] = marker; const [lat, lon] = marker;
const popupContent = createPopupContent(marker); const popupContent = createPopupContent(marker);
let markerColor = marker[5] < 0 ? "orange" : "blue"; let markerColor = marker[5] < 0 ? "orange" : "blue";
return L.circleMarker( return L.circleMarker(
[lat, lon], [lat, lon],
{ radius: 4, color: markerColor, zIndexOffset: 1000 } {
renderer: renderer, // Use canvas renderer
radius: 4,
color: markerColor,
zIndexOffset: 1000
}
).bindPopup(popupContent); ).bindPopup(popupContent);
}); });
} }

View file

@ -265,6 +265,9 @@ export function addHighlightOnHover(polylineGroup, map, polylineCoordinates, use
} }
export function createPolylinesLayer(markers, map, timezone, routeOpacity, userSettings, distanceUnit) { export function createPolylinesLayer(markers, map, timezone, routeOpacity, userSettings, distanceUnit) {
// Create a canvas renderer
const renderer = L.canvas({ padding: 0.5 });
const splitPolylines = []; const splitPolylines = [];
let currentPolyline = []; let currentPolyline = [];
const distanceThresholdMeters = parseInt(userSettings.meters_between_routes) || 500; const distanceThresholdMeters = parseInt(userSettings.meters_between_routes) || 500;
@ -298,7 +301,6 @@ export function createPolylinesLayer(markers, map, timezone, routeOpacity, userS
for (let i = 0; i < polylineCoordinates.length - 1; i++) { for (let i = 0; i < polylineCoordinates.length - 1; i++) {
const speed = calculateSpeed(polylineCoordinates[i], polylineCoordinates[i + 1]); const speed = calculateSpeed(polylineCoordinates[i], polylineCoordinates[i + 1]);
const color = getSpeedColor(speed, userSettings.speed_colored_routes); const color = getSpeedColor(speed, userSettings.speed_colored_routes);
const segment = L.polyline( const segment = L.polyline(
@ -307,11 +309,12 @@ export function createPolylinesLayer(markers, map, timezone, routeOpacity, userS
[polylineCoordinates[i + 1][0], polylineCoordinates[i + 1][1]] [polylineCoordinates[i + 1][0], polylineCoordinates[i + 1][1]]
], ],
{ {
renderer: renderer, // Use canvas renderer
color: color, color: color,
originalColor: color, originalColor: color,
opacity: routeOpacity, opacity: routeOpacity,
weight: 3, weight: 3,
speed: speed, // Store the calculated speed speed: speed,
startTime: polylineCoordinates[i][4], startTime: polylineCoordinates[i][4],
endTime: polylineCoordinates[i + 1][4] endTime: polylineCoordinates[i + 1][4]
} }