mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-11 01:31:39 -05:00
Precalculate hexagons for stats
This commit is contained in:
parent
bab666b182
commit
a2aa1be271
11 changed files with 204 additions and 52 deletions
|
|
@ -6,9 +6,26 @@ class Api::V1::Maps::HexagonsController < ApiController
|
||||||
before_action :set_user_and_dates
|
before_action :set_user_and_dates
|
||||||
|
|
||||||
def index
|
def index
|
||||||
service = Maps::HexagonGrid.new(hexagon_params)
|
hex_size = bbox_params[:hex_size]&.to_f || 1000.0
|
||||||
result = service.call
|
cache_service = HexagonCacheService.new(
|
||||||
|
user: @target_user,
|
||||||
|
stat: @stat,
|
||||||
|
start_date: @start_date,
|
||||||
|
end_date: @end_date
|
||||||
|
)
|
||||||
|
|
||||||
|
# Try to use pre-calculated hexagon data if available
|
||||||
|
if cache_service.available?(hex_size)
|
||||||
|
cached_result = cache_service.cached_geojson(hex_size)
|
||||||
|
if cached_result
|
||||||
|
Rails.logger.debug 'Using cached hexagon data'
|
||||||
|
return render json: cached_result
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Fall back to on-the-fly calculation
|
||||||
|
Rails.logger.debug 'Calculating hexagons on-the-fly'
|
||||||
|
result = Maps::HexagonGrid.new(hexagon_params).call
|
||||||
Rails.logger.debug "Hexagon service result: #{result['features']&.count || 0} features"
|
Rails.logger.debug "Hexagon service result: #{result['features']&.count || 0} features"
|
||||||
render json: result
|
render json: result
|
||||||
rescue Maps::HexagonGrid::BoundingBoxTooLargeError,
|
rescue Maps::HexagonGrid::BoundingBoxTooLargeError,
|
||||||
|
|
@ -26,32 +43,8 @@ class Api::V1::Maps::HexagonsController < ApiController
|
||||||
return render json: { error: 'No date range specified' }, status: :bad_request unless @start_date && @end_date
|
return render json: { error: 'No date range specified' }, status: :bad_request unless @start_date && @end_date
|
||||||
|
|
||||||
# Convert dates to timestamps (handle both string and timestamp formats)
|
# Convert dates to timestamps (handle both string and timestamp formats)
|
||||||
start_timestamp = case @start_date
|
start_timestamp = coerce_date(@start_date)
|
||||||
when String
|
end_timestamp = coerce_date(@end_date)
|
||||||
# Check if it's a numeric string (timestamp) or date string
|
|
||||||
if @start_date.match?(/^\d+$/)
|
|
||||||
@start_date.to_i
|
|
||||||
else
|
|
||||||
Time.parse(@start_date).to_i
|
|
||||||
end
|
|
||||||
when Integer
|
|
||||||
@start_date
|
|
||||||
else
|
|
||||||
@start_date.to_i
|
|
||||||
end
|
|
||||||
end_timestamp = case @end_date
|
|
||||||
when String
|
|
||||||
# Check if it's a numeric string (timestamp) or date string
|
|
||||||
if @end_date.match?(/^\d+$/)
|
|
||||||
@end_date.to_i
|
|
||||||
else
|
|
||||||
Time.parse(@end_date).to_i
|
|
||||||
end
|
|
||||||
when Integer
|
|
||||||
@end_date
|
|
||||||
else
|
|
||||||
@end_date.to_i
|
|
||||||
end
|
|
||||||
|
|
||||||
points_relation = @target_user.points.where(timestamp: start_timestamp..end_timestamp)
|
points_relation = @target_user.points.where(timestamp: start_timestamp..end_timestamp)
|
||||||
point_count = points_relation.count
|
point_count = points_relation.count
|
||||||
|
|
@ -140,4 +133,20 @@ class Api::V1::Maps::HexagonsController < ApiController
|
||||||
error: "Missing required parameters: #{missing_params.join(', ')}"
|
error: "Missing required parameters: #{missing_params.join(', ')}"
|
||||||
}, status: :bad_request
|
}, status: :bad_request
|
||||||
end
|
end
|
||||||
|
|
||||||
|
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
|
||||||
|
when Integer
|
||||||
|
param
|
||||||
|
else
|
||||||
|
param.to_i
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ class Shared::StatsController < ApplicationController
|
||||||
@user = @stat.user
|
@user = @stat.user
|
||||||
@is_public_view = true
|
@is_public_view = true
|
||||||
@data_bounds = @stat.calculate_data_bounds
|
@data_bounds = @stat.calculate_data_bounds
|
||||||
|
@hexagons_available = @stat.hexagons_available?
|
||||||
|
|
||||||
render 'stats/public_month'
|
render 'stats/public_month'
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ export default class extends BaseController {
|
||||||
month: Number,
|
month: Number,
|
||||||
uuid: String,
|
uuid: String,
|
||||||
dataBounds: Object,
|
dataBounds: Object,
|
||||||
|
hexagonsAvailable: Boolean,
|
||||||
selfHosted: String
|
selfHosted: String
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -122,13 +123,16 @@ export default class extends BaseController {
|
||||||
this.map.off('moveend');
|
this.map.off('moveend');
|
||||||
this.map.off('zoomend');
|
this.map.off('zoomend');
|
||||||
|
|
||||||
// Load hexagons only once on page load (static behavior)
|
// Load hexagons only if they are pre-calculated and data exists
|
||||||
// NOTE: Do NOT hide loading overlay here - let loadStaticHexagons() handle it
|
if (dataBounds && dataBounds.point_count > 0 && this.hexagonsAvailableValue) {
|
||||||
if (dataBounds && dataBounds.point_count > 0) {
|
|
||||||
await this.loadStaticHexagons();
|
await this.loadStaticHexagons();
|
||||||
} else {
|
} else {
|
||||||
console.warn('No data bounds or points available - not showing hexagons');
|
if (!this.hexagonsAvailableValue) {
|
||||||
// Only hide loading indicator if no hexagons to load
|
console.log('No pre-calculated hexagons available - skipping hexagon loading');
|
||||||
|
} else {
|
||||||
|
console.warn('No data bounds or points available - not showing hexagons');
|
||||||
|
}
|
||||||
|
// Hide loading indicator if no hexagons to load
|
||||||
const loadingElement = document.getElementById('map-loading');
|
const loadingElement = document.getElementById('map-loading');
|
||||||
if (loadingElement) {
|
if (loadingElement) {
|
||||||
loadingElement.style.display = 'none';
|
loadingElement.style.display = 'none';
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,10 @@ class Stat < ApplicationRecord
|
||||||
sharing_enabled? && !sharing_expired?
|
sharing_enabled? && !sharing_expired?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def hexagons_available?(hex_size = 1000)
|
||||||
|
hexagon_data&.dig(hex_size.to_s, 'geojson').present?
|
||||||
|
end
|
||||||
|
|
||||||
def generate_new_sharing_uuid!
|
def generate_new_sharing_uuid!
|
||||||
update!(sharing_uuid: SecureRandom.uuid)
|
update!(sharing_uuid: SecureRandom.uuid)
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -35,10 +35,7 @@ class HexagonQuery
|
||||||
SELECT ST_MakeEnvelope($1, $2, $3, $4, 4326) as geom
|
SELECT ST_MakeEnvelope($1, $2, $3, $4, 4326) as geom
|
||||||
),
|
),
|
||||||
bbox_utm AS (
|
bbox_utm AS (
|
||||||
SELECT
|
SELECT ST_Transform(geom, 3857) as geom_utm FROM bbox_geom
|
||||||
ST_Transform(geom, 3857) as geom_utm,
|
|
||||||
geom as geom_wgs84
|
|
||||||
FROM bbox_geom
|
|
||||||
),
|
),
|
||||||
user_points AS (
|
user_points AS (
|
||||||
SELECT
|
SELECT
|
||||||
|
|
@ -49,25 +46,22 @@ class HexagonQuery
|
||||||
FROM points
|
FROM points
|
||||||
WHERE #{user_sql}
|
WHERE #{user_sql}
|
||||||
#{date_filter}
|
#{date_filter}
|
||||||
AND ST_Intersects(
|
AND lonlat && (SELECT geom FROM bbox_geom)
|
||||||
lonlat,
|
|
||||||
(SELECT geom FROM bbox_geom)::geometry
|
|
||||||
)
|
|
||||||
),
|
),
|
||||||
hex_grid AS (
|
hex_grid AS (
|
||||||
SELECT
|
SELECT
|
||||||
(ST_HexagonGrid($5, bbox_utm.geom_utm)).geom as hex_geom_utm,
|
(ST_HexagonGrid($5, geom_utm)).geom as hex_geom_utm,
|
||||||
(ST_HexagonGrid($5, bbox_utm.geom_utm)).i as hex_i,
|
(ST_HexagonGrid($5, geom_utm)).i as hex_i,
|
||||||
(ST_HexagonGrid($5, bbox_utm.geom_utm)).j as hex_j
|
(ST_HexagonGrid($5, geom_utm)).j as hex_j
|
||||||
FROM bbox_utm
|
FROM bbox_utm
|
||||||
),
|
),
|
||||||
hexagons_with_points AS (
|
hexagons_with_points AS (
|
||||||
SELECT DISTINCT
|
SELECT DISTINCT
|
||||||
hex_geom_utm,
|
hg.hex_geom_utm,
|
||||||
hex_i,
|
hg.hex_i,
|
||||||
hex_j
|
hg.hex_j
|
||||||
FROM hex_grid hg
|
FROM hex_grid hg
|
||||||
INNER JOIN user_points up ON ST_Intersects(hg.hex_geom_utm, up.point_geom_utm)
|
JOIN user_points up ON ST_Intersects(hg.hex_geom_utm, up.point_geom_utm)
|
||||||
),
|
),
|
||||||
hexagon_stats AS (
|
hexagon_stats AS (
|
||||||
SELECT
|
SELECT
|
||||||
|
|
@ -78,7 +72,7 @@ class HexagonQuery
|
||||||
MIN(up.timestamp) as earliest_point,
|
MIN(up.timestamp) as earliest_point,
|
||||||
MAX(up.timestamp) as latest_point
|
MAX(up.timestamp) as latest_point
|
||||||
FROM hexagons_with_points hwp
|
FROM hexagons_with_points hwp
|
||||||
INNER JOIN user_points up ON ST_Intersects(hwp.hex_geom_utm, up.point_geom_utm)
|
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
|
GROUP BY hwp.hex_geom_utm, hwp.hex_i, hwp.hex_j
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
|
|
|
||||||
57
app/services/hexagon_cache_service.rb
Normal file
57
app/services/hexagon_cache_service.rb
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
# 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
|
||||||
|
|
@ -37,7 +37,8 @@ class Stats::CalculateMonth
|
||||||
stat.assign_attributes(
|
stat.assign_attributes(
|
||||||
daily_distance: distance_by_day,
|
daily_distance: distance_by_day,
|
||||||
distance: distance(distance_by_day),
|
distance: distance(distance_by_day),
|
||||||
toponyms: toponyms
|
toponyms: toponyms,
|
||||||
|
hexagon_data: calculate_hexagons
|
||||||
)
|
)
|
||||||
stat.save
|
stat.save
|
||||||
end
|
end
|
||||||
|
|
@ -82,4 +83,77 @@ class Stats::CalculateMonth
|
||||||
def destroy_month_stats(year, month)
|
def destroy_month_stats(year, month)
|
||||||
Stat.where(year:, month:, user:).destroy_all
|
Stat.where(year:, month:, user:).destroy_all
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def calculate_hexagons
|
||||||
|
return nil if points.empty?
|
||||||
|
|
||||||
|
# Calculate bounding box for the user's points in this month
|
||||||
|
bounds = calculate_data_bounds
|
||||||
|
return nil unless bounds
|
||||||
|
|
||||||
|
# Pre-calculate hexagons for 1000m size used across the system
|
||||||
|
hexagon_sizes = [1000] # 1000m hexagons for consistent visualization
|
||||||
|
|
||||||
|
hexagon_sizes.each_with_object({}) do |hex_size, result|
|
||||||
|
begin
|
||||||
|
service = Maps::HexagonGrid.new(
|
||||||
|
min_lon: bounds[:min_lng],
|
||||||
|
min_lat: bounds[:min_lat],
|
||||||
|
max_lon: bounds[:max_lng],
|
||||||
|
max_lat: bounds[:max_lat],
|
||||||
|
hex_size: hex_size,
|
||||||
|
user_id: user.id,
|
||||||
|
start_date: start_date_iso8601,
|
||||||
|
end_date: end_date_iso8601
|
||||||
|
)
|
||||||
|
|
||||||
|
geojson_result = service.call
|
||||||
|
|
||||||
|
# Store the complete GeoJSON result for instant serving
|
||||||
|
result[hex_size.to_s] = {
|
||||||
|
'geojson' => geojson_result,
|
||||||
|
'bbox' => bounds,
|
||||||
|
'generated_at' => Time.current.iso8601
|
||||||
|
}
|
||||||
|
|
||||||
|
Rails.logger.info "Pre-calculated #{geojson_result['features']&.size || 0} hexagons (#{hex_size}m) for user #{user.id}, #{year}-#{month}"
|
||||||
|
rescue Maps::HexagonGrid::BoundingBoxTooLargeError,
|
||||||
|
Maps::HexagonGrid::InvalidCoordinatesError,
|
||||||
|
Maps::HexagonGrid::PostGISError => e
|
||||||
|
Rails.logger.warn "Hexagon calculation failed for user #{user.id}, #{year}-#{month}, size #{hex_size}m: #{e.message}"
|
||||||
|
# Continue with other sizes even if one fails
|
||||||
|
next
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def calculate_data_bounds
|
||||||
|
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_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 start_date_iso8601
|
||||||
|
DateTime.new(year, month, 1).beginning_of_day.iso8601
|
||||||
|
end
|
||||||
|
|
||||||
|
def end_date_iso8601
|
||||||
|
DateTime.new(year, month, -1).end_of_day.iso8601
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,7 @@
|
||||||
data-public-stat-map-month-value="<%= @month %>"
|
data-public-stat-map-month-value="<%= @month %>"
|
||||||
data-public-stat-map-uuid-value="<%= @stat.sharing_uuid %>"
|
data-public-stat-map-uuid-value="<%= @stat.sharing_uuid %>"
|
||||||
data-public-stat-map-data-bounds-value="<%= @data_bounds.to_json if @data_bounds %>"
|
data-public-stat-map-data-bounds-value="<%= @data_bounds.to_json if @data_bounds %>"
|
||||||
|
data-public-stat-map-hexagons-available-value="<%= @hexagons_available %>"
|
||||||
data-public-stat-map-self-hosted-value="<%= @self_hosted %>"></div>
|
data-public-stat-map-self-hosted-value="<%= @self_hosted %>"></div>
|
||||||
|
|
||||||
<!-- Loading overlay -->
|
<!-- Loading overlay -->
|
||||||
|
|
|
||||||
7
db/migrate/20250913194134_add_hexagon_data_to_stats.rb
Normal file
7
db/migrate/20250913194134_add_hexagon_data_to_stats.rb
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class AddHexagonDataToStats < ActiveRecord::Migration[8.0]
|
||||||
|
def change
|
||||||
|
add_column :stats, :hexagon_data, :jsonb
|
||||||
|
end
|
||||||
|
end
|
||||||
3
db/schema.rb
generated
3
db/schema.rb
generated
|
|
@ -10,7 +10,7 @@
|
||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[8.0].define(version: 2025_09_10_224714) do
|
ActiveRecord::Schema[8.0].define(version: 2025_09_13_194134) do
|
||||||
# These are extensions that must be enabled in order to support this database
|
# These are extensions that must be enabled in order to support this database
|
||||||
enable_extension "pg_catalog.plpgsql"
|
enable_extension "pg_catalog.plpgsql"
|
||||||
enable_extension "postgis"
|
enable_extension "postgis"
|
||||||
|
|
@ -222,6 +222,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_10_224714) do
|
||||||
t.jsonb "daily_distance", default: {}
|
t.jsonb "daily_distance", default: {}
|
||||||
t.jsonb "sharing_settings", default: {}
|
t.jsonb "sharing_settings", default: {}
|
||||||
t.uuid "sharing_uuid"
|
t.uuid "sharing_uuid"
|
||||||
|
t.jsonb "hexagon_data"
|
||||||
t.index ["distance"], name: "index_stats_on_distance"
|
t.index ["distance"], name: "index_stats_on_distance"
|
||||||
t.index ["month"], name: "index_stats_on_month"
|
t.index ["month"], name: "index_stats_on_month"
|
||||||
t.index ["sharing_uuid"], name: "index_stats_on_sharing_uuid", unique: true
|
t.index ["sharing_uuid"], name: "index_stats_on_sharing_uuid", unique: true
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue