// deck.gl // SPDX-License-Identifier: MIT // Copyright (c) vis.gl contributors import {Widget} from '@deck.gl/core'; import type {WidgetPlacement, WidgetProps} from '@deck.gl/core'; import {FlyToInterpolator, LinearInterpolator} from '@deck.gl/core'; import {render} from 'preact'; import {DropdownMenu, type MenuItem} from './lib/components/dropdown-menu'; import {type Geocoder} from './lib/geocode/geocoder'; import {GeocoderHistory} from './lib/geocode/geocoder-history'; import { GoogleGeocoder, MapboxGeocoder, OpenCageGeocoder, CoordinatesGeocoder, CurrentLocationGeocoder } from './lib/geocode/geocoders'; /** @todo - is the the best we can do? */ type ViewState = Record; const CURRENT_LOCATION = 'current'; // Location pin icon (from Google Material Symbols) const LOCATION_ICON = `data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 -960 960 960'%3E%3Cpath d='M480-480q33 0 56.5-23.5T560-560q0-33-23.5-56.5T480-640q-33 0-56.5 23.5T400-560q0 33 23.5 56.5T480-480Zm0 294q122-112 181-203.5T720-552q0-109-69.5-178.5T480-800q-101 0-170.5 69.5T240-552q0 71 59 162.5T480-186Zm0 106Q319-217 239.5-334.5T160-552q0-150 96.5-239T480-880q127 0 223.5 89T800-552q0 100-79.5 217.5T480-80Z'/%3E%3C/svg%3E`; const CURRENT_LOCATION_ITEM: MenuItem = { label: 'Current location', value: CURRENT_LOCATION, icon: LOCATION_ICON }; /** Properties for the GeocoderWidget */ export type GeocoderWidgetProps = WidgetProps & { viewId?: string | null; /** Widget positioning within the view. Default 'top-left'. */ placement?: WidgetPlacement; /** Tooltip message */ label?: string; /** View state reset transition duration in ms. 0 disables the transition */ transitionDuration?: number; /** Geocoding service selector, for declarative usage */ geocoder?: 'google' | 'mapbox' | 'opencage' | 'coordinates' | 'custom'; /** Custom geocoding service (Used when geocoder = 'custom') */ customGeocoder?: Geocoder; /** API key used for geocoding services */ apiKey?: string; /** Whether to use geolocation @note Experimental*/ _geolocation?: boolean; /** * Callback when a location is geocoded and the view will navigate to it. */ onGeocode?: (params: { /** The view being updated */ viewId: string; /** The geocoded coordinates */ coordinates: {longitude: number; latitude: number; zoom?: number}; }) => void; }; /** * A widget that display a text box that lets user type in a location * and a button that moves the view to that location. * @todo For now only supports coordinates, Could be extended with location service integrations. */ export class GeocoderWidget extends Widget { static defaultProps: Required = { ...Widget.defaultProps, id: 'geocoder', viewId: null, placement: 'top-left', label: 'Geocoder', transitionDuration: 200, geocoder: 'coordinates', customGeocoder: CoordinatesGeocoder, apiKey: '', _geolocation: false, onGeocode: () => {} }; className = 'deck-widget-geocoder'; placement: WidgetPlacement = 'top-left'; geocodeHistory = new GeocoderHistory({}); addressText: string = ''; geocoder: Geocoder = CoordinatesGeocoder; isGettingLocation: boolean = false; constructor(props: GeocoderWidgetProps = {}) { super(props); this.setProps(this.props); } setProps(props: Partial): void { this.placement = props.placement ?? this.placement; this.viewId = props.viewId ?? this.viewId; this.geocoder = getGeocoder(this.props); if (this.geocoder.requiresApiKey && !this.props.apiKey) { throw new Error(`API key is required for the ${this.geocoder.name} geocoder`); } super.setProps(props); } onRenderHTML(rootElement: HTMLElement): void { const menuItems: MenuItem[] = this.props._geolocation ? [CURRENT_LOCATION_ITEM, ...this.geocodeHistory.addressHistory] : [...this.geocodeHistory.addressHistory]; render(
this.setInput(e.target?.value || '')} onKeyPress={this.handleKeyPress} /> {this.geocodeHistory.errorText && (
{this.geocodeHistory.errorText}
)}
, rootElement ); } setInput = (text: string) => { this.addressText = text; }; handleKeyPress = e => { if (e.key === 'Enter') { this.handleSubmit(); } }; handleSelect = (value: string) => { if (value === CURRENT_LOCATION) { // Don't put "current" in the text field, just trigger geolocation // eslint-disable-next-line @typescript-eslint/no-floating-promises this.getCurrentLocation(); } else { this.setInput(value); this.handleSubmit(); } }; /** Sync wrapper for async geocode() */ handleSubmit = () => { // eslint-disable-next-line @typescript-eslint/no-floating-promises this.geocode(this.addressText); }; /** Get current location via browser geolocation API */ getCurrentLocation = async () => { this.isGettingLocation = true; if (this.rootElement) { this.updateHTML(); } try { const coordinates = await CurrentLocationGeocoder.geocode(); if (coordinates) { this.flyTo(coordinates); } } catch (error) { this.geocodeHistory.errorText = error instanceof Error ? error.message : 'Location error'; } finally { this.isGettingLocation = false; if (this.rootElement) { this.updateHTML(); } } }; /** Perform geocoding */ geocode: (address: string) => Promise = async address => { const coordinates = await this.geocodeHistory.geocode( this.geocoder, this.addressText, this.props.apiKey ); // Re-render to show updated history or error (guard against torn-down widget) if (this.rootElement) { this.updateHTML(); } if (coordinates) { this.flyTo(coordinates); } }; flyTo(viewState: ViewState) { for (const viewId of this.viewIds) { // Call callback with geocoded coordinates if ('longitude' in viewState && 'latitude' in viewState) { this.props.onGeocode?.({ viewId, coordinates: { longitude: viewState.longitude as number, latitude: viewState.latitude as number, zoom: viewState.zoom as number | undefined } }); } const currentViewState = this.getViewState(viewId); const nextViewState: ViewState = { ...currentViewState, ...viewState }; if (this.props.transitionDuration > 0) { nextViewState.transitionDuration = this.props.transitionDuration; nextViewState.transitionInterpolator = 'latitude' in nextViewState ? new FlyToInterpolator() : new LinearInterpolator(); } this.setViewState(viewId, nextViewState); } } } function getGeocoder(props: {geocoder?: string; customGeocoder?: Geocoder}): Geocoder { switch (props.geocoder) { case 'google': return GoogleGeocoder; case 'mapbox': return MapboxGeocoder; case 'opencage': return OpenCageGeocoder; case 'coordinates': return CoordinatesGeocoder; case 'custom': if (!props.customGeocoder) { throw new Error('Custom geocoder is not defined'); } return props.customGeocoder; default: throw new Error(`Unknown geocoder: ${props.geocoder}`); } }