diff --git a/frontend/src/components/MapView/Layers/GeojsonDataLayer/index.tsx b/frontend/src/components/MapView/Layers/GeojsonDataLayer/index.tsx new file mode 100644 index 000000000..0c5f85d7e --- /dev/null +++ b/frontend/src/components/MapView/Layers/GeojsonDataLayer/index.tsx @@ -0,0 +1,61 @@ +import { memo, useEffect } from 'react'; +import { Layer, Source } from 'react-map-gl/maplibre'; + +import { useDispatch, useSelector } from 'react-redux'; +import { GeojsonDataLayerProps, LegendDefinition } from 'config/types'; + +import { LayerData, loadLayerData } from 'context/layers/layer-data'; +import { layerDataSelector } from 'context/mapStateSlice/selectors'; +import { getLayerMapId } from 'utils/map-utils'; +import { opacitySelector } from 'context/opacityStateSlice'; +import { FillLayerSpecification } from 'maplibre-gl'; + +const paintProps: ( + legend: LegendDefinition, + opacity: number | undefined, +) => FillLayerSpecification['paint'] = ( + legend: LegendDefinition, + opacity?: number, +) => ({ + 'fill-opacity': opacity || 1, + 'fill-color': { + property: 'level', + type: 'categorical', + stops: legend.map(({ value, color }) => [value, color]), + }, +}); + +// Polygon Data, takes any GeoJSON of polygons and shows it. +const GeojsonDataLayer = memo(({ layer, before }: LayersProps) => { + const dispatch = useDispatch(); + const layerId = getLayerMapId(layer.id); + const opacityState = useSelector(opacitySelector(layer.id)); + + const layerData = useSelector(layerDataSelector(layer.id)) as + | LayerData + | undefined; + + const { data } = layerData || {}; + + useEffect(() => { + dispatch(loadLayerData({ layer })); + }, [dispatch, layer]); + + return ( + + + + ); +}); + +export interface LayersProps { + layer: GeojsonDataLayerProps; + before?: string; +} + +export default GeojsonDataLayer; diff --git a/frontend/src/components/MapView/Map/index.tsx b/frontend/src/components/MapView/Map/index.tsx index 39750b5ca..3439dcba5 100644 --- a/frontend/src/components/MapView/Map/index.tsx +++ b/frontend/src/components/MapView/Map/index.tsx @@ -41,6 +41,7 @@ import { MapSourceDataEvent, Map as MaplibreMap } from 'maplibre-gl'; import 'maplibre-gl/dist/maplibre-gl.css'; import { Panel, leftPanelTabValueSelector } from 'context/leftPanelStateSlice'; import { mapStyle } from './utils'; +import GeojsonDataLayer from '../Layers/GeojsonDataLayer'; interface MapComponentProps { setIsAlertFormOpen: Dispatch>; @@ -58,6 +59,7 @@ const componentTypes: LayerComponentsMap = { admin_level_data: { component: AdminLevelDataLayer }, impact: { component: ImpactLayer }, point_data: { component: PointDataLayer }, + geojson_polygon: { component: GeojsonDataLayer }, static_raster: { component: StaticRasterLayer }, composite: { component: CompositeLayer }, anticipatory_action: { @@ -76,6 +78,7 @@ const MapComponent = memo(({ setIsAlertFormOpen }: MapComponentProps) => { const { selectedLayers, boundaryLayerId } = useLayers(); + console.log(selectedLayers); const selectedMap = useSelector(mapSelector); const tabValue = useSelector(leftPanelTabValueSelector); diff --git a/frontend/src/config/cambodia/layers.json b/frontend/src/config/cambodia/layers.json index 77481b8f7..6a20c844c 100644 --- a/frontend/src/config/cambodia/layers.json +++ b/frontend/src/config/cambodia/layers.json @@ -1988,5 +1988,92 @@ } ], "legend_text": "Index meausring a household’s social and economic capacities and resilience to cope with, adapt to and recover from the floods and droughts. Aggregated at Commune level" + }, + "google_flood_status_at_gauges": { + "title": "Flood Status at Gauges (Google AI)", + "type": "point_data", + "loader": "google_flood", + "hex_display": false, + "data": "http://0.0.0.0:80/google-floods/gauges/?region_codes=KH", + "detail_url": "http://0.0.0.0:80/google-floods/gauges/forecasts", + "data_field": "severity", + "data_field_type": "text", + "opacity": 0.9, + "legend_text": "Current flood status at verified gauges. Visit [Google Research](https://sites.research.google/floodforecasting/) about Google's AI Forecasting models.", + "legend": [ + { "label": "Extreme", "value": "EXTREME", "color": "#a70606" }, + { "label": "Danger", "value": "SEVERE", "color": "#ea250a" }, + { "label": "Warning", "value": "ABOVE_NORMAL", "color": "#fba705" }, + { "label": "Normal", "value": "NO_FLOODING", "color": "#089180" }, + { "label": "Unknown", "value": "UNKNOWN", "color": "#858585" } + ], + "feature_info_title": { + "siteName": { + "type": "text", + "template": "Site: {{siteName}}", + "visibility": "if-defined" + }, + "riverName": { + "type": "text", + "template": "River: {{riverName}}", + "visibility": "if-defined" + }, + "gaugeId": { + "type": "text", + "template": "Gauge ID: {{gaugeId}}", + "visibility": "if-defined" + } + }, + "feature_info_props": { + "siteName": { + "type": "text", + "dataTitle": "Site", + "visibility": "if-defined" + }, + "river": { + "type": "text", + "dataTitle": "River", + "visibility": "if-defined" + }, + "severity": { + "type": "labelMapping", + "dataTitle": "Flood Status", + "labelMap": { + "EXTREME": "Extreme", + "SEVERE": "Danger", + "ABOVE_NORMAL": "Warning", + "NO_FLOODING": "Normal", + "UNKNOWN": "Unknown" + } + }, + "gaugeId": { + "type": "text", + "dataTitle": "Gauge ID" + }, + "source": { + "type": "text", + "dataTitle": "Data source" + }, + "issuedTime": { + "type": "date", + "dataTitle": "Status issued" + } + } + }, + "google_flood_inundation_maps": { + "title": "Current Flood Inundation Map (Google AI)", + "type": "geojson_polygon", + "loader": "google_flood", + "hex_display": false, + "data": "http://0.0.0.0:80/google-floods/inundations/?region_codes=KH", + "data_field": "level", + "data_field_type": "text", + "opacity": 0.9, + "legend_text": "Current predicted flood maps. Visit [Google Research](https://sites.research.google/floodforecasting/) about Google's AI Forecasting models.", + "legend": [ + { "label": "High", "value": "HIGH", "color": "#00408A" }, + { "label": "Medium", "value": "MEDIUM", "color": "#5296E5" }, + { "label": "Low", "value": "LOW", "color": "#B5D2F5" } + ] } } diff --git a/frontend/src/config/cambodia/prism.json b/frontend/src/config/cambodia/prism.json index 5fa399a30..bf53211b7 100644 --- a/frontend/src/config/cambodia/prism.json +++ b/frontend/src/config/cambodia/prism.json @@ -209,6 +209,25 @@ }, "field_reports": { "field_reports": ["flood_report", "drought_report", "kh_incident_report"] + }, + "flooding": { + "flood_status": [ + { + "group_title": "Flood Status", + "activate_all": true, + "layers": [ + { + "id": "google_flood_status_at_gauges", + "label": "Flood Gauges", + "main": true + }, + { + "id": "google_flood_inundation_maps", + "label": "Inundation Maps" + } + ] + } + ] } } } diff --git a/frontend/src/config/types.ts b/frontend/src/config/types.ts index 69d91994d..a8a1f3de0 100644 --- a/frontend/src/config/types.ts +++ b/frontend/src/config/types.ts @@ -26,7 +26,8 @@ export type LayerType = | PointDataLayerProps | CompositeLayerProps | StaticRasterLayerProps - | AnticipatoryActionLayerProps; + | AnticipatoryActionLayerProps + | GeojsonDataLayerProps; type UnionToIntersection = (U extends any ? (k: U) => void : never) extends ( k: infer I, @@ -740,6 +741,29 @@ export class PointDataLayerProps extends CommonLayerProps { detailUrl?: string; } +export class GeojsonDataLayerProps extends CommonLayerProps { + type: 'geojson_polygon' = 'geojson_polygon'; + data: string; + + @makeRequired + dataField: string; + + @optional // if legend_label, uses the label from legend to display in feature info. if not, uses dataField + displaySource?: 'legend_label' | 'data_field'; + + @makeRequired + declare title: string; + + @makeRequired + declare legend: LegendDefinition; + + @makeRequired + declare legendText: string; + + @optional + additionalQueryParams?: { [key: string]: string | { [key: string]: string } }; +} + export type RequiredKeys = { [k in keyof T]: undefined extends T[k] ? never : k; }[keyof T]; @@ -890,6 +914,7 @@ export type PointData = { }; export type PointLayerData = FeatureCollection; +export type GeojsonLayerData = FeatureCollection; export interface BaseLayer { name: string; diff --git a/frontend/src/config/utils.ts b/frontend/src/config/utils.ts index 0250ff1f7..2ed5fc954 100644 --- a/frontend/src/config/utils.ts +++ b/frontend/src/config/utils.ts @@ -6,6 +6,7 @@ import { BoundaryLayerProps, checkRequiredKeys, CompositeLayerProps, + GeojsonDataLayerProps, ImpactLayerProps, LayerKey, LayersMap, @@ -130,6 +131,11 @@ export const getLayerByKey = (layerKey: LayerKey): LayerType => { return throwInvalidLayer(); } return definition; + case 'geojson_polygon': + if (!checkRequiredKeys(GeojsonDataLayerProps, definition, true)) { + return throwInvalidLayer(); + } + return definition; default: // doesn't do anything, but it helps catch any layer type cases we forgot above compile time via TS. // https://stackoverflow.com/questions/39419170/how-do-i-check-that-a-switch-block-is-exhaustive-in-typescript diff --git a/frontend/src/context/layers/geojson.ts b/frontend/src/context/layers/geojson.ts new file mode 100644 index 000000000..233896e89 --- /dev/null +++ b/frontend/src/context/layers/geojson.ts @@ -0,0 +1,38 @@ +import GeoJSON from 'geojson'; +import { GeojsonDataLayerProps } from 'config/types'; +import { queryParamsToString } from 'utils/url-utils'; +import { fetchWithTimeout } from 'utils/fetch-with-timeout'; +import { HTTPError } from 'utils/error-utils'; +import { setUserAuthGlobal } from 'context/serverStateSlice'; +import type { LazyLoader } from './layer-data'; + +export const fetchGeojsonLayerData: LazyLoader = + () => + async ({ layer: { data: dataUrl, additionalQueryParams } }, { dispatch }) => { + const requestUrl = `${dataUrl}${ + dataUrl.includes('?') ? '&' : '?' + }&${queryParamsToString(additionalQueryParams)}`; + let data; + let response: Response; + // TODO - Better error handling, esp. for unauthorized requests. + try { + // eslint-disable-next-line fp/no-mutation + response = await fetchWithTimeout( + requestUrl, + dispatch, + { + mode: 'cors', + }, + `Request failed for fetching point layer data at ${requestUrl}`, + ); + // eslint-disable-next-line fp/no-mutation + data = (await response.json()) as GeoJSON.FeatureCollection; + } catch (error) { + if ((error as HTTPError)?.statusCode === 401) { + dispatch(setUserAuthGlobal(undefined)); + } + throw error; + } + + return data as any as GeoJSON.FeatureCollection; + }; diff --git a/frontend/src/context/layers/layer-data.ts b/frontend/src/context/layers/layer-data.ts index 85ea2f4b7..6da20c035 100644 --- a/frontend/src/context/layers/layer-data.ts +++ b/frontend/src/context/layers/layer-data.ts @@ -3,6 +3,7 @@ import { AnticipatoryActionLayerProps, DateItem, DiscriminateUnion, + GeojsonLayerData, LayerType, PointLayerData, StaticRasterLayerProps, @@ -20,6 +21,7 @@ import { BoundaryLayerData, fetchBoundaryLayerData } from './boundary'; import { fetchImpactLayerData, ImpactLayerData } from './impact'; import type { CompositeLayerData } from './composite_data'; import { fetchCompositeLayerData } from './composite_data'; +import { fetchGeojsonLayerData } from './geojson'; export type LayerAcceptingDataType = Exclude< LayerType, @@ -35,6 +37,7 @@ type LayerSpecificDataTypes = { // eslint-disable-next-line camelcase point_data: PointLayerData | AdminLevelDataLayerData; composite: CompositeLayerData; + geojson_polygon: GeojsonLayerData; }; export interface LayerData { @@ -97,6 +100,7 @@ export const loadLayerData: LoadLayerDataFuncType = createAsyncThunk< admin_level_data: fetchAdminLevelDataLayerData, point_data: fetchPointLayerData, composite: fetchCompositeLayerData, + geojson_polygon: fetchGeojsonLayerData, }; const lazyLoad: LazyLoader = layerLoaders[layer.type]; try { diff --git a/frontend/src/context/opacityStateSlice.ts b/frontend/src/context/opacityStateSlice.ts index 8d8aeef50..70ae414db 100644 --- a/frontend/src/context/opacityStateSlice.ts +++ b/frontend/src/context/opacityStateSlice.ts @@ -51,6 +51,7 @@ export const opacityStateSlice = createSlice({ case 'admin_level_data': case 'composite': case 'impact': + case 'geojson_polygon': return [getLayerMapId(layerId), 'fill-opacity']; case 'point_data': // This is a hacky way to support opacity change for Kobo data.