diff --git a/@here/harp-examples/src/markers_dynamic.ts b/@here/harp-examples/src/markers_dynamic.ts index 7b1cbfc63d..368b44c84e 100644 --- a/@here/harp-examples/src/markers_dynamic.ts +++ b/@here/harp-examples/src/markers_dynamic.ts @@ -6,7 +6,7 @@ import { FeaturesDataSource, MapViewPointFeature } from "@here/harp-features-datasource"; import { GeoCoordinates } from "@here/harp-geoutils"; -import { MapControls, MapControlsUI } from "@here/harp-map-controls"; +import { LongPressHandler, MapControls, MapControlsUI } from "@here/harp-map-controls"; import { CopyrightElementHandler, MapView } from "@here/harp-mapview"; import { APIFormat, @@ -14,6 +14,7 @@ import { GeoJsonDataProvider, VectorTileDataSource } from "@here/harp-vectortile-datasource"; +import { GUI } from "dat.gui"; import { apikey, copyrightInfo } from "../config"; @@ -70,7 +71,8 @@ export namespace DynamicMarkersExample { } }, target: new GeoCoordinates(52.52, 13.4), - zoomLevel: 12 + zoomLevel: 12, + delayLabelsUntilMovementFinished: false }); CopyrightElementHandler.install("copyrightNotice").attach(map); @@ -84,18 +86,30 @@ export namespace DynamicMarkersExample { window.addEventListener("resize", () => { map.resize(window.innerWidth, window.innerHeight); }); + const omvDataSource = new VectorTileDataSource({ + baseUrl: "https://vector.hereapi.com/v2/vectortiles/base/mc", + apiFormat: APIFormat.XYZOMV, + styleSetName: "tilezen", + maxDataLevel: 17, + authenticationCode: apikey, + authenticationMethod: { + method: AuthenticationMethod.QueryString, + name: "apikey" + }, + copyrightInfo + }); + map.addDataSource(omvDataSource); + // Register the icon image referenced in the style. + for (const { name, url } of icons) { + map.userImageCache.addImage(name, url); + } map.update(); return map; } - function handlePick( - mapView: MapView, - markersDataSource: FeaturesDataSource, - x: number, - y: number - ): void { + function removeMarker(x: number, y: number): void { // Intersection test filtering the results by layer name to get only markers. const layerName = (markersDataSource.dataProvider() as GeoJsonDataProvider).name; const results = mapView.intersectMapObjects(x, y).filter(result => { @@ -115,78 +129,83 @@ export namespace DynamicMarkersExample { } let markerId = 0; - function attachClickEvents(mapView: MapView, markersDataSource: FeaturesDataSource) { - mapView.canvas.addEventListener("click", event => { - if (event.shiftKey) { - const geo = mapView.getGeoCoordinatesAt(event.clientX, event.clientY); - if (geo) { - // Add a new marker to the data source at the click coordinates. - markersDataSource.add( - new MapViewPointFeature(geo.toGeoPoint() as number[], { - text: markerId.toString(), - id: markerId, - icon: icons[markerId % icons.length].name, - renderOrder: markerId - }) - ); - markerId++; - } - } else if (event.ctrlKey) { - handlePick(mapView, markersDataSource, event.pageX, event.pageY); - } - }); + function addMarker(x: number, y: number) { + const geo = mapView.getGeoCoordinatesAt(x, y); + if (geo) { + // Add a new marker to the data source at the click coordinates. + markersDataSource.add( + new MapViewPointFeature(geo.toGeoPoint() as number[], { + text: markerId.toString(), + id: markerId, + icon: icons[markerId % icons.length].name, + renderOrder: markerId + }) + ); + markerId++; + } + } + + function clearMarkers() { + markersDataSource.clear(); + markerId = 0; + } + function getCanvasPosition(event: MouseEvent | Touch): { x: number; y: number } { + const { left, top } = mapView.canvas.getBoundingClientRect(); + return { x: event.clientX - Math.floor(left), y: event.clientY - Math.floor(top) }; + } + + function attachInputEvents() { + const canvas = mapView.canvas; + new LongPressHandler( + canvas, + event => { + const canvasPos = getCanvasPosition(event); + removeMarker(canvasPos.x, canvasPos.y); + }, + event => { + const canvasPos = getCanvasPosition(event); + addMarker(canvasPos.x, canvasPos.y); + } + ); window.addEventListener("keypress", event => { if (event.key === "c") { - markersDataSource.clear(); - markerId = 0; + clearMarkers(); } }); + } - const instructions = ` -Shift+Left Click to add a marker
Ctrl+Left Click to remove it
-Press 'c' to clear the map.
`; + function addUI() { + const gui = new GUI(); + gui.width = 250; + gui.add( + { + clear: clearMarkers + }, + "clear" + ).name("(C)lear markers"); + } + + function addInstructions() { const message = document.createElement("div"); - message.style.position = "absolute"; - message.style.cssFloat = "right"; - message.style.top = "10px"; - message.style.right = "10px"; - message.style.backgroundColor = "grey"; - message.innerHTML = instructions; + message.innerHTML = `Tap map to add a marker, long press on a marker to remove it`; + message.style.position = "relative"; + message.style.top = "60px"; document.body.appendChild(message); } - function attachDataSources(mapView: MapView) { - const omvDataSource = new VectorTileDataSource({ - baseUrl: "https://vector.hereapi.com/v2/vectortiles/base/mc", - apiFormat: APIFormat.XYZOMV, - styleSetName: "tilezen", - maxDataLevel: 17, - authenticationCode: apikey, - authenticationMethod: { - method: AuthenticationMethod.QueryString, - name: "apikey" - }, - copyrightInfo - }); - mapView.addDataSource(omvDataSource); - - // Register the icon image referenced in the style. - for (const { name, url } of icons) { - mapView.userImageCache.addImage(name, url); - } + addInstructions(); - // Create a [[FeaturesDataSource]] for the markers. - const markersDataSource = new FeaturesDataSource({ - name: "geojson", - styleSetName: "geojson", - gatherFeatureAttributes: true - }); - mapView.addDataSource(markersDataSource); + const mapView = initializeMapView("mapCanvas"); - attachClickEvents(mapView, markersDataSource); - } + // Create a [[FeaturesDataSource]] for the markers. + const markersDataSource = new FeaturesDataSource({ + name: "geojson", + styleSetName: "geojson", + gatherFeatureAttributes: true + }); + mapView.addDataSource(markersDataSource); - const mapView = initializeMapView("mapCanvas"); - attachDataSources(mapView); + attachInputEvents(); + addUI(); } diff --git a/@here/harp-map-controls/lib/LongPressHandler.ts b/@here/harp-map-controls/lib/LongPressHandler.ts index 9d8e5c92bd..c36aa617d0 100644 --- a/@here/harp-map-controls/lib/LongPressHandler.ts +++ b/@here/harp-map-controls/lib/LongPressHandler.ts @@ -6,7 +6,7 @@ /** * Class that can be used to track long presses on an HTML Element. A long press is a press that - * lasts a minimum duration (see the [[timeout]] member) while the mouse is not moved more than a + * lasts a minimum duration (see the [[timeout]] member) while the pointer is not moved more than a * certain threshold (see the [[moveThreshold]] member). */ export class LongPressHandler { @@ -21,31 +21,41 @@ export class LongPressHandler { moveThreshold: number = 5; /** - * Button id that should be handled by this event. + * Mouse button id that should be handled by this event. */ buttonId: number = 0; - private m_mouseDownEvent?: MouseEvent = undefined; + private m_pressEvent?: MouseEvent | Touch = undefined; private m_timerId?: number = undefined; private m_moveHandlerRegistered: boolean = false; - private readonly m_boundMouseMoveHandler: any; + private readonly m_boundPointerMoveHandler: any; private readonly m_boundMouseDownHandler: any; + private readonly m_boundTouchStartHandler: any; private readonly m_boundMouseUpHandler: any; + private readonly m_boundTouchEndHandler: any; /** * Default constructor. * * @param element - The HTML element to track. * @param onLongPress - The callback to call when a long press occurred. + * @param onTap - Optional callback to call on a tap, i.e. when the press ends before the + * specified timeout. */ - constructor(readonly element: HTMLElement, public onLongPress: (event: MouseEvent) => void) { - // workaround - need to bind 'this' for our dynamic mouse move handler - this.m_boundMouseMoveHandler = this.onMouseMove.bind(this); - this.m_boundMouseDownHandler = this.onMousedown.bind(this); - this.m_boundMouseUpHandler = this.onMouseup.bind(this); - + constructor( + readonly element: HTMLElement, + public onLongPress: (event: MouseEvent | Touch) => void, + public onTap?: (event: MouseEvent | Touch) => void + ) { + this.m_boundPointerMoveHandler = this.onPointerMove.bind(this); + this.m_boundMouseDownHandler = this.onMouseDown.bind(this); + this.m_boundTouchStartHandler = this.onTouchStart.bind(this); + this.m_boundMouseUpHandler = this.onMouseUp.bind(this); + this.m_boundTouchEndHandler = this.onTouchEnd.bind(this); this.element.addEventListener("mousedown", this.m_boundMouseDownHandler); + this.element.addEventListener("touchstart", this.m_boundTouchStartHandler); this.element.addEventListener("mouseup", this.m_boundMouseUpHandler); + this.element.addEventListener("touchend", this.m_boundTouchEndHandler); } /** @@ -54,45 +64,72 @@ export class LongPressHandler { dispose() { this.cancel(); this.element.removeEventListener("mousedown", this.m_boundMouseDownHandler); + this.element.removeEventListener("touchstart", this.m_boundTouchStartHandler); this.element.removeEventListener("mouseup", this.m_boundMouseUpHandler); + this.element.removeEventListener("touchend", this.m_boundTouchEndHandler); } - private onMousedown(event: MouseEvent) { - if (event.button !== this.buttonId) { - return; - } + private startPress(event: MouseEvent | TouchEvent) { this.cancelTimer(); - this.m_mouseDownEvent = event; + this.m_pressEvent = (event as any).changedTouches?.[0] ?? event; this.m_timerId = setTimeout(() => this.onTimeout(), this.timeout) as any; - this.addMouseMoveHandler(); + this.addPointerMoveHandler(); + } + + private onMouseDown(event: MouseEvent) { + if (event.button === this.buttonId) { + this.startPress(event); + } } - private onMouseup(event: MouseEvent) { - if (event.button !== this.buttonId) { + private onTouchStart(event: TouchEvent) { + if (this.m_pressEvent) { + // Cancel long press if a second touch starts while holding the first one. + this.cancel(); return; } - this.cancel(); + + if (event.changedTouches.length === 1) { + this.startPress(event); + } } - private onMouseMove(event: MouseEvent) { - if (this.m_mouseDownEvent === undefined) { - return; // Must not happen + private onMouseUp(event: MouseEvent) { + if (this.m_pressEvent && event.button === this.buttonId) { + this.cancel(); + this.onTap?.(event); } + } + private onTouchEnd(event: TouchEvent) { + if ( + event.changedTouches.length === 1 && + event.changedTouches[0].identifier === (this.m_pressEvent as any).identifier + ) { + this.cancel(); + this.onTap?.(event.changedTouches[0]); + } + } + + private onPointerMove(event: MouseEvent | TouchEvent) { + if (this.m_pressEvent === undefined) { + return; // Must not happen + } + const { clientX, clientY } = (event as any).changedTouches?.[0] ?? event; const manhattanLength = - Math.abs(event.clientX - this.m_mouseDownEvent.clientX) + - Math.abs(event.clientY - this.m_mouseDownEvent.clientY); + Math.abs(clientX - this.m_pressEvent.clientX) + + Math.abs(clientY - this.m_pressEvent.clientY); - if (manhattanLength >= this.moveThreshold) { + if (manhattanLength > this.moveThreshold) { this.cancel(); } } private cancel() { - this.m_mouseDownEvent = undefined; + this.m_pressEvent = undefined; this.cancelTimer(); - this.removeMouseMoveHandler(); + this.removePointerMoveHandler(); } private cancelTimer() { @@ -104,26 +141,28 @@ export class LongPressHandler { this.m_timerId = undefined; } - private addMouseMoveHandler() { + private addPointerMoveHandler() { if (this.m_moveHandlerRegistered) { return; } - this.element.addEventListener("mousemove", this.m_boundMouseMoveHandler); + this.element.addEventListener("mousemove", this.m_boundPointerMoveHandler); + this.element.addEventListener("touchmove", this.m_boundPointerMoveHandler); this.m_moveHandlerRegistered = true; } - private removeMouseMoveHandler() { + private removePointerMoveHandler() { if (!this.m_moveHandlerRegistered) { return; } - this.element.removeEventListener("mousemove", this.m_boundMouseMoveHandler); + this.element.removeEventListener("mousemove", this.m_boundPointerMoveHandler); + this.element.removeEventListener("touchmove", this.m_boundPointerMoveHandler); this.m_moveHandlerRegistered = false; } private onTimeout() { - const event = this.m_mouseDownEvent; + const event = this.m_pressEvent; this.m_timerId = undefined; this.cancel(); diff --git a/@here/harp-map-controls/test/LongPressHandlerTest.ts b/@here/harp-map-controls/test/LongPressHandlerTest.ts new file mode 100644 index 0000000000..84bb783557 --- /dev/null +++ b/@here/harp-map-controls/test/LongPressHandlerTest.ts @@ -0,0 +1,149 @@ +/* + * Copyright (C) 2021 HERE Europe B.V. + * Licensed under Apache 2.0, see full license in LICENSE + * SPDX-License-Identifier: Apache-2.0 + */ + +import { expect } from "chai"; +import * as sinon from "sinon"; + +import { LongPressHandler } from "../lib/LongPressHandler"; + +describe("LongPressHandler", function () { + const longPressTimeout = 10; + const dummyMouseEvent = { + button: 0, + clientX: 0, + clientY: 0 + } as MouseEvent; + const dummyTouchEvent = ({ + changedTouches: [ + { + clientX: 0, + clientY: 0, + identifier: 0 + } + ] + } as any) as TouchEvent; + + let element: HTMLElement; + let eventMap: Map; + let longPressSpy: sinon.SinonSpy; + let tapSpy: sinon.SinonSpy; + let longPressHandler: LongPressHandler; + let mouseDownHandler: EventListener; + let mouseUpHandler: EventListener; + let touchStartHandler: EventListener; + let touchEndHandler: EventListener; + + function sleep(ms: number): Promise { + return new Promise(resolve => { + setTimeout(resolve, ms); + }); + } + + beforeEach(function () { + eventMap = new Map(); + element = ({ + addEventListener: (event: string, listener: EventListener) => { + eventMap.set(event, listener); + }, + removeEventListener: (event: string, _: EventListener) => { + eventMap.delete(event); + } + } as any) as HTMLElement; + longPressSpy = sinon.spy(); + tapSpy = sinon.spy(); + longPressHandler = new LongPressHandler(element, longPressSpy, tapSpy); + longPressHandler.timeout = longPressTimeout; + + mouseDownHandler = eventMap.get("mousedown")!; + expect(mouseDownHandler).is.not.undefined; + mouseUpHandler = eventMap.get("mouseup")!; + expect(mouseUpHandler).is.not.undefined; + touchStartHandler = eventMap.get("touchstart")!; + expect(touchStartHandler).is.not.undefined; + touchEndHandler = eventMap.get("touchend")!; + expect(touchEndHandler).is.not.undefined; + }); + it("calls long press callback on long mouse press", function (done) { + mouseDownHandler(dummyMouseEvent); + + sleep(longPressTimeout * 1.1).then(() => { + expect(longPressSpy.called).to.be.true; + expect(tapSpy.called).to.be.false; + done(); + }); + }); + + it("calls long press callback on long touch", function (done) { + touchStartHandler(dummyTouchEvent); + + sleep(longPressTimeout * 1.1).then(() => { + expect(longPressSpy.called).to.be.true; + expect(tapSpy.called).to.be.false; + done(); + }); + }); + + it("calls tap callback on short mouse press", function (done) { + mouseDownHandler(dummyMouseEvent); + + sleep(longPressTimeout * 0.1).then(() => { + mouseUpHandler(dummyMouseEvent); + expect(longPressSpy.called).to.be.false; + expect(tapSpy.called).to.be.true; + done(); + }); + }); + + it("calls tap callback on short touch", function (done) { + touchStartHandler(dummyTouchEvent); + + sleep(longPressTimeout * 0.1).then(() => { + touchEndHandler(dummyTouchEvent); + expect(longPressSpy.called).to.be.false; + expect(tapSpy.called).to.be.true; + done(); + }); + }); + + it("does not call long press callback on long mouse press with movement", function (done) { + mouseDownHandler(dummyMouseEvent); + + const mouseMoveHandler = eventMap.get("mousemove")!; + expect(mouseMoveHandler).is.not.undefined; + + mouseMoveHandler({ + ...dummyMouseEvent, + clientX: longPressHandler.moveThreshold + 1, + clientY: 0 + } as MouseEvent); + + sleep(longPressTimeout * 1.1).then(() => { + expect(longPressSpy.called).to.be.false; + expect(tapSpy.called).to.be.false; + done(); + }); + }); + + it("does not call tap callback on short mouse press with movement", function (done) { + mouseDownHandler(dummyMouseEvent); + + const mouseMoveHandler = eventMap.get("mousemove")!; + expect(mouseMoveHandler).is.not.undefined; + + mouseMoveHandler({ + ...dummyMouseEvent, + clientX: 0, + clientY: longPressHandler.moveThreshold + 1 + } as MouseEvent); + + sleep(longPressTimeout * 0.1).then(() => { + mouseUpHandler(dummyMouseEvent); + expect(longPressSpy.called).to.be.false; + expect(tapSpy.called).to.be.false; + done(); + }); + }); +});