From ddb5d3adfd332a0f9089988cfefe0082f358de5a Mon Sep 17 00:00:00 2001 From: Jim O'Donnell Date: Thu, 13 Jun 2024 15:33:51 +0100 Subject: [PATCH] feat(frontend): aggregated risk map layer (risk hotspots) New raster layer showing aggregated risk values for: - variables: "exposure value", "population affected", "loss GDP", "demand affected". - hazards: "none", cyclone, "all flooding". Includes: - New aggregated risk map layer and params in `src/state/layers/modules/risks`. - New config in `src/config/risks`. Parameters defined in `src/config/risks/domains`. - New sidebar controls in `src/sidebar/risks`. - New legends and tooltips in `src/config/risks`. - Makes a start on generic formats for data values eg. financial or population. --- frontend/src/app/config/interaction-groups.ts | 8 ++ frontend/src/app/config/sections.ts | 4 + frontend/src/app/config/view-layers.ts | 1 + frontend/src/app/config/views.ts | 9 ++ frontend/src/app/map/legend/RasterLegend.tsx | 25 ++++-- frontend/src/app/map/legend/VectorLegend.tsx | 9 +- .../content/RasterHoverDescription.tsx | 26 +++++- frontend/src/app/sidebar/SidebarContent.tsx | 10 +++ frontend/src/app/state/data-params.ts | 2 + .../src/data-layers/assets/data-formats.ts | 6 +- .../risks/RiskHoverDescription.tsx | 22 +++++ frontend/src/data-layers/risks/RiskLegend.tsx | 16 ++++ frontend/src/data-layers/risks/color-maps.ts | 18 ++++ frontend/src/data-layers/risks/domains.ts | 66 ++++++++++++++ frontend/src/data-layers/risks/metadata.ts | 32 +++++++ .../src/data-layers/risks/risk-view-layer.ts | 85 +++++++++++++++++++ .../risks/sidebar/RisksControl.tsx | 32 +++++++ .../risks/sidebar/RisksSection.tsx | 19 +++++ frontend/src/data-layers/risks/source.ts | 8 ++ frontend/src/data-layers/risks/state/layer.ts | 19 +++++ frontend/src/data-layers/risks/styles.ts | 20 +++++ frontend/src/lib/helpers.ts | 3 + 22 files changed, 422 insertions(+), 18 deletions(-) create mode 100644 frontend/src/data-layers/risks/RiskHoverDescription.tsx create mode 100644 frontend/src/data-layers/risks/RiskLegend.tsx create mode 100644 frontend/src/data-layers/risks/color-maps.ts create mode 100644 frontend/src/data-layers/risks/domains.ts create mode 100644 frontend/src/data-layers/risks/metadata.ts create mode 100644 frontend/src/data-layers/risks/risk-view-layer.ts create mode 100644 frontend/src/data-layers/risks/sidebar/RisksControl.tsx create mode 100644 frontend/src/data-layers/risks/sidebar/RisksSection.tsx create mode 100644 frontend/src/data-layers/risks/source.ts create mode 100644 frontend/src/data-layers/risks/state/layer.ts create mode 100644 frontend/src/data-layers/risks/styles.ts diff --git a/frontend/src/app/config/interaction-groups.ts b/frontend/src/app/config/interaction-groups.ts index dbdf4c52..8a4173ee 100644 --- a/frontend/src/app/config/interaction-groups.ts +++ b/frontend/src/app/config/interaction-groups.ts @@ -19,6 +19,14 @@ export const INTERACTION_GROUPS = new Map([ pickMultiple: true, }, ], + [ + 'risks', + { + id: 'risks', + type: 'raster', + pickMultiple: false, + }, + ], [ 'regions', { diff --git a/frontend/src/app/config/sections.ts b/frontend/src/app/config/sections.ts index 41c094d7..e40a6cd2 100644 --- a/frontend/src/app/config/sections.ts +++ b/frontend/src/app/config/sections.ts @@ -5,6 +5,7 @@ import { NETWORK_STYLES } from 'data-layers/networks/styles'; import { REGION_STYLES } from 'data-layers/regions/styles'; import { MARINE_STYLES } from 'data-layers/marine/styles'; import { TERRESTRIAL_STYLES } from 'data-layers/terrestrial/styles'; +import { RISK_STYLES } from 'data-layers/risks/styles'; export const SECTIONS_CONFIG: Record }> = { assets: { @@ -14,6 +15,9 @@ export const SECTIONS_CONFIG: Record> = styles: ['type', 'damages'], defaultStyle: 'damages', }, + risks: { + expanded: true, + visible: true, + + styles: Object.keys(RISKS_METADATA), + defaultStyle: Object.keys(RISKS_METADATA)[0], + }, hazards: { expanded: false, visible: true, diff --git a/frontend/src/app/map/legend/RasterLegend.tsx b/frontend/src/app/map/legend/RasterLegend.tsx index 7727cb33..157af346 100644 --- a/frontend/src/app/map/legend/RasterLegend.tsx +++ b/frontend/src/app/map/legend/RasterLegend.tsx @@ -1,4 +1,6 @@ import { FC, useCallback } from 'react'; + +import { numFormatMoney } from 'lib/helpers'; import { GradientLegend } from './GradientLegend'; import { useRasterColorMapValues } from './use-color-map-values'; export interface ColorValue { @@ -10,18 +12,31 @@ export interface RasterColorMapValues { rangeTruncated: [boolean, boolean]; } +const formatter = { + hazard: (value, dataUnit) => `${value.toLocaleString()} ${dataUnit}`, + financial: numFormatMoney, + integer: (value) => + value.toLocaleString(undefined, { + maximumSignificantDigits: 3, + maximumFractionDigits: 0, + roundingPriority: 'lessPrecision', + }), + population: (value) => + value.toLocaleString(undefined, { + maximumSignificantDigits: 3, + }), +}; + export const RasterLegend: FC<{ label: string; dataUnit: string; scheme: string; range: [number, number]; -}> = ({ label, dataUnit, scheme, range }) => { + type?: string; +}> = ({ label, dataUnit, scheme, range, type = 'hazard' }) => { const { error, loading, colorMapValues } = useRasterColorMapValues(scheme, range); - const getValueLabel = useCallback( - (value: number) => `${value.toLocaleString()} ${dataUnit}`, - [dataUnit], - ); + const getValueLabel = (value: number) => formatter[type](value, dataUnit); return ( = ({ @@ -8,15 +8,12 @@ export const VectorLegend: FC<{ colorMap: ColorMap; legendFormatConfig: FormatCo legendFormatConfig, }) => { const { colorSpec, fieldSpec } = colorMap; - const colorMapValues = useMemo(() => colorScaleValues(colorSpec, 255), [colorSpec]); + const colorMapValues = colorScaleValues(colorSpec, 255); const { getDataLabel, getValueFormatted } = legendFormatConfig; const label = getDataLabel(fieldSpec); - const getValueLabel = useMemo( - () => (value) => getValueFormatted(value, fieldSpec), - [fieldSpec, getValueFormatted], - ); + const getValueLabel = (value) => getValueFormatted(value, fieldSpec); return ( value.toFixed(1) + dataUnit, + financial: numFormatMoney, + integer: (value) => + value.toLocaleString(undefined, { + maximumSignificantDigits: 3, + maximumFractionDigits: 0, + roundingPriority: 'lessPrecision', + }), + population: (value) => + value.toLocaleString(undefined, { + maximumSignificantDigits: 3, + }), +}; + +function formatValue(color, value, dataUnit, type) { return ( <> - {value == null ? '' : value.toFixed(1) + dataUnit} + {value == null ? '' : formatter[type](value, dataUnit)} ); } @@ -29,7 +45,8 @@ export const RasterHoverDescription: FC<{ dataUnit: string; scheme: string; range: [number, number]; -}> = ({ color, label, dataUnit, scheme, range }) => { + type?: string; +}> = ({ color, label, dataUnit, scheme, range, type = 'hazard' }) => { const title = `${label}`; const { colorMapValues } = useRasterColorMapValues(scheme, range); @@ -37,9 +54,10 @@ export const RasterHoverDescription: FC<{ const colorString = `rgb(${color[0]},${color[1]},${color[2]})`; const value = rasterValueLookup?.[colorString]; + return ( - + ); }; diff --git a/frontend/src/app/sidebar/SidebarContent.tsx b/frontend/src/app/sidebar/SidebarContent.tsx index ed2dfb9b..6c46c121 100644 --- a/frontend/src/app/sidebar/SidebarContent.tsx +++ b/frontend/src/app/sidebar/SidebarContent.tsx @@ -9,6 +9,7 @@ import { DroughtsSection } from 'data-layers/droughtRisks/sidebar/DroughtsSectio import { HazardsSection } from 'data-layers/hazards/sidebar/HazardsSection'; import { NetworksSection } from 'data-layers/networks/sidebar/NetworksSection'; import { RegionsSection } from 'data-layers/regions/sidebar/RegionsSection'; +import { RisksSection } from 'data-layers/risks/sidebar/RisksSection'; import { MarineSection } from 'data-layers/marine/sidebar/MarineSection'; import { TerrestrialSection } from 'data-layers/terrestrial/sidebar/TerrestrialSection'; import { ErrorBoundary } from 'lib/react/ErrorBoundary'; @@ -25,10 +26,19 @@ const SidebarContent: FC = () => { const view = useRecoilValue(viewState); switch (view) { case 'exposure': + return ( + <> + + + + + + ); case 'risk': return ( <> + diff --git a/frontend/src/app/state/data-params.ts b/frontend/src/app/state/data-params.ts index 9241d588..3076e0f5 100644 --- a/frontend/src/app/state/data-params.ts +++ b/frontend/src/app/state/data-params.ts @@ -1,5 +1,6 @@ import { HAZARD_DOMAINS } from 'data-layers/hazards/domains'; import { NETWORK_DOMAINS } from 'data-layers/networks/domains'; +import { RISK_DOMAINS } from 'data-layers/risks/domains'; import { DataParamGroupConfig, Param, @@ -21,6 +22,7 @@ export type DataParamParam = Readonly<{ export const dataParamConfig: Record = { ...HAZARD_DOMAINS, ...NETWORK_DOMAINS, + risks: RISK_DOMAINS, }; export const dataParamNamesByGroup = mapValues(dataParamConfig, (groupConfig) => diff --git a/frontend/src/data-layers/assets/data-formats.ts b/frontend/src/data-layers/assets/data-formats.ts index 4ce70d03..01afc98f 100644 --- a/frontend/src/data-layers/assets/data-formats.ts +++ b/frontend/src/data-layers/assets/data-formats.ts @@ -9,15 +9,15 @@ function getSourceLabel(eadSource: string) { return HAZARDS_METADATA[eadSource].label; } -function getDamageTypeLabel(field) { +function getDamageTypeLabel(field: string) { if (field === 'ead_mean') return 'Direct Damages'; else if (field === 'eael_mean') return 'Economic Losses'; } -function formatDamageValue(value) { +function formatDamageValue(value: number) { if (isNullish(value)) return value; - return `$${numFormatMoney(value)}`; + return numFormatMoney(value); } const DAMAGES_EXPECTED_DEFAULT_FORMAT: FormatConfig = { getDataLabel: (colorField) => { diff --git a/frontend/src/data-layers/risks/RiskHoverDescription.tsx b/frontend/src/data-layers/risks/RiskHoverDescription.tsx new file mode 100644 index 00000000..4486a2bb --- /dev/null +++ b/frontend/src/data-layers/risks/RiskHoverDescription.tsx @@ -0,0 +1,22 @@ +import { FC } from 'react'; + +import { RasterHoverDescription } from 'lib/data-map/types'; +import { RasterHoverDescription as RasterTooltip } from 'app/map/tooltip/content/RasterHoverDescription'; + +import * as RISKS_COLOR_MAPS from './color-maps'; +import { RISKS_METADATA } from './metadata'; + +export const RiskHoverDescription: FC = ({ target, viewLayer }) => { + const { label, dataUnit, format } = RISKS_METADATA[viewLayer.id]; + const { scheme, range } = RISKS_COLOR_MAPS[viewLayer.id]; + return ( + + ); +}; diff --git a/frontend/src/data-layers/risks/RiskLegend.tsx b/frontend/src/data-layers/risks/RiskLegend.tsx new file mode 100644 index 00000000..432f2175 --- /dev/null +++ b/frontend/src/data-layers/risks/RiskLegend.tsx @@ -0,0 +1,16 @@ +import { FC } from 'react'; + +import { RasterLegend } from 'app/map/legend/RasterLegend'; +import { ViewLayer } from 'lib/data-map/view-layers'; + +import * as RISKS_COLOR_MAPS from './color-maps'; +import { RISKS_METADATA } from './metadata'; + +export const RiskLegend: FC<{ viewLayer: ViewLayer }> = ({ viewLayer }) => { + const { id } = viewLayer; + const { label, dataUnit, format } = RISKS_METADATA[id]; + const { scheme, range } = RISKS_COLOR_MAPS[id]; + return ( + + ); +}; diff --git a/frontend/src/data-layers/risks/color-maps.ts b/frontend/src/data-layers/risks/color-maps.ts new file mode 100644 index 00000000..e03462b9 --- /dev/null +++ b/frontend/src/data-layers/risks/color-maps.ts @@ -0,0 +1,18 @@ +export const exposureValue = { + scheme: 'reds', + range: [0, 1e9], +}; +export const lossGdp = { + scheme: 'blues', + range: [0, 5e6], +}; + +export const populationAffected = { + scheme: 'purples', + range: [0, 1e4], +}; + +export const demandAffected = { + scheme: 'greens', + range: [0, 1e3], +}; diff --git a/frontend/src/data-layers/risks/domains.ts b/frontend/src/data-layers/risks/domains.ts new file mode 100644 index 00000000..01b9a1a7 --- /dev/null +++ b/frontend/src/data-layers/risks/domains.ts @@ -0,0 +1,66 @@ +import { DataParamGroupConfig } from 'lib/controls/data-params'; + +export interface RiskParams { + hazard: string; + returnPeriod: number; + epoch: number; + rcp: string; + confidence: string | number; +} + +/* + Default parameter ranges for each hazard type. + These are used to define ranges for input controls in the sidebar. +*/ +const hazardParamDomains = { + none: { + returnPeriod: [0], + epoch: [2010], + rcp: ['baseline'], + confidence: ['None'], + }, + cyclone: { + returnPeriod: [0], + epoch: [2010], + rcp: ['baseline'], + confidence: ['None'], + }, + fluvial: { + returnPeriod: [0], + epoch: [2010], + rcp: ['baseline'], + confidence: ['None'], + }, +}; + +export const RISK_DOMAINS: DataParamGroupConfig = { + /* + Default parameter ranges for each risk type. + */ + paramDomains: { + hazard: ['none', 'cyclone', 'fluvial'], + returnPeriod: [0], + epoch: [2010], + rcp: ['baseline'], + confidence: ['None'], + }, + /* + Default parameter values for each risk type. + */ + paramDefaults: { + hazard: 'none', + returnPeriod: 0, + epoch: 2010, + rcp: 'baseline', + confidence: 'None', + }, + /* + Callback functions to define custom parameter ranges based on selected hazard etc. + */ + paramDependencies: { + rcp: ({ hazard }) => hazardParamDomains[hazard].rcp, + epoch: ({ hazard }) => hazardParamDomains[hazard].epoch, + returnPeriod: ({ hazard }) => hazardParamDomains[hazard].returnPeriod, + confidence: ({ hazard }) => hazardParamDomains[hazard].confidence, + }, +}; diff --git a/frontend/src/data-layers/risks/metadata.ts b/frontend/src/data-layers/risks/metadata.ts new file mode 100644 index 00000000..a7b0c331 --- /dev/null +++ b/frontend/src/data-layers/risks/metadata.ts @@ -0,0 +1,32 @@ +export const HAZARDS_METADATA = { + none: { label: 'None' }, + cyclone: { label: 'Cyclone' }, + fluvial: { label: 'All Flooding' }, +}; + +export const HAZARDS = Object.keys(HAZARDS_METADATA); + +export const RISKS_METADATA = { + demandAffected: { + label: 'Demand affected', + dataUnit: '', + format: 'integer', + }, + exposureValue: { + label: 'Exposure value', + dataUnit: '', + format: 'financial', + }, + populationAffected: { + label: 'Population affected', + dataUnit: '', + format: 'population', + }, + lossGdp: { + label: 'Loss GDP', + dataUnit: '', + format: 'financial', + }, +}; + +export const RISKS = Object.keys(RISKS_METADATA); diff --git a/frontend/src/data-layers/risks/risk-view-layer.ts b/frontend/src/data-layers/risks/risk-view-layer.ts new file mode 100644 index 00000000..781c5c9d --- /dev/null +++ b/frontend/src/data-layers/risks/risk-view-layer.ts @@ -0,0 +1,85 @@ +import { createElement } from 'react'; +import GL from '@luma.gl/constants'; +import { RiskParams } from './domains'; + +import { rasterTileLayer } from 'lib/deck/layers/raster-tile-layer'; +import { ViewLayer } from 'lib/data-map/view-layers'; +import { RasterTarget } from 'lib/data-map/types'; + +import { RiskLegend } from './RiskLegend'; +import { RiskHoverDescription } from './RiskHoverDescription'; +import * as RISKS_COLOR_MAPS from './color-maps'; +import { RISK_SOURCE } from './source'; + +export function getRiskId< + F extends string, // risk variable + RP extends number, + RCP extends string, + E extends number, + C extends number | string, +>({ + riskType, + hazard, + returnPeriod, + rcp, + epoch, + confidence, +}: { + riskType: F; + hazard: string; + returnPeriod: RP; + rcp: RCP; + epoch: E; + confidence: C; +}) { + return `${riskType}__${hazard}__rp_${returnPeriod}__rcp_${rcp}__epoch_${epoch}__conf_${confidence}` as const; +} + +export function riskViewLayer(riskType: string, riskParams: RiskParams): ViewLayer { + const { hazard, returnPeriod, rcp, epoch, confidence } = riskParams; + + const deckId = getRiskId({ riskType, hazard, returnPeriod, rcp, epoch, confidence }); + + return { + id: riskType, + group: 'risks', + spatialType: 'raster', + interactionGroup: 'risks', + params: { riskType, riskParams }, + fn: ({ deckProps }) => { + const { scheme, range } = RISKS_COLOR_MAPS[riskType]; + const dataURL = RISK_SOURCE.getDataUrl( + { + riskType, + riskParams: { hazard, returnPeriod, rcp, epoch, confidence }, + }, + { scheme, range }, + ); + + return rasterTileLayer( + { + textureParameters: { + [GL.TEXTURE_MAG_FILTER]: GL.LINEAR, + // [GL.TEXTURE_MAG_FILTER]: zoom < 12 ? GL.NEAREST : GL.NEAREST_MIPMAP_LINEAR, + }, + opacity: riskType === 'cyclone' ? 0.6 : 1, + }, + deckProps, + { + id: `${riskType}@${deckId}`, // follow the convention viewLayerId@deckLayerId + data: dataURL, + refinementStrategy: 'no-overlap', + }, + ); + }, + renderLegend() { + return createElement(RiskLegend, { + key: riskType, + viewLayer: this, + }); + }, + renderTooltip({ target }: { target: RasterTarget }) { + return createElement(RiskHoverDescription, { key: this.id, target, viewLayer: this }); + }, + }; +} diff --git a/frontend/src/data-layers/risks/sidebar/RisksControl.tsx b/frontend/src/data-layers/risks/sidebar/RisksControl.tsx new file mode 100644 index 00000000..73e8d0f9 --- /dev/null +++ b/frontend/src/data-layers/risks/sidebar/RisksControl.tsx @@ -0,0 +1,32 @@ +import { FormControl, FormControlLabel, FormLabel, Radio, RadioGroup } from '@mui/material'; +import { useRecoilState, useRecoilValue } from 'recoil'; + +import { InputSection } from 'app/sidebar/ui/InputSection'; +import { sectionStyleOptionsState, sectionStyleValueState } from 'app/state/sections'; + +export const RisksControl = () => { + const [value, setValue] = useRecoilState(sectionStyleValueState('risks')); + const options = useRecoilValue(sectionStyleOptionsState('risks')); + function onChange(event, value) { + setValue(value); + } + + return ( + <> + + + Risk + + {options.map((option) => ( + } + /> + ))} + + + + + ); +}; diff --git a/frontend/src/data-layers/risks/sidebar/RisksSection.tsx b/frontend/src/data-layers/risks/sidebar/RisksSection.tsx new file mode 100644 index 00000000..14eef3b1 --- /dev/null +++ b/frontend/src/data-layers/risks/sidebar/RisksSection.tsx @@ -0,0 +1,19 @@ +import { FC } from 'react'; + +import { SidebarPanel } from 'app/sidebar/SidebarPanel'; +import { SidebarPanelSection } from 'app/sidebar/ui/SidebarPanelSection'; +import { ErrorBoundary } from 'lib/react/ErrorBoundary'; + +import { RisksControl } from './RisksControl'; + +export const RisksSection: FC = () => { + return ( + + + + + + + + ); +}; diff --git a/frontend/src/data-layers/risks/source.ts b/frontend/src/data-layers/risks/source.ts new file mode 100644 index 00000000..45aa4d57 --- /dev/null +++ b/frontend/src/data-layers/risks/source.ts @@ -0,0 +1,8 @@ +export const RISK_SOURCE = { + getDataUrl( + { riskType, riskParams: { hazard, returnPeriod, rcp, epoch, confidence } }, + { scheme, range }, + ) { + return `/raster/singleband/${riskType}/100/baseline/2010/None/{z}/{x}/{y}.png?colormap=${scheme}&stretch_range=[${range[0]},${range[1]}]`; + }, +}; diff --git a/frontend/src/data-layers/risks/state/layer.ts b/frontend/src/data-layers/risks/state/layer.ts new file mode 100644 index 00000000..9298df35 --- /dev/null +++ b/frontend/src/data-layers/risks/state/layer.ts @@ -0,0 +1,19 @@ +import { ViewLayer } from 'lib/data-map/view-layers'; +import { selector } from 'recoil'; +import { dataParamsByGroupState } from 'app/state/data-params'; +import { sectionVisibilityState } from 'app/state/sections'; +import { sectionStyleValueState } from 'app/state/sections'; + +import { RiskParams } from '../domains'; +import { riskViewLayer } from '../risk-view-layer'; + +export const risksLayerState = selector({ + key: 'risksLayerState', + get: ({ get }) => { + const risksStyle = get(sectionStyleValueState('risks')); + const dataParams = get(dataParamsByGroupState('risks')); + return get(sectionVisibilityState('risks')) + ? [riskViewLayer(risksStyle, dataParams as RiskParams)] + : []; + }, +}); diff --git a/frontend/src/data-layers/risks/styles.ts b/frontend/src/data-layers/risks/styles.ts new file mode 100644 index 00000000..f7e6dcc9 --- /dev/null +++ b/frontend/src/data-layers/risks/styles.ts @@ -0,0 +1,20 @@ +import { makeConfig } from 'lib/helpers'; + +export const RISK_STYLES = makeConfig([ + { + id: 'demandAffected', + label: 'Demand Affected', + }, + { + id: 'exposureValue', + label: 'Exposure Value', + }, + { + id: 'populationAffected', + label: 'Population Affected', + }, + { + id: 'lossGdp', + label: 'Loss GDP', + }, +]); diff --git a/frontend/src/lib/helpers.ts b/frontend/src/lib/helpers.ts index c55c63f2..c1ce5790 100644 --- a/frontend/src/lib/helpers.ts +++ b/frontend/src/lib/helpers.ts @@ -28,6 +28,9 @@ export function numFormatMoney(value: number) { return value.toLocaleString(undefined, { maximumSignificantDigits: 3, maximumFractionDigits: 2, + style: 'currency', + currency: 'JMD', + currencyDisplay: 'narrowSymbol', }); }