mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-11 17:51:39 -05:00
Compare commits
36 commits
master
...
0.36.2-rc.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f02ff86554 | ||
|
|
f8e3c10f24 | ||
|
|
fea34535f7 | ||
|
|
3662d4f4b3 | ||
|
|
39307e1bb3 | ||
|
|
756cda06f6 | ||
|
|
d13b86a177 | ||
|
|
af139f988d | ||
|
|
3f847ede4f | ||
|
|
dd479c17f2 | ||
|
|
36846cc96c | ||
|
|
bcf76c65e7 | ||
|
|
8d0fb2867e | ||
|
|
dad5fa9c4f | ||
|
|
cebbc28912 | ||
|
|
236da955d4 | ||
|
|
d612c82675 | ||
|
|
ac9b668c30 | ||
|
|
51a212d1fd | ||
|
|
987f0cb4a2 | ||
|
|
541488e6ce | ||
|
|
529eee775a | ||
|
|
1955ef371c | ||
|
|
e8392ee4f7 | ||
|
|
68410a1f2d | ||
|
|
6772f2f7b7 | ||
|
|
47dcaaf514 | ||
|
|
97179f809c | ||
|
|
0ac5e2ffed | ||
|
|
f49b6d4434 | ||
|
|
5bb3e7b099 | ||
|
|
b2802c9d6a | ||
|
|
3ffc563b35 | ||
|
|
ac6898e311 | ||
|
|
0ca4cb2008 | ||
|
|
ec54d202ff |
284 changed files with 1355 additions and 15581 deletions
|
|
@ -1 +1 @@
|
|||
0.37.2
|
||||
0.36.1
|
||||
|
|
|
|||
26
AGENTS.md
26
AGENTS.md
|
|
@ -1,26 +0,0 @@
|
|||
# Repository Guidelines
|
||||
|
||||
## Project Structure & Module Organization
|
||||
Dawarich is a Rails 8 monolith. Controllers, models, jobs, services, policies, and Stimulus/Turbo JS live in `app/`, while shared POROs sit in `lib/`. Configuration, credentials, and cron/Sidekiq settings live in `config/`; API documentation assets are in `swagger/`. Database migrations and seeds live in `db/`, Docker tooling sits in `docker/`, and docs or media live in `docs/` and `screenshots/`. Runtime artifacts in `storage/`, `tmp/`, and `log/` stay untracked.
|
||||
|
||||
## Architecture & Key Services
|
||||
The stack pairs Rails 8 with PostgreSQL + PostGIS, Redis-backed Sidekiq, Devise/Pundit, Tailwind + DaisyUI, and Leaflet/Chartkick. Imports, exports, sharing, and trip analytics lean on PostGIS geometries plus workers, so queue anything non-trivial instead of blocking requests.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
- `docker compose -f docker/docker-compose.yml up` — launches the full stack for smoke tests.
|
||||
- `bundle exec rails db:prepare` — create/migrate the PostGIS database.
|
||||
- `bundle exec bin/dev` and `bundle exec sidekiq` — start the web/Vite/Tailwind stack and workers locally.
|
||||
- `make test` — runs Playwright (`npx playwright test e2e --workers=1`) then `bundle exec rspec`.
|
||||
- `bundle exec rubocop` / `npx prettier --check app/javascript` — enforce formatting before commits.
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
Use two-space indentation, snake_case filenames, and CamelCase classes. Keep Stimulus controllers under `app/javascript/controllers/*_controller.ts` so names match DOM `data-controller` hooks. Prefer service objects in `app/services/` for multi-step imports/exports, and let migrations named like `202405061210_add_indexes_to_events` manage schema changes. Follow Tailwind ordering conventions and avoid bespoke CSS unless necessary.
|
||||
|
||||
## Testing Guidelines
|
||||
RSpec mirrors the app hierarchy inside `spec/` with files suffixed `_spec.rb`; rely on FactoryBot/FFaker for data, WebMock for HTTP, and SimpleCov for coverage. Browser journeys live in `e2e/` and should use `data-testid` selectors plus seeded demo data to reset state. Run `make test` before pushing and document intentional gaps when coverage dips.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
Write short, imperative commit subjects (`Add globe_projection setting`) and include the PR/issue reference like `(#2138)` when relevant. Target `dev`, describe migrations, configs, and verification steps, and attach screenshots or curl examples for UI/API work. Link related Discussions for larger changes and request review from domain owners (imports, sharing, trips, etc.).
|
||||
|
||||
## Security & Configuration Tips
|
||||
Start from `.env.example` or `.env.template` and store secrets in encrypted Rails credentials; never commit files from `gps-env/` or real trace data. Rotate API keys, scrub sensitive coordinates in fixtures, and use the synthetic traces in `db/seeds.rb` when demonstrating imports.
|
||||
106
CHANGELOG.md
106
CHANGELOG.md
|
|
@ -4,109 +4,21 @@ 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.37.3] - Unreleased
|
||||
|
||||
# [0.36.2] - Unreleased
|
||||
|
||||
|
||||
## Fixed
|
||||
|
||||
- Routes are now being drawn the very same way on Map V2 as in Map V1. #2132 #2086
|
||||
- RailsPulse performance monitoring is now disabled for self-hosted instances. It fixes poor performance on Synology. #2139
|
||||
|
||||
## Changed
|
||||
|
||||
- Map V2 points loading is significantly sped up.
|
||||
- Points size on Map V2 was reduced to prevent overlapping.
|
||||
- Points sent from Owntracks and Overland are now being created synchronously to instantly reflect success or failure of point creation.
|
||||
|
||||
# [0.37.2] - 2026-01-04
|
||||
|
||||
## Fixed
|
||||
|
||||
- Months are now correctly ordered (Jan-Dec) in the year-end digest chart instead of being sorted alphabetically.
|
||||
- Time spent in a country and city is now calculated correctly for the year-end digest email. #2104
|
||||
- Updated Trix to fix a XSS vulnerability. #2102
|
||||
- Map v2 UI no longer blocks when Immich/Photoprism integration has a bad URL or is unreachable. Added 10-second timeout to photo API requests and improved error handling to prevent UI freezing during initial load. #2085
|
||||
|
||||
## Added
|
||||
- In Map v2 settings, you can now enable map to be rendered as a globe.
|
||||
|
||||
# [0.37.1] - 2025-12-30
|
||||
|
||||
## Fixed
|
||||
|
||||
- The db migration preventing the app from starting.
|
||||
- Raw data archive verifier now allows having points deleted from the db after archiving.
|
||||
|
||||
# [0.37.0] - 2025-12-30
|
||||
|
||||
## Added
|
||||
|
||||
- In the beginning of the year users will receive a year-end digest email with stats about their tracking activity during the past year. Users can opt out of receiving these emails in User Settings -> Notifications. Emails won't be sent if no email is configured in the SMTP settings or if user has no points tracked during the year.
|
||||
|
||||
## Changed
|
||||
|
||||
- Added and removed some indexes to improve the app performance based on the production usage data.
|
||||
|
||||
## Changed
|
||||
|
||||
- Deleting an import will now be processed in the background to prevent request timeouts for large imports.
|
||||
|
||||
## Fixed
|
||||
|
||||
- Deleting an import will no longer result in negative points count for the user.
|
||||
- Updating stats. #2022
|
||||
- Validate trip start date to be earlier than end date. #2057
|
||||
- Fog of war radius slider in map v2 settings is now being respected correctly. #2041
|
||||
- Applying changes in map v2 settings now works correctly. #2041
|
||||
- Invalidate stats cache on recalculation and other operations that change stats data.
|
||||
|
||||
|
||||
# [0.36.4] - 2025-12-26
|
||||
|
||||
## Fixed
|
||||
|
||||
- Fixed a bug preventing the app to start if a composite index on stats table already exists. #2034 #2051 #2046
|
||||
- New compiled assets will override old ones on app start to prevent serving stale assets.
|
||||
- Number of points in stats should no longer go negative when points are deleted. #2054
|
||||
- Disable Family::Invitations::CleanupJob no invitations are in the database. #2043
|
||||
- User can now enable family layer in Maps v2 and center on family members by clicking their emails. #2036
|
||||
|
||||
|
||||
# [0.36.3] - 2025-12-14
|
||||
|
||||
## Added
|
||||
|
||||
- Setting `ARCHIVE_RAW_DATA` env var to true will enable monthly raw data archiving for all users. It will look for points older than 2 months with `raw_data` column not empty and create a zip archive containing raw data files for each month. After successful archiving, raw data will be removed from the database to save space. Monthly archiving job is being run every day at 2:00 AM. Default env var value is false.
|
||||
- In map v2, user can now move points when Points layer is enabled. #2024
|
||||
- In map v2, routes are now being rendered using same logic as in map v1, route-length-wise. #2026
|
||||
|
||||
## Fixed
|
||||
|
||||
- Cities visited during a trip are now being calculated correctly. #547 #641 #1686 #1976
|
||||
- Points on the map are now show time in user's timezone. #580 #1035 #1682
|
||||
- Date range inputs now handle pre-epoch dates gracefully by clamping to valid PostgreSQL integer range. #685
|
||||
- Redis client now also being configured so that it could connect via unix socket. #1970
|
||||
- Importing KML files now creates points with correct timestamps. #1988
|
||||
- Importing KMZ files now works correctly.
|
||||
- Map settings are now being respected in map v2. #2012
|
||||
|
||||
|
||||
# [0.36.2] - 2025-12-06
|
||||
|
||||
## The Map v2 release
|
||||
|
||||
In this release we're introducing Map v2 based on MapLibre GL JS. It brings better performance, smoother interactions and more features in the future. User can select between Map v1 (Leaflet) and Map v2 (MapLibre GL JS) in the Settings -> Map Settings. New map features will be added to Map v2 only.
|
||||
|
||||
## Added
|
||||
|
||||
- User can select between Map v1 (Leaflet) and Map v2 (MapLibre GL JS) in the User Settings.
|
||||
|
||||
## Fixed
|
||||
|
||||
- Heatmap and Fog of War now are moving correctly during map interactions on v2 map. #1798
|
||||
- Polyline crossing international date line now are rendered correctly on v2 map. #1162
|
||||
- Heatmap and Fog of War now are moving correctly during map interactions. #1798
|
||||
- Polyline crossing international date line now are rendered correctly. #1162
|
||||
- Place popup tags parsing (MapLibre GL JS compatibility)
|
||||
- Stats calculation should be faster now.
|
||||
|
||||
## Changed
|
||||
|
||||
- Points on the Map page are now loaded in chunks to improve performance and reduce memory consumption.
|
||||
|
||||
|
||||
# [0.36.1] - 2025-11-29
|
||||
|
||||
|
|
|
|||
41
CLAUDE.md
41
CLAUDE.md
|
|
@ -238,47 +238,6 @@ bundle exec bundle-audit # Dependency security
|
|||
- Respect expiration settings and disable sharing when expired
|
||||
- Only expose minimal necessary data in public sharing contexts
|
||||
|
||||
### Route Drawing Implementation (Critical)
|
||||
|
||||
⚠️ **IMPORTANT: Unit Mismatch in Route Splitting Logic**
|
||||
|
||||
Both Map v1 (Leaflet) and Map v2 (MapLibre) contain an **intentional unit mismatch** in route drawing that must be preserved for consistency:
|
||||
|
||||
**The Issue**:
|
||||
- `haversineDistance()` function returns distance in **kilometers** (e.g., 0.5 km)
|
||||
- Route splitting threshold is stored and compared as **meters** (e.g., 500)
|
||||
- The code compares them directly: `0.5 > 500` = always **FALSE**
|
||||
|
||||
**Result**:
|
||||
- The distance threshold (`meters_between_routes` setting) is **effectively disabled**
|
||||
- Routes only split on **time gaps** (default: 60 minutes between points)
|
||||
- This creates longer, more continuous routes that users expect
|
||||
|
||||
**Code Locations**:
|
||||
- **Map v1**: `app/javascript/maps/polylines.js:390`
|
||||
- Uses `haversineDistance()` from `maps/helpers.js` (returns km)
|
||||
- Compares to `distanceThresholdMeters` variable (value in meters)
|
||||
|
||||
- **Map v2**: `app/javascript/maps_maplibre/layers/routes_layer.js:82-104`
|
||||
- Has built-in `haversineDistance()` method (returns km)
|
||||
- Intentionally skips `/1000` conversion to replicate v1 behavior
|
||||
- Comment explains this is matching v1's unit mismatch
|
||||
|
||||
**Critical Rules**:
|
||||
1. ❌ **DO NOT "fix" the unit mismatch** - this would break user expectations
|
||||
2. ✅ **Keep both versions synchronized** - they must behave identically
|
||||
3. ✅ **Document any changes** - route drawing changes affect all users
|
||||
4. ⚠️ If you ever fix this bug:
|
||||
- You MUST update both v1 and v2 simultaneously
|
||||
- You MUST migrate user settings (multiply existing values by 1000 or divide by 1000 depending on direction)
|
||||
- You MUST communicate the breaking change to users
|
||||
|
||||
**Additional Route Drawing Details**:
|
||||
- **Time threshold**: 60 minutes (default) - actually functional
|
||||
- **Distance threshold**: 500 meters (default) - currently non-functional due to unit bug
|
||||
- **Sorting**: Map v2 sorts points by timestamp client-side; v1 relies on backend ASC order
|
||||
- **API ordering**: Map v2 must request `order: 'asc'` to match v1's chronological data flow
|
||||
|
||||
## Contributing
|
||||
|
||||
- **Main Branch**: `master`
|
||||
|
|
|
|||
7
Gemfile
7
Gemfile
|
|
@ -12,10 +12,8 @@ gem 'aws-sdk-kms', '~> 1.96.0', require: false
|
|||
gem 'aws-sdk-s3', '~> 1.177.0', require: false
|
||||
gem 'bootsnap', require: false
|
||||
gem 'chartkick'
|
||||
gem 'connection_pool', '< 3' # Pin to 2.x - version 3.0+ has breaking API changes with Rails RedisCacheStore
|
||||
gem 'data_migrate'
|
||||
gem 'devise'
|
||||
gem 'foreman'
|
||||
gem 'geocoder', github: 'Freika/geocoder', branch: 'master'
|
||||
gem 'gpx'
|
||||
gem 'groupdate'
|
||||
|
|
@ -37,7 +35,6 @@ gem 'puma'
|
|||
gem 'pundit', '>= 2.5.1'
|
||||
gem 'rails', '~> 8.0'
|
||||
gem 'rails_icons'
|
||||
gem 'rails_pulse'
|
||||
gem 'redis'
|
||||
gem 'rexml'
|
||||
gem 'rgeo'
|
||||
|
|
@ -49,7 +46,7 @@ gem 'rswag-ui'
|
|||
gem 'rubyzip', '~> 3.2'
|
||||
gem 'sentry-rails', '>= 5.27.0'
|
||||
gem 'sentry-ruby'
|
||||
gem 'sidekiq', '8.0.10' # Pin to 8.0.x - sidekiq 8.1+ requires connection_pool 3.0+ which has breaking changes with Rails
|
||||
gem 'sidekiq', '>= 8.0.5'
|
||||
gem 'sidekiq-cron', '>= 2.3.1'
|
||||
gem 'sidekiq-limit_fetch'
|
||||
gem 'sprockets-rails'
|
||||
|
|
@ -58,7 +55,7 @@ gem 'stimulus-rails'
|
|||
gem 'tailwindcss-rails', '= 3.3.2'
|
||||
gem 'turbo-rails', '>= 2.0.17'
|
||||
gem 'tzinfo-data', platforms: %i[mingw mswin x64_mingw jruby]
|
||||
gem 'with_advisory_lock'
|
||||
gem 'foreman'
|
||||
|
||||
group :development, :test, :staging do
|
||||
gem 'brakeman', require: false
|
||||
|
|
|
|||
102
Gemfile.lock
102
Gemfile.lock
|
|
@ -108,12 +108,12 @@ GEM
|
|||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
base64 (0.3.0)
|
||||
bcrypt (3.1.20)
|
||||
benchmark (0.5.0)
|
||||
bigdecimal (4.0.1)
|
||||
benchmark (0.4.1)
|
||||
bigdecimal (3.3.1)
|
||||
bindata (2.5.1)
|
||||
bootsnap (1.18.6)
|
||||
msgpack (~> 1.2)
|
||||
brakeman (7.1.1)
|
||||
brakeman (7.1.0)
|
||||
racc
|
||||
builder (3.3.0)
|
||||
bundler-audit (0.9.2)
|
||||
|
|
@ -129,19 +129,18 @@ GEM
|
|||
rack-test (>= 0.6.3)
|
||||
regexp_parser (>= 1.5, < 3.0)
|
||||
xpath (~> 3.2)
|
||||
chartkick (5.2.1)
|
||||
chartkick (5.2.0)
|
||||
chunky_png (1.4.0)
|
||||
coderay (1.1.3)
|
||||
concurrent-ruby (1.3.6)
|
||||
connection_pool (2.5.5)
|
||||
crack (1.0.1)
|
||||
concurrent-ruby (1.3.5)
|
||||
connection_pool (2.5.4)
|
||||
crack (1.0.0)
|
||||
bigdecimal
|
||||
rexml
|
||||
crass (1.0.6)
|
||||
cronex (0.15.0)
|
||||
tzinfo
|
||||
unicode (>= 0.4.4.5)
|
||||
css-zero (1.1.15)
|
||||
csv (3.3.4)
|
||||
data_migrate (11.3.1)
|
||||
activerecord (>= 6.1)
|
||||
|
|
@ -167,7 +166,7 @@ GEM
|
|||
drb (2.2.3)
|
||||
email_validator (2.2.4)
|
||||
activemodel
|
||||
erb (6.0.0)
|
||||
erb (5.1.3)
|
||||
erubi (1.13.1)
|
||||
et-orbi (1.4.0)
|
||||
tzinfo
|
||||
|
|
@ -209,25 +208,25 @@ GEM
|
|||
ffi (~> 1.9)
|
||||
rgeo-geojson (~> 2.1)
|
||||
zeitwerk (~> 2.5)
|
||||
hashdiff (1.2.1)
|
||||
hashdiff (1.1.2)
|
||||
hashie (5.0.0)
|
||||
httparty (0.23.1)
|
||||
csv
|
||||
mini_mime (>= 1.0.0)
|
||||
multi_xml (>= 0.5.2)
|
||||
i18n (1.14.8)
|
||||
i18n (1.14.7)
|
||||
concurrent-ruby (~> 1.0)
|
||||
importmap-rails (2.2.2)
|
||||
actionpack (>= 6.0.0)
|
||||
activesupport (>= 6.0.0)
|
||||
railties (>= 6.0.0)
|
||||
io-console (0.8.1)
|
||||
irb (1.15.3)
|
||||
irb (1.15.2)
|
||||
pp (>= 0.6.0)
|
||||
rdoc (>= 4.0.0)
|
||||
reline (>= 0.4.2)
|
||||
jmespath (1.6.2)
|
||||
json (2.18.0)
|
||||
json (2.15.0)
|
||||
json-jwt (1.17.0)
|
||||
activesupport (>= 4.2)
|
||||
aes_key_wrap
|
||||
|
|
@ -273,12 +272,11 @@ GEM
|
|||
method_source (1.1.0)
|
||||
mini_mime (1.1.5)
|
||||
mini_portile2 (2.8.9)
|
||||
minitest (6.0.1)
|
||||
prism (~> 1.5)
|
||||
minitest (5.26.0)
|
||||
msgpack (1.7.3)
|
||||
multi_json (1.15.0)
|
||||
multi_xml (0.8.0)
|
||||
bigdecimal (>= 3.1, < 5)
|
||||
multi_xml (0.7.1)
|
||||
bigdecimal (~> 3.1)
|
||||
net-http (0.6.0)
|
||||
uri
|
||||
net-imap (0.5.12)
|
||||
|
|
@ -353,11 +351,8 @@ GEM
|
|||
optimist (3.2.1)
|
||||
orm_adapter (0.5.0)
|
||||
ostruct (0.6.1)
|
||||
pagy (43.2.2)
|
||||
json
|
||||
yaml
|
||||
parallel (1.27.0)
|
||||
parser (3.3.10.0)
|
||||
parser (3.3.9.0)
|
||||
ast (~> 2.4.1)
|
||||
racc
|
||||
patience_diff (1.2.0)
|
||||
|
|
@ -370,7 +365,7 @@ GEM
|
|||
pp (0.6.3)
|
||||
prettyprint
|
||||
prettyprint (0.2.0)
|
||||
prism (1.7.0)
|
||||
prism (1.5.1)
|
||||
prometheus_exporter (2.2.0)
|
||||
webrick
|
||||
pry (0.15.2)
|
||||
|
|
@ -384,14 +379,14 @@ GEM
|
|||
psych (5.2.6)
|
||||
date
|
||||
stringio
|
||||
public_suffix (6.0.2)
|
||||
public_suffix (6.0.1)
|
||||
puma (7.1.0)
|
||||
nio4r (~> 2.0)
|
||||
pundit (2.5.2)
|
||||
activesupport (>= 3.0.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.8.1)
|
||||
rack (3.2.4)
|
||||
rack (3.2.3)
|
||||
rack-oauth2 (2.3.0)
|
||||
activesupport
|
||||
attr_required
|
||||
|
|
@ -434,14 +429,6 @@ GEM
|
|||
rails_icons (1.4.0)
|
||||
nokogiri (~> 1.16, >= 1.16.4)
|
||||
rails (> 6.1)
|
||||
rails_pulse (0.2.4)
|
||||
css-zero (~> 1.1, >= 1.1.4)
|
||||
groupdate (~> 6.0)
|
||||
pagy (>= 8, < 44)
|
||||
rails (>= 7.1.0, < 9.0.0)
|
||||
ransack (~> 4.0)
|
||||
request_store (~> 1.5)
|
||||
turbo-rails (~> 2.0.11)
|
||||
railties (8.0.3)
|
||||
actionpack (= 8.0.3)
|
||||
activesupport (= 8.0.3)
|
||||
|
|
@ -453,20 +440,16 @@ GEM
|
|||
zeitwerk (~> 2.6)
|
||||
rainbow (3.1.1)
|
||||
rake (13.3.1)
|
||||
ransack (4.4.1)
|
||||
activerecord (>= 7.2)
|
||||
activesupport (>= 7.2)
|
||||
i18n
|
||||
rdoc (6.16.1)
|
||||
rdoc (6.15.0)
|
||||
erb
|
||||
psych (>= 4.0.0)
|
||||
tsort
|
||||
redis (5.4.1)
|
||||
redis (5.4.0)
|
||||
redis-client (>= 0.22.0)
|
||||
redis-client (0.26.2)
|
||||
redis-client (0.24.0)
|
||||
connection_pool
|
||||
regexp_parser (2.11.3)
|
||||
reline (0.6.3)
|
||||
reline (0.6.2)
|
||||
io-console (~> 0.5)
|
||||
request_store (1.7.0)
|
||||
rack (>= 1.4)
|
||||
|
|
@ -513,7 +496,7 @@ GEM
|
|||
rswag-ui (2.17.0)
|
||||
actionpack (>= 5.2, < 8.2)
|
||||
railties (>= 5.2, < 8.2)
|
||||
rubocop (1.82.1)
|
||||
rubocop (1.81.1)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (~> 3.17.0.2)
|
||||
lint_roller (~> 1.1.0)
|
||||
|
|
@ -521,20 +504,20 @@ GEM
|
|||
parser (>= 3.3.0.2)
|
||||
rainbow (>= 2.2.2, < 4.0)
|
||||
regexp_parser (>= 2.9.3, < 3.0)
|
||||
rubocop-ast (>= 1.48.0, < 2.0)
|
||||
rubocop-ast (>= 1.47.1, < 2.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 2.4.0, < 4.0)
|
||||
rubocop-ast (1.49.0)
|
||||
rubocop-ast (1.47.1)
|
||||
parser (>= 3.3.7.2)
|
||||
prism (~> 1.7)
|
||||
rubocop-rails (2.34.2)
|
||||
prism (~> 1.4)
|
||||
rubocop-rails (2.33.4)
|
||||
activesupport (>= 4.2.0)
|
||||
lint_roller (~> 1.1)
|
||||
rack (>= 1.1)
|
||||
rubocop (>= 1.75.0, < 2.0)
|
||||
rubocop-ast (>= 1.44.0, < 2.0)
|
||||
ruby-progressbar (1.13.0)
|
||||
rubyzip (3.2.2)
|
||||
rubyzip (3.2.0)
|
||||
securerandom (0.4.1)
|
||||
selenium-webdriver (4.35.0)
|
||||
base64 (~> 0.2)
|
||||
|
|
@ -542,15 +525,15 @@ GEM
|
|||
rexml (~> 3.2, >= 3.2.5)
|
||||
rubyzip (>= 1.2.2, < 4.0)
|
||||
websocket (~> 1.0)
|
||||
sentry-rails (6.2.0)
|
||||
sentry-rails (6.0.0)
|
||||
railties (>= 5.2.0)
|
||||
sentry-ruby (~> 6.2.0)
|
||||
sentry-ruby (6.2.0)
|
||||
sentry-ruby (~> 6.0.0)
|
||||
sentry-ruby (6.0.0)
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
shoulda-matchers (6.5.0)
|
||||
activesupport (>= 5.2.0)
|
||||
sidekiq (8.0.10)
|
||||
sidekiq (8.0.8)
|
||||
connection_pool (>= 2.5.0)
|
||||
json (>= 2.9.0)
|
||||
logger (>= 1.6.2)
|
||||
|
|
@ -582,7 +565,7 @@ GEM
|
|||
stackprof (0.2.27)
|
||||
stimulus-rails (1.3.4)
|
||||
railties (>= 6.0.0)
|
||||
stringio (3.1.8)
|
||||
stringio (3.1.7)
|
||||
strong_migrations (2.5.1)
|
||||
activerecord (>= 7.1)
|
||||
super_diff (0.17.0)
|
||||
|
|
@ -606,7 +589,7 @@ GEM
|
|||
thor (1.4.0)
|
||||
timeout (0.4.4)
|
||||
tsort (0.2.0)
|
||||
turbo-rails (2.0.20)
|
||||
turbo-rails (2.0.17)
|
||||
actionpack (>= 7.1.0)
|
||||
railties (>= 7.1.0)
|
||||
tzinfo (2.0.6)
|
||||
|
|
@ -614,8 +597,8 @@ GEM
|
|||
unicode (0.4.4.5)
|
||||
unicode-display_width (3.2.0)
|
||||
unicode-emoji (~> 4.1)
|
||||
unicode-emoji (4.2.0)
|
||||
uri (1.1.1)
|
||||
unicode-emoji (4.1.0)
|
||||
uri (1.0.4)
|
||||
useragent (0.16.11)
|
||||
validate_url (1.0.15)
|
||||
activemodel (>= 3.0.0)
|
||||
|
|
@ -627,7 +610,7 @@ GEM
|
|||
activesupport
|
||||
faraday (~> 2.0)
|
||||
faraday-follow_redirects
|
||||
webmock (3.26.1)
|
||||
webmock (3.25.1)
|
||||
addressable (>= 2.8.0)
|
||||
crack (>= 0.3.2)
|
||||
hashdiff (>= 0.4.0, < 2.0.0)
|
||||
|
|
@ -637,12 +620,8 @@ GEM
|
|||
base64
|
||||
websocket-extensions (>= 0.1.0)
|
||||
websocket-extensions (0.1.5)
|
||||
with_advisory_lock (7.0.2)
|
||||
activerecord (>= 7.2)
|
||||
zeitwerk (>= 2.7)
|
||||
xpath (3.2.0)
|
||||
nokogiri (~> 1.8)
|
||||
yaml (0.4.0)
|
||||
zeitwerk (2.7.3)
|
||||
|
||||
PLATFORMS
|
||||
|
|
@ -663,7 +642,6 @@ DEPENDENCIES
|
|||
bundler-audit
|
||||
capybara
|
||||
chartkick
|
||||
connection_pool (< 3)
|
||||
data_migrate
|
||||
database_consistency (>= 2.0.5)
|
||||
debug
|
||||
|
|
@ -696,7 +674,6 @@ DEPENDENCIES
|
|||
pundit (>= 2.5.1)
|
||||
rails (~> 8.0)
|
||||
rails_icons
|
||||
rails_pulse
|
||||
redis
|
||||
rexml
|
||||
rgeo
|
||||
|
|
@ -713,7 +690,7 @@ DEPENDENCIES
|
|||
sentry-rails (>= 5.27.0)
|
||||
sentry-ruby
|
||||
shoulda-matchers
|
||||
sidekiq (= 8.0.10)
|
||||
sidekiq (>= 8.0.5)
|
||||
sidekiq-cron (>= 2.3.1)
|
||||
sidekiq-limit_fetch
|
||||
simplecov
|
||||
|
|
@ -726,7 +703,6 @@ DEPENDENCIES
|
|||
turbo-rails (>= 2.0.17)
|
||||
tzinfo-data
|
||||
webmock
|
||||
with_advisory_lock
|
||||
|
||||
RUBY VERSION
|
||||
ruby 3.4.6p54
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
[](https://discord.gg/pHsBjpt5J8) | [](https://ko-fi.com/H2H3IDYDD) | [](https://www.patreon.com/freika)
|
||||
|
||||
[](https://app.circleci.com/pipelines/github/Freika/dawarich)
|
||||
|
||||
---
|
||||
|
||||
## 📸 Screenshots
|
||||
|
|
@ -71,7 +73,7 @@ Simply install one of the supported apps on your device and configure it to send
|
|||
1. Clone the repository.
|
||||
2. Run the following command to start the app:
|
||||
```bash
|
||||
docker compose -f docker/docker-compose.yml up
|
||||
docker-compose -f docker/docker-compose.yml up
|
||||
```
|
||||
3. Access the app at `http://localhost:3000`.
|
||||
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -1 +0,0 @@
|
|||
<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-arrow-big-down-icon lucide-arrow-big-down"><path d="M15 11a1 1 0 0 0 1 1h2.939a1 1 0 0 1 .75 1.811l-6.835 6.836a1.207 1.207 0 0 1-1.707 0L4.31 13.81a1 1 0 0 1 .75-1.811H8a1 1 0 0 0 1-1V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1z"/></svg>
|
||||
|
Before Width: | Height: | Size: 429 B |
|
|
@ -1 +0,0 @@
|
|||
<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-calendar-plus2-icon lucide-calendar-plus-2"><path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/><path d="M10 16h4"/><path d="M12 14v4"/></svg>
|
||||
|
Before Width: | Height: | Size: 399 B |
|
|
@ -1 +0,0 @@
|
|||
<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-mail-icon lucide-mail"><path d="m22 7-8.991 5.727a2 2 0 0 1-2.009 0L2 7"/><rect x="2" y="4" width="20" height="16" rx="2"/></svg>
|
||||
|
Before Width: | Height: | Size: 332 B |
|
|
@ -1 +0,0 @@
|
|||
<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-message-circle-question-mark-icon lucide-message-circle-question-mark"><path d="M2.992 16.342a2 2 0 0 1 .094 1.167l-1.065 3.29a1 1 0 0 0 1.236 1.168l3.413-.998a2 2 0 0 1 1.099.092 10 10 0 1 0-4.777-4.719"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/></svg>
|
||||
|
Before Width: | Height: | Size: 485 B |
|
|
@ -1,7 +1,7 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::AreasController < ApiController
|
||||
before_action :set_area, only: %i[show update destroy]
|
||||
before_action :set_area, only: %i[update destroy]
|
||||
|
||||
def index
|
||||
@areas = current_api_user.areas
|
||||
|
|
@ -9,10 +9,6 @@ class Api::V1::AreasController < ApiController
|
|||
render json: @areas, status: :ok
|
||||
end
|
||||
|
||||
def show
|
||||
render json: @area, status: :ok
|
||||
end
|
||||
|
||||
def create
|
||||
@area = current_api_user.areas.build(area_params)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,17 +1,14 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::Countries::VisitedCitiesController < ApiController
|
||||
include SafeTimestampParser
|
||||
|
||||
before_action :validate_params
|
||||
|
||||
def index
|
||||
start_at = safe_timestamp(params[:start_at])
|
||||
end_at = safe_timestamp(params[:end_at])
|
||||
start_at = DateTime.parse(params[:start_at]).to_i
|
||||
end_at = DateTime.parse(params[:end_at]).to_i
|
||||
|
||||
points = current_api_user
|
||||
.points
|
||||
.without_raw_data
|
||||
.where(timestamp: start_at..end_at)
|
||||
|
||||
render json: { data: CountriesAndCities.new(points).call }
|
||||
|
|
|
|||
|
|
@ -5,13 +5,9 @@ class Api::V1::Overland::BatchesController < ApiController
|
|||
before_action :validate_points_limit, only: %i[create]
|
||||
|
||||
def create
|
||||
Overland::PointsCreator.new(batch_params, current_api_user.id).call
|
||||
Overland::BatchCreatingJob.perform_later(batch_params, current_api_user.id)
|
||||
|
||||
render json: { result: 'ok' }, status: :created
|
||||
rescue StandardError => e
|
||||
Sentry.capture_exception(e) if defined?(Sentry)
|
||||
|
||||
render json: { error: 'Batch creation failed' }, status: :internal_server_error
|
||||
end
|
||||
|
||||
private
|
||||
|
|
|
|||
|
|
@ -5,13 +5,9 @@ class Api::V1::Owntracks::PointsController < ApiController
|
|||
before_action :validate_points_limit, only: %i[create]
|
||||
|
||||
def create
|
||||
OwnTracks::PointCreator.new(point_params, current_api_user.id).call
|
||||
Owntracks::PointCreatingJob.perform_later(point_params, current_api_user.id)
|
||||
|
||||
render json: [], status: :ok
|
||||
rescue StandardError => e
|
||||
Sentry.capture_exception(e) if defined?(Sentry)
|
||||
|
||||
render json: { error: 'Point creation failed' }, status: :internal_server_error
|
||||
render json: {}, status: :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
|
|
|||
|
|
@ -16,11 +16,11 @@ module Api
|
|||
include_untagged = tag_ids.include?('untagged')
|
||||
|
||||
if numeric_tag_ids.any? && include_untagged
|
||||
# Both tagged and untagged: use OR logic to preserve eager loading
|
||||
tagged_ids = current_api_user.places.with_tags(numeric_tag_ids).pluck(:id)
|
||||
untagged_ids = current_api_user.places.without_tags.pluck(:id)
|
||||
combined_ids = (tagged_ids + untagged_ids).uniq
|
||||
@places = current_api_user.places.includes(:tags, :visits).where(id: combined_ids)
|
||||
# Both tagged and untagged: return union (OR logic)
|
||||
tagged = current_api_user.places.includes(:tags, :visits).with_tags(numeric_tag_ids)
|
||||
untagged = current_api_user.places.includes(:tags, :visits).without_tags
|
||||
@places = Place.from("(#{tagged.to_sql} UNION #{untagged.to_sql}) AS places")
|
||||
.includes(:tags, :visits)
|
||||
elsif numeric_tag_ids.any?
|
||||
# Only tagged places with ANY of the selected tags (OR logic)
|
||||
@places = @places.with_tags(numeric_tag_ids)
|
||||
|
|
@ -30,29 +30,6 @@ module Api
|
|||
end
|
||||
end
|
||||
|
||||
# Support pagination (defaults to page 1 with all results if no page param)
|
||||
page = params[:page].presence || 1
|
||||
per_page = [params[:per_page]&.to_i || 100, 500].min
|
||||
|
||||
# Apply pagination only if page param is explicitly provided
|
||||
if params[:page].present?
|
||||
@places = @places.page(page).per(per_page)
|
||||
end
|
||||
|
||||
# Always set pagination headers for consistency
|
||||
if @places.respond_to?(:current_page)
|
||||
# Paginated collection
|
||||
response.set_header('X-Current-Page', @places.current_page.to_s)
|
||||
response.set_header('X-Total-Pages', @places.total_pages.to_s)
|
||||
response.set_header('X-Total-Count', @places.total_count.to_s)
|
||||
else
|
||||
# Non-paginated collection - treat as single page with all results
|
||||
total = @places.count
|
||||
response.set_header('X-Current-Page', '1')
|
||||
response.set_header('X-Total-Pages', '1')
|
||||
response.set_header('X-Total-Count', total.to_s)
|
||||
end
|
||||
|
||||
render json: @places.map { |place| serialize_place(place) }
|
||||
end
|
||||
|
||||
|
|
@ -143,7 +120,7 @@ module Api
|
|||
note: place.note,
|
||||
icon: place.tags.first&.icon,
|
||||
color: place.tags.first&.color,
|
||||
visits_count: place.visits.size,
|
||||
visits_count: place.visits.count,
|
||||
created_at: place.created_at,
|
||||
tags: place.tags.map do |tag|
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,19 +1,16 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::PointsController < ApiController
|
||||
include SafeTimestampParser
|
||||
|
||||
before_action :authenticate_active_api_user!, only: %i[create update destroy bulk_destroy]
|
||||
before_action :validate_points_limit, only: %i[create]
|
||||
|
||||
def index
|
||||
start_at = params[:start_at].present? ? safe_timestamp(params[:start_at]) : nil
|
||||
end_at = params[:end_at].present? ? safe_timestamp(params[:end_at]) : Time.zone.now.to_i
|
||||
start_at = params[:start_at]&.to_datetime&.to_i
|
||||
end_at = params[:end_at]&.to_datetime&.to_i || Time.zone.now.to_i
|
||||
order = params[:order] || 'desc'
|
||||
|
||||
points = current_api_user
|
||||
.points
|
||||
.without_raw_data
|
||||
.where(timestamp: start_at..end_at)
|
||||
|
||||
# Filter by geographic bounds if provided
|
||||
|
|
@ -53,11 +50,9 @@ class Api::V1::PointsController < ApiController
|
|||
def update
|
||||
point = current_api_user.points.find(params[:id])
|
||||
|
||||
if point.update(lonlat: "POINT(#{point_params[:longitude]} #{point_params[:latitude]})")
|
||||
render json: point_serializer.new(point.reload).call
|
||||
else
|
||||
render json: { error: point.errors.full_messages.join(', ') }, status: :unprocessable_entity
|
||||
end
|
||||
point.update(lonlat: "POINT(#{point_params[:longitude]} #{point_params[:latitude]})")
|
||||
|
||||
render json: point_serializer.new(point).call
|
||||
end
|
||||
|
||||
def destroy
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ class Api::V1::SettingsController < ApiController
|
|||
|
||||
def index
|
||||
render json: {
|
||||
settings: current_api_user.safe_settings.config,
|
||||
settings: current_api_user.settings,
|
||||
status: 'success'
|
||||
}, status: :ok
|
||||
end
|
||||
|
|
@ -14,7 +14,7 @@ class Api::V1::SettingsController < ApiController
|
|||
settings_params.each { |key, value| current_api_user.settings[key] = value }
|
||||
|
||||
if current_api_user.save
|
||||
render json: { message: 'Settings updated', settings: current_api_user.safe_settings.config, status: 'success' },
|
||||
render json: { message: 'Settings updated', settings: current_api_user.settings, status: 'success' },
|
||||
status: :ok
|
||||
else
|
||||
render json: { message: 'Something went wrong', errors: current_api_user.errors.full_messages },
|
||||
|
|
@ -31,7 +31,7 @@ class Api::V1::SettingsController < ApiController
|
|||
: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, :fog_of_war_threshold,
|
||||
:maps_v2_style, :maps_maplibre_style, :globe_projection,
|
||||
:maps_v2_style,
|
||||
enabled_map_layers: []
|
||||
)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,16 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::TracksController < ApiController
|
||||
def index
|
||||
tracks_query = Tracks::IndexQuery.new(user: current_api_user, params: params)
|
||||
paginated_tracks = tracks_query.call
|
||||
|
||||
geojson = Tracks::GeojsonSerializer.new(paginated_tracks).call
|
||||
|
||||
tracks_query.pagination_headers(paginated_tracks).each do |header, value|
|
||||
response.set_header(header, value)
|
||||
end
|
||||
|
||||
render json: geojson
|
||||
end
|
||||
end
|
||||
|
|
@ -3,17 +3,6 @@
|
|||
class Api::V1::VisitsController < ApiController
|
||||
def index
|
||||
visits = Visits::Finder.new(current_api_user, params).call
|
||||
|
||||
# Support optional pagination (backward compatible - returns all if no page param)
|
||||
if params[:page].present?
|
||||
per_page = [params[:per_page]&.to_i || 100, 500].min
|
||||
visits = visits.page(params[:page]).per(per_page)
|
||||
|
||||
response.set_header('X-Current-Page', visits.current_page.to_s)
|
||||
response.set_header('X-Total-Pages', visits.total_pages.to_s)
|
||||
response.set_header('X-Total-Count', visits.total_count.to_s)
|
||||
end
|
||||
|
||||
serialized_visits = visits.map do |visit|
|
||||
Api::VisitSerializer.new(visit).call
|
||||
end
|
||||
|
|
@ -21,11 +10,6 @@ class Api::V1::VisitsController < ApiController
|
|||
render json: serialized_visits
|
||||
end
|
||||
|
||||
def show
|
||||
visit = current_api_user.visits.find(params[:id])
|
||||
render json: Api::VisitSerializer.new(visit).call
|
||||
end
|
||||
|
||||
def create
|
||||
service = Visits::Create.new(current_api_user, visit_params)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,24 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module SafeTimestampParser
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
private
|
||||
|
||||
def safe_timestamp(date_string)
|
||||
return Time.zone.now.to_i if date_string.blank?
|
||||
|
||||
parsed_time = Time.zone.parse(date_string)
|
||||
|
||||
# Time.zone.parse returns epoch time (2000-01-01) for unparseable strings
|
||||
# Check if it's a valid parse by seeing if year is suspiciously at epoch
|
||||
return Time.zone.now.to_i if parsed_time.nil? || (parsed_time.year == 2000 && !date_string.include?('2000'))
|
||||
|
||||
min_timestamp = Time.zone.parse('1970-01-01').to_i
|
||||
max_timestamp = Time.zone.parse('2100-01-01').to_i
|
||||
|
||||
parsed_time.to_i.clamp(min_timestamp, max_timestamp)
|
||||
rescue ArgumentError, TypeError
|
||||
Time.zone.now.to_i
|
||||
end
|
||||
end
|
||||
|
|
@ -7,7 +7,7 @@ class ExportsController < ApplicationController
|
|||
before_action :set_export, only: %i[destroy]
|
||||
|
||||
def index
|
||||
@exports = current_user.exports.with_attached_file.order(created_at: :desc).page(params[:page])
|
||||
@exports = current_user.exports.order(created_at: :desc).page(params[:page])
|
||||
end
|
||||
|
||||
def create
|
||||
|
|
|
|||
|
|
@ -1,12 +1,10 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class HomeController < ApplicationController
|
||||
include ApplicationHelper
|
||||
|
||||
def index
|
||||
# redirect_to 'https://dawarich.app', allow_other_host: true and return unless SELF_HOSTED
|
||||
|
||||
redirect_to preferred_map_path if current_user
|
||||
redirect_to map_url if current_user
|
||||
|
||||
@points = current_user.points.without_raw_data if current_user
|
||||
end
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ class ImportsController < ApplicationController
|
|||
def index
|
||||
@imports = policy_scope(Import)
|
||||
.select(:id, :name, :source, :created_at, :processed, :status)
|
||||
.with_attached_file
|
||||
.order(created_at: :desc)
|
||||
.page(params[:page])
|
||||
end
|
||||
|
|
@ -79,13 +78,9 @@ class ImportsController < ApplicationController
|
|||
end
|
||||
|
||||
def destroy
|
||||
@import.deleting!
|
||||
Imports::DestroyJob.perform_later(@import.id)
|
||||
Imports::Destroy.new(current_user, @import).call
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_to imports_url, notice: 'Import is being deleted.', status: :see_other }
|
||||
format.turbo_stream
|
||||
end
|
||||
redirect_to imports_url, notice: 'Import was successfully destroyed.', status: :see_other
|
||||
end
|
||||
|
||||
private
|
||||
|
|
|
|||
|
|
@ -1,35 +0,0 @@
|
|||
module Map
|
||||
class MaplibreController < ApplicationController
|
||||
include SafeTimestampParser
|
||||
|
||||
before_action :authenticate_user!
|
||||
layout 'map'
|
||||
|
||||
def index
|
||||
@start_at = parsed_start_at
|
||||
@end_at = parsed_end_at
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def start_at
|
||||
return safe_timestamp(params[:start_at]) if params[:start_at].present?
|
||||
|
||||
Time.zone.today.beginning_of_day.to_i
|
||||
end
|
||||
|
||||
def end_at
|
||||
return safe_timestamp(params[:end_at]) if params[:end_at].present?
|
||||
|
||||
Time.zone.today.end_of_day.to_i
|
||||
end
|
||||
|
||||
def parsed_start_at
|
||||
Time.zone.at(start_at)
|
||||
end
|
||||
|
||||
def parsed_end_at
|
||||
Time.zone.at(end_at)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,8 +1,6 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Map::LeafletController < ApplicationController
|
||||
include SafeTimestampParser
|
||||
|
||||
class MapController < ApplicationController
|
||||
before_action :authenticate_user!
|
||||
layout 'map', only: :index
|
||||
|
||||
|
|
@ -41,34 +39,19 @@ class Map::LeafletController < ApplicationController
|
|||
end
|
||||
|
||||
def calculate_distance
|
||||
return 0 if @points.count(:id) < 2
|
||||
return 0 if @coordinates.size < 2
|
||||
|
||||
# Use PostGIS window function for efficient distance calculation
|
||||
# This is O(1) database operation vs O(n) Ruby iteration
|
||||
import_filter = params[:import_id].present? ? 'AND import_id = :import_id' : ''
|
||||
total_distance = 0
|
||||
|
||||
sql = <<~SQL.squish
|
||||
SELECT COALESCE(SUM(distance_m) / 1000.0, 0) as total_km FROM (
|
||||
SELECT ST_Distance(
|
||||
lonlat::geography,
|
||||
LAG(lonlat::geography) OVER (ORDER BY timestamp)
|
||||
) as distance_m
|
||||
FROM points
|
||||
WHERE user_id = :user_id
|
||||
AND timestamp >= :start_at
|
||||
AND timestamp <= :end_at
|
||||
#{import_filter}
|
||||
) distances
|
||||
SQL
|
||||
@coordinates.each_cons(2) do
|
||||
distance_km = Geocoder::Calculations.distance_between(
|
||||
[_1[0], _1[1]], [_2[0], _2[1]], units: :km
|
||||
)
|
||||
|
||||
query_params = { user_id: current_user.id, start_at: start_at, end_at: end_at }
|
||||
query_params[:import_id] = params[:import_id] if params[:import_id].present?
|
||||
total_distance += distance_km
|
||||
end
|
||||
|
||||
result = Point.connection.select_value(
|
||||
ActiveRecord::Base.sanitize_sql_array([sql, query_params])
|
||||
)
|
||||
|
||||
result&.to_f&.round || 0
|
||||
total_distance.round
|
||||
end
|
||||
|
||||
def parsed_start_at
|
||||
|
|
@ -88,14 +71,14 @@ class Map::LeafletController < ApplicationController
|
|||
end
|
||||
|
||||
def start_at
|
||||
return safe_timestamp(params[:start_at]) if params[:start_at].present?
|
||||
return Time.zone.parse(params[:start_at]).to_i if params[:start_at].present?
|
||||
return Time.zone.at(points.last.timestamp).beginning_of_day.to_i if points.any?
|
||||
|
||||
Time.zone.today.beginning_of_day.to_i
|
||||
end
|
||||
|
||||
def end_at
|
||||
return safe_timestamp(params[:end_at]) if params[:end_at].present?
|
||||
return Time.zone.parse(params[:end_at]).to_i if params[:end_at].present?
|
||||
return Time.zone.at(points.last.timestamp).end_of_day.to_i if points.any?
|
||||
|
||||
Time.zone.today.end_of_day.to_i
|
||||
33
app/controllers/maps/maplibre_controller.rb
Normal file
33
app/controllers/maps/maplibre_controller.rb
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
module Maps
|
||||
class MaplibreController < ApplicationController
|
||||
before_action :authenticate_user!
|
||||
layout 'map'
|
||||
|
||||
def index
|
||||
@start_at = parsed_start_at
|
||||
@end_at = parsed_end_at
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def start_at
|
||||
return Time.zone.parse(params[:start_at]).to_i if params[:start_at].present?
|
||||
|
||||
Time.zone.today.beginning_of_day.to_i
|
||||
end
|
||||
|
||||
def end_at
|
||||
return Time.zone.parse(params[:end_at]).to_i if params[:end_at].present?
|
||||
|
||||
Time.zone.today.end_of_day.to_i
|
||||
end
|
||||
|
||||
def parsed_start_at
|
||||
Time.zone.at(start_at)
|
||||
end
|
||||
|
||||
def parsed_end_at
|
||||
Time.zone.at(end_at)
|
||||
end
|
||||
end
|
||||
end
|
||||
5
app/controllers/maps_controller.rb
Normal file
5
app/controllers/maps_controller.rb
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
class MapsController < ApplicationController
|
||||
def index
|
||||
redirect_to maps_maplibre_path
|
||||
end
|
||||
end
|
||||
|
|
@ -1,8 +1,6 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class PointsController < ApplicationController
|
||||
include SafeTimestampParser
|
||||
|
||||
before_action :authenticate_user!
|
||||
|
||||
def index
|
||||
|
|
@ -42,13 +40,13 @@ class PointsController < ApplicationController
|
|||
def start_at
|
||||
return 1.month.ago.beginning_of_day.to_i if params[:start_at].nil?
|
||||
|
||||
safe_timestamp(params[:start_at])
|
||||
Time.zone.parse(params[:start_at]).to_i
|
||||
end
|
||||
|
||||
def end_at
|
||||
return Time.zone.today.end_of_day.to_i if params[:end_at].nil?
|
||||
|
||||
safe_timestamp(params[:end_at])
|
||||
Time.zone.parse(params[:end_at]).to_i
|
||||
end
|
||||
|
||||
def points
|
||||
|
|
|
|||
|
|
@ -24,6 +24,6 @@ class Settings::MapsController < ApplicationController
|
|||
private
|
||||
|
||||
def settings_params
|
||||
params.require(:maps).permit(:name, :url, :distance_unit, :preferred_version)
|
||||
params.require(:maps).permit(:name, :url, :distance_unit)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ class SettingsController < ApplicationController
|
|||
:meters_between_routes, :minutes_between_routes, :fog_of_war_meters,
|
||||
:time_threshold_minutes, :merge_threshold_minutes, :route_opacity,
|
||||
:immich_url, :immich_api_key, :photoprism_url, :photoprism_api_key,
|
||||
:visits_suggestions_enabled, :digest_emails_enabled
|
||||
:visits_suggestions_enabled
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,55 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Shared::DigestsController < ApplicationController
|
||||
helper Users::DigestsHelper
|
||||
helper CountryFlagHelper
|
||||
|
||||
before_action :authenticate_user!, except: [:show]
|
||||
before_action :authenticate_active_user!, only: [:update]
|
||||
|
||||
def show
|
||||
@digest = Users::Digest.find_by(sharing_uuid: params[:uuid])
|
||||
|
||||
unless @digest&.public_accessible?
|
||||
return redirect_to root_path,
|
||||
alert: 'Shared digest not found or no longer available'
|
||||
end
|
||||
|
||||
@year = @digest.year
|
||||
@user = @digest.user
|
||||
@distance_unit = @user.safe_settings.distance_unit || 'km'
|
||||
@is_public_view = true
|
||||
|
||||
render 'users/digests/public_year'
|
||||
end
|
||||
|
||||
def update
|
||||
@year = params[:year].to_i
|
||||
@digest = current_user.digests.yearly.find_by(year: @year)
|
||||
|
||||
return head :not_found unless @digest
|
||||
|
||||
if params[:enabled] == '1'
|
||||
@digest.enable_sharing!(expiration: params[:expiration] || '24h')
|
||||
sharing_url = shared_users_digest_url(@digest.sharing_uuid)
|
||||
|
||||
render json: {
|
||||
success: true,
|
||||
sharing_url: sharing_url,
|
||||
message: 'Sharing enabled successfully'
|
||||
}
|
||||
else
|
||||
@digest.disable_sharing!
|
||||
|
||||
render json: {
|
||||
success: true,
|
||||
message: 'Sharing disabled successfully'
|
||||
}
|
||||
end
|
||||
rescue StandardError
|
||||
render json: {
|
||||
success: false,
|
||||
message: 'Failed to update sharing settings'
|
||||
}, status: :unprocessable_content
|
||||
end
|
||||
end
|
||||
|
|
@ -80,12 +80,8 @@ class StatsController < ApplicationController
|
|||
end
|
||||
|
||||
def build_stats
|
||||
columns = %i[id year month distance updated_at user_id]
|
||||
columns << :toponyms if DawarichSettings.reverse_geocoding_enabled?
|
||||
|
||||
current_user.stats
|
||||
.select(columns)
|
||||
.order(year: :desc, updated_at: :desc)
|
||||
.group_by(&:year)
|
||||
current_user.stats.group_by(&:year).transform_values do |stats|
|
||||
stats.sort_by(&:updated_at).reverse
|
||||
end.sort.reverse
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,59 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Users::DigestsController < ApplicationController
|
||||
helper Users::DigestsHelper
|
||||
helper CountryFlagHelper
|
||||
|
||||
before_action :authenticate_user!
|
||||
before_action :authenticate_active_user!, only: [:create]
|
||||
before_action :set_digest, only: %i[show destroy]
|
||||
|
||||
def index
|
||||
@digests = current_user.digests.yearly.order(year: :desc)
|
||||
@available_years = available_years_for_generation
|
||||
end
|
||||
|
||||
def show
|
||||
@distance_unit = current_user.safe_settings.distance_unit || 'km'
|
||||
end
|
||||
|
||||
def create
|
||||
year = params[:year].to_i
|
||||
|
||||
if valid_year?(year)
|
||||
Users::Digests::CalculatingJob.perform_later(current_user.id, year)
|
||||
redirect_to users_digests_path,
|
||||
notice: "Year-end digest for #{year} is being generated. Check back soon!",
|
||||
status: :see_other
|
||||
else
|
||||
redirect_to users_digests_path, alert: 'Invalid year selected', status: :see_other
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
year = @digest.year
|
||||
@digest.destroy!
|
||||
redirect_to users_digests_path, notice: "Year-end digest for #{year} has been deleted", status: :see_other
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_digest
|
||||
@digest = current_user.digests.yearly.find_by!(year: params[:year])
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
redirect_to users_digests_path, alert: 'Digest not found'
|
||||
end
|
||||
|
||||
def available_years_for_generation
|
||||
tracked_years = current_user.stats.select(:year).distinct.pluck(:year)
|
||||
existing_digests = current_user.digests.yearly.pluck(:year)
|
||||
|
||||
(tracked_years - existing_digests - [Time.current.year]).sort.reverse
|
||||
end
|
||||
|
||||
def valid_year?(year)
|
||||
return false if year < 2000 || year > Time.current.year
|
||||
|
||||
current_user.stats.exists?(year: year)
|
||||
end
|
||||
end
|
||||
|
|
@ -142,11 +142,4 @@ module ApplicationHelper
|
|||
|
||||
ALLOW_EMAIL_PASSWORD_REGISTRATION
|
||||
end
|
||||
|
||||
def preferred_map_path
|
||||
return map_v1_path unless user_signed_in?
|
||||
|
||||
preferred_version = current_user.safe_settings.maps&.dig('preferred_version')
|
||||
preferred_version == 'v2' ? map_v2_path : map_v1_path
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,71 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Users
|
||||
module DigestsHelper
|
||||
PROGRESS_COLORS = %w[
|
||||
progress-primary progress-secondary progress-accent
|
||||
progress-info progress-success progress-warning
|
||||
].freeze
|
||||
|
||||
def progress_color_for_index(index)
|
||||
PROGRESS_COLORS[index % PROGRESS_COLORS.length]
|
||||
end
|
||||
|
||||
def city_progress_value(city_count, max_cities)
|
||||
return 0 unless max_cities&.positive?
|
||||
|
||||
(city_count.to_f / max_cities * 100).round
|
||||
end
|
||||
|
||||
def max_cities_count(toponyms)
|
||||
return 0 if toponyms.blank?
|
||||
|
||||
toponyms.map { |country| country['cities']&.length || 0 }.max
|
||||
end
|
||||
|
||||
def distance_with_unit(distance_meters, unit)
|
||||
value = Users::Digest.convert_distance(distance_meters, unit).round
|
||||
"#{number_with_delimiter(value)} #{unit}"
|
||||
end
|
||||
|
||||
def distance_comparison_text(distance_meters)
|
||||
distance_km = distance_meters.to_f / 1000
|
||||
|
||||
if distance_km >= Users::Digest::MOON_DISTANCE_KM
|
||||
percentage = ((distance_km / Users::Digest::MOON_DISTANCE_KM) * 100).round(1)
|
||||
"That's #{percentage}% of the distance to the Moon!"
|
||||
else
|
||||
percentage = ((distance_km / Users::Digest::EARTH_CIRCUMFERENCE_KM) * 100).round(1)
|
||||
"That's #{percentage}% of Earth's circumference!"
|
||||
end
|
||||
end
|
||||
|
||||
def format_time_spent(minutes)
|
||||
return "#{minutes} minutes" if minutes < 60
|
||||
|
||||
hours = minutes / 60
|
||||
remaining_minutes = minutes % 60
|
||||
|
||||
if hours < 24
|
||||
"#{hours}h #{remaining_minutes}m"
|
||||
else
|
||||
days = hours / 24
|
||||
remaining_hours = hours % 24
|
||||
"#{days}d #{remaining_hours}h"
|
||||
end
|
||||
end
|
||||
|
||||
def yoy_change_class(change)
|
||||
return '' if change.nil?
|
||||
|
||||
change.negative? ? 'negative' : 'positive'
|
||||
end
|
||||
|
||||
def yoy_change_text(change)
|
||||
return '' if change.nil?
|
||||
|
||||
prefix = change.positive? ? '+' : ''
|
||||
"#{prefix}#{change}%"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -33,6 +33,7 @@ export default class extends Controller {
|
|||
*/
|
||||
setupEventListeners() {
|
||||
document.addEventListener('area:drawn', (e) => {
|
||||
console.log('[Area Creation V2] area:drawn event received:', e.detail)
|
||||
this.open(e.detail.center, e.detail.radius)
|
||||
})
|
||||
}
|
||||
|
|
@ -41,6 +42,8 @@ export default class extends Controller {
|
|||
* Open the modal with area data
|
||||
*/
|
||||
open(center, radius) {
|
||||
console.log('[Area Creation V2] open() called with center:', center, 'radius:', radius)
|
||||
|
||||
// Store area data
|
||||
this.area = { center, radius }
|
||||
|
||||
|
|
@ -153,6 +156,9 @@ export default class extends Controller {
|
|||
* Show success message
|
||||
*/
|
||||
showSuccess(message) {
|
||||
// You can replace this with a toast notification if available
|
||||
console.log(message)
|
||||
|
||||
// Try to use the Toast component if available
|
||||
if (window.Toast) {
|
||||
window.Toast.show(message, 'success')
|
||||
|
|
|
|||
|
|
@ -22,11 +22,13 @@ export default class extends Controller {
|
|||
* @param {maplibregl.Map} map - The MapLibre map instance
|
||||
*/
|
||||
startDrawing(map) {
|
||||
console.log('[Area Drawer] startDrawing called with map:', map)
|
||||
if (!map) {
|
||||
console.error('[Area Drawer] Map instance not provided')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[Area Drawer] Starting drawing mode')
|
||||
this.isDrawing = true
|
||||
this.map = map
|
||||
map.getCanvas().style.cursor = 'crosshair'
|
||||
|
|
@ -95,9 +97,13 @@ export default class extends Controller {
|
|||
|
||||
if (!this.center) {
|
||||
// First click - set center
|
||||
console.log('[Area Drawer] First click - setting center:', e.lngLat)
|
||||
this.center = [e.lngLat.lng, e.lngLat.lat]
|
||||
} else {
|
||||
// Second click - finish drawing
|
||||
console.log('[Area Drawer] Second click - finishing drawing')
|
||||
|
||||
console.log('[Area Drawer] Dispatching area:drawn event')
|
||||
document.dispatchEvent(new CustomEvent('area:drawn', {
|
||||
detail: {
|
||||
center: this.center,
|
||||
|
|
|
|||
|
|
@ -11,57 +11,9 @@ export default class extends BaseController {
|
|||
connect() {
|
||||
console.log("Datetime controller connected")
|
||||
this.debounceTimer = null;
|
||||
|
||||
// Add validation listeners
|
||||
if (this.hasStartedAtTarget && this.hasEndedAtTarget) {
|
||||
// Validate on change to set validation state
|
||||
this.startedAtTarget.addEventListener('change', () => this.validateDates())
|
||||
this.endedAtTarget.addEventListener('change', () => this.validateDates())
|
||||
|
||||
// Validate on blur to set validation state
|
||||
this.startedAtTarget.addEventListener('blur', () => this.validateDates())
|
||||
this.endedAtTarget.addEventListener('blur', () => this.validateDates())
|
||||
|
||||
// Add form submit validation
|
||||
const form = this.element.closest('form')
|
||||
if (form) {
|
||||
form.addEventListener('submit', (e) => {
|
||||
if (!this.validateDates()) {
|
||||
e.preventDefault()
|
||||
this.endedAtTarget.reportValidity()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
validateDates(showPopup = false) {
|
||||
const startDate = new Date(this.startedAtTarget.value)
|
||||
const endDate = new Date(this.endedAtTarget.value)
|
||||
|
||||
// Clear any existing custom validity
|
||||
this.startedAtTarget.setCustomValidity('')
|
||||
this.endedAtTarget.setCustomValidity('')
|
||||
|
||||
// Check if both dates are valid
|
||||
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Validate that start date is before end date
|
||||
if (startDate >= endDate) {
|
||||
const errorMessage = 'Start date must be earlier than end date'
|
||||
this.endedAtTarget.setCustomValidity(errorMessage)
|
||||
if (showPopup) {
|
||||
this.endedAtTarget.reportValidity()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
async updateCoordinates() {
|
||||
async updateCoordinates(event) {
|
||||
// Clear any existing timeout
|
||||
if (this.debounceTimer) {
|
||||
clearTimeout(this.debounceTimer);
|
||||
|
|
@ -73,11 +25,6 @@ export default class extends BaseController {
|
|||
const endedAt = this.endedAtTarget.value
|
||||
const apiKey = this.apiKeyTarget.value
|
||||
|
||||
// Validate dates before making API call (don't show popup, already shown on change)
|
||||
if (!this.validateDates(false)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (startedAt && endedAt) {
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
|
|
|
|||
|
|
@ -7,8 +7,7 @@ export default class extends Controller {
|
|||
|
||||
static values = {
|
||||
features: Object,
|
||||
userTheme: String,
|
||||
timezone: String
|
||||
userTheme: String
|
||||
}
|
||||
|
||||
connect() {
|
||||
|
|
@ -107,8 +106,7 @@ export default class extends Controller {
|
|||
});
|
||||
|
||||
// Format timestamp for display
|
||||
const timezone = this.timezoneValue || 'UTC';
|
||||
const lastSeen = new Date(location.updated_at).toLocaleString('en-US', { timeZone: timezone });
|
||||
const lastSeen = new Date(location.updated_at).toLocaleString();
|
||||
|
||||
// Create small tooltip that shows automatically
|
||||
const tooltipContent = this.createTooltipContent(lastSeen, location.battery);
|
||||
|
|
@ -178,8 +176,7 @@ export default class extends Controller {
|
|||
existingMarker.setIcon(newIcon);
|
||||
|
||||
// Update tooltip content
|
||||
const timezone = this.timezoneValue || 'UTC';
|
||||
const lastSeen = new Date(locationData.updated_at).toLocaleString('en-US', { timeZone: timezone });
|
||||
const lastSeen = new Date(locationData.updated_at).toLocaleString();
|
||||
const tooltipContent = this.createTooltipContent(lastSeen, locationData.battery);
|
||||
existingMarker.setTooltipContent(tooltipContent);
|
||||
|
||||
|
|
@ -217,8 +214,7 @@ export default class extends Controller {
|
|||
})
|
||||
});
|
||||
|
||||
const timezone = this.timezoneValue || 'UTC';
|
||||
const lastSeen = new Date(location.updated_at).toLocaleString('en-US', { timeZone: timezone });
|
||||
const lastSeen = new Date(location.updated_at).toLocaleString();
|
||||
|
||||
const tooltipContent = this.createTooltipContent(lastSeen, location.battery);
|
||||
familyMarker.bindTooltip(tooltipContent, {
|
||||
|
|
|
|||
|
|
@ -26,23 +26,16 @@ export default class extends BaseController {
|
|||
received: (data) => {
|
||||
const row = this.element.querySelector(`tr[data-import-id="${data.import.id}"]`);
|
||||
|
||||
if (!row) return;
|
||||
if (row) {
|
||||
const pointsCell = row.querySelector('[data-points-count]');
|
||||
if (pointsCell) {
|
||||
pointsCell.textContent = new Intl.NumberFormat().format(data.import.points_count);
|
||||
}
|
||||
|
||||
// Handle deletion complete - remove the row
|
||||
if (data.action === 'delete') {
|
||||
row.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle status and points updates
|
||||
const pointsCell = row.querySelector('[data-points-count]');
|
||||
if (pointsCell && data.import.points_count !== undefined) {
|
||||
pointsCell.textContent = new Intl.NumberFormat().format(data.import.points_count);
|
||||
}
|
||||
|
||||
const statusCell = row.querySelector('[data-status-display]');
|
||||
if (statusCell && data.import.status) {
|
||||
statusCell.textContent = data.import.status;
|
||||
const statusCell = row.querySelector('[data-status-display]');
|
||||
if (statusCell && data.import.status) {
|
||||
statusCell.textContent = data.import.status;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ export class AreaSelectionManager {
|
|||
* Start area selection mode
|
||||
*/
|
||||
async startSelectArea() {
|
||||
console.log('[Maps V2] Starting area selection mode')
|
||||
|
||||
// Initialize selection layer if not exists
|
||||
if (!this.selectionLayer) {
|
||||
this.selectionLayer = new SelectionLayer(this.map, {
|
||||
|
|
@ -34,6 +36,8 @@ export class AreaSelectionManager {
|
|||
type: 'FeatureCollection',
|
||||
features: []
|
||||
})
|
||||
|
||||
console.log('[Maps V2] Selection layer initialized')
|
||||
}
|
||||
|
||||
// Initialize selected points layer if not exists
|
||||
|
|
@ -46,6 +50,8 @@ export class AreaSelectionManager {
|
|||
type: 'FeatureCollection',
|
||||
features: []
|
||||
})
|
||||
|
||||
console.log('[Maps V2] Selected points layer initialized')
|
||||
}
|
||||
|
||||
// Enable selection mode
|
||||
|
|
@ -70,6 +76,8 @@ export class AreaSelectionManager {
|
|||
* Handle area selection completion
|
||||
*/
|
||||
async handleAreaSelected(bounds) {
|
||||
console.log('[Maps V2] Area selected:', bounds)
|
||||
|
||||
try {
|
||||
Toast.info('Fetching data in selected area...')
|
||||
|
||||
|
|
@ -290,6 +298,7 @@ export class AreaSelectionManager {
|
|||
Toast.success('Visit declined')
|
||||
await this.refreshSelectedVisits()
|
||||
} catch (error) {
|
||||
console.error('[Maps V2] Failed to decline visit:', error)
|
||||
Toast.error('Failed to decline visit')
|
||||
}
|
||||
}
|
||||
|
|
@ -318,6 +327,7 @@ export class AreaSelectionManager {
|
|||
this.replaceVisitsWithMerged(visitIds, mergedVisit)
|
||||
this.updateBulkActions()
|
||||
} catch (error) {
|
||||
console.error('[Maps V2] Failed to merge visits:', error)
|
||||
Toast.error('Failed to merge visits')
|
||||
}
|
||||
}
|
||||
|
|
@ -336,6 +346,7 @@ export class AreaSelectionManager {
|
|||
this.selectedVisitIds.clear()
|
||||
await this.refreshSelectedVisits()
|
||||
} catch (error) {
|
||||
console.error('[Maps V2] Failed to confirm visits:', error)
|
||||
Toast.error('Failed to confirm visits')
|
||||
}
|
||||
}
|
||||
|
|
@ -440,6 +451,8 @@ export class AreaSelectionManager {
|
|||
* Cancel area selection
|
||||
*/
|
||||
cancelAreaSelection() {
|
||||
console.log('[Maps V2] Cancelling area selection')
|
||||
|
||||
if (this.selectionLayer) {
|
||||
this.selectionLayer.disableSelectionMode()
|
||||
this.selectionLayer.clearSelection()
|
||||
|
|
@ -502,10 +515,14 @@ export class AreaSelectionManager {
|
|||
|
||||
if (!confirmed) return
|
||||
|
||||
console.log('[Maps V2] Deleting', pointIds.length, 'points')
|
||||
|
||||
try {
|
||||
Toast.info('Deleting points...')
|
||||
const result = await this.api.bulkDeletePoints(pointIds)
|
||||
|
||||
console.log('[Maps V2] Deleted', result.count, 'points')
|
||||
|
||||
this.cancelAreaSelection()
|
||||
|
||||
await this.controller.loadMapData({
|
||||
|
|
|
|||
|
|
@ -7,17 +7,9 @@ import { performanceMonitor } from 'maps_maplibre/utils/performance_monitor'
|
|||
* Handles loading and transforming data from API
|
||||
*/
|
||||
export class DataLoader {
|
||||
constructor(api, apiKey, settings = {}) {
|
||||
constructor(api, apiKey) {
|
||||
this.api = api
|
||||
this.apiKey = apiKey
|
||||
this.settings = settings
|
||||
}
|
||||
|
||||
/**
|
||||
* Update settings (called when user changes settings)
|
||||
*/
|
||||
updateSettings(settings) {
|
||||
this.settings = settings
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -38,10 +30,7 @@ export class DataLoader {
|
|||
// Transform points to GeoJSON
|
||||
performanceMonitor.mark('transform-geojson')
|
||||
data.pointsGeoJSON = pointsToGeoJSON(data.points)
|
||||
data.routesGeoJSON = RoutesLayer.pointsToRoutes(data.points, {
|
||||
distanceThresholdMeters: this.settings.metersBetweenRoutes || 500,
|
||||
timeThresholdMinutes: this.settings.minutesBetweenRoutes || 60
|
||||
})
|
||||
data.routesGeoJSON = RoutesLayer.pointsToRoutes(data.points)
|
||||
performanceMonitor.measure('transform-geojson')
|
||||
|
||||
// Fetch visits
|
||||
|
|
@ -56,36 +45,22 @@ export class DataLoader {
|
|||
}
|
||||
data.visitsGeoJSON = this.visitsToGeoJSON(data.visits)
|
||||
|
||||
// Fetch photos - only if photos layer is enabled and integration is configured
|
||||
// Skip API call if photos are disabled to avoid blocking on failed integrations
|
||||
if (this.settings.photosEnabled) {
|
||||
try {
|
||||
console.log('[Photos] Fetching photos from:', startDate, 'to', endDate)
|
||||
// Use Promise.race to enforce a client-side timeout
|
||||
const photosPromise = this.api.fetchPhotos({
|
||||
start_at: startDate,
|
||||
end_at: endDate
|
||||
})
|
||||
const timeoutPromise = new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Photo fetch timeout')), 15000) // 15 second timeout
|
||||
)
|
||||
|
||||
data.photos = await Promise.race([photosPromise, timeoutPromise])
|
||||
console.log('[Photos] Fetched photos:', data.photos.length, 'photos')
|
||||
console.log('[Photos] Sample photo:', data.photos[0])
|
||||
} catch (error) {
|
||||
console.warn('[Photos] Failed to fetch photos (non-blocking):', error.message)
|
||||
data.photos = []
|
||||
}
|
||||
} else {
|
||||
console.log('[Photos] Photos layer disabled, skipping fetch')
|
||||
// Fetch photos
|
||||
try {
|
||||
console.log('[Photos] Fetching photos from:', startDate, 'to', endDate)
|
||||
data.photos = await this.api.fetchPhotos({
|
||||
start_at: startDate,
|
||||
end_at: endDate
|
||||
})
|
||||
console.log('[Photos] Fetched photos:', data.photos.length, 'photos')
|
||||
console.log('[Photos] Sample photo:', data.photos[0])
|
||||
} catch (error) {
|
||||
console.error('[Photos] Failed to fetch photos:', error)
|
||||
data.photos = []
|
||||
}
|
||||
data.photosGeoJSON = this.photosToGeoJSON(data.photos)
|
||||
console.log('[Photos] Converted to GeoJSON:', data.photosGeoJSON.features.length, 'features')
|
||||
if (data.photosGeoJSON.features.length > 0) {
|
||||
console.log('[Photos] Sample feature:', data.photosGeoJSON.features[0])
|
||||
}
|
||||
console.log('[Photos] Sample feature:', data.photosGeoJSON.features[0])
|
||||
|
||||
// Fetch areas
|
||||
try {
|
||||
|
|
@ -105,16 +80,10 @@ export class DataLoader {
|
|||
}
|
||||
data.placesGeoJSON = this.placesToGeoJSON(data.places)
|
||||
|
||||
// Fetch tracks
|
||||
try {
|
||||
data.tracksGeoJSON = await this.api.fetchTracks({
|
||||
start_at: startDate,
|
||||
end_at: endDate
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn('[Tracks] Failed to fetch tracks (non-blocking):', error.message)
|
||||
data.tracksGeoJSON = { type: 'FeatureCollection', features: [] }
|
||||
}
|
||||
// Tracks - DISABLED: Backend API not yet implemented
|
||||
// TODO: Re-enable when /api/v1/tracks endpoint is created
|
||||
data.tracks = []
|
||||
data.tracksGeoJSON = this.tracksToGeoJSON(data.tracks)
|
||||
|
||||
return data
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
import { formatTimestamp } from 'maps_maplibre/utils/geojson_transformers'
|
||||
import { formatDistance, formatSpeed, minutesToDaysHoursMinutes } from 'maps/helpers'
|
||||
import maplibregl from 'maplibre-gl'
|
||||
|
||||
/**
|
||||
* Handles map interaction events (clicks, info display)
|
||||
|
|
@ -9,8 +7,6 @@ export class EventHandlers {
|
|||
constructor(map, controller) {
|
||||
this.map = map
|
||||
this.controller = controller
|
||||
this.selectedRouteFeature = null
|
||||
this.routeMarkers = [] // Store start/end markers for routes
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -22,7 +18,7 @@ export class EventHandlers {
|
|||
|
||||
const content = `
|
||||
<div class="space-y-2">
|
||||
<div><span class="font-semibold">Time:</span> ${formatTimestamp(properties.timestamp, this.controller.timezoneValue)}</div>
|
||||
<div><span class="font-semibold">Time:</span> ${formatTimestamp(properties.timestamp)}</div>
|
||||
${properties.battery ? `<div><span class="font-semibold">Battery:</span> ${properties.battery}%</div>` : ''}
|
||||
${properties.altitude ? `<div><span class="font-semibold">Altitude:</span> ${Math.round(properties.altitude)}m</div>` : ''}
|
||||
${properties.velocity ? `<div><span class="font-semibold">Speed:</span> ${Math.round(properties.velocity)} km/h</div>` : ''}
|
||||
|
|
@ -39,8 +35,8 @@ export class EventHandlers {
|
|||
const feature = e.features[0]
|
||||
const properties = feature.properties
|
||||
|
||||
const startTime = formatTimestamp(properties.started_at, this.controller.timezoneValue)
|
||||
const endTime = formatTimestamp(properties.ended_at, this.controller.timezoneValue)
|
||||
const startTime = formatTimestamp(properties.started_at)
|
||||
const endTime = formatTimestamp(properties.ended_at)
|
||||
const durationHours = Math.round(properties.duration / 3600)
|
||||
const durationDisplay = durationHours >= 1 ? `${durationHours}h` : `${Math.round(properties.duration / 60)}m`
|
||||
|
||||
|
|
@ -53,13 +49,7 @@ export class EventHandlers {
|
|||
</div>
|
||||
`
|
||||
|
||||
const actions = [{
|
||||
type: 'button',
|
||||
handler: 'handleEdit',
|
||||
id: properties.id,
|
||||
entityType: 'visit',
|
||||
label: 'Edit'
|
||||
}]
|
||||
const actions = [{ url: `/visits/${properties.id}`, label: 'View Details →' }]
|
||||
|
||||
this.controller.showInfo(properties.name || properties.place_name || 'Visit', content, actions)
|
||||
}
|
||||
|
|
@ -74,7 +64,7 @@ export class EventHandlers {
|
|||
const content = `
|
||||
<div class="space-y-2">
|
||||
${properties.photo_url ? `<img src="${properties.photo_url}" alt="Photo" class="w-full rounded-lg mb-2" />` : ''}
|
||||
${properties.taken_at ? `<div><span class="font-semibold">Taken:</span> ${formatTimestamp(properties.taken_at, this.controller.timezoneValue)}</div>` : ''}
|
||||
${properties.taken_at ? `<div><span class="font-semibold">Taken:</span> ${formatTimestamp(properties.taken_at)}</div>` : ''}
|
||||
</div>
|
||||
`
|
||||
|
||||
|
|
@ -95,13 +85,7 @@ export class EventHandlers {
|
|||
</div>
|
||||
`
|
||||
|
||||
const actions = properties.id ? [{
|
||||
type: 'button',
|
||||
handler: 'handleEdit',
|
||||
id: properties.id,
|
||||
entityType: 'place',
|
||||
label: 'Edit'
|
||||
}] : []
|
||||
const actions = properties.id ? [{ url: `/places/${properties.id}`, label: 'View Details →' }] : []
|
||||
|
||||
this.controller.showInfo(properties.name || 'Place', content, actions)
|
||||
}
|
||||
|
|
@ -120,271 +104,8 @@ export class EventHandlers {
|
|||
</div>
|
||||
`
|
||||
|
||||
const actions = properties.id ? [{
|
||||
type: 'button',
|
||||
handler: 'handleDelete',
|
||||
id: properties.id,
|
||||
entityType: 'area',
|
||||
label: 'Delete'
|
||||
}] : []
|
||||
const actions = properties.id ? [{ url: `/areas/${properties.id}`, label: 'View Details →' }] : []
|
||||
|
||||
this.controller.showInfo(properties.name || 'Area', content, actions)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle route hover
|
||||
*/
|
||||
handleRouteHover(e) {
|
||||
const clickedFeature = e.features[0]
|
||||
if (!clickedFeature) return
|
||||
|
||||
const routesLayer = this.controller.layerManager.getLayer('routes')
|
||||
if (!routesLayer) return
|
||||
|
||||
// Get the full feature from source (not the clipped tile version)
|
||||
// Fallback to clipped feature if full feature not found
|
||||
const fullFeature = this._getFullRouteFeature(clickedFeature.properties) || clickedFeature
|
||||
|
||||
// If a route is selected and we're hovering over a different route, show both
|
||||
if (this.selectedRouteFeature) {
|
||||
// Check if we're hovering over the same route that's selected
|
||||
const isSameRoute = this._areFeaturesSame(this.selectedRouteFeature, fullFeature)
|
||||
|
||||
if (!isSameRoute) {
|
||||
// Show both selected and hovered routes
|
||||
const features = [this.selectedRouteFeature, fullFeature]
|
||||
routesLayer.setHoverRoute({
|
||||
type: 'FeatureCollection',
|
||||
features: features
|
||||
})
|
||||
// Create markers for both routes
|
||||
this._createRouteMarkers(features)
|
||||
}
|
||||
} else {
|
||||
// No selection, just show hovered route
|
||||
routesLayer.setHoverRoute(fullFeature)
|
||||
// Create markers for hovered route
|
||||
this._createRouteMarkers(fullFeature)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle route mouse leave
|
||||
*/
|
||||
handleRouteMouseLeave(e) {
|
||||
const routesLayer = this.controller.layerManager.getLayer('routes')
|
||||
if (!routesLayer) return
|
||||
|
||||
// If a route is selected, keep showing only the selected route
|
||||
if (this.selectedRouteFeature) {
|
||||
routesLayer.setHoverRoute(this.selectedRouteFeature)
|
||||
// Keep markers for selected route only
|
||||
this._createRouteMarkers(this.selectedRouteFeature)
|
||||
} else {
|
||||
// No selection, clear hover and markers
|
||||
routesLayer.setHoverRoute(null)
|
||||
this._clearRouteMarkers()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get full route feature from source data (not clipped tile version)
|
||||
* MapLibre returns clipped geometries from queryRenderedFeatures()
|
||||
* We need the full geometry from the source for proper highlighting
|
||||
*/
|
||||
_getFullRouteFeature(properties) {
|
||||
const routesLayer = this.controller.layerManager.getLayer('routes')
|
||||
if (!routesLayer) return null
|
||||
|
||||
const source = this.map.getSource(routesLayer.sourceId)
|
||||
if (!source) return null
|
||||
|
||||
// Get the source data (GeoJSON FeatureCollection)
|
||||
// Try multiple ways to access the data
|
||||
let sourceData = null
|
||||
|
||||
// Method 1: Internal _data property (most common)
|
||||
if (source._data) {
|
||||
sourceData = source._data
|
||||
}
|
||||
// Method 2: Serialize and deserialize (fallback)
|
||||
else if (source.serialize) {
|
||||
const serialized = source.serialize()
|
||||
sourceData = serialized.data
|
||||
}
|
||||
// Method 3: Use cached data from layer
|
||||
else if (routesLayer.data) {
|
||||
sourceData = routesLayer.data
|
||||
}
|
||||
|
||||
if (!sourceData || !sourceData.features) return null
|
||||
|
||||
// Find the matching feature by properties
|
||||
// First try to match by unique ID (most reliable)
|
||||
if (properties.id) {
|
||||
const featureById = sourceData.features.find(f => f.properties.id === properties.id)
|
||||
if (featureById) return featureById
|
||||
}
|
||||
if (properties.routeId) {
|
||||
const featureByRouteId = sourceData.features.find(f => f.properties.routeId === properties.routeId)
|
||||
if (featureByRouteId) return featureByRouteId
|
||||
}
|
||||
|
||||
// Fall back to matching by start/end times and point count
|
||||
return sourceData.features.find(feature => {
|
||||
const props = feature.properties
|
||||
return props.startTime === properties.startTime &&
|
||||
props.endTime === properties.endTime &&
|
||||
props.pointCount === properties.pointCount
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two features to see if they represent the same route
|
||||
*/
|
||||
_areFeaturesSame(feature1, feature2) {
|
||||
if (!feature1 || !feature2) return false
|
||||
|
||||
const props1 = feature1.properties
|
||||
const props2 = feature2.properties
|
||||
|
||||
// First check for unique route identifier (most reliable)
|
||||
if (props1.id && props2.id) {
|
||||
return props1.id === props2.id
|
||||
}
|
||||
if (props1.routeId && props2.routeId) {
|
||||
return props1.routeId === props2.routeId
|
||||
}
|
||||
|
||||
// Fall back to comparing start/end times and point count
|
||||
return props1.startTime === props2.startTime &&
|
||||
props1.endTime === props2.endTime &&
|
||||
props1.pointCount === props2.pointCount
|
||||
}
|
||||
|
||||
/**
|
||||
* Create start/end markers for route(s)
|
||||
* @param {Array|Object} features - Single feature or array of features
|
||||
*/
|
||||
_createRouteMarkers(features) {
|
||||
// Clear existing markers first
|
||||
this._clearRouteMarkers()
|
||||
|
||||
// Ensure we have an array
|
||||
const featureArray = Array.isArray(features) ? features : [features]
|
||||
|
||||
featureArray.forEach(feature => {
|
||||
if (!feature || !feature.geometry || feature.geometry.type !== 'LineString') return
|
||||
|
||||
const coords = feature.geometry.coordinates
|
||||
if (coords.length < 2) return
|
||||
|
||||
// Start marker (🚥)
|
||||
const startCoord = coords[0]
|
||||
const startMarker = this._createEmojiMarker('🚥')
|
||||
startMarker.setLngLat(startCoord).addTo(this.map)
|
||||
this.routeMarkers.push(startMarker)
|
||||
|
||||
// End marker (🏁)
|
||||
const endCoord = coords[coords.length - 1]
|
||||
const endMarker = this._createEmojiMarker('🏁')
|
||||
endMarker.setLngLat(endCoord).addTo(this.map)
|
||||
this.routeMarkers.push(endMarker)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an emoji marker
|
||||
* @param {String} emoji - The emoji to display
|
||||
* @returns {maplibregl.Marker}
|
||||
*/
|
||||
_createEmojiMarker(emoji) {
|
||||
const el = document.createElement('div')
|
||||
el.className = 'route-emoji-marker'
|
||||
el.textContent = emoji
|
||||
el.style.fontSize = '24px'
|
||||
el.style.cursor = 'pointer'
|
||||
el.style.userSelect = 'none'
|
||||
|
||||
return new maplibregl.Marker({ element: el, anchor: 'center' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all route markers
|
||||
*/
|
||||
_clearRouteMarkers() {
|
||||
this.routeMarkers.forEach(marker => marker.remove())
|
||||
this.routeMarkers = []
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle route click
|
||||
*/
|
||||
handleRouteClick(e) {
|
||||
const clickedFeature = e.features[0]
|
||||
const properties = clickedFeature.properties
|
||||
|
||||
// Get the full feature from source (not the clipped tile version)
|
||||
// Fallback to clipped feature if full feature not found
|
||||
const fullFeature = this._getFullRouteFeature(properties) || clickedFeature
|
||||
|
||||
// Store selected route (use full feature)
|
||||
this.selectedRouteFeature = fullFeature
|
||||
|
||||
// Update hover layer to show selected route
|
||||
const routesLayer = this.controller.layerManager.getLayer('routes')
|
||||
if (routesLayer) {
|
||||
routesLayer.setHoverRoute(fullFeature)
|
||||
}
|
||||
|
||||
// Create markers for selected route
|
||||
this._createRouteMarkers(fullFeature)
|
||||
|
||||
// Calculate duration
|
||||
const durationSeconds = properties.endTime - properties.startTime
|
||||
const durationMinutes = Math.floor(durationSeconds / 60)
|
||||
const durationFormatted = minutesToDaysHoursMinutes(durationMinutes)
|
||||
|
||||
// Calculate average speed
|
||||
let avgSpeed = properties.speed
|
||||
if (!avgSpeed && properties.distance > 0 && durationSeconds > 0) {
|
||||
avgSpeed = (properties.distance / durationSeconds) * 3600 // km/h
|
||||
}
|
||||
|
||||
// Get user preferences
|
||||
const distanceUnit = this.controller.settings.distance_unit || 'km'
|
||||
|
||||
// Prepare route data object
|
||||
const routeData = {
|
||||
startTime: formatTimestamp(properties.startTime, this.controller.timezoneValue),
|
||||
endTime: formatTimestamp(properties.endTime, this.controller.timezoneValue),
|
||||
duration: durationFormatted,
|
||||
distance: formatDistance(properties.distance, distanceUnit),
|
||||
speed: avgSpeed ? formatSpeed(avgSpeed, distanceUnit) : null,
|
||||
pointCount: properties.pointCount
|
||||
}
|
||||
|
||||
// Call controller method to display route info
|
||||
this.controller.showRouteInfo(routeData)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear route selection
|
||||
*/
|
||||
clearRouteSelection() {
|
||||
if (!this.selectedRouteFeature) return
|
||||
|
||||
this.selectedRouteFeature = null
|
||||
|
||||
const routesLayer = this.controller.layerManager.getLayer('routes')
|
||||
if (routesLayer) {
|
||||
routesLayer.setHoverRoute(null)
|
||||
}
|
||||
|
||||
// Clear markers
|
||||
this._clearRouteMarkers()
|
||||
|
||||
// Close info panel
|
||||
this.controller.closeInfo()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import { TracksLayer } from 'maps_maplibre/layers/tracks_layer'
|
|||
import { PlacesLayer } from 'maps_maplibre/layers/places_layer'
|
||||
import { FogLayer } from 'maps_maplibre/layers/fog_layer'
|
||||
import { FamilyLayer } from 'maps_maplibre/layers/family_layer'
|
||||
import { RecentPointLayer } from 'maps_maplibre/layers/recent_point_layer'
|
||||
import { lazyLoader } from 'maps_maplibre/utils/lazy_loader'
|
||||
import { performanceMonitor } from 'maps_maplibre/utils/performance_monitor'
|
||||
|
||||
|
|
@ -21,7 +20,6 @@ export class LayerManager {
|
|||
this.settings = settings
|
||||
this.api = api
|
||||
this.layers = {}
|
||||
this.eventHandlersSetup = false
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -31,8 +29,7 @@ export class LayerManager {
|
|||
performanceMonitor.mark('add-layers')
|
||||
|
||||
// Layer order matters - layers added first render below layers added later
|
||||
// Order: scratch (bottom) -> heatmap -> areas -> tracks -> routes (visual) -> visits -> places -> photos -> family -> points -> routes-hit (interaction) -> recent-point (top) -> fog (canvas overlay)
|
||||
// Note: routes-hit is above points visually but points dragging takes precedence via event ordering
|
||||
// Order: scratch (bottom) -> heatmap -> areas -> tracks -> routes -> visits -> places -> photos -> family -> points (top) -> fog (canvas overlay)
|
||||
|
||||
await this._addScratchLayer(pointsGeoJSON)
|
||||
this._addHeatmapLayer(pointsGeoJSON)
|
||||
|
|
@ -51,8 +48,6 @@ export class LayerManager {
|
|||
|
||||
this._addFamilyLayer()
|
||||
this._addPointsLayer(pointsGeoJSON)
|
||||
this._addRoutesHitLayer() // Add hit target layer after points, will be on top visually
|
||||
this._addRecentPointLayer()
|
||||
this._addFogLayer(pointsGeoJSON)
|
||||
|
||||
performanceMonitor.measure('add-layers')
|
||||
|
|
@ -60,13 +55,8 @@ export class LayerManager {
|
|||
|
||||
/**
|
||||
* Setup event handlers for layer interactions
|
||||
* Only sets up handlers once to prevent duplicates
|
||||
*/
|
||||
setupLayerEventHandlers(handlers) {
|
||||
if (this.eventHandlersSetup) {
|
||||
return
|
||||
}
|
||||
|
||||
// Click handlers
|
||||
this.map.on('click', 'points', handlers.handlePointClick)
|
||||
this.map.on('click', 'visits', handlers.handleVisitClick)
|
||||
|
|
@ -77,11 +67,6 @@ export class LayerManager {
|
|||
this.map.on('click', 'areas-outline', handlers.handleAreaClick)
|
||||
this.map.on('click', 'areas-labels', handlers.handleAreaClick)
|
||||
|
||||
// Route handlers - use routes-hit layer for better interactivity
|
||||
this.map.on('click', 'routes-hit', handlers.handleRouteClick)
|
||||
this.map.on('mouseenter', 'routes-hit', handlers.handleRouteHover)
|
||||
this.map.on('mouseleave', 'routes-hit', handlers.handleRouteMouseLeave)
|
||||
|
||||
// Cursor change on hover
|
||||
this.map.on('mouseenter', 'points', () => {
|
||||
this.map.getCanvas().style.cursor = 'pointer'
|
||||
|
|
@ -107,36 +92,25 @@ export class LayerManager {
|
|||
this.map.on('mouseleave', 'places', () => {
|
||||
this.map.getCanvas().style.cursor = ''
|
||||
})
|
||||
// Route cursor handlers - use routes-hit layer
|
||||
this.map.on('mouseenter', 'routes-hit', () => {
|
||||
// Areas hover handlers for all sub-layers
|
||||
this.map.on('mouseenter', 'areas-fill', () => {
|
||||
this.map.getCanvas().style.cursor = 'pointer'
|
||||
})
|
||||
this.map.on('mouseleave', 'routes-hit', () => {
|
||||
this.map.on('mouseleave', 'areas-fill', () => {
|
||||
this.map.getCanvas().style.cursor = ''
|
||||
})
|
||||
// Areas hover handlers for all sub-layers
|
||||
const areaLayers = ['areas-fill', 'areas-outline', 'areas-labels']
|
||||
areaLayers.forEach(layerId => {
|
||||
// Only add handlers if layer exists
|
||||
if (this.map.getLayer(layerId)) {
|
||||
this.map.on('mouseenter', layerId, () => {
|
||||
this.map.getCanvas().style.cursor = 'pointer'
|
||||
})
|
||||
this.map.on('mouseleave', layerId, () => {
|
||||
this.map.getCanvas().style.cursor = ''
|
||||
})
|
||||
}
|
||||
this.map.on('mouseenter', 'areas-outline', () => {
|
||||
this.map.getCanvas().style.cursor = 'pointer'
|
||||
})
|
||||
|
||||
// Map-level click to deselect routes
|
||||
this.map.on('click', (e) => {
|
||||
const routeFeatures = this.map.queryRenderedFeatures(e.point, { layers: ['routes-hit'] })
|
||||
if (routeFeatures.length === 0) {
|
||||
handlers.clearRouteSelection()
|
||||
}
|
||||
this.map.on('mouseleave', 'areas-outline', () => {
|
||||
this.map.getCanvas().style.cursor = ''
|
||||
})
|
||||
this.map.on('mouseenter', 'areas-labels', () => {
|
||||
this.map.getCanvas().style.cursor = 'pointer'
|
||||
})
|
||||
this.map.on('mouseleave', 'areas-labels', () => {
|
||||
this.map.getCanvas().style.cursor = ''
|
||||
})
|
||||
|
||||
this.eventHandlersSetup = true
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -162,7 +136,6 @@ export class LayerManager {
|
|||
*/
|
||||
clearLayerReferences() {
|
||||
this.layers = {}
|
||||
this.eventHandlersSetup = false
|
||||
}
|
||||
|
||||
// Private methods for individual layer management
|
||||
|
|
@ -228,32 +201,6 @@ export class LayerManager {
|
|||
}
|
||||
}
|
||||
|
||||
_addRoutesHitLayer() {
|
||||
// Add invisible hit target layer for routes
|
||||
// Use beforeId to place it BELOW points layer so points remain draggable on top
|
||||
if (!this.map.getLayer('routes-hit') && this.map.getSource('routes-source')) {
|
||||
this.map.addLayer({
|
||||
id: 'routes-hit',
|
||||
type: 'line',
|
||||
source: 'routes-source',
|
||||
layout: {
|
||||
'line-join': 'round',
|
||||
'line-cap': 'round'
|
||||
},
|
||||
paint: {
|
||||
'line-color': 'transparent',
|
||||
'line-width': 20, // Much wider for easier clicking/hovering
|
||||
'line-opacity': 0
|
||||
}
|
||||
}, 'points') // Add before 'points' layer so points are on top for interaction
|
||||
// Match visibility with routes layer
|
||||
const routesLayer = this.layers.routesLayer
|
||||
if (routesLayer && !routesLayer.visible) {
|
||||
this.map.setLayoutProperty('routes-hit', 'visibility', 'none')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_addVisitsLayer(visitsGeoJSON) {
|
||||
if (!this.layers.visitsLayer) {
|
||||
this.layers.visitsLayer = new VisitsLayer(this.map, {
|
||||
|
|
@ -304,9 +251,7 @@ export class LayerManager {
|
|||
_addPointsLayer(pointsGeoJSON) {
|
||||
if (!this.layers.pointsLayer) {
|
||||
this.layers.pointsLayer = new PointsLayer(this.map, {
|
||||
visible: this.settings.pointsVisible !== false, // Default true unless explicitly false
|
||||
apiClient: this.api,
|
||||
layerManager: this
|
||||
visible: this.settings.pointsVisible !== false // Default true unless explicitly false
|
||||
})
|
||||
this.layers.pointsLayer.add(pointsGeoJSON)
|
||||
} else {
|
||||
|
|
@ -314,20 +259,11 @@ export class LayerManager {
|
|||
}
|
||||
}
|
||||
|
||||
_addRecentPointLayer() {
|
||||
if (!this.layers.recentPointLayer) {
|
||||
this.layers.recentPointLayer = new RecentPointLayer(this.map, {
|
||||
visible: false // Initially hidden, shown only when live mode is enabled
|
||||
})
|
||||
this.layers.recentPointLayer.add({ type: 'FeatureCollection', features: [] })
|
||||
}
|
||||
}
|
||||
|
||||
_addFogLayer(pointsGeoJSON) {
|
||||
// Always create fog layer for backward compatibility
|
||||
if (!this.layers.fogLayer) {
|
||||
this.layers.fogLayer = new FogLayer(this.map, {
|
||||
clearRadius: this.settings.fogOfWarRadius || 1000,
|
||||
clearRadius: 1000,
|
||||
visible: this.settings.fogEnabled || false
|
||||
})
|
||||
this.layers.fogLayer.add(pointsGeoJSON)
|
||||
|
|
|
|||
|
|
@ -90,31 +90,22 @@ export class MapDataManager {
|
|||
data.placesGeoJSON
|
||||
)
|
||||
|
||||
// Setup event handlers after layers are added
|
||||
this.layerManager.setupLayerEventHandlers({
|
||||
handlePointClick: this.eventHandlers.handlePointClick.bind(this.eventHandlers),
|
||||
handleVisitClick: this.eventHandlers.handleVisitClick.bind(this.eventHandlers),
|
||||
handlePhotoClick: this.eventHandlers.handlePhotoClick.bind(this.eventHandlers),
|
||||
handlePlaceClick: this.eventHandlers.handlePlaceClick.bind(this.eventHandlers),
|
||||
handleAreaClick: this.eventHandlers.handleAreaClick.bind(this.eventHandlers),
|
||||
handleRouteClick: this.eventHandlers.handleRouteClick.bind(this.eventHandlers),
|
||||
handleRouteHover: this.eventHandlers.handleRouteHover.bind(this.eventHandlers),
|
||||
handleRouteMouseLeave: this.eventHandlers.handleRouteMouseLeave.bind(this.eventHandlers),
|
||||
clearRouteSelection: this.eventHandlers.clearRouteSelection.bind(this.eventHandlers)
|
||||
handleAreaClick: this.eventHandlers.handleAreaClick.bind(this.eventHandlers)
|
||||
})
|
||||
}
|
||||
|
||||
// Always use Promise-based approach for consistent timing
|
||||
await new Promise((resolve) => {
|
||||
if (this.map.loaded()) {
|
||||
addAllLayers().then(resolve)
|
||||
} else {
|
||||
this.map.once('load', async () => {
|
||||
await addAllLayers()
|
||||
resolve()
|
||||
})
|
||||
}
|
||||
})
|
||||
if (this.map.loaded()) {
|
||||
await addAllLayers()
|
||||
} else {
|
||||
this.map.once('load', async () => {
|
||||
await addAllLayers()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -16,35 +16,17 @@ export class MapInitializer {
|
|||
mapStyle = 'streets',
|
||||
center = [0, 0],
|
||||
zoom = 2,
|
||||
showControls = true,
|
||||
globeProjection = false
|
||||
showControls = true
|
||||
} = settings
|
||||
|
||||
const style = await getMapStyle(mapStyle)
|
||||
|
||||
const mapOptions = {
|
||||
const map = new maplibregl.Map({
|
||||
container,
|
||||
style,
|
||||
center,
|
||||
zoom
|
||||
}
|
||||
|
||||
const map = new maplibregl.Map(mapOptions)
|
||||
|
||||
// Set globe projection after map loads
|
||||
if (globeProjection === true || globeProjection === 'true') {
|
||||
map.on('load', () => {
|
||||
map.setProjection({ type: 'globe' })
|
||||
|
||||
// Add atmosphere effect
|
||||
map.setSky({
|
||||
'atmosphere-blend': [
|
||||
'interpolate', ['linear'], ['zoom'],
|
||||
0, 1, 5, 1, 7, 0
|
||||
]
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if (showControls) {
|
||||
map.addControl(new maplibregl.NavigationControl(), 'top-right')
|
||||
|
|
|
|||
|
|
@ -216,6 +216,8 @@ export class PlacesManager {
|
|||
* Start create place mode
|
||||
*/
|
||||
startCreatePlace() {
|
||||
console.log('[Maps V2] Starting create place mode')
|
||||
|
||||
if (this.controller.hasSettingsPanelTarget && this.controller.settingsPanelTarget.classList.contains('open')) {
|
||||
this.controller.toggleSettings()
|
||||
}
|
||||
|
|
@ -240,6 +242,8 @@ export class PlacesManager {
|
|||
* Handle place creation event - reload places and update layer
|
||||
*/
|
||||
async handlePlaceCreated(event) {
|
||||
console.log('[Maps V2] Place created, reloading places...', event.detail)
|
||||
|
||||
try {
|
||||
const selectedTags = this.getSelectedPlaceTags()
|
||||
|
||||
|
|
@ -247,6 +251,8 @@ export class PlacesManager {
|
|||
tag_ids: selectedTags
|
||||
})
|
||||
|
||||
console.log('[Maps V2] Fetched places:', places.length)
|
||||
|
||||
const placesGeoJSON = this.dataLoader.placesToGeoJSON(places)
|
||||
|
||||
console.log('[Maps V2] Converted to GeoJSON:', placesGeoJSON.features.length, 'features')
|
||||
|
|
@ -254,6 +260,7 @@ export class PlacesManager {
|
|||
const placesLayer = this.layerManager.getLayer('places')
|
||||
if (placesLayer) {
|
||||
placesLayer.update(placesGeoJSON)
|
||||
console.log('[Maps V2] Places layer updated successfully')
|
||||
} else {
|
||||
console.warn('[Maps V2] Places layer not found, cannot update')
|
||||
}
|
||||
|
|
@ -261,11 +268,4 @@ export class PlacesManager {
|
|||
console.error('[Maps V2] Failed to reload places:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle place update event - reload places and update layer
|
||||
*/
|
||||
async handlePlaceUpdated(event) {
|
||||
await this.handlePlaceCreated(event)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -173,7 +173,7 @@ export class RoutesManager {
|
|||
timestamp: f.properties.timestamp
|
||||
})) || []
|
||||
|
||||
const distanceThresholdMeters = this.settings.metersBetweenRoutes || 1000
|
||||
const distanceThresholdMeters = this.settings.metersBetweenRoutes || 500
|
||||
const timeThresholdMinutes = this.settings.minutesBetweenRoutes || 60
|
||||
|
||||
const { calculateSpeed, getSpeedColor } = await import('maps_maplibre/utils/speed_colors')
|
||||
|
|
@ -357,28 +357,4 @@ export class RoutesManager {
|
|||
|
||||
SettingsManager.updateSetting('pointsVisible', visible)
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle family members layer
|
||||
*/
|
||||
async toggleFamily(event) {
|
||||
const enabled = event.target.checked
|
||||
SettingsManager.updateSetting('familyEnabled', enabled)
|
||||
|
||||
const familyLayer = this.layerManager.getLayer('family')
|
||||
if (familyLayer) {
|
||||
if (enabled) {
|
||||
familyLayer.show()
|
||||
// Load family members data
|
||||
await this.controller.loadFamilyMembers()
|
||||
} else {
|
||||
familyLayer.hide()
|
||||
}
|
||||
}
|
||||
|
||||
// Show/hide the family members list
|
||||
if (this.controller.hasFamilyMembersListTarget) {
|
||||
this.controller.familyMembersListTarget.style.display = enabled ? 'block' : 'none'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,17 +22,12 @@ export class SettingsController {
|
|||
}
|
||||
|
||||
/**
|
||||
* Load settings (sync from backend)
|
||||
* Load settings (sync from backend and localStorage)
|
||||
*/
|
||||
async loadSettings() {
|
||||
this.settings = await SettingsManager.sync()
|
||||
this.controller.settings = this.settings
|
||||
|
||||
// Update dataLoader with new settings
|
||||
if (this.controller.dataLoader) {
|
||||
this.controller.dataLoader.updateSettings(this.settings)
|
||||
}
|
||||
|
||||
console.log('[Maps V2] Settings loaded:', this.settings)
|
||||
return this.settings
|
||||
}
|
||||
|
||||
|
|
@ -53,14 +48,12 @@ export class SettingsController {
|
|||
placesToggle: 'placesEnabled',
|
||||
fogToggle: 'fogEnabled',
|
||||
scratchToggle: 'scratchEnabled',
|
||||
familyToggle: 'familyEnabled',
|
||||
speedColoredToggle: 'speedColoredRoutesEnabled'
|
||||
}
|
||||
|
||||
Object.entries(toggleMap).forEach(([targetName, settingKey]) => {
|
||||
const target = `${targetName}Target`
|
||||
const hasTarget = `has${targetName.charAt(0).toUpperCase()}${targetName.slice(1)}Target`
|
||||
if (controller[hasTarget]) {
|
||||
if (controller[target]) {
|
||||
controller[target].checked = this.settings[settingKey]
|
||||
}
|
||||
})
|
||||
|
|
@ -75,11 +68,6 @@ export class SettingsController {
|
|||
controller.placesFiltersTarget.style.display = controller.placesToggleTarget.checked ? 'block' : 'none'
|
||||
}
|
||||
|
||||
// Show/hide family members list based on initial toggle state
|
||||
if (controller.hasFamilyToggleTarget && controller.hasFamilyMembersListTarget && controller.familyToggleTarget) {
|
||||
controller.familyMembersListTarget.style.display = controller.familyToggleTarget.checked ? 'block' : 'none'
|
||||
}
|
||||
|
||||
// Sync route opacity slider
|
||||
if (controller.hasRouteOpacityRangeTarget) {
|
||||
controller.routeOpacityRangeTarget.value = (this.settings.routeOpacity || 1.0) * 100
|
||||
|
|
@ -91,11 +79,6 @@ export class SettingsController {
|
|||
mapStyleSelect.value = this.settings.mapStyle || 'light'
|
||||
}
|
||||
|
||||
// Sync globe projection toggle
|
||||
if (controller.hasGlobeToggleTarget) {
|
||||
controller.globeToggleTarget.checked = this.settings.globeProjection || false
|
||||
}
|
||||
|
||||
// Sync fog of war settings
|
||||
const fogRadiusInput = controller.element.querySelector('input[name="fogOfWarRadius"]')
|
||||
if (fogRadiusInput) {
|
||||
|
|
@ -151,6 +134,8 @@ export class SettingsController {
|
|||
if (speedColoredRoutesToggle) {
|
||||
speedColoredRoutesToggle.checked = this.settings.speedColoredRoutes || false
|
||||
}
|
||||
|
||||
console.log('[Maps V2] UI controls synced with settings')
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -169,6 +154,7 @@ export class SettingsController {
|
|||
|
||||
// Reload layers after style change
|
||||
this.map.once('style.load', () => {
|
||||
console.log('Style loaded, reloading map data')
|
||||
this.controller.loadMapData()
|
||||
})
|
||||
}
|
||||
|
|
@ -183,22 +169,6 @@ export class SettingsController {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle globe projection
|
||||
* Requires page reload to apply since projection is set at map initialization
|
||||
*/
|
||||
async toggleGlobe(event) {
|
||||
const enabled = event.target.checked
|
||||
await SettingsManager.updateSetting('globeProjection', enabled)
|
||||
|
||||
Toast.info('Globe view will be applied after page reload')
|
||||
|
||||
// Prompt user to reload
|
||||
if (confirm('Globe view requires a page reload to take effect. Reload now?')) {
|
||||
window.location.reload()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update route opacity in real-time
|
||||
*/
|
||||
|
|
@ -233,17 +203,11 @@ export class SettingsController {
|
|||
// Apply settings to current map
|
||||
await this.applySettingsToMap(settings)
|
||||
|
||||
// Save to backend
|
||||
// Save to backend and localStorage
|
||||
for (const [key, value] of Object.entries(settings)) {
|
||||
await SettingsManager.updateSetting(key, value)
|
||||
}
|
||||
|
||||
// Update controller settings and dataLoader
|
||||
this.controller.settings = { ...this.controller.settings, ...settings }
|
||||
if (this.controller.dataLoader) {
|
||||
this.controller.dataLoader.updateSettings(this.controller.settings)
|
||||
}
|
||||
|
||||
Toast.success('Settings updated successfully')
|
||||
}
|
||||
|
||||
|
|
@ -266,8 +230,8 @@ export class SettingsController {
|
|||
if (settings.fogOfWarRadius) {
|
||||
fogLayer.clearRadius = settings.fogOfWarRadius
|
||||
}
|
||||
// Redraw fog layer if it has data and is visible
|
||||
if (fogLayer.visible && fogLayer.data) {
|
||||
// Redraw fog layer
|
||||
if (fogLayer.visible) {
|
||||
await fogLayer.update(fogLayer.data)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -65,6 +65,8 @@ export class VisitsManager {
|
|||
* Start create visit mode
|
||||
*/
|
||||
startCreateVisit() {
|
||||
console.log('[Maps V2] Starting create visit mode')
|
||||
|
||||
if (this.controller.hasSettingsPanelTarget && this.controller.settingsPanelTarget.classList.contains('open')) {
|
||||
this.controller.toggleSettings()
|
||||
}
|
||||
|
|
@ -85,9 +87,12 @@ export class VisitsManager {
|
|||
* Open visit creation modal
|
||||
*/
|
||||
openVisitCreationModal(lat, lng) {
|
||||
console.log('[Maps V2] Opening visit creation modal', { lat, lng })
|
||||
|
||||
const modalElement = document.querySelector('[data-controller="visit-creation-v2"]')
|
||||
|
||||
if (!modalElement) {
|
||||
console.error('[Maps V2] Visit creation modal not found')
|
||||
Toast.error('Visit creation modal not available')
|
||||
return
|
||||
}
|
||||
|
|
@ -100,6 +105,7 @@ export class VisitsManager {
|
|||
if (controller) {
|
||||
controller.open(lat, lng, this.controller)
|
||||
} else {
|
||||
console.error('[Maps V2] Visit creation controller not found')
|
||||
Toast.error('Visit creation controller not available')
|
||||
}
|
||||
}
|
||||
|
|
@ -108,6 +114,8 @@ export class VisitsManager {
|
|||
* Handle visit creation event - reload visits and update layer
|
||||
*/
|
||||
async handleVisitCreated(event) {
|
||||
console.log('[Maps V2] Visit created, reloading visits...', event.detail)
|
||||
|
||||
try {
|
||||
const visits = await this.api.fetchVisits({
|
||||
start_at: this.controller.startDateValue,
|
||||
|
|
@ -124,6 +132,7 @@ export class VisitsManager {
|
|||
const visitsLayer = this.layerManager.getLayer('visits')
|
||||
if (visitsLayer) {
|
||||
visitsLayer.update(visitsGeoJSON)
|
||||
console.log('[Maps V2] Visits layer updated successfully')
|
||||
} else {
|
||||
console.warn('[Maps V2] Visits layer not found, cannot update')
|
||||
}
|
||||
|
|
@ -131,11 +140,4 @@ export class VisitsManager {
|
|||
console.error('[Maps V2] Failed to reload visits:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle visit update event - reload visits and update layer
|
||||
*/
|
||||
async handleVisitUpdated(event) {
|
||||
await this.handleVisitCreated(event)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,8 +26,7 @@ export default class extends Controller {
|
|||
static values = {
|
||||
apiKey: String,
|
||||
startDate: String,
|
||||
endDate: String,
|
||||
timezone: String
|
||||
endDate: String
|
||||
}
|
||||
|
||||
static targets = [
|
||||
|
|
@ -58,17 +57,11 @@ export default class extends Controller {
|
|||
'placesToggle',
|
||||
'fogToggle',
|
||||
'scratchToggle',
|
||||
'familyToggle',
|
||||
// Speed-colored routes
|
||||
'routesOptions',
|
||||
'speedColoredToggle',
|
||||
'speedColorScaleContainer',
|
||||
'speedColorScaleInput',
|
||||
// Globe projection
|
||||
'globeToggle',
|
||||
// Family members
|
||||
'familyMembersList',
|
||||
'familyMembersContainer',
|
||||
// Area selection
|
||||
'selectAreaButton',
|
||||
'selectionActions',
|
||||
|
|
@ -79,16 +72,7 @@ export default class extends Controller {
|
|||
'infoDisplay',
|
||||
'infoTitle',
|
||||
'infoContent',
|
||||
'infoActions',
|
||||
// Route info template
|
||||
'routeInfoTemplate',
|
||||
'routeStartTime',
|
||||
'routeEndTime',
|
||||
'routeDuration',
|
||||
'routeDistance',
|
||||
'routeSpeed',
|
||||
'routeSpeedContainer',
|
||||
'routePoints'
|
||||
'infoActions'
|
||||
]
|
||||
|
||||
async connect() {
|
||||
|
|
@ -108,7 +92,7 @@ export default class extends Controller {
|
|||
|
||||
// Initialize managers
|
||||
this.layerManager = new LayerManager(this.map, this.settings, this.api)
|
||||
this.dataLoader = new DataLoader(this.api, this.apiKeyValue, this.settings)
|
||||
this.dataLoader = new DataLoader(this.api, this.apiKeyValue)
|
||||
this.eventHandlers = new EventHandlers(this.map, this)
|
||||
this.filterManager = new FilterManager(this.dataLoader)
|
||||
this.mapDataManager = new MapDataManager(this)
|
||||
|
|
@ -122,25 +106,20 @@ export default class extends Controller {
|
|||
// Initialize search manager
|
||||
this.initializeSearch()
|
||||
|
||||
// Listen for visit and place creation/update events
|
||||
// Listen for visit and place creation events
|
||||
this.boundHandleVisitCreated = this.visitsManager.handleVisitCreated.bind(this.visitsManager)
|
||||
this.cleanup.addEventListener(document, 'visit:created', this.boundHandleVisitCreated)
|
||||
|
||||
this.boundHandleVisitUpdated = this.visitsManager.handleVisitUpdated.bind(this.visitsManager)
|
||||
this.cleanup.addEventListener(document, 'visit:updated', this.boundHandleVisitUpdated)
|
||||
|
||||
this.boundHandlePlaceCreated = this.placesManager.handlePlaceCreated.bind(this.placesManager)
|
||||
this.cleanup.addEventListener(document, 'place:created', this.boundHandlePlaceCreated)
|
||||
|
||||
this.boundHandlePlaceUpdated = this.placesManager.handlePlaceUpdated.bind(this.placesManager)
|
||||
this.cleanup.addEventListener(document, 'place:updated', this.boundHandlePlaceUpdated)
|
||||
|
||||
this.boundHandleAreaCreated = this.handleAreaCreated.bind(this)
|
||||
this.cleanup.addEventListener(document, 'area:created', this.boundHandleAreaCreated)
|
||||
|
||||
// Format initial dates
|
||||
this.startDateValue = DateManager.formatDateForAPI(new Date(this.startDateValue))
|
||||
this.endDateValue = DateManager.formatDateForAPI(new Date(this.endDateValue))
|
||||
console.log('[Maps V2] Initial dates:', this.startDateValue, 'to', this.endDateValue)
|
||||
|
||||
this.loadMapData()
|
||||
}
|
||||
|
|
@ -157,8 +136,7 @@ export default class extends Controller {
|
|||
*/
|
||||
async initializeMap() {
|
||||
this.map = await MapInitializer.initialize(this.containerTarget, {
|
||||
mapStyle: this.settings.mapStyle,
|
||||
globeProjection: this.settings.globeProjection
|
||||
mapStyle: this.settings.mapStyle
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -180,6 +158,8 @@ export default class extends Controller {
|
|||
|
||||
this.searchManager = new SearchManager(this.map, this.apiKeyValue)
|
||||
this.searchManager.initialize(this.searchInputTarget, this.searchResultsTarget)
|
||||
|
||||
console.log('[Maps V2] Search manager initialized')
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -204,6 +184,7 @@ export default class extends Controller {
|
|||
this.startDateValue = startDate
|
||||
this.endDateValue = endDate
|
||||
|
||||
console.log('[Maps V2] Date range changed:', this.startDateValue, 'to', this.endDateValue)
|
||||
this.loadMapData()
|
||||
}
|
||||
|
||||
|
|
@ -251,7 +232,6 @@ export default class extends Controller {
|
|||
updateFogThresholdDisplay(event) { return this.settingsController.updateFogThresholdDisplay(event) }
|
||||
updateMetersBetweenDisplay(event) { return this.settingsController.updateMetersBetweenDisplay(event) }
|
||||
updateMinutesBetweenDisplay(event) { return this.settingsController.updateMinutesBetweenDisplay(event) }
|
||||
toggleGlobe(event) { return this.settingsController.toggleGlobe(event) }
|
||||
|
||||
// Area Selection Manager methods
|
||||
startSelectArea() { return this.areaSelectionManager.startSelectArea() }
|
||||
|
|
@ -272,6 +252,8 @@ export default class extends Controller {
|
|||
|
||||
// Area creation
|
||||
startCreateArea() {
|
||||
console.log('[Maps V2] Starting create area mode')
|
||||
|
||||
if (this.hasSettingsPanelTarget && this.settingsPanelTarget.classList.contains('open')) {
|
||||
this.toggleSettings()
|
||||
}
|
||||
|
|
@ -283,26 +265,37 @@ export default class extends Controller {
|
|||
)
|
||||
|
||||
if (drawerController) {
|
||||
console.log('[Maps V2] Area drawer controller found, starting drawing with map:', this.map)
|
||||
drawerController.startDrawing(this.map)
|
||||
} else {
|
||||
console.error('[Maps V2] Area drawer controller not found')
|
||||
Toast.error('Area drawer controller not available')
|
||||
}
|
||||
}
|
||||
|
||||
async handleAreaCreated(event) {
|
||||
console.log('[Maps V2] Area created:', event.detail.area)
|
||||
|
||||
try {
|
||||
// Fetch all areas from API
|
||||
const areas = await this.api.fetchAreas()
|
||||
console.log('[Maps V2] Fetched areas:', areas.length)
|
||||
|
||||
// Convert to GeoJSON
|
||||
const areasGeoJSON = this.dataLoader.areasToGeoJSON(areas)
|
||||
console.log('[Maps V2] Converted to GeoJSON:', areasGeoJSON.features.length, 'features')
|
||||
if (areasGeoJSON.features.length > 0) {
|
||||
console.log('[Maps V2] First area GeoJSON:', JSON.stringify(areasGeoJSON.features[0], null, 2))
|
||||
}
|
||||
|
||||
// Get or create the areas layer
|
||||
let areasLayer = this.layerManager.getLayer('areas')
|
||||
console.log('[Maps V2] Areas layer exists?', !!areasLayer, 'visible?', areasLayer?.visible)
|
||||
|
||||
if (areasLayer) {
|
||||
// Update existing layer
|
||||
areasLayer.update(areasGeoJSON)
|
||||
console.log('[Maps V2] Areas layer updated')
|
||||
} else {
|
||||
// Create the layer if it doesn't exist yet
|
||||
console.log('[Maps V2] Creating areas layer')
|
||||
|
|
@ -314,6 +307,7 @@ export default class extends Controller {
|
|||
// Enable the layer if it wasn't already
|
||||
if (areasLayer) {
|
||||
if (!areasLayer.visible) {
|
||||
console.log('[Maps V2] Showing areas layer')
|
||||
areasLayer.show()
|
||||
this.settings.layers.areas = true
|
||||
this.settingsController.saveSetting('layers.areas', true)
|
||||
|
|
@ -329,6 +323,7 @@ export default class extends Controller {
|
|||
|
||||
Toast.success('Area created successfully!')
|
||||
} catch (error) {
|
||||
console.error('[Maps V2] Failed to reload areas:', error)
|
||||
Toast.error('Failed to reload areas')
|
||||
}
|
||||
}
|
||||
|
|
@ -345,102 +340,6 @@ export default class extends Controller {
|
|||
toggleSpeedColoredRoutes(event) { return this.routesManager.toggleSpeedColoredRoutes(event) }
|
||||
openSpeedColorEditor() { return this.routesManager.openSpeedColorEditor() }
|
||||
handleSpeedColorSave(event) { return this.routesManager.handleSpeedColorSave(event) }
|
||||
toggleFamily(event) { return this.routesManager.toggleFamily(event) }
|
||||
|
||||
// Family Members methods
|
||||
async loadFamilyMembers() {
|
||||
try {
|
||||
const response = await fetch(`/api/v1/families/locations?api_key=${this.apiKeyValue}`, {
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 403) {
|
||||
Toast.info('Family feature not available')
|
||||
return
|
||||
}
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const locations = data.locations || []
|
||||
|
||||
// Update family layer with locations
|
||||
const familyLayer = this.layerManager.getLayer('family')
|
||||
if (familyLayer) {
|
||||
familyLayer.loadMembers(locations)
|
||||
}
|
||||
|
||||
// Render family members list
|
||||
this.renderFamilyMembersList(locations)
|
||||
|
||||
Toast.success(`Loaded ${locations.length} family member(s)`)
|
||||
} catch (error) {
|
||||
console.error('[Maps V2] Failed to load family members:', error)
|
||||
Toast.error('Failed to load family members')
|
||||
}
|
||||
}
|
||||
|
||||
renderFamilyMembersList(locations) {
|
||||
if (!this.hasFamilyMembersContainerTarget) return
|
||||
|
||||
const container = this.familyMembersContainerTarget
|
||||
|
||||
if (locations.length === 0) {
|
||||
container.innerHTML = '<p class="text-xs text-base-content/60">No family members sharing location</p>'
|
||||
return
|
||||
}
|
||||
|
||||
container.innerHTML = locations.map(location => {
|
||||
const emailInitial = location.email?.charAt(0)?.toUpperCase() || '?'
|
||||
const color = this.getFamilyMemberColor(location.user_id)
|
||||
const lastSeen = new Date(location.updated_at).toLocaleString('en-US', {
|
||||
timeZone: this.timezoneValue || 'UTC',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit'
|
||||
})
|
||||
|
||||
return `
|
||||
<div class="flex items-center gap-2 p-2 hover:bg-base-200 rounded-lg cursor-pointer transition-colors"
|
||||
data-action="click->maps--maplibre#centerOnFamilyMember"
|
||||
data-member-id="${location.user_id}">
|
||||
<div style="background-color: ${color}; color: white; border-radius: 50%; width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: bold; flex-shrink: 0;">
|
||||
${emailInitial}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-medium truncate">${location.email || 'Unknown'}</div>
|
||||
<div class="text-xs text-base-content/60">${lastSeen}</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}).join('')
|
||||
}
|
||||
|
||||
getFamilyMemberColor(userId) {
|
||||
const colors = [
|
||||
'#3b82f6', '#10b981', '#f59e0b',
|
||||
'#ef4444', '#8b5cf6', '#ec4899'
|
||||
]
|
||||
// Use user ID to get consistent color
|
||||
const hash = userId.toString().split('').reduce((acc, char) => acc + char.charCodeAt(0), 0)
|
||||
return colors[hash % colors.length]
|
||||
}
|
||||
|
||||
centerOnFamilyMember(event) {
|
||||
const memberId = event.currentTarget.dataset.memberId
|
||||
if (!memberId) return
|
||||
|
||||
const familyLayer = this.layerManager.getLayer('family')
|
||||
if (familyLayer) {
|
||||
familyLayer.centerOnMember(parseInt(memberId))
|
||||
Toast.success('Centered on family member')
|
||||
}
|
||||
}
|
||||
|
||||
// Info Display methods
|
||||
showInfo(title, content, actions = []) {
|
||||
|
|
@ -454,17 +353,9 @@ export default class extends Controller {
|
|||
|
||||
// Set actions
|
||||
if (actions.length > 0) {
|
||||
this.infoActionsTarget.innerHTML = actions.map(action => {
|
||||
if (action.type === 'button') {
|
||||
// For button actions (modals, etc.), create a button with data-action
|
||||
// Use error styling for delete buttons
|
||||
const buttonClass = action.label === 'Delete' ? 'btn btn-sm btn-error' : 'btn btn-sm btn-primary'
|
||||
return `<button class="${buttonClass}" data-action="click->maps--maplibre#${action.handler}" data-id="${action.id}" data-entity-type="${action.entityType}">${action.label}</button>`
|
||||
} else {
|
||||
// For link actions, keep the original behavior
|
||||
return `<a href="${action.url}" class="btn btn-sm btn-primary">${action.label}</a>`
|
||||
}
|
||||
}).join('')
|
||||
this.infoActionsTarget.innerHTML = actions.map(action =>
|
||||
`<a href="${action.url}" class="btn btn-sm btn-primary">${action.label}</a>`
|
||||
).join('')
|
||||
} else {
|
||||
this.infoActionsTarget.innerHTML = ''
|
||||
}
|
||||
|
|
@ -476,180 +367,9 @@ export default class extends Controller {
|
|||
this.switchToToolsTab()
|
||||
}
|
||||
|
||||
showRouteInfo(routeData) {
|
||||
if (!this.hasRouteInfoTemplateTarget) return
|
||||
|
||||
// Clone the template
|
||||
const template = this.routeInfoTemplateTarget.content.cloneNode(true)
|
||||
|
||||
// Populate the template with data
|
||||
const fragment = document.createDocumentFragment()
|
||||
fragment.appendChild(template)
|
||||
|
||||
fragment.querySelector('[data-maps--maplibre-target="routeStartTime"]').textContent = routeData.startTime
|
||||
fragment.querySelector('[data-maps--maplibre-target="routeEndTime"]').textContent = routeData.endTime
|
||||
fragment.querySelector('[data-maps--maplibre-target="routeDuration"]').textContent = routeData.duration
|
||||
fragment.querySelector('[data-maps--maplibre-target="routeDistance"]').textContent = routeData.distance
|
||||
fragment.querySelector('[data-maps--maplibre-target="routePoints"]').textContent = routeData.pointCount
|
||||
|
||||
// Handle optional speed field
|
||||
const speedContainer = fragment.querySelector('[data-maps--maplibre-target="routeSpeedContainer"]')
|
||||
if (routeData.speed) {
|
||||
fragment.querySelector('[data-maps--maplibre-target="routeSpeed"]').textContent = routeData.speed
|
||||
speedContainer.style.display = ''
|
||||
} else {
|
||||
speedContainer.style.display = 'none'
|
||||
}
|
||||
|
||||
// Convert fragment to HTML string for showInfo
|
||||
const div = document.createElement('div')
|
||||
div.appendChild(fragment)
|
||||
|
||||
this.showInfo('Route Information', div.innerHTML)
|
||||
}
|
||||
|
||||
closeInfo() {
|
||||
if (!this.hasInfoDisplayTarget) return
|
||||
this.infoDisplayTarget.classList.add('hidden')
|
||||
|
||||
// Clear route selection when info panel is closed
|
||||
if (this.eventHandlers) {
|
||||
this.eventHandlers.clearRouteSelection()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle edit action from info display
|
||||
*/
|
||||
handleEdit(event) {
|
||||
const button = event.currentTarget
|
||||
const id = button.dataset.id
|
||||
const entityType = button.dataset.entityType
|
||||
|
||||
|
||||
switch (entityType) {
|
||||
case 'visit':
|
||||
this.openVisitModal(id)
|
||||
break
|
||||
case 'place':
|
||||
this.openPlaceEditModal(id)
|
||||
break
|
||||
default:
|
||||
console.warn('[Maps V2] Unknown entity type:', entityType)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle delete action from info display
|
||||
*/
|
||||
handleDelete(event) {
|
||||
const button = event.currentTarget
|
||||
const id = button.dataset.id
|
||||
const entityType = button.dataset.entityType
|
||||
|
||||
switch (entityType) {
|
||||
case 'area':
|
||||
this.deleteArea(id)
|
||||
break
|
||||
default:
|
||||
console.warn('[Maps V2] Unknown entity type for delete:', entityType)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open visit edit modal
|
||||
*/
|
||||
async openVisitModal(visitId) {
|
||||
try {
|
||||
// Fetch visit details
|
||||
const response = await fetch(`/api/v1/visits/${visitId}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.apiKeyValue}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch visit: ${response.status}`)
|
||||
}
|
||||
|
||||
const visit = await response.json()
|
||||
|
||||
// Trigger visit edit event
|
||||
const event = new CustomEvent('visit:edit', {
|
||||
detail: { visit },
|
||||
bubbles: true
|
||||
})
|
||||
document.dispatchEvent(event)
|
||||
} catch (error) {
|
||||
Toast.error('Failed to load visit details')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete area with confirmation
|
||||
*/
|
||||
async deleteArea(areaId) {
|
||||
try {
|
||||
// Fetch area details
|
||||
const area = await this.api.fetchArea(areaId)
|
||||
|
||||
// Show delete confirmation
|
||||
const confirmed = confirm(`Delete area "${area.name}"?\n\nThis action cannot be undone.`)
|
||||
|
||||
if (!confirmed) return
|
||||
|
||||
Toast.info('Deleting area...')
|
||||
|
||||
// Delete the area
|
||||
await this.api.deleteArea(areaId)
|
||||
|
||||
// Reload areas
|
||||
const areas = await this.api.fetchAreas()
|
||||
const areasGeoJSON = this.dataLoader.areasToGeoJSON(areas)
|
||||
|
||||
const areasLayer = this.layerManager.getLayer('areas')
|
||||
if (areasLayer) {
|
||||
areasLayer.update(areasGeoJSON)
|
||||
}
|
||||
|
||||
// Close info display
|
||||
this.closeInfo()
|
||||
|
||||
Toast.success('Area deleted successfully')
|
||||
} catch (error) {
|
||||
Toast.error('Failed to delete area')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open place edit modal
|
||||
*/
|
||||
async openPlaceEditModal(placeId) {
|
||||
try {
|
||||
// Fetch place details
|
||||
const response = await fetch(`/api/v1/places/${placeId}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.apiKeyValue}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch place: ${response.status}`)
|
||||
}
|
||||
|
||||
const place = await response.json()
|
||||
|
||||
// Trigger place edit event
|
||||
const event = new CustomEvent('place:edit', {
|
||||
detail: { place },
|
||||
bubbles: true
|
||||
})
|
||||
document.dispatchEvent(event)
|
||||
} catch (error) {
|
||||
Toast.error('Failed to load place details')
|
||||
}
|
||||
}
|
||||
|
||||
switchToToolsTab() {
|
||||
|
|
|
|||
|
|
@ -81,9 +81,6 @@ export default class extends Controller {
|
|||
toggleLiveMode(event) {
|
||||
this.liveModeEnabled = event.target.checked
|
||||
|
||||
// Update recent point layer visibility
|
||||
this.updateRecentPointLayerVisibility()
|
||||
|
||||
// Reconnect channels with new settings
|
||||
if (this.channels) {
|
||||
this.channels.unsubscribeAll()
|
||||
|
|
@ -94,28 +91,6 @@ export default class extends Controller {
|
|||
Toast.info(message)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update recent point layer visibility based on live mode state
|
||||
*/
|
||||
updateRecentPointLayerVisibility() {
|
||||
const mapsController = this.mapsV2Controller
|
||||
if (!mapsController) {
|
||||
return
|
||||
}
|
||||
|
||||
const recentPointLayer = mapsController.layerManager?.getLayer('recentPoint')
|
||||
if (!recentPointLayer) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.liveModeEnabled) {
|
||||
recentPointLayer.show()
|
||||
} else {
|
||||
recentPointLayer.hide()
|
||||
recentPointLayer.clear()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle connection
|
||||
*/
|
||||
|
|
@ -224,16 +199,6 @@ export default class extends Controller {
|
|||
|
||||
console.log('[Realtime Controller] Added new point to map:', id)
|
||||
|
||||
// Update recent point marker (always visible in live mode)
|
||||
this.updateRecentPoint(parseFloat(lon), parseFloat(lat), {
|
||||
id: parseInt(id),
|
||||
battery: parseFloat(battery) || null,
|
||||
altitude: parseFloat(altitude) || null,
|
||||
timestamp: timestamp,
|
||||
velocity: parseFloat(velocity) || null,
|
||||
country_name: countryName || null
|
||||
})
|
||||
|
||||
// Zoom to the new point
|
||||
this.zoomToPoint(parseFloat(lon), parseFloat(lat))
|
||||
|
||||
|
|
@ -260,31 +225,6 @@ export default class extends Controller {
|
|||
Toast.info(notification.message || 'New notification')
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the recent point marker
|
||||
* This marker is always visible in live mode, independent of points layer visibility
|
||||
*/
|
||||
updateRecentPoint(longitude, latitude, properties = {}) {
|
||||
const mapsController = this.mapsV2Controller
|
||||
if (!mapsController) {
|
||||
console.warn('[Realtime Controller] Maps controller not found')
|
||||
return
|
||||
}
|
||||
|
||||
const recentPointLayer = mapsController.layerManager?.getLayer('recentPoint')
|
||||
if (!recentPointLayer) {
|
||||
console.warn('[Realtime Controller] Recent point layer not found')
|
||||
return
|
||||
}
|
||||
|
||||
// Show the layer if live mode is enabled and update with new point
|
||||
if (this.liveModeEnabled) {
|
||||
recentPointLayer.show()
|
||||
recentPointLayer.updateRecentPoint(longitude, latitude, properties)
|
||||
console.log('[Realtime Controller] Updated recent point marker:', longitude, latitude)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zoom map to a specific point
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -2220,7 +2220,6 @@ export default class extends BaseController {
|
|||
return;
|
||||
}
|
||||
|
||||
const timezone = this.timezone || 'UTC';
|
||||
const html = citiesData.map(country => `
|
||||
<div class="mb-4" style="min-width: min-content;">
|
||||
<h4 class="font-bold text-md">${country.country}</h4>
|
||||
|
|
@ -2229,7 +2228,7 @@ export default class extends BaseController {
|
|||
<li class="text-sm whitespace-nowrap">
|
||||
${city.city}
|
||||
<span class="text-gray-500">
|
||||
(${new Date(city.timestamp * 1000).toLocaleDateString('en-US', { timeZone: timezone })})
|
||||
(${new Date(city.timestamp * 1000).toLocaleDateString()})
|
||||
</span>
|
||||
</li>
|
||||
`).join('')}
|
||||
|
|
|
|||
|
|
@ -10,8 +10,7 @@ export default class extends BaseController {
|
|||
uuid: String,
|
||||
dataBounds: Object,
|
||||
hexagonsAvailable: Boolean,
|
||||
selfHosted: String,
|
||||
timezone: String
|
||||
selfHosted: String
|
||||
};
|
||||
|
||||
connect() {
|
||||
|
|
@ -73,7 +72,9 @@ export default class extends BaseController {
|
|||
}
|
||||
|
||||
async loadHexagons() {
|
||||
console.log('🎯 loadHexagons started - checking overlay state');
|
||||
const initialLoadingElement = document.getElementById('map-loading');
|
||||
console.log('📊 Initial overlay display:', initialLoadingElement?.style.display || 'default');
|
||||
|
||||
try {
|
||||
// Use server-provided data bounds
|
||||
|
|
@ -93,6 +94,9 @@ export default class extends BaseController {
|
|||
// Fallback timeout in case moveend doesn't fire
|
||||
setTimeout(resolve, 1000);
|
||||
});
|
||||
console.log('✅ Map fitBounds complete - checking overlay state');
|
||||
const afterFitBoundsElement = document.getElementById('map-loading');
|
||||
console.log('📊 After fitBounds overlay display:', afterFitBoundsElement?.style.display || 'default');
|
||||
}
|
||||
|
||||
// Load hexagons only if they are pre-calculated and data exists
|
||||
|
|
@ -134,6 +138,7 @@ export default class extends BaseController {
|
|||
loadingElement.style.display = 'flex';
|
||||
loadingElement.style.visibility = 'visible';
|
||||
loadingElement.style.zIndex = '9999';
|
||||
console.log('👁️ Loading overlay ENSURED visible - should be visible now');
|
||||
}
|
||||
|
||||
// Disable map interaction during loading
|
||||
|
|
@ -182,6 +187,7 @@ export default class extends BaseController {
|
|||
}
|
||||
|
||||
const geojsonData = await response.json();
|
||||
console.log(`✅ Loaded ${geojsonData.features?.length || 0} hexagons`);
|
||||
|
||||
// Add hexagons directly to map as a static layer
|
||||
if (geojsonData.features && geojsonData.features.length > 0) {
|
||||
|
|
@ -204,6 +210,7 @@ export default class extends BaseController {
|
|||
const loadingElement = document.getElementById('map-loading');
|
||||
if (loadingElement) {
|
||||
loadingElement.style.display = 'none';
|
||||
console.log('🚫 Loading overlay hidden - hexagons are fully loaded');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -248,11 +255,10 @@ export default class extends BaseController {
|
|||
}
|
||||
|
||||
buildPopupContent(props) {
|
||||
const timezone = this.timezoneValue || 'UTC';
|
||||
const startDate = props.earliest_point ? new Date(props.earliest_point).toLocaleDateString('en-US', { timeZone: timezone }) : 'N/A';
|
||||
const endDate = props.latest_point ? new Date(props.latest_point).toLocaleDateString('en-US', { timeZone: timezone }) : 'N/A';
|
||||
const startTime = props.earliest_point ? new Date(props.earliest_point).toLocaleTimeString('en-US', { timeZone: timezone }) : '';
|
||||
const endTime = props.latest_point ? new Date(props.latest_point).toLocaleTimeString('en-US', { timeZone: timezone }) : '';
|
||||
const startDate = props.earliest_point ? new Date(props.earliest_point).toLocaleDateString() : 'N/A';
|
||||
const endDate = props.latest_point ? new Date(props.latest_point).toLocaleDateString() : 'N/A';
|
||||
const startTime = props.earliest_point ? new Date(props.earliest_point).toLocaleTimeString() : '';
|
||||
const endTime = props.latest_point ? new Date(props.latest_point).toLocaleTimeString() : '';
|
||||
|
||||
return `
|
||||
<div style="font-size: 12px; line-height: 1.6; max-width: 300px;">
|
||||
|
|
|
|||
|
|
@ -13,8 +13,7 @@ export default class extends Controller {
|
|||
'startTimeInput',
|
||||
'endTimeInput',
|
||||
'latitudeInput',
|
||||
'longitudeInput',
|
||||
'submitButton'
|
||||
'longitudeInput'
|
||||
]
|
||||
|
||||
static values = {
|
||||
|
|
@ -25,14 +24,6 @@ export default class extends Controller {
|
|||
console.log('[Visit Creation V2] Controller connected')
|
||||
this.marker = null
|
||||
this.mapController = null
|
||||
this.editingVisitId = null
|
||||
this.setupEventListeners()
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
document.addEventListener('visit:edit', (e) => {
|
||||
this.openForEdit(e.detail.visit)
|
||||
})
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
|
|
@ -45,19 +36,10 @@ export default class extends Controller {
|
|||
open(lat, lng, mapController) {
|
||||
console.log('[Visit Creation V2] Opening modal', { lat, lng })
|
||||
|
||||
this.editingVisitId = null
|
||||
this.mapController = mapController
|
||||
this.latitudeInputTarget.value = lat
|
||||
this.longitudeInputTarget.value = lng
|
||||
|
||||
// Set modal title and button for creation
|
||||
if (this.hasModalTitleTarget) {
|
||||
this.modalTitleTarget.textContent = 'Create New Visit'
|
||||
}
|
||||
if (this.hasSubmitButtonTarget) {
|
||||
this.submitButtonTarget.textContent = 'Create Visit'
|
||||
}
|
||||
|
||||
// Set default times
|
||||
const now = new Date()
|
||||
const oneHourLater = new Date(now.getTime() + (60 * 60 * 1000))
|
||||
|
|
@ -75,48 +57,6 @@ export default class extends Controller {
|
|||
this.addMarker(lat, lng)
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the modal for editing an existing visit
|
||||
*/
|
||||
openForEdit(visit) {
|
||||
console.log('[Visit Creation V2] Opening modal for edit', visit)
|
||||
|
||||
this.editingVisitId = visit.id
|
||||
|
||||
// Set modal title and button for editing
|
||||
if (this.hasModalTitleTarget) {
|
||||
this.modalTitleTarget.textContent = 'Edit Visit'
|
||||
}
|
||||
if (this.hasSubmitButtonTarget) {
|
||||
this.submitButtonTarget.textContent = 'Update Visit'
|
||||
}
|
||||
|
||||
// Fill form with visit data
|
||||
this.nameInputTarget.value = visit.name || ''
|
||||
this.latitudeInputTarget.value = visit.latitude
|
||||
this.longitudeInputTarget.value = visit.longitude
|
||||
|
||||
// Convert timestamps to datetime-local format
|
||||
this.startTimeInputTarget.value = this.formatDateTime(new Date(visit.started_at))
|
||||
this.endTimeInputTarget.value = this.formatDateTime(new Date(visit.ended_at))
|
||||
|
||||
// Show modal
|
||||
this.modalTarget.classList.add('modal-open')
|
||||
|
||||
// Focus on name input
|
||||
setTimeout(() => this.nameInputTarget.focus(), 100)
|
||||
|
||||
// Try to get map controller from the maps--maplibre controller
|
||||
const mapElement = document.querySelector('[data-controller*="maps--maplibre"]')
|
||||
if (mapElement) {
|
||||
const app = window.Stimulus || window.Application
|
||||
this.mapController = app?.getControllerForElementAndIdentifier(mapElement, 'maps--maplibre')
|
||||
}
|
||||
|
||||
// Add marker to map
|
||||
this.addMarker(visit.latitude, visit.longitude)
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the modal
|
||||
*/
|
||||
|
|
@ -129,9 +69,6 @@ export default class extends Controller {
|
|||
// Reset form
|
||||
this.formTarget.reset()
|
||||
|
||||
// Reset editing state
|
||||
this.editingVisitId = null
|
||||
|
||||
// Remove marker
|
||||
this.removeMarker()
|
||||
}
|
||||
|
|
@ -142,8 +79,7 @@ export default class extends Controller {
|
|||
async submit(event) {
|
||||
event.preventDefault()
|
||||
|
||||
const isEdit = this.editingVisitId !== null
|
||||
console.log(`[Visit Creation V2] Submitting form (${isEdit ? 'edit' : 'create'})`)
|
||||
console.log('[Visit Creation V2] Submitting form')
|
||||
|
||||
const formData = new FormData(this.formTarget)
|
||||
|
||||
|
|
@ -159,11 +95,8 @@ export default class extends Controller {
|
|||
}
|
||||
|
||||
try {
|
||||
const url = isEdit ? `/api/v1/visits/${this.editingVisitId}` : '/api/v1/visits'
|
||||
const method = isEdit ? 'PATCH' : 'POST'
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: method,
|
||||
const response = await fetch('/api/v1/visits', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${this.apiKeyValue}`,
|
||||
|
|
@ -174,27 +107,26 @@ export default class extends Controller {
|
|||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || `Failed to ${isEdit ? 'update' : 'create'} visit`)
|
||||
throw new Error(errorData.error || 'Failed to create visit')
|
||||
}
|
||||
|
||||
const visit = await response.json()
|
||||
const createdVisit = await response.json()
|
||||
|
||||
console.log(`[Visit Creation V2] Visit ${isEdit ? 'updated' : 'created'} successfully`, visit)
|
||||
console.log('[Visit Creation V2] Visit created successfully', createdVisit)
|
||||
|
||||
// Show success message
|
||||
this.showToast(`Visit ${isEdit ? 'updated' : 'created'} successfully`, 'success')
|
||||
this.showToast('Visit created successfully', 'success')
|
||||
|
||||
// Close modal
|
||||
this.close()
|
||||
|
||||
// Dispatch event to notify map controller
|
||||
const eventName = isEdit ? 'visit:updated' : 'visit:created'
|
||||
document.dispatchEvent(new CustomEvent(eventName, {
|
||||
detail: { visit }
|
||||
document.dispatchEvent(new CustomEvent('visit:created', {
|
||||
detail: createdVisit
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error(`[Visit Creation V2] Error ${isEdit ? 'updating' : 'creating'} visit:`, error)
|
||||
this.showToast(error.message || `Failed to ${isEdit ? 'update' : 'create'} visit`, 'error')
|
||||
console.error('[Visit Creation V2] Error creating visit:', error)
|
||||
this.showToast(error.message || 'Failed to create visit', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ const MARKER_DATA_INDICES = {
|
|||
* @param {number} size - Icon size in pixels (default: 8)
|
||||
* @returns {L.DivIcon} Leaflet divIcon instance
|
||||
*/
|
||||
export function createStandardIcon(color = 'blue', size = 4) {
|
||||
export function createStandardIcon(color = 'blue', size = 8) {
|
||||
return L.divIcon({
|
||||
className: 'custom-div-icon',
|
||||
html: `<div style='background-color: ${color}; width: ${size}px; height: ${size}px; border-radius: 50%;'></div>`,
|
||||
|
|
|
|||
|
|
@ -16,10 +16,12 @@ export class BaseLayer {
|
|||
* @param {Object} data - GeoJSON or layer-specific data
|
||||
*/
|
||||
add(data) {
|
||||
console.log(`[BaseLayer:${this.id}] add() called, visible:`, this.visible, 'features:', data?.features?.length || 0)
|
||||
this.data = data
|
||||
|
||||
// Add source
|
||||
if (!this.map.getSource(this.sourceId)) {
|
||||
console.log(`[BaseLayer:${this.id}] Adding source:`, this.sourceId)
|
||||
this.map.addSource(this.sourceId, this.getSourceConfig())
|
||||
} else {
|
||||
console.log(`[BaseLayer:${this.id}] Source already exists:`, this.sourceId)
|
||||
|
|
@ -30,6 +32,7 @@ export class BaseLayer {
|
|||
console.log(`[BaseLayer:${this.id}] Adding ${layers.length} layer(s)`)
|
||||
layers.forEach(layerConfig => {
|
||||
if (!this.map.getLayer(layerConfig.id)) {
|
||||
console.log(`[BaseLayer:${this.id}] Adding layer:`, layerConfig.id, 'type:', layerConfig.type)
|
||||
this.map.addLayer(layerConfig)
|
||||
} else {
|
||||
console.log(`[BaseLayer:${this.id}] Layer already exists:`, layerConfig.id)
|
||||
|
|
@ -37,6 +40,7 @@ export class BaseLayer {
|
|||
})
|
||||
|
||||
this.setVisibility(this.visible)
|
||||
console.log(`[BaseLayer:${this.id}] Layer added successfully`)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -148,63 +148,4 @@ export class FamilyLayer extends BaseLayer {
|
|||
features: filtered
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all family members from API
|
||||
* @param {Object} locations - Array of family member locations
|
||||
*/
|
||||
loadMembers(locations) {
|
||||
if (!Array.isArray(locations)) {
|
||||
console.warn('[FamilyLayer] Invalid locations data:', locations)
|
||||
return
|
||||
}
|
||||
|
||||
const features = locations.map(location => ({
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Point',
|
||||
coordinates: [location.longitude, location.latitude]
|
||||
},
|
||||
properties: {
|
||||
id: location.user_id,
|
||||
name: location.email || 'Unknown',
|
||||
email: location.email,
|
||||
color: location.color || this.getMemberColor(location.user_id),
|
||||
lastUpdate: Date.now(),
|
||||
battery: location.battery,
|
||||
batteryStatus: location.battery_status,
|
||||
updatedAt: location.updated_at
|
||||
}
|
||||
}))
|
||||
|
||||
this.update({
|
||||
type: 'FeatureCollection',
|
||||
features
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Center map on specific family member
|
||||
* @param {string} memberId - ID of the member to center on
|
||||
*/
|
||||
centerOnMember(memberId) {
|
||||
const features = this.data?.features || []
|
||||
const member = features.find(f => f.properties.id === memberId)
|
||||
|
||||
if (member && this.map) {
|
||||
this.map.flyTo({
|
||||
center: member.geometry.coordinates,
|
||||
zoom: 15,
|
||||
duration: 1500
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all current family members
|
||||
* @returns {Array} Array of member features
|
||||
*/
|
||||
getMembers() {
|
||||
return this.data?.features || []
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,11 +12,9 @@ export class FogLayer {
|
|||
this.ctx = null
|
||||
this.clearRadius = options.clearRadius || 1000 // meters
|
||||
this.points = []
|
||||
this.data = null // Store original data for updates
|
||||
}
|
||||
|
||||
add(data) {
|
||||
this.data = data // Store for later updates
|
||||
this.points = data.features || []
|
||||
this.createCanvas()
|
||||
if (this.visible) {
|
||||
|
|
@ -26,7 +24,6 @@ export class FogLayer {
|
|||
}
|
||||
|
||||
update(data) {
|
||||
this.data = data // Store for later updates
|
||||
this.points = data.features || []
|
||||
this.render()
|
||||
}
|
||||
|
|
@ -81,7 +78,6 @@ export class FogLayer {
|
|||
|
||||
// Clear circles around visited points
|
||||
this.ctx.globalCompositeOperation = 'destination-out'
|
||||
this.ctx.fillStyle = 'rgba(0, 0, 0, 1)' // Fully opaque to completely clear fog
|
||||
|
||||
this.points.forEach(feature => {
|
||||
const coords = feature.geometry.coordinates
|
||||
|
|
|
|||
|
|
@ -3,10 +3,14 @@ import { BaseLayer } from './base_layer'
|
|||
/**
|
||||
* Heatmap layer showing point density
|
||||
* Uses MapLibre's native heatmap for performance
|
||||
* Fixed radius: 20 pixels
|
||||
*/
|
||||
export class HeatmapLayer extends BaseLayer {
|
||||
constructor(map, options = {}) {
|
||||
super(map, { id: 'heatmap', ...options })
|
||||
this.radius = 20 // Fixed radius
|
||||
this.weight = options.weight || 1
|
||||
this.intensity = 1 // Fixed intensity
|
||||
this.opacity = options.opacity || 0.6
|
||||
}
|
||||
|
||||
|
|
@ -27,52 +31,53 @@ export class HeatmapLayer extends BaseLayer {
|
|||
type: 'heatmap',
|
||||
source: this.sourceId,
|
||||
paint: {
|
||||
// Fixed weight
|
||||
'heatmap-weight': 1,
|
||||
// Increase weight as diameter increases
|
||||
'heatmap-weight': [
|
||||
'interpolate',
|
||||
['linear'],
|
||||
['get', 'weight'],
|
||||
0, 0,
|
||||
6, 1
|
||||
],
|
||||
|
||||
// low intensity to view major clusters
|
||||
// Increase intensity as zoom increases
|
||||
'heatmap-intensity': [
|
||||
'interpolate',
|
||||
['linear'],
|
||||
['zoom'],
|
||||
0, 0.01,
|
||||
10, 0.1,
|
||||
15, 0.3
|
||||
0, this.intensity,
|
||||
9, this.intensity * 3
|
||||
],
|
||||
|
||||
// Color ramp
|
||||
// Color ramp from blue to red
|
||||
'heatmap-color': [
|
||||
'interpolate',
|
||||
['linear'],
|
||||
['heatmap-density'],
|
||||
0, 'rgba(0,0,0,0)',
|
||||
0.4, 'rgba(0,0,0,0)',
|
||||
0.65, 'rgba(33,102,172,0.4)',
|
||||
0.7, 'rgb(103,169,207)',
|
||||
0.8, 'rgb(209,229,240)',
|
||||
0.9, 'rgb(253,219,199)',
|
||||
0.95, 'rgb(239,138,98)',
|
||||
0, 'rgba(33,102,172,0)',
|
||||
0.2, 'rgb(103,169,207)',
|
||||
0.4, 'rgb(209,229,240)',
|
||||
0.6, 'rgb(253,219,199)',
|
||||
0.8, 'rgb(239,138,98)',
|
||||
1, 'rgb(178,24,43)'
|
||||
],
|
||||
|
||||
// Radius in pixels, exponential growth
|
||||
// Fixed radius adjusted by zoom level
|
||||
'heatmap-radius': [
|
||||
'interpolate',
|
||||
['exponential', 2],
|
||||
['linear'],
|
||||
['zoom'],
|
||||
10, 5,
|
||||
15, 10,
|
||||
20, 160
|
||||
0, this.radius,
|
||||
9, this.radius * 3
|
||||
],
|
||||
|
||||
// Visible when zoomed in, fades when zoomed out
|
||||
// Transition from heatmap to circle layer by zoom level
|
||||
'heatmap-opacity': [
|
||||
'interpolate',
|
||||
['linear'],
|
||||
['zoom'],
|
||||
0, 0.3,
|
||||
10, this.opacity,
|
||||
15, this.opacity
|
||||
7, this.opacity,
|
||||
9, 0
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,25 +1,11 @@
|
|||
import { BaseLayer } from './base_layer'
|
||||
import { Toast } from 'maps_maplibre/components/toast'
|
||||
|
||||
/**
|
||||
* Points layer for displaying individual location points
|
||||
* Supports dragging points to update their positions
|
||||
*/
|
||||
export class PointsLayer extends BaseLayer {
|
||||
constructor(map, options = {}) {
|
||||
super(map, { id: 'points', ...options })
|
||||
this.apiClient = options.apiClient
|
||||
this.layerManager = options.layerManager
|
||||
this.isDragging = false
|
||||
this.draggedFeature = null
|
||||
this.canvas = null
|
||||
|
||||
// Bind event handlers once and store references for proper cleanup
|
||||
this._onMouseEnter = this.onMouseEnter.bind(this)
|
||||
this._onMouseLeave = this.onMouseLeave.bind(this)
|
||||
this._onMouseDown = this.onMouseDown.bind(this)
|
||||
this._onMouseMove = this.onMouseMove.bind(this)
|
||||
this._onMouseUp = this.onMouseUp.bind(this)
|
||||
}
|
||||
|
||||
getSourceConfig() {
|
||||
|
|
@ -48,218 +34,4 @@ export class PointsLayer extends BaseLayer {
|
|||
}
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable dragging for points
|
||||
*/
|
||||
enableDragging() {
|
||||
if (this.draggingEnabled) return
|
||||
|
||||
this.draggingEnabled = true
|
||||
this.canvas = this.map.getCanvasContainer()
|
||||
|
||||
// Change cursor to pointer when hovering over points
|
||||
this.map.on('mouseenter', this.id, this._onMouseEnter)
|
||||
this.map.on('mouseleave', this.id, this._onMouseLeave)
|
||||
|
||||
// Handle drag events
|
||||
this.map.on('mousedown', this.id, this._onMouseDown)
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable dragging for points
|
||||
*/
|
||||
disableDragging() {
|
||||
if (!this.draggingEnabled) return
|
||||
|
||||
this.draggingEnabled = false
|
||||
|
||||
this.map.off('mouseenter', this.id, this._onMouseEnter)
|
||||
this.map.off('mouseleave', this.id, this._onMouseLeave)
|
||||
this.map.off('mousedown', this.id, this._onMouseDown)
|
||||
}
|
||||
|
||||
onMouseEnter() {
|
||||
this.canvas.style.cursor = 'move'
|
||||
}
|
||||
|
||||
onMouseLeave() {
|
||||
if (!this.isDragging) {
|
||||
this.canvas.style.cursor = ''
|
||||
}
|
||||
}
|
||||
|
||||
onMouseDown(e) {
|
||||
// Prevent default map drag behavior
|
||||
e.preventDefault()
|
||||
|
||||
// Store the feature being dragged
|
||||
this.draggedFeature = e.features[0]
|
||||
this.isDragging = true
|
||||
this.canvas.style.cursor = 'grabbing'
|
||||
|
||||
// Bind mouse move and up events
|
||||
this.map.on('mousemove', this._onMouseMove)
|
||||
this.map.once('mouseup', this._onMouseUp)
|
||||
}
|
||||
|
||||
onMouseMove(e) {
|
||||
if (!this.isDragging || !this.draggedFeature) return
|
||||
|
||||
// Get the new coordinates
|
||||
const coords = e.lngLat
|
||||
|
||||
// Update the feature's coordinates in the source
|
||||
const source = this.map.getSource(this.sourceId)
|
||||
if (source) {
|
||||
const data = source._data
|
||||
const feature = data.features.find(f => f.properties.id === this.draggedFeature.properties.id)
|
||||
if (feature) {
|
||||
feature.geometry.coordinates = [coords.lng, coords.lat]
|
||||
source.setData(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async onMouseUp(e) {
|
||||
if (!this.isDragging || !this.draggedFeature) return
|
||||
|
||||
const coords = e.lngLat
|
||||
const pointId = this.draggedFeature.properties.id
|
||||
const originalCoords = this.draggedFeature.geometry.coordinates
|
||||
|
||||
// Clean up drag state
|
||||
this.isDragging = false
|
||||
this.canvas.style.cursor = ''
|
||||
this.map.off('mousemove', this._onMouseMove)
|
||||
|
||||
// Update the point on the backend
|
||||
try {
|
||||
await this.updatePointPosition(pointId, coords.lat, coords.lng)
|
||||
|
||||
// Update routes after successful point update
|
||||
await this.updateConnectedRoutes(pointId, originalCoords, [coords.lng, coords.lat])
|
||||
} catch (error) {
|
||||
console.error('Failed to update point:', error)
|
||||
// Revert the point position on error
|
||||
const source = this.map.getSource(this.sourceId)
|
||||
if (source) {
|
||||
const data = source._data
|
||||
const feature = data.features.find(f => f.properties.id === pointId)
|
||||
if (feature && originalCoords) {
|
||||
feature.geometry.coordinates = originalCoords
|
||||
source.setData(data)
|
||||
}
|
||||
}
|
||||
Toast.error('Failed to update point position. Please try again.')
|
||||
}
|
||||
|
||||
this.draggedFeature = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Update point position via API
|
||||
*/
|
||||
async updatePointPosition(pointId, latitude, longitude) {
|
||||
if (!this.apiClient) {
|
||||
throw new Error('API client not configured')
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/v1/points/${pointId}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'Authorization': `Bearer ${this.apiClient.apiKey}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
point: {
|
||||
latitude: latitude.toString(),
|
||||
longitude: longitude.toString()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Update connected route segments when a point is moved
|
||||
*/
|
||||
async updateConnectedRoutes(pointId, oldCoords, newCoords) {
|
||||
if (!this.layerManager) {
|
||||
console.warn('LayerManager not configured, cannot update routes')
|
||||
return
|
||||
}
|
||||
|
||||
const routesLayer = this.layerManager.getLayer('routes')
|
||||
if (!routesLayer) {
|
||||
console.warn('Routes layer not found')
|
||||
return
|
||||
}
|
||||
|
||||
const routesSource = this.map.getSource(routesLayer.sourceId)
|
||||
if (!routesSource) {
|
||||
console.warn('Routes source not found')
|
||||
return
|
||||
}
|
||||
|
||||
const routesData = routesSource._data
|
||||
if (!routesData || !routesData.features) {
|
||||
return
|
||||
}
|
||||
|
||||
// Tolerance for coordinate comparison (account for floating point precision)
|
||||
const tolerance = 0.0001
|
||||
let routesUpdated = false
|
||||
|
||||
// Find and update route segments that contain the moved point
|
||||
routesData.features.forEach(feature => {
|
||||
if (feature.geometry.type === 'LineString') {
|
||||
const coordinates = feature.geometry.coordinates
|
||||
|
||||
// Check each coordinate in the line
|
||||
for (let i = 0; i < coordinates.length; i++) {
|
||||
const coord = coordinates[i]
|
||||
|
||||
// Check if this coordinate matches the old position
|
||||
if (Math.abs(coord[0] - oldCoords[0]) < tolerance &&
|
||||
Math.abs(coord[1] - oldCoords[1]) < tolerance) {
|
||||
// Update to new position
|
||||
coordinates[i] = newCoords
|
||||
routesUpdated = true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Update the routes source if any routes were modified
|
||||
if (routesUpdated) {
|
||||
routesSource.setData(routesData)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Override add method to enable dragging when layer is added
|
||||
*/
|
||||
add(data) {
|
||||
super.add(data)
|
||||
|
||||
// Wait for next tick to ensure layers are fully added before enabling dragging
|
||||
setTimeout(() => {
|
||||
this.enableDragging()
|
||||
}, 100)
|
||||
}
|
||||
|
||||
/**
|
||||
* Override remove method to clean up dragging handlers
|
||||
*/
|
||||
remove() {
|
||||
this.disableDragging()
|
||||
super.remove()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,94 +0,0 @@
|
|||
import { BaseLayer } from './base_layer'
|
||||
|
||||
/**
|
||||
* Recent point layer for displaying the most recent location in live mode
|
||||
* This layer is always visible when live mode is enabled, regardless of points layer visibility
|
||||
*/
|
||||
export class RecentPointLayer extends BaseLayer {
|
||||
constructor(map, options = {}) {
|
||||
super(map, { id: 'recent-point', visible: true, ...options })
|
||||
}
|
||||
|
||||
getSourceConfig() {
|
||||
return {
|
||||
type: 'geojson',
|
||||
data: this.data || {
|
||||
type: 'FeatureCollection',
|
||||
features: []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getLayerConfigs() {
|
||||
return [
|
||||
// Pulsing outer circle (animation effect)
|
||||
{
|
||||
id: `${this.id}-pulse`,
|
||||
type: 'circle',
|
||||
source: this.sourceId,
|
||||
paint: {
|
||||
'circle-color': '#ef4444',
|
||||
'circle-radius': [
|
||||
'interpolate',
|
||||
['linear'],
|
||||
['zoom'],
|
||||
0, 8,
|
||||
20, 40
|
||||
],
|
||||
'circle-opacity': 0.3
|
||||
}
|
||||
},
|
||||
// Main point circle
|
||||
{
|
||||
id: this.id,
|
||||
type: 'circle',
|
||||
source: this.sourceId,
|
||||
paint: {
|
||||
'circle-color': '#ef4444',
|
||||
'circle-radius': [
|
||||
'interpolate',
|
||||
['linear'],
|
||||
['zoom'],
|
||||
0, 6,
|
||||
20, 20
|
||||
],
|
||||
'circle-stroke-width': 2,
|
||||
'circle-stroke-color': '#ffffff'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Update layer with a single recent point
|
||||
* @param {number} lon - Longitude
|
||||
* @param {number} lat - Latitude
|
||||
* @param {Object} properties - Additional point properties
|
||||
*/
|
||||
updateRecentPoint(lon, lat, properties = {}) {
|
||||
const data = {
|
||||
type: 'FeatureCollection',
|
||||
features: [
|
||||
{
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Point',
|
||||
coordinates: [lon, lat]
|
||||
},
|
||||
properties
|
||||
}
|
||||
]
|
||||
}
|
||||
this.update(data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the recent point
|
||||
*/
|
||||
clear() {
|
||||
this.update({
|
||||
type: 'FeatureCollection',
|
||||
features: []
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1,16 +1,13 @@
|
|||
import { BaseLayer } from './base_layer'
|
||||
import { RouteSegmenter } from '../utils/route_segmenter'
|
||||
|
||||
/**
|
||||
* Routes layer showing travel paths
|
||||
* Connects points chronologically with solid color
|
||||
* Uses RouteSegmenter for route processing logic
|
||||
*/
|
||||
export class RoutesLayer extends BaseLayer {
|
||||
constructor(map, options = {}) {
|
||||
super(map, { id: 'routes', ...options })
|
||||
this.maxGapHours = options.maxGapHours || 5 // Max hours between points to connect
|
||||
this.hoverSourceId = 'routes-hover-source'
|
||||
}
|
||||
|
||||
getSourceConfig() {
|
||||
|
|
@ -23,36 +20,6 @@ export class RoutesLayer extends BaseLayer {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Override add() to create both main and hover sources
|
||||
*/
|
||||
add(data) {
|
||||
this.data = data
|
||||
|
||||
// Add main source
|
||||
if (!this.map.getSource(this.sourceId)) {
|
||||
this.map.addSource(this.sourceId, this.getSourceConfig())
|
||||
}
|
||||
|
||||
// Add hover source (initially empty)
|
||||
if (!this.map.getSource(this.hoverSourceId)) {
|
||||
this.map.addSource(this.hoverSourceId, {
|
||||
type: 'geojson',
|
||||
data: { type: 'FeatureCollection', features: [] }
|
||||
})
|
||||
}
|
||||
|
||||
// Add layers
|
||||
const layers = this.getLayerConfigs()
|
||||
layers.forEach(layerConfig => {
|
||||
if (!this.map.getLayer(layerConfig.id)) {
|
||||
this.map.addLayer(layerConfig)
|
||||
}
|
||||
})
|
||||
|
||||
this.setVisibility(this.visible)
|
||||
}
|
||||
|
||||
getLayerConfigs() {
|
||||
return [
|
||||
{
|
||||
|
|
@ -64,107 +31,16 @@ export class RoutesLayer extends BaseLayer {
|
|||
'line-cap': 'round'
|
||||
},
|
||||
paint: {
|
||||
// Use color from feature properties if available, otherwise default blue
|
||||
'line-color': [
|
||||
'case',
|
||||
['has', 'color'],
|
||||
['get', 'color'],
|
||||
'#0000ff' // Default blue color (matching v1)
|
||||
],
|
||||
'line-color': '#f97316', // Solid orange color
|
||||
'line-width': 3,
|
||||
'line-opacity': 0.8
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'routes-hover',
|
||||
type: 'line',
|
||||
source: this.hoverSourceId,
|
||||
layout: {
|
||||
'line-join': 'round',
|
||||
'line-cap': 'round'
|
||||
},
|
||||
paint: {
|
||||
'line-color': '#ffff00', // Yellow highlight
|
||||
'line-width': 8,
|
||||
'line-opacity': 1.0
|
||||
}
|
||||
}
|
||||
// Note: routes-hit layer is added separately in LayerManager after points layer
|
||||
// for better interactivity (see _addRoutesHitLayer method)
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Override setVisibility to also control routes-hit layer
|
||||
* @param {boolean} visible - Show/hide layer
|
||||
*/
|
||||
setVisibility(visible) {
|
||||
// Call parent to handle main routes and routes-hover layers
|
||||
super.setVisibility(visible)
|
||||
|
||||
// Also control routes-hit layer if it exists
|
||||
if (this.map.getLayer('routes-hit')) {
|
||||
const visibility = visible ? 'visible' : 'none'
|
||||
this.map.setLayoutProperty('routes-hit', 'visibility', visibility)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update hover layer with route geometry
|
||||
* @param {Object|null} feature - Route feature, FeatureCollection, or null to clear
|
||||
*/
|
||||
setHoverRoute(feature) {
|
||||
const hoverSource = this.map.getSource(this.hoverSourceId)
|
||||
if (!hoverSource) return
|
||||
|
||||
if (feature) {
|
||||
// Handle both single feature and FeatureCollection
|
||||
if (feature.type === 'FeatureCollection') {
|
||||
hoverSource.setData(feature)
|
||||
} else {
|
||||
hoverSource.setData({
|
||||
type: 'FeatureCollection',
|
||||
features: [feature]
|
||||
})
|
||||
}
|
||||
} else {
|
||||
hoverSource.setData({ type: 'FeatureCollection', features: [] })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Override remove() to clean up hover source and hit layer
|
||||
*/
|
||||
remove() {
|
||||
// Remove layers
|
||||
this.getLayerIds().forEach(layerId => {
|
||||
if (this.map.getLayer(layerId)) {
|
||||
this.map.removeLayer(layerId)
|
||||
}
|
||||
})
|
||||
|
||||
// Remove routes-hit layer if it exists
|
||||
if (this.map.getLayer('routes-hit')) {
|
||||
this.map.removeLayer('routes-hit')
|
||||
}
|
||||
|
||||
// Remove main source
|
||||
if (this.map.getSource(this.sourceId)) {
|
||||
this.map.removeSource(this.sourceId)
|
||||
}
|
||||
|
||||
// Remove hover source
|
||||
if (this.map.getSource(this.hoverSourceId)) {
|
||||
this.map.removeSource(this.hoverSourceId)
|
||||
}
|
||||
|
||||
this.data = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate haversine distance between two points in kilometers
|
||||
* Delegates to RouteSegmenter utility
|
||||
* @deprecated Use RouteSegmenter.haversineDistance directly
|
||||
* @param {number} lat1 - First point latitude
|
||||
* @param {number} lon1 - First point longitude
|
||||
* @param {number} lat2 - Second point latitude
|
||||
|
|
@ -172,17 +48,98 @@ export class RoutesLayer extends BaseLayer {
|
|||
* @returns {number} Distance in kilometers
|
||||
*/
|
||||
static haversineDistance(lat1, lon1, lat2, lon2) {
|
||||
return RouteSegmenter.haversineDistance(lat1, lon1, lat2, lon2)
|
||||
const R = 6371 // Earth's radius in kilometers
|
||||
const dLat = (lat2 - lat1) * Math.PI / 180
|
||||
const dLon = (lon2 - lon1) * Math.PI / 180
|
||||
const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
|
||||
Math.sin(dLon / 2) * Math.sin(dLon / 2)
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
|
||||
return R * c
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert points to route LineStrings with splitting
|
||||
* Delegates to RouteSegmenter utility for processing
|
||||
* Matches V1's route splitting logic for consistency
|
||||
* @param {Array} points - Points from API
|
||||
* @param {Object} options - Splitting options
|
||||
* @returns {Object} GeoJSON FeatureCollection
|
||||
*/
|
||||
static pointsToRoutes(points, options = {}) {
|
||||
return RouteSegmenter.pointsToRoutes(points, options)
|
||||
if (points.length < 2) {
|
||||
return { type: 'FeatureCollection', features: [] }
|
||||
}
|
||||
|
||||
// Default thresholds (matching V1 defaults from polylines.js)
|
||||
const distanceThresholdKm = (options.distanceThresholdMeters || 500) / 1000
|
||||
const timeThresholdMinutes = options.timeThresholdMinutes || 60
|
||||
|
||||
// Sort by timestamp
|
||||
const sorted = points.slice().sort((a, b) => a.timestamp - b.timestamp)
|
||||
|
||||
// Split into segments based on distance and time gaps (like V1)
|
||||
const segments = []
|
||||
let currentSegment = [sorted[0]]
|
||||
|
||||
for (let i = 1; i < sorted.length; i++) {
|
||||
const prev = sorted[i - 1]
|
||||
const curr = sorted[i]
|
||||
|
||||
// Calculate distance between consecutive points
|
||||
const distance = this.haversineDistance(
|
||||
prev.latitude, prev.longitude,
|
||||
curr.latitude, curr.longitude
|
||||
)
|
||||
|
||||
// Calculate time difference in minutes
|
||||
const timeDiff = (curr.timestamp - prev.timestamp) / 60
|
||||
|
||||
// Split if either threshold is exceeded (matching V1 logic)
|
||||
if (distance > distanceThresholdKm || timeDiff > timeThresholdMinutes) {
|
||||
if (currentSegment.length > 1) {
|
||||
segments.push(currentSegment)
|
||||
}
|
||||
currentSegment = [curr]
|
||||
} else {
|
||||
currentSegment.push(curr)
|
||||
}
|
||||
}
|
||||
|
||||
if (currentSegment.length > 1) {
|
||||
segments.push(currentSegment)
|
||||
}
|
||||
|
||||
// Convert segments to LineStrings
|
||||
const features = segments.map(segment => {
|
||||
const coordinates = segment.map(p => [p.longitude, p.latitude])
|
||||
|
||||
// Calculate total distance for the segment
|
||||
let totalDistance = 0
|
||||
for (let i = 0; i < segment.length - 1; i++) {
|
||||
totalDistance += this.haversineDistance(
|
||||
segment[i].latitude, segment[i].longitude,
|
||||
segment[i + 1].latitude, segment[i + 1].longitude
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'LineString',
|
||||
coordinates
|
||||
},
|
||||
properties: {
|
||||
pointCount: segment.length,
|
||||
startTime: segment[0].timestamp,
|
||||
endTime: segment[segment.length - 1].timestamp,
|
||||
distance: totalDistance
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,9 +18,7 @@ export class ApiClient {
|
|||
start_at,
|
||||
end_at,
|
||||
page: page.toString(),
|
||||
per_page: per_page.toString(),
|
||||
slim: 'true',
|
||||
order: 'asc'
|
||||
per_page: per_page.toString()
|
||||
})
|
||||
|
||||
const response = await fetch(`${this.baseURL}/points?${params}`, {
|
||||
|
|
@ -41,83 +39,43 @@ export class ApiClient {
|
|||
}
|
||||
|
||||
/**
|
||||
* Fetch all points for date range (handles pagination with parallel requests)
|
||||
* @param {Object} options - { start_at, end_at, onProgress, maxConcurrent }
|
||||
* Fetch all points for date range (handles pagination)
|
||||
* @param {Object} options - { start_at, end_at, onProgress }
|
||||
* @returns {Promise<Array>} All points
|
||||
*/
|
||||
async fetchAllPoints({ start_at, end_at, onProgress = null, maxConcurrent = 3 }) {
|
||||
// First fetch to get total pages
|
||||
const firstPage = await this.fetchPoints({ start_at, end_at, page: 1, per_page: 1000 })
|
||||
const totalPages = firstPage.totalPages
|
||||
async fetchAllPoints({ start_at, end_at, onProgress = null }) {
|
||||
const allPoints = []
|
||||
let page = 1
|
||||
let totalPages = 1
|
||||
|
||||
do {
|
||||
const { points, currentPage, totalPages: total } =
|
||||
await this.fetchPoints({ start_at, end_at, page, per_page: 1000 })
|
||||
|
||||
allPoints.push(...points)
|
||||
totalPages = total
|
||||
page++
|
||||
|
||||
// If only one page, return immediately
|
||||
if (totalPages === 1) {
|
||||
if (onProgress) {
|
||||
// Avoid division by zero - if no pages, progress is 100%
|
||||
const progress = totalPages > 0 ? currentPage / totalPages : 1.0
|
||||
onProgress({
|
||||
loaded: firstPage.points.length,
|
||||
currentPage: 1,
|
||||
totalPages: 1,
|
||||
progress: 1.0
|
||||
})
|
||||
}
|
||||
return firstPage.points
|
||||
}
|
||||
|
||||
// Initialize results array with first page
|
||||
const pageResults = [{ page: 1, points: firstPage.points }]
|
||||
let completedPages = 1
|
||||
|
||||
// Create array of remaining page numbers
|
||||
const remainingPages = Array.from(
|
||||
{ length: totalPages - 1 },
|
||||
(_, i) => i + 2
|
||||
)
|
||||
|
||||
// Process pages in batches of maxConcurrent
|
||||
for (let i = 0; i < remainingPages.length; i += maxConcurrent) {
|
||||
const batch = remainingPages.slice(i, i + maxConcurrent)
|
||||
|
||||
// Fetch batch in parallel
|
||||
const batchPromises = batch.map(page =>
|
||||
this.fetchPoints({ start_at, end_at, page, per_page: 1000 })
|
||||
.then(result => ({ page, points: result.points }))
|
||||
)
|
||||
|
||||
const batchResults = await Promise.all(batchPromises)
|
||||
pageResults.push(...batchResults)
|
||||
completedPages += batchResults.length
|
||||
|
||||
// Call progress callback after each batch
|
||||
if (onProgress) {
|
||||
const progress = totalPages > 0 ? completedPages / totalPages : 1.0
|
||||
onProgress({
|
||||
loaded: pageResults.reduce((sum, r) => sum + r.points.length, 0),
|
||||
currentPage: completedPages,
|
||||
loaded: allPoints.length,
|
||||
currentPage,
|
||||
totalPages,
|
||||
progress
|
||||
})
|
||||
}
|
||||
}
|
||||
} while (page <= totalPages)
|
||||
|
||||
// Sort by page number to ensure correct order
|
||||
pageResults.sort((a, b) => a.page - b.page)
|
||||
|
||||
// Flatten into single array
|
||||
return pageResults.flatMap(r => r.points)
|
||||
return allPoints
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch visits for date range (paginated)
|
||||
* @param {Object} options - { start_at, end_at, page, per_page }
|
||||
* @returns {Promise<Object>} { visits, currentPage, totalPages }
|
||||
* Fetch visits for date range
|
||||
*/
|
||||
async fetchVisitsPage({ start_at, end_at, page = 1, per_page = 500 }) {
|
||||
const params = new URLSearchParams({
|
||||
start_at,
|
||||
end_at,
|
||||
page: page.toString(),
|
||||
per_page: per_page.toString()
|
||||
})
|
||||
async fetchVisits({ start_at, end_at }) {
|
||||
const params = new URLSearchParams({ start_at, end_at })
|
||||
|
||||
const response = await fetch(`${this.baseURL}/visits?${params}`, {
|
||||
headers: this.getHeaders()
|
||||
|
|
@ -127,63 +85,20 @@ export class ApiClient {
|
|||
throw new Error(`Failed to fetch visits: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const visits = await response.json()
|
||||
|
||||
return {
|
||||
visits,
|
||||
currentPage: parseInt(response.headers.get('X-Current-Page') || '1'),
|
||||
totalPages: parseInt(response.headers.get('X-Total-Pages') || '1')
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all visits for date range (handles pagination)
|
||||
* @param {Object} options - { start_at, end_at, onProgress }
|
||||
* @returns {Promise<Array>} All visits
|
||||
* Fetch places optionally filtered by tags
|
||||
*/
|
||||
async fetchVisits({ start_at, end_at, onProgress = null }) {
|
||||
const allVisits = []
|
||||
let page = 1
|
||||
let totalPages = 1
|
||||
|
||||
do {
|
||||
const { visits, currentPage, totalPages: total } =
|
||||
await this.fetchVisitsPage({ start_at, end_at, page, per_page: 500 })
|
||||
|
||||
allVisits.push(...visits)
|
||||
totalPages = total
|
||||
page++
|
||||
|
||||
if (onProgress) {
|
||||
const progress = totalPages > 0 ? currentPage / totalPages : 1.0
|
||||
onProgress({
|
||||
loaded: allVisits.length,
|
||||
currentPage,
|
||||
totalPages,
|
||||
progress
|
||||
})
|
||||
}
|
||||
} while (page <= totalPages)
|
||||
|
||||
return allVisits
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch places (paginated)
|
||||
* @param {Object} options - { tag_ids, page, per_page }
|
||||
* @returns {Promise<Object>} { places, currentPage, totalPages }
|
||||
*/
|
||||
async fetchPlacesPage({ tag_ids = [], page = 1, per_page = 500 } = {}) {
|
||||
const params = new URLSearchParams({
|
||||
page: page.toString(),
|
||||
per_page: per_page.toString()
|
||||
})
|
||||
async fetchPlaces({ tag_ids = [] } = {}) {
|
||||
const params = new URLSearchParams()
|
||||
|
||||
if (tag_ids && tag_ids.length > 0) {
|
||||
tag_ids.forEach(id => params.append('tag_ids[]', id))
|
||||
}
|
||||
|
||||
const url = `${this.baseURL}/places?${params.toString()}`
|
||||
const url = `${this.baseURL}/places${params.toString() ? '?' + params.toString() : ''}`
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: this.getHeaders()
|
||||
|
|
@ -193,45 +108,7 @@ export class ApiClient {
|
|||
throw new Error(`Failed to fetch places: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const places = await response.json()
|
||||
|
||||
return {
|
||||
places,
|
||||
currentPage: parseInt(response.headers.get('X-Current-Page') || '1'),
|
||||
totalPages: parseInt(response.headers.get('X-Total-Pages') || '1')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all places optionally filtered by tags (handles pagination)
|
||||
* @param {Object} options - { tag_ids, onProgress }
|
||||
* @returns {Promise<Array>} All places
|
||||
*/
|
||||
async fetchPlaces({ tag_ids = [], onProgress = null } = {}) {
|
||||
const allPlaces = []
|
||||
let page = 1
|
||||
let totalPages = 1
|
||||
|
||||
do {
|
||||
const { places, currentPage, totalPages: total } =
|
||||
await this.fetchPlacesPage({ tag_ids, page, per_page: 500 })
|
||||
|
||||
allPlaces.push(...places)
|
||||
totalPages = total
|
||||
page++
|
||||
|
||||
if (onProgress) {
|
||||
const progress = totalPages > 0 ? currentPage / totalPages : 1.0
|
||||
onProgress({
|
||||
loaded: allPlaces.length,
|
||||
currentPage,
|
||||
totalPages,
|
||||
progress
|
||||
})
|
||||
}
|
||||
} while (page <= totalPages)
|
||||
|
||||
return allPlaces
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -246,11 +123,15 @@ export class ApiClient {
|
|||
})
|
||||
|
||||
const url = `${this.baseURL}/photos?${params}`
|
||||
console.log('[ApiClient] Fetching photos from:', url)
|
||||
console.log('[ApiClient] With headers:', this.getHeaders())
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: this.getHeaders()
|
||||
})
|
||||
|
||||
console.log('[ApiClient] Photos response status:', response.status)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch photos: ${response.statusText}`)
|
||||
}
|
||||
|
|
@ -274,38 +155,10 @@ export class ApiClient {
|
|||
}
|
||||
|
||||
/**
|
||||
* Fetch single area by ID
|
||||
* @param {number} areaId - Area ID
|
||||
* Fetch tracks
|
||||
*/
|
||||
async fetchArea(areaId) {
|
||||
const response = await fetch(`${this.baseURL}/areas/${areaId}`, {
|
||||
headers: this.getHeaders()
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch area: ${response.statusText}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch tracks for a single page
|
||||
* @param {Object} options - { start_at, end_at, page, per_page }
|
||||
* @returns {Promise<Object>} { features, currentPage, totalPages, totalCount }
|
||||
*/
|
||||
async fetchTracksPage({ start_at, end_at, page = 1, per_page = 100 }) {
|
||||
const params = new URLSearchParams({
|
||||
page: page.toString(),
|
||||
per_page: per_page.toString()
|
||||
})
|
||||
|
||||
if (start_at) params.append('start_at', start_at)
|
||||
if (end_at) params.append('end_at', end_at)
|
||||
|
||||
const url = `${this.baseURL}/tracks?${params.toString()}`
|
||||
|
||||
const response = await fetch(url, {
|
||||
async fetchTracks() {
|
||||
const response = await fetch(`${this.baseURL}/tracks`, {
|
||||
headers: this.getHeaders()
|
||||
})
|
||||
|
||||
|
|
@ -313,48 +166,7 @@ export class ApiClient {
|
|||
throw new Error(`Failed to fetch tracks: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const geojson = await response.json()
|
||||
|
||||
return {
|
||||
features: geojson.features,
|
||||
currentPage: parseInt(response.headers.get('X-Current-Page') || '1'),
|
||||
totalPages: parseInt(response.headers.get('X-Total-Pages') || '1'),
|
||||
totalCount: parseInt(response.headers.get('X-Total-Count') || '0')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all tracks (handles pagination automatically)
|
||||
* @param {Object} options - { start_at, end_at, onProgress }
|
||||
* @returns {Promise<Object>} GeoJSON FeatureCollection
|
||||
*/
|
||||
async fetchTracks({ start_at, end_at, onProgress } = {}) {
|
||||
let allFeatures = []
|
||||
let currentPage = 1
|
||||
let totalPages = 1
|
||||
|
||||
while (currentPage <= totalPages) {
|
||||
const { features, totalPages: tp } = await this.fetchTracksPage({
|
||||
start_at,
|
||||
end_at,
|
||||
page: currentPage,
|
||||
per_page: 100
|
||||
})
|
||||
|
||||
allFeatures = allFeatures.concat(features)
|
||||
totalPages = tp
|
||||
|
||||
if (onProgress) {
|
||||
onProgress(currentPage, totalPages)
|
||||
}
|
||||
|
||||
currentPage++
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: allFeatures
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -375,23 +187,6 @@ export class ApiClient {
|
|||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete area by ID
|
||||
* @param {number} areaId - Area ID
|
||||
*/
|
||||
async deleteArea(areaId) {
|
||||
const response = await fetch(`${this.baseURL}/areas/${areaId}`, {
|
||||
method: 'DELETE',
|
||||
headers: this.getHeaders()
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to delete area: ${response.statusText}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch points within a geographic area
|
||||
* @param {Object} options - { start_at, end_at, min_longitude, max_longitude, min_latitude, max_latitude }
|
||||
|
|
|
|||
|
|
@ -28,10 +28,9 @@ export function pointsToGeoJSON(points) {
|
|||
/**
|
||||
* Format timestamp for display
|
||||
* @param {number|string} timestamp - Unix timestamp (seconds) or ISO 8601 string
|
||||
* @param {string} timezone - IANA timezone string (e.g., 'Europe/Berlin')
|
||||
* @returns {string} Formatted date/time
|
||||
*/
|
||||
export function formatTimestamp(timestamp, timezone = 'UTC') {
|
||||
export function formatTimestamp(timestamp) {
|
||||
// Handle different timestamp formats
|
||||
let date
|
||||
if (typeof timestamp === 'string') {
|
||||
|
|
@ -50,7 +49,6 @@ export function formatTimestamp(timestamp, timezone = 'UTC') {
|
|||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZone: timezone
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,195 +0,0 @@
|
|||
/**
|
||||
* RouteSegmenter - Utility for converting points into route segments
|
||||
* Handles route splitting based on time/distance thresholds and IDL crossings
|
||||
*/
|
||||
export class RouteSegmenter {
|
||||
/**
|
||||
* Calculate haversine distance between two points in kilometers
|
||||
* @param {number} lat1 - First point latitude
|
||||
* @param {number} lon1 - First point longitude
|
||||
* @param {number} lat2 - Second point latitude
|
||||
* @param {number} lon2 - Second point longitude
|
||||
* @returns {number} Distance in kilometers
|
||||
*/
|
||||
static haversineDistance(lat1, lon1, lat2, lon2) {
|
||||
const R = 6371 // Earth's radius in kilometers
|
||||
const dLat = (lat2 - lat1) * Math.PI / 180
|
||||
const dLon = (lon2 - lon1) * Math.PI / 180
|
||||
const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
|
||||
Math.sin(dLon / 2) * Math.sin(dLon / 2)
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
|
||||
return R * c
|
||||
}
|
||||
|
||||
/**
|
||||
* Unwrap coordinates to handle International Date Line (IDL) crossings
|
||||
* This ensures routes draw the short way across IDL instead of wrapping around globe
|
||||
* @param {Array} segment - Array of points with longitude and latitude properties
|
||||
* @returns {Array} Array of [lon, lat] coordinate pairs with IDL unwrapping applied
|
||||
*/
|
||||
static unwrapCoordinates(segment) {
|
||||
const coordinates = []
|
||||
let offset = 0 // Cumulative longitude offset for unwrapping
|
||||
|
||||
for (let i = 0; i < segment.length; i++) {
|
||||
const point = segment[i]
|
||||
let lon = point.longitude + offset
|
||||
|
||||
// Check for IDL crossing between consecutive points
|
||||
if (i > 0) {
|
||||
const prevLon = coordinates[i - 1][0]
|
||||
const lonDiff = lon - prevLon
|
||||
|
||||
// If longitude jumps more than 180°, we crossed the IDL
|
||||
if (lonDiff > 180) {
|
||||
// Crossed from east to west (e.g., 170° to -170°)
|
||||
// Subtract 360° to make it continuous
|
||||
offset -= 360
|
||||
lon -= 360
|
||||
} else if (lonDiff < -180) {
|
||||
// Crossed from west to east (e.g., -170° to 170°)
|
||||
// Add 360° to make it continuous
|
||||
offset += 360
|
||||
lon += 360
|
||||
}
|
||||
}
|
||||
|
||||
coordinates.push([lon, point.latitude])
|
||||
}
|
||||
|
||||
return coordinates
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate total distance for a segment
|
||||
* @param {Array} segment - Array of points
|
||||
* @returns {number} Total distance in kilometers
|
||||
*/
|
||||
static calculateSegmentDistance(segment) {
|
||||
let totalDistance = 0
|
||||
for (let i = 0; i < segment.length - 1; i++) {
|
||||
totalDistance += this.haversineDistance(
|
||||
segment[i].latitude, segment[i].longitude,
|
||||
segment[i + 1].latitude, segment[i + 1].longitude
|
||||
)
|
||||
}
|
||||
return totalDistance
|
||||
}
|
||||
|
||||
/**
|
||||
* Split points into segments based on distance and time gaps
|
||||
* @param {Array} points - Sorted array of points
|
||||
* @param {Object} options - Splitting options
|
||||
* @param {number} options.distanceThresholdKm - Distance threshold in km
|
||||
* @param {number} options.timeThresholdMinutes - Time threshold in minutes
|
||||
* @returns {Array} Array of segments
|
||||
*/
|
||||
static splitIntoSegments(points, options) {
|
||||
const { distanceThresholdKm, timeThresholdMinutes } = options
|
||||
|
||||
const segments = []
|
||||
let currentSegment = [points[0]]
|
||||
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
const prev = points[i - 1]
|
||||
const curr = points[i]
|
||||
|
||||
// Calculate distance between consecutive points
|
||||
const distance = this.haversineDistance(
|
||||
prev.latitude, prev.longitude,
|
||||
curr.latitude, curr.longitude
|
||||
)
|
||||
|
||||
// Calculate time difference in minutes
|
||||
const timeDiff = (curr.timestamp - prev.timestamp) / 60
|
||||
|
||||
// Split if any threshold is exceeded
|
||||
if (distance > distanceThresholdKm || timeDiff > timeThresholdMinutes) {
|
||||
if (currentSegment.length > 1) {
|
||||
segments.push(currentSegment)
|
||||
}
|
||||
currentSegment = [curr]
|
||||
} else {
|
||||
currentSegment.push(curr)
|
||||
}
|
||||
}
|
||||
|
||||
if (currentSegment.length > 1) {
|
||||
segments.push(currentSegment)
|
||||
}
|
||||
|
||||
return segments
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a segment to a GeoJSON LineString feature
|
||||
* @param {Array} segment - Array of points
|
||||
* @returns {Object} GeoJSON Feature
|
||||
*/
|
||||
static segmentToFeature(segment) {
|
||||
const coordinates = this.unwrapCoordinates(segment)
|
||||
const totalDistance = this.calculateSegmentDistance(segment)
|
||||
|
||||
const startTime = segment[0].timestamp
|
||||
const endTime = segment[segment.length - 1].timestamp
|
||||
|
||||
// Generate a stable, unique route ID based on start/end times
|
||||
// This ensures the same route always has the same ID across re-renders
|
||||
const routeId = `route-${startTime}-${endTime}`
|
||||
|
||||
return {
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'LineString',
|
||||
coordinates
|
||||
},
|
||||
properties: {
|
||||
id: routeId,
|
||||
pointCount: segment.length,
|
||||
startTime: startTime,
|
||||
endTime: endTime,
|
||||
distance: totalDistance
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert points to route LineStrings with splitting
|
||||
* Matches V1's route splitting logic for consistency
|
||||
* Also handles International Date Line (IDL) crossings
|
||||
* @param {Array} points - Points from API
|
||||
* @param {Object} options - Splitting options
|
||||
* @param {number} options.distanceThresholdMeters - Distance threshold in meters (note: unit mismatch preserved for V1 compat)
|
||||
* @param {number} options.timeThresholdMinutes - Time threshold in minutes
|
||||
* @returns {Object} GeoJSON FeatureCollection
|
||||
*/
|
||||
static pointsToRoutes(points, options = {}) {
|
||||
if (points.length < 2) {
|
||||
return { type: 'FeatureCollection', features: [] }
|
||||
}
|
||||
|
||||
// Default thresholds (matching V1 defaults from polylines.js)
|
||||
// Note: V1 has a unit mismatch bug where it compares km to meters directly
|
||||
// We replicate this behavior for consistency with V1
|
||||
const distanceThresholdKm = options.distanceThresholdMeters || 500
|
||||
const timeThresholdMinutes = options.timeThresholdMinutes || 60
|
||||
|
||||
// Sort by timestamp
|
||||
const sorted = points.slice().sort((a, b) => a.timestamp - b.timestamp)
|
||||
|
||||
// Split into segments based on distance and time gaps
|
||||
const segments = this.splitIntoSegments(sorted, {
|
||||
distanceThresholdKm,
|
||||
timeThresholdMinutes
|
||||
})
|
||||
|
||||
// Convert segments to LineStrings
|
||||
const features = segments.map(segment => this.segmentToFeature(segment))
|
||||
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,21 +1,21 @@
|
|||
/**
|
||||
* Settings manager for persisting user preferences
|
||||
* Loads settings from backend API only (no localStorage)
|
||||
* Supports both localStorage (fallback) and backend API (primary)
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = 'dawarich-maps-maplibre-settings'
|
||||
|
||||
const DEFAULT_SETTINGS = {
|
||||
mapStyle: 'light',
|
||||
enabledMapLayers: ['Points', 'Routes'], // Compatible with v1 map
|
||||
// Advanced settings (matching v1 naming)
|
||||
routeOpacity: 0.6,
|
||||
fogOfWarRadius: 100,
|
||||
// Advanced settings
|
||||
routeOpacity: 1.0,
|
||||
fogOfWarRadius: 1000,
|
||||
fogOfWarThreshold: 1,
|
||||
metersBetweenRoutes: 500,
|
||||
minutesBetweenRoutes: 60,
|
||||
pointsRenderingMode: 'raw',
|
||||
speedColoredRoutes: false,
|
||||
speedColorScale: '0:#00ff00|15:#00ffff|30:#ff00ff|50:#ffff00|100:#ff3300',
|
||||
globeProjection: false
|
||||
speedColoredRoutes: false
|
||||
}
|
||||
|
||||
// Mapping between v2 layer names and v1 layer names in enabled_map_layers array
|
||||
|
|
@ -34,21 +34,11 @@ const LAYER_NAME_MAP = {
|
|||
// Mapping between frontend settings and backend API keys
|
||||
const BACKEND_SETTINGS_MAP = {
|
||||
mapStyle: 'maps_maplibre_style',
|
||||
enabledMapLayers: 'enabled_map_layers',
|
||||
routeOpacity: 'route_opacity',
|
||||
fogOfWarRadius: 'fog_of_war_meters',
|
||||
fogOfWarThreshold: 'fog_of_war_threshold',
|
||||
metersBetweenRoutes: 'meters_between_routes',
|
||||
minutesBetweenRoutes: 'minutes_between_routes',
|
||||
pointsRenderingMode: 'points_rendering_mode',
|
||||
speedColoredRoutes: 'speed_colored_routes',
|
||||
speedColorScale: 'speed_color_scale',
|
||||
globeProjection: 'globe_projection'
|
||||
enabledMapLayers: 'enabled_map_layers'
|
||||
}
|
||||
|
||||
export class SettingsManager {
|
||||
static apiKey = null
|
||||
static cachedSettings = null
|
||||
|
||||
/**
|
||||
* Initialize settings manager with API key
|
||||
|
|
@ -56,25 +46,24 @@ export class SettingsManager {
|
|||
*/
|
||||
static initialize(apiKey) {
|
||||
this.apiKey = apiKey
|
||||
this.cachedSettings = null // Clear cache on initialization
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all settings from cache or defaults
|
||||
* Get all settings (localStorage first, then merge with defaults)
|
||||
* Converts enabled_map_layers array to individual boolean flags
|
||||
* @returns {Object} Settings object
|
||||
*/
|
||||
static getSettings() {
|
||||
// Return cached settings if available
|
||||
if (this.cachedSettings) {
|
||||
return { ...this.cachedSettings }
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
const settings = stored ? { ...DEFAULT_SETTINGS, ...JSON.parse(stored) } : DEFAULT_SETTINGS
|
||||
|
||||
// Convert enabled_map_layers array to individual boolean flags
|
||||
return this._expandLayerSettings(settings)
|
||||
} catch (error) {
|
||||
console.error('Failed to load settings:', error)
|
||||
return DEFAULT_SETTINGS
|
||||
}
|
||||
|
||||
// Convert enabled_map_layers array to individual boolean flags
|
||||
const expandedSettings = this._expandLayerSettings(DEFAULT_SETTINGS)
|
||||
this.cachedSettings = expandedSettings
|
||||
|
||||
return { ...expandedSettings }
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -139,33 +128,14 @@ export class SettingsManager {
|
|||
const frontendSettings = {}
|
||||
Object.entries(BACKEND_SETTINGS_MAP).forEach(([frontendKey, backendKey]) => {
|
||||
if (backendKey in backendSettings) {
|
||||
let value = backendSettings[backendKey]
|
||||
|
||||
// Convert backend values to correct types
|
||||
if (frontendKey === 'routeOpacity') {
|
||||
value = parseFloat(value) || DEFAULT_SETTINGS.routeOpacity
|
||||
} else if (frontendKey === 'fogOfWarRadius') {
|
||||
value = parseInt(value) || DEFAULT_SETTINGS.fogOfWarRadius
|
||||
} else if (frontendKey === 'fogOfWarThreshold') {
|
||||
value = parseInt(value) || DEFAULT_SETTINGS.fogOfWarThreshold
|
||||
} else if (frontendKey === 'metersBetweenRoutes') {
|
||||
value = parseInt(value) || DEFAULT_SETTINGS.metersBetweenRoutes
|
||||
} else if (frontendKey === 'minutesBetweenRoutes') {
|
||||
value = parseInt(value) || DEFAULT_SETTINGS.minutesBetweenRoutes
|
||||
} else if (frontendKey === 'speedColoredRoutes') {
|
||||
value = value === true || value === 'true'
|
||||
} else if (frontendKey === 'globeProjection') {
|
||||
value = value === true || value === 'true'
|
||||
}
|
||||
|
||||
frontendSettings[frontendKey] = value
|
||||
frontendSettings[frontendKey] = backendSettings[backendKey]
|
||||
}
|
||||
})
|
||||
|
||||
// Merge with defaults
|
||||
// Merge with defaults, but prioritize backend's enabled_map_layers completely
|
||||
const mergedSettings = { ...DEFAULT_SETTINGS, ...frontendSettings }
|
||||
|
||||
// If backend has enabled_map_layers, use it as-is
|
||||
// If backend has enabled_map_layers, use it as-is (don't merge with defaults)
|
||||
if (backendSettings.enabled_map_layers) {
|
||||
mergedSettings.enabledMapLayers = backendSettings.enabled_map_layers
|
||||
}
|
||||
|
|
@ -173,8 +143,8 @@ export class SettingsManager {
|
|||
// Convert enabled_map_layers array to individual boolean flags
|
||||
const expandedSettings = this._expandLayerSettings(mergedSettings)
|
||||
|
||||
// Cache the settings
|
||||
this.cachedSettings = expandedSettings
|
||||
// Save to localStorage
|
||||
this.saveToLocalStorage(expandedSettings)
|
||||
|
||||
return expandedSettings
|
||||
} catch (error) {
|
||||
|
|
@ -184,11 +154,15 @@ export class SettingsManager {
|
|||
}
|
||||
|
||||
/**
|
||||
* Update cache with new settings
|
||||
* Save all settings to localStorage
|
||||
* @param {Object} settings - Settings object
|
||||
*/
|
||||
static updateCache(settings) {
|
||||
this.cachedSettings = { ...settings }
|
||||
static saveToLocalStorage(settings) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings))
|
||||
} catch (error) {
|
||||
console.error('Failed to save settings to localStorage:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -213,21 +187,7 @@ export class SettingsManager {
|
|||
// Use the collapsed array
|
||||
backendSettings[backendKey] = enabledMapLayers
|
||||
} else if (frontendKey in settings) {
|
||||
let value = settings[frontendKey]
|
||||
|
||||
// Convert frontend values to backend format
|
||||
if (frontendKey === 'routeOpacity') {
|
||||
value = parseFloat(value).toString()
|
||||
} else if (frontendKey === 'fogOfWarRadius' || frontendKey === 'fogOfWarThreshold' ||
|
||||
frontendKey === 'metersBetweenRoutes' || frontendKey === 'minutesBetweenRoutes') {
|
||||
value = parseInt(value).toString()
|
||||
} else if (frontendKey === 'speedColoredRoutes') {
|
||||
value = Boolean(value)
|
||||
} else if (frontendKey === 'globeProjection') {
|
||||
value = Boolean(value)
|
||||
}
|
||||
|
||||
backendSettings[backendKey] = value
|
||||
backendSettings[backendKey] = settings[frontendKey]
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -244,6 +204,7 @@ export class SettingsManager {
|
|||
throw new Error(`Failed to save settings: ${response.status}`)
|
||||
}
|
||||
|
||||
console.log('[Settings] Saved to backend successfully:', backendSettings)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('[Settings] Failed to save to backend:', error)
|
||||
|
|
@ -261,7 +222,7 @@ export class SettingsManager {
|
|||
}
|
||||
|
||||
/**
|
||||
* Update a specific setting and save to backend
|
||||
* Update a specific setting (saves to both localStorage and backend)
|
||||
* @param {string} key - Setting key
|
||||
* @param {*} value - New value
|
||||
*/
|
||||
|
|
@ -269,30 +230,27 @@ export class SettingsManager {
|
|||
const settings = this.getSettings()
|
||||
settings[key] = value
|
||||
|
||||
// If this is a layer visibility setting, also update the enabledMapLayers array
|
||||
// This ensures the array is in sync before backend save
|
||||
const isLayerSetting = Object.values(LAYER_NAME_MAP).includes(key)
|
||||
if (isLayerSetting) {
|
||||
settings.enabledMapLayers = this._collapseLayerSettings(settings)
|
||||
}
|
||||
// Save to localStorage immediately
|
||||
this.saveToLocalStorage(settings)
|
||||
|
||||
// Update cache immediately
|
||||
this.updateCache(settings)
|
||||
|
||||
// Save to backend
|
||||
await this.saveToBackend(settings)
|
||||
// Save to backend (non-blocking)
|
||||
this.saveToBackend(settings).catch(error => {
|
||||
console.warn('[Settings] Backend save failed, but localStorage updated:', error)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset to defaults
|
||||
*/
|
||||
static async resetToDefaults() {
|
||||
static resetToDefaults() {
|
||||
try {
|
||||
this.cachedSettings = null // Clear cache
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
|
||||
// Reset on backend
|
||||
// Also reset on backend
|
||||
if (this.apiKey) {
|
||||
await this.saveToBackend(DEFAULT_SETTINGS)
|
||||
this.saveToBackend(DEFAULT_SETTINGS).catch(error => {
|
||||
console.warn('[Settings] Failed to reset backend settings:', error)
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to reset settings:', error)
|
||||
|
|
@ -300,9 +258,9 @@ export class SettingsManager {
|
|||
}
|
||||
|
||||
/**
|
||||
* Sync settings: load from backend
|
||||
* Sync settings: load from backend and merge with localStorage
|
||||
* Call this on app initialization
|
||||
* @returns {Promise<Object>} Settings from backend
|
||||
* @returns {Promise<Object>} Merged settings
|
||||
*/
|
||||
static async sync() {
|
||||
const backendSettings = await this.loadFromBackend()
|
||||
|
|
|
|||
|
|
@ -102,7 +102,7 @@ function haversineDistance(lat1, lon1, lat2, lon2) {
|
|||
*/
|
||||
export function getSpeedColor(speedKmh, useSpeedColors, speedColorScale) {
|
||||
if (!useSpeedColors) {
|
||||
return '#0000ff' // Default blue color (matching v1)
|
||||
return '#f97316' // Default orange color
|
||||
}
|
||||
|
||||
let colorStops
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ class BulkVisitsSuggestingJob < ApplicationJob
|
|||
|
||||
users.active.find_each do |user|
|
||||
next unless user.safe_settings.visits_suggestions_enabled?
|
||||
next unless user.points_count&.positive?
|
||||
next unless user.points_count.positive?
|
||||
|
||||
schedule_chunked_jobs(user, time_chunks)
|
||||
end
|
||||
|
|
|
|||
8
app/jobs/cache/preheating_job.rb
vendored
8
app/jobs/cache/preheating_job.rb
vendored
|
|
@ -28,14 +28,6 @@ class Cache::PreheatingJob < ApplicationJob
|
|||
user.cities_visited_uncached,
|
||||
expires_in: 1.day
|
||||
)
|
||||
|
||||
# Preheat total_distance cache
|
||||
total_distance_meters = user.stats.sum(:distance)
|
||||
Rails.cache.write(
|
||||
"dawarich/user_#{user.id}_total_distance",
|
||||
Stat.convert_distance(total_distance_meters, user.safe_settings.distance_unit),
|
||||
expires_in: 1.day
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -4,8 +4,6 @@ class Family::Invitations::CleanupJob < ApplicationJob
|
|||
queue_as :families
|
||||
|
||||
def perform
|
||||
return unless DawarichSettings.family_feature_enabled?
|
||||
|
||||
Rails.logger.info 'Starting family invitations cleanup'
|
||||
|
||||
expired_count = Family::Invitation.where(status: :pending)
|
||||
|
|
|
|||
|
|
@ -1,46 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Imports::DestroyJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform(import_id)
|
||||
import = Import.find_by(id: import_id)
|
||||
return unless import
|
||||
|
||||
import.deleting!
|
||||
broadcast_status_update(import)
|
||||
|
||||
Imports::Destroy.new(import.user, import).call
|
||||
|
||||
broadcast_deletion_complete(import)
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
Rails.logger.warn "Import #{import_id} not found, may have already been deleted"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def broadcast_status_update(import)
|
||||
ImportsChannel.broadcast_to(
|
||||
import.user,
|
||||
{
|
||||
action: 'status_update',
|
||||
import: {
|
||||
id: import.id,
|
||||
status: import.status
|
||||
}
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def broadcast_deletion_complete(import)
|
||||
ImportsChannel.broadcast_to(
|
||||
import.user,
|
||||
{
|
||||
action: 'delete',
|
||||
import: {
|
||||
id: import.id
|
||||
}
|
||||
}
|
||||
)
|
||||
end
|
||||
end
|
||||
18
app/jobs/overland/batch_creating_job.rb
Normal file
18
app/jobs/overland/batch_creating_job.rb
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Overland::BatchCreatingJob < ApplicationJob
|
||||
include PointValidation
|
||||
|
||||
queue_as :points
|
||||
|
||||
def perform(params, user_id)
|
||||
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:))
|
||||
end
|
||||
end
|
||||
end
|
||||
16
app/jobs/owntracks/point_creating_job.rb
Normal file
16
app/jobs/owntracks/point_creating_job.rb
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Owntracks::PointCreatingJob < ApplicationJob
|
||||
include PointValidation
|
||||
|
||||
queue_as :points
|
||||
|
||||
def perform(point_params, user_id)
|
||||
parsed_params = OwnTracks::Params.new(point_params).call
|
||||
|
||||
return if parsed_params.try(:[], :timestamp).nil? || parsed_params.try(:[], :lonlat).nil?
|
||||
return if point_exists?(parsed_params, user_id)
|
||||
|
||||
Point.create!(parsed_params.merge(user_id:))
|
||||
end
|
||||
end
|
||||
|
|
@ -6,15 +6,8 @@ class Points::NightlyReverseGeocodingJob < ApplicationJob
|
|||
def perform
|
||||
return unless DawarichSettings.reverse_geocoding_enabled?
|
||||
|
||||
processed_user_ids = Set.new
|
||||
|
||||
Point.not_reverse_geocoded.find_each(batch_size: 1000) do |point|
|
||||
point.async_reverse_geocode
|
||||
processed_user_ids.add(point.user_id)
|
||||
end
|
||||
|
||||
processed_user_ids.each do |user_id|
|
||||
Cache::InvalidateUserCaches.new(user_id).call
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,21 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Points
|
||||
module RawData
|
||||
class ArchiveJob < ApplicationJob
|
||||
queue_as :archival
|
||||
|
||||
def perform
|
||||
return unless ENV['ARCHIVE_RAW_DATA'] == 'true'
|
||||
|
||||
stats = Points::RawData::Archiver.new.call
|
||||
|
||||
Rails.logger.info("Archive job complete: #{stats}")
|
||||
rescue StandardError => e
|
||||
ExceptionReporter.call(e, 'Points raw data archival job failed')
|
||||
|
||||
raise
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Points
|
||||
module RawData
|
||||
class ReArchiveMonthJob < ApplicationJob
|
||||
queue_as :archival
|
||||
|
||||
def perform(user_id, year, month)
|
||||
Rails.logger.info("Re-archiving #{user_id}/#{year}/#{month} (retrospective import)")
|
||||
|
||||
Points::RawData::Archiver.new.archive_specific_month(user_id, year, month)
|
||||
rescue StandardError => e
|
||||
ExceptionReporter.call(e, "Re-archival job failed for #{user_id}/#{year}/#{month}")
|
||||
|
||||
raise
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -21,7 +21,7 @@ class Tracks::DailyGenerationJob < ApplicationJob
|
|||
|
||||
def perform
|
||||
User.active_or_trial.find_each do |user|
|
||||
next if user.points_count&.zero?
|
||||
next if user.points_count.zero?
|
||||
|
||||
process_user_daily_tracks(user)
|
||||
rescue StandardError => e
|
||||
|
|
|
|||
|
|
@ -1,33 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Users::Digests::CalculatingJob < ApplicationJob
|
||||
queue_as :digests
|
||||
|
||||
def perform(user_id, year)
|
||||
recalculate_monthly_stats(user_id, year)
|
||||
Users::Digests::CalculateYear.new(user_id, year).call
|
||||
rescue StandardError => e
|
||||
create_digest_failed_notification(user_id, e)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def recalculate_monthly_stats(user_id, year)
|
||||
(1..12).each do |month|
|
||||
Stats::CalculateMonth.new(user_id, year, month).call
|
||||
end
|
||||
end
|
||||
|
||||
def create_digest_failed_notification(user_id, error)
|
||||
user = User.find(user_id)
|
||||
|
||||
Notifications::Create.new(
|
||||
user:,
|
||||
kind: :error,
|
||||
title: 'Year-End Digest calculation failed',
|
||||
content: "#{error.message}, stacktrace: #{error.backtrace.join("\n")}"
|
||||
).call
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Users::Digests::EmailSendingJob < ApplicationJob
|
||||
queue_as :mailers
|
||||
|
||||
def perform(user_id, year)
|
||||
user = User.find(user_id)
|
||||
digest = user.digests.yearly.find_by(year: year)
|
||||
|
||||
return unless should_send_email?(user, digest)
|
||||
|
||||
Users::DigestsMailer.with(user: user, digest: digest).year_end_digest.deliver_later
|
||||
|
||||
digest.update!(sent_at: Time.current)
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
ExceptionReporter.call(
|
||||
'Users::Digests::EmailSendingJob',
|
||||
"User with ID #{user_id} not found. Skipping year-end digest email."
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def should_send_email?(user, digest)
|
||||
return false unless user.safe_settings.digest_emails_enabled?
|
||||
return false if digest.blank?
|
||||
return false if digest.sent_at.present?
|
||||
|
||||
true
|
||||
end
|
||||
end
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Users::Digests::YearEndSchedulingJob < ApplicationJob
|
||||
queue_as :digests
|
||||
|
||||
def perform
|
||||
year = Time.current.year - 1 # Previous year's digest
|
||||
|
||||
::User.active_or_trial.find_each do |user|
|
||||
# Skip if user has no data for the year
|
||||
next unless user.stats.where(year: year).exists?
|
||||
|
||||
# Schedule calculation first
|
||||
Users::Digests::CalculatingJob.perform_later(user.id, year)
|
||||
|
||||
# Schedule email with delay to allow calculation to complete
|
||||
Users::Digests::EmailSendingJob.set(wait: 30.minutes).perform_later(user.id, year)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -6,7 +6,14 @@ class Users::MailerSendingJob < ApplicationJob
|
|||
def perform(user_id, email_type, **options)
|
||||
user = User.find(user_id)
|
||||
|
||||
return if should_skip_email?(user, email_type)
|
||||
if should_skip_email?(user, email_type)
|
||||
ExceptionReporter.call(
|
||||
'Users::MailerSendingJob',
|
||||
"Skipping #{email_type} email for user ID #{user_id} - #{skip_reason(user, email_type)}"
|
||||
)
|
||||
|
||||
return
|
||||
end
|
||||
|
||||
params = { user: user }.merge(options)
|
||||
|
||||
|
|
@ -30,4 +37,15 @@ class Users::MailerSendingJob < ApplicationJob
|
|||
false
|
||||
end
|
||||
end
|
||||
|
||||
def skip_reason(user, email_type)
|
||||
case email_type.to_s
|
||||
when 'trial_expires_soon', 'trial_expired'
|
||||
'user is already subscribed'
|
||||
when 'post_trial_reminder_early', 'post_trial_reminder_late'
|
||||
user.active? ? 'user is subscribed' : 'user is not in trial state'
|
||||
else
|
||||
'unknown reason'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,17 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Users::DigestsMailer < ApplicationMailer
|
||||
helper Users::DigestsHelper
|
||||
helper CountryFlagHelper
|
||||
|
||||
def year_end_digest
|
||||
@user = params[:user]
|
||||
@digest = params[:digest]
|
||||
@distance_unit = @user.safe_settings.distance_unit || 'km'
|
||||
|
||||
mail(
|
||||
to: @user.email,
|
||||
subject: "Your #{@digest.year} Year in Review - Dawarich"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Archivable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
belongs_to :raw_data_archive,
|
||||
class_name: 'Points::RawDataArchive',
|
||||
optional: true
|
||||
|
||||
scope :archived, -> { where(raw_data_archived: true) }
|
||||
scope :not_archived, -> { where(raw_data_archived: false) }
|
||||
scope :with_archived_raw_data, lambda {
|
||||
includes(raw_data_archive: { file_attachment: :blob })
|
||||
}
|
||||
end
|
||||
|
||||
# Main method: Get raw_data with fallback to archive
|
||||
# Use this instead of point.raw_data when you need archived data
|
||||
def raw_data_with_archive
|
||||
return raw_data if raw_data.present? || !raw_data_archived?
|
||||
|
||||
fetch_archived_raw_data
|
||||
end
|
||||
|
||||
# Restore archived data back to database column
|
||||
def restore_raw_data!(value)
|
||||
update!(
|
||||
raw_data: value,
|
||||
raw_data_archived: false,
|
||||
raw_data_archive_id: nil
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_archived_raw_data
|
||||
# Check temporary restore cache first (for migrations)
|
||||
cached = check_temporary_restore_cache
|
||||
return cached if cached
|
||||
|
||||
fetch_from_archive_file
|
||||
rescue StandardError => e
|
||||
handle_archive_fetch_error(e)
|
||||
end
|
||||
|
||||
def check_temporary_restore_cache
|
||||
return nil unless respond_to?(:timestamp)
|
||||
|
||||
recorded_time = Time.at(timestamp)
|
||||
cache_key = "raw_data:temp:#{user_id}:#{recorded_time.year}:#{recorded_time.month}:#{id}"
|
||||
Rails.cache.read(cache_key)
|
||||
end
|
||||
|
||||
def fetch_from_archive_file
|
||||
return {} unless raw_data_archive&.file&.attached?
|
||||
|
||||
# Download and search through JSONL
|
||||
compressed_content = raw_data_archive.file.blob.download
|
||||
io = StringIO.new(compressed_content)
|
||||
gz = Zlib::GzipReader.new(io)
|
||||
|
||||
begin
|
||||
result = nil
|
||||
gz.each_line do |line|
|
||||
data = JSON.parse(line)
|
||||
if data['id'] == id
|
||||
result = data['raw_data']
|
||||
break
|
||||
end
|
||||
end
|
||||
result || {}
|
||||
ensure
|
||||
gz.close
|
||||
end
|
||||
end
|
||||
|
||||
def handle_archive_fetch_error(error)
|
||||
ExceptionReporter.call(error, "Failed to fetch archived raw_data for Point ID #{id}")
|
||||
|
||||
{} # Graceful degradation
|
||||
end
|
||||
end
|
||||
|
|
@ -8,18 +8,18 @@ module Taggable
|
|||
has_many :tags, through: :taggings
|
||||
|
||||
scope :with_tags, ->(tag_ids) { joins(:taggings).where(taggings: { tag_id: tag_ids }).distinct }
|
||||
scope :with_all_tags, lambda { |tag_ids|
|
||||
tag_ids = Array(tag_ids).uniq
|
||||
scope :with_all_tags, ->(tag_ids) {
|
||||
tag_ids = Array(tag_ids)
|
||||
return none if tag_ids.empty?
|
||||
|
||||
# For each tag, join and filter, then use HAVING to ensure all tags are present
|
||||
joins(:taggings)
|
||||
.where(taggings: { tag_id: tag_ids })
|
||||
.group("#{table_name}.id")
|
||||
.having('COUNT(DISTINCT taggings.tag_id) = ?', tag_ids.length)
|
||||
.having("COUNT(DISTINCT taggings.tag_id) = ?", tag_ids.length)
|
||||
}
|
||||
scope :without_tags, -> { left_joins(:taggings).where(taggings: { id: nil }) }
|
||||
scope :tagged_with, lambda { |tag_name, user|
|
||||
scope :tagged_with, ->(tag_name, user) {
|
||||
joins(:tags).where(tags: { name: tag_name, user: user }).distinct
|
||||
}
|
||||
end
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ class Import < ApplicationRecord
|
|||
validate :file_size_within_limit, if: -> { user.trial? }
|
||||
validate :import_count_within_limit, if: -> { user.trial? }
|
||||
|
||||
enum :status, { created: 0, processing: 1, completed: 2, failed: 3, deleting: 4 }
|
||||
enum :status, { created: 0, processing: 1, completed: 2, failed: 3 }
|
||||
|
||||
enum :source, {
|
||||
google_semantic_history: 0, owntracks: 1, google_records: 2,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
class Point < ApplicationRecord
|
||||
include Nearable
|
||||
include Distanceable
|
||||
include Archivable
|
||||
|
||||
belongs_to :import, optional: true, counter_cache: true
|
||||
belongs_to :visit, optional: true
|
||||
|
|
|
|||
|
|
@ -1,70 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Points
|
||||
class RawDataArchive < ApplicationRecord
|
||||
self.table_name = 'points_raw_data_archives'
|
||||
|
||||
belongs_to :user
|
||||
has_many :points, dependent: :nullify
|
||||
|
||||
has_one_attached :file
|
||||
|
||||
validates :year, :month, :chunk_number, :point_count, presence: true
|
||||
validates :year, numericality: { greater_than: 1970, less_than: 2100 }
|
||||
validates :month, numericality: { greater_than_or_equal_to: 1, less_than_or_equal_to: 12 }
|
||||
validates :chunk_number, numericality: { greater_than: 0 }
|
||||
validates :point_count, numericality: { greater_than: 0 }
|
||||
validates :point_ids_checksum, presence: true
|
||||
|
||||
validate :metadata_contains_expected_and_actual_counts
|
||||
|
||||
scope :for_month, lambda { |user_id, year, month|
|
||||
where(user_id: user_id, year: year, month: month)
|
||||
.order(:chunk_number)
|
||||
}
|
||||
|
||||
scope :recent, -> { where('archived_at > ?', 30.days.ago) }
|
||||
scope :old, -> { where('archived_at < ?', 1.year.ago) }
|
||||
|
||||
def month_display
|
||||
Date.new(year, month, 1).strftime('%B %Y')
|
||||
end
|
||||
|
||||
def filename
|
||||
"raw_data_archives/#{user_id}/#{year}/#{format('%02d', month)}/#{format('%03d', chunk_number)}.jsonl.gz"
|
||||
end
|
||||
|
||||
def size_mb
|
||||
return 0 unless file.attached?
|
||||
|
||||
(file.blob.byte_size / 1024.0 / 1024.0).round(2)
|
||||
end
|
||||
|
||||
def verified?
|
||||
verified_at.present?
|
||||
end
|
||||
|
||||
def count_mismatch?
|
||||
return false unless metadata.present?
|
||||
|
||||
expected = metadata['expected_count']
|
||||
actual = metadata['actual_count']
|
||||
|
||||
return false if expected.nil? || actual.nil?
|
||||
|
||||
expected != actual
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def metadata_contains_expected_and_actual_counts
|
||||
return if metadata.blank?
|
||||
return if metadata['format_version'].blank?
|
||||
|
||||
# All archives must contain both expected_count and actual_count for data integrity
|
||||
if metadata['expected_count'].blank? || metadata['actual_count'].blank?
|
||||
errors.add(:metadata, 'must contain expected_count and actual_count')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -68,14 +68,12 @@ class Stat < ApplicationRecord
|
|||
|
||||
def enable_sharing!(expiration: '1h')
|
||||
# Default to 24h if an invalid expiration is provided
|
||||
expiration = '24h' unless %w[1h 12h 24h 1w 1m].include?(expiration)
|
||||
expiration = '24h' unless %w[1h 12h 24h].include?(expiration)
|
||||
|
||||
expires_at = case expiration
|
||||
when '1h' then 1.hour.from_now
|
||||
when '12h' then 12.hours.from_now
|
||||
when '24h' then 24.hours.from_now
|
||||
when '1w' then 1.week.from_now
|
||||
when '1m' then 1.month.from_now
|
||||
end
|
||||
|
||||
update!(
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ class Trip < ApplicationRecord
|
|||
belongs_to :user
|
||||
|
||||
validates :name, :started_at, :ended_at, presence: true
|
||||
validate :started_at_before_ended_at
|
||||
|
||||
after_create :enqueue_calculation_jobs
|
||||
after_update :enqueue_calculation_jobs, if: -> { saved_change_to_started_at? || saved_change_to_ended_at? }
|
||||
|
|
@ -48,11 +47,4 @@ class Trip < ApplicationRecord
|
|||
# to show all photos in the same height
|
||||
vertical_photos.count > horizontal_photos.count ? vertical_photos : horizontal_photos
|
||||
end
|
||||
|
||||
def started_at_before_ended_at
|
||||
return if started_at.blank? || ended_at.blank?
|
||||
return unless started_at >= ended_at
|
||||
|
||||
errors.add(:ended_at, 'must be after start date')
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -20,8 +20,6 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength
|
|||
has_many :tags, dependent: :destroy
|
||||
has_many :trips, dependent: :destroy
|
||||
has_many :tracks, dependent: :destroy
|
||||
has_many :raw_data_archives, class_name: 'Points::RawDataArchive', dependent: :destroy
|
||||
has_many :digests, class_name: 'Users::Digest', dependent: :destroy
|
||||
|
||||
after_create :create_api_key
|
||||
after_commit :activate, on: :create, if: -> { DawarichSettings.self_hosted? }
|
||||
|
|
@ -45,21 +43,24 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength
|
|||
|
||||
def countries_visited
|
||||
Rails.cache.fetch("dawarich/user_#{id}_countries_visited", expires_in: 1.day) do
|
||||
countries_visited_uncached
|
||||
points
|
||||
.without_raw_data
|
||||
.where.not(country_name: [nil, ''])
|
||||
.distinct
|
||||
.pluck(:country_name)
|
||||
.compact
|
||||
end
|
||||
end
|
||||
|
||||
def cities_visited
|
||||
Rails.cache.fetch("dawarich/user_#{id}_cities_visited", expires_in: 1.day) do
|
||||
cities_visited_uncached
|
||||
points.where.not(city: [nil, '']).distinct.pluck(:city).compact
|
||||
end
|
||||
end
|
||||
|
||||
def total_distance
|
||||
Rails.cache.fetch("dawarich/user_#{id}_total_distance", expires_in: 1.day) do
|
||||
total_distance_meters = stats.sum(:distance)
|
||||
Stat.convert_distance(total_distance_meters, safe_settings.distance_unit)
|
||||
end
|
||||
total_distance_meters = stats.sum(:distance)
|
||||
Stat.convert_distance(total_distance_meters, safe_settings.distance_unit)
|
||||
end
|
||||
|
||||
def total_countries
|
||||
|
|
@ -71,7 +72,7 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength
|
|||
end
|
||||
|
||||
def total_reverse_geocoded_points
|
||||
StatsQuery.new(self).points_stats[:geocoded]
|
||||
points.where.not(reverse_geocoded_at: nil).count
|
||||
end
|
||||
|
||||
def total_reverse_geocoded_points_without_data
|
||||
|
|
@ -136,47 +137,17 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength
|
|||
Time.zone.name
|
||||
end
|
||||
|
||||
# Aggregate countries from all stats' toponyms
|
||||
# This is more accurate than raw point queries as it uses processed data
|
||||
def countries_visited_uncached
|
||||
countries = Set.new
|
||||
|
||||
stats.find_each do |stat|
|
||||
toponyms = stat.toponyms
|
||||
next unless toponyms.is_a?(Array)
|
||||
|
||||
toponyms.each do |toponym|
|
||||
next unless toponym.is_a?(Hash)
|
||||
|
||||
countries.add(toponym['country']) if toponym['country'].present?
|
||||
end
|
||||
end
|
||||
|
||||
countries.to_a.sort
|
||||
points
|
||||
.without_raw_data
|
||||
.where.not(country_name: [nil, ''])
|
||||
.distinct
|
||||
.pluck(:country_name)
|
||||
.compact
|
||||
end
|
||||
|
||||
# Aggregate cities from all stats' toponyms
|
||||
# This respects MIN_MINUTES_SPENT_IN_CITY since toponyms are already filtered
|
||||
def cities_visited_uncached
|
||||
cities = Set.new
|
||||
|
||||
stats.find_each do |stat|
|
||||
toponyms = stat.toponyms
|
||||
next unless toponyms.is_a?(Array)
|
||||
|
||||
toponyms.each do |toponym|
|
||||
next unless toponym.is_a?(Hash)
|
||||
next unless toponym['cities'].is_a?(Array)
|
||||
|
||||
toponym['cities'].each do |city|
|
||||
next unless city.is_a?(Hash)
|
||||
|
||||
cities.add(city['city']) if city['city'].present?
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
cities.to_a.sort
|
||||
points.where.not(city: [nil, '']).distinct.pluck(:city).compact
|
||||
end
|
||||
|
||||
def home_place_coordinates
|
||||
|
|
|
|||
|
|
@ -1,170 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Users::Digest < ApplicationRecord
|
||||
self.table_name = 'digests'
|
||||
|
||||
include DistanceConvertible
|
||||
|
||||
EARTH_CIRCUMFERENCE_KM = 40_075
|
||||
MOON_DISTANCE_KM = 384_400
|
||||
|
||||
belongs_to :user
|
||||
|
||||
validates :year, :period_type, presence: true
|
||||
validates :year, uniqueness: { scope: %i[user_id period_type] }
|
||||
|
||||
before_create :generate_sharing_uuid
|
||||
|
||||
enum :period_type, { monthly: 0, yearly: 1 }
|
||||
|
||||
def sharing_enabled?
|
||||
sharing_settings.try(:[], 'enabled') == true
|
||||
end
|
||||
|
||||
def sharing_expired?
|
||||
expiration = sharing_settings.try(:[], 'expiration')
|
||||
return false if expiration.blank?
|
||||
|
||||
expires_at_value = sharing_settings.try(:[], 'expires_at')
|
||||
return true if expires_at_value.blank?
|
||||
|
||||
expires_at = begin
|
||||
Time.zone.parse(expires_at_value)
|
||||
rescue StandardError
|
||||
nil
|
||||
end
|
||||
|
||||
expires_at.present? ? Time.current > expires_at : true
|
||||
end
|
||||
|
||||
def public_accessible?
|
||||
sharing_enabled? && !sharing_expired?
|
||||
end
|
||||
|
||||
def generate_new_sharing_uuid!
|
||||
update!(sharing_uuid: SecureRandom.uuid)
|
||||
end
|
||||
|
||||
def enable_sharing!(expiration: '24h')
|
||||
expiration = '24h' unless %w[1h 12h 24h 1w 1m].include?(expiration)
|
||||
|
||||
expires_at = case expiration
|
||||
when '1h' then 1.hour.from_now
|
||||
when '12h' then 12.hours.from_now
|
||||
when '24h' then 24.hours.from_now
|
||||
when '1w' then 1.week.from_now
|
||||
when '1m' then 1.month.from_now
|
||||
end
|
||||
|
||||
update!(
|
||||
sharing_settings: {
|
||||
'enabled' => true,
|
||||
'expiration' => expiration,
|
||||
'expires_at' => expires_at.iso8601
|
||||
},
|
||||
sharing_uuid: sharing_uuid || SecureRandom.uuid
|
||||
)
|
||||
end
|
||||
|
||||
def disable_sharing!
|
||||
update!(
|
||||
sharing_settings: {
|
||||
'enabled' => false,
|
||||
'expiration' => nil,
|
||||
'expires_at' => nil
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def countries_count
|
||||
return 0 unless toponyms.is_a?(Array)
|
||||
|
||||
toponyms.count { |t| t['country'].present? }
|
||||
end
|
||||
|
||||
def cities_count
|
||||
return 0 unless toponyms.is_a?(Array)
|
||||
|
||||
toponyms.sum { |t| t['cities']&.count || 0 }
|
||||
end
|
||||
|
||||
def first_time_countries
|
||||
first_time_visits['countries'] || []
|
||||
end
|
||||
|
||||
def first_time_cities
|
||||
first_time_visits['cities'] || []
|
||||
end
|
||||
|
||||
def top_countries_by_time
|
||||
time_spent_by_location['countries'] || []
|
||||
end
|
||||
|
||||
def top_cities_by_time
|
||||
time_spent_by_location['cities'] || []
|
||||
end
|
||||
|
||||
def yoy_distance_change
|
||||
year_over_year['distance_change_percent']
|
||||
end
|
||||
|
||||
def yoy_countries_change
|
||||
year_over_year['countries_change']
|
||||
end
|
||||
|
||||
def yoy_cities_change
|
||||
year_over_year['cities_change']
|
||||
end
|
||||
|
||||
def previous_year
|
||||
year_over_year['previous_year']
|
||||
end
|
||||
|
||||
def total_countries_all_time
|
||||
all_time_stats['total_countries'] || 0
|
||||
end
|
||||
|
||||
def total_cities_all_time
|
||||
all_time_stats['total_cities'] || 0
|
||||
end
|
||||
|
||||
def total_distance_all_time
|
||||
(all_time_stats['total_distance'] || 0).to_i
|
||||
end
|
||||
|
||||
def untracked_days
|
||||
days_in_year = Date.leap?(year) ? 366 : 365
|
||||
[days_in_year - total_tracked_days, 0].max.round(1)
|
||||
end
|
||||
|
||||
def distance_km
|
||||
distance.to_f / 1000
|
||||
end
|
||||
|
||||
def distance_comparison_text
|
||||
if distance_km >= MOON_DISTANCE_KM
|
||||
percentage = ((distance_km / MOON_DISTANCE_KM) * 100).round(1)
|
||||
"That's #{percentage}% of the distance to the Moon!"
|
||||
else
|
||||
percentage = ((distance_km / EARTH_CIRCUMFERENCE_KM) * 100).round(1)
|
||||
"That's #{percentage}% of Earth's circumference!"
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_sharing_uuid
|
||||
self.sharing_uuid ||= SecureRandom.uuid
|
||||
end
|
||||
|
||||
def total_tracked_days
|
||||
(total_tracked_minutes / 1440.0).round(1)
|
||||
end
|
||||
|
||||
def total_tracked_minutes
|
||||
# Use total_country_minutes if available (new digests),
|
||||
# fall back to summing top_countries_by_time (existing digests)
|
||||
time_spent_by_location['total_country_minutes'] ||
|
||||
top_countries_by_time.sum { |country| country['minutes'].to_i }
|
||||
end
|
||||
end
|
||||
|
|
@ -11,7 +11,7 @@ class StatsQuery
|
|||
end
|
||||
|
||||
{
|
||||
total: user.points_count.to_i,
|
||||
total: user.points_count,
|
||||
geocoded: cached_stats[:geocoded],
|
||||
without_data: cached_stats[:without_data]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,68 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Tracks::IndexQuery
|
||||
DEFAULT_PER_PAGE = 100
|
||||
|
||||
def initialize(user:, params: {})
|
||||
@user = user
|
||||
@params = normalize_params(params)
|
||||
end
|
||||
|
||||
def call
|
||||
scoped = user.tracks
|
||||
scoped = apply_date_range(scoped)
|
||||
|
||||
scoped
|
||||
.order(start_at: :desc)
|
||||
.page(page_param)
|
||||
.per(per_page_param)
|
||||
end
|
||||
|
||||
def pagination_headers(paginated_relation)
|
||||
{
|
||||
'X-Current-Page' => paginated_relation.current_page.to_s,
|
||||
'X-Total-Pages' => paginated_relation.total_pages.to_s,
|
||||
'X-Total-Count' => paginated_relation.total_count.to_s
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :user, :params
|
||||
|
||||
def normalize_params(params)
|
||||
raw = if defined?(ActionController::Parameters) && params.is_a?(ActionController::Parameters)
|
||||
params.to_unsafe_h
|
||||
else
|
||||
params
|
||||
end
|
||||
|
||||
raw.with_indifferent_access
|
||||
end
|
||||
|
||||
def page_param
|
||||
candidate = params[:page].to_i
|
||||
candidate.positive? ? candidate : 1
|
||||
end
|
||||
|
||||
def per_page_param
|
||||
candidate = params[:per_page].to_i
|
||||
candidate.positive? ? candidate : DEFAULT_PER_PAGE
|
||||
end
|
||||
|
||||
def apply_date_range(scope)
|
||||
return scope unless params[:start_at].present? && params[:end_at].present?
|
||||
|
||||
start_at = parse_timestamp(params[:start_at])
|
||||
end_at = parse_timestamp(params[:end_at])
|
||||
return scope if start_at.blank? || end_at.blank?
|
||||
|
||||
scope.where('end_at >= ? AND start_at <= ?', start_at, end_at)
|
||||
end
|
||||
|
||||
def parse_timestamp(value)
|
||||
Time.zone.parse(value)
|
||||
rescue ArgumentError, TypeError
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
|
@ -42,8 +42,7 @@ class Api::UserSerializer
|
|||
photoprism_url: user.safe_settings.photoprism_url,
|
||||
visits_suggestions_enabled: user.safe_settings.visits_suggestions_enabled?,
|
||||
speed_color_scale: user.safe_settings.speed_color_scale,
|
||||
fog_of_war_threshold: user.safe_settings.fog_of_war_threshold,
|
||||
globe_projection: user.safe_settings.globe_projection
|
||||
fog_of_war_threshold: user.safe_settings.fog_of_war_threshold
|
||||
}
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ class StatsSerializer
|
|||
end
|
||||
|
||||
def reverse_geocoded_points
|
||||
StatsQuery.new(user).points_stats[:geocoded]
|
||||
user.points.reverse_geocoded.count
|
||||
end
|
||||
|
||||
def yearly_stats
|
||||
|
|
|
|||
|
|
@ -1,45 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Tracks::GeojsonSerializer
|
||||
DEFAULT_COLOR = '#ff0000'
|
||||
|
||||
def initialize(tracks)
|
||||
@tracks = Array.wrap(tracks)
|
||||
end
|
||||
|
||||
def call
|
||||
{
|
||||
type: 'FeatureCollection',
|
||||
features: tracks.map { |track| feature_for(track) }
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :tracks
|
||||
|
||||
def feature_for(track)
|
||||
{
|
||||
type: 'Feature',
|
||||
geometry: geometry_for(track),
|
||||
properties: properties_for(track)
|
||||
}
|
||||
end
|
||||
|
||||
def properties_for(track)
|
||||
{
|
||||
id: track.id,
|
||||
color: DEFAULT_COLOR,
|
||||
start_at: track.start_at.iso8601,
|
||||
end_at: track.end_at.iso8601,
|
||||
distance: track.distance.to_i,
|
||||
avg_speed: track.avg_speed.to_f,
|
||||
duration: track.duration
|
||||
}
|
||||
end
|
||||
|
||||
def geometry_for(track)
|
||||
geometry = RGeo::GeoJSON.encode(track.original_path)
|
||||
geometry.respond_to?(:as_json) ? geometry.as_json.deep_symbolize_keys : geometry
|
||||
end
|
||||
end
|
||||
11
app/services/cache/clean.rb
vendored
11
app/services/cache/clean.rb
vendored
|
|
@ -9,7 +9,6 @@ class Cache::Clean
|
|||
delete_years_tracked_cache
|
||||
delete_points_geocoded_stats_cache
|
||||
delete_countries_cities_cache
|
||||
delete_total_distance_cache
|
||||
Rails.logger.info('Cache cleaned')
|
||||
end
|
||||
|
||||
|
|
@ -37,14 +36,8 @@ class Cache::Clean
|
|||
|
||||
def delete_countries_cities_cache
|
||||
User.find_each do |user|
|
||||
Rails.cache.delete("dawarich/user_#{user.id}_countries_visited")
|
||||
Rails.cache.delete("dawarich/user_#{user.id}_cities_visited")
|
||||
end
|
||||
end
|
||||
|
||||
def delete_total_distance_cache
|
||||
User.find_each do |user|
|
||||
Rails.cache.delete("dawarich/user_#{user.id}_total_distance")
|
||||
Rails.cache.delete("dawarich/user_#{user.id}_countries")
|
||||
Rails.cache.delete("dawarich/user_#{user.id}_cities")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue