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
15 changes: 0 additions & 15 deletions .husky/pre-commit

This file was deleted.

69 changes: 61 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,20 @@ 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";

export type SelectionKind = "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 +55,11 @@ export interface SelectionProps extends CommonComponentProps, MapModelProps {
*/
sources: SelectionSource[];

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

/**
* This handler is called whenever the user has successfully selected
* some items.
Expand Down Expand Up @@ -96,17 +113,21 @@ const COMMON_SELECT_PROPS: SelectProps<any, any, any> = {
*/
export const Selection: FC<SelectionProps> = (props) => {
const intl = useIntl();
const { sources, onSelectionComplete, onSelectionSourceChanged } = props;
const { sources, selectionMethods, 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 showSelectionButtons = Boolean(selectionMethods && Array.isArray(selectionMethods) && selectionMethods.length > 1);
HenFo marked this conversation as resolved.
Show resolved Hide resolved
let initialSelectionKind = showSelectionButtons ? (selectionMethods as SelectionKind[])[0] : selectionMethods as SelectionKind;
initialSelectionKind ??= "extent";
const [selectionKind, setSelectionKind] = useState<SelectionKind>(initialSelectionKind);

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

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

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

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

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

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

/**
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"
selectionKind: "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"
selectionKind: "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";

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;
controller.olMap.getCoordinateFromPixel = (pixel) => pixel;
const evt = {
coordinate: [0, 0],
};
controller.onClick(evt as any);
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;
}
133 changes: 133 additions & 0 deletions src/packages/selection/selection-controller/ClickController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
// SPDX-FileCopyrightText: 2023 Open Pioneer project (https://github.com/open-pioneer)
// SPDX-License-Identifier: Apache-2.0
import { Resource } from "@open-pioneer/core";
import { MapBrowserEvent } from "ol";
import OlMap from "ol/Map";
import { unByKey } from "ol/Observable";
import Overlay from "ol/Overlay";
import Geometry from "ol/geom/Geometry";
import { Polygon } from "ol/geom";


/** Represents a tooltip rendered on the OpenLayers map. */
interface Tooltip extends Resource {
overlay: Overlay;
element: HTMLDivElement;
setText(value: string): void;
}

const ACTIVE_CLASS = "selection-active";
const INACTIVE_CLASS = "selection-inactive";

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;

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

/**
* Method for destroying the controller when it is no longer needed
*/
destroy() {
this.tooltip.destroy();
this.olMap.un("singleclick", this.onClick);
this.olMap.getViewport().classList.remove(ACTIVE_CLASS);
this.olMap.getViewport().classList.remove(INACTIVE_CLASS);

}

setActive(isActive: boolean) {
if (this.isActive === isActive) return;
const viewPort = this.olMap.getViewport();
if (isActive) {
this.tooltip.setText(this.tooltipMessage);
viewPort.classList.remove(INACTIVE_CLASS);
viewPort.classList.add(ACTIVE_CLASS);
this.isActive = true;
} else {
this.olMap.un("singleclick", this.onClick);
this.tooltip.setText(this.tooltipDisabledMessage);
viewPort.classList.remove(ACTIVE_CLASS);
viewPort.classList.add(INACTIVE_CLASS);
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;
}



/**
* Method to generate a tooltip on the mouse cursor
*/
private createHelpTooltip(olMap: OlMap, message: string): Tooltip {
const element = document.createElement("div");
element.className = "selection-tooltip printing-hide";
element.role = "tooltip";

const content = document.createElement("span");
content.textContent = message;
element.appendChild(content);

const overlay = new Overlay({
element: element,
offset: [15, 0],
positioning: "center-left"
});

const pointHandler = olMap.on("pointermove", (evt) => {
overlay.setPosition(evt.coordinate);
});

olMap.addOverlay(overlay);
return {
overlay,
element,
destroy() {
olMap.removeOverlay(overlay);
overlay.dispose();
unByKey(pointHandler);
},
setText(value) {
content.textContent = value;
}
};
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`expect tooltip to be successfully created after construction > Tooltip wurde gesetzt 1`] = `
<div
class="selection-tooltip printing-hide"
role="tooltip"
>
<span>
Tooltip wurde gesetzt
</span>
</div>
`;
Loading