Skip to content

Commit 89e6ace

Browse files
authored
Merge pull request #1629 from IFRCGo/project/operational-learning-2.0
project: Operational learning 2.0
2 parents 66e3fbc + 4843cb0 commit 89e6ace

File tree

18 files changed

+982
-70
lines changed

18 files changed

+982
-70
lines changed

.changeset/flat-horses-compete.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@ifrc-go/ui": patch
3+
---
4+
5+
- Pass styling props to `BarChart` and `TimeSeriesChart`
6+
- Fix date separation logic in `getDatesSeparatedByYear`

.changeset/rotten-ants-help.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"go-web-app": minor
3+
---
4+
5+
Added Operational Learning 2.0
6+
7+
- Key Figures Overview in Operational Learning
8+
- Map View for Operational Learning
9+
- Learning by Sector Bar Chart
10+
- Learning by Region Bar Chart
11+
- Sources Over Time Line Chart
12+
- Methodology changes for the prioritization step
13+
- Added an option to regenerate cached summaries
14+
- Summary post-processing and cleanup
15+
- Enabled MDR code search in admin

app/src/hooks/domain/usePerComponent.ts

Lines changed: 0 additions & 56 deletions
This file was deleted.

app/src/views/OperationalLearning/Filters/index.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,17 @@ import { type EntriesAsList } from '@togglecorp/toggle-form';
1717

1818
import CountryMultiSelectInput, { type CountryOption } from '#components/domain/CountryMultiSelectInput';
1919
import RegionSelectInput, { type RegionOption } from '#components/domain/RegionSelectInput';
20+
import { type PerComponents } from '#contexts/domain';
2021
import { type components } from '#generated/types';
2122
import { type DisasterType } from '#hooks/domain/useDisasterType';
22-
import { type PerComponent } from '#hooks/domain/usePerComponent';
2323
import { type SecondarySector } from '#hooks/domain/useSecondarySector';
2424
import { getFormattedComponentName } from '#utils/domain/per';
2525
import { type GoApiResponse } from '#utils/restRequest';
2626

2727
import i18n from './i18n.json';
2828

29+
export type PerComponent = NonNullable<PerComponents['results']>[number];
30+
2931
type OpsLearningOrganizationType = NonNullable<GoApiResponse<'/api/v2/ops-learning/organization-type/'>['results']>[number];
3032
export type PerLearningType = components<'read'>['schemas']['PerLearningTypeEnum'];
3133

app/src/views/OperationalLearning/KeyInsights/i18n.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"namespace": "operationalLearning",
33
"strings": {
4-
"opsLearningSummariesHeading": "Summary of learnings",
4+
"opsLearningSummariesHeading": "Summary of learning",
55
"keyInsightsDisclaimer": "These summaries were generated using AI and Large Language Models. They represent {numOfExtractsUsed} prioritised extracts out of {totalNumberOfExtracts} from the DREF and EA documents between {appealsFromDate} - {appealsToDate}. An initial automatic assessment of the quality of the summaries resulted in around 78% performance in terms of relevancy, coherence, consistency and fluency. To see the methodology behind the prioritisation {methodologyLink}.",
66
"methodologyLinkLabel": "click here",
77
"keyInsightsReportIssue": "Report an issue",
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"namespace": "operationalLearning",
3+
"strings": {
4+
"downloadMapTitle": "Operational learning map",
5+
"learningCount": "Learning count"
6+
}
7+
}
Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
import {
2+
useCallback,
3+
useMemo,
4+
useState,
5+
} from 'react';
6+
import {
7+
Container,
8+
NumberOutput,
9+
TextOutput,
10+
} from '@ifrc-go/ui';
11+
import { useTranslation } from '@ifrc-go/ui/hooks';
12+
import { maxSafe } from '@ifrc-go/ui/utils';
13+
import {
14+
_cs,
15+
isDefined,
16+
isNotDefined,
17+
listToMap,
18+
} from '@togglecorp/fujs';
19+
import {
20+
MapBounds,
21+
MapLayer,
22+
MapSource,
23+
} from '@togglecorp/re-map';
24+
import { type CirclePaint } from 'mapbox-gl';
25+
26+
import GlobalMap from '#components/domain/GlobalMap';
27+
import Link from '#components/Link';
28+
import MapContainerWithDisclaimer from '#components/MapContainerWithDisclaimer';
29+
import MapPopup from '#components/MapPopup';
30+
import useCountry from '#hooks/domain/useCountry';
31+
import {
32+
DEFAULT_MAP_PADDING,
33+
DURATION_MAP_ZOOM,
34+
} from '#utils/constants';
35+
import { getCountryListBoundingBox } from '#utils/map';
36+
import { type GoApiResponse } from '#utils/restRequest';
37+
38+
import i18n from './i18n.json';
39+
import styles from './styles.module.css';
40+
41+
type OperationLearningStatsResponse = GoApiResponse<'/api/v2/ops-learning/stats/'>;
42+
const sourceOptions: mapboxgl.GeoJSONSourceRaw = {
43+
type: 'geojson',
44+
};
45+
46+
interface CountryProperties {
47+
countryId: number;
48+
name: string;
49+
learningCount: number;
50+
}
51+
interface ClickedPoint {
52+
feature: GeoJSON.Feature<GeoJSON.Point, CountryProperties>;
53+
lngLat: mapboxgl.LngLatLike;
54+
}
55+
56+
const MIN_LEARNING_COUNT = 0;
57+
const LEARNING_COUNT_LOW_COLOR = 'var(--go-ui-color-blue-30)';
58+
const LEARNING_COUNT_HIGH_COLOR = 'var(--go-ui-color-blue-90)';
59+
60+
interface Props {
61+
className?: string;
62+
learningByCountry: OperationLearningStatsResponse['learning_by_country'] | undefined;
63+
}
64+
65+
function OperationalLearningMap(props: Props) {
66+
const strings = useTranslation(i18n);
67+
const {
68+
className,
69+
learningByCountry,
70+
} = props;
71+
72+
const [
73+
clickedPointProperties,
74+
setClickedPointProperties,
75+
] = useState<ClickedPoint | undefined>();
76+
77+
const countries = useCountry();
78+
79+
const countriesMap = useMemo(() => (
80+
listToMap(countries, (country) => country.id)
81+
), [countries]);
82+
83+
const learningCountGeoJSON = useMemo(
84+
(): GeoJSON.FeatureCollection<GeoJSON.Geometry> | undefined => {
85+
if ((countries?.length ?? 0) < 1 || (learningByCountry?.length ?? 0) < 1) {
86+
return undefined;
87+
}
88+
89+
const features = learningByCountry
90+
.map((value) => {
91+
const country = countriesMap?.[value.country_id];
92+
if (isNotDefined(country)) {
93+
return undefined;
94+
}
95+
return {
96+
type: 'Feature' as const,
97+
geometry: country.centroid as {
98+
type: 'Point',
99+
coordinates: [number, number],
100+
},
101+
properties: {
102+
countryId: country.id,
103+
name: country.name,
104+
learningCount: value.count,
105+
},
106+
};
107+
})
108+
.filter(isDefined) ?? [];
109+
110+
return {
111+
type: 'FeatureCollection',
112+
features,
113+
};
114+
},
115+
[learningByCountry, countriesMap, countries],
116+
);
117+
118+
const bluePointHaloCirclePaint: CirclePaint = useMemo(() => {
119+
const countriesWithLearning = learningByCountry?.filter((value) => value.count > 0);
120+
121+
const maxScaleValue = countriesWithLearning && countriesWithLearning.length > 0
122+
? Math.max(
123+
...(countriesWithLearning
124+
.map((country) => country.count)),
125+
)
126+
: 0;
127+
128+
return {
129+
'circle-opacity': 0.9,
130+
'circle-color': [
131+
'interpolate',
132+
['linear'],
133+
['number', ['get', 'learningCount']],
134+
0,
135+
LEARNING_COUNT_LOW_COLOR,
136+
maxScaleValue,
137+
LEARNING_COUNT_HIGH_COLOR,
138+
],
139+
'circle-radius': [
140+
'interpolate',
141+
['linear'],
142+
['zoom'],
143+
3, 10,
144+
8, 15,
145+
],
146+
};
147+
}, [learningByCountry]);
148+
149+
const handlePointClose = useCallback(() => {
150+
setClickedPointProperties(undefined);
151+
}, []);
152+
153+
const handlePointClick = useCallback(
154+
(feature: mapboxgl.MapboxGeoJSONFeature, lngLat: mapboxgl.LngLatLike) => {
155+
setClickedPointProperties({
156+
feature: feature as unknown as ClickedPoint['feature'],
157+
lngLat,
158+
});
159+
return true;
160+
},
161+
[setClickedPointProperties],
162+
);
163+
164+
const maxLearning = useMemo(() => (
165+
maxSafe(
166+
learningByCountry?.map((value) => value.count),
167+
)
168+
), [learningByCountry]);
169+
170+
const bounds = useMemo(
171+
() => {
172+
if (isNotDefined(learningByCountry)) {
173+
return undefined;
174+
}
175+
const countryList = learningByCountry
176+
.map((d) => countriesMap?.[d.country_id])
177+
.filter(isDefined);
178+
return getCountryListBoundingBox(countryList);
179+
},
180+
[countriesMap, learningByCountry],
181+
);
182+
183+
return (
184+
<Container
185+
className={_cs(styles.operationLearningMap, className)}
186+
footerClassName={styles.footer}
187+
footerContent={(
188+
<div className={styles.legend}>
189+
<div className={styles.legendLabel}>{strings.learningCount}</div>
190+
<div className={styles.legendContent}>
191+
<div
192+
className={styles.gradient}
193+
style={{ background: `linear-gradient(90deg, ${LEARNING_COUNT_LOW_COLOR}, ${LEARNING_COUNT_HIGH_COLOR})` }}
194+
/>
195+
<div className={styles.labelList}>
196+
<NumberOutput
197+
value={MIN_LEARNING_COUNT}
198+
/>
199+
<NumberOutput
200+
value={maxLearning}
201+
/>
202+
</div>
203+
</div>
204+
</div>
205+
)}
206+
childrenContainerClassName={styles.mainContent}
207+
>
208+
<GlobalMap>
209+
<MapContainerWithDisclaimer
210+
className={styles.mapContainer}
211+
title={strings.downloadMapTitle}
212+
/>
213+
{isDefined(learningCountGeoJSON) && (
214+
<MapSource
215+
sourceKey="points"
216+
sourceOptions={sourceOptions}
217+
geoJson={learningCountGeoJSON}
218+
>
219+
<MapLayer
220+
layerKey="points-halo-circle"
221+
onClick={handlePointClick}
222+
layerOptions={{
223+
type: 'circle',
224+
paint: bluePointHaloCirclePaint,
225+
}}
226+
/>
227+
</MapSource>
228+
)}
229+
{clickedPointProperties?.lngLat && (
230+
<MapPopup
231+
onCloseButtonClick={handlePointClose}
232+
coordinates={clickedPointProperties.lngLat}
233+
heading={(
234+
<Link
235+
to="countriesLayout"
236+
urlParams={{
237+
countryId: clickedPointProperties.feature.properties.countryId,
238+
}}
239+
>
240+
{clickedPointProperties.feature.properties.name}
241+
</Link>
242+
)}
243+
childrenContainerClassName={styles.popupContent}
244+
>
245+
<Container
246+
headingLevel={5}
247+
>
248+
<TextOutput
249+
value={clickedPointProperties.feature.properties.learningCount}
250+
label={strings.learningCount}
251+
valueType="number"
252+
/>
253+
</Container>
254+
</MapPopup>
255+
)}
256+
{isDefined(bounds) && (
257+
<MapBounds
258+
duration={DURATION_MAP_ZOOM}
259+
bounds={bounds}
260+
padding={DEFAULT_MAP_PADDING}
261+
/>
262+
)}
263+
</GlobalMap>
264+
</Container>
265+
);
266+
}
267+
268+
export default OperationalLearningMap;

0 commit comments

Comments
 (0)