diff --git a/components.d.ts b/components.d.ts index 77b7334..4f36cdf 100644 --- a/components.d.ts +++ b/components.d.ts @@ -16,5 +16,6 @@ declare module 'vue' { RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] TimeSeriesChart: typeof import('./src/components/TimeSeriesChart.vue')['default'] + TreeHealthChart: typeof import('./src/components/TreeHealthChart.vue')['default'] } } diff --git a/src/App.vue b/src/App.vue index e029940..61575bb 100644 --- a/src/App.vue +++ b/src/App.vue @@ -28,16 +28,17 @@
+ {{ item.dataleverancier }}
@@ -60,7 +67,44 @@ > mdi-chevron-down -
+
+
+

+ Boom + {{ + bomenLocationsStore.activeTree?.properties?.boomnaam || "..." + }} +

+ + + + Boomnaam + + {{ boomDataStore.treeName ?? "..." }} + + + + Soortgroep + + {{ boomDataStore.groupThatBelongs ?? "..." }} + + + + +
+
+ +
+
+

Details meetlocatie @@ -141,19 +185,26 @@ import { computed, ref, watch } from "vue"; import PeilfilterGraph from "@/components/PeilfilterGraph.vue"; import TimeSeriesChart from "@/components/TimeSeriesChart.vue"; + import TreeHealthChart from "@/components/TreeHealthChart.vue"; import { useAppStore } from "@/stores/app"; import { useDepthInfoStore } from "@/stores/depthInfo"; import { useLocationsStore } from "@/stores/locations"; + import { useBomenLocationsStore } from "@/stores/bomenLocations"; + import { useBoomDataStore } from "@/stores/boomData"; import { useChartTimeseriesStore } from "@/stores/chartTimeseries"; import { usePeilfilterDataStore } from "@/stores/peilfilterData"; + import { TREE_COLOR } from "@/lib/constants"; const appStore = useAppStore(); const chartTimeseriesStore = useChartTimeseriesStore(); const depthInfoStore = useDepthInfoStore(); const locationsStore = useLocationsStore(); + const bomenLocationsStore = useBomenLocationsStore(); + const boomDataStore = useBoomDataStore(); const peilfilterDataStore = usePeilfilterDataStore(); const panelIsCollapsed = computed(() => appStore.panelIsCollapsed); + const isTreePanel = computed(() => !!bomenLocationsStore.activeTree); const viewMode = computed({ get: () => appStore.viewMode, @@ -169,7 +220,6 @@ const legendItems = computed(() => { const uniqueProviders = new Map(); - // locationsStore.locations is now an array, not a FeatureCollection const locations = locationsStore.locations || []; locations.forEach((location) => { @@ -178,6 +228,7 @@ if (bronId && dataleverancier && !uniqueProviders.has(bronId)) { uniqueProviders.set(bronId, { + key: `provider-${bronId}`, bronId, dataleverancier, color: getColorForBronId(bronId), @@ -185,11 +236,31 @@ } }); - return Array.from(uniqueProviders.values()).sort( + const providers = Array.from(uniqueProviders.values()).sort( (a, b) => a.bronId - b.bronId ); + if (bomenLocationsStore.bomenLocations?.length > 0) { + providers.push({ + key: "trees", + bronId: null, + dataleverancier: "Bomen", + color: TREE_COLOR, + isTree: true, + }); + } + return providers; }); + function onLegendItemClick(item) { + if (!item) return; + if (item.isTree) { + appStore.toggleTrees(); + return; + } + if (item.bronId == null) return; + appStore.toggleCategory(item.bronId); + } + function getColorForBronId(bronId) { const colors = { 1: "#008fc5", @@ -197,7 +268,7 @@ 3: "#ffc107", 4: "#895129", }; - return colors[bronId] || "#6c757d"; // Gray fallback + return colors[bronId] || "#6c757d"; } const peilfilterOptions = computed(() => { @@ -231,12 +302,17 @@ const hasValidDLabel = computed(() => hasNonEmptyValue(peilfilterDataStore.dlabelFilter)); const hasValidPompId = computed(() => hasNonEmptyValue(peilfilterDataStore.pompidFilter)); - function clearPanelDataStores() { + function clearLocationPanelStores() { chartTimeseriesStore.clearData(); peilfilterDataStore.clearData(); depthInfoStore.clearData(); } + function clearAllPanelStores() { + clearLocationPanelStores(); + boomDataStore.clearData(); + } + function commaSplit(str) { if (typeof str !== "string") return []; return str.split(",").map((s) => s.trim()); @@ -247,10 +323,15 @@ (newLocation) => { if (!newLocation) { selectedPeilfilterId.value = null; - clearPanelDataStores(); + if (!bomenLocationsStore.activeTree) { + clearAllPanelStores(); + } else { + clearLocationPanelStores(); + } return; } - clearPanelDataStores(); + bomenLocationsStore.setActiveTree(null); + clearAllPanelStores(); const options = peilfilterOptions.value; selectedPeilfilterId.value = options.length > 0 ? options[0].value : null; @@ -347,12 +428,27 @@ .peilfilter__chart { flex: 0 0 auto; width: 250px; + min-width: 0; + min-height: 0; overflow: hidden; position: relative; padding: 0 0; } .details__chart { + flex: 1 1 0; + min-width: 0; + min-height: 0; + overflow: hidden; + position: relative; +} + +.tree__info { + flex: 0 0 auto; + width: 500px; +} + +.tree__graph { flex: 1 1 0; overflow: hidden; position: relative; diff --git a/src/components/MapComponent.vue b/src/components/MapComponent.vue index 5b31489..b99ef51 100644 --- a/src/components/MapComponent.vue +++ b/src/components/MapComponent.vue @@ -16,12 +16,16 @@ @mouseenter="(e) => handleLayerMouseenter(e, layer.id)" @mouseleave="() => handleLayerMouseleave(layer.id)" /> - + mapStore.mapboxLayers) const defaultMapStyle = computed(() => MAP_BASELAYER_DEFAULT.uri) - const styleChangeCounter = ref(0) // Counter to force MapLayer re-render after style changes + const styleChangeCounter = ref(0) + + const TREE_ICON_SIZE = 1.15 + const TREE_SELECTION_OUTLINE_SIZE = 1.55 - // Provide map instance for child components const map = computed(() => mapInstance.value) provide('map', map) - function onMapCreated(map) { - mapInstance.value = map + function ensureHoverPopup () { + if (!hoverPopup.value) { + hoverPopup.value = new mapboxgl.Popup({ + closeButton: false, + closeOnClick: false, + className: 'location-hover-popup', + }) + } + return hoverPopup.value + } - // Create popup instance for hover - hoverPopup.value = new mapboxgl.Popup({ - closeButton: false, - closeOnClick: false, - className: 'location-hover-popup', + function flyToFeature (mapObj, feature) { + const coords = feature.geometry.coordinates + const offsetY = mapObj.getCanvas().height * 0.25 + mapObj.flyTo({ + center: coords, + zoom: 12.5, + speed: 1.2, + offset: [0, -offsetY], }) + } + + function cloneFeature (feature) { + return JSON.parse(JSON.stringify(feature)) + } + + function setupMapAfterStyleLoad (map) { + registerTreeSdfIcon(map) + setupActiveLocationLayer() + setupActiveTreeLayers() + initializeMap() + } + + function onMapCreated (map) { + mapInstance.value = map + registerTreeSdfIcon(map) + ensureHoverPopup() - // Listen for style changes (when basemap is changed) map.on('style.load', () => { - // Increment counter to force MapLayer components to re-render with new keys + registerTreeSdfIcon(map) styleChangeCounter.value++ - - // Wait for style to be fully loaded before re-initializing layers - // MapboxLayer components need the style to be ready before they can add layers + const waitForStyleReady = () => { if (map.isStyleLoaded()) { - // Clear existing layers from store to force re-render mapStore.mapboxLayers = [] - // Small delay to ensure MapboxLayer components have time to clean up - setTimeout(() => { - setupActiveLocationLayer() - initializeMap() - }, 50) + setTimeout(() => setupMapAfterStyleLoad(map), 50) } else { setTimeout(waitForStyleReady, 50) } @@ -98,27 +132,98 @@ waitForStyleReady() }) - // Wait for initial style to load before adding sources/layers if (map.isStyleLoaded()) { - setupActiveLocationLayer() - initializeMap() + setupMapAfterStyleLoad(map) } else { - map.once('style.load', () => { - setupActiveLocationLayer() - initializeMap() - }) + map.once('style.load', () => setupMapAfterStyleLoad(map)) } } function initializeMap() { mapStore.initializeMapboxLayers() - locationsStore.fetchLocations().then(() => { + Promise.all([ + locationsStore.fetchLocations(), + bomenLocationsStore.fetchBomenLocations(), + ]).then(() => { mapStore.refreshLayers() }) } - // Setup active-location layer for highlighting selected location - function setupActiveLocationLayer() { + function setupActiveTreeLayers() { + const mapObj = mapInstance.value + if (!mapObj || mapObj.getSource('active-tree')) return + + mapObj.addSource('active-tree', { + type: 'geojson', + data: { type: 'FeatureCollection', features: [] }, + }) + + mapObj.addLayer({ + id: 'active-tree-outline-layer', + type: 'symbol', + source: 'active-tree', + layout: { + 'icon-image': TREE_ICON_ID, + 'icon-size': TREE_SELECTION_OUTLINE_SIZE, + 'icon-allow-overlap': true, + 'icon-padding': 0, + }, + paint: { + 'icon-color': TREE_SELECTION_COLOR, + 'icon-opacity': 1, + }, + }) + + mapObj.addLayer({ + id: 'active-tree-layer', + type: 'symbol', + source: 'active-tree', + layout: { + 'icon-image': TREE_ICON_ID, + 'icon-size': TREE_ICON_SIZE, + 'icon-allow-overlap': true, + 'icon-padding': 0, + }, + paint: { + 'icon-color': TREE_COLOR, + 'icon-halo-color': '#ffffff', + 'icon-halo-width': 0.8, + 'icon-opacity': 1, + }, + }) + } + + function updateActiveTreeSourceData () { + const mapObj = mapInstance.value + if (!mapObj?.getSource('active-tree')) return + + const activeTree = bomenLocationsStore.activeTree + mapObj.getSource('active-tree').setData({ + type: 'FeatureCollection', + features: activeTree ? [cloneFeature(activeTree)] : [], + }) + } + + function moveSelectionLayersToTop() { + const mapObj = mapInstance.value + if (!mapObj) return + + try { + if (mapObj.getLayer('active-tree-outline-layer')) { + mapObj.moveLayer('active-tree-outline-layer') + } + if (mapObj.getLayer('active-tree-layer')) { + mapObj.moveLayer('active-tree-layer') + } + if (mapObj.getLayer('active-location-layer')) { + mapObj.moveLayer('active-location-layer') + } + } catch (e) { + console.log('Layer movement failed', e) + } + } + + function setupActiveLocationLayer () { const mapObj = mapInstance.value if (!mapObj || mapObj.getSource('active-location')) return @@ -132,60 +237,61 @@ source: 'active-location', paint: { 'circle-color': '#fff', - 'circle-radius': 5.5, // Slightly larger than normal points (which are 5) - 'circle-stroke-width': 5, // Thick stroke to make red clearly visible - 'circle-stroke-color': '#ff0000', + 'circle-radius': 5.5, + 'circle-stroke-width': 5, + 'circle-stroke-color': TREE_SELECTION_COLOR, }, }) } - // Handle click on location layer - function handleLayerClick(feature, layerId) { - // Only handle clicks on locations-layer + function handleLayerClick (feature, layerId) { const mapObj = mapInstance.value - if (layerId !== 'locations-layer' || !feature || !mapObj) return + if (!feature || !mapObj) return + + if (layerId === 'bomen-locations-layer') { + if (appStore.disabledTrees) return + bomenLocationsStore.setActiveTree(feature) + locationsStore.setActiveLocation(null) + appStore.expandPanel() + flyToFeature(mapObj, feature) + return + } + if (layerId !== 'locations-layer') return const bronId = feature.properties?.bron_id - // Don't allow interaction if category is disabled if (appStore.disabledCategories.has(bronId)) { return } + bomenLocationsStore.setActiveTree(null) locationsStore.setActiveLocation(feature) appStore.expandPanel() - - const coords = feature.geometry.coordinates - const canvas = mapObj.getCanvas() - const offsetY = canvas.height * 0.25 - - mapObj.flyTo({ - center: coords, - zoom: 12.5, - speed: 1.2, - offset: [0, -offsetY], - }) + flyToFeature(mapObj, feature) } - // Handle mouseenter on location layer - function handleLayerMouseenter(e, layerId) { - // Only handle hover on locations-layer + function handleLayerMouseenter (e, layerId) { const mapObj = mapInstance.value - if (layerId !== 'locations-layer' || !mapObj) return - - // Ensure popup is initialized - if (!hoverPopup.value) { - hoverPopup.value = new mapboxgl.Popup({ - closeButton: false, - closeOnClick: false, - className: 'location-hover-popup', - }) - } + if (!mapObj) return const feature = e?.features?.[0] if (!feature) return + const popup = ensureHoverPopup() + + if (layerId === 'bomen-locations-layer') { + mapObj.getCanvas().style.cursor = appStore.disabledTrees ? 'grab' : 'pointer' + if (appStore.disabledTrees) return + + const boomnaam = feature.properties?.boomnaam || 'Onbekend' + popup + .setLngLat(feature.geometry.coordinates.slice()) + .setHTML(`
Boom: ${boomnaam}
`) + .addTo(mapObj) + return + } + if (layerId !== 'locations-layer') return + const hoverBronId = feature.properties?.bron_id - // Don't show hover if category is disabled if (appStore.disabledCategories.has(hoverBronId)) { mapObj.getCanvas().style.cursor = 'grab' return @@ -207,7 +313,6 @@ .sort((a, b) => Number(a.id) - Number(b.id)) .map((item) => item.naam || item.id) - // Build HTML content for Locatie ID let locatieIdHtml = 'Locatie ID: ' if (locatienaamMaster) { locatieIdHtml += `${locatienaamMaster}` @@ -230,12 +335,11 @@ htmlContent += `
${label}: ${ids}
` } - hoverPopup.value.setLngLat(coords).setHTML(htmlContent).addTo(mapObj) + popup.setLngLat(coords).setHTML(htmlContent).addTo(mapObj) } - // Handle mouseleave on location layer - function handleLayerMouseleave(layerId) { - if (layerId !== 'locations-layer') return + function handleLayerMouseleave (layerId) { + if (layerId !== 'locations-layer' && layerId !== 'bomen-locations-layer') return const mapObj = mapInstance.value if (mapObj) { @@ -246,8 +350,7 @@ } } - // Function to update locations layer source data with filtered locations - function updateLocationsSourceData() { + function updateLocationsSourceData () { const mapObj = mapInstance.value if (!mapObj || !mapObj.getLayer('locations-layer')) { return @@ -266,7 +369,6 @@ } - // Watch for locations changes and refresh layers watch( () => locationsStore.locations, () => { @@ -274,7 +376,6 @@ } ) - // Watch for view mode changes and update layer source data watch( () => appStore.viewMode, () => { @@ -284,11 +385,8 @@ } ) - - // Setup direct map event listeners for locations-layer let locationsLayerListenersAttached = false - // Store handler references for cleanup const locationsLayerHandlers = { click: null, mouseenter: null, @@ -301,7 +399,6 @@ return false } - // Remove any existing listeners first if (locationsLayerHandlers.click) { mapObj.off('click', 'locations-layer', locationsLayerHandlers.click) } @@ -312,7 +409,6 @@ mapObj.off('mouseleave', 'locations-layer', locationsLayerHandlers.mouseleave) } - // Click handler locationsLayerHandlers.click = (e) => { const feature = e.features?.[0] if (feature) { @@ -320,17 +416,14 @@ } } - // Mouseenter handler locationsLayerHandlers.mouseenter = (e) => { handleLayerMouseenter(e, 'locations-layer') } - // Mouseleave handler locationsLayerHandlers.mouseleave = () => { handleLayerMouseleave('locations-layer') } - // Attach listeners mapObj.on('click', 'locations-layer', locationsLayerHandlers.click) mapObj.on('mouseenter', 'locations-layer', locationsLayerHandlers.mouseenter) mapObj.on('mouseleave', 'locations-layer', locationsLayerHandlers.mouseleave) @@ -339,50 +432,36 @@ return true } - // Watch for layer to be created and apply initial disabled categories state + setup listeners let retryCount = 0 - const MAX_RETRIES = 50 // Maximum 5 seconds (50 * 100ms) - let setupInProgress = false // Flag to prevent concurrent setups + const MAX_RETRIES = 50 + let setupInProgress = false watch( () => mapboxLayers.value.find(l => l.id === 'locations-layer'), (locationsLayer, oldLocationsLayer) => { - // Only reset if layer state actually changed (not found → found) const wasFound = !!oldLocationsLayer const isFound = !!locationsLayer - + if (!wasFound && isFound) { - // Layer just appeared, reset flags locationsLayerListenersAttached = false retryCount = 0 - } else if (wasFound && isFound) { - // Layer already existed, don't reset - might be a duplicate trigger - if (locationsLayerListenersAttached) { - return - } + } else if (wasFound && isFound && locationsLayerListenersAttached) { + return } if (locationsLayer && mapInstance.value && !setupInProgress) { - setupInProgress = true // Prevent concurrent setups + setupInProgress = true const checkAndSetup = () => { const mapObj = mapInstance.value const layerExists = mapObj?.getLayer('locations-layer') const styleLoaded = mapObj?.isStyleLoaded() if (layerExists && styleLoaded) { - retryCount = 0 // Reset on success + retryCount = 0 setupLocationsLayerListeners() updateLocationsSourceData() - - if (mapObj.getLayer('active-location-layer') && mapObj.getLayer('locations-layer')) { - try { - mapObj.moveLayer('active-location-layer') - } catch (e) { - console.log('Layer movement failed', e) - // Layer movement failed, but not critical - } - } - setupInProgress = false // Clear flag on success + moveSelectionLayersToTop() + setupInProgress = false } else { retryCount++ if (retryCount >= MAX_RETRIES) { @@ -398,36 +477,34 @@ } ) - // Watch for active location changes (handles active-location layer) + watch( + () => bomenLocationsStore.activeTree, + () => { + updateActiveTreeSourceData() + moveSelectionLayersToTop() + }, + { immediate: true } + ) + watch( () => locationsStore.activeLocation, (activeLocation) => { const mapObj = mapInstance.value - if (!mapObj || !mapObj.getSource('active-location')) return - - if (activeLocation) { - const plainFeature = JSON.parse(JSON.stringify(activeLocation)) - mapObj.getSource('active-location').setData({ - type: 'FeatureCollection', - features: [plainFeature], - }) - } else { - mapObj.getSource('active-location').setData({ - type: 'FeatureCollection', - features: [], - }) - } + if (!mapObj?.getSource('active-location')) return + + mapObj.getSource('active-location').setData({ + type: 'FeatureCollection', + features: activeLocation ? [cloneFeature(activeLocation)] : [], + }) }, - { immediate: true } + { immediate: true }, ) - // Cleanup onBeforeUnmount(() => { if (hoverPopup.value) { hoverPopup.value.remove() } - // Remove direct map listeners const mapObj = mapInstance.value if (mapObj && locationsLayerListenersAttached && mapObj.getLayer('locations-layer')) { if (locationsLayerHandlers.click) { diff --git a/src/components/MapLayer.vue b/src/components/MapLayer.vue index f64ddc5..82586ff 100644 --- a/src/components/MapLayer.vue +++ b/src/components/MapLayer.vue @@ -9,8 +9,7 @@ /> diff --git a/src/lib/constants.js b/src/lib/constants.js index 9c35b6f..0ccd187 100644 --- a/src/lib/constants.js +++ b/src/lib/constants.js @@ -9,3 +9,20 @@ export const MAP_BASELAYERS = [ ] export const MAP_BASELAYER_DEFAULT = MAP_BASELAYERS[0] + +export const TREE_COLOR = '#00a651' +export const TREE_DISABLED_COLOR = '#9e9e9e' +export const TREE_GROUP_AVG_COLOR = '#008fc5' +export const TREE_SELECTION_COLOR = '#ff0000' + +export const DEFAULT_TREE_HEALTH_LABELS = [ + 'Dood', + 'Bijna dood', + 'Slecht', + 'Matig-slecht', + 'Matig', + 'Voldoende-matig', + 'Voldoende', + 'Voldoende-goed', + 'Goed', +] diff --git a/src/lib/get-bomen-locations-data.js b/src/lib/get-bomen-locations-data.js new file mode 100644 index 0000000..bba3c6e --- /dev/null +++ b/src/lib/get-bomen-locations-data.js @@ -0,0 +1,16 @@ +import sendWpsRequest from '@/lib/wps' + +export default async function getBomenLocationsData () { + const response = await sendWpsRequest({ + identifier: 'wps_get_bomen_locations', + inputs: [], + outputIdentifier: 'bomen_locations', + mimeType: 'application/json', + }) + + if (response?.errMsg) { + throw new Error(response.errMsg) + } + + return response +} diff --git a/src/lib/get-boom-data.js b/src/lib/get-boom-data.js new file mode 100644 index 0000000..0867e39 --- /dev/null +++ b/src/lib/get-boom-data.js @@ -0,0 +1,30 @@ +import sendWpsRequest from '@/lib/wps' + +export default async function getBoomData (boomcode) { + if (boomcode == null || boomcode === '') { + throw new Error('boomcode is required for getBoomData') + } + + const response = await sendWpsRequest({ + identifier: 'wps_get_boom_data', + inputs: [ + { + id: 'boominfo', + title: 'Boominfo as boomnaams array', + type: 'ComplexData', + mimeType: 'application/json', + value: { + boomnaams: [ String(boomcode) ], + }, + }, + ], + outputIdentifier: 'boom_data', + mimeType: 'application/json', + }) + + if (response?.errMsg) { + throw new Error(response.errMsg) + } + + return response +} diff --git a/src/lib/normalize-feature-collection.js b/src/lib/normalize-feature-collection.js new file mode 100644 index 0000000..4aa3f59 --- /dev/null +++ b/src/lib/normalize-feature-collection.js @@ -0,0 +1,16 @@ +/** + * Extracts a features array from WPS GeoJSON responses with varying shapes. + * @returns {Array|null} features array, or null if format is unrecognized + */ +export function normalizeFeatureCollection (data) { + if (data?.type === 'FeatureCollection' && Array.isArray(data.features)) { + return data.features + } + if (Array.isArray(data)) { + return data + } + if (Array.isArray(data?.features)) { + return data.features + } + return null +} diff --git a/src/lib/tree-sdf-icon.js b/src/lib/tree-sdf-icon.js new file mode 100644 index 0000000..fc46172 --- /dev/null +++ b/src/lib/tree-sdf-icon.js @@ -0,0 +1,38 @@ +export const TREE_ICON_ID = 'tree-sdf-icon' + +export function createTreeSdfImageData (size = 32) { + const canvas = document.createElement('canvas') + canvas.width = size + canvas.height = size + const ctx = canvas.getContext('2d') + if (!ctx) return null + + ctx.clearRect(0, 0, size, size) + ctx.fillStyle = '#000' + + ctx.beginPath() + ctx.arc(size * 0.38, size * 0.38, size * 0.16, 0, Math.PI * 2) + ctx.arc(size * 0.62, size * 0.38, size * 0.16, 0, Math.PI * 2) + ctx.arc(size * 0.5, size * 0.28, size * 0.18, 0, Math.PI * 2) + ctx.fill() + + ctx.beginPath() + ctx.moveTo(size * 0.28, size * 0.5) + ctx.lineTo(size * 0.72, size * 0.5) + ctx.lineTo(size * 0.5, size * 0.7) + ctx.closePath() + ctx.fill() + + ctx.fillRect(size * 0.45, size * 0.66, size * 0.1, size * 0.22) + ctx.fillRect(size * 0.4, size * 0.86, size * 0.2, size * 0.06) + + const { data } = ctx.getImageData(0, 0, size, size) + return { width: size, height: size, data } +} + +export function registerTreeSdfIcon (map) { + if (!map || map.hasImage(TREE_ICON_ID)) return + const imageData = createTreeSdfImageData() + if (!imageData) return + map.addImage(TREE_ICON_ID, imageData, { sdf: true }) +} diff --git a/src/stores/app.js b/src/stores/app.js index ce0b8ae..57ffba9 100644 --- a/src/stores/app.js +++ b/src/stores/app.js @@ -5,6 +5,7 @@ export const useAppStore = defineStore('app', { state: () => ({ panelIsCollapsed: true, disabledCategories: new Set(), + disabledTrees: false, viewMode: 'focus', // 'all' or 'focus' }), actions: { @@ -27,6 +28,9 @@ export const useAppStore = defineStore('app', { } this.disabledCategories = newSet }, + toggleTrees () { + this.disabledTrees = !this.disabledTrees + }, setViewMode (mode) { this.viewMode = mode }, diff --git a/src/stores/bomenLocations.js b/src/stores/bomenLocations.js new file mode 100644 index 0000000..06e4998 --- /dev/null +++ b/src/stores/bomenLocations.js @@ -0,0 +1,113 @@ +import { defineStore } from 'pinia' +import getBomenLocationsData from '@/lib/get-bomen-locations-data' +import { normalizeFeatureCollection } from '@/lib/normalize-feature-collection' +import { TREE_COLOR, TREE_DISABLED_COLOR } from '@/lib/constants' +import { TREE_ICON_ID } from '@/lib/tree-sdf-icon' +import { useAppStore } from '@/stores/app' +import { useBoomDataStore } from '@/stores/boomData' + +function buildTreeIconPaint (activeTreeId, disabledTrees) { + return { + 'icon-color': disabledTrees ? TREE_DISABLED_COLOR : TREE_COLOR, + 'icon-halo-color': '#ffffff', + 'icon-halo-width': 0.8, + 'icon-opacity': [ + 'case', + [ '==', [ 'get', 'boom_id' ], activeTreeId ], + 0, + disabledTrees, + 0.5, + 1, + ], + } +} + +export const useBomenLocationsStore = defineStore('bomenLocations', { + state: () => ({ + bomenLocations: [], + activeTree: null, + }), + + getters: { + activeTreeId () { + return this.activeTree?.properties?.boom_id ?? null + }, + + bomenLocationsFeatureCollection () { + if (!this.bomenLocations?.length) { + return { type: 'FeatureCollection', features: [] } + } + + return { + type: 'FeatureCollection', + features: this.bomenLocations, + } + }, + + bomenLocationsLayerConfig () { + const appStore = useAppStore() + const featureCollection = this.bomenLocationsFeatureCollection + const activeTreeId = this.activeTreeId ?? -1 + if (!featureCollection.features?.length) return null + + return { + id: 'bomen-locations-layer', + type: 'symbol', + source: { + type: 'geojson', + data: featureCollection, + }, + layout: { + 'icon-image': TREE_ICON_ID, + 'icon-size': 1.15, + 'icon-allow-overlap': true, + 'icon-padding': 0, + }, + paint: buildTreeIconPaint(activeTreeId, appStore.disabledTrees), + } + }, + + computedPaint () { + const appStore = useAppStore() + const activeTreeId = this.activeTreeId ?? -1 + + return { + ...buildTreeIconPaint(activeTreeId, appStore.disabledTrees), + 'icon-color-transition': { duration: 0, delay: 0 }, + 'icon-opacity-transition': { duration: 0, delay: 0 }, + } + }, + }, + + actions: { + async fetchBomenLocations () { + try { + const data = await getBomenLocationsData() + const features = normalizeFeatureCollection(data) + + if (features) { + this.bomenLocations = features + } else { + console.warn('Unexpected tree locations data format:', data) + this.bomenLocations = [] + } + } catch (error) { + console.error('Failed to fetch tree locations:', error) + this.bomenLocations = [] + } + }, + + setActiveTree (feature) { + this.activeTree = feature ?? null + + const boomDataStore = useBoomDataStore() + const boomcode = this.activeTree?.properties?.boomcode + + if (boomcode) { + boomDataStore.fetchBoomData(boomcode) + } else { + boomDataStore.clearData() + } + }, + }, +}) diff --git a/src/stores/boomData.js b/src/stores/boomData.js new file mode 100644 index 0000000..16f73e9 --- /dev/null +++ b/src/stores/boomData.js @@ -0,0 +1,99 @@ +import { defineStore } from 'pinia' +import getBoomData from '@/lib/get-boom-data' + +export const useBoomDataStore = defineStore('boomData', { + state: () => ({ + data: null, + activeBoomcode: null, + loading: false, + error: null, + }), + + getters: { + selectedTreeEntry (state) { + const trees = state.data?.trees + if (!Array.isArray(trees) || trees.length === 0) { + return null + } + + const code = state.activeBoomcode + if (code == null || code === '') { + return trees[0] + } + + return ( + trees.find( + (entry) => entry.tree === code || entry.tree_name === code, + ) ?? trees[0] + ) + }, + + treeName () { + return this.selectedTreeEntry?.tree_name ?? null + }, + + groupThatBelongs () { + return this.selectedTreeEntry?.group_that_belongs ?? null + }, + + yAxisLabels (state) { + return state.data?.y_axis?.labels ?? [] + }, + + yAxisMin (state) { + return state.data?.y_axis?.min ?? 0 + }, + + yAxisMax (state) { + return state.data?.y_axis?.max ?? 8 + }, + + treeTimeseries () { + return this.selectedTreeEntry?.timeseries ?? [] + }, + + groupAverageEntry (state) { + const group = this.groupThatBelongs + const averages = state.data?.group_averages + if (!group || !Array.isArray(averages)) { + return null + } + + return averages.find((entry) => entry.group === group) ?? null + }, + + groupAverageTimeseries () { + return this.groupAverageEntry?.timeseries ?? [] + }, + }, + + actions: { + async fetchBoomData (boomcode) { + if (boomcode == null || boomcode === '') { + this.clearData() + return + } + + this.activeBoomcode = String(boomcode) + this.loading = true + this.error = null + + try { + this.data = await getBoomData(this.activeBoomcode) + } catch (error) { + console.error('Failed to fetch boom data:', error) + this.error = error + this.data = null + } finally { + this.loading = false + } + }, + + clearData () { + this.data = null + this.activeBoomcode = null + this.error = null + this.loading = false + }, + }, +}) diff --git a/src/stores/locations.js b/src/stores/locations.js index af01776..8255056 100644 --- a/src/stores/locations.js +++ b/src/stores/locations.js @@ -1,6 +1,7 @@ // stores/locations.js import { defineStore } from 'pinia' import getLocationsData from '@/lib/get-locations-data' +import { normalizeFeatureCollection } from '@/lib/normalize-feature-collection' import { useAppStore } from '@/stores/app' export const useLocationsStore = defineStore('locations', { @@ -139,17 +140,10 @@ export const useLocationsStore = defineStore('locations', { async fetchLocations () { try { const data = await getLocationsData() - - // Handle both FeatureCollection and array of features - if (data.type === 'FeatureCollection' && data.features) { - // If FeatureCollection, extract features array for easier access - this.locations = data.features - } else if (Array.isArray(data)) { - // If already an array - this.locations = data - } else if (data.features && Array.isArray(data.features)) { - // If object with features property - this.locations = data.features + const features = normalizeFeatureCollection(data) + + if (features) { + this.locations = features } else { console.warn('Unexpected locations data format:', data) this.locations = [] diff --git a/src/stores/map.js b/src/stores/map.js index 3f6e356..6c16292 100644 --- a/src/stores/map.js +++ b/src/stores/map.js @@ -2,6 +2,7 @@ import { defineStore } from 'pinia' import buildMapboxLayer from '@/lib/build-mapbox-layer' import { useLocationsStore } from '@/stores/locations' +import { useBomenLocationsStore } from '@/stores/bomenLocations' export const useMapStore = defineStore('map', { state: () => ({ @@ -12,6 +13,7 @@ export const useMapStore = defineStore('map', { actions: { initializeMapboxLayers () { const locationsStore = useLocationsStore() + const bomenLocationsStore = useBomenLocationsStore() // Collect all layer configs const allLayerConfigs = [] @@ -21,6 +23,11 @@ export const useMapStore = defineStore('map', { if (locationsLayerConfig) { allLayerConfigs.push(locationsLayerConfig) } + + const bomenLocationsLayerConfig = bomenLocationsStore.bomenLocationsLayerConfig + if (bomenLocationsLayerConfig) { + allLayerConfigs.push(bomenLocationsLayerConfig) + } // 2. Add static layers (WMS/WMTS from config files if needed in future) allLayerConfigs.push(...this.staticLayerConfigs)