Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ declare module 'vue' {
HazardMenu: typeof import('./src/components/HazardMenu.vue')['default']
HazardSelect: typeof import('./src/components/HazardSelect.vue')['default']
HelloWorld: typeof import('./src/components/HelloWorld.vue')['default']
LayerLegend: typeof import('./src/components/LayerLegend.vue')['default']
LayerList: typeof import('./src/components/LayerList.vue')['default']
MapComponent: typeof import('./src/components/MapComponent.vue')['default']
MapLayer: typeof import('./src/components/MapLayer.vue')['default']
Expand Down
2 changes: 2 additions & 0 deletions src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@
<navigation-drawer />
<v-main style="padding-inline: 0">
<router-view />
<layer-legend />
</v-main>
</v-app>
</template>

<script setup>
import NavigationDrawer from '@/components/NavigationDrawer.vue'
import LayerLegend from '@/components/LayerLegend.vue'
</script>
191 changes: 191 additions & 0 deletions src/components/LayerLegend.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
<template>
<div
v-if="hasVisibleLayers"
class="layer-legend-container"
>
<v-badge
:content="visibleLayers.length"
:model-value="true"
color="primary"
overlap
>
<v-btn
class="legend-button"
icon="mdi-map-legend"
size="large"
elevation="4"
@click="toggleLegend"
/>
</v-badge>

<v-expand-transition>
<div
v-show="showLegend"
class="legend-panel"
>
<v-card
v-for="layer in visibleLayers"
:key="layer.id"
class="legend-item-card mb-2"
elevation="2"
rounded="xl"
max-width="300"
>
<v-card-title
class="d-flex justify-space-between align-center pa-3 cursor-pointer"
style="user-select: none;"
@click="toggleLayerLegend(layer.id)"
>
<span class="text-body-2 font-weight-medium">
{{ getLayerName(layer.id) }}
</span>
<v-icon
class="legend-chevron"
:class="{ 'legend-chevron--active': isLayerExpanded(layer.id) }"
>
mdi-chevron-down
</v-icon>
</v-card-title>

<v-expand-transition>
<v-card-text
v-show="isLayerExpanded(layer.id)"
class="pa-3 pt-2"
>
<img
v-if="!failedImageIds.has(layer.id)"
class="legend-image"
:src="legendUrl(layer)"
alt=""
@error="onImageError(layer.id)"
>
</v-card-text>
</v-expand-transition>
</v-card>
</div>
</v-expand-transition>
</div>
</template>

<script setup>
import { computed, ref, watch } from 'vue'
import { useMapStore } from '@/stores/map'
import buildLegendUrl from '@/lib/build-legend-url'
import navigationConfig from '@/config/navigation.json'

const mapStore = useMapStore()
const failedImageIds = ref(new Set())
const showLegend = ref(true)
const expandedLayers = ref(new Set())

const visibleLayers = computed(() => mapStore.visibleLayersWithConfig)
const hasVisibleLayers = computed(() => visibleLayers.value.length > 0)

watch(hasVisibleLayers, (newValue) => {
if (newValue) {
showLegend.value = true
visibleLayers.value.forEach(layer => {
expandedLayers.value.add(layer.id)
})
}
})

watch(visibleLayers, (newLayers, oldLayers) => {
const oldIds = new Set(oldLayers?.map(l => l.id) || [])
newLayers.forEach(layer => {
if (!oldIds.has(layer.id)) {
expandedLayers.value.add(layer.id)
}
})
const newIds = new Set(newLayers.map(l => l.id))
expandedLayers.value.forEach(id => {
if (!newIds.has(id)) {
expandedLayers.value.delete(id)
}
})
})

function getLayerNameFromNavigation (layerId) {
for (const menu of navigationConfig.menus) {
if (menu.components) {
for (const component of menu.components) {
if (component.component === 'LayerList' && component.componentProps?.layers) {
const layer = component.componentProps.layers.find(l => l.id === layerId)
if (layer) {
return layer.name
}
}
}
}
}
return null
}

function getLayerName (layerId) {
const navName = getLayerNameFromNavigation(layerId)
if (navName) {
return navName
}

const layer = visibleLayers.value.find(l => l.id === layerId)
return layer?.name || layerId
}

function legendUrl (layer) {
return buildLegendUrl(layer)
}

function toggleLegend () {
showLegend.value = !showLegend.value
}

function toggleLayerLegend (layerId) {
if (expandedLayers.value.has(layerId)) {
expandedLayers.value.delete(layerId)
} else {
expandedLayers.value.add(layerId)
}
}

function isLayerExpanded (layerId) {
return expandedLayers.value.has(layerId)
}

function onImageError (layerId) {
failedImageIds.value.add(layerId)
}
</script>

<style scoped>
.layer-legend-container {
position: absolute;
bottom: 24px;
right: 58px;
z-index: 2;
display: flex;
flex-direction: row-reverse;
align-items: flex-end;
gap: 12px;
}

.legend-panel {
max-height: calc(100vh - 200px);
overflow-y: auto;
display: flex;
flex-direction: column;
}

.legend-chevron {
transform: rotate(-180deg);
transition: transform 0.4s;
}

.legend-chevron--active {
transform: rotate(0deg);
}

.legend-image {
max-width: 100%;
display: block;
}
</style>
32 changes: 32 additions & 0 deletions src/lib/build-legend-url.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import queryString from 'query-string'

// GeoServer - GetLegendGraphic Docs
// https://docs.geoserver.org/stable/en/user/services/wms/get_legend_graphic/index.html
export default function buildLegendUrl (layerData) {
const { url: rawUrl, layer } = layerData

if (!rawUrl || !layer) {
return undefined
}

// Convert WMTS URL to WMS URL for GetLegendGraphic
// Standard GeoServer structure: /gwc/service/wmts? -> /wms?
let wmsUrl = rawUrl
if (rawUrl.includes('/gwc/service/wmts')) {
wmsUrl = rawUrl.replace('/gwc/service/wmts', '/wms')
}

// Remove trailing ? if present and add it back with params
const baseUrl = wmsUrl.endsWith('?') ? wmsUrl.slice(0, -1) : wmsUrl

const params = queryString.stringify({
'request': 'GetLegendGraphic',
'service': 'WMS',
'version': '1.0.0',
'format': 'image/png',
'layer': layer,
'legend_options': 'fontAntiAliasing:true;fontColor:0x000000;fontSize:16;labelMargin:8;dpi:90;',
}, { encode: true, sort: false })

return `${ baseUrl }?${ params }`
}
32 changes: 32 additions & 0 deletions src/stores/map.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,38 @@ export const useMapStore = defineStore('map', {
isLayerClickable: (state) => (layerId) => {
return state.layerClickable[layerId] ?? false
},

visibleLayersWithConfig: (state) => {
const visible = []
const seenIds = new Set()

for (const layerId in state.layerVisibility) {
if (state.layerVisibility[layerId] === true) {
// Skip raster layers with _raster suffix (only show base layer legends)
if (layerId.endsWith('_raster')) {
continue
}

// Avoid duplicates
if (seenIds.has(layerId)) {
continue
}
seenIds.add(layerId)

const layerConfig = state.layersConfig.find(config => config.id === layerId)
if (layerConfig && layerConfig.url && layerConfig.layer) {
visible.push({
id: layerId,
url: layerConfig.url,
layer: layerConfig.layer,
name: layerConfig.name,
})
}
}
}

return visible
},
},

actions: {
Expand Down
3 changes: 0 additions & 3 deletions src/views/Home.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,3 @@
<template>
<map-component />
</template>

<script setup>
</script>