diff --git a/apps/fishing-map/features/datasets/datasets.mock.ts b/apps/fishing-map/features/datasets/datasets.mock.ts index 6f0a954748..2833aa7bcb 100644 --- a/apps/fishing-map/features/datasets/datasets.mock.ts +++ b/apps/fishing-map/features/datasets/datasets.mock.ts @@ -1,5 +1,211 @@ import { Dataset } from '@globalfishingwatch/api-types' -export const datasets: Dataset[] = [] +export const datasets: Dataset[] = [ + { + alias: ['public-global-all-tracks:latest'], + id: 'public-global-all-tracks:v20231026', + name: 'Tracks', + type: 'tracks:v1', + description: 'The dataset contains the tracks from all vessels (AIS) - Version 20231026', + startDate: '2012-01-01T00:00:00.000Z', + endDate: '2024-01-14T00:00:00.000Z', + unit: 'NA', + status: 'done', + importLogs: null, + category: 'vessel', + subcategory: 'track', + source: 'Global Fishing Watch - AIS', + ownerId: 0, + ownerType: 'super-user', + configuration: { + id: '', + max: 0, + min: 0, + ttl: 0, + band: '', + srid: null, + scale: 0, + bucket: 'api-tracks-us-central1', + fields: null, + folder: 'public-global-all-tracks:v20231026', + format: null, + images: null, + offset: 0, + source: 'gcs', + maxZoom: 12, + filePath: null, + function: null, + latitude: null, + numBytes: null, + gcsFolder: '', + intervals: [], + longitude: null, + numLayers: null, + timestamp: null, + translate: true, + idProperty: '', + indexBoost: null, + emailGroups: [], + geometryType: null, + insightSources: [], + configurationUI: null, + valueProperties: null, + propertyToInclude: null, + disableInteraction: false, + apiSupportedVersions: ['v2', 'v3'], + propertyToIncludeRange: null, + }, + relatedDatasets: [], + schema: { + lat: { + type: 'number', + }, + lon: { + type: 'number', + }, + flag: { + type: 'string', + }, + night: { + type: 'boolean', + }, + speed: { + enum: [0, 20], + type: 'range', + }, + course: { + type: 'number', + }, + seg_id: { + type: 'string', + }, + elevation: { + enum: [-2000, 0], + type: 'range', + }, + timestamp: { + type: 'string', + format: 'date-time', + }, + distance_from_port: { + min: 0, + type: 'number', + }, + distance_from_shore: { + min: 0, + type: 'number', + }, + }, + fieldsAllowed: ['lat', 'lon', 'timestamp', 'latlon', 'seg_id', 'speed', 'elevation'], + createdAt: '2023-10-17T12:34:21.417Z', + endpoints: [ + { + id: 'tracks', + description: 'Endpoint to retrieve vessel track', + downloadable: true, + method: 'GET', + pathTemplate: '/v3/vessels/{{vesselId}}/tracks', + params: [ + { + label: 'vessel id', + id: 'vesselId', + type: 'string', + }, + ], + query: [ + { + label: 'dataset', + id: 'dataset', + type: 'string', + required: true, + array: false, + }, + { + label: 'start-date', + id: 'start-date', + type: 'Date ISO', + required: false, + }, + { + label: 'end-date', + id: 'end-date', + type: 'Date ISO', + required: false, + }, + { + label: 'binary', + id: 'binary', + type: 'boolean', + default: true, + }, + { + id: 'fields', + type: 'enum', + label: 'fields', + array: true, + enum: ['LAT', 'LON', 'TIMESTAMP', 'SPEED', 'COURSE', 'ELEVATION'], + }, + { + label: 'format', + id: 'format', + type: 'enum', + enum: ['POINT', 'LINES', 'VALUEARRAY'], + default: 'LINES', + description: + 'Specific encoding format to use for the track. Possible values lines, points or valueArray. valueArray: is a custom compact format, an array with all the fields serialized. The format is further explained in this issue: valueArray format. lines: Geojson with a single LineString feature containing all the points in the track points: Geojson with a FeatureCollection containing a Point feature for every point in the track', + }, + { + id: 'distance-fishing', + type: 'number', + label: 'Distance fishing', + required: false, + }, + { + id: 'bearing-val-fishing', + type: 'number', + label: 'Bearing value fishing', + required: false, + }, + { + id: 'change-speed-fishing', + type: 'number', + label: 'Change speed fishing', + required: false, + }, + { + id: 'min-accuracy-fishing', + type: 'number', + label: 'Minimun accuracy fishing', + required: false, + }, + { + id: 'distance-transit', + type: 'number', + label: 'Distance transit', + required: false, + }, + { + id: 'bearing-val-transit', + type: 'number', + label: 'Bearing value transit', + required: false, + }, + { + id: 'change-speed-transit', + type: 'number', + label: 'Change speed transit', + required: false, + }, + { + id: 'min-accuracy-transit', + type: 'number', + label: 'Minimun accuracy transit', + required: false, + }, + ], + }, + ], + }, +] export default datasets diff --git a/apps/fishing-map/features/datasets/datasets.utils.ts b/apps/fishing-map/features/datasets/datasets.utils.ts index 39c7785927..fd6642f594 100644 --- a/apps/fishing-map/features/datasets/datasets.utils.ts +++ b/apps/fishing-map/features/datasets/datasets.utils.ts @@ -90,6 +90,7 @@ export type SupportedActivityDatasetSchema = export type SupportedEnvDatasetSchema = | 'type' | 'speed' + | 'elevation' | 'flag' | 'vessel_type' | 'Height' @@ -540,7 +541,7 @@ export const datasetHasSchemaFields = (dataset: Dataset, schema: SupportedDatase if (!schemaConfig) { return false } - if (schemaConfig.type === 'array') { + if (schemaConfig.type === 'array' || schemaConfig.type === 'range') { const schemaEnum = schemaConfig?.enum || schemaConfig?.items?.enum return schemaEnum !== undefined && schemaEnum.length > 0 } @@ -640,6 +641,9 @@ export type SchemaFieldSelection = { export const VESSEL_GROUPS_MODAL_ID = 'vesselGroupsOpenModalId' export const getActiveDatasetsInDataview = (dataview: SchemaFieldDataview) => { + if (!dataview) { + return [] as Dataset[] + } if (dataview.category === DataviewCategory.User) { return dataview.datasets } @@ -819,13 +823,11 @@ export const getFiltersBySchema = ( const optionsSelected = getSchemaOptionsSelectedInDataview(dataview, schema, options) const unit = getSchemaFilterUnitInDataview(dataview, schema) const datasetsWithSchema = getSupportedSchemaFieldsDatasets(dataview, schema)!?.map((d) => d.id) - const activeDatasets = getActiveDatasetsInActivityDataviews([ - dataview as UrlDataviewInstance, - ]) + const activeDatasets = getActiveDatasetsInDataview(dataview)?.map((d) => d.id) const hasDatasetsWithSchema = compatibilityOperation === 'some' - ? activeDatasets.some((d) => datasetsWithSchema.includes(d)) - : activeDatasets.every((d) => datasetsWithSchema.includes(d)) + ? activeDatasets?.some((d) => datasetsWithSchema.includes(d)) + : activeDatasets?.every((d) => datasetsWithSchema.includes(d)) const incompatibleFilterSelection = getIncompatibleFilterSelection(dataview, schema)!?.length > 0 const disabled = !hasDatasetsWithSchema || incompatibleFilterSelection const datasetId = removeDatasetVersion(getActiveDatasetsInDataview(dataview)!?.[0]?.id) @@ -872,10 +874,16 @@ export const getSchemaFiltersInDataview = ( }) : fieldsAllowed const filtersAllowed = fielsAllowedOrdered.map((id) => { - return getFiltersBySchema(dataview, id, { vesselGroups }) + return getFiltersBySchema(dataview, id, { + vesselGroups, + compatibilityOperation: id === 'speed' || id === 'elevation' ? 'some' : 'every', + }) }) const filtersDisabled = fieldsDisabled.map((id) => { - return getFiltersBySchema(dataview, id, { vesselGroups }) + return getFiltersBySchema(dataview, id, { + vesselGroups, + compatibilityOperation: id === 'speed' || id === 'elevation' ? 'some' : 'every', + }) }) return { filtersAllowed, diff --git a/apps/fishing-map/features/dataviews/selectors/dataviews.instances.selectors.ts b/apps/fishing-map/features/dataviews/selectors/dataviews.instances.selectors.ts index fce08a97fd..6d46d3c3e9 100644 --- a/apps/fishing-map/features/dataviews/selectors/dataviews.instances.selectors.ts +++ b/apps/fishing-map/features/dataviews/selectors/dataviews.instances.selectors.ts @@ -7,6 +7,7 @@ import { DataviewInstance, } from '@globalfishingwatch/api-types' import { + extendDataviewDatasetConfig, GetDatasetConfigsCallbacks, getResources, mergeWorkspaceUrlDataviewInstances, @@ -23,10 +24,7 @@ import { getVesselDataviewInstanceDatasetConfig, VESSEL_DATAVIEW_INSTANCE_PREFIX, } from 'features/dataviews/dataviews.utils' -import { - selectTrackThinningConfig, - selectTrackChunksConfig, -} from 'features/resources/resources.selectors.thinning' +import { selectTrackThinningConfig } from 'features/resources/resources.selectors.thinning' import { infoDatasetConfigsCallback, trackDatasetConfigsCallback, @@ -131,9 +129,25 @@ export const selectDataviewInstancesMergedOrdered = createSelector( } ) +export const selectTimebarGraphSelector = selectWorkspaceStateProperty('timebarGraph') + export const selectAllDataviewInstancesResolved = createSelector( - [selectDataviewInstancesMergedOrdered, selectAllDataviews, selectAllDatasets, selectUserLogged], - (dataviewInstances, dataviews, datasets, loggedUser): UrlDataviewInstance[] | undefined => { + [ + selectDataviewInstancesMergedOrdered, + selectAllDataviews, + selectAllDatasets, + selectUserLogged, + selectTrackThinningConfig, + selectIsGuestUser, + ], + ( + dataviewInstances, + dataviews, + datasets, + loggedUser, + thinningConfig, + guestUser + ): UrlDataviewInstance[] | undefined => { if (!dataviews?.length || !datasets?.length || !dataviewInstances?.length) { return EMPTY_ARRAY } @@ -171,7 +185,16 @@ export const selectAllDataviewInstancesResolved = createSelector( dataviews, datasets ) - return dataviewInstancesResolved + const callbacks: GetDatasetConfigsCallbacks = { + track: trackDatasetConfigsCallback(thinningConfig), + // events: eventsDatasetConfigsCallback, + info: infoDatasetConfigsCallback(guestUser), + } + const dataviewInstancesResolvedExtended = extendDataviewDatasetConfig( + dataviewInstancesResolved, + callbacks + ) + return dataviewInstancesResolvedExtended } ) @@ -188,26 +211,14 @@ export const selectMarineManagerDataviewInstanceResolved = createSelector( } ) -export const selectTimebarGraphSelector = selectWorkspaceStateProperty('timebarGraph') /** * Calls getResources to prepare track dataviews' datasetConfigs. * Injects app-specific logic by using getResources's callback */ export const selectDataviewsResources = createSelector( - [ - selectAllDataviewInstancesResolved, - selectTrackThinningConfig, - selectTrackChunksConfig, - selectTimebarGraphSelector, - selectIsGuestUser, - ], - (dataviewInstances, thinningConfig, chunks, timebarGraph, guestUser) => { - const callbacks: GetDatasetConfigsCallbacks = { - track: trackDatasetConfigsCallback(thinningConfig, timebarGraph), - // events: eventsDatasetConfigsCallback, - info: infoDatasetConfigsCallback(guestUser), - } - return getResources(dataviewInstances || [], callbacks) + [selectAllDataviewInstancesResolved], + (dataviewInstances) => { + return getResources(dataviewInstances || []) } ) diff --git a/apps/fishing-map/features/map/map-bounds.hooks.ts b/apps/fishing-map/features/map/map-bounds.hooks.ts index 54a297aa99..afb22caa29 100644 --- a/apps/fishing-map/features/map/map-bounds.hooks.ts +++ b/apps/fishing-map/features/map/map-bounds.hooks.ts @@ -57,7 +57,7 @@ export const getMapCoordinatesFromBounds = ( params: FitBoundsParams = {} ) => { const { mapWidth, mapHeight, padding = 60 } = params - const width = mapWidth || (map ? map.width : window.innerWidth / 2) + const width = mapWidth || (map?.width ? map.width : window.innerWidth / 2) const height = mapHeight || (map ? map.height : window.innerHeight - TIMEBAR_HEIGHT - FOOTER_HEIGHT) const { latitude, longitude, zoom } = fitBounds({ diff --git a/apps/fishing-map/features/map/map-interactions.hooks.ts b/apps/fishing-map/features/map/map-interactions.hooks.ts index 3d5b2036e8..bd79e36366 100644 --- a/apps/fishing-map/features/map/map-interactions.hooks.ts +++ b/apps/fishing-map/features/map/map-interactions.hooks.ts @@ -269,7 +269,6 @@ export const useMapMouseHover = () => { const onMouseMove: DeckProps['onHover'] = useCallback( (info: PickingInfo, event: any) => { if (!info.coordinate) return - // TODO:deck handle when hovering a cluster point as we don't want to show anything else // const clusterFeature = event.features.find( // (f) => f.generatorType === DataviewType.TileCluster && parseInt(f.properties.count) > 1 diff --git a/apps/fishing-map/features/resources/resources.utils.ts b/apps/fishing-map/features/resources/resources.utils.ts index 0ca707c33f..bbc5733f8c 100644 --- a/apps/fishing-map/features/resources/resources.utils.ts +++ b/apps/fishing-map/features/resources/resources.utils.ts @@ -3,6 +3,7 @@ import { DataviewDatasetConfigParam, EndpointId, ThinningConfig, + TrackField, } from '@globalfishingwatch/api-types' import { GetDatasetConfigCallback, UrlDataviewInstance } from '@globalfishingwatch/dataviews-client' import { hasDatasetConfigVesselData } from 'features/datasets/datasets.utils' @@ -50,10 +51,7 @@ export const eventsDatasetConfigsCallback: GetDatasetConfigCallback = (events) = return allEvents.filter(Boolean) } -export const trackDatasetConfigsCallback = ( - thinningConfig: ThinningConfigParam | null, - timebarGraph: any -) => { +export const trackDatasetConfigsCallback = (thinningConfig: ThinningConfigParam | null) => { return ([track]: DataviewDatasetConfig[], dataview?: UrlDataviewInstance) => { if (track?.endpoint === EndpointId.Tracks) { const thinningQuery = Object.entries(thinningConfig?.config || []).map(([id, value]) => ({ @@ -61,49 +59,10 @@ export const trackDatasetConfigsCallback = ( value, })) - let trackGraph - if (timebarGraph !== TimebarGraphs.None) { - trackGraph = { ...track } - const fieldsQuery = { - id: 'fields', - // The api now requieres all params in upperCase - value: ['TIMESTAMP', timebarGraph.toUpperCase()], - } - const graphQuery = [...(track.query || []), ...thinningQuery] - const fieldsQueryIndex = graphQuery.findIndex((q) => q.id === 'fields') - if (fieldsQueryIndex > -1) { - graphQuery[fieldsQueryIndex] = fieldsQuery - trackGraph.query = graphQuery - } else { - trackGraph.query = [...graphQuery, fieldsQuery] - } - } + let trackQuery = [...(track.query?.map((query) => ({ ...query })) || []), ...thinningQuery] + const trackWithThinning = { ...track, query: trackQuery } - const trackWithThinning = { - ...track, - query: [...(track.query || []), ...thinningQuery], - } - - // const allEvents = events.map((event) => ({ - // ...event, - // query: [ - // ...(Object.entries(DEFAULT_PAGINATION_PARAMS).map(([id, value]) => ({ - // id, - // value, - // })) as DataviewDatasetConfigParam[]), - // ...(event?.query || []), - // ], - // })) - // Clean resources when mandatory vesselId is missing - // needed for vessels with no info datasets (zebraX) - // const vesselData = hasDatasetConfigVesselData(info) - - return [ - trackWithThinning, - // ...allEvents, - // ...(vesselData ? [info] : []), - // ...(trackGraph ? [trackGraph] : []), - ] + return [trackWithThinning] } return [track].filter(Boolean) } diff --git a/apps/fishing-map/features/search/basic/SearchBasicResult.tsx b/apps/fishing-map/features/search/basic/SearchBasicResult.tsx index e4ae8b1964..429653ce88 100644 --- a/apps/fishing-map/features/search/basic/SearchBasicResult.tsx +++ b/apps/fishing-map/features/search/basic/SearchBasicResult.tsx @@ -161,7 +161,15 @@ function SearchBasicResult({ setTimerange({ start: transmissionDateFrom, end: transmissionDateTo }) } }, - [dispatch, isSearchLocation, setTimerange, trackBbox, transmissionDateFrom, transmissionDateTo] + [ + dispatch, + fitBounds, + isSearchLocation, + setTimerange, + trackBbox, + transmissionDateFrom, + transmissionDateTo, + ] ) const onYearHover = useCallback( diff --git a/apps/fishing-map/features/timebar/Timebar.tsx b/apps/fishing-map/features/timebar/Timebar.tsx index a0d71f0e74..c3acef6993 100644 --- a/apps/fishing-map/features/timebar/Timebar.tsx +++ b/apps/fishing-map/features/timebar/Timebar.tsx @@ -171,7 +171,7 @@ const TimebarWrapper = () => { const dispatch = useAppDispatch() // const [isPending, startTransition] = useTransition() const tracks = useTimebarVesselTracks() - const tracksEvents = useTimebarVesselEvents() + const events = useTimebarVesselEvents() const [bookmark, setBookmark] = useState<{ start: string; end: string } | null>(null) const onBookmarkChange = useCallback( @@ -346,10 +346,10 @@ const TimebarWrapper = () => { {showGraph && tracksGraphsData && ( )} - {tracksEvents && ( + {events && ( { ) const activeTrackDataviews = useSelector(selectActiveTrackDataviews) const isStandaloneVesselLocation = useSelector(selectIsVesselLocation) - const hasTracksData = useSelector(selectHasTracksData) + const vesselIds = activeTrackDataviews.map((v) => v.id) + const vesselLayers = useGetDeckLayers(vesselIds) + // TODO:deck better validation of the layer contains data + const hasTracksData = vesselLayers?.length > 0 const activeVesselsDataviews = useSelector(selectActiveVesselsDataviews) const { timebarVisualisation, dispatchTimebarVisualisation } = useTimebarVisualisationConnect() const { timebarSelectedEnvId, dispatchTimebarSelectedEnvId } = useTimebarEnvironmentConnect() @@ -129,8 +133,8 @@ const TimebarSettings = ({ loading = false }: { loading: boolean }) => { loading ? t('vessel.loadingInfo') : optionsPanelOpen - ? t('timebarSettings.settings_close', 'Close timebar settings') - : t('timebarSettings.settings_open', 'Open timebar settings') + ? t('timebarSettings.settings_close', 'Close timebar settings') + : t('timebarSettings.settings_open', 'Open timebar settings') } /> {optionsPanelOpen && ( @@ -209,11 +213,11 @@ const TimebarSettings = ({ loading = false }: { loading: boolean }) => { !activeTrackDataviews?.length ? t('timebarSettings.tracksDisabled', 'Select at least one vessel') : !timebarGraphEnabled - ? t( - 'timebarSettings.graphDisabled', - 'Not available with more than 2 vessels selected' - ) - : t('timebarSettings.showGraphSpeed', 'Show track speed graph') + ? t( + 'timebarSettings.graphDisabled', + 'Not available with more than 2 vessels selected' + ) + : t('timebarSettings.showGraphSpeed', 'Show track speed graph') } onClick={setVesselGraphSpeed} /> @@ -236,11 +240,11 @@ const TimebarSettings = ({ loading = false }: { loading: boolean }) => { !activeTrackDataviews?.length ? t('timebarSettings.tracksDisabled', 'Select at least one vessel') : !timebarGraphEnabled - ? t( - 'timebarSettings.graphDisabled', - 'Not available with more than 2 vessels selected' - ) - : t('timebarSettings.showGraphDepth', 'Show track depth graph') + ? t( + 'timebarSettings.graphDisabled', + 'Not available with more than 2 vessels selected' + ) + : t('timebarSettings.showGraphDepth', 'Show track depth graph') } onClick={setVesselGraphDepth} /> diff --git a/apps/fishing-map/features/timebar/timebar-vessel.hooks.ts b/apps/fishing-map/features/timebar/timebar-vessel.hooks.ts index 441141f52e..60b06ea64d 100644 --- a/apps/fishing-map/features/timebar/timebar-vessel.hooks.ts +++ b/apps/fishing-map/features/timebar/timebar-vessel.hooks.ts @@ -8,16 +8,18 @@ import { } from '@globalfishingwatch/timebar' import { useGetDeckLayers } from '@globalfishingwatch/deck-layer-composer' import { VesselLayer } from '@globalfishingwatch/deck-layers' -import { selectVesselsDataviews } from 'features/dataviews/selectors/dataviews.instances.selectors' +import { selectActiveVesselsDataviews } from 'features/dataviews/selectors/dataviews.instances.selectors' import { getEventDescription } from 'utils/events' import { t } from 'features/i18n/i18n' +import { selectTimebarGraph } from 'features/app/selectors/app.timebar.selectors' +import { selectWorkspaceVisibleEventsArray } from 'features/workspace/workspace.selectors' const getUserTrackHighlighterLabel = ({ chunk }: HighlighterCallbackFnArgs) => { return chunk.props?.id || null } export const useTimebarVesselsLayers = () => { - const dataviews = useSelector(selectVesselsDataviews) + const dataviews = useSelector(selectActiveVesselsDataviews) const ids = useMemo(() => { return dataviews.map((d) => d.id) }, [dataviews]) @@ -26,20 +28,44 @@ export const useTimebarVesselsLayers = () => { } export const useTimebarVesselTracks = () => { + const timebarGraph = useSelector(selectTimebarGraph) const [tracks, setVesselTracks] = useState | null>(null) const vessels = useTimebarVesselsLayers() - const vesselsLoaded = vessels - .flatMap((v) => (v.instance.getVesselTracksLayersLoaded() ? v.id : [])) - .join(',') - // const tracksMemoHash = getVesselTimebarTrackMemoHash(vessels) + + const tracksLoaded = useMemo( + () => vessels.flatMap((v) => (v.instance.getVesselTracksLayersLoaded() ? v.id : [])).join(','), + [vessels] + ) + const tracksColor = useMemo( + () => vessels.flatMap((v) => v.instance.props.color.join('-')).join(','), + [vessels] + ) + + useEffect(() => { + if (!vessels?.length) { + return + } + setVesselTracks((tracks) => { + if (!tracks?.length) { + return tracks + } + return tracks.map((track, index) => { + if (!vessels[index]) { + return track + } + return { + ...track, + color: vessels[index]?.instance?.getVesselColor(), + } + }) + }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tracksColor]) useEffect(() => { requestAnimationFrame(() => { if (vessels?.length) { - const vesselTracks = vessels.flatMap(({ instance, loaded }) => { - if (!loaded || !instance.props.visible) { - return [] - } + const vesselTracks = vessels.flatMap(({ instance }) => { const segments = instance.getVesselTrackSegments() const chunks = segments?.map((t) => { const start = t[0]?.timestamp @@ -64,35 +90,26 @@ export const useTimebarVesselTracks = () => { } }) // eslint-disable-next-line react-hooks/exhaustive-deps - }, [vesselsLoaded]) - return tracks -} + }, [tracksLoaded, timebarGraph, tracksColor]) -const getTrackEventHighlighterLabel = ({ chunk, expanded }: HighlighterCallbackFnArgs): string => { - const { description, descriptionGeneric } = getEventDescription(chunk as any) - if (chunk.cluster) { - return `${descriptionGeneric} (${chunk.cluster.numChunks} ${t('event.events', 'events')})` - } - if (expanded) { - return description as string - } - return descriptionGeneric as string + return tracks } export const useTimebarVesselEvents = () => { - const vessels = useTimebarVesselsLayers() - const vesselsLoaded = vessels - .flatMap((v) => (v.instance.getVesselEventsLayersLoaded() ? v.id : [])) - .join(',') + const timebarGraph = useSelector(selectTimebarGraph) + const visibleEvents = useSelector(selectWorkspaceVisibleEventsArray) const [events, setVesselEvents] = useState | null>(null) + const vessels = useTimebarVesselsLayers() + const eventsLoaded = useMemo( + () => vessels.flatMap((v) => (v.instance.getVesselEventsLayersLoaded() ? v.id : [])).join(','), + [vessels] + ) + useEffect(() => { requestAnimationFrame(() => { - if (vessels.length) { - const vesselEvents: TimebarChartData = vessels.flatMap(({ instance }) => { - if (!instance.props.visible) { - return [] - } - const chunks = instance.getVesselEventsData(instance.props.visibleEvents) as any + if (vessels?.length) { + const vesselEvents: TimebarChartData = vessels.map(({ instance }) => { + const chunks = instance.getVesselEventsData(visibleEvents) as any return { color: instance.getVesselColor(), chunks, @@ -106,8 +123,19 @@ export const useTimebarVesselEvents = () => { setVesselEvents(vesselEvents) } }) - // TODO tracksMemoHash below in Deck.gl fixes // eslint-disable-next-line react-hooks/exhaustive-deps - }, [vesselsLoaded]) + }, [eventsLoaded, timebarGraph, visibleEvents]) + return events } + +const getTrackEventHighlighterLabel = ({ chunk, expanded }: HighlighterCallbackFnArgs): string => { + const { description, descriptionGeneric } = getEventDescription(chunk as any) + if (chunk.cluster) { + return `${descriptionGeneric} (${chunk.cluster.numChunks} ${t('event.events', 'events')})` + } + if (expanded) { + return description as string + } + return descriptionGeneric as string +} diff --git a/apps/fishing-map/features/timebar/timebar.selectors.ts b/apps/fishing-map/features/timebar/timebar.selectors.ts index 6e45fba007..f8628f8dff 100644 --- a/apps/fishing-map/features/timebar/timebar.selectors.ts +++ b/apps/fishing-map/features/timebar/timebar.selectors.ts @@ -209,10 +209,6 @@ export const selectTracksData = createSelector( } ) -export const selectHasTracksData = createSelector([selectTracksData], (tracks = []) => { - return tracks.some(({ chunks }) => chunks.length > 0) -}) - export const selectHasTracksWithNoData = createSelector([selectTracksData], (tracks = []) => { return tracks.some( ({ chunks, status }) => status !== ResourceStatus.Loading && chunks.length === 0 diff --git a/apps/fishing-map/features/workspace/common/LayerSchemaFilter.tsx b/apps/fishing-map/features/workspace/common/LayerSchemaFilter.tsx index 5ac520822a..1d1b70e362 100644 --- a/apps/fishing-map/features/workspace/common/LayerSchemaFilter.tsx +++ b/apps/fishing-map/features/workspace/common/LayerSchemaFilter.tsx @@ -99,8 +99,8 @@ const getSliderConfigBySchema = (schemaFilter: SchemaFilter) => { max: 10000, } } - const min = getValueByUnit(schemaFilter.options?.[0]?.id, { unit: schemaFilter.unit }) || 0 - const max = getValueByUnit(schemaFilter.options?.[1]?.id, { unit: schemaFilter.unit }) || 1 + const min = getValueByUnit(schemaFilter.options?.[0]?.id, { unit: schemaFilter.unit }) ?? 0 + const max = getValueByUnit(schemaFilter.options?.[1]?.id, { unit: schemaFilter.unit }) ?? 1 return { steps: [min, max], min, diff --git a/apps/fishing-map/features/workspace/shared/LayerPanel.module.css b/apps/fishing-map/features/workspace/shared/LayerPanel.module.css index 9bb6337e37..c4abb9bace 100644 --- a/apps/fishing-map/features/workspace/shared/LayerPanel.module.css +++ b/apps/fishing-map/features/workspace/shared/LayerPanel.module.css @@ -40,6 +40,7 @@ box-shadow: none; } +.expandedContainerOpen .dragger, .LayerPanel:hover .dragger { width: var(--size-S); } diff --git a/apps/fishing-map/features/workspace/vessels/VesselLayerPanel.tsx b/apps/fishing-map/features/workspace/vessels/VesselLayerPanel.tsx index b09265b75f..28c39776d0 100644 --- a/apps/fishing-map/features/workspace/vessels/VesselLayerPanel.tsx +++ b/apps/fishing-map/features/workspace/vessels/VesselLayerPanel.tsx @@ -35,6 +35,8 @@ import { getOtherVesselNames } from 'features/vessel/vessel.utils' import { formatI18nDate } from 'features/i18n/i18nDate' import { t } from 'features/i18n/i18n' import { selectIsGFWUser } from 'features/user/selectors/user.selectors' +import ExpandedContainer from 'features/workspace/shared/ExpandedContainer' +import Filters from '../common/LayerFilters' import Color from '../common/Color' import LayerSwitch from '../common/LayerSwitch' import Remove from '../common/Remove' @@ -95,6 +97,7 @@ export const getVesselIdentityTooltipSummary = ( function VesselLayerPanel({ dataview }: VesselLayerPanelProps): React.ReactElement { const { t } = useTranslation() + const [filterOpen, setFiltersOpen] = useState(false) const { upsertDataviewInstance } = useDataviewInstancesConnect() const { url: infoUrl, dataset } = resolveDataviewDatasetResource(dataview, DatasetTypes.Vessels) const resources = useSelector(selectResources) @@ -127,6 +130,10 @@ function VesselLayerPanel({ dataview }: VesselLayerPanelProps): React.ReactEleme setColorOpen(!colorOpen) } + const onToggleFilterOpen = () => { + setFiltersOpen(!filterOpen) + } + // const onToggleInfoOpen = () => { // setInfoOpen(!infoOpen) // } @@ -228,38 +235,11 @@ function VesselLayerPanel({ dataview }: VesselLayerPanelProps): React.ReactEleme /> ) - const InfoIconComponent = infoLoading ? ( - - ) : ( - - - - ) - return (
{layerActive && !infoLoading && TrackIconComponent} + {layerActive && ( + } + > +
+ +
+
+ )} - {infoResource && InfoIconComponent} + {infoLoading && ( + + )} + {infoError && ( + + )}
dataviews?.filter((d) => d.config?.visible) +export const selectActiveVesselsDataviews = createSelector([selectVesselsDataviews], (dataviews) => + dataviews?.filter((d) => d.config?.visible) ) export const selectActiveTrackDataviews = createSelector([selectTrackDataviews], (dataviews) => { diff --git a/libs/api-types/src/dataviews.ts b/libs/api-types/src/dataviews.ts index 0745798669..7c76caddb5 100644 --- a/libs/api-types/src/dataviews.ts +++ b/libs/api-types/src/dataviews.ts @@ -59,6 +59,8 @@ export interface DataviewConfig { visualizationMode?: string /** Property used when a layer can use white as last step in its color ramp */ colorRampWhiteEnd?: boolean + /** Property used when a track layer can use white as the color for its vessel events */ + singleTrack?: boolean auxiliarLayerActive?: boolean debug?: boolean visible?: boolean diff --git a/libs/api-types/src/tracks.ts b/libs/api-types/src/tracks.ts index 8bfce0a33f..184fae4531 100644 --- a/libs/api-types/src/tracks.ts +++ b/libs/api-types/src/tracks.ts @@ -5,6 +5,7 @@ export enum TrackField { timestamp = 'timestamp', fishing = 'fishing', speed = 'speed', + depth = 'depth', course = 'course', night = 'night', distanceFromPort = 'distance_from_port', diff --git a/libs/data-transforms/src/segments/segments-to-geojson.ts b/libs/data-transforms/src/segments/segments-to-geojson.ts index e5350ace13..7e1ac8bd00 100644 --- a/libs/data-transforms/src/segments/segments-to-geojson.ts +++ b/libs/data-transforms/src/segments/segments-to-geojson.ts @@ -13,6 +13,8 @@ const segmentsToFeatures = (segment: TrackSegment | TrackSegment[]): Feature point.timestamp) + const speeds = segment.map((point) => point.speed) + const elevations = segment.map((point) => point.elevation) const coordinateProperties = segment?.reduce((acc, point) => { const properties = point.coordinateProperties || {} Object.keys(properties).forEach((key) => { @@ -38,6 +40,8 @@ const segmentsToFeatures = (segment: TrackSegment | TrackSegment[]): Feature !!time) ? times : undefined, + speed: speeds.some((speed) => !!speed) ? speeds : undefined, + elevation: elevations.some((elevation) => !!elevation) ? elevations : undefined, }, }, } @@ -104,7 +108,7 @@ export const geoJSONToSegments = ( coordinateProperties: Object.keys(coordinateProperties || {}).reduce( (acc, prop) => ({ ...acc, - [prop]: coordinateProperties[prop][i], + [prop]: coordinateProperties?.[prop]?.[i], }), {} ), diff --git a/libs/dataviews-client/src/resources/get-resources.ts b/libs/dataviews-client/src/resources/get-resources.ts index e72736215f..4ead618709 100644 --- a/libs/dataviews-client/src/resources/get-resources.ts +++ b/libs/dataviews-client/src/resources/get-resources.ts @@ -23,7 +23,105 @@ export type GetDatasetConfigsCallbacks = { info?: GetDatasetConfigCallback events?: GetDatasetConfigCallback } + +export const splitTrackDataviews = (dataviews: UrlDataviewInstance[]) => { + return dataviews.reduce( + (acc, dataview) => { + const isTrack = dataview.config?.type === DataviewType.Track + if (isTrack) { + acc.trackDataviews.push(dataview) + } else { + acc.otherDataviews.push(dataview) + } + return acc + }, + { + trackDataviews: [] as UrlDataviewInstance[], + otherDataviews: [] as UrlDataviewInstance[], + } + ) +} + +export const extendDataviewDatasetConfig = ( + dataviews: UrlDataviewInstance[], + callbacks: GetDatasetConfigsCallbacks +) => { + const { trackDataviews, otherDataviews } = splitTrackDataviews(dataviews) + // Create dataset configs needed to load all tracks related endpoints + const trackDataviewsWithDatasetConfigs = trackDataviews.map((dataview) => { + const info = getDatasetConfigByDatasetType(dataview, DatasetTypes.Vessels) + + const trackDatasetType = + dataview.datasets && dataview.datasets?.[0]?.type === DatasetTypes.UserTracks + ? DatasetTypes.UserTracks + : DatasetTypes.Tracks + + const trackDatasetConfig = { ...getDatasetConfigByDatasetType(dataview, trackDatasetType) } + const hasTrackData = + trackDatasetType === DatasetTypes.Tracks + ? trackDatasetConfig?.params?.find((p) => p.id === 'vesselId')?.value !== undefined + : trackDatasetConfig?.params?.find((p) => p.id === 'id')?.value !== undefined + // Cleaning track resources with no data as now now the track is hidden for guest users in VMS full- datasets + const track = hasTrackData ? trackDatasetConfig : ({} as DataviewDatasetConfig) + + const events = getDatasetConfigsByDatasetType(dataview, DatasetTypes.Events).filter( + (datasetConfig) => datasetConfig.query?.find((q) => q.id === 'vessels')?.value + ) // Loitering + + let preparedInfoDatasetConfigs = [info] + let preparedTrackDatasetConfigs = [track] + let preparedEventsDatasetConfigs = events + + if (callbacks.info && preparedInfoDatasetConfigs?.length > 0) { + preparedInfoDatasetConfigs = callbacks.info(preparedInfoDatasetConfigs, dataview) + } + if (callbacks.track && preparedTrackDatasetConfigs?.length > 0) { + preparedTrackDatasetConfigs = callbacks.track(preparedTrackDatasetConfigs, dataview) + } + if (callbacks.events && preparedEventsDatasetConfigs?.length > 0) { + preparedEventsDatasetConfigs = callbacks.events(preparedEventsDatasetConfigs, dataview) + } + + const preparedDataview = { + ...dataview, + datasetsConfig: [ + ...preparedInfoDatasetConfigs, + ...preparedTrackDatasetConfigs, + ...preparedEventsDatasetConfigs, + ].filter(Boolean), + } + return preparedDataview + }) + return [...trackDataviewsWithDatasetConfigs, ...otherDataviews] +} + export const getResources = ( + dataviews: UrlDataviewInstance[] +): { resources: Resource[]; dataviews: UrlDataviewInstance[] } => { + const { trackDataviews } = splitTrackDataviews(dataviews) + // resolve urls for vessels info (tracks and events are fetched within the Deck layers) + const trackResources = trackDataviews.flatMap((dataview) => { + if (!dataview.datasetsConfig) return [] + + return dataview.datasetsConfig.flatMap((datasetConfig) => { + if (datasetConfig.endpoint === EndpointId.Vessel) { + const dataset = dataview.datasets?.find((dataset) => dataset.id === datasetConfig.datasetId) + if (!dataset) return [] + const url = resolveEndpoint(dataset, datasetConfig) + if (!url) return [] + return [{ dataset, datasetConfig, url, dataviewId: dataview.dataviewId as string }] + } + return [] + }) + }) + + return { + dataviews, + resources: trackResources, + } +} + +export const _getLegacyResources = ( dataviews: UrlDataviewInstance[], callbacks: GetDatasetConfigsCallbacks ): { resources: Resource[]; dataviews: UrlDataviewInstance[] } => { diff --git a/libs/deck-layer-composer/src/resolvers/dataviews.ts b/libs/deck-layer-composer/src/resolvers/dataviews.ts index 0cf0a3bd44..6d19a91e46 100644 --- a/libs/deck-layer-composer/src/resolvers/dataviews.ts +++ b/libs/deck-layer-composer/src/resolvers/dataviews.ts @@ -305,13 +305,20 @@ export function getDataviewsResolved( d.config?.type === DataviewType.HeatmapStatic ? false : singleHeatmapDataview, }) || [] ) + const trackDataviewsParsed = trackDataviews.flatMap((d) => ({ + ...d, + config: { + ...d.config, + singleTrack: trackDataviews.length === 1, + }, + })) const dataviewsMerged = [ ...otherDataviews, ...staticDataviewsParsed, ...fourwingsDataviewsParsed, ...mergedDetectionsDataview, ...mergedActivityDataview, - ...trackDataviews, + ...trackDataviewsParsed, ] return dataviewsMerged } diff --git a/libs/deck-layer-composer/src/resolvers/index.ts b/libs/deck-layer-composer/src/resolvers/index.ts index 7ba82a21fa..917230cba2 100644 --- a/libs/deck-layer-composer/src/resolvers/index.ts +++ b/libs/deck-layer-composer/src/resolvers/index.ts @@ -64,7 +64,7 @@ export const dataviewToDeckLayer = ( return layer } if (dataview.config?.type === DataviewType.Track) { - const deckLayerProps = resolveDeckVesselLayerProps(dataview, globalConfig, interactions) + const deckLayerProps = resolveDeckVesselLayerProps(dataview, globalConfig) const layer = new VesselLayer(deckLayerProps) return layer } diff --git a/libs/deck-layer-composer/src/resolvers/vessels.ts b/libs/deck-layer-composer/src/resolvers/vessels.ts index caa0e08398..85f83351d7 100644 --- a/libs/deck-layer-composer/src/resolvers/vessels.ts +++ b/libs/deck-layer-composer/src/resolvers/vessels.ts @@ -9,8 +9,7 @@ import { DeckResolverFunction } from './types' export const resolveDeckVesselLayerProps: DeckResolverFunction = ( dataview, - globalConfig, - interactions + globalConfig ) => { const trackUrl = resolveDataviewDatasetResource(dataview, DatasetTypes.Tracks)?.url @@ -24,6 +23,7 @@ export const resolveDeckVesselLayerProps: DeckResolverFunction ...(trackUrl && { trackUrl: GFWAPI.generateUrl(trackUrl, { absolute: true }), }), + singleTrack: dataview.config?.singleTrack, color: hexToDeckColor(dataview.config?.color!), events: resolveDataviewDatasetResources(dataview, DatasetTypes.Events).map((resource) => { const eventType = resource.dataset?.subcategory as EventTypes @@ -32,18 +32,21 @@ export const resolveDeckVesselLayerProps: DeckResolverFunction url: `${API_GATEWAY}${resource.url}`, } }), - // hoveredFeatures: interactions, - // clickedFeatures, - // highlightEndTime, - // highlightStartTime, - // highlightEventIds, visibleEvents: globalConfig.visibleEvents, + // clickedFeatures, + ...(dataview.config?.filters?.['speed']?.length && { + minSpeedFilter: parseFloat(dataview.config?.filters?.['speed'][0]), + maxSpeedFilter: parseFloat(dataview.config?.filters?.['speed'][1]), + }), + ...(dataview.config?.filters?.['elevation']?.length && { + minElevationFilter: parseFloat(dataview.config?.filters?.['elevation'][0]), + maxElevationFilter: parseFloat(dataview.config?.filters?.['elevation'][1]), + }), ...(globalConfig.highlightedTime?.start && { highlightStartTime: getUTCDateTime(globalConfig.highlightedTime?.start).toMillis(), }), ...(globalConfig.highlightedTime?.end && { highlightEndTime: getUTCDateTime(globalConfig.highlightedTime?.end).toMillis(), }), - // eventsResource: eventsData?.length ? parseEvents(eventsData) : [], } } diff --git a/libs/deck-layers/src/layers/fourwings/FourwingsHeatmapLayer.ts b/libs/deck-layers/src/layers/fourwings/FourwingsHeatmapLayer.ts index 89913edb9a..073f0f6460 100644 --- a/libs/deck-layers/src/layers/fourwings/FourwingsHeatmapLayer.ts +++ b/libs/deck-layers/src/layers/fourwings/FourwingsHeatmapLayer.ts @@ -3,6 +3,7 @@ import { PathLayer, SolidPolygonLayer, TextLayer } from '@deck.gl/layers' import { GeoBoundingBox } from '@deck.gl/geo-layers' import { PathStyleExtension } from '@deck.gl/extensions' import { screen } from 'color-blend' +import { isEqual } from 'lodash' import { FourwingsFeature, getTimeRangeKey } from '@globalfishingwatch/deck-loaders' import { COLOR_HIGHLIGHT_LINE, @@ -113,7 +114,11 @@ export class FourwingsHeatmapLayer extends CompositeLayer { if (value && (!chosenValue || value > chosenValue)) { chosenValue = value diff --git a/libs/deck-layers/src/layers/vessel/VesselLayer.ts b/libs/deck-layers/src/layers/vessel/VesselLayer.ts index 7b66e417d9..787dc0e398 100644 --- a/libs/deck-layers/src/layers/vessel/VesselLayer.ts +++ b/libs/deck-layers/src/layers/vessel/VesselLayer.ts @@ -18,7 +18,12 @@ import { BaseLayerProps } from '../../types' import { VesselEventsLayer, _VesselEventsLayerProps } from './VesselEventsLayer' import { VesselTrackLayer, _VesselTrackLayerProps } from './VesselTrackLayer' import { getVesselResourceChunks } from './vessel.utils' -import { EVENTS_COLORS, EVENT_LAYER_TYPE, TRACK_LAYER_TYPE } from './vessel.config' +import { + EVENTS_COLORS, + EVENT_LAYER_TYPE, + DEFAULT_FISHING_EVENT_COLOR, + TRACK_LAYER_TYPE, +} from './vessel.config' import { VesselDataStatus, VesselDataType, @@ -38,6 +43,7 @@ export type VesselLayerProps = BaseLayerProps & VesselEventsLayerProps & _VesselLayerProps +let warnLogged = false export class VesselLayer extends CompositeLayer { dataStatus: VesselDataStatus[] = [] @@ -68,24 +74,48 @@ export class VesselLayer extends CompositeLayer { this.setState({ error }) } + _getTracksUrl({ start, end, trackUrl }: { start: string; end: string; trackUrl: string }) { + const trackUrlObject = new URL(trackUrl) + trackUrlObject.searchParams.append('start-date', start) + trackUrlObject.searchParams.append('end-date', end) + const format = trackUrlObject.searchParams.get('format') || 'DECKGL' + if (format !== 'DECKGL' && !warnLogged) { + console.warn(`only DECKGL format is supported, the current format (${format}) was replaced`) + warnLogged = true + } + trackUrlObject.searchParams.set('format', 'DECKGL') + return trackUrlObject.toString() + } + _getVesselTrackLayers() { - const { trackUrl, visible, startTime, endTime, color, highlightStartTime, highlightEndTime } = - this.props + const { + trackUrl, + visible, + startTime, + endTime, + color, + highlightStartTime, + highlightEndTime, + minSpeedFilter, + maxSpeedFilter, + minElevationFilter, + maxElevationFilter, + } = this.props if (!trackUrl || !visible) { if (!trackUrl) console.warn('trackUrl needed for vessel layer') return [] } const chunks = getVesselResourceChunks(startTime, endTime) - return chunks.map(({ start, end }) => { + return chunks.flatMap(({ start, end }) => { + if (!start || !end) { + return [] + } const chunkId = `${TRACK_LAYER_TYPE}-${start}-${end}` - const trackUrlObject = new URL(trackUrl as string) - trackUrlObject.searchParams.append('start-date', start as string) - trackUrlObject.searchParams.append('end-date', end as string) return new VesselTrackLayer( this.getSubLayerProps({ id: chunkId, visible, - data: trackUrlObject.toString(), + data: this._getTracksUrl({ start, end, trackUrl }), type: TRACK_LAYER_TYPE, loaders: [VesselTrackLoader], _pathType: 'open', @@ -100,6 +130,10 @@ export class VesselLayer extends CompositeLayer { endTime, highlightStartTime, highlightEndTime, + minSpeedFilter, + maxSpeedFilter, + minElevationFilter, + maxElevationFilter, getPolygonOffset: (params: any) => getLayerGroupOffset(LayerGroup.Track, params), onError: this.onSublayerError, }) @@ -117,6 +151,7 @@ export class VesselLayer extends CompositeLayer { events, highlightStartTime, highlightEndTime, + singleTrack, color, } = this.props if (!visible) { @@ -147,7 +182,10 @@ export class VesselLayer extends CompositeLayer { highlightEndTime, getPolygonOffset: (params: any) => getLayerGroupOffset(LayerGroup.Point, params), getFillColor: (d: any): Color => { - return d.type === EventTypes.Fishing ? color : EVENTS_COLORS[d.type] + if (d.type === EventTypes.Fishing) { + return singleTrack ? DEFAULT_FISHING_EVENT_COLOR : color + } + return EVENTS_COLORS[d.type] }, updateTriggers: { getFillColor: [color], diff --git a/libs/deck-layers/src/layers/vessel/VesselTrackLayer.ts b/libs/deck-layers/src/layers/vessel/VesselTrackLayer.ts index bacb71138e..bf41084634 100644 --- a/libs/deck-layers/src/layers/vessel/VesselTrackLayer.ts +++ b/libs/deck-layers/src/layers/vessel/VesselTrackLayer.ts @@ -28,6 +28,26 @@ export type _VesselTrackLayerProps = { * @default 0 */ highlightEndTime?: number + /** + * The low speed filter + * @default 0 + */ + minSpeedFilter?: number + /** + * The high speed filter + * @default 999999999999999 + */ + maxSpeedFilter?: number + /** + * The low speed filter + * @default -999999999999999 + */ + minElevationFilter?: number + /** + * The high speed filter + * @default 999999999999999 + */ + maxElevationFilter?: number // /** // * Color to be used as a highlight path // * @default [255, 255, 255, 255] @@ -36,7 +56,9 @@ export type _VesselTrackLayerProps = { /** * Timestamp accessor. */ - getTimestamps?: AccessorFunction + getTimestamp?: AccessorFunction + getSpeed?: AccessorFunction + getElevation?: AccessorFunction /** * Callback on data changed to update */ @@ -51,14 +73,21 @@ export type _VesselTrackLayerProps = { // not needed anymore as the highlighted color is fixed // const DEFAULT_HIGHLIGHT_COLOR_RGBA = [255, 255, 255, 255] as Color +const MAX_FILTER_VALUE = 999999999999999 const defaultProps: DefaultProps = { _pathType: 'open', endTime: { type: 'number', value: 0, min: 0 }, startTime: { type: 'number', value: 0, min: 0 }, highlightStartTime: { type: 'number', value: 0, min: 0 }, highlightEndTime: { type: 'number', value: 0, min: 0 }, + minSpeedFilter: { type: 'number', value: -MAX_FILTER_VALUE, min: 0 }, + maxSpeedFilter: { type: 'number', value: MAX_FILTER_VALUE, min: 0 }, + minElevationFilter: { type: 'number', value: -MAX_FILTER_VALUE, min: 0 }, + maxElevationFilter: { type: 'number', value: MAX_FILTER_VALUE, min: 0 }, getPath: { type: 'accessor', value: () => [0, 0] }, - getTimestamps: { type: 'accessor', value: (d) => d }, + getTimestamp: { type: 'accessor', value: (d) => d }, + getSpeed: { type: 'accessor', value: (d) => d }, + getElevation: { type: 'accessor', value: (d) => d }, onDataChange: { type: 'function', value: () => {} }, getColor: { type: 'accessor', value: () => [255, 255, 255, 100] }, // getHighlightColor: { type: 'accessor', value: DEFAULT_HIGHLIGHT_COLOR_RGBA }, @@ -85,12 +114,18 @@ export class VesselTrackLayer extends PathLayer< uniform float highlightEndTime; in float instanceTimestamps; + in float instanceSpeeds; + in float instanceElevations; out float vTime; + out float vSpeed; + out float vElevation; // out vec4 vHighlightColor; `, // Timestamp of the vertex 'vs:#main-end': ` vTime = instanceTimestamps; + vSpeed = instanceSpeeds; + vElevation = instanceElevations; if(vTime > highlightStartTime && vTime < highlightEndTime) { gl_Position.z = 1.0; } @@ -101,12 +136,25 @@ export class VesselTrackLayer extends PathLayer< uniform float endTime; uniform float highlightStartTime; uniform float highlightEndTime; + uniform float minSpeedFilter; + uniform float maxSpeedFilter; + uniform float minElevationFilter; + uniform float maxElevationFilter; // in vec4 vHighlightColor; in float vTime; + in float vSpeed; + in float vElevation; `, // Drop the segments outside of the time window 'fs:#main-start': ` - if(vTime < startTime || vTime > endTime) { + if( + vTime < startTime || + vTime > endTime || + vSpeed < minSpeedFilter || + vSpeed > maxSpeedFilter || + vElevation < minElevationFilter || + vElevation > maxElevationFilter + ) { discard; } `, @@ -127,12 +175,30 @@ export class VesselTrackLayer extends PathLayer< attributeManager.addInstanced({ timestamps: { size: 1, - accessor: 'getTimestamps', + accessor: 'getTimestamp', shaderAttributes: { instanceTimestamps: {}, }, }, }) + attributeManager.addInstanced({ + speeds: { + size: 1, + accessor: 'getSpeed', + shaderAttributes: { + instanceSpeeds: {}, + }, + }, + }) + attributeManager.addInstanced({ + elevations: { + size: 1, + accessor: 'getElevation', + shaderAttributes: { + instanceElevations: {}, + }, + }, + }) } } @@ -145,14 +211,28 @@ export class VesselTrackLayer extends PathLayer< } draw(params: any) { - const { startTime, endTime, highlightStartTime, highlightEndTime, highlightColor } = this.props + const { + startTime, + endTime, + highlightStartTime = 0, + highlightEndTime = 0, + highlightColor, + minSpeedFilter = -MAX_FILTER_VALUE, + maxSpeedFilter = MAX_FILTER_VALUE, + minElevationFilter = -MAX_FILTER_VALUE, + maxElevationFilter = MAX_FILTER_VALUE, + } = this.props params.uniforms = { ...params.uniforms, startTime, endTime, - highlightStartTime: highlightStartTime ? highlightStartTime : 0, - highlightEndTime: highlightEndTime ? highlightEndTime : 0, + highlightStartTime, + highlightEndTime, + minSpeedFilter, + maxSpeedFilter, + minElevationFilter, + maxElevationFilter, highlightColor, } super.draw(params) @@ -166,16 +246,21 @@ export class VesselTrackLayer extends PathLayer< const data = this.props.data as VesselTrackData const segmentsIndex = data.startIndices const positions = data.attributes?.getPath?.value - const timestamps = data.attributes?.getTimestamps?.value + const timestamps = data.attributes?.getTimestamp?.value + const speeds = data.attributes?.getSpeed?.value + const elevations = data.attributes?.getElevation?.value + if (!positions?.length || !timestamps.length) { return [] } - const size = data.attributes.getPath!?.size + const size = data.attributes.getTimestamp!?.size const segments = segmentsIndex.map((segment, i, segments) => { const initialPoint = { // longitude: positions[segment], // latitude: positions[segment + 1], timestamp: timestamps[segment / size], + speed: speeds?.[segment / size], + elevation: elevations?.[segment / size], } const nextSegmentIndex = segments[i + 1] const lastPoint = @@ -184,11 +269,15 @@ export class VesselTrackLayer extends PathLayer< // longitude: positions[positions.length - size], // latitude: positions[positions.length - size + 1], timestamp: timestamps[timestamps.length - 1], + speed: speeds?.[speeds.length - 1], + elevation: elevations?.[elevations.length - 1], } : { // longitude: positions[nextSegmentIndex], // latitude: positions[nextSegmentIndex + 1], timestamp: timestamps[nextSegmentIndex / size - 1], + speed: speeds?.[nextSegmentIndex / size - 1], + elevation: elevations?.[nextSegmentIndex / size - 1], } return [initialPoint, lastPoint] }) diff --git a/libs/deck-layers/src/layers/vessel/vessel.config.ts b/libs/deck-layers/src/layers/vessel/vessel.config.ts index 99a33621a1..cdbbbf39c3 100644 --- a/libs/deck-layers/src/layers/vessel/vessel.config.ts +++ b/libs/deck-layers/src/layers/vessel/vessel.config.ts @@ -28,4 +28,5 @@ export const EVENTS_COLORS: Record = { highlight: hexToDeckColor('#ffffff'), } +export const DEFAULT_FISHING_EVENT_COLOR = [255, 255, 255] as Color export const DEFAULT_HIGHLIGHT_COLOR_VEC = [1.0, 1.0, 1.0, 1.0] diff --git a/libs/deck-layers/src/layers/vessel/vessel.types.ts b/libs/deck-layers/src/layers/vessel/vessel.types.ts index 891e2b77d4..25f2d877b3 100644 --- a/libs/deck-layers/src/layers/vessel/vessel.types.ts +++ b/libs/deck-layers/src/layers/vessel/vessel.types.ts @@ -1,5 +1,4 @@ import { Color, PickingInfo } from '@deck.gl/core' -import { Feature, LineString, MultiLineString, Point } from 'geojson' import { Tile2DHeader } from '@deck.gl/geo-layers/dist/tileset-2d' import { ApiEvent, EventTypes, ResourceStatus } from '@globalfishingwatch/api-types' import { BasePickingInfo } from '../../types' @@ -19,6 +18,7 @@ export type VesselDataStatus = { export type _VesselLayerProps = { name: string color: Color + singleTrack: boolean visible: boolean onVesselDataLoad?: (layers: VesselDataStatus[]) => void } diff --git a/libs/deck-loaders/src/vessels/lib/parse-tracks.ts b/libs/deck-loaders/src/vessels/lib/parse-tracks.ts index 92656a0432..813e004efe 100644 --- a/libs/deck-loaders/src/vessels/lib/parse-tracks.ts +++ b/libs/deck-loaders/src/vessels/lib/parse-tracks.ts @@ -1,143 +1,42 @@ -import Pbf from 'pbf' -import { TrackField } from '@globalfishingwatch/api-types' import { VesselTrackData } from './types' +import { DeckTrack } from './vessel-track-proto' export const DEFAULT_NULL_VALUE = -Math.pow(2, 31) -const transformerByField: Partial number>> = { - latitude: (value: number) => value / Math.pow(10, 6), - longitude: (value: number) => value / Math.pow(10, 6), - timestamp: (value: number) => value * Math.pow(10, 3), -} - -type Point = Record -export const trackValueArrayToSegments = (valueArray: number[], fields_: TrackField[]) => { - if (!fields_.length) { - throw new Error() - } - - const fields = [...fields_] - if (fields.includes(TrackField.lonlat)) { - const llIndex = fields.indexOf('lonlat' as TrackField) - fields.splice(llIndex, 1, TrackField.longitude, TrackField.latitude) - } - const numFields = fields.length - - let numSegments: number - const segmentIndices = [] as number[] - const segments = [] as Point[][] - - let nullValue = DEFAULT_NULL_VALUE - let currentSegment = [] as Point[] - let currentPoint = {} as Point - let pointsFieldIndex = 0 - let currentPointFieldIndex = 0 - let currentSegmentIndex = 0 - let currentSegPointIndex = 0 - if (valueArray && valueArray?.length) { - valueArray.forEach((value, index) => { - if (index === 0) { - nullValue = value - } else if (index === 1) { - numSegments = value - } else if (index < 2 + numSegments) { - segmentIndices.push(value) - } else { - // a segment starts - if (segmentIndices.includes(pointsFieldIndex)) { - // close previous segment, only if needed (it's not the first segment) - if (currentSegmentIndex !== 0) { - currentSegment.push(currentPoint) - segments.push(currentSegment) - } - currentSegPointIndex = 0 - currentSegmentIndex++ - // create new segment - currentSegment = [] - currentPoint = {} - } - - // get what is the current field for current point - currentPointFieldIndex = pointsFieldIndex % numFields - - // a point starts - if (currentPointFieldIndex === 0) { - // close previous point, only if needed (it's not the first point of this seg) - if (currentSegPointIndex !== 0) { - currentSegment.push(currentPoint) - } - currentSegPointIndex++ - // create new point - currentPoint = {} - } - - const field = fields[currentPointFieldIndex] - const transformer = transformerByField[field] - - if (value === nullValue || transformer === undefined) { - currentPoint[field] = null - } else { - currentPoint[field] = transformer(value) - } - - pointsFieldIndex++ - } - }) - } - - // close last open point and open segment - if (Object.keys(currentPoint).length) { - currentSegment.push(currentPoint) - } - - if (currentSegment.length) { - segments.push(currentSegment) - } - - return segments -} - -function readValueArrayData(_: any, data: any, pbf: any) { - data.push(pbf.readPackedSVarint()) -} - export const parseTrack = (arrayBuffer: ArrayBuffer): VesselTrackData => { - const track: VesselTrackData = { - // Number of geometries - length: 0, - // Indices into positions where each path starts - startIndices: [] as number[], - // Flat coordinates array + const track = DeckTrack.decode(new Uint8Array(arrayBuffer)) as any + if (!track.attributes.getPath.value.length) { + return {} as VesselTrackData + } + const defaultAttributesLength = + track.attributes.getPath.value.length / track.attributes.getPath.size + return { + ...track, attributes: { - getPath: { value: new Float32Array(), size: 2 }, - getTimestamps: { value: new Float32Array(), size: 1 }, + getPath: { + value: new Float32Array(track.attributes.getPath.value), + size: track.attributes.getPath.size, + }, + getTimestamp: { + value: track.attributes.getTimestamp.value?.length + ? new Float32Array(track.attributes.getTimestamp.value) + : new Float32Array(defaultAttributesLength), + size: track.attributes.getTimestamp.size, + }, + getSpeed: { + value: track.attributes.getSpeed.value?.length + ? new Float32Array(track.attributes.getSpeed.value) + : new Float32Array(defaultAttributesLength), + size: track.attributes.getSpeed.size, + }, + getElevation: { + value: track.attributes.getElevation.value?.length + ? new Float32Array(track.attributes.getElevation.value) + : new Float32Array(defaultAttributesLength), + size: track.attributes.getElevation.size, + }, + // TODO + // getCourse }, - } - - let index = 0 - const segmentIndexes = [0] as number[] - const data = new Pbf(arrayBuffer).readFields(readValueArrayData, [])[0] - // TODO make the fields dynamic to support speed or depth - const segments = trackValueArrayToSegments(data, [TrackField.lonlat, TrackField.timestamp]) - const dataLength = segments.reduce((acc, data) => data.length + acc, 0) - const positions = new Float32Array(dataLength * track.attributes.getPath.size) - const timestamps = new Float32Array(dataLength) - - segments.forEach((segment, i) => { - if (i > 0) { - segmentIndexes.push(index * track.attributes.getPath.size) - } - segment.forEach((point, j) => { - positions[track.attributes.getPath.size * index] = point.longitude as number - positions[track.attributes.getPath.size * index + 1] = point.latitude as number - timestamps[index] = Number(point.timestamp) - index++ - }) - }) - track.length = segmentIndexes.length - track.startIndices = segmentIndexes - track.attributes.getPath.value = positions - track.attributes.getTimestamps.value = timestamps - - return track + } as VesselTrackData } diff --git a/libs/deck-loaders/src/vessels/lib/types.ts b/libs/deck-loaders/src/vessels/lib/types.ts index 95340ac525..2f1eeaf35d 100644 --- a/libs/deck-loaders/src/vessels/lib/types.ts +++ b/libs/deck-loaders/src/vessels/lib/types.ts @@ -10,7 +10,9 @@ export type VesselTrackData = { // Populated automatically by deck.gl positions?: { value: Float32Array; size: number } getPath: { value: Float32Array; size: number } - getTimestamps: { value: Float32Array; size: number } + getTimestamp: { value: Float32Array; size: number } + getSpeed: { value: Float32Array; size: number } + getElevation: { value: Float32Array; size: number } } } diff --git a/libs/deck-loaders/src/vessels/lib/vessel-track-proto.ts b/libs/deck-loaders/src/vessels/lib/vessel-track-proto.ts new file mode 100644 index 0000000000..cd76ac187f --- /dev/null +++ b/libs/deck-loaders/src/vessels/lib/vessel-track-proto.ts @@ -0,0 +1,27 @@ +import { parse } from 'protobufjs' + +const proto = ` +syntax = "proto3"; +package vessels; + +message DeckTrackAttribute { + repeated float value = 1; + uint32 size = 2; +} + +message DeckTrackAttributeStruct { + DeckTrackAttribute getPath = 1; + DeckTrackAttribute getTimestamp = 2; + DeckTrackAttribute getSpeed = 3; + DeckTrackAttribute getElevation = 4; + DeckTrackAttribute getCourse = 5; +} + +message DeckTrack { + uint32 length = 1; + repeated uint32 startIndices = 2; + DeckTrackAttributeStruct attributes = 3; +} +` + +export const DeckTrack = parse(proto).root.lookupType('DeckTrack') diff --git a/libs/deck-loaders/src/vessels/tracks-loader.ts b/libs/deck-loaders/src/vessels/tracks-loader.ts index a76063cc92..38399fbbaf 100644 --- a/libs/deck-loaders/src/vessels/tracks-loader.ts +++ b/libs/deck-loaders/src/vessels/tracks-loader.ts @@ -4,7 +4,7 @@ import { PATH_BASENAME } from '../loaders.config' import { parseTrack } from './lib/parse-tracks' /** - * Worker loader for the Vessel Track int array format + * Worker loader for the Vessel Track DECKGL format */ export const VesselTrackWorkerLoader: Loader = { @@ -24,7 +24,7 @@ export const VesselTrackWorkerLoader: Loader = { } /** - * Loader for the Vessel Track int array format + * Loader for the Vessel Track DECKGL format */ export const VesselTrackLoader: LoaderWithParser = { ...VesselTrackWorkerLoader, diff --git a/libs/ui-components/src/slider/Slider.tsx b/libs/ui-components/src/slider/Slider.tsx index b141752143..7604221842 100644 --- a/libs/ui-components/src/slider/Slider.tsx +++ b/libs/ui-components/src/slider/Slider.tsx @@ -38,8 +38,9 @@ const borderColor = : fallbackBorderColor export const formatSliderNumber = (num: number): string => { - if (num >= 1000) return format('.2s')(num) - if (num > 9) return format('.0f')(num) + const absNum = Math.abs(num) + if (absNum >= 1000) return format('.2s')(num) + if (absNum > 9) return format('.0f')(num) return format('.1f')(num) }