feat(maps): add scale bar and location markers (#636)

Add distance scale bar and user-placed location pins to the offline maps viewer.

- Scale bar (bottom-left) shows distance reference that updates with zoom level
- Click anywhere on map to place a named pin with color selection (6 colors)
- Collapsible "Saved Locations" panel lists all pins with fly-to navigation
- Full dark mode support for popups and panel via CSS overrides
- New `map_markers` table with future-proofed columns for routing (marker_type,
  route_id, route_order, notes) to avoid a migration when routes are added later
- CRUD endpoints: GET/POST /api/maps/markers, PATCH/DELETE /api/maps/markers/:id
- VineJS validation on create/update
- MapMarker Lucid model

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
chriscrosstalk 2026-04-03 12:14:50 -07:00 committed by GitHub
parent 8277e793ce
commit 00d63aa87a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 575 additions and 2 deletions

View File

@ -1,4 +1,5 @@
import { MapService } from '#services/map_service'
import MapMarker from '#models/map_marker'
import {
assertNotPrivateUrl,
downloadCollectionValidator,
@ -8,6 +9,7 @@ import {
} from '#validators/common'
import { inject } from '@adonisjs/core'
import type { HttpContext } from '@adonisjs/core/http'
import vine from '@vinejs/vine'
@inject()
export default class MapsController {
@ -123,4 +125,60 @@ export default class MapsController {
message: 'Map file deleted successfully',
}
}
// --- Map Markers ---
async listMarkers({}: HttpContext) {
return await MapMarker.query().orderBy('created_at', 'asc')
}
async createMarker({ request }: HttpContext) {
const payload = await request.validateUsing(
vine.compile(
vine.object({
name: vine.string().trim().minLength(1).maxLength(255),
longitude: vine.number(),
latitude: vine.number(),
color: vine.string().trim().maxLength(20).optional(),
})
)
)
const marker = await MapMarker.create({
name: payload.name,
longitude: payload.longitude,
latitude: payload.latitude,
color: payload.color ?? 'orange',
})
return marker
}
async updateMarker({ request, response }: HttpContext) {
const { id } = request.params()
const marker = await MapMarker.find(id)
if (!marker) {
return response.status(404).send({ message: 'Marker not found' })
}
const payload = await request.validateUsing(
vine.compile(
vine.object({
name: vine.string().trim().minLength(1).maxLength(255).optional(),
color: vine.string().trim().maxLength(20).optional(),
})
)
)
if (payload.name !== undefined) marker.name = payload.name
if (payload.color !== undefined) marker.color = payload.color
await marker.save()
return marker
}
async deleteMarker({ request, response }: HttpContext) {
const { id } = request.params()
const marker = await MapMarker.find(id)
if (!marker) {
return response.status(404).send({ message: 'Marker not found' })
}
await marker.delete()
return { message: 'Marker deleted' }
}
}

View File

@ -0,0 +1,43 @@
import { DateTime } from 'luxon'
import { BaseModel, column, SnakeCaseNamingStrategy } from '@adonisjs/lucid/orm'
export default class MapMarker extends BaseModel {
static namingStrategy = new SnakeCaseNamingStrategy()
@column({ isPrimary: true })
declare id: number
@column()
declare name: string
@column()
declare longitude: number
@column()
declare latitude: number
@column()
declare color: string
// 'pin' for user-placed markers, 'waypoint' for route points (future)
@column()
declare marker_type: string
// Groups markers into a route (future)
@column()
declare route_id: string | null
// Order within a route (future)
@column()
declare route_order: number | null
// Optional user notes for a location
@column()
declare notes: string | null
@column.dateTime({ autoCreate: true })
declare created_at: DateTime
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updated_at: DateTime
}

View File

@ -0,0 +1,25 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'map_markers'
async up() {
this.schema.createTable(this.tableName, (table) => {
table.increments('id')
table.string('name').notNullable()
table.double('longitude').notNullable()
table.double('latitude').notNullable()
table.string('color', 20).notNullable().defaultTo('orange')
table.string('marker_type', 20).notNullable().defaultTo('pin')
table.string('route_id').nullable()
table.integer('route_order').nullable()
table.text('notes').nullable()
table.timestamp('created_at')
table.timestamp('updated_at')
})
}
async down() {
this.schema.dropTable(this.tableName)
}
}

View File

@ -1,10 +1,28 @@
import Map, { FullscreenControl, NavigationControl, MapProvider } from 'react-map-gl/maplibre'
import Map, {
FullscreenControl,
NavigationControl,
ScaleControl,
Marker,
Popup,
MapProvider,
} from 'react-map-gl/maplibre'
import type { MapRef, MapLayerMouseEvent } from 'react-map-gl/maplibre'
import maplibregl from 'maplibre-gl'
import 'maplibre-gl/dist/maplibre-gl.css'
import { Protocol } from 'pmtiles'
import { useEffect } from 'react'
import { useEffect, useRef, useState, useCallback } from 'react'
import { useMapMarkers, PIN_COLORS } from '~/hooks/useMapMarkers'
import type { PinColorId } from '~/hooks/useMapMarkers'
import MarkerPin from './MarkerPin'
import MarkerPanel from './MarkerPanel'
export default function MapComponent() {
const mapRef = useRef<MapRef>(null)
const { markers, addMarker, deleteMarker } = useMapMarkers()
const [placingMarker, setPlacingMarker] = useState<{ lng: number; lat: number } | null>(null)
const [markerName, setMarkerName] = useState('')
const [markerColor, setMarkerColor] = useState<PinColorId>('orange')
const [selectedMarkerId, setSelectedMarkerId] = useState<number | null>(null)
// Add the PMTiles protocol to maplibre-gl
useEffect(() => {
@ -15,9 +33,40 @@ export default function MapComponent() {
}
}, [])
const handleMapClick = useCallback((e: MapLayerMouseEvent) => {
setPlacingMarker({ lng: e.lngLat.lng, lat: e.lngLat.lat })
setMarkerName('')
setMarkerColor('orange')
setSelectedMarkerId(null)
}, [])
const handleSaveMarker = useCallback(() => {
if (placingMarker && markerName.trim()) {
addMarker(markerName.trim(), placingMarker.lng, placingMarker.lat, markerColor)
setPlacingMarker(null)
setMarkerName('')
setMarkerColor('orange')
}
}, [placingMarker, markerName, markerColor, addMarker])
const handleFlyTo = useCallback((longitude: number, latitude: number) => {
mapRef.current?.flyTo({ center: [longitude, latitude], zoom: 12, duration: 1500 })
}, [])
const handleDeleteMarker = useCallback(
(id: number) => {
if (selectedMarkerId === id) setSelectedMarkerId(null)
deleteMarker(id)
},
[selectedMarkerId, deleteMarker]
)
const selectedMarker = selectedMarkerId ? markers.find((m) => m.id === selectedMarkerId) : null
return (
<MapProvider>
<Map
ref={mapRef}
reuseMaps
style={{
width: '100%',
@ -30,10 +79,115 @@ export default function MapComponent() {
latitude: 40,
zoom: 3.5,
}}
onClick={handleMapClick}
>
<NavigationControl style={{ marginTop: '110px', marginRight: '36px' }} />
<FullscreenControl style={{ marginTop: '30px', marginRight: '36px' }} />
<ScaleControl position="bottom-left" maxWidth={150} unit="imperial" />
{/* Existing markers */}
{markers.map((marker) => (
<Marker
key={marker.id}
longitude={marker.longitude}
latitude={marker.latitude}
anchor="bottom"
onClick={(e) => {
e.originalEvent.stopPropagation()
setSelectedMarkerId(marker.id === selectedMarkerId ? null : marker.id)
setPlacingMarker(null)
}}
>
<MarkerPin
color={PIN_COLORS.find((c) => c.id === marker.color)?.hex}
active={marker.id === selectedMarkerId}
/>
</Marker>
))}
{/* Popup for selected marker */}
{selectedMarker && (
<Popup
longitude={selectedMarker.longitude}
latitude={selectedMarker.latitude}
anchor="bottom"
offset={[0, -36] as [number, number]}
onClose={() => setSelectedMarkerId(null)}
closeOnClick={false}
>
<div className="text-sm font-medium">{selectedMarker.name}</div>
</Popup>
)}
{/* Popup for placing a new marker */}
{placingMarker && (
<Popup
longitude={placingMarker.lng}
latitude={placingMarker.lat}
anchor="bottom"
onClose={() => setPlacingMarker(null)}
closeOnClick={false}
>
<div className="p-1">
<input
autoFocus
type="text"
placeholder="Name this location"
value={markerName}
onChange={(e) => setMarkerName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSaveMarker()
if (e.key === 'Escape') setPlacingMarker(null)
}}
className="block w-full rounded border border-gray-300 px-2 py-1 text-sm placeholder:text-gray-400 focus:outline-none focus:border-gray-500"
/>
<div className="mt-1.5 flex gap-1 items-center">
{PIN_COLORS.map((c) => (
<button
key={c.id}
onClick={() => setMarkerColor(c.id)}
title={c.label}
className="rounded-full p-0.5 transition-transform"
style={{
outline: markerColor === c.id ? `2px solid ${c.hex}` : '2px solid transparent',
outlineOffset: '1px',
}}
>
<div
className="w-4 h-4 rounded-full"
style={{ backgroundColor: c.hex }}
/>
</button>
))}
</div>
<div className="mt-1.5 flex gap-1.5 justify-end">
<button
onClick={() => setPlacingMarker(null)}
className="text-xs text-gray-500 hover:text-gray-700 px-2 py-1 rounded transition-colors"
>
Cancel
</button>
<button
onClick={handleSaveMarker}
disabled={!markerName.trim()}
className="text-xs bg-[#424420] text-white rounded px-2.5 py-1 hover:bg-[#525530] disabled:opacity-40 transition-colors"
>
Save
</button>
</div>
</div>
</Popup>
)}
</Map>
{/* Marker panel overlay */}
<MarkerPanel
markers={markers}
onDelete={handleDeleteMarker}
onFlyTo={handleFlyTo}
onSelect={setSelectedMarkerId}
selectedMarkerId={selectedMarkerId}
/>
</MapProvider>
)
}

View File

@ -0,0 +1,116 @@
import { useState } from 'react'
import { IconMapPinFilled, IconTrash, IconMapPin, IconX } from '@tabler/icons-react'
import { PIN_COLORS } from '~/hooks/useMapMarkers'
import type { MapMarker } from '~/hooks/useMapMarkers'
interface MarkerPanelProps {
markers: MapMarker[]
onDelete: (id: number) => void
onFlyTo: (longitude: number, latitude: number) => void
onSelect: (id: number | null) => void
selectedMarkerId: number | null
}
export default function MarkerPanel({
markers,
onDelete,
onFlyTo,
onSelect,
selectedMarkerId,
}: MarkerPanelProps) {
const [open, setOpen] = useState(false)
if (!open) {
return (
<button
onClick={() => setOpen(true)}
className="absolute left-4 top-[72px] z-40 flex items-center gap-1.5 rounded-lg bg-surface-primary/95 px-3 py-2 shadow-lg border border-border-subtle backdrop-blur-sm hover:bg-surface-secondary transition-colors"
title="Show saved locations"
>
<IconMapPin size={18} className="text-desert-orange" />
<span className="text-sm font-medium text-text-primary">Pins</span>
{markers.length > 0 && (
<span className="ml-0.5 flex h-5 min-w-5 items-center justify-center rounded-full bg-desert-orange text-[11px] font-bold text-white px-1">
{markers.length}
</span>
)}
</button>
)
}
return (
<div className="absolute left-4 top-[72px] z-40 w-72 rounded-lg bg-surface-primary/95 shadow-lg border border-border-subtle backdrop-blur-sm">
{/* Header */}
<div className="flex items-center justify-between px-3 py-2.5 border-b border-border-subtle">
<div className="flex items-center gap-2">
<IconMapPin size={18} className="text-desert-orange" />
<span className="text-sm font-semibold text-text-primary">
Saved Locations
</span>
{markers.length > 0 && (
<span className="flex h-5 min-w-5 items-center justify-center rounded-full bg-desert-orange text-[11px] font-bold text-white px-1">
{markers.length}
</span>
)}
</div>
<button
onClick={() => setOpen(false)}
className="rounded p-0.5 text-text-muted hover:text-text-primary hover:bg-surface-secondary transition-colors"
title="Close panel"
>
<IconX size={16} />
</button>
</div>
{/* Marker list */}
<div className="max-h-[calc(100vh-180px)] overflow-y-auto">
{markers.length === 0 ? (
<div className="px-3 py-6 text-center">
<IconMapPinFilled size={24} className="mx-auto mb-2 text-text-muted" />
<p className="text-sm text-text-muted">
Click anywhere on the map to drop a pin
</p>
</div>
) : (
<ul>
{markers.map((marker) => (
<li
key={marker.id}
className={`flex items-center gap-2 px-3 py-2 border-b border-border-subtle last:border-b-0 group transition-colors ${
marker.id === selectedMarkerId
? 'bg-desert-green/10'
: 'hover:bg-surface-secondary'
}`}
>
<IconMapPinFilled
size={16}
className="shrink-0"
style={{ color: PIN_COLORS.find((c) => c.id === marker.color)?.hex ?? '#a84a12' }}
/>
<button
onClick={() => {
onSelect(marker.id)
onFlyTo(marker.longitude, marker.latitude)
}}
className="flex-1 min-w-0 text-left"
title={marker.name}
>
<p className="text-sm font-medium text-text-primary truncate">
{marker.name}
</p>
</button>
<button
onClick={() => onDelete(marker.id)}
className="shrink-0 rounded p-1 text-text-muted opacity-0 group-hover:opacity-100 hover:text-desert-red hover:bg-surface-secondary transition-all"
title="Delete pin"
>
<IconTrash size={14} />
</button>
</li>
))}
</ul>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,17 @@
import { IconMapPinFilled } from '@tabler/icons-react'
interface MarkerPinProps {
color?: string
active?: boolean
}
export default function MarkerPin({ color = '#a84a12', active = false }: MarkerPinProps) {
return (
<div className="cursor-pointer" style={{ filter: 'drop-shadow(0 1px 2px rgba(0,0,0,0.4))' }}>
<IconMapPinFilled
size={active ? 36 : 32}
style={{ color }}
/>
</div>
)
}

View File

@ -118,4 +118,41 @@ body {
--color-btn-green-active: #3a3c24;
color-scheme: dark;
}
/* MapLibre popup styling for dark mode */
[data-theme="dark"] .maplibregl-popup-content {
background: #2a2918;
color: #f7eedc;
}
[data-theme="dark"] .maplibregl-popup-content input {
background: #353420;
color: #f7eedc;
border-color: #424420;
}
[data-theme="dark"] .maplibregl-popup-content input::placeholder {
color: #8f8f82;
}
[data-theme="dark"] .maplibregl-popup-tip {
border-top-color: #2a2918;
}
[data-theme="dark"] .maplibregl-popup-anchor-bottom .maplibregl-popup-tip {
border-top-color: #2a2918;
}
[data-theme="dark"] .maplibregl-popup-anchor-top .maplibregl-popup-tip {
border-bottom-color: #2a2918;
}
[data-theme="dark"] .maplibregl-popup-close-button {
color: #afafa5;
}
[data-theme="dark"] .maplibregl-popup-close-button:hover {
color: #f7eedc;
background: #353420;
}

View File

@ -0,0 +1,86 @@
import { useState, useCallback, useEffect } from 'react'
import api from '~/lib/api'
export const PIN_COLORS = [
{ id: 'orange', label: 'Orange', hex: '#a84a12' },
{ id: 'red', label: 'Red', hex: '#994444' },
{ id: 'green', label: 'Green', hex: '#424420' },
{ id: 'blue', label: 'Blue', hex: '#2563eb' },
{ id: 'purple', label: 'Purple', hex: '#7c3aed' },
{ id: 'yellow', label: 'Yellow', hex: '#ca8a04' },
] as const
export type PinColorId = typeof PIN_COLORS[number]['id']
export interface MapMarker {
id: number
name: string
longitude: number
latitude: number
color: PinColorId
createdAt: string
}
export function useMapMarkers() {
const [markers, setMarkers] = useState<MapMarker[]>([])
const [loaded, setLoaded] = useState(false)
// Load markers from API on mount
useEffect(() => {
api.listMapMarkers().then((data) => {
if (data) {
setMarkers(
data.map((m) => ({
id: m.id,
name: m.name,
longitude: m.longitude,
latitude: m.latitude,
color: m.color as PinColorId,
createdAt: m.created_at,
}))
)
}
setLoaded(true)
})
}, [])
const addMarker = useCallback(
async (name: string, longitude: number, latitude: number, color: PinColorId = 'orange') => {
const result = await api.createMapMarker({ name, longitude, latitude, color })
if (result) {
const marker: MapMarker = {
id: result.id,
name: result.name,
longitude: result.longitude,
latitude: result.latitude,
color: result.color as PinColorId,
createdAt: result.created_at,
}
setMarkers((prev) => [...prev, marker])
return marker
}
return null
},
[]
)
const updateMarker = useCallback(async (id: number, updates: { name?: string; color?: string }) => {
const result = await api.updateMapMarker(id, updates)
if (result) {
setMarkers((prev) =>
prev.map((m) =>
m.id === id
? { ...m, name: result.name, color: result.color as PinColorId }
: m
)
)
}
}, [])
const deleteMarker = useCallback(async (id: number) => {
await api.deleteMapMarker(id)
setMarkers((prev) => prev.filter((m) => m.id !== id))
}, [])
return { markers, loaded, addMarker, updateMarker, deleteMarker }
}

View File

@ -571,6 +571,39 @@ class API {
})()
}
async listMapMarkers() {
return catchInternal(async () => {
const response = await this.client.get<
Array<{ id: number; name: string; longitude: number; latitude: number; color: string; created_at: string }>
>('/maps/markers')
return response.data
})()
}
async createMapMarker(data: { name: string; longitude: number; latitude: number; color?: string }) {
return catchInternal(async () => {
const response = await this.client.post<
{ id: number; name: string; longitude: number; latitude: number; color: string; created_at: string }
>('/maps/markers', data)
return response.data
})()
}
async updateMapMarker(id: number, data: { name?: string; color?: string }) {
return catchInternal(async () => {
const response = await this.client.patch<
{ id: number; name: string; longitude: number; latitude: number; color: string }
>(`/maps/markers/${id}`, data)
return response.data
})()
}
async deleteMapMarker(id: number) {
return catchInternal(async () => {
await this.client.delete(`/maps/markers/${id}`)
})()
}
async listRemoteZimFiles({
start = 0,
count = 12,

View File

@ -80,6 +80,10 @@ router
router.post('/download-collection', [MapsController, 'downloadCollection'])
router.get('/global-map-info', [MapsController, 'globalMapInfo'])
router.post('/download-global-map', [MapsController, 'downloadGlobalMap'])
router.get('/markers', [MapsController, 'listMarkers'])
router.post('/markers', [MapsController, 'createMarker'])
router.patch('/markers/:id', [MapsController, 'updateMarker'])
router.delete('/markers/:id', [MapsController, 'deleteMarker'])
router.delete('/:filename', [MapsController, 'delete'])
})
.prefix('/api/maps')