diff --git a/apps/fishing-map/features/area-report/reports.selectors.ts b/apps/fishing-map/features/area-report/reports.selectors.ts index 9e65628d05..09a1199611 100644 --- a/apps/fishing-map/features/area-report/reports.selectors.ts +++ b/apps/fishing-map/features/area-report/reports.selectors.ts @@ -1,7 +1,6 @@ import { createSelector } from '@reduxjs/toolkit' import { groupBy, sum, uniq, uniqBy } from 'es-toolkit' import sumBy from 'lodash/sumBy' -import { matchSorter } from 'match-sorter' import { t } from 'i18next' import { FeatureCollection, MultiPolygon } from 'geojson' import { Dataset, DatasetTypes, ReportVessel } from '@globalfishingwatch/api-types' @@ -32,10 +31,11 @@ import { getBufferedArea, getBufferedFeature, getReportCategoryFromDataview, + getVesselsFiltered, } from 'features/area-report/reports.utils' import { ReportCategory } from 'types' import { createDeepEqualSelector } from 'utils/selectors' -import { EMPTY_FIELD_PLACEHOLDER, getVesselGearType } from 'utils/info' +import { EMPTY_FIELD_PLACEHOLDER, getVesselGearTypeLabel } from 'utils/info' import { sortStrings } from 'utils/shared' import { Area, AreaGeometry, selectAreas } from 'features/areas/areas.slice' import { @@ -226,7 +226,7 @@ export const selectReportVesselsListWithAllInfo = createSelector( flagTranslatedClean: cleanFlagState( t(`flags:${vesselActivity[0]?.flag as string}` as any, vesselActivity[0]?.flag) ), - geartype: getVesselGearType({ geartypes: vesselActivity[0]?.geartype }), + geartype: getVesselGearTypeLabel({ geartypes: vesselActivity[0]?.geartype }), vesselType: t( `vessel.veeselTypes.${vesselActivity[0]?.vesselType}` as any, vesselActivity[0]?.vesselType @@ -243,7 +243,7 @@ function cleanVesselOrGearType({ value, property }: CleanVesselOrGearTypeParams) const valuesCleanTranslated = valuesClean .map((value) => { if (property === 'geartype') { - return getVesselGearType({ geartypes: value }) + return getVesselGearTypeLabel({ geartypes: value }) } return t(`vessel.vesselTypes.${value?.toLowerCase()}` as any, value) }) @@ -257,65 +257,13 @@ export function cleanFlagState(flagState: string) { return flagState.replace(/,/g, '') } -type FilterProperty = 'name' | 'flag' | 'mmsi' | 'gear' | 'type' -const FILTER_PROPERTIES: Record = { - name: ['shipName'], - flag: ['flag', 'flagTranslated', 'flagTranslatedClean'], - mmsi: ['mmsi'], - gear: ['geartype'], - type: ['vesselType'], -} - -export function getVesselsFiltered(vessels: ReportVesselWithDatasets[], filter: string) { - if (!filter || !filter.length) { - return vessels - } - - const filterBlocks = filter - .replace(/ ,/g, ',') - .replace(/ , /g, ',') - .replace(/, /g, ',') - .split(',') - .filter((block) => block.length) - - if (!filterBlocks.length) { - return vessels - } - - return filterBlocks - .reduce((vessels, block) => { - const propertiesToMatch = - block.includes(':') && FILTER_PROPERTIES[block.split(':')[0] as FilterProperty] - const words = (propertiesToMatch ? (block.split(':')[1] as FilterProperty) : block) - .replace('-', '') - .split('|') - .map((word) => word.trim()) - .filter((word) => word.length) - const matched = words.flatMap((w) => - matchSorter(vessels, w, { - keys: propertiesToMatch || Object.values(FILTER_PROPERTIES).flat(), - threshold: matchSorter.rankings.CONTAINS, - }) - ) - const uniqMatched = block.includes('|') ? Array.from(new Set([...matched])) : matched - if (block.startsWith('-')) { - const uniqMatchedIds = new Set() - uniqMatched.forEach(({ vesselId = '' }) => { - uniqMatchedIds.add(vesselId) - }) - return vessels.filter(({ vesselId = '' }) => !uniqMatchedIds.has(vesselId)) - } else { - return uniqMatched - } - }, vessels) - .sort((a, b) => b.value - a.value) -} - export const selectReportVesselsFiltered = createSelector( [selectReportVesselsList, selectReportVesselFilter], (vessels, filter) => { if (!vessels?.length) return null - return getVesselsFiltered(vessels, filter) + return getVesselsFiltered(vessels, filter).sort( + (a, b) => b.value - a.value + ) } ) diff --git a/apps/fishing-map/features/area-report/reports.utils.ts b/apps/fishing-map/features/area-report/reports.utils.ts index 9e70b03972..1fdd2e48be 100644 --- a/apps/fishing-map/features/area-report/reports.utils.ts +++ b/apps/fishing-map/features/area-report/reports.utils.ts @@ -4,6 +4,7 @@ import { featureCollection, multiPolygon } from '@turf/helpers' import { difference, dissolve } from '@turf/turf' import { Feature, FeatureCollection, MultiPolygon, Polygon } from 'geojson' import { parse } from 'qs' +import { matchSorter } from 'match-sorter' import { UrlDataviewInstance } from '@globalfishingwatch/dataviews-client' import { Dataview, DataviewCategory, EXCLUDE_FILTER_ID } from '@globalfishingwatch/api-types' import { getFeatureBuffer, wrapGeometryBbox } from '@globalfishingwatch/data-transforms' @@ -20,6 +21,7 @@ import { import { Bbox, BufferOperation, BufferUnit, ReportCategory } from 'types' import { Area, AreaGeometry } from 'features/areas/areas.slice' import { IdentityVesselData, VesselDataIdentity } from 'features/vessel/vessel.slice' +import { VesselGroupReportVesselParsed } from 'features/vessel-group-report/vessels/vessel-group-report-vessels.types' import { DEFAULT_BUFFER_OPERATION, DEFAULT_POINT_BUFFER_UNIT, @@ -306,3 +308,65 @@ export function parseReportVesselsToIdentity( }) return identityVessels } + +export type FilterProperty = 'name' | 'flag' | 'mmsi' | 'gear' | 'type' +export const FILTER_PROPERTIES: Record = { + name: ['shipName'], + flag: ['flag', 'flagTranslated', 'flagTranslatedClean'], + mmsi: ['mmsi'], + gear: ['geartype'], + type: ['vesselType'], +} + +export function getVesselsFiltered< + Vessel = ReportVesselWithDatasets | VesselGroupReportVesselParsed +>(vessels: Vessel[], filter: string, filterProperties = FILTER_PROPERTIES) { + if (!filter || !filter.length) { + return vessels + } + + const filterBlocks = filter + .replace(/ ,/g, ',') + .replace(/ , /g, ',') + .replace(/, /g, ',') + .split(',') + .filter((block) => block.length) + + if (!filterBlocks.length) { + return vessels + } + + return filterBlocks.reduce((vessels, block) => { + const propertiesToMatch = + block.includes(':') && filterProperties[block.split(':')[0] as FilterProperty] + const words = (propertiesToMatch ? (block.split(':')[1] as FilterProperty) : block) + .replace('-', '') + .split('|') + .map((word) => word.trim()) + .filter((word) => word.length) + const matched = words.flatMap((w) => + matchSorter(vessels, w, { + keys: propertiesToMatch || Object.values(filterProperties).flat(), + threshold: matchSorter.rankings.CONTAINS, + }) + ) + const uniqMatched = block.includes('|') ? Array.from(new Set([...matched])) : matched + if (block.startsWith('-')) { + const uniqMatchedIds = new Set() + uniqMatched.forEach((vessel) => { + const id = + (vessel as ReportVesselWithDatasets).vesselId || + (vessel as VesselGroupReportVesselParsed).id + uniqMatchedIds.add(id) + }) + return vessels.filter((vessel) => { + const id = + (vessel as ReportVesselWithDatasets).vesselId || + (vessel as VesselGroupReportVesselParsed).id + return !uniqMatchedIds.has(id) + }) + } else { + return uniqMatched + } + }, vessels) as Vessel[] +} diff --git a/apps/fishing-map/features/area-report/vessels/ReportVessels.tsx b/apps/fishing-map/features/area-report/vessels/ReportVessels.tsx index b4f312bd37..b1897a1679 100644 --- a/apps/fishing-map/features/area-report/vessels/ReportVessels.tsx +++ b/apps/fishing-map/features/area-report/vessels/ReportVessels.tsx @@ -5,6 +5,7 @@ import ReportVesselsGraphSelector from 'features/area-report/vessels/ReportVesse import { selectActiveReportDataviews, selectReportCategory, + selectReportVesselFilter, } from 'features/app/selectors/app.reports.selector' import { ReportCategory } from 'types' import ReportSummaryTags from 'features/area-report/summary/ReportSummaryTags' @@ -24,6 +25,7 @@ type ReportVesselTableProps = { export default function ReportVessels({ activityUnit, reportName }: ReportVesselTableProps) { const { t } = useTranslation() const reportCategory = useSelector(selectReportCategory) + const reportVesselFilter = useSelector(selectReportVesselFilter) const dataviews = useSelector(selectActiveReportDataviews) const commonProperties = useMemo(() => { return getCommonProperties(dataviews).filter( @@ -53,7 +55,11 @@ export default function ReportVessels({ activityUnit, reportName }: ReportVessel ))} - + ) diff --git a/apps/fishing-map/features/area-report/vessels/ReportVesselsFilter.tsx b/apps/fishing-map/features/area-report/vessels/ReportVesselsFilter.tsx index fa9e9109ee..7283008cb0 100644 --- a/apps/fishing-map/features/area-report/vessels/ReportVesselsFilter.tsx +++ b/apps/fishing-map/features/area-report/vessels/ReportVesselsFilter.tsx @@ -1,24 +1,29 @@ import React, { useEffect, useState, Fragment } from 'react' -import { useSelector } from 'react-redux' import { useTranslation } from 'react-i18next' import { useDebounce } from 'use-debounce' import { InputText, Tooltip } from '@globalfishingwatch/ui-components' -import { selectReportVesselFilter } from 'features/app/selectors/app.reports.selector' import { useLocationConnect } from 'routes/routes.hook' import { TrackCategory, trackEvent } from 'features/app/analytics.hooks' import styles from './ReportVesselsFilter.module.css' -type ReportVesselsFilterProps = {} +type ReportVesselsFilterProps = { + filter: string + filterQueryParam: string + pageQueryParam: string +} -export default function ReportVesselsFilter(props: ReportVesselsFilterProps) { +export default function ReportVesselsFilter({ + filter, + filterQueryParam, + pageQueryParam, +}: ReportVesselsFilterProps) { const { t } = useTranslation() - const reportVesselFilter = useSelector(selectReportVesselFilter) const { dispatchQueryParams } = useLocationConnect() - const [query, setQuery] = useState(reportVesselFilter) + const [query, setQuery] = useState(filter) const [debouncedQuery] = useDebounce(query, 200) useEffect(() => { - dispatchQueryParams({ reportVesselFilter: debouncedQuery, reportVesselPage: 0 }) + dispatchQueryParams({ [filterQueryParam]: debouncedQuery, [pageQueryParam]: 0 }) trackEvent({ category: TrackCategory.Analysis, action: 'Type search into vessel list', @@ -28,11 +33,11 @@ export default function ReportVesselsFilter(props: ReportVesselsFilterProps) { }, [debouncedQuery]) useEffect(() => { - if (reportVesselFilter !== query) { - setQuery(reportVesselFilter) + if (filter !== query) { + setQuery(filter) } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [reportVesselFilter]) + }, [filter]) return (
diff --git a/apps/fishing-map/features/area-report/vessels/ReportVesselsGraph.tsx b/apps/fishing-map/features/area-report/vessels/ReportVesselsGraph.tsx index b881a7a433..a7c5685294 100644 --- a/apps/fishing-map/features/area-report/vessels/ReportVesselsGraph.tsx +++ b/apps/fishing-map/features/area-report/vessels/ReportVesselsGraph.tsx @@ -15,7 +15,7 @@ import { REPORT_VESSELS_GRAPH_VESSELTYPE, } from 'data/config' import { EMPTY_API_VALUES, OTHERS_CATEGORY_LABEL } from 'features/area-report/reports.config' -import { getVesselGearType } from 'utils/info' +import { getVesselGearTypeLabel } from 'utils/info' import { cleanFlagState, selectReportDataviewsWithPermissions, @@ -48,7 +48,7 @@ const ReportGraphTooltip = (props: any) => { let translatedLabel = '' if (EMPTY_API_VALUES.includes(label)) translatedLabel = t('common.unknown', 'Unknown') else if (type === 'geartype') { - translatedLabel = getVesselGearType({ geartypes: label }) + translatedLabel = getVesselGearTypeLabel({ geartypes: label }) } else { translatedLabel = t(`flags:${label}` as any, label) } @@ -88,7 +88,7 @@ const CustomTick = (props: any) => { if (EMPTY_API_VALUES.includes(label)) return t('analysis.unknown', 'Unknown') switch (selectedReportVesselGraph) { case 'geartype': - return getVesselGearType({ geartypes: label }) + return getVesselGearTypeLabel({ geartypes: label }) case 'vesselType': return `${t(`vessel.vesselTypes.${label?.toLowerCase()}` as any, label)}` case 'flag': diff --git a/apps/fishing-map/features/datasets/datasets.utils.ts b/apps/fishing-map/features/datasets/datasets.utils.ts index f573681305..2c8e160293 100644 --- a/apps/fishing-map/features/datasets/datasets.utils.ts +++ b/apps/fishing-map/features/datasets/datasets.utils.ts @@ -37,7 +37,7 @@ import { capitalize, sortFields } from 'utils/shared' import { t } from 'features/i18n/i18n' import { PUBLIC_SUFIX, FULL_SUFIX, DEFAULT_TIME_RANGE } from 'data/config' import { getFlags, getFlagsByIds } from 'utils/flags' -import { getVesselGearType, getVesselShipType } from 'utils/info' +import { getVesselGearTypeLabel, getVesselShipTypeLabel } from 'utils/info' import { getDatasetNameTranslated } from 'features/i18n/utils.datasets' import { formatI18nNumber } from 'features/i18n/i18nNumber' import styles from '../vessel-groups/VesselGroupModal.module.css' @@ -732,9 +732,9 @@ export const getCommonSchemaFieldsInDataview = ( if (label === field) { if (schema === 'geartypes' || schema === 'geartype') { // There is an fixed list of gearTypes independant of the dataset - label = getVesselGearType({ geartypes: field as string }) + label = getVesselGearTypeLabel({ geartypes: field as string }) } else if (schema === 'vessel_type') { - label = getVesselShipType({ shiptypes: field as string }) + label = getVesselShipTypeLabel({ shiptypes: field as string }) } else if ( dataview.category !== DataviewCategory.Context && schema !== 'vessel_id' && diff --git a/apps/fishing-map/features/dataviews/selectors/dataviews.selectors.ts b/apps/fishing-map/features/dataviews/selectors/dataviews.selectors.ts index a38a8c5d97..cd19b58427 100644 --- a/apps/fishing-map/features/dataviews/selectors/dataviews.selectors.ts +++ b/apps/fishing-map/features/dataviews/selectors/dataviews.selectors.ts @@ -42,7 +42,7 @@ import { import { isBathymetryDataview } from 'features/dataviews/dataviews.utils' import { selectDownloadActiveTabId } from 'features/download/downloadActivity.slice' import { HeatmapDownloadTab } from 'features/download/downloadActivity.config' -import { selectViewOnlyVesselGroup } from 'features/vessel-group-report/vessel.config.selectors' +import { selectViewOnlyVesselGroup } from 'features/vessel-group-report/vessel-group.config.selectors' import { selectContextAreasDataviews, selectActivityDataviews, diff --git a/apps/fishing-map/features/map/map-interactions.hooks.ts b/apps/fishing-map/features/map/map-interactions.hooks.ts index f00e042d09..a90afed60d 100644 --- a/apps/fishing-map/features/map/map-interactions.hooks.ts +++ b/apps/fishing-map/features/map/map-interactions.hooks.ts @@ -180,7 +180,6 @@ export const useClickedEventConnect = () => { } ) - console.log('heatmapFeatures:', heatmapFeatures) if (heatmapFeatures?.length) { dispatch(setHintDismissed('clickingOnAGridCellToShowVessels')) const heatmapProperties = heatmapFeatures.map((feature) => diff --git a/apps/fishing-map/features/map/popups/categories/VesselsTable.tsx b/apps/fishing-map/features/map/popups/categories/VesselsTable.tsx index a62880daa8..f1e6d4da4a 100644 --- a/apps/fishing-map/features/map/popups/categories/VesselsTable.tsx +++ b/apps/fishing-map/features/map/popups/categories/VesselsTable.tsx @@ -12,9 +12,9 @@ import { EMPTY_FIELD_PLACEHOLDER, formatInfoField, getDetectionsTimestamps, - getVesselGearType, + getVesselGearTypeLabel, getVesselOtherNamesLabel, - getVesselShipType, + getVesselShipTypeLabel, } from 'utils/info' import { getDatasetLabel } from 'features/datasets/datasets.utils' import I18nNumber from 'features/i18n/i18nNumber' @@ -163,10 +163,10 @@ function VesselsTable({ const vesselFlag = getVesselProperty(vessel, 'flag', getVesselPropertyParams) const vesselType = isPresenceActivity - ? getVesselShipType({ + ? getVesselShipTypeLabel({ shiptypes: getVesselProperty(vessel, 'shiptypes', getVesselPropertyParams), }) - : getVesselGearType({ + : getVesselGearTypeLabel({ geartypes: getVesselProperty(vessel, 'geartypes', getVesselPropertyParams), }) diff --git a/apps/fishing-map/features/search/SearchDownload.tsx b/apps/fishing-map/features/search/SearchDownload.tsx index e51e24ca84..68caa9aafc 100644 --- a/apps/fishing-map/features/search/SearchDownload.tsx +++ b/apps/fishing-map/features/search/SearchDownload.tsx @@ -4,7 +4,7 @@ import { unparse as unparseCSV } from 'papaparse' import { saveAs } from 'file-saver' import { IconButton } from '@globalfishingwatch/ui-components' import { getSearchIdentityResolved, getVesselProperty } from 'features/vessel/vessel.utils' -import { formatInfoField, getVesselGearType, getVesselShipType } from 'utils/info' +import { formatInfoField, getVesselGearTypeLabel, getVesselShipTypeLabel } from 'utils/info' import { TrackCategory, trackEvent } from 'features/app/analytics.hooks' import { selectSearchResults, selectSelectedVessels } from './search.slice' @@ -22,8 +22,10 @@ function SearchDownload() { imo: getVesselProperty(vessel, 'imo'), 'call sign': getVesselProperty(vessel, 'callsign'), flag: t(`flags:${getVesselProperty(vessel, 'flag')}` as any), - 'vessel type': getVesselShipType({ shiptypes: getVesselProperty(vessel, 'shiptypes') }), - 'gear type': getVesselGearType({ + 'vessel type': getVesselShipTypeLabel({ + shiptypes: getVesselProperty(vessel, 'shiptypes'), + }), + 'gear type': getVesselGearTypeLabel({ geartypes: getVesselProperty(vessel, 'geartypes'), }), owner: formatInfoField(getVesselProperty(vessel, 'owner'), 'owner'), diff --git a/apps/fishing-map/features/search/advanced/SearchAdvancedResults.tsx b/apps/fishing-map/features/search/advanced/SearchAdvancedResults.tsx index 87d705fecc..99d17189b7 100644 --- a/apps/fishing-map/features/search/advanced/SearchAdvancedResults.tsx +++ b/apps/fishing-map/features/search/advanced/SearchAdvancedResults.tsx @@ -16,8 +16,8 @@ import { import { formatInfoField, EMPTY_FIELD_PLACEHOLDER, - getVesselGearType, - getVesselShipType, + getVesselGearTypeLabel, + getVesselShipTypeLabel, getVesselOtherNamesLabel, } from 'utils/info' import I18nFlag from 'features/i18n/i18nFlag' @@ -125,7 +125,7 @@ function SearchAdvancedResults({ fetchResults, fetchMoreResults }: SearchCompone const shiptypes = getVesselProperty(vessel, 'shiptypes', { identitySource: VesselIdentitySourceEnum.SelfReported, }) - const label = getVesselShipType({ shiptypes }) + const label = getVesselShipTypeLabel({ shiptypes }) return ( {label || EMPTY_FIELD_PLACEHOLDER} @@ -140,7 +140,7 @@ function SearchAdvancedResults({ fetchResults, fetchMoreResults }: SearchCompone const geartypes = getVesselProperty(vessel, 'geartypes', { identitySource: VesselIdentitySourceEnum.SelfReported, }) - const label = getVesselGearType({ geartypes }) + const label = getVesselGearTypeLabel({ geartypes }) return ( TOOLTIP_LABEL_CHARACTERS ? label : ''}> @@ -159,7 +159,7 @@ function SearchAdvancedResults({ fetchResults, fetchMoreResults }: SearchCompone const geartypes = getVesselProperty(vessel, 'geartypes', { identitySource: VesselIdentitySourceEnum.Registry, }) - const label = getVesselGearType({ geartypes }) + const label = getVesselGearTypeLabel({ geartypes }) return (
- {getVesselShipType({ shiptypes }) || EMPTY_FIELD_PLACEHOLDER} + {getVesselShipTypeLabel({ shiptypes }) || EMPTY_FIELD_PLACEHOLDER}
diff --git a/apps/fishing-map/features/vessel-group-report/VesselGroupReport.tsx b/apps/fishing-map/features/vessel-group-report/VesselGroupReport.tsx index e38b151e4c..d61566aa9e 100644 --- a/apps/fishing-map/features/vessel-group-report/VesselGroupReport.tsx +++ b/apps/fishing-map/features/vessel-group-report/VesselGroupReport.tsx @@ -15,7 +15,7 @@ import { import VesselGroupReportError from './VesselGroupReportError' import VesselGroupReportTitle from './VesselGroupReportTitle' import VesselGroupReportVessels from './vessels/VesselGroupReportVessels' -import { selectVesselGroupReportSection } from './vessel.config.selectors' +import { selectVesselGroupReportSection } from './vessel-group.config.selectors' type VesselGroupReportProps = {} diff --git a/apps/fishing-map/features/vessel-group-report/VesselGroupReportTitle.tsx b/apps/fishing-map/features/vessel-group-report/VesselGroupReportTitle.tsx index 12531b13da..dbe4dc3ce6 100644 --- a/apps/fishing-map/features/vessel-group-report/VesselGroupReportTitle.tsx +++ b/apps/fishing-map/features/vessel-group-report/VesselGroupReportTitle.tsx @@ -17,7 +17,7 @@ import { useLocationConnect } from 'routes/routes.hook' import { selectHasOtherVesselGroupDataviews } from 'features/dataviews/selectors/dataviews.selectors' import styles from './VesselGroupReportTitle.module.css' import { VesselGroupReport } from './vessel-group-report.slice' -import { selectViewOnlyVesselGroup } from './vessel.config.selectors' +import { selectViewOnlyVesselGroup } from './vessel-group.config.selectors' type ReportTitleProps = { loading?: boolean 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 69ffc86916..ca16d5d123 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 @@ -1 +1,16 @@ +import { REPORT_VESSELS_PER_PAGE } from 'data/config' +import { VesselGroupReportState } from 'types' + export const OTHER_CATEGORY_LABEL = 'OTHER' + +export const DEFAULT_VESSEL_GROUP_REPORT_STATE: VesselGroupReportState = { + viewOnlyVesselGroup: true, + vesselGroupReportSection: 'vessels', + vesselGroupReportVesselsSubsection: 'flag', + vesselGroupReportActivitySubsection: 'fishing-effort', + vesselGroupReportEventsSubsection: 'encounters', + vesselGroupReportVesselPage: 0, + vesselGroupReportResultsPerPage: REPORT_VESSELS_PER_PAGE, + vesselGroupReportVesselsOrderProperty: 'shipname', + vesselGroupReportVesselsOrderDirection: 'asc', +} 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 dc93ef9fe8..3c1af9b61d 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 @@ -3,6 +3,7 @@ import { GFWAPI } from '@globalfishingwatch/api-client' import { APIPagination, IdentityVessel, VesselGroup } from '@globalfishingwatch/api-types' import { AsyncError, AsyncReducerStatus } from 'utils/async-slice' import { DEFAULT_VESSEL_IDENTITY_ID } from 'features/vessel/vessel.config' +import { getVesselProperty } from 'features/vessel/vessel.utils' export type VesselGroupReport = Omit & { vessels: IdentityVessel[] } @@ -32,7 +33,17 @@ export const fetchVesselGroupReportThunk = createAsyncThunk( const vesselGroupVessels = await GFWAPI.fetch>( `/vessels?vessel-groups[0]=${vesselGroupId}&datasets[0]=${DEFAULT_VESSEL_IDENTITY_ID}` ) - return { ...vesselGroup, vessels: vesselGroupVessels.entries } + return { + ...vesselGroup, + vessels: vesselGroupVessels.entries.toSorted((a, b) => { + const aValue = getVesselProperty(a, 'shipname') + const bValue = getVesselProperty(b, 'shipname') + if (aValue === bValue) { + return 0 + } + return aValue > bValue ? 1 : -1 + }), + } } catch (e) { console.warn(e) return rejectWithValue(e) diff --git a/apps/fishing-map/features/vessel-group-report/vessel.config.selectors.ts b/apps/fishing-map/features/vessel-group-report/vessel-group.config.selectors.ts similarity index 63% rename from apps/fishing-map/features/vessel-group-report/vessel.config.selectors.ts rename to apps/fishing-map/features/vessel-group-report/vessel-group.config.selectors.ts index 4a6ae30695..0fdbdc6f2c 100644 --- a/apps/fishing-map/features/vessel-group-report/vessel.config.selectors.ts +++ b/apps/fishing-map/features/vessel-group-report/vessel-group.config.selectors.ts @@ -1,7 +1,7 @@ import { createSelector } from '@reduxjs/toolkit' import { VesselGroupReportState, VesselGroupReportStateProperty } from 'types' import { selectQueryParam } from 'routes/routes.selectors' -import { DEFAULT_VESSEL_GROUP_REPORT_STATE } from 'features/vessel/vessel.config' +import { DEFAULT_VESSEL_GROUP_REPORT_STATE } from './vessel-group-report.config' type VesselGroupReportProperty

= Required[P] @@ -30,3 +30,19 @@ export const selectVesselGroupReportActivitySubsection = selectVesselGroupReport export const selectVesselGroupReportEventsSubsection = selectVesselGroupReportStateProperty( 'vesselGroupReportEventsSubsection' ) + +export const selectVesselGroupReportVesselFilter = selectVesselGroupReportStateProperty( + 'vesselGroupReportVesselFilter' +) +export const selectVesselGroupReportVesselPage = selectVesselGroupReportStateProperty( + 'vesselGroupReportVesselPage' +) +export const selectVesselGroupReportResultsPerPage = selectVesselGroupReportStateProperty( + 'vesselGroupReportResultsPerPage' +) +export const selectVesselGroupReportVesselsOrderProperty = selectVesselGroupReportStateProperty( + 'vesselGroupReportVesselsOrderProperty' +) +export const selectVesselGroupReportVesselsOrderDirection = selectVesselGroupReportStateProperty( + 'vesselGroupReportVesselsOrderDirection' +) diff --git a/apps/fishing-map/features/vessel-group-report/vessels/VesselGroupReportVessels.tsx b/apps/fishing-map/features/vessel-group-report/vessels/VesselGroupReportVessels.tsx index 581ef90bee..3add0bfedf 100644 --- a/apps/fishing-map/features/vessel-group-report/vessels/VesselGroupReportVessels.tsx +++ b/apps/fishing-map/features/vessel-group-report/vessels/VesselGroupReportVessels.tsx @@ -1,14 +1,23 @@ +import { useSelector } from 'react-redux' +import ReportVesselsFilter from 'features/area-report/vessels/ReportVesselsFilter' +import { selectVesselGroupReportVesselFilter } from '../vessel-group.config.selectors' import VesselGroupReportVesselsGraphSelector from './VesselGroupReportVesselsGraphSelector' import VesselGroupReportVesselsGraph from './VesselGroupReportVesselsGraph' +import VesselGroupReportVesselsTable from './VesselGroupReportVesselsTable' import styles from './VesselGroupReportVessels.module.css' -type VesselGroupReportVesselsProps = {} - -function VesselGroupReportVessels(props: VesselGroupReportVesselsProps) { +function VesselGroupReportVessels() { + const vesselGroupReportVesselFilter = useSelector(selectVesselGroupReportVesselFilter) return (

+ +
) } diff --git a/apps/fishing-map/features/vessel-group-report/vessels/VesselGroupReportVesselsGraph.module.css b/apps/fishing-map/features/vessel-group-report/vessels/VesselGroupReportVesselsGraph.module.css index 9193127cc5..9f5da2c0c5 100644 --- a/apps/fishing-map/features/vessel-group-report/vessels/VesselGroupReportVesselsGraph.module.css +++ b/apps/fishing-map/features/vessel-group-report/vessels/VesselGroupReportVesselsGraph.module.css @@ -40,3 +40,7 @@ .graph :global(.recharts-tooltip-cursor) { fill: var(--color-terthiary-blue); } + +.bar { + transition: fill 300ms linear; +} 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 c805e1dba0..0b24657cd4 100644 --- a/apps/fishing-map/features/vessel-group-report/vessels/VesselGroupReportVesselsGraph.tsx +++ b/apps/fishing-map/features/vessel-group-report/vessels/VesselGroupReportVesselsGraph.tsx @@ -5,15 +5,15 @@ import { BarChart, Bar, XAxis, Tooltip, ResponsiveContainer, LabelList } from 'r import { useTranslation } from 'react-i18next' import { VesselGroupReportVesselsSubsection } from 'types' import I18nNumber, { formatI18nNumber } from 'features/i18n/i18nNumber' -import { ReportVesselsGraphPlaceholder } from 'features/area-report/placeholders/ReportVesselsPlaceholder' import { EMPTY_API_VALUES, OTHERS_CATEGORY_LABEL } from 'features/area-report/reports.config' -import { getVesselGearType, getVesselShipType } from 'utils/info' -import { selectVesselGroupReportVesselsSubsection } from 'features/vessel-group-report/vessel.config.selectors' +import { formatInfoField } from 'utils/info' +import { selectVesselGroupReportVesselsSubsection } from 'features/vessel-group-report/vessel-group.config.selectors' import { selectVesselGroupReportVesselsGraphDataGrouped } from 'features/vessel-group-report/vessels/vessel-group-report-vessels.selectors' +import { selectReportVesselGroupId } from 'routes/routes.selectors' +import { selectActiveDataviewInstancesResolved } from 'features/dataviews/selectors/dataviews.instances.selectors' +import { useLocationConnect } from 'routes/routes.hook' import styles from './VesselGroupReportVesselsGraph.module.css' -const MAX_OTHER_TOOLTIP_ITEMS = 10 - type ReportGraphTooltipProps = { active: boolean payload: { @@ -33,21 +33,15 @@ const ReportGraphTooltip = (props: any) => { const { active, payload, label, type } = props as ReportGraphTooltipProps const { t } = useTranslation() - let translatedLabel = '' - if (EMPTY_API_VALUES.includes(label)) translatedLabel = t('common.unknown', 'Unknown') - else if (type === 'geartypes') { - translatedLabel = getVesselGearType({ geartypes: label }) - } else if (type === 'shiptypes') { - translatedLabel = getVesselShipType({ shiptypes: label }) - } else if (type === 'source') { - translatedLabel = t(`common.sourceOptions.${label}` as any, label) - } else { - translatedLabel = t(`flags:${label}` as any, label) + let parsedLabel = label + if (EMPTY_API_VALUES.includes(label)) parsedLabel = t('common.unknown', 'Unknown') + else if (type === 'flag') { + parsedLabel = formatInfoField(label, 'flag') as string } if (active && payload && payload.length) { return (
-

{translatedLabel}

+

{parsedLabel}

    {payload .map(({ value }, index) => { @@ -70,24 +64,32 @@ const CustomTick = (props: any) => { const { x, y, payload, width, visibleTicksCount } = props const { t } = useTranslation() const subsection = useSelector(selectVesselGroupReportVesselsSubsection) + const { dispatchQueryParams } = useLocationConnect() const isOtherCategory = payload.value === OTHERS_CATEGORY_LABEL const isCategoryInteractive = !EMPTY_API_VALUES.includes(payload.value) const getTickLabel = (label: string) => { if (EMPTY_API_VALUES.includes(label)) return t('analysis.unknown', 'Unknown') switch (subsection) { - case 'geartypes': - return getVesselGearType({ geartypes: label }) - case 'shiptypes': - return `${t(`vessel.vesselTypes.${label?.toLowerCase()}` as any, label)}` case 'flag': - return t(`flags:${label}` as any, label) - case 'source': - return t(`common.sourceOptions.${label}` as any, label) + return formatInfoField(label, 'flag') as string default: return label } } + const filterProperties: Record = { + flag: 'flag', + shiptypes: 'type', + geartypes: 'gear', + source: 'source', + } + + const onLabelClick = () => { + dispatchQueryParams({ + vesselGroupReportVesselFilter: `${filterProperties[subsection]}:${payload.value}`, + vesselGroupReportVesselPage: 0, + }) + } const label = isOtherCategory ? t('analysis.others', 'Others') : getTickLabel(payload.value) const labelChunks = label.split(' ') @@ -105,6 +107,7 @@ const CustomTick = (props: any) => { {labelChunksClean.map((chunk) => ( @@ -124,8 +127,12 @@ const CustomTick = (props: any) => { } export default function VesselGroupReportVesselsGraph() { - const { t } = useTranslation() - // const dataviews = useSelector(selectDataviewInstancesByCategory(DataviewCategory.VesselGroups)) + const dataviews = useSelector(selectActiveDataviewInstancesResolved) + const reportVesselGroupId = useSelector(selectReportVesselGroupId) + const reportDataview = dataviews?.find(({ config }) => + config?.filters?.['vessel-groups'].includes(reportVesselGroupId) + ) + const data = useSelector(selectVesselGroupReportVesselsGraphDataGrouped) const selectedReportVesselGraph = useSelector(selectVesselGroupReportVesselsSubsection) return ( @@ -147,7 +154,11 @@ export default function VesselGroupReportVesselsGraph() { {data && ( } /> )} - + formatI18nNumber(entry.value)} 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 7401316512..70101cd4c6 100644 --- a/apps/fishing-map/features/vessel-group-report/vessels/VesselGroupReportVesselsGraphSelector.tsx +++ b/apps/fishing-map/features/vessel-group-report/vessels/VesselGroupReportVesselsGraphSelector.tsx @@ -5,7 +5,7 @@ import { VesselGroupReportVesselsSubsection } from 'types' import { useLocationConnect } from 'routes/routes.hook' import { selectVesselGroupReportStatus } from 'features/vessel-group-report/vessel-group-report.slice' import { AsyncReducerStatus } from 'utils/async-slice' -import { selectVesselGroupReportVesselsSubsection } from '../vessel.config.selectors' +import { selectVesselGroupReportVesselsSubsection } from '../vessel-group.config.selectors' type VesselGroupReportVesselsGraphSelectorProps = {} diff --git a/apps/fishing-map/features/vessel-group-report/vessels/VesselGroupReportVesselsTable.module.css b/apps/fishing-map/features/vessel-group-report/vessels/VesselGroupReportVesselsTable.module.css new file mode 100644 index 0000000000..897defb2a2 --- /dev/null +++ b/apps/fishing-map/features/vessel-group-report/vessels/VesselGroupReportVesselsTable.module.css @@ -0,0 +1,68 @@ +.tableContainer { + margin-top: 2.5rem; + min-height: 42rem; +} + +.vesselsTable { + width: 100%; + border-collapse: collapse; + display: grid; + grid-template-columns: max-content 2fr max-content 2fr 2fr; +} + +.spansFirstTwoColumns { + grid-column: 1 / 3; +} + +.header { + text-align: left; + font: var(--font-S); + color: var(--color-secondary-blue); + text-transform: uppercase; + display: flex; +} + +.sortIcon:not(.active) { + display: none; +} + +.header:hover .sortIcon { + display: block; +} + +.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/vessels/VesselGroupReportVesselsTable.tsx b/apps/fishing-map/features/vessel-group-report/vessels/VesselGroupReportVesselsTable.tsx new file mode 100644 index 0000000000..655779c4ed --- /dev/null +++ b/apps/fishing-map/features/vessel-group-report/vessels/VesselGroupReportVesselsTable.tsx @@ -0,0 +1,180 @@ +import { useSelector } from 'react-redux' +import { useTranslation } from 'react-i18next' +import cx from 'classnames' +import { Fragment } from 'react' +import { IconButton } from '@globalfishingwatch/ui-components' +import { EMPTY_FIELD_PLACEHOLDER } from 'utils/info' +import { useLocationConnect } from 'routes/routes.hook' +import { getDatasetsReportNotSupported } from 'features/datasets/datasets.utils' +import { selectActiveReportDataviews } from 'features/app/selectors/app.reports.selector' +import { selectUserData } from 'features/user/selectors/user.selectors' +import DatasetLabel from 'features/datasets/DatasetLabel' +import { EMPTY_API_VALUES } from 'features/area-report/reports.config' +import VesselLink from 'features/vessel/VesselLink' +import VesselPin from 'features/vessel/VesselPin' +import { selectWorkspaceStatus } from 'features/workspace/workspace.selectors' +import { AsyncReducerStatus } from 'utils/async-slice' +import { + VesselGroupReportVesselsOrderDirection, + VesselGroupReportVesselsOrderProperty, +} from 'types' +import { + selectVesselGroupReportVesselsOrderDirection, + selectVesselGroupReportVesselsOrderProperty, +} from 'features/vessel-group-report/vessel-group.config.selectors' +import { selectVesselGroupReportVessels } from 'features/vessel-group-report/vessel-group-report.slice' +import styles from './VesselGroupReportVesselsTable.module.css' +import { selectVesselGroupReportVesselsPaginated } 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 userData = useSelector(selectUserData) + const dataviews = useSelector(selectActiveReportDataviews) + const workspaceStatus = useSelector(selectWorkspaceStatus) + const orderProperty = useSelector(selectVesselGroupReportVesselsOrderProperty) + const orderDirection = useSelector(selectVesselGroupReportVesselsOrderDirection) + const datasetsDownloadNotSupported = getDatasetsReportNotSupported( + dataviews, + userData?.permissions || [] + ) + + const onFilterClick = (vesselGroupReportVesselFilter: any) => { + dispatchQueryParams({ vesselGroupReportVesselFilter, vesselGroupReportVesselPage: 0 }) + } + + const onPinClick = () => { + dispatchQueryParams({ viewOnlyVesselGroup: false }) + } + + const handleSortClick = ( + property: VesselGroupReportVesselsOrderProperty, + direction: VesselGroupReportVesselsOrderDirection + ) => { + dispatchQueryParams({ + vesselGroupReportVesselsOrderProperty: property, + vesselGroupReportVesselsOrderDirection: direction, + }) + } + + return ( + +
    + {datasetsDownloadNotSupported.length > 0 && ( +

    + {t( + 'analysis.datasetsNotAllowed', + 'Vessels are not included from the following sources:' + )}{' '} + {datasetsDownloadNotSupported.map((dataset, index) => ( + + + {index < datasetsDownloadNotSupported.length - 1 && ', '} + + ))} +

    + )} +
    +
    + {t('common.name', 'Name')} + handleSortClick('shipname', orderDirection === 'asc' ? 'desc' : 'asc')} + className={cx(styles.sortIcon, { [styles.active]: orderProperty === 'shipname' })} + /> +
    +
    {t('vessel.mmsi', 'mmsi')}
    +
    + {t('layer.flagState_one', 'Flag state')} + handleSortClick('flag', orderDirection === 'asc' ? 'desc' : 'asc')} + className={cx(styles.sortIcon, { [styles.active]: orderProperty === 'flag' })} + /> +
    +
    + {t('vessel.vessel_type', 'Vessel Type')} + handleSortClick('shiptype', orderDirection === 'asc' ? 'desc' : 'asc')} + className={cx(styles.sortIcon, { [styles.active]: orderProperty === 'shiptype' })} + /> +
    + {vessels?.map((vessel, i) => { + const { id, shipName, flag, flagTranslatedClean, flagTranslated, mmsi, index } = vessel + const isLastRow = i === vessels.length - 1 + const flagInteractionEnabled = !EMPTY_API_VALUES.includes(flagTranslated) + const type = vessel.vesselType + const typeInteractionEnabled = type !== EMPTY_FIELD_PLACEHOLDER + const workspaceReady = workspaceStatus === AsyncReducerStatus.Finished + return ( + +
    + +
    +
    + {workspaceReady ? ( + + {shipName} + + ) : ( + shipName + )} +
    +
    + {mmsi || EMPTY_FIELD_PLACEHOLDER} +
    +
    onFilterClick(`flag:${flagTranslatedClean}`) + : undefined + } + > + {flagTranslated} +
    +
    onFilterClick(`${'type'}:${type}`) : undefined + } + > + {type} +
    +
    + ) + })} +
    +
    + {/* TODO */} + +
    + ) +} diff --git a/apps/fishing-map/features/vessel-group-report/vessels/VesselGroupReportVesselsTableFooter.module.css b/apps/fishing-map/features/vessel-group-report/vessels/VesselGroupReportVesselsTableFooter.module.css new file mode 100644 index 0000000000..958b7cdaeb --- /dev/null +++ b/apps/fishing-map/features/vessel-group-report/vessels/VesselGroupReportVesselsTableFooter.module.css @@ -0,0 +1,62 @@ +.footer { + display: flex; + flex-direction: column; + align-items: center; + justify-content: space-between; + margin-top: var(--space-S); + color: var(--color-secondary-blue); + text-transform: uppercase; + font: var(--font-S); + gap: 1rem; +} + +.flex { + display: flex; + align-items: center; + gap: 1rem; +} + +.footer > .flex { + justify-content: space-between; +} + +.footer .disabled { + opacity: 0.5; +} + +.noWrap { + white-space: nowrap; +} + +.expand { + flex: 1; + width: 100%; +} + +.footer > .end { + justify-content: flex-end; +} + +.pointer { + cursor: pointer; +} + +.dot { + width: 1rem; + height: 1rem; + border-radius: 0.5rem; + border: var(--border); + display: inline-flex; + background-color: currentcolor; + margin-right: 0.5rem; +} + +@media print { + .footer button { + display: none; + } + + .footer > div:last-child { + display: none; + } +} diff --git a/apps/fishing-map/features/vessel-group-report/vessels/VesselGroupReportVesselsTableFooter.tsx b/apps/fishing-map/features/vessel-group-report/vessels/VesselGroupReportVesselsTableFooter.tsx new file mode 100644 index 0000000000..8bbb7a72b1 --- /dev/null +++ b/apps/fishing-map/features/vessel-group-report/vessels/VesselGroupReportVesselsTableFooter.tsx @@ -0,0 +1,166 @@ +import { useSelector } from 'react-redux' +import { useTranslation } from 'react-i18next' +import cx from 'classnames' +import { Fragment } from 'react' +import { unparse as unparseCSV } from 'papaparse' +import { saveAs } from 'file-saver' +import { Button, IconButton } from '@globalfishingwatch/ui-components' +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 { TrackCategory, trackEvent } from 'features/app/analytics.hooks' +import { selectVesselGroupReportVesselFilter } from '../vessel-group.config.selectors' +import styles from './VesselGroupReportVesselsTableFooter.module.css' +import { + selectVesselGroupReportVesselsFiltered, + selectVesselGroupReportVesselsPagination, +} from './vessel-group-report-vessels.selectors' + +type ReportVesselsTableFooterProps = { + reportName: string +} + +export default function VesselGroupReportVesselsTableFooter({ + reportName, +}: ReportVesselsTableFooterProps) { + const { t } = useTranslation() + const { dispatchQueryParams } = useLocationConnect() + const allVessels = useSelector(selectVesselGroupReportVesselsFiltered) + const reportVesselFilter = useSelector(selectVesselGroupReportVesselFilter) + const pagination = useSelector(selectVesselGroupReportVesselsPagination) + const { start, end } = useSelector(selectTimeRange) + + if (!allVessels?.length) return null + + const onDownloadVesselsClick = () => { + const vessels = allVessels?.map((vessel) => { + const { flagTranslated, flagTranslatedClean, ...rest } = vessel + return rest + }) + if (vessels?.length) { + // trackEvent({ + // category: TrackCategory.Analysis, + // action: `Click 'Download CSV'`, + // label: `region name: ${reportAreaName} | timerange: ${start} - ${end} | filters: ${reportVesselFilter}`, + // }) + const csv = unparseCSV(vessels) + const blob = new Blob([csv], { type: 'text/plain;charset=utf-8' }) + saveAs(blob, `${reportName}-${start}-${end}.csv`) + } + } + + const onPrevPageClick = () => { + dispatchQueryParams({ vesselGroupReportVesselPage: pagination.page - 1 }) + } + const onNextPageClick = () => { + dispatchQueryParams({ vesselGroupReportVesselPage: pagination.page + 1 }) + } + const onShowMoreClick = () => { + dispatchQueryParams({ + vesselGroupReportResultsPerPage: REPORT_SHOW_MORE_VESSELS_PER_PAGE, + vesselGroupReportVesselPage: 0, + }) + trackEvent({ + category: TrackCategory.Analysis, + action: `Click on show more vessels`, + }) + } + const onShowLessClick = () => { + dispatchQueryParams({ + vesselGroupReportResultsPerPage: REPORT_VESSELS_PER_PAGE, + reportVesselPage: 0, + }) + trackEvent({ + category: TrackCategory.Analysis, + action: `Click on show less vessels`, + }) + } + // const onAddToVesselGroup = () => { + // const dataviewIds = heatmapDataviews.map(({ id }) => id) + // dispatch(setVesselGroupConfirmationMode('saveAndNavigate')) + // if (dataviewIds?.length) { + // dispatch(setVesselGroupCurrentDataviewIds(dataviewIds)) + // } + // trackEvent({ + // category: TrackCategory.VesselGroups, + // action: 'add_to_vessel_group', + // label: 'report', + // }) + // } + + const isShowingMore = pagination.resultsPerPage === REPORT_SHOW_MORE_VESSELS_PER_PAGE + const hasLessVesselsThanAPage = + pagination.page === 0 && pagination?.resultsNumber < pagination?.resultsPerPage + const isLastPaginationPage = + pagination?.offset + pagination?.resultsPerPage >= pagination?.totalFiltered + + return ( +
    +
    + +
    + + + {`${pagination?.offset + 1} - ${ + isLastPaginationPage + ? pagination?.totalFiltered + : pagination?.offset + pagination?.resultsPerPage + }`}{' '} + + +
    + + + {reportVesselFilter && ( + + {t('common.of', 'of')}{' '} + + )} + {' '} + {t('common.vessel', { count: pagination?.total })} + +
    +
    +
    + {/* */} + +
    +
    + ) +} 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 a00cb0bf92..7392a689d9 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 @@ -1,41 +1,164 @@ import { createSelector } from '@reduxjs/toolkit' import { groupBy } from 'es-toolkit' import { IdentityVessel } from '@globalfishingwatch/api-types' -import { selectVesselGroupReportVesselsSubsection } from 'features/vessel-group-report/vessel.config.selectors' import { OTHER_CATEGORY_LABEL } from 'features/vessel-group-report/vessel-group-report.config' -import { getVesselProperty } from 'features/vessel/vessel.utils' +import { getSearchIdentityResolved } from 'features/vessel/vessel.utils' +import { + selectVesselGroupReportResultsPerPage, + selectVesselGroupReportVesselFilter, + selectVesselGroupReportVesselPage, +} from 'features/vessel-group-report/vessel-group.config.selectors' +import { formatInfoField, getVesselGearTypeLabel, getVesselShipTypeLabel } from 'utils/info' +import { cleanFlagState } from 'features/area-report/reports.selectors' +import { t } from 'features/i18n/i18n' +import { + FILTER_PROPERTIES, + FilterProperty, + getVesselsFiltered, +} from 'features/area-report/reports.utils' +import { + selectVesselGroupReportVesselsOrderDirection, + selectVesselGroupReportVesselsOrderProperty, + selectVesselGroupReportVesselsSubsection, +} from '../vessel-group.config.selectors' import { selectVesselGroupReportVessels } from '../vessel-group-report.slice' +import { VesselGroupReportVesselParsed } from './vessel-group-report-vessels.types' + +const getVesselSource = (vessel: IdentityVessel) => { + let source = '' + if (vessel.registryInfo?.length && vessel.selfReportedInfo?.length) { + source = 'both' + } else if (vessel.registryInfo?.length) { + source = 'registry' + } else if (vessel.selfReportedInfo?.length) { + source = vessel.selfReportedInfo[0].sourceCode.join(', ') + } + 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[] + } +) + +type ReportFilterProperty = FilterProperty | 'source' +const REPORT_FILTER_PROPERTIES: Record = { + ...FILTER_PROPERTIES, + source: ['source'], +} + +export const selectVesselGroupReportVesselsFiltered = createSelector( + [selectVesselGroupReportVesselsParsed, selectVesselGroupReportVesselFilter], + (vessels, filter) => { + if (!vessels?.length) return null + return getVesselsFiltered( + vessels, + filter, + REPORT_FILTER_PROPERTIES + ) + } +) + +export const selectVesselGroupReportVesselsOrdered = createSelector( + [ + selectVesselGroupReportVesselsFiltered, + selectVesselGroupReportVesselsOrderProperty, + selectVesselGroupReportVesselsOrderDirection, + ], + (vessels, property, direction) => { + if (!vessels?.length) return [] + return vessels.toSorted((a, b) => { + let aValue = '' + let bValue = '' + if (property === 'flag') { + aValue = a.flagTranslated + bValue = b.flagTranslated + } else if (property === 'shiptype') { + aValue = a.vesselType + bValue = b.vesselType + } else { + aValue = a.shipName + bValue = b.shipName + } + if (aValue === bValue) { + return 0 + } + if (direction === 'asc') { + return aValue > bValue ? 1 : -1 + } + return aValue > bValue ? -1 : 1 + }) + } +) + +export const selectVesselGroupReportVesselsPaginated = createSelector( + [ + selectVesselGroupReportVesselsOrdered, + selectVesselGroupReportVesselPage, + selectVesselGroupReportResultsPerPage, + ], + (vessels, page, resultsPerPage) => { + if (!vessels?.length) return [] + return vessels.slice(resultsPerPage * page, resultsPerPage * (page + 1)) + } +) + +export const selectVesselGroupReportVesselsPagination = createSelector( + [ + selectVesselGroupReportVesselsPaginated, + selectVesselGroupReportVessels, + selectVesselGroupReportVesselsFiltered, + selectVesselGroupReportVesselPage, + selectVesselGroupReportResultsPerPage, + ], + (vessels, allVessels, allVesselsFiltered, page = 0, resultsPerPage) => { + return { + page, + offset: resultsPerPage * page, + resultsPerPage: + typeof resultsPerPage === 'number' ? resultsPerPage : parseInt(resultsPerPage), + resultsNumber: vessels!?.length, + totalFiltered: allVesselsFiltered!?.length, + total: allVessels!?.length, + } + } +) export const selectVesselGroupReportVesselsGraphDataGrouped = createSelector( - [selectVesselGroupReportVessels, selectVesselGroupReportVesselsSubsection], + [selectVesselGroupReportVesselsFiltered, selectVesselGroupReportVesselsSubsection], (vessels, subsection) => { if (!vessels) return [] let vesselsGrouped = {} switch (subsection) { case 'flag': - vesselsGrouped = groupBy(vessels, (vessel) => getVesselProperty(vessel, 'flag')) + vesselsGrouped = groupBy(vessels, (vessel) => vessel.flag) break case 'shiptypes': - vesselsGrouped = groupBy(vessels, (vessel) => getVesselProperty(vessel, 'shiptypes')?.[0]) + vesselsGrouped = groupBy(vessels, (vessel) => vessel.vesselType.split(', ')[0]) break case 'geartypes': - vesselsGrouped = groupBy(vessels, (vessel) => getVesselProperty(vessel, 'geartypes')?.[0]) + vesselsGrouped = groupBy(vessels, (vessel) => vessel.gearType.split(', ')[0]) break case 'source': - vesselsGrouped = vessels.reduce( - (acc, vessel) => { - if (vessel.registryInfo?.length && vessel.selfReportedInfo?.length) { - acc.both.push(vessel) - } else if (vessel.registryInfo?.length) { - acc.registryOnly.push(vessel) - } else if (vessel.selfReportedInfo?.length) { - acc.selfReportedOnly.push(vessel) - } - return acc - }, - { registryOnly: [], selfReportedOnly: [], both: [] } as Record - ) - break + vesselsGrouped = groupBy(vessels, (vessel) => vessel.source) } const orderedGroups = Object.entries(vesselsGrouped) .map(([key, value]) => ({ diff --git a/apps/fishing-map/features/vessel-group-report/vessels/vessel-group-report-vessels.types.ts b/apps/fishing-map/features/vessel-group-report/vessels/vessel-group-report-vessels.types.ts new file mode 100644 index 0000000000..167908fea0 --- /dev/null +++ b/apps/fishing-map/features/vessel-group-report/vessels/vessel-group-report-vessels.types.ts @@ -0,0 +1,13 @@ +import { VesselLastIdentity } from 'features/search/search.slice' + +export type VesselGroupReportVesselParsed = Omit & { + index: number + shipName: string + vesselType: string + gearType: string + flagTranslated: string + flagTranslatedClean: string + mmsi: string + dataset: string + source: string +} diff --git a/apps/fishing-map/features/vessel-groups/VesselGroupModalVessels.tsx b/apps/fishing-map/features/vessel-groups/VesselGroupModalVessels.tsx index 73c3d7d894..344beab513 100644 --- a/apps/fishing-map/features/vessel-groups/VesselGroupModalVessels.tsx +++ b/apps/fishing-map/features/vessel-groups/VesselGroupModalVessels.tsx @@ -5,7 +5,7 @@ import { groupBy } from 'es-toolkit' import { ActionCreatorWithPayload } from '@reduxjs/toolkit' import { IconButton, Tooltip, TransmissionsTimeline } from '@globalfishingwatch/ui-components' import { IdentityVessel, Locale, VesselRegistryInfo } from '@globalfishingwatch/api-types' -import { EMPTY_FIELD_PLACEHOLDER, formatInfoField, getVesselGearType } from 'utils/info' +import { EMPTY_FIELD_PLACEHOLDER, formatInfoField, getVesselGearTypeLabel } from 'utils/info' import { FIRST_YEAR_OF_DATA } from 'data/config' import I18nDate from 'features/i18n/i18nDate' import { useAppDispatch } from 'features/app/app.hooks' @@ -33,7 +33,7 @@ function VesselGroupVesselRow({ const { shipname, flag, ssvid, transmissionDateFrom, transmissionDateTo } = vessel || ({} as VesselRegistryInfo) const vesselName = formatInfoField(shipname, 'name') - const vesselGearType = getVesselGearType(vessel) + const vesselGearType = getVesselGearTypeLabel(vessel) return ( diff --git a/apps/fishing-map/features/vessel/identity/VesselIdentity.tsx b/apps/fishing-map/features/vessel/identity/VesselIdentity.tsx index 68761810fd..97b10afa34 100644 --- a/apps/fishing-map/features/vessel/identity/VesselIdentity.tsx +++ b/apps/fishing-map/features/vessel/identity/VesselIdentity.tsx @@ -18,8 +18,8 @@ import { selectVesselInfoData } from 'features/vessel/selectors/vessel.selectors import { EMPTY_FIELD_PLACEHOLDER, formatInfoField, - getVesselGearType, - getVesselShipType, + getVesselGearTypeLabel, + getVesselShipTypeLabel, } from 'utils/info' import { filterRegistryInfoByDateAndSSVID, @@ -84,8 +84,8 @@ const VesselIdentity = () => { ...vesselIdentity, nShipname: formatInfoField(shipname, 'shipname') as string, flag: t(`flags:${flag}`, flag) as string, - shiptypes: getVesselShipType(vesselIdentity, { joinCharacter: ' -' }), // Can't be commas as it would break the csv format - geartypes: getVesselGearType(vesselIdentity, { joinCharacter: ' -' }), + shiptypes: getVesselShipTypeLabel(vesselIdentity, { joinCharacter: ' -' }), // Can't be commas as it would break the csv format + geartypes: getVesselGearTypeLabel(vesselIdentity, { joinCharacter: ' -' }), registryPublicAuthorizations: registryPublicAuthorizations && filterRegistryInfoByDateAndSSVID(registryPublicAuthorizations, timerange, ssvid), diff --git a/apps/fishing-map/features/vessel/vessel.config.ts b/apps/fishing-map/features/vessel/vessel.config.ts index c2db180feb..8871446ec5 100644 --- a/apps/fishing-map/features/vessel/vessel.config.ts +++ b/apps/fishing-map/features/vessel/vessel.config.ts @@ -2,7 +2,7 @@ import { RegionType, SelfReportedSource } from '@globalfishingwatch/api-types' import { VesselIdentitySourceEnum } from '@globalfishingwatch/api-types' import { I18nNamespaces } from 'features/i18n/i18n.types' import { IdentityVesselData } from 'features/vessel/vessel.slice' -import { VesselGroupReportState, VesselProfileState } from 'types' +import { VesselProfileState } from 'types' export const DEFAULT_VESSEL_IDENTITY_DATASET = 'public-global-vessel-identity' export const DEFAULT_VESSEL_IDENTITY_VERSION = 'v3.0' @@ -21,14 +21,6 @@ export const DEFAULT_VESSEL_STATE: VesselProfileState = { viewOnlyVessel: true, } -export const DEFAULT_VESSEL_GROUP_REPORT_STATE: VesselGroupReportState = { - viewOnlyVesselGroup: true, - vesselGroupReportSection: 'vessels', - vesselGroupReportVesselsSubsection: 'flag', - vesselGroupReportActivitySubsection: 'fishing-effort', - vesselGroupReportEventsSubsection: 'encounters', -} - export type VesselRenderField = { key: Key label?: string diff --git a/apps/fishing-map/features/workspace/vessels/VesselLayerPanel.tsx b/apps/fishing-map/features/workspace/vessels/VesselLayerPanel.tsx index a88360f99f..72bd596d56 100644 --- a/apps/fishing-map/features/workspace/vessels/VesselLayerPanel.tsx +++ b/apps/fishing-map/features/workspace/vessels/VesselLayerPanel.tsx @@ -18,7 +18,7 @@ import { } from '@globalfishingwatch/dataviews-client' import { useGetDeckLayer } from '@globalfishingwatch/deck-layer-composer' import { VesselLayer } from '@globalfishingwatch/deck-layers' -import { formatInfoField, getVesselLabel, getVesselOtherNamesLabel } from 'utils/info' +import { formatInfoField, getVesselShipNameLabel, getVesselOtherNamesLabel } from 'utils/info' import styles from 'features/workspace/shared/LayerPanel.module.css' import { useDataviewInstancesConnect } from 'features/workspace/workspace.hook' import { selectResourceByUrl } from 'features/resources/resources.slice' @@ -159,7 +159,7 @@ function VesselLayerPanel({ dataview, showApplyToAll }: VesselLayerPanelProps): const trackLoading = trackLayerVisible && !trackLoaded && !trackError const vesselData = infoResource?.data - const vesselLabel = vesselData ? getVesselLabel(vesselData) : '' + const vesselLabel = vesselData ? getVesselShipNameLabel(vesselData) : '' const otherVesselsLabel = vesselData ? getVesselOtherNamesLabel(getOtherVesselNames(vesselData as IdentityVessel)) : '' diff --git a/apps/fishing-map/features/workspace/vessels/VesselsSection.tsx b/apps/fishing-map/features/workspace/vessels/VesselsSection.tsx index d4f0129f99..01257ced96 100644 --- a/apps/fishing-map/features/workspace/vessels/VesselsSection.tsx +++ b/apps/fishing-map/features/workspace/vessels/VesselsSection.tsx @@ -19,7 +19,7 @@ import { hasTracksWithNoData, useTimebarVesselTracksData, } from 'features/timebar/timebar-vessel.hooks' -import { getVesselLabel } from 'utils/info' +import { getVesselShipNameLabel } from 'utils/info' import { selectResources, ResourcesState } from 'features/resources/resources.slice' import { VESSEL_DATAVIEW_INSTANCE_PREFIX } from 'features/dataviews/dataviews.utils' import { selectReadOnly } from 'features/app/selectors/app.selectors' @@ -78,8 +78,8 @@ function VesselsSection(): React.ReactElement { .sort((a, b) => { const aResource = getVesselResourceByDataviewId(resources, a.id) const bResource = getVesselResourceByDataviewId(resources, b.id) - const aVesselLabel = aResource ? getVesselLabel(aResource.data) : '' - const bVesselLabel = bResource ? getVesselLabel(bResource.data) : '' + const aVesselLabel = aResource ? getVesselShipNameLabel(aResource.data) : '' + const bVesselLabel = bResource ? getVesselShipNameLabel(bResource.data) : '' if (!aVesselLabel || !bVesselLabel) return 0 if (sortOrder.current === 'ASC') { return aVesselLabel < bVesselLabel ? -1 : 1 diff --git a/apps/fishing-map/types/index.ts b/apps/fishing-map/types/index.ts index 3b26422e8c..00fcf31914 100644 --- a/apps/fishing-map/types/index.ts +++ b/apps/fishing-map/types/index.ts @@ -160,6 +160,8 @@ export type VesselGroupReportSection = 'vessels' | 'insights' | 'activity' | 'ev export type VesselGroupReportVesselsSubsection = 'flag' | 'shiptypes' | 'geartypes' | 'source' export type VesselGroupReportActivitySubsection = 'fishing-effort' | 'presence' export type VesselGroupReportEventsSubsection = 'fishing' | 'encounters' | 'port' | 'loitering' +export type VesselGroupReportVesselsOrderProperty = 'shipname' | 'flag' | 'shiptype' +export type VesselGroupReportVesselsOrderDirection = 'asc' | 'desc' export type VesselGroupReportState = { viewOnlyVesselGroup: boolean @@ -167,6 +169,11 @@ export type VesselGroupReportState = { vesselGroupReportVesselsSubsection?: VesselGroupReportVesselsSubsection vesselGroupReportActivitySubsection?: VesselGroupReportActivitySubsection vesselGroupReportEventsSubsection?: VesselGroupReportEventsSubsection + vesselGroupReportVesselPage?: number + vesselGroupReportResultsPerPage?: number + vesselGroupReportVesselFilter?: string + vesselGroupReportVesselsOrderProperty?: VesselGroupReportVesselsOrderProperty + vesselGroupReportVesselsOrderDirection?: VesselGroupReportVesselsOrderDirection } export type VesselGroupReportStateProperty = keyof VesselGroupReportState diff --git a/apps/fishing-map/utils/info.ts b/apps/fishing-map/utils/info.ts index 97259d3ba6..13b99c9c58 100644 --- a/apps/fishing-map/utils/info.ts +++ b/apps/fishing-map/utils/info.ts @@ -33,10 +33,10 @@ export const formatInfoField = ( return translationFn(`flags:${fieldValue}` as any, fieldValue) } if (type === 'shiptypes' || type === 'vesselType') { - return getVesselShipType({ shiptypes: fieldValue }, { translationFn }) + return getVesselShipTypeLabel({ shiptypes: fieldValue }, { translationFn }) } if (type === 'geartypes') { - return getVesselGearType({ geartypes: fieldValue }, { translationFn }) + return getVesselGearTypeLabel({ geartypes: fieldValue }, { translationFn }) } if (type === 'name' || type === 'shipname' || type === 'owner' || type === 'port') { return fieldValue @@ -49,9 +49,9 @@ export const formatInfoField = ( } } else if (Array.isArray(fieldValue)) { if (type === 'geartypes') { - return getVesselGearType({ geartypes: fieldValue as GearType[] }, { translationFn }) + return getVesselGearTypeLabel({ geartypes: fieldValue as GearType[] }, { translationFn }) } else if (type === 'shiptypes') { - return getVesselShipType({ shiptypes: fieldValue as VesselType[] }, { translationFn }) + return getVesselShipTypeLabel({ shiptypes: fieldValue as VesselType[] }, { translationFn }) } } else if (fieldValue) { return formatI18nNumber(fieldValue) @@ -66,7 +66,7 @@ export const formatNumber = (num: string | number, maximumFractionDigits?: numbe }) } -export const getVesselShipType = ( +export const getVesselShipTypeLabel = ( { shiptypes: shiptype } = {} as Pick | { shiptypes: string }, { joinCharacter = ', ', translationFn = t } = {} as { joinCharacter?: string @@ -82,10 +82,11 @@ export const getVesselShipType = ( ?.map((shiptype) => translationFn(`vessel.vesselTypes.${shiptype?.toLowerCase()}`, upperFirst(shiptype)) ) + .toSorted((a, b) => a.localeCompare(b)) .join(joinCharacter) as VesselType } -export const getVesselGearType = ( +export const getVesselGearTypeLabel = ( { geartypes: geartype } = {} as Pick | { geartypes: string }, { joinCharacter = ', ', translationFn = t } = {} as { joinCharacter?: string @@ -102,17 +103,18 @@ export const getVesselGearType = ( return gearTypes .filter(Boolean) ?.map((gear) => translationFn(`vessel.gearTypes.${gear?.toLowerCase()}`, upperFirst(gear))) + .toSorted((a, b) => a.localeCompare(b)) .join(joinCharacter) as GearType } -export const getVesselLabel = ( +export const getVesselShipNameLabel = ( vessel: ExtendedFeatureVessel | IdentityVessel, withGearType = false ): string => { const vesselInfo = getLatestIdentityPrioritised(vessel) if (!vesselInfo) return t('common.unknownVessel', 'Unknown vessel') if (vesselInfo.shipname && vesselInfo.geartypes && vesselInfo.flag && withGearType) { - const gearTypes = getVesselGearType(vesselInfo) + const gearTypes = getVesselGearTypeLabel(vesselInfo) return `${formatInfoField(vesselInfo.shipname, 'name')} (${t(`flags:${vesselInfo.flag}`, vesselInfo.flag)}, ${gearTypes || EMPTY_FIELD_PLACEHOLDER})` } @@ -121,7 +123,7 @@ export const getVesselLabel = ( } if (vesselInfo.geartypes) { return `${t('vessel.unkwownVesselByGeartype', { - gearType: getVesselGearType({ geartypes: vesselInfo.geartypes }), + gearType: getVesselGearTypeLabel({ geartypes: vesselInfo.geartypes }), })}` } return t('common.unknownVessel', 'Unknown vessel') diff --git a/libs/dataviews-client/src/url-workspace/url-workspace.ts b/libs/dataviews-client/src/url-workspace/url-workspace.ts index 09decedf9b..d40ea492f6 100644 --- a/libs/dataviews-client/src/url-workspace/url-workspace.ts +++ b/libs/dataviews-client/src/url-workspace/url-workspace.ts @@ -44,10 +44,64 @@ const PARAMS_TO_ABBREVIATED = { firstTransmissionDate: 'fTD', value: 'val', color: 'clr', + sidebarOpen: 'sbO', + timebarGraph: 'tG', + timebarSelectedEnvId: 'tSEI', + timebarVisualisation: 'tV', + visibleEvents: 'vE', + activityVisualizationMode: 'aVM', + detectionsVisualizationMode: 'dVM', + environmentVisualizationMode: 'eVM', + bivariateDataviews: 'bDV', + mapAnnotations: 'mA', + mapAnnotationsVisible: 'mAV', + mapRulers: 'mR', + mapRulersVisible: 'mRV', + //Vessel Profile + vesselDatasetId: 'vDi', + vesselRegistryId: 'vRi', + vesselSelfReportedId: 'vSRi', + vesselSection: 'vS', + vesselArea: 'vA', + vesselRelated: 'vR', + vesselIdentitySource: 'vIs', + vesselActivityMode: 'vAm', + viewOnlyVessel: 'vVO', + // 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', + // Area report + reportActivityGraph: 'rAG', + reportAreaBounds: 'rAB', + reportCategory: 'rC', + reportTimeComparison: 'rTC', + reportVesselFilter: 'rVF', + reportVesselGraph: 'rVG', + reportVesselPage: 'rVP', + reportBufferValue: 'rBV', + reportBufferUnit: 'rBU', + reportBufferOperation: 'rBO', + reportResultsPerPage: 'rRPP', } const ABBREVIATED_TO_PARAMS = invert(PARAMS_TO_ABBREVIATED) +const hasParamToBeAbbreviatedDuplicated = () => { + const paramsToBeAbbreviatedDuplicated = new Set(Object.values(PARAMS_TO_ABBREVIATED)) + return paramsToBeAbbreviatedDuplicated.size !== Object.keys(PARAMS_TO_ABBREVIATED).length +} +if (hasParamToBeAbbreviatedDuplicated()) { + throw new Error('Duplicated abbreviated params') +} + const TOKEN_PREFIX = '~' export const TOKEN_REGEX = /~(\d+)/