diff options
Diffstat (limited to 'node_modules/pigeon-maps/src')
-rw-r--r-- | node_modules/pigeon-maps/src/controls/ZoomControl.tsx | 53 | ||||
-rw-r--r-- | node_modules/pigeon-maps/src/index.tsx | 7 | ||||
-rw-r--r-- | node_modules/pigeon-maps/src/map/Map.tsx | 1411 | ||||
-rw-r--r-- | node_modules/pigeon-maps/src/overlays/Draggable.tsx | 181 | ||||
-rw-r--r-- | node_modules/pigeon-maps/src/overlays/GeoJson.tsx | 244 | ||||
-rw-r--r-- | node_modules/pigeon-maps/src/overlays/Marker.tsx | 86 | ||||
-rw-r--r-- | node_modules/pigeon-maps/src/overlays/Overlay.tsx | 23 | ||||
-rw-r--r-- | node_modules/pigeon-maps/src/providers.ts | 24 | ||||
-rw-r--r-- | node_modules/pigeon-maps/src/types.ts | 150 | ||||
-rw-r--r-- | node_modules/pigeon-maps/src/utils.ts | 25 |
10 files changed, 2204 insertions, 0 deletions
diff --git a/node_modules/pigeon-maps/src/controls/ZoomControl.tsx b/node_modules/pigeon-maps/src/controls/ZoomControl.tsx new file mode 100644 index 0000000..a98ba39 --- /dev/null +++ b/node_modules/pigeon-maps/src/controls/ZoomControl.tsx @@ -0,0 +1,53 @@ +import React from 'react' +import { PigeonProps } from '../types' + +interface ZoomProps extends PigeonProps { + style?: React.CSSProperties + buttonStyle?: React.CSSProperties +} + +const commonStyle: React.CSSProperties = { + position: 'absolute', + top: 10, + left: 10, +} + +const commonButtonStyle: React.CSSProperties = { + width: 28, + height: 28, + borderRadius: 2, + boxShadow: '0 1px 4px -1px rgba(0,0,0,.3)', + background: 'white', + lineHeight: '26px', + fontSize: '20px', + fontWeight: 700, + color: '#666', + marginBottom: 1, + cursor: 'pointer', + border: 'none', + display: 'block', + outline: 'none', +} + +export function ZoomControl({ style, buttonStyle, setCenterZoom, mapState, mapProps }: ZoomProps): JSX.Element { + return ( + <div className="pigeon-zoom-buttons pigeon-drag-block" style={style ? { ...commonStyle, ...style } : commonStyle}> + <button + className="pigeon-zoom-in" + type="button" + style={buttonStyle ? { ...commonButtonStyle, ...buttonStyle } : commonButtonStyle} + onClick={() => setCenterZoom(mapState.center, Math.min(mapState.zoom + 1, mapProps.maxZoom))} + > + + + </button> + <button + className="pigeon-zoom-out" + type="button" + style={buttonStyle ? { ...commonButtonStyle, ...buttonStyle } : commonButtonStyle} + onClick={() => setCenterZoom(mapState.center, Math.max(mapState.zoom - 1, mapProps.minZoom))} + > + – + </button> + </div> + ) +} diff --git a/node_modules/pigeon-maps/src/index.tsx b/node_modules/pigeon-maps/src/index.tsx new file mode 100644 index 0000000..78617f9 --- /dev/null +++ b/node_modules/pigeon-maps/src/index.tsx @@ -0,0 +1,7 @@ +export * from './types' +export { Map } from './map/Map' +export { Marker } from './overlays/Marker' +export { Overlay } from './overlays/Overlay' +export { GeoJson, GeoJsonLoader, GeoJsonFeature } from './overlays/GeoJson' +export { Draggable } from './overlays/Draggable' +export { ZoomControl } from './controls/ZoomControl' diff --git a/node_modules/pigeon-maps/src/map/Map.tsx b/node_modules/pigeon-maps/src/map/Map.tsx new file mode 100644 index 0000000..38a9c26 --- /dev/null +++ b/node_modules/pigeon-maps/src/map/Map.tsx @@ -0,0 +1,1411 @@ +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> + ) + } +} diff --git a/node_modules/pigeon-maps/src/overlays/Draggable.tsx b/node_modules/pigeon-maps/src/overlays/Draggable.tsx new file mode 100644 index 0000000..4cac3c6 --- /dev/null +++ b/node_modules/pigeon-maps/src/overlays/Draggable.tsx @@ -0,0 +1,181 @@ +import React, { useEffect, useRef, useState } from 'react' +import { PigeonProps, Point } from '../types' + +function isDescendentOf(element, ancestor) { + while (element) { + if (element === ancestor) { + return true + } + element = element.parentElement + } + + return false +} + +interface DraggableProps extends PigeonProps { + className?: string + style?: React.CSSProperties + + children?: React.ReactNode + + onDragStart?: (anchor: Point) => void + onDragMove?: (anchor: Point) => void + onDragEnd?: (anchor: Point) => void +} + +interface DraggableState { + isDragging: boolean + startX?: number + startY?: number + startLeft?: number + startTop?: number + deltaX: number + deltaY: number +} + +const defaultState: DraggableState = { + isDragging: false, + startX: undefined, + startY: undefined, + startLeft: undefined, + startTop: undefined, + deltaX: 0, + deltaY: 0, +} + +export function Draggable(props: DraggableProps): JSX.Element { + const dragRef = useRef<HTMLDivElement>() + const propsRef = useRef<DraggableProps>(props) + const stateRef = useRef({ ...defaultState }) + const [_state, _setState] = useState(defaultState) + + propsRef.current = props + + const setState = (stateUpdate: Partial<DraggableState>): void => { + const newState = { ...stateRef.current, ...stateUpdate } + stateRef.current = newState + _setState(newState) + } + + const { mouseEvents, touchEvents } = props.mapProps + + useEffect(() => { + const handleDragStart = (event: MouseEvent | TouchEvent) => { + if (isDescendentOf(event.target, dragRef.current)) { + event.preventDefault() + + setState({ + isDragging: true, + startX: ('touches' in event ? event.touches[0] : event).clientX, + startY: ('touches' in event ? event.touches[0] : event).clientY, + startLeft: propsRef.current.left, + startTop: propsRef.current.top, + deltaX: 0, + deltaY: 0, + }) + + if (propsRef.current.onDragStart) { + const { left, top, offset, pixelToLatLng } = propsRef.current + propsRef.current.onDragMove(pixelToLatLng([left + (offset ? offset[0] : 0), top + (offset ? offset[1] : 0)])) + } + } + } + + const handleDragMove = (event: MouseEvent | TouchEvent) => { + if (!stateRef.current.isDragging) { + return + } + + event.preventDefault() + + const x = ('touches' in event ? event.touches[0] : event).clientX + const y = ('touches' in event ? event.touches[0] : event).clientY + + const deltaX = x - stateRef.current.startX + const deltaY = y - stateRef.current.startY + + setState({ deltaX, deltaY }) + + if (propsRef.current.onDragMove) { + const { offset, pixelToLatLng } = propsRef.current + const { startLeft, startTop } = stateRef.current + + propsRef.current.onDragMove( + pixelToLatLng([startLeft + deltaX + (offset ? offset[0] : 0), startTop + deltaY + (offset ? offset[1] : 0)]) + ) + } + } + + const handleDragEnd = (event: MouseEvent | TouchEvent) => { + if (!stateRef.current.isDragging) { + return + } + + event.preventDefault() + + const { offset, pixelToLatLng } = propsRef.current + const { deltaX, deltaY, startLeft, startTop } = stateRef.current + + propsRef.current.onDragEnd?.( + pixelToLatLng([startLeft + deltaX + (offset ? offset[0] : 0), startTop + deltaY + (offset ? offset[1] : 0)]) + ) + + setState({ + isDragging: false, + startX: undefined, + startY: undefined, + startLeft: undefined, + startTop: undefined, + deltaX: 0, + deltaY: 0, + }) + } + + const wa = (e: string, t: EventListener, o?: AddEventListenerOptions) => window.addEventListener(e, t, o) + const wr = (e: string, t: EventListener) => window.removeEventListener(e, t) + + if (mouseEvents) { + wa('mousedown', handleDragStart) + wa('mousemove', handleDragMove) + wa('mouseup', handleDragEnd) + } + + if (touchEvents) { + wa('touchstart', handleDragStart, { passive: false }) + wa('touchmove', handleDragMove, { passive: false }) + wa('touchend', handleDragEnd, { passive: false }) + } + + return () => { + if (mouseEvents) { + wr('mousedown', handleDragStart) + wr('mousemove', handleDragMove) + wr('mouseup', handleDragEnd) + } + + if (touchEvents) { + wr('touchstart', handleDragStart) + wr('touchmove', handleDragMove) + wr('touchend', handleDragEnd) + } + } + }, [mouseEvents, touchEvents]) + + const { left, top, className, style } = props + const { deltaX, deltaY, startLeft, startTop, isDragging } = _state + + return ( + <div + style={{ + cursor: isDragging ? 'grabbing' : 'grab', + ...(style || {}), + position: 'absolute', + transform: `translate(${isDragging ? startLeft + deltaX : left}px, ${isDragging ? startTop + deltaY : top}px)`, + }} + ref={dragRef} + className={`pigeon-drag-block${className ? ` ${className}` : ''}`} + > + {props.children} + </div> + ) +} diff --git a/node_modules/pigeon-maps/src/overlays/GeoJson.tsx b/node_modules/pigeon-maps/src/overlays/GeoJson.tsx new file mode 100644 index 0000000..d25cfe6 --- /dev/null +++ b/node_modules/pigeon-maps/src/overlays/GeoJson.tsx @@ -0,0 +1,244 @@ +import React, { CSSProperties, SVGProps, useMemo, useEffect, useState } from 'react' +import { PigeonProps, Point } from '../types' + +interface GeoJsonProps extends PigeonProps { + className?: string + data?: any + svgAttributes?: any + styleCallback?: any + hover?: any + feature?: any + style?: CSSProperties + children?: React.ReactNode + + // callbacks + onClick?: ({ event: HTMLMouseEvent, anchor: Point, payload: any }) => void + onContextMenu?: ({ event: HTMLMouseEvent, anchor: Point, payload: any }) => void + onMouseOver?: ({ event: HTMLMouseEvent, anchor: Point, payload: any }) => void + onMouseOut?: ({ event: HTMLMouseEvent, anchor: Point, payload: any }) => void +} + +interface GeoJsonLoaderProps extends GeoJsonProps { + link?: string +} + +interface GeoJsonGeometry { + type: string + coordinates?: + | [number, number] + | Array<[number, number]> + | Array<Array<[number, number]>> + | Array<Array<Array<[number, number]>>> + geometries?: Array<GeoJsonGeometry> +} + +interface GeometryProps { + coordinates?: + | [number, number] + | Array<[number, number]> + | Array<Array<[number, number]>> + | Array<Array<Array<[number, number]>>> + latLngToPixel?: (latLng: Point, center?: Point, zoom?: number) => Point + svgAttributes?: SVGProps<SVGElement> + geometry?: GeoJsonGeometry +} + +const defaultSvgAttributes = { fill: '#93c0d099', strokeWidth: '2', stroke: 'white', r: '30' } + +export function PointComponent(props: GeometryProps): JSX.Element { + const { latLngToPixel } = props + const [y, x] = props.coordinates as [number, number] + const [cx, cy] = latLngToPixel([x, y]) + if (props.svgAttributes?.path) { + const path = `M${cx},${cy}c${props.svgAttributes.path.split(/[c|C|L|l|v|V|h|H](.*)/s)[1]}` + return <path d={path} {...(props.svgAttributes as SVGProps<SVGCircleElement>)} /> + } + return <circle cx={cx} cy={cy} {...(props.svgAttributes as SVGProps<SVGCircleElement>)} /> +} + +export function MultiPoint(props: GeometryProps): JSX.Element { + return ( + <> + {props.coordinates.map((point, i) => ( + <PointComponent {...props} coordinates={point} key={i} /> + ))} + </> + ) +} + +export function LineString(props: GeometryProps): JSX.Element { + const { latLngToPixel } = props + const p = + 'M' + + (props.coordinates as Array<[number, number]>).reduce((a, [y, x]) => { + const [v, w] = latLngToPixel([x, y]) + return a + ' ' + v + ' ' + w + }, '') + + return <path d={p} {...(props.svgAttributes as SVGProps<SVGPathElement>)} /> +} + +export function MultiLineString(props: GeometryProps): JSX.Element { + return ( + <> + {props.coordinates.map((line, i) => ( + <LineString {...props} coordinates={line} key={i} /> + ))} + </> + ) +} + +export function Polygon(props: GeometryProps): JSX.Element { + const { latLngToPixel } = props + // GeoJson polygons is a collection of linear rings + const p = (props.coordinates as Array<Array<[number, number]>>).reduce( + (a, part) => + a + + ' M' + + part.reduce((a, [y, x]) => { + const [v, w] = latLngToPixel([x, y]) + return a + ' ' + v + ' ' + w + }, '') + + 'Z', + '' + ) + return <path d={p} {...(props.svgAttributes as SVGProps<SVGPathElement>)} /> +} + +export function MultiPolygon(props: GeometryProps): JSX.Element { + return ( + <> + {props.coordinates.map((polygon, i) => ( + <Polygon {...props} coordinates={polygon} key={i} /> + ))} + </> + ) +} + +export function GeometryCollection(props: GeometryProps): JSX.Element { + const renderer = { + Point: PointComponent, + MultiPoint, + LineString, + MultiLineString, + Polygon, + MultiPolygon, + } + + const { type, coordinates, geometries } = props.geometry + + if (type === 'GeometryCollection') { + return ( + <> + {geometries.map((geometry, i) => ( + <GeometryCollection key={i} {...props} geometry={geometry} /> + ))} + </> + ) + } + + const Component = renderer[type] + + if (Component === undefined) { + console.warn(`The GeoJson Type ${type} is not known`) + return null + } + return ( + <Component + latLngToPixel={props.latLngToPixel} + geometry={props.geometry} + coordinates={coordinates} + svgAttributes={props.svgAttributes} + /> + ) +} + +export function GeoJsonFeature(props: GeoJsonProps): JSX.Element { + const [internalHover, setInternalHover] = useState(props.hover || false) + const hover = props.hover !== undefined ? props.hover : internalHover + const callbackSvgAttributes = props.styleCallback && props.styleCallback(props.feature, hover) + const svgAttributes = callbackSvgAttributes + ? props.svgAttributes + ? { ...props.svgAttributes, ...callbackSvgAttributes } + : callbackSvgAttributes + : props.svgAttributes + ? props.svgAttributes + : defaultSvgAttributes + + const eventParameters = (event: React.MouseEvent<SVGElement>) => ({ + event, + anchor: props.anchor, + payload: props.feature, + }) + + return ( + <g + clipRule="evenodd" + style={{ pointerEvents: 'auto' }} + onClick={props.onClick ? (event) => props.onClick(eventParameters(event)) : null} + onContextMenu={props.onContextMenu ? (event) => props.onContextMenu(eventParameters(event)) : null} + onMouseOver={(event) => { + props.onMouseOver && props.onMouseOver(eventParameters(event)) + setInternalHover(true) + }} + onMouseOut={(event) => { + props.onMouseOut && props.onMouseOut(eventParameters(event)) + setInternalHover(false) + }} + > + <GeometryCollection {...props} {...props.feature} svgAttributes={svgAttributes} /> + </g> + ) +} + +export function GeoJson(props: GeoJsonProps): JSX.Element { + const { width, height } = props.mapState + + return ( + <div + style={{ + position: 'absolute', + left: '0', + top: '0', + pointerEvents: 'none', + cursor: 'pointer', + ...(props.style || {}), + }} + className={props.className ? `${props.className} pigeon-click-block` : 'pigeon-click-block'} + > + <svg + width={width} + height={height} + viewBox={`0 0 ${width} ${height}`} + fill="none" + xmlns="http://www.w3.org/2000/svg" + > + {props.data && props.data.features.map((feature, i) => <GeoJsonFeature key={i} {...props} feature={feature} />)} + + {React.Children.map(props.children, (child) => { + if (!child) { + return null + } + + if (!React.isValidElement(child)) { + return child + } + + return React.cloneElement(child, props) + })} + </svg> + </div> + ) +} + +export function GeoJsonLoader(props: GeoJsonLoaderProps): JSX.Element { + const [data, setData] = useState(props.data ? props.data : null) + + useEffect(() => { + fetch(props.link) + .then((response) => response.json()) + .then((data) => setData(data)) + }, [props.link]) + + return data ? <GeoJson data={data} {...props} /> : null +} diff --git a/node_modules/pigeon-maps/src/overlays/Marker.tsx b/node_modules/pigeon-maps/src/overlays/Marker.tsx new file mode 100644 index 0000000..f1970c2 --- /dev/null +++ b/node_modules/pigeon-maps/src/overlays/Marker.tsx @@ -0,0 +1,86 @@ +import React, { useState } from 'react' +import { PigeonProps } from '../types' + +interface MarkerProps extends PigeonProps { + color?: string + payload?: any + + width?: number + height?: number + + // optional modifiers + hover?: boolean + style?: React.CSSProperties + className?: string + + children?: JSX.Element + + // callbacks + onClick?: ({ event: HTMLMouseEvent, anchor: Point, payload: any }) => void + onContextMenu?: ({ event: HTMLMouseEvent, anchor: Point, payload: any }) => void + onMouseOver?: ({ event: HTMLMouseEvent, anchor: Point, payload: any }) => void + onMouseOut?: ({ event: HTMLMouseEvent, anchor: Point, payload: any }) => void +} + +export function Marker(props: MarkerProps): JSX.Element { + const width = + typeof props.width !== 'undefined' + ? props.width + : typeof props.height !== 'undefined' + ? (props.height * 29) / 34 + : 29 + const height = + typeof props.height !== 'undefined' + ? props.height + : typeof props.width !== 'undefined' + ? (props.width * 34) / 29 + : 34 + const [internalHover, setInternalHover] = useState(props.hover || false) + const hover = typeof props.hover === 'undefined' ? internalHover : props.hover + const color = props.color || '#93C0D0' + + // what do you expect to get back with the event + const eventParameters = (event: React.MouseEvent) => ({ + event, + anchor: props.anchor, + payload: props.payload, + }) + + return ( + <div + style={{ + position: 'absolute', + transform: `translate(${props.left - width / 2}px, ${props.top - (height - 1)}px)`, + filter: hover ? 'drop-shadow(0 0 4px rgba(0, 0, 0, .3))' : '', + pointerEvents: 'none', + cursor: 'pointer', + ...(props.style || {}), + }} + className={props.className ? `${props.className} pigeon-click-block` : 'pigeon-click-block'} + onClick={props.onClick ? (event) => props.onClick(eventParameters(event)) : null} + onContextMenu={props.onContextMenu ? (event) => props.onContextMenu(eventParameters(event)) : null} + onMouseOver={(event) => { + props.onMouseOver && props.onMouseOver(eventParameters(event)) + setInternalHover(true) + }} + onMouseOut={(event) => { + props.onMouseOut && props.onMouseOut(eventParameters(event)) + setInternalHover(false) + }} + > + {props.children || ( + <svg width={width} height={height} viewBox="0 0 61 71" fill="none" xmlns="http://www.w3.org/2000/svg"> + <g style={{ pointerEvents: 'auto' }}> + <path + d="M52 31.5C52 36.8395 49.18 42.314 45.0107 47.6094C40.8672 52.872 35.619 57.678 31.1763 61.6922C30.7916 62.0398 30.2084 62.0398 29.8237 61.6922C25.381 57.678 20.1328 52.872 15.9893 47.6094C11.82 42.314 9 36.8395 9 31.5C9 18.5709 18.6801 9 30.5 9C42.3199 9 52 18.5709 52 31.5Z" + fill={color} + stroke="white" + strokeWidth="4" + /> + <circle cx="30.5" cy="30.5" r="8.5" fill="white" opacity={hover ? 0.98 : 0.6} /> + </g> + </svg> + )} + </div> + ) +} diff --git a/node_modules/pigeon-maps/src/overlays/Overlay.tsx b/node_modules/pigeon-maps/src/overlays/Overlay.tsx new file mode 100644 index 0000000..0bc8ecd --- /dev/null +++ b/node_modules/pigeon-maps/src/overlays/Overlay.tsx @@ -0,0 +1,23 @@ +import React from 'react' +import { PigeonProps } from '../types' + +interface OverlayProps extends PigeonProps { + style?: React.CSSProperties + className?: string + children?: React.ReactNode +} + +export function Overlay(props: OverlayProps) { + return ( + <div + style={{ + position: 'absolute', + transform: `translate(${props.left}px, ${props.top}px)`, + ...(props.style || {}), + }} + className={props.className ? `${props.className} pigeon-click-block` : 'pigeon-click-block'} + > + {props.children} + </div> + ) +} diff --git a/node_modules/pigeon-maps/src/providers.ts b/node_modules/pigeon-maps/src/providers.ts new file mode 100644 index 0000000..5a11046 --- /dev/null +++ b/node_modules/pigeon-maps/src/providers.ts @@ -0,0 +1,24 @@ +export function osm(x: number, y: number, z: number): string { + const s = String.fromCharCode(97 + ((x + y + z) % 3)) + return `https://tile.openstreetmap.org/${z}/${x}/${y}.png` +} + +export function stamenToner(x: number, y: number, z: number, dpr = 1): string { + return `https://stamen-tiles.a.ssl.fastly.net/toner/${z}/${x}/${y}${dpr >= 2 ? '@2x' : ''}.png` +} + +export function stamenTerrain(x: number, y: number, z: number, dpr = 1): string { + return `https://stamen-tiles.a.ssl.fastly.net/terrain/${z}/${x}/${y}${dpr >= 2 ? '@2x' : ''}.jpg` +} + +export const maptiler = + (apiKey: string, map = 'streets') => + (x: number, y: number, z: number, dpr = 1): string => { + return `https://api.maptiler.com/maps/${map}/256/${z}/${x}/${y}${dpr >= 2 ? '@2x' : ''}.png?key=${apiKey}` + } + +export const stadiamaps = + (style = 'alidade_smooth') => + (x: number, y: number, z: number, dpr = 1): string => { + return `https://tiles.stadiamaps.com/styles/${style}/${z}/${x}/${y}${dpr >= 2 ? '@2x' : ''}.png` + } diff --git a/node_modules/pigeon-maps/src/types.ts b/node_modules/pigeon-maps/src/types.ts new file mode 100644 index 0000000..2bee710 --- /dev/null +++ b/node_modules/pigeon-maps/src/types.ts @@ -0,0 +1,150 @@ +import React from 'react' + +/** @description `[latitude, longitude]` */ +export type Point = [number, number] + +/** @description `{ ne: [latitude, longitude], sw: [latitude, longitude] }` */ +export interface Bounds { + ne: [number, number] + sw: [number, number] +} + +export interface MapProps { + center?: Point + defaultCenter?: Point + + zoom?: number + defaultZoom?: number + + width?: number + defaultWidth?: number + + height?: number + defaultHeight?: number + + provider?: (x: number, y: number, z: number, dpr?: number) => string + dprs?: number[] + children?: React.ReactNode + + animate?: boolean + animateMaxScreens?: number + + minZoom?: number + maxZoom?: number + + metaWheelZoom?: boolean + metaWheelZoomWarning?: string + twoFingerDrag?: boolean + twoFingerDragWarning?: string + warningZIndex?: number + + attribution?: JSX.Element | false + attributionPrefix?: JSX.Element | false + + zoomSnap?: boolean + mouseEvents?: boolean + touchEvents?: boolean + + onClick?: ({ event, latLng, pixel }: { event: MouseEvent; latLng: [number, number]; pixel: [number, number] }) => void + onBoundsChanged?: ({ + center, + zoom, + bounds, + initial, + }: { + center: [number, number] + bounds: Bounds + zoom: number + initial: boolean + }) => void + onAnimationStart?: () => void + onAnimationStop?: () => void + + // will be set to "edge" from v0.12 onward, defaulted to "center" before + limitBounds?: 'center' | 'edge' + + boxClassname?: string + tileComponent?: TileComponent +} + +export type TileComponent = (props: TileComponentProps) => JSX.Element + +export interface TileComponentProps { + tile: Tile + tileLoaded: () => void +} + +export interface Tile { + key: string + url: string + srcSet: string + left: number + top: number + width: number + height: number + active: boolean +} + +export interface TileValues { + tileMinX: number + tileMaxX: number + tileMinY: number + tileMaxY: number + tileCenterX: number + tileCenterY: number + roundedZoom: number + zoomDelta: number + scaleWidth: number + scaleHeight: number + scale: number +} + +export type WarningType = 'fingers' | 'wheel' + +export type WAdd = typeof window.addEventListener +export type WRem = typeof window.removeEventListener + +export interface MoveEvent { + timestamp: number + coords: Point +} + +type MinLat = number +type MaxLat = number +type MinLng = number +type MaxLng = number +export type MinMaxBounds = [MinLat, MaxLat, MinLng, MaxLng] + +export interface MapReactState { + zoom: number + center: Point + width: number + height: number + zoomDelta: number + pixelDelta?: [number, number] + oldTiles: TileValues[] + showWarning: boolean + warningType?: WarningType +} + +export interface MapState { + bounds: Bounds + zoom: number + center: Point + width: number + height: number +} + +export interface PigeonProps { + anchor?: Point + offset?: Point + left?: number + top?: number + mapState?: MapState + mapProps?: MapProps + + // pigeon functions + latLngToPixel?: (latLng: Point, center?: Point, zoom?: number) => Point + pixelToLatLng?: (pixel: Point, center?: Point, zoom?: number) => Point + setCenterZoom?: (center: Point | null, zoom: number, zoomAround?: Point | null, animationDuration?: number) => void +} diff --git a/node_modules/pigeon-maps/src/utils.ts b/node_modules/pigeon-maps/src/utils.ts new file mode 100644 index 0000000..ae80350 --- /dev/null +++ b/node_modules/pigeon-maps/src/utils.ts @@ -0,0 +1,25 @@ +export function debounce<T extends (...args: any[]) => any>(func: T, wait: number) { + let timeout: ReturnType<typeof setTimeout> + return function (this: any, ...args: Parameters<T>) { + // eslint-disable-next-line @typescript-eslint/no-this-alias + const context = this + clearTimeout(timeout) + timeout = setTimeout(() => func.apply(context, args), wait) + } +} + +export function parentHasClass(element: HTMLElement, className: string) { + while (element) { + if (element.classList && element.classList.contains(className)) { + return true + } + element = element.parentElement + } + + return false +} + +export function parentPosition(element: HTMLElement) { + const rect = element.getBoundingClientRect() + return { x: rect.left, y: rect.top } +} |