Skip to content

Commit 7d65052

Browse files
feat(widget): Add geocoder support to GeolocateWidget (#9608)
1 parent bd8370b commit 7d65052

File tree

2 files changed

+132
-18
lines changed

2 files changed

+132
-18
lines changed

modules/widgets/src/geolocate-widget.tsx

Lines changed: 131 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ import {render} from 'preact';
1010
/** @todo - is the the best we can do? */
1111
type ViewState = Record<string, unknown>;
1212

13+
const GOOGLE_URL = 'https://maps.googleapis.com/maps/api/geocode/json';
14+
const MAPBOX_URL = 'https://api.mapbox.com/geocoding/v5/mapbox.places';
15+
const OPENCAGE_API_URL = 'https://api.opencagedata.com/geocode/v1/json';
16+
1317
/** Properties for the GeolocateWidget */
1418
export type GeolocateWidgetProps = WidgetProps & {
1519
viewId?: string;
@@ -18,6 +22,15 @@ export type GeolocateWidgetProps = WidgetProps & {
1822
/** Tooltip message */
1923
label?: string;
2024
transitionDuration?: number;
25+
/** Geocoding service */
26+
geocoder?: 'google' | 'mapbox' | 'opencage' | 'custom' | 'coordinates';
27+
/** API key used for geocoding services */
28+
apiKey?: string;
29+
/** Callback when using a custom geocoder */
30+
onGeocode?: (
31+
address: string,
32+
apiKey: string
33+
) => Promise<{longitude: number; latitude: number} | null>;
2134
};
2235

2336
/**
@@ -32,7 +45,10 @@ export class GeolocateWidget extends Widget<GeolocateWidgetProps> {
3245
viewId: undefined!,
3346
placement: 'top-left',
3447
label: 'Geolocate',
35-
transitionDuration: 200
48+
transitionDuration: 200,
49+
geocoder: 'coordinates',
50+
apiKey: '',
51+
onGeocode: undefined!
3652
};
3753

3854
className = 'deck-widget-geolocate';
@@ -50,6 +66,9 @@ export class GeolocateWidget extends Widget<GeolocateWidgetProps> {
5066
setProps(props: Partial<GeolocateWidgetProps>): void {
5167
this.placement = props.placement ?? this.placement;
5268
super.setProps(props);
69+
if (!this.props.apiKey && ['google', 'mapbox', 'opencage'].includes(this.props.geocoder)) {
70+
throw new Error('API key is required');
71+
}
5372
}
5473

5574
onRenderHTML(rootElement: HTMLElement): void {
@@ -59,10 +78,8 @@ export class GeolocateWidget extends Widget<GeolocateWidgetProps> {
5978
type="text"
6079
placeholder="-122.45, 37.8 or 37°48'N, 122°27'W"
6180
value={this.geolocateText}
62-
onInput={
63-
// @ts-expect-error event type
64-
e => this.setInput(e.target?.value || '')
65-
}
81+
// @ts-expect-error event type
82+
onInput={e => this.setInput(e.target?.value || '')}
6683
onKeyPress={this.handleKeyPress}
6784
/>
6885
<button onClick={this.handleSubmit}>Go</button>
@@ -76,26 +93,123 @@ export class GeolocateWidget extends Widget<GeolocateWidgetProps> {
7693
this.geolocateText = text;
7794
};
7895

79-
handleSubmit = () => {
80-
const coords = parseCoordinates(this.geolocateText);
81-
if (coords) {
82-
this.errorText = '';
83-
this.handleCoordinates(coords);
84-
} else {
85-
this.errorText = 'Invalid coordinate format.';
86-
}
87-
};
88-
8996
handleKeyPress = e => {
9097
if (e.key === 'Enter') {
9198
this.handleSubmit();
9299
}
93100
};
94101

95-
handleCoordinates = coordinates => {
96-
this.setViewState(coordinates);
102+
/** Sync wrapper for async geocode() */
103+
handleSubmit = () => {
104+
// eslint-disable-next-line @typescript-eslint/no-floating-promises
105+
this.geocode();
97106
};
98107

108+
geocode: () => Promise<void> = async () => {
109+
this.errorText = '';
110+
try {
111+
const coordinates = await this.callGeocoderService(this.geolocateText);
112+
if (coordinates) {
113+
this.setViewState(coordinates);
114+
} else {
115+
this.errorText = 'Invalid address';
116+
}
117+
} catch (error) {
118+
this.errorText = `Error: ${(error as Error).message}`;
119+
}
120+
};
121+
122+
callGeocoderService = async (
123+
address: string
124+
): Promise<{longitude: number; latitude: number} | null> => {
125+
const {geocoder, apiKey, onGeocode} = this.props;
126+
127+
switch (geocoder) {
128+
case 'google':
129+
return this.geocodeGoogle(address, apiKey);
130+
case 'mapbox':
131+
return this.geocodeMapbox(address, apiKey);
132+
case 'opencage':
133+
return this.geocodeOpenCage(address, apiKey);
134+
case 'custom':
135+
return await onGeocode(address, apiKey);
136+
case 'coordinates':
137+
return await this.geocodeCoordinates(address);
138+
default:
139+
throw new Error(`Unsupported geocoder: ${geocoder}`);
140+
}
141+
};
142+
143+
async geocodeGoogle(
144+
address: string,
145+
apiKey: string
146+
): Promise<{longitude: number; latitude: number} | null> {
147+
const encodedAddress = encodeURIComponent(address);
148+
const json = await this._fetchJson(`${GOOGLE_URL}?address=${encodedAddress}&key=${apiKey}`);
149+
150+
switch (json.status) {
151+
case 'OK':
152+
const loc = json.results.length > 0 && json.results[0].geometry.location;
153+
return loc ? {longitude: loc.lng, latitude: loc.lat} : null;
154+
default:
155+
throw new Error(`Google Geocoder failed: ${json.status}`);
156+
}
157+
}
158+
159+
async geocodeMapbox(
160+
address: string,
161+
apiKey: string
162+
): Promise<{longitude: number; latitude: number} | null> {
163+
const encodedAddress = encodeURIComponent(address);
164+
const json = await this._fetchJson(
165+
`${MAPBOX_URL}/${encodedAddress}.json?access_token=${apiKey}`
166+
);
167+
168+
if (Array.isArray(json.features) && json.features.length > 0) {
169+
const center = json.features[0].center;
170+
if (Array.isArray(center) && center.length >= 2) {
171+
return {longitude: center[0], latitude: center[1]};
172+
}
173+
}
174+
return null;
175+
}
176+
177+
async geocodeOpenCage(
178+
address: string,
179+
key: string
180+
): Promise<{longitude: number; latitude: number} | null> {
181+
const encodedAddress = encodeURIComponent(address);
182+
const data = await this._fetchJson(`${OPENCAGE_API_URL}?q=${encodedAddress}&key=${key}`);
183+
if (Array.isArray(data.results) && data.results.length > 0) {
184+
const geometry = data.results[0].geometry;
185+
return {longitude: geometry.lng, latitude: geometry.lat};
186+
}
187+
return null;
188+
}
189+
190+
async geocodeCoordinates(address: string): Promise<{longitude: number; latitude: number} | null> {
191+
return parseCoordinates(address) || null;
192+
}
193+
194+
/** Fetch JSON, catching HTTP errors */
195+
async _fetchJson(url: string): Promise<any> {
196+
let response: Response;
197+
try {
198+
response = await fetch(url);
199+
} catch (error) {
200+
// Annoyingly, fetch reports some errors (e.g. CORS) using excpetions, not response.ok
201+
throw new Error(`Failed to fetch ${url}: ${error}`);
202+
}
203+
if (!response.ok) {
204+
throw new Error(`Failed to fetch ${url}: ${response.statusText}`);
205+
}
206+
const data = await response.json();
207+
if (!data) {
208+
throw new Error(`No data returned from ${url}`);
209+
}
210+
return data;
211+
}
212+
99213
// TODO - MOVE TO WIDGETIMPL?
100214

101215
setViewState(viewState: ViewState) {

test/apps/widgets-example-9.2/app.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ const deck = new Deck({
100100
new ResetViewWidget(),
101101
new _LoadingWidget(),
102102
new _ScaleWidget({placement: 'bottom-right'}),
103-
new _GeolocateWidget(),
103+
new _GeolocateWidget({viewId: 'left-map'}),
104104
new _ThemeWidget(),
105105
new _InfoWidget({mode: 'hover', getTooltip}),
106106
new _InfoWidget({mode: 'click', getTooltip}),

0 commit comments

Comments
 (0)