Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add point selection type to selection package #372

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
5 changes: 5 additions & 0 deletions .changeset/orange-islands-ring.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@open-pioneer/selection": patch
---

added a selection controller which allows point selection of features on the map
7 changes: 7 additions & 0 deletions src/packages/selection/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ This package provides a UI component to perform a selection on given selection s
To add the component to your app, import `Selection` from `@open-pioneer/selection`. The `@open-pioneer/notifier` package is required too.

The mandatory properties are `mapId` and `sources` (layer source to be selected on).

`selectionMethods` is an optional property that can hold a list of selection methods: `point`, `extent`.
It defaults to `extent` if omitted.
If more than one method is provided, buttons to toggle between them are added to the selection window.
The first method in the list is initially selected.

The limit per selection is 10.000 items.

```tsx
Expand All @@ -25,6 +31,7 @@ import { Search, SearchSelectEvent } from "@open-pioneer/search";
<Selection
mapId={MAP_ID}
sources={datasources}
selectionMethods={["extent", "point"]}
onSelectionComplete={(event: SelectionCompleteEvent) => {
// do something
}}
Expand Down
79 changes: 71 additions & 8 deletions src/packages/selection/Selection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
Flex,
FormControl,
FormLabel,
HStack,
Icon,
Tooltip,
VStack,
Expand All @@ -30,9 +31,23 @@ import { useIntl, useService } from "open-pioneer:react-hooks";
import { FC, KeyboardEvent, useEffect, useMemo, useRef, useState } from "react";
import { FiAlertTriangle } from "react-icons/fi";
import { useReactiveSnapshot } from "@open-pioneer/reactivity";
import { DragController } from "./DragController";
import { SelectionController } from "./SelectionController";
import { SelectionResult, SelectionSource, SelectionSourceStatusObject } from "./api";
import { ClickController } from "./selection-controller/ClickController";
import { ToolButton } from "@open-pioneer/map-ui-components";
import { PiSelectionPlusBold } from "react-icons/pi";
import { TbPointerQuestion } from "react-icons/tb";
import { DragController } from "./selection-controller/DragController";
import { Map } from "ol";

/**
* The method how the user interacts with the map to select features.
*/
export type SelectionMethod = "extent" | "point";

export interface ISelectionTypeHandler<T> {
new (map: Map, tooltip: string, disabledMessage: string, onExtentSelected: (geometry: Geometry) => void): T;
};

/**
* Properties supported by the {@link Selection} component.
Expand All @@ -43,6 +58,11 @@ export interface SelectionProps extends CommonComponentProps, MapModelProps {
*/
sources: SelectionSource[];

/**
* Array of selection methods available for spatial selection.
*/
availableSelectionMethods?: SelectionMethod | SelectionMethod[];

/**
* This handler is called whenever the user has successfully selected
* some items.
Expand Down Expand Up @@ -96,17 +116,28 @@ const COMMON_SELECT_PROPS: SelectProps<any, any, any> = {
*/
export const Selection: FC<SelectionProps> = (props) => {
const intl = useIntl();
const { sources, onSelectionComplete, onSelectionSourceChanged } = props;
const { sources, availableSelectionMethods, onSelectionComplete, onSelectionSourceChanged } = props;
const { containerProps } = useCommonComponentProps("selection", props);
const defaultNotAvailableMessage = intl.formatMessage({ id: "sourceNotAvailable" });

const [currentSource, setCurrentSource] = useCurrentSelectionSource(
sources,
onSelectionSourceChanged
);

const currentSourceStatus = useSourceStatus(currentSource, defaultNotAvailableMessage);

const defaultSelectionMethod = "extent";
const [activeSelectionMethod, setActiveSelectionMethod] = useState<SelectionMethod>(defaultSelectionMethod);
useEffect(() => {
let method = availableSelectionMethods ?? defaultSelectionMethod;
method = Array.isArray(method) && method.length > 0 ? method[0]! : method as SelectionMethod;
setActiveSelectionMethod(method);
}, [availableSelectionMethods]);
const showSelectionButtons = useMemo(() => {
return Boolean(availableSelectionMethods && Array.isArray(availableSelectionMethods) && availableSelectionMethods.length > 1);
}, [availableSelectionMethods]);


const mapState = useMapModel(props);
const { onExtentSelected } = useSelectionController(
mapState.map,
Expand All @@ -117,7 +148,8 @@ export const Selection: FC<SelectionProps> = (props) => {
const chakraStyles = useChakraStyles();
const [isOpenSelect, setIsOpenSelect] = useState(false);

useDragSelection(
useInteractiveSelection(
activeSelectionMethod,
mapState.map,
intl,
onExtentSelected,
Expand Down Expand Up @@ -152,6 +184,21 @@ export const Selection: FC<SelectionProps> = (props) => {

return (
<VStack {...containerProps} spacing={2}>
{showSelectionButtons && <FormControl>
<FormLabel>{intl.formatMessage({ id: "selectionMethod" })}</FormLabel>
<HStack gap={2}>
<ToolButton
icon={<PiSelectionPlusBold />}
label={intl.formatMessage({id: "EXTENT"})}
onClick={() => setActiveSelectionMethod("extent")}
isActive={activeSelectionMethod === "extent"}/>
<ToolButton
icon={<TbPointerQuestion />}
label={intl.formatMessage({id: "POINT"})}
onClick={() => setActiveSelectionMethod("point")}
isActive={activeSelectionMethod === "point"}/>
</HStack>
</FormControl>}
<FormControl>
<FormLabel>{intl.formatMessage({ id: "selectSource" })}</FormLabel>
<Select<SelectionOption>
Expand Down Expand Up @@ -377,13 +424,28 @@ function useSourceStatus(
/**
* Hook to manage map controls and tooltip
*/
function useDragSelection(
function useInteractiveSelection(
selectionMethod: SelectionMethod,
map: MapModel | undefined,
intl: PackageIntl,
onExtentSelected: (geometry: Geometry) => void,
isActive: boolean,
hasSelectedSource: boolean
) {

function selectionMethodFactory(
selectionMethod: SelectionMethod,
): ISelectionTypeHandler<DragController | ClickController> {
switch (selectionMethod) {
case "extent":
return DragController;
case "point":
return ClickController;
default:
throw new Error(`Unknown selection kind: ${selectionMethod}`);
}
}

useEffect(() => {
if (!map) {
return;
Expand All @@ -393,9 +455,10 @@ function useDragSelection(
? intl.formatMessage({ id: "disabledTooltip" })
: intl.formatMessage({ id: "noSourceTooltip" });

const dragController = new DragController(
const controlerCls = selectionMethodFactory(selectionMethod);
const dragController = new controlerCls(
map.olMap,
intl.formatMessage({ id: "tooltip" }),
intl.formatMessage({ id: `tooltip.${selectionMethod}` }),
disabledMessage,
onExtentSelected
);
Expand All @@ -404,7 +467,7 @@ function useDragSelection(
return () => {
dragController?.destroy();
};
}, [map, intl, onExtentSelected, isActive, hasSelectedSource]);
}, [map, intl, onExtentSelected, isActive, hasSelectedSource, selectionMethod]);
}

/**
Expand Down
6 changes: 5 additions & 1 deletion src/packages/selection/i18n/de.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,12 @@ messages:
POLYGON: "Polygon"
FREEPOLYGON: "Freies Zeichnen"
CIRCLE: "Kreis"
POINT: "Punkt"
selectionMethod: "Selektionsart"
selectSource: "Quelle auswählen"
tooltip: "Klicken Sie in die Karte, halten Sie die Maustaste gedrückt und ziehen Sie ein Rechteck auf"
tooltip:
extent: "Klicken Sie in die Karte, halten Sie die Maustaste gedrückt und ziehen Sie ein Rechteck auf"
point: "Klicken Sie in die Karte, um einen Punkt auszuwählen"
disabledTooltip: "Die aktuelle Selektionsquelle ist nicht verfügbar."
noSourceTooltip: "Es ist keine Selektionsquelle ausgewählt. Zum Starten bitte Selektionsquelle auswählen."
sourceNotAvailable: "Quelle nicht verfügbar"
Expand Down
6 changes: 5 additions & 1 deletion src/packages/selection/i18n/en.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,12 @@ messages:
POLYGON: "Polygon"
FREEPOLYGON: "Freies Zeichnen"
CIRCLE: "Kreis"
POINT: "Point"
selectionMethod: "Selection type"
selectSource: "Select source"
tooltip: "Click on the map, hold down the mouse button and draw a rectangle"
tooltip:
extent: "Click on the map, hold down the mouse button and draw a rectangle"
point: "Click on the map to select a point"
disabledTooltip: "The current selection source is not available"
noSourceTooltip: "No selection source selected. Please choose a selection source to start."
sourceNotAvailable: "Source not available"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// SPDX-FileCopyrightText: 2023 Open Pioneer project (https://github.com/open-pioneer)
// SPDX-License-Identifier: Apache-2.0
import { afterEach, expect, vi, it } from "vitest";
import OlMap from "ol/Map";
import { ClickController } from "./ClickController";
import { LineString, Polygon } from "ol/geom";

Check warning on line 6 in src/packages/selection/selection-controller/ClickController.test.ts

View workflow job for this annotation

GitHub Actions / Run quality checks

'LineString' is defined but never used

afterEach(() => {
vi.restoreAllMocks();
});

it("expect tooltip to be successfully created after construction", async () => {
const { olMap, tooltipTest } = createController();
const activeTooltip = getTooltipElement(olMap, "selection-tooltip");
expect(activeTooltip).toMatchSnapshot(tooltipTest);
});

it("expect event handler function to call extentHandler with a polygon", async () => {
const { controller, extentHandler } = createController();
controller.olMap.getPixelFromCoordinate = (coordinate) => coordinate;

Check failure on line 20 in src/packages/selection/selection-controller/ClickController.test.ts

View workflow job for this annotation

GitHub Actions / Run quality checks

Property 'olMap' is private and only accessible within class 'ClickController'.
controller.olMap.getCoordinateFromPixel = (pixel) => pixel;

Check failure on line 21 in src/packages/selection/selection-controller/ClickController.test.ts

View workflow job for this annotation

GitHub Actions / Run quality checks

Property 'olMap' is private and only accessible within class 'ClickController'.
const evt = {
coordinate: [0, 0],
};
controller.onClick(evt as any);

Check failure on line 25 in src/packages/selection/selection-controller/ClickController.test.ts

View workflow job for this annotation

GitHub Actions / Run quality checks

Property 'onClick' is private and only accessible within class 'ClickController'.
expect(extentHandler).toBeCalledTimes(1);
expect(extentHandler).toBeCalledWith(expect.any(Polygon));
});


function createController() {
const olMap = new OlMap();
const tooltipTest = "Tooltip wurde gesetzt";
const disabledTooltipText = "Funktion ist deaktiviert";
const extentHandler = vi.fn();
const controller = new ClickController(olMap, tooltipTest, disabledTooltipText, extentHandler);
return { olMap, controller, tooltipTest, extentHandler, disabledTooltipText };
}

function getTooltipElement(olMap: OlMap, className: string): HTMLElement {
const allOverlays = olMap.getOverlays().getArray();
const tooltips = allOverlays.filter((ol) => ol.getElement()?.classList.contains(className));
if (tooltips.length === 0) {
throw Error("did not find any tooltips");
}
const element = tooltips[0]!.getElement();
if (!element) {
throw new Error("tooltip overlay did not have an element");
}
return element;
}
80 changes: 80 additions & 0 deletions src/packages/selection/selection-controller/ClickController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// SPDX-FileCopyrightText: 2023 Open Pioneer project (https://github.com/open-pioneer)
// SPDX-License-Identifier: Apache-2.0
import { MapBrowserEvent } from "ol";
import OlMap from "ol/Map";
import { unByKey } from "ol/Observable";
import Geometry from "ol/geom/Geometry";
import { Polygon } from "ol/geom";
import { activateViewportInteraction, createHelpTooltip, deactivateViewportInteraction, Tooltip } from "./helper";
import { EventsKey } from "ol/events";


export class ClickController {
HenFo marked this conversation as resolved.
Show resolved Hide resolved
private tooltip: Tooltip;
private olMap: OlMap;
private isActive: boolean = true;
private tooltipMessage: string;
private tooltipDisabledMessage: string;

private onClick: (evt: MapBrowserEvent<UIEvent>) => void;
private eventKey: EventsKey;

constructor(
olMap: OlMap,
tooltipMessage: string,
tooltipDisabledMessage: string,
onExtentSelected: (geometry: Geometry) => void
) {
this.tooltip = createHelpTooltip(olMap, tooltipMessage);
this.olMap = olMap;
this.tooltipMessage = tooltipMessage;
this.tooltipDisabledMessage = tooltipDisabledMessage;
this.onClick = this.getEventHandlerFunction(onExtentSelected);
this.eventKey = this.olMap.on("singleclick", this.onClick);
activateViewportInteraction(olMap);
}

/**
* Method for destroying the controller when it is no longer needed
*/
destroy() {
this.tooltip.destroy();
unByKey(this.eventKey);
deactivateViewportInteraction(this.olMap, true);

}

setActive(isActive: boolean) {
if (this.isActive === isActive) return;
if (isActive) {
this.tooltip.setText(this.tooltipMessage);
activateViewportInteraction(this.olMap);
this.isActive = true;
} else {
unByKey(this.eventKey);
this.tooltip.setText(this.tooltipDisabledMessage);
deactivateViewportInteraction(this.olMap);
this.isActive = false;
}
}

private getEventHandlerFunction(onExtentSelected: (geometry: Geometry) => void) {
const pixelTolerance = 5;
const map = this.olMap;
const getExtentFromEvent = (evt: MapBrowserEvent<UIEvent>) => {
const coordinates = evt.coordinate;
const pixel = map.getPixelFromCoordinate(coordinates);
const clickTolerance = [
[pixel[0]! - pixelTolerance, pixel[1]! - pixelTolerance],
[pixel[0]! - pixelTolerance, pixel[1]! + pixelTolerance],
[pixel[0]! + pixelTolerance, pixel[1]! + pixelTolerance],
[pixel[0]! + pixelTolerance, pixel[1]! - pixelTolerance],
[pixel[0]! - pixelTolerance, pixel[1]! - pixelTolerance],
];
const extent = clickTolerance.map((pixel) => map.getCoordinateFromPixel(pixel));
const geometry = new Polygon([extent]);
onExtentSelected(geometry);
};
return getExtentFromEvent;
}
}
Loading
Loading