From c1b5bd98b8caf8132d67e351c29a3adf9fa077c6 Mon Sep 17 00:00:00 2001 From: David Glogaza Date: Fri, 12 Dec 2025 14:58:56 +0100 Subject: [PATCH 01/18] move filter button factory to lib --- apps/topicmaps/generic/src/app/Map.jsx | 2 +- libraries/mapping/components/src/index.ts | 6 + .../GenericFilterButtonsFactory.tsx | 368 ++++++++++++++++++ 3 files changed, 375 insertions(+), 1 deletion(-) create mode 100644 libraries/mapping/components/src/lib/components/GenericFilterButtonsFactory.tsx diff --git a/apps/topicmaps/generic/src/app/Map.jsx b/apps/topicmaps/generic/src/app/Map.jsx index b6c9bf6e6..e69099007 100644 --- a/apps/topicmaps/generic/src/app/Map.jsx +++ b/apps/topicmaps/generic/src/app/Map.jsx @@ -27,9 +27,9 @@ import { } from "@carma-appframeworks/portals"; import { EmptySearchComponent } from "@carma-mapping/fuzzy-search"; import FuzzySearchWrapper from "./components/FuzzySearchWrapper"; -import { createFilterButtons } from "./components/GenericFilterButtonsFactory"; import { Control, ControlLayout } from "@carma-mapping/map-controls-layout"; import { + createFilterButtons, FullscreenControl, RoutedMapLocateControl, ZoomControl, diff --git a/libraries/mapping/components/src/index.ts b/libraries/mapping/components/src/index.ts index f95b84894..dfc1b8c86 100644 --- a/libraries/mapping/components/src/index.ts +++ b/libraries/mapping/components/src/index.ts @@ -1,4 +1,10 @@ export { FontAwesomeLikeIcon } from "./lib/components/FontAwesomeLikeIcon.tsx"; +export { + createFilterButtons, + type FilterConfig, + type FilterOption, + type GenericFilterButtonsProps, +} from "./lib/components/GenericFilterButtonsFactory.tsx"; export { FullscreenControl, diff --git a/libraries/mapping/components/src/lib/components/GenericFilterButtonsFactory.tsx b/libraries/mapping/components/src/lib/components/GenericFilterButtonsFactory.tsx new file mode 100644 index 000000000..3b71338a0 --- /dev/null +++ b/libraries/mapping/components/src/lib/components/GenericFilterButtonsFactory.tsx @@ -0,0 +1,368 @@ +import { useState, useEffect, ReactNode } from "react"; + +// Types for filter configuration +export interface FilterOption { + key: string; + label: string; + icon?: string; + /** Alternative icon to show when filter is inactive (e.g., red dot vs green dot) */ + inactiveIcon?: string; + /** Property name in the feature to check */ + propertyName: string; + /** Value that the property should have when filter is active */ + propertyValue: string; + /** Whether to show icon in grayscale when not selected (ignored if inactiveIcon is set) */ + grayscaleWhenInactive?: boolean; +} + +export interface FilterConfig { + /** The "show all" button label (not shown if filterMode is "or") */ + allLabel?: string; + /** Layer name pattern to match (case-insensitive includes) */ + layerPattern: string; + /** Filter mode: "and" (all conditions must match) or "or" (any condition matches). Default: "and" */ + filterMode?: "and" | "or"; + /** Available filter options */ + filters: FilterOption[]; + /** Style customizations */ + styles?: { + buttonBorderRadius?: string; + /** Border color when selected. Set to "none" to disable border entirely. */ + selectedBorderColor?: string; + iconSize?: string; + fontSize?: string; + gap?: string; + maxWidth?: string; + }; +} + +export interface GenericFilterButtonsProps { + maplibreMap: any; + selectedFeature: any; + setSelectedFeature: (feature: any) => void; + config: FilterConfig; +} + +type FilterState = Record; + +export const createFilterButtons = (config: FilterConfig) => { + const isOrMode = config.filterMode === "or"; + + const GenericFilterButtons = ({ + maplibreMap, + selectedFeature, + setSelectedFeature, + }: Omit) => { + // Initialize filter state + // In AND mode: "alle" starts as true, all filters false + // In OR mode: no "alle" button, all filters start as true (show all) + const initialState: FilterState = isOrMode + ? config.filters.reduce( + (acc, filter) => ({ ...acc, [filter.key]: true }), + {} + ) + : { + alle: true, + ...config.filters.reduce( + (acc, filter) => ({ ...acc, [filter.key]: false }), + {} + ), + }; + + const [selectedFilters, setSelectedFilters] = + useState(initialState); + + // Store original layer filters (captured once when map is ready) + const [originalFilters, setOriginalFilters] = useState< + Record + >({}); + + // Function to build filter expression from selected filters + const buildFilterExpression = (filters: FilterState): any[] | null => { + if (isOrMode) { + // OR mode: show features matching ANY of the selected filters + const conditions: any[] = []; + + config.filters.forEach((filterOption) => { + if (filters[filterOption.key]) { + conditions.push([ + "==", + ["get", filterOption.propertyName], + filterOption.propertyValue, + ]); + } + }); + + // If no filters selected, hide all features using a filter that can never match + if (conditions.length === 0) { + return [ + "==", + ["get", config.filters[0]?.propertyName || "wohnlage"], + "___HIDE_ALL___", + ]; + } + + // Always use explicit filter expression (don't optimize to null) + return ["any", ...conditions]; + } else { + // AND mode: show features matching ALL selected filters + if (filters.alle) { + return null; + } + + const conditions: any[] = []; + + config.filters.forEach((filterOption) => { + if (filters[filterOption.key]) { + conditions.push([ + "==", + ["get", filterOption.propertyName], + filterOption.propertyValue, + ]); + } + }); + + if (conditions.length > 0) { + return ["all", ...conditions]; + } + + return null; + } + }; + + // Function to check if a feature matches the current filter criteria + const checkFeatureMatchesFilter = ( + feature: any, + filters: FilterState + ): boolean => { + if (!feature?.properties) return false; + + const props = feature.properties; + + if (isOrMode) { + // OR mode: feature matches if it matches ANY selected filter + const selectedFilterOptions = config.filters.filter( + (f) => filters[f.key] + ); + + // If no filters selected, nothing matches + if (selectedFilterOptions.length === 0) return false; + + // If all filters selected, everything matches + if (selectedFilterOptions.length === config.filters.length) return true; + + // Check if feature matches any selected filter + return selectedFilterOptions.some( + (filterOption) => + props[filterOption.propertyName] === filterOption.propertyValue + ); + } else { + // AND mode: feature matches if it matches ALL selected filters + if (filters.alle) return true; + + for (const filterOption of config.filters) { + if ( + filters[filterOption.key] && + props[filterOption.propertyName] !== filterOption.propertyValue + ) { + return false; + } + } + + return true; + } + }; + + // Style function for filter buttons + const getFilterButtonStyle = (isSelected: boolean) => { + const borderColor = config.styles?.selectedBorderColor; + const showBorder = isSelected && borderColor !== "none"; + + return { + display: "flex", + alignItems: "center", + gap: "6px", + backgroundColor: "white", + padding: "6px 12px", + borderRadius: config.styles?.buttonBorderRadius || "10px", + boxShadow: "0 2px 4px rgba(0,0,0,0.2)", + cursor: "pointer", + height: "28px", + border: showBorder + ? `2px solid ${borderColor || "#4378ccCC"}` + : "2px solid transparent", + }; + }; + + // Capture original filters once when map becomes available + useEffect(() => { + if (!maplibreMap) return; + if (Object.keys(originalFilters).length > 0) return; // Already captured + + const layers = maplibreMap.getStyle()?.layers || []; + const targetLayers = layers.filter((layer: any) => + layer.id.toLowerCase().includes(config.layerPattern.toLowerCase()) + ); + + const captured: Record = {}; + targetLayers.forEach((layer: any) => { + captured[layer.id] = layer.filter || null; + }); + + setOriginalFilters(captured); + }, [maplibreMap]); + + // Apply filters to the map whenever selectedFilters or maplibreMap changes + useEffect(() => { + if (!maplibreMap) return; + if (Object.keys(originalFilters).length === 0) return; // Wait for original filters to be captured + + try { + const targetLayerIds = Object.keys(originalFilters); + + const filterExpression = buildFilterExpression(selectedFilters); + + targetLayerIds.forEach((layerId: string) => { + try { + // Get the ORIGINAL filter (not the current one which may have our filter applied) + const origFilter = originalFilters[layerId]; + + let combinedFilter = filterExpression; + + // If layer has an original filter, combine it with our filter using "all" + if (origFilter && filterExpression) { + combinedFilter = ["all", origFilter, filterExpression]; + } else if (origFilter && !filterExpression) { + combinedFilter = origFilter; + } + + maplibreMap.setFilter(layerId, combinedFilter); + } catch (error) { + console.error(`Error setting filter on layer ${layerId}:`, error); + } + }); + + // Check if selected feature still matches the new filter criteria + if (selectedFeature?.sourceFeature) { + const matchesFilter = checkFeatureMatchesFilter( + selectedFeature.sourceFeature, + selectedFilters + ); + + if (!matchesFilter) { + maplibreMap.setFeatureState( + { + source: selectedFeature.sourceFeature.source, + sourceLayer: selectedFeature.sourceFeature.sourceLayer, + id: selectedFeature.sourceFeature.id, + }, + { selected: false } + ); + + setSelectedFeature(undefined); + } + } + } catch (error) { + console.error("Error applying filters:", error); + } + }, [selectedFilters, maplibreMap, selectedFeature]); + + const handleFilterClick = (filterName: string) => { + if (isOrMode) { + // OR mode: simple toggle, no "alle" button + setSelectedFilters((prev) => ({ + ...prev, + [filterName]: !prev[filterName], + })); + } else { + // AND mode: original behavior with "alle" button + if (filterName === "alle") { + setSelectedFilters({ + alle: true, + ...config.filters.reduce( + (acc, filter) => ({ ...acc, [filter.key]: false }), + {} + ), + }); + } else { + setSelectedFilters((prev) => { + const newFilters = { + ...prev, + alle: false, + [filterName]: !prev[filterName], + }; + + const hasIconSelection = config.filters.some( + (f) => newFilters[f.key] + ); + if (!hasIconSelection) { + newFilters.alle = true; + } + + return newFilters; + }); + } + } + }; + + const iconSize = config.styles?.iconSize || "18px"; + + return ( +
+ {!isOrMode && ( +
handleFilterClick("alle")} + style={getFilterButtonStyle(selectedFilters.alle)} + > + {config.allLabel} +
+ )} + {config.filters.map((filterOption) => { + const isActive = selectedFilters[filterOption.key]; + const iconSrc = isActive + ? filterOption.icon + : filterOption.inactiveIcon || filterOption.icon; + const shouldGrayscale = + !isActive && + !filterOption.inactiveIcon && + filterOption.grayscaleWhenInactive !== false; + + return ( +
handleFilterClick(filterOption.key)} + style={getFilterButtonStyle(isActive)} + > + {iconSrc && ( + + )} + {filterOption.label} +
+ ); + })} +
+ ); + }; + + return GenericFilterButtons; +}; From 210b7cf27425844f6fc4a3a77fec8e7532541628 Mon Sep 17 00:00:00 2001 From: David Glogaza Date: Fri, 12 Dec 2025 15:05:13 +0100 Subject: [PATCH 02/18] add filter state --- apps/geoportal/src/app/store/slices/mapping.ts | 7 +++++++ libraries/appframeworks/portals/src/lib/types/index.d.ts | 1 + 2 files changed, 8 insertions(+) diff --git a/apps/geoportal/src/app/store/slices/mapping.ts b/apps/geoportal/src/app/store/slices/mapping.ts index 8d95a6dc2..3f4cc5fc2 100644 --- a/apps/geoportal/src/app/store/slices/mapping.ts +++ b/apps/geoportal/src/app/store/slices/mapping.ts @@ -17,6 +17,7 @@ const initialState: MappingState = { layers: [], savedLayerConfigs: [], selectedLayerIndex: SELECTED_LAYER_INDEX.NO_SELECTION, + activeFilterLayerIndex: null, paleOpacityValue: defaultOpacity, libreMapRef: null, layersIdle: false, @@ -227,6 +228,9 @@ const slice = createSlice({ } }, + setActiveFilterLayerIndex(state, action) { + state.activeFilterLayerIndex = action.payload; + }, setSelectedMapLayer(state, action: PayloadAction) { state.selectedMapLayer = action.payload; }, @@ -298,6 +302,7 @@ export const { setSelectedLayerIndexNoSelection, setNextSelectedLayerIndex, setPreviousSelectedLayerIndex, + setActiveFilterLayerIndex, setSelectedMapLayer, setBackgroundLayer, setSelectedLuftbildLayer, @@ -356,6 +361,8 @@ export const getShowMeasurementButton = (state: RootState) => state.mapping.showMeasurementButton; export const getShowRightScrollButton = (state: RootState) => state.mapping.showRightScrollButton; +export const getActiveFilterLayerIndex = (state: RootState) => + state.mapping.activeFilterLayerIndex; export const getStartDrawing = (state: RootState) => state.mapping.startDrawing; export const getLibreMapRef = (state: RootState) => state.mapping.libreMapRef; export const getConfigSelection = (state: RootState) => diff --git a/libraries/appframeworks/portals/src/lib/types/index.d.ts b/libraries/appframeworks/portals/src/lib/types/index.d.ts index 982c7067c..2d63b65ea 100644 --- a/libraries/appframeworks/portals/src/lib/types/index.d.ts +++ b/libraries/appframeworks/portals/src/lib/types/index.d.ts @@ -117,6 +117,7 @@ export interface MappingState extends LayerState { paleOpacityValue: number; showLeftScrollButton: boolean; showRightScrollButton: boolean; + activeFilterLayerIndex: number | null; showFullscreenButton: boolean; showLocatorButton: boolean; showMeasurementButton: boolean; From 0c899194787363311222bdbc3d1884ac20145f87 Mon Sep 17 00:00:00 2001 From: David Glogaza Date: Fri, 12 Dec 2025 15:05:46 +0100 Subject: [PATCH 03/18] add possible filter config to vector layers --- libraries/appframeworks/portals/src/lib/utils/utils.ts | 5 +++++ libraries/types/src/lib/carma-layers.d.ts | 1 + 2 files changed, 6 insertions(+) diff --git a/libraries/appframeworks/portals/src/lib/utils/utils.ts b/libraries/appframeworks/portals/src/lib/utils/utils.ts index 7ddb0b2cf..f99aacdc9 100644 --- a/libraries/appframeworks/portals/src/lib/utils/utils.ts +++ b/libraries/appframeworks/portals/src/lib/utils/utils.ts @@ -120,6 +120,7 @@ export const parseToMapLayer = async ( vectorStyle = layer.vectorStyle; } let metaData = {}; + let filterConfig = null; if (vectorStyle) { zoom = await fetch(vectorStyle) .then((response) => { @@ -133,6 +134,9 @@ export const parseToMapLayer = async ( if (result.metadata && result.metadata.carmaConf.layerInfo) { metaData = result.metadata.carmaConf.layerInfo; } + if (result.metadata && result.metadata.carmaConf.filterConfig) { + filterConfig = result.metadata.carmaConf.filterConfig; + } return parsedZoom; }); } @@ -168,6 +172,7 @@ export const parseToMapLayer = async ( capabilitiesUrl: capabilitiesUrl, ...metaData, }, + filterConfig, }; } else { switch (layer.layerType) { diff --git a/libraries/types/src/lib/carma-layers.d.ts b/libraries/types/src/lib/carma-layers.d.ts index c4d5a8fa9..e723ce70d 100644 --- a/libraries/types/src/lib/carma-layers.d.ts +++ b/libraries/types/src/lib/carma-layers.d.ts @@ -50,6 +50,7 @@ export type Layer = { conf?: CarmaConfig; icon?: string; other?: OtherLayerProps; + filterConfig?: any; } & ( | { layerType: "wmts" | "wmts-nt"; From fc04d63ce7697cc8bcdf61247835a0200f820450 Mon Sep 17 00:00:00 2001 From: David Glogaza Date: Fri, 12 Dec 2025 15:07:00 +0100 Subject: [PATCH 04/18] add option to open filter buttons --- .../layers/GeoportalLayerButton.tsx | 27 ++++++++++++++++++- .../app/components/layers/LayerWrapper.tsx | 20 +++++++++++++- 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/apps/geoportal/src/app/components/layers/GeoportalLayerButton.tsx b/apps/geoportal/src/app/components/layers/GeoportalLayerButton.tsx index 3c06d270e..781d3054a 100644 --- a/apps/geoportal/src/app/components/layers/GeoportalLayerButton.tsx +++ b/apps/geoportal/src/app/components/layers/GeoportalLayerButton.tsx @@ -7,7 +7,12 @@ import { useDispatch, useSelector } from "react-redux"; import { useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; -import { faEye, faEyeSlash, faX } from "@fortawesome/free-solid-svg-icons"; +import { + faEye, + faEyeSlash, + faFilter, + faX, +} from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import L from "leaflet"; @@ -22,11 +27,13 @@ import { getClickFromInfoView, getLayers, getSelectedLayerIndex, + getActiveFilterLayerIndex, getShowLeftScrollButton, removeLayer, setClickFromInfoView, setSelectedLayerIndex, setSelectedLayerIndexNoSelection, + setActiveFilterLayerIndex, setShowLeftScrollButton, setShowRightScrollButton, toggleUseInFeatureInfo, @@ -84,6 +91,7 @@ const GeoportalLayerButton = ({ const showLayerHideButtons = useSelector(getUIShowLayerHideButtons); const showLeftScrollButton = useSelector(getShowLeftScrollButton); const clickFromInfoView = useSelector(getClickFromInfoView); + const activeFilterLayerIndex = useSelector(getActiveFilterLayerIndex); const mode = useSelector(getUIMode); const showSettings = index === selectedLayerIndex; const layers = useSelector(getLayers); @@ -202,6 +210,23 @@ const GeoportalLayerButton = ({ {!background && ( <> {title} + {layer.filterConfig && ( + + )} + )} diff --git a/apps/geoportal/src/app/components/layers/LayerWrapper.tsx b/apps/geoportal/src/app/components/layers/LayerWrapper.tsx index 5d5240d85..9e35423e2 100644 --- a/apps/geoportal/src/app/components/layers/LayerWrapper.tsx +++ b/apps/geoportal/src/app/components/layers/LayerWrapper.tsx @@ -1,7 +1,7 @@ /* eslint-disable jsx-a11y/no-static-element-interactions */ /* eslint-disable jsx-a11y/click-events-have-key-events */ -import { useContext, useEffect, useMemo } from "react"; +import { useContext, useEffect, useMemo, useState } from "react"; import { useDispatch, useSelector } from "react-redux"; import { @@ -27,7 +27,10 @@ import { useWindowSize } from "@uidotdev/usehooks"; import { TopicMapContext } from "react-cismap/contexts/TopicMapContextProvider"; import { cn } from "@carma-commons/utils"; -import { createFilterButtons } from "@carma-mapping/components"; +import { + createFilterButtons, + type FilterInfo, +} from "@carma-mapping/components"; import { AppDispatch } from "../../store"; import { @@ -59,6 +62,10 @@ const LayerWrapper = () => { const { routedMapRef } = useContext(TopicMapContext); const size = useWindowSize(); + const [layerFilterInfo, setLayerFilterInfo] = useState< + Record + >({}); + const layers = useSelector(getLayers); const backgroundLayer = useSelector(getBackgroundLayer); const maplibreMaps = useSelector(getMaplibreMaps); @@ -214,6 +221,7 @@ const LayerWrapper = () => { : undefined } layer={layer} + filterInfo={layerFilterInfo[layer.id]} /> ))} @@ -244,6 +252,12 @@ const LayerWrapper = () => { setSelectedFeature={(feature) => dispatch(setSelectedFeatureAction(feature)) } + onFilterChange={(info: FilterInfo) => { + setLayerFilterInfo((prev) => ({ + ...prev, + [filterEntry.id]: info, + })); + }} /> ); diff --git a/libraries/mapping/components/src/index.ts b/libraries/mapping/components/src/index.ts index dfc1b8c86..cb1556946 100644 --- a/libraries/mapping/components/src/index.ts +++ b/libraries/mapping/components/src/index.ts @@ -2,6 +2,7 @@ export { FontAwesomeLikeIcon } from "./lib/components/FontAwesomeLikeIcon.tsx"; export { createFilterButtons, type FilterConfig, + type FilterInfo, type FilterOption, type GenericFilterButtonsProps, } from "./lib/components/GenericFilterButtonsFactory.tsx"; diff --git a/libraries/mapping/components/src/lib/components/GenericFilterButtonsFactory.tsx b/libraries/mapping/components/src/lib/components/GenericFilterButtonsFactory.tsx index 3b71338a0..c1fb0450e 100644 --- a/libraries/mapping/components/src/lib/components/GenericFilterButtonsFactory.tsx +++ b/libraries/mapping/components/src/lib/components/GenericFilterButtonsFactory.tsx @@ -36,11 +36,18 @@ export interface FilterConfig { }; } +export interface FilterInfo { + activeCount: number; + totalCount: number; + isShowingAll: boolean; +} + export interface GenericFilterButtonsProps { maplibreMap: any; selectedFeature: any; setSelectedFeature: (feature: any) => void; config: FilterConfig; + onFilterChange?: (filterInfo: FilterInfo) => void; } type FilterState = Record; @@ -52,6 +59,7 @@ export const createFilterButtons = (config: FilterConfig) => { maplibreMap, selectedFeature, setSelectedFeature, + onFilterChange, }: Omit) => { // Initialize filter state // In AND mode: "alle" starts as true, all filters false @@ -267,6 +275,30 @@ export const createFilterButtons = (config: FilterConfig) => { } }, [selectedFilters, maplibreMap, selectedFeature]); + const computeFilterInfo = (filters: FilterState): FilterInfo => { + if (isOrMode) { + const activeCount = config.filters.filter((f) => filters[f.key]).length; + return { + activeCount: config.filters.length - activeCount, // In OR mode, count deselected as "filtered out" + totalCount: config.filters.length, + isShowingAll: activeCount === config.filters.length, + }; + } else { + const activeCount = config.filters.filter((f) => filters[f.key]).length; + return { + activeCount, + totalCount: config.filters.length, + isShowingAll: filters.alle === true, + }; + } + }; + + useEffect(() => { + if (onFilterChange) { + onFilterChange(computeFilterInfo(selectedFilters)); + } + }, [selectedFilters]); + const handleFilterClick = (filterName: string) => { if (isOrMode) { // OR mode: simple toggle, no "alle" button From c7d9724dc28b340fc78e7d128b0a6d5b8057840d Mon Sep 17 00:00:00 2001 From: David Glogaza Date: Fri, 19 Dec 2025 11:37:26 +0100 Subject: [PATCH 13/18] fix feature info indicator not showing --- .../src/app/components/layers/LayerWrapper.tsx | 10 +++++----- .../src/app/components/layers/SecondaryView.tsx | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/geoportal/src/app/components/layers/LayerWrapper.tsx b/apps/geoportal/src/app/components/layers/LayerWrapper.tsx index 9e35423e2..1c2b0e8f6 100644 --- a/apps/geoportal/src/app/components/layers/LayerWrapper.tsx +++ b/apps/geoportal/src/app/components/layers/LayerWrapper.tsx @@ -199,7 +199,7 @@ const LayerWrapper = () => { {size.width > 640 && (
{
- dispatch(setSelectedFeatureAction(feature)) - } + setSelectedFeature={(feature) => { + dispatch(setSelectedFeatureAction(feature)); + }} onFilterChange={(info: FilterInfo) => { setLayerFilterInfo((prev) => ({ ...prev, diff --git a/apps/geoportal/src/app/components/layers/SecondaryView.tsx b/apps/geoportal/src/app/components/layers/SecondaryView.tsx index f8f378841..84307851f 100644 --- a/apps/geoportal/src/app/components/layers/SecondaryView.tsx +++ b/apps/geoportal/src/app/components/layers/SecondaryView.tsx @@ -178,7 +178,7 @@ const SecondaryView = forwardRef(({}, ref) => { onClick={() => { dispatch(setSelectedLayerIndexNoSelection()); }} - className="pt-4 w-full" + className="pt-3 w-full" >
Date: Fri, 19 Dec 2025 11:37:42 +0100 Subject: [PATCH 14/18] fix selected feature not resetting after filter changes --- apps/geoportal/src/app/components/GeoportalMap/topicmap.utils.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/geoportal/src/app/components/GeoportalMap/topicmap.utils.ts b/apps/geoportal/src/app/components/GeoportalMap/topicmap.utils.ts index 8371bde88..e1951d087 100644 --- a/apps/geoportal/src/app/components/GeoportalMap/topicmap.utils.ts +++ b/apps/geoportal/src/app/components/GeoportalMap/topicmap.utils.ts @@ -456,6 +456,7 @@ const createVectorFeature = async ( geometry: selectedVectorFeature.geometry, id: layer.id, vectorId: selectedVectorFeature.id, + sourceFeature: selectedVectorFeature, showMarker: selectedVectorFeature.geometry.type === "Polygon" || selectedVectorFeature.geometry.type === "MultiPolygon", From 9688f42cb9f727fca6495ac020c271606bc06ea1 Mon Sep 17 00:00:00 2001 From: David Glogaza Date: Fri, 19 Dec 2025 18:29:59 +0100 Subject: [PATCH 15/18] simulate click after filter changes --- .../src/app/components/GeoportalMap/GeoportalMap.tsx | 12 +++++++++--- .../src/app/components/layers/LayerWrapper.tsx | 5 +++++ apps/geoportal/src/app/store/slices/ui.ts | 8 ++++++++ .../lib/components/GenericFilterButtonsFactory.tsx | 4 +++- 4 files changed, 25 insertions(+), 4 deletions(-) diff --git a/apps/geoportal/src/app/components/GeoportalMap/GeoportalMap.tsx b/apps/geoportal/src/app/components/GeoportalMap/GeoportalMap.tsx index 4d85be9d1..4ee32f7ac 100644 --- a/apps/geoportal/src/app/components/GeoportalMap/GeoportalMap.tsx +++ b/apps/geoportal/src/app/components/GeoportalMap/GeoportalMap.tsx @@ -102,7 +102,11 @@ import { setLayersIdle, setMaplibreMaps, } from "../../store/slices/mapping.ts"; -import { getUIMode, UIMode } from "../../store/slices/ui.ts"; +import { + getUIMode, + UIMode, + getTriggerFeatureInfoUpdate, +} from "../../store/slices/ui.ts"; import LoginForm from "../LoginForm.tsx"; import { useModelSelectionDispatcher } from "../../hooks/useModelSelectionDispatcher.ts"; @@ -216,6 +220,7 @@ export const GeoportalMap = ({ height, width, allow3d }: MapProps) => { const [shouldUpdateFeatureInfo, setShouldUpdateFeatureInfo] = useState(false); const layersIdle = useSelector(getLayersIdle); + const triggerFeatureInfoUpdate = useSelector(getTriggerFeatureInfoUpdate); const version = getApplicationVersion(versionData); @@ -529,10 +534,11 @@ export const GeoportalMap = ({ height, width, allow3d }: MapProps) => { ); useEffect(() => { - if (shouldUpdateFeatureInfo) updateFeatureInfoLeaflet(); + if (shouldUpdateFeatureInfo || triggerFeatureInfoUpdate > 0) + updateFeatureInfoLeaflet(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [shouldUpdateFeatureInfo]); + }, [shouldUpdateFeatureInfo, triggerFeatureInfoUpdate]); const topicMapLocationChangedHandler = useCallback( (p: { lat: number; lng: number; zoom: number }) => { diff --git a/apps/geoportal/src/app/components/layers/LayerWrapper.tsx b/apps/geoportal/src/app/components/layers/LayerWrapper.tsx index 1c2b0e8f6..81c5717f1 100644 --- a/apps/geoportal/src/app/components/layers/LayerWrapper.tsx +++ b/apps/geoportal/src/app/components/layers/LayerWrapper.tsx @@ -49,9 +49,12 @@ import { getMaplibreMaps, } from "../../store/slices/mapping"; import { + getFeatures, getSelectedFeature, + getSecondaryInfoBoxElements, setSelectedFeature as setSelectedFeatureAction, } from "../../store/slices/features"; +import { triggerFeatureInfoUpdateAction } from "../../store/slices/ui"; import GeoportalLayerButton from "./GeoportalLayerButton"; import SecondaryView from "./SecondaryView"; @@ -249,6 +252,7 @@ const LayerWrapper = () => { { dispatch(setSelectedFeatureAction(feature)); }} @@ -257,6 +261,7 @@ const LayerWrapper = () => { ...prev, [filterEntry.id]: info, })); + dispatch(triggerFeatureInfoUpdateAction()); }} />
diff --git a/apps/geoportal/src/app/store/slices/ui.ts b/apps/geoportal/src/app/store/slices/ui.ts index 7f68deb9b..3240fe47d 100644 --- a/apps/geoportal/src/app/store/slices/ui.ts +++ b/apps/geoportal/src/app/store/slices/ui.ts @@ -23,6 +23,7 @@ export interface UIState { showResourceModal: boolean; zenMode: boolean; showLoginModal: boolean; + triggerFeatureInfoUpdate: number; } const initialState: UIState = { @@ -37,6 +38,7 @@ const initialState: UIState = { showResourceModal: false, zenMode: false, showLoginModal: false, + triggerFeatureInfoUpdate: 0, }; const slice = createSlice({ @@ -85,6 +87,9 @@ const slice = createSlice({ setShowLoginModal(state, action: PayloadAction) { state.showLoginModal = action.payload; }, + triggerFeatureInfoUpdateAction(state) { + state.triggerFeatureInfoUpdate = state.triggerFeatureInfoUpdate + 1; + }, }, }); @@ -101,6 +106,7 @@ export const { setShowResourceModal, setZenMode, setShowLoginModal, + triggerFeatureInfoUpdateAction, } = slice.actions; export const getUIMode = (state: RootState) => state.ui.mode; @@ -118,5 +124,7 @@ export const getUIShowResourceModal = (state: RootState) => state.ui.showResourceModal; export const getZenMode = (state: RootState) => state.ui.zenMode; export const getShowLoginModal = (state: RootState) => state.ui.showLoginModal; +export const getTriggerFeatureInfoUpdate = (state: RootState) => + state.ui.triggerFeatureInfoUpdate; export default slice.reducer; diff --git a/libraries/mapping/components/src/lib/components/GenericFilterButtonsFactory.tsx b/libraries/mapping/components/src/lib/components/GenericFilterButtonsFactory.tsx index c1fb0450e..a46cc21a1 100644 --- a/libraries/mapping/components/src/lib/components/GenericFilterButtonsFactory.tsx +++ b/libraries/mapping/components/src/lib/components/GenericFilterButtonsFactory.tsx @@ -48,6 +48,7 @@ export interface GenericFilterButtonsProps { setSelectedFeature: (feature: any) => void; config: FilterConfig; onFilterChange?: (filterInfo: FilterInfo) => void; + skipFeatureMatchCheck?: boolean; } type FilterState = Record; @@ -60,6 +61,7 @@ export const createFilterButtons = (config: FilterConfig) => { selectedFeature, setSelectedFeature, onFilterChange, + skipFeatureMatchCheck = false, }: Omit) => { // Initialize filter state // In AND mode: "alle" starts as true, all filters false @@ -251,7 +253,7 @@ export const createFilterButtons = (config: FilterConfig) => { }); // Check if selected feature still matches the new filter criteria - if (selectedFeature?.sourceFeature) { + if (!skipFeatureMatchCheck && selectedFeature?.sourceFeature) { const matchesFilter = checkFeatureMatchesFilter( selectedFeature.sourceFeature, selectedFilters From 7e2d25b7227075092b907a6d04421ae8e8d6302a Mon Sep 17 00:00:00 2001 From: David Glogaza Date: Fri, 19 Dec 2025 18:45:16 +0100 Subject: [PATCH 16/18] only skip feature match checking in feature info mode --- .../src/app/components/layers/LayerWrapper.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/geoportal/src/app/components/layers/LayerWrapper.tsx b/apps/geoportal/src/app/components/layers/LayerWrapper.tsx index 81c5717f1..a99d4fe3c 100644 --- a/apps/geoportal/src/app/components/layers/LayerWrapper.tsx +++ b/apps/geoportal/src/app/components/layers/LayerWrapper.tsx @@ -54,7 +54,11 @@ import { getSecondaryInfoBoxElements, setSelectedFeature as setSelectedFeatureAction, } from "../../store/slices/features"; -import { triggerFeatureInfoUpdateAction } from "../../store/slices/ui"; +import { + getUIMode, + triggerFeatureInfoUpdateAction, + UIMode, +} from "../../store/slices/ui"; import GeoportalLayerButton from "./GeoportalLayerButton"; import SecondaryView from "./SecondaryView"; @@ -73,6 +77,7 @@ const LayerWrapper = () => { const backgroundLayer = useSelector(getBackgroundLayer); const maplibreMaps = useSelector(getMaplibreMaps); const selectedFeature = useSelector(getSelectedFeature); + const uiMode = useSelector(getUIMode); const selectedLayerIndex = useSelector(getSelectedLayerIndex); const isNoSelectionIndex = useSelector(getSelectedLayerIndexIsNoSelection); @@ -80,6 +85,8 @@ const LayerWrapper = () => { const showRightScrollButton = useSelector(getShowRightScrollButton); const activeFilterLayerIndex = useSelector(getActiveFilterLayerIndex); + const isModeFeatureInfo = uiMode === UIMode.FEATURE_INFO; + const { isOver, setNodeRef } = useDroppable({ id: "droppable", }); @@ -252,7 +259,7 @@ const LayerWrapper = () => { { dispatch(setSelectedFeatureAction(feature)); }} From fa1896910e970e9cc82b19a5207389fc90530a8a Mon Sep 17 00:00:00 2001 From: David Glogaza Date: Mon, 22 Dec 2025 11:34:07 +0100 Subject: [PATCH 17/18] use id instead of index for displaying filters --- .../layers/GeoportalLayerButton.tsx | 12 +++++------ .../app/components/layers/LayerWrapper.tsx | 6 +++--- .../geoportal/src/app/store/slices/mapping.ts | 20 ++++++++----------- .../portals/src/lib/types/index.d.ts | 2 +- 4 files changed, 18 insertions(+), 22 deletions(-) diff --git a/apps/geoportal/src/app/components/layers/GeoportalLayerButton.tsx b/apps/geoportal/src/app/components/layers/GeoportalLayerButton.tsx index 93c301b3a..691ff0e0c 100644 --- a/apps/geoportal/src/app/components/layers/GeoportalLayerButton.tsx +++ b/apps/geoportal/src/app/components/layers/GeoportalLayerButton.tsx @@ -28,13 +28,13 @@ import { getClickFromInfoView, getLayers, getSelectedLayerIndex, - getActiveFilterLayerIndex, + getActiveFilterLayerID, getShowLeftScrollButton, removeLayer, setClickFromInfoView, setSelectedLayerIndex, setSelectedLayerIndexNoSelection, - setActiveFilterLayerIndex, + setActiveFilterLayerID, setShowLeftScrollButton, setShowRightScrollButton, toggleUseInFeatureInfo, @@ -94,7 +94,7 @@ const GeoportalLayerButton = ({ const showLayerHideButtons = useSelector(getUIShowLayerHideButtons); const showLeftScrollButton = useSelector(getShowLeftScrollButton); const clickFromInfoView = useSelector(getClickFromInfoView); - const activeFilterLayerIndex = useSelector(getActiveFilterLayerIndex); + const activeFilterLayerID = useSelector(getActiveFilterLayerID); const mode = useSelector(getUIMode); const showSettings = index === selectedLayerIndex; const layers = useSelector(getLayers); @@ -221,8 +221,8 @@ const GeoportalLayerButton = ({ e.preventDefault(); e.stopPropagation(); dispatch( - setActiveFilterLayerIndex( - activeFilterLayerIndex === index ? null : index + setActiveFilterLayerID( + activeFilterLayerID === id ? null : id ) ); }} @@ -240,7 +240,7 @@ const GeoportalLayerButton = ({ icon={faFilter} className={cn( "text-sm", - activeFilterLayerIndex === index + activeFilterLayerID === id ? "text-[#1677ff]" : "text-gray-600 hover:text-gray-500" )} diff --git a/apps/geoportal/src/app/components/layers/LayerWrapper.tsx b/apps/geoportal/src/app/components/layers/LayerWrapper.tsx index a99d4fe3c..d7315bc78 100644 --- a/apps/geoportal/src/app/components/layers/LayerWrapper.tsx +++ b/apps/geoportal/src/app/components/layers/LayerWrapper.tsx @@ -38,7 +38,7 @@ import { getLayers, getSelectedLayerIndex, getSelectedLayerIndexIsNoSelection, - getActiveFilterLayerIndex, + getActiveFilterLayerID, getShowLeftScrollButton, getShowRightScrollButton, setLayers, @@ -83,7 +83,7 @@ const LayerWrapper = () => { const isNoSelectionIndex = useSelector(getSelectedLayerIndexIsNoSelection); const showLeftScrollButton = useSelector(getShowLeftScrollButton); const showRightScrollButton = useSelector(getShowRightScrollButton); - const activeFilterLayerIndex = useSelector(getActiveFilterLayerIndex); + const activeFilterLayerID = useSelector(getActiveFilterLayerID); const isModeFeatureInfo = uiMode === UIMode.FEATURE_INFO; @@ -243,7 +243,7 @@ const LayerWrapper = () => { {filterComponents.map((filterEntry) => { - const isActive = filterEntry.index === activeFilterLayerIndex; + const isActive = filterEntry.id === activeFilterLayerID; const maplibreMap = maplibreMaps ? maplibreMaps.find((entry) => entry.id === filterEntry.id)?.map ?? null diff --git a/apps/geoportal/src/app/store/slices/mapping.ts b/apps/geoportal/src/app/store/slices/mapping.ts index 92f9665c5..7d2901c06 100644 --- a/apps/geoportal/src/app/store/slices/mapping.ts +++ b/apps/geoportal/src/app/store/slices/mapping.ts @@ -22,7 +22,7 @@ const initialState: MappingState = { layers: [], savedLayerConfigs: [], selectedLayerIndex: SELECTED_LAYER_INDEX.NO_SELECTION, - activeFilterLayerIndex: null, + activeFilterLayerID: null, paleOpacityValue: defaultOpacity, libreMapRef: null, maplibreMaps: [], @@ -124,12 +124,8 @@ const slice = createSlice({ state.maplibreMaps = state.maplibreMaps.filter( (entry) => entry.id !== action.payload ); - if (state.activeFilterLayerIndex !== null) { - if (removedIndex === state.activeFilterLayerIndex) { - state.activeFilterLayerIndex = null; - } else if (removedIndex < state.activeFilterLayerIndex) { - state.activeFilterLayerIndex = state.activeFilterLayerIndex - 1; - } + if (state.activeFilterLayerID === action.payload) { + state.activeFilterLayerID = null; } }, removeLastLayer(state) { @@ -247,8 +243,8 @@ const slice = createSlice({ } }, - setActiveFilterLayerIndex(state, action) { - state.activeFilterLayerIndex = action.payload; + setActiveFilterLayerID(state, action) { + state.activeFilterLayerID = action.payload; }, setSelectedMapLayer(state, action: PayloadAction) { state.selectedMapLayer = action.payload; @@ -333,7 +329,7 @@ export const { setSelectedLayerIndexNoSelection, setNextSelectedLayerIndex, setPreviousSelectedLayerIndex, - setActiveFilterLayerIndex, + setActiveFilterLayerID, setSelectedMapLayer, setBackgroundLayer, setSelectedLuftbildLayer, @@ -393,8 +389,8 @@ export const getShowMeasurementButton = (state: RootState) => state.mapping.showMeasurementButton; export const getShowRightScrollButton = (state: RootState) => state.mapping.showRightScrollButton; -export const getActiveFilterLayerIndex = (state: RootState) => - state.mapping.activeFilterLayerIndex; +export const getActiveFilterLayerID = (state: RootState) => + state.mapping.activeFilterLayerID; export const getStartDrawing = (state: RootState) => state.mapping.startDrawing; export const getLibreMapRef = (state: RootState) => state.mapping.libreMapRef; export const getMaplibreMaps = (state: RootState) => state.mapping.maplibreMaps; diff --git a/libraries/appframeworks/portals/src/lib/types/index.d.ts b/libraries/appframeworks/portals/src/lib/types/index.d.ts index 17a5f0b8a..d898a6834 100644 --- a/libraries/appframeworks/portals/src/lib/types/index.d.ts +++ b/libraries/appframeworks/portals/src/lib/types/index.d.ts @@ -117,7 +117,7 @@ export interface MappingState extends LayerState { paleOpacityValue: number; showLeftScrollButton: boolean; showRightScrollButton: boolean; - activeFilterLayerIndex: number | null; + activeFilterLayerID: string | null; showFullscreenButton: boolean; showLocatorButton: boolean; showMeasurementButton: boolean; From 82fb1b5203098554684ab61f7ab306890a5b4d15 Mon Sep 17 00:00:00 2001 From: David Glogaza Date: Mon, 22 Dec 2025 15:30:55 +0100 Subject: [PATCH 18/18] fix filter state after rearranging layers --- .../app/components/layers/LayerWrapper.tsx | 36 +++++++++++++------ 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/apps/geoportal/src/app/components/layers/LayerWrapper.tsx b/apps/geoportal/src/app/components/layers/LayerWrapper.tsx index d7315bc78..7fe698136 100644 --- a/apps/geoportal/src/app/components/layers/LayerWrapper.tsx +++ b/apps/geoportal/src/app/components/layers/LayerWrapper.tsx @@ -1,7 +1,7 @@ /* eslint-disable jsx-a11y/no-static-element-interactions */ /* eslint-disable jsx-a11y/click-events-have-key-events */ -import { useContext, useEffect, useMemo, useState } from "react"; +import { useContext, useEffect, useMemo, useState, useRef } from "react"; import { useDispatch, useSelector } from "react-redux"; import { @@ -73,6 +73,8 @@ const LayerWrapper = () => { Record >({}); + const filterComponentsCache = useRef>(new Map()); + const layers = useSelector(getLayers); const backgroundLayer = useSelector(getBackgroundLayer); const maplibreMaps = useSelector(getMaplibreMaps); @@ -129,17 +131,30 @@ const LayerWrapper = () => { // Create filter components for all layers that have filterConfig // We render all but hide inactive ones to preserve state + // Cache components by layer ID to preserve state when layers are reordered const filterComponents = useMemo(() => { - return layers - .map((layer, index) => { - if (!layer.filterConfig) return null; - return { - index, + const cache = filterComponentsCache.current; + + const currentLayerIds = new Set(layers.map((l) => l.id)); + for (const [id] of cache) { + if (!currentLayerIds.has(id)) { + cache.delete(id); + } + } + + layers.forEach((layer) => { + if (layer.filterConfig && !cache.has(layer.id)) { + cache.set(layer.id, { id: layer.id, Component: createFilterButtons(layer.filterConfig), - }; - }) - .filter(Boolean); + }); + } + }); + + // Return only components for layers that currently exist and have filterConfig + return layers + .filter((layer) => layer.filterConfig && cache.has(layer.id)) + .map((layer) => cache.get(layer.id)); }, [layers]); console.debug("RENDER: LayerWrapper selectedLayerIndex", selectedLayerIndex); @@ -248,6 +263,7 @@ const LayerWrapper = () => { ? maplibreMaps.find((entry) => entry.id === filterEntry.id)?.map ?? null : null; + const FilterComponent = filterEntry.Component; return (
{ !isActive && "hidden" )} > -