613 lines
22 KiB
Vue
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: '© EPFL · © OpenStreetMap contributors',
|
|
} as EpflTileLayerOptions)
|
|
if (showMap.value) osmLayer.addTo(map)
|
|
|
|
aerialLayer = L.tileLayer(AERIAL_TILES_URL, {
|
|
...baseZoomBounds,
|
|
pane: 'aerialPane',
|
|
opacity: aerialOpacity.value,
|
|
attribution: '© 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)} m²`
|
|
}
|
|
|
|
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>
|