mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-11 01:31:39 -05:00
Add hexagons to public stats page
This commit is contained in:
parent
5cd05f8d3a
commit
612c30026c
10 changed files with 1480 additions and 54 deletions
File diff suppressed because one or more lines are too long
127
app/controllers/api/v1/maps/hexagons_controller.rb
Normal file
127
app/controllers/api/v1/maps/hexagons_controller.rb
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::Maps::HexagonsController < ApiController
|
||||
skip_before_action :authenticate_api_key, if: :public_sharing_request?
|
||||
before_action :validate_bbox_params, except: [:bounds]
|
||||
before_action :set_user_and_dates
|
||||
|
||||
def index
|
||||
Rails.logger.debug "Hexagon API request params: #{params.inspect}"
|
||||
Rails.logger.debug "Hexagon params: #{hexagon_params}"
|
||||
|
||||
service = Maps::HexagonGrid.new(hexagon_params)
|
||||
result = service.call
|
||||
|
||||
Rails.logger.debug "Hexagon service result: #{result['features']&.count || 0} features"
|
||||
render json: result
|
||||
rescue Maps::HexagonGrid::BoundingBoxTooLargeError,
|
||||
Maps::HexagonGrid::InvalidCoordinatesError => e
|
||||
Rails.logger.error "Hexagon validation error: #{e.message}"
|
||||
render json: { error: e.message }, status: :bad_request
|
||||
rescue Maps::HexagonGrid::PostGISError => e
|
||||
Rails.logger.error "Hexagon PostGIS error: #{e.message}"
|
||||
render json: { error: e.message }, status: :internal_server_error
|
||||
rescue StandardError => e
|
||||
handle_service_error(e)
|
||||
end
|
||||
|
||||
def bounds
|
||||
# Get the bounding box of user's points for the date range
|
||||
return render json: { error: 'No user found' }, status: :not_found unless @target_user
|
||||
return render json: { error: 'No date range specified' }, status: :bad_request unless @start_date && @end_date
|
||||
|
||||
points_relation = @target_user.points.where(timestamp: @start_date..@end_date)
|
||||
point_count = points_relation.count
|
||||
|
||||
if point_count.positive?
|
||||
bounds_result = ActiveRecord::Base.connection.exec_query(
|
||||
"SELECT MIN(latitude) as min_lat, MAX(latitude) as max_lat,
|
||||
MIN(longitude) as min_lng, MAX(longitude) as max_lng
|
||||
FROM points
|
||||
WHERE user_id = $1
|
||||
AND timestamp BETWEEN $2 AND $3",
|
||||
'bounds_query',
|
||||
[@target_user.id, @start_date.to_i, @end_date.to_i]
|
||||
).first
|
||||
|
||||
render json: {
|
||||
min_lat: bounds_result['min_lat'].to_f,
|
||||
max_lat: bounds_result['max_lat'].to_f,
|
||||
min_lng: bounds_result['min_lng'].to_f,
|
||||
max_lng: bounds_result['max_lng'].to_f,
|
||||
point_count: point_count
|
||||
}
|
||||
else
|
||||
render json: {
|
||||
error: 'No data found for the specified date range',
|
||||
point_count: 0
|
||||
}, status: :not_found
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def bbox_params
|
||||
params.permit(:min_lon, :min_lat, :max_lon, :max_lat, :hex_size, :viewport_width, :viewport_height)
|
||||
end
|
||||
|
||||
def hexagon_params
|
||||
bbox_params.merge(
|
||||
user_id: @target_user&.id,
|
||||
start_date: @start_date,
|
||||
end_date: @end_date
|
||||
)
|
||||
end
|
||||
|
||||
def set_user_and_dates
|
||||
if params[:uuid].present?
|
||||
set_public_sharing_context
|
||||
else
|
||||
set_authenticated_context
|
||||
end
|
||||
end
|
||||
|
||||
def set_public_sharing_context
|
||||
Rails.logger.debug "Public sharing request with UUID: #{params[:uuid]}"
|
||||
@stat = Stat.find_by(sharing_uuid: params[:uuid])
|
||||
|
||||
unless @stat&.public_accessible?
|
||||
Rails.logger.error "Stat not found or not public accessible for UUID: #{params[:uuid]}"
|
||||
return render json: {
|
||||
error: 'Shared stats not found or no longer available'
|
||||
}, status: :not_found
|
||||
end
|
||||
|
||||
@target_user = @stat.user
|
||||
@start_date = Date.new(@stat.year, @stat.month, 1).beginning_of_day
|
||||
@end_date = @start_date.end_of_month.end_of_day
|
||||
|
||||
Rails.logger.debug "Found stat for user #{@target_user.id}, date range: #{@start_date} to #{@end_date}"
|
||||
end
|
||||
|
||||
def set_authenticated_context
|
||||
@target_user = current_api_user
|
||||
@start_date = params[:start_date]
|
||||
@end_date = params[:end_date]
|
||||
end
|
||||
|
||||
def handle_service_error(error)
|
||||
Rails.logger.error "Hexagon generation error: #{error.message}\n#{error.backtrace.join("\n")}"
|
||||
render json: { error: 'Failed to generate hexagon grid' }, status: :internal_server_error
|
||||
end
|
||||
|
||||
def public_sharing_request?
|
||||
params[:uuid].present?
|
||||
end
|
||||
|
||||
def validate_bbox_params
|
||||
required_params = %w[min_lon min_lat max_lon max_lat]
|
||||
missing_params = required_params.select { |param| params[param].blank? }
|
||||
|
||||
return unless missing_params.any?
|
||||
|
||||
render json: {
|
||||
error: "Missing required parameters: #{missing_params.join(', ')}"
|
||||
}, status: :bad_request
|
||||
end
|
||||
end
|
||||
|
|
@ -95,6 +95,7 @@ class StatsController < ApplicationController
|
|||
@month = @stat.month
|
||||
@user = @stat.user
|
||||
@is_public_view = true
|
||||
@data_bounds = calculate_data_bounds(@stat)
|
||||
|
||||
render 'public_month'
|
||||
end
|
||||
|
|
@ -131,4 +132,32 @@ class StatsController < ApplicationController
|
|||
stats.sort_by(&:updated_at).reverse
|
||||
end.sort.reverse
|
||||
end
|
||||
|
||||
def calculate_data_bounds(stat)
|
||||
start_date = Date.new(stat.year, stat.month, 1).beginning_of_day
|
||||
end_date = start_date.end_of_month.end_of_day
|
||||
|
||||
points_relation = stat.user.points.where(timestamp: start_date.to_i..end_date.to_i)
|
||||
point_count = points_relation.count
|
||||
|
||||
return nil if point_count.zero?
|
||||
|
||||
bounds_result = ActiveRecord::Base.connection.exec_query(
|
||||
"SELECT MIN(latitude) as min_lat, MAX(latitude) as max_lat,
|
||||
MIN(longitude) as min_lng, MAX(longitude) as max_lng
|
||||
FROM points
|
||||
WHERE user_id = $1
|
||||
AND timestamp BETWEEN $2 AND $3",
|
||||
'data_bounds_query',
|
||||
[stat.user.id, start_date.to_i, end_date.to_i]
|
||||
).first
|
||||
|
||||
{
|
||||
min_lat: bounds_result['min_lat'].to_f,
|
||||
max_lat: bounds_result['max_lat'].to_f,
|
||||
min_lng: bounds_result['min_lng'].to_f,
|
||||
max_lng: bounds_result['max_lng'].to_f,
|
||||
point_count: point_count
|
||||
}
|
||||
end
|
||||
end
|
||||
|
|
|
|||
254
app/javascript/controllers/public_stat_map_controller.js
Normal file
254
app/javascript/controllers/public_stat_map_controller.js
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
import { Controller } from "@hotwired/stimulus";
|
||||
import L from "leaflet";
|
||||
import { createHexagonGrid } from "../maps/hexagon_grid";
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["container"];
|
||||
static values = {
|
||||
year: Number,
|
||||
month: Number,
|
||||
uuid: String,
|
||||
dataBounds: Object
|
||||
};
|
||||
|
||||
connect() {
|
||||
this.initializeMap();
|
||||
this.loadHexagons();
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.hexagonGrid) {
|
||||
this.hexagonGrid.destroy();
|
||||
}
|
||||
if (this.map) {
|
||||
this.map.remove();
|
||||
}
|
||||
}
|
||||
|
||||
initializeMap() {
|
||||
// Initialize map with interactive controls enabled
|
||||
this.map = L.map(this.element, {
|
||||
zoomControl: true,
|
||||
scrollWheelZoom: true,
|
||||
doubleClickZoom: true,
|
||||
touchZoom: true,
|
||||
dragging: true,
|
||||
keyboard: false
|
||||
});
|
||||
|
||||
// Add tile layer
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap contributors',
|
||||
maxZoom: 15
|
||||
}).addTo(this.map);
|
||||
|
||||
// Default view
|
||||
this.map.setView([40.0, -100.0], 4);
|
||||
}
|
||||
|
||||
async loadHexagons() {
|
||||
try {
|
||||
// Calculate date range for the month
|
||||
const startDate = new Date(this.yearValue, this.monthValue - 1, 1);
|
||||
const endDate = new Date(this.yearValue, this.monthValue, 0, 23, 59, 59);
|
||||
|
||||
// Use server-provided data bounds
|
||||
const dataBounds = this.dataBoundsValue;
|
||||
|
||||
if (dataBounds && dataBounds.point_count > 0) {
|
||||
// Set map view to data bounds BEFORE creating hexagon grid
|
||||
this.map.fitBounds([
|
||||
[dataBounds.min_lat, dataBounds.min_lng],
|
||||
[dataBounds.max_lat, dataBounds.max_lng]
|
||||
], { padding: [20, 20] });
|
||||
|
||||
// Wait for the map to finish fitting bounds
|
||||
await new Promise(resolve => {
|
||||
this.map.once('moveend', resolve);
|
||||
// Fallback timeout in case moveend doesn't fire
|
||||
setTimeout(resolve, 1000);
|
||||
});
|
||||
}
|
||||
|
||||
// Create hexagon grid with API endpoint for public sharing
|
||||
// Note: We need to prevent automatic showing during init
|
||||
this.hexagonGrid = createHexagonGrid(this.map, {
|
||||
apiEndpoint: '/api/v1/maps/hexagons',
|
||||
style: {
|
||||
fillColor: '#3388ff',
|
||||
fillOpacity: 0.3,
|
||||
color: '#3388ff',
|
||||
weight: 1,
|
||||
opacity: 0.7
|
||||
},
|
||||
debounceDelay: 300,
|
||||
maxZoom: 15,
|
||||
minZoom: 4
|
||||
});
|
||||
|
||||
// Force hide immediately after creation to prevent auto-showing
|
||||
this.hexagonGrid.hide();
|
||||
|
||||
// Disable all dynamic behavior by removing event listeners
|
||||
this.map.off('moveend');
|
||||
this.map.off('zoomend');
|
||||
|
||||
// Load hexagons only once on page load (static behavior)
|
||||
if (dataBounds && dataBounds.point_count > 0) {
|
||||
await this.loadStaticHexagons();
|
||||
} else {
|
||||
console.warn('No data bounds or points available - not showing hexagons');
|
||||
}
|
||||
|
||||
// Hide loading indicator
|
||||
const loadingElement = document.getElementById('map-loading');
|
||||
if (loadingElement) {
|
||||
loadingElement.style.display = 'none';
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error initializing hexagon grid:', error);
|
||||
|
||||
// Hide loading indicator even on error
|
||||
const loadingElement = document.getElementById('map-loading');
|
||||
if (loadingElement) {
|
||||
loadingElement.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async loadStaticHexagons() {
|
||||
console.log('🔄 Loading static hexagons for public sharing...');
|
||||
|
||||
try {
|
||||
// Calculate date range for the month
|
||||
const startDate = new Date(this.yearValue, this.monthValue - 1, 1);
|
||||
const endDate = new Date(this.yearValue, this.monthValue, 0, 23, 59, 59);
|
||||
|
||||
// Use the full data bounds for hexagon request (not current map viewport)
|
||||
const dataBounds = this.dataBoundsValue;
|
||||
|
||||
const params = new URLSearchParams({
|
||||
min_lon: dataBounds.min_lng,
|
||||
min_lat: dataBounds.min_lat,
|
||||
max_lon: dataBounds.max_lng,
|
||||
max_lat: dataBounds.max_lat,
|
||||
hex_size: 1000, // Fixed 1km hexagons
|
||||
start_date: startDate.toISOString(),
|
||||
end_date: endDate.toISOString(),
|
||||
uuid: this.uuidValue
|
||||
});
|
||||
|
||||
const url = `/api/v1/maps/hexagons?${params}`;
|
||||
console.log('📍 Fetching static hexagons from:', url);
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('Hexagon API error:', response.status, response.statusText, errorText);
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const geojsonData = await response.json();
|
||||
console.log(`✅ Loaded ${geojsonData.features?.length || 0} hexagons`);
|
||||
|
||||
// Add hexagons directly to map as a static layer
|
||||
if (geojsonData.features && geojsonData.features.length > 0) {
|
||||
this.addStaticHexagonsToMap(geojsonData);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to load static hexagons:', error);
|
||||
}
|
||||
}
|
||||
|
||||
addStaticHexagonsToMap(geojsonData) {
|
||||
// Calculate max point count for color scaling
|
||||
const maxPoints = Math.max(...geojsonData.features.map(f => f.properties.point_count));
|
||||
|
||||
const staticHexagonLayer = L.geoJSON(geojsonData, {
|
||||
style: (feature) => this.styleHexagon(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),
|
||||
mouseout: (e) => this.onHexagonMouseOut(e)
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
staticHexagonLayer.addTo(this.map);
|
||||
}
|
||||
|
||||
styleHexagon(feature, maxPoints) {
|
||||
const props = feature.properties;
|
||||
const pointCount = props.point_count || 0;
|
||||
|
||||
// Calculate opacity based on point density
|
||||
const opacity = 0.2 + (pointCount / maxPoints) * 0.6;
|
||||
const color = '#3388ff';
|
||||
|
||||
return {
|
||||
fillColor: color,
|
||||
fillOpacity: opacity,
|
||||
color: color,
|
||||
weight: 1,
|
||||
opacity: opacity + 0.2
|
||||
};
|
||||
}
|
||||
|
||||
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>
|
||||
`;
|
||||
}
|
||||
|
||||
onHexagonMouseOver(e) {
|
||||
const layer = e.target;
|
||||
// Store original style before changing
|
||||
if (!layer._originalStyle) {
|
||||
layer._originalStyle = {
|
||||
fillOpacity: layer.options.fillOpacity,
|
||||
weight: layer.options.weight,
|
||||
opacity: layer.options.opacity
|
||||
};
|
||||
}
|
||||
|
||||
layer.setStyle({
|
||||
fillOpacity: 0.8,
|
||||
weight: 2,
|
||||
opacity: 1.0
|
||||
});
|
||||
}
|
||||
|
||||
onHexagonMouseOut(e) {
|
||||
const layer = e.target;
|
||||
// Reset to stored original style
|
||||
if (layer._originalStyle) {
|
||||
layer.setStyle(layer._originalStyle);
|
||||
}
|
||||
}
|
||||
|
||||
// getDataBounds method removed - now using server-provided data bounds
|
||||
}
|
||||
379
app/javascript/maps/hexagon_grid.js
Normal file
379
app/javascript/maps/hexagon_grid.js
Normal file
|
|
@ -0,0 +1,379 @@
|
|||
/**
|
||||
* 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;
|
||||
|
||||
// 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>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
98
app/javascript/maps/hexagon_integration.js
Normal file
98
app/javascript/maps/hexagon_integration.js
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
/**
|
||||
* Integration script for adding hexagon grid to the existing maps controller
|
||||
* This file provides the integration code to be added to maps_controller.js
|
||||
*/
|
||||
|
||||
import { createHexagonGrid } from './hexagon_grid';
|
||||
|
||||
// Add this to the maps_controller.js connect() method after line 240 (after live map initialization)
|
||||
export function initializeHexagonGrid(controller) {
|
||||
// Create hexagon grid instance
|
||||
controller.hexagonGrid = createHexagonGrid(controller.map, {
|
||||
apiEndpoint: `/api/v1/maps/hexagons?api_key=${controller.apiKey}`,
|
||||
style: {
|
||||
fillColor: '#3388ff',
|
||||
fillOpacity: 0.1,
|
||||
color: '#3388ff',
|
||||
weight: 1,
|
||||
opacity: 0.5
|
||||
},
|
||||
debounceDelay: 300,
|
||||
maxZoom: 16, // Don't show hexagons beyond this zoom
|
||||
minZoom: 8 // Don't show hexagons below this zoom
|
||||
});
|
||||
|
||||
return controller.hexagonGrid;
|
||||
}
|
||||
|
||||
// Add this to the controlsLayer object in maps_controller.js (around line 194-205)
|
||||
export function addHexagonToLayerControl(controller) {
|
||||
// This should be added to the controlsLayer object:
|
||||
// "Hexagon Grid": controller.hexagonGrid?.hexagonLayer || L.layerGroup()
|
||||
|
||||
return {
|
||||
"Hexagon Grid": controller.hexagonGrid?.hexagonLayer || L.layerGroup()
|
||||
};
|
||||
}
|
||||
|
||||
// Add this to the disconnect() method cleanup
|
||||
export function cleanupHexagonGrid(controller) {
|
||||
if (controller.hexagonGrid) {
|
||||
controller.hexagonGrid.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
// Settings panel integration - add this to the settings form HTML (around line 843)
|
||||
export const hexagonSettingsHTML = `
|
||||
<label for="hexagon_grid_enabled">
|
||||
Hexagon Grid
|
||||
<label for="hexagon_grid_enabled_info" class="btn-xs join-item inline">?</label>
|
||||
<input type="checkbox" id="hexagon_grid_enabled" name="hexagon_grid_enabled" class='w-4' style="width: 20px;" />
|
||||
</label>
|
||||
<label for="hexagon_opacity">Hexagon Opacity, %</label>
|
||||
<div class="join">
|
||||
<input type="number" class="input input-ghost join-item focus:input-ghost input-xs input-bordered w-full max-w-xs" id="hexagon_opacity" name="hexagon_opacity" min="10" max="100" step="10" value="50">
|
||||
<label for="hexagon_opacity_info" class="btn-xs join-item">?</label>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Settings update handler - add this to updateSettings method
|
||||
export function updateHexagonSettings(controller, event) {
|
||||
const hexagonEnabled = event.target.hexagon_grid_enabled?.checked || false;
|
||||
const hexagonOpacity = (parseInt(event.target.hexagon_opacity?.value) || 50) / 100;
|
||||
|
||||
if (controller.hexagonGrid) {
|
||||
if (hexagonEnabled) {
|
||||
controller.hexagonGrid.show();
|
||||
controller.hexagonGrid.updateStyle({
|
||||
fillOpacity: hexagonOpacity * 0.2, // Scale down for fill
|
||||
opacity: hexagonOpacity
|
||||
});
|
||||
} else {
|
||||
controller.hexagonGrid.hide();
|
||||
}
|
||||
}
|
||||
|
||||
// Return the settings object to be sent to the server
|
||||
return {
|
||||
hexagon_grid_enabled: hexagonEnabled,
|
||||
hexagon_opacity: hexagonOpacity
|
||||
};
|
||||
}
|
||||
|
||||
// Layer control event handlers - add these to the overlayadd/overlayremove event listeners
|
||||
export function handleHexagonLayerEvents(controller, event) {
|
||||
if (event.name === 'Hexagon Grid') {
|
||||
if (event.type === 'overlayadd') {
|
||||
console.log('Hexagon Grid layer enabled via layer control');
|
||||
if (controller.hexagonGrid) {
|
||||
controller.hexagonGrid.show();
|
||||
}
|
||||
} else if (event.type === 'overlayremove') {
|
||||
console.log('Hexagon Grid layer disabled via layer control');
|
||||
if (controller.hexagonGrid) {
|
||||
controller.hexagonGrid.hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
286
app/services/maps/hexagon_grid.rb
Normal file
286
app/services/maps/hexagon_grid.rb
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Maps::HexagonGrid
|
||||
include ActiveModel::Validations
|
||||
|
||||
# Constants for configuration
|
||||
DEFAULT_HEX_SIZE = 500 # meters (center to edge)
|
||||
TARGET_HEX_EDGE_PX = 20 # pixels (edge length target)
|
||||
MAX_HEXAGONS_PER_REQUEST = 5000
|
||||
MAX_AREA_KM2 = 250_000 # 500km x 500km
|
||||
|
||||
# Validation error classes
|
||||
class BoundingBoxTooLargeError < StandardError; end
|
||||
class InvalidCoordinatesError < StandardError; end
|
||||
class PostGISError < StandardError; end
|
||||
|
||||
attr_reader :min_lon, :min_lat, :max_lon, :max_lat, :hex_size, :user_id, :start_date, :end_date, :viewport_width,
|
||||
:viewport_height
|
||||
|
||||
validates :min_lon, :max_lon, inclusion: { in: -180..180 }
|
||||
validates :min_lat, :max_lat, inclusion: { in: -90..90 }
|
||||
validates :hex_size, numericality: { greater_than: 0 }
|
||||
|
||||
validate :validate_bbox_order
|
||||
validate :validate_area_size
|
||||
|
||||
def initialize(params = {})
|
||||
@min_lon = params[:min_lon].to_f
|
||||
@min_lat = params[:min_lat].to_f
|
||||
@max_lon = params[:max_lon].to_f
|
||||
@max_lat = params[:max_lat].to_f
|
||||
@viewport_width = params[:viewport_width]&.to_f
|
||||
@viewport_height = params[:viewport_height]&.to_f
|
||||
@hex_size = calculate_dynamic_hex_size(params)
|
||||
@user_id = params[:user_id]
|
||||
@start_date = params[:start_date]
|
||||
@end_date = params[:end_date]
|
||||
end
|
||||
|
||||
def call
|
||||
validate!
|
||||
generate_hexagons
|
||||
end
|
||||
|
||||
def area_km2
|
||||
@area_km2 ||= calculate_area_km2
|
||||
end
|
||||
|
||||
def crosses_dateline?
|
||||
min_lon > max_lon
|
||||
end
|
||||
|
||||
def in_polar_region?
|
||||
max_lat.abs > 85 || min_lat.abs > 85
|
||||
end
|
||||
|
||||
def estimated_hexagon_count
|
||||
# Rough estimation based on area
|
||||
# A 500m radius hexagon covers approximately 0.65 km²
|
||||
hexagon_area_km2 = 0.65 * (hex_size / 500.0)**2
|
||||
(area_km2 / hexagon_area_km2).round
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def calculate_dynamic_hex_size(params)
|
||||
# If viewport dimensions are provided, calculate hex_size for 20px edge length
|
||||
if viewport_width && viewport_height && viewport_width > 0 && viewport_height > 0
|
||||
# Calculate the geographic width of the bounding box in meters
|
||||
avg_lat = (min_lat + max_lat) / 2
|
||||
bbox_width_degrees = (max_lon - min_lon).abs
|
||||
bbox_width_meters = bbox_width_degrees * 111_320 * Math.cos(avg_lat * Math::PI / 180)
|
||||
|
||||
# Calculate how many meters per pixel based on current viewport span (zoom-independent)
|
||||
meters_per_pixel = bbox_width_meters / viewport_width
|
||||
|
||||
# For a regular hexagon, the edge length is approximately 0.866 times the radius (center to vertex)
|
||||
# So if we want a 20px edge, we need: edge_length_meters = 20 * meters_per_pixel
|
||||
# And radius = edge_length / 0.866
|
||||
edge_length_meters = TARGET_HEX_EDGE_PX * meters_per_pixel
|
||||
hex_radius_meters = edge_length_meters / 0.866
|
||||
|
||||
# Clamp to reasonable bounds to prevent excessive computation
|
||||
calculated_size = hex_radius_meters.clamp(50, 10_000)
|
||||
|
||||
Rails.logger.debug "Dynamic hex size calculation: bbox_width=#{bbox_width_meters.round}m, viewport=#{viewport_width}px, meters_per_pixel=#{meters_per_pixel.round(2)}, hex_size=#{calculated_size.round}m"
|
||||
|
||||
calculated_size
|
||||
else
|
||||
# Fallback to provided hex_size or default
|
||||
fallback_size = params[:hex_size]&.to_f || DEFAULT_HEX_SIZE
|
||||
Rails.logger.debug "Using fallback hex size: #{fallback_size}m (no viewport dimensions provided)"
|
||||
fallback_size
|
||||
end
|
||||
end
|
||||
|
||||
def validate_bbox_order
|
||||
errors.add(:base, 'min_lon must be less than max_lon') if min_lon >= max_lon
|
||||
errors.add(:base, 'min_lat must be less than max_lat') if min_lat >= max_lat
|
||||
end
|
||||
|
||||
def validate_area_size
|
||||
return unless area_km2 > MAX_AREA_KM2
|
||||
|
||||
errors.add(:base, "Area too large (#{area_km2.round} km²). Maximum allowed: #{MAX_AREA_KM2} km²")
|
||||
end
|
||||
|
||||
def calculate_area_km2
|
||||
width = (max_lon - min_lon).abs
|
||||
height = (max_lat - min_lat).abs
|
||||
|
||||
# Convert degrees to approximate kilometers
|
||||
# 1 degree latitude ≈ 111 km
|
||||
# 1 degree longitude ≈ 111 km * cos(latitude)
|
||||
avg_lat = (min_lat + max_lat) / 2
|
||||
width_km = width * 111 * Math.cos(avg_lat * Math::PI / 180)
|
||||
height_km = height * 111
|
||||
|
||||
width_km * height_km
|
||||
end
|
||||
|
||||
def generate_hexagons
|
||||
sql = build_hexagon_sql
|
||||
|
||||
Rails.logger.debug "Generating hexagons for bbox: #{[min_lon, min_lat, max_lon, max_lat]}"
|
||||
Rails.logger.debug "Estimated hexagon count: #{estimated_hexagon_count}"
|
||||
|
||||
result = execute_sql(sql)
|
||||
format_hexagons(result)
|
||||
rescue ActiveRecord::StatementInvalid => e
|
||||
Rails.logger.error "PostGIS error generating hexagons: #{e.message}"
|
||||
raise PostGISError, "Failed to generate hexagon grid: #{e.message}"
|
||||
end
|
||||
|
||||
def build_hexagon_sql
|
||||
user_filter = user_id ? "user_id = #{user_id}" : '1=1'
|
||||
date_filter = build_date_filter
|
||||
|
||||
<<~SQL
|
||||
WITH bbox_geom AS (
|
||||
SELECT ST_MakeEnvelope(#{min_lon}, #{min_lat}, #{max_lon}, #{max_lat}, 4326) as geom
|
||||
),
|
||||
bbox_utm AS (
|
||||
SELECT
|
||||
ST_Transform(geom, 3857) as geom_utm,
|
||||
geom as geom_wgs84
|
||||
FROM bbox_geom
|
||||
),
|
||||
user_points AS (
|
||||
SELECT
|
||||
lonlat::geometry as point_geom,
|
||||
ST_Transform(lonlat::geometry, 3857) as point_geom_utm,
|
||||
id,
|
||||
timestamp
|
||||
FROM points
|
||||
WHERE #{user_filter}
|
||||
#{date_filter}
|
||||
AND ST_Intersects(
|
||||
lonlat::geometry,
|
||||
(SELECT geom FROM bbox_geom)
|
||||
)
|
||||
),
|
||||
hex_grid AS (
|
||||
SELECT
|
||||
(ST_HexagonGrid(#{hex_size}, bbox_utm.geom_utm)).geom as hex_geom_utm,
|
||||
(ST_HexagonGrid(#{hex_size}, bbox_utm.geom_utm)).i as hex_i,
|
||||
(ST_HexagonGrid(#{hex_size}, bbox_utm.geom_utm)).j as hex_j
|
||||
FROM bbox_utm
|
||||
),
|
||||
hexagons_with_points AS (
|
||||
SELECT DISTINCT
|
||||
hex_geom_utm,
|
||||
hex_i,
|
||||
hex_j
|
||||
FROM hex_grid hg
|
||||
INNER JOIN user_points up ON ST_Intersects(hg.hex_geom_utm, up.point_geom_utm)
|
||||
),
|
||||
hexagon_stats AS (
|
||||
SELECT
|
||||
hwp.hex_geom_utm,
|
||||
hwp.hex_i,
|
||||
hwp.hex_j,
|
||||
COUNT(up.id) as point_count,
|
||||
MIN(up.timestamp) as earliest_point,
|
||||
MAX(up.timestamp) as latest_point
|
||||
FROM hexagons_with_points hwp
|
||||
INNER JOIN user_points up ON ST_Intersects(hwp.hex_geom_utm, up.point_geom_utm)
|
||||
GROUP BY hwp.hex_geom_utm, hwp.hex_i, hwp.hex_j
|
||||
)
|
||||
SELECT
|
||||
ST_AsGeoJSON(ST_Transform(hex_geom_utm, 4326)) as geojson,
|
||||
hex_i,
|
||||
hex_j,
|
||||
point_count,
|
||||
earliest_point,
|
||||
latest_point,
|
||||
row_number() OVER (ORDER BY point_count DESC) as id
|
||||
FROM hexagon_stats
|
||||
ORDER BY point_count DESC
|
||||
LIMIT #{MAX_HEXAGONS_PER_REQUEST};
|
||||
SQL
|
||||
end
|
||||
|
||||
def build_date_filter
|
||||
return '' unless start_date || end_date
|
||||
|
||||
conditions = []
|
||||
conditions << "timestamp >= EXTRACT(EPOCH FROM '#{start_date}'::timestamp)" if start_date
|
||||
conditions << "timestamp <= EXTRACT(EPOCH FROM '#{end_date}'::timestamp)" if end_date
|
||||
|
||||
conditions.any? ? "AND #{conditions.join(' AND ')}" : ''
|
||||
end
|
||||
|
||||
def execute_sql(sql)
|
||||
ActiveRecord::Base.connection.execute(sql)
|
||||
end
|
||||
|
||||
def format_hexagons(result)
|
||||
total_points = 0
|
||||
|
||||
hexagons = result.map do |row|
|
||||
point_count = row['point_count'].to_i
|
||||
total_points += point_count
|
||||
|
||||
# Parse timestamps and format dates
|
||||
earliest = row['earliest_point'] ? Time.at(row['earliest_point'].to_f).iso8601 : nil
|
||||
latest = row['latest_point'] ? Time.at(row['latest_point'].to_f).iso8601 : nil
|
||||
|
||||
{
|
||||
type: 'Feature',
|
||||
id: row['id'],
|
||||
geometry: JSON.parse(row['geojson']),
|
||||
properties: {
|
||||
hex_id: row['id'],
|
||||
hex_i: row['hex_i'],
|
||||
hex_j: row['hex_j'],
|
||||
hex_size: hex_size,
|
||||
point_count: point_count,
|
||||
earliest_point: earliest,
|
||||
latest_point: latest,
|
||||
density: calculate_density(point_count)
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
Rails.logger.info "Generated #{hexagons.count} hexagons containing #{total_points} points for area #{area_km2.round(2)} km²"
|
||||
|
||||
{
|
||||
type: 'FeatureCollection',
|
||||
features: hexagons,
|
||||
metadata: {
|
||||
bbox: [min_lon, min_lat, max_lon, max_lat],
|
||||
area_km2: area_km2.round(2),
|
||||
hex_size_m: hex_size,
|
||||
count: hexagons.count,
|
||||
total_points: total_points,
|
||||
user_id: user_id,
|
||||
date_range: build_date_range_metadata
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
def calculate_density(point_count)
|
||||
# Calculate points per km² for the hexagon
|
||||
# A hexagon with radius 500m has area ≈ 0.65 km²
|
||||
hexagon_area_km2 = 0.65 * (hex_size / 500.0)**2
|
||||
(point_count / hexagon_area_km2).round(2)
|
||||
end
|
||||
|
||||
def build_date_range_metadata
|
||||
return nil unless start_date || end_date
|
||||
|
||||
{
|
||||
start_date: start_date,
|
||||
end_date: end_date
|
||||
}
|
||||
end
|
||||
|
||||
def validate!
|
||||
return if valid?
|
||||
|
||||
raise BoundingBoxTooLargeError, errors.full_messages.join(', ') if area_km2 > MAX_AREA_KM2
|
||||
|
||||
raise InvalidCoordinatesError, errors.full_messages.join(', ')
|
||||
end
|
||||
end
|
||||
|
|
@ -64,21 +64,22 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Map Summary - Restricted View -->
|
||||
<!-- Map Summary - Hexagon View -->
|
||||
<div class="card bg-base-100 shadow-xl mb-8">
|
||||
<div class="card-body">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="card-title">🗺️ Map Summary</h2>
|
||||
<div class="badge badge-warning">Limited view</div>
|
||||
<h2 class="card-title">🗺️ Activity Overview</h2>
|
||||
<div class="badge badge-warning">Privacy-safe view</div>
|
||||
</div>
|
||||
|
||||
<!-- Restricted Map Container -->
|
||||
<!-- Hexagon Map Container -->
|
||||
<div class="w-full h-96 rounded-lg border border-base-300 relative overflow-hidden">
|
||||
<div id="public-monthly-stats-map" class="w-full h-full"
|
||||
data-controller="public-stat-map"
|
||||
data-year="<%= @year %>"
|
||||
data-month="<%= @month %>"
|
||||
data-uuid="<%= @stat.sharing_uuid %>"></div>
|
||||
data-public-stat-map-year-value="<%= @year %>"
|
||||
data-public-stat-map-month-value="<%= @month %>"
|
||||
data-public-stat-map-uuid-value="<%= @stat.sharing_uuid %>"
|
||||
data-public-stat-map-data-bounds-value="<%= @data_bounds.to_json if @data_bounds %>"></div>
|
||||
|
||||
<!-- Loading overlay -->
|
||||
<div id="map-loading" class="absolute inset-0 bg-base-200 flex items-center justify-center">
|
||||
|
|
@ -176,51 +177,6 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stimulus Controller for Public Map -->
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const mapElement = document.getElementById('public-monthly-stats-map');
|
||||
if (mapElement) {
|
||||
initPublicMap(mapElement);
|
||||
}
|
||||
});
|
||||
|
||||
function initPublicMap(mapElement) {
|
||||
const year = mapElement.dataset.year;
|
||||
const month = mapElement.dataset.month;
|
||||
const uuid = mapElement.dataset.uuid;
|
||||
|
||||
// Initialize restricted map
|
||||
const map = L.map('public-monthly-stats-map', {
|
||||
zoomControl: false,
|
||||
scrollWheelZoom: false,
|
||||
doubleClickZoom: false,
|
||||
touchZoom: false,
|
||||
dragging: false,
|
||||
keyboard: false
|
||||
}).setView([40.0, -100.0], 4); // Default centered view
|
||||
|
||||
// Add tile layer
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap contributors',
|
||||
maxZoom: 10 // Limit zoom level for privacy
|
||||
}).addTo(map);
|
||||
|
||||
// Add restricted data overlay
|
||||
const restrictedOverlay = L.divIcon({
|
||||
className: 'restricted-map-overlay',
|
||||
html: '<div style="background: rgba(255,193,7,0.8); padding: 10px; border-radius: 5px; text-align: center; font-weight: bold;">Map interaction disabled for privacy</div>',
|
||||
iconSize: [200, 50]
|
||||
});
|
||||
|
||||
// Hide loading
|
||||
document.getElementById('map-loading').style.display = 'none';
|
||||
|
||||
// Show privacy notice on map
|
||||
setTimeout(() => {
|
||||
const marker = L.marker([40.0, -100.0], { icon: restrictedOverlay }).addTo(map);
|
||||
}, 1000);
|
||||
}
|
||||
</script>
|
||||
<!-- Map is now handled by the Stimulus controller -->
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -148,6 +148,11 @@ Rails.application.routes.draw do
|
|||
|
||||
namespace :maps do
|
||||
resources :tile_usage, only: [:create]
|
||||
resources :hexagons, only: [:index] do
|
||||
collection do
|
||||
get :bounds
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
post 'subscriptions/callback', to: 'subscriptions#callback'
|
||||
|
|
|
|||
292
hexagons_doc.md
Normal file
292
hexagons_doc.md
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
# Hexagonal Grid Overlay Implementation
|
||||
|
||||
This implementation adds a hexagonal grid overlay to the Leaflet map in your Ruby on Rails + PostGIS project. The grid displays ~1km hexagons that dynamically load based on the current map viewport.
|
||||
|
||||
## Components
|
||||
|
||||
### 1. Backend - Rails API Controller
|
||||
|
||||
**File**: `app/controllers/api/v1/maps/hexagons_controller.rb`
|
||||
|
||||
**Endpoint**: `GET /api/v1/maps/hexagons`
|
||||
|
||||
**Authentication**: Requires valid API key
|
||||
|
||||
**Parameters**:
|
||||
- `api_key`: User's API key (required)
|
||||
- `min_lon`, `min_lat`, `max_lon`, `max_lat`: Bounding box coordinates
|
||||
|
||||
**Features**:
|
||||
- Generates hexagons using PostGIS `ST_HexagonGrid`
|
||||
- 1km edge-to-edge hexagon size (~500m center-to-edge)
|
||||
- Maximum 5000 hexagons per request for performance
|
||||
- Validates bounding box size and coordinates
|
||||
- Handles edge cases (large areas, invalid coordinates)
|
||||
- Returns GeoJSON FeatureCollection
|
||||
|
||||
### 2. Frontend - JavaScript Module
|
||||
|
||||
**File**: `app/javascript/maps/hexagon_grid.js`
|
||||
|
||||
**Key Features**:
|
||||
- Efficient viewport-based loading with debouncing
|
||||
- Zoom-level restrictions (min: 8, max: 16)
|
||||
- Automatic cleanup and memory management
|
||||
- Hover effects and click handling
|
||||
- Request cancellation for pending requests
|
||||
|
||||
### 3. Integration
|
||||
|
||||
**File**: `app/javascript/controllers/maps_controller.js` (modified)
|
||||
|
||||
**Integration Points**:
|
||||
- Import and initialize hexagon grid
|
||||
- Add to layer control
|
||||
- Event handling for layer toggle
|
||||
- Cleanup on disconnect
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Usage
|
||||
|
||||
The hexagon grid will be available as a layer in the map's layer control panel. Users can toggle it on/off via the "Hexagon Grid" checkbox.
|
||||
|
||||
### Programmatic Control
|
||||
|
||||
```javascript
|
||||
// Show hexagons
|
||||
controller.hexagonGrid.show();
|
||||
|
||||
// Hide hexagons
|
||||
controller.hexagonGrid.hide();
|
||||
|
||||
// Toggle visibility
|
||||
controller.hexagonGrid.toggle();
|
||||
|
||||
// Update styling
|
||||
controller.hexagonGrid.updateStyle({
|
||||
fillColor: '#ff0000',
|
||||
fillOpacity: 0.2,
|
||||
color: '#ff0000',
|
||||
weight: 2,
|
||||
opacity: 0.8
|
||||
});
|
||||
```
|
||||
|
||||
## PostGIS SQL Example
|
||||
|
||||
Here's the core SQL that generates the hexagon grid:
|
||||
|
||||
```sql
|
||||
WITH bbox_geom AS (
|
||||
SELECT ST_MakeEnvelope(-74.0, 40.7, -73.9, 40.8, 4326) as geom
|
||||
),
|
||||
bbox_utm AS (
|
||||
SELECT
|
||||
ST_Transform(geom, 3857) as geom_utm,
|
||||
geom as geom_wgs84
|
||||
FROM bbox_geom
|
||||
),
|
||||
hex_grid AS (
|
||||
SELECT
|
||||
(ST_HexagonGrid(500, bbox_utm.geom_utm)).geom as hex_geom_utm
|
||||
FROM bbox_utm
|
||||
)
|
||||
SELECT
|
||||
ST_AsGeoJSON(ST_Transform(hex_geom_utm, 4326)) as geojson,
|
||||
row_number() OVER () as id
|
||||
FROM hex_grid
|
||||
WHERE ST_Intersects(
|
||||
hex_geom_utm,
|
||||
(SELECT geom_utm FROM bbox_utm)
|
||||
)
|
||||
LIMIT 5000;
|
||||
```
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Backend Optimizations
|
||||
|
||||
1. **Request Limiting**: Maximum 5000 hexagons per request
|
||||
2. **Area Validation**: Rejects requests for areas > 250,000 km²
|
||||
3. **Coordinate Validation**: Validates lat/lng bounds
|
||||
4. **Efficient PostGIS**: Uses `ST_HexagonGrid` with proper indexing
|
||||
|
||||
### Frontend Optimizations
|
||||
|
||||
1. **Debounced Loading**: 300ms delay prevents excessive API calls
|
||||
2. **Viewport-based Loading**: Only loads visible hexagons
|
||||
3. **Request Cancellation**: Cancels pending requests when new ones start
|
||||
4. **Memory Management**: Clears old hexagons before loading new ones
|
||||
5. **Zoom Restrictions**: Prevents loading at inappropriate zoom levels
|
||||
|
||||
## Edge Cases and Solutions
|
||||
|
||||
### 1. Large Bounding Boxes
|
||||
|
||||
**Problem**: User zooms out too far, requesting millions of hexagons
|
||||
**Solution**:
|
||||
- Backend validates area size (max 250,000 km²)
|
||||
- Returns 400 error with user-friendly message
|
||||
- Frontend handles error gracefully
|
||||
|
||||
### 2. Crossing the International Date Line
|
||||
|
||||
**Problem**: Bounding box crosses longitude 180/-180
|
||||
**Detection**: `min_lon > max_lon`
|
||||
**Solution**: Currently handled by PostGIS coordinate system transformation
|
||||
|
||||
### 3. Polar Regions
|
||||
|
||||
**Problem**: Hexagon distortion near poles
|
||||
**Detection**: Latitude > ±85°
|
||||
**Note**: Current implementation works with Web Mercator (EPSG:3857) limitations
|
||||
|
||||
### 4. Network Issues
|
||||
|
||||
**Problem**: API requests fail or timeout
|
||||
**Solutions**:
|
||||
- Request cancellation prevents multiple concurrent requests
|
||||
- Error handling with console logging
|
||||
- Graceful degradation (no hexagons shown, but map still works)
|
||||
|
||||
### 5. Performance on Low-End Devices
|
||||
|
||||
**Problem**: Too many hexagons cause rendering slowness
|
||||
**Solutions**:
|
||||
- Zoom level restrictions prevent overloading
|
||||
- Limited hexagon count per request
|
||||
- Efficient DOM manipulation with LayerGroup
|
||||
|
||||
## Configuration Options
|
||||
|
||||
### HexagonGrid Constructor Options
|
||||
|
||||
```javascript
|
||||
const 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
|
||||
maxZoom: 16, // Don't show beyond this zoom
|
||||
minZoom: 8 // Don't show below this zoom
|
||||
};
|
||||
```
|
||||
|
||||
### Backend Configuration
|
||||
|
||||
Edit `app/controllers/api/v1/maps/hexagons_controller.rb`:
|
||||
|
||||
```ruby
|
||||
# Change hexagon size (in meters, center to edge)
|
||||
hex_size = 500 # For ~1km edge-to-edge
|
||||
|
||||
# Change maximum hexagons per request
|
||||
MAX_HEXAGONS_PER_REQUEST = 5000
|
||||
|
||||
# Change area limit (km²)
|
||||
area_km2 > 250_000
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Manual Testing Steps
|
||||
|
||||
1. **Basic Functionality**:
|
||||
- Open map at various zoom levels
|
||||
- Toggle "Hexagon Grid" layer on/off
|
||||
- Verify hexagons load dynamically when panning
|
||||
|
||||
2. **Performance Testing**:
|
||||
- Zoom to maximum level and pan rapidly
|
||||
- Verify no memory leaks or excessive API calls
|
||||
- Test on slow connections
|
||||
|
||||
3. **Edge Case Testing**:
|
||||
- Zoom out very far (should show error handling)
|
||||
- Test near International Date Line
|
||||
- Test in polar regions
|
||||
|
||||
4. **API Testing**:
|
||||
```bash
|
||||
# Test valid request
|
||||
curl "http://localhost:3000/api/v1/maps/hexagons?api_key=YOUR_KEY&min_lon=-74&min_lat=40.7&max_lon=-73.9&max_lat=40.8"
|
||||
|
||||
# Test invalid bounding box
|
||||
curl "http://localhost:3000/api/v1/maps/hexagons?api_key=YOUR_KEY&min_lon=-180&min_lat=-90&max_lon=180&max_lat=90"
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Hexagons not appearing**:
|
||||
- Check console for API errors
|
||||
- Verify API key is valid
|
||||
- Check zoom level is within min/max range
|
||||
|
||||
2. **Performance issues**:
|
||||
- Reduce `MAX_HEXAGONS_PER_REQUEST`
|
||||
- Increase `minZoom` to prevent loading at low zoom levels
|
||||
- Check for JavaScript errors preventing cleanup
|
||||
|
||||
3. **Database errors**:
|
||||
- Ensure PostGIS extension is installed
|
||||
- Verify `ST_HexagonGrid` function is available (PostGIS 3.1+)
|
||||
- Check coordinate system support
|
||||
|
||||
### Debug Information
|
||||
|
||||
Enable debug logging:
|
||||
|
||||
```javascript
|
||||
// Add to hexagon_grid.js constructor
|
||||
console.log('HexagonGrid initialized with options:', options);
|
||||
|
||||
// Add to loadHexagons method
|
||||
console.log('Loading hexagons for bounds:', bounds);
|
||||
```
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### Potential Improvements
|
||||
|
||||
1. **Caching**: Add Redis caching for frequently requested areas
|
||||
2. **Clustering**: Group nearby hexagons at low zoom levels
|
||||
3. **Data Visualization**: Color hexagons based on data (point density, etc.)
|
||||
4. **Custom Shapes**: Allow other grid patterns (squares, triangles)
|
||||
5. **Persistent Settings**: Remember user's hexagon visibility preference
|
||||
|
||||
### Performance Optimizations
|
||||
|
||||
1. **Server-side Caching**: Cache generated hexagon grids
|
||||
2. **Tile-based Loading**: Load hexagons in tile-like chunks
|
||||
3. **Progressive Enhancement**: Load lower resolution first, then refine
|
||||
4. **WebWorker Integration**: Move heavy calculations to background thread
|
||||
|
||||
## Dependencies
|
||||
|
||||
### Required
|
||||
|
||||
- **PostGIS 3.1+**: For `ST_HexagonGrid` function
|
||||
- **Leaflet**: Frontend mapping library
|
||||
- **Rails 6+**: Backend framework
|
||||
|
||||
### Optional
|
||||
|
||||
- **Redis**: For caching (future enhancement)
|
||||
- **Sidekiq**: For background processing (future enhancement)
|
||||
|
||||
## License and Credits
|
||||
|
||||
This implementation uses:
|
||||
- PostGIS for spatial calculations
|
||||
- Leaflet for map visualization
|
||||
- Ruby on Rails for API backend
|
||||
|
||||
The hexagon grid generation leverages PostGIS's built-in `ST_HexagonGrid` function for optimal performance and accuracy.
|
||||
Loading…
Reference in a new issue