* fix: move foreman to global gems to fix startup crash (#1971) * Update exporting code to stream points data to file in batches to red… (#1980) * Update exporting code to stream points data to file in batches to reduce memory usage * Update changelog * Update changelog * Feature/maplibre frontend (#1953) * Add a plan to use MapLibre GL JS for the frontend map rendering, replacing Leaflet * Implement phase 1 * Phases 1-3 + part of 4 * Fix e2e tests * Phase 6 * Implement fog of war * Phase 7 * Next step: fix specs, phase 7 done * Use our own map tiles * Extract v2 map logic to separate manager classes * Update settings panel on v2 map * Update v2 e2e tests structure * Reimplement location search in maps v2 * Update speed routes * Implement visits and places creation in v2 * Fix last failing test * Implement visits merging * Fix a routes e2e test and simplify the routes layer styling. * Extract js to modules from maps_v2_controller.js * Implement area creation * Fix spec problem * Fix some e2e tests * Implement live mode in v2 map * Update icons and panel * Extract some styles * Remove unused file * Start adding dark theme to popups on MapLibre maps * Make popups respect dark theme * Move v2 maps to maplibre namespace * Update v2 references to maplibre * Put place, area and visit info into side panel * Update API to use safe settings config method * Fix specs * Fix method name to config in SafeSettings and update usages accordingly * Add missing public files * Add handling for real time points * Fix remembering enabled/disabled layers of the v2 map * Fix lots of e2e tests * Add settings to select map version * Use maps/v2 as main path for MapLibre maps * Update routing * Update live mode * Update maplibre controller * Update changelog * Remove some console.log statements --------- Co-authored-by: Robin Tuszik <mail@robin.gg>
17 KiB
Dawarich JavaScript Architecture
This document provides a comprehensive guide to the JavaScript architecture used in the Dawarich application, with a focus on the Maps (MapLibre) implementation.
Table of Contents
- Overview
- Technology Stack
- Architecture Patterns
- Directory Structure
- Core Concepts
- Maps (MapLibre) Architecture
- Creating New Features
- Best Practices
Overview
Dawarich uses a modern JavaScript architecture built on Hotwire (Turbo + Stimulus) for page interactions and MapLibre GL JS for map rendering. The Maps (MapLibre) implementation follows object-oriented principles with clear separation of concerns.
Technology Stack
- Stimulus - Modest JavaScript framework for sprinkles of interactivity
- Turbo Rails - SPA-like page navigation without building an SPA
- MapLibre GL JS - Open-source map rendering engine
- ES6 Modules - Modern JavaScript module system
- Tailwind CSS + DaisyUI - Utility-first CSS framework
Architecture Patterns
1. Stimulus Controllers
Purpose: Connect DOM elements to JavaScript behavior
Location: app/javascript/controllers/
Pattern:
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
targetsfor DOM element references - Use
valuesfor passing data from HTML - Always cleanup in
disconnect()
2. Service Classes
Purpose: Encapsulate business logic and API communication
Location: app/javascript/maps_maplibre/services/
Pattern:
export class ApiClient {
constructor(apiKey) {
this.apiKey = apiKey
}
async fetchData() {
const response = await fetch(url, {
headers: this.getHeaders()
})
return response.json()
}
}
Key Principles:
- Single responsibility - one service per concern
- Consistent error handling
- Return promises for async operations
- Use constructor injection for dependencies
3. Layer Classes (Map Layers)
Purpose: Manage map visualization layers
Location: app/javascript/maps_maplibre/layers/
Pattern:
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()andgetLayerConfigs() - Store data in
this.data - Use
this.visiblefor visibility state - Inherit common methods:
add(),update(),show(),hide(),toggle()
4. Utility Modules
Purpose: Provide reusable helper functions
Location: app/javascript/maps_maplibre/utils/
Pattern:
export class UtilityClass {
static helperMethod(param) {
// Static methods for stateless utilities
}
}
// Or singleton pattern
export const utilityInstance = new UtilityClass()
5. Component Classes
Purpose: Reusable UI components
Location: app/javascript/maps_maplibre/components/
Pattern:
export class PopupFactory {
static createPopup(data) {
return `<div>${data.name}</div>`
}
}
Directory Structure
app/javascript/
├── application.js # Entry point
├── controllers/ # Stimulus controllers
│ ├── maps/maplibre_controller.js # Main map controller
│ ├── maps_maplibre/ # Controller modules
│ │ ├── layer_manager.js # Layer lifecycle management
│ │ ├── data_loader.js # API data fetching
│ │ ├── event_handlers.js # Map event handling
│ │ ├── filter_manager.js # Data filtering
│ │ └── date_manager.js # Date range management
│ └── ... # Other controllers
├── maps_maplibre/ # Maps (MapLibre) implementation
│ ├── layers/ # Map layer classes
│ │ ├── base_layer.js # Abstract base class
│ │ ├── points_layer.js # Point markers
│ │ ├── routes_layer.js # Route lines
│ │ ├── heatmap_layer.js # Heatmap visualization
│ │ ├── visits_layer.js # Visit markers
│ │ ├── photos_layer.js # Photo markers
│ │ ├── places_layer.js # Places markers
│ │ ├── areas_layer.js # User-defined areas
│ │ ├── fog_layer.js # Fog of war overlay
│ │ └── scratch_layer.js # Scratch map
│ ├── services/ # API and external services
│ │ ├── api_client.js # REST API wrapper
│ │ └── location_search_service.js
│ ├── utils/ # Helper utilities
│ │ ├── settings_manager.js # User preferences
│ │ ├── geojson_transformers.js
│ │ ├── performance_monitor.js
│ │ ├── lazy_loader.js # Code splitting
│ │ └── ...
│ ├── components/ # Reusable UI components
│ │ ├── popup_factory.js # Map popup generator
│ │ ├── toast.js # Toast notifications
│ │ └── ...
│ └── channels/ # ActionCable channels
│ └── map_channel.js # Real-time updates
└── maps/ # Legacy Maps V1 (being phased out)
Core Concepts
Manager Pattern
The Maps (MapLibre) controller delegates responsibilities to specialized managers:
- LayerManager - Layer lifecycle (add/remove/toggle/update)
- DataLoader - API data fetching and transformation
- EventHandlers - Map interaction events
- FilterManager - Data filtering and searching
- DateManager - Date range calculations
- 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:
// Dispatch
document.dispatchEvent(new CustomEvent('visit:created', {
detail: { visitId: 123 }
}))
// Listen
document.addEventListener('visit:created', (event) => {
console.log(event.detail.visitId)
})
Map Events:
map.on('click', 'layer-id', (e) => {
const feature = e.features[0]
// Handle click
})
Maps (MapLibre) Architecture
Layer Hierarchy
Layers are rendered in specific order (bottom to top):
- Scratch Layer - Visited countries/regions overlay
- Heatmap Layer - Point density visualization
- Areas Layer - User-defined circular areas
- Tracks Layer - Imported GPS tracks
- Routes Layer - Generated routes from points
- Visits Layer - Detected visits to places
- Places Layer - Named locations
- Photos Layer - Photos with geolocation
- Family Layer - Real-time family member locations
- Points Layer - Individual location points
- Fog Layer - Canvas overlay showing unexplored areas
BaseLayer Pattern
All layers extend BaseLayer which provides:
Methods:
add(data)- Add layer to mapupdate(data)- Update layer dataremove()- Remove layer from mapshow()/hide()- Toggle visibilitytoggle(visible)- Set visibility state
Abstract Methods (must implement):
getSourceConfig()- MapLibre source configurationgetLayerConfigs()- Array of MapLibre layer configurations
Example Implementation:
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:
// 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:
// 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 arrayvisitsToGeoJSON(visits)- Convert visitsphotosToGeoJSON(photos)- Convert photosplacesToGeoJSON(places)- Convert placesareasToGeoJSON(areas)- Convert circular areas to polygons
Creating New Features
Adding a New Layer
- Create layer class in
app/javascript/maps_maplibre/layers/:
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 */ }
}]
}
}
- Register in LayerManager (
controllers/maps_maplibre/layer_manager.js):
import { NewLayer } from 'maps_maplibre/layers/new_layer'
// In addAllLayers method
_addNewLayer(dataGeoJSON) {
if (!this.layers.newLayer) {
this.layers.newLayer = new NewLayer(this.map, {
visible: this.settings.newLayerEnabled || false
})
this.layers.newLayer.add(dataGeoJSON)
} else {
this.layers.newLayer.update(dataGeoJSON)
}
}
- Add to settings (
utils/settings_manager.js):
const DEFAULT_SETTINGS = {
// ...
newLayerEnabled: false
}
const LAYER_NAME_MAP = {
// ...
'New Layer': 'newLayerEnabled'
}
- Add UI controls in view template.
Adding a New API Endpoint
- Add method to ApiClient (
services/api_client.js):
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()
}
- Add transformation in DataLoader:
newDataToGeoJSON(data) {
return {
type: 'FeatureCollection',
features: data.map(item => ({
type: 'Feature',
geometry: { /* ... */ },
properties: { /* ... */ }
}))
}
}
- Use in controller:
const data = await this.api.fetchNewData({ param1, param2 })
const geojson = this.dataLoader.newDataToGeoJSON(data)
this.layerManager.updateLayer('new-layer', geojson)
Adding a New Utility
- Create utility file in
utils/:
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()
- Import and use:
import { NewUtility } from 'maps_maplibre/utils/new_utility'
const result = NewUtility.calculate(input)
Best Practices
Code Style
-
Use ES6+ features:
- Arrow functions
- Template literals
- Destructuring
- Async/await
- Classes
-
Naming conventions:
- Classes:
PascalCase - Methods/variables:
camelCase - Constants:
UPPER_SNAKE_CASE - Files:
snake_case.js
- Classes:
-
Always use semicolons for statement termination
-
Prefer
constoverlet, avoidvar
Performance
-
Lazy load heavy features:
const Layer = await lazyLoader.loadLayer('name') -
Debounce frequent operations:
let timeout function onInput(e) { clearTimeout(timeout) timeout = setTimeout(() => actualWork(e), 300) } -
Use performance monitoring:
performanceMonitor.mark('operation') // ... do work performanceMonitor.measure('operation') -
Minimize DOM manipulations - batch updates when possible
Error Handling
-
Always handle promise rejections:
try { const data = await fetchData() } catch (error) { console.error('Failed:', error) Toast.error('Operation failed') } -
Provide user feedback:
Toast.success('Data loaded') Toast.error('Failed to load data') Toast.info('Click map to add point') -
Log errors for debugging:
console.error('[Component] Error details:', error)
Memory Management
-
Always cleanup in disconnect():
disconnect() { this.searchManager?.destroy() this.cleanup.cleanup() this.map?.remove() } -
Use CleanupHelper for event listeners:
this.cleanup = new CleanupHelper() this.cleanup.addEventListener(element, 'click', handler) // In disconnect(): this.cleanup.cleanup() // Removes all listeners -
Remove map layers and sources:
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
- Keep methods small and focused - easier to test
- Avoid tight coupling - use dependency injection
- Separate pure functions from side effects
- Use static methods for stateless utilities
State Management
-
Single source of truth:
- Settings:
SettingsManager - Layer data: Layer instances
- UI state: Controller properties
- Settings:
-
Sync state with backend:
SettingsManager.updateSetting('key', value) // Saves to both localStorage and backend -
Restore state on load:
async connect() { this.settings = await SettingsManager.sync() this.syncToggleStates() }
Documentation
-
Add JSDoc comments for public APIs:
/** * 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 }) { // ... } -
Document complex logic with inline comments
-
Keep this README updated when adding major features
Code Organization
- One class per file - easier to find and maintain
- Group related functionality in directories
- Use index files for barrel exports when needed
- Avoid circular dependencies - use dependency injection
Migration from Maps V1 to V2
When updating features, follow this pattern:
- Keep V1 working - V2 is opt-in
- Share utilities where possible (e.g., color calculations)
- Use same API endpoints - maintain compatibility
- Document differences in code comments
Examples
Complete Layer Implementation
See app/javascript/maps_maplibre/layers/heatmap_layer.js for a simple example.
Complete Utility Implementation
See app/javascript/maps_maplibre/utils/settings_manager.js for state management.
Complete Service Implementation
See app/javascript/maps_maplibre/services/api_client.js for API communication.
Complete Controller Implementation
See app/javascript/controllers/maps/maplibre_controller.js for orchestration.
Questions or need help? Check the existing code for patterns or ask in Discord: https://discord.gg/pHsBjpt5J8