react-bkoi-gl
Version:
React components for Barikoi GL JS
625 lines (575 loc) • 18 kB
text/typescript
/* eslint-disable @typescript-eslint/no-floating-promises */
import {
transformToViewState,
applyViewStateToTransform,
} from "../utils/transform";
import { normalizeStyle } from "../utils/style-utils";
import { deepEqual } from "../utils/deep-equal";
import type { TransformLike } from "../types/internal";
import type {
ViewState,
Point,
PointLike,
PaddingOptions,
ImmutableLike,
LngLatBoundsLike,
MapGeoJSONFeature,
} from "../types/common";
import type {
StyleSpecification,
SkySpecification,
LightSpecification,
TerrainSpecification,
ProjectionSpecification,
} from "../types/style-spec";
import type { Map as MapInstance } from "../types/lib";
import type {
MapCallbacks,
ViewStateChangeEvent,
MapEvent,
ErrorEvent,
MapMouseEvent,
} from "../types/events";
export type MaplibreProps = Partial<ViewState> &
MapCallbacks & {
/** Camera options used when constructing the Map instance */
initialViewState?: Partial<ViewState> & {
/** The initial bounds of the map. If bounds is specified, it overrides longitude, latitude and zoom options. */
bounds?: LngLatBoundsLike;
/** A fitBounds options object to use only when setting the bounds option. */
fitBoundsOptions?: {
offset?: PointLike;
minZoom?: number;
maxZoom?: number;
padding?: number | PaddingOptions;
};
};
/** If provided, render into an external WebGL context */
gl?: WebGLRenderingContext;
/** For external controller to override the camera state */
viewState?: ViewState & {
width: number;
height: number;
};
// Styling
/** Mapbox style */
mapStyle?: string | StyleSpecification | ImmutableLike<StyleSpecification>;
/** Enable diffing when the map style changes
* @default true
*/
styleDiffing?: boolean;
/** The projection property of the style. Must conform to the Projection Style Specification.
* @default 'mercator'
*/
projection?: ProjectionSpecification | "mercator" | "globe";
/** Light properties of the map. */
light?: LightSpecification;
/** Terrain property of the style. Must conform to the Terrain Style Specification.
* If `undefined` is provided, removes terrain from the map. */
terrain?: TerrainSpecification;
/** Sky properties of the map. Must conform to the Sky Style Specification. */
sky?: SkySpecification;
/** Default layers to query on pointer events */
interactiveLayerIds?: string[];
/** CSS cursor */
cursor?: string;
};
const DEFAULT_STYLE = {
version: 8,
sources: {},
layers: [],
} as StyleSpecification;
const pointerEvents = {
mousedown: "onMouseDown",
mouseup: "onMouseUp",
mouseover: "onMouseOver",
mousemove: "onMouseMove",
click: "onClick",
dblclick: "onDblClick",
mouseenter: "onMouseEnter",
mouseleave: "onMouseLeave",
mouseout: "onMouseOut",
contextmenu: "onContextMenu",
touchstart: "onTouchStart",
touchend: "onTouchEnd",
touchmove: "onTouchMove",
touchcancel: "onTouchCancel",
};
const cameraEvents = {
movestart: "onMoveStart",
move: "onMove",
moveend: "onMoveEnd",
dragstart: "onDragStart",
drag: "onDrag",
dragend: "onDragEnd",
zoomstart: "onZoomStart",
zoom: "onZoom",
zoomend: "onZoomEnd",
rotatestart: "onRotateStart",
rotate: "onRotate",
rotateend: "onRotateEnd",
pitchstart: "onPitchStart",
pitch: "onPitch",
pitchend: "onPitchEnd",
};
const otherEvents = {
wheel: "onWheel",
boxzoomstart: "onBoxZoomStart",
boxzoomend: "onBoxZoomEnd",
boxzoomcancel: "onBoxZoomCancel",
resize: "onResize",
load: "onLoad",
render: "onRender",
idle: "onIdle",
remove: "onRemove",
data: "onData",
styledata: "onStyleData",
sourcedata: "onSourceData",
error: "onError",
};
const settingNames = [
"minZoom",
"maxZoom",
"minPitch",
"maxPitch",
"maxBounds",
"projection",
"renderWorldCopies",
];
const handlerNames = [
"scrollZoom",
"boxZoom",
"dragRotate",
"dragPan",
"keyboard",
"doubleClickZoom",
"touchZoomRotate",
"touchPitch",
];
/**
* A wrapper for mapbox-gl's Map class
*/
export default class Maplibre {
private _MapClass: { new (options: any): MapInstance };
// mapboxgl.Map instance
private _map: MapInstance = null;
// User-supplied props
props: MaplibreProps;
// Internal states
private _internalUpdate: boolean = false;
private _hoveredFeatures: MapGeoJSONFeature[] = null;
private _propsedCameraUpdate: ViewState | null = null;
private _styleComponents: {
light?: LightSpecification;
sky?: SkySpecification;
projection?: ProjectionSpecification;
terrain?: TerrainSpecification | null;
} = {};
static savedMaps: Maplibre[] = [];
constructor(
MapClass: { new (options: any): MapInstance },
props: MaplibreProps,
container: HTMLDivElement,
) {
this._MapClass = MapClass;
this.props = props;
this._initialize(container);
}
get map(): MapInstance {
return this._map;
}
setProps(props: MaplibreProps) {
const oldProps = this.props;
this.props = props;
const settingsChanged = this._updateSettings(props, oldProps);
const sizeChanged = this._updateSize(props);
const viewStateChanged = this._updateViewState(props);
this._updateStyle(props, oldProps);
this._updateStyleComponents(props);
this._updateHandlers(props, oldProps);
// If 1) view state has changed to match props and
// 2) the props change is not triggered by map events,
// it's driven by an external state change. Redraw immediately
if (
settingsChanged ||
sizeChanged ||
(viewStateChanged && !this._map.isMoving())
) {
this.redraw();
}
}
static reuse(props: MaplibreProps, container: HTMLDivElement): Maplibre {
const that = Maplibre.savedMaps.pop();
if (!that) {
return null;
}
const map = that.map;
// When reusing the saved map, we need to reparent the map(canvas) and other child nodes
// intoto the new container from the props.
// Step 1: reparenting child nodes from old container to new container
const oldContainer = map.getContainer();
container.className = oldContainer.className;
while (oldContainer.childNodes.length > 0) {
container.appendChild(oldContainer.childNodes[0]);
}
// Step 2: replace the internal container with new container from the react component
// @ts-ignore
map._container = container;
// With maplibre-gl as mapLib, map uses ResizeObserver to observe when its container resizes.
// When reusing the saved map, we need to disconnect the observer and observe the new container.
// Step 3: telling the ResizeObserver to disconnect and observe the new container
// @ts-ignore
const resizeObserver = map._resizeObserver;
if (resizeObserver) {
resizeObserver.disconnect();
resizeObserver.observe(container);
}
// Step 4: apply new props
that.setProps({ ...props, styleDiffing: false });
map.resize();
const { initialViewState } = props;
if (initialViewState) {
if (initialViewState.bounds) {
map.fitBounds(initialViewState.bounds, {
...initialViewState.fitBoundsOptions,
duration: 0,
});
} else {
that._updateViewState(initialViewState);
}
}
// Simulate load event
if (map.isStyleLoaded()) {
map.fire("load");
} else {
map.once("style.load", () => map.fire("load"));
}
// Force reload
// @ts-ignore
map._update();
return that;
}
/* eslint-disable complexity,max-statements */
private _initialize(container: HTMLDivElement) {
const { props } = this;
const { mapStyle = DEFAULT_STYLE } = props;
const mapOptions = {
...props,
...props.initialViewState,
container,
style: normalizeStyle(mapStyle),
};
const viewState =
mapOptions.initialViewState || mapOptions.viewState || mapOptions;
Object.assign(mapOptions, {
center: [viewState.longitude || 0, viewState.latitude || 0],
zoom: viewState.zoom || 0,
pitch: viewState.pitch || 0,
bearing: viewState.bearing || 0,
});
if (props.gl) {
// eslint-disable-next-line
const getContext = HTMLCanvasElement.prototype.getContext;
// Hijack canvas.getContext to return our own WebGLContext
// This will be called inside the mapboxgl.Map constructor
// @ts-expect-error
HTMLCanvasElement.prototype.getContext = () => {
// Unhijack immediately
HTMLCanvasElement.prototype.getContext = getContext;
return props.gl;
};
}
const map = new this._MapClass(mapOptions);
// Props that are not part of constructor options
if (viewState.padding) {
map.setPadding(viewState.padding);
}
if (props.cursor) {
map.getCanvas().style.cursor = props.cursor;
}
// add listeners
map.transformCameraUpdate = this._onCameraUpdate;
map.on("style.load", () => {
// Map style has changed, this would have wiped out all settings from props
this._styleComponents = {
light: map.getLight(),
sky: map.getSky(),
// @ts-ignore getProjection() does not exist in v4
projection: map.getProjection?.(),
terrain: map.getTerrain(),
};
this._updateStyleComponents(this.props);
});
map.on("sourcedata", () => {
// Some sources have loaded, we may need them to attach terrain
this._updateStyleComponents(this.props);
});
for (const eventName in pointerEvents) {
map.on(eventName, this._onPointerEvent);
}
for (const eventName in cameraEvents) {
map.on(eventName, this._onCameraEvent);
}
for (const eventName in otherEvents) {
map.on(eventName, this._onEvent);
}
this._map = map;
}
/* eslint-enable complexity,max-statements */
recycle() {
// Clean up unnecessary elements before storing for reuse.
const container = this.map.getContainer();
const children = container.querySelector("[mapboxgl-children]");
children?.remove();
Maplibre.savedMaps.push(this);
}
destroy() {
this._map.remove();
}
// Force redraw the map now. Typically resize() and jumpTo() is reflected in the next
// render cycle, which is managed by Mapbox's animation loop.
// This removes the synchronization issue caused by requestAnimationFrame.
redraw() {
const map = this._map as any;
// map._render will throw error if style does not exist
// https://github.com/mapbox/mapbox-gl-js/blob/fb9fc316da14e99ff4368f3e4faa3888fb43c513
// /src/ui/map.js#L1834
if (map.style) {
// cancel the scheduled update
if (map._frame) {
map._frame.cancel();
map._frame = null;
}
// the order is important - render() may schedule another update
map._render();
}
}
/* Trigger map resize if size is controlled
@param {object} nextProps
@returns {bool} true if size has changed
*/
private _updateSize(nextProps: MaplibreProps): boolean {
// Check if size is controlled
const { viewState } = nextProps;
if (viewState) {
const map = this._map;
if (
viewState.width !== map.transform.width ||
viewState.height !== map.transform.height
) {
map.resize();
return true;
}
}
return false;
}
// Adapted from map.jumpTo
/* Update camera to match props
@param {object} nextProps
@param {bool} triggerEvents - should fire camera events
@returns {bool} true if anything is changed
*/
private _updateViewState(nextProps: MaplibreProps): boolean {
const map = this._map;
const tr = map.transform;
const isMoving = map.isMoving();
// Avoid manipulating the real transform when interaction/animation is ongoing
// as it would interfere with Mapbox's handlers
if (!isMoving) {
const changes: any = applyViewStateToTransform(tr, nextProps);
if (Object.keys(changes).length > 0) {
this._internalUpdate = true;
map.jumpTo(changes);
this._internalUpdate = false;
return true;
}
}
return false;
}
/* Update camera constraints and projection settings to match props
@param {object} nextProps
@param {object} currProps
@returns {bool} true if anything is changed
*/
private _updateSettings(
nextProps: MaplibreProps,
currProps: MaplibreProps,
): boolean {
const map = this._map;
let changed = false;
for (const propName of settingNames) {
if (
propName in nextProps &&
!deepEqual(nextProps[propName], currProps[propName])
) {
changed = true;
const setter =
map[`set${propName[0].toUpperCase()}${propName.slice(1)}`];
setter?.call(map, nextProps[propName]);
}
}
return changed;
}
/* Update map style to match props */
private _updateStyle(
nextProps: MaplibreProps,
currProps: MaplibreProps,
): void {
if (nextProps.cursor !== currProps.cursor) {
this._map.getCanvas().style.cursor = nextProps.cursor || "";
}
if (nextProps.mapStyle !== currProps.mapStyle) {
const { mapStyle = DEFAULT_STYLE, styleDiffing = true } = nextProps;
const options: any = {
diff: styleDiffing,
};
if ("localIdeographFontFamily" in nextProps) {
// @ts-ignore Mapbox specific prop
options.localIdeographFontFamily = nextProps.localIdeographFontFamily;
}
this._map.setStyle(normalizeStyle(mapStyle), options);
}
}
/* Update fog, light, projection and terrain to match props
* These props are special because
* 1. They can not be applied right away. Certain conditions (style loaded, source loaded, etc.) must be met
* 2. They can be overwritten by mapStyle
*/
private _updateStyleComponents({
light,
projection,
sky,
terrain,
}: MaplibreProps): void {
const map = this._map;
const currProps = this._styleComponents;
// We can safely manipulate map style once it's loaded
if (map.style._loaded) {
if (light && !deepEqual(light, currProps.light)) {
currProps.light = light;
map.setLight(light);
}
if (
projection &&
!deepEqual(projection, currProps.projection) &&
projection !== currProps.projection?.type
) {
currProps.projection =
typeof projection === "string" ? { type: projection } : projection;
// @ts-ignore setProjection does not exist in v4
map.setProjection?.(currProps.projection);
}
if (sky && !deepEqual(sky, currProps.sky)) {
currProps.sky = sky;
map.setSky(sky);
}
if (terrain !== undefined && !deepEqual(terrain, currProps.terrain)) {
if (!terrain || map.getSource(terrain.source)) {
currProps.terrain = terrain;
map.setTerrain(terrain);
}
}
}
}
/* Update interaction handlers to match props */
private _updateHandlers(
nextProps: MaplibreProps,
currProps: MaplibreProps,
): void {
const map = this._map;
for (const propName of handlerNames) {
const newValue = nextProps[propName] ?? true;
const oldValue = currProps[propName] ?? true;
if (!deepEqual(newValue, oldValue)) {
if (newValue) {
map[propName].enable(newValue);
} else {
map[propName].disable();
}
}
}
}
private _onEvent = (e: MapEvent) => {
// @ts-ignore
const cb = this.props[otherEvents[e.type]];
if (cb) {
cb(e);
} else if (e.type === "error") {
console.error((e as ErrorEvent).error); // eslint-disable-line
}
};
private _onCameraEvent = (e: ViewStateChangeEvent) => {
if (this._internalUpdate) {
return;
}
e.viewState =
this._propsedCameraUpdate || transformToViewState(this._map.transform);
// @ts-ignore
const cb = this.props[cameraEvents[e.type]];
if (cb) {
cb(e);
}
};
private _onCameraUpdate = (tr: TransformLike) => {
if (this._internalUpdate) {
return tr;
}
this._propsedCameraUpdate = transformToViewState(tr);
return applyViewStateToTransform(tr, this.props) as any;
};
private _queryRenderedFeatures(point: Point) {
const map = this._map;
const { interactiveLayerIds = [] } = this.props;
try {
return map.queryRenderedFeatures(point, {
layers: interactiveLayerIds.filter(map.getLayer.bind(map)),
});
} catch {
// May fail if style is not loaded
return [];
}
}
private _updateHover(e: MapMouseEvent) {
const { props } = this;
const shouldTrackHoveredFeatures =
props.interactiveLayerIds &&
(props.onMouseMove || props.onMouseEnter || props.onMouseLeave);
if (shouldTrackHoveredFeatures) {
const eventType = e.type;
const wasHovering = this._hoveredFeatures?.length > 0;
const features = this._queryRenderedFeatures(e.point);
const isHovering = features.length > 0;
if (!isHovering && wasHovering) {
e.type = "mouseleave";
this._onPointerEvent(e);
}
this._hoveredFeatures = features;
if (isHovering && !wasHovering) {
e.type = "mouseenter";
this._onPointerEvent(e);
}
e.type = eventType;
} else {
this._hoveredFeatures = null;
}
}
private _onPointerEvent = (e: MapMouseEvent) => {
if (e.type === "mousemove" || e.type === "mouseout") {
this._updateHover(e);
}
// @ts-ignore
const cb = this.props[pointerEvents[e.type]];
if (cb) {
if (
this.props.interactiveLayerIds &&
e.type !== "mouseover" &&
e.type !== "mouseout"
) {
e.features =
this._hoveredFeatures || this._queryRenderedFeatures(e.point);
}
cb(e);
delete e.features;
}
};
}