Skip to content

Commit

Permalink
feat: results grid and station selection
Browse files Browse the repository at this point in the history
Ref: #187
  • Loading branch information
stdavis committed Nov 29, 2024
1 parent 69745d8 commit 26f8f0f
Show file tree
Hide file tree
Showing 9 changed files with 249 additions and 49 deletions.
1 change: 1 addition & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"noopener",
"noreferrer",
"oidc",
"Oids",
"overscan",
"packagejson",
"sgid",
Expand Down
69 changes: 30 additions & 39 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -188,38 +177,40 @@ export default function App() {
</Header>
{currentUser ? (
<section className="relative flex min-h-0 flex-1 overflow-x-hidden md:mr-2">
<Drawer main state={sideBarState} {...sideBarTriggerProps}>
<div className="mx-2 mb-2 grid grid-cols-1 gap-2">
<Filter />
<h2 className="text-xl font-bold">Map controls</h2>
<div className="flex flex-col gap-4 rounded border border-zinc-200 p-3 dark:border-zinc-700">
<ErrorBoundary FallbackComponent={ErrorFallback}>
<Sherlock {...streamsSherlockOptions} label="Find a stream" />
</ErrorBoundary>
<SelectionProvider>
<Drawer main state={sideBarState} {...sideBarTriggerProps}>
<div className="mx-2 mb-2 grid grid-cols-1 gap-2">
<Filter />
<h2 className="text-xl font-bold">Map controls</h2>
<div className="flex flex-col gap-4 rounded border border-zinc-200 p-3 dark:border-zinc-700">
<ErrorBoundary FallbackComponent={ErrorFallback}>
<Sherlock {...streamsSherlockOptions} label="Find a stream" />
</ErrorBoundary>
</div>
<div className="flex flex-col gap-4 rounded border border-zinc-200 p-3 dark:border-zinc-700">
<ErrorBoundary FallbackComponent={ErrorFallback}>
<Sherlock {...lakesSherlockOptions} label="Find a lake" />
</ErrorBoundary>
</div>
</div>
<div className="flex flex-col gap-4 rounded border border-zinc-200 p-3 dark:border-zinc-700">
</Drawer>
<div className="relative mb-2 flex flex-1 flex-col overflow-hidden rounded border border-zinc-200 dark:border-zinc-700">
<div className="relative flex-1 overflow-hidden dark:rounded">
<ErrorBoundary FallbackComponent={ErrorFallback}>
<Sherlock {...lakesSherlockOptions} label="Find a lake" />
<MapContainer bottomPadding={trayState.isOpen ? 320 : 0} />
</ErrorBoundary>
<Drawer
type="tray"
className="shadow-inner dark:shadow-white/20"
allowFullScreen
state={trayState}
{...trayTriggerProps}
>
<ResultsGrid />
</Drawer>
</div>
</div>
</Drawer>
<div className="relative mb-2 flex flex-1 flex-col overflow-hidden rounded border border-zinc-200 dark:border-zinc-700">
<div className="relative flex-1 overflow-hidden dark:rounded">
<ErrorBoundary FallbackComponent={ErrorFallback}>
<MapContainer bottomPadding={trayState.isOpen ? 320 : 0} />
</ErrorBoundary>
<Drawer
type="tray"
className="shadow-inner dark:shadow-white/20"
allowFullScreen
state={trayState}
{...trayTriggerProps}
>
<ResultsGrid />
</Drawer>
</div>
</div>
</SelectionProvider>
</section>
) : (
<section className="flex flex-1 items-center justify-center">
Expand Down
91 changes: 87 additions & 4 deletions src/components/Filter.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -15,18 +19,62 @@ export default function Filter(): JSX.Element {
const { addLayers, mapView } = useMap();
const stationsLayer = useRef<FeatureLayer>();
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) {
Expand All @@ -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 (
<>
Expand Down
2 changes: 1 addition & 1 deletion src/components/MapContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
41 changes: 36 additions & 5 deletions src/components/ResultsGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string | number | null>;
export type Result = Record<string, string | number | null>;
async function getData(where: string, currentUser: User): Promise<Result[]> {
if (where === '') {
return [];
Expand All @@ -28,6 +31,7 @@ async function getData(where: string, currentUser: User): Promise<Result[]> {
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}
Expand Down Expand Up @@ -97,7 +101,7 @@ async function getData(where: string, currentUser: User): Promise<Result[]> {

return responseJson.features.map((feature) => ({
...feature.attributes,
id: feature.attributes.ESRI_OID,
id: feature.attributes[config.fieldNames.ESRI_OID],
}));
}

Expand All @@ -115,6 +119,13 @@ export default function ResultsGrid() {
},
});

const { selectedStationIds, setSelectedStationIds } = useSelection();
const [selectedKeys, setSelectedKeys] = useState<Selection>(new Set());

useEffect(() => {
setSelectedKeys(getResultOidsFromStationIds(data, selectedStationIds));
}, [data, selectedStationIds]);

if (isPending) {
return (
<div className="flex h-full justify-center align-middle">
Expand All @@ -126,20 +137,40 @@ export default function ResultsGrid() {
return <span>{error.message}</span>;
}

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<string>));
}
};

return (
<>
<span className="absolute right-12 top-2 z-10 self-center">
Records: <strong>{data?.length}</strong>
{selectedStationIds.size > 0 && (
<span>
{' '}
| Selected: <strong>{selectedKeys === 'all' ? data?.length : selectedKeys.size}</strong>
</span>
)}
</span>
<Tabs aria-label="results panel">
<TabList>
<Tab id="grid">Results</Tab>
<Tab id="download">Download</Tab>
</TabList>
<TabPanel id="grid">
<Table aria-label="query results" className="-z-10 w-full border-t dark:border-t-zinc-300">
<Table
aria-label="query results"
className="-z-10 w-full border-t dark:border-t-zinc-300"
selectionMode="multiple"
selectedKeys={selectedKeys}
onSelectionChange={onSelectionChange}
>
<TableHeader>
<Column id={config.fieldNames.EVENT_DATE} minWidth={120}>
Event Date
Expand Down
32 changes: 32 additions & 0 deletions src/components/contexts/SelectionProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { createContext, useContext, useState } from 'react';

type SelectionContextType = {
selectedStationIds: Set<string>;
setSelectedStationIds: React.Dispatch<React.SetStateAction<Set<string>>>;
};

const SelectionContext = createContext<SelectionContextType>({
selectedStationIds: new Set(),
setSelectedStationIds: () => {},
});

export function SelectionProvider({ children }: { children: React.ReactNode }) {
const [selectedStationIds, setSelectedStationIds] = useState<Set<string>>(new Set());

return (
<SelectionContext.Provider value={{ selectedStationIds, setSelectedStationIds }}>
{children}
</SelectionContext.Provider>
);
}

export function useSelection() {
console.log('useSelection');
const context = useContext(SelectionContext);

if (!context) {
throw new Error('useSelection must be used within a SelectionProvider');
}

return context;
}
Loading

0 comments on commit 26f8f0f

Please sign in to comment.