Implement phase 1

This commit is contained in:
Eugene Burmakin 2025-11-16 12:45:26 +01:00
parent ec54d202ff
commit 0ca4cb2008
12 changed files with 714 additions and 0 deletions

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,9 @@
class MapsV2Controller < ApplicationController
before_action :authenticate_user!
def index
# Default to current month
@start_date = Date.today.beginning_of_month
@end_date = Date.today.end_of_month
end
end

View file

@ -0,0 +1,179 @@
import { Controller } from '@hotwired/stimulus'
import maplibregl from 'maplibre-gl'
import { ApiClient } from 'maps_v2/services/api_client'
import { PointsLayer } from 'maps_v2/layers/points_layer'
import { pointsToGeoJSON } from 'maps_v2/utils/geojson_transformers'
import { PopupFactory } from 'maps_v2/components/popup_factory'
/**
* Main map controller for Maps V2
* Phase 1: MVP with points layer
*/
export default class extends Controller {
static values = {
apiKey: String,
startDate: String,
endDate: String
}
static targets = ['container', 'loading', 'monthSelect']
connect() {
this.initializeMap()
this.initializeAPI()
this.loadMapData()
}
disconnect() {
this.map?.remove()
}
/**
* Initialize MapLibre map
*/
initializeMap() {
this.map = new maplibregl.Map({
container: this.containerTarget,
style: 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json',
center: [0, 0],
zoom: 2
})
// Add navigation controls
this.map.addControl(new maplibregl.NavigationControl(), 'top-right')
// Setup click handler for points
this.map.on('click', 'points', this.handlePointClick.bind(this))
// Change cursor on hover
this.map.on('mouseenter', 'points', () => {
this.map.getCanvas().style.cursor = 'pointer'
})
this.map.on('mouseleave', 'points', () => {
this.map.getCanvas().style.cursor = ''
})
}
/**
* Initialize API client
*/
initializeAPI() {
this.api = new ApiClient(this.apiKeyValue)
}
/**
* Load points data from API
*/
async loadMapData() {
this.showLoading()
try {
// Fetch all points for selected month
const points = await this.api.fetchAllPoints({
start_at: this.startDateValue,
end_at: this.endDateValue,
onProgress: this.updateLoadingProgress.bind(this)
})
console.log(`Loaded ${points.length} points`)
// Transform to GeoJSON
const geojson = pointsToGeoJSON(points)
// Create/update points layer
if (!this.pointsLayer) {
this.pointsLayer = new PointsLayer(this.map)
// Wait for map to load before adding layer
if (this.map.loaded()) {
this.pointsLayer.add(geojson)
} else {
this.map.on('load', () => {
this.pointsLayer.add(geojson)
})
}
} else {
this.pointsLayer.update(geojson)
}
// Fit map to data bounds
if (points.length > 0) {
this.fitMapToBounds(geojson)
}
} catch (error) {
console.error('Failed to load map data:', error)
alert('Failed to load location data. Please try again.')
} finally {
this.hideLoading()
}
}
/**
* Handle point click
*/
handlePointClick(e) {
const feature = e.features[0]
const coordinates = feature.geometry.coordinates.slice()
const properties = feature.properties
// Create popup
new maplibregl.Popup()
.setLngLat(coordinates)
.setHTML(PopupFactory.createPointPopup(properties))
.addTo(this.map)
}
/**
* Fit map to data bounds
*/
fitMapToBounds(geojson) {
const coordinates = geojson.features.map(f => f.geometry.coordinates)
const bounds = coordinates.reduce((bounds, coord) => {
return bounds.extend(coord)
}, new maplibregl.LngLatBounds(coordinates[0], coordinates[0]))
this.map.fitBounds(bounds, {
padding: 50,
maxZoom: 15
})
}
/**
* Month selector changed
*/
monthChanged(event) {
const [year, month] = event.target.value.split('-')
// Update date values
this.startDateValue = `${year}-${month}-01T00:00:00Z`
const lastDay = new Date(year, month, 0).getDate()
this.endDateValue = `${year}-${month}-${lastDay}T23:59:59Z`
// Reload data
this.loadMapData()
}
/**
* Show loading indicator
*/
showLoading() {
this.loadingTarget.classList.remove('hidden')
}
/**
* Hide loading indicator
*/
hideLoading() {
this.loadingTarget.classList.add('hidden')
}
/**
* Update loading progress
*/
updateLoadingProgress({ loaded, totalPages, progress }) {
const percentage = Math.round(progress * 100)
this.loadingTarget.textContent = `Loading... ${percentage}%`
}
}

View file

@ -0,0 +1,53 @@
import { formatTimestamp } from '../utils/geojson_transformers'
/**
* Factory for creating map popups
*/
export class PopupFactory {
/**
* Create popup for a point
* @param {Object} properties - Point properties
* @returns {string} HTML for popup
*/
static createPointPopup(properties) {
const { id, timestamp, altitude, battery, accuracy, velocity } = properties
return `
<div class="point-popup">
<div class="popup-header">
<strong>Point #${id}</strong>
</div>
<div class="popup-body">
<div class="popup-row">
<span class="label">Time:</span>
<span class="value">${formatTimestamp(timestamp)}</span>
</div>
${altitude ? `
<div class="popup-row">
<span class="label">Altitude:</span>
<span class="value">${Math.round(altitude)}m</span>
</div>
` : ''}
${battery ? `
<div class="popup-row">
<span class="label">Battery:</span>
<span class="value">${battery}%</span>
</div>
` : ''}
${accuracy ? `
<div class="popup-row">
<span class="label">Accuracy:</span>
<span class="value">${Math.round(accuracy)}m</span>
</div>
` : ''}
${velocity ? `
<div class="popup-row">
<span class="label">Speed:</span>
<span class="value">${Math.round(velocity * 3.6)} km/h</span>
</div>
` : ''}
</div>
</div>
`
}
}

View file

@ -0,0 +1,111 @@
/**
* Base class for all map layers
* Provides common functionality for layer management
*/
export class BaseLayer {
constructor(map, options = {}) {
this.map = map
this.id = options.id || this.constructor.name.toLowerCase()
this.sourceId = `${this.id}-source`
this.visible = options.visible !== false
this.data = null
}
/**
* Add layer to map with data
* @param {Object} data - GeoJSON or layer-specific data
*/
add(data) {
this.data = data
// Add source
if (!this.map.getSource(this.sourceId)) {
this.map.addSource(this.sourceId, this.getSourceConfig())
}
// Add layers
const layers = this.getLayerConfigs()
layers.forEach(layerConfig => {
if (!this.map.getLayer(layerConfig.id)) {
this.map.addLayer(layerConfig)
}
})
this.setVisibility(this.visible)
}
/**
* Update layer data
* @param {Object} data - New data
*/
update(data) {
this.data = data
const source = this.map.getSource(this.sourceId)
if (source && source.setData) {
source.setData(data)
}
}
/**
* Remove layer from map
*/
remove() {
this.getLayerIds().forEach(layerId => {
if (this.map.getLayer(layerId)) {
this.map.removeLayer(layerId)
}
})
if (this.map.getSource(this.sourceId)) {
this.map.removeSource(this.sourceId)
}
this.data = null
}
/**
* Toggle layer visibility
* @param {boolean} visible - Show/hide layer
*/
toggle(visible = !this.visible) {
this.visible = visible
this.setVisibility(visible)
}
/**
* Set visibility for all layer IDs
* @param {boolean} visible
*/
setVisibility(visible) {
const visibility = visible ? 'visible' : 'none'
this.getLayerIds().forEach(layerId => {
if (this.map.getLayer(layerId)) {
this.map.setLayoutProperty(layerId, 'visibility', visibility)
}
})
}
/**
* Get source configuration (override in subclass)
* @returns {Object} MapLibre source config
*/
getSourceConfig() {
throw new Error('Must implement getSourceConfig()')
}
/**
* Get layer configurations (override in subclass)
* @returns {Array<Object>} Array of MapLibre layer configs
*/
getLayerConfigs() {
throw new Error('Must implement getLayerConfigs()')
}
/**
* Get all layer IDs for this layer
* @returns {Array<string>}
*/
getLayerIds() {
return this.getLayerConfigs().map(config => config.id)
}
}

View file

@ -0,0 +1,85 @@
import { BaseLayer } from './base_layer'
/**
* Points layer with automatic clustering
*/
export class PointsLayer extends BaseLayer {
constructor(map, options = {}) {
super(map, { id: 'points', ...options })
this.clusterRadius = options.clusterRadius || 50
this.clusterMaxZoom = options.clusterMaxZoom || 14
}
getSourceConfig() {
return {
type: 'geojson',
data: this.data || {
type: 'FeatureCollection',
features: []
},
cluster: true,
clusterMaxZoom: this.clusterMaxZoom,
clusterRadius: this.clusterRadius
}
}
getLayerConfigs() {
return [
// Cluster circles
{
id: `${this.id}-clusters`,
type: 'circle',
source: this.sourceId,
filter: ['has', 'point_count'],
paint: {
'circle-color': [
'step',
['get', 'point_count'],
'#51bbd6', 10,
'#f1f075', 50,
'#f28cb1', 100,
'#ff6b6b'
],
'circle-radius': [
'step',
['get', 'point_count'],
20, 10,
30, 50,
40, 100,
50
]
}
},
// Cluster count labels
{
id: `${this.id}-count`,
type: 'symbol',
source: this.sourceId,
filter: ['has', 'point_count'],
layout: {
'text-field': '{point_count_abbreviated}',
'text-font': ['DIN Offc Pro Medium', 'Arial Unicode MS Bold'],
'text-size': 12
},
paint: {
'text-color': '#ffffff'
}
},
// Individual points
{
id: this.id,
type: 'circle',
source: this.sourceId,
filter: ['!', ['has', 'point_count']],
paint: {
'circle-color': '#3b82f6',
'circle-radius': 6,
'circle-stroke-width': 2,
'circle-stroke-color': '#ffffff'
}
}
]
}
}

View file

@ -0,0 +1,78 @@
/**
* API client for Maps V2
* Wraps all API endpoints with consistent error handling
*/
export class ApiClient {
constructor(apiKey) {
this.apiKey = apiKey
this.baseURL = '/api/v1'
}
/**
* Fetch points for date range (paginated)
* @param {Object} options - { start_at, end_at, page, per_page }
* @returns {Promise<Object>} { points, currentPage, totalPages }
*/
async fetchPoints({ start_at, end_at, page = 1, per_page = 1000 }) {
const params = new URLSearchParams({
start_at,
end_at,
page: page.toString(),
per_page: per_page.toString()
})
const response = await fetch(`${this.baseURL}/points?${params}`, {
headers: this.getHeaders()
})
if (!response.ok) {
throw new Error(`Failed to fetch points: ${response.statusText}`)
}
const points = await response.json()
return {
points,
currentPage: parseInt(response.headers.get('X-Current-Page') || '1'),
totalPages: parseInt(response.headers.get('X-Total-Pages') || '1')
}
}
/**
* Fetch all points for date range (handles pagination)
* @param {Object} options - { start_at, end_at, onProgress }
* @returns {Promise<Array>} All points
*/
async fetchAllPoints({ start_at, end_at, onProgress = null }) {
const allPoints = []
let page = 1
let totalPages = 1
do {
const { points, currentPage, totalPages: total } =
await this.fetchPoints({ start_at, end_at, page, per_page: 1000 })
allPoints.push(...points)
totalPages = total
page++
if (onProgress) {
onProgress({
loaded: allPoints.length,
currentPage,
totalPages,
progress: currentPage / totalPages
})
}
} while (page <= totalPages)
return allPoints
}
getHeaders() {
return {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json'
}
}
}

View file

@ -0,0 +1,41 @@
/**
* Transform points array to GeoJSON FeatureCollection
* @param {Array} points - Array of point objects from API
* @returns {Object} GeoJSON FeatureCollection
*/
export function pointsToGeoJSON(points) {
return {
type: 'FeatureCollection',
features: points.map(point => ({
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [point.longitude, point.latitude]
},
properties: {
id: point.id,
timestamp: point.timestamp,
altitude: point.altitude,
battery: point.battery,
accuracy: point.accuracy,
velocity: point.velocity
}
}))
}
}
/**
* Format timestamp for display
* @param {number} timestamp - Unix timestamp
* @returns {string} Formatted date/time
*/
export function formatTimestamp(timestamp) {
const date = new Date(timestamp * 1000)
return date.toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}

View file

@ -0,0 +1,144 @@
<div class="maps-v2-container"
data-controller="maps-v2"
data-maps-v2-api-key-value="<%= current_user.api_key %>"
data-maps-v2-start-date-value="<%= @start_date.to_s %>"
data-maps-v2-end-date-value="<%= @end_date.to_s %>">
<!-- Map container -->
<div class="map-wrapper">
<div data-maps-v2-target="container" class="map-container"></div>
<!-- Loading overlay -->
<div data-maps-v2-target="loading" class="loading-overlay hidden">
<div class="loading-spinner"></div>
<div class="loading-text">Loading points...</div>
</div>
</div>
<!-- Month selector -->
<div class="controls-panel">
<div class="control-group">
<label for="month-select">Month:</label>
<select id="month-select"
data-maps-v2-target="monthSelect"
data-action="change->maps-v2#monthChanged"
class="month-selector">
<% 12.times do |i| %>
<% date = Date.today.beginning_of_month - i.months %>
<option value="<%= date.strftime('%Y-%m') %>"
<%= 'selected' if date.year == @start_date.year && date.month == @start_date.month %>>
<%= date.strftime('%B %Y') %>
</option>
<% end %>
</select>
</div>
</div>
</div>
<style>
.maps-v2-container {
height: 100vh;
display: flex;
flex-direction: column;
width: 100%;
}
.map-wrapper {
flex: 1;
position: relative;
}
.map-container {
width: 100%;
height: 100%;
}
.loading-overlay {
position: absolute;
inset: 0;
background: rgba(255, 255, 255, 0.9);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 1000;
}
.loading-overlay.hidden {
display: none;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 4px solid #e5e7eb;
border-top-color: #3b82f6;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.loading-text {
margin-top: 16px;
font-size: 14px;
color: #6b7280;
}
.controls-panel {
padding: 16px;
background: white;
border-top: 1px solid #e5e7eb;
}
.control-group {
display: flex;
align-items: center;
gap: 12px;
}
.control-group label {
font-weight: 500;
color: #374151;
}
.month-selector {
padding: 8px 12px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 14px;
}
/* Popup styles */
.point-popup {
font-family: system-ui, -apple-system, sans-serif;
}
.popup-header {
margin-bottom: 8px;
padding-bottom: 8px;
border-bottom: 1px solid #e5e7eb;
}
.popup-body {
font-size: 13px;
}
.popup-row {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 4px 0;
}
.popup-row .label {
color: #6b7280;
}
.popup-row .value {
font-weight: 500;
color: #111827;
}
</style>

View file

@ -4,6 +4,7 @@
pin_all_from 'app/javascript/channels', under: 'channels'
pin_all_from 'app/javascript/maps', under: 'maps'
pin_all_from 'app/javascript/maps_v2', under: 'maps_v2'
pin 'application', preload: true
pin '@rails/actioncable', to: 'actioncable.esm.js'
@ -26,3 +27,4 @@ pin 'imports_channel', to: 'channels/imports_channel.js'
pin 'family_locations_channel', to: 'channels/family_locations_channel.js'
pin 'trix'
pin '@rails/actiontext', to: 'actiontext.esm.js'
pin "maplibre-gl" # @5.12.0

View file

@ -111,6 +111,9 @@ Rails.application.routes.draw do
get 'map', to: 'map#index'
# Maps V2
get '/maps_v2', to: 'maps_v2#index', as: :maps_v2
namespace :api do
namespace :v1 do
get 'photos', to: 'photos#index'

8
vendor/javascript/maplibre-gl.js vendored Normal file

File diff suppressed because one or more lines are too long