From e08f07742f8d75c46216c469481c9ddb7545cfb2 Mon Sep 17 00:00:00 2001 From: Friedrich Hartmann Date: Mon, 24 Nov 2025 23:11:13 +0100 Subject: [PATCH 01/20] Fix/00459 measurements touch bug snapping (#468) * typescript build of measure path * all typescript measurement lib * new watch build target * infobox moved out of portals lib * delete measurement playground * remove dead code * wip block leaflet draw touch events * fix touch issue * code cleanup * unified snapping hook * draw update guide to active snapping point * remove old InfoBoxMeasurement in commons/cismap lib * clean up types in measurements * move per measurement state into the hook * sync labels with snap * remove logging in geoportal prod builds * fix alkis desktop measurement topic map ref handling * limit snapping by zoom level * fix typing for snapping and deduplicate extraction --- .vscode/settings.json | 14 +- apps/geoportal/project.json | 62 +- apps/geoportal/src/app/App.tsx | 66 +- .../components/GeoportalMap/GeoportalMap.tsx | 34 +- .../GeoportalMap/controls/MapWrapper.tsx | 27 +- apps/geoportal/src/app/config/featureFlags.ts | 4 - apps/geoportal/vite.config.mts | 203 ++- .../desktop/src/components/commons/Map.jsx | 28 +- libraries/commons/measurements/project.json | 7 + libraries/commons/measurements/src/index.d.ts | 158 +- libraries/commons/measurements/src/index.ts | 8 +- .../src/lib/components/InfoBoxMeasurement.tsx | 9 +- .../components/MapMeasurementsProvider.tsx | 392 ----- .../src/lib/components/MeasurementControl.tsx | 33 +- .../lib/components/MeasurementStatusDebug.tsx | 34 - .../src/lib/components/MeasurementTitle.tsx | 13 +- .../lib/context/MapMeasurementsContext.d.ts | 28 + .../lib/context/MapMeasurementsContext.tsx | 127 ++ .../lib/context/MapMeasurementsProvider.tsx | 349 +++++ .../hooks/useMapMeasurementsContext.ts | 17 + .../measurements/src/lib/context/index.ts | 18 + .../useMeasurements.ts} | 913 +++++++---- .../measurements/src/lib/lib-measurements.tsx | 338 ----- .../measurements/src/lib/snapping/types.ts | 22 - .../snapping/utils/coordinateExtraction.ts | 2 +- .../lib/snapping/utils/mapLibreExtraction.ts | 68 + .../src/lib/types/MeasurementShape.d.ts | 15 + .../src/lib/types/SnappingPoint.d.ts | 10 + .../measurements/src/lib/types/index.ts | 10 + .../src/lib/types/leaflet-extensions.d.ts | 290 ++++ .../measurements/src/lib/utils/helper.ts | 33 +- .../src/lib/utils/measure-path.js | 1154 -------------- .../src/lib/utils/measure-path.ts | 1331 +++++++++++++++++ .../src/lib/utils/{measure.js => measure.ts} | 253 ++-- .../src/lib/utils/measurement-geometry.ts | 128 ++ .../src/lib/utils/vertex-click-handler.ts | 159 ++ libraries/commons/measurements/tsconfig.json | 1 - .../commons/measurements/tsconfig.lib.json | 7 +- libraries/commons/measurements/vite.config.ts | 37 + .../mapping/engines/leaflet/src/index.ts | 41 + .../mapping/engines/leaflet/src/lib/LatLng.ts | 17 +- .../mapping/engines/leaflet/src/lib/Map.ts | 9 +- .../src/lib/hooks/useLeafletZoomControls.ts | 2 +- libraries/mapping/utils/tsconfig.json | 3 +- libraries/mapping/utils/tsconfig.lib.json | 1 - libraries/mapping/utils/tsconfig.spec.json | 7 - libraries/types/src/index.d.ts | 1 - libraries/types/src/lib/carma-config.d.ts | 4 + .../types/src/lib/leaflet-extensions.d.ts | 274 ++++ .../components/map-measure/measure-path.js | 36 +- .../measurements-playground/index.html | 16 - .../postcss.config.cjs | 13 - .../measurements-playground/project.json | 68 - .../public/favicon.ico | Bin 15086 -> 0 bytes .../measurements-playground/src/app/App.tsx | 243 --- .../src/app/components/ModeButtons.tsx | 70 - .../src/app/components/RadiusSliders.tsx | 87 -- .../src/app/components/VectorLayerButton.tsx | 36 - .../src/app/helper/constants.js | 1 - .../src/app/store/index.ts | 83 - .../src/app/store/slices/mapping.ts | 23 - .../src/app/store/slices/ui.ts | 43 - .../src/app/utils/coordinateExtraction.ts | 178 --- .../src/assets/.gitkeep | 0 .../measurements-playground/src/main.tsx | 228 --- .../measurements-playground/src/styles.css | 33 - .../tailwind.config.cjs | 15 - .../measurements-playground/tsconfig.json | 6 - .../tsconfig.spec.json | 28 - .../measurements-playground/vite.config.ts | 55 - 70 files changed, 4069 insertions(+), 3954 deletions(-) delete mode 100644 libraries/commons/measurements/src/lib/components/MapMeasurementsProvider.tsx delete mode 100644 libraries/commons/measurements/src/lib/components/MeasurementStatusDebug.tsx create mode 100644 libraries/commons/measurements/src/lib/context/MapMeasurementsContext.d.ts create mode 100644 libraries/commons/measurements/src/lib/context/MapMeasurementsContext.tsx create mode 100644 libraries/commons/measurements/src/lib/context/MapMeasurementsProvider.tsx create mode 100644 libraries/commons/measurements/src/lib/context/hooks/useMapMeasurementsContext.ts create mode 100644 libraries/commons/measurements/src/lib/context/index.ts rename libraries/commons/measurements/src/lib/{components/MeasurementsSnapping.tsx => hooks/useMeasurements.ts} (50%) delete mode 100644 libraries/commons/measurements/src/lib/lib-measurements.tsx delete mode 100644 libraries/commons/measurements/src/lib/snapping/types.ts create mode 100644 libraries/commons/measurements/src/lib/snapping/utils/mapLibreExtraction.ts create mode 100644 libraries/commons/measurements/src/lib/types/MeasurementShape.d.ts create mode 100644 libraries/commons/measurements/src/lib/types/SnappingPoint.d.ts create mode 100644 libraries/commons/measurements/src/lib/types/index.ts create mode 100644 libraries/commons/measurements/src/lib/types/leaflet-extensions.d.ts delete mode 100644 libraries/commons/measurements/src/lib/utils/measure-path.js create mode 100644 libraries/commons/measurements/src/lib/utils/measure-path.ts rename libraries/commons/measurements/src/lib/utils/{measure.js => measure.ts} (58%) create mode 100644 libraries/commons/measurements/src/lib/utils/measurement-geometry.ts create mode 100644 libraries/commons/measurements/src/lib/utils/vertex-click-handler.ts create mode 100644 libraries/commons/measurements/vite.config.ts delete mode 100644 playgrounds/measurements-playground/index.html delete mode 100644 playgrounds/measurements-playground/postcss.config.cjs delete mode 100644 playgrounds/measurements-playground/project.json delete mode 100644 playgrounds/measurements-playground/public/favicon.ico delete mode 100644 playgrounds/measurements-playground/src/app/App.tsx delete mode 100644 playgrounds/measurements-playground/src/app/components/ModeButtons.tsx delete mode 100644 playgrounds/measurements-playground/src/app/components/RadiusSliders.tsx delete mode 100644 playgrounds/measurements-playground/src/app/components/VectorLayerButton.tsx delete mode 100644 playgrounds/measurements-playground/src/app/helper/constants.js delete mode 100644 playgrounds/measurements-playground/src/app/store/index.ts delete mode 100644 playgrounds/measurements-playground/src/app/store/slices/mapping.ts delete mode 100644 playgrounds/measurements-playground/src/app/store/slices/ui.ts delete mode 100644 playgrounds/measurements-playground/src/app/utils/coordinateExtraction.ts delete mode 100644 playgrounds/measurements-playground/src/assets/.gitkeep delete mode 100644 playgrounds/measurements-playground/src/main.tsx delete mode 100644 playgrounds/measurements-playground/src/styles.css delete mode 100644 playgrounds/measurements-playground/tailwind.config.cjs delete mode 100644 playgrounds/measurements-playground/tsconfig.json delete mode 100644 playgrounds/measurements-playground/tsconfig.spec.json delete mode 100644 playgrounds/measurements-playground/vite.config.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 1803a611d3..322349349a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -20,9 +20,13 @@ "dist/**": true, "node_modules/**": true }, - "cSpell.words": [ - "alkis", - "flur", - "landparcel" - ] + "cSpell.language": "en,de", + "cSpell.customDictionaries": { + "carma-project": { + "name": "CARMA Project Dictionary", + "path": "./.vscode/carma-project-dictionary.txt", + "addWords": true + } + }, + "debug.javascript.autoAttachFilter": "onlyWithFlag" } diff --git a/apps/geoportal/project.json b/apps/geoportal/project.json index 73f8808632..349a1cca43 100644 --- a/apps/geoportal/project.json +++ b/apps/geoportal/project.json @@ -12,12 +12,24 @@ "options": { "outputPath": "dist/apps/geoportal" }, + "inputs": [ + "production", + "^production", + { + "externalDependencies": ["vite"] + } + ], "configurations": { "development": { "mode": "development" }, "production": { "mode": "production" + }, + "preview": { + "mode": "development", + "sourcemap": true, + "minify": false } } }, @@ -52,6 +64,9 @@ }, "production": { "buildTarget": "geoportal:build:production" + }, + "preview": { + "buildTarget": "geoportal:build:preview" } } }, @@ -60,8 +75,53 @@ "options": { "command": "NODE_OPTIONS='--max-old-space-size=16384' vite preview --host localhost --port 4300", "cwd": "apps/geoportal" + } + }, + "preview:build": { + "executor": "nx:run-commands", + "options": { + "commands": [ + "npx nx run geoportal:build:preview", + "NODE_OPTIONS='--max-old-space-size=16384' vite preview --host localhost --port 4300" + ], + "cwd": "apps/geoportal", + "parallel": false + } + }, + "build:preview": { + "executor": "@nx/vite:build", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/apps/geoportal", + "mode": "development", + "sourcemap": true, + "minify": false }, - "dependsOn": ["build"] + "inputs": [ + "production", + "^production", + { + "externalDependencies": ["vite"] + } + ] + }, + "watch:preview": { + "executor": "nx:run-commands", + "options": { + "command": "NODE_OPTIONS='--max-old-space-size=16384' vite build --watch --mode development --sourcemap --minify false", + "cwd": "apps/geoportal" + } + }, + "dev:preview": { + "executor": "nx:run-commands", + "options": { + "commands": [ + "NODE_OPTIONS='--max-old-space-size=16384' vite build --watch --mode development --sourcemap --minify false", + "sleep 5 && NODE_OPTIONS='--max-old-space-size=16384' vite preview --host localhost --port 4300" + ], + "cwd": "apps/geoportal", + "parallel": true + } }, "test": { "executor": "@nx/vite:test", diff --git a/apps/geoportal/src/app/App.tsx b/apps/geoportal/src/app/App.tsx index 4d0e1ce874..ebf6c960c4 100644 --- a/apps/geoportal/src/app/App.tsx +++ b/apps/geoportal/src/app/App.tsx @@ -27,10 +27,7 @@ import { } from "@carma-providers/feature-flag"; import { HashStateProvider } from "@carma-providers/hash-state"; import { useCesiumDevConsoleTrigger } from "@carma-mapping/engines/cesium"; -import { - MapMeasurementsProvider, - MEASUREMENT_MODE, -} from "@carma-commons/measurements"; +import { MapMeasurementsProvider } from "@carma-commons/measurements"; // Local Modules import AppErrorFallback from "./components/AppErrorFallback"; @@ -65,13 +62,7 @@ const MEASUREMENTS_BASE_CONFIG = { }; import { getCustomFeatureFlags } from "./store/slices/layers"; -import { - getShowLoginModal, - getUIMode, - setShowLoginModal, - setUIMode, - UIMode, -} from "./store/slices/ui"; +import { getShowLoginModal, setShowLoginModal } from "./store/slices/ui"; // Side-Effect Imports import "bootstrap/dist/css/bootstrap.min.css"; @@ -87,39 +78,6 @@ function CesiumDevConsoleIntegration() { return null; } -function MeasurementsWrapper({ - children, - baseConfig, - externalMode, - setModeExternal, -}: { - children: React.ReactNode; - baseConfig: typeof MEASUREMENTS_BASE_CONFIG; - externalMode: MEASUREMENT_MODE; - setModeExternal: (mode: MEASUREMENT_MODE) => void; -}) { - const flags = useFeatureFlags(); - - // Memoize config to prevent recreation on every render - const config = useMemo( - () => ({ - ...baseConfig, - snappingEnabled: flags.isSnappingEnabled ?? baseConfig.snappingEnabled, - }), - [flags.isSnappingEnabled] - ); - - return ( - - {children} - - ); -} - function App({ published }: { published?: boolean }) { const dispatch = useDispatch(); const showLoginModal = useSelector(getShowLoginModal); @@ -128,18 +86,6 @@ function App({ published }: { published?: boolean }) { const syncToken = useSyncToken(); useKeyboardShortcuts(); const customFeatureFlags = useSelector(getCustomFeatureFlags); - const uiMode = useSelector(getUIMode); - const mode = - uiMode === UIMode.MEASUREMENT - ? MEASUREMENT_MODE.MEASUREMENT - : MEASUREMENT_MODE.DEFAULT; - const handleSetMode = (newMode: MEASUREMENT_MODE) => { - const newUIMode = - newMode === MEASUREMENT_MODE.MEASUREMENT - ? UIMode.MEASUREMENT - : UIMode.DEFAULT; - dispatch(setUIMode(newUIMode)); - }; // Memoize config objects to prevent recreation on every render const featureFlagsMergedConfig = useMemo( @@ -174,11 +120,7 @@ function App({ published }: { published?: boolean }) { config={OBLIQUE_CONFIG} fallbackDirectionConfig={CAMERA_ID_TO_DIRECTION} > - +
{isLoadingConfig && ( @@ -220,7 +162,7 @@ function App({ published }: { published?: boolean }) {
-
+ diff --git a/apps/geoportal/src/app/components/GeoportalMap/GeoportalMap.tsx b/apps/geoportal/src/app/components/GeoportalMap/GeoportalMap.tsx index 08eb0d7cb0..0def74dd9e 100644 --- a/apps/geoportal/src/app/components/GeoportalMap/GeoportalMap.tsx +++ b/apps/geoportal/src/app/components/GeoportalMap/GeoportalMap.tsx @@ -41,7 +41,10 @@ import { getCollabedHelpComponentConfig as getCollabedHelpElementsConfig } from import { ENDPOINT, isAreaType } from "@carma-commons/resources"; import type { FeatureInfo } from "@carma/types"; -import { Measurements } from "@carma-commons/measurements"; +import { + useMeasurements, + InfoBoxMeasurement, +} from "@carma-commons/measurements"; import { useOverlayHelper, @@ -67,7 +70,6 @@ import { useFeatureFlags } from "@carma-providers/feature-flag"; import { useHashState } from "@carma-providers/hash-state"; import FeatureInfoBox from "../feature-info/FeatureInfoBox.tsx"; -import { InfoBoxMeasurement } from "@carma-commons/measurements"; import PrintPreview from "../map-print/PrintPreview.tsx"; import versionData from "../../../version.json"; @@ -140,6 +142,19 @@ export const GeoportalMap = ({ height, width, allow3d }: MapProps) => { const container3dMapRef = useRef(null); // Store MapLibre maps outside Redux to avoid serialization issues const maplibreMapsRef = useRef>(new Map()); + const [tick, setTick] = useState(0); + + useEffect(() => { + // Monkey-patch the set method to trigger re-renders + const originalSet = maplibreMapsRef.current.set.bind( + maplibreMapsRef.current + ); + maplibreMapsRef.current.set = (key, value) => { + const res = originalSet(key, value); + setTick((t) => t + 1); + return res; + }; + }, []); // State and Selectors const backgroundLayer = useSelector(getBackgroundLayer); @@ -157,9 +172,13 @@ export const GeoportalMap = ({ height, width, allow3d }: MapProps) => { const markerAsset = models[CESIUM_CONFIG.markerKey]; // const markerAnchorHeight = CESIUM_CONFIG.markerAnchorHeight ?? 10; const layers = useSelector(getLayers); - const maplibreMaps = layers - .filter((l) => l.layerType === "vector" && l.visible) - .map((l) => maplibreMapsRef.current.get(l.id)); + const maplibreMaps = useMemo( + () => + layers + .filter((l) => l.layerType === "vector" && l.visible) + .map((l) => maplibreMapsRef.current.get(l.id)), + [layers, tick] + ); const uiMode = useSelector(getUIMode); const isModeMeasurement = uiMode === UIMode.MEASUREMENT; const isModeFeatureInfo = uiMode === UIMode.FEATURE_INFO; @@ -456,6 +475,8 @@ export const GeoportalMap = ({ height, width, allow3d }: MapProps) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [backgroundLayer]); + useMeasurements(maplibreMaps); + useEffect(() => { const leaflet = getLeafletMap(); if (uiMode !== UIMode.FEATURE_INFO && marker !== undefined && leaflet) { @@ -599,8 +620,6 @@ export const GeoportalMap = ({ height, width, allow3d }: MapProps) => { ] ); - // TODO Move out Controls to own component - console.debug( "RENDER: [GEOPORTAL] MAP", rerenderCountRef.current, @@ -806,7 +825,6 @@ export const GeoportalMap = ({ height, width, allow3d }: MapProps) => { {useCreateCismapLayers(layers, createLayerOptions)} - {allow3d && cesiumCanInitializeRef.current && ( diff --git a/apps/geoportal/src/app/components/GeoportalMap/controls/MapWrapper.tsx b/apps/geoportal/src/app/components/GeoportalMap/controls/MapWrapper.tsx index 12437f33af..2702ad5f0e 100644 --- a/apps/geoportal/src/app/components/GeoportalMap/controls/MapWrapper.tsx +++ b/apps/geoportal/src/app/components/GeoportalMap/controls/MapWrapper.tsx @@ -50,7 +50,10 @@ import { ControlLayoutCanvas, } from "@carma-mapping/map-controls-layout"; import { useFeatureFlags } from "@carma-providers/feature-flag"; -import { MeasurementControl } from "@carma-commons/measurements"; +import { + MeasurementControl, + useMapMeasurementsContext, +} from "@carma-commons/measurements"; import { GeoportalMap } from "../GeoportalMap.tsx"; import LibreGeoportalMap from "../LibreGeoportalMap.tsx"; @@ -87,6 +90,7 @@ import { getZenMode, setZenMode, toggleUIMode, + setUIMode, UIMode, } from "../../../store/slices/ui.ts"; @@ -102,14 +106,6 @@ const MapWrapper = () => { const dispatch = useDispatch(); const flags = useFeatureFlags(); - // Detect mobile device or browser's device toolbar (responsive design mode) - const isMobileDevice = - isMobile || - /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test( - navigator.userAgent - ) || - "ontouchstart" in window; - const showLibreMap = flags.featureFlagLibreMap; const rerenderCountRef = useRef(0); @@ -139,6 +135,17 @@ const MapWrapper = () => { const { isObliqueMode, isPreviewVisible: isObliquePreviewVisible } = useOblique(); + const { isMeasurementEnabled } = useMapMeasurementsContext(); + + useEffect(() => { + // sync legacy redux measurement mode with ui mode, remove once measurement provider handles this fully + if (isMeasurementEnabled && uiMode !== UIMode.MEASUREMENT) { + dispatch(setUIMode(UIMode.MEASUREMENT)); + } else if (!isMeasurementEnabled && uiMode === UIMode.MEASUREMENT) { + dispatch(setUIMode(UIMode.DEFAULT)); + } + }, [isMeasurementEnabled, uiMode, dispatch]); + const { handleZoomIn: handleZoomInCesium, handleZoomOut: handleZoomOutCesium, @@ -422,7 +429,7 @@ const MapWrapper = () => { )} - {!isObliquePreviewVisible && !isMobileDevice && ( + {!isObliquePreviewVisible && ( { + const isDevelopment = mode === 'development'; + + return { + root: __dirname, + cacheDir: "../../node_modules/.vite/apps/geoportal", + + // Pre-bundle dependencies to avoid re-transforming + optimizeDeps: { + include: [ + 'react', + 'react-dom', + 'react-redux', + '@reduxjs/toolkit', + 'redux-persist', + 'localforage', + 'react-router-dom', + 'antd', + '@ant-design/icons', + 'leaflet', + 'react-leaflet', + 'react-cismap', + ], + // Force deps to be bundled even in dev + force: false, + }, + + // Force NODE_ENV for React development build in preview mode + define: { + 'process.env.NODE_ENV': JSON.stringify(isDevelopment ? 'development' : 'production'), + 'global': {}, + }, + + esbuild: { + // Drop console logs in production + pure: mode === 'production' ? ['console.log', 'console.debug', 'console.info'] : [], + }, server: { port: 4200, @@ -25,87 +59,100 @@ export default defineConfig({ }, plugins: [ - react(), - nxViteTsPaths(), - viteStaticCopy({ - targets: [ - { - src: "../../node_modules/cesium/Build/Cesium/*", - dest: CESIUM_PATHNAME, - }, - ], - silent: false, - }), - ], + react(), + nxViteTsPaths(), + viteStaticCopy({ + targets: [ + { + src: "../../node_modules/cesium/Build/Cesium/*", + dest: CESIUM_PATHNAME, + }, + ], + silent: false, + }), + ], - worker: { - plugins: () => [nxViteTsPaths()], - }, + worker: { + plugins: () => [nxViteTsPaths()], + }, - build: { - outDir: "../../dist/apps/geoportal", - reportCompressedSize: true, - // 'hidden' generates sourcemaps but doesn't reference them in bundle - sourcemap: process.env.NODE_ENV === 'production' ? 'hidden' : true, - // Disable minification in development for readable stack traces - minify: process.env.NODE_ENV === 'production' ? 'esbuild' : false, - // Reduce memory pressure during build - chunkSizeWarningLimit: 1000, - rollupOptions: { - output: { - // Optimize chunks by load priority for better initial load & caching - manualChunks: { - 'vendor-react-core': [ - 'react', - 'react-dom', - 'react-redux', - '@reduxjs/toolkit', - 'redux-persist', - 'localforage', - 'react-router-dom' - ], - 'vendor-ui': ['antd', '@ant-design/icons'], - 'vendor-ui-icons': [ - '@fortawesome/react-fontawesome', - '@fortawesome/fontawesome-svg-core', - '@fortawesome/free-solid-svg-icons', - '@fortawesome/free-regular-svg-icons', - ], - 'vendor-leaflet': [ - 'leaflet', - 'react-leaflet', - 'leaflet-draw', - 'leaflet-editable', - ], - 'vendor-cismap': ['react-cismap'], - 'vendor-cesium': ['cesium'], - 'vendor-maplibre': ['maplibre-gl'], - }, - // Exclude vendor chunks from sourcemaps to save memory, but keep Cesium for debugging - sourcemapExcludeSources: true, - sourcemapIgnoreList: (relativeSourcePath) => { - // Exclude all node_modules EXCEPT cesium from sourcemaps - return relativeSourcePath.includes('node_modules') && - !relativeSourcePath.includes('node_modules/cesium') && - !relativeSourcePath.includes('node_modules/leaflet') && - !relativeSourcePath.includes('node_modules/react-cismap'); + build: { + outDir: "../../dist/apps/geoportal", + reportCompressedSize: true, + // 'hidden' generates sourcemaps but doesn't reference them in bundle + sourcemap: process.env.NODE_ENV === 'production' ? 'hidden' : true, + // Disable minification in development for readable stack traces + minify: process.env.NODE_ENV === 'production' ? 'esbuild' : false, + // Reduce memory pressure during build + chunkSizeWarningLimit: 1000, + // Skip type checking during build (use tsc separately if needed) + emitOnTypeCheck: false, + // Don't clear console on rebuild in watch mode + watch: { + clearScreen: false, + }, + rollupOptions: { + // Enable Rollup cache for faster rebuilds (experimental) + cache: true, + output: { + // Use content hash for vendor chunks - unchanged vendors keep same hash + entryFileNames: 'assets/[name].[hash].js', + chunkFileNames: 'assets/[name].[hash].js', + assetFileNames: 'assets/[name].[hash].[ext]', + // Optimize chunks by load priority for better initial load & caching + manualChunks: { + 'vendor-react-core': [ + 'react', + 'react-dom', + 'react-redux', + '@reduxjs/toolkit', + 'redux-persist', + 'localforage', + 'react-router-dom' + ], + 'vendor-ui': ['antd', '@ant-design/icons'], + 'vendor-ui-icons': [ + '@fortawesome/react-fontawesome', + '@fortawesome/fontawesome-svg-core', + '@fortawesome/free-solid-svg-icons', + '@fortawesome/free-regular-svg-icons', + ], + 'vendor-leaflet': [ + 'leaflet', + 'react-leaflet', + 'leaflet-draw', + 'leaflet-editable', + ], + 'vendor-cismap': ['react-cismap'], + 'vendor-cesium': ['cesium'], + 'vendor-maplibre': ['maplibre-gl'], + }, + // Exclude vendor chunks from sourcemaps to save memory, but keep Cesium for debugging + sourcemapExcludeSources: true, + sourcemapIgnoreList: (relativeSourcePath) => { + // Exclude all node_modules EXCEPT cesium from sourcemaps + return relativeSourcePath.includes('node_modules') && + !relativeSourcePath.includes('node_modules/cesium') && + !relativeSourcePath.includes('node_modules/leaflet') && + !relativeSourcePath.includes('node_modules/react-cismap'); + }, }, }, }, - }, - test: { - globals: true, - cache: { - dir: "../../node_modules/.vitest", - }, - environment: "jsdom", - include: ["src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"], + 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/geoportal", - provider: "v8", + reporters: ["default"], + coverage: { + reportsDirectory: "../../coverage/apps/geoportal", + provider: "v8", + }, }, - }, + }; }); diff --git a/apps/lagis/desktop/src/components/commons/Map.jsx b/apps/lagis/desktop/src/components/commons/Map.jsx index 70e6200cca..ca81b9e4a6 100644 --- a/apps/lagis/desktop/src/components/commons/Map.jsx +++ b/apps/lagis/desktop/src/components/commons/Map.jsx @@ -62,13 +62,15 @@ import { LibFuzzySearch } from "@carma-mapping/fuzzy-search"; import { isAreaType } from "@carma-commons/resources"; import { Control, ControlLayout } from "@carma-mapping/map-controls-layout"; import { ZoomControl } from "@carma-mapping/components"; -import { TopicMapDispatchContext } from "react-cismap/contexts/TopicMapContextProvider"; +import { + TopicMapDispatchContext, + TopicMapContext, +} from "react-cismap/contexts/TopicMapContextProvider"; import { MeasurementControl, InfoBoxMeasurement, useMapMeasurementsContext, - MEASUREMENT_MODE, - Measurements, + useMeasurements, } from "@carma-commons/measurements"; const { ScaleControl } = TransitiveReactLeaflet; @@ -136,6 +138,8 @@ const Map = ({ } = useContext(TopicMapStylingContext); const { setRoutedMapRef } = useContext(TopicMapDispatchContext); + const { realRoutedMapRef } = useContext(TopicMapContext); + const refRoutedMap = realRoutedMapRef; const isMapLoadingValue = useSelector(isMapLoading); let backgroundsFromMode; @@ -181,7 +185,7 @@ const Map = ({ lastPointSearchTimeRef.current = Date.now(); dispatch(storeShapeMode(mode)); if (mode === "point") { - setMeasurementMode("default"); + setMeasurementEnabled(false); } }; @@ -211,7 +215,6 @@ const Map = ({ // } }, [data?.featureCollection, urlParams]); - let refRoutedMap = useRef(null); const statusBarHeight = 20; const mapStyle = { width: mapWidth - 2 * padding, @@ -349,7 +352,7 @@ const Map = ({ } }, [ data?.featureCollection, - refRoutedMap.current, + refRoutedMap, isMapLoadingValue, activeBackgroundLayer, activeAdditionalLayers, @@ -359,9 +362,11 @@ const Map = ({ const { gazData } = useGazData(); const { setSelection } = useSelection(); - const { mode: measurementMode, setMode: setMeasurementMode } = + const { isMeasurementEnabled, setMeasurementEnabled } = useMapMeasurementsContext(); + useMeasurements(alkisMap ? [alkisMap] : []); + const onGazetteerSelection = (selection) => { if (!selection) { setSelection(null); @@ -491,7 +496,7 @@ const Map = ({ headStyle={{ backgroundColor: "white" }} type="inner" className={`overflow-hidden shadow-md ${ - measurementMode === MEASUREMENT_MODE.MEASUREMENT ? "lagis-map-card" : "" + isMeasurementEnabled ? "lagis-map-card" : "" }`} ref={cardRef} > @@ -515,12 +520,12 @@ const Map = ({ - {measurementMode === MEASUREMENT_MODE.MEASUREMENT && ( + {isMeasurementEnabled && ( )} @@ -566,7 +571,7 @@ const Map = ({ }} ondblclick={(event) => { // Don't switch landparcel when in measurement mode - if (measurementMode === MEASUREMENT_MODE.MEASUREMENT) { + if (isMeasurementEnabled) { return; } //if data contains a ondblclick handler, call it @@ -686,7 +691,6 @@ const Map = ({ jwt={jwt} mode={mode} /> - {/*
diff --git a/libraries/commons/measurements/project.json b/libraries/commons/measurements/project.json index 6d9384ab8e..97318c0c33 100644 --- a/libraries/commons/measurements/project.json +++ b/libraries/commons/measurements/project.json @@ -5,6 +5,13 @@ "projectType": "library", "tags": [], "targets": { + "build": { + "executor": "@nx/vite:build", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/libraries/commons/measurements" + } + }, "lint": { "executor": "@nx/eslint:lint" } diff --git a/libraries/commons/measurements/src/index.d.ts b/libraries/commons/measurements/src/index.d.ts index 7fc8c40bfe..7e6daf3eba 100644 --- a/libraries/commons/measurements/src/index.d.ts +++ b/libraries/commons/measurements/src/index.d.ts @@ -1,142 +1,16 @@ -export type ActiveShape = null | number | string | any; -export enum MEASUREMENT_MODE { - DEFAULT = "default", - MEASUREMENT = "measurement", -} - -export type MeasurementMapStatus = - | "INACTIVE" // not active - | "WAITING" // moving around not dragging anything and waiting for other stuff - | "DRAWING" // either lines or polygons but in the process - | "EDITING" // dragging vertices around - | "MOVING"; // dragging whole objects around - -export interface MapMeasurementsContextType { - mode: MEASUREMENT_MODE; - setMode: (mode: MEASUREMENT_MODE) => void; - shapes: any[]; - setShapes: (shapes: any[]) => void; - activeShape: ActiveShape; - setActiveShape: (shape: ActiveShape) => void; - visibleShapes: any[]; - setVisibleShapes: (shapes: any[]) => void; - snappingLatlng?: any; - setSnappingLatlng?: (coords: any) => void; - - showAll: boolean; - deleteAll: boolean; - drawingShape: boolean; - lastActiveShapeBeforeDrawing: null | any; - moveToShape: null | any; - updateShape: boolean; - mapMovingEnd: boolean; - updateTitleStatus: boolean; - setDrawingShape: (drawingShape: boolean) => void; - setShowAll: (showAll: boolean) => void; - setDeleteAll: (deleteAll: boolean) => void; - setMoveToShape: (moveToShape: any) => void; - setUpdateShape: (updateShape: boolean) => void; - setMapMovingEnd: (mapMovingEnd: boolean) => void; - setUpdateTitleStatus: (updateTitleStatus: boolean) => void; - setLastActiveShapeBeforeDrawing: (lastActiveShapeBeforeDrawing: any) => void; - addShape: (layer: any) => void; - deleteShapeById: (shapeId: string) => void; - deleteVisibleShapeById: (shapeId: string) => void; - updateShapeById: ( - shapeId: string, - newCoordinates?: any, - newDistance?: number, - newSquare?: number | null - ) => void; - setLastVisibleShapeActive: () => void; - setDrawingWithLastActiveShape: () => void; - setActiveShapeIfDrawCancelled: () => void; - toggleMeasurementMode: () => void; - updateAreaOfDrawing: (newArea: number) => void; - updateTitle: (shapeId: string | number, customTitle: string) => void; - setStartDrawing: (status: boolean) => void; - startDrawing: boolean; - currentDrawHandler: any; - setCurrentDrawHandler: (handler: any) => void; - completeCurrentShape: () => void; - config: MeasurementConfig; - status: MeasurementMapStatus; - setStatus: (status: MeasurementMapStatus) => void; -} - -export interface MeasurementShapeDrawing { - shapeId: number | string; - number: number; - coordinates?: unknown; - [key: string]: unknown; -} - -export type UIModeType = string | "measurement" | "default"; - -export interface MapMeasurementProps { - mode?: UIModeType; - polygonActiveIcon?: string; - polygonIcon?: string; -} - -export interface MeasurementShape { - shapeId: number | string; - distance?: number; - area?: number; - customTitle?: string; - shapeType?: "line" | "polygon" | string; - [key: string]: unknown; -} - -export interface InfoBoxMeasurementProps { - collapsedInfoBox?: boolean; - pixelWidth?: number; -} - -export interface MeasurementTitleProps { - title: string; - shapeId: number | string; - order: number; - updateTitleMeasurementById: (shapeId: number | string, title: string) => void; - setUpdateMeasurementStatus: (status: boolean) => void; - isCollapsed?: boolean; - collapsedContent?: string; - editable?: boolean; -} - -export interface MeasurementControlProps { - isActive?: boolean; - onToggle?: () => void; - position?: "topleft" | "topright" | "bottomleft" | "bottomright"; - order?: number; - iconBaseUrl?: string; - icons?: { - active: string; - inactive: string; - }; - altText?: string; - iconClassName?: string; - - // Universal features - disabled?: boolean; - useDisabledStyle?: boolean; - tooltip?: string | React.ReactNode; - tooltipPlacement?: "top" | "bottom" | "left" | "right"; - className?: string; - showInfoBox?: boolean; -} - -export interface MeasurementConfig { - editableTitle: boolean; - infoBoxHeaderColor: string; - localStorageKey: string; - snappingEnabled: boolean; - snappingOnUpdate: boolean; - snappingQueryRadius: number; - snappingMinZoom: number; - snappingRadiusVisible: boolean; - debugOutputMapStatus: boolean; - debugOutputMapStatusPosition: { x: number; y: number }; -} - -export type PartialMeasurementConfig = Partial; +/** + * Public Type Exports + * Re-exports types for external consumption + */ + +// Context types +export type { + ActiveShape, + MapMeasurementsContextType, + MeasurementConfig, + PartialMeasurementConfig, + MeasurementMapStatus, +} from "./lib/context/MapMeasurementsContext.d"; + +// Component types +export type { MeasurementShape } from "./lib/types/MeasurementShape"; diff --git a/libraries/commons/measurements/src/index.ts b/libraries/commons/measurements/src/index.ts index 0e0530c7a5..3af9e57e17 100644 --- a/libraries/commons/measurements/src/index.ts +++ b/libraries/commons/measurements/src/index.ts @@ -1,11 +1,11 @@ export { InfoBoxMeasurement } from "./lib/components/InfoBoxMeasurement"; export { MeasurementControl } from "./lib/components/MeasurementControl"; +export { MeasurementTitle } from "./lib/components/MeasurementTitle"; export { MapMeasurementsProvider, useMapMeasurementsContext, -} from "./lib/components/MapMeasurementsProvider"; -export { Measurements, MapMeasurementsObjects } from "./lib/lib-measurements"; -export { MeasurementsSnapping } from "./lib/components/MeasurementsSnapping"; -export { MeasurementStatusDebug } from "./lib/components/MeasurementStatusDebug"; + defaultConfig, +} from "./lib/context"; export { useMapLibreMap } from "./lib/hooks/useMapLibreMap"; +export { useMeasurements } from "./lib/hooks/useMeasurements"; export * from "./index.d"; diff --git a/libraries/commons/measurements/src/lib/components/InfoBoxMeasurement.tsx b/libraries/commons/measurements/src/lib/components/InfoBoxMeasurement.tsx index 293a014dcd..01a2fe656f 100644 --- a/libraries/commons/measurements/src/lib/components/InfoBoxMeasurement.tsx +++ b/libraries/commons/measurements/src/lib/components/InfoBoxMeasurement.tsx @@ -8,8 +8,12 @@ import "../styles/infoBox.css"; import { Tooltip } from "antd"; import { TopicMapContext } from "react-cismap/contexts/TopicMapContextProvider"; import { ResponsiveInfoBox } from "@carma-appframeworks/portals"; -import { useMapMeasurementsContext } from "./MapMeasurementsProvider"; -import { InfoBoxMeasurementProps, MeasurementShape } from "../.."; +import { useMapMeasurementsContext } from "../context"; +import { MeasurementShape } from "../types/MeasurementShape"; + +type InfoBoxMeasurementProps = { + pixelWidth?: number; +}; export function InfoBoxMeasurement({ pixelWidth = 350, @@ -183,7 +187,6 @@ export function InfoBoxMeasurement({ const deleteShapeHandler = (e: React.MouseEvent) => { e.stopPropagation(); - // the method activate delete process in MapMeasurementsObjects setDeleteAll(true); // cleanUpdateMeasurementStatus(); // setLastMeasureActive(); diff --git a/libraries/commons/measurements/src/lib/components/MapMeasurementsProvider.tsx b/libraries/commons/measurements/src/lib/components/MapMeasurementsProvider.tsx deleted file mode 100644 index dc26c4f4d7..0000000000 --- a/libraries/commons/measurements/src/lib/components/MapMeasurementsProvider.tsx +++ /dev/null @@ -1,392 +0,0 @@ -import { createContext, useContext, useEffect, useState } from "react"; -import { - ActiveShape, - MapMeasurementsContextType, - MeasurementConfig, - MeasurementMapStatus, - PartialMeasurementConfig, -} from "../../"; -import { setFromLocalforage, saveToLocalforage } from "../utils/helper"; - -enum MEASUREMENT_MODE { - DEFAULT = "default", - MEASUREMENT = "measurement", -} - -// Detect mobile devices -const isMobileDevice = () => { - const userAgent = typeof navigator !== "undefined" ? navigator.userAgent : ""; - const isMobileUA = - /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test( - userAgent - ); - const isSmallScreen = - typeof window !== "undefined" && - window.matchMedia("(max-width: 768px)").matches; - return isMobileUA || isSmallScreen; -}; - -const defaultConfig: MeasurementConfig = { - editableTitle: true, - infoBoxHeaderColor: "#3b82f6", - localStorageKey: "measurementShapes", - snappingEnabled: !isMobileDevice(), // Disable snapping on mobile - snappingOnUpdate: false, - snappingQueryRadius: 40, - snappingMinZoom: 17, - snappingRadiusVisible: false, - debugOutputMapStatus: false, - debugOutputMapStatusPosition: { x: 65, y: 15 }, -}; - -export const MapMeasurementsContext = createContext( - { - mode: MEASUREMENT_MODE.DEFAULT, - setMode: (mode: MEASUREMENT_MODE) => {}, - shapes: [], - setShapes: (shapes: any[]) => {}, - activeShape: null, - setActiveShape: (shape: ActiveShape) => {}, - visibleShapes: [], - setVisibleShapes: (shapes: any[]) => {}, - snappingLatlng: null, - setSnappingLatlng: (_latlng: any) => {}, - showAll: false, - deleteAll: false, - drawingShape: false, - lastActiveShapeBeforeDrawing: null, - moveToShape: null, - updateShape: false, - mapMovingEnd: false, - updateTitleStatus: false, - setDrawingShape: (drawingShape: boolean) => {}, - setShowAll: (showAll: boolean) => {}, - setDeleteAll: (deleteAll: boolean) => {}, - setMoveToShape: (moveToShape: any) => {}, - setUpdateShape: (updateShape: boolean) => {}, - setMapMovingEnd: (mapMovingEnd: boolean) => {}, - setUpdateTitleStatus: (updateTitleStatus: boolean) => {}, - setLastActiveShapeBeforeDrawing: (lastActiveShapeBeforeDrawing: any) => {}, - addShape: (layer: any) => {}, - deleteShapeById: (shapeId: string) => {}, - deleteVisibleShapeById: (shapeId: string) => {}, - updateShapeById: ( - shapeId: string, - newCoordinates?: any, - newDistance?: number, - newSquare?: number | null - ) => {}, - setLastVisibleShapeActive: () => {}, - setDrawingWithLastActiveShape: () => {}, - setActiveShapeIfDrawCancelled: () => {}, - toggleMeasurementMode: () => {}, - updateAreaOfDrawing: (newArea: number) => {}, - updateTitle: (_shapeId: string | number, _customTitle: string) => {}, - setStartDrawing: (status: boolean) => {}, - startDrawing: false, - currentDrawHandler: null, - setCurrentDrawHandler: (handler: any) => {}, - completeCurrentShape: () => {}, - config: defaultConfig, - status: "INACTIVE" as MeasurementMapStatus, - setStatus: (status: MeasurementMapStatus) => {}, - } -); - -export const MapMeasurementsProvider = ({ - children, - externalMode, - setModeExternal, - config = {}, -}: { - children: React.ReactNode; - externalMode?: MEASUREMENT_MODE; - setModeExternal?: (mode: MEASUREMENT_MODE) => void; - config?: PartialMeasurementConfig; -}) => { - // Internal mode state as fallback when external props not provided - const [internalMode, setInternalMode] = useState( - MEASUREMENT_MODE.DEFAULT - ); - - // Use external mode/setMode if provided, otherwise use internal state - const mode = externalMode ?? internalMode; - const setMode = setModeExternal ?? setInternalMode; - - // Merge provided config with defaults - // Force disable snapping on mobile devices regardless of config - const mergedConfig: MeasurementConfig = { - ...defaultConfig, - ...config, - snappingEnabled: isMobileDevice() - ? false - : config.snappingEnabled ?? defaultConfig.snappingEnabled, - }; - const [activeShape, setActiveShape] = useState(null); - const [shapes, setShapes] = useState([]); - const [visibleShapes, setVisibleShapes] = useState([]); - const [snappingLatlng, setSnappingLatlng] = useState(null); - const [showAll, setShowAll] = useState(false); - const [deleteAll, setDeleteAll] = useState(false); - const [drawingShape, setDrawingShape] = useState(false); - const [lastActiveShapeBeforeDrawing, setLastActiveShapeBeforeDrawing] = - useState(null); - const [moveToShape, setMoveToShape] = useState(null); - const [updateShape, setUpdateShape] = useState(false); - const [mapMovingEnd, setMapMovingEnd] = useState(false); - const [updateTitleStatus, setUpdateTitleStatus] = useState(false); - const [startDrawing, setStartDrawing] = useState(false); - const [currentDrawHandler, setCurrentDrawHandler] = useState(null); - const [status, setStatus] = useState("INACTIVE"); - - // Update status when mode changes - useEffect(() => { - if (mode === MEASUREMENT_MODE.MEASUREMENT) { - setStatus("WAITING"); - } else { - setStatus("INACTIVE"); - } - }, [mode]); - - // Update status when drawing starts/ends - useEffect(() => { - if (drawingShape) { - setStatus("DRAWING"); - } else if (mode === MEASUREMENT_MODE.MEASUREMENT) { - // Only set to WAITING if not already in EDITING or MOVING state - setStatus((currentStatus) => { - if (currentStatus === "EDITING" || currentStatus === "MOVING") { - return currentStatus; - } - return "WAITING"; - }); - } - }, [drawingShape, mode]); - - useEffect(() => { - setFromLocalforage(mergedConfig.localStorageKey, setShapes, []); - }, []); - - useEffect(() => { - saveToLocalforage(mergedConfig.localStorageKey, shapes); - }, [shapes]); - - // useEffect(() => { - // console.log("xxx visibleShapes", visibleShapes); - // }, [visibleShapes]); - - // useEffect(() => { - // console.log("xxx activeShape", activeShape); - // }, [activeShape]); - - const addShape = (layer: any) => { - setShapes((prevShapes) => [...prevShapes, layer]); - }; - - const deleteShapeById = (shapeId: string) => { - setShapes((currentShapes) => - currentShapes.filter((shape) => shape.shapeId !== shapeId) - ); - }; - - const deleteVisibleShapeById = (shapeId: string) => { - setVisibleShapes((currentVisibleShapes) => - currentVisibleShapes.filter((shape) => shape.shapeId !== shapeId) - ); - }; - - const updateShapeById = ( - shapeId: string, - newCoordinates?: any, - newDistance?: number, - newSquare?: number | null - ) => { - setUpdateShape(true); - setShapes((prevShapes) => { - return prevShapes.map((s) => { - if (s.shapeId === shapeId) { - return { - ...s, - coordinates: newCoordinates, - distance: newDistance, - area: newSquare, - }; - } else { - return s; - } - }); - }); - }; - - const setLastVisibleShapeActive = () => { - setShapes((currentShapes) => { - const lastShapeId = currentShapes[currentShapes.length - 1]?.shapeId; - if (lastShapeId) { - setActiveShape(lastShapeId); - } - return currentShapes; - }); - }; - - const setDrawingWithLastActiveShape = () => { - setActiveShape((currentActiveShape) => { - if (currentActiveShape) { - setLastActiveShapeBeforeDrawing(currentActiveShape); - setDrawingShape(true); - } - return currentActiveShape; - }); - }; - - const setActiveShapeIfDrawCancelled = () => { - setLastActiveShapeBeforeDrawing((lastActiveShape) => { - setVisibleShapes((visible) => { - if (lastActiveShape && visible[0]?.shapeId !== 55555) { - setActiveShape(lastActiveShape); - setDrawingShape(false); - } else { - return []; // Clear visible shapes - } - return visible; - }); - return lastActiveShape; - }); - }; - - const toggleMeasurementMode = () => { - if (mode === MEASUREMENT_MODE.DEFAULT) { - setMode(MEASUREMENT_MODE.MEASUREMENT); - } else { - setMode(MEASUREMENT_MODE.DEFAULT); - setDrawingShape(false); - setLastVisibleShapeActive(); - } - }; - - const updateAreaOfDrawing = (newArea: number) => { - setVisibleShapes((visibleShapes) => { - const shape = visibleShapes.map((s) => { - if (s.shapeId === 5555) { - return { - ...s, - area: newArea, - }; - } - return s; - }); - return shape; - }); - }; - - const updateTitle = (shapeId: string | number, customTitle: string) => { - setVisibleShapes((currentVisibleShapes) => { - const shapeFromVisible = currentVisibleShapes.find( - (s) => s.shapeId === shapeId - ); - - if (!shapeFromVisible) return currentVisibleShapes; - return currentVisibleShapes.map((shape) => { - if (shape.shapeId === shapeId) { - return { - ...shapeFromVisible, - customTitle, - }; - } - return shape; - }); - }); - - // Update all shapes - find the shape first to preserve all properties - setShapes((currentShapes) => { - const shapeFromAllShapes = currentShapes.find( - (s) => s.shapeId === shapeId - ); - if (!shapeFromAllShapes) return currentShapes; - - return currentShapes.map((shape) => { - if (shape.shapeId === shapeId) { - return { - ...shapeFromAllShapes, - customTitle, - }; - } - return shape; - }); - }); - - // Set update title status to trigger any necessary UI updates - setUpdateTitleStatus(true); - }; - - const completeCurrentShape = () => { - if ( - currentDrawHandler && - typeof currentDrawHandler.completeShape === "function" - ) { - currentDrawHandler.completeShape(); - } - }; - - return ( - - {children} - - ); -}; - -export function useMapMeasurementsContext() { - const ctx = useContext(MapMeasurementsContext); - if (!ctx) { - throw new Error( - "useMapMeasurementsContext must be used within an MapMeasurementsProvider" - ); - } - return ctx; -} diff --git a/libraries/commons/measurements/src/lib/components/MeasurementControl.tsx b/libraries/commons/measurements/src/lib/components/MeasurementControl.tsx index 7e7d8cc6ce..7900f4602b 100644 --- a/libraries/commons/measurements/src/lib/components/MeasurementControl.tsx +++ b/libraries/commons/measurements/src/lib/components/MeasurementControl.tsx @@ -5,11 +5,33 @@ import { ControlButtonStyler, } from "@carma-mapping/map-controls-layout"; import { InfoBoxMeasurement } from "./InfoBoxMeasurement"; -import { MeasurementControlProps, MEASUREMENT_MODE } from "../../index.d"; -import { useMapMeasurementsContext } from "./MapMeasurementsProvider"; +// import { MEASUREMENT_MODE } from "../context"; +import { useMapMeasurementsContext } from "../context"; import measureActive from "../assets/measure-active.png"; import measureInactive from "../assets/measure.png"; +interface MeasurementControlProps { + isActive?: boolean; + onToggle?: () => void; + position?: "topleft" | "topright" | "bottomleft" | "bottomright"; + order?: number; + iconBaseUrl?: string; + icons?: { + active: string; + inactive: string; + }; + altText?: string; + iconClassName?: string; + + // Universal features + disabled?: boolean; + useDisabledStyle?: boolean; + tooltip?: string | React.ReactNode; + tooltipPlacement?: "top" | "bottom" | "left" | "right"; + className?: string; + showInfoBox?: boolean; +} + export const MeasurementControl = forwardRef< HTMLButtonElement, MeasurementControlProps @@ -38,13 +60,12 @@ export const MeasurementControl = forwardRef< }, ref ) => { - const { mode, toggleMeasurementMode } = useMapMeasurementsContext(); + const { isMeasurementEnabled, toggleMeasurementMode } = + useMapMeasurementsContext(); // Use context values if props are not provided const isActive = - propIsActive !== undefined - ? propIsActive - : mode === MEASUREMENT_MODE.MEASUREMENT; + propIsActive !== undefined ? propIsActive : isMeasurementEnabled; const onToggle = propOnToggle || toggleMeasurementMode; const getUrlPrefix = () => { diff --git a/libraries/commons/measurements/src/lib/components/MeasurementStatusDebug.tsx b/libraries/commons/measurements/src/lib/components/MeasurementStatusDebug.tsx deleted file mode 100644 index 597956b301..0000000000 --- a/libraries/commons/measurements/src/lib/components/MeasurementStatusDebug.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { useMapMeasurementsContext } from "./MapMeasurementsProvider"; - -export function MeasurementStatusDebug() { - const { status, config } = useMapMeasurementsContext(); - - if (!config.debugOutputMapStatus) { - return null; - } - - const { x, y } = config.debugOutputMapStatusPosition; - - return ( -
- {status} -
- ); -} diff --git a/libraries/commons/measurements/src/lib/components/MeasurementTitle.tsx b/libraries/commons/measurements/src/lib/components/MeasurementTitle.tsx index e8b822c2ec..818656b792 100644 --- a/libraries/commons/measurements/src/lib/components/MeasurementTitle.tsx +++ b/libraries/commons/measurements/src/lib/components/MeasurementTitle.tsx @@ -1,5 +1,15 @@ import { useState, useEffect } from "react"; -import { MeasurementTitleProps } from "../.."; + +type MeasurementTitleProps = { + title: string; + shapeId: number | string; + order: number; + updateTitleMeasurementById: (shapeId: number | string, title: string) => void; + setUpdateMeasurementStatus: (status: boolean) => void; + isCollapsed?: boolean; + collapsedContent?: string; + editable?: boolean; +}; const MeasurementTitle = ({ title, @@ -51,6 +61,7 @@ const MeasurementTitle = ({ ); }; +export { MeasurementTitle }; export default MeasurementTitle; function capitalizeFirstLetter(text: string): string { diff --git a/libraries/commons/measurements/src/lib/context/MapMeasurementsContext.d.ts b/libraries/commons/measurements/src/lib/context/MapMeasurementsContext.d.ts new file mode 100644 index 0000000000..2fb7a60516 --- /dev/null +++ b/libraries/commons/measurements/src/lib/context/MapMeasurementsContext.d.ts @@ -0,0 +1,28 @@ +/** + * Map Measurements Context Types + * Shared types used by context, provider, and external consumers + */ + +export type ActiveShape = null | number | string | any; + +export type MeasurementMapStatus = + | "INACTIVE" // not active + | "WAITING" // moving around not dragging anything and waiting for other stuff + | "DRAWING" // either lines or polygons but in the process + | "EDITING" // dragging vertices around + | "MOVING"; // dragging whole objects around + +export interface MeasurementConfig { + editableTitle: boolean; + infoBoxHeaderColor: string; + localStorageKey: string; + snappingEnabled: boolean; + snappingOnUpdate: boolean; + snappingQueryRadius: number; + snappingMinZoom: number; + snappingRadiusVisible: boolean; + debugOutputMapStatus: boolean; + debugOutputMapStatusPosition: { x: number; y: number }; +} + +export type PartialMeasurementConfig = Partial; diff --git a/libraries/commons/measurements/src/lib/context/MapMeasurementsContext.tsx b/libraries/commons/measurements/src/lib/context/MapMeasurementsContext.tsx new file mode 100644 index 0000000000..790a00db9a --- /dev/null +++ b/libraries/commons/measurements/src/lib/context/MapMeasurementsContext.tsx @@ -0,0 +1,127 @@ +import { createContext } from "react"; +import type { + ActiveShape, + MeasurementConfig, + MeasurementMapStatus, +} from "./MapMeasurementsContext.d"; +// import { MEASUREMENT_MODE } from "./MapMeasurementsContext.d"; + +export interface MapMeasurementsContextType { + isMeasurementEnabled: boolean; + setMeasurementEnabled: (enabled: boolean) => void; + shapes: any[]; + setShapes: (shapes: any[]) => void; + activeShape: ActiveShape; + setActiveShape: (shape: ActiveShape) => void; + visibleShapes: any[]; + setVisibleShapes: (shapes: any[]) => void; + + showAll: boolean; + deleteAll: boolean; + drawingShape: boolean; + lastActiveShapeBeforeDrawing: null | any; + moveToShape: null | any; + updateShape: boolean; + mapMovingEnd: boolean; + updateTitleStatus: boolean; + setDrawingShape: (drawingShape: boolean) => void; + setShowAll: (showAll: boolean) => void; + setDeleteAll: (deleteAll: boolean) => void; + setMoveToShape: (moveToShape: any) => void; + setUpdateShape: (updateShape: boolean) => void; + setMapMovingEnd: (mapMovingEnd: boolean) => void; + setUpdateTitleStatus: (updateTitleStatus: boolean) => void; + setLastActiveShapeBeforeDrawing: (lastActiveShapeBeforeDrawing: any) => void; + addShape: (layer: any) => void; + deleteShapeById: (shapeId: string) => void; + deleteVisibleShapeById: (shapeId: string) => void; + updateShapeById: ( + shapeId: string, + newCoordinates?: any, + newDistance?: number, + newSquare?: number | null + ) => void; + setLastVisibleShapeActive: () => void; + setDrawingWithLastActiveShape: () => void; + setActiveShapeIfDrawCancelled: () => void; + toggleMeasurementMode: () => void; + // NOTE: newArea is pre-formatted string like "123.45 m²" or "1.23 km²" from calculateArea() + updateAreaOfDrawing: (newArea: string) => void; + updateTitle: (shapeId: string | number, customTitle: string) => void; + config: MeasurementConfig; + isSnapping: boolean; + setIsSnapping: (isSnapping: boolean) => void; +} + +// Detect mobile devices +const isMobileDevice = () => { + const userAgent = typeof navigator !== "undefined" ? navigator.userAgent : ""; + const isMobileUA = + /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test( + userAgent + ); + const isSmallScreen = + typeof window !== "undefined" && + window.matchMedia("(max-width: 768px)").matches; + return isMobileUA || isSmallScreen; +}; + +const defaultConfig: MeasurementConfig = { + editableTitle: true, + infoBoxHeaderColor: "#3b82f6", + localStorageKey: "measurementShapes", + snappingEnabled: !isMobileDevice(), // Disable snapping on mobile + snappingOnUpdate: false, + snappingQueryRadius: 40, + snappingMinZoom: 17, + snappingRadiusVisible: false, + debugOutputMapStatus: false, + debugOutputMapStatusPosition: { x: 65, y: 15 }, +}; + +export const MapMeasurementsContext = createContext( + { + isMeasurementEnabled: false, + setMeasurementEnabled: (enabled: boolean) => {}, + shapes: [], + setShapes: (shapes: any[]) => {}, + activeShape: null, + setActiveShape: (shape: ActiveShape) => {}, + visibleShapes: [], + setVisibleShapes: (shapes: any[]) => {}, + showAll: false, + deleteAll: false, + drawingShape: false, + lastActiveShapeBeforeDrawing: null, + moveToShape: null, + updateShape: false, + mapMovingEnd: false, + updateTitleStatus: false, + setDrawingShape: (drawingShape: boolean) => {}, + setShowAll: (showAll: boolean) => {}, + setDeleteAll: (deleteAll: boolean) => {}, + setMoveToShape: (moveToShape: any) => {}, + setUpdateShape: (updateShape: boolean) => {}, + setMapMovingEnd: (mapMovingEnd: boolean) => {}, + setUpdateTitleStatus: (updateTitleStatus: boolean) => {}, + setLastActiveShapeBeforeDrawing: (lastActiveShapeBeforeDrawing: any) => {}, + addShape: (layer: any) => {}, + deleteShapeById: (shapeId: string) => {}, + deleteVisibleShapeById: (shapeId: string) => {}, + updateShapeById: ( + shapeId: string, + newCoordinates?: any, + newDistance?: number, + newSquare?: number | null + ) => {}, + setLastVisibleShapeActive: () => {}, + setDrawingWithLastActiveShape: () => {}, + setActiveShapeIfDrawCancelled: () => {}, + toggleMeasurementMode: () => {}, + updateAreaOfDrawing: (newArea: string) => {}, + updateTitle: (shapeId: string | number, customTitle: string) => {}, + config: defaultConfig, + isSnapping: true, + setIsSnapping: (isSnapping: boolean) => {}, + } +); diff --git a/libraries/commons/measurements/src/lib/context/MapMeasurementsProvider.tsx b/libraries/commons/measurements/src/lib/context/MapMeasurementsProvider.tsx new file mode 100644 index 0000000000..c3758d56cc --- /dev/null +++ b/libraries/commons/measurements/src/lib/context/MapMeasurementsProvider.tsx @@ -0,0 +1,349 @@ +import { useEffect, useState, useRef, useCallback, useMemo } from "react"; +import type { + ActiveShape, + MeasurementConfig, + MeasurementMapStatus, + PartialMeasurementConfig, +} from "./MapMeasurementsContext.d"; +// import { MEASUREMENT_MODE } from "./MapMeasurementsContext.d"; +import { MapMeasurementsContext } from "./MapMeasurementsContext"; +import { setFromLocalforage, saveToLocalforage } from "../utils/helper"; +import { normalizeOptions } from "@carma-commons/utils"; + +// Detect mobile devices +const isMobileDevice = () => { + const userAgent = typeof navigator !== "undefined" ? navigator.userAgent : ""; + const isMobileUA = + /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test( + userAgent + ); + const isSmallScreen = + typeof window !== "undefined" && + window.matchMedia("(max-width: 768px)").matches; + return isMobileUA || isSmallScreen; +}; + +export const defaultConfig: MeasurementConfig = { + editableTitle: true, + infoBoxHeaderColor: "#3b82f6", + localStorageKey: "measurementShapes", + snappingEnabled: !isMobileDevice(), // Disable snapping on mobile + snappingOnUpdate: false, + snappingQueryRadius: 40, + snappingMinZoom: 17, + snappingRadiusVisible: false, + debugOutputMapStatus: false, + debugOutputMapStatusPosition: { x: 65, y: 15 }, +}; + +export const MapMeasurementsProvider = ({ + children, + config = {}, + snappingEnabled, +}: { + children: React.ReactNode; + config?: PartialMeasurementConfig; + snappingEnabled?: boolean; +}) => { + const [isMeasurementEnabled, setMeasurementEnabled] = + useState(false); + + const mergedConfig: MeasurementConfig = useMemo(() => { + const opts = normalizeOptions(config, defaultConfig); + if (snappingEnabled !== undefined) { + opts.snappingEnabled = snappingEnabled; + } + if (isMobileDevice()) { + opts.snappingEnabled = false; + } + return opts; + }, [config, snappingEnabled]); + + const [isSnapping, setIsSnapping] = useState( + mergedConfig.snappingEnabled + ); + + useEffect(() => { + setIsSnapping(mergedConfig.snappingEnabled); + }, [mergedConfig.snappingEnabled]); + + useEffect(() => { + console.debug("[MapMeasurementsProvider] Config initialized:", { + isMobile: isMobileDevice(), + snappingEnabled: mergedConfig.snappingEnabled, + providedConfig: config, + }); + }, []); + const [activeShape, setActiveShape] = useState(null); + const [shapes, setShapes] = useState([]); + const [visibleShapes, setVisibleShapes] = useState([]); + + const [showAll, setShowAll] = useState(false); + const [deleteAll, setDeleteAll] = useState(false); + const [drawingShape, setDrawingShape] = useState(false); + + // Wrap setDrawingShape to log calls + const setDrawingShapeWithLog = useCallback((value: boolean) => { + console.warn( + `[MapMeasurementsProvider] setDrawingShape(${value})`, + new Error().stack + ); + setDrawingShape(value); + }, []); + + const [lastActiveShapeBeforeDrawing, setLastActiveShapeBeforeDrawing] = + useState(null); + const [moveToShape, setMoveToShape] = useState(null); + const [updateShape, setUpdateShape] = useState(false); + const [mapMovingEnd, setMapMovingEnd] = useState(false); + const [updateTitleStatus, setUpdateTitleStatus] = useState(false); + + // DEBUG: Log Provider mount/unmount + useEffect(() => { + console.warn("[MapMeasurementsProvider] MOUNTED"); + return () => { + console.error( + "[MapMeasurementsProvider] UNMOUNTED - THIS SHOULD NOT HAPPEN DURING MEASUREMENT!" + ); + }; + }, []); + + useEffect(() => { + setFromLocalforage(mergedConfig.localStorageKey, setShapes, []); + }, []); + + useEffect(() => { + saveToLocalforage(mergedConfig.localStorageKey, shapes); + }, [shapes]); + + // useEffect(() => { + // console.log("xxx visibleShapes", visibleShapes); + // }, [visibleShapes]); + + // useEffect(() => { + // console.log("xxx activeShape", activeShape); + // }, [activeShape]); + + const addShape = useCallback((layer: any) => { + setShapes((prevShapes) => [...prevShapes, layer]); + }, []); + + const deleteShapeById = useCallback((shapeId: string) => { + setShapes((currentShapes) => + currentShapes.filter((shape) => shape.shapeId !== shapeId) + ); + }, []); + + const deleteVisibleShapeById = useCallback((shapeId: string) => { + setVisibleShapes((currentVisibleShapes) => + currentVisibleShapes.filter((shape) => shape.shapeId !== shapeId) + ); + }, []); + + const updateShapeById = useCallback( + ( + shapeId: string, + newCoordinates?: any, + newDistance?: number, + newSquare?: number | null + ) => { + setUpdateShape(true); + setShapes((prevShapes) => { + return prevShapes.map((s) => { + if (s.shapeId === shapeId) { + return { + ...s, + coordinates: newCoordinates, + distance: newDistance, + area: newSquare, + }; + } else { + return s; + } + }); + }); + }, + [] + ); + + const setLastVisibleShapeActive = useCallback(() => { + setShapes((currentShapes) => { + const lastShapeId = currentShapes[currentShapes.length - 1]?.shapeId; + if (lastShapeId) { + setActiveShape(lastShapeId); + } + return currentShapes; + }); + }, []); + + const setDrawingWithLastActiveShape = useCallback(() => { + setActiveShape((currentActiveShape) => { + if (currentActiveShape) { + setLastActiveShapeBeforeDrawing(currentActiveShape); + setDrawingShape(true); + } + return currentActiveShape; + }); + }, []); + + const setActiveShapeIfDrawCancelled = useCallback(() => { + setLastActiveShapeBeforeDrawing((lastActiveShape) => { + setVisibleShapes((visible) => { + if (lastActiveShape && visible[0]?.shapeId !== 55555) { + setActiveShape(lastActiveShape); + setDrawingShape(false); + } else { + return []; // Clear visible shapes + } + return visible; + }); + return lastActiveShape; + }); + }, []); + + const toggleMeasurementMode = useCallback(() => { + if (!isMeasurementEnabled) { + console.debug("[MapMeasurementsProvider] Enabling measurement mode"); + setMeasurementEnabled(true); + } else { + console.debug("[MapMeasurementsProvider] Disabling measurement mode"); + setMeasurementEnabled(false); + setDrawingShape(false); + setLastVisibleShapeActive(); + } + }, [isMeasurementEnabled, setLastVisibleShapeActive]); + + // NOTE: newArea is pre-formatted string like "123.45 m²" or "1.23 km²" from calculateArea() + const updateAreaOfDrawing = useCallback((newArea: string) => { + setVisibleShapes((visibleShapes) => { + const shape = visibleShapes.map((s) => { + if (s.shapeId === 5555) { + return { + ...s, + area: newArea, + }; + } + return s; + }); + return shape; + }); + }, []); + + const updateTitle = useCallback( + (shapeId: string | number, customTitle: string) => { + setVisibleShapes((currentVisibleShapes) => { + const shapeFromVisible = currentVisibleShapes.find( + (s) => s.shapeId === shapeId + ); + + if (!shapeFromVisible) return currentVisibleShapes; + return currentVisibleShapes.map((shape) => { + if (shape.shapeId === shapeId) { + return { + ...shapeFromVisible, + customTitle, + }; + } + return shape; + }); + }); + + // Update all shapes - find the shape first to preserve all properties + setShapes((currentShapes) => { + const shapeFromAllShapes = currentShapes.find( + (s) => s.shapeId === shapeId + ); + if (!shapeFromAllShapes) return currentShapes; + + return currentShapes.map((shape) => { + if (shape.shapeId === shapeId) { + return { + ...shapeFromAllShapes, + customTitle, + }; + } + return shape; + }); + }); + + // Set update title status to trigger any necessary UI updates + setUpdateTitleStatus(true); + }, + [] + ); + + const contextValue = useMemo( + () => ({ + isMeasurementEnabled, + setMeasurementEnabled, + shapes, + setShapes, + addShape, + activeShape, + setActiveShape, + visibleShapes, + setVisibleShapes, + showAll, + setShowAll, + deleteAll, + setDeleteAll, + drawingShape, + setDrawingShape: setDrawingShapeWithLog, + lastActiveShapeBeforeDrawing, + setLastActiveShapeBeforeDrawing, + moveToShape, + setMoveToShape, + updateShape, + setUpdateShape, + mapMovingEnd, + setMapMovingEnd, + updateTitleStatus, + setUpdateTitleStatus, + deleteShapeById, + deleteVisibleShapeById, + updateShapeById, + setLastVisibleShapeActive, + setDrawingWithLastActiveShape, + setActiveShapeIfDrawCancelled, + toggleMeasurementMode, + updateAreaOfDrawing, + updateTitle, + isSnapping, + setIsSnapping, + config: mergedConfig, + }), + [ + isMeasurementEnabled, + shapes, + addShape, + activeShape, + visibleShapes, + showAll, + deleteAll, + drawingShape, + setDrawingShapeWithLog, + lastActiveShapeBeforeDrawing, + moveToShape, + updateShape, + mapMovingEnd, + updateTitleStatus, + deleteShapeById, + deleteVisibleShapeById, + updateShapeById, + setLastVisibleShapeActive, + setDrawingWithLastActiveShape, + setActiveShapeIfDrawCancelled, + toggleMeasurementMode, + updateAreaOfDrawing, + updateTitle, + isSnapping, + mergedConfig, + ] + ); + + return ( + + {children} + + ); +}; diff --git a/libraries/commons/measurements/src/lib/context/hooks/useMapMeasurementsContext.ts b/libraries/commons/measurements/src/lib/context/hooks/useMapMeasurementsContext.ts new file mode 100644 index 0000000000..64b5d077fe --- /dev/null +++ b/libraries/commons/measurements/src/lib/context/hooks/useMapMeasurementsContext.ts @@ -0,0 +1,17 @@ +/** + * useMapMeasurementsContext Hook + * Hook to access the map measurements context + */ + +import { useContext } from "react"; +import { MapMeasurementsContext } from "../MapMeasurementsContext"; + +export function useMapMeasurementsContext() { + const ctx = useContext(MapMeasurementsContext); + if (!ctx) { + throw new Error( + "useMapMeasurementsContext must be used within an MapMeasurementsProvider" + ); + } + return ctx; +} diff --git a/libraries/commons/measurements/src/lib/context/index.ts b/libraries/commons/measurements/src/lib/context/index.ts new file mode 100644 index 0000000000..f84f111505 --- /dev/null +++ b/libraries/commons/measurements/src/lib/context/index.ts @@ -0,0 +1,18 @@ +/** + * Context Exports + * Re-export context, provider, hook, and types + */ + +export { MapMeasurementsContext } from "./MapMeasurementsContext"; +export type { MapMeasurementsContextType } from "./MapMeasurementsContext"; +export { + MapMeasurementsProvider, + defaultConfig, +} from "./MapMeasurementsProvider"; +export { useMapMeasurementsContext } from "./hooks/useMapMeasurementsContext"; +export type { + ActiveShape, + MeasurementConfig, + MeasurementMapStatus, + PartialMeasurementConfig, +} from "./MapMeasurementsContext.d"; diff --git a/libraries/commons/measurements/src/lib/components/MeasurementsSnapping.tsx b/libraries/commons/measurements/src/lib/hooks/useMeasurements.ts similarity index 50% rename from libraries/commons/measurements/src/lib/components/MeasurementsSnapping.tsx rename to libraries/commons/measurements/src/lib/hooks/useMeasurements.ts index 62c660cee8..7c04b5342a 100644 --- a/libraries/commons/measurements/src/lib/components/MeasurementsSnapping.tsx +++ b/libraries/commons/measurements/src/lib/hooks/useMeasurements.ts @@ -1,127 +1,128 @@ -import { useEffect, useRef, useContext, useState } from "react"; +import React, { useState, useEffect, useContext, useRef } from "react"; + +import { Map as MapLibreMap } from "maplibre-gl"; + +import L from "leaflet"; +import "leaflet-draw"; +import "leaflet-editable"; + import { TopicMapContext } from "react-cismap/contexts/TopicMapContextProvider"; -import { adjustClickPosition, toLatLngFromClosestPoint } from "../utils/helper"; -import { useMapMeasurementsContext } from "../components/MapMeasurementsProvider"; -import { SnappingPoint } from "../snapping/types"; + +import "../utils/measure"; +import "../utils/measure-path"; +import useDeviceDetection from "../hooks/useDeviceDetection"; +import { useMapMeasurementsContext } from "../context"; import { - extractPointsFromGeometry, - extractPointsFromMeasurementShape, -} from "../snapping/utils/coordinateExtraction"; - -export function MeasurementsSnapping({ - maplibreMaps, -}: { - maplibreMaps: any[]; -}) { - const { routedMapRef } = useContext(TopicMapContext); - const { shapes, setSnappingLatlng, config, currentDrawHandler, status } = - useMapMeasurementsContext(); - const [queryRadius, setQueryRadius] = useState(config.snappingQueryRadius); - const queryRadiusRef = useRef(queryRadius); + adjustClickPosition, + toLatLngFromClosestPoint, + filterArrByIds, + findLargestNumber, +} from "../utils/helper"; +import { SnappingPoint } from "./../types"; +import { extractPointsFromMeasurementShape } from "../snapping/utils/coordinateExtraction"; +import { getSnappingPointsFromMapLibre } from "../snapping/utils/mapLibreExtraction"; + +import "../styles/m-style.css"; +import "leaflet/dist/leaflet.css"; +import "leaflet-draw/dist/leaflet.draw.css"; +import "leaflet-measure-path/leaflet-measure-path.css"; + +export interface MeasurementShapeDrawing { + shapeId: number | string; + number: number; + coordinates?: unknown; + [key: string]: unknown; +} + +export const useMeasurements = (snappingLayers: MapLibreMap[] = []) => { + const { realRoutedMapRef } = + useContext(TopicMapContext); + const { + isMeasurementEnabled, + activeShape, + setActiveShape, + shapes, + setShapes, + addShape, + deleteAll, + setDeleteAll, + setUpdateShape, + visibleShapes, + setVisibleShapes, + drawingShape: ifDrawing, + setDrawingShape, + moveToShape, + setMoveToShape, + showAll, + setShowAll, + toggleMeasurementMode: toggleUIMode, + setMapMovingEnd, + deleteShapeById, + updateShapeById, + setLastVisibleShapeActive, + setDrawingWithLastActiveShape, + setActiveShapeIfDrawCancelled, + updateAreaOfDrawing, + deleteVisibleShapeById, + config, + } = useMapMeasurementsContext(); + + // Local state for drawing logic + const [status, setStatus] = useState("INACTIVE"); + const currentDrawHandlerRef = useRef(null); + const snappingLatlngRef = useRef(null); + const [measureControl, setMeasureControl] = useState(null); + + // Helper to update status + const setCurrentDrawHandler = (handler: any) => { + currentDrawHandlerRef.current = handler; + }; + + // destructure config for snapping + const { + snappingQueryRadius, + snappingMinZoom, + snappingOnUpdate, + snappingRadiusVisible, + } = config; + + const queryRadiusRef = useRef(snappingQueryRadius); const circleMarkerRef = useRef(null); const snappingIndicatorRef = useRef(null); // Leaflet marker for snapping point const shapesRef = useRef(shapes); - // Use config directly - const snappingEnabled = config.snappingEnabled; - const snappingEnabledRef = useRef(snappingEnabled); - const maplibreMapsRef = useRef(maplibreMaps); + const snappingLayersRef = useRef(snappingLayers); const lastHoveredMarkerRef = useRef(null); const isDraggingVertexRef = useRef(false); - const currentDrawHandlerRef = useRef(currentDrawHandler); const lastSnappedCoordRef = useRef<[number, number] | null>(null); const statusRef = useRef(status); + useEffect(() => { + snappingLayersRef.current = snappingLayers; + }, [snappingLayers]); + useEffect(() => { shapesRef.current = shapes; }, [shapes]); useEffect(() => { - queryRadiusRef.current = queryRadius; - }, [queryRadius]); + queryRadiusRef.current = snappingQueryRadius; + }, [snappingQueryRadius]); useEffect(() => { statusRef.current = status; }, [status]); - - useEffect(() => { - snappingEnabledRef.current = snappingEnabled; - }, [snappingEnabled]); - useEffect(() => { - maplibreMapsRef.current = maplibreMaps; - }, [maplibreMaps]); - - useEffect(() => { - currentDrawHandlerRef.current = currentDrawHandler; - }, [currentDrawHandler]); - - useEffect(() => { - const leafletMap = routedMapRef?.leafletMap?.leafletElement; - - // Clean up visual indicators and coordinates when snapping is disabled - if (!snappingEnabled) { - // Clear snapping coordinates immediately - if (setSnappingLatlng) { - setSnappingLatlng(null); - } - - // Remove all Leaflet markers - if (leafletMap) { - try { - if (snappingIndicatorRef.current) { - leafletMap.removeLayer(snappingIndicatorRef.current); - snappingIndicatorRef.current = null; - } - if (circleMarkerRef.current) { - leafletMap.removeLayer(circleMarkerRef.current); - circleMarkerRef.current = null; - } - } catch (_) { - // no-op safeguard - } - } - - // Clear cursor on all MapLibre maps - maplibreMaps.forEach((map) => { - if (map && map.getCanvas) { - map.getCanvas().style.cursor = ""; - } - }); - - // Add handlers when snapping is disabled to prevent stale coordinates - if (leafletMap && typeof leafletMap.on === "function") { - const clearSnappingHandler = () => { - if (setSnappingLatlng) { - setSnappingLatlng(null); - } - }; - - leafletMap.on("mousemove", clearSnappingHandler); - - // Add a mouseup handler that does NOT adjust click position - const mapContainer = leafletMap.getContainer(); - const noAdjustHandler = (event: MouseEvent) => { - // Do nothing - just let the event pass through normally - // This prevents the old adjustClickPosition handler from being used - }; - - mapContainer.addEventListener("mouseup", noAdjustHandler, true); - - return () => { - leafletMap.off("mousemove", clearSnappingHandler); - mapContainer.removeEventListener("mouseup", noAdjustHandler, true); - }; - } - - return; - } + const leafletMap = realRoutedMapRef.current?.leafletMap?.leafletElement; - if (leafletMap && typeof leafletMap.on === "function") { + if ( + isMeasurementEnabled && + leafletMap && + typeof leafletMap.on === "function" + ) { // Import L from leaflet - const L = (window as any).L; - let closestPoint: any = null; - const closestPointRef = { current: null as any }; // Stable ref to preserve closestPoint + let closestPoint: SnappingPoint | null = null; + const closestPointRef = { current: null as SnappingPoint | null }; // Stable ref to preserve closestPoint // Centralized cleanup for markers and closestPoint const clearBlackPoint = () => { @@ -135,7 +136,7 @@ export function MeasurementsSnapping({ snappingIndicatorRef.current = null; } // Clear cursor on all MapLibre maps - maplibreMaps.forEach((map) => { + snappingLayers.forEach((map) => { if (map && map.getCanvas) { map.getCanvas().style.cursor = ""; } @@ -147,32 +148,75 @@ export function MeasurementsSnapping({ } }; - const mousemoveHandler = (e: any) => { + // Store last mouse event to re-trigger handlers on key press + const lastMouseEventRef = { current: null as any }; + + const updateTooltipTemplate = (isPressed: boolean) => { + const snappingText = isPressed + ? "Snapping deaktiviert" + : `Snapping aktiv (${SNAPPING_MODIFIER_KEY} zum Deaktivieren)`; + + if ( + L.drawLocal && + L.drawLocal.draw && + L.drawLocal.draw.handlers && + L.drawLocal.draw.handlers.polyline + ) { + L.drawLocal.draw.handlers.polyline.tooltip.start = `Klicken, um den Startpunkt der Messung zu setzen.
${snappingText}`; + L.drawLocal.draw.handlers.polyline.tooltip.cont = `Klicken (ggf. mehrmals), um die nächsten Punkte des Linienzuges zu setzen.
${snappingText}`; + } + }; + + const mousemoveHandler = (e: MouseEvent) => { + // Prevent infinite loop from synthetic events we generate for snapping + if ((e as any)._isSyntheticSnapped) { + return; + } + + // If mouse button is pressed (e.g. panning), do not snap + if (e.buttons !== 0) { + if (!isDraggingVertexRef.current) { + clearBlackPoint(); + } + return; + } + + lastMouseEventRef.current = e; + + // Update tooltip text based on Snapping Modifier Key + const isPressed = isSnappingModifierPressed(e); + updateTooltipTemplate(isPressed); + + // Force update of current tooltip if it exists + // Removed to prevent clearing distance + // Skip snapping indicator during vertex drag if snappingOnUpdate is disabled - if (isDraggingVertexRef.current && !config.snappingOnUpdate) { + if (isDraggingVertexRef.current && !snappingOnUpdate) { clearBlackPoint(); return; } - // Check if ALT key is pressed - if so, disable snapping temporarily - if (e.originalEvent.altKey) { + // Check if Snapping Modifier Key is pressed - if so, disable snapping temporarily + if (isPressed) { clearBlackPoint(); if (circleMarkerRef.current) { leafletMap.removeLayer(circleMarkerRef.current); circleMarkerRef.current = null; } - if (setSnappingLatlng) { - setSnappingLatlng(null); + // Direct update to control instead of context + if (measureControl) { + measureControl.options.snappingLatlng = null; } - return; // Exit early - no snapping while ALT is pressed + snappingLatlngRef.current = null; + return; // Exit early - no snapping while modifier is pressed } // Check zoom level - only work if zoom >= configured minimum const currentZoom = leafletMap.getZoom(); - if (currentZoom < config.snappingMinZoom) { - // Zoom too low: centralized cleanup + + if (currentZoom < snappingMinZoom) { clearBlackPoint(); - return; // Exit early + return; } // Remove old circle if exists @@ -180,17 +224,17 @@ export function MeasurementsSnapping({ leafletMap.removeLayer(circleMarkerRef.current); } - const currentMaplibreMaps = maplibreMapsRef.current; + const currentSnappingLayers = snappingLayersRef.current; // Get mouse position in lat/lng using Leaflet (always available) - const mouseLatLng = leafletMap.mouseEventToLatLng(e.originalEvent); + const mouseLatLng = leafletMap.mouseEventToLatLng(e); const mousePoint = leafletMap.latLngToContainerPoint(mouseLatLng); const currentRadius = queryRadiusRef.current; // Show radius circle if enabled and in WAITING or DRAWING status if ( - config.snappingRadiusVisible && + snappingRadiusVisible && (statusRef.current === "WAITING" || statusRef.current === "DRAWING") ) { // Convert pixel radius to meters for the circle @@ -212,56 +256,13 @@ export function MeasurementsSnapping({ const coordinatePoints: SnappingPoint[] = []; // 1. Extract from vector features (loop through all MapLibre maps) - currentMaplibreMaps.forEach((currentMaplibreMap) => { - if ( - currentMaplibreMap && - currentMaplibreMap.getStyle && - currentMaplibreMap.getCanvas - ) { - try { - const style = currentMaplibreMap.getStyle(); - if (style && style.layers) { - const canvas = currentMaplibreMap.getCanvas(); - const rect = canvas.getBoundingClientRect(); - const point = { - x: e.originalEvent.clientX - rect.left, - y: e.originalEvent.clientY - rect.top, - }; - - const bbox = [ - [point.x - currentRadius, point.y - currentRadius], - [point.x + currentRadius, point.y + currentRadius], - ]; - - const features = currentMaplibreMap.queryRenderedFeatures( - bbox, - { - layers: style.layers - .filter((layer: any) => { - // Skip layers with skipSnapping metadata - const skipSnapping = - layer.metadata?.carmaConf?.skipSnapping; - return ( - !skipSnapping && !layer.id.startsWith("highlight-") - ); // if we have a layer with highlight- dont snap (thats only important if we are doing the snap vis dot in maplibre directly not atm. but we will keep it in here) - }) - .map((layer: any) => layer.id), - } - ); - - features.forEach((feature: any) => { - const points = extractPointsFromGeometry( - feature.geometry, - "vector-features" - ); - coordinatePoints.push(...points); - }); - } - } catch (error) { - console.warn("Error extracting vector features:", error); - } - } - }); + coordinatePoints.push( + ...getSnappingPointsFromMapLibre( + currentSnappingLayers, + { x: e.clientX, y: e.clientY }, + currentRadius + ) + ); // 2. Extract from measurement shapes (independent of MapLibre) // Use shapesRef which is kept in sync via useEffect @@ -353,10 +354,14 @@ export function MeasurementsSnapping({ closestPointRef.current = blackPoint[0]; const finalLatLng = toLatLngFromClosestPoint(closestPoint); - if (finalLatLng && setSnappingLatlng) { + // Logic for updating snappingLatlng + let newSnappingLatlng = null; + + if (finalLatLng) { // Check if we're snapping to the first vertex of an in-progress drawing // If so, only allow snapping if we're within the query radius (prevents premature polygon closure) let shouldSnap = true; + if ( currentDrawHandlerValue && currentDrawHandlerValue._markers && @@ -372,9 +377,9 @@ export function MeasurementsSnapping({ Math.abs(finalLatLng.lng - firstVertex.lng) < threshold ) { // We're trying to snap to first vertex - check pixel distance from mouse - const map = routedMapRef.current?.leafletMap?.leafletElement; - if (map && e.latlng) { - const mousePoint = map.latLngToContainerPoint(e.latlng); + const map = realRoutedMapRef.current?.leafletMap?.leafletElement; + if (map && mouseLatLng) { + const mousePoint = map.latLngToContainerPoint(mouseLatLng); const vertexPoint = map.latLngToContainerPoint(firstVertex); const pixelDistance = Math.sqrt( Math.pow(mousePoint.x - vertexPoint.x, 2) + @@ -382,7 +387,7 @@ export function MeasurementsSnapping({ ); // Only snap if mouse is within query radius - if (pixelDistance > queryRadius) { + if (pixelDistance > queryRadiusRef.current) { shouldSnap = false; } } @@ -390,12 +395,16 @@ export function MeasurementsSnapping({ } if (shouldSnap) { - setSnappingLatlng(finalLatLng); - } else { - setSnappingLatlng(null); + newSnappingLatlng = finalLatLng; } } + // Update ref and control directly + snappingLatlngRef.current = newSnappingLatlng; + if (measureControl) { + measureControl.options.snappingLatlng = newSnappingLatlng; + } + // Trigger vertex marker hover for tooltip/area preview (Phase 3) // Check if we snapped to the first vertex of in-progress drawing if ( @@ -466,7 +475,9 @@ export function MeasurementsSnapping({ if ( finalLatLng && isSnapped && - (statusRef.current === "WAITING" || statusRef.current === "DRAWING") + (statusRef.current === "WAITING" || + statusRef.current === "DRAWING" || + statusRef.current === "INACTIVE") ) { snappingIndicatorRef.current = L.circleMarker( [finalLatLng.lat, finalLatLng.lng], @@ -483,6 +494,33 @@ export function MeasurementsSnapping({ lastSnappedCoordRef.current = currentCoord; } + + // Dispatch synthetic event for Leaflet Draw if snapped + if (isSnapped && finalLatLng && !isPressed) { + e.stopPropagation(); + e.stopImmediatePropagation(); + + const mapContainer = leafletMap.getContainer(); + const rect = mapContainer.getBoundingClientRect(); + const point = leafletMap.latLngToContainerPoint(finalLatLng); + + const newEvent = new MouseEvent("mousemove", { + bubbles: true, + cancelable: true, + view: window, + clientX: rect.left + point.x, + clientY: rect.top + point.y, + screenX: e.screenX, + screenY: e.screenY, + altKey: e.altKey, + ctrlKey: e.ctrlKey, + shiftKey: e.shiftKey, + metaKey: e.metaKey, + buttons: e.buttons, + }); + (newEvent as any)._isSyntheticSnapped = true; + mapContainer.dispatchEvent(newEvent); + } }; const mouseoutHandler = () => { @@ -497,14 +535,17 @@ export function MeasurementsSnapping({ } }; - leafletMap.on("mousemove", mousemoveHandler); + const container = leafletMap.getContainer(); + container.addEventListener("mousemove", mousemoveHandler, { + capture: true, + }); leafletMap.on("mouseout", mouseoutHandler); // Phase 4: Show snap indicator during vertex drag const vertexDragHandler = (e: any) => { isDraggingVertexRef.current = true; - if (!snappingEnabledRef.current || !config.snappingOnUpdate) return; + if (!snappingOnUpdate) return; const vertex = e.vertex; if (!vertex) return; @@ -517,59 +558,19 @@ export function MeasurementsSnapping({ const coordinatePoints: SnappingPoint[] = []; // Extract snap points from vector features - const currentMaplibreMaps = maplibreMapsRef.current; - currentMaplibreMaps.forEach((currentMaplibreMap) => { - if ( - currentMaplibreMap && - currentMaplibreMap.getStyle && - currentMaplibreMap.getCanvas - ) { - try { - const style = currentMaplibreMap.getStyle(); - if (style && style.layers) { - const canvas = currentMaplibreMap.getCanvas(); - const rect = canvas.getBoundingClientRect(); - - // Convert Leaflet container point to MapLibre canvas point - const mapContainer = leafletMap.getContainer(); - const mapRect = mapContainer.getBoundingClientRect(); - const canvasX = vertexPoint.x + mapRect.left - rect.left; - const canvasY = vertexPoint.y + mapRect.top - rect.top; - - const bbox = [ - [canvasX - currentRadius, canvasY - currentRadius], - [canvasX + currentRadius, canvasY + currentRadius], - ]; - - const features = currentMaplibreMap.queryRenderedFeatures( - bbox, - { - layers: style.layers - .filter((layer: any) => { - // Skip layers with skipSnapping metadata - const skipSnapping = - layer.metadata?.carmaConf?.skipSnapping; - return ( - !skipSnapping && !layer.id.startsWith("highlight-") - ); - }) - .map((layer: any) => layer.id), - } - ); - - features.forEach((feature: any) => { - const points = extractPointsFromGeometry( - feature.geometry, - "vector-features" - ); - coordinatePoints.push(...points); - }); - } - } catch (error) { - console.warn("Error extracting vector features:", error); - } - } - }); + const currentMaplibreMaps = snappingLayersRef.current; + const mapContainer = leafletMap.getContainer(); + const mapRect = mapContainer.getBoundingClientRect(); + const screenX = vertexPoint.x + mapRect.left; + const screenY = vertexPoint.y + mapRect.top; + + coordinatePoints.push( + ...getSnappingPointsFromMapLibre( + currentMaplibreMaps, + { x: screenX, y: screenY }, + currentRadius + ) + ); // Extract from other measurement shapes const currentShapes = shapesRef.current; @@ -652,7 +653,7 @@ export function MeasurementsSnapping({ const vertexDragEndHandler = (e: any) => { isDraggingVertexRef.current = false; - if (!snappingEnabledRef.current || !config.snappingOnUpdate) return; + if (!snappingOnUpdate) return; const vertex = e.vertex; if (!vertex) return; @@ -665,59 +666,19 @@ export function MeasurementsSnapping({ const coordinatePoints: SnappingPoint[] = []; // Extract snap points from vector features - const currentMaplibreMaps = maplibreMapsRef.current; - currentMaplibreMaps.forEach((currentMaplibreMap) => { - if ( - currentMaplibreMap && - currentMaplibreMap.getStyle && - currentMaplibreMap.getCanvas - ) { - try { - const style = currentMaplibreMap.getStyle(); - if (style && style.layers) { - const canvas = currentMaplibreMap.getCanvas(); - const rect = canvas.getBoundingClientRect(); - - // Convert Leaflet container point to MapLibre canvas point - const mapContainer = leafletMap.getContainer(); - const mapRect = mapContainer.getBoundingClientRect(); - const canvasX = vertexPoint.x + mapRect.left - rect.left; - const canvasY = vertexPoint.y + mapRect.top - rect.top; - - const bbox = [ - [canvasX - currentRadius, canvasY - currentRadius], - [canvasX + currentRadius, canvasY + currentRadius], - ]; - - const features = currentMaplibreMap.queryRenderedFeatures( - bbox, - { - layers: style.layers - .filter((layer: any) => { - // Skip layers with skipSnapping metadata - const skipSnapping = - layer.metadata?.carmaConf?.skipSnapping; - return ( - !skipSnapping && !layer.id.startsWith("highlight-") - ); - }) - .map((layer: any) => layer.id), - } - ); - - features.forEach((feature: any) => { - const points = extractPointsFromGeometry( - feature.geometry, - "vector-features" - ); - coordinatePoints.push(...points); - }); - } - } catch (error) { - console.warn("Error extracting vector features:", error); - } - } - }); + const currentMaplibreMaps = snappingLayersRef.current; + const mapContainer = leafletMap.getContainer(); + const mapRect = mapContainer.getBoundingClientRect(); + const screenX = vertexPoint.x + mapRect.left; + const screenY = vertexPoint.y + mapRect.top; + + coordinatePoints.push( + ...getSnappingPointsFromMapLibre( + currentMaplibreMaps, + { x: screenX, y: screenY }, + currentRadius + ) + ); // Extract from other measurement shapes const currentShapes = shapesRef.current; @@ -792,32 +753,72 @@ export function MeasurementsSnapping({ const mapContainer = leafletMap.getContainer(); const mouseupHandler = (event: MouseEvent) => { // Only adjust if snapping is enabled - if (snappingEnabledRef.current) { - const snapPoint = closestPointRef.current; - adjustClickPosition( - event, - snapPoint, - "mouseup", - leafletMap, - currentDrawHandlerRef.current - ); - } + // Snapping is always enabled now + const snapPoint = closestPointRef.current; + adjustClickPosition( + event, + snapPoint, + "mouseup", + leafletMap, + currentDrawHandlerRef.current + ); }; mapContainer.addEventListener("mouseup", mouseupHandler, true); - // mapContainer.addEventListener( - // "click", - // (event: MouseEvent) => - // adjustClickPosition(event, closestPoint, "click", leafletMap), - // true - // ); + + // Keydown/keyup handlers for ALT key + const handleKeyToggle = (isPressed: boolean) => { + updateTooltipTemplate(isPressed); + + if (lastMouseEventRef.current) { + const mapContainer = leafletMap.getContainer(); + const originalEvent = lastMouseEventRef.current; + + // Dispatch synthetic event to trigger Leaflet Draw update + const newEvent = new MouseEvent("mousemove", { + bubbles: true, + cancelable: true, + view: window, + clientX: originalEvent.clientX, + clientY: originalEvent.clientY, + screenX: originalEvent.screenX, + screenY: originalEvent.screenY, + altKey: isPressed, + ctrlKey: originalEvent.ctrlKey, + shiftKey: originalEvent.shiftKey, + metaKey: originalEvent.metaKey, + buttons: originalEvent.buttons, + }); + + mapContainer.dispatchEvent(newEvent); + } + }; + + const keydownHandler = (e: KeyboardEvent) => { + if (e.key === SNAPPING_MODIFIER_KEY) { + handleKeyToggle(true); + } + }; + + const keyupHandler = (e: KeyboardEvent) => { + if (e.key === SNAPPING_MODIFIER_KEY) { + handleKeyToggle(false); + } + }; + + document.addEventListener("keydown", keydownHandler); + document.addEventListener("keyup", keyupHandler); // Cleanup function to remove listeners and markers return () => { - leafletMap.off("mousemove", mousemoveHandler); + leafletMap + .getContainer() + .removeEventListener("mousemove", mousemoveHandler, true); leafletMap.off("mouseout", mouseoutHandler); leafletMap.off("editable:vertex:drag", vertexDragHandler); leafletMap.off("editable:vertex:dragend", vertexDragEndHandler); mapContainer.removeEventListener("mouseup", mouseupHandler, true); + document.removeEventListener("keydown", keydownHandler); + document.removeEventListener("keyup", keyupHandler); if (circleMarkerRef.current) { leafletMap.removeLayer(circleMarkerRef.current); circleMarkerRef.current = null; @@ -829,10 +830,310 @@ export function MeasurementsSnapping({ }; } }, [ - routedMapRef, - snappingEnabled, - config.snappingMinZoom, - setSnappingLatlng, + realRoutedMapRef, + snappingMinZoom, + snappingOnUpdate, + snappingRadiusVisible, + // Removed setSnappingLatlng dependency + snappingLayers, + isMeasurementEnabled, + measureControl, // Added measureControl dependency for direct updates ]); - return null; -} + + const [visiblePolylines, setVisiblePolylines] = useState<(string | number)[]>( + [] + ); + const [drawingShape, setDrawingLine] = useState(null); + + // Track last valid state for recovery + const lastValidStateRef = React.useRef<{ + activeShape: any; + mode: string; + wasDrawing: boolean; + } | null>(null); + + const device = useDeviceDetection(); + + const toggleMeasurementModeHandler = () => { + toggleUIMode(); + }; + + useEffect(() => { + const leafletMap = realRoutedMapRef.current?.leafletMap?.leafletElement; + if (leafletMap && !measureControl) { + const mapExample = leafletMap; + + console.debug( + "[Measurements] Initializing measurement control with valid map" + ); + + const customOptions = { + position: "topright", + // icon_lineActive: makeMeasureActiveIcon, + // icon_lineInactive: makeMeasureIcon, + // icon_polygonActive: polygonActiveIcon, + // icon_polygonInactive: polygonIcon, + activeShape, + mode_btn: `
+
`, + msj_disable_tool: "Do you want to disable the tool?", + device, + shapes, + snappingLatlng: snappingLatlngRef?.current, + snappingEnabled: true, + cbSaveShape: saveShapeHandler, + cbUpdateShape: updateShapeHandler, + cdDeleteShape: deleteShapeHandler, + cbDeleteVisibleShapeById: deleteVisibleShapeByIdHandler, + cbVisiblePolylinesChange: visiblePolylinesChange, + cbSetDrawingStatus: drawingStatusHandler, + cbSetDrawingShape: drawingShapeHandler, + measurementOrder: findLargestNumber(shapes), + measurementMode: isMeasurementEnabled ? "measurement" : "default", + cbSetActiveShape: setActiveShapeHandler, + cbSetUpdateStatusHandler: setUpdateStatusHandler, + cbMapMovingEndHandler: mapMovingEndHandler, + cbSaveLastActiveShapeIdBeforeDrawingHandler: + saveLastActiveShapeIdBeforeDrawingHandler, + cbChangeActiveCanceldShapeId: changeActiveCancelledShapeId, + cbToggleMeasurementMode: toggleMeasurementModeHandler, + cbUpdateAreaOfDrawingMeasurement: updateAreaOfDrawingMeasurementHandler, + cbSetCurrentDrawHandler: setCurrentDrawHandler, + cbSetMapStatus: setStatus, + }; + + const measurePolygonControl = (L.control as any).measurePolygon( + customOptions + ); + measurePolygonControl.addTo(mapExample); + + setMeasureControl(measurePolygonControl); + + // Restore previous state if available + if (lastValidStateRef.current) { + console.debug( + "[Measurements] Restoring previous state:", + lastValidStateRef.current + ); + const savedState = lastValidStateRef.current; + + // Restore mode if it was in measurement mode + if (savedState.mode === "measurement" && !isMeasurementEnabled) { + // Mode will be restored by parent component + } + + // Restore active shape if there was one + if (savedState.activeShape && !savedState.wasDrawing) { + setTimeout(() => { + setActiveShape(savedState.activeShape); + }, 100); + } + + lastValidStateRef.current = null; + } + } + }, [realRoutedMapRef]); + + // Cleanup on unmount + useEffect(() => { + return () => { + if (measureControl) { + console.debug("[Measurements] Cleaning up control on unmount"); + try { + const mapExample = + realRoutedMapRef.current?.leafletMap?.leafletElement; + if (mapExample) { + mapExample.removeControl(measureControl); + } + } catch (e) { + console.warn("[Measurements] Error during cleanup:", e); + } + } + }; + }, [measureControl, realRoutedMapRef]); + + // Sync device detection to control options + useEffect(() => { + if (measureControl) { + measureControl.options.device = device; + } + }, [measureControl, device]); + + useEffect(() => { + if (measureControl && activeShape) { + const shapeCoordinates = shapes.filter((s) => s.shapeId === activeShape); + const map = realRoutedMapRef.current?.leafletMap?.leafletElement; + if (!map) return; + + if (ifDrawing) { + setMoveToShape(null); + } + + if (shapeCoordinates[0]?.shapeId && !ifDrawing && !deleteAll) { + measureControl.changeColorByActivePolyline( + map, + shapeCoordinates[0].shapeId + ); + } + if (showAll) { + const allPolylines = measureControl.getAllPolylines(map); + measureControl.fitMapToPolylines(map, allPolylines); + setShowAll(false); + } + + if (deleteAll) { + setMoveToShape(null); + measureControl.removePolylineById(map, activeShape); + const cleanArr = visibleShapes.filter((m) => m.shapeId !== activeShape); + deleteShapeHandler(activeShape); + setVisibleShapes(cleanArr); + + const cleanAllArr = shapes.filter((m) => m.shapeId !== activeShape); + setShapes(cleanAllArr); + setDeleteAll(false); + if (measureControl.options.shapes.length === 1) { + measureControl.options.shapes = []; + } + const cleanLocalLefletShapes = measureControl.options.shapes.filter( + (m) => m.shapeId !== activeShape + ); + + measureControl.options.shapes = cleanLocalLefletShapes; + } + if (moveToShape && !deleteAll) { + if (shapeCoordinates.length > 0) { + measureControl.showActiveShape(map, shapeCoordinates[0]?.coordinates); + } + } + } + + if (measureControl) { + const map = realRoutedMapRef.current?.leafletMap?.leafletElement; + if (!map) return; + measureControl.changeMeasurementMode( + isMeasurementEnabled ? "measurement" : "default", + map + ); + const shapeCoordinates = shapes.filter((s) => s.shapeId === activeShape); + if (shapeCoordinates[0]?.shapeId) { + measureControl.changeColorByActivePolyline( + map, + shapeCoordinates[0].shapeId + ); + } + + if (isMeasurementEnabled && visibleShapes.length === 0) { + measureControl.getVisibleShapeIdsArr(measureControl._map); + } + } + }, [ + activeShape, + measureControl, + showAll, + deleteAll, + ifDrawing, + moveToShape, + isMeasurementEnabled, + realRoutedMapRef, + ]); + + useEffect(() => { + if (measureControl) { + const cleanedVisibleArr = filterArrByIds(visiblePolylines, shapes); + + // Preserve drawing shape (5555) if we're in drawing mode + const drawingShapeInVisible = visibleShapes.find( + (s) => s.shapeId === 5555 + ); + if ( + ifDrawing && + drawingShapeInVisible && + !cleanedVisibleArr.find((s) => s.shapeId === 5555) + ) { + cleanedVisibleArr.push(drawingShapeInVisible); + } + + setVisibleShapes(cleanedVisibleArr); + measureControl.changeMeasurementsArr(shapes); + } + }, [visiblePolylines, shapes, ifDrawing]); + + useEffect(() => { + if (drawingShape) { + const cleanArr = visibleShapes.filter((m) => m.shapeId !== 5555); + setVisibleShapes([...cleanArr, drawingShape]); + } else { + setLastVisibleShapeActive(); + } + }, [drawingShape]); + + const saveShapeHandler = (layer) => { + addShape(layer); + }; + const deleteShapeHandler = (id) => { + deleteShapeById(id); + }; + const deleteVisibleShapeByIdHandler = (id) => { + deleteVisibleShapeById(id); + }; + const updateShapeHandler = (id, newCoordinates, newDistance, newSquare) => { + updateShapeById(id, newCoordinates, newDistance, newSquare); + }; + + const saveLastActiveShapeIdBeforeDrawingHandler = () => { + setDrawingWithLastActiveShape(); + }; + const changeActiveCancelledShapeId = () => { + setActiveShapeIfDrawCancelled(); + }; + + const visiblePolylinesChange = (arr) => { + setVisiblePolylines(arr); + }; + + const drawingStatusHandler = (status) => { + setDrawingShape(status); + }; + + const drawingShapeHandler = (draw) => { + setDrawingLine(draw); + }; + const setActiveShapeHandler = (id) => { + setActiveShape(id); + setMoveToShape(null); + }; + const setUpdateStatusHandler = (status) => { + setUpdateShape(status); + }; + const mapMovingEndHandler = (status) => { + setMapMovingEnd(status); + }; + + const updateAreaOfDrawingMeasurementHandler = (newArea) => { + updateAreaOfDrawing(newArea); + }; + + // Debug: Log what's causing rerenders + useEffect(() => { + console.debug("[Measurements] Rerender triggered. State:", { + activeShape, + shapesCount: shapes.length, + visibleShapesCount: visibleShapes.length, + isMeasurementEnabled, + ifDrawing, + snappingLatlng: !!snappingLatlngRef?.current, + }); + }); + + const SNAPPING_MODIFIER_KEY = "Alt"; + + const isSnappingModifierPressed = (event: any) => { + if (event.getModifierState) { + return event.getModifierState(SNAPPING_MODIFIER_KEY); + } + // Fallback for synthetic events or simple objects + if (SNAPPING_MODIFIER_KEY === "Alt") return event.altKey; + if (SNAPPING_MODIFIER_KEY === "Control") return event.ctrlKey; + if (SNAPPING_MODIFIER_KEY === "Shift") return event.shiftKey; + return false; + }; +}; diff --git a/libraries/commons/measurements/src/lib/lib-measurements.tsx b/libraries/commons/measurements/src/lib/lib-measurements.tsx deleted file mode 100644 index 02831d001a..0000000000 --- a/libraries/commons/measurements/src/lib/lib-measurements.tsx +++ /dev/null @@ -1,338 +0,0 @@ -import React, { useState, useEffect, useContext } from "react"; -import { TopicMapContext } from "react-cismap/contexts/TopicMapContextProvider"; -import "leaflet/dist/leaflet.css"; -import "leaflet-draw/dist/leaflet.draw.css"; -import L from "leaflet"; -import "leaflet-draw"; -import "leaflet-editable"; -import "./utils/measure"; -import "./utils/measure-path"; -import "leaflet-measure-path/leaflet-measure-path.css"; -import "./styles/m-style.css"; -import useDeviceDetection from "./hooks/useDeviceDetection"; -import { useMapMeasurementsContext } from "./components/MapMeasurementsProvider"; -import { MapMeasurementProps, MeasurementShapeDrawing } from ".."; -import { MeasurementsSnapping } from "./components/MeasurementsSnapping"; -import { MeasurementStatusDebug } from "./components/MeasurementStatusDebug"; - -export function Measurements({ - mode: propMode, - polygonActiveIcon, - polygonIcon, - snappingLayers, -}: Partial & { - snappingLayers?: any[]; // MapLibre layers for snapping -}) { - const { routedMapRef } = useContext(TopicMapContext); - const { - mode, - activeShape, - setActiveShape, - shapes, - setShapes, - addShape, - deleteAll, - setDeleteAll, - setUpdateShape, - visibleShapes, - setVisibleShapes, - drawingShape: ifDrawing, - setDrawingShape, - moveToShape, - setMoveToShape, - showAll, - setShowAll, - toggleMeasurementMode: toggleUIMode, - setMapMovingEnd, - deleteShapeById, - updateShapeById, - setLastVisibleShapeActive, - setDrawingWithLastActiveShape, - setActiveShapeIfDrawCancelled, - updateAreaOfDrawing, - deleteVisibleShapeById, - setCurrentDrawHandler, - snappingLatlng, - config, - setStatus, - - // looks unuseful - setStartDrawing, - } = useMapMeasurementsContext(); - - // Use context mode if propMode is not provided - const currentMode = propMode !== undefined ? propMode : mode; - - const [measureControl, setMeasureControl] = useState(null); - const [visiblePolylines, setVisiblePolylines] = useState<(string | number)[]>( - [] - ); - const [drawingShape, setDrawingLine] = useState(null); - - const device = useDeviceDetection(); - - const toggleMeasurementModeHandler = () => { - toggleUIMode(); - }; - - useEffect(() => { - if (routedMapRef?.leafletMap && !measureControl) { - const mapExample = routedMapRef?.leafletMap?.leafletElement; - const customOptions = { - position: "topright", - // icon_lineActive: makeMeasureActiveIcon, - // icon_lineInactive: makeMeasureIcon, - icon_polygonActive: polygonActiveIcon, - icon_polygonInactive: polygonIcon, - activeShape, - mode_btn: `
+
`, - msj_disable_tool: "Do you want to disable the tool?", - device, - shapes, - snappingLatlng, - snappingEnabled: config.snappingEnabled, - cbSaveShape: saveShapeHandler, - cbUpdateShape: updateShapeHandler, - cdDeleteShape: deleteShapeHandler, - cbDeleteVisibleShapeById: deleteVisibleShapeByIdHandler, - cbVisiblePolylinesChange: visiblePolylinesChange, - cbSetDrawingStatus: drawingStatusHandler, - cbSetDrawingShape: drawingShapeHandler, - measurementOrder: findLargestNumber(shapes), - measurementMode: currentMode, - cbSetActiveShape: setActiveShapeHandler, - cbSetUpdateStatusHandler: setUpdateStatusHandler, - cbMapMovingEndHandler: mapMovingEndHandler, - cbSaveLastActiveShapeIdBeforeDrawingHandler: - saveLastActiveShapeIdBeforeDrawingHandler, - cbChangeActiveCanceldShapeId: changeActiveCancelledShapeId, - cbToggleMeasurementMode: toggleMeasurementModeHandler, - cbUpdateAreaOfDrawingMeasurement: updateAreaOfDrawingMeasurementHandler, - cbSetCurrentDrawHandler: setCurrentDrawHandler, - cbSetMapStatus: setStatus, - }; - - const measurePolygonControl = (L.control as any).measurePolygon( - customOptions - ); - measurePolygonControl.addTo(mapExample); - - setMeasureControl(measurePolygonControl); - } - }, [routedMapRef]); - - useEffect(() => { - if (measureControl && activeShape) { - const shapeCoordinates = shapes.filter((s) => s.shapeId === activeShape); - const map = routedMapRef.leafletMap.leafletElement; - - if (ifDrawing) { - setMoveToShape(null); - } - - if (shapeCoordinates[0]?.shapeId && !ifDrawing && !deleteAll) { - measureControl.changeColorByActivePolyline( - map, - shapeCoordinates[0].shapeId - ); - } - if (showAll) { - const allPolylines = measureControl.getAllPolylines(map); - measureControl.fitMapToPolylines(map, allPolylines); - setShowAll(false); - } - - if (deleteAll) { - setMoveToShape(null); - measureControl.removePolylineById(map, activeShape); - const cleanArr = visibleShapes.filter((m) => m.shapeId !== activeShape); - deleteShapeHandler(activeShape); - setVisibleShapes(cleanArr); - - const cleanAllArr = shapes.filter((m) => m.shapeId !== activeShape); - setShapes(cleanAllArr); - setDeleteAll(false); - if (measureControl.options.shapes.length === 1) { - measureControl.options.shapes = []; - } - const cleanLocalLefletShapes = measureControl.options.shapes.filter( - (m) => m.shapeId !== activeShape - ); - - measureControl.options.shapes = cleanLocalLefletShapes; - } - if (moveToShape && !deleteAll) { - if (shapeCoordinates.length > 0) { - measureControl.showActiveShape(map, shapeCoordinates[0]?.coordinates); - } - } - } - - if (measureControl) { - const map = routedMapRef.leafletMap.leafletElement; - measureControl.changeMeasurementMode(currentMode, map); - const shapeCoordinates = shapes.filter((s) => s.shapeId === activeShape); - if (shapeCoordinates[0]?.shapeId) { - measureControl.changeColorByActivePolyline( - map, - shapeCoordinates[0].shapeId - ); - } - - if (currentMode === "measurement" && visibleShapes.length === 0) { - const visibleShapesIds = measureControl.getVisibleShapeIdsArr( - measureControl._map - ); - } - } - }, [ - activeShape, - measureControl, - showAll, - deleteAll, - ifDrawing, - moveToShape, - currentMode, - ]); - - // keep snappingLatlng and snappingEnabled in sync with control options - useEffect(() => { - if (measureControl) { - try { - // Update both snappingEnabled and snappingLatlng - measureControl.options.snappingEnabled = config.snappingEnabled; - // Force null when snapping is disabled, otherwise use the actual value - measureControl.options.snappingLatlng = config.snappingEnabled - ? snappingLatlng - : null; - } catch (_) {} - } - }, [snappingLatlng, measureControl, config.snappingEnabled]); - - useEffect(() => { - if (measureControl) { - const cleanedVisibleArr = filterArrByIds(visiblePolylines, shapes); - - // Preserve drawing shape (5555) if we're in drawing mode - const drawingShapeInVisible = visibleShapes.find( - (s) => s.shapeId === 5555 - ); - if ( - ifDrawing && - drawingShapeInVisible && - !cleanedVisibleArr.find((s) => s.shapeId === 5555) - ) { - cleanedVisibleArr.push(drawingShapeInVisible); - } - - setVisibleShapes(cleanedVisibleArr); - measureControl.changeMeasurementsArr(shapes); - } - }, [visiblePolylines, shapes, ifDrawing]); - - useEffect(() => { - if (drawingShape) { - const cleanArr = visibleShapes.filter((m) => m.shapeId !== 5555); - setVisibleShapes([...cleanArr, drawingShape]); - } else { - setLastVisibleShapeActive(); - } - }, [drawingShape]); - - const saveShapeHandler = (layer) => { - addShape(layer); - }; - const deleteShapeHandler = (id) => { - deleteShapeById(id); - }; - const deleteVisibleShapeByIdHandler = (id) => { - deleteVisibleShapeById(id); - }; - const updateShapeHandler = (id, newCoordinates, newDistance, newSquare) => { - updateShapeById(id, newCoordinates, newDistance, newSquare); - }; - - const saveLastActiveShapeIdBeforeDrawingHandler = () => { - setDrawingWithLastActiveShape(); - }; - const changeActiveCancelledShapeId = () => { - setActiveShapeIfDrawCancelled(); - }; - - const visiblePolylinesChange = (arr) => { - setVisiblePolylines(arr); - }; - - const drawingStatusHandler = (status) => { - setDrawingShape(status); - setStartDrawing(status); - }; - - const drawingShapeHandler = (draw) => { - setDrawingLine(draw); - }; - const setActiveShapeHandler = (id) => { - setActiveShape(id); - setMoveToShape(null); - }; - const setUpdateStatusHandler = (status) => { - setUpdateShape(status); - }; - const mapMovingEndHandler = (status) => { - setMapMovingEnd(status); - }; - - const updateAreaOfDrawingMeasurementHandler = (newArea) => { - updateAreaOfDrawing(newArea); - }; - - console.debug("RENDER: [MAPMEASUREMENT] MapMeasurement"); - - return ( - <> -
- - {currentMode === "measurement" && ( - - )} - - ); -} - -function filterArrByIds( - arrIds: (string | number)[], - fullArray: MeasurementShapeDrawing[] -): MeasurementShapeDrawing[] { - const finalResult: MeasurementShapeDrawing[] = []; - fullArray.forEach((currentItem) => { - if (arrIds.includes(currentItem.shapeId)) { - finalResult.push(currentItem); - } - }); - - return finalResult; -} - -function findLargestNumber(measurements: MeasurementShapeDrawing[]): number { - let largestNumber = 0; - - measurements.forEach((item) => { - if (item.number > largestNumber) { - largestNumber = item.number; - } - }); - - return largestNumber; -} - -/** - * @deprecated Use `Measurements` instead. This component will be removed in a future version. - */ -export function MapMeasurementsObjects( - props: Partial & { - snappingEnabled?: boolean; - snappingLayer?: any; - } -) { - return ; -} diff --git a/libraries/commons/measurements/src/lib/snapping/types.ts b/libraries/commons/measurements/src/lib/snapping/types.ts deleted file mode 100644 index f5828d5c2e..0000000000 --- a/libraries/commons/measurements/src/lib/snapping/types.ts +++ /dev/null @@ -1,22 +0,0 @@ -export interface SnappingPoint { - coordinates: [number, number]; // [lng, lat] - sourceId: string; - distance?: number; // Calculated later - metadata?: { - featureId?: string; - shapeId?: string | number; - geometryType?: string; - }; -} - -export interface MousePosition { - x: number; - y: number; -} - -export interface SnappingContext { - mousePosition: MousePosition; - queryRadius: number; - maplibreMap: any; - leafletMap: any; -} diff --git a/libraries/commons/measurements/src/lib/snapping/utils/coordinateExtraction.ts b/libraries/commons/measurements/src/lib/snapping/utils/coordinateExtraction.ts index 8cf198d841..c722445be5 100644 --- a/libraries/commons/measurements/src/lib/snapping/utils/coordinateExtraction.ts +++ b/libraries/commons/measurements/src/lib/snapping/utils/coordinateExtraction.ts @@ -1,4 +1,4 @@ -import { SnappingPoint } from "../types"; +import { SnappingPoint } from "../../types"; /** * Extract snapping points from a GeoJSON geometry diff --git a/libraries/commons/measurements/src/lib/snapping/utils/mapLibreExtraction.ts b/libraries/commons/measurements/src/lib/snapping/utils/mapLibreExtraction.ts new file mode 100644 index 0000000000..00c3eac41d --- /dev/null +++ b/libraries/commons/measurements/src/lib/snapping/utils/mapLibreExtraction.ts @@ -0,0 +1,68 @@ +import { + type LayerSpecification, + Map as MapLibreMap, + type MapGeoJSONFeature, + Point, + type PointLike, +} from "maplibre-gl"; +import { LayerCarmaConf } from "@carma/types"; +import { SnappingPoint } from "../../types"; +import { extractPointsFromGeometry } from "./coordinateExtraction"; + +export const getSnappingPointsFromMapLibre = ( + maps: MapLibreMap[], + screenPoint: { x: number; y: number }, + radius: number +): SnappingPoint[] => { + const coordinatePoints: SnappingPoint[] = []; + + maps.forEach((currentMaplibreMap) => { + if ( + currentMaplibreMap && + currentMaplibreMap.getStyle && + currentMaplibreMap.getCanvas + ) { + try { + const style = currentMaplibreMap.getStyle(); + if (style && style.layers) { + const canvas = currentMaplibreMap.getCanvas(); + const rect = canvas.getBoundingClientRect(); + + // Calculate point relative to this map's canvas + const pointX = screenPoint.x - rect.left; + const pointY = screenPoint.y - rect.top; + + // Use Point objects directly to avoid internal conversion overhead in MapLibre + const bbox: [PointLike, PointLike] = [ + new Point(pointX - radius, pointY - radius), + new Point(pointX + radius, pointY + radius), + ]; + + const features = currentMaplibreMap.queryRenderedFeatures(bbox, { + layers: style.layers + .filter((layer: LayerSpecification) => { + // Skip layers with skipSnapping metadata + const skipSnapping = ( + (layer.metadata as any)?.carmaConf as LayerCarmaConf + )?.skipSnapping; + return !skipSnapping && !layer.id.startsWith("highlight-"); + }) + .map((layer: LayerSpecification) => layer.id), + }); + + features.forEach((feature: MapGeoJSONFeature) => { + const points = extractPointsFromGeometry( + feature.geometry, + "vector-features" + ); + coordinatePoints.push(...points); + }); + } + } catch (error) { + console.warn("Error extracting vector features:", error); + } + } + }); + + return coordinatePoints; +}; diff --git a/libraries/commons/measurements/src/lib/types/MeasurementShape.d.ts b/libraries/commons/measurements/src/lib/types/MeasurementShape.d.ts new file mode 100644 index 0000000000..e20434aafa --- /dev/null +++ b/libraries/commons/measurements/src/lib/types/MeasurementShape.d.ts @@ -0,0 +1,15 @@ +export interface MeasurementShapeDrawing { + shapeId: number | string; + number: number; + coordinates?: unknown; + [key: string]: unknown; +} + +export interface MeasurementShape { + shapeId: number | string; + distance?: number; + area?: number; + customTitle?: string; + shapeType?: "line" | "polygon" | string; + [key: string]: unknown; +} diff --git a/libraries/commons/measurements/src/lib/types/SnappingPoint.d.ts b/libraries/commons/measurements/src/lib/types/SnappingPoint.d.ts new file mode 100644 index 0000000000..a2bf253c55 --- /dev/null +++ b/libraries/commons/measurements/src/lib/types/SnappingPoint.d.ts @@ -0,0 +1,10 @@ +export type SnappingPoint = { + coordinates: [number, number]; // [lng, lat] + sourceId: string; + distance?: number; // Calculated later + metadata?: { + featureId?: string; + shapeId?: string | number; + geometryType?: string; + }; +}; diff --git a/libraries/commons/measurements/src/lib/types/index.ts b/libraries/commons/measurements/src/lib/types/index.ts new file mode 100644 index 0000000000..03228a5c64 --- /dev/null +++ b/libraries/commons/measurements/src/lib/types/index.ts @@ -0,0 +1,10 @@ +/** + * Large Behemoth Type Exports + * Only for big type augmentations like Leaflet extensions + * Small/co-located types should stay with their modules + */ + +// Leaflet extensions (large type augmentation) +export * from "./leaflet-extensions.d"; +export * from "./MeasurementShape"; +export * from "./SnappingPoint"; diff --git a/libraries/commons/measurements/src/lib/types/leaflet-extensions.d.ts b/libraries/commons/measurements/src/lib/types/leaflet-extensions.d.ts new file mode 100644 index 0000000000..c8c8e1eccf --- /dev/null +++ b/libraries/commons/measurements/src/lib/types/leaflet-extensions.d.ts @@ -0,0 +1,290 @@ +import { + Polyline, + Polygon, + Marker, + Layer, + Control, + ControlOptions, + LayerGroup, + LatLng, + Point, + LeafletMouseEvent, + LeafletEvent, + Map as LeafletMap, +} from "@carma/leaflet"; + +// Extended interfaces for measurement-specific objects +export interface MeasurementPolyline extends Polyline { + customID?: number | string; + customShape?: string; + _path?: SVGPathElement; + _leaflet_id?: number; + enableEdit?: () => void; + disableEdit?: () => void; +} + +export interface MeasurementPolygon extends Polygon { + customID?: number | string; + customShape?: string; + customHandle?: number; + _path?: SVGPathElement; + enableEdit?: () => void; + disableEdit?: () => void; +} + +export interface MeasurementMarker extends Marker { + customHandle?: number; +} + +export interface MeasurementLayer extends Layer { + customID?: number | string; + customHandle?: number; + _path?: SVGPathElement; + enableEdit?: () => void; + disableEdit?: () => void; + getLatLng?: () => LatLng; + _leaflet_id?: number; +} + +export interface MeasurementLeafletEvent extends LeafletEvent { + layerType?: string; + layers?: LayerGroup; +} + +export interface MeasurementShapeData { + coordinates: number[][]; + options: { + color: string; + fillColor: string | null; + opacity: number; + weight: number; + }; + shapeId: number | string; + distance: string; + number: number; + area?: string | null; + shapeType: "line" | "polygon"; + customTitle?: string; +} + +export interface DrawHandler { + _poly?: { _latlngs: LatLng[] }; + _enabled?: boolean; + enable(): void; + disable(): void; + completeShape?: () => void; + addVertex?(latlng: LatLng): void; + _markers?: MeasurementMarker[]; +} + +export interface MeasurePolygonOptions extends ControlOptions { + icon_lineActive: string; + icon_lineInactive: string; + icon_polygonActive: string; + icon_polygonInactive: string; + html_template: string; + height: number; + width: number; + mode_btn: string; + color_polygon: string; + fillColor_polygon: string; + weight_polygon: string; + isDrawing: boolean; + changeModeButtonActive: boolean; + msj_disable_tool: string; + shapes: MeasurementShapeData[]; + activeShape: number | string | null; + shapeMode: "line" | "polygon"; + measurementOrder: number; + moveToShape: boolean | MeasurementShapeData | null; + cb: () => void; + cbSaveShape: (shape: MeasurementShapeData) => void; + cdDeleteShape: ( + id: number | string, + localShapeStore: MeasurementShapeData[] + ) => void; + cbUpdateShape: ( + id: number | string, + newCoordinates: number[][], + newDistance: string, + newSquare: string | null + ) => void; + cbVisiblePolylinesChange: (ids: (number | string)[]) => void; + cbSetDrawingStatus: (status: boolean) => void; + cbSetDrawingShape: (shape: MeasurementShapeData | null) => void; + cbSetActiveShape: (id: number | string) => void; + cbSetUpdateStatusHandler: (status: boolean) => void; + cbMapMovingEndHandler: (status: boolean) => void; + cbSaveLastActiveShapeIdBeforeDrawingHandler: () => void; + cbChangeActiveCanceldShapeId: () => void; + cbToggleMeasurementMode: () => void; + cbGetMeasurementModeHandler: () => void; + cbDeleteVisibleShapeById: (id: number | string) => void; + cbUpdateAreaOfDrawingMeasurement: (area: string | null) => void; + cbSetCurrentDrawHandler: (handler: DrawHandler | null) => void; + cbSetMapStatus?: (status: string) => void; + visiblePolylines: (string | number)[]; + localShapeStore: MeasurementShapeData[]; + isDrawingEmpty: boolean; + nativeMove: boolean; + currenLine: DrawHandler | null; + polygonMode: boolean; + measurementMode: string | boolean; + startDrawing: boolean; + customTooltip: HTMLElement | null; + device: "desktop" | "mobile" | "tablet" | "Desktop" | null; + clickAfterShapeSelection: boolean; + snappingLatlng: LatLng | null; + snappingEnabled: boolean; + snappingQueryRadius?: number; +} + +export interface MeasurePolygonControl extends Control { + options: MeasurePolygonOptions; + _map: LeafletMap; + _measureLayers: LayerGroup; + _measureHandler: any; + _lastOriginalClick: { latlng: LatLng; containerPoint: Point }; + + _mapClickHandler?: (event: LeafletMouseEvent) => void; + _drawCreatedHandler?: (event: any) => void; + _drawDrawstartHandler?: (event: any) => void; + _drawDrawvertexHandler?: (event: any) => void; + _drawCanceledHandler?: () => void; + _moveendHandler?: (event: any) => void; + _mousemoveHandler?: (event: LeafletMouseEvent) => void; + _mouseoutHandler?: (event: LeafletMouseEvent) => void; + _vertexClickHandler?: (event: LeafletMouseEvent) => void; + _isFinishingShape?: boolean; + drawingLines(map: LeafletMap, event: LeafletMouseEvent): void; + + onAdd(map: LeafletMap): HTMLElement; + _clearMeasurements(): void; + changeColorByActivePolyline(map: LeafletMap, customID: number | string): void; + changeColorByLastShape(map: LeafletMap): void; + showLastPolylineOnFirstLoding(map: LeafletMap): void; + getVisiblePolylines(map: LeafletMap): MeasurementPolyline[]; + getVisiblePolylinesIds(polylines: MeasurementPolyline[]): void; + getAllPolylines(map: LeafletMap): MeasurementPolyline[]; + removePolylineById(map: LeafletMap, customID: number | string): void; + fitMapToAllPolylines(map: LeafletMap): void; + fitMapToPolylines(map: LeafletMap, polylines: MeasurementPolyline[]): void; + convertPolylineToPolygon(map: LeafletMap, layer: MeasurementPolyline): void; + loadMeasurements(map?: LeafletMap): void; + _toggleMeasurementBtn(): void; + toggleMeasurementMode(ifChangeMode?: boolean, map?: LeafletMap): void; + _UpdateDistance(layer: MeasurementPolyline): string; + _toggleMeasure(id: string, iconActive: string, inactiveIcon: string): void; + calculateArea(coordinates: number[][]): string; + calculateDistance(latlngs: LatLng[]): number; + formatDistance(distance: number): string; + saveShapeHandler( + layer: MeasurementPolyline, + distance: string | null, + area: string | null, + map: LeafletMap + ): void; + _onPolylineDrag(event: LeafletEvent): void; + replaceLineToPolygon( + map: LeafletMap, + layer: MeasurementPolyline + ): MeasurementShapeData; + getVisibleShapeIdsArr(map: LeafletMap): (number | string)[]; + _UpdateDistanceByLatLngs(coordinates: number[][]): string; + showActiveShape(map: LeafletMap, coordinates: number[][]): void; + changeMeasurementMode(mode: string, map: LeafletMap): void; + changeMeasurementsArr(arr: MeasurementShapeData[]): void; + findLastCreatedLayer(layerGroup: LayerGroup): Layer | null; + cancelDrawing(): void; + startDrawing(): void; + _onPolygonClick(map: LeafletMap, event: LeafletMouseEvent): void; + _UpdateAreaperimeter(layer: MeasurementPolygon): void; +} + +declare module "leaflet" { + namespace Control { + interface MeasurementShapeData extends MeasurementShapeData {} + interface DrawHandler extends DrawHandler {} + interface MeasurePolygonOptions extends MeasurePolygonOptions {} + class MeasurePolygon extends Control implements MeasurePolygonControl { + options: MeasurePolygonOptions; + _map: LeafletMap; + _measureLayers: LayerGroup; + _measureHandler: any; + _lastOriginalClick: { latlng: LatLng; containerPoint: Point }; + + _mapClickHandler?: (event: LeafletMouseEvent) => void; + _drawCreatedHandler?: (event: any) => void; + _drawDrawstartHandler?: (event: any) => void; + _drawDrawvertexHandler?: (event: any) => void; + _drawCanceledHandler?: () => void; + _moveendHandler?: (event: any) => void; + _mousemoveHandler?: (event: LeafletMouseEvent) => void; + _mouseoutHandler?: (event: LeafletMouseEvent) => void; + _vertexClickHandler?: (event: LeafletMouseEvent) => void; + _isFinishingShape?: boolean; + drawingLines(map: LeafletMap, event: LeafletMouseEvent): void; + + onAdd(map: LeafletMap): HTMLElement; + _clearMeasurements(): void; + changeColorByActivePolyline( + map: LeafletMap, + customID: number | string + ): void; + changeColorByLastShape(map: LeafletMap): void; + showLastPolylineOnFirstLoding(map: LeafletMap): void; + getVisiblePolylines(map: LeafletMap): MeasurementPolyline[]; + getVisiblePolylinesIds(polylines: MeasurementPolyline[]): void; + getAllPolylines(map: LeafletMap): MeasurementPolyline[]; + removePolylineById(map: LeafletMap, customID: number | string): void; + fitMapToAllPolylines(map: LeafletMap): void; + fitMapToPolylines( + map: LeafletMap, + polylines: MeasurementPolyline[] + ): void; + convertPolylineToPolygon( + map: LeafletMap, + layer: MeasurementPolyline + ): void; + loadMeasurements(map?: LeafletMap): void; + _toggleMeasurementBtn(): void; + toggleMeasurementMode(ifChangeMode?: boolean, map?: LeafletMap): void; + _UpdateDistance(layer: MeasurementPolyline): string; + _toggleMeasure( + id: string, + iconActive: string, + inactiveIcon: string + ): void; + calculateArea(coordinates: number[][]): string; + calculateDistance(latlngs: LatLng[]): number; + formatDistance(distance: number): string; + saveShapeHandler( + layer: MeasurementPolyline, + distance: string | null, + area: string | null, + map: LeafletMap + ): void; + _onPolylineDrag(event: LeafletEvent): void; + replaceLineToPolygon( + map: LeafletMap, + layer: MeasurementPolyline + ): MeasurementShapeData; + getVisibleShapeIdsArr(map: LeafletMap): (number | string)[]; + _UpdateDistanceByLatLngs(coordinates: number[][]): string; + showActiveShape(map: LeafletMap, coordinates: number[][]): void; + changeMeasurementMode(mode: string, map: LeafletMap): void; + changeMeasurementsArr(arr: MeasurementShapeData[]): void; + findLastCreatedLayer(layerGroup: LayerGroup): Layer | null; + cancelDrawing(): void; + startDrawing(): void; + _onPolygonClick(map: LeafletMap, event: LeafletMouseEvent): void; + _UpdateAreaperimeter(layer: MeasurementPolygon): void; + } + } + + namespace control { + function measurePolygon( + options?: Partial + ): Control.MeasurePolygon; + } +} diff --git a/libraries/commons/measurements/src/lib/utils/helper.ts b/libraries/commons/measurements/src/lib/utils/helper.ts index 8c06ce04d8..ad54dcc40c 100644 --- a/libraries/commons/measurements/src/lib/utils/helper.ts +++ b/libraries/commons/measurements/src/lib/utils/helper.ts @@ -1,4 +1,5 @@ import localforage from "localforage"; +import { point, latLng } from "@carma/leaflet"; export const setFromLocalforage = async ( lfKey: string, @@ -37,14 +38,14 @@ export const adjustClickPosition = ( currentDrawHandler?: any ) => { const containerPoint = leafletMap.mouseEventToContainerPoint(domEvent); - const shiftedContainerPoint = L.point(containerPoint.x, containerPoint.y); + const shiftedContainerPoint = point(containerPoint.x, containerPoint.y); // Use closestPoint if available, otherwise use shifted click position if (!closestPoint) { return false; } const [lng, lat] = closestPoint.geometry.coordinates; - const finalLatLng = L.latLng(lat, lng); + const finalLatLng = latLng(lat, lng); // Check if we're drawing and snapped to first vertex (polygon closure) // ONLY trigger if the snap source is the drawing-in-progress (not external features) @@ -104,5 +105,31 @@ export const toLatLngFromClosestPoint = (closestPoint: any) => { return null; } const [lng, lat] = closestPoint.geometry.coordinates; - return L.latLng(lat, lng); + return latLng(lat, lng); }; + +export function filterArrByIds( + arrIds: (string | number)[], + fullArray: any[] +): any[] { + const finalResult: any[] = []; + fullArray.forEach((currentItem) => { + if (arrIds.includes(currentItem.shapeId)) { + finalResult.push(currentItem); + } + }); + + return finalResult; +} + +export function findLargestNumber(measurements: any[]): number { + let largestNumber = 0; + + measurements.forEach((item) => { + if (item.number > largestNumber) { + largestNumber = item.number; + } + }); + + return largestNumber; +} diff --git a/libraries/commons/measurements/src/lib/utils/measure-path.js b/libraries/commons/measurements/src/lib/utils/measure-path.js deleted file mode 100644 index 0896dbd1de..0000000000 --- a/libraries/commons/measurements/src/lib/utils/measure-path.js +++ /dev/null @@ -1,1154 +0,0 @@ -// Create a class for the plugin -L.Control.MeasurePolygon = L.Control.extend({ - options: { - position: "topright", - icon_lineActive: "https://img.icons8.com/?size=48&id=98497&format=png", - icon_lineInactive: "https://img.icons8.com/?size=48&id=98463&format=png", - icon_polygonActive: "https://img.icons8.com/?size=48&id=98497&format=png", - icon_polygonInactive: "https://img.icons8.com/?size=48&id=98463&format=png", - html_template: `

Results

-

Area:
_p_area

-

Perimeter :
_p_perimetro

`, - height: 130, - width: 150, - mode_btn: "", - color_polygon: "black", - fillColor_polygon: "yellow", - weight_polygon: "2", - checkonedrawpoligon: false, - changeModeButtonActive: false, - msj_disable_tool: "Möchten Sie das Tool deaktivieren?", - shapes: [], - activeShape: null, - shapeMode: "line", - measurementOrder: 0, - moveToShape: false, - cb: function () { - console.debug("Callback function executed!"); - }, - cbSaveShape: function () { - console.debug("Callback function executed!"); - }, - cdDeleteShape: function () { - console.debug("Callback function executed!"); - }, - cbUpdateShape: function () { - console.debug("Callback function executed!"); - }, - cbVisiblePolylinesChange: function () { - console.debug("Callback function executed!"); - }, - cbSetDrawingStatus: function () { - console.debug("Callback function executed!"); - }, - cbSetDrawingShape: function () { - console.debug("Callback function executed!"); - }, - cbSetActiveShape: function () { - console.debug("Callback function executed!"); - }, - cbSetUpdateStatusHandler: function () { - console.debug("Callback function executed!"); - }, - cbMapMovingEndHandler: function () { - console.debug("Callback function executed!"); - }, - cbSaveLastActiveShapeIdBeforeDrawingHandler: function () { - console.debug("Callback function executed!"); - }, - cbChangeActiveCanceldShapeId: function () { - console.debug("Callback function executed!"); - }, - cbToggleMeasurementMode: function () { - console.debug("Callback function executed!"); - }, - cbGetMeasurementModeHandler: function () { - console.debug("Callback function executed!"); - }, - cbDeleteVisibleShapeById: function () { - console.debug("Callback function executed!"); - }, - cbUpdateAreaOfDrawingMeasurement: function () { - console.debug("Callback function executed!"); - }, - cbSetCurrentDrawHandler: function () { - console.debug("Callback function executed!"); - }, - visiblePolylines: [], - localShapeStore: [], - ifDrawing: false, - nativeMove: false, - currenLine: null, - polygonMode: false, - measurementMode: false, - startDrawing: false, - customTooltip: null, - device: null, - clickAfterShapeSelection: false, - snappingLatlng: null, - snappingEnabled: true, - }, - - drawingPolygons: function (map) { - this.options.shapeMode = "polygon"; - this._measureHandler = new L.Draw.Polygon(map, { - showArea: true, - shapeOptions: { - color: "#267bdcd4", - fillColor: null, - fillOpacity: 0.2, - stroke: true, - }, - }); - - L.drawLocal.draw.handlers.polygon.tooltip.start = - "Klicken, um den Startpunkt der Messung zu setzen."; - L.drawLocal.draw.handlers.polygon.tooltip.cont = - "Klicken (ggf. mehrmals), um die nächsten Punkte des Linienzuges zu setzen."; - L.drawLocal.draw.handlers.polygon.tooltip.end = `Zum Beenden auf den letzten angelegt Punkt klicken. - Zum Messen einer Fläche auf den ersten angeleten. - Punkt klicken und die Fläche so schließen`; - - this._toggleMeasure( - "img_plg_measure_polygon", - "icon_polygonActive", - "icon_polygonInactive" - ); - }, - - drawingLines: function (map, event) { - if (this.options.customTooltip) { - this.options.customTooltip.style.visibility = "hidden"; - } - this.options.shapeMode = "line"; - this._measureHandler = new L.Draw.Polyline(map, { - showLength: true, - shapeOptions: { - weight: 3, - color: "#267bdcd4", - opacity: 1, - }, - }); - - this.options.currenLine = this._measureHandler; - this.options.cbSetCurrentDrawHandler(this._measureHandler); - - const tooltipContent = ` -
-
Zum Beenden auf den letzten angelegten Punkt klicken.
-
Zum Messen einer Fläche auf den ersten angelegten Punkt klicken und die Fläche so schließen.
-
-`; - - L.drawLocal.draw.handlers.polyline.tooltip.start = - "Klicken, um den Startpunkt der Messung zu setzen."; - L.drawLocal.draw.handlers.polyline.tooltip.cont = - "Klicken (ggf. mehrmals), um die nächsten Punkte des Linienzuges zu setzen."; - L.drawLocal.draw.handlers.polyline.tooltip.end = tooltipContent; - - this._measureHandler.enable(); - - // Store original click position before Leaflet.Draw modifies it - this._lastOriginalClick = null; - - const storeClickPosition = (e) => { - if (this._measureHandler && this._measureHandler._enabled) { - // Store the ORIGINAL click position - this._lastOriginalClick = { - latlng: L.latLng(e.latlng.lat, e.latlng.lng), - containerPoint: map.latLngToContainerPoint(e.latlng), - }; - } - }; - - map.on("click", storeClickPosition); - map.on("touchstart", storeClickPosition); // Also handle touch events on mobile - const latlng = - this.options.snappingEnabled && this.options.snappingLatlng - ? this.options.snappingLatlng - : event.latlng; - - this.options.currenLine.addVertex(latlng); - - const tooltip = document.querySelector(".leaflet-draw-tooltip"); - - const pos = map.latLngToLayerPoint(latlng); - L.DomUtil.setPosition(tooltip, pos); - - this._toggleMeasure( - "img_plg_lines", - "icon_lineActive", - "icon_lineInactive" - ); - }, - - startDrawing: function () { - this.options.startDrawing = true; - }, - - saveShapeHandler: function (layer, distance = null, area = null, map) { - const latlngs = layer.getLatLngs(); - const latlngsJSON = layer.toGeoJSON(); - const shapeId = layer._leaflet_id; - layer.customID = shapeId; - layer.on("click", () => { - this.options.cbSetActiveShape(layer.customID); - this.options.cbSetUpdateStatusHandler(false); - }); - - if (this.options.shapeMode === "polygon") { - const polygon = this.replaceLineToPolygon(map, layer); - this.options.cbSaveShape(polygon); - this.getVisibleShapeIdsArr(map); - } else { - const prepeareCoordinates = - this.options.shapeMode === "line" - ? latlngsJSON.geometry.coordinates - : latlngsJSON.geometry.coordinates[0]; - const reversedCoordinates = prepeareCoordinates.map((item) => { - return item.reverse(); - }); - - const preparePolygon = { - coordinates: reversedCoordinates, - options: { - color: "#267bdcd4", - fillColor: null, - opacity: 0.5, - weigt: 4, - }, - shapeId, - distance, - number: this.options.measurementOrder, - area, - shapeType: this.options.shapeMode, - }; - this.options.cbSaveShape(preparePolygon); - this.getVisibleShapeIdsArr(map); - } - }, - - _onPolylineDrag: function (event) { - if (this.options.customTooltip) { - this.options.customTooltip.style.visibility = "hidden"; - } - this.options.cbSetUpdateStatusHandler(true); - - // Set status based on drag type - if (this.options.cbSetMapStatus) { - if ( - event.type === "editable:drag" || - event.type === "editable:dragstart" - ) { - // Dragging whole shape - this.options.cbSetMapStatus("MOVING"); - } else if (event.type === "editable:vertex:drag") { - // Dragging vertices or deleting vertex - this.options.cbSetMapStatus("EDITING"); - } - } - - const polyline = event.target; - const layer = event.layer; - this.options.cbSetActiveShape(layer.customID); - const latlngsJSON = layer.toGeoJSON(); - const isLine = layer.toGeoJSON().geometry.type === "LineString"; - const prepeareCoordinates = isLine - ? latlngsJSON.geometry.coordinates - : latlngsJSON.geometry.coordinates[0]; - const reversedCoordinates = prepeareCoordinates.map((item) => { - return item.reverse(); - }); - - const square = !isLine ? this.calculateArea(reversedCoordinates) : null; - polyline.updateMeasurements(); - const newDistance = this._UpdateDistance(layer); - const shapeId = polyline?.customID - ? polyline?.customID - : polyline._leaflet_id; - - this.options.cbUpdateShape( - shapeId, - reversedCoordinates, - newDistance, - square - ); - this.options.checkonedrawpoligon = false; - }, - - _onPolygonClick: function (map, event) { - const clickedPolygon = event.target; - const latlngs = clickedPolygon.getLatLngs(); - - this._measureLayers.removeLayer(clickedPolygon._leaflet_id); - const shapeId = clickedPolygon?.customID - ? clickedPolygon?.customID - : clickedPolygon._leaflet_id; - - this.options.cdDeleteShape(shapeId, this.options.localShapeStore); - - const allPolyLines = this.getVisiblePolylines(map); - this.getVisiblePolylinesIds(allPolyLines); - }, - - onAdd: function (map) { - const linesContainer = L.DomUtil.create( - "div", - "leaflet-bar leaflet-control dont-show m-container" - ); - - // const modeBtn = L.DomUtil.create( - // "div", - // "leaflet-bar leaflet-control dont-show m-container hide-draw-btn draw-custom-button", - // linesContainer, - // ); - - // modeBtn.id = "draw_shape"; - // modeBtn.title = "Flächen- und Umfangsmessungen"; - - // modeBtn.innerHTML = this.options.mode_btn; - - const lineIcon = L.DomUtil.create("a", "", linesContainer); - lineIcon.innerHTML = ` -
- Ruler Icon -
- `; - lineIcon.href = "#"; - lineIcon.title = "Messmodus"; - - const iconsWrapper = L.DomUtil.create("div", "m-icons-wrapper"); - iconsWrapper.appendChild(linesContainer); - - L.DomEvent.on( - lineIcon, - "click", - (event) => { - event.preventDefault(); // Prevent default action (e.g., redirection) - this.toggleMeasurementMode(); - }, - this - ); - - this._map = map; - - this._measureLayers = L.layerGroup().addTo(map); - - map.on("click", (event) => { - const mode = this.options.measurementMode; - if (!this.options.checkonedrawpoligon && mode === "measurement") { - this.drawingLines(map, event); - this.options.checkonedrawpoligon = true; - } else { - // this.options.checkonedrawpoligon = false; - } - - if (this.options.clickAfterShapeSelection) { - this.options.checkonedrawpoligon = false; - this.options.clickAfterShapeSelection = false; - } - }); - - map.on("draw:created", (event) => { - this.options.checkonedrawpoligon = false; - this.options.ifDrawing = false; - - this.options.cbSetDrawingStatus(false); - this.options.cbSetDrawingShape(null); - - const layer = event.layer; - // layer.on("dblclick", this._onPolygonClick.bind(this, map)); - - layer.on("editable:vertex:dragend", () => { - this.options.cbSetUpdateStatusHandler(false); - // Reset status to WAITING when vertex editing ends - if (this.options.cbSetMapStatus) { - this.options.cbSetMapStatus("WAITING"); - } - }); - - // Reset status to WAITING when drag ends - layer.on("editable:dragend", () => { - if (this.options.cbSetMapStatus) { - this.options.cbSetMapStatus("WAITING"); - } - }); - - // Add style to polygon - layer.addTo(this._measureLayers).showMeasurements().enableEdit(); - layer.options.draggable = false; - - const distance = this._UpdateDistance(layer); - - this.saveShapeHandler(layer, distance, null, map); - - layer.on( - "editable:drag editable:vertex:drag editable:vertex:deleted editable:dragstart editable:dragend", - this._onPolylineDrag.bind(this) - ); - - this.options.checkonedrawpoligon = false; - - this._measureHandler.disable(); - }); - - map.on("draw:drawstart", (event) => { - const mouseActive = - L.Browser.touch && matchMedia("(hover:hover)").matches; - if ( - mouseActive || - event.layerType === "circle" || - event.layerType === "rectangle" - ) { - event.target.touchExtend.enable(); - } else { - event.target.touchExtend.disable(); - } - this.options.cbSaveLastActiveShapeIdBeforeDrawingHandler(); - this.options.measurementOrder = this.options.measurementOrder + 1; - const shapesObj = { - coordinates: [[51.352635, 7.209284]], - distance: 0, - shapeId: 5555, - number: this.options.measurementOrder, - shapeType: "line", - }; - this.changeColorByActivePolyline(map, "ddfsc1231"); - }); - - map.on("draw:drawvertex", (event) => { - const layers = event.layers; - const latlngs = []; - let index = 0; - let firsHovering = false; - - layers.eachLayer((layer) => { - const markerLatLng = layer.getLatLng(); - layer.customHandle = index++; - - // Store reference to the click handler so we can check conditions - const vertexClickHandler = (e) => { - const clickLatLng = e.latlng; - const markerLatLng = e.target.getLatLng(); - - // Use the ORIGINAL click position stored in map click handler - const originalClick = this._lastOriginalClick; - - // Calculate pixel distance using ORIGINAL click position - const clickPoint = originalClick - ? originalClick.containerPoint - : this._map.latLngToContainerPoint(clickLatLng); - const markerPoint = this._map.latLngToContainerPoint(markerLatLng); - const pixelDistance = Math.sqrt( - Math.pow(clickPoint.x - markerPoint.x, 2) + - Math.pow(clickPoint.y - markerPoint.y, 2) - ); - - // Only process first vertex clicks (for closing polygon) - if (e.target.customHandle !== 0) { - return; // Not the first vertex, let Leaflet.Draw handle it normally - } - - // Only apply distance check when snapping is enabled (desktop) - // On mobile, snapping is disabled so let Leaflet.Draw handle closure normally - if (this.options.snappingEnabled) { - // Check if original click is within snapping radius OR if snapping is active and brought us here - const maxClickDistance = this.options.snappingQueryRadius || 40; - const isWithinSnappingRadius = pixelDistance <= maxClickDistance; - const isSnappedToFirstVertex = - this.options.snappingLatlng && - Math.abs(this.options.snappingLatlng.lat - markerLatLng.lat) < - 0.0000001 && - Math.abs(this.options.snappingLatlng.lng - markerLatLng.lng) < - 0.0000001; - - if (!isWithinSnappingRadius && !isSnappedToFirstVertex) { - // Don't process this click - it shouldn't close the polygon - return; - } - } - // On mobile (snapping disabled), always allow closure when clicking first vertex - this.options.shapeMode = "polygon"; - this.options.currenLine.completeShape(); - }; - - layer.on("click", vertexClickHandler); - layer.on("mouseover", (e) => { - const coordinates = this._measureHandler._poly._latlngs; - const latLngArray = coordinates.map((c) => [c.lat, c.lng]); - latLngArray.push(latLngArray[0]); - const area = this.calculateArea(latLngArray); - // this.options.customTooltip.style.visibility = "hidden"; - if (e.target.customHandle === 0 && firsHovering) { - this.options.cbUpdateAreaOfDrawingMeasurement(area); - L.drawLocal.draw.handlers.polyline.tooltip.end = `Den Startpunkt anklicken, um die Fläche zu schließen.`; - } - firsHovering = true; - }); - - layer.on("mouseout", (e) => { - if (e.target.customHandle === 0) { - const tooltipContent = ` -
-
Zum Beenden auf den letzten angelegten Punkt klicken.
-
Zum Messen einer Fläche auf den ersten angelegten Punkt klicken und die Fläche so schließen.
-
- `; - L.drawLocal.draw.handlers.polyline.tooltip.end = tooltipContent; - - this.options.cbUpdateAreaOfDrawingMeasurement(null); - } - }); - - const latLng = layer.getLatLng(); - latlngs.push(latLng); - if (index === 1) { - L.drawLocal.draw.handlers.polyline.tooltip.end = ` -
Den Endpunkt erneut anklicken, -
um die Streckenmessung zu beenden.
`; - } - if (index > 2) { - L.drawLocal.draw.handlers.polyline.tooltip.end = ` -
Den Endpunkt erneut anklicken, um die Streckenmessung zu beenden.
-
Zum Messen einer Fläche erneut auf den Startpunkt klicken.
`; - } - }); - - const formatPerimeter = this.calculateDistance(latlngs); - const distance = this.formatDistance(formatPerimeter); - - if (!this.options.ifDrawing) { - const shapesObj = { - coordinates: [latlngs], - distance, - shapeId: 5555, - number: this.options.measurementOrder, - shapeType: "line", - }; - - this.options.ifDrawing = true; - this.options.cbSetDrawingStatus(true); - // this.options.cbSaveShape(shapesObj); - this.options.cbSetDrawingShape(shapesObj); - } else { - const shapesObj = { - coordinates: [latlngs], - distance, - shapeId: 5555, - shapeType: "line", - number: this.options.measurementOrder, - }; - this.options.cbSetDrawingShape(shapesObj); - } - }); - - map.on("draw:canceled", () => { - this.options.checkonedrawpoligon = true; - this.options.cbSetDrawingStatus(false); - - this._measureHandler.disable(); - - this._toggleMeasure( - "img_plg_lines", - "icon_lineActive", - "icon_lineInactive" - ); - - this.options.cbDeleteVisibleShapeById(5555); - - this.options.cbChangeActiveCanceldShapeId(); - }); - - map.on("moveend", () => { - const allPolyLines = this.getVisiblePolylines(map); - this.getVisiblePolylinesIds(allPolyLines); - this.options.cbMapMovingEndHandler(true); - this.options.cbSetUpdateStatusHandler(false); - }); - - map.on("mousemove", (event) => { - const target = event.originalEvent.target; - const isDesktop = this.options.device === "Desktop" ? true : false; - const mode = this.options.measurementMode; - // this._propagateEventToUnderlyingLayers(map, event, "mouseover"); - - if (isDesktop) { - if (!this.options.customTooltip && mode === "measurement") { - const popupPane = map._panes.popupPane; - - this.options.customTooltip = L.DomUtil.create( - "div", - "leaflet-draw-custom-tooltip", - popupPane - ); - - this.options.customTooltip.innerHTML = `
Klicken, um den Startpunkt der Messung zu setzen.
`; - this.options.customTooltip.style.visibility = "inherit"; - - const pos = this._map.latLngToLayerPoint(event.latlng); - L.DomUtil.setPosition(this.options.customTooltip, pos); - } - - if (this.options.customTooltip && mode === "measurement") { - const pos = this._map.latLngToLayerPoint(event.latlng); - // const offsetX = 20; - const offsetX = 0; - // L.DomUtil.setPosition(this.options.customTooltip, pos); - L.DomUtil.setPosition(this.options.customTooltip, { - x: pos.x + offsetX, - y: pos.y, - }); - if (target.classList.contains("leaflet-div-icon")) { - this.options.customTooltip.style.visibility = "hidden"; - } - if ( - (target.classList.contains("leaflet-container") || - target.classList.contains("leaflet-gl-layer")) && - !this.options.ifDrawing - ) { - this.options.customTooltip.style.visibility = "visible"; - } - } - } - }); - - map.on("mouseout", (event) => { - if (this.options.customTooltip) { - this.options.customTooltip.style.visibility = "hidden"; - } - }); - - return iconsWrapper; - }, - - // _propagateEventToUnderlyingLayers: function (map, event, eventType) { - // // Get the lat/lng of the vertex - // // const latlng = event.target.getLatLng(); - // const latlng = - // event.target && event.target.getLatLng - // ? event.target.getLatLng() // For vertex marker events - // : event.latlng; // For map mousemove events - - // // Convert to container point - // const point = map.latLngToContainerPoint(latlng); - - // // Find all layers at this point (excluding the measurement vertex itself) - // const layers = []; - // map.eachLayer((layer) => { - // // Skip the measurement layers and the current target - // if (layer === event.target || layer === this._measureLayers) { - // return; - // } - - // // Check if this layer is a GeoJSON or similar feature layer with mouseover handlers - // if ( - // layer.feature && - // layer._events && - // (layer._events.mouseover || layer._events.mouseout) - // ) { - // let isInside = false; - - // // For polygon/polyline layers, check if point is inside - // if (layer.getBounds) { - // const bounds = layer.getBounds(); - // if (bounds.contains(latlng)) { - // // For polygons, do a more precise check using Leaflet's internal method - // if ( - // layer instanceof L.Polygon || - // (layer._latlngs && Array.isArray(layer._latlngs)) - // ) { - // // Use a simple point-in-polygon check - // // For now, just use bounds check as a proxy - // isInside = true; - // } else { - // isInside = true; - // } - // } - // } else if (layer.getLatLng) { - // // For point markers - // isInside = layer.getLatLng().equals(latlng); - // } - - // if (isInside) { - // layers.push(layer); - // } - // } - // }); - - // // Fire the event on the underlying layers - // layers.forEach((layer) => { - // if (eventType === "mouseover" && layer._events.mouseover) { - // layer.fire("mouseover", { - // latlng: latlng, - // layerPoint: point, - // containerPoint: point, - // originalEvent: event.originalEvent, - // target: layer, - // }); - // } else if (eventType === "mouseout" && layer._events.mouseout) { - // layer.fire("mouseout", { - // latlng: latlng, - // layerPoint: point, - // containerPoint: point, - // originalEvent: event.originalEvent, - // target: layer, - // }); - // } - // }); - // }, - - _UpdateAreaPerimetro: function (layer) { - const latlngs = layer.getLatLngs()[0]; - - const options = { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }; - }, - - _UpdateDistance: function (layer) { - let totalDistance = 0; - const isLine = layer.toGeoJSON().geometry.type === "LineString"; - const latlngs = isLine ? layer.getLatLngs() : layer.getLatLngs()[0]; - - if (!isLine) { - latlngs.push(latlngs[0]); - } - - for (let i = 0; i < latlngs.length - 1; i++) { - const point1 = latlngs[i]; - const point2 = latlngs[i + 1]; - - const distance = point1.distanceTo(point2); - - totalDistance += distance; - } - - if (!isLine) { - latlngs.pop(latlngs[latlngs.length - 1]); - } - - const formatPerimeter = (perimeter) => { - if (perimeter >= 1000) { - return `${(perimeter / 1000).toFixed(2)} km`; - } else { - return `${perimeter.toFixed(2)} m`; - } - }; - - return formatPerimeter(totalDistance); - }, - - _UpdateDistanceByLatLngs: function (latlngs) { - let totalDistance = 0; - - for (let i = 0; i < latlngs.length - 1; i++) { - const point1 = L.latLng(latlngs[i][0], latlngs[i][1]); - const point2 = L.latLng(latlngs[i + 1][0], latlngs[i + 1][1]); - - const distance = point1.distanceTo(point2); - totalDistance += distance; - } - - const formatPerimeter = (perimeter) => { - if (perimeter >= 1000) { - return `${(perimeter / 1000).toFixed(2)} km`; - } else { - return `${perimeter.toFixed(2)} m`; - } - }; - - return formatPerimeter(totalDistance); - }, - - calculateDistance: function (latlngs) { - let totalDistance = 0; - - for (let i = 0; i < latlngs.length - 1; i++) { - const point1 = latlngs[i]; - const point2 = latlngs[i + 1]; - - const distance = point1.distanceTo(point2); - - totalDistance += distance; - } - - return totalDistance; - }, - - calculateArea: function (latlngs) { - const toRadians = (degree) => (degree * Math.PI) / 180; - - if (latlngs.length < 3) return 0; - - const earthRadius = 6378137; - - let total = 0; - for (let i = 0, l = latlngs.length; i < l; i++) { - const [lat1, lon1] = latlngs[i]; - const [lat2, lon2] = latlngs[(i + 1) % l]; - - total += - toRadians(lon2 - lon1) * - (2 + Math.sin(toRadians(lat1)) + Math.sin(toRadians(lat2))); - } - - total = Math.abs((total * earthRadius * earthRadius) / 2); - - const formatArea = (area) => { - if (area >= 1000000) { - return `${(area / 1000000).toFixed(2)} km²`; - } else { - return `${area.toFixed(2)} m²`; - } - }; - - return formatArea(total); - }, - - formatDistance: function (perimeter) { - const formatPerimeter = (perimeter) => { - if (perimeter >= 1000) { - return `${(perimeter / 1000).toFixed(2)} km`; - } else { - return `${perimeter.toFixed(2)} m`; - } - }; - - return formatPerimeter(perimeter); - }, - - _toggleMeasure: function (btnId = "", activeIcon = "", inactiveIcon = "") { - if (this.options.checkonedrawpoligon) { - this.options.checkonedrawpoligon = false; - } else { - this._measureHandler.enable(); - } - }, - - _clearMeasurements: function () { - this._measureLayers.clearLayers(); - }, - - changeColorByActivePolyline: function (map, customID) { - this._measureLayers.eachLayer(function (layer) { - const polyline = layer; - if (layer instanceof L.Polyline) { - if (layer.customID === customID) { - polyline._path.classList.remove("custom-polyline"); - polyline.enableEdit(); - } else { - polyline._path.classList.add("custom-polyline"); - polyline.disableEdit(); - } - } - }); - }, - - changeColorByLastShape: function (map) { - let lastPolyline = null; - - this._measureLayers.eachLayer(function (layer) { - if (layer instanceof L.Polyline) { - lastPolyline = layer; - layer._path.classList.add("custom-polyline"); - } - }); - - if (lastPolyline) { - lastPolyline._path.classList.remove("custom-polyline"); - } - }, - - getVisiblePolylines: function (map) { - const visiblePolylines = []; - const mapBounds = map.getBounds(); - - this._measureLayers.eachLayer(function (layer) { - if (layer instanceof L.Polyline) { - if (mapBounds.intersects(layer.getBounds())) { - visiblePolylines.push(layer); - } - } - }); - - return visiblePolylines; - }, - - getVisiblePolylinesIds: function (polylinesArr) { - const idsPolylinesArr = []; - this.options.visiblePolylines = []; - polylinesArr.forEach((m) => { - idsPolylinesArr.push(m.customID); - this.options.visiblePolylines.push(m.customID); - }); - - this.options.cbVisiblePolylinesChange(idsPolylinesArr); - }, - - getAllPolylines: function (map) { - const polylines = []; - - this._measureLayers.eachLayer(function (layer) { - if (layer instanceof L.Polyline) { - polylines.push(layer); - } - }); - - return polylines; - }, - - removePolylineById: function (map, customID) { - const self = this; - this._measureLayers.eachLayer(function (layer) { - if (layer instanceof L.Polyline && layer.customID === customID) { - self._measureLayers.removeLayer(layer); - } - }); - }, - - showActiveShape: function (map, coordinates) { - this.options.moveToShape = true; - const bounds = L.latLngBounds(coordinates); - map.fitBounds(bounds); - }, - - fitMapToPolylines: function (map, polylines) { - if (polylines.length === 0) { - return; - } - - const allBounds = L.latLngBounds(); - - polylines.forEach((polyline) => { - const polylineBounds = polyline.getBounds(); - allBounds.extend(polylineBounds); - }); - - map.fitBounds(allBounds); - }, - - replaceLineToPolygon: function (map, layer) { - const latlngsJSON = layer.toGeoJSON(); - const prepeareCoordinates = latlngsJSON.geometry.coordinates.map((l) => { - return l.reverse(); - }); - - map.removeLayer(layer); - - prepeareCoordinates.push(prepeareCoordinates[0]); - - const options = { - color: "#267bdcd4", - fillColor: "#267bdcd4", - opacity: 1, - weigt: 3, - }; - const distance = this._UpdateDistanceByLatLngs(prepeareCoordinates); - const square = this.calculateArea(prepeareCoordinates); - const preparePolygon = { - coordinates: prepeareCoordinates, - options, - shapeId: layer.customID, - distance: distance, - number: this.options.measurementOrder, - area: square, - shapeType: this.options.shapeMode, - }; - - const polygon = L.polygon(prepeareCoordinates, options); - - polygon.customID = layer.customID; - polygon.customShape = "polygon"; - - polygon.addTo(this._measureLayers).showMeasurements().enableEdit(); - // polygon.on("dblclick", this._onPolygonClick.bind(this, map)); - polygon.on("click", () => { - this.options.cbSetActiveShape(polygon.customID); - this.options.cbSetUpdateStatusHandler(false); - this.options.checkonedrawpoligon = false; - }); - polygon.on( - "editable:drag editable:dragstart editable:dragend editable:vertex:drag editable:vertex:deleted", - this._onPolylineDrag.bind(this) - ); - - polygon.on("editable:vertex:dragend", () => { - this.options.cbSetUpdateStatusHandler(false); - // Reset status to WAITING when vertex editing ends - if (this.options.cbSetMapStatus) { - this.options.cbSetMapStatus("WAITING"); - } - }); - - // Reset status to WAITING when drag ends - polygon.on("editable:dragend", () => { - if (this.options.cbSetMapStatus) { - this.options.cbSetMapStatus("WAITING"); - } - }); - - this.options.polygonMode = false; - - this.options.checkonedrawpoligon = true; - - this._toggleMeasure( - "img_plg_lines", - "icon_lineActive", - "icon_lineInactive" - ); - - // this.options.checkonedrawpoligon = false; - - // this._measureHandler.disable(); - - return preparePolygon; - }, - getVisibleShapeIdsArr: function (map) { - const allPolyLines = this.getVisiblePolylines(map); - this.getVisiblePolylinesIds(allPolyLines); - }, - - findLastCreatedLayer: function (layerGroup) { - let lastLayer = null; - let highestId = -1; - - layerGroup.eachLayer((layer) => { - if (layer._leaflet_id > highestId) { - highestId = layer._leaflet_id; - lastLayer = layer; - } - }); - - return lastLayer; - }, - - loadMeasurements: function (map) { - if (this.options.shapes.length !== 0) { - this.options.shapes.forEach((shape) => { - const { coordinates, options, shapeId, shapeType } = shape; - const shapeName = shapeType === "line" ? "polyline" : "polygon"; - - const savedShape = L[shapeName](coordinates, { - showLength: true, - className: "custom-polyline", - shapeOptions: { - weight: 4, - color: "#267bdcd4", - opacity: 1, - }, - }); - savedShape.customID = shapeId; - savedShape.addTo(this._measureLayers).showMeasurements().enableEdit(); - savedShape.on("click", () => { - this.options.checkonedrawpoligon = true; - this.options.cbSetActiveShape(savedShape.customID); - this.options.cbSetUpdateStatusHandler(false); - this.options.clickAfterShapeSelection = true; - }); - savedShape.on("mouseout", (e) => { - // this.options.checkonedrawpoligon = false; - }); - savedShape.on("mouseover", (e) => { - if (this.options.customTooltip) { - this.options.customTooltip.style.visibility = "hidden"; - } - }); - savedShape.on( - "editable:drag editable:dragstart editable:dragend editable:vertex:drag editable:vertex:deleted", - this._onPolylineDrag.bind(this) - ); - - savedShape.on("editable:vertex:dragend", () => { - this.options.cbSetUpdateStatusHandler(false); - // Reset status to WAITING when vertex editing ends - if (this.options.cbSetMapStatus) { - this.options.cbSetMapStatus("WAITING"); - } - }); - - // Reset status to WAITING when drag ends - savedShape.on("editable:dragend", () => { - if (this.options.cbSetMapStatus) { - this.options.cbSetMapStatus("WAITING"); - } - }); - }); - } - }, - - _toggleMeasurementBtn: function () { - if (this.options.changeModeButtonActive) { - document.getElementById("img_plg_lines").src = - this.options.icon_lineInactive; - this.options.changeModeButtonActive = false; - } else { - document.getElementById("img_plg_lines").src = - this.options.icon_lineActive; - this.options.changeModeButtonActive = true; - } - }, - - toggleMeasurementMode: function (ifChangeMode = true, map) { - const mode = this.options.measurementMode; - if (mode === "measurement") { - this._clearMeasurements(); - this.loadMeasurements(); - // const drawBtn = document.getElementById("draw_shape"); - // drawBtn.classList.remove("hide-draw-btn"); - - document.getElementById("img_plg_lines").src = - this.options.icon_lineActive; - - // if (this.options.isFirstLoading) { - // this.options.isFirstLoading = false; - // } - - const customTooltip = document.querySelector("#routedMap"); - customTooltip.style.cursor = "crosshair"; - } else { - this._clearMeasurements(); - - const customTooltip = document.querySelector("#routedMap"); - customTooltip.style.cursor = "pointer"; - if (this.options.currenLine) { - this.options.currenLine.disable(); - } - customTooltip.style.cursor = "pointer"; - // const drawBtn = document.getElementById("draw_shape"); - // drawBtn.classList.add("hide-draw-btn"); - this.options.checkonedrawpoligon = false; - document.getElementById("img_plg_lines").src = - this.options.icon_lineInactive; - } - - if (ifChangeMode) { - this.options.cbToggleMeasurementMode(); - } - }, - - changeMeasurementMode: function (mode, map) { - this.options.measurementMode = mode; - this.toggleMeasurementMode(false, map); - }, - changeMeasurementsArr: function (arr) { - this.options.shapes = arr; - }, - cancelDrawing: function () { - if (this.options.ifDrawing) { - this._measureHandler.disable(); - this.options.ifDrawing = false; - - this._measureLayers.clearLayers(); - - this.options.cbSetDrawingStatus(false); - this.options.cbDeleteVisibleShapeById(5555); - // this.options.checkonedrawpoligon = true; - } - }, -}); - -// Adds the method to create a new instance of the control -L.control.measurePolygon = function (options) { - return new L.Control.MeasurePolygon(options); -}; diff --git a/libraries/commons/measurements/src/lib/utils/measure-path.ts b/libraries/commons/measurements/src/lib/utils/measure-path.ts new file mode 100644 index 0000000000..104685e4e9 --- /dev/null +++ b/libraries/commons/measurements/src/lib/utils/measure-path.ts @@ -0,0 +1,1331 @@ +// Create a class for the plugin +import { + Control, + DomUtil, + DomEvent, + Browser, + point, + latLngBounds, + layerGroup, + Polyline, + Polygon, + LeafletMap, + LeafletMouseEvent, + LeafletEvent, + polygon, + polyline, +} from "@carma/leaflet"; +import * as L from "leaflet"; +import "leaflet-draw"; +import "@carma/types"; +import { + calculateArea, + calculateDistance, + formatDistance, + updateDistance, + updateDistanceByLatLngs, +} from "./measurement-geometry"; +import { createVertexClickHandler } from "./vertex-click-handler"; +import { + MeasurementPolyline, + MeasurementPolygon, + MeasurementLayer, + MeasurementLeafletEvent, + MeasurePolygonControl, +} from "../types/leaflet-extensions"; + +export const MeasurePolygon = Control.extend({ + options: { + position: "topright", + icon_lineActive: "https://img.icons8.com/?size=48&id=98497&format=png", + icon_lineInactive: "https://img.icons8.com/?size=48&id=98463&format=png", + icon_polygonActive: "https://img.icons8.com/?size=48&id=98497&format=png", + icon_polygonInactive: "https://img.icons8.com/?size=48&id=98463&format=png", + html_template: `

Results

+

Area:
_p_area

+

Perimeter :
_p_perimeter

`, + height: 130, + width: 150, + mode_btn: "", + color_polygon: "black", + fillColor_polygon: "yellow", + weight_polygon: "2", + isDrawing: false, + changeModeButtonActive: false, + msj_disable_tool: "Möchten Sie das Tool deaktivieren?", + shapes: [], + activeShape: null, + shapeMode: "line", + measurementOrder: 0, + moveToShape: false, + cb: function (...args: any[]) { + console.debug("Callback function executed!", args); + }, + cbSaveShape: function (...args: any[]) { + console.debug("Callback function executed!", args); + }, + cdDeleteShape: function (...args: any[]) { + console.debug("Callback function executed!", args); + }, + cbUpdateShape: function (...args: any[]) { + console.debug("Callback function executed!", args); + }, + cbVisiblePolylinesChange: function (...args: any[]) { + console.debug("Callback function executed!", args); + }, + cbSetDrawingStatus: function (...args: any[]) { + console.debug("Callback function executed!", args); + }, + cbSetDrawingShape: function (...args: any[]) { + console.debug("Callback function executed!", args); + }, + cbSetActiveShape: function (...args: any[]) { + console.debug("Callback function executed!", args); + }, + cbSetUpdateStatusHandler: function (...args: any[]) { + console.debug("Callback function executed!", args); + }, + cbMapMovingEndHandler: function (...args: any[]) { + console.debug("Callback function executed!", args); + }, + cbSaveLastActiveShapeIdBeforeDrawingHandler: function (...args: any[]) { + console.debug("Callback function executed!", args); + }, + cbChangeActiveCanceldShapeId: function (...args: any[]) { + console.debug("Callback function executed!", args); + }, + cbToggleMeasurementMode: function (...args: any[]) { + console.debug("Callback function executed!", args); + }, + cbGetMeasurementModeHandler: function (...args: any[]) { + console.debug("Callback function executed!", args); + }, + cbDeleteVisibleShapeById: function (...args: any[]) { + console.debug("Callback function executed!", args); + }, + cbUpdateAreaOfDrawingMeasurement: function (...args: any[]) { + console.debug("Callback function executed!", args); + }, + cbSetCurrentDrawHandler: function (...args: any[]) { + console.debug("Callback function executed!", args); + }, + cbSetMapStatus: function (...args: any[]) { + console.debug("Callback function executed!", args); + }, + visiblePolylines: [], + localShapeStore: [], + isDrawingEmpty: true, + nativeMove: false, + currenLine: null, + polygonMode: false, + measurementMode: false as string | boolean, + startDrawing: false, + customTooltip: null, + device: null, + clickAfterShapeSelection: false, + snappingLatlng: null, + snappingEnabled: true, + }, + + drawingLines: function ( + this: MeasurePolygonControl, + map: LeafletMap, + event: LeafletMouseEvent + ) { + if (this.options.customTooltip) { + this.options.customTooltip.style.visibility = "hidden"; + } + this.options.shapeMode = "line"; + this._measureHandler = new L.Draw.Polyline(map as any, { + showLength: true, + shapeOptions: { + weight: 3, + color: "#267bdcd4", + opacity: 1, + }, + }); + + const self = this; + + // Override _updateGuide to snap the preview line + const originalUpdateGuide = (this._measureHandler as any)._updateGuide; + if (originalUpdateGuide) { + (this._measureHandler as any)._updateGuide = function (point: any) { + if (self.options.snappingLatlng) { + // If we have a snapping point, use it for the guide line + // We need to convert the latlng to a layer point, as that's what _updateGuide expects + const snappedPoint = map.latLngToLayerPoint( + self.options.snappingLatlng + ); + return originalUpdateGuide.call(this, snappedPoint); + } + return originalUpdateGuide.call(this, point); + }; + } + + // DIAGNOSTIC: Hook into Leaflet.Draw's internal completion to see what triggers it + const originalFinishShape = (this._measureHandler as any)._finishShape; + if (originalFinishShape) { + (this._measureHandler as any)._finishShape = function (e) { + const eventInfo = e + ? { + type: e.type, + originalType: e.originalEvent?.type, + pointerType: e.originalEvent?.pointerType, + target: e.target?.className, + timeStamp: e.timeStamp, + } + : "no event"; + + // CRITICAL: Block Leaflet.Draw's native touch finishing + // We handle this manually in vertex-click-handler.ts to ensure consistent behavior + if ( + e && + (e.type?.startsWith("touch") || + e.originalEvent?.type?.startsWith("touch") || + e.originalEvent?.pointerType === "touch") + ) { + console.warn( + "[measure-path] Blocking native _finishShape on touch event", + eventInfo + ); + return; + } + + return originalFinishShape.apply(this, arguments); + }; + } + + const originalCompleteShape = (this._measureHandler as any).completeShape; + if (originalCompleteShape) { + (this._measureHandler as any).completeShape = function () { + console.warn("[measure-path] 🔴 completeShape() called!", { + stack: new Error().stack, + vertexCount: this._markers?.length || 0, + timestamp: Date.now(), + }); + return originalCompleteShape.apply(this, arguments); + }; + } + + const originalAddVertex = (this._measureHandler as any).addVertex; + if (originalAddVertex) { + (this._measureHandler as any).addVertex = function (latlng) { + console.log("[measure-path] addVertex() called", { + latlng, + currentVertexCount: this._markers?.length || 0, + timestamp: Date.now(), + }); + (self as any)._lastVertexAdded = Date.now(); + return originalAddVertex.apply(this, arguments); + }; + } + + this.options.currenLine = this._measureHandler; + this.options.cbSetCurrentDrawHandler(this._measureHandler); + + const tooltipContent = ` +
+
Zum Beenden auf den letzten angelegten Punkt klicken.
+
Zum Messen einer Fläche auf den ersten angelegten Punkt klicken und die Fläche so schließen.
+
+`; + + L.drawLocal.draw.handlers.polyline.tooltip.start = + "Klicken, um den Startpunkt der Messung zu setzen.
Snapping aktiv (Alt zum Deaktivieren)"; + L.drawLocal.draw.handlers.polyline.tooltip.cont = + "Klicken (ggf. mehrmals), um die nächsten Punkte des Linienzuges zu setzen.
Snapping aktiv (Alt zum Deaktivieren)"; + L.drawLocal.draw.handlers.polyline.tooltip.end = tooltipContent; + + this._measureHandler.enable(); + + // DIAGNOSTIC: Intercept Leaflet.Draw's built-in dblclick handler + const originalDblClick = (this._measureHandler as any)._onMouseDblClick; + if (originalDblClick) { + console.warn( + "[measure-path] Found Leaflet.Draw dblclick handler - intercepting" + ); + (this._measureHandler as any)._onMouseDblClick = function (e) { + console.error("[measure-path] LEAFLET.DRAW DBLCLICK HANDLER FIRED", { + eventType: e?.type, + originalEvent: e?.originalEvent?.type, + pointerType: e?.originalEvent?.pointerType, + timeStamp: e?.timeStamp, + vertexCount: this._markers?.length || 0, + timestamp: Date.now(), + stack: new Error().stack, + }); + return originalDblClick.apply(this, arguments); + }; + } else { + console.log( + "[measure-path] No dblclick handler found on _measureHandler" + ); + } + + // DIAGNOSTIC: Log all active event listeners on the map + console.log("[measure-path] Active map event listeners:", { + hasClick: map.listens("click"), + hasDblClick: map.listens("dblclick"), + clickCount: map.listens("click", true), + dblclickCount: map.listens("dblclick", true), + timestamp: Date.now(), + }); + + const latlng = + this.options.snappingEnabled && this.options.snappingLatlng + ? this.options.snappingLatlng + : event.latlng; + + // CRITICAL: Validate coordinates before adding vertex + // During map transitions, coordinates can become NaN + if (!latlng || isNaN(latlng.lat) || isNaN(latlng.lng)) { + console.warn( + "[measure-path] BLOCKING addVertex with invalid coordinates:", + latlng + ); + return; // Don't add invalid vertex + } + + this.options.currenLine.addVertex(latlng); + + const tooltip = document.querySelector( + ".leaflet-draw-tooltip" + ) as HTMLElement; + + const pos = map.latLngToLayerPoint(latlng); + DomUtil.setPosition(tooltip, pos); + + this._toggleMeasure( + "img_plg_lines", + "icon_lineActive", + "icon_lineInactive" + ); + }, + + startDrawing: function (this: MeasurePolygonControl) { + this.options.startDrawing = true; + }, + + saveShapeHandler: function ( + this: MeasurePolygonControl, + layer: MeasurementPolyline, + distance: string | null = null, + area: string | null = null, + map: LeafletMap + ) { + const latlngs = layer.getLatLngs(); + const latlngsJSON = layer.toGeoJSON(); + const shapeId = layer._leaflet_id; + layer.customID = shapeId; + console.log("[measure-path] layer click handler added", shapeId); + layer.on("click", (e) => { + // If we are snapped and in measurement mode (and not currently drawing), + // we want to start a new measurement snapped to this point, NOT select the shape. + // Leaflet event propagation will handle the map click to start drawing. + if ( + this.options.snappingLatlng && + this.options.measurementMode === "measurement" && + !this.options.isDrawing + ) { + console.debug( + "[measure-path] Click on shape ignored (snapping active) - letting map click handle it" + ); + return; + } + + this.options.cbSetActiveShape(layer.customID); + this.options.cbSetUpdateStatusHandler(false); + }); + + if (this.options.shapeMode === "polygon") { + const polygon = this.replaceLineToPolygon(map, layer); + this.options.cbSaveShape(polygon); + this.getVisibleShapeIdsArr(map); + } else { + const prepareCoordinates = + this.options.shapeMode === "line" + ? latlngsJSON.geometry.coordinates + : latlngsJSON.geometry.coordinates[0]; + const reversedCoordinates = prepareCoordinates.map((item) => { + return item.reverse(); + }); + + const preparePolygon = { + coordinates: reversedCoordinates, + options: { + color: "#267bdcd4", + fillColor: null, + opacity: 0.5, + weight: 4, + }, + shapeId, + distance, + number: this.options.measurementOrder, + area, + shapeType: this.options.shapeMode, + }; + this.options.cbSaveShape(preparePolygon); + this.getVisibleShapeIdsArr(map); + } + }, + + _onPolylineDrag: function (this: MeasurePolygonControl, event: LeafletEvent) { + if (this.options.customTooltip) { + this.options.customTooltip.style.visibility = "hidden"; + } + this.options.cbSetUpdateStatusHandler(true); + + // Set status based on drag type + if (this.options.cbSetMapStatus) { + if ( + event.type === "editable:drag" || + event.type === "editable:dragstart" + ) { + // Dragging whole shape + this.options.cbSetMapStatus("MOVING"); + } else if (event.type === "editable:vertex:drag") { + // Dragging vertices or deleting vertex + this.options.cbSetMapStatus("EDITING"); + } + } + + const polyline = event.target; + const layer = event.layer; + this.options.cbSetActiveShape(layer.customID); + const latlngsJSON = layer.toGeoJSON(); + const isLine = layer.toGeoJSON().geometry.type === "LineString"; + const prepareCoordinates = isLine + ? latlngsJSON.geometry.coordinates + : latlngsJSON.geometry.coordinates[0]; + const reversedCoordinates = prepareCoordinates.map((item) => { + return item.reverse(); + }); + + const square = !isLine ? calculateArea(reversedCoordinates) : null; + polyline.updateMeasurements(); + const newDistance = updateDistance(layer); + const shapeId = polyline?.customID + ? polyline?.customID + : polyline._leaflet_id; + + this.options.cbUpdateShape( + shapeId, + reversedCoordinates, + newDistance, + square + ); + this.options.isDrawing = false; + }, + + _onPolygonClick: function ( + this: MeasurePolygonControl, + map: LeafletMap, + event: LeafletMouseEvent + ) { + const clickedPolygon = event.target; + const latlngs = clickedPolygon.getLatLngs(); + + this._measureLayers.removeLayer(clickedPolygon._leaflet_id); + const shapeId = clickedPolygon?.customID + ? clickedPolygon?.customID + : clickedPolygon._leaflet_id; + + this.options.cdDeleteShape(shapeId, this.options.localShapeStore); + + const allPolyLines = this.getVisiblePolylines(map); + this.getVisiblePolylinesIds(allPolyLines); + }, + + onAdd: function (this: MeasurePolygonControl, map: LeafletMap) { + const linesContainer = DomUtil.create( + "div", + "leaflet-bar leaflet-control dont-show m-container" + ); + const lineIcon = DomUtil.create("a", "", linesContainer); + lineIcon.innerHTML = ` +
+ Ruler Icon +
+ `; + lineIcon.href = "#"; + lineIcon.title = "Messmodus"; + + const iconsWrapper = DomUtil.create("div", "m-icons-wrapper"); + iconsWrapper.appendChild(linesContainer); + + console.log("[measure-path] icon click handler added"); + + DomEvent.on( + lineIcon, + "click", + (event) => { + event.preventDefault(); // Prevent default action (e.g., redirection) + this.toggleMeasurementMode(); + }, + this + ); + + this._map = map; + + this._measureLayers = layerGroup().addTo(map); + + console.log( + "[measure-path] map click handler added", + (this._map as unknown as { _leaflet_id: number })._leaflet_id + ); + + // Store handler references for proper cleanup + this._mapClickHandler = (event) => { + const mode = this.options.measurementMode; + + console.log("[measure-path] Map clicked", { + isDrawing: this.options.isDrawing, + mode, + clickAfterShapeSelection: this.options.clickAfterShapeSelection, + isFinishingShape: (this as any)._isFinishingShape, + eventType: event.originalEvent?.type, + targetClassName: (event.originalEvent?.target as HTMLElement) + ?.className, + latlng: event.latlng, + timestamp: Date.now(), + }); + + // Don't start new measurement if we're finishing one + if ((this as any)._isFinishingShape) { + console.log( + "[measure-path] Ignoring map click - currently finishing shape" + ); + // Clear flag immediately so next click works + (this as any)._isFinishingShape = false; + return; + } + + if (!this.options.isDrawing && mode === "measurement") { + this.drawingLines(map, event); + this.options.isDrawing = true; + } else { + // this.options.isDrawing = false; + } + + if (this.options.clickAfterShapeSelection) { + this.options.isDrawing = false; + this.options.clickAfterShapeSelection = false; + } + }; + + this._drawCreatedHandler = (event) => { + console.warn("[measure-path] ========== draw:created FIRED ==========", { + stack: new Error().stack, + layerType: event.layerType, + vertexCount: event.layer.getLatLngs?.()?.length || 0, + timestamp: Date.now(), + }); + + // Reset finishing flag since the shape is successfully created + (this as any)._isFinishingShape = false; + + this.options.isDrawing = false; + this.options.isDrawingEmpty = true; + + this.options.cbSetDrawingStatus(false); + this.options.cbSetDrawingShape(null); + + // Re-enable edit on existing shapes + this._measureLayers.eachLayer((layer: any) => { + if (layer.enableEdit) { + layer.enableEdit(); + } + }); + + const layer = event.layer; + // layer.on("dblclick", this._onPolygonClick.bind(this, map)); + + layer.on("editable:vertex:dragend", () => { + this.options.cbSetUpdateStatusHandler(false); + // Reset status to WAITING when vertex editing ends + if (this.options.cbSetMapStatus) { + this.options.cbSetMapStatus("WAITING"); + } + }); + + // Reset status to WAITING when drag ends + layer.on("editable:dragend", () => { + if (this.options.cbSetMapStatus) { + this.options.cbSetMapStatus("WAITING"); + } + }); + + // Add style to polygon + layer.addTo(this._measureLayers).showMeasurements().enableEdit(); + layer.options.draggable = false; + + const distance = updateDistance(layer); + + this.saveShapeHandler(layer, distance, null, map); + + layer.on( + "editable:drag editable:vertex:drag editable:vertex:deleted editable:dragstart editable:dragend", + this._onPolylineDrag.bind(this) + ); + + this.options.isDrawing = false; + + this._measureHandler.disable(); + }; + + this._drawDrawstartHandler = (event) => { + console.warn( + "[measure-path] ========== draw:drawstart FIRED ==========", + { + layerType: event.layerType, + timestamp: Date.now(), + } + ); + + // Disable edit on existing shapes to remove grab cursors and prevent interaction conflicts + this._measureLayers.eachLayer((layer: any) => { + if (layer.disableEdit) { + layer.disableEdit(); + } + }); + + const mouseActive = Browser.touch && matchMedia("(hover:hover)").matches; + if ( + mouseActive || + event.layerType === "circle" || + event.layerType === "rectangle" + ) { + event.target.touchExtend.enable(); + } else { + event.target.touchExtend.disable(); + } + this.options.cbSaveLastActiveShapeIdBeforeDrawingHandler(); + this.options.measurementOrder = this.options.measurementOrder + 1; + this.changeColorByActivePolyline(map, "ddfsc1231"); + }; + + // Create vertex click handler once and attach to map (event delegation) + // This handler listens to ALL map clicks but only processes clicks on vertex markers + if (!this._vertexClickHandler) { + // Initialize flag to track when we're finishing a shape + (this as any)._isFinishingShape = false; + + this._vertexClickHandler = createVertexClickHandler( + () => this._measureHandler, + this.options as any, + () => this._measureHandler?._markers?.length || 0, + map, + () => (this as any)._isFinishingShape, + (value: boolean) => { + (this as any)._isFinishingShape = value; + }, + () => (this as any)._lastVertexAdded || 0 + ); + // Handler is now attached to individual markers in _drawDrawvertexHandler + // to ensure we can stop propagation before it reaches the map + // map.on("click", this._vertexClickHandler); + console.log( + "[measure-path] Created vertex handler (will attach to markers)" + ); + } + + this._drawDrawvertexHandler = (event) => { + const layers = event.layers; + const latlngs = []; + let index = 0; + let firsHovering = false; + + layers.eachLayer((layer) => { + const markerLatLng = layer.getLatLng(); + layer.customHandle = index++; + + // Leaflet.Draw attaches _finishShape click handlers DURING this event + // We need to remove them AFTER the event completes + setTimeout(() => { + const layerEvents = (layer as any)._events; + if (layerEvents) { + console.log("[measure-path] Marker listeners before cleanup:", { + handle: layer.customHandle, + hasClick: !!layerEvents.click, + clickCount: layerEvents.click?.length || 0, + hasTouchend: !!layerEvents.touchend, + touchendCount: layerEvents.touchend?.length || 0, + }); + + // Remove Leaflet.Draw's _finishShape handler + // Source: leaflet.draw-src.js line 856, 1129 + layer.off( + "click", + (this._measureHandler as any)._finishShape, + this._measureHandler + ); + layer.off( + "dblclick", + (this._measureHandler as any)._finishShape, + this._measureHandler + ); + + // Attach our custom handler to the marker + // This handler MUST stop propagation to prevent the map from seeing the click + layer.on("click", this._vertexClickHandler); + + // CRITICAL: Stop native touchstart propagation on the DOM element + // Leaflet.Draw listens to 'touchstart' on the map container to add vertices. + // We must stop the event at the marker icon DOM level to prevent it from bubbling to the map. + // layer.on('touchstart') is too late because it's a Leaflet event, not a native DOM capture. + const icon = layer.getElement(); + if (icon) { + L.DomEvent.on(icon, "touchstart", L.DomEvent.stopPropagation); + L.DomEvent.on(icon, "touchend", L.DomEvent.stopPropagation); + L.DomEvent.on(icon, "touchmove", L.DomEvent.stopPropagation); + console.log( + "[measure-path] Added native touch blockers to marker icon" + ); + } + + // Also stop Leaflet-level touchstart just in case + layer.on("touchstart", (e) => { + if (e.originalEvent) { + L.DomEvent.stopPropagation(e.originalEvent); + } + }); + + // We don't want to remove 'touchstart' listener we just added, so only remove touchend + layer.off("touchend"); + // layer.off("touchstart"); // Don't remove this, we just added our own! + + console.log("[measure-path] Removed Leaflet.Draw marker listeners"); + } + }, 0); + + layer.on("mouseover", (e) => { + const coordinates = (this._measureHandler as L.Control.DrawHandler) + ._poly._latlngs; + const latLngArray = coordinates.map((c) => [c.lat, c.lng]); + latLngArray.push(latLngArray[0]); + const area = calculateArea(latLngArray); + if (e.target.customHandle === 0 && firsHovering) { + this.options.cbUpdateAreaOfDrawingMeasurement(area); + L.drawLocal.draw.handlers.polyline.tooltip.end = `Den Startpunkt anklicken, um die Fläche zu schließen.`; + } + firsHovering = true; + }); + + layer.on("mouseout", (e) => { + if (e.target.customHandle === 0) { + const tooltipContent = ` +
+
Zum Beenden auf den letzten angelegten Punkt klicken.
+
Zum Messen einer Fläche auf den ersten angelegten Punkt klicken und die Fläche so schließen.
+
+ `; + L.drawLocal.draw.handlers.polyline.tooltip.end = tooltipContent; + this.options.cbUpdateAreaOfDrawingMeasurement(null); + } + }); + + const latLng = layer.getLatLng(); + latlngs.push(latLng); + if (index === 1) { + L.drawLocal.draw.handlers.polyline.tooltip.end = ` +
Den Endpunkt erneut anklicken, +
um die Streckenmessung zu beenden.
`; + } + if (index > 2) { + L.drawLocal.draw.handlers.polyline.tooltip.end = ` +
Den Endpunkt erneut anklicken, um die Streckenmessung zu beenden.
+
Zum Messen einer Fläche erneut auf den Startpunkt klicken.
`; + } + }); + + const formatPerimeter = calculateDistance(latlngs); + const distance = formatDistance(formatPerimeter); + + if (this.options.isDrawingEmpty) { + const shapesObj = { + coordinates: [latlngs], + distance, + shapeId: 5555, + number: this.options.measurementOrder, + shapeType: "line" as const, + options: { + color: "#267bdcd4", + fillColor: null, + opacity: 0.5, + weight: 3, + }, + }; + + this.options.isDrawingEmpty = false; + this.options.cbSetDrawingStatus(true); + // this.options.cbSaveShape(shapesObj); + this.options.cbSetDrawingShape(shapesObj); + } else { + const shapesObj = { + coordinates: [latlngs], + distance, + shapeId: 5555, + shapeType: "line" as const, + number: this.options.measurementOrder, + options: { + color: "#267bdcd4", + fillColor: null, + opacity: 0.5, + weight: 3, + }, + }; + this.options.cbSetDrawingShape(shapesObj); + } + }; + + this._drawCanceledHandler = () => { + this.options.isDrawing = true; + this.options.cbSetDrawingStatus(false); + + this._measureHandler.disable(); + + // Re-enable edit on existing shapes + this._measureLayers.eachLayer((layer: any) => { + if (layer.enableEdit) { + layer.enableEdit(); + } + }); + + this._toggleMeasure( + "img_plg_lines", + "icon_lineActive", + "icon_lineInactive" + ); + + this.options.cbDeleteVisibleShapeById(5555); + this.options.cbChangeActiveCanceldShapeId(); + }; + + this._moveendHandler = (event) => { + const allPolyLines = this.getVisiblePolylines(map); + this.getVisiblePolylinesIds(allPolyLines); + this.options.cbMapMovingEndHandler(true); + this.options.cbSetUpdateStatusHandler(false); + }; + + this._mousemoveHandler = (event) => { + const target = event.originalEvent.target; + const isDesktop = this.options.device === "Desktop" ? true : false; + const mode = this.options.measurementMode; + // this._propagateEventToUnderlyingLayers(map, event, "mouseover"); + + if (isDesktop) { + if (!this.options.customTooltip && mode === "measurement") { + const popupPane = map._panes.popupPane; + + this.options.customTooltip = DomUtil.create( + "div", + "leaflet-draw-custom-tooltip", + popupPane + ); + + this.options.customTooltip.innerHTML = `
Klicken, um den Startpunkt der Messung zu setzen.
`; + this.options.customTooltip.style.visibility = "inherit"; + + const pos = this._map.latLngToLayerPoint(event.latlng); + DomUtil.setPosition(this.options.customTooltip, pos); + } + + if (this.options.customTooltip && mode === "measurement") { + const latlng = + this.options.snappingEnabled && this.options.snappingLatlng + ? this.options.snappingLatlng + : event.latlng; + + const pos = this._map.latLngToLayerPoint(latlng); + // const offsetX = 20; + const offsetX = 0; + // DomUtil.setPosition(this.options.customTooltip, pos); + DomUtil.setPosition( + this.options.customTooltip, + point(pos.x + offsetX, pos.y) + ); + if ((target as HTMLElement).classList.contains("leaflet-div-icon")) { + this.options.customTooltip.style.visibility = "hidden"; + } + if ( + ((target as HTMLElement).classList.contains("leaflet-container") || + (target as HTMLElement).classList.contains("leaflet-gl-layer")) && + this.options.isDrawingEmpty + ) { + this.options.customTooltip.style.visibility = "visible"; + } + } + } + }; + + this._mouseoutHandler = (event) => { + if (this.options.customTooltip) { + this.options.customTooltip.style.visibility = "hidden"; + } + }; + + map.on("click", this._mapClickHandler); + map.on("draw:created", this._drawCreatedHandler); + map.on("draw:drawstart", this._drawDrawstartHandler); + map.on("draw:drawvertex", this._drawDrawvertexHandler); + map.on("draw:canceled", this._drawCanceledHandler); + map.on("moveend", this._moveendHandler); + map.on("mousemove", this._mousemoveHandler); + map.on("mouseout", this._mouseoutHandler); + + return iconsWrapper; + }, + + onRemove: function (this: MeasurePolygonControl, map: LeafletMap) { + // Clean up all event handlers to prevent memory leaks and duplicate handlers on HMR + console.log("[measure-path] onRemove: Cleaning up event handlers"); + + // Remove only OUR specific event handlers by reference + if (this._mapClickHandler) map.off("click", this._mapClickHandler); + if (this._drawCreatedHandler) + map.off("draw:created", this._drawCreatedHandler); + if (this._drawDrawstartHandler) + map.off("draw:drawstart", this._drawDrawstartHandler); + if (this._drawDrawvertexHandler) + map.off("draw:drawvertex", this._drawDrawvertexHandler); + if (this._drawCanceledHandler) + map.off("draw:canceled", this._drawCanceledHandler); + if (this._moveendHandler) map.off("moveend", this._moveendHandler); + if (this._mousemoveHandler) map.off("mousemove", this._mousemoveHandler); + if (this._mouseoutHandler) map.off("mouseout", this._mouseoutHandler); + + // Vertex click handler is attached to markers, which are removed automatically + // if (this._vertexClickHandler) { + // map.off("click", this._vertexClickHandler); + // } + + // Remove measure layers + if (this._measureLayers) { + this._measureLayers.clearLayers(); + map.removeLayer(this._measureLayers); + } + + // Disable active drawing handler + if (this._measureHandler) { + this._measureHandler.disable(); + } + }, + + _UpdateAreaperimeter: function (this: MeasurePolygonControl, layer: any) { + const latlngs = layer.getLatLngs()[0]; + + const options = { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }; + }, + + _toggleMeasure: function ( + this: MeasurePolygonControl, + btnId = "", + activeIcon = "", + inactiveIcon = "" + ) { + if (this.options.isDrawing) { + this.options.isDrawing = false; + } else { + this._measureHandler.enable(); + } + }, + + _clearMeasurements: function (this: MeasurePolygonControl) { + this._measureLayers.clearLayers(); + }, + + changeColorByActivePolyline: function ( + this: MeasurePolygonControl, + map: LeafletMap, + customID: string + ) { + this._measureLayers.eachLayer(function (layer) { + const polyline = layer as MeasurementPolyline; + if (layer instanceof Polyline) { + if ((layer as MeasurementPolyline).customID === customID) { + (polyline as MeasurementPolyline)._path.classList.remove( + "custom-polyline" + ); + (polyline as MeasurementPolyline).enableEdit(); + } else { + (polyline as MeasurementPolyline)._path.classList.add( + "custom-polyline" + ); + (polyline as MeasurementPolyline).disableEdit(); + } + } + }); + }, + + changeColorByLastShape: function ( + this: MeasurePolygonControl, + map: LeafletMap + ) { + let lastPolyline = null; + + this._measureLayers.eachLayer(function (layer) { + if (layer instanceof Polyline) { + lastPolyline = layer; + layer._path.classList.add("custom-polyline"); + } + }); + + if (lastPolyline) { + lastPolyline._path.classList.remove("custom-polyline"); + } + }, + + getVisiblePolylines: function (this: MeasurePolygonControl, map: LeafletMap) { + const visiblePolylines = []; + const mapBounds = map.getBounds(); + + this._measureLayers.eachLayer(function (layer) { + if (layer instanceof Polyline) { + if (mapBounds.intersects(layer.getBounds())) { + visiblePolylines.push(layer); + } + } + }); + + return visiblePolylines; + }, + + getVisiblePolylinesIds: function ( + this: MeasurePolygonControl, + polylinesArr: any[] + ) { + const idsPolylinesArr = []; + this.options.visiblePolylines = []; + polylinesArr.forEach((m) => { + idsPolylinesArr.push(m.customID); + this.options.visiblePolylines.push(m.customID); + }); + + this.options.cbVisiblePolylinesChange(idsPolylinesArr); + }, + + getAllPolylines: function (this: MeasurePolygonControl, map: LeafletMap) { + const polylines = []; + + this._measureLayers.eachLayer(function (layer) { + if (layer instanceof Polyline) { + polylines.push(layer); + } + }); + + return polylines; + }, + + removePolylineById: function ( + this: MeasurePolygonControl, + map: LeafletMap, + customID: string + ) { + const self = this; + this._measureLayers.eachLayer(function (layer) { + if (layer instanceof Polyline && layer.customID === customID) { + self._measureLayers.removeLayer(layer); + } + }); + }, + + showActiveShape: function ( + this: MeasurePolygonControl, + map: LeafletMap, + coordinates: any + ) { + this.options.moveToShape = true; + const bounds = latLngBounds(coordinates as L.LatLngExpression[]); + map.fitBounds(bounds); + }, + + fitMapToPolylines: function ( + this: MeasurePolygonControl, + map: LeafletMap, + polylines: any[] + ) { + if (polylines.length === 0) { + return; + } + + const allBounds = latLngBounds( + polylines[0].getBounds().getNorthEast(), + polylines[0].getBounds().getSouthWest() + ); + + polylines.forEach((polyline) => { + const polylineBounds = polyline.getBounds(); + allBounds.extend(polylineBounds); + }); + + map.fitBounds(allBounds); + }, + + replaceLineToPolygon: function ( + this: MeasurePolygonControl, + map: LeafletMap, + layer: any + ) { + const latlngsJSON = layer.toGeoJSON(); + const prepareCoordinates = latlngsJSON.geometry.coordinates.map((l) => { + return l.reverse(); + }); + + map.removeLayer(layer); + + prepareCoordinates.push(prepareCoordinates[0]); + + const options = { + color: "#267bdcd4", + fillColor: "#267bdcd4", + opacity: 1, + weight: 3, + }; + const distance = updateDistanceByLatLngs(prepareCoordinates); + const square = calculateArea(prepareCoordinates); + const preparePolygon = { + coordinates: prepareCoordinates, + options, + shapeId: layer.customID, + distance: distance, + number: this.options.measurementOrder, + area: square, + shapeType: this.options.shapeMode, + }; + + const polygonLayer = polygon(prepareCoordinates, options); + + polygonLayer.customID = layer.customID; + polygonLayer.customShape = "polygon"; + + polygonLayer.addTo(this._measureLayers).showMeasurements().enableEdit(); + // polygonLayer.on("dblclick", this._onPolygonClick.bind(this, map)); + polygonLayer.on("click", () => { + this.options.cbSetActiveShape(polygonLayer.customID); + this.options.cbSetUpdateStatusHandler(false); + this.options.isDrawing = false; + }); + polygonLayer.on( + "editable:drag editable:dragstart editable:dragend editable:vertex:drag editable:vertex:deleted", + this._onPolylineDrag.bind(this) + ); + + polygonLayer.on("editable:vertex:dragend", () => { + this.options.cbSetUpdateStatusHandler(false); + // Reset status to WAITING when vertex editing ends + if (this.options.cbSetMapStatus) { + this.options.cbSetMapStatus("WAITING"); + } + }); + + // Reset status to WAITING when drag ends + polygonLayer.on("editable:dragend", () => { + if (this.options.cbSetMapStatus) { + this.options.cbSetMapStatus("WAITING"); + } + }); + + this.options.polygonMode = false; + + this.options.isDrawing = true; + + this._toggleMeasure( + "img_plg_lines", + "icon_lineActive", + "icon_lineInactive" + ); + + // this.options.isDrawing = false; + + // this._measureHandler.disable(); + + return preparePolygon; + }, + getVisibleShapeIdsArr: function ( + this: MeasurePolygonControl, + map: LeafletMap + ) { + const allPolyLines = this.getVisiblePolylines(map); + this.getVisiblePolylinesIds(allPolyLines); + return this.options.visiblePolylines; + }, + + findLastCreatedLayer: function ( + this: MeasurePolygonControl, + layerGroup: any + ) { + let lastLayer = null; + let highestId = -1; + + layerGroup.eachLayer((layer) => { + if (layer._leaflet_id > highestId) { + highestId = layer._leaflet_id; + lastLayer = layer; + } + }); + + return lastLayer; + }, + + loadMeasurements: function (this: MeasurePolygonControl, map?: LeafletMap) { + if (this.options.shapes.length !== 0) { + this.options.shapes.forEach((shape) => { + const { coordinates, options, shapeId, shapeType } = shape; + + const savedShape = + shapeType === "line" + ? polyline( + coordinates as any, + { + showLength: true, + className: "custom-polyline", + shapeOptions: { + weight: 4, + color: "#267bdcd4", + opacity: 1, + }, + } as any + ) + : polygon( + coordinates as any, + { + showLength: true, + className: "custom-polyline", + shapeOptions: { + weight: 4, + color: "#267bdcd4", + opacity: 1, + }, + } as any + ); + + savedShape.customID = shapeId; + savedShape.addTo(this._measureLayers).showMeasurements().enableEdit(); + savedShape.on("click", () => { + this.options.isDrawing = true; + this.options.cbSetActiveShape(savedShape.customID); + this.options.cbSetUpdateStatusHandler(false); + this.options.clickAfterShapeSelection = true; + }); + savedShape.on("mouseout", (e) => { + // this.options.isDrawing = false; + }); + savedShape.on("mouseover", (e) => { + if (this.options.customTooltip) { + this.options.customTooltip.style.visibility = "hidden"; + } + }); + savedShape.on( + "editable:drag editable:dragstart editable:dragend editable:vertex:drag editable:vertex:deleted", + this._onPolylineDrag.bind(this) + ); + + savedShape.on("editable:vertex:dragend", () => { + this.options.cbSetUpdateStatusHandler(false); + // Reset status to WAITING when vertex editing ends + if (this.options.cbSetMapStatus) { + this.options.cbSetMapStatus("WAITING"); + } + }); + + // Reset status to WAITING when drag ends + savedShape.on("editable:dragend", () => { + if (this.options.cbSetMapStatus) { + this.options.cbSetMapStatus("WAITING"); + } + }); + }); + } + }, + + _toggleMeasurementBtn: function (this: MeasurePolygonControl) { + if (this.options.changeModeButtonActive) { + (document.getElementById("img_plg_lines") as HTMLImageElement).src = + this.options.icon_lineInactive; + this.options.changeModeButtonActive = false; + } else { + (document.getElementById("img_plg_lines") as HTMLImageElement).src = + this.options.icon_lineActive; + this.options.changeModeButtonActive = true; + } + }, + + toggleMeasurementMode: function ( + this: MeasurePolygonControl, + ifChangeMode = true, + map?: LeafletMap + ) { + const mode = this.options.measurementMode; + if (mode === "measurement") { + L.drawLocal.draw.handlers.polyline.tooltip.start = + "Klicken, um den Startpunkt der Messung zu setzen."; + this._clearMeasurements(); + this.loadMeasurements(); + // const drawBtn = document.getElementById("draw_shape"); + // drawBtn.classList.remove("hide-draw-btn"); + + (document.getElementById("img_plg_lines") as HTMLImageElement).src = + this.options.icon_lineActive; + + // if (this.options.isFirstLoading) { + // this.options.isFirstLoading = false; + // } + + const customTooltip = document.querySelector("#routedMap") as HTMLElement; + customTooltip.style.cursor = "crosshair"; + } else { + this._clearMeasurements(); + + const customTooltip = document.querySelector("#routedMap") as HTMLElement; + customTooltip.style.cursor = "pointer"; + if (this.options.currenLine) { + this.options.currenLine.disable(); + } + customTooltip.style.cursor = "pointer"; + // const drawBtn = document.getElementById("draw_shape"); + // drawBtn.classList.add("hide-draw-btn"); + this.options.isDrawing = false; + (document.getElementById("img_plg_lines") as HTMLImageElement).src = + this.options.icon_lineInactive; + } + + if (ifChangeMode) { + this.options.cbToggleMeasurementMode(); + } + }, + + changeMeasurementMode: function ( + this: MeasurePolygonControl, + mode: string, + map: LeafletMap + ) { + this.options.measurementMode = mode; + this.toggleMeasurementMode(false, map); + }, + changeMeasurementsArr: function (this: MeasurePolygonControl, arr: any[]) { + this.options.shapes = arr; + }, + cancelDrawing: function (this: MeasurePolygonControl) { + if (!this.options.isDrawingEmpty) { + this._measureHandler.disable(); + this.options.isDrawingEmpty = true; + + this._measureLayers.clearLayers(); + + this.options.cbSetDrawingStatus(false); + this.options.cbDeleteVisibleShapeById(5555); + // this.options.isDrawing = true; + } + }, +}); + +// Adds the method to create a new instance of the control +(L.Control as any).MeasurePolygon = MeasurePolygon; +(L.control as any).measurePolygon = function (options: any) { + return new MeasurePolygon(options); +}; diff --git a/libraries/commons/measurements/src/lib/utils/measure.js b/libraries/commons/measurements/src/lib/utils/measure.ts similarity index 58% rename from libraries/commons/measurements/src/lib/utils/measure.js rename to libraries/commons/measurements/src/lib/utils/measure.ts index cfb781e46b..036c8e08dd 100644 --- a/libraries/commons/measurements/src/lib/utils/measure.js +++ b/libraries/commons/measurements/src/lib/utils/measure.ts @@ -1,12 +1,21 @@ -!(function () { +import L from "leaflet"; + +(function () { "use strict"; - L.Marker.Measurement = L[L.Layer ? "Layer" : "Class"].extend({ + L.Marker.Measurement = L.Layer.extend({ options: { pane: "markerPane", }, - initialize: function (latlng, measurement, title, rotation, options) { + initialize: function ( + this: L.Marker.Measurement, + latlng: L.LatLng, + measurement: string, + title: string, + rotation: number, + options?: L.Marker.MeasurementOptions + ) { L.setOptions(this, options); this._latlng = latlng; @@ -15,36 +24,42 @@ this._rotation = rotation; }, - addTo: function (map) { + addTo: function (this: L.Marker.Measurement, map: L.Map) { map.addLayer(this); return this; }, - onAdd: function (map) { + onAdd: function (this: L.Marker.Measurement, map: L.Map) { this._map = map; - var pane = this.getPane ? this.getPane() : map.getPanes().markerPane; - var el = (this._element = L.DomUtil.create( + const pane = this.getPane + ? this.getPane() + : (map as any).getPanes().markerPane; + const el = (this._element = L.DomUtil.create( "div", "leaflet-zoom-animated leaflet-measure-path-measurement", pane )); - var inner = L.DomUtil.create("div", "", el); + const inner = L.DomUtil.create("div", "", el); inner.title = this._title; inner.innerHTML = this._measurement; map.on("zoomanim", this._animateZoom, this); this._setPosition(); + return this; }, - onRemove: function (map) { + onRemove: function (this: L.Marker.Measurement, map: L.Map) { map.off("zoomanim", this._animateZoom, this); - var pane = this.getPane ? this.getPane() : map.getPanes().markerPane; + const pane = this.getPane + ? this.getPane() + : (map as any).getPanes().markerPane; pane.removeChild(this._element); this._map = null; + return this; }, - _setPosition: function () { + _setPosition: function (this: L.Marker.Measurement) { L.DomUtil.setPosition( this._element, this._map.latLngToLayerPoint(this._latlng) @@ -52,8 +67,11 @@ this._element.style.transform += " rotate(" + this._rotation + "rad)"; }, - _animateZoom: function (opt) { - var pos = this._map + _animateZoom: function ( + this: L.Marker.Measurement, + opt: { zoom: number; center: L.LatLng } + ) { + const pos = (this._map as any) ._latLngToNewLayerPoint(this._latlng, opt.zoom, opt.center) .round(); L.DomUtil.setPosition(this._element, pos); @@ -62,12 +80,12 @@ }); L.marker.measurement = function ( - latLng, - measurement, - title, - rotation, - options - ) { + latLng: L.LatLng, + measurement: string, + title: string, + rotation: number, + options?: L.Marker.MeasurementOptions + ): L.Marker.Measurement { return new L.Marker.Measurement( latLng, measurement, @@ -77,8 +95,9 @@ ); }; - var formatDistance = function (d) { - var unit, feet; + const formatDistance = function (this: L.Polyline, d: number): string { + let unit: string; + let feet: number; if (this._measurementOptions.imperial) { feet = d / 0.3048; @@ -105,8 +124,8 @@ } }; - var formatArea = function (a) { - var unit, sqfeet; + const formatArea = function (this: L.Polyline, a: number): string { + let unit: string; if (this._measurementOptions.imperial) { if (a > 404.685642) { @@ -142,26 +161,26 @@ } }; - var RADIUS = 6378137; + const RADIUS = 6378137; // ringArea function copied from geojson-area // (https://github.com/mapbox/geojson-area) // This function is distributed under a separate license, // see LICENSE.md. - var ringArea = function ringArea(coords) { - var rad = function rad(_) { - return (_ * Math.PI) / 180; + const ringArea = function (coords: L.LatLng[]): number { + const rad = function (deg: number): number { + return (deg * Math.PI) / 180; }; - var p1, - p2, - p3, - lowerIndex, - middleIndex, - upperIndex, - area = 0, - coordsLength = coords.length; + let p1: L.LatLng, + p2: L.LatLng, + p3: L.LatLng, + lowerIndex: number, + middleIndex: number, + upperIndex: number; + let area = 0; + const coordsLength = coords.length; if (coordsLength > 2) { - for (var i = 0; i < coordsLength; i++) { + for (let i = 0; i < coordsLength; i++) { if (i === coordsLength - 2) { // i = N-2 lowerIndex = coordsLength - 2; @@ -189,45 +208,54 @@ return Math.abs(area); }; + /** * Handles the init hook for polylines and circles. * Implements the showOnHover functionality if called for. */ - var addInitHook = function () { - var showOnHover = + const addInitHook = function (this: L.Polyline) { + const showOnHover = this.options.measurementOptions && this.options.measurementOptions.showOnHover; if (this.options.showMeasurements && !showOnHover) { this.showMeasurements(); } if (this.options.showMeasurements && showOnHover) { - this.on("mouseover", function () { + this.on("mouseover", function (this: L.Polyline) { this.showMeasurements(); }); - this.on("mouseout", function () { + this.on("mouseout", function (this: L.Polyline) { this.hideMeasurements(); }); } }; - var override = function (method, fn, hookAfter) { + type MethodFunction = (...args: any[]) => any; + + const override = function ( + method: MethodFunction, + fn: (...args: any[]) => any, + hookAfter?: boolean + ): MethodFunction { if (!hookAfter) { - return function () { - var originalReturnValue = method.apply(this, arguments); - var args = Array.prototype.slice.call(arguments); - args.push(originalReturnValue); - return fn.apply(this, args); + return function (this: any, ...args: any[]) { + const originalReturnValue = method.apply(this, args); + const newArgs = [...args, originalReturnValue]; + return fn.apply(this, newArgs); }; } else { - return function () { - fn.apply(this, arguments); - return method.apply(this, arguments); + return function (this: any, ...args: any[]) { + fn.apply(this, args); + return method.apply(this, args); }; } }; L.Polyline.include({ - showMeasurements: function (options) { + showMeasurements: function ( + this: L.Polyline, + options?: L.Marker.MeasurementOptions + ) { if (!this._map || this._measurementLayer) return this; this._measurementOptions = L.extend( @@ -254,7 +282,7 @@ return this; }, - hideMeasurements: function () { + hideMeasurements: function (this: L.Polyline) { if (!this._map) return this; this._map.off("zoomend", this.updateMeasurements, this); @@ -266,20 +294,23 @@ return this; }, - onAdd: override(L.Polyline.prototype.onAdd, function (originalReturnValue) { - var showOnHover = - this.options.measurementOptions && - this.options.measurementOptions.showOnHover; - if (this.options.showMeasurements && !showOnHover) { - this.showMeasurements(this.options.measurementOptions); - } + onAdd: override( + L.Polyline.prototype.onAdd, + function (this: L.Polyline, originalReturnValue: any) { + const showOnHover = + this.options.measurementOptions && + this.options.measurementOptions.showOnHover; + if (this.options.showMeasurements && !showOnHover) { + this.showMeasurements(this.options.measurementOptions); + } - return originalReturnValue; - }), + return originalReturnValue; + } + ), onRemove: override( L.Polyline.prototype.onRemove, - function (originalReturnValue) { + function (this: L.Polyline, originalReturnValue: any) { this.hideMeasurements(); return originalReturnValue; @@ -289,16 +320,7 @@ setLatLngs: override( L.Polyline.prototype.setLatLngs, - function (originalReturnValue) { - this.updateMeasurements(); - - return originalReturnValue; - } - ), - - spliceLatLngs: override( - L.Polyline.prototype.spliceLatLngs, - function (originalReturnValue) { + function (this: L.Polyline, originalReturnValue: any) { this.updateMeasurements(); return originalReturnValue; @@ -308,7 +330,7 @@ formatDistance: formatDistance, formatArea: formatArea, - getCentroid(points) { + getCentroid(this: L.Polyline, points: L.LatLng[]): L.LatLng { let sumLat = 0; let sumLng = 0; const numPoints = points.length; @@ -321,44 +343,53 @@ const centroidLat = sumLat / numPoints; const centroidLng = sumLng / numPoints; - return [centroidLat, centroidLng]; + return L.latLng(centroidLat, centroidLng); }, - updateMeasurements: function () { + updateMeasurements: function (this: L.Polyline) { if (!this._measurementLayer) return this; - var latLngs = this.getLatLngs(), - isPolygon = this instanceof L.Polygon, - options = this._measurementOptions, - totalDist = 0, - formatter, - ll1, - ll2, - p1, - p2, - pixelDist, - dist; - - if (latLngs && latLngs.length && L.Util.isArray(latLngs[0])) { + let latLngs = this.getLatLngs() as L.LatLng[] | L.LatLng[][]; + const isPolygon = this instanceof L.Polygon; + const options = this._measurementOptions; + let totalDist = 0; + let formatter: (value: number) => string; + let ll1: L.LatLng, + ll2: L.LatLng, + p1: L.Point, + p2: L.Point, + pixelDist: number, + dist: number; + + if ( + latLngs && + latLngs.length && + Array.isArray(latLngs[0]) && + (latLngs[0] as any).lat === undefined + ) { // Outer ring is stored as an array in the first element, // use that instead. - latLngs = latLngs[0]; + latLngs = (latLngs as L.LatLng[][])[0]; } this._measurementLayer.clearLayers(); - if (this._measurementOptions.showDistances && latLngs.length > 1) { + if ( + this._measurementOptions.showDistances && + (latLngs as L.LatLng[]).length > 1 + ) { formatter = this._measurementOptions.formatDistance || - L.bind(this.formatDistance, this); - - for ( - var i = 1, len = latLngs.length; - (isPolygon && i <= len) || i < len; - i++ - ) { - ll1 = latLngs[i - 1]; - ll2 = latLngs[i % len]; + (L.bind(this.formatDistance, this) as unknown as ( + value: number + ) => string); + + const latLngsArray = latLngs as L.LatLng[]; + const len = latLngsArray.length; + + for (let i = 1; (isPolygon && i <= len) || i < len; i++) { + ll1 = latLngsArray[i - 1]; + ll2 = latLngsArray[i % len]; dist = ll1.distanceTo(ll2); totalDist += dist; @@ -379,7 +410,7 @@ this._getRotation(ll1, ll2), options ) - .addTo(this._measurementLayer); + .addTo(this._measurementLayer as any); } } @@ -393,36 +424,40 @@ 0, options ) - .addTo(this._measurementLayer); + .addTo(this._measurementLayer as any); } } - if (isPolygon && options.showArea && latLngs.length > 2) { - formatter = options.formatArea || L.bind(this.formatArea, this); - var area = ringArea(latLngs); + if (isPolygon && options.showArea && (latLngs as L.LatLng[]).length > 2) { + formatter = + options.formatArea || + (L.bind(this.formatArea, this) as unknown as ( + value: number + ) => string); + const area = ringArea(latLngs as L.LatLng[]); L.marker .measurement( - this.getCentroid(latLngs), + this.getCentroid(latLngs as L.LatLng[]), formatter(area), options.lang.totalArea, 0, options ) - .addTo(this._measurementLayer); + .addTo(this._measurementLayer as any); } return this; }, - _getRotation: function (ll1, ll2) { - var p1 = this._map.project(ll1), - p2 = this._map.project(ll2); + _getRotation: function (this: L.Polyline, ll1: L.LatLng, ll2: L.LatLng) { + const p1 = this._map.project(ll1); + const p2 = this._map.project(ll2); return Math.atan((p2.y - p1.y) / (p2.x - p1.x)); }, }); - L.Polyline.addInitHook(function () { + L.Polyline.addInitHook(function (this: L.Polyline) { addInitHook.call(this); }); })(); diff --git a/libraries/commons/measurements/src/lib/utils/measurement-geometry.ts b/libraries/commons/measurements/src/lib/utils/measurement-geometry.ts new file mode 100644 index 0000000000..35316b11cf --- /dev/null +++ b/libraries/commons/measurements/src/lib/utils/measurement-geometry.ts @@ -0,0 +1,128 @@ +/** + * Measurement Geometry Utilities + * Pure calculation functions for distance and area measurements + * Extracted from measure-path.ts to reduce file size and improve testability + */ + +import L from "leaflet"; + +/** + * Calculate total distance along a path of LatLng points + * @param latlngs - Array of Leaflet LatLng points + * @returns Distance in meters + */ +export function calculateDistance(latlngs: L.LatLng[]): number { + let totalDistance = 0; + + for (let i = 0; i < latlngs.length - 1; i++) { + const point1 = latlngs[i]; + const point2 = latlngs[i + 1]; + const distance = point1.distanceTo(point2); + totalDistance += distance; + } + + return totalDistance; +} + +/** + * Calculate area of a polygon using spherical geometry + * @param latlngs - Array of [lat, lng] coordinate pairs forming a closed polygon + * @returns Formatted area string (e.g., "123.45 m²" or "1.23 km²") + */ +export function calculateArea(latlngs: number[][]): string { + const toRadians = (degree: number): number => (degree * Math.PI) / 180; + + if (latlngs.length < 3) return "0 m²"; + + const earthRadius = 6378137; // meters + + let total = 0; + for (let i = 0, l = latlngs.length; i < l; i++) { + const [lat1, lon1] = latlngs[i]; + const [lat2, lon2] = latlngs[(i + 1) % l]; + + total += + toRadians(lon2 - lon1) * + (2 + Math.sin(toRadians(lat1)) + Math.sin(toRadians(lat2))); + } + + total = Math.abs((total * earthRadius * earthRadius) / 2); + + return formatArea(total); +} + +/** + * Format area value to human-readable string + * @param area - Area in square meters + * @returns Formatted string (e.g., "123.45 m²" or "1.23 km²") + */ +export function formatArea(area: number): string { + if (area >= 1000000) { + return `${(area / 1000000).toFixed(2)} km²`; + } else { + return `${area.toFixed(2)} m²`; + } +} + +/** + * Format distance value to human-readable string + * @param distance - Distance in meters + * @returns Formatted string (e.g., "123.45 m" or "1.23 km") + */ +export function formatDistance(distance: number): string { + if (distance >= 1000) { + return `${(distance / 1000).toFixed(2)} km`; + } else { + return `${distance.toFixed(2)} m`; + } +} + +/** + * Calculate and format distance for a Leaflet polyline/polygon layer + * @param layer - Leaflet Polyline or Polygon layer + * @returns Formatted distance string + */ +export function updateDistance(layer: L.Polyline | L.Polygon): string { + const isLine = layer.toGeoJSON().geometry.type === "LineString"; + const latlngsRaw = isLine + ? layer.getLatLngs() + : (layer as L.Polygon).getLatLngs()[0]; + + // Type guard: ensure we have LatLng[] + let latlngs: L.LatLng[]; + if (Array.isArray(latlngsRaw)) { + latlngs = latlngsRaw as L.LatLng[]; + } else { + latlngs = [latlngsRaw as L.LatLng]; + } + + if (!isLine) { + latlngs.push(latlngs[0]); // Close polygon + } + + const totalDistance = calculateDistance(latlngs); + + if (!isLine) { + latlngs.pop(); // Remove duplicate closing point + } + + return formatDistance(totalDistance); +} + +/** + * Calculate and format distance from array of coordinate pairs + * @param coordinates - Array of [lat, lng] pairs + * @returns Formatted distance string + */ +export function updateDistanceByLatLngs(coordinates: number[][]): string { + let totalDistance = 0; + + for (let i = 0; i < coordinates.length - 1; i++) { + const point1 = L.latLng(coordinates[i][0], coordinates[i][1]); + const point2 = L.latLng(coordinates[i + 1][0], coordinates[i + 1][1]); + const distance = point1.distanceTo(point2); + totalDistance += distance; + } + + return formatDistance(totalDistance); +} diff --git a/libraries/commons/measurements/src/lib/utils/vertex-click-handler.ts b/libraries/commons/measurements/src/lib/utils/vertex-click-handler.ts new file mode 100644 index 0000000000..35ca4d47db --- /dev/null +++ b/libraries/commons/measurements/src/lib/utils/vertex-click-handler.ts @@ -0,0 +1,159 @@ +import type { LeafletMouseEvent } from "@carma/leaflet"; +import type { Layer, Control, Marker } from "leaflet"; + +/** + * Handler for vertex clicks during measurement drawing + * Decides action based on which vertex was clicked: + * - First vertex (with 3+ total): Close polygon + * - Last vertex: Finish line + * - Middle vertices: No action + */ +export function createVertexClickHandler( + getMeasureHandler: () => + | (Control.DrawHandler & { + _markers?: Marker[]; + _finishShape?: () => void; + _poly?: { _latlngs?: unknown[] }; + }) + | null + | undefined, + options: Pick, + getCurrentVertexCount: () => number, + map?: any, + getIsFinishingShape?: () => boolean, + setIsFinishingShape?: (value: boolean) => void, + getLastVertexTimestamp?: () => number +) { + const handler = function (e: LeafletMouseEvent) { + const measureHandler = getMeasureHandler(); + if (!measureHandler) { + return; // No measure handler, ignore + } + + // CRITICAL: Stop propagation immediately to prevent the map from receiving this click + // This prevents Leaflet.Draw's map click handler from adding a new vertex when we click an existing one + if (e.originalEvent) { + e.originalEvent.stopPropagation(); + e.originalEvent.preventDefault(); + } + // Since we are now attaching this handler directly to the marker, + // we can get the marker from e.target instead of searching for it. + const clickedMarker = e.target as any; + const clickedHandle = clickedMarker.customHandle; + + if (clickedHandle === undefined || clickedHandle === null) { + console.warn( + "[measure-path] Clicked marker has no handle index", + clickedMarker + ); + return; + } + + const markers = measureHandler._markers; + if (!markers) return; + + const vertexCount = markers.length; + const isFirst = clickedHandle === 0; + const isLast = clickedHandle === vertexCount - 1; + + console.warn("[measure-path] Vertex clicked", { + handle: clickedHandle, + isFirst, + isLast, + totalVertices: vertexCount, + eventType: e.originalEvent?.type, + }); + + // CRITICAL: Ignore clicks on the last vertex if it was just added (ghost clicks / touch bounce) + if (isLast && getLastVertexTimestamp) { + const lastAdded = getLastVertexTimestamp(); + const now = Date.now(); + if (now - lastAdded < 200) { + console.warn( + "[measure-path] Ignoring click on last vertex - too soon after addVertex", + { diff: now - lastAdded } + ); + return; + } + } + + // First vertex: close polygon (requires 3+ vertices and valid polyline data) + if (isFirst && vertexCount >= 3) { + const polyLatlngs = measureHandler._poly?._latlngs; + if (!polyLatlngs || polyLatlngs.length < 3) { + console.warn( + "[measure-path] Cannot close polygon - polyline data not ready", + { + polyLatlngs: polyLatlngs?.length, + } + ); + return; + } + console.warn( + "[measure-path] Closing polygon - triggering finish like double-click" + ); + options.shapeMode = "polygon"; + + // Set flag to prevent map click handler from starting new measurement + if (setIsFinishingShape) { + setIsFinishingShape(true); + } + + // Trigger finish like double-click does + measureHandler._finishShape?.(); + + // Stop propagation to prevent map click handler from starting new measurement + e.originalEvent?.stopPropagation?.(); + e.originalEvent?.preventDefault?.(); + + // Disable the handler after draw:created event fires + if (map) { + map.once("draw:created", () => { + if (measureHandler.disable) { + measureHandler.disable(); + console.log( + "[measure-path] Disabled measurement handler after draw:created - ready for new measurement" + ); + } + }); + } + return; + } + + // Last vertex: finish line + if (isLast) { + console.warn( + "[measure-path] Finishing line - will disable handler after completion" + ); + + // Set flag to prevent map click handler from starting new measurement + if (setIsFinishingShape) { + setIsFinishingShape(true); + } + + measureHandler._finishShape?.(); + + // Stop propagation to prevent map click handler from starting new measurement + e.originalEvent?.stopPropagation?.(); + e.originalEvent?.preventDefault?.(); + + // Disable the handler after draw:created event fires + if (map) { + map.once("draw:created", () => { + if (measureHandler.disable) { + measureHandler.disable(); + console.log( + "[measure-path] Disabled measurement handler after draw:created - ready for new measurement" + ); + } + setIsFinishingShape(false); + }); + } + return; + } + + console.log("[measure-path] Middle vertex - no action"); + }; + + return handler; +} diff --git a/libraries/commons/measurements/tsconfig.json b/libraries/commons/measurements/tsconfig.json index f474c15225..26e340899f 100644 --- a/libraries/commons/measurements/tsconfig.json +++ b/libraries/commons/measurements/tsconfig.json @@ -1,6 +1,5 @@ { "extends": "../../../tsconfig.legacy.base.json", - "files": [], "include": [], "references": [ { diff --git a/libraries/commons/measurements/tsconfig.lib.json b/libraries/commons/measurements/tsconfig.lib.json index 85dc7fbc79..61eeadcf4b 100644 --- a/libraries/commons/measurements/tsconfig.lib.json +++ b/libraries/commons/measurements/tsconfig.lib.json @@ -2,9 +2,8 @@ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "../../../../dist/out-tsc", - "declaration": true, - "types": ["node"] + "declaration": true }, - "include": ["src/**/*.ts", "src/lib/lib-measurements.tsx"], - "exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"] + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.js"], + "exclude": ["src/**/*.spec.ts"] } diff --git a/libraries/commons/measurements/vite.config.ts b/libraries/commons/measurements/vite.config.ts new file mode 100644 index 0000000000..e3ee109f12 --- /dev/null +++ b/libraries/commons/measurements/vite.config.ts @@ -0,0 +1,37 @@ +/// +import { defineConfig } from 'vite'; +import dts from 'vite-plugin-dts'; +import * as path from 'path'; +import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; +import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin'; + +export default defineConfig({ + root: __dirname, + cacheDir: '../../../../node_modules/.vite/libraries/commons/measurements', + + plugins: [ + nxViteTsPaths(), + nxCopyAssetsPlugin(['*.md']), + dts({ + entryRoot: 'src', + tsconfigPath: path.join(__dirname, 'tsconfig.lib.json'), + }), + ], + + build: { + outDir: '../../../../dist/libraries/commons/measurements', + reportCompressedSize: true, + commonjsOptions: { + transformMixedEsModules: true, + }, + lib: { + entry: 'src/index.ts', + name: 'measurements', + fileName: 'index', + formats: ['es'], + }, + rollupOptions: { + external: ['leaflet', 'react', 'react-dom', 'react-leaflet', 'react-cismap'], + }, + }, +}); diff --git a/libraries/mapping/engines/leaflet/src/index.ts b/libraries/mapping/engines/leaflet/src/index.ts index 6c806623ab..7af111b32b 100644 --- a/libraries/mapping/engines/leaflet/src/index.ts +++ b/libraries/mapping/engines/leaflet/src/index.ts @@ -1,3 +1,44 @@ +import L from "leaflet"; + export { LeafletMapStateChangeEvents } from "./lib/events"; export * from "./lib/LatLng"; export * from "./lib/Map"; + +// Re-exports from Leaflet +export const Browser = L.Browser; + +export const Control = L.Control; +export type Control = L.Control; +export type ControlOptions = L.ControlOptions; + +export const DomEvent = L.DomEvent; +export const DomUtil = L.DomUtil; + +export const latLngBounds = L.latLngBounds; +export const LatLngBounds = L.LatLngBounds; +export type LatLngBounds = L.LatLngBounds; + +export const Layer = L.Layer; +export type Layer = L.Layer; + +export const layerGroup = L.layerGroup; +export const LayerGroup = L.LayerGroup; +export type LayerGroup = L.LayerGroup; + +export const LeafletControl = L.Control; +export type LeafletControl = L.Control; + +export type LeafletEvent = L.LeafletEvent; +export type LeafletMouseEvent = L.LeafletMouseEvent; + +export const point = L.point; +export const Point = L.Point; +export type Point = L.Point; + +export const polygon = L.polygon; +export const Polygon = L.Polygon; +export type Polygon = L.Polygon; + +export const polyline = L.polyline; +export const Polyline = L.Polyline; +export type Polyline = L.Polyline; diff --git a/libraries/mapping/engines/leaflet/src/lib/LatLng.ts b/libraries/mapping/engines/leaflet/src/lib/LatLng.ts index cb1c6dcf49..ae83578d3b 100644 --- a/libraries/mapping/engines/leaflet/src/lib/LatLng.ts +++ b/libraries/mapping/engines/leaflet/src/lib/LatLng.ts @@ -1,6 +1,11 @@ -import { LatLng as LeafletLatLng } from "leaflet"; +import L from "leaflet"; import type { Degrees } from "@carma/units/types"; +export const LatLng = L.LatLng; +export type LatLng = L.LatLng; + +export const latLng = L.latLng; + export type LatLngJson = { latitude: Degrees; longitude: Degrees; @@ -9,9 +14,7 @@ export type LatLngJson = { /** * Convert Leaflet LatLng to CARMA LatLng.deg */ -export const leafletLatLngToLatLngJson = ( - latLng: LeafletLatLng -): LatLngJson => { +export const leafletLatLngToLatLngJson = (latLng: LatLng): LatLngJson => { return { latitude: latLng.lat as Degrees, longitude: latLng.lng as Degrees, @@ -21,8 +24,6 @@ export const leafletLatLngToLatLngJson = ( /** * Convert CARMA LatLng.deg to Leaflet LatLng tuple */ -export const latLngUnitsTypedToLatLngJson = ( - latLng: LatLngJson -): LeafletLatLng => { - return new LeafletLatLng(latLng.latitude, latLng.longitude); +export const latLngUnitsTypedToLatLngJson = (latLng: LatLngJson): LatLng => { + return new LatLng(latLng.latitude, latLng.longitude); }; diff --git a/libraries/mapping/engines/leaflet/src/lib/Map.ts b/libraries/mapping/engines/leaflet/src/lib/Map.ts index 2e795197eb..d01d166704 100644 --- a/libraries/mapping/engines/leaflet/src/lib/Map.ts +++ b/libraries/mapping/engines/leaflet/src/lib/Map.ts @@ -1,15 +1,16 @@ // prevent namespace collisions with other mapping libraries and JavaScript built-in Map type -import { Map as LeafletMap, LatLng } from "leaflet"; +import L from "leaflet"; -export { LeafletMap }; +export const LeafletMap = L.Map; +export type LeafletMap = L.Map; export type LeafletView = { - center: LatLng; + center: L.LatLng; zoom: number; }; export const isLeafletMap = (map: unknown): map is LeafletMap => { - return map instanceof LeafletMap; + return map instanceof L.Map; }; export const getLeafletView = (leaflet: LeafletMap): LeafletView => { diff --git a/libraries/mapping/utils/src/lib/hooks/useLeafletZoomControls.ts b/libraries/mapping/utils/src/lib/hooks/useLeafletZoomControls.ts index da9f9a2bce..7c7bf2c77d 100644 --- a/libraries/mapping/utils/src/lib/hooks/useLeafletZoomControls.ts +++ b/libraries/mapping/utils/src/lib/hooks/useLeafletZoomControls.ts @@ -1,5 +1,5 @@ import { useCallback, useContext } from "react"; -import type { Map as LeafletMap } from "leaflet"; +import { LeafletMap } from "@carma/leaflet"; import { TopicMapContext } from "react-cismap/contexts/TopicMapContextProvider"; import { logOnce } from "@carma-commons/utils"; diff --git a/libraries/mapping/utils/tsconfig.json b/libraries/mapping/utils/tsconfig.json index 3ceab515a4..ec5af9f447 100644 --- a/libraries/mapping/utils/tsconfig.json +++ b/libraries/mapping/utils/tsconfig.json @@ -1,8 +1,7 @@ { // Using legacy for now - contains old code "extends": "../../../tsconfig.legacy.base.json", - "compilerOptions": {}, - "files": [], + "includes": [], "references": [ { "path": "./tsconfig.lib.json" diff --git a/libraries/mapping/utils/tsconfig.lib.json b/libraries/mapping/utils/tsconfig.lib.json index 56cd2a7f98..b744da8324 100644 --- a/libraries/mapping/utils/tsconfig.lib.json +++ b/libraries/mapping/utils/tsconfig.lib.json @@ -3,7 +3,6 @@ "compilerOptions": { "outDir": "../../../dist/out-tsc", "declaration": true, - "types": ["node", "vite/client"] }, "include": ["src/**/*.ts"], "exclude": ["vite.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"] diff --git a/libraries/mapping/utils/tsconfig.spec.json b/libraries/mapping/utils/tsconfig.spec.json index 05a0e18393..bcf47646bd 100644 --- a/libraries/mapping/utils/tsconfig.spec.json +++ b/libraries/mapping/utils/tsconfig.spec.json @@ -6,21 +6,14 @@ "vitest/globals", "vitest/importMeta", "vite/client", - "node", "vitest" ] }, "include": [ "vite.config.ts", "vitest.config.ts", - "src/**/*.test.ts", "src/**/*.spec.ts", - "src/**/*.test.tsx", "src/**/*.spec.tsx", - "src/**/*.test.js", - "src/**/*.spec.js", - "src/**/*.test.jsx", - "src/**/*.spec.jsx", "src/**/*.d.ts" ] } diff --git a/libraries/types/src/index.d.ts b/libraries/types/src/index.d.ts index 68d0ac4bfa..5c6d52a748 100644 --- a/libraries/types/src/index.d.ts +++ b/libraries/types/src/index.d.ts @@ -1,6 +1,5 @@ export * from "./lib/carma-config.d"; export * from "./lib/carma-layers.d"; -export * from "./lib/cesium-config.d"; export * from "./lib/cesium-shaders.d"; export * from "./lib/cismap-search.d"; export * from "./lib/feature-info.d"; diff --git a/libraries/types/src/lib/carma-config.d.ts b/libraries/types/src/lib/carma-config.d.ts index bcb7715062..17e2473388 100644 --- a/libraries/types/src/lib/carma-config.d.ts +++ b/libraries/types/src/lib/carma-config.d.ts @@ -1 +1,5 @@ export type CarmaConfig = Record; + +export interface LayerCarmaConf { + skipSnapping?: boolean; +} diff --git a/libraries/types/src/lib/leaflet-extensions.d.ts b/libraries/types/src/lib/leaflet-extensions.d.ts index 71617ed78a..413500152d 100644 --- a/libraries/types/src/lib/leaflet-extensions.d.ts +++ b/libraries/types/src/lib/leaflet-extensions.d.ts @@ -19,4 +19,278 @@ declare module "leaflet" { options?: GeoJSONOptions ): LGeoJSON; } + + // Measurement plugin (leaflet-measure-path) + namespace Control { + interface MeasurementShapeData { + coordinates: number[][]; + options: { + color: string; + fillColor: string | null; + opacity: number; + weight: number; + }; + shapeId: number | string; + distance: string; + number: number; + area?: string | null; + shapeType: "line" | "polygon"; + customTitle?: string; + } + + interface DrawHandler { + _poly?: { _latlngs: LatLng[] }; + _enabled?: boolean; + enable(): void; + disable(): void; + completeShape?: () => void; + addVertex?(latlng: LatLng): void; + } + + class MeasurePolygon extends Control { + options: MeasurePolygonOptions; + _map: Map; + _measureLayers: LayerGroup; + _measureHandler: Draw.Polyline | Draw.Polygon | DrawHandler; + _lastOriginalClick: { latlng: LatLng; containerPoint: Point }; + + drawingPolygons(map: Map): void; + drawingLines(map: Map, event: LeafletMouseEvent): void; + onAdd(map: Map): HTMLElement; + _clearMeasurements(): void; + changeColorByActivePolyline(map: Map, customID: number | string): void; + changeColorByLastShape(map: Map): void; + showLastPolylineOnFirstLoding(map: Map): void; + getVisiblePolylines(map: Map): Polyline[]; + getVisiblePolylinesIds(polylines: Polyline[]): void; + getAllPolylines(map: Map): Polyline[]; + removePolylineById(map: Map, customID: number | string): void; + fitMapToAllPolylines(map: Map): void; + fitMapToPolylines(map: Map, polylines: Polyline[]): void; + convertPolylineToPolygon(map: Map, layer: Polyline): void; + loadMeasurements(map?: Map): void; + _toggleMeasurementBtn(): void; + toggleMeasurementMode(ifChangeMode?: boolean, map?: Map): void; + _UpdateDistance(layer: Polyline): string; + _toggleMeasure( + id: string, + iconActive: string, + inactiveIcon: string + ): void; + calculateArea(coordinates: number[][]): string; + calculateDistance(latlngs: LatLng[]): number; + formatDistance(distance: number): string; + saveShapeHandler( + layer: Polyline, + distance: string | null, + area: string | null, + map: Map + ): void; + _onPolylineDrag(event: LeafletEvent): void; + replaceLineToPolygon(map: Map, layer: Polyline): MeasurementShapeData; + getVisibleShapeIdsArr(map: Map): (number | string)[]; + _UpdateDistanceByLatLngs(coordinates: number[][]): string; + showActiveShape(map: Map, coordinates: number[][]): void; + changeMeasurementMode(mode: string, map: Map): void; + changeMeasurementsArr(arr: MeasurementShapeData[]): void; + findLastCreatedLayer(layerGroup: LayerGroup): Layer | null; + cancelDrawing(): void; + startDrawing(): void; + _onPolygonClick(map: Map, event: LeafletMouseEvent): void; + _UpdateAreaperimeter(layer: Polygon): void; + } + + interface MeasurePolygonOptions extends ControlOptions { + icon_lineActive: string; + icon_lineInactive: string; + icon_polygonActive: string; + icon_polygonInactive: string; + html_template: string; + height: number; + width: number; + mode_btn: string; + color_polygon: string; + fillColor_polygon: string; + weight_polygon: string; + isDrawing: boolean; + changeModeButtonActive: boolean; + msj_disable_tool: string; + shapes: MeasurementShapeData[]; + activeShape: number | string | null; + shapeMode: "line" | "polygon"; + measurementOrder: number; + moveToShape: boolean | MeasurementShapeData | null; + cb: () => void; + cbSaveShape: (shape: MeasurementShapeData) => void; + cdDeleteShape: ( + id: number | string, + localShapeStore: MeasurementShapeData[] + ) => void; + cbUpdateShape: ( + id: number | string, + newCoordinates: number[][], + newDistance: string, + newSquare: string | null + ) => void; + cbVisiblePolylinesChange: (ids: (number | string)[]) => void; + cbSetDrawingStatus: (status: boolean) => void; + cbSetDrawingShape: (shape: MeasurementShapeData | null) => void; + cbSetActiveShape: (id: number | string) => void; + cbSetUpdateStatusHandler: (status: boolean) => void; + cbMapMovingEndHandler: (status: boolean) => void; + cbSaveLastActiveShapeIdBeforeDrawingHandler: () => void; + cbChangeActiveCanceldShapeId: () => void; + cbToggleMeasurementMode: () => void; + cbGetMeasurementModeHandler: () => void; + cbDeleteVisibleShapeById: (id: number | string) => void; + cbUpdateAreaOfDrawingMeasurement: (area: string | null) => void; + cbSetCurrentDrawHandler: (handler: DrawHandler | null) => void; + cbSetMapStatus?: (status: string) => void; + visiblePolylines: (string | number)[]; + localShapeStore: MeasurementShapeData[]; + isDrawingEmpty: boolean; + nativeMove: boolean; + currenLine: DrawHandler | null; + polygonMode: boolean; + measurementMode: string | boolean; + startDrawing: boolean; + customTooltip: HTMLElement | null; + device: "desktop" | "mobile" | "tablet" | "Desktop" | null; + clickAfterShapeSelection: boolean; + snappingLatlng: LatLng | null; + snappingEnabled: boolean; + snappingQueryRadius?: number; + } + } + + namespace control { + function measurePolygon( + options?: Partial + ): Control.MeasurePolygon; + } + + interface Polyline { + customID?: number | string; + customShape?: string; + _path?: SVGPathElement; + _leaflet_id?: number; + _measurementLayer?: L.LayerGroup; + _measurementOptions?: any; + showMeasurements?: (options?: any) => this; + hideMeasurements?: () => this; + updateMeasurements?: () => void; + formatDistance?: (distance: number) => string; + formatArea?: (area: number) => string; + _getRotation?: (ll1: L.LatLng, ll2: L.LatLng) => number; + getCentroid?: (latlngs: L.LatLng[]) => L.LatLng; + enableEdit?: () => void; + disableEdit?: () => void; + } + + interface Polygon { + customID?: number | string; + customShape?: string; + customHandle?: number; + _path?: SVGPathElement; + _measurementLayer?: L.LayerGroup; + _measurementOptions?: any; + showMeasurements?: (options?: any) => this; + hideMeasurements?: () => this; + updateMeasurements?: () => void; + formatDistance?: (distance: number) => string; + formatArea?: (area: number) => string; + _getRotation?: (ll1: L.LatLng, ll2: L.LatLng) => number; + getCentroid?: (latlngs: L.LatLng[]) => L.LatLng; + enableEdit?: () => void; + disableEdit?: () => void; + } + + namespace Marker { + interface MeasurementOptions { + pane?: string; + showOnHover?: boolean; + minPixelDistance?: number; + showDistances?: boolean; + showArea?: boolean; + showTotalDistance?: boolean; + imperial?: boolean; + ha?: boolean; + formatDistance?: (distance: number) => string; + formatArea?: (area: number) => string; + lang?: { + totalLength?: string; + totalArea?: string; + segmentLength?: string; + }; + } + + class Measurement extends L.Layer { + options: MeasurementOptions; + _latlng: L.LatLng; + _measurement: string; + _title: string; + _rotation: number; + _map: L.Map; + _element: HTMLElement; + + constructor( + latlng: L.LatLng, + measurement: string, + title: string, + rotation: number, + options?: MeasurementOptions + ); + + addTo(map: L.Map): this; + onAdd(map: L.Map): this; + onRemove(map: L.Map): this; + _setPosition(): void; + _animateZoom(opt: { zoom: number; center: L.LatLng }): void; + } + } + + namespace marker { + function measurement( + latLng: L.LatLng, + measurement: string, + title: string, + rotation: number, + options?: Marker.MeasurementOptions + ): Marker.Measurement; + } + + interface PolylineOptions { + showMeasurements?: boolean; + measurementOptions?: Marker.MeasurementOptions; + } + + interface Marker { + customHandle?: number; + } + + interface Layer { + customID?: number | string; + customHandle?: number; + _path?: SVGPathElement; + enableEdit?: () => void; + disableEdit?: () => void; + getLatLng?: () => LatLng; + _leaflet_id?: number; + } + + interface Map { + _panes?: { popupPane: HTMLElement; [key: string]: HTMLElement }; + } + + namespace Draw { + interface DrawMap extends Map { + mergeOptions(options: any): void; + addInitHook(fn: () => void): void; + } + } + + interface LeafletEvent { + layerType?: string; + layers?: LayerGroup; + } } diff --git a/playgrounds/geoportal-playground/src/app/components/map-measure/measure-path.js b/playgrounds/geoportal-playground/src/app/components/map-measure/measure-path.js index ee4c7c5f64..2451026fab 100644 --- a/playgrounds/geoportal-playground/src/app/components/map-measure/measure-path.js +++ b/playgrounds/geoportal-playground/src/app/components/map-measure/measure-path.js @@ -8,7 +8,7 @@ L.Control.MeasurePolygon = L.Control.extend({ icon_polygonInactive: "https://img.icons8.com/?size=48&id=98463&format=png", html_template: `

Results

Area:
_p_area

-

Perimeter :
_p_perimetro

`, +

Perimeter :
_p_perimeter

`, height: 130, width: 150, mode_btn: "", @@ -281,6 +281,10 @@ L.Control.MeasurePolygon = L.Control.extend({ this._measureLayers = L.layerGroup().addTo(map); map.on("draw:created", (event) => { + console.log("[MEASURE] draw:created event fired", { + layer: event.layer, + shapeMode: this.options.shapeMode, + }); this.options.checkonedrawpoligon = false; this.options.ifDrawing = false; @@ -313,6 +317,7 @@ L.Control.MeasurePolygon = L.Control.extend({ }); map.on("draw:drawstart", (event) => { + console.log("[MEASURE] draw:drawstart event fired"); this.options.cbSaveLastActiveShapeIdBeforeDrawingHandler(); this.options.measurementOrder = this.options.measurementOrder + 1; const shapesObj = { @@ -326,6 +331,9 @@ L.Control.MeasurePolygon = L.Control.extend({ }); map.on("draw:drawvertex", (event) => { + console.log("[MEASURE] draw:drawvertex event fired", { + numLayers: Object.keys(event.layers._layers).length, + }); const layers = event.layers; const latlngs = []; let index = 0; @@ -333,12 +341,32 @@ L.Control.MeasurePolygon = L.Control.extend({ layers.eachLayer((layer) => { layer.customHandle = index++; - layer.on("click", (e) => { + + const clickHandler = (e) => { + console.log("[MEASURE] Vertex marker clicked:", { + customHandle: e.target.customHandle, + isFirstMarker: e.target.customHandle === 0, + shapeMode: this.options.shapeMode, + measureHandler: this._measureHandler, + latLng: e.latlng, + originalEvent: e.originalEvent, + }); if (e.target.customHandle === 0) { + console.log( + "[MEASURE] First marker clicked - attempting to close polygon" + ); + console.log( + "[MEASURE] Before completeShape - markers:", + this._measureHandler._markers + ? this._measureHandler._markers.length + : "N/A" + ); this.options.shapeMode = "polygon"; this.options.currenLine.completeShape(); + console.log("[MEASURE] After completeShape() called"); } - }); + }; + layer.on("click", clickHandler); layer.on("mouseover", (e) => { const coordinates = this._measureHandler._poly._latlngs; const latLngArray = coordinates.map((c) => [c.lat, c.lng]); @@ -421,7 +449,7 @@ L.Control.MeasurePolygon = L.Control.extend({ return iconsWrapper; }, - _UpdateAreaPerimetro: function (layer) { + _UpdateAreaperimeter: function (layer) { const latlngs = layer.getLatLngs()[0]; const options = { diff --git a/playgrounds/measurements-playground/index.html b/playgrounds/measurements-playground/index.html deleted file mode 100644 index 0fb60ec454..0000000000 --- a/playgrounds/measurements-playground/index.html +++ /dev/null @@ -1,16 +0,0 @@ - - - - - Measurements playground - - - - - - - -
- - - diff --git a/playgrounds/measurements-playground/postcss.config.cjs b/playgrounds/measurements-playground/postcss.config.cjs deleted file mode 100644 index a5b8c605be..0000000000 --- a/playgrounds/measurements-playground/postcss.config.cjs +++ /dev/null @@ -1,13 +0,0 @@ -/* 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/playgrounds/measurements-playground/project.json b/playgrounds/measurements-playground/project.json deleted file mode 100644 index e75660e239..0000000000 --- a/playgrounds/measurements-playground/project.json +++ /dev/null @@ -1,68 +0,0 @@ -{ - "name": "measurements-playground", - "$schema": "../../node_modules/nx/schemas/project-schema.json", - "sourceRoot": "playgrounds/measurements-playground/src", - "projectType": "application", - "tags": [], - "targets": { - "build": { - "executor": "@nx/vite:build", - "outputs": ["{options.outputPath}"], - "defaultConfiguration": "production", - "options": { - "outputPath": "dist/playgrounds/measurements-playground" - }, - "configurations": { - "development": { - "mode": "development" - }, - "production": { - "mode": "production" - } - } - }, - "serve": { - "executor": "@nx/vite:dev-server", - "defaultConfiguration": "development", - "options": { - "buildTarget": "measurements-playground:build" - }, - "configurations": { - "development": { - "buildTarget": "measurements-playground:build:development", - "hmr": true - }, - "production": { - "buildTarget": "measurements-playground:build:production", - "hmr": false - } - } - }, - "preview": { - "executor": "@nx/vite:preview-server", - "defaultConfiguration": "development", - "options": { - "buildTarget": "measurements-playground:build" - }, - "configurations": { - "development": { - "buildTarget": "measurements-playground:build:development" - }, - "production": { - "buildTarget": "measurements-playground:build:production" - } - }, - "dependsOn": ["build"] - }, - "test": { - "executor": "@nx/vite:test", - "outputs": ["{options.reportsDirectory}"], - "options": { - "reportsDirectory": "../../coverage/playgrounds/measurements-playground" - } - }, - "lint": { - "executor": "@nx/eslint:lint" - } - } -} diff --git a/playgrounds/measurements-playground/public/favicon.ico b/playgrounds/measurements-playground/public/favicon.ico deleted file mode 100644 index 317ebcb2336e0833a22dddf0ab287849f26fda57..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15086 zcmeI332;U^%p|z7g|#(P)qFEA@4f!_@qOK2 z_lJl}!lhL!VT_U|uN7%8B2iKH??xhDa;*`g{yjTFWHvXn;2s{4R7kH|pKGdy(7z!K zgftM+Ku7~24TLlh(!g)gz|foI94G^t2^IO$uvX$3(OR0<_5L2sB)lMAMy|+`xodJ{ z_Uh_1m)~h?a;2W{dmhM;u!YGo=)OdmId_B<%^V^{ovI@y`7^g1_V9G}*f# zNzAtvou}I!W1#{M^@ROc(BZ! z+F!!_aR&Px3_reO(EW+TwlW~tv*2zr?iP7(d~a~yA|@*a89IUke+c472NXM0wiX{- zl`UrZC^1XYyf%1u)-Y)jj9;MZ!SLfd2Hl?o|80Su%Z?To_=^g_Jt0oa#CT*tjx>BI z16wec&AOWNK<#i0Qd=1O$fymLRoUR*%;h@*@v7}wApDl^w*h}!sYq%kw+DKDY)@&A z@9$ULEB3qkR#85`lb8#WZw=@})#kQig9oqy^I$dj&k4jU&^2(M3q{n1AKeGUKPFbr z1^<)aH;VsG@J|B&l>UtU#Ejv3GIqERzYgL@UOAWtW<{p#zy`WyJgpCy8$c_e%wYJL zyGHRRx38)HyjU3y{-4z6)pzb>&Q1pR)B&u01F-|&Gx4EZWK$nkUkOI|(D4UHOXg_- zw{OBf!oWQUn)Pe(=f=nt=zkmdjpO^o8ZZ9o_|4tW1ni+Un9iCW47*-ut$KQOww!;u z`0q)$s6IZO!~9$e_P9X!hqLxu`fpcL|2f^I5d4*a@Dq28;@2271v_N+5HqYZ>x;&O z05*7JT)mUe&%S0@UD)@&8SmQrMtsDfZT;fkdA!r(S=}Oz>iP)w=W508=Rc#nNn7ym z1;42c|8($ALY8#a({%1#IXbWn9-Y|0eDY$_L&j{63?{?AH{);EzcqfydD$@-B`Y3<%IIj7S7rK_N}je^=dEk%JQ4c z!tBdTPE3Tse;oYF>cnrapWq*o)m47X1`~6@(!Y29#>-#8zm&LXrXa(3=7Z)ElaQqj z-#0JJy3Fi(C#Rx(`=VXtJ63E2_bZGCz+QRa{W0e2(m3sI?LOcUBx)~^YCqZ{XEPX)C>G>U4tfqeH8L(3|pQR*zbL1 zT9e~4Tb5p9_G}$y4t`i*4t_Mr9QYvL9C&Ah*}t`q*}S+VYh0M6GxTTSXI)hMpMpIq zD1ImYqJLzbj0}~EpE-aH#VCH_udYEW#`P2zYmi&xSPs_{n6tBj=MY|-XrA;SGA_>y zGtU$?HXm$gYj*!N)_nQ59%lQdXtQZS3*#PC-{iB_sm+ytD*7j`D*k(P&IH2GHT}Eh z5697eQECVIGQAUe#eU2I!yI&%0CP#>%6MWV z@zS!p@+Y1i1b^QuuEF*13CuB zu69dve5k7&Wgb+^s|UB08Dr3u`h@yM0NTj4h7MnHo-4@xmyr7(*4$rpPwsCDZ@2be zRz9V^GnV;;?^Lk%ynzq&K(Aix`mWmW`^152Hoy$CTYVehpD-S1-W^#k#{0^L`V6CN+E z!w+xte;2vu4AmVNEFUOBmrBL>6MK@!O2*N|2=d|Y;oN&A&qv=qKn73lDD zI(+oJAdgv>Yr}8(&@ZuAZE%XUXmX(U!N+Z_sjL<1vjy1R+1IeHt`79fnYdOL{$ci7 z%3f0A*;Zt@ED&Gjm|OFTYBDe%bbo*xXAQsFz+Q`fVBH!N2)kaxN8P$c>sp~QXnv>b zwq=W3&Mtmih7xkR$YA)1Yi?avHNR6C99!u6fh=cL|KQ&PwF!n@ud^n(HNIImHD!h87!i*t?G|p0o+eelJ?B@A64_9%SBhNaJ64EvKgD&%LjLCYnNfc; znj?%*p@*?dq#NqcQFmmX($wms@CSAr9#>hUR^=I+=0B)vvGX%T&#h$kmX*s=^M2E!@N9#m?LhMvz}YB+kd zG~mbP|D(;{s_#;hsKK9lbVK&Lo734x7SIFJ9V_}2$@q?zm^7?*XH94w5Qae{7zOMUF z^?%F%)c1Y)Q?Iy?I>knw*8gYW#ok|2gdS=YYZLiD=CW|Nj;n^x!=S#iJ#`~Ld79+xXpVmUK^B(xO_vO!btA9y7w3L3-0j-y4 z?M-V{%z;JI`bk7yFDcP}OcCd*{Q9S5$iGA7*E1@tfkyjAi!;wP^O71cZ^Ep)qrQ)N z#wqw0_HS;T7x3y|`P==i3hEwK%|>fZ)c&@kgKO1~5<5xBSk?iZV?KI6&i72H6S9A* z=U(*e)EqEs?Oc04)V-~K5AUmh|62H4*`UAtItO$O(q5?6jj+K^oD!04r=6#dsxp?~}{`?&sXn#q2 zGuY~7>O2=!u@@Kfu7q=W*4egu@qPMRM>(eyYyaIE<|j%d=iWNdGsx%c!902v#ngNg z@#U-O_4xN$s_9?(`{>{>7~-6FgWpBpqXb`Ydc3OFL#&I}Irse9F_8R@4zSS*Y*o*B zXL?6*Aw!AfkNCgcr#*yj&p3ZDe2y>v$>FUdKIy_2N~}6AbHc7gA3`6$g@1o|dE>vz z4pl(j9;kyMsjaw}lO?(?Xg%4k!5%^t#@5n=WVc&JRa+XT$~#@rldvN3S1rEpU$;XgxVny7mki3 z-Hh|jUCHrUXuLr!)`w>wgO0N%KTB-1di>cj(x3Bav`7v z3G7EIbU$z>`Nad7Rk_&OT-W{;qg)-GXV-aJT#(ozdmnA~Rq3GQ_3mby(>q6Ocb-RgTUhTN)))x>m&eD;$J5Bg zo&DhY36Yg=J=$Z>t}RJ>o|@hAcwWzN#r(WJ52^g$lh^!63@hh+dR$&_dEGu&^CR*< z!oFqSqO@>xZ*nC2oiOd0eS*F^IL~W-rsrO`J`ej{=ou_q^_(<$&-3f^J z&L^MSYWIe{&pYq&9eGaArA~*kA void; -}) { - const { responsiveState, gap, windowSize } = useContext( - ResponsiveTopicMapContext - ) as any; - useSelectionTopicMap(); - const [selectedFeature, setSelectedFeature] = useState(undefined); - const { maplibreMap, setMaplibreMap } = useMapLibreMap(); - const [maplibreMaps, setMaplibreMaps] = useState([]); - const { mode: measurementMode, setMode: setMeasurementMode } = - useMapMeasurementsContext(); - const { zoomToFeature } = useContext(TopicMapDispatchContext) as any; - const { snappingEnabled, setSnappingEnabled } = useContext(SnappingContext); - - const pixelwidth = - responsiveState === "normal" ? "300px" : (windowSize?.width || 300) - gap; - - let links: any[] = []; - if (selectedFeature) { - links = getActionLinksForFeature(selectedFeature, { - displayZoomToFeature: true, - zoomToFeature: () => { - if (selectedFeature) { - const f = JSON.stringify(selectedFeature, null, 2); - const pf = JSON.parse(f); - pf.crs = { - type: "name", - properties: { - name: "urn:ogc:def:crs:EPSG::4326", - }, - }; - console.log("xxx zoomToFeature", pf); - - zoomToFeature(pf); - } - }, - }); - } - - return ( -
- - - - - - -
- -
-
- -
- {/* First row: Snapping toggle and Clear button */} -
-
- setSnappingEnabled(e.target.checked)} - style={{ cursor: "pointer" }} - /> - -
- - {vectorStyles.length > 0 && ( - <> -
- - - )} -
- - {/* Second row: Vector layer links */} - - - - - - - - {vectorStyles.map((style, index) => { - return ( - { - setMaplibreMap(map); - setMaplibreMaps((prev) => [...prev, map]); - }, - }} - /> - ); - })} - -
- ); -} diff --git a/playgrounds/measurements-playground/src/app/components/ModeButtons.tsx b/playgrounds/measurements-playground/src/app/components/ModeButtons.tsx deleted file mode 100644 index 8cd8d56e2a..0000000000 --- a/playgrounds/measurements-playground/src/app/components/ModeButtons.tsx +++ /dev/null @@ -1,70 +0,0 @@ -/** - * Mode selection buttons component - */ - -type Mode = - | "features" - | "coordinates" - | "coordinatesUnderPointer" - | "spider" - | "spiderRocket" - | "serious"; - -interface ModeButtonsProps { - mode: Mode | null; - onModeChange: (mode: Mode) => void; -} - -export function ModeButtons({ mode, onModeChange }: ModeButtonsProps) { - const buttonStyle = (isActive: boolean) => ({ - padding: "8px 12px", - border: "1px solid #ccc", - borderRadius: "4px", - background: isActive ? "#4CAF50" : "white", - color: isActive ? "white" : "black", - cursor: "pointer", - fontSize: "14px", - whiteSpace: "nowrap" as const, - }); - - return ( - <> - - - - - - - - ); -} diff --git a/playgrounds/measurements-playground/src/app/components/RadiusSliders.tsx b/playgrounds/measurements-playground/src/app/components/RadiusSliders.tsx deleted file mode 100644 index 5f922ae7ed..0000000000 --- a/playgrounds/measurements-playground/src/app/components/RadiusSliders.tsx +++ /dev/null @@ -1,87 +0,0 @@ -/** - * Radius sliders component for query and tolerance radius - */ - -import { Slider } from "antd"; - -type Mode = - | "features" - | "coordinates" - | "coordinatesUnderPointer" - | "spider" - | "spiderRocket" - | "serious"; - -interface RadiusSlidersProps { - queryRadius: number; - toleranceRadius: number; - mode: Mode | null; - hasVectorLayer: boolean; - onQueryRadiusChange: (value: number) => void; - onToleranceRadiusChange: (value: number) => void; -} - -export function RadiusSliders({ - queryRadius, - toleranceRadius, - mode, - hasVectorLayer, - onQueryRadiusChange, - onToleranceRadiusChange, -}: RadiusSlidersProps) { - const showToleranceSlider = - hasVectorLayer && - (mode === "spider" || mode === "spiderRocket" || mode === "serious"); - - return ( - <> - {/* Query Radius Slider */} -
- - onQueryRadiusChange(value)} - /> -
- - {/* Tolerance Radius Slider - only show for spider, spiderRocket, and serious modes */} - {showToleranceSlider && ( -
- - onToleranceRadiusChange(value)} - /> -
- )} - - ); -} diff --git a/playgrounds/measurements-playground/src/app/components/VectorLayerButton.tsx b/playgrounds/measurements-playground/src/app/components/VectorLayerButton.tsx deleted file mode 100644 index 86c3a707a8..0000000000 --- a/playgrounds/measurements-playground/src/app/components/VectorLayerButton.tsx +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Garbage can button to clear saved vector layer - */ - -interface VectorLayerButtonProps { - hasSavedVectorStyle: boolean; - onClear: () => void; -} - -export function VectorLayerButton({ - hasSavedVectorStyle, - onClear, -}: VectorLayerButtonProps) { - if (!hasSavedVectorStyle) { - return null; - } - - return ( - - ); -} diff --git a/playgrounds/measurements-playground/src/app/helper/constants.js b/playgrounds/measurements-playground/src/app/helper/constants.js deleted file mode 100644 index 479fb27085..0000000000 --- a/playgrounds/measurements-playground/src/app/helper/constants.js +++ /dev/null @@ -1 +0,0 @@ -export const host = import.meta.env.VITE_WUPP_ASSET_BASEURL; diff --git a/playgrounds/measurements-playground/src/app/store/index.ts b/playgrounds/measurements-playground/src/app/store/index.ts deleted file mode 100644 index 5bfe910507..0000000000 --- a/playgrounds/measurements-playground/src/app/store/index.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { configureStore } from "@reduxjs/toolkit"; - -import { createLogger } from "redux-logger"; -import { persistReducer } from "redux-persist"; -import localForage from "localforage"; - -import mappingReducer from "./slices/mapping"; -import uiReducer from "./slices/ui"; -// import measurementsReducer from "./slices/measurements"; - -console.info("store initializing ...."); - -const devToolsEnabled = - new URLSearchParams(window.location.search).get("devToolsEnabled") === "true"; -console.debug("devToolsEnabled:", devToolsEnabled); -const stateLoggingEnabledFromSearch = new URLSearchParams( - window.location.search -).get("stateLoggingEnabled"); - -const inProduction = process.env.NODE_ENV === "production"; - -console.info("in Production Mode:", inProduction); -const stateLoggingEnabled = - (stateLoggingEnabledFromSearch !== null && - stateLoggingEnabledFromSearch !== "false") || - !inProduction; - -console.info( - "stateLoggingEnabled:", - stateLoggingEnabledFromSearch, - "x", - stateLoggingEnabled -); -const logger = createLogger({ - collapsed: true, -}); - -let middleware; -if (stateLoggingEnabled === true) { - middleware = (getDefaultMiddleware) => - getDefaultMiddleware({ - serializableCheck: false, - }).concat(logger); -} else { - middleware = (getDefaultMiddleware) => - getDefaultMiddleware({ - serializableCheck: false, - }); -} - -const uiConfig = { - key: "@measurements-playground.app.config", - storage: localForage, - whitelist: [], -}; - -const mappingConfig = { - key: "@measurements-playground.app.mapping", - storage: localForage, - whitelist: [], -}; - -// const measurementsConfig = { -// key: "@measurements-playground.app.measurements", -// storage: localForage, -// whitelist: ["shapes"], -// }; - -const store = configureStore({ - reducer: { - mapping: persistReducer(mappingConfig, mappingReducer), - ui: persistReducer(uiConfig, uiReducer), - // measurements: persistReducer(measurementsConfig, measurementsReducer), - }, - devTools: devToolsEnabled === true && inProduction === false, - middleware, -}); - -export type AppStore = typeof store; -export type RootState = ReturnType; -export type AppDispatch = AppStore["dispatch"]; - -export default store; diff --git a/playgrounds/measurements-playground/src/app/store/slices/mapping.ts b/playgrounds/measurements-playground/src/app/store/slices/mapping.ts deleted file mode 100644 index 1a9e2ed735..0000000000 --- a/playgrounds/measurements-playground/src/app/store/slices/mapping.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { createSlice } from "@reduxjs/toolkit"; -import type { PayloadAction } from "@reduxjs/toolkit"; - -import { RootState } from ".."; - -const initialState = { - startDrawing: false, -}; - -const slice = createSlice({ - name: "mapping", - initialState, - reducers: { - setStartDrawing(state, action: PayloadAction) { - state.startDrawing = action.payload; - }, - }, -}); - -export const { setStartDrawing } = slice.actions; -export const getStartDrawing = (state: RootState) => state.mapping.startDrawing; - -export default slice.reducer; diff --git a/playgrounds/measurements-playground/src/app/store/slices/ui.ts b/playgrounds/measurements-playground/src/app/store/slices/ui.ts deleted file mode 100644 index 2e2d59c46f..0000000000 --- a/playgrounds/measurements-playground/src/app/store/slices/ui.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { createSlice } from "@reduxjs/toolkit"; - -import type { PayloadAction } from "@reduxjs/toolkit"; - -import { RootState } from ".."; - -export enum UIMode { - DEFAULT = "default", - FEATURE_INFO = "featureInfo", - MEASUREMENT = "measurement", - PRINT = "print", -} - -export interface UIState { - mode: UIMode; -} - -const initialState: UIState = { - mode: UIMode.DEFAULT, -}; - -const slice = createSlice({ - name: "ui", - initialState, - reducers: { - setUIMode(state, action) { - state.mode = action.payload; - }, - toggleUIMode(state, action: PayloadAction) { - if (state.mode === action.payload) { - state.mode = UIMode.DEFAULT; - } else { - state.mode = action.payload; - } - }, - }, -}); - -export const { setUIMode, toggleUIMode } = slice.actions; - -export const getUIMode = (state: RootState) => state.ui.mode; - -export default slice.reducer; diff --git a/playgrounds/measurements-playground/src/app/utils/coordinateExtraction.ts b/playgrounds/measurements-playground/src/app/utils/coordinateExtraction.ts deleted file mode 100644 index 37d28b8d50..0000000000 --- a/playgrounds/measurements-playground/src/app/utils/coordinateExtraction.ts +++ /dev/null @@ -1,178 +0,0 @@ -/** - * Utility functions for extracting and processing coordinates from GeoJSON features - */ - -/** - * Extracts all coordinates from a collection of GeoJSON features as individual points - */ -export function extractCoordinatesFromFeatures( - features: any[], - properties: Record = {} -): any[] { - const coordinatePoints: any[] = []; - - features.forEach((feature: any) => { - const geometry = feature.geometry; - - // Extract coordinates based on geometry type - if (geometry.type === "Point") { - coordinatePoints.push({ - type: "Feature", - geometry: { - type: "Point", - coordinates: geometry.coordinates, - }, - properties: { ...properties }, - }); - } else if (geometry.type === "LineString") { - geometry.coordinates.forEach((coord: any) => { - coordinatePoints.push({ - type: "Feature", - geometry: { - type: "Point", - coordinates: coord, - }, - properties: { ...properties }, - }); - }); - } else if (geometry.type === "Polygon") { - geometry.coordinates.forEach((ring: any) => { - ring.forEach((coord: any) => { - coordinatePoints.push({ - type: "Feature", - geometry: { - type: "Point", - coordinates: coord, - }, - properties: { ...properties }, - }); - }); - }); - } else if (geometry.type === "MultiPoint") { - geometry.coordinates.forEach((coord: any) => { - coordinatePoints.push({ - type: "Feature", - geometry: { - type: "Point", - coordinates: coord, - }, - properties: { ...properties }, - }); - }); - } else if (geometry.type === "MultiLineString") { - geometry.coordinates.forEach((line: any) => { - line.forEach((coord: any) => { - coordinatePoints.push({ - type: "Feature", - geometry: { - type: "Point", - coordinates: coord, - }, - properties: { ...properties }, - }); - }); - }); - } else if (geometry.type === "MultiPolygon") { - geometry.coordinates.forEach((polygon: any) => { - polygon.forEach((ring: any) => { - ring.forEach((coord: any) => { - coordinatePoints.push({ - type: "Feature", - geometry: { - type: "Point", - coordinates: coord, - }, - properties: { ...properties }, - }); - }); - }); - }); - } - }); - - return coordinatePoints; -} - -/** - * Filters points to only those within a specified pixel radius from a center point - */ -export function filterPointsByRadius( - points: any[], - centerPoint: { x: number; y: number }, - radius: number, - maplibreMap: any -): any[] { - return points.filter((pointFeature: any) => { - const coord = pointFeature.geometry.coordinates; - const projectedPoint = maplibreMap.project(coord); - - const dx = projectedPoint.x - centerPoint.x; - const dy = projectedPoint.y - centerPoint.y; - const distance = Math.sqrt(dx * dx + dy * dy); - - return distance <= radius; - }); -} - -/** - * Calculates distances for points and returns them with distance information - */ -export function calculatePointDistances( - points: any[], - centerPoint: { x: number; y: number }, - maplibreMap: any -): Array<{ pointFeature: any; distance: number }> { - return points.map((pointFeature: any) => { - const coord = pointFeature.geometry.coordinates; - const projectedPoint = maplibreMap.project(coord); - - const dx = projectedPoint.x - centerPoint.x; - const dy = projectedPoint.y - centerPoint.y; - const distance = Math.sqrt(dx * dx + dy * dy); - - return { pointFeature, distance }; - }); -} - -/** - * Finds the closest point from a list of points with distances - */ -export function findClosestPoint( - pointsWithDistance: Array<{ pointFeature: any; distance: number }> -): { index: number; distance: number } | null { - let shortestDistance = Infinity; - let shortestIndex = -1; - - pointsWithDistance.forEach((item: any, index: number) => { - if (item.distance < shortestDistance) { - shortestDistance = item.distance; - shortestIndex = index; - } - }); - - if (shortestIndex === -1) { - return null; - } - - return { index: shortestIndex, distance: shortestDistance }; -} - -/** - * Creates spider lines from a center point to multiple target points - */ -export function createSpiderLines( - centerCoords: [number, number], - targetPoints: any[], - properties: Record = {} -): any[] { - return targetPoints.map((pointFeature: any) => { - return { - type: "Feature", - geometry: { - type: "LineString", - coordinates: [centerCoords, pointFeature.geometry.coordinates], - }, - properties: { ...properties }, - }; - }); -} diff --git a/playgrounds/measurements-playground/src/assets/.gitkeep b/playgrounds/measurements-playground/src/assets/.gitkeep deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/playgrounds/measurements-playground/src/main.tsx b/playgrounds/measurements-playground/src/main.tsx deleted file mode 100644 index 9e5033fa2d..0000000000 --- a/playgrounds/measurements-playground/src/main.tsx +++ /dev/null @@ -1,228 +0,0 @@ -import { StrictMode, useEffect, useState, createContext } from "react"; -import * as ReactDOM from "react-dom/client"; -import { Provider, useSelector, useDispatch } from "react-redux"; -import { TopicMapContextProvider } from "react-cismap/contexts/TopicMapContextProvider"; -import store from "./app/store"; -import type { AppDispatch } from "./app/store"; - -import { App } from "./app/App"; - -import "./styles.css"; -import "bootstrap/dist/css/bootstrap.min.css"; -import "react-bootstrap-typeahead/css/Typeahead.css"; -import "leaflet/dist/leaflet.css"; -import { PersistGate } from "redux-persist/integration/react"; -import { persistStore } from "redux-persist"; -import { - MapMeasurementsProvider, - MEASUREMENT_MODE, -} from "@carma-commons/measurements"; -import { - GazDataProvider, - SelectionProvider, -} from "@carma-appframeworks/portals"; -import { getUIMode, setUIMode, UIMode } from "./app/store/slices/ui"; - -// Context for snapping control -export const SnappingContext = createContext<{ - snappingEnabled: boolean; - setSnappingEnabled: (enabled: boolean) => void; -}>({ - snappingEnabled: true, - setSnappingEnabled: () => {}, -}); - -// Wrapper component to connect Redux to MapMeasurementsProvider -const MeasurementsProviderWrapper = ({ - children, -}: { - children: React.ReactNode; -}) => { - const uiMode = useSelector(getUIMode); - const dispatch = useDispatch(); - const [snappingEnabled, setSnappingEnabled] = useState(true); - - const measurementsConfig = { - // Only override what you want to change - editableTitle: true, - snappingEnabled: snappingEnabled, - snappingOnUpdate: false, - snappingRadiusVisible: true, - debugOutputMapStatus: true, - localStorageKey: "@MEASUREMENT_PLAYGROUNDY.app.measurements", - - // infoBoxHeaderColor: "#22c55e", - }; - - const mode = - uiMode === UIMode.MEASUREMENT - ? MEASUREMENT_MODE.MEASUREMENT - : MEASUREMENT_MODE.DEFAULT; - const handleSetMode = (newMode: MEASUREMENT_MODE) => { - const newUIMode = - newMode === MEASUREMENT_MODE.MEASUREMENT - ? UIMode.MEASUREMENT - : UIMode.DEFAULT; - dispatch(setUIMode(newUIMode)); - }; - - return ( - - - {children} - - - ); -}; - -// Root component with drag-and-drop functionality -const RootComponent = () => { - const [vectorStylesArray, setVectorStylesArray] = useState(() => { - // Load from localStorage on mount - const saved = localStorage.getItem("measurements-vector-style"); - if (saved) { - try { - const parsed = JSON.parse(saved); - // Handle both old format (single object) and new format (array) - return Array.isArray(parsed) ? parsed : [parsed]; - } catch (e) { - console.error("Failed to parse saved vector style:", e); - return []; - } - } - return []; - }); - - useEffect(() => { - const handleDrop = async (event: DragEvent) => { - event.preventDefault(); - - const url = event.dataTransfer?.getData("URL"); - console.log("handleDrop", url); - - if (url) { - try { - // Fetch the content of the URL - const response = await fetch(url); - - if (response.ok) { - const contentType = response.headers.get("Content-Type"); - - if (contentType?.includes("application/json")) { - const jsonData = await response.json(); - console.log("JSON content fetched:", jsonData); - - // Add to existing layers instead of replacing - setVectorStylesArray((prev) => { - const updatedArray = [...prev, jsonData]; - // Save updated array to localStorage - localStorage.setItem( - "measurements-vector-style", - JSON.stringify(updatedArray) - ); - return updatedArray; - }); - } else { - console.warn("The content is not JSON"); - } - } else { - console.error("Failed to fetch the URL:", response.statusText); - } - } catch (error) { - console.error("Error fetching URL:", error); - } - } else if ( - event.dataTransfer?.files && - event.dataTransfer.files.length > 0 - ) { - // Handle file drop - const file = event.dataTransfer.files[0]; // Get the first dropped file - console.log("File dropped:", file.name, file); - - const reader = new FileReader(); - reader.onload = (e) => { - try { - // Attempt to parse the file content as JSON - const fileContent = e.target?.result; - if (typeof fileContent === "string") { - const processedContent = fileContent.replace( - /__SERVER_URL__/g, - "https://tiles.cismet.de" - ); - - const jsonData = JSON.parse(processedContent); - console.log("Parsed JSON from file:", jsonData); - - // Add to existing layers instead of replacing - setVectorStylesArray((prev) => { - const updatedArray = [...prev, jsonData]; - // Save updated array to localStorage - localStorage.setItem( - "measurements-vector-style", - JSON.stringify(updatedArray) - ); - return updatedArray; - }); - } - } catch (error) { - console.error("Failed to parse the file as JSON:", error); - } - }; - - reader.readAsText(file); // Read the file as text - } - }; - - const handleDragOver = (event: DragEvent) => { - event.preventDefault(); - }; - - window.addEventListener("drop", handleDrop); - window.addEventListener("dragover", handleDragOver); - - return () => { - window.removeEventListener("drop", handleDrop); - window.removeEventListener("dragover", handleDragOver); - }; - }, []); - - const clearAllVectorLayers = () => { - setVectorStylesArray([]); - localStorage.removeItem("measurements-vector-style"); - }; - - return ( - - ); -}; - -const persistor = persistStore(store); - -const root = ReactDOM.createRoot( - document.getElementById("root") as HTMLElement -); -root.render( - - - - - - - - - - - - - - - -); diff --git a/playgrounds/measurements-playground/src/styles.css b/playgrounds/measurements-playground/src/styles.css deleted file mode 100644 index db82083e9c..0000000000 --- a/playgrounds/measurements-playground/src/styles.css +++ /dev/null @@ -1,33 +0,0 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; - -.leaflet-control * img { - display: initial; -} - -.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; -} diff --git a/playgrounds/measurements-playground/tailwind.config.cjs b/playgrounds/measurements-playground/tailwind.config.cjs deleted file mode 100644 index c70a51dd3e..0000000000 --- a/playgrounds/measurements-playground/tailwind.config.cjs +++ /dev/null @@ -1,15 +0,0 @@ -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/playgrounds/measurements-playground/tsconfig.json b/playgrounds/measurements-playground/tsconfig.json deleted file mode 100644 index ca5385bfa4..0000000000 --- a/playgrounds/measurements-playground/tsconfig.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "extends": "../../tsconfig.legacy.base.json", - "compilerOptions": {}, - "files": [], - "references": [] -} diff --git a/playgrounds/measurements-playground/tsconfig.spec.json b/playgrounds/measurements-playground/tsconfig.spec.json deleted file mode 100644 index d6054e7f30..0000000000 --- a/playgrounds/measurements-playground/tsconfig.spec.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "outDir": "../../dist/out-tsc", - "types": [ - "vitest/globals", - "vitest/importMeta", - "vite/client", - "node", - "vitest", - "@nx/react/typings/cssmodule.d.ts", - "@nx/react/typings/image.d.ts" - ] - }, - "include": [ - "vite.config.ts", - "vitest.config.ts", - "src/**/*.test.ts", - "src/**/*.spec.ts", - "src/**/*.test.tsx", - "src/**/*.spec.tsx", - "src/**/*.test.js", - "src/**/*.spec.js", - "src/**/*.test.jsx", - "src/**/*.spec.jsx", - "src/**/*.d.ts" - ] -} diff --git a/playgrounds/measurements-playground/vite.config.ts b/playgrounds/measurements-playground/vite.config.ts deleted file mode 100644 index ee050b1a48..0000000000 --- a/playgrounds/measurements-playground/vite.config.ts +++ /dev/null @@ -1,55 +0,0 @@ -/// -import { defineConfig } from "vite"; -import react from "@vitejs/plugin-react"; -import { nxViteTsPaths } from "@nx/vite/plugins/nx-tsconfig-paths.plugin"; - -// Use an environment variable to set the base URL -const base = process.env.BASE_URL || "/"; - -export default defineConfig({ - root: __dirname, - cacheDir: "../../node_modules/.vite/playgrounds/fuzzy-search-playground", - - server: { - port: 4200, - host: "localhost", - fs: { - allow: ["../../"], - }, - }, - - preview: { - port: 4300, - host: "localhost", - }, - - plugins: [react(), nxViteTsPaths()], - base: base, - // Uncomment this if you are using workers. - // worker: { - // plugins: [ nxViteTsPaths() ], - // }, - - build: { - outDir: "../../dist/playgrounds/fuzzy-search-playground", - 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/playgrounds/stadtplan-playground", - provider: "v8", - }, - }, -}); From 0d8069ea94e2d3ec7f6054dddbd3aa893a1be8e0 Mon Sep 17 00:00:00 2001 From: Friedrich Hartmann Date: Mon, 1 Dec 2025 17:49:26 +0100 Subject: [PATCH 02/20] add more logging for clicks --- .../measurements/src/lib/hooks/useMeasurements.ts | 9 +++++++++ libraries/commons/measurements/src/lib/utils/helper.ts | 7 +++++++ .../commons/measurements/src/lib/utils/measure-path.ts | 1 + 3 files changed, 17 insertions(+) diff --git a/libraries/commons/measurements/src/lib/hooks/useMeasurements.ts b/libraries/commons/measurements/src/lib/hooks/useMeasurements.ts index 7c04b5342a..109e0320bc 100644 --- a/libraries/commons/measurements/src/lib/hooks/useMeasurements.ts +++ b/libraries/commons/measurements/src/lib/hooks/useMeasurements.ts @@ -755,6 +755,15 @@ export const useMeasurements = (snappingLayers: MapLibreMap[] = []) => { // Only adjust if snapping is enabled // Snapping is always enabled now const snapPoint = closestPointRef.current; + + console.log("[snapping] mouseupHandler called", { + hasSnapPoint: !!snapPoint, + snapCoords: snapPoint?.geometry?.coordinates, + mouseX: event.clientX, + mouseY: event.clientY, + timestamp: Date.now(), + }); + adjustClickPosition( event, snapPoint, diff --git a/libraries/commons/measurements/src/lib/utils/helper.ts b/libraries/commons/measurements/src/lib/utils/helper.ts index ad54dcc40c..aa590749c3 100644 --- a/libraries/commons/measurements/src/lib/utils/helper.ts +++ b/libraries/commons/measurements/src/lib/utils/helper.ts @@ -86,10 +86,17 @@ export const adjustClickPosition = ( } // Fire a new click event with shifted coordinates on the map + console.log("[snapping] Firing synthetic Leaflet event", { + eventType, + latlng: finalLatLng, + originalCoords: [domEvent.clientX, domEvent.clientY], + }); + leafletMap.fire(eventType, { latlng: finalLatLng, containerPoint: shiftedContainerPoint, originalEvent: domEvent, + _isSyntheticSnap: true, // Mark as synthetic snap event }); return false; diff --git a/libraries/commons/measurements/src/lib/utils/measure-path.ts b/libraries/commons/measurements/src/lib/utils/measure-path.ts index 104685e4e9..38a22d0c49 100644 --- a/libraries/commons/measurements/src/lib/utils/measure-path.ts +++ b/libraries/commons/measurements/src/lib/utils/measure-path.ts @@ -485,6 +485,7 @@ export const MeasurePolygon = Control.extend({ clickAfterShapeSelection: this.options.clickAfterShapeSelection, isFinishingShape: (this as any)._isFinishingShape, eventType: event.originalEvent?.type, + isSyntheticSnap: !!(event as any)._isSyntheticSnap, targetClassName: (event.originalEvent?.target as HTMLElement) ?.className, latlng: event.latlng, From c4000475188c6af1c74c737a27d2da339cce363c Mon Sep 17 00:00:00 2001 From: Friedrich Hartmann Date: Tue, 2 Dec 2025 12:01:43 +0100 Subject: [PATCH 03/20] stop circle marker from capturing clicks --- .../src/lib/hooks/useMeasurements.ts | 2 ++ .../measurements/src/lib/utils/measure-path.ts | 16 ++++++++++++---- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/libraries/commons/measurements/src/lib/hooks/useMeasurements.ts b/libraries/commons/measurements/src/lib/hooks/useMeasurements.ts index 109e0320bc..14987a16f1 100644 --- a/libraries/commons/measurements/src/lib/hooks/useMeasurements.ts +++ b/libraries/commons/measurements/src/lib/hooks/useMeasurements.ts @@ -488,6 +488,7 @@ export const useMeasurements = (snappingLayers: MapLibreMap[] = []) => { fillOpacity: 0.8, weight: 1, opacity: 0.8, + interactive: false, } ).addTo(leafletMap); } @@ -641,6 +642,7 @@ export const useMeasurements = (snappingLayers: MapLibreMap[] = []) => { fillOpacity: 0.8, weight: 1, opacity: 0.8, + interactive: false, // Don't capture mouse events } ).addTo(leafletMap); } diff --git a/libraries/commons/measurements/src/lib/utils/measure-path.ts b/libraries/commons/measurements/src/lib/utils/measure-path.ts index 38a22d0c49..bc6f3867f1 100644 --- a/libraries/commons/measurements/src/lib/utils/measure-path.ts +++ b/libraries/commons/measurements/src/lib/utils/measure-path.ts @@ -211,13 +211,21 @@ export const MeasurePolygon = Control.extend({ const originalAddVertex = (this._measureHandler as any).addVertex; if (originalAddVertex) { (this._measureHandler as any).addVertex = function (latlng) { - console.log("[measure-path] addVertex() called", { - latlng, + // Use snapped position if available + const finalLatlng = + self.options.snappingEnabled && self.options.snappingLatlng + ? self.options.snappingLatlng + : latlng; + + console.debug("[measure-path] addVertex() called", { + original: latlng, + snapped: self.options.snappingLatlng, + final: finalLatlng, currentVertexCount: this._markers?.length || 0, timestamp: Date.now(), }); (self as any)._lastVertexAdded = Date.now(); - return originalAddVertex.apply(this, arguments); + return originalAddVertex.call(this, finalLatlng); }; } @@ -479,7 +487,7 @@ export const MeasurePolygon = Control.extend({ this._mapClickHandler = (event) => { const mode = this.options.measurementMode; - console.log("[measure-path] Map clicked", { + console.log("[measure-path] Map clicked", this.options, { isDrawing: this.options.isDrawing, mode, clickAfterShapeSelection: this.options.clickAfterShapeSelection, From b72c1de94626363d0bcb0382d2972bf97ebf5600 Mon Sep 17 00:00:00 2001 From: Friedrich Hartmann Date: Tue, 2 Dec 2025 12:33:19 +0100 Subject: [PATCH 04/20] fix build error, fix logged type --- .../commons/measurements/src/lib/hooks/useMeasurements.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libraries/commons/measurements/src/lib/hooks/useMeasurements.ts b/libraries/commons/measurements/src/lib/hooks/useMeasurements.ts index 14987a16f1..e81c1d9801 100644 --- a/libraries/commons/measurements/src/lib/hooks/useMeasurements.ts +++ b/libraries/commons/measurements/src/lib/hooks/useMeasurements.ts @@ -758,9 +758,9 @@ export const useMeasurements = (snappingLayers: MapLibreMap[] = []) => { // Snapping is always enabled now const snapPoint = closestPointRef.current; - console.log("[snapping] mouseupHandler called", { + console.debug("[snapping] mouseupHandler called", { hasSnapPoint: !!snapPoint, - snapCoords: snapPoint?.geometry?.coordinates, + snapCoords: snapPoint?.coordinates, mouseX: event.clientX, mouseY: event.clientY, timestamp: Date.now(), From 68d74ec0e8a9298ec218ae0fb15bebc3d3491476 Mon Sep 17 00:00:00 2001 From: Friedrich Hartmann Date: Tue, 2 Dec 2025 17:14:00 +0100 Subject: [PATCH 05/20] fix snapping issue --- .../src/lib/hooks/useMeasurements.ts | 39 ++++++++++--------- .../measurements/src/lib/utils/helper.ts | 2 +- 2 files changed, 21 insertions(+), 20 deletions(-) diff --git a/libraries/commons/measurements/src/lib/hooks/useMeasurements.ts b/libraries/commons/measurements/src/lib/hooks/useMeasurements.ts index e81c1d9801..e4e2b30110 100644 --- a/libraries/commons/measurements/src/lib/hooks/useMeasurements.ts +++ b/libraries/commons/measurements/src/lib/hooks/useMeasurements.ts @@ -13,7 +13,6 @@ import "../utils/measure-path"; import useDeviceDetection from "../hooks/useDeviceDetection"; import { useMapMeasurementsContext } from "../context"; import { - adjustClickPosition, toLatLngFromClosestPoint, filterArrByIds, findLargestNumber, @@ -751,30 +750,32 @@ export const useMeasurements = (snappingLayers: MapLibreMap[] = []) => { leafletMap.on("editable:vertex:dragend", vertexDragEndHandler); - // Add DOM listener in CAPTURE phase to intercept before Leaflet + // click handler for snapped vertices const mapContainer = leafletMap.getContainer(); - const mouseupHandler = (event: MouseEvent) => { - // Only adjust if snapping is enabled - // Snapping is always enabled now + const clickHandler = (event: MouseEvent) => { const snapPoint = closestPointRef.current; + if (!snapPoint) return; // No snap point, let normal click handling proceed - console.debug("[snapping] mouseupHandler called", { - hasSnapPoint: !!snapPoint, - snapCoords: snapPoint?.coordinates, - mouseX: event.clientX, - mouseY: event.clientY, + const drawHandler = currentDrawHandlerRef.current; + if (!drawHandler || !drawHandler.addVertex) return; // Not drawing + + // Get snapped latlng + const snappedLatlng = toLatLngFromClosestPoint(snapPoint); + if (!snappedLatlng) return; + + console.debug("[snapping] Direct addVertex from click", { + snappedLatlng, timestamp: Date.now(), }); - adjustClickPosition( - event, - snapPoint, - "mouseup", - leafletMap, - currentDrawHandlerRef.current - ); + // Directly add vertex at snapped position + drawHandler.addVertex(snappedLatlng); + + // Stop the original click from also adding a vertex + event.stopPropagation(); + event.stopImmediatePropagation(); }; - mapContainer.addEventListener("mouseup", mouseupHandler, true); + mapContainer.addEventListener("click", clickHandler, true); // Keydown/keyup handlers for ALT key const handleKeyToggle = (isPressed: boolean) => { @@ -827,7 +828,7 @@ export const useMeasurements = (snappingLayers: MapLibreMap[] = []) => { leafletMap.off("mouseout", mouseoutHandler); leafletMap.off("editable:vertex:drag", vertexDragHandler); leafletMap.off("editable:vertex:dragend", vertexDragEndHandler); - mapContainer.removeEventListener("mouseup", mouseupHandler, true); + mapContainer.removeEventListener("click", clickHandler, true); document.removeEventListener("keydown", keydownHandler); document.removeEventListener("keyup", keyupHandler); if (circleMarkerRef.current) { diff --git a/libraries/commons/measurements/src/lib/utils/helper.ts b/libraries/commons/measurements/src/lib/utils/helper.ts index aa590749c3..9a79a71b39 100644 --- a/libraries/commons/measurements/src/lib/utils/helper.ts +++ b/libraries/commons/measurements/src/lib/utils/helper.ts @@ -99,7 +99,7 @@ export const adjustClickPosition = ( _isSyntheticSnap: true, // Mark as synthetic snap event }); - return false; + return true; // Return true to indicate we handled the snap (caller should stop propagation) }; // Prepare a Leaflet LatLng from a GeoJSON Point-like feature with coordinates [lng, lat] From 3db4606d0f1ca592e3d16643d1463a272e5a8886 Mon Sep 17 00:00:00 2001 From: Friedrich Hartmann Date: Tue, 2 Dec 2025 17:21:50 +0100 Subject: [PATCH 06/20] fix closing issue --- .../src/lib/hooks/useMeasurements.ts | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/libraries/commons/measurements/src/lib/hooks/useMeasurements.ts b/libraries/commons/measurements/src/lib/hooks/useMeasurements.ts index e4e2b30110..75d6613c65 100644 --- a/libraries/commons/measurements/src/lib/hooks/useMeasurements.ts +++ b/libraries/commons/measurements/src/lib/hooks/useMeasurements.ts @@ -763,6 +763,39 @@ export const useMeasurements = (snappingLayers: MapLibreMap[] = []) => { const snappedLatlng = toLatLngFromClosestPoint(snapPoint); if (!snappedLatlng) return; + // Check if snapping to first vertex (polygon closure) + if (drawHandler._poly && drawHandler._poly._latlngs) { + const latlngs = drawHandler._poly._latlngs; + if (latlngs.length >= 3) { + const firstVertex = latlngs[0]; + // Compare snapped position with first vertex + const exactMatchThreshold = 1e-10; + if ( + Math.abs(snappedLatlng.lat - firstVertex.lat) < + exactMatchThreshold && + Math.abs(snappedLatlng.lng - firstVertex.lng) < + exactMatchThreshold + ) { + // Click on first vertex marker to close polygon + if (drawHandler._markers && drawHandler._markers.length > 0) { + const firstMarker = drawHandler._markers[0]; + if (firstMarker) { + console.debug( + "[snapping] Closing polygon via first vertex click" + ); + firstMarker.fire("click", { + latlng: firstVertex, + target: firstMarker, + }); + event.stopPropagation(); + event.stopImmediatePropagation(); + return; + } + } + } + } + } + console.debug("[snapping] Direct addVertex from click", { snappedLatlng, timestamp: Date.now(), From af929bb7837f3264bb6228ed7730694a7314df96 Mon Sep 17 00:00:00 2001 From: Friedrich Hartmann Date: Tue, 2 Dec 2025 17:46:49 +0100 Subject: [PATCH 07/20] dedupe distance calculations and use turf --- .../lib/context/MapMeasurementsContext.d.ts | 2 + .../lib/context/MapMeasurementsContext.tsx | 1 + .../lib/context/MapMeasurementsProvider.tsx | 1 + .../src/lib/hooks/useMeasurements.ts | 183 ++++++++---------- .../measurements/src/lib/utils/helper.ts | 105 ++++++---- 5 files changed, 152 insertions(+), 140 deletions(-) diff --git a/libraries/commons/measurements/src/lib/context/MapMeasurementsContext.d.ts b/libraries/commons/measurements/src/lib/context/MapMeasurementsContext.d.ts index 2fb7a60516..0314a40ac1 100644 --- a/libraries/commons/measurements/src/lib/context/MapMeasurementsContext.d.ts +++ b/libraries/commons/measurements/src/lib/context/MapMeasurementsContext.d.ts @@ -21,6 +21,8 @@ export interface MeasurementConfig { snappingQueryRadius: number; snappingMinZoom: number; snappingRadiusVisible: boolean; + /** Minimum distance in meters to consider two points identical (default 0.1m = 10cm) */ + snappingIdentityDistanceMeters: number; debugOutputMapStatus: boolean; debugOutputMapStatusPosition: { x: number; y: number }; } diff --git a/libraries/commons/measurements/src/lib/context/MapMeasurementsContext.tsx b/libraries/commons/measurements/src/lib/context/MapMeasurementsContext.tsx index 790a00db9a..f5ab6dc006 100644 --- a/libraries/commons/measurements/src/lib/context/MapMeasurementsContext.tsx +++ b/libraries/commons/measurements/src/lib/context/MapMeasurementsContext.tsx @@ -75,6 +75,7 @@ const defaultConfig: MeasurementConfig = { snappingQueryRadius: 40, snappingMinZoom: 17, snappingRadiusVisible: false, + snappingIdentityDistanceMeters: 0.1, // 10cm - points closer than this are considered identical debugOutputMapStatus: false, debugOutputMapStatusPosition: { x: 65, y: 15 }, }; diff --git a/libraries/commons/measurements/src/lib/context/MapMeasurementsProvider.tsx b/libraries/commons/measurements/src/lib/context/MapMeasurementsProvider.tsx index c3758d56cc..c61cba3935 100644 --- a/libraries/commons/measurements/src/lib/context/MapMeasurementsProvider.tsx +++ b/libraries/commons/measurements/src/lib/context/MapMeasurementsProvider.tsx @@ -32,6 +32,7 @@ export const defaultConfig: MeasurementConfig = { snappingQueryRadius: 40, snappingMinZoom: 17, snappingRadiusVisible: false, + snappingIdentityDistanceMeters: 0.1, // 10cm - points closer than this are considered identical debugOutputMapStatus: false, debugOutputMapStatusPosition: { x: 65, y: 15 }, }; diff --git a/libraries/commons/measurements/src/lib/hooks/useMeasurements.ts b/libraries/commons/measurements/src/lib/hooks/useMeasurements.ts index 75d6613c65..ad668189cb 100644 --- a/libraries/commons/measurements/src/lib/hooks/useMeasurements.ts +++ b/libraries/commons/measurements/src/lib/hooks/useMeasurements.ts @@ -16,6 +16,10 @@ import { toLatLngFromClosestPoint, filterArrByIds, findLargestNumber, + isCoordMatchLatLng, + isFirstVertexMatch, + tryClosePolygon, + distanceBetweenLatLng, } from "../utils/helper"; import { SnappingPoint } from "./../types"; import { extractPointsFromMeasurementShape } from "../snapping/utils/coordinateExtraction"; @@ -83,6 +87,7 @@ export const useMeasurements = (snappingLayers: MapLibreMap[] = []) => { snappingMinZoom, snappingOnUpdate, snappingRadiusVisible, + snappingIdentityDistanceMeters, } = config; const queryRadiusRef = useRef(snappingQueryRadius); @@ -362,33 +367,26 @@ export const useMeasurements = (snappingLayers: MapLibreMap[] = []) => { let shouldSnap = true; if ( - currentDrawHandlerValue && - currentDrawHandlerValue._markers && - currentDrawHandlerValue._poly?._latlngs && - currentDrawHandlerValue._poly._latlngs.length >= 3 + isFirstVertexMatch( + currentDrawHandlerValue, + finalLatLng, + snappingIdentityDistanceMeters + ) ) { + // We're trying to snap to first vertex - check pixel distance from mouse + const map = realRoutedMapRef.current?.leafletMap?.leafletElement; const firstVertex = currentDrawHandlerValue._poly._latlngs[0]; - const threshold = 0.0001; // ~11 meters - - // Check if finalLatLng matches first vertex - if ( - Math.abs(finalLatLng.lat - firstVertex.lat) < threshold && - Math.abs(finalLatLng.lng - firstVertex.lng) < threshold - ) { - // We're trying to snap to first vertex - check pixel distance from mouse - const map = realRoutedMapRef.current?.leafletMap?.leafletElement; - if (map && mouseLatLng) { - const mousePoint = map.latLngToContainerPoint(mouseLatLng); - const vertexPoint = map.latLngToContainerPoint(firstVertex); - const pixelDistance = Math.sqrt( - Math.pow(mousePoint.x - vertexPoint.x, 2) + - Math.pow(mousePoint.y - vertexPoint.y, 2) - ); - - // Only snap if mouse is within query radius - if (pixelDistance > queryRadiusRef.current) { - shouldSnap = false; - } + if (map && mouseLatLng) { + const mousePoint = map.latLngToContainerPoint(mouseLatLng); + const vertexPoint = map.latLngToContainerPoint(firstVertex); + const pixelDistance = Math.sqrt( + Math.pow(mousePoint.x - vertexPoint.x, 2) + + Math.pow(mousePoint.y - vertexPoint.y, 2) + ); + + // Only snap if mouse is within query radius + if (pixelDistance > queryRadiusRef.current) { + shouldSnap = false; } } } @@ -412,33 +410,33 @@ export const useMeasurements = (snappingLayers: MapLibreMap[] = []) => { currentDrawHandlerValue._markers && shortestIndex !== -1 ) { - const latlngs = currentDrawHandlerValue._poly?._latlngs; - if (latlngs && latlngs.length >= 3) { - const firstVertex = latlngs[0]; - const snappedItem = filteredPointsWithDistance[shortestIndex]; - const snappedCoord = snappedItem.snappingPoint.coordinates; - const threshold = 0.0001; // ~11 meters - - // Check if snapped point matches first vertex coordinates (regardless of source) - // This handles both drawing-in-progress points AND vector features at same location - if ( - Math.abs(snappedCoord[1] - firstVertex.lat) < threshold && - Math.abs(snappedCoord[0] - firstVertex.lng) < threshold - ) { - // Fire mouseover on first vertex marker to show area preview - const firstMarker = currentDrawHandlerValue._markers[0]; - if (firstMarker && lastHoveredMarkerRef.current !== firstMarker) { - lastHoveredMarkerRef.current = firstMarker; - firstMarker.fire("mouseover", { target: firstMarker }); - } - } else { - // Snapped to different point - fire mouseout - if (lastHoveredMarkerRef.current) { - lastHoveredMarkerRef.current.fire("mouseout", { - target: lastHoveredMarkerRef.current, - }); - lastHoveredMarkerRef.current = null; - } + const snappedItem = filteredPointsWithDistance[shortestIndex]; + const snappedCoord = snappedItem.snappingPoint.coordinates; + + // Check if snapped point matches first vertex coordinates (regardless of source) + // This handles both drawing-in-progress points AND vector features at same location + const firstVertex = currentDrawHandlerValue._poly?._latlngs?.[0]; + if ( + firstVertex && + isCoordMatchLatLng( + snappedCoord, + firstVertex, + snappingIdentityDistanceMeters + ) + ) { + // Fire mouseover on first vertex marker to show area preview + const firstMarker = currentDrawHandlerValue._markers[0]; + if (firstMarker && lastHoveredMarkerRef.current !== firstMarker) { + lastHoveredMarkerRef.current = firstMarker; + firstMarker.fire("mouseover", { target: firstMarker }); + } + } else { + // Snapped to different point - fire mouseout + if (lastHoveredMarkerRef.current) { + lastHoveredMarkerRef.current.fire("mouseout", { + target: lastHoveredMarkerRef.current, + }); + lastHoveredMarkerRef.current = null; } } } else { @@ -583,15 +581,14 @@ export const useMeasurements = (snappingLayers: MapLibreMap[] = []) => { }); // Filter out the vertex being dragged (exclude self-snapping) - const threshold = 0.00001; // Very small threshold to identify same point const filteredCoordinatePoints = coordinatePoints.filter((point) => { - const pointLatLng = L.latLng( - point.coordinates[1], - point.coordinates[0] - ); - return !( - Math.abs(pointLatLng.lat - vertexLatLng.lat) < threshold && - Math.abs(pointLatLng.lng - vertexLatLng.lng) < threshold + const pointLatLng = { + lat: point.coordinates[1], + lng: point.coordinates[0], + }; + return ( + distanceBetweenLatLng(pointLatLng, vertexLatLng) >= + snappingIdentityDistanceMeters ); }); @@ -692,15 +689,14 @@ export const useMeasurements = (snappingLayers: MapLibreMap[] = []) => { }); // Filter out the vertex being dragged (exclude self-snapping) - const threshold = 0.00001; // Very small threshold to identify same point const filteredCoordinatePoints = coordinatePoints.filter((point) => { - const pointLatLng = L.latLng( - point.coordinates[1], - point.coordinates[0] - ); - return !( - Math.abs(pointLatLng.lat - vertexLatLng.lat) < threshold && - Math.abs(pointLatLng.lng - vertexLatLng.lng) < threshold + const pointLatLng = { + lat: point.coordinates[1], + lng: point.coordinates[0], + }; + return ( + distanceBetweenLatLng(pointLatLng, vertexLatLng) >= + snappingIdentityDistanceMeters ); }); @@ -764,47 +760,22 @@ export const useMeasurements = (snappingLayers: MapLibreMap[] = []) => { if (!snappedLatlng) return; // Check if snapping to first vertex (polygon closure) - if (drawHandler._poly && drawHandler._poly._latlngs) { - const latlngs = drawHandler._poly._latlngs; - if (latlngs.length >= 3) { - const firstVertex = latlngs[0]; - // Compare snapped position with first vertex - const exactMatchThreshold = 1e-10; - if ( - Math.abs(snappedLatlng.lat - firstVertex.lat) < - exactMatchThreshold && - Math.abs(snappedLatlng.lng - firstVertex.lng) < - exactMatchThreshold - ) { - // Click on first vertex marker to close polygon - if (drawHandler._markers && drawHandler._markers.length > 0) { - const firstMarker = drawHandler._markers[0]; - if (firstMarker) { - console.debug( - "[snapping] Closing polygon via first vertex click" - ); - firstMarker.fire("click", { - latlng: firstVertex, - target: firstMarker, - }); - event.stopPropagation(); - event.stopImmediatePropagation(); - return; - } - } - } + if ( + isFirstVertexMatch( + drawHandler, + snappedLatlng, + snappingIdentityDistanceMeters + ) + ) { + if (tryClosePolygon(drawHandler)) { + event.stopPropagation(); + event.stopImmediatePropagation(); + return; } } - console.debug("[snapping] Direct addVertex from click", { - snappedLatlng, - timestamp: Date.now(), - }); - // Directly add vertex at snapped position drawHandler.addVertex(snappedLatlng); - - // Stop the original click from also adding a vertex event.stopPropagation(); event.stopImmediatePropagation(); }; @@ -879,10 +850,10 @@ export const useMeasurements = (snappingLayers: MapLibreMap[] = []) => { snappingMinZoom, snappingOnUpdate, snappingRadiusVisible, - // Removed setSnappingLatlng dependency + snappingIdentityDistanceMeters, snappingLayers, isMeasurementEnabled, - measureControl, // Added measureControl dependency for direct updates + measureControl, ]); const [visiblePolylines, setVisiblePolylines] = useState<(string | number)[]>( diff --git a/libraries/commons/measurements/src/lib/utils/helper.ts b/libraries/commons/measurements/src/lib/utils/helper.ts index 9a79a71b39..baa2f34a72 100644 --- a/libraries/commons/measurements/src/lib/utils/helper.ts +++ b/libraries/commons/measurements/src/lib/utils/helper.ts @@ -1,5 +1,73 @@ import localforage from "localforage"; import { point, latLng } from "@carma/leaflet"; +import { distance } from "@turf/turf"; + +import type { LatLng } from "leaflet"; + +/** Default threshold for coordinate match (0.1 meters = 10cm) */ +export const EXACT_MATCH_METERS = 0.1; + +/** + * Distance in meters between two lat/lng positions (Turf geodesic) + */ +export const distanceBetweenLatLng = ( + a: { lat: number; lng: number }, + b: { lat: number; lng: number } +): number => distance([a.lng, a.lat], [b.lng, b.lat], { units: "meters" }); + +/** + * Check if a coordinate [lng, lat] matches a LatLng within threshold (default 0.1m) + */ +export const isCoordMatchLatLng = ( + coord: [number, number], + latlng: { lat: number; lng: number }, + thresholdMeters = EXACT_MATCH_METERS +): boolean => + distanceBetweenLatLng({ lat: coord[1], lng: coord[0] }, latlng) < + thresholdMeters; + +/** + * Get the first vertex of a draw handler's polygon if it has 3+ vertices + */ +export const getFirstVertexIfClosable = (drawHandler: any): LatLng | null => { + if (drawHandler?._poly?._latlngs?.length >= 3) { + return drawHandler._poly._latlngs[0]; + } + return null; +}; + +/** + * Check if a position matches the first vertex of a closable polygon + */ +export const isFirstVertexMatch = ( + drawHandler: any, + position: { lat: number; lng: number }, + thresholdMeters = EXACT_MATCH_METERS +): boolean => { + const firstVertex = getFirstVertexIfClosable(drawHandler); + return firstVertex + ? distanceBetweenLatLng(position, firstVertex) < thresholdMeters + : false; +}; + +/** + * Try to close a polygon by clicking its first vertex marker + * Returns true if closure was triggered + */ +export const tryClosePolygon = (drawHandler: any): boolean => { + const firstVertex = getFirstVertexIfClosable(drawHandler); + if (!firstVertex) return false; + + const firstMarker = drawHandler._markers?.[0]; + if (!firstMarker) return false; + + console.debug("[snapping] Closing polygon via first vertex click"); + firstMarker.fire("click", { + latlng: firstVertex, + target: firstMarker, + }); + return true; +}; export const setFromLocalforage = async ( lfKey: string, @@ -48,40 +116,9 @@ export const adjustClickPosition = ( const finalLatLng = latLng(lat, lng); // Check if we're drawing and snapped to first vertex (polygon closure) - // ONLY trigger if the snap source is the drawing-in-progress (not external features) - if ( - currentDrawHandler && - currentDrawHandler._poly && - currentDrawHandler._poly._latlngs - ) { - const latlngs = currentDrawHandler._poly._latlngs; - if (latlngs.length >= 3) { - const firstVertex = latlngs[0]; - const snappedCoord = closestPoint.geometry.coordinates; - // Trigger polygon closure if snapped coordinates EXACTLY match the first vertex - // Use very tight threshold (1e-10) to ensure it's the exact same point, not just nearby - const exactMatchThreshold = 1e-10; - if ( - Math.abs(snappedCoord[1] - firstVertex.lat) < exactMatchThreshold && - Math.abs(snappedCoord[0] - firstVertex.lng) < exactMatchThreshold - ) { - // Try to find and click the first vertex marker directly - // The vertex markers are in _markers array with customHandle property - if ( - currentDrawHandler._markers && - currentDrawHandler._markers.length > 0 - ) { - const firstMarker = currentDrawHandler._markers[0]; - if (firstMarker) { - // Fire click event on the first vertex marker, not the map - firstMarker.fire("click", { - latlng: firstVertex, - target: firstMarker, - }); - return true; // Don't fire synthetic map event - } - } - } + if (isFirstVertexMatch(currentDrawHandler, finalLatLng)) { + if (tryClosePolygon(currentDrawHandler)) { + return true; // Don't fire synthetic map event } } From 85c6b61f97ff7ac17dc4d4e1e24119bab8b015a4 Mon Sep 17 00:00:00 2001 From: Friedrich Hartmann Date: Tue, 2 Dec 2025 18:28:40 +0100 Subject: [PATCH 08/20] refactor and dedupe measurements --- .../lib/context/MapMeasurementsProvider.tsx | 2 +- .../src/lib/hooks/useMeasurements.ts | 326 +++++------------- .../measurements/src/lib/utils/helper.ts | 179 ---------- .../measurements/src/lib/utils/shapes.ts | 20 ++ .../measurements/src/lib/utils/snapping.ts | 221 ++++++++++++ .../measurements/src/lib/utils/storage.ts | 33 ++ 6 files changed, 370 insertions(+), 411 deletions(-) delete mode 100644 libraries/commons/measurements/src/lib/utils/helper.ts create mode 100644 libraries/commons/measurements/src/lib/utils/shapes.ts create mode 100644 libraries/commons/measurements/src/lib/utils/snapping.ts create mode 100644 libraries/commons/measurements/src/lib/utils/storage.ts diff --git a/libraries/commons/measurements/src/lib/context/MapMeasurementsProvider.tsx b/libraries/commons/measurements/src/lib/context/MapMeasurementsProvider.tsx index c61cba3935..ee04cce406 100644 --- a/libraries/commons/measurements/src/lib/context/MapMeasurementsProvider.tsx +++ b/libraries/commons/measurements/src/lib/context/MapMeasurementsProvider.tsx @@ -7,7 +7,7 @@ import type { } from "./MapMeasurementsContext.d"; // import { MEASUREMENT_MODE } from "./MapMeasurementsContext.d"; import { MapMeasurementsContext } from "./MapMeasurementsContext"; -import { setFromLocalforage, saveToLocalforage } from "../utils/helper"; +import { setFromLocalforage, saveToLocalforage } from "../utils/storage"; import { normalizeOptions } from "@carma-commons/utils"; // Detect mobile devices diff --git a/libraries/commons/measurements/src/lib/hooks/useMeasurements.ts b/libraries/commons/measurements/src/lib/hooks/useMeasurements.ts index ad668189cb..c8c5a45412 100644 --- a/libraries/commons/measurements/src/lib/hooks/useMeasurements.ts +++ b/libraries/commons/measurements/src/lib/hooks/useMeasurements.ts @@ -14,13 +14,18 @@ import useDeviceDetection from "../hooks/useDeviceDetection"; import { useMapMeasurementsContext } from "../context"; import { toLatLngFromClosestPoint, - filterArrByIds, - findLargestNumber, isCoordMatchLatLng, isFirstVertexMatch, tryClosePolygon, distanceBetweenLatLng, -} from "../utils/helper"; + screenPixelDistance, + pixelRadiusToMeters, + createSnappingIndicator, + findClosestSnappingPoint, + SNAPPING_MODIFIER_KEY, + isSnappingModifierPressed, +} from "../utils/snapping"; +import { filterArrByIds, findLargestNumber } from "../utils/shapes"; import { SnappingPoint } from "./../types"; import { extractPointsFromMeasurementShape } from "../snapping/utils/coordinateExtraction"; import { getSnappingPointsFromMapLibre } from "../snapping/utils/mapLibreExtraction"; @@ -241,11 +246,11 @@ export const useMeasurements = (snappingLayers: MapLibreMap[] = []) => { snappingRadiusVisible && (statusRef.current === "WAITING" || statusRef.current === "DRAWING") ) { - // Convert pixel radius to meters for the circle - const metersPerPixel = - (156543.03392 * Math.cos((mouseLatLng.lat * Math.PI) / 180)) / - Math.pow(2, currentZoom); - const radiusInMeters = currentRadius * metersPerPixel; + const radiusInMeters = pixelRadiusToMeters( + currentRadius, + mouseLatLng.lat, + currentZoom + ); circleMarkerRef.current = L.circle(mouseLatLng, { radius: radiusInMeters, @@ -254,7 +259,7 @@ export const useMeasurements = (snappingLayers: MapLibreMap[] = []) => { fillOpacity: 0.15, weight: 1, opacity: 0.4, - interactive: false, // Don't capture mouse events + interactive: false, }).addTo(leafletMap); } const coordinatePoints: SnappingPoint[] = []; @@ -296,66 +301,51 @@ export const useMeasurements = (snappingLayers: MapLibreMap[] = []) => { } // Filter points to only those within the query radius and calculate distances - // Use Leaflet for coordinate projection (works without MapLibre) - const filteredPointsWithDistance = coordinatePoints - .map((snappingPoint: SnappingPoint) => { - const coord = snappingPoint.coordinates; - const pointLatLng = L.latLng(coord[1], coord[0]); // [lng, lat] -> L.latLng(lat, lng) - const projectedPoint = - leafletMap.latLngToContainerPoint(pointLatLng); - - const dx = projectedPoint.x - mousePoint.x; - const dy = projectedPoint.y - mousePoint.y; - const distance = Math.sqrt(dx * dx + dy * dy); - - return { snappingPoint, distance }; - }) - .filter((item) => item.distance <= currentRadius); - - // Find the shortest distance - let shortestDistance = Infinity; - let shortestIndex = -1; - - filteredPointsWithDistance.forEach((item: any, index: number) => { - if (item.distance < shortestDistance) { - shortestDistance = item.distance; - shortestIndex = index; - } - }); + // Find closest snapping point using helper + const projectToScreen = (coord: [number, number]) => { + const pointLatLng = L.latLng(coord[1], coord[0]); + return leafletMap.latLngToContainerPoint(pointLatLng); + }; + + const closestResult = findClosestSnappingPoint( + coordinatePoints, + mousePoint, + currentRadius, + projectToScreen + ); // Determine snapping point - const blackPoint: any[] = []; let isSnapped = false; + let snappedFeature: any; - if (shortestIndex === -1) { + if (!closestResult) { // No points found - use mouse pointer but don't show indicator - blackPoint.push({ + snappedFeature = { type: "Feature", geometry: { type: "Point", coordinates: [mouseLatLng.lng, mouseLatLng.lat], }, properties: { black: true }, - }); + }; isSnapped = false; } else { // Snap to the closest point found within query radius - const closestItem = filteredPointsWithDistance[shortestIndex]; - blackPoint.push({ + snappedFeature = { type: "Feature", geometry: { type: "Point", - coordinates: closestItem.snappingPoint.coordinates, + coordinates: closestResult.point.coordinates, }, properties: { black: true, - source: closestItem.snappingPoint.sourceId, // Pass source for polygon closure check + source: closestResult.point.sourceId, }, - }); + }; isSnapped = true; } - closestPoint = blackPoint[0]; - closestPointRef.current = blackPoint[0]; + closestPoint = snappedFeature; + closestPointRef.current = snappedFeature; const finalLatLng = toLatLngFromClosestPoint(closestPoint); // Logic for updating snappingLatlng @@ -377,15 +367,12 @@ export const useMeasurements = (snappingLayers: MapLibreMap[] = []) => { const map = realRoutedMapRef.current?.leafletMap?.leafletElement; const firstVertex = currentDrawHandlerValue._poly._latlngs[0]; if (map && mouseLatLng) { - const mousePoint = map.latLngToContainerPoint(mouseLatLng); - const vertexPoint = map.latLngToContainerPoint(firstVertex); - const pixelDistance = Math.sqrt( - Math.pow(mousePoint.x - vertexPoint.x, 2) + - Math.pow(mousePoint.y - vertexPoint.y, 2) - ); + const mousePt = map.latLngToContainerPoint(mouseLatLng); + const vertexPt = map.latLngToContainerPoint(firstVertex); + const pixelDist = screenPixelDistance(mousePt, vertexPt); // Only snap if mouse is within query radius - if (pixelDistance > queryRadiusRef.current) { + if (pixelDist > queryRadiusRef.current) { shouldSnap = false; } } @@ -408,10 +395,9 @@ export const useMeasurements = (snappingLayers: MapLibreMap[] = []) => { isSnapped && currentDrawHandlerValue && currentDrawHandlerValue._markers && - shortestIndex !== -1 + closestResult ) { - const snappedItem = filteredPointsWithDistance[shortestIndex]; - const snappedCoord = snappedItem.snappingPoint.coordinates; + const snappedCoord = closestResult.point.coordinates; // Check if snapped point matches first vertex coordinates (regardless of source) // This handles both drawing-in-progress points AND vector features at same location @@ -468,7 +454,6 @@ export const useMeasurements = (snappingLayers: MapLibreMap[] = []) => { } // Create Leaflet marker for snapping indicator ONLY when snapped - // Match the size of measurement handles (8px total = 4px radius) if ( finalLatLng && isSnapped && @@ -476,18 +461,10 @@ export const useMeasurements = (snappingLayers: MapLibreMap[] = []) => { statusRef.current === "DRAWING" || statusRef.current === "INACTIVE") ) { - snappingIndicatorRef.current = L.circleMarker( - [finalLatLng.lat, finalLatLng.lng], - { - radius: 3.5, - color: "#000000", - fillColor: "#000000", - fillOpacity: 0.8, - weight: 1, - opacity: 0.8, - interactive: false, - } - ).addTo(leafletMap); + snappingIndicatorRef.current = createSnappingIndicator( + finalLatLng, + leafletMap + ); } lastSnappedCoordRef.current = currentCoord; @@ -539,24 +516,16 @@ export const useMeasurements = (snappingLayers: MapLibreMap[] = []) => { }); leafletMap.on("mouseout", mouseoutHandler); - // Phase 4: Show snap indicator during vertex drag - const vertexDragHandler = (e: any) => { - isDraggingVertexRef.current = true; - - if (!snappingOnUpdate) return; - - const vertex = e.vertex; - if (!vertex) return; - - // Get current vertex position during drag - const vertexLatLng = vertex.latlng; + // Shared helper for vertex drag snapping + const findVertexSnapTarget = (vertexLatLng: { + lat: number; + lng: number; + }): SnappingPoint | null => { const vertexPoint = leafletMap.latLngToContainerPoint(vertexLatLng); - const currentRadius = queryRadiusRef.current; const coordinatePoints: SnappingPoint[] = []; // Extract snap points from vector features - const currentMaplibreMaps = snappingLayersRef.current; const mapContainer = leafletMap.getContainer(); const mapRect = mapContainer.getBoundingClientRect(); const screenX = vertexPoint.x + mapRect.left; @@ -564,24 +533,21 @@ export const useMeasurements = (snappingLayers: MapLibreMap[] = []) => { coordinatePoints.push( ...getSnappingPointsFromMapLibre( - currentMaplibreMaps, + snappingLayersRef.current, { x: screenX, y: screenY }, currentRadius ) ); - // Extract from other measurement shapes - const currentShapes = shapesRef.current; - currentShapes.forEach((shape: any) => { - const points = extractPointsFromMeasurementShape( - shape, - "measurements" + // Extract from measurement shapes + shapesRef.current.forEach((shape: any) => { + coordinatePoints.push( + ...extractPointsFromMeasurementShape(shape, "measurements") ); - coordinatePoints.push(...points); }); - // Filter out the vertex being dragged (exclude self-snapping) - const filteredCoordinatePoints = coordinatePoints.filter((point) => { + // Filter out self (exclude self-snapping) + const filtered = coordinatePoints.filter((point) => { const pointLatLng = { lat: point.coordinates[1], lng: point.coordinates[0], @@ -592,21 +558,29 @@ export const useMeasurements = (snappingLayers: MapLibreMap[] = []) => { ); }); - // Find closest point within radius - const filteredPointsWithDistance = filteredCoordinatePoints - .map((snappingPoint: SnappingPoint) => { - const coord = snappingPoint.coordinates; - const pointLatLng = L.latLng(coord[1], coord[0]); - const projectedPoint = - leafletMap.latLngToContainerPoint(pointLatLng); + // Find closest + const projectToScreen = (coord: [number, number]) => { + const pointLatLng = L.latLng(coord[1], coord[0]); + return leafletMap.latLngToContainerPoint(pointLatLng); + }; + + const result = findClosestSnappingPoint( + filtered, + vertexPoint, + currentRadius, + projectToScreen + ); - const dx = projectedPoint.x - vertexPoint.x; - const dy = projectedPoint.y - vertexPoint.y; - const distance = Math.sqrt(dx * dx + dy * dy); + return result?.point ?? null; + }; + + // Phase 4: Show snap indicator during vertex drag + const vertexDragHandler = (e: any) => { + isDraggingVertexRef.current = true; + if (!snappingOnUpdate) return; - return { snappingPoint, distance }; - }) - .filter((item) => item.distance <= currentRadius); + const vertex = e.vertex; + if (!vertex) return; // Remove old indicator if (snappingIndicatorRef.current) { @@ -614,34 +588,12 @@ export const useMeasurements = (snappingLayers: MapLibreMap[] = []) => { snappingIndicatorRef.current = null; } - if (filteredPointsWithDistance.length > 0) { - // Find shortest distance - let shortestDistance = Infinity; - let shortestIndex = -1; - filteredPointsWithDistance.forEach((item, index) => { - if (item.distance < shortestDistance) { - shortestDistance = item.distance; - shortestIndex = index; - } - }); - - if (shortestIndex !== -1) { - const closestItem = filteredPointsWithDistance[shortestIndex]; - const snappedCoord = closestItem.snappingPoint.coordinates; - // Show snap indicator at target location - snappingIndicatorRef.current = L.circleMarker( - [snappedCoord[1], snappedCoord[0]], - { - radius: 3.5, - color: "#000000", - fillColor: "#000000", - fillOpacity: 0.8, - weight: 1, - opacity: 0.8, - interactive: false, // Don't capture mouse events - } - ).addTo(leafletMap); - } + const snapTarget = findVertexSnapTarget(vertex.latlng); + if (snapTarget) { + snappingIndicatorRef.current = createSnappingIndicator( + { lat: snapTarget.coordinates[1], lng: snapTarget.coordinates[0] }, + leafletMap + ); } }; @@ -650,97 +602,22 @@ export const useMeasurements = (snappingLayers: MapLibreMap[] = []) => { // Phase 4: Snap vertex AFTER drag ends const vertexDragEndHandler = (e: any) => { isDraggingVertexRef.current = false; - if (!snappingOnUpdate) return; const vertex = e.vertex; if (!vertex) return; - // Get final vertex position after drag - const vertexLatLng = vertex.latlng; - const vertexPoint = leafletMap.latLngToContainerPoint(vertexLatLng); - - const currentRadius = queryRadiusRef.current; - const coordinatePoints: SnappingPoint[] = []; - - // Extract snap points from vector features - const currentMaplibreMaps = snappingLayersRef.current; - const mapContainer = leafletMap.getContainer(); - const mapRect = mapContainer.getBoundingClientRect(); - const screenX = vertexPoint.x + mapRect.left; - const screenY = vertexPoint.y + mapRect.top; - - coordinatePoints.push( - ...getSnappingPointsFromMapLibre( - currentMaplibreMaps, - { x: screenX, y: screenY }, - currentRadius - ) - ); - - // Extract from other measurement shapes - const currentShapes = shapesRef.current; - currentShapes.forEach((shape: any) => { - const points = extractPointsFromMeasurementShape( - shape, - "measurements" - ); - coordinatePoints.push(...points); - }); - - // Filter out the vertex being dragged (exclude self-snapping) - const filteredCoordinatePoints = coordinatePoints.filter((point) => { - const pointLatLng = { - lat: point.coordinates[1], - lng: point.coordinates[0], - }; - return ( - distanceBetweenLatLng(pointLatLng, vertexLatLng) >= - snappingIdentityDistanceMeters - ); - }); - - // Find closest point within radius - const filteredPointsWithDistance = filteredCoordinatePoints - .map((snappingPoint: SnappingPoint) => { - const coord = snappingPoint.coordinates; - const pointLatLng = L.latLng(coord[1], coord[0]); - const projectedPoint = - leafletMap.latLngToContainerPoint(pointLatLng); - - const dx = projectedPoint.x - vertexPoint.x; - const dy = projectedPoint.y - vertexPoint.y; - const distance = Math.sqrt(dx * dx + dy * dy); - - return { snappingPoint, distance }; - }) - .filter((item) => item.distance <= currentRadius); - - if (filteredPointsWithDistance.length > 0) { - // Find shortest distance - let shortestDistance = Infinity; - let shortestIndex = -1; - filteredPointsWithDistance.forEach((item, index) => { - if (item.distance < shortestDistance) { - shortestDistance = item.distance; - shortestIndex = index; - } - }); - - if (shortestIndex !== -1) { - const closestItem = filteredPointsWithDistance[shortestIndex]; - const snappedCoord = closestItem.snappingPoint.coordinates; - // Snap vertex to final position - vertex.latlng.lat = snappedCoord[1]; - vertex.latlng.lng = snappedCoord[0]; - vertex.update(); - // Force complete refresh of the editor to recalculate middle markers - if (e.layer.editor) { - // Reset the editor to force recalculation - e.layer.editor.reset(); - } - e.layer.redraw(); + const snapTarget = findVertexSnapTarget(vertex.latlng); + if (snapTarget) { + // Snap vertex to final position + vertex.latlng.lat = snapTarget.coordinates[1]; + vertex.latlng.lng = snapTarget.coordinates[0]; + vertex.update(); + // Force complete refresh of the editor + if (e.layer.editor) { + e.layer.editor.reset(); } + e.layer.redraw(); } }; @@ -1139,17 +1016,4 @@ export const useMeasurements = (snappingLayers: MapLibreMap[] = []) => { snappingLatlng: !!snappingLatlngRef?.current, }); }); - - const SNAPPING_MODIFIER_KEY = "Alt"; - - const isSnappingModifierPressed = (event: any) => { - if (event.getModifierState) { - return event.getModifierState(SNAPPING_MODIFIER_KEY); - } - // Fallback for synthetic events or simple objects - if (SNAPPING_MODIFIER_KEY === "Alt") return event.altKey; - if (SNAPPING_MODIFIER_KEY === "Control") return event.ctrlKey; - if (SNAPPING_MODIFIER_KEY === "Shift") return event.shiftKey; - return false; - }; }; diff --git a/libraries/commons/measurements/src/lib/utils/helper.ts b/libraries/commons/measurements/src/lib/utils/helper.ts deleted file mode 100644 index baa2f34a72..0000000000 --- a/libraries/commons/measurements/src/lib/utils/helper.ts +++ /dev/null @@ -1,179 +0,0 @@ -import localforage from "localforage"; -import { point, latLng } from "@carma/leaflet"; -import { distance } from "@turf/turf"; - -import type { LatLng } from "leaflet"; - -/** Default threshold for coordinate match (0.1 meters = 10cm) */ -export const EXACT_MATCH_METERS = 0.1; - -/** - * Distance in meters between two lat/lng positions (Turf geodesic) - */ -export const distanceBetweenLatLng = ( - a: { lat: number; lng: number }, - b: { lat: number; lng: number } -): number => distance([a.lng, a.lat], [b.lng, b.lat], { units: "meters" }); - -/** - * Check if a coordinate [lng, lat] matches a LatLng within threshold (default 0.1m) - */ -export const isCoordMatchLatLng = ( - coord: [number, number], - latlng: { lat: number; lng: number }, - thresholdMeters = EXACT_MATCH_METERS -): boolean => - distanceBetweenLatLng({ lat: coord[1], lng: coord[0] }, latlng) < - thresholdMeters; - -/** - * Get the first vertex of a draw handler's polygon if it has 3+ vertices - */ -export const getFirstVertexIfClosable = (drawHandler: any): LatLng | null => { - if (drawHandler?._poly?._latlngs?.length >= 3) { - return drawHandler._poly._latlngs[0]; - } - return null; -}; - -/** - * Check if a position matches the first vertex of a closable polygon - */ -export const isFirstVertexMatch = ( - drawHandler: any, - position: { lat: number; lng: number }, - thresholdMeters = EXACT_MATCH_METERS -): boolean => { - const firstVertex = getFirstVertexIfClosable(drawHandler); - return firstVertex - ? distanceBetweenLatLng(position, firstVertex) < thresholdMeters - : false; -}; - -/** - * Try to close a polygon by clicking its first vertex marker - * Returns true if closure was triggered - */ -export const tryClosePolygon = (drawHandler: any): boolean => { - const firstVertex = getFirstVertexIfClosable(drawHandler); - if (!firstVertex) return false; - - const firstMarker = drawHandler._markers?.[0]; - if (!firstMarker) return false; - - console.debug("[snapping] Closing polygon via first vertex click"); - firstMarker.fire("click", { - latlng: firstVertex, - target: firstMarker, - }); - return true; -}; - -export const setFromLocalforage = async ( - lfKey: string, - setter: (value: any) => void, - fallbackValue?: any, - forceFallback?: boolean -) => { - try { - const value = await localforage.getItem(lfKey); - if (value !== undefined && value !== null) { - setter(value); - } else if (fallbackValue !== undefined || forceFallback === true) { - setter(fallbackValue); - } - } catch (error) { - console.warn(`Failed to load ${lfKey} from localStorage:`, error); - if (fallbackValue !== undefined || forceFallback === true) { - setter(fallbackValue); - } - } -}; - -export const saveToLocalforage = async (lfKey: string, value: any) => { - try { - await localforage.setItem(lfKey, value); - } catch (error) { - console.warn(`Failed to save ${lfKey} to localStorage:`, error); - } -}; - -export const adjustClickPosition = ( - domEvent: MouseEvent, - closestPoint: any, - eventType: string, - leafletMap: any, - currentDrawHandler?: any -) => { - const containerPoint = leafletMap.mouseEventToContainerPoint(domEvent); - const shiftedContainerPoint = point(containerPoint.x, containerPoint.y); - // Use closestPoint if available, otherwise use shifted click position - if (!closestPoint) { - return false; - } - - const [lng, lat] = closestPoint.geometry.coordinates; - const finalLatLng = latLng(lat, lng); - - // Check if we're drawing and snapped to first vertex (polygon closure) - if (isFirstVertexMatch(currentDrawHandler, finalLatLng)) { - if (tryClosePolygon(currentDrawHandler)) { - return true; // Don't fire synthetic map event - } - } - - // Fire a new click event with shifted coordinates on the map - console.log("[snapping] Firing synthetic Leaflet event", { - eventType, - latlng: finalLatLng, - originalCoords: [domEvent.clientX, domEvent.clientY], - }); - - leafletMap.fire(eventType, { - latlng: finalLatLng, - containerPoint: shiftedContainerPoint, - originalEvent: domEvent, - _isSyntheticSnap: true, // Mark as synthetic snap event - }); - - return true; // Return true to indicate we handled the snap (caller should stop propagation) -}; - -// Prepare a Leaflet LatLng from a GeoJSON Point-like feature with coordinates [lng, lat] -export const toLatLngFromClosestPoint = (closestPoint: any) => { - if ( - !closestPoint || - !closestPoint.geometry || - !closestPoint.geometry.coordinates - ) { - return null; - } - const [lng, lat] = closestPoint.geometry.coordinates; - return latLng(lat, lng); -}; - -export function filterArrByIds( - arrIds: (string | number)[], - fullArray: any[] -): any[] { - const finalResult: any[] = []; - fullArray.forEach((currentItem) => { - if (arrIds.includes(currentItem.shapeId)) { - finalResult.push(currentItem); - } - }); - - return finalResult; -} - -export function findLargestNumber(measurements: any[]): number { - let largestNumber = 0; - - measurements.forEach((item) => { - if (item.number > largestNumber) { - largestNumber = item.number; - } - }); - - return largestNumber; -} diff --git a/libraries/commons/measurements/src/lib/utils/shapes.ts b/libraries/commons/measurements/src/lib/utils/shapes.ts new file mode 100644 index 0000000000..877415b910 --- /dev/null +++ b/libraries/commons/measurements/src/lib/utils/shapes.ts @@ -0,0 +1,20 @@ +/** + * Shape/measurement array utilities + */ + +export function filterArrByIds( + arrIds: (string | number)[], + fullArray: any[] +): any[] { + return fullArray.filter((item) => arrIds.includes(item.shapeId)); +} + +export function findLargestNumber(measurements: any[]): number { + let largestNumber = 0; + for (const item of measurements) { + if (item.number > largestNumber) { + largestNumber = item.number; + } + } + return largestNumber; +} diff --git a/libraries/commons/measurements/src/lib/utils/snapping.ts b/libraries/commons/measurements/src/lib/utils/snapping.ts new file mode 100644 index 0000000000..e9d3566477 --- /dev/null +++ b/libraries/commons/measurements/src/lib/utils/snapping.ts @@ -0,0 +1,221 @@ +/** + * Snapping utilities for measurement tools + */ +import L from "leaflet"; +import { latLng } from "@carma/leaflet"; +import { distanceMeters, metersPerPixel } from "@carma/geo/utils"; + +import type { LatLng, Map as LeafletMap, CircleMarker } from "leaflet"; +import type { SnappingPoint } from "../types"; + +/** Snapping modifier key to temporarily disable snapping */ +export const SNAPPING_MODIFIER_KEY = "Alt"; + +/** + * Check if snapping modifier key is pressed + */ +export const isSnappingModifierPressed = (event: { + getModifierState?: (key: string) => boolean; + altKey?: boolean; + ctrlKey?: boolean; + shiftKey?: boolean; +}): boolean => { + if (event.getModifierState) { + return event.getModifierState(SNAPPING_MODIFIER_KEY); + } + if (SNAPPING_MODIFIER_KEY === "Alt") return !!event.altKey; + if (SNAPPING_MODIFIER_KEY === "Control") return !!event.ctrlKey; + if (SNAPPING_MODIFIER_KEY === "Shift") return !!event.shiftKey; + return false; +}; + +/** Default threshold for coordinate match (0.1 meters = 10cm) */ +export const EXACT_MATCH_METERS = 0.1; + +/** + * Screen pixel distance between two points + */ +export const screenPixelDistance = ( + p1: { x: number; y: number }, + p2: { x: number; y: number } +): number => { + const dx = p1.x - p2.x; + const dy = p1.y - p2.y; + return Math.sqrt(dx * dx + dy * dy); +}; + +/** + * Convert pixel radius to meters at a given lat/zoom + */ +export const pixelRadiusToMeters = ( + pixelRadius: number, + lat: number, + zoom: number +): number => { + const mpp = metersPerPixel(zoom, lat as any); + return pixelRadius * mpp; +}; + +/** + * Distance in meters between two lat/lng positions (uses @carma/geo/utils) + */ +export const distanceBetweenLatLng = ( + a: { lat: number; lng: number }, + b: { lat: number; lng: number } +): number => + distanceMeters( + { latitude: a.lat, longitude: a.lng } as any, + { latitude: b.lat, longitude: b.lng } as any + ); + +/** + * Check if a coordinate [lng, lat] matches a LatLng within threshold (default 0.1m) + */ +export const isCoordMatchLatLng = ( + coord: [number, number], + latlng: { lat: number; lng: number }, + thresholdMeters = EXACT_MATCH_METERS +): boolean => + distanceBetweenLatLng({ lat: coord[1], lng: coord[0] }, latlng) < + thresholdMeters; + +/** + * Create a snapping indicator marker + */ +export const createSnappingIndicator = ( + latlng: { lat: number; lng: number }, + map: LeafletMap +): CircleMarker => { + return L.circleMarker([latlng.lat, latlng.lng], { + radius: 3.5, + color: "#000000", + fillColor: "#000000", + fillOpacity: 0.8, + weight: 1, + opacity: 0.8, + interactive: false, + }).addTo(map); +}; + +/** + * Find the closest snapping point within a pixel radius + */ +export const findClosestSnappingPoint = ( + points: SnappingPoint[], + referencePoint: { x: number; y: number }, + maxPixelRadius: number, + projectToScreen: (coord: [number, number]) => { x: number; y: number } +): { point: SnappingPoint; distance: number } | null => { + let closest: { point: SnappingPoint; distance: number } | null = null; + + for (const snappingPoint of points) { + const screenPoint = projectToScreen(snappingPoint.coordinates); + const dist = screenPixelDistance(screenPoint, referencePoint); + + if (dist <= maxPixelRadius && (!closest || dist < closest.distance)) { + closest = { point: snappingPoint, distance: dist }; + } + } + + return closest; +}; + +/** + * Get the first vertex of a draw handler's polygon if it has 3+ vertices + */ +export const getFirstVertexIfClosable = (drawHandler: any): LatLng | null => { + if (drawHandler?._poly?._latlngs?.length >= 3) { + return drawHandler._poly._latlngs[0]; + } + return null; +}; + +/** + * Check if a position matches the first vertex of a closable polygon + */ +export const isFirstVertexMatch = ( + drawHandler: any, + position: { lat: number; lng: number }, + thresholdMeters = EXACT_MATCH_METERS +): boolean => { + const firstVertex = getFirstVertexIfClosable(drawHandler); + return firstVertex + ? distanceBetweenLatLng(position, firstVertex) < thresholdMeters + : false; +}; + +/** + * Try to close a polygon by clicking its first vertex marker + */ +export const tryClosePolygon = (drawHandler: any): boolean => { + const firstVertex = getFirstVertexIfClosable(drawHandler); + if (!firstVertex) return false; + + const firstMarker = drawHandler._markers?.[0]; + if (!firstMarker) return false; + + console.debug("[snapping] Closing polygon via first vertex click"); + firstMarker.fire("click", { + latlng: firstVertex, + target: firstMarker, + }); + return true; +}; + +/** + * Prepare a Leaflet LatLng from a GeoJSON Point-like feature + */ +export const toLatLngFromClosestPoint = (closestPoint: any) => { + if ( + !closestPoint || + !closestPoint.geometry || + !closestPoint.geometry.coordinates + ) { + return null; + } + const [lng, lat] = closestPoint.geometry.coordinates; + return latLng(lat, lng); +}; + +/** + * Adjust click position for snapping (fires synthetic event) + */ +export const adjustClickPosition = ( + domEvent: MouseEvent, + closestPoint: any, + eventType: string, + leafletMap: any, + currentDrawHandler?: any +) => { + const containerPoint = leafletMap.mouseEventToContainerPoint(domEvent); + const shiftedContainerPoint = L.point(containerPoint.x, containerPoint.y); + + if (!closestPoint) { + return false; + } + + const [lng, lat] = closestPoint.geometry.coordinates; + const finalLatLng = latLng(lat, lng); + + // Check if we're drawing and snapped to first vertex (polygon closure) + if (isFirstVertexMatch(currentDrawHandler, finalLatLng)) { + if (tryClosePolygon(currentDrawHandler)) { + return true; + } + } + + console.log("[snapping] Firing synthetic Leaflet event", { + eventType, + latlng: finalLatLng, + originalCoords: [domEvent.clientX, domEvent.clientY], + }); + + leafletMap.fire(eventType, { + latlng: finalLatLng, + containerPoint: shiftedContainerPoint, + originalEvent: domEvent, + _isSyntheticSnap: true, + }); + + return true; +}; diff --git a/libraries/commons/measurements/src/lib/utils/storage.ts b/libraries/commons/measurements/src/lib/utils/storage.ts new file mode 100644 index 0000000000..d6834faf18 --- /dev/null +++ b/libraries/commons/measurements/src/lib/utils/storage.ts @@ -0,0 +1,33 @@ +/** + * LocalForage storage utilities + */ +import localforage from "localforage"; + +export const setFromLocalforage = async ( + lfKey: string, + setter: (value: any) => void, + fallbackValue?: any, + forceFallback?: boolean +) => { + try { + const value = await localforage.getItem(lfKey); + if (value !== undefined && value !== null) { + setter(value); + } else if (fallbackValue !== undefined || forceFallback === true) { + setter(fallbackValue); + } + } catch (error) { + console.warn(`Failed to load ${lfKey} from localStorage:`, error); + if (fallbackValue !== undefined || forceFallback === true) { + setter(fallbackValue); + } + } +}; + +export const saveToLocalforage = async (lfKey: string, value: any) => { + try { + await localforage.setItem(lfKey, value); + } catch (error) { + console.warn(`Failed to save ${lfKey} to localStorage:`, error); + } +}; From 070912274830077b6c82cb309f046fda5051c58a Mon Sep 17 00:00:00 2001 From: Friedrich Hartmann Date: Tue, 2 Dec 2025 18:55:47 +0100 Subject: [PATCH 09/20] fix closing lines with snapping enabled --- .../src/lib/hooks/useMeasurements.ts | 20 ++++++++- .../measurements/src/lib/utils/snapping.ts | 44 +++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/libraries/commons/measurements/src/lib/hooks/useMeasurements.ts b/libraries/commons/measurements/src/lib/hooks/useMeasurements.ts index c8c5a45412..4aad746268 100644 --- a/libraries/commons/measurements/src/lib/hooks/useMeasurements.ts +++ b/libraries/commons/measurements/src/lib/hooks/useMeasurements.ts @@ -16,7 +16,9 @@ import { toLatLngFromClosestPoint, isCoordMatchLatLng, isFirstVertexMatch, + isLastVertexMatch, tryClosePolygon, + tryFinishLine, distanceBetweenLatLng, screenPixelDistance, pixelRadiusToMeters, @@ -345,7 +347,8 @@ export const useMeasurements = (snappingLayers: MapLibreMap[] = []) => { isSnapped = true; } closestPoint = snappedFeature; - closestPointRef.current = snappedFeature; + // Only store snap point if actually snapped - prevents click handler from using stale/unsnapped positions + closestPointRef.current = isSnapped ? snappedFeature : null; const finalLatLng = toLatLngFromClosestPoint(closestPoint); // Logic for updating snappingLatlng @@ -651,6 +654,21 @@ export const useMeasurements = (snappingLayers: MapLibreMap[] = []) => { } } + // Check if snapping to last vertex (finish line measurement) + if ( + isLastVertexMatch( + drawHandler, + snappedLatlng, + snappingIdentityDistanceMeters + ) + ) { + if (tryFinishLine(drawHandler)) { + event.stopPropagation(); + event.stopImmediatePropagation(); + return; + } + } + // Directly add vertex at snapped position drawHandler.addVertex(snappedLatlng); event.stopPropagation(); diff --git a/libraries/commons/measurements/src/lib/utils/snapping.ts b/libraries/commons/measurements/src/lib/utils/snapping.ts index e9d3566477..4337704c96 100644 --- a/libraries/commons/measurements/src/lib/utils/snapping.ts +++ b/libraries/commons/measurements/src/lib/utils/snapping.ts @@ -130,6 +130,17 @@ export const getFirstVertexIfClosable = (drawHandler: any): LatLng | null => { return null; }; +/** + * Get the last vertex of a draw handler's line if it has 2+ vertices + */ +export const getLastVertexIfFinishable = (drawHandler: any): LatLng | null => { + const latlngs = drawHandler?._poly?._latlngs; + if (latlngs?.length >= 2) { + return latlngs[latlngs.length - 1]; + } + return null; +}; + /** * Check if a position matches the first vertex of a closable polygon */ @@ -144,6 +155,20 @@ export const isFirstVertexMatch = ( : false; }; +/** + * Check if a position matches the last vertex of a finishable line + */ +export const isLastVertexMatch = ( + drawHandler: any, + position: { lat: number; lng: number }, + thresholdMeters = EXACT_MATCH_METERS +): boolean => { + const lastVertex = getLastVertexIfFinishable(drawHandler); + return lastVertex + ? distanceBetweenLatLng(position, lastVertex) < thresholdMeters + : false; +}; + /** * Try to close a polygon by clicking its first vertex marker */ @@ -162,6 +187,25 @@ export const tryClosePolygon = (drawHandler: any): boolean => { return true; }; +/** + * Try to finish a line measurement by clicking its last vertex marker + */ +export const tryFinishLine = (drawHandler: any): boolean => { + const markers = drawHandler?._markers; + if (!markers || markers.length < 2) return false; + + const lastMarker = markers[markers.length - 1]; + const lastVertex = getLastVertexIfFinishable(drawHandler); + if (!lastMarker || !lastVertex) return false; + + console.debug("[snapping] Finishing line via last vertex click"); + lastMarker.fire("click", { + latlng: lastVertex, + target: lastMarker, + }); + return true; +}; + /** * Prepare a Leaflet LatLng from a GeoJSON Point-like feature */ From 5817fe666650098f7607035760836102da678ede Mon Sep 17 00:00:00 2001 From: Friedrich Hartmann Date: Tue, 2 Dec 2025 19:19:01 +0100 Subject: [PATCH 10/20] wip fix zoom issue --- .../src/lib/hooks/useMeasurements.ts | 28 ++++++++----------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/libraries/commons/measurements/src/lib/hooks/useMeasurements.ts b/libraries/commons/measurements/src/lib/hooks/useMeasurements.ts index 4aad746268..64c4dab4d6 100644 --- a/libraries/commons/measurements/src/lib/hooks/useMeasurements.ts +++ b/libraries/commons/measurements/src/lib/hooks/useMeasurements.ts @@ -135,8 +135,8 @@ export const useMeasurements = (snappingLayers: MapLibreMap[] = []) => { let closestPoint: SnappingPoint | null = null; const closestPointRef = { current: null as SnappingPoint | null }; // Stable ref to preserve closestPoint - // Centralized cleanup for markers and closestPoint - const clearBlackPoint = () => { + // Centralized cleanup for all snapping state + const clearSnapping = () => { try { if (circleMarkerRef.current) { leafletMap.removeLayer(circleMarkerRef.current); @@ -152,8 +152,13 @@ export const useMeasurements = (snappingLayers: MapLibreMap[] = []) => { map.getCanvas().style.cursor = ""; } }); + // Clear all snapping refs closestPoint = null; closestPointRef.current = null; + snappingLatlngRef.current = null; + if (measureControl) { + measureControl.options.snappingLatlng = null; + } } catch (_) { // no-op safeguard } @@ -187,7 +192,7 @@ export const useMeasurements = (snappingLayers: MapLibreMap[] = []) => { // If mouse button is pressed (e.g. panning), do not snap if (e.buttons !== 0) { if (!isDraggingVertexRef.current) { - clearBlackPoint(); + clearSnapping(); } return; } @@ -203,30 +208,21 @@ export const useMeasurements = (snappingLayers: MapLibreMap[] = []) => { // Skip snapping indicator during vertex drag if snappingOnUpdate is disabled if (isDraggingVertexRef.current && !snappingOnUpdate) { - clearBlackPoint(); + clearSnapping(); return; } // Check if Snapping Modifier Key is pressed - if so, disable snapping temporarily if (isPressed) { - clearBlackPoint(); - if (circleMarkerRef.current) { - leafletMap.removeLayer(circleMarkerRef.current); - circleMarkerRef.current = null; - } - // Direct update to control instead of context - if (measureControl) { - measureControl.options.snappingLatlng = null; - } - snappingLatlngRef.current = null; - return; // Exit early - no snapping while modifier is pressed + clearSnapping(); + return; } // Check zoom level - only work if zoom >= configured minimum const currentZoom = leafletMap.getZoom(); if (currentZoom < snappingMinZoom) { - clearBlackPoint(); + clearSnapping(); return; } From 159c83fc5a9f6a15a43f45f854601288c3c0c6f7 Mon Sep 17 00:00:00 2001 From: Friedrich Hartmann Date: Wed, 3 Dec 2025 14:06:25 +0100 Subject: [PATCH 11/20] fix vertex ealy close conflict by tracking vertex count --- .../src/lib/hooks/useMeasurements.ts | 24 ++++++++++++++ .../src/lib/utils/measure-path.ts | 31 +++++++++++++------ .../measurements/src/lib/utils/snapping.ts | 19 +++++------- 3 files changed, 52 insertions(+), 22 deletions(-) diff --git a/libraries/commons/measurements/src/lib/hooks/useMeasurements.ts b/libraries/commons/measurements/src/lib/hooks/useMeasurements.ts index 64c4dab4d6..2080004849 100644 --- a/libraries/commons/measurements/src/lib/hooks/useMeasurements.ts +++ b/libraries/commons/measurements/src/lib/hooks/useMeasurements.ts @@ -107,6 +107,7 @@ export const useMeasurements = (snappingLayers: MapLibreMap[] = []) => { const isDraggingVertexRef = useRef(false); const lastSnappedCoordRef = useRef<[number, number] | null>(null); const statusRef = useRef(status); + const lastVertexCountRef = useRef(0); useEffect(() => { snappingLayersRef.current = snappingLayers; @@ -123,6 +124,7 @@ export const useMeasurements = (snappingLayers: MapLibreMap[] = []) => { useEffect(() => { statusRef.current = status; }, [status]); + useEffect(() => { const leafletMap = realRoutedMapRef.current?.leafletMap?.leafletElement; @@ -184,6 +186,13 @@ export const useMeasurements = (snappingLayers: MapLibreMap[] = []) => { }; const mousemoveHandler = (e: MouseEvent) => { + // Track vertex count to detect if Leaflet Draw adds a vertex before our click handler + // This helps prevents premature line finishing when snapping adds a new vertex + if (currentDrawHandlerRef.current?._markers) { + lastVertexCountRef.current = + currentDrawHandlerRef.current._markers.length; + } + // Prevent infinite loop from synthetic events we generate for snapping if ((e as any)._isSyntheticSnapped) { return; @@ -635,7 +644,19 @@ export const useMeasurements = (snappingLayers: MapLibreMap[] = []) => { const snappedLatlng = toLatLngFromClosestPoint(snapPoint); if (!snappedLatlng) return; + // Check if vertex count increased (meaning Leaflet Draw already added the vertex) + // If so, abort to prevent premature finishing or double-adding + const currentVertexCount = + drawHandler._markers && drawHandler._markers.length + ? drawHandler._markers.length + : 0; + + if (currentVertexCount > lastVertexCountRef.current) { + return; + } + // Check if snapping to first vertex (polygon closure) + // Use snapped position - snapping to first vertex should close the polygon if ( isFirstVertexMatch( drawHandler, @@ -651,6 +672,7 @@ export const useMeasurements = (snappingLayers: MapLibreMap[] = []) => { } // Check if snapping to last vertex (finish line measurement) + // Use snapped position - snapping to last vertex should finish the line if ( isLastVertexMatch( drawHandler, @@ -667,6 +689,8 @@ export const useMeasurements = (snappingLayers: MapLibreMap[] = []) => { // Directly add vertex at snapped position drawHandler.addVertex(snappedLatlng); + // Clear snap ref after adding vertex to prevent stale snap on next click + closestPointRef.current = null; event.stopPropagation(); event.stopImmediatePropagation(); }; diff --git a/libraries/commons/measurements/src/lib/utils/measure-path.ts b/libraries/commons/measurements/src/lib/utils/measure-path.ts index bc6f3867f1..28e4cdcb59 100644 --- a/libraries/commons/measurements/src/lib/utils/measure-path.ts +++ b/libraries/commons/measurements/src/lib/utils/measure-path.ts @@ -736,16 +736,27 @@ export const MeasurePolygon = Control.extend({ const latLng = layer.getLatLng(); latlngs.push(latLng); - if (index === 1) { - L.drawLocal.draw.handlers.polyline.tooltip.end = ` -
Den Endpunkt erneut anklicken, -
um die Streckenmessung zu beenden.
`; - } - if (index > 2) { - L.drawLocal.draw.handlers.polyline.tooltip.end = ` -
Den Endpunkt erneut anklicken, um die Streckenmessung zu beenden.
-
Zum Messen einer Fläche erneut auf den Startpunkt klicken.
`; - } + + // Add mouseover/mouseout for last vertex to show finish tooltip + // Only when hovering over the last marker, not unconditionally + layer.on("mouseover", (e) => { + const isLastVertex = e.target.customHandle === index; + if (isLastVertex && index >= 1) { + if (index >= 2) { + L.drawLocal.draw.handlers.polyline.tooltip.end = `Den Endpunkt erneut anklicken, um die Streckenmessung zu beenden.
Zum Messen einer Fläche erneut auf den Startpunkt klicken.`; + } else { + L.drawLocal.draw.handlers.polyline.tooltip.end = `Den Endpunkt erneut anklicken,
um die Streckenmessung zu beenden.`; + } + } + }); + + layer.on("mouseout", (e) => { + const isLastVertex = e.target.customHandle === index; + if (isLastVertex && index >= 1) { + // Reset to default tooltip + L.drawLocal.draw.handlers.polyline.tooltip.end = `Zum Beenden auf den letzten angelegten Punkt klicken.
Zum Messen einer Fläche auf den ersten angelegten Punkt klicken und die Fläche so schließen.`; + } + }); }); const formatPerimeter = calculateDistance(latlngs); diff --git a/libraries/commons/measurements/src/lib/utils/snapping.ts b/libraries/commons/measurements/src/lib/utils/snapping.ts index 4337704c96..d6f94f0d70 100644 --- a/libraries/commons/measurements/src/lib/utils/snapping.ts +++ b/libraries/commons/measurements/src/lib/utils/snapping.ts @@ -188,21 +188,16 @@ export const tryClosePolygon = (drawHandler: any): boolean => { }; /** - * Try to finish a line measurement by clicking its last vertex marker + * Try to finish a line measurement by calling _finishShape directly */ export const tryFinishLine = (drawHandler: any): boolean => { - const markers = drawHandler?._markers; - if (!markers || markers.length < 2) return false; - - const lastMarker = markers[markers.length - 1]; - const lastVertex = getLastVertexIfFinishable(drawHandler); - if (!lastMarker || !lastVertex) return false; + if (!drawHandler?._finishShape) return false; + + const latlngs = drawHandler?._poly?._latlngs; + if (!latlngs || latlngs.length < 2) return false; - console.debug("[snapping] Finishing line via last vertex click"); - lastMarker.fire("click", { - latlng: lastVertex, - target: lastMarker, - }); + console.debug("[snapping] Finishing line via _finishShape"); + drawHandler._finishShape(); return true; }; From f9468b9831f1a44d2ed43c4673c06c51da4b111b Mon Sep 17 00:00:00 2001 From: Friedrich Hartmann Date: Wed, 3 Dec 2025 14:19:51 +0100 Subject: [PATCH 12/20] collect label strings --- .../src/lib/hooks/useMeasurements.ts | 9 +++++---- .../measurements/src/lib/utils/labels.ts | 20 +++++++++++++++++++ .../src/lib/utils/measure-path.ts | 19 +++++++++--------- .../measurements/src/lib/utils/snapping.ts | 2 +- 4 files changed, 35 insertions(+), 15 deletions(-) create mode 100644 libraries/commons/measurements/src/lib/utils/labels.ts diff --git a/libraries/commons/measurements/src/lib/hooks/useMeasurements.ts b/libraries/commons/measurements/src/lib/hooks/useMeasurements.ts index 2080004849..8620827aec 100644 --- a/libraries/commons/measurements/src/lib/hooks/useMeasurements.ts +++ b/libraries/commons/measurements/src/lib/hooks/useMeasurements.ts @@ -29,6 +29,7 @@ import { } from "../utils/snapping"; import { filterArrByIds, findLargestNumber } from "../utils/shapes"; import { SnappingPoint } from "./../types"; +import { TOOLTIP_LABELS } from "../utils/labels"; import { extractPointsFromMeasurementShape } from "../snapping/utils/coordinateExtraction"; import { getSnappingPointsFromMapLibre } from "../snapping/utils/mapLibreExtraction"; @@ -171,8 +172,8 @@ export const useMeasurements = (snappingLayers: MapLibreMap[] = []) => { const updateTooltipTemplate = (isPressed: boolean) => { const snappingText = isPressed - ? "Snapping deaktiviert" - : `Snapping aktiv (${SNAPPING_MODIFIER_KEY} zum Deaktivieren)`; + ? TOOLTIP_LABELS.snapping.inactive + : TOOLTIP_LABELS.snapping.active; if ( L.drawLocal && @@ -180,8 +181,8 @@ export const useMeasurements = (snappingLayers: MapLibreMap[] = []) => { L.drawLocal.draw.handlers && L.drawLocal.draw.handlers.polyline ) { - L.drawLocal.draw.handlers.polyline.tooltip.start = `Klicken, um den Startpunkt der Messung zu setzen.
${snappingText}`; - L.drawLocal.draw.handlers.polyline.tooltip.cont = `Klicken (ggf. mehrmals), um die nächsten Punkte des Linienzuges zu setzen.
${snappingText}`; + L.drawLocal.draw.handlers.polyline.tooltip.start = `${TOOLTIP_LABELS.measurement.start}
${snappingText}`; + L.drawLocal.draw.handlers.polyline.tooltip.cont = `${TOOLTIP_LABELS.measurement.continue}
${snappingText}`; } }; diff --git a/libraries/commons/measurements/src/lib/utils/labels.ts b/libraries/commons/measurements/src/lib/utils/labels.ts new file mode 100644 index 0000000000..8b531f0f5a --- /dev/null +++ b/libraries/commons/measurements/src/lib/utils/labels.ts @@ -0,0 +1,20 @@ +export const TOOLTIP_LABELS = { + snapping: { + active: "Snapping aktiv (Alt zum Deaktivieren)", + inactive: "Snapping deaktiviert", + }, + measurement: { + start: "Klicken, um den Startpunkt der Messung zu setzen.", + continue: + "Klicken (ggf. mehrmals), um die nächsten Punkte des Linienzuges zu setzen.", + finishLine: "Zum Beenden auf den letzten angelegten Punkt klicken.", + finishPolygon: + "Zum Messen einer Fläche auf den ersten angelegten Punkt klicken und die Fläche so schließen.", + finishLineHover: + "Den Endpunkt erneut anklicken,
um die Streckenmessung zu beenden.", + finishPolygonHover: + "Den Endpunkt erneut anklicken, um die Streckenmessung zu beenden.
Zum Messen einer Fläche erneut auf den Startpunkt klicken.", + finishLineSimple: + "Den Endpunkt erneut anklicken,
um die Streckenmessung zu beenden.", + }, +}; diff --git a/libraries/commons/measurements/src/lib/utils/measure-path.ts b/libraries/commons/measurements/src/lib/utils/measure-path.ts index 28e4cdcb59..324b7d48b7 100644 --- a/libraries/commons/measurements/src/lib/utils/measure-path.ts +++ b/libraries/commons/measurements/src/lib/utils/measure-path.ts @@ -33,6 +33,7 @@ import { MeasurementLeafletEvent, MeasurePolygonControl, } from "../types/leaflet-extensions"; +import { TOOLTIP_LABELS } from "./labels"; export const MeasurePolygon = Control.extend({ options: { @@ -716,19 +717,15 @@ export const MeasurePolygon = Control.extend({ const area = calculateArea(latLngArray); if (e.target.customHandle === 0 && firsHovering) { this.options.cbUpdateAreaOfDrawingMeasurement(area); - L.drawLocal.draw.handlers.polyline.tooltip.end = `Den Startpunkt anklicken, um die Fläche zu schließen.`; + L.drawLocal.draw.handlers.polyline.tooltip.end = + TOOLTIP_LABELS.measurement.finishPolygon; } firsHovering = true; }); layer.on("mouseout", (e) => { if (e.target.customHandle === 0) { - const tooltipContent = ` -
-
Zum Beenden auf den letzten angelegten Punkt klicken.
-
Zum Messen einer Fläche auf den ersten angelegten Punkt klicken und die Fläche so schließen.
-
- `; + const tooltipContent = `${TOOLTIP_LABELS.measurement.finishLine}
${TOOLTIP_LABELS.measurement.finishPolygon}`; L.drawLocal.draw.handlers.polyline.tooltip.end = tooltipContent; this.options.cbUpdateAreaOfDrawingMeasurement(null); } @@ -743,9 +740,11 @@ export const MeasurePolygon = Control.extend({ const isLastVertex = e.target.customHandle === index; if (isLastVertex && index >= 1) { if (index >= 2) { - L.drawLocal.draw.handlers.polyline.tooltip.end = `Den Endpunkt erneut anklicken, um die Streckenmessung zu beenden.
Zum Messen einer Fläche erneut auf den Startpunkt klicken.`; + L.drawLocal.draw.handlers.polyline.tooltip.end = + TOOLTIP_LABELS.measurement.finishPolygonHover; } else { - L.drawLocal.draw.handlers.polyline.tooltip.end = `Den Endpunkt erneut anklicken,
um die Streckenmessung zu beenden.`; + L.drawLocal.draw.handlers.polyline.tooltip.end = + TOOLTIP_LABELS.measurement.finishLineHover; } } }); @@ -754,7 +753,7 @@ export const MeasurePolygon = Control.extend({ const isLastVertex = e.target.customHandle === index; if (isLastVertex && index >= 1) { // Reset to default tooltip - L.drawLocal.draw.handlers.polyline.tooltip.end = `Zum Beenden auf den letzten angelegten Punkt klicken.
Zum Messen einer Fläche auf den ersten angelegten Punkt klicken und die Fläche so schließen.`; + L.drawLocal.draw.handlers.polyline.tooltip.end = `${TOOLTIP_LABELS.measurement.finishLine}
${TOOLTIP_LABELS.measurement.finishPolygon}`; } }); }); diff --git a/libraries/commons/measurements/src/lib/utils/snapping.ts b/libraries/commons/measurements/src/lib/utils/snapping.ts index d6f94f0d70..234af4c395 100644 --- a/libraries/commons/measurements/src/lib/utils/snapping.ts +++ b/libraries/commons/measurements/src/lib/utils/snapping.ts @@ -192,7 +192,7 @@ export const tryClosePolygon = (drawHandler: any): boolean => { */ export const tryFinishLine = (drawHandler: any): boolean => { if (!drawHandler?._finishShape) return false; - + const latlngs = drawHandler?._poly?._latlngs; if (!latlngs || latlngs.length < 2) return false; From e263694bd7d79bd5aedb269c06fb0751c59b2c6e Mon Sep 17 00:00:00 2001 From: Friedrich Hartmann Date: Wed, 3 Dec 2025 14:20:07 +0100 Subject: [PATCH 13/20] collect label strings --- libraries/commons/measurements/src/lib/hooks/useMeasurements.ts | 2 +- libraries/commons/measurements/src/lib/{utils => }/labels.ts | 0 libraries/commons/measurements/src/lib/utils/measure-path.ts | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename libraries/commons/measurements/src/lib/{utils => }/labels.ts (100%) diff --git a/libraries/commons/measurements/src/lib/hooks/useMeasurements.ts b/libraries/commons/measurements/src/lib/hooks/useMeasurements.ts index 8620827aec..4bde73d4c3 100644 --- a/libraries/commons/measurements/src/lib/hooks/useMeasurements.ts +++ b/libraries/commons/measurements/src/lib/hooks/useMeasurements.ts @@ -29,7 +29,7 @@ import { } from "../utils/snapping"; import { filterArrByIds, findLargestNumber } from "../utils/shapes"; import { SnappingPoint } from "./../types"; -import { TOOLTIP_LABELS } from "../utils/labels"; +import { TOOLTIP_LABELS } from "../labels"; import { extractPointsFromMeasurementShape } from "../snapping/utils/coordinateExtraction"; import { getSnappingPointsFromMapLibre } from "../snapping/utils/mapLibreExtraction"; diff --git a/libraries/commons/measurements/src/lib/utils/labels.ts b/libraries/commons/measurements/src/lib/labels.ts similarity index 100% rename from libraries/commons/measurements/src/lib/utils/labels.ts rename to libraries/commons/measurements/src/lib/labels.ts diff --git a/libraries/commons/measurements/src/lib/utils/measure-path.ts b/libraries/commons/measurements/src/lib/utils/measure-path.ts index 324b7d48b7..71d807a4df 100644 --- a/libraries/commons/measurements/src/lib/utils/measure-path.ts +++ b/libraries/commons/measurements/src/lib/utils/measure-path.ts @@ -33,7 +33,7 @@ import { MeasurementLeafletEvent, MeasurePolygonControl, } from "../types/leaflet-extensions"; -import { TOOLTIP_LABELS } from "./labels"; +import { TOOLTIP_LABELS } from "../labels"; export const MeasurePolygon = Control.extend({ options: { From c868af378325e694017f17b5ef395ab049eb658d Mon Sep 17 00:00:00 2001 From: Friedrich Hartmann Date: Wed, 3 Dec 2025 14:40:45 +0100 Subject: [PATCH 14/20] collect more strings, remove external image urls --- .../src/lib/hooks/useMeasurements.ts | 2 +- .../commons/measurements/src/lib/labels.ts | 7 +++ .../src/lib/utils/measure-path.ts | 43 +++++++++---------- 3 files changed, 29 insertions(+), 23 deletions(-) diff --git a/libraries/commons/measurements/src/lib/hooks/useMeasurements.ts b/libraries/commons/measurements/src/lib/hooks/useMeasurements.ts index 4bde73d4c3..ed7226a062 100644 --- a/libraries/commons/measurements/src/lib/hooks/useMeasurements.ts +++ b/libraries/commons/measurements/src/lib/hooks/useMeasurements.ts @@ -807,7 +807,7 @@ export const useMeasurements = (snappingLayers: MapLibreMap[] = []) => { // icon_polygonInactive: polygonIcon, activeShape, mode_btn: `
+
`, - msj_disable_tool: "Do you want to disable the tool?", + msj_disable_tool: TOOLTIP_LABELS.general.disableTool, device, shapes, snappingLatlng: snappingLatlngRef?.current, diff --git a/libraries/commons/measurements/src/lib/labels.ts b/libraries/commons/measurements/src/lib/labels.ts index 8b531f0f5a..da7397852a 100644 --- a/libraries/commons/measurements/src/lib/labels.ts +++ b/libraries/commons/measurements/src/lib/labels.ts @@ -17,4 +17,11 @@ export const TOOLTIP_LABELS = { finishLineSimple: "Den Endpunkt erneut anklicken,
um die Streckenmessung zu beenden.", }, + general: { + disableTool: "Möchten Sie das Tool deaktivieren?", + results: "Ergebnisse", + area: "Fläche", + perimeter: "Umfang", + measurementMode: "Messmodus", + }, }; diff --git a/libraries/commons/measurements/src/lib/utils/measure-path.ts b/libraries/commons/measurements/src/lib/utils/measure-path.ts index 71d807a4df..e5f692ad20 100644 --- a/libraries/commons/measurements/src/lib/utils/measure-path.ts +++ b/libraries/commons/measurements/src/lib/utils/measure-path.ts @@ -35,16 +35,22 @@ import { } from "../types/leaflet-extensions"; import { TOOLTIP_LABELS } from "../labels"; +// Placeholder for icons to not show broken images +// Transparent 1x1 GIF (43 bytes) +// See http://probablyprogramming.com/2009/03/15/the-tiniest-gif-ever +const TRANSPARENT_PIXEL = + "data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=="; + export const MeasurePolygon = Control.extend({ options: { position: "topright", - icon_lineActive: "https://img.icons8.com/?size=48&id=98497&format=png", - icon_lineInactive: "https://img.icons8.com/?size=48&id=98463&format=png", - icon_polygonActive: "https://img.icons8.com/?size=48&id=98497&format=png", - icon_polygonInactive: "https://img.icons8.com/?size=48&id=98463&format=png", - html_template: `

Results

-

Area:
_p_area

-

Perimeter :
_p_perimeter

`, + icon_lineActive: TRANSPARENT_PIXEL, + icon_lineInactive: TRANSPARENT_PIXEL, + icon_polygonActive: TRANSPARENT_PIXEL, + icon_polygonInactive: TRANSPARENT_PIXEL, + html_template: `

${TOOLTIP_LABELS.general.results}

+

${TOOLTIP_LABELS.general.area}:
_p_area

+

${TOOLTIP_LABELS.general.perimeter} :
_p_perimeter

`, height: 130, width: 150, mode_btn: "", @@ -53,7 +59,7 @@ export const MeasurePolygon = Control.extend({ weight_polygon: "2", isDrawing: false, changeModeButtonActive: false, - msj_disable_tool: "Möchten Sie das Tool deaktivieren?", + msj_disable_tool: TOOLTIP_LABELS.general.disableTool, shapes: [], activeShape: null, shapeMode: "line", @@ -233,17 +239,10 @@ export const MeasurePolygon = Control.extend({ this.options.currenLine = this._measureHandler; this.options.cbSetCurrentDrawHandler(this._measureHandler); - const tooltipContent = ` -
-
Zum Beenden auf den letzten angelegten Punkt klicken.
-
Zum Messen einer Fläche auf den ersten angelegten Punkt klicken und die Fläche so schließen.
-
-`; - - L.drawLocal.draw.handlers.polyline.tooltip.start = - "Klicken, um den Startpunkt der Messung zu setzen.
Snapping aktiv (Alt zum Deaktivieren)"; - L.drawLocal.draw.handlers.polyline.tooltip.cont = - "Klicken (ggf. mehrmals), um die nächsten Punkte des Linienzuges zu setzen.
Snapping aktiv (Alt zum Deaktivieren)"; + const tooltipContent = `${TOOLTIP_LABELS.measurement.finishLine}
${TOOLTIP_LABELS.measurement.finishPolygon}`; + + L.drawLocal.draw.handlers.polyline.tooltip.start = `${TOOLTIP_LABELS.measurement.start}
${TOOLTIP_LABELS.snapping.active}`; + L.drawLocal.draw.handlers.polyline.tooltip.cont = `${TOOLTIP_LABELS.measurement.continue}
${TOOLTIP_LABELS.snapping.active}`; L.drawLocal.draw.handlers.polyline.tooltip.end = tooltipContent; this._measureHandler.enable(); @@ -458,7 +457,7 @@ export const MeasurePolygon = Control.extend({
`; lineIcon.href = "#"; - lineIcon.title = "Messmodus"; + lineIcon.title = TOOLTIP_LABELS.general.measurementMode; const iconsWrapper = DomUtil.create("div", "m-icons-wrapper"); iconsWrapper.appendChild(linesContainer); @@ -844,7 +843,7 @@ export const MeasurePolygon = Control.extend({ popupPane ); - this.options.customTooltip.innerHTML = `
Klicken, um den Startpunkt der Messung zu setzen.
`; + this.options.customTooltip.innerHTML = `
${TOOLTIP_LABELS.measurement.start}
`; this.options.customTooltip.style.visibility = "inherit"; const pos = this._map.latLngToLayerPoint(event.latlng); @@ -1282,7 +1281,7 @@ export const MeasurePolygon = Control.extend({ const mode = this.options.measurementMode; if (mode === "measurement") { L.drawLocal.draw.handlers.polyline.tooltip.start = - "Klicken, um den Startpunkt der Messung zu setzen."; + TOOLTIP_LABELS.measurement.start; this._clearMeasurements(); this.loadMeasurements(); // const drawBtn = document.getElementById("draw_shape"); From dc631478a84169a808b423aa1038df7bb4e02289 Mon Sep 17 00:00:00 2001 From: Friedrich Hartmann Date: Wed, 3 Dec 2025 15:09:05 +0100 Subject: [PATCH 15/20] prevent 0 length single point measurements --- .../src/lib/hooks/useMeasurements.ts | 17 +++++------ .../src/lib/utils/measure-path.ts | 15 ++++++++++ .../measurements/src/lib/utils/snapping.ts | 30 +++++++++++++++++++ 3 files changed, 52 insertions(+), 10 deletions(-) diff --git a/libraries/commons/measurements/src/lib/hooks/useMeasurements.ts b/libraries/commons/measurements/src/lib/hooks/useMeasurements.ts index ed7226a062..fe6006abb2 100644 --- a/libraries/commons/measurements/src/lib/hooks/useMeasurements.ts +++ b/libraries/commons/measurements/src/lib/hooks/useMeasurements.ts @@ -16,9 +16,7 @@ import { toLatLngFromClosestPoint, isCoordMatchLatLng, isFirstVertexMatch, - isLastVertexMatch, tryClosePolygon, - tryFinishLine, distanceBetweenLatLng, screenPixelDistance, pixelRadiusToMeters, @@ -26,6 +24,7 @@ import { findClosestSnappingPoint, SNAPPING_MODIFIER_KEY, isSnappingModifierPressed, + handleDuplicateVertex, } from "../utils/snapping"; import { filterArrByIds, findLargestNumber } from "../utils/shapes"; import { SnappingPoint } from "./../types"; @@ -672,20 +671,18 @@ export const useMeasurements = (snappingLayers: MapLibreMap[] = []) => { } } - // Check if snapping to last vertex (finish line measurement) - // Use snapped position - snapping to last vertex should finish the line + // Check for duplicate of LAST vertex + // This covers both "finish line" (on 2+ points) and "prevent duplicate start" (on 1 point) if ( - isLastVertexMatch( + handleDuplicateVertex( drawHandler, snappedLatlng, snappingIdentityDistanceMeters ) ) { - if (tryFinishLine(drawHandler)) { - event.stopPropagation(); - event.stopImmediatePropagation(); - return; - } + event.stopPropagation(); + event.stopImmediatePropagation(); + return; } // Directly add vertex at snapped position diff --git a/libraries/commons/measurements/src/lib/utils/measure-path.ts b/libraries/commons/measurements/src/lib/utils/measure-path.ts index e5f692ad20..0edb2c1ea9 100644 --- a/libraries/commons/measurements/src/lib/utils/measure-path.ts +++ b/libraries/commons/measurements/src/lib/utils/measure-path.ts @@ -34,6 +34,7 @@ import { MeasurePolygonControl, } from "../types/leaflet-extensions"; import { TOOLTIP_LABELS } from "../labels"; +import { distanceBetweenLatLng } from "./snapping"; // Placeholder for icons to not show broken images // Transparent 1x1 GIF (43 bytes) @@ -231,6 +232,20 @@ export const MeasurePolygon = Control.extend({ currentVertexCount: this._markers?.length || 0, timestamp: Date.now(), }); + + // Check for duplicate vertex to prevent 0-length segments + if (this._markers && this._markers.length > 0) { + const lastMarker = this._markers[this._markers.length - 1]; + if ( + distanceBetweenLatLng(finalLatlng, lastMarker.getLatLng()) < 0.001 + ) { + console.warn( + "[measure-path] Preventing 0-length segment in addVertex - duplicate vertex ignored" + ); + return; + } + } + (self as any)._lastVertexAdded = Date.now(); return originalAddVertex.call(this, finalLatlng); }; diff --git a/libraries/commons/measurements/src/lib/utils/snapping.ts b/libraries/commons/measurements/src/lib/utils/snapping.ts index 234af4c395..9fcac719d0 100644 --- a/libraries/commons/measurements/src/lib/utils/snapping.ts +++ b/libraries/commons/measurements/src/lib/utils/snapping.ts @@ -258,3 +258,33 @@ export const adjustClickPosition = ( return true; }; + +/** + * Handle potential duplicate vertex (snapped or unsnapped). + * Returns true if the vertex was handled (either finished the line or ignored as duplicate). + * Returns false if the vertex should be added as new. + */ +export const handleDuplicateVertex = ( + drawHandler: any, + position: { lat: number; lng: number }, + thresholdMeters: number +): boolean => { + if (drawHandler._markers && drawHandler._markers.length > 0) { + const lastMarker = drawHandler._markers[drawHandler._markers.length - 1]; + const lastLatLng = lastMarker.getLatLng(); + + if (distanceBetweenLatLng(position, lastLatLng) < thresholdMeters) { + // It is the same point as the last one. + // Try to finish if possible (Leaflet Draw logic: click last point to finish) + if (tryFinishLine(drawHandler)) { + return true; // Handled (finished) + } + // If we couldn't finish (e.g. only 1 point), it's just a duplicate. Ignore it. + console.debug( + "[snapping] Ignoring duplicate vertex click (0-length segment prevention)" + ); + return true; // Handled (ignored) + } + } + return false; // Not a duplicate +}; From d3a4efd2b02b412ef09a9260241748fa391781c3 Mon Sep 17 00:00:00 2001 From: Friedrich Hartmann Date: Wed, 3 Dec 2025 15:50:29 +0100 Subject: [PATCH 16/20] handle geoportal leaflet map modeswitching measure/sachdaten --- .../GeoportalMap/controls/MapWrapper.tsx | 28 ++-------- apps/geoportal/src/app/hooks/useMapModes.ts | 54 +++++++++++++++++++ 2 files changed, 57 insertions(+), 25 deletions(-) create mode 100644 apps/geoportal/src/app/hooks/useMapModes.ts diff --git a/apps/geoportal/src/app/components/GeoportalMap/controls/MapWrapper.tsx b/apps/geoportal/src/app/components/GeoportalMap/controls/MapWrapper.tsx index 2702ad5f0e..e25515273b 100644 --- a/apps/geoportal/src/app/components/GeoportalMap/controls/MapWrapper.tsx +++ b/apps/geoportal/src/app/components/GeoportalMap/controls/MapWrapper.tsx @@ -50,10 +50,7 @@ import { ControlLayoutCanvas, } from "@carma-mapping/map-controls-layout"; import { useFeatureFlags } from "@carma-providers/feature-flag"; -import { - MeasurementControl, - useMapMeasurementsContext, -} from "@carma-commons/measurements"; +import { MeasurementControl } from "@carma-commons/measurements"; import { GeoportalMap } from "../GeoportalMap.tsx"; import LibreGeoportalMap from "../LibreGeoportalMap.tsx"; @@ -63,15 +60,13 @@ import LayerWrapper from "../../layers/LayerWrapper.tsx"; import useLeafletZoomControls from "../../../hooks/leaflet/useLeafletZoomControls.ts"; import { useAppSearchParams } from "../../../hooks/useAppSearchParams"; import { useDispatchSachdatenInfoText } from "../../../hooks/useDispatchSachdatenInfoText.ts"; -import { useFeatureInfoModeCursorStyle } from "../../../hooks/useFeatureInfoModeCursorStyle.ts"; +import { useMapModes } from "../../../hooks/useMapModes.ts"; import { useMapStyleReduxSync } from "../../../hooks/useMapStyleReduxSync"; import { useTourRefCollabLabels } from "../../../hooks/useTourRefCollabLabels.ts"; import { useWindowSize } from "../../../hooks/useWindowSize.ts"; import { useOblique } from "../../../oblique/hooks/useOblique.ts"; -import { cancelOngoingRequests } from "../topicmap.utils"; - import { setFeatures, setPreferredLayerId, @@ -90,7 +85,6 @@ import { getZenMode, setZenMode, toggleUIMode, - setUIMode, UIMode, } from "../../../store/slices/ui.ts"; @@ -135,16 +129,7 @@ const MapWrapper = () => { const { isObliqueMode, isPreviewVisible: isObliquePreviewVisible } = useOblique(); - const { isMeasurementEnabled } = useMapMeasurementsContext(); - - useEffect(() => { - // sync legacy redux measurement mode with ui mode, remove once measurement provider handles this fully - if (isMeasurementEnabled && uiMode !== UIMode.MEASUREMENT) { - dispatch(setUIMode(UIMode.MEASUREMENT)); - } else if (!isMeasurementEnabled && uiMode === UIMode.MEASUREMENT) { - dispatch(setUIMode(UIMode.DEFAULT)); - } - }, [isMeasurementEnabled, uiMode, dispatch]); + const { handleToggleFeatureInfo } = useMapModes(); const { handleZoomIn: handleZoomInCesium, @@ -225,13 +210,6 @@ const MapWrapper = () => { const { gazData } = useGazData(); const { width, height } = useWindowSize(wrapperRef); - const handleToggleFeatureInfo = () => { - cancelOngoingRequests(); - dispatch(toggleUIMode(UIMode.FEATURE_INFO)); - }; - - useFeatureInfoModeCursorStyle(); - const { setSelection } = useSelection(); const onGazetteerSelection = (selection: SearchResultItem) => { diff --git a/apps/geoportal/src/app/hooks/useMapModes.ts b/apps/geoportal/src/app/hooks/useMapModes.ts new file mode 100644 index 0000000000..54f983c075 --- /dev/null +++ b/apps/geoportal/src/app/hooks/useMapModes.ts @@ -0,0 +1,54 @@ +import { useEffect, useRef } from "react"; +import { useDispatch, useSelector } from "react-redux"; +import { useMapMeasurementsContext } from "@carma-commons/measurements"; +import { getUIMode, setUIMode, toggleUIMode, UIMode } from "../store/slices/ui"; +import { useFeatureInfoModeCursorStyle } from "./useFeatureInfoModeCursorStyle"; +import { cancelOngoingRequests } from "../components/GeoportalMap/topicmap.utils"; + +export const useMapModes = () => { + const dispatch = useDispatch(); + const uiMode = useSelector(getUIMode); + const { isMeasurementEnabled, setMeasurementEnabled } = + useMapMeasurementsContext(); + const prevUiModeRef = useRef(uiMode); + + useFeatureInfoModeCursorStyle(); + + useEffect(() => { + const justSwitchedToFeatureInfo = + uiMode === UIMode.FEATURE_INFO && + prevUiModeRef.current !== UIMode.FEATURE_INFO; + + if (justSwitchedToFeatureInfo) { + if (isMeasurementEnabled) { + setMeasurementEnabled(false); + } + } else { + // sync legacy redux measurement mode with ui mode, remove once measurement provider handles this fully + if (isMeasurementEnabled && uiMode !== UIMode.MEASUREMENT) { + dispatch(setUIMode(UIMode.MEASUREMENT)); + } else if (!isMeasurementEnabled && uiMode === UIMode.MEASUREMENT) { + dispatch(setUIMode(UIMode.DEFAULT)); + } + } + prevUiModeRef.current = uiMode; + }, [isMeasurementEnabled, uiMode, dispatch, setMeasurementEnabled]); + + // Prevent cursor race condition when switching from Measurement to Feature Info + // The measurement tool resets cursor to 'pointer' when disabled, overriding Feature Info's 'crosshair' + useEffect(() => { + if (uiMode === UIMode.FEATURE_INFO && !isMeasurementEnabled) { + const mapElement = document.getElementById("routedMap"); + if (mapElement) { + mapElement.style.cursor = "crosshair"; + } + } + }, [uiMode, isMeasurementEnabled]); + + const handleToggleFeatureInfo = () => { + cancelOngoingRequests(); + dispatch(toggleUIMode(UIMode.FEATURE_INFO)); + }; + + return { handleToggleFeatureInfo }; +}; From 04be9989471ddcfb2f425135a41e6f157a54bf0e Mon Sep 17 00:00:00 2001 From: Friedrich Hartmann Date: Wed, 3 Dec 2025 16:17:09 +0100 Subject: [PATCH 17/20] consolidate mode switching in geoportal and fix cursor style handling conflicts --- .../components/GeoportalMap/GeoportalMap.tsx | 6 +++--- .../GeoportalMap/controls/MapWrapper.tsx | 1 - .../hooks/useFeatureInfoModeCursorStyle.ts | 16 -------------- .../src/app/hooks/useMapCursorStyle.ts | 21 +++++++++++++++++++ apps/geoportal/src/app/hooks/useMapModes.ts | 14 ------------- 5 files changed, 24 insertions(+), 34 deletions(-) delete mode 100644 apps/geoportal/src/app/hooks/useFeatureInfoModeCursorStyle.ts create mode 100644 apps/geoportal/src/app/hooks/useMapCursorStyle.ts diff --git a/apps/geoportal/src/app/components/GeoportalMap/GeoportalMap.tsx b/apps/geoportal/src/app/components/GeoportalMap/GeoportalMap.tsx index 0def74dd9e..3a7c77dbcd 100644 --- a/apps/geoportal/src/app/components/GeoportalMap/GeoportalMap.tsx +++ b/apps/geoportal/src/app/components/GeoportalMap/GeoportalMap.tsx @@ -80,7 +80,7 @@ import { addCssToOverlayHelperItem } from "../../helper/overlayHelper.ts"; import useLeafletZoomControls from "../../hooks/leaflet/useLeafletZoomControls.ts"; import { useDispatchSachdatenInfoText } from "../../hooks/useDispatchSachdatenInfoText.ts"; -import { useFeatureInfoModeCursorStyle } from "../../hooks/useFeatureInfoModeCursorStyle.ts"; +import { useMapCursorStyle } from "../../hooks/useMapCursorStyle.ts"; import { useObliqueInitializer } from "../../oblique/hooks/useObliqueInitializer.ts"; import { useGeoportalFrameworkSwitcher } from "./controls/use-geoportal-framework-switcher.ts"; @@ -352,8 +352,6 @@ export const GeoportalMap = ({ height, width, allow3d }: MapProps) => { const { gazData } = useGazData(); - useFeatureInfoModeCursorStyle(); - const onComplete = useCallback( (selection: SelectionItem) => { if (layers.filter((l) => l.layerType === "vector").length === 0) return; @@ -477,6 +475,8 @@ export const GeoportalMap = ({ height, width, allow3d }: MapProps) => { useMeasurements(maplibreMaps); + useMapCursorStyle(); + useEffect(() => { const leaflet = getLeafletMap(); if (uiMode !== UIMode.FEATURE_INFO && marker !== undefined && leaflet) { diff --git a/apps/geoportal/src/app/components/GeoportalMap/controls/MapWrapper.tsx b/apps/geoportal/src/app/components/GeoportalMap/controls/MapWrapper.tsx index e25515273b..32d1fdefa6 100644 --- a/apps/geoportal/src/app/components/GeoportalMap/controls/MapWrapper.tsx +++ b/apps/geoportal/src/app/components/GeoportalMap/controls/MapWrapper.tsx @@ -84,7 +84,6 @@ import { getUIMode, getZenMode, setZenMode, - toggleUIMode, UIMode, } from "../../../store/slices/ui.ts"; diff --git a/apps/geoportal/src/app/hooks/useFeatureInfoModeCursorStyle.ts b/apps/geoportal/src/app/hooks/useFeatureInfoModeCursorStyle.ts deleted file mode 100644 index 8e2214cd34..0000000000 --- a/apps/geoportal/src/app/hooks/useFeatureInfoModeCursorStyle.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { useEffect } from "react"; -import { useSelector } from "react-redux"; -import { getUIMode, UIMode } from "../store/slices/ui"; - -export const useFeatureInfoModeCursorStyle = ( - topicMapElementId: string = "routedMap" -) => { - const uiMode = useSelector(getUIMode); - const isModeFeatureInfo = uiMode === UIMode.FEATURE_INFO; - useEffect(() => { - const mapElement = document.getElementById(topicMapElementId); - if (mapElement) { - mapElement.style.cursor = isModeFeatureInfo ? "crosshair" : "pointer"; - } - }, [isModeFeatureInfo, topicMapElementId]); -}; diff --git a/apps/geoportal/src/app/hooks/useMapCursorStyle.ts b/apps/geoportal/src/app/hooks/useMapCursorStyle.ts new file mode 100644 index 0000000000..88a5e216bc --- /dev/null +++ b/apps/geoportal/src/app/hooks/useMapCursorStyle.ts @@ -0,0 +1,21 @@ +import { useEffect } from "react"; +import { useSelector } from "react-redux"; +import { getUIMode, UIMode } from "../store/slices/ui"; +import { useMapMeasurementsContext } from "@carma-commons/measurements"; + +export const useMapCursorStyle = (topicMapElementId: string = "routedMap") => { + const uiMode = useSelector(getUIMode); + const { isMeasurementEnabled } = useMapMeasurementsContext(); + + const isModeFeatureInfo = uiMode === UIMode.FEATURE_INFO; + + // Determine if we should show crosshair (either Feature Info OR Measurement) + const shouldShowCrosshair = isModeFeatureInfo || isMeasurementEnabled; + + useEffect(() => { + const mapElement = document.getElementById(topicMapElementId); + if (mapElement) { + mapElement.style.cursor = shouldShowCrosshair ? "crosshair" : "pointer"; + } + }, [shouldShowCrosshair, topicMapElementId, isMeasurementEnabled]); +}; diff --git a/apps/geoportal/src/app/hooks/useMapModes.ts b/apps/geoportal/src/app/hooks/useMapModes.ts index 54f983c075..ac5916485b 100644 --- a/apps/geoportal/src/app/hooks/useMapModes.ts +++ b/apps/geoportal/src/app/hooks/useMapModes.ts @@ -2,7 +2,6 @@ import { useEffect, useRef } from "react"; import { useDispatch, useSelector } from "react-redux"; import { useMapMeasurementsContext } from "@carma-commons/measurements"; import { getUIMode, setUIMode, toggleUIMode, UIMode } from "../store/slices/ui"; -import { useFeatureInfoModeCursorStyle } from "./useFeatureInfoModeCursorStyle"; import { cancelOngoingRequests } from "../components/GeoportalMap/topicmap.utils"; export const useMapModes = () => { @@ -12,8 +11,6 @@ export const useMapModes = () => { useMapMeasurementsContext(); const prevUiModeRef = useRef(uiMode); - useFeatureInfoModeCursorStyle(); - useEffect(() => { const justSwitchedToFeatureInfo = uiMode === UIMode.FEATURE_INFO && @@ -34,17 +31,6 @@ export const useMapModes = () => { prevUiModeRef.current = uiMode; }, [isMeasurementEnabled, uiMode, dispatch, setMeasurementEnabled]); - // Prevent cursor race condition when switching from Measurement to Feature Info - // The measurement tool resets cursor to 'pointer' when disabled, overriding Feature Info's 'crosshair' - useEffect(() => { - if (uiMode === UIMode.FEATURE_INFO && !isMeasurementEnabled) { - const mapElement = document.getElementById("routedMap"); - if (mapElement) { - mapElement.style.cursor = "crosshair"; - } - } - }, [uiMode, isMeasurementEnabled]); - const handleToggleFeatureInfo = () => { cancelOngoingRequests(); dispatch(toggleUIMode(UIMode.FEATURE_INFO)); From d1a0aaeecf51769f4849446eec350f24fdc86e3c Mon Sep 17 00:00:00 2001 From: Friedrich Hartmann Date: Wed, 3 Dec 2025 18:26:50 +0100 Subject: [PATCH 18/20] wipunify snapping unsnaped --- .../commons/measurements/src/lib/hooks/useMeasurements.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libraries/commons/measurements/src/lib/hooks/useMeasurements.ts b/libraries/commons/measurements/src/lib/hooks/useMeasurements.ts index fe6006abb2..bb5b19d1eb 100644 --- a/libraries/commons/measurements/src/lib/hooks/useMeasurements.ts +++ b/libraries/commons/measurements/src/lib/hooks/useMeasurements.ts @@ -352,8 +352,8 @@ export const useMeasurements = (snappingLayers: MapLibreMap[] = []) => { isSnapped = true; } closestPoint = snappedFeature; - // Only store snap point if actually snapped - prevents click handler from using stale/unsnapped positions - closestPointRef.current = isSnapped ? snappedFeature : null; + // Store snap point (snapped or unsnapped) - unifies click handling + closestPointRef.current = snappedFeature; const finalLatLng = toLatLngFromClosestPoint(closestPoint); // Logic for updating snappingLatlng From c3018e0b602150ae2c30ed578dba311c89b47064 Mon Sep 17 00:00:00 2001 From: Friedrich Hartmann Date: Wed, 3 Dec 2025 18:57:08 +0100 Subject: [PATCH 19/20] wip unify snapping/unsnapped handling, snap to other measurements --- .../src/lib/hooks/useMeasurements.ts | 151 ++++++++---------- 1 file changed, 71 insertions(+), 80 deletions(-) diff --git a/libraries/commons/measurements/src/lib/hooks/useMeasurements.ts b/libraries/commons/measurements/src/lib/hooks/useMeasurements.ts index bb5b19d1eb..26c80ae4a2 100644 --- a/libraries/commons/measurements/src/lib/hooks/useMeasurements.ts +++ b/libraries/commons/measurements/src/lib/hooks/useMeasurements.ts @@ -109,12 +109,20 @@ export const useMeasurements = (snappingLayers: MapLibreMap[] = []) => { const statusRef = useRef(status); const lastVertexCountRef = useRef(0); + const cachedShapePointsRef = useRef([]); + useEffect(() => { snappingLayersRef.current = snappingLayers; }, [snappingLayers]); useEffect(() => { shapesRef.current = shapes; + // Update cache for snapping points from shapes + const points: SnappingPoint[] = []; + shapes.forEach((shape: any) => { + points.push(...extractPointsFromMeasurementShape(shape, "measurements")); + }); + cachedShapePointsRef.current = points; }, [shapes]); useEffect(() => { @@ -134,8 +142,8 @@ export const useMeasurements = (snappingLayers: MapLibreMap[] = []) => { typeof leafletMap.on === "function" ) { // Import L from leaflet - let closestPoint: SnappingPoint | null = null; - const closestPointRef = { current: null as SnappingPoint | null }; // Stable ref to preserve closestPoint + let nextVertexCandidate: SnappingPoint | null = null; + const nextVertexCandidateRef = { current: null as SnappingPoint | null }; // Stable ref // Centralized cleanup for all snapping state const clearSnapping = () => { @@ -155,8 +163,8 @@ export const useMeasurements = (snappingLayers: MapLibreMap[] = []) => { } }); // Clear all snapping refs - closestPoint = null; - closestPointRef.current = null; + nextVertexCandidate = null; + nextVertexCandidateRef.current = null; snappingLatlngRef.current = null; if (measureControl) { measureControl.options.snappingLatlng = null; @@ -230,11 +238,6 @@ export const useMeasurements = (snappingLayers: MapLibreMap[] = []) => { // Check zoom level - only work if zoom >= configured minimum const currentZoom = leafletMap.getZoom(); - if (currentZoom < snappingMinZoom) { - clearSnapping(); - return; - } - // Remove old circle if exists if (circleMarkerRef.current) { leafletMap.removeLayer(circleMarkerRef.current); @@ -272,24 +275,20 @@ export const useMeasurements = (snappingLayers: MapLibreMap[] = []) => { const coordinatePoints: SnappingPoint[] = []; // 1. Extract from vector features (loop through all MapLibre maps) - coordinatePoints.push( - ...getSnappingPointsFromMapLibre( - currentSnappingLayers, - { x: e.clientX, y: e.clientY }, - currentRadius - ) - ); + // Only extract if zoom is high enough + if (currentZoom >= snappingMinZoom) { + coordinatePoints.push( + ...getSnappingPointsFromMapLibre( + currentSnappingLayers, + { x: e.clientX, y: e.clientY }, + currentRadius + ) + ); + } // 2. Extract from measurement shapes (independent of MapLibre) - // Use shapesRef which is kept in sync via useEffect - const currentShapes = shapesRef.current; - currentShapes.forEach((shape: any) => { - const points = extractPointsFromMeasurementShape( - shape, - "measurements" - ); - coordinatePoints.push(...points); - }); + // Use cached points + coordinatePoints.push(...cachedShapePointsRef.current); // 3. Extract from in-progress drawing (if currently drawing) const currentDrawHandlerValue = currentDrawHandlerRef.current; @@ -322,40 +321,32 @@ export const useMeasurements = (snappingLayers: MapLibreMap[] = []) => { ); // Determine snapping point - let isSnapped = false; - let snappedFeature: any; - - if (!closestResult) { - // No points found - use mouse pointer but don't show indicator - snappedFeature = { - type: "Feature", - geometry: { - type: "Point", - coordinates: [mouseLatLng.lng, mouseLatLng.lat], - }, - properties: { black: true }, - }; - isSnapped = false; - } else { - // Snap to the closest point found within query radius - snappedFeature = { - type: "Feature", - geometry: { - type: "Point", - coordinates: closestResult.point.coordinates, - }, - properties: { - black: true, - source: closestResult.point.sourceId, - }, - }; - isSnapped = true; - } - closestPoint = snappedFeature; - // Store snap point (snapped or unsnapped) - unifies click handling - closestPointRef.current = snappedFeature; + const isSnapped = !!closestResult; + const coordinates = closestResult + ? closestResult.point.coordinates + : [mouseLatLng.lng, mouseLatLng.lat]; + + const sourceId = closestResult + ? closestResult.point.sourceId + : "pointerposition"; + + const snappedFeature: any = { + type: "Feature", + geometry: { + type: "Point", + coordinates: coordinates, + }, + properties: { + isSnapped: isSnapped, + source: sourceId, + }, + }; - const finalLatLng = toLatLngFromClosestPoint(closestPoint); + nextVertexCandidate = snappedFeature; + // Store candidate (snapped or unsnapped) + nextVertexCandidateRef.current = snappedFeature; + + const finalLatLng = toLatLngFromClosestPoint(nextVertexCandidate); // Logic for updating snappingLatlng let newSnappingLatlng = null; @@ -532,27 +523,26 @@ export const useMeasurements = (snappingLayers: MapLibreMap[] = []) => { const vertexPoint = leafletMap.latLngToContainerPoint(vertexLatLng); const currentRadius = queryRadiusRef.current; const coordinatePoints: SnappingPoint[] = []; + const currentZoom = leafletMap.getZoom(); // Extract snap points from vector features - const mapContainer = leafletMap.getContainer(); - const mapRect = mapContainer.getBoundingClientRect(); - const screenX = vertexPoint.x + mapRect.left; - const screenY = vertexPoint.y + mapRect.top; - - coordinatePoints.push( - ...getSnappingPointsFromMapLibre( - snappingLayersRef.current, - { x: screenX, y: screenY }, - currentRadius - ) - ); + if (currentZoom >= snappingMinZoom) { + const mapContainer = leafletMap.getContainer(); + const mapRect = mapContainer.getBoundingClientRect(); + const screenX = vertexPoint.x + mapRect.left; + const screenY = vertexPoint.y + mapRect.top; - // Extract from measurement shapes - shapesRef.current.forEach((shape: any) => { coordinatePoints.push( - ...extractPointsFromMeasurementShape(shape, "measurements") + ...getSnappingPointsFromMapLibre( + snappingLayersRef.current, + { x: screenX, y: screenY }, + currentRadius + ) ); - }); + } + + // Extract from measurement shapes + coordinatePoints.push(...cachedShapePointsRef.current); // Filter out self (exclude self-snapping) const filtered = coordinatePoints.filter((point) => { @@ -634,14 +624,15 @@ export const useMeasurements = (snappingLayers: MapLibreMap[] = []) => { // click handler for snapped vertices const mapContainer = leafletMap.getContainer(); const clickHandler = (event: MouseEvent) => { - const snapPoint = closestPointRef.current; - if (!snapPoint) return; // No snap point, let normal click handling proceed - const drawHandler = currentDrawHandlerRef.current; if (!drawHandler || !drawHandler.addVertex) return; // Not drawing - // Get snapped latlng - const snappedLatlng = toLatLngFromClosestPoint(snapPoint); + const candidate = nextVertexCandidateRef.current; + if (!candidate) return; // Should not happen if mouse on map + + // Get latlng from candidate (snapped or unsnapped) + const snappedLatlng = toLatLngFromClosestPoint(candidate); + if (!snappedLatlng) return; // Check if vertex count increased (meaning Leaflet Draw already added the vertex) @@ -688,7 +679,7 @@ export const useMeasurements = (snappingLayers: MapLibreMap[] = []) => { // Directly add vertex at snapped position drawHandler.addVertex(snappedLatlng); // Clear snap ref after adding vertex to prevent stale snap on next click - closestPointRef.current = null; + nextVertexCandidateRef.current = null; event.stopPropagation(); event.stopImmediatePropagation(); }; From 8ec44ff9cbc7ae620edda4ca3de09417584de62e Mon Sep 17 00:00:00 2001 From: Friedrich Hartmann Date: Wed, 3 Dec 2025 21:45:00 +0100 Subject: [PATCH 20/20] improve close on snap, simplify modifier check --- .../src/lib/components/InfoBoxMeasurement.tsx | 30 +- .../src/lib/components/MeasurementTitle.tsx | 7 +- .../lib/context/MapMeasurementsProvider.tsx | 17 +- .../src/lib/hooks/useMeasurements.ts | 95 +++-- .../MeasureControl.ts} | 392 ++++++++++++------ .../index.ts | 1 + .../src/lib/types/MeasurementLayer.ts | 11 + .../src/lib/types/MeasurementLeafletEvent.ts | 6 + .../src/lib/types/MeasurementPolygon.ts | 10 + .../src/lib/types/MeasurementPolyline.ts | 10 + .../src/lib/types/MeasurementShapeData.ts | 15 + .../measurements/src/lib/types/index.ts | 14 +- .../src/lib/types/leaflet-extensions.d.ts | 290 ------------- .../measurements/src/lib/utils/constants.ts | 1 + .../measurements/src/lib/utils/snapping.ts | 84 ++-- 15 files changed, 450 insertions(+), 533 deletions(-) rename libraries/commons/measurements/src/lib/{utils/measure-path.ts => leaflet-control-measurement-extension/MeasureControl.ts} (79%) create mode 100644 libraries/commons/measurements/src/lib/leaflet-control-measurement-extension/index.ts create mode 100644 libraries/commons/measurements/src/lib/types/MeasurementLayer.ts create mode 100644 libraries/commons/measurements/src/lib/types/MeasurementLeafletEvent.ts create mode 100644 libraries/commons/measurements/src/lib/types/MeasurementPolygon.ts create mode 100644 libraries/commons/measurements/src/lib/types/MeasurementPolyline.ts create mode 100644 libraries/commons/measurements/src/lib/types/MeasurementShapeData.ts delete mode 100644 libraries/commons/measurements/src/lib/types/leaflet-extensions.d.ts create mode 100644 libraries/commons/measurements/src/lib/utils/constants.ts diff --git a/libraries/commons/measurements/src/lib/components/InfoBoxMeasurement.tsx b/libraries/commons/measurements/src/lib/components/InfoBoxMeasurement.tsx index 01a2fe656f..4154299ee3 100644 --- a/libraries/commons/measurements/src/lib/components/InfoBoxMeasurement.tsx +++ b/libraries/commons/measurements/src/lib/components/InfoBoxMeasurement.tsx @@ -10,6 +10,8 @@ import { TopicMapContext } from "react-cismap/contexts/TopicMapContextProvider"; import { ResponsiveInfoBox } from "@carma-appframeworks/portals"; import { useMapMeasurementsContext } from "../context"; import { MeasurementShape } from "../types/MeasurementShape"; +import { MeasurementShapeData } from "../types/MeasurementShapeData"; +import { DRAWING_SHAPE_ID } from "../utils/constants"; type InfoBoxMeasurementProps = { pixelWidth?: number; @@ -42,7 +44,7 @@ export function InfoBoxMeasurement({ const [currentMeasure, setCurrentMeasure] = useState(0); const [oldDataLength, setOldDataLength] = useState(measurementsData.length); const [stepAfterMoveToShape, setStepAfterMoveToShape] = useState< - number | string | null + number | string | symbol | null >(null); const [stepAfterUpdating, setStepAfterUpdating] = useState(false); const [stepAfterCreating, setStepAfterCreating] = useState(false); @@ -68,7 +70,7 @@ export function InfoBoxMeasurement({ useEffect(() => { if (drawingMode) { // setLastMeasureActive(); - setActiveShape(5555); + setActiveShape(DRAWING_SHAPE_ID); return; } }, [drawingMode]); @@ -151,7 +153,7 @@ export function InfoBoxMeasurement({ }; const activeShapeHandler = ( - shapeId: number | string | null + shapeId: number | string | symbol | null ): number | null => { let activeShapePosition: number | null = null; visibleShapesData.forEach((s, idx) => { @@ -162,7 +164,9 @@ export function InfoBoxMeasurement({ return activeShapePosition; }; - const getPositionInAllArray = (shapeId: number | string): number | null => { + const getPositionInAllArray = ( + shapeId: number | string | symbol + ): number | null => { let activeShapePosition: number | null = null; measurementsData.forEach((s, idx) => { if (s.shapeId === shapeId) { @@ -172,9 +176,9 @@ export function InfoBoxMeasurement({ return activeShapePosition; }; - const getOrderOfShape = (shapeId: number | string): number => { + const getOrderOfShape = (shapeId: number | string | symbol): number => { let position: number; - if (shapeId === 5555) { + if (shapeId === DRAWING_SHAPE_ID) { position = measurementsData.length === 0 ? 1 : measurementsData.length + 1; } else { @@ -202,7 +206,7 @@ export function InfoBoxMeasurement({ const setLastMeasureActive = () => { // Set activeShape (source of truth) to the last visible shape - if (activeShape === 5555) { + if (activeShape === DRAWING_SHAPE_ID) { return; } @@ -221,9 +225,13 @@ export function InfoBoxMeasurement({ }; const updateTitleMeasurementById = ( - shapeId: number | string, + shapeId: number | string | symbol, customTitle: string ) => { + if (typeof shapeId === "symbol") { + console.warn("Cannot update title for symbol shapeId", shapeId); + return; + } updateTitle(shapeId, customTitle); }; @@ -246,7 +254,7 @@ export function InfoBoxMeasurement({ void; + updateTitleMeasurementById: ( + shapeId: number | string | symbol, + title: string + ) => void; setUpdateMeasurementStatus: (status: boolean) => void; isCollapsed?: boolean; collapsedContent?: string; diff --git a/libraries/commons/measurements/src/lib/context/MapMeasurementsProvider.tsx b/libraries/commons/measurements/src/lib/context/MapMeasurementsProvider.tsx index ee04cce406..1386829a70 100644 --- a/libraries/commons/measurements/src/lib/context/MapMeasurementsProvider.tsx +++ b/libraries/commons/measurements/src/lib/context/MapMeasurementsProvider.tsx @@ -9,6 +9,7 @@ import type { import { MapMeasurementsContext } from "./MapMeasurementsContext"; import { setFromLocalforage, saveToLocalforage } from "../utils/storage"; import { normalizeOptions } from "@carma-commons/utils"; +import { DRAWING_SHAPE_ID } from "../utils/constants"; // Detect mobile devices const isMobileDevice = () => { @@ -83,15 +84,6 @@ export const MapMeasurementsProvider = ({ const [deleteAll, setDeleteAll] = useState(false); const [drawingShape, setDrawingShape] = useState(false); - // Wrap setDrawingShape to log calls - const setDrawingShapeWithLog = useCallback((value: boolean) => { - console.warn( - `[MapMeasurementsProvider] setDrawingShape(${value})`, - new Error().stack - ); - setDrawingShape(value); - }, []); - const [lastActiveShapeBeforeDrawing, setLastActiveShapeBeforeDrawing] = useState(null); const [moveToShape, setMoveToShape] = useState(null); @@ -190,7 +182,7 @@ export const MapMeasurementsProvider = ({ const setActiveShapeIfDrawCancelled = useCallback(() => { setLastActiveShapeBeforeDrawing((lastActiveShape) => { setVisibleShapes((visible) => { - if (lastActiveShape && visible[0]?.shapeId !== 55555) { + if (lastActiveShape && visible[0]?.shapeId !== DRAWING_SHAPE_ID) { setActiveShape(lastActiveShape); setDrawingShape(false); } else { @@ -218,7 +210,7 @@ export const MapMeasurementsProvider = ({ const updateAreaOfDrawing = useCallback((newArea: string) => { setVisibleShapes((visibleShapes) => { const shape = visibleShapes.map((s) => { - if (s.shapeId === 5555) { + if (s.shapeId === DRAWING_SHAPE_ID) { return { ...s, area: newArea, @@ -289,7 +281,7 @@ export const MapMeasurementsProvider = ({ deleteAll, setDeleteAll, drawingShape, - setDrawingShape: setDrawingShapeWithLog, + setDrawingShape, lastActiveShapeBeforeDrawing, setLastActiveShapeBeforeDrawing, moveToShape, @@ -322,7 +314,6 @@ export const MapMeasurementsProvider = ({ showAll, deleteAll, drawingShape, - setDrawingShapeWithLog, lastActiveShapeBeforeDrawing, moveToShape, updateShape, diff --git a/libraries/commons/measurements/src/lib/hooks/useMeasurements.ts b/libraries/commons/measurements/src/lib/hooks/useMeasurements.ts index 26c80ae4a2..7ad6526827 100644 --- a/libraries/commons/measurements/src/lib/hooks/useMeasurements.ts +++ b/libraries/commons/measurements/src/lib/hooks/useMeasurements.ts @@ -9,7 +9,8 @@ import "leaflet-editable"; import { TopicMapContext } from "react-cismap/contexts/TopicMapContextProvider"; import "../utils/measure"; -import "../utils/measure-path"; +import { DRAWING_SHAPE_ID } from "../utils/constants"; +import { MeasureControl } from "../leaflet-control-measurement-extension"; import useDeviceDetection from "../hooks/useDeviceDetection"; import { useMapMeasurementsContext } from "../context"; import { @@ -23,8 +24,8 @@ import { createSnappingIndicator, findClosestSnappingPoint, SNAPPING_MODIFIER_KEY, - isSnappingModifierPressed, handleDuplicateVertex, + createSnappingFeature, } from "../utils/snapping"; import { filterArrByIds, findLargestNumber } from "../utils/shapes"; import { SnappingPoint } from "./../types"; @@ -120,10 +121,16 @@ export const useMeasurements = (snappingLayers: MapLibreMap[] = []) => { // Update cache for snapping points from shapes const points: SnappingPoint[] = []; shapes.forEach((shape: any) => { - points.push(...extractPointsFromMeasurementShape(shape, "measurements")); + // Filter out the currently active shape to avoid ghost snapping to its old state + // The active shape's current points are handled by the drawing-in-progress logic + if (shape.shapeId !== activeShape) { + points.push( + ...extractPointsFromMeasurementShape(shape, "measurements") + ); + } }); cachedShapePointsRef.current = points; - }, [shapes]); + }, [shapes, activeShape]); useEffect(() => { queryRadiusRef.current = snappingQueryRadius; @@ -217,7 +224,7 @@ export const useMeasurements = (snappingLayers: MapLibreMap[] = []) => { lastMouseEventRef.current = e; // Update tooltip text based on Snapping Modifier Key - const isPressed = isSnappingModifierPressed(e); + const isPressed = e.getModifierState(SNAPPING_MODIFIER_KEY); updateTooltipTemplate(isPressed); // Force update of current tooltip if it exists @@ -322,25 +329,10 @@ export const useMeasurements = (snappingLayers: MapLibreMap[] = []) => { // Determine snapping point const isSnapped = !!closestResult; - const coordinates = closestResult - ? closestResult.point.coordinates - : [mouseLatLng.lng, mouseLatLng.lat]; - - const sourceId = closestResult - ? closestResult.point.sourceId - : "pointerposition"; - - const snappedFeature: any = { - type: "Feature", - geometry: { - type: "Point", - coordinates: coordinates, - }, - properties: { - isSnapped: isSnapped, - source: sourceId, - }, - }; + const snappedFeature: any = createSnappingFeature( + closestResult, + mouseLatLng + ); nextVertexCandidate = snappedFeature; // Store candidate (snapped or unsnapped) @@ -655,11 +647,35 @@ export const useMeasurements = (snappingLayers: MapLibreMap[] = []) => { snappingIdentityDistanceMeters ) ) { - if (tryClosePolygon(drawHandler)) { - event.stopPropagation(); - event.stopImmediatePropagation(); + // Check if handler is still valid/enabled before trying to close + // Leaflet.Draw might have already closed it via addVertex override + if (!drawHandler._enabled) { + console.debug( + "[useMeasurements] Handler disabled - aborting tryClosePolygon" + ); return; } + + // Explicitly set polygon mode when closing via snap + if (measureControl && measureControl.options) { + console.debug( + "[useMeasurements] Closing polygon via snap - setting shapeMode to 'polygon'" + ); + measureControl.options.shapeMode = "polygon"; + } + + try { + if (tryClosePolygon(drawHandler)) { + event.stopPropagation(); + event.stopImmediatePropagation(); + return; + } + } catch (e) { + console.warn( + "[useMeasurements] Error closing polygon (likely already closed):", + e + ); + } } // Check for duplicate of LAST vertex @@ -802,28 +818,26 @@ export const useMeasurements = (snappingLayers: MapLibreMap[] = []) => { snappingEnabled: true, cbSaveShape: saveShapeHandler, cbUpdateShape: updateShapeHandler, - cdDeleteShape: deleteShapeHandler, + cbDeleteShape: deleteShapeHandler, cbDeleteVisibleShapeById: deleteVisibleShapeByIdHandler, cbVisiblePolylinesChange: visiblePolylinesChange, cbSetDrawingStatus: drawingStatusHandler, cbSetDrawingShape: drawingShapeHandler, measurementOrder: findLargestNumber(shapes), - measurementMode: isMeasurementEnabled ? "measurement" : "default", + enabled: isMeasurementEnabled, cbSetActiveShape: setActiveShapeHandler, cbSetUpdateStatusHandler: setUpdateStatusHandler, cbMapMovingEndHandler: mapMovingEndHandler, cbSaveLastActiveShapeIdBeforeDrawingHandler: saveLastActiveShapeIdBeforeDrawingHandler, - cbChangeActiveCanceldShapeId: changeActiveCancelledShapeId, + cbChangeActiveCancelledShapeId: changeActiveCancelledShapeId, cbToggleMeasurementMode: toggleMeasurementModeHandler, cbUpdateAreaOfDrawingMeasurement: updateAreaOfDrawingMeasurementHandler, cbSetCurrentDrawHandler: setCurrentDrawHandler, cbSetMapStatus: setStatus, }; - const measurePolygonControl = (L.control as any).measurePolygon( - customOptions - ); + const measurePolygonControl = new (MeasureControl as any)(customOptions); measurePolygonControl.addTo(mapExample); setMeasureControl(measurePolygonControl); @@ -929,10 +943,7 @@ export const useMeasurements = (snappingLayers: MapLibreMap[] = []) => { if (measureControl) { const map = realRoutedMapRef.current?.leafletMap?.leafletElement; if (!map) return; - measureControl.changeMeasurementMode( - isMeasurementEnabled ? "measurement" : "default", - map - ); + measureControl.setMeasurementEnabled(isMeasurementEnabled, map); const shapeCoordinates = shapes.filter((s) => s.shapeId === activeShape); if (shapeCoordinates[0]?.shapeId) { measureControl.changeColorByActivePolyline( @@ -960,14 +971,14 @@ export const useMeasurements = (snappingLayers: MapLibreMap[] = []) => { if (measureControl) { const cleanedVisibleArr = filterArrByIds(visiblePolylines, shapes); - // Preserve drawing shape (5555) if we're in drawing mode + // Preserve drawing shape (DRAWING_SHAPE_ID) if we're in drawing mode const drawingShapeInVisible = visibleShapes.find( - (s) => s.shapeId === 5555 + (s) => s.shapeId === DRAWING_SHAPE_ID ); if ( ifDrawing && drawingShapeInVisible && - !cleanedVisibleArr.find((s) => s.shapeId === 5555) + !cleanedVisibleArr.find((s) => s.shapeId === DRAWING_SHAPE_ID) ) { cleanedVisibleArr.push(drawingShapeInVisible); } @@ -979,7 +990,9 @@ export const useMeasurements = (snappingLayers: MapLibreMap[] = []) => { useEffect(() => { if (drawingShape) { - const cleanArr = visibleShapes.filter((m) => m.shapeId !== 5555); + const cleanArr = visibleShapes.filter( + (m) => m.shapeId !== DRAWING_SHAPE_ID + ); setVisibleShapes([...cleanArr, drawingShape]); } else { setLastVisibleShapeActive(); diff --git a/libraries/commons/measurements/src/lib/utils/measure-path.ts b/libraries/commons/measurements/src/lib/leaflet-control-measurement-extension/MeasureControl.ts similarity index 79% rename from libraries/commons/measurements/src/lib/utils/measure-path.ts rename to libraries/commons/measurements/src/lib/leaflet-control-measurement-extension/MeasureControl.ts index 0edb2c1ea9..dd0d2ca70d 100644 --- a/libraries/commons/measurements/src/lib/utils/measure-path.ts +++ b/libraries/commons/measurements/src/lib/leaflet-control-measurement-extension/MeasureControl.ts @@ -14,6 +14,11 @@ import { LeafletEvent, polygon, polyline, + ControlOptions, + LayerGroup, + LatLng, + Point, + Layer, } from "@carma/leaflet"; import * as L from "leaflet"; import "leaflet-draw"; @@ -24,17 +29,163 @@ import { formatDistance, updateDistance, updateDistanceByLatLngs, -} from "./measurement-geometry"; -import { createVertexClickHandler } from "./vertex-click-handler"; +} from "../utils/measurement-geometry"; +import { createVertexClickHandler } from "../utils/vertex-click-handler"; +import { TOOLTIP_LABELS } from "../labels"; +import { + distanceBetweenLatLng, + EXACT_MATCH_METERS, + isFirstVertexMatch, +} from "../utils/snapping"; +import { DRAWING_SHAPE_ID } from "../utils/constants"; import { MeasurementPolyline, MeasurementPolygon, MeasurementLayer, MeasurementLeafletEvent, - MeasurePolygonControl, -} from "../types/leaflet-extensions"; -import { TOOLTIP_LABELS } from "../labels"; -import { distanceBetweenLatLng } from "./snapping"; + MeasurementShapeData, +} from "../types"; + +// --- Type Definitions --- + +export interface MeasurementMarker extends L.Marker { + customHandle?: number; +} + +export interface DrawHandler { + _poly?: { _latlngs: LatLng[] }; + _enabled?: boolean; + enable(): void; + disable(): void; + completeShape?: () => void; + addVertex?(latlng: LatLng): void; + _markers?: MeasurementMarker[]; +} + +export interface MeasureControlOptions extends ControlOptions { + icon_lineActive: string; + icon_lineInactive: string; + icon_polygonActive: string; + icon_polygonInactive: string; + html_template: string; + height: number; + width: number; + mode_btn: string; + isDrawing: boolean; + changeModeButtonActive: boolean; + msj_disable_tool: string; + shapes: MeasurementShapeData[]; + activeShape: number | string | symbol | null; + shapeMode: "line" | "polygon"; + measurementOrder: number; + moveToShape: boolean | MeasurementShapeData | null; + cb: () => void; + cbSaveShape: (shape: MeasurementShapeData) => void; + cbDeleteShape: ( + id: number | string | symbol, + localShapeStore: MeasurementShapeData[] + ) => void; + cbUpdateShape: ( + id: number | string | symbol, + newCoordinates: number[][], + newDistance: string, + newSquare: string | null + ) => void; + cbVisiblePolylinesChange: (ids: (number | string | symbol)[]) => void; + cbSetDrawingStatus: (status: boolean) => void; + cbSetDrawingShape: (shape: MeasurementShapeData | null) => void; + cbSetActiveShape: (id: number | string | symbol) => void; + cbSetUpdateStatusHandler: (status: boolean) => void; + cbMapMovingEndHandler: (status: boolean) => void; + cbSaveLastActiveShapeIdBeforeDrawingHandler: () => void; + cbChangeActiveCancelledShapeId: () => void; + cbToggleMeasurementMode: () => void; + cbGetMeasurementModeHandler: () => void; + cbDeleteVisibleShapeById: (id: number | string | symbol) => void; + cbUpdateAreaOfDrawingMeasurement: (area: string | null) => void; + cbSetCurrentDrawHandler: (handler: DrawHandler | null) => void; + cbSetMapStatus?: (status: string) => void; + visiblePolylines: (string | number | symbol)[]; + localShapeStore: MeasurementShapeData[]; + isDrawingEmpty: boolean; + nativeMove: boolean; + currentLine: DrawHandler | null; + polygonMode: boolean; + enabled: boolean; + startDrawing: boolean; + customTooltip: HTMLElement | null; + device: "desktop" | "mobile" | "tablet" | "Desktop" | null; + clickAfterShapeSelection: boolean; + snappingLatlng: LatLng | null; + snappingEnabled: boolean; + snappingQueryRadius?: number; + measurementMode?: "measurement" | "other_mode"; // TODO: check what other modes exist or if this is correct type +} + +export interface MeasureControl extends Control { + options: MeasureControlOptions; + _map: LeafletMap; + _measureLayers: LayerGroup; + _measureHandler: any; + _lastOriginalClick: { latlng: LatLng; containerPoint: Point }; + + _mapClickHandler?: (event: LeafletMouseEvent) => void; + _drawCreatedHandler?: (event: any) => void; + _drawDrawstartHandler?: (event: any) => void; + _drawDrawvertexHandler?: (event: any) => void; + _drawCanceledHandler?: () => void; + _moveendHandler?: (event: any) => void; + _mousemoveHandler?: (event: LeafletMouseEvent) => void; + _mouseoutHandler?: (event: LeafletMouseEvent) => void; + _vertexClickHandler?: (event: LeafletMouseEvent) => void; + _isFinishingShape?: boolean; + drawingLines(map: LeafletMap, event: LeafletMouseEvent): void; + + onAdd(map: LeafletMap): HTMLElement; + _clearMeasurements(): void; + changeColorByActivePolyline( + map: LeafletMap, + customID: number | string | symbol + ): void; + changeColorByLastShape(map: LeafletMap): void; + showLastPolylineOnFirstLoding(map: LeafletMap): void; + getVisiblePolylines(map: LeafletMap): MeasurementPolyline[]; + getVisiblePolylinesIds(polylines: MeasurementPolyline[]): void; + getAllPolylines(map: LeafletMap): MeasurementPolyline[]; + removePolylineById(map: LeafletMap, customID: number | string | symbol): void; + fitMapToAllPolylines(map: LeafletMap): void; + fitMapToPolylines(map: LeafletMap, polylines: MeasurementPolyline[]): void; + convertPolylineToPolygon(map: LeafletMap, layer: MeasurementPolyline): void; + loadMeasurements(map?: LeafletMap): void; + _toggleMeasurementBtn(): void; + toggleMeasurementMode(ifChangeMode?: boolean, map?: LeafletMap): void; + _UpdateDistance(layer: MeasurementPolyline): string; + _toggleMeasure(id: string, iconActive: string, inactiveIcon: string): void; + calculateArea(coordinates: number[][]): string; + calculateDistance(latlngs: LatLng[]): number; + formatDistance(distance: number): string; + saveShapeHandler( + layer: MeasurementPolyline, + distance: string | null, + area: string | null, + map: LeafletMap + ): void; + _onPolylineDrag(event: LeafletEvent): void; + replaceLineToPolygon( + map: LeafletMap, + layer: MeasurementPolyline + ): MeasurementShapeData; + getVisibleShapeIdsArr(map: LeafletMap): (number | string | symbol)[]; + _UpdateDistanceByLatLngs(coordinates: number[][]): string; + showActiveShape(map: LeafletMap, coordinates: number[][]): void; + setMeasurementEnabled(enabled: boolean, map: LeafletMap): void; + changeMeasurementsArr(arr: MeasurementShapeData[]): void; + findLastCreatedLayer(layerGroup: LayerGroup): Layer | null; + cancelDrawing(): void; + startDrawing(): void; + _onPolygonClick(map: LeafletMap, event: LeafletMouseEvent): void; + _UpdateAreaperimeter(layer: MeasurementPolygon): void; +} // Placeholder for icons to not show broken images // Transparent 1x1 GIF (43 bytes) @@ -42,7 +193,7 @@ import { distanceBetweenLatLng } from "./snapping"; const TRANSPARENT_PIXEL = "data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=="; -export const MeasurePolygon = Control.extend({ +export const MeasureControl = Control.extend({ options: { position: "topright", icon_lineActive: TRANSPARENT_PIXEL, @@ -55,9 +206,6 @@ export const MeasurePolygon = Control.extend({ height: 130, width: 150, mode_btn: "", - color_polygon: "black", - fillColor_polygon: "yellow", - weight_polygon: "2", isDrawing: false, changeModeButtonActive: false, msj_disable_tool: TOOLTIP_LABELS.general.disableTool, @@ -66,67 +214,31 @@ export const MeasurePolygon = Control.extend({ shapeMode: "line", measurementOrder: 0, moveToShape: false, - cb: function (...args: any[]) { - console.debug("Callback function executed!", args); - }, - cbSaveShape: function (...args: any[]) { - console.debug("Callback function executed!", args); - }, - cdDeleteShape: function (...args: any[]) { - console.debug("Callback function executed!", args); - }, - cbUpdateShape: function (...args: any[]) { - console.debug("Callback function executed!", args); - }, - cbVisiblePolylinesChange: function (...args: any[]) { - console.debug("Callback function executed!", args); - }, - cbSetDrawingStatus: function (...args: any[]) { - console.debug("Callback function executed!", args); - }, - cbSetDrawingShape: function (...args: any[]) { - console.debug("Callback function executed!", args); - }, - cbSetActiveShape: function (...args: any[]) { - console.debug("Callback function executed!", args); - }, - cbSetUpdateStatusHandler: function (...args: any[]) { - console.debug("Callback function executed!", args); - }, - cbMapMovingEndHandler: function (...args: any[]) { - console.debug("Callback function executed!", args); - }, - cbSaveLastActiveShapeIdBeforeDrawingHandler: function (...args: any[]) { - console.debug("Callback function executed!", args); - }, - cbChangeActiveCanceldShapeId: function (...args: any[]) { - console.debug("Callback function executed!", args); - }, - cbToggleMeasurementMode: function (...args: any[]) { - console.debug("Callback function executed!", args); - }, - cbGetMeasurementModeHandler: function (...args: any[]) { - console.debug("Callback function executed!", args); - }, - cbDeleteVisibleShapeById: function (...args: any[]) { - console.debug("Callback function executed!", args); - }, - cbUpdateAreaOfDrawingMeasurement: function (...args: any[]) { - console.debug("Callback function executed!", args); - }, - cbSetCurrentDrawHandler: function (...args: any[]) { - console.debug("Callback function executed!", args); - }, - cbSetMapStatus: function (...args: any[]) { - console.debug("Callback function executed!", args); - }, + cb: function (...args: any[]) {}, + cbSaveShape: function (...args: any[]) {}, + cbDeleteShape: function (...args: any[]) {}, + cbUpdateShape: function (...args: any[]) {}, + cbVisiblePolylinesChange: function (...args: any[]) {}, + cbSetDrawingStatus: function (...args: any[]) {}, + cbSetDrawingShape: function (...args: any[]) {}, + cbSetActiveShape: function (...args: any[]) {}, + cbSetUpdateStatusHandler: function (...args: any[]) {}, + cbMapMovingEndHandler: function (...args: any[]) {}, + cbSaveLastActiveShapeIdBeforeDrawingHandler: function (...args: any[]) {}, + cbChangeActiveCancelledShapeId: function (...args: any[]) {}, + cbToggleMeasurementMode: function (...args: any[]) {}, + cbGetMeasurementModeHandler: function (...args: any[]) {}, + cbDeleteVisibleShapeById: function (...args: any[]) {}, + cbUpdateAreaOfDrawingMeasurement: function (...args: any[]) {}, + cbSetCurrentDrawHandler: function (...args: any[]) {}, + cbSetMapStatus: function (...args: any[]) {}, visiblePolylines: [], localShapeStore: [], isDrawingEmpty: true, nativeMove: false, - currenLine: null, + currentLine: null, polygonMode: false, - measurementMode: false as string | boolean, + enabled: false, startDrawing: false, customTooltip: null, device: null, @@ -136,7 +248,7 @@ export const MeasurePolygon = Control.extend({ }, drawingLines: function ( - this: MeasurePolygonControl, + this: MeasureControl, map: LeafletMap, event: LeafletMouseEvent ) { @@ -235,9 +347,26 @@ export const MeasurePolygon = Control.extend({ // Check for duplicate vertex to prevent 0-length segments if (this._markers && this._markers.length > 0) { + // Check for closing polygon (snapping to first vertex) + // We use isFirstVertexMatch (requires 3+ vertices) to detect closure + if (isFirstVertexMatch(this, finalLatlng, EXACT_MATCH_METERS)) { + console.debug( + "[measure-path] Closing polygon via addVertex override (snapped to start)" + ); + // Explicitly set polygon mode + self.options.shapeMode = "polygon"; + + // Set finishing flag to prevent map click from starting new shape + (self as any)._isFinishingShape = true; + + this._finishShape(); + return; + } + const lastMarker = this._markers[this._markers.length - 1]; if ( - distanceBetweenLatLng(finalLatlng, lastMarker.getLatLng()) < 0.001 + distanceBetweenLatLng(finalLatlng, lastMarker.getLatLng()) < + EXACT_MATCH_METERS ) { console.warn( "[measure-path] Preventing 0-length segment in addVertex - duplicate vertex ignored" @@ -251,7 +380,7 @@ export const MeasurePolygon = Control.extend({ }; } - this.options.currenLine = this._measureHandler; + this.options.currentLine = this._measureHandler; this.options.cbSetCurrentDrawHandler(this._measureHandler); const tooltipContent = `${TOOLTIP_LABELS.measurement.finishLine}
${TOOLTIP_LABELS.measurement.finishPolygon}`; @@ -310,7 +439,7 @@ export const MeasurePolygon = Control.extend({ return; // Don't add invalid vertex } - this.options.currenLine.addVertex(latlng); + this.options.currentLine.addVertex(latlng); const tooltip = document.querySelector( ".leaflet-draw-tooltip" @@ -326,12 +455,12 @@ export const MeasurePolygon = Control.extend({ ); }, - startDrawing: function (this: MeasurePolygonControl) { + startDrawing: function (this: MeasureControl) { this.options.startDrawing = true; }, saveShapeHandler: function ( - this: MeasurePolygonControl, + this: MeasureControl, layer: MeasurementPolyline, distance: string | null = null, area: string | null = null, @@ -393,7 +522,7 @@ export const MeasurePolygon = Control.extend({ } }, - _onPolylineDrag: function (this: MeasurePolygonControl, event: LeafletEvent) { + _onPolylineDrag: function (this: MeasureControl, event: LeafletEvent) { if (this.options.customTooltip) { this.options.customTooltip.style.visibility = "hidden"; } @@ -442,7 +571,7 @@ export const MeasurePolygon = Control.extend({ }, _onPolygonClick: function ( - this: MeasurePolygonControl, + this: MeasureControl, map: LeafletMap, event: LeafletMouseEvent ) { @@ -454,13 +583,13 @@ export const MeasurePolygon = Control.extend({ ? clickedPolygon?.customID : clickedPolygon._leaflet_id; - this.options.cdDeleteShape(shapeId, this.options.localShapeStore); + this.options.cbDeleteShape(shapeId, this.options.localShapeStore); const allPolyLines = this.getVisiblePolylines(map); this.getVisiblePolylinesIds(allPolyLines); }, - onAdd: function (this: MeasurePolygonControl, map: LeafletMap) { + onAdd: function (this: MeasureControl, map: LeafletMap) { const linesContainer = DomUtil.create( "div", "leaflet-bar leaflet-control dont-show m-container" @@ -500,11 +629,11 @@ export const MeasurePolygon = Control.extend({ // Store handler references for proper cleanup this._mapClickHandler = (event) => { - const mode = this.options.measurementMode; + const enabled = this.options.enabled; console.log("[measure-path] Map clicked", this.options, { isDrawing: this.options.isDrawing, - mode, + enabled, clickAfterShapeSelection: this.options.clickAfterShapeSelection, isFinishingShape: (this as any)._isFinishingShape, eventType: event.originalEvent?.type, @@ -525,7 +654,7 @@ export const MeasurePolygon = Control.extend({ return; } - if (!this.options.isDrawing && mode === "measurement") { + if (!this.options.isDrawing && enabled) { this.drawingLines(map, event); this.options.isDrawing = true; } else { @@ -539,15 +668,18 @@ export const MeasurePolygon = Control.extend({ }; this._drawCreatedHandler = (event) => { - console.warn("[measure-path] ========== draw:created FIRED ==========", { - stack: new Error().stack, + console.log("[measure-path] ========== draw:created FIRED ==========", { layerType: event.layerType, vertexCount: event.layer.getLatLngs?.()?.length || 0, timestamp: Date.now(), }); // Reset finishing flag since the shape is successfully created - (this as any)._isFinishingShape = false; + // CRITICAL: Use setTimeout to ensure _mapClickHandler sees this as TRUE for the current event loop + // If we reset it synchronously, _mapClickHandler (which runs after this) will think we are done and start a new shape + setTimeout(() => { + (this as any)._isFinishingShape = false; + }, 0); this.options.isDrawing = false; this.options.isDrawingEmpty = true; @@ -779,7 +911,7 @@ export const MeasurePolygon = Control.extend({ const shapesObj = { coordinates: [latlngs], distance, - shapeId: 5555, + shapeId: DRAWING_SHAPE_ID, number: this.options.measurementOrder, shapeType: "line" as const, options: { @@ -798,7 +930,7 @@ export const MeasurePolygon = Control.extend({ const shapesObj = { coordinates: [latlngs], distance, - shapeId: 5555, + shapeId: DRAWING_SHAPE_ID, shapeType: "line" as const, number: this.options.measurementOrder, options: { @@ -831,8 +963,8 @@ export const MeasurePolygon = Control.extend({ "icon_lineInactive" ); - this.options.cbDeleteVisibleShapeById(5555); - this.options.cbChangeActiveCanceldShapeId(); + this.options.cbDeleteVisibleShapeById(DRAWING_SHAPE_ID); + this.options.cbChangeActiveCancelledShapeId(); }; this._moveendHandler = (event) => { @@ -845,11 +977,11 @@ export const MeasurePolygon = Control.extend({ this._mousemoveHandler = (event) => { const target = event.originalEvent.target; const isDesktop = this.options.device === "Desktop" ? true : false; - const mode = this.options.measurementMode; + const enabled = this.options.enabled; // this._propagateEventToUnderlyingLayers(map, event, "mouseover"); if (isDesktop) { - if (!this.options.customTooltip && mode === "measurement") { + if (!this.options.customTooltip && enabled) { const popupPane = map._panes.popupPane; this.options.customTooltip = DomUtil.create( @@ -865,7 +997,7 @@ export const MeasurePolygon = Control.extend({ DomUtil.setPosition(this.options.customTooltip, pos); } - if (this.options.customTooltip && mode === "measurement") { + if (this.options.customTooltip && enabled) { const latlng = this.options.snappingEnabled && this.options.snappingLatlng ? this.options.snappingLatlng @@ -911,7 +1043,7 @@ export const MeasurePolygon = Control.extend({ return iconsWrapper; }, - onRemove: function (this: MeasurePolygonControl, map: LeafletMap) { + onRemove: function (this: MeasureControl, map: LeafletMap) { // Clean up all event handlers to prevent memory leaks and duplicate handlers on HMR console.log("[measure-path] onRemove: Cleaning up event handlers"); @@ -946,7 +1078,7 @@ export const MeasurePolygon = Control.extend({ } }, - _UpdateAreaperimeter: function (this: MeasurePolygonControl, layer: any) { + _UpdateAreaperimeter: function (this: MeasureControl, layer: any) { const latlngs = layer.getLatLngs()[0]; const options = { @@ -956,7 +1088,7 @@ export const MeasurePolygon = Control.extend({ }, _toggleMeasure: function ( - this: MeasurePolygonControl, + this: MeasureControl, btnId = "", activeIcon = "", inactiveIcon = "" @@ -968,19 +1100,19 @@ export const MeasurePolygon = Control.extend({ } }, - _clearMeasurements: function (this: MeasurePolygonControl) { + _clearMeasurements: function (this: MeasureControl) { this._measureLayers.clearLayers(); }, changeColorByActivePolyline: function ( - this: MeasurePolygonControl, + this: MeasureControl, map: LeafletMap, customID: string ) { this._measureLayers.eachLayer(function (layer) { - const polyline = layer as MeasurementPolyline; + const polyline = layer as unknown as MeasurementPolyline; if (layer instanceof Polyline) { - if ((layer as MeasurementPolyline).customID === customID) { + if ((layer as unknown as MeasurementPolyline).customID === customID) { (polyline as MeasurementPolyline)._path.classList.remove( "custom-polyline" ); @@ -995,10 +1127,7 @@ export const MeasurePolygon = Control.extend({ }); }, - changeColorByLastShape: function ( - this: MeasurePolygonControl, - map: LeafletMap - ) { + changeColorByLastShape: function (this: MeasureControl, map: LeafletMap) { let lastPolyline = null; this._measureLayers.eachLayer(function (layer) { @@ -1013,7 +1142,7 @@ export const MeasurePolygon = Control.extend({ } }, - getVisiblePolylines: function (this: MeasurePolygonControl, map: LeafletMap) { + getVisiblePolylines: function (this: MeasureControl, map: LeafletMap) { const visiblePolylines = []; const mapBounds = map.getBounds(); @@ -1028,10 +1157,7 @@ export const MeasurePolygon = Control.extend({ return visiblePolylines; }, - getVisiblePolylinesIds: function ( - this: MeasurePolygonControl, - polylinesArr: any[] - ) { + getVisiblePolylinesIds: function (this: MeasureControl, polylinesArr: any[]) { const idsPolylinesArr = []; this.options.visiblePolylines = []; polylinesArr.forEach((m) => { @@ -1042,7 +1168,7 @@ export const MeasurePolygon = Control.extend({ this.options.cbVisiblePolylinesChange(idsPolylinesArr); }, - getAllPolylines: function (this: MeasurePolygonControl, map: LeafletMap) { + getAllPolylines: function (this: MeasureControl, map: LeafletMap) { const polylines = []; this._measureLayers.eachLayer(function (layer) { @@ -1055,7 +1181,7 @@ export const MeasurePolygon = Control.extend({ }, removePolylineById: function ( - this: MeasurePolygonControl, + this: MeasureControl, map: LeafletMap, customID: string ) { @@ -1068,7 +1194,7 @@ export const MeasurePolygon = Control.extend({ }, showActiveShape: function ( - this: MeasurePolygonControl, + this: MeasureControl, map: LeafletMap, coordinates: any ) { @@ -1078,7 +1204,7 @@ export const MeasurePolygon = Control.extend({ }, fitMapToPolylines: function ( - this: MeasurePolygonControl, + this: MeasureControl, map: LeafletMap, polylines: any[] ) { @@ -1100,7 +1226,7 @@ export const MeasurePolygon = Control.extend({ }, replaceLineToPolygon: function ( - this: MeasurePolygonControl, + this: MeasureControl, map: LeafletMap, layer: any ) { @@ -1179,19 +1305,13 @@ export const MeasurePolygon = Control.extend({ return preparePolygon; }, - getVisibleShapeIdsArr: function ( - this: MeasurePolygonControl, - map: LeafletMap - ) { + getVisibleShapeIdsArr: function (this: MeasureControl, map: LeafletMap) { const allPolyLines = this.getVisiblePolylines(map); this.getVisiblePolylinesIds(allPolyLines); return this.options.visiblePolylines; }, - findLastCreatedLayer: function ( - this: MeasurePolygonControl, - layerGroup: any - ) { + findLastCreatedLayer: function (this: MeasureControl, layerGroup: any) { let lastLayer = null; let highestId = -1; @@ -1205,7 +1325,7 @@ export const MeasurePolygon = Control.extend({ return lastLayer; }, - loadMeasurements: function (this: MeasurePolygonControl, map?: LeafletMap) { + loadMeasurements: function (this: MeasureControl, map?: LeafletMap) { if (this.options.shapes.length !== 0) { this.options.shapes.forEach((shape) => { const { coordinates, options, shapeId, shapeType } = shape; @@ -1237,7 +1357,7 @@ export const MeasurePolygon = Control.extend({ } as any ); - savedShape.customID = shapeId; + (savedShape as any).customID = shapeId; savedShape.addTo(this._measureLayers).showMeasurements().enableEdit(); savedShape.on("click", () => { this.options.isDrawing = true; @@ -1276,7 +1396,7 @@ export const MeasurePolygon = Control.extend({ } }, - _toggleMeasurementBtn: function (this: MeasurePolygonControl) { + _toggleMeasurementBtn: function (this: MeasureControl) { if (this.options.changeModeButtonActive) { (document.getElementById("img_plg_lines") as HTMLImageElement).src = this.options.icon_lineInactive; @@ -1289,12 +1409,12 @@ export const MeasurePolygon = Control.extend({ }, toggleMeasurementMode: function ( - this: MeasurePolygonControl, + this: MeasureControl, ifChangeMode = true, map?: LeafletMap ) { - const mode = this.options.measurementMode; - if (mode === "measurement") { + const enabled = this.options.enabled; + if (enabled) { L.drawLocal.draw.handlers.polyline.tooltip.start = TOOLTIP_LABELS.measurement.start; this._clearMeasurements(); @@ -1316,8 +1436,8 @@ export const MeasurePolygon = Control.extend({ const customTooltip = document.querySelector("#routedMap") as HTMLElement; customTooltip.style.cursor = "pointer"; - if (this.options.currenLine) { - this.options.currenLine.disable(); + if (this.options.currentLine) { + this.options.currentLine.disable(); } customTooltip.style.cursor = "pointer"; // const drawBtn = document.getElementById("draw_shape"); @@ -1332,18 +1452,18 @@ export const MeasurePolygon = Control.extend({ } }, - changeMeasurementMode: function ( - this: MeasurePolygonControl, - mode: string, + setMeasurementEnabled: function ( + this: MeasureControl, + enabled: boolean, map: LeafletMap ) { - this.options.measurementMode = mode; + this.options.enabled = enabled; this.toggleMeasurementMode(false, map); }, - changeMeasurementsArr: function (this: MeasurePolygonControl, arr: any[]) { + changeMeasurementsArr: function (this: MeasureControl, arr: any[]) { this.options.shapes = arr; }, - cancelDrawing: function (this: MeasurePolygonControl) { + cancelDrawing: function (this: MeasureControl) { if (!this.options.isDrawingEmpty) { this._measureHandler.disable(); this.options.isDrawingEmpty = true; @@ -1351,14 +1471,14 @@ export const MeasurePolygon = Control.extend({ this._measureLayers.clearLayers(); this.options.cbSetDrawingStatus(false); - this.options.cbDeleteVisibleShapeById(5555); + this.options.cbDeleteVisibleShapeById(DRAWING_SHAPE_ID); // this.options.isDrawing = true; } }, }); // Adds the method to create a new instance of the control -(L.Control as any).MeasurePolygon = MeasurePolygon; -(L.control as any).measurePolygon = function (options: any) { - return new MeasurePolygon(options); +(L.Control as any).MeasureControl = MeasureControl; +(L.control as any).measureControl = function (options: any) { + return new MeasureControl(options); }; diff --git a/libraries/commons/measurements/src/lib/leaflet-control-measurement-extension/index.ts b/libraries/commons/measurements/src/lib/leaflet-control-measurement-extension/index.ts new file mode 100644 index 0000000000..d772d30f04 --- /dev/null +++ b/libraries/commons/measurements/src/lib/leaflet-control-measurement-extension/index.ts @@ -0,0 +1 @@ +export * from "./MeasureControl"; diff --git a/libraries/commons/measurements/src/lib/types/MeasurementLayer.ts b/libraries/commons/measurements/src/lib/types/MeasurementLayer.ts new file mode 100644 index 0000000000..fbf3fa7f8c --- /dev/null +++ b/libraries/commons/measurements/src/lib/types/MeasurementLayer.ts @@ -0,0 +1,11 @@ +import { Layer, LatLng } from "@carma/leaflet"; + +export interface MeasurementLayer extends Omit { + customID?: number | string | symbol; + customHandle?: number; + _path?: SVGPathElement; + enableEdit?: () => void; + disableEdit?: () => void; + getLatLng?: () => LatLng; + _leaflet_id?: number; +} diff --git a/libraries/commons/measurements/src/lib/types/MeasurementLeafletEvent.ts b/libraries/commons/measurements/src/lib/types/MeasurementLeafletEvent.ts new file mode 100644 index 0000000000..0d958f568b --- /dev/null +++ b/libraries/commons/measurements/src/lib/types/MeasurementLeafletEvent.ts @@ -0,0 +1,6 @@ +import { LeafletEvent, LayerGroup } from "@carma/leaflet"; + +export interface MeasurementLeafletEvent extends LeafletEvent { + layerType?: string; + layers?: LayerGroup; +} diff --git a/libraries/commons/measurements/src/lib/types/MeasurementPolygon.ts b/libraries/commons/measurements/src/lib/types/MeasurementPolygon.ts new file mode 100644 index 0000000000..fd9b2ae761 --- /dev/null +++ b/libraries/commons/measurements/src/lib/types/MeasurementPolygon.ts @@ -0,0 +1,10 @@ +import { Polygon } from "@carma/leaflet"; + +export interface MeasurementPolygon extends Omit { + customID?: number | string | symbol; + customShape?: string; + customHandle?: number; + _path?: SVGPathElement; + enableEdit?: () => void; + disableEdit?: () => void; +} diff --git a/libraries/commons/measurements/src/lib/types/MeasurementPolyline.ts b/libraries/commons/measurements/src/lib/types/MeasurementPolyline.ts new file mode 100644 index 0000000000..616bf901de --- /dev/null +++ b/libraries/commons/measurements/src/lib/types/MeasurementPolyline.ts @@ -0,0 +1,10 @@ +import { Polyline } from "@carma/leaflet"; + +export interface MeasurementPolyline extends Omit { + customID?: number | string | symbol; + customShape?: string; + _path?: SVGPathElement; + _leaflet_id?: number; + enableEdit?: () => void; + disableEdit?: () => void; +} diff --git a/libraries/commons/measurements/src/lib/types/MeasurementShapeData.ts b/libraries/commons/measurements/src/lib/types/MeasurementShapeData.ts new file mode 100644 index 0000000000..bf44907c58 --- /dev/null +++ b/libraries/commons/measurements/src/lib/types/MeasurementShapeData.ts @@ -0,0 +1,15 @@ +export interface MeasurementShapeData { + coordinates: number[][]; + options: { + color: string; + fillColor: string | null; + opacity: number; + weight: number; + }; + shapeId: number | string | symbol; + distance: string; + number: number; + area?: string | null; + shapeType: "line" | "polygon"; + customTitle?: string; +} diff --git a/libraries/commons/measurements/src/lib/types/index.ts b/libraries/commons/measurements/src/lib/types/index.ts index 03228a5c64..76d2486e83 100644 --- a/libraries/commons/measurements/src/lib/types/index.ts +++ b/libraries/commons/measurements/src/lib/types/index.ts @@ -1,10 +1,8 @@ -/** - * Large Behemoth Type Exports - * Only for big type augmentations like Leaflet extensions - * Small/co-located types should stay with their modules - */ - // Leaflet extensions (large type augmentation) -export * from "./leaflet-extensions.d"; -export * from "./MeasurementShape"; +export * from "./MeasurementPolyline"; +export * from "./MeasurementPolygon"; +export * from "./MeasurementLayer"; +export * from "./MeasurementLeafletEvent"; +export * from "./MeasurementShapeData"; export * from "./SnappingPoint"; +export * from "./MeasurementShape"; diff --git a/libraries/commons/measurements/src/lib/types/leaflet-extensions.d.ts b/libraries/commons/measurements/src/lib/types/leaflet-extensions.d.ts deleted file mode 100644 index c8c8e1eccf..0000000000 --- a/libraries/commons/measurements/src/lib/types/leaflet-extensions.d.ts +++ /dev/null @@ -1,290 +0,0 @@ -import { - Polyline, - Polygon, - Marker, - Layer, - Control, - ControlOptions, - LayerGroup, - LatLng, - Point, - LeafletMouseEvent, - LeafletEvent, - Map as LeafletMap, -} from "@carma/leaflet"; - -// Extended interfaces for measurement-specific objects -export interface MeasurementPolyline extends Polyline { - customID?: number | string; - customShape?: string; - _path?: SVGPathElement; - _leaflet_id?: number; - enableEdit?: () => void; - disableEdit?: () => void; -} - -export interface MeasurementPolygon extends Polygon { - customID?: number | string; - customShape?: string; - customHandle?: number; - _path?: SVGPathElement; - enableEdit?: () => void; - disableEdit?: () => void; -} - -export interface MeasurementMarker extends Marker { - customHandle?: number; -} - -export interface MeasurementLayer extends Layer { - customID?: number | string; - customHandle?: number; - _path?: SVGPathElement; - enableEdit?: () => void; - disableEdit?: () => void; - getLatLng?: () => LatLng; - _leaflet_id?: number; -} - -export interface MeasurementLeafletEvent extends LeafletEvent { - layerType?: string; - layers?: LayerGroup; -} - -export interface MeasurementShapeData { - coordinates: number[][]; - options: { - color: string; - fillColor: string | null; - opacity: number; - weight: number; - }; - shapeId: number | string; - distance: string; - number: number; - area?: string | null; - shapeType: "line" | "polygon"; - customTitle?: string; -} - -export interface DrawHandler { - _poly?: { _latlngs: LatLng[] }; - _enabled?: boolean; - enable(): void; - disable(): void; - completeShape?: () => void; - addVertex?(latlng: LatLng): void; - _markers?: MeasurementMarker[]; -} - -export interface MeasurePolygonOptions extends ControlOptions { - icon_lineActive: string; - icon_lineInactive: string; - icon_polygonActive: string; - icon_polygonInactive: string; - html_template: string; - height: number; - width: number; - mode_btn: string; - color_polygon: string; - fillColor_polygon: string; - weight_polygon: string; - isDrawing: boolean; - changeModeButtonActive: boolean; - msj_disable_tool: string; - shapes: MeasurementShapeData[]; - activeShape: number | string | null; - shapeMode: "line" | "polygon"; - measurementOrder: number; - moveToShape: boolean | MeasurementShapeData | null; - cb: () => void; - cbSaveShape: (shape: MeasurementShapeData) => void; - cdDeleteShape: ( - id: number | string, - localShapeStore: MeasurementShapeData[] - ) => void; - cbUpdateShape: ( - id: number | string, - newCoordinates: number[][], - newDistance: string, - newSquare: string | null - ) => void; - cbVisiblePolylinesChange: (ids: (number | string)[]) => void; - cbSetDrawingStatus: (status: boolean) => void; - cbSetDrawingShape: (shape: MeasurementShapeData | null) => void; - cbSetActiveShape: (id: number | string) => void; - cbSetUpdateStatusHandler: (status: boolean) => void; - cbMapMovingEndHandler: (status: boolean) => void; - cbSaveLastActiveShapeIdBeforeDrawingHandler: () => void; - cbChangeActiveCanceldShapeId: () => void; - cbToggleMeasurementMode: () => void; - cbGetMeasurementModeHandler: () => void; - cbDeleteVisibleShapeById: (id: number | string) => void; - cbUpdateAreaOfDrawingMeasurement: (area: string | null) => void; - cbSetCurrentDrawHandler: (handler: DrawHandler | null) => void; - cbSetMapStatus?: (status: string) => void; - visiblePolylines: (string | number)[]; - localShapeStore: MeasurementShapeData[]; - isDrawingEmpty: boolean; - nativeMove: boolean; - currenLine: DrawHandler | null; - polygonMode: boolean; - measurementMode: string | boolean; - startDrawing: boolean; - customTooltip: HTMLElement | null; - device: "desktop" | "mobile" | "tablet" | "Desktop" | null; - clickAfterShapeSelection: boolean; - snappingLatlng: LatLng | null; - snappingEnabled: boolean; - snappingQueryRadius?: number; -} - -export interface MeasurePolygonControl extends Control { - options: MeasurePolygonOptions; - _map: LeafletMap; - _measureLayers: LayerGroup; - _measureHandler: any; - _lastOriginalClick: { latlng: LatLng; containerPoint: Point }; - - _mapClickHandler?: (event: LeafletMouseEvent) => void; - _drawCreatedHandler?: (event: any) => void; - _drawDrawstartHandler?: (event: any) => void; - _drawDrawvertexHandler?: (event: any) => void; - _drawCanceledHandler?: () => void; - _moveendHandler?: (event: any) => void; - _mousemoveHandler?: (event: LeafletMouseEvent) => void; - _mouseoutHandler?: (event: LeafletMouseEvent) => void; - _vertexClickHandler?: (event: LeafletMouseEvent) => void; - _isFinishingShape?: boolean; - drawingLines(map: LeafletMap, event: LeafletMouseEvent): void; - - onAdd(map: LeafletMap): HTMLElement; - _clearMeasurements(): void; - changeColorByActivePolyline(map: LeafletMap, customID: number | string): void; - changeColorByLastShape(map: LeafletMap): void; - showLastPolylineOnFirstLoding(map: LeafletMap): void; - getVisiblePolylines(map: LeafletMap): MeasurementPolyline[]; - getVisiblePolylinesIds(polylines: MeasurementPolyline[]): void; - getAllPolylines(map: LeafletMap): MeasurementPolyline[]; - removePolylineById(map: LeafletMap, customID: number | string): void; - fitMapToAllPolylines(map: LeafletMap): void; - fitMapToPolylines(map: LeafletMap, polylines: MeasurementPolyline[]): void; - convertPolylineToPolygon(map: LeafletMap, layer: MeasurementPolyline): void; - loadMeasurements(map?: LeafletMap): void; - _toggleMeasurementBtn(): void; - toggleMeasurementMode(ifChangeMode?: boolean, map?: LeafletMap): void; - _UpdateDistance(layer: MeasurementPolyline): string; - _toggleMeasure(id: string, iconActive: string, inactiveIcon: string): void; - calculateArea(coordinates: number[][]): string; - calculateDistance(latlngs: LatLng[]): number; - formatDistance(distance: number): string; - saveShapeHandler( - layer: MeasurementPolyline, - distance: string | null, - area: string | null, - map: LeafletMap - ): void; - _onPolylineDrag(event: LeafletEvent): void; - replaceLineToPolygon( - map: LeafletMap, - layer: MeasurementPolyline - ): MeasurementShapeData; - getVisibleShapeIdsArr(map: LeafletMap): (number | string)[]; - _UpdateDistanceByLatLngs(coordinates: number[][]): string; - showActiveShape(map: LeafletMap, coordinates: number[][]): void; - changeMeasurementMode(mode: string, map: LeafletMap): void; - changeMeasurementsArr(arr: MeasurementShapeData[]): void; - findLastCreatedLayer(layerGroup: LayerGroup): Layer | null; - cancelDrawing(): void; - startDrawing(): void; - _onPolygonClick(map: LeafletMap, event: LeafletMouseEvent): void; - _UpdateAreaperimeter(layer: MeasurementPolygon): void; -} - -declare module "leaflet" { - namespace Control { - interface MeasurementShapeData extends MeasurementShapeData {} - interface DrawHandler extends DrawHandler {} - interface MeasurePolygonOptions extends MeasurePolygonOptions {} - class MeasurePolygon extends Control implements MeasurePolygonControl { - options: MeasurePolygonOptions; - _map: LeafletMap; - _measureLayers: LayerGroup; - _measureHandler: any; - _lastOriginalClick: { latlng: LatLng; containerPoint: Point }; - - _mapClickHandler?: (event: LeafletMouseEvent) => void; - _drawCreatedHandler?: (event: any) => void; - _drawDrawstartHandler?: (event: any) => void; - _drawDrawvertexHandler?: (event: any) => void; - _drawCanceledHandler?: () => void; - _moveendHandler?: (event: any) => void; - _mousemoveHandler?: (event: LeafletMouseEvent) => void; - _mouseoutHandler?: (event: LeafletMouseEvent) => void; - _vertexClickHandler?: (event: LeafletMouseEvent) => void; - _isFinishingShape?: boolean; - drawingLines(map: LeafletMap, event: LeafletMouseEvent): void; - - onAdd(map: LeafletMap): HTMLElement; - _clearMeasurements(): void; - changeColorByActivePolyline( - map: LeafletMap, - customID: number | string - ): void; - changeColorByLastShape(map: LeafletMap): void; - showLastPolylineOnFirstLoding(map: LeafletMap): void; - getVisiblePolylines(map: LeafletMap): MeasurementPolyline[]; - getVisiblePolylinesIds(polylines: MeasurementPolyline[]): void; - getAllPolylines(map: LeafletMap): MeasurementPolyline[]; - removePolylineById(map: LeafletMap, customID: number | string): void; - fitMapToAllPolylines(map: LeafletMap): void; - fitMapToPolylines( - map: LeafletMap, - polylines: MeasurementPolyline[] - ): void; - convertPolylineToPolygon( - map: LeafletMap, - layer: MeasurementPolyline - ): void; - loadMeasurements(map?: LeafletMap): void; - _toggleMeasurementBtn(): void; - toggleMeasurementMode(ifChangeMode?: boolean, map?: LeafletMap): void; - _UpdateDistance(layer: MeasurementPolyline): string; - _toggleMeasure( - id: string, - iconActive: string, - inactiveIcon: string - ): void; - calculateArea(coordinates: number[][]): string; - calculateDistance(latlngs: LatLng[]): number; - formatDistance(distance: number): string; - saveShapeHandler( - layer: MeasurementPolyline, - distance: string | null, - area: string | null, - map: LeafletMap - ): void; - _onPolylineDrag(event: LeafletEvent): void; - replaceLineToPolygon( - map: LeafletMap, - layer: MeasurementPolyline - ): MeasurementShapeData; - getVisibleShapeIdsArr(map: LeafletMap): (number | string)[]; - _UpdateDistanceByLatLngs(coordinates: number[][]): string; - showActiveShape(map: LeafletMap, coordinates: number[][]): void; - changeMeasurementMode(mode: string, map: LeafletMap): void; - changeMeasurementsArr(arr: MeasurementShapeData[]): void; - findLastCreatedLayer(layerGroup: LayerGroup): Layer | null; - cancelDrawing(): void; - startDrawing(): void; - _onPolygonClick(map: LeafletMap, event: LeafletMouseEvent): void; - _UpdateAreaperimeter(layer: MeasurementPolygon): void; - } - } - - namespace control { - function measurePolygon( - options?: Partial - ): Control.MeasurePolygon; - } -} diff --git a/libraries/commons/measurements/src/lib/utils/constants.ts b/libraries/commons/measurements/src/lib/utils/constants.ts new file mode 100644 index 0000000000..768507eec7 --- /dev/null +++ b/libraries/commons/measurements/src/lib/utils/constants.ts @@ -0,0 +1 @@ +export const DRAWING_SHAPE_ID = Symbol("active-drawing-shape"); diff --git a/libraries/commons/measurements/src/lib/utils/snapping.ts b/libraries/commons/measurements/src/lib/utils/snapping.ts index 9fcac719d0..7699603347 100644 --- a/libraries/commons/measurements/src/lib/utils/snapping.ts +++ b/libraries/commons/measurements/src/lib/utils/snapping.ts @@ -5,30 +5,11 @@ import L from "leaflet"; import { latLng } from "@carma/leaflet"; import { distanceMeters, metersPerPixel } from "@carma/geo/utils"; -import type { LatLng, Map as LeafletMap, CircleMarker } from "leaflet"; -import type { SnappingPoint } from "../types"; +import { SnappingPoint } from "../types"; /** Snapping modifier key to temporarily disable snapping */ export const SNAPPING_MODIFIER_KEY = "Alt"; -/** - * Check if snapping modifier key is pressed - */ -export const isSnappingModifierPressed = (event: { - getModifierState?: (key: string) => boolean; - altKey?: boolean; - ctrlKey?: boolean; - shiftKey?: boolean; -}): boolean => { - if (event.getModifierState) { - return event.getModifierState(SNAPPING_MODIFIER_KEY); - } - if (SNAPPING_MODIFIER_KEY === "Alt") return !!event.altKey; - if (SNAPPING_MODIFIER_KEY === "Control") return !!event.ctrlKey; - if (SNAPPING_MODIFIER_KEY === "Shift") return !!event.shiftKey; - return false; -}; - /** Default threshold for coordinate match (0.1 meters = 10cm) */ export const EXACT_MATCH_METERS = 0.1; @@ -84,8 +65,8 @@ export const isCoordMatchLatLng = ( */ export const createSnappingIndicator = ( latlng: { lat: number; lng: number }, - map: LeafletMap -): CircleMarker => { + map: L.Map +): L.CircleMarker => { return L.circleMarker([latlng.lat, latlng.lng], { radius: 3.5, color: "#000000", @@ -123,7 +104,7 @@ export const findClosestSnappingPoint = ( /** * Get the first vertex of a draw handler's polygon if it has 3+ vertices */ -export const getFirstVertexIfClosable = (drawHandler: any): LatLng | null => { +export const getFirstVertexIfClosable = (drawHandler: any): L.LatLng | null => { if (drawHandler?._poly?._latlngs?.length >= 3) { return drawHandler._poly._latlngs[0]; } @@ -133,7 +114,9 @@ export const getFirstVertexIfClosable = (drawHandler: any): LatLng | null => { /** * Get the last vertex of a draw handler's line if it has 2+ vertices */ -export const getLastVertexIfFinishable = (drawHandler: any): LatLng | null => { +export const getLastVertexIfFinishable = ( + drawHandler: any +): L.LatLng | null => { const latlngs = drawHandler?._poly?._latlngs; if (latlngs?.length >= 2) { return latlngs[latlngs.length - 1]; @@ -170,21 +153,27 @@ export const isLastVertexMatch = ( }; /** - * Try to close a polygon by clicking its first vertex marker + * Try to close a polygon by calling _finishShape directly */ export const tryClosePolygon = (drawHandler: any): boolean => { const firstVertex = getFirstVertexIfClosable(drawHandler); if (!firstVertex) return false; - const firstMarker = drawHandler._markers?.[0]; - if (!firstMarker) return false; + if (drawHandler._finishShape) { + // If the handler is a Polyline handler (but not a Polygon handler), + // it doesn't automatically close the shape. We must manually add the closing vertex. + if (drawHandler.type === "polyline") { + console.debug( + "[snapping] Closing polyline to form polygon: adding first vertex" + ); + drawHandler.addVertex(firstVertex); + } - console.debug("[snapping] Closing polygon via first vertex click"); - firstMarker.fire("click", { - latlng: firstVertex, - target: firstMarker, - }); - return true; + console.debug("[snapping] Closing polygon via _finishShape"); + drawHandler._finishShape(); + return true; + } + return false; }; /** @@ -288,3 +277,32 @@ export const handleDuplicateVertex = ( } return false; // Not a duplicate }; + +/** + * Create a GeoJSON Feature for the snapping candidate (snapped or unsnapped) + */ +export const createSnappingFeature = ( + closestResult: { point: SnappingPoint; distance: number } | null, + mouseLatLng: { lat: number; lng: number } +): any => { + const isSnapped = !!closestResult; + const coordinates = closestResult + ? closestResult.point.coordinates + : [mouseLatLng.lng, mouseLatLng.lat]; + + const sourceId = closestResult + ? closestResult.point.sourceId + : "pointerposition"; + + return { + type: "Feature", + geometry: { + type: "Point", + coordinates: coordinates, + }, + properties: { + isSnapped: isSnapped, + source: sourceId, + }, + }; +};