@globalfishingwatch/react-map-gl
Version:
A React wrapper for MapboxGL-js and overlay API.
231 lines (204 loc) • 7.46 kB
JavaScript
// @flow
// Copyright (c) 2015 Uber Technologies, Inc.
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
import * as React from 'react';
import {useRef, useState, useEffect} from 'react';
import PropTypes from 'prop-types';
import useMapControl, {mapControlDefaultProps, mapControlPropTypes} from './use-map-control';
import {getDynamicPosition, ANCHOR_POSITION} from '../utils/dynamic-position';
import type {MapControlProps} from './use-map-control';
import {crispPercentage, crispPixel} from '../utils/crisp-pixel';
import type {PositionType} from '../utils/dynamic-position';
import type {WebMercatorViewport} from 'viewport-mercator-project';
const propTypes = Object.assign({}, mapControlPropTypes, {
// Custom className
className: PropTypes.string,
// Longitude of the anchor point
longitude: PropTypes.number.isRequired,
// Latitude of the anchor point
latitude: PropTypes.number.isRequired,
// Altitude of the anchor point
altitude: PropTypes.number,
// Offset from the left
offsetLeft: PropTypes.number,
// Offset from the top
offsetTop: PropTypes.number,
// Size of the tip
tipSize: PropTypes.number,
// Whether to show close button
closeButton: PropTypes.bool,
// Whether to close on click
closeOnClick: PropTypes.bool,
// The popup's location relative to the coordinate
anchor: PropTypes.oneOf(Object.keys(ANCHOR_POSITION)),
// Whether the popup anchor should be auto-adjusted to fit within the container
dynamicPosition: PropTypes.bool,
// Whether popups should be sorted by depth. Useful when using multiple popups with tilted map.
sortByDepth: PropTypes.bool,
// Callback when component is closed
onClose: PropTypes.func
});
const defaultProps = Object.assign({}, mapControlDefaultProps, {
className: '',
altitude: 0,
offsetLeft: 0,
offsetTop: 0,
tipSize: 10,
anchor: 'bottom',
dynamicPosition: true,
sortByDepth: false,
closeButton: true,
closeOnClick: true,
onClose: () => {}
});
export type PopupProps = MapControlProps & {
className: string,
longitude: number,
latitude: number,
altitude: number,
offsetLeft: number,
offsetTop: number,
tipSize: number,
closeButton: boolean,
closeOnClick: boolean,
anchor: PositionType,
dynamicPosition: boolean,
sortByDepth: boolean,
onClose: Function
};
function getPosition(
props: PopupProps,
viewport: WebMercatorViewport,
el: null | HTMLElement,
[x, y]: [number, number]
): PositionType {
const {anchor, dynamicPosition, tipSize} = props;
if (el) {
return dynamicPosition
? getDynamicPosition({
x,
y,
anchor,
padding: tipSize,
width: viewport.width,
height: viewport.height,
selfWidth: el.clientWidth,
selfHeight: el.clientHeight
})
: anchor;
}
return anchor;
}
function getContainerStyle(
props: PopupProps,
viewport: WebMercatorViewport,
el: null | HTMLElement,
[x, y, z]: [number, number, number],
positionType: PositionType
) {
const {offsetLeft, offsetTop, sortByDepth} = props;
const anchorPosition = ANCHOR_POSITION[positionType];
const left = x + offsetLeft;
const top = y + offsetTop;
const xPercentage = crispPercentage(el, -anchorPosition.x * 100);
const yPercentage = crispPercentage(el, -anchorPosition.y * 100, 'y');
const style = {
position: 'absolute',
transform: `
translate(${xPercentage}%, ${yPercentage}%)
translate(${crispPixel(left)}px, ${crispPixel(top)}px)
`,
display: undefined,
zIndex: undefined
};
if (!sortByDepth) {
return style;
}
if (z > 1 || z < -1 || x < 0 || x > viewport.width || y < 0 || y > viewport.height) {
// clipped
style.display = 'none';
} else {
// use z-index to rearrange components
style.zIndex = Math.floor(((1 - z) / 2) * 100000);
}
return style;
}
function onClick(evt, {props, context}) {
if (props.closeOnClick || evt.target.className === 'mapboxgl-popup-close-button') {
props.onClose();
if (context.eventManager) {
// Using with InteractiveMap
// After we call `onClose` on `anyclick`, this component will be unmounted
// at which point we unregister the event listeners and stop blocking propagation.
// Then after a short delay a `click` event will fire
// Attach a one-time event listener here to prevent it from triggering `onClick` of the base map
context.eventManager.once('click', e => e.stopPropagation(), evt.target);
}
}
}
/*
* PureComponent doesn't update when context changes.
* The only way is to implement our own shouldComponentUpdate here. Considering
* the parent component (StaticMap or InteractiveMap) is pure, and map re-render
* is almost always triggered by a viewport change, we almost definitely need to
* recalculate the popup's position when the parent re-renders.
*/
function Popup(props: PopupProps) {
const contentRef = useRef<null | HTMLElement>(null);
const {context, containerRef} = useMapControl(props, {onClick});
const [, setLoaded] = useState(false);
useEffect(
() => {
// Container just got a size, re-calculate position
setLoaded(true);
},
[contentRef.current]
);
const {viewport} = context;
const {className, longitude, latitude, altitude, tipSize, closeButton, children} = props;
const position = viewport.project([longitude, latitude, altitude]);
const positionType = getPosition(props, viewport, contentRef.current, position);
const containerStyle = getContainerStyle(
props,
viewport,
containerRef.current,
position,
positionType
);
// If eventManager does not exist (using with static map), listen to React event
const onReactClick = context.eventManager ? null : onClick;
return (
<div
className={`mapboxgl-popup mapboxgl-popup-anchor-${positionType} ${className}`}
style={containerStyle}
ref={containerRef}
>
<div key="tip" className="mapboxgl-popup-tip" style={{borderWidth: tipSize}} />
<div key="content" ref={contentRef} className="mapboxgl-popup-content" onClick={onReactClick}>
{closeButton && (
<button key="close-button" className="mapboxgl-popup-close-button" type="button">
×
</button>
)}
{children}
</div>
</div>
);
}
Popup.propTypes = propTypes;
Popup.defaultProps = defaultProps;
export default Popup;