Skip to content

Commit fb05a79

Browse files
committed
feat(active-operation): update active operation map
- Create new component for active operation map Container - Add tabs for crisis categorization and appeals type map - Add new base point layer for crisis categorization
1 parent 5dd59da commit fb05a79

5 files changed

Lines changed: 613 additions & 394 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"namespace": "activeOperationMap",
3+
"strings": {
4+
"operationPopoverPeopleAffected": "People Targeted",
5+
"operationPopoverAmountRequested": "Amount Requested (CHF)",
6+
"operationPopoverAmountFunded": "Amount Funded (CHF)",
7+
"operationPopoverEmpty": "No Current Operations",
8+
"explanationBubbleScalePoints": "Scale points by",
9+
"lastUpdateLabel": "Last update"
10+
}
11+
}
Lines changed: 347 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,347 @@
1+
import {
2+
useCallback,
3+
useMemo,
4+
useState,
5+
} from 'react';
6+
import {
7+
Container,
8+
LegendItem,
9+
ListView,
10+
RadioInput,
11+
TextOutput,
12+
} from '@ifrc-go/ui';
13+
import { useTranslation } from '@ifrc-go/ui/hooks';
14+
import { sumSafe } from '@ifrc-go/ui/utils';
15+
import {
16+
compareNumber,
17+
isDefined,
18+
isNotDefined,
19+
listToGroupList,
20+
mapToMap,
21+
unique,
22+
} from '@togglecorp/fujs';
23+
import {
24+
MapBounds,
25+
MapLayer,
26+
MapSource,
27+
} from '@togglecorp/re-map';
28+
import { type LngLatBoundsLike } from 'mapbox-gl';
29+
30+
import GoMapContainer from '#components/GoMapContainer';
31+
import Link from '#components/Link';
32+
import MapPopup from '#components/MapPopup';
33+
import useCountryRaw from '#hooks/domain/useCountryRaw';
34+
import useInputState from '#hooks/useInputState';
35+
import {
36+
DEFAULT_MAP_PADDING,
37+
DURATION_MAP_ZOOM,
38+
} from '#utils/constants';
39+
import { type GoApiResponse } from '#utils/restRequest';
40+
41+
import GlobalMap, { type AdminZeroFeatureProperties } from '../../GlobalMap';
42+
import {
43+
APPEAL_TYPE_MULTIPLE,
44+
basePointLayerOptions,
45+
DISASTER_UNCATEGORISED,
46+
optionKeySelector,
47+
optionLabelSelector,
48+
outerCircleLayerOptionsForFinancialRequirements,
49+
outerCircleLayerOptionsForPeopleTargeted,
50+
type ScaleOption,
51+
severityOrderMapping,
52+
} from '../utils';
53+
54+
import i18n from './i18n.json';
55+
56+
type AppealResponse = GoApiResponse<'/api/v2/appeal/'>;
57+
58+
const sourceOptions: mapboxgl.GeoJSONSourceRaw = {
59+
type: 'geojson',
60+
};
61+
62+
type LegendOptions = {
63+
value: number;
64+
label: string;
65+
color: string;
66+
}
67+
68+
interface ClickedPoint {
69+
featureProperties: AdminZeroFeatureProperties;
70+
lngLat: mapboxgl.LngLatLike;
71+
}
72+
73+
interface Props {
74+
variant: 'crisis' | 'appeal';
75+
presentationModeAdditionalBeforeContent?: React.ReactNode;
76+
presentationModeAdditionalAfterContent?: React.ReactNode;
77+
mapTitle: string;
78+
bbox: LngLatBoundsLike | undefined;
79+
appealResponse?: AppealResponse;
80+
legendOptions: LegendOptions[];
81+
onPresentationModeChange: (presentation: boolean) => void;
82+
scaleOptions: ScaleOption[];
83+
}
84+
85+
function OperationMapContainer(props: Props) {
86+
const {
87+
variant,
88+
presentationModeAdditionalBeforeContent,
89+
presentationModeAdditionalAfterContent,
90+
mapTitle,
91+
bbox,
92+
appealResponse,
93+
legendOptions,
94+
onPresentationModeChange,
95+
scaleOptions,
96+
} = props;
97+
98+
const [scaleBy, setScaleBy] = useInputState<ScaleOption['value']>('peopleTargeted');
99+
const strings = useTranslation(i18n);
100+
101+
const countryResponse = useCountryRaw();
102+
103+
const [clickedPoint, setClickedPoint] = useState<ClickedPoint | undefined>();
104+
105+
const countryGroupedAppeal = useMemo(
106+
() => listToGroupList(
107+
appealResponse?.results ?? [],
108+
(appeal) => appeal.country.iso3 ?? '<no-key>',
109+
),
110+
[appealResponse],
111+
);
112+
113+
const countryCentroidGeoJson = useMemo((): GeoJSON.FeatureCollection<GeoJSON.Geometry> => {
114+
const countryToOperationTypeMap = mapToMap(
115+
countryGroupedAppeal,
116+
(key) => key,
117+
(appealList) => {
118+
const uniqueAppealList = unique(
119+
appealList.map((appeal) => appeal.atype),
120+
);
121+
122+
const uniqueEventList = unique(
123+
appealList.map((severity) => severity.event_details
124+
?.ifrc_severity_level).filter(isDefined),
125+
);
126+
127+
const peopleTargeted = sumSafe(
128+
appealList.map((appeal) => appeal.num_beneficiaries),
129+
);
130+
const financialRequirements = sumSafe(
131+
appealList.map((appeal) => appeal.amount_requested),
132+
);
133+
134+
const severityLevel = (() => {
135+
if (uniqueEventList.length > 1) {
136+
const highestSeverity = uniqueEventList.sort((a, b) => (
137+
compareNumber(severityOrderMapping[a], severityOrderMapping[b])
138+
));
139+
return highestSeverity[0];
140+
}
141+
if (uniqueEventList.length === 0) return DISASTER_UNCATEGORISED;
142+
return uniqueEventList[0];
143+
});
144+
145+
const appealType = (() => {
146+
if (uniqueAppealList.length > 1) return APPEAL_TYPE_MULTIPLE;
147+
return uniqueAppealList[0];
148+
});
149+
150+
return {
151+
appealType: appealType(),
152+
severityLevel: severityLevel(),
153+
peopleTargeted,
154+
financialRequirements,
155+
};
156+
},
157+
);
158+
159+
return {
160+
type: 'FeatureCollection' as const,
161+
features:
162+
countryResponse
163+
?.map((country) => {
164+
if (
165+
(!country.independent && isNotDefined(country.record_type))
166+
|| isNotDefined(country.centroid)
167+
|| isNotDefined(country.iso3)
168+
) {
169+
return undefined;
170+
}
171+
172+
const operation = countryToOperationTypeMap[country.iso3];
173+
if (isNotDefined(operation)) {
174+
return undefined;
175+
}
176+
177+
return {
178+
type: 'Feature' as const,
179+
geometry: country.centroid as {
180+
type: 'Point';
181+
coordinates: [number, number];
182+
},
183+
properties: {
184+
id: country.iso3,
185+
appealType: operation.appealType,
186+
severityLevel: operation.severityLevel,
187+
variant,
188+
peopleTargeted: operation.peopleTargeted,
189+
financialRequirements: operation.financialRequirements,
190+
},
191+
};
192+
})
193+
.filter(isDefined) ?? [],
194+
};
195+
}, [countryResponse, countryGroupedAppeal, variant]);
196+
197+
const handleCountryClick = useCallback(
198+
(
199+
featureProperties: AdminZeroFeatureProperties,
200+
lngLat: mapboxgl.LngLatLike,
201+
) => {
202+
setClickedPoint({
203+
featureProperties,
204+
lngLat,
205+
});
206+
207+
return true;
208+
},
209+
[],
210+
);
211+
212+
const handlePointClose = useCallback(() => {
213+
setClickedPoint(undefined);
214+
}, [setClickedPoint]);
215+
216+
const popupDetails = clickedPoint
217+
? countryGroupedAppeal[clickedPoint.featureProperties.iso3]
218+
: undefined;
219+
220+
return (
221+
<GlobalMap onAdminZeroFillClick={handleCountryClick}>
222+
<GoMapContainer
223+
presentationModeAdditionalAfterContent={
224+
presentationModeAdditionalAfterContent
225+
}
226+
presentationModeAdditionalBeforeContent={
227+
presentationModeAdditionalBeforeContent
228+
}
229+
withPresentationMode
230+
onPresentationModeChange={onPresentationModeChange}
231+
title={mapTitle}
232+
footer={(
233+
<>
234+
<RadioInput
235+
label={strings.explanationBubbleScalePoints}
236+
name={undefined}
237+
options={scaleOptions}
238+
keySelector={optionKeySelector}
239+
labelSelector={optionLabelSelector}
240+
value={scaleBy}
241+
onChange={setScaleBy}
242+
/>
243+
<ListView withWrap withSpacingOpticalCorrection spacing="sm">
244+
{legendOptions.map((legendItem) => (
245+
<LegendItem
246+
key={legendItem.value}
247+
color={legendItem.color}
248+
label={legendItem.label}
249+
/>
250+
))}
251+
</ListView>
252+
</>
253+
)}
254+
/>
255+
<MapSource
256+
sourceKey="points"
257+
sourceOptions={sourceOptions}
258+
geoJson={countryCentroidGeoJson}
259+
>
260+
<MapLayer
261+
layerKey="point-circle"
262+
layerOptions={basePointLayerOptions}
263+
/>
264+
<MapLayer
265+
key={scaleBy}
266+
layerKey="point-outer-circle"
267+
layerOptions={scaleBy === 'peopleTargeted'
268+
? outerCircleLayerOptionsForPeopleTargeted
269+
: outerCircleLayerOptionsForFinancialRequirements}
270+
/>
271+
</MapSource>
272+
{clickedPoint?.lngLat && (
273+
<MapPopup
274+
onCloseButtonClick={handlePointClose}
275+
coordinates={clickedPoint.lngLat}
276+
heading={(
277+
<Link
278+
to="countriesLayout"
279+
urlParams={{
280+
countryId: clickedPoint.featureProperties.country_id,
281+
}}
282+
>
283+
{clickedPoint.featureProperties.name}
284+
</Link>
285+
)}
286+
withPadding
287+
empty={isNotDefined(popupDetails) || popupDetails.length === 0}
288+
emptyMessage={strings.operationPopoverEmpty}
289+
>
290+
<ListView layout="block" spacing="sm" withSpacingOpticalCorrection>
291+
{popupDetails?.map((appeal) => (
292+
<Container
293+
key={appeal.id}
294+
heading={appeal.name}
295+
headerDescription={(
296+
<TextOutput
297+
textSize="sm"
298+
valueType="date"
299+
withLightText
300+
label={strings.lastUpdateLabel}
301+
value={appeal.modified_at}
302+
/>
303+
)}
304+
headingLevel={6}
305+
spacing="xs"
306+
>
307+
<ListView
308+
layout="block"
309+
spacing="2xs"
310+
withSpacingOpticalCorrection
311+
>
312+
<TextOutput
313+
value={appeal.num_beneficiaries}
314+
description={strings.operationPopoverPeopleAffected}
315+
valueType="number"
316+
textSize="sm"
317+
/>
318+
<TextOutput
319+
value={appeal.amount_requested}
320+
description={strings.operationPopoverAmountRequested}
321+
valueType="number"
322+
textSize="sm"
323+
/>
324+
<TextOutput
325+
value={appeal.amount_funded}
326+
description={strings.operationPopoverAmountFunded}
327+
valueType="number"
328+
textSize="sm"
329+
/>
330+
</ListView>
331+
</Container>
332+
))}
333+
</ListView>
334+
</MapPopup>
335+
)}
336+
{isDefined(bbox) && (
337+
<MapBounds
338+
duration={DURATION_MAP_ZOOM}
339+
bounds={bbox}
340+
padding={DEFAULT_MAP_PADDING}
341+
/>
342+
)}
343+
</GlobalMap>
344+
);
345+
}
346+
347+
export default OperationMapContainer;

app/src/components/domain/ActiveOperationMap/i18n.json

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,24 +5,25 @@
55
"operationMapViewAll": "View all Operations",
66
"operationMapViewAllInRegion": "View all Operations in this Region",
77
"operationMapViewAllInCountry": "View all Operations in this Country",
8-
"operationPopoverPeopleAffected": "People Targeted",
9-
"operationPopoverAmountRequested": "Amount Requested (CHF)",
10-
"operationPopoverAmountFunded": "Amount Funded (CHF)",
11-
"operationPopoverEmpty": "No Current Operations",
128
"operationType": "Type of Appeal",
139
"operationFilterTypePlaceholder": "All Appeal Types",
1410
"operationDisasterType": "Disaster Type",
1511
"operationFilterDisastersPlaceholder": "All Disaster Types",
12+
"crisisTabName": "Crisis Categorization",
13+
"appealTabName": "Appeal Types",
1614
"mapStartDateAfter": "Start After",
1715
"mapStartDateBefore": "Start Before",
18-
"explanationBubbleScalePoints": "Scale points by",
19-
"explanationBubblePopulationLabel": "# of people targeted",
20-
"explanationBubbleAmountLabel": "IFRC financial requirements",
2116
"explanationBubbleEmergencyAppeal": "Emergency appeal",
2217
"explanationBubbleDref": "DREF",
2318
"explanationBubbleMultiple": "Multiple types",
2419
"operationMapProvinces": "Provinces",
2520
"operationMapClearFilters": "Clear Filters",
26-
"operationFilterDistrictPlaceholder": "All Provinces"
21+
"operationFilterDistrictPlaceholder": "All Provinces",
22+
"crisisRedEmergency": "Red Emergency",
23+
"crisisOrangeEmergency": "Orange Emergency",
24+
"crisisYellowEmergency": "Yellow Emergency",
25+
"crisisUncategorisedEmergency": "Uncategorised",
26+
"explanationBubblePopulationLabel": "# of people targeted",
27+
"explanationBubbleAmountLabel": "IFRC financial requirements"
2728
}
28-
}
29+
}

0 commit comments

Comments
 (0)