diff --git a/app/src/views/OperationalLearning/LearningMap/i18n.json b/app/src/views/OperationalLearning/LearningMap/i18n.json new file mode 100644 index 000000000..92542bd94 --- /dev/null +++ b/app/src/views/OperationalLearning/LearningMap/i18n.json @@ -0,0 +1,7 @@ +{ + "namespace": "learningMap", + "strings": { + "learningDownloadMapTitle": "Operational learning", + "presentationModeButton":"Presentation Mode" + } +} diff --git a/app/src/views/OperationalLearning/LearningMap/index.tsx b/app/src/views/OperationalLearning/LearningMap/index.tsx new file mode 100644 index 000000000..969c0c66e --- /dev/null +++ b/app/src/views/OperationalLearning/LearningMap/index.tsx @@ -0,0 +1,228 @@ +import { + useCallback, + useMemo, + useState, +} from 'react'; +import { ArtboardLineIcon } from '@ifrc-go/icons'; +import { + Button, + Container, + TextOutput, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + _cs, + isDefined, + isNotDefined, +} from '@togglecorp/fujs'; +import Map, { + MapBounds, + MapLayer, + MapSource, +} from '@togglecorp/re-map'; +import { LngLatBoundsLike } from 'mapbox-gl'; + +import BaseMap from '#components/domain/BaseMap'; +import Link from '#components/Link'; +import MapContainerWithDisclaimer from '#components/MapContainerWithDisclaimer'; +import MapPopup from '#components/MapPopup'; +import useCountryRaw from '#hooks/domain/useCountryRaw'; +import { + DEFAULT_MAP_PADDING, + DURATION_MAP_ZOOM, +} from '#utils/constants'; + +import { + adminFillLayerOptions, + basePointLayerOptions, + outerCircleLayerOptionsForPersonnel, +} from './utils'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +const sourceOptions: mapboxgl.GeoJSONSourceRaw = { + type: 'geojson', +}; + +interface CountryProperties { + country_id: number; + name: string; + units: number; +} + +interface ClickedPoint { + feature: GeoJSON.Feature; + lngLat: mapboxgl.LngLatLike; +} + +interface Props { + className?: string; +} + +function OperationalLearningMap(props: Props) { + const strings = useTranslation(i18n); + const { + className, + } = props; + + const [ + clickedPointProperties, + setClickedPointProperties] = useState(); + + const learning_by_country = [ + { + country_name: 'Afghanistan', + country_id: 14, + operation_count: 4, + }, + { + country_name: 'Albania', + country_id: 15, + operation_count: 1, + }, + { + country_name: 'Argentina', + country_id: 20, + operation_count: 1, + }, + { + country_name: 'Australia', + country_id: 22, + operation_count: 1, + }, + { + country_name: 'Belgium', + country_id: 30, + operation_count: 1, + }, + { + country_name: 'Canada', + country_id: 42, + operation_count: 1, + }, + ]; + + const countryResponse = useCountryRaw(); + + const countryCentroidGeoJson = useMemo( + (): GeoJSON.FeatureCollection => ({ + type: 'FeatureCollection', + features: countryResponse + ?.map((country) => { + if ( + (!country.independent && isNotDefined(country.record_type)) + || isNotDefined(country.centroid) + || isNotDefined(country.iso3) + ) { + return undefined; + } + + const learningList = learning_by_country.find( + (item) => item.country_id, + ); + if (isNotDefined(learningList)) { + return undefined; + } + + const units = learningList.operation_count ?? 0; + + return { + type: 'Feature' as const, + geometry: country.centroid as { + type: 'Point', + coordinates: [number, number], + }, + properties: { + id: country, + name: country.name, + units, + }, + }; + }) + .filter(isDefined) ?? [], + }), + [], + ); + + const handleCountryClick = useCallback( + (feature: mapboxgl.MapboxGeoJSONFeature, lngLat: mapboxgl.LngLatLike) => { + setClickedPointProperties({ + feature: feature as unknown as ClickedPoint['feature'], + lngLat, + }); + return false; + }, + [], + ); + + const handlePointClose = useCallback(() => { + setClickedPointProperties(undefined); + }, []); + + return ( + + + )} + > + + + + + + {clickedPointProperties?.lngLat && ( + + {clickedPointProperties.feature.properties.name} + + )} + childrenContainerClassName={styles.popupContent} + > + + + + + )} + + + ); +} + +export default OperationalLearningMap; diff --git a/app/src/views/OperationalLearning/LearningMap/styles.module.css b/app/src/views/OperationalLearning/LearningMap/styles.module.css new file mode 100644 index 000000000..fa393c112 --- /dev/null +++ b/app/src/views/OperationalLearning/LearningMap/styles.module.css @@ -0,0 +1,22 @@ +.learning-map { + + .map-container { + height: 40rem; + } +} + +.popup-content { + display: flex; + flex-direction: column; + gap: var(--go-ui-spacing-md); + + .popup-appeal { + gap: var(--go-ui-spacing-xs); + + .popup-appeal-detail { + display: flex; + flex-direction: column; + font-size: var(--go-ui-font-size-sm); + } + } +} diff --git a/app/src/views/OperationalLearning/LearningMap/utils.ts b/app/src/views/OperationalLearning/LearningMap/utils.ts new file mode 100644 index 000000000..44d5f6dfd --- /dev/null +++ b/app/src/views/OperationalLearning/LearningMap/utils.ts @@ -0,0 +1,82 @@ +import type { + CircleLayer, + CirclePaint, + FillLayer, +} from 'mapbox-gl'; + +import { + COLOR_BLACK, + COLOR_BLUE, + COLOR_DARK_GREY, + COLOR_LIGHT_GREY, + COLOR_RED, + COLOR_YELLOW, +} from '#utils/constants'; + +const COLOR_LEARNING_AND_PERSONNEL = COLOR_BLUE; +const COLOR_LEARNING_ONLY = COLOR_RED; +const COLOR_PERSONNEL_ONLY = COLOR_YELLOW; +const COLOR_DEFAULT = COLOR_BLACK; + +export const adminFillLayerOptions: Omit = { + type: 'fill', + paint: { + 'fill-color': [ + 'case', + ['boolean', ['feature-state', 'hovered'], false], + COLOR_DARK_GREY, + COLOR_LIGHT_GREY, + ], + }, +}; + +const circleColor: CirclePaint['circle-color'] = [ + 'case', + COLOR_LEARNING_AND_PERSONNEL, + COLOR_LEARNING_ONLY, + COLOR_PERSONNEL_ONLY, + COLOR_DEFAULT, +]; + +const basePointPaint: CirclePaint = { + 'circle-radius': 5, + 'circle-color': circleColor, + 'circle-opacity': 0.8, +}; + +export const basePointLayerOptions: Omit = { + type: 'circle', + paint: basePointPaint, +}; + +const baseOuterCirclePaint: CirclePaint = { + 'circle-color': circleColor, + 'circle-opacity': 0.4, +}; + +const outerCirclePaintForPersonnel: CirclePaint = { + ...baseOuterCirclePaint, + 'circle-radius': [ + 'interpolate', + ['linear', 1], + ['get', 'learning'], + + 2, + 5, + 4, + 7, + 6, + 9, + 8, + 11, + 10, + 13, + 12, + 15, + ], +}; + +export const outerCircleLayerOptionsForPersonnel: Omit = { + type: 'circle', + paint: outerCirclePaintForPersonnel, +}; diff --git a/app/src/views/OperationalLearning/index.tsx b/app/src/views/OperationalLearning/index.tsx index 1330314ac..b7b9c6b5e 100644 --- a/app/src/views/OperationalLearning/index.tsx +++ b/app/src/views/OperationalLearning/index.tsx @@ -64,6 +64,7 @@ import { import Filters, { type FilterValue } from './Filters'; import KeyInsights from './KeyInsights'; +import OperationalLearningMap from './LearningMap'; import Summary, { type Props as SummaryProps } from './Summary'; import i18n from './i18n.json'; @@ -102,29 +103,59 @@ const perComponentKeySelector = (option: PerComponent) => option.id; const disasterTypeKeySelector = (type: DisasterType) => type.id; const disasterTypeLabelSelector = (type: DisasterType) => type.name ?? '?'; -/** @knipignore */ - const responseData = { + operations_included: 9, + learning_extracts: 6, + sectors_covered: 6, + sources_used: 8, learning_by_region: [ - { region_name: 'Americas', region_id: 1, count: 2 }, - { region_name: 'Asia Pacific', region_id: 2, count: 5 }, - { region_name: 'Europe', region_id: 3, count: 2 }, - ], - learning_by_country: [ - { country_name: 'Afghanistan', country_id: 14, operation_count: 4 }, - { country_name: 'Albania', country_id: 15, operation_count: 1 }, - { country_name: 'Argentina', country_id: 20, operation_count: 1 }, - { country_name: 'Australia', country_id: 22, operation_count: 1 }, - { country_name: 'Belgium', country_id: 30, operation_count: 1 }, - { country_name: 'Canada', country_id: 42, operation_count: 1 }, + { + region_name: 'Americas', + region_id: 1, + count: 2, + }, + { + region_name: 'Asia Pacific', + region_id: 2, + count: 5, + }, + { + region_name: 'Europe', + region_id: 3, + count: 2, + }, ], learning_by_sector: [ - { id: 17, count: 1, title: 'health' }, - { id: 18, count: 1, title: 'education' }, - { id: 19, count: 3, title: 'Livelihoods and basic needs' }, - { id: 20, count: 4, title: 'Migration' }, - { id: 21, count: 1, title: 'WASH' }, - { id: 22, count: 1, title: 'Shelter' }, + { + id: 17, + count: 1, + title: 'health', + }, + { + id: 18, + count: 1, + title: 'education', + }, + { + id: 19, + count: 3, + title: 'Livelihoods and basic needs', + }, + { + id: 20, + count: 4, + title: 'Migration', + }, + { + id: 21, + count: 1, + title: 'WASH', + }, + { + id: 22, + count: 1, + title: 'Shelter', + }, ], sources_overtime: { DREF: [ @@ -176,11 +207,11 @@ const xAxisFormatter = (date: Date) => date.toLocaleString( { month: 'short' }, ); const keySelector = (datum: { key: number; value: number; label: string }) => datum.key; - const valueSelector = (d: { value: number }) => d.value; const labelSelector = (d: { label: string }) => d.label; const dateSelector = (d: { date: string }) => d.date; +/** @knipignore */ // eslint-disable-next-line import/prefer-default-export export function Component() { const strings = useTranslation(i18n); @@ -548,39 +579,42 @@ export function Component() {
-
- -
+ + + -
-
+ + {showKeyInsights && (