mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-11 09:41:40 -05:00
Implement visits merging
This commit is contained in:
parent
51a212d1fd
commit
d612c82675
14 changed files with 2348 additions and 447 deletions
File diff suppressed because one or more lines are too long
1
app/assets/svg/icons/lucide/outline/x.svg
Normal file
1
app/assets/svg/icons/lucide/outline/x.svg
Normal 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 |
|
|
@ -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
724
app/javascript/README.md
Normal 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
|
||||
|
|
@ -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.')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
156
app/javascript/maps_v2/components/visit_card.js
Normal file
156
app/javascript/maps_v2/components/visit_card.js
Normal 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>
|
||||
`
|
||||
}
|
||||
}
|
||||
96
app/javascript/maps_v2/layers/selected_points_layer.js
Normal file
96
app/javascript/maps_v2/layers/selected_points_layer.js
Normal 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
|
||||
}
|
||||
}
|
||||
200
app/javascript/maps_v2/layers/selection_layer.js
Normal file
200
app/javascript/maps_v2/layers/selection_layer.js
Normal 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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}`,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
317
e2e/v2/map/area-selection.spec.js
Normal file
317
e2e/v2/map/area-selection.spec.js
Normal 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()
|
||||
}
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue