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
This commit is contained in:
Evgenii Burmakin 2025-12-06 20:34:49 +01:00 committed by GitHub
parent cebbc28912
commit 4421a5bf3c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
145 changed files with 73492 additions and 155 deletions

View file

@ -4,7 +4,23 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).
[0.36.1] - 2025-11-29
# [0.36.2] - Unreleased
## Fixed
- Heatmap and Fog of War now are moving correctly during map interactions. #1798
- Polyline crossing international date line now are rendered correctly. #1162
- Place popup tags parsing (MapLibre GL JS compatibility)
- Stats calculation should be faster now.
## Changed
- Points on the Map page are now loaded in chunks to improve performance and reduce memory consumption.
# [0.36.1] - 2025-11-29
## Fixed

View file

@ -73,7 +73,7 @@ Simply install one of the supported apps on your device and configure it to send
1. Clone the repository.
2. Run the following command to start the app:
```bash
docker-compose -f docker/docker-compose.yml up
docker compose -f docker/docker-compose.yml up
```
3. Access the app at `http://localhost:3000`.

File diff suppressed because one or more lines are too long

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-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-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-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

@ -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

@ -12,6 +12,23 @@ class Api::V1::PointsController < ApiController
points = current_api_user
.points
.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,
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

@ -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

@ -1,6 +1,6 @@
# frozen_string_literal: true
class MapController < ApplicationController
class Map::LeafletController < ApplicationController
before_action :authenticate_user!
layout 'map', only: :index

View file

@ -0,0 +1,33 @@
module Map
class MaplibreController < ApplicationController
before_action :authenticate_user!
layout 'map'
def index
@start_at = parsed_start_at
@end_at = parsed_end_at
end
private
def start_at
return Time.zone.parse(params[:start_at]).to_i if params[:start_at].present?
Time.zone.today.beginning_of_day.to_i
end
def end_at
return Time.zone.parse(params[:end_at]).to_i if params[:end_at].present?
Time.zone.today.end_of_day.to_i
end
def parsed_start_at
Time.zone.at(start_at)
end
def parsed_end_at
Time.zone.at(end_at)
end
end
end

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

@ -142,4 +142,11 @@ module ApplicationHelper
ALLOW_EMAIL_PASSWORD_REGISTRATION
end
def preferred_map_path
return map_v1_path unless user_signed_in?
preferred_version = current_user.safe_settings.maps&.dig('preferred_version')
preferred_version == 'v2' ? map_v2_path : map_v1_path
end
end

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,167 @@
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) => {
console.log('[Area Creation V2] area:drawn event received:', e.detail)
this.open(e.detail.center, e.detail.radius)
})
}
/**
* Open the modal with area data
*/
open(center, radius) {
console.log('[Area Creation V2] open() called with center:', center, 'radius:', radius)
// Store area data
this.area = { center, radius }
// 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) {
// You can replace this with a toast notification if available
console.log(message)
// Try to use the Toast component if available
if (window.Toast) {
window.Toast.show(message, 'success')
}
}
}

View file

@ -0,0 +1,152 @@
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) {
console.log('[Area Drawer] startDrawing called with map:', map)
if (!map) {
console.error('[Area Drawer] Map instance not provided')
return
}
console.log('[Area Drawer] Starting drawing mode')
this.isDrawing = true
this.map = map
map.getCanvas().style.cursor = 'crosshair'
// 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
console.log('[Area Drawer] First click - setting center:', e.lngLat)
this.center = [e.lngLat.lng, e.lngLat.lat]
} else {
// Second click - finish drawing
console.log('[Area Drawer] Second click - finishing drawing')
console.log('[Area Drawer] Dispatching area:drawn event')
document.dispatchEvent(new CustomEvent('area:drawn', {
detail: {
center: this.center,
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

@ -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,225 @@
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) {
this.api = api
this.apiKey = apiKey
}
/**
* 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)
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
try {
console.log('[Photos] Fetching photos from:', startDate, 'to', endDate)
data.photos = await this.api.fetchPhotos({
start_at: startDate,
end_at: endDate
})
console.log('[Photos] Fetched photos:', data.photos.length, 'photos')
console.log('[Photos] Sample photo:', data.photos[0])
} catch (error) {
console.error('[Photos] Failed to fetch photos:', error)
data.photos = []
}
data.photosGeoJSON = this.photosToGeoJSON(data.photos)
console.log('[Photos] Converted to GeoJSON:', data.photosGeoJSON.features.length, 'features')
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)}</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)
const endTime = formatTimestamp(properties.ended_at)
const durationHours = Math.round(properties.duration / 3600)
const durationDisplay = durationHours >= 1 ? `${durationHours}h` : `${Math.round(properties.duration / 60)}m`
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)}</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,279 @@
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
})
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: 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,66 @@
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
} = settings
const style = await getMapStyle(mapStyle)
const map = new maplibregl.Map({
container,
style,
center,
zoom
})
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,360 @@
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 || 500
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)
}
}

View file

@ -0,0 +1,271 @@
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 and localStorage)
*/
async loadSettings() {
this.settings = await SettingsManager.sync()
this.controller.settings = this.settings
console.log('[Maps V2] Settings loaded:', 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',
speedColoredToggle: 'speedColoredRoutesEnabled'
}
Object.entries(toggleMap).forEach(([targetName, settingKey]) => {
const target = `${targetName}Target`
if (controller[target]) {
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'
}
// 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 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
}
console.log('[Maps V2] UI controls synced with settings')
}
/**
* 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', () => {
console.log('Style loaded, reloading map data')
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()
}
}
/**
* 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 and localStorage
for (const [key, value] of Object.entries(settings)) {
await SettingsManager.updateSetting(key, value)
}
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 (fogLayer.visible) {
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,543 @@
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
}
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',
// Speed-colored routes
'routesOptions',
'speedColoredToggle',
'speedColorScaleContainer',
'speedColorScaleInput',
// 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.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
})
}
/**
* 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) }
// 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) }
// 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

@ -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

@ -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,151 @@
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
})
}
}

View file

@ -0,0 +1,140 @@
/**
* 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 = []
}
add(data) {
this.points = data.features || []
this.createCanvas()
if (this.visible) {
this.show()
}
this.render()
}
update(data) {
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.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,86 @@
import { BaseLayer } from './base_layer'
/**
* Heatmap layer showing point density
* Uses MapLibre's native heatmap for performance
* Fixed radius: 20 pixels
*/
export class HeatmapLayer extends BaseLayer {
constructor(map, options = {}) {
super(map, { id: 'heatmap', ...options })
this.radius = 20 // Fixed radius
this.weight = options.weight || 1
this.intensity = 1 // Fixed intensity
this.opacity = options.opacity || 0.6
}
getSourceConfig() {
return {
type: 'geojson',
data: this.data || {
type: 'FeatureCollection',
features: []
}
}
}
getLayerConfigs() {
return [
{
id: this.id,
type: 'heatmap',
source: this.sourceId,
paint: {
// Increase weight as diameter increases
'heatmap-weight': [
'interpolate',
['linear'],
['get', 'weight'],
0, 0,
6, 1
],
// Increase intensity as zoom increases
'heatmap-intensity': [
'interpolate',
['linear'],
['zoom'],
0, this.intensity,
9, this.intensity * 3
],
// Color ramp from blue to red
'heatmap-color': [
'interpolate',
['linear'],
['heatmap-density'],
0, 'rgba(33,102,172,0)',
0.2, 'rgb(103,169,207)',
0.4, 'rgb(209,229,240)',
0.6, 'rgb(253,219,199)',
0.8, 'rgb(239,138,98)',
1, 'rgb(178,24,43)'
],
// Fixed radius adjusted by zoom level
'heatmap-radius': [
'interpolate',
['linear'],
['zoom'],
0, this.radius,
9, this.radius * 3
],
// Transition from heatmap to circle layer by zoom level
'heatmap-opacity': [
'interpolate',
['linear'],
['zoom'],
7, this.opacity,
9, 0
]
}
}
]
}
}

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,37 @@
import { BaseLayer } from './base_layer'
/**
* Points layer for displaying individual location points
*/
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 [
// 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'
}
}
]
}
}

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,145 @@
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: {
'line-color': '#f97316', // Solid orange color
'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,361 @@
/**
* 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()
})
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}`
console.log('[ApiClient] Fetching photos from:', url)
console.log('[ApiClient] With headers:', this.getHeaders())
const response = await fetch(url, {
headers: this.getHeaders()
})
console.log('[ApiClient] Photos response status:', response.status)
if (!response.ok) {
throw new Error(`Failed to fetch photos: ${response.statusText}`)
}
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,54 @@
/**
* 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
* @returns {string} Formatted date/time
*/
export function formatTimestamp(timestamp) {
// 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'
})
}

View file

@ -0,0 +1,69 @@
/**
* Calculate distance between two points in meters
* @param {Array} point1 - [lng, lat]
* @param {Array} point2 - [lng, lat]
* @returns {number} Distance in meters
*/
export function calculateDistance(point1, point2) {
const [lng1, lat1] = point1
const [lng2, lat2] = point2
const R = 6371000 // Earth radius in meters
const φ1 = lat1 * Math.PI / 180
const φ2 = lat2 * Math.PI / 180
const Δφ = (lat2 - lat1) * Math.PI / 180
const Δλ = (lng2 - lng1) * Math.PI / 180
const a = Math.sin(Δφ / 2) * Math.sin(Δφ / 2) +
Math.cos(φ1) * Math.cos(φ2) *
Math.sin(Δλ / 2) * Math.sin(Δλ / 2)
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
return R * c
}
/**
* Create circle polygon
* @param {Array} center - [lng, lat]
* @param {number} radiusInMeters
* @param {number} points - Number of points in polygon
* @returns {Array} Coordinates array
*/
export function createCircle(center, radiusInMeters, points = 64) {
const [lng, lat] = center
const coords = []
const distanceX = radiusInMeters / (111320 * Math.cos(lat * Math.PI / 180))
const distanceY = radiusInMeters / 110540
for (let i = 0; i < points; i++) {
const theta = (i / points) * (2 * Math.PI)
const x = distanceX * Math.cos(theta)
const y = distanceY * Math.sin(theta)
coords.push([lng + x, lat + y])
}
coords.push(coords[0]) // Close the circle
return coords
}
/**
* Create rectangle from bounds
* @param {Object} bounds - { minLng, minLat, maxLng, maxLat }
* @returns {Array} Coordinates array
*/
export function createRectangle(bounds) {
const { minLng, minLat, maxLng, maxLat } = bounds
return [
[
[minLng, minLat],
[maxLng, minLat],
[maxLng, maxLat],
[minLng, maxLat],
[minLng, minLat]
]
]
}

View file

@ -0,0 +1,76 @@
/**
* Lazy loader for heavy map layers
* Reduces initial bundle size by loading layers on demand
*/
export class LazyLoader {
constructor() {
this.cache = new Map()
this.loading = new Map()
}
/**
* Load layer class dynamically
* @param {string} name - Layer name (e.g., 'fog', 'scratch')
* @returns {Promise<Class>}
*/
async loadLayer(name) {
// Return cached
if (this.cache.has(name)) {
return this.cache.get(name)
}
// Wait for loading
if (this.loading.has(name)) {
return this.loading.get(name)
}
// Start loading
const loadPromise = this.#load(name)
this.loading.set(name, loadPromise)
try {
const LayerClass = await loadPromise
this.cache.set(name, LayerClass)
this.loading.delete(name)
return LayerClass
} catch (error) {
this.loading.delete(name)
throw error
}
}
async #load(name) {
const paths = {
'fog': () => import('../layers/fog_layer.js'),
'scratch': () => import('../layers/scratch_layer.js')
}
const loader = paths[name]
if (!loader) {
throw new Error(`Unknown layer: ${name}`)
}
const module = await loader()
return module[this.#getClassName(name)]
}
#getClassName(name) {
// fog -> FogLayer, scratch -> ScratchLayer
return name.charAt(0).toUpperCase() + name.slice(1) + 'Layer'
}
/**
* Preload layers
* @param {string[]} names
*/
async preload(names) {
return Promise.all(names.map(name => this.loadLayer(name)))
}
clear() {
this.cache.clear()
this.loading.clear()
}
}
export const lazyLoader = new LazyLoader()

View file

@ -0,0 +1,108 @@
/**
* Performance monitoring utility
* Tracks timing metrics and memory usage
*/
export class PerformanceMonitor {
constructor() {
this.marks = new Map()
this.metrics = []
}
/**
* Start timing
* @param {string} name
*/
mark(name) {
this.marks.set(name, performance.now())
}
/**
* End timing and record
* @param {string} name
* @returns {number} Duration in ms
*/
measure(name) {
const startTime = this.marks.get(name)
if (!startTime) {
console.warn(`No mark found for: ${name}`)
return 0
}
const duration = performance.now() - startTime
this.marks.delete(name)
this.metrics.push({
name,
duration,
timestamp: Date.now()
})
return duration
}
/**
* Get performance report
* @returns {Object}
*/
getReport() {
const grouped = this.metrics.reduce((acc, metric) => {
if (!acc[metric.name]) {
acc[metric.name] = []
}
acc[metric.name].push(metric.duration)
return acc
}, {})
const report = {}
for (const [name, durations] of Object.entries(grouped)) {
const avg = durations.reduce((a, b) => a + b, 0) / durations.length
const min = Math.min(...durations)
const max = Math.max(...durations)
report[name] = {
count: durations.length,
avg: Math.round(avg),
min: Math.round(min),
max: Math.round(max)
}
}
return report
}
/**
* Get memory usage
* @returns {Object|null}
*/
getMemoryUsage() {
if (!performance.memory) return null
return {
used: Math.round(performance.memory.usedJSHeapSize / 1048576),
total: Math.round(performance.memory.totalJSHeapSize / 1048576),
limit: Math.round(performance.memory.jsHeapSizeLimit / 1048576)
}
}
/**
* Log report to console
*/
logReport() {
console.group('Performance Report')
console.table(this.getReport())
const memory = this.getMemoryUsage()
if (memory) {
console.log(`Memory: ${memory.used}MB / ${memory.total}MB (limit: ${memory.limit}MB)`)
}
console.groupEnd()
}
clear() {
this.marks.clear()
this.metrics = []
}
}
export const performanceMonitor = new PerformanceMonitor()

View file

@ -0,0 +1,120 @@
/**
* Theme utilities for MapLibre popups
* Provides consistent theming across all popup types
*/
/**
* Get current theme from document
* @returns {string} 'dark' or 'light'
*/
export function getCurrentTheme() {
if (document.documentElement.getAttribute('data-theme') === 'dark' ||
document.documentElement.classList.contains('dark')) {
return 'dark'
}
return 'light'
}
/**
* Get theme-aware color values
* @param {string} theme - 'dark' or 'light'
* @returns {Object} Color values for the theme
*/
export function getThemeColors(theme = getCurrentTheme()) {
if (theme === 'dark') {
return {
// Background colors
background: '#1f2937',
backgroundAlt: '#374151',
// Text colors
textPrimary: '#f9fafb',
textSecondary: '#d1d5db',
textMuted: '#9ca3af',
// Border colors
border: '#4b5563',
borderLight: '#374151',
// Accent colors
accent: '#3b82f6',
accentHover: '#2563eb',
// Badge colors
badgeSuggested: { bg: '#713f12', text: '#fef3c7' },
badgeConfirmed: { bg: '#065f46', text: '#d1fae5' }
}
} else {
return {
// Background colors
background: '#ffffff',
backgroundAlt: '#f9fafb',
// Text colors
textPrimary: '#111827',
textSecondary: '#374151',
textMuted: '#6b7280',
// Border colors
border: '#e5e7eb',
borderLight: '#f3f4f6',
// Accent colors
accent: '#3b82f6',
accentHover: '#2563eb',
// Badge colors
badgeSuggested: { bg: '#fef3c7', text: '#92400e' },
badgeConfirmed: { bg: '#d1fae5', text: '#065f46' }
}
}
}
/**
* Get base popup styles as inline CSS
* @param {string} theme - 'dark' or 'light'
* @returns {string} CSS string for inline styles
*/
export function getPopupBaseStyles(theme = getCurrentTheme()) {
const colors = getThemeColors(theme)
return `
font-family: system-ui, -apple-system, sans-serif;
background-color: ${colors.background};
color: ${colors.textPrimary};
`
}
/**
* Get popup container class with theme
* @param {string} baseClass - Base CSS class name
* @param {string} theme - 'dark' or 'light'
* @returns {string} Class name with theme
*/
export function getPopupClass(baseClass, theme = getCurrentTheme()) {
return `${baseClass} ${baseClass}--${theme}`
}
/**
* Listen for theme changes and update popup if needed
* @param {Function} callback - Callback to execute on theme change
* @returns {Function} Cleanup function to remove listener
*/
export function onThemeChange(callback) {
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'attributes' &&
(mutation.attributeName === 'data-theme' ||
mutation.attributeName === 'class')) {
callback(getCurrentTheme())
}
})
})
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme', 'class']
})
return () => observer.disconnect()
}

View file

@ -0,0 +1,101 @@
/**
* Progressive loader for large datasets
* Loads data in chunks with progress feedback and abort capability
*/
export class ProgressiveLoader {
constructor(options = {}) {
this.onProgress = options.onProgress || null
this.onComplete = options.onComplete || null
this.abortController = null
}
/**
* Load data progressively
* @param {Function} fetchFn - Function that fetches one page
* @param {Object} options - { batchSize, maxConcurrent, maxPoints }
* @returns {Promise<Array>}
*/
async load(fetchFn, options = {}) {
const {
batchSize = 1000,
maxConcurrent = 3,
maxPoints = 100000 // Limit for safety
} = options
this.abortController = new AbortController()
const allData = []
let page = 1
let totalPages = 1
const activeRequests = []
try {
do {
// Check abort
if (this.abortController.signal.aborted) {
throw new Error('Load cancelled')
}
// Check max points limit
if (allData.length >= maxPoints) {
console.warn(`Reached max points limit: ${maxPoints}`)
break
}
// Limit concurrent requests
while (activeRequests.length >= maxConcurrent) {
await Promise.race(activeRequests)
}
const requestPromise = fetchFn({
page,
per_page: batchSize,
signal: this.abortController.signal
}).then(result => {
allData.push(...result.data)
if (result.totalPages) {
totalPages = result.totalPages
}
this.onProgress?.({
loaded: allData.length,
total: Math.min(totalPages * batchSize, maxPoints),
currentPage: page,
totalPages,
progress: page / totalPages
})
// Remove from active
const idx = activeRequests.indexOf(requestPromise)
if (idx > -1) activeRequests.splice(idx, 1)
return result
})
activeRequests.push(requestPromise)
page++
} while (page <= totalPages && allData.length < maxPoints)
// Wait for remaining
await Promise.all(activeRequests)
this.onComplete?.(allData)
return allData
} catch (error) {
if (error.name === 'AbortError' || error.message === 'Load cancelled') {
console.log('Progressive load cancelled')
return allData // Return partial data
}
throw error
}
}
/**
* Cancel loading
*/
cancel() {
this.abortController?.abort()
}
}

View file

@ -0,0 +1,729 @@
/**
* Search Manager
* Manages location search functionality for Maps V2
*/
import { LocationSearchService } from '../services/location_search_service.js'
export class SearchManager {
constructor(map, apiKey) {
this.map = map
this.service = new LocationSearchService(apiKey)
this.searchInput = null
this.resultsContainer = null
this.debounceTimer = null
this.debounceDelay = 300 // ms
this.currentMarker = null
this.currentVisitsData = null // Store visits data for click handling
}
/**
* Initialize search manager with DOM elements
* @param {HTMLInputElement} searchInput - Search input element
* @param {HTMLElement} resultsContainer - Container for search results
*/
initialize(searchInput, resultsContainer) {
this.searchInput = searchInput
this.resultsContainer = resultsContainer
if (!this.searchInput || !this.resultsContainer) {
console.warn('SearchManager: Missing required DOM elements')
return
}
this.attachEventListeners()
}
/**
* Attach event listeners to search input
*/
attachEventListeners() {
// Input event with debouncing
this.searchInput.addEventListener('input', (e) => {
this.handleSearchInput(e.target.value)
})
// Prevent results from hiding when clicking inside results container
this.resultsContainer.addEventListener('mousedown', (e) => {
e.preventDefault() // Prevent blur event on search input
})
// Clear results when clicking outside
document.addEventListener('click', (e) => {
if (!this.searchInput.contains(e.target) && !this.resultsContainer.contains(e.target)) {
// Delay to allow animations to complete
setTimeout(() => {
this.clearResults()
}, 100)
}
})
// Handle Enter key
this.searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault()
const firstResult = this.resultsContainer.querySelector('.search-result-item')
if (firstResult) {
firstResult.click()
}
}
})
}
/**
* Handle search input with debouncing
* @param {string} query - Search query
*/
handleSearchInput(query) {
clearTimeout(this.debounceTimer)
if (!query || query.length < 2) {
this.clearResults()
return
}
this.debounceTimer = setTimeout(async () => {
try {
this.showLoading()
const suggestions = await this.service.fetchSuggestions(query)
this.displayResults(suggestions)
} catch (error) {
this.showError('Failed to fetch suggestions')
console.error('SearchManager: Search error:', error)
}
}, this.debounceDelay)
}
/**
* Display search results
* @param {Array} suggestions - Array of location suggestions
*/
displayResults(suggestions) {
this.clearResults()
if (!suggestions || suggestions.length === 0) {
this.showNoResults()
return
}
suggestions.forEach(suggestion => {
const resultItem = this.createResultItem(suggestion)
this.resultsContainer.appendChild(resultItem)
})
this.resultsContainer.classList.remove('hidden')
}
/**
* Create a result item element
* @param {Object} suggestion - Location suggestion
* @returns {HTMLElement} Result item element
*/
createResultItem(suggestion) {
const item = document.createElement('div')
item.className = 'search-result-item p-3 hover:bg-base-200 cursor-pointer rounded-lg transition-colors'
item.setAttribute('data-lat', suggestion.lat)
item.setAttribute('data-lon', suggestion.lon)
const name = document.createElement('div')
name.className = 'font-medium text-sm'
name.textContent = suggestion.name || 'Unknown location'
if (suggestion.address) {
const address = document.createElement('div')
address.className = 'text-xs text-base-content/60 mt-1'
address.textContent = suggestion.address
item.appendChild(name)
item.appendChild(address)
} else {
item.appendChild(name)
}
item.addEventListener('click', () => {
this.handleResultClick(suggestion)
})
return item
}
/**
* Handle click on search result
* @param {Object} location - Selected location
*/
async handleResultClick(location) {
// Fly to location on map
this.map.flyTo({
center: [location.lon, location.lat],
zoom: 15,
duration: 1000
})
// Add temporary marker
this.addSearchMarker(location.lon, location.lat)
// Update search input
if (this.searchInput) {
this.searchInput.value = location.name || ''
}
// Show loading state in results
this.showVisitsLoading(location.name)
// Search for visits at this location
try {
const visitsData = await this.service.searchVisits({
lat: location.lat,
lon: location.lon,
name: location.name,
address: location.address || ''
})
// Display visits results
this.displayVisitsResults(visitsData, location)
} catch (error) {
console.error('SearchManager: Failed to fetch visits:', error)
this.showError('Failed to load visits for this location')
}
// Dispatch custom event for other components
this.dispatchSearchEvent(location)
}
/**
* Add a temporary marker at search location
* @param {number} lon - Longitude
* @param {number} lat - Latitude
*/
addSearchMarker(lon, lat) {
// Remove existing marker
if (this.currentMarker) {
this.currentMarker.remove()
}
// Create marker element
const el = document.createElement('div')
el.className = 'search-marker'
el.style.cssText = `
width: 30px;
height: 30px;
background-color: #3b82f6;
border: 3px solid white;
border-radius: 50%;
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
cursor: pointer;
`
// Add marker to map (MapLibre GL style)
if (this.map.getSource) {
// Use MapLibre marker
const maplibregl = window.maplibregl
if (maplibregl) {
this.currentMarker = new maplibregl.Marker({ element: el })
.setLngLat([lon, lat])
.addTo(this.map)
}
}
}
/**
* Dispatch custom search event
* @param {Object} location - Selected location
*/
dispatchSearchEvent(location) {
const event = new CustomEvent('location-search:selected', {
detail: { location },
bubbles: true
})
document.dispatchEvent(event)
}
/**
* Show loading indicator
*/
showLoading() {
this.clearResults()
this.resultsContainer.innerHTML = `
<div class="p-3 text-sm text-base-content/60 flex items-center gap-2">
<span class="loading loading-spinner loading-sm"></span>
Searching...
</div>
`
this.resultsContainer.classList.remove('hidden')
}
/**
* Show no results message
*/
showNoResults() {
this.resultsContainer.innerHTML = `
<div class="p-3 text-sm text-base-content/60">
No locations found
</div>
`
this.resultsContainer.classList.remove('hidden')
}
/**
* Show error message
* @param {string} message - Error message
*/
showError(message) {
this.resultsContainer.innerHTML = `
<div class="p-3 text-sm text-error">
${message}
</div>
`
this.resultsContainer.classList.remove('hidden')
}
/**
* Show loading state while fetching visits
* @param {string} locationName - Name of the location being searched
*/
showVisitsLoading(locationName) {
this.resultsContainer.innerHTML = `
<div class="p-4 text-sm text-base-content/60">
<div class="flex items-center gap-2 mb-2">
<span class="loading loading-spinner loading-sm"></span>
<span class="font-medium">Searching for visits...</span>
</div>
<div class="text-xs">${this.escapeHtml(locationName)}</div>
</div>
`
this.resultsContainer.classList.remove('hidden')
}
/**
* Display visits results
* @param {Object} visitsData - Visits data from API
* @param {Object} location - Selected location
*/
displayVisitsResults(visitsData, location) {
// Store visits data for click handling
this.currentVisitsData = visitsData
if (!visitsData.locations || visitsData.locations.length === 0) {
this.resultsContainer.innerHTML = `
<div class="p-6 text-center text-base-content/60">
<div class="text-3xl mb-3">📍</div>
<div class="text-sm font-medium">No visits found</div>
<div class="text-xs mt-1">No visits found for "${this.escapeHtml(location.name)}"</div>
</div>
`
this.resultsContainer.classList.remove('hidden')
return
}
// Display visits grouped by location
let html = `
<div class="p-4 border-b bg-base-200">
<div class="text-sm font-medium">Found ${visitsData.total_locations} location(s)</div>
<div class="text-xs text-base-content/60 mt-1">for "${this.escapeHtml(location.name)}"</div>
</div>
`
visitsData.locations.forEach((loc, index) => {
html += this.buildLocationVisitsHtml(loc, index)
})
this.resultsContainer.innerHTML = html
this.resultsContainer.classList.remove('hidden')
// Attach event listeners to year toggles and visit items
this.attachYearToggleListeners()
}
/**
* Build HTML for a location with its visits
* @param {Object} location - Location with visits
* @param {number} index - Location index
* @returns {string} HTML string
*/
buildLocationVisitsHtml(location, index) {
const visits = location.visits || []
if (visits.length === 0) return ''
// Handle case where visits are sorted newest first
const sortedVisits = [...visits].sort((a, b) => new Date(a.date) - new Date(b.date))
const firstVisit = sortedVisits[0]
const lastVisit = sortedVisits[sortedVisits.length - 1]
const visitsByYear = this.groupVisitsByYear(visits)
// Use place_name, address, or coordinates as fallback
const displayName = location.place_name || location.address ||
`Location (${location.coordinates?.[0]?.toFixed(4)}, ${location.coordinates?.[1]?.toFixed(4)})`
return `
<div class="location-result border-b" data-location-index="${index}">
<div class="p-4">
<div class="font-medium text-sm">${this.escapeHtml(displayName)}</div>
${location.address && location.place_name !== location.address ?
`<div class="text-xs text-base-content/60 mt-1">${this.escapeHtml(location.address)}</div>` : ''}
<div class="flex justify-between items-center mt-3">
<div class="text-xs text-primary">${location.total_visits} visit(s)</div>
<div class="text-xs text-base-content/60">
first ${this.formatDateShort(firstVisit.date)}, last ${this.formatDateShort(lastVisit.date)}
</div>
</div>
</div>
<!-- Years Section -->
<div class="border-t bg-base-200">
${Object.entries(visitsByYear).map(([year, yearVisits]) => `
<div class="year-section">
<div class="year-toggle p-3 hover:bg-base-300 cursor-pointer border-b flex justify-between items-center"
data-location-index="${index}" data-year="${year}">
<span class="text-sm font-medium">${year}</span>
<div class="flex items-center gap-2">
<span class="text-xs text-primary">${yearVisits.length} visits</span>
<span class="year-arrow text-base-content/40 transition-transform"></span>
</div>
</div>
<div class="year-visits hidden" id="year-${index}-${year}">
${yearVisits.map((visit) => `
<div class="visit-item text-xs py-2 px-4 border-b hover:bg-base-300 cursor-pointer"
data-location-index="${index}" data-visit-index="${visits.indexOf(visit)}">
<div class="flex justify-between items-start">
<div>📍 ${this.formatDateTime(visit.date)}</div>
<div class="text-xs text-base-content/60">${visit.duration_estimate || 'N/A'}</div>
</div>
</div>
`).join('')}
</div>
</div>
`).join('')}
</div>
</div>
`
}
/**
* Group visits by year
* @param {Array} visits - Array of visits
* @returns {Object} Visits grouped by year
*/
groupVisitsByYear(visits) {
const groups = {}
visits.forEach(visit => {
const year = new Date(visit.date).getFullYear().toString()
if (!groups[year]) {
groups[year] = []
}
groups[year].push(visit)
})
return groups
}
/**
* Attach event listeners to year toggle elements
*/
attachYearToggleListeners() {
const toggles = this.resultsContainer.querySelectorAll('.year-toggle')
toggles.forEach(toggle => {
toggle.addEventListener('click', (e) => {
const locationIndex = e.currentTarget.dataset.locationIndex
const year = e.currentTarget.dataset.year
const visitsContainer = document.getElementById(`year-${locationIndex}-${year}`)
const arrow = e.currentTarget.querySelector('.year-arrow')
if (visitsContainer) {
visitsContainer.classList.toggle('hidden')
arrow.style.transform = visitsContainer.classList.contains('hidden') ? 'rotate(0deg)' : 'rotate(90deg)'
}
})
})
// Attach event listeners to individual visit items
const visitItems = this.resultsContainer.querySelectorAll('.visit-item')
visitItems.forEach(item => {
item.addEventListener('click', (e) => {
e.stopPropagation()
const locationIndex = parseInt(item.dataset.locationIndex)
const visitIndex = parseInt(item.dataset.visitIndex)
this.handleVisitClick(locationIndex, visitIndex)
})
})
}
/**
* Handle click on individual visit item
* @param {number} locationIndex - Index of location in results
* @param {number} visitIndex - Index of visit within location
*/
handleVisitClick(locationIndex, visitIndex) {
if (!this.currentVisitsData || !this.currentVisitsData.locations) return
const location = this.currentVisitsData.locations[locationIndex]
if (!location || !location.visits) return
const visit = location.visits[visitIndex]
if (!visit) return
// Fly to visit coordinates (more precise than location coordinates)
const [lat, lon] = visit.coordinates || location.coordinates
this.map.flyTo({
center: [lon, lat],
zoom: 18,
duration: 1000
})
// Extract visit details
const visitDetails = visit.visit_details || {}
const startTime = visitDetails.start_time || visit.date
const endTime = visitDetails.end_time || visit.date
const placeName = location.place_name || location.address || 'Unnamed Location'
// Open create visit modal
this.openCreateVisitModal({
name: placeName,
latitude: lat,
longitude: lon,
started_at: startTime,
ended_at: endTime
})
}
/**
* Open modal to create a visit with prefilled data
* @param {Object} visitData - Visit data to prefill
*/
openCreateVisitModal(visitData) {
// Create modal HTML
const modalId = 'create-visit-modal'
// Remove existing modal if present
const existingModal = document.getElementById(modalId)
if (existingModal) {
existingModal.remove()
}
const modal = document.createElement('div')
modal.id = modalId
modal.innerHTML = `
<input type="checkbox" id="${modalId}-toggle" class="modal-toggle" checked />
<div class="modal" role="dialog">
<div class="modal-box">
<h3 class="text-lg font-bold mb-4">Create Visit</h3>
<form id="${modalId}-form">
<div class="form-control mb-4">
<label class="label">
<span class="label-text">Name</span>
</label>
<input type="text" name="name" class="input input-bordered w-full"
value="${this.escapeHtml(visitData.name)}" required />
</div>
<div class="form-control mb-4">
<label class="label">
<span class="label-text">Start Time</span>
</label>
<input type="datetime-local" name="started_at" class="input input-bordered w-full"
value="${this.formatDateTimeForInput(visitData.started_at)}" required />
</div>
<div class="form-control mb-4">
<label class="label">
<span class="label-text">End Time</span>
</label>
<input type="datetime-local" name="ended_at" class="input input-bordered w-full"
value="${this.formatDateTimeForInput(visitData.ended_at)}" required />
</div>
<input type="hidden" name="latitude" value="${visitData.latitude}" />
<input type="hidden" name="longitude" value="${visitData.longitude}" />
<div class="modal-action">
<button type="button" class="btn" data-action="close">Cancel</button>
<button type="submit" class="btn btn-primary">
<span class="submit-text">Create Visit</span>
<span class="loading loading-spinner loading-sm hidden"></span>
</button>
</div>
</form>
</div>
<label class="modal-backdrop" for="${modalId}-toggle"></label>
</div>
`
document.body.appendChild(modal)
// Attach event listeners
const form = modal.querySelector('form')
const closeBtn = modal.querySelector('[data-action="close"]')
const modalToggle = modal.querySelector(`#${modalId}-toggle`)
const backdrop = modal.querySelector('.modal-backdrop')
form.addEventListener('submit', (e) => {
e.preventDefault()
this.submitCreateVisit(form, modal)
})
closeBtn.addEventListener('click', () => {
modalToggle.checked = false
setTimeout(() => modal.remove(), 300)
})
backdrop.addEventListener('click', () => {
modalToggle.checked = false
setTimeout(() => modal.remove(), 300)
})
}
/**
* Submit create visit form
* @param {HTMLFormElement} form - Form element
* @param {HTMLElement} modal - Modal element
*/
async submitCreateVisit(form, modal) {
const submitBtn = form.querySelector('button[type="submit"]')
const submitText = submitBtn.querySelector('.submit-text')
const spinner = submitBtn.querySelector('.loading')
// Disable submit button and show loading
submitBtn.disabled = true
submitText.classList.add('hidden')
spinner.classList.remove('hidden')
try {
const formData = new FormData(form)
const visitData = {
name: formData.get('name'),
latitude: parseFloat(formData.get('latitude')),
longitude: parseFloat(formData.get('longitude')),
started_at: formData.get('started_at'),
ended_at: formData.get('ended_at'),
status: 'confirmed'
}
const response = await this.service.createVisit(visitData)
if (response.error) {
throw new Error(response.error)
}
// Success - close modal and show success message
const modalToggle = modal.querySelector('.modal-toggle')
modalToggle.checked = false
setTimeout(() => modal.remove(), 300)
// Show success notification
this.showSuccessNotification('Visit created successfully!')
// Dispatch custom event for other components to react
document.dispatchEvent(new CustomEvent('visit:created', {
detail: { visit: response, coordinates: [visitData.longitude, visitData.latitude] }
}))
} catch (error) {
console.error('Failed to create visit:', error)
alert(`Failed to create visit: ${error.message}`)
// Re-enable submit button
submitBtn.disabled = false
submitText.classList.remove('hidden')
spinner.classList.add('hidden')
}
}
/**
* Show success notification
* @param {string} message - Success message
*/
showSuccessNotification(message) {
const notification = document.createElement('div')
notification.className = 'toast toast-top toast-end z-[9999]'
notification.innerHTML = `
<div class="alert alert-success">
<span> ${this.escapeHtml(message)}</span>
</div>
`
document.body.appendChild(notification)
setTimeout(() => {
notification.remove()
}, 3000)
}
/**
* Format datetime for input field (YYYY-MM-DDTHH:MM)
* @param {string} dateString - Date string
* @returns {string} Formatted datetime
*/
formatDateTimeForInput(dateString) {
const date = new Date(dateString)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
return `${year}-${month}-${day}T${hours}:${minutes}`
}
/**
* Format date in short format
* @param {string} dateString - Date string
* @returns {string} Formatted date
*/
formatDateShort(dateString) {
const date = new Date(dateString)
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
}
/**
* Format date and time
* @param {string} dateString - Date string
* @returns {string} Formatted date and time
*/
formatDateTime(dateString) {
const date = new Date(dateString)
return date.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
/**
* Escape HTML to prevent XSS
* @param {string} str - String to escape
* @returns {string} Escaped string
*/
escapeHtml(str) {
if (!str) return ''
const div = document.createElement('div')
div.textContent = str
return div.innerHTML
}
/**
* Clear search results
*/
clearResults() {
if (this.resultsContainer) {
this.resultsContainer.innerHTML = ''
this.resultsContainer.classList.add('hidden')
}
}
/**
* Clear search marker
*/
clearMarker() {
if (this.currentMarker) {
this.currentMarker.remove()
this.currentMarker = null
}
}
/**
* Cleanup
*/
destroy() {
clearTimeout(this.debounceTimer)
this.clearMarker()
this.clearResults()
}
}

View file

@ -0,0 +1,296 @@
/**
* Settings manager for persisting user preferences
* Supports both localStorage (fallback) and backend API (primary)
*/
const STORAGE_KEY = 'dawarich-maps-maplibre-settings'
const DEFAULT_SETTINGS = {
mapStyle: 'light',
enabledMapLayers: ['Points', 'Routes'], // Compatible with v1 map
// Advanced settings
routeOpacity: 1.0,
fogOfWarRadius: 1000,
fogOfWarThreshold: 1,
metersBetweenRoutes: 500,
minutesBetweenRoutes: 60,
pointsRenderingMode: 'raw',
speedColoredRoutes: false
}
// Mapping between v2 layer names and v1 layer names in enabled_map_layers array
const LAYER_NAME_MAP = {
'Points': 'pointsVisible',
'Routes': 'routesVisible',
'Heatmap': 'heatmapEnabled',
'Visits': 'visitsEnabled',
'Photos': 'photosEnabled',
'Areas': 'areasEnabled',
'Tracks': 'tracksEnabled',
'Fog of War': 'fogEnabled',
'Scratch map': 'scratchEnabled'
}
// Mapping between frontend settings and backend API keys
const BACKEND_SETTINGS_MAP = {
mapStyle: 'maps_maplibre_style',
enabledMapLayers: 'enabled_map_layers'
}
export class SettingsManager {
static apiKey = null
static cachedSettings = null
/**
* Initialize settings manager with API key
* @param {string} apiKey - User's API key for backend requests
*/
static initialize(apiKey) {
this.apiKey = apiKey
this.cachedSettings = null // Clear cache on initialization
}
/**
* Get all settings (localStorage first, then merge with defaults)
* Converts enabled_map_layers array to individual boolean flags
* Uses cached settings if available to avoid race conditions
* @returns {Object} Settings object
*/
static getSettings() {
// Return cached settings if available
if (this.cachedSettings) {
return { ...this.cachedSettings }
}
try {
const stored = localStorage.getItem(STORAGE_KEY)
const settings = stored ? { ...DEFAULT_SETTINGS, ...JSON.parse(stored) } : DEFAULT_SETTINGS
// Convert enabled_map_layers array to individual boolean flags
const expandedSettings = this._expandLayerSettings(settings)
// Cache the settings
this.cachedSettings = expandedSettings
return { ...expandedSettings }
} catch (error) {
console.error('Failed to load settings:', error)
return DEFAULT_SETTINGS
}
}
/**
* Convert enabled_map_layers array to individual boolean flags
* @param {Object} settings - Settings with enabledMapLayers array
* @returns {Object} Settings with individual layer booleans
*/
static _expandLayerSettings(settings) {
const enabledLayers = settings.enabledMapLayers || []
// Set boolean flags based on array contents
Object.entries(LAYER_NAME_MAP).forEach(([layerName, settingKey]) => {
settings[settingKey] = enabledLayers.includes(layerName)
})
return settings
}
/**
* Convert individual boolean flags to enabled_map_layers array
* @param {Object} settings - Settings with individual layer booleans
* @returns {Array} Array of enabled layer names
*/
static _collapseLayerSettings(settings) {
const enabledLayers = []
Object.entries(LAYER_NAME_MAP).forEach(([layerName, settingKey]) => {
if (settings[settingKey] === true) {
enabledLayers.push(layerName)
}
})
return enabledLayers
}
/**
* Load settings from backend API
* @returns {Promise<Object>} Settings object from backend
*/
static async loadFromBackend() {
if (!this.apiKey) {
console.warn('[Settings] API key not set, cannot load from backend')
return null
}
try {
const response = await fetch('/api/v1/settings', {
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json'
}
})
if (!response.ok) {
throw new Error(`Failed to load settings: ${response.status}`)
}
const data = await response.json()
const backendSettings = data.settings
// Convert backend settings to frontend format
const frontendSettings = {}
Object.entries(BACKEND_SETTINGS_MAP).forEach(([frontendKey, backendKey]) => {
if (backendKey in backendSettings) {
frontendSettings[frontendKey] = backendSettings[backendKey]
}
})
// Merge with defaults, but prioritize backend's enabled_map_layers completely
const mergedSettings = { ...DEFAULT_SETTINGS, ...frontendSettings }
// If backend has enabled_map_layers, use it as-is (don't merge with defaults)
if (backendSettings.enabled_map_layers) {
mergedSettings.enabledMapLayers = backendSettings.enabled_map_layers
}
// Convert enabled_map_layers array to individual boolean flags
const expandedSettings = this._expandLayerSettings(mergedSettings)
// Save to localStorage and cache
this.saveToLocalStorage(expandedSettings)
return expandedSettings
} catch (error) {
console.error('[Settings] Failed to load from backend:', error)
return null
}
}
/**
* Save all settings to localStorage and update cache
* @param {Object} settings - Settings object
*/
static saveToLocalStorage(settings) {
try {
// Update cache first
this.cachedSettings = { ...settings }
// Then save to localStorage
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings))
} catch (error) {
console.error('Failed to save settings to localStorage:', error)
}
}
/**
* Save settings to backend API
* @param {Object} settings - Settings to save
* @returns {Promise<boolean>} Success status
*/
static async saveToBackend(settings) {
if (!this.apiKey) {
console.warn('[Settings] API key not set, cannot save to backend')
return false
}
try {
// Convert individual layer booleans to enabled_map_layers array
const enabledMapLayers = this._collapseLayerSettings(settings)
// Convert frontend settings to backend format
const backendSettings = {}
Object.entries(BACKEND_SETTINGS_MAP).forEach(([frontendKey, backendKey]) => {
if (frontendKey === 'enabledMapLayers') {
// Use the collapsed array
backendSettings[backendKey] = enabledMapLayers
} else if (frontendKey in settings) {
backendSettings[backendKey] = settings[frontendKey]
}
})
const response = await fetch('/api/v1/settings', {
method: 'PATCH',
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ settings: backendSettings })
})
if (!response.ok) {
throw new Error(`Failed to save settings: ${response.status}`)
}
console.log('[Settings] Saved to backend successfully:', backendSettings)
return true
} catch (error) {
console.error('[Settings] Failed to save to backend:', error)
return false
}
}
/**
* Get a specific setting
* @param {string} key - Setting key
* @returns {*} Setting value
*/
static getSetting(key) {
return this.getSettings()[key]
}
/**
* Update a specific setting (saves to both localStorage and backend)
* @param {string} key - Setting key
* @param {*} value - New value
*/
static async updateSetting(key, value) {
const settings = this.getSettings()
settings[key] = value
// If this is a layer visibility setting, also update the enabledMapLayers array
// This ensures the array is in sync before backend save
const isLayerSetting = Object.values(LAYER_NAME_MAP).includes(key)
if (isLayerSetting) {
settings.enabledMapLayers = this._collapseLayerSettings(settings)
}
// Save to localStorage immediately
this.saveToLocalStorage(settings)
// Save to backend (non-blocking)
this.saveToBackend(settings).catch(error => {
console.warn('[Settings] Backend save failed, but localStorage updated:', error)
})
}
/**
* Reset to defaults
*/
static resetToDefaults() {
try {
localStorage.removeItem(STORAGE_KEY)
this.cachedSettings = null // Clear cache
// Also reset on backend
if (this.apiKey) {
this.saveToBackend(DEFAULT_SETTINGS).catch(error => {
console.warn('[Settings] Failed to reset backend settings:', error)
})
}
} catch (error) {
console.error('Failed to reset settings:', error)
}
}
/**
* Sync settings: load from backend and merge with localStorage
* Call this on app initialization
* @returns {Promise<Object>} Merged settings
*/
static async sync() {
const backendSettings = await this.loadFromBackend()
if (backendSettings) {
return backendSettings
}
return this.getSettings()
}
}

View file

@ -0,0 +1,140 @@
/**
* Speed color utilities for route visualization
* Provides speed calculation and color interpolation for route segments
*/
// Default color stops for speed visualization
export const colorStopsFallback = [
{ speed: 0, color: '#00ff00' }, // Stationary/very slow (green)
{ speed: 15, color: '#00ffff' }, // Walking/jogging (cyan)
{ speed: 30, color: '#ff00ff' }, // Cycling/slow driving (magenta)
{ speed: 50, color: '#ffff00' }, // Urban driving (yellow)
{ speed: 100, color: '#ff3300' } // Highway driving (red)
]
/**
* Encode color stops array to string format for storage
* @param {Array} arr - Array of {speed, color} objects
* @returns {string} Encoded string (e.g., "0:#00ff00|15:#00ffff")
*/
export function colorFormatEncode(arr) {
return arr.map(item => `${item.speed}:${item.color}`).join('|')
}
/**
* Decode color stops string to array format
* @param {string} str - Encoded color stops string
* @returns {Array} Array of {speed, color} objects
*/
export function colorFormatDecode(str) {
return str.split('|').map(segment => {
const [speed, color] = segment.split(':')
return { speed: Number(speed), color }
})
}
/**
* Convert hex color to RGB object
* @param {string} hex - Hex color (e.g., "#ff0000")
* @returns {Object} RGB object {r, g, b}
*/
function hexToRGB(hex) {
const r = parseInt(hex.slice(1, 3), 16)
const g = parseInt(hex.slice(3, 5), 16)
const b = parseInt(hex.slice(5, 7), 16)
return { r, g, b }
}
/**
* Calculate speed between two points
* @param {Object} point1 - First point with lat, lon, timestamp
* @param {Object} point2 - Second point with lat, lon, timestamp
* @returns {number} Speed in km/h
*/
export function calculateSpeed(point1, point2) {
if (!point1 || !point2 || !point1.timestamp || !point2.timestamp) {
return 0
}
const distanceKm = haversineDistance(
point1.latitude, point1.longitude,
point2.latitude, point2.longitude
)
const timeDiffSeconds = point2.timestamp - point1.timestamp
// Handle edge cases
if (timeDiffSeconds <= 0 || distanceKm <= 0) {
return 0
}
const speedKmh = (distanceKm / timeDiffSeconds) * 3600
// Cap speed at reasonable maximum (150 km/h)
const MAX_SPEED = 150
return Math.min(speedKmh, MAX_SPEED)
}
/**
* Calculate haversine distance between two points
* @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
*/
function 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
}
/**
* Get color for a given speed with interpolation
* @param {number} speedKmh - Speed in km/h
* @param {boolean} useSpeedColors - Whether to use speed-based coloring
* @param {string} speedColorScale - Encoded color scale string
* @returns {string} RGB color string (e.g., "rgb(255, 0, 0)")
*/
export function getSpeedColor(speedKmh, useSpeedColors, speedColorScale) {
if (!useSpeedColors) {
return '#f97316' // Default orange color
}
let colorStops
try {
colorStops = colorFormatDecode(speedColorScale).map(stop => ({
...stop,
rgb: hexToRGB(stop.color)
}))
} catch (error) {
// If user has given invalid values, use fallback
colorStops = colorStopsFallback.map(stop => ({
...stop,
rgb: hexToRGB(stop.color)
}))
}
// Find the appropriate color segment and interpolate
for (let i = 1; i < colorStops.length; i++) {
if (speedKmh <= colorStops[i].speed) {
const ratio = (speedKmh - colorStops[i-1].speed) / (colorStops[i].speed - colorStops[i-1].speed)
const color1 = colorStops[i-1].rgb
const color2 = colorStops[i].rgb
const r = Math.round(color1.r + (color2.r - color1.r) * ratio)
const g = Math.round(color1.g + (color2.g - color1.g) * ratio)
const b = Math.round(color1.b + (color2.b - color1.b) * ratio)
return `rgb(${r}, ${g}, ${b})`
}
}
// If speed exceeds all stops, return the last color
return colorStops[colorStops.length - 1].color
}

View file

@ -0,0 +1,113 @@
/**
* Style Manager for MapLibre GL styles
* Loads and configures local map styles with dynamic tile source
*/
const TILE_SOURCE_URL = 'https://tyles.dwri.xyz/planet/{z}/{x}/{y}.mvt'
// Cache for loaded styles
const styleCache = {}
/**
* Available map styles
*/
export const MAP_STYLES = {
dark: 'dark',
light: 'light',
white: 'white',
black: 'black',
grayscale: 'grayscale'
}
/**
* Load a style JSON file via fetch
* @param {string} styleName - Name of the style
* @returns {Promise<Object>} Style object
*/
async function loadStyleFile(styleName) {
// Check cache first
if (styleCache[styleName]) {
return styleCache[styleName]
}
// Fetch the style file from the public assets
const response = await fetch(`/maps_maplibre/styles/${styleName}.json`)
if (!response.ok) {
throw new Error(`Failed to load style: ${styleName} (${response.status})`)
}
const style = await response.json()
styleCache[styleName] = style
return style
}
/**
* Get a map style with configured tile source
* @param {string} styleName - Name of the style (dark, light, white, black, grayscale)
* @returns {Promise<Object>} MapLibre style object
*/
export async function getMapStyle(styleName = 'light') {
try {
// Load the style file
const style = await loadStyleFile(styleName)
// Clone the style to avoid mutating the cached object
const clonedStyle = JSON.parse(JSON.stringify(style))
// Update the tile source URL
if (clonedStyle.sources && clonedStyle.sources.protomaps) {
clonedStyle.sources.protomaps = {
type: 'vector',
tiles: [TILE_SOURCE_URL],
minzoom: 0,
maxzoom: 14,
attribution: clonedStyle.sources.protomaps.attribution ||
'<a href="https://github.com/protomaps/basemaps">Protomaps</a> © <a href="https://openstreetmap.org">OpenStreetMap</a>'
}
}
return clonedStyle
} catch (error) {
console.error(`Error loading style '${styleName}':`, error)
// Fall back to light style if the requested style fails
if (styleName !== 'light') {
console.warn(`Falling back to 'light' style`)
return getMapStyle('light')
}
throw error
}
}
/**
* Get list of available style names
* @returns {string[]} Array of style names
*/
export function getAvailableStyles() {
return Object.keys(MAP_STYLES)
}
/**
* Get style display name
* @param {string} styleName - Style identifier
* @returns {string} Human-readable style name
*/
export function getStyleDisplayName(styleName) {
const displayNames = {
dark: 'Dark',
light: 'Light',
white: 'White',
black: 'Black',
grayscale: 'Grayscale'
}
return displayNames[styleName] || styleName.charAt(0).toUpperCase() + styleName.slice(1)
}
/**
* Preload all styles into cache for faster switching
* @returns {Promise<void>}
*/
export async function preloadAllStyles() {
const styleNames = getAvailableStyles()
await Promise.all(styleNames.map(name => loadStyleFile(name)))
console.log('All map styles preloaded')
}

View file

@ -0,0 +1,82 @@
/**
* WebSocket connection manager
* Handles reconnection logic and connection state
*/
export class WebSocketManager {
constructor(options = {}) {
this.maxReconnectAttempts = options.maxReconnectAttempts || 5
this.reconnectDelay = options.reconnectDelay || 1000
this.reconnectAttempts = 0
this.isConnected = false
this.subscription = null
this.onConnect = options.onConnect || null
this.onDisconnect = options.onDisconnect || null
this.onError = options.onError || null
}
/**
* Connect to channel
* @param {Object} subscription - ActionCable subscription
*/
connect(subscription) {
this.subscription = subscription
// Monitor connection state
this.subscription.connected = () => {
this.isConnected = true
this.reconnectAttempts = 0
this.onConnect?.()
}
this.subscription.disconnected = () => {
this.isConnected = false
this.onDisconnect?.()
this.attemptReconnect()
}
}
/**
* Attempt to reconnect
*/
attemptReconnect() {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
this.onError?.(new Error('Max reconnect attempts reached'))
return
}
this.reconnectAttempts++
const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1)
console.log(`Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`)
setTimeout(() => {
if (!this.isConnected) {
this.subscription?.perform('reconnect')
}
}, delay)
}
/**
* Disconnect
*/
disconnect() {
if (this.subscription) {
this.subscription.unsubscribe()
this.subscription = null
}
this.isConnected = false
}
/**
* Send message
*/
send(action, data = {}) {
if (!this.isConnected) {
console.warn('Cannot send message: not connected')
return
}
this.subscription?.perform(action, data)
}
}

View file

@ -8,6 +8,16 @@ module Taggable
has_many :tags, through: :taggings
scope :with_tags, ->(tag_ids) { joins(:taggings).where(taggings: { tag_id: tag_ids }).distinct }
scope :with_all_tags, ->(tag_ids) {
tag_ids = Array(tag_ids)
return none if tag_ids.empty?
# For each tag, join and filter, then use HAVING to ensure all tags are present
joins(:taggings)
.where(taggings: { tag_id: tag_ids })
.group("#{table_name}.id")
.having("COUNT(DISTINCT taggings.tag_id) = ?", tag_ids.length)
}
scope :without_tags, -> { left_joins(:taggings).where(taggings: { id: nil }) }
scope :tagged_with, ->(tag_name, user) {
joins(:tags).where(tags: { name: tag_name, user: user }).distinct

View file

@ -17,6 +17,7 @@ class Api::PointSerializer
attributes['latitude'] = lat&.to_s
attributes['longitude'] = lon&.to_s
attributes['country_name'] = point.country_name
end
end

View file

@ -20,7 +20,8 @@ class Users::SafeSettings
'photoprism_api_key' => nil,
'maps' => { 'distance_unit' => 'km' },
'visits_suggestions_enabled' => 'true',
'enabled_map_layers' => ['Routes', 'Heatmap']
'enabled_map_layers' => ['Routes', 'Heatmap'],
'maps_maplibre_style' => 'light'
}.freeze
def initialize(settings = {})
@ -28,7 +29,7 @@ class Users::SafeSettings
end
# rubocop:disable Metrics/MethodLength
def default_settings
def config
{
fog_of_war_meters: fog_of_war_meters,
meters_between_routes: meters_between_routes,
@ -49,7 +50,8 @@ class Users::SafeSettings
visits_suggestions_enabled: visits_suggestions_enabled?,
speed_color_scale: speed_color_scale,
fog_of_war_threshold: fog_of_war_threshold,
enabled_map_layers: enabled_map_layers
enabled_map_layers: enabled_map_layers,
maps_maplibre_style: maps_maplibre_style
}
end
# rubocop:enable Metrics/MethodLength
@ -133,4 +135,8 @@ class Users::SafeSettings
def enabled_map_layers
settings['enabled_map_layers']
end
def maps_maplibre_style
settings['maps_maplibre_style']
end
end

View file

@ -0,0 +1,35 @@
<% content_for :title, 'Map' %>
<%= render 'shared/map/date_navigation', start_at: @start_at, end_at: @end_at %>
<!-- Map Container - Fills remaining space -->
<div class="w-full h-full">
<div
id='map'
class="w-full h-full"
data-controller="maps points add-visit family-members"
data-points-target="map"
data-api_key="<%= current_user.api_key %>"
data-self_hosted="<%= @self_hosted %>"
data-user_settings='<%= current_user.safe_settings.settings.to_json %>'
data-user_theme="<%= current_user&.theme || 'dark' %>"
data-coordinates='<%= @coordinates.to_json.html_safe %>'
data-tracks='<%= @tracks.to_json.html_safe %>'
data-distance="<%= @distance %>"
data-points_number="<%= @points_number %>"
data-timezone="<%= Rails.configuration.time_zone %>"
data-features='<%= @features.to_json.html_safe %>'
data-user_tags='<%= current_user.tags.ordered.select(:id, :name, :icon, :color).as_json.to_json.html_safe %>'
data-home_coordinates='<%= @home_coordinates.to_json.html_safe %>'
data-family-members-features-value='<%= @features.to_json.html_safe %>'
data-family-members-user-theme-value="<%= current_user&.theme || 'dark' %>">
<div data-maps-target="container" class="w-full h-full">
<div id="fog" class="fog"></div>
</div>
</div>
</div>
<%= render 'map/leaflet/settings_modals' %>
<!-- Include Place Creation Modal -->
<%= render 'shared/place_creation_modal' %>

View file

@ -0,0 +1,67 @@
<div data-controller="area-creation-v2"
data-area-creation-v2-api-key-value="<%= current_user.api_key %>">
<div class="modal z-[10000]" data-area-creation-v2-target="modal">
<div class="modal-box max-w-xl">
<h3 class="font-bold text-lg mb-4">Create New Area</h3>
<form data-area-creation-v2-target="form" data-action="submit->area-creation-v2#submit">
<input type="hidden" name="latitude" data-area-creation-v2-target="latitudeInput">
<input type="hidden" name="longitude" data-area-creation-v2-target="longitudeInput">
<input type="hidden" name="radius" data-area-creation-v2-target="radiusInput">
<div class="space-y-4">
<!-- Area Name -->
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Area Name *</span>
</label>
<input
type="text"
name="name"
placeholder="e.g. Home, Office, Gym..."
class="input input-bordered w-full"
data-area-creation-v2-target="nameInput"
required>
</div>
<!-- Radius Display -->
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Radius</span>
</label>
<div class="text-lg font-semibold">
<span data-area-creation-v2-target="radiusDisplay">0</span> meters
</div>
<label class="label">
<span class="label-text-alt">Draw on the map to set the radius</span>
</label>
</div>
<!-- Drawing Instructions -->
<div class="alert alert-info">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<div class="text-sm">
<strong>How to draw:</strong>
<ol class="list-decimal list-inside mt-1">
<li>Click once to set the center point</li>
<li>Move mouse to adjust radius</li>
<li>Click again to finish drawing</li>
</ol>
</div>
</div>
</div>
<div class="modal-action">
<button type="button" class="btn btn-ghost" data-action="click->area-creation-v2#close">Cancel</button>
<button type="submit" class="btn btn-primary" data-area-creation-v2-target="submitButton">
<span class="loading loading-sm hidden" data-area-creation-v2-target="submitSpinner"></span>
<span data-area-creation-v2-target="submitText">Create Area</span>
</button>
</div>
</form>
</div>
<div class="modal-backdrop" data-action="click->area-creation-v2#close"></div>
</div>
</div>

View file

@ -0,0 +1,655 @@
<div class="map-control-panel" data-maps--maplibre-target="settingsPanel" data-controller="map-panel">
<!-- Vertical Icon Tabs (Left Side) -->
<div class="panel-tabs">
<button class="tab-btn active"
data-action="click->map-panel#switchTab"
data-tab="layers"
data-map-panel-target="tabButton"
title="Map Layers">
<%= icon 'layer' %>
</button>
<button class="tab-btn"
data-action="click->map-panel#switchTab"
data-tab="search"
data-map-panel-target="tabButton"
title="Search">
<%= icon 'search' %>
</button>
<button class="tab-btn"
data-action="click->map-panel#switchTab"
data-tab="tools"
data-map-panel-target="tabButton"
title="Tools">
<%= icon 'pocket-knife' %>
</button>
<button class="tab-btn"
data-action="click->map-panel#switchTab"
data-tab="settings"
data-map-panel-target="tabButton"
title="Settings">
<%= icon 'settings' %>
</button>
<% if !DawarichSettings.self_hosted? %>
<button class="tab-btn"
data-action="click->map-panel#switchTab"
data-tab="links"
data-map-panel-target="tabButton"
title="Links">
<%= icon 'info' %>
</button>
<% end %>
</div>
<!-- Panel Content -->
<div class="panel-content">
<!-- Panel Header -->
<div class="panel-header">
<h3 class="panel-title" data-map-panel-target="title">Layers</h3>
<button class="btn btn-ghost btn-sm btn-circle"
data-action="click->maps--maplibre#toggleSettings"
title="Close panel">
<%= icon 'x' %>
</button>
</div>
<!-- Panel Body -->
<div class="panel-body">
<!-- Search Tab -->
<div class="tab-content" data-tab-content="search" data-map-panel-target="tabContent">
<div class="form-control w-full">
<label class="label">
<span class="label-text">Search for a place</span>
</label>
<div class="relative">
<input type="text"
placeholder="Enter name of a place"
class="input input-bordered w-full"
data-maps--maplibre-target="searchInput"
autocomplete="off" />
<!-- Search Results -->
<div class="absolute z-50 w-full mt-1 bg-base-100 rounded-lg shadow-lg border border-base-300 hidden max-h-full overflow-y-auto"
data-maps--maplibre-target="searchResults">
<!-- Results will be populated by SearchManager -->
</div>
</div>
<p class="text-xs text-base-content/60 mt-2">
Search for a location to find places you visited
</p>
</div>
</div>
<!-- Layers Tab -->
<div class="tab-content active" data-tab-content="layers" data-map-panel-target="tabContent">
<div class="space-y-4">
<!-- Points Layer -->
<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox"
class="toggle toggle-primary"
data-maps--maplibre-target="pointsToggle"
data-action="change->maps--maplibre#togglePoints" />
<span class="label-text font-medium">Points</span>
</label>
<p class="text-sm text-base-content/60 ml-14">Show individual location points</p>
</div>
<div class="divider"></div>
<!-- Routes Layer -->
<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox"
class="toggle toggle-primary"
data-maps--maplibre-target="routesToggle"
data-action="change->maps--maplibre#toggleRoutes" />
<span class="label-text font-medium">Routes</span>
</label>
<p class="text-sm text-base-content/60 ml-14">Show connected route lines</p>
</div>
<!-- Speed-Colored Routes Options (conditionally shown) -->
<div class="ml-14 space-y-3" data-maps--maplibre-target="routesOptions" style="display: none;">
<div class="form-control">
<label class="label cursor-pointer py-2">
<span class="label-text text-sm">Color by speed</span>
<input type="checkbox"
class="toggle toggle-sm toggle-primary"
data-maps--maplibre-target="speedColoredToggle"
data-action="change->maps--maplibre#toggleSpeedColoredRoutes" />
</label>
</div>
<!-- Speed Color Scale Editor (shown when speed colors enabled) -->
<div class="hidden" data-maps--maplibre-target="speedColorScaleContainer">
<button type="button"
class="btn btn-sm btn-outline w-full"
data-action="click->maps--maplibre#openSpeedColorEditor">
<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="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
</svg>
Edit Color Gradient
</button>
<input type="hidden" data-maps--maplibre-target="speedColorScaleInput" value="" />
</div>
</div>
<div class="divider"></div>
<!-- Heatmap Layer -->
<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox"
class="toggle toggle-primary"
data-maps--maplibre-target="heatmapToggle"
data-action="change->maps--maplibre#toggleHeatmap" />
<span class="label-text font-medium">Heatmap</span>
</label>
<p class="text-sm text-base-content/60 ml-14">Show density heatmap</p>
</div>
<div class="divider"></div>
<!-- Visits Layer -->
<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox"
class="toggle toggle-primary"
data-maps--maplibre-target="visitsToggle"
data-action="change->maps--maplibre#toggleVisits" />
<span class="label-text font-medium">Visits</span>
</label>
<p class="text-sm text-base-content/60 ml-14">Show detected area visits</p>
</div>
<!-- Visits Search (conditionally shown) -->
<div class="ml-14 space-y-2" data-maps--maplibre-target="visitsSearch" style="display: none;">
<input type="text"
id="visits-search"
placeholder="Filter by name..."
class="input input-sm input-bordered w-full"
data-action="input->maps--maplibre#searchVisits" />
<select class="select select-bordered w-full"
data-action="change->maps--maplibre#filterVisits">
<option value="all">All Visits</option>
<option value="confirmed">Confirmed Only</option>
<option value="suggested">Suggested Only</option>
</select>
</div>
<div class="divider"></div>
<!-- Places Layer -->
<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox"
class="toggle toggle-primary"
data-maps--maplibre-target="placesToggle"
data-action="change->maps--maplibre#togglePlaces" />
<span class="label-text font-medium">Places</span>
</label>
<p class="text-sm text-base-content/60 ml-14">Show your saved places</p>
</div>
<!-- Places Tags (conditionally shown) -->
<div class="ml-14 space-y-2" data-maps--maplibre-target="placesFilters" style="display: none;">
<div class="form-control">
<label class="label cursor-pointer justify-start gap-2">
<input type="checkbox"
class="toggle toggle-sm"
data-maps--maplibre-target="enableAllPlaceTagsToggle"
data-action="change->maps--maplibre#toggleAllPlaceTags">
<span class="label-text text-sm">Enable All Tags</span>
</label>
</div>
<div class="form-control">
<label class="label">
<span class="label-text text-sm">Filter by Tags</span>
</label>
<div class="flex flex-wrap gap-2">
<!-- Untagged option -->
<label class="cursor-pointer">
<input type="checkbox"
name="place_tag_ids[]"
value="untagged"
class="checkbox checkbox-xs hidden peer"
data-action="change->maps--maplibre#filterPlacesByTags">
<span class="badge badge-sm badge-outline transition-all peer-checked:scale-105"
style="border-color: #94a3b8; color: #94a3b8;"
data-checked-style="background-color: #94a3b8; color: white;">
🏷️ Untagged
</span>
</label>
<% current_user.tags.ordered.each do |tag| %>
<label class="cursor-pointer">
<input type="checkbox"
name="place_tag_ids[]"
value="<%= tag.id %>"
class="checkbox checkbox-xs hidden peer"
data-action="change->maps--maplibre#filterPlacesByTags">
<span class="badge badge-sm badge-outline transition-all peer-checked:scale-105"
style="border-color: <%= tag.color %>; color: <%= tag.color %>;"
data-checked-style="background-color: <%= tag.color %>; color: white;">
<%= tag.icon %> #<%= tag.name %>
</span>
</label>
<% end %>
</div>
<label class="label">
<span class="label-text-alt">Click tags to filter places</span>
</label>
</div>
</div>
<div class="divider"></div>
<!-- Photos Layer -->
<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox"
class="toggle toggle-primary"
data-maps--maplibre-target="photosToggle"
data-action="change->maps--maplibre#togglePhotos" />
<span class="label-text font-medium">Photos</span>
</label>
<p class="text-sm text-base-content/60 ml-14">Show geotagged photos</p>
</div>
<div class="divider"></div>
<!-- Areas Layer -->
<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox"
class="toggle toggle-primary"
data-maps--maplibre-target="areasToggle"
data-action="change->maps--maplibre#toggleAreas" />
<span class="label-text font-medium">Areas</span>
</label>
<p class="text-sm text-base-content/60 ml-14">Show defined areas</p>
</div>
<div class="divider"></div>
<!-- Tracks Layer -->
<%# <div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox"
class="toggle toggle-primary"
data-maps--maplibre-target="tracksToggle"
data-action="change->maps--maplibre#toggleTracks" />
<span class="label-text font-medium">Tracks</span>
</label>
<p class="text-sm text-base-content/60 ml-14">Show saved tracks</p>
</div> %>
<%# <div class="divider"></div> %>
<!-- Fog of War Layer -->
<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox"
class="toggle toggle-primary"
data-maps--maplibre-target="fogToggle"
data-action="change->maps--maplibre#toggleFog" />
<span class="label-text font-medium">Fog of War</span>
</label>
<p class="text-sm text-base-content/60 ml-14">Show explored areas</p>
</div>
<div class="divider"></div>
<!-- Scratch Map Layer -->
<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox"
class="toggle toggle-primary"
data-maps--maplibre-target="scratchToggle"
data-action="change->maps--maplibre#toggleScratch" />
<span class="label-text font-medium">Scratch Map</span>
</label>
<p class="text-sm text-base-content/60 ml-14">Show scratched countries</p>
</div>
</div>
</div>
<!-- Settings Tab -->
<div class="tab-content" data-tab-content="settings" data-map-panel-target="tabContent">
<form data-action="submit->maps--maplibre#updateAdvancedSettings" class="space-y-4">
<!-- Map Style -->
<div class="form-control w-full">
<label class="label">
<span class="label-text font-medium">Map Style</span>
</label>
<select class="select select-bordered w-full"
name="mapStyle"
data-action="change->maps--maplibre#updateMapStyle">
<option value="light" selected>Light</option>
<option value="dark">Dark</option>
<option value="white">White</option>
<option value="black">Black</option>
<option value="grayscale">Grayscale</option>
</select>
</div>
<div class="divider"></div>
<!-- Route Opacity -->
<div class="form-control w-full">
<label class="label">
<span class="label-text font-medium">Route Opacity</span>
<span class="label-text-alt">%</span>
</label>
<input type="range"
name="routeOpacity"
min="10"
max="100"
step="10"
value="100"
class="range range-sm"
data-maps--maplibre-target="routeOpacityRange"
data-action="input->maps--maplibre#updateRouteOpacity" />
<div class="w-full flex justify-between text-xs px-2 mt-1">
<span>10%</span>
<span>50%</span>
<span>100%</span>
</div>
</div>
<div class="divider"></div>
<!-- Fog of War Settings -->
<div class="form-control w-full">
<label class="label">
<span class="label-text font-medium">Fog of War Radius</span>
<span class="label-text-alt" data-maps--maplibre-target="fogRadiusValue">1000m</span>
</label>
<input type="range"
name="fogOfWarRadius"
min="5"
max="2000"
step="5"
value="1000"
class="range range-sm"
data-action="input->maps--maplibre#updateFogRadiusDisplay" />
<div class="w-full flex justify-between text-xs px-2 mt-1">
<span>5m</span>
<span>1000m</span>
<span>2000m</span>
</div>
<p class="text-xs text-base-content/60 mt-1">Clear radius around visited points</p>
</div>
<div class="form-control w-full">
<label class="label">
<span class="label-text font-medium">Fog of War Threshold</span>
<span class="label-text-alt" data-maps--maplibre-target="fogThresholdValue">1</span>
</label>
<input type="range"
name="fogOfWarThreshold"
min="1"
max="10"
step="1"
value="1"
class="range range-sm"
data-action="input->maps--maplibre#updateFogThresholdDisplay" />
<div class="w-full flex justify-between text-xs px-2 mt-1">
<span>1</span>
<span>5</span>
<span>10</span>
</div>
<p class="text-xs text-base-content/60 mt-1">Minimum points to clear fog</p>
</div>
<div class="divider"></div>
<!-- Route Generation Settings -->
<div class="form-control w-full">
<label class="label">
<span class="label-text font-medium">Meters Between Routes</span>
<span class="label-text-alt" data-maps--maplibre-target="metersBetweenValue">500m</span>
</label>
<input type="range"
name="metersBetweenRoutes"
min="100"
max="5000"
step="100"
value="500"
class="range range-sm"
data-action="input->maps--maplibre#updateMetersBetweenDisplay" />
<div class="w-full flex justify-between text-xs px-2 mt-1">
<span>100m</span>
<span>2500m</span>
<span>5000m</span>
</div>
<p class="text-xs text-base-content/60 mt-1">Distance threshold for route splitting</p>
</div>
<div class="form-control w-full">
<label class="label">
<span class="label-text font-medium">Minutes Between Routes</span>
<span class="label-text-alt" data-maps--maplibre-target="minutesBetweenValue">60min</span>
</label>
<input type="range"
name="minutesBetweenRoutes"
min="1"
max="180"
step="1"
value="60"
class="range range-sm"
data-action="input->maps--maplibre#updateMinutesBetweenDisplay" />
<div class="w-full flex justify-between text-xs px-2 mt-1">
<span>1min</span>
<span>90min</span>
<span>180min</span>
</div>
<p class="text-xs text-base-content/60 mt-1">Time threshold for route splitting</p>
</div>
<div class="divider"></div>
<!-- Points Rendering Mode -->
<div class="form-control w-full">
<label class="label">
<span class="label-text font-medium">Points Rendering Mode</span>
</label>
<div class="flex flex-col gap-2">
<label class="label cursor-pointer justify-start gap-3 py-1">
<input type="radio"
name="pointsRenderingMode"
value="raw"
class="radio radio-primary radio-sm"
checked />
<span class="label-text">Raw (all points)</span>
</label>
<label class="label cursor-pointer justify-start gap-3 py-1">
<input type="radio"
name="pointsRenderingMode"
value="simplified"
class="radio radio-primary radio-sm" />
<span class="label-text">Simplified (reduced points)</span>
</label>
</div>
</div>
<div class="divider"></div>
<!-- Speed-Colored Routes -->
<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox"
name="speedColoredRoutes"
class="toggle toggle-primary" />
<span class="label-text font-medium">Speed-Colored Routes</span>
</label>
<p class="text-sm text-base-content/60 mt-1">Color routes by speed</p>
</div>
<div class="divider"></div>
<!-- Live Mode -->
<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox"
class="toggle toggle-primary"
data-action="change->maps--maplibre-realtime#toggleLiveMode"
data-maps--maplibre-realtime-target="liveModeToggle" />
<span class="label-text font-medium">Live Mode</span>
</label>
<p class="text-sm text-base-content/60 mt-1">Show new points in real-time</p>
</div>
<div class="divider"></div>
<!-- Update Button -->
<button type="submit" class="btn btn-sm btn-primary btn-block">
<%= icon 'save' %>
Apply Settings
</button>
<!-- Reset Settings -->
<button type="button"
class="btn btn-sm btn-outline btn-block"
data-action="click->maps--maplibre#resetSettings">
<%= icon 'rotate-ccw' %>
Reset to Defaults
</button>
</form>
</div>
<!-- Tools Tab -->
<div class="tab-content" data-tab-content="tools" data-map-panel-target="tabContent">
<div class="space-y-4">
<!-- Tools Grid: Full width on mobile/tablet, 2 columns on large screens -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3">
<!-- Create a Visit Button -->
<button type="button"
class="btn btn-sm btn-outline"
data-action="click->maps--maplibre#startCreateVisit">
<%= icon 'map-pin-check' %>
Create a Visit
</button>
<!-- Create a Place Button -->
<button type="button"
class="btn btn-sm btn-outline"
data-action="click->maps--maplibre#startCreatePlace">
<%= icon 'map-pin-plus' %>
Create a Place
</button>
<!-- Select Area Button -->
<button type="button"
class="btn btn-sm btn-outline"
data-maps--maplibre-target="selectAreaButton"
data-action="click->maps--maplibre#startSelectArea">
<%= icon 'square-dashed-mouse-pointer' %>
Select Area
</button>
<!-- Create Area Button -->
<button type="button"
class="btn btn-sm btn-outline"
data-action="click->maps--maplibre#startCreateArea">
<%= icon 'circle-plus' %>
Create an Area
</button>
</div>
<!-- Info Display (shown when clicking on visit/area/place) -->
<div class="hidden mt-4" data-maps--maplibre-target="infoDisplay">
<div class="card bg-base-200 shadow-md">
<div class="card-body p-4">
<div class="flex justify-between items-start mb-2">
<h4 class="card-title text-base" data-maps--maplibre-target="infoTitle"></h4>
<button class="btn btn-ghost btn-xs btn-circle" data-action="click->maps--maplibre#closeInfo" title="Close">✕</button>
</div>
<div class="space-y-2 text-sm" data-maps--maplibre-target="infoContent">
<!-- Content will be dynamically inserted -->
</div>
<div class="card-actions justify-end mt-3" data-maps--maplibre-target="infoActions">
<!-- Action buttons will be dynamically inserted -->
</div>
</div>
</div>
</div>
<!-- Selection Actions (shown after area is selected) -->
<div class="hidden mt-4 space-y-2" data-maps--maplibre-target="selectionActions">
<button type="button"
class="btn btn-sm btn-outline btn-error btn-block"
data-action="click->maps--maplibre#deleteSelectedPoints"
data-maps--maplibre-target="deletePointsButton">
<%= icon 'trash-2' %>
<span data-maps--maplibre-target="deleteButtonText">Delete Selected Points</span>
</button>
<!-- Selected Visits Container -->
<div class="hidden mt-4 max-h-full overflow-y-auto" data-maps--maplibre-target="selectedVisitsContainer">
<!-- Visit cards will be dynamically inserted here -->
</div>
<!-- Bulk Actions for Visits -->
<div class="hidden" data-maps--maplibre-target="selectedVisitsBulkActions">
<!-- Bulk action buttons will be dynamically inserted here -->
</div>
</div>
</div>
</div>
<% if !DawarichSettings.self_hosted? %>
<!-- Links Tab -->
<div class="tab-content" data-tab-content="links" data-map-panel-target="tabContent">
<div class="space-y-6">
<!-- Community Section -->
<div>
<h4 class="font-semibold text-base mb-3">Community</h4>
<div class="flex flex-col gap-2">
<a href="https://discord.gg/pHsBjpt5J8" target="_blank" class="link-hover text-sm">Discord</a>
<a href="https://x.com/freymakesstuff" target="_blank" class="link-hover text-sm">X</a>
<a href="https://github.com/Freika/dawarich" target="_blank" class="link-hover text-sm">Github</a>
<a href="https://mastodon.social/@dawarich" target="_blank" class="link-hover text-sm">Mastodon</a>
</div>
</div>
<div class="divider"></div>
<!-- Docs Section -->
<div>
<h4 class="font-semibold text-base mb-3">Docs</h4>
<div class="flex flex-col gap-2">
<a href="https://dawarich.app/docs/intro" target="_blank" class="link-hover text-sm">Tutorial</a>
<a href="https://dawarich.app/docs/tutorials/import-existing-data" target="_blank" class="link-hover text-sm">Import existing data</a>
<a href="https://dawarich.app/docs/tutorials/export-your-data" target="_blank" class="link-hover text-sm">Exporting data</a>
<a href="https://dawarich.app/docs/FAQ" target="_blank" class="link-hover text-sm">FAQ</a>
<a href="https://dawarich.app/contact" target="_blank" class="link-hover text-sm">Contact</a>
</div>
</div>
<div class="divider"></div>
<!-- More Section -->
<div>
<h4 class="font-semibold text-base mb-3">More</h4>
<div class="flex flex-col gap-2">
<a href="https://dawarich.app/privacy-policy" target="_blank" class="link-hover text-sm">Privacy policy</a>
<a href="https://dawarich.app/terms-and-conditions" target="_blank" class="link-hover text-sm">Terms and Conditions</a>
<a href="https://dawarich.app/refund-policy" target="_blank" class="link-hover text-sm">Refund policy</a>
<a href="https://dawarich.app/impressum" target="_blank" class="link-hover text-sm">Impressum</a>
<a href="https://dawarich.app/blog" target="_blank" class="link-hover text-sm">Blog</a>
</div>
</div>
</div>
</div>
<% end %>
</div>
</div>
</div>

View file

@ -0,0 +1,60 @@
<div data-controller="visit-creation-v2" data-visit-creation-v2-api-key-value="<%= current_user.api_key %>">
<div class="modal z-[10000]" data-visit-creation-v2-target="modal">
<div class="modal-box max-w-2xl">
<h3 class="font-bold text-lg mb-4" data-visit-creation-v2-target="modalTitle">Create New Visit</h3>
<form data-visit-creation-v2-target="form" data-action="submit->visit-creation-v2#submit">
<input type="hidden" name="latitude" data-visit-creation-v2-target="latitudeInput">
<input type="hidden" name="longitude" data-visit-creation-v2-target="longitudeInput">
<div class="space-y-4">
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Visit Name *</span>
</label>
<input
type="text"
name="name"
placeholder="Enter visit name..."
class="input input-bordered w-full"
data-visit-creation-v2-target="nameInput"
required>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Start Time *</span>
</label>
<input
type="datetime-local"
name="started_at"
class="input input-bordered w-full"
data-visit-creation-v2-target="startTimeInput"
required>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">End Time *</span>
</label>
<input
type="datetime-local"
name="ended_at"
class="input input-bordered w-full"
data-visit-creation-v2-target="endTimeInput"
required>
</div>
</div>
</div>
<div class="modal-action">
<button type="button" class="btn btn-ghost" data-action="click->visit-creation-v2#close">Cancel</button>
<button type="submit" class="btn btn-primary" data-visit-creation-v2-target="submitButton">Create Visit</button>
</div>
</form>
</div>
<div class="modal-backdrop" data-action="click->visit-creation-v2#close"></div>
</div>
</div>

View file

@ -0,0 +1,49 @@
<% content_for :title, 'Map' %>
<%= render 'shared/map/date_navigation_v2', start_at: @start_at, end_at: @end_at %>
<div id="maps-maplibre-container"
data-controller="maps--maplibre area-drawer maps--maplibre-realtime"
data-maps--maplibre-api-key-value="<%= current_user.api_key %>"
data-maps--maplibre-start-date-value="<%= @start_at.to_s %>"
data-maps--maplibre-end-date-value="<%= @end_at.to_s %>"
data-maps--maplibre-realtime-enabled-value="true"
style="width: 100%; height: 100%; position: relative;">
<!-- Map container takes full width and height -->
<div data-maps--maplibre-target="container" class="maps-maplibre-container" style="width: 100%; height: 100%;"></div>
<!-- Connection indicator -->
<div class="connection-indicator disconnected">
<span class="indicator-dot"></span>
<span class="indicator-text"></span>
</div>
<!-- Settings button (top-left corner) -->
<div class="absolute top-4 left-4 z-10">
<button data-action="click->maps--maplibre#toggleSettings"
class="btn btn-sm btn-primary"
title="Open map settings">
<%= icon 'square-pen' %>
<span class="ml-1">Settings</span>
</button>
</div>
<!-- Loading overlay -->
<div data-maps--maplibre-target="loading" class="loading-overlay hidden">
<div class="loading-spinner"></div>
<div class="loading-text" data-maps--maplibre-target="loadingText">Loading points...</div>
</div>
<!-- Settings panel -->
<%= render 'map/maplibre/settings_panel' %>
<!-- Visit creation modal -->
<%= render 'map/maplibre/visit_creation_modal' %>
<!-- Area creation modal -->
<%= render 'map/maplibre/area_creation_modal' %>
<!-- Place creation modal (shared) -->
<%= render 'shared/place_creation_modal' %>
</div>

View file

@ -80,6 +80,23 @@
</div>
</label>
</div>
<div class="form-control">
<label class="label cursor-pointer justify-start">
<span class="label-text mr-4 flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-map mr-2 w-4 h-4">
<polygon points="3 6 9 3 15 6 21 3 21 18 15 21 9 18 3 21"></polygon>
<line x1="9" x2="9" y1="3" y2="18"></line>
<line x1="15" x2="15" y1="6" y2="21"></line>
</svg>Preferred Map Version </span>
<div class="flex items-center space-x-2">
<%= f.label :preferred_version_v1, 'V1 (Leaflet)', class: 'cursor-pointer' %>
<%= f.radio_button :preferred_version, 'v1', id: 'maps_preferred_version_v1', class: 'radio radio-primary ml-1 mr-4', checked: @maps['preferred_version'] != 'v2' %>
<%= f.label :preferred_version_v2, 'V2 (MapLibre)', class: 'cursor-pointer' %>
<%= f.radio_button :preferred_version, 'v2', id: 'maps_preferred_version_v2', class: 'radio radio-primary ml-1', checked: @maps['preferred_version'] == 'v2' %>
</div>
</label>
<span class="label-text-alt mt-1">Choose which map version to use by default. V1 uses Leaflet, V2 uses MapLibre with enhanced features.</span>
</div>
</div>
<div class="bg-base-100 p-5 rounded-lg shadow-sm">
<h3 class="font-semibold mb-2">Map Preview</h3>

View file

@ -5,7 +5,7 @@
<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="M4 6h16M4 12h8m-8 6h16" /></svg>
</label>
<ul tabindex="0" class="menu menu-sm dropdown-content mt-3 z-[1] p-2 shadow bg-base-100 rounded-box w-52">
<li><%= link_to 'Map', map_url, class: "#{active_class?(map_url)}" %></li>
<li><%= link_to 'Map', preferred_map_path, class: "#{active_class?(preferred_map_path)}" %></li>
<li><%= link_to 'Trips<sup>α</sup>'.html_safe, trips_url, class: "#{active_class?(trips_url)}" %></li>
<li><%= link_to 'Stats', stats_url, class: "#{active_class?(stats_url)}" %></li>
<% if user_signed_in? && DawarichSettings.family_feature_enabled? %>
@ -44,7 +44,7 @@
<% end %>
</ul>
</div>
<%= link_to 'Dawarich<sup>α</sup>'.html_safe, root_path, class: 'btn btn-ghost normal-case text-xl'%>
<%= link_to 'Dawarich<sup>α</sup>'.html_safe, (user_signed_in? ? preferred_map_path : root_path), class: 'btn btn-ghost normal-case text-xl'%>
<div class="badge mx-4 <%= 'badge-outline' if new_version_available? %>">
<a href="https://github.com/Freika/dawarich/releases/latest" target="_blank" class="inline-flex items-center">
<% if new_version_available? %>
@ -71,7 +71,7 @@
</div>
<div class="navbar-center hidden lg:flex">
<ul class="menu menu-horizontal px-1">
<li><%= link_to 'Map', map_url, class: "mx-1 #{active_class?(map_url)}" %></li>
<li><%= link_to 'Map', preferred_map_path, class: "mx-1 #{active_class?(preferred_map_path)}" %></li>
<li><%= link_to 'Trips<sup>α</sup>'.html_safe, trips_url, class: "mx-1 #{active_class?(trips_url)}" %></li>
<li><%= link_to 'Stats', stats_url, class: "mx-1 #{active_class?(stats_url)}" %></li>
<% if user_signed_in? && DawarichSettings.family_feature_enabled? %>

View file

@ -1,5 +1,5 @@
<div data-controller="place-creation" data-place-creation-api-key-value="<%= current_user.api_key %>">
<div class="modal" data-place-creation-target="modal">
<div class="modal z-[10000]" data-place-creation-target="modal">
<div class="modal-box max-w-2xl">
<h3 class="font-bold text-lg mb-4" data-place-creation-target="modalTitle">Create New Place</h3>

View file

@ -1,5 +1,3 @@
<% content_for :title, 'Map' %>
<!-- Date Navigation Controls - Native Page Element -->
<div class="w-full px-4 bg-base-100" data-controller="map-controls">
<!-- Mobile: Compact Toggle Button -->
@ -11,7 +9,7 @@
<span data-map-controls-target="toggleIcon">
<%= icon 'chevron-down' %>
</span>
<span class="ml-2"><%= human_date(@start_at) %></span>
<span class="ml-2"><%= human_date(start_at) %></span>
</button>
</div>
@ -23,23 +21,23 @@
<div class="flex flex-col space-y-4 lg:flex-row lg:space-y-0 lg:space-x-4 lg:items-end">
<div class="w-full lg:w-1/12">
<div class="flex flex-col space-y-2">
<span class="tooltip" data-tip="<%= human_date(@start_at - 1.day) %>">
<%= link_to map_path(start_at: @start_at - 1.day, end_at: @end_at - 1.day, import_id: params[:import_id]), class: "btn btn-sm border border-base-300 hover:btn-ghost w-full" do %>
<span class="tooltip" data-tip="<%= human_date(start_at - 1.day) %>">
<%= link_to map_path(start_at: start_at - 1.day, end_at: end_at - 1.day, import_id: params[:import_id]), class: "btn btn-sm border border-base-300 hover:btn-ghost w-full" do %>
<%= icon 'chevron-left' %>
<% end %>
</span>
</div>
</div>
<div class="w-full lg:w-2/12 tooltip tooltip-bottom" data-tip="Start date and time">
<%= f.datetime_local_field :start_at, include_seconds: false, class: "input input-sm input-bordered hover:cursor-pointer hover:input-primary w-full", value: @start_at %>
<%= f.datetime_local_field :start_at, include_seconds: false, class: "input input-sm input-bordered hover:cursor-pointer hover:input-primary w-full", value: start_at %>
</div>
<div class="w-full lg:w-2/12 tooltip tooltip-bottom" data-tip="End date and time">
<%= f.datetime_local_field :end_at, include_seconds: false, class: "input input-sm input-bordered hover:cursor-pointer hover:input-primary w-full", value: @end_at %>
<%= f.datetime_local_field :end_at, include_seconds: false, class: "input input-sm input-bordered hover:cursor-pointer hover:input-primary w-full", value: end_at %>
</div>
<div class="w-full lg:w-1/12">
<div class="flex flex-col space-y-2">
<span class="tooltip" data-tip="<%= human_date(@start_at + 1.day) %>">
<%= link_to map_path(start_at: @start_at + 1.day, end_at: @end_at + 1.day, import_id: params[:import_id]), class: "btn btn-sm border border-base-300 hover:btn-ghost w-full" do %>
<span class="tooltip" data-tip="<%= human_date(start_at + 1.day) %>">
<%= link_to map_path(start_at: start_at + 1.day, end_at: end_at + 1.day, import_id: params[:import_id]), class: "btn btn-sm border border-base-300 hover:btn-ghost w-full" do %>
<%= icon 'chevron-right' %>
<% end %>
</span>
@ -71,35 +69,3 @@
<% end %>
</div>
</div>
<!-- Map Container - Fills remaining space -->
<div class="w-full h-full">
<div
id='map'
class="w-full h-full"
data-controller="maps points add-visit family-members"
data-points-target="map"
data-api_key="<%= current_user.api_key %>"
data-self_hosted="<%= @self_hosted %>"
data-user_settings='<%= current_user.safe_settings.settings.to_json %>'
data-user_theme="<%= current_user&.theme || 'dark' %>"
data-coordinates='<%= @coordinates.to_json.html_safe %>'
data-tracks='<%= @tracks.to_json.html_safe %>'
data-distance="<%= @distance %>"
data-points_number="<%= @points_number %>"
data-timezone="<%= Rails.configuration.time_zone %>"
data-features='<%= @features.to_json.html_safe %>'
data-user_tags='<%= current_user.tags.ordered.select(:id, :name, :icon, :color).as_json.to_json.html_safe %>'
data-home_coordinates='<%= @home_coordinates.to_json.html_safe %>'
data-family-members-features-value='<%= @features.to_json.html_safe %>'
data-family-members-user-theme-value="<%= current_user&.theme || 'dark' %>">
<div data-maps-target="container" class="w-full h-full">
<div id="fog" class="fog"></div>
</div>
</div>
</div>
<%= render 'map/settings_modals' %>
<!-- Include Place Creation Modal -->
<%= render 'shared/place_creation_modal' %>

View file

@ -0,0 +1,71 @@
<!-- Date Navigation Controls - Native Page Element -->
<div class="w-full px-4 bg-base-100" data-controller="map-controls">
<!-- Mobile: Compact Toggle Button -->
<div class="lg:hidden flex justify-center">
<button
type="button"
data-action="click->map-controls#toggle"
class="btn btn-primary w-96 shadow-lg">
<span data-map-controls-target="toggleIcon">
<%= icon 'chevron-down' %>
</span>
<span class="ml-2"><%= human_date(start_at) %></span>
</button>
</div>
<!-- Expandable Panel (hidden on mobile by default, always visible on desktop) -->
<div
data-map-controls-target="panel"
class="hidden lg:!block bg-base-100 rounded-lg shadow-lg p-4 mt-2 lg:mt-0">
<%= form_with url: map_v2_path(import_id: params[:import_id]), method: :get do |f| %>
<div class="flex flex-col space-y-4 lg:flex-row lg:space-y-0 lg:space-x-4 lg:items-end">
<div class="w-full lg:w-1/12">
<div class="flex flex-col space-y-2">
<span class="tooltip" data-tip="<%= human_date(start_at - 1.day) %>">
<%= link_to map_v2_path(start_at: start_at - 1.day, end_at: end_at - 1.day, import_id: params[:import_id]), class: "btn btn-sm border border-base-300 hover:btn-ghost w-full" do %>
<%= icon 'chevron-left' %>
<% end %>
</span>
</div>
</div>
<div class="w-full lg:w-2/12 tooltip tooltip-bottom" data-tip="Start date and time">
<%= f.datetime_local_field :start_at, include_seconds: false, class: "input input-sm input-bordered hover:cursor-pointer hover:input-primary w-full", value: start_at %>
</div>
<div class="w-full lg:w-2/12 tooltip tooltip-bottom" data-tip="End date and time">
<%= f.datetime_local_field :end_at, include_seconds: false, class: "input input-sm input-bordered hover:cursor-pointer hover:input-primary w-full", value: end_at %>
</div>
<div class="w-full lg:w-1/12">
<div class="flex flex-col space-y-2">
<span class="tooltip" data-tip="<%= human_date(start_at + 1.day) %>">
<%= link_to map_v2_path(start_at: start_at + 1.day, end_at: end_at + 1.day, import_id: params[:import_id]), class: "btn btn-sm border border-base-300 hover:btn-ghost w-full" do %>
<%= icon 'chevron-right' %>
<% end %>
</span>
</div>
</div>
<div class="w-full lg:w-1/12">
<div class="flex flex-col space-y-2">
<%= f.submit "Search", class: "btn btn-sm btn-primary hover:btn-info w-full" %>
</div>
</div>
<div class="w-full lg:w-1/12">
<div class="flex flex-col space-y-2 text-center">
<%= link_to "Today",
map_v2_path(start_at: Time.current.beginning_of_day, end_at: Time.current.end_of_day, import_id: params[:import_id]),
class: "btn btn-sm border border-base-300 hover:btn-ghost w-full" %>
</div>
</div>
<div class="w-full lg:w-2/12">
<div class="flex flex-col space-y-2 text-center">
<%= link_to "Last 7 days", map_v2_path(start_at: 1.week.ago.beginning_of_day, end_at: Time.current.end_of_day, import_id: params[:import_id]), class: "btn btn-sm border border-base-300 hover:btn-ghost w-full" %>
</div>
</div>
<div class="w-full lg:w-2/12">
<div class="flex flex-col space-y-2 text-center">
<%= link_to "Last month", map_v2_path(start_at: 1.month.ago.beginning_of_day, end_at: Time.current.end_of_day, import_id: params[:import_id]), class: "btn btn-sm border border-base-300 hover:btn-ghost w-full" %>
</div>
</div>
</div>
<% end %>
</div>
</div>

View file

@ -4,6 +4,7 @@
pin_all_from 'app/javascript/channels', under: 'channels'
pin_all_from 'app/javascript/maps', under: 'maps'
pin_all_from 'app/javascript/maps_maplibre', under: 'maps_maplibre'
pin 'application', preload: true
pin '@rails/actioncable', to: 'actioncable.esm.js'
@ -14,7 +15,7 @@ pin '@hotwired/stimulus', to: 'stimulus.min.js', preload: true
pin '@hotwired/stimulus-loading', to: 'stimulus-loading.js', preload: true
pin_all_from 'app/javascript/controllers', under: 'controllers'
pin "leaflet" # @1.9.4
pin 'leaflet' # @1.9.4
pin 'leaflet-providers' # @2.0.0
pin 'chartkick', to: 'chartkick.js'
pin 'Chart.bundle', to: 'Chart.bundle.js'
@ -26,5 +27,6 @@ pin 'imports_channel', to: 'channels/imports_channel.js'
pin 'family_locations_channel', to: 'channels/family_locations_channel.js'
pin 'trix'
pin '@rails/actiontext', to: 'actiontext.esm.js'
pin "leaflet.control.layers.tree" # @1.2.0
pin "emoji-mart" # @5.6.0
pin 'leaflet.control.layers.tree' # @1.2.0
pin 'emoji-mart' # @5.6.0
pin 'maplibre-gl' # @5.12.0

View file

@ -110,7 +110,15 @@ Rails.application.routes.draw do
resources :metrics, only: [:index]
get 'map', to: 'map#index'
# Map namespace with versioning
namespace :map do
get '/v1', to: 'leaflet#index', as: :v1
get '/v2', to: 'maplibre#index', as: :v2
end
# Backward compatibility redirects
get '/map', to: 'map/leaflet#index'
get '/maps/v2', to: redirect('/map/v2')
namespace :api do
namespace :v1 do
@ -120,7 +128,7 @@ Rails.application.routes.draw do
get 'settings', to: 'settings#index'
get 'users/me', to: 'users#me'
resources :areas, only: %i[index create update destroy]
resources :areas, only: %i[index show create update destroy]
resources :places, only: %i[index show create update destroy] do
collection do
get 'nearby'
@ -136,7 +144,7 @@ Rails.application.routes.draw do
delete :bulk_destroy
end
end
resources :visits, only: %i[index create update destroy] do
resources :visits, only: %i[index show create update destroy] do
get 'possible_places', to: 'visits/possible_places#index', on: :member
collection do
post 'merge', to: 'visits#merge'

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