@performant-software/geospatial 1.1.3-beta.4 → 1.1.3-beta.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,158 @@
1
+ // @flow
2
+
3
+ import React, {
4
+ Children,
5
+ useCallback,
6
+ useEffect,
7
+ useMemo, useRef,
8
+ useState
9
+ } from 'react';
10
+ import { BsStack } from 'react-icons/bs';
11
+ import { IoCheckmarkOutline } from 'react-icons/io5';
12
+ import _ from 'underscore';
13
+ import LayerMenuContainer from './LayerMenuContainer';
14
+ import './LayerMenu.css';
15
+
16
+ type Props = {
17
+ children: Node,
18
+ names: Array<string>,
19
+ position: 'top-left' | 'bottom-left' | 'top-right' | 'bottom-right'
20
+ };
21
+
22
+ const MENU_PADDING = 30;
23
+
24
+ const LayerMenu = (props: Props) => {
25
+ const [canvasHeight, setCanvasHeight] = useState(0);
26
+ const [visible, setVisible] = useState();
27
+ const [menuOpen, setMenuOpen] = useState(false);
28
+
29
+ const mapRef = useRef();
30
+
31
+ /**
32
+ * Returns the name of the layer at the passed index.
33
+ *
34
+ * @type {unknown}
35
+ */
36
+ const getLayerName = useCallback((index) => (
37
+ props.names && props.names.length > index && props.names[index]
38
+ ), [props.names]);
39
+
40
+ /**
41
+ * Returns true if the child element at the passed index is visible.
42
+ *
43
+ * @type {function(*): *}
44
+ */
45
+ const isVisible = useCallback((index) => _.includes(visible, index), [visible]);
46
+
47
+ /**
48
+ * Returns a memoized array of the child elements.
49
+ *
50
+ * @type {Array<$NonMaybeType<unknown>>}
51
+ */
52
+ const children = useMemo(() => Children.toArray(props.children), [props.children]);
53
+
54
+ /**
55
+ * Returns a memoized array of visible child elements.
56
+ */
57
+ const visibleChildren = useMemo(() => _.filter(children, (child, index) => isVisible(index)), [children, isVisible]);
58
+
59
+ /**
60
+ * Toggles the visibility for the child element at the passed index.
61
+ *
62
+ * @type {(function(*): void)|*}
63
+ */
64
+ const toggleVisibility = useCallback((index) => {
65
+ let value;
66
+
67
+ if (isVisible(index)) {
68
+ value = _.without(visible, index);
69
+ } else {
70
+ value = [...visible, index];
71
+ }
72
+
73
+ setVisible(value);
74
+ }, [isVisible, visible]);
75
+
76
+ /**
77
+ * Sets all of the child elements to be visible when the component mounts.
78
+ */
79
+ useEffect(() => {
80
+ setVisible(_.times(children.length, (index) => index));
81
+ }, []);
82
+
83
+ /**
84
+ * Sets the map canvas height.
85
+ */
86
+ useEffect(() => {
87
+ const { current: instance } = mapRef;
88
+
89
+ if (instance && instance._canvas) {
90
+ const { offsetHeight = 0 } = mapRef.current._canvas;
91
+ setCanvasHeight(offsetHeight);
92
+ }
93
+ }, [mapRef.current]);
94
+
95
+ if (_.isEmpty(children)) {
96
+ return null;
97
+ }
98
+
99
+ return (
100
+ <>
101
+ <LayerMenuContainer
102
+ mapRef={mapRef}
103
+ position={props.position}
104
+ >
105
+ <button
106
+ className='mapbox-gl-draw_ctrl-draw-btn layer-button'
107
+ onClick={() => setMenuOpen((prevMenuOpen) => !prevMenuOpen)}
108
+ type='button'
109
+ >
110
+ <BsStack
111
+ size='1.25em'
112
+ />
113
+ </button>
114
+ { menuOpen && (
115
+ <div
116
+ className='layer-menu'
117
+ style={{
118
+ maxHeight: `calc(${canvasHeight}px - ${MENU_PADDING}px)`
119
+ }}
120
+ >
121
+ <div
122
+ className='menu'
123
+ >
124
+ { _.map(children, (child, index) => (
125
+ <div
126
+ aria-selected={isVisible(index)}
127
+ className='option'
128
+ role='option'
129
+ onClick={() => toggleVisibility(index)}
130
+ onKeyDown={() => toggleVisibility(index)}
131
+ tabIndex={index}
132
+ >
133
+ <div
134
+ className='checkmark-container'
135
+ >
136
+ { isVisible(index) && (
137
+ <IoCheckmarkOutline
138
+ size='1em'
139
+ />
140
+ )}
141
+ </div>
142
+ { getLayerName(index) }
143
+ </div>
144
+ ))}
145
+ </div>
146
+ </div>
147
+ )}
148
+ </LayerMenuContainer>
149
+ { visibleChildren }
150
+ </>
151
+ );
152
+ };
153
+
154
+ LayerMenu.defaultProps = {
155
+ position: 'top-left'
156
+ };
157
+
158
+ export default LayerMenu;
@@ -0,0 +1,80 @@
1
+ // @flow
2
+
3
+ import {
4
+ Children,
5
+ cloneElement,
6
+ useEffect,
7
+ useState
8
+ } from 'react';
9
+ import { createPortal } from 'react-dom';
10
+ import { MapboxMap, useControl } from 'react-map-gl';
11
+ import { IControl } from 'maplibre-gl';
12
+ import _ from 'underscore';
13
+
14
+ /**
15
+ * Layer control implementation.
16
+ */
17
+ class LayerControl implements IControl {
18
+ _map: MapboxMap = null;
19
+ _container: HTMLElement;
20
+ _redraw: () => void;
21
+
22
+ constructor(redraw: () => void) {
23
+ this._redraw = redraw;
24
+ }
25
+
26
+ onAdd(map) {
27
+ this._map = map;
28
+ map.on('move', this._redraw);
29
+
30
+ this._container = document.createElement('div');
31
+ this._container.className = 'maplibregl-ctrl-group maplibregl-ctrl';
32
+ this._redraw();
33
+ return this._container;
34
+ }
35
+
36
+ onRemove() {
37
+ this._container.remove();
38
+ this._map.off('move', this._redraw);
39
+ this._map = null;
40
+ }
41
+
42
+ getMap() {
43
+ return this._map;
44
+ }
45
+
46
+ getElement() {
47
+ return this._container;
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Layer container.
53
+ *
54
+ * @param props
55
+ *
56
+ * @returns {React$Portal}
57
+ */
58
+ const LayerMenuContainer = (props) => {
59
+ const [, setVersion] = useState(0);
60
+
61
+ const ctrl = useControl(() => {
62
+ const forceUpdate = () => setVersion((v) => v + 1);
63
+ return new LayerControl(forceUpdate);
64
+ }, { position: props.position });
65
+
66
+ const map = ctrl.getMap();
67
+
68
+ const children = Children.map(_.compact(props.children), (child) => cloneElement(child, { map }));
69
+
70
+ useEffect(() => {
71
+ if (props.mapRef) {
72
+ // eslint-disable-next-line no-param-reassign
73
+ props.mapRef.current = map;
74
+ }
75
+ }, [map, props.mapRef]);
76
+
77
+ return map && createPortal(children, ctrl.getElement());
78
+ };
79
+
80
+ export default LayerMenuContainer;
@@ -1,5 +1,2 @@
1
1
  @import '@mapbox/mapbox-gl-draw/dist/mapbox-gl-draw.css';
2
-
3
- div[mapboxgl-children] {
4
- height: unset !important;
5
- }
2
+ @import 'maplibre-gl/dist/maplibre-gl.css';
@@ -3,6 +3,8 @@
3
3
  import MapboxDraw from '@mapbox/mapbox-gl-draw';
4
4
  import {
5
5
  bbox,
6
+ bboxPolygon,
7
+ buffer,
6
8
  feature,
7
9
  type FeatureCollection,
8
10
  type GeometryCollection
@@ -16,18 +18,22 @@ import React, {
16
18
  useState,
17
19
  type Node
18
20
  } from 'react';
19
- import Map, { Layer, MapRef, Source } from 'react-map-gl';
21
+ import Map, { MapRef } from 'react-map-gl';
20
22
  import _ from 'underscore';
21
23
  import DrawControl from './DrawControl';
22
24
  import './MapDraw.css';
23
25
 
24
- type LayerType = {
25
- id?: string | number,
26
- type: string,
27
- data: any
28
- };
26
+ // Override the MapboxDraw components to use MapLibre styles
27
+ MapboxDraw.constants.classes.CONTROL_BASE = 'maplibregl-ctrl';
28
+ MapboxDraw.constants.classes.CONTROL_PREFIX = 'maplibregl-ctrl-';
29
+ MapboxDraw.constants.classes.CONTROL_GROUP = 'maplibregl-ctrl-group';
29
30
 
30
31
  type Props = {
32
+ /**
33
+ * The number of miles to buffer the GeoJSON data.
34
+ */
35
+ buffer?: number,
36
+
31
37
  /**
32
38
  * Additional child nodes to render.
33
39
  */
@@ -38,8 +44,6 @@ type Props = {
38
44
  */
39
45
  data: GeometryCollection | FeatureCollection,
40
46
 
41
- layers: Array<LayerType>,
42
-
43
47
  /**
44
48
  * URL of the map style to render. This URL should contain any necessary API keys.
45
49
  */
@@ -55,9 +59,17 @@ type Props = {
55
59
  /**
56
60
  * Map style object.
57
61
  */
58
- style?: any
62
+ style?: any,
63
+
64
+ /**
65
+ * The time in milliseconds to zoom into the location.
66
+ */
67
+ zoomDuration?: number
59
68
  };
60
69
 
70
+ const DEFAULT_BUFFER = 2;
71
+ const DEFAULT_ZOOM_DELAY = 1000;
72
+
61
73
  const GeometryTypes = {
62
74
  geometryCollection: 'GeometryCollection',
63
75
  point: 'Point'
@@ -73,8 +85,6 @@ const MapDraw = (props: Props) => {
73
85
  const drawRef = useRef<MapboxDraw>();
74
86
  const mapRef = useRef<MapRef>();
75
87
 
76
- const geojsonLayers = useMemo(() => _.filter(props.layers, (layer) => !!layer.data), [props.layers]);
77
-
78
88
  /**
79
89
  * Calls the onChange prop with all of the geometries in the current drawer.
80
90
  *
@@ -96,14 +106,24 @@ const MapDraw = (props: Props) => {
96
106
  */
97
107
  useEffect(() => {
98
108
  if (loaded && props.data) {
99
- // Sets the bounding box for the current geometry.
100
- const boundingBox = bbox(props.data);
109
+ // Convert the GeoJSON into a bounding box
110
+ const box = bbox(props.data);
111
+
112
+ // Convert the bounding box to a polygon
113
+ const polygon = bboxPolygon(box);
114
+
115
+ // Create a buffer around the polygon
116
+ const polygonBuffer = buffer(polygon, props.buffer, { units: 'miles' });
101
117
 
118
+ // Convert the buffer to a bounding box
119
+ const boundingBox = bbox(polygonBuffer);
120
+
121
+ // Sets the bounding box for the current geometry.
102
122
  if (_.every(boundingBox, _.isFinite)) {
103
123
  const [minLng, minLat, maxLng, maxLat] = boundingBox;
104
124
  const bounds = [[minLng, minLat], [maxLng, maxLat]];
105
125
 
106
- mapRef.current.fitBounds(bounds, { padding: 40, duration: 1000 });
126
+ mapRef.current.fitBounds(bounds, { duration: props.zoomDuration });
107
127
  }
108
128
 
109
129
  // Handle special cases for geometry collection (not supported by mabox-gl-draw) and point
@@ -138,20 +158,16 @@ const MapDraw = (props: Props) => {
138
158
  onCreate={onChange}
139
159
  onUpdate={onChange}
140
160
  onDelete={onChange}
161
+ position='bottom-left'
141
162
  />
142
- { _.map(geojsonLayers, (layer) => (
143
- <Source
144
- type='geojson'
145
- data={layer.data}
146
- >
147
- <Layer
148
- {..._.omit(layer, 'data')}
149
- />
150
- </Source>
151
- ))}
152
163
  { props.children }
153
164
  </Map>
154
165
  );
155
166
  };
156
167
 
168
+ MapDraw.defaultProps = {
169
+ buffer: DEFAULT_BUFFER,
170
+ zoomDuration: DEFAULT_ZOOM_DELAY
171
+ };
172
+
157
173
  export default MapDraw;
@@ -0,0 +1,36 @@
1
+ // @flow
2
+
3
+ import React from 'react';
4
+ import { Layer, Source } from 'react-map-gl';
5
+
6
+ type Props = {
7
+ maxzoom?: number,
8
+ minzoom?: number,
9
+ opacity?: number,
10
+ tileSize?: number,
11
+ url?: string,
12
+ };
13
+
14
+ const RasterLayer = (props: Props) => (
15
+ <Source
16
+ tileSize={props.tileSize}
17
+ tiles={[props.url]}
18
+ type='raster'
19
+ >
20
+ <Layer
21
+ type='raster'
22
+ paint={{
23
+ 'raster-opacity': props.opacity
24
+ }}
25
+ minzoom={props.minzoom}
26
+ maxzoom={props.maxzoom}
27
+ />
28
+ </Source>
29
+ );
30
+
31
+ RasterLayer.defaultProps = {
32
+ opacity: 0.7,
33
+ tileSize: 256
34
+ };
35
+
36
+ export default RasterLayer;
package/src/index.js CHANGED
@@ -2,4 +2,7 @@
2
2
 
3
3
  // Components
4
4
  export { default as DrawControl } from './components/DrawControl';
5
+ export { default as GeoJsonLayer } from './components/GeoJsonLayer';
6
+ export { default as LayerMenu } from './components/LayerMenu';
5
7
  export { default as MapDraw } from './components/MapDraw';
8
+ export { default as RasterLayer } from './components/RasterLayer';
@@ -0,0 +1,118 @@
1
+ // @flow
2
+
3
+ import React, {
4
+ useCallback,
5
+ useEffect,
6
+ useMemo,
7
+ useState
8
+ } from 'react';
9
+ import { Layer, Source } from 'react-map-gl';
10
+ import _ from 'underscore';
11
+
12
+ type Props = {
13
+ data?: { [key: string]: any },
14
+ fillStyle?: { [key: string]: any },
15
+ lineStyle?: { [key: string]: any },
16
+ pointStyle?: { [key: string]: any },
17
+ url?: string
18
+ };
19
+
20
+ const DEFAULT_COLOR = '#CC3333';
21
+ const HIGHLIGHT_COLOR = '#990000';
22
+
23
+ const DEFAULT_FILL_STYLES = {
24
+ 'fill-color': DEFAULT_COLOR,
25
+ 'fill-opacity': 0.2
26
+ };
27
+
28
+ const DEFAULT_LINE_STYLES = {
29
+ 'line-color': HIGHLIGHT_COLOR,
30
+ 'line-opacity': 0.6
31
+ };
32
+
33
+ const DEFAULT_POINT_STYLES = {
34
+ 'circle-radius': [
35
+ 'interpolate',
36
+ ['linear'],
37
+ ['number', ['get', 'point_count'], 1],
38
+ 0, 4,
39
+ 10, 14
40
+ ],
41
+ 'circle-stroke-width': 1,
42
+ 'circle-color': DEFAULT_COLOR,
43
+ 'circle-stroke-color': HIGHLIGHT_COLOR
44
+ };
45
+
46
+ const GeoJsonLayer = (props: Props) => {
47
+ const [data, setData] = useState(props.data);
48
+
49
+ /**
50
+ * Returns the layer style for the passed style and default.
51
+ *
52
+ * @type {function(*, *): *}
53
+ */
54
+ const getLayerStyles = useCallback((style, defaultStyle) => _.defaults(style, defaultStyle), []);
55
+
56
+ /**
57
+ * Sets the fill layer style.
58
+ *
59
+ * @type {*}
60
+ */
61
+ const fillStyle = useMemo(() => (
62
+ getLayerStyles(props.fillStyle, DEFAULT_FILL_STYLES)
63
+ ), [getLayerStyles, props.fillStyle]);
64
+
65
+ /**
66
+ * Sets the line layer style.
67
+ *
68
+ * @type {*}
69
+ */
70
+ const lineStyle = useMemo(() => (
71
+ getLayerStyles(props.lineStyle, DEFAULT_LINE_STYLES)
72
+ ), [getLayerStyles, props.lineStyle]);
73
+
74
+ /**
75
+ * Sets the point layer style.
76
+ *
77
+ * @type {*}
78
+ */
79
+ const pointStyle = useMemo(() => (
80
+ getLayerStyles(props.pointStyle, DEFAULT_POINT_STYLES)
81
+ ), [getLayerStyles, props.pointStyle]);
82
+
83
+ /**
84
+ * If the data is passed as a URL, fetches the passed URL and sets the response on the state.
85
+ */
86
+ useEffect(() => {
87
+ if (props.url) {
88
+ fetch(props.url)
89
+ .then((response) => response.json())
90
+ .then((json) => setData(json));
91
+ }
92
+ }, [props.url]);
93
+
94
+ return (
95
+ <Source
96
+ data={data}
97
+ type='geojson'
98
+ >
99
+ <Layer
100
+ filter={['!=', '$type', 'Point']}
101
+ paint={fillStyle}
102
+ type='fill'
103
+ />
104
+ <Layer
105
+ filter={['!=', '$type', 'Point']}
106
+ paint={lineStyle}
107
+ type='line'
108
+ />
109
+ <Layer
110
+ filter={['==', '$type', 'Point']}
111
+ paint={pointStyle}
112
+ type='circle'
113
+ />
114
+ </Source>
115
+ );
116
+ };
117
+
118
+ export default GeoJsonLayer;
@@ -0,0 +1,158 @@
1
+ // @flow
2
+
3
+ import React, {
4
+ Children,
5
+ useCallback,
6
+ useEffect,
7
+ useMemo, useRef,
8
+ useState
9
+ } from 'react';
10
+ import { BsStack } from 'react-icons/bs';
11
+ import { IoCheckmarkOutline } from 'react-icons/io5';
12
+ import _ from 'underscore';
13
+ import LayerMenuContainer from './LayerMenuContainer';
14
+ import './LayerMenu.css';
15
+
16
+ type Props = {
17
+ children: Node,
18
+ names: Array<string>,
19
+ position: 'top-left' | 'bottom-left' | 'top-right' | 'bottom-right'
20
+ };
21
+
22
+ const MENU_PADDING = 30;
23
+
24
+ const LayerMenu = (props: Props) => {
25
+ const [canvasHeight, setCanvasHeight] = useState(0);
26
+ const [visible, setVisible] = useState();
27
+ const [menuOpen, setMenuOpen] = useState(false);
28
+
29
+ const mapRef = useRef();
30
+
31
+ /**
32
+ * Returns the name of the layer at the passed index.
33
+ *
34
+ * @type {unknown}
35
+ */
36
+ const getLayerName = useCallback((index) => (
37
+ props.names && props.names.length > index && props.names[index]
38
+ ), [props.names]);
39
+
40
+ /**
41
+ * Returns true if the child element at the passed index is visible.
42
+ *
43
+ * @type {function(*): *}
44
+ */
45
+ const isVisible = useCallback((index) => _.includes(visible, index), [visible]);
46
+
47
+ /**
48
+ * Returns a memoized array of the child elements.
49
+ *
50
+ * @type {Array<$NonMaybeType<unknown>>}
51
+ */
52
+ const children = useMemo(() => Children.toArray(props.children), [props.children]);
53
+
54
+ /**
55
+ * Returns a memoized array of visible child elements.
56
+ */
57
+ const visibleChildren = useMemo(() => _.filter(children, (child, index) => isVisible(index)), [children, isVisible]);
58
+
59
+ /**
60
+ * Toggles the visibility for the child element at the passed index.
61
+ *
62
+ * @type {(function(*): void)|*}
63
+ */
64
+ const toggleVisibility = useCallback((index) => {
65
+ let value;
66
+
67
+ if (isVisible(index)) {
68
+ value = _.without(visible, index);
69
+ } else {
70
+ value = [...visible, index];
71
+ }
72
+
73
+ setVisible(value);
74
+ }, [isVisible, visible]);
75
+
76
+ /**
77
+ * Sets all of the child elements to be visible when the component mounts.
78
+ */
79
+ useEffect(() => {
80
+ setVisible(_.times(children.length, (index) => index));
81
+ }, []);
82
+
83
+ /**
84
+ * Sets the map canvas height.
85
+ */
86
+ useEffect(() => {
87
+ const { current: instance } = mapRef;
88
+
89
+ if (instance && instance._canvas) {
90
+ const { offsetHeight = 0 } = mapRef.current._canvas;
91
+ setCanvasHeight(offsetHeight);
92
+ }
93
+ }, [mapRef.current]);
94
+
95
+ if (_.isEmpty(children)) {
96
+ return null;
97
+ }
98
+
99
+ return (
100
+ <>
101
+ <LayerMenuContainer
102
+ mapRef={mapRef}
103
+ position={props.position}
104
+ >
105
+ <button
106
+ className='mapbox-gl-draw_ctrl-draw-btn layer-button'
107
+ onClick={() => setMenuOpen((prevMenuOpen) => !prevMenuOpen)}
108
+ type='button'
109
+ >
110
+ <BsStack
111
+ size='1.25em'
112
+ />
113
+ </button>
114
+ { menuOpen && (
115
+ <div
116
+ className='layer-menu'
117
+ style={{
118
+ maxHeight: `calc(${canvasHeight}px - ${MENU_PADDING}px)`
119
+ }}
120
+ >
121
+ <div
122
+ className='menu'
123
+ >
124
+ { _.map(children, (child, index) => (
125
+ <div
126
+ aria-selected={isVisible(index)}
127
+ className='option'
128
+ role='option'
129
+ onClick={() => toggleVisibility(index)}
130
+ onKeyDown={() => toggleVisibility(index)}
131
+ tabIndex={index}
132
+ >
133
+ <div
134
+ className='checkmark-container'
135
+ >
136
+ { isVisible(index) && (
137
+ <IoCheckmarkOutline
138
+ size='1em'
139
+ />
140
+ )}
141
+ </div>
142
+ { getLayerName(index) }
143
+ </div>
144
+ ))}
145
+ </div>
146
+ </div>
147
+ )}
148
+ </LayerMenuContainer>
149
+ { visibleChildren }
150
+ </>
151
+ );
152
+ };
153
+
154
+ LayerMenu.defaultProps = {
155
+ position: 'top-left'
156
+ };
157
+
158
+ export default LayerMenu;