mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-11 09:41:40 -05:00
commit
f710f24f23
133 changed files with 2601 additions and 3608 deletions
|
|
@ -1 +1 @@
|
|||
0.26.0
|
||||
0.26.1
|
||||
|
|
|
|||
|
|
@ -28,7 +28,6 @@ services:
|
|||
APPLICATION_HOSTS: localhost
|
||||
TIME_ZONE: Europe/London
|
||||
APPLICATION_PROTOCOL: http
|
||||
DISTANCE_UNIT: km
|
||||
PROMETHEUS_EXPORTER_ENABLED: false
|
||||
PROMETHEUS_EXPORTER_HOST: 0.0.0.0
|
||||
PROMETHEUS_EXPORTER_PORT: 9394
|
||||
|
|
|
|||
|
|
@ -4,4 +4,3 @@ DATABASE_PASSWORD=password
|
|||
DATABASE_NAME=dawarich_development
|
||||
DATABASE_PORT=5432
|
||||
REDIS_URL=redis://localhost:6379/1
|
||||
DISTANCE_UNIT='km'
|
||||
|
|
|
|||
1
.rspec
1
.rspec
|
|
@ -1 +1,2 @@
|
|||
--require spec_helper
|
||||
--profile
|
||||
|
|
|
|||
58
CHANGELOG.md
58
CHANGELOG.md
|
|
@ -4,6 +4,63 @@ All notable changes to this project will be documented in this file.
|
|||
The format is based on [Keep a Changelog](http://keepachangelog.com/)
|
||||
and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||
|
||||
|
||||
# 0.26.1 - 2025-05-15
|
||||
|
||||
## Geodata on demand
|
||||
|
||||
This release introduces a new environment variable `STORE_GEODATA` with default value `true` to control whether to store geodata in the database or not. Currently, geodata is being used when:
|
||||
|
||||
- Fetching places geodata
|
||||
- Fetching countries for a trip
|
||||
- Suggesting place name for a visit
|
||||
|
||||
Opting out of storing geodata will make each feature that uses geodata to make a direct request to the geocoding service to calculate required data instead of using existing geodata from the database. Setting `STORE_GEODATA` to `false` can also use you some database space.
|
||||
|
||||
If you decide to opt out, you can safely delete your existing geodata from the database:
|
||||
|
||||
1. Get into the [console](https://dawarich.app/docs/FAQ/#how-to-enter-dawarich-console)
|
||||
2. Run the following commands:
|
||||
|
||||
```ruby
|
||||
Point.update_all(geodata: {}) # to remove existing geodata
|
||||
|
||||
ActiveRecord::Base.connection.execute("VACUUM FULL") # to free up some space
|
||||
```
|
||||
|
||||
Note, that this will take some time to complete, depending on the number of points you have. This is not a required step.
|
||||
|
||||
If you're running your own Photon instance, you can safely set `STORE_GEODATA` to `false`, otherwise it'd be better to keep it enabled, because that way Dawarich will be using existing geodata for its calculations.
|
||||
|
||||
Also, after updating to this version, Dawarich will start a huge background job to calculate countries for all your points. Just let it work.
|
||||
|
||||
## Added
|
||||
|
||||
- Map page now has a button to go to the previous and next day. #296 #631 #904
|
||||
- Clicking on number of countries and cities in stats cards now opens a modal with a list of countries and cities visited in that year.
|
||||
|
||||
## Changed
|
||||
|
||||
- Reverse geocoding is now working as on-demand job instead of storing the result in the database. #619
|
||||
- Stats cards now show the last update time. #733
|
||||
- Visit card now shows buttons to confirm or decline a visit only if it's not confirmed or declined yet.
|
||||
- Distance unit is now being stored in the user settings. You can choose between kilometers and miles, default is kilometers. The setting is accessible in the user settings -> Maps -> Distance Unit. You might want to recalculate your stats after changing the unit. #1126
|
||||
- Fog of war is now being displayed as lines instead of dots. Thanks to @MeijiRestored!
|
||||
|
||||
## Fixed
|
||||
|
||||
- Fixed a bug with an attempt to write points with same lonlat and timestamp from iOS app. #1170
|
||||
- Importing GeoJSON files now saves velocity if it was stored in either `velocity` or `speed` property.
|
||||
- `rake points:migrate_to_lonlat` should work properly now. #1083 #1161
|
||||
- PostGIS extension is now being enabled only if it's not already enabled. #1186
|
||||
- Fixed a bug where visits were returning into Suggested state after being confirmed or declined. #848
|
||||
- If no points are found for a month during stats calculation, stats are now being deleted instead of being left empty. #1066 #406
|
||||
|
||||
## Removed
|
||||
|
||||
- Removed `DISTANCE_UNIT` constant. It can be safely removed from your environment variables in docker-compose.yml.
|
||||
|
||||
|
||||
# 0.26.0 - 2025-05-08
|
||||
|
||||
⚠️ This release includes a breaking change. ⚠️
|
||||
|
|
@ -19,7 +76,6 @@ If you have encountered problems with moving to a PostGIS image while still on P
|
|||
- Dawarich now uses PostgreSQL 17 with PostGIS 3.5 by default.
|
||||
|
||||
|
||||
|
||||
# 0.25.10 - 2025-05-08
|
||||
|
||||
## Added
|
||||
|
|
|
|||
2
Gemfile
2
Gemfile
|
|
@ -30,10 +30,12 @@ gem 'rails', '~> 8.0'
|
|||
gem 'rexml'
|
||||
gem 'rgeo'
|
||||
gem 'rgeo-activerecord'
|
||||
gem 'rgeo-geojson'
|
||||
gem 'rswag-api'
|
||||
gem 'rswag-ui'
|
||||
gem 'sentry-ruby'
|
||||
gem 'sentry-rails'
|
||||
gem 'stackprof'
|
||||
gem 'sidekiq'
|
||||
gem 'sidekiq-cron'
|
||||
gem 'sidekiq-limit_fetch'
|
||||
|
|
|
|||
11
Gemfile.lock
11
Gemfile.lock
|
|
@ -105,7 +105,7 @@ GEM
|
|||
racc
|
||||
builder (3.3.0)
|
||||
byebug (12.0.0)
|
||||
chartkick (5.1.4)
|
||||
chartkick (5.1.5)
|
||||
coderay (1.1.3)
|
||||
concurrent-ruby (1.3.5)
|
||||
connection_pool (2.5.3)
|
||||
|
|
@ -219,6 +219,7 @@ GEM
|
|||
mini_portile2 (2.8.8)
|
||||
minitest (5.25.5)
|
||||
msgpack (1.7.3)
|
||||
multi_json (1.15.0)
|
||||
multi_xml (0.7.1)
|
||||
bigdecimal (~> 3.1)
|
||||
net-imap (0.5.8)
|
||||
|
|
@ -339,6 +340,9 @@ GEM
|
|||
rgeo-activerecord (8.0.0)
|
||||
activerecord (>= 7.0)
|
||||
rgeo (>= 3.0)
|
||||
rgeo-geojson (2.2.0)
|
||||
multi_json (~> 1.15)
|
||||
rgeo (>= 1.0.0)
|
||||
rspec-core (3.13.3)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-expectations (3.13.3)
|
||||
|
|
@ -395,7 +399,7 @@ GEM
|
|||
sentry-ruby (5.23.0)
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
shoulda-matchers (6.4.0)
|
||||
shoulda-matchers (6.5.0)
|
||||
activesupport (>= 5.2.0)
|
||||
sidekiq (7.3.9)
|
||||
base64
|
||||
|
|
@ -423,6 +427,7 @@ GEM
|
|||
actionpack (>= 6.1)
|
||||
activesupport (>= 6.1)
|
||||
sprockets (>= 3.0.0)
|
||||
stackprof (0.2.27)
|
||||
stimulus-rails (1.3.4)
|
||||
railties (>= 6.0.0)
|
||||
stringio (3.1.7)
|
||||
|
|
@ -512,6 +517,7 @@ DEPENDENCIES
|
|||
rexml
|
||||
rgeo
|
||||
rgeo-activerecord
|
||||
rgeo-geojson
|
||||
rspec-rails
|
||||
rswag-api
|
||||
rswag-specs
|
||||
|
|
@ -525,6 +531,7 @@ DEPENDENCIES
|
|||
sidekiq-limit_fetch
|
||||
simplecov
|
||||
sprockets-rails
|
||||
stackprof
|
||||
stimulus-rails
|
||||
strong_migrations
|
||||
super_diff
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -3,7 +3,7 @@
|
|||
class Api::V1::Countries::BordersController < ApplicationController
|
||||
def index
|
||||
countries = Rails.cache.fetch('dawarich/countries_codes', expires_in: 1.day) do
|
||||
Oj.load(File.read(Rails.root.join('lib/assets/countries.json')))
|
||||
Oj.load(File.read(Rails.root.join('lib/assets/countries.geojson')))
|
||||
end
|
||||
|
||||
render json: countries
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
class Api::V1::Overland::BatchesController < ApiController
|
||||
before_action :authenticate_active_api_user!, only: %i[create]
|
||||
before_action :validate_points_limit, only: %i[create]
|
||||
|
||||
def create
|
||||
Overland::BatchCreatingJob.perform_later(batch_params, current_api_user.id)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
class Api::V1::Owntracks::PointsController < ApiController
|
||||
before_action :authenticate_active_api_user!, only: %i[create]
|
||||
before_action :validate_points_limit, only: %i[create]
|
||||
|
||||
def create
|
||||
Owntracks::PointCreatingJob.perform_later(point_params, current_api_user.id)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
class Api::V1::PointsController < ApiController
|
||||
before_action :authenticate_active_api_user!, only: %i[create update destroy]
|
||||
before_action :validate_points_limit, only: %i[create]
|
||||
|
||||
def index
|
||||
start_at = params[:start_at]&.to_datetime&.to_i
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ class Api::V1::SettingsController < ApiController
|
|||
:time_threshold_minutes, :merge_threshold_minutes, :route_opacity,
|
||||
:preferred_map_layer, :points_rendering_mode, :live_map_enabled,
|
||||
:immich_url, :immich_api_key, :photoprism_url, :photoprism_api_key,
|
||||
:speed_colored_routes, :speed_color_scale
|
||||
:speed_colored_routes, :speed_color_scale, :fog_of_war_threshold
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
class Api::V1::SubscriptionsController < ApiController
|
||||
skip_before_action :authenticate_api_key, only: %i[callback]
|
||||
|
||||
def callback
|
||||
decoded_token = Subscription::DecodeJwtToken.new(params[:token]).call
|
||||
|
||||
|
|
|
|||
|
|
@ -41,4 +41,10 @@ class ApiController < ApplicationController
|
|||
def required_params
|
||||
[]
|
||||
end
|
||||
|
||||
def validate_points_limit
|
||||
limit_exceeded = PointsLimitExceeded.new(current_api_user).call
|
||||
|
||||
render json: { error: 'Points limit exceeded' }, status: :unauthorized if limit_exceeded
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ class ImportsController < ApplicationController
|
|||
before_action :authenticate_user!
|
||||
before_action :authenticate_active_user!, only: %i[new create]
|
||||
before_action :set_import, only: %i[show edit update destroy]
|
||||
|
||||
before_action :validate_points_limit, only: %i[new create]
|
||||
def index
|
||||
@imports =
|
||||
current_user
|
||||
|
|
@ -102,4 +102,10 @@ class ImportsController < ApplicationController
|
|||
|
||||
import
|
||||
end
|
||||
|
||||
def validate_points_limit
|
||||
limit_exceeded = PointsLimitExceeded.new(current_user).call
|
||||
|
||||
redirect_to imports_path, alert: 'Points limit exceeded', status: :unprocessable_entity if limit_exceeded
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -36,7 +36,9 @@ class MapController < ApplicationController
|
|||
@distance ||= 0
|
||||
|
||||
@coordinates.each_cons(2) do
|
||||
@distance += Geocoder::Calculations.distance_between([_1[0], _1[1]], [_2[0], _2[1]], units: DISTANCE_UNIT)
|
||||
@distance += Geocoder::Calculations.distance_between(
|
||||
[_1[0], _1[1]], [_2[0], _2[1]], units: current_user.safe_settings.distance_unit.to_sym
|
||||
)
|
||||
end
|
||||
|
||||
@distance.round(1)
|
||||
|
|
|
|||
|
|
@ -24,6 +24,6 @@ class Settings::MapsController < ApplicationController
|
|||
private
|
||||
|
||||
def settings_params
|
||||
params.require(:maps).permit(:name, :url)
|
||||
params.require(:maps).permit(:name, :url, :distance_unit)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ class StatsController < ApplicationController
|
|||
before_action :authenticate_active_user!, only: %i[update update_all]
|
||||
|
||||
def index
|
||||
@stats = current_user.stats.group_by(&:year).sort.reverse
|
||||
@stats = current_user.stats.group_by(&:year).transform_values { |stats| stats.sort_by(&:updated_at).reverse }.sort.reverse
|
||||
@points_total = current_user.tracked_points.count
|
||||
@points_reverse_geocoded = current_user.total_reverse_geocoded_points
|
||||
@points_reverse_geocoded_without_data = current_user.total_reverse_geocoded_points_without_data
|
||||
|
|
|
|||
|
|
@ -15,6 +15,10 @@ class TripsController < ApplicationController
|
|||
@trip.photo_previews
|
||||
end
|
||||
@photo_sources = @trip.photo_sources
|
||||
|
||||
if @trip.path.blank? || @trip.distance.blank? || @trip.visited_countries.blank?
|
||||
Trips::CalculateAllJob.perform_later(@trip.id)
|
||||
end
|
||||
end
|
||||
|
||||
def new
|
||||
|
|
@ -28,7 +32,7 @@ class TripsController < ApplicationController
|
|||
@trip = current_user.trips.build(trip_params)
|
||||
|
||||
if @trip.save
|
||||
redirect_to @trip, notice: 'Trip was successfully created.'
|
||||
redirect_to @trip, notice: 'Trip was successfully created. Data is being calculated in the background.'
|
||||
else
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
|
|
|
|||
|
|
@ -40,7 +40,32 @@ module ApplicationHelper
|
|||
data[:cities].flatten!.uniq!
|
||||
data[:countries].flatten!.uniq!
|
||||
|
||||
"#{data[:countries].count} countries, #{data[:cities].count} cities"
|
||||
grouped_by_country = {}
|
||||
stats.select { _1.year == year }.each do |stat|
|
||||
stat.toponyms.flatten.each do |toponym|
|
||||
country = toponym['country']
|
||||
next unless country.present?
|
||||
|
||||
grouped_by_country[country] ||= []
|
||||
|
||||
if toponym['cities'].present?
|
||||
toponym['cities'].each do |city_data|
|
||||
city = city_data['city']
|
||||
grouped_by_country[country] << city if city.present?
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
grouped_by_country.transform_values!(&:uniq)
|
||||
|
||||
{
|
||||
countries_count: data[:countries].count,
|
||||
cities_count: data[:cities].count,
|
||||
grouped_by_country: grouped_by_country.transform_values(&:sort).sort.to_h,
|
||||
year: year,
|
||||
modal_id: "countries_cities_modal_#{year}"
|
||||
}
|
||||
end
|
||||
|
||||
def countries_and_cities_stat_for_month(stat)
|
||||
|
|
@ -51,7 +76,7 @@ module ApplicationHelper
|
|||
end
|
||||
|
||||
def year_distance_stat(year, user)
|
||||
# In km or miles, depending on the application settings (DISTANCE_UNIT)
|
||||
# In km or miles, depending on the user.safe_settings.distance_unit
|
||||
Stat.year_distance(year, user).sum { _1[1] }
|
||||
end
|
||||
|
||||
|
|
@ -76,7 +101,7 @@ module ApplicationHelper
|
|||
def sidebar_distance(distance)
|
||||
return unless distance
|
||||
|
||||
"#{distance} #{DISTANCE_UNIT}"
|
||||
"#{distance} #{current_user.safe_settings.distance_unit}"
|
||||
end
|
||||
|
||||
def sidebar_points(points)
|
||||
|
|
|
|||
27
app/helpers/country_flag_helper.rb
Normal file
27
app/helpers/country_flag_helper.rb
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module CountryFlagHelper
|
||||
def country_flag(country_name)
|
||||
country_code = country_to_code(country_name)
|
||||
return "" unless country_code
|
||||
|
||||
# Convert country code to regional indicator symbols (flag emoji)
|
||||
country_code.upcase.each_char.map { |c| (c.ord + 127397).chr(Encoding::UTF_8) }.join
|
||||
end
|
||||
|
||||
|
||||
private
|
||||
|
||||
def country_to_code(country_name)
|
||||
mapping = Country.names_to_iso_a2
|
||||
|
||||
return mapping[country_name] if mapping[country_name]
|
||||
|
||||
mapping.each do |name, code|
|
||||
return code if country_name.downcase == name.downcase
|
||||
return code if country_name.downcase.include?(name.downcase) || name.downcase.include?(country_name.downcase)
|
||||
end
|
||||
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
|
@ -45,8 +45,9 @@ export default class extends BaseController {
|
|||
this.timezone = this.element.dataset.timezone;
|
||||
this.userSettings = JSON.parse(this.element.dataset.user_settings);
|
||||
this.clearFogRadius = parseInt(this.userSettings.fog_of_war_meters) || 50;
|
||||
this.fogLinethreshold = parseInt(this.userSettings.fog_of_war_threshold) || 90;
|
||||
this.routeOpacity = parseFloat(this.userSettings.route_opacity) || 0.6;
|
||||
this.distanceUnit = this.element.dataset.distance_unit || "km";
|
||||
this.distanceUnit = this.userSettings.maps.distance_unit || "km";
|
||||
this.pointsRenderingMode = this.userSettings.points_rendering_mode || "raw";
|
||||
this.liveMapEnabled = this.userSettings.live_map_enabled || false;
|
||||
this.countryCodesMap = countryCodesMap();
|
||||
|
|
@ -175,13 +176,13 @@ export default class extends BaseController {
|
|||
// Update event handlers
|
||||
this.map.on('moveend', () => {
|
||||
if (document.getElementById('fog')) {
|
||||
this.updateFog(this.markers, this.clearFogRadius);
|
||||
this.updateFog(this.markers, this.clearFogRadius, this.fogLinethreshold);
|
||||
}
|
||||
});
|
||||
|
||||
this.map.on('zoomend', () => {
|
||||
if (document.getElementById('fog')) {
|
||||
this.updateFog(this.markers, this.clearFogRadius);
|
||||
this.updateFog(this.markers, this.clearFogRadius, this.fogLinethreshold);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -198,7 +199,7 @@ export default class extends BaseController {
|
|||
if (e.name === 'Fog of War') {
|
||||
fogEnabled = true;
|
||||
document.getElementById('fog').style.display = 'block';
|
||||
this.updateFog(this.markers, this.clearFogRadius);
|
||||
this.updateFog(this.markers, this.clearFogRadius, this.fogLinethreshold);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -212,7 +213,7 @@ export default class extends BaseController {
|
|||
// Update fog circles on zoom and move
|
||||
this.map.on('zoomend moveend', () => {
|
||||
if (fogEnabled) {
|
||||
this.updateFog(this.markers, this.clearFogRadius);
|
||||
this.updateFog(this.markers, this.clearFogRadius, this.fogLinethreshold);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -350,7 +351,7 @@ export default class extends BaseController {
|
|||
|
||||
// Update fog of war if enabled
|
||||
if (this.map.hasLayer(this.fogOverlay)) {
|
||||
this.updateFog(this.markers, this.clearFogRadius);
|
||||
this.updateFog(this.markers, this.clearFogRadius, this.fogLinethreshold);
|
||||
}
|
||||
|
||||
// Update the last marker
|
||||
|
|
@ -390,7 +391,7 @@ export default class extends BaseController {
|
|||
|
||||
const visitedCountries = this.getVisitedCountries(countryCodesMap)
|
||||
const filteredFeatures = worldData.features.filter(feature =>
|
||||
visitedCountries.includes(feature.properties.ISO_A2)
|
||||
visitedCountries.includes(feature.properties["ISO3166-1-Alpha-2"])
|
||||
)
|
||||
|
||||
this.scratchLayer.addData({
|
||||
|
|
@ -587,7 +588,7 @@ export default class extends BaseController {
|
|||
|
||||
// Update fog if enabled
|
||||
if (this.map.hasLayer(this.fogOverlay)) {
|
||||
this.updateFog(this.markers, this.clearFogRadius);
|
||||
this.updateFog(this.markers, this.clearFogRadius, this.fogLinethreshold);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
|
|
@ -623,12 +624,12 @@ export default class extends BaseController {
|
|||
}
|
||||
}
|
||||
|
||||
updateFog(markers, clearFogRadius) {
|
||||
updateFog(markers, clearFogRadius, fogLinethreshold) {
|
||||
const fog = document.getElementById('fog');
|
||||
if (!fog) {
|
||||
initializeFogCanvas(this.map);
|
||||
}
|
||||
requestAnimationFrame(() => drawFogCanvas(this.map, markers, clearFogRadius));
|
||||
requestAnimationFrame(() => drawFogCanvas(this.map, markers, clearFogRadius, fogLinethreshold));
|
||||
}
|
||||
|
||||
initializeDrawControl() {
|
||||
|
|
@ -724,7 +725,7 @@ export default class extends BaseController {
|
|||
|
||||
// Form HTML
|
||||
div.innerHTML = `
|
||||
<form id="settings-form" class="w-48 h-144 overflow-y-auto">
|
||||
<form id="settings-form" style="overflow-y: auto; height: 36rem; width: 12rem;">
|
||||
<label for="route-opacity">Route Opacity</label>
|
||||
<div class="join">
|
||||
<input type="number" class="input input-ghost join-item focus:input-ghost input-xs input-bordered w-full max-w-xs" id="route-opacity" name="route_opacity" min="0" max="1" step="0.1" value="${this.routeOpacity}">
|
||||
|
|
@ -738,6 +739,12 @@ export default class extends BaseController {
|
|||
<label for="fog_of_war_meters_info" class="btn-xs join-item">?</label>
|
||||
</div>
|
||||
|
||||
<label for="fog_of_war_threshold">Seconds between Fog of War lines</label>
|
||||
<div class="join">
|
||||
<input type="number" class="join-item input input-ghost focus:input-ghost input-xs input-bordered w-full max-w-xs" id="fog_of_war_threshold" name="fog_of_war_threshold" step="1" value="${this.userSettings.fog_of_war_threshold}">
|
||||
<label for="fog_of_war_threshold_info" class="btn-xs join-item">?</label>
|
||||
</div>
|
||||
|
||||
|
||||
<label for="meters_between_routes">Meters between routes</label>
|
||||
<div class="join">
|
||||
|
|
@ -863,6 +870,7 @@ export default class extends BaseController {
|
|||
settings: {
|
||||
route_opacity: event.target.route_opacity.value,
|
||||
fog_of_war_meters: event.target.fog_of_war_meters.value,
|
||||
fog_of_war_threshold: event.target.fog_of_war_threshold.value,
|
||||
meters_between_routes: event.target.meters_between_routes.value,
|
||||
minutes_between_routes: event.target.minutes_between_routes.value,
|
||||
time_threshold_minutes: event.target.time_threshold_minutes.value,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
|
||||
import BaseController from "./base_controller"
|
||||
import L from "leaflet"
|
||||
import { createAllMapLayers } from "../maps/layers"
|
||||
|
||||
export default class extends BaseController {
|
||||
static values = {
|
||||
|
|
@ -31,11 +32,13 @@ export default class extends BaseController {
|
|||
attributionControl: true
|
||||
})
|
||||
|
||||
// Add the tile layer
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
maxZoom: 19,
|
||||
attribution: "© <a href='http://www.openstreetmap.org/copyright'>OpenStreetMap</a>"
|
||||
}).addTo(this.map)
|
||||
// Add base map layer
|
||||
const selectedLayerName = this.hasUserSettingsValue ?
|
||||
this.userSettingsValue.preferred_map_layer || "OpenStreetMap" :
|
||||
"OpenStreetMap";
|
||||
const maps = this.baseMaps();
|
||||
const defaultLayer = maps[selectedLayerName] || Object.values(maps)[0];
|
||||
defaultLayer.addTo(this.map);
|
||||
|
||||
// If we have coordinates, show the route
|
||||
if (this.hasPathValue && this.pathValue) {
|
||||
|
|
@ -45,8 +48,39 @@ export default class extends BaseController {
|
|||
}
|
||||
}
|
||||
|
||||
baseMaps() {
|
||||
const selectedLayerName = this.hasUserSettingsValue ?
|
||||
this.userSettingsValue.preferred_map_layer || "OpenStreetMap" :
|
||||
"OpenStreetMap";
|
||||
|
||||
let maps = createAllMapLayers(this.map, selectedLayerName);
|
||||
|
||||
// Add custom map if it exists in settings
|
||||
if (this.hasUserSettingsValue && this.userSettingsValue.maps && this.userSettingsValue.maps.url) {
|
||||
const customLayer = L.tileLayer(this.userSettingsValue.maps.url, {
|
||||
maxZoom: 19,
|
||||
attribution: "© OpenStreetMap contributors"
|
||||
});
|
||||
|
||||
// If this is the preferred layer, add it to the map immediately
|
||||
if (selectedLayerName === this.userSettingsValue.maps.name) {
|
||||
customLayer.addTo(this.map);
|
||||
// Remove any other base layers that might be active
|
||||
Object.values(maps).forEach(layer => {
|
||||
if (this.map.hasLayer(layer)) {
|
||||
this.map.removeLayer(layer);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
maps[this.userSettingsValue.maps.name] = customLayer;
|
||||
}
|
||||
|
||||
return maps;
|
||||
}
|
||||
|
||||
showRoute() {
|
||||
const points = this.parseLineString(this.pathValue)
|
||||
const points = this.getCoordinates(this.pathValue)
|
||||
|
||||
// Only create polyline if we have points
|
||||
if (points.length > 0) {
|
||||
|
|
@ -69,37 +103,34 @@ export default class extends BaseController {
|
|||
}
|
||||
}
|
||||
|
||||
parseLineString(linestring) {
|
||||
getCoordinates(pathData) {
|
||||
try {
|
||||
// Remove 'LINESTRING (' from start and ')' from end
|
||||
const coordsString = linestring
|
||||
.replace(/LINESTRING\s*\(/, '') // Remove LINESTRING and opening parenthesis
|
||||
.replace(/\)$/, '') // Remove closing parenthesis
|
||||
.trim() // Remove any leading/trailing whitespace
|
||||
// Parse the path data if it's a string
|
||||
let coordinates = pathData;
|
||||
if (typeof pathData === 'string') {
|
||||
try {
|
||||
coordinates = JSON.parse(pathData);
|
||||
} catch (e) {
|
||||
console.error("Error parsing path data as JSON:", e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Split into coordinate pairs and parse
|
||||
const points = coordsString.split(',').map(pair => {
|
||||
// Clean up any extra whitespace and remove any special characters
|
||||
const cleanPair = pair.trim().replace(/[()"\s]+/g, ' ')
|
||||
const [lng, lat] = cleanPair.split(' ').filter(Boolean).map(Number)
|
||||
// Handle array format - convert from [lng, lat] to [lat, lng] for Leaflet
|
||||
return coordinates.map(coord => {
|
||||
const [lng, lat] = coord;
|
||||
|
||||
// Validate the coordinates
|
||||
if (isNaN(lat) || isNaN(lng) || !lat || !lng) {
|
||||
console.error("Invalid coordinates:", cleanPair)
|
||||
return null
|
||||
console.error("Invalid coordinates:", coord);
|
||||
return null;
|
||||
}
|
||||
|
||||
return [lat, lng] // Leaflet uses [lat, lng] order
|
||||
}).filter(point => point !== null) // Remove any invalid points
|
||||
|
||||
// Validate we have points before returning
|
||||
if (points.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
return points
|
||||
return [lat, lng]; // Leaflet uses [lat, lng] order
|
||||
}).filter(point => point !== null);
|
||||
} catch (error) {
|
||||
return []
|
||||
console.error("Error processing coordinates:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ export default class extends BaseController {
|
|||
this.apiKey = this.containerTarget.dataset.api_key
|
||||
this.userSettings = JSON.parse(this.containerTarget.dataset.user_settings || '{}')
|
||||
this.timezone = this.containerTarget.dataset.timezone
|
||||
this.distanceUnit = this.containerTarget.dataset.distance_unit
|
||||
this.distanceUnit = this.userSettings.maps.distance_unit || "km"
|
||||
|
||||
// Initialize map and layers
|
||||
this.initializeMap()
|
||||
|
|
@ -133,22 +133,31 @@ export default class extends BaseController {
|
|||
|
||||
// After map initialization, add the path if it exists
|
||||
if (this.containerTarget.dataset.path) {
|
||||
const pathData = this.containerTarget.dataset.path.replace(/^"|"$/g, ''); // Remove surrounding quotes
|
||||
const coordinates = this.parseLineString(pathData);
|
||||
try {
|
||||
let coordinates;
|
||||
const pathData = this.containerTarget.dataset.path.replace(/^"|"$/g, ''); // Remove surrounding quotes
|
||||
|
||||
const polyline = L.polyline(coordinates, {
|
||||
color: 'blue',
|
||||
opacity: 0.8,
|
||||
weight: 3,
|
||||
zIndexOffset: 400
|
||||
});
|
||||
// Try to parse as JSON first (new format)
|
||||
coordinates = JSON.parse(pathData);
|
||||
// Convert from [lng, lat] to [lat, lng] for Leaflet
|
||||
coordinates = coordinates.map(coord => [coord[1], coord[0]]);
|
||||
|
||||
polyline.addTo(this.polylinesLayer);
|
||||
this.polylinesLayer.addTo(this.map);
|
||||
const polyline = L.polyline(coordinates, {
|
||||
color: 'blue',
|
||||
opacity: 0.8,
|
||||
weight: 3,
|
||||
zIndexOffset: 400
|
||||
});
|
||||
|
||||
// Fit the map to the polyline bounds
|
||||
if (coordinates.length > 0) {
|
||||
this.map.fitBounds(polyline.getBounds(), { padding: [50, 50] });
|
||||
polyline.addTo(this.polylinesLayer);
|
||||
this.polylinesLayer.addTo(this.map);
|
||||
|
||||
// Fit the map to the polyline bounds
|
||||
if (coordinates.length > 0) {
|
||||
this.map.fitBounds(polyline.getBounds(), { padding: [50, 50] });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error processing path data:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -246,17 +255,4 @@ export default class extends BaseController {
|
|||
this.fitMapToBounds()
|
||||
}
|
||||
}
|
||||
|
||||
// Add this method to parse the LineString format
|
||||
parseLineString(lineString) {
|
||||
// Remove LINESTRING and parentheses, then split into coordinate pairs
|
||||
const coordsString = lineString.replace('LINESTRING (', '').replace(')', '');
|
||||
const coords = coordsString.split(', ');
|
||||
|
||||
// Convert each coordinate pair to [lat, lng] format
|
||||
return coords.map(coord => {
|
||||
const [lng, lat] = coord.split(' ').map(Number);
|
||||
return [lat, lng]; // Swap to lat, lng for Leaflet
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ export function initializeFogCanvas(map) {
|
|||
return fog;
|
||||
}
|
||||
|
||||
export function drawFogCanvas(map, markers, clearFogRadius) {
|
||||
export function drawFogCanvas(map, markers, clearFogRadius, fogLinethreshold) {
|
||||
const fog = document.getElementById('fog');
|
||||
// Return early if fog element doesn't exist or isn't a canvas
|
||||
if (!fog || !(fog instanceof HTMLCanvasElement)) return;
|
||||
|
|
@ -33,38 +33,60 @@ export function drawFogCanvas(map, markers, clearFogRadius) {
|
|||
|
||||
const size = map.getSize();
|
||||
|
||||
// Clear the canvas
|
||||
// 1) Paint base fog
|
||||
ctx.clearRect(0, 0, size.x, size.y);
|
||||
|
||||
// Keep the light fog for unexplored areas
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.4)';
|
||||
ctx.fillRect(0, 0, size.x, size.y);
|
||||
|
||||
// Set up for "cutting" holes
|
||||
// 2) Cut out holes
|
||||
ctx.globalCompositeOperation = 'destination-out';
|
||||
|
||||
// Draw clear circles for each point
|
||||
markers.forEach(point => {
|
||||
const latLng = L.latLng(point[0], point[1]);
|
||||
const pixelPoint = map.latLngToContainerPoint(latLng);
|
||||
const radiusInPixels = metersToPixels(map, clearFogRadius);
|
||||
// 3) Build & sort points
|
||||
const pts = markers
|
||||
.map(pt => {
|
||||
const pixel = map.latLngToContainerPoint(L.latLng(pt[0], pt[1]));
|
||||
return { pixel, time: parseInt(pt[4], 10) };
|
||||
})
|
||||
.sort((a, b) => a.time - b.time);
|
||||
|
||||
// Make explored areas completely transparent
|
||||
const gradient = ctx.createRadialGradient(
|
||||
pixelPoint.x, pixelPoint.y, 0,
|
||||
pixelPoint.x, pixelPoint.y, radiusInPixels
|
||||
);
|
||||
gradient.addColorStop(0, 'rgba(255, 255, 255, 1)'); // 100% transparent
|
||||
gradient.addColorStop(0.85, 'rgba(255, 255, 255, 1)'); // Still 100% transparent
|
||||
gradient.addColorStop(1, 'rgba(255, 255, 255, 0)'); // Fade to fog at edge
|
||||
const radiusPx = Math.max(metersToPixels(map, clearFogRadius), 2);
|
||||
console.log(radiusPx);
|
||||
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.beginPath();
|
||||
ctx.arc(pixelPoint.x, pixelPoint.y, radiusInPixels, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
// 4) Mark which pts are part of a line
|
||||
const connected = new Array(pts.length).fill(false);
|
||||
for (let i = 0; i < pts.length - 1; i++) {
|
||||
if (pts[i + 1].time - pts[i].time <= fogLinethreshold) {
|
||||
connected[i] = true;
|
||||
connected[i + 1] = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 5) Draw circles only for “alone” points
|
||||
pts.forEach((pt, i) => {
|
||||
if (!connected[i]) {
|
||||
ctx.fillStyle = 'rgba(255,255,255,1)';
|
||||
ctx.beginPath();
|
||||
ctx.arc(pt.pixel.x, pt.pixel.y, radiusPx, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
});
|
||||
|
||||
// Reset composite operation
|
||||
// 6) Draw rounded lines
|
||||
ctx.lineWidth = radiusPx * 2;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.strokeStyle = 'rgba(255,255,255,1)';
|
||||
|
||||
for (let i = 0; i < pts.length - 1; i++) {
|
||||
if (pts[i + 1].time - pts[i].time <= fogLinethreshold) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(pts[i].pixel.x, pts[i].pixel.y);
|
||||
ctx.lineTo(pts[i + 1].pixel.x, pts[i + 1].pixel.y);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
// 7) Reset composite operation
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
}
|
||||
|
||||
|
|
|
|||
11
app/jobs/data_migrations/set_points_country_ids_job.rb
Normal file
11
app/jobs/data_migrations/set_points_country_ids_job.rb
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class DataMigrations::SetPointsCountryIdsJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform(point_id)
|
||||
point = Point.find(point_id)
|
||||
point.country_id = Country.containing_point(point.lon, point.lat).id
|
||||
point.save!
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class DataMigrations::StartSettingsPointsCountryIdsJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform
|
||||
Point.where(country_id: nil).find_each do |point|
|
||||
DataMigrations::SetPointsCountryIdsJob.perform_later(point.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -9,6 +9,7 @@ class Overland::BatchCreatingJob < ApplicationJob
|
|||
data = Overland::Params.new(params).call
|
||||
|
||||
data.each do |location|
|
||||
next if location[:lonlat].nil?
|
||||
next if point_exists?(location, user_id)
|
||||
|
||||
Point.create!(location.merge(user_id:))
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ class Owntracks::PointCreatingJob < ApplicationJob
|
|||
def perform(point_params, user_id)
|
||||
parsed_params = OwnTracks::Params.new(point_params).call
|
||||
|
||||
return if parsed_params[:timestamp].nil? || parsed_params[:lonlat].nil?
|
||||
return if point_exists?(parsed_params, user_id)
|
||||
|
||||
Point.create!(parsed_params.merge(user_id:))
|
||||
|
|
|
|||
11
app/jobs/trips/calculate_all_job.rb
Normal file
11
app/jobs/trips/calculate_all_job.rb
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Trips::CalculateAllJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform(trip_id)
|
||||
Trips::CalculatePathJob.perform_later(trip_id)
|
||||
Trips::CalculateDistanceJob.perform_later(trip_id)
|
||||
Trips::CalculateCountriesJob.perform_later(trip_id)
|
||||
end
|
||||
end
|
||||
25
app/jobs/trips/calculate_countries_job.rb
Normal file
25
app/jobs/trips/calculate_countries_job.rb
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Trips::CalculateCountriesJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform(trip_id)
|
||||
trip = Trip.find(trip_id)
|
||||
|
||||
trip.calculate_countries
|
||||
trip.save!
|
||||
|
||||
broadcast_update(trip)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def broadcast_update(trip)
|
||||
Turbo::StreamsChannel.broadcast_update_to(
|
||||
"trip_#{trip.id}",
|
||||
target: "trip_countries",
|
||||
partial: "trips/countries",
|
||||
locals: { trip: trip }
|
||||
)
|
||||
end
|
||||
end
|
||||
25
app/jobs/trips/calculate_distance_job.rb
Normal file
25
app/jobs/trips/calculate_distance_job.rb
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Trips::CalculateDistanceJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform(trip_id)
|
||||
trip = Trip.find(trip_id)
|
||||
|
||||
trip.calculate_distance
|
||||
trip.save!
|
||||
|
||||
broadcast_update(trip)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def broadcast_update(trip)
|
||||
Turbo::StreamsChannel.broadcast_update_to(
|
||||
"trip_#{trip.id}",
|
||||
target: "trip_distance",
|
||||
partial: "trips/distance",
|
||||
locals: { trip: trip }
|
||||
)
|
||||
end
|
||||
end
|
||||
25
app/jobs/trips/calculate_path_job.rb
Normal file
25
app/jobs/trips/calculate_path_job.rb
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Trips::CalculatePathJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform(trip_id)
|
||||
trip = Trip.find(trip_id)
|
||||
|
||||
trip.calculate_path
|
||||
trip.save!
|
||||
|
||||
broadcast_update(trip)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def broadcast_update(trip)
|
||||
Turbo::StreamsChannel.broadcast_update_to(
|
||||
"trip_#{trip.id}",
|
||||
target: "trip_path",
|
||||
partial: "trips/path",
|
||||
locals: { trip: trip }
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Trips::CreatePathJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform(trip_id)
|
||||
trip = Trip.find(trip_id)
|
||||
|
||||
trip.calculate_path_and_distance
|
||||
|
||||
trip.save!
|
||||
end
|
||||
end
|
||||
|
|
@ -3,14 +3,6 @@
|
|||
module Distanceable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
DISTANCE_UNITS = {
|
||||
km: 1000, # to meters
|
||||
mi: 1609.34, # to meters
|
||||
m: 1, # already in meters
|
||||
ft: 0.3048, # to meters
|
||||
yd: 0.9144 # to meters
|
||||
}.freeze
|
||||
|
||||
module ClassMethods
|
||||
def total_distance(points = nil, unit = :km)
|
||||
# Handle method being called directly on relation vs with array
|
||||
|
|
@ -24,8 +16,8 @@ module Distanceable
|
|||
private
|
||||
|
||||
def calculate_distance_for_relation(unit)
|
||||
unless DISTANCE_UNITS.key?(unit.to_sym)
|
||||
raise ArgumentError, "Invalid unit. Supported units are: #{DISTANCE_UNITS.keys.join(', ')}"
|
||||
unless ::DISTANCE_UNITS.key?(unit.to_sym)
|
||||
raise ArgumentError, "Invalid unit. Supported units are: #{::DISTANCE_UNITS.keys.join(', ')}"
|
||||
end
|
||||
|
||||
distance_in_meters = connection.select_value(<<-SQL.squish)
|
||||
|
|
@ -48,12 +40,12 @@ module Distanceable
|
|||
WHERE prev_lonlat IS NOT NULL
|
||||
SQL
|
||||
|
||||
distance_in_meters.to_f / DISTANCE_UNITS[unit.to_sym]
|
||||
distance_in_meters.to_f / ::DISTANCE_UNITS[unit.to_sym]
|
||||
end
|
||||
|
||||
def calculate_distance_for_array(points, unit = :km)
|
||||
unless DISTANCE_UNITS.key?(unit.to_sym)
|
||||
raise ArgumentError, "Invalid unit. Supported units are: #{DISTANCE_UNITS.keys.join(', ')}"
|
||||
unless ::DISTANCE_UNITS.key?(unit.to_sym)
|
||||
raise ArgumentError, "Invalid unit. Supported units are: #{::DISTANCE_UNITS.keys.join(', ')}"
|
||||
end
|
||||
|
||||
return 0 if points.length < 2
|
||||
|
|
@ -66,13 +58,13 @@ module Distanceable
|
|||
)
|
||||
end
|
||||
|
||||
total_meters.to_f / DISTANCE_UNITS[unit.to_sym]
|
||||
total_meters.to_f / ::DISTANCE_UNITS[unit.to_sym]
|
||||
end
|
||||
end
|
||||
|
||||
def distance_to(other_point, unit = :km)
|
||||
unless DISTANCE_UNITS.key?(unit.to_sym)
|
||||
raise ArgumentError, "Invalid unit. Supported units are: #{DISTANCE_UNITS.keys.join(', ')}"
|
||||
unless ::DISTANCE_UNITS.key?(unit.to_sym)
|
||||
raise ArgumentError, "Invalid unit. Supported units are: #{::DISTANCE_UNITS.keys.join(', ')}"
|
||||
end
|
||||
|
||||
# Extract coordinates based on what type other_point is
|
||||
|
|
@ -88,7 +80,7 @@ module Distanceable
|
|||
SQL
|
||||
|
||||
# Convert to requested unit
|
||||
distance_in_meters.to_f / DISTANCE_UNITS[unit.to_sym]
|
||||
distance_in_meters.to_f / ::DISTANCE_UNITS[unit.to_sym]
|
||||
end
|
||||
|
||||
private
|
||||
|
|
|
|||
|
|
@ -3,14 +3,6 @@
|
|||
module Nearable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
DISTANCE_UNITS = {
|
||||
km: 1000, # to meters
|
||||
mi: 1609.34, # to meters
|
||||
m: 1, # already in meters
|
||||
ft: 0.3048, # to meters
|
||||
yd: 0.9144 # to meters
|
||||
}.freeze
|
||||
|
||||
class_methods do
|
||||
# It accepts an array of coordinates [latitude, longitude]
|
||||
# and an optional radius and distance unit
|
||||
|
|
@ -19,12 +11,12 @@ module Nearable
|
|||
def near(*args)
|
||||
latitude, longitude, radius, unit = extract_coordinates_and_options(*args)
|
||||
|
||||
unless DISTANCE_UNITS.key?(unit.to_sym)
|
||||
raise ArgumentError, "Invalid unit. Supported units are: #{DISTANCE_UNITS.keys.join(', ')}"
|
||||
unless ::DISTANCE_UNITS.key?(unit.to_sym)
|
||||
raise ArgumentError, "Invalid unit. Supported units are: #{::DISTANCE_UNITS.keys.join(', ')}"
|
||||
end
|
||||
|
||||
# Convert radius to meters for ST_DWithin
|
||||
radius_in_meters = radius * DISTANCE_UNITS[unit.to_sym]
|
||||
radius_in_meters = radius * ::DISTANCE_UNITS[unit.to_sym]
|
||||
|
||||
# Create a point from the given coordinates
|
||||
point = "SRID=4326;POINT(#{longitude} #{latitude})"
|
||||
|
|
@ -41,12 +33,12 @@ module Nearable
|
|||
def with_distance(*args)
|
||||
latitude, longitude, unit = extract_coordinates_and_options(*args)
|
||||
|
||||
unless DISTANCE_UNITS.key?(unit.to_sym)
|
||||
raise ArgumentError, "Invalid unit. Supported units are: #{DISTANCE_UNITS.keys.join(', ')}"
|
||||
unless ::DISTANCE_UNITS.key?(unit.to_sym)
|
||||
raise ArgumentError, "Invalid unit. Supported units are: #{::DISTANCE_UNITS.keys.join(', ')}"
|
||||
end
|
||||
|
||||
point = "SRID=4326;POINT(#{longitude} #{latitude})"
|
||||
conversion_factor = 1.0 / DISTANCE_UNITS[unit.to_sym]
|
||||
conversion_factor = 1.0 / ::DISTANCE_UNITS[unit.to_sym]
|
||||
|
||||
select(<<-SQL.squish)
|
||||
#{table_name}.*,
|
||||
|
|
|
|||
15
app/models/country.rb
Normal file
15
app/models/country.rb
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Country < ApplicationRecord
|
||||
validates :name, :iso_a2, :iso_a3, :geom, presence: true
|
||||
|
||||
def self.containing_point(lon, lat)
|
||||
where("ST_Contains(geom, ST_SetSRID(ST_MakePoint(?, ?), 4326))", lon, lat)
|
||||
.select(:id, :name, :iso_a2, :iso_a3)
|
||||
.first
|
||||
end
|
||||
|
||||
def self.names_to_iso_a2
|
||||
pluck(:name, :iso_a2).to_h
|
||||
end
|
||||
end
|
||||
|
|
@ -22,29 +22,19 @@ class Place < ApplicationRecord
|
|||
lonlat.y
|
||||
end
|
||||
|
||||
def async_reverse_geocode
|
||||
return unless DawarichSettings.reverse_geocoding_enabled?
|
||||
|
||||
ReverseGeocodingJob.perform_later(self.class.to_s, id)
|
||||
end
|
||||
|
||||
def reverse_geocoded?
|
||||
geodata.present?
|
||||
end
|
||||
|
||||
def osm_id
|
||||
geodata['properties']['osm_id']
|
||||
geodata.dig('properties', 'osm_id')
|
||||
end
|
||||
|
||||
def osm_key
|
||||
geodata['properties']['osm_key']
|
||||
geodata.dig('properties', 'osm_key')
|
||||
end
|
||||
|
||||
def osm_value
|
||||
geodata['properties']['osm_value']
|
||||
geodata.dig('properties', 'osm_value')
|
||||
end
|
||||
|
||||
def osm_type
|
||||
geodata['properties']['osm_type']
|
||||
geodata.dig('properties', 'osm_type')
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -28,7 +28,8 @@ class Point < ApplicationRecord
|
|||
scope :visited, -> { where.not(visit_id: nil) }
|
||||
scope :not_visited, -> { where(visit_id: nil) }
|
||||
|
||||
after_create :async_reverse_geocode
|
||||
after_create :async_reverse_geocode, if: -> { DawarichSettings.store_geodata? }
|
||||
after_create :set_country
|
||||
after_create_commit :broadcast_coordinates
|
||||
|
||||
def self.without_raw_data
|
||||
|
|
@ -57,6 +58,10 @@ class Point < ApplicationRecord
|
|||
lonlat.y
|
||||
end
|
||||
|
||||
def found_in_country
|
||||
Country.containing_point(lon, lat)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# rubocop:disable Metrics/MethodLength Metrics/AbcSize
|
||||
|
|
@ -76,4 +81,9 @@ class Point < ApplicationRecord
|
|||
)
|
||||
end
|
||||
# rubocop:enable Metrics/MethodLength
|
||||
|
||||
def set_country
|
||||
self.country_id = found_in_country&.id
|
||||
save! if changed?
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ class Stat < ApplicationRecord
|
|||
def calculate_daily_distances(monthly_points)
|
||||
timespan.to_a.map.with_index(1) do |day, index|
|
||||
daily_points = filter_points_for_day(monthly_points, day)
|
||||
distance = Point.total_distance(daily_points, DISTANCE_UNIT)
|
||||
distance = Point.total_distance(daily_points, user.safe_settings.distance_unit)
|
||||
[index, distance.round(2)]
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -7,11 +7,11 @@ class Trip < ApplicationRecord
|
|||
|
||||
validates :name, :started_at, :ended_at, presence: true
|
||||
|
||||
before_save :calculate_path_and_distance
|
||||
after_create :enqueue_calculation_jobs
|
||||
after_update :enqueue_calculation_jobs, if: -> { saved_change_to_started_at? || saved_change_to_ended_at? }
|
||||
|
||||
def calculate_path_and_distance
|
||||
calculate_path
|
||||
calculate_distance
|
||||
def enqueue_calculation_jobs
|
||||
Trips::CalculateAllJob.perform_later(id)
|
||||
end
|
||||
|
||||
def points
|
||||
|
|
@ -19,7 +19,9 @@ class Trip < ApplicationRecord
|
|||
end
|
||||
|
||||
def countries
|
||||
points.pluck(:country).uniq.compact
|
||||
return points.pluck(:country).uniq.compact if DawarichSettings.store_geodata?
|
||||
|
||||
visited_countries
|
||||
end
|
||||
|
||||
def photo_previews
|
||||
|
|
@ -30,6 +32,25 @@ class Trip < ApplicationRecord
|
|||
@photo_sources ||= photos.map { _1[:source] }.uniq
|
||||
end
|
||||
|
||||
def calculate_path
|
||||
trip_path = Tracks::BuildPath.new(points.pluck(:lonlat)).call
|
||||
|
||||
self.path = trip_path
|
||||
end
|
||||
|
||||
def calculate_distance
|
||||
distance = Point.total_distance(points, user.safe_settings.distance_unit)
|
||||
|
||||
self.distance = distance.round
|
||||
end
|
||||
|
||||
def calculate_countries
|
||||
countries =
|
||||
Country.where(id: points.pluck(:country_id).compact.uniq).pluck(:name)
|
||||
|
||||
self.visited_countries = countries
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def photos
|
||||
|
|
@ -44,16 +65,4 @@ class Trip < ApplicationRecord
|
|||
# to show all photos in the same height
|
||||
vertical_photos.count > horizontal_photos.count ? vertical_photos : horizontal_photos
|
||||
end
|
||||
|
||||
def calculate_path
|
||||
trip_path = Tracks::BuildPath.new(points.pluck(:lonlat)).call
|
||||
|
||||
self.path = trip_path
|
||||
end
|
||||
|
||||
def calculate_distance
|
||||
distance = Point.total_distance(points, DISTANCE_UNIT)
|
||||
|
||||
self.distance = distance.round
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ class User < ApplicationRecord
|
|||
end
|
||||
|
||||
def total_distance
|
||||
# In km or miles, depending on the application settings (DISTANCE_UNIT)
|
||||
# In km or miles, depending on user.safe_settings.distance_unit
|
||||
stats.sum(:distance)
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -29,7 +25,9 @@ class Visit < ApplicationRecord
|
|||
return area&.radius if area.present?
|
||||
|
||||
radius = points.map do |point|
|
||||
Geocoder::Calculations.distance_between(center, [point.lat, point.lon])
|
||||
Geocoder::Calculations.distance_between(
|
||||
center, [point.lat, point.lon], units: user.safe_settings.distance_unit.to_sym
|
||||
)
|
||||
end.max
|
||||
|
||||
radius && radius >= 15 ? radius : 15
|
||||
|
|
|
|||
|
|
@ -7,14 +7,16 @@ class Api::PlaceSerializer
|
|||
|
||||
def call
|
||||
{
|
||||
id: place.id,
|
||||
name: place.name,
|
||||
longitude: place.lon,
|
||||
latitude: place.lat,
|
||||
city: place.city,
|
||||
country: place.country,
|
||||
source: place.source,
|
||||
geodata: place.geodata,
|
||||
id: place.id,
|
||||
name: place.name,
|
||||
longitude: place.lon,
|
||||
latitude: place.lat,
|
||||
city: place.city,
|
||||
country: place.country,
|
||||
source: place.source,
|
||||
geodata: place.geodata,
|
||||
created_at: place.created_at,
|
||||
updated_at: place.updated_at,
|
||||
reverse_geocoded_at: place.reverse_geocoded_at
|
||||
}
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Api::PointSerializer < PointSerializer
|
||||
EXCLUDED_ATTRIBUTES = %w[created_at updated_at visit_id import_id user_id raw_data].freeze
|
||||
EXCLUDED_ATTRIBUTES = %w[created_at updated_at visit_id import_id user_id raw_data country_id].freeze
|
||||
|
||||
def call
|
||||
point.attributes.except(*EXCLUDED_ATTRIBUTES)
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
class PointSerializer
|
||||
EXCLUDED_ATTRIBUTES = %w[
|
||||
created_at updated_at visit_id id import_id user_id raw_data lonlat
|
||||
reverse_geocoded_at
|
||||
reverse_geocoded_at country_id
|
||||
].freeze
|
||||
|
||||
def initialize(point)
|
||||
|
|
|
|||
|
|
@ -31,14 +31,14 @@ class Areas::Visits::Create
|
|||
|
||||
def area_points(area)
|
||||
area_radius =
|
||||
if ::DISTANCE_UNIT == :km
|
||||
area.radius / 1000.0
|
||||
if user.safe_settings.distance_unit == :km
|
||||
area.radius / ::DISTANCE_UNITS[:km]
|
||||
else
|
||||
area.radius / 1609.344
|
||||
area.radius / ::DISTANCE_UNITS[user.safe_settings.distance_unit.to_sym]
|
||||
end
|
||||
|
||||
points = Point.where(user_id: user.id)
|
||||
.near([area.latitude, area.longitude], area_radius, DISTANCE_UNIT)
|
||||
.near([area.latitude, area.longitude], area_radius, user.safe_settings.distance_unit)
|
||||
.order(timestamp: :asc)
|
||||
|
||||
# check if all points within the area are assigned to a visit
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ class Geojson::Importer
|
|||
data = Geojson::Params.new(json).call
|
||||
|
||||
data.each.with_index(1) do |point, index|
|
||||
next if point[:lonlat].nil?
|
||||
next if point_exists?(point, user_id)
|
||||
|
||||
Point.create!(point.merge(user_id:, import_id: import.id))
|
||||
|
|
|
|||
|
|
@ -95,7 +95,9 @@ class Geojson::Params
|
|||
end
|
||||
|
||||
def speed(feature)
|
||||
feature.dig(:properties, :speed).to_f.round(1)
|
||||
value = feature.dig(:properties, :speed) || feature.dig(:properties, :velocity)
|
||||
|
||||
value.to_f.round(1)
|
||||
end
|
||||
|
||||
def accuracy(feature)
|
||||
|
|
|
|||
|
|
@ -21,8 +21,6 @@ class Jobs::Create
|
|||
raise InvalidJobName, 'Invalid job name'
|
||||
end
|
||||
|
||||
points.find_each(batch_size: 1_000) do |point|
|
||||
point.async_reverse_geocode
|
||||
end
|
||||
points.find_each(&:async_reverse_geocode)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -11,9 +11,12 @@ class Points::Create
|
|||
def call
|
||||
data = Points::Params.new(params, user.id).call
|
||||
|
||||
# Deduplicate points based on unique constraint
|
||||
deduplicated_data = data.uniq { |point| [point[:lonlat], point[:timestamp], point[:user_id]] }
|
||||
|
||||
created_points = []
|
||||
|
||||
data.each_slice(1000) do |location_batch|
|
||||
deduplicated_data.each_slice(1000) do |location_batch|
|
||||
# rubocop:disable Rails/SkipsModelValidations
|
||||
result = Point.upsert_all(
|
||||
location_batch,
|
||||
|
|
|
|||
20
app/services/points_limit_exceeded.rb
Normal file
20
app/services/points_limit_exceeded.rb
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class PointsLimitExceeded
|
||||
def initialize(user)
|
||||
@user = user
|
||||
end
|
||||
|
||||
def call
|
||||
return false if DawarichSettings.self_hosted?
|
||||
return true if @user.points.count >= points_limit
|
||||
|
||||
false
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def points_limit
|
||||
DawarichSettings::BASIC_PAID_PLAN_LIMIT
|
||||
end
|
||||
end
|
||||
|
|
@ -17,10 +17,22 @@ class ReverseGeocoding::Places::FetchData
|
|||
return
|
||||
end
|
||||
|
||||
first_place = reverse_geocoded_places.shift
|
||||
places = reverse_geocoded_places
|
||||
first_place = places.shift
|
||||
update_place(first_place)
|
||||
|
||||
reverse_geocoded_places.each { |reverse_geocoded_place| fetch_and_create_place(reverse_geocoded_place) }
|
||||
osm_ids = places.map { |place| place.data['properties']['osm_id'].to_s }
|
||||
|
||||
return if osm_ids.empty?
|
||||
|
||||
existing_places =
|
||||
Place.where("geodata->'properties'->>'osm_id' IN (?)", osm_ids)
|
||||
.index_by { |p| p.geodata.dig('properties', 'osm_id').to_s }
|
||||
.compact
|
||||
|
||||
places.each do |reverse_geocoded_place|
|
||||
fetch_and_create_place(reverse_geocoded_place, existing_places)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
|
@ -41,13 +53,13 @@ class ReverseGeocoding::Places::FetchData
|
|||
)
|
||||
end
|
||||
|
||||
def fetch_and_create_place(reverse_geocoded_place)
|
||||
def fetch_and_create_place(reverse_geocoded_place, existing_places)
|
||||
data = reverse_geocoded_place.data
|
||||
new_place = find_place(data)
|
||||
new_place = find_place(data, existing_places)
|
||||
|
||||
new_place.name = place_name(data)
|
||||
new_place.city = data['properties']['city']
|
||||
new_place.country = data['properties']['country']
|
||||
new_place.country = data['properties']['country'] # TODO: Use country id
|
||||
new_place.geodata = data
|
||||
new_place.source = :photon
|
||||
if new_place.lonlat.blank?
|
||||
|
|
@ -57,18 +69,14 @@ class ReverseGeocoding::Places::FetchData
|
|||
new_place.save!
|
||||
end
|
||||
|
||||
def reverse_geocoded?
|
||||
place.geodata.present?
|
||||
end
|
||||
def find_place(place_data, existing_places)
|
||||
osm_id = place_data['properties']['osm_id'].to_s
|
||||
|
||||
def find_place(place_data)
|
||||
found_place = Place.where(
|
||||
"geodata->'properties'->>'osm_id' = ?", place_data['properties']['osm_id'].to_s
|
||||
).first
|
||||
existing_place = existing_places[osm_id]
|
||||
return existing_place if existing_place.present?
|
||||
|
||||
return found_place if found_place.present?
|
||||
|
||||
Place.find_or_initialize_by(
|
||||
# If not found in existing places, initialize a new one
|
||||
Place.new(
|
||||
lonlat: "POINT(#{place_data['geometry']['coordinates'][0].to_f.round(5)} #{place_data['geometry']['coordinates'][1].to_f.round(5)})",
|
||||
latitude: place_data['geometry']['coordinates'][1].to_f.round(5),
|
||||
longitude: place_data['geometry']['coordinates'][0].to_f.round(5)
|
||||
|
|
@ -92,7 +100,7 @@ class ReverseGeocoding::Places::FetchData
|
|||
limit: 10,
|
||||
distance_sort: true,
|
||||
radius: 1,
|
||||
units: ::DISTANCE_UNIT
|
||||
units: :km
|
||||
)
|
||||
|
||||
data.reject do |place|
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ class ReverseGeocoding::Points::FetchData
|
|||
def initialize(point_id)
|
||||
@point = Point.find(point_id)
|
||||
rescue ActiveRecord::RecordNotFound => e
|
||||
ExceptionReporter.call(e)
|
||||
|
||||
Rails.logger.error("Point with id #{point_id} not found: #{e.message}")
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,11 @@ class Stats::CalculateMonth
|
|||
end
|
||||
|
||||
def call
|
||||
return if points.empty?
|
||||
if points.empty?
|
||||
destroy_month_stats(year, month)
|
||||
|
||||
return
|
||||
end
|
||||
|
||||
update_month_stats(year, month)
|
||||
rescue StandardError => e
|
||||
|
|
@ -66,4 +70,8 @@ class Stats::CalculateMonth
|
|||
content: "#{error.message}, stacktrace: #{error.backtrace.join("\n")}"
|
||||
).call
|
||||
end
|
||||
|
||||
def destroy_month_stats(year, month)
|
||||
Stat.where(year:, month:, user:).destroy_all
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -24,7 +24,8 @@ class Users::SafeSettings
|
|||
immich_api_key: immich_api_key,
|
||||
photoprism_url: photoprism_url,
|
||||
photoprism_api_key: photoprism_api_key,
|
||||
maps: maps
|
||||
maps: maps,
|
||||
distance_unit: distance_unit
|
||||
}
|
||||
end
|
||||
# rubocop:enable Metrics/MethodLength
|
||||
|
|
@ -90,4 +91,8 @@ class Users::SafeSettings
|
|||
def maps
|
||||
settings['maps'] || {}
|
||||
end
|
||||
|
||||
def distance_unit
|
||||
settings.dig('maps', 'distance_unit') || 'km'
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -11,6 +11,10 @@ module Visits
|
|||
|
||||
def create_visits(visits)
|
||||
visits.map do |visit_data|
|
||||
# Check for existing confirmed visits at this location
|
||||
existing_confirmed = find_existing_confirmed_visit(visit_data)
|
||||
next existing_confirmed if existing_confirmed
|
||||
|
||||
# Variables to store data outside the transaction
|
||||
visit_instance = nil
|
||||
place_data = nil
|
||||
|
|
@ -46,11 +50,46 @@ module Visits
|
|||
end
|
||||
|
||||
visit_instance
|
||||
end
|
||||
end.compact
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Find if there's already a confirmed visit at this location within a similar time
|
||||
def find_existing_confirmed_visit(visit_data)
|
||||
# Define time window to look for existing visits (slightly wider than the visit)
|
||||
start_time = Time.zone.at(visit_data[:start_time]) - 1.hour
|
||||
end_time = Time.zone.at(visit_data[:end_time]) + 1.hour
|
||||
|
||||
# Look for confirmed visits with a similar location
|
||||
user.visits
|
||||
.confirmed
|
||||
.where('(started_at BETWEEN ? AND ?) OR (ended_at BETWEEN ? AND ?)',
|
||||
start_time, end_time, start_time, end_time)
|
||||
.find_each do |visit|
|
||||
# Skip if the visit doesn't have place or area coordinates
|
||||
next unless visit.place || visit.area
|
||||
|
||||
# Get coordinates to compare
|
||||
visit_lat = visit.place&.lat || visit.area&.latitude
|
||||
visit_lon = visit.place&.lon || visit.area&.longitude
|
||||
|
||||
next unless visit_lat && visit_lon
|
||||
|
||||
# Calculate distance between centers
|
||||
distance = Geocoder::Calculations.distance_between(
|
||||
[visit_data[:center_lat], visit_data[:center_lon]],
|
||||
[visit_lat, visit_lon],
|
||||
units: :km
|
||||
)
|
||||
|
||||
# If this confirmed visit is within 100 meters of the new suggestion
|
||||
return visit if distance <= 0.1
|
||||
end
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
# Create place_visits records directly to avoid deadlocks
|
||||
def associate_suggested_places(visit, suggested_places)
|
||||
existing_place_ids = visit.place_visits.pluck(:place_id)
|
||||
|
|
|
|||
|
|
@ -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 = Visits::Names::Suggester
|
||||
end
|
||||
|
||||
def detect_potential_visits
|
||||
|
|
@ -89,7 +90,7 @@ module Visits
|
|||
center_lat: center[0],
|
||||
center_lon: center[1],
|
||||
radius: calculate_visit_radius(points, center),
|
||||
suggested_name: suggest_place_name(points)
|
||||
suggested_name: suggest_place_name(points) || fetch_place_name(center)
|
||||
)
|
||||
end
|
||||
|
||||
|
|
@ -111,48 +112,11 @@ 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?
|
||||
place_name_suggester.new(points).call
|
||||
end
|
||||
|
||||
# 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(', ')
|
||||
def fetch_place_name(center)
|
||||
Visits::Names::Fetcher.new(center).call
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
68
app/services/visits/names/builder.rb
Normal file
68
app/services/visits/names/builder.rb
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Visits
|
||||
module Names
|
||||
# Builds descriptive names for places from geodata features
|
||||
class Builder
|
||||
def self.build_from_properties(properties)
|
||||
return nil if properties.blank?
|
||||
|
||||
name_components = [
|
||||
properties['name'],
|
||||
properties['street'],
|
||||
properties['housenumber'],
|
||||
properties['city'],
|
||||
properties['state']
|
||||
].compact.reject(&:empty?).uniq
|
||||
|
||||
name_components.any? ? name_components.join(', ') : nil
|
||||
end
|
||||
|
||||
def initialize(features, feature_type, name)
|
||||
@features = features
|
||||
@feature_type = feature_type
|
||||
@name = name
|
||||
end
|
||||
|
||||
def call
|
||||
return nil if features.blank? || feature_type.blank? || name.blank?
|
||||
return nil unless feature
|
||||
|
||||
[
|
||||
name,
|
||||
properties['street'],
|
||||
properties['city'],
|
||||
properties['state']
|
||||
].compact.uniq.join(', ')
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :features, :feature_type, :name
|
||||
|
||||
def feature
|
||||
@feature ||= find_feature
|
||||
end
|
||||
|
||||
def find_feature
|
||||
features.find do |f|
|
||||
f.dig('properties', 'type') == feature_type &&
|
||||
f.dig('properties', 'name') == name
|
||||
end || find_feature_by_osm_value
|
||||
end
|
||||
|
||||
def find_feature_by_osm_value
|
||||
features.find do |f|
|
||||
f.dig('properties', 'osm_value') == feature_type &&
|
||||
f.dig('properties', 'name') == name
|
||||
end
|
||||
end
|
||||
|
||||
def properties
|
||||
return {} unless feature && feature['properties'].is_a?(Hash)
|
||||
|
||||
feature['properties']
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
59
app/services/visits/names/fetcher.rb
Normal file
59
app/services/visits/names/fetcher.rb
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Visits
|
||||
module Names
|
||||
# Fetches names for places from reverse geocoding API
|
||||
class Fetcher
|
||||
def initialize(center)
|
||||
@center = center
|
||||
end
|
||||
|
||||
def call
|
||||
return nil if geocoder_results.blank?
|
||||
|
||||
build_place_name
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :center
|
||||
|
||||
def geocoder_results
|
||||
@geocoder_results ||= Geocoder.search(
|
||||
center, limit: 10, distance_sort: true, radius: 1, units: :km
|
||||
)
|
||||
end
|
||||
|
||||
def build_place_name
|
||||
return nil if geocoder_results.first&.data.blank?
|
||||
|
||||
return nil if properties.blank?
|
||||
|
||||
# First try the direct properties approach
|
||||
name = Visits::Names::Builder.build_from_properties(properties)
|
||||
return name if name.present?
|
||||
|
||||
# Fall back to the instance-based approach
|
||||
return nil unless properties['name'] && properties['osm_value']
|
||||
|
||||
Visits::Names::Builder.new(
|
||||
features,
|
||||
properties['osm_value'],
|
||||
properties['name']
|
||||
).call
|
||||
end
|
||||
|
||||
def features
|
||||
geocoder_results.map do |result|
|
||||
{
|
||||
'properties' => result.data['properties']
|
||||
}
|
||||
end.compact
|
||||
end
|
||||
|
||||
def properties
|
||||
@properties ||= geocoder_results.first.data['properties']
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
59
app/services/visits/names/suggester.rb
Normal file
59
app/services/visits/names/suggester.rb
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Visits
|
||||
module Names
|
||||
# Suggests names for places based on geodata from tracked points
|
||||
class Suggester
|
||||
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?
|
||||
|
||||
Visits::Names::Builder.new(
|
||||
features, most_common_type, most_common_name
|
||||
).call
|
||||
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
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -51,7 +51,7 @@ module Visits
|
|||
return existing_by_location if existing_by_location
|
||||
|
||||
# Then try by name if available
|
||||
return nil unless name.present?
|
||||
return nil if name.blank?
|
||||
|
||||
Place.where(name: name)
|
||||
.near([lat, lon], SEARCH_RADIUS, :m)
|
||||
|
|
@ -64,16 +64,13 @@ module Visits
|
|||
lon = visit_data[:center_lon]
|
||||
|
||||
# Get places from points' geodata
|
||||
places_from_points = extract_places_from_points(visit_data[:points], lat, lon)
|
||||
|
||||
# Get places from external API
|
||||
places_from_api = fetch_places_from_api(lat, lon)
|
||||
places_from_points = extract_places_from_points(visit_data[:points])
|
||||
|
||||
# Combine and deduplicate by name
|
||||
combined_places = []
|
||||
|
||||
# Add API places first (usually better quality)
|
||||
places_from_api.each do |api_place|
|
||||
reverse_geocoded_places(lat, lon).each do |api_place|
|
||||
combined_places << api_place unless place_name_exists?(combined_places, api_place.name)
|
||||
end
|
||||
|
||||
|
|
@ -86,7 +83,7 @@ module Visits
|
|||
end
|
||||
|
||||
# Step 3: Extract places from points
|
||||
def extract_places_from_points(points, center_lat, center_lon)
|
||||
def extract_places_from_points(points)
|
||||
return [] if points.blank?
|
||||
|
||||
# Filter points with geodata
|
||||
|
|
@ -101,7 +98,7 @@ module Visits
|
|||
places << place if place
|
||||
end
|
||||
|
||||
places.uniq { |place| place.name }
|
||||
places.uniq(&:name)
|
||||
end
|
||||
|
||||
# Step 4: Create place from point
|
||||
|
|
@ -141,7 +138,7 @@ module Visits
|
|||
end
|
||||
|
||||
# Step 5: Fetch places from API
|
||||
def fetch_places_from_api(lat, lon)
|
||||
def reverse_geocoded_places(lat, lon)
|
||||
# Get broader search results from Geocoder
|
||||
geocoder_results = Geocoder.search([lat, lon], units: :km, limit: 20, distance_sort: true)
|
||||
return [] if geocoder_results.blank?
|
||||
|
|
@ -228,15 +225,22 @@ module Visits
|
|||
# Helper methods
|
||||
|
||||
def build_place_name(properties)
|
||||
name_components = [
|
||||
properties['name'],
|
||||
properties['street'],
|
||||
properties['housenumber'],
|
||||
properties['postcode'],
|
||||
properties['city']
|
||||
].compact.reject(&:empty?).uniq
|
||||
# First try building with our name builder
|
||||
built_name = Visits::Names::Builder.build_from_properties(properties)
|
||||
return built_name if built_name.present?
|
||||
|
||||
name_components.any? ? name_components.join(', ') : Place::DEFAULT_NAME
|
||||
# Try using the instance-based approach as a fallback
|
||||
features = [{ 'properties' => properties }]
|
||||
feature_type = properties['type'] || properties['osm_value']
|
||||
name = properties['name']
|
||||
|
||||
if feature_type.present? && name.present?
|
||||
built_name = Visits::Names::Builder.new(features, feature_type, name).call
|
||||
return built_name if built_name.present?
|
||||
end
|
||||
|
||||
# Fallback to the default name if all else fails
|
||||
Place::DEFAULT_NAME
|
||||
end
|
||||
|
||||
def place_name_exists?(places, name)
|
||||
|
|
|
|||
|
|
@ -6,11 +6,21 @@
|
|||
|
||||
<h3 class='text-xl font-bold mt-4'>Usage examples</h3>
|
||||
|
||||
<h3 class='text-lg font-bold mt-4'>Dawarich iOS app</h3>
|
||||
<p>Provide your instance URL:</p>
|
||||
<p class='mb-2'><code><%= root_url %></code></p>
|
||||
|
||||
<p>And provide your API key:</p>
|
||||
<p><code><%= current_user.api_key %></code></p>
|
||||
<div class='divider'>OR</div>
|
||||
|
||||
<h3 class='text-lg font-bold mt-4'>OwnTracks</h3>
|
||||
<p><code><%= api_v1_owntracks_points_url(api_key: current_user.api_key) %></code></p>
|
||||
|
||||
<div class='divider'>OR</div>
|
||||
<h3 class='text-lg font-bold mt-4'>Overland</h3>
|
||||
<p><code><%= api_v1_overland_batches_url(api_key: current_user.api_key) %></code></p>
|
||||
|
||||
</p>
|
||||
<p class='py-2'>
|
||||
<%= link_to "Generate new API key", generate_api_key_path, data: { confirm: "Are you sure? This will invalidate the current API key.", turbo_confirm: "Are you sure?", turbo_method: :post }, class: 'btn btn-primary' %>
|
||||
|
|
|
|||
6
app/views/devise/registrations/_points_usage.html.erb
Normal file
6
app/views/devise/registrations/_points_usage.html.erb
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<p class="py-6">
|
||||
<p class='py-2'>
|
||||
You have used <%= number_with_delimiter(current_user.points.count) %> points of <%= number_with_delimiter(DawarichSettings::BASIC_PAID_PLAN_LIMIT) %> available.
|
||||
</p>
|
||||
<progress class="progress progress-primary w-1/2 h-5" value="<%= current_user.points.count %>" max="<%= DawarichSettings::BASIC_PAID_PLAN_LIMIT %>"></progress>
|
||||
</p>
|
||||
|
|
@ -1,10 +1,13 @@
|
|||
<% content_for :title, 'Account' %>
|
||||
|
||||
<div class="hero min-h-content bg-base-200">
|
||||
<div class="hero-content flex-col lg:flex-row-reverse w-full my-10">
|
||||
<div class="hero-content flex-col lg:flex-row-reverse w-full my-5">
|
||||
<div class="text-center lg:text-left">
|
||||
<h1 class="text-5xl font-bold">Edit your account!</h1>
|
||||
<h1 class="text-5xl font-bold mb-5">Edit your account!</h1>
|
||||
<%= render 'devise/registrations/api_key' %>
|
||||
<% if !DawarichSettings.self_hosted? %>
|
||||
<%= render 'devise/registrations/points_usage' %>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="card flex-shrink-0 w-full max-w-sm shadow-2xl bg-base-100 px-5 py-5">
|
||||
<%= form_for(resource, as: resource_name, url: registration_path(resource_name), class: 'form-body', method: :put, data: { turbo_method: :put, turbo: false }) do |f| %>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,10 @@
|
|||
</h1>
|
||||
<p class="py-6 text-3xl">The only location history tracker you'll ever need.</p>
|
||||
|
||||
<%#= link_to 'Sign up', new_user_registration_path, class: "rounded-lg py-3 px-5 my-3 bg-blue-600 text-white block font-medium" %>
|
||||
<% if !DawarichSettings.self_hosted? %>
|
||||
<%= link_to 'Sign up', new_user_registration_path, class: "rounded-lg py-3 px-5 my-3 bg-blue-600 text-white block font-medium" %>
|
||||
<div class="divider">or</div>
|
||||
<% end %>
|
||||
<%= link_to 'Sign in', new_user_session_path, class: "rounded-lg py-3 px-5 bg-neutral text-neutral-content block font-medium" %>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
|
||||
<div id="imports" class="min-w-full">
|
||||
<% if @imports.empty? %>
|
||||
<div class="hero min-h-80 bg-base-200">
|
||||
<div class="hero min-h-80 bg-base-200 my-5">
|
||||
<div class="hero-content text-center">
|
||||
<div class="max-w-md">
|
||||
<h1 class="text-5xl font-bold">Hello there!</h1>
|
||||
|
|
@ -41,7 +41,9 @@
|
|||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Imported points</th>
|
||||
<th>Reverse geocoded points</th>
|
||||
<% if DawarichSettings.store_geodata? %>
|
||||
<th>Reverse geocoded points</th>
|
||||
<% end %>
|
||||
<th>Created at</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
|
@ -65,9 +67,11 @@
|
|||
<td data-points-count>
|
||||
<%= number_with_delimiter import.processed %>
|
||||
</td>
|
||||
<td data-reverse-geocoded-points-count>
|
||||
<%= number_with_delimiter import.reverse_geocoded_points_count %>
|
||||
</td>
|
||||
<% if DawarichSettings.store_geodata? %>
|
||||
<td data-reverse-geocoded-points-count>
|
||||
<%= number_with_delimiter import.reverse_geocoded_points_count %>
|
||||
</td>
|
||||
<% end %>
|
||||
<td><%= human_datetime(import.created_at) %></td>
|
||||
</tr>
|
||||
<% end %>
|
||||
|
|
|
|||
|
|
@ -27,6 +27,20 @@
|
|||
<label class="modal-backdrop" for="fog_of_war_meters_info">Close</label>
|
||||
</div>
|
||||
|
||||
<input type="checkbox" id="fog_of_war_threshold_info" class="modal-toggle" />
|
||||
<div class="modal focus:z-99" role="dialog">
|
||||
<div class="modal-box">
|
||||
<h3 class="text-lg font-bold">Fog of War Line Threshold</h3>
|
||||
<p class="py-4">
|
||||
Value in seconds.
|
||||
</p>
|
||||
<p class="py-4">
|
||||
Points in the fog are connected by lines. This value is the maximum time between two points to be connected by a line. If the time between two points is greater than this value, they will not be connected.
|
||||
</p>
|
||||
</div>
|
||||
<label class="modal-backdrop" for="fog_of_war_threshold_info">Close</label>
|
||||
</div>
|
||||
|
||||
<input type="checkbox" id="meters_between_routes_info" class="modal-toggle" />
|
||||
<div class="modal focus:z-99" role="dialog">
|
||||
<div class="modal-box">
|
||||
|
|
|
|||
|
|
@ -5,18 +5,36 @@
|
|||
<div class="flex flex-col space-y-4 mb-4 w-full">
|
||||
<%= form_with url: map_path(import_id: params[:import_id]), method: :get do |f| %>
|
||||
<div class="flex flex-col space-y-4 sm:flex-row sm:space-y-0 sm:space-x-4 sm:items-end">
|
||||
<div class="w-full sm:w-2/12 md:w-1/12 lg:w-3/12">
|
||||
<div class="w-full sm:w-1/12 md:w-1/12 lg:w-1/12">
|
||||
<div class="flex flex-col space-y-2">
|
||||
<span class="tooltip" data-tip="<%= human_date(@start_at - 1.day) %>">
|
||||
<%= link_to map_path(start_at: @start_at - 1.day, end_at: @end_at - 1.day, import_id: params[:import_id]), class: "btn btn-neutral hover:btn-ghost w-full" do %>
|
||||
◀️
|
||||
<% end %>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full sm:w-2/12 md:w-1/12 lg:w-2/12">
|
||||
<div class="flex flex-col space-y-2">
|
||||
<%= f.label :start_at, class: "text-sm font-semibold" %>
|
||||
<%= f.datetime_local_field :start_at, include_seconds: false, class: "input input-bordered hover:cursor-pointer hover:input-primary", value: @start_at %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full sm:w-2/12 md:w-1/12 lg:w-3/12">
|
||||
<div class="w-full sm:w-2/12 md:w-1/12 lg:w-2/12">
|
||||
<div class="flex flex-col space-y-2">
|
||||
<%= f.label :end_at, class: "text-sm font-semibold" %>
|
||||
<%= f.datetime_local_field :end_at, include_seconds: false, class: "input input-bordered hover:cursor-pointer hover:input-primary", value: @end_at %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full sm:w-1/12 md:w-1/12 lg:w-1/12">
|
||||
<div class="flex flex-col space-y-2">
|
||||
<span class="tooltip" data-tip="<%= human_date(@start_at + 1.day) %>">
|
||||
<%= link_to map_path(start_at: @start_at + 1.day, end_at: @end_at + 1.day, import_id: params[:import_id]), class: "btn btn-neutral hover:btn-ghost w-full" do %>
|
||||
▶️
|
||||
<% end %>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full sm:w-6/12 md:w-2/12 lg:w-1/12">
|
||||
<div class="flex flex-col space-y-2">
|
||||
<%= f.submit "Search", class: "btn btn-primary hover:btn-info" %>
|
||||
|
|
@ -47,7 +65,6 @@
|
|||
class="w-full z-0"
|
||||
data-controller="maps points"
|
||||
data-points-target="map"
|
||||
data-distance_unit="<%= DISTANCE_UNIT %>"
|
||||
data-api_key="<%= current_user.api_key %>"
|
||||
data-self_hosted="<%= @self_hosted %>"
|
||||
data-user_settings='<%= current_user.settings.to_json.html_safe %>'
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
<div role="tablist" class="tabs tabs-lifted tabs-lg">
|
||||
<%= link_to 'Integrations', settings_path, role: 'tab', class: "tab #{active_tab?(settings_path)}" %>
|
||||
<div class="tabs tabs-boxed mb-6">
|
||||
<%= link_to 'Integrations', settings_path, role: 'tab', class: "tab tab-lg #{active_tab?(settings_path)}" %>
|
||||
<%= link_to 'Map', settings_maps_path, role: 'tab', class: "tab tab-lg #{active_tab?(settings_maps_path)}" %>
|
||||
<% if DawarichSettings.self_hosted? && current_user.admin? %>
|
||||
<%= link_to 'Users', settings_users_path, role: 'tab', class: "tab #{active_tab?(settings_users_path)}" %>
|
||||
<%= link_to 'Background Jobs', settings_background_jobs_path, role: 'tab', class: "tab #{active_tab?(settings_background_jobs_path)}" %>
|
||||
<%= link_to 'Users', settings_users_path, role: 'tab', class: "tab tab-lg #{active_tab?(settings_users_path)}" %>
|
||||
<%= link_to 'Background Jobs', settings_background_jobs_path, role: 'tab', class: "tab tab-lg #{active_tab?(settings_background_jobs_path)}" %>
|
||||
<% end %>
|
||||
<%= link_to 'Map', settings_maps_path, role: 'tab', class: "tab #{active_tab?(settings_maps_path)}" %>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,9 @@
|
|||
<% content_for :title, "Background jobs" %>
|
||||
|
||||
<div class="min-h-content w-full my-5">
|
||||
<h1 class="text-3xl font-bold mb-6">Background jobs</h1>
|
||||
<%= render 'settings/navigation' %>
|
||||
|
||||
<div class="flex justify-between items-center mt-5">
|
||||
<h1 class="font-bold text-4xl">Background jobs</h1>
|
||||
</div>
|
||||
|
||||
<div role="alert" class="alert m-5">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
|
|
|
|||
|
|
@ -1,34 +1,78 @@
|
|||
<% content_for :title, 'Settings' %>
|
||||
|
||||
<div class="min-h-content w-full my-5">
|
||||
<h1 class="text-3xl font-bold mb-6">User Settings</h1>
|
||||
<%= render 'settings/navigation' %>
|
||||
|
||||
<div class="flex flex-col lg:flex-row w-full my-10 space-x-4">
|
||||
<div class="card flex-shrink-0 w-full max-w-sm shadow-2xl bg-base-100 px-5 py-5 mx-5">
|
||||
<h2 class="text-2xl font-bold">Edit your Integrations settings!</h1>
|
||||
<%= form_for :settings, url: settings_path, method: :patch, data: { turbo_method: :patch, turbo: false } do |f| %>
|
||||
<div class="form-control my-2">
|
||||
<%= f.label :immich_url %>
|
||||
<%= f.text_field :immich_url, value: current_user.safe_settings.immich_url, class: "input input-bordered", placeholder: 'http://192.168.0.1:2283' %>
|
||||
<div class="card bg-base-200 shadow-xl">
|
||||
<%= form_for :settings, url: settings_path, method: :patch, data: { turbo_method: :patch, turbo: false } do |f| %>
|
||||
<div class="card-body">
|
||||
<div class="space-y-8 animate-fade-in">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold mb-4 flex items-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-camera mr-2 text-primary">
|
||||
<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z"></path>
|
||||
<circle cx="12" cy="13" r="3"></circle>
|
||||
</svg>Immich Integration
|
||||
</h2>
|
||||
<div class="bg-base-100 p-5 rounded-lg shadow-sm space-y-4">
|
||||
<div class="form-control w-full">
|
||||
<%= f.label :immich_url, class: 'label' do %>
|
||||
<span class="label-text font-medium">Immich URL</span>
|
||||
<% end %>
|
||||
<%= f.url_field :immich_url, value: current_user.safe_settings.immich_url, class: "input input-bordered w-full pr-10", placeholder: 'http://192.168.0.1:2283' %>
|
||||
<span class="label-text-alt mt-1">The base URL of your Immich instance</span>
|
||||
</div>
|
||||
<div class="form-control w-full">
|
||||
<%= f.label :immich_api_key, class: 'label' do %>
|
||||
<span class="label-text font-medium">Immich API Key</span>
|
||||
<% end %>
|
||||
<div class="relative">
|
||||
<%= f.password_field :immich_api_key, value: current_user.safe_settings.immich_api_key, class: "input input-bordered w-full pr-10", placeholder: 'xxxxxxxxxxxxxx' %>
|
||||
</div>
|
||||
<span class="label-text-alt mt-1">Found in your Immich admin panel under API settings</span>
|
||||
</div>
|
||||
<%# <div class="flex justify-end">
|
||||
<button class="btn btn-sm btn-outline">Test Connection</button>
|
||||
</div> %>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold mb-4 flex items-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-camera mr-2 text-primary">
|
||||
<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z"></path>
|
||||
<circle cx="12" cy="13" r="3"></circle>
|
||||
</svg>Photoprism Integration
|
||||
</h2>
|
||||
<div class="bg-base-100 p-5 rounded-lg shadow-sm space-y-4">
|
||||
<div class="form-control w-full">
|
||||
<%= f.label :photoprism_url, class: 'label' do %>
|
||||
<span class="label-text font-medium">Photoprism URL</span>
|
||||
<% end %>
|
||||
<div class="relative">
|
||||
<%= f.url_field :photoprism_url, value: current_user.safe_settings.photoprism_url, class: "input input-bordered w-full pr-10", placeholder: 'http://192.168.0.1:2342' %>
|
||||
</div>
|
||||
<span class="label-text-alt mt-1">The base URL of your Photoprism instance</span>
|
||||
</div>
|
||||
<div class="form-control w-full">
|
||||
<%= f.label :photoprism_api_key, class: 'label' do %>
|
||||
<span class="label-text font-medium">Photoprism API Key</span>
|
||||
<% end %>
|
||||
<div class="relative">
|
||||
<%= f.password_field :photoprism_api_key, value: current_user.safe_settings.photoprism_api_key, class: "input input-bordered w-full pr-10", placeholder: 'xxxxxxxxxxxxxx' %>
|
||||
</div>
|
||||
<span class="label-text-alt mt-1">Found in your Photoprism settings under Library</span>
|
||||
</div>
|
||||
<%# <div class="flex justify-end">
|
||||
<button class="btn btn-sm btn-outline">Test Connection</button>
|
||||
</div> %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-control my-2">
|
||||
<%= f.label :immich_api_key %>
|
||||
<%= f.text_field :immich_api_key, value: current_user.safe_settings.immich_api_key, class: "input input-bordered", placeholder: 'xxxxxxxxxxxxxx' %>
|
||||
<div class="card-actions justify-end mt-6">
|
||||
<%= f.submit "Save changes", class: "btn btn-primary" %>
|
||||
</div>
|
||||
<div class="divider"></div>
|
||||
<div class="form-control my-2">
|
||||
<%= f.label :photoprism_url %>
|
||||
<%= f.text_field :photoprism_url, value: current_user.safe_settings.photoprism_url, class: "input input-bordered", placeholder: 'http://192.168.0.1:2342' %>
|
||||
</div>
|
||||
<div class="form-control my-2">
|
||||
<%= f.label :photoprism_api_key %>
|
||||
<%= f.text_field :photoprism_api_key, value: current_user.safe_settings.photoprism_api_key, class: "input input-bordered", placeholder: 'xxxxxxxxxxxxxx' %>
|
||||
</div>
|
||||
|
||||
<div class="form-control my-2">
|
||||
<%= f.submit "Update", class: "btn btn-primary" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,9 @@
|
|||
<% content_for :title, "Map settings" %>
|
||||
|
||||
<div class="min-h-content w-full my-5">
|
||||
<h1 class="text-3xl font-bold mb-6">Map settings</h1>
|
||||
<%= render 'settings/navigation' %>
|
||||
|
||||
<div class="flex justify-between items-center my-5">
|
||||
<h1 class="font-bold text-4xl">Maps settings</h1>
|
||||
</div>
|
||||
|
||||
<div role="alert" class="alert alert-info">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
|
|
@ -22,50 +19,106 @@
|
|||
<span>Please remember, that using a custom tile URL may result in extra costs. Check your map tile provider's terms of service for more information.</span>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mt-5" data-controller="map-preview">
|
||||
<div class="flex flex-col gap-4">
|
||||
<%= form_for :maps,
|
||||
url: settings_maps_path,
|
||||
method: :patch,
|
||||
autocomplete: "off",
|
||||
data: { turbo_method: :patch, turbo: false } do |f| %>
|
||||
<div class="form-control my-2">
|
||||
<%= f.label :name %>
|
||||
<%= f.text_field :name, value: @maps['name'], placeholder: 'Example: OpenStreetMap', class: "input input-bordered" %>
|
||||
<div class="card bg-base-200 shadow-xl">
|
||||
<%= form_for :maps,
|
||||
url: settings_maps_path,
|
||||
method: :patch,
|
||||
autocomplete: "off",
|
||||
data: { turbo_method: :patch, turbo: false } do |f| %>
|
||||
|
||||
<div class="card-body">
|
||||
<div class="space-y-8 animate-fade-in">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold mb-4 flex items-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-map mr-2 text-primary">
|
||||
<polygon points="3 6 9 3 15 6 21 3 21 18 15 21 9 18 3 21"></polygon>
|
||||
<line x1="9" x2="9" y1="3" y2="18"></line>
|
||||
<line x1="15" x2="15" y1="6" y2="21"></line>
|
||||
</svg>Map Configuration
|
||||
</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6" data-controller="map-preview">
|
||||
<div class="bg-base-100 p-5 rounded-lg shadow-sm space-y-4">
|
||||
<div class="form-control w-full">
|
||||
<%= f.label :name, class: 'label' do %>
|
||||
<span class="label-text font-medium">Map Name</span>
|
||||
<% end %>
|
||||
<div class="relative">
|
||||
<%= f.text_field :name, value: @maps['name'], placeholder: 'Example: OpenStreetMap', class: "input input-bordered w-full pr-10" %>
|
||||
</div>
|
||||
<span class="label-text-alt mt-1">A descriptive name for your map configuration</span>
|
||||
</div>
|
||||
<div class="form-control w-full">
|
||||
<%= f.label :url, class: 'label' do %>
|
||||
<span class="label-text font-medium">Tile URL</span>
|
||||
<% end %>
|
||||
<div class="relative">
|
||||
<%= f.text_field :url,
|
||||
value: @maps['url'],
|
||||
autocomplete: "off",
|
||||
placeholder: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
class: "input input-bordered w-full pr-10",
|
||||
data: {
|
||||
map_preview_target: "urlInput",
|
||||
action: "input->map-preview#updatePreview"
|
||||
} %>
|
||||
</div>
|
||||
<span class="label-text-alt mt-1">URL pattern for map tiles. Must include {x}, {y}, and {z} placeholders</span>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer justify-start">
|
||||
<span class="label-text mr-4 flex items-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-globe mr-2 w-4 h-4">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"></path>
|
||||
<path d="M2 12h20"></path>
|
||||
</svg>Distance Unit </span>
|
||||
<div class="flex items-center space-x-2">
|
||||
<%= f.label :distance_unit_km, 'Kilometers', class: 'cursor-pointer' %>
|
||||
<%= f.radio_button :distance_unit, 'km', id: 'maps_distance_unit_km', class: 'radio radio-primary ml-1 mr-4', checked: @maps['distance_unit'] == 'km' %>
|
||||
<%= f.label :distance_unit_mi, 'Miles', class: 'cursor-pointer' %>
|
||||
<%= f.radio_button :distance_unit, 'mi', id: 'maps_distance_unit_mi', class: 'radio radio-primary ml-1', checked: @maps['distance_unit'] == 'mi' %>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-base-100 p-5 rounded-lg shadow-sm">
|
||||
<h3 class="font-semibold mb-2">Map Preview</h3>
|
||||
<div class="h-[250px] w-full rounded-lg overflow-hidden border border-base-300">
|
||||
<div class="h-full w-full relative">
|
||||
<div style="height: 500px;">
|
||||
<div
|
||||
data-map-preview-target="mapContainer"
|
||||
class="w-full h-full rounded-lg border"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-base-100 p-5 mt-5 rounded-lg shadow-sm">
|
||||
<h3 class="font-semibold mb-4">Tile Usage (Last 7 Days)</h3>
|
||||
<div class="h-[250px]">
|
||||
<%= line_chart(
|
||||
@tile_usage,
|
||||
height: '200px',
|
||||
xtitle: 'Days',
|
||||
ytitle: 'Tiles',
|
||||
suffix: ' tiles loaded'
|
||||
) %>
|
||||
</div>
|
||||
<div class="mt-4 text-sm text-base-content/70">
|
||||
<p>Total usage this week: <span class="font-semibold"><%= @tile_usage.sum { |_, count| count } %> tiles</span>
|
||||
</p>
|
||||
<!--p>Monthly quota: <span class="font-semibold">100,000 tiles</span-->
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-control my-2">
|
||||
<%= f.label :url, 'URL' %>
|
||||
<%= f.text_field :url,
|
||||
value: @maps['url'],
|
||||
autocomplete: "off",
|
||||
placeholder: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
class: "input input-bordered",
|
||||
data: {
|
||||
map_preview_target: "urlInput",
|
||||
action: "input->map-preview#updatePreview"
|
||||
} %>
|
||||
<div class="card-actions justify-end mt-6">
|
||||
<%= f.submit 'Save changes', class: "btn btn-primary", data: { map_preview_target: "saveButton" } %>
|
||||
</div>
|
||||
|
||||
<%= f.submit 'Save', class: "btn btn-primary", data: { map_preview_target: "saveButton" } %>
|
||||
<% end %>
|
||||
|
||||
<h2 class="text-lg font-bold">Tile usage</h2>
|
||||
|
||||
<%= line_chart(
|
||||
@tile_usage,
|
||||
height: '200px',
|
||||
xtitle: 'Days',
|
||||
ytitle: 'Tiles',
|
||||
suffix: ' tiles loaded'
|
||||
) %>
|
||||
</div>
|
||||
|
||||
<div style="height: 500px;">
|
||||
<div
|
||||
data-map-preview-target="mapContainer"
|
||||
class="w-full h-full rounded-lg border"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<% content_for :title, 'Users' %>
|
||||
|
||||
<div class="min-h-content w-full">
|
||||
<div class="min-h-content w-full my-5">
|
||||
<h1 class="text-3xl font-bold mb-6">Users management</h1>
|
||||
<%= render 'settings/navigation' %>
|
||||
|
||||
<div class="flex flex-col lg:flex-row w-full my-10 space-x-4">
|
||||
|
|
|
|||
|
|
@ -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()">
|
||||
|
|
|
|||
|
|
@ -7,11 +7,12 @@
|
|||
<% end %>
|
||||
</h2>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<%= link_to '[Update]', update_year_month_stats_path(stat.year, stat.month), data: { turbo_method: :put }, class: 'text-sm text-gray-500 hover:underline' %>
|
||||
<div class="gap-2">
|
||||
<span class='text-xs text-gray-500'>Last update <%= human_date(stat.updated_at) %></span>
|
||||
<%= link_to '🔄', update_year_month_stats_path(stat.year, stat.month), data: { turbo_method: :put }, class: 'text-sm text-gray-500 hover:underline' %>
|
||||
</div>
|
||||
</div>
|
||||
<p><%= stat.distance %><%= DISTANCE_UNIT %></p>
|
||||
<p><%= number_with_delimiter stat.distance %><%= current_user.safe_settings.distance_unit %></p>
|
||||
<% if DawarichSettings.reverse_geocoding_enabled? %>
|
||||
<div class="card-actions justify-end">
|
||||
<%= countries_and_cities_stat_for_month(stat) %>
|
||||
|
|
@ -21,7 +22,7 @@
|
|||
<%= column_chart(
|
||||
stat.daily_distance,
|
||||
height: '100px',
|
||||
suffix: " #{DISTANCE_UNIT}",
|
||||
suffix: " #{current_user.safe_settings.distance_unit}",
|
||||
xtitle: 'Days',
|
||||
ytitle: 'Distance'
|
||||
) %>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
<%= column_chart(
|
||||
Stat.year_distance(year, current_user),
|
||||
height: '200px',
|
||||
suffix: " #{DISTANCE_UNIT}",
|
||||
suffix: " #{current_user.safe_settings.distance_unit}",
|
||||
xtitle: 'Days',
|
||||
ytitle: 'Distance'
|
||||
) %>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
<div class="stats stats-vertical lg:stats-horizontal shadow w-full bg-base-200">
|
||||
<div class="stat text-center">
|
||||
<div class="stat-value text-primary">
|
||||
<%= number_with_delimiter(current_user.total_distance) %> <%= DISTANCE_UNIT %>
|
||||
<%= number_with_delimiter(current_user.total_distance) %> <%= current_user.safe_settings.distance_unit %>
|
||||
</div>
|
||||
<div class="stat-title">Total distance</div>
|
||||
</div>
|
||||
|
|
@ -32,22 +32,59 @@
|
|||
<%= link_to year, "/stats/#{year}", class: 'underline hover:no-underline' %>
|
||||
<%= link_to '[Map]', map_url(year_timespan(year)), class: 'underline hover:no-underline' %>
|
||||
</div>
|
||||
<%= link_to '[Update]', update_year_month_stats_path(year, :all), data: { turbo_method: :put }, class: 'text-sm text-gray-500 hover:underline' %>
|
||||
<div class="gap-2">
|
||||
<span class='text-xs text-gray-500'>Last updated: <%= human_date(stats.first.updated_at) %></span>
|
||||
<%= link_to '🔄', update_year_month_stats_path(year, :all), data: { turbo_method: :put }, class: 'text-sm text-gray-500 hover:underline' %>
|
||||
</div>
|
||||
</h2>
|
||||
<p>
|
||||
<% cache [current_user, 'year_distance_stat', year], skip_digest: true do %>
|
||||
<%= number_with_delimiter year_distance_stat(year, current_user) %><%= DISTANCE_UNIT %>
|
||||
<%= number_with_delimiter year_distance_stat(year, current_user) %><%= current_user.safe_settings.distance_unit %>
|
||||
<% end %>
|
||||
</p>
|
||||
<% if DawarichSettings.reverse_geocoding_enabled? %>
|
||||
<div class="card-actions justify-end">
|
||||
<%= countries_and_cities_stat_for_year(year, stats) %>
|
||||
<% location_data = countries_and_cities_stat_for_year(year, stats) %>
|
||||
<%= link_to "#{location_data[:countries_count]} countries, #{location_data[:cities_count]} cities",
|
||||
"##{location_data[:modal_id]}",
|
||||
class: "link link-primary",
|
||||
onclick: "document.getElementById('#{location_data[:modal_id]}').checked = true" %>
|
||||
|
||||
<!-- Modal structure -->
|
||||
<div>
|
||||
<input type="checkbox" id="<%= location_data[:modal_id] %>" class="modal-toggle" />
|
||||
<div class="modal" role="dialog">
|
||||
<div class="modal-box max-w-3xl">
|
||||
<h3 class="text-lg font-bold mb-4">Countries and Cities visited in <%= location_data[:year] %></h3>
|
||||
<div class="max-h-96 overflow-y-auto">
|
||||
<% location_data[:grouped_by_country].each do |country, cities| %>
|
||||
<div class="mb-4">
|
||||
<h4 class="font-bold">
|
||||
<span class="mr-2"><%= country_flag(country) %></span>
|
||||
<%= country %>
|
||||
</h4>
|
||||
<% if cities.any? %>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2 pl-4">
|
||||
<% cities.each do |city| %>
|
||||
<div class="text-sm"><%= city %></div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<p class="text-sm text-gray-500 italic pl-4">No specific cities recorded</p>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<label class="modal-backdrop" for="<%= location_data[:modal_id] %>"></label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<%= column_chart(
|
||||
Stat.year_distance(year, current_user),
|
||||
height: '200px',
|
||||
suffix: " #{DISTANCE_UNIT}",
|
||||
suffix: " #{current_user.safe_settings.distance_unit}",
|
||||
xtitle: 'Days',
|
||||
ytitle: 'Distance'
|
||||
) %>
|
||||
|
|
|
|||
14
app/views/trips/_countries.html.erb
Normal file
14
app/views/trips/_countries.html.erb
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<% if trip.countries.any? %>
|
||||
<p class="text-md text-base-content/60">
|
||||
<%= "#{trip.countries.join(', ')} (#{trip.distance} #{current_user.safe_settings.distance_unit})" %>
|
||||
</p>
|
||||
<% elsif trip.visited_countries.present? %>
|
||||
<p class="text-md text-base-content/60">
|
||||
<%= "#{trip.visited_countries.join(', ')} (#{trip.distance} #{current_user.safe_settings.distance_unit})" %>
|
||||
</p>
|
||||
<% else %>
|
||||
<p class="text-md text-base-content/60">
|
||||
<span>Countries are being calculated...</span>
|
||||
<span class="loading loading-dots loading-sm"></span>
|
||||
</p>
|
||||
<% end %>
|
||||
6
app/views/trips/_distance.html.erb
Normal file
6
app/views/trips/_distance.html.erb
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<% if trip.distance.present? %>
|
||||
<span class="text-md"><%= trip.distance %> <%= current_user.safe_settings.distance_unit %></span>
|
||||
<% else %>
|
||||
<span class="text-md">Calculating...</span>
|
||||
<span class="loading loading-dots loading-sm"></span>
|
||||
<% end %>
|
||||
|
|
@ -17,7 +17,6 @@
|
|||
id='map trips-container'
|
||||
class="w-full h-full rounded-lg"
|
||||
data-trips-target="container"
|
||||
data-distance_unit="<%= DISTANCE_UNIT %>"
|
||||
data-api_key="<%= current_user.api_key %>"
|
||||
data-user_settings="<%= current_user.settings.to_json %>"
|
||||
data-path="<%= trip.path.to_json %>"
|
||||
|
|
|
|||
23
app/views/trips/_path.html.erb
Normal file
23
app/views/trips/_path.html.erb
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<% if trip.path.present? %>
|
||||
<div
|
||||
id='map'
|
||||
class="w-full h-full rounded-lg z-0"
|
||||
data-controller="trips"
|
||||
data-trips-target="container"
|
||||
data-api_key="<%= trip.user.api_key %>"
|
||||
data-user_settings="<%= trip.user.settings.to_json %>"
|
||||
data-path="<%= trip.path.coordinates.to_json %>"
|
||||
data-started_at="<%= trip.started_at %>"
|
||||
data-ended_at="<%= trip.ended_at %>"
|
||||
data-timezone="<%= Rails.configuration.time_zone %>">
|
||||
<div data-trips-target="container" class="h-[25rem] w-full min-h-screen">
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="flex items-center justify-center h-full">
|
||||
<div class="text-center">
|
||||
<p class="text-base-content/60">Trip path is being calculated...</p>
|
||||
<div class="loading loading-spinner loading-lg mt-4"></div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
|
@ -5,7 +5,7 @@
|
|||
<span class="hover:underline"><%= trip.name %></span>
|
||||
</h2>
|
||||
<p class="text-sm text-gray-600 text-center">
|
||||
<%= "#{human_date(trip.started_at)} – #{human_date(trip.ended_at)}, #{trip.distance} #{DISTANCE_UNIT}" %>
|
||||
<%= "#{human_date(trip.started_at)} – #{human_date(trip.ended_at)}, #{trip.distance} #{current_user.safe_settings.distance_unit}" %>
|
||||
</p>
|
||||
|
||||
<div style="width: 100%; aspect-ratio: 1/1;"
|
||||
|
|
@ -13,11 +13,10 @@
|
|||
class="rounded-lg z-0"
|
||||
data-controller="trip-map"
|
||||
data-trip-map-trip-id-value="<%= trip.id %>"
|
||||
data-trip-map-path-value="<%= trip.path.to_json %>"
|
||||
data-trip-map-path-value="<%= trip.path.coordinates.to_json %>"
|
||||
data-trip-map-api-key-value="<%= current_user.api_key %>"
|
||||
data-trip-map-user-settings-value="<%= current_user.settings.to_json %>"
|
||||
data-trip-map-timezone-value="<%= Rails.configuration.time_zone %>"
|
||||
data-trip-map-distance-unit-value="<%= DISTANCE_UNIT %>">
|
||||
data-trip-map-timezone-value="<%= Rails.configuration.time_zone %>">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,36 +1,31 @@
|
|||
<% content_for :title, @trip.name %>
|
||||
|
||||
<%= turbo_stream_from "trip_#{@trip.id}" %>
|
||||
|
||||
<div class="container mx-auto px-4 max-w-4xl my-5">
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-4xl font-bold mb-2"><%= @trip.name %></h1>
|
||||
<p class="text-md text-base-content/60">
|
||||
<%= human_date(@trip.started_at) %> - <%= human_date(@trip.ended_at) %>
|
||||
</p>
|
||||
<% if @trip.countries.any? %>
|
||||
<p class="text-lg text-base-content/60">
|
||||
<%= "#{@trip.countries.join(', ')} (#{@trip.distance} #{DISTANCE_UNIT})" %>
|
||||
</p>
|
||||
<% if @trip.countries.any? || @trip.visited_countries.present? %>
|
||||
<div id="trip_countries">
|
||||
<%= render "trips/countries", trip: @trip %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div id="trip_countries">
|
||||
<p class="text-md text-base-content/60">
|
||||
<span>Countries are being calculated...</span>
|
||||
<span class="loading loading-dots loading-sm"></span>
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="bg-base-100 my-8 p-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div class="w-full">
|
||||
<div
|
||||
id='map'
|
||||
class="w-full h-full rounded-lg z-0"
|
||||
data-controller="trips"
|
||||
data-trips-target="container"
|
||||
data-distance_unit="<%= DISTANCE_UNIT %>"
|
||||
data-api_key="<%= current_user.api_key %>"
|
||||
data-user_settings="<%= current_user.settings.to_json %>"
|
||||
data-path="<%= @trip.path.to_json %>"
|
||||
data-started_at="<%= @trip.started_at %>"
|
||||
data-ended_at="<%= @trip.ended_at %>"
|
||||
data-timezone="<%= Rails.configuration.time_zone %>">
|
||||
<div data-trips-target="container" class="h-[25rem] w-full min-h-screen">
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full" id="trip_path">
|
||||
<%= render "trips/path", trip: @trip, current_user: current_user %>
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -1,2 +1,6 @@
|
|||
<%= link_to 'Confirm', visit_path(visit, 'visit[status]': :confirmed), method: :patch, data: { turbo_method: :patch }, class: 'btn btn-xs btn-success' %>
|
||||
<%= link_to 'Decline', visit_path(visit, 'visit[status]': :declined), method: :patch, data: { turbo_method: :patch }, class: 'btn btn-xs btn-error mx-1' %>
|
||||
<% if !visit.confirmed? %>
|
||||
<%= link_to 'Confirm', visit_path(visit, 'visit[status]': :confirmed), method: :patch, data: { turbo_method: :patch }, class: 'btn btn-xs btn-success' %>
|
||||
<% end %>
|
||||
<% if !visit.declined? %>
|
||||
<%= link_to 'Decline', visit_path(visit, 'visit[status]': :declined), method: :patch, data: { turbo_method: :patch }, class: 'btn btn-xs btn-error mx-1' %>
|
||||
<% end %>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,14 @@
|
|||
SELF_HOSTED = ENV.fetch('SELF_HOSTED', 'true') == 'true'
|
||||
|
||||
MIN_MINUTES_SPENT_IN_CITY = ENV.fetch('MIN_MINUTES_SPENT_IN_CITY', 60).to_i
|
||||
DISTANCE_UNIT = ENV.fetch('DISTANCE_UNIT', 'km').to_sym
|
||||
|
||||
DISTANCE_UNITS = {
|
||||
km: 1000, # to meters
|
||||
mi: 1609.34, # to meters
|
||||
m: 1, # already in meters
|
||||
ft: 0.3048, # to meters
|
||||
yd: 0.9144 # to meters
|
||||
}.freeze
|
||||
|
||||
APP_VERSION = File.read('.app_version').strip
|
||||
|
||||
|
|
@ -17,6 +24,7 @@ NOMINATIM_API_KEY = ENV.fetch('NOMINATIM_API_KEY', nil)
|
|||
NOMINATIM_API_USE_HTTPS = ENV.fetch('NOMINATIM_API_USE_HTTPS', 'true') == 'true'
|
||||
|
||||
GEOAPIFY_API_KEY = ENV.fetch('GEOAPIFY_API_KEY', nil)
|
||||
STORE_GEODATA = ENV.fetch('STORE_GEODATA', 'true') == 'true'
|
||||
# /Reverse geocoding settings
|
||||
|
||||
SENTRY_DSN = ENV.fetch('SENTRY_DSN', nil)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class DawarichSettings
|
||||
BASIC_PAID_PLAN_LIMIT = 10_000_000 # 10 million points
|
||||
|
||||
class << self
|
||||
|
||||
def reverse_geocoding_enabled?
|
||||
@reverse_geocoding_enabled ||= photon_enabled? || geoapify_enabled? || nominatim_enabled?
|
||||
end
|
||||
|
|
@ -32,5 +35,9 @@ class DawarichSettings
|
|||
def nominatim_enabled?
|
||||
@nominatim_enabled ||= NOMINATIM_API_HOST.present?
|
||||
end
|
||||
|
||||
def store_geodata?
|
||||
@store_geodata ||= STORE_GEODATA
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
settings = {
|
||||
timeout: 5,
|
||||
units: DISTANCE_UNIT,
|
||||
units: :km,
|
||||
cache: Redis.new,
|
||||
always_raise: :all,
|
||||
use_https: PHOTON_API_USE_HTTPS,
|
||||
|
|
|
|||
|
|
@ -6,4 +6,6 @@ Sentry.init do |config|
|
|||
config.breadcrumbs_logger = [:active_support_logger]
|
||||
config.dsn = SENTRY_DSN
|
||||
config.traces_sample_rate = 1.0
|
||||
config.profiles_sample_rate = 1.0
|
||||
# config.enable_logs = true
|
||||
end
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
class CreatePathsForTrips < ActiveRecord::Migration[8.0]
|
||||
def up
|
||||
Trip.find_each do |trip|
|
||||
Trips::CreatePathJob.perform_later(trip.id)
|
||||
Trips::CalculatePathJob.perform_later(trip.id)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
11
db/data/20250516180933_set_points_country_ids.rb
Normal file
11
db/data/20250516180933_set_points_country_ids.rb
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class SetPointsCountryIds < ActiveRecord::Migration[8.0]
|
||||
def up
|
||||
DataMigrations::StartSettingsPointsCountryIdsJob.perform_later
|
||||
end
|
||||
|
||||
def down
|
||||
raise ActiveRecord::IrreversibleMigration
|
||||
end
|
||||
end
|
||||
|
|
@ -1 +1 @@
|
|||
DataMigrate::Data.define(version: 20_250_404_182_629)
|
||||
DataMigrate::Data.define(version: 20250516181033)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,6 @@
|
|||
|
||||
class EnablePostgisExtension < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
enable_extension 'postgis'
|
||||
enable_extension 'postgis' unless extension_enabled?('postgis')
|
||||
end
|
||||
end
|
||||
|
|
|
|||
11
db/migrate/20250513164521_add_visited_countries_to_trips.rb
Normal file
11
db/migrate/20250513164521_add_visited_countries_to_trips.rb
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class AddVisitedCountriesToTrips < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
safety_assured do
|
||||
execute <<-SQL
|
||||
ALTER TABLE trips ADD COLUMN visited_countries JSONB DEFAULT '{}'::jsonb NOT NULL;
|
||||
SQL
|
||||
end
|
||||
end
|
||||
end
|
||||
19
db/migrate/20250515190752_create_countries.rb
Normal file
19
db/migrate/20250515190752_create_countries.rb
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class CreateCountries < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
create_table :countries do |t|
|
||||
t.string :name, null: false
|
||||
t.string :iso_a2, null: false
|
||||
t.string :iso_a3, null: false
|
||||
t.multi_polygon :geom, srid: 4326
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :countries, :name
|
||||
add_index :countries, :iso_a2
|
||||
add_index :countries, :iso_a3
|
||||
add_index :countries, :geom, using: :gist
|
||||
end
|
||||
end
|
||||
9
db/migrate/20250515192211_add_country_id_to_points.rb
Normal file
9
db/migrate/20250515192211_add_country_id_to_points.rb
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class AddCountryIdToPoints < ActiveRecord::Migration[8.0]
|
||||
disable_ddl_transaction!
|
||||
|
||||
def change
|
||||
add_reference :points, :country, index: { algorithm: :concurrently }
|
||||
end
|
||||
end
|
||||
465
db/schema.rb
generated
465
db/schema.rb
generated
|
|
@ -10,264 +10,277 @@
|
|||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.0].define(version: 20_250_404_182_437) do
|
||||
ActiveRecord::Schema[8.0].define(version: 2025_05_15_192211) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension 'pg_catalog.plpgsql'
|
||||
enable_extension 'postgis'
|
||||
enable_extension "pg_catalog.plpgsql"
|
||||
enable_extension "postgis"
|
||||
|
||||
create_table 'action_text_rich_texts', force: :cascade do |t|
|
||||
t.string 'name', null: false
|
||||
t.text 'body'
|
||||
t.string 'record_type', null: false
|
||||
t.bigint 'record_id', null: false
|
||||
t.datetime 'created_at', null: false
|
||||
t.datetime 'updated_at', null: false
|
||||
t.index %w[record_type record_id name], name: 'index_action_text_rich_texts_uniqueness', unique: true
|
||||
create_table "action_text_rich_texts", force: :cascade do |t|
|
||||
t.string "name", null: false
|
||||
t.text "body"
|
||||
t.string "record_type", null: false
|
||||
t.bigint "record_id", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["record_type", "record_id", "name"], name: "index_action_text_rich_texts_uniqueness", unique: true
|
||||
end
|
||||
|
||||
create_table 'active_storage_attachments', force: :cascade do |t|
|
||||
t.string 'name', null: false
|
||||
t.string 'record_type', null: false
|
||||
t.bigint 'record_id', null: false
|
||||
t.bigint 'blob_id', null: false
|
||||
t.datetime 'created_at', null: false
|
||||
t.index ['blob_id'], name: 'index_active_storage_attachments_on_blob_id'
|
||||
t.index %w[record_type record_id name blob_id], name: 'index_active_storage_attachments_uniqueness',
|
||||
unique: true
|
||||
create_table "active_storage_attachments", force: :cascade do |t|
|
||||
t.string "name", null: false
|
||||
t.string "record_type", null: false
|
||||
t.bigint "record_id", null: false
|
||||
t.bigint "blob_id", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id"
|
||||
t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true
|
||||
end
|
||||
|
||||
create_table 'active_storage_blobs', force: :cascade do |t|
|
||||
t.string 'key', null: false
|
||||
t.string 'filename', null: false
|
||||
t.string 'content_type'
|
||||
t.text 'metadata'
|
||||
t.string 'service_name', null: false
|
||||
t.bigint 'byte_size', null: false
|
||||
t.string 'checksum'
|
||||
t.datetime 'created_at', null: false
|
||||
t.index ['key'], name: 'index_active_storage_blobs_on_key', unique: true
|
||||
create_table "active_storage_blobs", force: :cascade do |t|
|
||||
t.string "key", null: false
|
||||
t.string "filename", null: false
|
||||
t.string "content_type"
|
||||
t.text "metadata"
|
||||
t.string "service_name", null: false
|
||||
t.bigint "byte_size", null: false
|
||||
t.string "checksum"
|
||||
t.datetime "created_at", null: false
|
||||
t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true
|
||||
end
|
||||
|
||||
create_table 'active_storage_variant_records', force: :cascade do |t|
|
||||
t.bigint 'blob_id', null: false
|
||||
t.string 'variation_digest', null: false
|
||||
t.index %w[blob_id variation_digest], name: 'index_active_storage_variant_records_uniqueness', unique: true
|
||||
create_table "active_storage_variant_records", force: :cascade do |t|
|
||||
t.bigint "blob_id", null: false
|
||||
t.string "variation_digest", null: false
|
||||
t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true
|
||||
end
|
||||
|
||||
create_table 'areas', force: :cascade do |t|
|
||||
t.string 'name', null: false
|
||||
t.bigint 'user_id', null: false
|
||||
t.decimal 'longitude', precision: 10, scale: 6, null: false
|
||||
t.decimal 'latitude', precision: 10, scale: 6, null: false
|
||||
t.integer 'radius', null: false
|
||||
t.datetime 'created_at', null: false
|
||||
t.datetime 'updated_at', null: false
|
||||
t.index ['user_id'], name: 'index_areas_on_user_id'
|
||||
create_table "areas", force: :cascade do |t|
|
||||
t.string "name", null: false
|
||||
t.bigint "user_id", null: false
|
||||
t.decimal "longitude", precision: 10, scale: 6, null: false
|
||||
t.decimal "latitude", precision: 10, scale: 6, null: false
|
||||
t.integer "radius", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["user_id"], name: "index_areas_on_user_id"
|
||||
end
|
||||
|
||||
create_table 'data_migrations', primary_key: 'version', id: :string, force: :cascade do |t|
|
||||
create_table "countries", force: :cascade do |t|
|
||||
t.string "name", null: false
|
||||
t.string "iso_a2", null: false
|
||||
t.string "iso_a3", null: false
|
||||
t.geometry "geom", limit: {srid: 4326, type: "multi_polygon"}
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["geom"], name: "index_countries_on_geom", using: :gist
|
||||
t.index ["iso_a2"], name: "index_countries_on_iso_a2"
|
||||
t.index ["iso_a3"], name: "index_countries_on_iso_a3"
|
||||
t.index ["name"], name: "index_countries_on_name"
|
||||
end
|
||||
|
||||
create_table 'exports', force: :cascade do |t|
|
||||
t.string 'name', null: false
|
||||
t.string 'url'
|
||||
t.integer 'status', default: 0, null: false
|
||||
t.bigint 'user_id', null: false
|
||||
t.datetime 'created_at', null: false
|
||||
t.datetime 'updated_at', null: false
|
||||
t.integer 'file_format', default: 0
|
||||
t.datetime 'start_at'
|
||||
t.datetime 'end_at'
|
||||
t.index ['status'], name: 'index_exports_on_status'
|
||||
t.index ['user_id'], name: 'index_exports_on_user_id'
|
||||
create_table "data_migrations", primary_key: "version", id: :string, force: :cascade do |t|
|
||||
end
|
||||
|
||||
create_table 'imports', force: :cascade do |t|
|
||||
t.string 'name', null: false
|
||||
t.bigint 'user_id', null: false
|
||||
t.integer 'source', default: 0
|
||||
t.datetime 'created_at', null: false
|
||||
t.datetime 'updated_at', null: false
|
||||
t.integer 'raw_points', default: 0
|
||||
t.integer 'doubles', default: 0
|
||||
t.integer 'processed', default: 0
|
||||
t.jsonb 'raw_data'
|
||||
t.integer 'points_count', default: 0
|
||||
t.index ['source'], name: 'index_imports_on_source'
|
||||
t.index ['user_id'], name: 'index_imports_on_user_id'
|
||||
create_table "exports", force: :cascade do |t|
|
||||
t.string "name", null: false
|
||||
t.string "url"
|
||||
t.integer "status", default: 0, null: false
|
||||
t.bigint "user_id", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.integer "file_format", default: 0
|
||||
t.datetime "start_at"
|
||||
t.datetime "end_at"
|
||||
t.index ["status"], name: "index_exports_on_status"
|
||||
t.index ["user_id"], name: "index_exports_on_user_id"
|
||||
end
|
||||
|
||||
create_table 'notifications', force: :cascade do |t|
|
||||
t.string 'title', null: false
|
||||
t.text 'content', null: false
|
||||
t.bigint 'user_id', null: false
|
||||
t.integer 'kind', default: 0, null: false
|
||||
t.datetime 'read_at'
|
||||
t.datetime 'created_at', null: false
|
||||
t.datetime 'updated_at', null: false
|
||||
t.index ['kind'], name: 'index_notifications_on_kind'
|
||||
t.index ['user_id'], name: 'index_notifications_on_user_id'
|
||||
create_table "imports", force: :cascade do |t|
|
||||
t.string "name", null: false
|
||||
t.bigint "user_id", null: false
|
||||
t.integer "source", default: 0
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.integer "raw_points", default: 0
|
||||
t.integer "doubles", default: 0
|
||||
t.integer "processed", default: 0
|
||||
t.jsonb "raw_data"
|
||||
t.integer "points_count", default: 0
|
||||
t.index ["source"], name: "index_imports_on_source"
|
||||
t.index ["user_id"], name: "index_imports_on_user_id"
|
||||
end
|
||||
|
||||
create_table 'place_visits', force: :cascade do |t|
|
||||
t.bigint 'place_id', null: false
|
||||
t.bigint 'visit_id', null: false
|
||||
t.datetime 'created_at', null: false
|
||||
t.datetime 'updated_at', null: false
|
||||
t.index ['place_id'], name: 'index_place_visits_on_place_id'
|
||||
t.index ['visit_id'], name: 'index_place_visits_on_visit_id'
|
||||
create_table "notifications", force: :cascade do |t|
|
||||
t.string "title", null: false
|
||||
t.text "content", null: false
|
||||
t.bigint "user_id", null: false
|
||||
t.integer "kind", default: 0, null: false
|
||||
t.datetime "read_at"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["kind"], name: "index_notifications_on_kind"
|
||||
t.index ["user_id"], name: "index_notifications_on_user_id"
|
||||
end
|
||||
|
||||
create_table 'places', force: :cascade do |t|
|
||||
t.string 'name', null: false
|
||||
t.decimal 'longitude', precision: 10, scale: 6, null: false
|
||||
t.decimal 'latitude', precision: 10, scale: 6, null: false
|
||||
t.string 'city'
|
||||
t.string 'country'
|
||||
t.integer 'source', default: 0
|
||||
t.jsonb 'geodata', default: {}, null: false
|
||||
t.datetime 'reverse_geocoded_at'
|
||||
t.datetime 'created_at', null: false
|
||||
t.datetime 'updated_at', null: false
|
||||
t.geography 'lonlat', limit: { srid: 4326, type: 'st_point', geographic: true }
|
||||
t.index ['lonlat'], name: 'index_places_on_lonlat', using: :gist
|
||||
create_table "place_visits", force: :cascade do |t|
|
||||
t.bigint "place_id", null: false
|
||||
t.bigint "visit_id", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["place_id"], name: "index_place_visits_on_place_id"
|
||||
t.index ["visit_id"], name: "index_place_visits_on_visit_id"
|
||||
end
|
||||
|
||||
create_table 'points', force: :cascade do |t|
|
||||
t.integer 'battery_status'
|
||||
t.string 'ping'
|
||||
t.integer 'battery'
|
||||
t.string 'tracker_id'
|
||||
t.string 'topic'
|
||||
t.integer 'altitude'
|
||||
t.decimal 'longitude', precision: 10, scale: 6
|
||||
t.string 'velocity'
|
||||
t.integer 'trigger'
|
||||
t.string 'bssid'
|
||||
t.string 'ssid'
|
||||
t.integer 'connection'
|
||||
t.integer 'vertical_accuracy'
|
||||
t.integer 'accuracy'
|
||||
t.integer 'timestamp'
|
||||
t.decimal 'latitude', precision: 10, scale: 6
|
||||
t.integer 'mode'
|
||||
t.text 'inrids', default: [], array: true
|
||||
t.text 'in_regions', default: [], array: true
|
||||
t.jsonb 'raw_data', default: {}
|
||||
t.bigint 'import_id'
|
||||
t.string 'city'
|
||||
t.string 'country'
|
||||
t.datetime 'created_at', null: false
|
||||
t.datetime 'updated_at', null: false
|
||||
t.bigint 'user_id'
|
||||
t.jsonb 'geodata', default: {}, null: false
|
||||
t.bigint 'visit_id'
|
||||
t.datetime 'reverse_geocoded_at'
|
||||
t.decimal 'course', precision: 8, scale: 5
|
||||
t.decimal 'course_accuracy', precision: 8, scale: 5
|
||||
t.string 'external_track_id'
|
||||
t.geography 'lonlat', limit: { srid: 4326, type: 'st_point', geographic: true }
|
||||
t.index ['altitude'], name: 'index_points_on_altitude'
|
||||
t.index ['battery'], name: 'index_points_on_battery'
|
||||
t.index ['battery_status'], name: 'index_points_on_battery_status'
|
||||
t.index ['city'], name: 'index_points_on_city'
|
||||
t.index ['connection'], name: 'index_points_on_connection'
|
||||
t.index ['country'], name: 'index_points_on_country'
|
||||
t.index ['external_track_id'], name: 'index_points_on_external_track_id'
|
||||
t.index ['geodata'], name: 'index_points_on_geodata', using: :gin
|
||||
t.index ['import_id'], name: 'index_points_on_import_id'
|
||||
t.index %w[latitude longitude], name: 'index_points_on_latitude_and_longitude'
|
||||
t.index %w[lonlat timestamp user_id], name: 'index_points_on_lonlat_timestamp_user_id', unique: true
|
||||
t.index ['lonlat'], name: 'index_points_on_lonlat', using: :gist
|
||||
t.index ['reverse_geocoded_at'], name: 'index_points_on_reverse_geocoded_at'
|
||||
t.index ['timestamp'], name: 'index_points_on_timestamp'
|
||||
t.index ['trigger'], name: 'index_points_on_trigger'
|
||||
t.index ['user_id'], name: 'index_points_on_user_id'
|
||||
t.index ['visit_id'], name: 'index_points_on_visit_id'
|
||||
create_table "places", force: :cascade do |t|
|
||||
t.string "name", null: false
|
||||
t.decimal "longitude", precision: 10, scale: 6, null: false
|
||||
t.decimal "latitude", precision: 10, scale: 6, null: false
|
||||
t.string "city"
|
||||
t.string "country"
|
||||
t.integer "source", default: 0
|
||||
t.jsonb "geodata", default: {}, null: false
|
||||
t.datetime "reverse_geocoded_at"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.geography "lonlat", limit: {srid: 4326, type: "st_point", geographic: true}
|
||||
t.index ["lonlat"], name: "index_places_on_lonlat", using: :gist
|
||||
end
|
||||
|
||||
create_table 'stats', force: :cascade do |t|
|
||||
t.integer 'year', null: false
|
||||
t.integer 'month', null: false
|
||||
t.integer 'distance', null: false
|
||||
t.jsonb 'toponyms'
|
||||
t.datetime 'created_at', null: false
|
||||
t.datetime 'updated_at', null: false
|
||||
t.bigint 'user_id', null: false
|
||||
t.jsonb 'daily_distance', default: {}
|
||||
t.index ['distance'], name: 'index_stats_on_distance'
|
||||
t.index ['month'], name: 'index_stats_on_month'
|
||||
t.index ['user_id'], name: 'index_stats_on_user_id'
|
||||
t.index ['year'], name: 'index_stats_on_year'
|
||||
create_table "points", force: :cascade do |t|
|
||||
t.integer "battery_status"
|
||||
t.string "ping"
|
||||
t.integer "battery"
|
||||
t.string "tracker_id"
|
||||
t.string "topic"
|
||||
t.integer "altitude"
|
||||
t.decimal "longitude", precision: 10, scale: 6
|
||||
t.string "velocity"
|
||||
t.integer "trigger"
|
||||
t.string "bssid"
|
||||
t.string "ssid"
|
||||
t.integer "connection"
|
||||
t.integer "vertical_accuracy"
|
||||
t.integer "accuracy"
|
||||
t.integer "timestamp"
|
||||
t.decimal "latitude", precision: 10, scale: 6
|
||||
t.integer "mode"
|
||||
t.text "inrids", default: [], array: true
|
||||
t.text "in_regions", default: [], array: true
|
||||
t.jsonb "raw_data", default: {}
|
||||
t.bigint "import_id"
|
||||
t.string "city"
|
||||
t.string "country"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.bigint "user_id"
|
||||
t.jsonb "geodata", default: {}, null: false
|
||||
t.bigint "visit_id"
|
||||
t.datetime "reverse_geocoded_at"
|
||||
t.decimal "course", precision: 8, scale: 5
|
||||
t.decimal "course_accuracy", precision: 8, scale: 5
|
||||
t.string "external_track_id"
|
||||
t.geography "lonlat", limit: {srid: 4326, type: "st_point", geographic: true}
|
||||
t.bigint "country_id"
|
||||
t.index ["altitude"], name: "index_points_on_altitude"
|
||||
t.index ["battery"], name: "index_points_on_battery"
|
||||
t.index ["battery_status"], name: "index_points_on_battery_status"
|
||||
t.index ["city"], name: "index_points_on_city"
|
||||
t.index ["connection"], name: "index_points_on_connection"
|
||||
t.index ["country"], name: "index_points_on_country"
|
||||
t.index ["country_id"], name: "index_points_on_country_id"
|
||||
t.index ["external_track_id"], name: "index_points_on_external_track_id"
|
||||
t.index ["geodata"], name: "index_points_on_geodata", using: :gin
|
||||
t.index ["import_id"], name: "index_points_on_import_id"
|
||||
t.index ["latitude", "longitude"], name: "index_points_on_latitude_and_longitude"
|
||||
t.index ["lonlat", "timestamp", "user_id"], name: "index_points_on_lonlat_timestamp_user_id", unique: true
|
||||
t.index ["lonlat"], name: "index_points_on_lonlat", using: :gist
|
||||
t.index ["reverse_geocoded_at"], name: "index_points_on_reverse_geocoded_at"
|
||||
t.index ["timestamp"], name: "index_points_on_timestamp"
|
||||
t.index ["trigger"], name: "index_points_on_trigger"
|
||||
t.index ["user_id"], name: "index_points_on_user_id"
|
||||
t.index ["visit_id"], name: "index_points_on_visit_id"
|
||||
end
|
||||
|
||||
create_table 'trips', force: :cascade do |t|
|
||||
t.string 'name', null: false
|
||||
t.datetime 'started_at', null: false
|
||||
t.datetime 'ended_at', null: false
|
||||
t.integer 'distance'
|
||||
t.bigint 'user_id', null: false
|
||||
t.datetime 'created_at', null: false
|
||||
t.datetime 'updated_at', null: false
|
||||
t.geometry 'path', limit: { srid: 3857, type: 'line_string' }
|
||||
t.index ['user_id'], name: 'index_trips_on_user_id'
|
||||
create_table "stats", force: :cascade do |t|
|
||||
t.integer "year", null: false
|
||||
t.integer "month", null: false
|
||||
t.integer "distance", null: false
|
||||
t.jsonb "toponyms"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.bigint "user_id", null: false
|
||||
t.jsonb "daily_distance", default: {}
|
||||
t.index ["distance"], name: "index_stats_on_distance"
|
||||
t.index ["month"], name: "index_stats_on_month"
|
||||
t.index ["user_id"], name: "index_stats_on_user_id"
|
||||
t.index ["year"], name: "index_stats_on_year"
|
||||
end
|
||||
|
||||
create_table 'users', force: :cascade do |t|
|
||||
t.string 'email', default: '', null: false
|
||||
t.string 'encrypted_password', default: '', null: false
|
||||
t.string 'reset_password_token'
|
||||
t.datetime 'reset_password_sent_at'
|
||||
t.datetime 'remember_created_at'
|
||||
t.datetime 'created_at', null: false
|
||||
t.datetime 'updated_at', null: false
|
||||
t.string 'api_key', default: '', null: false
|
||||
t.string 'theme', default: 'dark', null: false
|
||||
t.jsonb 'settings',
|
||||
default: { 'fog_of_war_meters' => '100', 'meters_between_routes' => '1000',
|
||||
'minutes_between_routes' => '60' }
|
||||
t.boolean 'admin', default: false
|
||||
t.integer 'sign_in_count', default: 0, null: false
|
||||
t.datetime 'current_sign_in_at'
|
||||
t.datetime 'last_sign_in_at'
|
||||
t.string 'current_sign_in_ip'
|
||||
t.string 'last_sign_in_ip'
|
||||
t.integer 'status', default: 0
|
||||
t.datetime 'active_until'
|
||||
t.index ['email'], name: 'index_users_on_email', unique: true
|
||||
t.index ['reset_password_token'], name: 'index_users_on_reset_password_token', unique: true
|
||||
create_table "trips", force: :cascade do |t|
|
||||
t.string "name", null: false
|
||||
t.datetime "started_at", null: false
|
||||
t.datetime "ended_at", null: false
|
||||
t.integer "distance"
|
||||
t.bigint "user_id", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.geometry "path", limit: {srid: 3857, type: "line_string"}
|
||||
t.jsonb "visited_countries", default: {}, null: false
|
||||
t.index ["user_id"], name: "index_trips_on_user_id"
|
||||
end
|
||||
|
||||
add_check_constraint 'users', 'admin IS NOT NULL', name: 'users_admin_null', validate: false
|
||||
|
||||
create_table 'visits', force: :cascade do |t|
|
||||
t.bigint 'area_id'
|
||||
t.bigint 'user_id', null: false
|
||||
t.datetime 'started_at', null: false
|
||||
t.datetime 'ended_at', null: false
|
||||
t.integer 'duration', null: false
|
||||
t.string 'name', null: false
|
||||
t.integer 'status', default: 0, null: false
|
||||
t.datetime 'created_at', null: false
|
||||
t.datetime 'updated_at', null: false
|
||||
t.bigint 'place_id'
|
||||
t.index ['area_id'], name: 'index_visits_on_area_id'
|
||||
t.index ['place_id'], name: 'index_visits_on_place_id'
|
||||
t.index ['started_at'], name: 'index_visits_on_started_at'
|
||||
t.index ['user_id'], name: 'index_visits_on_user_id'
|
||||
create_table "users", force: :cascade do |t|
|
||||
t.string "email", default: "", null: false
|
||||
t.string "encrypted_password", default: "", null: false
|
||||
t.string "reset_password_token"
|
||||
t.datetime "reset_password_sent_at"
|
||||
t.datetime "remember_created_at"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.string "api_key", default: "", null: false
|
||||
t.string "theme", default: "dark", null: false
|
||||
t.jsonb "settings", default: {"fog_of_war_meters" => "100", "meters_between_routes" => "1000", "minutes_between_routes" => "60"}
|
||||
t.boolean "admin", default: false
|
||||
t.integer "sign_in_count", default: 0, null: false
|
||||
t.datetime "current_sign_in_at"
|
||||
t.datetime "last_sign_in_at"
|
||||
t.string "current_sign_in_ip"
|
||||
t.string "last_sign_in_ip"
|
||||
t.integer "status", default: 0
|
||||
t.datetime "active_until"
|
||||
t.index ["email"], name: "index_users_on_email", unique: true
|
||||
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
|
||||
end
|
||||
|
||||
add_foreign_key 'active_storage_attachments', 'active_storage_blobs', column: 'blob_id'
|
||||
add_foreign_key 'active_storage_variant_records', 'active_storage_blobs', column: 'blob_id'
|
||||
add_foreign_key 'areas', 'users'
|
||||
add_foreign_key 'notifications', 'users'
|
||||
add_foreign_key 'place_visits', 'places'
|
||||
add_foreign_key 'place_visits', 'visits'
|
||||
add_foreign_key 'points', 'users'
|
||||
add_foreign_key 'points', 'visits'
|
||||
add_foreign_key 'stats', 'users'
|
||||
add_foreign_key 'trips', 'users'
|
||||
add_foreign_key 'visits', 'areas'
|
||||
add_foreign_key 'visits', 'places'
|
||||
add_foreign_key 'visits', 'users'
|
||||
add_check_constraint "users", "admin IS NOT NULL", name: "users_admin_null", validate: false
|
||||
|
||||
create_table "visits", force: :cascade do |t|
|
||||
t.bigint "area_id"
|
||||
t.bigint "user_id", null: false
|
||||
t.datetime "started_at", null: false
|
||||
t.datetime "ended_at", null: false
|
||||
t.integer "duration", null: false
|
||||
t.string "name", null: false
|
||||
t.integer "status", default: 0, null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.bigint "place_id"
|
||||
t.index ["area_id"], name: "index_visits_on_area_id"
|
||||
t.index ["place_id"], name: "index_visits_on_place_id"
|
||||
t.index ["started_at"], name: "index_visits_on_started_at"
|
||||
t.index ["user_id"], name: "index_visits_on_user_id"
|
||||
end
|
||||
|
||||
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
|
||||
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
|
||||
add_foreign_key "areas", "users"
|
||||
add_foreign_key "notifications", "users"
|
||||
add_foreign_key "place_visits", "places"
|
||||
add_foreign_key "place_visits", "visits"
|
||||
add_foreign_key "points", "users"
|
||||
add_foreign_key "points", "visits"
|
||||
add_foreign_key "stats", "users"
|
||||
add_foreign_key "trips", "users"
|
||||
add_foreign_key "visits", "areas"
|
||||
add_foreign_key "visits", "places"
|
||||
add_foreign_key "visits", "users"
|
||||
end
|
||||
|
|
|
|||
42
db/seeds.rb
42
db/seeds.rb
|
|
@ -1,14 +1,38 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
return if User.any?
|
||||
if User.none?
|
||||
puts 'Creating user...'
|
||||
|
||||
puts 'Creating user...'
|
||||
User.create!(
|
||||
email: 'demo@dawarich.app',
|
||||
password: 'password',
|
||||
password_confirmation: 'password',
|
||||
admin: true,
|
||||
active: true,
|
||||
active_until: 100.years.from_now
|
||||
)
|
||||
|
||||
User.create!(
|
||||
email: 'demo@dawarich.app',
|
||||
password: 'password',
|
||||
password_confirmation: 'password',
|
||||
admin: true
|
||||
)
|
||||
puts "User created: #{User.first.email} / password: 'password'"
|
||||
end
|
||||
|
||||
puts "User created: #{User.first.email} / password: 'password'"
|
||||
if Country.none?
|
||||
puts 'Creating countries...'
|
||||
|
||||
countries_json = Oj.load(File.read(Rails.root.join('lib/assets/countries.geojson')))
|
||||
|
||||
factory = RGeo::Geos.factory(srid: 4326)
|
||||
countries_multi_polygon = RGeo::GeoJSON.decode(countries_json.to_json, geo_factory: factory)
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
countries_multi_polygon.each do |country, index|
|
||||
p "Creating #{country.properties['name']}..."
|
||||
|
||||
Country.create!(
|
||||
name: country.properties['name'],
|
||||
iso_a2: country.properties['ISO3166-1-Alpha-2'],
|
||||
iso_a3: country.properties['ISO3166-1-Alpha-3'],
|
||||
geom: country.geometry
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ RUN apk -U add --no-cache \
|
|||
less \
|
||||
yaml-dev \
|
||||
gcompat \
|
||||
geos \
|
||||
&& mkdir -p $APP_PATH
|
||||
|
||||
# Update gem system and install bundler
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue