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

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

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

* Update changelog

* Update changelog

* Feature/maplibre frontend (#1953)

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

* Implement phase 1

* Phases 1-3 + part of 4

* Fix e2e tests

* Phase 6

* Implement fog of war

* Phase 7

* Next step: fix specs, phase 7 done

* Use our own map tiles

* Extract v2 map logic to separate manager classes

* Update settings panel on v2 map

* Update v2 e2e tests structure

* Reimplement location search in maps v2

* Update speed routes

* Implement visits and places creation in v2

* Fix last failing test

* Implement visits merging

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

* Extract js to modules from maps_v2_controller.js

* Implement area creation

* Fix spec problem

* Fix some e2e tests

* Implement live mode in v2 map

* Update icons and panel

* Extract some styles

* Remove unused file

* Start adding dark theme to popups on MapLibre maps

* Make popups respect dark theme

* Move v2 maps to maplibre namespace

* Update v2 references to maplibre

* Put place, area and visit info into side panel

* Update API to use safe settings config method

* Fix specs

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

* Add missing public files

* Add handling for real time points

* Fix remembering enabled/disabled layers of the v2 map

* Fix lots of e2e tests

* Add settings to select map version

* Use maps/v2 as main path for MapLibre maps

* Update routing

* Update live mode

* Update maplibre controller

* Update changelog

* Remove some console.log statements

---------

Co-authored-by: Robin Tuszik <mail@robin.gg>
This commit is contained in:
Evgenii Burmakin 2025-12-06 20:54:49 +01:00 committed by GitHub
parent d5dbf002e0
commit 8934c29fce
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
147 changed files with 73480 additions and 164 deletions

View file

@ -1 +1 @@
0.36.1 0.36.2

View file

@ -4,7 +4,26 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/) The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/). and this project adheres to [Semantic Versioning](http://semver.org/).
[0.36.1] - 2025-11-29
# [0.36.2] - 2025-12-06
## The Map v2 release
In this release we're introducing Map v2 based on MapLibre GL JS. It brings better performance, smoother interactions and more features in the future. User can select between Map v1 (Leaflet) and Map v2 (MapLibre GL JS) in the Settings -> Map Settings. New map features will be added to Map v2 only.
## Added
- User can select between Map v1 (Leaflet) and Map v2 (MapLibre GL JS) in the User Settings.
## Fixed
- Heatmap and Fog of War now are moving correctly during map interactions on v2 map. #1798
- Polyline crossing international date line now are rendered correctly on v2 map. #1162
- Place popup tags parsing (MapLibre GL JS compatibility)
- Stats calculation should be faster now.
# [0.36.1] - 2025-11-29
## Fixed ## 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. 1. Clone the repository.
2. Run the following command to start the app: 2. Run the following command to start the app:
```bash ```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`. 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 # frozen_string_literal: true
class Api::V1::AreasController < ApiController class Api::V1::AreasController < ApiController
before_action :set_area, only: %i[update destroy] before_action :set_area, only: %i[show update destroy]
def index def index
@areas = current_api_user.areas @areas = current_api_user.areas
@ -9,6 +9,10 @@ class Api::V1::AreasController < ApiController
render json: @areas, status: :ok render json: @areas, status: :ok
end end
def show
render json: @area, status: :ok
end
def create def create
@area = current_api_user.areas.build(area_params) @area = current_api_user.areas.build(area_params)

View file

@ -7,8 +7,28 @@ module Api
def index def index
@places = current_api_user.places.includes(:tags, :visits) @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) } render json: @places.map { |place| serialize_place(place) }
end end

View file

@ -12,6 +12,23 @@ class Api::V1::PointsController < ApiController
points = current_api_user points = current_api_user
.points .points
.where(timestamp: start_at..end_at) .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) .order(timestamp: order)
.page(params[:page]) .page(params[:page])
.per(params[:per_page] || 100) .per(params[:per_page] || 100)

View file

@ -5,7 +5,7 @@ class Api::V1::SettingsController < ApiController
def index def index
render json: { render json: {
settings: current_api_user.safe_settings, settings: current_api_user.safe_settings.config,
status: 'success' status: 'success'
}, status: :ok }, status: :ok
end end
@ -14,7 +14,7 @@ class Api::V1::SettingsController < ApiController
settings_params.each { |key, value| current_api_user.settings[key] = value } settings_params.each { |key, value| current_api_user.settings[key] = value }
if current_api_user.save 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 status: :ok
else else
render json: { message: 'Something went wrong', errors: current_api_user.errors.full_messages }, 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, :preferred_map_layer, :points_rendering_mode, :live_map_enabled,
:immich_url, :immich_api_key, :photoprism_url, :photoprism_api_key, :immich_url, :immich_api_key, :photoprism_url, :photoprism_api_key,
:speed_colored_routes, :speed_color_scale, :fog_of_war_threshold, :speed_colored_routes, :speed_color_scale, :fog_of_war_threshold,
:maps_v2_style, :maps_maplibre_style,
enabled_map_layers: [] enabled_map_layers: []
) )
end end

View file

@ -10,6 +10,11 @@ class Api::V1::VisitsController < ApiController
render json: serialized_visits render json: serialized_visits
end end
def show
visit = current_api_user.visits.find(params[:id])
render json: Api::VisitSerializer.new(visit).call
end
def create def create
service = Visits::Create.new(current_api_user, visit_params) service = Visits::Create.new(current_api_user, visit_params)

View file

@ -1,10 +1,12 @@
# frozen_string_literal: true # frozen_string_literal: true
class HomeController < ApplicationController class HomeController < ApplicationController
include ApplicationHelper
def index def index
# redirect_to 'https://dawarich.app', allow_other_host: true and return unless SELF_HOSTED # 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 @points = current_user.points.without_raw_data if current_user
end end

View file

@ -1,6 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
class MapController < ApplicationController class Map::LeafletController < ApplicationController
before_action :authenticate_user! before_action :authenticate_user!
layout 'map', only: :index 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 private
def settings_params def settings_params
params.require(:maps).permit(:name, :url, :distance_unit) params.require(:maps).permit(:name, :url, :distance_unit, :preferred_version)
end end
end end

View file

@ -142,4 +142,11 @@ module ApplicationHelper
ALLOW_EMAIL_PASSWORD_REGISTRATION ALLOW_EMAIL_PASSWORD_REGISTRATION
end 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 end

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

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

View file

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

View file

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

View file

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

View file

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

@ -72,9 +72,7 @@ export default class extends BaseController {
} }
async loadHexagons() { async loadHexagons() {
console.log('🎯 loadHexagons started - checking overlay state');
const initialLoadingElement = document.getElementById('map-loading'); const initialLoadingElement = document.getElementById('map-loading');
console.log('📊 Initial overlay display:', initialLoadingElement?.style.display || 'default');
try { try {
// Use server-provided data bounds // Use server-provided data bounds
@ -94,9 +92,6 @@ export default class extends BaseController {
// Fallback timeout in case moveend doesn't fire // Fallback timeout in case moveend doesn't fire
setTimeout(resolve, 1000); setTimeout(resolve, 1000);
}); });
console.log('✅ Map fitBounds complete - checking overlay state');
const afterFitBoundsElement = document.getElementById('map-loading');
console.log('📊 After fitBounds overlay display:', afterFitBoundsElement?.style.display || 'default');
} }
// Load hexagons only if they are pre-calculated and data exists // Load hexagons only if they are pre-calculated and data exists
@ -138,7 +133,6 @@ export default class extends BaseController {
loadingElement.style.display = 'flex'; loadingElement.style.display = 'flex';
loadingElement.style.visibility = 'visible'; loadingElement.style.visibility = 'visible';
loadingElement.style.zIndex = '9999'; loadingElement.style.zIndex = '9999';
console.log('👁️ Loading overlay ENSURED visible - should be visible now');
} }
// Disable map interaction during loading // Disable map interaction during loading
@ -187,7 +181,6 @@ export default class extends BaseController {
} }
const geojsonData = await response.json(); const geojsonData = await response.json();
console.log(`✅ Loaded ${geojsonData.features?.length || 0} hexagons`);
// Add hexagons directly to map as a static layer // Add hexagons directly to map as a static layer
if (geojsonData.features && geojsonData.features.length > 0) { if (geojsonData.features && geojsonData.features.length > 0) {
@ -210,7 +203,6 @@ export default class extends BaseController {
const loadingElement = document.getElementById('map-loading'); const loadingElement = document.getElementById('map-loading');
if (loadingElement) { if (loadingElement) {
loadingElement.style.display = 'none'; loadingElement.style.display = 'none';
console.log('🚫 Loading overlay hidden - hexagons are fully loaded');
} }
} }
} }

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 = { export const mapsConfig = {
"Light": { "Light": {
url: "https://tyles.dwri.xyz/planet/{z}/{x}/{y}.mvt", url: "https://tyles.dwri.xyz/planet/{z}/{x}/{y}.mvt",
flavor: "light", flavor: "light",
maxZoom: 16, maxZoom: 14,
attribution: "<a href='https://github.com/protomaps/basemaps'>Protomaps</a>, &copy; <a href='https://openstreetmap.org'>OpenStreetMap</a>" attribution: "<a href='https://github.com/protomaps/basemaps'>Protomaps</a>, &copy; <a href='https://openstreetmap.org'>OpenStreetMap</a>"
}, },
"Dark": { "Dark": {
url: "https://tyles.dwri.xyz/planet/{z}/{x}/{y}.mvt", url: "https://tyles.dwri.xyz/planet/{z}/{x}/{y}.mvt",
flavor: "dark", flavor: "dark",
maxZoom: 16, maxZoom: 14,
attribution: "<a href='https://github.com/protomaps/basemaps'>Protomaps</a>, &copy; <a href='https://openstreetmap.org'>OpenStreetMap</a>" attribution: "<a href='https://github.com/protomaps/basemaps'>Protomaps</a>, &copy; <a href='https://openstreetmap.org'>OpenStreetMap</a>"
}, },
"White": { "White": {
url: "https://tyles.dwri.xyz/planet/{z}/{x}/{y}.mvt", url: "https://tyles.dwri.xyz/planet/{z}/{x}/{y}.mvt",
flavor: "white", flavor: "white",
maxZoom: 16, maxZoom: 14,
attribution: "<a href='https://github.com/protomaps/basemaps'>Protomaps</a>, &copy; <a href='https://openstreetmap.org'>OpenStreetMap</a>" attribution: "<a href='https://github.com/protomaps/basemaps'>Protomaps</a>, &copy; <a href='https://openstreetmap.org'>OpenStreetMap</a>"
}, },
"Grayscale": { "Grayscale": {
url: "https://tyles.dwri.xyz/planet/{z}/{x}/{y}.mvt", url: "https://tyles.dwri.xyz/planet/{z}/{x}/{y}.mvt",
flavor: "grayscale", flavor: "grayscale",
maxZoom: 16, maxZoom: 14,
attribution: "<a href='https://github.com/protomaps/basemaps'>Protomaps</a>, &copy; <a href='https://openstreetmap.org'>OpenStreetMap</a>" attribution: "<a href='https://github.com/protomaps/basemaps'>Protomaps</a>, &copy; <a href='https://openstreetmap.org'>OpenStreetMap</a>"
}, },
"Black": { "Black": {
url: "https://tyles.dwri.xyz/planet/{z}/{x}/{y}.mvt", url: "https://tyles.dwri.xyz/planet/{z}/{x}/{y}.mvt",
flavor: "black", flavor: "black",
maxZoom: 16, maxZoom: 14,
attribution: "<a href='https://github.com/protomaps/basemaps'>Protomaps</a>, &copy; <a href='https://openstreetmap.org'>OpenStreetMap</a>" 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,357 @@
/**
* 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}`
const response = await fetch(url, {
headers: this.getHeaders()
})
if (!response.ok) {
throw new Error(`Failed to fetch photos: ${response.statusText}`)
}
return response.json()
}
/**
* Fetch areas
*/
async fetchAreas() {
const response = await fetch(`${this.baseURL}/areas`, {
headers: this.getHeaders()
})
if (!response.ok) {
throw new Error(`Failed to fetch areas: ${response.statusText}`)
}
return response.json()
}
/**
* Fetch single area by ID
* @param {number} areaId - Area ID
*/
async fetchArea(areaId) {
const response = await fetch(`${this.baseURL}/areas/${areaId}`, {
headers: this.getHeaders()
})
if (!response.ok) {
throw new Error(`Failed to fetch area: ${response.statusText}`)
}
return response.json()
}
/**
* Fetch tracks
*/
async fetchTracks() {
const response = await fetch(`${this.baseURL}/tracks`, {
headers: this.getHeaders()
})
if (!response.ok) {
throw new Error(`Failed to fetch tracks: ${response.statusText}`)
}
return response.json()
}
/**
* Create area
* @param {Object} area - Area data
*/
async createArea(area) {
const response = await fetch(`${this.baseURL}/areas`, {
method: 'POST',
headers: this.getHeaders(),
body: JSON.stringify({ area })
})
if (!response.ok) {
throw new Error(`Failed to create area: ${response.statusText}`)
}
return response.json()
}
/**
* Delete area by ID
* @param {number} areaId - Area ID
*/
async deleteArea(areaId) {
const response = await fetch(`${this.baseURL}/areas/${areaId}`, {
method: 'DELETE',
headers: this.getHeaders()
})
if (!response.ok) {
throw new Error(`Failed to delete area: ${response.statusText}`)
}
return response.json()
}
/**
* Fetch points within a geographic area
* @param {Object} options - { start_at, end_at, min_longitude, max_longitude, min_latitude, max_latitude }
* @returns {Promise<Array>} Points within the area
*/
async fetchPointsInArea({ start_at, end_at, min_longitude, max_longitude, min_latitude, max_latitude }) {
const params = new URLSearchParams({
start_at,
end_at,
min_longitude: min_longitude.toString(),
max_longitude: max_longitude.toString(),
min_latitude: min_latitude.toString(),
max_latitude: max_latitude.toString(),
per_page: '10000' // Get all points in area (up to 10k)
})
const response = await fetch(`${this.baseURL}/points?${params}`, {
headers: this.getHeaders()
})
if (!response.ok) {
throw new Error(`Failed to fetch points in area: ${response.statusText}`)
}
return response.json()
}
/**
* Fetch visits within a geographic area
* @param {Object} options - { start_at, end_at, sw_lat, sw_lng, ne_lat, ne_lng }
* @returns {Promise<Array>} Visits within the area
*/
async fetchVisitsInArea({ start_at, end_at, sw_lat, sw_lng, ne_lat, ne_lng }) {
const params = new URLSearchParams({
start_at,
end_at,
selection: 'true',
sw_lat: sw_lat.toString(),
sw_lng: sw_lng.toString(),
ne_lat: ne_lat.toString(),
ne_lng: ne_lng.toString()
})
const response = await fetch(`${this.baseURL}/visits?${params}`, {
headers: this.getHeaders()
})
if (!response.ok) {
throw new Error(`Failed to fetch visits in area: ${response.statusText}`)
}
return response.json()
}
/**
* Bulk delete points
* @param {Array<number>} pointIds - Array of point IDs to delete
* @returns {Promise<Object>} { message, count }
*/
async bulkDeletePoints(pointIds) {
const response = await fetch(`${this.baseURL}/points/bulk_destroy`, {
method: 'DELETE',
headers: this.getHeaders(),
body: JSON.stringify({ point_ids: pointIds })
})
if (!response.ok) {
throw new Error(`Failed to delete points: ${response.statusText}`)
}
return response.json()
}
/**
* Update visit status (confirm/decline)
* @param {number} visitId - Visit ID
* @param {string} status - 'confirmed' or 'declined'
* @returns {Promise<Object>} Updated visit
*/
async updateVisitStatus(visitId, status) {
const response = await fetch(`${this.baseURL}/visits/${visitId}`, {
method: 'PATCH',
headers: this.getHeaders(),
body: JSON.stringify({ visit: { status } })
})
if (!response.ok) {
throw new Error(`Failed to update visit status: ${response.statusText}`)
}
return response.json()
}
/**
* Merge multiple visits
* @param {Array<number>} visitIds - Array of visit IDs to merge
* @returns {Promise<Object>} Merged visit
*/
async mergeVisits(visitIds) {
const response = await fetch(`${this.baseURL}/visits/merge`, {
method: 'POST',
headers: this.getHeaders(),
body: JSON.stringify({ visit_ids: visitIds })
})
if (!response.ok) {
throw new Error(`Failed to merge visits: ${response.statusText}`)
}
return response.json()
}
/**
* Bulk update visit status
* @param {Array<number>} visitIds - Array of visit IDs to update
* @param {string} status - 'confirmed' or 'declined'
* @returns {Promise<Object>} Update result
*/
async bulkUpdateVisits(visitIds, status) {
const response = await fetch(`${this.baseURL}/visits/bulk_update`, {
method: 'POST',
headers: this.getHeaders(),
body: JSON.stringify({ visit_ids: visitIds, status })
})
if (!response.ok) {
throw new Error(`Failed to bulk update visits: ${response.statusText}`)
}
return response.json()
}
getHeaders() {
return {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json'
}
}
}

View file

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

View file

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

View file

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

View file

@ -0,0 +1,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 has_many :tags, through: :taggings
scope :with_tags, ->(tag_ids) { joins(:taggings).where(taggings: { tag_id: tag_ids }).distinct } 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 :without_tags, -> { left_joins(:taggings).where(taggings: { id: nil }) }
scope :tagged_with, ->(tag_name, user) { scope :tagged_with, ->(tag_name, user) {
joins(:tags).where(tags: { name: tag_name, user: user }).distinct 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['latitude'] = lat&.to_s
attributes['longitude'] = lon&.to_s attributes['longitude'] = lon&.to_s
attributes['country_name'] = point.country_name
end end
end end

View file

@ -20,7 +20,8 @@ class Users::SafeSettings
'photoprism_api_key' => nil, 'photoprism_api_key' => nil,
'maps' => { 'distance_unit' => 'km' }, 'maps' => { 'distance_unit' => 'km' },
'visits_suggestions_enabled' => 'true', 'visits_suggestions_enabled' => 'true',
'enabled_map_layers' => ['Routes', 'Heatmap'] 'enabled_map_layers' => ['Routes', 'Heatmap'],
'maps_maplibre_style' => 'light'
}.freeze }.freeze
def initialize(settings = {}) def initialize(settings = {})
@ -28,7 +29,7 @@ class Users::SafeSettings
end end
# rubocop:disable Metrics/MethodLength # rubocop:disable Metrics/MethodLength
def default_settings def config
{ {
fog_of_war_meters: fog_of_war_meters, fog_of_war_meters: fog_of_war_meters,
meters_between_routes: meters_between_routes, meters_between_routes: meters_between_routes,
@ -49,7 +50,8 @@ class Users::SafeSettings
visits_suggestions_enabled: visits_suggestions_enabled?, visits_suggestions_enabled: visits_suggestions_enabled?,
speed_color_scale: speed_color_scale, speed_color_scale: speed_color_scale,
fog_of_war_threshold: fog_of_war_threshold, 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 end
# rubocop:enable Metrics/MethodLength # rubocop:enable Metrics/MethodLength
@ -133,4 +135,8 @@ class Users::SafeSettings
def enabled_map_layers def enabled_map_layers
settings['enabled_map_layers'] settings['enabled_map_layers']
end end
def maps_maplibre_style
settings['maps_maplibre_style']
end
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> </div>
</label> </label>
</div> </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>
<div class="bg-base-100 p-5 rounded-lg shadow-sm"> <div class="bg-base-100 p-5 rounded-lg shadow-sm">
<h3 class="font-semibold mb-2">Map Preview</h3> <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> <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> </label>
<ul tabindex="0" class="menu menu-sm dropdown-content mt-3 z-[1] p-2 shadow bg-base-100 rounded-box w-52"> <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 '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> <li><%= link_to 'Stats', stats_url, class: "#{active_class?(stats_url)}" %></li>
<% if user_signed_in? && DawarichSettings.family_feature_enabled? %> <% if user_signed_in? && DawarichSettings.family_feature_enabled? %>
@ -44,7 +44,7 @@
<% end %> <% end %>
</ul> </ul>
</div> </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? %>"> <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"> <a href="https://github.com/Freika/dawarich/releases/latest" target="_blank" class="inline-flex items-center">
<% if new_version_available? %> <% if new_version_available? %>
@ -71,7 +71,7 @@
</div> </div>
<div class="navbar-center hidden lg:flex"> <div class="navbar-center hidden lg:flex">
<ul class="menu menu-horizontal px-1"> <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 '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> <li><%= link_to 'Stats', stats_url, class: "mx-1 #{active_class?(stats_url)}" %></li>
<% if user_signed_in? && DawarichSettings.family_feature_enabled? %> <% 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 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"> <div class="modal-box max-w-2xl">
<h3 class="font-bold text-lg mb-4" data-place-creation-target="modalTitle">Create New Place</h3> <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 --> <!-- Date Navigation Controls - Native Page Element -->
<div class="w-full px-4 bg-base-100" data-controller="map-controls"> <div class="w-full px-4 bg-base-100" data-controller="map-controls">
<!-- Mobile: Compact Toggle Button --> <!-- Mobile: Compact Toggle Button -->
@ -11,7 +9,7 @@
<span data-map-controls-target="toggleIcon"> <span data-map-controls-target="toggleIcon">
<%= icon 'chevron-down' %> <%= icon 'chevron-down' %>
</span> </span>
<span class="ml-2"><%= human_date(@start_at) %></span> <span class="ml-2"><%= human_date(start_at) %></span>
</button> </button>
</div> </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="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="w-full lg:w-1/12">
<div class="flex flex-col space-y-2"> <div class="flex flex-col space-y-2">
<span class="tooltip" data-tip="<%= human_date(@start_at - 1.day) %>"> <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 %> <%= 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' %> <%= icon 'chevron-left' %>
<% end %> <% end %>
</span> </span>
</div> </div>
</div> </div>
<div class="w-full lg:w-2/12 tooltip tooltip-bottom" data-tip="Start date and time"> <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>
<div class="w-full lg:w-2/12 tooltip tooltip-bottom" data-tip="End date and time"> <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>
<div class="w-full lg:w-1/12"> <div class="w-full lg:w-1/12">
<div class="flex flex-col space-y-2"> <div class="flex flex-col space-y-2">
<span class="tooltip" data-tip="<%= human_date(@start_at + 1.day) %>"> <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 %> <%= 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' %> <%= icon 'chevron-right' %>
<% end %> <% end %>
</span> </span>
@ -71,35 +69,3 @@
<% end %> <% end %>
</div> </div>
</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>

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