mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-11 01:31:39 -05:00
Implement area selecting to show visits
This commit is contained in:
parent
adf923542d
commit
52fd54e39f
15 changed files with 580 additions and 155 deletions
|
|
@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
|||
TODO:
|
||||
|
||||
- Selecting a visit should put it above other visits on the map to make it easier to edit it. If many visits are on the same place, we should be able to click on them
|
||||
- Do we need to reverse geocode places if we already got their data with address during visit suggestion?
|
||||
|
||||
# 0.24.2 - 2025-02-24
|
||||
|
||||
|
|
|
|||
|
|
@ -75,3 +75,31 @@
|
|||
.controls-shifted {
|
||||
right: 338px !important;
|
||||
}
|
||||
|
||||
/* Selection Tool Styles */
|
||||
.leaflet-control-custom {
|
||||
background-color: white;
|
||||
border-radius: 4px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.leaflet-control-custom:hover {
|
||||
background-color: #f3f4f6;
|
||||
}
|
||||
|
||||
#selection-tool-button.active {
|
||||
background-color: #60a5fa;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Cancel Selection Button */
|
||||
#cancel-selection-button {
|
||||
margin-bottom: 1rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,23 +2,43 @@
|
|||
|
||||
class Api::V1::VisitsController < ApiController
|
||||
def index
|
||||
start_time = begin
|
||||
Time.zone.parse(params[:start_at])
|
||||
rescue StandardError
|
||||
Time.zone.now.beginning_of_day
|
||||
end
|
||||
end_time = begin
|
||||
Time.zone.parse(params[:end_at])
|
||||
rescue StandardError
|
||||
Time.zone.now.end_of_day
|
||||
end
|
||||
# If selection is true, filter by coordinates instead of time
|
||||
if params[:selection] == 'true' && params[:sw_lat].present? && params[:sw_lng].present? && params[:ne_lat].present? && params[:ne_lng].present?
|
||||
sw_lat = params[:sw_lat].to_f
|
||||
sw_lng = params[:sw_lng].to_f
|
||||
ne_lat = params[:ne_lat].to_f
|
||||
ne_lng = params[:ne_lng].to_f
|
||||
|
||||
visits =
|
||||
Visit
|
||||
.includes(:place)
|
||||
.where(user: current_api_user)
|
||||
.where('started_at >= ? AND ended_at <= ?', start_time, end_time)
|
||||
.order(started_at: :desc)
|
||||
# Create the PostGIS bounding box polygon
|
||||
bounding_box = "ST_MakeEnvelope(#{sw_lng}, #{sw_lat}, #{ne_lng}, #{ne_lat}, 4326)"
|
||||
|
||||
visits =
|
||||
Visit
|
||||
.includes(:place)
|
||||
.where(user: current_api_user)
|
||||
.joins(:place)
|
||||
.where("ST_Contains(#{bounding_box}, ST_SetSRID(places.lonlat::geometry, 4326))")
|
||||
.order(started_at: :desc)
|
||||
else
|
||||
# Regular time-based filtering
|
||||
start_time = begin
|
||||
Time.zone.parse(params[:start_at])
|
||||
rescue StandardError
|
||||
Time.zone.now.beginning_of_day
|
||||
end
|
||||
end_time = begin
|
||||
Time.zone.parse(params[:end_at])
|
||||
rescue StandardError
|
||||
Time.zone.now.end_of_day
|
||||
end
|
||||
|
||||
visits =
|
||||
Visit
|
||||
.includes(:place)
|
||||
.where(user: current_api_user)
|
||||
.where('started_at >= ? AND ended_at <= ?', start_time, end_time)
|
||||
.order(started_at: :desc)
|
||||
end
|
||||
|
||||
serialized_visits = visits.map do |visit|
|
||||
Api::VisitSerializer.new(visit).call
|
||||
|
|
|
|||
|
|
@ -12,6 +12,9 @@ export class VisitsManager {
|
|||
this.confirmedVisitCircles = L.layerGroup().addTo(map); // Always visible layer for confirmed visits
|
||||
this.currentPopup = null;
|
||||
this.drawerOpen = false;
|
||||
this.selectionMode = false;
|
||||
this.selectionRect = null;
|
||||
this.isSelectionActive = false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -62,6 +65,187 @@ export class VisitsManager {
|
|||
});
|
||||
|
||||
this.map.addControl(new DrawerControl({ position: 'topright' }));
|
||||
|
||||
// Add the selection tool button
|
||||
this.addSelectionButton();
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a button to enable/disable the area selection tool
|
||||
*/
|
||||
addSelectionButton() {
|
||||
const SelectionControl = L.Control.extend({
|
||||
onAdd: (map) => {
|
||||
const button = L.DomUtil.create('button', 'leaflet-bar leaflet-control leaflet-control-custom');
|
||||
button.innerHTML = '<i class="fas fa-draw-polygon"></i>';
|
||||
button.title = 'Select Area';
|
||||
button.id = 'selection-tool-button';
|
||||
button.onclick = () => this.toggleSelectionMode();
|
||||
return button;
|
||||
}
|
||||
});
|
||||
|
||||
new SelectionControl({ position: 'topright' }).addTo(this.map);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles the area selection mode
|
||||
*/
|
||||
toggleSelectionMode() {
|
||||
if (this.selectionMode) {
|
||||
// Disable selection mode
|
||||
this.selectionMode = false;
|
||||
this.map.dragging.enable();
|
||||
document.getElementById('selection-tool-button').classList.remove('active');
|
||||
this.map.off('mousedown', this.onMouseDown, this);
|
||||
} else {
|
||||
// Enable selection mode
|
||||
this.selectionMode = true;
|
||||
document.getElementById('selection-tool-button').classList.add('active');
|
||||
this.map.dragging.disable();
|
||||
this.map.on('mousedown', this.onMouseDown, this);
|
||||
|
||||
showFlashMessage('info', 'Selection mode enabled. Click and drag to select an area.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the mousedown event to start the selection
|
||||
*/
|
||||
onMouseDown(e) {
|
||||
// Clear any existing selection
|
||||
this.clearSelection();
|
||||
|
||||
// Store start point and create rectangle
|
||||
this.startPoint = e.latlng;
|
||||
|
||||
// Add mousemove and mouseup listeners
|
||||
this.map.on('mousemove', this.onMouseMove, this);
|
||||
this.map.on('mouseup', this.onMouseUp, this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the mousemove event to update the selection rectangle
|
||||
*/
|
||||
onMouseMove(e) {
|
||||
if (!this.startPoint) return;
|
||||
|
||||
// If we already have a rectangle, update its bounds
|
||||
if (this.selectionRect) {
|
||||
const bounds = L.latLngBounds(this.startPoint, e.latlng);
|
||||
this.selectionRect.setBounds(bounds);
|
||||
} else {
|
||||
// Create a new rectangle
|
||||
this.selectionRect = L.rectangle(
|
||||
L.latLngBounds(this.startPoint, e.latlng),
|
||||
{ color: '#3388ff', weight: 2, fillOpacity: 0.1 }
|
||||
).addTo(this.map);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the mouseup event to complete the selection
|
||||
*/
|
||||
onMouseUp(e) {
|
||||
// Remove the mouse event listeners
|
||||
this.map.off('mousemove', this.onMouseMove, this);
|
||||
this.map.off('mouseup', this.onMouseUp, this);
|
||||
|
||||
if (!this.selectionRect) return;
|
||||
|
||||
// Finalize the selection
|
||||
this.isSelectionActive = true;
|
||||
|
||||
// Re-enable map dragging
|
||||
this.map.dragging.enable();
|
||||
|
||||
// Disable selection mode
|
||||
this.selectionMode = false;
|
||||
document.getElementById('selection-tool-button').classList.remove('active');
|
||||
this.map.off('mousedown', this.onMouseDown, this);
|
||||
|
||||
// Fetch visits within the selection
|
||||
this.fetchVisitsInSelection();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the current area selection
|
||||
*/
|
||||
clearSelection() {
|
||||
if (this.selectionRect) {
|
||||
this.map.removeLayer(this.selectionRect);
|
||||
this.selectionRect = null;
|
||||
}
|
||||
this.isSelectionActive = false;
|
||||
this.startPoint = null;
|
||||
|
||||
// If the drawer is open, refresh with time-based visits
|
||||
if (this.drawerOpen) {
|
||||
this.fetchAndDisplayVisits();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches visits within the selected area
|
||||
*/
|
||||
async fetchVisitsInSelection() {
|
||||
if (!this.selectionRect) return;
|
||||
|
||||
const bounds = this.selectionRect.getBounds();
|
||||
const sw = bounds.getSouthWest();
|
||||
const ne = bounds.getNorthEast();
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/v1/visits?selection=true&sw_lat=${sw.lat}&sw_lng=${sw.lng}&ne_lat=${ne.lat}&ne_lng=${ne.lng}`,
|
||||
{
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${this.apiKey}`,
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Network response was not ok');
|
||||
}
|
||||
|
||||
const visits = await response.json();
|
||||
this.displayVisits(visits);
|
||||
|
||||
// Make sure the drawer is open
|
||||
if (!this.drawerOpen) {
|
||||
this.toggleDrawer();
|
||||
}
|
||||
|
||||
// Add cancel selection button to the drawer
|
||||
this.addSelectionCancelButton();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching visits in selection:', error);
|
||||
showFlashMessage('error', 'Failed to load visits in selected area');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a cancel button to the drawer to clear the selection
|
||||
*/
|
||||
addSelectionCancelButton() {
|
||||
const container = document.getElementById('visits-list');
|
||||
if (!container) return;
|
||||
|
||||
// Add cancel button at the top of the drawer if it doesn't exist
|
||||
if (!document.getElementById('cancel-selection-button')) {
|
||||
const cancelButton = document.createElement('button');
|
||||
cancelButton.id = 'cancel-selection-button';
|
||||
cancelButton.className = 'btn btn-sm btn-warning mb-4 w-full';
|
||||
cancelButton.textContent = 'Cancel Area Selection';
|
||||
cancelButton.onclick = () => this.clearSelection();
|
||||
|
||||
// Insert at the beginning of the container
|
||||
container.insertBefore(cancelButton, container.firstChild);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -138,6 +322,12 @@ export class VisitsManager {
|
|||
*/
|
||||
async fetchAndDisplayVisits() {
|
||||
try {
|
||||
// If there's an active selection, don't perform time-based fetch
|
||||
if (this.isSelectionActive && this.selectionRect) {
|
||||
this.fetchVisitsInSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
// Get current timeframe from URL parameters
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const startAt = urlParams.get('start_at') || new Date().toISOString();
|
||||
|
|
@ -753,6 +943,7 @@ export class VisitsManager {
|
|||
closeButton: true,
|
||||
closeOnClick: true,
|
||||
autoClose: true,
|
||||
closeOnEscapeKey: true,
|
||||
maxWidth: 450, // Set maximum width
|
||||
minWidth: 300 // Set minimum width
|
||||
})
|
||||
|
|
|
|||
47
app/jobs/bulk_visits_suggesting_job.rb
Normal file
47
app/jobs/bulk_visits_suggesting_job.rb
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class BulkVisitsSuggestingJob < ApplicationJob
|
||||
queue_as :default
|
||||
sidekiq_options retry: false
|
||||
|
||||
# Passing timespan of more than 3 years somehow results in duplicated Places
|
||||
def perform(start_at:, end_at:, user_ids: [])
|
||||
users = user_ids.any? ? User.where(id: user_ids) : User.all
|
||||
start_at = start_at.to_datetime
|
||||
end_at = end_at.to_datetime
|
||||
|
||||
time_chunks = time_chunks(start_at:, end_at:)
|
||||
|
||||
users.active.find_each do |user|
|
||||
next if user.tracked_points.empty?
|
||||
|
||||
time_chunks.each do |time_chunk|
|
||||
VisitSuggestingJob.perform_later(
|
||||
user_id: user.id, start_at: time_chunk.first, end_at: time_chunk.last
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def time_chunks(start_at:, end_at:)
|
||||
time_chunks = []
|
||||
|
||||
# First chunk: from start_at to end of that year
|
||||
first_end = start_at.end_of_year
|
||||
time_chunks << (start_at...first_end) if start_at < first_end
|
||||
|
||||
# Full-year chunks
|
||||
current = first_end.beginning_of_year + 1.year # Start from the next full year
|
||||
while current + 1.year <= end_at.beginning_of_year
|
||||
time_chunks << (current...current + 1.year)
|
||||
current += 1.year
|
||||
end
|
||||
|
||||
# Last chunk: from start of the last year to end_at
|
||||
time_chunks << (current...end_at) if current < end_at
|
||||
|
||||
time_chunks
|
||||
end
|
||||
end
|
||||
|
|
@ -4,22 +4,14 @@ class VisitSuggestingJob < ApplicationJob
|
|||
queue_as :visit_suggesting
|
||||
sidekiq_options retry: false
|
||||
|
||||
def perform(user_ids: [], start_at: 1.day.ago, end_at: Time.current)
|
||||
users = user_ids.any? ? User.where(id: user_ids) : User.all
|
||||
start_at = start_at.to_datetime
|
||||
end_at = end_at.to_datetime
|
||||
# Passing timespan of more than 3 years somehow results in duplicated Places
|
||||
def perform(user_id:, start_at:, end_at:)
|
||||
user = User.find(user_id)
|
||||
|
||||
users.find_each do |user|
|
||||
next unless user.active?
|
||||
next if user.tracked_points.empty?
|
||||
time_chunks = (start_at..end_at).step(1.day).to_a
|
||||
|
||||
# Split the time range into 24-hour chunks
|
||||
# This prevents from places duplicates
|
||||
time_chunks = (start_at..end_at).step(1.day).to_a
|
||||
|
||||
time_chunks.each do |time_chunk|
|
||||
Visits::Suggest.new(user, start_at: time_chunk, end_at: time_chunk + 1.day).call
|
||||
end
|
||||
time_chunks.each do |time_chunk|
|
||||
Visits::Suggest.new(user, start_at: time_chunk, end_at: time_chunk + 1.day).call
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -37,9 +37,9 @@ class Visit < ApplicationRecord
|
|||
|
||||
def center
|
||||
if area.present?
|
||||
area.to_coordinates
|
||||
[area.lat, area.lon]
|
||||
elsif place.present?
|
||||
place.to_coordinates
|
||||
[place.lat, place.lon]
|
||||
else
|
||||
center_from_points
|
||||
end
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ class ReverseGeocoding::Places::FetchData
|
|||
|
||||
first_place = reverse_geocoded_places.shift
|
||||
update_place(first_place)
|
||||
add_suggested_place_to_a_visit
|
||||
reverse_geocoded_places.each { |reverse_geocoded_place| fetch_and_create_place(reverse_geocoded_place) }
|
||||
end
|
||||
|
||||
|
|
@ -52,24 +51,12 @@ class ReverseGeocoding::Places::FetchData
|
|||
new_place.source = :photon
|
||||
|
||||
new_place.save!
|
||||
|
||||
add_suggested_place_to_a_visit(suggested_place: new_place)
|
||||
end
|
||||
|
||||
def reverse_geocoded?
|
||||
place.geodata.present?
|
||||
end
|
||||
|
||||
def add_suggested_place_to_a_visit(suggested_place: place)
|
||||
visits = Place.near([suggested_place.latitude, suggested_place.longitude], 0.1).flat_map(&:visits)
|
||||
|
||||
visits.each do |visit|
|
||||
next if visit.suggested_places.include?(suggested_place)
|
||||
|
||||
visit.suggested_places << suggested_place
|
||||
end
|
||||
end
|
||||
|
||||
def find_place(place_data)
|
||||
found_place = Place.where(
|
||||
"geodata->'properties'->>'osm_id' = ?", place_data['properties']['osm_id'].to_s
|
||||
|
|
|
|||
|
|
@ -11,31 +11,65 @@ module Visits
|
|||
|
||||
def create_visits(visits)
|
||||
visits.map do |visit_data|
|
||||
# Variables to store data outside the transaction
|
||||
visit_instance = nil
|
||||
place_data = nil
|
||||
|
||||
# First transaction to create the visit
|
||||
ActiveRecord::Base.transaction do
|
||||
# Try to find matching area or place
|
||||
area = find_matching_area(visit_data)
|
||||
place = area ? nil : PlaceFinder.new(user).find_or_create_place(visit_data)
|
||||
|
||||
visit = Visit.create!(
|
||||
# Only find/create place if no area was found
|
||||
place_data = PlaceFinder.new(user).find_or_create_place(visit_data) unless area
|
||||
|
||||
main_place = place_data&.dig(:main_place)
|
||||
|
||||
visit_instance = Visit.create!(
|
||||
user: user,
|
||||
area: area,
|
||||
place: place,
|
||||
place: main_place,
|
||||
started_at: Time.zone.at(visit_data[:start_time]),
|
||||
ended_at: Time.zone.at(visit_data[:end_time]),
|
||||
duration: visit_data[:duration] / 60, # Convert to minutes
|
||||
name: generate_visit_name(area, place, visit_data[:suggested_name]),
|
||||
name: generate_visit_name(area, main_place, visit_data[:suggested_name]),
|
||||
status: :suggested
|
||||
)
|
||||
|
||||
Point.where(id: visit_data[:points].map(&:id)).update_all(visit_id: visit.id)
|
||||
|
||||
visit
|
||||
Point.where(id: visit_data[:points].map(&:id)).update_all(visit_id: visit_instance.id)
|
||||
end
|
||||
|
||||
# Associate suggested places outside the main transaction
|
||||
# to avoid deadlocks when multiple processes run simultaneously
|
||||
if place_data&.dig(:suggested_places).present?
|
||||
associate_suggested_places(visit_instance, place_data[:suggested_places])
|
||||
end
|
||||
|
||||
visit_instance
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Create place_visits records directly to avoid deadlocks
|
||||
def associate_suggested_places(visit, suggested_places)
|
||||
existing_place_ids = visit.place_visits.pluck(:place_id)
|
||||
|
||||
# Only create associations that don't already exist
|
||||
place_ids_to_add = suggested_places.map(&:id) - existing_place_ids
|
||||
|
||||
# Skip if there's nothing to add
|
||||
return if place_ids_to_add.empty?
|
||||
|
||||
# Batch create place_visit records
|
||||
place_visits_attrs = place_ids_to_add.map do |place_id|
|
||||
{ visit_id: visit.id, place_id: place_id, created_at: Time.current, updated_at: Time.current }
|
||||
end
|
||||
|
||||
# Use insert_all for efficient bulk insertion without callbacks
|
||||
PlaceVisit.insert_all(place_visits_attrs) if place_visits_attrs.any?
|
||||
end
|
||||
|
||||
def find_matching_area(visit_data)
|
||||
user.areas.find do |area|
|
||||
near_area?([visit_data[:center_lat], visit_data[:center_lon]], area)
|
||||
|
|
@ -45,7 +79,8 @@ module Visits
|
|||
def near_area?(center, area)
|
||||
distance = Geocoder::Calculations.distance_between(
|
||||
center,
|
||||
[area.latitude, area.longitude]
|
||||
[area.latitude, area.longitude],
|
||||
units: :km
|
||||
)
|
||||
distance * 1000 <= area.radius # Convert to meters
|
||||
end
|
||||
|
|
|
|||
|
|
@ -3,10 +3,9 @@
|
|||
module Visits
|
||||
# Detects potential visits from a collection of tracked points
|
||||
class Detector
|
||||
MINIMUM_VISIT_DURATION = 5.minutes
|
||||
MINIMUM_VISIT_DURATION = 3.minutes
|
||||
MAXIMUM_VISIT_GAP = 30.minutes
|
||||
MINIMUM_POINTS_FOR_VISIT = 3
|
||||
SIGNIFICANT_MOVEMENT_THRESHOLD = 50 # meters
|
||||
|
||||
attr_reader :points
|
||||
|
||||
|
|
@ -103,7 +102,7 @@ module Visits
|
|||
|
||||
def calculate_visit_radius(points, center)
|
||||
max_distance = points.map do |point|
|
||||
Geocoder::Calculations.distance_between(center, [point.lat, point.lon])
|
||||
Geocoder::Calculations.distance_between(center, [point.lat, point.lon], units: :km)
|
||||
end.max
|
||||
|
||||
# Convert to meters and ensure minimum radius
|
||||
|
|
@ -140,7 +139,7 @@ module Visits
|
|||
.transform_values(&:size)
|
||||
most_common_name = name_counts.max_by { |_, count| count }&.first
|
||||
|
||||
return unless most_common_name.present?
|
||||
return if most_common_name.blank?
|
||||
|
||||
# If we have a name, try to get additional context
|
||||
feature = common_features.find { |f| f.dig('properties', 'name') == most_common_name }
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ module Visits
|
|||
merged = []
|
||||
current_merged = visits.first
|
||||
|
||||
visits[1..-1].each do |visit|
|
||||
visits[1..].each do |visit|
|
||||
if can_merge_visits?(current_merged, visit)
|
||||
# Merge the visits
|
||||
current_merged[:end_time] = visit[:end_time]
|
||||
|
|
@ -70,7 +70,8 @@ module Visits
|
|||
max_distance = between_points.map do |point|
|
||||
Geocoder::Calculations.distance_between(
|
||||
visit_center,
|
||||
[point.lat, point.lon]
|
||||
[point.lat, point.lon],
|
||||
units: :km
|
||||
)
|
||||
end.max
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@ module Visits
|
|||
class PlaceFinder
|
||||
attr_reader :user
|
||||
|
||||
SEARCH_RADIUS = 500 # meters
|
||||
SIMILARITY_RADIUS = 50 # meters
|
||||
|
||||
def initialize(user)
|
||||
@user = user
|
||||
end
|
||||
|
|
@ -12,119 +15,234 @@ module Visits
|
|||
def find_or_create_place(visit_data)
|
||||
lat = visit_data[:center_lat].round(5)
|
||||
lon = visit_data[:center_lon].round(5)
|
||||
name = visit_data[:suggested_name]
|
||||
|
||||
# Define the search radius in meters
|
||||
search_radius = 100 # Adjust this value as needed
|
||||
# First check if there's an existing place
|
||||
existing_place = find_existing_place(lat, lon, visit_data[:suggested_name])
|
||||
|
||||
existing_place = Place.where(name: name)
|
||||
.near([lon, lat], search_radius, :m)
|
||||
.first
|
||||
|
||||
return existing_place if existing_place
|
||||
|
||||
# Use a database transaction with a lock to prevent race conditions
|
||||
Place.transaction do
|
||||
# Check again within transaction to prevent race conditions
|
||||
existing_place = Place.where('ST_DWithin(lonlat, ST_SetSRID(ST_MakePoint(?, ?), 4326), 50)', lon, lat)
|
||||
.lock(true)
|
||||
.first
|
||||
|
||||
return existing_place if existing_place
|
||||
|
||||
create_new_place(lat, lon, visit_data[:suggested_name])
|
||||
# If we found an exact match, return it
|
||||
if existing_place
|
||||
return {
|
||||
main_place: existing_place,
|
||||
suggested_places: find_suggested_places(lat, lon, existing_place.id)
|
||||
}
|
||||
end
|
||||
|
||||
# Get potential places from all sources
|
||||
potential_places = collect_potential_places(visit_data)
|
||||
|
||||
# Find or create the main place
|
||||
main_place = select_or_create_main_place(potential_places, lat, lon, visit_data[:suggested_name])
|
||||
|
||||
# Get suggested places including our main place
|
||||
all_suggested_places = potential_places.presence || [main_place]
|
||||
|
||||
{
|
||||
main_place: main_place,
|
||||
suggested_places: all_suggested_places.uniq { |place| place.name }
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_new_place(lat, lon, suggested_name)
|
||||
# If no existing place is found, create a new one
|
||||
place = Place.new(
|
||||
lonlat: "POINT(#{lon} #{lat})",
|
||||
latitude: lat,
|
||||
longitude: lon
|
||||
)
|
||||
# Step 1: Find existing place
|
||||
def find_existing_place(lat, lon, name)
|
||||
# Try to find existing place by location first
|
||||
existing_by_location = Place.near([lat, lon], SIMILARITY_RADIUS, :m).first
|
||||
return existing_by_location if existing_by_location
|
||||
|
||||
# Get reverse geocoding data
|
||||
geocoded_data = Geocoder.search([lat, lon])
|
||||
# Then try by name if available
|
||||
return nil unless name.present?
|
||||
|
||||
if geocoded_data.present?
|
||||
first_result = geocoded_data.first
|
||||
data = first_result.data.with_indifferent_access
|
||||
properties = data['properties'] || {}
|
||||
Place.where(name: name)
|
||||
.near([lat, lon], SEARCH_RADIUS, :m)
|
||||
.first
|
||||
end
|
||||
|
||||
# Build a descriptive name from available components
|
||||
name_components = [
|
||||
properties['name'],
|
||||
properties['street'],
|
||||
properties['housenumber'],
|
||||
properties['postcode'],
|
||||
properties['city']
|
||||
].compact.uniq
|
||||
# Step 2: Collect potential places from all sources
|
||||
def collect_potential_places(visit_data)
|
||||
lat = visit_data[:center_lat].round(5)
|
||||
lon = visit_data[:center_lon].round(5)
|
||||
|
||||
place.name = name_components.any? ? name_components.join(', ') : Place::DEFAULT_NAME
|
||||
place.city = properties['city']
|
||||
place.country = properties['country']
|
||||
place.geodata = data
|
||||
place.source = :photon
|
||||
# Get places from points' geodata
|
||||
places_from_points = extract_places_from_points(visit_data[:points], lat, lon)
|
||||
|
||||
place.save!
|
||||
# Get places from external API
|
||||
places_from_api = fetch_places_from_api(lat, lon)
|
||||
|
||||
# Process nearby organizations outside the main transaction
|
||||
process_nearby_organizations(geocoded_data.drop(1))
|
||||
else
|
||||
place.name = suggested_name || Place::DEFAULT_NAME
|
||||
place.source = :manual
|
||||
place.save!
|
||||
# Combine and deduplicate by name
|
||||
combined_places = []
|
||||
|
||||
# Add API places first (usually better quality)
|
||||
places_from_api.each do |api_place|
|
||||
combined_places << api_place unless place_name_exists?(combined_places, api_place.name)
|
||||
end
|
||||
|
||||
# Add places from points if name doesn't already exist
|
||||
places_from_points.each do |point_place|
|
||||
combined_places << point_place unless place_name_exists?(combined_places, point_place.name)
|
||||
end
|
||||
|
||||
combined_places
|
||||
end
|
||||
|
||||
# Step 3: Extract places from points
|
||||
def extract_places_from_points(points, center_lat, center_lon)
|
||||
return [] if points.blank?
|
||||
|
||||
# Filter points with geodata
|
||||
points_with_geodata = points.select { |point| point.geodata.present? }
|
||||
return [] if points_with_geodata.empty?
|
||||
|
||||
# Process each point to create or find places
|
||||
places = []
|
||||
|
||||
points_with_geodata.each do |point|
|
||||
place = create_place_from_point(point)
|
||||
places << place if place
|
||||
end
|
||||
|
||||
places.uniq { |place| place.name }
|
||||
end
|
||||
|
||||
# Step 4: Create place from point
|
||||
def create_place_from_point(point)
|
||||
return nil unless point.geodata.is_a?(Hash)
|
||||
|
||||
properties = point.geodata['properties'] || {}
|
||||
return nil if properties.blank?
|
||||
|
||||
# Get or build a name
|
||||
name = build_place_name(properties)
|
||||
return nil if name == Place::DEFAULT_NAME
|
||||
|
||||
# Look for existing place with this name
|
||||
existing = Place.where(name: name)
|
||||
.near([point.latitude, point.longitude], SIMILARITY_RADIUS, :m)
|
||||
.first
|
||||
|
||||
return existing if existing
|
||||
|
||||
# Create new place
|
||||
place = Place.new(
|
||||
name: name,
|
||||
lonlat: "POINT(#{point.longitude} #{point.latitude})",
|
||||
latitude: point.latitude,
|
||||
longitude: point.longitude,
|
||||
city: properties['city'],
|
||||
country: properties['country'],
|
||||
geodata: point.geodata,
|
||||
source: :photon
|
||||
)
|
||||
|
||||
place.save!
|
||||
place
|
||||
rescue ActiveRecord::RecordInvalid
|
||||
nil
|
||||
end
|
||||
|
||||
# Step 5: Fetch places from API
|
||||
def fetch_places_from_api(lat, lon)
|
||||
# Get broader search results from Geocoder
|
||||
geocoder_results = Geocoder.search([lat, lon], radius: (SEARCH_RADIUS / 1000.0), units: :km)
|
||||
return [] if geocoder_results.blank?
|
||||
|
||||
places = []
|
||||
|
||||
geocoder_results.each do |result|
|
||||
place = create_place_from_api_result(result)
|
||||
places << place if place
|
||||
end
|
||||
|
||||
places
|
||||
end
|
||||
|
||||
# Step 6: Create place from API result
|
||||
def create_place_from_api_result(result)
|
||||
return nil unless result && result.data.is_a?(Hash)
|
||||
|
||||
properties = result.data['properties'] || {}
|
||||
return nil if properties.blank?
|
||||
|
||||
# Get or build a name
|
||||
name = build_place_name(properties)
|
||||
return nil if name == Place::DEFAULT_NAME
|
||||
|
||||
# Look for existing place with this name
|
||||
existing = Place.where(name: name)
|
||||
.near([result.latitude, result.longitude], SIMILARITY_RADIUS, :m)
|
||||
.first
|
||||
|
||||
return existing if existing
|
||||
|
||||
# Create new place
|
||||
place = Place.new(
|
||||
name: name,
|
||||
lonlat: "POINT(#{result.longitude} #{result.latitude})",
|
||||
latitude: result.latitude,
|
||||
longitude: result.longitude,
|
||||
city: properties['city'],
|
||||
country: properties['country'],
|
||||
geodata: result.data,
|
||||
source: :photon
|
||||
)
|
||||
|
||||
place.save!
|
||||
place
|
||||
rescue ActiveRecord::RecordInvalid
|
||||
nil
|
||||
end
|
||||
|
||||
# Step 7: Select or create main place
|
||||
def select_or_create_main_place(potential_places, lat, lon, suggested_name)
|
||||
return create_default_place(lat, lon, suggested_name) if potential_places.blank?
|
||||
|
||||
# Choose the closest place as the main one
|
||||
sorted_places = potential_places.sort_by do |place|
|
||||
place.distance_to([lat, lon], :m)
|
||||
end
|
||||
|
||||
sorted_places.first
|
||||
end
|
||||
|
||||
# Step 8: Create default place when no other options
|
||||
def create_default_place(lat, lon, suggested_name)
|
||||
name = suggested_name.presence || Place::DEFAULT_NAME
|
||||
|
||||
place = Place.new(
|
||||
name: name,
|
||||
lonlat: "POINT(#{lon} #{lat})",
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
source: :manual
|
||||
)
|
||||
|
||||
place.save!
|
||||
place
|
||||
end
|
||||
|
||||
def process_nearby_organizations(geocoded_data)
|
||||
# Fetch nearby organizations
|
||||
nearby_organizations = fetch_nearby_organizations(geocoded_data)
|
||||
|
||||
# Save each organization as a possible place
|
||||
nearby_organizations.each do |org|
|
||||
lon = org[:longitude]
|
||||
lat = org[:latitude]
|
||||
|
||||
# Check if a similar place already exists
|
||||
existing = Place.where(name: org[:name])
|
||||
.where('ST_DWithin(lonlat, ST_SetSRID(ST_MakePoint(?, ?), 4326), 1)', lon, lat)
|
||||
.first
|
||||
|
||||
next if existing
|
||||
|
||||
Place.create!(
|
||||
name: org[:name],
|
||||
lonlat: "POINT(#{lon} #{lat})",
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
city: org[:city],
|
||||
country: org[:country],
|
||||
geodata: org[:geodata],
|
||||
source: :photon
|
||||
)
|
||||
end
|
||||
# Step 9: Find suggested places
|
||||
def find_suggested_places(lat, lon, exclude_id = nil)
|
||||
query = Place.near([lat, lon], SEARCH_RADIUS, :m).with_distance([lat, lon], :m)
|
||||
query = query.where.not(id: exclude_id) if exclude_id
|
||||
query.limit(5)
|
||||
end
|
||||
|
||||
def fetch_nearby_organizations(geocoded_data)
|
||||
geocoded_data.map do |result|
|
||||
data = result.data
|
||||
properties = data['properties'] || {}
|
||||
# Helper methods
|
||||
|
||||
{
|
||||
name: properties['name'] || 'Unknown Organization',
|
||||
latitude: result.latitude,
|
||||
longitude: result.longitude,
|
||||
city: properties['city'],
|
||||
country: properties['country'],
|
||||
geodata: data
|
||||
}
|
||||
end
|
||||
def build_place_name(properties)
|
||||
name_components = [
|
||||
properties['name'],
|
||||
properties['street'],
|
||||
properties['housenumber'],
|
||||
properties['postcode'],
|
||||
properties['city']
|
||||
].compact.reject(&:empty?).uniq
|
||||
|
||||
name_components.any? ? name_components.join(', ') : Place::DEFAULT_NAME
|
||||
end
|
||||
|
||||
def place_name_exists?(places, name)
|
||||
places.any? { |place| place.name == name }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -3,11 +3,9 @@
|
|||
module Visits
|
||||
# Coordinates the process of detecting and creating visits from tracked points
|
||||
class SmartDetect
|
||||
MINIMUM_VISIT_DURATION = 5.minutes
|
||||
MINIMUM_VISIT_DURATION = 3.minutes
|
||||
MAXIMUM_VISIT_GAP = 30.minutes
|
||||
MINIMUM_POINTS_FOR_VISIT = 3
|
||||
SIGNIFICANT_PLACE_VISITS = 2 # Number of visits to consider a place significant
|
||||
SIGNIFICANT_MOVEMENT_THRESHOLD = 50 # meters
|
||||
|
||||
attr_reader :user, :start_at, :end_at, :points
|
||||
|
||||
|
|
|
|||
|
|
@ -14,12 +14,19 @@ class Visits::Suggest
|
|||
|
||||
def call
|
||||
visits = Visits::SmartDetect.new(user, start_at:, end_at:).call
|
||||
create_visits_notification(user) if visits.any?
|
||||
# create_visits_notification(user) if visits.any?
|
||||
|
||||
return nil unless DawarichSettings.reverse_geocoding_enabled?
|
||||
|
||||
visits.each(&:async_reverse_geocode)
|
||||
visits
|
||||
rescue StandardError => e
|
||||
# create a notification with stacktrace and what arguments were used
|
||||
user.notifications.create!(
|
||||
kind: :error,
|
||||
title: 'Error suggesting visits',
|
||||
content: "Error suggesting visits: #{e.message}\n#{e.backtrace.join("\n")}"
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
<link href="https://cdn.jsdelivr.net/npm/daisyui@4.12.14/dist/full.css" rel="stylesheet" type="text/css">
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="" />
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.css"/>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA==" crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||
|
||||
<%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %>
|
||||
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
|
||||
|
|
|
|||
Loading…
Reference in a new issue