mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-10 17:21:38 -05:00
363 lines
9 KiB
JavaScript
363 lines
9 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() {
|
|
console.log('❌ Using ORIGINAL loadHexagons method (should not happen for public sharing)');
|
|
|
|
// 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);
|
|
}
|
|
});
|
|
|
|
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;
|
|
|
|
let color = '#3388ff'
|
|
|
|
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;">
|
|
<strong>Date Range:</strong><br>
|
|
<small>${startDate} - ${endDate}</small>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* 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;
|