Skip to content

Commit 2f410ac

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 - add filter for event and needs_confirmation for appeal response
1 parent 5dd59da commit 2f410ac

8 files changed

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

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

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,24 +5,24 @@
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+
"explanationBubblePopulationLabel": "# of people targeted",
26+
"explanationBubbleAmountLabel": "IFRC financial requirements"
2727
}
28-
}
28+
}

0 commit comments

Comments
 (0)