From 9f2552736e12a4ce55539409be609812f8b7b91c Mon Sep 17 00:00:00 2001 From: Jim O'Donnell Date: Fri, 24 Jan 2025 15:57:50 +0000 Subject: [PATCH] feat: pixel driller frontend --- frontend/dev-proxy/proxy-table.dev.json | 7 + frontend/dev-proxy/proxy-table.docker.json | 7 + frontend/package-lock.json | 44 ++++ frontend/package.json | 1 + frontend/src/details/DetailsSidebar.tsx | 4 + frontend/src/details/pixel-data/PixelData.tsx | 50 +++++ .../src/details/pixel-data/PixelDataGrid.tsx | 34 +++ .../state/interactions/use-interactions.ts | 4 + frontend/src/lib/state/pixel-driller.ts | 200 ++++++++++++++++++ 9 files changed, 351 insertions(+) create mode 100644 frontend/src/details/pixel-data/PixelData.tsx create mode 100644 frontend/src/details/pixel-data/PixelDataGrid.tsx create mode 100644 frontend/src/lib/state/pixel-driller.ts 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..4f40c962 --- /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)]; + + 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..9481a836 --- /dev/null +++ b/frontend/src/details/pixel-data/PixelDataGrid.tsx @@ -0,0 +1,34 @@ +import { DataGrid } from '@mui/x-data-grid'; + +import { pixelDrillerDataHeaders, pixelDrillerDataRows } from 'lib/state/pixel-driller'; +import { useRecoilValue } from 'recoil'; + +const columns = [ + { field: 'epoch', headerName: 'Epoch' }, + { field: 'rcp', headerName: 'RCP' }, +]; + +const returnPeriods = [10, 20, 50, 100, 200, 500]; + +returnPeriods.forEach((rp) => { + columns.push({ field: `rp-${rp}`, headerName: `RP ${rp}` }); +}); + +export const PixelDataGrid = ({ hazard }) => { + const headers = useRecoilValue(pixelDrillerDataHeaders); + const rows = useRecoilValue(pixelDrillerDataRows(hazard)); + if (!headers.length) { + return null; + } + const variable = rows[0].variable; + const unit = rows[0].unit; + + return ( + <> +

+ {hazard}: {variable} ({unit}) +

+ + + ); +}; 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..43afb27d --- /dev/null +++ b/frontend/src/lib/state/pixel-driller.ts @@ -0,0 +1,200 @@ +import { atom, noWait, RecoilState, RecoilValueReadOnly, selector, selectorFamily } from 'recoil'; + +const parameters = [ + { + epoch: 2010, + rcp: 'baseline', + }, + { + epoch: 2050, + rcp: '4.5', + }, + { + epoch: 2050, + rcp: '8.5', + }, + { + epoch: 2070, + rcp: '4.5', + }, + { + epoch: 2070, + rcp: '8.5', + }, + { + epoch: 2080, + rcp: '4.5', + }, + { + epoch: 2080, + rcp: '8.5', + }, + { + epoch: 2100, + rcp: '4.5', + }, + { + epoch: 2100, + rcp: '8.5', + }, +]; + +const returnPeriods = [10, 20, 50, 100, 200, 500]; + +type PixelDrillerQueryParams = { + lat: number; + lon: number; +}; + +type PixelData = { + band_data: number[]; + confidence: number[]; + epoch: number[]; + hazard: string[]; + key: string[]; + rcp: string[]; + rp: number[]; + unit: string[]; + variable: string[]; +}; + +/** + * 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: PixelData = 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 mapDataArraysToRowObjects(data: PixelData) { + const keys = Object.keys(data); + return data[keys[0]].map((_, rowNumber) => { + const row = { id: rowNumber }; + keys.forEach((key) => { + row[key] = data[key][rowNumber]; + }); + return row; + }); +} + +/** + * Filter pixel data by hazard, epoch, RCP, and confidence level. + * @param pixelData + * @param headers + * @param hazard + * @param epoch + * @param rcp + * @param confidence + * @returns + */ +function getFilteredPixelData( + pixelData: PixelData, + hazard: string, + epoch: number, + rcp: string, + confidence?: number, +) { + const rows: Row[] = mapDataArraysToRowObjects(pixelData) + .map((row) => ({ ...row, band_data: row.band_data?.toFixed(2) })) + .filter((row) => row.hazard === hazard) + .filter((row) => returnPeriods.includes(row.rp)) + .filter((row) => { + if (rcp && epoch) { + if (confidence) { + return row.rcp === rcp && row.epoch === epoch && row.confidence === confidence; + } + return row.rcp === rcp && row.epoch === epoch; + } + return true; + }); + return rows; +} + +/** + * Reduce a set of data rows down to a single row with multiple RP columns. + * @param data + * @param hazard + * @param epoch + * @param rcp + * @returns + */ +function reducePixelDataRow(data: Row[], hazard: string, epoch: number, rcp: string) { + if (!data.length) { + return null; + } + const { variable, unit } = data[0]; + const row = { + id: `${hazard}-${epoch}-${rcp}`, + variable, + unit, + hazard, + epoch, + rcp, + }; + data.forEach((d) => { + row[`rp-${d.rp}`] = d.band_data; + }); + return row; +} + +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; + console.log(pixelData); + return parameters + .map(({ epoch, rcp }) => { + const confidence = hazard === 'cyclone' ? 95 : null; + const data = getFilteredPixelData(pixelData, hazard, epoch, rcp, confidence); + return reducePixelDataRow(data, hazard, epoch, rcp); + }) + .filter(Boolean); + }, +});