diff --git a/.app_version b/.app_version index 2094a100..48b91fd8 100644 --- a/.app_version +++ b/.app_version @@ -1 +1 @@ -0.24.0 +0.24.1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 372d12b5..0aab7b37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,38 @@ 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.24.0 - 2025-02-09 +# 0.24.1 - 2025-02-13 + +## Custom map tiles + +In the user settings, you can now set a custom tile URL for the map. This is useful if you want to use a custom map tile provider or if you want to use a map tile provider that is not listed in the dropdown. + +To set a custom tile URL, go to the user settings and set the `Maps` section to your liking. Be mindful that currently, only raster tiles are supported. The URL should be a valid tile URL, like `https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png`. You, as the user, are responsible for any extra costs that may occur due to using a custom tile URL. + +### Added + +- Safe settings for user with default values. +- Nominatim API is now supported as a reverse geocoding provider. +- In the user settings, you can now set a custom tile URL for the map. #429 #715 +- In the user map settings, you can now see a chart of map tiles usage. +- If you have Prometheus exporter enabled, you can now see a `ruby_dawarich_map_tiles` metric in Prometheus, which shows the total number of map tiles loaded. Example: + +``` +# HELP ruby_dawarich_map_tiles_usage +# TYPE ruby_dawarich_map_tiles_usage counter +ruby_dawarich_map_tiles_usage 99 +``` + +### Fixed + +- Speed on the Points page is now being displayed in kilometers per hour. #700 +- Fog of war displacement #774 + +### Reverted + +- #748 + +# 0.24.0 - 2025-02-10 ## Points speed units diff --git a/Procfile.prometheus.dev b/Procfile.prometheus.dev index 71fe0374..95a12639 100644 --- a/Procfile.prometheus.dev +++ b/Procfile.prometheus.dev @@ -1,2 +1,2 @@ prometheus_exporter: bundle exec prometheus_exporter -b ANY -web: bin/rails server -p 3000 -b :: \ No newline at end of file +web: bin/rails server -p 3000 -b :: diff --git a/README.md b/README.md index 0d21ed03..5aa76a1b 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ Donate using crypto: [0x6bAd13667692632f1bF926cA9B421bEe7EaEB8D4](https://ethers - Explore statistics like the number of countries and cities visited, total distance traveled, and more! 📄 **Changelog**: Find the latest updates [here](CHANGELOG.md). + 👩💻 **Contribute**: See [CONTRIBUTING.md](CONTRIBUTING.md) for how to contribute to Dawarich. --- diff --git a/app/controllers/api/v1/maps/tile_usage_controller.rb b/app/controllers/api/v1/maps/tile_usage_controller.rb new file mode 100644 index 00000000..c22778e7 --- /dev/null +++ b/app/controllers/api/v1/maps/tile_usage_controller.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class Api::V1::Maps::TileUsageController < ApiController + def create + Maps::TileUsage::Track.new(current_api_user.id, tile_usage_params[:count].to_i).call + + head :ok + end + + private + + def tile_usage_params + params.require(:tile_usage).permit(:count) + end +end diff --git a/app/controllers/settings/maps_controller.rb b/app/controllers/settings/maps_controller.rb new file mode 100644 index 00000000..58e2fef6 --- /dev/null +++ b/app/controllers/settings/maps_controller.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +class Settings::MapsController < ApplicationController + before_action :authenticate_user! + + def index + @maps = current_user.safe_settings.maps + + @tile_usage = 7.days.ago.to_date.upto(Time.zone.today).map do |date| + [ + date.to_s, + Rails.cache.read("dawarich_map_tiles_usage:#{current_user.id}:#{date}") || 0 + ] + end + end + + def update + current_user.settings['maps'] = settings_params + current_user.save! + + redirect_to settings_maps_path, notice: 'Settings updated' + end + + private + + def settings_params + params.require(:maps).permit(:name, :url) + end +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index c6258d08..a4a01a5e 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -120,4 +120,10 @@ module ApplicationHelper 'text-red-500' end + + def point_speed(speed) + return speed if speed.to_i <= 0 + + speed * 3.6 + end end diff --git a/app/javascript/controllers/map_preview_controller.js b/app/javascript/controllers/map_preview_controller.js new file mode 100644 index 00000000..3b610a33 --- /dev/null +++ b/app/javascript/controllers/map_preview_controller.js @@ -0,0 +1,67 @@ +import { Controller } from "@hotwired/stimulus" +import L from "leaflet" +import { showFlashMessage } from "../maps/helpers" + +export default class extends Controller { + static targets = ["urlInput", "mapContainer", "saveButton"] + + DEFAULT_TILE_URL = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' + + connect() { + console.log("Controller connected!") + // Wait for the next frame to ensure the DOM is ready + requestAnimationFrame(() => { + // Force container height + this.mapContainerTarget.style.height = '500px' + this.initializeMap() + }) + } + + initializeMap() { + console.log("Initializing map...") + if (!this.map) { + this.map = L.map(this.mapContainerTarget).setView([51.505, -0.09], 13) + // Invalidate size after initialization + setTimeout(() => { + this.map.invalidateSize() + }, 0) + this.updatePreview() + } + } + + updatePreview() { + console.log("Updating preview...") + const url = this.urlInputTarget.value || this.DEFAULT_TILE_URL + + // Only animate if save button target exists + if (this.hasSaveButtonTarget) { + this.saveButtonTarget.classList.add('btn-animate') + setTimeout(() => { + this.saveButtonTarget.classList.remove('btn-animate') + }, 1000) + } + + if (this.currentLayer) { + this.map.removeLayer(this.currentLayer) + } + + try { + this.currentLayer = L.tileLayer(url, { + maxZoom: 19, + attribution: '© OpenStreetMap contributors' + }).addTo(this.map) + } catch (e) { + console.error('Invalid tile URL:', e) + showFlashMessage('error', 'Invalid tile URL. Reverting to OpenStreetMap.') + + // Reset input to default OSM URL + this.urlInputTarget.value = this.DEFAULT_TILE_URL + + // Create default layer + this.currentLayer = L.tileLayer(this.DEFAULT_TILE_URL, { + maxZoom: 19, + attribution: '© OpenStreetMap contributors' + }).addTo(this.map) + } + } +} diff --git a/app/javascript/controllers/maps_controller.js b/app/javascript/controllers/maps_controller.js index 2e921c86..d2f59dbb 100644 --- a/app/javascript/controllers/maps_controller.js +++ b/app/javascript/controllers/maps_controller.js @@ -8,9 +8,7 @@ import { createMarkersArray } from "../maps/markers"; import { createPolylinesLayer, updatePolylinesOpacity, - updatePolylinesColors, - calculateSpeed, - getSpeedColor + updatePolylinesColors } from "../maps/polylines"; import { fetchAndDrawAreas, handleAreaCreated } from "../maps/areas"; @@ -32,6 +30,7 @@ import { countryCodesMap } from "../maps/country_codes"; import "leaflet-draw"; import { initializeFogCanvas, drawFogCanvas, createFogOverlay } from "../maps/fog_of_war"; +import { TileMonitor } from "../maps/tile_monitor"; export default class extends Controller { static targets = ["container"]; @@ -245,6 +244,19 @@ export default class extends Controller { if (this.liveMapEnabled) { this.setupSubscription(); } + + // Initialize tile monitor + this.tileMonitor = new TileMonitor(this.apiKey); + + // Add tile load event handlers to each base layer + Object.entries(this.baseMaps()).forEach(([name, layer]) => { + layer.on('tileload', () => { + this.tileMonitor.recordTileLoad(name); + }); + }); + + // Start monitoring + this.tileMonitor.startMonitoring(); } disconnect() { @@ -260,6 +272,11 @@ export default class extends Controller { if (this.map) { this.map.remove(); } + + // Stop tile monitoring + if (this.tileMonitor) { + this.tileMonitor.stopMonitoring(); + } } setupSubscription() { @@ -385,8 +402,7 @@ export default class extends Controller { baseMaps() { let selectedLayerName = this.userSettings.preferred_map_layer || "OpenStreetMap"; - - return { + let maps = { OpenStreetMap: osmMapLayer(this.map, selectedLayerName), "OpenStreetMap.HOT": osmHotMapLayer(this.map, selectedLayerName), OPNV: OPNVMapLayer(this.map, selectedLayerName), @@ -397,6 +413,33 @@ export default class extends Controller { esriWorldImagery: esriWorldImageryMapLayer(this.map, selectedLayerName), esriWorldGrayCanvas: esriWorldGrayCanvasMapLayer(this.map, selectedLayerName) }; + + // Add custom map if it exists in settings + if (this.userSettings.maps && this.userSettings.maps.url) { + const customLayer = L.tileLayer(this.userSettings.maps.url, { + maxZoom: 19, + attribution: "© OpenStreetMap contributors" + }); + + // If this is the preferred layer, add it to the map immediately + if (selectedLayerName === this.userSettings.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.userSettings.maps.name] = customLayer; + } else { + // If no custom map is set, ensure a default layer is added + const defaultLayer = maps[selectedLayerName] || maps["OpenStreetMap"]; + defaultLayer.addTo(this.map); + } + + return maps; } removeEventListeners() { diff --git a/app/javascript/maps/fog_of_war.js b/app/javascript/maps/fog_of_war.js index 8e910274..bc3acbb1 100644 --- a/app/javascript/maps/fog_of_war.js +++ b/app/javascript/maps/fog_of_war.js @@ -85,15 +85,12 @@ export function createFogOverlay() { onAdd: (map) => { initializeFogCanvas(map); - // Add drag event handlers to update fog during marker movement - map.on('drag', () => { - const fog = document.getElementById('fog'); - if (fog) { - // Update fog canvas position to match map position - const mapPos = map.getContainer().getBoundingClientRect(); - fog.style.left = `${mapPos.left}px`; - fog.style.top = `${mapPos.top}px`; - } + // Add resize event handlers to update fog size + map.on('resize', () => { + // Set canvas size to match map container + const mapSize = map.getSize(); + fog.width = mapSize.x; + fog.height = mapSize.y; }); }, onRemove: (map) => { @@ -102,7 +99,7 @@ export function createFogOverlay() { fog.remove(); } // Clean up event listener - map.off('drag'); + map.off('resize'); } }); } diff --git a/app/javascript/maps/tile_monitor.js b/app/javascript/maps/tile_monitor.js new file mode 100644 index 00000000..0a1edc60 --- /dev/null +++ b/app/javascript/maps/tile_monitor.js @@ -0,0 +1,63 @@ +export class TileMonitor { + constructor(apiKey) { + this.apiKey = apiKey; + this.tileQueue = 0; + this.tileUpdateInterval = null; + } + + startMonitoring() { + // Clear any existing interval + if (this.tileUpdateInterval) { + clearInterval(this.tileUpdateInterval); + } + + // Set up a regular interval to send stats + this.tileUpdateInterval = setInterval(() => { + this.sendTileUsage(); + }, 5000); // Exactly every 5 seconds + } + + stopMonitoring() { + if (this.tileUpdateInterval) { + clearInterval(this.tileUpdateInterval); + this.sendTileUsage(); // Send any remaining stats + } + } + + recordTileLoad() { + this.tileQueue += 1; + } + + sendTileUsage() { + if (this.tileQueue === 0) return; + + const currentCount = this.tileQueue; + console.log('Sending tile usage batch:', currentCount); + + fetch('/api/v1/maps/tile_usage', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.apiKey}` + }, + body: JSON.stringify({ + tile_usage: { + count: currentCount + } + }) + }) + .then(response => { + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + // Only subtract sent count if it hasn't changed + if (this.tileQueue === currentCount) { + this.tileQueue = 0; + } else { + this.tileQueue -= currentCount; + } + console.log('Tile usage batch sent successfully'); + }) + .catch(error => console.error('Error recording tile usage:', error)); + } +} diff --git a/app/models/user.rb b/app/models/user.rb index b8d27f17..97fb9fe0 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -16,13 +16,18 @@ class User < ApplicationRecord has_many :trips, dependent: :destroy after_create :create_api_key - before_save :strip_trailing_slashes + before_save :sanitize_input validates :email, presence: true + validates :reset_password_token, uniqueness: true, allow_nil: true attribute :admin, :boolean, default: false + def safe_settings + Users::SafeSettings.new(settings) + end + def countries_visited stats.pluck(:toponyms).flatten.map { _1['country'] }.uniq.compact end @@ -99,8 +104,9 @@ class User < ApplicationRecord save end - def strip_trailing_slashes + def sanitize_input settings['immich_url']&.gsub!(%r{/+\z}, '') settings['photoprism_url']&.gsub!(%r{/+\z}, '') + settings.try(:[], 'maps')&.try(:[], 'url')&.strip! end end diff --git a/app/services/areas/visits/create.rb b/app/services/areas/visits/create.rb index f58bf4b7..768f5f9f 100644 --- a/app/services/areas/visits/create.rb +++ b/app/services/areas/visits/create.rb @@ -6,8 +6,8 @@ class Areas::Visits::Create def initialize(user, areas) @user = user @areas = areas - @time_threshold_minutes = 30 || user.settings['time_threshold_minutes'] - @merge_threshold_minutes = 15 || user.settings['merge_threshold_minutes'] + @time_threshold_minutes = 30 || user.safe_settings.time_threshold_minutes + @merge_threshold_minutes = 15 || user.safe_settings.merge_threshold_minutes end def call diff --git a/app/services/immich/request_photos.rb b/app/services/immich/request_photos.rb index 0d3f6e1f..0dfcbcd5 100644 --- a/app/services/immich/request_photos.rb +++ b/app/services/immich/request_photos.rb @@ -5,15 +5,15 @@ class Immich::RequestPhotos def initialize(user, start_date: '1970-01-01', end_date: nil) @user = user - @immich_api_base_url = URI.parse("#{user.settings['immich_url']}/api/search/metadata") - @immich_api_key = user.settings['immich_api_key'] + @immich_api_base_url = URI.parse("#{user.safe_settings.immich_url}/api/search/metadata") + @immich_api_key = user.safe_settings.immich_api_key @start_date = start_date @end_date = end_date end def call raise ArgumentError, 'Immich API key is missing' if immich_api_key.blank? - raise ArgumentError, 'Immich URL is missing' if user.settings['immich_url'].blank? + raise ArgumentError, 'Immich URL is missing' if user.safe_settings.immich_url.blank? data = retrieve_immich_data diff --git a/app/services/maps/tile_usage/track.rb b/app/services/maps/tile_usage/track.rb new file mode 100644 index 00000000..a2ec819d --- /dev/null +++ b/app/services/maps/tile_usage/track.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +class Maps::TileUsage::Track + def initialize(user_id, count = 1) + @user_id = user_id + @count = count + end + + def call + report_to_prometheus + report_to_cache + rescue StandardError => e + Rails.logger.error("Failed to send tile usage metric: #{e.message}") + end + + private + + def report_to_prometheus + return unless DawarichSettings.prometheus_exporter_enabled? + + metric_data = { + type: 'counter', + name: 'dawarich_map_tiles_usage', + value: @count + } + + PrometheusExporter::Client.default.send_json(metric_data) + end + + def report_to_cache + today_key = "dawarich_map_tiles_usage:#{@user_id}:#{Time.zone.today}" + + current_value = (Rails.cache.read(today_key) || 0).to_i + Rails.cache.write(today_key, current_value + @count, expires_in: 7.days) + end +end diff --git a/app/services/photoprism/request_photos.rb b/app/services/photoprism/request_photos.rb index 276e7e5c..0f7fd93b 100644 --- a/app/services/photoprism/request_photos.rb +++ b/app/services/photoprism/request_photos.rb @@ -9,14 +9,14 @@ class Photoprism::RequestPhotos def initialize(user, start_date: '1970-01-01', end_date: nil) @user = user - @photoprism_api_base_url = URI.parse("#{user.settings['photoprism_url']}/api/v1/photos") - @photoprism_api_key = user.settings['photoprism_api_key'] + @photoprism_api_base_url = URI.parse("#{user.safe_settings.photoprism_url}/api/v1/photos") + @photoprism_api_key = user.safe_settings.photoprism_api_key @start_date = start_date @end_date = end_date end def call - raise ArgumentError, 'Photoprism URL is missing' if user.settings['photoprism_url'].blank? + raise ArgumentError, 'Photoprism URL is missing' if user.safe_settings.photoprism_url.blank? raise ArgumentError, 'Photoprism API key is missing' if photoprism_api_key.blank? data = retrieve_photoprism_data diff --git a/app/services/photos/thumbnail.rb b/app/services/photos/thumbnail.rb index 6bdb7fd5..4f927aad 100644 --- a/app/services/photos/thumbnail.rb +++ b/app/services/photos/thumbnail.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class Photos::Thumbnail + SUPPORTED_SOURCES = %w[immich photoprism].freeze + def initialize(user, source, id) @user = user @source = source @@ -8,6 +10,8 @@ class Photos::Thumbnail end def call + raise unsupported_source_error unless SUPPORTED_SOURCES.include?(source) + HTTParty.get(request_url, headers: headers) end @@ -16,11 +20,11 @@ class Photos::Thumbnail attr_reader :user, :source, :id def source_url - user.settings["#{source}_url"] + user.safe_settings.public_send("#{source}_url") end def source_api_key - user.settings["#{source}_api_key"] + user.safe_settings.public_send("#{source}_api_key") end def source_path @@ -30,8 +34,6 @@ class Photos::Thumbnail when 'photoprism' preview_token = Rails.cache.read("#{Photoprism::CachePreviewToken::TOKEN_CACHE_KEY}_#{user.id}") "/api/v1/t/#{id}/#{preview_token}/tile_500" - else - raise "Unsupported source: #{source}" end end @@ -48,4 +50,8 @@ class Photos::Thumbnail request_headers end + + def unsupported_source_error + raise ArgumentError, "Unsupported source: #{source}" + end end diff --git a/app/services/reverse_geocoding/places/fetch_data.rb b/app/services/reverse_geocoding/places/fetch_data.rb index 9eec9de4..9b691d36 100644 --- a/app/services/reverse_geocoding/places/fetch_data.rb +++ b/app/services/reverse_geocoding/places/fetch_data.rb @@ -96,7 +96,13 @@ class ReverseGeocoding::Places::FetchData end def reverse_geocoded_places - data = Geocoder.search([place.latitude, place.longitude], limit: 10, distance_sort: true) + data = Geocoder.search( + [place.latitude, place.longitude], + limit: 10, + distance_sort: true, + radius: 1, + units: DISTANCE_UNITS + ) data.reject do |place| place.data['properties']['osm_value'].in?(IGNORED_OSM_VALUES) || diff --git a/app/services/users/safe_settings.rb b/app/services/users/safe_settings.rb new file mode 100644 index 00000000..dab0a516 --- /dev/null +++ b/app/services/users/safe_settings.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +class Users::SafeSettings + attr_reader :settings + + def initialize(settings) + @settings = settings + end + + # rubocop:disable Metrics/MethodLength + def config + { + fog_of_war_meters: fog_of_war_meters, + meters_between_routes: meters_between_routes, + preferred_map_layer: preferred_map_layer, + speed_colored_routes: speed_colored_routes, + points_rendering_mode: points_rendering_mode, + minutes_between_routes: minutes_between_routes, + time_threshold_minutes: time_threshold_minutes, + merge_threshold_minutes: merge_threshold_minutes, + live_map_enabled: live_map_enabled, + route_opacity: route_opacity, + immich_url: immich_url, + immich_api_key: immich_api_key, + photoprism_url: photoprism_url, + photoprism_api_key: photoprism_api_key, + maps: maps + } + end + # rubocop:enable Metrics/MethodLength + + def fog_of_war_meters + settings['fog_of_war_meters'] || 50 + end + + def meters_between_routes + settings['meters_between_routes'] || 500 + end + + def preferred_map_layer + settings['preferred_map_layer'] || 'OpenStreetMap' + end + + def speed_colored_routes + settings['speed_colored_routes'] || false + end + + def points_rendering_mode + settings['points_rendering_mode'] || 'raw' + end + + def minutes_between_routes + settings['minutes_between_routes'] || 30 + end + + def time_threshold_minutes + settings['time_threshold_minutes'] || 30 + end + + def merge_threshold_minutes + settings['merge_threshold_minutes'] || 15 + end + + def live_map_enabled + return settings['live_map_enabled'] if settings.key?('live_map_enabled') + + true + end + + def route_opacity + settings['route_opacity'] || 0.6 + end + + def immich_url + settings['immich_url'] + end + + def immich_api_key + settings['immich_api_key'] + end + + def photoprism_url + settings['photoprism_url'] + end + + def photoprism_api_key + settings['photoprism_api_key'] + end + + def maps + settings['maps'] || {} + end +end diff --git a/app/views/imports/index.html.erb b/app/views/imports/index.html.erb index d2ee8d30..97f82a38 100644 --- a/app/views/imports/index.html.erb +++ b/app/views/imports/index.html.erb @@ -5,12 +5,12 @@