From 4a5daa3b5d6def3765e5a825a2d50f2c08af3853 Mon Sep 17 00:00:00 2001 From: David Glogaza Date: Wed, 18 Mar 2026 11:29:22 +0100 Subject: [PATCH 01/27] add poi filter ui to library --- libraries/mapping/components/src/index.ts | 12 ++ .../lib/components/AdvancedFilterPanel.tsx | 155 ++++++++++++++++++ .../lib/components/TriStateFilterButton.tsx | 89 ++++++++++ 3 files changed, 256 insertions(+) create mode 100644 libraries/mapping/components/src/lib/components/AdvancedFilterPanel.tsx create mode 100644 libraries/mapping/components/src/lib/components/TriStateFilterButton.tsx diff --git a/libraries/mapping/components/src/index.ts b/libraries/mapping/components/src/index.ts index cdd45af98..4e6a401d8 100644 --- a/libraries/mapping/components/src/index.ts +++ b/libraries/mapping/components/src/index.ts @@ -62,3 +62,15 @@ export { type ObjectCentricViewStateInfoBoxProps, type ObjectCentricViewStateInfoRow, } from "./lib/components/ObjectCentricViewStateInfoBox"; + +export { + TriStateFilterButton, + type TriState, +} from "./lib/components/TriStateFilterButton"; + +export { + AdvancedFilterPanel, + type AdvancedFilterCategory, + type AdvancedFilterState, + type AdvancedFilterPanelProps, +} from "./lib/components/AdvancedFilterPanel"; diff --git a/libraries/mapping/components/src/lib/components/AdvancedFilterPanel.tsx b/libraries/mapping/components/src/lib/components/AdvancedFilterPanel.tsx new file mode 100644 index 000000000..a649679ca --- /dev/null +++ b/libraries/mapping/components/src/lib/components/AdvancedFilterPanel.tsx @@ -0,0 +1,155 @@ +import { useCallback } from "react"; +import { TriStateFilterButton, type TriState } from "./TriStateFilterButton"; +import { PieChart } from "@carma-appframeworks/portals"; + +export interface AdvancedFilterCategory { + key: string; + label: string; +} + +export interface AdvancedFilterState { + positiv: string[]; + negativ: string[]; +} + +export interface AdvancedFilterPanelProps { + categories: AdvancedFilterCategory[]; + filterState: AdvancedFilterState; + onFilterStateChange: (state: AdvancedFilterState) => void; + width?: number; + pieChartData?: [string, number][]; + pieChartColors?: string[]; +} + +export const AdvancedFilterPanel = ({ + categories, + filterState, + onFilterStateChange, + width = 500, + pieChartData, + pieChartColors, +}: AdvancedFilterPanelProps) => { + const getTriState = useCallback( + (key: string): TriState => { + if (filterState.positiv.includes(key)) return "positiv"; + if (filterState.negativ.includes(key)) return "negativ"; + return "neutral"; + }, + [filterState] + ); + + const handleToggle = useCallback( + (key: string, newState: TriState) => { + const updated = { + positiv: filterState.positiv.filter((k) => k !== key), + negativ: filterState.negativ.filter((k) => k !== key), + }; + + if (newState === "positiv") { + updated.positiv = [...updated.positiv, key].sort(); + } else if (newState === "negativ") { + updated.negativ = [...updated.negativ, key].sort(); + } + + onFilterStateChange(updated); + }, + [filterState, onFilterStateChange] + ); + + const handleSelectAll = useCallback(() => { + onFilterStateChange({ + positiv: categories.map((c) => c.key), + negativ: [], + }); + }, [categories, onFilterStateChange]); + + const handleClearPositiv = useCallback(() => { + onFilterStateChange({ ...filterState, positiv: [] }); + }, [filterState, onFilterStateChange]); + + const handleClearNegativ = useCallback(() => { + onFilterStateChange({ ...filterState, negativ: [] }); + }, [filterState, onFilterStateChange]); + + const filterRows = ( +
+ {categories.map((cat) => ( + handleToggle(cat.key, newState)} + /> + ))} +
+ ); + + const pieChart = + pieChartData && pieChartData.length > 0 && pieChartColors ? ( + + ) : null; + + const isWide = width >= 600; + + const btnStyle: React.CSSProperties = { + padding: "3px 8px", + border: "1px solid #ccc", + borderRadius: "4px", + background: "#f8f9fa", + cursor: "pointer", + fontSize: "11px", + whiteSpace: "nowrap", + }; + + return ( +
+
+ + + +
+ + {isWide && pieChart ? ( +
+
{filterRows}
+
+ {pieChart} +
+
+ ) : ( + <> + {filterRows} + {pieChart &&
{pieChart}
} + + )} +
+ ); +}; diff --git a/libraries/mapping/components/src/lib/components/TriStateFilterButton.tsx b/libraries/mapping/components/src/lib/components/TriStateFilterButton.tsx new file mode 100644 index 000000000..fc85c5315 --- /dev/null +++ b/libraries/mapping/components/src/lib/components/TriStateFilterButton.tsx @@ -0,0 +1,89 @@ +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faThumbsUp, faThumbsDown } from "@fortawesome/free-solid-svg-icons"; + +export type TriState = "positiv" | "neutral" | "negativ"; + +interface TriStateFilterButtonProps { + label: string; + state: TriState; + onChange: (newState: TriState) => void; + footnote?: string; +} + +const btnBase: React.CSSProperties = { + border: "1px solid #ddd", + background: "#f8f9fa", + padding: "1px 6px", + cursor: "pointer", + fontSize: "11px", + lineHeight: 1, +}; + +export const TriStateFilterButton = ({ + label, + state, + onChange, + footnote, +}: TriStateFilterButtonProps) => { + return ( +
+
+ {label} + {footnote && {footnote}} +
+
+ + + +
+
+ ); +}; From 428b8fea2ab89de68758e2fc075e47dd29494bcc Mon Sep 17 00:00:00 2001 From: David Glogaza Date: Wed, 18 Mar 2026 11:29:37 +0100 Subject: [PATCH 02/27] add example poi filter --- .../app/components/layers/InteractionView.tsx | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/apps/geoportal/src/app/components/layers/InteractionView.tsx b/apps/geoportal/src/app/components/layers/InteractionView.tsx index 3f8f2ace0..b8f2a3ba2 100644 --- a/apps/geoportal/src/app/components/layers/InteractionView.tsx +++ b/apps/geoportal/src/app/components/layers/InteractionView.tsx @@ -12,6 +12,9 @@ import { createFilterButtons, FilterInfo, FilterState, + AdvancedFilterPanel, + type AdvancedFilterState, + type AdvancedFilterCategory, } from "@carma-mapping/components"; import { getSelectedFeature, @@ -25,7 +28,57 @@ import { import { useFilterBackground } from "./useFilterBackground"; import FilterBackdrop from "./FilterBackdrop"; +// Hardcoded POI test data for the AdvancedFilterPanel +const POI_CATEGORIES: AdvancedFilterCategory[] = [ + { key: "Freizeit", label: "Freizeit" }, + { key: "Sport", label: "Sport" }, + { key: "Mobilität", label: "Mobilität" }, + { key: "Religion", label: "Religion" }, + { key: "Gesundheit", label: "Gesundheit" }, + { key: "Kultur", label: "Kultur" }, + { key: "Gesellschaft", label: "Gesellschaft" }, + { key: "Bildung", label: "Bildung" }, + { key: "Kinderbetreuung", label: "Kinderbetreuung" }, + { key: "Dienstleistungen", label: "Dienstleistungen" }, + { + key: "öffentliche Dienstleistungen", + label: "öffentliche Dienstleistungen", + }, + { key: "Orientierung", label: "Orientierung" }, + { key: "Stadtbild", label: "Stadtbild" }, + { key: "Erholung", label: "Erholung" }, +]; + +// Dummy PieChart data for testing +const DUMMY_PIE_DATA: [string, number][] = [ + ["Freizeit, Sport", 42], + ["Mobilität", 35], + ["Religion", 28], + ["Gesundheit", 22], + ["Bildung", 38], + ["Kultur", 15], + ["Gesellschaft", 20], + ["Kinderbetreuung", 12], +]; + +const DUMMY_PIE_COLORS = [ + "#194761", + "#6BB6D7", + "#0D0D0D", + "#CB0D0D", + "#FFC000", + "#B27A08", + "#B0CBEC", + "#00A0B0", +]; + const InteractionView = ({ isDragging }: { isDragging?: boolean }) => { + const [filterState, setFilterState] = useState(); + const [advancedFilterState, setAdvancedFilterState] = + useState({ + positiv: POI_CATEGORIES.map((c) => c.key), + negativ: [], + }); const dispatch = useDispatch(); const activeInteractionLayerID = useSelector(getActiveInteractionLayerID); const layers = useSelector(getLayers); @@ -43,6 +96,7 @@ const InteractionView = ({ isDragging }: { isDragging?: boolean }) => { activeInteractionLayerID, isDragging ); + const isPoiLayer = layer?.id?.toLowerCase().includes("poi"); const FilterComponent = useMemo( () => @@ -54,6 +108,24 @@ const InteractionView = ({ isDragging }: { isDragging?: boolean }) => { return null; } + if (isPoiLayer) { + return ( +
+ +
+ ); + } + + if (!FilterComponent) { + return null; + } + return (
{validBg && !isDragging && } From 4e156fd9b2d6a0abb0b9a9f807711b3d1dea2340 Mon Sep 17 00:00:00 2001 From: David Glogaza Date: Fri, 27 Mar 2026 09:36:43 +0100 Subject: [PATCH 03/27] add filtering to ng stadtplan --- .../lib/components/AdvancedFilterPanel.tsx | 16 +- .../ng-topicmap-playground/src/app/Menu.tsx | 52 +++- .../src/app/Stadtplan.tsx | 235 +++++++++++++++--- 3 files changed, 256 insertions(+), 47 deletions(-) diff --git a/libraries/mapping/components/src/lib/components/AdvancedFilterPanel.tsx b/libraries/mapping/components/src/lib/components/AdvancedFilterPanel.tsx index a649679ca..7f97d13a9 100644 --- a/libraries/mapping/components/src/lib/components/AdvancedFilterPanel.tsx +++ b/libraries/mapping/components/src/lib/components/AdvancedFilterPanel.tsx @@ -102,19 +102,7 @@ export const AdvancedFilterPanel = ({ }; return ( -
+
-
{filterRows}
+
{filterRows}
diff --git a/playgrounds/ng-topicmap-playground/src/app/Menu.tsx b/playgrounds/ng-topicmap-playground/src/app/Menu.tsx index 2babec256..77a5a55b4 100644 --- a/playgrounds/ng-topicmap-playground/src/app/Menu.tsx +++ b/playgrounds/ng-topicmap-playground/src/app/Menu.tsx @@ -3,6 +3,7 @@ import CustomizationContextProvider from "react-cismap/contexts/CustomizationCon import { UIDispatchContext } from "react-cismap/contexts/UIContextProvider"; import DefaultSettingsPanel from "react-cismap/topicmaps/menu/DefaultSettingsPanel"; import ModalApplicationMenu from "react-cismap/topicmaps/menu/ModalApplicationMenu"; +import Section from "react-cismap/topicmaps/menu/Section"; import { GenericDigitalTwinReferenceSection } from "@carma-collab/wuppertal/commons"; import { KompaktanleitungSection, @@ -13,11 +14,38 @@ import { import versionData from "../version.json"; import { getApplicationVersion } from "@carma-commons/utils"; import { PreviewLibreMap } from "@carma-mapping/engines/maplibre"; +import { + AdvancedFilterPanel, + type AdvancedFilterCategory, + type AdvancedFilterState, +} from "@carma-mapping/components"; + +interface MenuProps { + categories?: AdvancedFilterCategory[]; + filterState?: AdvancedFilterState; + onFilterStateChange?: (state: AdvancedFilterState) => void; + pieChartData?: [string, number][]; + pieChartColors?: string[]; +} -const Menu = () => { +const Menu = ({ + categories, + filterState, + onFilterStateChange, + pieChartData, + pieChartColors, +}: MenuProps) => { const { setAppMenuActiveMenuSection } = useContext(UIDispatchContext); + const hasFilter = categories && filterState && onFilterStateChange; + + const filterTitle = + filterState && + (filterState.positiv.length > 0 || filterState.negativ.length > 0) + ? `Filter (${filterState.positiv.length} aktiv, ${filterState.negativ.length} ausgeschlossen)` + : "Filter"; + return ( { /> } menuSections={[ + ...(hasFilter + ? [ +
+ } + />, + ] + : []), { + getSymbolSVG={(size: number, color: string) => { return ( = { + "Freizeit, Sport": "#194761", + Mobilität: "#6BB6D7", + "Erholung, Religion": "#094409", + Gesellschaft: "#B0CBEC", + Religion: "#0D0D0D", + Gesundheit: "#CB0D0D", + "Erholung, Freizeit": "#638555", + Sport: "#0141CF", + "Freizeit, Kultur": "#B27A08", + "Gesellschaft, Kultur": "#E26B0A", + "öffentliche Dienstleistungen": "#417DD4", + Orientierung: "#BFBFBF", + Bildung: "#FFC000", + Stadtbild: "#695656", + "Gesellschaft, öffentliche Dienstleistungen": "#569AD6", + "Dienstleistungen, Freizeit": "#26978F", + Dienstleistungen: "#538DD5", + "Bildung, Freizeit": "#BBAA1E", + Kinderbetreuung: "#00A0B0", +}; + +/** Deterministic fallback color for combinations not in POI_COLORS */ +function hashColor(str: string): string { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = str.charCodeAt(i) + ((hash << 5) - hash); + } + const h = Math.abs(hash) % 360; + return `hsl(${h}, 30%, 50%)`; +} + +function getColorForCombination(combination: string): string { + return POI_COLORS[combination] || hashColor(combination); +} + +const POI_SOURCE_ID = "geojson-source-0"; + +/** Given all unique kombi values and the current filter state, return + * those kombi values whose features should be visible. */ +function getAllowedKombis( + allKombis: string[], + filterState: AdvancedFilterState +): string[] { + return allKombis.filter((kombi) => { + const ll = kombi.split(", "); + for (const lebenslage of ll) { + if (filterState.negativ.includes(lebenslage)) return false; + } + return ll.some((l) => filterState.positiv.includes(l)); + }); +} + +/** Build a MapLibre filter expression that only shows features with allowed kombi values. */ +function buildPoiFilterExpression(allowedKombis: string[]): any[] { + if (allowedKombis.length === 0) { + return ["==", ["get", "kombi"], "___HIDE_ALL___"]; + } + return [ + "any", + ["!", ["has", "kombi"]], + ["==", ["get", "kombi"], ""], + ["match", ["get", "kombi"], allowedKombis, true, false], + ]; +} + +/** Apply the current POI filter to all layers belonging to the POI source. */ +function applyPoiFilter( + map: any, + allKombis: string[], + filterState: AdvancedFilterState +) { + const allowedKombis = getAllowedKombis(allKombis, filterState); + const isShowingAll = allowedKombis.length === allKombis.length; + const filterExpr = isShowingAll + ? null + : buildPoiFilterExpression(allowedKombis); + + const layers = map.getStyle()?.layers || []; + for (const layer of layers) { + if (layer.id.startsWith(POI_SOURCE_ID)) { + try { + map.setFilter(layer.id, filterExpr); + } catch (e) { + console.error(`Error setting filter on layer ${layer.id}:`, e); + } + } + } +} + export function Stadtplan() { const { progress, showProgress, handleProgressUpdate } = useProgress(); + const [allFeatures, setAllFeatures] = useState([]); + const [lebenslagen, setLebenslagen] = useState([]); + const [filterState, setFilterState] = useState({ + positiv: [], + negativ: [], + }); + const allKombisRef = useRef([]); + const filterStateRef = useRef(filterState); + filterStateRef.current = filterState; + + // Capture original features and extract lebenslagen on first filterFunction call + const handleFilter = useCallback( + (map: any, layers: any) => { + layers?.forEach((layer: any, index: number) => { + if (layer.type !== "geojson") return; + + const sourceId = `geojson-source-${index}`; + const styleSource = map.getStyle().sources[sourceId] as any; + if (!styleSource?.data?.features) return; + + // Extract lebenslagen and kombi values only once + if (allKombisRef.current.length === 0) { + const features = styleSource.data.features; + setAllFeatures(features); + + const llSet = new Set(); + const kombiSet = new Set(); + for (const f of features) { + const kombi = f.properties?.kombi; + if (typeof kombi === "string" && kombi.length > 0) { + kombiSet.add(kombi); + for (const ll of kombi.split(", ")) { + llSet.add(ll); + } + } + } + const sorted = Array.from(llSet).sort(); + allKombisRef.current = Array.from(kombiSet); + setLebenslagen(sorted); + + const initialFilter = { positiv: sorted, negativ: [] }; + filterStateRef.current = initialFilter; + setFilterState(initialFilter); + } + + // Apply current filter via setFilter (also handles style rebuilds) + applyPoiFilter(map, allKombisRef.current, filterStateRef.current); + }); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + + // Re-apply filter dynamically when the user changes filter state + useEffect(() => { + if (allKombisRef.current.length === 0 || lebenslagen.length === 0) return; + + const map = (window as any).__carmaMap; + if (!map) return; + + applyPoiFilter(map, allKombisRef.current, filterState); + }, [filterState, lebenslagen]); + + // Compute pie chart data from filtered features + const { pieChartData, pieChartColors } = useMemo(() => { + if (allFeatures.length === 0) + return { pieChartData: [], pieChartColors: [] }; + + const allowedKombis = new Set( + getAllowedKombis(allKombisRef.current, filterState) + ); + const stats: Record = {}; + const colors: Record = {}; + + for (const f of allFeatures) { + const kombi = f.properties?.kombi; + if (typeof kombi !== "string" || kombi.length === 0) continue; + if (!allowedKombis.has(kombi)) continue; + const key = kombi.split(", ").slice().sort().join(", "); + stats[key] = (stats[key] || 0) + 1; + if (!colors[key]) { + colors[key] = getColorForCombination(key); + } + } + + const data: [string, number][] = Object.entries(stats); + const colorArr = data.map(([key]) => colors[key]); + return { pieChartData: data, pieChartColors: colorArr }; + }, [filterState, allFeatures]); + + const categories = useMemo( + () => lebenslagen.map((ll) => ({ key: ll, label: ll })), + [lebenslagen] + ); + return ( { - layers?.forEach((layer, index) => { - if (layer.type === "geojson") { - const sourceId = `geojson-source-${index}`; - const styleSource = map.getStyle().sources[ - sourceId - ] as any; - - if (styleSource?.data?.features) { - const filteredFeatures = - styleSource.data.features.filter((feature: any) => { - const identifications = - feature.properties?.identifications; - if (!Array.isArray(identifications)) return true; - return !identifications.some( - (id: any) => id.identification === "Schule" - ); - }); - - const source = map.getSource(sourceId); - if (source && "setData" in source) { - (source as any).setData({ - type: "FeatureCollection", - features: filteredFeatures, - }); - } - } - } - }); - }} - modalMenu={} + filterFunction={handleFilter} + modalMenu={ + + } /> From 183a54ac9eb08b5d64dad06235e87f12158c4311 Mon Sep 17 00:00:00 2001 From: David Glogaza Date: Fri, 27 Mar 2026 11:41:27 +0100 Subject: [PATCH 04/27] fix filtering for clustered items --- .../src/app/Stadtplan.tsx | 55 +++++++++++++++---- 1 file changed, 43 insertions(+), 12 deletions(-) diff --git a/playgrounds/ng-topicmap-playground/src/app/Stadtplan.tsx b/playgrounds/ng-topicmap-playground/src/app/Stadtplan.tsx index 92b5380bf..66f4b61cf 100644 --- a/playgrounds/ng-topicmap-playground/src/app/Stadtplan.tsx +++ b/playgrounds/ng-topicmap-playground/src/app/Stadtplan.tsx @@ -1,10 +1,4 @@ -import { - useState, - useCallback, - useEffect, - useMemo, - useRef, -} from "react"; +import { useState, useCallback, useEffect, useMemo, useRef } from "react"; import { SelectionProvider, ProgressIndicator, @@ -94,9 +88,12 @@ function buildPoiFilterExpression(allowedKombis: string[]): any[] { ]; } -/** Apply the current POI filter to all layers belonging to the POI source. */ +/** Apply the current POI filter. + * - setFilter on non-cluster layers for instant, flicker-free toggling + * - setData on the source so cluster aggregation only includes visible features */ function applyPoiFilter( map: any, + allFeatures: any[], allKombis: string[], filterState: AdvancedFilterState ) { @@ -106,9 +103,10 @@ function applyPoiFilter( ? null : buildPoiFilterExpression(allowedKombis); + // Instant visual update on non-cluster layers via setFilter const layers = map.getStyle()?.layers || []; for (const layer of layers) { - if (layer.id.startsWith(POI_SOURCE_ID)) { + if (layer.id.startsWith(POI_SOURCE_ID) && !layer.id.endsWith("-clusters")) { try { map.setFilter(layer.id, filterExpr); } catch (e) { @@ -116,6 +114,27 @@ function applyPoiFilter( } } } + + // Update source data so clusters recompute with only the visible features + const source = map.getSource(POI_SOURCE_ID); + if (source && "setData" in source) { + if (isShowingAll) { + (source as any).setData({ + type: "FeatureCollection", + features: allFeatures, + }); + } else { + const allowedSet = new Set(allowedKombis); + (source as any).setData({ + type: "FeatureCollection", + features: allFeatures.filter((f: any) => { + const kombi = f.properties?.kombi; + if (typeof kombi !== "string" || kombi.length === 0) return true; + return allowedSet.has(kombi); + }), + }); + } + } } export function Stadtplan() { @@ -127,6 +146,7 @@ export function Stadtplan() { positiv: [], negativ: [], }); + const allFeaturesRef = useRef([]); const allKombisRef = useRef([]); const filterStateRef = useRef(filterState); filterStateRef.current = filterState; @@ -144,6 +164,7 @@ export function Stadtplan() { // Extract lebenslagen and kombi values only once if (allKombisRef.current.length === 0) { const features = styleSource.data.features; + allFeaturesRef.current = features; setAllFeatures(features); const llSet = new Set(); @@ -166,8 +187,13 @@ export function Stadtplan() { setFilterState(initialFilter); } - // Apply current filter via setFilter (also handles style rebuilds) - applyPoiFilter(map, allKombisRef.current, filterStateRef.current); + // Apply current filter (also handles style rebuilds) + applyPoiFilter( + map, + allFeaturesRef.current, + allKombisRef.current, + filterStateRef.current + ); }); }, // eslint-disable-next-line react-hooks/exhaustive-deps @@ -181,7 +207,12 @@ export function Stadtplan() { const map = (window as any).__carmaMap; if (!map) return; - applyPoiFilter(map, allKombisRef.current, filterState); + applyPoiFilter( + map, + allFeaturesRef.current, + allKombisRef.current, + filterState + ); }, [filterState, lebenslagen]); // Compute pie chart data from filtered features From 57132c29db3cb97c3516ec3f3564309f26bf4892 Mon Sep 17 00:00:00 2001 From: David Glogaza Date: Fri, 27 Mar 2026 12:54:32 +0100 Subject: [PATCH 05/27] add poi filtering without dummy data --- .../layers/GeoportalLayerButton.tsx | 6 +- .../app/components/layers/InteractionView.tsx | 89 +++----- .../portals/src/lib/components/PieChart.tsx | 2 +- libraries/mapping/components/src/index.ts | 5 + .../GenericFilterButtonsFactory.tsx | 8 +- .../src/lib/components/PoiFilterPanel.tsx | 201 ++++++++++++++++++ .../src/lib/contracts/carma-layers.d.ts | 10 +- 7 files changed, 248 insertions(+), 73 deletions(-) create mode 100644 libraries/mapping/components/src/lib/components/PoiFilterPanel.tsx diff --git a/apps/geoportal/src/app/components/layers/GeoportalLayerButton.tsx b/apps/geoportal/src/app/components/layers/GeoportalLayerButton.tsx index 71ff8993e..62d562575 100644 --- a/apps/geoportal/src/app/components/layers/GeoportalLayerButton.tsx +++ b/apps/geoportal/src/app/components/layers/GeoportalLayerButton.tsx @@ -160,6 +160,8 @@ const GeoportalLayerButton = ({ useEffect(() => { if (!layer.filterConfig || !layer.filterState || filterAppliedRef.current) return; + if (layer.filterConfig.filterType === "poi") return; + const filterConfig = layer.filterConfig; const mapEntry = maplibreMaps?.find((entry) => entry.id === id); if (!mapEntry?.map) return; @@ -167,12 +169,12 @@ const GeoportalLayerButton = ({ const libreMap = mapEntry.map; try { const originals = captureOriginalFilters( - layer.filterConfig.layerPattern, + filterConfig.layerPattern, libreMap ); const filterExpression = buildFilterExpression( - layer.filterConfig, + filterConfig, layer.filterState ); diff --git a/apps/geoportal/src/app/components/layers/InteractionView.tsx b/apps/geoportal/src/app/components/layers/InteractionView.tsx index b8f2a3ba2..7f29cf659 100644 --- a/apps/geoportal/src/app/components/layers/InteractionView.tsx +++ b/apps/geoportal/src/app/components/layers/InteractionView.tsx @@ -12,9 +12,7 @@ import { createFilterButtons, FilterInfo, FilterState, - AdvancedFilterPanel, - type AdvancedFilterState, - type AdvancedFilterCategory, + PoiFilterPanel, } from "@carma-mapping/components"; import { getSelectedFeature, @@ -28,57 +26,8 @@ import { import { useFilterBackground } from "./useFilterBackground"; import FilterBackdrop from "./FilterBackdrop"; -// Hardcoded POI test data for the AdvancedFilterPanel -const POI_CATEGORIES: AdvancedFilterCategory[] = [ - { key: "Freizeit", label: "Freizeit" }, - { key: "Sport", label: "Sport" }, - { key: "Mobilität", label: "Mobilität" }, - { key: "Religion", label: "Religion" }, - { key: "Gesundheit", label: "Gesundheit" }, - { key: "Kultur", label: "Kultur" }, - { key: "Gesellschaft", label: "Gesellschaft" }, - { key: "Bildung", label: "Bildung" }, - { key: "Kinderbetreuung", label: "Kinderbetreuung" }, - { key: "Dienstleistungen", label: "Dienstleistungen" }, - { - key: "öffentliche Dienstleistungen", - label: "öffentliche Dienstleistungen", - }, - { key: "Orientierung", label: "Orientierung" }, - { key: "Stadtbild", label: "Stadtbild" }, - { key: "Erholung", label: "Erholung" }, -]; - -// Dummy PieChart data for testing -const DUMMY_PIE_DATA: [string, number][] = [ - ["Freizeit, Sport", 42], - ["Mobilität", 35], - ["Religion", 28], - ["Gesundheit", 22], - ["Bildung", 38], - ["Kultur", 15], - ["Gesellschaft", 20], - ["Kinderbetreuung", 12], -]; - -const DUMMY_PIE_COLORS = [ - "#194761", - "#6BB6D7", - "#0D0D0D", - "#CB0D0D", - "#FFC000", - "#B27A08", - "#B0CBEC", - "#00A0B0", -]; - const InteractionView = ({ isDragging }: { isDragging?: boolean }) => { const [filterState, setFilterState] = useState(); - const [advancedFilterState, setAdvancedFilterState] = - useState({ - positiv: POI_CATEGORIES.map((c) => c.key), - negativ: [], - }); const dispatch = useDispatch(); const activeInteractionLayerID = useSelector(getActiveInteractionLayerID); const layers = useSelector(getLayers); @@ -96,28 +45,38 @@ const InteractionView = ({ isDragging }: { isDragging?: boolean }) => { activeInteractionLayerID, isDragging ); - const isPoiLayer = layer?.id?.toLowerCase().includes("poi"); + + const filterType = layer?.filterConfig?.filterType; const FilterComponent = useMemo( () => - layer?.filterConfig ? createFilterButtons(layer.filterConfig) : null, - [layer?.filterConfig] + layer?.filterConfig && filterType !== "poi" + ? createFilterButtons(layer.filterConfig) + : null, + [layer?.filterConfig, filterType] ); - if (!layer) { + if (!layer || !layer.filterConfig) { return null; } - if (isPoiLayer) { + if (filterType === "poi") { return ( -
- +
+ {validBg && !isDragging && } +
+
+ +
+
); } diff --git a/libraries/appframeworks/portals/src/lib/components/PieChart.tsx b/libraries/appframeworks/portals/src/lib/components/PieChart.tsx index 4dc9c1dea..8d7b530d3 100644 --- a/libraries/appframeworks/portals/src/lib/components/PieChart.tsx +++ b/libraries/appframeworks/portals/src/lib/components/PieChart.tsx @@ -42,7 +42,7 @@ export const PieChart = ({ justifyContent: "center", }} > -
+
void; - config: FilterConfig; + config: ButtonsFilterConfig; onFilterChange?: (filterInfo: FilterInfo, filterState: FilterState) => void; skipFeatureMatchCheck?: boolean; initialFilters?: FilterState; @@ -70,7 +70,7 @@ export const captureOriginalFilters = ( // Function to build filter expression from selected filters export const buildFilterExpression = ( - config: FilterConfig, + config: ButtonsFilterConfig, filters: FilterState ): any[] | null => { const isOrMode = config.filterMode === "or"; @@ -122,7 +122,7 @@ export const buildFilterExpression = ( } }; -export const createFilterButtons = (config: FilterConfig) => { +export const createFilterButtons = (config: ButtonsFilterConfig) => { const isOrMode = config.filterMode === "or"; const GenericFilterButtons = ({ diff --git a/libraries/mapping/components/src/lib/components/PoiFilterPanel.tsx b/libraries/mapping/components/src/lib/components/PoiFilterPanel.tsx new file mode 100644 index 000000000..c16cc927c --- /dev/null +++ b/libraries/mapping/components/src/lib/components/PoiFilterPanel.tsx @@ -0,0 +1,201 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + AdvancedFilterPanel, + type AdvancedFilterCategory, + type AdvancedFilterState, +} from "./AdvancedFilterPanel"; + +// Color mapping for lebenslage combinations (sorted alphabetically) +const POI_COLORS: Record = { + "Freizeit, Sport": "#194761", + Mobilität: "#6BB6D7", + "Erholung, Religion": "#094409", + Gesellschaft: "#B0CBEC", + Religion: "#0D0D0D", + Gesundheit: "#CB0D0D", + "Erholung, Freizeit": "#638555", + Sport: "#0141CF", + "Freizeit, Kultur": "#B27A08", + "Gesellschaft, Kultur": "#E26B0A", + "öffentliche Dienstleistungen": "#417DD4", + Orientierung: "#BFBFBF", + Bildung: "#FFC000", + Stadtbild: "#695656", + "Gesellschaft, öffentliche Dienstleistungen": "#569AD6", + "Dienstleistungen, Freizeit": "#26978F", + Dienstleistungen: "#538DD5", + "Bildung, Freizeit": "#BBAA1E", + Kinderbetreuung: "#00A0B0", +}; + +function hashColor(str: string): string { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = str.charCodeAt(i) + ((hash << 5) - hash); + } + const h = Math.abs(hash) % 360; + return `hsl(${h}, 30%, 50%)`; +} + +function getColorForCombination(combination: string): string { + return POI_COLORS[combination] || hashColor(combination); +} + +function getAllowedKombis( + allKombis: string[], + filterState: AdvancedFilterState +): string[] { + return allKombis.filter((kombi) => { + const ll = kombi.split(", "); + for (const lebenslage of ll) { + if (filterState.negativ.includes(lebenslage)) return false; + } + return ll.some((l) => filterState.positiv.includes(l)); + }); +} + +function buildPoiFilterExpression(allowedKombis: string[]): any[] | null { + if (allowedKombis.length === 0) { + return ["==", ["get", "kombi"], "___HIDE_ALL___"]; + } + return [ + "any", + ["!", ["has", "kombi"]], + ["==", ["get", "kombi"], ""], + ["match", ["get", "kombi"], allowedKombis, true, false], + ]; +} + +function findPoiLayerIds(map: any): string[] { + const style = map.getStyle(); + if (!style?.layers) return []; + return style.layers + .filter( + (l: any) => l["source-layer"] === "poi" && l.id !== "poi-images-selection" + ) + .map((l: any) => l.id); +} + +export interface PoiFilterPanelProps { + maplibreMap: any; + width?: number; +} + +export const PoiFilterPanel = ({ + maplibreMap, + width = 700, +}: PoiFilterPanelProps) => { + const [advancedFilterState, setAdvancedFilterState] = + useState({ positiv: [], negativ: [] }); + const [categories, setCategories] = useState([]); + const [allKombis, setAllKombis] = useState([]); + const allKombisRef = useRef([]); + const initializedForMap = useRef(null); + + // Extract categories from vector tile features once the map is available + const extractCategories = useCallback((map: any) => { + const features = map.querySourceFeatures("poi-source", { + sourceLayer: "poi", + }); + if (features.length === 0) return; + + const llSet = new Set(); + const kombiSet = new Set(); + for (const f of features) { + const kombi = f.properties?.kombi; + if (typeof kombi === "string" && kombi.length > 0) { + kombiSet.add(kombi); + for (const ll of kombi.split(", ")) { + llSet.add(ll); + } + } + } + + if (llSet.size === 0) return; + + const sorted = Array.from(llSet).sort(); + const kombis = Array.from(kombiSet); + allKombisRef.current = kombis; + setAllKombis(kombis); + setCategories(sorted.map((ll) => ({ key: ll, label: ll }))); + setAdvancedFilterState({ positiv: sorted, negativ: [] }); + initializedForMap.current = map; + }, []); + + // Initialize categories when the maplibre map becomes available + useEffect(() => { + if (!maplibreMap || initializedForMap.current === maplibreMap) return; + + const tryExtract = () => extractCategories(maplibreMap); + + tryExtract(); + if (allKombisRef.current.length === 0) { + maplibreMap.on("sourcedata", tryExtract); + return () => maplibreMap.off("sourcedata", tryExtract); + } + }, [maplibreMap, extractCategories]); + + // Apply filter to map layers when filter state changes + useEffect(() => { + if (!maplibreMap || allKombisRef.current.length === 0) return; + + const allowed = getAllowedKombis(allKombisRef.current, advancedFilterState); + const isShowingAll = allowed.length === allKombisRef.current.length; + const filterExpr = isShowingAll ? null : buildPoiFilterExpression(allowed); + + const poiLayerIds = findPoiLayerIds(maplibreMap); + for (const layerId of poiLayerIds) { + try { + maplibreMap.setFilter(layerId, filterExpr); + } catch (e) { + console.error(`[POI_FILTER] Error setting filter on ${layerId}:`, e); + } + } + }, [advancedFilterState, maplibreMap]); + + // Compute pie chart data from the current filter state + const { pieChartData, pieChartColors } = useMemo(() => { + if (!maplibreMap || allKombis.length === 0) + return { pieChartData: [], pieChartColors: [] }; + + const allowedSet = new Set( + getAllowedKombis(allKombis, advancedFilterState) + ); + const features = maplibreMap.querySourceFeatures("poi-source", { + sourceLayer: "poi", + }); + + // Deduplicate features by ID (vector tiles can return duplicates across tile boundaries) + const seen = new Set(); + const stats: Record = {}; + const colors: Record = {}; + for (const f of features) { + if (f.id !== undefined && seen.has(f.id as number)) continue; + if (f.id !== undefined) seen.add(f.id as number); + + const kombi = f.properties?.kombi; + if (typeof kombi !== "string" || kombi.length === 0) continue; + if (!allowedSet.has(kombi)) continue; + const key = kombi.split(", ").slice().sort().join(", "); + stats[key] = (stats[key] || 0) + 1; + if (!colors[key]) { + colors[key] = getColorForCombination(key); + } + } + + const data: [string, number][] = Object.entries(stats); + const colorArr = data.map(([key]) => colors[key]); + return { pieChartData: data, pieChartColors: colorArr }; + }, [advancedFilterState, maplibreMap, allKombis]); + + return ( + + ); +}; diff --git a/libraries/mapping/layers/src/lib/contracts/carma-layers.d.ts b/libraries/mapping/layers/src/lib/contracts/carma-layers.d.ts index 70348640d..dfe68b1ce 100644 --- a/libraries/mapping/layers/src/lib/contracts/carma-layers.d.ts +++ b/libraries/mapping/layers/src/lib/contracts/carma-layers.d.ts @@ -65,7 +65,9 @@ export type FilterOption = { grayscaleWhenInactive?: boolean; }; -export type FilterConfig = { +export type ButtonsFilterConfig = { + filterType?: "buttons"; + /** The "show all" button label (not shown if filterMode is "or") */ allLabel?: string; layerPattern: string; filterMode?: FilterMode; @@ -80,6 +82,12 @@ export type FilterConfig = { }; }; +export type PoiFilterConfig = { + filterType: "poi"; +}; + +export type FilterConfig = ButtonsFilterConfig | PoiFilterConfig; + export type LayerProps = { url: string; name: string; From 0d59078c38749c9b0268503d2d1ae2294df8c908 Mon Sep 17 00:00:00 2001 From: David Glogaza Date: Fri, 27 Mar 2026 13:13:35 +0100 Subject: [PATCH 06/27] fix filter resets after unmount --- .../app/components/layers/InteractionView.tsx | 13 ++- .../src/lib/components/PoiFilterPanel.tsx | 91 ++++++++++++++++++- 2 files changed, 99 insertions(+), 5 deletions(-) diff --git a/apps/geoportal/src/app/components/layers/InteractionView.tsx b/apps/geoportal/src/app/components/layers/InteractionView.tsx index 7f29cf659..3408cd38d 100644 --- a/apps/geoportal/src/app/components/layers/InteractionView.tsx +++ b/apps/geoportal/src/app/components/layers/InteractionView.tsx @@ -74,7 +74,18 @@ const InteractionView = ({ isDragging }: { isDragging?: boolean }) => { padding: "8px 12px", }} > - + { + dispatch( + setLayerFilterState({ id: layer.id, filterState: state }) + ); + dispatch( + setLayerFilterInfo({ id: layer.id, filterInfo: info }) + ); + }} + />
diff --git a/libraries/mapping/components/src/lib/components/PoiFilterPanel.tsx b/libraries/mapping/components/src/lib/components/PoiFilterPanel.tsx index c16cc927c..ef68e5618 100644 --- a/libraries/mapping/components/src/lib/components/PoiFilterPanel.tsx +++ b/libraries/mapping/components/src/lib/components/PoiFilterPanel.tsx @@ -4,6 +4,7 @@ import { type AdvancedFilterCategory, type AdvancedFilterState, } from "./AdvancedFilterPanel"; +import type { FilterInfo } from "./GenericFilterButtonsFactory"; // Color mapping for lebenslage combinations (sorted alphabetically) const POI_COLORS: Record = { @@ -76,14 +77,49 @@ function findPoiLayerIds(map: any): string[] { .map((l: any) => l.id); } +/** + * Serialize AdvancedFilterState to Record for store persistence. + * true = positiv, false = negativ, absent = neutral. + */ +function serializeFilterState( + state: AdvancedFilterState +): Record { + const result: Record = {}; + for (const key of state.positiv) result[key] = true; + for (const key of state.negativ) result[key] = false; + return result; +} + +/** + * Deserialize Record back to AdvancedFilterState. + */ +function deserializeFilterState( + stored: Record +): AdvancedFilterState { + const positiv: string[] = []; + const negativ: string[] = []; + for (const [key, value] of Object.entries(stored)) { + if (value === true) positiv.push(key); + else if (value === false) negativ.push(key); + } + return { positiv, negativ }; +} + export interface PoiFilterPanelProps { maplibreMap: any; width?: number; + initialFilterState?: Record; + onFilterChange?: ( + filterInfo: FilterInfo, + filterState: Record + ) => void; } export const PoiFilterPanel = ({ maplibreMap, width = 700, + initialFilterState, + onFilterChange, }: PoiFilterPanelProps) => { const [advancedFilterState, setAdvancedFilterState] = useState({ positiv: [], negativ: [] }); @@ -91,6 +127,10 @@ export const PoiFilterPanel = ({ const [allKombis, setAllKombis] = useState([]); const allKombisRef = useRef([]); const initializedForMap = useRef(null); + const onFilterChangeRef = useRef(onFilterChange); + onFilterChangeRef.current = onFilterChange; + const initialFilterStateRef = useRef(initialFilterState); + const userHasInteracted = useRef(false); // Extract categories from vector tile features once the map is available const extractCategories = useCallback((map: any) => { @@ -118,7 +158,31 @@ export const PoiFilterPanel = ({ allKombisRef.current = kombis; setAllKombis(kombis); setCategories(sorted.map((ll) => ({ key: ll, label: ll }))); - setAdvancedFilterState({ positiv: sorted, negativ: [] }); + + // Restore persisted state or default to all positiv + const stored = initialFilterStateRef.current; + let restoredState: AdvancedFilterState; + if (stored && Object.keys(stored).length > 0) { + restoredState = deserializeFilterState(stored); + } else { + restoredState = { positiv: sorted, negativ: [] }; + } + setAdvancedFilterState(restoredState); + + // Apply persisted filter to the map immediately (in case map was recreated) + const allowed = getAllowedKombis(kombis, restoredState); + const isShowingAll = allowed.length === kombis.length; + if (!isShowingAll) { + const filterExpr = buildPoiFilterExpression(allowed); + const poiLayerIds = findPoiLayerIds(map); + for (const layerId of poiLayerIds) { + try { + map.setFilter(layerId, filterExpr); + } catch (e) { + // Layer may not be ready yet + } + } + } initializedForMap.current = map; }, []); @@ -135,9 +199,11 @@ export const PoiFilterPanel = ({ } }, [maplibreMap, extractCategories]); - // Apply filter to map layers when filter state changes + // Apply filter to map layers and notify parent when filter state changes useEffect(() => { if (!maplibreMap || allKombisRef.current.length === 0) return; + // Skip filter application on mount/restore; the map already has the correct filters + if (!userHasInteracted.current) return; const allowed = getAllowedKombis(allKombisRef.current, advancedFilterState); const isShowingAll = allowed.length === allKombisRef.current.length; @@ -151,7 +217,21 @@ export const PoiFilterPanel = ({ console.error(`[POI_FILTER] Error setting filter on ${layerId}:`, e); } } - }, [advancedFilterState, maplibreMap]); + + if (onFilterChangeRef.current) { + const totalCount = categories.length; + // Count categories changed from default (not in positiv = changed) + const changedCount = totalCount - advancedFilterState.positiv.length; + onFilterChangeRef.current( + { + activeCount: changedCount, + totalCount, + isShowingAll, + }, + serializeFilterState(advancedFilterState) + ); + } + }, [advancedFilterState, maplibreMap, categories.length]); // Compute pie chart data from the current filter state const { pieChartData, pieChartColors } = useMemo(() => { @@ -192,7 +272,10 @@ export const PoiFilterPanel = ({ { + userHasInteracted.current = true; + setAdvancedFilterState(state); + }} pieChartData={pieChartData} pieChartColors={pieChartColors} width={width} From 724acb3727331a382563321d2971d95236416f33 Mon Sep 17 00:00:00 2001 From: David Glogaza Date: Fri, 27 Mar 2026 13:54:47 +0100 Subject: [PATCH 07/27] fix filter restore after refresh --- .../layers/GeoportalLayerButton.tsx | 60 ++------ libraries/mapping/components/src/index.ts | 2 + .../src/lib/components/PoiFilterPanel.tsx | 70 +--------- .../src/lib/components/poiFilterUtils.ts | 76 +++++++++++ .../lib/components/useRestoreLayerFilter.ts | 128 ++++++++++++++++++ 5 files changed, 221 insertions(+), 115 deletions(-) create mode 100644 libraries/mapping/components/src/lib/components/poiFilterUtils.ts create mode 100644 libraries/mapping/components/src/lib/components/useRestoreLayerFilter.ts diff --git a/apps/geoportal/src/app/components/layers/GeoportalLayerButton.tsx b/apps/geoportal/src/app/components/layers/GeoportalLayerButton.tsx index 62d562575..3420212db 100644 --- a/apps/geoportal/src/app/components/layers/GeoportalLayerButton.tsx +++ b/apps/geoportal/src/app/components/layers/GeoportalLayerButton.tsx @@ -55,8 +55,7 @@ import "./tabs.css"; import { LayerButton, LayerIcon, - buildFilterExpression, - captureOriginalFilters, + useRestoreLayerFilter, } from "@carma-mapping/components"; import { Badge, Spin } from "antd"; import { LoadingOutlined } from "@ant-design/icons"; @@ -156,56 +155,13 @@ const GeoportalLayerButton = ({ } }, [layersLength]); - const filterAppliedRef = useRef(false); - useEffect(() => { - if (!layer.filterConfig || !layer.filterState || filterAppliedRef.current) - return; - if (layer.filterConfig.filterType === "poi") return; - const filterConfig = layer.filterConfig; - - const mapEntry = maplibreMaps?.find((entry) => entry.id === id); - if (!mapEntry?.map) return; - - const libreMap = mapEntry.map; - try { - const originals = captureOriginalFilters( - filterConfig.layerPattern, - libreMap - ); - - const filterExpression = buildFilterExpression( - filterConfig, - layer.filterState - ); - - Object.keys(originals).forEach((layerId) => { - try { - const origFilter = originals[layerId]; - let combinedFilter = filterExpression; - - if (origFilter && filterExpression) { - combinedFilter = ["all", origFilter, filterExpression]; - } else if (origFilter && !filterExpression) { - combinedFilter = origFilter; - } - - libreMap.setFilter(layerId, combinedFilter); - } catch (error) { - console.error( - `[FilterRestore] Error setting filter on layer ${layerId}:`, - error - ); - } - }); - - filterAppliedRef.current = true; - } catch (error) { - console.error( - `[FilterRestore] Error restoring filters for ${id}:`, - error - ); - } - }, [layer.filterConfig, layer.filterState, maplibreMaps, id]); + const layerMaplibreMap = + maplibreMaps?.find((entry) => entry.id === id)?.map ?? null; + useRestoreLayerFilter( + layer.filterConfig, + layer.filterState, + layerMaplibreMap + ); const isCurrentlyVisible = () => { if (zoom >= layer?.props?.maxZoom || zoom <= layer?.props?.minZoom) { diff --git a/libraries/mapping/components/src/index.ts b/libraries/mapping/components/src/index.ts index a0143799d..8ed4d2a96 100644 --- a/libraries/mapping/components/src/index.ts +++ b/libraries/mapping/components/src/index.ts @@ -79,3 +79,5 @@ export { PoiFilterPanel, type PoiFilterPanelProps, } from "./lib/components/PoiFilterPanel"; + +export { useRestoreLayerFilter } from "./lib/components/useRestoreLayerFilter"; diff --git a/libraries/mapping/components/src/lib/components/PoiFilterPanel.tsx b/libraries/mapping/components/src/lib/components/PoiFilterPanel.tsx index ef68e5618..1bd879a1c 100644 --- a/libraries/mapping/components/src/lib/components/PoiFilterPanel.tsx +++ b/libraries/mapping/components/src/lib/components/PoiFilterPanel.tsx @@ -5,6 +5,13 @@ import { type AdvancedFilterState, } from "./AdvancedFilterPanel"; import type { FilterInfo } from "./GenericFilterButtonsFactory"; +import { + getAllowedKombis, + buildPoiFilterExpression, + findPoiLayerIds, + serializeFilterState, + deserializeFilterState, +} from "./poiFilterUtils"; // Color mapping for lebenslage combinations (sorted alphabetically) const POI_COLORS: Record = { @@ -42,69 +49,6 @@ function getColorForCombination(combination: string): string { return POI_COLORS[combination] || hashColor(combination); } -function getAllowedKombis( - allKombis: string[], - filterState: AdvancedFilterState -): string[] { - return allKombis.filter((kombi) => { - const ll = kombi.split(", "); - for (const lebenslage of ll) { - if (filterState.negativ.includes(lebenslage)) return false; - } - return ll.some((l) => filterState.positiv.includes(l)); - }); -} - -function buildPoiFilterExpression(allowedKombis: string[]): any[] | null { - if (allowedKombis.length === 0) { - return ["==", ["get", "kombi"], "___HIDE_ALL___"]; - } - return [ - "any", - ["!", ["has", "kombi"]], - ["==", ["get", "kombi"], ""], - ["match", ["get", "kombi"], allowedKombis, true, false], - ]; -} - -function findPoiLayerIds(map: any): string[] { - const style = map.getStyle(); - if (!style?.layers) return []; - return style.layers - .filter( - (l: any) => l["source-layer"] === "poi" && l.id !== "poi-images-selection" - ) - .map((l: any) => l.id); -} - -/** - * Serialize AdvancedFilterState to Record for store persistence. - * true = positiv, false = negativ, absent = neutral. - */ -function serializeFilterState( - state: AdvancedFilterState -): Record { - const result: Record = {}; - for (const key of state.positiv) result[key] = true; - for (const key of state.negativ) result[key] = false; - return result; -} - -/** - * Deserialize Record back to AdvancedFilterState. - */ -function deserializeFilterState( - stored: Record -): AdvancedFilterState { - const positiv: string[] = []; - const negativ: string[] = []; - for (const [key, value] of Object.entries(stored)) { - if (value === true) positiv.push(key); - else if (value === false) negativ.push(key); - } - return { positiv, negativ }; -} - export interface PoiFilterPanelProps { maplibreMap: any; width?: number; diff --git a/libraries/mapping/components/src/lib/components/poiFilterUtils.ts b/libraries/mapping/components/src/lib/components/poiFilterUtils.ts new file mode 100644 index 000000000..b766ea9d8 --- /dev/null +++ b/libraries/mapping/components/src/lib/components/poiFilterUtils.ts @@ -0,0 +1,76 @@ +import type { AdvancedFilterState } from "./AdvancedFilterPanel"; + +/** + * Given all unique kombi values and the current filter state, return + * those kombi values whose features should be visible. + */ +export function getAllowedKombis( + allKombis: string[], + filterState: AdvancedFilterState +): string[] { + return allKombis.filter((kombi) => { + const ll = kombi.split(", "); + for (const lebenslage of ll) { + if (filterState.negativ.includes(lebenslage)) return false; + } + return ll.some((l) => filterState.positiv.includes(l)); + }); +} + +/** + * Build a MapLibre filter expression that only shows features with allowed kombi values. + */ +export function buildPoiFilterExpression( + allowedKombis: string[] +): any[] | null { + if (allowedKombis.length === 0) { + return ["==", ["get", "kombi"], "___HIDE_ALL___"]; + } + return [ + "any", + ["!", ["has", "kombi"]], + ["==", ["get", "kombi"], ""], + ["match", ["get", "kombi"], allowedKombis, true, false], + ]; +} + +/** + * Find all layer IDs in the map that use source-layer "poi" (excluding the selection layer). + */ +export function findPoiLayerIds(map: any): string[] { + const style = map.getStyle(); + if (!style?.layers) return []; + return style.layers + .filter( + (l: any) => l["source-layer"] === "poi" && l.id !== "poi-images-selection" + ) + .map((l: any) => l.id); +} + +/** + * Serialize AdvancedFilterState to Record for store persistence. + * true = positiv, false = negativ, absent = neutral. + */ +export function serializeFilterState( + state: AdvancedFilterState +): Record { + const result: Record = {}; + for (const key of state.positiv) result[key] = true; + for (const key of state.negativ) result[key] = false; + return result; +} + +/** + * Deserialize Record back to AdvancedFilterState. + */ +export function deserializeFilterState( + stored: Record +): AdvancedFilterState { + const positiv: string[] = []; + const negativ: string[] = []; + for (const [key, value] of Object.entries(stored)) { + if (value === true) positiv.push(key); + else if (value === false) negativ.push(key); + } + return { positiv, negativ }; +} diff --git a/libraries/mapping/components/src/lib/components/useRestoreLayerFilter.ts b/libraries/mapping/components/src/lib/components/useRestoreLayerFilter.ts new file mode 100644 index 000000000..0ece20da9 --- /dev/null +++ b/libraries/mapping/components/src/lib/components/useRestoreLayerFilter.ts @@ -0,0 +1,128 @@ +import { useEffect, useRef } from "react"; +import type { FilterConfig } from "@carma/types"; +import { + buildFilterExpression, + captureOriginalFilters, +} from "./GenericFilterButtonsFactory"; +import { + deserializeFilterState, + getAllowedKombis, + buildPoiFilterExpression, + findPoiLayerIds, +} from "./poiFilterUtils"; + +/** + * Restores persisted filter state to the maplibre map on page reload. + * Handles both "buttons" (generic) and "poi" filter types. + */ +export function useRestoreLayerFilter( + filterConfig: FilterConfig | undefined, + filterState: Record | undefined, + maplibreMap: any | null +) { + const appliedRef = useRef(false); + + useEffect(() => { + if (!filterConfig || !filterState || !maplibreMap || appliedRef.current) + return; + + try { + if (filterConfig.filterType === "poi") { + const advanced = deserializeFilterState(filterState); + // If everything is positiv and nothing negativ, no filter needed + if (advanced.negativ.length === 0) { + // We need the kombis to check if all are positiv, but we don't + // have them yet. Query the source to find out. + const features = maplibreMap.querySourceFeatures("poi-source", { + sourceLayer: "poi", + }); + if (features.length === 0) { + // Tiles not loaded yet, retry on sourcedata + const onSourceData = () => { + if (appliedRef.current) return; + const f = maplibreMap.querySourceFeatures("poi-source", { + sourceLayer: "poi", + }); + if (f.length === 0) return; + + applyPoiFilter(maplibreMap, filterState); + appliedRef.current = true; + maplibreMap.off("sourcedata", onSourceData); + }; + maplibreMap.on("sourcedata", onSourceData); + return () => maplibreMap.off("sourcedata", onSourceData); + } + + applyPoiFilter(maplibreMap, filterState); + } else { + // Has negativ entries, definitely needs filtering + applyPoiFilter(maplibreMap, filterState); + } + } else { + const originals = captureOriginalFilters( + filterConfig.layerPattern, + maplibreMap + ); + + const filterExpression = buildFilterExpression( + filterConfig, + filterState + ); + + Object.keys(originals).forEach((layerId) => { + try { + const origFilter = originals[layerId]; + let combinedFilter = filterExpression; + + if (origFilter && filterExpression) { + combinedFilter = ["all", origFilter, filterExpression]; + } else if (origFilter && !filterExpression) { + combinedFilter = origFilter; + } + + maplibreMap.setFilter(layerId, combinedFilter); + } catch (error) { + console.error( + `[FilterRestore] Error setting filter on layer ${layerId}:`, + error + ); + } + }); + } + + appliedRef.current = true; + } catch (error) { + console.error("[FilterRestore] Error restoring filters:", error); + } + }, [filterConfig, filterState, maplibreMap]); +} + +function applyPoiFilter(map: any, stored: Record) { + const advanced = deserializeFilterState(stored); + + // Get all kombis from loaded features + const features = map.querySourceFeatures("poi-source", { + sourceLayer: "poi", + }); + const kombiSet = new Set(); + for (const f of features) { + const kombi = f.properties?.kombi; + if (typeof kombi === "string" && kombi.length > 0) { + kombiSet.add(kombi); + } + } + const allKombis = Array.from(kombiSet); + + const allowed = getAllowedKombis(allKombis, advanced); + const isShowingAll = allowed.length === allKombis.length; + const filterExpr = isShowingAll ? null : buildPoiFilterExpression(allowed); + + const poiLayerIds = findPoiLayerIds(map); + for (const layerId of poiLayerIds) { + try { + map.setFilter(layerId, filterExpr); + } catch (e) { + console.error(`[FilterRestore] Error setting filter on ${layerId}:`, e); + } + } +} From c3ba74814b826c51f827dbf01ea968eaf552e00d Mon Sep 17 00:00:00 2001 From: David Glogaza Date: Fri, 27 Mar 2026 15:57:09 +0100 Subject: [PATCH 08/27] create ng stadtplan topicmap --- apps/topicmaps/ng-stadtplan/index.html | 22 ++++ .../topicmaps/ng-stadtplan/postcss.config.cjs | 13 ++ apps/topicmaps/ng-stadtplan/project.json | 69 +++++++++++ .../topicmaps/ng-stadtplan/public/favicon.ico | Bin 0 -> 1150 bytes apps/topicmaps/ng-stadtplan/src/app/App.tsx | 112 ++++++++++++++++++ apps/topicmaps/ng-stadtplan/src/app/Menu.tsx | 109 +++++++++++++++++ .../ng-stadtplan/src/app/helper/colors.ts | 15 +++ .../ng-stadtplan/src/app/helper/constants.ts | 104 ++++++++++++++++ .../ng-stadtplan/src/app/helper/filter.ts | 98 +++++++++++++++ .../src/app/helper/pieChartStats.ts | 37 ++++++ apps/topicmaps/ng-stadtplan/src/main.tsx | 35 ++++++ apps/topicmaps/ng-stadtplan/src/styles.css | 44 +++++++ apps/topicmaps/ng-stadtplan/src/version.json | 3 + .../ng-stadtplan/tailwind.config.cjs | 15 +++ apps/topicmaps/ng-stadtplan/tsconfig.json | 6 + apps/topicmaps/ng-stadtplan/vite.config.ts | 63 ++++++++++ 16 files changed, 745 insertions(+) create mode 100644 apps/topicmaps/ng-stadtplan/index.html create mode 100644 apps/topicmaps/ng-stadtplan/postcss.config.cjs create mode 100644 apps/topicmaps/ng-stadtplan/project.json create mode 100644 apps/topicmaps/ng-stadtplan/public/favicon.ico create mode 100644 apps/topicmaps/ng-stadtplan/src/app/App.tsx create mode 100644 apps/topicmaps/ng-stadtplan/src/app/Menu.tsx create mode 100644 apps/topicmaps/ng-stadtplan/src/app/helper/colors.ts create mode 100644 apps/topicmaps/ng-stadtplan/src/app/helper/constants.ts create mode 100644 apps/topicmaps/ng-stadtplan/src/app/helper/filter.ts create mode 100644 apps/topicmaps/ng-stadtplan/src/app/helper/pieChartStats.ts create mode 100644 apps/topicmaps/ng-stadtplan/src/main.tsx create mode 100644 apps/topicmaps/ng-stadtplan/src/styles.css create mode 100644 apps/topicmaps/ng-stadtplan/src/version.json create mode 100644 apps/topicmaps/ng-stadtplan/tailwind.config.cjs create mode 100644 apps/topicmaps/ng-stadtplan/tsconfig.json create mode 100644 apps/topicmaps/ng-stadtplan/vite.config.ts diff --git a/apps/topicmaps/ng-stadtplan/index.html b/apps/topicmaps/ng-stadtplan/index.html new file mode 100644 index 000000000..e7a980db0 --- /dev/null +++ b/apps/topicmaps/ng-stadtplan/index.html @@ -0,0 +1,22 @@ + + + + + Online-Stadtplan Wuppertal + + + + + + + + + + +
+ + + diff --git a/apps/topicmaps/ng-stadtplan/postcss.config.cjs b/apps/topicmaps/ng-stadtplan/postcss.config.cjs new file mode 100644 index 000000000..a5b8c605b --- /dev/null +++ b/apps/topicmaps/ng-stadtplan/postcss.config.cjs @@ -0,0 +1,13 @@ +/* postcss.config.cjs */ +const path = require("path"); + +module.exports = { + plugins: { + "postcss-import": {}, + "tailwindcss/nesting": {}, + tailwindcss: { + config: path.join(__dirname, "tailwind.config.cjs"), + }, + autoprefixer: {}, + }, +}; diff --git a/apps/topicmaps/ng-stadtplan/project.json b/apps/topicmaps/ng-stadtplan/project.json new file mode 100644 index 000000000..2f2e69109 --- /dev/null +++ b/apps/topicmaps/ng-stadtplan/project.json @@ -0,0 +1,69 @@ +{ + "name": "ng-stadtplan", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "apps/topicmaps/ng-stadtplan/src", + "projectType": "application", + "tags": [], + "targets": { + "build": { + "executor": "@nx/vite:build", + "outputs": ["{options.outputPath}"], + "defaultConfiguration": "production", + "options": { + "outputPath": "dist/apps/topicmaps/ng-stadtplan" + }, + "configurations": { + "development": { + "mode": "development" + }, + "production": { + "mode": "production" + } + } + }, + "serve": { + "executor": "@nx/vite:dev-server", + "defaultConfiguration": "development", + "options": { + "buildTarget": "ng-stadtplan:build", + "host": "0.0.0.0" + }, + "configurations": { + "development": { + "buildTarget": "ng-stadtplan:build:development", + "hmr": true + }, + "production": { + "buildTarget": "ng-stadtplan:build:production", + "hmr": false + } + } + }, + "preview": { + "executor": "@nx/vite:preview-server", + "defaultConfiguration": "development", + "options": { + "buildTarget": "ng-stadtplan:build" + }, + "configurations": { + "development": { + "buildTarget": "ng-stadtplan:build:development" + }, + "production": { + "buildTarget": "ng-stadtplan:build:production" + } + }, + "dependsOn": ["build"] + }, + "test": { + "executor": "@nx/vite:test", + "outputs": ["{options.reportsDirectory}"], + "options": { + "reportsDirectory": "../../../coverage/apps/topicmaps/ng-stadtplan" + } + }, + "lint": { + "executor": "@nx/eslint:lint" + } + } +} diff --git a/apps/topicmaps/ng-stadtplan/public/favicon.ico b/apps/topicmaps/ng-stadtplan/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..c0b440dacd93bbddf16dc3191bf8eebbb5dc0554 GIT binary patch literal 1150 zcmcJPv1-Cl6oyYk9D+jzk&apr92~nUojZ4N(N){QK0_r4?nRt^2G{xs-2^u$aS|sH z5z_BAy>JYoNzjBZ_vYO5{eepo5r@+Kfig7U8P;HYi^+R9mqEP^E7fbA==e=fMrUyU7Q@pyE$idCuutfZ zm2dV}XUCcUDE}pezfgW5g+H;r8S%doX1quI9R3K7F>>bgxZ1aK>W}aWJ@|kP=>6Pr zU$gz!Mtbf0Yci%#x#s9~`ahuyPr81ax$ynN0Qw?(M`R02{uU?v*G([]); + const [lebenslagen, setLebenslagen] = useState([]); + const [filterState, setFilterState] = useState({ + positiv: [], + negativ: [], + }); + const allFeaturesRef = useRef([]); + const allKombisRef = useRef([]); + const filterStateRef = useRef(filterState); + filterStateRef.current = filterState; + + // Capture original features and extract lebenslagen on first filterFunction call + const handleFilter = useCallback( + (map: any, layers: any) => { + layers?.forEach((layer: any, index: number) => { + if (layer.type !== "geojson") return; + + const sourceId = `geojson-source-${index}`; + const styleSource = map.getStyle().sources[sourceId] as any; + if (!styleSource?.data?.features) return; + + // Extract lebenslagen and kombi values only once + if (allKombisRef.current.length === 0) { + const data = extractLebenslagen(styleSource.data.features); + allFeaturesRef.current = data.features; + allKombisRef.current = data.kombis; + setAllFeatures(data.features); + setLebenslagen(data.lebenslagen); + + const initialFilter = { positiv: data.lebenslagen, negativ: [] }; + filterStateRef.current = initialFilter; + setFilterState(initialFilter); + } + + // Apply current filter (also handles style rebuilds) + applyPoiFilter( + map, + allFeaturesRef.current, + allKombisRef.current, + filterStateRef.current + ); + }); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + + // Re-apply filter dynamically when the user changes filter state + useEffect(() => { + if (allKombisRef.current.length === 0 || lebenslagen.length === 0) return; + + const map = (window as any).__carmaMap; + if (!map) return; + + applyPoiFilter( + map, + allFeaturesRef.current, + allKombisRef.current, + filterState + ); + }, [filterState, lebenslagen]); + + const { pieChartData, pieChartColors } = useMemo( + () => computePieChartStats(allFeatures, allKombisRef.current, filterState), + [filterState, allFeatures] + ); + + const categories = useMemo( + () => lebenslagen.map((ll) => ({ key: ll, label: ll })), + [lebenslagen] + ); + + return ( + <> + + {}} + mapEngine="maplibre" + exposeMapToWindow + overrideGlyphs="https://tiles.cismet.de/fonts/{fontstack}/{range}.pbf" + onProgressUpdate={handleProgressUpdate} + libreLayers={[POI_LAYER_CONFIG]} + filterFunction={handleFilter} + modalMenu={ + + } + /> + + ); +} diff --git a/apps/topicmaps/ng-stadtplan/src/app/Menu.tsx b/apps/topicmaps/ng-stadtplan/src/app/Menu.tsx new file mode 100644 index 000000000..77a5a55b4 --- /dev/null +++ b/apps/topicmaps/ng-stadtplan/src/app/Menu.tsx @@ -0,0 +1,109 @@ +import { useContext } from "react"; +import CustomizationContextProvider from "react-cismap/contexts/CustomizationContextProvider"; +import { UIDispatchContext } from "react-cismap/contexts/UIContextProvider"; +import DefaultSettingsPanel from "react-cismap/topicmaps/menu/DefaultSettingsPanel"; +import ModalApplicationMenu from "react-cismap/topicmaps/menu/ModalApplicationMenu"; +import Section from "react-cismap/topicmaps/menu/Section"; +import { GenericDigitalTwinReferenceSection } from "@carma-collab/wuppertal/commons"; +import { + KompaktanleitungSection, + MenuTitle, + MenuIntroduction, + Footer, +} from "@carma-collab/wuppertal/stadtplan"; +import versionData from "../version.json"; +import { getApplicationVersion } from "@carma-commons/utils"; +import { PreviewLibreMap } from "@carma-mapping/engines/maplibre"; +import { + AdvancedFilterPanel, + type AdvancedFilterCategory, + type AdvancedFilterState, +} from "@carma-mapping/components"; + +interface MenuProps { + categories?: AdvancedFilterCategory[]; + filterState?: AdvancedFilterState; + onFilterStateChange?: (state: AdvancedFilterState) => void; + pieChartData?: [string, number][]; + pieChartColors?: string[]; +} + +const Menu = ({ + categories, + filterState, + onFilterStateChange, + pieChartData, + pieChartColors, +}: MenuProps) => { + const { setAppMenuActiveMenuSection } = + useContext(UIDispatchContext); + + const hasFilter = categories && filterState && onFilterStateChange; + + const filterTitle = + filterState && + (filterState.positiv.length > 0 || filterState.negativ.length > 0) + ? `Filter (${filterState.positiv.length} aktiv, ${filterState.negativ.length} ausgeschlossen)` + : "Filter"; + + return ( + + } + menuFooter={ +
+ } + menuIntroduction={ + + } + menuSections={[ + ...(hasFilter + ? [ +
+ } + />, + ] + : []), + { + return ( + symbol + ); + }} + overridingMapPreview={} + />, + , + , + ]} + /> + + ); +}; +export default Menu; diff --git a/apps/topicmaps/ng-stadtplan/src/app/helper/colors.ts b/apps/topicmaps/ng-stadtplan/src/app/helper/colors.ts new file mode 100644 index 000000000..bd4522128 --- /dev/null +++ b/apps/topicmaps/ng-stadtplan/src/app/helper/colors.ts @@ -0,0 +1,15 @@ +import { POI_COLORS } from "./constants"; + +/** Deterministic fallback color for combinations not in POI_COLORS */ +function hashColor(str: string): string { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = str.charCodeAt(i) + ((hash << 5) - hash); + } + const h = Math.abs(hash) % 360; + return `hsl(${h}, 30%, 50%)`; +} + +export function getColorForCombination(combination: string): string { + return POI_COLORS[combination] || hashColor(combination); +} diff --git a/apps/topicmaps/ng-stadtplan/src/app/helper/constants.ts b/apps/topicmaps/ng-stadtplan/src/app/helper/constants.ts new file mode 100644 index 000000000..49489293e --- /dev/null +++ b/apps/topicmaps/ng-stadtplan/src/app/helper/constants.ts @@ -0,0 +1,104 @@ +export const POI_SOURCE_ID = "geojson-source-0"; + +/** Predefined color mapping for lebenslage combinations */ +export const POI_COLORS: Record = { + "Freizeit, Sport": "#194761", + Mobilität: "#6BB6D7", + "Erholung, Religion": "#094409", + Gesellschaft: "#B0CBEC", + Religion: "#0D0D0D", + Gesundheit: "#CB0D0D", + "Erholung, Freizeit": "#638555", + Sport: "#0141CF", + "Freizeit, Kultur": "#B27A08", + "Gesellschaft, Kultur": "#E26B0A", + "öffentliche Dienstleistungen": "#417DD4", + Orientierung: "#BFBFBF", + Bildung: "#FFC000", + Stadtbild: "#695656", + "Gesellschaft, öffentliche Dienstleistungen": "#569AD6", + "Dienstleistungen, Freizeit": "#26978F", + Dienstleistungen: "#538DD5", + "Bildung, Freizeit": "#BBAA1E", + Kinderbetreuung: "#00A0B0", +}; + +export const crossLinkApps = [ + { + on: ["Kinderbetreuung"], + name: "Kita-Finder", + bsStyle: "success", + backgroundColor: null, + link: "https://digital-twin-wuppertal-live.github.io/kita-finder/", + target: "_kitas", + }, + { + on: ["Sport", "Freizeit"], + name: "Bäderkarte", + bsStyle: "primary", + backgroundColor: null, + link: "https://digital-twin-wuppertal-live.github.io/baederkarte/", + target: "_baeder", + }, + { + on: ["Kultur"], + name: "Kulturstadtplan", + bsStyle: "warning", + backgroundColor: null, + link: "https://digital-twin-wuppertal-live.github.io/kulturstadtplan/", + target: "_kulturstadtplan", + }, + { + on: ["Mobilität"], + name: "Park+Ride-Karte", + bsStyle: "warning", + backgroundColor: "#62B7D5", + link: "https://digital-twin-wuppertal-live.github.io/xandride/", + target: "_xandride", + }, + + { + on: ["Mobilität"], + name: "E-Auto-Ladestationskarte", + bsStyle: "warning", + backgroundColor: "#003E7A", + link: "https://digital-twin-wuppertal-live.github.io/elektromobilitaet/", + target: "_elektromobilitaet", + }, + { + on: ["Mobilität"], + name: "E-Fahrrad-Karte", + bsStyle: "warning", + backgroundColor: "#326C88", //'#15A44C', //'#EC7529', + link: "https://digital-twin-wuppertal-live.github.io/ebikes/", + target: "_ebikes", + }, + // { + // on: ['Gesundheit'], + // name: 'Corona-Präventionskarte', + // bsStyle: 'warning', + // backgroundColor: '#BD000E', //'#15A44C', //'#EC7529', + // link: 'https://topicmaps-wuppertal.github.io/corona-praevention/#/?title', + // target: '_corona', + // }, + + // { on: ["Sport"], name: "Sporthallen", bsStyle: "default", + // backgroundColor: null, link: "/#/ehrenamt", target: "_hallen" } +]; + +export const POI_LAYER_CONFIG = { + type: "geojson" as const, + name: "POIs", + data: "https://tiles.cismet.de/poi/poi.json", + infoboxMapping: [ + "foto: p.foto", + "headerColor:p.schrift", + "header:p.kombi", + "title:p.geographicidentifier", + "additionalInfo:p.adresse", + "subtitle: p.info", + "url:p.url", + "tel:p.telefon", + "email:p.email", + ], +}; diff --git a/apps/topicmaps/ng-stadtplan/src/app/helper/filter.ts b/apps/topicmaps/ng-stadtplan/src/app/helper/filter.ts new file mode 100644 index 000000000..0c90a3b92 --- /dev/null +++ b/apps/topicmaps/ng-stadtplan/src/app/helper/filter.ts @@ -0,0 +1,98 @@ +import type { AdvancedFilterState } from "@carma-mapping/components"; +import { POI_SOURCE_ID } from "./constants"; + +export interface LebenslagenData { + lebenslagen: string[]; + kombis: string[]; + features: any[]; +} + +export function extractLebenslagen(features: any[]): LebenslagenData { + const llSet = new Set(); + const kombiSet = new Set(); + + for (const f of features) { + const kombi = f.properties?.kombi; + if (typeof kombi === "string" && kombi.length > 0) { + kombiSet.add(kombi); + for (const ll of kombi.split(", ")) { + llSet.add(ll); + } + } + } + + return { + lebenslagen: Array.from(llSet).sort(), + kombis: Array.from(kombiSet), + features, + }; +} + +export function getAllowedKombis( + allKombis: string[], + filterState: AdvancedFilterState +): string[] { + return allKombis.filter((kombi) => { + const ll = kombi.split(", "); + for (const lebenslage of ll) { + if (filterState.negativ.includes(lebenslage)) return false; + } + return ll.some((l) => filterState.positiv.includes(l)); + }); +} + +function buildPoiFilterExpression(allowedKombis: string[]): any[] { + if (allowedKombis.length === 0) { + return ["==", ["get", "kombi"], "___HIDE_ALL___"]; + } + return [ + "any", + ["!", ["has", "kombi"]], + ["==", ["get", "kombi"], ""], + ["match", ["get", "kombi"], allowedKombis, true, false], + ]; +} + +export function applyPoiFilter( + map: any, + allFeatures: any[], + allKombis: string[], + filterState: AdvancedFilterState +) { + const allowedKombis = getAllowedKombis(allKombis, filterState); + const isShowingAll = allowedKombis.length === allKombis.length; + const filterExpr = isShowingAll + ? null + : buildPoiFilterExpression(allowedKombis); + + const layers = map.getStyle()?.layers || []; + for (const layer of layers) { + if (layer.id.startsWith(POI_SOURCE_ID) && !layer.id.endsWith("-clusters")) { + try { + map.setFilter(layer.id, filterExpr); + } catch (e) { + console.error(`Error setting filter on layer ${layer.id}:`, e); + } + } + } + + const source = map.getSource(POI_SOURCE_ID); + if (source && "setData" in source) { + if (isShowingAll) { + (source as any).setData({ + type: "FeatureCollection", + features: allFeatures, + }); + } else { + const allowedSet = new Set(allowedKombis); + (source as any).setData({ + type: "FeatureCollection", + features: allFeatures.filter((f: any) => { + const kombi = f.properties?.kombi; + if (typeof kombi !== "string" || kombi.length === 0) return true; + return allowedSet.has(kombi); + }), + }); + } + } +} diff --git a/apps/topicmaps/ng-stadtplan/src/app/helper/pieChartStats.ts b/apps/topicmaps/ng-stadtplan/src/app/helper/pieChartStats.ts new file mode 100644 index 000000000..8ab3216e5 --- /dev/null +++ b/apps/topicmaps/ng-stadtplan/src/app/helper/pieChartStats.ts @@ -0,0 +1,37 @@ +import type { AdvancedFilterState } from "@carma-mapping/components"; +import { getAllowedKombis } from "./filter"; +import { getColorForCombination } from "./colors"; + +export interface PieChartResult { + pieChartData: [string, number][]; + pieChartColors: string[]; +} + +export function computePieChartStats( + allFeatures: any[], + allKombis: string[], + filterState: AdvancedFilterState +): PieChartResult { + if (allFeatures.length === 0) { + return { pieChartData: [], pieChartColors: [] }; + } + + const allowedKombis = new Set(getAllowedKombis(allKombis, filterState)); + const stats: Record = {}; + const colors: Record = {}; + + for (const f of allFeatures) { + const kombi = f.properties?.kombi; + if (typeof kombi !== "string" || kombi.length === 0) continue; + if (!allowedKombis.has(kombi)) continue; + const key = kombi.split(", ").slice().sort().join(", "); + stats[key] = (stats[key] || 0) + 1; + if (!colors[key]) { + colors[key] = getColorForCombination(key); + } + } + + const data: [string, number][] = Object.entries(stats); + const colorArr = data.map(([key]) => colors[key]); + return { pieChartData: data, pieChartColors: colorArr }; +} diff --git a/apps/topicmaps/ng-stadtplan/src/main.tsx b/apps/topicmaps/ng-stadtplan/src/main.tsx new file mode 100644 index 000000000..3e2d57a75 --- /dev/null +++ b/apps/topicmaps/ng-stadtplan/src/main.tsx @@ -0,0 +1,35 @@ +import { StrictMode } from "react"; +import * as ReactDOM from "react-dom/client"; +import { + SelectionProvider, + GazDataProvider, +} from "@carma-appframeworks/portals"; +import { SandboxedEvalProvider } from "@carma-commons/sandbox-eval"; +import { LibreContextProvider } from "@carma-mapping/engines/maplibre"; +import TopicMapContextProvider from "react-cismap/contexts/TopicMapContextProvider"; +import { defaultGazDataConfig } from "@carma-commons/resources"; +import { cjsGlobalShim } from "@carma-commons/utils"; +import App from "./app/App"; +import "./styles.css"; + +cjsGlobalShim(); + +const root = ReactDOM.createRoot( + document.getElementById("root") as HTMLElement +); + +root.render( + + + + + + + + + + + + + +); diff --git a/apps/topicmaps/ng-stadtplan/src/styles.css b/apps/topicmaps/ng-stadtplan/src/styles.css new file mode 100644 index 000000000..a0692df8d --- /dev/null +++ b/apps/topicmaps/ng-stadtplan/src/styles.css @@ -0,0 +1,44 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +html, +body { + overscroll-behavior: contain; +} + +.ant-tooltip-arrow { + display: none !important; +} + +.modal-footer img[alt="Logo DigiTal Zwilling"] { + max-height: 60px; +} + +/* Make lists look good again */ +ul { + list-style: disc !important; + display: block; + list-style-type: disc; + margin-block-start: 1em; + margin-block-end: 1em; + margin-inline-start: 0px; + margin-inline-end: 0px; + padding-inline-start: 40px; + unicode-bidi: isolate; +} +.modal-content svg, +.modal-content img { + display: inline; +} + +div.leaflet-top.leaflet-right > .leaflet-control { + position: fixed; + right: max(10px, env(safe-area-inset-right)); + margin-right: 0 !important; +} + +/* Fix Tailwind's .collapse overriding Bootstrap's accordion collapse */ +.collapse.show { + visibility: visible; +} diff --git a/apps/topicmaps/ng-stadtplan/src/version.json b/apps/topicmaps/ng-stadtplan/src/version.json new file mode 100644 index 000000000..6a3252ea8 --- /dev/null +++ b/apps/topicmaps/ng-stadtplan/src/version.json @@ -0,0 +1,3 @@ +{ + "version": "0.1.0" +} diff --git a/apps/topicmaps/ng-stadtplan/tailwind.config.cjs b/apps/topicmaps/ng-stadtplan/tailwind.config.cjs new file mode 100644 index 000000000..c70a51dd3 --- /dev/null +++ b/apps/topicmaps/ng-stadtplan/tailwind.config.cjs @@ -0,0 +1,15 @@ +const { join } = require("path"); +const { workspaceRoot } = require('@nx/devkit'); +const { createGlobPatternsForDependencies } = require("@nx/react/tailwind"); + +const preset = require(join(workspaceRoot, 'tailwind.preset.cjs')); + +const depsGlobs = createGlobPatternsForDependencies(__dirname); + +module.exports = { + presets: [preset], + content: [ + join(__dirname, "src/**/*!(*.stories|*.spec|*.test).{js,ts,jsx,tsx}"), + ...depsGlobs, + ], +}; diff --git a/apps/topicmaps/ng-stadtplan/tsconfig.json b/apps/topicmaps/ng-stadtplan/tsconfig.json new file mode 100644 index 000000000..cb150df57 --- /dev/null +++ b/apps/topicmaps/ng-stadtplan/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../../tsconfig.legacy.base.json", + "compilerOptions": {}, + "files": [], + "references": [] +} diff --git a/apps/topicmaps/ng-stadtplan/vite.config.ts b/apps/topicmaps/ng-stadtplan/vite.config.ts new file mode 100644 index 000000000..e155539b0 --- /dev/null +++ b/apps/topicmaps/ng-stadtplan/vite.config.ts @@ -0,0 +1,63 @@ +/// +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import { nxViteTsPaths } from "@nx/vite/plugins/nx-tsconfig-paths.plugin"; + +const base = process.env.BASE_URL || "/"; + +export default defineConfig({ + root: __dirname, + cacheDir: "../../../node_modules/.vite/apps/topicmaps/ng-stadtplan", + + server: { + port: 4200, + host: true, + fs: { + allow: ["../../.."], + }, + }, + + preview: { + port: 4300, + host: "localhost", + }, + + plugins: [react(), nxViteTsPaths()], + base: base, + + optimizeDeps: { + include: ["maplibre-gl"], + esbuildOptions: { + target: "es2022", + }, + }, + + esbuild: { + supported: { + "class-static-field": true, + }, + }, + + build: { + outDir: "../../../dist/apps/topicmaps/ng-stadtplan", + reportCompressedSize: true, + commonjsOptions: { + transformMixedEsModules: true, + }, + }, + + test: { + globals: true, + cache: { + dir: "../../../node_modules/.vitest", + }, + environment: "jsdom", + include: ["src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"], + + reporters: ["default"], + coverage: { + reportsDirectory: "../../../coverage/apps/topicmaps/ng-stadtplan", + provider: "v8", + }, + }, +}); From 3fe74a5804771592648b668f0d8c6f61f2b61fff Mon Sep 17 00:00:00 2001 From: David Glogaza Date: Fri, 27 Mar 2026 15:59:30 +0100 Subject: [PATCH 09/27] add ng stadtplan to deployment --- deployment-config.json | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/deployment-config.json b/deployment-config.json index 1895f392a..975ffd193 100644 --- a/deployment-config.json +++ b/deployment-config.json @@ -31,6 +31,33 @@ }, "projectPath": "./dist/apps/topicmaps/stadtplan" }, + "ng-stadtplan": { + "deployment": { + "manual": { + "dev": { + "pages": { + "org": "carma-dev-deployments", + "prj": "topicmaps-ng-stadtplan-wuppertal" + } + }, + "live": { + "pages": { + "org": "digital-twin-wuppertal-live", + "prj": "ng-stadtplan" + } + } + }, + "auto": { + "dev": { + "pages": { + "org": "carma-dev-deployments", + "prj": "topicmaps-ng-stadtplan-wuppertal" + } + } + } + }, + "projectPath": "./dist/apps/topicmaps/ng-stadtplan" + }, "legals": { "deployment": { "manual": { From fb9c5d11b5a1bf7f9ae808f0eb906ec1a4035d5e Mon Sep 17 00:00:00 2001 From: David Glogaza Date: Fri, 27 Mar 2026 16:15:59 +0100 Subject: [PATCH 10/27] move piechart component --- apps/topicmaps/e-auto-ladestation/src/app/PieChart.jsx | 2 +- .../e-bikes/src/app/components/Menu/EBikesPieChart.tsx | 2 +- apps/topicmaps/kita-finder/src/app/KitasPieChart.jsx | 2 +- .../kulturstadtplan/src/app/components/menu/KulturPieChart.tsx | 2 +- apps/topicmaps/stadtplan/src/app/FilterUI.jsx | 2 +- .../src/app/components/Menu/VorhabenkartePieChart.tsx | 2 +- .../x-and-ride/src/app/components/menu/XandRidePieChart.tsx | 2 +- libraries/appframeworks/portals/src/index.ts | 1 - libraries/commons/cismap/src/lib/FilterPieChart.jsx | 2 +- libraries/mapping/components/src/index.ts | 2 ++ .../components/src/lib/components/AdvancedFilterPanel.tsx | 2 +- .../components}/src/lib/components/PieChart.tsx | 0 12 files changed, 11 insertions(+), 10 deletions(-) rename libraries/{appframeworks/portals => mapping/components}/src/lib/components/PieChart.tsx (100%) diff --git a/apps/topicmaps/e-auto-ladestation/src/app/PieChart.jsx b/apps/topicmaps/e-auto-ladestation/src/app/PieChart.jsx index f8e1d1733..f279da0ef 100644 --- a/apps/topicmaps/e-auto-ladestation/src/app/PieChart.jsx +++ b/apps/topicmaps/e-auto-ladestation/src/app/PieChart.jsx @@ -1,7 +1,7 @@ import { useContext } from "react"; import { FeatureCollectionContext } from "react-cismap/contexts/FeatureCollectionContextProvider"; import { getColorForProperties } from "./helper/styler"; -import { PieChart } from "@carma-appframeworks/portals"; +import { PieChart } from "@carma-mapping/components"; const ChartComp = ({ visible = true }) => { const { filteredItems } = useContext(FeatureCollectionContext); diff --git a/apps/topicmaps/e-bikes/src/app/components/Menu/EBikesPieChart.tsx b/apps/topicmaps/e-bikes/src/app/components/Menu/EBikesPieChart.tsx index 6f3bf6ca1..58a0f679b 100644 --- a/apps/topicmaps/e-bikes/src/app/components/Menu/EBikesPieChart.tsx +++ b/apps/topicmaps/e-bikes/src/app/components/Menu/EBikesPieChart.tsx @@ -1,7 +1,7 @@ import { useContext } from "react"; import { FeatureCollectionContext } from "react-cismap/contexts/FeatureCollectionContextProvider"; import { getColorForProperties } from "../../../helper/styler"; -import { PieChart } from "@carma-appframeworks/portals"; +import { PieChart } from "@carma-mapping/components"; const EBikesPieChart = ({ visible = true }) => { const { filteredItems } = useContext( diff --git a/apps/topicmaps/kita-finder/src/app/KitasPieChart.jsx b/apps/topicmaps/kita-finder/src/app/KitasPieChart.jsx index 7a8e13ebf..3a41b9cef 100644 --- a/apps/topicmaps/kita-finder/src/app/KitasPieChart.jsx +++ b/apps/topicmaps/kita-finder/src/app/KitasPieChart.jsx @@ -4,7 +4,7 @@ import { useContext } from "react"; import { FeatureCollectionContext } from "react-cismap/contexts/FeatureCollectionContextProvider"; import { useSelector } from "react-redux"; import { getFeatureRenderingOption } from "./store/slices/ui"; -import { PieChart } from "@carma-appframeworks/portals"; +import { PieChart } from "@carma-mapping/components"; const KitasPieChart = ({ visible = true }) => { const { filteredItems } = useContext(FeatureCollectionContext); diff --git a/apps/topicmaps/kulturstadtplan/src/app/components/menu/KulturPieChart.tsx b/apps/topicmaps/kulturstadtplan/src/app/components/menu/KulturPieChart.tsx index c0c1c415e..5d7add6f1 100644 --- a/apps/topicmaps/kulturstadtplan/src/app/components/menu/KulturPieChart.tsx +++ b/apps/topicmaps/kulturstadtplan/src/app/components/menu/KulturPieChart.tsx @@ -6,7 +6,7 @@ import { getColorFromMainlocationTypeName, textConversion, } from "../../../helper/styler"; -import { PieChart } from "@carma-appframeworks/portals"; +import { PieChart } from "@carma-mapping/components"; const KulturPieChart = ({ visible = true }) => { const { filteredItems } = useContext( diff --git a/apps/topicmaps/stadtplan/src/app/FilterUI.jsx b/apps/topicmaps/stadtplan/src/app/FilterUI.jsx index e9adbec95..23b8b1b8a 100644 --- a/apps/topicmaps/stadtplan/src/app/FilterUI.jsx +++ b/apps/topicmaps/stadtplan/src/app/FilterUI.jsx @@ -21,7 +21,7 @@ import { getColorFromLebenslagenCombination } from "./helper/styler"; import MultiToggleButton from "./MultiToggleButton"; import "url-search-params-polyfill"; -import { PieChart } from "@carma-appframeworks/portals"; +import { PieChart } from "@carma-mapping/components"; const FilterUI = ({ apps = crossLinkApps }) => { const { itemsDictionary, filteredItems, filterState } = useContext( diff --git a/apps/topicmaps/vorhabenkarte/src/app/components/Menu/VorhabenkartePieChart.tsx b/apps/topicmaps/vorhabenkarte/src/app/components/Menu/VorhabenkartePieChart.tsx index de4b7c6e8..a3af1b3bb 100644 --- a/apps/topicmaps/vorhabenkarte/src/app/components/Menu/VorhabenkartePieChart.tsx +++ b/apps/topicmaps/vorhabenkarte/src/app/components/Menu/VorhabenkartePieChart.tsx @@ -1,6 +1,6 @@ import { useContext } from "react"; import { FeatureCollectionContext } from "react-cismap/contexts/FeatureCollectionContextProvider"; -import { PieChart } from "@carma-appframeworks/portals"; +import { PieChart } from "@carma-mapping/components"; const VorhabenkartePieChart = ({ visible = true }) => { const { filteredItems } = useContext( diff --git a/apps/topicmaps/x-and-ride/src/app/components/menu/XandRidePieChart.tsx b/apps/topicmaps/x-and-ride/src/app/components/menu/XandRidePieChart.tsx index 304a2e478..a86b58108 100644 --- a/apps/topicmaps/x-and-ride/src/app/components/menu/XandRidePieChart.tsx +++ b/apps/topicmaps/x-and-ride/src/app/components/menu/XandRidePieChart.tsx @@ -1,7 +1,7 @@ import { useContext } from "react"; import { FeatureCollectionContext } from "react-cismap/contexts/FeatureCollectionContextProvider"; import { getColorForProperties } from "../../../helper/styler"; -import { PieChart } from "@carma-appframeworks/portals"; +import { PieChart } from "@carma-mapping/components"; const XandRidePieChart = ({ visible = true }) => { const { filteredItems } = useContext( diff --git a/libraries/appframeworks/portals/src/index.ts b/libraries/appframeworks/portals/src/index.ts index e1c1d4291..267016868 100644 --- a/libraries/appframeworks/portals/src/index.ts +++ b/libraries/appframeworks/portals/src/index.ts @@ -47,7 +47,6 @@ export { CarmaMapProviderWrapper } from "./lib/components/CarmaMapProviderWrappe export { InfoBox } from "./lib/components/InfoBox.tsx"; export { ResponsiveInfoBox } from "./lib/components/ResponsiveInfoBox.tsx"; export { GenericInfoBoxFromFeature } from "./lib/components/GenericInfoBoxFromFeature.tsx"; -export { PieChart } from "./lib/components/PieChart.tsx"; export { ContactMailButton } from "./lib/components/ContactMailButton.tsx"; export { FeatureInfobox } from "./lib/components/FeatureInfobox.tsx"; export { InfoBoxHeader } from "./lib/components/InfoBoxHeader.tsx"; diff --git a/libraries/commons/cismap/src/lib/FilterPieChart.jsx b/libraries/commons/cismap/src/lib/FilterPieChart.jsx index e7f91ef13..4bafde93c 100644 --- a/libraries/commons/cismap/src/lib/FilterPieChart.jsx +++ b/libraries/commons/cismap/src/lib/FilterPieChart.jsx @@ -1,4 +1,4 @@ -import { PieChart } from "@carma-appframeworks/portals"; +import { PieChart } from "@carma-mapping/components"; export const FilterPieChart = ({ filteredItems, diff --git a/libraries/mapping/components/src/index.ts b/libraries/mapping/components/src/index.ts index 8ed4d2a96..3965d0a64 100644 --- a/libraries/mapping/components/src/index.ts +++ b/libraries/mapping/components/src/index.ts @@ -81,3 +81,5 @@ export { } from "./lib/components/PoiFilterPanel"; export { useRestoreLayerFilter } from "./lib/components/useRestoreLayerFilter"; + +export { PieChart } from "./lib/components/PieChart"; diff --git a/libraries/mapping/components/src/lib/components/AdvancedFilterPanel.tsx b/libraries/mapping/components/src/lib/components/AdvancedFilterPanel.tsx index 7f97d13a9..49f4dc03b 100644 --- a/libraries/mapping/components/src/lib/components/AdvancedFilterPanel.tsx +++ b/libraries/mapping/components/src/lib/components/AdvancedFilterPanel.tsx @@ -1,6 +1,6 @@ import { useCallback } from "react"; import { TriStateFilterButton, type TriState } from "./TriStateFilterButton"; -import { PieChart } from "@carma-appframeworks/portals"; +import { PieChart } from "./PieChart"; export interface AdvancedFilterCategory { key: string; diff --git a/libraries/appframeworks/portals/src/lib/components/PieChart.tsx b/libraries/mapping/components/src/lib/components/PieChart.tsx similarity index 100% rename from libraries/appframeworks/portals/src/lib/components/PieChart.tsx rename to libraries/mapping/components/src/lib/components/PieChart.tsx From 6bae2a185268df13b758968b731243c20b60933b Mon Sep 17 00:00:00 2001 From: David Glogaza Date: Fri, 27 Mar 2026 16:29:09 +0100 Subject: [PATCH 11/27] fix ui jumps --- .../src/lib/components/AdvancedFilterPanel.tsx | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/libraries/mapping/components/src/lib/components/AdvancedFilterPanel.tsx b/libraries/mapping/components/src/lib/components/AdvancedFilterPanel.tsx index 49f4dc03b..19c0d1682 100644 --- a/libraries/mapping/components/src/lib/components/AdvancedFilterPanel.tsx +++ b/libraries/mapping/components/src/lib/components/AdvancedFilterPanel.tsx @@ -84,10 +84,8 @@ export const AdvancedFilterPanel = ({
); - const pieChart = - pieChartData && pieChartData.length > 0 && pieChartColors ? ( - - ) : null; + const hasPieChartProps = + pieChartData !== undefined && pieChartColors !== undefined; const isWide = width >= 600; @@ -123,19 +121,23 @@ export const AdvancedFilterPanel = ({
- {isWide && pieChart ? ( + {isWide && hasPieChartProps ? (
{filterRows}
- {pieChart} +
) : ( <> {filterRows} - {pieChart &&
{pieChart}
} + {hasPieChartProps && ( +
+ +
+ )} )}
From 21c5cd2ef1a87f56890e25f1db630a7ba7849d8a Mon Sep 17 00:00:00 2001 From: David Glogaza Date: Fri, 27 Mar 2026 16:29:19 +0100 Subject: [PATCH 12/27] change filter panel title --- apps/topicmaps/ng-stadtplan/src/app/App.tsx | 7 +++++++ apps/topicmaps/ng-stadtplan/src/app/Menu.tsx | 10 +++++----- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/apps/topicmaps/ng-stadtplan/src/app/App.tsx b/apps/topicmaps/ng-stadtplan/src/app/App.tsx index 18441f995..6745eed08 100644 --- a/apps/topicmaps/ng-stadtplan/src/app/App.tsx +++ b/apps/topicmaps/ng-stadtplan/src/app/App.tsx @@ -81,6 +81,11 @@ export default function App() { [filterState, allFeatures] ); + const filteredPoiCount = useMemo( + () => pieChartData.reduce((sum, [, count]) => sum + count, 0), + [pieChartData] + ); + const categories = useMemo( () => lebenslagen.map((ll) => ({ key: ll, label: ll })), [lebenslagen] @@ -104,6 +109,8 @@ export default function App() { onFilterStateChange={setFilterState} pieChartData={pieChartData} pieChartColors={pieChartColors} + filteredPoiCount={filteredPoiCount} + totalPoiCount={allFeatures.length} /> } /> diff --git a/apps/topicmaps/ng-stadtplan/src/app/Menu.tsx b/apps/topicmaps/ng-stadtplan/src/app/Menu.tsx index 77a5a55b4..3eb8637b2 100644 --- a/apps/topicmaps/ng-stadtplan/src/app/Menu.tsx +++ b/apps/topicmaps/ng-stadtplan/src/app/Menu.tsx @@ -26,6 +26,8 @@ interface MenuProps { onFilterStateChange?: (state: AdvancedFilterState) => void; pieChartData?: [string, number][]; pieChartColors?: string[]; + filteredPoiCount?: number; + totalPoiCount?: number; } const Menu = ({ @@ -34,17 +36,15 @@ const Menu = ({ onFilterStateChange, pieChartData, pieChartColors, + filteredPoiCount = 0, + totalPoiCount = 0, }: MenuProps) => { const { setAppMenuActiveMenuSection } = useContext(UIDispatchContext); const hasFilter = categories && filterState && onFilterStateChange; - const filterTitle = - filterState && - (filterState.positiv.length > 0 || filterState.negativ.length > 0) - ? `Filter (${filterState.positiv.length} aktiv, ${filterState.negativ.length} ausgeschlossen)` - : "Filter"; + const filterTitle = `Mein Themenstadtplan (${filteredPoiCount} POIs gefunden, davon ${filteredPoiCount} in der Karte)`; return ( From 11a0adf5a9226e69ba631d31985dbfeef443291e Mon Sep 17 00:00:00 2001 From: David Glogaza Date: Wed, 8 Apr 2026 12:21:52 +0200 Subject: [PATCH 13/27] use correct number of visible pois --- apps/topicmaps/ng-stadtplan/src/app/App.tsx | 47 +++++++++++++++++++- apps/topicmaps/ng-stadtplan/src/app/Menu.tsx | 4 +- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/apps/topicmaps/ng-stadtplan/src/app/App.tsx b/apps/topicmaps/ng-stadtplan/src/app/App.tsx index 6745eed08..8b5829f84 100644 --- a/apps/topicmaps/ng-stadtplan/src/app/App.tsx +++ b/apps/topicmaps/ng-stadtplan/src/app/App.tsx @@ -4,7 +4,11 @@ import { CarmaMap } from "@carma-mapping/core"; import type { AdvancedFilterState } from "@carma-mapping/components"; import Menu from "./Menu"; import { POI_LAYER_CONFIG } from "./helper/constants"; -import { applyPoiFilter, extractLebenslagen } from "./helper/filter"; +import { + applyPoiFilter, + extractLebenslagen, + getAllowedKombis, +} from "./helper/filter"; import { computePieChartStats } from "./helper/pieChartStats"; import "bootstrap/dist/css/bootstrap.min.css"; import "react-bootstrap-typeahead/css/Typeahead.css"; @@ -86,6 +90,46 @@ export default function App() { [pieChartData] ); + const [visiblePoiCount, setVisiblePoiCount] = useState(0); + + const filteredFeatures = useMemo(() => { + if (allFeatures.length === 0) return []; + const allowedKombis = new Set( + getAllowedKombis(allKombisRef.current, filterState) + ); + return allFeatures.filter((f: any) => { + const kombi = f.properties?.kombi; + if (typeof kombi !== "string" || kombi.length === 0) return true; + return allowedKombis.has(kombi); + }); + }, [allFeatures, filterState]); + + // Count filtered features inside the current viewport. + // Uses bounds checking against React state so clustering doesn't affect the count. + useEffect(() => { + const map = (window as any).__carmaMap; + if (!map) return; + + const updateVisibleCount = () => { + const bounds = map.getBounds(); + let count = 0; + for (const feature of filteredFeatures) { + const coords = feature.geometry?.coordinates; + if (!coords) continue; + const [lng, lat] = coords; + if (bounds.contains([lng, lat])) count++; + } + setVisiblePoiCount(count); + }; + + map.on("moveend", updateVisibleCount); + updateVisibleCount(); + + return () => { + map.off("moveend", updateVisibleCount); + }; + }, [filteredFeatures]); + const categories = useMemo( () => lebenslagen.map((ll) => ({ key: ll, label: ll })), [lebenslagen] @@ -110,6 +154,7 @@ export default function App() { pieChartData={pieChartData} pieChartColors={pieChartColors} filteredPoiCount={filteredPoiCount} + visiblePoiCount={visiblePoiCount} totalPoiCount={allFeatures.length} /> } diff --git a/apps/topicmaps/ng-stadtplan/src/app/Menu.tsx b/apps/topicmaps/ng-stadtplan/src/app/Menu.tsx index 3eb8637b2..27d60e025 100644 --- a/apps/topicmaps/ng-stadtplan/src/app/Menu.tsx +++ b/apps/topicmaps/ng-stadtplan/src/app/Menu.tsx @@ -27,6 +27,7 @@ interface MenuProps { pieChartData?: [string, number][]; pieChartColors?: string[]; filteredPoiCount?: number; + visiblePoiCount?: number; totalPoiCount?: number; } @@ -37,6 +38,7 @@ const Menu = ({ pieChartData, pieChartColors, filteredPoiCount = 0, + visiblePoiCount = 0, totalPoiCount = 0, }: MenuProps) => { const { setAppMenuActiveMenuSection } = @@ -44,7 +46,7 @@ const Menu = ({ const hasFilter = categories && filterState && onFilterStateChange; - const filterTitle = `Mein Themenstadtplan (${filteredPoiCount} POIs gefunden, davon ${filteredPoiCount} in der Karte)`; + const filterTitle = `Mein Themenstadtplan (${filteredPoiCount} POIs gefunden, davon ${visiblePoiCount} in der Karte)`; return ( From 4de31044e5f728a383b044966d50d8c5fa4cc315 Mon Sep 17 00:00:00 2001 From: David Glogaza Date: Wed, 8 Apr 2026 12:52:04 +0200 Subject: [PATCH 14/27] add crosslink apps --- .../ng-stadtplan/src/app/FilterUI.tsx | 105 ++++++++++++++++++ apps/topicmaps/ng-stadtplan/src/app/Menu.tsx | 18 +-- 2 files changed, 115 insertions(+), 8 deletions(-) create mode 100644 apps/topicmaps/ng-stadtplan/src/app/FilterUI.tsx diff --git a/apps/topicmaps/ng-stadtplan/src/app/FilterUI.tsx b/apps/topicmaps/ng-stadtplan/src/app/FilterUI.tsx new file mode 100644 index 000000000..6686d022f --- /dev/null +++ b/apps/topicmaps/ng-stadtplan/src/app/FilterUI.tsx @@ -0,0 +1,105 @@ +import { useMemo } from "react"; +import { Badge } from "react-bootstrap"; +import { + AdvancedFilterPanel, + type AdvancedFilterCategory, + type AdvancedFilterState, +} from "@carma-mapping/components"; +import { crossLinkApps } from "./helper/constants"; + +interface FilterUIProps { + categories: AdvancedFilterCategory[]; + filterState: AdvancedFilterState; + onFilterStateChange: (state: AdvancedFilterState) => void; + width?: number; + pieChartData?: [string, number][]; + pieChartColors?: string[]; +} + +const FilterUI = ({ + categories, + filterState, + onFilterStateChange, + width = 900, + pieChartData, + pieChartColors, +}: FilterUIProps) => { + const additionalAppArray = useMemo(() => { + if (!filterState?.positiv) return []; + const usedApps: string[] = []; + const result: JSX.Element[] = []; + + for (const app of crossLinkApps) { + for (const appLebenslage of app.on) { + if ( + filterState.positiv.indexOf(appLebenslage) !== -1 && + usedApps.indexOf(app.name) === -1 + ) { + usedApps.push(app.name); + result.push( + + + {app.name} + + + ); + } + } + } + + return result; + }, [filterState?.positiv]); + + return ( +
+ + {additionalAppArray.length > 0 && ( +
+
+ * Themenspezifische Karten: + {" "} +

+ {additionalAppArray} +

+
+ )} +
+ ); +}; + +export default FilterUI; diff --git a/apps/topicmaps/ng-stadtplan/src/app/Menu.tsx b/apps/topicmaps/ng-stadtplan/src/app/Menu.tsx index 27d60e025..c25827624 100644 --- a/apps/topicmaps/ng-stadtplan/src/app/Menu.tsx +++ b/apps/topicmaps/ng-stadtplan/src/app/Menu.tsx @@ -14,11 +14,11 @@ import { import versionData from "../version.json"; import { getApplicationVersion } from "@carma-commons/utils"; import { PreviewLibreMap } from "@carma-mapping/engines/maplibre"; -import { - AdvancedFilterPanel, - type AdvancedFilterCategory, - type AdvancedFilterState, +import type { + AdvancedFilterCategory, + AdvancedFilterState, } from "@carma-mapping/components"; +import FilterUI from "./FilterUI"; interface MenuProps { categories?: AdvancedFilterCategory[]; @@ -46,7 +46,10 @@ const Menu = ({ const hasFilter = categories && filterState && onFilterStateChange; - const filterTitle = `Mein Themenstadtplan (${filteredPoiCount} POIs gefunden, davon ${visiblePoiCount} in der Karte)`; + const getFilterHeader = () => { + const term = filteredPoiCount === 1 ? "POI" : "POIs"; + return `Mein Themenstadtplan (${filteredPoiCount} ${term} gefunden, davon ${visiblePoiCount} in der Karte)`; + }; return ( @@ -70,14 +73,13 @@ const Menu = ({
From fb9083913bc58122a7f7d36707ca4ec3219bf725 Mon Sep 17 00:00:00 2001 From: David Glogaza Date: Wed, 8 Apr 2026 15:19:00 +0200 Subject: [PATCH 15/27] update styling --- .../ng-stadtplan/src/app/FilterUI.tsx | 15 ++- .../lib/components/AdvancedFilterPanel.tsx | 70 ++++++++------ .../src/lib/components/PieChart.tsx | 2 +- .../lib/components/TriStateFilterButton.tsx | 96 ++++++++----------- 4 files changed, 95 insertions(+), 88 deletions(-) diff --git a/apps/topicmaps/ng-stadtplan/src/app/FilterUI.tsx b/apps/topicmaps/ng-stadtplan/src/app/FilterUI.tsx index 6686d022f..7cd0b4727 100644 --- a/apps/topicmaps/ng-stadtplan/src/app/FilterUI.tsx +++ b/apps/topicmaps/ng-stadtplan/src/app/FilterUI.tsx @@ -24,6 +24,18 @@ const FilterUI = ({ pieChartData, pieChartColors, }: FilterUIProps) => { + const categoryFootnotes = useMemo(() => { + const footnotes: Record = {}; + for (const app of crossLinkApps) { + for (const ll of app.on) { + if (!footnotes[ll]) { + footnotes[ll] = " *"; + } + } + } + return footnotes; + }, []); + const additionalAppArray = useMemo(() => { if (!filterState?.positiv) return []; const usedApps: string[] = []; @@ -75,10 +87,11 @@ const FilterUI = ({ width={width} pieChartData={pieChartData} pieChartColors={pieChartColors} + categoryFootnotes={categoryFootnotes} /> {additionalAppArray.length > 0 && (
-
+
* Themenspezifische Karten: {" "}

; } export const AdvancedFilterPanel = ({ @@ -28,6 +30,7 @@ export const AdvancedFilterPanel = ({ width = 500, pieChartData, pieChartColors, + categoryFootnotes, }: AdvancedFilterPanelProps) => { const getTriState = useCallback( (key: string): TriState => { @@ -72,13 +75,14 @@ export const AdvancedFilterPanel = ({ }, [filterState, onFilterStateChange]); const filterRows = ( -
+
{categories.map((cat) => ( handleToggle(cat.key, newState)} + footnote={categoryFootnotes?.[cat.key]} /> ))}
@@ -89,43 +93,49 @@ export const AdvancedFilterPanel = ({ const isWide = width >= 600; - const btnStyle: React.CSSProperties = { - padding: "3px 8px", - border: "1px solid #ccc", - borderRadius: "4px", - background: "#f8f9fa", - cursor: "pointer", - fontSize: "11px", - whiteSpace: "nowrap", - }; - return ( -
-
- - + - + +
+
{isWide && hasPieChartProps ? ( -
-
{filterRows}
+
+
{filterRows}
diff --git a/libraries/mapping/components/src/lib/components/PieChart.tsx b/libraries/mapping/components/src/lib/components/PieChart.tsx index 8d7b530d3..29cffb2a6 100644 --- a/libraries/mapping/components/src/lib/components/PieChart.tsx +++ b/libraries/mapping/components/src/lib/components/PieChart.tsx @@ -42,7 +42,7 @@ export const PieChart = ({ justifyContent: "center", }} > -
+
{label} - {footnote && {footnote}} -
-
- - - + {footnote && {footnote}}
+ + + + + + +
); }; From 744ec2ebbe10e2c9e55b0082438b07eaa6fa56ac Mon Sep 17 00:00:00 2001 From: David Glogaza Date: Thu, 9 Apr 2026 09:46:32 +0200 Subject: [PATCH 16/27] remove css import --- apps/topicmaps/ng-stadtplan/src/main.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/topicmaps/ng-stadtplan/src/main.tsx b/apps/topicmaps/ng-stadtplan/src/main.tsx index 3e2d57a75..a7ce96abf 100644 --- a/apps/topicmaps/ng-stadtplan/src/main.tsx +++ b/apps/topicmaps/ng-stadtplan/src/main.tsx @@ -10,7 +10,6 @@ import TopicMapContextProvider from "react-cismap/contexts/TopicMapContextProvid import { defaultGazDataConfig } from "@carma-commons/resources"; import { cjsGlobalShim } from "@carma-commons/utils"; import App from "./app/App"; -import "./styles.css"; cjsGlobalShim(); From 169dd8c4da9a8bb45fca999c3b7b1546cbfad949 Mon Sep 17 00:00:00 2001 From: David Glogaza Date: Thu, 9 Apr 2026 09:55:48 +0200 Subject: [PATCH 17/27] dont create infobox on gazetteer search --- apps/topicmaps/ng-stadtplan/src/app/App.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/topicmaps/ng-stadtplan/src/app/App.tsx b/apps/topicmaps/ng-stadtplan/src/app/App.tsx index 8b5829f84..c3ad368b2 100644 --- a/apps/topicmaps/ng-stadtplan/src/app/App.tsx +++ b/apps/topicmaps/ng-stadtplan/src/app/App.tsx @@ -146,6 +146,7 @@ export default function App() { onProgressUpdate={handleProgressUpdate} libreLayers={[POI_LAYER_CONFIG]} filterFunction={handleFilter} + gazetteerInfoOnClick={false} modalMenu={ Date: Thu, 9 Apr 2026 10:04:16 +0200 Subject: [PATCH 18/27] add types --- apps/topicmaps/ng-stadtplan/src/app/App.tsx | 28 +++++++++++-------- .../ng-stadtplan/src/app/helper/filter.ts | 26 +++++++++++------ .../src/app/helper/pieChartStats.ts | 2 +- 3 files changed, 34 insertions(+), 22 deletions(-) diff --git a/apps/topicmaps/ng-stadtplan/src/app/App.tsx b/apps/topicmaps/ng-stadtplan/src/app/App.tsx index c3ad368b2..ebe846d7c 100644 --- a/apps/topicmaps/ng-stadtplan/src/app/App.tsx +++ b/apps/topicmaps/ng-stadtplan/src/app/App.tsx @@ -1,7 +1,8 @@ import { useState, useCallback, useEffect, useMemo, useRef } from "react"; import { ProgressIndicator, useProgress } from "@carma-appframeworks/portals"; -import { CarmaMap } from "@carma-mapping/core"; +import { CarmaMap, LibreLayer } from "@carma-mapping/core"; import type { AdvancedFilterState } from "@carma-mapping/components"; +import type maplibregl from "maplibre-gl"; import Menu from "./Menu"; import { POI_LAYER_CONFIG } from "./helper/constants"; import { @@ -18,25 +19,27 @@ import "leaflet/dist/leaflet.css"; export default function App() { const { progress, showProgress, handleProgressUpdate } = useProgress(); - const [allFeatures, setAllFeatures] = useState([]); + const [allFeatures, setAllFeatures] = useState([]); const [lebenslagen, setLebenslagen] = useState([]); const [filterState, setFilterState] = useState({ positiv: [], negativ: [], }); - const allFeaturesRef = useRef([]); + const allFeaturesRef = useRef([]); const allKombisRef = useRef([]); const filterStateRef = useRef(filterState); filterStateRef.current = filterState; // Capture original features and extract lebenslagen on first filterFunction call const handleFilter = useCallback( - (map: any, layers: any) => { - layers?.forEach((layer: any, index: number) => { + (map: maplibregl.Map, layers?: LibreLayer[]) => { + layers?.forEach((layer, index) => { if (layer.type !== "geojson") return; const sourceId = `geojson-source-${index}`; - const styleSource = map.getStyle().sources[sourceId] as any; + const styleSource = map.getStyle().sources[sourceId] as + | { data?: GeoJSON.FeatureCollection } + | undefined; if (!styleSource?.data?.features) return; // Extract lebenslagen and kombi values only once @@ -69,7 +72,8 @@ export default function App() { useEffect(() => { if (allKombisRef.current.length === 0 || lebenslagen.length === 0) return; - const map = (window as any).__carmaMap; + const map = (window as unknown as { __carmaMap?: maplibregl.Map }) + .__carmaMap; if (!map) return; applyPoiFilter( @@ -97,7 +101,7 @@ export default function App() { const allowedKombis = new Set( getAllowedKombis(allKombisRef.current, filterState) ); - return allFeatures.filter((f: any) => { + return allFeatures.filter((f) => { const kombi = f.properties?.kombi; if (typeof kombi !== "string" || kombi.length === 0) return true; return allowedKombis.has(kombi); @@ -107,16 +111,16 @@ export default function App() { // Count filtered features inside the current viewport. // Uses bounds checking against React state so clustering doesn't affect the count. useEffect(() => { - const map = (window as any).__carmaMap; + const map = (window as unknown as { __carmaMap?: maplibregl.Map }) + .__carmaMap; if (!map) return; const updateVisibleCount = () => { const bounds = map.getBounds(); let count = 0; for (const feature of filteredFeatures) { - const coords = feature.geometry?.coordinates; - if (!coords) continue; - const [lng, lat] = coords; + if (feature.geometry?.type !== "Point") continue; + const [lng, lat] = feature.geometry.coordinates; if (bounds.contains([lng, lat])) count++; } setVisiblePoiCount(count); diff --git a/apps/topicmaps/ng-stadtplan/src/app/helper/filter.ts b/apps/topicmaps/ng-stadtplan/src/app/helper/filter.ts index 0c90a3b92..87d5d69dd 100644 --- a/apps/topicmaps/ng-stadtplan/src/app/helper/filter.ts +++ b/apps/topicmaps/ng-stadtplan/src/app/helper/filter.ts @@ -1,13 +1,17 @@ import type { AdvancedFilterState } from "@carma-mapping/components"; +import type maplibregl from "maplibre-gl"; +import type { FilterSpecification } from "maplibre-gl"; import { POI_SOURCE_ID } from "./constants"; export interface LebenslagenData { lebenslagen: string[]; kombis: string[]; - features: any[]; + features: GeoJSON.Feature[]; } -export function extractLebenslagen(features: any[]): LebenslagenData { +export function extractLebenslagen( + features: GeoJSON.Feature[] +): LebenslagenData { const llSet = new Set(); const kombiSet = new Set(); @@ -41,7 +45,9 @@ export function getAllowedKombis( }); } -function buildPoiFilterExpression(allowedKombis: string[]): any[] { +function buildPoiFilterExpression( + allowedKombis: string[] +): FilterSpecification { if (allowedKombis.length === 0) { return ["==", ["get", "kombi"], "___HIDE_ALL___"]; } @@ -54,8 +60,8 @@ function buildPoiFilterExpression(allowedKombis: string[]): any[] { } export function applyPoiFilter( - map: any, - allFeatures: any[], + map: maplibregl.Map, + allFeatures: GeoJSON.Feature[], allKombis: string[], filterState: AdvancedFilterState ) { @@ -76,18 +82,20 @@ export function applyPoiFilter( } } - const source = map.getSource(POI_SOURCE_ID); + const source = map.getSource(POI_SOURCE_ID) as + | maplibregl.GeoJSONSource + | undefined; if (source && "setData" in source) { if (isShowingAll) { - (source as any).setData({ + source.setData({ type: "FeatureCollection", features: allFeatures, }); } else { const allowedSet = new Set(allowedKombis); - (source as any).setData({ + source.setData({ type: "FeatureCollection", - features: allFeatures.filter((f: any) => { + features: allFeatures.filter((f) => { const kombi = f.properties?.kombi; if (typeof kombi !== "string" || kombi.length === 0) return true; return allowedSet.has(kombi); diff --git a/apps/topicmaps/ng-stadtplan/src/app/helper/pieChartStats.ts b/apps/topicmaps/ng-stadtplan/src/app/helper/pieChartStats.ts index 8ab3216e5..285ff747d 100644 --- a/apps/topicmaps/ng-stadtplan/src/app/helper/pieChartStats.ts +++ b/apps/topicmaps/ng-stadtplan/src/app/helper/pieChartStats.ts @@ -8,7 +8,7 @@ export interface PieChartResult { } export function computePieChartStats( - allFeatures: any[], + allFeatures: GeoJSON.Feature[], allKombis: string[], filterState: AdvancedFilterState ): PieChartResult { From 828ad1de31861581d472fcd8285b5f4ed536a034 Mon Sep 17 00:00:00 2001 From: David Glogaza Date: Thu, 9 Apr 2026 10:49:48 +0200 Subject: [PATCH 19/27] add option to show filter title --- apps/topicmaps/ng-stadtplan/src/app/App.tsx | 9 + apps/topicmaps/ng-stadtplan/src/app/Menu.tsx | 6 +- .../ng-stadtplan/src/app/TitleBox.tsx | 69 +++ libraries/commons/cismap/src/index.ts | 1 + .../cismap/src/lib/DefaultSettingsPanel.jsx | 518 ++++++++++++++++++ 5 files changed, 602 insertions(+), 1 deletion(-) create mode 100644 apps/topicmaps/ng-stadtplan/src/app/TitleBox.tsx create mode 100644 libraries/commons/cismap/src/lib/DefaultSettingsPanel.jsx diff --git a/apps/topicmaps/ng-stadtplan/src/app/App.tsx b/apps/topicmaps/ng-stadtplan/src/app/App.tsx index ebe846d7c..38a8b9e59 100644 --- a/apps/topicmaps/ng-stadtplan/src/app/App.tsx +++ b/apps/topicmaps/ng-stadtplan/src/app/App.tsx @@ -4,6 +4,7 @@ import { CarmaMap, LibreLayer } from "@carma-mapping/core"; import type { AdvancedFilterState } from "@carma-mapping/components"; import type maplibregl from "maplibre-gl"; import Menu from "./Menu"; +import TitleBox from "./TitleBox"; import { POI_LAYER_CONFIG } from "./helper/constants"; import { applyPoiFilter, @@ -25,6 +26,10 @@ export default function App() { positiv: [], negativ: [], }); + const [showFilterTitle, setShowFilterTitle] = useState(() => + new URLSearchParams(window.location.hash.split("?")[1] || "").has("title") + ); + const allFeaturesRef = useRef([]); const allKombisRef = useRef([]); const filterStateRef = useRef(filterState); @@ -142,6 +147,9 @@ export default function App() { return ( <> + {showFilterTitle && ( + + )} {}} mapEngine="maplibre" @@ -161,6 +169,7 @@ export default function App() { filteredPoiCount={filteredPoiCount} visiblePoiCount={visiblePoiCount} totalPoiCount={allFeatures.length} + onTitleDisplayChange={setShowFilterTitle} /> } /> diff --git a/apps/topicmaps/ng-stadtplan/src/app/Menu.tsx b/apps/topicmaps/ng-stadtplan/src/app/Menu.tsx index c25827624..1a5a76f38 100644 --- a/apps/topicmaps/ng-stadtplan/src/app/Menu.tsx +++ b/apps/topicmaps/ng-stadtplan/src/app/Menu.tsx @@ -1,7 +1,7 @@ import { useContext } from "react"; import CustomizationContextProvider from "react-cismap/contexts/CustomizationContextProvider"; import { UIDispatchContext } from "react-cismap/contexts/UIContextProvider"; -import DefaultSettingsPanel from "react-cismap/topicmaps/menu/DefaultSettingsPanel"; +import { DefaultSettingsPanel } from "@carma-commons/cismap"; import ModalApplicationMenu from "react-cismap/topicmaps/menu/ModalApplicationMenu"; import Section from "react-cismap/topicmaps/menu/Section"; import { GenericDigitalTwinReferenceSection } from "@carma-collab/wuppertal/commons"; @@ -29,6 +29,7 @@ interface MenuProps { filteredPoiCount?: number; visiblePoiCount?: number; totalPoiCount?: number; + onTitleDisplayChange?: (show: boolean) => void; } const Menu = ({ @@ -40,6 +41,7 @@ const Menu = ({ filteredPoiCount = 0, visiblePoiCount = 0, totalPoiCount = 0, + onTitleDisplayChange, }: MenuProps) => { const { setAppMenuActiveMenuSection } = useContext(UIDispatchContext); @@ -89,6 +91,8 @@ const Menu = ({ : []), { return ( = lebenslagen.length + ) { + return null; + } + + let desc = ""; + + if (filterState.positiv.length <= 4) { + desc += filterState.positiv.join(", "); + } else { + desc += filterState.positiv.length + " Themen"; + } + + if (filterState.negativ.length > 0) { + if (filterState.negativ.length <= 3) { + desc += " ohne " + filterState.negativ.join(", "); + } else { + desc += " (" + filterState.negativ.length + " Themen ausgeschlossen)"; + } + } + + return desc; +} + +const TitleBox = ({ filterState, lebenslagen }: TitleBoxProps) => { + const desc = buildTitleText(filterState, lebenslagen); + + if (!desc) return null; + + return ( +
+ Mein Themenstadtplan: {desc} +
+ ); +}; + +export default TitleBox; diff --git a/libraries/commons/cismap/src/index.ts b/libraries/commons/cismap/src/index.ts index 178433061..4685590cb 100644 --- a/libraries/commons/cismap/src/index.ts +++ b/libraries/commons/cismap/src/index.ts @@ -3,3 +3,4 @@ export type { CismapSupportedLayerTypes, CismapLayerProps, } from "./lib/contracts/react-cismap.extended.d"; +export { default as DefaultSettingsPanel } from "./lib/DefaultSettingsPanel"; diff --git a/libraries/commons/cismap/src/lib/DefaultSettingsPanel.jsx b/libraries/commons/cismap/src/lib/DefaultSettingsPanel.jsx new file mode 100644 index 000000000..a322a895b --- /dev/null +++ b/libraries/commons/cismap/src/lib/DefaultSettingsPanel.jsx @@ -0,0 +1,518 @@ +import queryString from "query-string"; +import React, { useContext, useEffect, useState } from "react"; +import { removeQueryPart } from "react-cismap/tools/routingHelper"; +import Section from "react-cismap/topicmaps/menu/Section"; +import SettingsPanelWithPreviewSection from "react-cismap/topicmaps/menu/SettingsPanelWithPreviewSection"; + +import { Form } from "react-bootstrap"; +import { MappingConstants } from "react-cismap"; +import { + defaultClusteringOptions, + getDefaultFeatureStyler, +} from "react-cismap/FeatureCollection"; +import FeatureCollectionDisplay from "react-cismap/FeatureCollectionDisplay"; +import { + FeatureCollectionContext, + FeatureCollectionDispatchContext, +} from "react-cismap/contexts/FeatureCollectionContextProvider"; +import { + OfflineLayerCacheContext, + OfflineLayerCacheDispatchContext, +} from "react-cismap/contexts/OfflineLayerCacheContextProvider"; +import { ResponsiveTopicMapContext } from "react-cismap/contexts/ResponsiveTopicMapContextProvider"; +import { TopicMapContext } from "react-cismap/contexts/TopicMapContextProvider"; +import { + TopicMapStylingContext, + TopicMapStylingDispatchContext, +} from "react-cismap/contexts/TopicMapStylingContextProvider"; +import { + UIContext, + UIDispatchContext, +} from "react-cismap/contexts/UIContextProvider"; +import getLayersByName from "react-cismap/tools/layerFactory"; +import { getSymbolSVGGetter } from "react-cismap/tools/uiHelper"; +import NamedMapStyleChooser from "react-cismap/topicmaps/menu/NamedMapStyleChooser"; +import PreviewMap from "react-cismap/topicmaps/menu/PreviewMap"; +import SymbolSizeChooser from "react-cismap/topicmaps/menu/SymbolSizeChooser"; + +const SettingsPanel = (props) => { + const { + namedMapStyle, + urlPathname, + urlSearch, + pushNewRoute, + width, + setLayerByKey, + activeLayerKey, + backgroundModes: _backgroundModes, + changeMarkerSymbolSize, + currentMarkerSize, + getSymbolSVG, + symbolColor, + previewMapPosition, + previewFeatureCollection, + previewFeatureCollectionCount, + previewMapClusteringEnabled, + previewMapClusteringOptions, + titleCheckBoxlabel = "Titel bei individueller Filterung anzeigen", + hasFilter = false, + onTitleDisplayChange, + skipFilterTitleSettings = false, + skipClusteringSettings = false, + skipOfflineLayerSettings = false, + skipBackgroundSettings = false, + skipSymbolsizeSetting = false, + defaultContextValues = {}, + sparseSettingsSectionsExtensions = [], + previewFeatureCollectionDisplayProps, + checkBoxSettingsSectionTitle = "Einstellungen:", + checkBoxTextClustering = "Objekte maßstabsabhängig zusammenfassen", + overridingMapPreview, + previewChildren, + previewMapKeyPostfix, + previewChildrenKey, + } = props; + + const { setAppMenuActiveMenuSection, setAppMenuVisible } = + useContext(UIDispatchContext) || defaultContextValues; + const { activeMenuSection } = useContext(UIContext) || defaultContextValues; + const { routedMapRef, history, referenceSystem } = + useContext(TopicMapContext) || defaultContextValues; + const { setMarkerSymbolSize } = + useContext(TopicMapStylingDispatchContext) || defaultContextValues; + const { + markerSymbolSize, + additionalLayerConfiguration, + activeAdditionalLayerKeys, + additionalStylingInfo, + baseLayerConf, + } = useContext(TopicMapStylingContext) || defaultContextValues; + const { + allFeatures, + getFeatureStyler, + getColorFromProperties, + clusteringEnabled, + clusteringOptions, + getSymbolSVG: getSymbolSVGFromContext, + itemFilterFunction, + filterFunction, + } = useContext(FeatureCollectionContext) || defaultContextValues; + const { setClusteringEnabled } = + useContext(FeatureCollectionDispatchContext) || defaultContextValues; + const { windowSize } = + useContext(ResponsiveTopicMapContext) || defaultContextValues; + const { + offlineCacheConfig, + vectorLayerOfflineEnabled, + readyToUse: offlineReadyToUse, + } = useContext(OfflineLayerCacheContext) || defaultContextValues; + const { setVectorLayerOfflineEnabled } = + useContext(OfflineLayerCacheDispatchContext) || defaultContextValues; + const { + backgroundModesFromContexts, + selectedBackground, + backgroundConfigurations, + } = useContext(TopicMapStylingContext) || defaultContextValues; + + const backgroundModes = backgroundModesFromContexts || _backgroundModes; + + const _width = width || windowSize?.width; + const _changeMarkerSymbolSize = changeMarkerSymbolSize || setMarkerSymbolSize; + const _markerSymbolSize = currentMarkerSize || markerSymbolSize; + let namedMapStyleFromUrl = + new URLSearchParams(window.location.href).get("mapStyle") || "default"; + let _getSymbolSVG = getSymbolSVG || getSymbolSVGFromContext; + let _symbolColor; + + if (allFeatures && allFeatures[0]) { + if (getColorFromProperties) { + _symbolColor = getColorFromProperties(allFeatures[0].properties); + } else { + _symbolColor = allFeatures[0].properties.color; + } + } + if (_symbolColor === undefined) { + _symbolColor = "#2664D8"; + } + if (_getSymbolSVG === undefined) { + try { + if ( + allFeatures?.length > 0 && + allFeatures[0]?.properties?.svgBadge && + allFeatures[0]?.properties?.svgBadgeDimension + ) { + _getSymbolSVG = getSymbolSVGGetter( + allFeatures[0]?.properties?.svgBadge, + allFeatures[0]?.properties?.svgBadgeDimension + ); + } + } catch (e) { + //in this case a default Icon is shown + } + } + let previewMapPositionParams = new URLSearchParams(previewMapPosition); + let previewMapLng = previewMapPositionParams.get("lng") || "7.14534279930707"; + let previewMapLat = + previewMapPositionParams.get("lat") || "51.25548256737119"; + let previewMapZoom = previewMapPositionParams.get("zoom") || "12"; + + let _urlPathname, _urlSearch, _pushNewRoute; + const _namedMapStyle = namedMapStyleFromUrl; + const layers = routedMapRef?.props?.backgroundlayers; + const [mapPreview, setMapPreview] = useState(); + const qTitle = queryString.parse(history.location.search).title; + + const [titleDisplay, setTitleDisplay] = useState(qTitle !== undefined); + let backgroundsFromMode; + try { + backgroundsFromMode = backgroundConfigurations[selectedBackground].layerkey; + } catch (e) {} + + useEffect(() => { + //uglyWinning : with variable using for mapPreveiw there are refresh Problems + + let style; + if (getFeatureStyler !== undefined) { + const appMode = undefined; + const secondarySelection = undefined; + style = getFeatureStyler( + _markerSymbolSize, + getColorFromProperties, + appMode, + secondarySelection, + additionalStylingInfo + ); + } else { + style = getDefaultFeatureStyler( + _markerSymbolSize, + getColorFromProperties + ); + } + let previewFeatures; + + if (previewFeatureCollection) { + previewFeatures = previewFeatureCollection; + } else { + if ( + previewFeatureCollectionCount === -1 || + previewFeatureCollectionCount === undefined + ) { + previewFeatures = allFeatures; + } else { + previewFeatures = allFeatures.slice(0, previewFeatureCollectionCount); + } + } + + setMapPreview( + overridingMapPreview || ( + +
+ {getLayersByName( + backgroundsFromMode, + _namedMapStyle, + undefined, + baseLayerConf + )} + {activeAdditionalLayerKeys !== undefined && + activeAdditionalLayerKeys?.length > 0 && + activeAdditionalLayerKeys.map((activekey, index) => { + if (additionalLayerConfiguration) { + const layerConf = additionalLayerConfiguration[activekey]; + if (layerConf?.layer) { + return layerConf.layer; + } else if (layerConf?.layerkey) { + const layers = getLayersByName(layerConf.layerkey); + return layers; + } + } + })} +
+ +
{previewChildren}
+
+ ) + ); + }, [ + allFeatures, + backgroundsFromMode, + _namedMapStyle, + clusteringEnabled, + _markerSymbolSize, + activeAdditionalLayerKeys, + offlineReadyToUse, + ]); + + let titlePreview = ( +
+
+ + + + + + +
+ Kartentitel +
+
+ ); + let marginBottomCorrection = 0; + if (titleDisplay) { + marginBottomCorrection = -40; + } + const preview = ( +
+ + Vorschau: +
+
+ {mapPreview} + {titleDisplay === true && ( +
+ {titlePreview} +
+ )} +
+
+
+ ); + + if (urlPathname) { + _urlPathname = urlPathname; + } else { + _urlPathname = history.location.pathname; + } + if (urlSearch) { + _urlSearch = urlSearch; + } else { + _urlSearch = history.location.search; + } + if (pushNewRoute) { + _pushNewRoute = pushNewRoute; + } else { + _pushNewRoute = history.push; + } + + const settingsSections = + checkBoxSettingsSectionTitle || + (skipFilterTitleSettings === false && + (hasFilter || itemFilterFunction || filterFunction)) || + skipClusteringSettings === false || + (skipOfflineLayerSettings === false && offlineCacheConfig?.optional) + ? [ +
+ {checkBoxSettingsSectionTitle && ( + <> + {checkBoxSettingsSectionTitle} +
+ + )} + {skipFilterTitleSettings === false && + (hasFilter || itemFilterFunction || filterFunction) && ( + + { + if (e.target.checked === false) { + _pushNewRoute( + _urlPathname + removeQueryPart(_urlSearch, "title") + ); + setTitleDisplay(false); + onTitleDisplayChange?.(false); + } else { + _pushNewRoute( + _urlPathname + + (_urlSearch !== "" ? _urlSearch : "?") + + "&title" + ); + setTitleDisplay(true); + onTitleDisplayChange?.(true); + } + }} + label={titleCheckBoxlabel} + > + + )} + + {skipClusteringSettings === false && ( + + { + // console.log("xxx onClick", e); + }} + onChange={(e) => { + if (e.target.checked === false) { + setClusteringEnabled(false); + } else { + setClusteringEnabled(true); + } + }} + label={checkBoxTextClustering} + /> + + )} + {skipOfflineLayerSettings === false && + offlineCacheConfig?.optional && ( + + { + // console.log("xxx onClick", e); + }} + onChange={(e) => { + if (e.target.checked === false) { + setVectorLayerOfflineEnabled(false); + } else { + setVectorLayerOfflineEnabled(true); + } + }} + label="Vektorlayer offline verfügbar machen" + /> + + )} +
, + ] + : []; + if (skipBackgroundSettings === false) { + settingsSections.push( + + ); + } + if (skipSymbolsizeSetting === false) { + settingsSections.push( + + ); + } + + for (let i = 0; i < sparseSettingsSectionsExtensions.length; i++) { + const element = sparseSettingsSectionsExtensions[i]; + if (element) { + settingsSections.splice(i, 0, element); + } + } + + return ( +
+ } + /> + ); +}; +export default SettingsPanel; From ef09016771bfe2b756ad2c707c511105e3016602 Mon Sep 17 00:00:00 2001 From: David Glogaza Date: Thu, 9 Apr 2026 11:15:40 +0200 Subject: [PATCH 20/27] save filtering to local storage --- apps/topicmaps/ng-stadtplan/src/app/App.tsx | 12 ++++- .../src/app/helper/filterStorage.ts | 50 +++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 apps/topicmaps/ng-stadtplan/src/app/helper/filterStorage.ts diff --git a/apps/topicmaps/ng-stadtplan/src/app/App.tsx b/apps/topicmaps/ng-stadtplan/src/app/App.tsx index 38a8b9e59..061246135 100644 --- a/apps/topicmaps/ng-stadtplan/src/app/App.tsx +++ b/apps/topicmaps/ng-stadtplan/src/app/App.tsx @@ -11,6 +11,10 @@ import { extractLebenslagen, getAllowedKombis, } from "./helper/filter"; +import { + readFilterFromStorage, + writeFilterToStorage, +} from "./helper/filterStorage"; import { computePieChartStats } from "./helper/pieChartStats"; import "bootstrap/dist/css/bootstrap.min.css"; import "react-bootstrap-typeahead/css/Typeahead.css"; @@ -55,7 +59,11 @@ export default function App() { setAllFeatures(data.features); setLebenslagen(data.lebenslagen); - const initialFilter = { positiv: data.lebenslagen, negativ: [] }; + const restored = readFilterFromStorage(data.lebenslagen); + const initialFilter = restored ?? { + positiv: data.lebenslagen, + negativ: [], + }; filterStateRef.current = initialFilter; setFilterState(initialFilter); } @@ -87,6 +95,8 @@ export default function App() { allKombisRef.current, filterState ); + + writeFilterToStorage(filterState, lebenslagen); }, [filterState, lebenslagen]); const { pieChartData, pieChartColors } = useMemo( diff --git a/apps/topicmaps/ng-stadtplan/src/app/helper/filterStorage.ts b/apps/topicmaps/ng-stadtplan/src/app/helper/filterStorage.ts new file mode 100644 index 000000000..65b0ffa2c --- /dev/null +++ b/apps/topicmaps/ng-stadtplan/src/app/helper/filterStorage.ts @@ -0,0 +1,50 @@ +import type { AdvancedFilterState } from "@carma-mapping/components"; + +const STORAGE_KEY = "ng-stadtplan-filter"; + +export function readFilterFromStorage( + validCategories: string[] +): AdvancedFilterState | null { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return null; + + const stored = JSON.parse(raw) as { + positiv?: string[]; + negativ?: string[]; + }; + + if (!stored || typeof stored !== "object") return null; + + const valid = new Set(validCategories); + + const positiv = Array.isArray(stored.positiv) + ? stored.positiv.filter((s) => typeof s === "string" && valid.has(s)) + : []; + + const negativ = Array.isArray(stored.negativ) + ? stored.negativ.filter((s) => typeof s === "string" && valid.has(s)) + : []; + + return { positiv, negativ }; + } catch { + return null; + } +} + +export function writeFilterToStorage( + state: AdvancedFilterState, + allCategories: string[] +): void { + const isDefault = + state.negativ.length === 0 && state.positiv.length === allCategories.length; + + if (isDefault) { + localStorage.removeItem(STORAGE_KEY); + } else { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ positiv: state.positiv, negativ: state.negativ }) + ); + } +} From e755408d82c41048e7557701922799849640d67c Mon Sep 17 00:00:00 2001 From: David Glogaza Date: Thu, 9 Apr 2026 15:03:22 +0200 Subject: [PATCH 21/27] fix feature flickering on map movement --- apps/topicmaps/ng-stadtplan/src/app/App.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/topicmaps/ng-stadtplan/src/app/App.tsx b/apps/topicmaps/ng-stadtplan/src/app/App.tsx index 061246135..5e15cf8fc 100644 --- a/apps/topicmaps/ng-stadtplan/src/app/App.tsx +++ b/apps/topicmaps/ng-stadtplan/src/app/App.tsx @@ -68,7 +68,7 @@ export default function App() { setFilterState(initialFilter); } - // Apply current filter (also handles style rebuilds) + // Re-apply filter (also handles style rebuilds that recreate the source) applyPoiFilter( map, allFeaturesRef.current, @@ -149,6 +149,8 @@ export default function App() { }; }, [filteredFeatures]); + const libreLayers = useMemo(() => [POI_LAYER_CONFIG], []); + const categories = useMemo( () => lebenslagen.map((ll) => ({ key: ll, label: ll })), [lebenslagen] @@ -166,7 +168,7 @@ export default function App() { exposeMapToWindow overrideGlyphs="https://tiles.cismet.de/fonts/{fontstack}/{range}.pbf" onProgressUpdate={handleProgressUpdate} - libreLayers={[POI_LAYER_CONFIG]} + libreLayers={libreLayers} filterFunction={handleFilter} gazetteerInfoOnClick={false} modalMenu={ From 646266b15e113f2d4c9e9330c99afb22080a2d9f Mon Sep 17 00:00:00 2001 From: David Glogaza Date: Thu, 9 Apr 2026 15:28:32 +0200 Subject: [PATCH 22/27] fix flickering on marker size changes --- .../maplibre/src/components/LibreMap.tsx | 143 ++++++++++++------ .../maplibre/src/hooks/useImperativeStyle.ts | 10 +- .../maplibre/src/utils/styleBuilder.ts | 62 ++++++++ 3 files changed, 161 insertions(+), 54 deletions(-) diff --git a/libraries/mapping/engines/maplibre/src/components/LibreMap.tsx b/libraries/mapping/engines/maplibre/src/components/LibreMap.tsx index 94a2e5247..c0b88891a 100644 --- a/libraries/mapping/engines/maplibre/src/components/LibreMap.tsx +++ b/libraries/mapping/engines/maplibre/src/components/LibreMap.tsx @@ -1,8 +1,8 @@ -import { cogProtocol } from "@geomatico/maplibre-cog-protocol"; +import type { LayerSpecification, StyleSpecification } from "maplibre-gl"; import maplibregl from "maplibre-gl"; -import type { StyleSpecification } from "maplibre-gl"; - import "maplibre-gl/dist/maplibre-gl.css"; +import { cogProtocol } from "@geomatico/maplibre-cog-protocol"; + // Register COG protocol once maplibregl.addProtocol("cog", cogProtocol as any); import { @@ -19,6 +19,7 @@ import PhotoLightBox from "react-cismap/topicmaps/PhotoLightbox"; import { TopicMapStylingContext } from "react-cismap/contexts/TopicMapStylingContextProvider"; import "../styles/map.css"; import { + applySymbolScalingToMap, getVectorMapping, styleManipulation, vectorStylesToMapLibreStyle, @@ -287,6 +288,7 @@ export const LibreMap = ({ Array<{ sourceId: string; uniqueColors: string[] }> >([]); const isInitialGeoJsonLoad = useRef(true); + const baseStyleLayersRef = useRef([]); const { clusteringEnabled } = useContext( FeatureCollectionContext @@ -294,6 +296,8 @@ export const LibreMap = ({ const { markerSymbolSize } = useContext( TopicMapStylingContext ); + const markerSymbolSizeRef = useRef(markerSymbolSize); + markerSymbolSizeRef.current = markerSymbolSize; const { setMapStyle, geoJsonMetadata, @@ -700,37 +704,45 @@ export const LibreMap = ({ } } - if (bestHitLayer && bestResult && bestResult.resolvedSourceIndex != null) { - // Before accepting the 3D hit, check if there's a 2D symbol - // feature at the click point. Symbol layers (POI icons/text) - // render visually above 3D layers, so they should win clicks. - const hits2d = mapInstance.queryRenderedFeatures(e.point); - // queryRenderedFeatures returns hits in visual order (top first). - // If the topmost hit has a layerMapping, it's a selectable feature - // rendered above the 3D layer, so let the 2D path handle the click. - // If the topmost 2D hit is from a LIBRE_LAYERS sub-style (has - // layer-id metadata) but NOT from a source that has a 3D layer, - // it renders above the 3D objects, so let 2D selection win. - // Check if any 2D hit comes from a layer positioned after all - // 3D source layers in the style stack. Such layers render visually - // above the 3D objects and should win the click. - const styleLayers = mapInstance.getStyle()?.layers ?? []; - const threeSources = new Set(threeLayers.map((l) => l._config.sourceId)); - let lastThreeSourceIdx = -1; - for (let i = 0; i < styleLayers.length; i++) { - const src = (styleLayers[i] as { source?: string }).source; - if (src && threeSources.has(src)) lastThreeSourceIdx = i; - } - const hitAbove3d = lastThreeSourceIdx >= 0 && hits2d.some((f) => { + if ( + bestHitLayer && + bestResult && + bestResult.resolvedSourceIndex != null + ) { + // Before accepting the 3D hit, check if there's a 2D symbol + // feature at the click point. Symbol layers (POI icons/text) + // render visually above 3D layers, so they should win clicks. + const hits2d = mapInstance.queryRenderedFeatures(e.point); + // queryRenderedFeatures returns hits in visual order (top first). + // If the topmost hit has a layerMapping, it's a selectable feature + // rendered above the 3D layer, so let the 2D path handle the click. + // If the topmost 2D hit is from a LIBRE_LAYERS sub-style (has + // layer-id metadata) but NOT from a source that has a 3D layer, + // it renders above the 3D objects, so let 2D selection win. + // Check if any 2D hit comes from a layer positioned after all + // 3D source layers in the style stack. Such layers render visually + // above the 3D objects and should win the click. + const styleLayers = mapInstance.getStyle()?.layers ?? []; + const threeSources = new Set( + threeLayers.map((l) => l._config.sourceId) + ); + let lastThreeSourceIdx = -1; + for (let i = 0; i < styleLayers.length; i++) { + const src = (styleLayers[i] as { source?: string }).source; + if (src && threeSources.has(src)) lastThreeSourceIdx = i; + } + const hitAbove3d = + lastThreeSourceIdx >= 0 && + hits2d.some((f) => { const idx = styleLayers.findIndex((sl) => sl.id === f.layer.id); return idx > lastThreeSourceIdx; }); - if (hitAbove3d) { - // Let the 2D selection path handle this click - threeLayers.forEach((l) => l.unhighlight()); - } + if (hitAbove3d) { + // Let the 2D selection path handle this click + threeLayers.forEach((l) => l.unhighlight()); + } - if (!hitAbove3d) { + if (!hitAbove3d) { const threeLayer = bestHitLayer; const result = bestResult; // [3D-SELECT] closest hit log suppressed @@ -841,7 +853,7 @@ export const LibreMap = ({ ); } return; // skip 2D selection - } // end if (!hitAbove3d) + } // end if (!hitAbove3d) } } // ── end 3D raycast ──────────────────────────────────── @@ -1161,8 +1173,16 @@ export const LibreMap = ({ // Bail out if effect was cleaned up during async work (StrictMode double-fire) if (aborted) return; - // Apply marker symbol size scaling - const style = styleManipulation(markerSymbolSize, baseStyle); + // Store unscaled layers for live symbol-size updates + baseStyleLayersRef.current = baseStyle.layers + ? JSON.parse(JSON.stringify(baseStyle.layers)) + : []; + + // Apply marker symbol size scaling (use ref for current value since this is async) + const style = styleManipulation( + markerSymbolSizeRef.current, + baseStyle + ); // Store geojson metadata for pie chart rendering (local ref and context) geoJsonMetadataRef.current = geoJsonMetadata; @@ -1348,28 +1368,37 @@ export const LibreMap = ({ const loadedSources = new Set(); const handleStyleLoad = () => { + const trackSource = (sourceId: string) => { + if (loadedSources.has(sourceId)) return; + loadedSources.add(sourceId); + if (isInitialGeoJsonLoad.current) { + onProgressUpdate({ + current: loadedSources.size, + total: geoJsonMetadata.length, + }); + + if (loadedSources.size === geoJsonMetadata.length) { + isInitialGeoJsonLoad.current = false; + } + } + }; + const handleData = (e: any) => { - const isRelevantSource = geoJsonMetadata.some( + const meta = geoJsonMetadata.find( ({ sourceId }) => e.sourceId === sourceId ); - if (!isRelevantSource || !e.isSourceLoaded) return; - - if (!loadedSources.has(e.sourceId)) { - loadedSources.add(e.sourceId); - if (isInitialGeoJsonLoad.current) { - onProgressUpdate({ - current: loadedSources.size, - total: geoJsonMetadata.length, - }); - - if (loadedSources.size === geoJsonMetadata.length) { - isInitialGeoJsonLoad.current = false; - } - } - } + if (!meta || !e.isSourceLoaded) return; + trackSource(meta.sourceId); }; map.current!.on("data", handleData); + + // Check sources that may have loaded before the listener was attached + for (const { sourceId } of geoJsonMetadata) { + if (map.current!.isSourceLoaded(sourceId)) { + trackSource(sourceId); + } + } }; if (map.current!.isStyleLoaded()) { @@ -1397,16 +1426,30 @@ export const LibreMap = ({ return () => { aborted = true; }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- markerSymbolSize handled by dedicated effect below }, [ backgroundStyle, vectorBackgroundLayers, layers, clusteringEnabled, - markerSymbolSize, filterFunction, layerMode, ]); + useEffect(() => { + if ( + !map.current || + layerMode === "imperative" || + baseStyleLayersRef.current.length === 0 + ) + return; + applySymbolScalingToMap( + map.current, + markerSymbolSize, + baseStyleLayersRef.current + ); + }, [markerSymbolSize, layerMode]); + const getLeafletMap = useCallback(() => { const m = map.current; if (!m) return null; diff --git a/libraries/mapping/engines/maplibre/src/hooks/useImperativeStyle.ts b/libraries/mapping/engines/maplibre/src/hooks/useImperativeStyle.ts index aca36336e..f7d417799 100644 --- a/libraries/mapping/engines/maplibre/src/hooks/useImperativeStyle.ts +++ b/libraries/mapping/engines/maplibre/src/hooks/useImperativeStyle.ts @@ -97,6 +97,8 @@ export function useImperativeStyle({ const prevIdsRef = useRef([]); const prevOpacitiesRef = useRef>(new Map()); const isApplyingRef = useRef(false); + const markerSymbolSizeRef = useRef(markerSymbolSize); + markerSymbolSizeRef.current = markerSymbolSize; // Stable callback: add all effective layers to the composer const applyAllLayers = useCallback( @@ -126,7 +128,7 @@ export function useImperativeStyle({ if (layer.type === "vector") { await composer.addVectorSubStyle(layer, { opacity: layer.opacity, - markerSymbolSize, + markerSymbolSize: markerSymbolSizeRef.current, zIndex: i, }); } else if (layer.type === "geojson") { @@ -209,11 +211,11 @@ export function useImperativeStyle({ isApplyingRef.current = false; } }, + // eslint-disable-next-line react-hooks/exhaustive-deps -- markerSymbolSize accessed via ref [ vectorBackgroundLayers, layers, clusteringEnabled, - markerSymbolSize, filterFunction, onMappingUpdate, onGeoJsonMetadataUpdate, @@ -369,7 +371,7 @@ export function useImperativeStyle({ if (layer.type === "vector") { await composer.addVectorSubStyle(layer, { opacity: layer.opacity, - markerSymbolSize, + markerSymbolSize: markerSymbolSizeRef.current, zIndex: i, beforeId, }); @@ -437,13 +439,13 @@ export function useImperativeStyle({ return () => { aborted = true; }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- markerSymbolSize accessed via ref }, [ enabled, map, layers, vectorBackgroundLayers, clusteringEnabled, - markerSymbolSize, filterFunction, onMappingUpdate, onGeoJsonMetadataUpdate, diff --git a/libraries/mapping/engines/maplibre/src/utils/styleBuilder.ts b/libraries/mapping/engines/maplibre/src/utils/styleBuilder.ts index d1a3a1814..13863a5d6 100644 --- a/libraries/mapping/engines/maplibre/src/utils/styleBuilder.ts +++ b/libraries/mapping/engines/maplibre/src/utils/styleBuilder.ts @@ -184,6 +184,68 @@ export const styleManipulation = ( return newStyle; }; +export const applySymbolScalingToMap = ( + map: import("maplibre-gl").Map, + markerSymbolSize: number, + baseStyleLayers: LayerSpecification[] +): void => { + const scale = (markerSymbolSize / 35) * 1.35; + + for (const baseLayer of baseStyleLayers) { + if (baseLayer.type !== "symbol") continue; + const layout = baseLayer.layout || {}; + const layerId = baseLayer.id; + + // Check layer still exists on the map + if (!map.getLayer(layerId)) continue; + + const iconSize = layout["icon-size"]; + if (iconSize !== undefined) { + if (typeof iconSize === "number") { + map.setLayoutProperty(layerId, "icon-size", iconSize * scale); + } else if (Array.isArray(iconSize) && iconSize[0] === "interpolate") { + const newIconSize = [...iconSize] as unknown[]; + for (let i = 3; i < newIconSize.length; i += 2) { + if (typeof newIconSize[i + 1] === "number") { + (newIconSize[i + 1] as number) = + (newIconSize[i + 1] as number) * scale; + } + } + map.setLayoutProperty(layerId, "icon-size", newIconSize); + } + } + + const textSize = layout["text-size"]; + if (textSize !== undefined && typeof textSize === "number") { + map.setLayoutProperty(layerId, "text-size", textSize * scale); + } + + const textOffset = layout["text-offset"]; + if (textOffset !== undefined) { + if (Array.isArray(textOffset) && textOffset[0] === "interpolate") { + const newTextOffset = [...textOffset] as unknown[]; + for (let i = 3; i < newTextOffset.length; i += 2) { + if ( + Array.isArray(newTextOffset[i + 1]) && + (newTextOffset[i + 1] as unknown[])[0] === "literal" + ) { + const literalArray = [ + ...((newTextOffset[i + 1] as unknown[])[1] as number[]), + ] as number[]; + literalArray[1] = literalArray[1] * scale; + newTextOffset[i + 1] = ["literal", literalArray]; + } + } + map.setLayoutProperty(layerId, "text-offset", newTextOffset); + } else if (Array.isArray(textOffset) && textOffset.length === 2) { + const x = typeof textOffset[0] === "number" ? textOffset[0] : 0; + const y = typeof textOffset[1] === "number" ? textOffset[1] : 0; + map.setLayoutProperty(layerId, "text-offset", [x, y * scale]); + } + } + } +}; + /** * Get vector layer mapping from WMS capabilities or style metadata */ From 7909920f1b74cf13a65480d9bfdd18d4db65f09b Mon Sep 17 00:00:00 2001 From: David Glogaza Date: Fri, 10 Apr 2026 10:37:42 +0200 Subject: [PATCH 23/27] add switch to neutral state --- .../src/lib/components/TriStateFilterButton.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/libraries/mapping/components/src/lib/components/TriStateFilterButton.tsx b/libraries/mapping/components/src/lib/components/TriStateFilterButton.tsx index 66ef822ce..e242feea2 100644 --- a/libraries/mapping/components/src/lib/components/TriStateFilterButton.tsx +++ b/libraries/mapping/components/src/lib/components/TriStateFilterButton.tsx @@ -36,7 +36,9 @@ export const TriStateFilterButton = ({