Extract place name suggester

This commit is contained in:
Eugene Burmakin 2025-05-12 22:49:30 +02:00
parent ed7b6d6d24
commit aa521dba9b
7 changed files with 238 additions and 65 deletions

View file

@ -22,10 +22,6 @@ class Place < ApplicationRecord
lonlat.y
end
def reverse_geocoded?
geodata.present?
end
def osm_id
geodata['properties']['osm_id']
end

View file

@ -12,10 +12,6 @@ class Visit < ApplicationRecord
enum :status, { suggested: 0, confirmed: 1, declined: 2 }
def reverse_geocoded?
place.geodata.present?
end
def coordinates
points.pluck(:latitude, :longitude).map { [_1[0].to_f, _1[1].to_f] }
end

View file

@ -57,10 +57,6 @@ class ReverseGeocoding::Places::FetchData
new_place.save!
end
def reverse_geocoded?
place.geodata.present?
end
def find_place(place_data)
found_place = Place.where(
"geodata->'properties'->>'osm_id' = ?", place_data['properties']['osm_id'].to_s

View file

@ -7,10 +7,11 @@ module Visits
MAXIMUM_VISIT_GAP = 30.minutes
MINIMUM_POINTS_FOR_VISIT = 2
attr_reader :points
attr_reader :points, :place_name_suggester
def initialize(points)
@points = points
@place_name_suggester = PlaceNameSuggester
end
def detect_potential_visits
@ -111,48 +112,7 @@ module Visits
end
def suggest_place_name(points)
# Get points with geodata
geocoded_points = points.select { |p| p.geodata.present? && !p.geodata.empty? }
return nil if geocoded_points.empty?
# Extract all features from points' geodata
features = geocoded_points.flat_map do |point|
next [] unless point.geodata['features'].is_a?(Array)
point.geodata['features']
end.compact
return nil if features.empty?
# Group features by type and count occurrences
feature_counts = features.group_by { |f| f.dig('properties', 'type') }
.transform_values(&:size)
# Find the most common feature type
most_common_type = feature_counts.max_by { |_, count| count }&.first
return nil unless most_common_type
# Get all features of the most common type
common_features = features.select { |f| f.dig('properties', 'type') == most_common_type }
# Group these features by name and get the most common one
name_counts = common_features.group_by { |f| f.dig('properties', 'name') }
.transform_values(&:size)
most_common_name = name_counts.max_by { |_, count| count }&.first
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 }
properties = feature['properties']
# Build a more descriptive name if possible
[
most_common_name,
properties['street'],
properties['city'],
properties['state']
].compact.uniq.join(', ')
place_name_suggester.new(points).call
end
end
end

View file

@ -0,0 +1,70 @@
# frozen_string_literal: true
module Visits
# Suggests names for places based on geodata from tracked points
class PlaceNameSuggester
def initialize(points)
@points = points
end
def call
geocoded_points = extract_geocoded_points(points)
return nil if geocoded_points.empty?
features = extract_features(geocoded_points)
return nil if features.empty?
most_common_type = find_most_common_feature_type(features)
return nil unless most_common_type
most_common_name = find_most_common_name(features, most_common_type)
return nil if most_common_name.blank?
build_descriptive_name(features, most_common_type, most_common_name)
end
private
attr_reader :points
def extract_geocoded_points(points)
points.select { |p| p.geodata.present? && !p.geodata.empty? }
end
def extract_features(geocoded_points)
geocoded_points.flat_map do |point|
next [] unless point.geodata['features'].is_a?(Array)
point.geodata['features']
end.compact
end
def find_most_common_feature_type(features)
feature_counts = features.group_by { |f| f.dig('properties', 'type') }
.transform_values(&:size)
feature_counts.max_by { |_, count| count }&.first
end
def find_most_common_name(features, feature_type)
common_features = features.select { |f| f.dig('properties', 'type') == feature_type }
name_counts = common_features.group_by { |f| f.dig('properties', 'name') }
.transform_values(&:size)
name_counts.max_by { |_, count| count }&.first
end
def build_descriptive_name(features, feature_type, name)
feature = features.find do |f|
f.dig('properties', 'type') == feature_type &&
f.dig('properties', 'name') == name
end
properties = feature['properties']
[
name,
properties['street'],
properties['city'],
properties['state']
].compact.uniq.join(', ')
end
end
end

View file

@ -1,14 +1,16 @@
<div class="stat text-center">
<div class="stat-value text-secondary">
<%= number_with_delimiter @points_reverse_geocoded %>
<% if DawarichSettings.store_geodata? %>
<div class="stat text-center">
<div class="stat-value text-secondary">
<%= number_with_delimiter @points_reverse_geocoded %>
</div>
<div class="stat-title">Reverse geocoded points</div>
<div class="stat-title">
<span class="tooltip underline decoration-dotted" data-tip="Points that were reverse geocoded but had no data">
<%= number_with_delimiter @points_reverse_geocoded_without_data %> points without data
</span>
</div>
</div>
<div class="stat-title">Reverse geocoded points</div>
<div class="stat-title">
<span class="tooltip underline decoration-dotted" data-tip="Points that were reverse geocoded but had no data">
<%= number_with_delimiter @points_reverse_geocoded_without_data %> points without data
</span>
</div>
</div>
<% end %>
<div class="stat text-center">
<div class="stat-value text-warning underline hover:no-underline hover:cursor-pointer" onclick="countries_visited.showModal()">

View file

@ -0,0 +1,153 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Visits::PlaceNameSuggester do
subject(:suggester) { described_class.new(points) }
describe '#call' do
context 'when no points have geodata' do
let(:points) do
[
double('Point', geodata: nil),
double('Point', geodata: {})
]
end
it 'returns nil' do
expect(suggester.call).to be_nil
end
end
context 'when points have geodata but no features' do
let(:points) do
[
double('Point', geodata: { 'features' => [] })
]
end
it 'returns nil' do
expect(suggester.call).to be_nil
end
end
context 'when features exist but with different types' do
let(:points) do
[
double('Point', geodata: {
'features' => [
{ 'properties' => { 'type' => 'cafe', 'name' => 'Coffee Shop' } },
{ 'properties' => { 'type' => 'restaurant', 'name' => 'Pizza Place' } }
]
})
]
end
it 'returns the name of the most common type' do
# Since both types appear once, it will pick the first one alphabetically in practice
expect(suggester.call).to eq('Coffee Shop')
end
end
context 'when features have a common type but different names' do
let(:points) do
[
double('Point', geodata: {
'features' => [
{ 'properties' => { 'type' => 'park', 'name' => 'Central Park' } }
]
}),
double('Point', geodata: {
'features' => [
{ 'properties' => { 'type' => 'park', 'name' => 'City Park' } }
]
}),
double('Point', geodata: {
'features' => [
{ 'properties' => { 'type' => 'park', 'name' => 'Central Park' } }
]
})
]
end
it 'returns the most common name' do
expect(suggester.call).to eq('Central Park')
end
end
context 'when a complete place can be built' do
let(:points) do
[
double('Point', geodata: {
'features' => [
{
'properties' => {
'type' => 'cafe',
'name' => 'Starbucks',
'street' => '123 Main St',
'city' => 'San Francisco',
'state' => 'CA'
}
}
]
})
]
end
it 'returns a descriptive name with all components' do
expect(suggester.call).to eq('Starbucks, 123 Main St, San Francisco, CA')
end
end
context 'when only partial place details are available' do
let(:points) do
[
double('Point', geodata: {
'features' => [
{
'properties' => {
'type' => 'cafe',
'name' => 'Starbucks',
'city' => 'San Francisco'
# No street or state
}
}
]
})
]
end
it 'returns a name with available components' do
expect(suggester.call).to eq('Starbucks, San Francisco')
end
end
context 'when points have geodata with non-array features' do
let(:points) do
[
double('Point', geodata: { 'features' => 'not an array' })
]
end
it 'returns nil' do
expect(suggester.call).to be_nil
end
end
context 'when most common name is blank' do
let(:points) do
[
double('Point', geodata: {
'features' => [
{ 'properties' => { 'type' => 'road', 'name' => '' } }
]
})
]
end
it 'returns nil' do
expect(suggester.call).to be_nil
end
end
end
end