UNPKG

11.2 kBTypeScriptView Raw
1import * as MapboxGl from 'mapbox-gl';
2import * as React from 'react';
3import {
4 Events,
5 listenEvents,
6 events,
7 Listeners,
8 updateEvents
9} from './map-events';
10import { MapContext } from './context';
11import { createPortal } from 'react-dom';
12const isEqual = require('deep-equal'); //tslint:disable-line
13
14export interface PaddingOptions {
15 top: number;
16 bottom: number;
17 left: number;
18 right: number;
19}
20
21export interface FitBoundsOptions {
22 linear?: boolean;
23 easing?: (time: number) => number;
24 padding?: number | PaddingOptions;
25 offset?: MapboxGl.Point | [number, number];
26 maxZoom?: number;
27 duration?: number;
28}
29
30export type FitBounds = [[number, number], [number, number]];
31
32export interface AnimationOptions {
33 duration: number;
34 animate: boolean;
35 easing(time: number): number;
36 offset: number[];
37}
38
39export interface FlyToOptions {
40 curve: number;
41 minZoom: number;
42 speed: number;
43 screenSpeed: number;
44}
45
46// React Props updated between re-render
47export interface Props {
48 style: string | MapboxGl.Style;
49 center?: [number, number];
50 zoom?: [number];
51 maxBounds?: MapboxGl.LngLatBounds | FitBounds;
52 fitBounds?: FitBounds;
53 fitBoundsOptions?: FitBoundsOptions;
54 bearing?: [number];
55 pitch?: [number];
56 containerStyle?: React.CSSProperties;
57 className?: string;
58 movingMethod?: 'jumpTo' | 'easeTo' | 'flyTo';
59 animationOptions?: Partial<AnimationOptions>;
60 flyToOptions?: Partial<FlyToOptions>;
61 children?: JSX.Element | JSX.Element[] | Array<JSX.Element | undefined>;
62 renderChildrenInPortal?: boolean;
63}
64
65export interface State {
66 map?: MapboxGl.Map;
67 ready: boolean;
68}
69
70export type RequestTransformFunction = (
71 url: string,
72 resourceType: any // tslint:disable-line:no-any
73) => any; // tslint:disable-line:no-any
74
75// Static Properties of the map
76export interface FactoryParameters {
77 accessToken: string;
78 apiUrl?: string;
79 minZoom?: number;
80 maxZoom?: number;
81 hash?: boolean;
82 preserveDrawingBuffer?: boolean;
83 scrollZoom?: boolean;
84 interactive?: boolean;
85 dragRotate?: boolean;
86 pitchWithRotate?: boolean;
87 attributionControl?: boolean;
88 customAttribution?: string | string[];
89 logoPosition?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
90 renderWorldCopies?: boolean;
91 trackResize?: boolean;
92 touchZoomRotate?: boolean;
93 doubleClickZoom?: boolean;
94 keyboard?: boolean;
95 dragPan?: boolean;
96 boxZoom?: boolean;
97 refreshExpiredTiles?: boolean;
98 failIfMajorPerformanceCaveat?: boolean;
99 bearingSnap?: number;
100 transformRequest?: RequestTransformFunction;
101 antialias?: boolean;
102 mapInstance?: MapboxGl.Map;
103}
104
105// Satisfy typescript pitfall with defaultProps
106const defaultZoom = [11];
107const defaultMovingMethod = 'flyTo';
108const defaultCenter = [-0.2416815, 51.5285582];
109
110// tslint:disable-next-line:no-namespace
111declare global {
112 namespace mapboxgl {
113 export interface MapboxOptions {
114 failIfMajorPerformanceCaveat?: boolean;
115 transformRequest?: MapboxGl.TransformRequestFunction;
116 }
117 }
118}
119
120const ReactMapboxFactory = ({
121 accessToken,
122 apiUrl,
123 minZoom = 0,
124 maxZoom = 20,
125 hash = false,
126 preserveDrawingBuffer = false,
127 scrollZoom = true,
128 interactive = true,
129 dragRotate = true,
130 pitchWithRotate = true,
131 attributionControl = true,
132 customAttribution,
133 logoPosition = 'bottom-left',
134 renderWorldCopies = true,
135 trackResize = true,
136 touchZoomRotate = true,
137 doubleClickZoom = true,
138 keyboard = true,
139 dragPan = true,
140 boxZoom = true,
141 refreshExpiredTiles = true,
142 failIfMajorPerformanceCaveat = false,
143 bearingSnap = 7,
144 antialias = false,
145 mapInstance,
146 transformRequest
147}: FactoryParameters) => {
148 return class ReactMapboxGl extends React.Component<Props & Events, State> {
149 public static defaultProps = {
150 // tslint:disable-next-line:no-any
151 onStyleLoad: (map: MapboxGl.Map, evt: any) => null,
152 center: defaultCenter,
153 zoom: defaultZoom,
154 bearing: 0,
155 movingMethod: defaultMovingMethod,
156 pitch: 0,
157 containerStyle: {
158 textAlign: 'left'
159 }
160 };
161
162 public state: State = {
163 map: mapInstance,
164 ready: false
165 };
166
167 public listeners: Listeners = {};
168
169 // tslint:disable-next-line:variable-name
170 public _isMounted = true;
171
172 public container?: HTMLElement;
173
174 public calcCenter = (bounds: FitBounds): [number, number] => [
175 (bounds[0][0] + bounds[1][0]) / 2,
176 (bounds[0][1] + bounds[1][1]) / 2
177 ];
178
179 public componentDidMount() {
180 const {
181 style,
182 onStyleLoad,
183 center,
184 pitch,
185 zoom,
186 fitBounds,
187 fitBoundsOptions,
188 bearing,
189 maxBounds
190 } = this.props;
191
192 // tslint:disable-next-line:no-any
193 (MapboxGl as any).accessToken = accessToken;
194 if (apiUrl) {
195 // tslint:disable-next-line:no-any
196 (MapboxGl as any).config.API_URL = apiUrl;
197 }
198
199 if (!Array.isArray(zoom)) {
200 throw new Error(
201 'zoom need to be an array type of length 1 for reliable update'
202 );
203 }
204
205 const opts: MapboxGl.MapboxOptions = {
206 preserveDrawingBuffer,
207 hash,
208 zoom: zoom[0],
209 minZoom,
210 maxZoom,
211 maxBounds,
212 container: this.container!,
213 center:
214 fitBounds && center === defaultCenter
215 ? this.calcCenter(fitBounds)
216 : center,
217 style,
218 scrollZoom,
219 attributionControl,
220 customAttribution,
221 interactive,
222 dragRotate,
223 pitchWithRotate,
224 renderWorldCopies,
225 trackResize,
226 touchZoomRotate,
227 doubleClickZoom,
228 keyboard,
229 dragPan,
230 boxZoom,
231 refreshExpiredTiles,
232 logoPosition,
233 bearingSnap,
234 failIfMajorPerformanceCaveat,
235 antialias,
236 transformRequest
237 };
238
239 if (bearing) {
240 if (!Array.isArray(bearing)) {
241 throw new Error(
242 'bearing need to be an array type of length 1 for reliable update'
243 );
244 }
245
246 opts.bearing = bearing[0];
247 }
248
249 if (pitch) {
250 if (!Array.isArray(pitch)) {
251 throw new Error(
252 'pitch need to be an array type of length 1 for reliable update'
253 );
254 }
255
256 opts.pitch = pitch[0];
257 }
258
259 // This is a hack to allow injecting the map instance, which assists
260 // in testing and theoretically provides a means for users to inject
261 // their own map instance.
262 let map = this.state.map;
263
264 if (!map) {
265 map = new MapboxGl.Map(opts);
266 this.setState({ map });
267 }
268
269 if (fitBounds) {
270 map.fitBounds(fitBounds, fitBoundsOptions, { fitboundUpdate: true });
271 }
272
273 // tslint:disable-next-line:no-any
274 map.on('load', (evt: React.SyntheticEvent<any>) => {
275 if (this._isMounted) {
276 this.setState({ ready: true });
277 }
278
279 if (onStyleLoad) {
280 onStyleLoad(map!, evt);
281 }
282 });
283
284 this.listeners = listenEvents(events, this.props, map);
285 }
286
287 public componentWillUnmount() {
288 const { map } = this.state;
289 this._isMounted = false;
290
291 if (map) {
292 map.remove();
293 }
294 }
295
296 public componentDidUpdate(prevProps: Props & Events) {
297 const { map } = this.state;
298 if (!map) {
299 return null;
300 }
301
302 // Update event listeners
303 this.listeners = updateEvents(this.listeners, this.props, map);
304
305 const center = map.getCenter();
306 const zoom = map.getZoom();
307 const bearing = map.getBearing();
308 const pitch = map.getPitch();
309
310 const didZoomUpdate =
311 prevProps.zoom !== this.props.zoom &&
312 (this.props.zoom && this.props.zoom[0]) !== zoom;
313
314 const didCenterUpdate =
315 prevProps.center !== this.props.center &&
316 ((this.props.center && this.props.center[0]) !== center.lng ||
317 (this.props.center && this.props.center[1]) !== center.lat);
318
319 const didBearingUpdate =
320 prevProps.bearing !== this.props.bearing &&
321 (this.props.bearing && this.props.bearing[0]) !== bearing;
322
323 const didPitchUpdate =
324 prevProps.pitch !== this.props.pitch &&
325 (this.props.pitch && this.props.pitch[0]) !== pitch;
326
327 if (this.props.maxBounds) {
328 const didMaxBoundsUpdate = prevProps.maxBounds !== this.props.maxBounds;
329
330 if (didMaxBoundsUpdate) {
331 map.setMaxBounds(this.props.maxBounds);
332 }
333 }
334
335 if (this.props.fitBounds) {
336 const { fitBounds } = prevProps;
337
338 const didFitBoundsUpdate =
339 fitBounds !== this.props.fitBounds || // Check for reference equality
340 this.props.fitBounds.length !== (fitBounds && fitBounds.length) || // Added element
341 !!fitBounds.filter((c, i) => {
342 // Check for equality
343 const nc = this.props.fitBounds && this.props.fitBounds[i];
344 return c[0] !== (nc && nc[0]) || c[1] !== (nc && nc[1]);
345 })[0];
346
347 if (
348 didFitBoundsUpdate ||
349 !isEqual(prevProps.fitBoundsOptions, this.props.fitBoundsOptions)
350 ) {
351 map.fitBounds(this.props.fitBounds, this.props.fitBoundsOptions, {
352 fitboundUpdate: true
353 });
354 }
355 }
356
357 if (
358 didZoomUpdate ||
359 didCenterUpdate ||
360 didBearingUpdate ||
361 didPitchUpdate
362 ) {
363 const mm: string = this.props.movingMethod || defaultMovingMethod;
364 const { flyToOptions, animationOptions } = this.props;
365
366 map[mm]({
367 ...animationOptions,
368 ...flyToOptions,
369 zoom: didZoomUpdate && this.props.zoom ? this.props.zoom[0] : zoom,
370 center: didCenterUpdate ? this.props.center : center,
371 bearing: didBearingUpdate ? this.props.bearing : bearing,
372 pitch: didPitchUpdate ? this.props.pitch : pitch
373 });
374 }
375
376 if (!isEqual(prevProps.style, this.props.style)) {
377 map.setStyle(this.props.style);
378 }
379
380 return null;
381 }
382
383 public setRef = (x: HTMLElement | null) => {
384 this.container = x!;
385 };
386
387 public render() {
388 const {
389 containerStyle,
390 className,
391 children,
392 renderChildrenInPortal
393 } = this.props;
394
395 const { ready, map } = this.state;
396
397 if (renderChildrenInPortal) {
398 const container =
399 ready && map && typeof map.getCanvasContainer === 'function'
400 ? map.getCanvasContainer()
401 : undefined;
402
403 return (
404 <MapContext.Provider value={map}>
405 <div
406 ref={this.setRef}
407 className={className}
408 style={{ ...containerStyle }}
409 >
410 {ready && container && createPortal(children, container)}
411 </div>
412 </MapContext.Provider>
413 );
414 }
415
416 return (
417 <MapContext.Provider value={map}>
418 <div
419 ref={this.setRef}
420 className={className}
421 style={{ ...containerStyle }}
422 >
423 {ready && children}
424 </div>
425 </MapContext.Provider>
426 );
427 }
428 };
429};
430
431export default ReactMapboxFactory;