diff --git a/apps/fishing-map/features/dataviews/selectors/dataviews.selectors.ts b/apps/fishing-map/features/dataviews/selectors/dataviews.selectors.ts index 6058aa36fb..0e85115401 100644 --- a/apps/fishing-map/features/dataviews/selectors/dataviews.selectors.ts +++ b/apps/fishing-map/features/dataviews/selectors/dataviews.selectors.ts @@ -226,12 +226,6 @@ export const selectActiveVesselGroupDataviews = createSelector( (dataviews): UrlDataviewInstance[] => dataviews?.filter((d) => d.config?.visible) ) -export const selectVesselGroupReportDataview = createSelector( - [selectActiveVesselGroupDataviews, selectReportVesselGroupId], - (dataviews, reportVesselGroupId): UrlDataviewInstance | undefined => - dataviews?.find(({ vesselGroup }) => vesselGroup?.id === reportVesselGroupId) -) - export const selectDetectionsMergedDataviewId = createSelector( [selectActiveDetectionsDataviews], (dataviews): string => { diff --git a/apps/fishing-map/features/vessel-group-report/VesselGroupReport.tsx b/apps/fishing-map/features/vessel-group-report/VesselGroupReport.tsx index f7f84aac2a..f3590b9956 100644 --- a/apps/fishing-map/features/vessel-group-report/VesselGroupReport.tsx +++ b/apps/fishing-map/features/vessel-group-report/VesselGroupReport.tsx @@ -12,27 +12,24 @@ import { useTimebarVesselGroupConnect, useTimebarVisualisationConnect, } from 'features/timebar/timebar.hooks' -import { selectVesselGroupReportDataview } from 'features/dataviews/selectors/dataviews.selectors' import VGREvents from 'features/vessel-group-report/events/VGREvents' import { useFetchVesselGroupReport } from './vessel-group-report.hooks' -import { - selectVesselGroupReportData, - selectVesselGroupReportStatus, -} from './vessel-group-report.slice' -import VesselGroupReportError from './VesselGroupReportError' +import { selectVGRData, selectVGRStatus } from './vessel-group-report.slice' import VesselGroupReportTitle from './VesselGroupReportTitle' import VesselGroupReportVessels from './vessels/VesselGroupReportVessels' -import { selectVesselGroupReportSection } from './vessel-group.config.selectors' +import { selectVGRSection } from './vessel-group.config.selectors' +import VesselGroupReportInsights from './insights/VGRInsights' +import { selectVGRDataview } from './vessel-group-report.selectors' function VesselGroupReport() { const { t } = useTranslation() const { dispatchQueryParams } = useLocationConnect() const fetchVesselGroupReport = useFetchVesselGroupReport() const vesselGroupId = useSelector(selectReportVesselGroupId) - const vesselGroup = useSelector(selectVesselGroupReportData)! - const reportStatus = useSelector(selectVesselGroupReportStatus) - const reportSection = useSelector(selectVesselGroupReportSection) - const reportDataview = useSelector(selectVesselGroupReportDataview) + const vesselGroup = useSelector(selectVGRData)! + const reportStatus = useSelector(selectVGRStatus) + const reportSection = useSelector(selectVGRSection) + const reportDataview = useSelector(selectVGRDataview) const { dispatchTimebarVisualisation } = useTimebarVisualisationConnect() const { dispatchTimebarSelectedVGId } = useTimebarVesselGroupConnect() @@ -52,7 +49,7 @@ function VesselGroupReport() { const changeTab = useCallback( (tab: Tab) => { - dispatchQueryParams({ vesselGroupReportSection: tab.id }) + dispatchQueryParams({ vGRSection: tab.id }) trackEvent({ category: TrackCategory.VesselGroupReport, action: `click_${tab.id}_tab`, @@ -70,9 +67,8 @@ function VesselGroupReport() { }, { id: 'insights', - title: t('common.areas', 'Areas'), - disabled: true, - content:

Coming soon

, + title: t('common.insights', 'Insights'), + content: , }, { id: 'activity', diff --git a/apps/fishing-map/features/vessel-group-report/VesselGroupReportError.tsx b/apps/fishing-map/features/vessel-group-report/VesselGroupReportError.tsx index 6c6b5fb476..d7eadd5530 100644 --- a/apps/fishing-map/features/vessel-group-report/VesselGroupReportError.tsx +++ b/apps/fishing-map/features/vessel-group-report/VesselGroupReportError.tsx @@ -3,15 +3,12 @@ import { Trans, useTranslation } from 'react-i18next' import { AsyncReducerStatus } from 'utils/async-slice' import LocalStorageLoginLink from 'routes/LoginLink' import styles from './VesselGroupReport.module.css' -import { - selectVesselGroupReportError, - selectVesselGroupReportStatus, -} from './vessel-group-report.slice' +import { selectVGRError, selectVGRStatus } from './vessel-group-report.slice' function VesselGroupReportError() { const { t } = useTranslation() - const reportStatus = useSelector(selectVesselGroupReportStatus) - const reportError = useSelector(selectVesselGroupReportError) + const reportStatus = useSelector(selectVGRStatus) + const reportError = useSelector(selectVGRError) if (reportStatus !== AsyncReducerStatus.Error) { return null diff --git a/apps/fishing-map/features/vessel-group-report/events/VGREventsSubsectionSelector.tsx b/apps/fishing-map/features/vessel-group-report/events/VGREventsSubsectionSelector.tsx index 0eccc7dfb0..f1afc0be5d 100644 --- a/apps/fishing-map/features/vessel-group-report/events/VGREventsSubsectionSelector.tsx +++ b/apps/fishing-map/features/vessel-group-report/events/VGREventsSubsectionSelector.tsx @@ -2,7 +2,7 @@ import { useSelector } from 'react-redux' import { useTranslation } from 'react-i18next' import { Choice, ChoiceOption } from '@globalfishingwatch/ui-components' import { useLocationConnect } from 'routes/routes.hook' -import { selectVesselGroupReportStatus } from 'features/vessel-group-report/vessel-group-report.slice' +import { selectVGRStatus } from 'features/vessel-group-report/vessel-group-report.slice' import { AsyncReducerStatus } from 'utils/async-slice' import { VGREventsSubsection } from 'features/vessel-groups/vessel-groups.types' import { selectVGREventsSubsection } from '../vessel-group.config.selectors' @@ -10,7 +10,7 @@ import { selectVGREventsSubsection } from '../vessel-group.config.selectors' function VesselGroupReportEventsSubsectionSelector() { const { t } = useTranslation() const { dispatchQueryParams } = useLocationConnect() - const vesselGroupReportStatus = useSelector(selectVesselGroupReportStatus) + const vesselGroupReportStatus = useSelector(selectVGRStatus) const subsection = useSelector(selectVGREventsSubsection) const loading = vesselGroupReportStatus === AsyncReducerStatus.Loading const options: ChoiceOption[] = [ diff --git a/apps/fishing-map/features/vessel-group-report/events/VGREventsVesselsTableFooter.tsx b/apps/fishing-map/features/vessel-group-report/events/VGREventsVesselsTableFooter.tsx index 7e915b7a4b..4d9987dc4f 100644 --- a/apps/fishing-map/features/vessel-group-report/events/VGREventsVesselsTableFooter.tsx +++ b/apps/fishing-map/features/vessel-group-report/events/VGREventsVesselsTableFooter.tsx @@ -9,7 +9,7 @@ import I18nNumber from 'features/i18n/i18nNumber' import { useLocationConnect } from 'routes/routes.hook' import { selectTimeRange } from 'features/app/selectors/app.timebar.selectors' import { REPORT_SHOW_MORE_VESSELS_PER_PAGE, REPORT_VESSELS_PER_PAGE } from 'data/config' -import { selectVesselGroupReportData } from 'features/vessel-group-report/vessel-group-report.slice' +import { selectVGRData } from 'features/vessel-group-report/vessel-group-report.slice' import { formatInfoField } from 'utils/info' import { selectVGREventsVessels, @@ -21,7 +21,7 @@ import styles from '../vessels/VesselGroupReportVesselsTableFooter.module.css' export default function VesselGroupReportVesselsTableFooter() { const { t } = useTranslation() const { dispatchQueryParams } = useLocationConnect() - const vesselGroup = useSelector(selectVesselGroupReportData) + const vesselGroup = useSelector(selectVGRData) const allVessels = useSelector(selectVGREventsVessels) const reportVesselFilter = useSelector(selectVGREventsVesselFilter) const pagination = useSelector(selectVGREventsVesselsPagination) diff --git a/apps/fishing-map/features/vessel-group-report/events/vgr-events.selectors.ts b/apps/fishing-map/features/vessel-group-report/events/vgr-events.selectors.ts index 8d5bcfa757..40defbece8 100644 --- a/apps/fishing-map/features/vessel-group-report/events/vgr-events.selectors.ts +++ b/apps/fishing-map/features/vessel-group-report/events/vgr-events.selectors.ts @@ -4,7 +4,7 @@ import { selectVesselGroupEventsVessels, VesselGroupEventsVesselsParams, } from 'queries/vessel-group-events-stats-api' -import { selectVesselGroupReportData } from 'features/vessel-group-report/vessel-group-report.slice' +import { selectVGRData } from 'features/vessel-group-report/vessel-group-report.slice' import { getSearchIdentityResolved, getVesselId } from 'features/vessel/vessel.utils' import { selectTimeRange } from 'features/app/selectors/app.timebar.selectors' import { selectReportVesselGroupId } from 'routes/routes.selectors' @@ -45,7 +45,7 @@ export const selectVGREventsVesselsData = createSelector( ) export const selectVGREventsVessels = createSelector( - [selectVGREventsVesselsData, selectVesselGroupReportData], + [selectVGREventsVesselsData, selectVGRData], (data, vesselGroup) => { if (!data || !vesselGroup) { return diff --git a/apps/fishing-map/features/vessel-group-report/insights/VGRInsightCoverage.tsx b/apps/fishing-map/features/vessel-group-report/insights/VGRInsightCoverage.tsx new file mode 100644 index 0000000000..9559de5afe --- /dev/null +++ b/apps/fishing-map/features/vessel-group-report/insights/VGRInsightCoverage.tsx @@ -0,0 +1,43 @@ +import { useTranslation } from 'react-i18next' +import { useGetVesselGroupInsightQuery } from 'queries/vessel-insight-api' +import { useSelector } from 'react-redux' +import { ParsedAPIError } from '@globalfishingwatch/api-client' +import InsightError from 'features/vessel/insights/InsightErrorMessage' +import DataTerminology from 'features/vessel/identity/DataTerminology' +import { selectFetchVesselGroupReportCoverageParams } from '../vessel-group-report.selectors' +import styles from './VGRInsights.module.css' +import VesselGroupReportInsightCoverageGraph from './VGRInsightCoverageGraph' + +const VesselGroupReportInsightCoveragePlaceholder = () => { + // TODO graph bar placeholder + return
+} + +const VesselGroupReportInsightCoverage = () => { + const { t } = useTranslation() + const fetchParams = useSelector(selectFetchVesselGroupReportCoverageParams) + const { data, error, isLoading } = useGetVesselGroupInsightQuery(fetchParams) + + return ( +
+
+ + +
+ {isLoading ? ( + + ) : error ? ( + + ) : data?.coverage && data?.coverage?.length > 0 ? ( + + ) : null} +
+ ) +} + +export default VesselGroupReportInsightCoverage diff --git a/apps/fishing-map/features/vessel-group-report/insights/VGRInsightCoverageGraph.module.css b/apps/fishing-map/features/vessel-group-report/insights/VGRInsightCoverageGraph.module.css new file mode 100644 index 0000000000..849da41490 --- /dev/null +++ b/apps/fishing-map/features/vessel-group-report/insights/VGRInsightCoverageGraph.module.css @@ -0,0 +1,62 @@ +.graph { + width: 100%; + height: 30rem; + margin-block: var(--space-S); +} + +.graph tspan { + text-transform: uppercase; + font: var(--font-XS); + line-height: 1.2rem; + fill: var(--color-secondary-blue); +} + +.tooltipContainer { + background-color: var(--color-white); + border: var(--border); + padding: var(--space-S); +} + +.axisLabel { + cursor: pointer; +} + +.tooltipRow { + display: flex; + align-items: center; +} + +.tooltipLabel { + color: var(--color-secondary-blue); +} + +.graph tspan.info { + text-transform: lowercase; + font-family: serif; + font-style: italic; + font-weight: 600; +} + +.graph :global(.recharts-tooltip-cursor) { + fill: var(--color-terthiary-blue); +} + +.bar { + transition: fill 300ms linear; +} + +.graph :global(.recharts-bar-rectangle):nth-child(1) { + opacity: 0.3; +} + +.graph :global(.recharts-bar-rectangle):nth-child(2) { + opacity: 0.5; +} + +.graph :global(.recharts-bar-rectangle):nth-child(3) { + opacity: 0.7; +} + +.graph :global(.recharts-bar-rectangle):nth-child(4) { + opacity: 0.85; +} diff --git a/apps/fishing-map/features/vessel-group-report/insights/VGRInsightCoverageGraph.tsx b/apps/fishing-map/features/vessel-group-report/insights/VGRInsightCoverageGraph.tsx new file mode 100644 index 0000000000..f3d47cccec --- /dev/null +++ b/apps/fishing-map/features/vessel-group-report/insights/VGRInsightCoverageGraph.tsx @@ -0,0 +1,101 @@ +import React, { Fragment, useMemo } from 'react' +import { useSelector } from 'react-redux' +import { BarChart, Bar, XAxis, ResponsiveContainer, LabelList } from 'recharts' +import { groupBy } from 'es-toolkit' +import { VesselGroupInsightResponse } from '@globalfishingwatch/api-types' +import { COLOR_PRIMARY_BLUE } from 'features/app/app.config' +import { selectVGRDataview } from '../vessel-group-report.selectors' +import styles from './VGRInsightCoverageGraph.module.css' + +const CustomTick = (props: any) => { + const { x, y, payload } = props + + return ( + + + {payload.value} + + + ) +} + +type VesselGroupReportCoverageGraphData = { + name: string + value: number +} + +const COVERAGE_GRAPH_BUCKETS: Record = { + '<20%': 20, + '20-40%': 40, + '40-60%': 60, + '60-80%': 80, + '>80%': 100, +} +const CoverageGraphBuckets = Object.keys(COVERAGE_GRAPH_BUCKETS) +function parseCoverageGraphValueBucket(value: number) { + return ( + CoverageGraphBuckets.find((key) => value < COVERAGE_GRAPH_BUCKETS[key]) || + CoverageGraphBuckets[CoverageGraphBuckets.length - 1] + ) +} + +function parseCoverageGraphData( + data: VesselGroupInsightResponse['coverage'] +): VesselGroupReportCoverageGraphData[] { + if (!data) return [] + const dataByCoverage = data.map((d) => ({ + name: d.vesselId, + value: parseCoverageGraphValueBucket(d.percentage), + })) + const groupedDataByCoverage = groupBy(dataByCoverage, (entry) => entry.value!) + return Object.keys(COVERAGE_GRAPH_BUCKETS).map((key) => ({ + name: key, + value: groupedDataByCoverage[key]?.length || 0, + })) +} + +export default function VesselGroupReportInsightCoverageGraph({ + data, +}: { + data: VesselGroupInsightResponse['coverage'] +}) { + const dataGrouped = useMemo(() => parseCoverageGraphData(data), [data]) + const reportDataview = useSelector(selectVGRDataview) + return ( + +
+ {dataGrouped && ( + + + + entry.value} /> + + } + tickMargin={0} + /> + + + )} +
+
+ ) +} diff --git a/apps/fishing-map/features/vessel-group-report/insights/VGRInsightFishing.tsx b/apps/fishing-map/features/vessel-group-report/insights/VGRInsightFishing.tsx new file mode 100644 index 0000000000..ef14b1df9c --- /dev/null +++ b/apps/fishing-map/features/vessel-group-report/insights/VGRInsightFishing.tsx @@ -0,0 +1,189 @@ +import { useTranslation } from 'react-i18next' +import { useGetVesselGroupInsightQuery } from 'queries/vessel-insight-api' +import { useState } from 'react' +import cx from 'classnames' +import { useSelector } from 'react-redux' +import { ParsedAPIError } from '@globalfishingwatch/api-client' +import { Collapsable } from '@globalfishingwatch/ui-components' +import InsightError from 'features/vessel/insights/InsightErrorMessage' +import DataTerminology from 'features/vessel/identity/DataTerminology' +import { formatInfoField } from 'utils/info' +import { selectVGRData } from '../vessel-group-report.slice' +import { selectFetchVesselGroupReportFishingParams } from '../vessel-group-report.selectors' +import styles from './VGRInsights.module.css' +import VesselGroupReportInsightPlaceholder from './VGRInsightsPlaceholders' +import VesselGroupReportInsightVesselEvents from './VGRInsightVesselEvents' +import { + selectVGRVesselsWithNoTakeMpas, + selectVGRVesselsInRfmoWithoutKnownAuthorization, + VesselGroupReportInsightVessel, +} from './vessel-group-report-insights.selectors' + +const VesselGroupReportInsightFishing = () => { + const { t } = useTranslation() + const [isMPAExpanded, setIsMPAExpanded] = useState(false) + const [isRFMOExpanded, setIsRFMOExpanded] = useState(false) + const [expandedVesselIds, setExpandedVesselIds] = useState([]) + const vesselGroup = useSelector(selectVGRData) + const reportFishingParams = useSelector(selectFetchVesselGroupReportFishingParams) + + const { error, isLoading } = useGetVesselGroupInsightQuery(reportFishingParams, { + skip: !vesselGroup, + }) + const vesselsWithNoTakeMpas = useSelector(selectVGRVesselsWithNoTakeMpas) + const vesselsInRfmoWithoutKnownAuthorization = useSelector( + selectVGRVesselsInRfmoWithoutKnownAuthorization + ) + + const onMPAToggle = (isOpen: boolean) => { + if (isOpen !== isMPAExpanded) { + setIsMPAExpanded(!isMPAExpanded) + } + if (!isOpen) { + setExpandedVesselIds([]) + } + } + + const onRFMOToggle = (isOpen: boolean) => { + if (isOpen !== isRFMOExpanded) { + setIsRFMOExpanded(!isRFMOExpanded) + } + if (!isOpen) { + setExpandedVesselIds([]) + } + } + + const getVesselGroupReportInsighFishingVessels = ( + vessels: VesselGroupReportInsightVessel[], + insight: 'eventsInNoTakeMPAs' | 'eventsInRFMOWithoutKnownAuthorization' + ) => { + return ( +
    + {vessels.map((vessel) => { + const vesselId = vessel.identity.id + const isExpandedVessel = expandedVesselIds.includes(vesselId) + return ( +
  • + + {formatInfoField(vessel.identity.shipname, 'name')}{' '} + + ({vessel.periodSelectedCounters[insight]}) + + + } + onToggle={(isOpen, id) => { + setExpandedVesselIds((expandedIds) => { + return isOpen && id + ? [...expandedIds, id] + : expandedIds.filter((vesselId) => vesselId !== id) + }) + }} + > + {isExpandedVessel && vessel.datasets?.[0] && ( + + )} + +
  • + ) + })} +
+ ) + } + + return ( +
+
+ + +
+ {isLoading || !vesselGroup ? ( + + ) : error ? ( + + ) : ( +
+ {!vesselsWithNoTakeMpas || vesselsWithNoTakeMpas?.length === 0 ? ( +

+ {t( + 'vessel.insights.fishingEventsInNoTakeMpasEmpty', + 'No fishing events detected in no-take MPAs' + )} +

+ ) : ( + acc + vessel.periodSelectedCounters.eventsInNoTakeMPAs, + 0 + ), + vessels: vesselsWithNoTakeMpas?.length, + })} + onToggle={onMPAToggle} + > + {getVesselGroupReportInsighFishingVessels( + vesselsWithNoTakeMpas, + 'eventsInNoTakeMPAs' + )} + + )} + {!vesselsInRfmoWithoutKnownAuthorization || + !vesselsInRfmoWithoutKnownAuthorization?.length ? ( +

+ {t( + 'vessel.insights.fishingEventsInRfmoWithoutKnownAuthorizationEmpty', + 'No fishing events detected outside known RFMO authorized areas' + )} +

+ ) : ( + + acc + vessel.periodSelectedCounters.eventsInRFMOWithoutKnownAuthorization, + 0 + ), + vessels: vesselsInRfmoWithoutKnownAuthorization.length, + })} + onToggle={onRFMOToggle} + > + {getVesselGroupReportInsighFishingVessels( + vesselsInRfmoWithoutKnownAuthorization, + 'eventsInRFMOWithoutKnownAuthorization' + )} + + )} +
+ )} +
+ ) +} + +export default VesselGroupReportInsightFishing diff --git a/apps/fishing-map/features/vessel-group-report/insights/VGRInsightFlagChange.tsx b/apps/fishing-map/features/vessel-group-report/insights/VGRInsightFlagChange.tsx new file mode 100644 index 0000000000..528f2a20c1 --- /dev/null +++ b/apps/fishing-map/features/vessel-group-report/insights/VGRInsightFlagChange.tsx @@ -0,0 +1,87 @@ +import { useTranslation } from 'react-i18next' +import { useGetVesselGroupInsightQuery } from 'queries/vessel-insight-api' +import { useState } from 'react' +import cx from 'classnames' +import { useSelector } from 'react-redux' +import { ParsedAPIError } from '@globalfishingwatch/api-client' +import { Collapsable } from '@globalfishingwatch/ui-components' +import InsightError from 'features/vessel/insights/InsightErrorMessage' +import DataTerminology from 'features/vessel/identity/DataTerminology' +import { formatInfoField } from 'utils/info' +import VesselIdentityFieldLogin from 'features/vessel/identity/VesselIdentityFieldLogin' +import { selectIsGuestUser } from 'features/user/selectors/user.selectors' +import { selectVGRData } from '../vessel-group-report.slice' +import { selectFetchVesselGroupReportFlagChangeParams } from '../vessel-group-report.selectors' +import styles from './VGRInsights.module.css' +import VesselGroupReportInsightPlaceholder from './VGRInsightsPlaceholders' +import { selectVGRFlagChangesVessels } from './vessel-group-report-insights.selectors' + +const VesselGroupReportInsightFlagChange = () => { + const { t } = useTranslation() + const [isExpanded, setIsExpanded] = useState(false) + const guestUser = useSelector(selectIsGuestUser) + const vesselGroup = useSelector(selectVGRData) + const fetchVesselGroupParams = useSelector(selectFetchVesselGroupReportFlagChangeParams) + + const { error, isLoading } = useGetVesselGroupInsightQuery(fetchVesselGroupParams, { + skip: !vesselGroup, + }) + + const vesselsWithFlagChanges = useSelector(selectVGRFlagChangesVessels) + + return ( +
+
+ + +
+ {guestUser ? ( + + ) : isLoading || !vesselGroup ? ( + + ) : error ? ( + + ) : !vesselsWithFlagChanges || vesselsWithFlagChanges.length === 0 ? ( + + {t( + 'vesselGroupReport.insights.flagChangesEmpty', + 'There are no vessels with flag changes' + )} + + ) : ( +
+ isOpen !== isExpanded && setIsExpanded(!isExpanded)} + > +
    + {vesselsWithFlagChanges.map((vessel) => ( +
  • + {formatInfoField(vessel.identity.shipname, 'name')} ( + {vessel.flagsChanges?.valuesInThePeriod.map((v) => + formatInfoField(v.value, 'flag') + )} + ) +
  • + ))} +
+
+
+ )} +
+ ) +} + +export default VesselGroupReportInsightFlagChange diff --git a/apps/fishing-map/features/vessel-group-report/insights/VGRInsightGaps.tsx b/apps/fishing-map/features/vessel-group-report/insights/VGRInsightGaps.tsx new file mode 100644 index 0000000000..540266a82d --- /dev/null +++ b/apps/fishing-map/features/vessel-group-report/insights/VGRInsightGaps.tsx @@ -0,0 +1,115 @@ +import { useTranslation } from 'react-i18next' +import { useGetVesselGroupInsightQuery } from 'queries/vessel-insight-api' +import { useState } from 'react' +import cx from 'classnames' +import { useSelector } from 'react-redux' +import { ParsedAPIError } from '@globalfishingwatch/api-client' +import { Collapsable } from '@globalfishingwatch/ui-components' +import InsightError from 'features/vessel/insights/InsightErrorMessage' +import DataTerminology from 'features/vessel/identity/DataTerminology' +import { formatInfoField } from 'utils/info' +import { selectVGRData } from '../vessel-group-report.slice' +import { selectFetchVesselGroupReportGapParams } from '../vessel-group-report.selectors' +import styles from './VGRInsights.module.css' +import VesselGroupReportInsightPlaceholder from './VGRInsightsPlaceholders' +import VesselGroupReportInsightVesselEvents from './VGRInsightVesselEvents' +import { selectVGRGapVessels } from './vessel-group-report-insights.selectors' + +const VesselGroupReportInsightGap = () => { + const { t } = useTranslation() + const [isExpanded, setIsExpanded] = useState(false) + const [expandedVesselIds, setExpandedVesselIds] = useState([]) + const vesselGroup = useSelector(selectVGRData) + const fetchVesselGroupParams = useSelector(selectFetchVesselGroupReportGapParams) + + const { error, isLoading } = useGetVesselGroupInsightQuery(fetchVesselGroupParams, { + skip: !vesselGroup, + }) + const vesselsWithGaps = useSelector(selectVGRGapVessels) + + return ( +
+
+ + +
+ {isLoading || !vesselGroup ? ( + + ) : error ? ( + + ) : !vesselsWithGaps || vesselsWithGaps?.length === 0 ? ( +

+ {t('vessel.insights.gapsEventsEmpty', 'No AIS Off events detected')} +

+ ) : ( +
+ acc + vessel.periodSelectedCounters.eventsGapOff, + 0 + ), + vessels: vesselsWithGaps.length, + })} + onToggle={(isOpen) => isOpen !== isExpanded && setIsExpanded(!isExpanded)} + > + {vesselsWithGaps && vesselsWithGaps?.length > 0 && ( +
    + {vesselsWithGaps.map((vessel) => { + const vesselId = vessel.identity.id + const isExpandedVessel = expandedVesselIds.includes(vesselId) + return ( +
  • + + {formatInfoField(vessel.identity.shipname, 'name')}{' '} + + ({vessel.periodSelectedCounters.eventsGapOff}) + + + } + onToggle={(isOpen, id) => { + setExpandedVesselIds((expandedIds) => { + return isOpen && id + ? [...expandedIds, id] + : expandedIds.filter((vesselId) => vesselId !== id) + }) + }} + > + {isExpandedVessel && vessel.datasets[0] && ( + + )} + +
  • + ) + })} +
+ )} +
+
+ )} +
+ ) +} + +export default VesselGroupReportInsightGap diff --git a/apps/fishing-map/features/vessel-group-report/insights/VGRInsightIUU.tsx b/apps/fishing-map/features/vessel-group-report/insights/VGRInsightIUU.tsx new file mode 100644 index 0000000000..4f40342141 --- /dev/null +++ b/apps/fishing-map/features/vessel-group-report/insights/VGRInsightIUU.tsx @@ -0,0 +1,73 @@ +import { useTranslation } from 'react-i18next' +import { useGetVesselGroupInsightQuery } from 'queries/vessel-insight-api' +import { useSelector } from 'react-redux' +import cx from 'classnames' +import { useState } from 'react' +import { ParsedAPIError } from '@globalfishingwatch/api-client' +import { Collapsable } from '@globalfishingwatch/ui-components' +import InsightError from 'features/vessel/insights/InsightErrorMessage' +import DataTerminology from 'features/vessel/identity/DataTerminology' +import { selectVGRData } from '../vessel-group-report.slice' +import { selectFetchVesselGroupReportIUUParams } from '../vessel-group-report.selectors' +import styles from './VGRInsights.module.css' +import VesselGroupReportInsightPlaceholder from './VGRInsightsPlaceholders' +import { selectVGRIUUVessels } from './vessel-group-report-insights.selectors' +import VesselGroupReportInsightVesselTable from './VGRInsightVesselsTable' + +const VesselGroupReportInsightIUU = () => { + const { t } = useTranslation() + const [isExpanded, setIsExpanded] = useState(false) + const vesselGroup = useSelector(selectVGRData) + const fetchVesselGroupParams = useSelector(selectFetchVesselGroupReportIUUParams) + + const { error, isLoading } = useGetVesselGroupInsightQuery(fetchVesselGroupParams, { + skip: !vesselGroup, + }) + const vesselsWithIIU = useSelector(selectVGRIUUVessels) + + return ( +
+
+ + +
+ {isLoading || !vesselGroup ? ( + + ) : error ? ( + + ) : !vesselsWithIIU || vesselsWithIIU.length === 0 ? ( + + {t( + 'vesselGroupReport.insights.IUUBlackListsEmpty', + 'No vessels are present on a RFMO IUU vessel list' + )} + + ) : ( +
+ isOpen !== isExpanded && setIsExpanded(!isExpanded)} + > +
+ +
+
+
+ )} +
+ ) +} + +export default VesselGroupReportInsightIUU diff --git a/apps/fishing-map/features/vessel-group-report/insights/VGRInsightMOU.tsx b/apps/fishing-map/features/vessel-group-report/insights/VGRInsightMOU.tsx new file mode 100644 index 0000000000..3c02b80a4e --- /dev/null +++ b/apps/fishing-map/features/vessel-group-report/insights/VGRInsightMOU.tsx @@ -0,0 +1,172 @@ +import { useTranslation } from 'react-i18next' +import { useGetVesselGroupInsightQuery } from 'queries/vessel-insight-api' +import { useState } from 'react' +import cx from 'classnames' +import { useSelector } from 'react-redux' +import { groupBy } from 'es-toolkit' +import { ParsedAPIError } from '@globalfishingwatch/api-client' +import { Collapsable } from '@globalfishingwatch/ui-components' +import InsightError from 'features/vessel/insights/InsightErrorMessage' +import DataTerminology from 'features/vessel/identity/DataTerminology' +import { selectIsGuestUser } from 'features/user/selectors/user.selectors' +import VesselIdentityFieldLogin from 'features/vessel/identity/VesselIdentityFieldLogin' +import { formatInfoField } from 'utils/info' +import { selectVGRData } from '../vessel-group-report.slice' +import { selectFetchVesselGroupReportMOUParams } from '../vessel-group-report.selectors' +import styles from './VGRInsights.module.css' +import VesselGroupReportInsightPlaceholder from './VGRInsightsPlaceholders' +import { + MOUInsightCountry, + MOUInsightList, + MouVesselByCategoryInsight, + MOUVesselByList, + selectVGRMOUVesselsGrouped, +} from './vessel-group-report-insights.selectors' + +type ExpandedMOUInsights = `${MOUInsightCountry}-${MOUInsightList}` + +const VesselsInMOUByCategory = ({ + insights, + onToggle, + expanded, + label, +}: { + insights: MouVesselByCategoryInsight[] + onToggle: (isOpen: boolean) => void + expanded: boolean + label: string +}) => { + const insightsByVessel = groupBy(insights, (i) => i.vessel.id) + return ( + + {Object.keys(insightsByVessel)?.length > 0 && ( +
    + {Object.values(insightsByVessel).map((insights) => { + const name = formatInfoField(insights[0].vessel.shipname, 'name') + const flags = Array.from(new Set(insights.map((i) => i.insight.reference))) + return ( +
  • + {name} ({flags.map((f) => formatInfoField(f, 'flag')).join(',')}) +
  • + ) + })} +
+ )} +
+ ) +} + +const VesselGroupReportInsightMOU = () => { + const { t } = useTranslation() + const guestUser = useSelector(selectIsGuestUser) + const [insightsExpanded, setInsightsExpanded] = useState([]) + const vesselGroup = useSelector(selectVGRData) + const fetchVesselGroupParams = useSelector(selectFetchVesselGroupReportMOUParams) + + const { error, isLoading } = useGetVesselGroupInsightQuery(fetchVesselGroupParams, { + skip: !vesselGroup, + }) + + const MOUVesselsGrouped = useSelector(selectVGRMOUVesselsGrouped) || {} + + const hasVesselsInParisMOU = MOUVesselsGrouped?.paris + ? Object.values(MOUVesselsGrouped?.paris).some((vessels) => vessels.length > 0) + : false + const hasVesselsInTokyoMOU = MOUVesselsGrouped?.tokyo + ? Object.values(MOUVesselsGrouped?.tokyo).some((vessels) => vessels.length > 0) + : false + + const getVesselsInMOU = (vessels: MOUVesselByList, country: MOUInsightCountry) => { + const onToggle = (isOpen: boolean, list: MOUInsightList) => { + setInsightsExpanded((expandedInsights) => { + const expandedInsight = `${country}-${list}` as ExpandedMOUInsights + if (isOpen && expandedInsights.includes(expandedInsight)) { + return expandedInsights + } + return isOpen + ? [...expandedInsights, expandedInsight] + : expandedInsights.filter((insight) => insight !== expandedInsight) + }) + } + return (['black', 'grey'] as MOUInsightList[]).map((list) => { + const vesselInsights = vessels[list] + if (!vesselInsights || vesselInsights.length === 0) { + return null + } + const uniqVessels = Array.from(new Set(vesselInsights.map((v) => v.vessel.id))) + return ( +
+ onToggle(isOpen, list)} + label={t(`vesselGroupReport.insights.MOUListsCount`, { + vessels: uniqVessels.length, + list: t(`insights.lists.${list}`, list), + defaultValue: `{{vessels}} vessels operated under a flag present on the {{list}} list`, + })} + /> +
+ ) + }) + } + + return ( +
+
+ + +
+ {guestUser ? ( + + ) : isLoading || !vesselGroup ? ( + + ) : error ? ( + + ) : ( +
+
+ + {hasVesselsInParisMOU ? ( + getVesselsInMOU(MOUVesselsGrouped.paris, 'paris') + ) : ( +

+ {t('vesselGroupReport.insights.MOUListsEmpty', { + defaultValue: + 'No vessels flying under a flag present on the {{country}} MOU black or grey lists', + country: t('insights.countries.paris', 'Paris'), + })} +

+ )} + + {hasVesselsInTokyoMOU ? ( + getVesselsInMOU(MOUVesselsGrouped.tokyo, 'tokyo') + ) : ( +

+ {t('vesselGroupReport.insights.MOUListsEmpty', { + defaultValue: + 'No vessels flying under a flag present on the {{country}} MOU black or grey lists', + country: t('insights.countries.tokyo', 'Tokyo'), + })} +

+ )} +
+
+ )} +
+ ) +} + +export default VesselGroupReportInsightMOU diff --git a/apps/fishing-map/features/vessel-group-report/insights/VGRInsightVesselEvents.tsx b/apps/fishing-map/features/vessel-group-report/insights/VGRInsightVesselEvents.tsx new file mode 100644 index 0000000000..9c7c059725 --- /dev/null +++ b/apps/fishing-map/features/vessel-group-report/insights/VGRInsightVesselEvents.tsx @@ -0,0 +1,45 @@ +import { useGetVesselEventsQuery } from 'queries/vessel-events-api' +import { Spinner } from '@globalfishingwatch/ui-components' +import VesselEvent from 'features/vessel/activity/event/Event' +import styles from './VGRInsights.module.css' + +const VesselGroupReportInsightVesselEvents = ({ + ids, + vesselId, + datasetId, + start, + end, +}: { + ids?: string[] + vesselId?: string + datasetId: string + start: string + end: string +}) => { + const { data, isLoading } = useGetVesselEventsQuery( + { + ...(vesselId && { vessels: [vesselId] }), + ...(ids && { ids: ids }), + datasets: [datasetId], + 'start-date': start, + 'end-date': end, + }, + { skip: !ids && !vesselId } + ) + if (isLoading) { + return + } + if (!data?.entries) { + return null + } + + return ( +
    + {data.entries.map((event) => ( + + ))} +
+ ) +} + +export default VesselGroupReportInsightVesselEvents diff --git a/apps/fishing-map/features/vessel-group-report/insights/VGRInsightVesselsTable.module.css b/apps/fishing-map/features/vessel-group-report/insights/VGRInsightVesselsTable.module.css new file mode 100644 index 0000000000..12ff1f5671 --- /dev/null +++ b/apps/fishing-map/features/vessel-group-report/insights/VGRInsightVesselsTable.module.css @@ -0,0 +1,55 @@ +.tableContainer { + margin-top: 2.5rem; + min-height: 42rem; +} + +.vesselsTable { + width: 100%; + border-collapse: collapse; + display: grid; + grid-template-columns: repeat(4, 1fr); +} + +.header { + text-align: left; + font: var(--font-S); + color: var(--color-secondary-blue); + text-transform: uppercase; +} + +.vesselsTable > div:not(.header) { + text-overflow: ellipsis; + white-space: nowrap; + height: 4rem; + padding-block: 0.5rem; +} + +.vesselsTable > div:not(.header, .icon) { + overflow: hidden; + padding-block: 0.8rem; +} + +.vesselsTable > div:not(.header, .icon, .right) { + padding-right: 1rem; +} + +.right { + text-align: right; +} + +.border { + border-bottom: 1px solid var(--color-terthiary-blue); +} + +.pointer { + cursor: pointer; +} + +.error { + color: var(--color-secondary-blue); + margin-bottom: var(--space-S); +} + +.link { + text-decoration: underline; +} diff --git a/apps/fishing-map/features/vessel-group-report/insights/VGRInsightVesselsTable.tsx b/apps/fishing-map/features/vessel-group-report/insights/VGRInsightVesselsTable.tsx new file mode 100644 index 0000000000..c3ba2a4e0e --- /dev/null +++ b/apps/fishing-map/features/vessel-group-report/insights/VGRInsightVesselsTable.tsx @@ -0,0 +1,58 @@ +import { useTranslation } from 'react-i18next' +import cx from 'classnames' +import { Fragment } from 'react' +import VesselLink from 'features/vessel/VesselLink' +import { EMPTY_FIELD_PLACEHOLDER, formatInfoField, getVesselGearTypeLabel } from 'utils/info' +import styles from './VGRInsightVesselsTable.module.css' +import { VesselGroupReportInsightVessel } from './vessel-group-report-insights.selectors' + +const VesselGroupReportInsightVesselTable = ({ + vessels, +}: { + vessels: VesselGroupReportInsightVessel[] +}) => { + const { t } = useTranslation() + return ( +
+
{t('common.name', 'Name')}
+
{t('vessel.mmsi', 'mmsi')}
+
{t('layer.flagState_one', 'Flag state')}
+
{t('vessel.geartype', 'Gear Type')}
+ + {vessels.map((vessel, i) => { + const vesselId = vessel.identity.id + const isLastRow = i === vessels.length - 1 + return ( + +
+ + {formatInfoField(vessel.identity.shipname, 'name')} + +
+
+ {vessel.identity.ssvid || EMPTY_FIELD_PLACEHOLDER} +
+
+ + {formatInfoField(vessel.identity.flag, 'flag') || EMPTY_FIELD_PLACEHOLDER} + +
+
+ {getVesselGearTypeLabel(vessel.identity) || EMPTY_FIELD_PLACEHOLDER} +
+
+ ) + })} +
+ ) +} + +export default VesselGroupReportInsightVesselTable diff --git a/apps/fishing-map/features/vessel-group-report/insights/VGRInsights.module.css b/apps/fishing-map/features/vessel-group-report/insights/VGRInsights.module.css new file mode 100644 index 0000000000..295bea7b3b --- /dev/null +++ b/apps/fishing-map/features/vessel-group-report/insights/VGRInsights.module.css @@ -0,0 +1,135 @@ +.placeholder { + padding-block: var(--space-XL); +} + +.disclaimer { + display: flex; + align-items: top; + margin: var(--space-L) var(--space-M); + font: var(--font-S); + color: var(--color-secondary-blue); +} + +.nested { + margin-left: var(--space-M); +} + +.row { + margin-block: var(--space-XS); +} + +.disclaimer svg { + margin-top: 0.2rem; +} + +.container { + padding-inline: var(--space-M); +} + +.title { + padding-top: var(--space-M); +} + +.insightContainer { + padding-block: var(--space-M); + border-bottom: var(--border); +} + +.insightContainer:last-child { + border-bottom: none; + padding-bottom: var(--space-L); +} + +.insightTitle { + display: flex; + align-items: center; +} + +.coverageBar { + margin-top: var(--space-M); + position: relative; + width: 20rem; + height: 0.5rem; + border-radius: 0.5rem; + background-image: linear-gradient(90deg, rgba(22, 63, 137, 0.2) 0%, rgba(22, 63, 137, 0.8) 100%); +} + +.coverageIndicator { + position: absolute; + width: 10rem; + transform: translate(-50%, -1.9rem); + display: flex; + flex-direction: column; + align-items: center; +} + +.coverageDot { + width: 1.2rem; + height: 1.2rem; + border-radius: 0.6rem; + background: var(--color-primary-blue); + border: 1px solid var(--color-white); +} + +.coverageLabel { + text-align: center; + line-height: 1; +} + +.secondary { + color: var(--color-secondary-blue); +} + +.eventDetailsList li { + padding-left: 0; + margin-left: var(--space-M); +} + +.eventDetailsList li:not(:last-child) { + border-bottom: var(--border); +} + +.seeMoreBtn { + top: 0.4rem; + margin-left: var(--space-XS); +} + +.insightContainer .collapsable { + justify-content: flex-start; +} + +.insightContainer .collapsable::before { + display: none; + content: ''; +} + +.insightContainer .collapsableLabel { + padding-left: 0; + font: var(--font-M); + color: var(--color-primary-blue); + text-transform: none; +} + +.loadingPlaceholder { + height: 1.2rem; + border-radius: 0.8rem; + background: linear-gradient( + 90deg, + rgba(var(--primary-blue-rgb), 0.1) 0%, + rgba(var(--primary-blue-rgb), 0.2) 40%, + rgba(var(--primary-blue-rgb), 0.1) 70% + ); + background-size: 1000px 100%; + animation: placeHolderShimmer 2s linear forwards infinite; + margin-block: var(--space-XS); +} + +@keyframes placeHolderShimmer { + 0% { + background-position: -500px 0; + } + + 100% { + background-position: 500px 0; + } +} diff --git a/apps/fishing-map/features/vessel-group-report/insights/VGRInsights.tsx b/apps/fishing-map/features/vessel-group-report/insights/VGRInsights.tsx new file mode 100644 index 0000000000..adcf651d5b --- /dev/null +++ b/apps/fishing-map/features/vessel-group-report/insights/VGRInsights.tsx @@ -0,0 +1,60 @@ +import { useTranslation } from 'react-i18next' +import { useSelector } from 'react-redux' +import { DateTime } from 'luxon' +import { Icon } from '@globalfishingwatch/ui-components' +import { selectTimeRange } from 'features/app/selectors/app.timebar.selectors' +import { formatI18nDate } from 'features/i18n/i18nDate' +import DataTerminology from 'features/vessel/identity/DataTerminology' +import { MIN_INSIGHTS_YEAR } from 'features/vessel/insights/insights.config' +import styles from './VGRInsights.module.css' +import VesselGroupReportInsightCoverage from './VGRInsightCoverage' +import VesselGroupReportInsightGap from './VGRInsightGaps' +import VesselGroupReportInsightIUU from './VGRInsightIUU' +import VesselGroupReportInsightFishing from './VGRInsightFishing' +import VesselGroupReportInsightFlagChange from './VGRInsightFlagChange' +import VesselGroupReportInsightMOU from './VGRInsightMOU' + +const VesselGroupReportInsights = () => { + const { t } = useTranslation() + const { start, end } = useSelector(selectTimeRange) + + if (DateTime.fromISO(start).year < MIN_INSIGHTS_YEAR) { + return ( +
+ + {t('vessel.insights.disclaimerTimeRangeBeforeMinYear', { + defaultValue: + 'Insights available from 1 January {{year}} onwards. Adjust your time range to view insights.', + year: MIN_INSIGHTS_YEAR, + })} +
+ ) + } + + return ( +
+

{t('vessel.sectionInsights', 'Insights')}

+

+ {t('vessel.insights.sectionTitle', { + defaultValue: 'Vessel insights between {{start}} and {{end}}', + start: formatI18nDate(start), + end: formatI18nDate(end), + })} + +

+ + + + + + +
+ ) +} + +export default VesselGroupReportInsights diff --git a/apps/fishing-map/features/vessel-group-report/insights/VGRInsightsPlaceholders.module.css b/apps/fishing-map/features/vessel-group-report/insights/VGRInsightsPlaceholders.module.css new file mode 100644 index 0000000000..5e2066f122 --- /dev/null +++ b/apps/fishing-map/features/vessel-group-report/insights/VGRInsightsPlaceholders.module.css @@ -0,0 +1,23 @@ +.loadingPlaceholder { + height: 1.2rem; + border-radius: 0.8rem; + background: linear-gradient( + 90deg, + rgba(var(--primary-blue-rgb), 0.1) 0%, + rgba(var(--primary-blue-rgb), 0.2) 40%, + rgba(var(--primary-blue-rgb), 0.1) 70% + ); + background-size: 1000px 100%; + animation: placeHolderShimmer 2s linear forwards infinite; + margin-block: var(--space-XS); +} + +@keyframes placeHolderShimmer { + 0% { + background-position: -500px 0; + } + + 100% { + background-position: 500px 0; + } +} diff --git a/apps/fishing-map/features/vessel-group-report/insights/VGRInsightsPlaceholders.tsx b/apps/fishing-map/features/vessel-group-report/insights/VGRInsightsPlaceholders.tsx new file mode 100644 index 0000000000..175313d3c2 --- /dev/null +++ b/apps/fishing-map/features/vessel-group-report/insights/VGRInsightsPlaceholders.tsx @@ -0,0 +1,8 @@ +import styles from './VGRInsightsPlaceholders.module.css' + +// TODO graph bar placeholder +export const VesselGroupReportInsightPlaceholder = () => { + return
+} + +export default VesselGroupReportInsightPlaceholder diff --git a/apps/fishing-map/features/vessel-group-report/insights/vessel-group-report-insights.selectors.ts b/apps/fishing-map/features/vessel-group-report/insights/vessel-group-report-insights.selectors.ts new file mode 100644 index 0000000000..ce7b28d287 --- /dev/null +++ b/apps/fishing-map/features/vessel-group-report/insights/vessel-group-report-insights.selectors.ts @@ -0,0 +1,132 @@ +import { createSelector } from '@reduxjs/toolkit' +import get from 'lodash/get' +import { RootState } from 'reducers' +import { + InsightFishing, + InsightGaps, + InsightIdentity, + InsightIdentityFlagsChanges, + InsightIdentityIUU, + InsightIdentityMOU, + InsightValueInPeriod, + VesselGroupInsight, + VesselGroupInsightResponse, +} from '@globalfishingwatch/api-types' +import { getSearchIdentityResolved, getVesselId } from 'features/vessel/vessel.utils' +import { VesselLastIdentity } from 'features/search/search.slice' +import { + selectVGRFishingInsightData, + selectVGRFlagChangeInsightData, + selectVGRGapInsightData, + selectVGRIUUInsightData, + selectVGRMOUInsightData, +} from '../vessel-group-report.selectors' +import { selectVGRData } from '../vessel-group-report.slice' + +export type VesselGroupReportInsightVessel = VesselGroupInsight & { + identity: VesselLastIdentity +} + +export const selectVGRVesselsByInsight = ( + insightSelector: (state: RootState) => VesselGroupInsightResponse | undefined, + insightProperty: keyof Omit, + insightCounter?: string +) => { + return createSelector([insightSelector, selectVGRData], (data, vesselGroup) => { + if (!data || !vesselGroup) { + return [] + } + const insightVessels = vesselGroup?.vessels?.flatMap((vessel) => { + const vesselWithInsight = data?.[insightProperty]?.find( + (v) => v.vesselId === getVesselId(vessel) + ) + if (!vesselWithInsight || (insightCounter && get(vesselWithInsight, insightCounter) === 0)) { + return [] + } + return { ...vesselWithInsight, identity: getSearchIdentityResolved(vessel) } + }) + return insightVessels.sort((a, b) => { + if (insightCounter) { + const countA = get(a, insightCounter) + const countB = get(b, insightCounter) + if (countA === countB) return a.identity.shipname?.localeCompare(b.identity.shipname) || 0 + return countB - countA + } + return a.identity.shipname?.localeCompare(b.identity.shipname) || 0 + }) as VesselGroupReportInsightVessel>[] + }) +} + +export const selectVGRGapVessels = selectVGRVesselsByInsight( + selectVGRGapInsightData, + 'gap', + 'periodSelectedCounters.eventsGapOff' +) + +export const selectVGRVesselsWithNoTakeMpas = selectVGRVesselsByInsight( + selectVGRFishingInsightData, + 'apparentFishing', + 'periodSelectedCounters.eventsInNoTakeMPAs' +) + +export const selectVGRVesselsInRfmoWithoutKnownAuthorization = + selectVGRVesselsByInsight( + selectVGRFishingInsightData, + 'apparentFishing', + 'periodSelectedCounters.eventsInRFMOWithoutKnownAuthorization' + ) + +export const selectVGRIUUVessels = selectVGRVesselsByInsight>( + selectVGRIUUInsightData, + 'vesselIdentity', + 'iuuVesselList.totalTimesListedInThePeriod' +) + +export const selectVGRFlagChangesVessels = selectVGRVesselsByInsight< + InsightIdentity +>(selectVGRFlagChangeInsightData, 'vesselIdentity', 'flagsChanges.totalTimesListedInThePeriod') + +export const selectVGRMOUVessels = selectVGRVesselsByInsight>( + selectVGRMOUInsightData, + 'vesselIdentity' +) + +export type MouVesselByCategoryInsight = { + vessel: VesselLastIdentity + insight: InsightValueInPeriod +} + +export type MOUInsightCountry = 'paris' | 'tokyo' +export type MOUInsightList = 'black' | 'grey' +export type MOUVesselByList = Record +export type MOUVesselsGrouped = Record +export const selectVGRMOUVesselsGrouped = createSelector([selectVGRMOUVessels], (vessels) => { + return vessels?.reduce( + (acc, vessel) => { + if ( + vessel.mouList?.tokyo?.totalTimesListedInThePeriod && + vessel.mouList?.tokyo?.totalTimesListedInThePeriod > 0 + ) { + vessel.mouList.tokyo.valuesInThePeriod.forEach((value) => { + acc.tokyo[value.value.toLowerCase() as keyof MOUVesselByList]?.push({ + vessel: vessel.identity, + insight: value, + }) + }) + } + if ( + vessel.mouList?.paris?.totalTimesListedInThePeriod && + vessel.mouList?.paris?.totalTimesListedInThePeriod > 0 + ) { + vessel.mouList.paris.valuesInThePeriod.forEach((value) => { + acc.paris[value.value.toLowerCase() as keyof MOUVesselByList]?.push({ + vessel: vessel.identity, + insight: value, + }) + }) + } + return acc + }, + { tokyo: { black: [], grey: [] }, paris: { black: [], grey: [] } } as MOUVesselsGrouped + ) +}) diff --git a/apps/fishing-map/features/vessel-group-report/vessel-group-report.config.ts b/apps/fishing-map/features/vessel-group-report/vessel-group-report.config.ts index 3612ff4b2a..1e9acb1504 100644 --- a/apps/fishing-map/features/vessel-group-report/vessel-group-report.config.ts +++ b/apps/fishing-map/features/vessel-group-report/vessel-group-report.config.ts @@ -5,15 +5,15 @@ export const OTHER_CATEGORY_LABEL = 'OTHER' export const DEFAULT_VESSEL_GROUP_REPORT_STATE: VesselGroupReportState = { viewOnlyVesselGroup: true, - vesselGroupReportSection: 'vessels', - vesselGroupReportVesselsSubsection: 'flag', - vesselGroupReportActivitySubsection: 'fishing-effort', + vGRSection: 'vessels', + vGRVesselsSubsection: 'flag', + vGRActivitySubsection: 'fishing-effort', vGREventsSubsection: 'encounter-events', vGREventsVesselsProperty: 'flag', - vesselGroupReportVesselPage: 0, - vesselGroupReportResultsPerPage: REPORT_VESSELS_PER_PAGE, + vGRVesselPage: 0, + vGRVesselsResultsPerPage: REPORT_VESSELS_PER_PAGE, vGREventsVesselPage: 0, vGREventsResultsPerPage: REPORT_VESSELS_PER_PAGE, - vesselGroupReportVesselsOrderProperty: 'shipname', - vesselGroupReportVesselsOrderDirection: 'asc', + vGRVesselsOrderProperty: 'shipname', + vGRVesselsOrderDirection: 'asc', } diff --git a/apps/fishing-map/features/vessel-group-report/vessel-group-report.selectors.ts b/apps/fishing-map/features/vessel-group-report/vessel-group-report.selectors.ts new file mode 100644 index 0000000000..48e1b42853 --- /dev/null +++ b/apps/fishing-map/features/vessel-group-report/vessel-group-report.selectors.ts @@ -0,0 +1,83 @@ +import { createSelector } from '@reduxjs/toolkit' +import { + selectVesselGroupInsight, + selectVesselGroupInsightApiSlice, + VesselGroupInsightParams, +} from 'queries/vessel-insight-api' +import { RootState } from 'reducers' +import { InsightType } from '@globalfishingwatch/api-types' +import { selectActiveDataviewInstancesResolved } from 'features/dataviews/selectors/dataviews.instances.selectors' +import { selectReportVesselGroupId } from 'routes/routes.selectors' +import { selectTimeRange } from 'features/app/selectors/app.timebar.selectors' + +export const COVERAGE_INSIGHT_ID = 'COVERAGE' as InsightType +export const GAP_INSIGHT_ID = 'GAP' as InsightType +export const FISHING_INSIGHT_ID = 'FISHING' as InsightType +export const IUU_INSIGHT_ID = 'VESSEL-IDENTITY-IUU-VESSEL-LIST' as InsightType +export const FLAG_CHANGE_INSIGHT_ID = 'VESSEL-IDENTITY-FLAG-CHANGES' as InsightType +export const MOU_INSIGHT_ID = 'VESSEL-IDENTITY-MOU-LIST' as InsightType + +export const selectVGRDataview = createSelector( + [selectActiveDataviewInstancesResolved, selectReportVesselGroupId], + (dataviews, reportVesselGroupId) => { + return dataviews?.find(({ config }) => + config?.filters?.['vessel-groups'].includes(reportVesselGroupId) + ) + } +) + +export const selectBaseVesselGroupReportParams = createSelector( + [selectTimeRange, selectReportVesselGroupId], + ({ start, end }, reportVesselGroupId) => { + return { + vesselGroupId: reportVesselGroupId, + start, + end, + } + } +) + +export const selectFetchVGRParamsByInsight = (insight: InsightType) => + createSelector([selectBaseVesselGroupReportParams], (params) => { + return { ...params, insight } + }) + +export const selectFetchVesselGroupReportFishingParams = + selectFetchVGRParamsByInsight(FISHING_INSIGHT_ID) +export const selectFetchVesselGroupReportCoverageParams = + selectFetchVGRParamsByInsight(COVERAGE_INSIGHT_ID) +export const selectFetchVesselGroupReportGapParams = selectFetchVGRParamsByInsight(GAP_INSIGHT_ID) +export const selectFetchVesselGroupReportIUUParams = selectFetchVGRParamsByInsight(IUU_INSIGHT_ID) +export const selectFetchVesselGroupReportFlagChangeParams = + selectFetchVGRParamsByInsight(FLAG_CHANGE_INSIGHT_ID) +export const selectFetchVesselGroupReportMOUParams = selectFetchVGRParamsByInsight(MOU_INSIGHT_ID) + +export const selectVGRInsightDataById = ( + selector: (state: RootState) => VesselGroupInsightParams +) => { + return createSelector( + [selectVesselGroupInsightApiSlice, selector], + (vesselInsightApi, params) => { + return selectVesselGroupInsight(params)({ vesselInsightApi })?.data + } + ) +} + +export const selectVGRGapInsightData = selectVGRInsightDataById( + selectFetchVesselGroupReportGapParams +) +export const selectVGRCoverageInsightData = selectVGRInsightDataById( + selectFetchVesselGroupReportCoverageParams +) +export const selectVGRFishingInsightData = selectVGRInsightDataById( + selectFetchVesselGroupReportFishingParams +) +export const selectVGRIUUInsightData = selectVGRInsightDataById( + selectFetchVesselGroupReportIUUParams +) +export const selectVGRFlagChangeInsightData = selectVGRInsightDataById( + selectFetchVesselGroupReportFlagChangeParams +) +export const selectVGRMOUInsightData = selectVGRInsightDataById( + selectFetchVesselGroupReportMOUParams +) diff --git a/apps/fishing-map/features/vessel-group-report/vessel-group-report.slice.ts b/apps/fishing-map/features/vessel-group-report/vessel-group-report.slice.ts index e771088d4d..837ced174e 100644 --- a/apps/fishing-map/features/vessel-group-report/vessel-group-report.slice.ts +++ b/apps/fishing-map/features/vessel-group-report/vessel-group-report.slice.ts @@ -1,4 +1,5 @@ import { createSlice, createAsyncThunk } from '@reduxjs/toolkit' +import { stringify } from 'qs' import { GFWAPI } from '@globalfishingwatch/api-client' import { APIPagination, IdentityVessel, VesselGroup } from '@globalfishingwatch/api-types' import { AsyncError, AsyncReducerStatus } from 'utils/async-slice' @@ -30,7 +31,8 @@ export const fetchVesselGroupReportThunk = createAsyncThunk( try { const vesselGroup = await GFWAPI.fetch(`/vessel-groups/${vesselGroupId}`) const vesselGroupVessels = await GFWAPI.fetch>( - `/vessels?vessel-groups[0]=${vesselGroupId}` + `/vessels?${stringify({ 'vessel-groups': [vesselGroupId] })}`, + { cache: 'reload' } ) return { ...vesselGroup, @@ -86,13 +88,12 @@ const vesselGroupReportSlice = createSlice({ export const { resetReportData } = vesselGroupReportSlice.actions -export const selectVesselGroupReportStatus = (state: VesselGroupReportSliceState) => +export const selectVGRStatus = (state: VesselGroupReportSliceState) => state.vesselGroupReport.status -export const selectVesselGroupReportError = (state: VesselGroupReportSliceState) => - state.vesselGroupReport.error -export const selectVesselGroupReportData = (state: VesselGroupReportSliceState) => +export const selectVGRError = (state: VesselGroupReportSliceState) => state.vesselGroupReport.error +export const selectVGRData = (state: VesselGroupReportSliceState) => state.vesselGroupReport.vesselGroup -export const selectVesselGroupReportVessels = (state: VesselGroupReportSliceState) => +export const selectVGRVessels = (state: VesselGroupReportSliceState) => state.vesselGroupReport.vesselGroup?.vessels export default vesselGroupReportSlice.reducer diff --git a/apps/fishing-map/features/vessel-group-report/vessel-group.config.selectors.ts b/apps/fishing-map/features/vessel-group-report/vessel-group.config.selectors.ts index 36ae19d559..a87d819b81 100644 --- a/apps/fishing-map/features/vessel-group-report/vessel-group.config.selectors.ts +++ b/apps/fishing-map/features/vessel-group-report/vessel-group.config.selectors.ts @@ -8,9 +8,7 @@ import { DEFAULT_VESSEL_GROUP_REPORT_STATE } from './vessel-group-report.config' type VesselGroupReportProperty

= Required[P] -function selectVesselGroupReportStateProperty

( - property: P -) { +function selectVGRStateProperty

(property: P) { return createSelector( [selectQueryParam(property)], (urlProperty): VesselGroupReportProperty

=> { @@ -20,39 +18,19 @@ function selectVesselGroupReportStateProperty

{timeRange && vessels && flags && ( @@ -56,13 +53,13 @@ function VesselGroupReportVessels() { data={data} color={reportDataview?.config?.color} property={subsection as VesselGroupReportVesselsGraphProperty} - filterQueryParam="vesselGroupReportVesselFilter" - pageQueryParam="vesselGroupReportVesselPage" + filterQueryParam="vGRVesselFilter" + pageQueryParam="vGRVesselPage" />

diff --git a/apps/fishing-map/features/vessel-group-report/vessels/VesselGroupReportVesselsGraph.tsx b/apps/fishing-map/features/vessel-group-report/vessels/VesselGroupReportVesselsGraph.tsx index d6af95b680..bdfa910e3e 100644 --- a/apps/fishing-map/features/vessel-group-report/vessels/VesselGroupReportVesselsGraph.tsx +++ b/apps/fishing-map/features/vessel-group-report/vessels/VesselGroupReportVesselsGraph.tsx @@ -3,14 +3,19 @@ import cx from 'classnames' import { BarChart, Bar, XAxis, Tooltip, ResponsiveContainer, LabelList } from 'recharts' import { useTranslation } from 'react-i18next' import { VesselGroupEventsStatsResponseGroups } from 'queries/vessel-group-events-stats-api' +import { useSelector } from 'react-redux' import I18nNumber, { formatI18nNumber } from 'features/i18n/i18nNumber' import { EMPTY_API_VALUES, OTHERS_CATEGORY_LABEL } from 'features/area-report/reports.config' import { formatInfoField } from 'utils/info' import { useLocationConnect } from 'routes/routes.hook' +import { selectVGRVesselsSubsection } from 'features/vessel-group-report/vessel-group.config.selectors' +import { selectVGRVesselsGraphDataGrouped } from 'features/vessel-group-report/vessels/vessel-group-report-vessels.selectors' import { VesselGroupReportState, VesselGroupReportVesselsSubsection, } from 'features/vessel-groups/vessel-groups.types' +import { COLOR_PRIMARY_BLUE } from 'features/app/app.config' +import { selectVGRDataview } from '../vessel-group-report.selectors' import styles from './VesselGroupReportVesselsGraph.module.css' type ReportGraphTooltipProps = { @@ -65,6 +70,7 @@ const CustomTick = (props: any) => { const { x, y, payload, width, visibleTicksCount, property, filterQueryParam, pageQueryParam } = props const { t } = useTranslation() + const subsection = useSelector(selectVGRVesselsSubsection) const { dispatchQueryParams } = useLocationConnect() const isOtherCategory = payload.value === OTHERS_CATEGORY_LABEL const isCategoryInteractive = !EMPTY_API_VALUES.includes(payload.value) @@ -136,7 +142,7 @@ export type VesselGroupReportVesselsGraphProperty = 'flag' | 'geartype' export default function VesselGroupReportVesselsGraph({ data, - color = 'rgb(22, 63, 137)', + color = COLOR_PRIMARY_BLUE, property, filterQueryParam, pageQueryParam, diff --git a/apps/fishing-map/features/vessel-group-report/vessels/VesselGroupReportVesselsGraphSelector.tsx b/apps/fishing-map/features/vessel-group-report/vessels/VesselGroupReportVesselsGraphSelector.tsx index 953017c8c1..a9ef36ccca 100644 --- a/apps/fishing-map/features/vessel-group-report/vessels/VesselGroupReportVesselsGraphSelector.tsx +++ b/apps/fishing-map/features/vessel-group-report/vessels/VesselGroupReportVesselsGraphSelector.tsx @@ -2,18 +2,18 @@ import { useSelector } from 'react-redux' import { useTranslation } from 'react-i18next' import { Choice, ChoiceOption } from '@globalfishingwatch/ui-components' import { useLocationConnect } from 'routes/routes.hook' -import { selectVesselGroupReportStatus } from 'features/vessel-group-report/vessel-group-report.slice' +import { selectVGRStatus } from 'features/vessel-group-report/vessel-group-report.slice' import { AsyncReducerStatus } from 'utils/async-slice' import { VesselGroupReportVesselsSubsection } from 'features/vessel-groups/vessel-groups.types' -import { selectVesselGroupReportVesselsSubsection } from '../vessel-group.config.selectors' +import { selectVGRVesselsSubsection } from '../vessel-group.config.selectors' type VesselGroupReportVesselsGraphSelectorProps = {} function VesselGroupReportVesselsGraphSelector(props: VesselGroupReportVesselsGraphSelectorProps) { const { t } = useTranslation() const { dispatchQueryParams } = useLocationConnect() - const vesselGroupReportStatus = useSelector(selectVesselGroupReportStatus) - const subsection = useSelector(selectVesselGroupReportVesselsSubsection) + const vesselGroupReportStatus = useSelector(selectVGRStatus) + const subsection = useSelector(selectVGRVesselsSubsection) const loading = vesselGroupReportStatus === AsyncReducerStatus.Loading const options: ChoiceOption[] = [ { @@ -44,7 +44,7 @@ function VesselGroupReportVesselsGraphSelector(props: VesselGroupReportVesselsGr // category: TrackCategory.Analysis, // action: `Click on ${option.id} activity graph`, // }) - dispatchQueryParams({ vesselGroupReportVesselsSubsection: option.id }) + dispatchQueryParams({ vGRVesselsSubsection: option.id }) } } diff --git a/apps/fishing-map/features/vessel-group-report/vessels/VesselGroupReportVesselsTable.tsx b/apps/fishing-map/features/vessel-group-report/vessels/VesselGroupReportVesselsTable.tsx index e3bf96fe70..105fc41090 100644 --- a/apps/fishing-map/features/vessel-group-report/vessels/VesselGroupReportVesselsTable.tsx +++ b/apps/fishing-map/features/vessel-group-report/vessels/VesselGroupReportVesselsTable.tsx @@ -15,35 +15,35 @@ import VesselPin from 'features/vessel/VesselPin' import { selectWorkspaceStatus } from 'features/workspace/workspace.selectors' import { AsyncReducerStatus } from 'utils/async-slice' import { - selectVesselGroupReportVesselsOrderDirection, - selectVesselGroupReportVesselsOrderProperty, + selectVGRVesselsOrderDirection, + selectVGRVesselsOrderProperty, } from 'features/vessel-group-report/vessel-group.config.selectors' -import { selectVesselGroupReportVessels } from 'features/vessel-group-report/vessel-group-report.slice' +import { selectVGRVessels } from 'features/vessel-group-report/vessel-group-report.slice' import { VesselGroupReportVesselsOrderProperty, VesselGroupReportVesselsOrderDirection, } from 'features/vessel-groups/vessel-groups.types' import styles from './VesselGroupReportVesselsTable.module.css' -import { selectVesselGroupReportVesselsPaginated } from './vessel-group-report-vessels.selectors' +import { selectVGRVesselsPaginated } from './vessel-group-report-vessels.selectors' import VesselGroupReportVesselsTableFooter from './VesselGroupReportVesselsTableFooter' export default function VesselGroupReportVesselsTable() { const { t } = useTranslation() const { dispatchQueryParams } = useLocationConnect() - const vesselsRaw = useSelector(selectVesselGroupReportVessels) - const vessels = useSelector(selectVesselGroupReportVesselsPaginated) + const vesselsRaw = useSelector(selectVGRVessels) + const vessels = useSelector(selectVGRVesselsPaginated) const userData = useSelector(selectUserData) const dataviews = useSelector(selectActiveReportDataviews) const workspaceStatus = useSelector(selectWorkspaceStatus) - const orderProperty = useSelector(selectVesselGroupReportVesselsOrderProperty) - const orderDirection = useSelector(selectVesselGroupReportVesselsOrderDirection) + const orderProperty = useSelector(selectVGRVesselsOrderProperty) + const orderDirection = useSelector(selectVGRVesselsOrderDirection) const datasetsDownloadNotSupported = getDatasetsReportNotSupported( dataviews, userData?.permissions || [] ) - const onFilterClick = (vesselGroupReportVesselFilter: any) => { - dispatchQueryParams({ vesselGroupReportVesselFilter, vesselGroupReportVesselPage: 0 }) + const onFilterClick = (vGRVesselFilter: any) => { + dispatchQueryParams({ vGRVesselFilter, vGRVesselPage: 0 }) } const onPinClick = () => { @@ -55,8 +55,8 @@ export default function VesselGroupReportVesselsTable() { direction: VesselGroupReportVesselsOrderDirection ) => { dispatchQueryParams({ - vesselGroupReportVesselsOrderProperty: property, - vesselGroupReportVesselsOrderDirection: direction, + vGRVesselsOrderProperty: property, + vGRVesselsOrderDirection: direction, }) } diff --git a/apps/fishing-map/features/vessel-group-report/vessels/VesselGroupReportVesselsTableFooter.tsx b/apps/fishing-map/features/vessel-group-report/vessels/VesselGroupReportVesselsTableFooter.tsx index 4ebb8c9fc3..7b85581df9 100644 --- a/apps/fishing-map/features/vessel-group-report/vessels/VesselGroupReportVesselsTableFooter.tsx +++ b/apps/fishing-map/features/vessel-group-report/vessels/VesselGroupReportVesselsTableFooter.tsx @@ -10,22 +10,22 @@ import { useLocationConnect } from 'routes/routes.hook' import { selectTimeRange } from 'features/app/selectors/app.timebar.selectors' import { REPORT_SHOW_MORE_VESSELS_PER_PAGE, REPORT_VESSELS_PER_PAGE } from 'data/config' import { TrackCategory, trackEvent } from 'features/app/analytics.hooks' -import { selectVesselGroupReportData } from 'features/vessel-group-report/vessel-group-report.slice' +import { selectVGRData } from 'features/vessel-group-report/vessel-group-report.slice' import { formatInfoField } from 'utils/info' -import { selectVesselGroupReportVesselFilter } from '../vessel-group.config.selectors' +import { selectVGRVesselFilter } from '../vessel-group.config.selectors' import styles from './VesselGroupReportVesselsTableFooter.module.css' import { - selectVesselGroupReportVesselsFiltered, - selectVesselGroupReportVesselsPagination, + selectVGRVesselsFiltered, + selectVGRVesselsPagination, } from './vessel-group-report-vessels.selectors' export default function VesselGroupReportVesselsTableFooter() { const { t } = useTranslation() const { dispatchQueryParams } = useLocationConnect() - const vesselGroup = useSelector(selectVesselGroupReportData) - const allVessels = useSelector(selectVesselGroupReportVesselsFiltered) - const reportVesselFilter = useSelector(selectVesselGroupReportVesselFilter) - const pagination = useSelector(selectVesselGroupReportVesselsPagination) + const vesselGroup = useSelector(selectVGRData) + const allVessels = useSelector(selectVGRVesselsFiltered) + const reportVesselFilter = useSelector(selectVGRVesselFilter) + const pagination = useSelector(selectVGRVesselsPagination) const { start, end } = useSelector(selectTimeRange) if (!allVessels?.length) return null @@ -48,15 +48,15 @@ export default function VesselGroupReportVesselsTableFooter() { } const onPrevPageClick = () => { - dispatchQueryParams({ vesselGroupReportVesselPage: pagination.page - 1 }) + dispatchQueryParams({ vGRVesselPage: pagination.page - 1 }) } const onNextPageClick = () => { - dispatchQueryParams({ vesselGroupReportVesselPage: pagination.page + 1 }) + dispatchQueryParams({ vGRVesselPage: pagination.page + 1 }) } const onShowMoreClick = () => { dispatchQueryParams({ - vesselGroupReportResultsPerPage: REPORT_SHOW_MORE_VESSELS_PER_PAGE, - vesselGroupReportVesselPage: 0, + vGRVesselsResultsPerPage: REPORT_SHOW_MORE_VESSELS_PER_PAGE, + vGRVesselPage: 0, }) // trackEvent({ // category: TrackCategory.Analysis, @@ -65,7 +65,7 @@ export default function VesselGroupReportVesselsTableFooter() { } const onShowLessClick = () => { dispatchQueryParams({ - vesselGroupReportResultsPerPage: REPORT_VESSELS_PER_PAGE, + vGRVesselsResultsPerPage: REPORT_VESSELS_PER_PAGE, reportVesselPage: 0, }) // trackEvent({ diff --git a/apps/fishing-map/features/vessel-group-report/vessels/vessel-group-report-vessels.selectors.ts b/apps/fishing-map/features/vessel-group-report/vessels/vessel-group-report-vessels.selectors.ts index 92d87557d1..1e8b6a878b 100644 --- a/apps/fishing-map/features/vessel-group-report/vessels/vessel-group-report-vessels.selectors.ts +++ b/apps/fishing-map/features/vessel-group-report/vessels/vessel-group-report-vessels.selectors.ts @@ -4,9 +4,9 @@ import { IdentityVessel } from '@globalfishingwatch/api-types' import { OTHER_CATEGORY_LABEL } from 'features/vessel-group-report/vessel-group-report.config' import { getSearchIdentityResolved } from 'features/vessel/vessel.utils' import { - selectVesselGroupReportResultsPerPage, - selectVesselGroupReportVesselFilter, - selectVesselGroupReportVesselPage, + selectVGRVesselsResultsPerPage, + selectVGRVesselFilter, + selectVGRVesselPage, } from 'features/vessel-group-report/vessel-group.config.selectors' import { formatInfoField, getVesselGearTypeLabel, getVesselShipTypeLabel } from 'utils/info' import { cleanFlagState } from 'features/area-report/reports.selectors' @@ -17,11 +17,11 @@ import { getVesselsFiltered, } from 'features/area-report/reports.utils' import { - selectVesselGroupReportVesselsOrderDirection, - selectVesselGroupReportVesselsOrderProperty, - selectVesselGroupReportVesselsSubsection, + selectVGRVesselsOrderDirection, + selectVGRVesselsOrderProperty, + selectVGRVesselsSubsection, } from '../vessel-group.config.selectors' -import { selectVesselGroupReportVessels } from '../vessel-group-report.slice' +import { selectVGRVessels } from '../vessel-group-report.slice' import { VesselGroupReportVesselParsed } from './vessel-group-report-vessels.types' const getVesselSource = (vessel: IdentityVessel) => { @@ -36,28 +36,25 @@ const getVesselSource = (vessel: IdentityVessel) => { return source } -export const selectVesselGroupReportVesselsParsed = createSelector( - [selectVesselGroupReportVessels], - (vessels) => { - if (!vessels?.length) return null - return vessels.map((vessel, index) => { - const { ssvid, ...vesselData } = getSearchIdentityResolved(vessel) - const source = getVesselSource(vessel) - return { - ...vesselData, - index: index, - shipName: formatInfoField(vesselData.shipname, 'name'), - vesselType: getVesselShipTypeLabel(vesselData), - gearType: getVesselGearTypeLabel(vesselData), - flagTranslated: t(`flags:${vesselData.flag as string}` as any), - flagTranslatedClean: cleanFlagState(t(`flags:${vesselData.flag as string}` as any)), - source: t(`common.sourceOptions.${source}`, source), - mmsi: ssvid, - dataset: vessel.dataset, - } - }) as VesselGroupReportVesselParsed[] - } -) +export const selectVGRVesselsParsed = createSelector([selectVGRVessels], (vessels) => { + if (!vessels?.length) return null + return vessels.map((vessel, index) => { + const { ssvid, ...vesselData } = getSearchIdentityResolved(vessel) + const source = getVesselSource(vessel) + return { + ...vesselData, + index: index, + shipName: formatInfoField(vesselData.shipname, 'name'), + vesselType: getVesselShipTypeLabel(vesselData), + gearType: getVesselGearTypeLabel(vesselData), + flagTranslated: t(`flags:${vesselData.flag as string}` as any), + flagTranslatedClean: cleanFlagState(t(`flags:${vesselData.flag as string}` as any)), + source: t(`common.sourceOptions.${source}`, source), + mmsi: ssvid, + dataset: vessel.dataset, + } + }) as VesselGroupReportVesselParsed[] +}) type ReportFilterProperty = FilterProperty | 'source' export const REPORT_FILTER_PROPERTIES: Record = { @@ -65,40 +62,34 @@ export const REPORT_FILTER_PROPERTIES: Record = source: ['source'], } -export const selectVesselGroupReportVesselsTimeRange = createSelector( - [selectVesselGroupReportVesselsParsed], - (vessels) => { - if (!vessels?.length) return null - let start: string = '' - let end: string = '' - vessels.forEach((vessel) => { - if (!start || vessel.transmissionDateFrom < start) { - start = vessel.transmissionDateFrom - } - if (!end || vessel.transmissionDateTo > end) { - end = vessel.transmissionDateTo - } - }) - return { start, end } - } -) +export const selectVGRVesselsTimeRange = createSelector([selectVGRVesselsParsed], (vessels) => { + if (!vessels?.length) return null + let start: string = '' + let end: string = '' + vessels.forEach((vessel) => { + if (!start || vessel.transmissionDateFrom < start) { + start = vessel.transmissionDateFrom + } + if (!end || vessel.transmissionDateTo > end) { + end = vessel.transmissionDateTo + } + }) + return { start, end } +}) -export const selectVesselGroupReportVesselsFlags = createSelector( - [selectVesselGroupReportVesselsParsed], - (vessels) => { - if (!vessels?.length) return null - let flags = new Set() - vessels.forEach((vessel) => { - if (vessel.flag) { - flags.add(vessel.flag) - } - }) - return flags - } -) +export const selectVGRVesselsFlags = createSelector([selectVGRVesselsParsed], (vessels) => { + if (!vessels?.length) return null + let flags = new Set() + vessels.forEach((vessel) => { + if (vessel.flag) { + flags.add(vessel.flag) + } + }) + return flags +}) -export const selectVesselGroupReportVesselsFiltered = createSelector( - [selectVesselGroupReportVesselsParsed, selectVesselGroupReportVesselFilter], +export const selectVGRVesselsFiltered = createSelector( + [selectVGRVesselsParsed, selectVGRVesselFilter], (vessels, filter) => { if (!vessels?.length) return null return getVesselsFiltered( @@ -109,12 +100,8 @@ export const selectVesselGroupReportVesselsFiltered = createSelector( } ) -export const selectVesselGroupReportVesselsOrdered = createSelector( - [ - selectVesselGroupReportVesselsFiltered, - selectVesselGroupReportVesselsOrderProperty, - selectVesselGroupReportVesselsOrderDirection, - ], +export const selectVGRVesselsOrdered = createSelector( + [selectVGRVesselsFiltered, selectVGRVesselsOrderProperty, selectVGRVesselsOrderDirection], (vessels, property, direction) => { if (!vessels?.length) return [] return vessels.toSorted((a, b) => { @@ -141,25 +128,21 @@ export const selectVesselGroupReportVesselsOrdered = createSelector( } ) -export const selectVesselGroupReportVesselsPaginated = createSelector( - [ - selectVesselGroupReportVesselsOrdered, - selectVesselGroupReportVesselPage, - selectVesselGroupReportResultsPerPage, - ], +export const selectVGRVesselsPaginated = createSelector( + [selectVGRVesselsOrdered, selectVGRVesselPage, selectVGRVesselsResultsPerPage], (vessels, page, resultsPerPage) => { if (!vessels?.length) return [] return vessels.slice(resultsPerPage * page, resultsPerPage * (page + 1)) } ) -export const selectVesselGroupReportVesselsPagination = createSelector( +export const selectVGRVesselsPagination = createSelector( [ - selectVesselGroupReportVesselsPaginated, - selectVesselGroupReportVessels, - selectVesselGroupReportVesselsFiltered, - selectVesselGroupReportVesselPage, - selectVesselGroupReportResultsPerPage, + selectVGRVesselsPaginated, + selectVGRVessels, + selectVGRVesselsFiltered, + selectVGRVesselPage, + selectVGRVesselsResultsPerPage, ], (vessels, allVessels, allVesselsFiltered, page = 0, resultsPerPage) => { return { @@ -174,8 +157,8 @@ export const selectVesselGroupReportVesselsPagination = createSelector( } ) -export const selectVesselGroupReportVesselsGraphDataGrouped = createSelector( - [selectVesselGroupReportVesselsFiltered, selectVesselGroupReportVesselsSubsection], +export const selectVGRVesselsGraphDataGrouped = createSelector( + [selectVGRVesselsFiltered, selectVGRVesselsSubsection], (vessels, subsection) => { if (!vessels) return [] let vesselsGrouped = {} diff --git a/apps/fishing-map/features/vessel-groups/vessel-groups.types.ts b/apps/fishing-map/features/vessel-groups/vessel-groups.types.ts index a9394822f3..592e9b037a 100644 --- a/apps/fishing-map/features/vessel-groups/vessel-groups.types.ts +++ b/apps/fishing-map/features/vessel-groups/vessel-groups.types.ts @@ -8,19 +8,19 @@ export type VesselGroupReportVesselsOrderDirection = 'asc' | 'desc' export type VesselGroupReportState = { viewOnlyVesselGroup: boolean - vesselGroupReportSection: VesselGroupReportSection - vesselGroupReportVesselsSubsection?: VesselGroupReportVesselsSubsection - vesselGroupReportActivitySubsection?: VesselGroupReportActivitySubsection + vGRSection: VesselGroupReportSection + vGRVesselsSubsection?: VesselGroupReportVesselsSubsection + vGRActivitySubsection?: VesselGroupReportActivitySubsection vGREventsSubsection?: VGREventsSubsection vGREventsVesselsProperty?: VGREventsVesselsProperty - vesselGroupReportVesselPage?: number - vesselGroupReportResultsPerPage?: number - vesselGroupReportVesselFilter?: string + vGRVesselPage?: number + vGRVesselsResultsPerPage?: number + vGRVesselFilter?: string vGREventsVesselPage?: number vGREventsResultsPerPage?: number vGREventsVesselFilter?: string - vesselGroupReportVesselsOrderProperty?: VesselGroupReportVesselsOrderProperty - vesselGroupReportVesselsOrderDirection?: VesselGroupReportVesselsOrderDirection + vGRVesselsOrderProperty?: VesselGroupReportVesselsOrderProperty + vGRVesselsOrderDirection?: VesselGroupReportVesselsOrderDirection } export type VesselGroupReportStateProperty = keyof VesselGroupReportState diff --git a/apps/fishing-map/features/vessel/Vessel.tsx b/apps/fishing-map/features/vessel/Vessel.tsx index d13d580671..ef811efa1e 100644 --- a/apps/fishing-map/features/vessel/Vessel.tsx +++ b/apps/fishing-map/features/vessel/Vessel.tsx @@ -13,8 +13,6 @@ import { fetchVesselInfoThunk } from 'features/vessel/vessel.slice' import { useAppDispatch } from 'features/app/app.hooks' import VesselHeader from 'features/vessel/VesselHeader' import { AsyncReducerStatus } from 'utils/async-slice' -import { fetchRegionsThunk } from 'features/regions/regions.slice' -import { selectRegionsDatasets } from 'features/regions/regions.selectors' import { useFetchDataviewResources } from 'features/resources/resources.hooks' import { ErrorPlaceHolder, WorkspaceLoginError } from 'features/workspace/WorkspaceError' import { @@ -65,7 +63,6 @@ const Vessel = () => { const hasEventsDataset = useSelector(selectVesselHasEventsDatasets) const infoError = useSelector(selectVesselInfoError) const isWorkspaceVesselLocation = useSelector(selectIsWorkspaceVesselLocation) - const regionsDatasets = useSelector(selectRegionsDatasets) const guestUser = useSelector(selectIsGuestUser) const vesselData = useSelector(selectVesselInfoData) const hasSelfReportedData = @@ -126,12 +123,6 @@ const Vessel = () => { [t, updateAreaLayersVisibility, hasEventsDataset] ) - useEffect(() => { - if (Object.values(regionsDatasets).every((d) => d)) { - dispatch(fetchRegionsThunk(regionsDatasets)) - } - }, [dispatch, regionsDatasets]) - useEffect(() => { const fetchVesselProfileAreaDatasets = async () => { const vesselProfileDataviews = [ diff --git a/apps/fishing-map/features/vessel/activity/activity-by-type/ActivityByType.tsx b/apps/fishing-map/features/vessel/activity/activity-by-type/ActivityByType.tsx index 9208dde0dd..ac8a924f0e 100644 --- a/apps/fishing-map/features/vessel/activity/activity-by-type/ActivityByType.tsx +++ b/apps/fishing-map/features/vessel/activity/activity-by-type/ActivityByType.tsx @@ -20,6 +20,7 @@ import { useLocationConnect } from 'routes/routes.hook' import { selectVesselPrintMode } from 'features/vessel/selectors/vessel.selectors' import Event, { EVENT_HEIGHT } from '../event/Event' import styles from '../ActivityGroupedList.module.css' +import VesselEvent from '../event/Event' import { useActivityByType } from './activity-by-type.hook' import ActivityGroup from './ActivityGroup' @@ -41,9 +42,9 @@ function ActivityByType() { const [expandedType, toggleExpandedType] = useActivityByType() const viewport = useMapViewport() const setMapCoordinates = useSetMapCoordinates() - const [selectedEvent, setSelectedEvent] = useState() + const [selectedEvent, setSelectedEvent] = useState() - const onInfoClick = useCallback((event: ActivityEvent) => { + const onInfoClick = useCallback((event: VesselEvent) => { setSelectedEvent((state) => (state?.id === event.id ? undefined : event)) }, []) @@ -61,7 +62,7 @@ function ActivityByType() { ) const onMapHover = useCallback( - (event?: ActivityEvent) => { + (event?: VesselEvent) => { if (event?.id) { dispatch(setHighlightedEvents([event.id])) } else { @@ -72,7 +73,7 @@ function ActivityByType() { ) const selectEventOnMap = useCallback( - (event: ActivityEvent) => { + (event: VesselEvent) => { if (viewport?.zoom) { const zoom = viewport.zoom ?? DEFAULT_VIEWPORT.zoom // TODO diff --git a/apps/fishing-map/features/vessel/activity/activity-by-voyage/ActivityByVoyage.tsx b/apps/fishing-map/features/vessel/activity/activity-by-voyage/ActivityByVoyage.tsx index f714cbe2db..eae4ee1da1 100644 --- a/apps/fishing-map/features/vessel/activity/activity-by-voyage/ActivityByVoyage.tsx +++ b/apps/fishing-map/features/vessel/activity/activity-by-voyage/ActivityByVoyage.tsx @@ -29,6 +29,7 @@ import { useLocationConnect } from 'routes/routes.hook' import { selectVesselPrintMode } from 'features/vessel/selectors/vessel.selectors' import { useMapFitBounds } from 'features/map/map-bounds.hooks' import { useDebouncedDispatchHighlightedEvent } from 'features/map/map-interactions.hooks' +import VesselEvent from 'features/vessel/activity/event/Event' import styles from '../ActivityGroupedList.module.css' const ActivityByVoyage = () => { @@ -40,11 +41,11 @@ const ActivityByVoyage = () => { const { dispatchQueryParams } = useLocationConnect() const visibleEvents = useSelector(selectVisibleEvents) const vesselPrintMode = useSelector(selectVesselPrintMode) - const [selectedEvent, setSelectedEvent] = useState() + const [selectedEvent, setSelectedEvent] = useState() const [expandedVoyages, toggleExpandedVoyage] = useExpandedVoyages() const fitBounds = useMapFitBounds() - const onInfoClick = useCallback((event: ActivityEvent) => { + const onInfoClick = useCallback((event: VesselEvent) => { setSelectedEvent((state) => (state?.id === event.id ? undefined : event)) }, []) @@ -85,7 +86,7 @@ const ActivityByVoyage = () => { ) const onEventMapHover = useCallback( - (event?: ActivityEvent) => { + (event?: VesselEvent) => { if (event?.id) { dispatch(setHighlightedEvents([event.id])) } else { @@ -96,7 +97,7 @@ const ActivityByVoyage = () => { ) const selectEventOnMap = useCallback( - (event: ActivityEvent) => { + (event: VesselEvent) => { if (viewport?.zoom) { const zoom = viewport.zoom ?? DEFAULT_VIEWPORT.zoom setMapCoordinates({ @@ -170,7 +171,7 @@ const ActivityByVoyage = () => { increaseViewportBy={EVENT_HEIGHT * 4} customScrollParent={getScrollElement()} groupContent={(index) => { - const events = voyages[groups[index]] + const events = voyages[groups[index] as any] if (!events) { return null } diff --git a/apps/fishing-map/features/vessel/activity/event/Event.tsx b/apps/fishing-map/features/vessel/activity/event/Event.tsx index 3ee45bcf13..f576ada218 100644 --- a/apps/fishing-map/features/vessel/activity/event/Event.tsx +++ b/apps/fishing-map/features/vessel/activity/event/Event.tsx @@ -1,25 +1,28 @@ import React from 'react' import cx from 'classnames' import { IconButton } from '@globalfishingwatch/ui-components' +import { ApiEvent } from '@globalfishingwatch/api-types' import { ActivityEvent } from 'features/vessel/activity/vessels-activity.selectors' import EventIcon from 'features/vessel/activity/event/EventIcon' import ActivityDate from './ActivityDate' import { useActivityEventTranslations } from './event.hook' import styles from './Event.module.css' +type VesselEvent = ActivityEvent | ApiEvent + interface EventProps { className?: string - event: ActivityEvent + event: VesselEvent children?: React.ReactNode - onInfoClick?: (event: ActivityEvent) => void - onMapClick?: (event: ActivityEvent) => void - onMapHover?: (event?: ActivityEvent) => void + onInfoClick?: (event: VesselEvent) => void + onMapClick?: (event: VesselEvent) => void + onMapHover?: (event?: VesselEvent) => void testId?: string } export const EVENT_HEIGHT = 56 -const Event: React.FC = (props): React.ReactElement => { +const VesselEvent: React.FC = (props): React.ReactElement => { const { event, children, className = '', onInfoClick, onMapHover, onMapClick, testId } = props const { getEventDescription } = useActivityEventTranslations() return ( @@ -32,8 +35,10 @@ const Event: React.FC = (props): React.ReactElement => { >
- -

{getEventDescription(event) as string}

+ +

+ {getEventDescription(event as ActivityEvent) as string} +

{onInfoClick && } @@ -47,4 +52,4 @@ const Event: React.FC = (props): React.ReactElement => { ) } -export default Event +export default VesselEvent diff --git a/apps/fishing-map/features/vessel/activity/event/EventDetail.tsx b/apps/fishing-map/features/vessel/activity/event/EventDetail.tsx index adff41ad4d..84a1266442 100644 --- a/apps/fishing-map/features/vessel/activity/event/EventDetail.tsx +++ b/apps/fishing-map/features/vessel/activity/event/EventDetail.tsx @@ -9,9 +9,10 @@ import { useActivityEventTranslations } from 'features/vessel/activity/event/eve import { VesselRenderField } from 'features/vessel/vessel.config' import { formatInfoField } from 'utils/info' import styles from './Event.module.css' +import VesselEvent from './Event' interface ActivityContentProps { - event: ActivityEvent + event: VesselEvent } const BASE_FIELDS = [ @@ -88,7 +89,7 @@ const ActivityContent = ({ event }: ActivityContentProps) => { return (
    {fields.map((field) => { - const value = getEventFieldValue(event, field) + const value = getEventFieldValue(event as ActivityEvent, field) if (!value) return null return (
  • diff --git a/apps/fishing-map/features/vessel/activity/event/event.hook.tsx b/apps/fishing-map/features/vessel/activity/event/event.hook.tsx index e81b2de53d..4ce1d13d65 100644 --- a/apps/fishing-map/features/vessel/activity/event/event.hook.tsx +++ b/apps/fishing-map/features/vessel/activity/event/event.hook.tsx @@ -1,5 +1,6 @@ -import { useCallback, useMemo } from 'react' +import { useCallback, useEffect, useMemo } from 'react' import { useTranslation } from 'react-i18next' +import { useSelector } from 'react-redux' import { EventTypes, GapPosition, Regions } from '@globalfishingwatch/api-types' import { Tooltip } from '@globalfishingwatch/ui-components' import { getUTCDateTime } from 'utils/dates' @@ -8,9 +9,23 @@ import { REGIONS_PRIORITY } from 'features/vessel/vessel.config' import VesselLink from 'features/vessel/VesselLink' import { EMPTY_FIELD_PLACEHOLDER, formatInfoField } from 'utils/info' import { useRegionNamesByType } from 'features/regions/regions.hooks' +import { selectRegionsDatasets } from 'features/regions/regions.selectors' +import { fetchRegionsThunk } from 'features/regions/regions.slice' +import { useAppDispatch } from 'features/app/app.hooks' import styles from './Event.module.css' +export function useFetchRegionsData() { + const dispatch = useAppDispatch() + const regionsDatasets = useSelector(selectRegionsDatasets) + useEffect(() => { + if (Object.values(regionsDatasets).every((d) => d)) { + dispatch(fetchRegionsThunk(regionsDatasets)) + } + }, [dispatch, regionsDatasets]) +} + export function useActivityEventTranslations() { + useFetchRegionsData() const { t } = useTranslation() const { getRegionNamesByType } = useRegionNamesByType() diff --git a/apps/fishing-map/features/vessel/activity/vessels-activity.selectors.ts b/apps/fishing-map/features/vessel/activity/vessels-activity.selectors.ts index 0b261d5001..63b1df9151 100644 --- a/apps/fishing-map/features/vessel/activity/vessels-activity.selectors.ts +++ b/apps/fishing-map/features/vessel/activity/vessels-activity.selectors.ts @@ -1,5 +1,5 @@ import { createSelector } from '@reduxjs/toolkit' -import { groupBy, uniqBy } from 'lodash' +import { groupBy, uniqBy } from 'es-toolkit' import { EventType, EventTypes, RegionType, Regions, Vessel } from '@globalfishingwatch/api-types' import { ApiEvent } from '@globalfishingwatch/api-types' import { selectVesselAreaSubsection } from 'features/vessel/vessel.config.selectors' @@ -23,7 +23,7 @@ export interface ActivityEvent extends ApiEvent { export const selectEventsGroupedByType = createSelector( [selectVesselEventsFilteredByTimerange], (eventsList) => { - return groupBy(eventsList, 'type') + return groupBy(eventsList, (e) => e.type) } ) @@ -83,7 +83,7 @@ export const selectVesselEventTypes = createSelector( [selectVesselProfileDataview], (vesselDataview) => { const eventDatasets = - vesselDataview && uniqBy(getEventsDatasetsInDataview(vesselDataview), 'subcategory') + vesselDataview && uniqBy(getEventsDatasetsInDataview(vesselDataview), (e) => e.subcategory) return eventDatasets?.map(({ subcategory }) => subcategory as EventType) || [] } ) @@ -160,7 +160,7 @@ export const selectEventsGroupedByVoyages = createSelector( } return event }) - return groupBy(eventsListWithEntryExitEvents, 'voyage') + return groupBy(eventsListWithEntryExitEvents, (e) => e.voyage) } ) diff --git a/apps/fishing-map/features/vessel/insights/InsightCoverage.tsx b/apps/fishing-map/features/vessel/insights/InsightCoverage.tsx index 09b29b5a8a..91823e6a91 100644 --- a/apps/fishing-map/features/vessel/insights/InsightCoverage.tsx +++ b/apps/fishing-map/features/vessel/insights/InsightCoverage.tsx @@ -1,5 +1,5 @@ import { useTranslation } from 'react-i18next' -import { InsightCoverageResponse } from '@globalfishingwatch/api-types' +import { InsightResponse } from '@globalfishingwatch/api-types' import { ParsedAPIError } from '@globalfishingwatch/api-client' import { EMPTY_FIELD_PLACEHOLDER } from 'utils/info' import InsightError from 'features/vessel/insights/InsightErrorMessage' @@ -11,7 +11,7 @@ const InsightCoverage = ({ isLoading, error, }: { - insightData: InsightCoverageResponse + insightData?: InsightResponse isLoading: boolean error: ParsedAPIError }) => { @@ -31,7 +31,7 @@ const InsightCoverage = ({
    ) : error ? ( - ) : insightData?.coverage.percentage ? ( + ) : insightData?.coverage?.percentage ? (
    void visible: boolean }) => { diff --git a/apps/fishing-map/features/vessel/insights/InsightFishing.tsx b/apps/fishing-map/features/vessel/insights/InsightFishing.tsx index 664de9f5ef..b8b46efa4b 100644 --- a/apps/fishing-map/features/vessel/insights/InsightFishing.tsx +++ b/apps/fishing-map/features/vessel/insights/InsightFishing.tsx @@ -1,7 +1,7 @@ import { useTranslation } from 'react-i18next' import { Fragment, useCallback, useMemo, useState } from 'react' import { useSelector } from 'react-redux' -import { InsightFishingResponse } from '@globalfishingwatch/api-types' +import { InsightResponse } from '@globalfishingwatch/api-types' import { ParsedAPIError } from '@globalfishingwatch/api-client' import InsightError from 'features/vessel/insights/InsightErrorMessage' import DataTerminology from 'features/vessel/identity/DataTerminology' @@ -15,7 +15,7 @@ const InsightFishing = ({ isLoading, error, }: { - insightData: InsightFishingResponse + insightData?: InsightResponse isLoading: boolean error: ParsedAPIError }) => { diff --git a/apps/fishing-map/features/vessel/insights/InsightFlagChanges.tsx b/apps/fishing-map/features/vessel/insights/InsightFlagChanges.tsx index c60c1ad314..87b9518e66 100644 --- a/apps/fishing-map/features/vessel/insights/InsightFlagChanges.tsx +++ b/apps/fishing-map/features/vessel/insights/InsightFlagChanges.tsx @@ -1,7 +1,7 @@ import { useTranslation } from 'react-i18next' import { Fragment } from 'react' import { useSelector } from 'react-redux' -import { InsightFlagChangesResponse } from '@globalfishingwatch/api-types' +import { InsightResponse } from '@globalfishingwatch/api-types' import { ParsedAPIError } from '@globalfishingwatch/api-client' import { selectIsGuestUser } from 'features/user/selectors/user.selectors' import VesselIdentityFieldLogin from 'features/vessel/identity/VesselIdentityFieldLogin' @@ -15,7 +15,7 @@ const InsightFlagChanges = ({ isLoading, error, }: { - insightData: InsightFlagChangesResponse + insightData?: InsightResponse isLoading: boolean error: ParsedAPIError }) => { diff --git a/apps/fishing-map/features/vessel/insights/InsightGaps.tsx b/apps/fishing-map/features/vessel/insights/InsightGaps.tsx index 5c5d7ce662..ef7a22d372 100644 --- a/apps/fishing-map/features/vessel/insights/InsightGaps.tsx +++ b/apps/fishing-map/features/vessel/insights/InsightGaps.tsx @@ -1,6 +1,6 @@ import { useTranslation } from 'react-i18next' import { Fragment, useCallback, useState } from 'react' -import { InsightGapsResponse } from '@globalfishingwatch/api-types' +import { InsightResponse } from '@globalfishingwatch/api-types' import { ParsedAPIError } from '@globalfishingwatch/api-client' import DataTerminology from 'features/vessel/identity/DataTerminology' import InsightError from './InsightErrorMessage' @@ -12,7 +12,7 @@ const InsightGaps = ({ isLoading, error, }: { - insightData: InsightGapsResponse + insightData?: InsightResponse isLoading: boolean error: ParsedAPIError }) => { diff --git a/apps/fishing-map/features/vessel/insights/InsightGapsDetails.tsx b/apps/fishing-map/features/vessel/insights/InsightGapsDetails.tsx index 7af7a20612..12e0a2251c 100644 --- a/apps/fishing-map/features/vessel/insights/InsightGapsDetails.tsx +++ b/apps/fishing-map/features/vessel/insights/InsightGapsDetails.tsx @@ -2,14 +2,14 @@ import { useGetVesselEventsQuery } from 'queries/vessel-events-api' import { useSelector } from 'react-redux' import { Fragment } from 'react' import { useTranslation } from 'react-i18next' -import { InsightGapsResponse, VesselIdentitySourceEnum } from '@globalfishingwatch/api-types' +import { InsightResponse, VesselIdentitySourceEnum } from '@globalfishingwatch/api-types' import { IconButton } from '@globalfishingwatch/ui-components' import { getVesselIdentities } from 'features/vessel/vessel.utils' import { selectTimeRange } from 'features/app/selectors/app.timebar.selectors' -import { ActivityEvent } from 'features/vessel/activity/vessels-activity.selectors' import { removeNonTunaRFMO } from 'features/vessel/insights/insights.utils' import Event from '../activity/event/Event' import { selectVesselInfoData } from '../selectors/vessel.selectors' +import VesselEvent from '../activity/event/Event' import styles from './Insights.module.css' const InsightGapsDetails = ({ @@ -17,7 +17,7 @@ const InsightGapsDetails = ({ toggleVisibility, visible, }: { - insightData: InsightGapsResponse + insightData?: InsightResponse toggleVisibility: () => void visible: boolean }) => { @@ -31,7 +31,7 @@ const InsightGapsDetails = ({ const { data, isLoading } = useGetVesselEventsQuery( { vessels: identities?.map((i) => i.id), - datasets: insightData?.gap.datasets || [], + datasets: insightData?.gap?.datasets || [], 'start-date': start, 'end-date': end, }, @@ -52,12 +52,12 @@ const InsightGapsDetails = ({ } icon={visible ? 'arrow-top' : 'arrow-down'} /> - {visible && data?.entries?.length > 0 && ( + {visible && data?.entries && data?.entries?.length > 0 && (
      {[...data.entries] .reverse() .map(removeNonTunaRFMO) - .map((event: ActivityEvent) => ( + .map((event: VesselEvent) => ( ))}
    diff --git a/apps/fishing-map/features/vessel/insights/InsightIUU.tsx b/apps/fishing-map/features/vessel/insights/InsightIUU.tsx index 20dcaa2691..77b7f91729 100644 --- a/apps/fishing-map/features/vessel/insights/InsightIUU.tsx +++ b/apps/fishing-map/features/vessel/insights/InsightIUU.tsx @@ -1,5 +1,5 @@ import { useTranslation } from 'react-i18next' -import { InsightIUUResponse } from '@globalfishingwatch/api-types' +import { InsightResponse } from '@globalfishingwatch/api-types' import { ParsedAPIError } from '@globalfishingwatch/api-client' import InsightError from 'features/vessel/insights/InsightErrorMessage' import DataTerminology from 'features/vessel/identity/DataTerminology' @@ -10,7 +10,7 @@ const InsightIUU = ({ isLoading, error, }: { - insightData: InsightIUUResponse + insightData?: InsightResponse isLoading: boolean error: ParsedAPIError }) => { diff --git a/apps/fishing-map/features/vessel/insights/InsightMOUList.tsx b/apps/fishing-map/features/vessel/insights/InsightMOUList.tsx index bb65a50aa6..f3276e556b 100644 --- a/apps/fishing-map/features/vessel/insights/InsightMOUList.tsx +++ b/apps/fishing-map/features/vessel/insights/InsightMOUList.tsx @@ -1,7 +1,7 @@ import { useTranslation } from 'react-i18next' import { Fragment } from 'react' import { useSelector } from 'react-redux' -import { InsightMOUListResponse, ValueInPeriod } from '@globalfishingwatch/api-types' +import { InsightResponse, InsightValueInPeriod } from '@globalfishingwatch/api-types' import { ParsedAPIError } from '@globalfishingwatch/api-client' import { selectIsGuestUser } from 'features/user/selectors/user.selectors' import VesselIdentityFieldLogin from 'features/vessel/identity/VesselIdentityFieldLogin' @@ -16,14 +16,14 @@ const InsightMOUList = ({ isLoading, error, }: { - insightData: InsightMOUListResponse + insightData?: InsightResponse isLoading: boolean error: ParsedAPIError }) => { const { t } = useTranslation() const guestUser = useSelector(selectIsGuestUser) const { mouList } = insightData?.vesselIdentity || {} - let tokyoAppearences: Record> = { + let tokyoAppearences: Record> = { BLACK: {}, GREY: {}, } @@ -42,7 +42,7 @@ const InsightMOUList = ({ } } }) - let parisAppearences: Record> = { + let parisAppearences: Record> = { BLACK: {}, GREY: {}, } @@ -103,6 +103,7 @@ const InsightMOUList = ({ if ( !hasTokyoBlackAppearences && !hasTokyoGreyAppearences && + mouList?.tokyo.totalTimesListed && mouList?.tokyo.totalTimesListed > 0 ) { messages.push( @@ -156,6 +157,7 @@ const InsightMOUList = ({ if ( !hasParisBlackAppearences && !hasParisGreyAppearences && + mouList?.paris.totalTimesListed && mouList?.paris.totalTimesListed > 0 ) { messages.push( diff --git a/apps/fishing-map/features/vessel/insights/InsightWrapper.tsx b/apps/fishing-map/features/vessel/insights/InsightWrapper.tsx index 5e41f4728f..5182c005cf 100644 --- a/apps/fishing-map/features/vessel/insights/InsightWrapper.tsx +++ b/apps/fishing-map/features/vessel/insights/InsightWrapper.tsx @@ -1,21 +1,8 @@ import { useSelector } from 'react-redux' -import { useGetVesselInsightMutation } from 'queries/vessel-insight-api' -import { useCallback, useEffect } from 'react' -import { - InsightFlagChangesResponse, - InsightIUUResponse, - InsightMOUListResponse, - InsightType, - VesselIdentitySourceEnum, -} from '@globalfishingwatch/api-types' -import { - InsightCoverageResponse, - InsightFishingResponse, - InsightGapsResponse, -} from '@globalfishingwatch/api-types' +import { useGetVesselInsightQuery } from 'queries/vessel-insight-api' +import { InsightType, VesselIdentitySourceEnum } from '@globalfishingwatch/api-types' import { ParsedAPIError } from '@globalfishingwatch/api-client' import { getVesselIdentities } from 'features/vessel/vessel.utils' -import { IdentityVesselData } from 'features/vessel/vessel.slice' import { selectTimeRange } from 'features/app/selectors/app.timebar.selectors' import InsightMOUList from 'features/vessel/insights/InsightMOUList' import { selectVesselInfoData } from '../selectors/vessel.selectors' @@ -28,100 +15,53 @@ import InsightFlagChanges from './InsightFlagChanges' const InsightWrapper = ({ insight }: { insight: InsightType }) => { const { start, end } = useSelector(selectTimeRange) const vessel = useSelector(selectVesselInfoData) - - const [getInsight, { isLoading, data, error }] = useGetVesselInsightMutation({ - fixedCacheKey: [insight, start, end, vessel.id].join(), + const identities = getVesselIdentities(vessel, { + identitySource: VesselIdentitySourceEnum.SelfReported, }) - const callInsight = useCallback( - async ({ + const { isLoading, data, error } = useGetVesselInsightQuery( + { + vessels: identities.map((identity) => ({ + vesselId: identity.id, + datasetId: vessel.dataset.id, + })), + insight, start, end, - insight, - vessel, - }: { - start: string - end: string - insight: string - vessel: IdentityVesselData - }) => { - const identities = getVesselIdentities(vessel, { - identitySource: VesselIdentitySourceEnum.SelfReported, - }) - const params = { - vessels: identities.map((identity) => ({ - vesselId: identity.id, - datasetId: vessel.dataset.id, - })), - includes: [insight], - startDate: start, - endDate: end, - } - try { - await getInsight(params) - } catch (error) { - console.error('rejected', error) - } }, - [getInsight] + { + skip: !identities?.length, + } ) - useEffect(() => { - callInsight({ start, end, insight, vessel }) - }, [callInsight, end, insight, start, vessel]) - if (insight === 'COVERAGE') { return ( - + ) } if (insight === 'GAP') { - return ( - - ) + return } if (insight === 'FISHING') { return ( - + ) } if (insight === 'VESSEL-IDENTITY-IUU-VESSEL-LIST') { - return ( - - ) + return } if (insight === 'VESSEL-IDENTITY-FLAG-CHANGES') { return ( ) } if (insight === 'VESSEL-IDENTITY-MOU-LIST') { return ( - + ) } } diff --git a/apps/fishing-map/features/vessel/insights/insights.utils.ts b/apps/fishing-map/features/vessel/insights/insights.utils.ts index eda2c9d5e9..c648a5c23c 100644 --- a/apps/fishing-map/features/vessel/insights/insights.utils.ts +++ b/apps/fishing-map/features/vessel/insights/insights.utils.ts @@ -1,8 +1,9 @@ +import { ApiEvent } from '@globalfishingwatch/api-types' import { ActivityEvent } from 'features/vessel/activity/vessels-activity.selectors' const TUNA_RFMO_AREAS = ['CCSBT', 'IATTC', 'ICCAT', 'IOTC', 'NPFC', 'SPRFMO', 'WCPFC'] -export const removeNonTunaRFMO = (event: ActivityEvent): ActivityEvent => { +export const removeNonTunaRFMO = (event: ActivityEvent | ApiEvent): ActivityEvent | ApiEvent => { if (!event.regions?.rfmo.length) return event return { ...event, diff --git a/apps/fishing-map/public/locales/source/translations.json b/apps/fishing-map/public/locales/source/translations.json index 59e216a708..43cd25e5c3 100644 --- a/apps/fishing-map/public/locales/source/translations.json +++ b/apps/fishing-map/public/locales/source/translations.json @@ -876,6 +876,14 @@ }, "insights": { "coverage": "AIS Coverage", + "countries": { + "paris": "Paris", + "tokyo": "Tokyo" + }, + "list": { + "black": "black", + "grey": "grey" + }, "disclaimerTimeRangeBeforeMinYear": "Insights available from 1 January {{year}} onwards. Adjust your time range to view insights.", "errorPermisions": "You don't have permissions to see this insight", "fishing": "Fishing Events", @@ -1037,6 +1045,24 @@ "vesselGroupReport": { "clickToSee": "Click to see the vessel group report", "linkToReport": "Check the vessel group report here", + "insights": { + "gaps_one": "{{count}} AIS Off Event from {{vessels}} vessels detected", + "gaps_other": "{{count}} AIS Off Event from {{vessels}} vessels detected", + "IUUBlackListsEmpty": "No vessels are present on a RFMO IUU vessel list", + "IUUBlackListsCount_one": "{{vessels}} vessel is present on a RFMO IUU vessel list", + "IUUBlackListsCount_other": "{{vessels}} vessels are present on a RFMO IUU vessel list", + "MOULists": "MOU Lists", + "MOUListsEmpty": "No vessels flying under a flag present on the {{country}} MOU black or grey lists", + "MOUListsCount_one": "{{vessels}} vessel operated under a flag present on the {{list}} list", + "MOUListsCount_other": "{{vessels}} vessels operated under a flag present on the {{list}} list", + "fishingInNoTakeMpas_one": "{{count}} fishing event from {{vessels}} vessels detected in no-take MPAs", + "fishingInNoTakeMpas_other": "{{count}} fishing events from {{vessels}} vessels detected in no-take MPAs", + "fishingInRfmoWithoutKnownAuthorization_one": "{{count}} fishing event from {{vessels}} vessels detected outside known RFMO authorized areas", + "fishingInRfmoWithoutKnownAuthorization_other": "{{count}} fishing event from {{vessels}} vessels detected outside known RFMO authorized areas", + "flagChangesEmpty": "There are no vessels with flag changes", + "flagChangesCount_one": "{{vessels}} vessel had flag changes", + "flagChangesCount_other": "{{vessels}} vessels had flag changes" + }, "notFound": "Vessel group not found", "user": "User Vessel Group" }, diff --git a/apps/fishing-map/queries/vessel-events-api.ts b/apps/fishing-map/queries/vessel-events-api.ts index 0f9888f93d..7fc23ed0c8 100644 --- a/apps/fishing-map/queries/vessel-events-api.ts +++ b/apps/fishing-map/queries/vessel-events-api.ts @@ -1,9 +1,10 @@ import { createApi } from '@reduxjs/toolkit/query/react' -import { stringify } from 'qs' -import { gfwBaseQuery } from 'queries/base' +import { getQueryParamsResolved, gfwBaseQuery } from 'queries/base' +import { ApiEvents } from '@globalfishingwatch/api-types' type VesselEventsApiParams = { - vessels: string[] + vessels?: string[] + ids?: string[] datasets: string[] 'start-date': string 'end-date': string @@ -16,9 +17,10 @@ export const vesselEventsApi = createApi({ baseUrl: '/events', }), endpoints: (builder) => ({ - getVesselEvents: builder.query({ + getVesselEvents: builder.query({ serializeQueryArgs: ({ queryArgs }: { queryArgs: VesselEventsApiParams }) => { return [ + queryArgs.ids?.join('-'), queryArgs.vessels?.join('-'), queryArgs.datasets?.join('-'), queryArgs['start-date'], @@ -32,7 +34,7 @@ export const vesselEventsApi = createApi({ offset: 0, } return { - url: stringify(params, { arrayFormat: 'indices', addQueryPrefix: true }), + url: getQueryParamsResolved(params), } }, }), diff --git a/apps/fishing-map/queries/vessel-insight-api.ts b/apps/fishing-map/queries/vessel-insight-api.ts index 1afe23d11f..0ff0c623f9 100644 --- a/apps/fishing-map/queries/vessel-insight-api.ts +++ b/apps/fishing-map/queries/vessel-insight-api.ts @@ -1,28 +1,67 @@ import { createApi } from '@reduxjs/toolkit/query/react' -import { gfwBaseQuery } from 'queries/base' -import { InsightResponse } from '@globalfishingwatch/api-types' +import { getQueryParamsResolved, gfwBaseQuery } from 'queries/base' +import { RootState } from 'reducers' +import { + InsightResponse, + InsightType, + VesselGroupInsightResponse, +} from '@globalfishingwatch/api-types' -type VesselInsightParams = { +export type BaseInsightParams = { + insight: InsightType + start: string + end: string +} + +export type VesselInsightParams = BaseInsightParams & { vessels: { vesselId: string; datasetId: string }[] - includes: string[] - startDate: string - endDate: string } -// Define a service using a base URL and expected endpoints +export type VesselGroupInsightParams = BaseInsightParams & { + vesselGroupId: string +} + +const getBaseQueryParams = (params: BaseInsightParams) => { + return { + includes: [params.insight], + 'start-date': params.start, + 'end-date': params.end, + } +} + export const vesselInsightApi = createApi({ reducerPath: 'vesselInsightApi', - baseQuery: gfwBaseQuery({ - baseUrl: `/insights/vessels`, - method: 'POST', + baseQuery: gfwBaseQuery({ + baseUrl: `/insights`, }), endpoints: (builder) => ({ - getVesselInsight: builder.mutation({ - query: (body) => ({ url: '', method: 'POST', body }), + getVesselInsight: builder.query({ + query: ({ vessels, ...params }) => { + const query = { + ...getBaseQueryParams(params), + vessels: vessels.map((v) => v.vesselId), + datasets: vessels.map((v) => v.datasetId), + } + return { url: `/vessels${getQueryParamsResolved(query)}` } + }, + }), + getVesselGroupInsight: builder.query({ + query: ({ vesselGroupId, ...params }) => { + const query = { + ...getBaseQueryParams(params), + 'vessel-groups': [vesselGroupId], + } + return { + url: `/vessel-groups${getQueryParamsResolved(query)}`, + } + }, }), }), }) -// Export hooks for usage in functional components, which are -// auto-generated based on the defined endpoints -export const { useGetVesselInsightMutation } = vesselInsightApi +export const { useGetVesselInsightQuery, useGetVesselGroupInsightQuery } = vesselInsightApi + +export const selectVesselGroupInsightApiSlice = (state: RootState) => state.vesselInsightApi + +export const selectVesselGroupInsight = (params: VesselGroupInsightParams) => + vesselInsightApi.endpoints.getVesselGroupInsight.select(params) diff --git a/libs/api-types/src/index.ts b/libs/api-types/src/index.ts index b092645482..88138cd448 100644 --- a/libs/api-types/src/index.ts +++ b/libs/api-types/src/index.ts @@ -14,6 +14,7 @@ export * from './stats' export * from './user-applications' export * from './user' export * from './vessel-insights' +export * from './vessel-groups-insights' export * from './vessel-report' export * from './vessel' export * from './vesselGroups' diff --git a/libs/api-types/src/vessel-groups-insights.ts b/libs/api-types/src/vessel-groups-insights.ts new file mode 100644 index 0000000000..26b00d179e --- /dev/null +++ b/libs/api-types/src/vessel-groups-insights.ts @@ -0,0 +1,17 @@ +import { + InsightBase, + InsightCoverage, + InsightFishing, + InsightGaps, + InsightIdentity, +} from './vessel-insights' + +export type VesselGroupInsight = G & { vesselId: string; datasets: string[] } + +export type VesselGroupInsightResponse = InsightBase & { + vesselIdsWithoutIdentity: string[] | null + coverage?: VesselGroupInsight[] + gap?: VesselGroupInsight[] + apparentFishing?: VesselGroupInsight[] + vesselIdentity?: VesselGroupInsight[] +} diff --git a/libs/api-types/src/vessel-insights.ts b/libs/api-types/src/vessel-insights.ts index 9b80f9f680..6d1e4cf896 100644 --- a/libs/api-types/src/vessel-insights.ts +++ b/libs/api-types/src/vessel-insights.ts @@ -6,110 +6,76 @@ export type InsightType = | 'VESSEL-IDENTITY-FLAG-CHANGES' | 'VESSEL-IDENTITY-MOU-LIST' -type InsightBase = { +export type InsightBase = { period: { startDate: string endDate: string } } -export type ValueInPeriod = { +export type InsightValueInPeriod = { from: string to: string value: string // BLACK | GREY reference: string // Flag } -export type InsightCoverageResponse = InsightBase & { - coverage: { - blocks: number - blocksWithPositions: number - percentage: number - historicalCoverage: { - blocks: number - blocksWithPositions: number - percentage: number - } - } +export type InsightCoverage = { + blocks: number + blocksWithPositions: number + percentage: number } -export type InsightFishingResponse = InsightBase & { - apparentFishing: { - datasets: string[] - historicalCounters: { - events: number - eventsInRFMOWithoutKnownAuthorization: number - eventsInNoTakeMPAs: number - } - periodSelectedCounters: { - events: number - eventsInRFMOWithoutKnownAuthorization: number - eventsInNoTakeMPAs: number - } - eventsInRfmoWithoutKnownAuthorization: string[] - eventsInNoTakeMpas: string[] +export type InsightFishing = { + datasets: string[] + periodSelectedCounters: { + events: number + eventsInRFMOWithoutKnownAuthorization: number + eventsInNoTakeMPAs: number } + eventsInRfmoWithoutKnownAuthorization: string[] + eventsInNoTakeMpas: string[] } -export type InsightGapsResponse = InsightBase & { - gap: { - datasets: string[] - historicalCounters: { - events: number - eventsGapOff: number - } - periodSelectedCounters: { - events: number - eventsGapOff: number - } - aisOff: string[] +export type InsightGaps = { + datasets: string[] + periodSelectedCounters: { + events: number + eventsGapOff: number } + aisOff: string[] } -export type InsightFlagChangesResponse = InsightBase & { - vesselIdentity: { - datasets: string[] - flagsChanges: { - totalTimesListed: number - totalTimesListedInThePeriod: number - valuesInThePeriod: ValueInPeriod[] - } - } +export type InsightIdentityEntry = { + totalTimesListed: number + totalTimesListedInThePeriod: number + valuesInThePeriod: InsightValueInPeriod[] } -export type InsightMOUListResponse = InsightBase & { - vesselIdentity: { - datasets: string[] - mouList: { - tokyo: { - totalTimesListed: number - totalTimesListedInThePeriod: number - valuesInThePeriod: ValueInPeriod[] - } - paris: { - totalTimesListed: number - totalTimesListedInThePeriod: number - valuesInThePeriod: ValueInPeriod[] - } - } +export type InsightIdentityMOU = { + mouList?: { + tokyo: InsightIdentityEntry + paris: InsightIdentityEntry } } -export type InsightIUUResponse = InsightBase & { - vesselIdentity: { - datasets: string[] - iuuVesselList: { - totalTimesListed: number - totalTimesListedInThePeriod: number - valuesInThePeriod: ValueInPeriod[] - } - } +export type InsightIdentityIUU = { + iuuVesselList?: InsightIdentityEntry } -export type InsightResponse = - | InsightCoverageResponse - | InsightFishingResponse - | InsightGapsResponse - | InsightFlagChangesResponse - | InsightMOUListResponse - | InsightIUUResponse +export type InsightIdentityFlagsChanges = { + flagsChanges?: InsightIdentityEntry +} + +export type InsightIdentity< + InsighIdentityType = InsightIdentityMOU & InsightIdentityIUU & InsightIdentityFlagsChanges +> = { + datasets: string[] +} & InsighIdentityType + +export type InsightResponse = InsightBase & { + coverage?: InsightCoverage + gap?: InsightGaps + apparentFishing?: InsightFishing + vesselIdentity?: InsightIdentity +} diff --git a/libs/dataviews-client/src/url-workspace/url-workspace.ts b/libs/dataviews-client/src/url-workspace/url-workspace.ts index d40ea492f6..99ce846397 100644 --- a/libs/dataviews-client/src/url-workspace/url-workspace.ts +++ b/libs/dataviews-client/src/url-workspace/url-workspace.ts @@ -70,15 +70,19 @@ const PARAMS_TO_ABBREVIATED = { // Vessel Group Report 'vessel-groups': 'vGs', viewOnlyVesselGroup: 'vOVG', - vesselGroupReportSection: 'vGRS', - vesselGroupReportVesselsSubsection: 'vGRSS', - vesselGroupReportActivitySubsection: 'vGRAS', - vesselGroupReportEventsSubsection: 'vGRES', - vesselGroupReportVesselPage: 'vGRVP', - vesselGroupReportResultsPerPage: 'vGRRPP', - vesselGroupReportVesselFilter: 'vGRVF', - vesselGroupReportVesselsOrderProperty: 'vGRVOP', - vesselGroupReportVesselsOrderDirection: 'vGRVOD', + vGRSection: 'vGRS', + vGRVesselsSubsection: 'vGRSS', + vGRActivitySubsection: 'vGRAS', + vGRVesselPage: 'vGRVP', + vGRVesselsResultsPerPage: 'vGRRPP', + vGRVesselFilter: 'vGRVF', + vGRVesselsOrderProperty: 'vGRVOP', + vGRVesselsOrderDirection: 'vGRVOD', + vGREventsSubsection: 'vGRES', + vGREventsVesselsProperty: 'vGREVProp', + vGREventsVesselFilter: 'vGREVF', + vGREventsVesselPage: 'vGREVP', + vGREventsResultsPerPage: 'vGRERPP', // Area report reportActivityGraph: 'rAG', reportAreaBounds: 'rAB', diff --git a/libs/deck-layer-composer/src/resolvers/clusters.ts b/libs/deck-layer-composer/src/resolvers/clusters.ts index 9cb27b5180..bddd7b9179 100644 --- a/libs/deck-layer-composer/src/resolvers/clusters.ts +++ b/libs/deck-layer-composer/src/resolvers/clusters.ts @@ -72,6 +72,9 @@ export const resolveDeckFourwingsClustersLayerProps: DeckResolverFunction< extentEnd, }), }, + ...(dataview.config?.['vessel-groups'] + ? [{ id: 'vessel-groups', value: dataview.config?.['vessel-groups'] }] + : []), ], (p) => p.id ), diff --git a/libs/deck-layers/src/layers/fourwings/clusters/FourwingsClustersLayer.ts b/libs/deck-layers/src/layers/fourwings/clusters/FourwingsClustersLayer.ts index 8eac2932dc..44fc4d5127 100644 --- a/libs/deck-layers/src/layers/fourwings/clusters/FourwingsClustersLayer.ts +++ b/libs/deck-layers/src/layers/fourwings/clusters/FourwingsClustersLayer.ts @@ -92,7 +92,7 @@ export class FourwingsClustersLayer extends CompositeLayer< viewportLoaded: false, clusterIndex: new Supercluster({ radius: 70, - maxZoom: MAX_ZOOM_TO_CLUSTER_POINTS, + maxZoom: Math.floor(MAX_ZOOM_TO_CLUSTER_POINTS), reduce: (accumulated, props) => { accumulated.count += props.count }, @@ -284,6 +284,7 @@ export class FourwingsClustersLayer extends CompositeLayer< return null } let url = getURLFromTemplate(tile.url!, tile) + console.log('url:', url) if (this.isInPositionsMode) { url = url?.replace('{{type}}', 'position').concat(`&format=MVT`) return this._fetchPositions(url!, { signal: tile.signal }) diff --git a/libs/ui-components/src/collapsable/Collapsable.module.css b/libs/ui-components/src/collapsable/Collapsable.module.css index 6fbaa9694d..75a766ec56 100644 --- a/libs/ui-components/src/collapsable/Collapsable.module.css +++ b/libs/ui-components/src/collapsable/Collapsable.module.css @@ -8,11 +8,12 @@ background-color: white; } -.details:not([open]) .summary .icon { +.details > .summary > .icon { padding-right: var(--space-M); } -.details[open] .summary .icon { +.details[open] > .summary > .icon { + padding-right: 0; padding-left: var(--space-M); transform: rotate(180deg); } diff --git a/libs/ui-components/src/collapsable/Collapsable.tsx b/libs/ui-components/src/collapsable/Collapsable.tsx index ddb5b1be96..c024558a6c 100644 --- a/libs/ui-components/src/collapsable/Collapsable.tsx +++ b/libs/ui-components/src/collapsable/Collapsable.tsx @@ -4,20 +4,30 @@ import { Icon } from '../icon' import styles from './Collapsable.module.css' interface CollapsableProps { + id?: string open?: boolean label?: string | ReactNode children?: string | ReactNode className?: string - onToggle?: () => void + labelClassName?: string + onToggle?: (isOpen: boolean, id?: string) => void } export function Collapsable(props: CollapsableProps) { - const { open = true, label, className, children, onToggle } = props + const { id, open = true, label, className, labelClassName, children, onToggle } = props + + const handleToggle = (e: any) => { + e.stopPropagation() + if (onToggle) { + onToggle(e.nativeEvent.newState === 'open', id) + } + } + return ( -
    - - {label} - +
    + + {label} + {children}