dawarich/app/javascript/README.md
Evgenii Burmakin 8934c29fce
0.36.2 (#2007)
* 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>
2025-12-06 20:54:49 +01:00

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

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 targets for DOM element references
  • Use values for passing data from HTML
  • Always cleanup in disconnect()

2. Service Classes

Purpose: Encapsulate business logic and API communication

Location: app/javascript/maps_maplibre/services/

Pattern:

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() and getLayerConfigs()
  • Store data in this.data
  • Use this.visible for visibility state
  • Inherit common methods: add(), update(), show(), hide(), toggle()

4. Utility Modules

Purpose: Provide reusable helper functions

Location: app/javascript/maps_maplibre/utils/

Pattern:

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:

  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:

// 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):

  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:

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 array
  • visitsToGeoJSON(visits) - Convert visits
  • photosToGeoJSON(photos) - Convert photos
  • placesToGeoJSON(places) - Convert places
  • areasToGeoJSON(areas) - Convert circular areas to polygons

Creating New Features

Adding a New Layer

  1. Create layer class in app/javascript/maps_maplibre/layers/:
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 */ }
    }]
  }
}
  1. 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)
  }
}
  1. Add to settings (utils/settings_manager.js):
const DEFAULT_SETTINGS = {
  // ...
  newLayerEnabled: false
}

const LAYER_NAME_MAP = {
  // ...
  'New Layer': 'newLayerEnabled'
}
  1. Add UI controls in view template.

Adding a New API Endpoint

  1. 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()
}
  1. Add transformation in DataLoader:
newDataToGeoJSON(data) {
  return {
    type: 'FeatureCollection',
    features: data.map(item => ({
      type: 'Feature',
      geometry: { /* ... */ },
      properties: { /* ... */ }
    }))
  }
}
  1. 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

  1. 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()
  1. Import and use:
import { NewUtility } from 'maps_maplibre/utils/new_utility'

const result = NewUtility.calculate(input)

Best Practices

Code Style

  1. Use ES6+ features:

    • Arrow functions
    • Template literals
    • Destructuring
    • Async/await
    • Classes
  2. Naming conventions:

    • Classes: PascalCase
    • Methods/variables: camelCase
    • Constants: UPPER_SNAKE_CASE
    • Files: snake_case.js
  3. Always use semicolons for statement termination

  4. Prefer const over let, avoid var

Performance

  1. Lazy load heavy features:

    const Layer = await lazyLoader.loadLayer('name')
    
  2. Debounce frequent operations:

    let timeout
    function onInput(e) {
      clearTimeout(timeout)
      timeout = setTimeout(() => actualWork(e), 300)
    }
    
  3. Use performance monitoring:

    performanceMonitor.mark('operation')
    // ... do work
    performanceMonitor.measure('operation')
    
  4. Minimize DOM manipulations - batch updates when possible

Error Handling

  1. Always handle promise rejections:

    try {
      const data = await fetchData()
    } catch (error) {
      console.error('Failed:', error)
      Toast.error('Operation failed')
    }
    
  2. Provide user feedback:

    Toast.success('Data loaded')
    Toast.error('Failed to load data')
    Toast.info('Click map to add point')
    
  3. Log errors for debugging:

    console.error('[Component] Error details:', error)
    

Memory Management

  1. Always cleanup in disconnect():

    disconnect() {
      this.searchManager?.destroy()
      this.cleanup.cleanup()
      this.map?.remove()
    }
    
  2. Use CleanupHelper for event listeners:

    this.cleanup = new CleanupHelper()
    this.cleanup.addEventListener(element, 'click', handler)
    
    // In disconnect():
    this.cleanup.cleanup() // Removes all listeners
    
  3. 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

  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:

    SettingsManager.updateSetting('key', value)
    // Saves to both localStorage and backend
    
  3. Restore state on load:

    async connect() {
      this.settings = await SettingsManager.sync()
      this.syncToggleStates()
    }
    

Documentation

  1. 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 }) {
      // ...
    }
    
  2. Document complex logic with inline comments

  3. Keep this README updated when adding major features

Code Organization

  1. One class per file - easier to find and maintain
  2. Group related functionality in directories
  3. Use index files for barrel exports when needed
  4. Avoid circular dependencies - use dependency injection

Migration from Maps V1 to V2

When updating features, follow this pattern:

  1. Keep V1 working - V2 is opt-in
  2. Share utilities where possible (e.g., color calculations)
  3. Use same API endpoints - maintain compatibility
  4. Document differences in code comments

Examples

Complete Layer Implementation

See app/javascript/maps_maplibre/layers/heatmap_layer.js for a simple example.

Complete Utility Implementation

See app/javascript/maps_maplibre/utils/settings_manager.js for state management.

Complete Service Implementation

See app/javascript/maps_maplibre/services/api_client.js for API communication.

Complete Controller Implementation

See app/javascript/controllers/maps/maplibre_controller.js for orchestration.


Questions or need help? Check the existing code for patterns or ask in Discord: https://discord.gg/pHsBjpt5J8