diff --git a/.vscode/settings.json b/.vscode/settings.json index f1e6d84..56be041 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -26,6 +26,7 @@ "noopener", "noreferrer", "oidc", + "Oids", "overscan", "packagejson", "sgid", diff --git a/src/App.tsx b/src/App.tsx index 06edc38..81df112 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,6 +18,7 @@ import { ErrorBoundary } from 'react-error-boundary'; import { useOverlayTriggerState } from 'react-stately'; import { MapContainer } from './components'; import { useFilter } from './components/contexts/FilterProvider'; +import { SelectionProvider } from './components/contexts/SelectionProvider'; import Filter from './components/Filter'; import { useMap } from './components/hooks'; import { DnrLogo } from './components/Logo'; @@ -139,18 +140,6 @@ export default function App() { onSherlockMatch, }; - // const onClick = useCallback( - // (event: __esri.ViewImmediateClickEvent) => { - // mapView!.hitTest(event).then(({ results }) => { - // if (!results.length) { - // trayState.open(); - - // return setInitialIdentifyLocation(event.mapPoint); - // } - // }); - // }, - // [mapView, trayState], - // ); useEffect(() => { if (currentUser) { // this should take care of all requests made through the esri js api @@ -188,38 +177,40 @@ export default function App() { {currentUser ? (
- -
- -

Map controls

-
- - - + + +
+ +

Map controls

+
+ + + +
+
+ + + +
-
+ +
+
- + + + +
- -
-
- - - - - - -
-
+
) : (
diff --git a/src/components/Filter.tsx b/src/components/Filter.tsx index 65495da..82d3bf7 100644 --- a/src/components/Filter.tsx +++ b/src/components/Filter.tsx @@ -1,7 +1,11 @@ +import Extent from '@arcgis/core/geometry/Extent'; import FeatureLayer from '@arcgis/core/layers/FeatureLayer'; +import FeatureEffect from '@arcgis/core/layers/support/FeatureEffect'; +import FeatureFilter from '@arcgis/core/layers/support/FeatureFilter'; import { useEffect, useRef } from 'react'; import config from '../config'; import { useFilter } from './contexts/FilterProvider'; +import { useSelection } from './contexts/SelectionProvider'; import DateRange from './filters/DateRange'; import Location from './filters/Location'; import Purpose from './filters/Purpose'; @@ -15,18 +19,62 @@ export default function Filter(): JSX.Element { const { addLayers, mapView } = useMap(); const stationsLayer = useRef(); const { filter } = useFilter(); + const initialized = useRef(false); + const { selectedStationIds, setSelectedStationIds } = useSelection(); useEffect(() => { - if (!mapView || !addLayers) { + if (!mapView || !addLayers || initialized.current) { return; } stationsLayer.current = new FeatureLayer({ url: config.urls.stations, definitionExpression: emptyDefinition, + outFields: [config.fieldNames.STATION_ID], }); addLayers([stationsLayer.current]); - }, [addLayers, mapView]); + + mapView.on('pointer-move', (event) => { + mapView.hitTest(event, { include: stationsLayer.current as FeatureLayer }).then((response) => { + if (response.results.length > 0) { + mapView.container.style.cursor = 'pointer'; + } else { + mapView.container.style.cursor = 'default'; + } + }); + }); + mapView.on('click', (event) => { + mapView + .hitTest(event, { + include: stationsLayer.current as FeatureLayer, + }) + .then((response) => { + if (response.results.length > 0) { + const graphics = (response.results as __esri.GraphicHit[]).map((result) => result.graphic); + const stationIds = graphics.map((graphic) => graphic.attributes[config.fieldNames.STATION_ID]); + if (event.native.ctrlKey || event.native.metaKey || event.native.shiftKey) { + setSelectedStationIds((previousSelectedIds) => { + const newSelectedKeys = new Set(previousSelectedIds); + stationIds.forEach((stationId) => { + if (newSelectedKeys.has(stationId)) { + newSelectedKeys.delete(stationId); + } else { + newSelectedKeys.add(stationId); + } + }); + return newSelectedKeys; + }); + } else { + setSelectedStationIds(new Set(stationIds)); + } + } else { + setSelectedStationIds(new Set()); + } + }); + }); + + initialized.current = true; + }, [addLayers, mapView, setSelectedStationIds]); useEffect(() => { if (!stationsLayer.current) { @@ -41,16 +89,51 @@ export default function Filter(): JSX.Element { stationsLayer.current.definitionExpression = emptyDefinition; } + setSelectedStationIds(new Set()); + stationsLayer.current .queryExtent({ where: stationsLayer.current.definitionExpression, }) .then((result) => { if (mapView) { - mapView.goTo(result.extent); + if (result.extent && result.count > 0) { + mapView.goTo(result.extent); + } else { + mapView.goTo( + new Extent({ + xmax: -12612006, + xmin: -12246370, + ymax: 5125456, + ymin: 4473357, + spatialReference: { + wkid: 102100, + }, + }), + ); + } } }); - }, [filter, mapView]); + }, [filter, mapView, setSelectedStationIds]); + + useEffect(() => { + if (!stationsLayer.current) { + return; + } + + if (selectedStationIds.size === 0) { + // @ts-expect-error null is a valid value + stationsLayer.current.featureEffect = null; + } else { + const where = `${config.fieldNames.STATION_ID} IN ('${Array.from(selectedStationIds).join("','")}')`; + stationsLayer.current.featureEffect = new FeatureEffect({ + filter: new FeatureFilter({ + where, + }), + excludedEffect: 'opacity(50%)', + }); + } + }, [selectedStationIds]); return ( <> diff --git a/src/components/MapContainer.tsx b/src/components/MapContainer.tsx index a798930..e0be23d 100644 --- a/src/components/MapContainer.tsx +++ b/src/components/MapContainer.tsx @@ -50,7 +50,7 @@ export const MapContainer = ({ const { setMapView } = useMap(); const isDrawing = useViewLoading(mapView.current); - if (mapView.current && bottomPadding) { + if (mapView.current) { mapView.current.padding.bottom = bottomPadding; } diff --git a/src/components/ResultsGrid.tsx b/src/components/ResultsGrid.tsx index 311f7a7..b730be8 100644 --- a/src/components/ResultsGrid.tsx +++ b/src/components/ResultsGrid.tsx @@ -2,15 +2,18 @@ import { useQuery } from '@tanstack/react-query'; import { Spinner, useFirebaseAuth } from '@ugrc/utah-design-system'; import { User } from 'firebase/auth'; import ky from 'ky'; -import { Tab, TableBody, TabList, TabPanel, Tabs } from 'react-aria-components'; +import { useEffect, useState } from 'react'; +import { Selection, Tab, TableBody, TabList, TabPanel, Tabs } from 'react-aria-components'; import config from '../config'; import { useFilter } from './contexts/FilterProvider'; +import { useSelection } from './contexts/SelectionProvider'; import Download from './Download'; import { getGridQuery, removeIrrelevantWhiteSpace } from './queryHelpers'; import { Cell, Column, Row, Table, TableHeader } from './Table'; +import { getResultOidsFromStationIds, getStationIdsFromResultRows } from './utils'; const STATION_NAME = 'STATION_NAME'; -type Result = Record; +export type Result = Record; async function getData(where: string, currentUser: User): Promise { if (where === '') { return []; @@ -28,6 +31,7 @@ async function getData(where: string, currentUser: User): Promise { st.${config.fieldNames.DWR_WaterID} as ${config.fieldNames.DWR_WaterID}_Stream, st.${config.fieldNames.ReachCode} as ${config.fieldNames.ReachCode}_Stream, s.${config.fieldNames.NAME} as ${STATION_NAME}, + s.${config.fieldNames.STATION_ID}, SPECIES = STUFF((SELECT DISTINCT ', ' + f.${config.fieldNames.SPECIES_CODE} FROM ${config.databaseSecrets.databaseName}.${config.databaseSecrets.user}.Fish_evw as f WHERE se.${config.fieldNames.EVENT_ID} = f.${config.fieldNames.EVENT_ID} @@ -97,7 +101,7 @@ async function getData(where: string, currentUser: User): Promise { return responseJson.features.map((feature) => ({ ...feature.attributes, - id: feature.attributes.ESRI_OID, + id: feature.attributes[config.fieldNames.ESRI_OID], })); } @@ -115,6 +119,13 @@ export default function ResultsGrid() { }, }); + const { selectedStationIds, setSelectedStationIds } = useSelection(); + const [selectedKeys, setSelectedKeys] = useState(new Set()); + + useEffect(() => { + setSelectedKeys(getResultOidsFromStationIds(data, selectedStationIds)); + }, [data, selectedStationIds]); + if (isPending) { return (
@@ -126,12 +137,26 @@ export default function ResultsGrid() { return {error.message}; } - const eventIds = data?.length ? (data.map((row) => row[config.fieldNames.EVENT_ID]) as string[]) : ([] as string[]); + const eventIds = data?.length ? (data.map((row) => row[config.fieldNames.ESRI_OID]) as string[]) : ([] as string[]); + + const onSelectionChange = (selectedOids: Selection) => { + if (selectedOids === 'all') { + setSelectedStationIds(new Set(data?.map((row) => row[config.fieldNames.STATION_ID] as string))); + } else { + setSelectedStationIds(getStationIdsFromResultRows(data, selectedOids as Set)); + } + }; return ( <> Records: {data?.length} + {selectedStationIds.size > 0 && ( + + {' '} + | Selected: {selectedKeys === 'all' ? data?.length : selectedKeys.size} + + )} @@ -139,7 +164,13 @@ export default function ResultsGrid() { Download - +
Event Date diff --git a/src/components/contexts/SelectionProvider.tsx b/src/components/contexts/SelectionProvider.tsx new file mode 100644 index 0000000..7b1d50e --- /dev/null +++ b/src/components/contexts/SelectionProvider.tsx @@ -0,0 +1,32 @@ +import { createContext, useContext, useState } from 'react'; + +type SelectionContextType = { + selectedStationIds: Set; + setSelectedStationIds: React.Dispatch>>; +}; + +const SelectionContext = createContext({ + selectedStationIds: new Set(), + setSelectedStationIds: () => {}, +}); + +export function SelectionProvider({ children }: { children: React.ReactNode }) { + const [selectedStationIds, setSelectedStationIds] = useState>(new Set()); + + return ( + + {children} + + ); +} + +export function useSelection() { + console.log('useSelection'); + const context = useContext(SelectionContext); + + if (!context) { + throw new Error('useSelection must be used within a SelectionProvider'); + } + + return context; +} diff --git a/src/components/utils.test.ts b/src/components/utils.test.ts new file mode 100644 index 0000000..6fd3552 --- /dev/null +++ b/src/components/utils.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import config from '../config'; +import { Result } from './ResultsGrid'; +import { getResultOidsFromStationIds, getStationIdsFromResultRows } from './utils'; + +describe('getStationIdsFromResultRows', () => { + const data: Result[] = [ + { [config.fieldNames.ESRI_OID]: '1', [config.fieldNames.STATION_ID]: 'A' }, + { [config.fieldNames.ESRI_OID]: '2', [config.fieldNames.STATION_ID]: 'B' }, + ]; + + it('should return correct station IDs', () => { + const selectedOids = new Set(['1']); + const result = getStationIdsFromResultRows(data, selectedOids); + expect(result).toEqual(new Set(['A'])); + }); + + it('should return an empty set if no matching OIDs', () => { + const selectedOids = new Set(['3']); + const result = getStationIdsFromResultRows(data, selectedOids); + expect(result).toEqual(new Set()); + }); +}); + +describe('getResultOidsFromStationIds', () => { + const data: Result[] = [ + { [config.fieldNames.ESRI_OID]: '1', [config.fieldNames.STATION_ID]: 'A' }, + { [config.fieldNames.ESRI_OID]: '2', [config.fieldNames.STATION_ID]: 'B' }, + ]; + + it('should return correct OIDs', () => { + const selectedStationIds = new Set(['A']); + const result = getResultOidsFromStationIds(data, selectedStationIds); + expect(result).toEqual(new Set(['1'])); + }); + + it('should return an empty set if no matching station IDs', () => { + const selectedStationIds = new Set(['C']); + const result = getResultOidsFromStationIds(data, selectedStationIds); + expect(result).toEqual(new Set()); + }); +}); diff --git a/src/components/utils.ts b/src/components/utils.ts index 35e0d82..5d036dc 100644 --- a/src/components/utils.ts +++ b/src/components/utils.ts @@ -1,6 +1,8 @@ import { composeRenderProps } from 'react-aria-components'; import { twMerge } from 'tailwind-merge'; import { tv } from 'tailwind-variants'; +import config from '../config'; +import { Result } from './ResultsGrid'; export const focusRing = tv({ base: 'outline outline-offset-2 outline-primary-900 dark:outline-secondary-600', @@ -18,3 +20,19 @@ export function composeTailwindRenderProps( ): string | ((v: T) => string) { return composeRenderProps(className, (className) => twMerge(tw, className)); } + +export function getStationIdsFromResultRows(data: Result[] | undefined, selectedOids: Set): Set { + return new Set( + data + ?.filter((row) => selectedOids.has(row[config.fieldNames.ESRI_OID] as string)) + .map((row) => row[config.fieldNames.STATION_ID] as string), + ); +} + +export function getResultOidsFromStationIds(data: Result[] | undefined, selectedStationIds: Set): Set { + return new Set( + data + ?.filter((row) => selectedStationIds.has(row[config.fieldNames.STATION_ID] as string)) + .map((row) => row[config.fieldNames.ESRI_OID] as string), + ); +} diff --git a/src/config.ts b/src/config.ts index 37029c8..55aa92a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -17,6 +17,8 @@ const config = { dynamicWorkspaceId: 'ElectrofishingQuery', fieldNames: { + ESRI_OID: 'ESRI_OID', + // common STATION_ID: 'STATION_ID',