mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-10 17:21:38 -05:00
Implement phase 1
This commit is contained in:
parent
ec54d202ff
commit
0ca4cb2008
12 changed files with 714 additions and 0 deletions
1
app/assets/stylesheets/maplibre-gl.css
Normal file
1
app/assets/stylesheets/maplibre-gl.css
Normal file
File diff suppressed because one or more lines are too long
9
app/controllers/maps_v2_controller.rb
Normal file
9
app/controllers/maps_v2_controller.rb
Normal 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
|
||||
179
app/javascript/controllers/maps_v2_controller.js
Normal file
179
app/javascript/controllers/maps_v2_controller.js
Normal 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}%`
|
||||
}
|
||||
}
|
||||
53
app/javascript/maps_v2/components/popup_factory.js
Normal file
53
app/javascript/maps_v2/components/popup_factory.js
Normal 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>
|
||||
`
|
||||
}
|
||||
}
|
||||
111
app/javascript/maps_v2/layers/base_layer.js
Normal file
111
app/javascript/maps_v2/layers/base_layer.js
Normal 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)
|
||||
}
|
||||
}
|
||||
85
app/javascript/maps_v2/layers/points_layer.js
Normal file
85
app/javascript/maps_v2/layers/points_layer.js
Normal 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'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
78
app/javascript/maps_v2/services/api_client.js
Normal file
78
app/javascript/maps_v2/services/api_client.js
Normal 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'
|
||||
}
|
||||
}
|
||||
}
|
||||
41
app/javascript/maps_v2/utils/geojson_transformers.js
Normal file
41
app/javascript/maps_v2/utils/geojson_transformers.js
Normal 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'
|
||||
})
|
||||
}
|
||||
144
app/views/maps_v2/index.html.erb
Normal file
144
app/views/maps_v2/index.html.erb
Normal 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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
8
vendor/javascript/maplibre-gl.js
vendored
Normal file
File diff suppressed because one or more lines are too long
Loading…
Reference in a new issue