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();
+ });
+ });
+});