import React, { Component } from 'react'
import { debounce, parentPosition, parentHasClass } from '../utils'
import {
Bounds,
MapProps,
MapReactState,
MinMaxBounds,
MoveEvent,
Point,
Tile,
TileComponent,
TileValues,
WAdd,
WarningType,
WRem,
} from '../types'
import { osm } from '../providers'
const ANIMATION_TIME = 300
const DIAGONAL_THROW_TIME = 1500
const SCROLL_PIXELS_FOR_ZOOM_LEVEL = 150
const MIN_DRAG_FOR_THROW = 40
const CLICK_TOLERANCE = 2
const DOUBLE_CLICK_DELAY = 300
const DEBOUNCE_DELAY = 60
const PINCH_RELEASE_THROW_DELAY = 300
const WARNING_DISPLAY_TIMEOUT = 300
const NOOP = () => true
// https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames
const lng2tile = (lon: number, zoom: number): number => ((lon + 180) / 360) * Math.pow(2, zoom)
const lat2tile = (lat: number, zoom: number): number =>
((1 - Math.log(Math.tan((lat * Math.PI) / 180) + 1 / Math.cos((lat * Math.PI) / 180)) / Math.PI) / 2) *
Math.pow(2, zoom)
function tile2lng(x: number, z: number): number {
return (x / Math.pow(2, z)) * 360 - 180
}
function tile2lat(y: number, z: number): number {
const n = Math.PI - (2 * Math.PI * y) / Math.pow(2, z)
return (180 / Math.PI) * Math.atan(0.5 * (Math.exp(n) - Math.exp(-n)))
}
function getMousePixel(dom: HTMLElement, event: Pick<MouseEvent, 'clientX' | 'clientY'>): Point {
const parent = parentPosition(dom)
return [event.clientX - parent.x, event.clientY - parent.y]
}
function easeOutQuad(t: number): number {
return t * (2 - t)
}
// minLat, maxLat, minLng, maxLng
const absoluteMinMax = [
tile2lat(Math.pow(2, 10), 10),
tile2lat(0, 10),
tile2lng(0, 10),
tile2lng(Math.pow(2, 10), 10),
] as MinMaxBounds
const hasWindow = typeof window !== 'undefined'
const performanceNow =
hasWindow && window.performance && window.performance.now
? () => window.performance.now()
: (() => {
const timeStart = new Date().getTime()
return () => new Date().getTime() - timeStart
})()
const requestAnimationFrame = (callback: (timestamp: number) => void): number | null => {
if (hasWindow) {
return (window.requestAnimationFrame || window.setTimeout)(callback)
} else {
callback(new Date().getTime())
return null
}
}
const cancelAnimationFrame = (animFrame: number | null) =>
hasWindow && animFrame ? (window.cancelAnimationFrame || window.clearTimeout)(animFrame) : false
function srcSet(
dprs: number[],
url: (x: number, y: number, z: number, dpr?: number) => string,
x: number,
y: number,
z: number
): string {
if (!dprs || dprs.length === 0) {
return ''
}
return dprs.map((dpr) => url(x, y, z, dpr) + (dpr === 1 ? '' : ` ${dpr}x`)).join(', ')
}
const ImgTile: TileComponent = ({ tile, tileLoaded }) => (
<img
src={tile.url}
srcSet={tile.srcSet}
width={tile.width}
height={tile.height}
loading={'lazy'}
onLoad={tileLoaded}
alt={''}
style={{
position: 'absolute',
left: tile.left,
top: tile.top,
willChange: 'transform',
transformOrigin: 'top left',
opacity: 1,
}}
/>
)
export class Map extends Component<MapProps, MapReactState> {
static defaultProps = {
animate: true,
metaWheelZoom: false,
metaWheelZoomWarning: 'Use META + wheel to zoom!',
twoFingerDrag: false,
twoFingerDragWarning: 'Use two fingers to move the map',
zoomSnap: true,
mouseEvents: true,
touchEvents: true,
warningZIndex: 100,
animateMaxScreens: 5,
minZoom: 1,
maxZoom: 18,
limitBounds: 'center',
dprs: [],
tileComponent: ImgTile,
}
_containerRef?: HTMLDivElement
_mousePosition?: Point
_loadTracker?: { [key: string]: boolean }
_dragStart: Point | null = null
_mouseDown = false
_moveEvents: MoveEvent[] = []
_lastClick: number | null = null
_lastTap: number | null = null
_lastWheel: number | null = null
_touchStartPixel: Point[] | null = null
_touchStartMidPoint: Point | null = null
_touchStartDistance: number | null = null
_secondTouchEnd: number | null = null
_warningClearTimeout: number | null = null
_isAnimating = false
_animationStart: number | null = null
_animationEnd: number | null = null
_zoomStart: number | null = null
_centerTarget: Point | null = null
_zoomTarget: number | null = null
_zoomAround: Point | null = null
_animFrame: number | null = null
_boundsSynced = false
_minMaxCache: [number, number, number, MinMaxBounds] | null = null
_lastZoom: number
_lastCenter: Point
_centerStart?: Point
_resizeObserver = null
constructor(props: MapProps) {
super(props)
this.syncToProps = debounce(this.syncToProps, DEBOUNCE_DELAY)
// When users are using uncontrolled components we have to keep this
// so we can know if we should call onBoundsChanged
this._lastZoom = props.defaultZoom ?? props.zoom ?? 14
this._lastCenter = props.defaultCenter ?? props.center ?? [0, 0]
this.state = {
zoom: this._lastZoom,
center: this._lastCenter,
width: props.width ?? props.defaultWidth ?? -1,
height: props.height ?? props.defaultHeight ?? -1,
zoomDelta: 0,
pixelDelta: undefined,
oldTiles: [],
showWarning: false,
warningType: undefined,
}
}
componentDidMount(): void {
this.props.mouseEvents && this.bindMouseEvents()
this.props.touchEvents && this.bindTouchEvents()
if (!this.props.width || !this.props.height) {
// A height:100% container div often results in height=0 being returned on mount.
// So ask again once everything is painted.
if (!this.updateWidthHeight()) {
requestAnimationFrame(this.updateWidthHeight)
}
this.bindResizeEvent()
}
this.bindWheelEvent()
this.syncToProps()
if (typeof (window as any).ResizeObserver !== 'undefined') {
this._resizeObserver = new (window as any).ResizeObserver(() => {
this.updateWidthHeight()
})
this._resizeObserver.observe(this._containerRef)
}
}
componentWillUnmount(): void {
this.props.mouseEvents && this.unbindMouseEvents()
this.props.touchEvents && this.unbindTouchEvents()
this.unbindWheelEvent()
if (!this.props.width || !this.props.height) {
this.unbindResizeEvent()
}
if (this._resizeObserver) {
this._resizeObserver.disconnect()
}
}
updateWidthHeight = (): boolean => {
if (this._containerRef) {
const rect = this._containerRef.getBoundingClientRect()
if (rect && rect.width > 0 && rect.height > 0) {
this.setState({
width: rect.width,
height: rect.height,
})
return true
}
}
return false
}
wa: WAdd = (...args: Parameters<WAdd>) => window.addEventListener(...args)
wr: WRem = (...args: Parameters<WRem>) => window.removeEventListener(...args)
bindMouseEvents = (): void => {
this.wa('mousedown', this.handleMouseDown)
this.wa('mouseup', this.handleMouseUp)
this.wa('mousemove', this.handleMouseMove)
}
bindTouchEvents = (): void => {
this.wa('touchstart', this.handleTouchStart, { passive: false })
this.wa('touchmove', this.handleTouchMove, { passive: false })
this.wa('touchend', this.handleTouchEnd, { passive: false })
}
unbindMouseEvents = (): void => {
this.wr('mousedown', this.handleMouseDown)
this.wr('mouseup', this.handleMouseUp)
this.wr('mousemove', this.handleMouseMove)
}
unbindTouchEvents = (): void => {
this.wr('touchstart', this.handleTouchStart)
this.wr('touchmove', this.handleTouchMove)
this.wr('touchend', this.handleTouchEnd)
}
bindResizeEvent = (): void => {
this.wa('resize', this.updateWidthHeight)
}
unbindResizeEvent = (): void => {
this.wr('resize', this.updateWidthHeight)
}
bindWheelEvent = (): void => {
if (this._containerRef) {
this._containerRef.addEventListener('wheel', this.handleWheel, { passive: false })
}
}
unbindWheelEvent = (): void => {
if (this._containerRef) {
this._containerRef.removeEventListener('wheel', this.handleWheel)
}
}
componentDidUpdate(prevProps: MapProps): void {
if (this.props.mouseEvents !== prevProps.mouseEvents) {
this.props.mouseEvents ? this.bindMouseEvents() : this.unbindMouseEvents()
}
if (this.props.touchEvents !== prevProps.touchEvents) {
this.props.touchEvents ? this.bindTouchEvents() : this.unbindTouchEvents()
}
if (this.props.width && this.props.width !== prevProps.width) {
this.setState({ width: this.props.width })
}
if (this.props.height && this.props.height !== prevProps.height) {
this.setState({ height: this.props.height })
}
if (!this.props.center && !this.props.zoom) {
// if the user isn't controlling neither zoom nor center we don't have to update.
return
}
if (
(!this.props.center ||
(this.props.center[0] === prevProps?.center?.[0] && this.props.center[1] === prevProps.center[1])) &&
this.props.zoom === prevProps.zoom
) {
// if the user is controlling either zoom or center but nothing changed
// we don't have to update aswell
return
}
const currentCenter = this._isAnimating ? this._centerTarget : this.state.center
const currentZoom = this._isAnimating ? this._zoomTarget : this.state.zoom
if (currentCenter && currentZoom) {
const nextCenter = this.props.center ?? currentCenter // prevent the rare null errors
const nextZoom = this.props.zoom ?? currentZoom
if (
Math.abs(nextZoom - currentZoom) > 0.001 ||
Math.abs(nextCenter[0] - currentCenter[0]) > 0.0001 ||
Math.abs(nextCenter[1] - currentCenter[1]) > 0.0001
) {
this.setCenterZoomTarget(nextCenter, nextZoom, true)
}
}
}
setCenterZoomTarget = (
center: Point | null,
zoom: number,
fromProps = false,
zoomAround: Point | null = null,
animationDuration = ANIMATION_TIME
): void => {
if (
this.props.animate &&
(!fromProps ||
this.distanceInScreens(center, zoom, this.state.center, this.state.zoom) <= this.props.animateMaxScreens)
) {
if (this._isAnimating) {
cancelAnimationFrame(this._animFrame)
const { centerStep, zoomStep } = this.animationStep(performanceNow())
this._centerStart = centerStep
this._zoomStart = zoomStep
} else {
this._isAnimating = true
this._centerStart = this.limitCenterAtZoom([this._lastCenter[0], this._lastCenter[1]], this._lastZoom)
this._zoomStart = this._lastZoom
this.onAnimationStart()
}
this._animationStart = performanceNow()
this._animationEnd = this._animationStart + animationDuration
if (zoomAround) {
this._zoomAround = zoomAround
this._centerTarget = this.calculateZoomCenter(this._lastCenter, zoomAround, this._lastZoom, zoom)
} else {
this._zoomAround = null
this._centerTarget = center
}
this._zoomTarget = zoom
this._animFrame = requestAnimationFrame(this.animate)
} else {
this.stopAnimating()
if (zoomAround) {
const center = this.calculateZoomCenter(this._lastCenter, zoomAround, this._lastZoom, zoom)
this.setCenterZoom(center, zoom, fromProps)
} else {
this.setCenterZoom(center || this.state.center, zoom, fromProps)
}
}
}
setCenterZoomForChildren = (center: Point | null, zoom: number): void => {
this.setCenterZoomTarget(center || this.state.center, zoom || this.state.zoom, true)
}
distanceInScreens = (centerTarget: Point, zoomTarget: number, center: Point, zoom: number): number => {
const { width, height } = this.state
// distance in pixels at the current zoom level
const l1 = this.latLngToPixel(center, center, zoom)
const l2 = this.latLngToPixel(centerTarget, center, zoom)
// distance in pixels at the target zoom level (could be the same)
const z1 = this.latLngToPixel(center, center, zoomTarget)
const z2 = this.latLngToPixel(centerTarget, center, zoomTarget)
// take the average between the two and divide by width or height to get the distance multiplier in screens
const w = (Math.abs(l1[0] - l2[0]) + Math.abs(z1[0] - z2[0])) / 2 / width
const h = (Math.abs(l1[1] - l2[1]) + Math.abs(z1[1] - z2[1])) / 2 / height
// return the distance
return Math.sqrt(w * w + h * h)
}
animationStep = (timestamp: number): { centerStep: Point; zoomStep: number } => {
if (
!this._animationEnd ||
!this._animationStart ||
!this._zoomTarget ||
!this._zoomStart ||
!this._centerStart ||
!this._centerTarget
) {
return {
centerStep: this.state.center,
zoomStep: this.state.zoom,
}
}
const length = this._animationEnd - this._animationStart
const progress = Math.max(timestamp - this._animationStart, 0)
const percentage = easeOutQuad(progress / length)
const zoomDiff = (this._zoomTarget - this._zoomStart) * percentage
const zoomStep = this._zoomStart + zoomDiff
if (this._zoomAround) {
const centerStep = this.calculateZoomCenter(this._centerStart, this._zoomAround, this._zoomStart, zoomStep)
return { centerStep, zoomStep }
} else {
const centerStep = [
this._centerStart[0] + (this._centerTarget[0] - this._centerStart[0]) * percentage,
this._centerStart[1] + (this._centerTarget[1] - this._centerStart[1]) * percentage,
] as Point
return { centerStep, zoomStep }
}
}
animate = (timestamp: number): void => {
if (!this._animationEnd || timestamp >= this._animationEnd) {
this._isAnimating = false
this.setCenterZoom(this._centerTarget, this._zoomTarget, true)
this.onAnimationStop()
} else {
const { centerStep, zoomStep } = this.animationStep(timestamp)
this.setCenterZoom(centerStep, zoomStep)
this._animFrame = requestAnimationFrame(this.animate)
}
}
stopAnimating = (): void => {
if (this._isAnimating) {
this._isAnimating = false
this.onAnimationStop()
cancelAnimationFrame(this._animFrame)
}
}
limitCenterAtZoom = (center?: Point | null, zoom?: number | null): Point => {
// [minLat, maxLat, minLng, maxLng]
const minMax = this.getBoundsMinMax(zoom || this.state.zoom)
return [
Math.max(Math.min(!center || isNaN(center[0]) ? this.state.center[0] : center[0], minMax[1]), minMax[0]),
Math.max(Math.min(!center || isNaN(center[1]) ? this.state.center[1] : center[1], minMax[3]), minMax[2]),
] as Point
}
onAnimationStart = (): void => {
this.props.onAnimationStart && this.props.onAnimationStart()
}
onAnimationStop = (): void => {
this.props.onAnimationStop && this.props.onAnimationStop()
}
// main logic when changing coordinates
setCenterZoom = (center?: Point | null, zoom?: number | null, animationEnded = false): void => {
const limitedCenter = this.limitCenterAtZoom(center, zoom)
if (zoom && Math.round(this.state.zoom) !== Math.round(zoom)) {
const tileValues = this.tileValues(this.state)
const nextValues = this.tileValues({
center: limitedCenter,
zoom,
width: this.state.width,
height: this.state.height,
})
const oldTiles = this.state.oldTiles
this.setState(
{
oldTiles: oldTiles.filter((o) => o.roundedZoom !== tileValues.roundedZoom).concat(tileValues),
},
NOOP
)
const loadTracker: { [key: string]: boolean } = {}
for (let x = nextValues.tileMinX; x <= nextValues.tileMaxX; x++) {
for (let y = nextValues.tileMinY; y <= nextValues.tileMaxY; y++) {
const key = `${x}-${y}-${nextValues.roundedZoom}`
loadTracker[key] = false
}
}
this._loadTracker = loadTracker
}
this.setState({ center: limitedCenter, zoom: zoom || this.state.zoom }, NOOP)
const maybeZoom = this.props.zoom ? this.props.zoom : this._lastZoom
const maybeCenter = this.props.center ? this.props.center : this._lastCenter
if (
zoom &&
(animationEnded ||
Math.abs(maybeZoom - zoom) > 0.001 ||
Math.abs(maybeCenter[0] - limitedCenter[0]) > 0.00001 ||
Math.abs(maybeCenter[1] - limitedCenter[1]) > 0.00001)
) {
this._lastZoom = zoom
this._lastCenter = [...limitedCenter]
this.syncToProps(limitedCenter, zoom)
}
}
getBoundsMinMax = (zoom: number): MinMaxBounds => {
if (this.props.limitBounds === 'center') {
return absoluteMinMax
}
const { width, height } = this.state
if (
this._minMaxCache &&
this._minMaxCache[0] === zoom &&
this._minMaxCache[1] === width &&
this._minMaxCache[2] === height
) {
return this._minMaxCache[3]
}
const pixelsAtZoom = Math.pow(2, zoom) * 256
const minLng = width > pixelsAtZoom ? 0 : tile2lng(width / 512, zoom) // x
const minLat = height > pixelsAtZoom ? 0 : tile2lat(Math.pow(2, zoom) - height / 512, zoom) // y
const maxLng = width > pixelsAtZoom ? 0 : tile2lng(Math.pow(2, zoom) - width / 512, zoom) // x
const maxLat = height > pixelsAtZoom ? 0 : tile2lat(height / 512, zoom) // y
const minMax = [minLat, maxLat, minLng, maxLng] as MinMaxBounds
this._minMaxCache = [zoom, width, height, minMax]
return minMax
}
tileLoaded = (key: string): void => {
if (this._loadTracker && key in this._loadTracker) {
this._loadTracker[key] = true
const unloadedCount = Object.values(this._loadTracker).filter((v) => !v).length
if (unloadedCount === 0) {
this.setState({ oldTiles: [] }, NOOP)
}
}
}
coordsInside(pixel: Point): boolean {
const { width, height } = this.state
if (pixel[0] < 0 || pixel[1] < 0 || pixel[0] >= width || pixel[1] >= height) {
return false
}
const parent = this._containerRef
if (parent) {
const pos = parentPosition(parent)
const element = document.elementFromPoint(pixel[0] + pos.x, pixel[1] + pos.y)
return parent === element || parent.contains(element)
} else {
return false
}
}
handleTouchStart = (event: TouchEvent): void => {
if (!this._containerRef) {
return
}
if (event.target && parentHasClass(event.target as HTMLElement, 'pigeon-drag-block')) {
return
}
if (event.touches.length === 1) {
const touch = event.touches[0]
const pixel = getMousePixel(this._containerRef, touch)
if (this.coordsInside(pixel)) {
this._touchStartPixel = [pixel]
if (!this.props.twoFingerDrag) {
this.stopAnimating()
if (this._lastTap && performanceNow() - this._lastTap < DOUBLE_CLICK_DELAY) {
event.preventDefault()
const latLngNow = this.pixelToLatLng(this._touchStartPixel[0])
this.setCenterZoomTarget(
null,
Math.max(this.props.minZoom, Math.min(this.state.zoom + 1, this.props.maxZoom)),
false,
latLngNow
)
} else {
this._lastTap = performanceNow()
this.trackMoveEvents(pixel)
}
}
}
// added second finger and first one was in the area
} else if (event.touches.length === 2 && this._touchStartPixel) {
event.preventDefault()
this.stopTrackingMoveEvents()
if (this.state.pixelDelta || this.state.zoomDelta) {
this.sendDeltaChange()
}
const t1 = getMousePixel(this._containerRef, event.touches[0])
const t2 = getMousePixel(this._containerRef, event.touches[1])
this._touchStartPixel = [t1, t2]
this._touchStartMidPoint = [(t1[0] + t2[0]) / 2, (t1[1] + t2[1]) / 2]
this._touchStartDistance = Math.sqrt(Math.pow(t1[0] - t2[0], 2) + Math.pow(t1[1] - t2[1], 2))
}
}
handleTouchMove = (event: TouchEvent): void => {
if (!this._containerRef) {
this._touchStartPixel = null
return
}
if (event.touches.length === 1 && this._touchStartPixel) {
const touch = event.touches[0]
const pixel = getMousePixel(this._containerRef, touch)
if (this.props.twoFingerDrag) {
if (this.coordsInside(pixel)) {
this.showWarning('fingers')
}
} else {
event.preventDefault()
this.trackMoveEvents(pixel)
this.setState(
{
pixelDelta: [pixel[0] - this._touchStartPixel[0][0], pixel[1] - this._touchStartPixel[0][1]],
},
NOOP
)
}
} else if (
event.touches.length === 2 &&
this._touchStartPixel &&
this._touchStartMidPoint &&
this._touchStartDistance
) {
const { width, height, zoom } = this.state
event.preventDefault()
const t1 = getMousePixel(this._containerRef, event.touches[0])
const t2 = getMousePixel(this._containerRef, event.touches[1])
const midPoint = [(t1[0] + t2[0]) / 2, (t1[1] + t2[1]) / 2]
const midPointDiff = [midPoint[0] - this._touchStartMidPoint[0], midPoint[1] - this._touchStartMidPoint[1]]
const distance = Math.sqrt(Math.pow(t1[0] - t2[0], 2) + Math.pow(t1[1] - t2[1], 2))
const zoomDelta =
Math.max(
this.props.minZoom,
Math.min(this.props.maxZoom, zoom + Math.log2(distance / this._touchStartDistance))
) - zoom
const scale = Math.pow(2, zoomDelta)
const centerDiffDiff = [(width / 2 - midPoint[0]) * (scale - 1), (height / 2 - midPoint[1]) * (scale - 1)]
this.setState(
{
zoomDelta: zoomDelta,
pixelDelta: [centerDiffDiff[0] + midPointDiff[0] * scale, centerDiffDiff[1] + midPointDiff[1] * scale],
},
NOOP
)
}
}
handleTouchEnd = (event: TouchEvent): void => {
if (!this._containerRef) {
this._touchStartPixel = null
return
}
if (this._touchStartPixel) {
const { zoomSnap, twoFingerDrag, minZoom, maxZoom } = this.props
const { zoomDelta } = this.state
const { center, zoom } = this.sendDeltaChange()
if (event.touches.length === 0) {
if (twoFingerDrag) {
this.clearWarning()
} else {
// if the click started and ended at about
// the same place we can view it as a click
// and not prevent default behavior.
const oldTouchPixel = this._touchStartPixel[0]
const newTouchPixel = getMousePixel(this._containerRef, event.changedTouches[0])
if (
Math.abs(oldTouchPixel[0] - newTouchPixel[0]) > CLICK_TOLERANCE ||
Math.abs(oldTouchPixel[1] - newTouchPixel[1]) > CLICK_TOLERANCE
) {
// don't throw immediately after releasing the second finger
if (!this._secondTouchEnd || performanceNow() - this._secondTouchEnd > PINCH_RELEASE_THROW_DELAY) {
event.preventDefault()
this.throwAfterMoving(newTouchPixel, center, zoom)
}
}
this._touchStartPixel = null
this._secondTouchEnd = null
}
} else if (event.touches.length === 1) {
event.preventDefault()
const touch = getMousePixel(this._containerRef, event.touches[0])
this._secondTouchEnd = performanceNow()
this._touchStartPixel = [touch]
this.trackMoveEvents(touch)
if (zoomSnap) {
// if somehow we have no midpoint for the two finger touch, just take the center of the map
const latLng = this._touchStartMidPoint ? this.pixelToLatLng(this._touchStartMidPoint) : this.state.center
let zoomTarget
// do not zoom up/down if we must drag with 2 fingers and didn't change the zoom level
if (twoFingerDrag && Math.round(this.state.zoom) === Math.round(this.state.zoom + zoomDelta)) {
zoomTarget = Math.round(this.state.zoom)
} else {
zoomTarget = zoomDelta > 0 ? Math.ceil(this.state.zoom) : Math.floor(this.state.zoom)
}
const zoom = Math.max(minZoom, Math.min(zoomTarget, maxZoom))
this.setCenterZoomTarget(latLng, zoom, false, latLng)
}
}
}
}
handleMouseDown = (event: MouseEvent): void => {
if (!this._containerRef) {
return
}
const pixel = getMousePixel(this._containerRef, event)
if (
event.button === 0 &&
(!event.target || !parentHasClass(event.target as HTMLElement, 'pigeon-drag-block')) &&
this.coordsInside(pixel)
) {
this.stopAnimating()
event.preventDefault()
if (this._lastClick && performanceNow() - this._lastClick < DOUBLE_CLICK_DELAY) {
if (!parentHasClass(event.target as HTMLElement, 'pigeon-click-block')) {
const latLngNow = this.pixelToLatLng(this._mousePosition || pixel)
this.setCenterZoomTarget(
null,
Math.max(this.props.minZoom, Math.min(this.state.zoom + 1, this.props.maxZoom)),
false,
latLngNow
)
}
} else {
this._lastClick = performanceNow()
this._mouseDown = true
this._dragStart = pixel
this.trackMoveEvents(pixel)
}
}
}
handleMouseMove = (event: MouseEvent): void => {
if (!this._containerRef) {
return
}
this._mousePosition = getMousePixel(this._containerRef, event)
if (this._mouseDown && this._dragStart) {
this.trackMoveEvents(this._mousePosition)
this.setState(
{
pixelDelta: [this._mousePosition[0] - this._dragStart[0], this._mousePosition[1] - this._dragStart[1]],
},
NOOP
)
}
}
handleMouseUp = (event: MouseEvent): void => {
if (!this._containerRef) {
this._mouseDown = false
return
}
const { pixelDelta } = this.state
if (this._mouseDown) {
this._mouseDown = false
const pixel = getMousePixel(this._containerRef, event)
if (
this.props.onClick &&
(!event.target || !parentHasClass(event.target as HTMLElement, 'pigeon-click-block')) &&
(!pixelDelta || Math.abs(pixelDelta[0]) + Math.abs(pixelDelta[1]) <= CLICK_TOLERANCE)
) {
const latLng = this.pixelToLatLng(pixel)
this.props.onClick({ event, latLng, pixel })
this.setState({ pixelDelta: undefined }, NOOP)
} else {
const { center, zoom } = this.sendDeltaChange()
this.throwAfterMoving(pixel, center, zoom)
}
}
}
// https://www.bennadel.com/blog/1856-using-jquery-s-animate-step-callback-function-to-create-custom-animations.htm
stopTrackingMoveEvents = (): void => {
this._moveEvents = []
}
trackMoveEvents = (coords: Point): void => {
const timestamp = performanceNow()
if (this._moveEvents.length === 0 || timestamp - this._moveEvents[this._moveEvents.length - 1].timestamp > 40) {
this._moveEvents.push({ timestamp, coords })
if (this._moveEvents.length > 2) {
this._moveEvents.shift()
}
}
}
throwAfterMoving = (coords: Point, center: Point, zoom: number): void => {
const { width, height } = this.state
const { animate } = this.props
const timestamp = performanceNow()
const lastEvent = this._moveEvents.shift()
if (lastEvent && animate) {
const deltaMs = Math.max(timestamp - lastEvent.timestamp, 1)
const delta = [
((coords[0] - lastEvent.coords[0]) / deltaMs) * 120,
((coords[1] - lastEvent.coords[1]) / deltaMs) * 120,
]
const distance = Math.sqrt(delta[0] * delta[0] + delta[1] * delta[1])
if (distance > MIN_DRAG_FOR_THROW) {
const diagonal = Math.sqrt(width * width + height * height)
const throwTime = (DIAGONAL_THROW_TIME * distance) / diagonal
const lng = tile2lng(lng2tile(center[1], zoom) - delta[0] / 256.0, zoom)
const lat = tile2lat(lat2tile(center[0], zoom) - delta[1] / 256.0, zoom)
this.setCenterZoomTarget([lat, lng], zoom, false, null, throwTime)
}
}
this.stopTrackingMoveEvents()
}
sendDeltaChange = () => {
const { center, zoom, pixelDelta, zoomDelta } = this.state
let lat = center[0]
let lng = center[1]
if (pixelDelta || zoomDelta !== 0) {
lng = tile2lng(lng2tile(center[1], zoom + zoomDelta) - (pixelDelta ? pixelDelta[0] / 256.0 : 0), zoom + zoomDelta)
lat = tile2lat(lat2tile(center[0], zoom + zoomDelta) - (pixelDelta ? pixelDelta[1] / 256.0 : 0), zoom + zoomDelta)
this.setCenterZoom([lat, lng], zoom + zoomDelta)
}
this.setState(
{
pixelDelta: undefined,
zoomDelta: 0,
},
NOOP
)
return {
center: this.limitCenterAtZoom([lat, lng], zoom + zoomDelta),
zoom: zoom + zoomDelta,
}
}
getBounds = (center = this.state.center, zoom = this.zoomPlusDelta()): Bounds => {
const { width, height } = this.state
return {
ne: this.pixelToLatLng([width - 1, 0], center, zoom),
sw: this.pixelToLatLng([0, height - 1], center, zoom),
}
}
syncToProps = (center = this.state.center, zoom = this.state.zoom): void => {
const { onBoundsChanged } = this.props
if (onBoundsChanged) {
const bounds = this.getBounds(center, zoom)
onBoundsChanged({ center, zoom, bounds, initial: !this._boundsSynced })
this._boundsSynced = true
}
}
handleWheel = (event: WheelEvent): void => {
const { mouseEvents, metaWheelZoom, zoomSnap, animate } = this.props
if (!mouseEvents) {
return
}
if (!metaWheelZoom || event.metaKey || event.ctrlKey) {
event.preventDefault()
const addToZoom = -event.deltaY / SCROLL_PIXELS_FOR_ZOOM_LEVEL
if (!zoomSnap && this._zoomTarget) {
const stillToAdd = this._zoomTarget - this.state.zoom
this.zoomAroundMouse(addToZoom + stillToAdd, event)
} else {
if (animate) {
this.zoomAroundMouse(addToZoom, event)
} else {
if (!this._lastWheel || performanceNow() - this._lastWheel > ANIMATION_TIME) {
this._lastWheel = performanceNow()
this.zoomAroundMouse(addToZoom, event)
}
}
}
} else {
this.showWarning('wheel')
}
}
showWarning = (warningType: WarningType): void => {
if (!this.state.showWarning || this.state.warningType !== warningType) {
this.setState({ showWarning: true, warningType })
}
if (this._warningClearTimeout) {
window.clearTimeout(this._warningClearTimeout)
}
this._warningClearTimeout = window.setTimeout(this.clearWarning, WARNING_DISPLAY_TIMEOUT)
}
clearWarning = (): void => {
if (this.state.showWarning) {
this.setState({ showWarning: false })
}
}
zoomAroundMouse = (zoomDiff: number, event: MouseEvent): void => {
if (!this._containerRef) {
return
}
const { zoom } = this.state
const { minZoom, maxZoom, zoomSnap } = this.props
this._mousePosition = getMousePixel(this._containerRef, event)
if (!this._mousePosition || (zoom === minZoom && zoomDiff < 0) || (zoom === maxZoom && zoomDiff > 0)) {
return
}
const latLngNow = this.pixelToLatLng(this._mousePosition)
let zoomTarget = zoom + zoomDiff
if (zoomSnap) {
zoomTarget = zoomDiff < 0 ? Math.floor(zoomTarget) : Math.ceil(zoomTarget)
}
zoomTarget = Math.max(minZoom, Math.min(zoomTarget, maxZoom))
this.setCenterZoomTarget(null, zoomTarget, false, latLngNow)
}
// tools
zoomPlusDelta = (): number => {
return this.state.zoom + this.state.zoomDelta
}
pixelToLatLng = (pixel: Point, center = this.state.center, zoom = this.zoomPlusDelta()): Point => {
const { width, height, pixelDelta } = this.state
const pointDiff = [
(pixel[0] - width / 2 - (pixelDelta ? pixelDelta[0] : 0)) / 256.0,
(pixel[1] - height / 2 - (pixelDelta ? pixelDelta[1] : 0)) / 256.0,
]
const tileX = lng2tile(center[1], zoom) + pointDiff[0]
const tileY = lat2tile(center[0], zoom) + pointDiff[1]
return [
Math.max(absoluteMinMax[0], Math.min(absoluteMinMax[1], tile2lat(tileY, zoom))),
Math.max(absoluteMinMax[2], Math.min(absoluteMinMax[3], tile2lng(tileX, zoom))),
] as Point
}
latLngToPixel = (latLng: Point, center = this.state.center, zoom = this.zoomPlusDelta()): Point => {
const { width, height, pixelDelta } = this.state
const tileCenterX = lng2tile(center[1], zoom)
const tileCenterY = lat2tile(center[0], zoom)
const tileX = lng2tile(latLng[1], zoom)
const tileY = lat2tile(latLng[0], zoom)
return [
(tileX - tileCenterX) * 256.0 + width / 2 + (pixelDelta ? pixelDelta[0] : 0),
(tileY - tileCenterY) * 256.0 + height / 2 + (pixelDelta ? pixelDelta[1] : 0),
] as Point
}
calculateZoomCenter = (center: Point, coords: Point, oldZoom: number, newZoom: number): Point => {
const { width, height } = this.state
const pixelBefore = this.latLngToPixel(coords, center, oldZoom)
const pixelAfter = this.latLngToPixel(coords, center, newZoom)
const newCenter = this.pixelToLatLng(
[width / 2 + pixelAfter[0] - pixelBefore[0], height / 2 + pixelAfter[1] - pixelBefore[1]],
center,
newZoom
)
return this.limitCenterAtZoom(newCenter, newZoom)
}
// ref
setRef = (dom: HTMLDivElement) => {
this._containerRef = dom
}
// data to display the tiles
tileValues({
center,
zoom,
pixelDelta,
zoomDelta,
width,
height,
}: {
center: Point
zoom: number
pixelDelta?: Point
zoomDelta?: number
width: number
height: number
}): TileValues {
const roundedZoom = Math.round(zoom + (zoomDelta || 0))
const zoomDiff = zoom + (zoomDelta || 0) - roundedZoom
const scale = Math.pow(2, zoomDiff)
const scaleWidth = width / scale
const scaleHeight = height / scale
const tileCenterX = lng2tile(center[1], roundedZoom) - (pixelDelta ? pixelDelta[0] / 256.0 / scale : 0)
const tileCenterY = lat2tile(center[0], roundedZoom) - (pixelDelta ? pixelDelta[1] / 256.0 / scale : 0)
const halfWidth = scaleWidth / 2 / 256.0
const halfHeight = scaleHeight / 2 / 256.0
const tileMinX = Math.floor(tileCenterX - halfWidth)
const tileMaxX = Math.floor(tileCenterX + halfWidth)
const tileMinY = Math.floor(tileCenterY - halfHeight)
const tileMaxY = Math.floor(tileCenterY + halfHeight)
return {
tileMinX,
tileMaxX,
tileMinY,
tileMaxY,
tileCenterX,
tileCenterY,
roundedZoom,
zoomDelta: zoomDelta || 0,
scaleWidth,
scaleHeight,
scale,
}
}
// display the tiles
renderTiles(): JSX.Element {
const { oldTiles, width, height } = this.state
const { dprs } = this.props
const mapUrl = this.props.provider || osm
const {
tileMinX,
tileMaxX,
tileMinY,
tileMaxY,
tileCenterX,
tileCenterY,
roundedZoom,
scaleWidth,
scaleHeight,
scale,
} = this.tileValues(this.state)
const tiles: Tile[] = []
for (let i = 0; i < oldTiles.length; i++) {
const old = oldTiles[i]
const zoomDiff = old.roundedZoom - roundedZoom
if (Math.abs(zoomDiff) > 4 || zoomDiff === 0) {
continue
}
const pow = 1 / Math.pow(2, zoomDiff)
const xDiff = -(tileMinX - old.tileMinX * pow) * 256
const yDiff = -(tileMinY - old.tileMinY * pow) * 256
const xMin = Math.max(old.tileMinX, 0)
const yMin = Math.max(old.tileMinY, 0)
const xMax = Math.min(old.tileMaxX, Math.pow(2, old.roundedZoom) - 1)
const yMax = Math.min(old.tileMaxY, Math.pow(2, old.roundedZoom) - 1)
for (let x = xMin; x <= xMax; x++) {
for (let y = yMin; y <= yMax; y++) {
tiles.push({
key: `${x}-${y}-${old.roundedZoom}`,
url: mapUrl(x, y, old.roundedZoom),
srcSet: srcSet(dprs, mapUrl, x, y, old.roundedZoom),
left: xDiff + (x - old.tileMinX) * 256 * pow,
top: yDiff + (y - old.tileMinY) * 256 * pow,
width: 256 * pow,
height: 256 * pow,
active: false,
})
}
}
}
const xMin = Math.max(tileMinX, 0)
const yMin = Math.max(tileMinY, 0)
const xMax = Math.min(tileMaxX, Math.pow(2, roundedZoom) - 1)
const yMax = Math.min(tileMaxY, Math.pow(2, roundedZoom) - 1)
for (let x = xMin; x <= xMax; x++) {
for (let y = yMin; y <= yMax; y++) {
tiles.push({
key: `${x}-${y}-${roundedZoom}`,
url: mapUrl(x, y, roundedZoom),
srcSet: srcSet(dprs, mapUrl, x, y, roundedZoom),
left: (x - tileMinX) * 256,
top: (y - tileMinY) * 256,
width: 256,
height: 256,
active: true,
})
}
}
const boxStyle: React.CSSProperties = {
width: scaleWidth,
height: scaleHeight,
position: 'absolute',
top: `calc((100% - ${height}px) / 2)`,
left: `calc((100% - ${width}px) / 2)`,
overflow: 'hidden',
willChange: 'transform',
transform: `scale(${scale}, ${scale})`,
transformOrigin: 'top left',
}
const boxClassname = this.props.boxClassname || 'pigeon-tiles-box'
const left = -((tileCenterX - tileMinX) * 256 - scaleWidth / 2)
const top = -((tileCenterY - tileMinY) * 256 - scaleHeight / 2)
const tilesStyle: React.CSSProperties = {
position: 'absolute',
width: (tileMaxX - tileMinX + 1) * 256,
height: (tileMaxY - tileMinY + 1) * 256,
willChange: 'transform',
transform: `translate(${left}px, ${top}px)`,
}
const Tile = this.props.tileComponent
return (
<div style={boxStyle} className={boxClassname}>
<div className="pigeon-tiles" style={tilesStyle}>
{tiles.map((tile) => (
<Tile key={tile.key} tile={tile} tileLoaded={() => this.tileLoaded(tile.key)} />
))}
</div>
</div>
)
}
renderOverlays(): JSX.Element {
const { width, height, center } = this.state
const mapState = {
bounds: this.getBounds(),
zoom: this.zoomPlusDelta(),
center: center,
width,
height,
}
const childrenWithProps = React.Children.map(this.props.children, (child) => {
if (!child) {
return null
}
if (!React.isValidElement(child)) {
return child
}
const { anchor, position, offset } = child.props
const c = this.latLngToPixel(anchor || position || center)
return React.cloneElement(child, {
left: c[0] - (offset ? offset[0] : 0),
top: c[1] - (offset ? offset[1] : 0),
latLngToPixel: this.latLngToPixel,
pixelToLatLng: this.pixelToLatLng,
setCenterZoom: this.setCenterZoomForChildren,
mapProps: this.props,
mapState,
})
})
const childrenStyle: React.CSSProperties = {
position: 'absolute',
width: width,
height: height,
top: `calc((100% - ${height}px) / 2)`,
left: `calc((100% - ${width}px) / 2)`,
}
return (
<div className="pigeon-overlays" style={childrenStyle}>
{childrenWithProps}
</div>
)
}
renderAttribution(): JSX.Element | null {
const { attribution, attributionPrefix } = this.props
if (attribution === false) {
return null
}
const style: React.CSSProperties = {
position: 'absolute',
bottom: 0,
right: 0,
fontSize: '11px',
padding: '2px 5px',
background: 'rgba(255, 255, 255, 0.7)',
fontFamily: "'Helvetica Neue', Helvetica, Arial, sans-serif",
color: '#333',
}
const linkStyle: React.CSSProperties = {
color: '#0078A8',
textDecoration: 'none',
}
return (
<div key="attr" className="pigeon-attribution" style={style}>
{attributionPrefix === false ? null : (
<span>
{attributionPrefix || (
<a href="https://pigeon-maps.js.org/" style={linkStyle} target="_blank" rel="noreferrer noopener">
Pigeon
</a>
)}
{' | '}
</span>
)}
{attribution || (
<span>
{' © '}
<a
href="https://www.openstreetmap.org/copyright"
style={linkStyle}
target="_blank"
rel="noreferrer noopener"
>
OpenStreetMap
</a>
{' contributors'}
</span>
)}
</div>
)
}
renderWarning(): JSX.Element | null {
const { metaWheelZoom, metaWheelZoomWarning, twoFingerDrag, twoFingerDragWarning, warningZIndex } = this.props
const { showWarning, warningType, width, height } = this.state
if ((metaWheelZoom && metaWheelZoomWarning) || (twoFingerDrag && twoFingerDragWarning)) {
const style: React.CSSProperties = {
position: 'absolute',
top: 0,
left: 0,
width: width,
height: height,
overflow: 'hidden',
pointerEvents: 'none',
opacity: showWarning ? 100 : 0,
transition: 'opacity 300ms',
background: 'rgba(0,0,0,0.5)',
color: '#fff',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
fontSize: 22,
fontFamily: '"Arial", sans-serif',
textAlign: 'center',
zIndex: warningZIndex,
}
const meta =
typeof window !== 'undefined' && window.navigator && window.navigator.platform.toUpperCase().indexOf('MAC') >= 0
? '⌘'
: 'ctrl'
const warningText = warningType === 'fingers' ? twoFingerDragWarning : metaWheelZoomWarning
return (
<div className="pigeon-overlay-warning" style={style}>
{warningText.replace('META', meta)}
</div>
)
} else {
return null
}
}
render(): JSX.Element {
const { touchEvents, twoFingerDrag } = this.props
const { width, height } = this.state
const containerStyle: React.CSSProperties = {
width: this.props.width ? width : '100%',
height: this.props.height ? height : '100%',
position: 'relative',
display: 'inline-block',
overflow: 'hidden',
background: '#dddddd',
touchAction: touchEvents ? (twoFingerDrag ? 'pan-x pan-y' : 'none') : 'auto',
}
const hasSize = !!(width && height)
return (
<div style={containerStyle} ref={this.setRef} dir="ltr">
{hasSize && this.renderTiles()}
{hasSize && this.renderOverlays()}
{hasSize && this.renderAttribution()}
{hasSize && this.renderWarning()}
</div>
)
}
}