diff --git a/react/src/__fixtures__/featuresFixture.ts b/react/src/__fixtures__/featuresFixture.ts index a3af6a5a..41624232 100644 --- a/react/src/__fixtures__/featuresFixture.ts +++ b/react/src/__fixtures__/featuresFixture.ts @@ -1,5 +1,5 @@ import { Point } from 'geojson'; -import { FeatureCollection, AssetType } from '@hazmapper/types'; +import { FeatureCollection, AssetType, Feature } from '@hazmapper/types'; export const featureCollection: FeatureCollection = { type: 'FeatureCollection', @@ -188,3 +188,102 @@ export const featureCollectionWithDuplicateImages: FeatureCollection = { }, ], }; + +export const mockImgFeature: Feature = { + id: 10000, + project_id: 100, + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [-97.726075437, 30.389785554], + }, + properties: { + path: '/test/test/Photo 4.jpg', + title: 'Photo 4', + units: '', + format: 'jpg', + system: 'project-11111-242ac11c-0001-012', + _author: 'Test', + basePath: '/test/test', + data_type: 'image', + _timestamp: 1558449670.0902429, + misc_notes: '', + description: 'Words', + geoid_model: '', + geolocation: [ + { + course: -1, + altitude: 236.7534637451172, + latitude: 30.38978555407587, + longitude: -97.72607543696425, + }, + ], + coord_system: '', + geodetic_datum: '', + vertical_datum: '', + instrument_type: 'other', + _test_asset_uuid: '0007766-226F-4A04-93ED-EC769630D372', + coord_ref_system: '', + data_hazard_type: 'other', + coord_system_epoch: '', + referenced_data_links: [], + geodetic_datum_realization: '', + _test_parent_collection_uuid: 'DB66DAEA-2B36-4D20-83C1-K9876C0', + instrument_manufacturer_and_model: '', + }, + styles: {}, + assets: [ + { + id: 1412157, + path: '925/test-86cc-4ae2-8820-f30353b6c714.jpeg', + uuid: 'test-86cc-4ae2-8820-f30353b6c714', + asset_type: 'image', + original_path: 'project-test-242ac11c-0001-012/test/test/Photo 4.jpg', + original_name: null, + display_path: 'project-test-242ac11c-0001-012/test/test/Photo 4.jpg', + }, + ], +}; +export const mockPointFeature: Feature = { + id: 100001, + project_id: 100, + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [173.043228, -42.619206], + }, + properties: { + name: 'IMG_0198.JPG', + color: '#ff0000', + label: 'IMG_0198.JPG', + opacity: 1, + fillColor: '#ff0000', + description: + '\n\nOblique dextral fault scarp along pre-existing fold(?) scarp\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
IMG_0198.JPG
\n\n
\n\n
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
PathE:\\EH717\\New_Zealand\\112116\\11_21_2016\\Stahl\\TS06\\IMG_0198.JPG
NameIMG_0198.JPG
DateTime2016:11:21 16:12:23
Direction247.482353
\n\n
\n\n\n\n', + }, + styles: null, + assets: [], +}; + +export const mockVideoFeature: Feature = { + id: 100002, + project_id: 100, + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [-97.725663, 30.390238], + }, + properties: {}, + styles: null, + assets: [ + { + id: 60933, + path: '1027/935152cc-dba1-4cb6-9efd-82036a6446ac.mp4', + uuid: '935152cc-dba1-4cb6-9efd-82036a6446ac', + asset_type: 'video', + original_path: '/test/test/Video 1.mov', + original_name: null, + display_path: '/test/test/Video 1.mov', + }, + ], +}; diff --git a/react/src/components/AssetDetail/AssetDetail.module.css b/react/src/components/AssetDetail/AssetDetail.module.css new file mode 100644 index 00000000..70037c36 --- /dev/null +++ b/react/src/components/AssetDetail/AssetDetail.module.css @@ -0,0 +1,85 @@ +.root { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + overflow: hidden; +} + +.topSection { + flex: 0 0 auto; + padding: 10px; + display: flex; + text-align: center; + justify-content: space-between; + align-items: center; + font-size: large; +} + +.middleSection { + flex: 1 1 auto; + overflow: hidden; + min-height: 0; + display: flex; + text-align: center; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 10px; +} +.middleSection > div { + flex: 0 1 auto; + overflow: auto; +} +.assetContainer { + display: flex; + align-content: center; + justify-content: center; +} +.assetContainer > * { + max-width: 100%; +} +.bottomSection { + display: block; + flex: 0 0 auto; + overflow-x: hidden; + justify-items: flex-end; + align-items: flex-end; + width: 100%; +} +.metadataTable { + flex-grow: 1; + width: 100%; +} +.metadataTable table { + width: 100%; + table-layout: fixed; + border-collapse: collapse; + margin: 5px; + padding: 5px; +} +.metadataTable thead, +th { + background: #d0d0d0; +} +.metadataTable tbody { + display: block; + max-height: 300px; + overflow-y: auto; + scroll-padding: 5px; +} +.metadataTable tr { + display: table; + width: 100%; + table-layout: fixed; + text-overflow: ellipsis; + overflow: hidden; + white-space: wrap; + max-height: 30px; +} +.metadataTable td { + word-wrap: break-word; + word-break: break-word; + white-space: normal; + text-overflow: clip; +} diff --git a/react/src/components/AssetDetail/AssetDetail.test.tsx b/react/src/components/AssetDetail/AssetDetail.test.tsx new file mode 100644 index 00000000..5b8daf6d --- /dev/null +++ b/react/src/components/AssetDetail/AssetDetail.test.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import AssetDetail from './AssetDetail'; +import { mockImgFeature } from '@hazmapper/__fixtures__/featuresFixture'; + +jest.mock('@hazmapper/hooks', () => ({ + useFeatureSelection: jest.fn(), + useAppConfiguration: jest.fn().mockReturnValue({ + geoapiUrl: 'https://example.com/geoapi', + }), +})); + +describe('AssetDetail', () => { + const AssetModalProps = { + onClose: jest.fn(), + selectedFeature: mockImgFeature, + isPublicView: false, + }; + + it('renders all main components', () => { + const { getByText } = render(); + // Check for title, button, and tables + expect(getByText('Photo 4.jpg')).toBeDefined(); + expect(getByText('Download')).toBeDefined(); + expect(getByText('Metadata')).toBeDefined(); + expect(getByText('Geometry')).toBeDefined(); + }); +}); diff --git a/react/src/components/AssetDetail/AssetDetail.tsx b/react/src/components/AssetDetail/AssetDetail.tsx new file mode 100644 index 00000000..957b9743 --- /dev/null +++ b/react/src/components/AssetDetail/AssetDetail.tsx @@ -0,0 +1,162 @@ +import React, { Suspense } from 'react'; +import _ from 'lodash'; +import { useAppConfiguration } from '@hazmapper/hooks'; +import { FeatureTypeNullable, Feature, getFeatureType } from '@hazmapper/types'; +import { FeatureIcon } from '@hazmapper/components/FeatureIcon'; +import { Button, LoadingSpinner, SectionMessage } from '@tacc/core-components'; +import styles from './AssetDetail.module.css'; + +type AssetModalProps = { + onClose: () => void; + selectedFeature: Feature; + isPublicView: boolean; +}; + +const AssetDetail: React.FC = ({ + selectedFeature, + onClose, + isPublicView, +}) => { + const config = useAppConfiguration(); + const geoapiUrl = config.geoapiUrl; + + const featureSource: string = + geoapiUrl + '/assets/' + selectedFeature?.assets?.[0]?.path; + + const fileType = getFeatureType(selectedFeature); + + const AssetRenderer = React.memo( + ({ + type, + source, + }: { + type: string | undefined; + source: string | undefined; + }) => { + switch (type) { + case 'image': + return Asset; + case 'video': + return ( + + ); + case 'point_cloud': + /*TODO Add pointcloud */ + return
source={source}
; + case 'questionnaire': + /*TODO Add questionnaire */ + return
source={source}
; + default: + return null; + } + } + ); + AssetRenderer.displayName = 'AssetRenderer'; + + return ( +
+
+ + {selectedFeature?.assets?.length > 0 + ? selectedFeature?.assets.map((asset) => + // To make sure fileTree name matches title and catches null + asset.display_path + ? asset.display_path.split('/').pop() + : asset.id + ? asset.id + : selectedFeature.id + ) + : selectedFeature?.id} + +
+
+ {fileType ? ( + <> + }> +
+ +
+
+ + + ) : ( + <> + Feature has no asset. + {!isPublicView && ( + + )} + + )} +
+
+
+ + + + + + + + {selectedFeature?.properties && + Object.keys(selectedFeature.properties).length > 0 ? ( + Object.entries(selectedFeature.properties) + .filter(([key]) => !key.startsWith('_hazmapper')) + .sort(([keyA], [keyB]) => keyA.localeCompare(keyB)) // Alphabetizes metadata + .map(([propKey, propValue]) => ( + + + + + )) + ) : ( + + + + )} + +
Metadata
{_.startCase(propKey)} + {propKey.startsWith('description') ? ( + {propValue} + ) : ( + _.trim(JSON.stringify(propValue), '"') + )} +
There are no metadata properties.
+ + + + + + + + {selectedFeature?.geometry && + Object.entries(selectedFeature.geometry).map( + ([propKey, propValue]) => + propValue && + propValue !== undefined && + propValue.toString().trim() !== '' && + propValue.toString() !== 'null' && ( + + + + + ) + )} + +
Geometry
{_.trim(_.startCase(propKey.toString()), '"')} + {' '} + {Array.isArray(propValue) && propValue.length === 2 + ? `Latitude: ${propValue[0].toString()}, + Longitude: ${propValue[1].toString()}` + : _.trim(JSON.stringify(propValue), '"')} +
+
+
+
+ ); +}; + +export default AssetDetail; diff --git a/react/src/components/AssetDetail/index.tsx b/react/src/components/AssetDetail/index.tsx new file mode 100644 index 00000000..5aa97340 --- /dev/null +++ b/react/src/components/AssetDetail/index.tsx @@ -0,0 +1 @@ +export { default } from './AssetDetail'; diff --git a/react/src/hooks/features/useFeatures.ts b/react/src/hooks/features/useFeatures.ts index a9dd667c..6c06e802 100644 --- a/react/src/hooks/features/useFeatures.ts +++ b/react/src/hooks/features/useFeatures.ts @@ -1,4 +1,4 @@ -import { useQueryClient, UseQueryResult } from 'react-query'; +import { useQueryClient, UseQueryResult, QueryKey } from 'react-query'; import { FeatureCollection } from '@hazmapper/types'; import { useGet } from '@hazmapper/requests'; @@ -40,22 +40,26 @@ export const useFeatures = ({ }); return query; }; - export const useCurrentFeatures = (): UseQueryResult => { const queryClient = useQueryClient(); - - // Get all existing queries that match the KEY_USE_FEATURES prefix - const queries = queryClient.getQueriesData([ - KEY_USE_FEATURES, - ]); - - // Find first query with data - getQueriesData returns [queryKey, data] tuples - const activeQuery = queries.find(([, queryData]) => !!queryData); - const currentData = activeQuery ? activeQuery[1] : undefined; + const latestQuery = queryClient + .getQueriesData([KEY_USE_FEATURES]) + .filter(([, value]) => Boolean(value)) + .reduce<[QueryKey, FeatureCollection] | null>((latest, current) => { + const currentState = queryClient.getQueryState(current[0]); + const latestState = latest ? queryClient.getQueryState(latest[0]) : null; + if ( + !latestState || + (currentState && currentState.dataUpdatedAt > latestState.dataUpdatedAt) + ) { + return current; + } + return latest; + }, null); return { - data: currentData, - isSuccess: !!currentData, + data: latestQuery?.[1], + isSuccess: !!latestQuery?.[1], isLoading: false, isError: false, error: null, diff --git a/react/src/pages/MapProject/MapProject.module.css b/react/src/pages/MapProject/MapProject.module.css index 19d843c8..e1489c8b 100644 --- a/react/src/pages/MapProject/MapProject.module.css +++ b/react/src/pages/MapProject/MapProject.module.css @@ -52,6 +52,18 @@ left: var(--hazmapper-panel-navbar-width); z-index: 5000; } +.detailContainer { + min-width: 250px; + max-width: 400px; + height: auto; + background-color: var(--global-color-primary--xx-light); + position: fixed; + padding: 5px; + right: 10px; + top: 75px; + bottom: 20px; + z-index: 5000; +} .map { width: 100%; diff --git a/react/src/pages/MapProject/MapProject.tsx b/react/src/pages/MapProject/MapProject.tsx index 455865c8..17dff4de 100644 --- a/react/src/pages/MapProject/MapProject.tsx +++ b/react/src/pages/MapProject/MapProject.tsx @@ -3,9 +3,15 @@ import { useState } from 'react'; import { useLocation } from 'react-router-dom'; import Map from '@hazmapper/components/Map'; import AssetsPanel from '@hazmapper/components/AssetsPanel'; +import AssetDetail from '@hazmapper/components/AssetDetail'; import ManageMapProjectModal from '@hazmapper/components/ManageMapProjectModal'; import { queryPanelKey, Panel } from '@hazmapper/utils/panels'; -import { useFeatures, useProject, useTileServers } from '@hazmapper/hooks'; +import { + useFeatures, + useProject, + useTileServers, + useFeatureSelection, +} from '@hazmapper/hooks'; import { useParams } from 'react-router-dom'; import styles from './MapProject.module.css'; import MapProjectNavBar from '@hazmapper/components/MapProjectNavBar'; @@ -96,6 +102,8 @@ const LoadedMapProject: React.FC = ({ const [endDate, setEndDate] = useState( new Date(Date.now() + 24 * 60 * 60 * 1000) ); + const { selectedFeature, setSelectedFeatureId: toggleSelectedFeature } = + useFeatureSelection(); const formatAssetTypeName = (name: string) => { switch (name) { @@ -189,6 +197,15 @@ const LoadedMapProject: React.FC = ({ featureCollection={featureCollection} /> + {selectedFeature && ( +
+ toggleSelectedFeature(selectedFeature.id)} + isPublicView={activeProject.public} + /> +
+ )} );