Implement visits merging

This commit is contained in:
Eugene Burmakin 2025-11-29 19:32:28 +01:00
parent 51a212d1fd
commit d612c82675
14 changed files with 2348 additions and 447 deletions

File diff suppressed because one or more lines are too long

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

@ -12,6 +12,23 @@ class Api::V1::PointsController < ApiController
points = current_api_user
.points
.where(timestamp: start_at..end_at)
# Filter by geographic bounds if provided
if params[:min_longitude].present? && params[:max_longitude].present? &&
params[:min_latitude].present? && params[:max_latitude].present?
min_lng = params[:min_longitude].to_f
max_lng = params[:max_longitude].to_f
min_lat = params[:min_latitude].to_f
max_lat = params[:max_latitude].to_f
# Use PostGIS to filter points within bounding box
points = points.where(
'ST_X(lonlat::geometry) BETWEEN ? AND ? AND ST_Y(lonlat::geometry) BETWEEN ? AND ?',
min_lng, max_lng, min_lat, max_lat
)
end
points = points
.order(timestamp: order)
.page(params[:page])
.per(params[:per_page] || 100)

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 V2 implementation.
## Table of Contents
- [Overview](#overview)
- [Technology Stack](#technology-stack)
- [Architecture Patterns](#architecture-patterns)
- [Directory Structure](#directory-structure)
- [Core Concepts](#core-concepts)
- [Maps V2 Architecture](#maps-v2-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 V2 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_v2/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_v2/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_v2/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_v2/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_v2_controller.js # Main map controller
│ ├── maps_v2/ # 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_v2/ # Maps V2 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 V2 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 V2 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_v2/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_v2/layer_manager.js`):
```javascript
import { NewLayer } from 'maps_v2/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_v2/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_v2/layers/heatmap_layer.js` for a simple example.
### Complete Utility Implementation
See `app/javascript/maps_v2/utils/settings_manager.js` for state management.
### Complete Service Implementation
See `app/javascript/maps_v2/services/api_client.js` for API communication.
### Complete Controller Implementation
See `app/javascript/controllers/maps_v2_controller.js` for orchestration.
---
**Questions or need help?** Check the existing code for patterns or ask in Discord: https://discord.gg/pHsBjpt5J8

View file

@ -13,6 +13,10 @@ import { EventHandlers } from './maps_v2/event_handlers'
import { FilterManager } from './maps_v2/filter_manager'
import { DateManager } from './maps_v2/date_manager'
import { lazyLoader } from 'maps_v2/utils/lazy_loader'
import { SelectionLayer } from 'maps_v2/layers/selection_layer'
import { SelectedPointsLayer } from 'maps_v2/layers/selected_points_layer'
import { pointsToGeoJSON } from 'maps_v2/utils/geojson_transformers'
import { VisitCard } from 'maps_v2/components/visit_card'
/**
* Main map controller for Maps V2
@ -58,7 +62,13 @@ export default class extends Controller {
'routesOptions',
'speedColoredToggle',
'speedColorScaleContainer',
'speedColorScaleInput'
'speedColorScaleInput',
// Area selection
'selectAreaButton',
'selectionActions',
'deleteButtonText',
'selectedVisitsContainer',
'selectedVisitsBulkActions'
]
async connect() {
@ -435,17 +445,27 @@ export default class extends Controller {
/**
* Load map data from API
* @param {Object} options - { showLoading, fitBounds, showToast }
*/
async loadMapData() {
async loadMapData(options = {}) {
const {
showLoading = true,
fitBounds = true,
showToast = true
} = options
performanceMonitor.mark('load-map-data')
this.showLoading()
if (showLoading) {
this.showLoading()
}
try {
// Fetch all map data
const data = await this.dataLoader.fetchMapData(
this.startDateValue,
this.endDateValue,
this.updateLoadingProgress.bind(this)
showLoading ? this.updateLoadingProgress.bind(this) : null
)
// Store visits for filtering
@ -481,19 +501,23 @@ export default class extends Controller {
})
}
// Fit map to data bounds
if (data.points.length > 0) {
// Fit map to data bounds (optional)
if (fitBounds && data.points.length > 0) {
this.fitMapToBounds(data.pointsGeoJSON)
}
// Show success toast
Toast.success(`Loaded ${data.points.length} location ${data.points.length === 1 ? 'point' : 'points'}`)
// Show success toast (optional)
if (showToast) {
Toast.success(`Loaded ${data.points.length} location ${data.points.length === 1 ? 'point' : 'points'}`)
}
} catch (error) {
console.error('Failed to load map data:', error)
Toast.error('Failed to load location data. Please try again.')
} finally {
this.hideLoading()
if (showLoading) {
this.hideLoading()
}
const duration = performanceMonitor.measure('load-map-data')
console.log(`[Performance] Map data loaded in ${duration}ms`)
}
@ -1372,4 +1396,593 @@ export default class extends Controller {
return routesGeoJSON
}
/**
* Start area selection mode
*/
async startSelectArea() {
console.log('[Maps V2] Starting area selection mode')
// Keep settings panel open during selection mode
// (Don't close it)
// Initialize selection layer if not exists
if (!this.selectionLayer) {
this.selectionLayer = new SelectionLayer(this.map, {
visible: true,
onSelectionComplete: this.handleAreaSelected.bind(this)
})
// Add layer to map immediately (map is already loaded at this point)
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
})
// Add layer to map immediately (map is already loaded at this point)
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.hasSelectAreaButtonTarget) {
this.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
`
// Change action to cancel
this.selectAreaButtonTarget.dataset.action = 'click->maps-v2#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 {
// Fetch both points and visits within the selected area
Toast.info('Fetching data in selected area...')
const [points, visits] = await Promise.all([
this.api.fetchPointsInArea({
start_at: this.startDateValue,
end_at: this.endDateValue,
min_longitude: bounds.minLng,
max_longitude: bounds.maxLng,
min_latitude: bounds.minLat,
max_latitude: bounds.maxLat
}),
this.api.fetchVisitsInArea({
start_at: this.startDateValue,
end_at: this.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.hasSelectionActionsTarget) {
this.selectionActionsTarget.classList.remove('hidden')
}
// Update delete button text with count
if (this.hasDeleteButtonTextTarget) {
this.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.hasSelectedVisitsContainerTarget) return
// Store visits for later use
this.selectedVisits = visits
this.selectedVisitIds = new Set()
// Generate HTML for all visit cards
const cardsHTML = visits.map(visit =>
VisitCard.create(visit, {
isSelected: false
})
).join('')
// Update container
this.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>
`
// Show container
this.selectedVisitsContainerTarget.classList.remove('hidden')
// Attach event listeners
this.attachVisitCardListeners()
// Update bulk actions after DOM updates (removes them if no visits selected)
requestAnimationFrame(() => {
this.updateBulkActions()
})
}
/**
* Attach event listeners to visit cards
*/
attachVisitCardListeners() {
// Checkbox selection
this.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()
})
})
// Confirm button
this.element.querySelectorAll('[data-visit-confirm]').forEach(btn => {
btn.addEventListener('click', async (e) => {
const button = e.currentTarget
const visitId = parseInt(button.dataset.visitConfirm)
await this.confirmVisit(visitId)
})
})
// Decline button
this.element.querySelectorAll('[data-visit-decline]').forEach(btn => {
btn.addEventListener('click', async (e) => {
const button = e.currentTarget
const visitId = parseInt(button.dataset.visitDecline)
await this.declineVisit(visitId)
})
})
}
/**
* Update bulk action buttons visibility and attach listeners
*/
updateBulkActions() {
const selectedCount = this.selectedVisitIds.size
// Remove any existing bulk action buttons from visit cards
const existingBulkActions = this.element.querySelectorAll('.bulk-actions-inline')
existingBulkActions.forEach(el => el.remove())
if (selectedCount >= 2) {
// Find the last (lowest) selected visit card
const selectedVisitCards = Array.from(this.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]
// Create bulk actions element
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>
`
// Insert after the last selected card
lastSelectedCard.insertAdjacentElement('afterend', bulkActionsDiv)
// Attach listeners
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')
// Refresh the visit card
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')
// Refresh the visit card
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')
// Clear selection state
this.selectedVisitIds.clear()
// Remove the old visit cards and add the merged one
this.replaceVisitsWithMerged(visitIds, mergedVisit)
// Update bulk actions (will remove the panel since selection is cleared)
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`)
// Clear selection state before refreshing
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`)
// Clear selection state before refreshing
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.element.querySelector('.selected-visits-list')
if (!container) return
// Find the correct position to insert BEFORE removing old cards
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)
// Skip cards that we're about to remove
if (oldVisitIds.includes(cardId)) continue
// Find the visit data for this card
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
}
}
}
// Remove old visit cards from DOM
oldVisitIds.forEach(id => {
const card = this.element.querySelector(`.visit-card[data-visit-id="${id}"]`)
if (card) {
card.remove()
}
})
// Update the selectedVisits array and sort by started_at
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))
// Create new visit card HTML
const newCardHTML = VisitCard.create(mergedVisit, { isSelected: false })
// Insert the new card in the correct position
if (insertBeforeCard) {
insertBeforeCard.insertAdjacentHTML('beforebegin', newCardHTML)
} else {
// If no card starts after this one, append to the end
container.insertAdjacentHTML('beforeend', newCardHTML)
}
// Update header count
const header = container.querySelector('h3')
if (header) {
header.textContent = `Visits in Area (${this.selectedVisits.length})`
}
// Attach event listeners to the new card
this.attachVisitCardListeners()
}
/**
* Refresh selected visits after changes
*/
async refreshSelectedVisits() {
// Re-fetch visits in the same area
const bounds = this.selectionLayer.currentRect
if (!bounds) return
try {
const visits = await this.api.fetchVisitsInArea({
start_at: this.startDateValue,
end_at: this.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')
// Clear selection layers
if (this.selectionLayer) {
this.selectionLayer.disableSelectionMode()
this.selectionLayer.clearSelection()
}
if (this.selectedPointsLayer) {
this.selectedPointsLayer.clearSelection()
}
// Clear visits
if (this.hasSelectedVisitsContainerTarget) {
this.selectedVisitsContainerTarget.classList.add('hidden')
this.selectedVisitsContainerTarget.innerHTML = ''
}
if (this.hasSelectedVisitsBulkActionsTarget) {
this.selectedVisitsBulkActionsTarget.classList.add('hidden')
}
// Clear stored data
this.selectedVisits = []
this.selectedVisitIds = new Set()
// Update UI - restore Select Area button
if (this.hasSelectAreaButtonTarget) {
this.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.selectAreaButtonTarget.classList.remove('btn-error')
this.selectAreaButtonTarget.classList.add('btn', 'btn-outline')
// Restore original action
this.selectAreaButtonTarget.dataset.action = 'click->maps-v2#startSelectArea'
}
if (this.hasSelectionActionsTarget) {
this.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
}
// Confirm deletion
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...')
// Call bulk delete API
const result = await this.api.bulkDeletePoints(pointIds)
console.log('[Maps V2] Deleted', result.count, 'points')
// Clear selection first
this.cancelAreaSelection()
// Reload map data silently (no loading overlay, no camera movement, no success toast)
await this.loadMapData({
showLoading: false,
fitBounds: false,
showToast: false
})
// Show success toast after reload
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

@ -1,376 +0,0 @@
# Maps V2 Settings Persistence
Maps V2 persists user settings across sessions and devices using a hybrid approach with backend API storage and localStorage fallback. **Settings are shared with Maps V1** for seamless migration.
## Architecture
### Dual Storage Strategy
1. **Primary: Backend API** (`/api/v1/settings`)
- Settings stored in User's `settings` JSONB column
- Syncs across all devices/browsers
- Requires authentication via API key
- **Compatible with v1 map settings**
2. **Fallback: localStorage**
- Instant save/load without network
- Browser-specific storage
- Used when backend unavailable
## Settings Stored
Maps V2 shares layer visibility settings with v1 using the `enabled_map_layers` array:
| Frontend Setting | Backend Key | Type | Default |
|-----------------|-------------|------|---------|
| `mapStyle` | `maps_v2_style` | string | `'light'` |
| `enabledMapLayers` | `enabled_map_layers` | array | `['Points', 'Routes']` |
### Layer Names
The `enabled_map_layers` array contains layer names as strings:
- `'Points'` - Individual location points
- `'Routes'` - Connected route lines
- `'Heatmap'` - Density heatmap
- `'Visits'` - Detected area visits
- `'Photos'` - Geotagged photos
- `'Areas'` - Defined areas
- `'Tracks'` - Saved tracks
- `'Fog of War'` - Explored areas
- `'Scratch map'` - Scratched countries
Internally, v2 converts these to boolean flags (e.g., `pointsVisible`, `routesVisible`) for easier state management, but always saves back to the shared array format.
## How It Works
### Initialization Flow
```
1. User opens Maps V2
2. SettingsManager.initialize(apiKey)
3. SettingsManager.sync()
4. Load from backend API
5. Merge with defaults
6. Save to localStorage (cache)
7. Return merged settings
```
### Update Flow
```
User toggles Heatmap layer
SettingsManager.updateSetting('heatmapEnabled', true)
Convert booleans → array: ['Points', 'Routes', 'Heatmap']
┌──────────────────┬──────────────────┐
│ Save to │ Save to │
│ localStorage │ Backend API │
│ (instant) │ (async) │
└──────────────────┴──────────────────┘
↓ ↓
UI updates Backend stores:
immediately { enabled_map_layers: [...] }
```
### Format Conversion
v2 internally uses boolean flags for state management but saves/loads using v1's array format:
**Loading (Array → Booleans)**:
```javascript
// Backend returns
{ enabled_map_layers: ['Points', 'Routes', 'Heatmap'] }
// Converted to
{
pointsVisible: true,
routesVisible: true,
heatmapEnabled: true,
visitsEnabled: false,
// ... etc
}
```
**Saving (Booleans → Array)**:
```javascript
// v2 state
{
pointsVisible: true,
routesVisible: false,
heatmapEnabled: true
}
// Saved as
{ enabled_map_layers: ['Points', 'Heatmap'] }
```
## API Integration
### Backend Endpoints
**GET `/api/v1/settings`**
```javascript
// Request
Headers: {
'Authorization': 'Bearer <api_key>'
}
// Response
{
"settings": {
"maps_v2_style": "dark",
"maps_v2_heatmap": true,
// ... other settings
},
"status": "success"
}
```
**PATCH `/api/v1/settings`**
```javascript
// Request
Headers: {
'Authorization': 'Bearer <api_key>',
'Content-Type': 'application/json'
}
Body: {
"settings": {
"maps_v2_style": "dark",
"maps_v2_heatmap": true
}
}
// Response
{
"message": "Settings updated",
"settings": { /* updated settings */ },
"status": "success"
}
```
## Usage Examples
### Basic Usage
```javascript
import { SettingsManager } from 'maps_v2/utils/settings_manager'
// Initialize with API key (done in controller)
SettingsManager.initialize(apiKey)
// Sync settings from backend on app load
const settings = await SettingsManager.sync()
// Get specific setting
const mapStyle = SettingsManager.getSetting('mapStyle')
// Update setting (saves to both localStorage and backend)
await SettingsManager.updateSetting('mapStyle', 'dark')
// Reset to defaults
SettingsManager.resetToDefaults()
```
### In Controller
```javascript
export default class extends Controller {
static values = { apiKey: String }
async connect() {
// Initialize settings manager
SettingsManager.initialize(this.apiKeyValue)
// Load settings (syncs from backend)
this.settings = await SettingsManager.sync()
// Use settings
const style = await getMapStyle(this.settings.mapStyle)
this.map = new maplibregl.Map({ style })
}
updateMapStyle(event) {
const style = event.target.value
// Automatically saves to both localStorage and backend
SettingsManager.updateSetting('mapStyle', style)
}
}
```
## Error Handling
The settings manager handles errors gracefully:
1. **Backend unavailable**: Falls back to localStorage
2. **localStorage full**: Logs error, uses defaults
3. **Invalid settings**: Merges with defaults
4. **Network errors**: Non-blocking, localStorage still updated
```javascript
// Example: Backend fails, but localStorage succeeds
SettingsManager.updateSetting('mapStyle', 'dark')
// → UI updates immediately (localStorage)
// → Backend save fails silently (logged to console)
// → User experience not interrupted
```
## Benefits
### Cross-Device Sync
Settings automatically sync when user logs in from different devices:
```
User enables heatmap on Desktop
Backend stores setting
User opens app on Mobile
Settings sync from backend
Heatmap enabled on Mobile too
```
### Offline Support
Works without internet connection:
```
User offline
Settings load from localStorage
User changes settings
Saves to localStorage only
User goes online
Next setting change syncs to backend
```
### Performance
- **Instant UI updates**: localStorage writes are synchronous
- **Non-blocking backend sync**: API calls don't freeze UI
- **Cached locally**: No network request on every page load
## Migration from localStorage-Only
Existing users with localStorage settings will seamlessly migrate:
```
1. Old user opens Maps V2
2. Settings manager initializes
3. Loads settings from localStorage
4. Syncs with backend (first time)
5. Backend stores localStorage settings
6. Future sessions load from backend
```
## Database Schema
Settings stored in `users.settings` JSONB column:
```sql
-- Example user settings (shared between v1 and v2)
{
"maps_v2_style": "dark",
"enabled_map_layers": ["Points", "Routes", "Heatmap", "Visits"],
// ... other settings shared by both versions
"preferred_map_layer": "OpenStreetMap",
"fog_of_war_meters": "100",
"route_opacity": 60
}
```
## Testing
### Manual Testing
1. **Test Backend Sync**
```javascript
// In browser console
SettingsManager.updateSetting('mapStyle', 'dark')
// Check Network tab for PATCH /api/v1/settings
```
2. **Test Cross-Device**
- Change setting on Device A
- Open Maps V2 on Device B
- Verify setting is synced
3. **Test Offline**
- Go offline (Network tab → Offline)
- Change settings
- Verify localStorage updated
- Go online
- Change another setting
- Verify backend receives update
### Automated Testing (Future)
```ruby
# spec/requests/api/v1/settings_controller_spec.rb
RSpec.describe 'Maps V2 Settings' do
it 'saves maps_v2 settings' do
patch '/api/v1/settings',
params: { settings: { maps_v2_style: 'dark' } },
headers: auth_headers
expect(user.reload.settings['maps_v2_style']).to eq('dark')
end
end
```
## Troubleshooting
### Settings Not Syncing
**Check API key**:
```javascript
console.log('API key set:', SettingsManager.apiKey !== null)
```
**Check network requests**:
- Open DevTools → Network
- Filter for `/api/v1/settings`
- Verify PATCH requests after setting changes
**Check backend response**:
```javascript
// Enable verbose logging
SettingsManager.sync().then(console.log)
```
### Settings Reset After Reload
**Possible causes**:
1. Backend not saving (check server logs)
2. API key invalid/expired
3. localStorage disabled (private browsing)
**Solution**:
```javascript
// Clear and resync
localStorage.removeItem('dawarich-maps-v2-settings')
await SettingsManager.sync()
```
## Future Enhancements
Possible improvements:
1. **Settings versioning**: Migrate old setting formats
2. **Conflict resolution**: Handle concurrent updates
3. **Setting presets**: Save/load named presets
4. **Export/import**: Share settings between users
5. **Real-time sync**: WebSocket updates for multi-tab support

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

@ -187,6 +187,138 @@ export class ApiClient {
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}`,

View file

@ -86,7 +86,7 @@
data-maps-v2-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-96 overflow-y-auto"
<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-v2-target="searchResults">
<!-- Results will be populated by SearchManager -->
</div>
@ -112,7 +112,7 @@
<p class="text-sm text-base-content/60 ml-14">Show individual location points</p>
</div>
<div class="divider my-2"></div>
<div class="divider"></div>
<!-- Routes Layer -->
<div class="form-control">
@ -152,7 +152,7 @@
</div>
</div>
<div class="divider my-2"></div>
<div class="divider"></div>
<!-- Heatmap Layer -->
<div class="form-control">
@ -166,7 +166,7 @@
<p class="text-sm text-base-content/60 ml-14">Show density heatmap</p>
</div>
<div class="divider my-2"></div>
<div class="divider"></div>
<!-- Visits Layer -->
<div class="form-control">
@ -196,7 +196,7 @@
</select>
</div>
<div class="divider my-2"></div>
<div class="divider"></div>
<!-- Places Layer -->
<div class="form-control">
@ -261,7 +261,7 @@
</div>
</div>
<div class="divider my-2"></div>
<div class="divider"></div>
<!-- Photos Layer -->
<div class="form-control">
@ -275,7 +275,7 @@
<p class="text-sm text-base-content/60 ml-14">Show geotagged photos</p>
</div>
<div class="divider my-2"></div>
<div class="divider"></div>
<!-- Areas Layer -->
<div class="form-control">
@ -289,7 +289,7 @@
<p class="text-sm text-base-content/60 ml-14">Show defined areas</p>
</div>
<div class="divider my-2"></div>
<div class="divider"></div>
<!-- Tracks Layer -->
<%# <div class="form-control">
@ -303,7 +303,7 @@
<p class="text-sm text-base-content/60 ml-14">Show saved tracks</p>
</div> %>
<%# <div class="divider my-2"></div> %>
<%# <div class="divider"></div> %>
<!-- Fog of War Layer -->
<div class="form-control">
@ -317,7 +317,7 @@
<p class="text-sm text-base-content/60 ml-14">Show explored areas</p>
</div>
<div class="divider my-2"></div>
<div class="divider"></div>
<!-- Scratch Map Layer -->
<div class="form-control">
@ -353,7 +353,7 @@
</select>
</div>
<div class="divider my-2"></div>
<div class="divider"></div>
<!-- Route Opacity -->
<div class="form-control w-full">
@ -377,7 +377,7 @@
</div>
</div>
<div class="divider my-2"></div>
<div class="divider"></div>
<!-- Fog of War Settings -->
<div class="form-control w-full">
@ -422,7 +422,7 @@
<p class="text-xs text-base-content/60 mt-1">Minimum points to clear fog</p>
</div>
<div class="divider my-2"></div>
<div class="divider"></div>
<!-- Route Generation Settings -->
<div class="form-control w-full">
@ -467,7 +467,7 @@
<p class="text-xs text-base-content/60 mt-1">Time threshold for route splitting</p>
</div>
<div class="divider my-2"></div>
<div class="divider"></div>
<!-- Points Rendering Mode -->
<div class="form-control w-full">
@ -493,7 +493,7 @@
</div>
</div>
<div class="divider my-2"></div>
<div class="divider"></div>
<!-- Speed-Colored Routes -->
<div class="form-control">
@ -506,7 +506,7 @@
<p class="text-sm text-base-content/60 mt-1">Color routes by speed</p>
</div>
<div class="divider my-2"></div>
<div class="divider"></div>
<!-- Live Mode -->
<div class="form-control">
@ -520,10 +520,10 @@
<p class="text-sm text-base-content/60 mt-1">Show new points in real-time</p>
</div>
<div class="divider my-2"></div>
<div class="divider"></div>
<!-- Update Button -->
<button type="submit" class="btn btn-primary btn-block">
<button type="submit" class="btn btn-sm btn-primary btn-block">
<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">
<path d="M21 12a9 9 0 1 1-6.219-8.56"></path>
<polyline points="12 2 12 12 14.5 14.5"></polyline>
@ -533,7 +533,7 @@
<!-- Reset Settings -->
<button type="button"
class="btn btn-outline btn-block"
class="btn btn-sm btn-outline btn-block"
data-action="click->maps-v2#resetSettings">
<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">
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"></path>
@ -549,46 +549,54 @@
<!-- Tools Tab -->
<div class="tab-content" data-tab-content="tools" data-map-panel-target="tabContent">
<div class="space-y-4">
<!-- Create a Visit Button -->
<button type="button"
class="btn btn-primary btn-block"
data-action="click->maps-v2#startCreateVisit">
<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">
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path>
<circle cx="12" cy="10" r="3"></circle>
</svg>
Create a Visit
</button>
<!-- Tools Grid: 2 columns on md+ screens, 1 column on smaller screens -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<!-- Create a Visit Button -->
<button type="button"
class="btn btn-sm btn-primary"
data-action="click->maps-v2#startCreateVisit">
<%= icon 'map-pin-plus' %>
Create a Visit
</button>
<div class="divider my-2"></div>
<!-- Create a Place Button -->
<button type="button"
class="btn btn-sm btn-outline"
data-action="click->maps-v2#startCreatePlace">
<%= icon 'map-pin-plus' %>
Create a Place
</button>
<!-- Create a Place Button -->
<button type="button"
class="btn btn-outline btn-block"
data-action="click->maps-v2#startCreatePlace">
<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">
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"></path>
<circle cx="12" cy="10" r="3"></circle>
</svg>
Create a Place
</button>
<!-- Select Area Button -->
<button type="button"
class="btn btn-sm btn-outline md:col-span-2"
data-maps-v2-target="selectAreaButton"
data-action="click->maps-v2#startSelectArea">
<%= icon 'square-dashed-mouse-pointer' %>
Select Area
</button>
</div>
<div class="divider my-2"></div>
<!-- Selection Actions (shown after area is selected) -->
<div class="hidden mt-4 space-y-2" data-maps-v2-target="selectionActions">
<button type="button"
class="btn btn-sm btn-outline btn-error btn-block"
data-action="click->maps-v2#deleteSelectedPoints"
data-maps-v2-target="deletePointsButton">
<%= icon 'trash-2' %>
<span data-maps-v2-target="deleteButtonText">Delete Selected Points</span>
</button>
<!-- Select Area Button -->
<button type="button"
class="btn btn-outline btn-block"
disabled>
<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
</button>
<p class="text-xs text-base-content/60 text-center">Coming soon</p>
<!-- Selected Visits Container -->
<div class="hidden mt-4 max-h-full overflow-y-auto" data-maps-v2-target="selectedVisitsContainer">
<!-- Visit cards will be dynamically inserted here -->
</div>
<!-- Bulk Actions for Visits -->
<div class="hidden" data-maps-v2-target="selectedVisitsBulkActions">
<!-- Bulk action buttons will be dynamically inserted here -->
</div>
</div>
</div>
</div>
@ -607,7 +615,7 @@
</div>
</div>
<div class="divider my-2"></div>
<div class="divider"></div>
<!-- Docs Section -->
<div>
@ -621,7 +629,7 @@
</div>
</div>
<div class="divider my-2"></div>
<div class="divider"></div>
<!-- More Section -->
<div>

View file

@ -134,12 +134,12 @@ Rails.application.routes.draw do
get 'suggestions'
end
end
resources :points, only: %i[index create update destroy] do
resources :points, only: %i[index create update destroy] do
collection do
delete :bulk_destroy
end
end
resources :visits, only: %i[index create update destroy] do
resources :visits, only: %i[index create update destroy] do
get 'possible_places', to: 'visits/possible_places#index', on: :member
collection do
post 'merge', to: 'visits#merge'

View file

@ -90,3 +90,16 @@ export async function getMapZoom(page) {
return controller?.map?.getZoom() || null;
});
}
/**
* Wait for MapLibre map (Maps V2) to be fully initialized
* @param {Page} page - Playwright page object
*/
export async function waitForMapLoad(page) {
await page.waitForFunction(() => {
return window.map && window.map.loaded();
}, { timeout: 10000 });
// Wait for initial data load to complete
await page.waitForSelector('[data-maps-v2-target="loading"].hidden', { timeout: 15000 });
}

View file

@ -0,0 +1,317 @@
import { test, expect } from '@playwright/test'
import { closeOnboardingModal } from '../../helpers/navigation.js'
import { waitForMapLibre, waitForLoadingComplete } from '../helpers/setup.js'
test.describe('Area Selection in Maps V2', () => {
test.beforeEach(async ({ page }) => {
// Navigate to Maps V2 with specific date range that has data
await page.goto('/maps_v2?start_at=2025-10-15T00:00&end_at=2025-10-15T23:59')
await closeOnboardingModal(page)
await waitForMapLibre(page)
await waitForLoadingComplete(page)
// Wait a bit for data to load
await page.waitForTimeout(1000)
})
test('should enable area selection mode when clicking Select Area button', async ({ page }) => {
// Open settings panel and switch to Tools tab
await page.click('[data-action="click->maps-v2#toggleSettings"]')
await page.click('button[data-tab="tools"]')
// Click Select Area button
await page.click('[data-maps-v2-target="selectAreaButton"]')
// Wait a moment for UI to update
await page.waitForTimeout(100)
// Verify the button changes to Cancel Selection
const selectButton = page.locator('[data-maps-v2-target="selectAreaButton"]')
await expect(selectButton).toContainText('Cancel Selection', { timeout: 2000 })
await expect(selectButton).toHaveClass(/btn-error/)
// Verify cursor changes to crosshair (via canvas style)
const canvas = page.locator('canvas.maplibregl-canvas')
const cursorStyle = await canvas.evaluate(el => window.getComputedStyle(el).cursor)
expect(cursorStyle).toBe('crosshair')
// Verify toast notification appears
await expect(page.locator('.toast, [role="alert"]').filter({ hasText: 'Draw a rectangle' })).toBeVisible({ timeout: 5000 })
})
test('should draw selection rectangle when dragging mouse', async ({ page }) => {
// Open settings panel and switch to Tools tab
await page.click('[data-action="click->maps-v2#toggleSettings"]')
await page.click('button[data-tab="tools"]')
// Click Select Area button
await page.click('[data-maps-v2-target="selectAreaButton"]')
// Wait for selection mode to be enabled
await page.waitForTimeout(500)
// Check if selection layer has been added to map
const hasSelectionLayer = await page.evaluate(() => {
const element = document.querySelector('[data-controller="maps-v2"]')
const app = window.Stimulus || window.Application
const controller = app.getControllerForElementAndIdentifier(element, 'maps-v2')
return controller.selectionLayer !== undefined
})
expect(hasSelectionLayer).toBeTruthy()
// Get map canvas
const canvas = page.locator('canvas.maplibregl-canvas')
const box = await canvas.boundingBox()
// Draw selection rectangle with fewer steps to avoid timeout
await page.mouse.move(box.x + 100, box.y + 100)
await page.mouse.down()
await page.mouse.move(box.x + 300, box.y + 300, { steps: 3 })
await page.mouse.up()
// Wait for API call to complete (or timeout gracefully)
await page.waitForResponse(response =>
response.url().includes('/api/v1/points') &&
response.url().includes('min_longitude'),
{ timeout: 5000 }
).catch(() => null)
})
test('should show selection actions when points are selected', async ({ page }) => {
// Open settings panel and switch to Tools tab
await page.click('[data-action="click->maps-v2#toggleSettings"]')
await page.click('button[data-tab="tools"]')
// Click Select Area button
await page.click('[data-maps-v2-target="selectAreaButton"]')
// Get map canvas
const canvas = page.locator('canvas.maplibregl-canvas')
const box = await canvas.boundingBox()
// Draw selection rectangle over map center
await page.mouse.move(box.x + box.width / 2 - 100, box.y + box.height / 2 - 100)
await page.mouse.down()
await page.mouse.move(box.x + box.width / 2 + 100, box.y + box.height / 2 + 100, { steps: 10 })
await page.mouse.up()
// Wait for API call to complete
await page.waitForResponse(response =>
response.url().includes('/api/v1/points'),
{ timeout: 5000 }
).catch(() => null)
// Wait for potential updates
await page.waitForTimeout(1000)
// If points were found, verify UI updates
const selectionActions = page.locator('[data-maps-v2-target="selectionActions"]')
const isVisible = await selectionActions.isVisible().catch(() => false)
if (isVisible) {
// Verify delete button is visible and shows count
const deleteButton = page.locator('[data-maps-v2-target="deleteButtonText"]')
await expect(deleteButton).toBeVisible()
// Wait for button text to update with count
await expect(deleteButton).toContainText(/Delete \d+ Points?/, { timeout: 2000 })
// Verify the Select Area button has changed to Cancel Selection (at top of tools)
const selectButton = page.locator('[data-maps-v2-target="selectAreaButton"]')
await expect(selectButton).toContainText('Cancel Selection')
}
})
test('should cancel area selection', async ({ page }) => {
// Open settings panel and switch to Tools tab
await page.click('[data-action="click->maps-v2#toggleSettings"]')
await page.click('button[data-tab="tools"]')
// Click Select Area button
await page.click('[data-maps-v2-target="selectAreaButton"]')
// Wait for selection mode
await page.waitForTimeout(500)
// Get map canvas
const canvas = page.locator('canvas.maplibregl-canvas')
const box = await canvas.boundingBox()
// Draw selection rectangle with fewer steps
await page.mouse.move(box.x + box.width / 2 - 100, box.y + box.height / 2 - 100)
await page.mouse.down()
await page.mouse.move(box.x + box.width / 2 + 100, box.y + box.height / 2 + 100, { steps: 3 })
await page.mouse.up()
// Wait for API call
await page.waitForResponse(response =>
response.url().includes('/api/v1/points'),
{ timeout: 5000 }
).catch(() => null)
await page.waitForTimeout(500)
// Check if selection actions are visible
const selectionActions = page.locator('[data-maps-v2-target="selectionActions"]')
const isVisible = await selectionActions.isVisible().catch(() => false)
if (isVisible) {
// Click Cancel button (the red one at the top that replaced Select Area)
const cancelButton = page.locator('[data-maps-v2-target="selectAreaButton"]')
await expect(cancelButton).toContainText('Cancel Selection')
await cancelButton.click()
// Verify selection actions are hidden
await expect(selectionActions).toBeHidden()
// Verify Select Area button is restored
await expect(cancelButton).toContainText('Select Area')
await expect(cancelButton).toHaveClass(/btn-outline/)
await expect(cancelButton).not.toHaveClass(/btn-error/)
}
})
test('should display delete confirmation dialog', async ({ page }) => {
// Open settings panel and switch to Tools tab
await page.click('[data-action="click->maps-v2#toggleSettings"]')
await page.click('button[data-tab="tools"]')
// Click Select Area button
await page.click('[data-maps-v2-target="selectAreaButton"]')
// Get map canvas
const canvas = page.locator('canvas.maplibregl-canvas')
const box = await canvas.boundingBox()
// Draw selection rectangle
await page.mouse.move(box.x + box.width / 2 - 100, box.y + box.height / 2 - 100)
await page.mouse.down()
await page.mouse.move(box.x + box.width / 2 + 100, box.y + box.height / 2 + 100, { steps: 10 })
await page.mouse.up()
// Wait for API call
await page.waitForResponse(response =>
response.url().includes('/api/v1/points'),
{ timeout: 5000 }
).catch(() => null)
await page.waitForTimeout(500)
// Check if selection actions are visible
const selectionActions = page.locator('[data-maps-v2-target="selectionActions"]')
const isVisible = await selectionActions.isVisible().catch(() => false)
if (isVisible) {
// Setup dialog handler before clicking
let dialogShown = false
page.once('dialog', async dialog => {
dialogShown = true
expect(dialog.message()).toContain('Are you sure')
expect(dialog.message()).toContain('delete')
await dialog.dismiss()
})
// Click Delete button (text now includes count like "Delete 100 Points")
await page.locator('[data-maps-v2-target="deletePointsButton"]').click()
// Wait for dialog to be handled
await page.waitForTimeout(1000)
// Verify dialog was shown
expect(dialogShown).toBe(true)
// Verify selection is still active (because we dismissed)
await expect(selectionActions).toBeVisible()
}
})
test('should have API support for geographic bounds filtering', async ({ page }) => {
// Test that the backend accepts geographic bounds parameters
// by verifying the API call is made with the correct parameters when selecting an area
// Open settings panel and switch to Tools tab
await page.click('[data-action="click->maps-v2#toggleSettings"]')
await page.click('button[data-tab="tools"]')
// Click Select Area button
await page.click('[data-maps-v2-target="selectAreaButton"]')
await page.waitForTimeout(500)
// Get map canvas
const canvas = page.locator('canvas.maplibregl-canvas')
const box = await canvas.boundingBox()
// Set up network listener before drawing
let hasGeoBounds = false
page.on('request', request => {
if (request.url().includes('/api/v1/points')) {
const url = new URL(request.url(), 'http://localhost')
if (url.searchParams.has('min_longitude') &&
url.searchParams.has('max_longitude') &&
url.searchParams.has('min_latitude') &&
url.searchParams.has('max_latitude')) {
hasGeoBounds = true
}
}
})
// Draw selection rectangle
await page.mouse.move(box.x + 100, box.y + 100)
await page.mouse.down()
await page.mouse.move(box.x + 200, box.y + 200, { steps: 2 })
await page.mouse.up()
// Wait for API call
await page.waitForTimeout(2000)
// Verify the API was called with geographic bounds parameters
expect(hasGeoBounds).toBe(true)
})
test('should add selected points layer to map when points are selected', async ({ page }) => {
// Open settings panel and switch to Tools tab
await page.click('[data-action="click->maps-v2#toggleSettings"]')
await page.click('button[data-tab="tools"]')
// Click Select Area button
await page.click('[data-maps-v2-target="selectAreaButton"]')
// Get map canvas
const canvas = page.locator('canvas.maplibregl-canvas')
const box = await canvas.boundingBox()
// Draw selection rectangle
await page.mouse.move(box.x + box.width / 2 - 50, box.y + box.height / 2 - 50)
await page.mouse.down()
await page.mouse.move(box.x + box.width / 2 + 50, box.y + box.height / 2 + 50, { steps: 5 })
await page.mouse.up()
// Wait for API call
await page.waitForResponse(response =>
response.url().includes('/api/v1/points'),
{ timeout: 5000 }
).catch(() => null)
await page.waitForTimeout(500)
// Check if selected points layer exists
const hasSelectedPointsLayer = await page.evaluate(() => {
const element = document.querySelector('[data-controller="maps-v2"]')
const app = window.Stimulus || window.Application
const controller = app.getControllerForElementAndIdentifier(element, 'maps-v2')
return controller?.selectedPointsLayer !== undefined
})
// If points were selected, layer should exist
if (hasSelectedPointsLayer) {
// Verify layer is on the map
const layerExistsOnMap = await page.evaluate(() => {
const element = document.querySelector('[data-controller="maps-v2"]')
const app = window.Stimulus || window.Application
const controller = app.getControllerForElementAndIdentifier(element, 'maps-v2')
return controller?.map?.getLayer('selected-points') !== undefined
})
expect(layerExistsOnMap).toBeTruthy()
}
})
})