agepoly-events/frontend/src/components/PlanMap.vue
Antoine Pelletier 52363d59ac first commit
2026-07-30 06:26:31 +02:00

613 lines
22 KiB
Vue

<script setup lang="ts">
import { onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner'
import type * as Leaflet from 'leaflet'
import 'leaflet/dist/leaflet.css'
import 'leaflet-draw/dist/leaflet.draw.css'
import markerIcon2x from 'leaflet/dist/images/marker-icon-2x.png'
import markerIcon from 'leaflet/dist/images/marker-icon.png'
import markerShadow from 'leaflet/dist/images/marker-shadow.png'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
// The editing toolbar (add/edit/delete shapes) only shows up for admins; a
// public visitor sees the drawn plan read-only.
const { editable = false } = defineProps<{ editable?: boolean }>()
const geojson = defineModel<GeoJSON.FeatureCollection>({ required: true })
const { t } = useI18n()
const EPFL_CENTER: [number, number] = [46.5185, 6.5666]
const EPFL_INITIAL_ZOOM = 22
// EPFL's building outlines are only published through plan.epfl.ch's own
// (public, unauthenticated, but undocumented) tile service, in the Swiss
// national grid (EPSG:2056) rather than the usual Web Mercator one — so the
// whole map has to use that grid to align the OSM base layer with the
// building tiles. Origin and per-zoom resolutions come straight from that
// service's own WMTS capabilities document (prod-plan-epfl-tiles*.epfl.ch/
// 1.0.0/WMTSCapabilities_prod_2056.xml), not guessed.
const SWISS_PROJ4_DEF =
'+proj=somerc +lat_0=46.95240555555556 +lon_0=7.439583333333333 +k_0=1 +x_0=2600000 +y_0=1200000 +ellps=bessel +towgs84=674.374,15.056,405.346,0,0,0,0 +units=m +no_defs'
const SWISS_GRID_ORIGIN: [number, number] = [2420000, 1350000]
// Actual tile data only goes up to index 29 (buildings) / 28 (base map,
// aerial). A couple more levels are appended so the map can still be zoomed
// in further than that: Leaflet then just upscales the deepest available
// tile instead of fetching a non-existent one (`maxNativeZoom` below).
const NATIVE_RESOLUTIONS = [
4000, 3750, 3500, 3250, 3000, 2750, 2500, 2250, 2000, 1750, 1500, 1250, 1000, 750, 650, 500,
250, 100, 50, 20, 10, 5, 2.5, 2, 1.5, 1, 0.5, 0.25, 0.1, 0.05,
]
const EXTRA_ZOOM_LEVELS = 2
const SWISS_RESOLUTIONS = [
...NATIVE_RESOLUTIONS,
...Array.from(
{ length: EXTRA_ZOOM_LEVELS },
(_, i) => NATIVE_RESOLUTIONS[NATIVE_RESOLUTIONS.length - 1] / 2 ** (i + 1),
),
]
const MAX_ZOOM = SWISS_RESOLUTIONS.length - 1
const BUILDINGS_MAX_NATIVE_ZOOM = NATIVE_RESOLUTIONS.length - 1
const BASE_LAYERS_MAX_NATIVE_ZOOM = NATIVE_RESOLUTIONS.length - 2
// `{floorPath}` is "" for the base map and "{floor}/" for the buildings
// layer, matching the two real URL shapes observed on plan.epfl.ch:
// .../osm-wmts/default/{date}/2056/{z}/{row}/{col}.png
// .../batiments/default/{date}/{floor}/2056/{z}/{row}/{col}.png
// Note the path is TileMatrix/TileRow/TileCol (OGC WMTS REST convention,
// i.e. z/y/x) rather than the more common z/x/y "slippy map" order.
const EPFL_TILES_URL =
'https://prod-plan-epfl-tiles{s}.epfl.ch/1.0.0/{layer}/default/{date}/{floorPath}2056/{z}/{y}/{x}.png'
const EPFL_TILE_SUBDOMAINS = ['0', '1', '2', '3', '4']
// The aerial/satellite imagery is served separately, by the official (public,
// documented) Swiss geoportal, on the same grid (origin and resolutions
// verified against its own WMTS capabilities document) but NOT the same URL
// convention: swisstopo's REST template is TileMatrix/TileCol/TileRow, i.e.
// z/x/y ("slippy map" order), unlike EPFL's own proxy above (z/y/x).
const AERIAL_TILES_URL =
'https://wmts.geo.admin.ch/1.0.0/ch.swisstopo.swissimage-product/default/current/2056/{z}/{x}/{y}.jpeg'
interface EpflTileLayerOptions extends Leaflet.TileLayerOptions {
layer: string
date: string
floorPath: string
}
const DEFAULT_COLOR = '#3388ff'
const DASHED_PATTERN = '8, 8'
const floor = ref(0)
const backgroundPanelOpen = ref(false)
const showMap = ref(true)
const showAerial = ref(false)
const aerialOpacity = ref(1)
// Style applied to newly drawn shapes.
const drawColor = ref(DEFAULT_COLOR)
const drawDashed = ref(false)
const styleDialogOpen = ref(false)
const styleForm = reactive({ color: DEFAULT_COLOR, dashed: false })
const annotationDialogOpen = ref(false)
const annotationKind = ref<'text' | 'icon'>('text')
const annotationForm = reactive({ text: '', svg: '' })
const mapContainer = ref<HTMLDivElement>()
let map: Leaflet.Map | undefined
let osmLayer: Leaflet.TileLayer | undefined
let aerialLayer: Leaflet.TileLayer | undefined
let buildingsLayer: Leaflet.TileLayer | undefined
let drawnItems: Leaflet.FeatureGroup | undefined
let LeafletLib: typeof Leaflet | undefined
function floorOf(feature: GeoJSON.Feature): number {
const value = feature.properties?.floor
return typeof value === 'number' ? value : 0
}
function escapeHtml(value: string): string {
const div = document.createElement('div')
div.textContent = value
return div.innerHTML
}
// Minimal SVG sanitizer: strips <script>/<foreignObject> and any event
// handler or javascript: URI, since this markup gets rendered for every
// visitor of the (public) plan. Not a full sanitizer, but covers the
// common XSS vectors for a narrow "paste an icon" use case.
function sanitizeSvg(svgString: string): string | null {
let doc: Document
try {
doc = new DOMParser().parseFromString(svgString, 'image/svg+xml')
} catch {
return null
}
const root = doc.documentElement
if (!root || root.nodeName.toLowerCase() !== 'svg' || doc.querySelector('parsererror')) {
return null
}
const stripDangerousAttrs = (el: Element) => {
for (const attr of Array.from(el.attributes)) {
const name = attr.name.toLowerCase()
const value = attr.value.trim().toLowerCase()
if (name.startsWith('on') || ((name === 'href' || name === 'xlink:href') && value.startsWith('javascript:'))) {
el.removeAttribute(attr.name)
}
}
}
const clean = (node: Element) => {
stripDangerousAttrs(node)
for (const child of Array.from(node.children)) {
const tag = child.tagName.toLowerCase()
if (tag === 'script' || tag === 'foreignobject') {
child.remove()
continue
}
clean(child)
}
}
clean(root)
return new XMLSerializer().serializeToString(root)
}
function annotationHtml(properties: GeoJSON.GeoJsonProperties): string {
if (properties?.kind === 'text') {
const color = typeof properties.color === 'string' ? properties.color : DEFAULT_COLOR
return `<div class="plan-annotation-text" style="color:${color}">${escapeHtml(String(properties.text ?? ''))}</div>`
}
if (typeof properties?.emoji === 'string') {
return `<div class="plan-annotation-icon">${escapeHtml(properties.emoji)}</div>`
}
// Re-sanitize on every render, not just at creation time: the stored
// `plan` blob is opaque to the backend (no server-side validation), so
// this is the actual trust boundary — anything that ends up in the
// database must still be safe to render for every future visitor.
const svg = typeof properties?.svg === 'string' ? (sanitizeSvg(properties.svg) ?? '') : ''
return `<div class="plan-annotation-icon">${svg}</div>`
}
function makeAnnotationIcon(properties: GeoJSON.GeoJsonProperties): Leaflet.DivIcon {
return LeafletLib!.divIcon({
html: annotationHtml(properties),
className: 'plan-annotation-marker',
iconSize: [32, 32],
iconAnchor: [16, 16],
})
}
type StyledLayer = Leaflet.Path & { feature?: GeoJSON.Feature }
let editingStyleLayer: StyledLayer | undefined
function hasSetStyle(layer: Leaflet.Layer): layer is StyledLayer {
return typeof (layer as unknown as Partial<Leaflet.Path>).setStyle === 'function'
}
function attachStyleClickHandler(layer: Leaflet.Layer) {
if (!editable || !hasSetStyle(layer)) return
layer.on('click', () => openStyleEditor(layer))
}
function openStyleEditor(layer: StyledLayer) {
editingStyleLayer = layer
const properties = layer.feature?.properties ?? {}
styleForm.color = typeof properties.color === 'string' ? properties.color : DEFAULT_COLOR
styleForm.dashed = !!properties.dashArray
styleDialogOpen.value = true
}
function applyStyleToSelected() {
if (!editingStyleLayer) return
const dashArray = styleForm.dashed ? DASHED_PATTERN : undefined
editingStyleLayer.setStyle({ color: styleForm.color, dashArray })
editingStyleLayer.feature = {
type: 'Feature',
geometry: editingStyleLayer.feature?.geometry ?? (null as never),
properties: {
...editingStyleLayer.feature?.properties,
color: styleForm.color,
dashArray: dashArray ?? null,
},
}
styleDialogOpen.value = false
emitCurrentShapes()
}
function openAnnotationDialog(kind: 'text' | 'icon') {
annotationKind.value = kind
annotationForm.text = ''
annotationForm.svg = ''
annotationDialogOpen.value = true
}
function confirmAnnotation() {
if (!map || !LeafletLib || !drawnItems) return
const L = LeafletLib
const properties: GeoJSON.GeoJsonProperties = { floor: floor.value, kind: annotationKind.value, color: drawColor.value }
if (annotationKind.value === 'text') {
if (!annotationForm.text.trim()) return
properties.text = annotationForm.text.trim()
} else {
const raw = annotationForm.svg.trim()
if (!raw) return
if (raw.startsWith('<')) {
const cleaned = sanitizeSvg(raw)
if (!cleaned) {
toast.error(t('admin.invalid-svg'))
return
}
properties.svg = cleaned
} else {
// Not SVG markup: treat it as plain text (e.g. an emoji).
properties.emoji = raw
}
}
const marker = L.marker(map.getCenter(), {
draggable: true,
pane: 'drawingsPane',
icon: makeAnnotationIcon(properties),
}) as Leaflet.Marker & { feature?: GeoJSON.Feature }
marker.feature = { type: 'Feature', properties, geometry: null as never }
marker.on('dragend', emitCurrentShapes)
drawnItems.addLayer(marker)
annotationDialogOpen.value = false
emitCurrentShapes()
}
function loadFloorIntoDrawnItems() {
if (!drawnItems || !LeafletLib) return
const L = LeafletLib
drawnItems.clearLayers()
const features = (geojson.value.features ?? []).filter((f) => floorOf(f) === floor.value)
const collection: GeoJSON.FeatureCollection = { type: 'FeatureCollection', features }
L.geoJSON(collection, {
pointToLayer: (feature, latlng) => {
if (feature.properties?.kind === 'text' || feature.properties?.kind === 'icon') {
const marker = L.marker(latlng, {
draggable: editable,
pane: 'drawingsPane',
icon: makeAnnotationIcon(feature.properties),
})
if (editable) marker.on('dragend', emitCurrentShapes)
return marker
}
return L.marker(latlng, { pane: 'drawingsPane' })
},
style: (feature) => ({
pane: 'drawingsPane',
color: typeof feature?.properties?.color === 'string' ? feature.properties.color : DEFAULT_COLOR,
dashArray:
typeof feature?.properties?.dashArray === 'string' ? feature.properties.dashArray : undefined,
}),
}).eachLayer((layer) => {
attachStyleClickHandler(layer)
drawnItems?.addLayer(layer)
})
}
function emitCurrentShapes() {
if (!drawnItems) return
const otherFloors = (geojson.value.features ?? []).filter((f) => floorOf(f) !== floor.value)
const currentFloor = (drawnItems.toGeoJSON() as GeoJSON.FeatureCollection).features
geojson.value = {
type: 'FeatureCollection',
features: [...otherFloors, ...currentFloor],
}
}
onMounted(async () => {
// leaflet-draw and proj4leaflet are old-style plugins that patch the
// global `L` object rather than importing leaflet themselves, so `window.L`
// must be set before they load. Dynamic imports (unlike static ones) run in
// the order awaited, which is what makes this ordering reliable.
const L = (await import('leaflet')).default
LeafletLib = L
;(window as unknown as { L: typeof L }).L = L
await import('proj4leaflet')
// Leaflet's default marker icon paths break under bundlers: point them at
// the actual bundled asset URLs instead.
delete (L.Icon.Default.prototype as { _getIconUrl?: unknown })._getIconUrl
L.Icon.Default.mergeOptions({
iconRetinaUrl: markerIcon2x,
iconUrl: markerIcon,
shadowUrl: markerShadow,
})
if (!mapContainer.value) return
const swissCrs = new L.Proj.CRS('EPSG:2056', SWISS_PROJ4_DEF, {
origin: SWISS_GRID_ORIGIN,
resolutions: SWISS_RESOLUTIONS,
})
map = L.map(mapContainer.value, {
crs: swissCrs as unknown as Leaflet.CRS,
minZoom: 0,
maxZoom: MAX_ZOOM,
}).setView(EPFL_CENTER, EPFL_INITIAL_ZOOM)
// Dedicated panes (rather than each TileLayer's own `zIndex` option) so
// stacking is robust across zoom-transition animations, which otherwise
// apply their own z-index to the layer's internal wrapper. Note: Leaflet
// strips the "Pane" suffix from the name to build the CSS class, so e.g.
// "mapPane" would collide with Leaflet's own built-in `leaflet-map-pane`
// (the whole map's transform container) — hence "osmBasePane" here.
map.createPane('osmBasePane').style.zIndex = '200'
map.createPane('aerialPane').style.zIndex = '300'
map.createPane('buildingsPane').style.zIndex = '400'
map.createPane('drawingsPane').style.zIndex = '500'
// Each TileLayer's own maxZoom defaults to 18 regardless of the map's: it
// must be raised to match our resolutions array, or GridLayer silently
// renders nothing once the map zoom exceeds it. maxNativeZoom caps where
// Leaflet stops fetching new tiles and starts upscaling the deepest one.
const baseZoomBounds = { minZoom: 0, maxZoom: MAX_ZOOM, maxNativeZoom: BASE_LAYERS_MAX_NATIVE_ZOOM }
const buildingsZoomBounds = { minZoom: 0, maxZoom: MAX_ZOOM, maxNativeZoom: BUILDINGS_MAX_NATIVE_ZOOM }
osmLayer = L.tileLayer(EPFL_TILES_URL, {
...baseZoomBounds,
pane: 'osmBasePane',
subdomains: EPFL_TILE_SUBDOMAINS,
layer: 'osm-wmts',
date: '20250317',
floorPath: '',
attribution: '&copy; EPFL &middot; &copy; OpenStreetMap contributors',
} as EpflTileLayerOptions)
if (showMap.value) osmLayer.addTo(map)
aerialLayer = L.tileLayer(AERIAL_TILES_URL, {
...baseZoomBounds,
pane: 'aerialPane',
opacity: aerialOpacity.value,
attribution: '&copy; swisstopo',
})
if (showAerial.value) aerialLayer.addTo(map)
buildingsLayer = L.tileLayer(EPFL_TILES_URL, {
...buildingsZoomBounds,
pane: 'buildingsPane',
subdomains: EPFL_TILE_SUBDOMAINS,
layer: 'batiments',
date: '20231011',
floorPath: `${floor.value}/`,
} as EpflTileLayerOptions).addTo(map)
drawnItems = L.featureGroup().addTo(map)
loadFloorIntoDrawnItems()
if (editable) {
await import('leaflet-draw')
// leaflet-draw 1.0.4's GeometryUtil.readableArea assigns to an
// undeclared `type` variable, which throws under strict-mode ESM
// bundling (it silently leaked a global in classic <script> usage
// instead). Replace it with an equivalent metric-only implementation.
L.GeometryUtil.readableArea = (area: number) => {
if (area >= 1_000_000) return `${(area / 1_000_000).toFixed(2)} km²`
if (area >= 10_000) return `${(area / 10_000).toFixed(2)} ha`
return `${Math.round(area)}`
}
const drawControl = new L.Control.Draw({
edit: { featureGroup: drawnItems },
draw: {
marker: { pane: 'drawingsPane' } as unknown as Leaflet.MarkerOptions,
polyline: { shapeOptions: { pane: 'drawingsPane' } },
polygon: { shapeOptions: { pane: 'drawingsPane' } },
rectangle: { shapeOptions: { pane: 'drawingsPane' } },
circle: { shapeOptions: { pane: 'drawingsPane' } },
circlemarker: false,
},
})
map.addControl(drawControl)
map.on(L.Draw.Event.CREATED, (e) => {
const event = e as Leaflet.DrawEvents.Created
const layer = event.layer as Leaflet.Layer & { feature?: GeoJSON.Feature }
const properties: GeoJSON.GeoJsonProperties = { floor: floor.value }
if (hasSetStyle(layer)) {
const dashArray = drawDashed.value ? DASHED_PATTERN : undefined
layer.setStyle({ color: drawColor.value, dashArray })
properties.color = drawColor.value
properties.dashArray = dashArray ?? null
}
layer.feature = { type: 'Feature', properties, geometry: null as never }
attachStyleClickHandler(layer)
drawnItems?.addLayer(layer)
emitCurrentShapes()
})
map.on(L.Draw.Event.EDITED, emitCurrentShapes)
map.on(L.Draw.Event.DELETED, emitCurrentShapes)
}
})
watch(floor, (value) => {
if (buildingsLayer) {
;(buildingsLayer.options as EpflTileLayerOptions).floorPath = `${value}/`
buildingsLayer.redraw()
}
loadFloorIntoDrawnItems()
})
watch(showMap, (value) => {
if (!map || !osmLayer) return
if (value) osmLayer.addTo(map)
else map.removeLayer(osmLayer)
})
watch(showAerial, (value) => {
if (!map || !aerialLayer) return
if (value) aerialLayer.addTo(map)
else map.removeLayer(aerialLayer)
})
watch(aerialOpacity, (value) => {
aerialLayer?.setOpacity(value)
})
function changeFloor(delta: number) {
floor.value += delta
}
onBeforeUnmount(() => {
map?.remove()
})
</script>
<template>
<div class="flex flex-col gap-2">
<div class="flex flex-wrap items-center gap-2">
<Button type="button" variant="outline" size="icon-sm" @click="changeFloor(-1)">-</Button>
<span class="w-24 text-center text-sm">{{ $t('admin.floor-label', { floor }) }}</span>
<Button type="button" variant="outline" size="icon-sm" @click="changeFloor(1)">+</Button>
<template v-if="editable">
<label class="ml-2 flex items-center gap-1 text-sm">
{{ $t('admin.draw-color') }}
<input v-model="drawColor" type="color" class="h-7 w-9 cursor-pointer rounded border" />
</label>
<select v-model="drawDashed" class="rounded-md border px-2 py-1 text-sm">
<option :value="false">{{ $t('admin.line-solid') }}</option>
<option :value="true">{{ $t('admin.line-dashed') }}</option>
</select>
<Button type="button" variant="outline" size="sm" @click="openAnnotationDialog('text')">
{{ $t('admin.add-text') }}
</Button>
<Button type="button" variant="outline" size="sm" @click="openAnnotationDialog('icon')">
{{ $t('admin.add-icon') }}
</Button>
</template>
<div class="relative ml-auto">
<Button
type="button"
variant="outline"
size="sm"
@click="backgroundPanelOpen = !backgroundPanelOpen"
>
{{ $t('admin.background-layer') }}
</Button>
<div
v-if="backgroundPanelOpen"
class="bg-background absolute right-0 z-[1000] mt-1 w-56 rounded-md border p-3 shadow-md"
>
<label class="flex items-center gap-2 text-sm">
<input v-model="showMap" type="checkbox" />
{{ $t('admin.layer-map') }}
</label>
<label class="mt-2 flex items-center gap-2 text-sm">
<input v-model="showAerial" type="checkbox" />
{{ $t('admin.layer-aerial') }}
</label>
<input
v-if="showAerial"
v-model.number="aerialOpacity"
type="range"
min="0"
max="1"
step="0.01"
class="mt-2 w-full"
:aria-label="t('admin.layer-aerial-opacity')"
/>
</div>
</div>
</div>
<p v-if="editable" class="text-muted-foreground text-xs">{{ $t('admin.annotation-hint') }}</p>
<div ref="mapContainer" class="isolate h-[500px] w-full rounded-md border" />
<Dialog v-model:open="styleDialogOpen">
<DialogContent>
<DialogHeader>
<DialogTitle>{{ $t('admin.style-title') }}</DialogTitle>
</DialogHeader>
<div class="flex flex-col gap-3">
<label class="flex items-center gap-2 text-sm">
{{ $t('admin.draw-color') }}
<input v-model="styleForm.color" type="color" class="h-8 w-10 cursor-pointer rounded border" />
</label>
<select v-model="styleForm.dashed" class="rounded-md border px-2 py-1 text-sm">
<option :value="false">{{ $t('admin.line-solid') }}</option>
<option :value="true">{{ $t('admin.line-dashed') }}</option>
</select>
</div>
<DialogFooter>
<Button type="button" @click="applyStyleToSelected">{{ $t('admin.save') }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog v-model:open="annotationDialogOpen">
<DialogContent>
<DialogHeader>
<DialogTitle>
{{ annotationKind === 'text' ? $t('admin.add-text') : $t('admin.add-icon') }}
</DialogTitle>
</DialogHeader>
<form class="flex flex-col gap-3" @submit.prevent="confirmAnnotation">
<Input
v-if="annotationKind === 'text'"
v-model="annotationForm.text"
:placeholder="$t('admin.text-placeholder')"
required
/>
<template v-else>
<p class="text-muted-foreground text-xs">{{ $t('admin.icon-hint') }}</p>
<Textarea
v-model="annotationForm.svg"
rows="6"
:placeholder="$t('admin.icon-placeholder')"
required
/>
</template>
<DialogFooter>
<Button type="submit">{{ $t('admin.add-to-plan') }}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
</template>
<style>
.plan-annotation-marker {
background: transparent;
border: none;
overflow: visible;
}
.plan-annotation-text {
width: max-content;
max-width: 200px;
font-weight: 600;
font-size: 13px;
padding: 1px 4px;
background: rgba(255, 255, 255, 0.85);
border-radius: 4px;
white-space: pre-wrap;
}
.plan-annotation-icon {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
line-height: 1;
}
.plan-annotation-icon svg {
width: 100%;
height: 100%;
}
</style>