Skip to content

Commit

Permalink
Overview Map of Record locations (#1236)
Browse files Browse the repository at this point in the history
# Overview Map of Record locations

## JIRA Ticket

None (Fieldmark)

## Description

Adds a map tab to the notebook/survey view to show the locations stored
in existing records for the notebook.

## Proposed Changes

- Add the OverviewMap component to display GIS locations

## How to Test

View a notebook with existing records containing GIS fields (eg. Rapid
Impact Survey), view the Map tab to see the record locations.

Also look at a notebook with no GIS fields or no records to see a
suitable message.


## Additional Information

This is the first version of this feature, many refinements are
possible.

## Checklist

- [x] I have confirmed all commits have been signed.
- [x] I have added JSDoc style comments to any new functions or classes.
- [x] Relevant documentation such as READMEs, guides, and class comments
are updated.
  • Loading branch information
stevecassidy authored Nov 20, 2024
2 parents 52ca1b3 + c1653e4 commit 7d829af
Show file tree
Hide file tree
Showing 3 changed files with 299 additions and 3 deletions.
16 changes: 14 additions & 2 deletions app/src/gui/components/notebook/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {useQuery} from '@tanstack/react-query';
import {getMetadataValue} from '../../../sync/metadata';
import {ProjectExtended} from '../../../types/project';
import RangeHeader from './range_header';
import {OverviewMap} from './overview_map';

/**
* TabPanelProps defines the properties for the TabPanel component.
Expand Down Expand Up @@ -153,8 +154,12 @@ export default function NotebookComponent({project}: NotebookComponentProps) {

const {data: template_id} = useQuery({
queryKey: ['project-template-id', project.project_id],
queryFn: () =>
getMetadataValue(project.project_id, 'template_id') as Promise<string>,
queryFn: async () : Promise<string | null> => {

Check warning on line 157 in app/src/gui/components/notebook/index.tsx

View workflow job for this annotation

GitHub Actions / Build and Test

Delete `·`
// don't return undefined from queryFn
const id = await getMetadataValue(project.project_id, 'template_id');
if (id !== undefined) return id as string;
else return null;
},
});

/**
Expand Down Expand Up @@ -274,6 +279,7 @@ export default function NotebookComponent({project}: NotebookComponentProps) {
/>
<Tab label="Details" {...a11yProps(1, NOTEBOOK_NAME)} />
<Tab label="Settings" {...a11yProps(2, NOTEBOOK_NAME)} />
<Tab label="Map" {...a11yProps(3, NOTEBOOK_NAME)} />
</Tabs>
</AppBar>
</Box>
Expand Down Expand Up @@ -567,6 +573,12 @@ export default function NotebookComponent({project}: NotebookComponentProps) {
<TabPanel value={notebookTabValue} index={2} id={'notebook'}>
{uiSpec !== null && <NotebookSettings uiSpec={uiSpec} />}
</TabPanel>

<TabPanel value={notebookTabValue} index={3} id={'notebook'}>
{uiSpec !== null && (
<OverviewMap project_id={project.project_id} uiSpec={uiSpec} />
)}
</TabPanel>
</Box>
)}
</Box>
Expand Down
284 changes: 284 additions & 0 deletions app/src/gui/components/notebook/overview_map.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,284 @@
/*
* Copyright 2021, 2022 Macquarie University
*
* Licensed under the Apache License Version 2.0 (the, "License");
* you may not use, this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing software
* distributed under the License is distributed on an "AS IS" BASIS
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND either express or implied.
* See, the License, for the specific language governing permissions and
* limitations under the License.
*
* Filename: overview_map.tsx
* Description:
* Display an overview map of the records in the notebook.
*/

import {
getMetadataForAllRecords,
ProjectID,
ProjectUIModel,
} from '@faims3/data-model';
import {Box, Popover} from '@mui/material';
import {useQuery} from '@tanstack/react-query';
import {View} from 'ol';
import {Zoom} from 'ol/control';
import GeoJSON from 'ol/format/GeoJSON';
import TileLayer from 'ol/layer/Tile';
import VectorLayer from 'ol/layer/Vector';
import Map from 'ol/Map';
import {transform} from 'ol/proj';
import {OSM} from 'ol/source';
import VectorSource from 'ol/source/Vector';
import {Fill, Stroke, Style} from 'ol/style';
import CircleStyle from 'ol/style/Circle';
import {useCallback, useMemo, useRef, useState} from 'react';
import {Link} from 'react-router-dom';
import * as ROUTES from '../../../constants/routes';
import {createCenterControl} from '../map/center-control';

interface OverviewMapProps {
uiSpec: ProjectUIModel;
project_id: ProjectID;
}

interface FeatureProps {
name: string;
record_id: string;
revision_id: string;
}

const defaultMapProjection = 'EPSG:3857';

/**
* Create an overview map of the records in the notebook.
*
* @param props {uiSpec, project_id}
*/
export const OverviewMap = (props: OverviewMapProps) => {
const [map, setMap] = useState<Map | undefined>(undefined);
const [selectedFeature, setSelectedFeature] = useState<FeatureProps | null>(
null
);

/**
* Get the names of all GIS fields in this UI Specification
* @param uiSpec UI specification for the project
* @returns
*/
const getGISFields = () => {
const fields = Object.getOwnPropertyNames(props.uiSpec.fields);
return fields.filter(
(field: string) =>
props.uiSpec.fields[field]['component-name'] === 'MapFormField' ||
props.uiSpec.fields[field]['component-name'] === 'TakePoint'
);
};
const gisFields = useMemo(getGISFields, [props.uiSpec]);

/**
* Extract all of the features from the records in the notebook that
* we will display on the map. To be used in the useQuery hook below.
*
* @returns a FeatureProps object containing all of the features in the record
*/
const getFeatures = async () => {
const f: FeatureProps[] = [];
if (gisFields.length > 0) {
const records = await getMetadataForAllRecords(props.project_id, true);
if (records) {
records.forEach(record => {
if (record.data) {
gisFields.forEach((field: string) => {
// two options here, if it's a TakePoint field we'll have a single feature
// if it's a MapFormField we'll have an object with multiple features
if (record.data?.[field] && record.data[field].type) {
if (record.data[field].type === 'FeatureCollection') {
record.data[field].features.forEach((feature: any) => {
// add properties to the feature for display
feature.properties = {
name: record.hrid,
record_id: record.record_id,
revision_id: record.revision_id,
};
f.push(feature);
});
} else {
f.push({
...record.data[field],
properties: {
name: record.hrid,
record_id: record.record_id,
revision_id: record.revision_id,
},
});
}
}
});
}
});
}
}
return {
type: 'FeatureCollection',
features: f,
};
};

const {data: features, isLoading: loadingFeatures} = useQuery({
queryKey: ['records', props.project_id],
queryFn: getFeatures,
});

// create state ref that can be accessed in OpenLayers onclick callback function
// https://stackoverflow.com/a/60643670
const mapRef = useRef<Map | undefined>();
mapRef.current = map;

const map_center = [30, -10];

/**
* Create the OpenLayers map element
*/
const createMap = useCallback(async (element: HTMLElement): Promise<Map> => {
const center = transform(map_center, 'EPSG:4326', defaultMapProjection);

const tileLayer = new TileLayer({source: new OSM()});
const view = new View({
projection: defaultMapProjection,
center: center,
zoom: 12,
});

const theMap = new Map({
target: element,
layers: [tileLayer],
view: view,
controls: [new Zoom()],
});

theMap.addControl(createCenterControl(theMap.getView(), center));

theMap.getView().setCenter(center);

theMap.on('click', evt => {
const feature = theMap.forEachFeatureAtPixel(evt.pixel, feature => {
return feature.getProperties();
});
if (!feature) {
return;
}
setSelectedFeature(feature as FeatureProps);
});

return theMap;
}, []);

/**
* Add the features to the map and set the map view to
* encompass the features.
*
* @param map OpenLayers map object
*/
const addFeaturesToMap = (map: Map) => {
const source = new VectorSource();
const geoJson = new GeoJSON();

const layer = new VectorLayer({
source: source,
style: new Style({
stroke: new Stroke({
color: '#FF0000',
width: 4,
}),
image: new CircleStyle({
radius: 7,
fill: new Fill({color: '#FF0000'}),
}),
}),
});

if (features && features.features.length > 0) {
const parsedFeatures = geoJson.readFeatures(features, {
dataProjection: 'EPSG:4326',
featureProjection: map.getView().getProjection(),
});
source.addFeatures(parsedFeatures);

// set the view so that we can see the features
// but don't zoom too much
const extent = source.getExtent();
// don't fit if the extent is infinite because it crashes
if (!extent.includes(Infinity)) {
map.getView().fit(extent, {padding: [100, 100, 100, 100], maxZoom: 12});
}
}

map.addLayer(layer);
};

// callback to add the map to the DOM
const refCallback = (element: HTMLElement | null) => {
if (element) {
if (!map) {
// create map
createMap(element).then((theMap: Map) => {
addFeaturesToMap(theMap);
setMap(theMap);
});
} else {
map.setTarget(element);
}
}
};

const handlePopoverClose = () => {
setSelectedFeature(null);
};

if (gisFields.length === 0) {
return <Box>No GIS fields found.</Box>;
} else if (features?.features.length === 0) {
return <Box>No records with locations found.</Box>;
} else if (loadingFeatures) {
return <Box>Loading...</Box>;
} else
return (
<>
<Box
ref={refCallback}
sx={{
height: 600,
width: '100%',
}}
/>
<Popover
open={!!selectedFeature}
onClose={handlePopoverClose}
anchorEl={mapRef.current?.getTargetElement()}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left',
}}
>
{selectedFeature && (
<Box sx={{padding: '50px'}}>
<Link
to={ROUTES.getRecordRoute(
props.project_id || 'dummy',
selectedFeature.record_id,
selectedFeature.revision_id
)}
>
{selectedFeature.name}
</Link>
</Box>
)}
</Popover>
</>
);
};
2 changes: 1 addition & 1 deletion library/data-model/src/data_storage/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -549,7 +549,7 @@ export async function getRecordsWithRegex(

import {FormData} from './internals';

const hydrateRecord = async (
export const hydrateRecord = async (
project_id: string,
record: any // return type of getSomeRecords
) => {
Expand Down

0 comments on commit 7d829af

Please sign in to comment.