Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Responsive-visualization/core #2988

Draft
wants to merge 38 commits into
base: develop
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
1e1bb59
initial visualization library commit
j8seangel Jan 8, 2025
844e10c
use responsive-visualization in vessels list graph
j8seangel Jan 8, 2025
669f7c7
individual tooltip component
j8seangel Jan 9, 2025
0b084b1
support dataKey and labelKey and improve typings
j8seangel Jan 9, 2025
627b0f7
extract reusable logic from BarChart to hooks
j8seangel Jan 9, 2025
895912e
wip: timeseries aggregated graph
j8seangel Jan 9, 2025
a0fbe62
individual timeseries points
j8seangel Jan 10, 2025
f79a22b
align timeseries points
j8seangel Jan 10, 2025
7ebf76e
fix deck core version
satellitestudiodesign Jan 10, 2025
b8c5a7a
fix timeseries layout
satellitestudiodesign Jan 10, 2025
9c6aed7
fix vessel group vessels graph labels
satellitestudiodesign Jan 10, 2025
d6c00c1
fix tooltip overflow
satellitestudiodesign Jan 10, 2025
83bf263
enhance TimeseriesIndividual single time data representation
satellitestudiodesign Jan 10, 2025
91a9000
use local containerRef instead of passing it as a prop
satellitestudiodesign Jan 13, 2025
0396787
use in vessel-groups coverage insight
satellitestudiodesign Jan 13, 2025
4c93694
reuse vessel grouping
satellitestudiodesign Jan 13, 2025
f23b25a
match covegare opacity in individual graph
satellitestudiodesign Jan 13, 2025
c8710ea
fix crashes and warnings
satellitestudiodesign Jan 13, 2025
70df1bf
make opacity change only the list
satellitestudiodesign Jan 13, 2025
a8627a9
individual point events
j8seangel Jan 13, 2025
344d962
fix typing
j8seangel Jan 13, 2025
69a2ce0
better typings
j8seangel Jan 13, 2025
20b1102
add validation for MAX_INDIVIDUAL_ITEMS
j8seangel Jan 13, 2025
2f7882c
ensure isIndividualSupported when received individualData
j8seangel Jan 14, 2025
8e01b3e
add includes in individual data requests
j8seangel Jan 14, 2025
2ee6fb9
responsive visualiztion placeholders
j8seangel Jan 14, 2025
e23b163
adjust density calculation
satellitestudiodesign Jan 14, 2025
49e84de
include individual items filters in request
j8seangel Jan 14, 2025
2d5d3db
refactor dates parse fn
j8seangel Jan 14, 2025
31b3c92
fix timeseries in hour
j8seangel Jan 14, 2025
ad22fc1
render individual points in vessel group events vessels
j8seangel Jan 14, 2025
18f13c4
render individual points in port report events vessels
j8seangel Jan 14, 2025
e74da3e
use event icons in individual timeseries
satellitestudiodesign Jan 14, 2025
0fd97db
vessel tooltip
satellitestudiodesign Jan 14, 2025
0802a68
encounter tooltip
satellitestudiodesign Jan 14, 2025
90a92db
Merge branch 'develop' into responsive-visualization/core
j8seangel Jan 14, 2025
f625c64
fix build
j8seangel Jan 14, 2025
5bb0e00
calculate pointSize based on space
j8seangel Jan 14, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/fishing-map/features/i18n/i18nDate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { Fragment } from 'react'
import type { DateTimeFormatOptions } from 'luxon'
import { DateTime } from 'luxon'
import { useTranslation } from 'react-i18next'
import type { SupportedDateType } from '@globalfishingwatch/data-transforms'
import type { Locale } from 'types'
import type { SupportedDateType } from 'utils/dates'
import { getUTCDateTime } from 'utils/dates'
import i18n from './i18n'

Expand Down
12 changes: 11 additions & 1 deletion apps/fishing-map/features/reports/ports/PortsReport.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ import { Fragment } from 'react'
import { useTranslation } from 'react-i18next'
import parse from 'html-react-parser'
import { DateTime } from 'luxon'
import { Button } from '@globalfishingwatch/ui-components'
import { useGetReportEventsStatsQuery } from 'queries/report-events-stats-api'
import { Button } from '@globalfishingwatch/ui-components'
import { getDataviewFilters } from '@globalfishingwatch/dataviews-client'
import EventsReportGraph from 'features/reports/shared/events/EventsReportGraph'
import { selectReportPortId } from 'routes/routes.selectors'
import EventsReportVesselPropertySelector from 'features/reports/shared/events/EventsReportVesselPropertySelector'
Expand All @@ -30,6 +31,7 @@ import { useFetchPortsReport } from './ports-report.hooks'
import {
selectPortReportsDataview,
selectPortReportVesselsGrouped,
selectPortReportVesselsIndividualData,
selectPortReportVesselsPaginated,
selectPortReportVesselsPagination,
} from './ports-report.selectors'
Expand Down Expand Up @@ -65,6 +67,7 @@ function PortsReport() {
const portsReportData = useSelector(selectPortsReportData)
const portsReportDataStatus = useSelector(selectPortsReportStatus)
const portsReportVesselsGrouped = useSelector(selectPortReportVesselsGrouped)
const portReportIndividualData = useSelector(selectPortReportVesselsIndividualData)
const portsReportVesselsPaginated = useSelector(selectPortReportVesselsPaginated)
const {
data,
Expand Down Expand Up @@ -155,6 +158,12 @@ function PortsReport() {
color={color}
start={start}
end={end}
filters={{
portId,
...(dataview && { ...getDataviewFilters(dataview) }),
}}
includes={['id', 'start', 'end', 'vessel']}
datasetId={datasetId}
timeseries={data.timeseries || []}
/>
)}
Expand Down Expand Up @@ -215,6 +224,7 @@ function PortsReport() {
</div>
<EventsReportVesselsGraph
data={portsReportVesselsGrouped}
individualData={portReportIndividualData}
color={color}
property={portReportVesselsProperty}
filterQueryParam="portsReportVesselsFilter"
Expand Down
15 changes: 13 additions & 2 deletions apps/fishing-map/features/reports/ports/ports-report.selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,11 @@ import {
TEMPLATE_VESSEL_DATAVIEW_SLUG,
} from 'data/workspaces'
import { selectAllDataviews } from 'features/dataviews/dataviews.slice'
import { REPORT_FILTER_PROPERTIES } from '../vessel-groups/vessels/vessel-group-report-vessels.selectors'
import { OTHER_CATEGORY_LABEL } from '../vessel-groups/vessel-group-report.config'
import { getVesselIndividualGroupedData } from '../vessel-groups/vessels/vessel-group-report-vessels.selectors'
import {
OTHER_CATEGORY_LABEL,
REPORT_FILTER_PROPERTIES,
} from '../vessel-groups/vessel-group-report.config'
import { selectPortsReportVessels } from './ports-report.slice'
import {
selectPortReportVesselsFilter,
Expand Down Expand Up @@ -115,6 +118,14 @@ export const selectPortReportVesselsGrouped = createSelector(
}
)

export const selectPortReportVesselsIndividualData = createSelector(
[selectPortReportVesselsFiltered, selectPortReportVesselsProperty],
(vessels, groupBy) => {
if (!vessels || !groupBy) return []
return getVesselIndividualGroupedData(vessels, groupBy)
}
)

export const selectPortReportVesselsPaginated = createSelector(
[
selectPortReportVesselsFiltered,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,22 @@
.graph :global(.recharts-tooltip-cursor) {
stroke: var(--color-secondary-blue);
}

.event {
display: flex;
flex-direction: column;
gap: var(--space-S);
}

.properties {
display: flex;
gap: var(--space-S);
}

.property label {
font: var(--font-XS);
}

.property span {
font: var(--font-S);
}
215 changes: 118 additions & 97 deletions apps/fishing-map/features/reports/shared/events/EventsReportGraph.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,27 @@
import React, { useMemo } from 'react'
import {
ResponsiveContainer,
CartesianGrid,
XAxis,
YAxis,
Tooltip,
Line,
ComposedChart,
} from 'recharts'
import min from 'lodash/min'
import max from 'lodash/max'
import type { DurationUnit } from 'luxon'
import { DateTime, Duration } from 'luxon'
import type { ReactElement } from 'react'
import React, { useCallback } from 'react'
import { DateTime } from 'luxon'
import { groupBy } from 'es-toolkit'
import { stringify } from 'qs'
import { useTranslation } from 'react-i18next'
import type { FourwingsInterval } from '@globalfishingwatch/deck-loaders'
import { getFourwingsInterval } from '@globalfishingwatch/deck-loaders'
import { getEventsStatsQuery } from 'queries/report-events-stats-api'
import type { BaseReportEventsVesselsParamsFilters } from 'queries/report-events-stats-api'
import { getFourwingsInterval, type FourwingsInterval } from '@globalfishingwatch/deck-loaders'
import type { BaseResponsiveTimeseriesProps } from '@globalfishingwatch/responsive-visualizations'
import { ResponsiveTimeseries } from '@globalfishingwatch/responsive-visualizations'
import { GFWAPI } from '@globalfishingwatch/api-client'
import type { ApiEvent, APIPagination, EventType } from '@globalfishingwatch/api-types'
import { useMemoCompare } from '@globalfishingwatch/react-hooks'
import { getISODateByInterval } from '@globalfishingwatch/data-transforms'
import i18n from 'features/i18n/i18n'
import { formatDateForInterval, getUTCDateTime } from 'utils/dates'
import { formatI18nNumber } from 'features/i18n/i18nNumber'
import { tickFormatter } from 'features/reports/areas/area-reports.utils'
import { COLOR_PRIMARY_BLUE } from 'features/app/app.config'
import { formatInfoField } from 'utils/info'
import { getTimeLabels } from 'utils/events'
import EncounterIcon from '../../shared/events/icons/event-encounter.svg'
import LoiteringIcon from '../../shared/events/icons/event-loitering.svg'
import PortVisitIcon from '../../shared/events/icons/event-port.svg'
import styles from './EventsReportGraph.module.css'

type EventsReportGraphTooltipProps = {
Expand All @@ -37,7 +39,7 @@ type EventsReportGraphTooltipProps = {
timeChunkInterval: FourwingsInterval
}

const ReportGraphTooltip = (props: any) => {
const AggregatedGraphTooltip = (props: any) => {
const { active, payload, label, timeChunkInterval } = props as EventsReportGraphTooltipProps

if (active && payload && payload.length) {
Expand All @@ -56,111 +58,130 @@ const ReportGraphTooltip = (props: any) => {
return null
}

const formatDateTicks = (tick: string, timeChunkInterval: FourwingsInterval) => {
const IndividualGraphTooltip = ({ data, eventType }: { data?: any; eventType?: EventType }) => {
const { t } = useTranslation()
if (!data?.vessel?.name) {
return null
}
console.log('data:', data)
if (eventType === 'encounter') {
const { start, duration } = getTimeLabels({ start: data.start, end: data.end })
return (
<div className={styles.event}>
<div className={styles.properties}>
<div className={styles.property}>
<label>{t('eventInfo.start', 'Start')}</label>
<span>{start}</span>
</div>
<div className={styles.property}>
<label>{t('eventInfo.duration', 'Duration')}</label>
<span>{duration}</span>
</div>
</div>
<div className={styles.properties}>
<div className={styles.property}>
<label>{formatInfoField(data.vessel?.type, 'shiptypes')}</label>
<span>{formatInfoField(data.vessel?.name, 'shipname')}</span>
</div>
<div className={styles.property}>
<label>{formatInfoField(data.encounter?.vessel?.type, 'shiptypes')}</label>
<span>{formatInfoField(data.encounter?.vessel?.name, 'shipname')}</span>
</div>
</div>
</div>
)
}
return formatInfoField(data.vessel.name, 'shipname')
}

const formatDateTicks: BaseResponsiveTimeseriesProps['tickLabelFormatter'] = (
tick,
timeChunkInterval
) => {
const date = getUTCDateTime(tick).setLocale(i18n.language)
return formatDateForInterval(date, timeChunkInterval)
}

const graphMargin = { top: 0, right: 0, left: -20, bottom: -10 }

export default function EventsReportGraph({
datasetId,
filters,
includes,
color = COLOR_PRIMARY_BLUE,
end,
start,
timeseries,
eventType,
}: {
datasetId: string
filters?: BaseReportEventsVesselsParamsFilters
includes?: string[]
color?: string
end: string
start: string
timeseries: { date: string; value: number }[]
eventType?: EventType
}) {
const { t } = useTranslation()
const containerRef = React.useRef<HTMLDivElement>(null)

const startMillis = DateTime.fromISO(start).toMillis()
const endMillis = DateTime.fromISO(end).toMillis()
const interval = getFourwingsInterval(startMillis, endMillis)
const filtersMemo = useMemoCompare(filters)
const includesMemo = useMemoCompare(includes)

const domain = useMemo(() => {
if (start && end && interval) {
const cleanEnd = DateTime.fromISO(end, { zone: 'utc' })
.minus({ [interval]: 1 })
.toISO() as string
return [new Date(start).getTime(), new Date(cleanEnd).getTime()]
}
}, [start, end, interval])

const dataMin: number = timeseries.length
? (min(timeseries.map(({ value }: { value: number }) => value)) as number)
: 0
const dataMax: number = timeseries.length
? (max(timeseries.map(({ value }: { value: number }) => value)) as number)
: 0
let icon: ReactElement | undefined
if (eventType === 'encounter') {
icon = <EncounterIcon />
} else if (eventType === 'loitering') {
icon = <LoiteringIcon />
} else if (eventType === 'port_visit') {
icon = <PortVisitIcon />
}

const domainPadding = (dataMax - dataMin) / 8
const paddedDomain: [number, number] = [
Math.max(0, Math.floor(dataMin - domainPadding)),
Math.ceil(dataMax + domainPadding),
]
const intervalDiff = Math.floor(
Duration.fromMillis(endMillis - startMillis).as(interval.toLowerCase() as DurationUnit)
)
const fullTimeseries = useMemo(() => {
if (!timeseries || !domain) {
return []
const getAggregatedData = useCallback(async () => timeseries, [timeseries])
const getIndividualData = useCallback(async () => {
const params = {
...getEventsStatsQuery({
start,
end,
filters: filtersMemo,
dataset: datasetId,
}),
...(includesMemo && { includes: includesMemo }),
limit: 1000,
offset: 0,
}
return Array(intervalDiff)
.fill(0)
.map((_, i) => i)
.map((i) => {
const d = DateTime.fromMillis(startMillis, { zone: 'UTC' })
.plus({ [interval]: i })
.toISO()
return {
date: d,
value: timeseries.find(({ date }: { date: string }) => date === d)?.value || 0,
}
})
}, [timeseries, domain, intervalDiff, startMillis, interval])
const data = await GFWAPI.fetch<APIPagination<ApiEvent>>(`/v3/events?${stringify(params)}`)
const groupedData = groupBy(data.entries, (item) => getISODateByInterval(item.start, interval))
console.log(
Object.entries(groupedData)
.map(([date, events]) => ({ date, values: events }))
.sort((a, b) => a.date.localeCompare(b.date))
)

return Object.entries(groupedData)
.map(([date, events]) => ({ date, values: events }))
.sort((a, b) => a.date.localeCompare(b.date))
}, [start, end, filtersMemo, includesMemo, datasetId, interval])

if (!fullTimeseries.length || !domain) {
if (!timeseries.length) {
return null
}

return (
<div className={styles.graph}>
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={fullTimeseries} margin={graphMargin}>
<CartesianGrid vertical={false} />
<XAxis
domain={domain}
dataKey="date"
interval="preserveStartEnd"
tickFormatter={(tick: string) => formatDateTicks(tick, interval)}
axisLine={paddedDomain[0] === 0}
/>
<YAxis
scale="linear"
domain={paddedDomain}
interval="preserveEnd"
tickFormatter={tickFormatter}
axisLine={false}
tickLine={false}
tickCount={4}
/>
{timeseries?.length && (
<Tooltip content={<ReportGraphTooltip timeChunkInterval={interval} />} />
)}
<Line
name="line"
type="monotone"
dataKey="value"
unit={t('common.events', 'Events').toLowerCase()}
dot={false}
isAnimationActive={false}
stroke={color}
strokeWidth={2}
/>
</ComposedChart>
</ResponsiveContainer>
<div ref={containerRef} className={styles.graph}>
<ResponsiveTimeseries
start={start}
end={end}
timeseriesInterval={interval}
getAggregatedData={getAggregatedData}
getIndividualData={getIndividualData}
tickLabelFormatter={formatDateTicks}
aggregatedTooltip={<AggregatedGraphTooltip />}
individualTooltip={<IndividualGraphTooltip eventType={eventType} />}
color={color}
individualIcon={icon}
/>
</div>
)
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading