Skip to content

Commit ac80829

Browse files
authored
Add polygon draw mode; don't require holding Shift to draw bbox (#792)
1 parent c2fbf4a commit ac80829

File tree

2 files changed

+115
-76
lines changed

2 files changed

+115
-76
lines changed

src/components/filters/location.js

+101-62
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import maplibre from 'maplibre-gl';
99
import {
1010
TerraDraw,
1111
TerraDrawRectangleMode,
12+
TerraDrawPolygonMode,
1213
TerraDrawRenderMode
1314
} from 'terra-draw';
1415
import { TerraDrawMapLibreGLAdapter } from 'terra-draw-maplibre-gl-adapter';
@@ -23,16 +24,14 @@ import { nominatimSearch } from '../../network/nominatim';
2324
const LocationSelect = props => {
2425
const { name, value, placeholder, onChange } = props;
2526

26-
// State
2727
const [queryType, setQueryType] = useState('q');
2828
const [isLoading, setIsLoading] = useState(false);
2929
const [inputValue, setInputValue] = useState('');
30+
const [activeMode, setActiveMode] = useState('render');
3031

31-
// Refs
3232
const mapRef = useRef(null);
3333
const drawRef = useRef(null);
3434

35-
// Query type options
3635
const queryTypeOptions = [
3736
{ value: 'q', label: 'Any' },
3837
{ value: 'city', label: 'City' },
@@ -41,7 +40,6 @@ const LocationSelect = props => {
4140
{ value: 'country', label: 'Country' }
4241
];
4342

44-
// Update map with new data
4543
const updateMap = useCallback(
4644
data => {
4745
const map = mapRef.current;
@@ -52,13 +50,7 @@ const LocationSelect = props => {
5250
if (map.getSource('feature')) {
5351
map.getSource('feature').setData(data);
5452
} else {
55-
map.addSource('feature', {
56-
type: 'geojson',
57-
data: {
58-
type: 'Feature',
59-
geometry: data
60-
}
61-
});
53+
map.addSource('feature', { type: 'geojson', data });
6254
}
6355

6456
if (map.getLayer('geometry') === undefined) {
@@ -68,23 +60,18 @@ const LocationSelect = props => {
6860
source: 'feature',
6961
paint: {
7062
'fill-color': '#088',
71-
'fill-opacity': 0.6
63+
'fill-opacity': 0.3
7264
}
7365
});
7466
}
7567

76-
onChange('in_bbox', undefined);
77-
onChange('geometry', fromJS([{ label: data, value: data }]));
78-
7968
const bounds = bbox(data);
8069
map.fitBounds([bounds.slice(0, 2), bounds.slice(2)], { padding: 20 });
8170
},
8271
[onChange]
8372
);
8473

85-
// Initialize map and draw
8674
useEffect(() => {
87-
// Create map
8875
const map = new maplibre.Map({
8976
container: 'geometry-map',
9077
style: '/positron.json'
@@ -96,11 +83,11 @@ const LocationSelect = props => {
9683
map.touchZoomRotate.disableRotation();
9784
map.keyboard.disableRotation();
9885

99-
// Create draw
10086
const draw = new TerraDraw({
10187
adapter: new TerraDrawMapLibreGLAdapter({ map, lib: maplibre }),
10288
modes: [
10389
new TerraDrawRectangleMode(),
90+
new TerraDrawPolygonMode(),
10491
new TerraDrawRenderMode({ modeName: 'render' })
10592
]
10693
});
@@ -110,67 +97,49 @@ const LocationSelect = props => {
11097
draw.on('finish', (id, context) => {
11198
const snapshot = draw.getSnapshot();
11299
const feature = snapshot.find(f => f.id === id);
113-
const bounds = bbox(feature); // even though the user drew a box, 'feature' is a Polygon
114-
const wsen = bounds.map(v => v.toFixed(4)).join(',');
115100

116-
onChange('geometry', undefined);
117-
onChange('in_bbox', fromJS([{ label: wsen, value: wsen }]));
101+
if (feature.geometry.type === 'Polygon') {
102+
if (draw.getMode() === 'rectangle') {
103+
const bounds = bbox(feature);
104+
const wsen = bounds.map(v => v.toFixed(4)).join(',');
105+
onChange('geometry', null);
106+
onChange('in_bbox', fromJS([{ label: wsen, value: wsen }]));
107+
} else {
108+
onChange(
109+
'geometry',
110+
fromJS([{ label: feature.geometry, value: feature.geometry }])
111+
);
112+
onChange('in_bbox', null);
113+
}
114+
}
115+
116+
// Set mode back to render after completing a shape
117+
draw.setMode('render');
118+
setActiveMode('render');
119+
updateMap(feature.geometry);
118120
});
119121

120-
// Store refs
121122
mapRef.current = map;
122123
drawRef.current = draw;
123124

124-
// Set up event listeners
125-
const handleKeyDown = event => {
126-
if (event.key === 'Shift') {
127-
draw.clear();
128-
map.getSource('feature')?.setData({
129-
type: 'Feature',
130-
geometry: null
131-
});
132-
draw.setMode('rectangle');
133-
}
134-
};
135-
136-
const handleKeyUp = event => {
137-
if (event.key === 'Shift') {
138-
draw.setMode('render');
139-
}
140-
};
141-
142-
document.addEventListener('keydown', handleKeyDown);
143-
document.addEventListener('keyup', handleKeyUp);
144-
145125
map.on('style.load', () => {
146126
map.setProjection({ type: 'globe' });
147127

148128
// Display initial bbox or polygon (if it exists) on the map
149129
if (value && value.size > 0) {
150130
const { value: geometry } = value.get(0).toJS();
151-
console.log('geometry', geometry);
152131
if (geometry && typeof geometry === 'object') {
153132
// geometry is a GeoJSON polygon
154133
updateMap(geometry);
155134
} else if (geometry && typeof geometry === 'string') {
156135
// geometry is a bbox string (WSEN, comma-separated)
157136
const bounds = geometry.split(',').map(Number);
158-
console.log('bounds', bounds);
159-
console.log('bboxPolygon', bboxPolygon(bounds));
160137
updateMap(bboxPolygon(bounds).geometry);
161138
}
162139
}
163140
});
164141

165-
// Cleanup
166-
return () => {
167-
document.removeEventListener('keydown', handleKeyDown);
168-
document.removeEventListener('keyup', handleKeyUp);
169-
170-
if (map) {
171-
map.remove();
172-
}
173-
};
142+
return () => map?.remove();
174143
}, [onChange, updateMap]);
175144

176145
// Check if one character input is allowed (for East Asian languages)
@@ -220,15 +189,13 @@ const LocationSelect = props => {
220189
[queryType, isOneCharInputAllowed]
221190
);
222191

223-
// Create debounced version of loadOptions
224192
const debouncedLoadOptions = useCallback(
225193
debounce((inputValue, callback) => {
226194
loadOptions(inputValue).then(callback);
227195
}, 500),
228196
[loadOptions]
229197
);
230198

231-
// Handle selection change
232199
const handleChange = useCallback(
233200
selectedOption => {
234201
if (selectedOption) {
@@ -252,16 +219,41 @@ const LocationSelect = props => {
252219
[updateMap]
253220
);
254221

255-
// Handle query type change
256222
const handleQueryTypeChange = useCallback(selectedOption => {
257223
setQueryType(selectedOption.value);
258224
}, []);
259225

260-
// Handle input change
261226
const handleInputChange = useCallback(newValue => {
262227
setInputValue(newValue);
263228
}, []);
264229

230+
const handleModeChange = useCallback(mode => {
231+
const draw = drawRef.current;
232+
if (draw) {
233+
draw.clear();
234+
draw.setMode(mode);
235+
setActiveMode(mode);
236+
}
237+
}, []);
238+
239+
const handleClear = useCallback(() => {
240+
const draw = drawRef.current;
241+
const map = mapRef.current;
242+
if (draw) {
243+
draw.clear();
244+
draw.setMode('render');
245+
setActiveMode('render');
246+
}
247+
if (map && map.getSource('feature')) {
248+
map.getSource('feature').setData({
249+
type: 'Feature',
250+
geometry: null
251+
});
252+
}
253+
onChange('geometry', null);
254+
onChange('in_bbox', null);
255+
}, [onChange]);
256+
265257
return (
266258
<div>
267259
<div className="grid grid--gut12">
@@ -290,12 +282,59 @@ const LocationSelect = props => {
290282
</div>
291283
</div>
292284
<div className="grid grid--gut12 pt6">
293-
<div className="col col--12 map-select">
285+
<div className="col col--12">
286+
<div className="flex-parent flex-parent--row flex-parent--center-cross mb6">
287+
<div className="flex-child flex-child--no-shrink mr6">
288+
<button
289+
className={`btn btn--s border border--1 border--darken5 border--darken25-on-hover round bg-darken10 bg-darken5-on-hover color-gray transition ${
290+
activeMode === 'rectangle'
291+
? 'bg-darken25 bg-darken25-on-hover'
292+
: ''
293+
}`}
294+
onClick={() => handleModeChange('rectangle')}
295+
>
296+
<svg className="icon h18 w18 inline-block align-middle">
297+
<use xlinkHref="#icon-polygon" />
298+
</svg>
299+
Box
300+
</button>
301+
</div>
302+
<div className="flex-child flex-child--no-shrink mr6">
303+
<button
304+
className={`btn btn--s border border--1 border--darken5 border--darken25-on-hover round bg-darken10 bg-darken5-on-hover color-gray transition ${
305+
activeMode === 'polygon'
306+
? 'bg-darken25 bg-darken25-on-hover'
307+
: ''
308+
}`}
309+
onClick={() => handleModeChange('polygon')}
310+
>
311+
<svg className="icon h18 w18 inline-block align-middle">
312+
<use xlinkHref="#icon-pencil" />
313+
</svg>
314+
Polygon
315+
</button>
316+
</div>
317+
<div className="flex-child flex-child--no-shrink">
318+
<button
319+
className="btn btn--s border border--1 border--darken5 border--darken25-on-hover round bg-darken10 bg-darken5-on-hover color-gray transition"
320+
onClick={handleClear}
321+
title="Clear selection"
322+
>
323+
<svg className="icon h18 w18 inline-block align-middle">
324+
<use xlinkHref="#icon-close" />
325+
</svg>
326+
Clear
327+
</button>
328+
</div>
329+
</div>
294330
<div id="geometry-map" />
295331
</div>
296332
</div>
297333
<p>
298-
Hold <kbd>Shift</kbd> and click to draw a bounding box.
334+
{activeMode == 'rectangle' &&
335+
'Click two corners to draw a bounding box.'}
336+
{activeMode == 'polygon' &&
337+
'Click a series of points to draw a polygon; click back on the first point to finish.'}
299338
</p>
300339
</div>
301340
);

src/views/filters.js

+14-14
Original file line numberDiff line numberDiff line change
@@ -88,20 +88,20 @@ class Filters extends React.PureComponent<void, propsType, stateType> {
8888
}
8989
};
9090
handleChange = (name: string, values?: filterType) => {
91-
let filters = this.state.filters;
92-
// if someone cleared date__gte filter
93-
// we use the convention defined at `noDateGte`
94-
// to signify no default gte.
95-
if (name === 'date__gte' && values == null) {
96-
filters = filters.merge(noDateGte);
97-
} else if (values == null) {
98-
// clear this filter
99-
filters = filters.delete(name);
100-
} else {
101-
filters = filters.set(name, values);
102-
}
103-
return this.setState({
104-
filters
91+
this.setState(state => {
92+
let filters = state.filters;
93+
// if someone cleared date__gte filter
94+
// we use the convention defined at `noDateGte`
95+
// to signify no default gte.
96+
if (name === 'date__gte' && values == null) {
97+
filters = filters.merge(noDateGte);
98+
} else if (values == null) {
99+
// clear this filter
100+
filters = filters.delete(name);
101+
} else {
102+
filters = filters.set(name, values);
103+
}
104+
return { filters };
105105
});
106106
};
107107
handleToggleAll = (name: string, values?: filterType) => {

0 commit comments

Comments
 (0)