diff --git a/.vscode/settings.json b/.vscode/settings.json index 816e900..822949b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,4 +1,4 @@ { "editor.formatOnSave": true, - "cSpell.words": ["fontawesome", "fortawesome"] + "cSpell.words": ["fontawesome", "fortawesome", "TSQL"] } diff --git a/package-lock.json b/package-lock.json index 0e2565c..f23cd28 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,10 @@ "@testing-library/react": "^13.0.1", "@testing-library/user-event": "^13.5.0", "bootstrap": "^5.1.3", + "downshift": "^6.1.7", + "lodash.debounce": "^4.0.8", + "lodash.escaperegexp": "^4.1.2", + "lodash.uniqwith": "^4.5.0", "react": "^18.0.0", "react-dom": "^18.0.0", "react-scripts": "5.0.1", @@ -5472,6 +5476,11 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" }, + "node_modules/compute-scroll-into-view": { + "version": "1.0.17", + "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-1.0.17.tgz", + "integrity": "sha512-j4dx+Fb0URmzbwwMUrhqWM2BEWHdFGx+qZ9qqASHRPqvTYdqvWnHg0H1hIbcyLnvgnoNAVMlwkepyqM3DaIFUg==" + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -6392,6 +6401,21 @@ "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-5.1.0.tgz", "integrity": "sha512-YXQl1DSa4/PQyRfgrv6aoNjhasp/p4qs9FjJ4q4cQk+8m4r6k4ZSiEyytKG8f8W9gi8WsQtIObNmKd+tMzNTmA==" }, + "node_modules/downshift": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/downshift/-/downshift-6.1.7.tgz", + "integrity": "sha512-cVprZg/9Lvj/uhYRxELzlu1aezRcgPWBjTvspiGTVEU64gF5pRdSRKFVLcxqsZC637cLAGMbL40JavEfWnqgNg==", + "dependencies": { + "@babel/runtime": "^7.14.8", + "compute-scroll-into-view": "^1.0.17", + "prop-types": "^15.7.2", + "react-is": "^17.0.2", + "tslib": "^2.3.0" + }, + "peerDependencies": { + "react": ">=16.12.0" + } + }, "node_modules/duplexer": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", @@ -10985,6 +11009,11 @@ "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", "integrity": "sha1-gteb/zCmfEAF/9XiUVMArZyk168=" }, + "node_modules/lodash.escaperegexp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", + "integrity": "sha1-ZHYsSGGAglGKw99Mz11YhtriA0c=" + }, "node_modules/lodash.isplainobject": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", @@ -11010,6 +11039,11 @@ "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", "integrity": "sha1-0CJTc662Uq3BvILklFM5qEJ1R3M=" }, + "node_modules/lodash.uniqwith": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.uniqwith/-/lodash.uniqwith-4.5.0.tgz", + "integrity": "sha1-egy/ZfQ7WShiWp1NDcVLGMrcfvM=" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -20246,6 +20280,11 @@ } } }, + "compute-scroll-into-view": { + "version": "1.0.17", + "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-1.0.17.tgz", + "integrity": "sha512-j4dx+Fb0URmzbwwMUrhqWM2BEWHdFGx+qZ9qqASHRPqvTYdqvWnHg0H1hIbcyLnvgnoNAVMlwkepyqM3DaIFUg==" + }, "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -20903,6 +20942,18 @@ "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-5.1.0.tgz", "integrity": "sha512-YXQl1DSa4/PQyRfgrv6aoNjhasp/p4qs9FjJ4q4cQk+8m4r6k4ZSiEyytKG8f8W9gi8WsQtIObNmKd+tMzNTmA==" }, + "downshift": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/downshift/-/downshift-6.1.7.tgz", + "integrity": "sha512-cVprZg/9Lvj/uhYRxELzlu1aezRcgPWBjTvspiGTVEU64gF5pRdSRKFVLcxqsZC637cLAGMbL40JavEfWnqgNg==", + "requires": { + "@babel/runtime": "^7.14.8", + "compute-scroll-into-view": "^1.0.17", + "prop-types": "^15.7.2", + "react-is": "^17.0.2", + "tslib": "^2.3.0" + } + }, "duplexer": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", @@ -24202,6 +24253,11 @@ "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", "integrity": "sha1-gteb/zCmfEAF/9XiUVMArZyk168=" }, + "lodash.escaperegexp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", + "integrity": "sha1-ZHYsSGGAglGKw99Mz11YhtriA0c=" + }, "lodash.isplainobject": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", @@ -24227,6 +24283,11 @@ "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", "integrity": "sha1-0CJTc662Uq3BvILklFM5qEJ1R3M=" }, + "lodash.uniqwith": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.uniqwith/-/lodash.uniqwith-4.5.0.tgz", + "integrity": "sha1-egy/ZfQ7WShiWp1NDcVLGMrcfvM=" + }, "loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", diff --git a/package.json b/package.json index a7907ff..2779f55 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,10 @@ "@testing-library/react": "^13.0.1", "@testing-library/user-event": "^13.5.0", "bootstrap": "^5.1.3", + "downshift": "^6.1.7", + "lodash.debounce": "^4.0.8", + "lodash.escaperegexp": "^4.1.2", + "lodash.uniqwith": "^4.5.0", "react": "^18.0.0", "react-dom": "^18.0.0", "react-scripts": "5.0.1", diff --git a/src/App.js b/src/App.js index 26e93e7..7057fd8 100644 --- a/src/App.js +++ b/src/App.js @@ -1,3 +1,4 @@ +import * as watchUtils from '@arcgis/core/core/watchUtils'; import Map from '@arcgis/core/Map'; import MapView from '@arcgis/core/views/MapView'; import Home from '@arcgis/core/widgets/Home'; @@ -5,10 +6,12 @@ import React from 'react'; import 'typeface-montserrat'; import './App.scss'; import Filter from './components/Filter'; +import { MapServiceProvider, Sherlock } from './components/Sherlock'; import config from './services/config'; function App() { const [mapView, setMapView] = React.useState(null); + const [zoomToGraphic, setZoomToGraphic] = React.useState(null); React.useEffect(() => { const map = new Map({ @@ -20,6 +23,71 @@ function App() { setMapView(view); }, []); + const [displayedZoomGraphic, setDisplayedZoomGraphic] = React.useState(null); + const zoomTo = React.useCallback( + async (zoomObj) => { + if (!Array.isArray(zoomObj.target)) { + zoomObj.target = [zoomObj.target]; + } + + if (!zoomObj.zoom) { + if (zoomObj.target.every((graphic) => graphic.geometry.type === 'point')) { + zoomObj = { + target: zoomObj.target, + zoom: 10, + }; + } else { + zoomObj = { + target: zoomObj.target, + }; + } + } + + await mapView.goTo(zoomObj); + + if (displayedZoomGraphic) { + mapView.graphics.removeMany(displayedZoomGraphic); + } + + setDisplayedZoomGraphic(zoomObj.target); + + mapView.graphics.addMany(zoomObj.target); + + if (!zoomObj.preserve) { + watchUtils.once(mapView, 'extent', () => { + mapView.graphics.removeAll(); + }); + } + }, + [displayedZoomGraphic, mapView] + ); + + React.useEffect(() => { + if (zoomToGraphic) { + const { graphic, level, preserve } = zoomToGraphic; + + graphic && + zoomTo({ + target: graphic, + zoom: level, + preserve: preserve, + }); + } + }, [zoomToGraphic, mapView, zoomTo]); + + const onSherlockMatch = (graphics) => { + setZoomToGraphic({ + graphic: graphics, + preserve: false, + }); + }; + + const sherlockConfig = { + provider: new MapServiceProvider(config.SHERLOCK.serviceUrl, config.SHERLOCK.searchField), + placeHolder: 'Search...', + onSherlockMatch, + }; + return (
@@ -27,6 +95,7 @@ function App() {
+
); diff --git a/src/components/Sherlock.js b/src/components/Sherlock.js new file mode 100644 index 0000000..b68c2e1 --- /dev/null +++ b/src/components/Sherlock.js @@ -0,0 +1,494 @@ +import Graphic from '@arcgis/core/Graphic'; +import * as query from '@arcgis/core/rest/query'; +import Query from '@arcgis/core/rest/support/Query'; +import { faSearch } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import Downshift from 'downshift'; +import escapeRegExp from 'lodash.escaperegexp'; +import sortBy from 'lodash.sortby'; +import uniqWith from 'lodash.uniqwith'; +import React from 'react'; +import { Button, Input, InputGroup } from 'reactstrap'; +import './Sherlock.scss'; + +const defaultSymbols = { + polygon: { + type: 'simple-fill', + color: [240, 240, 240, 0.5], + outline: { + style: 'solid', + color: [255, 255, 0, 0.5], + width: 2.5, + }, + }, + line: { + type: 'simple-line', + style: 'solid', + color: [255, 255, 0], + width: 5, + }, + point: { + type: 'simple-marker', + style: 'circle', + color: [255, 255, 0, 0.5], + size: 10, + }, +}; + +export function Sherlock({ + symbols = defaultSymbols, + provider, + onSherlockMatch, + label, + placeHolder, + maxResultsToDisplay, +}) { + const handleStateChange = async (feature) => { + const searchValue = feature.attributes[provider.searchField]; + + let contextValue; + if (provider.contextField) { + contextValue = feature.attributes[provider.contextField]; + } + + const response = await provider.getFeature(searchValue, contextValue); + + const results = response.data; + + const graphics = results.map( + (feature) => + new Graphic({ + geometry: feature.geometry, + attributes: feature.attributes, + symbol: symbols[feature.geometry.type], + }) + ); + + onSherlockMatch(graphics); + }; + + const itemToString = (item) => { + console.log('Clue:itemToString', arguments); + + return item ? item.attributes[provider.searchField] : ''; + }; + + return ( + + {({ getInputProps, getItemProps, highlightedIndex, isOpen, inputValue, getMenuProps }) => ( +
+

{label}

+
+ + + + +
+
    + {!isOpen ? null : ( + + {({ short, hasMore, error, data = [] }) => { + if (short) { + return ( +
  • + Type more than 2 letters. +
  • + ); + } + + if (error) { + return ( +
  • + Error! ${error} +
  • + ); + } + + if (!data.length) { + return ( +
  • + No items found. +
  • + ); + } + + let items = data.map((item, index) => ( +
  • + +
    {item.attributes[provider.contextField] || ''}
    +
  • + )); + + if (hasMore) { + items.push( +
  • + More than {maxResultsToDisplay} items found. +
  • + ); + } + + return items; + }} +
    + )} +
+
+
+
+ )} +
+ ); +} + +function Clue({ clue, provider, maxResults, children }) { + const [state, setState] = React.useState({ + data: undefined, + loading: false, + error: false, + short: true, + hasMore: false, + }); + + const updateState = (newProps) => { + setState((oldState) => { + return { + ...oldState, + ...newProps, + }; + }); + }; + + const makeNetworkRequest = React.useCallback(async () => { + console.log('makeNetworkRequest'); + const { searchField, contextField } = provider; + + const response = await provider.search(clue).catch((e) => { + updateState({ + data: undefined, + error: e.message, + loading: false, + short: clue.length <= 2, + hasMore: false, + }); + + console.error(e); + }); + + const iteratee = [`attributes.${searchField}`]; + let hasContext = false; + if (contextField) { + iteratee.push(`attributes.${contextField}`); + hasContext = true; + } + + let features = uniqWith(response.data, (a, b) => { + if (hasContext) { + return ( + a.attributes[searchField] === b.attributes[searchField] && + a.attributes[contextField] === b.attributes[contextField] + ); + } else { + return a.attributes[searchField] === b.attributes[searchField]; + } + }); + + features = sortBy(features, iteratee); + let hasMore = false; + if (features.length > maxResults) { + features = features.slice(0, maxResults); + hasMore = true; + } + + updateState({ + data: features, + loading: false, + error: false, + short: clue.length <= 2, + hasMore: hasMore, + }); + }, [clue, maxResults, provider]); + + React.useEffect(() => { + console.log('clue or makeNetworkRequest changed'); + updateState({ + error: false, + loading: true, + short: clue.length <= 2, + hasMore: false, + }); + + if (clue.length > 2) { + makeNetworkRequest(); + } + }, [clue, makeNetworkRequest]); + + const { short, data, loading, error, hasMore } = state; + + return children({ + short, + data, + loading, + error, + hasMore, + // refetch: fetchData, + }); +} + +class ProviderBase { + controller = new AbortController(); + signal = this.controller.signal; + + getOutFields(outFields, searchField, contextField) { + outFields = outFields || []; + + // don't mess with '*' + if (outFields[0] === '*') { + return outFields; + } + + const addField = (fld) => { + if (fld && outFields.indexOf(fld) === -1) { + outFields.push(fld); + } + }; + + addField(searchField); + addField(contextField); + + return outFields; + } + + getSearchClause(text) { + return `UPPER(${this.searchField}) LIKE UPPER('%${text}%')`; + } + + getFeatureClause(searchValue, contextValue) { + let statement = `${this.searchField}='${searchValue}'`; + + if (this.contextField) { + if (contextValue && contextValue.length > 0) { + statement += ` AND ${this.contextField}='${contextValue}'`; + } else { + statement += ` AND ${this.contextField} IS NULL`; + } + } + + return statement; + } + + cancelPendingRequests() { + this.controller.abort(); + } +} + +export class MapServiceProvider extends ProviderBase { + constructor(serviceUrl, searchField, options = {}) { + console.log('sherlock.MapServiceProvider:constructor', arguments); + super(); + + this.searchField = searchField; + this.contextField = options.contextField; + this.serviceUrl = serviceUrl; + + this.setUpQueryTask(options); + } + + async setUpQueryTask(options) { + const defaultWkid = 3857; + this.query = new Query(); + this.query.outFields = this.getOutFields(options.outFields, this.searchField, options.contextField); + this.query.returnGeometry = false; + this.query.outSpatialReference = { wkid: options.wkid || defaultWkid }; + } + + async search(searchString) { + console.log('sherlock.MapServiceProvider:search', arguments); + + this.query.where = this.getSearchClause(searchString); + const featureSet = await query.executeQueryJSON(this.serviceUrl, this.query); + + return { data: featureSet.features }; + } + + async getFeature(searchValue, contextValue) { + console.log('sherlock.MapServiceProvider', arguments); + + this.query.where = this.getFeatureClause(searchValue, contextValue); + this.query.returnGeometry = true; + const featureSet = await query.executeQueryJSON(this.serviceUrl, this.query); + + return { data: featureSet.features }; + } +} + +export class WebApiProvider extends ProviderBase { + constructor(apiKey, searchLayer, searchField, options) { + super(); + console.log('sherlock.providers.WebAPI:constructor', arguments); + + const defaultWkid = 3857; + this.geometryClasses = { + point: console.log, + polygon: console.log, + polyline: console.log, + }; + + this.searchLayer = searchLayer; + this.searchField = searchField; + + if (options) { + this.wkid = options.wkid || defaultWkid; + this.contextField = options.contextField; + this.outFields = this.getOutFields(options.outFields, this.searchField, this.contextField); + } else { + this.wkid = defaultWkid; + } + + this.outFields = this.getOutFields(null, this.searchField, this.contextField); + this.webApi = new WebApi(apiKey, this.signal); + } + + async search(searchString) { + console.log('sherlock.providers.WebAPI:search', arguments); + + return await this.webApi.search(this.searchLayer, this.outFields, { + predicate: this.getSearchClause(searchString), + spatialReference: this.wkid, + }); + } + + async getFeature(searchValue, contextValue) { + console.log('sherlock.providers.WebAPI:getFeature', arguments); + + return await this.webApi.search(this.searchLayer, this.outFields.concat('shape@'), { + predicate: this.getFeatureClause(searchValue, contextValue), + spatialReference: this.wkid, + }); + } +} + +const Highlighted = ({ text = '', highlight = '' }) => { + if (!highlight.trim()) { + return
{text}
; + } + + const regex = new RegExp(`(${escapeRegExp(highlight)})`, 'gi'); + const parts = text.split(regex); + + return ( +
+ {parts + .filter((part) => part) + .map((part, i) => (regex.test(part) ? {part} : {part}))} +
+ ); +}; + +class WebApi { + constructor(apiKey, signal) { + this.baseUrl = 'https://api.mapserv.utah.gov/api/v1/'; + + // defaultAttributeStyle: String + this.defaultAttributeStyle = 'identical'; + + // xhrProvider: dojo/request/* provider + // The current provider as determined by the search function + this.xhrProvider = null; + + // Properties to be sent into constructor + + // apiKey: String + // web api key (http://developer.mapserv.utah.gov/AccountAccess) + this.apiKey = apiKey; + + this.signal = signal; + } + + async search(featureClass, returnValues, options) { + // summary: + // search service wrapper (http://api.mapserv.utah.gov/#search) + // featureClass: String + // Fully qualified feature class name eg: SGID10.Boundaries.Counties + // returnValues: String[] + // A list of attributes to return eg: ['NAME', 'FIPS']. + // To include the geometry use the shape@ token or if you want the + // envelope use the shape@envelope token. + // options.predicate: String + // Search criteria for finding specific features in featureClass. + // Any valid ArcObjects where clause will work. If omitted, a TSQL * + // will be used instead. eg: NAME LIKE 'K%' + // options.geometry: String (not fully implemented) + // The point geometry used for spatial queries. Points are denoted as + // 'point:[x,y]'. + // options.spatialReference: Number + // The spatial reference of the input geographic coordinate pair. + // Choose any of the wkid's from the Geographic Coordinate System wkid reference + // or Projected Coordinate System wkid reference. 26912 is the default. + // options.tolerance: Number (not implemented) + // options.spatialRelation: String (default: 'intersect') + // options.buffer: Number + // A distance in meters to buffer the input geometry. + // 2000 meters is the maximum buffer. + // options.pageSize: Number (not implemented) + // options.skip: Number (not implemented) + // options.attributeStyle: String (defaults to 'identical') + // Controls the casing of the attributes that are returned. + // Options: + // + // 'identical': as is in data. + // 'upper': upper cases all attribute names. + // 'lower': lower cases all attribute names. + // 'camel': camel cases all attribute names + // + // returns: Promise + console.log('WebApi:search', arguments); + + var url = `${this.baseUrl}search/${featureClass}/${encodeURIComponent(returnValues.join(','))}?`; + + if (!options) { + options = {}; + } + + options.apiKey = this.apiKey; + if (!options.attributeStyle) { + options.attributeStyle = this.defaultAttributeStyle; + } + + const response = await fetch(url + URLSearchParams(options), { signal: this.signal }); + + if (!response.ok) { + return { + ok: false, + message: response.message || response.statusText, + }; + } + + const result = await response.json(); + + if (result.status !== 200) { + return { + ok: false, + message: result.message, + }; + } + + return { + ok: true, + data: result.result, + }; + } +} diff --git a/src/components/Sherlock.scss b/src/components/Sherlock.scss new file mode 100644 index 0000000..76035d6 --- /dev/null +++ b/src/components/Sherlock.scss @@ -0,0 +1,57 @@ +@import '../variables.scss'; + +.sherlock { + position: absolute; + z-index: 3; + width: 260px; + left: 62px; + top: 7px; + input[type='text'], + button.disabled { + border-radius: 0; + } + button.disabled { + opacity: 1; + background-color: $highlightColor; + } +} +.sherlock__match-dropdown { + width: 280px; + background-color: #fff; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); + font-size: 12px; + position: absolute; + z-index: 9999; +} +.sherlock__matches { + display: block; + width: 100%; + padding-inline-start: 0; + margin-bottom: 0; +} +.sherlock__match-item { + padding: 2px 12px; + display: flex; + justify-content: space-between; + mark { + padding: 0; + } +} +.sherlock__match-item:hover, +.sherlock__match-item--selected { + background-color: #428bca; + color: #fff; + cursor: pointer; +} +.sherlock__match-item[disabled], +.sherlock__match-item[disabled]:hover { + pointer-events: none; + justify-content: center; +} +.sherlock__message { + display: block; + padding: 0.5rem 1rem; +} +.dropdown-menu form .sherlock { + position: relative; +} diff --git a/src/services/config.js b/src/services/config.js index ee2ea84..e2d8935 100644 --- a/src/services/config.js +++ b/src/services/config.js @@ -3,6 +3,10 @@ const config = { center: [-111.9, 40.75], zoom: 11, }, + SHERLOCK: { + serviceUrl: 'https://gis.wfrc.org/arcgis/rest/services/General/ZoomToPlaceNames/FeatureServer/1', + searchField: 'NAME', + }, }; export default config;