From 1410edb3e91c767ecb41e7f23f67cd61357b0171 Mon Sep 17 00:00:00 2001 From: Anna Viklund Date: Wed, 27 May 2026 15:02:29 +0200 Subject: [PATCH 01/11] layout: setup new page for session details --- ui/src/app.tsx | 2 +- .../new/hooks/useActiveCapture.ts | 17 ++++ .../new/hooks/useActiveOccurrences.ts | 21 +++++ .../session-details/new/session-details.tsx | 93 +++++++++++++++++++ .../session-details/new/session-info.tsx | 63 +++++++++++++ .../session-details/new/session-plots.tsx | 22 +++++ ui/src/utils/language.ts | 1 + 7 files changed, 218 insertions(+), 1 deletion(-) create mode 100644 ui/src/pages/session-details/new/hooks/useActiveCapture.ts create mode 100644 ui/src/pages/session-details/new/hooks/useActiveOccurrences.ts create mode 100644 ui/src/pages/session-details/new/session-details.tsx create mode 100644 ui/src/pages/session-details/new/session-info.tsx create mode 100644 ui/src/pages/session-details/new/session-plots.tsx diff --git a/ui/src/app.tsx b/ui/src/app.tsx index 5f686e0e2..f882c8d06 100644 --- a/ui/src/app.tsx +++ b/ui/src/app.tsx @@ -36,7 +36,7 @@ import { Storage } from 'pages/project/storage/storage' import { Summary } from 'pages/project/summary/summary' import { Team } from 'pages/project/team/team' import { Projects } from 'pages/projects/projects' -import SessionDetails from 'pages/session-details/session-details' +import SessionDetails from 'pages/session-details/new/session-details' import { Sessions } from 'pages/sessions/sessions' import { Species } from 'pages/species/species' import { TaxaListDetails } from 'pages/taxa-list-details/taxa-list-details' diff --git a/ui/src/pages/session-details/new/hooks/useActiveCapture.ts b/ui/src/pages/session-details/new/hooks/useActiveCapture.ts new file mode 100644 index 000000000..6fb8d4862 --- /dev/null +++ b/ui/src/pages/session-details/new/hooks/useActiveCapture.ts @@ -0,0 +1,17 @@ +import { useSearchParams } from 'react-router-dom' + +const SEARCH_PARAM_KEY = 'capture' + +export const useActiveCaptureId = (defaultValue?: string) => { + const [searchParams, setSearchParams] = useSearchParams() + + const activeCaptureId = searchParams.get(SEARCH_PARAM_KEY) ?? defaultValue + + const setActiveCaptureId = (captureId: string) => { + searchParams.delete(SEARCH_PARAM_KEY) + searchParams.set(SEARCH_PARAM_KEY, captureId) + setSearchParams(searchParams, { replace: true }) + } + + return { activeCaptureId, setActiveCaptureId } +} diff --git a/ui/src/pages/session-details/new/hooks/useActiveOccurrences.ts b/ui/src/pages/session-details/new/hooks/useActiveOccurrences.ts new file mode 100644 index 000000000..79115b79f --- /dev/null +++ b/ui/src/pages/session-details/new/hooks/useActiveOccurrences.ts @@ -0,0 +1,21 @@ +import { useCallback } from 'react' +import { useSearchParams } from 'react-router-dom' + +const SEARCH_PARAM_KEY = 'occurrence' + +export const useActiveOccurrences = () => { + const [searchParams, setSearchParams] = useSearchParams() + + const activeOccurrences = searchParams.getAll(SEARCH_PARAM_KEY) + + const setActiveOccurrences = useCallback( + (occurrences: string[]) => { + searchParams.delete(SEARCH_PARAM_KEY) + occurrences.forEach((o) => searchParams.append(SEARCH_PARAM_KEY, o)) + setSearchParams(searchParams, { replace: true }) + }, + [searchParams, setSearchParams] + ) + + return { activeOccurrences, setActiveOccurrences } +} diff --git a/ui/src/pages/session-details/new/session-details.tsx b/ui/src/pages/session-details/new/session-details.tsx new file mode 100644 index 000000000..fc75afd2a --- /dev/null +++ b/ui/src/pages/session-details/new/session-details.tsx @@ -0,0 +1,93 @@ +import { ErrorState } from 'components/error-state/error-state' +import { useSessionDetails } from 'data-services/hooks/sessions/useSessionDetails' +import { Box, LoadingSpinner, PageHeader, Tabs } from 'nova-ui-kit' +import { useContext, useEffect } from 'react' +import { Helmet } from 'react-helmet-async' +import { useParams } from 'react-router-dom' +import { BreadcrumbContext } from 'utils/breadcrumbContext' +import { STRING, translate } from 'utils/language' +import { useActiveCaptureId } from './hooks/useActiveCapture' +import { useActiveOccurrences } from './hooks/useActiveOccurrences' +import { SessionInfo } from './session-info' +import { SessionPlots } from './session-plots' + +const TABS = { + FIELDS: 'fields', + CHARTS: 'charts', +} + +export const SessionDetails = () => { + const { id } = useParams() + const { setDetailBreadcrumb } = useContext(BreadcrumbContext) + const { activeOccurrences } = useActiveOccurrences() + const { activeCaptureId } = useActiveCaptureId() + const { session, isLoading, error } = useSessionDetails(id as string, { + capture: activeCaptureId, + occurrence: activeOccurrences[0], + }) + + useEffect(() => { + setDetailBreadcrumb(session ? { title: session.label } : undefined) + + return () => { + setDetailBreadcrumb(undefined) + } + }, [session]) + + if (isLoading) { + return ( +
+ +
+ ) + } + + if (!session || error) { + return + } + + return ( + <> + + + + +
+ + + + + + + + + + +
+ +
+
+
+
+
+ <> +
+
+ + ) +} + +export default SessionDetails diff --git a/ui/src/pages/session-details/new/session-info.tsx b/ui/src/pages/session-details/new/session-info.tsx new file mode 100644 index 000000000..325ae8831 --- /dev/null +++ b/ui/src/pages/session-details/new/session-info.tsx @@ -0,0 +1,63 @@ +import { SessionDetails } from 'data-services/models/session-details' +import { InfoBlock } from 'nova-ui-kit' +import { useParams } from 'react-router-dom' +import { APP_ROUTES } from 'utils/constants' +import { getAppRoute } from 'utils/getAppRoute' +import { STRING, translate } from 'utils/language' + +export const SessionInfo = ({ session }: { session: SessionDetails }) => { + const { projectId } = useParams() + + const fields = [ + { + label: translate(STRING.FIELD_LABEL_ID), + value: session.id, + }, + { + label: translate(STRING.FIELD_LABEL_DEPLOYMENT), + value: session.deploymentLabel, + to: APP_ROUTES.DEPLOYMENT_DETAILS({ + projectId: projectId as string, + deploymentId: session.deploymentId, + }), + }, + { + label: translate(STRING.FIELD_LABEL_DATE), + value: session.datespanLabel, + }, + { + label: translate(STRING.FIELD_LABEL_TIME), + value: session.timespanLabel, + }, + { + label: translate(STRING.FIELD_LABEL_DURATION), + value: session.durationLabel, + }, + { + label: translate(STRING.FIELD_LABEL_CAPTURES), + value: session.numImages, + }, + { + label: translate(STRING.FIELD_LABEL_OCCURRENCES), + value: session.numOccurrences, + to: getAppRoute({ + to: APP_ROUTES.OCCURRENCES({ projectId: projectId as string }), + filters: { event: session.id }, + }), + }, + ...(session.numTaxa !== undefined + ? [ + { + label: translate(STRING.FIELD_LABEL_TAXA), + value: session.numTaxa, + to: getAppRoute({ + to: APP_ROUTES.TAXA({ projectId: projectId as string }), + filters: { event: session.id }, + }), + }, + ] + : []), + ] + + return +} diff --git a/ui/src/pages/session-details/new/session-plots.tsx b/ui/src/pages/session-details/new/session-plots.tsx new file mode 100644 index 000000000..e601cd119 --- /dev/null +++ b/ui/src/pages/session-details/new/session-plots.tsx @@ -0,0 +1,22 @@ +import { Plot } from 'components/plot/lazy-plot' +import { SessionDetails } from 'data-services/models/session-details' + +export const SessionPlots = ({ session }: { session: SessionDetails }) => ( + <> + {session.summaryData.map((summary, index) => { + if (summary.data.x.length <= 1) { + return null + } + + return ( + + ) + })} + +) diff --git a/ui/src/utils/language.ts b/ui/src/utils/language.ts index e432a04d7..8cf0b7214 100644 --- a/ui/src/utils/language.ts +++ b/ui/src/utils/language.ts @@ -314,6 +314,7 @@ export enum STRING { REJECT_ID, REMOVE_MEMBER, REMOVE_TAXA_LIST_TAXON, + RESULTS_CAPTURES, RESULTS_MEMBERS, RESULTS, SELECT_COLUMNS, From 3f33386727d616fef6d7e9b9e2274ccff976ebd9 Mon Sep 17 00:00:00 2001 From: Anna Viklund Date: Wed, 27 May 2026 15:47:42 +0200 Subject: [PATCH 02/11] layout: render capture and include capture navigation and star button --- .../new/capture-navigation.tsx | 112 ++++++ .../new/capture/capture.module.scss | 70 ++++ .../session-details/new/capture/capture.tsx | 318 ++++++++++++++++++ .../session-details/new/session-details.tsx | 132 ++++++-- .../pages/session-details/new/star-button.tsx | 44 +++ ui/src/pages/session-details/new/utils.tsx | 81 +++++ 6 files changed, 728 insertions(+), 29 deletions(-) create mode 100644 ui/src/pages/session-details/new/capture-navigation.tsx create mode 100644 ui/src/pages/session-details/new/capture/capture.module.scss create mode 100644 ui/src/pages/session-details/new/capture/capture.tsx create mode 100644 ui/src/pages/session-details/new/star-button.tsx create mode 100644 ui/src/pages/session-details/new/utils.tsx diff --git a/ui/src/pages/session-details/new/capture-navigation.tsx b/ui/src/pages/session-details/new/capture-navigation.tsx new file mode 100644 index 000000000..050a86bb4 --- /dev/null +++ b/ui/src/pages/session-details/new/capture-navigation.tsx @@ -0,0 +1,112 @@ +import { CaptureDetails } from 'data-services/models/capture-details' +import { TimelineTick } from 'data-services/models/timeline-tick' +import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react' +import { Button } from 'nova-ui-kit' +import { useEffect, useState } from 'react' +import { STRING, translate } from 'utils/language' +import { findClosestCaptureId } from './utils' + +export const CaptureNavigation = ({ + activeCapture, + snapToDetections, + timeline, + setActiveCaptureId, +}: { + activeCapture?: CaptureDetails + snapToDetections?: boolean + timeline: TimelineTick[] + setActiveCaptureId: (captureId: string) => void +}) => { + const [currentIndex, setCurrentIndex] = useState(activeCapture?.currentIndex) + const [totalCaptures, setTotalCaptures] = useState( + activeCapture?.totalCaptures + ) + + useEffect(() => { + if (activeCapture) { + setCurrentIndex(activeCapture.currentIndex) + setTotalCaptures(activeCapture.totalCaptures) + } + }, [activeCapture]) + + const goToPrev = () => { + if (!activeCapture) { + return + } + + const prevCaptureId = snapToDetections + ? findClosestCaptureId({ + maxDate: activeCapture.date, + snapToDetections: true, + targetDate: activeCapture.date, + timeline, + }) + : activeCapture.prevCaptureId + + if (prevCaptureId) { + setActiveCaptureId(prevCaptureId) + } + } + + const goToNext = () => { + if (!activeCapture) { + return + } + + const nextCaptureId = snapToDetections + ? findClosestCaptureId({ + minDate: activeCapture.date, + snapToDetections: true, + targetDate: activeCapture.date, + timeline, + }) + : activeCapture.nextCaptureId + + if (nextCaptureId) { + setActiveCaptureId(nextCaptureId) + } + } + + // Listen to key down events + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'ArrowLeft') { + e.preventDefault() + goToPrev() + } else if (e.key === 'ArrowRight') { + e.preventDefault() + goToNext() + } + } + + document.addEventListener('keydown', onKeyDown) + + return () => document.removeEventListener('keydown', onKeyDown) + }, [goToPrev, goToNext]) + + return ( +
+ + + {currentIndex?.toLocaleString()} / {totalCaptures?.toLocaleString()} + + +
+ ) +} diff --git a/ui/src/pages/session-details/new/capture/capture.module.scss b/ui/src/pages/session-details/new/capture/capture.module.scss new file mode 100644 index 000000000..04ddb8334 --- /dev/null +++ b/ui/src/pages/session-details/new/capture/capture.module.scss @@ -0,0 +1,70 @@ +.wrapper { + position: relative; + width: 100%; + height: 0; +} + +.image, +.overlay, +.details, +.detections, +.loadingWrapper { + position: absolute; + width: 100%; + height: 100%; + vertical-align: middle; +} + +.details { + .overlay { + opacity: 0; + transition: opacity 250ms ease-in-out; + } + + &.showOverlay { + .overlay { + opacity: 1; + } + } +} + +.detection { + position: absolute; + box-sizing: border-box; + outline: 2px solid var(--color-success); + border-radius: 2px; + + &.warning { + outline-color: var(--color-warning); + } + + &.alert { + outline-color: var(--color-destructive); + } + + &.filtered { + outline: 2px solid var(--color-neutral-500); + } + + &.active { + outline: 2px solid var(--color-secondary-400); + } + + &.clickable { + &:hover { + cursor: pointer; + } + } +} + +.loadingWrapper { + display: flex; + align-items: center; + justify-content: center; +} + +@media only screen and (max-width: $breakpoint-md) { + .wrapper { + grid-column: span 2; + } +} diff --git a/ui/src/pages/session-details/new/capture/capture.tsx b/ui/src/pages/session-details/new/capture/capture.tsx new file mode 100644 index 000000000..d2c61fb7d --- /dev/null +++ b/ui/src/pages/session-details/new/capture/capture.tsx @@ -0,0 +1,318 @@ +import classNames from 'classnames' +import { DeterminationScore } from 'components/determination-score' +import { useOccurrenceDetails } from 'data-services/hooks/occurrences/useOccurrenceDetails' +import { CaptureDetection } from 'data-services/models/capture' +import { Dialog, LoadingSpinner, Tooltip } from 'nova-ui-kit' +import { + OccurrenceDetails, + TABS, +} from 'pages/occurrence-details/occurrence-details' +import { useLayoutEffect, useMemo, useRef, useState } from 'react' +import { SCORE_THRESHOLDS } from 'utils/constants' +import { STRING, translate } from 'utils/language' +import { useActiveOccurrences } from '../hooks/useActiveOccurrences' +import styles from './capture.module.scss' + +const FALLBACK_RATIO = 16 / 9 + +interface BoxStyle { + width: string + height: string + top: string + left: string +} + +interface CaptureProps { + defaultFilters: boolean + detections: CaptureDetection[] + height: number | null + showDetections?: boolean + src?: string + width: number | null +} + +export const Capture = ({ + defaultFilters, + detections, + height, + showDetections, + src, + width, +}: CaptureProps) => { + const [naturalSize, setNaturalSize] = useState<{ + width: number + height: number + }>() + const imageRef = useRef(null) + const [isLoading, setIsLoading] = useState() + const [renderOverlay, setRenderOverlay] = useState() + + useLayoutEffect(() => { + if (!imageRef.current) { + return + } + + setIsLoading(true) + setNaturalSize(undefined) + + if (src) { + imageRef.current.src = src + imageRef.current.onload = () => { + if (imageRef.current?.width && imageRef.current.height) { + setNaturalSize({ + width: imageRef.current.naturalWidth, + height: imageRef.current.naturalHeight, + }) + } + setIsLoading(false) + } + + imageRef.current.onerror = () => { + setNaturalSize(undefined) + setIsLoading(false) + } + } + }, [src]) + + useLayoutEffect(() => { + // Ugly hack to make overlay correct on first render + setRenderOverlay(true) + }, []) + + const boxStyles = useMemo( + () => + detections.reduce((result: { [key: string]: BoxStyle }, detection) => { + const [boxLeft, boxTop, boxRight, boxBottom] = detection.bbox + const boxWidth = boxRight - boxLeft + const boxHeight = boxBottom - boxTop + + const _width = naturalSize?.width ?? width + const _height = naturalSize?.height ?? height + + if (!_width || !_height) { + return result + } + + result[detection.id] = { + width: `${(boxWidth / _width) * 100}%`, + height: `${(boxHeight / _height) * 100}%`, + top: `${(boxTop / _height) * 100}%`, + left: `${(boxLeft / _width) * 100}%`, + } + + return result + }, {}), + [width, height, naturalSize, detections] + ) + + const ratio = useMemo(() => { + if (naturalSize) { + return naturalSize.width / naturalSize.height + } + + if (width && height) { + return width / height + } + + return FALLBACK_RATIO + }, [width, height, naturalSize]) + + return ( +
+ +
+ {renderOverlay && } + +
+ {isLoading && ( +
+ +
+ )} +
+ ) +} + +const CaptureOverlay = ({ + boxStyles, +}: { + boxStyles: { [key: number]: BoxStyle } +}) => ( + + + + + {Object.entries(boxStyles).map(([id, style]) => ( + + ))} + + + + +) + +const CaptureDetections = ({ + boxStyles, + defaultFilters, + detections, + showDetections, +}: { + boxStyles: { [key: number]: BoxStyle } + defaultFilters: boolean + detections: CaptureDetection[] + showDetections?: boolean +}) => { + const containerRef = useRef(null) + const [activeOccurrence, setActiveOccurrence] = useState() + const { activeOccurrences, setActiveOccurrences } = useActiveOccurrences() + + const toggleActiveState = (occurrenceId: string) => { + const isActive = activeOccurrences.includes(occurrenceId) + + if (isActive) { + setActiveOccurrences( + activeOccurrences.filter((occurrence) => occurrence !== occurrenceId) + ) + } else { + setActiveOccurrences([...activeOccurrences, occurrenceId]) + } + } + + return ( + <> +
+ {Object.entries(boxStyles).map(([id, style]) => { + const detection = detections.find((d) => d.id === id) + + const isActive = detection?.occurrenceId + ? activeOccurrences.includes(detection.occurrenceId) + : false + + if (!detection || (!showDetections && !isActive)) { + return null + } + + return ( + + + +
{ + if (detection.occurrenceId) { + toggleActiveState(detection?.occurrenceId) + } + }} + /> + + +
+ + +
+
+ + + ) + })} + {activeOccurrence ? ( + setActiveOccurrence(undefined)} + /> + ) : null} +
+ + ) +} + +const OccurrenceDetailsDialog = ({ + id, + onClose, +}: { + id: string + onClose: () => void +}) => { + const [selectedView, setSelectedView] = useState( + TABS.FIELDS + ) + const { occurrence, isLoading, error } = useOccurrenceDetails(id) + + return ( + { + if (!open) { + onClose() + } + }} + > + + {occurrence ? ( + + ) : null} + + + ) +} diff --git a/ui/src/pages/session-details/new/session-details.tsx b/ui/src/pages/session-details/new/session-details.tsx index fc75afd2a..afbf113a2 100644 --- a/ui/src/pages/session-details/new/session-details.tsx +++ b/ui/src/pages/session-details/new/session-details.tsx @@ -1,22 +1,29 @@ import { ErrorState } from 'components/error-state/error-state' +import { useCaptureDetails } from 'data-services/hooks/captures/useCaptureDetails' import { useSessionDetails } from 'data-services/hooks/sessions/useSessionDetails' +import { useSessionTimeline } from 'data-services/hooks/sessions/useSessionTimeline' +import { SessionDetails } from 'data-services/models/session-details' import { Box, LoadingSpinner, PageHeader, Tabs } from 'nova-ui-kit' -import { useContext, useEffect } from 'react' +import { useContext, useEffect, useState } from 'react' import { Helmet } from 'react-helmet-async' import { useParams } from 'react-router-dom' import { BreadcrumbContext } from 'utils/breadcrumbContext' import { STRING, translate } from 'utils/language' +import { useUser } from 'utils/user/userContext' +import { CaptureNavigation } from './capture-navigation' +import { Capture } from './capture/capture' import { useActiveCaptureId } from './hooks/useActiveCapture' import { useActiveOccurrences } from './hooks/useActiveOccurrences' import { SessionInfo } from './session-info' import { SessionPlots } from './session-plots' +import { StarButton } from './star-button' const TABS = { FIELDS: 'fields', CHARTS: 'charts', } -export const SessionDetails = () => { +export const SessionDetailsPage = () => { const { id } = useParams() const { setDetailBreadcrumb } = useContext(BreadcrumbContext) const { activeOccurrences } = useActiveOccurrences() @@ -59,35 +66,102 @@ export const SessionDetails = () => { isLoading={isLoading} tooltip={translate(STRING.TOOLTIP_SESSION)} /> -
- - - - - - - - - - -
- -
-
-
-
-
- <> + + + ) +} + +const Content = ({ session }: { session: SessionDetails }) => { + // Settings + const [poll, setPoll] = useState(false) + + // Data + const { projectId } = useParams() + const { user } = useUser() + const { activeCaptureId, setActiveCaptureId } = useActiveCaptureId( + session.firstCapture?.id + ) + const { capture: activeCapture } = useCaptureDetails({ + id: activeCaptureId as string, + poll, + projectId: projectId as string, + }) + const { timeline = [] } = useSessionTimeline(session.id) + + useEffect(() => { + // If the active capture has a job in progress, we want to poll the endpoint so we can show job updates + if (activeCapture?.hasJobInProgress) { + setPoll(true) + } else { + setPoll(false) + } + }, [activeCapture]) + + if (!session.firstCapture) { + return null + } + + return ( +
+ + + + + + + + + + +
+ +
+
+
+
+
+
+ +
+
+
+ {activeCapture ? ( + <> + + {activeCapture.dateTimeLabel} + + + + ) : null} +
+
+ +
+
- +
) } -export default SessionDetails +export default SessionDetailsPage diff --git a/ui/src/pages/session-details/new/star-button.tsx b/ui/src/pages/session-details/new/star-button.tsx new file mode 100644 index 000000000..3a25f6f85 --- /dev/null +++ b/ui/src/pages/session-details/new/star-button.tsx @@ -0,0 +1,44 @@ +import { useStarCapture } from 'data-services/hooks/captures/useStarCapture' +import { CaptureDetails as Capture } from 'data-services/models/capture-details' +import { Loader2Icon, StarIcon } from 'lucide-react' +import { BasicTooltip, Button } from 'nova-ui-kit' +import { STRING, translate } from 'utils/language' + +export const StarButton = ({ + capture, + canStar, +}: { + capture: Capture + canStar: boolean +}) => { + const isStarred = capture.isStarred ?? false + const { starCapture, isLoading } = useStarCapture(capture.id, isStarred) + const tooltipContent = canStar + ? isStarred + ? translate(STRING.STARRED) + : translate(STRING.STAR) + : translate(STRING.MESSAGE_PERMISSIONS_MISSING) + + return ( + + + + ) +} diff --git a/ui/src/pages/session-details/new/utils.tsx b/ui/src/pages/session-details/new/utils.tsx new file mode 100644 index 000000000..137c5fc5a --- /dev/null +++ b/ui/src/pages/session-details/new/utils.tsx @@ -0,0 +1,81 @@ +import { TimelineTick } from 'data-services/models/timeline-tick' + +export const findClosestCaptureId = ({ + maxDate, + minDate, + snapToDetections, + targetDate, + timeline, +}: { + maxDate?: Date + minDate?: Date + snapToDetections?: boolean + targetDate: Date + timeline: TimelineTick[] +}) => { + let closestCaptureId: string | undefined + let smallestDifference = Infinity + + timeline.forEach((timelineTick) => { + if (!timelineTick.representativeCaptureId) { + return + } + + if (snapToDetections && !timelineTick.numDetections) { + return + } + + if (minDate && timelineTick.startDate <= minDate) { + return + } + + if (maxDate && timelineTick.endDate >= maxDate) { + return + } + + const difference = Math.abs( + timelineTick.startDate.getTime() - targetDate.getTime() + ) + + if (difference < smallestDifference) { + smallestDifference = difference + closestCaptureId = timelineTick.representativeCaptureId + } + }) + + return closestCaptureId +} + +export const dateToValue = ({ + date, + startDate, + endDate, +}: { + date: Date + startDate: Date + endDate: Date +}) => { + if (endDate.getTime() === startDate.getTime()) { + return 50 + } + + return ( + ((date.getTime() - startDate.getTime()) / + (endDate.getTime() - startDate.getTime())) * + 100 + ) +} + +export const valueToDate = ({ + value, + startDate, + endDate, +}: { + value: number + startDate: Date + endDate: Date +}) => + new Date( + startDate.getTime() + + ((endDate.getTime() - startDate.getTime()) * value) / 100 + ) From 4f2be11ba4f2711a6770ffd837d29d6c8151e4d9 Mon Sep 17 00:00:00 2001 From: Anna Viklund Date: Thu, 28 May 2026 12:58:17 +0200 Subject: [PATCH 03/11] layout: render activity plot and timeline slider + cleanup --- ui/src/nova-ui-kit/components/slider/dial.svg | 3 - .../components/slider/timestamp-slider.tsx | 46 ----- .../new/activity-plot/activity-plot.tsx | 178 ++++++++++++++++++ .../new/activity-plot/lazy-activity-plot.tsx | 20 ++ .../new/activity-plot/useDynamicPlotWidth.ts | 25 +++ .../session-details/new/session-details.tsx | 123 ++++++------ .../new/timeline-slider}/styles.module.scss | 11 +- .../new/timeline-slider/timeline-slider.tsx | 107 +++++++++++ 8 files changed, 406 insertions(+), 107 deletions(-) delete mode 100644 ui/src/nova-ui-kit/components/slider/dial.svg delete mode 100644 ui/src/nova-ui-kit/components/slider/timestamp-slider.tsx create mode 100644 ui/src/pages/session-details/new/activity-plot/activity-plot.tsx create mode 100644 ui/src/pages/session-details/new/activity-plot/lazy-activity-plot.tsx create mode 100644 ui/src/pages/session-details/new/activity-plot/useDynamicPlotWidth.ts rename ui/src/{nova-ui-kit/components/slider => pages/session-details/new/timeline-slider}/styles.module.scss (80%) create mode 100644 ui/src/pages/session-details/new/timeline-slider/timeline-slider.tsx diff --git a/ui/src/nova-ui-kit/components/slider/dial.svg b/ui/src/nova-ui-kit/components/slider/dial.svg deleted file mode 100644 index 69dacc0d0..000000000 --- a/ui/src/nova-ui-kit/components/slider/dial.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/ui/src/nova-ui-kit/components/slider/timestamp-slider.tsx b/ui/src/nova-ui-kit/components/slider/timestamp-slider.tsx deleted file mode 100644 index 4529fde61..000000000 --- a/ui/src/nova-ui-kit/components/slider/timestamp-slider.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import * as _Slider from '@radix-ui/react-slider' -import Dial from './dial.svg?react' -import styles from './styles.module.scss' - -interface TimestampSliderProps { - labels: string[] - value: number - valueLabel?: string - onValueChange: (value: number) => void - onValueCommit: (value: number) => void -} - -export const TimestampSlider = ({ - labels, - value, - valueLabel, - onValueChange, - onValueCommit, -}: TimestampSliderProps) => ( -
- <_Slider.Root - className={styles.sliderRoot} - min={0} - max={100} - step={0.01} - value={[value]} - onValueChange={(values) => onValueChange(values[0])} - onValueCommit={(values) => onValueCommit(values[0])} - > - <_Slider.Track className={styles.sliderTrack}> - <_Slider.Range className={styles.sliderRange} /> - - <_Slider.Thumb className={styles.sliderThumb}> - {valueLabel && {valueLabel}} - - - -
- {labels.map((label, index) => ( - - {label} - - ))} -
-
-) diff --git a/ui/src/pages/session-details/new/activity-plot/activity-plot.tsx b/ui/src/pages/session-details/new/activity-plot/activity-plot.tsx new file mode 100644 index 000000000..4d384fc18 --- /dev/null +++ b/ui/src/pages/session-details/new/activity-plot/activity-plot.tsx @@ -0,0 +1,178 @@ +import { SessionDetails } from 'data-services/models/session-details' +import { TimelineTick } from 'data-services/models/timeline-tick' +import { CONSTANTS } from 'nova-ui-kit' +import { useRef } from 'react' +import Plot from 'react-plotly.js' +import { getCompactTimespanString } from 'utils/date/getCompactTimespanString/getCompactTimespanString' +import { findClosestCaptureId } from '../utils' +import { useDynamicPlotWidth } from './useDynamicPlotWidth' + +const fontFamily = 'Mazzard' +const fontSize = 14 +const lineColorCaptures = CONSTANTS.COLORS.neutral[300] +const lineColorDetections = CONSTANTS.COLOR_THEME.secondary.DEFAULT +const spikeColor = CONSTANTS.COLOR_THEME.foreground +const textColor = CONSTANTS.COLOR_THEME.foreground +const tooltipBgColor = CONSTANTS.COLOR_THEME.background +const tooltipBorderColor = CONSTANTS.COLOR_THEME.border + +export interface ActivityPlotProps { + session: SessionDetails + setActiveCaptureId: (captureId: string) => void + snapToDetections?: boolean + timeline: TimelineTick[] +} + +const ActivityPlot = ({ + session, + snapToDetections, + timeline, + setActiveCaptureId, +}: ActivityPlotProps) => { + const containerRef = useRef(null) + const width = useDynamicPlotWidth(containerRef) + + // Calculate the average number of captures + const avgCaptures = + timeline.reduce((sum, tick) => sum + tick.numCaptures, 0) / timeline.length + + // Calculate the maximum deviation from the average + const maxDeviation = Math.max( + ...timeline.map((tick) => Math.abs(tick.numCaptures - avgCaptures)) + ) + + // Set the y-axis range to be centered around the average + const yAxisMin = Math.max(0, avgCaptures - maxDeviation) + const yAxisMax = avgCaptures + maxDeviation + + return ( +
+
+ new Date(timelineTick.startDate) + ), + y: timeline.map((timelineTick) => timelineTick.numCaptures), + hovertemplate: 'Captures: %{y}', + fill: 'tozeroy', + type: 'scatter', + mode: 'lines', + line: { color: lineColorCaptures, width: 1 }, + name: 'Captures', + yaxis: 'y', + }, + { + x: timeline.map( + (timelineTick) => new Date(timelineTick.startDate) + ), + y: timeline.map((timelineTick) => timelineTick.avgDetections), + hovertemplate: 'Avg. detections: %{y}', + fill: 'tozeroy', + type: 'scatter', + mode: 'lines', + line: { color: lineColorDetections, width: 1 }, + name: 'Avg. detections', + yaxis: 'y2', + }, + ]} + layout={{ + height: 100, + width: width, + paper_bgcolor: 'transparent', + plot_bgcolor: 'transparent', + margin: { + l: 0, + r: 0, + b: 0, + t: 0, + pad: 0, + }, + hovermode: 'x unified', + // y-axis for captures + yaxis: { + showgrid: false, + showticklabels: false, + zeroline: false, + rangemode: 'nonnegative', + fixedrange: true, + range: [yAxisMin, yAxisMax], + side: 'left', + }, + // y-axis for detections + yaxis2: { + showgrid: false, + showticklabels: false, + zeroline: false, + rangemode: 'nonnegative', + fixedrange: true, + range: [0, Math.max(session.detectionsMaxCount ?? 0, 1)], // Ensure a minimum range of 1 + side: 'right', + overlaying: 'y', + }, + xaxis: { + fixedrange: true, + range: [new Date(session.startDate), new Date(session.endDate)], + showgrid: false, + showline: false, + showticklabels: false, + spikecolor: spikeColor, + spikethickness: -2, + ticktext: timeline.map((timelineTick) => + getCompactTimespanString({ + date1: timelineTick.startDate, + date2: timelineTick.endDate, + options: { + second: true, + }, + }) + ), + tickvals: timeline.map( + (timelineTick) => new Date(timelineTick.startDate) + ), + zeroline: false, + }, + hoverlabel: { + bgcolor: tooltipBgColor, + bordercolor: tooltipBorderColor, + font: { + family: fontFamily, + size: fontSize, + color: textColor, + }, + }, + showlegend: false, + }} + config={{ + displayModeBar: false, + }} + onClick={(data) => { + const timelineTickIndex = data.points[0].pointIndex + const timelineTick = timeline[timelineTickIndex] + + if (!timelineTick) { + return + } + + const captureId = + snapToDetections || !timelineTick.representativeCaptureId + ? findClosestCaptureId({ + snapToDetections, + timeline, + targetDate: timelineTick.startDate, + }) + : timelineTick.representativeCaptureId + + if (captureId) { + setActiveCaptureId(captureId) + } + }} + /> +
+
+ ) +} + +export default ActivityPlot diff --git a/ui/src/pages/session-details/new/activity-plot/lazy-activity-plot.tsx b/ui/src/pages/session-details/new/activity-plot/lazy-activity-plot.tsx new file mode 100644 index 000000000..f0de0a6d8 --- /dev/null +++ b/ui/src/pages/session-details/new/activity-plot/lazy-activity-plot.tsx @@ -0,0 +1,20 @@ +import { ErrorBoundary } from 'components/error-boundary/error-boundary' +import { LoadingSpinner } from 'nova-ui-kit' +import React, { Suspense } from 'react' +import { ActivityPlotProps } from './activity-plot' + +const _ActivityPlot = React.lazy(() => import('./activity-plot')) + +export const ActivityPlot = (props: ActivityPlotProps) => ( + + +
+ } + > + + <_ActivityPlot {...props} /> + + +) diff --git a/ui/src/pages/session-details/new/activity-plot/useDynamicPlotWidth.ts b/ui/src/pages/session-details/new/activity-plot/useDynamicPlotWidth.ts new file mode 100644 index 000000000..4fb19bdde --- /dev/null +++ b/ui/src/pages/session-details/new/activity-plot/useDynamicPlotWidth.ts @@ -0,0 +1,25 @@ +import { RefObject, useEffect, useState } from 'react' + +export const useDynamicPlotWidth = (containerRef: RefObject) => { + const [width, setWidth] = useState() + + useEffect(() => { + const updateState = () => { + const container = containerRef.current + + if (container) { + setWidth(container.clientWidth) + } + } + + updateState() + + window.addEventListener('resize', updateState) + + return () => { + window.removeEventListener('resize', updateState) + } + }, [containerRef]) + + return width +} diff --git a/ui/src/pages/session-details/new/session-details.tsx b/ui/src/pages/session-details/new/session-details.tsx index afbf113a2..3dbc2e362 100644 --- a/ui/src/pages/session-details/new/session-details.tsx +++ b/ui/src/pages/session-details/new/session-details.tsx @@ -10,6 +10,7 @@ import { useParams } from 'react-router-dom' import { BreadcrumbContext } from 'utils/breadcrumbContext' import { STRING, translate } from 'utils/language' import { useUser } from 'utils/user/userContext' +import ActivityPlot from './activity-plot/activity-plot' import { CaptureNavigation } from './capture-navigation' import { Capture } from './capture/capture' import { useActiveCaptureId } from './hooks/useActiveCapture' @@ -17,6 +18,7 @@ import { useActiveOccurrences } from './hooks/useActiveOccurrences' import { SessionInfo } from './session-info' import { SessionPlots } from './session-plots' import { StarButton } from './star-button' +import { TimelineSlider } from './timeline-slider/timeline-slider' const TABS = { FIELDS: 'fields', @@ -102,64 +104,79 @@ const Content = ({ session }: { session: SessionDetails }) => { } return ( -
- - - - +
+ + + + + + + + + + +
+ +
+
+
+
+
+
+ - - - - - - -
- -
-
- - -
-
- -
-
-
- {activeCapture ? ( - <> - - {activeCapture.dateTimeLabel} - - - - ) : null}
-
- +
+
+ {activeCapture ? ( + <> + + {activeCapture.dateTimeLabel} + + + + ) : null} +
+
+ +
+
-
+
+ + +
) } diff --git a/ui/src/nova-ui-kit/components/slider/styles.module.scss b/ui/src/pages/session-details/new/timeline-slider/styles.module.scss similarity index 80% rename from ui/src/nova-ui-kit/components/slider/styles.module.scss rename to ui/src/pages/session-details/new/timeline-slider/styles.module.scss index 64abd5025..43d161cf5 100644 --- a/ui/src/nova-ui-kit/components/slider/styles.module.scss +++ b/ui/src/pages/session-details/new/timeline-slider/styles.module.scss @@ -5,9 +5,9 @@ .label { display: block; - @include body-small(); + @include body-overline(); font-weight: 600; - color: var(--color-generic-white); + color: var(--color-primary); text-transform: uppercase; white-space: nowrap; } @@ -17,7 +17,8 @@ justify-content: space-between; .label { - color: var(--color-neutral-500); + @include body-overline-small(); + color: var(--color-muted-foreground); } } @@ -36,7 +37,7 @@ .sliderTrack { position: relative; - background-color: var(--color-neutral-400); + background-color: var(--color-neutral-300); flex-grow: 1; border-radius: 2px; height: 4px; @@ -44,7 +45,7 @@ .sliderRange { position: absolute; - background-color: var(--color-neutral-400); + background-color: var(--color-neutral-300); border-radius: 2px; height: 100%; } diff --git a/ui/src/pages/session-details/new/timeline-slider/timeline-slider.tsx b/ui/src/pages/session-details/new/timeline-slider/timeline-slider.tsx new file mode 100644 index 000000000..5b67c7fc8 --- /dev/null +++ b/ui/src/pages/session-details/new/timeline-slider/timeline-slider.tsx @@ -0,0 +1,107 @@ +import * as _Slider from '@radix-ui/react-slider' +import { Capture } from 'data-services/models/capture' +import { SessionDetails } from 'data-services/models/session-details' +import { TimelineTick } from 'data-services/models/timeline-tick' +import { TriangleIcon } from 'lucide-react' +import { useEffect, useState } from 'react' +import { getFormatedTimeString } from 'utils/date/getFormatedTimeString/getFormatedTimeString' +import { dateToValue, findClosestCaptureId, valueToDate } from '../utils' +import styles from './styles.module.scss' + +export const TimelineSlider = ({ + activeCapture, + session, + setActiveCaptureId, + snapToDetections, + timeline, +}: { + activeCapture?: Capture + session: SessionDetails + setActiveCaptureId: (captireId: string) => void + snapToDetections?: boolean + timeline: TimelineTick[] +}) => { + const [value, setValue] = useState(0) + const startDate = session.startDate + const endDate = session.endDate + const showLabels = session.startDate.getTime() !== session.endDate.getTime() + + useEffect(() => { + if (activeCapture) { + setValue(dateToValue({ date: activeCapture.date, startDate, endDate })) + } + }, [activeCapture]) + + return ( + setValue(value)} + onValueCommit={(value) => { + // Update active capture based on date + const targetDate = valueToDate({ value, startDate, endDate }) + const captureId = findClosestCaptureId({ + snapToDetections, + targetDate, + timeline, + }) + + if (captureId && activeCapture?.id !== captureId) { + setActiveCaptureId(captureId) + } else if (activeCapture) { + setValue( + dateToValue({ date: activeCapture.date, startDate, endDate }) + ) + } + }} + /> + ) +} + +const Slider = ({ + labels, + value, + valueLabel, + onValueChange, + onValueCommit, +}: { + labels: string[] + value: number + valueLabel?: string + onValueChange: (value: number) => void + onValueCommit: (value: number) => void +}) => ( +
+ <_Slider.Root + className={styles.sliderRoot} + min={0} + max={100} + step={0.01} + value={[value]} + onValueChange={(values) => onValueChange(values[0])} + onValueCommit={(values) => onValueCommit(values[0])} + > + <_Slider.Track className={styles.sliderTrack}> + <_Slider.Range className={styles.sliderRange} /> + + <_Slider.Thumb className={styles.sliderThumb}> + {valueLabel && {valueLabel}} + + + +
+ {labels.map((label, index) => ( + + {label} + + ))} +
+
+) From 1c18ca65b6231918beb63fa749c34f82eef491dd Mon Sep 17 00:00:00 2001 From: Anna Viklund Date: Fri, 29 May 2026 10:32:46 +0200 Subject: [PATCH 04/11] layout: include capture info --- .../components/info-block/info-block.tsx | 2 +- .../session-details/new/capture-info.tsx | 47 ++++++++++++++++ .../session-details/new/session-details.tsx | 53 ++++++++++++++++--- 3 files changed, 93 insertions(+), 9 deletions(-) create mode 100644 ui/src/pages/session-details/new/capture-info.tsx diff --git a/ui/src/nova-ui-kit/components/info-block/info-block.tsx b/ui/src/nova-ui-kit/components/info-block/info-block.tsx index 3ae84f52e..b08162e48 100644 --- a/ui/src/nova-ui-kit/components/info-block/info-block.tsx +++ b/ui/src/nova-ui-kit/components/info-block/info-block.tsx @@ -62,7 +62,7 @@ export const InfoBlockFieldValue = ({ ) : ( - {valueLabel} + {valueLabel} )} ) diff --git a/ui/src/pages/session-details/new/capture-info.tsx b/ui/src/pages/session-details/new/capture-info.tsx new file mode 100644 index 000000000..30638bd65 --- /dev/null +++ b/ui/src/pages/session-details/new/capture-info.tsx @@ -0,0 +1,47 @@ +import { CaptureDetails } from 'data-services/models/capture-details' +import { InfoBlock } from 'nova-ui-kit' +import { useParams } from 'react-router-dom' +import { APP_ROUTES } from 'utils/constants' +import { getAppRoute } from 'utils/getAppRoute' +import { STRING, translate } from 'utils/language' + +export const CaptureInfo = ({ capture }: { capture: CaptureDetails }) => { + const { projectId } = useParams() + + const fields = [ + { + label: translate(STRING.FIELD_LABEL_ID), + value: capture.id, + }, + { + label: translate(STRING.FIELD_LABEL_FILE_SIZE), + value: capture.fileSize, + }, + { + label: translate(STRING.FIELD_LABEL_RESOLUTION), + value: capture.dimensionsLabel, + }, + { + label: translate(STRING.FIELD_LABEL_FILENAME), + value: capture.filename, + }, + { + label: translate(STRING.FIELD_LABEL_PATH), + value: capture.path, + }, + { + label: translate(STRING.FIELD_LABEL_OCCURRENCES), + value: capture.numOccurrences, + to: getAppRoute({ + to: APP_ROUTES.OCCURRENCES({ projectId: projectId as string }), + filters: { detections__source_image: capture.id }, + }), + }, + { + label: translate(STRING.FIELD_LABEL_TAXA), + value: capture.numTaxa, + }, + ] + + return +} diff --git a/ui/src/pages/session-details/new/session-details.tsx b/ui/src/pages/session-details/new/session-details.tsx index 3dbc2e362..ce3b958d5 100644 --- a/ui/src/pages/session-details/new/session-details.tsx +++ b/ui/src/pages/session-details/new/session-details.tsx @@ -3,7 +3,16 @@ import { useCaptureDetails } from 'data-services/hooks/captures/useCaptureDetail import { useSessionDetails } from 'data-services/hooks/sessions/useSessionDetails' import { useSessionTimeline } from 'data-services/hooks/sessions/useSessionTimeline' import { SessionDetails } from 'data-services/models/session-details' -import { Box, LoadingSpinner, PageHeader, Tabs } from 'nova-ui-kit' +import { ExternalLinkIcon } from 'lucide-react' +import { + BasicTooltip, + Box, + buttonVariants, + LoadingSpinner, + PageHeader, + Tabs, +} from 'nova-ui-kit' +import { cn } from 'nova-ui-kit/utils' import { useContext, useEffect, useState } from 'react' import { Helmet } from 'react-helmet-async' import { useParams } from 'react-router-dom' @@ -11,6 +20,7 @@ import { BreadcrumbContext } from 'utils/breadcrumbContext' import { STRING, translate } from 'utils/language' import { useUser } from 'utils/user/userContext' import ActivityPlot from './activity-plot/activity-plot' +import { CaptureInfo } from './capture-info' import { CaptureNavigation } from './capture-navigation' import { Capture } from './capture/capture' import { useActiveCaptureId } from './hooks/useActiveCapture' @@ -21,7 +31,8 @@ import { StarButton } from './star-button' import { TimelineSlider } from './timeline-slider/timeline-slider' const TABS = { - FIELDS: 'fields', + SESSION: 'session', + CAPTURE: 'capture', CHARTS: 'charts', } @@ -106,20 +117,31 @@ const Content = ({ session }: { session: SessionDetails }) => { return (
- - + + + - - + +
+ +
+
+ +
+ {activeCapture ? : null} +
@@ -150,6 +172,21 @@ const Content = ({ session }: { session: SessionDetails }) => { capture={activeCapture} canStar={user.loggedIn && activeCapture.canStar} /> + + + + + ) : null}
From 16c6d5942379e59be705c5370ece5ad393f80bfb Mon Sep 17 00:00:00 2001 From: Anna Viklund Date: Fri, 29 May 2026 11:15:16 +0200 Subject: [PATCH 05/11] layout: setup view settings --- .../filtering/default-filter-control.tsx | 9 +- .../components/checkbox/checkbox.module.scss | 16 ---- .../components/checkbox/checkbox.tsx | 16 +--- .../column-settings.module.scss | 20 ----- .../table/column-settings/column-settings.tsx | 45 +++++----- ui/src/nova-ui-kit/index.ts | 2 +- .../session-details/new/capture/capture.tsx | 2 +- .../session-details/new/session-details.tsx | 19 +++- .../session-details/new/view-settings.tsx | 89 +++++++++++++++++++ .../session-details/playback/playback.tsx | 12 +-- 10 files changed, 132 insertions(+), 98 deletions(-) delete mode 100644 ui/src/nova-ui-kit/components/table/column-settings/column-settings.module.scss create mode 100644 ui/src/pages/session-details/new/view-settings.tsx diff --git a/ui/src/components/filtering/default-filter-control.tsx b/ui/src/components/filtering/default-filter-control.tsx index 888d71efa..ef8673c5e 100644 --- a/ui/src/components/filtering/default-filter-control.tsx +++ b/ui/src/components/filtering/default-filter-control.tsx @@ -49,20 +49,13 @@ export const DefaultFiltersControl = ({ field }: { field: string }) => { } export const DefaultFiltersTooltip = ({ - className, project, }: { - className?: string project: ProjectDetails }) => ( - diff --git a/ui/src/nova-ui-kit/components/checkbox/checkbox.module.scss b/ui/src/nova-ui-kit/components/checkbox/checkbox.module.scss index c63648fcd..d72cddbeb 100644 --- a/ui/src/nova-ui-kit/components/checkbox/checkbox.module.scss +++ b/ui/src/nova-ui-kit/components/checkbox/checkbox.module.scss @@ -14,10 +14,6 @@ border: 1px solid var(--color-border); flex-shrink: 0; - &.neutral { - border-color: var(--color-neutral-400); - } - &:focus-visible { box-shadow: 0 0 0 2px var(--color-generic-black); } @@ -50,18 +46,6 @@ color: var(--color-muted-foreground); margin-left: 6px; - &.success { - color: var(--color-success); - } - - &.alert { - color: var(--color-accent); - } - - &.neutral { - color: var(--color-generic-white); - } - &.disabled { opacity: 0.5; } diff --git a/ui/src/nova-ui-kit/components/checkbox/checkbox.tsx b/ui/src/nova-ui-kit/components/checkbox/checkbox.tsx index 4d8ca6961..5a5276e54 100644 --- a/ui/src/nova-ui-kit/components/checkbox/checkbox.tsx +++ b/ui/src/nova-ui-kit/components/checkbox/checkbox.tsx @@ -3,19 +3,11 @@ import classNames from 'classnames' import { CheckIcon, MinusIcon } from 'lucide-react' import styles from './checkbox.module.scss' -export enum CheckboxTheme { - Default = 'default', - Success = 'success', - Alert = 'alert', - Neutral = 'neutral', -} - interface CheckboxProps { checked: boolean | 'indeterminate' disabled?: boolean id?: string label?: string - theme?: CheckboxTheme onCheckedChange?: (checked: boolean) => void } @@ -24,15 +16,12 @@ export const Checkbox = ({ disabled, id, label, - theme = CheckboxTheme.Default, onCheckedChange, }: CheckboxProps) => (
<_Checkbox.Root checked={checked} - className={classNames(styles.checkboxRoot, { - [styles.neutral]: theme === CheckboxTheme.Neutral, - })} + className={styles.checkboxRoot} disabled={disabled} id={id} onCheckedChange={onCheckedChange} @@ -48,9 +37,6 @@ export const Checkbox = ({