diff --git a/apps/geoportal/src/app/components/layers/GeoportalLayerButton.tsx b/apps/geoportal/src/app/components/layers/GeoportalLayerButton.tsx index 71ff8993e7..3420212db3 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,54 +155,13 @@ const GeoportalLayerButton = ({ } }, [layersLength]); - const filterAppliedRef = useRef(false); - useEffect(() => { - if (!layer.filterConfig || !layer.filterState || filterAppliedRef.current) - return; - - const mapEntry = maplibreMaps?.find((entry) => entry.id === id); - if (!mapEntry?.map) return; - - const libreMap = mapEntry.map; - try { - const originals = captureOriginalFilters( - layer.filterConfig.layerPattern, - libreMap - ); - - const filterExpression = buildFilterExpression( - layer.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/apps/geoportal/src/app/components/layers/InteractionView.tsx b/apps/geoportal/src/app/components/layers/InteractionView.tsx index 3f8f2ace00..3408cd38dd 100644 --- a/apps/geoportal/src/app/components/layers/InteractionView.tsx +++ b/apps/geoportal/src/app/components/layers/InteractionView.tsx @@ -12,6 +12,7 @@ import { createFilterButtons, FilterInfo, FilterState, + PoiFilterPanel, } from "@carma-mapping/components"; import { getSelectedFeature, @@ -26,6 +27,7 @@ import { useFilterBackground } from "./useFilterBackground"; import FilterBackdrop from "./FilterBackdrop"; const InteractionView = ({ isDragging }: { isDragging?: boolean }) => { + const [filterState, setFilterState] = useState(); const dispatch = useDispatch(); const activeInteractionLayerID = useSelector(getActiveInteractionLayerID); const layers = useSelector(getLayers); @@ -44,13 +46,53 @@ const InteractionView = ({ isDragging }: { isDragging?: boolean }) => { isDragging ); + 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 (filterType === "poi") { + return ( +
+ {validBg && !isDragging && } +
+
+ { + dispatch( + setLayerFilterState({ id: layer.id, filterState: state }) + ); + dispatch( + setLayerFilterInfo({ id: layer.id, filterInfo: info }) + ); + }} + /> +
+
+
+ ); + } + + if (!FilterComponent) { return null; } diff --git a/apps/topicmaps/e-auto-ladestation/src/app/PieChart.jsx b/apps/topicmaps/e-auto-ladestation/src/app/PieChart.jsx index f8e1d17337..f279da0ef7 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 6f3bf6ca12..58a0f679b8 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 7a8e13ebf6..3a41b9cefb 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/klimaorte/vite.config.ts b/apps/topicmaps/klimaorte/vite.config.ts index 55dedcc026..d3da3cbc98 100644 --- a/apps/topicmaps/klimaorte/vite.config.ts +++ b/apps/topicmaps/klimaorte/vite.config.ts @@ -22,6 +22,10 @@ export default defineConfig({ plugins: [react(), nxViteTsPaths()], + optimizeDeps: { + include: ['leaflet', 'leaflet-snap'], + }, + // Uncomment this if you are using workers. // worker: { // plugins: [ nxViteTsPaths() ], diff --git a/apps/topicmaps/kulturstadtplan/src/app/components/menu/KulturPieChart.tsx b/apps/topicmaps/kulturstadtplan/src/app/components/menu/KulturPieChart.tsx index c0c1c415ec..5d7add6f19 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/luftmessstationen/vite.config.ts b/apps/topicmaps/luftmessstationen/vite.config.ts index fb1ff43f91..e7f2ecb504 100644 --- a/apps/topicmaps/luftmessstationen/vite.config.ts +++ b/apps/topicmaps/luftmessstationen/vite.config.ts @@ -22,6 +22,10 @@ export default defineConfig({ plugins: [react(), nxViteTsPaths()], + optimizeDeps: { + include: ['leaflet', 'leaflet-snap'], + }, + // Uncomment this if you are using workers. // worker: { // plugins: [ nxViteTsPaths() ], diff --git a/apps/topicmaps/ng-stadtplan/index.html b/apps/topicmaps/ng-stadtplan/index.html new file mode 100644 index 0000000000..e7a980db03 --- /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 0000000000..a5b8c605be --- /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 0000000000..2f2e691090 --- /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 0000000000..c0b440dacd Binary files /dev/null and b/apps/topicmaps/ng-stadtplan/public/favicon.ico differ diff --git a/apps/topicmaps/ng-stadtplan/src/app/App.tsx b/apps/topicmaps/ng-stadtplan/src/app/App.tsx new file mode 100644 index 0000000000..cf5b41bbb7 --- /dev/null +++ b/apps/topicmaps/ng-stadtplan/src/app/App.tsx @@ -0,0 +1,189 @@ +import { useState, useCallback, useEffect, useMemo, useRef } from "react"; +import { ProgressIndicator, useProgress } from "@carma-appframeworks/portals"; +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, + 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"; +import "react-cismap/topicMaps.css"; +import "leaflet/dist/leaflet.css"; + +export default function App() { + const { progress, showProgress, handleProgressUpdate } = useProgress(); + + const [allFeatures, setAllFeatures] = useState([]); + const [lebenslagen, setLebenslagen] = useState([]); + const [filterState, setFilterState] = useState({ + 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); + filterStateRef.current = filterState; + + // Capture original features and extract lebenslagen on first filterFunction call + const handleFilter = useCallback( + (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 + | { data?: GeoJSON.FeatureCollection } + | undefined; + 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 restored = readFilterFromStorage(data.lebenslagen); + const initialFilter = restored ?? { + positiv: data.lebenslagen, + negativ: [], + }; + filterStateRef.current = initialFilter; + setFilterState(initialFilter); + } + + // Re-apply filter (also handles style rebuilds that recreate the source) + 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 unknown as { __carmaMap?: maplibregl.Map }) + .__carmaMap; + if (!map) return; + + applyPoiFilter( + map, + allFeaturesRef.current, + allKombisRef.current, + filterState + ); + + writeFilterToStorage(filterState, lebenslagen); + }, [filterState, lebenslagen]); + + const { pieChartData, pieChartColors } = useMemo( + () => computePieChartStats(allFeatures, allKombisRef.current, filterState), + [filterState, allFeatures] + ); + + const filteredPoiCount = useMemo( + () => pieChartData.reduce((sum, [, count]) => sum + count, 0), + [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) => { + 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 unknown as { __carmaMap?: maplibregl.Map }) + .__carmaMap; + if (!map) return; + + const updateVisibleCount = () => { + const bounds = map.getBounds(); + let count = 0; + for (const feature of filteredFeatures) { + if (feature.geometry?.type !== "Point") continue; + const [lng, lat] = feature.geometry.coordinates; + if (bounds.contains([lng, lat])) count++; + } + setVisiblePoiCount(count); + }; + + map.on("moveend", updateVisibleCount); + updateVisibleCount(); + + return () => { + map.off("moveend", updateVisibleCount); + }; + }, [filteredFeatures]); + + const libreLayers = useMemo(() => [POI_LAYER_CONFIG], []); + + const categories = useMemo( + () => lebenslagen.map((ll) => ({ key: ll, label: ll })), + [lebenslagen] + ); + + return ( + <> + + {showFilterTitle && ( + + )} + + } + /> + + ); +} 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 0000000000..7cd0b47271 --- /dev/null +++ b/apps/topicmaps/ng-stadtplan/src/app/FilterUI.tsx @@ -0,0 +1,118 @@ +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 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[] = []; + 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 new file mode 100644 index 0000000000..1a5a76f38e --- /dev/null +++ b/apps/topicmaps/ng-stadtplan/src/app/Menu.tsx @@ -0,0 +1,117 @@ +import { useContext } from "react"; +import CustomizationContextProvider from "react-cismap/contexts/CustomizationContextProvider"; +import { UIDispatchContext } from "react-cismap/contexts/UIContextProvider"; +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"; +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 type { + AdvancedFilterCategory, + AdvancedFilterState, +} from "@carma-mapping/components"; +import FilterUI from "./FilterUI"; + +interface MenuProps { + categories?: AdvancedFilterCategory[]; + filterState?: AdvancedFilterState; + onFilterStateChange?: (state: AdvancedFilterState) => void; + pieChartData?: [string, number][]; + pieChartColors?: string[]; + filteredPoiCount?: number; + visiblePoiCount?: number; + totalPoiCount?: number; + onTitleDisplayChange?: (show: boolean) => void; +} + +const Menu = ({ + categories, + filterState, + onFilterStateChange, + pieChartData, + pieChartColors, + filteredPoiCount = 0, + visiblePoiCount = 0, + totalPoiCount = 0, + onTitleDisplayChange, +}: MenuProps) => { + const { setAppMenuActiveMenuSection } = + useContext(UIDispatchContext); + + const hasFilter = categories && filterState && onFilterStateChange; + + const getFilterHeader = () => { + const term = filteredPoiCount === 1 ? "POI" : "POIs"; + return `Mein Themenstadtplan (${filteredPoiCount} ${term} gefunden, davon ${visiblePoiCount} in der Karte)`; + }; + + return ( + + } + menuFooter={ +