diff --git a/.gitignore b/.gitignore index 4db73ca6f4..7904d8b9ff 100644 --- a/.gitignore +++ b/.gitignore @@ -80,4 +80,4 @@ packages/l7/package_bak.json stories/Test packages/draw/node_modules/@turf -packages/district/src/data \ No newline at end of file +packages/district/src/data diff --git a/docs/api/district/baselayer.en.md b/docs/api/district/baselayer.en.md index 60581e9927..782e860f0b 100644 --- a/docs/api/district/baselayer.en.md +++ b/docs/api/district/baselayer.en.md @@ -55,10 +55,11 @@ order: 2 - strokeWidth 填充描边宽度 `ProvinceLayer, CityLayer, CountyLayer` - autoFit 是否自动缩放到图层范围 `boolean` - popup 信息窗口 - - - enable 是否开启 `boolean` - - triggerEvent 触发事件 例如 'mousemove' | 'click'; - - Html popup html 字符串,支持回调函数 (properties: any) => string; + - openTriggerEvent 触发事件 例如 'mousemove' | 'click'; + - closeTriggerEvent 触发事件 例如 'mousemove' | 'click'; + - enable 是否开启 `boolean` + - triggerEvent 触发事件 例如 'mousemove' | 'click'; + - Html popup html 字符串,支持回调函数 (properties: any) => string; - chinaNationalStroke 中国国界线颜色 `CountryLayer` - chinaNationalWidth 中国国界线宽度 `CountryLayer` diff --git a/docs/api/district/baselayer.zh.md b/docs/api/district/baselayer.zh.md index 7e8c95f197..87d8d225ea 100644 --- a/docs/api/district/baselayer.zh.md +++ b/docs/api/district/baselayer.zh.md @@ -62,7 +62,8 @@ order: 2 - popup 信息窗口 - enable 是否开启 `boolean` - - triggerEvent 触发事件 例如 'mousemove' | 'click'; + - openTriggerEvent 触发事件 例如 'mousemove' | 'click'; + - closeTriggerEvent 触发事件 例如 'mousemove' | 'click'; - Html popup html 字符串,支持回调函数 (properties: any) => string; - chinaNationalStroke 中国国界线颜色 `CountryLayer` diff --git a/packages/component/src/popup.ts b/packages/component/src/popup.ts index 08db3b0018..40ae3cf990 100644 --- a/packages/component/src/popup.ts +++ b/packages/component/src/popup.ts @@ -4,6 +4,7 @@ import { IPoint, IPopup, IPopupOption, + ISceneService, TYPES, } from '@antv/l7-core'; import { @@ -21,6 +22,7 @@ import { Container } from 'inversify'; export default class Popup extends EventEmitter implements IPopup { private popupOption: IPopupOption; private mapsService: IMapService; + private sceneSerive: ISceneService; private lngLat: ILngLat; private content: HTMLElement; private closeButton: HTMLElement; @@ -40,6 +42,7 @@ export default class Popup extends EventEmitter implements IPopup { public addTo(scene: Container) { this.mapsService = scene.get(TYPES.IMapService); + this.sceneSerive = scene.get(TYPES.ISceneService); this.mapsService.on('camerachange', this.update); this.scene = scene; this.update(); @@ -177,6 +180,7 @@ export default class Popup extends EventEmitter implements IPopup { offsets: [0, 0], anchor: anchorType.BOTTOM, className: '', + stopPropagation: true, }; } @@ -193,12 +197,12 @@ export default class Popup extends EventEmitter implements IPopup { if (!this.mapsService || !hasPosition || !this.content) { return; } - const markerContainer = this.mapsService.getMarkerContainer(); - if (!this.container && markerContainer) { + const popupContainer = this.sceneSerive.getSceneContainer(); + if (!this.container && popupContainer) { this.container = this.creatDom( 'div', 'l7-popup', - markerContainer.parentNode as HTMLElement, + popupContainer as HTMLElement, ); this.tip = this.creatDom('div', 'l7-popup-tip', this.container); @@ -208,11 +212,12 @@ export default class Popup extends EventEmitter implements IPopup { .split(' ') .forEach((name) => this.container.classList.add(name)); } - ['mousemove', 'mousedown', 'mouseup', 'click'].forEach((type) => { - this.container.addEventListener(type, (e) => { - e.stopPropagation(); - }); - }); + + // ['mousemove', 'mousedown', 'mouseup', 'click'].forEach((type) => { + // this.container.addEventListener(type, (e) => { + // e.stopPropagation(); + // }); + // }); } if (maxWidth && this.container.style.maxWidth !== maxWidth) { this.container.style.maxWidth = maxWidth; diff --git a/packages/core/src/services/component/IPopupService.ts b/packages/core/src/services/component/IPopupService.ts index b3b76996f8..c89ac44a36 100644 --- a/packages/core/src/services/component/IPopupService.ts +++ b/packages/core/src/services/component/IPopupService.ts @@ -9,6 +9,7 @@ export interface IPopupOption { anchor: anchorType[any]; className: string; offsets: number[]; + stopPropagation: boolean; } export interface IPopup { addTo(scene: Container): this; diff --git a/packages/core/src/services/layer/StyleAttributeService.ts b/packages/core/src/services/layer/StyleAttributeService.ts index ca404befab..e533b19a61 100644 --- a/packages/core/src/services/layer/StyleAttributeService.ts +++ b/packages/core/src/services/layer/StyleAttributeService.ts @@ -308,7 +308,7 @@ export default class StyleAttributeService implements IStyleAttributeService { } }); - this.attributesAndIndices.elements.destroy(); + this.attributesAndIndices?.elements.destroy(); this.attributes = []; } } diff --git a/packages/core/src/services/renderer/ITexture2D.ts b/packages/core/src/services/renderer/ITexture2D.ts index ff6eaca325..3c4cb7d3bf 100644 --- a/packages/core/src/services/renderer/ITexture2D.ts +++ b/packages/core/src/services/renderer/ITexture2D.ts @@ -80,7 +80,8 @@ export interface ITexture2DInitializationOptions { export interface ITexture2D { get(): unknown; - update(): void; + update(options: any): void; + bind(): void; resize(options: { width: number; height: number }): void; /** diff --git a/packages/l7/demo/image.html b/packages/l7/demo/image.html new file mode 100644 index 0000000000..2f7cf40075 --- /dev/null +++ b/packages/l7/demo/image.html @@ -0,0 +1,85 @@ + + + + + L7 IE + + + + +
+ + + + + + + + diff --git a/packages/layers/src/point/models/image.ts b/packages/layers/src/point/models/image.ts index 5a26e3e98f..5f878940a2 100644 --- a/packages/layers/src/point/models/image.ts +++ b/packages/layers/src/point/models/image.ts @@ -21,7 +21,7 @@ export default class ImageModel extends BaseModel { public getUninforms(): IModelUniform { const { opacity } = this.layer.getLayerConfig() as IImageLayerStyleOptions; if (this.rendererService.getDirty()) { - this.texture.update(); + this.texture.bind(); } return { u_opacity: opacity || 1.0, @@ -112,7 +112,11 @@ export default class ImageModel extends BaseModel { private updateTexture = () => { const { createTexture2D } = this.rendererService; if (this.texture) { - this.texture.destroy(); + this.texture.update({ + data: this.iconService.getCanvas(), + }); + this.layer.render(); + return; } this.texture = createTexture2D({ data: this.iconService.getCanvas(), @@ -121,6 +125,5 @@ export default class ImageModel extends BaseModel { width: 1024, height: this.iconService.canvasHeight || 128, }); - this.layer.render(); }; } diff --git a/packages/map/README.md b/packages/map/README.md new file mode 100644 index 0000000000..74ed6847c3 --- /dev/null +++ b/packages/map/README.md @@ -0,0 +1,11 @@ +# `map` + +> TODO: description + +## Usage + +``` +const map = require('map'); + +// TODO: DEMONSTRATE API +``` diff --git a/packages/map/__tests__/map.spec.ts b/packages/map/__tests__/map.spec.ts new file mode 100644 index 0000000000..84ab966d77 --- /dev/null +++ b/packages/map/__tests__/map.spec.ts @@ -0,0 +1,14 @@ +import { Map } from '../src/map'; +describe('Map', () => { + const el = document.createElement('div'); + el.id = 'test-div-id'; + // el.style.width = '500px'; + // el.style.height = '500px'; + el.style.background = '#aaa'; + document.querySelector('body')?.appendChild(el); + it('init Map', () => { + const map = new Map({ + container: el, + }); + }); +}); diff --git a/packages/map/package.json b/packages/map/package.json new file mode 100644 index 0000000000..812a827834 --- /dev/null +++ b/packages/map/package.json @@ -0,0 +1,46 @@ +{ + "name": "@antv/l7-map", + "version": "2.2.21", + "description": "l7 map", + "keywords": [], + "author": "thinkinggis ", + "license": "ISC", + "main": "lib/index.js", + "module": "es/index.js", + "unpkg": "dist/l7-map.js", + "types": "es/index.d.ts", + "sideEffects": true, + "files": [ + "dist", + "lib", + "es", + "README.md" + ], + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/antvis/L7.git" + }, + "scripts": { + "tsc": "tsc --project tsconfig.build.json", + "clean": "rimraf dist; rimraf es; rimraf lib;", + "build": "run-p build:*", + "build:cjs": "BABEL_ENV=cjs babel src --root-mode upward --out-dir lib --source-maps --extensions .ts,.tsx --delete-dir-on-start --no-comments", + "build:esm": "BABEL_ENV=esm babel src --root-mode upward --out-dir es --source-maps --extensions .ts,.tsx --delete-dir-on-start --no-comments", + "watch": "BABEL_ENV=cjs babel src --watch --root-mode upward --out-dir lib --source-maps --extensions .ts,.tsx --delete-dir-on-start --no-comments", + "test": "jest" + }, + "bugs": { + "url": "https://github.com/antvis/L7/issues" + }, + "homepage": "https://github.com/antvis/L7#readme", + "dependencies": { + "@antv/l7-utils": "2.2.14", + "@mapbox/point-geometry": "^0.1.0", + "@mapbox/unitbezier": "^0.0.0", + "eventemitter3": "^4.0.4", + "lodash": "^4.17.15" + } +} diff --git a/packages/map/src/camera.ts b/packages/map/src/camera.ts new file mode 100644 index 0000000000..9e900c9260 --- /dev/null +++ b/packages/map/src/camera.ts @@ -0,0 +1,889 @@ +// @ts-ignore +import { EventEmitter } from 'eventemitter3'; +import { merge } from 'lodash'; +import { IPaddingOptions } from './geo/edge_insets'; +import LngLat, { LngLatLike } from './geo/lng_lat'; +import LngLatBounds, { LngLatBoundsLike } from './geo/lng_lat_bounds'; +import Point, { PointLike } from './geo/point'; +import Transform from './geo/transform'; +import { Event } from './handler/events/event'; +import { IMapOptions } from './interface'; +type CallBack = (_: number) => void; +import { + cancel, + clamp, + ease as defaultEasing, + interpolate, + now, + pick, + prefersReducedMotion, + raf, + wrap, +} from './util'; + +export interface ICameraOptions { + center?: LngLatLike; + zoom?: number; + bearing?: number; + pitch?: number; + around?: LngLatLike; + padding?: IPaddingOptions; +} + +export interface IAnimationOptions { + duration?: number; + easing?: (_: number) => number; + offset?: PointLike; + animate?: boolean; + essential?: boolean; + linear?: boolean; +} + +export default class Camera extends EventEmitter { + public transform: Transform; + // public requestRenderFrame: (_: any) => number; + // public cancelRenderFrame: (_: number) => void; + protected options: IMapOptions; + protected moving: boolean; + protected zooming: boolean; + protected rotating: boolean; + protected pitching: boolean; + protected padding: boolean; + + private bearingSnap: number; + private easeEndTimeoutID: number; + private easeStart: number; + private easeOptions: { + duration: number; + easing: (_: number) => number; + }; + private easeId: string | void; + private onEaseFrame: (_: number) => void; + private onEaseEnd: (easeId?: string) => void; + private easeFrameId: number; + + constructor(options: IMapOptions) { + super(); + this.options = options; + const { minZoom, maxZoom, minPitch, maxPitch, renderWorldCopies } = options; + this.moving = false; + this.zooming = false; + this.bearingSnap = options.bearingSnap; + this.transform = new Transform( + minZoom, + maxZoom, + minPitch, + maxPitch, + renderWorldCopies, + ); + } + public requestRenderFrame(cb: CallBack): number { + return 0; + } + public cancelRenderFrame(_: number): void { + return; + } + + public getCenter() { + const { lng, lat } = this.transform.center; + return new LngLat(lng, lat); + } + + public getZoom(): number { + return this.transform.zoom; + } + + public getPitch(): number { + return this.transform.pitch; + } + + public setCenter(center: LngLatLike, eventData?: any) { + return this.jumpTo({ center }, eventData); + } + + public setPitch(pitch: number, eventData?: any) { + this.jumpTo({ pitch }, eventData); + return this; + } + + public getBearing(): number { + return this.transform.bearing; + } + + public panTo( + lnglat: LngLatLike, + options?: IAnimationOptions, + eventData?: any, + ) { + return this.easeTo( + merge( + { + center: lnglat, + }, + options, + ), + eventData, + ); + } + + public zoomOut(options?: IAnimationOptions, eventData?: any) { + this.zoomTo(this.getZoom() - 1, options, eventData); + return this; + } + + public setBearing(bearing: number, eventData?: any) { + this.jumpTo({ bearing }, eventData); + return this; + } + public setZoom(zoom: number, eventData?: any) { + this.jumpTo({ zoom }, eventData); + return this; + } + + public zoomIn(options?: IAnimationOptions, eventData?: any) { + this.zoomTo(this.getZoom() + 1, options, eventData); + return this; + } + + public zoomTo(zoom: number, options?: IAnimationOptions, eventData?: any) { + return this.easeTo( + merge( + { + zoom, + }, + options, + ), + eventData, + ); + } + + public getPadding(): IPaddingOptions { + return this.transform.padding; + } + + public setPadding(padding: IPaddingOptions, eventData?: any) { + this.jumpTo({ padding }, eventData); + return this; + } + + public rotateTo( + bearing: number, + options?: IAnimationOptions, + eventData?: any, + ) { + return this.easeTo( + merge( + { + bearing, + }, + options, + ), + eventData, + ); + } + + public resetNorth(options?: IAnimationOptions, eventData?: any) { + this.rotateTo(0, merge({ duration: 1000 }, options), eventData); + return this; + } + + public resetNorthPitch(options?: IAnimationOptions, eventData?: any) { + this.easeTo( + merge( + { + bearing: 0, + pitch: 0, + duration: 1000, + }, + options, + ), + eventData, + ); + return this; + } + public fitBounds( + bounds: LngLatBoundsLike, + options?: IAnimationOptions & ICameraOptions, + eventData?: any, + ) { + return this.fitInternal( + // @ts-ignore + this.cameraForBounds(bounds, options), + options, + eventData, + ); + } + public cameraForBounds( + bounds: LngLatBoundsLike, + options?: ICameraOptions, + ): void | (ICameraOptions & IAnimationOptions) { + bounds = LngLatBounds.convert(bounds); + return this.cameraForBoxAndBearing( + bounds.getNorthWest(), + bounds.getSouthEast(), + 0, + // @ts-ignore + options, + ); + } + + public snapToNorth(options?: IAnimationOptions, eventData?: any) { + if (Math.abs(this.getBearing()) < this.bearingSnap) { + return this.resetNorth(options, eventData); + } + return this; + } + + public jumpTo(options: ICameraOptions = {}, eventData?: any) { + this.stop(); + + const tr = this.transform; + let zoomChanged = false; + let bearingChanged = false; + let pitchChanged = false; + + if (options.zoom && tr.zoom !== +options.zoom) { + zoomChanged = true; + tr.zoom = +options.zoom; + } + + if (options.center !== undefined) { + tr.center = LngLat.convert(options.center); + } + + if (options.bearing && tr.bearing !== +options.bearing) { + bearingChanged = true; + tr.bearing = +options.bearing; + } + + if (options.pitch && tr.pitch !== +options.pitch) { + pitchChanged = true; + tr.pitch = +options.pitch; + } + + if (options.padding != null && !tr.isPaddingEqual(options.padding)) { + tr.padding = options.padding; + } + + this.emit('movestart', new Event('movestart', eventData)); + this.emit('move', new Event('move', eventData)); + + if (zoomChanged) { + this.emit('zoomstart', new Event('zoomstart', eventData)); + this.emit('zoom', new Event('zoom', eventData)); + this.emit('zoomend', new Event('zoomend', eventData)); + } + + if (bearingChanged) { + this.emit('rotatestart', new Event('rotatestart', eventData)); + this.emit('rotate', new Event('rotate', eventData)); + this.emit('rotateend', new Event('rotateend', eventData)); + } + + if (pitchChanged) { + this.emit('pitchstart', new Event('pitchstart', eventData)); + this.emit('pitch', new Event('pitch', eventData)); + this.emit('pitchend', new Event('pitchend', eventData)); + } + + return this.emit('moveend', new Event('moveend', eventData)); + } + + public easeTo( + options: ICameraOptions & + IAnimationOptions & { easeId?: string; noMoveStart?: boolean } = {}, + eventData?: any, + ) { + options = merge( + { + offset: [0, 0], + duration: 500, + easing: defaultEasing, + }, + options, + ); + + if ( + options.animate === false || + (!options.essential && prefersReducedMotion()) + ) { + options.duration = 0; + } + + const tr = this.transform; + const startZoom = this.getZoom(); + const startBearing = this.getBearing(); + const startPitch = this.getPitch(); + const startPadding = this.getPadding(); + + const zoom = options.zoom ? +options.zoom : startZoom; + const bearing = options.bearing + ? this.normalizeBearing(options.bearing, startBearing) + : startBearing; + const pitch = options.pitch ? +options.pitch : startPitch; + const padding = options.padding ? options.padding : tr.padding; + + const offsetAsPoint = Point.convert(options.offset); + let pointAtOffset = tr.centerPoint.add(offsetAsPoint); + const locationAtOffset = tr.pointLocation(pointAtOffset); + const center = LngLat.convert(options.center || locationAtOffset); + this.normalizeCenter(center); + + const from = tr.project(locationAtOffset); + const delta = tr.project(center).sub(from); + const finalScale = tr.zoomScale(zoom - startZoom); + + let around: LngLat; + let aroundPoint: Point; + + if (options.around) { + around = LngLat.convert(options.around); + aroundPoint = tr.locationPoint(around); + } + + const currently = { + moving: this.moving, + zooming: this.zooming, + rotating: this.rotating, + pitching: this.pitching, + }; + + this.zooming = this.zooming || zoom !== startZoom; + this.rotating = this.rotating || startBearing !== bearing; + this.pitching = this.pitching || pitch !== startPitch; + this.padding = !tr.isPaddingEqual(padding); + + this.easeId = options.easeId; + this.prepareEase(eventData, options.noMoveStart, currently); + + clearTimeout(this.easeEndTimeoutID); + + this.ease( + (k) => { + if (this.zooming) { + tr.zoom = interpolate(startZoom, zoom, k); + } + if (this.rotating) { + tr.bearing = interpolate(startBearing, bearing, k); + } + if (this.pitching) { + tr.pitch = interpolate(startPitch, pitch, k); + } + if (this.padding) { + tr.interpolatePadding(startPadding, padding, k); + // When padding is being applied, Transform#centerPoint is changing continously, + // thus we need to recalculate offsetPoint every fra,e + pointAtOffset = tr.centerPoint.add(offsetAsPoint); + } + + if (around) { + tr.setLocationAtPoint(around, aroundPoint); + } else { + const scale = tr.zoomScale(tr.zoom - startZoom); + const base = + zoom > startZoom + ? Math.min(2, finalScale) + : Math.max(0.5, finalScale); + const speedup = Math.pow(base, 1 - k); + const newCenter = tr.unproject( + from.add(delta.mult(k * speedup)).mult(scale), + ); + tr.setLocationAtPoint( + tr.renderWorldCopies ? newCenter.wrap() : newCenter, + pointAtOffset, + ); + } + + this.fireMoveEvents(eventData); + }, + (interruptingEaseId?: string) => { + this.afterEase(eventData, interruptingEaseId); + }, + // @ts-ignore + options, + ); + + return this; + } + public flyTo(options: any = {}, eventData?: any) { + // Fall through to jumpTo if user has set prefers-reduced-motion + if (!options.essential && prefersReducedMotion()) { + const coercedOptions = pick(options, [ + 'center', + 'zoom', + 'bearing', + 'pitch', + 'around', + ]) as ICameraOptions; + return this.jumpTo(coercedOptions, eventData); + } + + this.stop(); + + options = merge( + { + offset: [0, 0], + speed: 1.2, + curve: 1.42, + easing: defaultEasing, + }, + options, + ); + const tr = this.transform; + const startZoom = this.getZoom(); + const startBearing = this.getBearing(); + const startPitch = this.getPitch(); + const startPadding = this.getPadding(); + + const zoom = options.zoom + ? clamp(+options.zoom, tr.minZoom, tr.maxZoom) + : startZoom; + const bearing = options.bearing + ? this.normalizeBearing(options.bearing, startBearing) + : startBearing; + const pitch = options.pitch ? +options.pitch : startPitch; + const padding = 'padding' in options ? options.padding : tr.padding; + + const scale = tr.zoomScale(zoom - startZoom); + const offsetAsPoint = Point.convert(options.offset); + let pointAtOffset = tr.centerPoint.add(offsetAsPoint); + const locationAtOffset = tr.pointLocation(pointAtOffset); + const center = LngLat.convert(options.center || locationAtOffset); + this.normalizeCenter(center); + + const from = tr.project(locationAtOffset); + const delta = tr.project(center).sub(from); + + let rho = options.curve; + + // w₀: Initial visible span, measured in pixels at the initial scale. + const w0 = Math.max(tr.width, tr.height); + // w₁: Final visible span, measured in pixels with respect to the initial scale. + const w1 = w0 / scale; + // Length of the flight path as projected onto the ground plane, measured in pixels from + // the world image origin at the initial scale. + const u1 = delta.mag(); + + if ('minZoom' in options) { + const minZoom = clamp( + Math.min(options.minZoom, startZoom, zoom), + tr.minZoom, + tr.maxZoom, + ); + // wm: Maximum visible span, measured in pixels with respect to the initial + // scale. + const wMax = w0 / tr.zoomScale(minZoom - startZoom); + rho = Math.sqrt((wMax / u1) * 2); + } + + // ρ² + const rho2 = rho * rho; + + /** + * rᵢ: Returns the zoom-out factor at one end of the animation. + * + * @param i 0 for the ascent or 1 for the descent. + * @private + */ + function r(i: number) { + const b = + (w1 * w1 - w0 * w0 + (i ? -1 : 1) * rho2 * rho2 * u1 * u1) / + (2 * (i ? w1 : w0) * rho2 * u1); + return Math.log(Math.sqrt(b * b + 1) - b); + } + + function sinh(n: number) { + return (Math.exp(n) - Math.exp(-n)) / 2; + } + function cosh(n: number) { + return (Math.exp(n) + Math.exp(-n)) / 2; + } + function tanh(n: number) { + return sinh(n) / cosh(n); + } + + // r₀: Zoom-out factor during ascent. + const r0 = r(0); + + // w(s): Returns the visible span on the ground, measured in pixels with respect to the + // initial scale. Assumes an angular field of view of 2 arctan ½ ≈ 53°. + let w: (_: number) => number = (s) => { + return cosh(r0) / cosh(r0 + rho * s); + }; + + // u(s): Returns the distance along the flight path as projected onto the ground plane, + // measured in pixels from the world image origin at the initial scale. + let u: (_: number) => number = (s) => { + return (w0 * ((cosh(r0) * tanh(r0 + rho * s) - sinh(r0)) / rho2)) / u1; + }; + + // S: Total length of the flight path, measured in ρ-screenfuls. + let S = (r(1) - r0) / rho; + + // When u₀ = u₁, the optimal path doesn’t require both ascent and descent. + if (Math.abs(u1) < 0.000001 || !isFinite(S)) { + // Perform a more or less instantaneous transition if the path is too short. + if (Math.abs(w0 - w1) < 0.000001) { + return this.easeTo(options, eventData); + } + + const k = w1 < w0 ? -1 : 1; + S = Math.abs(Math.log(w1 / w0)) / rho; + + u = () => { + return 0; + }; + w = (s) => { + return Math.exp(k * rho * s); + }; + } + + if ('duration' in options) { + options.duration = +options.duration; + } else { + const V = + 'screenSpeed' in options ? +options.screenSpeed / rho : +options.speed; + options.duration = (1000 * S) / V; + } + + if (options.maxDuration && options.duration > options.maxDuration) { + options.duration = 0; + } + + this.zooming = true; + this.rotating = startBearing !== bearing; + this.pitching = pitch !== startPitch; + this.padding = !tr.isPaddingEqual(padding); + + this.prepareEase(eventData, false); + + this.ease( + (k) => { + // s: The distance traveled along the flight path, measured in ρ-screenfuls. + const s = k * S; + // @ts-ignore + const easeScale = 1 / w(s); + tr.zoom = k === 1 ? zoom : startZoom + tr.scaleZoom(easeScale); + + if (this.rotating) { + tr.bearing = interpolate(startBearing, bearing, k); + } + if (this.pitching) { + tr.pitch = interpolate(startPitch, pitch, k); + } + if (this.padding) { + tr.interpolatePadding(startPadding, padding, k); + // When padding is being applied, Transform#centerPoint is changing continously, + // thus we need to recalculate offsetPoint every frame + pointAtOffset = tr.centerPoint.add(offsetAsPoint); + } + + const newCenter = + k === 1 + ? center + : tr.unproject(from.add(delta.mult(u(s))).mult(easeScale)); + tr.setLocationAtPoint( + tr.renderWorldCopies ? newCenter.wrap() : newCenter, + pointAtOffset, + ); + + this.fireMoveEvents(eventData); + }, + () => this.afterEase(eventData), + options, + ); + + return this; + } + public fitScreenCoordinates( + p0: PointLike, + p1: PointLike, + bearing: number, + options?: IAnimationOptions & ICameraOptions, + eventData?: any, + ) { + return this.fitInternal( + // @ts-ignore + this.cameraForBoxAndBearing( + this.transform.pointLocation(Point.convert(p0)), + this.transform.pointLocation(Point.convert(p1)), + bearing, + // @ts-ignore + options, + ), + options, + eventData, + ); + } + public stop(allowGestures?: boolean, easeId?: string) { + if (this.easeFrameId) { + this.cancelRenderFrame(this.easeFrameId); + delete this.easeFrameId; + delete this.onEaseFrame; + } + + if (this.onEaseEnd) { + // The _onEaseEnd function might emit events which trigger new + // animation, which sets a new _onEaseEnd. Ensure we don't delete + // it unintentionally. + const onEaseEnd = this.onEaseEnd; + delete this.onEaseEnd; + onEaseEnd.call(this, easeId); + } + // if (!allowGestures) { + // const handlers = (this: any).handlers; + // if (handlers) handlers.stop(); + // } + return this; + } + public renderFrameCallback = () => { + const t = Math.min((now() - this.easeStart) / this.easeOptions.duration, 1); + this.onEaseFrame(this.easeOptions.easing(t)); + if (t < 1) { + // this.easeFrameId = window.requestAnimationFrame(this.renderFrameCallback); + this.easeFrameId = this.requestRenderFrame(this.renderFrameCallback); + } else { + this.stop(); + } + }; + private normalizeBearing(bearing: number, currentBearing: number) { + bearing = wrap(bearing, -180, 180); + const diff = Math.abs(bearing - currentBearing); + if (Math.abs(bearing - 360 - currentBearing) < diff) { + bearing -= 360; + } + if (Math.abs(bearing + 360 - currentBearing) < diff) { + bearing += 360; + } + return bearing; + } + + private normalizeCenter(center: LngLat) { + const tr = this.transform; + if (!tr.renderWorldCopies || tr.lngRange) { + return; + } + + const delta = center.lng - tr.center.lng; + center.lng += delta > 180 ? -360 : delta < -180 ? 360 : 0; + } + + private fireMoveEvents(eventData?: any) { + this.emit('move', new Event('move', eventData)); + if (this.zooming) { + this.emit('zoom', new Event('zoom', eventData)); + } + if (this.rotating) { + this.emit('rotate', new Event('rotate', eventData)); + } + if (this.pitching) { + this.emit('rotate', new Event('pitch', eventData)); + } + } + private prepareEase( + eventData: object | undefined, + noMoveStart: boolean = false, + currently: { [key: string]: boolean } = {}, + ) { + this.moving = true; + + if (!noMoveStart && !currently.moving) { + this.emit('movestart', new Event('movestart', eventData)); + } + if (this.zooming && !currently.zooming) { + this.emit('zoomstart', new Event('zoomstart', eventData)); + } + if (this.rotating && !currently.rotating) { + this.emit('rotatestart', new Event('rotatestart', eventData)); + } + if (this.pitching && !currently.pitching) { + this.emit('pitchstart', new Event('pitchstart', eventData)); + } + } + + private afterEase(eventData: object | undefined, easeId?: string) { + // if this easing is being stopped to start another easing with + // the same id then don't fire any events to avoid extra start/stop events + if (this.easeId && easeId && this.easeId === easeId) { + return; + } + delete this.easeId; + + const wasZooming = this.zooming; + const wasRotating = this.rotating; + const wasPitching = this.pitching; + this.moving = false; + this.zooming = false; + this.rotating = false; + this.pitching = false; + this.padding = false; + + if (wasZooming) { + this.emit('zoomend', new Event('zoomend', eventData)); + } + if (wasRotating) { + this.emit('rotateend', new Event('rotateend', eventData)); + } + if (wasPitching) { + this.emit('pitchend', new Event('pitchend', eventData)); + } + this.emit('moveend', new Event('moveend', eventData)); + } + + private ease( + frame: (_: number) => void, + finish: () => void, + options: { + animate: boolean; + duration: number; + easing: (_: number) => number; + }, + ) { + if (options.animate === false || options.duration === 0) { + frame(1); + finish(); + } else { + this.easeStart = now(); + this.easeOptions = options; + this.onEaseFrame = frame; + this.onEaseEnd = finish; + this.easeFrameId = this.requestRenderFrame(this.renderFrameCallback); + } + } + + private cameraForBoxAndBearing( + p0: LngLatLike, + p1: LngLatLike, + bearing: number, + options?: ICameraOptions & { + offset: [number, number]; + maxZoom: number; + padding: IPaddingOptions; + }, + ): void | (ICameraOptions & IAnimationOptions) { + const defaultPadding = { + top: 0, + bottom: 0, + right: 0, + left: 0, + }; + options = merge( + { + padding: defaultPadding, + offset: [0, 0], + maxZoom: this.transform.maxZoom, + }, + options, + ); + + if (typeof options.padding === 'number') { + const p = options.padding; + options.padding = { + top: p, + bottom: p, + right: p, + left: p, + }; + } + + options.padding = merge(defaultPadding, options.padding); + const tr = this.transform; + const edgePadding = tr.padding as IPaddingOptions; + + // We want to calculate the upper right and lower left of the box defined by p0 and p1 + // in a coordinate system rotate to match the destination bearing. + const p0world = tr.project(LngLat.convert(p0)); + const p1world = tr.project(LngLat.convert(p1)); + const p0rotated = p0world.rotate((-bearing * Math.PI) / 180); + const p1rotated = p1world.rotate((-bearing * Math.PI) / 180); + + const upperRight = new Point( + Math.max(p0rotated.x, p1rotated.x), + Math.max(p0rotated.y, p1rotated.y), + ); + const lowerLeft = new Point( + Math.min(p0rotated.x, p1rotated.x), + Math.min(p0rotated.y, p1rotated.y), + ); + + // Calculate zoom: consider the original bbox and padding. + const size = upperRight.sub(lowerLeft); + const scaleX = + (tr.width - + // @ts-ignore + (edgePadding.left + + // @ts-ignore + edgePadding.right + + // @ts-ignore + options.padding.left + + // @ts-ignore + options.padding.right)) / + size.x; + const scaleY = + (tr.height - + // @ts-ignore + (edgePadding.top + + // @ts-ignore + edgePadding.bottom + + // @ts-ignore + options.padding.top + + // @ts-ignore + options.padding.bottom)) / + size.y; + + if (scaleY < 0 || scaleX < 0) { + return; + } + + const zoom = Math.min( + tr.scaleZoom(tr.scale * Math.min(scaleX, scaleY)), + options.maxZoom, + ); + + // Calculate center: apply the zoom, the configured offset, as well as offset that exists as a result of padding. + const offset = Point.convert(options.offset); + // @ts-ignore + const paddingOffsetX = (options.padding.left - options.padding.right) / 2; + // @ts-ignore + const paddingOffsetY = (options.padding.top - options.padding.bottom) / 2; + const offsetAtInitialZoom = new Point( + offset.x + paddingOffsetX, + offset.y + paddingOffsetY, + ); + const offsetAtFinalZoom = offsetAtInitialZoom.mult( + tr.scale / tr.zoomScale(zoom), + ); + + const center = tr.unproject( + p0world + .add(p1world) + .div(2) + .sub(offsetAtFinalZoom), + ); + + return { + center, + zoom, + bearing, + }; + } + + private fitInternal( + calculatedOptions?: ICameraOptions & IAnimationOptions, + options?: IAnimationOptions & ICameraOptions, + eventData?: any, + ) { + // cameraForBounds warns + returns undefined if unable to fit: + if (!calculatedOptions) { + return this; + } + + options = merge(calculatedOptions, options); + // Explictly remove the padding field because, calculatedOptions already accounts for padding by setting zoom and center accordingly. + delete options.padding; + // @ts-ignore + return options.linear + ? this.easeTo(options, eventData) + : this.flyTo(options, eventData); + } +} diff --git a/packages/map/src/css/l7.css b/packages/map/src/css/l7.css new file mode 100644 index 0000000000..1d9f184b51 --- /dev/null +++ b/packages/map/src/css/l7.css @@ -0,0 +1,98 @@ +.l7-map { + font: 12px/20px 'Helvetica Neue', Arial, Helvetica, sans-serif; + overflow: hidden; + position: relative; + -webkit-tap-highlight-color: rgba(0, 0, 0, 0); +} + +.l7-canvas { + position: absolute; + left: 0; + top: 0; +} + +.l7-map:-webkit-full-screen { + width: 100%; + height: 100%; +} + +.l7-canary { + background-color: salmon; +} + +.l7-canvas-container.l7-interactive, +.l7-ctrl-group button.l7-ctrl-compass { + cursor: -webkit-grab; + cursor: -moz-grab; + cursor: grab; + -moz-user-select: none; + -webkit-user-select: none; + -ms-user-select: none; + user-select: none; +} + +.l7-canvas-container.l7-interactive.l7-track-pointer { + cursor: pointer; +} + +.l7-canvas-container.l7-interactive:active, +.l7-ctrl-group button.l7-ctrl-compass:active { + cursor: -webkit-grabbing; + cursor: -moz-grabbing; + cursor: grabbing; +} + +.l7-canvas-container.l7-touch-zoom-rotate, +.l7-canvas-container.l7-touch-zoom-rotate .l7-canvas { + touch-action: pan-x pan-y; +} + +.l7-canvas-container.l7-touch-drag-pan, +.l7-canvas-container.l7-touch-drag-pan .l7-canvas { + touch-action: pinch-zoom; +} + +.l7-canvas-container.l7-touch-zoom-rotate.l7-touch-drag-pan, +.l7-canvas-container.l7-touch-zoom-rotate.l7-touch-drag-pan .l7-canvas { + touch-action: none; +} + +.l7-ctrl-top-left, +.l7-ctrl-top-right, +.l7-ctrl-bottom-left, +.l7-ctrl-bottom-right { position: absolute; pointer-events: none; z-index: 2; } +.l7-ctrl-top-left { top: 0; left: 0; } +.l7-ctrl-top-right { top: 0; right: 0; } +.l7-ctrl-bottom-left { bottom: 0; left: 0; } +.l7-ctrl-bottom-right { right: 0; bottom: 0; } + +.l7-ctrl { + clear: both; + pointer-events: auto; + + /* workaround for a Safari bug https://github.com/mapbox/mapbox-gl-js/issues/8185 */ + transform: translate(0, 0); +} +.l7-ctrl-top-left .l7-ctrl { margin: 10px 0 0 10px; float: left; } +.l7-ctrl-top-right .l7-ctrl { margin: 10px 10px 0 0; float: right; } +.l7-ctrl-bottom-left .l7-ctrl { margin: 0 0 10px 10px; float: left; } +.l7-ctrl-bottom-right .l7-ctrl { margin: 0 10px 10px 0; float: right; } + + +.l7-crosshair, +.l7-crosshair .l7-interactive, +.l7-crosshair .l7-interactive:active { + cursor: crosshair; +} + +.l7-boxzoom { + position: absolute; + top: 0; + left: 0; + width: 0; + height: 0; + background: #fff; + border: 2px dotted #202020; + opacity: 0.5; + z-index: 10; +} diff --git a/packages/map/src/geo/edge_insets.ts b/packages/map/src/geo/edge_insets.ts new file mode 100644 index 0000000000..dc6aa3ae92 --- /dev/null +++ b/packages/map/src/geo/edge_insets.ts @@ -0,0 +1,130 @@ +// @ts-ignore +import { clamp, interpolate } from '../util'; +import Point from './point'; + +/** + * An `EdgeInset` object represents screen space padding applied to the edges of the viewport. + * This shifts the apprent center or the vanishing point of the map. This is useful for adding floating UI elements + * on top of the map and having the vanishing point shift as UI elements resize. + * + * @param {number} [top=0] + * @param {number} [bottom=0] + * @param {number} [left=0] + * @param {number} [right=0] + */ +export default class EdgeInsets { + public top: number; + public bottom: number; + public left: number; + public right: number; + + constructor( + top: number = 0, + bottom: number = 0, + left: number = 0, + right: number = 0, + ) { + if ( + isNaN(top) || + top < 0 || + isNaN(bottom) || + bottom < 0 || + isNaN(left) || + left < 0 || + isNaN(right) || + right < 0 + ) { + throw new Error( + 'Invalid value for edge-insets, top, bottom, left and right must all be numbers', + ); + } + + this.top = top; + this.bottom = bottom; + this.left = left; + this.right = right; + } + + /** + * Interpolates the inset in-place. + * This maintains the current inset value for any inset not present in `target`. + * + * @param {PaddingOptions} target + * @param {number} t + * @returns {EdgeInsets} + * @memberof EdgeInsets + */ + public interpolate( + start: IPaddingOptions | EdgeInsets, + target: IPaddingOptions, + t: number, + ): EdgeInsets { + if (target.top != null && start.top != null) { + this.top = interpolate(start.top, target.top, t); + } + if (target.bottom != null && start.bottom != null) { + this.bottom = interpolate(start.bottom, target.bottom, t); + } + if (target.left != null && start.left != null) { + this.left = interpolate(start.left, target.left, t); + } + if (target.right != null && start.right != null) { + this.right = interpolate(start.right, target.right, t); + } + + return this; + } + + /** + * Utility method that computes the new apprent center or vanishing point after applying insets. + * This is in pixels and with the top left being (0.0) and +y being downwards. + * + * @param {number} width + * @param {number} height + * @returns {Point} + * @memberof EdgeInsets + */ + public getCenter(width: number, height: number): Point { + // Clamp insets so they never overflow width/height and always calculate a valid center + const x = clamp((this.left + width - this.right) / 2, 0, width); + const y = clamp((this.top + height - this.bottom) / 2, 0, height); + + return new Point(x, y); + } + + public equals(other: IPaddingOptions): boolean { + return ( + this.top === other.top && + this.bottom === other.bottom && + this.left === other.left && + this.right === other.right + ); + } + + public clone(): EdgeInsets { + return new EdgeInsets(this.top, this.bottom, this.left, this.right); + } + + /** + * Returns the current sdtate as json, useful when you want to have a + * read-only representation of the inset. + * + * @returns {PaddingOptions} + * @memberof EdgeInsets + */ + public toJSON(): IPaddingOptions { + return { + top: this.top, + bottom: this.bottom, + left: this.left, + right: this.right, + }; + } +} + +export interface IPaddingOptions { + top?: number; + bottom?: number; + right?: number; + left?: number; +} diff --git a/packages/map/src/geo/lng_lat.ts b/packages/map/src/geo/lng_lat.ts new file mode 100644 index 0000000000..347ca9949d --- /dev/null +++ b/packages/map/src/geo/lng_lat.ts @@ -0,0 +1,75 @@ +import { wrap } from '../util'; +import LngLatBounds from './lng_lat_bounds'; +export const earthRadius = 6371008.8; +export type LngLatLike = + | LngLat + | { lng: number; lat: number } + | { lon: number; lat: number } + | [number, number]; + +export default class LngLat { + public static convert(input: LngLatLike): LngLat { + if (input instanceof LngLat) { + return input; + } + if (Array.isArray(input) && (input.length === 2 || input.length === 3)) { + return new LngLat(Number(input[0]), Number(input[1])); + } + if (!Array.isArray(input) && typeof input === 'object' && input !== null) { + const lng = 'lng' in input ? input.lng : input.lon; + return new LngLat( + // flow can't refine this to have one of lng or lat, so we have to cast to any + Number(lng), + Number(input.lat), + ); + } + throw new Error( + '`LngLatLike` argument must be specified as a LngLat instance, an object {lng: , lat: }, an object {lon: , lat: }, or an array of [, ]', + ); + } + public lng: number; + public lat: number; + constructor(lng: number, lat: number) { + if (isNaN(lng) || isNaN(lat)) { + throw new Error(`Invalid LngLat object: (${lng}, ${lat})`); + } + this.lng = +lng; + this.lat = +lat; + if (this.lat > 90 || this.lat < -90) { + throw new Error( + 'Invalid LngLat latitude value: must be between -90 and 90', + ); + } + } + + public wrap() { + return new LngLat(wrap(this.lng, -180, 180), this.lat); + } + public toArray(): [number, number] { + return [this.lng, this.lat]; + } + public toBounds(radius: number = 0) { + const earthCircumferenceInMetersAtEquator = 40075017; + const latAccuracy = (360 * radius) / earthCircumferenceInMetersAtEquator; + const lngAccuracy = latAccuracy / Math.cos((Math.PI / 180) * this.lat); + + return new LngLatBounds( + new LngLat(this.lng - lngAccuracy, this.lat - latAccuracy), + new LngLat(this.lng + lngAccuracy, this.lat + latAccuracy), + ); + } + public toString() { + return `LngLat(${this.lng}, ${this.lat})`; + } + public distanceTo(lngLat: LngLat) { + const rad = Math.PI / 180; + const lat1 = this.lat * rad; + const lat2 = lngLat.lat * rad; + const a = + Math.sin(lat1) * Math.sin(lat2) + + Math.cos(lat1) * Math.cos(lat2) * Math.cos((lngLat.lng - this.lng) * rad); + + const maxMeters = earthRadius * Math.acos(Math.min(a, 1)); + return maxMeters; + } +} diff --git a/packages/map/src/geo/lng_lat_bounds.ts b/packages/map/src/geo/lng_lat_bounds.ts new file mode 100644 index 0000000000..7d1031326f --- /dev/null +++ b/packages/map/src/geo/lng_lat_bounds.ts @@ -0,0 +1,142 @@ +import LngLat, { LngLatLike } from './lng_lat'; +export type LngLatBoundsLike = + | LngLatBounds + | [LngLatLike, LngLatLike] + | [number, number, number, number]; +export default class LngLatBounds { + public static convert(input: LngLatBoundsLike): LngLatBounds { + if (input instanceof LngLatBounds) { + return input; + } + return new LngLatBounds(input); + } + private ne: LngLat; + private sw: LngLat; + constructor(sw?: any, ne?: any) { + if (!sw) { + // noop + } else if (ne) { + this.setSouthWest(sw).setNorthEast(ne); + } else if (sw.length === 4) { + this.setSouthWest([sw[0], sw[1]]).setNorthEast([sw[2], sw[3]]); + } else { + this.setSouthWest(sw[0]).setNorthEast(sw[1]); + } + } + + public setNorthEast(ne: LngLatLike) { + this.ne = + ne instanceof LngLat ? new LngLat(ne.lng, ne.lat) : LngLat.convert(ne); + return this; + } + public setSouthWest(sw: LngLatLike) { + this.sw = + sw instanceof LngLat ? new LngLat(sw.lng, sw.lat) : LngLat.convert(sw); + return this; + } + + public extend(obj: LngLatLike | LngLatBoundsLike): this { + const sw = this.sw; + const ne = this.ne; + let sw2: any; + let ne2: any; + + if (obj instanceof LngLat) { + sw2 = obj; + ne2 = obj; + } else if (obj instanceof LngLatBounds) { + sw2 = obj.sw; + ne2 = obj.ne; + + if (!sw2 || !ne2) { + return this; + } + } else { + if (Array.isArray(obj)) { + if (obj.length === 4 || obj.every(Array.isArray)) { + const lngLatBoundsObj = obj as LngLatBoundsLike; + return this.extend(LngLatBounds.convert(lngLatBoundsObj)); + } else { + const lngLatObj = obj as LngLatLike; + return this.extend(LngLat.convert(lngLatObj)); + } + } + return this; + } + + if (!sw && !ne) { + this.sw = new LngLat(sw2.lng, sw2.lat); + this.ne = new LngLat(ne2.lng, ne2.lat); + } else { + sw.lng = Math.min(sw2.lng, sw.lng); + sw.lat = Math.min(sw2.lat, sw.lat); + ne.lng = Math.max(ne2.lng, ne.lng); + ne.lat = Math.max(ne2.lat, ne.lat); + } + + return this; + } + public getCenter(): LngLat { + return new LngLat( + (this.sw.lng + this.ne.lng) / 2, + (this.sw.lat + this.ne.lat) / 2, + ); + } + + public getSouthWest(): LngLat { + return this.sw; + } + + public getNorthEast(): LngLat { + return this.ne; + } + + public getNorthWest(): LngLat { + return new LngLat(this.getWest(), this.getNorth()); + } + + public getSouthEast(): LngLat { + return new LngLat(this.getEast(), this.getSouth()); + } + + public getWest(): number { + return this.sw.lng; + } + + public getSouth(): number { + return this.sw.lat; + } + + public getEast(): number { + return this.ne.lng; + } + + public getNorth(): number { + return this.ne.lat; + } + + public toArray(): [[number, number], [number, number]] { + return [this.sw.toArray(), this.ne.toArray()]; + } + + public toString() { + return `LngLatBounds(${this.sw.toString()}, ${this.ne.toString()})`; + } + + public isEmpty() { + return !(this.sw && this.ne); + } + + public contains(lnglat: LngLatLike) { + const { lng, lat } = LngLat.convert(lnglat); + + const containsLatitude = this.sw.lat <= lat && lat <= this.ne.lat; + let containsLongitude = this.sw.lng <= lng && lng <= this.ne.lng; + if (this.sw.lng > this.ne.lng) { + // wrapped coordinates + containsLongitude = this.sw.lng >= lng && lng >= this.ne.lng; + } + + return containsLatitude && containsLongitude; + } +} diff --git a/packages/map/src/geo/mercator.ts b/packages/map/src/geo/mercator.ts new file mode 100644 index 0000000000..52dff815b5 --- /dev/null +++ b/packages/map/src/geo/mercator.ts @@ -0,0 +1,91 @@ +import LngLat, { earthRadius, LngLatLike } from '../geo/lng_lat'; + +/* + * The average circumference of the world in meters. + */ +const earthCircumfrence = 2 * Math.PI * earthRadius; // meters + +/* + * The circumference at a line of latitude in meters. + */ +function circumferenceAtLatitude(latitude: number) { + return earthCircumfrence * Math.cos((latitude * Math.PI) / 180); +} + +export function mercatorXfromLng(lng: number) { + return (180 + lng) / 360; +} + +export function mercatorYfromLat(lat: number) { + return ( + (180 - + (180 / Math.PI) * + Math.log(Math.tan(Math.PI / 4 + (lat * Math.PI) / 360))) / + 360 + ); +} + +export function mercatorZfromAltitude(altitude: number, lat: number) { + return altitude / circumferenceAtLatitude(lat); +} + +export function lngFromMercatorX(x: number) { + return x * 360 - 180; +} + +export function latFromMercatorY(y: number) { + const y2 = 180 - y * 360; + return (360 / Math.PI) * Math.atan(Math.exp((y2 * Math.PI) / 180)) - 90; +} + +export function altitudeFromMercatorZ(z: number, y: number) { + return z * circumferenceAtLatitude(latFromMercatorY(y)); +} + +/** + * Determine the Mercator scale factor for a given latitude, see + * https://en.wikipedia.org/wiki/Mercator_projection#Scale_factor + * + * At the equator the scale factor will be 1, which increases at higher latitudes. + * + * @param {number} lat Latitude + * @returns {number} scale factor + * @private + */ +export function mercatorScale(lat: number) { + return 1 / Math.cos((lat * Math.PI) / 180); +} + +export default class MercatorCoordinate { + public static fromLngLat(lngLatLike: LngLatLike, altitude: number = 0) { + const lngLat = LngLat.convert(lngLatLike); + + return new MercatorCoordinate( + mercatorXfromLng(lngLat.lng), + mercatorYfromLat(lngLat.lat), + mercatorZfromAltitude(altitude, lngLat.lat), + ); + } + public x: number; + public y: number; + public z: number; + + constructor(x: number, y: number, z: number = 0) { + this.x = +x; + this.y = +y; + this.z = +z; + } + + public toLngLat() { + return new LngLat(lngFromMercatorX(this.x), latFromMercatorY(this.y)); + } + + public toAltitude() { + return altitudeFromMercatorZ(this.z, this.y); + } + + public meterInMercatorCoordinateUnits() { + // 1 meter / circumference at equator in meters * Mercator projection scale factor at this latitude + return (1 / earthCircumfrence) * mercatorScale(latFromMercatorY(this.y)); + } +} diff --git a/packages/map/src/geo/point.ts b/packages/map/src/geo/point.ts new file mode 100644 index 0000000000..d3a9e8565c --- /dev/null +++ b/packages/map/src/geo/point.ts @@ -0,0 +1,183 @@ +export type PointLike = [number, number] | Point; + +export default class Point { + public static convert(a: any) { + if (a instanceof Point) { + return a; + } + if (Array.isArray(a)) { + return new Point(a[0], a[1]); + } + return a; + } + public x: number; + public y: number; + + constructor(x: number, y: number) { + this.x = x; + this.y = y; + } + + public clone() { + return new Point(this.x, this.y); + } + + public _add(p: Point) { + this.x += p.x; + this.y += p.y; + return this; + } + + public add(p: Point) { + return this.clone()._add(p); + } + + public _sub(p: Point) { + this.x -= p.x; + this.y -= p.y; + return this; + } + public sub(p: Point) { + return this.clone()._sub(p); + } + + public _multByPoint(p: Point) { + this.x *= p.x; + this.y *= p.y; + return this; + } + + public multByPoint(p: Point) { + return this.clone()._multByPoint(p); + } + + public _divByPoint(p: Point) { + this.x /= p.x; + this.y /= p.y; + return this; + } + public divByPoint(p: Point) { + return this.clone()._divByPoint(p); + } + + public _mult(k: number) { + this.x *= k; + this.y *= k; + return this; + } + + public mult(k: number) { + return this.clone()._mult(k); + } + + public _div(k: number) { + this.x /= k; + this.y /= k; + return this; + } + + public div(k: number) { + return this.clone()._div(k); + } + + public _rotate(angle: number) { + const cos = Math.cos(angle); + const sin = Math.sin(angle); + const x = cos * this.x - sin * this.y; + const y = sin * this.x + cos * this.y; + this.x = x; + this.y = y; + return this; + } + + public rotate(angle: number) { + return this.clone()._rotate(angle); + } + + public _rotateAround(angle: number, p: Point) { + const cos = Math.cos(angle); + const sin = Math.sin(angle); + const x = p.x + cos * (this.x - p.x) - sin * (this.y - p.y); + const y = p.y + sin * (this.x - p.x) + cos * (this.y - p.y); + this.x = x; + this.y = y; + return this; + } + public roateAround(angle: number, p: Point) { + return this.clone()._rotateAround(angle, p); + } + + public _matMult(m: number[]) { + const x = m[0] * this.x + m[1] * this.y; + const y = m[2] * this.x + m[3] * this.y; + this.x = x; + this.y = y; + return this; + } + + public matMult(m: number[]) { + return this.clone()._matMult(m); + } + + public _unit() { + this.div(this.mag()); + return this; + } + public unit() { + return this.clone()._unit(); + } + + public _perp() { + const y = this.y; + this.y = this.x; + this.x = -y; + return this; + } + public perp() { + return this.clone()._perp(); + } + + public _round() { + this.x = Math.round(this.x); + this.y = Math.round(this.y); + return this; + } + + public round() { + return this.clone()._round(); + } + + public mag() { + return Math.sqrt(this.x * this.x + this.y * this.y); + } + + public equals(other: Point) { + return this.x === other.x && this.y === other.y; + } + + public dist(p: Point) { + return Math.sqrt(this.distSqr(p)); + } + + public distSqr(p: Point) { + const dx = p.x - this.x; + const dy = p.y - this.y; + return dx * dx + dy * dy; + } + + public angle() { + return Math.atan2(this.y, this.x); + } + + public angleTo(b: Point) { + return Math.atan2(this.y - b.y, this.x - b.x); + } + + public angleWith(b: Point) { + return this.angleWithSep(b.x, b.y); + } + + public angleWithSep(x: number, y: number) { + return Math.atan2(this.x * y - this.y * x, this.x * x + this.y * y); + } +} diff --git a/packages/map/src/geo/simple.ts b/packages/map/src/geo/simple.ts new file mode 100644 index 0000000000..7fc3e9ada1 --- /dev/null +++ b/packages/map/src/geo/simple.ts @@ -0,0 +1,84 @@ +import LngLat, { earthRadius, LngLatLike } from '../geo/lng_lat'; + +/* + * The average circumference of the world in meters. + */ +const earthCircumfrence = 2 * Math.PI * earthRadius; // meters + +/* + * The circumference at a line of latitude in meters. + */ +function circumferenceAtLatitude(latitude: number) { + return earthCircumfrence * Math.cos((latitude * Math.PI) / 180); +} + +export function mercatorXfromLng(lng: number) { + return lng; +} + +export function mercatorYfromLat(lat: number) { + return lat; +} + +export function mercatorZfromAltitude(altitude: number, lat: number) { + return altitude; +} + +export function lngFromMercatorX(x: number) { + return x; +} + +export function latFromMercatorY(y: number) { + return y; +} + +export function altitudeFromMercatorZ(z: number, y: number) { + return z; +} + +/** + * Determine the Mercator scale factor for a given latitude, see + * https://en.wikipedia.org/wiki/Mercator_projection#Scale_factor + * + * At the equator the scale factor will be 1, which increases at higher latitudes. + * + * @param {number} lat Latitude + * @returns {number} scale factor + * @private + */ +export function mercatorScale(lat: number) { + return 1; +} + +export default class SimpleCoordinate { + public static fromLngLat(lngLatLike: LngLatLike, altitude: number = 0) { + const lngLat = LngLat.convert(lngLatLike); + + return new SimpleCoordinate( + mercatorXfromLng(lngLat.lng), + mercatorYfromLat(lngLat.lat), + mercatorZfromAltitude(altitude, lngLat.lat), + ); + } + public x: number; + public y: number; + public z: number; + + constructor(x: number, y: number, z: number = 0) { + this.x = +x; + this.y = +y; + this.z = +z; + } + public toLngLat() { + return new LngLat(this.x, this.y); + } + + public toAltitude() { + return this.z; + } + + public meterInMercatorCoordinateUnits() { + // 1 meter / circumference at equator in meters * Mercator projection scale factor at this latitude + return 1; + } +} diff --git a/packages/map/src/geo/transform.ts b/packages/map/src/geo/transform.ts new file mode 100644 index 0000000000..20cd68af7c --- /dev/null +++ b/packages/map/src/geo/transform.ts @@ -0,0 +1,1021 @@ +// @ts-ignore +import { mat2, mat4, vec3, vec4 } from 'gl-matrix'; +import Point, { PointLike } from '../geo/point'; +import { clamp, interpolate, wrap } from '../util'; +import Aabb from '../utils/Aabb'; +import Frustum from '../utils/primitives'; +import EdgeInsets, { IPaddingOptions } from './edge_insets'; +import LngLat from './lng_lat'; +import LngLatBounds from './lng_lat_bounds'; +import MercatorCoordinate, { + mercatorXfromLng, + mercatorYfromLat, + mercatorZfromAltitude, +} from './mercator'; +export const EXTENT = 8192; +export default class Transform { + get minZoom(): number { + return this._minZoom; + } + set minZoom(zoom: number) { + if (this._minZoom === zoom) { + return; + } + this._minZoom = zoom; + this.zoom = Math.max(this.zoom, zoom); + } + + get maxZoom(): number { + return this._maxZoom; + } + set maxZoom(zoom: number) { + if (this._maxZoom === zoom) { + return; + } + this._maxZoom = zoom; + this.zoom = Math.min(this.zoom, zoom); + } + + get minPitch(): number { + return this._minPitch; + } + set minPitch(pitch: number) { + if (this._minPitch === pitch) { + return; + } + this._minPitch = pitch; + this._pitch = Math.max(this._pitch, pitch); + } + + get maxPitch(): number { + return this._maxPitch; + } + set maxPitch(pitch: number) { + if (this._maxPitch === pitch) { + return; + } + this._maxPitch = pitch; + this._pitch = Math.min(this._pitch, pitch); + } + + get renderWorldCopies(): boolean { + return this._renderWorldCopies; + } + set renderWorldCopies(renderWorldCopies: boolean) { + if (renderWorldCopies === undefined) { + renderWorldCopies = true; + } else if (renderWorldCopies === null) { + renderWorldCopies = false; + } + + this._renderWorldCopies = renderWorldCopies; + } + + get worldSize(): number { + return this.tileSize * this.scale; + } + + get centerOffset(): Point { + return this.centerPoint._sub(this.size._div(2)); + } + + get size(): Point { + return new Point(this.width, this.height); + } + + get bearing(): number { + return (-this.angle / Math.PI) * 180; + } + set bearing(bearing: number) { + const b = (-wrap(bearing, -180, 180) * Math.PI) / 180; + if (this.angle === b) { + return; + } + this.unmodified = false; + this.angle = b; + this.calcMatrices(); + + // 2x2 matrix for rotating points + this.rotationMatrix = mat2.create(); + mat2.rotate(this.rotationMatrix, this.rotationMatrix, this.angle); + } + + get pitch(): number { + return (this._pitch / Math.PI) * 180; + } + set pitch(pitch: number) { + const p = (clamp(pitch, this._minPitch, this._maxPitch) / 180) * Math.PI; + if (this._pitch === p) { + return; + } + this.unmodified = false; + this._pitch = p; + this.calcMatrices(); + } + + get fov(): number { + return (this._fov / Math.PI) * 180; + } + + set fov(fov: number) { + fov = Math.max(0.01, Math.min(60, fov)); + if (this._fov === fov) { + return; + } + this.unmodified = false; + this._fov = (fov / 180) * Math.PI; + this.calcMatrices(); + } + + get zoom(): number { + return this._zoom; + } + + set zoom(zoom: number) { + const z = Math.min(Math.max(zoom, this._minZoom), this._maxZoom); + if (this._zoom === z) { + return; + } + this.unmodified = false; + this._zoom = z; + this.scale = this.zoomScale(z); + this.tileZoom = Math.floor(z); + this.zoomFraction = z - this.tileZoom; + this.constrain(); + this.calcMatrices(); + } + + get center(): LngLat { + return this._center; + } + + set center(center: LngLat) { + if (center.lat === this._center.lat && center.lng === this._center.lng) { + return; + } + this.unmodified = false; + this._center = center; + this.constrain(); + this.calcMatrices(); + } + + get padding(): IPaddingOptions { + return this.edgeInsets.toJSON(); + } + + set padding(padding: IPaddingOptions) { + if (this.edgeInsets.equals(padding)) { + return; + } + this.unmodified = false; + // Update edge-insets inplace + this.edgeInsets.interpolate(this.edgeInsets, padding, 1); + this.calcMatrices(); + } + + /** + * The center of the screen in pixels with the top-left corner being (0,0) + * and +y axis pointing downwards. This accounts for padding. + * + * @readonly + * @type {Point} + * @memberof Transform + */ + get centerPoint(): Point { + return this.edgeInsets.getCenter(this.width, this.height); + } + + get point(): Point { + return this.project(this.center); + } + public tileSize: number; + public tileZoom: number; + public lngRange?: [number, number]; + public latRange?: [number, number]; + public maxValidLatitude: number; + public scale: number; + public width: number; + public height: number; + public angle: number; + public rotationMatrix: mat2; + public pixelsToGLUnits: [number, number]; + public cameraToCenterDistance: number; + public mercatorMatrix: mat4; + public projMatrix: mat4; + public invProjMatrix: mat4; + public alignedProjMatrix: mat4; + public pixelMatrix: mat4; + public pixelMatrixInverse: mat4; + public glCoordMatrix: mat4; + public labelPlaneMatrix: mat4; + // tslint:disable:variable-name + private _fov: number; + private _pitch: number; + private _zoom: number; + private _renderWorldCopies: boolean; + private _minZoom: number; + private _maxZoom: number; + private _minPitch: number; + private _maxPitch: number; + private _center: LngLat; + // tslint:enable + private zoomFraction: number; + private unmodified: boolean; + private edgeInsets: EdgeInsets; + private constraining: boolean; + private posMatrixCache: { [_: string]: Float32Array }; + private alignedPosMatrixCache: { [_: string]: Float32Array }; + constructor( + minZoom: number, + maxZoom: number, + minPitch: number, + maxPitch: number, + renderWorldCopies: boolean | void, + ) { + this.tileSize = 512; // constant + this.maxValidLatitude = 85.051129; // constant + + this._renderWorldCopies = (renderWorldCopies === undefined + ? true + : renderWorldCopies) as boolean; + this._minZoom = minZoom || 0; + this._maxZoom = maxZoom || 22; + + this._minPitch = minPitch === undefined || minPitch === null ? 0 : minPitch; + this._maxPitch = + maxPitch === undefined || maxPitch === null ? 60 : maxPitch; + + this.setMaxBounds(); + + this.width = 0; + this.height = 0; + this._center = new LngLat(0, 0); + this.zoom = 0; + this.angle = 0; + this._fov = 0.6435011087932844; + this._pitch = 0; + this.unmodified = true; + this.edgeInsets = new EdgeInsets(); + this.posMatrixCache = {}; + this.alignedPosMatrixCache = {}; + } + + public clone(): Transform { + const clone = new Transform( + this._minZoom, + this._maxZoom, + this._minPitch, + this._maxPitch, + this._renderWorldCopies, + ); + clone.tileSize = this.tileSize; + clone.latRange = this.latRange; + clone.width = this.width; + clone.height = this.height; + clone.center = this._center; + clone.zoom = this.zoom; + clone.angle = this.angle; + clone.fov = this._fov; + clone.pitch = this._pitch; + clone.unmodified = this.unmodified; + clone.edgeInsets = this.edgeInsets.clone(); + clone.calcMatrices(); + return clone; + } + + /** + * Returns if the padding params match + * + * @param {IPaddingOptions} padding + * @returns {boolean} + * @memberof Transform + */ + public isPaddingEqual(padding: IPaddingOptions): boolean { + return this.edgeInsets.equals(padding); + } + + /** + * Helper method to upadte edge-insets inplace + * + * @param {IPaddingOptions} target + * @param {number} t + * @memberof Transform + */ + public interpolatePadding( + start: IPaddingOptions, + target: IPaddingOptions, + t: number, + ) { + this.unmodified = false; + this.edgeInsets.interpolate(start, target, t); + this.constrain(); + this.calcMatrices(); + } + + /** + * Return a zoom level that will cover all tiles the transform + * @param {Object} options options + * @param {number} options.tileSize Tile size, expressed in screen pixels. + * @param {boolean} options.roundZoom Target zoom level. If true, the value will be rounded to the closest integer. Otherwise the value will be floored. + * @returns {number} zoom level An integer zoom level at which all tiles will be visible. + */ + public coveringZoomLevel(options: { roundZoom?: boolean; tileSize: number }) { + const z = (options.roundZoom ? Math.round : Math.floor)( + this.zoom + this.scaleZoom(this.tileSize / options.tileSize), + ); + // At negative zoom levels load tiles from z0 because negative tile zoom levels don't exist. + return Math.max(0, z); + } + + /** + * Return any "wrapped" copies of a given tile coordinate that are visible + * in the current view. + * + * @private + */ + // public getVisibleUnwrappedCoordinates(tileID: CanonicalTileID) { + // const result = [new UnwrappedTileID(0, tileID)]; + // if (this._renderWorldCopies) { + // const utl = this.pointCoordinate(new Point(0, 0)); + // const utr = this.pointCoordinate(new Point(this.width, 0)); + // const ubl = this.pointCoordinate(new Point(this.width, this.height)); + // const ubr = this.pointCoordinate(new Point(0, this.height)); + // const w0 = Math.floor(Math.min(utl.x, utr.x, ubl.x, ubr.x)); + // const w1 = Math.floor(Math.max(utl.x, utr.x, ubl.x, ubr.x)); + + // // Add an extra copy of the world on each side to properly render ImageSources and CanvasSources. + // // Both sources draw outside the tile boundaries of the tile that "contains them" so we need + // // to add extra copies on both sides in case offscreen tiles need to draw into on-screen ones. + // const extraWorldCopy = 1; + + // for (let w = w0 - extraWorldCopy; w <= w1 + extraWorldCopy; w++) { + // if (w === 0) { + // continue; + // } + // result.push(new UnwrappedTileID(w, tileID)); + // } + // } + // return result; + // } + + /** + * Return all coordinates that could cover this transform for a covering + * zoom level. + * @param {Object} options + * @param {number} options.tileSize + * @param {number} options.minzoom + * @param {number} options.maxzoom + * @param {boolean} options.roundZoom + * @param {boolean} options.reparseOverscaled + * @param {boolean} options.renderWorldCopies + * @returns {Array} OverscaledTileIDs + * @private + */ + // public coveringTiles(options: { + // tileSize: number; + // minzoom?: number; + // maxzoom?: number; + // roundZoom?: boolean; + // reparseOverscaled?: boolean; + // renderWorldCopies?: boolean; + // }): OverscaledTileID[] { + // let z = this.coveringZoomLevel(options); + // const actualZ = z; + + // if (options.minzoom !== undefined && z < options.minzoom) { + // return []; + // } + // if (options.maxzoom !== undefined && z > options.maxzoom) { + // z = options.maxzoom; + // } + + // const centerCoord = MercatorCoordinate.fromLngLat(this.center); + // const numTiles = Math.pow(2, z); + // const centerPoint = [numTiles * centerCoord.x, numTiles * centerCoord.y, 0]; + // const cameraFrustum = Frustum.fromInvProjectionMatrix( + // this.invProjMatrix, + // this.worldSize, + // z, + // ); + + // // No change of LOD behavior for pitch lower than 60 and when there is no top padding: return only tile ids from the requested zoom level + // let minZoom = options.minzoom || 0; + // // Use 0.1 as an epsilon to avoid for explicit == 0.0 floating point checks + // if (this._pitch <= 60.0 && this.edgeInsets.top < 0.1) { + // minZoom = z; + // } + + // // There should always be a certain number of maximum zoom level tiles surrounding the center location + // const radiusOfMaxLvlLodInTiles = 3; + + // const newRootTile = (wrap: number): any => { + // return { + // // All tiles are on zero elevation plane => z difference is zero + // aabb: new Aabb( + // [wrap * numTiles, 0, 0], + // [(wrap + 1) * numTiles, numTiles, 0], + // ), + // zoom: 0, + // x: 0, + // y: 0, + // wrap, + // fullyVisible: false, + // }; + // }; + + // // Do a depth-first traversal to find visible tiles and proper levels of detail + // const stack = []; + // const result = []; + // const maxZoom = z; + // const overscaledZ = options.reparseOverscaled ? actualZ : z; + + // if (this._renderWorldCopies) { + // // Render copy of the globe thrice on both sides + // for (let i = 1; i <= 3; i++) { + // stack.push(newRootTile(-i)); + // stack.push(newRootTile(i)); + // } + // } + + // stack.push(newRootTile(0)); + + // while (stack.length > 0) { + // const it = stack.pop(); + // const x = it.x; + // const y = it.y; + // let fullyVisible = it.fullyVisible; + + // // Visibility of a tile is not required if any of its ancestor if fully inside the frustum + // if (!fullyVisible) { + // const intersectResult = it.aabb.intersects(cameraFrustum); + + // if (intersectResult === 0) { + // continue; + // } + + // fullyVisible = intersectResult === 2; + // } + + // const distanceX = it.aabb.distanceX(centerPoint); + // const distanceY = it.aabb.distanceY(centerPoint); + // const longestDim = Math.max(Math.abs(distanceX), Math.abs(distanceY)); + + // // We're using distance based heuristics to determine if a tile should be split into quadrants or not. + // // radiusOfMaxLvlLodInTiles defines that there's always a certain number of maxLevel tiles next to the map center. + // // Using the fact that a parent node in quadtree is twice the size of its children (per dimension) + // // we can define distance thresholds for each relative level: + // // f(k) = offset + 2 + 4 + 8 + 16 + ... + 2^k. This is the same as "offset+2^(k+1)-2" + // const distToSplit = + // radiusOfMaxLvlLodInTiles + (1 << (maxZoom - it.zoom)) - 2; + + // // Have we reached the target depth or is the tile too far away to be any split further? + // if ( + // it.zoom === maxZoom || + // (longestDim > distToSplit && it.zoom >= minZoom) + // ) { + // result.push({ + // tileID: new OverscaledTileID( + // it.zoom === maxZoom ? overscaledZ : it.zoom, + // it.wrap, + // it.zoom, + // x, + // y, + // ), + // distanceSq: vec2.sqrLen([ + // centerPoint[0] - 0.5 - x, + // centerPoint[1] - 0.5 - y, + // ]), + // }); + // continue; + // } + + // for (let i = 0; i < 4; i++) { + // const childX = (x << 1) + (i % 2); + // const childY = (y << 1) + (i >> 1); + + // stack.push({ + // aabb: it.aabb.quadrant(i), + // zoom: it.zoom + 1, + // x: childX, + // y: childY, + // wrap: it.wrap, + // fullyVisible, + // }); + // } + // } + + // return result + // .sort((a, b) => a.distanceSq - b.distanceSq) + // .map((a) => a.tileID); + // } + + public resize(width: number, height: number) { + this.width = width; + this.height = height; + + this.pixelsToGLUnits = [2 / width, -2 / height]; + this.constrain(); + this.calcMatrices(); + } + + public zoomScale(zoom: number) { + return Math.pow(2, zoom); + } + public scaleZoom(scale: number) { + return Math.log(scale) / Math.LN2; + } + + public project(lnglat: LngLat) { + const lat = clamp( + lnglat.lat, + -this.maxValidLatitude, + this.maxValidLatitude, + ); + return new Point( + mercatorXfromLng(lnglat.lng) * this.worldSize, + mercatorYfromLat(lat) * this.worldSize, + ); + } + + public unproject(point: Point): LngLat { + return new MercatorCoordinate( + point.x / this.worldSize, + point.y / this.worldSize, + ).toLngLat(); + } + + public setLocationAtPoint(lnglat: LngLat, point: Point) { + const a = this.pointCoordinate(point); + const b = this.pointCoordinate(this.centerPoint); + const loc = this.locationCoordinate(lnglat); + const newCenter = new MercatorCoordinate( + loc.x - (a.x - b.x), + loc.y - (a.y - b.y), + ); + this.center = this.coordinateLocation(newCenter); + if (this._renderWorldCopies) { + this.center = this.center.wrap(); + } + } + + public pointCoordinate(p: Point) { + const targetZ = 0; + // since we don't know the correct projected z value for the point, + // unproject two points to get a line and then find the point on that + // line with z=0 + + const coord0 = new Float32Array([p.x, p.y, 0, 1]); + const coord1 = new Float32Array([p.x, p.y, 1, 1]); + + vec4.transformMat4(coord0, coord0, this.pixelMatrixInverse); + vec4.transformMat4(coord1, coord1, this.pixelMatrixInverse); + + const w0 = coord0[3]; + const w1 = coord1[3]; + const x0 = coord0[0] / w0; + const x1 = coord1[0] / w1; + const y0 = coord0[1] / w0; + const y1 = coord1[1] / w1; + const z0 = coord0[2] / w0; + const z1 = coord1[2] / w1; + + const t = z0 === z1 ? 0 : (targetZ - z0) / (z1 - z0); + + return new MercatorCoordinate( + interpolate(x0, x1, t) / this.worldSize, + interpolate(y0, y1, t) / this.worldSize, + ); + } + + /** + * Returns the map's geographical bounds. When the bearing or pitch is non-zero, the visible region is not + * an axis-aligned rectangle, and the result is the smallest bounds that encompasses the visible region. + * @returns {LngLatBounds} Returns a {@link LngLatBounds} object describing the map's geographical bounds. + */ + public getBounds(): LngLatBounds { + return new LngLatBounds() + .extend(this.pointLocation(new Point(0, 0))) + .extend(this.pointLocation(new Point(this.width, 0))) + .extend(this.pointLocation(new Point(this.width, this.height))) + .extend(this.pointLocation(new Point(0, this.height))); + } + + /** + * Returns the maximum geographical bounds the map is constrained to, or `null` if none set. + * @returns {LngLatBounds} {@link LngLatBounds} + */ + public getMaxBounds(): LngLatBounds | null { + if ( + !this.latRange || + this.latRange.length !== 2 || + !this.lngRange || + this.lngRange.length !== 2 + ) { + return null; + } + + return new LngLatBounds( + [this.lngRange[0], this.latRange[0]], + [this.lngRange[1], this.latRange[1]], + ); + } + + /** + * Sets or clears the map's geographical constraints. + * @param {LngLatBounds} bounds A {@link LngLatBounds} object describing the new geographic boundaries of the map. + */ + public setMaxBounds(bounds?: LngLatBounds) { + if (bounds) { + this.lngRange = [bounds.getWest(), bounds.getEast()]; + this.latRange = [bounds.getSouth(), bounds.getNorth()]; + this.constrain(); + } else { + this.lngRange = undefined; + this.latRange = [-this.maxValidLatitude, this.maxValidLatitude]; + } + } + + public customLayerMatrix(): number[] { + return (this.mercatorMatrix as number[]).slice(); + } + + public maxPitchScaleFactor() { + // calcMatrices hasn't run yet + if (!this.pixelMatrixInverse) { + return 1; + } + + const coord = this.pointCoordinate(new Point(0, 0)); + const p = new Float32Array([ + coord.x * this.worldSize, + coord.y * this.worldSize, + 0, + 1, + ]); + const topPoint = vec4.transformMat4(p, p, this.pixelMatrix); + return topPoint[3] / this.cameraToCenterDistance; + } + + /* + * The camera looks at the map from a 3D (lng, lat, altitude) location. Let's use `cameraLocation` + * as the name for the location under the camera and on the surface of the earth (lng, lat, 0). + * `cameraPoint` is the projected position of the `cameraLocation`. + * + * This point is useful to us because only fill-extrusions that are between `cameraPoint` and + * the query point on the surface of the earth can extend and intersect the query. + * + * When the map is not pitched the `cameraPoint` is equivalent to the center of the map because + * the camera is right above the center of the map. + */ + public getCameraPoint() { + const pitch = this._pitch; + const yOffset = Math.tan(pitch) * (this.cameraToCenterDistance || 1); + return this.centerPoint.add(new Point(0, yOffset)); + } + + /* + * When the map is pitched, some of the 3D features that intersect a query will not intersect + * the query at the surface of the earth. Instead the feature may be closer and only intersect + * the query because it extrudes into the air. + * + * This returns a geometry that includes all of the original query as well as all possible ares of the + * screen where the *base* of a visible extrusion could be. + * - For point queries, the line from the query point to the "camera point" + * - For other geometries, the envelope of the query geometry and the "camera point" + */ + public getCameraQueryGeometry(queryGeometry: Point[]): Point[] { + const c = this.getCameraPoint(); + + if (queryGeometry.length === 1) { + return [queryGeometry[0], c]; + } else { + let minX = c.x; + let minY = c.y; + let maxX = c.x; + let maxY = c.y; + for (const p of queryGeometry) { + minX = Math.min(minX, p.x); + minY = Math.min(minY, p.y); + maxX = Math.max(maxX, p.x); + maxY = Math.max(maxY, p.y); + } + return [ + new Point(minX, minY), + new Point(maxX, minY), + new Point(maxX, maxY), + new Point(minX, maxY), + new Point(minX, minY), + ]; + } + } + + /** + * Given a coordinate, return the screen point that corresponds to it + * @param {Coordinate} coord + * @returns {Point} screen point + * @private + */ + public coordinatePoint(coord: MercatorCoordinate) { + const p = vec4.fromValues( + coord.x * this.worldSize, + coord.y * this.worldSize, + 0, + 1, + ); + vec4.transformMat4(p, p, this.pixelMatrix); + return new Point(p[0] / p[3], p[1] / p[3]); + } + /** + * Given a location, return the screen point that corresponds to it + * @param {LngLat} lnglat location + * @returns {Point} screen point + * @private + */ + public locationPoint(lnglat: LngLat) { + return this.coordinatePoint(this.locationCoordinate(lnglat)); + } + + /** + * Given a point on screen, return its lnglat + * @param {Point} p screen point + * @returns {LngLat} lnglat location + * @private + */ + public pointLocation(p: Point) { + return this.coordinateLocation(this.pointCoordinate(p)); + } + + /** + * Given a geographical lnglat, return an unrounded + * coordinate that represents it at this transform's zoom level. + * @param {LngLat} lnglat + * @returns {Coordinate} + * @private + */ + public locationCoordinate(lnglat: LngLat) { + return MercatorCoordinate.fromLngLat(lnglat); + } + + /** + * Given a Coordinate, return its geographical position. + * @param {Coordinate} coord + * @returns {LngLat} lnglat + * @private + */ + public coordinateLocation(coord: MercatorCoordinate) { + return coord.toLngLat(); + } + + public getProjectionMatrix(): mat4 { + return this.projMatrix; + } + /** + * Calculate the posMatrix that, given a tile coordinate, would be used to display the tile on a map. + * @param {UnwrappedTileID} unwrappedTileID; + * @private + */ + // private calculatePosMatrix( + // unwrappedTileID: UnwrappedTileID, + // aligned: boolean = false, + // ): Float32Array { + // const posMatrixKey = unwrappedTileID.key; + // const cache = aligned ? this.alignedPosMatrixCache : this.posMatrixCache; + // if (cache[posMatrixKey]) { + // return cache[posMatrixKey]; + // } + + // const canonical = unwrappedTileID.canonical; + // const scale = this.worldSize / this.zoomScale(canonical.z); + // const unwrappedX = + // canonical.x + Math.pow(2, canonical.z) * unwrappedTileID.wrap; + + // const posMatrix = mat4.identity(new Float64Array(16)); + // mat4.translate(posMatrix, posMatrix, [ + // unwrappedX * scale, + // canonical.y * scale, + // 0, + // ]); + // mat4.scale(posMatrix, posMatrix, [scale / EXTENT, scale / EXTENT, 1]); + // mat4.multiply( + // posMatrix, + // aligned ? this.alignedProjMatrix : this.projMatrix, + // posMatrix, + // ); + + // cache[posMatrixKey] = new Float32Array(posMatrix); + // return cache[posMatrixKey]; + // } + + private constrain() { + if (!this.center || !this.width || !this.height || this.constraining) { + return; + } + + this.constraining = true; + + let minY = -90; + let maxY = 90; + let minX = -180; + let maxX = 180; + let sy; + let sx; + let x2; + let y2; + const size = this.size; + const unmodified = this.unmodified; + + if (this.latRange) { + const latRange = this.latRange; + minY = mercatorYfromLat(latRange[1]) * this.worldSize; + maxY = mercatorYfromLat(latRange[0]) * this.worldSize; + sy = maxY - minY < size.y ? size.y / (maxY - minY) : 0; + } + + if (this.lngRange) { + const lngRange = this.lngRange; + minX = mercatorXfromLng(lngRange[0]) * this.worldSize; + maxX = mercatorXfromLng(lngRange[1]) * this.worldSize; + sx = maxX - minX < size.x ? size.x / (maxX - minX) : 0; + } + + const point = this.point; + + // how much the map should scale to fit the screen into given latitude/longitude ranges + const s = Math.max(sx || 0, sy || 0); + + if (s) { + this.center = this.unproject( + new Point( + sx ? (maxX + minX) / 2 : point.x, + sy ? (maxY + minY) / 2 : point.y, + ), + ); + this.zoom += this.scaleZoom(s); + this.unmodified = unmodified; + this.constraining = false; + return; + } + + if (this.latRange) { + const y = point.y; + const h2 = size.y / 2; + + if (y - h2 < minY) { + y2 = minY + h2; + } + if (y + h2 > maxY) { + y2 = maxY - h2; + } + } + + if (this.lngRange) { + const x = point.x; + const w2 = size.x / 2; + + if (x - w2 < minX) { + x2 = minX + w2; + } + if (x + w2 > maxX) { + x2 = maxX - w2; + } + } + + // pan the map if the screen goes off the range + if (x2 !== undefined || y2 !== undefined) { + this.center = this.unproject( + new Point( + x2 !== undefined ? x2 : point.x, + y2 !== undefined ? y2 : point.y, + ), + ); + } + + this.unmodified = unmodified; + this.constraining = false; + } + + private calcMatrices() { + if (!this.height) { + return; + } + + const halfFov = this._fov / 2; + const offset = this.centerOffset; + this.cameraToCenterDistance = (0.5 / Math.tan(halfFov)) * this.height; + + // Find the distance from the center point [width/2 + offset.x, height/2 + offset.y] to the + // center top point [width/2 + offset.x, 0] in Z units, using the law of sines. + // 1 Z unit is equivalent to 1 horizontal px at the center of the map + // (the distance between[width/2, height/2] and [width/2 + 1, height/2]) + const groundAngle = Math.PI / 2 + this._pitch; + const fovAboveCenter = this._fov * (0.5 + offset.y / this.height); + const topHalfSurfaceDistance = + (Math.sin(fovAboveCenter) * this.cameraToCenterDistance) / + Math.sin( + clamp(Math.PI - groundAngle - fovAboveCenter, 0.01, Math.PI - 0.01), + ); + const point = this.point; + const x = point.x; + const y = point.y; + + // Calculate z distance of the farthest fragment that should be rendered. + const furthestDistance = + Math.cos(Math.PI / 2 - this._pitch) * topHalfSurfaceDistance + + this.cameraToCenterDistance; + // Add a bit extra to avoid precision problems when a fragment's distance is exactly `furthestDistance` + const farZ = furthestDistance * 1.01; + + // The larger the value of nearZ is + // - the more depth precision is available for features (good) + // - clipping starts appearing sooner when the camera is close to 3d features (bad) + // + // Smaller values worked well for mapbox-gl-js but deckgl was encountering precision issues + // when rendering it's layers using custom layers. This value was experimentally chosen and + // seems to solve z-fighting issues in deckgl while not clipping buildings too close to the camera. + const nearZ = this.height / 50; + + // matrix for conversion from location to GL coordinates (-1 .. 1) + let m = mat4.create(); + mat4.perspective(m, this._fov, this.width / this.height, nearZ, farZ); + + // Apply center of perspective offset + m[8] = (-offset.x * 2) / this.width; + m[9] = (offset.y * 2) / this.height; + + mat4.scale(m, m, [1, -1, 1]); + mat4.translate(m, m, [0, 0, -this.cameraToCenterDistance]); + mat4.rotateX(m, m, this._pitch); + mat4.rotateZ(m, m, this.angle); + mat4.translate(m, m, [-x, -y, 0]); + + // The mercatorMatrix can be used to transform points from mercator coordinates + // ([0, 0] nw, [1, 1] se) to GL coordinates. + this.mercatorMatrix = mat4.scale(mat4.create(), m, [ + this.worldSize, + this.worldSize, + this.worldSize, + ]); + // scale vertically to meters per pixel (inverse of ground resolution): + + mat4.scale( + m, + m, + vec3.fromValues( + 1, + 1, + mercatorZfromAltitude(1, this.center.lat) * this.worldSize, + ), + ); + + this.projMatrix = m; + this.invProjMatrix = mat4.invert(mat4.create(), this.projMatrix); + + // Make a second projection matrix that is aligned to a pixel grid for rendering raster tiles. + // We're rounding the (floating point) x/y values to achieve to avoid rendering raster images to fractional + // coordinates. Additionally, we adjust by half a pixel in either direction in case that viewport dimension + // is an odd integer to preserve rendering to the pixel grid. We're rotating this shift based on the angle + // of the transformation so that 0°, 90°, 180°, and 270° rasters are crisp, and adjust the shift so that + // it is always <= 0.5 pixels. + const xShift = (this.width % 2) / 2; + const yShift = (this.height % 2) / 2; + const angleCos = Math.cos(this.angle); + const angleSin = Math.sin(this.angle); + const dx = x - Math.round(x) + angleCos * xShift + angleSin * yShift; + const dy = y - Math.round(y) + angleCos * yShift + angleSin * xShift; + const alignedM = mat4.clone(m); + mat4.translate(alignedM, alignedM, [ + dx > 0.5 ? dx - 1 : dx, + dy > 0.5 ? dy - 1 : dy, + 0, + ]); + this.alignedProjMatrix = alignedM; + + m = mat4.create(); + mat4.scale(m, m, [this.width / 2, -this.height / 2, 1]); + mat4.translate(m, m, [1, -1, 0]); + this.labelPlaneMatrix = m; + + m = mat4.create(); + mat4.scale(m, m, [1, -1, 1]); + mat4.translate(m, m, [-1, -1, 0]); + mat4.scale(m, m, [2 / this.width, 2 / this.height, 1]); + this.glCoordMatrix = m; + + // matrix for conversion from location to screen coordinates + this.pixelMatrix = mat4.multiply( + mat4.create(), + this.labelPlaneMatrix, + this.projMatrix, + ); + + // inverse matrix for conversion from screen coordinaes to location + m = mat4.invert(mat4.create(), this.pixelMatrix); + if (!m) { + throw new Error('failed to invert matrix'); + } + this.pixelMatrixInverse = m; + + this.posMatrixCache = {}; + this.alignedPosMatrixCache = {}; + } +} diff --git a/packages/map/src/handler/IHandler.ts b/packages/map/src/handler/IHandler.ts new file mode 100644 index 0000000000..1dd11a64d1 --- /dev/null +++ b/packages/map/src/handler/IHandler.ts @@ -0,0 +1,59 @@ +// @ts-ignore +import Point from '../geo/point'; +import { Map } from '../map'; +export interface IHandlerResult { + panDelta?: Point; + zoomDelta?: number; + bearingDelta?: number; + pitchDelta?: number; + around?: Point | null; + pinchAround?: Point | null; + cameraAnimation?: (map: Map) => any; + originalEvent?: any; + // Makes the manager trigger a frame; allowing the handler to return multiple results over time (see scrollzoom). + needsRenderFrame?: boolean; + noInertia?: boolean; +} + +export interface IHandler { + // Handlers can optionally implement these methods. + // They are called with dom events whenever those dom evens are received. + touchstart?: ( + e: TouchEvent, + points: Point[], + mapTouches: Touch[], + ) => IHandlerResult | void; + touchmove?: ( + e: TouchEvent, + points: Point[], + mapTouches: Touch[], + ) => IHandlerResult | void; + touchend?: ( + e: TouchEvent, + points: Point[], + mapTouches: Touch[], + ) => IHandlerResult | void; + touchcancel?: ( + e: TouchEvent, + points: Point[], + mapTouches: Touch[], + ) => IHandlerResult | void; + mousedown?: (e: MouseEvent, point: Point) => IHandlerResult | void; + mousemove?: (e: MouseEvent, point: Point) => IHandlerResult | void; + mouseup?: (e: MouseEvent, point: Point) => IHandlerResult | void; + dblclick?: (e: MouseEvent, point: Point) => IHandlerResult | void; + wheel?: (e: WheelEvent, point: Point) => IHandlerResult | void; + keydown?: (e: KeyboardEvent) => IHandlerResult | void; + keyup?: (e: KeyboardEvent) => IHandlerResult | void; + + // `renderFrame` is the only non-dom event. It is called during render + // frames and can be used to smooth camera changes (see scroll handler). + renderFrame?: () => IHandlerResult | void; + enable(options?: any): void; + disable(): void; + isEnabled(): boolean; + isActive(): boolean; + + // `reset` can be called by the manager at any time and must reset everything to it's original state + reset(): void; +} diff --git a/packages/map/src/handler/blockable_map_event.ts b/packages/map/src/handler/blockable_map_event.ts new file mode 100644 index 0000000000..3ac38a7ffc --- /dev/null +++ b/packages/map/src/handler/blockable_map_event.ts @@ -0,0 +1,66 @@ +// @ts-ignore +import Point from '../geo/point'; +import { Map } from '../map'; +import { MapMouseEvent, MapTouchEvent, MapWheelEvent } from './events'; +export default class BlockableMapEventHandler { + private map: Map; + private delayContextMenu: boolean; + private contextMenuEvent: MouseEvent; + + constructor(map: Map) { + this.map = map; + } + + public reset() { + this.delayContextMenu = false; + delete this.contextMenuEvent; + } + + public mousemove(e: MouseEvent) { + // mousemove map events should not be fired when interaction handlers (pan, rotate, etc) are active + this.map.emit(e.type, new MapMouseEvent(e.type, this.map, e)); + } + + public mousedown() { + this.delayContextMenu = true; + } + + public mouseup() { + this.delayContextMenu = false; + if (this.contextMenuEvent) { + this.map.emit( + 'contextmenu', + new MapMouseEvent('contextmenu', this.map, this.contextMenuEvent), + ); + delete this.contextMenuEvent; + } + } + public contextmenu(e: MouseEvent) { + if (this.delayContextMenu) { + // Mac: contextmenu fired on mousedown; we save it until mouseup for consistency's sake + this.contextMenuEvent = e; + } else { + // Windows: contextmenu fired on mouseup, so fire event now + this.map.emit(e.type, new MapMouseEvent(e.type, this.map, e)); + } + + // prevent browser context menu when necessary + if (this.map.listeners('contextmenu')) { + e.preventDefault(); + } + } + + public isEnabled() { + return true; + } + + public isActive() { + return false; + } + public enable() { + return true; + } + public disable() { + return false; + } +} diff --git a/packages/map/src/handler/box_zoom.ts b/packages/map/src/handler/box_zoom.ts new file mode 100644 index 0000000000..91b184dfba --- /dev/null +++ b/packages/map/src/handler/box_zoom.ts @@ -0,0 +1,192 @@ +// @ts-ignore +import Point from '../geo/point'; +import { Map } from '../map'; +import DOM from '../utils/dom'; +import { Event } from './events/event'; + +/** + * The `BoxZoomHandler` allows the user to zoom the map to fit within a bounding box. + * The bounding box is defined by clicking and holding `shift` while dragging the cursor. + */ +class BoxZoomHandler { + private map: Map; + private el: HTMLElement; + private container: HTMLElement; + private enabled: boolean; + private active: boolean; + private startPos: Point; + private lastPos: Point; + private box: HTMLElement | null; + private clickTolerance: number; + + /** + * @private + */ + constructor( + map: Map, + options: { + clickTolerance: number; + }, + ) { + this.map = map; + this.el = map.getCanvasContainer(); + this.container = map.getContainer(); + this.clickTolerance = options.clickTolerance || 1; + } + + /** + * Returns a Boolean indicating whether the "box zoom" interaction is enabled. + * + * @returns {boolean} `true` if the "box zoom" interaction is enabled. + */ + public isEnabled() { + return !!this.enabled; + } + + /** + * Returns a Boolean indicating whether the "box zoom" interaction is active, i.e. currently being used. + * + * @returns {boolean} `true` if the "box zoom" interaction is active. + */ + public isActive() { + return !!this.active; + } + + /** + * Enables the "box zoom" interaction. + * + * @example + * map.boxZoom.enable(); + */ + public enable() { + if (this.isEnabled()) { + return; + } + this.enabled = true; + } + + /** + * Disables the "box zoom" interaction. + * + * @example + * map.boxZoom.disable(); + */ + public disable() { + if (!this.isEnabled()) { + return; + } + this.enabled = false; + } + + public mousedown(e: MouseEvent, point: Point) { + if (!this.isEnabled()) { + return; + } + if (!(e.shiftKey && e.button === 0)) { + return; + } + + DOM.disableDrag(); + this.startPos = this.lastPos = point; + this.active = true; + } + + public mousemoveWindow(e: MouseEvent, point: Point) { + if (!this.active) { + return; + } + + const pos = point; + + if ( + this.lastPos.equals(pos) || + (!this.box && pos.dist(this.startPos) < this.clickTolerance) + ) { + return; + } + + const p0 = this.startPos; + this.lastPos = pos; + + if (!this.box) { + this.box = DOM.create('div', 'l7-boxzoom', this.container); + this.container.classList.add('l7-crosshair'); + this.fireEvent('boxzoomstart', e); + } + const minX = Math.min(p0.x, pos.x); + const maxX = Math.max(p0.x, pos.x); + const minY = Math.min(p0.y, pos.y); + const maxY = Math.max(p0.y, pos.y); + + DOM.setTransform(this.box, `translate(${minX}px,${minY}px)`); + if (this.box) { + this.box.style.width = `${maxX - minX}px`; + this.box.style.height = `${maxY - minY}px`; + } + } + + public mouseupWindow(e: MouseEvent, point: Point) { + if (!this.active) { + return; + } + + if (e.button !== 0) { + return; + } + + const p0 = this.startPos; + const p1 = point; + + this.reset(); + + DOM.suppressClick(); + + if (p0.x === p1.x && p0.y === p1.y) { + this.fireEvent('boxzoomcancel', e); + } else { + this.map.emit( + 'boxzoomend', + new Event('boxzoomend', { originalEvent: e }), + ); + return { + cameraAnimation: (map: Map) => + map.fitScreenCoordinates(p0, p1, this.map.getBearing(), { + linear: true, + }), + }; + } + } + + public keydown(e: KeyboardEvent) { + if (!this.active) { + return; + } + + if (e.keyCode === 27) { + this.reset(); + this.fireEvent('boxzoomcancel', e); + } + } + + public reset() { + this.active = false; + + this.container.classList.remove('l7-crosshair'); + + if (this.box) { + DOM.remove(this.box); + this.box = null; + } + + DOM.enableDrag(); + + delete this.startPos; + delete this.lastPos; + } + + public fireEvent(type: string, e: any) { + return this.map.emit(type, new Event(type, { originalEvent: e })); + } +} + +export default BoxZoomHandler; diff --git a/packages/map/src/handler/click_zoom.ts b/packages/map/src/handler/click_zoom.ts new file mode 100644 index 0000000000..d4aaf8d1ec --- /dev/null +++ b/packages/map/src/handler/click_zoom.ts @@ -0,0 +1,48 @@ +// @ts-ignore +import Point from '../geo/point'; +import { Map } from '../map'; + +export default class ClickZoomHandler { + private enabled: boolean; + private active: boolean; + + constructor() { + this.reset(); + } + + public reset() { + this.active = false; + } + + public dblclick(e: MouseEvent, point: Point) { + e.preventDefault(); + return { + cameraAnimation: (map: Map) => { + map.easeTo( + { + duration: 300, + zoom: map.getZoom() + (e.shiftKey ? -1 : 1), + around: map.unproject(point), + }, + { originalEvent: e }, + ); + }, + }; + } + + public enable() { + this.enabled = true; + } + public disable() { + this.enabled = false; + this.reset(); + } + + public isEnabled() { + return this.enabled; + } + + public isActive() { + return this.active; + } +} diff --git a/packages/map/src/handler/events/event.ts b/packages/map/src/handler/events/event.ts new file mode 100644 index 0000000000..237240085b --- /dev/null +++ b/packages/map/src/handler/events/event.ts @@ -0,0 +1,9 @@ +// tslint:disable-next-line:no-submodule-imports +import merge from 'lodash/merge'; +export class Event { + public type: string; + constructor(type: string, data: any = {}) { + merge(this, data); + this.type = type; + } +} diff --git a/packages/map/src/handler/events/index.ts b/packages/map/src/handler/events/index.ts new file mode 100644 index 0000000000..deb1a0925c --- /dev/null +++ b/packages/map/src/handler/events/index.ts @@ -0,0 +1,5 @@ +import MapMouseEvent from './map_mouse_event'; +import MapTouchEvent from './map_touch_event'; +import MapWheelEvent, { IMapBoxZoomEvent } from './map_wheel_event'; + +export { MapMouseEvent, MapTouchEvent, MapWheelEvent }; diff --git a/packages/map/src/handler/events/map_mouse_event.ts b/packages/map/src/handler/events/map_mouse_event.ts new file mode 100644 index 0000000000..32deb0e7ca --- /dev/null +++ b/packages/map/src/handler/events/map_mouse_event.ts @@ -0,0 +1,67 @@ +// @ts-ignore +// tslint:disable-next-line:no-submodule-imports +import merge from 'lodash/merge'; +import LngLat from '../../geo/lng_lat'; +import Point from '../../geo/point'; +import { Map } from '../../map'; +import DOM from '../../utils/dom'; +import { Event } from './event'; +export default class MapMouseEvent extends Event { + /** + * `true` if `preventDefault` has been called. + * @private + */ + + public type: + | 'mousedown' + | 'mouseup' + | 'click' + | 'dblclick' + | 'mousemove' + | 'mouseover' + | 'mouseenter' + | 'mouseleave' + | 'mouseout' + | 'contextmenu'; + + /** + * The `Map` object that fired the event. + */ + public target: Map; + + /** + * The DOM event which caused the map event. + */ + public originalEvent: MouseEvent; + + /** + * The pixel coordinates of the mouse cursor, relative to the map and measured from the top left corner. + */ + public point: Point; + + /** + * The geographic location on the map of the mouse cursor. + */ + public lngLat: LngLat; + + public defaultPrevented: boolean; + + /** + * @private + */ + constructor( + type: string, + map: Map, + originalEvent: MouseEvent, + data: any = {}, + ) { + const point = DOM.mousePos(map.getCanvasContainer(), originalEvent); + const lngLat = map.unproject(point); + super(type, merge({ point, lngLat, originalEvent }, data)); + this.defaultPrevented = false; + this.target = map; + } + public preventDefault() { + this.defaultPrevented = true; + } +} diff --git a/packages/map/src/handler/events/map_touch_event.ts b/packages/map/src/handler/events/map_touch_event.ts new file mode 100644 index 0000000000..722d43a5dd --- /dev/null +++ b/packages/map/src/handler/events/map_touch_event.ts @@ -0,0 +1,86 @@ +// @ts-ignore +import LngLat from '../../geo/lng_lat'; +import Point from '../../geo/point'; +import { Map } from '../../map'; +import DOM from '../../utils/dom'; +import { Event } from './event'; +export default class MapTouchEvent extends Event { + /** + * The event type. + */ + public type: 'touchstart' | 'touchend' | 'touchcancel'; + + /** + * The `Map` object that fired the event. + */ + public target: Map; + + /** + * The DOM event which caused the map event. + */ + public originalEvent: TouchEvent; + + /** + * The geographic location on the map of the center of the touch event points. + */ + public lngLat: LngLat; + + /** + * The pixel coordinates of the center of the touch event points, relative to the map and measured from the top left + * corner. + */ + public point: Point; + + /** + * The array of pixel coordinates corresponding to a + * [touch event's `touches`](https://developer.mozilla.org/en-US/docs/Web/API/TouchEvent/touches) property. + */ + public points: Point[]; + + /** + * The geographical locations on the map corresponding to a + * [touch event's `touches`](https://developer.mozilla.org/en-US/docs/Web/API/TouchEvent/touches) property. + */ + public lngLats: LngLat[]; + + /** + * `true` if `preventDefault` has been called. + * @private + */ + + public defaultPrevented: boolean; + + /** + * @private + */ + constructor(type: string, map: Map, originalEvent: TouchEvent) { + const touches = + type === 'touchend' + ? originalEvent.changedTouches + : originalEvent.touches; + const points = DOM.touchPos(map.getCanvasContainer(), touches); + const lngLats = points.map((t: Point) => map.unproject(t)); + const point = points.reduce( + (prev: Point, curr: Point, i: number, arr: Point[]) => { + return prev.add(curr.div(arr.length)); + }, + new Point(0, 0), + ); + const lngLat = map.unproject(point); + super(type, { points, point, lngLats, lngLat, originalEvent }); + this.defaultPrevented = false; + } + + /** + * Prevents subsequent default processing of the event by the map. + * + * Calling this method will prevent the following default map behaviors: + * + * * On `touchstart` events, the behavior of {@link DragPanHandler} + * * On `touchstart` events, the behavior of {@link TouchZoomRotateHandler} + * + */ + private preventDefault() { + this.defaultPrevented = true; + } +} diff --git a/packages/map/src/handler/events/map_wheel_event.ts b/packages/map/src/handler/events/map_wheel_event.ts new file mode 100644 index 0000000000..214012c675 --- /dev/null +++ b/packages/map/src/handler/events/map_wheel_event.ts @@ -0,0 +1,43 @@ +import { Map } from '../../map'; +import { Event } from './event'; + +export interface IMapBoxZoomEvent { + type: 'boxzoomstart' | 'boxzoomend' | 'boxzoomcancel'; + target: Map; + originalEvent: MouseEvent; +} +export default class MapWheelEvent extends Event { + /** + * The event type. + */ + public type: 'wheel'; + + /** + * The DOM event which caused the map event. + */ + public originalEvent: WheelEvent; + + public defaultPrevented: boolean; + + /** + * The `Map` object that fired the event. + */ + public target: Map; + + /** + * @private + */ + constructor(type: string, map: Map, originalEvent: WheelEvent) { + super(type, { originalEvent }); + this.defaultPrevented = false; + } + + /** + * Prevents subsequent default processing of the event by the map. + * + * Calling this method will prevent the the behavior of {@link ScrollZoomHandler}. + */ + private preventDefault() { + this.defaultPrevented = true; + } +} diff --git a/packages/map/src/handler/events/render_event.ts b/packages/map/src/handler/events/render_event.ts new file mode 100644 index 0000000000..a2e96e2cab --- /dev/null +++ b/packages/map/src/handler/events/render_event.ts @@ -0,0 +1,5 @@ +import { Event } from './event'; +export default class RenderFrameEvent extends Event { + public type: 'renderFrame' = 'renderFrame'; + public timeStamp: number; +} diff --git a/packages/map/src/handler/handler_inertia.ts b/packages/map/src/handler/handler_inertia.ts new file mode 100644 index 0000000000..e30b268310 --- /dev/null +++ b/packages/map/src/handler/handler_inertia.ts @@ -0,0 +1,205 @@ +// @ts-ignore +import Point from '../geo/point'; + +// tslint:disable-next-line:no-submodule-imports +import merge from 'lodash/merge'; +import { Map } from '../map'; +import { bezier, clamp, now } from '../util'; +import { IDragPanOptions } from './shim/drag_pan'; + +const defaultInertiaOptions = { + linearity: 0.3, + easing: bezier(0, 0, 0.3, 1), +}; + +const defaultPanInertiaOptions = merge( + { + deceleration: 2500, + maxSpeed: 1400, + }, + defaultInertiaOptions, +); + +const defaultZoomInertiaOptions = merge( + { + deceleration: 20, + maxSpeed: 1400, + }, + defaultInertiaOptions, +); + +const defaultBearingInertiaOptions = merge( + { + deceleration: 1000, + maxSpeed: 360, + }, + defaultInertiaOptions, +); + +const defaultPitchInertiaOptions = merge( + { + deceleration: 1000, + maxSpeed: 90, + }, + defaultInertiaOptions, +); + +export interface IInertiaOptions { + linearity: number; + easing: (t: number) => number; + deceleration: number; + maxSpeed: number; +} + +export type InputEvent = MouseEvent | TouchEvent | KeyboardEvent | WheelEvent; + +export default class HandlerInertia { + private map: Map; + private inertiaBuffer: Array<{ + time: number; + settings: { [key: string]: any }; + }>; + + constructor(map: Map) { + this.map = map; + this.clear(); + } + + public clear() { + this.inertiaBuffer = []; + } + + public record(settings: any) { + this.drainInertiaBuffer(); + this.inertiaBuffer.push({ time: now(), settings }); + } + + public drainInertiaBuffer() { + const inertia = this.inertiaBuffer; + const nowTime = now(); + const cutoff = 160; // msec + + while (inertia.length > 0 && nowTime - inertia[0].time > cutoff) { + inertia.shift(); + } + } + + public onMoveEnd(panInertiaOptions?: IDragPanOptions) { + this.drainInertiaBuffer(); + if (this.inertiaBuffer.length < 2) { + return; + } + + const deltas = { + zoom: 0, + bearing: 0, + pitch: 0, + pan: new Point(0, 0), + pinchAround: undefined, + around: undefined, + }; + + for (const { settings } of this.inertiaBuffer) { + deltas.zoom += settings.zoomDelta || 0; + deltas.bearing += settings.bearingDelta || 0; + deltas.pitch += settings.pitchDelta || 0; + if (settings.panDelta) { + deltas.pan._add(settings.panDelta); + } + if (settings.around) { + deltas.around = settings.around; + } + if (settings.pinchAround) { + deltas.pinchAround = settings.pinchAround; + } + } + + const lastEntry = this.inertiaBuffer[this.inertiaBuffer.length - 1]; + const duration = lastEntry.time - this.inertiaBuffer[0].time; + + const easeOptions: { [key: string]: any } = {}; + + if (deltas.pan.mag()) { + const result = calculateEasing( + deltas.pan.mag(), + duration, + merge({}, defaultPanInertiaOptions, panInertiaOptions || {}), + ); + easeOptions.offset = deltas.pan.mult(result.amount / deltas.pan.mag()); + easeOptions.center = this.map.transform.center; + extendDuration(easeOptions, result); + } + + if (deltas.zoom) { + const result = calculateEasing( + deltas.zoom, + duration, + defaultZoomInertiaOptions, + ); + easeOptions.zoom = this.map.transform.zoom + result.amount; + extendDuration(easeOptions, result); + } + + if (deltas.bearing) { + const result = calculateEasing( + deltas.bearing, + duration, + defaultBearingInertiaOptions, + ); + easeOptions.bearing = + this.map.transform.bearing + clamp(result.amount, -179, 179); + extendDuration(easeOptions, result); + } + + if (deltas.pitch) { + const result = calculateEasing( + deltas.pitch, + duration, + defaultPitchInertiaOptions, + ); + easeOptions.pitch = this.map.transform.pitch + result.amount; + extendDuration(easeOptions, result); + } + + if (easeOptions.zoom || easeOptions.bearing) { + const last = + deltas.pinchAround === undefined ? deltas.around : deltas.pinchAround; + easeOptions.around = last + ? this.map.unproject(last) + : this.map.getCenter(); + } + + this.clear(); + return merge(easeOptions, { + noMoveStart: true, + }); + } +} + +// Unfortunately zoom, bearing, etc can't have different durations and easings so +// we need to choose one. We use the longest duration and it's corresponding easing. +function extendDuration(easeOptions: any, result: any) { + if (!easeOptions.duration || easeOptions.duration < result.duration) { + easeOptions.duration = result.duration; + easeOptions.easing = result.easing; + } +} + +function calculateEasing( + amount: number, + inertiaDuration: number, + inertiaOptions: IInertiaOptions, +) { + const { maxSpeed, linearity, deceleration } = inertiaOptions; + const speed = clamp( + (amount * linearity) / (inertiaDuration / 1000), + -maxSpeed, + maxSpeed, + ); + const duration = Math.abs(speed) / (deceleration * linearity); + return { + easing: inertiaOptions.easing, + duration: duration * 1000, + amount: speed * (duration / 2), + }; +} diff --git a/packages/map/src/handler/handler_manager.ts b/packages/map/src/handler/handler_manager.ts new file mode 100644 index 0000000000..6410484e64 --- /dev/null +++ b/packages/map/src/handler/handler_manager.ts @@ -0,0 +1,634 @@ +// @ts-ignore +// tslint:disable-next-line: no-submodule-imports +import merge from 'lodash/merge'; +import Point from '../geo/point'; +import { Map } from '../map'; +import DOM from '../utils/dom'; +import BlockableMapEventHandler from './blockable_map_event'; +import BoxZoomHandler from './box_zoom'; +import ClickZoomHandler from './click_zoom'; +import { Event } from './events/event'; +import RenderFrameEvent from './events/render_event'; +import HandlerInertia from './handler_inertia'; +import { IHandler, IHandlerResult } from './IHandler'; +import KeyboardHandler from './keyboard'; +import MapEventHandler from './map_event'; +import { + MousePanHandler, + MousePitchHandler, + MouseRotateHandler, +} from './mouse'; +import ScrollZoomHandler from './scroll_zoom'; +import DoubleClickZoomHandler from './shim/dblclick_zoom'; +import DragPanHandler from './shim/drag_pan'; +import DragRotateHandler from './shim/drag_rotate'; +import TouchZoomRotateHandler from './shim/touch_zoom_rotate'; +import TapDragZoomHandler from './tap/tap_drag_zoom'; +import TapZoomHandler from './tap/tap_zoom'; +import { + TouchPanHandler, + TouchPitchHandler, + TouchRotateHandler, + TouchZoomHandler, +} from './touch'; + +export type InputEvent = MouseEvent | TouchEvent | KeyboardEvent | WheelEvent; + +const isMoving = (p: any) => p.zoom || p.drag || p.pitch || p.rotate; + +function hasChange(result: IHandlerResult) { + return ( + (result.panDelta && result.panDelta.mag()) || + result.zoomDelta || + result.bearingDelta || + result.pitchDelta + ); +} + +export interface IHandlerOptions { + interactive: boolean; + boxZoom: boolean; + dragRotate: boolean; + dragPan: boolean; + keyboard: boolean; + doubleClickZoom: boolean; + touchZoomRotate: boolean; + touchPitch: boolean; + trackResize: boolean; + renderWorldCopies: boolean; + bearingSnap: number; + clickTolerance: number; + pitchWithRotate: boolean; +} + +class HandlerManager { + private map: Map; + private el: HTMLElement; + private handlers: Array<{ + handlerName: string; + handler: IHandler; + allowed: any; + }>; + private eventsInProgress: any; + private frameId: number; + private inertia: HandlerInertia; + private bearingSnap: number; + private handlersById: { [key: string]: IHandler }; + private updatingCamera: boolean; + private changes: Array<[IHandlerResult, any, any]>; + private previousActiveHandlers: { [key: string]: IHandler }; + private bearingChanged: boolean; + private listeners: Array< + [HTMLElement, string, void | { passive?: boolean; capture?: boolean }] + >; + + constructor(map: Map, options: IHandlerOptions) { + this.map = map; + this.el = this.map.getCanvasContainer(); + this.handlers = []; + this.handlersById = {}; + this.changes = []; + + this.inertia = new HandlerInertia(map); + this.bearingSnap = options.bearingSnap; + this.previousActiveHandlers = {}; + + // Track whether map is currently moving, to compute start/move/end events + this.eventsInProgress = {}; + + this.addDefaultHandlers(options); + + const el = this.el; + + this.listeners = [ + // Bind touchstart and touchmove with passive: false because, even though + // they only fire a map events and therefore could theoretically be + // passive, binding with passive: true causes iOS not to respect + // e.preventDefault() in _other_ handlers, even if they are non-passive + // (see https://bugs.webkit.org/show_bug.cgi?id=184251) + [el, 'touchstart', { passive: false }], + [el, 'touchmove', { passive: false }], + [el, 'touchend', undefined], + [el, 'touchcancel', undefined], + + [el, 'mousedown', undefined], + [el, 'mousemove', undefined], + [el, 'mouseup', undefined], + + // Bind window-level event listeners for move and up/end events. In the absence of + // the pointer capture API, which is not supported by all necessary platforms, + // window-level event listeners give us the best shot at capturing events that + // fall outside the map canvas element. Use `{capture: true}` for the move event + // to prevent map move events from being fired during a drag. + // @ts-ignore + [window.document, 'mousemove', { capture: true }], + // @ts-ignore + [window.document, 'mouseup', undefined], + + [el, 'mouseover', undefined], + [el, 'mouseout', undefined], + [el, 'dblclick', undefined], + [el, 'click', undefined], + + [el, 'keydown', { capture: false }], + [el, 'keyup', undefined], + + [el, 'wheel', { passive: false }], + [el, 'contextmenu', undefined], + // @ts-ignore + [window, 'blur', undefined], + ]; + for (const [target, type, listenerOptions] of this.listeners) { + // @ts-ignore + DOM.addEventListener( + target, + type, + // @ts-ignore + target === window.document ? this.handleWindowEvent : this.handleEvent, + listenerOptions, + ); + } + } + public destroy() { + for (const [target, type, listenerOptions] of this.listeners) { + // @ts-ignore + DOM.removeEventListener( + target, + type, + // @ts-ignore + target === window.document ? this.handleWindowEvent : this.handleEvent, + listenerOptions, + ); + } + } + + public stop() { + // do nothing if this method was triggered by a gesture update + if (this.updatingCamera) { + return; + } + + for (const { handler } of this.handlers) { + handler.reset(); + } + this.inertia.clear(); + this.fireEvents({}, {}); + this.changes = []; + } + + public isActive() { + for (const { handler } of this.handlers) { + if (handler.isActive()) { + return true; + } + } + return false; + } + + public isZooming() { + return !!this.eventsInProgress.zoom || this.map.scrollZoom.isZooming(); + } + public isRotating() { + return !!this.eventsInProgress.rotate; + } + + public isMoving() { + return Boolean(isMoving(this.eventsInProgress)) || this.isZooming(); + } + + public handleWindowEvent = (e: InputEvent) => { + this.handleEvent(e, `${e.type}Window`); + }; + + public handleEvent = ( + e: InputEvent | RenderFrameEvent, + eventName?: string, + ) => { + if (e.type === 'blur') { + this.stop(); + return; + } + this.updatingCamera = true; + const inputEvent = e.type === 'renderFrame' ? undefined : (e as InputEvent); + + /* + * We don't call e.preventDefault() for any events by default. + * Handlers are responsible for calling it where necessary. + */ + + const mergedIHandlerResult: IHandlerResult = { needsRenderFrame: false }; + const eventsInProgress: { [key: string]: any } = {}; + const activeHandlers: { [key: string]: any } = {}; + // @ts-ignore + const mapTouches = e.touches + ? // @ts-ignore + this.getMapTouches(e.touches as Touch[]) + : undefined; + const points = mapTouches + ? DOM.touchPos(this.el, mapTouches) + : DOM.mousePos(this.el, e as MouseEvent); + + for (const { handlerName, handler, allowed } of this.handlers) { + if (!handler.isEnabled()) { + continue; + } + let data: IHandlerResult; + if (this.blockedByActive(activeHandlers, allowed, handlerName)) { + handler.reset(); + } else { + const handerName = eventName || e.type; + // @ts-ignore + if (handler && handler[handerName]) { + // @ts-ignore + data = handler[handerName](e, points, mapTouches); + this.mergeIHandlerResult( + mergedIHandlerResult, + eventsInProgress, + data, + handlerName, + inputEvent, + ); + if (data && data.needsRenderFrame) { + this.triggerRenderFrame(); + } + } + } + // @ts-ignore + if (data || handler.isActive()) { + activeHandlers[handlerName] = handler; + } + } + + const deactivatedHandlers: { [key: string]: any } = {}; + for (const name in this.previousActiveHandlers) { + if (!activeHandlers[name]) { + deactivatedHandlers[name] = inputEvent; + } + } + this.previousActiveHandlers = activeHandlers; + if ( + Object.keys(deactivatedHandlers).length || + hasChange(mergedIHandlerResult) + ) { + this.changes.push([ + mergedIHandlerResult, + eventsInProgress, + deactivatedHandlers, + ]); + this.triggerRenderFrame(); + } + + if (Object.keys(activeHandlers).length || hasChange(mergedIHandlerResult)) { + this.map.stop(true); + } + + this.updatingCamera = false; + + const { cameraAnimation } = mergedIHandlerResult; + if (cameraAnimation) { + this.inertia.clear(); + this.fireEvents({}, {}); + this.changes = []; + cameraAnimation(this.map); + } + }; + + public mergeIHandlerResult( + mergedIHandlerResult: IHandlerResult, + eventsInProgress: { [key: string]: any }, + HandlerResult: IHandlerResult, + name: string, + e?: InputEvent, + ) { + if (!HandlerResult) { + return; + } + + merge(mergedIHandlerResult, HandlerResult); + + const eventData = { + handlerName: name, + originalEvent: HandlerResult.originalEvent || e, + }; + + // track which handler changed which camera property + if (HandlerResult.zoomDelta !== undefined) { + eventsInProgress.zoom = eventData; + } + if (HandlerResult.panDelta !== undefined) { + eventsInProgress.drag = eventData; + } + if (HandlerResult.pitchDelta !== undefined) { + eventsInProgress.pitch = eventData; + } + if (HandlerResult.bearingDelta !== undefined) { + eventsInProgress.rotate = eventData; + } + } + + public triggerRenderFrame() { + if (this.frameId === undefined) { + this.frameId = this.map.requestRenderFrame((timeStamp: number) => { + delete this.frameId; + this.handleEvent(new RenderFrameEvent('renderFrame', { timeStamp })); + this.applyChanges(); + }); + } + } + + private addDefaultHandlers(options: IHandlerOptions) { + const map = this.map; + const el = map.getCanvasContainer(); + this.add('mapEvent', new MapEventHandler(map, options)); + + const boxZoom = (map.boxZoom = new BoxZoomHandler(map, options)); + this.add('boxZoom', boxZoom); + + const tapZoom = new TapZoomHandler(); + const clickZoom = new ClickZoomHandler(); + map.doubleClickZoom = new DoubleClickZoomHandler(clickZoom, tapZoom); + this.add('tapZoom', tapZoom); + this.add('clickZoom', clickZoom); + + const tapDragZoom = new TapDragZoomHandler(); + this.add('tapDragZoom', tapDragZoom); + + const touchPitch = (map.touchPitch = new TouchPitchHandler()); + this.add('touchPitch', touchPitch); + + const mouseRotate = new MouseRotateHandler(options); + const mousePitch = new MousePitchHandler(options); + map.dragRotate = new DragRotateHandler(options, mouseRotate, mousePitch); + this.add('mouseRotate', mouseRotate, ['mousePitch']); + this.add('mousePitch', mousePitch, ['mouseRotate']); + + const mousePan = new MousePanHandler(options); + const touchPan = new TouchPanHandler(options); + map.dragPan = new DragPanHandler(el, mousePan, touchPan); + this.add('mousePan', mousePan); + this.add('touchPan', touchPan, ['touchZoom', 'touchRotate']); + + const touchRotate = new TouchRotateHandler(); + const touchZoom = new TouchZoomHandler(); + map.touchZoomRotate = new TouchZoomRotateHandler( + el, + touchZoom, + touchRotate, + tapDragZoom, + ); + this.add('touchRotate', touchRotate, ['touchPan', 'touchZoom']); + this.add('touchZoom', touchZoom, ['touchPan', 'touchRotate']); + + const scrollZoom = (map.scrollZoom = new ScrollZoomHandler(map, this)); + this.add('scrollZoom', scrollZoom, ['mousePan']); + + const keyboard = (map.keyboard = new KeyboardHandler()); + this.add('keyboard', keyboard); + + this.add('blockableMapEvent', new BlockableMapEventHandler(map)); + + for (const name of [ + 'boxZoom', + 'doubleClickZoom', + 'tapDragZoom', + 'touchPitch', + 'dragRotate', + 'dragPan', + 'touchZoomRotate', + 'scrollZoom', + 'keyboard', + ]) { + // @ts-ignore + if (options.interactive && options[name]) { + // @ts-ignore + map[name].enable(options[name]); + } + } + } + + private add(handlerName: string, handler: IHandler, allowed?: string[]) { + this.handlers.push({ handlerName, handler, allowed }); + this.handlersById[handlerName] = handler; + } + + private blockedByActive( + activeHandlers: { [key: string]: IHandler }, + allowed: string[], + myName: string, + ) { + for (const name in activeHandlers) { + if (name === myName) { + continue; + } + if (!allowed || allowed.indexOf(name) < 0) { + return true; + } + } + return false; + } + + private getMapTouches(touches: Touch[]): Touch[] { + const mapTouches = []; + for (const t of touches) { + const target = t.target as Node; + if (this.el.contains(target)) { + mapTouches.push(t); + } + } + return mapTouches; + } + + private applyChanges() { + const combined: { [key: string]: any } = {}; + const combinedEventsInProgress = {}; + const combinedDeactivatedHandlers = {}; + + for (const [change, eventsInProgress, deactivatedHandlers] of this + .changes) { + if (change.panDelta) { + combined.panDelta = (combined.panDelta || new Point(0, 0))._add( + change.panDelta, + ); + } + if (change.zoomDelta) { + combined.zoomDelta = (combined.zoomDelta || 0) + change.zoomDelta; + } + if (change.bearingDelta) { + combined.bearingDelta = + (combined.bearingDelta || 0) + change.bearingDelta; + } + if (change.pitchDelta) { + combined.pitchDelta = (combined.pitchDelta || 0) + change.pitchDelta; + } + if (change.around !== undefined) { + combined.around = change.around; + } + if (change.pinchAround !== undefined) { + combined.pinchAround = change.pinchAround; + } + if (change.noInertia) { + combined.noInertia = change.noInertia; + } + + merge(combinedEventsInProgress, eventsInProgress); + merge(combinedDeactivatedHandlers, deactivatedHandlers); + } + + this.updateMapTransform( + combined, + combinedEventsInProgress, + combinedDeactivatedHandlers, + ); + this.changes = []; + } + + private updateMapTransform( + combinedResult: any, + combinedEventsInProgress: any, + deactivatedHandlers: any, + ) { + const map = this.map; + const tr = map.transform; + + if (!hasChange(combinedResult)) { + return this.fireEvents(combinedEventsInProgress, deactivatedHandlers); + } + const { + panDelta, + zoomDelta, + bearingDelta, + pitchDelta, + pinchAround, + } = combinedResult; + let { around } = combinedResult; + + if (pinchAround !== undefined) { + around = pinchAround; + } + + // stop any ongoing camera animations (easeTo, flyTo) + map.stop(true); + + around = around || map.transform.centerPoint; + const loc = tr.pointLocation(panDelta ? around.sub(panDelta) : around); + if (bearingDelta) { + tr.bearing += bearingDelta; + } + if (pitchDelta) { + tr.pitch += pitchDelta; + } + if (zoomDelta) { + tr.zoom += zoomDelta; + } + tr.setLocationAtPoint(loc, around); + + this.map.update(); + if (!combinedResult.noInertia) { + this.inertia.record(combinedResult); + } + this.fireEvents(combinedEventsInProgress, deactivatedHandlers); + } + + private fireEvents( + newEventsInProgress: { [key: string]: any }, + deactivatedHandlers: { [key: string]: any }, + ) { + const wasMoving = isMoving(this.eventsInProgress); + const nowMoving = isMoving(newEventsInProgress); + + const startEvents: { [key: string]: any } = {}; + + for (const eventName in newEventsInProgress) { + if (newEventsInProgress[eventName]) { + const { originalEvent } = newEventsInProgress[eventName]; + if (!this.eventsInProgress[eventName]) { + startEvents[`${eventName}start`] = originalEvent; + } + + this.eventsInProgress[eventName] = newEventsInProgress[eventName]; + } + } + + // fire start events only after this.eventsInProgress has been updated + if (!wasMoving && nowMoving) { + this.fireEvent('movestart', nowMoving.originalEvent); + } + + for (const name in startEvents) { + if (startEvents[name]) { + this.fireEvent(name, startEvents[name]); + } + } + + if (newEventsInProgress.rotate) { + this.bearingChanged = true; + } + + if (nowMoving) { + this.fireEvent('move', nowMoving.originalEvent); + } + + for (const eventName in newEventsInProgress) { + if (newEventsInProgress[eventName]) { + const { originalEvent } = newEventsInProgress[eventName]; + this.fireEvent(eventName, originalEvent); + } + } + + const endEvents: { [key: string]: any } = {}; + + let originalEndEvent; + for (const eventName in this.eventsInProgress) { + if (this.eventsInProgress[eventName]) { + const { handlerName, originalEvent } = this.eventsInProgress[eventName]; + if (!this.handlersById[handlerName].isActive()) { + delete this.eventsInProgress[eventName]; + originalEndEvent = deactivatedHandlers[handlerName] || originalEvent; + endEvents[`${eventName}end`] = originalEndEvent; + } + } + } + + for (const name in endEvents) { + if (endEvents[name]) { + this.fireEvent(name, endEvents[name]); + } + } + + const stillMoving = isMoving(this.eventsInProgress); + if ((wasMoving || nowMoving) && !stillMoving) { + this.updatingCamera = true; + const inertialEase = this.inertia.onMoveEnd( + this.map.dragPan.inertiaOptions, + ); + + const shouldSnapToNorth = (bearing: number) => + bearing !== 0 && + -this.bearingSnap < bearing && + bearing < this.bearingSnap; + + if (inertialEase) { + if (shouldSnapToNorth(inertialEase.bearing || this.map.getBearing())) { + inertialEase.bearing = 0; + } + this.map.easeTo(inertialEase, { originalEvent: originalEndEvent }); + } else { + this.map.emit( + 'moveend', + new Event('moveend', { originalEvent: originalEndEvent }), + ); + if (shouldSnapToNorth(this.map.getBearing())) { + this.map.resetNorth(); + } + } + this.bearingChanged = false; + this.updatingCamera = false; + } + } + + private fireEvent(type: string, e: any) { + this.map.emit(type, new Event(type, e ? { originalEvent: e } : {})); + } +} + +export default HandlerManager; diff --git a/packages/map/src/handler/handler_util.ts b/packages/map/src/handler/handler_util.ts new file mode 100644 index 0000000000..ab5060c9a9 --- /dev/null +++ b/packages/map/src/handler/handler_util.ts @@ -0,0 +1,10 @@ +// @ts-ignore +import Point from '../geo/point'; + +export function indexTouches(touches: Touch[], points: Point[]) { + const obj: { [key: string]: any } = {}; + for (let i = 0; i < touches.length; i++) { + obj[touches[i].identifier] = points[i]; + } + return obj; +} diff --git a/packages/map/src/handler/keyboard.ts b/packages/map/src/handler/keyboard.ts new file mode 100644 index 0000000000..a5ad19d263 --- /dev/null +++ b/packages/map/src/handler/keyboard.ts @@ -0,0 +1,153 @@ +import { Map } from '../map'; + +const defaultOptions = { + panStep: 100, + bearingStep: 15, + pitchStep: 10, +}; + +/** + * The `KeyboardHandler` allows the user to zoom, rotate, and pan the map using + * the following keyboard shortcuts: + * + * - `=` / `+`: Increase the zoom level by 1. + * - `Shift-=` / `Shift-+`: Increase the zoom level by 2. + * - `-`: Decrease the zoom level by 1. + * - `Shift--`: Decrease the zoom level by 2. + * - Arrow keys: Pan by 100 pixels. + * - `Shift+⇢`: Increase the rotation by 15 degrees. + * - `Shift+⇠`: Decrease the rotation by 15 degrees. + * - `Shift+⇡`: Increase the pitch by 10 degrees. + * - `Shift+⇣`: Decrease the pitch by 10 degrees. + */ +class KeyboardHandler { + private enabled: boolean; + private active: boolean; + private panStep: number; + private bearingStep: number; + private pitchStep: number; + + /** + * @private + */ + constructor() { + const stepOptions = defaultOptions; + this.panStep = stepOptions.panStep; + this.bearingStep = stepOptions.bearingStep; + this.pitchStep = stepOptions.pitchStep; + } + + public reset() { + this.active = false; + } + + public keydown(e: KeyboardEvent) { + if (e.altKey || e.ctrlKey || e.metaKey) { + return; + } + let zoomDir = 0; + let bearingDir = 0; + let pitchDir = 0; + let xDir = 0; + let yDir = 0; + + switch (e.keyCode) { + case 61: + case 107: + case 171: + case 187: + zoomDir = 1; + break; + + case 189: + case 109: + case 173: + zoomDir = -1; + break; + + case 37: + if (e.shiftKey) { + bearingDir = -1; + } else { + e.preventDefault(); + xDir = -1; + } + break; + + case 39: + if (e.shiftKey) { + bearingDir = 1; + } else { + e.preventDefault(); + xDir = 1; + } + break; + + case 38: + if (e.shiftKey) { + pitchDir = 1; + } else { + e.preventDefault(); + yDir = -1; + } + break; + + case 40: + if (e.shiftKey) { + pitchDir = -1; + } else { + e.preventDefault(); + yDir = 1; + } + break; + + default: + return; + } + + return { + cameraAnimation: (map: Map) => { + const zoom = map.getZoom(); + map.easeTo( + { + duration: 300, + easeId: 'keyboardHandler', + easing: easeOut, + + zoom: zoomDir + ? Math.round(zoom) + zoomDir * (e.shiftKey ? 2 : 1) + : zoom, + bearing: map.getBearing() + bearingDir * this.bearingStep, + pitch: map.getPitch() + pitchDir * this.pitchStep, + offset: [-xDir * this.panStep, -yDir * this.panStep], + center: map.getCenter(), + }, + { originalEvent: e }, + ); + }, + }; + } + + public enable() { + this.enabled = true; + } + + public disable() { + this.enabled = false; + this.reset(); + } + + public isEnabled() { + return this.enabled; + } + + public isActive() { + return this.active; + } +} + +function easeOut(t: number) { + return t * (2 - t); +} + +export default KeyboardHandler; diff --git a/packages/map/src/handler/map_event.ts b/packages/map/src/handler/map_event.ts new file mode 100644 index 0000000000..783c5d2c65 --- /dev/null +++ b/packages/map/src/handler/map_event.ts @@ -0,0 +1,110 @@ +// @ts-ignore +import Point from '../geo/point'; +import { Map } from '../map'; +import { MapMouseEvent, MapTouchEvent, MapWheelEvent } from './events'; + +export default class MapEventHandler { + private mousedownPos: Point; + private clickTolerance: number; + private map: Map; + + constructor(map: Map, options: { clickTolerance: number }) { + this.map = map; + this.clickTolerance = options.clickTolerance; + } + + public reset() { + delete this.mousedownPos; + } + + public wheel(e: WheelEvent) { + // If mapEvent.preventDefault() is called by the user, prevent handlers such as: + // - ScrollZoom + return this.firePreventable(new MapWheelEvent(e.type, this.map, e)); + } + + public mousedown(e: MouseEvent, point: Point) { + this.mousedownPos = point; + // If mapEvent.preventDefault() is called by the user, prevent handlers such as: + // - MousePan + // - MouseRotate + // - MousePitch + // - DblclickHandler + return this.firePreventable(new MapMouseEvent(e.type, this.map, e)); + } + + public mouseup(e: MouseEvent) { + this.map.emit(e.type, new MapMouseEvent(e.type, this.map, e)); + } + + public click(e: MouseEvent, point: Point) { + if ( + this.mousedownPos && + this.mousedownPos.dist(point) >= this.clickTolerance + ) { + return; + } + this.map.emit(e.type, new MapMouseEvent(e.type, this.map, e)); + } + + public dblclick(e: MouseEvent) { + // If mapEvent.preventDefault() is called by the user, prevent handlers such as: + // - DblClickZoom + return this.firePreventable(new MapMouseEvent(e.type, this.map, e)); + } + + public mouseover(e: MouseEvent) { + this.map.emit(e.type, new MapMouseEvent(e.type, this.map, e)); + } + + public mouseout(e: MouseEvent) { + this.map.emit(e.type, new MapMouseEvent(e.type, this.map, e)); + } + + public touchstart(e: TouchEvent) { + // If mapEvent.preventDefault() is called by the user, prevent handlers such as: + // - TouchPan + // - TouchZoom + // - TouchRotate + // - TouchPitch + // - TapZoom + // - SwipeZoom + return this.firePreventable(new MapTouchEvent(e.type, this.map, e)); + } + + public touchmove(e: TouchEvent) { + this.map.emit(e.type, new MapTouchEvent(e.type, this.map, e)); + } + + public touchend(e: TouchEvent) { + this.map.emit(e.type, new MapTouchEvent(e.type, this.map, e)); + } + + public touchcancel(e: TouchEvent) { + this.map.emit(e.type, new MapTouchEvent(e.type, this.map, e)); + } + + public firePreventable( + mapEvent: MapMouseEvent | MapTouchEvent | MapWheelEvent, + ) { + this.map.emit(mapEvent.type, mapEvent); + if (mapEvent.defaultPrevented) { + // returning an object marks the handler as active and resets other handlers + return {}; + } + } + + public isEnabled() { + return true; + } + + public isActive() { + return false; + } + public enable() { + return false; + } + public disable() { + return false; + } +} diff --git a/packages/map/src/handler/mouse/index.ts b/packages/map/src/handler/mouse/index.ts new file mode 100644 index 0000000000..92c2abc2c9 --- /dev/null +++ b/packages/map/src/handler/mouse/index.ts @@ -0,0 +1,5 @@ +import MousePanHandler from './mousepan_handler'; +import MouseRotateHandler from './mousepitch_hander'; +import MousePitchHandler from './mouserotate_hander'; + +export { MousePanHandler, MouseRotateHandler, MousePitchHandler }; diff --git a/packages/map/src/handler/mouse/mouse_handler.ts b/packages/map/src/handler/mouse/mouse_handler.ts new file mode 100644 index 0000000000..cf60a50e18 --- /dev/null +++ b/packages/map/src/handler/mouse/mouse_handler.ts @@ -0,0 +1,107 @@ +// @ts-ignore +import Point from '../../geo/point'; +import DOM from '../../utils/dom'; +import { buttonStillPressed } from './util'; +export default class MouseHandler { + protected enabled: boolean; + protected active: boolean; + protected lastPoint: Point; + protected eventButton: 1 | 2; + protected moved: boolean; + protected clickTolerance: number; + + constructor(options: { clickTolerance: number }) { + this.reset(); + this.clickTolerance = options.clickTolerance || 1; + } + + public reset() { + this.active = false; + this.moved = false; + delete this.lastPoint; + delete this.eventButton; + } + + public mousedown(e: MouseEvent, point: Point) { + if (this.lastPoint) { + return; + } + + const eventButton = DOM.mouseButton(e); + if (!this.correctButton(e, eventButton)) { + return; + } + + this.lastPoint = point; + this.eventButton = eventButton; + } + + public mousemoveWindow(e: MouseEvent, point: Point) { + const lastPoint = this.lastPoint; + if (!lastPoint) { + return; + } + e.preventDefault(); + + if (buttonStillPressed(e, this.eventButton)) { + // Some browsers don't fire a `mouseup` when the mouseup occurs outside + // the window or iframe: + // https://github.com/mapbox/mapbox-gl-js/issues/4622 + // + // If the button is no longer pressed during this `mousemove` it may have + // been released outside of the window or iframe. + this.reset(); + return; + } + + if (!this.moved && point.dist(lastPoint) < this.clickTolerance) { + return; + } + this.moved = true; + this.lastPoint = point; + + // implemented by child class + return this.move(lastPoint, point); + } + + public mouseupWindow(e: MouseEvent) { + if (!this.lastPoint) { + return; + } + const eventButton = DOM.mouseButton(e); + if (eventButton !== this.eventButton) { + return; + } + if (this.moved) { + DOM.suppressClick(); + } + this.reset(); + } + + public enable() { + this.enabled = true; + } + + public disable() { + this.enabled = false; + this.reset(); + } + + public isEnabled() { + return this.enabled; + } + + public isActive() { + return this.active; + } + + protected correctButton(e: MouseEvent, button: number) { + // eslint-disable-line + return false; // implemented by child + } + + protected move(lastPoint: Point, point: Point) { + // eslint-disable-line + return; // implemented by child + } +} diff --git a/packages/map/src/handler/mouse/mousepan_handler.ts b/packages/map/src/handler/mouse/mousepan_handler.ts new file mode 100644 index 0000000000..e7058219a5 --- /dev/null +++ b/packages/map/src/handler/mouse/mousepan_handler.ts @@ -0,0 +1,23 @@ +// @ts-ignore +import Point from '../../geo/point'; +import DOM from '../../utils/dom'; +import MouseHandler from './mouse_handler'; +import { buttonStillPressed, LEFT_BUTTON } from './util'; +export default class MousePanHandler extends MouseHandler { + public mousedown(e: MouseEvent, point: Point) { + super.mousedown(e, point); + if (this.lastPoint) { + this.active = true; + } + } + + public move(lastPoint: Point, point: Point) { + return { + around: point, + panDelta: point.sub(lastPoint), + }; + } + protected correctButton(e: MouseEvent, button: number) { + return button === LEFT_BUTTON && !e.ctrlKey; + } +} diff --git a/packages/map/src/handler/mouse/mousepitch_hander.ts b/packages/map/src/handler/mouse/mousepitch_hander.ts new file mode 100644 index 0000000000..70c4c9594c --- /dev/null +++ b/packages/map/src/handler/mouse/mousepitch_hander.ts @@ -0,0 +1,24 @@ +// @ts-ignore +import Point from '../../geo/point'; +import MouseHandler from './mouse_handler'; +import { LEFT_BUTTON, RIGHT_BUTTON } from './util'; +export default class MousePitchHandler extends MouseHandler { + public correctButton(e: MouseEvent, button: number) { + return (button === LEFT_BUTTON && e.ctrlKey) || button === RIGHT_BUTTON; + } + + public move(lastPoint: Point, point: Point) { + const degreesPerPixelMoved = -0.5; + const pitchDelta = (point.y - lastPoint.y) * degreesPerPixelMoved; + if (pitchDelta) { + this.active = true; + return { pitchDelta }; + } + } + + public contextmenu(e: MouseEvent) { + // prevent browser context menu when necessary; we don't allow it with rotation + // because we can't discern rotation gesture start from contextmenu on Mac + e.preventDefault(); + } +} diff --git a/packages/map/src/handler/mouse/mouserotate_hander.ts b/packages/map/src/handler/mouse/mouserotate_hander.ts new file mode 100644 index 0000000000..47b5a75e28 --- /dev/null +++ b/packages/map/src/handler/mouse/mouserotate_hander.ts @@ -0,0 +1,23 @@ +// @ts-ignore +import Point from '../../geo/point'; +import MouseHandler from './mouse_handler'; +import { LEFT_BUTTON, RIGHT_BUTTON } from './util'; +export default class MouseRotateHandler extends MouseHandler { + public contextmenu(e: MouseEvent) { + // prevent browser context menu when necessary; we don't allow it with rotation + // because we can't discern rotation gesture start from contextmenu on Mac + e.preventDefault(); + } + protected correctButton(e: MouseEvent, button: number) { + return (button === LEFT_BUTTON && e.ctrlKey) || button === RIGHT_BUTTON; + } + + protected move(lastPoint: Point, point: Point) { + const degreesPerPixelMoved = 0.8; + const bearingDelta = (point.x - lastPoint.x) * degreesPerPixelMoved; + if (bearingDelta) { + this.active = true; + return { bearingDelta }; + } + } +} diff --git a/packages/map/src/handler/mouse/util.ts b/packages/map/src/handler/mouse/util.ts new file mode 100644 index 0000000000..ab86544211 --- /dev/null +++ b/packages/map/src/handler/mouse/util.ts @@ -0,0 +1,13 @@ +export const LEFT_BUTTON = 0; +export const RIGHT_BUTTON = 2; + +// the values for each button in MouseEvent.buttons +export const BUTTONS_FLAGS: { [key: number]: number } = { + [LEFT_BUTTON]: 1, + [RIGHT_BUTTON]: 2, +}; + +export function buttonStillPressed(e: MouseEvent, button: 1 | 2) { + const flag = BUTTONS_FLAGS[button]; + return e.buttons === undefined || (e.buttons & flag) !== flag; +} diff --git a/packages/map/src/handler/scroll_zoom.ts b/packages/map/src/handler/scroll_zoom.ts new file mode 100644 index 0000000000..fe440f2c41 --- /dev/null +++ b/packages/map/src/handler/scroll_zoom.ts @@ -0,0 +1,368 @@ +// @ts-ignore +import LngLat from '../geo/lng_lat'; +import Point from '../geo/point'; +import { Map } from '../map'; +import { bezier, ease, interpolate, now } from '../util'; +import DOM from '../utils/dom'; +import HandlerManager from './handler_manager'; + +// deltaY value for mouse scroll wheel identification +const wheelZoomDelta = 4.000244140625; + +// These magic numbers control the rate of zoom. Trackpad events fire at a greater +// frequency than mouse scroll wheel, so reduce the zoom rate per wheel tick +const defaultZoomRate = 1 / 100; +const wheelZoomRate = 1 / 450; + +// upper bound on how much we scale the map in any single render frame; this +// is used to limit zoom rate in the case of very fast scrolling +const maxScalePerFrame = 2; + +/** + * The `ScrollZoomHandler` allows the user to zoom the map by scrolling. + */ +class ScrollZoomHandler { + private map: Map; + private el: HTMLElement; + private enabled: boolean; + private active: boolean; + private zooming: boolean; + private aroundCenter: boolean; + private around: LngLat; + private aroundPoint: Point; + private type: 'wheel' | 'trackpad' | null; + private lastValue: number; + private timeout: number | null; // used for delayed-handling of a single wheel movement + private finishTimeout: number; // used to delay final '{move,zoom}end' events + + private lastWheelEvent: any; + private lastWheelEventTime: number; + + private startZoom: number; + private targetZoom: number; + private delta: number; + private easing: (time: number) => number; + private prevEase: { + start: number; + duration: number; + easing: (_: number) => number; + }; + + private frameId: boolean | null; + private handler: HandlerManager; + + private defaultZoomRate: number; + private wheelZoomRate: number; + + /** + * @private + */ + constructor(map: Map, handler: HandlerManager) { + this.map = map; + this.el = map.getCanvasContainer(); + this.handler = handler; + + this.delta = 0; + this.defaultZoomRate = defaultZoomRate; + this.wheelZoomRate = wheelZoomRate; + } + + /** + * Set the zoom rate of a trackpad + * @param {number} [zoomRate=1/100] The rate used to scale trackpad movement to a zoom value. + * @example + * // Speed up trackpad zoom + * map.scrollZoom.setZoomRate(1/25); + */ + public setZoomRate(zoomRate: number) { + this.defaultZoomRate = zoomRate; + } + + /** + * Set the zoom rate of a mouse wheel + * @param {number} [wheelZoomRate=1/450] The rate used to scale mouse wheel movement to a zoom value. + * @example + * // Slow down zoom of mouse wheel + * map.scrollZoom.setWheelZoomRate(1/600); + */ + public setWheelZoomRate(zoomRate: number) { + this.wheelZoomRate = zoomRate; + } + + /** + * Returns a Boolean indicating whether the "scroll to zoom" interaction is enabled. + * + * @returns {boolean} `true` if the "scroll to zoom" interaction is enabled. + */ + public isEnabled() { + return !!this.enabled; + } + + /* + * Active state is turned on and off with every scroll wheel event and is set back to false before the map + * render is called, so _active is not a good candidate for determining if a scroll zoom animation is in + * progress. + */ + public isActive() { + return !!this.active || this.finishTimeout !== undefined; + } + + public isZooming() { + return !!this.zooming; + } + + /** + * Enables the "scroll to zoom" interaction. + * + * @param {Object} [options] Options object. + * @param {string} [options.around] If "center" is passed, map will zoom around center of map + * + * @example + * map.scrollZoom.enable(); + * @example + * map.scrollZoom.enable({ around: 'center' }) + */ + public enable(options?: any) { + if (this.isEnabled()) { + return; + } + this.enabled = true; + this.aroundCenter = options && options.around === 'center'; + } + + /** + * Disables the "scroll to zoom" interaction. + * + * @example + * map.scrollZoom.disable(); + */ + public disable() { + if (!this.isEnabled()) { + return; + } + this.enabled = false; + } + + public wheel(e: WheelEvent) { + if (!this.isEnabled()) { + return; + } + // Remove `any` cast when https://github.com/facebook/flow/issues/4879 is fixed. + let value = + e.deltaMode === window.WheelEvent.DOM_DELTA_LINE + ? e.deltaY * 40 + : e.deltaY; + const nowTime = now(); + const timeDelta = nowTime - (this.lastWheelEventTime || 0); + + this.lastWheelEventTime = nowTime; + + if (value !== 0 && value % wheelZoomDelta === 0) { + // This one is definitely a mouse wheel event. + this.type = 'wheel'; + } else if (value !== 0 && Math.abs(value) < 4) { + // This one is definitely a trackpad event because it is so small. + this.type = 'trackpad'; + } else if (timeDelta > 400) { + // This is likely a new scroll action. + this.type = null; + this.lastValue = value; + + // Start a timeout in case this was a singular event, and dely it by up to 40ms. + // @ts-ignore + this.timeout = setTimeout(this.onTimeout, 40, e); + } else if (!this.type) { + // This is a repeating event, but we don't know the type of event just yet. + // If the delta per time is small, we assume it's a fast trackpad; otherwise we switch into wheel mode. + this.type = Math.abs(timeDelta * value) < 200 ? 'trackpad' : 'wheel'; + + // Make sure our delayed event isn't fired again, because we accumulate + // the previous event (which was less than 40ms ago) into this event. + if (this.timeout) { + clearTimeout(this.timeout); + this.timeout = null; + value += this.lastValue; + } + } + + // Slow down zoom if shift key is held for more precise zooming + if (e.shiftKey && value) { + value = value / 4; + } + // Only fire the callback if we actually know what type of scrolling device the user uses. + if (this.type) { + this.lastWheelEvent = e; + this.delta -= value; + if (!this.active) { + this.start(e); + } + } + + e.preventDefault(); + } + + public renderFrame() { + return this.onScrollFrame(); + } + + public reset() { + this.active = false; + } + + private onScrollFrame = () => { + if (!this.frameId) { + return; + } + this.frameId = null; + + if (!this.isActive()) { + return; + } + const tr = this.map.transform; + + // if we've had scroll events since the last render frame, consume the + // accumulated delta, and update the target zoom level accordingly + if (this.delta !== 0) { + // For trackpad events and single mouse wheel ticks, use the default zoom rate + const zoomRate = + this.type === 'wheel' && Math.abs(this.delta) > wheelZoomDelta + ? this.wheelZoomRate + : this.defaultZoomRate; + // Scale by sigmoid of scroll wheel delta. + let scale = + maxScalePerFrame / (1 + Math.exp(-Math.abs(this.delta * zoomRate))); + + if (this.delta < 0 && scale !== 0) { + scale = 1 / scale; + } + + const fromScale = + typeof this.targetZoom === 'number' + ? tr.zoomScale(this.targetZoom) + : tr.scale; + this.targetZoom = Math.min( + tr.maxZoom, + Math.max(tr.minZoom, tr.scaleZoom(fromScale * scale)), + ); + + // if this is a mouse wheel, refresh the starting zoom and easing + // function we're using to smooth out the zooming between wheel + // events + if (this.type === 'wheel') { + this.startZoom = tr.zoom; + this.easing = this.smoothOutEasing(200); + } + + this.delta = 0; + } + + const targetZoom = + typeof this.targetZoom === 'number' ? this.targetZoom : tr.zoom; + const startZoom = this.startZoom; + const easing = this.easing; + + let finished = false; + let zoom; + if (this.type === 'wheel' && startZoom && easing) { + const t = Math.min((now() - this.lastWheelEventTime) / 200, 1); + const k = easing(t); + zoom = interpolate(startZoom, targetZoom, k); + if (t < 1) { + if (!this.frameId) { + this.frameId = true; + } + } else { + finished = true; + } + } else { + zoom = targetZoom; + finished = true; + } + + this.active = true; + + if (finished) { + this.active = false; + // @ts-ignore + this.finishTimeout = setTimeout(() => { + this.zooming = false; + this.handler.triggerRenderFrame(); + delete this.targetZoom; + delete this.finishTimeout; + }, 200); + } + + return { + noInertia: true, + needsRenderFrame: !finished, + zoomDelta: zoom - tr.zoom, + around: this.aroundPoint, + originalEvent: this.lastWheelEvent, + }; + }; + + private onTimeout(initialEvent: any) { + this.type = 'wheel'; + this.delta -= this.lastValue; + if (!this.active) { + this.start(initialEvent); + } + } + + private start(e: any) { + if (!this.delta) { + return; + } + + if (this.frameId) { + this.frameId = null; + } + + this.active = true; + if (!this.isZooming()) { + this.zooming = true; + } + + if (this.finishTimeout) { + clearTimeout(this.finishTimeout); + delete this.finishTimeout; + } + + const pos = DOM.mousePos(this.el, e); + + this.around = LngLat.convert( + this.aroundCenter ? this.map.getCenter() : this.map.unproject(pos), + ); + this.aroundPoint = this.map.transform.locationPoint(this.around); + if (!this.frameId) { + this.frameId = true; + this.handler.triggerRenderFrame(); + } + } + + private smoothOutEasing(duration: number) { + let easing = ease; + + if (this.prevEase) { + const preEase = this.prevEase; + const t = (now() - preEase.start) / preEase.duration; + const speed = preEase.easing(t + 0.01) - preEase.easing(t); + + // Quick hack to make new bezier that is continuous with last + const x = (0.27 / Math.sqrt(speed * speed + 0.0001)) * 0.01; + const y = Math.sqrt(0.27 * 0.27 - x * x); + + easing = bezier(x, y, 0.25, 1); + } + + this.prevEase = { + start: now(), + duration, + easing, + }; + + return easing; + } +} + +export default ScrollZoomHandler; diff --git a/packages/map/src/handler/shim/dblclick_zoom.ts b/packages/map/src/handler/shim/dblclick_zoom.ts new file mode 100644 index 0000000000..78e2900ce0 --- /dev/null +++ b/packages/map/src/handler/shim/dblclick_zoom.ts @@ -0,0 +1,59 @@ +import ClickZoomHandler from '../click_zoom'; +import TapZoomHandler from '../tap/tap_zoom'; + +/** + * The `DoubleClickZoomHandler` allows the user to zoom the map at a point by + * double clicking or double tapping. + */ +export default class DoubleClickZoomHandler { + private clickZoom: ClickZoomHandler; + private tapZoom: TapZoomHandler; + + /** + * @private + */ + constructor(clickZoom: ClickZoomHandler, TapZoom: TapZoomHandler) { + this.clickZoom = clickZoom; + this.tapZoom = TapZoom; + } + + /** + * Enables the "double click to zoom" interaction. + * + * @example + * map.doubleClickZoom.enable(); + */ + public enable() { + this.clickZoom.enable(); + this.tapZoom.enable(); + } + + /** + * Disables the "double click to zoom" interaction. + * + * @example + * map.doubleClickZoom.disable(); + */ + public disable() { + this.clickZoom.disable(); + this.tapZoom.disable(); + } + + /** + * Returns a Boolean indicating whether the "double click to zoom" interaction is enabled. + * + * @returns {boolean} `true` if the "double click to zoom" interaction is enabled. + */ + public isEnabled() { + return this.clickZoom.isEnabled() && this.tapZoom.isEnabled(); + } + + /** + * Returns a Boolean indicating whether the "double click to zoom" interaction is active, i.e. currently being used. + * + * @returns {boolean} `true` if the "double click to zoom" interaction is active. + */ + public isActive() { + return this.clickZoom.isActive() || this.tapZoom.isActive(); + } +} diff --git a/packages/map/src/handler/shim/drag_pan.ts b/packages/map/src/handler/shim/drag_pan.ts new file mode 100644 index 0000000000..1580c234ae --- /dev/null +++ b/packages/map/src/handler/shim/drag_pan.ts @@ -0,0 +1,88 @@ +import { MousePanHandler } from '../mouse/'; +import { TouchPanHandler } from '../touch/'; + +export interface IDragPanOptions { + linearity?: number; + easing?: (t: number) => number; + deceleration?: number; + maxSpeed?: number; +} + +/** + * The `DragPanHandler` allows the user to pan the map by clicking and dragging + * the cursor. + */ +export default class DragPanHandler { + public inertiaOptions: IDragPanOptions; + private el: HTMLElement; + private mousePan: MousePanHandler; + private touchPan: TouchPanHandler; + /** + * @private + */ + constructor( + el: HTMLElement, + mousePan: MousePanHandler, + touchPan: TouchPanHandler, + ) { + this.el = el; + this.mousePan = mousePan; + this.touchPan = touchPan; + } + + /** + * Enables the "drag to pan" interaction. + * + * @param {Object} [options] Options object + * @param {number} [options.linearity=0] factor used to scale the drag velocity + * @param {Function} [options.easing=bezier(0, 0, 0.3, 1)] easing function applled to `map.panTo` when applying the drag. + * @param {number} [options.maxSpeed=1400] the maximum value of the drag velocity. + * @param {number} [options.deceleration=2500] the rate at which the speed reduces after the pan ends. + * + * @example + * map.dragPan.enable(); + * @example + * map.dragPan.enable({ + * linearity: 0.3, + * easing: bezier(0, 0, 0.3, 1), + * maxSpeed: 1400, + * deceleration: 2500, + * }); + */ + public enable(options?: IDragPanOptions) { + this.inertiaOptions = options || {}; + this.mousePan.enable(); + this.touchPan.enable(); + this.el.classList.add('l7-touch-drag-pan'); + } + + /** + * Disables the "drag to pan" interaction. + * + * @example + * map.dragPan.disable(); + */ + public disable() { + this.mousePan.disable(); + this.touchPan.disable(); + this.el.classList.remove('l7-touch-drag-pan'); + } + + /** + * Returns a Boolean indicating whether the "drag to pan" interaction is enabled. + * + * @returns {boolean} `true` if the "drag to pan" interaction is enabled. + */ + public isEnabled() { + return this.mousePan.isEnabled() && this.touchPan.isEnabled(); + } + + /** + * Returns a Boolean indicating whether the "drag to pan" interaction is active, i.e. currently being used. + * + * @returns {boolean} `true` if the "drag to pan" interaction is active. + */ + public isActive() { + return this.mousePan.isActive() || this.touchPan.isActive(); + } +} diff --git a/packages/map/src/handler/shim/drag_rotate.ts b/packages/map/src/handler/shim/drag_rotate.ts new file mode 100644 index 0000000000..fe152bf19d --- /dev/null +++ b/packages/map/src/handler/shim/drag_rotate.ts @@ -0,0 +1,73 @@ +import { MousePitchHandler, MouseRotateHandler } from '../mouse'; + +/** + * The `DragRotateHandler` allows the user to rotate the map by clicking and + * dragging the cursor while holding the right mouse button or `ctrl` key. + */ +export default class DragRotateHandler { + private mouseRotate: MouseRotateHandler; + private mousePitch: MousePitchHandler; + private pitchWithRotate: boolean; + + /** + * @param {Object} [options] + * @param {number} [options.bearingSnap] The threshold, measured in degrees, that determines when the map's + * bearing will snap to north. + * @param {bool} [options.pitchWithRotate=true] Control the map pitch in addition to the bearing + * @private + */ + constructor( + options: { pitchWithRotate: boolean }, + mouseRotate: MouseRotateHandler, + mousePitch: MousePitchHandler, + ) { + this.pitchWithRotate = options.pitchWithRotate; + this.mouseRotate = mouseRotate; + this.mousePitch = mousePitch; + } + + /** + * Enables the "drag to rotate" interaction. + * + * @example + * map.dragRotate.enable(); + */ + public enable() { + this.mouseRotate.enable(); + if (this.pitchWithRotate) { + this.mousePitch.enable(); + } + } + + /** + * Disables the "drag to rotate" interaction. + * + * @example + * map.dragRotate.disable(); + */ + public disable() { + this.mouseRotate.disable(); + this.mousePitch.disable(); + } + + /** + * Returns a Boolean indicating whether the "drag to rotate" interaction is enabled. + * + * @returns {boolean} `true` if the "drag to rotate" interaction is enabled. + */ + public isEnabled() { + return ( + this.mouseRotate.isEnabled() && + (!this.pitchWithRotate || this.mousePitch.isEnabled()) + ); + } + + /** + * Returns a Boolean indicating whether the "drag to rotate" interaction is active, i.e. currently being used. + * + * @returns {boolean} `true` if the "drag to rotate" interaction is active. + */ + public isActive() { + return this.mouseRotate.isActive() || this.mousePitch.isActive(); + } +} diff --git a/packages/map/src/handler/shim/touch_zoom_rotate.ts b/packages/map/src/handler/shim/touch_zoom_rotate.ts new file mode 100644 index 0000000000..67c57f8247 --- /dev/null +++ b/packages/map/src/handler/shim/touch_zoom_rotate.ts @@ -0,0 +1,120 @@ +import TapDragZoomHandler from '../tap/tap_drag_zoom'; +import { TouchRotateHandler, TouchZoomHandler } from '../touch'; + +/** + * The `TouchZoomRotateHandler` allows the user to zoom and rotate the map by + * pinching on a touchscreen. + * + * They can zoom with one finger by double tapping and dragging. On the second tap, + * hold the finger down and drag up or down to zoom in or out. + */ +export default class TouchZoomRotateHandler { + private el: HTMLElement; + private touchZoom: TouchZoomHandler; + private touchRotate: TouchRotateHandler; + private tapDragZoom: TapDragZoomHandler; + private rotationDisabled: boolean; + private enabled: boolean; + + /** + * @private + */ + constructor( + el: HTMLElement, + touchZoom: TouchZoomHandler, + touchRotate: TouchRotateHandler, + tapDragZoom: TapDragZoomHandler, + ) { + this.el = el; + this.touchZoom = touchZoom; + this.touchRotate = touchRotate; + this.tapDragZoom = tapDragZoom; + this.rotationDisabled = false; + this.enabled = true; + } + + /** + * Enables the "pinch to rotate and zoom" interaction. + * + * @param {Object} [options] Options object. + * @param {string} [options.around] If "center" is passed, map will zoom around the center + * + * @example + * map.touchZoomRotate.enable(); + * @example + * map.touchZoomRotate.enable({ around: 'center' }); + */ + public enable(options: { around?: 'center' }) { + this.touchZoom.enable(options); + if (!this.rotationDisabled) { + this.touchRotate.enable(options); + } + this.tapDragZoom.enable(); + this.el.classList.add('l7-touch-zoom-rotate'); + } + + /** + * Disables the "pinch to rotate and zoom" interaction. + * + * @example + * map.touchZoomRotate.disable(); + */ + public disable() { + this.touchZoom.disable(); + this.touchRotate.disable(); + this.tapDragZoom.disable(); + this.el.classList.remove('l7-touch-zoom-rotate'); + } + + /** + * Returns a Boolean indicating whether the "pinch to rotate and zoom" interaction is enabled. + * + * @returns {boolean} `true` if the "pinch to rotate and zoom" interaction is enabled. + */ + public isEnabled() { + return ( + this.touchZoom.isEnabled() && + (this.rotationDisabled || this.touchRotate.isEnabled()) && + this.tapDragZoom.isEnabled() + ); + } + + /** + * Returns true if the handler is enabled and has detected the start of a zoom/rotate gesture. + * + * @returns {boolean} //eslint-disable-line + */ + public isActive() { + return ( + this.touchZoom.isActive() || + this.touchRotate.isActive() || + this.tapDragZoom.isActive() + ); + } + + /** + * Disables the "pinch to rotate" interaction, leaving the "pinch to zoom" + * interaction enabled. + * + * @example + * map.touchZoomRotate.disableRotation(); + */ + public disableRotation() { + this.rotationDisabled = true; + this.touchRotate.disable(); + } + + /** + * Enables the "pinch to rotate" interaction. + * + * @example + * map.touchZoomRotate.enable(); + * map.touchZoomRotate.enableRotation(); + */ + public enableRotation() { + this.rotationDisabled = false; + if (this.touchZoom.isEnabled()) { + this.touchRotate.enable(); + } + } +} diff --git a/packages/map/src/handler/tap/single_tap_recognizer.ts b/packages/map/src/handler/tap/single_tap_recognizer.ts new file mode 100644 index 0000000000..d524d587b7 --- /dev/null +++ b/packages/map/src/handler/tap/single_tap_recognizer.ts @@ -0,0 +1,85 @@ +// @ts-ignore +import Point from '../../geo/point'; +import { indexTouches } from '../handler_util'; + +function getCentroid(points: Point[]) { + const sum = new Point(0, 0); + for (const point of points) { + sum._add(point); + } + // @ts-ignore + return sum.div(points.length); +} + +export const MAX_TAP_INTERVAL = 500; +export const MAX_TOUCH_TIME = 500; +export const MAX_DIST = 30; + +export default class SingleTapRecognizer { + public numTouches: number; + public centroid: Point; + public startTime: number; + public aborted: boolean; + public touches: { [key: string]: Point }; + + constructor(options: { numTouches: number }) { + this.reset(); + this.numTouches = options.numTouches; + } + + public reset() { + delete this.centroid; + delete this.startTime; + delete this.touches; + this.aborted = false; + } + + public touchstart(e: TouchEvent, points: Point[], mapTouches: Touch[]) { + if (this.centroid || mapTouches.length > this.numTouches) { + this.aborted = true; + } + if (this.aborted) { + return; + } + + if (this.startTime === undefined) { + this.startTime = e.timeStamp; + } + + if (mapTouches.length === this.numTouches) { + this.centroid = getCentroid(points); + this.touches = indexTouches(mapTouches, points); + } + } + + public touchmove(e: TouchEvent, points: Point[], mapTouches: Touch[]) { + if (this.aborted || !this.centroid) { + return; + } + + const newTouches = indexTouches(mapTouches, points); + for (const id in this.touches) { + if (this.touches[id]) { + const prevPos = this.touches[id]; + const pos = newTouches[id]; + if (!pos || pos.dist(prevPos) > MAX_DIST) { + this.aborted = true; + } + } + } + } + + public touchend(e: TouchEvent, points: Point[], mapTouches: Touch[]) { + if (!this.centroid || e.timeStamp - this.startTime > MAX_TOUCH_TIME) { + this.aborted = true; + } + + if (mapTouches.length === 0) { + const centroid = !this.aborted && this.centroid; + this.reset(); + if (centroid) { + return centroid; + } + } + } +} diff --git a/packages/map/src/handler/tap/tap_drag_zoom.ts b/packages/map/src/handler/tap/tap_drag_zoom.ts new file mode 100644 index 0000000000..5b74790dc2 --- /dev/null +++ b/packages/map/src/handler/tap/tap_drag_zoom.ts @@ -0,0 +1,102 @@ +// @ts-ignore +import Point from '../../geo/point'; +import { MAX_TAP_INTERVAL } from './single_tap_recognizer'; +import TapRecognizer from './tap_recognizer'; + +export default class TapDragZoomHandler { + public enabled: boolean; + public active: boolean; + public swipePoint: Point; + public swipeTouch: number; + public tapTime: number; + public tap: TapRecognizer; + + constructor() { + this.tap = new TapRecognizer({ + numTouches: 1, + numTaps: 1, + }); + + this.reset(); + } + + public reset() { + this.active = false; + delete this.swipePoint; + delete this.swipeTouch; + delete this.tapTime; + this.tap.reset(); + } + + public touchstart(e: TouchEvent, points: Point[], mapTouches: Touch[]) { + if (this.swipePoint) { + return; + } + + if (this.tapTime && e.timeStamp - this.tapTime > MAX_TAP_INTERVAL) { + this.reset(); + } + + if (!this.tapTime) { + this.tap.touchstart(e, points, mapTouches); + } else if (mapTouches.length > 0) { + this.swipePoint = points[0]; + this.swipeTouch = mapTouches[0].identifier; + } + } + + public touchmove(e: TouchEvent, points: Point[], mapTouches: Touch[]) { + if (!this.tapTime) { + this.tap.touchmove(e, points, mapTouches); + } else if (this.swipePoint) { + if (mapTouches[0].identifier !== this.swipeTouch) { + return; + } + + const newSwipePoint = points[0]; + const dist = newSwipePoint.y - this.swipePoint.y; + this.swipePoint = newSwipePoint; + + e.preventDefault(); + this.active = true; + + return { + zoomDelta: dist / 128, + }; + } + } + + public touchend(e: TouchEvent, points: Point[], mapTouches: Touch[]) { + if (!this.tapTime) { + const point = this.tap.touchend(e, points, mapTouches); + if (point) { + this.tapTime = e.timeStamp; + } + } else if (this.swipePoint) { + if (mapTouches.length === 0) { + this.reset(); + } + } + } + + public touchcancel() { + this.reset(); + } + + public enable() { + this.enabled = true; + } + + public disable() { + this.enabled = false; + this.reset(); + } + + public isEnabled() { + return this.enabled; + } + + public isActive() { + return this.active; + } +} diff --git a/packages/map/src/handler/tap/tap_recognizer.ts b/packages/map/src/handler/tap/tap_recognizer.ts new file mode 100644 index 0000000000..642f7170a9 --- /dev/null +++ b/packages/map/src/handler/tap/tap_recognizer.ts @@ -0,0 +1,56 @@ +// @ts-ignore +import Point from '../../geo/point'; +import SingleTapRecognizer, { + MAX_DIST, + MAX_TAP_INTERVAL, +} from './single_tap_recognizer'; + +export default class TapRecognizer { + public singleTap: SingleTapRecognizer; + public numTaps: number; + public lastTime: number; + public lastTap: Point; + public count: number; + + constructor(options: { numTaps: number; numTouches: number }) { + this.singleTap = new SingleTapRecognizer(options); + this.numTaps = options.numTaps; + this.reset(); + } + + public reset() { + this.lastTime = Infinity; + delete this.lastTap; + this.count = 0; + this.singleTap.reset(); + } + + public touchstart(e: TouchEvent, points: Point[], mapTouches: Touch[]) { + this.singleTap.touchstart(e, points, mapTouches); + } + + public touchmove(e: TouchEvent, points: Point[], mapTouches: Touch[]) { + this.singleTap.touchmove(e, points, mapTouches); + } + + public touchend(e: TouchEvent, points: Point[], mapTouches: Touch[]) { + const tap = this.singleTap.touchend(e, points, mapTouches); + if (tap) { + const soonEnough = e.timeStamp - this.lastTime < MAX_TAP_INTERVAL; + const closeEnough = !this.lastTap || this.lastTap.dist(tap) < MAX_DIST; + + if (!soonEnough || !closeEnough) { + this.reset(); + } + + this.count++; + this.lastTime = e.timeStamp; + this.lastTap = tap; + + if (this.count === this.numTaps) { + this.reset(); + return tap; + } + } + } +} diff --git a/packages/map/src/handler/tap/tap_zoom.ts b/packages/map/src/handler/tap/tap_zoom.ts new file mode 100644 index 0000000000..040de7445b --- /dev/null +++ b/packages/map/src/handler/tap/tap_zoom.ts @@ -0,0 +1,99 @@ +// @ts-ignore +import Point from '../../geo/point'; +import { Map } from '../../map'; +import TapRecognizer from './tap_recognizer'; + +export default class TapZoomHandler { + public enabled: boolean; + public active: boolean; + public zoomIn: TapRecognizer; + public zoomOut: TapRecognizer; + + constructor() { + this.zoomIn = new TapRecognizer({ + numTouches: 1, + numTaps: 2, + }); + + this.zoomOut = new TapRecognizer({ + numTouches: 2, + numTaps: 1, + }); + + this.reset(); + } + + public reset() { + this.active = false; + this.zoomIn.reset(); + this.zoomOut.reset(); + } + + public touchstart(e: TouchEvent, points: Point[], mapTouches: Touch[]) { + this.zoomIn.touchstart(e, points, mapTouches); + this.zoomOut.touchstart(e, points, mapTouches); + } + + public touchmove(e: TouchEvent, points: Point[], mapTouches: Touch[]) { + this.zoomIn.touchmove(e, points, mapTouches); + this.zoomOut.touchmove(e, points, mapTouches); + } + + public touchend(e: TouchEvent, points: Point[], mapTouches: Touch[]) { + const zoomInPoint = this.zoomIn.touchend(e, points, mapTouches); + const zoomOutPoint = this.zoomOut.touchend(e, points, mapTouches); + + if (zoomInPoint) { + this.active = true; + e.preventDefault(); + setTimeout(() => this.reset(), 0); + return { + cameraAnimation: (map: Map) => + map.easeTo( + { + duration: 300, + zoom: map.getZoom() + 1, + around: map.unproject(zoomInPoint), + }, + { originalEvent: e }, + ), + }; + } else if (zoomOutPoint) { + this.active = true; + e.preventDefault(); + setTimeout(() => this.reset(), 0); + return { + cameraAnimation: (map: Map) => + map.easeTo( + { + duration: 300, + zoom: map.getZoom() - 1, + around: map.unproject(zoomOutPoint), + }, + { originalEvent: e }, + ), + }; + } + } + + public touchcancel() { + this.reset(); + } + + public enable() { + this.enabled = true; + } + + public disable() { + this.enabled = false; + this.reset(); + } + + public isEnabled() { + return this.enabled; + } + + public isActive() { + return this.active; + } +} diff --git a/packages/map/src/handler/touch/index.ts b/packages/map/src/handler/touch/index.ts new file mode 100644 index 0000000000..e00ac2daeb --- /dev/null +++ b/packages/map/src/handler/touch/index.ts @@ -0,0 +1,11 @@ +import TouchPanHandler from './touch_pan'; +import TouchPitchHandler from './touch_pitch'; +import TouchRotateHandler from './touch_rotate'; +import TouchZoomHandler from './touch_zoom'; + +export { + TouchPanHandler, + TouchPitchHandler, + TouchRotateHandler, + TouchZoomHandler, +}; diff --git a/packages/map/src/handler/touch/touch_pan.ts b/packages/map/src/handler/touch/touch_pan.ts new file mode 100644 index 0000000000..4cbf15bc8e --- /dev/null +++ b/packages/map/src/handler/touch/touch_pan.ts @@ -0,0 +1,112 @@ +// @ts-ignore +import Point from '../../geo/point'; +import { indexTouches } from '../handler_util'; + +export default class TouchPanHandler { + public enabled: boolean; + public active: boolean; + public touches: { [key: string]: Point }; + public minTouches: number; + public clickTolerance: number; + public sum: Point; + + constructor(options: { clickTolerance: number }) { + this.minTouches = 1; + this.clickTolerance = options.clickTolerance || 1; + this.reset(); + } + + public reset() { + this.active = false; + this.touches = {}; + this.sum = new Point(0, 0); + } + + public touchstart(e: TouchEvent, points: Point[], mapTouches: Touch[]) { + return this.calculateTransform(e, points, mapTouches); + } + + public touchmove(e: TouchEvent, points: Point[], mapTouches: Touch[]) { + if (!this.active) { + return; + } + e.preventDefault(); + return this.calculateTransform(e, points, mapTouches); + } + + public touchend(e: TouchEvent, points: Point[], mapTouches: Touch[]) { + this.calculateTransform(e, points, mapTouches); + + if (this.active && mapTouches.length < this.minTouches) { + this.reset(); + } + } + + public touchcancel() { + this.reset(); + } + public enable() { + this.enabled = true; + } + + public disable() { + this.enabled = false; + this.reset(); + } + + public isEnabled() { + return this.enabled; + } + + public isActive() { + return this.active; + } + + private calculateTransform( + e: TouchEvent, + points: Point[], + mapTouches: Touch[], + ) { + if (mapTouches.length > 0) { + this.active = true; + } + + const touches = indexTouches(mapTouches, points); + + const touchPointSum = new Point(0, 0); + const touchDeltaSum = new Point(0, 0); + let touchDeltaCount = 0; + + for (const identifier in touches) { + if (touches[identifier]) { + const point = touches[identifier]; + const prevPoint = this.touches[identifier]; + if (prevPoint) { + touchPointSum._add(point); + touchDeltaSum._add(point.sub(prevPoint)); + touchDeltaCount++; + touches[identifier] = point; + } + } + } + + this.touches = touches; + + if (touchDeltaCount < this.minTouches || !touchDeltaSum.mag()) { + return; + } + // @ts-ignore + const panDelta = touchDeltaSum.div(touchDeltaCount); + this.sum._add(panDelta); + if (this.sum.mag() < this.clickTolerance) { + return; + } + // @ts-ignore + const around = touchPointSum.div(touchDeltaCount); + + return { + around, + panDelta, + }; + } +} diff --git a/packages/map/src/handler/touch/touch_pitch.ts b/packages/map/src/handler/touch/touch_pitch.ts new file mode 100644 index 0000000000..99a72a3175 --- /dev/null +++ b/packages/map/src/handler/touch/touch_pitch.ts @@ -0,0 +1,85 @@ +// @ts-ignore +import Point from '../../geo/point'; +import TwoTouchHandler from './two_touch'; + +function isVertical(vector: { x: number; y: number }) { + return Math.abs(vector.y) > Math.abs(vector.x); +} + +const ALLOWED_SINGLE_TOUCH_TIME = 100; + +export default class TouchPitchHandler extends TwoTouchHandler { + public valid: boolean | void; + public firstMove: number; + public lastPoints: [Point, Point]; + + public reset() { + super.reset(); + this.valid = undefined; + delete this.firstMove; + delete this.lastPoints; + } + + public start(points: [Point, Point]) { + this.lastPoints = points; + if (isVertical(points[0].sub(points[1]))) { + // fingers are more horizontal than vertical + this.valid = false; + } + } + + public move(points: [Point, Point], center: Point, e: TouchEvent) { + const vectorA = points[0].sub(this.lastPoints[0]); + const vectorB = points[1].sub(this.lastPoints[1]); + + this.valid = this.gestureBeginsVertically(vectorA, vectorB, e.timeStamp); + if (!this.valid) { + return; + } + + this.lastPoints = points; + this.active = true; + const yDeltaAverage = (vectorA.y + vectorB.y) / 2; + const degreesPerPixelMoved = -0.5; + return { + pitchDelta: yDeltaAverage * degreesPerPixelMoved, + }; + } + + public gestureBeginsVertically( + vectorA: Point, + vectorB: Point, + timeStamp: number, + ) { + if (this.valid !== undefined) { + return this.valid; + } + + const threshold = 2; + const movedA = vectorA.mag() >= threshold; + const movedB = vectorB.mag() >= threshold; + + // neither finger has moved a meaningful amount, wait + if (!movedA && !movedB) { + return; + } + + // One finger has moved and the other has not. + // If enough time has passed, decide it is not a pitch. + if (!movedA || !movedB) { + if (this.firstMove === undefined) { + this.firstMove = timeStamp; + } + + if (timeStamp - this.firstMove < ALLOWED_SINGLE_TOUCH_TIME) { + // still waiting for a movement from the second finger + return undefined; + } else { + return false; + } + } + + const isSameDirection = vectorA.y > 0 === vectorB.y > 0; + return isVertical(vectorA) && isVertical(vectorB) && isSameDirection; + } +} diff --git a/packages/map/src/handler/touch/touch_rotate.ts b/packages/map/src/handler/touch/touch_rotate.ts new file mode 100644 index 0000000000..39c2d89d24 --- /dev/null +++ b/packages/map/src/handler/touch/touch_rotate.ts @@ -0,0 +1,60 @@ +// @ts-ignore +import Point from '../../geo/point'; +import DOM from '../../utils/dom'; +import TwoTouchHandler from './two_touch'; + +const ROTATION_THRESHOLD = 25; // pixels along circumference of touch circle + +function getBearingDelta(a: Point, b: Point) { + return (a.angleWith(b) * 180) / Math.PI; +} + +export default class TouchRotateHandler extends TwoTouchHandler { + private minDiameter: number; + + public reset() { + super.reset(); + delete this.minDiameter; + delete this.startVector; + delete this.vector; + } + + public start(points: [Point, Point]) { + this.startVector = this.vector = points[0].sub(points[1]); + this.minDiameter = points[0].dist(points[1]); + } + + public move(points: [Point, Point], pinchAround: Point) { + const lastVector = this.vector; + this.vector = points[0].sub(points[1]); + + if (!this.active && this.isBelowThreshold(this.vector)) { + return; + } + this.active = true; + + return { + bearingDelta: getBearingDelta(this.vector, lastVector), + pinchAround, + }; + } + + private isBelowThreshold(vector: Point) { + /* + * The threshold before a rotation actually happens is configured in + * pixels alongth circumference of the circle formed by the two fingers. + * This makes the threshold in degrees larger when the fingers are close + * together and smaller when the fingers are far apart. + * + * Use the smallest diameter from the whole gesture to reduce sensitivity + * when pinching in and out. + */ + + this.minDiameter = Math.min(this.minDiameter, vector.mag()); + const circumference = Math.PI * this.minDiameter; + const threshold = (ROTATION_THRESHOLD / circumference) * 360; + + const bearingDeltaSinceStart = getBearingDelta(vector, this.startVector); + return Math.abs(bearingDeltaSinceStart) < threshold; + } +} diff --git a/packages/map/src/handler/touch/touch_zoom.ts b/packages/map/src/handler/touch/touch_zoom.ts new file mode 100644 index 0000000000..55cb7b33e0 --- /dev/null +++ b/packages/map/src/handler/touch/touch_zoom.ts @@ -0,0 +1,39 @@ +// @ts-ignore +import Point from '../../geo/point'; +import DOM from '../../utils/dom'; +import TwoTouchHandler from './two_touch'; + +const ZOOM_THRESHOLD = 0.1; +function getZoomDelta(distance: number, lastDistance: number) { + return Math.log(distance / lastDistance) / Math.LN2; +} +export default class TouchZoomHandler extends TwoTouchHandler { + private distance: number; + private startDistance: number; + + public reset() { + super.reset(); + delete this.distance; + delete this.startDistance; + } + + public start(points: [Point, Point]) { + this.startDistance = this.distance = points[0].dist(points[1]); + } + + public move(points: [Point, Point], pinchAround: Point) { + const lastDistance = this.distance; + this.distance = points[0].dist(points[1]); + if ( + !this.active && + Math.abs(getZoomDelta(this.distance, this.startDistance)) < ZOOM_THRESHOLD + ) { + return; + } + this.active = true; + return { + zoomDelta: getZoomDelta(this.distance, lastDistance), + pinchAround, + }; + } +} diff --git a/packages/map/src/handler/touch/two_touch.ts b/packages/map/src/handler/touch/two_touch.ts new file mode 100644 index 0000000000..dfb91cc175 --- /dev/null +++ b/packages/map/src/handler/touch/two_touch.ts @@ -0,0 +1,116 @@ +// @ts-ignore +import Point from '../../geo/point'; +import DOM from '../../utils/dom'; + +export default class TwoTouchHandler { + protected enabled: boolean; + protected active: boolean; + protected firstTwoTouches: [number, number]; + protected vector: Point; + protected startVector: Point; + protected aroundCenter: boolean; + + constructor() { + this.reset(); + } + + public reset() { + this.active = false; + delete this.firstTwoTouches; + } + public start(points: [Point, Point]) { + return; + } // eslint-disable-line + public move( + points: [Point, Point], + pinchAround: Point | null, + e: TouchEvent, + ) { + return; + } // eslint-disable-line + + public touchstart(e: TouchEvent, points: Point[], mapTouches: Touch[]) { + // console.log(e.target, e.targetTouches.length ? e.targetTouches[0].target : null); + // log('touchstart', points, e.target.innerHTML, e.targetTouches.length ? e.targetTouches[0].target.innerHTML: undefined); + if (this.firstTwoTouches || mapTouches.length < 2) { + return; + } + + this.firstTwoTouches = [mapTouches[0].identifier, mapTouches[1].identifier]; + + // implemented by child classes + this.start([points[0], points[1]]); + } + + public touchmove(e: TouchEvent, points: Point[], mapTouches: Touch[]) { + if (!this.firstTwoTouches) { + return; + } + + e.preventDefault(); + + const [idA, idB] = this.firstTwoTouches; + const a = getTouchById(mapTouches, points, idA); + const b = getTouchById(mapTouches, points, idB); + if (!a || !b) { + return; + } + const pinchAround = this.aroundCenter ? null : a.add(b).div(2); + + // implemented by child classes + return this.move([a, b], pinchAround, e); + } + + public touchend(e: TouchEvent, points: Point[], mapTouches: Touch[]) { + if (!this.firstTwoTouches) { + return; + } + + const [idA, idB] = this.firstTwoTouches; + const a = getTouchById(mapTouches, points, idA); + const b = getTouchById(mapTouches, points, idB); + if (a && b) { + return; + } + + if (this.active) { + DOM.suppressClick(); + } + + this.reset(); + } + + public touchcancel() { + this.reset(); + } + + public enable(options?: { around?: 'center' }) { + this.enabled = true; + this.aroundCenter = !!options && options.around === 'center'; + } + + public disable() { + this.enabled = false; + this.reset(); + } + + public isEnabled() { + return this.enabled; + } + + public isActive() { + return this.active; + } +} + +function getTouchById( + mapTouches: Touch[], + points: Point[], + identifier: number, +) { + for (let i = 0; i < mapTouches.length; i++) { + if (mapTouches[i].identifier === identifier) { + return points[i]; + } + } +} diff --git a/packages/map/src/hash.ts b/packages/map/src/hash.ts new file mode 100644 index 0000000000..6b7af9cd1c --- /dev/null +++ b/packages/map/src/hash.ts @@ -0,0 +1,139 @@ +// @ts-ignore +// tslint:disable-next-line:no-submodule-imports +import throttle from 'lodash/throttle'; +import { Map } from './map'; + +/* + * Adds the map's position to its page's location hash. + * Passed as an option to the map object. + * + * @returns {Hash} `this` + */ +class Hash { + private map: Map; + private updateHash: () => number | void; + private hashName?: string; + + constructor(hashName?: string) { + this.hashName = hashName && encodeURIComponent(hashName); + + // Mobile Safari doesn't allow updating the hash more than 100 times per 30 seconds. + this.updateHash = throttle(this.updateHashUnthrottled, (30 * 1000) / 100); + } + public addTo(map: Map) { + this.map = map; + window.addEventListener('hashchange', this.onHashChange, false); + this.map.on('moveend', this.updateHash); + return this; + } + public remove() { + window.removeEventListener('hashchange', this.onHashChange, false); + this.map.off('moveend', this.updateHash); + // clearTimeout(this.updateHash()); + + delete this.map; + return this; + } + + public onHashChange = () => { + const loc = this.getCurrentHash(); + if (loc.length >= 3 && !loc.some((v: string) => isNaN(+v))) { + const bearing = + this.map.dragRotate.isEnabled() && this.map.touchZoomRotate.isEnabled() + ? +(loc[3] || 0) + : this.map.getBearing(); + this.map.jumpTo({ + center: [+loc[2], +loc[1]], + zoom: +loc[0], + bearing, + pitch: +(loc[4] || 0), + }); + return true; + } + return false; + }; + + private getCurrentHash = () => { + // Get the current hash from location, stripped from its number sign + const hash = window.location.hash.replace('#', ''); + if (this.hashName) { + // Split the parameter-styled hash into parts and find the value we need + let keyval; + hash + .split('&') + .map((part) => part.split('=')) + .forEach((part) => { + if (part[0] === this.hashName) { + keyval = part; + } + }); + return (keyval ? keyval[1] || '' : '').split('/'); + } + return hash.split('/'); + }; + + private getHashString(mapFeedback?: boolean) { + const center = this.map.getCenter(); + const zoom = Math.round(this.map.getZoom() * 100) / 100; + // derived from equation: 512px * 2^z / 360 / 10^d < 0.5px + const precision = Math.ceil( + (zoom * Math.LN2 + Math.log(512 / 360 / 0.5)) / Math.LN10, + ); + const m = Math.pow(10, precision); + const lng = Math.round(center.lng * m) / m; + const lat = Math.round(center.lat * m) / m; + const bearing = this.map.getBearing(); + const pitch = this.map.getPitch(); + let hash = ''; + if (mapFeedback) { + // new map feedback site has some constraints that don't allow + // us to use the same hash format as we do for the Map hash option. + hash += `/${lng}/${lat}/${zoom}`; + } else { + hash += `${zoom}/${lat}/${lng}`; + } + + if (bearing || pitch) { + hash += `/${Math.round(bearing * 10) / 10}`; + } + if (pitch) { + hash += `/${Math.round(pitch)}`; + } + + if (this.hashName) { + const hashName = this.hashName; + let found = false; + const parts = window.location.hash + .slice(1) + .split('&') + .map((part) => { + const key = part.split('=')[0]; + if (key === hashName) { + found = true; + return `${key}=${hash}`; + } + return part; + }) + .filter((a) => a); + if (!found) { + parts.push(`${hashName}=${hash}`); + } + return `#${parts.join('&')}`; + } + + return `#${hash}`; + } + + private updateHashUnthrottled = () => { + const hash = this.getHashString(); + try { + window.history.replaceState(window.history.state, '', hash); + } catch (SecurityError) { + // IE11 does not allow this if the page is within an iframe created + // with iframe.contentWindow.document.write(...). + // https://github.com/mapbox/mapbox-gl-js/issues/7410 + } + }; +} + +export default Hash; diff --git a/packages/map/src/index.ts b/packages/map/src/index.ts new file mode 100644 index 0000000000..111a393c67 --- /dev/null +++ b/packages/map/src/index.ts @@ -0,0 +1 @@ +export * from './map'; diff --git a/packages/map/src/interface.ts b/packages/map/src/interface.ts new file mode 100644 index 0000000000..99f94d7c67 --- /dev/null +++ b/packages/map/src/interface.ts @@ -0,0 +1,32 @@ +import { LngLatBoundsLike } from './geo/lng_lat_bounds'; + +export interface IMapOptions { + hash: boolean; + style?: any; + container?: HTMLElement | string; + center: [number, number]; + zoom: number; + bearing: number; + pitch: number; + interactive: boolean; + scrollZoom: boolean; + bounds?: LngLatBoundsLike; + maxBounds?: LngLatBoundsLike; + fitBoundsOptions?: any; + minZoom: number; + maxZoom: number; + minPitch: number; + maxPitch: number; + boxZoom: boolean; + dragRotate: boolean; + dragPan: boolean; + keyboard: boolean; + doubleClickZoom: boolean; + touchZoomRotate: boolean; + touchPitch: boolean; + trackResize: boolean; + renderWorldCopies: boolean; + bearingSnap: number; + clickTolerance: number; + pitchWithRotate: boolean; +} diff --git a/packages/map/src/map.ts b/packages/map/src/map.ts new file mode 100644 index 0000000000..6865dee993 --- /dev/null +++ b/packages/map/src/map.ts @@ -0,0 +1,380 @@ +import { DOM } from '@antv/l7-utils'; +import { merge } from 'lodash'; +import Camera from './camera'; +import './css/l7.css'; +import LngLat, { LngLatLike } from './geo/lng_lat'; +import LngLatBounds, { LngLatBoundsLike } from './geo/lng_lat_bounds'; +// @ts-ignore +import Point, { PointLike } from './geo/point'; +import BoxZoomHandler from './handler/box_zoom'; +import HandlerManager from './handler/handler_manager'; +import KeyboardHandler from './handler/keyboard'; + +import ScrollZoomHandler from './handler/scroll_zoom'; +import DoubleClickZoomHandler from './handler/shim/dblclick_zoom'; +import DragPanHandler from './handler/shim/drag_pan'; +import DragRotateHandler from './handler/shim/drag_rotate'; +import TouchZoomRotateHandler from './handler/shim/touch_zoom_rotate'; +import { TouchPitchHandler } from './handler/touch'; +import Hash from './hash'; +import { IMapOptions } from './interface'; +import { renderframe } from './util'; +import { PerformanceUtils } from './utils/performance'; +import TaskQueue, { TaskID } from './utils/task_queue'; +type CallBack = (_: number) => void; +const defaultMinZoom = -2; +const defaultMaxZoom = 22; + +// the default values, but also the valid range +const defaultMinPitch = 0; +const defaultMaxPitch = 60; + +const DefaultOptions: IMapOptions = { + hash: false, + zoom: -1, + center: [112, 32], + pitch: 0, + bearing: 0, + interactive: true, + minZoom: defaultMinZoom, + maxZoom: defaultMaxZoom, + minPitch: defaultMinPitch, + maxPitch: defaultMaxPitch, + scrollZoom: true, + boxZoom: true, + dragRotate: true, + dragPan: true, + keyboard: true, + doubleClickZoom: true, + touchZoomRotate: true, + touchPitch: true, + bearingSnap: 7, + clickTolerance: 3, + pitchWithRotate: true, + trackResize: true, + renderWorldCopies: true, +}; +export class Map extends Camera { + public doubleClickZoom: DoubleClickZoomHandler; + public dragRotate: DragRotateHandler; + public dragPan: DragPanHandler; + public touchZoomRotate: TouchZoomRotateHandler; + public scrollZoom: ScrollZoomHandler; + public keyboard: KeyboardHandler; + public touchPitch: TouchPitchHandler; + public boxZoom: BoxZoomHandler; + public handlers: HandlerManager; + + private container: HTMLElement; + private canvas: HTMLCanvasElement; + private canvasContainer: HTMLElement; + private renderTaskQueue: TaskQueue = new TaskQueue(); + private frame: { cancel: () => void } | null; + private trackResize: boolean = true; + private hash: Hash | undefined; + constructor(options: Partial) { + super(merge({}, DefaultOptions, options)); + this.initContainer(); + this.resize(); + this.handlers = new HandlerManager(this, this.options); + // this.on('move', () => this.update()); + // this.on('moveend', () => this.update()); + // this.on('zoom', () => { + // console.log('zoom'); + // }); + + if (typeof window !== 'undefined') { + window.addEventListener('online', this.onWindowOnline, false); + window.addEventListener('resize', this.onWindowResize, false); + window.addEventListener('orientationchange', this.onWindowResize, false); + } + + const hashName = + (typeof options.hash === 'string' && options.hash) || undefined; + if (options.hash) { + this.hash = new Hash(hashName).addTo(this) as Hash; + } + + // don't set position from options if set through hash + if (!this.hash || !this.hash.onHashChange()) { + this.jumpTo({ + center: options.center, + zoom: options.zoom, + bearing: options.bearing, + pitch: options.pitch, + }); + + if (options.bounds) { + this.resize(); + this.fitBounds( + options.bounds, + merge({}, options.fitBoundsOptions, { duration: 0 }), + ); + } + } + } + + public resize(eventData?: any) { + const dimensions = this.containerDimensions(); + const width = dimensions[0]; + const height = dimensions[1]; + + // this.resizeCanvas(width, height); + this.transform.resize(width, height); + const fireMoving = !this.moving; + if (fireMoving) { + this.stop(); + this.emit('movestart', new Event('movestart', eventData)); + this.emit('move', new Event('move', eventData)); + } + + this.emit('resize', new Event('resize', eventData)); + + if (fireMoving) { + this.emit('moveend', new Event('moveend', eventData)); + } + + return this; + } + + public getContainer() { + return this.container; + } + + public getCanvas() { + return this.canvas; + } + + public getCanvasContainer() { + return this.canvasContainer; + } + + public project(lngLat: LngLatLike) { + return this.transform.locationPoint(LngLat.convert(lngLat)); + } + + public unproject(point: PointLike) { + return this.transform.pointLocation(Point.convert(point)); + } + + public getBounds(): LngLatBounds { + return this.transform.getBounds(); + } + + public getMaxBounds(): LngLatBounds | null { + return this.transform.getMaxBounds(); + } + + public setMaxBounds(bounds: LngLatBoundsLike) { + this.transform.setMaxBounds(LngLatBounds.convert(bounds)); + } + + public setStyle(style: any) { + return; + } + public setMinZoom(minZoom?: number) { + minZoom = + minZoom === null || minZoom === undefined ? defaultMinZoom : minZoom; + if (minZoom >= defaultMinZoom && minZoom <= this.transform.maxZoom) { + this.transform.minZoom = minZoom; + if (this.getZoom() < minZoom) { + this.setZoom(minZoom); + } + + return this; + } else { + throw new Error( + `minZoom must be between ${defaultMinZoom} and the current maxZoom, inclusive`, + ); + } + } + + public getMinZoom() { + return this.transform.minZoom; + } + + public setMaxZoom(maxZoom?: number) { + maxZoom = + maxZoom === null || maxZoom === undefined ? defaultMaxZoom : maxZoom; + + if (maxZoom >= this.transform.minZoom) { + this.transform.maxZoom = maxZoom; + if (this.getZoom() > maxZoom) { + this.setZoom(maxZoom); + } + + return this; + } else { + throw new Error('maxZoom must be greater than the current minZoom'); + } + } + public getMaxZoom() { + return this.transform.maxZoom; + } + + public setMinPitch(minPitch?: number) { + minPitch = + minPitch === null || minPitch === undefined ? defaultMinPitch : minPitch; + + if (minPitch < defaultMinPitch) { + throw new Error( + `minPitch must be greater than or equal to ${defaultMinPitch}`, + ); + } + + if (minPitch >= defaultMinPitch && minPitch <= this.transform.maxPitch) { + this.transform.minPitch = minPitch; + if (this.getPitch() < minPitch) { + this.setPitch(minPitch); + } + + return this; + } else { + throw new Error( + `minPitch must be between ${defaultMinPitch} and the current maxPitch, inclusive`, + ); + } + } + + public getMinPitch() { + return this.transform.minPitch; + } + + public setMaxPitch(maxPitch?: number) { + maxPitch = + maxPitch === null || maxPitch === undefined ? defaultMaxPitch : maxPitch; + + if (maxPitch > defaultMaxPitch) { + throw new Error( + `maxPitch must be less than or equal to ${defaultMaxPitch}`, + ); + } + + if (maxPitch >= this.transform.minPitch) { + this.transform.maxPitch = maxPitch; + if (this.getPitch() > maxPitch) { + this.setPitch(maxPitch); + } + + return this; + } else { + throw new Error('maxPitch must be greater than the current minPitch'); + } + } + + public getMaxPitch() { + return this.transform.maxPitch; + } + + public getRenderWorldCopies() { + return this.transform.renderWorldCopies; + } + + public setRenderWorldCopies(renderWorldCopies?: boolean) { + this.transform.renderWorldCopies = !!renderWorldCopies; + } + + public remove() { + if (this.frame) { + this.frame.cancel(); + this.frame = null; + } + this.renderTaskQueue.clear(); + } + + public requestRenderFrame(cb: CallBack): TaskID { + this.update(); + return this.renderTaskQueue.add(cb); + } + + public cancelRenderFrame(id: TaskID) { + return this.renderTaskQueue.remove(id); + } + + public triggerRepaint() { + if (!this.frame) { + this.frame = renderframe((paintStartTimeStamp: number) => { + PerformanceUtils.frame(paintStartTimeStamp); + this.frame = null; + this.update(paintStartTimeStamp); + }); + } + } + + public update(time?: number) { + if (!this.frame) { + this.frame = renderframe((paintStartTimeStamp: number) => { + PerformanceUtils.frame(paintStartTimeStamp); + this.frame = null; + this.renderTaskQueue.run(time); + }); + } + } + + private initContainer() { + if (typeof this.options.container === 'string') { + this.container = window.document.getElementById( + this.options.container, + ) as HTMLElement; + if (!this.container) { + throw new Error(`Container '${this.options.container}' not found.`); + } + } else if (this.options.container instanceof HTMLElement) { + this.container = this.options.container; + } else { + throw new Error( + "Invalid type: 'container' must be a String or HTMLElement.", + ); + } + + const container = this.container; + container.classList.add('l7-map'); + + const canvasContainer = (this.canvasContainer = DOM.create( + 'div', + 'l7-canvas-container', + container, + )); + if (this.options.interactive) { + canvasContainer.classList.add('l7-interactive'); + } + + // this.canvas = DOM.create( + // 'canvas', + // 'l7-canvas', + // canvasContainer, + // ) as HTMLCanvasElement; + // this.canvas.setAttribute('tabindex', '-'); + // this.canvas.setAttribute('aria-label', 'Map'); + } + + private containerDimensions(): [number, number] { + let width = 0; + let height = 0; + if (this.container) { + width = this.container.clientWidth || 400; + height = this.container.clientHeight || 300; + } + return [width, height]; + } + + private resizeCanvas(width: number, height: number) { + const pixelRatio = window.devicePixelRatio || 1; + this.canvas.width = pixelRatio * width; + this.canvas.height = pixelRatio * height; + + // Maintain the same canvas size, potentially downscaling it for HiDPI displays + this.canvas.style.width = `${width}px`; + this.canvas.style.height = `${height}px`; + } + + private onWindowOnline = () => { + this.update(); + }; + + private onWindowResize = (event: Event) => { + if (this.trackResize) { + this.resize({ originalEvent: event }).update(); + } + }; +} diff --git a/packages/map/src/util.ts b/packages/map/src/util.ts new file mode 100644 index 0000000000..6443c2f00a --- /dev/null +++ b/packages/map/src/util.ts @@ -0,0 +1,84 @@ +// @ts-ignore +import UnitBezier from '@mapbox/unitbezier'; +let reducedMotionQuery: MediaQueryList; +export interface ICancelable { + cancel: () => void; +} +export function wrap(n: number, min: number, max: number): number { + const d = max - min; + const w = ((((n - min) % d) + d) % d) + min; + return w === min ? max : w; +} + +export function clamp(n: number, min: number, max: number): number { + return Math.min(max, Math.max(min, n)); +} + +export function interpolate(a: number, b: number, t: number) { + return a * (1 - t) + b * t; +} +export function bezier( + p1x: number, + p1y: number, + p2x: number, + p2y: number, +): (t: number) => number { + const bez = new UnitBezier(p1x, p1y, p2x, p2y); + return (t: number) => { + return bez.solve(t); + }; +} + +export const ease = bezier(0.25, 0.1, 0.25, 1); + +export function prefersReducedMotion(): boolean { + if (!window.matchMedia) { + return false; + } + // Lazily initialize media query + if (reducedMotionQuery == null) { + reducedMotionQuery = window.matchMedia('(prefers-reduced-motion: reduce)'); + } + return reducedMotionQuery.matches; +} + +export function pick( + src: { [key: string]: any }, + properties: string[], +): { [key: string]: any } { + const result: { [key: string]: any } = {}; + for (const name of properties) { + if (name in src) { + result[name] = src[name]; + } + } + return result; +} + +export const now = + window.performance && window.performance.now + ? window.performance.now.bind(window.performance) + : Date.now.bind(Date); + +export const raf = + window.requestAnimationFrame || + // @ts-ignore + window.mozRequestAnimationFrame || + window.webkitRequestAnimationFrame || + // @ts-ignore + window.msRequestAnimationFrame; + +export const cancel = + window.cancelAnimationFrame || + // @ts-ignore + window.mozCancelAnimationFrame || + window.webkitCancelAnimationFrame || + // @ts-ignore + window.msCancelAnimationFrame; + +export function renderframe( + fn: (paintStartTimestamp: number) => void, +): ICancelable { + const frame = raf(fn); + return { cancel: () => cancel(frame) }; +} diff --git a/packages/map/src/utils/Aabb.ts b/packages/map/src/utils/Aabb.ts new file mode 100644 index 0000000000..9ee73bfcac --- /dev/null +++ b/packages/map/src/utils/Aabb.ts @@ -0,0 +1,96 @@ +import { vec3, vec4 } from 'gl-matrix'; +import Frustum from './primitives'; +export default class Aabb { + public min: vec3; + public max: vec3; + public center: vec3; + + constructor(min: vec3, max: vec3) { + this.min = min; + this.max = max; + this.center = vec3.scale( + new Float32Array(3), + vec3.add(new Float32Array(3), this.min, this.max), + 0.5, + ); + } + + public quadrant(index: number): Aabb { + const split = [index % 2 === 0, index < 2]; + const qMin = vec3.clone(this.min); + const qMax = vec3.clone(this.max); + for (let axis = 0; axis < split.length; axis++) { + qMin[axis] = split[axis] ? this.min[axis] : this.center[axis]; + qMax[axis] = split[axis] ? this.center[axis] : this.max[axis]; + } + // Elevation is always constant, hence quadrant.max.z = this.max.z + qMax[2] = this.max[2]; + return new Aabb(qMin, qMax); + } + + public distanceX(point: number[]): number { + const pointOnAabb = Math.max(Math.min(this.max[0], point[0]), this.min[0]); + return pointOnAabb - point[0]; + } + + public distanceY(point: number[]): number { + const pointOnAabb = Math.max(Math.min(this.max[1], point[1]), this.min[1]); + return pointOnAabb - point[1]; + } + + // Performs a frustum-aabb intersection test. Returns 0 if there's no intersection, + // 1 if shapes are intersecting and 2 if the aabb if fully inside the frustum. + public intersects(frustum: Frustum): number { + // Execute separating axis test between two convex objects to find intersections + // Each frustum plane together with 3 major axes define the separating axes + // Note: test only 4 points as both min and max points have equal elevation + + const aabbPoints = [ + [this.min[0], this.min[1], 0.0, 1], + [this.max[0], this.min[1], 0.0, 1], + [this.max[0], this.max[1], 0.0, 1], + [this.min[0], this.max[1], 0.0, 1], + ]; + + let fullyInside = true; + + for (const plane of frustum.planes) { + let pointsInside = 0; + + for (const i of aabbPoints) { + // @ts-ignore + pointsInside += vec4.dot(plane, i) >= 0; + } + + if (pointsInside === 0) { + return 0; + } + + if (pointsInside !== aabbPoints.length) { + fullyInside = false; + } + } + + if (fullyInside) { + return 2; + } + + for (let axis = 0; axis < 3; axis++) { + let projMin = Number.MAX_VALUE; + let projMax = -Number.MAX_VALUE; + + for (const p of frustum.points) { + const projectedPoint = p[axis] - this.min[axis]; + + projMin = Math.min(projMin, projectedPoint); + projMax = Math.max(projMax, projectedPoint); + } + + if (projMax < 0 || projMin > this.max[axis] - this.min[axis]) { + return 0; + } + } + + return 1; + } +} diff --git a/packages/map/src/utils/dom.ts b/packages/map/src/utils/dom.ts new file mode 100644 index 0000000000..888ddf56fb --- /dev/null +++ b/packages/map/src/utils/dom.ts @@ -0,0 +1,168 @@ +// @ts-ignore +import Point from '../geo/point'; + +const DOM: { + [key: string]: (...arg: any[]) => any; +} = {}; +export default DOM; + +DOM.create = (tagName: string, className?: string, container?: HTMLElement) => { + const el = window.document.createElement(tagName); + if (className !== undefined) { + el.className = className; + } + if (container) { + container.appendChild(el); + } + return el; +}; + +DOM.createNS = (namespaceURI: string, tagName: string) => { + const el = window.document.createElementNS(namespaceURI, tagName); + return el; +}; + +const docStyle = window.document && window.document.documentElement.style; + +function testProp(props: any) { + if (!docStyle) { + return props[0]; + } + for (const i of props) { + if (i in docStyle) { + return i; + } + } + return props[0]; +} + +const selectProp = testProp([ + 'userSelect', + 'MozUserSelect', + 'WebkitUserSelect', + 'msUserSelect', +]); +let userSelect: any; + +DOM.disableDrag = () => { + if (docStyle && selectProp) { + userSelect = docStyle[selectProp]; + docStyle[selectProp] = 'none'; + } +}; + +DOM.enableDrag = () => { + if (docStyle && selectProp) { + docStyle[selectProp] = userSelect; + } +}; + +const transformProp = testProp(['transform', 'WebkitTransform']); + +DOM.setTransform = (el: HTMLElement, value: string) => { + // https://github.com/facebook/flow/issues/7754 + // $FlowFixMe + el.style[transformProp] = value; +}; + +// Feature detection for {passive: false} support in add/removeEventListener. +let passiveSupported = false; + +try { + // https://github.com/facebook/flow/issues/285 + // $FlowFixMe + const options = Object.defineProperty({}, 'passive', { + get() { + // eslint-disable-line + passiveSupported = true; + }, + }); + window.addEventListener('test', options, options); + window.removeEventListener('test', options, options); +} catch (err) { + passiveSupported = false; +} + +DOM.addEventListener = ( + target: any, + type: any, + callback: any, + options: { passive?: boolean; capture?: boolean } = {}, +) => { + if ('passive' in options && passiveSupported) { + target.addEventListener(type, callback, options); + } else { + target.addEventListener(type, callback, options.capture); + } +}; + +DOM.removeEventListener = ( + target: any, + type: any, + callback: any, + options: { passive?: boolean; capture?: boolean } = {}, +) => { + if ('passive' in options && passiveSupported) { + target.removeEventListener(type, callback, options); + } else { + target.removeEventListener(type, callback, options.capture); + } +}; + +// Suppress the next click, but only if it's immediate. +const suppressClick = (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + window.removeEventListener('click', suppressClick, true); +}; + +DOM.suppressClick = () => { + window.addEventListener('click', suppressClick, true); + window.setTimeout(() => { + window.removeEventListener('click', suppressClick, true); + }, 0); +}; + +DOM.mousePos = (el: HTMLElement, e: MouseEvent | Touch) => { + const rect = el.getBoundingClientRect(); + return new Point( + e.clientX - rect.left - el.clientLeft, + e.clientY - rect.top - el.clientTop, + ); +}; + +DOM.touchPos = (el: HTMLElement, touches: Touch[]) => { + const rect = el.getBoundingClientRect(); + const points = []; + for (const touche of touches) { + points.push( + new Point( + touche.clientX - rect.left - el.clientLeft, + touche.clientY - rect.top - el.clientTop, + ), + ); + } + return points; +}; + +DOM.mouseButton = (e: MouseEvent) => { + if ( + // @ts-ignore + typeof window.InstallTrigger !== 'undefined' && + e.button === 2 && + e.ctrlKey && + window.navigator.platform.toUpperCase().indexOf('MAC') >= 0 + ) { + // Fix for https://github.com/mapbox/mapbox-gl-js/issues/3131: + // Firefox (detected by InstallTrigger) on Mac determines e.button = 2 when + // using Control + left click + return 0; + } + return e.button; +}; + +DOM.remove = (node: HTMLElement) => { + if (node.parentNode) { + node.parentNode.removeChild(node); + } +}; diff --git a/packages/map/src/utils/performance.ts b/packages/map/src/utils/performance.ts new file mode 100644 index 0000000000..8b079d5903 --- /dev/null +++ b/packages/map/src/utils/performance.ts @@ -0,0 +1,81 @@ +let lastFrameTime: number | null = null; +let frameTimes: number[] = []; + +const minFramerateTarget = 30; +const frameTimeTarget = 1000 / minFramerateTarget; +const performance = window.performance; + +export interface IPerformanceMetrics { + loadTime: number; + fullLoadTime: number; + fps: number; + percentDroppedFrames: number; +} + +export const PerformanceMarkers = { + create: 'create', + load: 'load', + fullLoad: 'fullLoad', +}; + +export const PerformanceUtils = { + mark(marker: string) { + performance.mark(marker); + }, + frame(timestamp: number) { + const currTimestamp = timestamp; + if (lastFrameTime != null) { + const frameTime = currTimestamp - lastFrameTime; + frameTimes.push(frameTime); + } + lastFrameTime = currTimestamp; + }, + clearMetrics() { + lastFrameTime = null; + frameTimes = []; + performance.clearMeasures('loadTime'); + performance.clearMeasures('fullLoadTime'); + // @ts-ignore + // tslint:disable-next-line:forin + for (const marker in PerformanceMarkers) { + // @ts-ignore + performance.clearMarks(PerformanceMarkers[marker]); + } + }, + getPerformanceMetrics(): IPerformanceMetrics { + const loadTime = performance.measure( + 'loadTime', + PerformanceMarkers.create, + PerformanceMarkers.load, + // @ts-ignore + ).duration; + + const fullLoadTime = performance.measure( + 'fullLoadTime', + PerformanceMarkers.create, + PerformanceMarkers.fullLoad, + // @ts-ignore + ).duration; + const totalFrames = frameTimes.length; + + const avgFrameTime = + frameTimes.reduce((prev, curr) => prev + curr, 0) / totalFrames / 1000; + const fps = 1 / avgFrameTime; + + // count frames that missed our framerate target + const droppedFrames = frameTimes + .filter((frameTime) => frameTime > frameTimeTarget) + .reduce((acc, curr) => { + return acc + (curr - frameTimeTarget) / frameTimeTarget; + }, 0); + const percentDroppedFrames = + (droppedFrames / (totalFrames + droppedFrames)) * 100; + + return { + loadTime, + fullLoadTime, + fps, + percentDroppedFrames, + }; + }, +}; diff --git a/packages/map/src/utils/primitives.ts b/packages/map/src/utils/primitives.ts new file mode 100644 index 0000000000..88d23dfcd0 --- /dev/null +++ b/packages/map/src/utils/primitives.ts @@ -0,0 +1,67 @@ +import { vec3, vec4 } from 'gl-matrix'; +export default class Frustum { + public static fromInvProjectionMatrix( + invProj: Float32Array, + worldSize: number, + zoom: number, + ): Frustum { + const clipSpaceCorners: Array<[number, number, number, number]> = [ + [-1, 1, -1, 1], + [1, 1, -1, 1], + [1, -1, -1, 1], + [-1, -1, -1, 1], + [-1, 1, 1, 1], + [1, 1, 1, 1], + [1, -1, 1, 1], + [-1, -1, 1, 1], + ]; + + const scale = Math.pow(2, zoom); + + // Transform frustum corner points from clip space to tile space + const frustumCoords = clipSpaceCorners + .map((v) => vec4.transformMat4(new Float32Array([]), v, invProj)) + .map((v) => + vec4.scale(new Float32Array([]), v, (1.0 / v[3] / worldSize) * scale), + ); + + const frustumPlanePointIndices: Array<[number, number, number]> = [ + [0, 1, 2], // near + [6, 5, 4], // far + [0, 3, 7], // left + [2, 1, 5], // right + [3, 2, 6], // bottom + [0, 4, 5], // top + ]; + + const frustumPlanes = frustumPlanePointIndices.map( + (p: [number, number, number]) => { + const a = vec3.sub( + new Float32Array(3), + new Float32Array(frustumCoords[p[0]]), + new Float32Array(frustumCoords[p[1]]), + ); + const b = vec3.sub( + new Float32Array(3), + new Float32Array(frustumCoords[p[2]]), + new Float32Array(frustumCoords[p[1]]), + ); + const n = vec3.normalize( + new Float32Array(3), + vec3.cross(new Float32Array(3), a, b), + ); + const d = -vec3.dot(n, new Float32Array(frustumCoords[p[1]])); + return (n as number[]).concat(d); + }, + ); + + return new Frustum(frustumCoords as number[][], frustumPlanes); + } + public points: number[][]; + public planes: number[][]; + + constructor(points: number[][], planes: number[][]) { + this.points = points; + this.planes = planes; + } +} diff --git a/packages/map/src/utils/task_queue.ts b/packages/map/src/utils/task_queue.ts new file mode 100644 index 0000000000..c4c9d47910 --- /dev/null +++ b/packages/map/src/utils/task_queue.ts @@ -0,0 +1,68 @@ +export type TaskID = number; // can't mark opaque due to https://github.com/flowtype/flow-remove-types/pull/61 +interface ITask { + callback: (timeStamp: number) => void; + id: TaskID; + cancelled: boolean; +} + +class TaskQueue { + private queue: ITask[]; + private id: TaskID; + private cleared: boolean; + private currentlyRunning: ITask[] | false; + + constructor() { + this.queue = []; + this.id = 0; + this.cleared = false; + this.currentlyRunning = false; + } + + public add(callback: (timeStamp: number) => void): TaskID { + const id = ++this.id; + const queue = this.queue; + queue.push({ callback, id, cancelled: false }); + return id; + } + + public remove(id: TaskID) { + const running = this.currentlyRunning; + const queue = running ? this.queue.concat(running) : this.queue; + for (const task of queue) { + if (task.id === id) { + task.cancelled = true; + return; + } + } + } + + public run(timeStamp: number = 0) { + const queue = (this.currentlyRunning = this.queue); + + // Tasks queued by callbacks in the current queue should be executed + // on the next run, not the current run. + this.queue = []; + + for (const task of queue) { + if (task.cancelled) { + continue; + } + task.callback(timeStamp); + if (this.cleared) { + break; + } + } + + this.cleared = false; + this.currentlyRunning = false; + } + + public clear() { + if (this.currentlyRunning) { + this.cleared = true; + } + this.queue = []; + } +} + +export default TaskQueue; diff --git a/packages/map/tsconfig.build.json b/packages/map/tsconfig.build.json new file mode 100644 index 0000000000..ac68cc72b2 --- /dev/null +++ b/packages/map/tsconfig.build.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.build.json", + "compilerOptions": { + "declarationDir": "./es", + "rootDir": "./src", + "baseUrl": "./", + "paths": { + "*": ["node_modules", "typings/*"] + } + }, + "include": ["./src"] +} diff --git a/packages/maps/src/index.ts b/packages/maps/src/index.ts index 920d74d4a4..7e363b9af7 100644 --- a/packages/maps/src/index.ts +++ b/packages/maps/src/index.ts @@ -1,4 +1,5 @@ import GaodeMap from './amap/'; +import Map from './map/'; import Mapbox from './mapbox/'; -export { GaodeMap, Mapbox }; +export { GaodeMap, Mapbox, Map }; diff --git a/packages/maps/src/map/Viewport.ts b/packages/maps/src/map/Viewport.ts new file mode 100644 index 0000000000..92796ce173 --- /dev/null +++ b/packages/maps/src/map/Viewport.ts @@ -0,0 +1,75 @@ +import { IMapCamera, IViewport } from '@antv/l7-core'; +import WebMercatorViewport from 'viewport-mercator-project'; + +export default class Viewport implements IViewport { + private viewport: WebMercatorViewport; + + public syncWithMapCamera(mapCamera: Partial) { + const { + center, + zoom, + pitch, + bearing, + viewportHeight, + viewportWidth, + } = mapCamera; + + /** + * Deck.gl 使用的也是 Mapbox 同步相机,相机参数保持一致 + * 例如相机高度固定为 height * 1.5,因此不需要传 + */ + this.viewport = new WebMercatorViewport({ + width: viewportWidth, + height: viewportHeight, + longitude: center && center[0], + latitude: center && center[1], + zoom, + pitch, + bearing, + }); + } + + public getZoom(): number { + return this.viewport.zoom; + } + + public getZoomScale(): number { + return Math.pow(2, this.getZoom()); + } + + public getCenter(): [number, number] { + return [this.viewport.longitude, this.viewport.latitude]; + } + + public getProjectionMatrix(): number[] { + return this.viewport.projectionMatrix; + } + + public getViewMatrix(): number[] { + return this.viewport.viewMatrix; + } + + public getViewMatrixUncentered(): number[] { + // @ts-ignore + return this.viewport.viewMatrixUncentered; + } + public getViewProjectionMatrix(): number[] { + // @ts-ignore + return this.viewport.viewProjectionMatrix; + } + + public getViewProjectionMatrixUncentered(): number[] { + // @ts-ignore + return this.viewport.viewProjectionMatrix; + } + public getFocalDistance() { + return 1; + } + + public projectFlat( + lngLat: [number, number], + scale?: number | undefined, + ): [number, number] { + return this.viewport.projectFlat(lngLat, scale); + } +} diff --git a/packages/maps/src/map/index.ts b/packages/maps/src/map/index.ts new file mode 100644 index 0000000000..95b12d25c8 --- /dev/null +++ b/packages/maps/src/map/index.ts @@ -0,0 +1,8 @@ +import { Map } from '@antv/l7-map'; +import BaseMapWrapper from '../BaseMapWrapper'; +import MapboxService from './map'; +export default class MapboxWrapper extends BaseMapWrapper { + protected getServiceConstructor() { + return MapboxService; + } +} diff --git a/packages/maps/src/map/map.ts b/packages/maps/src/map/map.ts new file mode 100644 index 0000000000..7e780f2ae0 --- /dev/null +++ b/packages/maps/src/map/map.ts @@ -0,0 +1,351 @@ +/** + * MapboxService + */ +import { + Bounds, + CoordinateSystem, + ICoordinateSystemService, + IGlobalConfigService, + ILngLat, + ILogService, + IMapConfig, + IMapService, + IMercator, + IPoint, + IStatusOptions, + IViewport, + MapServiceEvent, + MapStyle, + TYPES, +} from '@antv/l7-core'; +import { Map } from '@antv/l7-map'; +import { DOM } from '@antv/l7-utils'; +import { mat4, vec2, vec3 } from 'gl-matrix'; +import { inject, injectable } from 'inversify'; + +import Viewport from './Viewport'; +const EventMap: { + [key: string]: any; +} = { + mapmove: 'move', + camerachange: 'move', + zoomchange: 'zoom', + dragging: 'drag', +}; +import { MapTheme } from './theme'; + +const LNGLAT_OFFSET_ZOOM_THRESHOLD = 12; +/** + * AMapService + */ +@injectable() +export default class L7MapService implements IMapService { + public map: Map; + + @inject(TYPES.MapConfig) + private readonly config: Partial; + + @inject(TYPES.IGlobalConfigService) + private readonly configService: IGlobalConfigService; + + @inject(TYPES.ILogService) + private readonly logger: ILogService; + @inject(TYPES.ICoordinateSystemService) + private readonly coordinateSystemService: ICoordinateSystemService; + + @inject(TYPES.IEventEmitter) + private eventEmitter: any; + private viewport: Viewport; + private markerContainer: HTMLElement; + private cameraChangedCallback: (viewport: IViewport) => void; + private $mapContainer: HTMLElement | null; + + // init + public addMarkerContainer(): void { + const container = this.map.getCanvasContainer(); + this.markerContainer = DOM.create('div', 'l7-marker-container', container); + this.markerContainer.setAttribute('tabindex', '-1'); + } + + public getMarkerContainer(): HTMLElement { + return this.markerContainer; + } + + // map event + public on(type: string, handle: (...args: any[]) => void): void { + if (MapServiceEvent.indexOf(type) !== -1) { + this.eventEmitter.on(type, handle); + } else { + // 统一事件名称 + this.map.on(EventMap[type] || type, handle); + } + } + public off(type: string, handle: (...args: any[]) => void): void { + this.map.off(EventMap[type] || type, handle); + } + + public getContainer(): HTMLElement | null { + return this.map.getContainer(); + } + + public getMapCanvasContainer(): HTMLElement { + return this.map.getCanvasContainer() as HTMLElement; + } + + public getSize(): [number, number] { + const size = this.map.transform; + return [size.width, size.height]; + } + // get mapStatus method + + public getType() { + return 'default'; + } + + public getZoom(): number { + return this.map.getZoom(); + } + + public setZoom(zoom: number) { + return this.map.setZoom(zoom); + } + + public getCenter(): ILngLat { + return this.map.getCenter(); + } + + public setCenter(lnglat: [number, number]): void { + this.map.setCenter(lnglat); + } + + public getPitch(): number { + return this.map.getPitch(); + } + + public getRotation(): number { + return this.map.getBearing(); + } + + public getBounds(): Bounds { + return this.map.getBounds().toArray() as Bounds; + } + + public getMinZoom(): number { + return this.map.getMinZoom(); + } + + public getMaxZoom(): number { + return this.map.getMaxZoom(); + } + + public setRotation(rotation: number): void { + this.map.setBearing(rotation); + } + + public zoomIn(option?: any, eventData?: any): void { + this.map.zoomIn(option, eventData); + } + public zoomOut(option?: any, eventData?: any): void { + this.map.zoomOut(option, eventData); + } + public setPitch(pitch: number) { + return this.map.setPitch(pitch); + } + + public panTo(p: [number, number]): void { + this.map.panTo(p); + } + + public panBy(pixel: [number, number]): void { + this.panTo(pixel); + } + + public fitBounds(bound: Bounds, fitBoundsOptions?: any): void { + this.map.fitBounds(bound, fitBoundsOptions); + } + + public setMaxZoom(max: number): void { + this.map.setMaxZoom(max); + } + + public setMinZoom(min: number): void { + this.map.setMinZoom(min); + } + public setMapStatus(option: Partial): void { + if (option.doubleClickZoom === true) { + this.map.doubleClickZoom.enable(); + } + if (option.doubleClickZoom === false) { + this.map.doubleClickZoom.disable(); + } + if (option.dragEnable === false) { + this.map.dragPan.disable(); + } + if (option.dragEnable === true) { + this.map.dragPan.enable(); + } + if (option.rotateEnable === false) { + this.map.dragRotate.disable(); + } + if (option.dragEnable === true) { + this.map.dragRotate.enable(); + } + if (option.keyboardEnable === false) { + this.map.keyboard.disable(); + } + if (option.keyboardEnable === true) { + this.map.keyboard.enable(); + } + if (option.zoomEnable === false) { + this.map.scrollZoom.disable(); + } + if (option.zoomEnable === true) { + this.map.scrollZoom.enable(); + } + } + + public setZoomAndCenter(zoom: number, center: [number, number]): void { + this.map.flyTo({ + zoom, + center, + }); + } + + public setMapStyle(style: any): void { + this.map.setStyle(this.getMapStyle(style)); + } + // TODO: 计算像素坐标 + public pixelToLngLat(pixel: [number, number]): ILngLat { + return this.map.unproject(pixel); + } + + public lngLatToPixel(lnglat: [number, number]): IPoint { + return this.map.project(lnglat); + } + + public containerToLngLat(pixel: [number, number]): ILngLat { + return this.map.unproject(pixel); + } + + public lngLatToContainer(lnglat: [number, number]): IPoint { + return this.map.project(lnglat); + } + public lngLatToMercator( + lnglat: [number, number], + altitude: number, + ): IMercator { + throw new Error('not implement'); + } + public getModelMatrix( + lnglat: [number, number], + altitude: number, + rotate: [number, number, number], + scale: [number, number, number] = [1, 1, 1], + origin: IMercator = { x: 0, y: 0, z: 0 }, + ): number[] { + throw new Error('not implement'); + } + + public async init(): Promise { + const { + id = 'map', + attributionControl = false, + style = 'light', + rotation = 0, + mapInstance, + ...rest + } = this.config; + + this.viewport = new Viewport(); + + if (mapInstance) { + // @ts-ignore + this.map = mapInstance; + this.$mapContainer = this.map.getContainer(); + } else { + this.$mapContainer = this.creatAmapContainer(id); + // @ts-ignore + this.map = new Map({ + container: this.$mapContainer, + style: this.getMapStyle(style), + bearing: rotation, + ...rest, + }); + } + this.map.on('load', this.handleCameraChanged); + this.map.on('move', this.handleCameraChanged); + + // 不同于高德地图,需要手动触发首次渲染 + this.handleCameraChanged(); + } + + public destroy() { + this.eventEmitter.removeAllListeners(); + if (this.map) { + this.map.remove(); + this.$mapContainer = null; + } + } + public emit(name: string, ...args: any[]) { + this.eventEmitter.emit(name, ...args); + } + public once(name: string, ...args: any[]) { + this.eventEmitter.once(name, ...args); + } + + public getMapContainer() { + return this.$mapContainer; + } + + public exportMap(type: 'jpg' | 'png'): string { + const renderCanvas = this.map.getCanvas(); + const layersPng = + type === 'jpg' + ? (renderCanvas?.toDataURL('image/jpeg') as string) + : (renderCanvas?.toDataURL('image/png') as string); + return layersPng; + } + public onCameraChanged(callback: (viewport: IViewport) => void): void { + this.cameraChangedCallback = callback; + } + + private handleCameraChanged = () => { + const { lat, lng } = this.map.getCenter(); + + // resync + this.viewport.syncWithMapCamera({ + bearing: this.map.getBearing(), + center: [lng, lat], + viewportHeight: this.map.transform.height, + pitch: this.map.getPitch(), + viewportWidth: this.map.transform.width, + zoom: this.map.getZoom(), + // mapbox 中固定相机高度为 viewport 高度的 1.5 倍 + cameraHeight: 0, + }); + // set coordinate system + if (this.viewport.getZoom() > LNGLAT_OFFSET_ZOOM_THRESHOLD) { + this.coordinateSystemService.setCoordinateSystem( + CoordinateSystem.LNGLAT_OFFSET, + ); + } else { + this.coordinateSystemService.setCoordinateSystem(CoordinateSystem.LNGLAT); + } + + this.cameraChangedCallback(this.viewport); + }; + + private creatAmapContainer(id: string | HTMLDivElement) { + let $wrapper = id as HTMLDivElement; + if (typeof id === 'string') { + $wrapper = document.getElementById(id) as HTMLDivElement; + } + return $wrapper; + } + private getMapStyle(name: MapStyle) { + if (typeof name !== 'string') { + return name; + } + return MapTheme[name] ? MapTheme[name] : name; + } +} diff --git a/packages/maps/src/map/theme.ts b/packages/maps/src/map/theme.ts new file mode 100644 index 0000000000..3b29aa499f --- /dev/null +++ b/packages/maps/src/map/theme.ts @@ -0,0 +1,23 @@ +export const MapTheme: { + [key: string]: any; +} = { + light: 'mapbox://styles/zcxduo/ck2ypyb1r3q9o1co1766dex29', + dark: 'mapbox://styles/zcxduo/ck241p6413s0b1cpayzldv7x7', + normal: 'mapbox://styles/mapbox/streets-v11', + blank: { + version: 8, + // sprite: 'https://lzxue.github.io/font-glyphs/sprite/sprite', + // glyphs: + // 'https://gw.alipayobjects.com/os/antvdemo/assets/mapbox/glyphs/{fontstack}/{range}.pbf', + sources: {}, + layers: [ + { + id: 'background', + type: 'background', + layout: { + visibility: 'none', + }, + }, + ], + }, +}; diff --git a/packages/maps/src/mapbox/map.ts b/packages/maps/src/mapbox/map.ts index 7faaac38c5..a81ea87a10 100644 --- a/packages/maps/src/mapbox/map.ts +++ b/packages/maps/src/mapbox/map.ts @@ -37,7 +37,7 @@ const EventMap: { dragging: 'drag', }; import { MapTheme } from './theme'; - +let mapdivCount = 0; const LNGLAT_OFFSET_ZOOM_THRESHOLD = 12; const MAPBOX_API_KEY = 'pk.eyJ1IjoibHp4dWUiLCJhIjoiYnhfTURyRSJ9.Ugm314vAKPHBzcPmY1p4KQ'; @@ -71,6 +71,7 @@ export default class MapboxService public addMarkerContainer(): void { const container = this.map.getCanvasContainer(); this.markerContainer = DOM.create('div', 'l7-marker-container', container); + this.markerContainer.setAttribute('tabindex', '-1'); } public getMarkerContainer(): HTMLElement { @@ -404,12 +405,28 @@ export default class MapboxService this.cameraChangedCallback(this.viewport); }; + // private creatAmapContainer(id: string | HTMLDivElement) { + // let $wrapper = id as HTMLDivElement; + // if (typeof id === 'string') { + // $wrapper = document.getElementById(id) as HTMLDivElement; + // } + // return $wrapper; + // } private creatAmapContainer(id: string | HTMLDivElement) { let $wrapper = id as HTMLDivElement; if (typeof id === 'string') { $wrapper = document.getElementById(id) as HTMLDivElement; } - return $wrapper; + const $amapdiv = document.createElement('div'); + $amapdiv.style.cssText += ` + position: absolute; + top: 0; + height: 100%; + width: 100%; + `; + $amapdiv.id = 'l7_mapbox_div' + mapdivCount++; + $wrapper.appendChild($amapdiv); + return $amapdiv; } private getMapStyle(name: MapStyle) { if (typeof name !== 'string') { diff --git a/packages/react/src/component/Popup.tsx b/packages/react/src/component/Popup.tsx index 8e46083ebf..90c36fb3be 100644 --- a/packages/react/src/component/Popup.tsx +++ b/packages/react/src/component/Popup.tsx @@ -12,7 +12,7 @@ import { createPortal } from 'react-dom'; import { SceneContext } from './SceneContext'; interface IPopupProps { option?: Partial; - lnglat: number[]; + lnglat: number[] | { lng: number; lat: number }; children?: React.ReactNode; } export default class PopupComponet extends React.PureComponent { @@ -25,7 +25,10 @@ export default class PopupComponet extends React.PureComponent { } public componentDidMount() { const { lnglat, children, option } = this.props; - const p = new Popup(option); + const p = new Popup({ + ...option, + stopPropagation: false, + }); if (lnglat) { p.setLnglat(lnglat); @@ -38,12 +41,21 @@ export default class PopupComponet extends React.PureComponent { } public componentDidUpdate(prevProps: IPopupProps) { - const positionChanged = - prevProps?.lnglat?.toString() !== this.props?.lnglat?.toString(); + // @ts-ignore + const preLnglat = Array.isArray(prevProps.lnglat) + ? prevProps.lnglat + : [prevProps?.lnglat?.lng, prevProps?.lnglat?.lat]; + const nowLnglat = Array.isArray(this.props.lnglat) + ? this.props.lnglat + : [this.props?.lnglat?.lng, this.props?.lnglat?.lat]; + const positionChanged = preLnglat.toString() !== nowLnglat.toString(); if (positionChanged) { this.popup.remove(); - this.popup = new Popup(this.props.option); + this.popup = new Popup({ + ...this.props.option, + stopPropagation: false, + }); this.popup.setLnglat(this.props.lnglat); this.popup.setDOMContent(this.el); this.scene.addPopup(this.popup); diff --git a/packages/renderer/src/regl/ReglTexture2D.ts b/packages/renderer/src/regl/ReglTexture2D.ts index 8797dbbc8f..93af3b76d2 100644 --- a/packages/renderer/src/regl/ReglTexture2D.ts +++ b/packages/renderer/src/regl/ReglTexture2D.ts @@ -73,7 +73,11 @@ export default class ReglTexture2D implements ITexture2D { public get() { return this.texture; } - public update() { + public update(props: regl.Texture2DOptions = {}) { + this.texture(props); + } + + public bind() { // @ts-ignore this.texture._texture.bind(); } diff --git a/packages/scene/__tests__/l7_map.spec.ts b/packages/scene/__tests__/l7_map.spec.ts new file mode 100644 index 0000000000..b3191b06b3 --- /dev/null +++ b/packages/scene/__tests__/l7_map.spec.ts @@ -0,0 +1,49 @@ +// @ts-ignore +import { PolygonLayer } from '@antv/l7-layers'; +import { Map } from '@antv/l7-maps'; +import { Scene } from '../src/'; +describe('template', () => { + const el = document.createElement('div'); + el.id = 'test-div-id'; + el.style.width = '500px'; + el.style.height = '500px'; + document.querySelector('body')?.appendChild(el); + const scene = new Scene({ + id: 'test-div-id', + map: new Map({ + center: [110.19382669582967, 30.258134], + pitch: 0, + zoom: 2, + }), + }); + fetch( + 'https://gw.alipayobjects.com/os/basement_prod/d2e0e930-fd44-4fca-8872-c1037b0fee7b.json', + ) + .then((res) => res.json()) + .then((data) => { + const layer = new PolygonLayer({ + name: '01', + }); + + layer + .source(data) + .size('name', [0, 10000, 50000, 30000, 100000]) + .color('name', [ + '#2E8AE6', + '#69D1AB', + '#DAF291', + '#FFD591', + '#FF7A45', + '#CF1D49', + ]) + .shape('fill') + .select(true) + .style({ + opacity: 1.0, + }); + scene.addLayer(layer); + }); + it('scene l7 map method', () => { + // console.log(scene.getZoom()); + }); +}); diff --git a/stories/3D_Model/Components/threeRender.tsx b/stories/3D_Model/Components/threeRender.tsx index 5b1521ba04..43f7cce41f 100644 --- a/stories/3D_Model/Components/threeRender.tsx +++ b/stories/3D_Model/Components/threeRender.tsx @@ -58,9 +58,9 @@ export default class GlTFThreeJSDemo extends React.Component { y: 'latitude', }, }) - .shape('name', ['00', '01', '02']) - // .shape('triangle') - // .color('red') + // .shape('name', ['00', '01', '02']) + .shape('triangle') + .color('red') .active(true) .size(20); scene.addLayer(imageLayer); diff --git a/stories/Components/components/Marker.tsx b/stories/Components/components/Marker.tsx index 250d73a5c7..0ae7a3a898 100644 --- a/stories/Components/components/Marker.tsx +++ b/stories/Components/components/Marker.tsx @@ -27,7 +27,7 @@ export default class MarkerComponent extends React.Component { const popup = new Popup({ offsets: [0, 20], - }).setText('hello'); + }).setHTML('

11111

'); const marker = new Marker({ offsets: [0, -20], diff --git a/stories/District/Layer/world.tsx b/stories/District/Layer/world.tsx index 05274b9aa2..28992fb570 100644 --- a/stories/District/Layer/world.tsx +++ b/stories/District/Layer/world.tsx @@ -14,7 +14,7 @@ export default class Country extends React.Component { public async componentDidMount() { const scene = new Scene({ id: 'map', - map: new GaodeMap({ + map: new Mapbox({ center: [116.2825, 39.9], pitch: 0, style: 'blank', @@ -50,8 +50,11 @@ export default class Country extends React.Component { }, popup: { enable: true, + openTriggerEvent: 'click', Html: (props: any) => { - return `${props.NAME_CHN + ':' + props.value}`; + return `${props.NAME_CHN + + ':' + + props.value}`; }, }, }); diff --git a/stories/Layers/components/Point3D.tsx b/stories/Layers/components/Point3D.tsx index ad87d619bf..92d2878a3c 100644 --- a/stories/Layers/components/Point3D.tsx +++ b/stories/Layers/components/Point3D.tsx @@ -22,6 +22,9 @@ export default class Point3D extends React.Component { }), }); const pointLayer = new PointLayer(); + scene.on('resize', () => { + console.log('resize'); + }); pointLayer .source(data, { cluster: true, diff --git a/stories/React/components/world_ncov.tsx b/stories/React/components/world_ncov.tsx index 31c975c2ab..b0a67d4b49 100644 --- a/stories/React/components/world_ncov.tsx +++ b/stories/React/components/world_ncov.tsx @@ -70,9 +70,9 @@ export default React.memo(function Map() { fetch( 'https://gw.alipayobjects.com/os/bmw-prod/e62a2f3b-ea99-4c98-9314-01d7c886263d.json', ).then((d) => d.json()), - fetch('https://lab.isaaclin.cn/nCoV/api/area?latest=1').then((d) => - d.json(), - ), + fetch( + 'https://gw.alipayobjects.com/os/bmw-prod/55a7dd2e-3fb4-4442-8899-900bb03ee67a.json', + ).then((d) => d.json()), ]); setData(joinData(geoData, ncovData.results)); }; diff --git a/stories/React/components/world_ncov_bubble.tsx b/stories/React/components/world_ncov_bubble.tsx index 647854dd7e..5d9c55e9bb 100644 --- a/stories/React/components/world_ncov_bubble.tsx +++ b/stories/React/components/world_ncov_bubble.tsx @@ -85,9 +85,9 @@ export default React.memo(function Map() { fetch( 'https://gw.alipayobjects.com/os/bmw-prod/e62a2f3b-ea99-4c98-9314-01d7c886263d.json', ).then((d) => d.json()), - fetch('https://lab.isaaclin.cn/nCoV/api/area?latest=1').then((d) => - d.json(), - ), + fetch( + 'https://gw.alipayobjects.com/os/bmw-prod/55a7dd2e-3fb4-4442-8899-900bb03ee67a.json', + ).then((d) => d.json()), ]); const worldData = joinData(geoData, ncovData.results); const pointdata = worldData.features.map((feature: any) => { @@ -133,7 +133,17 @@ export default React.memo(function Map() { margin: 0, }} > -
  • 现有确诊:{popupInfo.feature.currentConfirmedCount}
  • +
  • + + 现有确诊:{popupInfo.feature.currentConfirmedCount} +
  • 累计确诊:{popupInfo.feature.confirmedCount}
  • 治愈:{popupInfo.feature.curedCount}
  • 死亡:{popupInfo.feature.deadCount}
  • @@ -204,22 +214,7 @@ export default React.memo(function Map() { }, }} color={{ - field: 'confirmedCount', - values: (count) => { - return count > 10000 - ? colors[6] - : count > 1000 - ? colors[5] - : count > 500 - ? colors[4] - : count > 100 - ? colors[3] - : count > 10 - ? colors[2] - : count > 1 - ? colors[1] - : colors[0]; - }, + values: '#b10026', }} shape={{ values: 'circle', @@ -237,8 +232,8 @@ export default React.memo(function Map() { opacity: 0.6, }} > - - + + {/* */} , - + , ]} diff --git a/stories/React/components/world_ncov_bubble_animate.tsx b/stories/React/components/world_ncov_bubble_animate.tsx index 02a015bb90..78b914f248 100644 --- a/stories/React/components/world_ncov_bubble_animate.tsx +++ b/stories/React/components/world_ncov_bubble_animate.tsx @@ -77,9 +77,9 @@ export default React.memo(function Map() { fetch( 'https://gw.alipayobjects.com/os/bmw-prod/e62a2f3b-ea99-4c98-9314-01d7c886263d.json', ).then((d) => d.json()), - fetch('https://lab.isaaclin.cn/nCoV/api/area?latest=1').then((d) => - d.json(), - ), + fetch( + 'https://gw.alipayobjects.com/os/bmw-prod/55a7dd2e-3fb4-4442-8899-900bb03ee67a.json', + ).then((d) => d.json()), ]); const worldData = joinData(geoData, ncovData.results); const pointdata = worldData.features.map((feature: any) => { diff --git a/stories/React/components/world_ncov_column.tsx b/stories/React/components/world_ncov_column.tsx index be005923d8..bf06d5c0ad 100644 --- a/stories/React/components/world_ncov_column.tsx +++ b/stories/React/components/world_ncov_column.tsx @@ -72,9 +72,9 @@ export default React.memo(function Map() { fetch( 'https://gw.alipayobjects.com/os/bmw-prod/e62a2f3b-ea99-4c98-9314-01d7c886263d.json', ).then((d) => d.json()), - fetch('https://lab.isaaclin.cn/nCoV/api/area?latest=1').then((d) => - d.json(), - ), + fetch( + 'https://gw.alipayobjects.com/os/bmw-prod/55a7dd2e-3fb4-4442-8899-900bb03ee67a.json', + ).then((d) => d.json()), ]); const worldData = joinData(geoData, ncovData.results); const pointdata = worldData.features.map((feature: any) => { diff --git a/stories/React/components/world_ncov_fill.tsx b/stories/React/components/world_ncov_fill.tsx index b027d48274..4aadae5fc1 100644 --- a/stories/React/components/world_ncov_fill.tsx +++ b/stories/React/components/world_ncov_fill.tsx @@ -1,6 +1,8 @@ import { + LayerEvent, LineLayer, MapboxScene, + AMapScene, Marker, PolygonLayer, Popup, @@ -64,15 +66,27 @@ function joinData(geodata: any, ncovData: any) { export default React.memo(function Map() { const [data, setData] = React.useState(); + const [popupInfo, setPopupInfo] = React.useState<{ + lnglat: number[]; + feature: any; + }>(); + + function showPopup(args: any): void { + console.log(args.lngLat); + setPopupInfo({ + lnglat: args.lngLat, + feature: args.feature, + }); + } React.useEffect(() => { const fetchData = async () => { const [geoData, ncovData] = await Promise.all([ fetch( 'https://gw.alipayobjects.com/os/bmw-prod/e62a2f3b-ea99-4c98-9314-01d7c886263d.json', ).then((d) => d.json()), - fetch('https://lab.isaaclin.cn/nCoV/api/area?latest=1').then((d) => - d.json(), - ), + fetch( + 'https://gw.alipayobjects.com/os/bmw-prod/55a7dd2e-3fb4-4442-8899-900bb03ee67a.json', + ).then((d) => d.json()), ]); setData(joinData(geoData, ncovData.results)); }; @@ -80,7 +94,7 @@ export default React.memo(function Map() { }, []); return ( <> - + {popupInfo && ( + + {popupInfo.feature.name} +
      +
    • + + 现有确诊:{popupInfo.feature.currentConfirmedCount} +
    • +
    • 累计确诊:{popupInfo.feature.confirmedCount}
    • +
    • 治愈:{popupInfo.feature.curedCount}
    • +
    • 死亡:{popupInfo.feature.deadCount}
    • +
    +
    + )} {data && [ , + > + + {/* */} + , + , , ]} -
    + ); }); diff --git a/stories/customMap/components/Map.tsx b/stories/customMap/components/Map.tsx new file mode 100644 index 0000000000..24b3cd2164 --- /dev/null +++ b/stories/customMap/components/Map.tsx @@ -0,0 +1,67 @@ +// @ts-ignore +import { Scene } from '@antv/l7'; +import { PolygonLayer } from '@antv/l7-layers'; +import { Map } from '@antv/l7-maps'; +import * as React from 'react'; + +export default class ScaleComponent extends React.Component { + private scene: Scene; + + public componentWillUnmount() { + this.scene.destroy(); + } + + public async componentDidMount() { + const scene = new Scene({ + id: 'map', + map: new Map({ + hash: true, + center: [110.19382669582967, 30.258134], + pitch: 0, + zoom: 2, + }), + }); + fetch( + 'https://gw.alipayobjects.com/os/basement_prod/d2e0e930-fd44-4fca-8872-c1037b0fee7b.json', + ) + .then((res) => res.json()) + .then((data) => { + const layer = new PolygonLayer({ + name: '01', + }); + + layer + .source(data) + .size('name', [0, 10000, 50000, 30000, 100000]) + .color('name', [ + '#2E8AE6', + '#69D1AB', + '#DAF291', + '#FFD591', + '#FF7A45', + '#CF1D49', + ]) + .shape('fill') + .select(true) + .style({ + opacity: 1.0, + }); + scene.addLayer(layer); + }); + } + + public render() { + return ( +
    + ); + } +} diff --git a/stories/customMap/components/coord.tsx b/stories/customMap/components/coord.tsx new file mode 100644 index 0000000000..a1da97e405 --- /dev/null +++ b/stories/customMap/components/coord.tsx @@ -0,0 +1,77 @@ +// @ts-ignore +import { Scene } from '@antv/l7'; +import { PolygonLayer } from '@antv/l7-layers'; +import { Map } from '@antv/l7-maps'; +import * as React from 'react'; + +export default class ScaleComponent extends React.Component { + private scene: Scene; + + public componentWillUnmount() { + this.scene.destroy(); + } + + public async componentDidMount() { + const scene = new Scene({ + id: 'map', + map: new Map({ + coord: 'simple', + hash: true, + center: [200, 200], + pitch: 0, + zoom: 0, + }), + }); + + const layer = new PolygonLayer({ + name: '01', + }); + + layer + .source({ + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + properties: {}, + geometry: { + type: 'Polygon', + coordinates: [ + [ + [20, 20], + [400, 20], + [400, 400], + [20, 400], + [20, 20], + ], + ], + }, + }, + ], + }) + .color('#2E8AE6') + .shape('fill') + .style({ + opacity: 1.0, + }); + scene.addLayer(layer); + scene.on('loaded', () => { + console.log(scene.getCenter()); + }); + } + + public render() { + return ( +
    + ); + } +} diff --git a/stories/customMap/map.stories.tsx b/stories/customMap/map.stories.tsx new file mode 100644 index 0000000000..d264331bc0 --- /dev/null +++ b/stories/customMap/map.stories.tsx @@ -0,0 +1,7 @@ +import { storiesOf } from '@storybook/react'; +import * as React from 'react'; + +import Map from './components/Map'; +// @ts-ignore +storiesOf('自定义地图', module) + .add('地图', () => ) diff --git a/yarn.lock b/yarn.lock index 0fb821939b..0a04619928 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11290,7 +11290,7 @@ eventemitter3@^3.1.0: resolved "https://registry.npmjs.org/eventemitter3/-/eventemitter3-3.1.2.tgz#2d3d48f9c346698fce83a85d7d664e98535df6e7" integrity sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q== -eventemitter3@^4.0.0: +eventemitter3@^4.0.0, eventemitter3@^4.0.4: version "4.0.4" resolved "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.4.tgz#b5463ace635a083d018bdc7c917b4c5f10a85384" integrity sha512-rlaVLnVxtxvoyLsQQFBx53YmXHDxRIzzTLbdfxqi4yocpSjAxXwkU0cScM5JgSKMqEhrZpnvQ2D9gjylR0AimQ==