From 2243f0759212d45dc4b0238ec633f1c13a37ecbb Mon Sep 17 00:00:00 2001 From: Henry Fock Date: Fri, 15 Nov 2024 11:35:52 +0100 Subject: [PATCH 01/10] [Selection] move selection controller to subfolder --- .husky/pre-commit | 15 --------------- src/packages/selection/Selection.tsx | 2 +- .../DragController.test.ts | 0 .../{ => selection-controller}/DragController.ts | 0 .../__snapshots__/DragController.test.ts.snap | 0 5 files changed, 1 insertion(+), 16 deletions(-) delete mode 100755 .husky/pre-commit rename src/packages/selection/{ => selection-controller}/DragController.test.ts (100%) rename src/packages/selection/{ => selection-controller}/DragController.ts (100%) rename src/packages/selection/{ => selection-controller}/__snapshots__/DragController.test.ts.snap (100%) diff --git a/.husky/pre-commit b/.husky/pre-commit deleted file mode 100755 index a9343f427..000000000 --- a/.husky/pre-commit +++ /dev/null @@ -1,15 +0,0 @@ -if [ "$NO_VERIFY" ]; then - echo 'pre-commit hook skipped' 1>&2 - exit 0 -fi - -echo '--- check code style ---' -pnpm exec lint-staged - -echo '--- run typescript check ---' -pnpm check-types - -echo '--- run tests ---' -# CI=1 disallows `.only` in tests -# --changed only runs the tests affected by changed files -CI=1 pnpm exec vitest run --changed --passWithNoTests diff --git a/src/packages/selection/Selection.tsx b/src/packages/selection/Selection.tsx index df8ed10dc..ad5ccf9eb 100644 --- a/src/packages/selection/Selection.tsx +++ b/src/packages/selection/Selection.tsx @@ -30,7 +30,7 @@ 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 { DragController } from "./selection-controller/DragController"; import { SelectionController } from "./SelectionController"; import { SelectionResult, SelectionSource, SelectionSourceStatusObject } from "./api"; diff --git a/src/packages/selection/DragController.test.ts b/src/packages/selection/selection-controller/DragController.test.ts similarity index 100% rename from src/packages/selection/DragController.test.ts rename to src/packages/selection/selection-controller/DragController.test.ts diff --git a/src/packages/selection/DragController.ts b/src/packages/selection/selection-controller/DragController.ts similarity index 100% rename from src/packages/selection/DragController.ts rename to src/packages/selection/selection-controller/DragController.ts diff --git a/src/packages/selection/__snapshots__/DragController.test.ts.snap b/src/packages/selection/selection-controller/__snapshots__/DragController.test.ts.snap similarity index 100% rename from src/packages/selection/__snapshots__/DragController.test.ts.snap rename to src/packages/selection/selection-controller/__snapshots__/DragController.test.ts.snap From 1dc56fbd0418ec9e8fcc928a8af61d0738d5ff3a Mon Sep 17 00:00:00 2001 From: Henry Fock Date: Fri, 15 Nov 2024 14:24:21 +0100 Subject: [PATCH 02/10] [Selection] allow the user to select between extent and point selection [Selection] add tests for ClickController --- src/packages/selection/Selection.tsx | 59 +++++++- src/packages/selection/i18n/de.yaml | 6 +- src/packages/selection/i18n/en.yaml | 6 +- .../ClickController.test.ts | 51 +++++++ .../selection-controller/ClickController.ts | 133 ++++++++++++++++++ .../ClickController.test.ts.snap | 12 ++ 6 files changed, 258 insertions(+), 9 deletions(-) create mode 100644 src/packages/selection/selection-controller/ClickController.test.ts create mode 100644 src/packages/selection/selection-controller/ClickController.ts create mode 100644 src/packages/selection/selection-controller/__snapshots__/ClickController.test.ts.snap diff --git a/src/packages/selection/Selection.tsx b/src/packages/selection/Selection.tsx index ad5ccf9eb..cb938fdca 100644 --- a/src/packages/selection/Selection.tsx +++ b/src/packages/selection/Selection.tsx @@ -5,6 +5,7 @@ import { Flex, FormControl, FormLabel, + HStack, Icon, Tooltip, VStack, @@ -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 "./selection-controller/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"; + +type SelectionKind = "extent" | "point"; + +export interface ISelectionTypeHandler { + new (map: Map, tooltip: string, disabledMessage: string, onExtentSelected: (geometry: Geometry) => void): T; +}; /** * Properties supported by the {@link Selection} component. @@ -104,9 +116,10 @@ export const Selection: FC = (props) => { sources, onSelectionSourceChanged ); - const currentSourceStatus = useSourceStatus(currentSource, defaultNotAvailableMessage); + const [selectionKind, setSelectionKind] = useState("extent"); + const mapState = useMapModel(props); const { onExtentSelected } = useSelectionController( mapState.map, @@ -117,7 +130,8 @@ export const Selection: FC = (props) => { const chakraStyles = useChakraStyles(); const [isOpenSelect, setIsOpenSelect] = useState(false); - useDragSelection( + useInteractiveSelection( + selectionKind, mapState.map, intl, onExtentSelected, @@ -152,6 +166,21 @@ export const Selection: FC = (props) => { return ( + + {intl.formatMessage({ id: "selectionKind" })} + + } + label={intl.formatMessage({id: "EXTENT"})} + onClick={() => setSelectionKind("extent")} + isActive={selectionKind === "extent"}/> + } + label={intl.formatMessage({id: "POINT"})} + onClick={() => setSelectionKind("point")} + isActive={selectionKind === "point"}/> + + {intl.formatMessage({ id: "selectSource" })} @@ -377,13 +406,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 { + switch (selectionKind) { + case "extent": + return DragController; + case "point": + return ClickController; + default: + throw new Error(`Unknown selection kind: ${selectionKind}`); + } + } + useEffect(() => { if (!map) { return; @@ -393,9 +437,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 ); @@ -404,7 +449,7 @@ function useDragSelection( return () => { dragController?.destroy(); }; - }, [map, intl, onExtentSelected, isActive, hasSelectedSource]); + }, [map, intl, onExtentSelected, isActive, hasSelectedSource, selectionKind]); } /** diff --git a/src/packages/selection/i18n/de.yaml b/src/packages/selection/i18n/de.yaml index ac20c3a61..4424ed90f 100644 --- a/src/packages/selection/i18n/de.yaml +++ b/src/packages/selection/i18n/de.yaml @@ -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" diff --git a/src/packages/selection/i18n/en.yaml b/src/packages/selection/i18n/en.yaml index 51c07c348..10df2db61 100644 --- a/src/packages/selection/i18n/en.yaml +++ b/src/packages/selection/i18n/en.yaml @@ -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" diff --git a/src/packages/selection/selection-controller/ClickController.test.ts b/src/packages/selection/selection-controller/ClickController.test.ts new file mode 100644 index 000000000..af4731a5e --- /dev/null +++ b/src/packages/selection/selection-controller/ClickController.test.ts @@ -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; +} diff --git a/src/packages/selection/selection-controller/ClickController.ts b/src/packages/selection/selection-controller/ClickController.ts new file mode 100644 index 000000000..d706e740b --- /dev/null +++ b/src/packages/selection/selection-controller/ClickController.ts @@ -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 { + private tooltip: Tooltip; + private olMap: OlMap; + private isActive: boolean = true; + private tooltipMessage: string; + private tooltipDisabledMessage: string; + + private onClick: (evt: MapBrowserEvent) => 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) => { + 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; + } + }; + } + +} \ No newline at end of file diff --git a/src/packages/selection/selection-controller/__snapshots__/ClickController.test.ts.snap b/src/packages/selection/selection-controller/__snapshots__/ClickController.test.ts.snap new file mode 100644 index 000000000..2ecfefec6 --- /dev/null +++ b/src/packages/selection/selection-controller/__snapshots__/ClickController.test.ts.snap @@ -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`] = ` + +`; From cb0a308127b9548607c21ffffe03d5ff33b77261 Mon Sep 17 00:00:00 2001 From: Henry Fock Date: Tue, 19 Nov 2024 10:58:34 +0100 Subject: [PATCH 03/10] [Selection] make selection method switcher optional --- src/packages/selection/Selection.tsx | 18 +++++++++++++----- src/samples/map-sample/ol-app/ui/Selection.tsx | 1 + 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/packages/selection/Selection.tsx b/src/packages/selection/Selection.tsx index cb938fdca..4547c7272 100644 --- a/src/packages/selection/Selection.tsx +++ b/src/packages/selection/Selection.tsx @@ -40,7 +40,7 @@ import { TbPointerQuestion } from "react-icons/tb"; import { DragController } from "./selection-controller/DragController"; import { Map } from "ol"; -type SelectionKind = "extent" | "point"; +export type SelectionKind = "extent" | "point"; export interface ISelectionTypeHandler { new (map: Map, tooltip: string, disabledMessage: string, onExtentSelected: (geometry: Geometry) => void): T; @@ -55,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. @@ -108,7 +113,7 @@ const COMMON_SELECT_PROPS: SelectProps = { */ export const Selection: FC = (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" }); @@ -118,7 +123,10 @@ export const Selection: FC = (props) => { ); const currentSourceStatus = useSourceStatus(currentSource, defaultNotAvailableMessage); - const [selectionKind, setSelectionKind] = useState("extent"); + const showSelectionButtons = Boolean(selectionMethods && Array.isArray(selectionMethods) && selectionMethods.length > 1); + let initialSelectionKind = showSelectionButtons ? (selectionMethods as SelectionKind[])[0] : selectionMethods as SelectionKind; + initialSelectionKind ??= "extent"; + const [selectionKind, setSelectionKind] = useState(initialSelectionKind); const mapState = useMapModel(props); const { onExtentSelected } = useSelectionController( @@ -166,7 +174,7 @@ export const Selection: FC = (props) => { return ( - + {showSelectionButtons && {intl.formatMessage({ id: "selectionKind" })} = (props) => { onClick={() => setSelectionKind("point")} isActive={selectionKind === "point"}/> - + } {intl.formatMessage({ id: "selectSource" })} diff --git a/src/samples/map-sample/ol-app/ui/Selection.tsx b/src/samples/map-sample/ol-app/ui/Selection.tsx index 6483db360..d28f36e22 100644 --- a/src/samples/map-sample/ol-app/ui/Selection.tsx +++ b/src/samples/map-sample/ol-app/ui/Selection.tsx @@ -83,6 +83,7 @@ export function SelectionComponent() { > From 21b3eaf871c0bcf23bdfa3ba6077c747c158d8a4 Mon Sep 17 00:00:00 2001 From: Henry Fock Date: Tue, 19 Nov 2024 12:09:52 +0100 Subject: [PATCH 04/10] [Selection] extract common methods to functions --- .../selection-controller/ClickController.ts | 81 ++++--------------- .../selection-controller/DragController.ts | 65 ++------------- .../selection/selection-controller/helper.ts | 71 ++++++++++++++++ 3 files changed, 92 insertions(+), 125 deletions(-) create mode 100644 src/packages/selection/selection-controller/helper.ts diff --git a/src/packages/selection/selection-controller/ClickController.ts b/src/packages/selection/selection-controller/ClickController.ts index d706e740b..bd985e4ad 100644 --- a/src/packages/selection/selection-controller/ClickController.ts +++ b/src/packages/selection/selection-controller/ClickController.ts @@ -1,24 +1,14 @@ // 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"; +import { activateViewportInteraction, createHelpTooltip, deactivateViewportInteraction, Tooltip } from "./helper"; +import { EventsKey } from "ol/events"; -/** 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 { private tooltip: Tooltip; private olMap: OlMap; @@ -27,20 +17,21 @@ export class ClickController { private tooltipDisabledMessage: string; private onClick: (evt: MapBrowserEvent) => void; + private eventKey: EventsKey; constructor( olMap: OlMap, tooltipMessage: string, tooltipDisabledMessage: string, onExtentSelected: (geometry: Geometry) => void - ) { - this.tooltip = this.createHelpTooltip(olMap, tooltipMessage); + ) { + this.tooltip = 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); + this.eventKey = this.olMap.on("singleclick", this.onClick); + activateViewportInteraction(olMap); } /** @@ -48,25 +39,21 @@ export class ClickController { */ destroy() { this.tooltip.destroy(); - this.olMap.un("singleclick", this.onClick); - this.olMap.getViewport().classList.remove(ACTIVE_CLASS); - this.olMap.getViewport().classList.remove(INACTIVE_CLASS); + unByKey(this.eventKey); + deactivateViewportInteraction(this.olMap); } 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); + activateViewportInteraction(this.olMap); this.isActive = true; } else { - this.olMap.un("singleclick", this.onClick); + unByKey(this.eventKey); this.tooltip.setText(this.tooltipDisabledMessage); - viewPort.classList.remove(ACTIVE_CLASS); - viewPort.classList.add(INACTIVE_CLASS); + deactivateViewportInteraction(this.olMap); this.isActive = false; } } @@ -78,8 +65,8 @@ export class ClickController { 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], [pixel[0]! + pixelTolerance, pixel[1]! - pixelTolerance], [pixel[0]! - pixelTolerance, pixel[1]! - pixelTolerance], @@ -90,44 +77,4 @@ export class ClickController { }; 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; - } - }; - } - } \ No newline at end of file diff --git a/src/packages/selection/selection-controller/DragController.ts b/src/packages/selection/selection-controller/DragController.ts index 057d2dd9b..ca10f9f5b 100644 --- a/src/packages/selection/selection-controller/DragController.ts +++ b/src/packages/selection/selection-controller/DragController.ts @@ -2,25 +2,15 @@ // SPDX-License-Identifier: Apache-2.0 import { Resource } from "@open-pioneer/core"; import OlMap from "ol/Map"; -import { unByKey } from "ol/Observable"; -import Overlay from "ol/Overlay"; import { mouseActionButton } from "ol/events/condition"; import Geometry from "ol/geom/Geometry"; import { DragBox, DragPan } from "ol/interaction"; import PointerInteraction from "ol/interaction/Pointer"; +import { activateViewportInteraction, createHelpTooltip, deactivateViewportInteraction, Tooltip } from "./helper"; interface InteractionResource extends Resource { interaction: PointerInteraction; } -/** 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 DragController { private tooltip: Tooltip; @@ -42,15 +32,15 @@ export class DragController { ); this.interactionResources.push(this.createDrag(olMap, viewPort, this.interactionResources)); - this.tooltip = this.createHelpTooltip(olMap, tooltipMessage); + this.tooltip = createHelpTooltip(olMap, tooltipMessage); this.olMap = olMap; this.tooltipMessage = tooltipMessage; this.tooltipDisabledMessage = tooltipDisabledMessage; } initViewport(olMap: OlMap) { + activateViewportInteraction(olMap); const viewPort = olMap.getViewport(); - viewPort.classList.add(ACTIVE_CLASS); viewPort.oncontextmenu = (e) => { e.preventDefault(); @@ -71,22 +61,19 @@ export class DragController { setActive(isActive: boolean) { if (this.isActive === isActive) return; - const viewPort = this.olMap.getViewport(); if (isActive) { this.interactionResources.forEach((interaction) => this.olMap.addInteraction(interaction.interaction) ); this.tooltip.setText(this.tooltipMessage); - viewPort.classList.remove(INACTIVE_CLASS); - viewPort.classList.add(ACTIVE_CLASS); + activateViewportInteraction(this.olMap); this.isActive = true; } else { this.interactionResources.forEach((interaction) => this.olMap.removeInteraction(interaction.interaction) ); this.tooltip.setText(this.tooltipDisabledMessage); - viewPort.classList.remove(ACTIVE_CLASS); - viewPort.classList.add(INACTIVE_CLASS); + deactivateViewportInteraction(this.olMap); this.isActive = false; } } @@ -116,8 +103,7 @@ export class DragController { olMap.removeInteraction(dragBox); interactionResources.splice(interactionResources.indexOf(this)); dragBox.dispose(); - viewPort.classList.remove(ACTIVE_CLASS); - viewPort.classList.remove(INACTIVE_CLASS); + deactivateViewportInteraction(olMap); viewPort.oncontextmenu = null; } }; @@ -151,8 +137,7 @@ export class DragController { olMap.removeInteraction(drag); interactionResources.splice(interactionResources.indexOf(this)); drag.dispose(); - viewPort.classList.remove(ACTIVE_CLASS); - viewPort.classList.remove(INACTIVE_CLASS); + deactivateViewportInteraction(olMap); viewPort.oncontextmenu = null; } }; @@ -160,42 +145,6 @@ export class DragController { return interactionResource; } - /** - * 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; - } - }; - } /** * Method for testing purposes only diff --git a/src/packages/selection/selection-controller/helper.ts b/src/packages/selection/selection-controller/helper.ts new file mode 100644 index 000000000..1b4a5b7d2 --- /dev/null +++ b/src/packages/selection/selection-controller/helper.ts @@ -0,0 +1,71 @@ +// SPDX-FileCopyrightText: 2023 Open Pioneer project (https://github.com/open-pioneer) +// SPDX-License-Identifier: Apache-2.0 +import { Resource } from "@open-pioneer/core"; +import { Overlay } from "ol"; +import OlMap from "ol/map"; +import { unByKey } from "ol/Observable"; + +const ACTIVE_CLASS = "selection-active"; +const INACTIVE_CLASS = "selection-inactive"; + +/** Represents a tooltip rendered on the OpenLayers map. */ +export interface Tooltip extends Resource { + overlay: Overlay; + element: HTMLDivElement; + setText(value: string): void; +} + +/** + * Applies appropriate css classes to the viewport to indicate the active selection process + */ +export function activateViewportInteraction(map: OlMap) { + const viewPort = map.getViewport(); + viewPort.classList.remove(INACTIVE_CLASS); + viewPort.classList.add(ACTIVE_CLASS); +} + +/** + * Applies appropriate css classes from the viewport to indicate that the selection process is inactive + */ +export function deactivateViewportInteraction(map: OlMap) { + const viewPort = map.getViewport(); + viewPort.classList.remove(ACTIVE_CLASS); + viewPort.classList.add(INACTIVE_CLASS); +} + +/** + * Method to generate a tooltip on the mouse cursor + */ +export function 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; + } + }; +} \ No newline at end of file From 611487f123da26fb899fc307c16a968001e3dca1 Mon Sep 17 00:00:00 2001 From: Henry Fock Date: Wed, 20 Nov 2024 20:50:47 +0100 Subject: [PATCH 05/10] [Selection] make selectionMethod reactive --- src/packages/selection/Selection.tsx | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/src/packages/selection/Selection.tsx b/src/packages/selection/Selection.tsx index 4547c7272..92e8d69fd 100644 --- a/src/packages/selection/Selection.tsx +++ b/src/packages/selection/Selection.tsx @@ -40,7 +40,7 @@ import { TbPointerQuestion } from "react-icons/tb"; import { DragController } from "./selection-controller/DragController"; import { Map } from "ol"; -export type SelectionKind = "extent" | "point"; +export type SelectionMethod = "extent" | "point"; export interface ISelectionTypeHandler { new (map: Map, tooltip: string, disabledMessage: string, onExtentSelected: (geometry: Geometry) => void): T; @@ -58,7 +58,7 @@ export interface SelectionProps extends CommonComponentProps, MapModelProps { /** * Array of selection methods available for spatial selection. */ - selectionMethods?: SelectionKind | SelectionKind[]; + selectionMethods?: SelectionMethod | SelectionMethod[]; /** * This handler is called whenever the user has successfully selected @@ -123,10 +123,17 @@ export const Selection: FC = (props) => { ); const currentSourceStatus = useSourceStatus(currentSource, defaultNotAvailableMessage); - const showSelectionButtons = Boolean(selectionMethods && Array.isArray(selectionMethods) && selectionMethods.length > 1); - let initialSelectionKind = showSelectionButtons ? (selectionMethods as SelectionKind[])[0] : selectionMethods as SelectionKind; - initialSelectionKind ??= "extent"; - const [selectionKind, setSelectionKind] = useState(initialSelectionKind); + const selectionDefault = "extent"; + const [selectionKind, setSelectionKind] = useState(selectionDefault); + useEffect(() => { + let method = selectionMethods ?? selectionDefault; + method = Array.isArray(method) && method.length > 0 ? method[0]! : method as SelectionMethod; + setSelectionKind(method); + }, [selectionMethods]); + const showSelectionButtons = useMemo(() => { + return Boolean(selectionMethods && Array.isArray(selectionMethods) && selectionMethods.length > 1); + }, [selectionMethods]); + const mapState = useMapModel(props); const { onExtentSelected } = useSelectionController( @@ -415,7 +422,7 @@ function useSourceStatus( * Hook to manage map controls and tooltip */ function useInteractiveSelection( - selectionKind: SelectionKind, + selectionKind: SelectionMethod, map: MapModel | undefined, intl: PackageIntl, onExtentSelected: (geometry: Geometry) => void, @@ -424,7 +431,7 @@ function useInteractiveSelection( ) { function selectionKindFactory( - selectionKind: SelectionKind, + selectionKind: SelectionMethod, ): ISelectionTypeHandler { switch (selectionKind) { case "extent": From 1c60e241e38cf2bb0fe81602b738824473e84095 Mon Sep 17 00:00:00 2001 From: Henry Fock Date: Fri, 15 Nov 2024 14:41:20 +0100 Subject: [PATCH 06/10] [Selection] add documentation --- src/packages/selection/README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/packages/selection/README.md b/src/packages/selection/README.md index 00a25d9cb..1f48914a4 100644 --- a/src/packages/selection/README.md +++ b/src/packages/selection/README.md @@ -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 @@ -25,6 +31,7 @@ import { Search, SearchSelectEvent } from "@open-pioneer/search"; { // do something }} From 706703e886932dfc8b5231ef42c243e79a2874b2 Mon Sep 17 00:00:00 2001 From: Henry Fock Date: Wed, 20 Nov 2024 10:07:42 +0100 Subject: [PATCH 07/10] [Selection] add changeset --- .changeset/orange-islands-ring.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/orange-islands-ring.md diff --git a/.changeset/orange-islands-ring.md b/.changeset/orange-islands-ring.md new file mode 100644 index 000000000..90a8d213f --- /dev/null +++ b/.changeset/orange-islands-ring.md @@ -0,0 +1,5 @@ +--- +"@open-pioneer/selection": patch +--- + +added a selection controller which allows point selection of features on the map From f3dddb1b83e37da92190183ec4223fb3228a1ade Mon Sep 17 00:00:00 2001 From: Henry Fock Date: Wed, 20 Nov 2024 21:26:59 +0100 Subject: [PATCH 08/10] [Selection] rename internal SelectionKind -> SelectionMethod --- src/packages/selection/Selection.tsx | 49 ++++++++++--------- src/packages/selection/i18n/de.yaml | 2 +- src/packages/selection/i18n/en.yaml | 2 +- .../map-sample/ol-app/ui/Selection.tsx | 2 +- 4 files changed, 29 insertions(+), 26 deletions(-) diff --git a/src/packages/selection/Selection.tsx b/src/packages/selection/Selection.tsx index 92e8d69fd..cf0469dc2 100644 --- a/src/packages/selection/Selection.tsx +++ b/src/packages/selection/Selection.tsx @@ -40,6 +40,9 @@ 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 { @@ -58,7 +61,7 @@ export interface SelectionProps extends CommonComponentProps, MapModelProps { /** * Array of selection methods available for spatial selection. */ - selectionMethods?: SelectionMethod | SelectionMethod[]; + availableSelectionMethods?: SelectionMethod | SelectionMethod[]; /** * This handler is called whenever the user has successfully selected @@ -113,7 +116,7 @@ const COMMON_SELECT_PROPS: SelectProps = { */ export const Selection: FC = (props) => { const intl = useIntl(); - const { sources, selectionMethods, onSelectionComplete, onSelectionSourceChanged } = props; + const { sources, availableSelectionMethods, onSelectionComplete, onSelectionSourceChanged } = props; const { containerProps } = useCommonComponentProps("selection", props); const defaultNotAvailableMessage = intl.formatMessage({ id: "sourceNotAvailable" }); @@ -123,16 +126,16 @@ export const Selection: FC = (props) => { ); const currentSourceStatus = useSourceStatus(currentSource, defaultNotAvailableMessage); - const selectionDefault = "extent"; - const [selectionKind, setSelectionKind] = useState(selectionDefault); + const defaultSelectionMethod = "extent"; + const [activeSelectionMethod, setActiveSelectionMethod] = useState(defaultSelectionMethod); useEffect(() => { - let method = selectionMethods ?? selectionDefault; + let method = availableSelectionMethods ?? defaultSelectionMethod; method = Array.isArray(method) && method.length > 0 ? method[0]! : method as SelectionMethod; - setSelectionKind(method); - }, [selectionMethods]); + setActiveSelectionMethod(method); + }, [availableSelectionMethods]); const showSelectionButtons = useMemo(() => { - return Boolean(selectionMethods && Array.isArray(selectionMethods) && selectionMethods.length > 1); - }, [selectionMethods]); + return Boolean(availableSelectionMethods && Array.isArray(availableSelectionMethods) && availableSelectionMethods.length > 1); + }, [availableSelectionMethods]); const mapState = useMapModel(props); @@ -146,7 +149,7 @@ export const Selection: FC = (props) => { const [isOpenSelect, setIsOpenSelect] = useState(false); useInteractiveSelection( - selectionKind, + activeSelectionMethod, mapState.map, intl, onExtentSelected, @@ -182,18 +185,18 @@ export const Selection: FC = (props) => { return ( {showSelectionButtons && - {intl.formatMessage({ id: "selectionKind" })} + {intl.formatMessage({ id: "selectionMethod" })} } label={intl.formatMessage({id: "EXTENT"})} - onClick={() => setSelectionKind("extent")} - isActive={selectionKind === "extent"}/> + onClick={() => setActiveSelectionMethod("extent")} + isActive={activeSelectionMethod === "extent"}/> } label={intl.formatMessage({id: "POINT"})} - onClick={() => setSelectionKind("point")} - isActive={selectionKind === "point"}/> + onClick={() => setActiveSelectionMethod("point")} + isActive={activeSelectionMethod === "point"}/> } @@ -422,7 +425,7 @@ function useSourceStatus( * Hook to manage map controls and tooltip */ function useInteractiveSelection( - selectionKind: SelectionMethod, + selectionMethod: SelectionMethod, map: MapModel | undefined, intl: PackageIntl, onExtentSelected: (geometry: Geometry) => void, @@ -430,16 +433,16 @@ function useInteractiveSelection( hasSelectedSource: boolean ) { - function selectionKindFactory( - selectionKind: SelectionMethod, + function selectionMethodFactory( + selectionMethod: SelectionMethod, ): ISelectionTypeHandler { - switch (selectionKind) { + switch (selectionMethod) { case "extent": return DragController; case "point": return ClickController; default: - throw new Error(`Unknown selection kind: ${selectionKind}`); + throw new Error(`Unknown selection kind: ${selectionMethod}`); } } @@ -452,10 +455,10 @@ function useInteractiveSelection( ? intl.formatMessage({ id: "disabledTooltip" }) : intl.formatMessage({ id: "noSourceTooltip" }); - const controlerCls = selectionKindFactory(selectionKind); + const controlerCls = selectionMethodFactory(selectionMethod); const dragController = new controlerCls( map.olMap, - intl.formatMessage({ id: `tooltip.${selectionKind}` }), + intl.formatMessage({ id: `tooltip.${selectionMethod}` }), disabledMessage, onExtentSelected ); @@ -464,7 +467,7 @@ function useInteractiveSelection( return () => { dragController?.destroy(); }; - }, [map, intl, onExtentSelected, isActive, hasSelectedSource, selectionKind]); + }, [map, intl, onExtentSelected, isActive, hasSelectedSource, selectionMethod]); } /** diff --git a/src/packages/selection/i18n/de.yaml b/src/packages/selection/i18n/de.yaml index 4424ed90f..a4c489a03 100644 --- a/src/packages/selection/i18n/de.yaml +++ b/src/packages/selection/i18n/de.yaml @@ -5,7 +5,7 @@ messages: FREEPOLYGON: "Freies Zeichnen" CIRCLE: "Kreis" POINT: "Punkt" - selectionKind: "Selektionsart" + selectionMethod: "Selektionsart" selectSource: "Quelle auswählen" tooltip: extent: "Klicken Sie in die Karte, halten Sie die Maustaste gedrückt und ziehen Sie ein Rechteck auf" diff --git a/src/packages/selection/i18n/en.yaml b/src/packages/selection/i18n/en.yaml index 10df2db61..8e47a8827 100644 --- a/src/packages/selection/i18n/en.yaml +++ b/src/packages/selection/i18n/en.yaml @@ -5,7 +5,7 @@ messages: FREEPOLYGON: "Freies Zeichnen" CIRCLE: "Kreis" POINT: "Point" - selectionKind: "Selection type" + selectionMethod: "Selection type" selectSource: "Select source" tooltip: extent: "Click on the map, hold down the mouse button and draw a rectangle" diff --git a/src/samples/map-sample/ol-app/ui/Selection.tsx b/src/samples/map-sample/ol-app/ui/Selection.tsx index d28f36e22..885390cfb 100644 --- a/src/samples/map-sample/ol-app/ui/Selection.tsx +++ b/src/samples/map-sample/ol-app/ui/Selection.tsx @@ -83,7 +83,7 @@ export function SelectionComponent() { > From 3db261f04f6b80184f868f44789fe218bb89c343 Mon Sep 17 00:00:00 2001 From: Henry Fock Date: Thu, 21 Nov 2024 09:58:28 +0100 Subject: [PATCH 09/10] [Selection] fix css on window close --- .../selection/selection-controller/ClickController.ts | 2 +- .../selection/selection-controller/DragController.ts | 1 + src/packages/selection/selection-controller/helper.ts | 5 +++-- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/packages/selection/selection-controller/ClickController.ts b/src/packages/selection/selection-controller/ClickController.ts index bd985e4ad..e5f15225f 100644 --- a/src/packages/selection/selection-controller/ClickController.ts +++ b/src/packages/selection/selection-controller/ClickController.ts @@ -40,7 +40,7 @@ export class ClickController { destroy() { this.tooltip.destroy(); unByKey(this.eventKey); - deactivateViewportInteraction(this.olMap); + deactivateViewportInteraction(this.olMap, true); } diff --git a/src/packages/selection/selection-controller/DragController.ts b/src/packages/selection/selection-controller/DragController.ts index ca10f9f5b..7458866cc 100644 --- a/src/packages/selection/selection-controller/DragController.ts +++ b/src/packages/selection/selection-controller/DragController.ts @@ -57,6 +57,7 @@ export class DragController { this.interactionResources.forEach((interaction) => { interaction.destroy(); }); + deactivateViewportInteraction(this.olMap, true); } setActive(isActive: boolean) { diff --git a/src/packages/selection/selection-controller/helper.ts b/src/packages/selection/selection-controller/helper.ts index 1b4a5b7d2..33b66ce3a 100644 --- a/src/packages/selection/selection-controller/helper.ts +++ b/src/packages/selection/selection-controller/helper.ts @@ -27,10 +27,11 @@ export function activateViewportInteraction(map: OlMap) { /** * Applies appropriate css classes from the viewport to indicate that the selection process is inactive */ -export function deactivateViewportInteraction(map: OlMap) { +export function deactivateViewportInteraction(map: OlMap, destroy = true) { const viewPort = map.getViewport(); viewPort.classList.remove(ACTIVE_CLASS); - viewPort.classList.add(INACTIVE_CLASS); + if (!destroy) + viewPort.classList.add(INACTIVE_CLASS); } /** From a8e63fca3979d320584b35cccedbc325f4f6a7d7 Mon Sep 17 00:00:00 2001 From: Henry Fock Date: Mon, 2 Dec 2024 21:25:13 +0100 Subject: [PATCH 10/10] WIP fixup! [Selection] move selection controller to subfolder --- .husky/pre-commit | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100755 .husky/pre-commit diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 000000000..a9343f427 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,15 @@ +if [ "$NO_VERIFY" ]; then + echo 'pre-commit hook skipped' 1>&2 + exit 0 +fi + +echo '--- check code style ---' +pnpm exec lint-staged + +echo '--- run typescript check ---' +pnpm check-types + +echo '--- run tests ---' +# CI=1 disallows `.only` in tests +# --changed only runs the tests affected by changed files +CI=1 pnpm exec vitest run --changed --passWithNoTests