From f578288ac9007606d68d8118a023b3c72b6e0b9b Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Sun, 24 Aug 2025 15:51:52 +0200 Subject: [PATCH] Set default size of a hexagon --- .../api/v1/maps/hexagons_controller.rb | 2 +- app/javascript/maps/hexagon_grid.js | 54 +++++++++++-------- app/services/maps/hexagon_grid.rb | 38 ++++++++++++- 3 files changed, 68 insertions(+), 26 deletions(-) diff --git a/app/controllers/api/v1/maps/hexagons_controller.rb b/app/controllers/api/v1/maps/hexagons_controller.rb index cf08dc93..84e47971 100644 --- a/app/controllers/api/v1/maps/hexagons_controller.rb +++ b/app/controllers/api/v1/maps/hexagons_controller.rb @@ -22,7 +22,7 @@ class Api::V1::Maps::HexagonsController < ApiController private def bbox_params - params.permit(:min_lon, :min_lat, :max_lon, :max_lat, :hex_size) + params.permit(:min_lon, :min_lat, :max_lon, :max_lat, :hex_size, :viewport_width, :viewport_height) end def hexagon_params diff --git a/app/javascript/maps/hexagon_grid.js b/app/javascript/maps/hexagon_grid.js index 709121f8..6aff8cbe 100644 --- a/app/javascript/maps/hexagon_grid.js +++ b/app/javascript/maps/hexagon_grid.js @@ -19,23 +19,23 @@ export class HexagonGrid { 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(); @@ -94,7 +94,7 @@ export class HexagonGrid { } const currentBounds = this.map.getBounds(); - + // Only reload if bounds have changed significantly if (this.boundsChanged(currentBounds)) { this.loadHexagons(); @@ -187,11 +187,18 @@ export class HexagonGrid { 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() + max_lat: bounds.getNorth(), + viewport_width: viewportWidth, + viewport_height: viewportHeight }); // Add date parameters if they exist @@ -210,7 +217,7 @@ export class HexagonGrid { } const geojsonData = await response.json(); - + // Clear existing hexagons and add new ones this.clearHexagons(); this.addHexagonsToMap(geojsonData); @@ -252,7 +259,7 @@ export class HexagonGrid { // 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) => { @@ -279,21 +286,22 @@ export class HexagonGrid { 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'; // 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 - } + 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, @@ -310,7 +318,7 @@ export class HexagonGrid { 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 `

Hexagon Stats

@@ -356,7 +364,7 @@ export class HexagonGrid { */ updateStyle(newStyle) { this.options.style = { ...this.options.style, ...newStyle }; - + // Update existing hexagons this.hexagonLayer.eachLayer((layer) => { if (layer.setStyle) { @@ -400,4 +408,4 @@ export function createHexagonGrid(map, options = {}) { } // Default export -export default HexagonGrid; \ No newline at end of file +export default HexagonGrid; diff --git a/app/services/maps/hexagon_grid.rb b/app/services/maps/hexagon_grid.rb index 29a5bc6d..c9f5d66a 100644 --- a/app/services/maps/hexagon_grid.rb +++ b/app/services/maps/hexagon_grid.rb @@ -5,6 +5,7 @@ class Maps::HexagonGrid # 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 @@ -13,7 +14,7 @@ class Maps::HexagonGrid 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 + 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 } @@ -27,7 +28,9 @@ class Maps::HexagonGrid @min_lat = params[:min_lat].to_f @max_lon = params[:max_lon].to_f @max_lat = params[:max_lat].to_f - @hex_size = params[:hex_size]&.to_f || DEFAULT_HEX_SIZE + @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] @@ -59,6 +62,37 @@ class Maps::HexagonGrid 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