diff --git a/app/controllers/api/v1/maps/hexagons_controller.rb b/app/controllers/api/v1/maps/hexagons_controller.rb
index e117af0f..cf08dc93 100644
--- a/app/controllers/api/v1/maps/hexagons_controller.rb
+++ b/app/controllers/api/v1/maps/hexagons_controller.rb
@@ -1,12 +1,10 @@
# frozen_string_literal: true
class Api::V1::Maps::HexagonsController < ApiController
- skip_before_action :authenticate_api_key
-
before_action :validate_bbox_params
def index
- service = Maps::HexagonGrid.new(bbox_params)
+ service = Maps::HexagonGrid.new(hexagon_params)
result = service.call
render json: result
@@ -27,6 +25,14 @@ class Api::V1::Maps::HexagonsController < ApiController
params.permit(:min_lon, :min_lat, :max_lon, :max_lat, :hex_size)
end
+ def hexagon_params
+ bbox_params.merge(
+ user_id: current_api_user&.id,
+ start_date: params[:start_date],
+ end_date: params[:end_date]
+ )
+ 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? }
diff --git a/app/javascript/maps/hexagon_grid.js b/app/javascript/maps/hexagon_grid.js
index 9aa3623a..709121f8 100644
--- a/app/javascript/maps/hexagon_grid.js
+++ b/app/javascript/maps/hexagon_grid.js
@@ -182,6 +182,11 @@ export class HexagonGrid {
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');
+
const params = new URLSearchParams({
min_lon: bounds.getWest(),
min_lat: bounds.getSouth(),
@@ -189,6 +194,10 @@ export class HexagonGrid {
max_lat: bounds.getNorth()
});
+ // 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: {
@@ -241,13 +250,21 @@ export class HexagonGrid {
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: () => this.options.style,
+ 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);
+
// Add hover effects
layer.on({
- mouseover: (e) => this.onHexagonMouseOver(e),
- mouseout: (e) => this.onHexagonMouseOut(e),
+ mouseover: (e) => this.onHexagonMouseOver(e, feature),
+ mouseout: (e) => this.onHexagonMouseOut(e, feature),
click: (e) => this.onHexagonClick(e, feature)
});
}
@@ -256,6 +273,57 @@ export class HexagonGrid {
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'; // 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 `
+
+
Hexagon Stats
+ Points: ${props.point_count || 0}
+ Density: ${props.density || 0} pts/km²
+ ${props.avg_speed ? `Avg Speed: ${props.avg_speed} km/h
` : ''}
+ ${props.avg_battery ? `Avg Battery: ${props.avg_battery}%
` : ''}
+ Date Range:
+ ${startDate} - ${endDate}
+
+ `;
+ }
+
/**
* Handle hexagon mouseover event
*/
diff --git a/app/services/maps/hexagon_grid.rb b/app/services/maps/hexagon_grid.rb
index e6bd0538..29a5bc6d 100644
--- a/app/services/maps/hexagon_grid.rb
+++ b/app/services/maps/hexagon_grid.rb
@@ -2,18 +2,18 @@
class Maps::HexagonGrid
include ActiveModel::Validations
-
+
# Constants for configuration
DEFAULT_HEX_SIZE = 500 # meters (center to edge)
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
+ attr_reader :min_lon, :min_lat, :max_lon, :max_lat, :hex_size, :user_id, :start_date, :end_date
validates :min_lon, :max_lon, inclusion: { in: -180..180 }
validates :min_lat, :max_lat, inclusion: { in: -90..90 }
@@ -24,10 +24,13 @@ class Maps::HexagonGrid
def initialize(params = {})
@min_lon = params[:min_lon].to_f
- @min_lat = params[:min_lat].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
+ @user_id = params[:user_id]
+ @start_date = params[:start_date]
+ @end_date = params[:end_date]
end
def call
@@ -70,23 +73,23 @@ class Maps::HexagonGrid
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
@@ -95,43 +98,99 @@ class Maps::HexagonGrid
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
+ 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
+ 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
+ SELECT
ST_AsGeoJSON(ST_Transform(hex_geom_utm, 4326)) as geojson,
hex_i,
hex_j,
- row_number() OVER (ORDER BY hex_i, hex_j) as id
- FROM hex_grid
- WHERE ST_Intersects(
- hex_geom_utm,
- (SELECT geom_utm FROM bbox_utm)
- )
+ 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'],
@@ -140,13 +199,17 @@ class Maps::HexagonGrid
hex_id: row['id'],
hex_i: row['hex_i'],
hex_j: row['hex_j'],
- hex_size: hex_size
+ 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 for area #{area_km2.round(2)} km²"
-
+ Rails.logger.info "Generated #{hexagons.count} hexagons containing #{total_points} points for area #{area_km2.round(2)} km²"
+
{
type: 'FeatureCollection',
features: hexagons,
@@ -155,18 +218,36 @@ class Maps::HexagonGrid
area_km2: area_km2.round(2),
hex_size_m: hex_size,
count: hexagons.count,
- estimated_count: estimated_hexagon_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?
if area_km2 > MAX_AREA_KM2
raise BoundingBoxTooLargeError, errors.full_messages.join(', ')
end
-
+
raise InvalidCoordinatesError, errors.full_messages.join(', ')
end
-end
\ No newline at end of file
+end