dawarich/app/javascript/maps/hexagon_grid.js
2025-08-24 15:51:52 +02:00

411 lines
10 KiB
JavaScript

/**
* HexagonGrid - Manages hexagonal grid overlay on Leaflet maps
* Provides efficient loading and rendering of hexagon tiles based on viewport
*/
export class HexagonGrid {
constructor(map, options = {}) {
this.map = map;
this.options = {
apiEndpoint: '/api/v1/maps/hexagons',
style: {
fillColor: '#3388ff',
fillOpacity: 0.1,
color: '#3388ff',
weight: 1,
opacity: 0.5
},
debounceDelay: 300, // ms to wait before loading new hexagons
maxZoom: 18, // Don't show hexagons beyond this zoom level
minZoom: 8, // Don't show hexagons below this zoom level
...options
};
this.hexagonLayer = null;
this.loadingController = null; // For aborting requests
this.lastBounds = null;
this.isVisible = false;
this.init();
}
init() {
// Create the hexagon layer group
this.hexagonLayer = L.layerGroup();
// Bind map events
this.map.on('moveend', this.debounce(this.onMapMove.bind(this), this.options.debounceDelay));
this.map.on('zoomend', this.onZoomChange.bind(this));
// Initial load if within zoom range
if (this.shouldShowHexagons()) {
this.show();
}
}
/**
* Show the hexagon grid overlay
*/
show() {
if (!this.isVisible) {
this.isVisible = true;
if (this.shouldShowHexagons()) {
this.hexagonLayer.addTo(this.map);
this.loadHexagons();
}
}
}
/**
* Hide the hexagon grid overlay
*/
hide() {
if (this.isVisible) {
this.isVisible = false;
this.hexagonLayer.remove();
this.cancelPendingRequest();
}
}
/**
* Toggle visibility of hexagon grid
*/
toggle() {
if (this.isVisible) {
this.hide();
} else {
this.show();
}
}
/**
* Check if hexagons should be displayed at current zoom level
*/
shouldShowHexagons() {
const zoom = this.map.getZoom();
return zoom >= this.options.minZoom && zoom <= this.options.maxZoom;
}
/**
* Handle map move events
*/
onMapMove() {
if (!this.isVisible || !this.shouldShowHexagons()) {
return;
}
const currentBounds = this.map.getBounds();
// Only reload if bounds have changed significantly
if (this.boundsChanged(currentBounds)) {
this.loadHexagons();
}
}
/**
* Handle zoom change events
*/
onZoomChange() {
if (!this.isVisible) {
return;
}
if (this.shouldShowHexagons()) {
// Show hexagons and load for new zoom level
if (!this.map.hasLayer(this.hexagonLayer)) {
this.hexagonLayer.addTo(this.map);
}
this.loadHexagons();
} else {
// Hide hexagons when zoomed too far in/out
this.hexagonLayer.remove();
this.cancelPendingRequest();
}
}
/**
* Check if bounds have changed enough to warrant reloading
*/
boundsChanged(newBounds) {
if (!this.lastBounds) {
return true;
}
const threshold = 0.1; // 10% change threshold
const oldArea = this.getBoundsArea(this.lastBounds);
const newArea = this.getBoundsArea(newBounds);
const intersection = this.getBoundsIntersection(this.lastBounds, newBounds);
const intersectionRatio = intersection / Math.min(oldArea, newArea);
return intersectionRatio < (1 - threshold);
}
/**
* Calculate approximate area of bounds
*/
getBoundsArea(bounds) {
const sw = bounds.getSouthWest();
const ne = bounds.getNorthEast();
return (ne.lat - sw.lat) * (ne.lng - sw.lng);
}
/**
* Calculate intersection area between two bounds
*/
getBoundsIntersection(bounds1, bounds2) {
const sw1 = bounds1.getSouthWest();
const ne1 = bounds1.getNorthEast();
const sw2 = bounds2.getSouthWest();
const ne2 = bounds2.getNorthEast();
const left = Math.max(sw1.lng, sw2.lng);
const right = Math.min(ne1.lng, ne2.lng);
const bottom = Math.max(sw1.lat, sw2.lat);
const top = Math.min(ne1.lat, ne2.lat);
if (left < right && bottom < top) {
return (right - left) * (top - bottom);
}
return 0;
}
/**
* Load hexagons for current viewport
*/
async loadHexagons() {
// Cancel any pending request
this.cancelPendingRequest();
const bounds = this.map.getBounds();
this.lastBounds = bounds;
// Create new AbortController for this request
this.loadingController = new AbortController();
try {
// Get current date range from URL parameters
const urlParams = new URLSearchParams(window.location.search);
const startDate = urlParams.get('start_at');
const endDate = urlParams.get('end_at');
// Get viewport dimensions
const mapContainer = this.map.getContainer();
const viewportWidth = mapContainer.offsetWidth;
const viewportHeight = mapContainer.offsetHeight;
const params = new URLSearchParams({
min_lon: bounds.getWest(),
min_lat: bounds.getSouth(),
max_lon: bounds.getEast(),
max_lat: bounds.getNorth(),
viewport_width: viewportWidth,
viewport_height: viewportHeight
});
// Add date parameters if they exist
if (startDate) params.append('start_date', startDate);
if (endDate) params.append('end_date', endDate);
const response = await fetch(`${this.options.apiEndpoint}&${params}`, {
signal: this.loadingController.signal,
headers: {
'Content-Type': 'application/json'
}
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const geojsonData = await response.json();
// Clear existing hexagons and add new ones
this.clearHexagons();
this.addHexagonsToMap(geojsonData);
} catch (error) {
if (error.name !== 'AbortError') {
console.error('Failed to load hexagons:', error);
// Optionally show user-friendly error message
}
} finally {
this.loadingController = null;
}
}
/**
* Cancel pending hexagon loading request
*/
cancelPendingRequest() {
if (this.loadingController) {
this.loadingController.abort();
this.loadingController = null;
}
}
/**
* Clear existing hexagons from the map
*/
clearHexagons() {
this.hexagonLayer.clearLayers();
}
/**
* Add hexagons to the map from GeoJSON data
*/
addHexagonsToMap(geojsonData) {
if (!geojsonData.features || geojsonData.features.length === 0) {
return;
}
// Calculate max point count for color scaling
const maxPoints = Math.max(...geojsonData.features.map(f => f.properties.point_count));
const geoJsonLayer = L.geoJSON(geojsonData, {
style: (feature) => this.styleHexagonByData(feature, maxPoints),
onEachFeature: (feature, layer) => {
// Add popup with statistics
const props = feature.properties;
const popupContent = this.buildPopupContent(props);
layer.bindPopup(popupContent);
// Add hover effects
layer.on({
mouseover: (e) => this.onHexagonMouseOver(e, feature),
mouseout: (e) => this.onHexagonMouseOut(e, feature),
click: (e) => this.onHexagonClick(e, feature)
});
}
});
geoJsonLayer.addTo(this.hexagonLayer);
}
/**
* Style hexagon based on point density and other data
*/
styleHexagonByData(feature, maxPoints) {
const props = feature.properties;
const pointCount = props.point_count || 0;
// Calculate opacity based on point density (0.2 to 0.8)
const opacity = 0.2 + (pointCount / maxPoints) * 0.6;
// Calculate color based on density
let color = '#3388ff'
// let color = '#3388ff'; // Default blue
// if (pointCount > maxPoints * 0.7) {
// color = '#d73027'; // High density - red
// } else if (pointCount > maxPoints * 0.4) {
// color = '#fc8d59'; // Medium-high density - orange
// } else if (pointCount > maxPoints * 0.2) {
// color = '#fee08b'; // Medium density - yellow
// } else {
// color = '#91bfdb'; // Low density - light blue
// }
return {
fillColor: color,
fillOpacity: opacity,
color: color,
weight: 1,
opacity: opacity + 0.2
};
}
/**
* Build popup content with hexagon statistics
*/
buildPopupContent(props) {
const startDate = props.earliest_point ? new Date(props.earliest_point).toLocaleDateString() : 'N/A';
const endDate = props.latest_point ? new Date(props.latest_point).toLocaleDateString() : 'N/A';
return `
<div style="font-size: 12px; line-height: 1.4;">
<h4 style="margin: 0 0 8px 0; color: #2c5aa0;">Hexagon Stats</h4>
<strong>Points:</strong> ${props.point_count || 0}<br>
<strong>Density:</strong> ${props.density || 0} pts/km²<br>
${props.avg_speed ? `<strong>Avg Speed:</strong> ${props.avg_speed} km/h<br>` : ''}
${props.avg_battery ? `<strong>Avg Battery:</strong> ${props.avg_battery}%<br>` : ''}
<strong>Date Range:</strong><br>
<small>${startDate} - ${endDate}</small>
</div>
`;
}
/**
* Handle hexagon mouseover event
*/
onHexagonMouseOver(e) {
const layer = e.target;
layer.setStyle({
fillOpacity: 0.2,
weight: 2
});
}
/**
* Handle hexagon mouseout event
*/
onHexagonMouseOut(e) {
const layer = e.target;
layer.setStyle(this.options.style);
}
/**
* Handle hexagon click event
*/
onHexagonClick(e, feature) {
// Override this method to add custom click behavior
console.log('Hexagon clicked:', feature, 'at coordinates:', e.latlng);
}
/**
* Update hexagon style
*/
updateStyle(newStyle) {
this.options.style = { ...this.options.style, ...newStyle };
// Update existing hexagons
this.hexagonLayer.eachLayer((layer) => {
if (layer.setStyle) {
layer.setStyle(this.options.style);
}
});
}
/**
* Destroy the hexagon grid and clean up
*/
destroy() {
this.hide();
this.map.off('moveend');
this.map.off('zoomend');
this.hexagonLayer = null;
this.lastBounds = null;
}
/**
* Simple debounce utility
*/
debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
}
/**
* Create and return a new HexagonGrid instance
*/
export function createHexagonGrid(map, options = {}) {
return new HexagonGrid(map, options);
}
// Default export
export default HexagonGrid;