From 47376b507db8fd05d8077c2270b12e0bf5261cd4 Mon Sep 17 00:00:00 2001 From: "lukas.dufek" Date: Wed, 11 Mar 2026 11:14:47 +0100 Subject: [PATCH 1/8] polygon drawing implemented --- .../map/PolygonDrawing/ControlButtons.tsx | 32 ++++ .../map/PolygonDrawing/PolygonDrawing.tsx | 154 ++++++++++++++++++ .../PolygonDrawing/_layers/polygonLayer.ts | 90 ++++++++++ .../PolygonDrawing/_logic/onPolygonClick.ts | 47 ++++++ .../PolygonDrawing/_logic/onPolygonDrag.ts | 32 ++++ .../PolygonDrawing/_logic/onPolygonHover.ts | 25 +++ .../_logic/polygonDrawingTypes.ts | 26 +++ 7 files changed, 406 insertions(+) create mode 100644 src/client/map/PolygonDrawing/ControlButtons.tsx create mode 100644 src/client/map/PolygonDrawing/PolygonDrawing.tsx create mode 100644 src/client/map/PolygonDrawing/_layers/polygonLayer.ts create mode 100644 src/client/map/PolygonDrawing/_logic/onPolygonClick.ts create mode 100644 src/client/map/PolygonDrawing/_logic/onPolygonDrag.ts create mode 100644 src/client/map/PolygonDrawing/_logic/onPolygonHover.ts create mode 100644 src/client/map/PolygonDrawing/_logic/polygonDrawingTypes.ts diff --git a/src/client/map/PolygonDrawing/ControlButtons.tsx b/src/client/map/PolygonDrawing/ControlButtons.tsx new file mode 100644 index 0000000..970f239 --- /dev/null +++ b/src/client/map/PolygonDrawing/ControlButtons.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { Button, Stack } from '@mantine/core'; +import { IconPencil, IconTrash } from '@tabler/icons-react'; + +interface ControlButtonsProps { + isClosed: boolean; + onClear: () => void; + onToggleActive: () => void; + isActive: boolean; +} + +export const ControlButtons: React.FC = ({ isClosed, onClear, onToggleActive, isActive }) => { + return ( + + + + + ); +}; + diff --git a/src/client/map/PolygonDrawing/PolygonDrawing.tsx b/src/client/map/PolygonDrawing/PolygonDrawing.tsx new file mode 100644 index 0000000..e2f5692 --- /dev/null +++ b/src/client/map/PolygonDrawing/PolygonDrawing.tsx @@ -0,0 +1,154 @@ +import React, { useState, cloneElement, Children, ReactElement } from 'react'; +import { ControlButtons } from './ControlButtons'; +import { polygonLayer } from './_layers/polygonLayer'; +import { onPolygonClick } from './_logic/onPolygonClick'; +import { onPolygonDrag } from './_logic/onPolygonDrag'; +import { onPolygonHover } from './_logic/onPolygonHover'; +import { PolygonClickInfo, PolygonDragInfo } from './_logic/polygonDrawingTypes'; + +interface PolygonDrawingProps { + /** The map component to wrap */ + children: ReactElement; + /** Callback when the polygon coordinates change */ + onPolygonChange?: (polygon: [number, number][]) => void; +} + +/** + * Component that allows drawing and editing a polygon on a map. + * Wraps a map component (like RenderingMap) and injects deck.gl layers and event handlers. + */ +export const PolygonDrawing: React.FC = ({ children, onPolygonChange }) => { + // State for the polygon vertices [longitude, latitude] + const [polygonCoordinates, setPolygonCoordinates] = useState<[number, number][]>([]); + // State to track if the polygon loop is closed + const [isClosed, setIsClosed] = useState(false); + // State to control if drawing/editing is enabled + const [isActive, setIsActive] = useState(false); + // State to track if the cursor is hovering over a vertex (for styling and drag initiation) + const [isHoveringPoint, setIsHoveringPoint] = useState(false); + // State to store the index of the vertex being hovered + const [hoveredPointIndex, setHoveredPointIndex] = useState(null); + // State to track if a vertex is currently being dragged + const [isDragging, setIsDragging] = useState(false); + + /** + * Updates the polygon coordinates and triggers the external callback. + */ + const handlePolygonUpdate = (coords: [number, number][]) => { + setPolygonCoordinates(coords); + if (onPolygonChange) { + onPolygonChange(coords); + } + }; + + const handleIsClosedUpdate = (closed: boolean) => { + setIsClosed(closed); + // Logic for what happens when polygon closes can be extended here + }; + + /** + * Resets the drawing state to start over. + */ + const handleClear = () => { + setPolygonCoordinates([]); + setIsClosed(false); + setIsActive(true); // Automatically switch to drawing mode + setIsHoveringPoint(false); + setHoveredPointIndex(null); + if (onPolygonChange) onPolygonChange([]); + }; + + const handleToggleActive = () => { + setIsActive(!isActive); + }; + + // Calculate the deck.gl layers to render based on current state + const layers = polygonLayer({ + polygonCoordinates, + isClosed, + isActive, + hoveredPointIndex + }); + + // Clone the child map component to inject necessary props for interaction + const mappedChildren = Children.map(children, (child) => { + if (!React.isValidElement(child)) return child; + + return cloneElement(child as ReactElement, { + // Inject the generated layers into the map + layer: layers, + + // Handle click events on the map + onClick: (info: PolygonClickInfo) => { + // Ignore clicks if drawing/editing is disabled + if (!isActive) return; + + onPolygonClick({ + info, + polygonCoordinates, + isClosed, + setPolygonCoordinates: handlePolygonUpdate, + setIsClosed: handleIsClosedUpdate + }); + }, + + // Handle drag events (moving vertices) + onDrag: (info: PolygonDragInfo) => { + if (!isActive) return; + onPolygonDrag({ + info, + polygonCoordinates, + setPolygonCoordinates: handlePolygonUpdate + }); + }, + + // Handle hover events (detecting vertices) + onHover: (info: PolygonClickInfo) => { + if (!isActive) return; + onPolygonHover({ + info, + setIsHoveringPoint, + setHoveredPointIndex + }); + }, + + // Handle start of a drag interaction + onStartDragging: () => { + // Only allow dragging if we are hovering over a point + if (isActive && isHoveringPoint) { + setIsDragging(true); + } + }, + + // Handle end of a drag interaction + onStopDragging: () => { + setIsDragging(false); + }, + + // dynamic cursor styling based on state + getCursor: ({ isDragging }: { isDragging: boolean }) => { + if (isDragging) return 'grabbing'; + if (isHoveringPoint && isActive) return 'pointer'; + if (isActive && !isClosed) return 'crosshair'; + return 'default'; + }, + + // disable default map controls (pan/zoom) when dragging a point or maybe we want to disable them when drawing? + // Here we disable only when dragging a point. + disableControls: (isActive && !isClosed) || isDragging + }); + }); + + return ( +
+ {mappedChildren} + +
+ ); +}; + diff --git a/src/client/map/PolygonDrawing/_layers/polygonLayer.ts b/src/client/map/PolygonDrawing/_layers/polygonLayer.ts new file mode 100644 index 0000000..bc141a8 --- /dev/null +++ b/src/client/map/PolygonDrawing/_layers/polygonLayer.ts @@ -0,0 +1,90 @@ +import { PolygonLayer, ScatterplotLayer, PathLayer } from '@deck.gl/layers'; + +interface PolygonLayerProps { + polygonCoordinates: [number, number][]; + isClosed: boolean; + isActive: boolean; + hoveredPointIndex: number | null; +} + +/** + * Generates deck.gl layers for displaying and editing the polygon. + * + * @param props Props for generating layers + * @returns Array of deck.gl layers + */ +export const polygonLayer = ({ + polygonCoordinates, + isClosed, + isActive, + hoveredPointIndex, +}: PolygonLayerProps) => { + + // Safety check: Don't render if coordinates are missing + if (!polygonCoordinates) return []; + + const layers: any[] = []; + + // Layer for vertices (ScatterplotLayer) + // Allows dragging points and highlights the starting point for closing the loop. + if (isActive && polygonCoordinates.length > 0) { + layers.push( + new ScatterplotLayer({ + id: 'vertex-layer', + data: polygonCoordinates.map((_coord, _index) => ({ position: _coord, index: _index })), + getPosition: (_data: any) => _data.position, + getRadius: 50, // TODO: Adjust radius based on zoom level for better UX + getFillColor: (_data: any) => { + // Highlight hovered point + if (_data.index === hoveredPointIndex) return [255, 255, 0]; + // Highlight first point in red if loop is closeable (unclosed & > 2 points) + return (_data.index === 0 && !isClosed && polygonCoordinates.length > 2) ? [255, 0, 0] : [255, 255, 255]; + }, + getLineColor: [0, 0, 0], + lineWidthMinPixels: 1, + radiusMinPixels: 5, + pickable: true, + autoHighlight: true, + highlightColor: [255, 0, 0, 255], + updateTriggers: { + getFillColor: [isClosed, polygonCoordinates.length] + } + }) + ); + } + + // Layer for closed polygon (PolygonLayer) + // Rendered when the loop is closed to show the area. + if (isClosed && polygonCoordinates.length >= 3) { + layers.push( + new PolygonLayer({ + id: 'polygon-fill-layer', + data: [{ polygon: polygonCoordinates }], + getPolygon: (_data: any) => _data.polygon, + getFillColor: [0, 150, 255, 100], + getLineColor: [0, 100, 255], + pickable: true, + stroked: true, + filled: true, + lineWidthMinPixels: 2, + autoHighlight: true, + }) + ); + } + // Layer for open path (while drawing) (PathLayer) + // Rendered while drawing to connect the points placed so far. + else if (polygonCoordinates.length > 0) { + layers.push( + new PathLayer({ + id: 'polygon-path-layer', + data: [{ path: polygonCoordinates }], + getPath: (_data: any) => _data.path, + getColor: [0, 0, 255], + widthMinPixels: 2, + pickable: false, + }) + ); + } + + return layers; +}; diff --git a/src/client/map/PolygonDrawing/_logic/onPolygonClick.ts b/src/client/map/PolygonDrawing/_logic/onPolygonClick.ts new file mode 100644 index 0000000..498911d --- /dev/null +++ b/src/client/map/PolygonDrawing/_logic/onPolygonClick.ts @@ -0,0 +1,47 @@ +import { PolygonCoordinates, PolygonClickInfo } from './polygonDrawingTypes'; + +interface OnClickParams { + info: PolygonClickInfo; + polygonCoordinates: PolygonCoordinates; + isClosed: boolean; + setPolygonCoordinates: (coords: PolygonCoordinates) => void; + setIsClosed: (closed: boolean) => void; +} + +/** + * Validates click events and either adds a new vertex or closes the polygon loop. + */ +export const onPolygonClick = ({ + info, + polygonCoordinates, + isClosed, + setPolygonCoordinates, + setIsClosed, +}: OnClickParams) => { + // If polygon is already closed, prevent adding more points. + // Edit mode (dragging existing points) is handled separately. + if (isClosed) return; + + // Safety check for info + if (!info) return; + + const { coordinate, index, layer } = info; + + // Check if the user clicked on an existing vertex + if (layer && layer.id && layer.id.includes('vertex-layer')) { + // If clicking on the first point (index 0) and we have enough points (>=3), close the polygon + if (typeof index === 'number' && index === 0 && polygonCoordinates.length > 2) { + setIsClosed(true); + return; + } + // If clicked on any vertex, do not add a new point + // This prevents adding points on top of existing ones + return; + } + + // Add new point at clicked coordinate + if (coordinate) { + setPolygonCoordinates([...polygonCoordinates, coordinate as [number, number]]); + } +}; + diff --git a/src/client/map/PolygonDrawing/_logic/onPolygonDrag.ts b/src/client/map/PolygonDrawing/_logic/onPolygonDrag.ts new file mode 100644 index 0000000..907a4b8 --- /dev/null +++ b/src/client/map/PolygonDrawing/_logic/onPolygonDrag.ts @@ -0,0 +1,32 @@ +import { PolygonCoordinates, PolygonDragInfo } from './polygonDrawingTypes'; + +interface OnDragParams { + info: PolygonDragInfo; + polygonCoordinates: PolygonCoordinates; + setPolygonCoordinates: (coords: PolygonCoordinates) => void; +} + +/** + * Handles dragging of polygon vertices. + * Updates the coordinate of the dragged vertex in real-time. + */ +export const onPolygonDrag = ({ + info, + polygonCoordinates, + setPolygonCoordinates, +}: OnDragParams) => { + // Safety check + if (!info) return; + + const { coordinate, index } = info; + + // Validate that we are dragging a valid vertex index + if (typeof index !== 'number' || index < 0 || index >= polygonCoordinates.length) return; + + // Create a copy of coordinates and update the specific vertex position + // This allows real-time visual feedback while dragging + const newCoords = [...polygonCoordinates]; + newCoords[index] = [coordinate[0], coordinate[1]]; + setPolygonCoordinates(newCoords); +}; + diff --git a/src/client/map/PolygonDrawing/_logic/onPolygonHover.ts b/src/client/map/PolygonDrawing/_logic/onPolygonHover.ts new file mode 100644 index 0000000..958423a --- /dev/null +++ b/src/client/map/PolygonDrawing/_logic/onPolygonHover.ts @@ -0,0 +1,25 @@ +import { PolygonClickInfo } from './polygonDrawingTypes'; + +interface OnHoverParams { + info: PolygonClickInfo; + setIsHoveringPoint: (isHovering: boolean) => void; + setHoveredPointIndex: (index: number | null) => void; +} + +/** + * Detects if the cursor is hovering over a polygon vertex. + * Used to update UI state for highlighting and cursor styling. + */ +export const onPolygonHover = ({ info, setIsHoveringPoint, setHoveredPointIndex }: OnHoverParams) => { + const { layer, index } = info; + + // Check if the hovered object belongs to the 'vertex-layer' and has a valid index + if (layer && layer.id === 'vertex-layer' && typeof index === 'number' && index >= 0) { + setIsHoveringPoint(true); + setHoveredPointIndex(index); + } else { + setIsHoveringPoint(false); + setHoveredPointIndex(null); + } +}; + diff --git a/src/client/map/PolygonDrawing/_logic/polygonDrawingTypes.ts b/src/client/map/PolygonDrawing/_logic/polygonDrawingTypes.ts new file mode 100644 index 0000000..6a6763e --- /dev/null +++ b/src/client/map/PolygonDrawing/_logic/polygonDrawingTypes.ts @@ -0,0 +1,26 @@ +export type PolygonCoordinate = [number, number]; +export type PolygonCoordinates = PolygonCoordinate[]; + +export interface PolygonDragInfo { + object: any; + coordinate: PolygonCoordinate; + index: number; +} + +export interface PolygonClickInfo { + coordinate: PolygonCoordinate; + object?: any; + layer?: any; + index?: number; +} + +export interface PolygonDragStartInfo { + index: number; + coordinate: PolygonCoordinate; +} + +export interface PolygonGetCursorInfo { + isHovering: boolean; + isDragging: boolean; +} + From ca6af7c630eb3e04629d9077fd1b7ecc37b05e9d Mon Sep 17 00:00:00 2001 From: "lukas.dufek" Date: Wed, 11 Mar 2026 11:41:06 +0100 Subject: [PATCH 2/8] types and prettier fixs --- .../map/PolygonDrawing/ControlButtons.tsx | 46 +++--- .../map/PolygonDrawing/PolygonDrawing.tsx | 3 +- .../PolygonDrawing/_layers/polygonLayer.ts | 144 +++++++++--------- .../_logic/polygonDrawingTypes.ts | 6 +- 4 files changed, 99 insertions(+), 100 deletions(-) diff --git a/src/client/map/PolygonDrawing/ControlButtons.tsx b/src/client/map/PolygonDrawing/ControlButtons.tsx index 970f239..a515e0f 100644 --- a/src/client/map/PolygonDrawing/ControlButtons.tsx +++ b/src/client/map/PolygonDrawing/ControlButtons.tsx @@ -3,30 +3,30 @@ import { Button, Stack } from '@mantine/core'; import { IconPencil, IconTrash } from '@tabler/icons-react'; interface ControlButtonsProps { - isClosed: boolean; - onClear: () => void; - onToggleActive: () => void; - isActive: boolean; + isClosed: boolean; + onClear: () => void; + onToggleActive: () => void; + isActive: boolean; } -export const ControlButtons: React.FC = ({ isClosed, onClear, onToggleActive, isActive }) => { - return ( - - - - - ); +export const ControlButtons: React.FC = ({isClosed, onClear, onToggleActive, isActive}) => { + return ( + + + + + ); }; diff --git a/src/client/map/PolygonDrawing/PolygonDrawing.tsx b/src/client/map/PolygonDrawing/PolygonDrawing.tsx index e2f5692..a1927ae 100644 --- a/src/client/map/PolygonDrawing/PolygonDrawing.tsx +++ b/src/client/map/PolygonDrawing/PolygonDrawing.tsx @@ -133,8 +133,7 @@ export const PolygonDrawing: React.FC = ({ children, onPoly return 'default'; }, - // disable default map controls (pan/zoom) when dragging a point or maybe we want to disable them when drawing? - // Here we disable only when dragging a point. + // disable default map controls (pan/zoom) while drawing an open polygon or dragging a point disableControls: (isActive && !isClosed) || isDragging }); }); diff --git a/src/client/map/PolygonDrawing/_layers/polygonLayer.ts b/src/client/map/PolygonDrawing/_layers/polygonLayer.ts index bc141a8..7ca9ced 100644 --- a/src/client/map/PolygonDrawing/_layers/polygonLayer.ts +++ b/src/client/map/PolygonDrawing/_layers/polygonLayer.ts @@ -1,10 +1,10 @@ import { PolygonLayer, ScatterplotLayer, PathLayer } from '@deck.gl/layers'; interface PolygonLayerProps { - polygonCoordinates: [number, number][]; - isClosed: boolean; - isActive: boolean; - hoveredPointIndex: number | null; + polygonCoordinates: [number, number][]; + isClosed: boolean; + isActive: boolean; + hoveredPointIndex: number | null; } /** @@ -14,77 +14,77 @@ interface PolygonLayerProps { * @returns Array of deck.gl layers */ export const polygonLayer = ({ - polygonCoordinates, - isClosed, - isActive, - hoveredPointIndex, -}: PolygonLayerProps) => { + polygonCoordinates, + isClosed, + isActive, + hoveredPointIndex, + }: PolygonLayerProps) => { - // Safety check: Don't render if coordinates are missing - if (!polygonCoordinates) return []; + // Safety check: Don't render if coordinates are missing + if (!polygonCoordinates) return []; - const layers: any[] = []; + const layers: any[] = []; - // Layer for vertices (ScatterplotLayer) - // Allows dragging points and highlights the starting point for closing the loop. - if (isActive && polygonCoordinates.length > 0) { - layers.push( - new ScatterplotLayer({ - id: 'vertex-layer', - data: polygonCoordinates.map((_coord, _index) => ({ position: _coord, index: _index })), - getPosition: (_data: any) => _data.position, - getRadius: 50, // TODO: Adjust radius based on zoom level for better UX - getFillColor: (_data: any) => { - // Highlight hovered point - if (_data.index === hoveredPointIndex) return [255, 255, 0]; - // Highlight first point in red if loop is closeable (unclosed & > 2 points) - return (_data.index === 0 && !isClosed && polygonCoordinates.length > 2) ? [255, 0, 0] : [255, 255, 255]; - }, - getLineColor: [0, 0, 0], - lineWidthMinPixels: 1, - radiusMinPixels: 5, - pickable: true, - autoHighlight: true, - highlightColor: [255, 0, 0, 255], - updateTriggers: { - getFillColor: [isClosed, polygonCoordinates.length] - } - }) - ); - } + // Layer for vertices (ScatterplotLayer) + // Allows dragging points and highlights the starting point for closing the loop. + if (isActive && polygonCoordinates.length > 0) { + layers.push( + new ScatterplotLayer({ + id: 'vertex-layer', + data: polygonCoordinates.map((_coord, _index) => ({position: _coord, index: _index})), + getPosition: (_data: any) => _data.position, + getRadius: 50, // TODO: Adjust radius based on zoom level for better UX + getFillColor: (_data: any) => { + // Highlight hovered point + if (_data.index === hoveredPointIndex) return [255, 255, 0]; + // Highlight first point in red if loop is closeable (unclosed & > 2 points) + return (_data.index === 0 && !isClosed && polygonCoordinates.length > 2) ? [255, 0, 0] : [255, 255, 255]; + }, + getLineColor: [0, 0, 0], + lineWidthMinPixels: 1, + radiusMinPixels: 5, + pickable: true, + autoHighlight: true, + highlightColor: [255, 0, 0, 255], + updateTriggers: { + getFillColor: [isClosed, polygonCoordinates.length] + } + }) + ); + } - // Layer for closed polygon (PolygonLayer) - // Rendered when the loop is closed to show the area. - if (isClosed && polygonCoordinates.length >= 3) { - layers.push( - new PolygonLayer({ - id: 'polygon-fill-layer', - data: [{ polygon: polygonCoordinates }], - getPolygon: (_data: any) => _data.polygon, - getFillColor: [0, 150, 255, 100], - getLineColor: [0, 100, 255], - pickable: true, - stroked: true, - filled: true, - lineWidthMinPixels: 2, - autoHighlight: true, - }) - ); - } - // Layer for open path (while drawing) (PathLayer) - // Rendered while drawing to connect the points placed so far. - else if (polygonCoordinates.length > 0) { - layers.push( - new PathLayer({ - id: 'polygon-path-layer', - data: [{ path: polygonCoordinates }], - getPath: (_data: any) => _data.path, - getColor: [0, 0, 255], - widthMinPixels: 2, - pickable: false, - }) - ); - } + // Layer for closed polygon (PolygonLayer) + // Rendered when the loop is closed to show the area. + if (isClosed && polygonCoordinates.length >= 3) { + layers.push( + new PolygonLayer({ + id: 'polygon-fill-layer', + data: [{polygon: polygonCoordinates}], + getPolygon: (_data: any) => _data.polygon, + getFillColor: [0, 150, 255, 100], + getLineColor: [0, 100, 255], + pickable: true, + stroked: true, + filled: true, + lineWidthMinPixels: 2, + autoHighlight: true, + }) + ); + } + // Layer for open path (while drawing) (PathLayer) + // Rendered while drawing to connect the points placed so far. + else if (polygonCoordinates.length > 0) { + layers.push( + new PathLayer({ + id: 'polygon-path-layer', + data: [{path: polygonCoordinates}], + getPath: (_data: any) => _data.path, + getColor: [0, 0, 255], + widthMinPixels: 2, + pickable: false, + }) + ); + } - return layers; + return layers; }; diff --git a/src/client/map/PolygonDrawing/_logic/polygonDrawingTypes.ts b/src/client/map/PolygonDrawing/_logic/polygonDrawingTypes.ts index 6a6763e..a6ff015 100644 --- a/src/client/map/PolygonDrawing/_logic/polygonDrawingTypes.ts +++ b/src/client/map/PolygonDrawing/_logic/polygonDrawingTypes.ts @@ -2,15 +2,15 @@ export type PolygonCoordinate = [number, number]; export type PolygonCoordinates = PolygonCoordinate[]; export interface PolygonDragInfo { - object: any; + object: unknown; coordinate: PolygonCoordinate; index: number; } export interface PolygonClickInfo { coordinate: PolygonCoordinate; - object?: any; - layer?: any; + object?: unknown; + layer?: unknown; index?: number; } From ed5820cb577d0aad90469dc722e61fb31fceb4ad Mon Sep 17 00:00:00 2001 From: "lukas.dufek" Date: Wed, 11 Mar 2026 11:51:06 +0100 Subject: [PATCH 3/8] Recommendations and prettier changes --- .../map/PolygonDrawing/PolygonDrawing.tsx | 272 +++++++++--------- .../PolygonDrawing/_layers/polygonLayer.ts | 3 +- .../_logic/polygonDrawingTypes.ts | 6 +- 3 files changed, 141 insertions(+), 140 deletions(-) diff --git a/src/client/map/PolygonDrawing/PolygonDrawing.tsx b/src/client/map/PolygonDrawing/PolygonDrawing.tsx index a1927ae..7bc1342 100644 --- a/src/client/map/PolygonDrawing/PolygonDrawing.tsx +++ b/src/client/map/PolygonDrawing/PolygonDrawing.tsx @@ -7,147 +7,147 @@ import { onPolygonHover } from './_logic/onPolygonHover'; import { PolygonClickInfo, PolygonDragInfo } from './_logic/polygonDrawingTypes'; interface PolygonDrawingProps { - /** The map component to wrap */ - children: ReactElement; - /** Callback when the polygon coordinates change */ - onPolygonChange?: (polygon: [number, number][]) => void; + /** The map component to wrap */ + children: ReactElement; + /** Callback when the polygon coordinates change */ + onPolygonChange?: (polygon: [number, number][]) => void; } /** * Component that allows drawing and editing a polygon on a map. * Wraps a map component (like RenderingMap) and injects deck.gl layers and event handlers. */ -export const PolygonDrawing: React.FC = ({ children, onPolygonChange }) => { - // State for the polygon vertices [longitude, latitude] - const [polygonCoordinates, setPolygonCoordinates] = useState<[number, number][]>([]); - // State to track if the polygon loop is closed - const [isClosed, setIsClosed] = useState(false); - // State to control if drawing/editing is enabled - const [isActive, setIsActive] = useState(false); - // State to track if the cursor is hovering over a vertex (for styling and drag initiation) - const [isHoveringPoint, setIsHoveringPoint] = useState(false); - // State to store the index of the vertex being hovered - const [hoveredPointIndex, setHoveredPointIndex] = useState(null); - // State to track if a vertex is currently being dragged - const [isDragging, setIsDragging] = useState(false); - - /** - * Updates the polygon coordinates and triggers the external callback. - */ - const handlePolygonUpdate = (coords: [number, number][]) => { - setPolygonCoordinates(coords); - if (onPolygonChange) { - onPolygonChange(coords); - } - }; - - const handleIsClosedUpdate = (closed: boolean) => { - setIsClosed(closed); - // Logic for what happens when polygon closes can be extended here - }; - - /** - * Resets the drawing state to start over. - */ - const handleClear = () => { - setPolygonCoordinates([]); - setIsClosed(false); - setIsActive(true); // Automatically switch to drawing mode - setIsHoveringPoint(false); - setHoveredPointIndex(null); - if (onPolygonChange) onPolygonChange([]); - }; - - const handleToggleActive = () => { - setIsActive(!isActive); - }; - - // Calculate the deck.gl layers to render based on current state - const layers = polygonLayer({ - polygonCoordinates, - isClosed, - isActive, - hoveredPointIndex - }); - - // Clone the child map component to inject necessary props for interaction - const mappedChildren = Children.map(children, (child) => { - if (!React.isValidElement(child)) return child; - - return cloneElement(child as ReactElement, { - // Inject the generated layers into the map - layer: layers, - - // Handle click events on the map - onClick: (info: PolygonClickInfo) => { - // Ignore clicks if drawing/editing is disabled - if (!isActive) return; - - onPolygonClick({ - info, - polygonCoordinates, - isClosed, - setPolygonCoordinates: handlePolygonUpdate, - setIsClosed: handleIsClosedUpdate - }); - }, - - // Handle drag events (moving vertices) - onDrag: (info: PolygonDragInfo) => { - if (!isActive) return; - onPolygonDrag({ - info, - polygonCoordinates, - setPolygonCoordinates: handlePolygonUpdate - }); - }, - - // Handle hover events (detecting vertices) - onHover: (info: PolygonClickInfo) => { - if (!isActive) return; - onPolygonHover({ - info, - setIsHoveringPoint, - setHoveredPointIndex - }); - }, - - // Handle start of a drag interaction - onStartDragging: () => { - // Only allow dragging if we are hovering over a point - if (isActive && isHoveringPoint) { - setIsDragging(true); - } - }, - - // Handle end of a drag interaction - onStopDragging: () => { - setIsDragging(false); - }, - - // dynamic cursor styling based on state - getCursor: ({ isDragging }: { isDragging: boolean }) => { - if (isDragging) return 'grabbing'; - if (isHoveringPoint && isActive) return 'pointer'; - if (isActive && !isClosed) return 'crosshair'; - return 'default'; - }, - - // disable default map controls (pan/zoom) while drawing an open polygon or dragging a point - disableControls: (isActive && !isClosed) || isDragging - }); - }); - - return ( -
- {mappedChildren} - -
- ); +export const PolygonDrawing: React.FC = ({children, onPolygonChange}) => { + // State for the polygon vertices [longitude, latitude] + const [polygonCoordinates, setPolygonCoordinates] = useState<[number, number][]>([]); + // State to track if the polygon loop is closed + const [isClosed, setIsClosed] = useState(false); + // State to control if drawing/editing is enabled + const [isActive, setIsActive] = useState(false); + // State to track if the cursor is hovering over a vertex (for styling and drag initiation) + const [isHoveringPoint, setIsHoveringPoint] = useState(false); + // State to store the index of the vertex being hovered + const [hoveredPointIndex, setHoveredPointIndex] = useState(null); + // State to track if a vertex is currently being dragged + const [isDragging, setIsDragging] = useState(false); + + /** + * Updates the polygon coordinates and triggers the external callback. + */ + const handlePolygonUpdate = (coords: [number, number][]) => { + setPolygonCoordinates(coords); + if (onPolygonChange) { + onPolygonChange(coords); + } + }; + + const handleIsClosedUpdate = (closed: boolean) => { + setIsClosed(closed); + // Logic for what happens when polygon closes can be extended here + }; + + /** + * Resets the drawing state to start over. + */ + const handleClear = () => { + setPolygonCoordinates([]); + setIsClosed(false); + setIsActive(true); // Automatically switch to drawing mode + setIsHoveringPoint(false); + setHoveredPointIndex(null); + if (onPolygonChange) onPolygonChange([]); + }; + + const handleToggleActive = () => { + setIsActive(!isActive); + }; + + // Calculate the deck.gl layers to render based on current state + const layers = polygonLayer({ + polygonCoordinates, + isClosed, + isActive, + hoveredPointIndex + }); + + // Clone the child map component to inject necessary props for interaction + const mappedChildren = Children.map(children, (child) => { + if (!React.isValidElement(child)) return child; + + return cloneElement(child as ReactElement, { + // Inject the generated layers into the map + layer: layers, + + // Handle click events on the map + onClick: (info: PolygonClickInfo) => { + // Ignore clicks if drawing/editing is disabled + if (!isActive) return; + + onPolygonClick({ + info, + polygonCoordinates, + isClosed, + setPolygonCoordinates: handlePolygonUpdate, + setIsClosed: handleIsClosedUpdate + }); + }, + + // Handle drag events (moving vertices) + onDrag: (info: PolygonDragInfo) => { + if (!isActive) return; + onPolygonDrag({ + info, + polygonCoordinates, + setPolygonCoordinates: handlePolygonUpdate + }); + }, + + // Handle hover events (detecting vertices) + onHover: (info: PolygonClickInfo) => { + if (!isActive) return; + onPolygonHover({ + info, + setIsHoveringPoint, + setHoveredPointIndex + }); + }, + + // Handle start of a drag interaction + onStartDragging: () => { + // Only allow dragging if we are hovering over a point + if (isActive && isHoveringPoint) { + setIsDragging(true); + } + }, + + // Handle end of a drag interaction + onStopDragging: () => { + setIsDragging(false); + }, + + // dynamic cursor styling based on state + getCursor: ({isDragging}: { isDragging: boolean }) => { + if (isDragging) return 'grabbing'; + if (isHoveringPoint && isActive) return 'pointer'; + if (isActive && !isClosed) return 'crosshair'; + return 'default'; + }, + + // disable default map controls (pan/zoom) while drawing an open polygon or dragging a point + disableControls: (isActive && !isClosed) || isDragging + }); + }); + + return ( +
+ {mappedChildren} + +
+ ); }; diff --git a/src/client/map/PolygonDrawing/_layers/polygonLayer.ts b/src/client/map/PolygonDrawing/_layers/polygonLayer.ts index 7ca9ced..5178539 100644 --- a/src/client/map/PolygonDrawing/_layers/polygonLayer.ts +++ b/src/client/map/PolygonDrawing/_layers/polygonLayer.ts @@ -40,6 +40,7 @@ export const polygonLayer = ({ // Highlight first point in red if loop is closeable (unclosed & > 2 points) return (_data.index === 0 && !isClosed && polygonCoordinates.length > 2) ? [255, 0, 0] : [255, 255, 255]; }, + stroked: true, getLineColor: [0, 0, 0], lineWidthMinPixels: 1, radiusMinPixels: 5, @@ -47,7 +48,7 @@ export const polygonLayer = ({ autoHighlight: true, highlightColor: [255, 0, 0, 255], updateTriggers: { - getFillColor: [isClosed, polygonCoordinates.length] + getFillColor: [isClosed, polygonCoordinates.length, hoveredPointIndex] } }) ); diff --git a/src/client/map/PolygonDrawing/_logic/polygonDrawingTypes.ts b/src/client/map/PolygonDrawing/_logic/polygonDrawingTypes.ts index a6ff015..6a6763e 100644 --- a/src/client/map/PolygonDrawing/_logic/polygonDrawingTypes.ts +++ b/src/client/map/PolygonDrawing/_logic/polygonDrawingTypes.ts @@ -2,15 +2,15 @@ export type PolygonCoordinate = [number, number]; export type PolygonCoordinates = PolygonCoordinate[]; export interface PolygonDragInfo { - object: unknown; + object: any; coordinate: PolygonCoordinate; index: number; } export interface PolygonClickInfo { coordinate: PolygonCoordinate; - object?: unknown; - layer?: unknown; + object?: any; + layer?: any; index?: number; } From 26a7c3d832afd7b890a7b146d71f1a18b34fbdc2 Mon Sep 17 00:00:00 2001 From: "lukas.dufek" Date: Thu, 12 Mar 2026 14:32:12 +0100 Subject: [PATCH 4/8] drawing circle --- .../map/PolygonDrawing/ControlButtons.tsx | 25 +++- .../map/PolygonDrawing/PolygonDrawing.tsx | 15 +- .../PolygonDrawing/_layers/polygonLayer.ts | 140 +++++++++++++----- .../PolygonDrawing/_logic/onPolygonClick.ts | 18 ++- .../PolygonDrawing/_logic/onPolygonDrag.ts | 30 +++- .../_logic/polygonDrawingTypes.ts | 2 +- 6 files changed, 176 insertions(+), 54 deletions(-) diff --git a/src/client/map/PolygonDrawing/ControlButtons.tsx b/src/client/map/PolygonDrawing/ControlButtons.tsx index a515e0f..f736562 100644 --- a/src/client/map/PolygonDrawing/ControlButtons.tsx +++ b/src/client/map/PolygonDrawing/ControlButtons.tsx @@ -1,23 +1,41 @@ import React from 'react'; import { Button, Stack } from '@mantine/core'; -import { IconPencil, IconTrash } from '@tabler/icons-react'; +import { DrawingMode } from './_logic/polygonDrawingTypes'; +import { IconPencil, IconTrash, IconCircle, IconPolygon } from '@tabler/icons-react'; interface ControlButtonsProps { isClosed: boolean; onClear: () => void; onToggleActive: () => void; isActive: boolean; + mode: DrawingMode; + setMode: (mode: DrawingMode) => void; } -export const ControlButtons: React.FC = ({isClosed, onClear, onToggleActive, isActive}) => { +export const ControlButtons: React.FC = ({isClosed, onClear, onToggleActive, isActive, mode, setMode}) => { return ( + {!isActive && !isClosed && ( + + + + + )} + - - - )} - - - - - ); -}; diff --git a/src/client/map/PolygonDrawing/PolygonDrawing.tsx b/src/client/map/PolygonDrawing/PolygonDrawing.tsx index 8258a43..70be05b 100644 --- a/src/client/map/PolygonDrawing/PolygonDrawing.tsx +++ b/src/client/map/PolygonDrawing/PolygonDrawing.tsx @@ -1,5 +1,4 @@ -import React, { useState, cloneElement, Children, ReactElement } from 'react'; -import { ControlButtons } from './ControlButtons'; +import React, { useState, cloneElement, Children, ReactElement, ReactNode } from 'react'; import { polygonLayer } from './_layers/polygonLayer'; import { onPolygonClick } from './_logic/onPolygonClick'; import { onPolygonDrag } from './_logic/onPolygonDrag'; @@ -9,152 +8,109 @@ import { PolygonClickInfo, PolygonDragInfo, DrawingMode } from './_logic/polygon interface PolygonDrawingProps { /** The map component to wrap */ children: ReactElement; - /** Callback when the polygon coordinates change */ - onPolygonChange?: (polygon: [number, number][]) => void; + /** Current drawing mode – controlled from outside */ + mode: DrawingMode; + /** Current polygon/circle coordinates – controlled from outside */ + polygonCoordinates: [number, number][]; + /** Whether the polygon loop is closed – controlled from outside */ + isClosed: boolean; + /** Whether drawing/editing is active – controlled from outside */ + isActive: boolean; + /** Called when coordinates change (vertex added, moved) */ + onPolygonChange: (coords: [number, number][]) => void; + /** Called when closed state changes */ + onIsClosedChange: (closed: boolean) => void; + /** Slot for app-level control buttons rendered inside the relative wrapper */ + controlsSlot?: ReactNode; } /** - * Component that allows drawing and editing a polygon on a map. - * Wraps a map component (like RenderingMap) and injects deck.gl layers and event handlers. + * Controlled component that allows drawing and editing a polygon/circle on a map. + * Wraps a SingleMap and injects deck.gl layers + event handlers via cloneElement. + * + * mode / polygonCoordinates / isClosed / isActive are owned by the caller. + * Only low-level interaction state (hover/drag) is managed internally. */ -export const PolygonDrawing: React.FC = ({children, onPolygonChange}) => { - // State for the drawing mode - const [mode, setMode] = useState('polygon'); - // State for the polygon vertices [longitude, latitude] - const [polygonCoordinates, setPolygonCoordinates] = useState<[number, number][]>([]); - // State to track if the polygon loop is closed - const [isClosed, setIsClosed] = useState(false); - // State to control if drawing/editing is enabled - const [isActive, setIsActive] = useState(false); - // State to track if the cursor is hovering over a vertex (for styling and drag initiation) +export const PolygonDrawing: React.FC = ({ + children, + mode, + polygonCoordinates, + isClosed, + isActive, + onPolygonChange, + onIsClosedChange, + controlsSlot, +}) => { + // Internal interaction state – not needed outside this component const [isHoveringPoint, setIsHoveringPoint] = useState(false); - // State to store the index of the vertex being hovered const [hoveredPointIndex, setHoveredPointIndex] = useState(null); - // State to track if a vertex is currently being dragged const [isDragging, setIsDragging] = useState(false); - /** - * Updates the polygon coordinates and triggers the external callback. - */ - const handlePolygonUpdate = (coords: [number, number][]) => { - setPolygonCoordinates(coords); - if (onPolygonChange) { - onPolygonChange(coords); - } - }; - - const handleIsClosedUpdate = (closed: boolean) => { - setIsClosed(closed); - // Logic for what happens when polygon closes can be extended here - }; - - /** - * Resets the drawing state to start over. - */ - const handleClear = () => { - setPolygonCoordinates([]); - setIsClosed(false); - setIsActive(true); // Automatically switch to drawing mode - setIsHoveringPoint(false); - setHoveredPointIndex(null); - if (onPolygonChange) onPolygonChange([]); - }; - - const handleToggleActive = () => { - setIsActive(!isActive); - }; - // Calculate the deck.gl layers to render based on current state - const layers = polygonLayer({ - polygonCoordinates, - isClosed, - isActive, - hoveredPointIndex, - mode - }); + const layers = polygonLayer({ polygonCoordinates, isClosed, isActive, hoveredPointIndex, mode }); - // Clone the child map component to inject necessary props for interaction + // Clone the child map component (SingleMap) and inject drawing props. + // Uses the "external" prop convention supported by SingleMap. const mappedChildren = Children.map(children, (child) => { if (!React.isValidElement(child)) return child; return cloneElement(child as ReactElement, { - // Inject the generated layers into the map - layer: layers, + // Inject the generated deck.gl drawing layers on top of managed map layers + extraLayers: layers, - // Handle click events on the map - onClick: (info: PolygonClickInfo) => { - // Ignore clicks if drawing/editing is disabled + // Handle click events – add new vertex or close the polygon + onClickExternal: (info: PolygonClickInfo) => { if (!isActive) return; - onPolygonClick({ info, polygonCoordinates, isClosed, - setPolygonCoordinates: handlePolygonUpdate, - setIsClosed: handleIsClosedUpdate, - mode + setPolygonCoordinates: onPolygonChange, + setIsClosed: onIsClosedChange, + mode, }); }, - // Handle drag events (moving vertices) - onDrag: (info: PolygonDragInfo) => { + // Handle drag events – move the dragged vertex in real time + onDragExternal: (info: PolygonDragInfo) => { if (!isActive) return; - onPolygonDrag({ - info, - polygonCoordinates, - setPolygonCoordinates: handlePolygonUpdate, - mode - }); + onPolygonDrag({ info, polygonCoordinates, setPolygonCoordinates: onPolygonChange, mode }); }, - // Handle hover events (detecting vertices) - onHover: (info: PolygonClickInfo) => { + // Handle hover events – detect when cursor is over a vertex + onHoverExternal: (info: PolygonClickInfo) => { if (!isActive) return; - onPolygonHover({ - info, - setIsHoveringPoint, - setHoveredPointIndex - }); + onPolygonHover({ info, setIsHoveringPoint, setHoveredPointIndex }); }, - // Handle start of a drag interaction - onStartDragging: () => { - // Only allow dragging if we are hovering over a point - if (isActive && isHoveringPoint) { - setIsDragging(true); - } + // Mark drag as started only when hovering over a vertex + onDragStartExternal: () => { + if (isActive && isHoveringPoint) setIsDragging(true); }, - // Handle end of a drag interaction - onStopDragging: () => { - setIsDragging(false); - }, + // Clear drag state when gesture ends + onDragEndExternal: () => setIsDragging(false), - // dynamic cursor styling based on state - getCursor: ({isDragging}: { isDragging: boolean }) => { - if (isDragging) return 'grabbing'; - if (isHoveringPoint && isActive) return 'pointer'; - if (isActive && !isClosed) return 'crosshair'; + // Dynamic cursor based on drawing / editing state + getCursorExternal: ({ isDragging: _d }: { isDragging: boolean }) => { + if (isDragging) return 'grabbing'; // vertex is being dragged + if (isHoveringPoint && isActive) return 'pointer'; // hovering over a vertex + if (isActive && !isClosed) return 'crosshair'; // drawing mode return 'default'; }, - // disable default map controls (pan/zoom) while drawing an open polygon or dragging a point - disableControls: (isActive && !isClosed) || isDragging + // Disable map pan/zoom while: + // - drawing an open polygon (always locked) + // - hovering over a vertex in edit mode (prevents race condition with DeckGL controller) + // - actively dragging a vertex + controllerDisabled: isActive && (!isClosed || isHoveringPoint || isDragging), }); }); return ( -
+
{mappedChildren} - + {controlsSlot}
); }; - diff --git a/src/client/map/PolygonDrawing/_layers/polygonLayer.ts b/src/client/map/PolygonDrawing/_layers/polygonLayer.ts index 588a0d3..24fd787 100644 --- a/src/client/map/PolygonDrawing/_layers/polygonLayer.ts +++ b/src/client/map/PolygonDrawing/_layers/polygonLayer.ts @@ -9,80 +9,124 @@ interface PolygonLayerProps { mode: DrawingMode; } +/** Haversine great-circle distance in metres – used only for the radius value. */ function getDistance(coord1: [number, number], coord2: [number, number]): number { - const toRad = (degrees: number) => degrees * Math.PI / 180; - const earthRadius = 6371000; // Earth radius in meters - + const toRad = (d: number) => (d * Math.PI) / 180; + const R = 6371000; const dLat = toRad(coord2[1] - coord1[1]); const dLon = toRad(coord2[0] - coord1[0]); const lat1 = toRad(coord1[1]); const lat2 = toRad(coord2[1]); - - const squareHalfChord = Math.sin(dLat / 2) * Math.sin(dLat / 2) + + const a = + Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.sin(dLon / 2) * Math.sin(dLon / 2) * Math.cos(lat1) * Math.cos(lat2); - const centralAngle = 2 * Math.atan2(Math.sqrt(squareHalfChord), Math.sqrt(1 - squareHalfChord)); + return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); +} - return earthRadius * centralAngle; +/** + * Computes a destination point given a start, distance (m) and bearing (rad). + * Uses the same spherical formula as Haversine, so the result is always + * exactly `distance` metres from `origin` by the Haversine metric. + */ +function destinationPoint( + origin: [number, number], + distance: number, + bearing: number +): [number, number] { + const R = 6371000; + const d = distance / R; // angular distance + const lat1 = (origin[1] * Math.PI) / 180; + const lon1 = (origin[0] * Math.PI) / 180; + + const lat2 = Math.asin( + Math.sin(lat1) * Math.cos(d) + Math.cos(lat1) * Math.sin(d) * Math.cos(bearing) + ); + const lon2 = + lon1 + + Math.atan2( + Math.sin(bearing) * Math.sin(d) * Math.cos(lat1), + Math.cos(d) - Math.sin(lat1) * Math.sin(lat2) + ); + + return [(lon2 * 180) / Math.PI, (lat2 * 180) / Math.PI]; } /** - * Generates deck.gl layers for displaying and editing the polygon. - * - * @param props Props for generating layers - * @returns Array of deck.gl layers + * Generates a geodesic circle polygon with `numPoints` vertices. + * Because each vertex is placed using the same spherical formula as getDistance, + * the edge point (coord[1]) will always sit exactly on the polygon boundary + * regardless of zoom level or Mercator distortion. */ -export const polygonLayer = ({ - polygonCoordinates, - isClosed, - isActive, - hoveredPointIndex, - mode, - }: PolygonLayerProps) => { +function generateCirclePolygon( + center: [number, number], + radius: number, + numPoints = 64 +): [number, number][] { + return Array.from({ length: numPoints }, (_, i) => { + const bearing = (2 * Math.PI * i) / numPoints; + return destinationPoint(center, radius, bearing); + }); +} - // Safety check: Don't render if coordinates are missing +/** + * Generates deck.gl layers for displaying and editing the polygon / circle. + */ +export const polygonLayer = ({ + polygonCoordinates, + isClosed, + isActive, + hoveredPointIndex, + mode, +}: PolygonLayerProps) => { if (!polygonCoordinates) return []; const layers: any[] = []; if (mode === 'circle') { - // Draw the circle area + // ── Circle fill ──────────────────────────────────────────────────────── + // Use PolygonLayer with a geodesic polygon instead of ScatterplotLayer. + // ScatterplotLayer renders circles using a flat-earth Mercator approximation; + // for large circles this causes the edge point to appear off the circle at + // extreme zoom levels. The geodesic polygon uses the same spherical maths as + // getDistance, so the edge vertex is guaranteed to sit on the boundary. if (isClosed && polygonCoordinates.length === 2) { const radius = getDistance(polygonCoordinates[0], polygonCoordinates[1]); + const circlePolygon = generateCirclePolygon(polygonCoordinates[0], radius); layers.push( - new ScatterplotLayer({ + new PolygonLayer({ id: 'circle-fill-layer', - data: [{position: polygonCoordinates[0], radius}], - getPosition: (_data: any) => _data.position, - getRadius: (_data: any) => _data.radius, + data: [{ polygon: circlePolygon }], + getPolygon: (_data: any) => _data.polygon, getFillColor: [0, 150, 255, 100], getLineColor: [0, 100, 255], + pickable: true, stroked: true, filled: true, lineWidthMinPixels: 2, - pickable: true, autoHighlight: true, highlightColor: [0, 0, 255, 100], }) ); } - // Draw separate path line for radius if needed - if (polygonCoordinates.length === 2) { - layers.push( - new PathLayer({ - id: 'circle-radius-line', - data: [{path: polygonCoordinates}], - getPath: (_data: any) => _data.path, - getColor: [0, 0, 0, 100], - widthMinPixels: 1, - pickable: false, - dashJustified: true, - getDashArray: [5, 5], - extensions: [] - }) - ); - } - + // ── Radius line ──────────────────────────────────────────────────────── + // Only shown while the user is actively drawing / editing (isActive). + // Hidden when the circle is complete and editing mode is off. + if (isActive && polygonCoordinates.length === 2) { + layers.push( + new PathLayer({ + id: 'circle-radius-line', + data: [{ path: polygonCoordinates }], + getPath: (_data: any) => _data.path, + getColor: [0, 0, 0, 100], + widthMinPixels: 1, + pickable: false, + dashJustified: true, + getDashArray: [5, 5], + extensions: [], + }) + ); + } } else { // Polygon Mode // Layer for closed polygon (PolygonLayer) diff --git a/src/client/shared/models/models.layers.ts b/src/client/shared/models/models.layers.ts index d2104db..74d943e 100644 --- a/src/client/shared/models/models.layers.ts +++ b/src/client/shared/models/models.layers.ts @@ -3,7 +3,19 @@ import { LayerTreeInteraction } from '../layers/models.layers'; import { DatasourceWithNeighbours } from './models.metadata'; /** - * Layer in rendering context, but still undepedent to specific rendering framework + * Polygon drawing state stored on a dedicated RenderingLayer entry. + * Kept as part of RenderingLayer so drawing state lives inside + * the existing AppSharedState.renderingLayers array. + */ +export interface RenderingLayerPolygonDrawing { + mode: 'polygon' | 'circle'; + isActive: boolean; + isClosed: boolean; + polygonCoordinates: [number, number][]; +} + +/** + * Layer in rendering context, but still independent to specific rendering framework */ export interface RenderingLayer { isActive: boolean; @@ -19,4 +31,6 @@ export interface RenderingLayer { route: string; method: 'GET' | 'POST'; }; + /** Polygon drawing state – present only on the dedicated 'polygonDrawing' layer entry */ + polygonDrawing?: RenderingLayerPolygonDrawing; } From 978f9c09f7543218f0474089d029e62447fd9c28 Mon Sep 17 00:00:00 2001 From: "lukas.dufek" Date: Fri, 20 Mar 2026 15:09:08 +0100 Subject: [PATCH 7/8] changes: use mapSet, merging layers, sync use --- src/client/map/MapSet/MapSet.tsx | 12 ++-- .../map/PolygonDrawing/PolygonDrawing.tsx | 72 ++++++++++++++----- .../PolygonDrawing/_logic/onPolygonClick.ts | 18 ++++- .../PolygonDrawing/_logic/onPolygonHover.ts | 2 +- .../map/components/layers/LayerManager.tsx | 4 ++ .../layers/PolygonDrawingLayerSource.tsx | 57 +++++++++++++++ src/client/shared/models/models.layers.ts | 2 + 7 files changed, 140 insertions(+), 27 deletions(-) create mode 100644 src/client/map/components/layers/PolygonDrawingLayerSource.tsx diff --git a/src/client/map/MapSet/MapSet.tsx b/src/client/map/MapSet/MapSet.tsx index db35544..408887e 100644 --- a/src/client/map/MapSet/MapSet.tsx +++ b/src/client/map/MapSet/MapSet.tsx @@ -1,7 +1,7 @@ import classnames from 'classnames'; import React from 'react'; import { ReactCompareSlider, ReactCompareSliderHandle } from 'react-compare-slider'; -import { SingleMap } from './SingleMap'; +import { SingleMap, BasicMapProps } from './SingleMap'; import { useSharedState } from '../../shared/hooks/state.useSharedState'; import { getMapSetByKey } from '../../shared/appState/selectors/getMapSetByKey'; import { getSyncedView } from '../../shared/appState/selectors/getSyncedView'; @@ -33,7 +33,7 @@ export const MapSetWrapper = ({ children }: MapSetWrapperProps) => { * @property {React.ElementType} [MapSetTools] - Optional tools component to render for the entire map set. * @property {React.ElementType | boolean} [CustomTooltip] - Optional custom tooltip component for the maps. */ -export interface MapSetProps { +export interface MapSetProps extends Partial> { sharedStateKey: string; SingleMapTools?: React.ElementType; MapSetTools?: React.ElementType; @@ -46,7 +46,7 @@ export interface MapSetProps { * @param {MapSetProps} props - The props for the MapSet component. * @returns {JSX.Element | null} The rendered MapSet or null if no maps are found. */ -export const MapSet = ({ sharedStateKey, SingleMapTools, MapSetTools, CustomTooltip }: MapSetProps) => { +export const MapSet = ({ sharedStateKey, SingleMapTools, MapSetTools, CustomTooltip, ...rest }: MapSetProps) => { // Retrieve shared state and map set using the provided key const [sharedState] = useSharedState(); const mapSet = getMapSetByKey(sharedState, sharedStateKey); @@ -80,7 +80,7 @@ export const MapSet = ({ sharedStateKey, SingleMapTools, MapSetTools, CustomTool itemOne={
{/* Render individual map */} - + {/* Optionally render tools for the map */} {SingleMapTools && React.createElement(SingleMapTools, { @@ -93,7 +93,7 @@ export const MapSet = ({ sharedStateKey, SingleMapTools, MapSetTools, CustomTool itemTwo={
{/* Render individual map */} - + {/* Optionally render tools for the map */} {SingleMapTools && React.createElement(SingleMapTools, { @@ -128,7 +128,7 @@ export const MapSet = ({ sharedStateKey, SingleMapTools, MapSetTools, CustomTool {maps.map((mapKey: string) => (
{/* Render individual map */} - + {/* Optionally render tools for the map */} {SingleMapTools && React.createElement(SingleMapTools, { mapKey, mapSetKey: sharedStateKey })}
diff --git a/src/client/map/PolygonDrawing/PolygonDrawing.tsx b/src/client/map/PolygonDrawing/PolygonDrawing.tsx index 70be05b..4ac479f 100644 --- a/src/client/map/PolygonDrawing/PolygonDrawing.tsx +++ b/src/client/map/PolygonDrawing/PolygonDrawing.tsx @@ -22,6 +22,22 @@ interface PolygonDrawingProps { onIsClosedChange: (closed: boolean) => void; /** Slot for app-level control buttons rendered inside the relative wrapper */ controlsSlot?: ReactNode; + + /** + * OPTIONAL: Controlled hover state. + * If provided, component uses this instead of local state for hover. + * Needed when rendering is handled externally (e.g. via Redux & LayerManager) + * and we need to synchronize hover state with the renderer. + */ + hoveredPointIndex?: number | null; + onHoverChange?: (index: number | null) => void; + + /** + * OPTIONAL: Whether to inject layers into the child map. + * Default: true. + * Set to false if layers are rendered via standard LayerManager (Redux). + */ + injectLayers?: boolean; } /** @@ -40,24 +56,39 @@ export const PolygonDrawing: React.FC = ({ onPolygonChange, onIsClosedChange, controlsSlot, + hoveredPointIndex: propHoveredPointIndex, + onHoverChange, + injectLayers = true, }) => { - // Internal interaction state – not needed outside this component - const [isHoveringPoint, setIsHoveringPoint] = useState(false); - const [hoveredPointIndex, setHoveredPointIndex] = useState(null); + // Internal interaction state – used if props not provided + const [internalHoveredPointIndex, setInternalHoveredPointIndex] = useState(null); const [isDragging, setIsDragging] = useState(false); + // Resolve controlled vs uncontrolled hover state + const isHoverControlled = propHoveredPointIndex !== undefined && onHoverChange !== undefined; + const hoveredPointIndex = isHoverControlled ? propHoveredPointIndex : internalHoveredPointIndex; + const setHoveredPointIndex = isHoverControlled ? onHoverChange : setInternalHoveredPointIndex; + + const isHoveringPoint = hoveredPointIndex !== null; + // Calculate the deck.gl layers to render based on current state - const layers = polygonLayer({ polygonCoordinates, isClosed, isActive, hoveredPointIndex, mode }); + // Only generate if we are injecting them + const layers = injectLayers + ? polygonLayer({ + polygonCoordinates, + isClosed, + isActive, + hoveredPointIndex, + mode, + }) + : []; // Clone the child map component (SingleMap) and inject drawing props. // Uses the "external" prop convention supported by SingleMap. const mappedChildren = Children.map(children, (child) => { if (!React.isValidElement(child)) return child; - return cloneElement(child as ReactElement, { - // Inject the generated deck.gl drawing layers on top of managed map layers - extraLayers: layers, - + const cloneProps: any = { // Handle click events – add new vertex or close the polygon onClickExternal: (info: PolygonClickInfo) => { if (!isActive) return; @@ -80,7 +111,12 @@ export const PolygonDrawing: React.FC = ({ // Handle hover events – detect when cursor is over a vertex onHoverExternal: (info: PolygonClickInfo) => { if (!isActive) return; - onPolygonHover({ info, setIsHoveringPoint, setHoveredPointIndex }); + // setIsHoveringPoint is a no-op because isHoveringPoint is derived from hoveredPointIndex + onPolygonHover({ + info, + setIsHoveringPoint: () => {}, + setHoveredPointIndex, + }); }, // Mark drag as started only when hovering over a vertex @@ -93,18 +129,20 @@ export const PolygonDrawing: React.FC = ({ // Dynamic cursor based on drawing / editing state getCursorExternal: ({ isDragging: _d }: { isDragging: boolean }) => { - if (isDragging) return 'grabbing'; // vertex is being dragged + if (isDragging) return 'grabbing'; // vertex is being dragged if (isHoveringPoint && isActive) return 'pointer'; // hovering over a vertex - if (isActive && !isClosed) return 'crosshair'; // drawing mode + if (isActive && !isClosed) return 'crosshair'; // drawing mode return 'default'; }, + + controllerDisabled: isActive, + }; + + if (injectLayers) { + cloneProps.extraLayers = layers; + } - // Disable map pan/zoom while: - // - drawing an open polygon (always locked) - // - hovering over a vertex in edit mode (prevents race condition with DeckGL controller) - // - actively dragging a vertex - controllerDisabled: isActive && (!isClosed || isHoveringPoint || isDragging), - }); + return cloneElement(child as ReactElement, cloneProps); }); return ( diff --git a/src/client/map/PolygonDrawing/_logic/onPolygonClick.ts b/src/client/map/PolygonDrawing/_logic/onPolygonClick.ts index 15a0853..7879b6f 100644 --- a/src/client/map/PolygonDrawing/_logic/onPolygonClick.ts +++ b/src/client/map/PolygonDrawing/_logic/onPolygonClick.ts @@ -27,12 +27,24 @@ export const onPolygonClick = ({ // Safety check for info if (!info) return; - const { coordinate, index, layer } = info; + const rawInfo = info as PolygonClickInfo & { + sourceLayer?: { id?: string }; + object?: { index?: number }; + }; + + const coordinate = rawInfo.coordinate; + const clickedLayerId = rawInfo.sourceLayer?.id ?? rawInfo.layer?.id ?? ''; + const clickedIndex = + typeof rawInfo.index === 'number' + ? rawInfo.index + : typeof rawInfo.object?.index === 'number' + ? rawInfo.object.index + : undefined; // Check if the user clicked on an existing vertex - if (layer && layer.id && layer.id.includes('vertex-layer')) { + if (clickedLayerId.includes('vertex-layer')) { // If clicking on the first point (index 0) and we have enough points (>=3), close the polygon - if (mode === 'polygon' && typeof index === 'number' && index === 0 && polygonCoordinates.length > 2) { + if (mode === 'polygon' && clickedIndex === 0 && polygonCoordinates.length >= 3) { setIsClosed(true); return; } diff --git a/src/client/map/PolygonDrawing/_logic/onPolygonHover.ts b/src/client/map/PolygonDrawing/_logic/onPolygonHover.ts index 958423a..f0ec92f 100644 --- a/src/client/map/PolygonDrawing/_logic/onPolygonHover.ts +++ b/src/client/map/PolygonDrawing/_logic/onPolygonHover.ts @@ -14,7 +14,7 @@ export const onPolygonHover = ({ info, setIsHoveringPoint, setHoveredPointIndex const { layer, index } = info; // Check if the hovered object belongs to the 'vertex-layer' and has a valid index - if (layer && layer.id === 'vertex-layer' && typeof index === 'number' && index >= 0) { + if (layer && layer.id && layer.id.includes('vertex-layer') && typeof index === 'number' && index >= 0) { setIsHoveringPoint(true); setHoveredPointIndex(index); } else { diff --git a/src/client/map/components/layers/LayerManager.tsx b/src/client/map/components/layers/LayerManager.tsx index fd76e0d..a212f42 100644 --- a/src/client/map/components/layers/LayerManager.tsx +++ b/src/client/map/components/layers/LayerManager.tsx @@ -8,6 +8,7 @@ import { COGLayerSource } from './COGLayerSource'; import { GeojsonLayerSource } from './GeojsonLayerSource'; import { WMSLayerSource } from './WMSLayerSource'; import { IconLayerSource } from './IconLayerSource'; +import { PolygonDrawingLayerSource } from './PolygonDrawingLayerSource'; /** * Represents the possible types of layer instances that can be managed. @@ -71,6 +72,9 @@ export const LayerManager = ({ layers, onLayerUpdate, viewport, CustomTooltip }: return ; } else if (labels.includes(UsedDatasourceLabels.WMS)) { return ; + } else if (labels.includes('polygonDrawing') || layer.polygonDrawing) { + // Custom check for polygon drawing layer + return ; } else if (labels.includes(UsedDatasourceLabels.Geojson)) { // Determine the specific layer type for GeoJSON data switch (layer.layerType) { diff --git a/src/client/map/components/layers/PolygonDrawingLayerSource.tsx b/src/client/map/components/layers/PolygonDrawingLayerSource.tsx new file mode 100644 index 0000000..29977a8 --- /dev/null +++ b/src/client/map/components/layers/PolygonDrawingLayerSource.tsx @@ -0,0 +1,57 @@ +import { useEffect } from 'react'; +import { CompositeLayer } from '@deck.gl/core'; +import { LayerSourceProps } from './LayerManager'; +import { polygonLayer } from '../../PolygonDrawing/_layers/polygonLayer'; +import { RenderingLayerPolygonDrawing } from '../../../shared/models/models.layers'; + + /** + * A deck.gl CompositeLayer that delegates rendering to {@link polygonLayer}. + * Acts as a bridge so that the polygon/circle drawing sub-layers + * (vertices, edges, fill, radius line, …) can be managed as a single + * layer instance inside the standard LayerManager pipeline. + */ +class DrawCompositeLayer extends CompositeLayer { + renderLayers() { + return polygonLayer(this.props as any); + } +} + +/** + * Layer source for polygon / circle drawing. + * + * Reads `polygonDrawing` state from the rendering layer definition, + * wraps it in a {@link DrawCompositeLayer} and registers the resulting + * deck.gl layer instance via `onLayerUpdate` so that LayerManager can + * include it in the standard layer stack. + * + * Renders no DOM – returns `null`. + * + * @param {LayerSourceProps} props – Standard layer-source props provided by LayerManager. + */ +export const PolygonDrawingLayerSource = ({ + layer, + onLayerUpdate, +}: LayerSourceProps) => { + const drawingState: RenderingLayerPolygonDrawing | undefined = + layer.polygonDrawing; + + useEffect(() => { + if (drawingState) { + const compositeLayer = new DrawCompositeLayer({ + id: layer.key, + ...drawingState, + // Ensure updates are triggered when properties change + updateTriggers: { + // We can trigger update on specific props, or just rely on new instance creation + // containing new props. + ...drawingState, + }, + }); + onLayerUpdate(layer.key, compositeLayer); + } else { + onLayerUpdate(layer.key, null); + } + }, [layer.key, drawingState, onLayerUpdate]); + + return null; +}; diff --git a/src/client/shared/models/models.layers.ts b/src/client/shared/models/models.layers.ts index 74d943e..4b382c8 100644 --- a/src/client/shared/models/models.layers.ts +++ b/src/client/shared/models/models.layers.ts @@ -12,6 +12,8 @@ export interface RenderingLayerPolygonDrawing { isActive: boolean; isClosed: boolean; polygonCoordinates: [number, number][]; + /** Index of the vertex currently being hovered, or null if none */ + hoveredPointIndex?: number | null; } /** From 61fe1b0e781ebc97b43d6c8cfe9de497722fab18 Mon Sep 17 00:00:00 2001 From: "lukas.dufek" Date: Fri, 20 Mar 2026 16:34:49 +0100 Subject: [PATCH 8/8] copilots improvements --- src/client/map/MapSet/SingleMap.tsx | 9 ++-- .../map/PolygonDrawing/PolygonDrawing.tsx | 3 +- .../PolygonDrawing/_layers/polygonLayer.ts | 9 ++-- .../PolygonDrawing/_logic/onPolygonClick.ts | 17 +++----- .../_logic/polygonDrawingTypes.ts | 42 ++++++++++++------- 5 files changed, 42 insertions(+), 38 deletions(-) diff --git a/src/client/map/MapSet/SingleMap.tsx b/src/client/map/MapSet/SingleMap.tsx index d1c5ada..adfb6f3 100644 --- a/src/client/map/MapSet/SingleMap.tsx +++ b/src/client/map/MapSet/SingleMap.tsx @@ -29,8 +29,9 @@ export interface BasicMapProps { // --- Props for external layer injection and event override (used by PolygonDrawing) --- /** Extra deck.gl layer instances rendered on top of managed layers */ extraLayers?: LayerInstance[]; - /** Called BEFORE internal click/selection logic – lets drawing tools handle clicks first */ - onClickExternal?: (event: PickingInfo) => void; + /** Called BEFORE internal click/selection logic – lets drawing tools handle clicks first. + * Return `true` to signal the event was handled and skip internal selection logic. */ + onClickExternal?: (event: PickingInfo) => boolean | void; /** Called on every drag event – used to move drawing vertices */ onDragExternal?: (event: PickingInfo) => void; /** Called on every hover event – used to detect vertex hover */ @@ -233,8 +234,8 @@ export const SingleMap = ({ height="100%" onViewStateChange={onViewStateChange} onClick={(event) => { - onClickExternal?.(event); // drawing/external handler has priority - onClick(event); // internal selection logic + const handled = onClickExternal?.(event); // drawing/external handler has priority + if (!handled) onClick(event); // internal selection logic }} onHover={(event) => { onHover(event); // internal hover / cursor logic diff --git a/src/client/map/PolygonDrawing/PolygonDrawing.tsx b/src/client/map/PolygonDrawing/PolygonDrawing.tsx index 4ac479f..38bc62b 100644 --- a/src/client/map/PolygonDrawing/PolygonDrawing.tsx +++ b/src/client/map/PolygonDrawing/PolygonDrawing.tsx @@ -90,7 +90,7 @@ export const PolygonDrawing: React.FC = ({ const cloneProps: any = { // Handle click events – add new vertex or close the polygon - onClickExternal: (info: PolygonClickInfo) => { + onClickExternal: (info: PolygonClickInfo): boolean | void => { if (!isActive) return; onPolygonClick({ info, @@ -100,6 +100,7 @@ export const PolygonDrawing: React.FC = ({ setIsClosed: onIsClosedChange, mode, }); + return true; // Signal that the click was handled – skip internal selection }, // Handle drag events – move the dragged vertex in real time diff --git a/src/client/map/PolygonDrawing/_layers/polygonLayer.ts b/src/client/map/PolygonDrawing/_layers/polygonLayer.ts index 24fd787..9c9d425 100644 --- a/src/client/map/PolygonDrawing/_layers/polygonLayer.ts +++ b/src/client/map/PolygonDrawing/_layers/polygonLayer.ts @@ -99,11 +99,11 @@ export const polygonLayer = ({ getPolygon: (_data: any) => _data.polygon, getFillColor: [0, 150, 255, 100], getLineColor: [0, 100, 255], - pickable: true, + pickable: isActive, stroked: true, filled: true, lineWidthMinPixels: 2, - autoHighlight: true, + autoHighlight: isActive, highlightColor: [0, 0, 255, 100], }) ); @@ -121,9 +121,6 @@ export const polygonLayer = ({ getColor: [0, 0, 0, 100], widthMinPixels: 1, pickable: false, - dashJustified: true, - getDashArray: [5, 5], - extensions: [], }) ); } @@ -139,7 +136,7 @@ export const polygonLayer = ({ getPolygon: (_data: any) => _data.polygon, getFillColor: [0, 150, 255, 100], getLineColor: [0, 100, 255], - pickable: true, + pickable: isActive, stroked: true, filled: true, lineWidthMinPixels: 2, diff --git a/src/client/map/PolygonDrawing/_logic/onPolygonClick.ts b/src/client/map/PolygonDrawing/_logic/onPolygonClick.ts index 7879b6f..167a225 100644 --- a/src/client/map/PolygonDrawing/_logic/onPolygonClick.ts +++ b/src/client/map/PolygonDrawing/_logic/onPolygonClick.ts @@ -27,18 +27,13 @@ export const onPolygonClick = ({ // Safety check for info if (!info) return; - const rawInfo = info as PolygonClickInfo & { - sourceLayer?: { id?: string }; - object?: { index?: number }; - }; - - const coordinate = rawInfo.coordinate; - const clickedLayerId = rawInfo.sourceLayer?.id ?? rawInfo.layer?.id ?? ''; + const coordinate = info.coordinate; + const clickedLayerId = info.sourceLayer?.id ?? info.layer?.id ?? ''; const clickedIndex = - typeof rawInfo.index === 'number' - ? rawInfo.index - : typeof rawInfo.object?.index === 'number' - ? rawInfo.object.index + typeof info.index === 'number' + ? info.index + : typeof info.object?.index === 'number' + ? info.object.index : undefined; // Check if the user clicked on an existing vertex diff --git a/src/client/map/PolygonDrawing/_logic/polygonDrawingTypes.ts b/src/client/map/PolygonDrawing/_logic/polygonDrawingTypes.ts index 01c1fbe..5af8ab1 100644 --- a/src/client/map/PolygonDrawing/_logic/polygonDrawingTypes.ts +++ b/src/client/map/PolygonDrawing/_logic/polygonDrawingTypes.ts @@ -1,26 +1,36 @@ +import type { PickingInfo } from '@deck.gl/core'; + +/** A single [longitude, latitude] coordinate pair. */ export type PolygonCoordinate = [number, number]; + +/** Ordered list of polygon / circle coordinates. */ export type PolygonCoordinates = PolygonCoordinate[]; -export type DrawingMode = 'polygon' | 'circle'; -export interface PolygonDragInfo { - object: any; - coordinate: PolygonCoordinate; - index: number; -} +/** Supported drawing modes. */ +export type DrawingMode = 'polygon' | 'circle'; +/** + * Picking info for polygon click and hover events. + * + * Uses indexed access types from Deck.gl {@link PickingInfo} to stay + * type-safe without duplicating Deck.gl's own definitions. + */ export interface PolygonClickInfo { - coordinate: PolygonCoordinate; - object?: any; - layer?: any; - index?: number; + coordinate: PickingInfo['coordinate']; + object?: PickingInfo['object']; + layer?: PickingInfo['layer']; + index?: PickingInfo['index']; + /** Present in composite-layer picks – the actual sub-layer that matched. */ + sourceLayer?: PickingInfo['sourceLayer']; } -export interface PolygonDragStartInfo { - index: number; +/** + * Picking info for vertex drag events. + * + * Only the fields required by {@link onPolygonDrag} are included. + */ +export interface PolygonDragInfo { coordinate: PolygonCoordinate; + index: number; } -export interface PolygonGetCursorInfo { - isHovering: boolean; - isDragging: boolean; -}