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)