Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions src/client/map/MapSet/MapSet.tsx
Original file line number Diff line number Diff line change
@@ -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, BasicMapProps } from './SingleMap';
import { useSharedState } from '../../shared/hooks/state.useSharedState';
import { getMapSetByKey } from '../../shared/appState/selectors/getMapSetByKey';
import { getSyncedView } from '../../shared/appState/selectors/getSyncedView';
Expand Down Expand Up @@ -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<Omit<BasicMapProps, 'mapKey' | 'syncedView'>> {
sharedStateKey: string;
SingleMapTools?: React.ElementType;
MapSetTools?: React.ElementType;
Expand All @@ -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);
Expand Down Expand Up @@ -80,7 +80,7 @@ export const MapSet = ({ sharedStateKey, SingleMapTools, MapSetTools, CustomTool
itemOne={
<div key={maps[0]} className="ptr-MapSet-map">
{/* Render individual map */}
<SingleMap mapKey={maps[0]} syncedView={syncedView} CustomTooltip={CustomTooltip} />
<SingleMap mapKey={maps[0]} syncedView={syncedView} CustomTooltip={CustomTooltip} {...rest} />
{/* Optionally render tools for the map */}
{SingleMapTools &&
React.createElement(SingleMapTools, {
Expand All @@ -93,7 +93,7 @@ export const MapSet = ({ sharedStateKey, SingleMapTools, MapSetTools, CustomTool
itemTwo={
<div key={maps[1]} className="ptr-MapSet-map">
{/* Render individual map */}
<SingleMap mapKey={maps[1]} syncedView={syncedView} CustomTooltip={CustomTooltip} />
<SingleMap mapKey={maps[1]} syncedView={syncedView} CustomTooltip={CustomTooltip} {...rest} />
{/* Optionally render tools for the map */}
{SingleMapTools &&
React.createElement(SingleMapTools, {
Expand Down Expand Up @@ -128,7 +128,7 @@ export const MapSet = ({ sharedStateKey, SingleMapTools, MapSetTools, CustomTool
{maps.map((mapKey: string) => (
<div key={mapKey} className="ptr-MapSet-map">
{/* Render individual map */}
<SingleMap mapKey={mapKey} syncedView={syncedView} CustomTooltip={CustomTooltip} />
<SingleMap mapKey={mapKey} syncedView={syncedView} CustomTooltip={CustomTooltip} {...rest} />
{/* Optionally render tools for the map */}
{SingleMapTools && React.createElement(SingleMapTools, { mapKey, mapSetKey: sharedStateKey })}
</div>
Expand Down
61 changes: 55 additions & 6 deletions src/client/map/MapSet/SingleMap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,25 @@ export interface BasicMapProps {
syncedView: Partial<MapView>;
/** Custom tooltip component */
CustomTooltip?: React.ElementType | boolean;

// --- 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.
* 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 */
onHoverExternal?: (event: PickingInfo) => void;
/** Called when a drag gesture starts */
onDragStartExternal?: (event: PickingInfo) => void;
/** Called when a drag gesture ends */
onDragEndExternal?: () => void;
/** When provided, overrides the internal getCursor logic entirely */
getCursorExternal?: (info: { isDragging: boolean }) => string;
/** When true, the DeckGL controller (pan / zoom) is disabled */
controllerDisabled?: boolean;
}

type LayerRegistry = Record<string, LayerInstance>;
Expand All @@ -37,7 +56,19 @@ type LayerRegistry = Record<string, LayerInstance>;
* @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,
extraLayers,
onClickExternal,
onDragExternal,
onHoverExternal,
onDragStartExternal,
onDragEndExternal,
getCursorExternal,
controllerDisabled = false,
}: BasicMapProps) => {
const [sharedState, sharedStateDispatch] = useSharedState();
const [controlIsDown, setControlIsDown] = useState(false);
const [layerIsHovered, setLayerIsHovered] = useState(false);
Expand Down Expand Up @@ -197,14 +228,32 @@ export const SingleMap = ({ mapKey, syncedView, CustomTooltip = false }: BasicMa
/>
<DeckGL
viewState={mapViewState}
layers={activeLayers}
controller={true}
layers={[...activeLayers, ...(extraLayers?.filter(Boolean) ?? [])]}
controller={!controllerDisabled}
width="100%"
height="100%"
onViewStateChange={onViewStateChange}
onClick={onClick}
onHover={onHover}
getCursor={({ isDragging }) => (isDragging ? 'grabbing' : layerIsHovered ? 'pointer' : 'grab')}
onClick={(event) => {
const handled = onClickExternal?.(event); // drawing/external handler has priority
if (!handled) onClick(event); // internal selection logic
}}
onHover={(event) => {
onHover(event); // internal hover / cursor logic
onHoverExternal?.(event); // vertex detection for PolygonDrawing
}}
onDrag={(event) => {
onDragExternal?.(event);
}}
onDragStart={(event) => {
onDragStartExternal?.(event);
}}
onDragEnd={() => {
onDragEndExternal?.();
}}
getCursor={({ isDragging }) => {
if (getCursorExternal) return getCursorExternal({ isDragging });
return isDragging ? 'grabbing' : layerIsHovered ? 'pointer' : 'grab';
}}
/**
* Default DeckGL tooltip:
* - Disabled when a CustomTooltip component is provided (layer sources
Expand Down
20 changes: 16 additions & 4 deletions src/client/map/MapSet/handleMapClick.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,11 +58,23 @@ export function handleMapClick({
? mapLayers.find((layer: RenderingLayer) => layer.key === layerId)
: undefined;

// If the clicked layer is not a managed layer (e.g. drawing layers injected via extraLayers),
// 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) {
Expand All @@ -71,7 +83,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;
Expand Down
155 changes: 155 additions & 0 deletions src/client/map/PolygonDrawing/PolygonDrawing.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import React, { useState, cloneElement, Children, ReactElement, ReactNode } from 'react';
import { polygonLayer } from './_layers/polygonLayer';
import { onPolygonClick } from './_logic/onPolygonClick';
import { onPolygonDrag } from './_logic/onPolygonDrag';
import { onPolygonHover } from './_logic/onPolygonHover';
import { PolygonClickInfo, PolygonDragInfo, DrawingMode } from './_logic/polygonDrawingTypes';

interface PolygonDrawingProps {
/** The map component to wrap */
children: ReactElement;
/** 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;

/**
* 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;
}

/**
* 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<PolygonDrawingProps> = ({
children,
mode,
polygonCoordinates,
isClosed,
isActive,
onPolygonChange,
onIsClosedChange,
controlsSlot,
hoveredPointIndex: propHoveredPointIndex,
onHoverChange,
injectLayers = true,
}) => {
// Internal interaction state – used if props not provided
const [internalHoveredPointIndex, setInternalHoveredPointIndex] = useState<number | null>(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
// 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;

const cloneProps: any = {
// Handle click events – add new vertex or close the polygon
onClickExternal: (info: PolygonClickInfo): boolean | void => {
if (!isActive) return;
onPolygonClick({
info,
polygonCoordinates,
isClosed,
setPolygonCoordinates: onPolygonChange,
setIsClosed: onIsClosedChange,
mode,
});
return true; // Signal that the click was handled – skip internal selection
},

// Handle drag events – move the dragged vertex in real time
onDragExternal: (info: PolygonDragInfo) => {
if (!isActive) return;
onPolygonDrag({ info, polygonCoordinates, setPolygonCoordinates: onPolygonChange, mode });
},

// Handle hover events – detect when cursor is over a vertex
onHoverExternal: (info: PolygonClickInfo) => {
if (!isActive) return;
// 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
onDragStartExternal: () => {
if (isActive && isHoveringPoint) setIsDragging(true);
},

// Clear drag state when gesture ends
onDragEndExternal: () => setIsDragging(false),

// 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';
},

controllerDisabled: isActive,
};

if (injectLayers) {
cloneProps.extraLayers = layers;
}

return cloneElement(child as ReactElement<any>, cloneProps);
});

return (
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
{mappedChildren}
{controlsSlot}
</div>
);
};
Loading
Loading