Merge pull request from antvis/coord

Coord
This commit is contained in:
@thinkinggis 2020-07-28 18:09:56 +08:00 committed by GitHub
commit 915728c39a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
90 changed files with 8177 additions and 73 deletions

View File

@ -55,7 +55,8 @@ order: 2
- strokeWidth 填充描边宽度 `ProvinceLayer, CityLayer, CountyLayer`
- autoFit 是否自动缩放到图层范围 `boolean`
- popup 信息窗口
- openTriggerEvent 触发事件 例如 'mousemove' | 'click';
- closeTriggerEvent 触发事件 例如 'mousemove' | 'click';
- enable 是否开启 `boolean`
- triggerEvent 触发事件 例如 'mousemove' | 'click';
- Html popup html 字符串,支持回调函数 (properties: any) => string;

View File

@ -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`

View File

@ -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<unknown>;
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<IMapService>(TYPES.IMapService);
this.sceneSerive = scene.get<ISceneService>(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;

View File

@ -9,6 +9,7 @@ export interface IPopupOption {
anchor: anchorType[any];
className: string;
offsets: number[];
stopPropagation: boolean;
}
export interface IPopup {
addTo(scene: Container): this;

View File

@ -308,7 +308,7 @@ export default class StyleAttributeService implements IStyleAttributeService {
}
});
this.attributesAndIndices.elements.destroy();
this.attributesAndIndices?.elements.destroy();
this.attributes = [];
}
}

View File

@ -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;
/**

View File

@ -0,0 +1,85 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>L7 IE</title>
<style>
html,
body {
overflow: hidden;
margin: 0;
}
#map {
position: absolute;
top: 0;
bottom: 0;
width: 100%;
cursor:pointer;
}
</style>
<link
href="https://api.mapbox.com/mapbox-gl-js/v1.8.0/mapbox-gl.css"
rel="stylesheet"
/>
</head>
<body>
<div id="map"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/babel-polyfill/7.8.3/polyfill.min.js"></script>
<script src="https://api.mapbox.com/mapbox-gl-js/v1.8.0/mapbox-gl.js"></script>
<script src="../dist/l7-dev.js"></script>
<script>
console.log(L7);
const scene = new L7.Scene({
id: "map",
map: new L7.Mapbox({
style: "blank", // 样式URL
center: [108.6167, 19.1000],
pitch: 0,
zoom: 6
})
});
const data = {"list":[{"w":19.1000,"t":24.6000,"s":"海南","l":11,"m":"东方","j":108.6167,"h":"59838"},{"w":20.0000,"t":23.8000,"s":"海南","l":11,"m":"海口","j":110.2500,"h":"59758"},{"w":22.2750,"t":23.6000,"s":"广东","l":12,"m":"珠海","j":113.5669,"h":"59488"},{"w":20.3372,"t":23.4000,"s":"广东","l":12,"m":"徐闻","j":110.1794,"h":"59754"},{"w":19.2089,"t":23.2000,"s":"海南","l":12,"m":"琼海","j":110.4819,"h":"59855"},{"w":21.7358,"t":23.2000,"s":"广东","l":11,"m":"上川岛","j":112.7731,"h":"59673"},{"w":23.3853,"t":23.0000,"s":"广东","l":11,"m":"汕头","j":116.6792,"h":"59316"},{"w":22.5417,"t":23.0000,"s":"广东","l":12,"m":"深圳","j":114.0033,"h":"59493"},{"w":19.5167,"t":22.9000,"s":"海南","l":12,"m":"儋州","j":109.5833,"h":"59845"},{"w":21.1547,"t":22.7000,"s":"广东","l":12,"m":"湛江","j":110.3022,"h":"59658"},{"w":21.4500,"t":22.7000,"s":"广西","l":12,"m":"北海","j":109.1333,"h":"59644"},{"w":22.5000,"t":22.6000,"s":"广东","l":12,"m":"中山","j":113.4000,"h":"59485"},{"w":21.8453,"t":22.6000,"s":"广东","l":12,"m":"阳江","j":111.9783,"h":"59663"},{"w":22.3469,"t":22.6000,"s":"广东","l":12,"m":"信宜","j":110.9250,"h":"59456"},{"w":22.8000,"t":22.5000,"s":"广东","l":12,"m":"汕尾","j":115.3667,"h":"59501"},{"w":23.4275,"t":22.3000,"s":"广东","l":12,"m":"南澳","j":117.0292,"h":"59324"},{"w":22.7100,"t":22.3000,"s":"广东","l":12,"m":"罗定","j":111.6000,"h":"59462"},{"w":19.0333,"t":22.3000,"s":"海南","l":12,"m":"琼中","j":109.8333,"h":"59849"},{"w":21.5458,"t":22.2000,"s":"广东","l":11,"m":"电白","j":110.9886,"h":"59664"},{"w":22.9661,"t":21.9000,"s":"广东","l":12,"m":"东莞","j":113.7389,"h":"59289"},{"w":22.2472,"t":21.8000,"s":"广东","l":12,"m":"台山","j":112.7858,"h":"59478"},{"w":22.9836,"t":21.6000,"s":"广东","l":12,"m":"惠来","j":116.3014,"h":"59317"},{"w":22.9906,"t":21.0000,"s":"广东","l":12,"m":"高要","j":112.4786,"h":"59278"},{"w":23.9000,"t":20.8000,"s":"广西","l":12,"m":"百色","j":106.6000,"h":"59211"},{"w":23.0711,"t":20.4000,"s":"广东","l":12,"m":"惠阳","j":114.3744,"h":"59298"},{"w":23.4497,"t":20.4000,"s":"广东","l":12,"m":"揭西","j":115.8492,"h":"59306"},{"w":23.3353,"t":20.4000,"s":"广东","l":11,"m":"增城","j":113.8275,"h":"59294"},{"w":23.4167,"t":19.9000,"s":"广西","l":12,"m":"那坡","j":105.8333,"h":"59209"},{"w":24.9000,"t":19.7000,"s":"福建","l":11,"m":"崇武","j":118.9167,"h":"59133"},{"w":24.4833,"t":19.7000,"s":"福建","l":12,"m":"厦门","j":118.0667,"h":"59134"},{"w":23.7936,"t":19.6000,"s":"广东","l":12,"m":"河源","j":114.7297,"h":"59293"},{"w":23.7106,"t":19.4000,"s":"广东","l":12,"m":"清远","j":113.0850,"h":"59280"},{"w":23.1000,"t":19.4000,"s":"广西","l":12,"m":"靖西","j":106.4500,"h":"59218"},{"w":23.6000,"t":19.4000,"s":"广西","l":13,"m":"田东","j":107.1167,"h":"59224"},{"w":25.5167,"t":19.2000,"s":"福建","l":12,"m":"平潭","j":119.7833,"h":"58944"},{"w":25.0500,"t":19.2000,"s":"福建","l":12,"m":"龙岩","j":117.0167,"h":"58927"},{"w":23.2100,"t":19.0000,"s":"广东","l":12,"m":"广州","j":113.4822,"h":"59287"},{"w":21.9833,"t":18.9000,"s":"广西","l":12,"m":"钦州","j":108.6000,"h":"59632"}]}
scene.on('loaded',function(){
scene.addImage(
'00',
'https://gw.alipayobjects.com/zos/basement_prod/604b5e7f-309e-40db-b95b-4fac746c5153.svg'
);
scene.addImage(
'01',
'https://gw.alipayobjects.com/zos/basement_prod/30580bc9-506f-4438-8c1a-744e082054ec.svg'
);
scene.addImage(
'02',
'https://gw.alipayobjects.com/zos/basement_prod/7aa1f460-9f9f-499f-afdf-13424aa26bbf.svg'
);
const pointLayer = new L7.PointLayer({})
.source(data.list, {
parser: {
type: "json",
x: "j",
y: "w"
}
})
.shape('m', [ '00', '01', '02' ])
.size(20)
.active(true)
.style({
opacity: 1.0
});
scene.addLayer(pointLayer);
pointLayer.on('inited',function(){
console.log('加载完成');
})
scene.render();
})
</script>
</body>
</html>

View File

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

11
packages/map/README.md Normal file
View File

@ -0,0 +1,11 @@
# `map`
> TODO: description
## Usage
```
const map = require('map');
// TODO: DEMONSTRATE API
```

View File

@ -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,
});
});
});

46
packages/map/package.json Normal file
View File

@ -0,0 +1,46 @@
{
"name": "@antv/l7-map",
"version": "2.2.21",
"description": "l7 map",
"keywords": [],
"author": "thinkinggis <lzx199065@gmail.com>",
"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"
}
}

889
packages/map/src/camera.ts Normal file
View File

@ -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,
);
// w<sub>m</sub>: 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 doesnt 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);
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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: <lng>, lat: <lat>}, an object {lon: <lng>, lat: <lat>}, or an array of [<lng>, <lat>]',
);
}
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;
}
}

View File

@ -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;
}
}

View File

@ -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));
}
}

View File

@ -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);
}
}

View File

@ -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;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -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;

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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 };

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -0,0 +1,5 @@
import { Event } from './event';
export default class RenderFrameEvent extends Event {
public type: 'renderFrame' = 'renderFrame';
public timeStamp: number;
}

View File

@ -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),
};
}

View File

@ -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;

View File

@ -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;
}

View File

@ -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;

View File

@ -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;
}
}

View File

@ -0,0 +1,5 @@
import MousePanHandler from './mousepan_handler';
import MouseRotateHandler from './mousepitch_hander';
import MousePitchHandler from './mouserotate_hander';
export { MousePanHandler, MouseRotateHandler, MousePitchHandler };

View File

@ -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
}
}

View File

@ -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;
}
}

View File

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

View File

@ -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 };
}
}
}

View File

@ -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;
}

View File

@ -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;

View File

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

View File

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

View File

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

View File

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

View File

@ -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;
}
}
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}
}
}

View File

@ -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;
}
}

View File

@ -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,
};

View File

@ -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,
};
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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,
};
}
}

View File

@ -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];
}
}
}

139
packages/map/src/hash.ts Normal file
View File

@ -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;

View File

@ -0,0 +1 @@
export * from './map';

View File

@ -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;
}

380
packages/map/src/map.ts Normal file
View File

@ -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<IMapOptions>) {
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();
}
};
}

84
packages/map/src/util.ts Normal file
View File

@ -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) };
}

View File

@ -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;
}
}

View File

@ -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);
}
};

View File

@ -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,
};
},
};

View File

@ -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;
}
}

View File

@ -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;

View File

@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.build.json",
"compilerOptions": {
"declarationDir": "./es",
"rootDir": "./src",
"baseUrl": "./",
"paths": {
"*": ["node_modules", "typings/*"]
}
},
"include": ["./src"]
}

View File

@ -1,4 +1,5 @@
import GaodeMap from './amap/';
import Map from './map/';
import Mapbox from './mapbox/';
export { GaodeMap, Mapbox };
export { GaodeMap, Mapbox, Map };

View File

@ -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<IMapCamera>) {
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);
}
}

View File

@ -0,0 +1,8 @@
import { Map } from '@antv/l7-map';
import BaseMapWrapper from '../BaseMapWrapper';
import MapboxService from './map';
export default class MapboxWrapper extends BaseMapWrapper<Map> {
protected getServiceConstructor() {
return MapboxService;
}
}

View File

@ -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<Map> {
public map: Map;
@inject(TYPES.MapConfig)
private readonly config: Partial<IMapConfig>;
@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<IStatusOptions>): 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<void> {
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;
}
}

View File

@ -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',
},
},
],
},
};

View File

@ -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') {

View File

@ -12,7 +12,7 @@ import { createPortal } from 'react-dom';
import { SceneContext } from './SceneContext';
interface IPopupProps {
option?: Partial<IPopupOption>;
lnglat: number[];
lnglat: number[] | { lng: number; lat: number };
children?: React.ReactNode;
}
export default class PopupComponet extends React.PureComponent<IPopupProps> {
@ -25,7 +25,10 @@ export default class PopupComponet extends React.PureComponent<IPopupProps> {
}
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<IPopupProps> {
}
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);

View File

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

View File

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

View File

@ -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);

View File

@ -27,7 +27,7 @@ export default class MarkerComponent extends React.Component {
const popup = new Popup({
offsets: [0, 20],
}).setText('hello');
}).setHTML('<h1 onclick= alert("12223")>11111</h1>');
const marker = new Marker({
offsets: [0, -20],

View File

@ -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 `<span>${props.NAME_CHN + ':' + props.value}</span>`;
return `<span><button onclick='alert(11111)'>点击</button>${props.NAME_CHN +
':' +
props.value}</span>`;
},
},
});

View File

@ -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,

View File

@ -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));
};

View File

@ -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,
}}
>
<li>:{popupInfo.feature.currentConfirmedCount}</li>
<li>
<button
onMouseDown={() => {
alert('test');
}}
value="点击"
>
</button>
:{popupInfo.feature.currentConfirmedCount}
</li>
<li>:{popupInfo.feature.confirmedCount}</li>
<li>:{popupInfo.feature.curedCount}</li>
<li>:{popupInfo.feature.deadCount}</li>
@ -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,
}}
>
<LayerEvent type="mousemove" handler={showPopup} />
<LayerEvent type="mouseout" handler={hidePopup} />
<LayerEvent type="click" handler={showPopup} />
{/* <LayerEvent type="mouseout" handler={hidePopup} /> */}
</PointLayer>,
<PointLayer
key={'5'}
@ -269,7 +264,7 @@ export default React.memo(function Map() {
opacity: 1,
}}
>
<LayerEvent type="mousemove" handler={showPopup} />
<LayerEvent type="click" handler={showPopup} />
</PointLayer>,
]}
</MapboxScene>

View File

@ -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) => {

View File

@ -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) => {

View File

@ -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 (
<>
<MapboxScene
<AMapScene
map={{
center: [110.19382669582967, 50.258134],
pitch: 0,
@ -95,6 +109,31 @@ export default React.memo(function Map() {
bottom: 0,
}}
>
{popupInfo && (
<Popup lnglat={popupInfo.lnglat}>
{popupInfo.feature.name}
<ul
style={{
margin: 0,
}}
>
<li>
<button
onClick={() => {
alert('test');
}}
value="点击"
>
</button>
:{popupInfo.feature.currentConfirmedCount}
</li>
<li>:{popupInfo.feature.confirmedCount}</li>
<li>:{popupInfo.feature.curedCount}</li>
<li>:{popupInfo.feature.deadCount}</li>
</ul>
</Popup>
)}
{data && [
<PolygonLayer
key={'1'}
@ -137,7 +176,11 @@ export default React.memo(function Map() {
style={{
opacity: 1,
}}
/>,
>
<LayerEvent type="click" handler={showPopup} />
{/* <LayerEvent type="mouseout" handler={hidePopup} /> */}
</PolygonLayer>,
,
<LineLayer
key={'2'}
source={{
@ -157,7 +200,7 @@ export default React.memo(function Map() {
}}
/>,
]}
</MapboxScene>
</AMapScene>
</>
);
});

View File

@ -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 (
<div
id="map"
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
}}
/>
);
}
}

View File

@ -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 (
<div
id="map"
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
}}
/>
);
}
}

View File

@ -0,0 +1,7 @@
import { storiesOf } from '@storybook/react';
import * as React from 'react';
import Map from './components/Map';
// @ts-ignore
storiesOf('自定义地图', module)
.add('地图', () => <Map />)

View File

@ -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==