1 | import * as React from 'react';
|
2 | import { Map } from 'mapbox-gl';
|
3 | import { AnchorLimits } from './util/types';
|
4 | import { withMap } from './context';
|
5 |
|
6 | const triggerEvents = ['moveend', 'touchend', 'zoomend'];
|
7 |
|
8 | const scales = [
|
9 | 0.01,
|
10 | 0.02,
|
11 | 0.05,
|
12 | 0.1,
|
13 | 0.2,
|
14 | 0.5,
|
15 | 1,
|
16 | 2,
|
17 | 5,
|
18 | 10,
|
19 | 20,
|
20 | 50,
|
21 | 100,
|
22 | 200,
|
23 | 500,
|
24 | 1000,
|
25 | 2 * 1000,
|
26 | 5 * 1000,
|
27 | 10 * 1000
|
28 | ];
|
29 |
|
30 | const positions = {
|
31 | 'top-right': { top: 10, right: 10, bottom: 'auto', left: 'auto' },
|
32 | 'top-left': { top: 10, left: 10, bottom: 'auto', right: 'auto' },
|
33 | 'bottom-right': { bottom: 10, right: 10, top: 'auto', left: 'auto' },
|
34 | 'bottom-left': { bottom: 10, left: 10, top: 'auto', right: 'auto' }
|
35 | };
|
36 |
|
37 | const containerStyle: React.CSSProperties = {
|
38 | position: 'absolute',
|
39 | zIndex: 10,
|
40 | boxShadow: '0px 1px 4px rgba(0, 0, 0, .3)',
|
41 | border: '1px solid rgba(0, 0, 0, 0.1)',
|
42 | right: 50,
|
43 | backgroundColor: '#fff',
|
44 | opacity: 0.85,
|
45 | display: 'flex',
|
46 | flexDirection: 'row',
|
47 | alignItems: 'baseline',
|
48 | padding: '3px 7px'
|
49 | };
|
50 |
|
51 | const scaleStyle = {
|
52 | border: '2px solid #7e8490',
|
53 | boxShadow: '0px 1px 4px rgba(0, 0, 0, .3)',
|
54 | borderTop: 'none',
|
55 | height: 7,
|
56 | borderBottomLeftRadius: 1,
|
57 | borderBottomRightRadius: 1
|
58 | };
|
59 |
|
60 | const POSITIONS = Object.keys(positions);
|
61 | const MEASUREMENTS = ['km', 'mi'] as Measurement[];
|
62 |
|
63 | const MILE_IN_KILOMETERS = 1.60934;
|
64 | const MILE_IN_FEET = 5280;
|
65 | const KILOMETER_IN_METERS = 1000;
|
66 |
|
67 | const MIN_WIDTH_SCALE = 60;
|
68 |
|
69 | export type Measurement = 'km' | 'mi';
|
70 |
|
71 | export interface Props {
|
72 | measurement?: Measurement;
|
73 | position?: AnchorLimits;
|
74 | style?: React.CSSProperties;
|
75 | className?: string;
|
76 | tabIndex?: number;
|
77 | map: Map;
|
78 | }
|
79 |
|
80 | export interface State {
|
81 | chosenScale: number;
|
82 | scaleWidth: number;
|
83 | }
|
84 |
|
85 | export class ScaleControl extends React.Component<Props, State> {
|
86 | public static defaultProps = {
|
87 | measurement: MEASUREMENTS[0],
|
88 | position: POSITIONS[2]
|
89 | };
|
90 |
|
91 | public state = {
|
92 | chosenScale: 0,
|
93 | scaleWidth: MIN_WIDTH_SCALE
|
94 | };
|
95 |
|
96 | public componentDidMount() {
|
97 | this.setScale();
|
98 |
|
99 | triggerEvents.forEach(event => {
|
100 | this.props.map.on(event, this.setScale);
|
101 | });
|
102 | }
|
103 |
|
104 | public componentWillUnmount() {
|
105 | if (this.props.map) {
|
106 | triggerEvents.forEach(event => {
|
107 | this.props.map.off(event, this.setScale);
|
108 | });
|
109 | }
|
110 | }
|
111 |
|
112 | private setScale = () => {
|
113 | const { measurement, map } = this.props;
|
114 |
|
115 |
|
116 | const clientWidth = (map as any)._canvas.clientWidth;
|
117 |
|
118 | const { _ne, _sw } = map.getBounds() as any;
|
119 |
|
120 | const totalWidth = this._getDistanceTwoPoints(
|
121 | [_sw.lng, _ne.lat],
|
122 | [_ne.lng, _ne.lat],
|
123 | measurement
|
124 | );
|
125 |
|
126 | const relativeWidth = totalWidth / clientWidth * MIN_WIDTH_SCALE;
|
127 |
|
128 | const chosenScale = scales.reduce((acc, curr) => {
|
129 | if (!acc && curr > relativeWidth) {
|
130 | return curr;
|
131 | }
|
132 |
|
133 | return acc;
|
134 | }, 0);
|
135 |
|
136 |
|
137 | const scaleWidth = chosenScale / totalWidth * clientWidth;
|
138 |
|
139 | this.setState({
|
140 | chosenScale,
|
141 | scaleWidth
|
142 | });
|
143 | };
|
144 |
|
145 | private _getDistanceTwoPoints(x: number[], y: number[], measurement = 'km') {
|
146 | const [lng1, lat1] = x;
|
147 | const [lng2, lat2] = y;
|
148 |
|
149 |
|
150 | const R = measurement === 'km' ? 6371 : 6371 / MILE_IN_KILOMETERS;
|
151 | const dLat = this._deg2rad(lat2 - lat1);
|
152 | const dLng = this._deg2rad(lng2 - lng1);
|
153 | const a =
|
154 | Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
155 | Math.cos(this._deg2rad(lat1)) *
|
156 | Math.cos(this._deg2rad(lat2)) *
|
157 | Math.sin(dLng / 2) *
|
158 | Math.sin(dLng / 2);
|
159 | const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
160 | const d = R * c;
|
161 |
|
162 | return d;
|
163 | }
|
164 |
|
165 | private _deg2rad(deg: number) {
|
166 | return deg * (Math.PI / 180);
|
167 | }
|
168 |
|
169 | private _displayMeasurement(measurement: Measurement, chosenScale: number) {
|
170 | if (chosenScale >= 1) {
|
171 | return `${chosenScale} ${measurement}`;
|
172 | }
|
173 |
|
174 | if (measurement === 'mi') {
|
175 | return `${Math.floor(chosenScale * MILE_IN_FEET)} ft`;
|
176 | }
|
177 |
|
178 | return `${Math.floor(chosenScale * KILOMETER_IN_METERS)} m`;
|
179 | }
|
180 |
|
181 | public render() {
|
182 | const { measurement, style, position, className, tabIndex } = this.props;
|
183 | const { chosenScale, scaleWidth } = this.state;
|
184 |
|
185 | return (
|
186 | <div
|
187 | tabIndex={tabIndex}
|
188 | style={{ ...containerStyle, ...positions[position!], ...style }}
|
189 | className={className}
|
190 | >
|
191 | <div style={{ ...scaleStyle, width: scaleWidth }} />
|
192 | <div style={{ paddingLeft: 10 }}>
|
193 | {this._displayMeasurement(measurement!, chosenScale)}
|
194 | </div>
|
195 | </div>
|
196 | );
|
197 | }
|
198 | }
|
199 |
|
200 | export default withMap(ScaleControl);
|