app/javascript/README.md
This document provides a comprehensive guide to the JavaScript architecture used in the Dawarich application, with a focus on the Maps (MapLibre) implementation.
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.
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:
targets for DOM element referencesvalues for passing data from HTMLdisconnect()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:
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:
BaseLayergetSourceConfig() and getLayerConfigs()this.datathis.visible for visibility stateadd(), update(), show(), hide(), toggle()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()
Purpose: Reusable UI components
Location: app/javascript/maps_maplibre/components/
Pattern:
export class PopupFactory {
static createPopup(data) {
return `<div>${data.name}</div>`
}
}
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)
The Maps (MapLibre) controller delegates responsibilities to specialized managers:
Benefits:
User Action
↓
Stimulus Controller Method
↓
Manager (e.g., DataLoader)
↓
Service (e.g., ApiClient)
↓
API Endpoint
↓
Transform to GeoJSON
↓
Update Layer
↓
MapLibre Renders
Settings Persistence:
/api/v1/settings)Layer State:
this.visible, this.data)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
})
Layers are rendered in specific order (bottom to top):
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 stateAbstract Methods (must implement):
getSourceConfig() - MapLibre source configurationgetLayerConfigs() - Array of MapLibre layer configurationsExample 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'
}
}]
}
}
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:
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 polygonsapp/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 */ }
}]
}
}
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)
}
}
utils/settings_manager.js):const DEFAULT_SETTINGS = {
// ...
newLayerEnabled: false
}
const LAYER_NAME_MAP = {
// ...
'New Layer': 'newLayerEnabled'
}
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()
}
newDataToGeoJSON(data) {
return {
type: 'FeatureCollection',
features: data.map(item => ({
type: 'Feature',
geometry: { /* ... */ },
properties: { /* ... */ }
}))
}
}
const data = await this.api.fetchNewData({ param1, param2 })
const geojson = this.dataLoader.newDataToGeoJSON(data)
this.layerManager.updateLayer('new-layer', geojson)
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 { NewUtility } from 'maps_maplibre/utils/new_utility'
const result = NewUtility.calculate(input)
Use ES6+ features:
Naming conventions:
PascalCasecamelCaseUPPER_SNAKE_CASEsnake_case.jsAlways use semicolons for statement termination
Prefer const over let, avoid var
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
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)
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)
}
}
Single source of truth:
SettingsManagerSync 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()
}
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
When updating features, follow this pattern:
See app/javascript/maps_maplibre/layers/heatmap_layer.js for a simple example.
See app/javascript/maps_maplibre/utils/settings_manager.js for state management.
See app/javascript/maps_maplibre/services/api_client.js for API communication.
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