Put place, area and visit info into side panel

This commit is contained in:
Eugene Burmakin 2025-12-03 22:46:29 +01:00
parent fea34535f7
commit f8e3c10f24
14 changed files with 238 additions and 209 deletions

File diff suppressed because one or more lines are too long

View file

@ -13,7 +13,6 @@ export default class extends Controller {
'longitudeInput',
'radiusInput',
'radiusDisplay',
'locationDisplay',
'submitButton',
'submitSpinner',
'submitText'
@ -52,8 +51,7 @@ export default class extends Controller {
this.latitudeInputTarget.value = center[1]
this.longitudeInputTarget.value = center[0]
this.radiusInputTarget.value = Math.round(radius)
this.radiusDisplayTarget.value = Math.round(radius)
this.locationDisplayTarget.value = `${center[1].toFixed(6)}, ${center[0].toFixed(6)}`
this.radiusDisplayTarget.textContent = Math.round(radius)
// Show modal
this.modalTarget.classList.add('modal-open')
@ -151,8 +149,7 @@ export default class extends Controller {
resetForm() {
this.formTarget.reset()
this.area = null
this.radiusDisplayTarget.value = ''
this.locationDisplayTarget.value = ''
this.radiusDisplayTarget.textContent = '0'
}
/**

View file

@ -27,11 +27,30 @@ export default class extends Controller {
const button = event.currentTarget
const tabName = button.dataset.tab
this.activateTab(tabName)
}
/**
* Programmatically switch to a tab by name
*/
switchToTab(tabName) {
this.activateTab(tabName)
}
/**
* Internal method to activate a tab
*/
activateTab(tabName) {
// Find the button for this tab
const button = this.tabButtonTargets.find(btn => btn.dataset.tab === tabName)
// Update active button
this.tabButtonTargets.forEach(btn => {
btn.classList.remove('active')
})
button.classList.add('active')
if (button) {
button.classList.add('active')
}
// Update tab content
this.tabContentTargets.forEach(content => {

View file

@ -1,14 +1,12 @@
import maplibregl from 'maplibre-gl'
import { PopupFactory } from 'maps_maplibre/components/popup_factory'
import { VisitPopupFactory } from 'maps_maplibre/components/visit_popup'
import { PhotoPopupFactory } from 'maps_maplibre/components/photo_popup'
import { formatTimestamp } from 'maps_maplibre/utils/geojson_transformers'
/**
* Handles map interaction events (clicks, popups)
* Handles map interaction events (clicks, info display)
*/
export class EventHandlers {
constructor(map) {
constructor(map, controller) {
this.map = map
this.controller = controller
}
/**
@ -16,13 +14,18 @@ export class EventHandlers {
*/
handlePointClick(e) {
const feature = e.features[0]
const coordinates = feature.geometry.coordinates.slice()
const properties = feature.properties
new maplibregl.Popup()
.setLngLat(coordinates)
.setHTML(PopupFactory.createPointPopup(properties))
.addTo(this.map)
const content = `
<div class="space-y-2">
<div><span class="font-semibold">Time:</span> ${formatTimestamp(properties.timestamp)}</div>
${properties.battery ? `<div><span class="font-semibold">Battery:</span> ${properties.battery}%</div>` : ''}
${properties.altitude ? `<div><span class="font-semibold">Altitude:</span> ${Math.round(properties.altitude)}m</div>` : ''}
${properties.velocity ? `<div><span class="font-semibold">Speed:</span> ${Math.round(properties.velocity)} km/h</div>` : ''}
</div>
`
this.controller.showInfo('Location Point', content)
}
/**
@ -30,13 +33,25 @@ export class EventHandlers {
*/
handleVisitClick(e) {
const feature = e.features[0]
const coordinates = feature.geometry.coordinates.slice()
const properties = feature.properties
new maplibregl.Popup()
.setLngLat(coordinates)
.setHTML(VisitPopupFactory.createVisitPopup(properties))
.addTo(this.map)
const startTime = formatTimestamp(properties.started_at)
const endTime = formatTimestamp(properties.ended_at)
const durationHours = Math.round(properties.duration / 3600)
const durationDisplay = durationHours >= 1 ? `${durationHours}h` : `${Math.round(properties.duration / 60)}m`
const content = `
<div class="space-y-2">
<div class="badge badge-sm ${properties.status === 'confirmed' ? 'badge-success' : 'badge-warning'}">${properties.status}</div>
<div><span class="font-semibold">Arrived:</span> ${startTime}</div>
<div><span class="font-semibold">Left:</span> ${endTime}</div>
<div><span class="font-semibold">Duration:</span> ${durationDisplay}</div>
</div>
`
const actions = [{ url: `/visits/${properties.id}`, label: 'View Details →' }]
this.controller.showInfo(properties.name || properties.place_name || 'Visit', content, actions)
}
/**
@ -44,13 +59,16 @@ export class EventHandlers {
*/
handlePhotoClick(e) {
const feature = e.features[0]
const coordinates = feature.geometry.coordinates.slice()
const properties = feature.properties
new maplibregl.Popup()
.setLngLat(coordinates)
.setHTML(PhotoPopupFactory.createPhotoPopup(properties))
.addTo(this.map)
const content = `
<div class="space-y-2">
${properties.photo_url ? `<img src="${properties.photo_url}" alt="Photo" class="w-full rounded-lg mb-2" />` : ''}
${properties.taken_at ? `<div><span class="font-semibold">Taken:</span> ${formatTimestamp(properties.taken_at)}</div>` : ''}
</div>
`
this.controller.showInfo('Photo', content)
}
/**
@ -58,12 +76,36 @@ export class EventHandlers {
*/
handlePlaceClick(e) {
const feature = e.features[0]
const coordinates = feature.geometry.coordinates.slice()
const properties = feature.properties
new maplibregl.Popup()
.setLngLat(coordinates)
.setHTML(PopupFactory.createPlacePopup(properties))
.addTo(this.map)
const content = `
<div class="space-y-2">
${properties.tag ? `<div class="badge badge-sm badge-primary">${properties.tag}</div>` : ''}
${properties.description ? `<div>${properties.description}</div>` : ''}
</div>
`
const actions = properties.id ? [{ url: `/places/${properties.id}`, label: 'View Details →' }] : []
this.controller.showInfo(properties.name || 'Place', content, actions)
}
/**
* Handle area click
*/
handleAreaClick(e) {
const feature = e.features[0]
const properties = feature.properties
const content = `
<div class="space-y-2">
${properties.radius ? `<div><span class="font-semibold">Radius:</span> ${Math.round(properties.radius)}m</div>` : ''}
${properties.latitude && properties.longitude ? `<div><span class="font-semibold">Center:</span> ${properties.latitude.toFixed(6)}, ${properties.longitude.toFixed(6)}</div>` : ''}
</div>
`
const actions = properties.id ? [{ url: `/areas/${properties.id}`, label: 'View Details →' }] : []
this.controller.showInfo(properties.name || 'Area', content, actions)
}
}

View file

@ -62,6 +62,10 @@ export class LayerManager {
this.map.on('click', 'visits', handlers.handleVisitClick)
this.map.on('click', 'photos', handlers.handlePhotoClick)
this.map.on('click', 'places', handlers.handlePlaceClick)
// Areas have multiple layers (fill, outline, labels)
this.map.on('click', 'areas-fill', handlers.handleAreaClick)
this.map.on('click', 'areas-outline', handlers.handleAreaClick)
this.map.on('click', 'areas-labels', handlers.handleAreaClick)
// Cursor change on hover
this.map.on('mouseenter', 'points', () => {
@ -88,6 +92,25 @@ export class LayerManager {
this.map.on('mouseleave', 'places', () => {
this.map.getCanvas().style.cursor = ''
})
// Areas hover handlers for all sub-layers
this.map.on('mouseenter', 'areas-fill', () => {
this.map.getCanvas().style.cursor = 'pointer'
})
this.map.on('mouseleave', 'areas-fill', () => {
this.map.getCanvas().style.cursor = ''
})
this.map.on('mouseenter', 'areas-outline', () => {
this.map.getCanvas().style.cursor = 'pointer'
})
this.map.on('mouseleave', 'areas-outline', () => {
this.map.getCanvas().style.cursor = ''
})
this.map.on('mouseenter', 'areas-labels', () => {
this.map.getCanvas().style.cursor = 'pointer'
})
this.map.on('mouseleave', 'areas-labels', () => {
this.map.getCanvas().style.cursor = ''
})
}
/**

View file

@ -94,7 +94,8 @@ export class MapDataManager {
handlePointClick: this.eventHandlers.handlePointClick.bind(this.eventHandlers),
handleVisitClick: this.eventHandlers.handleVisitClick.bind(this.eventHandlers),
handlePhotoClick: this.eventHandlers.handlePhotoClick.bind(this.eventHandlers),
handlePlaceClick: this.eventHandlers.handlePlaceClick.bind(this.eventHandlers)
handlePlaceClick: this.eventHandlers.handlePlaceClick.bind(this.eventHandlers),
handleAreaClick: this.eventHandlers.handleAreaClick.bind(this.eventHandlers)
})
}

View file

@ -67,7 +67,12 @@ export default class extends Controller {
'selectionActions',
'deleteButtonText',
'selectedVisitsContainer',
'selectedVisitsBulkActions'
'selectedVisitsBulkActions',
// Info display
'infoDisplay',
'infoTitle',
'infoContent',
'infoActions'
]
async connect() {
@ -88,7 +93,7 @@ export default class extends Controller {
// Initialize managers
this.layerManager = new LayerManager(this.map, this.settings, this.api)
this.dataLoader = new DataLoader(this.api, this.apiKeyValue)
this.eventHandlers = new EventHandlers(this.map)
this.eventHandlers = new EventHandlers(this.map, this)
this.filterManager = new FilterManager(this.dataLoader)
this.mapDataManager = new MapDataManager(this)
@ -335,4 +340,50 @@ export default class extends Controller {
toggleSpeedColoredRoutes(event) { return this.routesManager.toggleSpeedColoredRoutes(event) }
openSpeedColorEditor() { return this.routesManager.openSpeedColorEditor() }
handleSpeedColorSave(event) { return this.routesManager.handleSpeedColorSave(event) }
// Info Display methods
showInfo(title, content, actions = []) {
if (!this.hasInfoDisplayTarget) return
// Set title
this.infoTitleTarget.textContent = title
// Set content
this.infoContentTarget.innerHTML = content
// Set actions
if (actions.length > 0) {
this.infoActionsTarget.innerHTML = actions.map(action =>
`<a href="${action.url}" class="btn btn-sm btn-primary">${action.label}</a>`
).join('')
} else {
this.infoActionsTarget.innerHTML = ''
}
// Show info display
this.infoDisplayTarget.classList.remove('hidden')
// Switch to tools tab and open panel
this.switchToToolsTab()
}
closeInfo() {
if (!this.hasInfoDisplayTarget) return
this.infoDisplayTarget.classList.add('hidden')
}
switchToToolsTab() {
// Open the panel if it's not already open
if (!this.settingsPanelTarget.classList.contains('open')) {
this.toggleSettings()
}
// Find the map-panel controller and switch to tools tab
const panelElement = this.settingsPanelTarget
const panelController = this.application.getControllerForElementAndIdentifier(panelElement, 'map-panel')
if (panelController && panelController.switchToTab) {
panelController.switchToTab('tools')
}
}
}

View file

@ -13,11 +13,7 @@ export default class extends Controller {
'startTimeInput',
'endTimeInput',
'latitudeInput',
'longitudeInput',
'locationDisplay',
'submitButton',
'submitSpinner',
'submitText'
'longitudeInput'
]
static values = {
@ -28,7 +24,6 @@ export default class extends Controller {
console.log('[Visit Creation V2] Controller connected')
this.marker = null
this.mapController = null
this.adjustingLocation = false
}
disconnect() {
@ -52,9 +47,6 @@ export default class extends Controller {
this.startTimeInputTarget.value = this.formatDateTime(now)
this.endTimeInputTarget.value = this.formatDateTime(oneHourLater)
// Update location display
this.updateLocationDisplay()
// Show modal
this.modalTarget.classList.add('modal-open')
@ -79,17 +71,6 @@ export default class extends Controller {
// Remove marker
this.removeMarker()
// Exit adjust location mode if active
if (this.adjustingLocation) {
this.exitAdjustLocationMode()
}
// Clean up map click listener
if (this.mapController && this.mapClickHandler) {
this.mapController.map.off('click', this.mapClickHandler)
this.mapClickHandler = null
}
}
/**
@ -100,11 +81,6 @@ export default class extends Controller {
console.log('[Visit Creation V2] Submitting form')
// Disable submit button and show spinner
this.submitButtonTarget.disabled = true
this.submitSpinnerTarget.classList.remove('hidden')
this.submitTextTarget.textContent = 'Creating...'
const formData = new FormData(this.formTarget)
const visitData = {
@ -151,82 +127,9 @@ export default class extends Controller {
} catch (error) {
console.error('[Visit Creation V2] Error creating visit:', error)
this.showToast(error.message || 'Failed to create visit', 'error')
// Re-enable submit button
this.submitButtonTarget.disabled = false
this.submitSpinnerTarget.classList.add('hidden')
this.submitTextTarget.textContent = 'Create Visit'
}
}
/**
* Enter adjust location mode
*/
adjustLocation() {
console.log('[Visit Creation V2] Entering adjust location mode')
if (!this.mapController) return
this.adjustingLocation = true
// Change cursor to crosshair
this.mapController.map.getCanvas().style.cursor = 'crosshair'
// Show info message
this.showToast('Click on the map to adjust visit location', 'info')
// Add map click listener
this.mapClickHandler = (e) => {
const { lng, lat } = e.lngLat
this.updateLocation(lat, lng)
}
this.mapController.map.once('click', this.mapClickHandler)
}
/**
* Exit adjust location mode
*/
exitAdjustLocationMode() {
if (!this.mapController) return
this.adjustingLocation = false
this.mapController.map.getCanvas().style.cursor = ''
}
/**
* Update location coordinates
*/
updateLocation(lat, lng) {
console.log('[Visit Creation V2] Updating location', { lat, lng })
this.latitudeInputTarget.value = lat
this.longitudeInputTarget.value = lng
// Update location display
this.updateLocationDisplay()
// Update marker position
if (this.marker) {
this.marker.setLngLat([lng, lat])
} else {
this.addMarker(lat, lng)
}
// Exit adjust location mode
this.exitAdjustLocationMode()
}
/**
* Update location display text
*/
updateLocationDisplay() {
const lat = parseFloat(this.latitudeInputTarget.value)
const lng = parseFloat(this.longitudeInputTarget.value)
this.locationDisplayTarget.value = `${lat.toFixed(6)}, ${lng.toFixed(6)}`
}
/**
* Add marker to map
*/
@ -241,20 +144,13 @@ export default class extends Controller {
el.className = 'visit-creation-marker'
el.innerHTML = '📍'
el.style.fontSize = '30px'
el.style.cursor = 'pointer'
// Use maplibregl if available (from mapController)
const maplibregl = window.maplibregl
if (maplibregl) {
this.marker = new maplibregl.Marker({ element: el, draggable: true })
this.marker = new maplibregl.Marker({ element: el })
.setLngLat([lng, lat])
.addTo(this.mapController.map)
// Update coordinates on drag
this.marker.on('dragend', () => {
const lngLat = this.marker.getLngLat()
this.updateLocation(lngLat.lat, lngLat.lng)
})
}
}
@ -273,11 +169,6 @@ export default class extends Controller {
*/
cleanup() {
this.removeMarker()
if (this.mapController && this.mapClickHandler) {
this.mapController.map.off('click', this.mapClickHandler)
this.mapClickHandler = null
}
}
/**

View file

@ -50,25 +50,33 @@ export class VisitPopupFactory {
<style>
.visit-popup {
font-family: system-ui, -apple-system, sans-serif;
min-width: 250px;
min-width: 280px;
}
.popup-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
padding-bottom: 8px;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid ${colors.border};
gap: 12px;
}
.popup-header strong {
font-size: 15px;
flex: 1;
}
.visit-badge {
padding: 2px 8px;
padding: 4px 8px;
border-radius: 4px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
white-space: nowrap;
flex-shrink: 0;
}
.visit-badge.suggested {
@ -83,39 +91,40 @@ export class VisitPopupFactory {
.popup-body {
font-size: 13px;
margin-bottom: 12px;
margin-bottom: 16px;
}
.popup-row {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 4px 0;
margin-bottom: 10px;
}
.popup-row .label {
color: ${colors.textMuted};
display: block;
margin-bottom: 4px;
font-size: 12px;
}
.popup-row .value {
font-weight: 500;
color: ${colors.textPrimary};
display: block;
}
.popup-footer {
padding-top: 8px;
padding-top: 12px;
border-top: 1px solid ${colors.border};
}
.view-details-btn {
display: block;
text-align: center;
padding: 6px 12px;
padding: 10px 16px;
background: ${colors.accent};
color: white;
text-decoration: none;
border-radius: 6px;
font-size: 13px;
font-size: 14px;
font-weight: 500;
transition: background 0.2s;
}

View file

@ -27,11 +27,23 @@ export function pointsToGeoJSON(points) {
/**
* Format timestamp for display
* @param {number} timestamp - Unix timestamp
* @param {number|string} timestamp - Unix timestamp (seconds) or ISO 8601 string
* @returns {string} Formatted date/time
*/
export function formatTimestamp(timestamp) {
const date = new Date(timestamp * 1000)
// Handle different timestamp formats
let date
if (typeof timestamp === 'string') {
// ISO 8601 string
date = new Date(timestamp)
} else if (timestamp < 10000000000) {
// Unix timestamp in seconds
date = new Date(timestamp * 1000)
} else {
// Unix timestamp in milliseconds
date = new Date(timestamp)
}
return date.toLocaleString('en-US', {
year: 'numeric',
month: 'short',

View file

@ -29,31 +29,14 @@
<label class="label">
<span class="label-text font-semibold">Radius</span>
</label>
<div class="flex items-center gap-2">
<input
type="text"
class="input input-bordered w-full"
data-area-creation-v2-target="radiusDisplay"
readonly>
<div class="badge badge-info">meters</div>
<div class="text-lg font-semibold">
<span data-area-creation-v2-target="radiusDisplay">0</span> meters
</div>
<label class="label">
<span class="label-text-alt">Draw on the map to set the radius</span>
</label>
</div>
<!-- Center Location -->
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Center Location</span>
</label>
<input
type="text"
class="input input-bordered w-full text-sm"
data-area-creation-v2-target="locationDisplay"
readonly>
</div>
<!-- Drawing Instructions -->
<div class="alert alert-info">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6">
@ -73,7 +56,7 @@
<div class="modal-action">
<button type="button" class="btn btn-ghost" data-action="click->area-creation-v2#close">Cancel</button>
<button type="submit" class="btn btn-primary" data-area-creation-v2-target="submitButton">
<span class="loading loading-spinner loading-sm hidden" data-area-creation-v2-target="submitSpinner"></span>
<span class="loading loading-sm hidden" data-area-creation-v2-target="submitSpinner"></span>
<span data-area-creation-v2-target="submitText">Create Area</span>
</button>
</div>

View file

@ -53,7 +53,7 @@
<button class="btn btn-ghost btn-sm btn-circle"
data-action="click->maps--maplibre#toggleSettings"
title="Close panel">
<%= icon 'search' %>
<%= icon 'x' %>
</button>
</div>
@ -563,6 +563,24 @@
</button>
</div>
<!-- Info Display (shown when clicking on visit/area/place) -->
<div class="hidden mt-4" data-maps--maplibre-target="infoDisplay">
<div class="card bg-base-200 shadow-md">
<div class="card-body p-4">
<div class="flex justify-between items-start mb-2">
<h4 class="card-title text-base" data-maps--maplibre-target="infoTitle"></h4>
<button class="btn btn-ghost btn-xs btn-circle" data-action="click->maps--maplibre#closeInfo" title="Close">✕</button>
</div>
<div class="space-y-2 text-sm" data-maps--maplibre-target="infoContent">
<!-- Content will be dynamically inserted -->
</div>
<div class="card-actions justify-end mt-3" data-maps--maplibre-target="infoActions">
<!-- Action buttons will be dynamically inserted -->
</div>
</div>
</div>
</div>
<!-- Selection Actions (shown after area is selected) -->
<div class="hidden mt-4 space-y-2" data-maps--maplibre-target="selectionActions">
<button type="button"

View file

@ -47,36 +47,11 @@
</div>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Location</span>
</label>
<div class="flex gap-2">
<input
type="text"
class="input input-bordered w-full"
data-visit-creation-v2-target="locationDisplay"
readonly>
<button
type="button"
class="btn btn-outline btn-sm"
data-action="click->visit-creation-v2#adjustLocation"
title="Adjust location on map">
📍 Adjust
</button>
</div>
<label class="label">
<span class="label-text-alt">Click on the map to change location</span>
</label>
</div>
</div>
<div class="modal-action">
<button type="button" class="btn btn-ghost" data-action="click->visit-creation-v2#close">Cancel</button>
<button type="submit" class="btn btn-primary" data-visit-creation-v2-target="submitButton">
<span class="loading loading-spinner loading-sm hidden" data-visit-creation-v2-target="submitSpinner"></span>
<span data-visit-creation-v2-target="submitText">Create Visit</span>
</button>
<button type="submit" class="btn btn-primary">Create Visit</button>
</div>
</form>
</div>

View file

@ -167,7 +167,15 @@ test.describe('Routes Layer', () => {
test.describe('Persistence', () => {
test('date navigation preserves routes layer', async ({ page }) => {
await page.waitForTimeout(1000)
// Wait for routes layer to be added to the map
await page.waitForFunction(() => {
const element = document.querySelector('[data-controller*="maps--maplibre"]')
if (!element) return false
const app = window.Stimulus || window.Application
if (!app) return false
const controller = app.getControllerForElementAndIdentifier(element, 'maps--maplibre')
return controller?.map?.getLayer('routes') !== undefined
}, { timeout: 10000 })
const initialRoutes = await hasLayer(page, 'routes')
expect(initialRoutes).toBe(true)