Add hexagons to public stats page

This commit is contained in:
Eugene Burmakin 2025-09-12 08:33:51 +02:00
parent 5cd05f8d3a
commit 612c30026c
10 changed files with 1480 additions and 54 deletions

File diff suppressed because one or more lines are too long

View 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

View file

@ -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

View 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
}

View 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;

View 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();
}
}
}
}

View 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

View file

@ -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>

View file

@ -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
View 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.