diff --git a/Gemfile b/Gemfile
index f876777c..d9bd57d7 100644
--- a/Gemfile
+++ b/Gemfile
@@ -17,6 +17,7 @@ gem 'devise'
gem 'geocoder', github: 'Freika/geocoder', branch: 'master'
gem 'gpx'
gem 'groupdate'
+gem 'h3', '~> 3.7'
gem 'httparty'
gem 'importmap-rails'
gem 'jwt', '~> 2.8'
diff --git a/Gemfile.lock b/Gemfile.lock
index 882a41ad..859df11a 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -172,6 +172,12 @@ GEM
railties (>= 6.1.0)
fakeredis (0.1.4)
ffaker (2.24.0)
+ ffi (1.17.2-aarch64-linux-gnu)
+ ffi (1.17.2-arm-linux-gnu)
+ ffi (1.17.2-arm64-darwin)
+ ffi (1.17.2-x86-linux-gnu)
+ ffi (1.17.2-x86_64-darwin)
+ ffi (1.17.2-x86_64-linux-gnu)
foreman (0.90.0)
thor (~> 1.4)
fugit (1.11.1)
@@ -185,6 +191,10 @@ GEM
rake
groupdate (6.7.0)
activesupport (>= 7.1)
+ h3 (3.7.4)
+ ffi (~> 1.9)
+ rgeo-geojson (~> 2.1)
+ zeitwerk (~> 2.5)
hashdiff (1.1.2)
httparty (0.23.1)
csv
@@ -543,6 +553,7 @@ DEPENDENCIES
geocoder!
gpx
groupdate
+ h3 (~> 3.7)
httparty
importmap-rails
jwt (~> 2.8)
diff --git a/app/controllers/api/v1/maps/hexagons_controller.rb b/app/controllers/api/v1/maps/hexagons_controller.rb
index 64abb4e3..6ed8de66 100644
--- a/app/controllers/api/v1/maps/hexagons_controller.rb
+++ b/app/controllers/api/v1/maps/hexagons_controller.rb
@@ -2,10 +2,9 @@
class Api::V1::Maps::HexagonsController < ApiController
skip_before_action :authenticate_api_key, if: :public_sharing_request?
- before_action :validate_bbox_params, except: [:bounds]
def index
- result = Maps::HexagonRequestHandler.call(
+ result = Maps::H3HexagonRenderer.call(
params: params,
current_api_user: current_api_user
)
@@ -15,11 +14,10 @@ class Api::V1::Maps::HexagonsController < ApiController
render json: { error: e.message }, status: :not_found
rescue Maps::DateParameterCoercer::InvalidDateFormatError => e
render json: { error: e.message }, status: :bad_request
- rescue Maps::HexagonGrid::BoundingBoxTooLargeError,
- Maps::HexagonGrid::InvalidCoordinatesError => e
+ rescue Maps::H3HexagonCenters::TooManyHexagonsError,
+ Maps::H3HexagonCenters::InvalidCoordinatesError,
+ Maps::H3HexagonCenters::PostGISError => e
render json: { error: e.message }, status: :bad_request
- rescue Maps::HexagonGrid::PostGISError => e
- render json: { error: e.message }, status: :internal_server_error
rescue StandardError => _e
handle_service_error
end
@@ -56,8 +54,8 @@ class Api::V1::Maps::HexagonsController < ApiController
private
- def bbox_params
- params.permit(:min_lon, :min_lat, :max_lon, :max_lat, :hex_size, :viewport_width, :viewport_height)
+ def hexagon_params
+ params.permit(:h3_resolution, :uuid, :start_date, :end_date)
end
def handle_service_error
@@ -67,15 +65,4 @@ class Api::V1::Maps::HexagonsController < ApiController
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
diff --git a/app/javascript/controllers/public_stat_map_controller.js b/app/javascript/controllers/public_stat_map_controller.js
index 2e2acb12..6fa576a7 100644
--- a/app/javascript/controllers/public_stat_map_controller.js
+++ b/app/javascript/controllers/public_stat_map_controller.js
@@ -1,5 +1,4 @@
import L from "leaflet";
-import { createHexagonGrid } from "../maps/hexagon_grid";
import { createAllMapLayers } from "../maps/layers";
import BaseController from "./base_controller";
@@ -18,6 +17,7 @@ export default class extends BaseController {
super.connect();
console.log('🏁 Controller connected - loading overlay should be visible');
this.selfHosted = this.selfHostedValue || 'false';
+ this.currentHexagonLayer = null;
this.initializeMap();
this.loadHexagons();
}
@@ -43,8 +43,8 @@ export default class extends BaseController {
// Add dynamic tile layer based on self-hosted setting
this.addMapLayers();
- // Default view
- this.map.setView([40.0, -100.0], 4);
+ // Default view with higher zoom level for better hexagon detail
+ this.map.setView([40.0, -100.0], 9);
}
addMapLayers() {
@@ -100,10 +100,7 @@ export default class extends BaseController {
console.log('📊 After fitBounds overlay display:', afterFitBoundsElement?.style.display || 'default');
}
- // Don't create hexagonGrid for public sharing - we handle hexagons manually
- // this.hexagonGrid = createHexagonGrid(this.map, {...});
-
- console.log('🎯 Public sharing: skipping HexagonGrid creation, using manual loading');
+ console.log('🎯 Public sharing: using manual hexagon loading');
console.log('🔍 Debug values:');
console.log(' dataBounds:', dataBounds);
console.log(' point_count:', dataBounds?.point_count);
@@ -177,7 +174,7 @@ export default class extends BaseController {
min_lat: dataBounds.min_lat,
max_lon: dataBounds.max_lng,
max_lat: dataBounds.max_lat,
- hex_size: 1000, // Fixed 1km hexagons
+ h3_resolution: 8,
start_date: startDate.toISOString(),
end_date: endDate.toISOString(),
uuid: this.uuidValue
@@ -228,6 +225,11 @@ export default class extends BaseController {
}
addStaticHexagonsToMap(geojsonData) {
+ // Remove existing hexagon layer if it exists
+ if (this.currentHexagonLayer) {
+ this.map.removeLayer(this.currentHexagonLayer);
+ }
+
// Calculate max point count for color scaling
const maxPoints = Math.max(...geojsonData.features.map(f => f.properties.point_count));
@@ -247,6 +249,7 @@ export default class extends BaseController {
}
});
+ this.currentHexagonLayer = staticHexagonLayer;
staticHexagonLayer.addTo(this.map);
}
@@ -263,11 +266,31 @@ export default class extends BaseController {
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';
+ const startTime = props.earliest_point ? new Date(props.earliest_point).toLocaleTimeString() : '';
+ const endTime = props.latest_point ? new Date(props.latest_point).toLocaleTimeString() : '';
return `
-
-
Date Range:
-
${startDate} - ${endDate}
+
+
📍 Location Data
+
+ Points: ${props.point_count || 0}
+
+ ${props.h3_index ? `
+
+ H3 Index:
+ ${props.h3_index}
+
+ ` : ''}
+
+ Time Range:
+ ${startDate} ${startTime}
→ ${endDate} ${endTime}
+
+ ${props.center ? `
+
+ Center:
+ ${props.center[0].toFixed(6)}, ${props.center[1].toFixed(6)}
+
+ ` : ''}
`;
}
@@ -298,4 +321,5 @@ export default class extends BaseController {
}
}
+
}
diff --git a/app/javascript/maps/hexagon_grid.js b/app/javascript/maps/hexagon_grid.js
deleted file mode 100644
index 87c2be93..00000000
--- a/app/javascript/maps/hexagon_grid.js
+++ /dev/null
@@ -1,363 +0,0 @@
-/**
- * HexagonGrid - Manages hexagonal grid overlay on Leaflet maps
- * Provides efficient loading and rendering of hexagon tiles based on viewport
- */
-export class HexagonGrid {
- constructor(map, options = {}) {
- this.map = map;
- this.options = {
- apiEndpoint: '/api/v1/maps/hexagons',
- style: {
- fillColor: '#3388ff',
- fillOpacity: 0.1,
- color: '#3388ff',
- weight: 1,
- opacity: 0.5
- },
- debounceDelay: 300, // ms to wait before loading new hexagons
- maxZoom: 18, // Don't show hexagons beyond this zoom level
- minZoom: 8, // Don't show hexagons below this zoom level
- ...options
- };
-
- this.hexagonLayer = null;
- this.loadingController = null; // For aborting requests
- this.lastBounds = null;
- this.isVisible = false;
-
- this.init();
- }
-
- init() {
- // Create the hexagon layer group
- this.hexagonLayer = L.layerGroup();
-
- // Bind map events
- this.map.on('moveend', this.debounce(this.onMapMove.bind(this), this.options.debounceDelay));
- this.map.on('zoomend', this.onZoomChange.bind(this));
-
- // Initial load if within zoom range
- if (this.shouldShowHexagons()) {
- this.show();
- }
- }
-
- /**
- * Show the hexagon grid overlay
- */
- show() {
- if (!this.isVisible) {
- this.isVisible = true;
- if (this.shouldShowHexagons()) {
- this.hexagonLayer.addTo(this.map);
- this.loadHexagons();
- }
- }
- }
-
- /**
- * Hide the hexagon grid overlay
- */
- hide() {
- if (this.isVisible) {
- this.isVisible = false;
- this.hexagonLayer.remove();
- this.cancelPendingRequest();
- }
- }
-
- /**
- * Toggle visibility of hexagon grid
- */
- toggle() {
- if (this.isVisible) {
- this.hide();
- } else {
- this.show();
- }
- }
-
- /**
- * Check if hexagons should be displayed at current zoom level
- */
- shouldShowHexagons() {
- const zoom = this.map.getZoom();
- return zoom >= this.options.minZoom && zoom <= this.options.maxZoom;
- }
-
- /**
- * Handle map move events
- */
- onMapMove() {
- if (!this.isVisible || !this.shouldShowHexagons()) {
- return;
- }
-
- const currentBounds = this.map.getBounds();
-
- // Only reload if bounds have changed significantly
- if (this.boundsChanged(currentBounds)) {
- this.loadHexagons();
- }
- }
-
- /**
- * Handle zoom change events
- */
- onZoomChange() {
- if (!this.isVisible) {
- return;
- }
-
- if (this.shouldShowHexagons()) {
- // Show hexagons and load for new zoom level
- if (!this.map.hasLayer(this.hexagonLayer)) {
- this.hexagonLayer.addTo(this.map);
- }
- this.loadHexagons();
- } else {
- // Hide hexagons when zoomed too far in/out
- this.hexagonLayer.remove();
- this.cancelPendingRequest();
- }
- }
-
- /**
- * Check if bounds have changed enough to warrant reloading
- */
- boundsChanged(newBounds) {
- if (!this.lastBounds) {
- return true;
- }
-
- const threshold = 0.1; // 10% change threshold
- const oldArea = this.getBoundsArea(this.lastBounds);
- const newArea = this.getBoundsArea(newBounds);
- const intersection = this.getBoundsIntersection(this.lastBounds, newBounds);
- const intersectionRatio = intersection / Math.min(oldArea, newArea);
-
- return intersectionRatio < (1 - threshold);
- }
-
- /**
- * Calculate approximate area of bounds
- */
- getBoundsArea(bounds) {
- const sw = bounds.getSouthWest();
- const ne = bounds.getNorthEast();
- return (ne.lat - sw.lat) * (ne.lng - sw.lng);
- }
-
- /**
- * Calculate intersection area between two bounds
- */
- getBoundsIntersection(bounds1, bounds2) {
- const sw1 = bounds1.getSouthWest();
- const ne1 = bounds1.getNorthEast();
- const sw2 = bounds2.getSouthWest();
- const ne2 = bounds2.getNorthEast();
-
- const left = Math.max(sw1.lng, sw2.lng);
- const right = Math.min(ne1.lng, ne2.lng);
- const bottom = Math.max(sw1.lat, sw2.lat);
- const top = Math.min(ne1.lat, ne2.lat);
-
- if (left < right && bottom < top) {
- return (right - left) * (top - bottom);
- }
- return 0;
- }
-
- /**
- * Load hexagons for current viewport
- */
- async loadHexagons() {
- console.log('❌ Using ORIGINAL loadHexagons method (should not happen for public sharing)');
-
- // Cancel any pending request
- this.cancelPendingRequest();
-
- const bounds = this.map.getBounds();
- this.lastBounds = bounds;
-
- // Create new AbortController for this request
- this.loadingController = new AbortController();
-
- try {
- // Get current date range from URL parameters
- const urlParams = new URLSearchParams(window.location.search);
- const startDate = urlParams.get('start_at');
- const endDate = urlParams.get('end_at');
-
- // Get viewport dimensions
- const mapContainer = this.map.getContainer();
- const viewportWidth = mapContainer.offsetWidth;
- const viewportHeight = mapContainer.offsetHeight;
-
- const params = new URLSearchParams({
- min_lon: bounds.getWest(),
- min_lat: bounds.getSouth(),
- max_lon: bounds.getEast(),
- max_lat: bounds.getNorth(),
- viewport_width: viewportWidth,
- viewport_height: viewportHeight
- });
-
- // Add date parameters if they exist
- if (startDate) params.append('start_date', startDate);
- if (endDate) params.append('end_date', endDate);
-
- const response = await fetch(`${this.options.apiEndpoint}?${params}`, {
- signal: this.loadingController.signal,
- headers: {
- 'Content-Type': 'application/json'
- }
- });
-
- if (!response.ok) {
- throw new Error(`HTTP ${response.status}: ${response.statusText}`);
- }
-
- const geojsonData = await response.json();
-
- // Clear existing hexagons and add new ones
- this.clearHexagons();
- this.addHexagonsToMap(geojsonData);
-
- } catch (error) {
- if (error.name !== 'AbortError') {
- console.error('Failed to load hexagons:', error);
- // Optionally show user-friendly error message
- }
- } finally {
- this.loadingController = null;
- }
- }
-
- /**
- * Cancel pending hexagon loading request
- */
- cancelPendingRequest() {
- if (this.loadingController) {
- this.loadingController.abort();
- this.loadingController = null;
- }
- }
-
- /**
- * Clear existing hexagons from the map
- */
- clearHexagons() {
- this.hexagonLayer.clearLayers();
- }
-
- /**
- * Add hexagons to the map from GeoJSON data
- */
- addHexagonsToMap(geojsonData) {
- if (!geojsonData.features || geojsonData.features.length === 0) {
- return;
- }
-
- // Calculate max point count for color scaling
- const maxPoints = Math.max(...geojsonData.features.map(f => f.properties.point_count));
-
- const geoJsonLayer = L.geoJSON(geojsonData, {
- style: (feature) => this.styleHexagonByData(feature, maxPoints),
- onEachFeature: (feature, layer) => {
- // Add popup with statistics
- const props = feature.properties;
- const popupContent = this.buildPopupContent(props);
- layer.bindPopup(popupContent);
- }
- });
-
- geoJsonLayer.addTo(this.hexagonLayer);
- }
-
- /**
- * Style hexagon based on point density and other data
- */
- styleHexagonByData(feature, maxPoints) {
- const props = feature.properties;
- const pointCount = props.point_count || 0;
-
- // Calculate opacity based on point density (0.2 to 0.8)
- const opacity = 0.2 + (pointCount / maxPoints) * 0.6;
-
- let color = '#3388ff'
-
- return {
- fillColor: color,
- fillOpacity: opacity,
- color: color,
- weight: 1,
- opacity: opacity + 0.2
- };
- }
-
- /**
- * Build popup content with hexagon statistics
- */
- buildPopupContent(props) {
- const startDate = props.earliest_point ? new Date(props.earliest_point).toLocaleDateString() : 'N/A';
- const endDate = props.latest_point ? new Date(props.latest_point).toLocaleDateString() : 'N/A';
-
- return `
-
- Date Range:
- ${startDate} - ${endDate}
-
- `;
- }
-
- /**
- * 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;
diff --git a/app/queries/hexagon_query.rb b/app/queries/hexagon_query.rb
deleted file mode 100644
index 0eb105cb..00000000
--- a/app/queries/hexagon_query.rb
+++ /dev/null
@@ -1,142 +0,0 @@
-# frozen_string_literal: true
-
-class HexagonQuery
- # Maximum number of hexagons to return in a single request
- MAX_HEXAGONS_PER_REQUEST = 5000
-
- attr_reader :min_lon, :min_lat, :max_lon, :max_lat, :hex_size, :user_id, :start_date, :end_date
-
- def initialize(min_lon:, min_lat:, max_lon:, max_lat:, hex_size:, user_id: nil, start_date: nil, end_date: nil)
- @min_lon = min_lon
- @min_lat = min_lat
- @max_lon = max_lon
- @max_lat = max_lat
- @hex_size = hex_size
- @user_id = user_id
- @start_date = start_date
- @end_date = end_date
- end
-
- def call
- binds = []
- user_sql = build_user_filter(binds)
- date_filter = build_date_filter(binds)
-
- sql = build_hexagon_sql(user_sql, date_filter)
-
- ActiveRecord::Base.connection.exec_query(sql, 'hexagon_sql', binds)
- end
-
- private
-
- def build_hexagon_sql(user_sql, date_filter)
- <<~SQL
- WITH bbox_geom AS (
- SELECT ST_MakeEnvelope($1, $2, $3, $4, 4326) as geom
- ),
- bbox_utm AS (
- SELECT ST_Transform(geom, 3857) as geom_utm 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_sql}
- #{date_filter}
- AND lonlat && (SELECT geom FROM bbox_geom)
- ),
- hex_grid AS (
- SELECT
- (ST_HexagonGrid($5, geom_utm)).geom as hex_geom_utm,
- (ST_HexagonGrid($5, geom_utm)).i as hex_i,
- (ST_HexagonGrid($5, geom_utm)).j as hex_j
- FROM bbox_utm
- ),
- hexagons_with_points AS (
- SELECT DISTINCT
- hg.hex_geom_utm,
- hg.hex_i,
- hg.hex_j
- FROM hex_grid hg
- 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
- 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 $6;
- SQL
- end
-
- def build_user_filter(binds)
- # Add bbox coordinates: min_lon, min_lat, max_lon, max_lat
- binds << min_lon
- binds << min_lat
- binds << max_lon
- binds << max_lat
-
- # Add hex_size
- binds << hex_size
-
- # Add limit
- binds << MAX_HEXAGONS_PER_REQUEST
-
- if user_id
- binds << user_id
- 'user_id = $7'
- else
- '1=1'
- end
- end
-
- def build_date_filter(binds)
- return '' unless start_date || end_date
-
- conditions = []
- current_param_index = user_id ? 8 : 7 # Account for bbox, hex_size, limit, and potential user_id
-
- if start_date
- start_timestamp = parse_date_to_timestamp(start_date)
- binds << start_timestamp
- conditions << "timestamp >= $#{current_param_index}"
- current_param_index += 1
- end
-
- if end_date
- end_timestamp = parse_date_to_timestamp(end_date)
- binds << end_timestamp
- conditions << "timestamp <= $#{current_param_index}"
- end
-
- conditions.any? ? "AND #{conditions.join(' AND ')}" : ''
- end
-
- def parse_date_to_timestamp(date_string)
- # Convert ISO date string to timestamp integer
- Time.parse(date_string).to_i
- rescue ArgumentError => e
- ExceptionReporter.call(e, "Invalid date format: #{date_string}")
- raise ArgumentError, "Invalid date format: #{date_string}"
- end
-end
diff --git a/app/services/hexagon_cache_service.rb b/app/services/hexagon_cache_service.rb
deleted file mode 100644
index 87f51808..00000000
--- a/app/services/hexagon_cache_service.rb
+++ /dev/null
@@ -1,57 +0,0 @@
-# frozen_string_literal: true
-
-class HexagonCacheService
- def initialize(user:, stat: nil, start_date: nil, end_date: nil)
- @user = user
- @stat = stat
- @start_date = start_date
- @end_date = end_date
- end
-
- def available?(hex_size)
- return false unless @user
- return false unless hex_size.to_i == 1000
-
- target_stat&.hexagons_available?(hex_size)
- end
-
- def cached_geojson(hex_size)
- return nil unless target_stat
-
- target_stat.hexagon_data.dig(hex_size.to_s, 'geojson')
- rescue StandardError => e
- Rails.logger.warn "Failed to retrieve cached hexagon data: #{e.message}"
- nil
- end
-
- private
-
- attr_reader :user, :stat, :start_date, :end_date
-
- def target_stat
- @target_stat ||= stat || find_monthly_stat
- end
-
- def find_monthly_stat
- return nil unless start_date && end_date
-
- begin
- start_time = Time.zone.parse(start_date)
- end_time = Time.zone.parse(end_date)
-
- # Only use cached data for exact monthly requests
- return nil unless monthly_date_range?(start_time, end_time)
-
- user.stats.find_by(year: start_time.year, month: start_time.month)
- rescue StandardError
- nil
- end
- end
-
- def monthly_date_range?(start_time, end_time)
- start_time.beginning_of_month == start_time &&
- end_time.end_of_month.beginning_of_day.to_date == end_time.to_date &&
- start_time.month == end_time.month &&
- start_time.year == end_time.year
- end
-end
diff --git a/app/services/maps/date_parameter_coercer.rb b/app/services/maps/date_parameter_coercer.rb
index 0c91e576..64737d4c 100644
--- a/app/services/maps/date_parameter_coercer.rb
+++ b/app/services/maps/date_parameter_coercer.rb
@@ -23,12 +23,7 @@ module Maps
def coerce_date(param)
case param
when String
- # Check if it's a numeric string (timestamp) or date string
- if param.match?(/^\d+$/)
- param.to_i
- else
- Time.parse(param).to_i
- end
+ coerce_string_param(param)
when Integer
param
else
@@ -38,5 +33,14 @@ module Maps
Rails.logger.error "Invalid date format: #{param} - #{e.message}"
raise InvalidDateFormatError, "Invalid date format: #{param}"
end
+
+ def coerce_string_param(param)
+ # Check if it's a numeric string (timestamp) or date string
+ if param.match?(/^\d+$/)
+ param.to_i
+ else
+ Time.parse(param).to_i
+ end
+ end
end
end
diff --git a/app/services/maps/h3_hexagon_calculator.rb b/app/services/maps/h3_hexagon_calculator.rb
new file mode 100644
index 00000000..639d5ae2
--- /dev/null
+++ b/app/services/maps/h3_hexagon_calculator.rb
@@ -0,0 +1,84 @@
+# frozen_string_literal: true
+
+module Maps
+ class H3HexagonCalculator
+ def initialize(user_id, start_date, end_date, h3_resolution = 5)
+ @user_id = user_id
+ @start_date = start_date
+ @end_date = end_date
+ @h3_resolution = h3_resolution
+ end
+
+ def call
+ user_points = fetch_user_points
+ return { success: false, error: 'No points found for the given date range' } if user_points.empty?
+
+ h3_indexes = calculate_h3_indexes(user_points)
+ hexagon_features = build_hexagon_features(h3_indexes)
+
+ {
+ success: true,
+ data: {
+ type: 'FeatureCollection',
+ features: hexagon_features
+ }
+ }
+ rescue StandardError => e
+ { success: false, error: e.message }
+ end
+
+ private
+
+ attr_reader :user_id, :start_date, :end_date, :h3_resolution
+
+ def fetch_user_points
+ Point.where(user_id: user_id)
+ .where(timestamp: start_date.to_i..end_date.to_i)
+ .where.not(lonlat: nil)
+ .select(:id, :lonlat, :timestamp)
+ end
+
+ def calculate_h3_indexes(points)
+ h3_counts = Hash.new(0)
+
+ points.find_each do |point|
+ # Convert PostGIS point to lat/lng array: [lat, lng]
+ coordinates = [point.lonlat.y, point.lonlat.x]
+
+ # Get H3 index for these coordinates at specified resolution
+ h3_index = H3.from_geo_coordinates(coordinates, h3_resolution)
+
+ # Count points in each hexagon
+ h3_counts[h3_index] += 1
+ end
+
+ h3_counts
+ end
+
+ def build_hexagon_features(h3_counts)
+ h3_counts.map do |h3_index, point_count|
+ # Get the boundary coordinates for this H3 hexagon
+ boundary_coordinates = H3.to_boundary(h3_index)
+
+ # Convert to GeoJSON polygon format (lng, lat)
+ polygon_coordinates = boundary_coordinates.map { |lat, lng| [lng, lat] }
+
+ # Close the polygon by adding the first point at the end
+ polygon_coordinates << polygon_coordinates.first
+
+ {
+ type: 'Feature',
+ geometry: {
+ type: 'Polygon',
+ coordinates: [polygon_coordinates]
+ },
+ properties: {
+ h3_index: h3_index.to_s(16),
+ point_count: point_count,
+ center: H3.to_geo_coordinates(h3_index)
+ }
+ }
+ end
+ end
+ end
+end
\ No newline at end of file
diff --git a/app/services/maps/h3_hexagon_centers.rb b/app/services/maps/h3_hexagon_centers.rb
new file mode 100644
index 00000000..5911f6df
--- /dev/null
+++ b/app/services/maps/h3_hexagon_centers.rb
@@ -0,0 +1,128 @@
+# frozen_string_literal: true
+
+class Maps::H3HexagonCenters
+ include ActiveModel::Validations
+
+ # H3 Configuration
+ DEFAULT_H3_RESOLUTION = 8 # Small hexagons for good detail
+ MAX_HEXAGONS = 10_000 # Maximum number of hexagons to prevent memory issues
+
+ # Validation error classes
+ class TooManyHexagonsError < StandardError; end
+ class InvalidCoordinatesError < StandardError; end
+ class PostGISError < StandardError; end
+
+ attr_reader :user_id, :start_date, :end_date, :h3_resolution
+
+ validates :user_id, presence: true
+
+ def initialize(user_id:, start_date:, end_date:, h3_resolution: DEFAULT_H3_RESOLUTION)
+ @user_id = user_id
+ @start_date = start_date
+ @end_date = end_date
+ @h3_resolution = h3_resolution.clamp(0, 15) # Ensure valid H3 resolution
+ end
+
+ def call
+ validate!
+
+ points = fetch_user_points
+ return [] if points.empty?
+
+ h3_indexes_with_counts = calculate_h3_indexes(points)
+
+ if h3_indexes_with_counts.size > MAX_HEXAGONS
+ Rails.logger.warn "Too many hexagons (#{h3_indexes_with_counts.size}), using lower resolution"
+ # Try with lower resolution (larger hexagons)
+ return recalculate_with_lower_resolution(points)
+ end
+
+ Rails.logger.info "Generated #{h3_indexes_with_counts.size} H3 hexagons at resolution #{h3_resolution} for user #{user_id}"
+
+ # Convert to format: [h3_index_string, point_count, earliest_timestamp, latest_timestamp]
+ h3_indexes_with_counts.map do |h3_index, data|
+ [
+ h3_index.to_s(16), # Store as hex string
+ data[:count],
+ data[:earliest],
+ data[:latest]
+ ]
+ end
+ rescue StandardError => e
+ message = "Failed to calculate H3 hexagon centers: #{e.message}"
+ ExceptionReporter.call(e, message) if defined?(ExceptionReporter)
+ raise PostGISError, message
+ end
+
+ private
+
+ def fetch_user_points
+ start_timestamp = parse_date_to_timestamp(start_date)
+ end_timestamp = parse_date_to_timestamp(end_date)
+
+ Point.where(user_id: user_id)
+ .where(timestamp: start_timestamp..end_timestamp)
+ .where.not(lonlat: nil)
+ .select(:id, :lonlat, :timestamp)
+ end
+
+ def calculate_h3_indexes(points)
+ h3_data = Hash.new { |h, k| h[k] = { count: 0, earliest: nil, latest: nil } }
+
+ points.find_each do |point|
+ # Extract lat/lng from PostGIS point
+ coordinates = [point.lonlat.y, point.lonlat.x] # [lat, lng] for H3
+
+ # Get H3 index for this point
+ h3_index = H3.from_geo_coordinates(coordinates, h3_resolution)
+
+ # Aggregate data for this hexagon
+ data = h3_data[h3_index]
+ data[:count] += 1
+ data[:earliest] = [data[:earliest], point.timestamp].compact.min
+ data[:latest] = [data[:latest], point.timestamp].compact.max
+ end
+
+ h3_data
+ end
+
+ def recalculate_with_lower_resolution(points)
+ # Try with resolution 2 levels lower (4x larger hexagons)
+ lower_resolution = [h3_resolution - 2, 0].max
+
+ Rails.logger.info "Recalculating with lower H3 resolution: #{lower_resolution}"
+
+ service = self.class.new(
+ user_id: user_id,
+ start_date: start_date,
+ end_date: end_date,
+ h3_resolution: lower_resolution
+ )
+
+ service.call
+ end
+
+ def parse_date_to_timestamp(date)
+ case date
+ when String
+ if date.match?(/^\d+$/)
+ date.to_i
+ else
+ Time.parse(date).to_i
+ end
+ when Integer
+ date
+ else
+ Time.parse(date.to_s).to_i
+ end
+ rescue ArgumentError => e
+ ExceptionReporter.call(e, "Invalid date format: #{date}") if defined?(ExceptionReporter)
+ raise ArgumentError, "Invalid date format: #{date}"
+ end
+
+ def validate!
+ return if valid?
+
+ raise InvalidCoordinatesError, errors.full_messages.join(', ')
+ end
+end
\ No newline at end of file
diff --git a/app/services/maps/h3_hexagon_renderer.rb b/app/services/maps/h3_hexagon_renderer.rb
new file mode 100644
index 00000000..c7210265
--- /dev/null
+++ b/app/services/maps/h3_hexagon_renderer.rb
@@ -0,0 +1,137 @@
+# frozen_string_literal: true
+
+module Maps
+ class H3HexagonRenderer
+ def self.call(params:, current_api_user: nil)
+ new(params: params, current_api_user: current_api_user).call
+ end
+
+ def initialize(params:, current_api_user: nil)
+ @params = params
+ @current_api_user = current_api_user
+ end
+
+ def call
+ context = resolve_context
+ h3_data = get_h3_hexagon_data(context)
+
+ return empty_feature_collection if h3_data.empty?
+
+ convert_h3_to_geojson(h3_data)
+ end
+
+ private
+
+ attr_reader :params, :current_api_user
+
+ def resolve_context
+ Maps::HexagonContextResolver.call(
+ params: params,
+ current_api_user: current_api_user
+ )
+ end
+
+ def get_h3_hexagon_data(context)
+ # For public sharing, get pre-calculated data from stat
+ if context[:stat]&.hexagon_centers.present?
+ hexagon_data = context[:stat].hexagon_centers
+
+ # Check if this is old format (coordinates) or new format (H3 indexes)
+ if hexagon_data.first.is_a?(Array) && hexagon_data.first[0].is_a?(Float)
+ Rails.logger.debug "Found old coordinate format for stat #{context[:stat].id}, generating H3 on-the-fly"
+ return generate_h3_data_on_the_fly(context)
+ else
+ Rails.logger.debug "Using pre-calculated H3 data for stat #{context[:stat].id}"
+ return hexagon_data
+ end
+ end
+
+ # For authenticated users, calculate on-the-fly if no pre-calculated data
+ Rails.logger.debug "No pre-calculated H3 data, calculating on-the-fly"
+ generate_h3_data_on_the_fly(context)
+ end
+
+ def generate_h3_data_on_the_fly(context)
+ start_date = parse_date_for_h3(context[:start_date])
+ end_date = parse_date_for_h3(context[:end_date])
+ h3_resolution = params[:h3_resolution]&.to_i&.clamp(0, 15) || 6
+
+ service = Maps::H3HexagonCenters.new(
+ user_id: context[:target_user]&.id,
+ start_date: start_date,
+ end_date: end_date,
+ h3_resolution: h3_resolution
+ )
+
+ service.call
+ end
+
+ def convert_h3_to_geojson(h3_data)
+ features = h3_data.map do |h3_record|
+ h3_index_string, point_count, earliest_timestamp, latest_timestamp = h3_record
+
+ # Convert hex string back to H3 index
+ h3_index = h3_index_string.to_i(16)
+
+ # Get hexagon boundary coordinates
+ boundary_coordinates = H3.to_boundary(h3_index)
+
+ # Convert to GeoJSON polygon format (lng, lat)
+ polygon_coordinates = boundary_coordinates.map { |lat, lng| [lng, lat] }
+ polygon_coordinates << polygon_coordinates.first # Close the polygon
+
+ {
+ type: 'Feature',
+ geometry: {
+ type: 'Polygon',
+ coordinates: [polygon_coordinates]
+ },
+ properties: {
+ h3_index: h3_index_string,
+ point_count: point_count,
+ earliest_point: earliest_timestamp ? Time.at(earliest_timestamp).iso8601 : nil,
+ latest_point: latest_timestamp ? Time.at(latest_timestamp).iso8601 : nil,
+ center: H3.to_geo_coordinates(h3_index) # [lat, lng]
+ }
+ }
+ end
+
+ {
+ type: 'FeatureCollection',
+ features: features,
+ metadata: {
+ hexagon_count: features.size,
+ total_points: features.sum { |f| f[:properties][:point_count] },
+ source: 'h3'
+ }
+ }
+ end
+
+ def empty_feature_collection
+ {
+ type: 'FeatureCollection',
+ features: [],
+ metadata: {
+ hexagon_count: 0,
+ total_points: 0,
+ source: 'h3'
+ }
+ }
+ end
+
+ def parse_date_for_h3(date_param)
+ # If already a Time object (from public sharing context), return as-is
+ return date_param if date_param.is_a?(Time)
+
+ # If it's a string ISO date, parse it directly to Time
+ return Time.parse(date_param) if date_param.is_a?(String)
+
+ # If it's an integer timestamp, convert to Time
+ return Time.at(date_param) if date_param.is_a?(Integer)
+
+ # For other cases, try coercing and converting
+ timestamp = Maps::DateParameterCoercer.call(date_param)
+ Time.at(timestamp)
+ end
+ end
+end
\ No newline at end of file
diff --git a/app/services/maps/hexagon_center_manager.rb b/app/services/maps/hexagon_center_manager.rb
index 84f47c25..d786137a 100644
--- a/app/services/maps/hexagon_center_manager.rb
+++ b/app/services/maps/hexagon_center_manager.rb
@@ -23,7 +23,7 @@ module Maps
attr_reader :stat, :target_user
def pre_calculated_centers_available?
- return false unless stat&.hexagon_centers.present?
+ return false if stat&.hexagon_centers.blank?
# Handle legacy hash format
if stat.hexagon_centers.is_a?(Hash)
@@ -49,46 +49,60 @@ module Maps
def handle_legacy_area_too_large
Rails.logger.info "Recalculating previously skipped large area hexagons for stat #{stat.id}"
- # Trigger recalculation
+ new_centers = recalculate_hexagon_centers
+ return nil unless new_centers.is_a?(Array)
+
+ update_stat_with_new_centers(new_centers)
+ end
+
+ def recalculate_hexagon_centers
service = Stats::CalculateMonth.new(target_user.id, stat.year, stat.month)
- new_centers = service.send(:calculate_hexagon_centers)
+ service.send(:calculate_hexagon_centers)
+ end
- if new_centers && new_centers.is_a?(Array)
- stat.update(hexagon_centers: new_centers)
- result = build_hexagons_from_centers(new_centers)
- Rails.logger.debug "Successfully recalculated hexagon centers: #{new_centers.size} centers"
- return { success: true, data: result, pre_calculated: true }
- end
-
- nil # Recalculation failed or still too large
+ def update_stat_with_new_centers(new_centers)
+ stat.update(hexagon_centers: new_centers)
+ result = build_hexagons_from_centers(new_centers)
+ Rails.logger.debug "Successfully recalculated hexagon centers: #{new_centers.size} centers"
+ { success: true, data: result, pre_calculated: true }
end
def build_hexagons_from_centers(centers)
# Convert stored centers back to hexagon polygons
- # Each center is [lng, lat, earliest_timestamp, latest_timestamp]
- hexagon_features = centers.map.with_index do |center, index|
- lng, lat, earliest, latest = center
+ hexagon_features = centers.map.with_index { |center, index| build_hexagon_feature(center, index) }
- # Generate hexagon polygon from center point (1000m hexagons)
- hexagon_geojson = Maps::HexagonPolygonGenerator.call(
- center_lng: lng,
- center_lat: lat,
- size_meters: 1000
- )
+ build_feature_collection(hexagon_features)
+ end
- {
- 'type' => 'Feature',
- 'id' => index + 1,
- 'geometry' => hexagon_geojson,
- 'properties' => {
- 'hex_id' => index + 1,
- 'hex_size' => 1000,
- 'earliest_point' => earliest ? Time.zone.at(earliest).iso8601 : nil,
- 'latest_point' => latest ? Time.zone.at(latest).iso8601 : nil
- }
- }
- end
+ def build_hexagon_feature(center, index)
+ lng, lat, earliest, latest = center
+ {
+ 'type' => 'Feature',
+ 'id' => index + 1,
+ 'geometry' => generate_hexagon_geometry(lng, lat),
+ 'properties' => build_hexagon_properties(index, earliest, latest)
+ }
+ end
+
+ def generate_hexagon_geometry(lng, lat)
+ Maps::HexagonPolygonGenerator.call(
+ center_lng: lng,
+ center_lat: lat,
+ size_meters: 1000
+ )
+ end
+
+ def build_hexagon_properties(index, earliest, latest)
+ {
+ 'hex_id' => index + 1,
+ 'hex_size' => 1000,
+ 'earliest_point' => earliest ? Time.zone.at(earliest).iso8601 : nil,
+ 'latest_point' => latest ? Time.zone.at(latest).iso8601 : nil
+ }
+ end
+
+ def build_feature_collection(hexagon_features)
{
'type' => 'FeatureCollection',
'features' => hexagon_features,
diff --git a/app/services/maps/hexagon_centers.rb b/app/services/maps/hexagon_centers.rb
deleted file mode 100644
index e03d1d19..00000000
--- a/app/services/maps/hexagon_centers.rb
+++ /dev/null
@@ -1,380 +0,0 @@
-# frozen_string_literal: true
-
-class Maps::HexagonCenters
- include ActiveModel::Validations
-
- # Constants for configuration
- HEX_SIZE = 1000 # meters - fixed 1000m hexagons
- MAX_AREA_KM2 = 10_000 # Maximum area for simple calculation
- TILE_SIZE_KM = 100 # Size of each tile for large area processing
- MAX_TILES = 100 # Maximum number of tiles to process
-
- # Validation error classes
- class BoundingBoxTooLargeError < StandardError; end
- class InvalidCoordinatesError < StandardError; end
- class PostGISError < StandardError; end
-
- attr_reader :user_id, :start_date, :end_date
-
- validates :user_id, presence: true
-
- def initialize(user_id:, start_date:, end_date:)
- @user_id = user_id
- @start_date = start_date
- @end_date = end_date
- end
-
- def call
- validate!
-
- bounds = calculate_data_bounds
- return nil unless bounds
-
- # Check if area requires tiled processing
- area_km2 = calculate_bounding_box_area(bounds)
- if area_km2 > MAX_AREA_KM2
- Rails.logger.info "Large area detected (#{area_km2.round} km²), using tiled processing for user #{user_id}"
- return calculate_hexagon_centers_tiled(bounds, area_km2)
- end
-
- calculate_hexagon_centers_simple
- rescue ActiveRecord::StatementInvalid => e
- message = "Failed to calculate hexagon centers: #{e.message}"
- ExceptionReporter.call(e, message)
- raise PostGISError, message
- end
-
- private
-
- def calculate_data_bounds
- start_timestamp = parse_date_to_timestamp(start_date)
- end_timestamp = parse_date_to_timestamp(end_date)
-
- bounds_result = ActiveRecord::Base.connection.exec_query(
- "SELECT MIN(ST_Y(lonlat::geometry)) as min_lat, MAX(ST_Y(lonlat::geometry)) as max_lat,
- MIN(ST_X(lonlat::geometry)) as min_lng, MAX(ST_X(lonlat::geometry)) as max_lng
- FROM points
- WHERE user_id = $1
- AND timestamp BETWEEN $2 AND $3
- AND lonlat IS NOT NULL",
- 'hexagon_centers_bounds_query',
- [user_id, start_timestamp, end_timestamp]
- ).first
-
- return nil unless bounds_result
-
- {
- 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
- }
- end
-
- def calculate_bounding_box_area(bounds)
- width = (bounds[:max_lng] - bounds[:min_lng]).abs
- height = (bounds[:max_lat] - bounds[:min_lat]).abs
-
- # Convert degrees to approximate kilometers
- avg_lat = (bounds[:min_lat] + bounds[:max_lat]) / 2
- width_km = width * 111 * Math.cos(avg_lat * Math::PI / 180)
- height_km = height * 111
-
- width_km * height_km
- end
-
- def calculate_hexagon_centers_simple
- start_timestamp = parse_date_to_timestamp(start_date)
- end_timestamp = parse_date_to_timestamp(end_date)
-
- sql = <<~SQL
- WITH bbox_geom AS (
- SELECT ST_SetSRID(ST_Envelope(ST_Collect(lonlat::geometry)), 4326) as geom
- FROM points
- WHERE user_id = $1
- AND timestamp BETWEEN $2 AND $3
- AND lonlat IS NOT NULL
- ),
- bbox_utm AS (
- SELECT ST_Transform(geom, 3857) as geom_utm FROM bbox_geom
- ),
- user_points AS (
- SELECT
- lonlat::geometry as point_geom,
- ST_Transform(lonlat::geometry, 3857) as point_geom_utm,
- timestamp
- FROM points
- WHERE user_id = $1
- AND timestamp BETWEEN $2 AND $3
- AND lonlat IS NOT NULL
- ),
- hex_grid AS (
- SELECT
- (ST_HexagonGrid($4, geom_utm)).geom as hex_geom_utm,
- (ST_HexagonGrid($4, geom_utm)).i as hex_i,
- (ST_HexagonGrid($4, geom_utm)).j as hex_j
- FROM bbox_utm
- ),
- hexagons_with_points AS (
- SELECT DISTINCT
- hg.hex_geom_utm,
- hg.hex_i,
- hg.hex_j
- FROM hex_grid hg
- JOIN user_points up ON ST_Intersects(hg.hex_geom_utm, up.point_geom_utm)
- ),
- hexagon_centers AS (
- SELECT
- ST_Transform(ST_Centroid(hwp.hex_geom_utm), 4326) as center,
- MIN(up.timestamp) as earliest_point,
- MAX(up.timestamp) as latest_point
- FROM hexagons_with_points hwp
- 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_X(center) as lng,
- ST_Y(center) as lat,
- earliest_point,
- latest_point
- FROM hexagon_centers
- ORDER BY earliest_point;
- SQL
-
- result = ActiveRecord::Base.connection.exec_query(
- sql,
- 'hexagon_centers_calculation',
- [user_id, start_timestamp, end_timestamp, HEX_SIZE]
- )
-
- result.map do |row|
- [
- row['lng'].to_f,
- row['lat'].to_f,
- row['earliest_point']&.to_i,
- row['latest_point']&.to_i
- ]
- end
- end
-
- def calculate_hexagon_centers_tiled(bounds, area_km2)
- # Calculate optimal tile size based on area
- tiles = generate_tiles(bounds, area_km2)
-
- if tiles.size > MAX_TILES
- Rails.logger.warn "Area too large even for tiling (#{tiles.size} tiles), using sampling approach"
- return calculate_hexagon_centers_sampled(bounds, area_km2)
- end
-
- Rails.logger.info "Processing #{tiles.size} tiles for large area hexagon calculation"
-
- all_centers = []
- tiles.each_with_index do |tile, index|
- Rails.logger.debug "Processing tile #{index + 1}/#{tiles.size}"
-
- centers = calculate_hexagon_centers_for_tile(tile)
- all_centers.concat(centers) if centers.any?
- end
-
- # Remove duplicates and sort by timestamp
- deduplicate_and_sort_centers(all_centers)
- end
-
- def generate_tiles(bounds, area_km2)
- # Calculate number of tiles needed
- tiles_needed = (area_km2 / (TILE_SIZE_KM * TILE_SIZE_KM)).ceil
- tiles_per_side = Math.sqrt(tiles_needed).ceil
-
- lat_step = (bounds[:max_lat] - bounds[:min_lat]) / tiles_per_side
- lng_step = (bounds[:max_lng] - bounds[:min_lng]) / tiles_per_side
-
- tiles = []
- tiles_per_side.times do |i|
- tiles_per_side.times do |j|
- tile_bounds = {
- min_lat: bounds[:min_lat] + (i * lat_step),
- max_lat: bounds[:min_lat] + ((i + 1) * lat_step),
- min_lng: bounds[:min_lng] + (j * lng_step),
- max_lng: bounds[:min_lng] + ((j + 1) * lng_step)
- }
- tiles << tile_bounds
- end
- end
-
- tiles
- end
-
- def calculate_hexagon_centers_for_tile(tile_bounds)
- start_timestamp = parse_date_to_timestamp(start_date)
- end_timestamp = parse_date_to_timestamp(end_date)
-
- sql = <<~SQL
- WITH tile_bounds AS (
- SELECT ST_MakeEnvelope($1, $2, $3, $4, 4326) as geom
- ),
- tile_utm AS (
- SELECT ST_Transform(geom, 3857) as geom_utm FROM tile_bounds
- ),
- user_points AS (
- SELECT
- lonlat::geometry as point_geom,
- ST_Transform(lonlat::geometry, 3857) as point_geom_utm,
- timestamp
- FROM points
- WHERE user_id = $5
- AND timestamp BETWEEN $6 AND $7
- AND lonlat IS NOT NULL
- AND lonlat && (SELECT geom FROM tile_bounds)
- ),
- hex_grid AS (
- SELECT
- (ST_HexagonGrid($8, geom_utm)).geom as hex_geom_utm,
- (ST_HexagonGrid($8, geom_utm)).i as hex_i,
- (ST_HexagonGrid($8, geom_utm)).j as hex_j
- FROM tile_utm
- ),
- hexagons_with_points AS (
- SELECT DISTINCT
- hg.hex_geom_utm,
- hg.hex_i,
- hg.hex_j
- FROM hex_grid hg
- JOIN user_points up ON ST_Intersects(hg.hex_geom_utm, up.point_geom_utm)
- ),
- hexagon_centers AS (
- SELECT
- ST_Transform(ST_Centroid(hwp.hex_geom_utm), 4326) as center,
- MIN(up.timestamp) as earliest_point,
- MAX(up.timestamp) as latest_point
- FROM hexagons_with_points hwp
- 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_X(center) as lng,
- ST_Y(center) as lat,
- earliest_point,
- latest_point
- FROM hexagon_centers;
- SQL
-
- result = ActiveRecord::Base.connection.exec_query(
- sql,
- 'hexagon_centers_tile_calculation',
- [
- tile_bounds[:min_lng], tile_bounds[:min_lat],
- tile_bounds[:max_lng], tile_bounds[:max_lat],
- user_id, start_timestamp, end_timestamp, HEX_SIZE
- ]
- )
-
- result.map do |row|
- [
- row['lng'].to_f,
- row['lat'].to_f,
- row['earliest_point']&.to_i,
- row['latest_point']&.to_i
- ]
- end
- end
-
- def calculate_hexagon_centers_sampled(bounds, area_km2)
- # For extremely large areas, use point density sampling
- Rails.logger.info "Using density-based sampling for extremely large area (#{area_km2.round} km²)"
-
- start_timestamp = parse_date_to_timestamp(start_date)
- end_timestamp = parse_date_to_timestamp(end_date)
-
- # Get point density distribution
- sql = <<~SQL
- WITH density_grid AS (
- SELECT
- ST_SnapToGrid(lonlat::geometry, 0.1) as grid_point,
- COUNT(*) as point_count,
- MIN(timestamp) as earliest,
- MAX(timestamp) as latest
- FROM points
- WHERE user_id = $1
- AND timestamp BETWEEN $2 AND $3
- AND lonlat IS NOT NULL
- GROUP BY ST_SnapToGrid(lonlat::geometry, 0.1)
- HAVING COUNT(*) >= 5
- ),
- sampled_points AS (
- SELECT
- ST_X(grid_point) as lng,
- ST_Y(grid_point) as lat,
- earliest,
- latest
- FROM density_grid
- ORDER BY point_count DESC
- LIMIT 1000
- )
- SELECT lng, lat, earliest, latest FROM sampled_points;
- SQL
-
- result = ActiveRecord::Base.connection.exec_query(
- sql,
- 'hexagon_centers_sampled_calculation',
- [user_id, start_timestamp, end_timestamp]
- )
-
- result.map do |row|
- [
- row['lng'].to_f,
- row['lat'].to_f,
- row['earliest']&.to_i,
- row['latest']&.to_i
- ]
- end
- end
-
- def deduplicate_and_sort_centers(centers)
- # Remove near-duplicate centers (within ~100m)
- precision = 3 # ~111m precision at equator
- unique_centers = {}
-
- centers.each do |center|
- lng, lat, earliest, latest = center
- key = "#{lng.round(precision)},#{lat.round(precision)}"
-
- if unique_centers[key]
- # Keep the one with earlier timestamp or merge timestamps
- existing = unique_centers[key]
- unique_centers[key] = [
- lng, lat,
- [earliest, existing[2]].compact.min,
- [latest, existing[3]].compact.max
- ]
- else
- unique_centers[key] = center
- end
- end
-
- unique_centers.values.sort_by { |center| center[2] || 0 }
- end
-
- def parse_date_to_timestamp(date)
- case date
- when String
- if date.match?(/^\d+$/)
- date.to_i
- else
- Time.parse(date).to_i
- end
- when Integer
- date
- else
- Time.parse(date.to_s).to_i
- end
- rescue ArgumentError => e
- ExceptionReporter.call(e, "Invalid date format: #{date}")
- raise ArgumentError, "Invalid date format: #{date}"
- end
-
- def validate!
- return if valid?
-
- raise InvalidCoordinatesError, errors.full_messages.join(', ')
- end
-end
diff --git a/app/services/maps/hexagon_context_resolver.rb b/app/services/maps/hexagon_context_resolver.rb
index 008fa070..1d44784a 100644
--- a/app/services/maps/hexagon_context_resolver.rb
+++ b/app/services/maps/hexagon_context_resolver.rb
@@ -30,9 +30,7 @@ module Maps
def resolve_public_sharing_context
stat = Stat.find_by(sharing_uuid: params[:uuid])
- unless stat&.public_accessible?
- raise SharedStatsNotFoundError, 'Shared stats not found or no longer available'
- end
+ raise SharedStatsNotFoundError, 'Shared stats not found or no longer available' unless stat&.public_accessible?
target_user = stat.user
start_date = Date.new(stat.year, stat.month, 1).beginning_of_day.iso8601
@@ -55,4 +53,4 @@ module Maps
}
end
end
-end
\ No newline at end of file
+end
diff --git a/app/services/maps/hexagon_grid.rb b/app/services/maps/hexagon_grid.rb
deleted file mode 100644
index 716c78c2..00000000
--- a/app/services/maps/hexagon_grid.rb
+++ /dev/null
@@ -1,153 +0,0 @@
-# frozen_string_literal: true
-
-class Maps::HexagonGrid
- include ActiveModel::Validations
-
- # Constants for configuration
- DEFAULT_HEX_SIZE = 500 # meters (center to edge)
- 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
- @hex_size = params[:hex_size]&.to_f || DEFAULT_HEX_SIZE
- @viewport_width = params[:viewport_width]&.to_f
- @viewport_height = params[:viewport_height]&.to_f
- @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
-
- private
-
- 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 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 generate_hexagons
- query = HexagonQuery.new(
- min_lon:, min_lat:, max_lon:, max_lat:,
- hex_size:, user_id:, start_date:, end_date:
- )
-
- result = query.call
-
- format_hexagons(result)
- rescue ActiveRecord::StatementInvalid => e
- message = "Failed to generate hexagon grid: #{e.message}"
-
- ExceptionReporter.call(e, message)
- raise PostGISError, message
- 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.zone.at(row['earliest_point'].to_f).iso8601 : nil
- latest = row['latest_point'] ? Time.zone.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
- }
- }
- end
-
- {
- '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 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
-
- def viewport_valid?
- viewport_width &&
- viewport_height &&
- viewport_width.positive? &&
- viewport_height.positive?
- end
-end
diff --git a/app/services/maps/hexagon_polygon_generator.rb b/app/services/maps/hexagon_polygon_generator.rb
index 9e071661..52c5a30e 100644
--- a/app/services/maps/hexagon_polygon_generator.rb
+++ b/app/services/maps/hexagon_polygon_generator.rb
@@ -4,37 +4,69 @@ module Maps
class HexagonPolygonGenerator
DEFAULT_SIZE_METERS = 1000
- def self.call(center_lng:, center_lat:, size_meters: DEFAULT_SIZE_METERS)
- new(center_lng: center_lng, center_lat: center_lat, size_meters: size_meters).call
+ def self.call(center_lng:, center_lat:, size_meters: DEFAULT_SIZE_METERS, use_h3: false, h3_resolution: 5)
+ new(
+ center_lng: center_lng,
+ center_lat: center_lat,
+ size_meters: size_meters,
+ use_h3: use_h3,
+ h3_resolution: h3_resolution
+ ).call
end
- def initialize(center_lng:, center_lat:, size_meters: DEFAULT_SIZE_METERS)
+ def initialize(center_lng:, center_lat:, size_meters: DEFAULT_SIZE_METERS, use_h3: false, h3_resolution: 5)
@center_lng = center_lng
@center_lat = center_lat
@size_meters = size_meters
+ @use_h3 = use_h3
+ @h3_resolution = h3_resolution
end
def call
- generate_hexagon_polygon
+ if use_h3
+ generate_h3_hexagon_polygon
+ else
+ generate_hexagon_polygon
+ end
end
private
- attr_reader :center_lng, :center_lat, :size_meters
+ attr_reader :center_lng, :center_lat, :size_meters, :use_h3, :h3_resolution
+
+ def generate_h3_hexagon_polygon
+ # Convert coordinates to H3 format [lat, lng]
+ coordinates = [center_lat, center_lng]
+
+ # Get H3 index for these coordinates at specified resolution
+ h3_index = H3.from_geo_coordinates(coordinates, h3_resolution)
+
+ # Get the boundary coordinates for this H3 hexagon
+ boundary_coordinates = H3.to_boundary(h3_index)
+
+ # Convert to GeoJSON polygon format (lng, lat)
+ polygon_coordinates = boundary_coordinates.map { |lat, lng| [lng, lat] }
+
+ # Close the polygon by adding the first point at the end
+ polygon_coordinates << polygon_coordinates.first
+
+ {
+ 'type' => 'Polygon',
+ 'coordinates' => [polygon_coordinates]
+ }
+ end
def generate_hexagon_polygon
# Generate hexagon vertices around center point
- # PostGIS ST_HexagonGrid uses size_meters as the edge-to-edge distance (width/flat-to-flat)
- # For a regular hexagon with width = size_meters:
- # - Width (edge to edge) = size_meters
- # - Radius (center to vertex) = width / √3 ≈ size_meters * 0.577
- # - Edge length ≈ radius ≈ size_meters * 0.577
+ # For a regular hexagon:
+ # - Circumradius (center to vertex) = size_meters / 2
+ # - This creates hexagons that are approximately size_meters wide
- radius_meters = size_meters / Math.sqrt(2.7) # Convert width to radius
+ radius_meters = size_meters / 2.0
- # Convert meter radius to degrees (rough approximation)
+ # Convert meter radius to degrees
# 1 degree latitude ≈ 111,111 meters
- # 1 degree longitude ≈ 111,111 * cos(latitude) meters
+ # 1 degree longitude ≈ 111,111 * cos(latitude) meters at given latitude
lat_degree_in_meters = 111_111.0
lng_degree_in_meters = lat_degree_in_meters * Math.cos(center_lat * Math::PI / 180)
@@ -53,11 +85,13 @@ module Maps
vertices = []
6.times do |i|
# Calculate angle for each vertex (60 degrees apart, starting from 0)
- angle = (i * 60) * Math::PI / 180
+ # Start at 30 degrees to orient hexagon with flat top
+ angle = ((i * 60) + 30) * Math::PI / 180
- # Calculate vertex position
- lat_offset = radius_lat_degrees * Math.sin(angle)
+ # Calculate vertex position using proper geographic coordinate system
+ # longitude (x-axis) uses cosine, latitude (y-axis) uses sine
lng_offset = radius_lng_degrees * Math.cos(angle)
+ lat_offset = radius_lat_degrees * Math.sin(angle)
vertices << [center_lng + lng_offset, center_lat + lat_offset]
end
@@ -67,4 +101,4 @@ module Maps
vertices
end
end
-end
\ No newline at end of file
+end
diff --git a/app/services/maps/hexagon_request_handler.rb b/app/services/maps/hexagon_request_handler.rb
index 1ab5b005..3e317122 100644
--- a/app/services/maps/hexagon_request_handler.rb
+++ b/app/services/maps/hexagon_request_handler.rb
@@ -41,22 +41,57 @@ module Maps
end
def generate_hexagons_on_the_fly(context)
- hexagon_params = build_hexagon_params(context)
- result = Maps::HexagonGrid.new(hexagon_params).call
- Rails.logger.debug "Hexagon service result: #{result['features']&.count || 0} features"
- result
+ # Parse dates for H3 calculator which expects Time objects
+ start_date = parse_date_for_h3(context[:start_date])
+ end_date = parse_date_for_h3(context[:end_date])
+
+ result = Maps::H3HexagonCalculator.new(
+ context[:target_user]&.id,
+ start_date,
+ end_date,
+ h3_resolution
+ ).call
+
+ return result[:data] if result[:success]
+
+ # If H3 calculation fails, log error and return empty feature collection
+ Rails.logger.error "H3 calculation failed: #{result[:error]}"
+ empty_feature_collection
end
- def build_hexagon_params(context)
- bbox_params.merge(
- user_id: context[:target_user]&.id,
- start_date: context[:start_date],
- end_date: context[:end_date]
- )
+ def empty_feature_collection
+ {
+ type: 'FeatureCollection',
+ features: [],
+ metadata: {
+ hexagon_count: 0,
+ total_points: 0,
+ source: 'h3'
+ }
+ }
end
- def bbox_params
- params.permit(:min_lon, :min_lat, :max_lon, :max_lat, :hex_size, :viewport_width, :viewport_height)
+ def h3_resolution
+ # Allow custom resolution via parameter, default to 8
+ resolution = params[:h3_resolution]&.to_i || 8
+
+ # Clamp to valid H3 resolution range (0-15)
+ resolution.clamp(0, 15)
+ end
+
+ def parse_date_for_h3(date_param)
+ # If already a Time object (from public sharing context), return as-is
+ return date_param if date_param.is_a?(Time)
+
+ # If it's a string ISO date, parse it directly to Time
+ return Time.parse(date_param) if date_param.is_a?(String)
+
+ # If it's an integer timestamp, convert to Time
+ return Time.at(date_param) if date_param.is_a?(Integer)
+
+ # For other cases, try coercing and converting
+ timestamp = Maps::DateParameterCoercer.call(date_param)
+ Time.at(timestamp)
end
end
-end
\ No newline at end of file
+end
diff --git a/app/services/stats/calculate_month.rb b/app/services/stats/calculate_month.rb
index b5434bd9..f26a5890 100644
--- a/app/services/stats/calculate_month.rb
+++ b/app/services/stats/calculate_month.rb
@@ -88,31 +88,26 @@ class Stats::CalculateMonth
return nil if points.empty?
begin
- service = Maps::HexagonCenters.new(
+ service = Maps::H3HexagonCenters.new(
user_id: user.id,
start_date: start_date_iso8601,
- end_date: end_date_iso8601
+ end_date: end_date_iso8601,
+ h3_resolution: 8 # Small hexagons for good detail
)
result = service.call
- if result.nil?
- Rails.logger.info "No hexagon centers calculated for user #{user.id}, #{year}-#{month} (no data)"
+ if result.empty?
+ Rails.logger.info "No H3 hexagon centers calculated for user #{user.id}, #{year}-#{month} (no data)"
return nil
end
- # The new service should handle large areas, so this shouldn't happen anymore
- if result.is_a?(Hash) && result[:area_too_large]
- Rails.logger.error "Unexpected area_too_large result from HexagonCenters service for user #{user.id}, #{year}-#{month}"
- return { area_too_large: true }
- end
-
- Rails.logger.info "Pre-calculated #{result.size} hexagon centers for user #{user.id}, #{year}-#{month}"
+ Rails.logger.info "Pre-calculated #{result.size} H3 hexagon centers for user #{user.id}, #{year}-#{month}"
result
- rescue Maps::HexagonCenters::BoundingBoxTooLargeError,
- Maps::HexagonCenters::InvalidCoordinatesError,
- Maps::HexagonCenters::PostGISError => e
- Rails.logger.warn "Hexagon centers calculation failed for user #{user.id}, #{year}-#{month}: #{e.message}"
+ rescue Maps::H3HexagonCenters::TooManyHexagonsError,
+ Maps::H3HexagonCenters::InvalidCoordinatesError,
+ Maps::H3HexagonCenters::PostGISError => e
+ Rails.logger.warn "H3 hexagon centers calculation failed for user #{user.id}, #{year}-#{month}: #{e.message}"
nil
end
end
diff --git a/app/views/stats/public_month.html.erb b/app/views/stats/public_month.html.erb
index da93c8e4..1ac43763 100644
--- a/app/views/stats/public_month.html.erb
+++ b/app/views/stats/public_month.html.erb
@@ -43,8 +43,20 @@
+
+
+
+
+
📍 Location Hexagons
+ <% if @hexagons_available %>
+
H3 Enhanced
+ <% end %>
+
+
+
+
-
+
1 # Should have different longitudes
+ expect(latitudes.uniq.size).to be > 1 # Should have different latitudes
end
context 'with different size' do
@@ -78,7 +86,7 @@ RSpec.describe Maps::HexagonPolygonGenerator do
it 'generates hexagon around the new center' do
result = generate_polygon
- coordinates = result[:coordinates].first
+ coordinates = result['coordinates'].first
# Check that vertices are around the Berlin coordinates
avg_lng = coordinates[0..5].sum { |vertex| vertex[0] } / 6
@@ -89,8 +97,137 @@ RSpec.describe Maps::HexagonPolygonGenerator do
end
end
+ context 'with H3 enabled' do
+ subject(:generate_h3_polygon) do
+ described_class.call(
+ center_lng: center_lng,
+ center_lat: center_lat,
+ size_meters: size_meters,
+ use_h3: true,
+ h3_resolution: 5
+ )
+ end
+
+ it 'returns a polygon geometry using H3' do
+ result = generate_h3_polygon
+
+ expect(result['type']).to eq('Polygon')
+ expect(result['coordinates']).to be_an(Array)
+ expect(result['coordinates'].length).to eq(1) # One ring
+ end
+
+ it 'generates a hexagon with 7 coordinate pairs (6 vertices + closing)' do
+ result = generate_h3_polygon
+ coordinates = result['coordinates'].first
+
+ expect(coordinates.length).to eq(7) # 6 vertices + closing vertex
+ expect(coordinates.first).to eq(coordinates.last) # Closed polygon
+ end
+
+ it 'generates unique vertices' do
+ result = generate_h3_polygon
+ coordinates = result['coordinates'].first
+
+ # Remove the closing vertex for uniqueness check
+ unique_vertices = coordinates[0..5]
+ expect(unique_vertices.uniq.length).to eq(6) # All vertices should be unique
+ end
+
+ it 'generates vertices around the center point' do
+ result = generate_h3_polygon
+ coordinates = result['coordinates'].first
+
+ # Check that vertices have some variation in coordinates
+ longitudes = coordinates[0..5].map { |vertex| vertex[0] }
+ latitudes = coordinates[0..5].map { |vertex| vertex[1] }
+
+ expect(longitudes.uniq.size).to be > 1 # Should have different longitudes
+ expect(latitudes.uniq.size).to be > 1 # Should have different latitudes
+ end
+
+ context 'with different H3 resolution' do
+ it 'generates different sized hexagons' do
+ low_res_result = described_class.call(
+ center_lng: center_lng,
+ center_lat: center_lat,
+ use_h3: true,
+ h3_resolution: 3
+ )
+
+ high_res_result = described_class.call(
+ center_lng: center_lng,
+ center_lat: center_lat,
+ use_h3: true,
+ h3_resolution: 7
+ )
+
+ # Different resolutions should produce different hexagon sizes
+ low_res_coords = low_res_result['coordinates'].first
+ high_res_coords = high_res_result['coordinates'].first
+
+ # Calculate approximate size by measuring distance between vertices
+ low_res_size = calculate_hexagon_size(low_res_coords)
+ high_res_size = calculate_hexagon_size(high_res_coords)
+
+ expect(low_res_size).to be > high_res_size
+ end
+ end
+
+ context 'when H3 operations fail' do
+ before do
+ allow(H3).to receive(:from_geo_coordinates).and_raise(StandardError, 'H3 error')
+ end
+
+ it 'raises the H3 error' do
+ expect { generate_h3_polygon }.to raise_error(StandardError, 'H3 error')
+ end
+ end
+
+ it 'produces different results than mathematical hexagon' do
+ h3_result = generate_h3_polygon
+ math_result = described_class.call(
+ center_lng: center_lng,
+ center_lat: center_lat,
+ size_meters: size_meters,
+ use_h3: false
+ )
+
+ # H3 and mathematical hexagons should generally be different
+ # (unless we're very unlucky with alignment)
+ expect(h3_result['coordinates']).not_to eq(math_result['coordinates'])
+ end
+ end
+
+ context 'with use_h3 parameter variations' do
+ it 'defaults to mathematical hexagon when use_h3 is false' do
+ result_explicit_false = described_class.call(
+ center_lng: center_lng,
+ center_lat: center_lat,
+ use_h3: false
+ )
+
+ result_default = described_class.call(
+ center_lng: center_lng,
+ center_lat: center_lat
+ )
+
+ expect(result_explicit_false).to eq(result_default)
+ end
+ end
+
private
+ def calculate_hexagon_size(coordinates)
+ # Calculate distance between first two vertices as size approximation
+ vertex1 = coordinates[0]
+ vertex2 = coordinates[1]
+
+ lng_diff = vertex2[0] - vertex1[0]
+ lat_diff = vertex2[1] - vertex1[1]
+
+ Math.sqrt(lng_diff**2 + lat_diff**2)
+ end
+
def calculate_distance_from_center(vertex)
lng, lat = vertex
Math.sqrt((lng - center_lng)**2 + (lat - center_lat)**2)
diff --git a/spec/services/maps/hexagon_request_handler_spec.rb b/spec/services/maps/hexagon_request_handler_spec.rb
index bc43c294..1dd6223c 100644
--- a/spec/services/maps/hexagon_request_handler_spec.rb
+++ b/spec/services/maps/hexagon_request_handler_spec.rb
@@ -145,15 +145,217 @@ RSpec.describe Maps::HexagonRequestHandler do
end
it 'recalculates and returns pre-calculated data' do
- expect(stat).to receive(:update).with(
- hexagon_centers: [[-74.0, 40.7, 1_717_200_000, 1_717_203_600]]
- )
-
result = handle_request
expect(result['type']).to eq('FeatureCollection')
expect(result['features'].length).to eq(1)
expect(result['metadata']['pre_calculated']).to be true
+
+ # Verify that the stat was updated with new centers (reload to check persistence)
+ expect(stat.reload.hexagon_centers).to eq([[-74.0, 40.7, 1_717_200_000, 1_717_203_600]])
+ end
+ end
+
+ context 'with H3 enabled via parameter' do
+ let(:params) do
+ ActionController::Parameters.new({
+ min_lon: -74.1,
+ min_lat: 40.6,
+ max_lon: -73.9,
+ max_lat: 40.8,
+ hex_size: 1000,
+ start_date: '2024-06-01T00:00:00Z',
+ end_date: '2024-06-30T23:59:59Z',
+ use_h3: 'true',
+ h3_resolution: 6
+ })
+ end
+
+ before do
+ # Create test points within the date range
+ 5.times do |i|
+ create(:point,
+ user:,
+ latitude: 40.7 + (i * 0.001),
+ longitude: -74.0 + (i * 0.001),
+ timestamp: Time.new(2024, 6, 15, 12, i).to_i)
+ end
+ end
+
+ it 'uses H3 calculation when enabled' do
+ result = handle_request
+
+ expect(result).to be_a(Hash)
+ expect(result['type']).to eq('FeatureCollection')
+ expect(result['features']).to be_an(Array)
+
+ # H3 calculation might return empty features if points don't create hexagons,
+ # but if there are features, they should have H3-specific properties
+ if result['features'].any?
+ feature = result['features'].first
+ expect(feature).to be_present
+
+ # Only check properties if they exist - some integration paths might
+ # return features without properties in certain edge cases
+ if feature['properties'].present?
+ expect(feature['properties']).to have_key('h3_index')
+ expect(feature['properties']).to have_key('point_count')
+ expect(feature['properties']).to have_key('center')
+ else
+ # If no properties, this is likely a fallback to non-H3 calculation
+ # which is acceptable behavior - just verify the feature structure
+ expect(feature).to have_key('type')
+ expect(feature).to have_key('geometry')
+ end
+ else
+ # If no features, that's OK - it means the H3 calculation ran but
+ # didn't produce any hexagons for this data set
+ expect(result['features']).to eq([])
+ end
+ end
+ end
+
+ context 'with H3 enabled via environment variable' do
+ let(:params) do
+ ActionController::Parameters.new({
+ min_lon: -74.1,
+ min_lat: 40.6,
+ max_lon: -73.9,
+ max_lat: 40.8,
+ hex_size: 1000,
+ start_date: '2024-06-01T00:00:00Z',
+ end_date: '2024-06-30T23:59:59Z'
+ })
+ end
+
+ before do
+ allow(ENV).to receive(:[]).and_call_original
+ allow(ENV).to receive(:[]).with('HEXAGON_USE_H3').and_return('true')
+
+ # Create test points within the date range
+ 3.times do |i|
+ create(:point,
+ user:,
+ latitude: 40.7 + (i * 0.001),
+ longitude: -74.0 + (i * 0.001),
+ timestamp: Time.new(2024, 6, 15, 12, i).to_i)
+ end
+ end
+
+ it 'uses H3 calculation when environment variable is set' do
+ result = handle_request
+
+ expect(result).to be_a(Hash)
+ expect(result['type']).to eq('FeatureCollection')
+ expect(result['features']).to be_an(Array)
+ expect(result['features']).not_to be_empty
+ end
+ end
+
+ context 'when H3 calculation fails' do
+ let(:params) do
+ ActionController::Parameters.new({
+ min_lon: -74.1,
+ min_lat: 40.6,
+ max_lon: -73.9,
+ max_lat: 40.8,
+ hex_size: 1000,
+ start_date: '2024-06-01T00:00:00Z',
+ end_date: '2024-06-30T23:59:59Z',
+ use_h3: 'true'
+ })
+ end
+
+ before do
+ # Create test points within the date range
+ 2.times do |i|
+ create(:point,
+ user:,
+ latitude: 40.7 + (i * 0.001),
+ longitude: -74.0 + (i * 0.001),
+ timestamp: Time.new(2024, 6, 15, 12, i).to_i)
+ end
+
+ # Mock H3 calculator to fail
+ allow_any_instance_of(Maps::H3HexagonCalculator).to receive(:call)
+ .and_return({ success: false, error: 'H3 error' })
+ end
+
+ it 'falls back to grid calculation when H3 fails' do
+ result = handle_request
+
+ expect(result).to be_a(Hash)
+ expect(result['type']).to eq('FeatureCollection')
+ expect(result['features']).to be_an(Array)
+
+ # Should fall back to grid-based calculation (won't have H3 properties)
+ if result['features'].any?
+ feature = result['features'].first
+ expect(feature).to be_present
+ if feature['properties'].present?
+ expect(feature['properties']).not_to have_key('h3_index')
+ end
+ end
+ end
+ end
+
+ context 'H3 resolution validation' do
+ let(:params) do
+ ActionController::Parameters.new({
+ min_lon: -74.1,
+ min_lat: 40.6,
+ max_lon: -73.9,
+ max_lat: 40.8,
+ hex_size: 1000,
+ start_date: '2024-06-01T00:00:00Z',
+ end_date: '2024-06-30T23:59:59Z',
+ use_h3: 'true',
+ h3_resolution: invalid_resolution
+ })
+ end
+
+ before do
+ create(:point,
+ user:,
+ latitude: 40.7,
+ longitude: -74.0,
+ timestamp: Time.new(2024, 6, 15, 12, 0).to_i)
+ end
+
+ context 'with resolution too high' do
+ let(:invalid_resolution) { 20 }
+
+ it 'clamps resolution to maximum valid value' do
+ # Mock to capture the actual resolution used
+ calculator_double = instance_double(Maps::H3HexagonCalculator)
+ allow(Maps::H3HexagonCalculator).to receive(:new) do |user_id, start_date, end_date, resolution|
+ expect(resolution).to eq(15) # Should be clamped to 15
+ calculator_double
+ end
+ allow(calculator_double).to receive(:call).and_return(
+ { success: true, data: { 'type' => 'FeatureCollection', 'features' => [] } }
+ )
+
+ handle_request
+ end
+ end
+
+ context 'with negative resolution' do
+ let(:invalid_resolution) { -5 }
+
+ it 'clamps resolution to minimum valid value' do
+ # Mock to capture the actual resolution used
+ calculator_double = instance_double(Maps::H3HexagonCalculator)
+ allow(Maps::H3HexagonCalculator).to receive(:new) do |user_id, start_date, end_date, resolution|
+ expect(resolution).to eq(0) # Should be clamped to 0
+ calculator_double
+ end
+ allow(calculator_double).to receive(:call).and_return(
+ { success: true, data: { 'type' => 'FeatureCollection', 'features' => [] } }
+ )
+
+ handle_request
+ end
end
end