Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

project: Operational learning 2.0 #1629

Merged
merged 13 commits into from
Jan 29, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/flat-horses-compete.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@ifrc-go/ui": patch
---

- Pass styling props to `BarChart` and `TimeSeriesChart`
- Fix date separation logic in `getDatesSeparatedByYear`
15 changes: 15 additions & 0 deletions .changeset/rotten-ants-help.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"go-web-app": minor
---

Added Operational Learning 2.0

- Key Figures Overview in Operational Learning
- Map View for Operational Learning
- Learning by Sector Bar Chart
- Learning by Region Bar Chart
- Sources Over Time Line Chart
- Methodology changes for the prioritization step
- Added an option to regenerate cached summaries
- Summary post-processing and cleanup
- Enabled MDR code search in admin
56 changes: 0 additions & 56 deletions app/src/hooks/domain/usePerComponent.ts

This file was deleted.

4 changes: 3 additions & 1 deletion app/src/views/OperationalLearning/Filters/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,17 @@ import { type EntriesAsList } from '@togglecorp/toggle-form';

import CountryMultiSelectInput, { type CountryOption } from '#components/domain/CountryMultiSelectInput';
import RegionSelectInput, { type RegionOption } from '#components/domain/RegionSelectInput';
import { type PerComponents } from '#contexts/domain';
import { type components } from '#generated/types';
import { type DisasterType } from '#hooks/domain/useDisasterType';
import { type PerComponent } from '#hooks/domain/usePerComponent';
import { type SecondarySector } from '#hooks/domain/useSecondarySector';
import { getFormattedComponentName } from '#utils/domain/per';
import { type GoApiResponse } from '#utils/restRequest';

import i18n from './i18n.json';

export type PerComponent = NonNullable<PerComponents['results']>[number];

type OpsLearningOrganizationType = NonNullable<GoApiResponse<'/api/v2/ops-learning/organization-type/'>['results']>[number];
export type PerLearningType = components<'read'>['schemas']['PerLearningTypeEnum'];

Expand Down
2 changes: 1 addition & 1 deletion app/src/views/OperationalLearning/KeyInsights/i18n.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"namespace": "operationalLearning",
"strings": {
"opsLearningSummariesHeading": "Summary of learnings",
"opsLearningSummariesHeading": "Summary of learning",
"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}.",
"methodologyLinkLabel": "click here",
"keyInsightsReportIssue": "Report an issue",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"namespace": "operationalLearning",
"strings": {
"downloadMapTitle": "Operational learning map",
"learningCount": "Learning count"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,268 @@
import {
useCallback,
useMemo,
useState,
} from 'react';
import {
Container,
NumberOutput,
TextOutput,
} from '@ifrc-go/ui';
import { useTranslation } from '@ifrc-go/ui/hooks';
import { maxSafe } from '@ifrc-go/ui/utils';
import {
_cs,
isDefined,
isNotDefined,
listToMap,
} from '@togglecorp/fujs';
import {
MapBounds,
MapLayer,
MapSource,
} from '@togglecorp/re-map';
import { type CirclePaint } from 'mapbox-gl';

import GlobalMap from '#components/domain/GlobalMap';
import Link from '#components/Link';
import MapContainerWithDisclaimer from '#components/MapContainerWithDisclaimer';
import MapPopup from '#components/MapPopup';
import useCountry from '#hooks/domain/useCountry';
import {
DEFAULT_MAP_PADDING,
DURATION_MAP_ZOOM,
} from '#utils/constants';
import { getCountryListBoundingBox } from '#utils/map';
import { type GoApiResponse } from '#utils/restRequest';

import i18n from './i18n.json';
import styles from './styles.module.css';

type OperationLearningStatsResponse = GoApiResponse<'/api/v2/ops-learning/stats/'>;
const sourceOptions: mapboxgl.GeoJSONSourceRaw = {
type: 'geojson',
};

interface CountryProperties {
countryId: number;
name: string;
learningCount: number;
}
interface ClickedPoint {
feature: GeoJSON.Feature<GeoJSON.Point, CountryProperties>;
lngLat: mapboxgl.LngLatLike;
}

const MIN_LEARNING_COUNT = 0;
const LEARNING_COUNT_LOW_COLOR = 'var(--go-ui-color-blue-30)';
const LEARNING_COUNT_HIGH_COLOR = 'var(--go-ui-color-blue-90)';

interface Props {
className?: string;
learningByCountry: OperationLearningStatsResponse['learning_by_country'] | undefined;
}

function OperationalLearningMap(props: Props) {
const strings = useTranslation(i18n);
const {
className,
learningByCountry,
} = props;

const [
clickedPointProperties,
setClickedPointProperties,
] = useState<ClickedPoint | undefined>();

const countries = useCountry();

const countriesMap = useMemo(() => (
listToMap(countries, (country) => country.id)
), [countries]);

const learningCountGeoJSON = useMemo(
(): GeoJSON.FeatureCollection<GeoJSON.Geometry> | undefined => {
if ((countries?.length ?? 0) < 1 || (learningByCountry?.length ?? 0) < 1) {
return undefined;
}

const features = learningByCountry
.map((value) => {
const country = countriesMap?.[value.country_id];
if (isNotDefined(country)) {
return undefined;
}
return {
type: 'Feature' as const,
geometry: country.centroid as {
type: 'Point',
coordinates: [number, number],
},
properties: {
countryId: country.id,
name: country.name,
learningCount: value.count,
},
};
})
.filter(isDefined) ?? [];

return {
type: 'FeatureCollection',
features,
};
},
[learningByCountry, countriesMap, countries],
);

const bluePointHaloCirclePaint: CirclePaint = useMemo(() => {
const countriesWithLearning = learningByCountry?.filter((value) => value.count > 0);

const maxScaleValue = countriesWithLearning && countriesWithLearning.length > 0
? Math.max(
...(countriesWithLearning
.map((country) => country.count)),
)
: 0;

return {
'circle-opacity': 0.9,
'circle-color': [
'interpolate',
['linear'],
['number', ['get', 'learningCount']],
0,
LEARNING_COUNT_LOW_COLOR,
maxScaleValue,
LEARNING_COUNT_HIGH_COLOR,
],
'circle-radius': [
'interpolate',
['linear'],
['zoom'],
3, 10,
8, 15,
],
};
}, [learningByCountry]);

const handlePointClose = useCallback(() => {
setClickedPointProperties(undefined);
}, []);

const handlePointClick = useCallback(
(feature: mapboxgl.MapboxGeoJSONFeature, lngLat: mapboxgl.LngLatLike) => {
setClickedPointProperties({
feature: feature as unknown as ClickedPoint['feature'],
lngLat,
});
return true;
},
[setClickedPointProperties],
);

const maxLearning = useMemo(() => (
maxSafe(
learningByCountry?.map((value) => value.count),
)
), [learningByCountry]);

const bounds = useMemo(
() => {
if (isNotDefined(learningByCountry)) {
return undefined;
}
const countryList = learningByCountry
.map((d) => countriesMap?.[d.country_id])
.filter(isDefined);
return getCountryListBoundingBox(countryList);
},
[countriesMap, learningByCountry],
);

return (
<Container
className={_cs(styles.operationLearningMap, className)}
footerClassName={styles.footer}
footerContent={(
<div className={styles.legend}>
<div className={styles.legendLabel}>{strings.learningCount}</div>
<div className={styles.legendContent}>
<div
className={styles.gradient}
style={{ background: `linear-gradient(90deg, ${LEARNING_COUNT_LOW_COLOR}, ${LEARNING_COUNT_HIGH_COLOR})` }}
/>
<div className={styles.labelList}>
<NumberOutput
value={MIN_LEARNING_COUNT}
/>
<NumberOutput
value={maxLearning}
/>
</div>
</div>
</div>
)}
childrenContainerClassName={styles.mainContent}
>
<GlobalMap>
<MapContainerWithDisclaimer
className={styles.mapContainer}
title={strings.downloadMapTitle}
/>
{isDefined(learningCountGeoJSON) && (
<MapSource
sourceKey="points"
sourceOptions={sourceOptions}
geoJson={learningCountGeoJSON}
>
<MapLayer
layerKey="points-halo-circle"
onClick={handlePointClick}
layerOptions={{
type: 'circle',
paint: bluePointHaloCirclePaint,
}}
/>
</MapSource>
)}
{clickedPointProperties?.lngLat && (
<MapPopup
onCloseButtonClick={handlePointClose}
coordinates={clickedPointProperties.lngLat}
heading={(
<Link
to="countriesLayout"
urlParams={{
countryId: clickedPointProperties.feature.properties.countryId,
}}
>
{clickedPointProperties.feature.properties.name}
</Link>
)}
childrenContainerClassName={styles.popupContent}
>
<Container
headingLevel={5}
>
<TextOutput
value={clickedPointProperties.feature.properties.learningCount}
label={strings.learningCount}
valueType="number"
/>
</Container>
</MapPopup>
)}
{isDefined(bounds) && (
<MapBounds
duration={DURATION_MAP_ZOOM}
bounds={bounds}
padding={DEFAULT_MAP_PADDING}
/>
)}
</GlobalMap>
</Container>
);
}

export default OperationalLearningMap;
Loading
Loading