diff --git a/frontend/dev-proxy/proxy-table.dev.json b/frontend/dev-proxy/proxy-table.dev.json index 3ca165d5..2f4c9d12 100644 --- a/frontend/dev-proxy/proxy-table.dev.json +++ b/frontend/dev-proxy/proxy-table.dev.json @@ -19,5 +19,12 @@ "pathRewrite": { "^/api": "/" } + }, + "/pixel": { + "target": "http://localhost", + "changeOrigin": true, + "pathRewrite": { + "^/pixel": "/pixel" + } } } diff --git a/frontend/dev-proxy/proxy-table.docker.json b/frontend/dev-proxy/proxy-table.docker.json index 00d53237..f38ba1df 100644 --- a/frontend/dev-proxy/proxy-table.docker.json +++ b/frontend/dev-proxy/proxy-table.docker.json @@ -19,5 +19,12 @@ "pathRewrite": { "^/api": "/api" } + }, + "/pixel": { + "target": "http://localhost", + "changeOrigin": true, + "pathRewrite": { + "^/pixel": "/pixel" + } } } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b2d6c65f..3f0cfe78 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -16,6 +16,7 @@ "@mui/icons-material": "^6.1.0", "@mui/lab": "^6.0.0-beta.9", "@mui/material": "^6.1.0", + "@mui/x-data-grid": "^7.24.1", "@mui/x-tree-view": "^7.5.0", "@react-hook/debounce": "^4.0.0", "@recoiljs/refine": "^0.1.1", @@ -4761,6 +4762,43 @@ } } }, + "node_modules/@mui/x-data-grid": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@mui/x-data-grid/-/x-data-grid-7.24.1.tgz", + "integrity": "sha512-4sYTbMwsDotuTd2Cwa2JGTPXPWQs8RGJvocAKnIsNOzNdZNMrikE//HO35snriK8s4dauAApY7RVbeisjpVT+A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.7", + "@mui/utils": "^5.16.6 || ^6.0.0", + "@mui/x-internals": "7.24.1", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "reselect": "^5.1.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.9.0", + "@emotion/styled": "^11.8.1", + "@mui/material": "^5.15.14 || ^6.0.0", + "@mui/system": "^5.15.14 || ^6.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + } + } + }, "node_modules/@mui/x-internals": { "version": "7.24.1", "resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-7.24.1.tgz", @@ -17197,6 +17235,12 @@ "dev": true, "license": "MIT" }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", diff --git a/frontend/package.json b/frontend/package.json index be4dad7f..69244a25 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,6 +12,7 @@ "@mui/icons-material": "^6.1.0", "@mui/lab": "^6.0.0-beta.9", "@mui/material": "^6.1.0", + "@mui/x-data-grid": "^7.24.1", "@mui/x-tree-view": "^7.5.0", "@react-hook/debounce": "^4.0.0", "@recoiljs/refine": "^0.1.1", diff --git a/frontend/src/details/DetailsSidebar.tsx b/frontend/src/details/DetailsSidebar.tsx index dbb35732..d43218bc 100644 --- a/frontend/src/details/DetailsSidebar.tsx +++ b/frontend/src/details/DetailsSidebar.tsx @@ -7,6 +7,7 @@ import { AdaptationsSidebar } from './adaptations/AdaptationsSidebar'; import { FeatureSidebar } from './features/FeatureSidebar'; import { RegionDetails } from './regions/RegionDetails'; import { SolutionsSidebar } from './solutions/SolutionsSidebar'; +import { PixelData } from './pixel-data/PixelData'; export const showAdaptationsTableState = selector({ key: 'showAdaptationsTable', @@ -18,6 +19,9 @@ export const DetailsSidebar = () => { const showAdaptationsTable = useRecoilValue(showAdaptationsTableState); return ( <> + + + diff --git a/frontend/src/details/pixel-data/PixelData.tsx b/frontend/src/details/pixel-data/PixelData.tsx new file mode 100644 index 00000000..f2193ad7 --- /dev/null +++ b/frontend/src/details/pixel-data/PixelData.tsx @@ -0,0 +1,50 @@ +import { useRecoilValue, useSetRecoilState } from 'recoil'; + +import { Box, IconButton } from '@mui/material'; +import { SidePanel } from 'details/SidePanel'; +import { ErrorBoundary } from 'lib/react/ErrorBoundary'; +import { MobileTabContentWatcher } from 'lib/map/layouts/tab-has-content'; +import { + pixelDrillerDataHeaders, + pixelDrillerDataState, + pixelSelectionState, +} from 'lib/state/pixel-driller'; +import { PixelDataGrid } from './PixelDataGrid'; +import { Close } from '@mui/icons-material'; + +/** + * Display detailed information about a selected pixel (lat/lon point.) + */ +export const PixelData = () => { + const { data: selectedData } = useRecoilValue(pixelDrillerDataState); + const headers = useRecoilValue(pixelDrillerDataHeaders); + const setPixelSelection = useSetRecoilState(pixelSelectionState); + + function clearSelectedLocation() { + setPixelSelection(null); + } + + if (!selectedData) { + return null; + } + if (!headers.length) { + return null; + } + const hazards = [...new Set(selectedData.hazard)] as string[]; + + return ( + + + + + + + + + {hazards.map((hazard) => ( + + ))} + + + ); +}; diff --git a/frontend/src/details/pixel-data/PixelDataGrid.tsx b/frontend/src/details/pixel-data/PixelDataGrid.tsx new file mode 100644 index 00000000..1ebf8e71 --- /dev/null +++ b/frontend/src/details/pixel-data/PixelDataGrid.tsx @@ -0,0 +1,25 @@ +import { DataGrid } from '@mui/x-data-grid'; + +import { pixelDrillerDataHeaders, pixelDrillerDataRows } from 'lib/state/pixel-driller'; +import { useRecoilValue } from 'recoil'; + +const columns = [ + { field: 'variable', headerName: 'Variable' }, + { field: 'band_data', headerName: 'Band Data' }, + { field: 'unit', headerName: 'Unit' }, + { field: 'rp', headerName: 'Return Period' }, +]; + +export const PixelDataGrid = ({ hazard }) => { + const headers = useRecoilValue(pixelDrillerDataHeaders); + const rows = useRecoilValue(pixelDrillerDataRows(hazard)); + if (!headers.length) { + return null; + } + return ( + <> +

{hazard}

+ + + ); +}; diff --git a/frontend/src/lib/state/interactions/use-interactions.ts b/frontend/src/lib/state/interactions/use-interactions.ts index d37612b1..4e209eca 100644 --- a/frontend/src/lib/state/interactions/use-interactions.ts +++ b/frontend/src/lib/state/interactions/use-interactions.ts @@ -21,6 +21,7 @@ import { } from './interaction-state'; import { RecoilStateFamily } from 'lib/recoil/types'; import { PickingInfo } from 'deck.gl/typed'; +import { pixelSelectionState } from '../pixel-driller'; function processRasterTarget(info: any): RasterTarget { const { bitmap, sourceLayer } = info; @@ -118,6 +119,7 @@ export function useInteractions( const setInteractionGroupHover = useSetInteractionGroupState(hoverState); const setInteractionGroupSelection = useSetInteractionGroupState(selectionState); + const setPixelSelection = useSetRecoilState(pixelSelectionState); const [primaryGroup] = [...interactionGroups.keys()]; const primaryGroupPickingRadius = interactionGroups.get(primaryGroup).pickingRadius; @@ -176,6 +178,8 @@ export function useInteractions( setInteractionGroupSelection(groupName, selectionTarget); } } + const [lon, lat] = info.coordinate; + setPixelSelection({ lon, lat }); }; /** diff --git a/frontend/src/lib/state/pixel-driller.ts b/frontend/src/lib/state/pixel-driller.ts new file mode 100644 index 00000000..6d21fa49 --- /dev/null +++ b/frontend/src/lib/state/pixel-driller.ts @@ -0,0 +1,113 @@ +import { atom, noWait, RecoilState, RecoilValueReadOnly, selector, selectorFamily } from 'recoil'; + +const parameters = { + cyclone: { + epoch: 2010, + rcp: 'baseline', + confidence: 95, + }, + fluvial: { + rcp: 'baseline', + epoch: 2010, + }, + surface: { + rcp: 'baseline', + epoch: 2010, + }, +}; + +const returnPeriods = [10, 20, 50, 100, 200, 500]; + +type PixelDrillerQueryParams = { + lat: number; + lon: number; +}; + +/** + * Latitude and longitude of the selected map pixel. + */ +export const pixelSelectionState: RecoilState = atom({ + key: 'pixelSelection', + default: { lat: 0, lon: 0 }, +}); + +/** + * Query to fetch hazard data for the selected map pixel. + */ +const pixelDrillerQuery = selector({ + key: 'pixelDrillerQuery', + get: async ({ get }) => { + const { lat, lon } = get(pixelSelectionState); + const response = await fetch(`/pixel/${lon.toFixed(3)}/${lat.toFixed(3)}`); + const data = await response.json(); + return data; + }, +}); + +/** + * Loadable state for the current pixel driller data. + */ +export const pixelDrillerDataState = selector({ + key: 'pixelDrillerDataState', + get: ({ get }) => { + const loadable = get(noWait(pixelDrillerQuery)); + const data = loadable.state === 'hasValue' ? loadable.contents : null; + const error = loadable.state === 'hasError' ? loadable.contents : null; + return { data, error }; + }, +}); + +/** + * Column headers for the pixel driller data tables. + */ +export const pixelDrillerDataHeaders: RecoilValueReadOnly = selector({ + key: 'pixelDrillerDataHeaders', + get: ({ get }) => { + const pixelData = get(pixelDrillerDataState).data; + if (!pixelData) { + return []; + } + const headers = Object.keys(pixelData); + return headers; + }, +}); + +function getPixelDataRows(pixelData, headers: string[], hazard: string) { + const rows = pixelData[headers[0]] + .map((_, rowNumber) => { + const row = { id: rowNumber }; + headers.forEach((header) => { + row[header] = pixelData[header][rowNumber]; + }); + return row; + }) + .map((row) => ({ ...row, band_data: row.band_data?.toFixed(2) })) + .filter((row) => row.hazard === hazard) + .filter((row) => returnPeriods.includes(row.rp)) + .filter((row) => { + if (parameters[hazard]) { + const { rcp, epoch, confidence } = parameters[hazard]; + if (confidence) { + return rcp === rcp && row.epoch === epoch && row.confidence === confidence; + } + return rcp === rcp && row.epoch === epoch; + } + return true; + }); + return rows; +} + +type Row = Record; +/** + * Rows of pixel driller data for a specific hazard, epoch, RCP, and confidence level. + */ +export const pixelDrillerDataRows: (hazard: string) => RecoilValueReadOnly = selectorFamily({ + key: 'pixelDrillerDataRows', + get: + (hazard: string) => + ({ get }) => { + const pixelData = get(pixelDrillerDataState).data; + const headers = get(pixelDrillerDataHeaders); + return getPixelDataRows(pixelData, headers, hazard); + }, +});