import type { PointProperties } from '@arcgis/core/geometry/Point'; import type { GraphicProperties } from '@arcgis/core/Graphic'; import type { PopupTemplateProperties } from '@arcgis/core/PopupTemplate'; import type { SymbolProperties } from '@arcgis/core/symbols/Symbol'; import { toQueryString } from '@ugrc/utilities/url'; import { TriangleAlertIcon } from 'lucide-react'; import type { KeyboardEvent } from 'react'; import { useCallback, useState } from 'react'; import type { PressEvent } from 'react-aria-components'; import { Group } from 'react-aria-components/Group'; import { Button, type ButtonProps } from './Button'; import { Spinner } from './Spinner'; import { TextField } from './TextField'; const ADDRESS_TYPE = 'single-address'; type GeocodeComponentType = 'single-address' | 'route-milepost'; type GeocodeProps = { apiKey: string; type?: GeocodeComponentType; address?: { acceptScore: number; suggest: number; locators: 'all' | 'addressPoints' | 'roadCenterlines'; poBox: boolean; scoreDifference: boolean; }; milepost?: { side: 'increasing' | 'decreasing'; fullRoute: boolean; }; wkid?: number; callback?: string; format?: 'geojson' | 'esrijson'; pointSymbol?: SymbolProperties | null | undefined; events?: { success: (result: GraphicProperties) => void; error: (error: object) => void; }; }; type AddressResult = { location: PointProperties; score: number; locator: string; source: string; matchAddress: string; matchRoute: string; inputAddress: string; standardizedAddress: string; addressGrid: string; }; const defaultProps: Omit = { type: ADDRESS_TYPE, address: { acceptScore: 70, suggest: 0, locators: 'all', poBox: false, scoreDifference: false, }, milepost: { side: 'increasing', fullRoute: false, }, wkid: 3857, callback: undefined, format: undefined, pointSymbol: { style: 'diamond', color: [255, 0, 0, 0.5], } as SymbolProperties, events: { success: console.log, error: console.error, }, }; const sanitize = (attributes: Record = {}) => { const customProps = ['beforeClick', 'beforeKeyUp']; return Object.keys(attributes) .filter((key) => customProps.indexOf(key) === -1) .reduce( (result: Record, key: string) => ( (result[key] = attributes[key]), result ), {}, ); }; const Geocode = (props: GeocodeProps) => { const { getFirstFieldProps, getSecondFieldProps, getButtonProps, found, status, } = useGeocoding(props); return (
); }; const useGeocoding = (userProps: GeocodeProps) => { const props = { ...defaultProps, ...userProps, }; const [firstInput, setFirstInput] = useState(''); const [secondInput, setSecondInput] = useState(''); const [firstIsValid, setFirstIsValid] = useState(true); const [secondIsValid, setSecondIsValid] = useState(true); const [status, setStatus] = useState('idle'); const [found, setFound] = useState(undefined); let baseUrl = 'https://api.mapserv.utah.gov/api/v1/geocode'; if (props.type !== ADDRESS_TYPE) { baseUrl += '/milepost'; } type KeyboardEventHandler = (e: KeyboardEvent) => void; const getFirstFieldProps = (inputProps?: { beforeKeyUp: KeyboardEventHandler; }) => ({ label: props.type === ADDRESS_TYPE ? 'Street address' : 'Route', errorMessage: props.type === ADDRESS_TYPE ? 'A street address is required' : 'A highway route number is required', isRequired: true, isInvalid: !firstIsValid, onChange: (data: string) => { setStatus('idle'); setFirstInput(data); }, name: props.type === ADDRESS_TYPE ? 'dartboard_street_input' : 'dartboard_milepost_input', onKeyUp: (e: KeyboardEvent) => { inputProps?.beforeKeyUp(e); handleKeyUp(e); }, autoComplete: 'off', ...sanitize(inputProps), }); const getSecondFieldProps = (inputProps?: { beforeKeyUp: KeyboardEventHandler; }) => ({ label: props.type === ADDRESS_TYPE ? 'City or Zip code' : 'Milepost', errorMessage: props.type === ADDRESS_TYPE ? 'A city or zip code is required' : 'A milepost number is required', isRequired: true, isInvalid: !secondIsValid, onChange: (data: string) => { setStatus('idle'); setSecondInput(data); }, name: props.type === ADDRESS_TYPE ? 'dartboard_zone_input' : 'dartboard_route_input', onKeyUp: (e: KeyboardEvent) => { inputProps?.beforeKeyUp(e); handleKeyUp(e); }, autoComplete: 'off', ...sanitize(inputProps), }); type ClickHandler = (e: PressEvent) => void; const getButtonProps = (buttonProps?: { beforeClick: ClickHandler; }): ButtonProps => ({ onPress: (e: PressEvent) => { buttonProps?.beforeClick(e); find(); }, type: 'button', variant: 'secondary', isDisabled: status === 'pending', ...sanitize(buttonProps), }); const validate = useCallback(() => { const firstValidity = (firstInput?.trim() ?? '').length > 0; const secondValidity = (secondInput?.trim() ?? '').length > 0; setFirstIsValid(firstValidity); setSecondIsValid(secondValidity); // reset not found message setFound(undefined); setStatus('idle'); return firstValidity && secondValidity; }, [firstInput, secondInput]); const get = useCallback< (options: { firstInput: string; secondInput: string }) => Promise >( async (options) => { const url = `${baseUrl}/${options.firstInput}/${options.secondInput}?`; let query = { apiKey: props.apiKey, spatialReference: props.wkid, format: props.format, callback: props.callback, }; if (props.type === ADDRESS_TYPE) { query = { ...props.address, ...query }; } else { query = { ...props.milepost, ...query }; } const querystring = toQueryString(query); return Spinner.minDelay( fetch(url + querystring, { method: 'GET', mode: 'cors', }), 500, ) as Promise; }, [ props.apiKey, props.wkid, props.address, props.milepost, props.type, props.format, props.callback, baseUrl, ], ); const outputTransform = useCallback< (result: AddressResult, point: PointProperties) => GraphicProperties >( (result, point) => { let attributes: GraphicProperties['attributes'] = { address: result.inputAddress, addressSystem: result.addressGrid, locator: result.locator === 'AddressPoints.AddressGrid' ? 'address point' : 'road centerline', score: result.score, matchAddress: result.matchAddress, }; let popupTemplate: PopupTemplateProperties = { title: 'UGRC API geocoding match', content: 'The input address {address} matched against {matchAddress} using {locator} data.

The confidence score is {score}.

This address belongs to the {addressSystem} address system.', overwriteActions: true, }; if (props.type !== ADDRESS_TYPE) { attributes = { matchRoute: result.matchRoute, }; popupTemplate = { title: '{matchRoute}', }; } return { geometry: point, symbol: props.pointSymbol, attributes, popupTemplate, } as GraphicProperties; }, [props.pointSymbol, props.type], ); const extractResponse = useCallback< (response: Response) => Promise >( async (response) => { if (!response.ok) { setFound(false); if (response.status !== 404) { setStatus('error'); } else { setStatus('success'); } return props.events?.error(await response.json()); } let result = await response.json(); if (result.status !== 200) { setFound(false); return props.events?.error(result); } setFound(true); result = result.result; if (props.format?.toLowerCase() === 'geojson') { return result; } const point = { type: 'point', x: result?.location?.x, y: result?.location?.y, spatialReference: { wkid: props.wkid, }, } as PointProperties; if (props.format?.toLowerCase() === 'esrijson') { point.x = result?.geometry?.x; point.y = result?.geometry?.y; } return outputTransform(result, point); }, [outputTransform, props.wkid, props.format, props.events], ); const find = useCallback(async () => { if (!validate()) { return false; } setStatus('pending'); setFound(undefined); let response; try { response = await get({ firstInput, secondInput, }); } catch (err: unknown) { setStatus('error'); setFound(false); return props.events?.error( response?.text() || { message: (err as Error)?.message, status: 400, }, ); } const location = await extractResponse(response); if (location) { setStatus('success'); return props.events?.success(location); } }, [firstInput, secondInput, validate, props.events, get, extractResponse]); const handleKeyUp = useCallback( (event: KeyboardEvent) => { if (event.key !== 'Enter') { return; } find(); }, [find], ); return { // prop getters getFirstFieldProps, getSecondFieldProps, getButtonProps, // actions setFirstIsValid, setSecondIsValid, setFound, // state isSecondInputValid: secondIsValid, isFirstInputValid: firstIsValid, found, firstInput, secondInput, status, }; }; export { Geocode, useGeocoding };