diff --git a/src/client/map/MapSet/MapSet.tsx b/src/client/map/MapSet/MapSet.tsx index 1c949fc..7ef04cd 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 '../MapSet/SingleMap'; +import { SingleMap } from './SingleMap'; import { useSharedState } from '../../shared/hooks/state.useSharedState'; import { getMapSetByKey } from '../../shared/appState/selectors/getMapSetByKey'; import { getSyncedView } from '../../shared/appState/selectors/getSyncedView'; @@ -21,6 +21,7 @@ export interface MapSetWrapperProps { * @param {MapSetWrapperProps} props - The props for the wrapper component. * @returns {JSX.Element} The wrapped children inside a div with the "ptr-MapSet" class. */ + export const MapSetWrapper = ({ children }: MapSetWrapperProps) => { return
{children}
; }; @@ -29,8 +30,10 @@ export const MapSetWrapper = ({ children }: MapSetWrapperProps) => { * Props for the MapSet component. * @interface MapSetProps * @property {string} sharedStateKey - The key used to retrieve the map set from the shared state. - * @property {React.ElementType} [SingleMapTools] - Optional tools component to render alongside each map. - * @property {React.ElementType} [MapSetTools] - Optional tools component to render for the entire map set. + * @property {React.ElementType} [SingleMapTools] - Optional tools component rendered alongside each map. + * Receives `{ mapKey, mapSetKey }` props (and `isSliderModeLeftMap` / `isSliderModeRightMap` in slider mode). + * @property {React.ElementType} [MapSetTools] - Optional tools component rendered once for the entire set. + * Receives `{ mapSetKey }` props. * @property {React.ElementType | boolean} [CustomTooltip] - Optional custom tooltip component for the maps. */ export interface MapSetProps { @@ -43,11 +46,14 @@ export interface MapSetProps { /** * MapSet component renders a set of maps in either a grid or slider mode based on the shared state. * Handles synced zoom and center for maps and provides warnings for invalid configurations. + * + * Drawing, event interception, and any other map-level concerns are driven entirely by + * shared state – no extra props are needed here. + * * @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) => { - // Retrieve shared state and map set using the provided key const [sharedState] = useSharedState(); const mapSet = getMapSetByKey(sharedState, sharedStateKey); const maps = mapSet?.maps; @@ -75,12 +81,12 @@ export const MapSet = ({ sharedStateKey, SingleMapTools, MapSetTools, CustomTool } + handle={} className="ptr-MapSetSliderComparator" itemOne={
{/* Render individual map */} - + {/* Optionally render tools for the map */} {SingleMapTools && React.createElement(SingleMapTools, { @@ -93,7 +99,7 @@ export const MapSet = ({ sharedStateKey, SingleMapTools, MapSetTools, CustomTool itemTwo={
{/* Render individual map */} - + {/* Optionally render tools for the map */} {SingleMapTools && React.createElement(SingleMapTools, { @@ -103,7 +109,7 @@ export const MapSet = ({ sharedStateKey, SingleMapTools, MapSetTools, CustomTool })}
} - > + /> {MapSetTools && React.createElement(MapSetTools, { mapSetKey: sharedStateKey })} ); @@ -128,7 +134,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/MapSet/SingleMap.tsx b/src/client/map/MapSet/SingleMap.tsx index 6c13536..ac06bd2 100644 --- a/src/client/map/MapSet/SingleMap.tsx +++ b/src/client/map/MapSet/SingleMap.tsx @@ -6,14 +6,17 @@ import { getMapByKey } from '../../shared/appState/selectors/getMapByKey'; import { MapView } from '../../shared/models/models.mapView'; import { StateActionType } from '../../shared/appState/enum.state.actionType'; import { getLayersByMapKey } from '../../shared/appState/selectors/getLayersByMapKey'; -import { ActionMapViewChange } from '../../shared/appState/state.models.actions'; +import { ActionMapViewChange, ActionPolygonDrawingUpdate } from '../../shared/appState/state.models.actions'; import { mergeViews } from '../logic/mapView/mergeViews'; import { getViewChange } from '../logic/mapView/getViewChange'; import { handleMapClick } from './handleMapClick'; import { handleMapHover } from './handleMapHover'; import { getMapTooltip } from './MapTooltip/getMapTooltip'; import { LayerInstance, LayerManager } from '../components/layers/LayerManager'; -import { RenderingLayer } from '../../shared/models/models.layers'; +import { RenderingLayer, RenderingLayerPolygonDrawing } from '../../shared/models/models.layers'; +import { onPolygonClick } from '../PolygonDrawing/_logic/onPolygonClick'; +import { onPolygonDrag } from '../PolygonDrawing/_logic/onPolygonDrag'; +import { onPolygonHover } from '../PolygonDrawing/_logic/onPolygonHover'; const TOOLTIP_VERTICAL_OFFSET_CURSOR_POINTER = -10; const TOOLTIP_VERTICAL_OFFSET_CURSOR_GRABBER = -20; @@ -33,14 +36,22 @@ type LayerRegistry = Record; * SingleMap component intended to be used in MapSet component. * * Renders a DeckGL map instance with selection, hover, and view state sync logic. + * Polygon/circle drawing is handled automatically when a rendering layer with a + * `polygonDrawing` field exists in the map's layer list – no extra props required. * * @param {BasicMapProps} props - The props for the map. * @returns {JSX.Element} DeckGL map component. */ -export const SingleMap = ({ mapKey, syncedView, CustomTooltip = false }: BasicMapProps) => { +export const SingleMap = ({ + mapKey, + syncedView, + CustomTooltip = false, +}: BasicMapProps) => { const [sharedState, sharedStateDispatch] = useSharedState(); const [controlIsDown, setControlIsDown] = useState(false); const [layerIsHovered, setLayerIsHovered] = useState(false); + // Local drag-gesture state used only for cursor styling during vertex drag + const [isDragging, setIsDragging] = useState(false); // Ref + size are used only to compute a DeckGL Viewport instance that is // passed into LayerManager so selection tooltips in layer sources can @@ -72,6 +83,33 @@ export const SingleMap = ({ mapKey, syncedView, CustomTooltip = false }: BasicMa const mapViewState = mergeViews(syncedView, mapState?.view ?? {}); const mapLayers = getLayersByMapKey(sharedState, mapKey) ?? []; + // --------------------------------------------------------------------------- + // Drawing state – read automatically from the dedicated polygonDrawing layer. + // Drawing is active when any layer in this map has polygonDrawing.isActive. + // No extra props on MapSet or SingleMap are required – drawing activates purely + // by the presence of a RenderingLayer with a `polygonDrawing` field in state. + // --------------------------------------------------------------------------- + const drawingLayer: RenderingLayer | undefined = mapLayers.find((l) => l.polygonDrawing); + const drawingState: RenderingLayerPolygonDrawing | undefined = drawingLayer?.polygonDrawing; + const isDrawingActive = drawingState?.isActive ?? false; + /** True when the cursor is currently over a vertex handle */ + const isHoveringPoint = (drawingState?.hoveredPointIndex ?? null) !== null; + + /** + * Dispatches a partial patch to the drawing state of `drawingLayer`. + * No-op if there is no drawing layer in this map. + */ + const updateDrawing = useCallback( + (patch: Partial) => { + if (!drawingLayer) return; + sharedStateDispatch({ + type: StateActionType.POLYGON_DRAWING_UPDATE, + payload: { layerKey: drawingLayer.key, patch }, + } as ActionPolygonDrawingUpdate); + }, + [drawingLayer, sharedStateDispatch] + ); + // Local registry for actual Deck.gl class instances const [layerRegistry, setLayerRegistry] = useState({}); @@ -116,7 +154,7 @@ export const SingleMap = ({ mapKey, syncedView, CustomTooltip = false }: BasicMa }, []); /** - * Handles click events on the map. + * Internal selection click handler (only runs when drawing is NOT active). * * @param {PickingInfo} event - The DeckGL picking event containing information about the clicked object. */ @@ -198,13 +236,75 @@ export const SingleMap = ({ mapKey, syncedView, CustomTooltip = false }: BasicMa (isDragging ? 'grabbing' : layerIsHovered ? 'pointer' : 'grab')} + onClick={(event) => { + if (isDrawingActive && drawingState) { + // Drawing mode: handle vertex placement / polygon closing. + // Return early to skip internal layer-selection logic. + onPolygonClick({ + info: event as any, + polygonCoordinates: drawingState.polygonCoordinates, + isClosed: drawingState.isClosed, + setPolygonCoordinates: (coords) => updateDrawing({ polygonCoordinates: coords }), + setIsClosed: (closed) => updateDrawing({ isClosed: closed }), + mode: drawingState.mode, + }); + return; // skip internal selection + } + onClick(event); + }} + onHover={(event) => { + // Always run internal hover so cursor / tooltip state stays correct + onHover(event); + // Additionally detect vertex hover when drawing is active + if (isDrawingActive && drawingState) { + onPolygonHover({ + info: event as any, + setIsHoveringPoint: () => {}, + setHoveredPointIndex: (index) => { + // Guard: dispatch only when the value actually changes to prevent + // an infinite render loop (onHover fires on every frame over an + // interactive layer and would otherwise dispatch on every render). + if (index !== (drawingState.hoveredPointIndex ?? null)) { + updateDrawing({ hoveredPointIndex: index }); + } + }, + }); + } + }} + onDrag={(event) => { + // Move the dragged vertex in real time + if (isDrawingActive && drawingState) { + onPolygonDrag({ + info: event as any, + polygonCoordinates: drawingState.polygonCoordinates, + setPolygonCoordinates: (coords) => updateDrawing({ polygonCoordinates: coords }), + mode: drawingState.mode, + }); + } + }} + onDragStart={() => { + // Only mark as dragging when a vertex handle is being grabbed + if (isDrawingActive && isHoveringPoint) setIsDragging(true); + }} + onDragEnd={() => setIsDragging(false)} + getCursor={({ isDragging: drag }) => { + if (isDrawingActive) { + if (drag || isDragging) return 'grabbing'; // vertex being dragged + if (isHoveringPoint) return 'pointer'; // hovering over a vertex + if (!drawingState?.isClosed) return 'crosshair'; // drawing mode + return 'default'; + } + // Default map cursor behaviour + return drag ? 'grabbing' : layerIsHovered ? 'pointer' : 'grab'; + }} /** * Default DeckGL tooltip: * - Disabled when a CustomTooltip component is provided (layer sources diff --git a/src/client/map/MapSet/handleMapClick.ts b/src/client/map/MapSet/handleMapClick.ts index a13b155..219ac00 100644 --- a/src/client/map/MapSet/handleMapClick.ts +++ b/src/client/map/MapSet/handleMapClick.ts @@ -58,11 +58,24 @@ export function handleMapClick({ ? mapLayers.find((layer: RenderingLayer) => layer.key === layerId) : undefined; + // If the clicked layer is not a managed layer (e.g. polygon drawing vertex/fill layers + // rendered by PolygonDrawingLayerSource whose IDs like 'vertex-layer' are not in mapLayers), + // skip selection logic entirely to avoid crashes on unknown layer types. + if (!mapLayer) return; + // Get the configuration from the clicked mapLayer's datasource - const config = parseDatasourceConfiguration(mapLayer?.datasource?.configuration); + const config = parseDatasourceConfiguration(mapLayer.datasource?.configuration); - // Get the unique feature identifier for selection logic - const featureId = getFeatureId(pickedFeature, config.geojsonOptions?.featureIdProperty); + // Get the unique feature identifier for selection logic. + // Wrapped in try/catch because getFeatureId throws when the feature has no id field + // (e.g. tile bitmap objects or drawing vertex objects). + let featureId: string | number; + try { + featureId = getFeatureId(pickedFeature, config?.geojsonOptions?.featureIdProperty); + } catch { + console.warn('[handleMapClick] Could not determine feature ID – skipping selection.', pickedFeature); + return; + } // Warn if featureId or layerId is missing if (!featureId || !layerId) { @@ -71,7 +84,7 @@ export function handleMapClick({ } // Safely extract the custom selection style from the configuration object, if available - const customSelectionStyle = config.geojsonOptions?.selectionStyle; + const customSelectionStyle = config?.geojsonOptions?.selectionStyle; // Check if selections are enabled for this layer const selectionsEnabled = !config?.geojsonOptions?.disableSelections; diff --git a/src/client/map/PolygonDrawing/_layers/polygonLayer.ts b/src/client/map/PolygonDrawing/_layers/polygonLayer.ts new file mode 100644 index 0000000..9c9d425 --- /dev/null +++ b/src/client/map/PolygonDrawing/_layers/polygonLayer.ts @@ -0,0 +1,200 @@ +import { PolygonLayer, ScatterplotLayer, PathLayer } from '@deck.gl/layers'; +import { DrawingMode } from '../_logic/polygonDrawingTypes'; + +interface PolygonLayerProps { + polygonCoordinates: [number, number][]; + isClosed: boolean; + isActive: boolean; + hoveredPointIndex: number | null; + 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 = (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 a = + Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.sin(dLon / 2) * Math.sin(dLon / 2) * Math.cos(lat1) * Math.cos(lat2); + return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); +} + +/** + * 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 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. + */ +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); + }); +} + +/** + * 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') { + // ── 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 PolygonLayer({ + id: 'circle-fill-layer', + data: [{ polygon: circlePolygon }], + getPolygon: (_data: any) => _data.polygon, + getFillColor: [0, 150, 255, 100], + getLineColor: [0, 100, 255], + pickable: isActive, + stroked: true, + filled: true, + lineWidthMinPixels: 2, + autoHighlight: isActive, + highlightColor: [0, 0, 255, 100], + }) + ); + } + + // ── 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, + }) + ); + } + } else { + // Polygon Mode + // 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: isActive, + stroked: true, + filled: true, + lineWidthMinPixels: 2, + autoHighlight: true, + highlightColor: [0, 0, 255, 100], + }) + ); + } + // 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 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]; + + if (mode === 'polygon') { + // 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]; + } else { + // Circle: Center (0) and Edge (1). Maybe highlight Center differently? + return [255, 255, 255]; + } + }, + stroked: true, + getLineColor: [0, 0, 0], + lineWidthMinPixels: 1, + radiusMinPixels: 5, + pickable: true, + autoHighlight: true, + highlightColor: [255, 0, 0, 255], + updateTriggers: { + getFillColor: [isClosed, polygonCoordinates.length, hoveredPointIndex, mode] + } + }) + ); + } + + 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..fb79d6e --- /dev/null +++ b/src/client/map/PolygonDrawing/_logic/onPolygonClick.ts @@ -0,0 +1,64 @@ +import { PolygonCoordinates, PolygonClickInfo, DrawingMode } from './polygonDrawingTypes'; + +interface OnClickParams { + info: PolygonClickInfo; + polygonCoordinates: PolygonCoordinates; + isClosed: boolean; + setPolygonCoordinates: (coords: PolygonCoordinates) => void; + setIsClosed: (closed: boolean) => void; + mode: DrawingMode; +} + +/** + * Validates click events and either adds a new vertex or closes the polygon loop. + */ +export const onPolygonClick = ({ + info, + polygonCoordinates, + isClosed, + setPolygonCoordinates, + setIsClosed, + mode, + }: 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 = info.coordinate; + const clickedLayerId = info.sourceLayer?.id ?? info.layer?.id ?? ''; + const clickedIndex = + typeof info.index === 'number' + ? info.index + : typeof info.object?.index === 'number' + ? info.object.index + : undefined; + + // Check if the user clicked on an existing vertex + 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' && clickedIndex === 0 && polygonCoordinates.length >= 3) { + 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) { + if (mode === 'circle') { + const newCoords = [... polygonCoordinates, coordinate as [number, number]]; + setPolygonCoordinates(newCoords); + // Circle is defined by center and one edge point (radius) + if (newCoords.length === 2) { + setIsClosed(true); + } + } else { + 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..2f1e4af --- /dev/null +++ b/src/client/map/PolygonDrawing/_logic/onPolygonDrag.ts @@ -0,0 +1,62 @@ +import { PolygonCoordinates, PolygonDragInfo, DrawingMode } from './polygonDrawingTypes'; + +interface OnDragParams { + info: PolygonDragInfo; + polygonCoordinates: PolygonCoordinates; + setPolygonCoordinates: (coords: PolygonCoordinates) => void; + mode: DrawingMode; +} + +/** + * Handles dragging of polygon vertices. + * Updates the coordinate of the dragged vertex in real-time. + */ +export const onPolygonDrag = ({ + info, + polygonCoordinates, + setPolygonCoordinates, + mode, + }: OnDragParams) => { + // Safety check + if (!info) return; + + const { coordinate, index } = info; + + // Validate drag info shape: index must be a number and coordinate a numeric [x, y] + if ( + !Array.isArray(coordinate) || + coordinate.length < 2 || + typeof coordinate[0] !== 'number' || + typeof coordinate[1] !== 'number' + ) { + return; + } + + // Validate that we are dragging a valid vertex index + if (index < 0 || index >= polygonCoordinates.length) return; + + const newCoords = [... polygonCoordinates]; + + if (mode === 'circle') { + if (index === 0) { + // Dragging Center: Move the whole circle (center + radius point) + const dx = coordinate[0] - polygonCoordinates[0][0]; + const dy = coordinate[1] - polygonCoordinates[0][1]; + + newCoords[0] = [coordinate[0], coordinate[1]]; + if (newCoords[1]) { + newCoords[1] = [newCoords[1][0] + dx, newCoords[1][1] + dy]; + } + } else if (index === 1) { + // Dragging Radius Point: Just move the point to resize radius + newCoords[1] = [coordinate[0], coordinate[1]]; + } + } else { + // Create a copy of coordinates and update the specific vertex position + // This allows real-time visual feedback while dragging + 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..d82f99a --- /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 && layer.id.includes('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..49faf37 --- /dev/null +++ b/src/client/map/PolygonDrawing/_logic/polygonDrawingTypes.ts @@ -0,0 +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[]; + +/** 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: 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']; +} + +/** + * Picking info for vertex drag events. + * + * Only the fields required by {@link onPolygonDrag} are included. + */ +export interface PolygonDragInfo { + coordinate: PolygonCoordinate; + index: number; +} + diff --git a/src/client/map/components/layers/LayerManager.tsx b/src/client/map/components/layers/LayerManager.tsx index fd76e0d..6c1ba0e 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. @@ -57,21 +58,22 @@ export const LayerManager = ({ layers, onLayerUpdate, viewport, CustomTooltip }: // Extract datasource labels from the layer const labels: string[] = layer?.datasource?.labels; + // Log an error if no labels are provided for the layer - if (!labels?.length) { + if (!labels?.length && !layer.polygonDrawing) { // Log it instead of throwing to keep the React tree stable console.error(`Datasource error: Missing labels for layer ${layer.key}`); return null; } // Render the appropriate layer source component based on the datasource labels - if (labels.includes(UsedDatasourceLabels.XYZ)) { - return ; - } else if (labels.includes(UsedDatasourceLabels.COG)) { - return ; - } else if (labels.includes(UsedDatasourceLabels.WMS)) { - return ; - } else if (labels.includes(UsedDatasourceLabels.Geojson)) { + if (labels?.includes(UsedDatasourceLabels.XYZ)) { + return ; + } else if (labels?.includes(UsedDatasourceLabels.COG)) { + return ; + } else if (labels?.includes(UsedDatasourceLabels.WMS)) { + return ; + } else if (labels?.includes(UsedDatasourceLabels.Geojson)) { // Determine the specific layer type for GeoJSON data switch (layer.layerType) { case 'icon': @@ -105,6 +107,9 @@ export const LayerManager = ({ layers, onLayerUpdate, viewport, CustomTooltip }: /> ); } + } else if (layer.polygonDrawing) { + // Custom check for polygon drawing layer + return ; } else { // Log a warning if the datasource type is unknown console.warn(`Datasource Warning - Unknown datasource type for layer ${layer.key}`); 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/appState/enum.state.actionType.ts b/src/client/shared/appState/enum.state.actionType.ts index f5dc028..d579ccf 100644 --- a/src/client/shared/appState/enum.state.actionType.ts +++ b/src/client/shared/appState/enum.state.actionType.ts @@ -79,4 +79,7 @@ export enum StateActionType { /** Action to remove a map set. */ MAP_SET_REMOVE = 'mapSetRemove', + + /** Action to update polygon/circle drawing state on a RenderingLayer. */ + POLYGON_DRAWING_UPDATE = 'polygonDrawingUpdate', } diff --git a/src/client/shared/appState/reducerHandlers/polygonDrawingUpdate.ts b/src/client/shared/appState/reducerHandlers/polygonDrawingUpdate.ts new file mode 100644 index 0000000..0b1a154 --- /dev/null +++ b/src/client/shared/appState/reducerHandlers/polygonDrawingUpdate.ts @@ -0,0 +1,80 @@ +import { AppSharedState } from '../state.models'; +import { RenderingLayer, RenderingLayerPolygonDrawing } from '../../models/models.layers'; +import { ActionPolygonDrawingUpdate } from '../state.models.actions'; + +/** + * Full default drawing state used when a layer has no existing `polygonDrawing` + * value and a patch is dispatched for it. + * + * Ensures that after every update the resulting `polygonDrawing` object always + * satisfies the full `RenderingLayerPolygonDrawing` shape – no required field + * can be accidentally absent due to an incomplete first patch. + */ +const DEFAULT_POLYGON_DRAWING_STATE: RenderingLayerPolygonDrawing = { + mode: 'polygon', + isActive: false, + isClosed: false, + polygonCoordinates: [], + hoveredPointIndex: null, +}; + +/** + * Core reducer handler for POLYGON_DRAWING_UPDATE. + * + * Finds the RenderingLayer identified by `payload.layerKey` and merges + * `payload.patch` into its `polygonDrawing` field. All other layers and + * all other state properties are left untouched. + * + * Registered as a core reducer in `state.reducer.ts` so any application + * using `ptr-fe-core` automatically supports drawing state without having + * to add an app-specific reducer entry. + */ +export const reduceHandlerPolygonDrawingUpdate = ( + state: T, + action: ActionPolygonDrawingUpdate +): T => { + const { layerKey, patch } = action.payload; + + const newRenderingLayers = state.renderingLayers.map((layer: RenderingLayer) => { + if (layer.key !== layerKey) return layer; + + /** + * Fall back to the full default state when `polygonDrawing` is absent. + * Using `{}` here would leave required fields (mode, polygonCoordinates, + * isActive, isClosed) missing after the spread, causing runtime crashes + * in drawing handlers that assume those fields always exist. + */ + const current: RenderingLayerPolygonDrawing = + layer.polygonDrawing ?? DEFAULT_POLYGON_DRAWING_STATE; + + /** + * No-op guard: if every field in the patch already has the same value + * (using Object.is for primitives, reference equality for arrays), + * return the same layer reference so React does not schedule a re-render. + * + * This is the backstop that prevents an infinite loop when onHover fires + * repeatedly with `hoveredPointIndex: null` → `null` → `null` … + * (The primary guard lives in SingleMap onHover; this one catches any + * other caller that may dispatch an unchanged patch.) + * + * Note: for arrays (polygonCoordinates) the caller always creates a new + * array reference when coordinates change, so Object.is correctly returns + * false there and the update proceeds normally. + */ + const isNoOp = Object.keys(patch).every( + (key) => Object.is((current as any)[key], (patch as any)[key]) + ); + if (isNoOp) return layer; + + return { + ...layer, + polygonDrawing: { + ...current, + ...patch + }, + }; + }); + + return { ...state, renderingLayers: newRenderingLayers }; +}; + diff --git a/src/client/shared/appState/state.models.actions.ts b/src/client/shared/appState/state.models.actions.ts index f2d91f3..1f713bf 100644 --- a/src/client/shared/appState/state.models.actions.ts +++ b/src/client/shared/appState/state.models.actions.ts @@ -2,7 +2,7 @@ import { SingleMapModel } from '../models/models.singleMap'; import { MapView } from '../models/models.mapView'; import { MapSetModel } from '../models/models.mapSet'; import { MapSetSync } from '../models/models.mapSetSync'; -import { RenderingLayer } from '../models/models.layers'; +import { RenderingLayer, RenderingLayerPolygonDrawing } from '../models/models.layers'; import { StateActionType } from './enum.state.actionType'; // Import the ActionType enum import { AppSpecificAction } from './state.models.reducer'; import { Selection } from '../models/models.selections'; @@ -266,4 +266,18 @@ export type OneOfStateActions = AppSpecificAction & | ActionMapSetAdd | ActionMapSetRemove | ActionMapAdd + | ActionPolygonDrawingUpdate ); + +/** + * Updates the polygonDrawing field of the RenderingLayer identified by layerKey. + * Only the changed fields need to be provided (partial patch). + */ +export interface ActionPolygonDrawingUpdate extends AppSpecificAction { + type: StateActionType.POLYGON_DRAWING_UPDATE; + payload: { + layerKey: string; + patch: Partial; + }; +} + diff --git a/src/client/shared/appState/state.reducer.ts b/src/client/shared/appState/state.reducer.ts index 4f71b9c..8de94d3 100644 --- a/src/client/shared/appState/state.reducer.ts +++ b/src/client/shared/appState/state.reducer.ts @@ -43,6 +43,8 @@ import { reduceHandlerRemoveFeatureKeyInSelections } from './reducerHandlers/map import { reduceHandlerSetFeatureKeyInSelections } from './reducerHandlers/mapLayerSetFeatureKeyInSelections'; import { reduceHandlerMapLayerInteractivityChange } from './reducerHandlers/mapLayerInteractivityChange'; import { reduceHandlerMapAdd } from './reducerHandlers/mapAdd'; +import { reduceHandlerPolygonDrawingUpdate } from './reducerHandlers/polygonDrawingUpdate'; +import { ActionPolygonDrawingUpdate } from './state.models.actions'; /** * Creates a reducer function for a specific application state that combines core and application-specific reducers. @@ -81,7 +83,7 @@ export const reducerForSpecificApp = reduceHandlerMapSetRemove(currentState, action as ActionMapSetRemove) ); + reducerSwitch.set(StateActionType.POLYGON_DRAWING_UPDATE, () => + reduceHandlerPolygonDrawingUpdate(currentState, action as ActionPolygonDrawingUpdate) + ); // 2. now we need to add the application specific actions and reducers to the switch map diff --git a/src/client/shared/models/models.layers.ts b/src/client/shared/models/models.layers.ts index d2104db..4b382c8 100644 --- a/src/client/shared/models/models.layers.ts +++ b/src/client/shared/models/models.layers.ts @@ -3,7 +3,21 @@ 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][]; + /** Index of the vertex currently being hovered, or null if none */ + hoveredPointIndex?: number | null; +} + +/** + * Layer in rendering context, but still independent to specific rendering framework */ export interface RenderingLayer { isActive: boolean; @@ -19,4 +33,6 @@ export interface RenderingLayer { route: string; method: 'GET' | 'POST'; }; + /** Polygon drawing state – present only on the dedicated 'polygonDrawing' layer entry */ + polygonDrawing?: RenderingLayerPolygonDrawing; }