mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-11 09:41:40 -05:00
Extract logic to service
This commit is contained in:
parent
60705ac301
commit
b0f3289435
3 changed files with 184 additions and 29 deletions
|
|
@ -1,12 +1,10 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class Api::V1::Maps::HexagonsController < ApiController
|
class Api::V1::Maps::HexagonsController < ApiController
|
||||||
skip_before_action :authenticate_api_key
|
|
||||||
|
|
||||||
before_action :validate_bbox_params
|
before_action :validate_bbox_params
|
||||||
|
|
||||||
def index
|
def index
|
||||||
service = Maps::HexagonGrid.new(bbox_params)
|
service = Maps::HexagonGrid.new(hexagon_params)
|
||||||
result = service.call
|
result = service.call
|
||||||
|
|
||||||
render json: result
|
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)
|
params.permit(:min_lon, :min_lat, :max_lon, :max_lat, :hex_size)
|
||||||
end
|
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
|
def validate_bbox_params
|
||||||
required_params = %w[min_lon min_lat max_lon max_lat]
|
required_params = %w[min_lon min_lat max_lon max_lat]
|
||||||
missing_params = required_params.select { |param| params[param].blank? }
|
missing_params = required_params.select { |param| params[param].blank? }
|
||||||
|
|
|
||||||
|
|
@ -182,6 +182,11 @@ export class HexagonGrid {
|
||||||
this.loadingController = new AbortController();
|
this.loadingController = new AbortController();
|
||||||
|
|
||||||
try {
|
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({
|
const params = new URLSearchParams({
|
||||||
min_lon: bounds.getWest(),
|
min_lon: bounds.getWest(),
|
||||||
min_lat: bounds.getSouth(),
|
min_lat: bounds.getSouth(),
|
||||||
|
|
@ -189,6 +194,10 @@ export class HexagonGrid {
|
||||||
max_lat: bounds.getNorth()
|
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}`, {
|
const response = await fetch(`${this.options.apiEndpoint}&${params}`, {
|
||||||
signal: this.loadingController.signal,
|
signal: this.loadingController.signal,
|
||||||
headers: {
|
headers: {
|
||||||
|
|
@ -241,13 +250,21 @@ export class HexagonGrid {
|
||||||
return;
|
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, {
|
const geoJsonLayer = L.geoJSON(geojsonData, {
|
||||||
style: () => this.options.style,
|
style: (feature) => this.styleHexagonByData(feature, maxPoints),
|
||||||
onEachFeature: (feature, layer) => {
|
onEachFeature: (feature, layer) => {
|
||||||
|
// Add popup with statistics
|
||||||
|
const props = feature.properties;
|
||||||
|
const popupContent = this.buildPopupContent(props);
|
||||||
|
layer.bindPopup(popupContent);
|
||||||
|
|
||||||
// Add hover effects
|
// Add hover effects
|
||||||
layer.on({
|
layer.on({
|
||||||
mouseover: (e) => this.onHexagonMouseOver(e),
|
mouseover: (e) => this.onHexagonMouseOver(e, feature),
|
||||||
mouseout: (e) => this.onHexagonMouseOut(e),
|
mouseout: (e) => this.onHexagonMouseOut(e, feature),
|
||||||
click: (e) => this.onHexagonClick(e, feature)
|
click: (e) => this.onHexagonClick(e, feature)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -256,6 +273,57 @@ export class HexagonGrid {
|
||||||
geoJsonLayer.addTo(this.hexagonLayer);
|
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 `
|
||||||
|
<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>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle hexagon mouseover event
|
* Handle hexagon mouseover event
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ class Maps::HexagonGrid
|
||||||
class InvalidCoordinatesError < StandardError; end
|
class InvalidCoordinatesError < StandardError; end
|
||||||
class PostGISError < 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_lon, :max_lon, inclusion: { in: -180..180 }
|
||||||
validates :min_lat, :max_lat, inclusion: { in: -90..90 }
|
validates :min_lat, :max_lat, inclusion: { in: -90..90 }
|
||||||
|
|
@ -28,6 +28,9 @@ class Maps::HexagonGrid
|
||||||
@max_lon = params[:max_lon].to_f
|
@max_lon = params[:max_lon].to_f
|
||||||
@max_lat = params[:max_lat].to_f
|
@max_lat = params[:max_lat].to_f
|
||||||
@hex_size = params[:hex_size]&.to_f || DEFAULT_HEX_SIZE
|
@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
|
end
|
||||||
|
|
||||||
def call
|
def call
|
||||||
|
|
@ -95,6 +98,9 @@ class Maps::HexagonGrid
|
||||||
end
|
end
|
||||||
|
|
||||||
def build_hexagon_sql
|
def build_hexagon_sql
|
||||||
|
user_filter = user_id ? "user_id = #{user_id}" : "1=1"
|
||||||
|
date_filter = build_date_filter
|
||||||
|
|
||||||
<<~SQL
|
<<~SQL
|
||||||
WITH bbox_geom AS (
|
WITH bbox_geom AS (
|
||||||
SELECT ST_MakeEnvelope(#{min_lon}, #{min_lat}, #{max_lon}, #{max_lat}, 4326) as geom
|
SELECT ST_MakeEnvelope(#{min_lon}, #{min_lat}, #{max_lon}, #{max_lat}, 4326) as geom
|
||||||
|
|
@ -105,33 +111,86 @@ class Maps::HexagonGrid
|
||||||
geom as geom_wgs84
|
geom as geom_wgs84
|
||||||
FROM bbox_geom
|
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 (
|
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)).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)).i as hex_i,
|
||||||
(ST_HexagonGrid(#{hex_size}, bbox_utm.geom_utm)).j as hex_j
|
(ST_HexagonGrid(#{hex_size}, bbox_utm.geom_utm)).j as hex_j
|
||||||
FROM bbox_utm
|
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,
|
ST_AsGeoJSON(ST_Transform(hex_geom_utm, 4326)) as geojson,
|
||||||
hex_i,
|
hex_i,
|
||||||
hex_j,
|
hex_j,
|
||||||
row_number() OVER (ORDER BY hex_i, hex_j) as id
|
point_count,
|
||||||
FROM hex_grid
|
earliest_point,
|
||||||
WHERE ST_Intersects(
|
latest_point,
|
||||||
hex_geom_utm,
|
row_number() OVER (ORDER BY point_count DESC) as id
|
||||||
(SELECT geom_utm FROM bbox_utm)
|
FROM hexagon_stats
|
||||||
)
|
ORDER BY point_count DESC
|
||||||
LIMIT #{MAX_HEXAGONS_PER_REQUEST};
|
LIMIT #{MAX_HEXAGONS_PER_REQUEST};
|
||||||
SQL
|
SQL
|
||||||
end
|
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)
|
def execute_sql(sql)
|
||||||
ActiveRecord::Base.connection.execute(sql)
|
ActiveRecord::Base.connection.execute(sql)
|
||||||
end
|
end
|
||||||
|
|
||||||
def format_hexagons(result)
|
def format_hexagons(result)
|
||||||
|
total_points = 0
|
||||||
|
|
||||||
hexagons = result.map do |row|
|
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',
|
type: 'Feature',
|
||||||
id: row['id'],
|
id: row['id'],
|
||||||
|
|
@ -140,12 +199,16 @@ class Maps::HexagonGrid
|
||||||
hex_id: row['id'],
|
hex_id: row['id'],
|
||||||
hex_i: row['hex_i'],
|
hex_i: row['hex_i'],
|
||||||
hex_j: row['hex_j'],
|
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
|
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',
|
type: 'FeatureCollection',
|
||||||
|
|
@ -155,11 +218,29 @@ class Maps::HexagonGrid
|
||||||
area_km2: area_km2.round(2),
|
area_km2: area_km2.round(2),
|
||||||
hex_size_m: hex_size,
|
hex_size_m: hex_size,
|
||||||
count: hexagons.count,
|
count: hexagons.count,
|
||||||
estimated_count: estimated_hexagon_count
|
total_points: total_points,
|
||||||
|
user_id: user_id,
|
||||||
|
date_range: build_date_range_metadata
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
end
|
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!
|
def validate!
|
||||||
return if valid?
|
return if valid?
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue