Compare commits

...

16 commits

Author SHA1 Message Date
Evgenii Burmakin
29f81738df
0.37.2 (#2114)
* fix: move foreman to global gems to fix startup crash (#1971)

* Update exporting code to stream points data to file in batches to red… (#1980)

* Update exporting code to stream points data to file in batches to reduce memory usage

* Update changelog

* Update changelog

* Feature/maplibre frontend (#1953)

* Add a plan to use MapLibre GL JS for the frontend map rendering, replacing Leaflet

* Implement phase 1

* Phases 1-3 + part of 4

* Fix e2e tests

* Phase 6

* Implement fog of war

* Phase 7

* Next step: fix specs, phase 7 done

* Use our own map tiles

* Extract v2 map logic to separate manager classes

* Update settings panel on v2 map

* Update v2 e2e tests structure

* Reimplement location search in maps v2

* Update speed routes

* Implement visits and places creation in v2

* Fix last failing test

* Implement visits merging

* Fix a routes e2e test and simplify the routes layer styling.

* Extract js to modules from maps_v2_controller.js

* Implement area creation

* Fix spec problem

* Fix some e2e tests

* Implement live mode in v2 map

* Update icons and panel

* Extract some styles

* Remove unused file

* Start adding dark theme to popups on MapLibre maps

* Make popups respect dark theme

* Move v2 maps to maplibre namespace

* Update v2 references to maplibre

* Put place, area and visit info into side panel

* Update API to use safe settings config method

* Fix specs

* Fix method name to config in SafeSettings and update usages accordingly

* Add missing public files

* Add handling for real time points

* Fix remembering enabled/disabled layers of the v2 map

* Fix lots of e2e tests

* Add settings to select map version

* Use maps/v2 as main path for MapLibre maps

* Update routing

* Update live mode

* Update maplibre controller

* Update changelog

* Remove some console.log statements

* Pull only necessary data for map v2 points

* Feature/raw data archive (#2009)

* 0.36.2 (#2007)

* fix: move foreman to global gems to fix startup crash (#1971)

* Update exporting code to stream points data to file in batches to red… (#1980)

* Update exporting code to stream points data to file in batches to reduce memory usage

* Update changelog

* Update changelog

* Feature/maplibre frontend (#1953)

* Add a plan to use MapLibre GL JS for the frontend map rendering, replacing Leaflet

* Implement phase 1

* Phases 1-3 + part of 4

* Fix e2e tests

* Phase 6

* Implement fog of war

* Phase 7

* Next step: fix specs, phase 7 done

* Use our own map tiles

* Extract v2 map logic to separate manager classes

* Update settings panel on v2 map

* Update v2 e2e tests structure

* Reimplement location search in maps v2

* Update speed routes

* Implement visits and places creation in v2

* Fix last failing test

* Implement visits merging

* Fix a routes e2e test and simplify the routes layer styling.

* Extract js to modules from maps_v2_controller.js

* Implement area creation

* Fix spec problem

* Fix some e2e tests

* Implement live mode in v2 map

* Update icons and panel

* Extract some styles

* Remove unused file

* Start adding dark theme to popups on MapLibre maps

* Make popups respect dark theme

* Move v2 maps to maplibre namespace

* Update v2 references to maplibre

* Put place, area and visit info into side panel

* Update API to use safe settings config method

* Fix specs

* Fix method name to config in SafeSettings and update usages accordingly

* Add missing public files

* Add handling for real time points

* Fix remembering enabled/disabled layers of the v2 map

* Fix lots of e2e tests

* Add settings to select map version

* Use maps/v2 as main path for MapLibre maps

* Update routing

* Update live mode

* Update maplibre controller

* Update changelog

* Remove some console.log statements

---------

Co-authored-by: Robin Tuszik <mail@robin.gg>

* Remove esbuild scripts from package.json

* Remove sideEffects field from package.json

* Raw data archivation

* Add tests

* Fix tests

* Fix tests

* Update ExceptionReporter

* Add schedule to run raw data archival job monthly

* Change file structure for raw data archival feature

* Update changelog and version for raw data archival feature

---------

Co-authored-by: Robin Tuszik <mail@robin.gg>

* Set raw_data to an empty hash instead of nil when archiving

* Fix storage configuration and file extraction

* Consider MIN_MINUTES_SPENT_IN_CITY during stats calculation (#2018)

* Consider MIN_MINUTES_SPENT_IN_CITY during stats calculation

* Remove raw data from visited cities api endpoint

* Use user timezone to show dates on maps (#2020)

* Fix/pre epoch time (#2019)

* Use user timezone to show dates on maps

* Limit timestamps to valid range to prevent database errors when users enter pre-epoch dates.

* Limit timestamps to valid range to prevent database errors when users enter pre-epoch dates.

* Fix tests failing due to new index on stats table

* Fix failing specs

* Update redis client configuration to support unix socket connection

* Update changelog

* Fix kml kmz import issues (#2023)

* Fix kml kmz import issues

* Refactor KML importer to improve readability and maintainability

* Implement moving points in map v2 and fix route rendering logic to ma… (#2027)

* Implement moving points in map v2 and fix route rendering logic to match map v1.

* Fix route spec

* fix(maplibre): update date format to ISO 8601 (#2029)

* Add verification step to raw data archival process (#2028)

* Add verification step to raw data archival process

* Add actual verification of raw data archives after creation, and only clear raw_data for verified archives.

* Fix failing specs

* Eliminate zip-bomb risk

* Fix potential memory leak in js

* Return .keep files

* Use Toast instead of alert for notifications

* Add help section to navbar dropdown

* Update changelog

* Remove raw_data_archival_job

* Ensure file is being closed properly after reading in Archivable concern

* Add composite index to stats table if not exists

* Update changelog

* Update entrypoint to always sync static assets (not only new ones)

* Add family layer to MapLibre maps (#2055)

* Add family layer to MapLibre maps

* Update migration

* Don't show family toggle if feature is disabled

* Update changelog

* Return changelog

* Update changelog

* Update tailwind file

* Bump sentry-rails from 6.0.0 to 6.1.0 (#1945)

Bumps [sentry-rails](https://github.com/getsentry/sentry-ruby) from 6.0.0 to 6.1.0.
- [Release notes](https://github.com/getsentry/sentry-ruby/releases)
- [Changelog](https://github.com/getsentry/sentry-ruby/blob/master/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-ruby/compare/6.0.0...6.1.0)

---
updated-dependencies:
- dependency-name: sentry-rails
  dependency-version: 6.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Bump turbo-rails from 2.0.17 to 2.0.20 (#1944)

Bumps [turbo-rails](https://github.com/hotwired/turbo-rails) from 2.0.17 to 2.0.20.
- [Release notes](https://github.com/hotwired/turbo-rails/releases)
- [Commits](https://github.com/hotwired/turbo-rails/compare/v2.0.17...v2.0.20)

---
updated-dependencies:
- dependency-name: turbo-rails
  dependency-version: 2.0.20
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Evgenii Burmakin <Freika@users.noreply.github.com>

* Bump webmock from 3.25.1 to 3.26.1 (#1943)

Bumps [webmock](https://github.com/bblimke/webmock) from 3.25.1 to 3.26.1.
- [Release notes](https://github.com/bblimke/webmock/releases)
- [Changelog](https://github.com/bblimke/webmock/blob/master/CHANGELOG.md)
- [Commits](https://github.com/bblimke/webmock/compare/v3.25.1...v3.26.1)

---
updated-dependencies:
- dependency-name: webmock
  dependency-version: 3.26.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Evgenii Burmakin <Freika@users.noreply.github.com>

* Bump brakeman from 7.1.0 to 7.1.1 (#1942)

Bumps [brakeman](https://github.com/presidentbeef/brakeman) from 7.1.0 to 7.1.1.
- [Release notes](https://github.com/presidentbeef/brakeman/releases)
- [Changelog](https://github.com/presidentbeef/brakeman/blob/main/CHANGES.md)
- [Commits](https://github.com/presidentbeef/brakeman/compare/v7.1.0...v7.1.1)

---
updated-dependencies:
- dependency-name: brakeman
  dependency-version: 7.1.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Bump redis from 5.4.0 to 5.4.1 (#1941)

Bumps [redis](https://github.com/redis/redis-rb) from 5.4.0 to 5.4.1.
- [Changelog](https://github.com/redis/redis-rb/blob/master/CHANGELOG.md)
- [Commits](https://github.com/redis/redis-rb/compare/v5.4.0...v5.4.1)

---
updated-dependencies:
- dependency-name: redis
  dependency-version: 5.4.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Put import deletion into background job (#2045)

* Put import deletion into background job

* Update changelog

* fix null type error and update heatmap styling (#2037)

* fix: use constant weight for maplibre heatmap layer

* fix null type, update heatmap styling

* improve heatmap styling

* fix typo

* Fix stats calculation to recursively reduce H3 resolution when too ma… (#2065)

* Fix stats calculation to recursively reduce H3 resolution when too many hexagons are generated

* Update CHANGELOG.md

* Validate trip start and end dates (#2066)

* Validate trip start and end dates

* Update changelog

* Update migration to clean up duplicate stats before adding unique index

* Fix fog of war radius setting being ignored and applying settings causing errors (#2068)

* Update changelog

* Add Rack::Deflater middleware to config/application.rb to enable gzip compression for responses.

* Add composite index to points on user_id and timestamp

* Deduplicte points based on timestamp brought to unix time

* Fix/stats cache invalidation (#2072)

* Fix family layer toggle in Map v2 settings for non-selfhosted env

* Invalidate cache

* Remove comments

* Remove comment

* Add new indicies to improve performance and remove unused ones to opt… (#2078)

* Add new indicies to improve performance and remove unused ones to optimize database.

* Remove comments

* Update map search suggestions panel styling

* Add yearly digest (#2073)

* Add yearly digest

* Rename YearlyDigests to Users::Digests

* Minor changes

* Update yearly digest layout and styles

* Add flags and chart to email

* Update colors

* Fix layout of stats in yearly digest view

* Remove cron job for yearly digest scheduling

* Update CHANGELOG.md

* Update digest email setting handling

* Allow sharing digest for 1 week or 1 month

* Change Digests Distance to Bigint

* Fix settings page

* Update changelog

* Add RailsPulse (#2079)

* Add RailsPulse

* Add RailsPulse monitoring tool with basic HTTP authentication

* Bring points_count to integer

* Update migration and version

* Update rubocop issues

* Fix migrations and data verification to remove safety_assured blocks and handle missing points gracefully.

* Update version

* Update calculation of time spent in a country for year-end digest email (#2110)

* Update calculation of time spent in a country for year-end digest email

* Add a filter to exclude raw data points when calculating yearly digests.

* Bump trix from 2.1.15 to 2.1.16 in the npm_and_yarn group across 1 directory (#2098)

* 0.37.1 (#2092)

* fix: move foreman to global gems to fix startup crash (#1971)

* Update exporting code to stream points data to file in batches to red… (#1980)

* Update exporting code to stream points data to file in batches to reduce memory usage

* Update changelog

* Update changelog

* Feature/maplibre frontend (#1953)

* Add a plan to use MapLibre GL JS for the frontend map rendering, replacing Leaflet

* Implement phase 1

* Phases 1-3 + part of 4

* Fix e2e tests

* Phase 6

* Implement fog of war

* Phase 7

* Next step: fix specs, phase 7 done

* Use our own map tiles

* Extract v2 map logic to separate manager classes

* Update settings panel on v2 map

* Update v2 e2e tests structure

* Reimplement location search in maps v2

* Update speed routes

* Implement visits and places creation in v2

* Fix last failing test

* Implement visits merging

* Fix a routes e2e test and simplify the routes layer styling.

* Extract js to modules from maps_v2_controller.js

* Implement area creation

* Fix spec problem

* Fix some e2e tests

* Implement live mode in v2 map

* Update icons and panel

* Extract some styles

* Remove unused file

* Start adding dark theme to popups on MapLibre maps

* Make popups respect dark theme

* Move v2 maps to maplibre namespace

* Update v2 references to maplibre

* Put place, area and visit info into side panel

* Update API to use safe settings config method

* Fix specs

* Fix method name to config in SafeSettings and update usages accordingly

* Add missing public files

* Add handling for real time points

* Fix remembering enabled/disabled layers of the v2 map

* Fix lots of e2e tests

* Add settings to select map version

* Use maps/v2 as main path for MapLibre maps

* Update routing

* Update live mode

* Update maplibre controller

* Update changelog

* Remove some console.log statements

* Pull only necessary data for map v2 points

* Feature/raw data archive (#2009)

* 0.36.2 (#2007)

* fix: move foreman to global gems to fix startup crash (#1971)

* Update exporting code to stream points data to file in batches to red… (#1980)

* Update exporting code to stream points data to file in batches to reduce memory usage

* Update changelog

* Update changelog

* Feature/maplibre frontend (#1953)

* Add a plan to use MapLibre GL JS for the frontend map rendering, replacing Leaflet

* Implement phase 1

* Phases 1-3 + part of 4

* Fix e2e tests

* Phase 6

* Implement fog of war

* Phase 7

* Next step: fix specs, phase 7 done

* Use our own map tiles

* Extract v2 map logic to separate manager classes

* Update settings panel on v2 map

* Update v2 e2e tests structure

* Reimplement location search in maps v2

* Update speed routes

* Implement visits and places creation in v2

* Fix last failing test

* Implement visits merging

* Fix a routes e2e test and simplify the routes layer styling.

* Extract js to modules from maps_v2_controller.js

* Implement area creation

* Fix spec problem

* Fix some e2e tests

* Implement live mode in v2 map

* Update icons and panel

* Extract some styles

* Remove unused file

* Start adding dark theme to popups on MapLibre maps

* Make popups respect dark theme

* Move v2 maps to maplibre namespace

* Update v2 references to maplibre

* Put place, area and visit info into side panel

* Update API to use safe settings config method

* Fix specs

* Fix method name to config in SafeSettings and update usages accordingly

* Add missing public files

* Add handling for real time points

* Fix remembering enabled/disabled layers of the v2 map

* Fix lots of e2e tests

* Add settings to select map version

* Use maps/v2 as main path for MapLibre maps

* Update routing

* Update live mode

* Update maplibre controller

* Update changelog

* Remove some console.log statements

---------

Co-authored-by: Robin Tuszik <mail@robin.gg>

* Remove esbuild scripts from package.json

* Remove sideEffects field from package.json

* Raw data archivation

* Add tests

* Fix tests

* Fix tests

* Update ExceptionReporter

* Add schedule to run raw data archival job monthly

* Change file structure for raw data archival feature

* Update changelog and version for raw data archival feature

---------

Co-authored-by: Robin Tuszik <mail@robin.gg>

* Set raw_data to an empty hash instead of nil when archiving

* Fix storage configuration and file extraction

* Consider MIN_MINUTES_SPENT_IN_CITY during stats calculation (#2018)

* Consider MIN_MINUTES_SPENT_IN_CITY during stats calculation

* Remove raw data from visited cities api endpoint

* Use user timezone to show dates on maps (#2020)

* Fix/pre epoch time (#2019)

* Use user timezone to show dates on maps

* Limit timestamps to valid range to prevent database errors when users enter pre-epoch dates.

* Limit timestamps to valid range to prevent database errors when users enter pre-epoch dates.

* Fix tests failing due to new index on stats table

* Fix failing specs

* Update redis client configuration to support unix socket connection

* Update changelog

* Fix kml kmz import issues (#2023)

* Fix kml kmz import issues

* Refactor KML importer to improve readability and maintainability

* Implement moving points in map v2 and fix route rendering logic to ma… (#2027)

* Implement moving points in map v2 and fix route rendering logic to match map v1.

* Fix route spec

* fix(maplibre): update date format to ISO 8601 (#2029)

* Add verification step to raw data archival process (#2028)

* Add verification step to raw data archival process

* Add actual verification of raw data archives after creation, and only clear raw_data for verified archives.

* Fix failing specs

* Eliminate zip-bomb risk

* Fix potential memory leak in js

* Return .keep files

* Use Toast instead of alert for notifications

* Add help section to navbar dropdown

* Update changelog

* Remove raw_data_archival_job

* Ensure file is being closed properly after reading in Archivable concern

* Add composite index to stats table if not exists

* Update changelog

* Update entrypoint to always sync static assets (not only new ones)

* Add family layer to MapLibre maps (#2055)

* Add family layer to MapLibre maps

* Update migration

* Don't show family toggle if feature is disabled

* Update changelog

* Return changelog

* Update changelog

* Update tailwind file

* Bump sentry-rails from 6.0.0 to 6.1.0 (#1945)

Bumps [sentry-rails](https://github.com/getsentry/sentry-ruby) from 6.0.0 to 6.1.0.
- [Release notes](https://github.com/getsentry/sentry-ruby/releases)
- [Changelog](https://github.com/getsentry/sentry-ruby/blob/master/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-ruby/compare/6.0.0...6.1.0)

---
updated-dependencies:
- dependency-name: sentry-rails
  dependency-version: 6.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Bump turbo-rails from 2.0.17 to 2.0.20 (#1944)

Bumps [turbo-rails](https://github.com/hotwired/turbo-rails) from 2.0.17 to 2.0.20.
- [Release notes](https://github.com/hotwired/turbo-rails/releases)
- [Commits](https://github.com/hotwired/turbo-rails/compare/v2.0.17...v2.0.20)

---
updated-dependencies:
- dependency-name: turbo-rails
  dependency-version: 2.0.20
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Evgenii Burmakin <Freika@users.noreply.github.com>

* Bump webmock from 3.25.1 to 3.26.1 (#1943)

Bumps [webmock](https://github.com/bblimke/webmock) from 3.25.1 to 3.26.1.
- [Release notes](https://github.com/bblimke/webmock/releases)
- [Changelog](https://github.com/bblimke/webmock/blob/master/CHANGELOG.md)
- [Commits](https://github.com/bblimke/webmock/compare/v3.25.1...v3.26.1)

---
updated-dependencies:
- dependency-name: webmock
  dependency-version: 3.26.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Evgenii Burmakin <Freika@users.noreply.github.com>

* Bump brakeman from 7.1.0 to 7.1.1 (#1942)

Bumps [brakeman](https://github.com/presidentbeef/brakeman) from 7.1.0 to 7.1.1.
- [Release notes](https://github.com/presidentbeef/brakeman/releases)
- [Changelog](https://github.com/presidentbeef/brakeman/blob/main/CHANGES.md)
- [Commits](https://github.com/presidentbeef/brakeman/compare/v7.1.0...v7.1.1)

---
updated-dependencies:
- dependency-name: brakeman
  dependency-version: 7.1.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Bump redis from 5.4.0 to 5.4.1 (#1941)

Bumps [redis](https://github.com/redis/redis-rb) from 5.4.0 to 5.4.1.
- [Changelog](https://github.com/redis/redis-rb/blob/master/CHANGELOG.md)
- [Commits](https://github.com/redis/redis-rb/compare/v5.4.0...v5.4.1)

---
updated-dependencies:
- dependency-name: redis
  dependency-version: 5.4.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Put import deletion into background job (#2045)

* Put import deletion into background job

* Update changelog

* fix null type error and update heatmap styling (#2037)

* fix: use constant weight for maplibre heatmap layer

* fix null type, update heatmap styling

* improve heatmap styling

* fix typo

* Fix stats calculation to recursively reduce H3 resolution when too ma… (#2065)

* Fix stats calculation to recursively reduce H3 resolution when too many hexagons are generated

* Update CHANGELOG.md

* Validate trip start and end dates (#2066)

* Validate trip start and end dates

* Update changelog

* Update migration to clean up duplicate stats before adding unique index

* Fix fog of war radius setting being ignored and applying settings causing errors (#2068)

* Update changelog

* Add Rack::Deflater middleware to config/application.rb to enable gzip compression for responses.

* Add composite index to points on user_id and timestamp

* Deduplicte points based on timestamp brought to unix time

* Fix/stats cache invalidation (#2072)

* Fix family layer toggle in Map v2 settings for non-selfhosted env

* Invalidate cache

* Remove comments

* Remove comment

* Add new indicies to improve performance and remove unused ones to opt… (#2078)

* Add new indicies to improve performance and remove unused ones to optimize database.

* Remove comments

* Update map search suggestions panel styling

* Add yearly digest (#2073)

* Add yearly digest

* Rename YearlyDigests to Users::Digests

* Minor changes

* Update yearly digest layout and styles

* Add flags and chart to email

* Update colors

* Fix layout of stats in yearly digest view

* Remove cron job for yearly digest scheduling

* Update CHANGELOG.md

* Update digest email setting handling

* Allow sharing digest for 1 week or 1 month

* Change Digests Distance to Bigint

* Fix settings page

* Update changelog

* Add RailsPulse (#2079)

* Add RailsPulse

* Add RailsPulse monitoring tool with basic HTTP authentication

* Bring points_count to integer

* Update migration and version

* Update rubocop issues

* Fix migrations and data verification to remove safety_assured blocks and handle missing points gracefully.

* Update version

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Robin Tuszik <mail@robin.gg>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Bump trix in the npm_and_yarn group across 1 directory

Bumps the npm_and_yarn group with 1 update in the / directory: [trix](https://github.com/basecamp/trix).


Updates `trix` from 2.1.15 to 2.1.16
- [Release notes](https://github.com/basecamp/trix/releases)
- [Commits](https://github.com/basecamp/trix/compare/v2.1.15...v2.1.16)

---
updated-dependencies:
- dependency-name: trix
  dependency-version: 2.1.16
  dependency-type: direct:production
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Evgenii Burmakin <Freika@users.noreply.github.com>
Co-authored-by: Robin Tuszik <mail@robin.gg>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Map v2 will no longer block the UI when Immich/Photoprism integration has a bad URL or is unreachable (#2113)

* Bump rubocop-rails from 2.33.4 to 2.34.2 (#2080)

Bumps [rubocop-rails](https://github.com/rubocop/rubocop-rails) from 2.33.4 to 2.34.2.
- [Release notes](https://github.com/rubocop/rubocop-rails/releases)
- [Changelog](https://github.com/rubocop/rubocop-rails/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rubocop/rubocop-rails/compare/v2.33.4...v2.34.2)

---
updated-dependencies:
- dependency-name: rubocop-rails
  dependency-version: 2.34.2
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Bump chartkick from 5.2.0 to 5.2.1 (#2081)

Bumps [chartkick](https://github.com/ankane/chartkick) from 5.2.0 to 5.2.1.
- [Changelog](https://github.com/ankane/chartkick/blob/master/CHANGELOG.md)
- [Commits](https://github.com/ankane/chartkick/compare/v5.2.0...v5.2.1)

---
updated-dependencies:
- dependency-name: chartkick
  dependency-version: 5.2.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Bump rubyzip from 3.2.0 to 3.2.2 (#2082)

Bumps [rubyzip](https://github.com/rubyzip/rubyzip) from 3.2.0 to 3.2.2.
- [Release notes](https://github.com/rubyzip/rubyzip/releases)
- [Changelog](https://github.com/rubyzip/rubyzip/blob/main/Changelog.md)
- [Commits](https://github.com/rubyzip/rubyzip/compare/v3.2.0...v3.2.2)

---
updated-dependencies:
- dependency-name: rubyzip
  dependency-version: 3.2.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Bump sentry-ruby from 6.0.0 to 6.2.0 (#2083)

Bumps [sentry-ruby](https://github.com/getsentry/sentry-ruby) from 6.0.0 to 6.2.0.
- [Release notes](https://github.com/getsentry/sentry-ruby/releases)
- [Changelog](https://github.com/getsentry/sentry-ruby/blob/master/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-ruby/compare/6.0.0...6.2.0)

---
updated-dependencies:
- dependency-name: sentry-ruby
  dependency-version: 6.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Evgenii Burmakin <Freika@users.noreply.github.com>

* Bump sidekiq from 8.0.8 to 8.1.0 (#2084)

Bumps [sidekiq](https://github.com/sidekiq/sidekiq) from 8.0.8 to 8.1.0.
- [Changelog](https://github.com/sidekiq/sidekiq/blob/main/Changes.md)
- [Commits](https://github.com/sidekiq/sidekiq/compare/v8.0.8...v8.1.0)

---
updated-dependencies:
- dependency-name: sidekiq
  dependency-version: 8.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Evgenii Burmakin <Freika@users.noreply.github.com>

* Update digest calculation to use actual time spent in countries based… (#2115)

* Update digest calculation to use actual time spent in countries based on consecutive points, avoiding double-counting days when crossing borders.

* Move methods to private

* Update Gemfile and Gemfile.lock to pin connection_pool and sidekiq versions

* Rework country tracked days calculation

* Adjust calculate_duration_in_minutes to only count continuous presence within cities, excluding long gaps.

* Move helpers for digest city progress to a helper method

* Implement globe projection option for Map v2 using MapLibre GL JS.

* Update time spent calculation for country minutes in user digests

* Stats are now calculated with more accuracy by storing total minutes spent per country.

* Add globe_projection setting to safe settings

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Robin Tuszik <mail@robin.gg>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-04 20:05:04 +01:00
Evgenii Burmakin
6ed6a4fd89
0.37.1 (#2092)
* fix: move foreman to global gems to fix startup crash (#1971)

* Update exporting code to stream points data to file in batches to red… (#1980)

* Update exporting code to stream points data to file in batches to reduce memory usage

* Update changelog

* Update changelog

* Feature/maplibre frontend (#1953)

* Add a plan to use MapLibre GL JS for the frontend map rendering, replacing Leaflet

* Implement phase 1

* Phases 1-3 + part of 4

* Fix e2e tests

* Phase 6

* Implement fog of war

* Phase 7

* Next step: fix specs, phase 7 done

* Use our own map tiles

* Extract v2 map logic to separate manager classes

* Update settings panel on v2 map

* Update v2 e2e tests structure

* Reimplement location search in maps v2

* Update speed routes

* Implement visits and places creation in v2

* Fix last failing test

* Implement visits merging

* Fix a routes e2e test and simplify the routes layer styling.

* Extract js to modules from maps_v2_controller.js

* Implement area creation

* Fix spec problem

* Fix some e2e tests

* Implement live mode in v2 map

* Update icons and panel

* Extract some styles

* Remove unused file

* Start adding dark theme to popups on MapLibre maps

* Make popups respect dark theme

* Move v2 maps to maplibre namespace

* Update v2 references to maplibre

* Put place, area and visit info into side panel

* Update API to use safe settings config method

* Fix specs

* Fix method name to config in SafeSettings and update usages accordingly

* Add missing public files

* Add handling for real time points

* Fix remembering enabled/disabled layers of the v2 map

* Fix lots of e2e tests

* Add settings to select map version

* Use maps/v2 as main path for MapLibre maps

* Update routing

* Update live mode

* Update maplibre controller

* Update changelog

* Remove some console.log statements

* Pull only necessary data for map v2 points

* Feature/raw data archive (#2009)

* 0.36.2 (#2007)

* fix: move foreman to global gems to fix startup crash (#1971)

* Update exporting code to stream points data to file in batches to red… (#1980)

* Update exporting code to stream points data to file in batches to reduce memory usage

* Update changelog

* Update changelog

* Feature/maplibre frontend (#1953)

* Add a plan to use MapLibre GL JS for the frontend map rendering, replacing Leaflet

* Implement phase 1

* Phases 1-3 + part of 4

* Fix e2e tests

* Phase 6

* Implement fog of war

* Phase 7

* Next step: fix specs, phase 7 done

* Use our own map tiles

* Extract v2 map logic to separate manager classes

* Update settings panel on v2 map

* Update v2 e2e tests structure

* Reimplement location search in maps v2

* Update speed routes

* Implement visits and places creation in v2

* Fix last failing test

* Implement visits merging

* Fix a routes e2e test and simplify the routes layer styling.

* Extract js to modules from maps_v2_controller.js

* Implement area creation

* Fix spec problem

* Fix some e2e tests

* Implement live mode in v2 map

* Update icons and panel

* Extract some styles

* Remove unused file

* Start adding dark theme to popups on MapLibre maps

* Make popups respect dark theme

* Move v2 maps to maplibre namespace

* Update v2 references to maplibre

* Put place, area and visit info into side panel

* Update API to use safe settings config method

* Fix specs

* Fix method name to config in SafeSettings and update usages accordingly

* Add missing public files

* Add handling for real time points

* Fix remembering enabled/disabled layers of the v2 map

* Fix lots of e2e tests

* Add settings to select map version

* Use maps/v2 as main path for MapLibre maps

* Update routing

* Update live mode

* Update maplibre controller

* Update changelog

* Remove some console.log statements

---------

Co-authored-by: Robin Tuszik <mail@robin.gg>

* Remove esbuild scripts from package.json

* Remove sideEffects field from package.json

* Raw data archivation

* Add tests

* Fix tests

* Fix tests

* Update ExceptionReporter

* Add schedule to run raw data archival job monthly

* Change file structure for raw data archival feature

* Update changelog and version for raw data archival feature

---------

Co-authored-by: Robin Tuszik <mail@robin.gg>

* Set raw_data to an empty hash instead of nil when archiving

* Fix storage configuration and file extraction

* Consider MIN_MINUTES_SPENT_IN_CITY during stats calculation (#2018)

* Consider MIN_MINUTES_SPENT_IN_CITY during stats calculation

* Remove raw data from visited cities api endpoint

* Use user timezone to show dates on maps (#2020)

* Fix/pre epoch time (#2019)

* Use user timezone to show dates on maps

* Limit timestamps to valid range to prevent database errors when users enter pre-epoch dates.

* Limit timestamps to valid range to prevent database errors when users enter pre-epoch dates.

* Fix tests failing due to new index on stats table

* Fix failing specs

* Update redis client configuration to support unix socket connection

* Update changelog

* Fix kml kmz import issues (#2023)

* Fix kml kmz import issues

* Refactor KML importer to improve readability and maintainability

* Implement moving points in map v2 and fix route rendering logic to ma… (#2027)

* Implement moving points in map v2 and fix route rendering logic to match map v1.

* Fix route spec

* fix(maplibre): update date format to ISO 8601 (#2029)

* Add verification step to raw data archival process (#2028)

* Add verification step to raw data archival process

* Add actual verification of raw data archives after creation, and only clear raw_data for verified archives.

* Fix failing specs

* Eliminate zip-bomb risk

* Fix potential memory leak in js

* Return .keep files

* Use Toast instead of alert for notifications

* Add help section to navbar dropdown

* Update changelog

* Remove raw_data_archival_job

* Ensure file is being closed properly after reading in Archivable concern

* Add composite index to stats table if not exists

* Update changelog

* Update entrypoint to always sync static assets (not only new ones)

* Add family layer to MapLibre maps (#2055)

* Add family layer to MapLibre maps

* Update migration

* Don't show family toggle if feature is disabled

* Update changelog

* Return changelog

* Update changelog

* Update tailwind file

* Bump sentry-rails from 6.0.0 to 6.1.0 (#1945)

Bumps [sentry-rails](https://github.com/getsentry/sentry-ruby) from 6.0.0 to 6.1.0.
- [Release notes](https://github.com/getsentry/sentry-ruby/releases)
- [Changelog](https://github.com/getsentry/sentry-ruby/blob/master/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-ruby/compare/6.0.0...6.1.0)

---
updated-dependencies:
- dependency-name: sentry-rails
  dependency-version: 6.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Bump turbo-rails from 2.0.17 to 2.0.20 (#1944)

Bumps [turbo-rails](https://github.com/hotwired/turbo-rails) from 2.0.17 to 2.0.20.
- [Release notes](https://github.com/hotwired/turbo-rails/releases)
- [Commits](https://github.com/hotwired/turbo-rails/compare/v2.0.17...v2.0.20)

---
updated-dependencies:
- dependency-name: turbo-rails
  dependency-version: 2.0.20
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Evgenii Burmakin <Freika@users.noreply.github.com>

* Bump webmock from 3.25.1 to 3.26.1 (#1943)

Bumps [webmock](https://github.com/bblimke/webmock) from 3.25.1 to 3.26.1.
- [Release notes](https://github.com/bblimke/webmock/releases)
- [Changelog](https://github.com/bblimke/webmock/blob/master/CHANGELOG.md)
- [Commits](https://github.com/bblimke/webmock/compare/v3.25.1...v3.26.1)

---
updated-dependencies:
- dependency-name: webmock
  dependency-version: 3.26.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Evgenii Burmakin <Freika@users.noreply.github.com>

* Bump brakeman from 7.1.0 to 7.1.1 (#1942)

Bumps [brakeman](https://github.com/presidentbeef/brakeman) from 7.1.0 to 7.1.1.
- [Release notes](https://github.com/presidentbeef/brakeman/releases)
- [Changelog](https://github.com/presidentbeef/brakeman/blob/main/CHANGES.md)
- [Commits](https://github.com/presidentbeef/brakeman/compare/v7.1.0...v7.1.1)

---
updated-dependencies:
- dependency-name: brakeman
  dependency-version: 7.1.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Bump redis from 5.4.0 to 5.4.1 (#1941)

Bumps [redis](https://github.com/redis/redis-rb) from 5.4.0 to 5.4.1.
- [Changelog](https://github.com/redis/redis-rb/blob/master/CHANGELOG.md)
- [Commits](https://github.com/redis/redis-rb/compare/v5.4.0...v5.4.1)

---
updated-dependencies:
- dependency-name: redis
  dependency-version: 5.4.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Put import deletion into background job (#2045)

* Put import deletion into background job

* Update changelog

* fix null type error and update heatmap styling (#2037)

* fix: use constant weight for maplibre heatmap layer

* fix null type, update heatmap styling

* improve heatmap styling

* fix typo

* Fix stats calculation to recursively reduce H3 resolution when too ma… (#2065)

* Fix stats calculation to recursively reduce H3 resolution when too many hexagons are generated

* Update CHANGELOG.md

* Validate trip start and end dates (#2066)

* Validate trip start and end dates

* Update changelog

* Update migration to clean up duplicate stats before adding unique index

* Fix fog of war radius setting being ignored and applying settings causing errors (#2068)

* Update changelog

* Add Rack::Deflater middleware to config/application.rb to enable gzip compression for responses.

* Add composite index to points on user_id and timestamp

* Deduplicte points based on timestamp brought to unix time

* Fix/stats cache invalidation (#2072)

* Fix family layer toggle in Map v2 settings for non-selfhosted env

* Invalidate cache

* Remove comments

* Remove comment

* Add new indicies to improve performance and remove unused ones to opt… (#2078)

* Add new indicies to improve performance and remove unused ones to optimize database.

* Remove comments

* Update map search suggestions panel styling

* Add yearly digest (#2073)

* Add yearly digest

* Rename YearlyDigests to Users::Digests

* Minor changes

* Update yearly digest layout and styles

* Add flags and chart to email

* Update colors

* Fix layout of stats in yearly digest view

* Remove cron job for yearly digest scheduling

* Update CHANGELOG.md

* Update digest email setting handling

* Allow sharing digest for 1 week or 1 month

* Change Digests Distance to Bigint

* Fix settings page

* Update changelog

* Add RailsPulse (#2079)

* Add RailsPulse

* Add RailsPulse monitoring tool with basic HTTP authentication

* Bring points_count to integer

* Update migration and version

* Update rubocop issues

* Fix migrations and data verification to remove safety_assured blocks and handle missing points gracefully.

* Update version

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Robin Tuszik <mail@robin.gg>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-30 19:06:10 +01:00
Evgenii Burmakin
8d2ade1bdc
0.37.0 (#2067)
* fix: move foreman to global gems to fix startup crash (#1971)

* Update exporting code to stream points data to file in batches to red… (#1980)

* Update exporting code to stream points data to file in batches to reduce memory usage

* Update changelog

* Update changelog

* Feature/maplibre frontend (#1953)

* Add a plan to use MapLibre GL JS for the frontend map rendering, replacing Leaflet

* Implement phase 1

* Phases 1-3 + part of 4

* Fix e2e tests

* Phase 6

* Implement fog of war

* Phase 7

* Next step: fix specs, phase 7 done

* Use our own map tiles

* Extract v2 map logic to separate manager classes

* Update settings panel on v2 map

* Update v2 e2e tests structure

* Reimplement location search in maps v2

* Update speed routes

* Implement visits and places creation in v2

* Fix last failing test

* Implement visits merging

* Fix a routes e2e test and simplify the routes layer styling.

* Extract js to modules from maps_v2_controller.js

* Implement area creation

* Fix spec problem

* Fix some e2e tests

* Implement live mode in v2 map

* Update icons and panel

* Extract some styles

* Remove unused file

* Start adding dark theme to popups on MapLibre maps

* Make popups respect dark theme

* Move v2 maps to maplibre namespace

* Update v2 references to maplibre

* Put place, area and visit info into side panel

* Update API to use safe settings config method

* Fix specs

* Fix method name to config in SafeSettings and update usages accordingly

* Add missing public files

* Add handling for real time points

* Fix remembering enabled/disabled layers of the v2 map

* Fix lots of e2e tests

* Add settings to select map version

* Use maps/v2 as main path for MapLibre maps

* Update routing

* Update live mode

* Update maplibre controller

* Update changelog

* Remove some console.log statements

* Pull only necessary data for map v2 points

* Feature/raw data archive (#2009)

* 0.36.2 (#2007)

* fix: move foreman to global gems to fix startup crash (#1971)

* Update exporting code to stream points data to file in batches to red… (#1980)

* Update exporting code to stream points data to file in batches to reduce memory usage

* Update changelog

* Update changelog

* Feature/maplibre frontend (#1953)

* Add a plan to use MapLibre GL JS for the frontend map rendering, replacing Leaflet

* Implement phase 1

* Phases 1-3 + part of 4

* Fix e2e tests

* Phase 6

* Implement fog of war

* Phase 7

* Next step: fix specs, phase 7 done

* Use our own map tiles

* Extract v2 map logic to separate manager classes

* Update settings panel on v2 map

* Update v2 e2e tests structure

* Reimplement location search in maps v2

* Update speed routes

* Implement visits and places creation in v2

* Fix last failing test

* Implement visits merging

* Fix a routes e2e test and simplify the routes layer styling.

* Extract js to modules from maps_v2_controller.js

* Implement area creation

* Fix spec problem

* Fix some e2e tests

* Implement live mode in v2 map

* Update icons and panel

* Extract some styles

* Remove unused file

* Start adding dark theme to popups on MapLibre maps

* Make popups respect dark theme

* Move v2 maps to maplibre namespace

* Update v2 references to maplibre

* Put place, area and visit info into side panel

* Update API to use safe settings config method

* Fix specs

* Fix method name to config in SafeSettings and update usages accordingly

* Add missing public files

* Add handling for real time points

* Fix remembering enabled/disabled layers of the v2 map

* Fix lots of e2e tests

* Add settings to select map version

* Use maps/v2 as main path for MapLibre maps

* Update routing

* Update live mode

* Update maplibre controller

* Update changelog

* Remove some console.log statements

---------

Co-authored-by: Robin Tuszik <mail@robin.gg>

* Remove esbuild scripts from package.json

* Remove sideEffects field from package.json

* Raw data archivation

* Add tests

* Fix tests

* Fix tests

* Update ExceptionReporter

* Add schedule to run raw data archival job monthly

* Change file structure for raw data archival feature

* Update changelog and version for raw data archival feature

---------

Co-authored-by: Robin Tuszik <mail@robin.gg>

* Set raw_data to an empty hash instead of nil when archiving

* Fix storage configuration and file extraction

* Consider MIN_MINUTES_SPENT_IN_CITY during stats calculation (#2018)

* Consider MIN_MINUTES_SPENT_IN_CITY during stats calculation

* Remove raw data from visited cities api endpoint

* Use user timezone to show dates on maps (#2020)

* Fix/pre epoch time (#2019)

* Use user timezone to show dates on maps

* Limit timestamps to valid range to prevent database errors when users enter pre-epoch dates.

* Limit timestamps to valid range to prevent database errors when users enter pre-epoch dates.

* Fix tests failing due to new index on stats table

* Fix failing specs

* Update redis client configuration to support unix socket connection

* Update changelog

* Fix kml kmz import issues (#2023)

* Fix kml kmz import issues

* Refactor KML importer to improve readability and maintainability

* Implement moving points in map v2 and fix route rendering logic to ma… (#2027)

* Implement moving points in map v2 and fix route rendering logic to match map v1.

* Fix route spec

* fix(maplibre): update date format to ISO 8601 (#2029)

* Add verification step to raw data archival process (#2028)

* Add verification step to raw data archival process

* Add actual verification of raw data archives after creation, and only clear raw_data for verified archives.

* Fix failing specs

* Eliminate zip-bomb risk

* Fix potential memory leak in js

* Return .keep files

* Use Toast instead of alert for notifications

* Add help section to navbar dropdown

* Update changelog

* Remove raw_data_archival_job

* Ensure file is being closed properly after reading in Archivable concern

* Add composite index to stats table if not exists

* Update changelog

* Update entrypoint to always sync static assets (not only new ones)

* Add family layer to MapLibre maps (#2055)

* Add family layer to MapLibre maps

* Update migration

* Don't show family toggle if feature is disabled

* Update changelog

* Return changelog

* Update changelog

* Update tailwind file

* Bump sentry-rails from 6.0.0 to 6.1.0 (#1945)

Bumps [sentry-rails](https://github.com/getsentry/sentry-ruby) from 6.0.0 to 6.1.0.
- [Release notes](https://github.com/getsentry/sentry-ruby/releases)
- [Changelog](https://github.com/getsentry/sentry-ruby/blob/master/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-ruby/compare/6.0.0...6.1.0)

---
updated-dependencies:
- dependency-name: sentry-rails
  dependency-version: 6.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Bump turbo-rails from 2.0.17 to 2.0.20 (#1944)

Bumps [turbo-rails](https://github.com/hotwired/turbo-rails) from 2.0.17 to 2.0.20.
- [Release notes](https://github.com/hotwired/turbo-rails/releases)
- [Commits](https://github.com/hotwired/turbo-rails/compare/v2.0.17...v2.0.20)

---
updated-dependencies:
- dependency-name: turbo-rails
  dependency-version: 2.0.20
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Evgenii Burmakin <Freika@users.noreply.github.com>

* Bump webmock from 3.25.1 to 3.26.1 (#1943)

Bumps [webmock](https://github.com/bblimke/webmock) from 3.25.1 to 3.26.1.
- [Release notes](https://github.com/bblimke/webmock/releases)
- [Changelog](https://github.com/bblimke/webmock/blob/master/CHANGELOG.md)
- [Commits](https://github.com/bblimke/webmock/compare/v3.25.1...v3.26.1)

---
updated-dependencies:
- dependency-name: webmock
  dependency-version: 3.26.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Evgenii Burmakin <Freika@users.noreply.github.com>

* Bump brakeman from 7.1.0 to 7.1.1 (#1942)

Bumps [brakeman](https://github.com/presidentbeef/brakeman) from 7.1.0 to 7.1.1.
- [Release notes](https://github.com/presidentbeef/brakeman/releases)
- [Changelog](https://github.com/presidentbeef/brakeman/blob/main/CHANGES.md)
- [Commits](https://github.com/presidentbeef/brakeman/compare/v7.1.0...v7.1.1)

---
updated-dependencies:
- dependency-name: brakeman
  dependency-version: 7.1.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Bump redis from 5.4.0 to 5.4.1 (#1941)

Bumps [redis](https://github.com/redis/redis-rb) from 5.4.0 to 5.4.1.
- [Changelog](https://github.com/redis/redis-rb/blob/master/CHANGELOG.md)
- [Commits](https://github.com/redis/redis-rb/compare/v5.4.0...v5.4.1)

---
updated-dependencies:
- dependency-name: redis
  dependency-version: 5.4.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Put import deletion into background job (#2045)

* Put import deletion into background job

* Update changelog

* fix null type error and update heatmap styling (#2037)

* fix: use constant weight for maplibre heatmap layer

* fix null type, update heatmap styling

* improve heatmap styling

* fix typo

* Fix stats calculation to recursively reduce H3 resolution when too ma… (#2065)

* Fix stats calculation to recursively reduce H3 resolution when too many hexagons are generated

* Update CHANGELOG.md

* Validate trip start and end dates (#2066)

* Validate trip start and end dates

* Update changelog

* Update migration to clean up duplicate stats before adding unique index

* Fix fog of war radius setting being ignored and applying settings causing errors (#2068)

* Update changelog

* Add Rack::Deflater middleware to config/application.rb to enable gzip compression for responses.

* Add composite index to points on user_id and timestamp

* Deduplicte points based on timestamp brought to unix time

* Fix/stats cache invalidation (#2072)

* Fix family layer toggle in Map v2 settings for non-selfhosted env

* Invalidate cache

* Remove comments

* Remove comment

* Add new indicies to improve performance and remove unused ones to opt… (#2078)

* Add new indicies to improve performance and remove unused ones to optimize database.

* Remove comments

* Update map search suggestions panel styling

* Add yearly digest (#2073)

* Add yearly digest

* Rename YearlyDigests to Users::Digests

* Minor changes

* Update yearly digest layout and styles

* Add flags and chart to email

* Update colors

* Fix layout of stats in yearly digest view

* Remove cron job for yearly digest scheduling

* Update CHANGELOG.md

* Update digest email setting handling

* Allow sharing digest for 1 week or 1 month

* Change Digests Distance to Bigint

* Fix settings page

* Update changelog

* Add RailsPulse (#2079)

* Add RailsPulse

* Add RailsPulse monitoring tool with basic HTTP authentication

* Bring points_count to integer

* Update migration and version

* Update rubocop issues

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Robin Tuszik <mail@robin.gg>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-30 17:33:56 +01:00
Evgenii Burmakin
3f0aaa09f5
0.36.4 (#2062)
* fix: move foreman to global gems to fix startup crash (#1971)

* Update exporting code to stream points data to file in batches to red… (#1980)

* Update exporting code to stream points data to file in batches to reduce memory usage

* Update changelog

* Update changelog

* Feature/maplibre frontend (#1953)

* Add a plan to use MapLibre GL JS for the frontend map rendering, replacing Leaflet

* Implement phase 1

* Phases 1-3 + part of 4

* Fix e2e tests

* Phase 6

* Implement fog of war

* Phase 7

* Next step: fix specs, phase 7 done

* Use our own map tiles

* Extract v2 map logic to separate manager classes

* Update settings panel on v2 map

* Update v2 e2e tests structure

* Reimplement location search in maps v2

* Update speed routes

* Implement visits and places creation in v2

* Fix last failing test

* Implement visits merging

* Fix a routes e2e test and simplify the routes layer styling.

* Extract js to modules from maps_v2_controller.js

* Implement area creation

* Fix spec problem

* Fix some e2e tests

* Implement live mode in v2 map

* Update icons and panel

* Extract some styles

* Remove unused file

* Start adding dark theme to popups on MapLibre maps

* Make popups respect dark theme

* Move v2 maps to maplibre namespace

* Update v2 references to maplibre

* Put place, area and visit info into side panel

* Update API to use safe settings config method

* Fix specs

* Fix method name to config in SafeSettings and update usages accordingly

* Add missing public files

* Add handling for real time points

* Fix remembering enabled/disabled layers of the v2 map

* Fix lots of e2e tests

* Add settings to select map version

* Use maps/v2 as main path for MapLibre maps

* Update routing

* Update live mode

* Update maplibre controller

* Update changelog

* Remove some console.log statements

* Pull only necessary data for map v2 points

* Feature/raw data archive (#2009)

* 0.36.2 (#2007)

* fix: move foreman to global gems to fix startup crash (#1971)

* Update exporting code to stream points data to file in batches to red… (#1980)

* Update exporting code to stream points data to file in batches to reduce memory usage

* Update changelog

* Update changelog

* Feature/maplibre frontend (#1953)

* Add a plan to use MapLibre GL JS for the frontend map rendering, replacing Leaflet

* Implement phase 1

* Phases 1-3 + part of 4

* Fix e2e tests

* Phase 6

* Implement fog of war

* Phase 7

* Next step: fix specs, phase 7 done

* Use our own map tiles

* Extract v2 map logic to separate manager classes

* Update settings panel on v2 map

* Update v2 e2e tests structure

* Reimplement location search in maps v2

* Update speed routes

* Implement visits and places creation in v2

* Fix last failing test

* Implement visits merging

* Fix a routes e2e test and simplify the routes layer styling.

* Extract js to modules from maps_v2_controller.js

* Implement area creation

* Fix spec problem

* Fix some e2e tests

* Implement live mode in v2 map

* Update icons and panel

* Extract some styles

* Remove unused file

* Start adding dark theme to popups on MapLibre maps

* Make popups respect dark theme

* Move v2 maps to maplibre namespace

* Update v2 references to maplibre

* Put place, area and visit info into side panel

* Update API to use safe settings config method

* Fix specs

* Fix method name to config in SafeSettings and update usages accordingly

* Add missing public files

* Add handling for real time points

* Fix remembering enabled/disabled layers of the v2 map

* Fix lots of e2e tests

* Add settings to select map version

* Use maps/v2 as main path for MapLibre maps

* Update routing

* Update live mode

* Update maplibre controller

* Update changelog

* Remove some console.log statements

---------

Co-authored-by: Robin Tuszik <mail@robin.gg>

* Remove esbuild scripts from package.json

* Remove sideEffects field from package.json

* Raw data archivation

* Add tests

* Fix tests

* Fix tests

* Update ExceptionReporter

* Add schedule to run raw data archival job monthly

* Change file structure for raw data archival feature

* Update changelog and version for raw data archival feature

---------

Co-authored-by: Robin Tuszik <mail@robin.gg>

* Set raw_data to an empty hash instead of nil when archiving

* Fix storage configuration and file extraction

* Consider MIN_MINUTES_SPENT_IN_CITY during stats calculation (#2018)

* Consider MIN_MINUTES_SPENT_IN_CITY during stats calculation

* Remove raw data from visited cities api endpoint

* Use user timezone to show dates on maps (#2020)

* Fix/pre epoch time (#2019)

* Use user timezone to show dates on maps

* Limit timestamps to valid range to prevent database errors when users enter pre-epoch dates.

* Limit timestamps to valid range to prevent database errors when users enter pre-epoch dates.

* Fix tests failing due to new index on stats table

* Fix failing specs

* Update redis client configuration to support unix socket connection

* Update changelog

* Fix kml kmz import issues (#2023)

* Fix kml kmz import issues

* Refactor KML importer to improve readability and maintainability

* Implement moving points in map v2 and fix route rendering logic to ma… (#2027)

* Implement moving points in map v2 and fix route rendering logic to match map v1.

* Fix route spec

* fix(maplibre): update date format to ISO 8601 (#2029)

* Add verification step to raw data archival process (#2028)

* Add verification step to raw data archival process

* Add actual verification of raw data archives after creation, and only clear raw_data for verified archives.

* Fix failing specs

* Eliminate zip-bomb risk

* Fix potential memory leak in js

* Return .keep files

* Use Toast instead of alert for notifications

* Add help section to navbar dropdown

* Update changelog

* Remove raw_data_archival_job

* Ensure file is being closed properly after reading in Archivable concern

* Add composite index to stats table if not exists

* Update changelog

* Update entrypoint to always sync static assets (not only new ones)

* Add family layer to MapLibre maps (#2055)

* Add family layer to MapLibre maps

* Update migration

* Don't show family toggle if feature is disabled

* Update changelog

* Return changelog

* Update changelog

* Update tailwind file

---------

Co-authored-by: Robin Tuszik <mail@robin.gg>
2025-12-26 14:57:55 +01:00
Eugene Burmakin
2a1584e0b8 Fix storage configuration and file extraction 2025-12-26 14:35:04 +01:00
Evgenii Burmakin
c8242ce902
0.36.3 (#2013)
* fix: move foreman to global gems to fix startup crash (#1971)

* Update exporting code to stream points data to file in batches to red… (#1980)

* Update exporting code to stream points data to file in batches to reduce memory usage

* Update changelog

* Update changelog

* Feature/maplibre frontend (#1953)

* Add a plan to use MapLibre GL JS for the frontend map rendering, replacing Leaflet

* Implement phase 1

* Phases 1-3 + part of 4

* Fix e2e tests

* Phase 6

* Implement fog of war

* Phase 7

* Next step: fix specs, phase 7 done

* Use our own map tiles

* Extract v2 map logic to separate manager classes

* Update settings panel on v2 map

* Update v2 e2e tests structure

* Reimplement location search in maps v2

* Update speed routes

* Implement visits and places creation in v2

* Fix last failing test

* Implement visits merging

* Fix a routes e2e test and simplify the routes layer styling.

* Extract js to modules from maps_v2_controller.js

* Implement area creation

* Fix spec problem

* Fix some e2e tests

* Implement live mode in v2 map

* Update icons and panel

* Extract some styles

* Remove unused file

* Start adding dark theme to popups on MapLibre maps

* Make popups respect dark theme

* Move v2 maps to maplibre namespace

* Update v2 references to maplibre

* Put place, area and visit info into side panel

* Update API to use safe settings config method

* Fix specs

* Fix method name to config in SafeSettings and update usages accordingly

* Add missing public files

* Add handling for real time points

* Fix remembering enabled/disabled layers of the v2 map

* Fix lots of e2e tests

* Add settings to select map version

* Use maps/v2 as main path for MapLibre maps

* Update routing

* Update live mode

* Update maplibre controller

* Update changelog

* Remove some console.log statements

* Pull only necessary data for map v2 points

* Feature/raw data archive (#2009)

* 0.36.2 (#2007)

* fix: move foreman to global gems to fix startup crash (#1971)

* Update exporting code to stream points data to file in batches to red… (#1980)

* Update exporting code to stream points data to file in batches to reduce memory usage

* Update changelog

* Update changelog

* Feature/maplibre frontend (#1953)

* Add a plan to use MapLibre GL JS for the frontend map rendering, replacing Leaflet

* Implement phase 1

* Phases 1-3 + part of 4

* Fix e2e tests

* Phase 6

* Implement fog of war

* Phase 7

* Next step: fix specs, phase 7 done

* Use our own map tiles

* Extract v2 map logic to separate manager classes

* Update settings panel on v2 map

* Update v2 e2e tests structure

* Reimplement location search in maps v2

* Update speed routes

* Implement visits and places creation in v2

* Fix last failing test

* Implement visits merging

* Fix a routes e2e test and simplify the routes layer styling.

* Extract js to modules from maps_v2_controller.js

* Implement area creation

* Fix spec problem

* Fix some e2e tests

* Implement live mode in v2 map

* Update icons and panel

* Extract some styles

* Remove unused file

* Start adding dark theme to popups on MapLibre maps

* Make popups respect dark theme

* Move v2 maps to maplibre namespace

* Update v2 references to maplibre

* Put place, area and visit info into side panel

* Update API to use safe settings config method

* Fix specs

* Fix method name to config in SafeSettings and update usages accordingly

* Add missing public files

* Add handling for real time points

* Fix remembering enabled/disabled layers of the v2 map

* Fix lots of e2e tests

* Add settings to select map version

* Use maps/v2 as main path for MapLibre maps

* Update routing

* Update live mode

* Update maplibre controller

* Update changelog

* Remove some console.log statements

---------

Co-authored-by: Robin Tuszik <mail@robin.gg>

* Remove esbuild scripts from package.json

* Remove sideEffects field from package.json

* Raw data archivation

* Add tests

* Fix tests

* Fix tests

* Update ExceptionReporter

* Add schedule to run raw data archival job monthly

* Change file structure for raw data archival feature

* Update changelog and version for raw data archival feature

---------

Co-authored-by: Robin Tuszik <mail@robin.gg>

* Set raw_data to an empty hash instead of nil when archiving

* Fix storage configuration and file extraction

* Consider MIN_MINUTES_SPENT_IN_CITY during stats calculation (#2018)

* Consider MIN_MINUTES_SPENT_IN_CITY during stats calculation

* Remove raw data from visited cities api endpoint

* Use user timezone to show dates on maps (#2020)

* Fix/pre epoch time (#2019)

* Use user timezone to show dates on maps

* Limit timestamps to valid range to prevent database errors when users enter pre-epoch dates.

* Limit timestamps to valid range to prevent database errors when users enter pre-epoch dates.

* Fix tests failing due to new index on stats table

* Fix failing specs

* Update redis client configuration to support unix socket connection

* Update changelog

* Fix kml kmz import issues (#2023)

* Fix kml kmz import issues

* Refactor KML importer to improve readability and maintainability

* Implement moving points in map v2 and fix route rendering logic to ma… (#2027)

* Implement moving points in map v2 and fix route rendering logic to match map v1.

* Fix route spec

* fix(maplibre): update date format to ISO 8601 (#2029)

* Add verification step to raw data archival process (#2028)

* Add verification step to raw data archival process

* Add actual verification of raw data archives after creation, and only clear raw_data for verified archives.

* Fix failing specs

* Eliminate zip-bomb risk

* Fix potential memory leak in js

* Return .keep files

* Use Toast instead of alert for notifications

* Add help section to navbar dropdown

* Update changelog

* Remove raw_data_archival_job

* Ensure file is being closed properly after reading in Archivable concern

---------

Co-authored-by: Robin Tuszik <mail@robin.gg>
2025-12-14 12:05:59 +01:00
Eugene Burmakin
0ed20df08b Remove sideEffects field from package.json 2025-12-06 21:49:30 +01:00
Eugene Burmakin
d50f2cc416 Remove esbuild scripts from package.json 2025-12-06 21:41:59 +01:00
Evgenii Burmakin
8934c29fce
0.36.2 (#2007)
* fix: move foreman to global gems to fix startup crash (#1971)

* Update exporting code to stream points data to file in batches to red… (#1980)

* Update exporting code to stream points data to file in batches to reduce memory usage

* Update changelog

* Update changelog

* Feature/maplibre frontend (#1953)

* Add a plan to use MapLibre GL JS for the frontend map rendering, replacing Leaflet

* Implement phase 1

* Phases 1-3 + part of 4

* Fix e2e tests

* Phase 6

* Implement fog of war

* Phase 7

* Next step: fix specs, phase 7 done

* Use our own map tiles

* Extract v2 map logic to separate manager classes

* Update settings panel on v2 map

* Update v2 e2e tests structure

* Reimplement location search in maps v2

* Update speed routes

* Implement visits and places creation in v2

* Fix last failing test

* Implement visits merging

* Fix a routes e2e test and simplify the routes layer styling.

* Extract js to modules from maps_v2_controller.js

* Implement area creation

* Fix spec problem

* Fix some e2e tests

* Implement live mode in v2 map

* Update icons and panel

* Extract some styles

* Remove unused file

* Start adding dark theme to popups on MapLibre maps

* Make popups respect dark theme

* Move v2 maps to maplibre namespace

* Update v2 references to maplibre

* Put place, area and visit info into side panel

* Update API to use safe settings config method

* Fix specs

* Fix method name to config in SafeSettings and update usages accordingly

* Add missing public files

* Add handling for real time points

* Fix remembering enabled/disabled layers of the v2 map

* Fix lots of e2e tests

* Add settings to select map version

* Use maps/v2 as main path for MapLibre maps

* Update routing

* Update live mode

* Update maplibre controller

* Update changelog

* Remove some console.log statements

---------

Co-authored-by: Robin Tuszik <mail@robin.gg>
2025-12-06 20:54:49 +01:00
Evgenii Burmakin
d5dbf002e0
0.36.1 (#1986)
* fix: move foreman to global gems to fix startup crash (#1971)

* Update exporting code to stream points data to file in batches to red… (#1980)

* Update exporting code to stream points data to file in batches to reduce memory usage

* Update changelog

* Update changelog

---------

Co-authored-by: Robin Tuszik <mail@robin.gg>
2025-11-29 19:59:26 +01:00
Eugene Burmakin
52eb80503d Rework usage of OIDC auto-registration and email/password registration settings to use constants instead of direct ENV access. 2025-11-24 20:04:31 +01:00
Eugene Burmakin
f8be3ecdca Merge branch 'dev' 2025-11-24 19:47:00 +01:00
Eugene Burmakin
573ed510a5 Update changelog 2025-11-24 19:46:27 +01:00
Evgenii Burmakin
b1393ee674
0.36.0 (#1952)
* Implement OmniAuth GitHub authentication

* Fix omniauth GitHub scope to include user email access

* Remove margin-bottom

* Implement Google OAuth2 authentication

* Implement OIDC authentication for Dawarich using omniauth_openid_connect gem.

* Add patreon account linking and patron checking service

* Update docker-compose.yml to use boolean values instead of strings

* Add support for KML files

* Add tests

* Update changelog

* Remove patreon OAuth integration

* Move omniauthable to a concern

* Update an icon in integrations

* Update changelog

* Update app version

* Fix family location sharing toggle

* Move family location sharing to its own controller

* Update changelog

* Implement basic tagging functionality for places, allowing users to categorize and label places with custom tags.

* Add places management API and tags feature

* Add some changes related to places management feature

* Fix some tests

* Fix sometests

* Add places layer

* Update places layer to use Leaflet.Control.Layers.Tree for hierarchical layer control

* Rework tag form

* Add hashtag

* Add privacy zones to tags

* Add notes to places and manage place tags

* Update changelog

* Update e2e tests

* Extract tag serializer to its own file

* Fix some tests

* Fix tags request specs

* Fix some tests

* Fix rest of the tests

* Revert some changes

* Add missing specs

* Revert changes in place export/import code

* Fix some specs

* Fix PlaceFinder to only consider global places when finding existing places

* Fix few more specs

* Fix visits creator spec

* Fix last tests

* Update place creating modal

* Add home location based on "Home" tagged place

* Save enabled tag layers

* Some fixes

* Fix bug where enabling place tag layers would trigger saving enabled layers, overwriting with incomplete data

* Update migration to use disable_ddl_transaction! and add up/down methods

* Fix tag layers restoration and filtering logic

* Update OIDC auto-registration and email/password registration settings

* Fix potential xss
2025-11-24 19:45:09 +01:00
Eugene Burmakin
fa4e368003 Fix potential xss 2025-11-24 19:44:21 +01:00
Eugene Burmakin
4aa6edc3cc Update OIDC auto-registration and email/password registration settings 2025-11-24 19:33:51 +01:00
316 changed files with 84228 additions and 813 deletions

View file

@ -1 +1 @@
0.36.0
0.37.2

View file

@ -4,21 +4,109 @@ 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/).
## Unreleased
# [0.37.2] - 2026-01-04
## TEST
## Fixed
- [ ] KML upload
- [ ] OIDC login with Authentik
- [ ] How does it work with existing settings to disable registrations?
- [x] Place creation
- [x] Tag creation + management
- [x] Place privacy zones
- [x] Settings panel is scrollable
- [ ] Family members can enable their location sharing and see each other on the map
- [x] Home location
- 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
- In Map v2 settings, you can now enable map to be rendered as a globe.
# OIDC and KML support release
# [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
- Place popup tags parsing (MapLibre GL JS compatibility)
- Stats calculation should be faster now.
# [0.36.1] - 2025-11-29
## Fixed
- Exporting user data now works a lot faster and consumes less memory.
- Fix the restart loop. #1937 #1975
# [0.36.0] - 2025-11-24
## OIDC and KML support release
So, you want to configure your OIDC provider. If not — skip to the actual changelog. You're going to need to provide at least 4 environment variables: `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, `OIDC_ISSUER`, and `OIDC_REDIRECT_URI`. Then, if you want to rename the provider from "OpenID Connect" to something else (e.g. "Authentik"), set `OIDC_PROVIDER_NAME` variable as well. If you want to disable email/password registration and allow only OIDC login, set `ALLOW_EMAIL_PASSWORD_REGISTRATION` to `false`. After just 7 brand new environment variables, you'll never have to deal with passwords in Dawarich again!
Jokes aside, even though I'm not a fan of bloating the environment with too many variables, this is a nice addition and it will be reused in the cloud version of Dawarich as well. Thanks for waiting more than a year for this feature!
To configure your OIDC provider, set the following environment variables:
@ -27,6 +115,9 @@ OIDC_CLIENT_ID=client_id_example
OIDC_CLIENT_SECRET=client_secret_example
OIDC_ISSUER=https://authentik.yourdomain.com/application/o/dawarich/
OIDC_REDIRECT_URI=https://your-dawarich-url.com/users/auth/openid_connect/callback
OIDC_AUTO_REGISTER=true # optional, default is false
OIDC_PROVIDER_NAME=YourProviderName # optional, default is OpenID Connect
ALLOW_EMAIL_PASSWORD_REGISTRATION=false # optional, default is true
```
## Added
@ -35,6 +126,7 @@ OIDC_REDIRECT_URI=https://your-dawarich-url.com/users/auth/openid_connect/callba
- Added a commented line in the `docker-compose.yml` file to use an alternative PostGIS image for ARM architecture.
- User can now create a place directly from the map and add tags and notes to it. If reverse geocoding is enabled, list of nearby places will be shown as suggestions.
- User can create and manage tags for places.
- Visits for manually created places are being suggested automatically, just like for areas.
- User can enable or disable places layers on the map to show/hide all or just some of their visited places based on tags.
- User can define privacy zones around places with specific tags to hide map data within a certain radius.
- If user has a place tagged with a tag named "Home" (case insensitive), and this place doesn't have a privacy zone defined, this place will be used as home location for days with no tracked data. #1659 #1575

View file

@ -12,8 +12,10 @@ 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'
@ -35,6 +37,7 @@ gem 'puma'
gem 'pundit', '>= 2.5.1'
gem 'rails', '~> 8.0'
gem 'rails_icons'
gem 'rails_pulse'
gem 'redis'
gem 'rexml'
gem 'rgeo'
@ -46,7 +49,7 @@ gem 'rswag-ui'
gem 'rubyzip', '~> 3.2'
gem 'sentry-rails', '>= 5.27.0'
gem 'sentry-ruby'
gem 'sidekiq', '>= 8.0.5'
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-cron', '>= 2.3.1'
gem 'sidekiq-limit_fetch'
gem 'sprockets-rails'
@ -55,6 +58,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'
group :development, :test, :staging do
gem 'brakeman', require: false
@ -81,7 +85,6 @@ end
group :development do
gem 'database_consistency', '>= 2.0.5', require: false
gem 'foreman'
gem 'rubocop-rails', '>= 2.33.4', require: false
gem 'strong_migrations', '>= 2.4.0'
end

View file

@ -108,12 +108,12 @@ GEM
aws-eventstream (~> 1, >= 1.0.2)
base64 (0.3.0)
bcrypt (3.1.20)
benchmark (0.4.1)
bigdecimal (3.3.1)
benchmark (0.5.0)
bigdecimal (4.0.1)
bindata (2.5.1)
bootsnap (1.18.6)
msgpack (~> 1.2)
brakeman (7.1.0)
brakeman (7.1.1)
racc
builder (3.3.0)
bundler-audit (0.9.2)
@ -129,18 +129,19 @@ GEM
rack-test (>= 0.6.3)
regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2)
chartkick (5.2.0)
chartkick (5.2.1)
chunky_png (1.4.0)
coderay (1.1.3)
concurrent-ruby (1.3.5)
connection_pool (2.5.4)
crack (1.0.0)
concurrent-ruby (1.3.6)
connection_pool (2.5.5)
crack (1.0.1)
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)
@ -166,7 +167,7 @@ GEM
drb (2.2.3)
email_validator (2.2.4)
activemodel
erb (5.1.3)
erb (6.0.0)
erubi (1.13.1)
et-orbi (1.4.0)
tzinfo
@ -208,25 +209,25 @@ GEM
ffi (~> 1.9)
rgeo-geojson (~> 2.1)
zeitwerk (~> 2.5)
hashdiff (1.1.2)
hashdiff (1.2.1)
hashie (5.0.0)
httparty (0.23.1)
csv
mini_mime (>= 1.0.0)
multi_xml (>= 0.5.2)
i18n (1.14.7)
i18n (1.14.8)
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.2)
irb (1.15.3)
pp (>= 0.6.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
jmespath (1.6.2)
json (2.15.0)
json (2.18.0)
json-jwt (1.17.0)
activesupport (>= 4.2)
aes_key_wrap
@ -272,11 +273,12 @@ GEM
method_source (1.1.0)
mini_mime (1.1.5)
mini_portile2 (2.8.9)
minitest (5.26.0)
minitest (6.0.1)
prism (~> 1.5)
msgpack (1.7.3)
multi_json (1.15.0)
multi_xml (0.7.1)
bigdecimal (~> 3.1)
multi_xml (0.8.0)
bigdecimal (>= 3.1, < 5)
net-http (0.6.0)
uri
net-imap (0.5.12)
@ -351,8 +353,11 @@ 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.9.0)
parser (3.3.10.0)
ast (~> 2.4.1)
racc
patience_diff (1.2.0)
@ -365,7 +370,7 @@ GEM
pp (0.6.3)
prettyprint
prettyprint (0.2.0)
prism (1.5.1)
prism (1.7.0)
prometheus_exporter (2.2.0)
webrick
pry (0.15.2)
@ -379,14 +384,14 @@ GEM
psych (5.2.6)
date
stringio
public_suffix (6.0.1)
public_suffix (6.0.2)
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.3)
rack (3.2.4)
rack-oauth2 (2.3.0)
activesupport
attr_required
@ -429,6 +434,14 @@ 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)
@ -440,16 +453,20 @@ GEM
zeitwerk (~> 2.6)
rainbow (3.1.1)
rake (13.3.1)
rdoc (6.15.0)
ransack (4.4.1)
activerecord (>= 7.2)
activesupport (>= 7.2)
i18n
rdoc (6.16.1)
erb
psych (>= 4.0.0)
tsort
redis (5.4.0)
redis (5.4.1)
redis-client (>= 0.22.0)
redis-client (0.24.0)
redis-client (0.26.2)
connection_pool
regexp_parser (2.11.3)
reline (0.6.2)
reline (0.6.3)
io-console (~> 0.5)
request_store (1.7.0)
rack (>= 1.4)
@ -496,7 +513,7 @@ GEM
rswag-ui (2.17.0)
actionpack (>= 5.2, < 8.2)
railties (>= 5.2, < 8.2)
rubocop (1.81.1)
rubocop (1.82.1)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
@ -504,20 +521,20 @@ GEM
parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.9.3, < 3.0)
rubocop-ast (>= 1.47.1, < 2.0)
rubocop-ast (>= 1.48.0, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.47.1)
rubocop-ast (1.49.0)
parser (>= 3.3.7.2)
prism (~> 1.4)
rubocop-rails (2.33.4)
prism (~> 1.7)
rubocop-rails (2.34.2)
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.0)
rubyzip (3.2.2)
securerandom (0.4.1)
selenium-webdriver (4.35.0)
base64 (~> 0.2)
@ -525,15 +542,15 @@ GEM
rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 4.0)
websocket (~> 1.0)
sentry-rails (6.0.0)
sentry-rails (6.2.0)
railties (>= 5.2.0)
sentry-ruby (~> 6.0.0)
sentry-ruby (6.0.0)
sentry-ruby (~> 6.2.0)
sentry-ruby (6.2.0)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
shoulda-matchers (6.5.0)
activesupport (>= 5.2.0)
sidekiq (8.0.8)
sidekiq (8.0.10)
connection_pool (>= 2.5.0)
json (>= 2.9.0)
logger (>= 1.6.2)
@ -565,7 +582,7 @@ GEM
stackprof (0.2.27)
stimulus-rails (1.3.4)
railties (>= 6.0.0)
stringio (3.1.7)
stringio (3.1.8)
strong_migrations (2.5.1)
activerecord (>= 7.1)
super_diff (0.17.0)
@ -589,7 +606,7 @@ GEM
thor (1.4.0)
timeout (0.4.4)
tsort (0.2.0)
turbo-rails (2.0.17)
turbo-rails (2.0.20)
actionpack (>= 7.1.0)
railties (>= 7.1.0)
tzinfo (2.0.6)
@ -597,8 +614,8 @@ GEM
unicode (0.4.4.5)
unicode-display_width (3.2.0)
unicode-emoji (~> 4.1)
unicode-emoji (4.1.0)
uri (1.0.4)
unicode-emoji (4.2.0)
uri (1.1.1)
useragent (0.16.11)
validate_url (1.0.15)
activemodel (>= 3.0.0)
@ -610,7 +627,7 @@ GEM
activesupport
faraday (~> 2.0)
faraday-follow_redirects
webmock (3.25.1)
webmock (3.26.1)
addressable (>= 2.8.0)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
@ -620,8 +637,12 @@ 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
@ -642,6 +663,7 @@ DEPENDENCIES
bundler-audit
capybara
chartkick
connection_pool (< 3)
data_migrate
database_consistency (>= 2.0.5)
debug
@ -674,6 +696,7 @@ DEPENDENCIES
pundit (>= 2.5.1)
rails (~> 8.0)
rails_icons
rails_pulse
redis
rexml
rgeo
@ -690,7 +713,7 @@ DEPENDENCIES
sentry-rails (>= 5.27.0)
sentry-ruby
shoulda-matchers
sidekiq (>= 8.0.5)
sidekiq (= 8.0.10)
sidekiq-cron (>= 2.3.1)
sidekiq-limit_fetch
simplecov
@ -703,6 +726,7 @@ DEPENDENCIES
turbo-rails (>= 2.0.17)
tzinfo-data
webmock
with_advisory_lock
RUBY VERSION
ruby 3.4.6p54

View file

@ -2,8 +2,6 @@
[![Discord](https://dcbadge.limes.pink/api/server/pHsBjpt5J8)](https://discord.gg/pHsBjpt5J8) | [![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/H2H3IDYDD) | [![Patreon](https://img.shields.io/endpoint.svg?url=https%3A%2F%2Fshieldsio-patreon.vercel.app%2Fapi%3Fusername%3Dfreika%26type%3Dpatrons&style=for-the-badge)](https://www.patreon.com/freika)
[![CircleCI](https://circleci.com/gh/Freika/dawarich.svg?style=svg)](https://app.circleci.com/pipelines/github/Freika/dawarich)
---
## 📸 Screenshots
@ -73,7 +71,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

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,187 @@
/* Maps V2 Styles */
/* Loading Overlay */
.loading-overlay {
position: absolute;
inset: 0;
background: rgba(255, 255, 255, 0.9);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 1000;
}
.loading-overlay.hidden {
display: none;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 4px solid #e5e7eb;
border-top-color: #3b82f6;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.loading-text {
margin-top: 16px;
font-size: 14px;
color: #6b7280;
}
/* Popup Styles */
.point-popup {
font-family: system-ui, -apple-system, sans-serif;
}
.popup-header {
margin-bottom: 8px;
padding-bottom: 8px;
border-bottom: 1px solid #e5e7eb;
}
.popup-body {
font-size: 13px;
}
.popup-row {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 4px 0;
}
.popup-row .label {
color: #6b7280;
}
.popup-row .value {
font-weight: 500;
color: #111827;
}
/* MapLibre Popup Theme Support */
.maplibregl-popup-content {
padding: 16px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
/* Larger close button */
.maplibregl-popup-close-button {
width: 32px;
height: 32px;
font-size: 24px;
line-height: 32px;
right: 4px;
top: 4px;
padding: 0;
border-radius: 4px;
transition: background-color 0.2s;
}
.maplibregl-popup-close-button:hover {
background-color: rgba(0, 0, 0, 0.08);
}
/* Light theme (default) */
.maplibregl-popup-content {
background-color: #ffffff;
color: #111827;
}
.maplibregl-popup-close-button {
color: #6b7280;
}
.maplibregl-popup-close-button:hover {
background-color: #f3f4f6;
color: #111827;
}
.maplibregl-popup-tip {
border-top-color: #ffffff;
}
/* Dark theme */
html[data-theme="dark"] .maplibregl-popup-content,
html.dark .maplibregl-popup-content {
background-color: #1f2937;
color: #f9fafb;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
html[data-theme="dark"] .maplibregl-popup-close-button,
html.dark .maplibregl-popup-close-button {
color: #d1d5db;
}
html[data-theme="dark"] .maplibregl-popup-close-button:hover,
html.dark .maplibregl-popup-close-button:hover {
background-color: #374151;
color: #f9fafb;
}
html[data-theme="dark"] .maplibregl-popup-tip,
html.dark .maplibregl-popup-tip {
border-top-color: #1f2937;
}
/* Connection Indicator */
.connection-indicator {
position: absolute;
top: 16px;
left: 50%;
transform: translateX(-50%);
padding: 8px 16px;
background: white;
border-radius: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
display: none; /* Hidden by default, shown when family sharing is active */
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 500;
z-index: 20;
transition: all 0.3s;
}
/* Show connection indicator when family sharing is active */
.connection-indicator.active {
display: flex;
}
.indicator-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #ef4444;
animation: pulse 2s ease-in-out infinite;
}
.connection-indicator.connected .indicator-dot {
background: #22c55e;
}
.connection-indicator.connected .indicator-text::before {
content: 'Connected';
}
.connection-indicator.disconnected .indicator-text::before {
content: 'Connecting...';
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}

View file

@ -0,0 +1,286 @@
/* Maps V2 Control Panel Styles */
.map-control-panel {
position: absolute;
top: 0;
right: -480px; /* Hidden by default */
width: 480px;
height: 100%;
background: oklch(var(--b1));
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.15);
z-index: 9999;
transition: right 0.3s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
overflow: hidden;
}
.map-control-panel.open {
right: 0;
}
/* Vertical Tab Bar */
.panel-tabs {
width: 64px;
background: oklch(var(--b2));
border-right: 1px solid oklch(var(--bc) / 0.1);
display: flex;
flex-direction: column;
align-items: center;
padding: 16px 0;
gap: 8px;
flex-shrink: 0;
}
.tab-btn {
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
border: none;
background: transparent;
cursor: pointer;
transition: all 0.2s;
position: relative;
color: oklch(var(--bc) / 0.6);
}
.tab-btn:hover {
background: oklch(var(--b3));
color: oklch(var(--bc));
}
.tab-btn.active {
background: oklch(var(--p));
color: oklch(var(--pc));
}
.tab-btn.active::after {
content: '';
position: absolute;
right: -1px;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 24px;
background: oklch(var(--p));
border-radius: 2px 0 0 2px;
}
.tab-icon {
width: 24px;
height: 24px;
}
/* Panel Content */
.panel-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 0;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px;
border-bottom: 1px solid oklch(var(--bc) / 0.1);
background: oklch(var(--b1));
flex-shrink: 0;
}
.panel-title {
font-size: 1.25rem;
font-weight: 600;
margin: 0;
color: oklch(var(--bc));
}
.panel-body {
flex: 1;
overflow-y: auto;
padding: 24px;
}
/* Tab Content */
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
/* Custom Scrollbar */
.panel-body::-webkit-scrollbar {
width: 8px;
}
.panel-body::-webkit-scrollbar-track {
background: transparent;
}
.panel-body::-webkit-scrollbar-thumb {
background: oklch(var(--bc) / 0.2);
border-radius: 4px;
}
.panel-body::-webkit-scrollbar-thumb:hover {
background: oklch(var(--bc) / 0.3);
}
/* Toggle Focus State - Remove all focus indicators */
.toggle:focus,
.toggle:focus-visible,
.toggle:focus-within {
outline: none !important;
box-shadow: none !important;
border-color: inherit !important;
}
/* Override DaisyUI toggle focus styles */
.toggle:focus-visible:checked,
.toggle:checked:focus,
.toggle:checked:focus-visible {
outline: none !important;
box-shadow: none !important;
}
/* Ensure no outline on the toggle container */
.form-control .toggle:focus {
outline: none !important;
}
/* Prevent indeterminate visual state on toggles */
.toggle:indeterminate {
opacity: 1;
}
/* Ensure smooth toggle transitions without intermediate states */
.toggle {
transition: background-color 0.2s ease, border-color 0.2s ease;
}
.toggle:checked {
transition: background-color 0.2s ease, border-color 0.2s ease;
}
/* Remove any active/pressed state that might cause intermediate appearance */
.toggle:active,
.toggle:active:focus {
outline: none !important;
box-shadow: none !important;
}
/* Responsive Breakpoints */
/* Large tablets and smaller desktops (1024px - 1280px) */
@media (max-width: 1280px) {
.map-control-panel {
width: 420px;
right: -420px;
}
}
/* Tablets (768px - 1024px) */
@media (max-width: 1024px) {
.map-control-panel {
width: 380px;
right: -380px;
}
.panel-body {
padding: 20px;
}
}
/* Small tablets and large phones (640px - 768px) */
@media (max-width: 768px) {
.map-control-panel {
width: 95%;
right: -95%;
max-width: 480px;
}
.panel-header {
padding: 16px 20px;
}
.panel-title {
font-size: 1.125rem;
}
.panel-body {
padding: 16px 20px;
}
}
/* Mobile phones (< 640px) */
@media (max-width: 640px) {
.map-control-panel {
width: 100%;
right: -100%;
max-width: none;
}
.panel-tabs {
width: 56px;
padding: 12px 0;
gap: 6px;
}
.tab-btn {
width: 44px;
height: 44px;
}
.tab-icon {
width: 20px;
height: 20px;
}
.panel-header {
padding: 14px 16px;
}
.panel-title {
font-size: 1rem;
}
.panel-body {
padding: 16px;
}
/* Reduce spacing on mobile */
.space-y-4 > * + * {
margin-top: 0.75rem;
}
.space-y-6 > * + * {
margin-top: 1rem;
}
}
/* Very small phones (< 375px) */
@media (max-width: 375px) {
.panel-tabs {
width: 52px;
padding: 10px 0;
}
.tab-btn {
width: 40px;
height: 40px;
}
.panel-header {
padding: 12px;
}
.panel-body {
padding: 12px;
}
}

View file

@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 429 B

View file

@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 399 B

View file

@ -0,0 +1 @@
<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-circle-plus-icon lucide-circle-plus"><circle cx="12" cy="12" r="10"/><path d="M8 12h8"/><path d="M12 8v8"/></svg>

After

Width:  |  Height:  |  Size: 316 B

View file

@ -0,0 +1 @@
<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-grid2x2-icon lucide-grid-2x2"><path d="M12 3v18"/><path d="M3 12h18"/><rect x="3" y="3" width="18" height="18" rx="2"/></svg>

After

Width:  |  Height:  |  Size: 328 B

View file

@ -0,0 +1 @@
<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-layers-icon lucide-layers"><path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z"/><path d="M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12"/><path d="M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17"/></svg>

After

Width:  |  Height:  |  Size: 526 B

View file

@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 332 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-map-pin-check-icon lucide-map-pin-check"><path d="M19.43 12.935c.357-.967.57-1.955.57-2.935a8 8 0 0 0-16 0c0 4.993 5.539 10.193 7.399 11.799a1 1 0 0 0 1.202 0 32.197 32.197 0 0 0 .813-.728"/><circle cx="12" cy="10" r="3"/><path d="m16 18 2 2 4-4"/></svg>

After

Width:  |  Height:  |  Size: 457 B

View file

@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 485 B

View file

@ -0,0 +1 @@
<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-pocket-knife-icon lucide-pocket-knife"><path d="M3 2v1c0 1 2 1 2 2S3 6 3 7s2 1 2 2-2 1-2 2 2 1 2 2"/><path d="M18 6h.01"/><path d="M6 18h.01"/><path d="M20.83 8.83a4 4 0 0 0-5.66-5.66l-12 12a4 4 0 1 0 5.66 5.66Z"/><path d="M18 11.66V22a4 4 0 0 0 4-4V6"/></svg>

After

Width:  |  Height:  |  Size: 463 B

View file

@ -0,0 +1 @@
<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-rotate-ccw-icon lucide-rotate-ccw"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>

After

Width:  |  Height:  |  Size: 325 B

View file

@ -0,0 +1 @@
<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-route-icon lucide-route"><circle cx="6" cy="19" r="3"/><path d="M9 19h8.5a3.5 3.5 0 0 0 0-7h-11a3.5 3.5 0 0 1 0-7H15"/><circle cx="18" cy="5" r="3"/></svg>

After

Width:  |  Height:  |  Size: 358 B

View file

@ -0,0 +1 @@
<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-save-icon lucide-save"><path d="M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z"/><path d="M17 21v-7a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v7"/><path d="M7 3v4a1 1 0 0 0 1 1h7"/></svg>

After

Width:  |  Height:  |  Size: 429 B

View file

@ -0,0 +1 @@
<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-settings-icon lucide-settings"><path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915"/><circle cx="12" cy="12" r="3"/></svg>

After

Width:  |  Height:  |  Size: 610 B

View file

@ -0,0 +1 @@
<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-x-icon lucide-x"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>

After

Width:  |  Height:  |  Size: 270 B

View file

@ -1,7 +1,7 @@
# frozen_string_literal: true
class Api::V1::AreasController < ApiController
before_action :set_area, only: %i[update destroy]
before_action :set_area, only: %i[show update destroy]
def index
@areas = current_api_user.areas
@ -9,6 +9,10 @@ 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)

View file

@ -1,14 +1,17 @@
# frozen_string_literal: true
class Api::V1::Countries::VisitedCitiesController < ApiController
include SafeTimestampParser
before_action :validate_params
def index
start_at = DateTime.parse(params[:start_at]).to_i
end_at = DateTime.parse(params[:end_at]).to_i
start_at = safe_timestamp(params[:start_at])
end_at = safe_timestamp(params[:end_at])
points = current_api_user
.points
.without_raw_data
.where(timestamp: start_at..end_at)
render json: { data: CountriesAndCities.new(points).call }

View file

@ -7,8 +7,28 @@ module Api
def index
@places = current_api_user.places.includes(:tags, :visits)
@places = @places.with_tags(params[:tag_ids]) if params[:tag_ids].present?
@places = @places.without_tags if params[:untagged] == 'true'
if params[:tag_ids].present?
tag_ids = Array(params[:tag_ids])
# Separate numeric tag IDs from "untagged"
numeric_tag_ids = tag_ids.reject { |id| id == 'untagged' }.map(&:to_i)
include_untagged = tag_ids.include?('untagged')
if numeric_tag_ids.any? && include_untagged
# 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)
elsif include_untagged
# Only untagged places
@places = @places.without_tags
end
end
render json: @places.map { |place| serialize_place(place) }
end

View file

@ -1,17 +1,37 @@
# 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]&.to_datetime&.to_i
end_at = params[:end_at]&.to_datetime&.to_i || Time.zone.now.to_i
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
order = params[:order] || 'desc'
points = current_api_user
.points
.without_raw_data
.where(timestamp: start_at..end_at)
# Filter by geographic bounds if provided
if params[:min_longitude].present? && params[:max_longitude].present? &&
params[:min_latitude].present? && params[:max_latitude].present?
min_lng = params[:min_longitude].to_f
max_lng = params[:max_longitude].to_f
min_lat = params[:min_latitude].to_f
max_lat = params[:max_latitude].to_f
# Use PostGIS to filter points within bounding box
points = points.where(
'ST_X(lonlat::geometry) BETWEEN ? AND ? AND ST_Y(lonlat::geometry) BETWEEN ? AND ?',
min_lng, max_lng, min_lat, max_lat
)
end
points = points
.order(timestamp: order)
.page(params[:page])
.per(params[:per_page] || 100)

View file

@ -5,7 +5,7 @@ class Api::V1::SettingsController < ApiController
def index
render json: {
settings: current_api_user.safe_settings,
settings: current_api_user.safe_settings.config,
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.settings, status: 'success' },
render json: { message: 'Settings updated', settings: current_api_user.safe_settings.config, status: 'success' },
status: :ok
else
render json: { message: 'Something went wrong', errors: current_api_user.errors.full_messages },
@ -31,6 +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,
enabled_map_layers: []
)
end

View file

@ -10,6 +10,11 @@ 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)

View file

@ -0,0 +1,24 @@
# 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

View file

@ -7,7 +7,7 @@ class ExportsController < ApplicationController
before_action :set_export, only: %i[destroy]
def index
@exports = current_user.exports.order(created_at: :desc).page(params[:page])
@exports = current_user.exports.with_attached_file.order(created_at: :desc).page(params[:page])
end
def create

View file

@ -1,10 +1,12 @@
# 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 map_url if current_user
redirect_to preferred_map_path if current_user
@points = current_user.points.without_raw_data if current_user
end

View file

@ -14,6 +14,7 @@ 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
@ -78,9 +79,13 @@ class ImportsController < ApplicationController
end
def destroy
Imports::Destroy.new(current_user, @import).call
@import.deleting!
Imports::DestroyJob.perform_later(@import.id)
redirect_to imports_url, notice: 'Import was successfully destroyed.', status: :see_other
respond_to do |format|
format.html { redirect_to imports_url, notice: 'Import is being deleted.', status: :see_other }
format.turbo_stream
end
end
private

View file

@ -1,6 +1,8 @@
# frozen_string_literal: true
class MapController < ApplicationController
class Map::LeafletController < ApplicationController
include SafeTimestampParser
before_action :authenticate_user!
layout 'map', only: :index
@ -71,14 +73,14 @@ class MapController < ApplicationController
end
def start_at
return Time.zone.parse(params[:start_at]).to_i if params[:start_at].present?
return safe_timestamp(params[:start_at]) 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 Time.zone.parse(params[:end_at]).to_i if params[:end_at].present?
return safe_timestamp(params[:end_at]) 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

View file

@ -0,0 +1,35 @@
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

View file

@ -1,6 +1,8 @@
# frozen_string_literal: true
class PointsController < ApplicationController
include SafeTimestampParser
before_action :authenticate_user!
def index
@ -40,13 +42,13 @@ class PointsController < ApplicationController
def start_at
return 1.month.ago.beginning_of_day.to_i if params[:start_at].nil?
Time.zone.parse(params[:start_at]).to_i
safe_timestamp(params[:start_at])
end
def end_at
return Time.zone.today.end_of_day.to_i if params[:end_at].nil?
Time.zone.parse(params[:end_at]).to_i
safe_timestamp(params[:end_at])
end
def points

View file

@ -24,6 +24,6 @@ class Settings::MapsController < ApplicationController
private
def settings_params
params.require(:maps).permit(:name, :url, :distance_unit)
params.require(:maps).permit(:name, :url, :distance_unit, :preferred_version)
end
end

View file

@ -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
:visits_suggestions_enabled, :digest_emails_enabled
)
end
end

View file

@ -0,0 +1,55 @@
# 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

View file

@ -0,0 +1,59 @@
# 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

View file

@ -44,11 +44,24 @@ class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController
def handle_auth(provider)
@user = User.from_omniauth(request.env['omniauth.auth'])
if @user.persisted?
if @user&.persisted?
flash[:notice] = I18n.t 'devise.omniauth_callbacks.success', kind: provider
sign_in_and_redirect @user, event: :authentication
elsif @user.nil?
# User creation was rejected (e.g., OIDC auto-register disabled)
error_message = if provider == 'OpenID Connect' && !oidc_auto_register_enabled?
'Your account must be created by an administrator before you can sign in with OIDC. ' \
'Please contact your administrator.'
else
'Unable to create your account. Please try again or contact support.'
end
redirect_to root_path, alert: error_message
else
redirect_to new_user_registration_url, alert: @user.errors.full_messages.join("\n")
end
end
def oidc_auto_register_enabled?
OIDC_AUTO_REGISTER
end
end

View file

@ -45,6 +45,7 @@ class Users::RegistrationsController < Devise::RegistrationsController
def check_registration_allowed
return unless self_hosted_mode?
return if valid_invitation_token?
return if email_password_registration_allowed?
redirect_to root_path,
alert: 'Registration is not available. Please contact your administrator for access.'
@ -96,4 +97,8 @@ class Users::RegistrationsController < Devise::RegistrationsController
def sign_up_params
super
end
def email_password_registration_allowed?
ALLOW_EMAIL_PASSWORD_REGISTRATION
end
end

View file

@ -130,4 +130,23 @@ module ApplicationHelper
'btn-success'
end
end
def oauth_provider_name(provider)
return OIDC_PROVIDER_NAME if provider == :openid_connect
OmniAuth::Utils.camelize(provider)
end
def email_password_registration_enabled?
return true unless DawarichSettings.self_hosted?
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

View file

@ -0,0 +1,71 @@
# 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

724
app/javascript/README.md Normal file
View file

@ -0,0 +1,724 @@
# Dawarich JavaScript Architecture
This document provides a comprehensive guide to the JavaScript architecture used in the Dawarich application, with a focus on the Maps (MapLibre) implementation.
## Table of Contents
- [Overview](#overview)
- [Technology Stack](#technology-stack)
- [Architecture Patterns](#architecture-patterns)
- [Directory Structure](#directory-structure)
- [Core Concepts](#core-concepts)
- [Maps (MapLibre) Architecture](#maps-maplibre-architecture)
- [Creating New Features](#creating-new-features)
- [Best Practices](#best-practices)
## Overview
Dawarich uses a modern JavaScript architecture built on **Hotwire (Turbo + Stimulus)** for page interactions and **MapLibre GL JS** for map rendering. The Maps (MapLibre) implementation follows object-oriented principles with clear separation of concerns.
## Technology Stack
- **Stimulus** - Modest JavaScript framework for sprinkles of interactivity
- **Turbo Rails** - SPA-like page navigation without building an SPA
- **MapLibre GL JS** - Open-source map rendering engine
- **ES6 Modules** - Modern JavaScript module system
- **Tailwind CSS + DaisyUI** - Utility-first CSS framework
## Architecture Patterns
### 1. Stimulus Controllers
**Purpose:** Connect DOM elements to JavaScript behavior
**Location:** `app/javascript/controllers/`
**Pattern:**
```javascript
import { Controller } from '@hotwired/stimulus'
export default class extends Controller {
static targets = ['element']
static values = { apiKey: String }
connect() {
// Initialize when element appears in DOM
}
disconnect() {
// Cleanup when element is removed
}
}
```
**Key Principles:**
- Controllers should be stateless when possible
- Use `targets` for DOM element references
- Use `values` for passing data from HTML
- Always cleanup in `disconnect()`
### 2. Service Classes
**Purpose:** Encapsulate business logic and API communication
**Location:** `app/javascript/maps_maplibre/services/`
**Pattern:**
```javascript
export class ApiClient {
constructor(apiKey) {
this.apiKey = apiKey
}
async fetchData() {
const response = await fetch(url, {
headers: this.getHeaders()
})
return response.json()
}
}
```
**Key Principles:**
- Single responsibility - one service per concern
- Consistent error handling
- Return promises for async operations
- Use constructor injection for dependencies
### 3. Layer Classes (Map Layers)
**Purpose:** Manage map visualization layers
**Location:** `app/javascript/maps_maplibre/layers/`
**Pattern:**
```javascript
import { BaseLayer } from './base_layer'
export class CustomLayer extends BaseLayer {
constructor(map, options = {}) {
super(map, { id: 'custom', ...options })
}
getSourceConfig() {
return {
type: 'geojson',
data: this.data
}
}
getLayerConfigs() {
return [{
id: this.id,
type: 'circle',
source: this.sourceId,
paint: { /* ... */ }
}]
}
}
```
**Key Principles:**
- All layers extend `BaseLayer`
- Implement `getSourceConfig()` and `getLayerConfigs()`
- Store data in `this.data`
- Use `this.visible` for visibility state
- Inherit common methods: `add()`, `update()`, `show()`, `hide()`, `toggle()`
### 4. Utility Modules
**Purpose:** Provide reusable helper functions
**Location:** `app/javascript/maps_maplibre/utils/`
**Pattern:**
```javascript
export class UtilityClass {
static helperMethod(param) {
// Static methods for stateless utilities
}
}
// Or singleton pattern
export const utilityInstance = new UtilityClass()
```
### 5. Component Classes
**Purpose:** Reusable UI components
**Location:** `app/javascript/maps_maplibre/components/`
**Pattern:**
```javascript
export class PopupFactory {
static createPopup(data) {
return `<div>${data.name}</div>`
}
}
```
## Directory Structure
```
app/javascript/
├── application.js # Entry point
├── controllers/ # Stimulus controllers
│ ├── maps/maplibre_controller.js # Main map controller
│ ├── maps_maplibre/ # Controller modules
│ │ ├── layer_manager.js # Layer lifecycle management
│ │ ├── data_loader.js # API data fetching
│ │ ├── event_handlers.js # Map event handling
│ │ ├── filter_manager.js # Data filtering
│ │ └── date_manager.js # Date range management
│ └── ... # Other controllers
├── maps_maplibre/ # Maps (MapLibre) implementation
│ ├── layers/ # Map layer classes
│ │ ├── base_layer.js # Abstract base class
│ │ ├── points_layer.js # Point markers
│ │ ├── routes_layer.js # Route lines
│ │ ├── heatmap_layer.js # Heatmap visualization
│ │ ├── visits_layer.js # Visit markers
│ │ ├── photos_layer.js # Photo markers
│ │ ├── places_layer.js # Places markers
│ │ ├── areas_layer.js # User-defined areas
│ │ ├── fog_layer.js # Fog of war overlay
│ │ └── scratch_layer.js # Scratch map
│ ├── services/ # API and external services
│ │ ├── api_client.js # REST API wrapper
│ │ └── location_search_service.js
│ ├── utils/ # Helper utilities
│ │ ├── settings_manager.js # User preferences
│ │ ├── geojson_transformers.js
│ │ ├── performance_monitor.js
│ │ ├── lazy_loader.js # Code splitting
│ │ └── ...
│ ├── components/ # Reusable UI components
│ │ ├── popup_factory.js # Map popup generator
│ │ ├── toast.js # Toast notifications
│ │ └── ...
│ └── channels/ # ActionCable channels
│ └── map_channel.js # Real-time updates
└── maps/ # Legacy Maps V1 (being phased out)
```
## Core Concepts
### Manager Pattern
The Maps (MapLibre) controller delegates responsibilities to specialized managers:
1. **LayerManager** - Layer lifecycle (add/remove/toggle/update)
2. **DataLoader** - API data fetching and transformation
3. **EventHandlers** - Map interaction events
4. **FilterManager** - Data filtering and searching
5. **DateManager** - Date range calculations
6. **SettingsManager** - User preferences persistence
**Benefits:**
- Single Responsibility Principle
- Easier testing
- Improved code organization
- Better reusability
### Data Flow
```
User Action
Stimulus Controller Method
Manager (e.g., DataLoader)
Service (e.g., ApiClient)
API Endpoint
Transform to GeoJSON
Update Layer
MapLibre Renders
```
### State Management
**Settings Persistence:**
- Primary: Backend API (`/api/v1/settings`)
- Fallback: localStorage
- Sync on initialization
- Save on every change (debounced)
**Layer State:**
- Stored in layer instances (`this.visible`, `this.data`)
- Synced with SettingsManager
- Persisted across sessions
### Event System
**Custom Events:**
```javascript
// Dispatch
document.dispatchEvent(new CustomEvent('visit:created', {
detail: { visitId: 123 }
}))
// Listen
document.addEventListener('visit:created', (event) => {
console.log(event.detail.visitId)
})
```
**Map Events:**
```javascript
map.on('click', 'layer-id', (e) => {
const feature = e.features[0]
// Handle click
})
```
## Maps (MapLibre) Architecture
### Layer Hierarchy
Layers are rendered in specific order (bottom to top):
1. **Scratch Layer** - Visited countries/regions overlay
2. **Heatmap Layer** - Point density visualization
3. **Areas Layer** - User-defined circular areas
4. **Tracks Layer** - Imported GPS tracks
5. **Routes Layer** - Generated routes from points
6. **Visits Layer** - Detected visits to places
7. **Places Layer** - Named locations
8. **Photos Layer** - Photos with geolocation
9. **Family Layer** - Real-time family member locations
10. **Points Layer** - Individual location points
11. **Fog Layer** - Canvas overlay showing unexplored areas
### BaseLayer Pattern
All layers extend `BaseLayer` which provides:
**Methods:**
- `add(data)` - Add layer to map
- `update(data)` - Update layer data
- `remove()` - Remove layer from map
- `show()` / `hide()` - Toggle visibility
- `toggle(visible)` - Set visibility state
**Abstract Methods (must implement):**
- `getSourceConfig()` - MapLibre source configuration
- `getLayerConfigs()` - Array of MapLibre layer configurations
**Example Implementation:**
```javascript
export class PointsLayer extends BaseLayer {
constructor(map, options = {}) {
super(map, { id: 'points', ...options })
}
getSourceConfig() {
return {
type: 'geojson',
data: this.data || { type: 'FeatureCollection', features: [] }
}
}
getLayerConfigs() {
return [{
id: 'points',
type: 'circle',
source: this.sourceId,
paint: {
'circle-radius': 4,
'circle-color': '#3b82f6'
}
}]
}
}
```
### Lazy Loading
Heavy layers are lazy-loaded to reduce initial bundle size:
```javascript
// In lazy_loader.js
const paths = {
'fog': () => import('../layers/fog_layer.js'),
'scratch': () => import('../layers/scratch_layer.js')
}
// Usage
const ScratchLayer = await lazyLoader.loadLayer('scratch')
const layer = new ScratchLayer(map, options)
```
**When to use:**
- Large dependencies (e.g., canvas-based rendering)
- Rarely-used features
- Heavy computations
### GeoJSON Transformations
All data is transformed to GeoJSON before rendering:
```javascript
// Points
{
type: 'FeatureCollection',
features: [{
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [longitude, latitude]
},
properties: {
id: 1,
timestamp: '2024-01-01T12:00:00Z',
// ... other properties
}
}]
}
```
**Key Functions:**
- `pointsToGeoJSON(points)` - Convert points array
- `visitsToGeoJSON(visits)` - Convert visits
- `photosToGeoJSON(photos)` - Convert photos
- `placesToGeoJSON(places)` - Convert places
- `areasToGeoJSON(areas)` - Convert circular areas to polygons
## Creating New Features
### Adding a New Layer
1. **Create layer class** in `app/javascript/maps_maplibre/layers/`:
```javascript
import { BaseLayer } from './base_layer'
export class NewLayer extends BaseLayer {
constructor(map, options = {}) {
super(map, { id: 'new-layer', ...options })
}
getSourceConfig() {
return {
type: 'geojson',
data: this.data || { type: 'FeatureCollection', features: [] }
}
}
getLayerConfigs() {
return [{
id: this.id,
type: 'symbol', // or 'circle', 'line', 'fill', 'heatmap'
source: this.sourceId,
paint: { /* styling */ },
layout: { /* layout */ }
}]
}
}
```
2. **Register in LayerManager** (`controllers/maps_maplibre/layer_manager.js`):
```javascript
import { NewLayer } from 'maps_maplibre/layers/new_layer'
// In addAllLayers method
_addNewLayer(dataGeoJSON) {
if (!this.layers.newLayer) {
this.layers.newLayer = new NewLayer(this.map, {
visible: this.settings.newLayerEnabled || false
})
this.layers.newLayer.add(dataGeoJSON)
} else {
this.layers.newLayer.update(dataGeoJSON)
}
}
```
3. **Add to settings** (`utils/settings_manager.js`):
```javascript
const DEFAULT_SETTINGS = {
// ...
newLayerEnabled: false
}
const LAYER_NAME_MAP = {
// ...
'New Layer': 'newLayerEnabled'
}
```
4. **Add UI controls** in view template.
### Adding a New API Endpoint
1. **Add method to ApiClient** (`services/api_client.js`):
```javascript
async fetchNewData({ param1, param2 }) {
const params = new URLSearchParams({ param1, param2 })
const response = await fetch(`${this.baseURL}/new-endpoint?${params}`, {
headers: this.getHeaders()
})
if (!response.ok) {
throw new Error(`Failed to fetch: ${response.statusText}`)
}
return response.json()
}
```
2. **Add transformation** in DataLoader:
```javascript
newDataToGeoJSON(data) {
return {
type: 'FeatureCollection',
features: data.map(item => ({
type: 'Feature',
geometry: { /* ... */ },
properties: { /* ... */ }
}))
}
}
```
3. **Use in controller:**
```javascript
const data = await this.api.fetchNewData({ param1, param2 })
const geojson = this.dataLoader.newDataToGeoJSON(data)
this.layerManager.updateLayer('new-layer', geojson)
```
### Adding a New Utility
1. **Create utility file** in `utils/`:
```javascript
export class NewUtility {
static calculate(input) {
// Pure function - no side effects
return result
}
}
// Or singleton for stateful utilities
class NewManager {
constructor() {
this.state = {}
}
doSomething() {
// Stateful operation
}
}
export const newManager = new NewManager()
```
2. **Import and use:**
```javascript
import { NewUtility } from 'maps_maplibre/utils/new_utility'
const result = NewUtility.calculate(input)
```
## Best Practices
### Code Style
1. **Use ES6+ features:**
- Arrow functions
- Template literals
- Destructuring
- Async/await
- Classes
2. **Naming conventions:**
- Classes: `PascalCase`
- Methods/variables: `camelCase`
- Constants: `UPPER_SNAKE_CASE`
- Files: `snake_case.js`
3. **Always use semicolons** for statement termination
4. **Prefer `const` over `let`**, avoid `var`
### Performance
1. **Lazy load heavy features:**
```javascript
const Layer = await lazyLoader.loadLayer('name')
```
2. **Debounce frequent operations:**
```javascript
let timeout
function onInput(e) {
clearTimeout(timeout)
timeout = setTimeout(() => actualWork(e), 300)
}
```
3. **Use performance monitoring:**
```javascript
performanceMonitor.mark('operation')
// ... do work
performanceMonitor.measure('operation')
```
4. **Minimize DOM manipulations** - batch updates when possible
### Error Handling
1. **Always handle promise rejections:**
```javascript
try {
const data = await fetchData()
} catch (error) {
console.error('Failed:', error)
Toast.error('Operation failed')
}
```
2. **Provide user feedback:**
```javascript
Toast.success('Data loaded')
Toast.error('Failed to load data')
Toast.info('Click map to add point')
```
3. **Log errors for debugging:**
```javascript
console.error('[Component] Error details:', error)
```
### Memory Management
1. **Always cleanup in disconnect():**
```javascript
disconnect() {
this.searchManager?.destroy()
this.cleanup.cleanup()
this.map?.remove()
}
```
2. **Use CleanupHelper for event listeners:**
```javascript
this.cleanup = new CleanupHelper()
this.cleanup.addEventListener(element, 'click', handler)
// In disconnect():
this.cleanup.cleanup() // Removes all listeners
```
3. **Remove map layers and sources:**
```javascript
remove() {
this.getLayerIds().forEach(id => {
if (this.map.getLayer(id)) {
this.map.removeLayer(id)
}
})
if (this.map.getSource(this.sourceId)) {
this.map.removeSource(this.sourceId)
}
}
```
### Testing Considerations
1. **Keep methods small and focused** - easier to test
2. **Avoid tight coupling** - use dependency injection
3. **Separate pure functions** from side effects
4. **Use static methods** for stateless utilities
### State Management
1. **Single source of truth:**
- Settings: `SettingsManager`
- Layer data: Layer instances
- UI state: Controller properties
2. **Sync state with backend:**
```javascript
SettingsManager.updateSetting('key', value)
// Saves to both localStorage and backend
```
3. **Restore state on load:**
```javascript
async connect() {
this.settings = await SettingsManager.sync()
this.syncToggleStates()
}
```
### Documentation
1. **Add JSDoc comments for public APIs:**
```javascript
/**
* Fetch all points for date range
* @param {Object} options - { start_at, end_at, onProgress }
* @returns {Promise<Array>} All points
*/
async fetchAllPoints({ start_at, end_at, onProgress }) {
// ...
}
```
2. **Document complex logic with inline comments**
3. **Keep this README updated** when adding major features
### Code Organization
1. **One class per file** - easier to find and maintain
2. **Group related functionality** in directories
3. **Use index files** for barrel exports when needed
4. **Avoid circular dependencies** - use dependency injection
### Migration from Maps V1 to V2
When updating features, follow this pattern:
1. **Keep V1 working** - V2 is opt-in
2. **Share utilities** where possible (e.g., color calculations)
3. **Use same API endpoints** - maintain compatibility
4. **Document differences** in code comments
---
## Examples
### Complete Layer Implementation
See `app/javascript/maps_maplibre/layers/heatmap_layer.js` for a simple example.
### Complete Utility Implementation
See `app/javascript/maps_maplibre/utils/settings_manager.js` for state management.
### Complete Service Implementation
See `app/javascript/maps_maplibre/services/api_client.js` for API communication.
### Complete Controller Implementation
See `app/javascript/controllers/maps/maplibre_controller.js` for orchestration.
---
**Questions or need help?** Check the existing code for patterns or ask in Discord: https://discord.gg/pHsBjpt5J8

View file

@ -0,0 +1,161 @@
import { Controller } from '@hotwired/stimulus'
/**
* Area creation controller
* Handles the area creation modal and form submission
*/
export default class extends Controller {
static targets = [
'modal',
'form',
'nameInput',
'latitudeInput',
'longitudeInput',
'radiusInput',
'radiusDisplay',
'submitButton',
'submitSpinner',
'submitText'
]
static values = {
apiKey: String
}
connect() {
this.area = null
this.setupEventListeners()
console.log('[Area Creation V2] Controller connected')
}
/**
* Setup event listeners for area drawing
*/
setupEventListeners() {
document.addEventListener('area:drawn', (e) => {
this.open(e.detail.center, e.detail.radius)
})
}
/**
* Open the modal with area data
*/
open(center, radius) {
// Store area data
this.area = { center, radius }
// Update form fields
this.latitudeInputTarget.value = center[1]
this.longitudeInputTarget.value = center[0]
this.radiusInputTarget.value = Math.round(radius)
this.radiusDisplayTarget.textContent = Math.round(radius)
// Show modal
this.modalTarget.classList.add('modal-open')
this.nameInputTarget.focus()
}
/**
* Close the modal
*/
close() {
this.modalTarget.classList.remove('modal-open')
this.resetForm()
}
/**
* Submit the form
*/
async submit(event) {
event.preventDefault()
if (!this.area) {
console.error('No area data available')
return
}
const formData = new FormData(this.formTarget)
const name = formData.get('name')
const latitude = parseFloat(formData.get('latitude'))
const longitude = parseFloat(formData.get('longitude'))
const radius = parseFloat(formData.get('radius'))
if (!name || !latitude || !longitude || !radius) {
alert('Please fill in all required fields')
return
}
this.setLoading(true)
try {
const response = await fetch('/api/v1/areas', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKeyValue}`
},
body: JSON.stringify({
name,
latitude,
longitude,
radius
})
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.message || 'Failed to create area')
}
const area = await response.json()
// Close modal
this.close()
// Dispatch document event for area created
document.dispatchEvent(new CustomEvent('area:created', {
detail: { area }
}))
} catch (error) {
console.error('Error creating area:', error)
alert(`Error creating area: ${error.message}`)
} finally {
this.setLoading(false)
}
}
/**
* Set loading state
*/
setLoading(loading) {
this.submitButtonTarget.disabled = loading
if (loading) {
this.submitSpinnerTarget.classList.remove('hidden')
this.submitTextTarget.textContent = 'Creating...'
} else {
this.submitSpinnerTarget.classList.add('hidden')
this.submitTextTarget.textContent = 'Create Area'
}
}
/**
* Reset form
*/
resetForm() {
this.formTarget.reset()
this.area = null
this.radiusDisplayTarget.textContent = '0'
}
/**
* Show success message
*/
showSuccess(message) {
// Try to use the Toast component if available
if (window.Toast) {
window.Toast.show(message, 'success')
}
}
}

View file

@ -0,0 +1,146 @@
import { Controller } from '@hotwired/stimulus'
import { createCircle, calculateDistance } from 'maps_maplibre/utils/geometry'
/**
* Area drawer controller
* Draw circular areas on map
*/
export default class extends Controller {
connect() {
this.isDrawing = false
this.center = null
this.radius = 0
this.map = null
// Bind event handlers to maintain context
this.onClick = this.onClick.bind(this)
this.onMouseMove = this.onMouseMove.bind(this)
}
/**
* Start drawing mode
* @param {maplibregl.Map} map - The MapLibre map instance
*/
startDrawing(map) {
if (!map) {
console.error('[Area Drawer] Map instance not provided')
return
}
this.isDrawing = true
this.map = map
map.getCanvas().style.cursor = 'crosshair'
// Add temporary layer
if (!map.getSource('draw-source')) {
map.addSource('draw-source', {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] }
})
map.addLayer({
id: 'draw-fill',
type: 'fill',
source: 'draw-source',
paint: {
'fill-color': '#22c55e',
'fill-opacity': 0.2
}
})
map.addLayer({
id: 'draw-outline',
type: 'line',
source: 'draw-source',
paint: {
'line-color': '#22c55e',
'line-width': 2
}
})
}
// Add event listeners
map.on('click', this.onClick)
map.on('mousemove', this.onMouseMove)
}
/**
* Cancel drawing mode
*/
cancelDrawing() {
if (!this.map) return
this.isDrawing = false
this.center = null
this.radius = 0
this.map.getCanvas().style.cursor = ''
// Clear drawing
const source = this.map.getSource('draw-source')
if (source) {
source.setData({ type: 'FeatureCollection', features: [] })
}
// Remove event listeners
this.map.off('click', this.onClick)
this.map.off('mousemove', this.onMouseMove)
}
/**
* Click handler
*/
onClick(e) {
if (!this.isDrawing || !this.map) return
if (!this.center) {
// First click - set center
this.center = [e.lngLat.lng, e.lngLat.lat]
} else {
// Second click - finish drawing
document.dispatchEvent(new CustomEvent('area:drawn', {
detail: {
center: this.center,
radius: this.radius
}
}))
this.cancelDrawing()
}
}
/**
* Mouse move handler
*/
onMouseMove(e) {
if (!this.isDrawing || !this.center || !this.map) return
const currentPoint = [e.lngLat.lng, e.lngLat.lat]
this.radius = calculateDistance(this.center, currentPoint)
this.updateDrawing()
}
/**
* Update drawing visualization
*/
updateDrawing() {
if (!this.center || this.radius === 0 || !this.map) return
const coordinates = createCircle(this.center, this.radius)
const source = this.map.getSource('draw-source')
if (source) {
source.setData({
type: 'FeatureCollection',
features: [{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [coordinates]
}
}]
})
}
}
}

View file

@ -0,0 +1,161 @@
import { Controller } from '@hotwired/stimulus'
import { createRectangle } from 'maps_maplibre/utils/geometry'
/**
* Area selector controller
* Draw rectangle selection on map
*/
export default class extends Controller {
static outlets = ['mapsV2']
connect() {
this.isSelecting = false
this.startPoint = null
this.currentPoint = null
}
/**
* Start rectangle selection mode
*/
startSelection() {
if (!this.hasMapsV2Outlet) {
console.error('Maps V2 outlet not found')
return
}
this.isSelecting = true
const map = this.mapsV2Outlet.map
map.getCanvas().style.cursor = 'crosshair'
// Add temporary layer for selection
if (!map.getSource('selection-source')) {
map.addSource('selection-source', {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] }
})
map.addLayer({
id: 'selection-fill',
type: 'fill',
source: 'selection-source',
paint: {
'fill-color': '#3b82f6',
'fill-opacity': 0.2
}
})
map.addLayer({
id: 'selection-outline',
type: 'line',
source: 'selection-source',
paint: {
'line-color': '#3b82f6',
'line-width': 2,
'line-dasharray': [2, 2]
}
})
}
// Add event listeners
map.on('mousedown', this.onMouseDown)
map.on('mousemove', this.onMouseMove)
map.on('mouseup', this.onMouseUp)
}
/**
* Cancel selection mode
*/
cancelSelection() {
if (!this.hasMapsV2Outlet) return
this.isSelecting = false
this.startPoint = null
this.currentPoint = null
const map = this.mapsV2Outlet.map
map.getCanvas().style.cursor = ''
// Clear selection
const source = map.getSource('selection-source')
if (source) {
source.setData({ type: 'FeatureCollection', features: [] })
}
// Remove event listeners
map.off('mousedown', this.onMouseDown)
map.off('mousemove', this.onMouseMove)
map.off('mouseup', this.onMouseUp)
}
/**
* Mouse down handler
*/
onMouseDown = (e) => {
if (!this.isSelecting || !this.hasMapsV2Outlet) return
this.startPoint = [e.lngLat.lng, e.lngLat.lat]
this.mapsV2Outlet.map.dragPan.disable()
}
/**
* Mouse move handler
*/
onMouseMove = (e) => {
if (!this.isSelecting || !this.startPoint || !this.hasMapsV2Outlet) return
this.currentPoint = [e.lngLat.lng, e.lngLat.lat]
this.updateSelection()
}
/**
* Mouse up handler
*/
onMouseUp = (e) => {
if (!this.isSelecting || !this.startPoint || !this.hasMapsV2Outlet) return
this.currentPoint = [e.lngLat.lng, e.lngLat.lat]
this.mapsV2Outlet.map.dragPan.enable()
// Emit selection event
const bounds = this.getSelectionBounds()
this.dispatch('selected', { detail: { bounds } })
this.cancelSelection()
}
/**
* Update selection visualization
*/
updateSelection() {
if (!this.startPoint || !this.currentPoint || !this.hasMapsV2Outlet) return
const bounds = this.getSelectionBounds()
const rectangle = createRectangle(bounds)
const source = this.mapsV2Outlet.map.getSource('selection-source')
if (source) {
source.setData({
type: 'FeatureCollection',
features: [{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: rectangle
}
}]
})
}
}
/**
* Get selection bounds
*/
getSelectionBounds() {
return {
minLng: Math.min(this.startPoint[0], this.currentPoint[0]),
minLat: Math.min(this.startPoint[1], this.currentPoint[1]),
maxLng: Math.max(this.startPoint[0], this.currentPoint[0]),
maxLat: Math.max(this.startPoint[1], this.currentPoint[1])
}
}
}

View file

@ -11,9 +11,57 @@ 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()
}
})
}
}
}
async updateCoordinates(event) {
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() {
// Clear any existing timeout
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
@ -25,6 +73,11 @@ 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({

View file

@ -7,7 +7,8 @@ export default class extends Controller {
static values = {
features: Object,
userTheme: String
userTheme: String,
timezone: String
}
connect() {
@ -106,7 +107,8 @@ export default class extends Controller {
});
// Format timestamp for display
const lastSeen = new Date(location.updated_at).toLocaleString();
const timezone = this.timezoneValue || 'UTC';
const lastSeen = new Date(location.updated_at).toLocaleString('en-US', { timeZone: timezone });
// Create small tooltip that shows automatically
const tooltipContent = this.createTooltipContent(lastSeen, location.battery);
@ -176,7 +178,8 @@ export default class extends Controller {
existingMarker.setIcon(newIcon);
// Update tooltip content
const lastSeen = new Date(locationData.updated_at).toLocaleString();
const timezone = this.timezoneValue || 'UTC';
const lastSeen = new Date(locationData.updated_at).toLocaleString('en-US', { timeZone: timezone });
const tooltipContent = this.createTooltipContent(lastSeen, locationData.battery);
existingMarker.setTooltipContent(tooltipContent);
@ -214,7 +217,8 @@ export default class extends Controller {
})
});
const lastSeen = new Date(location.updated_at).toLocaleString();
const timezone = this.timezoneValue || 'UTC';
const lastSeen = new Date(location.updated_at).toLocaleString('en-US', { timeZone: timezone });
const tooltipContent = this.createTooltipContent(lastSeen, location.battery);
familyMarker.bindTooltip(tooltipContent, {

View file

@ -26,16 +26,23 @@ export default class extends BaseController {
received: (data) => {
const row = this.element.querySelector(`tr[data-import-id="${data.import.id}"]`);
if (row) {
const pointsCell = row.querySelector('[data-points-count]');
if (pointsCell) {
pointsCell.textContent = new Intl.NumberFormat().format(data.import.points_count);
}
if (!row) return;
const statusCell = row.querySelector('[data-status-display]');
if (statusCell && data.import.status) {
statusCell.textContent = data.import.status;
}
// 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;
}
}
}

View file

@ -0,0 +1,68 @@
import { Controller } from '@hotwired/stimulus'
/**
* Map Panel Controller
* Handles tab switching in the map control panel
*/
export default class extends Controller {
static targets = ['tabButton', 'tabContent', 'title']
// Tab title mappings
static titles = {
search: 'Search',
layers: 'Map Layers',
tools: 'Tools',
links: 'Links',
settings: 'Settings'
}
connect() {
console.log('[Map Panel] Connected')
}
/**
* Switch to a different tab
*/
switchTab(event) {
const button = event.currentTarget
const tabName = button.dataset.tab
this.activateTab(tabName)
}
/**
* Programmatically switch to a tab by name
*/
switchToTab(tabName) {
this.activateTab(tabName)
}
/**
* Internal method to activate a tab
*/
activateTab(tabName) {
// Find the button for this tab
const button = this.tabButtonTargets.find(btn => btn.dataset.tab === tabName)
// Update active button
this.tabButtonTargets.forEach(btn => {
btn.classList.remove('active')
})
if (button) {
button.classList.add('active')
}
// Update tab content
this.tabContentTargets.forEach(content => {
const contentTab = content.dataset.tabContent
if (contentTab === tabName) {
content.classList.add('active')
} else {
content.classList.remove('active')
}
})
// Update title
this.titleTarget.textContent = this.constructor.titles[tabName] || tabName
}
}

View file

@ -0,0 +1,540 @@
import { SelectionLayer } from 'maps_maplibre/layers/selection_layer'
import { SelectedPointsLayer } from 'maps_maplibre/layers/selected_points_layer'
import { pointsToGeoJSON } from 'maps_maplibre/utils/geojson_transformers'
import { VisitCard } from 'maps_maplibre/components/visit_card'
import { Toast } from 'maps_maplibre/components/toast'
/**
* Manages area selection and bulk operations for Maps V2
* Handles selection mode, visit cards, and bulk actions (merge, confirm, decline)
*/
export class AreaSelectionManager {
constructor(controller) {
this.controller = controller
this.map = controller.map
this.api = controller.api
this.selectionLayer = null
this.selectedPointsLayer = null
this.selectedVisits = []
this.selectedVisitIds = new Set()
}
/**
* 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, {
visible: true,
onSelectionComplete: this.handleAreaSelected.bind(this)
})
this.selectionLayer.add({
type: 'FeatureCollection',
features: []
})
console.log('[Maps V2] Selection layer initialized')
}
// Initialize selected points layer if not exists
if (!this.selectedPointsLayer) {
this.selectedPointsLayer = new SelectedPointsLayer(this.map, {
visible: true
})
this.selectedPointsLayer.add({
type: 'FeatureCollection',
features: []
})
console.log('[Maps V2] Selected points layer initialized')
}
// Enable selection mode
this.selectionLayer.enableSelectionMode()
// Update UI - replace Select Area button with Cancel Selection button
if (this.controller.hasSelectAreaButtonTarget) {
this.controller.selectAreaButtonTarget.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-5 h-5">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
Cancel Selection
`
this.controller.selectAreaButtonTarget.dataset.action = 'click->maps--maplibre#cancelAreaSelection'
}
Toast.info('Draw a rectangle on the map to select points')
}
/**
* Handle area selection completion
*/
async handleAreaSelected(bounds) {
console.log('[Maps V2] Area selected:', bounds)
try {
Toast.info('Fetching data in selected area...')
const [points, visits] = await Promise.all([
this.api.fetchPointsInArea({
start_at: this.controller.startDateValue,
end_at: this.controller.endDateValue,
min_longitude: bounds.minLng,
max_longitude: bounds.maxLng,
min_latitude: bounds.minLat,
max_latitude: bounds.maxLat
}),
this.api.fetchVisitsInArea({
start_at: this.controller.startDateValue,
end_at: this.controller.endDateValue,
sw_lat: bounds.minLat,
sw_lng: bounds.minLng,
ne_lat: bounds.maxLat,
ne_lng: bounds.maxLng
})
])
console.log('[Maps V2] Found', points.length, 'points and', visits.length, 'visits in area')
if (points.length === 0 && visits.length === 0) {
Toast.info('No data found in selected area')
this.cancelAreaSelection()
return
}
// Convert points to GeoJSON and display
if (points.length > 0) {
const geojson = pointsToGeoJSON(points)
this.selectedPointsLayer.updateSelectedPoints(geojson)
this.selectedPointsLayer.show()
}
// Display visits in side panel and on map
if (visits.length > 0) {
this.displaySelectedVisits(visits)
}
// Update UI - show action buttons
if (this.controller.hasSelectionActionsTarget) {
this.controller.selectionActionsTarget.classList.remove('hidden')
}
// Update delete button text with count
if (this.controller.hasDeleteButtonTextTarget) {
this.controller.deleteButtonTextTarget.textContent = `Delete ${points.length} Point${points.length === 1 ? '' : 's'}`
}
// Disable selection mode
this.selectionLayer.disableSelectionMode()
const messages = []
if (points.length > 0) messages.push(`${points.length} point${points.length === 1 ? '' : 's'}`)
if (visits.length > 0) messages.push(`${visits.length} visit${visits.length === 1 ? '' : 's'}`)
Toast.success(`Selected ${messages.join(' and ')}`)
} catch (error) {
console.error('[Maps V2] Failed to fetch data in area:', error)
Toast.error('Failed to fetch data in selected area')
this.cancelAreaSelection()
}
}
/**
* Display selected visits in side panel
*/
displaySelectedVisits(visits) {
if (!this.controller.hasSelectedVisitsContainerTarget) return
this.selectedVisits = visits
this.selectedVisitIds = new Set()
const cardsHTML = visits.map(visit =>
VisitCard.create(visit, { isSelected: false })
).join('')
this.controller.selectedVisitsContainerTarget.innerHTML = `
<div class="selected-visits-list">
<div class="flex items-center gap-2 mb-3 pb-2 border-b border-base-300">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<h3 class="text-sm font-bold">Visits in Area (${visits.length})</h3>
</div>
${cardsHTML}
</div>
`
this.controller.selectedVisitsContainerTarget.classList.remove('hidden')
this.attachVisitCardListeners()
requestAnimationFrame(() => {
this.updateBulkActions()
})
}
/**
* Attach event listeners to visit cards
*/
attachVisitCardListeners() {
this.controller.element.querySelectorAll('[data-visit-select]').forEach(checkbox => {
checkbox.addEventListener('change', (e) => {
const visitId = parseInt(e.target.dataset.visitSelect)
if (e.target.checked) {
this.selectedVisitIds.add(visitId)
} else {
this.selectedVisitIds.delete(visitId)
}
this.updateBulkActions()
})
})
this.controller.element.querySelectorAll('[data-visit-confirm]').forEach(btn => {
btn.addEventListener('click', async (e) => {
const visitId = parseInt(e.currentTarget.dataset.visitConfirm)
await this.confirmVisit(visitId)
})
})
this.controller.element.querySelectorAll('[data-visit-decline]').forEach(btn => {
btn.addEventListener('click', async (e) => {
const visitId = parseInt(e.currentTarget.dataset.visitDecline)
await this.declineVisit(visitId)
})
})
}
/**
* Update bulk action buttons visibility and attach listeners
*/
updateBulkActions() {
const selectedCount = this.selectedVisitIds.size
const existingBulkActions = this.controller.element.querySelectorAll('.bulk-actions-inline')
existingBulkActions.forEach(el => el.remove())
if (selectedCount >= 2) {
const selectedVisitCards = Array.from(this.controller.element.querySelectorAll('.visit-card'))
.filter(card => {
const visitId = parseInt(card.dataset.visitId)
return this.selectedVisitIds.has(visitId)
})
if (selectedVisitCards.length > 0) {
const lastSelectedCard = selectedVisitCards[selectedVisitCards.length - 1]
const bulkActionsDiv = document.createElement('div')
bulkActionsDiv.className = 'bulk-actions-inline mb-2'
bulkActionsDiv.innerHTML = `
<div class="bg-primary/10 border-2 border-primary border-dashed rounded-lg p-3">
<div class="text-xs font-semibold mb-2 text-primary flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>${selectedCount} visit${selectedCount === 1 ? '' : 's'} selected</span>
</div>
<div class="grid grid-cols-3 gap-1.5">
<button class="btn btn-xs btn-outline normal-case" data-bulk-merge>
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
</svg>
Merge
</button>
<button class="btn btn-xs btn-primary normal-case" data-bulk-confirm>
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
Confirm
</button>
<button class="btn btn-xs btn-outline btn-error normal-case" data-bulk-decline>
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
Decline
</button>
</div>
</div>
`
lastSelectedCard.insertAdjacentElement('afterend', bulkActionsDiv)
const mergeBtn = bulkActionsDiv.querySelector('[data-bulk-merge]')
const confirmBtn = bulkActionsDiv.querySelector('[data-bulk-confirm]')
const declineBtn = bulkActionsDiv.querySelector('[data-bulk-decline]')
if (mergeBtn) mergeBtn.addEventListener('click', () => this.bulkMergeVisits())
if (confirmBtn) confirmBtn.addEventListener('click', () => this.bulkConfirmVisits())
if (declineBtn) declineBtn.addEventListener('click', () => this.bulkDeclineVisits())
}
}
}
/**
* Confirm a single visit
*/
async confirmVisit(visitId) {
try {
await this.api.updateVisitStatus(visitId, 'confirmed')
Toast.success('Visit confirmed')
await this.refreshSelectedVisits()
} catch (error) {
console.error('[Maps V2] Failed to confirm visit:', error)
Toast.error('Failed to confirm visit')
}
}
/**
* Decline a single visit
*/
async declineVisit(visitId) {
try {
await this.api.updateVisitStatus(visitId, 'declined')
Toast.success('Visit declined')
await this.refreshSelectedVisits()
} catch (error) {
console.error('[Maps V2] Failed to decline visit:', error)
Toast.error('Failed to decline visit')
}
}
/**
* Bulk merge selected visits
*/
async bulkMergeVisits() {
const visitIds = Array.from(this.selectedVisitIds)
if (visitIds.length < 2) {
Toast.error('Select at least 2 visits to merge')
return
}
if (!confirm(`Merge ${visitIds.length} visits into one?`)) {
return
}
try {
Toast.info('Merging visits...')
const mergedVisit = await this.api.mergeVisits(visitIds)
Toast.success('Visits merged successfully')
this.selectedVisitIds.clear()
this.replaceVisitsWithMerged(visitIds, mergedVisit)
this.updateBulkActions()
} catch (error) {
console.error('[Maps V2] Failed to merge visits:', error)
Toast.error('Failed to merge visits')
}
}
/**
* Bulk confirm selected visits
*/
async bulkConfirmVisits() {
const visitIds = Array.from(this.selectedVisitIds)
try {
Toast.info('Confirming visits...')
await this.api.bulkUpdateVisits(visitIds, 'confirmed')
Toast.success(`Confirmed ${visitIds.length} visits`)
this.selectedVisitIds.clear()
await this.refreshSelectedVisits()
} catch (error) {
console.error('[Maps V2] Failed to confirm visits:', error)
Toast.error('Failed to confirm visits')
}
}
/**
* Bulk decline selected visits
*/
async bulkDeclineVisits() {
const visitIds = Array.from(this.selectedVisitIds)
if (!confirm(`Decline ${visitIds.length} visits?`)) {
return
}
try {
Toast.info('Declining visits...')
await this.api.bulkUpdateVisits(visitIds, 'declined')
Toast.success(`Declined ${visitIds.length} visits`)
this.selectedVisitIds.clear()
await this.refreshSelectedVisits()
} catch (error) {
console.error('[Maps V2] Failed to decline visits:', error)
Toast.error('Failed to decline visits')
}
}
/**
* Replace merged visit cards with the new merged visit
*/
replaceVisitsWithMerged(oldVisitIds, mergedVisit) {
const container = this.controller.element.querySelector('.selected-visits-list')
if (!container) return
const mergedStartTime = new Date(mergedVisit.started_at).getTime()
const allCards = Array.from(container.querySelectorAll('.visit-card'))
let insertBeforeCard = null
for (const card of allCards) {
const cardId = parseInt(card.dataset.visitId)
if (oldVisitIds.includes(cardId)) continue
const cardVisit = this.selectedVisits.find(v => v.id === cardId)
if (cardVisit) {
const cardStartTime = new Date(cardVisit.started_at).getTime()
if (cardStartTime > mergedStartTime) {
insertBeforeCard = card
break
}
}
}
oldVisitIds.forEach(id => {
const card = this.controller.element.querySelector(`.visit-card[data-visit-id="${id}"]`)
if (card) card.remove()
})
this.selectedVisits = this.selectedVisits.filter(v => !oldVisitIds.includes(v.id))
this.selectedVisits.push(mergedVisit)
this.selectedVisits.sort((a, b) => new Date(a.started_at) - new Date(b.started_at))
const newCardHTML = VisitCard.create(mergedVisit, { isSelected: false })
if (insertBeforeCard) {
insertBeforeCard.insertAdjacentHTML('beforebegin', newCardHTML)
} else {
container.insertAdjacentHTML('beforeend', newCardHTML)
}
const header = container.querySelector('h3')
if (header) {
header.textContent = `Visits in Area (${this.selectedVisits.length})`
}
this.attachVisitCardListeners()
}
/**
* Refresh selected visits after changes
*/
async refreshSelectedVisits() {
const bounds = this.selectionLayer.currentRect
if (!bounds) return
try {
const visits = await this.api.fetchVisitsInArea({
start_at: this.controller.startDateValue,
end_at: this.controller.endDateValue,
sw_lat: bounds.start.lat < bounds.end.lat ? bounds.start.lat : bounds.end.lat,
sw_lng: bounds.start.lng < bounds.end.lng ? bounds.start.lng : bounds.end.lng,
ne_lat: bounds.start.lat > bounds.end.lat ? bounds.start.lat : bounds.end.lat,
ne_lng: bounds.start.lng > bounds.end.lng ? bounds.start.lng : bounds.end.lng
})
this.displaySelectedVisits(visits)
} catch (error) {
console.error('[Maps V2] Failed to refresh visits:', error)
}
}
/**
* Cancel area selection
*/
cancelAreaSelection() {
console.log('[Maps V2] Cancelling area selection')
if (this.selectionLayer) {
this.selectionLayer.disableSelectionMode()
this.selectionLayer.clearSelection()
}
if (this.selectedPointsLayer) {
this.selectedPointsLayer.clearSelection()
}
if (this.controller.hasSelectedVisitsContainerTarget) {
this.controller.selectedVisitsContainerTarget.classList.add('hidden')
this.controller.selectedVisitsContainerTarget.innerHTML = ''
}
if (this.controller.hasSelectedVisitsBulkActionsTarget) {
this.controller.selectedVisitsBulkActionsTarget.classList.add('hidden')
}
this.selectedVisits = []
this.selectedVisitIds = new Set()
if (this.controller.hasSelectAreaButtonTarget) {
this.controller.selectAreaButtonTarget.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-5 h-5">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<path d="M9 3v18"></path>
<path d="M15 3v18"></path>
<path d="M3 9h18"></path>
<path d="M3 15h18"></path>
</svg>
Select Area
`
this.controller.selectAreaButtonTarget.classList.remove('btn-error')
this.controller.selectAreaButtonTarget.classList.add('btn', 'btn-outline')
this.controller.selectAreaButtonTarget.dataset.action = 'click->maps--maplibre#startSelectArea'
}
if (this.controller.hasSelectionActionsTarget) {
this.controller.selectionActionsTarget.classList.add('hidden')
}
Toast.info('Selection cancelled')
}
/**
* Delete selected points
*/
async deleteSelectedPoints() {
const pointCount = this.selectedPointsLayer.getCount()
const pointIds = this.selectedPointsLayer.getSelectedPointIds()
if (pointIds.length === 0) {
Toast.error('No points selected')
return
}
const confirmed = confirm(
`Are you sure you want to delete ${pointCount} point${pointCount === 1 ? '' : 's'}? This action cannot be undone.`
)
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({
showLoading: false,
fitBounds: false,
showToast: false
})
Toast.success(`Deleted ${result.count} point${result.count === 1 ? '' : 's'}`)
} catch (error) {
console.error('[Maps V2] Failed to delete points:', error)
Toast.error('Failed to delete points. Please try again.')
}
}
}

View file

@ -0,0 +1,250 @@
import { pointsToGeoJSON } from 'maps_maplibre/utils/geojson_transformers'
import { RoutesLayer } from 'maps_maplibre/layers/routes_layer'
import { createCircle } from 'maps_maplibre/utils/geometry'
import { performanceMonitor } from 'maps_maplibre/utils/performance_monitor'
/**
* Handles loading and transforming data from API
*/
export class DataLoader {
constructor(api, apiKey, settings = {}) {
this.api = api
this.apiKey = apiKey
this.settings = settings
}
/**
* Update settings (called when user changes settings)
*/
updateSettings(settings) {
this.settings = settings
}
/**
* Fetch all map data (points, visits, photos, areas, tracks)
*/
async fetchMapData(startDate, endDate, onProgress) {
const data = {}
// Fetch points
performanceMonitor.mark('fetch-points')
data.points = await this.api.fetchAllPoints({
start_at: startDate,
end_at: endDate,
onProgress: onProgress
})
performanceMonitor.measure('fetch-points')
// Transform points to GeoJSON
performanceMonitor.mark('transform-geojson')
data.pointsGeoJSON = pointsToGeoJSON(data.points)
data.routesGeoJSON = RoutesLayer.pointsToRoutes(data.points, {
distanceThresholdMeters: this.settings.metersBetweenRoutes || 1000,
timeThresholdMinutes: this.settings.minutesBetweenRoutes || 60
})
performanceMonitor.measure('transform-geojson')
// Fetch visits
try {
data.visits = await this.api.fetchVisits({
start_at: startDate,
end_at: endDate
})
} catch (error) {
console.warn('Failed to fetch visits:', error)
data.visits = []
}
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')
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])
}
// Fetch areas
try {
data.areas = await this.api.fetchAreas()
} catch (error) {
console.warn('Failed to fetch areas:', error)
data.areas = []
}
data.areasGeoJSON = this.areasToGeoJSON(data.areas)
// Fetch places (no date filtering)
try {
data.places = await this.api.fetchPlaces()
} catch (error) {
console.warn('Failed to fetch places:', error)
data.places = []
}
data.placesGeoJSON = this.placesToGeoJSON(data.places)
// 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
}
/**
* Convert visits to GeoJSON
*/
visitsToGeoJSON(visits) {
return {
type: 'FeatureCollection',
features: visits.map(visit => ({
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [visit.place.longitude, visit.place.latitude]
},
properties: {
id: visit.id,
name: visit.name,
place_name: visit.place?.name,
status: visit.status,
started_at: visit.started_at,
ended_at: visit.ended_at,
duration: visit.duration
}
}))
}
}
/**
* Convert photos to GeoJSON
*/
photosToGeoJSON(photos) {
return {
type: 'FeatureCollection',
features: photos.map(photo => {
// Construct thumbnail URL
const thumbnailUrl = `/api/v1/photos/${photo.id}/thumbnail.jpg?api_key=${this.apiKey}&source=${photo.source}`
return {
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [photo.longitude, photo.latitude]
},
properties: {
id: photo.id,
thumbnail_url: thumbnailUrl,
taken_at: photo.localDateTime,
filename: photo.originalFileName,
city: photo.city,
state: photo.state,
country: photo.country,
type: photo.type,
source: photo.source
}
}
})
}
}
/**
* Convert places to GeoJSON
*/
placesToGeoJSON(places) {
return {
type: 'FeatureCollection',
features: places.map(place => ({
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [place.longitude, place.latitude]
},
properties: {
id: place.id,
name: place.name,
latitude: place.latitude,
longitude: place.longitude,
note: place.note,
// Stringify tags for MapLibre GL JS compatibility
tags: JSON.stringify(place.tags || []),
// Use first tag's color if available
color: place.tags?.[0]?.color || '#6366f1'
}
}))
}
}
/**
* Convert areas to GeoJSON
* Backend returns circular areas with latitude, longitude, radius
*/
areasToGeoJSON(areas) {
return {
type: 'FeatureCollection',
features: areas.map(area => {
// Create circle polygon from center and radius
// Parse as floats since API returns strings
const center = [parseFloat(area.longitude), parseFloat(area.latitude)]
const coordinates = createCircle(center, area.radius)
return {
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [coordinates]
},
properties: {
id: area.id,
name: area.name,
color: area.color || '#ef4444',
radius: area.radius
}
}
})
}
}
/**
* Convert tracks to GeoJSON
*/
tracksToGeoJSON(tracks) {
return {
type: 'FeatureCollection',
features: tracks.map(track => ({
type: 'Feature',
geometry: {
type: 'LineString',
coordinates: track.coordinates
},
properties: {
id: track.id,
name: track.name,
color: track.color || '#8b5cf6'
}
}))
}
}
}

View file

@ -0,0 +1,35 @@
/**
* Manages date formatting and range calculations
*/
export class DateManager {
/**
* Format date for API requests (matching V1 format)
* Format: "YYYY-MM-DDTHH:MM" (e.g., "2025-10-15T00:00", "2025-10-15T23:59")
*/
static formatDateForAPI(date) {
const pad = (n) => String(n).padStart(2, '0')
const year = date.getFullYear()
const month = pad(date.getMonth() + 1)
const day = pad(date.getDate())
const hours = pad(date.getHours())
const minutes = pad(date.getMinutes())
return `${year}-${month}-${day}T${hours}:${minutes}`
}
/**
* Parse month selector value to date range
*/
static parseMonthSelector(value) {
const [year, month] = value.split('-')
const startDate = new Date(year, month - 1, 1, 0, 0, 0)
const lastDay = new Date(year, month, 0).getDate()
const endDate = new Date(year, month - 1, lastDay, 23, 59, 0)
return {
startDate: this.formatDateForAPI(startDate),
endDate: this.formatDateForAPI(endDate)
}
}
}

View file

@ -0,0 +1,129 @@
import { formatTimestamp } from 'maps_maplibre/utils/geojson_transformers'
/**
* Handles map interaction events (clicks, info display)
*/
export class EventHandlers {
constructor(map, controller) {
this.map = map
this.controller = controller
}
/**
* Handle point click
*/
handlePointClick(e) {
const feature = e.features[0]
const properties = feature.properties
const content = `
<div class="space-y-2">
<div><span class="font-semibold">Time:</span> ${formatTimestamp(properties.timestamp, this.controller.timezoneValue)}</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>` : ''}
</div>
`
this.controller.showInfo('Location Point', content)
}
/**
* Handle visit click
*/
handleVisitClick(e) {
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 durationHours = Math.round(properties.duration / 3600)
const durationDisplay = durationHours >= 1 ? `${durationHours}h` : `${Math.round(properties.duration / 60)}m`
const content = `
<div class="space-y-2">
<div class="badge badge-sm ${properties.status === 'confirmed' ? 'badge-success' : 'badge-warning'}">${properties.status}</div>
<div><span class="font-semibold">Arrived:</span> ${startTime}</div>
<div><span class="font-semibold">Left:</span> ${endTime}</div>
<div><span class="font-semibold">Duration:</span> ${durationDisplay}</div>
</div>
`
const actions = [{
type: 'button',
handler: 'handleEdit',
id: properties.id,
entityType: 'visit',
label: 'Edit'
}]
this.controller.showInfo(properties.name || properties.place_name || 'Visit', content, actions)
}
/**
* Handle photo click
*/
handlePhotoClick(e) {
const feature = e.features[0]
const properties = feature.properties
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>` : ''}
</div>
`
this.controller.showInfo('Photo', content)
}
/**
* Handle place click
*/
handlePlaceClick(e) {
const feature = e.features[0]
const properties = feature.properties
const content = `
<div class="space-y-2">
${properties.tag ? `<div class="badge badge-sm badge-primary">${properties.tag}</div>` : ''}
${properties.description ? `<div>${properties.description}</div>` : ''}
</div>
`
const actions = properties.id ? [{
type: 'button',
handler: 'handleEdit',
id: properties.id,
entityType: 'place',
label: 'Edit'
}] : []
this.controller.showInfo(properties.name || 'Place', content, actions)
}
/**
* Handle area click
*/
handleAreaClick(e) {
const feature = e.features[0]
const properties = feature.properties
const content = `
<div class="space-y-2">
${properties.radius ? `<div><span class="font-semibold">Radius:</span> ${Math.round(properties.radius)}m</div>` : ''}
${properties.latitude && properties.longitude ? `<div><span class="font-semibold">Center:</span> ${properties.latitude.toFixed(6)}, ${properties.longitude.toFixed(6)}</div>` : ''}
</div>
`
const actions = properties.id ? [{
type: 'button',
handler: 'handleDelete',
id: properties.id,
entityType: 'area',
label: 'Delete'
}] : []
this.controller.showInfo(properties.name || 'Area', content, actions)
}
}

View file

@ -0,0 +1,53 @@
/**
* Manages filtering and searching of map data
*/
export class FilterManager {
constructor(dataLoader) {
this.dataLoader = dataLoader
this.currentVisitFilter = 'all'
this.allVisits = []
}
/**
* Store all visits for filtering
*/
setAllVisits(visits) {
this.allVisits = visits
}
/**
* Filter and update visits display
*/
filterAndUpdateVisits(searchTerm, statusFilter, visitsLayer) {
if (!this.allVisits || !visitsLayer) return
const filtered = this.allVisits.filter(visit => {
// Apply search
const matchesSearch = !searchTerm ||
visit.name?.toLowerCase().includes(searchTerm) ||
visit.place?.name?.toLowerCase().includes(searchTerm)
// Apply status filter
const matchesStatus = statusFilter === 'all' || visit.status === statusFilter
return matchesSearch && matchesStatus
})
const geojson = this.dataLoader.visitsToGeoJSON(filtered)
visitsLayer.update(geojson)
}
/**
* Get current visit filter
*/
getCurrentVisitFilter() {
return this.currentVisitFilter
}
/**
* Set current visit filter
*/
setCurrentVisitFilter(filter) {
this.currentVisitFilter = filter
}
}

View file

@ -0,0 +1,281 @@
import { PointsLayer } from 'maps_maplibre/layers/points_layer'
import { RoutesLayer } from 'maps_maplibre/layers/routes_layer'
import { HeatmapLayer } from 'maps_maplibre/layers/heatmap_layer'
import { VisitsLayer } from 'maps_maplibre/layers/visits_layer'
import { PhotosLayer } from 'maps_maplibre/layers/photos_layer'
import { AreasLayer } from 'maps_maplibre/layers/areas_layer'
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'
/**
* Manages all map layers lifecycle and visibility
*/
export class LayerManager {
constructor(map, settings, api) {
this.map = map
this.settings = settings
this.api = api
this.layers = {}
}
/**
* Add or update all layers with provided data
*/
async addAllLayers(pointsGeoJSON, routesGeoJSON, visitsGeoJSON, photosGeoJSON, areasGeoJSON, tracksGeoJSON, placesGeoJSON) {
performanceMonitor.mark('add-layers')
// Layer order matters - layers added first render below layers added later
// Order: scratch (bottom) -> heatmap -> areas -> tracks -> routes -> visits -> places -> photos -> family -> points -> recent-point (top) -> fog (canvas overlay)
await this._addScratchLayer(pointsGeoJSON)
this._addHeatmapLayer(pointsGeoJSON)
this._addAreasLayer(areasGeoJSON)
this._addTracksLayer(tracksGeoJSON)
this._addRoutesLayer(routesGeoJSON)
this._addVisitsLayer(visitsGeoJSON)
this._addPlacesLayer(placesGeoJSON)
// Add photos layer with error handling (async, might fail loading images)
try {
await this._addPhotosLayer(photosGeoJSON)
} catch (error) {
console.warn('Failed to add photos layer:', error)
}
this._addFamilyLayer()
this._addPointsLayer(pointsGeoJSON)
this._addRecentPointLayer()
this._addFogLayer(pointsGeoJSON)
performanceMonitor.measure('add-layers')
}
/**
* Setup event handlers for layer interactions
*/
setupLayerEventHandlers(handlers) {
// Click handlers
this.map.on('click', 'points', handlers.handlePointClick)
this.map.on('click', 'visits', handlers.handleVisitClick)
this.map.on('click', 'photos', handlers.handlePhotoClick)
this.map.on('click', 'places', handlers.handlePlaceClick)
// Areas have multiple layers (fill, outline, labels)
this.map.on('click', 'areas-fill', handlers.handleAreaClick)
this.map.on('click', 'areas-outline', handlers.handleAreaClick)
this.map.on('click', 'areas-labels', handlers.handleAreaClick)
// Cursor change on hover
this.map.on('mouseenter', 'points', () => {
this.map.getCanvas().style.cursor = 'pointer'
})
this.map.on('mouseleave', 'points', () => {
this.map.getCanvas().style.cursor = ''
})
this.map.on('mouseenter', 'visits', () => {
this.map.getCanvas().style.cursor = 'pointer'
})
this.map.on('mouseleave', 'visits', () => {
this.map.getCanvas().style.cursor = ''
})
this.map.on('mouseenter', 'photos', () => {
this.map.getCanvas().style.cursor = 'pointer'
})
this.map.on('mouseleave', 'photos', () => {
this.map.getCanvas().style.cursor = ''
})
this.map.on('mouseenter', 'places', () => {
this.map.getCanvas().style.cursor = 'pointer'
})
this.map.on('mouseleave', 'places', () => {
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 = ''
})
}
})
}
/**
* Toggle layer visibility
*/
toggleLayer(layerName) {
const layer = this.layers[`${layerName}Layer`]
if (!layer) return null
layer.toggle()
return layer.visible
}
/**
* Get layer instance
*/
getLayer(layerName) {
return this.layers[`${layerName}Layer`]
}
/**
* Clear all layer references (for style changes)
*/
clearLayerReferences() {
this.layers = {}
}
// Private methods for individual layer management
async _addScratchLayer(pointsGeoJSON) {
try {
if (!this.layers.scratchLayer && this.settings.scratchEnabled) {
const ScratchLayer = await lazyLoader.loadLayer('scratch')
this.layers.scratchLayer = new ScratchLayer(this.map, {
visible: true,
apiClient: this.api
})
await this.layers.scratchLayer.add(pointsGeoJSON)
} else if (this.layers.scratchLayer) {
await this.layers.scratchLayer.update(pointsGeoJSON)
}
} catch (error) {
console.warn('Failed to load scratch layer:', error)
}
}
_addHeatmapLayer(pointsGeoJSON) {
if (!this.layers.heatmapLayer) {
this.layers.heatmapLayer = new HeatmapLayer(this.map, {
visible: this.settings.heatmapEnabled
})
this.layers.heatmapLayer.add(pointsGeoJSON)
} else {
this.layers.heatmapLayer.update(pointsGeoJSON)
}
}
_addAreasLayer(areasGeoJSON) {
if (!this.layers.areasLayer) {
this.layers.areasLayer = new AreasLayer(this.map, {
visible: this.settings.areasEnabled || false
})
this.layers.areasLayer.add(areasGeoJSON)
} else {
this.layers.areasLayer.update(areasGeoJSON)
}
}
_addTracksLayer(tracksGeoJSON) {
if (!this.layers.tracksLayer) {
this.layers.tracksLayer = new TracksLayer(this.map, {
visible: this.settings.tracksEnabled || false
})
this.layers.tracksLayer.add(tracksGeoJSON)
} else {
this.layers.tracksLayer.update(tracksGeoJSON)
}
}
_addRoutesLayer(routesGeoJSON) {
if (!this.layers.routesLayer) {
this.layers.routesLayer = new RoutesLayer(this.map, {
visible: this.settings.routesVisible !== false // Default true unless explicitly false
})
this.layers.routesLayer.add(routesGeoJSON)
} else {
this.layers.routesLayer.update(routesGeoJSON)
}
}
_addVisitsLayer(visitsGeoJSON) {
if (!this.layers.visitsLayer) {
this.layers.visitsLayer = new VisitsLayer(this.map, {
visible: this.settings.visitsEnabled || false
})
this.layers.visitsLayer.add(visitsGeoJSON)
} else {
this.layers.visitsLayer.update(visitsGeoJSON)
}
}
_addPlacesLayer(placesGeoJSON) {
if (!this.layers.placesLayer) {
this.layers.placesLayer = new PlacesLayer(this.map, {
visible: this.settings.placesEnabled || false
})
this.layers.placesLayer.add(placesGeoJSON)
} else {
this.layers.placesLayer.update(placesGeoJSON)
}
}
async _addPhotosLayer(photosGeoJSON) {
console.log('[Photos] Adding photos layer, visible:', this.settings.photosEnabled)
if (!this.layers.photosLayer) {
this.layers.photosLayer = new PhotosLayer(this.map, {
visible: this.settings.photosEnabled || false
})
console.log('[Photos] Created new PhotosLayer instance')
await this.layers.photosLayer.add(photosGeoJSON)
console.log('[Photos] Added photos to layer')
} else {
console.log('[Photos] Updating existing PhotosLayer')
await this.layers.photosLayer.update(photosGeoJSON)
console.log('[Photos] Updated photos layer')
}
}
_addFamilyLayer() {
if (!this.layers.familyLayer) {
this.layers.familyLayer = new FamilyLayer(this.map, {
visible: false // Initially hidden, shown when family locations arrive via ActionCable
})
this.layers.familyLayer.add({ type: 'FeatureCollection', features: [] })
}
}
_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
})
this.layers.pointsLayer.add(pointsGeoJSON)
} else {
this.layers.pointsLayer.update(pointsGeoJSON)
}
}
_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,
visible: this.settings.fogEnabled || false
})
this.layers.fogLayer.add(pointsGeoJSON)
} else {
this.layers.fogLayer.update(pointsGeoJSON)
}
}
}

View file

@ -0,0 +1,131 @@
import maplibregl from 'maplibre-gl'
import { Toast } from 'maps_maplibre/components/toast'
import { performanceMonitor } from 'maps_maplibre/utils/performance_monitor'
/**
* Manages data loading and layer setup for the map
*/
export class MapDataManager {
constructor(controller) {
this.controller = controller
this.map = controller.map
this.dataLoader = controller.dataLoader
this.layerManager = controller.layerManager
this.filterManager = controller.filterManager
this.eventHandlers = controller.eventHandlers
}
/**
* Load map data from API and setup layers
* @param {string} startDate - Start date for data range
* @param {string} endDate - End date for data range
* @param {Object} options - Loading options
*/
async loadMapData(startDate, endDate, options = {}) {
const {
showLoading = true,
fitBounds = true,
showToast = true,
onProgress = null
} = options
performanceMonitor.mark('load-map-data')
if (showLoading) {
this.controller.showLoading()
}
try {
// Fetch data from API
const data = await this.dataLoader.fetchMapData(
startDate,
endDate,
showLoading ? onProgress : null
)
// Store visits for filtering
this.filterManager.setAllVisits(data.visits)
// Setup layers
await this._setupLayers(data)
// Fit bounds if requested
if (fitBounds && data.points.length > 0) {
this._fitMapToBounds(data.pointsGeoJSON)
}
// Show success message
if (showToast) {
const pointText = data.points.length === 1 ? 'point' : 'points'
Toast.success(`Loaded ${data.points.length} location ${pointText}`)
}
return data
} catch (error) {
console.error('[MapDataManager] Failed to load map data:', error)
Toast.error('Failed to load location data. Please try again.')
throw error
} finally {
if (showLoading) {
this.controller.hideLoading()
}
const duration = performanceMonitor.measure('load-map-data')
console.log(`[Performance] Map data loaded in ${duration}ms`)
}
}
/**
* Setup all map layers with loaded data
* @private
*/
async _setupLayers(data) {
const addAllLayers = async () => {
await this.layerManager.addAllLayers(
data.pointsGeoJSON,
data.routesGeoJSON,
data.visitsGeoJSON,
data.photosGeoJSON,
data.areasGeoJSON,
data.tracksGeoJSON,
data.placesGeoJSON
)
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)
})
}
if (this.map.loaded()) {
await addAllLayers()
} else {
this.map.once('load', async () => {
await addAllLayers()
})
}
}
/**
* Fit map to data bounds
* @private
*/
_fitMapToBounds(geojson) {
if (!geojson?.features?.length) {
return
}
const coordinates = geojson.features.map(f => f.geometry.coordinates)
const bounds = coordinates.reduce((bounds, coord) => {
return bounds.extend(coord)
}, new maplibregl.LngLatBounds(coordinates[0], coordinates[0]))
this.map.fitBounds(bounds, {
padding: 50,
maxZoom: 15
})
}
}

View file

@ -0,0 +1,84 @@
import maplibregl from 'maplibre-gl'
import { getMapStyle } from 'maps_maplibre/utils/style_manager'
/**
* Handles map initialization for Maps V2
*/
export class MapInitializer {
/**
* Initialize MapLibre map instance
* @param {HTMLElement} container - The container element for the map
* @param {Object} settings - Map settings (style, center, zoom)
* @returns {Promise<maplibregl.Map>} The initialized map instance
*/
static async initialize(container, settings = {}) {
const {
mapStyle = 'streets',
center = [0, 0],
zoom = 2,
showControls = true,
globeProjection = false
} = settings
const style = await getMapStyle(mapStyle)
const mapOptions = {
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')
}
return map
}
/**
* Fit map to bounds of GeoJSON features
* @param {maplibregl.Map} map - The map instance
* @param {Object} geojson - GeoJSON FeatureCollection
* @param {Object} options - Fit bounds options
*/
static fitToBounds(map, geojson, options = {}) {
const {
padding = 50,
maxZoom = 15
} = options
if (!geojson?.features?.length) {
console.warn('[MapInitializer] No features to fit bounds to')
return
}
const coordinates = geojson.features.map(f => f.geometry.coordinates)
const bounds = coordinates.reduce((bounds, coord) => {
return bounds.extend(coord)
}, new maplibregl.LngLatBounds(coordinates[0], coordinates[0]))
map.fitBounds(bounds, {
padding,
maxZoom
})
}
}

View file

@ -0,0 +1,281 @@
import { SettingsManager } from 'maps_maplibre/utils/settings_manager'
import { Toast } from 'maps_maplibre/components/toast'
/**
* Manages places-related operations for Maps V2
* Including place creation, tag filtering, and layer management
*/
export class PlacesManager {
constructor(controller) {
this.controller = controller
this.layerManager = controller.layerManager
this.api = controller.api
this.dataLoader = controller.dataLoader
this.settings = controller.settings
}
/**
* Toggle places layer
*/
togglePlaces(event) {
const enabled = event.target.checked
SettingsManager.updateSetting('placesEnabled', enabled)
const placesLayer = this.layerManager.getLayer('places')
if (placesLayer) {
if (enabled) {
placesLayer.show()
if (this.controller.hasPlacesFiltersTarget) {
this.controller.placesFiltersTarget.style.display = 'block'
}
this.initializePlaceTagFilters()
} else {
placesLayer.hide()
if (this.controller.hasPlacesFiltersTarget) {
this.controller.placesFiltersTarget.style.display = 'none'
}
}
}
}
/**
* Initialize place tag filters (enable all by default or restore saved state)
*/
initializePlaceTagFilters() {
const savedFilters = this.settings.placesTagFilters
if (savedFilters && savedFilters.length > 0) {
this.restoreSavedTagFilters(savedFilters)
} else {
this.enableAllTagsInitial()
}
}
/**
* Restore saved tag filters
*/
restoreSavedTagFilters(savedFilters) {
const tagCheckboxes = document.querySelectorAll('input[name="place_tag_ids[]"]')
tagCheckboxes.forEach(checkbox => {
const value = checkbox.value === 'untagged' ? checkbox.value : parseInt(checkbox.value)
const shouldBeChecked = savedFilters.includes(value)
if (checkbox.checked !== shouldBeChecked) {
checkbox.checked = shouldBeChecked
const badge = checkbox.nextElementSibling
const color = badge.style.borderColor
if (shouldBeChecked) {
badge.classList.remove('badge-outline')
badge.style.backgroundColor = color
badge.style.color = 'white'
} else {
badge.classList.add('badge-outline')
badge.style.backgroundColor = 'transparent'
badge.style.color = color
}
}
})
this.syncEnableAllTagsToggle()
this.loadPlacesWithTags(savedFilters)
}
/**
* Enable all tags initially
*/
enableAllTagsInitial() {
if (this.controller.hasEnableAllPlaceTagsToggleTarget) {
this.controller.enableAllPlaceTagsToggleTarget.checked = true
}
const tagCheckboxes = document.querySelectorAll('input[name="place_tag_ids[]"]')
const allTagIds = []
tagCheckboxes.forEach(checkbox => {
checkbox.checked = true
const badge = checkbox.nextElementSibling
const color = badge.style.borderColor
badge.classList.remove('badge-outline')
badge.style.backgroundColor = color
badge.style.color = 'white'
const value = checkbox.value === 'untagged' ? checkbox.value : parseInt(checkbox.value)
allTagIds.push(value)
})
SettingsManager.updateSetting('placesTagFilters', allTagIds)
this.loadPlacesWithTags(allTagIds)
}
/**
* Get selected place tag IDs
*/
getSelectedPlaceTags() {
return Array.from(
document.querySelectorAll('input[name="place_tag_ids[]"]:checked')
).map(cb => {
const value = cb.value
return value === 'untagged' ? value : parseInt(value)
})
}
/**
* Filter places by selected tags
*/
filterPlacesByTags(event) {
const badge = event.target.nextElementSibling
const color = badge.style.borderColor
if (event.target.checked) {
badge.classList.remove('badge-outline')
badge.style.backgroundColor = color
badge.style.color = 'white'
} else {
badge.classList.add('badge-outline')
badge.style.backgroundColor = 'transparent'
badge.style.color = color
}
this.syncEnableAllTagsToggle()
const checkedTags = this.getSelectedPlaceTags()
SettingsManager.updateSetting('placesTagFilters', checkedTags)
this.loadPlacesWithTags(checkedTags)
}
/**
* Sync "Enable All Tags" toggle with individual tag states
*/
syncEnableAllTagsToggle() {
if (!this.controller.hasEnableAllPlaceTagsToggleTarget) return
const tagCheckboxes = document.querySelectorAll('input[name="place_tag_ids[]"]')
const allChecked = Array.from(tagCheckboxes).every(cb => cb.checked)
this.controller.enableAllPlaceTagsToggleTarget.checked = allChecked
}
/**
* Load places filtered by tags
*/
async loadPlacesWithTags(tagIds = []) {
try {
let places = []
if (tagIds.length > 0) {
places = await this.api.fetchPlaces({ tag_ids: tagIds })
}
const placesGeoJSON = this.dataLoader.placesToGeoJSON(places)
const placesLayer = this.layerManager.getLayer('places')
if (placesLayer) {
placesLayer.update(placesGeoJSON)
}
} catch (error) {
console.error('[Maps V2] Failed to load places:', error)
}
}
/**
* Toggle all place tags on/off
*/
toggleAllPlaceTags(event) {
const enableAll = event.target.checked
const tagCheckboxes = document.querySelectorAll('input[name="place_tag_ids[]"]')
tagCheckboxes.forEach(checkbox => {
if (checkbox.checked !== enableAll) {
checkbox.checked = enableAll
const badge = checkbox.nextElementSibling
const color = badge.style.borderColor
if (enableAll) {
badge.classList.remove('badge-outline')
badge.style.backgroundColor = color
badge.style.color = 'white'
} else {
badge.classList.add('badge-outline')
badge.style.backgroundColor = 'transparent'
badge.style.color = color
}
}
})
const selectedTags = this.getSelectedPlaceTags()
SettingsManager.updateSetting('placesTagFilters', selectedTags)
this.loadPlacesWithTags(selectedTags)
}
/**
* 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()
}
this.controller.map.getCanvas().style.cursor = 'crosshair'
Toast.info('Click on the map to place a place')
this.handleCreatePlaceClick = (e) => {
const { lng, lat } = e.lngLat
document.dispatchEvent(new CustomEvent('place:create', {
detail: { latitude: lat, longitude: lng }
}))
this.controller.map.getCanvas().style.cursor = ''
}
this.controller.map.once('click', this.handleCreatePlaceClick)
}
/**
* 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()
const places = await this.api.fetchPlaces({
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')
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')
}
} catch (error) {
console.error('[Maps V2] Failed to reload places:', error)
}
}
/**
* Handle place update event - reload places and update layer
*/
async handlePlaceUpdated(event) {
console.log('[Maps V2] Place updated, reloading places...', event.detail)
// Reuse the same logic as creation
await this.handlePlaceCreated(event)
}
}

View file

@ -0,0 +1,384 @@
import { SettingsManager } from 'maps_maplibre/utils/settings_manager'
import { Toast } from 'maps_maplibre/components/toast'
import { lazyLoader } from 'maps_maplibre/utils/lazy_loader'
/**
* Manages routes-related operations for Maps V2
* Including speed-colored routes, route generation, and layer management
*/
export class RoutesManager {
constructor(controller) {
this.controller = controller
this.map = controller.map
this.layerManager = controller.layerManager
this.settings = controller.settings
}
/**
* Toggle routes layer visibility
*/
toggleRoutes(event) {
const element = event.currentTarget
const visible = element.checked
const routesLayer = this.layerManager.getLayer('routes')
if (routesLayer) {
routesLayer.toggle(visible)
}
if (this.controller.hasRoutesOptionsTarget) {
this.controller.routesOptionsTarget.style.display = visible ? 'block' : 'none'
}
SettingsManager.updateSetting('routesVisible', visible)
}
/**
* Toggle speed-colored routes
*/
async toggleSpeedColoredRoutes(event) {
const enabled = event.target.checked
SettingsManager.updateSetting('speedColoredRoutesEnabled', enabled)
if (this.controller.hasSpeedColorScaleContainerTarget) {
this.controller.speedColorScaleContainerTarget.classList.toggle('hidden', !enabled)
}
await this.reloadRoutes()
}
/**
* Open speed color editor modal
*/
openSpeedColorEditor() {
const currentScale = this.controller.speedColorScaleInputTarget.value ||
'0:#00ff00|15:#00ffff|30:#ff00ff|50:#ffff00|100:#ff3300'
let modal = document.getElementById('speed-color-editor-modal')
if (!modal) {
modal = this.createSpeedColorEditorModal(currentScale)
document.body.appendChild(modal)
} else {
const controller = this.controller.application.getControllerForElementAndIdentifier(modal, 'speed-color-editor')
if (controller) {
controller.colorStopsValue = currentScale
controller.loadColorStops()
}
}
const checkbox = modal.querySelector('.modal-toggle')
if (checkbox) {
checkbox.checked = true
}
}
/**
* Create speed color editor modal element
*/
createSpeedColorEditorModal(currentScale) {
const modal = document.createElement('div')
modal.id = 'speed-color-editor-modal'
modal.setAttribute('data-controller', 'speed-color-editor')
modal.setAttribute('data-speed-color-editor-color-stops-value', currentScale)
modal.setAttribute('data-action', 'speed-color-editor:save->maps--maplibre#handleSpeedColorSave')
modal.innerHTML = `
<input type="checkbox" id="speed-color-editor-toggle" class="modal-toggle" />
<div class="modal" role="dialog" data-speed-color-editor-target="modal">
<div class="modal-box max-w-2xl">
<h3 class="text-lg font-bold mb-4">Edit Speed Color Gradient</h3>
<div class="space-y-4">
<!-- Gradient Preview -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Preview</span>
</label>
<div class="h-12 rounded-lg border-2 border-base-300"
data-speed-color-editor-target="preview"></div>
<label class="label">
<span class="label-text-alt">This gradient will be applied to routes based on speed</span>
</label>
</div>
<!-- Color Stops List -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Color Stops</span>
</label>
<div class="space-y-2" data-speed-color-editor-target="stopsList"></div>
</div>
<!-- Add Stop Button -->
<button type="button"
class="btn btn-sm btn-outline w-full"
data-action="click->speed-color-editor#addStop">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Add Color Stop
</button>
</div>
<div class="modal-action">
<button type="button"
class="btn btn-ghost"
data-action="click->speed-color-editor#resetToDefault">
Reset to Default
</button>
<button type="button"
class="btn"
data-action="click->speed-color-editor#close">
Cancel
</button>
<button type="button"
class="btn btn-primary"
data-action="click->speed-color-editor#save">
Save
</button>
</div>
</div>
<label class="modal-backdrop" for="speed-color-editor-toggle"></label>
</div>
`
return modal
}
/**
* Handle speed color save event from editor
*/
handleSpeedColorSave(event) {
const newScale = event.detail.colorStops
this.controller.speedColorScaleInputTarget.value = newScale
SettingsManager.updateSetting('speedColorScale', newScale)
if (this.controller.speedColoredToggleTarget.checked) {
this.reloadRoutes()
}
}
/**
* Reload routes layer
*/
async reloadRoutes() {
this.controller.showLoading('Reloading routes...')
try {
const pointsLayer = this.layerManager.getLayer('points')
const points = pointsLayer?.data?.features?.map(f => ({
latitude: f.geometry.coordinates[1],
longitude: f.geometry.coordinates[0],
timestamp: f.properties.timestamp
})) || []
const distanceThresholdMeters = this.settings.metersBetweenRoutes || 1000
const timeThresholdMinutes = this.settings.minutesBetweenRoutes || 60
const { calculateSpeed, getSpeedColor } = await import('maps_maplibre/utils/speed_colors')
const routesGeoJSON = await this.generateRoutesWithSpeedColors(
points,
{ distanceThresholdMeters, timeThresholdMinutes },
calculateSpeed,
getSpeedColor
)
this.layerManager.updateLayer('routes', routesGeoJSON)
} catch (error) {
console.error('Failed to reload routes:', error)
Toast.error('Failed to reload routes')
} finally {
this.controller.hideLoading()
}
}
/**
* Generate routes with speed coloring
*/
async generateRoutesWithSpeedColors(points, options, calculateSpeed, getSpeedColor) {
const { RoutesLayer } = await import('maps_maplibre/layers/routes_layer')
const useSpeedColors = this.settings.speedColoredRoutesEnabled || false
const speedColorScale = this.settings.speedColorScale || '0:#00ff00|15:#00ffff|30:#ff00ff|50:#ffff00|100:#ff3300'
const routesGeoJSON = RoutesLayer.pointsToRoutes(points, options)
if (!useSpeedColors) {
return routesGeoJSON
}
routesGeoJSON.features = routesGeoJSON.features.map((feature, index) => {
const segment = points.slice(
points.findIndex(p => p.timestamp === feature.properties.startTime),
points.findIndex(p => p.timestamp === feature.properties.endTime) + 1
)
if (segment.length >= 2) {
const speed = calculateSpeed(segment[0], segment[segment.length - 1])
const color = getSpeedColor(speed, useSpeedColors, speedColorScale)
feature.properties.speed = speed
feature.properties.color = color
}
return feature
})
return routesGeoJSON
}
/**
* Toggle heatmap visibility
*/
toggleHeatmap(event) {
const enabled = event.target.checked
SettingsManager.updateSetting('heatmapEnabled', enabled)
const heatmapLayer = this.layerManager.getLayer('heatmap')
if (heatmapLayer) {
if (enabled) {
heatmapLayer.show()
} else {
heatmapLayer.hide()
}
}
}
/**
* Toggle fog of war layer
*/
toggleFog(event) {
const enabled = event.target.checked
SettingsManager.updateSetting('fogEnabled', enabled)
const fogLayer = this.layerManager.getLayer('fog')
if (fogLayer) {
fogLayer.toggle(enabled)
} else {
console.warn('Fog layer not yet initialized')
}
}
/**
* Toggle scratch map layer
*/
async toggleScratch(event) {
const enabled = event.target.checked
SettingsManager.updateSetting('scratchEnabled', enabled)
try {
const scratchLayer = this.layerManager.getLayer('scratch')
if (!scratchLayer && enabled) {
const ScratchLayer = await lazyLoader.loadLayer('scratch')
const newScratchLayer = new ScratchLayer(this.map, {
visible: true,
apiClient: this.controller.api
})
const pointsLayer = this.layerManager.getLayer('points')
const pointsData = pointsLayer?.data || { type: 'FeatureCollection', features: [] }
await newScratchLayer.add(pointsData)
this.layerManager.layers.scratchLayer = newScratchLayer
} else if (scratchLayer) {
if (enabled) {
scratchLayer.show()
} else {
scratchLayer.hide()
}
}
} catch (error) {
console.error('Failed to toggle scratch layer:', error)
Toast.error('Failed to load scratch layer')
}
}
/**
* Toggle photos layer
*/
togglePhotos(event) {
const enabled = event.target.checked
SettingsManager.updateSetting('photosEnabled', enabled)
const photosLayer = this.layerManager.getLayer('photos')
if (photosLayer) {
if (enabled) {
photosLayer.show()
} else {
photosLayer.hide()
}
}
}
/**
* Toggle areas layer
*/
toggleAreas(event) {
const enabled = event.target.checked
SettingsManager.updateSetting('areasEnabled', enabled)
const areasLayer = this.layerManager.getLayer('areas')
if (areasLayer) {
if (enabled) {
areasLayer.show()
} else {
areasLayer.hide()
}
}
}
/**
* Toggle tracks layer
*/
toggleTracks(event) {
const enabled = event.target.checked
SettingsManager.updateSetting('tracksEnabled', enabled)
const tracksLayer = this.layerManager.getLayer('tracks')
if (tracksLayer) {
if (enabled) {
tracksLayer.show()
} else {
tracksLayer.hide()
}
}
}
/**
* Toggle points layer visibility
*/
togglePoints(event) {
const element = event.currentTarget
const visible = element.checked
const pointsLayer = this.layerManager.getLayer('points')
if (pointsLayer) {
pointsLayer.toggle(visible)
}
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'
}
}
}

View file

@ -0,0 +1,307 @@
import { SettingsManager } from 'maps_maplibre/utils/settings_manager'
import { getMapStyle } from 'maps_maplibre/utils/style_manager'
import { Toast } from 'maps_maplibre/components/toast'
/**
* Handles all settings-related operations for Maps V2
* Including toggles, advanced settings, and UI synchronization
*/
export class SettingsController {
constructor(controller) {
this.controller = controller
this.settings = controller.settings
}
// Lazy getters for properties that may not be initialized yet
get map() {
return this.controller.map
}
get layerManager() {
return this.controller.layerManager
}
/**
* Load settings (sync from backend)
*/
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)
}
return this.settings
}
/**
* Sync UI controls with loaded settings
*/
syncToggleStates() {
const controller = this.controller
// Sync layer toggles
const toggleMap = {
pointsToggle: 'pointsVisible',
routesToggle: 'routesVisible',
heatmapToggle: 'heatmapEnabled',
visitsToggle: 'visitsEnabled',
photosToggle: 'photosEnabled',
areasToggle: 'areasEnabled',
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]) {
controller[target].checked = this.settings[settingKey]
}
})
// Show/hide visits search based on initial toggle state
if (controller.hasVisitsToggleTarget && controller.hasVisitsSearchTarget) {
controller.visitsSearchTarget.style.display = controller.visitsToggleTarget.checked ? 'block' : 'none'
}
// Show/hide places filters based on initial toggle state
if (controller.hasPlacesToggleTarget && controller.hasPlacesFiltersTarget) {
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
}
// Sync map style dropdown
const mapStyleSelect = controller.element.querySelector('select[name="mapStyle"]')
if (mapStyleSelect) {
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) {
fogRadiusInput.value = this.settings.fogOfWarRadius || 1000
if (controller.hasFogRadiusValueTarget) {
controller.fogRadiusValueTarget.textContent = `${fogRadiusInput.value}m`
}
}
const fogThresholdInput = controller.element.querySelector('input[name="fogOfWarThreshold"]')
if (fogThresholdInput) {
fogThresholdInput.value = this.settings.fogOfWarThreshold || 1
if (controller.hasFogThresholdValueTarget) {
controller.fogThresholdValueTarget.textContent = fogThresholdInput.value
}
}
// Sync route generation settings
const metersBetweenInput = controller.element.querySelector('input[name="metersBetweenRoutes"]')
if (metersBetweenInput) {
metersBetweenInput.value = this.settings.metersBetweenRoutes || 500
if (controller.hasMetersBetweenValueTarget) {
controller.metersBetweenValueTarget.textContent = `${metersBetweenInput.value}m`
}
}
const minutesBetweenInput = controller.element.querySelector('input[name="minutesBetweenRoutes"]')
if (minutesBetweenInput) {
minutesBetweenInput.value = this.settings.minutesBetweenRoutes || 60
if (controller.hasMinutesBetweenValueTarget) {
controller.minutesBetweenValueTarget.textContent = `${minutesBetweenInput.value}min`
}
}
// Sync speed-colored routes settings
if (controller.hasSpeedColorScaleInputTarget) {
const colorScale = this.settings.speedColorScale || '0:#00ff00|15:#00ffff|30:#ff00ff|50:#ffff00|100:#ff3300'
controller.speedColorScaleInputTarget.value = colorScale
}
if (controller.hasSpeedColorScaleContainerTarget && controller.hasSpeedColoredToggleTarget) {
const isEnabled = controller.speedColoredToggleTarget.checked
controller.speedColorScaleContainerTarget.classList.toggle('hidden', !isEnabled)
}
// Sync points rendering mode radio buttons
const pointsRenderingRadios = controller.element.querySelectorAll('input[name="pointsRenderingMode"]')
pointsRenderingRadios.forEach(radio => {
radio.checked = radio.value === (this.settings.pointsRenderingMode || 'raw')
})
// Sync speed-colored routes toggle
const speedColoredRoutesToggle = controller.element.querySelector('input[name="speedColoredRoutes"]')
if (speedColoredRoutesToggle) {
speedColoredRoutesToggle.checked = this.settings.speedColoredRoutes || false
}
}
/**
* Update map style from settings
*/
async updateMapStyle(event) {
const styleName = event.target.value
SettingsManager.updateSetting('mapStyle', styleName)
const style = await getMapStyle(styleName)
// Clear layer references
this.layerManager.clearLayerReferences()
this.map.setStyle(style)
// Reload layers after style change
this.map.once('style.load', () => {
this.controller.loadMapData()
})
}
/**
* Reset settings to defaults
*/
resetSettings() {
if (confirm('Reset all settings to defaults? This will reload the page.')) {
SettingsManager.resetToDefaults()
window.location.reload()
}
}
/**
* 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
*/
updateRouteOpacity(event) {
const opacity = parseInt(event.target.value) / 100
const routesLayer = this.layerManager.getLayer('routes')
if (routesLayer && this.map.getLayer('routes')) {
this.map.setPaintProperty('routes', 'line-opacity', opacity)
}
SettingsManager.updateSetting('routeOpacity', opacity)
}
/**
* Update advanced settings from form submission
*/
async updateAdvancedSettings(event) {
event.preventDefault()
const formData = new FormData(event.target)
const settings = {
routeOpacity: parseFloat(formData.get('routeOpacity')) / 100,
fogOfWarRadius: parseInt(formData.get('fogOfWarRadius')),
fogOfWarThreshold: parseInt(formData.get('fogOfWarThreshold')),
metersBetweenRoutes: parseInt(formData.get('metersBetweenRoutes')),
minutesBetweenRoutes: parseInt(formData.get('minutesBetweenRoutes')),
pointsRenderingMode: formData.get('pointsRenderingMode'),
speedColoredRoutes: formData.get('speedColoredRoutes') === 'on'
}
// Apply settings to current map
await this.applySettingsToMap(settings)
// Save to backend
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')
}
/**
* Apply settings to map without reload
*/
async applySettingsToMap(settings) {
// Update route opacity
if (settings.routeOpacity !== undefined) {
const routesLayer = this.layerManager.getLayer('routes')
if (routesLayer && this.map.getLayer('routes')) {
this.map.setPaintProperty('routes', 'line-opacity', settings.routeOpacity)
}
}
// Update fog of war settings
if (settings.fogOfWarRadius !== undefined || settings.fogOfWarThreshold !== undefined) {
const fogLayer = this.layerManager.getLayer('fog')
if (fogLayer) {
if (settings.fogOfWarRadius) {
fogLayer.clearRadius = settings.fogOfWarRadius
}
// Redraw fog layer if it has data and is visible
if (fogLayer.visible && fogLayer.data) {
await fogLayer.update(fogLayer.data)
}
}
}
// For settings that require data reload
if (settings.pointsRenderingMode || settings.speedColoredRoutes !== undefined) {
Toast.info('Reloading map data with new settings...')
await this.controller.loadMapData()
}
}
// Display value update methods
updateFogRadiusDisplay(event) {
if (this.controller.hasFogRadiusValueTarget) {
this.controller.fogRadiusValueTarget.textContent = `${event.target.value}m`
}
}
updateFogThresholdDisplay(event) {
if (this.controller.hasFogThresholdValueTarget) {
this.controller.fogThresholdValueTarget.textContent = event.target.value
}
}
updateMetersBetweenDisplay(event) {
if (this.controller.hasMetersBetweenValueTarget) {
this.controller.metersBetweenValueTarget.textContent = `${event.target.value}m`
}
}
updateMinutesBetweenDisplay(event) {
if (this.controller.hasMinutesBetweenValueTarget) {
this.controller.minutesBetweenValueTarget.textContent = `${event.target.value}min`
}
}
}

View file

@ -0,0 +1,153 @@
import { SettingsManager } from 'maps_maplibre/utils/settings_manager'
import { Toast } from 'maps_maplibre/components/toast'
/**
* Manages visits-related operations for Maps V2
* Including visit creation, filtering, and layer management
*/
export class VisitsManager {
constructor(controller) {
this.controller = controller
this.layerManager = controller.layerManager
this.filterManager = controller.filterManager
this.api = controller.api
this.dataLoader = controller.dataLoader
}
/**
* Toggle visits layer
*/
toggleVisits(event) {
const enabled = event.target.checked
SettingsManager.updateSetting('visitsEnabled', enabled)
const visitsLayer = this.layerManager.getLayer('visits')
if (visitsLayer) {
if (enabled) {
visitsLayer.show()
if (this.controller.hasVisitsSearchTarget) {
this.controller.visitsSearchTarget.style.display = 'block'
}
} else {
visitsLayer.hide()
if (this.controller.hasVisitsSearchTarget) {
this.controller.visitsSearchTarget.style.display = 'none'
}
}
}
}
/**
* Search visits
*/
searchVisits(event) {
const searchTerm = event.target.value.toLowerCase()
const visitsLayer = this.layerManager.getLayer('visits')
this.filterManager.filterAndUpdateVisits(
searchTerm,
this.filterManager.getCurrentVisitFilter(),
visitsLayer
)
}
/**
* Filter visits by status
*/
filterVisits(event) {
const filter = event.target.value
this.filterManager.setCurrentVisitFilter(filter)
const searchTerm = document.getElementById('visits-search')?.value.toLowerCase() || ''
const visitsLayer = this.layerManager.getLayer('visits')
this.filterManager.filterAndUpdateVisits(searchTerm, filter, visitsLayer)
}
/**
* 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()
}
this.controller.map.getCanvas().style.cursor = 'crosshair'
Toast.info('Click on the map to place a visit')
this.handleCreateVisitClick = (e) => {
const { lng, lat } = e.lngLat
this.openVisitCreationModal(lat, lng)
this.controller.map.getCanvas().style.cursor = ''
}
this.controller.map.once('click', this.handleCreateVisitClick)
}
/**
* 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
}
const controller = this.controller.application.getControllerForElementAndIdentifier(
modalElement,
'visit-creation-v2'
)
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')
}
}
/**
* 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,
end_at: this.controller.endDateValue
})
console.log('[Maps V2] Fetched visits:', visits.length)
this.filterManager.setAllVisits(visits)
const visitsGeoJSON = this.dataLoader.visitsToGeoJSON(visits)
console.log('[Maps V2] Converted to GeoJSON:', visitsGeoJSON.features.length, 'features')
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')
}
} catch (error) {
console.error('[Maps V2] Failed to reload visits:', error)
}
}
/**
* Handle visit update event - reload visits and update layer
*/
async handleVisitUpdated(event) {
console.log('[Maps V2] Visit updated, reloading visits...', event.detail)
// Reuse the same logic as creation
await this.handleVisitCreated(event)
}
}

View file

@ -0,0 +1,649 @@
import { Controller } from '@hotwired/stimulus'
import { ApiClient } from 'maps_maplibre/services/api_client'
import { SettingsManager } from 'maps_maplibre/utils/settings_manager'
import { SearchManager } from 'maps_maplibre/utils/search_manager'
import { Toast } from 'maps_maplibre/components/toast'
import { performanceMonitor } from 'maps_maplibre/utils/performance_monitor'
import { CleanupHelper } from 'maps_maplibre/utils/cleanup_helper'
import { MapInitializer } from './maplibre/map_initializer'
import { MapDataManager } from './maplibre/map_data_manager'
import { LayerManager } from './maplibre/layer_manager'
import { DataLoader } from './maplibre/data_loader'
import { EventHandlers } from './maplibre/event_handlers'
import { FilterManager } from './maplibre/filter_manager'
import { DateManager } from './maplibre/date_manager'
import { SettingsController } from './maplibre/settings_manager'
import { AreaSelectionManager } from './maplibre/area_selection_manager'
import { VisitsManager } from './maplibre/visits_manager'
import { PlacesManager } from './maplibre/places_manager'
import { RoutesManager } from './maplibre/routes_manager'
/**
* Main map controller for Maps V2
* Coordinates between different managers and handles UI interactions
*/
export default class extends Controller {
static values = {
apiKey: String,
startDate: String,
endDate: String,
timezone: String
}
static targets = [
'container',
'loading',
'loadingText',
'monthSelect',
'clusterToggle',
'settingsPanel',
'visitsSearch',
'routeOpacityRange',
'placesFilters',
'enableAllPlaceTagsToggle',
'fogRadiusValue',
'fogThresholdValue',
'metersBetweenValue',
'minutesBetweenValue',
// Search
'searchInput',
'searchResults',
// Layer toggles
'pointsToggle',
'routesToggle',
'heatmapToggle',
'visitsToggle',
'photosToggle',
'areasToggle',
'placesToggle',
'fogToggle',
'scratchToggle',
'familyToggle',
// Speed-colored routes
'routesOptions',
'speedColoredToggle',
'speedColorScaleContainer',
'speedColorScaleInput',
// Globe projection
'globeToggle',
// Family members
'familyMembersList',
'familyMembersContainer',
// Area selection
'selectAreaButton',
'selectionActions',
'deleteButtonText',
'selectedVisitsContainer',
'selectedVisitsBulkActions',
// Info display
'infoDisplay',
'infoTitle',
'infoContent',
'infoActions'
]
async connect() {
this.cleanup = new CleanupHelper()
// Initialize API and settings
SettingsManager.initialize(this.apiKeyValue)
this.settingsController = new SettingsController(this)
await this.settingsController.loadSettings()
this.settings = this.settingsController.settings
// Sync toggle states with loaded settings
this.settingsController.syncToggleStates()
await this.initializeMap()
this.initializeAPI()
// Initialize managers
this.layerManager = new LayerManager(this.map, this.settings, this.api)
this.dataLoader = new DataLoader(this.api, this.apiKeyValue, this.settings)
this.eventHandlers = new EventHandlers(this.map, this)
this.filterManager = new FilterManager(this.dataLoader)
this.mapDataManager = new MapDataManager(this)
// Initialize feature managers
this.areaSelectionManager = new AreaSelectionManager(this)
this.visitsManager = new VisitsManager(this)
this.placesManager = new PlacesManager(this)
this.routesManager = new RoutesManager(this)
// Initialize search manager
this.initializeSearch()
// Listen for visit and place creation/update 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()
}
disconnect() {
this.searchManager?.destroy()
this.cleanup.cleanup()
this.map?.remove()
performanceMonitor.logReport()
}
/**
* Initialize MapLibre map
*/
async initializeMap() {
this.map = await MapInitializer.initialize(this.containerTarget, {
mapStyle: this.settings.mapStyle,
globeProjection: this.settings.globeProjection
})
}
/**
* Initialize API client
*/
initializeAPI() {
this.api = new ApiClient(this.apiKeyValue)
}
/**
* Initialize location search
*/
initializeSearch() {
if (!this.hasSearchInputTarget || !this.hasSearchResultsTarget) {
console.warn('[Maps V2] Search targets not found, search functionality disabled')
return
}
this.searchManager = new SearchManager(this.map, this.apiKeyValue)
this.searchManager.initialize(this.searchInputTarget, this.searchResultsTarget)
console.log('[Maps V2] Search manager initialized')
}
/**
* Load map data from API
*/
async loadMapData(options = {}) {
return this.mapDataManager.loadMapData(
this.startDateValue,
this.endDateValue,
{
...options,
onProgress: this.updateLoadingProgress.bind(this)
}
)
}
/**
* Month selector changed
*/
monthChanged(event) {
const { startDate, endDate } = DateManager.parseMonthSelector(event.target.value)
this.startDateValue = startDate
this.endDateValue = endDate
console.log('[Maps V2] Date range changed:', this.startDateValue, 'to', this.endDateValue)
this.loadMapData()
}
/**
* Show loading indicator
*/
showLoading() {
this.loadingTarget.classList.remove('hidden')
}
/**
* Hide loading indicator
*/
hideLoading() {
this.loadingTarget.classList.add('hidden')
}
/**
* Update loading progress
*/
updateLoadingProgress({ loaded, totalPages, progress }) {
if (this.hasLoadingTextTarget) {
const percentage = Math.round(progress * 100)
this.loadingTextTarget.textContent = `Loading... ${percentage}%`
}
}
/**
* Toggle settings panel
*/
toggleSettings() {
if (this.hasSettingsPanelTarget) {
this.settingsPanelTarget.classList.toggle('open')
}
}
// ===== Delegated Methods to Managers =====
// Settings Controller methods
updateMapStyle(event) { return this.settingsController.updateMapStyle(event) }
resetSettings() { return this.settingsController.resetSettings() }
updateRouteOpacity(event) { return this.settingsController.updateRouteOpacity(event) }
updateAdvancedSettings(event) { return this.settingsController.updateAdvancedSettings(event) }
updateFogRadiusDisplay(event) { return this.settingsController.updateFogRadiusDisplay(event) }
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() }
cancelAreaSelection() { return this.areaSelectionManager.cancelAreaSelection() }
deleteSelectedPoints() { return this.areaSelectionManager.deleteSelectedPoints() }
// Visits Manager methods
toggleVisits(event) { return this.visitsManager.toggleVisits(event) }
searchVisits(event) { return this.visitsManager.searchVisits(event) }
filterVisits(event) { return this.visitsManager.filterVisits(event) }
startCreateVisit() { return this.visitsManager.startCreateVisit() }
// Places Manager methods
togglePlaces(event) { return this.placesManager.togglePlaces(event) }
filterPlacesByTags(event) { return this.placesManager.filterPlacesByTags(event) }
toggleAllPlaceTags(event) { return this.placesManager.toggleAllPlaceTags(event) }
startCreatePlace() { return this.placesManager.startCreatePlace() }
// Area creation
startCreateArea() {
console.log('[Maps V2] Starting create area mode')
if (this.hasSettingsPanelTarget && this.settingsPanelTarget.classList.contains('open')) {
this.toggleSettings()
}
// Find area drawer controller on the same element
const drawerController = this.application.getControllerForElementAndIdentifier(
this.element,
'area-drawer'
)
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')
this.layerManager._addAreasLayer(areasGeoJSON)
areasLayer = this.layerManager.getLayer('areas')
console.log('[Maps V2] Areas layer created, visible?', areasLayer?.visible)
}
// 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)
// Update toggle state
if (this.hasAreasToggleTarget) {
this.areasToggleTarget.checked = true
}
} else {
console.log('[Maps V2] Areas layer already visible')
}
}
Toast.success('Area created successfully!')
} catch (error) {
console.error('[Maps V2] Failed to reload areas:', error)
Toast.error('Failed to reload areas')
}
}
// Routes Manager methods
togglePoints(event) { return this.routesManager.togglePoints(event) }
toggleRoutes(event) { return this.routesManager.toggleRoutes(event) }
toggleHeatmap(event) { return this.routesManager.toggleHeatmap(event) }
toggleFog(event) { return this.routesManager.toggleFog(event) }
toggleScratch(event) { return this.routesManager.toggleScratch(event) }
togglePhotos(event) { return this.routesManager.togglePhotos(event) }
toggleAreas(event) { return this.routesManager.toggleAreas(event) }
toggleTracks(event) { return this.routesManager.toggleTracks(event) }
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) {
console.warn('[Maps V2] Family feature not enabled or user not in family')
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 = []) {
if (!this.hasInfoDisplayTarget) return
// Set title
this.infoTitleTarget.textContent = title
// Set content
this.infoContentTarget.innerHTML = content
// 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('')
} else {
this.infoActionsTarget.innerHTML = ''
}
// Show info display
this.infoDisplayTarget.classList.remove('hidden')
// Switch to tools tab and open panel
this.switchToToolsTab()
}
closeInfo() {
if (!this.hasInfoDisplayTarget) return
this.infoDisplayTarget.classList.add('hidden')
}
/**
* Handle edit action from info display
*/
handleEdit(event) {
const button = event.currentTarget
const id = button.dataset.id
const entityType = button.dataset.entityType
console.log('[Maps V2] Opening edit for', entityType, id)
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
console.log('[Maps V2] Deleting', entityType, id)
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) {
console.error('[Maps V2] Failed to load visit:', 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) {
console.error('[Maps V2] Failed to delete area:', 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) {
console.error('[Maps V2] Failed to load place:', error)
Toast.error('Failed to load place details')
}
}
switchToToolsTab() {
// Open the panel if it's not already open
if (!this.settingsPanelTarget.classList.contains('open')) {
this.toggleSettings()
}
// Find the map-panel controller and switch to tools tab
const panelElement = this.settingsPanelTarget
const panelController = this.application.getControllerForElementAndIdentifier(panelElement, 'map-panel')
if (panelController && panelController.switchToTab) {
panelController.switchToTab('tools')
}
}
}

View file

@ -0,0 +1,323 @@
import { Controller } from '@hotwired/stimulus'
import { createMapChannel } from 'maps_maplibre/channels/map_channel'
import { WebSocketManager } from 'maps_maplibre/utils/websocket_manager'
import { Toast } from 'maps_maplibre/components/toast'
/**
* Real-time controller
* Manages ActionCable connection and real-time updates
*/
export default class extends Controller {
static targets = ['liveModeToggle']
static values = {
enabled: { type: Boolean, default: true },
liveMode: { type: Boolean, default: false }
}
connect() {
console.log('[Realtime Controller] Connecting...')
if (!this.enabledValue) {
console.log('[Realtime Controller] Disabled, skipping setup')
return
}
try {
this.connectedChannels = new Set()
this.liveModeEnabled = false // Start with live mode disabled
// Delay channel setup to ensure ActionCable is ready
// This prevents race condition with page initialization
setTimeout(() => {
try {
this.setupChannels()
} catch (error) {
console.error('[Realtime Controller] Failed to setup channels in setTimeout:', error)
this.updateConnectionIndicator(false)
}
}, 1000)
// Initialize toggle state from settings
if (this.hasLiveModeToggleTarget) {
this.liveModeToggleTarget.checked = this.liveModeEnabled
}
} catch (error) {
console.error('[Realtime Controller] Failed to initialize:', error)
// Don't throw - allow page to continue loading
}
}
disconnect() {
this.channels?.unsubscribeAll()
}
/**
* Setup ActionCable channels
* Family channel is always enabled when family feature is on
* Points channel (live mode) is controlled by user toggle
*/
setupChannels() {
try {
console.log('[Realtime Controller] Setting up channels...')
this.channels = createMapChannel({
connected: this.handleConnected.bind(this),
disconnected: this.handleDisconnected.bind(this),
received: this.handleReceived.bind(this),
enableLiveMode: this.liveModeEnabled // Control points channel
})
console.log('[Realtime Controller] Channels setup complete')
} catch (error) {
console.error('[Realtime Controller] Failed to setup channels:', error)
console.error('[Realtime Controller] Error stack:', error.stack)
this.updateConnectionIndicator(false)
// Don't throw - page should continue to work
}
}
/**
* Toggle live mode (new points appearing in real-time)
*/
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()
}
this.setupChannels()
const message = this.liveModeEnabled ? 'Live mode enabled' : 'Live mode disabled'
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
*/
handleConnected(channelName) {
this.connectedChannels.add(channelName)
// Only show toast when at least one channel is connected
if (this.connectedChannels.size === 1) {
Toast.success('Connected to real-time updates')
this.updateConnectionIndicator(true)
}
}
/**
* Handle disconnection
*/
handleDisconnected(channelName) {
this.connectedChannels.delete(channelName)
// Show warning only when all channels are disconnected
if (this.connectedChannels.size === 0) {
Toast.warning('Disconnected from real-time updates')
this.updateConnectionIndicator(false)
}
}
/**
* Handle received data
*/
handleReceived(data) {
switch (data.type) {
case 'new_point':
this.handleNewPoint(data.point)
break
case 'family_location':
this.handleFamilyLocation(data.member)
break
case 'notification':
this.handleNotification(data.notification)
break
}
}
/**
* Get the maps--maplibre controller (on same element)
*/
get mapsV2Controller() {
const element = this.element
const app = this.application
return app.getControllerForElementAndIdentifier(element, 'maps--maplibre')
}
/**
* Handle new point
* Point data is broadcast as: [lat, lon, battery, altitude, timestamp, velocity, id, country_name]
*/
handleNewPoint(pointData) {
const mapsController = this.mapsV2Controller
if (!mapsController) {
console.warn('[Realtime Controller] Maps controller not found')
return
}
console.log('[Realtime Controller] Received point data:', pointData)
// Parse point data from array format
const [lat, lon, battery, altitude, timestamp, velocity, id, countryName] = pointData
// Get points layer from layer manager
const pointsLayer = mapsController.layerManager?.getLayer('points')
if (!pointsLayer) {
console.warn('[Realtime Controller] Points layer not found')
return
}
// Get current data
const currentData = pointsLayer.data || { type: 'FeatureCollection', features: [] }
const features = [...(currentData.features || [])]
// Add new point
features.push({
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [parseFloat(lon), parseFloat(lat)]
},
properties: {
id: parseInt(id),
latitude: parseFloat(lat),
longitude: parseFloat(lon),
battery: parseFloat(battery) || null,
altitude: parseFloat(altitude) || null,
timestamp: timestamp,
velocity: parseFloat(velocity) || null,
country_name: countryName || null
}
})
// Update layer with new data
pointsLayer.update({
type: 'FeatureCollection',
features
})
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))
Toast.info('New location recorded')
}
/**
* Handle family member location update
*/
handleFamilyLocation(member) {
const mapsController = this.mapsV2Controller
if (!mapsController) return
const familyLayer = mapsController.familyLayer
if (familyLayer) {
familyLayer.updateMember(member)
}
}
/**
* Handle notification
*/
handleNotification(notification) {
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
*/
zoomToPoint(longitude, latitude) {
const mapsController = this.mapsV2Controller
if (!mapsController || !mapsController.map) {
console.warn('[Realtime Controller] Map not available for zooming')
return
}
const map = mapsController.map
// Fly to the new point with a smooth animation
map.flyTo({
center: [longitude, latitude],
zoom: Math.max(map.getZoom(), 14), // Zoom to at least level 14, or keep current zoom if higher
duration: 2000, // 2 second animation
essential: true // This animation is considered essential with respect to prefers-reduced-motion
})
console.log('[Realtime Controller] Zoomed to point:', longitude, latitude)
}
/**
* Update connection indicator
*/
updateConnectionIndicator(connected) {
const indicator = document.querySelector('.connection-indicator')
if (indicator) {
// Show the indicator when connection is attempted
indicator.classList.add('active')
indicator.classList.toggle('connected', connected)
indicator.classList.toggle('disconnected', !connected)
}
}
}

View file

@ -2220,6 +2220,7 @@ 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>
@ -2228,7 +2229,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()})
(${new Date(city.timestamp * 1000).toLocaleDateString('en-US', { timeZone: timezone })})
</span>
</li>
`).join('')}

View file

@ -1,8 +1,9 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["modal", "form", "nameInput", "latitudeInput", "longitudeInput",
"nearbyList", "loadingSpinner", "tagCheckboxes", "loadMoreContainer", "loadMoreButton"]
static targets = ["modal", "form", "nameInput", "latitudeInput", "longitudeInput", "noteInput",
"nearbyList", "loadingSpinner", "tagCheckboxes", "loadMoreContainer", "loadMoreButton",
"modalTitle", "submitButton", "placeIdInput"]
static values = {
apiKey: String
}
@ -12,12 +13,17 @@ export default class extends Controller {
this.currentRadius = 0.5 // Start with 500m (0.5km)
this.maxRadius = 1.5 // Max 1500m (1.5km)
this.setupTagListeners()
this.editingPlaceId = null
}
setupEventListeners() {
document.addEventListener('place:create', (e) => {
this.open(e.detail.latitude, e.detail.longitude)
})
document.addEventListener('place:edit', (e) => {
this.openForEdit(e.detail.place)
})
}
setupTagListeners() {
@ -47,22 +53,71 @@ export default class extends Controller {
}
async open(latitude, longitude) {
this.editingPlaceId = null
this.latitudeInputTarget.value = latitude
this.longitudeInputTarget.value = longitude
this.currentRadius = 0.5 // Reset radius when opening modal
// Update modal for creation mode
if (this.hasModalTitleTarget) {
this.modalTitleTarget.textContent = 'Create New Place'
}
if (this.hasSubmitButtonTarget) {
this.submitButtonTarget.textContent = 'Create Place'
}
this.modalTarget.classList.add('modal-open')
this.nameInputTarget.focus()
await this.loadNearbyPlaces(latitude, longitude)
}
async openForEdit(place) {
this.editingPlaceId = place.id
this.currentRadius = 0.5
// Fill in form with place data
this.nameInputTarget.value = place.name
this.latitudeInputTarget.value = place.latitude
this.longitudeInputTarget.value = place.longitude
if (this.hasNoteInputTarget && place.note) {
this.noteInputTarget.value = place.note
}
// Update modal for edit mode
if (this.hasModalTitleTarget) {
this.modalTitleTarget.textContent = 'Edit Place'
}
if (this.hasSubmitButtonTarget) {
this.submitButtonTarget.textContent = 'Update Place'
}
// Check the appropriate tag checkboxes
const tagCheckboxes = this.formTarget.querySelectorAll('input[name="tag_ids[]"]')
tagCheckboxes.forEach(checkbox => {
const isSelected = place.tags.some(tag => tag.id === parseInt(checkbox.value))
checkbox.checked = isSelected
// Trigger change event to update badge styling
const event = new Event('change', { bubbles: true })
checkbox.dispatchEvent(event)
})
this.modalTarget.classList.add('modal-open')
this.nameInputTarget.focus()
// Load nearby places for suggestions
await this.loadNearbyPlaces(place.latitude, place.longitude)
}
close() {
this.modalTarget.classList.remove('modal-open')
this.formTarget.reset()
this.nearbyListTarget.innerHTML = ''
this.loadMoreContainerTarget.classList.add('hidden')
this.currentRadius = 0.5
this.editingPlaceId = null
const event = new CustomEvent('place:create:cancelled')
document.dispatchEvent(event)
@ -180,14 +235,19 @@ export default class extends Controller {
name: formData.get('name'),
latitude: parseFloat(formData.get('latitude')),
longitude: parseFloat(formData.get('longitude')),
note: formData.get('note') || null,
source: 'manual',
tag_ids: tagIds
}
}
try {
const response = await fetch('/api/v1/places', {
method: 'POST',
const isEdit = this.editingPlaceId !== null
const url = isEdit ? `/api/v1/places/${this.editingPlaceId}` : '/api/v1/places'
const method = isEdit ? 'PATCH' : 'POST'
const response = await fetch(url, {
method: method,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKeyValue}`
@ -197,18 +257,19 @@ export default class extends Controller {
if (!response.ok) {
const error = await response.json()
throw new Error(error.errors?.join(', ') || 'Failed to create place')
throw new Error(error.errors?.join(', ') || `Failed to ${isEdit ? 'update' : 'create'} place`)
}
const place = await response.json()
this.close()
this.showNotification('Place created successfully!', 'success')
const event = new CustomEvent('place:created', { detail: { place } })
document.dispatchEvent(event)
this.showNotification(`Place ${isEdit ? 'updated' : 'created'} successfully!`, 'success')
const eventName = isEdit ? 'place:updated' : 'place:created'
const customEvent = new CustomEvent(eventName, { detail: { place } })
document.dispatchEvent(customEvent)
} catch (error) {
console.error('Error creating place:', error)
console.error(`Error ${this.editingPlaceId ? 'updating' : 'creating'} place:`, error)
this.showNotification(error.message, 'error')
}
}

View file

@ -10,7 +10,8 @@ export default class extends BaseController {
uuid: String,
dataBounds: Object,
hexagonsAvailable: Boolean,
selfHosted: String
selfHosted: String,
timezone: String
};
connect() {
@ -72,9 +73,7 @@ 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
@ -94,9 +93,6 @@ 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
@ -138,7 +134,6 @@ 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
@ -187,7 +182,6 @@ 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) {
@ -210,7 +204,6 @@ 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');
}
}
}
@ -255,10 +248,11 @@ export default class extends BaseController {
}
buildPopupContent(props) {
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() : '';
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 }) : '';
return `
<div style="font-size: 12px; line-height: 1.6; max-width: 300px;">

View file

@ -0,0 +1,184 @@
import { Controller } from '@hotwired/stimulus'
/**
* Speed Color Editor Controller
* Manages the gradient editor modal for speed-colored routes
*/
export default class extends Controller {
static targets = ['modal', 'stopsList', 'preview']
static values = {
colorStops: String
}
connect() {
this.loadColorStops()
}
loadColorStops() {
const stopsString = this.colorStopsValue || '0:#00ff00|15:#00ffff|30:#ff00ff|50:#ffff00|100:#ff3300'
this.stops = this.parseColorStops(stopsString)
this.renderStops()
this.updatePreview()
}
parseColorStops(stopsString) {
return stopsString.split('|').map(segment => {
const [speed, color] = segment.split(':')
return { speed: Number(speed), color }
})
}
serializeColorStops() {
return this.stops.map(stop => `${stop.speed}:${stop.color}`).join('|')
}
renderStops() {
if (!this.hasStopsListTarget) return
this.stopsListTarget.innerHTML = this.stops.map((stop, index) => `
<div class="flex items-center gap-3 p-3 bg-base-200 rounded-lg" data-index="${index}">
<div class="flex-1">
<label class="label">
<span class="label-text text-sm">Speed (km/h)</span>
</label>
<input type="number"
class="input input-bordered input-sm w-full"
value="${stop.speed}"
min="0"
max="200"
data-action="input->speed-color-editor#updateSpeed"
data-index="${index}" />
</div>
<div class="flex-1">
<label class="label">
<span class="label-text text-sm">Color</span>
</label>
<div class="flex gap-2 items-center">
<input type="color"
class="w-12 h-10 rounded cursor-pointer border-2 border-base-300"
value="${stop.color}"
data-action="input->speed-color-editor#updateColor"
data-index="${index}" />
<input type="text"
class="input input-bordered input-sm w-24 font-mono text-xs"
value="${stop.color}"
pattern="^#[0-9A-Fa-f]{6}$"
data-action="input->speed-color-editor#updateColorText"
data-index="${index}" />
</div>
</div>
<button type="button"
class="btn btn-sm btn-ghost btn-circle text-error mt-6"
data-action="click->speed-color-editor#removeStop"
data-index="${index}"
${this.stops.length <= 2 ? 'disabled' : ''}>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
`).join('')
}
updateSpeed(event) {
const index = parseInt(event.target.dataset.index)
this.stops[index].speed = Number(event.target.value)
this.updatePreview()
}
updateColor(event) {
const index = parseInt(event.target.dataset.index)
const color = event.target.value
this.stops[index].color = color
// Update text input
const textInput = event.target.parentElement.querySelector('input[type="text"]')
if (textInput) {
textInput.value = color
}
this.updatePreview()
}
updateColorText(event) {
const index = parseInt(event.target.dataset.index)
const color = event.target.value
if (/^#[0-9A-Fa-f]{6}$/.test(color)) {
this.stops[index].color = color
// Update color picker
const colorInput = event.target.parentElement.querySelector('input[type="color"]')
if (colorInput) {
colorInput.value = color
}
this.updatePreview()
}
}
addStop() {
// Find a good speed value between existing stops
const lastStop = this.stops[this.stops.length - 1]
const newSpeed = lastStop.speed + 10
this.stops.push({
speed: newSpeed,
color: '#ff0000'
})
// Sort by speed
this.stops.sort((a, b) => a.speed - b.speed)
this.renderStops()
this.updatePreview()
}
removeStop(event) {
const index = parseInt(event.target.dataset.index)
if (this.stops.length > 2) {
this.stops.splice(index, 1)
this.renderStops()
this.updatePreview()
}
}
updatePreview() {
if (!this.hasPreviewTarget) return
const gradient = this.stops.map((stop, index) => {
const percentage = (index / (this.stops.length - 1)) * 100
return `${stop.color} ${percentage}%`
}).join(', ')
this.previewTarget.style.background = `linear-gradient(to right, ${gradient})`
}
save() {
const serialized = this.serializeColorStops()
// Dispatch event with the new color stops
this.dispatch('save', {
detail: { colorStops: serialized }
})
this.close()
}
close() {
if (this.hasModalTarget) {
const checkbox = this.modalTarget.querySelector('.modal-toggle')
if (checkbox) {
checkbox.checked = false
}
}
}
resetToDefault() {
this.colorStopsValue = '0:#00ff00|15:#00ffff|30:#ff00ff|50:#ffff00|100:#ff3300'
this.loadColorStops()
}
}

View file

@ -0,0 +1,255 @@
import { Controller } from '@hotwired/stimulus'
import { Toast } from 'maps_maplibre/components/toast'
/**
* Controller for visit creation modal in Maps V2
*/
export default class extends Controller {
static targets = [
'modal',
'form',
'modalTitle',
'nameInput',
'startTimeInput',
'endTimeInput',
'latitudeInput',
'longitudeInput',
'submitButton'
]
static values = {
apiKey: String
}
connect() {
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() {
this.cleanup()
}
/**
* Open the modal with coordinates
*/
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))
this.startTimeInputTarget.value = this.formatDateTime(now)
this.endTimeInputTarget.value = this.formatDateTime(oneHourLater)
// Show modal
this.modalTarget.classList.add('modal-open')
// Focus on name input
setTimeout(() => this.nameInputTarget.focus(), 100)
// Add marker to map
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
*/
close() {
console.log('[Visit Creation V2] Closing modal')
// Hide modal
this.modalTarget.classList.remove('modal-open')
// Reset form
this.formTarget.reset()
// Reset editing state
this.editingVisitId = null
// Remove marker
this.removeMarker()
}
/**
* Handle form submission
*/
async submit(event) {
event.preventDefault()
const isEdit = this.editingVisitId !== null
console.log(`[Visit Creation V2] Submitting form (${isEdit ? 'edit' : 'create'})`)
const formData = new FormData(this.formTarget)
const visitData = {
visit: {
name: formData.get('name'),
started_at: formData.get('started_at'),
ended_at: formData.get('ended_at'),
latitude: parseFloat(formData.get('latitude')),
longitude: parseFloat(formData.get('longitude')),
status: 'confirmed'
}
}
try {
const url = isEdit ? `/api/v1/visits/${this.editingVisitId}` : '/api/v1/visits'
const method = isEdit ? 'PATCH' : 'POST'
const response = await fetch(url, {
method: method,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKeyValue}`,
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || ''
},
body: JSON.stringify(visitData)
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || `Failed to ${isEdit ? 'update' : 'create'} visit`)
}
const visit = await response.json()
console.log(`[Visit Creation V2] Visit ${isEdit ? 'updated' : 'created'} successfully`, visit)
// Show success message
this.showToast(`Visit ${isEdit ? 'updated' : '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 }
}))
} catch (error) {
console.error(`[Visit Creation V2] Error ${isEdit ? 'updating' : 'creating'} visit:`, error)
this.showToast(error.message || `Failed to ${isEdit ? 'update' : 'create'} visit`, 'error')
}
}
/**
* Add marker to map
*/
addMarker(lat, lng) {
if (!this.mapController) return
// Remove existing marker if any
this.removeMarker()
// Create marker element
const el = document.createElement('div')
el.className = 'visit-creation-marker'
el.innerHTML = '📍'
el.style.fontSize = '30px'
// Use maplibregl if available (from mapController)
const maplibregl = window.maplibregl
if (maplibregl) {
this.marker = new maplibregl.Marker({ element: el })
.setLngLat([lng, lat])
.addTo(this.mapController.map)
}
}
/**
* Remove marker from map
*/
removeMarker() {
if (this.marker) {
this.marker.remove()
this.marker = null
}
}
/**
* Clean up resources
*/
cleanup() {
this.removeMarker()
}
/**
* Format date for datetime-local input
*/
formatDateTime(date) {
return date.toISOString().slice(0, 16)
}
/**
* Show toast notification
*/
showToast(message, type = 'info') {
Toast[type](message)
}
}

View file

@ -38,19 +38,89 @@ export class PlacesManager {
// Show success message
showFlashMessage('success', `Place "${place.name}" created successfully!`);
// Add the new place to the main places layer
await this.refreshPlaces();
// Add the place to our local array
this.places.push(place);
// Refresh all filtered layers that are currently on the map
this.map.eachLayer((layer) => {
if (layer._tagIds !== undefined) {
// This is a filtered layer, reload it
this.loadPlacesIntoLayer(layer, layer._tagIds);
}
});
// Create marker for the new place and add to main layer
const marker = this.createPlaceMarker(place);
if (marker) {
this.markers[place.id] = marker;
marker.addTo(this.placesLayer);
}
// Ensure the main Places layer is visible
this.ensurePlacesLayerVisible();
// Also add to any filtered layers that match this place's tags
this.map.eachLayer((layer) => {
if (layer._tagIds !== undefined) {
// Check if this place's tags match this filtered layer
const placeTagIds = place.tags.map(tag => tag.id);
const layerTagIds = layer._tagIds;
// If it's an untagged layer (empty array) and place has no tags
if (layerTagIds.length === 0 && placeTagIds.length === 0) {
const marker = this.createPlaceMarker(place);
if (marker) layer.addLayer(marker);
}
// If place has any tags that match this layer's tags
else if (placeTagIds.some(tagId => layerTagIds.includes(tagId))) {
const marker = this.createPlaceMarker(place);
if (marker) layer.addLayer(marker);
}
}
});
});
// Refresh places when a place is updated
document.addEventListener('place:updated', async (event) => {
const { place } = event.detail;
// Show success message
showFlashMessage('success', `Place "${place.name}" updated successfully!`);
// Update the place in our local array
const index = this.places.findIndex(p => p.id === place.id);
if (index !== -1) {
this.places[index] = place;
}
// Remove old marker and add updated one to main layer
if (this.markers[place.id]) {
this.placesLayer.removeLayer(this.markers[place.id]);
}
const marker = this.createPlaceMarker(place);
if (marker) {
this.markers[place.id] = marker;
marker.addTo(this.placesLayer);
}
// Update in all filtered layers
this.map.eachLayer((layer) => {
if (layer._tagIds !== undefined) {
// Remove old marker from this layer
layer.eachLayer((layerMarker) => {
if (layerMarker.options && layerMarker.options.placeId === place.id) {
layer.removeLayer(layerMarker);
}
});
// Check if updated place should be in this layer
const placeTagIds = place.tags.map(tag => tag.id);
const layerTagIds = layer._tagIds;
// If it's an untagged layer (empty array) and place has no tags
if (layerTagIds.length === 0 && placeTagIds.length === 0) {
const marker = this.createPlaceMarker(place);
if (marker) layer.addLayer(marker);
}
// If place has any tags that match this layer's tags
else if (placeTagIds.some(tagId => layerTagIds.includes(tagId))) {
const marker = this.createPlaceMarker(place);
if (marker) layer.addLayer(marker);
}
}
});
});
}
@ -107,8 +177,10 @@ export class PlacesManager {
}
createPlaceIcon(place) {
const emoji = place.icon || place.tags[0]?.icon || '📍';
const color = place.color || place.tags[0]?.color || '#4CAF50';
const rawEmoji = place.icon || place.tags[0]?.icon || '📍';
const emoji = this.escapeHtml(rawEmoji);
const rawColor = place.color || place.tags[0]?.color || '#4CAF50';
const color = this.sanitizeColor(rawColor);
const iconHtml = `
<div class="place-marker" style="
@ -137,19 +209,28 @@ export class PlacesManager {
}
createPopupContent(place) {
const tags = place.tags.map(tag =>
`<span class="badge badge-sm" style="background-color: ${tag.color}">
${tag.icon} #${tag.name}
</span>`
).join(' ');
const tags = place.tags.map(tag => {
const safeIcon = this.escapeHtml(tag.icon || '');
const safeName = this.escapeHtml(tag.name || '');
const safeColor = this.sanitizeColor(tag.color);
return `<span class="badge badge-sm" style="background-color: ${safeColor}">
${safeIcon} #${safeName}
</span>`;
}).join(' ');
const safeName = this.escapeHtml(place.name || '');
const safeVisitsCount = place.visits_count ? parseInt(place.visits_count, 10) : 0;
return `
<div class="place-popup" style="min-width: 200px;">
<h3 class="font-bold text-lg mb-2">${place.name}</h3>
<h3 class="font-bold text-lg mb-2">${safeName}</h3>
${tags ? `<div class="mb-2">${tags}</div>` : ''}
${place.note ? `<p class="text-sm text-gray-600 mb-2 italic">${this.escapeHtml(place.note)}</p>` : ''}
${place.visits_count ? `<p class="text-sm">Visits: ${place.visits_count}</p>` : ''}
${safeVisitsCount > 0 ? `<p class="text-sm">Visits: ${safeVisitsCount}</p>` : ''}
<div class="mt-2 flex gap-2">
<button class="btn btn-xs btn-primary" data-place-id="${place.id}" data-action="edit-place">
Edit
</button>
<button class="btn btn-xs btn-error" data-place-id="${place.id}" data-action="delete-place">
Delete
</button>
@ -164,6 +245,20 @@ export class PlacesManager {
return div.innerHTML;
}
sanitizeColor(color) {
// Validate hex color format (#RGB or #RRGGBB)
if (!color || typeof color !== 'string') {
return '#4CAF50'; // Default green
}
const hexColorRegex = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
if (hexColorRegex.test(color)) {
return color;
}
return '#4CAF50'; // Default green for invalid colors
}
setupMapClickHandler() {
this.map.on('click', (e) => {
if (this.creationMode) {
@ -171,10 +266,21 @@ export class PlacesManager {
}
});
// Delegate event handling for delete buttons
// Delegate event handling for edit and delete buttons
this.map.on('popupopen', (e) => {
const popup = e.popup;
const deleteBtn = popup.getElement()?.querySelector('[data-action="delete-place"]');
const popupElement = popup.getElement();
const editBtn = popupElement?.querySelector('[data-action="edit-place"]');
const deleteBtn = popupElement?.querySelector('[data-action="delete-place"]');
if (editBtn) {
editBtn.addEventListener('click', () => {
const placeId = editBtn.dataset.placeId;
this.editPlace(placeId);
popup.remove();
});
}
if (deleteBtn) {
deleteBtn.addEventListener('click', async () => {
@ -211,6 +317,20 @@ export class PlacesManager {
document.dispatchEvent(event);
}
editPlace(placeId) {
const place = this.places.find(p => p.id === parseInt(placeId));
if (!place) {
console.error('Place not found:', placeId);
return;
}
const event = new CustomEvent('place:edit', {
detail: { place },
bubbles: true
});
document.dispatchEvent(event);
}
async deletePlace(placeId) {
if (!confirm('Are you sure you want to delete this place?')) return;
@ -332,38 +452,37 @@ export class PlacesManager {
return;
}
// Try to find and enable the Places checkbox in the tree control
// Directly add the layer to the map first for immediate visibility
this.map.addLayer(this.placesLayer);
// Then try to sync the checkbox in the layer control if it exists
const layerControl = document.querySelector('.leaflet-control-layers');
if (!layerControl) {
this.map.addLayer(this.placesLayer);
return;
}
// Find the Places checkbox and enable it
setTimeout(() => {
const inputs = layerControl.querySelectorAll('input[type="checkbox"]');
inputs.forEach(input => {
const label = input.closest('label') || input.nextElementSibling;
if (label && label.textContent.trim() === 'Places') {
if (!input.checked) {
// Set a flag to prevent saving during programmatic layer addition
if (window.mapsController) {
window.mapsController.isRestoringLayers = true;
}
input.checked = true;
input.dispatchEvent(new Event('change', { bubbles: true }));
// Reset the flag after a short delay to allow the event to process
setTimeout(() => {
if (layerControl) {
setTimeout(() => {
const inputs = layerControl.querySelectorAll('input[type="checkbox"]');
inputs.forEach(input => {
const label = input.closest('label') || input.nextElementSibling;
if (label && label.textContent.trim() === 'Places') {
if (!input.checked) {
// Set a flag to prevent saving during programmatic layer addition
if (window.mapsController) {
window.mapsController.isRestoringLayers = false;
window.mapsController.isRestoringLayers = true;
}
}, 50);
input.checked = true;
// Don't dispatch change event since we already added the layer
// Reset the flag after a short delay
setTimeout(() => {
if (window.mapsController) {
window.mapsController.isRestoringLayers = false;
}
}, 50);
}
}
}
});
}, 100);
});
}, 100);
}
}
show() {

View file

@ -100,7 +100,10 @@ export function createPlacesControl(placesManager, tags, userTheme = 'dark') {
FILTER BY TAG
</div>
<div style="max-height: 250px; overflow-y: auto; margin-right: -5px; padding-right: 5px;">
${this.tags.map(tag => `
${this.tags.map(tag => {
const safeIcon = tag.icon ? this.escapeHtml(tag.icon) : '📍';
const safeColor = this.sanitizeColor(tag.color);
return `
<label style="display: flex; align-items: center; padding: 6px; cursor: pointer; border-radius: 4px; margin-bottom: 2px;"
class="places-control-item"
onmouseover="this.style.backgroundColor='rgba(128,128,128,0.2)'"
@ -110,11 +113,12 @@ export function createPlacesControl(placesManager, tags, userTheme = 'dark') {
data-tag-id="${tag.id}"
style="margin-right: 8px; cursor: pointer;"
${this.activeFilters.has(tag.id) ? 'checked' : ''}>
<span style="font-size: 18px; margin-right: 6px;">${tag.icon || '📍'}</span>
<span style="font-size: 18px; margin-right: 6px;">${safeIcon}</span>
<span style="flex: 1;">#${this.escapeHtml(tag.name)}</span>
${tag.color ? `<span style="width: 12px; height: 12px; border-radius: 50%; background-color: ${tag.color}; margin-left: 4px;"></span>` : ''}
${tag.color ? `<span style="width: 12px; height: 12px; border-radius: 50%; background-color: ${safeColor}; margin-left: 4px;"></span>` : ''}
</label>
`).join('')}
`;
}).join('')}
</div>
</div>
` : '<div style="font-size: 12px; opacity: 0.6; padding: 8px; text-align: center;">No tags created yet</div>'}
@ -209,6 +213,20 @@ export function createPlacesControl(placesManager, tags, userTheme = 'dark') {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
},
sanitizeColor: function(color) {
// Validate hex color format (#RGB or #RRGGBB)
if (!color || typeof color !== 'string') {
return '#4CAF50'; // Default green
}
const hexColorRegex = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
if (hexColorRegex.test(color)) {
return color;
}
return '#4CAF50'; // Default green for invalid colors
}
});
}

View file

@ -1,32 +1,36 @@
/**
* Vector maps configuration for Maps V1 (legacy)
* For Maps V2, use style_manager.js instead
*/
export const mapsConfig = {
"Light": {
url: "https://tyles.dwri.xyz/planet/{z}/{x}/{y}.mvt",
flavor: "light",
maxZoom: 16,
maxZoom: 14,
attribution: "<a href='https://github.com/protomaps/basemaps'>Protomaps</a>, &copy; <a href='https://openstreetmap.org'>OpenStreetMap</a>"
},
"Dark": {
url: "https://tyles.dwri.xyz/planet/{z}/{x}/{y}.mvt",
flavor: "dark",
maxZoom: 16,
maxZoom: 14,
attribution: "<a href='https://github.com/protomaps/basemaps'>Protomaps</a>, &copy; <a href='https://openstreetmap.org'>OpenStreetMap</a>"
},
"White": {
url: "https://tyles.dwri.xyz/planet/{z}/{x}/{y}.mvt",
flavor: "white",
maxZoom: 16,
maxZoom: 14,
attribution: "<a href='https://github.com/protomaps/basemaps'>Protomaps</a>, &copy; <a href='https://openstreetmap.org'>OpenStreetMap</a>"
},
"Grayscale": {
url: "https://tyles.dwri.xyz/planet/{z}/{x}/{y}.mvt",
flavor: "grayscale",
maxZoom: 16,
maxZoom: 14,
attribution: "<a href='https://github.com/protomaps/basemaps'>Protomaps</a>, &copy; <a href='https://openstreetmap.org'>OpenStreetMap</a>"
},
"Black": {
url: "https://tyles.dwri.xyz/planet/{z}/{x}/{y}.mvt",
flavor: "black",
maxZoom: 16,
maxZoom: 14,
attribution: "<a href='https://github.com/protomaps/basemaps'>Protomaps</a>, &copy; <a href='https://openstreetmap.org'>OpenStreetMap</a>"
},
};

View file

@ -0,0 +1,118 @@
import consumer from '../../channels/consumer'
/**
* Create map channel subscription for maps_maplibre
* Wraps the existing FamilyLocationsChannel and other channels for real-time updates
* @param {Object} options - { received, connected, disconnected, enableLiveMode }
* @returns {Object} Subscriptions object with multiple channels
*/
export function createMapChannel(options = {}) {
const { enableLiveMode = false, ...callbacks } = options
const subscriptions = {
family: null,
points: null,
notifications: null
}
console.log('[MapChannel] Creating channels with enableLiveMode:', enableLiveMode)
// Defensive check - consumer might not be available
if (!consumer) {
console.warn('[MapChannel] ActionCable consumer not available')
return {
subscriptions,
unsubscribeAll() {}
}
}
// Subscribe to family locations if family feature is enabled
try {
const familyFeaturesElement = document.querySelector('[data-family-members-features-value]')
const features = familyFeaturesElement ? JSON.parse(familyFeaturesElement.dataset.familyMembersFeaturesValue) : {}
if (features.family) {
subscriptions.family = consumer.subscriptions.create('FamilyLocationsChannel', {
connected() {
console.log('FamilyLocationsChannel connected')
callbacks.connected?.('family')
},
disconnected() {
console.log('FamilyLocationsChannel disconnected')
callbacks.disconnected?.('family')
},
received(data) {
console.log('FamilyLocationsChannel received:', data)
callbacks.received?.({
type: 'family_location',
member: data
})
}
})
}
} catch (error) {
console.warn('[MapChannel] Failed to subscribe to family channel:', error)
}
// Subscribe to points channel for real-time point updates (only if live mode is enabled)
if (enableLiveMode) {
try {
subscriptions.points = consumer.subscriptions.create('PointsChannel', {
connected() {
console.log('PointsChannel connected')
callbacks.connected?.('points')
},
disconnected() {
console.log('PointsChannel disconnected')
callbacks.disconnected?.('points')
},
received(data) {
console.log('PointsChannel received:', data)
callbacks.received?.({
type: 'new_point',
point: data
})
}
})
} catch (error) {
console.warn('[MapChannel] Failed to subscribe to points channel:', error)
}
} else {
console.log('[MapChannel] Live mode disabled, not subscribing to PointsChannel')
}
// Subscribe to notifications channel
try {
subscriptions.notifications = consumer.subscriptions.create('NotificationsChannel', {
connected() {
console.log('NotificationsChannel connected')
callbacks.connected?.('notifications')
},
disconnected() {
console.log('NotificationsChannel disconnected')
callbacks.disconnected?.('notifications')
},
received(data) {
console.log('NotificationsChannel received:', data)
callbacks.received?.({
type: 'notification',
notification: data
})
}
})
} catch (error) {
console.warn('[MapChannel] Failed to subscribe to notifications channel:', error)
}
return {
subscriptions,
unsubscribeAll() {
Object.values(subscriptions).forEach(sub => sub?.unsubscribe())
}
}
}

View file

@ -0,0 +1,100 @@
/**
* Factory for creating photo popups
*/
export class PhotoPopupFactory {
/**
* Create popup for a photo
* @param {Object} properties - Photo properties
* @returns {string} HTML for popup
*/
static createPhotoPopup(properties) {
const {
id,
thumbnail_url,
taken_at,
filename,
city,
state,
country,
type,
source
} = properties
const takenDate = taken_at ? new Date(taken_at).toLocaleString() : 'Unknown'
const location = [city, state, country].filter(Boolean).join(', ') || 'Unknown location'
const mediaType = type === 'VIDEO' ? '🎥 Video' : '📷 Photo'
return `
<div class="photo-popup">
<div class="photo-preview">
<img src="${thumbnail_url}"
alt="${filename}"
loading="lazy">
</div>
<div class="photo-info">
<div class="filename">${filename}</div>
<div class="timestamp">Taken: ${takenDate}</div>
<div class="location">Location: ${location}</div>
<div class="source">Source: ${source}</div>
<div class="media-type">${mediaType}</div>
</div>
</div>
<style>
.photo-popup {
font-family: system-ui, -apple-system, sans-serif;
max-width: 300px;
}
.photo-preview {
width: 100%;
border-radius: 8px;
overflow: hidden;
margin-bottom: 12px;
background: #f3f4f6;
}
.photo-preview img {
width: 100%;
height: auto;
max-height: 300px;
object-fit: cover;
display: block;
}
.photo-info {
font-size: 13px;
}
.photo-info > div {
margin-bottom: 6px;
}
.photo-info .filename {
font-weight: 600;
color: #111827;
}
.photo-info .timestamp {
color: #6b7280;
font-size: 12px;
}
.photo-info .location {
color: #6b7280;
font-size: 12px;
}
.photo-info .source {
color: #9ca3af;
font-size: 11px;
}
.photo-info .media-type {
font-size: 14px;
margin-top: 8px;
}
</style>
`
}
}

View file

@ -0,0 +1,114 @@
import { formatTimestamp } from '../utils/geojson_transformers'
import { getCurrentTheme, getThemeColors } from '../utils/popup_theme'
/**
* Factory for creating map popups
*/
export class PopupFactory {
/**
* Create popup for a point
* @param {Object} properties - Point properties
* @returns {string} HTML for popup
*/
static createPointPopup(properties) {
const { id, timestamp, altitude, battery, accuracy, velocity } = properties
// Get theme colors
const theme = getCurrentTheme()
const colors = getThemeColors(theme)
return `
<div class="point-popup" style="color: ${colors.textPrimary};">
<div class="popup-header" style="border-bottom: 1px solid ${colors.border};">
<strong>Point #${id}</strong>
</div>
<div class="popup-body">
<div class="popup-row">
<span class="label" style="color: ${colors.textMuted};">Time:</span>
<span class="value" style="color: ${colors.textPrimary};">${formatTimestamp(timestamp)}</span>
</div>
${altitude ? `
<div class="popup-row">
<span class="label" style="color: ${colors.textMuted};">Altitude:</span>
<span class="value" style="color: ${colors.textPrimary};">${Math.round(altitude)}m</span>
</div>
` : ''}
${battery ? `
<div class="popup-row">
<span class="label" style="color: ${colors.textMuted};">Battery:</span>
<span class="value" style="color: ${colors.textPrimary};">${battery}%</span>
</div>
` : ''}
${accuracy ? `
<div class="popup-row">
<span class="label" style="color: ${colors.textMuted};">Accuracy:</span>
<span class="value" style="color: ${colors.textPrimary};">${Math.round(accuracy)}m</span>
</div>
` : ''}
${velocity ? `
<div class="popup-row">
<span class="label" style="color: ${colors.textMuted};">Speed:</span>
<span class="value" style="color: ${colors.textPrimary};">${Math.round(velocity * 3.6)} km/h</span>
</div>
` : ''}
</div>
</div>
`
}
/**
* Create popup for a place
* @param {Object} properties - Place properties
* @returns {string} HTML for popup
*/
static createPlacePopup(properties) {
const { id, name, latitude, longitude, note, tags } = properties
// Get theme colors
const theme = getCurrentTheme()
const colors = getThemeColors(theme)
// Parse tags if they're stringified
let parsedTags = tags
if (typeof tags === 'string') {
try {
parsedTags = JSON.parse(tags)
} catch (e) {
parsedTags = []
}
}
// Format tags as badges
const tagsHtml = parsedTags && Array.isArray(parsedTags) && parsedTags.length > 0
? parsedTags.map(tag => `
<span class="badge badge-sm" style="background-color: ${tag.color}; color: white;">
${tag.icon} #${tag.name}
</span>
`).join(' ')
: `<span class="badge badge-sm badge-outline" style="border-color: ${colors.border}; color: ${colors.textMuted};">Untagged</span>`
return `
<div class="place-popup" style="color: ${colors.textPrimary};">
<div class="popup-header" style="border-bottom: 1px solid ${colors.border};">
<strong>${name || `Place #${id}`}</strong>
</div>
<div class="popup-body">
${note ? `
<div class="popup-row">
<span class="label" style="color: ${colors.textMuted};">Note:</span>
<span class="value" style="color: ${colors.textPrimary};">${note}</span>
</div>
` : ''}
<div class="popup-row">
<span class="label" style="color: ${colors.textMuted};">Tags:</span>
<div class="value">${tagsHtml}</div>
</div>
<div class="popup-row">
<span class="label" style="color: ${colors.textMuted};">Coordinates:</span>
<span class="value" style="color: ${colors.textPrimary};">${latitude.toFixed(5)}, ${longitude.toFixed(5)}</span>
</div>
</div>
</div>
`
}
}

View file

@ -0,0 +1,183 @@
/**
* Toast notification system
* Displays temporary notifications in the top-right corner
*/
export class Toast {
static container = null
/**
* Initialize toast container
*/
static init() {
if (this.container) return
this.container = document.createElement('div')
this.container.className = 'toast-container'
this.container.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 12px;
pointer-events: none;
`
document.body.appendChild(this.container)
// Add CSS animations
this.addStyles()
}
/**
* Add CSS animations for toasts
*/
static addStyles() {
if (document.getElementById('toast-styles')) return
const style = document.createElement('style')
style.id = 'toast-styles'
style.textContent = `
@keyframes toast-slide-in {
from {
transform: translateX(400px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes toast-slide-out {
from {
transform: translateX(0);
opacity: 1;
}
to {
transform: translateX(400px);
opacity: 0;
}
}
.toast {
pointer-events: auto;
animation: toast-slide-in 0.3s ease-out;
}
.toast.removing {
animation: toast-slide-out 0.3s ease-out;
}
`
document.head.appendChild(style)
}
/**
* Show toast notification
* @param {string} message - Message to display
* @param {string} type - Toast type: 'success', 'error', 'info', 'warning'
* @param {number} duration - Duration in milliseconds (default 3000)
*/
static show(message, type = 'info', duration = 3000) {
this.init()
const toast = document.createElement('div')
toast.className = `toast toast-${type}`
toast.textContent = message
toast.style.cssText = `
padding: 12px 20px;
background: ${this.getBackgroundColor(type)};
color: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
font-size: 14px;
font-weight: 500;
max-width: 300px;
line-height: 1.4;
`
this.container.appendChild(toast)
// Auto dismiss after duration
if (duration > 0) {
setTimeout(() => {
this.dismiss(toast)
}, duration)
}
return toast
}
/**
* Dismiss a toast
* @param {HTMLElement} toast - Toast element to dismiss
*/
static dismiss(toast) {
toast.classList.add('removing')
setTimeout(() => {
toast.remove()
}, 300)
}
/**
* Get background color for toast type
* @param {string} type - Toast type
* @returns {string} CSS color
*/
static getBackgroundColor(type) {
const colors = {
success: '#22c55e',
error: '#ef4444',
warning: '#f59e0b',
info: '#3b82f6'
}
return colors[type] || colors.info
}
/**
* Show success toast
* @param {string} message
* @param {number} duration
*/
static success(message, duration = 3000) {
return this.show(message, 'success', duration)
}
/**
* Show error toast
* @param {string} message
* @param {number} duration
*/
static error(message, duration = 4000) {
return this.show(message, 'error', duration)
}
/**
* Show warning toast
* @param {string} message
* @param {number} duration
*/
static warning(message, duration = 3500) {
return this.show(message, 'warning', duration)
}
/**
* Show info toast
* @param {string} message
* @param {number} duration
*/
static info(message, duration = 3000) {
return this.show(message, 'info', duration)
}
/**
* Clear all toasts
*/
static clearAll() {
if (!this.container) return
const toasts = this.container.querySelectorAll('.toast')
toasts.forEach(toast => this.dismiss(toast))
}
}

View file

@ -0,0 +1,156 @@
/**
* Visit card component for rendering individual visit cards in the side panel
*/
export class VisitCard {
/**
* Create HTML for a visit card
* @param {Object} visit - Visit object with id, name, status, started_at, ended_at, duration, place
* @param {Object} options - { isSelected, onSelect, onConfirm, onDecline, onHover }
* @returns {string} HTML string
*/
static create(visit, options = {}) {
const { isSelected = false, onSelect, onConfirm, onDecline, onHover } = options
const isSuggested = visit.status === 'suggested'
const isConfirmed = visit.status === 'confirmed'
const isDeclined = visit.status === 'declined'
// Format date and time
const startDate = new Date(visit.started_at)
const endDate = new Date(visit.ended_at)
const dateStr = startDate.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
})
const timeRange = `${startDate.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit'
})} - ${endDate.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit'
})}`
// Format duration (duration is in minutes from the backend)
const hours = Math.floor(visit.duration / 60)
const minutes = visit.duration % 60
const durationStr = hours > 0
? `${hours}h ${minutes}m`
: `${minutes}m`
// Border style based on status
const borderClass = isSuggested ? 'border-dashed' : ''
const bgClass = isDeclined ? 'bg-base-200 opacity-60' : 'bg-base-100'
const selectedClass = isSelected ? 'ring-2 ring-primary' : ''
return `
<div class="visit-card card ${bgClass} ${borderClass} ${selectedClass} border-2 border-base-content/20 mb-2 hover:shadow-md transition-all relative"
data-visit-id="${visit.id}"
data-visit-status="${visit.status}"
onmouseenter="this.querySelector('.visit-checkbox').classList.remove('hidden')"
onmouseleave="if(!this.querySelector('.visit-checkbox input').checked) this.querySelector('.visit-checkbox').classList.add('hidden')">
<!-- Checkbox (hidden by default, shown on hover) -->
<div class="visit-checkbox absolute top-3 right-3 z-10 ${isSelected ? '' : 'hidden'}">
<input type="checkbox"
class="checkbox checkbox-primary checkbox-sm"
${isSelected ? 'checked' : ''}
data-visit-select="${visit.id}"
onclick="event.stopPropagation()">
</div>
<div class="card-body p-3">
<!-- Visit Name -->
<h3 class="card-title text-sm font-semibold mb-2">
${visit.name || visit.place?.name || 'Unnamed Visit'}
</h3>
<!-- Date and Time -->
<div class="text-xs text-base-content/70 space-y-1">
<div class="flex items-center gap-1.5">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<span class="truncate">${dateStr}</span>
</div>
<div class="flex items-center gap-1.5">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="truncate">${timeRange}</span>
</div>
<div class="flex items-center gap-1.5">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
</svg>
<span class="truncate">${durationStr}</span>
</div>
</div>
<!-- Action buttons for suggested visits -->
${isSuggested ? `
<div class="card-actions justify-end mt-3 gap-1.5">
<button class="btn btn-xs btn-outline btn-error" data-visit-decline="${visit.id}">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
Decline
</button>
<button class="btn btn-xs btn-primary" data-visit-confirm="${visit.id}">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
Confirm
</button>
</div>
` : ''}
<!-- Status badge for confirmed/declined visits -->
${isConfirmed || isDeclined ? `
<div class="mt-2">
<span class="badge badge-xs ${isConfirmed ? 'badge-success' : 'badge-error'}">
${visit.status}
</span>
</div>
` : ''}
</div>
</div>
`
}
/**
* Create bulk action buttons HTML
* @param {number} selectedCount - Number of selected visits
* @returns {string} HTML string
*/
static createBulkActions(selectedCount) {
if (selectedCount < 2) return ''
return `
<div class="bulk-actions-panel sticky bottom-0 bg-base-100 border-t border-base-300 p-4 mt-4 space-y-2">
<div class="text-sm font-medium mb-3">
${selectedCount} visit${selectedCount === 1 ? '' : 's'} selected
</div>
<div class="grid grid-cols-3 gap-2">
<button class="btn btn-sm btn-outline" data-bulk-merge>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
</svg>
Merge
</button>
<button class="btn btn-sm btn-primary" data-bulk-confirm>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
Confirm
</button>
<button class="btn btn-sm btn-outline btn-error" data-bulk-decline>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
Decline
</button>
</div>
</div>
`
}
}

View file

@ -0,0 +1,138 @@
import { formatTimestamp } from '../utils/geojson_transformers'
import { getCurrentTheme, getThemeColors } from '../utils/popup_theme'
/**
* Factory for creating visit popups
*/
export class VisitPopupFactory {
/**
* Create popup for a visit
* @param {Object} properties - Visit properties
* @returns {string} HTML for popup
*/
static createVisitPopup(properties) {
const { id, name, status, started_at, ended_at, duration, place_name } = properties
const startTime = formatTimestamp(started_at)
const endTime = formatTimestamp(ended_at)
const durationHours = Math.round(duration / 3600)
const durationDisplay = durationHours >= 1 ? `${durationHours}h` : `${Math.round(duration / 60)}m`
// Get theme colors
const theme = getCurrentTheme()
const colors = getThemeColors(theme)
return `
<div class="visit-popup">
<div class="popup-header">
<strong>${name || place_name || 'Unknown Place'}</strong>
<span class="visit-badge ${status}">${status}</span>
</div>
<div class="popup-body">
<div class="popup-row">
<span class="label">Arrived:</span>
<span class="value">${startTime}</span>
</div>
<div class="popup-row">
<span class="label">Left:</span>
<span class="value">${endTime}</span>
</div>
<div class="popup-row">
<span class="label">Duration:</span>
<span class="value">${durationDisplay}</span>
</div>
</div>
<div class="popup-footer">
<a href="/visits/${id}" class="view-details-btn">View Details </a>
</div>
</div>
<style>
.visit-popup {
font-family: system-ui, -apple-system, sans-serif;
min-width: 280px;
}
.popup-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid ${colors.border};
gap: 12px;
}
.popup-header strong {
font-size: 15px;
flex: 1;
}
.visit-badge {
padding: 4px 8px;
border-radius: 4px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
white-space: nowrap;
flex-shrink: 0;
}
.visit-badge.suggested {
background: ${colors.badgeSuggested.bg};
color: ${colors.badgeSuggested.text};
}
.visit-badge.confirmed {
background: ${colors.badgeConfirmed.bg};
color: ${colors.badgeConfirmed.text};
}
.popup-body {
font-size: 13px;
margin-bottom: 16px;
}
.popup-row {
margin-bottom: 10px;
}
.popup-row .label {
color: ${colors.textMuted};
display: block;
margin-bottom: 4px;
font-size: 12px;
}
.popup-row .value {
font-weight: 500;
color: ${colors.textPrimary};
display: block;
}
.popup-footer {
padding-top: 12px;
border-top: 1px solid ${colors.border};
}
.view-details-btn {
display: block;
text-align: center;
padding: 10px 16px;
background: ${colors.accent};
color: white;
text-decoration: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
transition: background 0.2s;
}
.view-details-btn:hover {
background: ${colors.accentHover};
}
</style>
`
}
}

View file

@ -0,0 +1,67 @@
import { BaseLayer } from './base_layer'
/**
* Areas layer for user-defined regions
*/
export class AreasLayer extends BaseLayer {
constructor(map, options = {}) {
super(map, { id: 'areas', ...options })
}
getSourceConfig() {
return {
type: 'geojson',
data: this.data || {
type: 'FeatureCollection',
features: []
}
}
}
getLayerConfigs() {
return [
// Area fills
{
id: `${this.id}-fill`,
type: 'fill',
source: this.sourceId,
paint: {
'fill-color': '#ff0000',
'fill-opacity': 0.4
}
},
// Area outlines
{
id: `${this.id}-outline`,
type: 'line',
source: this.sourceId,
paint: {
'line-color': '#ff0000',
'line-width': 3
}
},
// Area labels
{
id: `${this.id}-labels`,
type: 'symbol',
source: this.sourceId,
layout: {
'text-field': ['get', 'name'],
'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'],
'text-size': 14
},
paint: {
'text-color': '#111827',
'text-halo-color': '#ffffff',
'text-halo-width': 2
}
}
]
}
getLayerIds() {
return [`${this.id}-fill`, `${this.id}-outline`, `${this.id}-labels`]
}
}

View file

@ -0,0 +1,136 @@
/**
* Base class for all map layers
* Provides common functionality for layer management
*/
export class BaseLayer {
constructor(map, options = {}) {
this.map = map
this.id = options.id || this.constructor.name.toLowerCase()
this.sourceId = `${this.id}-source`
this.visible = options.visible !== false
this.data = null
}
/**
* Add layer to map with data
* @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)
}
// Add layers
const layers = this.getLayerConfigs()
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)
}
})
this.setVisibility(this.visible)
console.log(`[BaseLayer:${this.id}] Layer added successfully`)
}
/**
* Update layer data
* @param {Object} data - New data
*/
update(data) {
this.data = data
const source = this.map.getSource(this.sourceId)
if (source && source.setData) {
source.setData(data)
}
}
/**
* Remove layer from map
*/
remove() {
this.getLayerIds().forEach(layerId => {
if (this.map.getLayer(layerId)) {
this.map.removeLayer(layerId)
}
})
if (this.map.getSource(this.sourceId)) {
this.map.removeSource(this.sourceId)
}
this.data = null
}
/**
* Show layer
*/
show() {
this.visible = true
this.setVisibility(true)
}
/**
* Hide layer
*/
hide() {
this.visible = false
this.setVisibility(false)
}
/**
* Toggle layer visibility
* @param {boolean} visible - Show/hide layer
*/
toggle(visible = !this.visible) {
this.visible = visible
this.setVisibility(visible)
}
/**
* Set visibility for all layer IDs
* @param {boolean} visible
*/
setVisibility(visible) {
const visibility = visible ? 'visible' : 'none'
this.getLayerIds().forEach(layerId => {
if (this.map.getLayer(layerId)) {
this.map.setLayoutProperty(layerId, 'visibility', visibility)
}
})
}
/**
* Get source configuration (override in subclass)
* @returns {Object} MapLibre source config
*/
getSourceConfig() {
throw new Error('Must implement getSourceConfig()')
}
/**
* Get layer configurations (override in subclass)
* @returns {Array<Object>} Array of MapLibre layer configs
*/
getLayerConfigs() {
throw new Error('Must implement getLayerConfigs()')
}
/**
* Get all layer IDs for this layer
* @returns {Array<string>}
*/
getLayerIds() {
return this.getLayerConfigs().map(config => config.id)
}
}

View file

@ -0,0 +1,210 @@
import { BaseLayer } from './base_layer'
/**
* Family layer showing family member locations
* Each member has unique color
*/
export class FamilyLayer extends BaseLayer {
constructor(map, options = {}) {
super(map, { id: 'family', ...options })
this.memberColors = {}
}
getSourceConfig() {
return {
type: 'geojson',
data: this.data || {
type: 'FeatureCollection',
features: []
}
}
}
getLayerConfigs() {
return [
// Member circles
{
id: this.id,
type: 'circle',
source: this.sourceId,
paint: {
'circle-radius': 10,
'circle-color': ['get', 'color'],
'circle-stroke-width': 2,
'circle-stroke-color': '#ffffff',
'circle-opacity': 0.9
}
},
// Member labels
{
id: `${this.id}-labels`,
type: 'symbol',
source: this.sourceId,
layout: {
'text-field': ['get', 'name'],
'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'],
'text-size': 12,
'text-offset': [0, 1.5],
'text-anchor': 'top'
},
paint: {
'text-color': '#111827',
'text-halo-color': '#ffffff',
'text-halo-width': 2
}
},
// Pulse animation
{
id: `${this.id}-pulse`,
type: 'circle',
source: this.sourceId,
paint: {
'circle-radius': [
'interpolate',
['linear'],
['zoom'],
10, 15,
15, 25
],
'circle-color': ['get', 'color'],
'circle-opacity': [
'interpolate',
['linear'],
['get', 'lastUpdate'],
Date.now() - 10000, 0,
Date.now(), 0.3
]
}
}
]
}
getLayerIds() {
return [this.id, `${this.id}-labels`, `${this.id}-pulse`]
}
/**
* Update single family member location
* @param {Object} member - { id, name, latitude, longitude, color }
*/
updateMember(member) {
const features = this.data?.features || []
// Find existing or add new
const index = features.findIndex(f => f.properties.id === member.id)
const feature = {
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [member.longitude, member.latitude]
},
properties: {
id: member.id,
name: member.name,
color: member.color || this.getMemberColor(member.id),
lastUpdate: Date.now()
}
}
if (index >= 0) {
features[index] = feature
} else {
features.push(feature)
}
this.update({
type: 'FeatureCollection',
features
})
}
/**
* Get consistent color for member
*/
getMemberColor(memberId) {
if (!this.memberColors[memberId]) {
const colors = [
'#3b82f6', '#10b981', '#f59e0b',
'#ef4444', '#8b5cf6', '#ec4899'
]
const index = Object.keys(this.memberColors).length % colors.length
this.memberColors[memberId] = colors[index]
}
return this.memberColors[memberId]
}
/**
* Remove family member
*/
removeMember(memberId) {
const features = this.data?.features || []
const filtered = features.filter(f => f.properties.id !== memberId)
this.update({
type: 'FeatureCollection',
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 || []
}
}

View file

@ -0,0 +1,144 @@
/**
* Fog of war layer
* Shows explored vs unexplored areas using canvas overlay
* Does not extend BaseLayer as it uses canvas instead of MapLibre layers
*/
export class FogLayer {
constructor(map, options = {}) {
this.map = map
this.id = 'fog'
this.visible = options.visible !== undefined ? options.visible : false
this.canvas = null
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) {
this.show()
}
this.render()
}
update(data) {
this.data = data // Store for later updates
this.points = data.features || []
this.render()
}
createCanvas() {
if (this.canvas) return
// Create canvas overlay
this.canvas = document.createElement('canvas')
this.canvas.className = 'fog-canvas'
this.canvas.style.position = 'absolute'
this.canvas.style.top = '0'
this.canvas.style.left = '0'
this.canvas.style.pointerEvents = 'none'
this.canvas.style.zIndex = '10'
this.canvas.style.display = this.visible ? 'block' : 'none'
this.ctx = this.canvas.getContext('2d')
// Add to map container
const mapContainer = this.map.getContainer()
mapContainer.appendChild(this.canvas)
// Update on map move/zoom/resize
this.map.on('move', () => this.render())
this.map.on('zoom', () => this.render())
this.map.on('resize', () => this.resizeCanvas())
this.resizeCanvas()
}
resizeCanvas() {
if (!this.canvas) return
const container = this.map.getContainer()
this.canvas.width = container.offsetWidth
this.canvas.height = container.offsetHeight
this.render()
}
render() {
if (!this.canvas || !this.ctx || !this.visible) return
const { width, height } = this.canvas
// Clear canvas
this.ctx.clearRect(0, 0, width, height)
// Draw fog overlay
this.ctx.fillStyle = 'rgba(0, 0, 0, 0.6)'
this.ctx.fillRect(0, 0, width, height)
// 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
const point = this.map.project(coords)
// Calculate pixel radius based on zoom level
const metersPerPixel = this.getMetersPerPixel(coords[1])
const radiusPixels = this.clearRadius / metersPerPixel
this.ctx.beginPath()
this.ctx.arc(point.x, point.y, radiusPixels, 0, Math.PI * 2)
this.ctx.fill()
})
this.ctx.globalCompositeOperation = 'source-over'
}
getMetersPerPixel(latitude) {
const earthCircumference = 40075017 // meters at equator
const latitudeRadians = latitude * Math.PI / 180
const zoom = this.map.getZoom()
return earthCircumference * Math.cos(latitudeRadians) / (256 * Math.pow(2, zoom))
}
show() {
this.visible = true
if (this.canvas) {
this.canvas.style.display = 'block'
this.render()
}
}
hide() {
this.visible = false
if (this.canvas) {
this.canvas.style.display = 'none'
}
}
toggle(visible = !this.visible) {
if (visible) {
this.show()
} else {
this.hide()
}
}
remove() {
if (this.canvas) {
this.canvas.remove()
this.canvas = null
this.ctx = null
}
// Remove event listeners
this.map.off('move', this.render)
this.map.off('zoom', this.render)
this.map.off('resize', this.resizeCanvas)
}
}

View file

@ -0,0 +1,81 @@
import { BaseLayer } from './base_layer'
/**
* Heatmap layer showing point density
* Uses MapLibre's native heatmap for performance
*/
export class HeatmapLayer extends BaseLayer {
constructor(map, options = {}) {
super(map, { id: 'heatmap', ...options })
this.opacity = options.opacity || 0.6
}
getSourceConfig() {
return {
type: 'geojson',
data: this.data || {
type: 'FeatureCollection',
features: []
}
}
}
getLayerConfigs() {
return [
{
id: this.id,
type: 'heatmap',
source: this.sourceId,
paint: {
// Fixed weight
'heatmap-weight': 1,
// low intensity to view major clusters
'heatmap-intensity': [
'interpolate',
['linear'],
['zoom'],
0, 0.01,
10, 0.1,
15, 0.3
],
// Color ramp
'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)',
1, 'rgb(178,24,43)'
],
// Radius in pixels, exponential growth
'heatmap-radius': [
'interpolate',
['exponential', 2],
['zoom'],
10, 5,
15, 10,
20, 160
],
// Visible when zoomed in, fades when zoomed out
'heatmap-opacity': [
'interpolate',
['linear'],
['zoom'],
0, 0.3,
10, this.opacity,
15, this.opacity
]
}
}
]
}
}

View file

@ -0,0 +1,220 @@
import { BaseLayer } from './base_layer'
import maplibregl from 'maplibre-gl'
import { getCurrentTheme, getThemeColors } from '../utils/popup_theme'
/**
* Photos layer with thumbnail markers
* Uses HTML DOM markers with circular image thumbnails
*/
export class PhotosLayer extends BaseLayer {
constructor(map, options = {}) {
super(map, { id: 'photos', ...options })
this.markers = [] // Store marker references for cleanup
}
async add(data) {
console.log('[PhotosLayer] add() called with data:', {
featuresCount: data.features?.length || 0,
sampleFeature: data.features?.[0],
visible: this.visible
})
// Store data
this.data = data
// Create HTML markers for photos
this.createPhotoMarkers(data)
console.log('[PhotosLayer] Photo markers created')
}
async update(data) {
console.log('[PhotosLayer] update() called with data:', {
featuresCount: data.features?.length || 0
})
// Remove existing markers
this.clearMarkers()
// Create new markers
this.createPhotoMarkers(data)
console.log('[PhotosLayer] Photo markers updated')
}
/**
* Create HTML markers with photo thumbnails
* @param {Object} geojson - GeoJSON with photo features
*/
createPhotoMarkers(geojson) {
if (!geojson?.features) {
console.log('[PhotosLayer] No features to create markers for')
return
}
console.log('[PhotosLayer] Creating markers for', geojson.features.length, 'photos')
console.log('[PhotosLayer] Sample feature:', geojson.features[0])
geojson.features.forEach((feature, index) => {
const { id, thumbnail_url, photo_url, taken_at } = feature.properties
const [lng, lat] = feature.geometry.coordinates
if (index === 0) {
console.log('[PhotosLayer] First marker thumbnail_url:', thumbnail_url)
}
// Create marker container (MapLibre will position this)
const container = document.createElement('div')
container.style.cssText = `
display: ${this.visible ? 'block' : 'none'};
`
// Create inner element for the image (this is what we'll transform)
const el = document.createElement('div')
el.className = 'photo-marker'
el.style.cssText = `
width: 50px;
height: 50px;
border-radius: 50%;
cursor: pointer;
background-size: cover;
background-position: center;
background-image: url('${thumbnail_url}');
border: 3px solid white;
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
transition: transform 0.2s, box-shadow 0.2s;
`
// Add hover effect
el.addEventListener('mouseenter', () => {
el.style.transform = 'scale(1.2)'
el.style.boxShadow = '0 4px 8px rgba(0,0,0,0.4)'
el.style.zIndex = '1000'
})
el.addEventListener('mouseleave', () => {
el.style.transform = 'scale(1)'
el.style.boxShadow = '0 2px 4px rgba(0,0,0,0.3)'
el.style.zIndex = '1'
})
// Add click handler to show popup
el.addEventListener('click', (e) => {
e.stopPropagation()
this.showPhotoPopup(feature)
})
// Add image element to container
container.appendChild(el)
// Create MapLibre marker with container
const marker = new maplibregl.Marker({ element: container })
.setLngLat([lng, lat])
.addTo(this.map)
this.markers.push(marker)
if (index === 0) {
console.log('[PhotosLayer] First marker created at:', lng, lat)
}
})
console.log('[PhotosLayer] Created', this.markers.length, 'markers, visible:', this.visible)
}
/**
* Show photo popup with image
* @param {Object} feature - GeoJSON feature with photo properties
*/
showPhotoPopup(feature) {
const { thumbnail_url, taken_at, filename, city, state, country, type, source } = feature.properties
const [lng, lat] = feature.geometry.coordinates
const takenDate = taken_at ? new Date(taken_at).toLocaleString() : 'Unknown'
const location = [city, state, country].filter(Boolean).join(', ') || 'Unknown location'
const mediaType = type === 'VIDEO' ? '🎥 Video' : '📷 Photo'
// Get theme colors
const theme = getCurrentTheme()
const colors = getThemeColors(theme)
// Create popup HTML with theme-aware styling
const popupHTML = `
<div class="photo-popup" style="font-family: system-ui, -apple-system, sans-serif; max-width: 350px;">
<div style="width: 100%; border-radius: 8px; overflow: hidden; margin-bottom: 12px; background: ${colors.backgroundAlt};">
<img
src="${thumbnail_url}"
alt="${filename || 'Photo'}"
style="width: 100%; height: auto; max-height: 350px; object-fit: contain; display: block;"
loading="lazy"
/>
</div>
<div style="font-size: 13px;">
${filename ? `<div style="font-weight: 600; color: ${colors.textPrimary}; margin-bottom: 6px; word-wrap: break-word;">${filename}</div>` : ''}
<div style="color: ${colors.textMuted}; font-size: 12px; margin-bottom: 6px;">📅 ${takenDate}</div>
<div style="color: ${colors.textMuted}; font-size: 12px; margin-bottom: 6px;">📍 ${location}</div>
<div style="color: ${colors.textMuted}; font-size: 12px; margin-bottom: 6px;">Coordinates: ${lat.toFixed(6)}, ${lng.toFixed(6)}</div>
${source ? `<div style="color: ${colors.textSecondary}; font-size: 11px; margin-bottom: 6px;">Source: ${source}</div>` : ''}
<div style="font-size: 14px; margin-top: 8px; color: ${colors.textPrimary};">${mediaType}</div>
</div>
</div>
`
// Create and show popup
new maplibregl.Popup({
closeButton: true,
closeOnClick: true,
maxWidth: '400px'
})
.setLngLat([lng, lat])
.setHTML(popupHTML)
.addTo(this.map)
}
/**
* Clear all markers from map
*/
clearMarkers() {
this.markers.forEach(marker => marker.remove())
this.markers = []
}
/**
* Override remove to clean up markers
*/
remove() {
this.clearMarkers()
super.remove()
}
/**
* Override show to display markers
*/
show() {
this.visible = true
this.markers.forEach(marker => {
marker.getElement().style.display = 'block'
})
}
/**
* Override hide to hide markers
*/
hide() {
this.visible = false
this.markers.forEach(marker => {
marker.getElement().style.display = 'none'
})
}
// Override these methods since we're not using source/layer approach
getSourceConfig() {
return null
}
getLayerConfigs() {
return []
}
getLayerIds() {
return []
}
}

View file

@ -0,0 +1,66 @@
import { BaseLayer } from './base_layer'
/**
* Places layer showing user-created places with tags
* Different colors based on tags
*/
export class PlacesLayer extends BaseLayer {
constructor(map, options = {}) {
super(map, { id: 'places', ...options })
}
getSourceConfig() {
return {
type: 'geojson',
data: this.data || {
type: 'FeatureCollection',
features: []
}
}
}
getLayerConfigs() {
return [
// Place circles
{
id: this.id,
type: 'circle',
source: this.sourceId,
paint: {
'circle-radius': 10,
'circle-color': [
'coalesce',
['get', 'color'], // Use tag color if available
'#6366f1' // Default indigo color
],
'circle-stroke-width': 2,
'circle-stroke-color': '#ffffff',
'circle-opacity': 0.85
}
},
// Place labels
{
id: `${this.id}-labels`,
type: 'symbol',
source: this.sourceId,
layout: {
'text-field': ['get', 'name'],
'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'],
'text-size': 11,
'text-offset': [0, 1.3],
'text-anchor': 'top'
},
paint: {
'text-color': '#111827',
'text-halo-color': '#ffffff',
'text-halo-width': 2
}
}
]
}
getLayerIds() {
return [this.id, `${this.id}-labels`]
}
}

View file

@ -0,0 +1,265 @@
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() {
return {
type: 'geojson',
data: this.data || {
type: 'FeatureCollection',
features: []
}
}
}
getLayerConfigs() {
return [
// Individual points
{
id: this.id,
type: 'circle',
source: this.sourceId,
paint: {
'circle-color': '#3b82f6',
'circle-radius': 6,
'circle-stroke-width': 2,
'circle-stroke-color': '#ffffff'
}
}
]
}
/**
* 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()
}
}

View file

@ -0,0 +1,94 @@
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: []
})
}
}

View file

@ -0,0 +1,151 @@
import { BaseLayer } from './base_layer'
/**
* Routes layer showing travel paths
* Connects points chronologically with solid color
*/
export class RoutesLayer extends BaseLayer {
constructor(map, options = {}) {
super(map, { id: 'routes', ...options })
this.maxGapHours = options.maxGapHours || 5 // Max hours between points to connect
}
getSourceConfig() {
return {
type: 'geojson',
data: this.data || {
type: 'FeatureCollection',
features: []
}
}
}
getLayerConfigs() {
return [
{
id: this.id,
type: 'line',
source: this.sourceId,
layout: {
'line-join': 'round',
'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-width': 3,
'line-opacity': 0.8
}
}
]
}
/**
* 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
}
/**
* Convert points to route LineStrings with splitting
* 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 = {}) {
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
}
}
}

View file

@ -0,0 +1,178 @@
import { BaseLayer } from './base_layer'
/**
* Scratch map layer
* Highlights countries that have been visited based on points' country_name attribute
* Extracts country names from points (via database country relationship)
* Matches country names to polygons in lib/assets/countries.geojson by name field
* "Scratches off" visited countries by overlaying gold/amber polygons
*/
export class ScratchLayer extends BaseLayer {
constructor(map, options = {}) {
super(map, { id: 'scratch', ...options })
this.visitedCountries = new Set()
this.countriesData = null
this.loadingCountries = null // Promise for loading countries
this.apiClient = options.apiClient // For authenticated requests
}
async add(data) {
const points = data.features || []
// Load country boundaries
await this.loadCountryBoundaries()
// Detect which countries have been visited
this.visitedCountries = this.detectCountriesFromPoints(points)
// Create GeoJSON with visited countries
const geojson = this.createCountriesGeoJSON()
super.add(geojson)
}
async update(data) {
const points = data.features || []
// Countries already loaded from add()
this.visitedCountries = this.detectCountriesFromPoints(points)
const geojson = this.createCountriesGeoJSON()
super.update(geojson)
}
/**
* Extract country names from points' country_name attribute
* Points already have country association from database (country_id relationship)
* @param {Array} points - Array of point features with properties.country_name
* @returns {Set} Set of country names
*/
detectCountriesFromPoints(points) {
const visitedCountries = new Set()
// Extract unique country names from points
points.forEach(point => {
const countryName = point.properties?.country_name
if (countryName && countryName !== 'Unknown') {
visitedCountries.add(countryName)
}
})
return visitedCountries
}
/**
* Load country boundaries from internal API endpoint
* Endpoint: GET /api/v1/countries/borders
*/
async loadCountryBoundaries() {
// Return existing promise if already loading
if (this.loadingCountries) {
return this.loadingCountries
}
// Return immediately if already loaded
if (this.countriesData) {
return
}
this.loadingCountries = (async () => {
try {
// Use internal API endpoint with authentication
const headers = {}
if (this.apiClient) {
headers['Authorization'] = `Bearer ${this.apiClient.apiKey}`
}
const response = await fetch('/api/v1/countries/borders.json', {
headers: headers
})
if (!response.ok) {
throw new Error(`Failed to load country borders: ${response.statusText}`)
}
this.countriesData = await response.json()
} catch (error) {
console.error('[ScratchLayer] Failed to load country boundaries:', error)
// Fallback to empty data
this.countriesData = { type: 'FeatureCollection', features: [] }
}
})()
return this.loadingCountries
}
/**
* Create GeoJSON for visited countries
* Matches visited country names from points to boundary polygons by name
* @returns {Object} GeoJSON FeatureCollection
*/
createCountriesGeoJSON() {
if (!this.countriesData || this.visitedCountries.size === 0) {
return {
type: 'FeatureCollection',
features: []
}
}
// Filter country features by matching name field to visited country names
const visitedFeatures = this.countriesData.features.filter(country => {
const countryName = country.properties.name || country.properties.NAME
if (!countryName) return false
// Case-insensitive exact match
return Array.from(this.visitedCountries).some(visitedName =>
countryName.toLowerCase() === visitedName.toLowerCase()
)
})
return {
type: 'FeatureCollection',
features: visitedFeatures
}
}
getSourceConfig() {
return {
type: 'geojson',
data: this.data || {
type: 'FeatureCollection',
features: []
}
}
}
getLayerConfigs() {
return [
// Country fill
{
id: this.id,
type: 'fill',
source: this.sourceId,
paint: {
'fill-color': '#fbbf24', // Amber/gold color
'fill-opacity': 0.3
}
},
// Country outline
{
id: `${this.id}-outline`,
type: 'line',
source: this.sourceId,
paint: {
'line-color': '#f59e0b',
'line-width': 1,
'line-opacity': 0.6
}
}
]
}
getLayerIds() {
return [this.id, `${this.id}-outline`]
}
}

View file

@ -0,0 +1,96 @@
import { BaseLayer } from './base_layer'
/**
* Layer for displaying selected points with distinct styling
*/
export class SelectedPointsLayer extends BaseLayer {
constructor(map, options = {}) {
super(map, { id: 'selected-points', ...options })
this.pointIds = []
}
getSourceConfig() {
return {
type: 'geojson',
data: this.data || {
type: 'FeatureCollection',
features: []
}
}
}
getLayerConfigs() {
return [
// Outer circle (highlight)
{
id: `${this.id}-highlight`,
type: 'circle',
source: this.sourceId,
paint: {
'circle-radius': 8,
'circle-color': '#ef4444',
'circle-opacity': 0.3
}
},
// Inner circle (selected point)
{
id: this.id,
type: 'circle',
source: this.sourceId,
paint: {
'circle-radius': 5,
'circle-color': '#ef4444',
'circle-stroke-width': 2,
'circle-stroke-color': '#ffffff'
}
}
]
}
/**
* Get layer IDs for this layer
*/
getLayerIds() {
return [`${this.id}-highlight`, this.id]
}
/**
* Update selected points and store their IDs
*/
updateSelectedPoints(geojson) {
this.data = geojson
// Extract point IDs
this.pointIds = geojson.features.map(f => f.properties.id)
// Update map source
this.update(geojson)
console.log('[SelectedPointsLayer] Updated with', this.pointIds.length, 'points')
}
/**
* Get IDs of selected points
*/
getSelectedPointIds() {
return this.pointIds
}
/**
* Clear selected points
*/
clearSelection() {
this.pointIds = []
this.update({
type: 'FeatureCollection',
features: []
})
}
/**
* Get count of selected points
*/
getCount() {
return this.pointIds.length
}
}

View file

@ -0,0 +1,200 @@
import { BaseLayer } from './base_layer'
/**
* Selection layer for drawing selection rectangles on the map
* Allows users to select areas by clicking and dragging
*/
export class SelectionLayer extends BaseLayer {
constructor(map, options = {}) {
super(map, { id: 'selection', ...options })
this.isDrawing = false
this.startPoint = null
this.currentRect = null
this.onSelectionComplete = options.onSelectionComplete || (() => {})
}
getSourceConfig() {
return {
type: 'geojson',
data: this.data || {
type: 'FeatureCollection',
features: []
}
}
}
getLayerConfigs() {
return [
// Fill layer
{
id: `${this.id}-fill`,
type: 'fill',
source: this.sourceId,
paint: {
'fill-color': '#3b82f6',
'fill-opacity': 0.1
}
},
// Outline layer
{
id: `${this.id}-outline`,
type: 'line',
source: this.sourceId,
paint: {
'line-color': '#3b82f6',
'line-width': 2,
'line-dasharray': [2, 2]
}
}
]
}
/**
* Get layer IDs for this layer
*/
getLayerIds() {
return [`${this.id}-fill`, `${this.id}-outline`]
}
/**
* Enable selection mode
*/
enableSelectionMode() {
this.map.getCanvas().style.cursor = 'crosshair'
// Add mouse event listeners
this.handleMouseDown = this.onMouseDown.bind(this)
this.handleMouseMove = this.onMouseMove.bind(this)
this.handleMouseUp = this.onMouseUp.bind(this)
this.map.on('mousedown', this.handleMouseDown)
this.map.on('mousemove', this.handleMouseMove)
this.map.on('mouseup', this.handleMouseUp)
console.log('[SelectionLayer] Selection mode enabled')
}
/**
* Disable selection mode
*/
disableSelectionMode() {
this.map.getCanvas().style.cursor = ''
// Remove mouse event listeners
if (this.handleMouseDown) {
this.map.off('mousedown', this.handleMouseDown)
this.map.off('mousemove', this.handleMouseMove)
this.map.off('mouseup', this.handleMouseUp)
}
// Clear selection
this.clearSelection()
console.log('[SelectionLayer] Selection mode disabled')
}
/**
* Handle mouse down - start drawing
*/
onMouseDown(e) {
// Prevent default to stop map panning during selection
e.preventDefault()
this.isDrawing = true
this.startPoint = e.lngLat
console.log('[SelectionLayer] Started drawing at:', this.startPoint)
}
/**
* Handle mouse move - update rectangle
*/
onMouseMove(e) {
if (!this.isDrawing || !this.startPoint) return
const endPoint = e.lngLat
// Create rectangle from start and end points
const rect = this.createRectangle(this.startPoint, endPoint)
// Update layer with rectangle
this.update({
type: 'FeatureCollection',
features: [{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [rect]
}
}]
})
this.currentRect = { start: this.startPoint, end: endPoint }
}
/**
* Handle mouse up - finish drawing
*/
onMouseUp(e) {
if (!this.isDrawing || !this.startPoint) return
this.isDrawing = false
const endPoint = e.lngLat
// Calculate bounds
const bounds = this.calculateBounds(this.startPoint, endPoint)
console.log('[SelectionLayer] Selection completed:', bounds)
// Notify callback
this.onSelectionComplete(bounds)
this.startPoint = null
}
/**
* Create rectangle coordinates from two points
*/
createRectangle(start, end) {
return [
[start.lng, start.lat],
[end.lng, start.lat],
[end.lng, end.lat],
[start.lng, end.lat],
[start.lng, start.lat]
]
}
/**
* Calculate bounds from two points
*/
calculateBounds(start, end) {
return {
minLng: Math.min(start.lng, end.lng),
maxLng: Math.max(start.lng, end.lng),
minLat: Math.min(start.lat, end.lat),
maxLat: Math.max(start.lat, end.lat)
}
}
/**
* Clear current selection
*/
clearSelection() {
this.update({
type: 'FeatureCollection',
features: []
})
this.currentRect = null
this.startPoint = null
this.isDrawing = false
}
/**
* Remove layer and cleanup
*/
remove() {
this.disableSelectionMode()
super.remove()
}
}

View file

@ -0,0 +1,39 @@
import { BaseLayer } from './base_layer'
/**
* Tracks layer for saved routes
*/
export class TracksLayer extends BaseLayer {
constructor(map, options = {}) {
super(map, { id: 'tracks', ...options })
}
getSourceConfig() {
return {
type: 'geojson',
data: this.data || {
type: 'FeatureCollection',
features: []
}
}
}
getLayerConfigs() {
return [
{
id: this.id,
type: 'line',
source: this.sourceId,
layout: {
'line-join': 'round',
'line-cap': 'round'
},
paint: {
'line-color': ['get', 'color'],
'line-width': 4,
'line-opacity': 0.7
}
}
]
}
}

View file

@ -0,0 +1,66 @@
import { BaseLayer } from './base_layer'
/**
* Visits layer showing suggested and confirmed visits
* Yellow = suggested, Green = confirmed
*/
export class VisitsLayer extends BaseLayer {
constructor(map, options = {}) {
super(map, { id: 'visits', ...options })
}
getSourceConfig() {
return {
type: 'geojson',
data: this.data || {
type: 'FeatureCollection',
features: []
}
}
}
getLayerConfigs() {
return [
// Visit circles
{
id: this.id,
type: 'circle',
source: this.sourceId,
paint: {
'circle-radius': 12,
'circle-color': [
'case',
['==', ['get', 'status'], 'confirmed'], '#22c55e', // Green for confirmed
'#eab308' // Yellow for suggested
],
'circle-stroke-width': 2,
'circle-stroke-color': '#ffffff',
'circle-opacity': 0.9
}
},
// Visit labels
{
id: `${this.id}-labels`,
type: 'symbol',
source: this.sourceId,
layout: {
'text-field': ['get', 'name'],
'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'],
'text-size': 11,
'text-offset': [0, 1.5],
'text-anchor': 'top'
},
paint: {
'text-color': '#111827',
'text-halo-color': '#ffffff',
'text-halo-width': 2
}
}
]
}
getLayerIds() {
return [this.id, `${this.id}-labels`]
}
}

View file

@ -0,0 +1,358 @@
/**
* API client for Maps V2
* Wraps all API endpoints with consistent error handling
*/
export class ApiClient {
constructor(apiKey) {
this.apiKey = apiKey
this.baseURL = '/api/v1'
}
/**
* Fetch points for date range (paginated)
* @param {Object} options - { start_at, end_at, page, per_page }
* @returns {Promise<Object>} { points, currentPage, totalPages }
*/
async fetchPoints({ start_at, end_at, page = 1, per_page = 1000 }) {
const params = new URLSearchParams({
start_at,
end_at,
page: page.toString(),
per_page: per_page.toString(),
slim: 'true'
})
const response = await fetch(`${this.baseURL}/points?${params}`, {
headers: this.getHeaders()
})
if (!response.ok) {
throw new Error(`Failed to fetch points: ${response.statusText}`)
}
const points = await response.json()
return {
points,
currentPage: parseInt(response.headers.get('X-Current-Page') || '1'),
totalPages: parseInt(response.headers.get('X-Total-Pages') || '1')
}
}
/**
* 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 }) {
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 (onProgress) {
// Avoid division by zero - if no pages, progress is 100%
const progress = totalPages > 0 ? currentPage / totalPages : 1.0
onProgress({
loaded: allPoints.length,
currentPage,
totalPages,
progress
})
}
} while (page <= totalPages)
return allPoints
}
/**
* Fetch visits for date range
*/
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()
})
if (!response.ok) {
throw new Error(`Failed to fetch visits: ${response.statusText}`)
}
return response.json()
}
/**
* Fetch places optionally filtered by tags
*/
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() ? '?' + params.toString() : ''}`
const response = await fetch(url, {
headers: this.getHeaders()
})
if (!response.ok) {
throw new Error(`Failed to fetch places: ${response.statusText}`)
}
return response.json()
}
/**
* Fetch photos for date range
*/
async fetchPhotos({ start_at, end_at }) {
// Photos API uses start_date/end_date parameters
// Pass dates as-is (matching V1 behavior)
const params = new URLSearchParams({
start_date: start_at,
end_date: end_at
})
const url = `${this.baseURL}/photos?${params}`
const response = await fetch(url, {
headers: this.getHeaders()
})
if (!response.ok) {
throw new Error(`Failed to fetch photos: ${response.statusText}`)
}
return response.json()
}
/**
* Fetch areas
*/
async fetchAreas() {
const response = await fetch(`${this.baseURL}/areas`, {
headers: this.getHeaders()
})
if (!response.ok) {
throw new Error(`Failed to fetch areas: ${response.statusText}`)
}
return response.json()
}
/**
* Fetch single area by ID
* @param {number} areaId - Area ID
*/
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
*/
async fetchTracks() {
const response = await fetch(`${this.baseURL}/tracks`, {
headers: this.getHeaders()
})
if (!response.ok) {
throw new Error(`Failed to fetch tracks: ${response.statusText}`)
}
return response.json()
}
/**
* Create area
* @param {Object} area - Area data
*/
async createArea(area) {
const response = await fetch(`${this.baseURL}/areas`, {
method: 'POST',
headers: this.getHeaders(),
body: JSON.stringify({ area })
})
if (!response.ok) {
throw new Error(`Failed to create area: ${response.statusText}`)
}
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 }
* @returns {Promise<Array>} Points within the area
*/
async fetchPointsInArea({ start_at, end_at, min_longitude, max_longitude, min_latitude, max_latitude }) {
const params = new URLSearchParams({
start_at,
end_at,
min_longitude: min_longitude.toString(),
max_longitude: max_longitude.toString(),
min_latitude: min_latitude.toString(),
max_latitude: max_latitude.toString(),
per_page: '10000' // Get all points in area (up to 10k)
})
const response = await fetch(`${this.baseURL}/points?${params}`, {
headers: this.getHeaders()
})
if (!response.ok) {
throw new Error(`Failed to fetch points in area: ${response.statusText}`)
}
return response.json()
}
/**
* Fetch visits within a geographic area
* @param {Object} options - { start_at, end_at, sw_lat, sw_lng, ne_lat, ne_lng }
* @returns {Promise<Array>} Visits within the area
*/
async fetchVisitsInArea({ start_at, end_at, sw_lat, sw_lng, ne_lat, ne_lng }) {
const params = new URLSearchParams({
start_at,
end_at,
selection: 'true',
sw_lat: sw_lat.toString(),
sw_lng: sw_lng.toString(),
ne_lat: ne_lat.toString(),
ne_lng: ne_lng.toString()
})
const response = await fetch(`${this.baseURL}/visits?${params}`, {
headers: this.getHeaders()
})
if (!response.ok) {
throw new Error(`Failed to fetch visits in area: ${response.statusText}`)
}
return response.json()
}
/**
* Bulk delete points
* @param {Array<number>} pointIds - Array of point IDs to delete
* @returns {Promise<Object>} { message, count }
*/
async bulkDeletePoints(pointIds) {
const response = await fetch(`${this.baseURL}/points/bulk_destroy`, {
method: 'DELETE',
headers: this.getHeaders(),
body: JSON.stringify({ point_ids: pointIds })
})
if (!response.ok) {
throw new Error(`Failed to delete points: ${response.statusText}`)
}
return response.json()
}
/**
* Update visit status (confirm/decline)
* @param {number} visitId - Visit ID
* @param {string} status - 'confirmed' or 'declined'
* @returns {Promise<Object>} Updated visit
*/
async updateVisitStatus(visitId, status) {
const response = await fetch(`${this.baseURL}/visits/${visitId}`, {
method: 'PATCH',
headers: this.getHeaders(),
body: JSON.stringify({ visit: { status } })
})
if (!response.ok) {
throw new Error(`Failed to update visit status: ${response.statusText}`)
}
return response.json()
}
/**
* Merge multiple visits
* @param {Array<number>} visitIds - Array of visit IDs to merge
* @returns {Promise<Object>} Merged visit
*/
async mergeVisits(visitIds) {
const response = await fetch(`${this.baseURL}/visits/merge`, {
method: 'POST',
headers: this.getHeaders(),
body: JSON.stringify({ visit_ids: visitIds })
})
if (!response.ok) {
throw new Error(`Failed to merge visits: ${response.statusText}`)
}
return response.json()
}
/**
* Bulk update visit status
* @param {Array<number>} visitIds - Array of visit IDs to update
* @param {string} status - 'confirmed' or 'declined'
* @returns {Promise<Object>} Update result
*/
async bulkUpdateVisits(visitIds, status) {
const response = await fetch(`${this.baseURL}/visits/bulk_update`, {
method: 'POST',
headers: this.getHeaders(),
body: JSON.stringify({ visit_ids: visitIds, status })
})
if (!response.ok) {
throw new Error(`Failed to bulk update visits: ${response.statusText}`)
}
return response.json()
}
getHeaders() {
return {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json'
}
}
}

View file

@ -0,0 +1,117 @@
/**
* Location Search Service
* Handles API calls for location search (suggestions and visits)
*/
export class LocationSearchService {
constructor(apiKey) {
this.apiKey = apiKey
this.baseHeaders = {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json'
}
}
/**
* Fetch location suggestions based on query
* @param {string} query - Search query
* @returns {Promise<Array>} Array of location suggestions
*/
async fetchSuggestions(query) {
if (!query || query.length < 2) {
return []
}
try {
const response = await fetch(
`/api/v1/locations/suggestions?q=${encodeURIComponent(query)}`,
{
method: 'GET',
headers: this.baseHeaders
}
)
if (!response.ok) {
throw new Error(`Suggestions API error: ${response.status}`)
}
const data = await response.json()
// Transform suggestions to expected format
// API returns coordinates as [lat, lon], we need { lat, lon }
const suggestions = (data.suggestions || []).map(suggestion => ({
name: suggestion.name,
address: suggestion.address,
lat: suggestion.coordinates?.[0],
lon: suggestion.coordinates?.[1],
type: suggestion.type
}))
return suggestions
} catch (error) {
console.error('LocationSearchService: Suggestion fetch error:', error)
throw error
}
}
/**
* Search for visits at a specific location
* @param {Object} params - Search parameters
* @param {number} params.lat - Latitude
* @param {number} params.lon - Longitude
* @param {string} params.name - Location name
* @param {string} params.address - Location address
* @returns {Promise<Object>} Search results with locations and visits
*/
async searchVisits({ lat, lon, name, address = '' }) {
try {
const params = new URLSearchParams({
lat: lat.toString(),
lon: lon.toString(),
name,
address
})
const response = await fetch(`/api/v1/locations?${params}`, {
method: 'GET',
headers: this.baseHeaders
})
if (!response.ok) {
throw new Error(`Location search API error: ${response.status}`)
}
const data = await response.json()
return data
} catch (error) {
console.error('LocationSearchService: Visit search error:', error)
throw error
}
}
/**
* Create a new visit
* @param {Object} visitData - Visit data
* @returns {Promise<Object>} Created visit
*/
async createVisit(visitData) {
try {
const response = await fetch('/api/v1/visits', {
method: 'POST',
headers: this.baseHeaders,
body: JSON.stringify({ visit: visitData })
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || data.message || 'Failed to create visit')
}
return data
} catch (error) {
console.error('LocationSearchService: Create visit error:', error)
throw error
}
}
}

View file

@ -0,0 +1,49 @@
/**
* Helper for tracking and cleaning up resources
* Prevents memory leaks by tracking event listeners, intervals, timeouts, and observers
*/
export class CleanupHelper {
constructor() {
this.listeners = []
this.intervals = []
this.timeouts = []
this.observers = []
}
addEventListener(target, event, handler, options) {
target.addEventListener(event, handler, options)
this.listeners.push({ target, event, handler, options })
}
setInterval(callback, delay) {
const id = setInterval(callback, delay)
this.intervals.push(id)
return id
}
setTimeout(callback, delay) {
const id = setTimeout(callback, delay)
this.timeouts.push(id)
return id
}
addObserver(observer) {
this.observers.push(observer)
}
cleanup() {
this.listeners.forEach(({ target, event, handler, options }) => {
target.removeEventListener(event, handler, options)
})
this.listeners = []
this.intervals.forEach(id => clearInterval(id))
this.intervals = []
this.timeouts.forEach(id => clearTimeout(id))
this.timeouts = []
this.observers.forEach(observer => observer.disconnect())
this.observers = []
}
}

View file

@ -0,0 +1,49 @@
/**
* FPS (Frames Per Second) monitor
* Tracks rendering performance
*/
export class FPSMonitor {
constructor(sampleSize = 60) {
this.sampleSize = sampleSize
this.frames = []
this.lastTime = performance.now()
this.isRunning = false
this.rafId = null
}
start() {
if (this.isRunning) return
this.isRunning = true
this.#tick()
}
stop() {
this.isRunning = false
if (this.rafId) {
cancelAnimationFrame(this.rafId)
this.rafId = null
}
}
getFPS() {
if (this.frames.length === 0) return 0
const avg = this.frames.reduce((a, b) => a + b, 0) / this.frames.length
return Math.round(avg)
}
#tick = () => {
if (!this.isRunning) return
const now = performance.now()
const delta = now - this.lastTime
const fps = 1000 / delta
this.frames.push(fps)
if (this.frames.length > this.sampleSize) {
this.frames.shift()
}
this.lastTime = now
this.rafId = requestAnimationFrame(this.#tick)
}
}

View file

@ -0,0 +1,56 @@
/**
* Transform points array to GeoJSON FeatureCollection
* @param {Array} points - Array of point objects from API
* @returns {Object} GeoJSON FeatureCollection
*/
export function pointsToGeoJSON(points) {
return {
type: 'FeatureCollection',
features: points.map(point => ({
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [point.longitude, point.latitude]
},
properties: {
id: point.id,
timestamp: point.timestamp,
altitude: point.altitude,
battery: point.battery,
accuracy: point.accuracy,
velocity: point.velocity,
country_name: point.country_name
}
}))
}
}
/**
* 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') {
// Handle different timestamp formats
let date
if (typeof timestamp === 'string') {
// ISO 8601 string
date = new Date(timestamp)
} else if (timestamp < 10000000000) {
// Unix timestamp in seconds
date = new Date(timestamp * 1000)
} else {
// Unix timestamp in milliseconds
date = new Date(timestamp)
}
return date.toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
timeZone: timezone
})
}

Some files were not shown because too many files have changed in this diff Show more