Skip to content

Commit 0ca4cb2

Browse files
committed
Implement phase 1
1 parent ec54d20 commit 0ca4cb2

File tree

12 files changed

+714
-0
lines changed

12 files changed

+714
-0
lines changed

app/assets/stylesheets/maplibre-gl.css

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
class MapsV2Controller < ApplicationController
2+
before_action :authenticate_user!
3+
4+
def index
5+
# Default to current month
6+
@start_date = Date.today.beginning_of_month
7+
@end_date = Date.today.end_of_month
8+
end
9+
end
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import { Controller } from '@hotwired/stimulus'
2+
import maplibregl from 'maplibre-gl'
3+
import { ApiClient } from 'maps_v2/services/api_client'
4+
import { PointsLayer } from 'maps_v2/layers/points_layer'
5+
import { pointsToGeoJSON } from 'maps_v2/utils/geojson_transformers'
6+
import { PopupFactory } from 'maps_v2/components/popup_factory'
7+
8+
/**
9+
* Main map controller for Maps V2
10+
* Phase 1: MVP with points layer
11+
*/
12+
export default class extends Controller {
13+
static values = {
14+
apiKey: String,
15+
startDate: String,
16+
endDate: String
17+
}
18+
19+
static targets = ['container', 'loading', 'monthSelect']
20+
21+
connect() {
22+
this.initializeMap()
23+
this.initializeAPI()
24+
this.loadMapData()
25+
}
26+
27+
disconnect() {
28+
this.map?.remove()
29+
}
30+
31+
/**
32+
* Initialize MapLibre map
33+
*/
34+
initializeMap() {
35+
this.map = new maplibregl.Map({
36+
container: this.containerTarget,
37+
style: 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json',
38+
center: [0, 0],
39+
zoom: 2
40+
})
41+
42+
// Add navigation controls
43+
this.map.addControl(new maplibregl.NavigationControl(), 'top-right')
44+
45+
// Setup click handler for points
46+
this.map.on('click', 'points', this.handlePointClick.bind(this))
47+
48+
// Change cursor on hover
49+
this.map.on('mouseenter', 'points', () => {
50+
this.map.getCanvas().style.cursor = 'pointer'
51+
})
52+
this.map.on('mouseleave', 'points', () => {
53+
this.map.getCanvas().style.cursor = ''
54+
})
55+
}
56+
57+
/**
58+
* Initialize API client
59+
*/
60+
initializeAPI() {
61+
this.api = new ApiClient(this.apiKeyValue)
62+
}
63+
64+
/**
65+
* Load points data from API
66+
*/
67+
async loadMapData() {
68+
this.showLoading()
69+
70+
try {
71+
// Fetch all points for selected month
72+
const points = await this.api.fetchAllPoints({
73+
start_at: this.startDateValue,
74+
end_at: this.endDateValue,
75+
onProgress: this.updateLoadingProgress.bind(this)
76+
})
77+
78+
console.log(`Loaded ${points.length} points`)
79+
80+
// Transform to GeoJSON
81+
const geojson = pointsToGeoJSON(points)
82+
83+
// Create/update points layer
84+
if (!this.pointsLayer) {
85+
this.pointsLayer = new PointsLayer(this.map)
86+
87+
// Wait for map to load before adding layer
88+
if (this.map.loaded()) {
89+
this.pointsLayer.add(geojson)
90+
} else {
91+
this.map.on('load', () => {
92+
this.pointsLayer.add(geojson)
93+
})
94+
}
95+
} else {
96+
this.pointsLayer.update(geojson)
97+
}
98+
99+
// Fit map to data bounds
100+
if (points.length > 0) {
101+
this.fitMapToBounds(geojson)
102+
}
103+
104+
} catch (error) {
105+
console.error('Failed to load map data:', error)
106+
alert('Failed to load location data. Please try again.')
107+
} finally {
108+
this.hideLoading()
109+
}
110+
}
111+
112+
/**
113+
* Handle point click
114+
*/
115+
handlePointClick(e) {
116+
const feature = e.features[0]
117+
const coordinates = feature.geometry.coordinates.slice()
118+
const properties = feature.properties
119+
120+
// Create popup
121+
new maplibregl.Popup()
122+
.setLngLat(coordinates)
123+
.setHTML(PopupFactory.createPointPopup(properties))
124+
.addTo(this.map)
125+
}
126+
127+
/**
128+
* Fit map to data bounds
129+
*/
130+
fitMapToBounds(geojson) {
131+
const coordinates = geojson.features.map(f => f.geometry.coordinates)
132+
133+
const bounds = coordinates.reduce((bounds, coord) => {
134+
return bounds.extend(coord)
135+
}, new maplibregl.LngLatBounds(coordinates[0], coordinates[0]))
136+
137+
this.map.fitBounds(bounds, {
138+
padding: 50,
139+
maxZoom: 15
140+
})
141+
}
142+
143+
/**
144+
* Month selector changed
145+
*/
146+
monthChanged(event) {
147+
const [year, month] = event.target.value.split('-')
148+
149+
// Update date values
150+
this.startDateValue = `${year}-${month}-01T00:00:00Z`
151+
const lastDay = new Date(year, month, 0).getDate()
152+
this.endDateValue = `${year}-${month}-${lastDay}T23:59:59Z`
153+
154+
// Reload data
155+
this.loadMapData()
156+
}
157+
158+
/**
159+
* Show loading indicator
160+
*/
161+
showLoading() {
162+
this.loadingTarget.classList.remove('hidden')
163+
}
164+
165+
/**
166+
* Hide loading indicator
167+
*/
168+
hideLoading() {
169+
this.loadingTarget.classList.add('hidden')
170+
}
171+
172+
/**
173+
* Update loading progress
174+
*/
175+
updateLoadingProgress({ loaded, totalPages, progress }) {
176+
const percentage = Math.round(progress * 100)
177+
this.loadingTarget.textContent = `Loading... ${percentage}%`
178+
}
179+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { formatTimestamp } from '../utils/geojson_transformers'
2+
3+
/**
4+
* Factory for creating map popups
5+
*/
6+
export class PopupFactory {
7+
/**
8+
* Create popup for a point
9+
* @param {Object} properties - Point properties
10+
* @returns {string} HTML for popup
11+
*/
12+
static createPointPopup(properties) {
13+
const { id, timestamp, altitude, battery, accuracy, velocity } = properties
14+
15+
return `
16+
<div class="point-popup">
17+
<div class="popup-header">
18+
<strong>Point #${id}</strong>
19+
</div>
20+
<div class="popup-body">
21+
<div class="popup-row">
22+
<span class="label">Time:</span>
23+
<span class="value">${formatTimestamp(timestamp)}</span>
24+
</div>
25+
${altitude ? `
26+
<div class="popup-row">
27+
<span class="label">Altitude:</span>
28+
<span class="value">${Math.round(altitude)}m</span>
29+
</div>
30+
` : ''}
31+
${battery ? `
32+
<div class="popup-row">
33+
<span class="label">Battery:</span>
34+
<span class="value">${battery}%</span>
35+
</div>
36+
` : ''}
37+
${accuracy ? `
38+
<div class="popup-row">
39+
<span class="label">Accuracy:</span>
40+
<span class="value">${Math.round(accuracy)}m</span>
41+
</div>
42+
` : ''}
43+
${velocity ? `
44+
<div class="popup-row">
45+
<span class="label">Speed:</span>
46+
<span class="value">${Math.round(velocity * 3.6)} km/h</span>
47+
</div>
48+
` : ''}
49+
</div>
50+
</div>
51+
`
52+
}
53+
}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
/**
2+
* Base class for all map layers
3+
* Provides common functionality for layer management
4+
*/
5+
export class BaseLayer {
6+
constructor(map, options = {}) {
7+
this.map = map
8+
this.id = options.id || this.constructor.name.toLowerCase()
9+
this.sourceId = `${this.id}-source`
10+
this.visible = options.visible !== false
11+
this.data = null
12+
}
13+
14+
/**
15+
* Add layer to map with data
16+
* @param {Object} data - GeoJSON or layer-specific data
17+
*/
18+
add(data) {
19+
this.data = data
20+
21+
// Add source
22+
if (!this.map.getSource(this.sourceId)) {
23+
this.map.addSource(this.sourceId, this.getSourceConfig())
24+
}
25+
26+
// Add layers
27+
const layers = this.getLayerConfigs()
28+
layers.forEach(layerConfig => {
29+
if (!this.map.getLayer(layerConfig.id)) {
30+
this.map.addLayer(layerConfig)
31+
}
32+
})
33+
34+
this.setVisibility(this.visible)
35+
}
36+
37+
/**
38+
* Update layer data
39+
* @param {Object} data - New data
40+
*/
41+
update(data) {
42+
this.data = data
43+
const source = this.map.getSource(this.sourceId)
44+
if (source && source.setData) {
45+
source.setData(data)
46+
}
47+
}
48+
49+
/**
50+
* Remove layer from map
51+
*/
52+
remove() {
53+
this.getLayerIds().forEach(layerId => {
54+
if (this.map.getLayer(layerId)) {
55+
this.map.removeLayer(layerId)
56+
}
57+
})
58+
59+
if (this.map.getSource(this.sourceId)) {
60+
this.map.removeSource(this.sourceId)
61+
}
62+
63+
this.data = null
64+
}
65+
66+
/**
67+
* Toggle layer visibility
68+
* @param {boolean} visible - Show/hide layer
69+
*/
70+
toggle(visible = !this.visible) {
71+
this.visible = visible
72+
this.setVisibility(visible)
73+
}
74+
75+
/**
76+
* Set visibility for all layer IDs
77+
* @param {boolean} visible
78+
*/
79+
setVisibility(visible) {
80+
const visibility = visible ? 'visible' : 'none'
81+
this.getLayerIds().forEach(layerId => {
82+
if (this.map.getLayer(layerId)) {
83+
this.map.setLayoutProperty(layerId, 'visibility', visibility)
84+
}
85+
})
86+
}
87+
88+
/**
89+
* Get source configuration (override in subclass)
90+
* @returns {Object} MapLibre source config
91+
*/
92+
getSourceConfig() {
93+
throw new Error('Must implement getSourceConfig()')
94+
}
95+
96+
/**
97+
* Get layer configurations (override in subclass)
98+
* @returns {Array<Object>} Array of MapLibre layer configs
99+
*/
100+
getLayerConfigs() {
101+
throw new Error('Must implement getLayerConfigs()')
102+
}
103+
104+
/**
105+
* Get all layer IDs for this layer
106+
* @returns {Array<string>}
107+
*/
108+
getLayerIds() {
109+
return this.getLayerConfigs().map(config => config.id)
110+
}
111+
}

0 commit comments

Comments
 (0)