mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-04-05 22:57:40 +03:00
Compare commits
2 Commits
62bc2aeaab
...
dbbe40bd27
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dbbe40bd27 | ||
|
|
901002a0a5 |
@ -13,6 +13,8 @@
|
|||||||
},
|
},
|
||||||
"timeline": "Timeline",
|
"timeline": "Timeline",
|
||||||
"timeline.aria": "Select timeline",
|
"timeline.aria": "Select timeline",
|
||||||
|
"zoomIn": "Zoom In",
|
||||||
|
"zoomOut": "Zoom Out",
|
||||||
"events": {
|
"events": {
|
||||||
"label": "Events",
|
"label": "Events",
|
||||||
"aria": "Select events",
|
"aria": "Select events",
|
||||||
@ -24,7 +26,12 @@
|
|||||||
"aria": "Toggle detail view",
|
"aria": "Toggle detail view",
|
||||||
"trackedObject_one": "object",
|
"trackedObject_one": "object",
|
||||||
"trackedObject_other": "objects",
|
"trackedObject_other": "objects",
|
||||||
"noObjectDetailData": "No object detail data available."
|
"noObjectDetailData": "No object detail data available.",
|
||||||
|
"settings": "Detail View Settings",
|
||||||
|
"alwaysExpandActive": {
|
||||||
|
"title": "Always expand active",
|
||||||
|
"desc": "Always expand the active review item's object details when available."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"objectTrack": {
|
"objectTrack": {
|
||||||
"trackedPoint": "Tracked point",
|
"trackedPoint": "Tracked point",
|
||||||
|
|||||||
@ -71,7 +71,7 @@
|
|||||||
},
|
},
|
||||||
"offset": {
|
"offset": {
|
||||||
"label": "Annotation Offset",
|
"label": "Annotation Offset",
|
||||||
"desc": "This data comes from your camera's detect feed but is overlayed on images from the the record feed. It is unlikely that the two streams are perfectly in sync. As a result, the bounding box and the footage will not line up perfectly. However, the <code>annotation_offset</code> field can be used to adjust this.",
|
"desc": "This data comes from your camera's detect feed but is overlayed on images from the the record feed. It is unlikely that the two streams are perfectly in sync. As a result, the bounding box and the footage will not line up perfectly. You can use this setting to offset the annotations forward or backward in time to better align them with the recorded footage.",
|
||||||
"millisecondsToOffset": "Milliseconds to offset detect annotations by. <em>Default: 0</em>",
|
"millisecondsToOffset": "Milliseconds to offset detect annotations by. <em>Default: 0</em>",
|
||||||
"tips": "TIP: Imagine there is an event clip with a person walking from left to right. If the event timeline bounding box is consistently to the left of the person then the value should be decreased. Similarly, if a person is walking from left to right and the bounding box is consistently ahead of the person then the value should be increased.",
|
"tips": "TIP: Imagine there is an event clip with a person walking from left to right. If the event timeline bounding box is consistently to the left of the person then the value should be decreased. Similarly, if a person is walking from left to right and the bounding box is consistently ahead of the person then the value should be increased.",
|
||||||
"toast": {
|
"toast": {
|
||||||
|
|||||||
@ -1,11 +1,15 @@
|
|||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { Slider } from "@/components/ui/slider";
|
import { Slider } from "@/components/ui/slider";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "../../ui/popover";
|
||||||
import { useDetailStream } from "@/context/detail-stream-context";
|
import { useDetailStream } from "@/context/detail-stream-context";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { useSWRConfig } from "swr";
|
import { useSWRConfig } from "swr";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
|
import { LuInfo } from "react-icons/lu";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { isMobile } from "react-device-detect";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
className?: string;
|
className?: string;
|
||||||
@ -65,30 +69,67 @@ export default function AnnotationOffsetSlider({ className }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`absolute bottom-0 left-0 right-0 z-30 flex items-center gap-3 bg-background p-3 ${className ?? ""}`}
|
className={cn(
|
||||||
style={{ pointerEvents: "auto" }}
|
"flex flex-col gap-0.5",
|
||||||
|
isMobile && "landscape:gap-3",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<div className="w-56 text-sm">
|
<div
|
||||||
Annotation offset (ms): {annotationOffset}
|
className={cn(
|
||||||
|
"flex items-center gap-3",
|
||||||
|
isMobile &&
|
||||||
|
"landscape:flex-col landscape:items-start landscape:gap-4",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex max-w-28 flex-row items-center gap-2 text-sm md:max-w-48">
|
||||||
|
<span className="max-w-24 md:max-w-44">
|
||||||
|
{t("trackingDetails.annotationSettings.offset.label")}:
|
||||||
|
</span>
|
||||||
|
<span className="text-primary-variant">{annotationOffset}</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full flex-1 landscape:flex">
|
||||||
|
<Slider
|
||||||
|
value={[annotationOffset]}
|
||||||
|
min={-1500}
|
||||||
|
max={1500}
|
||||||
|
step={50}
|
||||||
|
onValueChange={handleChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button size="sm" variant="ghost" onClick={reset}>
|
||||||
|
{t("button.reset", { ns: "common" })}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={save} disabled={isSaving}>
|
||||||
|
{isSaving
|
||||||
|
? t("button.saving", { ns: "common" })
|
||||||
|
: t("button.save", { ns: "common" })}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div
|
||||||
<Slider
|
className={cn(
|
||||||
value={[annotationOffset]}
|
"flex items-center gap-2 text-xs text-muted-foreground",
|
||||||
min={-1500}
|
isMobile && "landscape:flex-col landscape:items-start",
|
||||||
max={1500}
|
)}
|
||||||
step={50}
|
>
|
||||||
onValueChange={handleChange}
|
<Trans ns="views/explore">
|
||||||
/>
|
trackingDetails.annotationSettings.offset.millisecondsToOffset
|
||||||
</div>
|
</Trans>
|
||||||
<div className="flex items-center gap-2">
|
<Popover>
|
||||||
<Button size="sm" variant="ghost" onClick={reset}>
|
<PopoverTrigger asChild>
|
||||||
Reset
|
<button
|
||||||
</Button>
|
className="focus:outline-none"
|
||||||
<Button size="sm" onClick={save} disabled={isSaving}>
|
aria-label={t("trackingDetails.annotationSettings.offset.desc")}
|
||||||
{isSaving
|
>
|
||||||
? t("button.saving", { ns: "common" })
|
<LuInfo className="size-4" />
|
||||||
: t("button.save", { ns: "common" })}
|
</button>
|
||||||
</Button>
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-80 text-sm">
|
||||||
|
{t("trackingDetails.annotationSettings.offset.desc")}
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -16,13 +16,21 @@ import ActivityIndicator from "../indicators/activity-indicator";
|
|||||||
import { Event } from "@/types/event";
|
import { Event } from "@/types/event";
|
||||||
import { getIconForLabel } from "@/utils/iconUtil";
|
import { getIconForLabel } from "@/utils/iconUtil";
|
||||||
import { ReviewSegment } from "@/types/review";
|
import { ReviewSegment } from "@/types/review";
|
||||||
import { LuChevronDown, LuCircle, LuChevronRight } from "react-icons/lu";
|
import {
|
||||||
|
LuChevronDown,
|
||||||
|
LuCircle,
|
||||||
|
LuChevronRight,
|
||||||
|
LuSettings,
|
||||||
|
} from "react-icons/lu";
|
||||||
import { getTranslatedLabel } from "@/utils/i18n";
|
import { getTranslatedLabel } from "@/utils/i18n";
|
||||||
import EventMenu from "@/components/timeline/EventMenu";
|
import EventMenu from "@/components/timeline/EventMenu";
|
||||||
import { FrigatePlusDialog } from "@/components/overlay/dialog/FrigatePlusDialog";
|
import { FrigatePlusDialog } from "@/components/overlay/dialog/FrigatePlusDialog";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { usePersistence } from "@/hooks/use-persistence";
|
||||||
|
import { isDesktop } from "react-device-detect";
|
||||||
|
|
||||||
type DetailStreamProps = {
|
type DetailStreamProps = {
|
||||||
reviewItems?: ReviewSegment[];
|
reviewItems?: ReviewSegment[];
|
||||||
@ -51,6 +59,11 @@ export default function DetailStream({
|
|||||||
|
|
||||||
const effectiveTime = currentTime + annotationOffset / 1000;
|
const effectiveTime = currentTime + annotationOffset / 1000;
|
||||||
const [upload, setUpload] = useState<Event | undefined>(undefined);
|
const [upload, setUpload] = useState<Event | undefined>(undefined);
|
||||||
|
const [controlsExpanded, setControlsExpanded] = useState(false);
|
||||||
|
const [alwaysExpandActive, setAlwaysExpandActive] = usePersistence(
|
||||||
|
"detailStreamActiveExpanded",
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
const onSeekCheckPlaying = (timestamp: number) => {
|
const onSeekCheckPlaying = (timestamp: number) => {
|
||||||
onSeek(timestamp, isPlaying);
|
onSeek(timestamp, isPlaying);
|
||||||
@ -168,7 +181,7 @@ export default function DetailStream({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<>
|
||||||
<FrigatePlusDialog
|
<FrigatePlusDialog
|
||||||
upload={upload}
|
upload={upload}
|
||||||
onClose={() => setUpload(undefined)}
|
onClose={() => setUpload(undefined)}
|
||||||
@ -179,38 +192,80 @@ export default function DetailStream({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div className="relative flex h-full flex-col">
|
||||||
ref={scrollRef}
|
<div
|
||||||
className="scrollbar-container h-[calc(100vh-70px)] overflow-y-auto"
|
ref={scrollRef}
|
||||||
>
|
className="scrollbar-container flex-1 overflow-y-auto pb-14"
|
||||||
<div className="space-y-4 py-2">
|
>
|
||||||
{reviewItems?.length === 0 ? (
|
<div className="space-y-4 py-2">
|
||||||
<div className="py-8 text-center text-muted-foreground">
|
{reviewItems?.length === 0 ? (
|
||||||
{t("detail.noDataFound")}
|
<div className="py-8 text-center text-muted-foreground">
|
||||||
|
{t("detail.noDataFound")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
reviewItems?.map((review: ReviewSegment) => {
|
||||||
|
const id = `review-${review.id ?? review.start_time ?? Math.floor(review.start_time ?? 0)}`;
|
||||||
|
return (
|
||||||
|
<ReviewGroup
|
||||||
|
key={id}
|
||||||
|
id={id}
|
||||||
|
review={review}
|
||||||
|
config={config}
|
||||||
|
onSeek={onSeekCheckPlaying}
|
||||||
|
effectiveTime={effectiveTime}
|
||||||
|
isActive={activeReviewId == id}
|
||||||
|
onActivate={() => setActiveReviewId(id)}
|
||||||
|
onOpenUpload={(e) => setUpload(e)}
|
||||||
|
alwaysExpandActive={alwaysExpandActive}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute bottom-0 left-0 right-0 z-30 rounded-t-md border border-secondary-highlight bg-background_alt shadow-md",
|
||||||
|
isDesktop && "border-b-0",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => setControlsExpanded(!controlsExpanded)}
|
||||||
|
className="flex w-full items-center justify-between p-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium">
|
||||||
|
<LuSettings className="size-4" />
|
||||||
|
<span>{t("detail.settings")}</span>
|
||||||
|
</div>
|
||||||
|
{controlsExpanded ? (
|
||||||
|
<LuChevronDown className="size-4 text-primary-variant" />
|
||||||
|
) : (
|
||||||
|
<LuChevronRight className="size-4 text-primary-variant" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{controlsExpanded && (
|
||||||
|
<div className="space-y-3 px-3 pb-3">
|
||||||
|
<AnnotationOffsetSlider />
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="text-sm font-medium">
|
||||||
|
{t("detail.alwaysExpandActive.title")}
|
||||||
|
</label>
|
||||||
|
<Switch
|
||||||
|
checked={alwaysExpandActive}
|
||||||
|
onCheckedChange={setAlwaysExpandActive}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{t("detail.alwaysExpandActive.desc")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
reviewItems?.map((review: ReviewSegment) => {
|
|
||||||
const id = `review-${review.id ?? review.start_time ?? Math.floor(review.start_time ?? 0)}`;
|
|
||||||
return (
|
|
||||||
<ReviewGroup
|
|
||||||
key={id}
|
|
||||||
id={id}
|
|
||||||
review={review}
|
|
||||||
config={config}
|
|
||||||
onSeek={onSeekCheckPlaying}
|
|
||||||
effectiveTime={effectiveTime}
|
|
||||||
isActive={activeReviewId == id}
|
|
||||||
onActivate={() => setActiveReviewId(id)}
|
|
||||||
onOpenUpload={(e) => setUpload(e)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
<AnnotationOffsetSlider />
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -223,6 +278,7 @@ type ReviewGroupProps = {
|
|||||||
onActivate?: () => void;
|
onActivate?: () => void;
|
||||||
onOpenUpload?: (e: Event) => void;
|
onOpenUpload?: (e: Event) => void;
|
||||||
effectiveTime?: number;
|
effectiveTime?: number;
|
||||||
|
alwaysExpandActive?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function ReviewGroup({
|
function ReviewGroup({
|
||||||
@ -234,11 +290,19 @@ function ReviewGroup({
|
|||||||
onActivate,
|
onActivate,
|
||||||
onOpenUpload,
|
onOpenUpload,
|
||||||
effectiveTime,
|
effectiveTime,
|
||||||
|
alwaysExpandActive = false,
|
||||||
}: ReviewGroupProps) {
|
}: ReviewGroupProps) {
|
||||||
const { t } = useTranslation("views/events");
|
const { t } = useTranslation("views/events");
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const start = review.start_time ?? 0;
|
const start = review.start_time ?? 0;
|
||||||
|
|
||||||
|
// Auto-expand when this review becomes active and alwaysExpandActive is enabled
|
||||||
|
useEffect(() => {
|
||||||
|
if (isActive && alwaysExpandActive) {
|
||||||
|
setOpen(true);
|
||||||
|
}
|
||||||
|
}, [isActive, alwaysExpandActive]);
|
||||||
|
|
||||||
const displayTime = formatUnixTimestampToDateTime(start, {
|
const displayTime = formatUnixTimestampToDateTime(start, {
|
||||||
timezone: config.ui.timezone,
|
timezone: config.ui.timezone,
|
||||||
date_format:
|
date_format:
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import {
|
|||||||
ReviewSegment,
|
ReviewSegment,
|
||||||
ReviewSeverity,
|
ReviewSeverity,
|
||||||
TimelineZoomDirection,
|
TimelineZoomDirection,
|
||||||
|
ZoomLevel,
|
||||||
} from "@/types/review";
|
} from "@/types/review";
|
||||||
import ReviewTimeline from "./ReviewTimeline";
|
import ReviewTimeline from "./ReviewTimeline";
|
||||||
import {
|
import {
|
||||||
@ -42,6 +43,9 @@ export type EventReviewTimelineProps = {
|
|||||||
isZooming: boolean;
|
isZooming: boolean;
|
||||||
zoomDirection: TimelineZoomDirection;
|
zoomDirection: TimelineZoomDirection;
|
||||||
dense?: boolean;
|
dense?: boolean;
|
||||||
|
onZoomChange?: (newZoomLevel: number) => void;
|
||||||
|
possibleZoomLevels?: ZoomLevel[];
|
||||||
|
currentZoomLevel?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function EventReviewTimeline({
|
export function EventReviewTimeline({
|
||||||
@ -69,6 +73,9 @@ export function EventReviewTimeline({
|
|||||||
isZooming,
|
isZooming,
|
||||||
zoomDirection,
|
zoomDirection,
|
||||||
dense = false,
|
dense = false,
|
||||||
|
onZoomChange,
|
||||||
|
possibleZoomLevels,
|
||||||
|
currentZoomLevel,
|
||||||
}: EventReviewTimelineProps) {
|
}: EventReviewTimelineProps) {
|
||||||
const internalTimelineRef = useRef<HTMLDivElement>(null);
|
const internalTimelineRef = useRef<HTMLDivElement>(null);
|
||||||
const selectedTimelineRef = timelineRef || internalTimelineRef;
|
const selectedTimelineRef = timelineRef || internalTimelineRef;
|
||||||
@ -157,6 +164,9 @@ export function EventReviewTimeline({
|
|||||||
scrollToSegment={scrollToSegment}
|
scrollToSegment={scrollToSegment}
|
||||||
isZooming={isZooming}
|
isZooming={isZooming}
|
||||||
zoomDirection={zoomDirection}
|
zoomDirection={zoomDirection}
|
||||||
|
onZoomChange={onZoomChange}
|
||||||
|
possibleZoomLevels={possibleZoomLevels}
|
||||||
|
currentZoomLevel={currentZoomLevel}
|
||||||
>
|
>
|
||||||
<VirtualizedEventSegments
|
<VirtualizedEventSegments
|
||||||
ref={virtualizedSegmentsRef}
|
ref={virtualizedSegmentsRef}
|
||||||
|
|||||||
@ -235,7 +235,7 @@ export function EventSegment({
|
|||||||
<div className="flex w-[20px] flex-row justify-center md:w-[40px]">
|
<div className="flex w-[20px] flex-row justify-center md:w-[40px]">
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<div
|
<div
|
||||||
className="absolute left-1/2 z-10 ml-[2px] h-[8px] w-[8px] -translate-x-1/2 transform cursor-pointer"
|
className="absolute left-1/2 z-10 ml-[2px] h-[8px] w-[8px] -translate-x-1/2 transform cursor-pointer md:ml-0"
|
||||||
data-severity={severityValue}
|
data-severity={severityValue}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import {
|
|||||||
MotionData,
|
MotionData,
|
||||||
ReviewSegment,
|
ReviewSegment,
|
||||||
TimelineZoomDirection,
|
TimelineZoomDirection,
|
||||||
|
ZoomLevel,
|
||||||
} from "@/types/review";
|
} from "@/types/review";
|
||||||
import ReviewTimeline from "./ReviewTimeline";
|
import ReviewTimeline from "./ReviewTimeline";
|
||||||
import { useMotionSegmentUtils } from "@/hooks/use-motion-segment-utils";
|
import { useMotionSegmentUtils } from "@/hooks/use-motion-segment-utils";
|
||||||
@ -47,6 +48,9 @@ export type MotionReviewTimelineProps = {
|
|||||||
isZooming: boolean;
|
isZooming: boolean;
|
||||||
zoomDirection: TimelineZoomDirection;
|
zoomDirection: TimelineZoomDirection;
|
||||||
alwaysShowMotionLine?: boolean;
|
alwaysShowMotionLine?: boolean;
|
||||||
|
onZoomChange?: (newZoomLevel: number) => void;
|
||||||
|
possibleZoomLevels?: ZoomLevel[];
|
||||||
|
currentZoomLevel?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function MotionReviewTimeline({
|
export function MotionReviewTimeline({
|
||||||
@ -77,6 +81,9 @@ export function MotionReviewTimeline({
|
|||||||
isZooming,
|
isZooming,
|
||||||
zoomDirection,
|
zoomDirection,
|
||||||
alwaysShowMotionLine = false,
|
alwaysShowMotionLine = false,
|
||||||
|
onZoomChange,
|
||||||
|
possibleZoomLevels,
|
||||||
|
currentZoomLevel,
|
||||||
}: MotionReviewTimelineProps) {
|
}: MotionReviewTimelineProps) {
|
||||||
const internalTimelineRef = useRef<HTMLDivElement>(null);
|
const internalTimelineRef = useRef<HTMLDivElement>(null);
|
||||||
const selectedTimelineRef = timelineRef || internalTimelineRef;
|
const selectedTimelineRef = timelineRef || internalTimelineRef;
|
||||||
@ -206,6 +213,9 @@ export function MotionReviewTimeline({
|
|||||||
isZooming={isZooming}
|
isZooming={isZooming}
|
||||||
zoomDirection={zoomDirection}
|
zoomDirection={zoomDirection}
|
||||||
getRecordingAvailability={getRecordingAvailability}
|
getRecordingAvailability={getRecordingAvailability}
|
||||||
|
onZoomChange={onZoomChange}
|
||||||
|
possibleZoomLevels={possibleZoomLevels}
|
||||||
|
currentZoomLevel={currentZoomLevel}
|
||||||
>
|
>
|
||||||
<VirtualizedMotionSegments
|
<VirtualizedMotionSegments
|
||||||
ref={virtualizedSegmentsRef}
|
ref={virtualizedSegmentsRef}
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import useDraggableElement from "@/hooks/use-draggable-element";
|
|||||||
import { useTimelineUtils } from "@/hooks/use-timeline-utils";
|
import { useTimelineUtils } from "@/hooks/use-timeline-utils";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { DraggableElement } from "@/types/draggable-element";
|
import { DraggableElement } from "@/types/draggable-element";
|
||||||
import { TimelineZoomDirection } from "@/types/review";
|
import { TimelineZoomDirection, ZoomLevel } from "@/types/review";
|
||||||
import {
|
import {
|
||||||
ReactNode,
|
ReactNode,
|
||||||
RefObject,
|
RefObject,
|
||||||
@ -13,6 +13,11 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { isIOS, isMobile } from "react-device-detect";
|
import { isIOS, isMobile } from "react-device-detect";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
import { LuZoomIn, LuZoomOut } from "react-icons/lu";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||||
|
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
||||||
|
|
||||||
export type ReviewTimelineProps = {
|
export type ReviewTimelineProps = {
|
||||||
timelineRef: RefObject<HTMLDivElement>;
|
timelineRef: RefObject<HTMLDivElement>;
|
||||||
@ -37,6 +42,9 @@ export type ReviewTimelineProps = {
|
|||||||
isZooming: boolean;
|
isZooming: boolean;
|
||||||
zoomDirection: TimelineZoomDirection;
|
zoomDirection: TimelineZoomDirection;
|
||||||
getRecordingAvailability?: (time: number) => boolean | undefined;
|
getRecordingAvailability?: (time: number) => boolean | undefined;
|
||||||
|
onZoomChange?: (newZoomLevel: number) => void;
|
||||||
|
possibleZoomLevels?: ZoomLevel[];
|
||||||
|
currentZoomLevel?: number;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -63,8 +71,12 @@ export function ReviewTimeline({
|
|||||||
isZooming,
|
isZooming,
|
||||||
zoomDirection,
|
zoomDirection,
|
||||||
getRecordingAvailability,
|
getRecordingAvailability,
|
||||||
|
onZoomChange,
|
||||||
|
possibleZoomLevels,
|
||||||
|
currentZoomLevel,
|
||||||
children,
|
children,
|
||||||
}: ReviewTimelineProps) {
|
}: ReviewTimelineProps) {
|
||||||
|
const { t } = useTranslation("views/events");
|
||||||
const [isDraggingHandlebar, setIsDraggingHandlebar] = useState(false);
|
const [isDraggingHandlebar, setIsDraggingHandlebar] = useState(false);
|
||||||
const [isDraggingExportStart, setIsDraggingExportStart] = useState(false);
|
const [isDraggingExportStart, setIsDraggingExportStart] = useState(false);
|
||||||
const [isDraggingExportEnd, setIsDraggingExportEnd] = useState(false);
|
const [isDraggingExportEnd, setIsDraggingExportEnd] = useState(false);
|
||||||
@ -78,6 +90,13 @@ export function ReviewTimeline({
|
|||||||
const exportEndRef = useRef<HTMLDivElement>(null);
|
const exportEndRef = useRef<HTMLDivElement>(null);
|
||||||
const exportEndTimeRef = useRef<HTMLDivElement>(null);
|
const exportEndTimeRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Use provided zoom levels or fallback to empty array
|
||||||
|
const zoomLevels = possibleZoomLevels ?? [];
|
||||||
|
|
||||||
|
const currentZoomLevelIndex =
|
||||||
|
currentZoomLevel ??
|
||||||
|
zoomLevels.findIndex((level) => level.segmentDuration === segmentDuration);
|
||||||
|
|
||||||
const isDragging = useMemo(
|
const isDragging = useMemo(
|
||||||
() => isDraggingHandlebar || isDraggingExportStart || isDraggingExportEnd,
|
() => isDraggingHandlebar || isDraggingExportStart || isDraggingExportEnd,
|
||||||
[isDraggingHandlebar, isDraggingExportStart, isDraggingExportEnd],
|
[isDraggingHandlebar, isDraggingExportStart, isDraggingExportEnd],
|
||||||
@ -348,148 +367,204 @@ export function ReviewTimeline({
|
|||||||
}, [getRecordingAvailability, handlebarTime, segmentDuration]);
|
}, [getRecordingAvailability, handlebarTime, segmentDuration]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<>
|
||||||
ref={timelineRef}
|
<div
|
||||||
className={cn(
|
ref={timelineRef}
|
||||||
"no-scrollbar relative h-full select-none overflow-y-auto bg-secondary transition-all duration-500 ease-in-out",
|
className={cn(
|
||||||
isZooming && zoomDirection === "in" && "animate-timeline-zoom-in",
|
"no-scrollbar relative h-full select-none overflow-y-auto bg-secondary transition-all duration-500 ease-in-out",
|
||||||
isZooming && zoomDirection === "out" && "animate-timeline-zoom-out",
|
isZooming && zoomDirection === "in" && "animate-timeline-zoom-in",
|
||||||
isDragging && (showHandlebar || showExportHandles)
|
isZooming && zoomDirection === "out" && "animate-timeline-zoom-out",
|
||||||
? "cursor-grabbing"
|
isDragging && (showHandlebar || showExportHandles)
|
||||||
: "cursor-auto",
|
? "cursor-grabbing"
|
||||||
)}
|
: "cursor-auto",
|
||||||
>
|
)}
|
||||||
<div ref={segmentsRef} className="relative flex flex-col">
|
>
|
||||||
<div className="pointer-events-none absolute inset-x-0 top-0 z-20 h-[30px] w-full bg-gradient-to-b from-secondary to-transparent"></div>
|
<div ref={segmentsRef} className="relative flex flex-col">
|
||||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 h-[30px] w-full bg-gradient-to-t from-secondary to-transparent"></div>
|
<div className="pointer-events-none absolute inset-x-0 top-0 z-20 h-[30px] w-full bg-gradient-to-b from-secondary to-transparent"></div>
|
||||||
{children}
|
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 h-[30px] w-full bg-gradient-to-t from-secondary to-transparent"></div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
{children && (
|
||||||
|
<>
|
||||||
|
{showHandlebar && (
|
||||||
|
<div
|
||||||
|
className={`absolute left-0 top-0 ${isDraggingHandlebar && isIOS ? "" : "z-20"} w-full`}
|
||||||
|
role="scrollbar"
|
||||||
|
ref={handlebarRef}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex touch-none select-none items-center justify-center"
|
||||||
|
onMouseDown={handleHandlebar}
|
||||||
|
onTouchStart={handleHandlebar}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`relative w-full ${
|
||||||
|
isDraggingHandlebar ? "cursor-grabbing" : "cursor-grab"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`mx-auto rounded-full bg-destructive ${
|
||||||
|
dense
|
||||||
|
? "w-12 md:w-20"
|
||||||
|
: segmentDuration < 60
|
||||||
|
? "w-[80px]"
|
||||||
|
: "w-20"
|
||||||
|
} h-5 ${isDraggingHandlebar && isMobile ? "fixed left-1/2 top-[18px] z-20 h-[30px] w-auto -translate-x-1/2 transform bg-destructive/80 px-3" : "static"} flex items-center justify-center`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={handlebarTimeRef}
|
||||||
|
className={`pointer-events-none text-white ${textSizeClasses("handlebar")} z-10`}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`absolute h-[4px] w-full bg-destructive ${isDraggingHandlebar && isMobile ? "top-1" : "top-1/2 -translate-y-1/2 transform"}`}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* TODO: determine if we should keep this tooltip */}
|
||||||
|
{false && isHandlebarInNoRecordingPeriod && (
|
||||||
|
<div className="absolute left-1/2 top-full z-50 mt-2 -translate-x-1/2 rounded-md bg-destructive/80 px-4 py-1 text-center text-xs text-white shadow-lg">
|
||||||
|
No recordings
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{showExportHandles && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={`export-end absolute left-0 top-0 ${isDraggingExportEnd && isIOS ? "" : "z-20"} w-full`}
|
||||||
|
role="scrollbar"
|
||||||
|
ref={exportEndRef}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex touch-none select-none items-center justify-center"
|
||||||
|
onMouseDown={handleExportEnd}
|
||||||
|
onTouchStart={handleExportEnd}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`relative mt-[6.5px] w-full ${
|
||||||
|
isDraggingExportEnd ? "cursor-grabbing" : "cursor-grab"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`mx-auto -mt-4 bg-selected ${
|
||||||
|
dense
|
||||||
|
? "w-12 md:w-20"
|
||||||
|
: segmentDuration < 60
|
||||||
|
? "w-[80px]"
|
||||||
|
: "w-20"
|
||||||
|
} h-5 ${isDraggingExportEnd && isMobile ? "fixed left-1/2 top-[18px] z-20 mt-0 h-[30px] w-auto -translate-x-1/2 transform rounded-full bg-selected/80 px-3" : "static rounded-tl-lg rounded-tr-lg"} flex items-center justify-center`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={exportEndTimeRef}
|
||||||
|
className={`pointer-events-none text-white ${isDraggingExportEnd && isMobile ? "mt-0" : ""} ${textSizeClasses("export_end")} z-10`}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`absolute h-[4px] w-full bg-selected ${isDraggingExportEnd && isMobile ? "top-0" : "top-1/2 -translate-y-1/2 transform"}`}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
ref={exportSectionRef}
|
||||||
|
className="absolute w-full bg-selected/50"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
className={`export-start absolute left-0 top-0 ${isDraggingExportStart && isIOS ? "" : "z-20"} w-full`}
|
||||||
|
role="scrollbar"
|
||||||
|
ref={exportStartRef}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex touch-none select-none items-center justify-center"
|
||||||
|
onMouseDown={handleExportStart}
|
||||||
|
onTouchStart={handleExportStart}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`relative -mt-[6.5px] w-full ${
|
||||||
|
isDragging ? "cursor-grabbing" : "cursor-grab"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`absolute h-[4px] w-full bg-selected ${isDraggingExportStart && isMobile ? "top-[12px]" : "top-1/2 -translate-y-1/2 transform"}`}
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
className={`mx-auto mt-4 bg-selected ${
|
||||||
|
dense
|
||||||
|
? "w-12 md:w-20"
|
||||||
|
: segmentDuration < 60
|
||||||
|
? "w-[80px]"
|
||||||
|
: "w-20"
|
||||||
|
} h-5 ${isDraggingExportStart && isMobile ? "fixed left-1/2 top-[4px] z-20 mt-0 h-[30px] w-auto -translate-x-1/2 transform rounded-full bg-selected/80 px-3" : "static rounded-bl-lg rounded-br-lg"} flex items-center justify-center`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={exportStartTimeRef}
|
||||||
|
className={`pointer-events-none text-white ${isDraggingExportStart && isMobile ? "mt-0" : ""} ${textSizeClasses("export_start")} z-10`}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{children && (
|
|
||||||
<>
|
{onZoomChange && currentZoomLevelIndex !== -1 && (
|
||||||
{showHandlebar && (
|
<div
|
||||||
<div
|
className={`absolute z-30 flex gap-2 ${
|
||||||
className={`absolute left-0 top-0 ${isDraggingHandlebar && isIOS ? "" : "z-20"} w-full`}
|
isMobile
|
||||||
role="scrollbar"
|
? "bottom-4 right-1 flex-col gap-3"
|
||||||
ref={handlebarRef}
|
: "bottom-2 left-1/2 -translate-x-1/2"
|
||||||
>
|
}`}
|
||||||
<div
|
>
|
||||||
className="flex touch-none select-none items-center justify-center"
|
<Tooltip>
|
||||||
onMouseDown={handleHandlebar}
|
<TooltipTrigger asChild>
|
||||||
onTouchStart={handleHandlebar}
|
<Button
|
||||||
|
onClick={(e) => {
|
||||||
|
const newLevel = Math.max(0, currentZoomLevelIndex - 1);
|
||||||
|
onZoomChange(newLevel);
|
||||||
|
e.currentTarget.blur();
|
||||||
|
}}
|
||||||
|
variant="outline"
|
||||||
|
disabled={currentZoomLevelIndex === 0}
|
||||||
|
className="bg-background_alt p-3 hover:bg-accent hover:text-accent-foreground active:scale-95 [@media(hover:none)]:hover:bg-background_alt"
|
||||||
|
type="button"
|
||||||
>
|
>
|
||||||
<div
|
<LuZoomOut className={cn("size-5 text-primary-variant")} />
|
||||||
className={`relative w-full ${
|
</Button>
|
||||||
isDraggingHandlebar ? "cursor-grabbing" : "cursor-grab"
|
</TooltipTrigger>
|
||||||
}`}
|
<TooltipPortal>
|
||||||
>
|
<TooltipContent>{t("zoomIn")}</TooltipContent>
|
||||||
<div
|
</TooltipPortal>
|
||||||
className={`mx-auto rounded-full bg-destructive ${
|
</Tooltip>
|
||||||
dense
|
<Tooltip>
|
||||||
? "w-12 md:w-20"
|
<TooltipTrigger asChild>
|
||||||
: segmentDuration < 60
|
<Button
|
||||||
? "w-[80px]"
|
onClick={(e) => {
|
||||||
: "w-20"
|
const newLevel = Math.min(
|
||||||
} h-5 ${isDraggingHandlebar && isMobile ? "fixed left-1/2 top-[18px] z-20 h-[30px] w-auto -translate-x-1/2 transform bg-destructive/80 px-3" : "static"} flex items-center justify-center`}
|
zoomLevels.length - 1,
|
||||||
>
|
currentZoomLevelIndex + 1,
|
||||||
<div
|
);
|
||||||
ref={handlebarTimeRef}
|
onZoomChange(newLevel);
|
||||||
className={`pointer-events-none text-white ${textSizeClasses("handlebar")} z-10`}
|
e.currentTarget.blur();
|
||||||
></div>
|
}}
|
||||||
</div>
|
variant="outline"
|
||||||
<div
|
disabled={currentZoomLevelIndex === zoomLevels.length - 1}
|
||||||
className={`absolute h-[4px] w-full bg-destructive ${isDraggingHandlebar && isMobile ? "top-1" : "top-1/2 -translate-y-1/2 transform"}`}
|
className="bg-background_alt p-3 hover:bg-accent hover:text-accent-foreground active:scale-95 [@media(hover:none)]:hover:bg-background_alt"
|
||||||
></div>
|
type="button"
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* TODO: determine if we should keep this tooltip */}
|
|
||||||
{false && isHandlebarInNoRecordingPeriod && (
|
|
||||||
<div className="absolute left-1/2 top-full z-50 mt-2 -translate-x-1/2 rounded-md bg-destructive/80 px-4 py-1 text-center text-xs text-white shadow-lg">
|
|
||||||
No recordings
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{showExportHandles && (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
className={`export-end absolute left-0 top-0 ${isDraggingExportEnd && isIOS ? "" : "z-20"} w-full`}
|
|
||||||
role="scrollbar"
|
|
||||||
ref={exportEndRef}
|
|
||||||
>
|
>
|
||||||
<div
|
<LuZoomIn className={cn("size-5 text-primary-variant")} />
|
||||||
className="flex touch-none select-none items-center justify-center"
|
</Button>
|
||||||
onMouseDown={handleExportEnd}
|
</TooltipTrigger>
|
||||||
onTouchStart={handleExportEnd}
|
<TooltipPortal>
|
||||||
>
|
<TooltipContent>{t("zoomOut")}</TooltipContent>
|
||||||
<div
|
</TooltipPortal>
|
||||||
className={`relative mt-[6.5px] w-full ${
|
</Tooltip>
|
||||||
isDraggingExportEnd ? "cursor-grabbing" : "cursor-grab"
|
</div>
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`mx-auto -mt-4 bg-selected ${
|
|
||||||
dense
|
|
||||||
? "w-12 md:w-20"
|
|
||||||
: segmentDuration < 60
|
|
||||||
? "w-[80px]"
|
|
||||||
: "w-20"
|
|
||||||
} h-5 ${isDraggingExportEnd && isMobile ? "fixed left-1/2 top-[18px] z-20 mt-0 h-[30px] w-auto -translate-x-1/2 transform rounded-full bg-selected/80 px-3" : "static rounded-tl-lg rounded-tr-lg"} flex items-center justify-center`}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
ref={exportEndTimeRef}
|
|
||||||
className={`pointer-events-none text-white ${isDraggingExportEnd && isMobile ? "mt-0" : ""} ${textSizeClasses("export_end")} z-10`}
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={`absolute h-[4px] w-full bg-selected ${isDraggingExportEnd && isMobile ? "top-0" : "top-1/2 -translate-y-1/2 transform"}`}
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
ref={exportSectionRef}
|
|
||||||
className="absolute w-full bg-selected/50"
|
|
||||||
></div>
|
|
||||||
<div
|
|
||||||
className={`export-start absolute left-0 top-0 ${isDraggingExportStart && isIOS ? "" : "z-20"} w-full`}
|
|
||||||
role="scrollbar"
|
|
||||||
ref={exportStartRef}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="flex touch-none select-none items-center justify-center"
|
|
||||||
onMouseDown={handleExportStart}
|
|
||||||
onTouchStart={handleExportStart}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`relative -mt-[6.5px] w-full ${
|
|
||||||
isDragging ? "cursor-grabbing" : "cursor-grab"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`absolute h-[4px] w-full bg-selected ${isDraggingExportStart && isMobile ? "top-[12px]" : "top-1/2 -translate-y-1/2 transform"}`}
|
|
||||||
></div>
|
|
||||||
<div
|
|
||||||
className={`mx-auto mt-4 bg-selected ${
|
|
||||||
dense
|
|
||||||
? "w-12 md:w-20"
|
|
||||||
: segmentDuration < 60
|
|
||||||
? "w-[80px]"
|
|
||||||
: "w-20"
|
|
||||||
} h-5 ${isDraggingExportStart && isMobile ? "fixed left-1/2 top-[4px] z-20 mt-0 h-[30px] w-auto -translate-x-1/2 transform rounded-full bg-selected/80 px-3" : "static rounded-bl-lg rounded-br-lg"} flex items-center justify-center`}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
ref={exportStartTimeRef}
|
|
||||||
className={`pointer-events-none text-white ${isDraggingExportStart && isMobile ? "mt-0" : ""} ${textSizeClasses("export_start")} z-10`}
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import {
|
|||||||
ReviewData,
|
ReviewData,
|
||||||
ReviewSegment,
|
ReviewSegment,
|
||||||
ReviewSeverity,
|
ReviewSeverity,
|
||||||
|
ZoomLevel,
|
||||||
} from "@/types/review";
|
} from "@/types/review";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import CameraActivityIndicator from "@/components/indicators/CameraActivityIndicator";
|
import CameraActivityIndicator from "@/components/indicators/CameraActivityIndicator";
|
||||||
@ -180,7 +181,7 @@ function UIPlayground() {
|
|||||||
timestampSpread: 15,
|
timestampSpread: 15,
|
||||||
});
|
});
|
||||||
|
|
||||||
const possibleZoomLevels = useMemo(
|
const possibleZoomLevels: ZoomLevel[] = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{ segmentDuration: 60, timestampSpread: 15 },
|
{ segmentDuration: 60, timestampSpread: 15 },
|
||||||
{ segmentDuration: 30, timestampSpread: 5 },
|
{ segmentDuration: 30, timestampSpread: 5 },
|
||||||
@ -196,6 +197,14 @@ function UIPlayground() {
|
|||||||
[possibleZoomLevels],
|
[possibleZoomLevels],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const currentZoomLevel = useMemo(
|
||||||
|
() =>
|
||||||
|
possibleZoomLevels.findIndex(
|
||||||
|
(level) => level.segmentDuration === zoomSettings.segmentDuration,
|
||||||
|
),
|
||||||
|
[possibleZoomLevels, zoomSettings.segmentDuration],
|
||||||
|
);
|
||||||
|
|
||||||
const { zoomLevel, handleZoom, isZooming, zoomDirection } = useTimelineZoom({
|
const { zoomLevel, handleZoom, isZooming, zoomDirection } = useTimelineZoom({
|
||||||
zoomSettings,
|
zoomSettings,
|
||||||
zoomLevels: possibleZoomLevels,
|
zoomLevels: possibleZoomLevels,
|
||||||
@ -414,6 +423,9 @@ function UIPlayground() {
|
|||||||
dense={isMobile} // dense will produce a smaller handlebar and only minute resolution on timestamps
|
dense={isMobile} // dense will produce a smaller handlebar and only minute resolution on timestamps
|
||||||
isZooming={isZooming} // is the timeline actively zooming?
|
isZooming={isZooming} // is the timeline actively zooming?
|
||||||
zoomDirection={zoomDirection} // is the timeline zooming in or out
|
zoomDirection={zoomDirection} // is the timeline zooming in or out
|
||||||
|
onZoomChange={handleZoomChange}
|
||||||
|
possibleZoomLevels={possibleZoomLevels}
|
||||||
|
currentZoomLevel={currentZoomLevel}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{isEventsReviewTimeline && (
|
{isEventsReviewTimeline && (
|
||||||
@ -441,6 +453,9 @@ function UIPlayground() {
|
|||||||
dense // dense will produce a smaller handlebar and only minute resolution on timestamps
|
dense // dense will produce a smaller handlebar and only minute resolution on timestamps
|
||||||
isZooming={isZooming} // is the timeline actively zooming?
|
isZooming={isZooming} // is the timeline actively zooming?
|
||||||
zoomDirection={zoomDirection} // is the timeline zooming in or out
|
zoomDirection={zoomDirection} // is the timeline zooming in or out
|
||||||
|
onZoomChange={handleZoomChange}
|
||||||
|
possibleZoomLevels={possibleZoomLevels}
|
||||||
|
currentZoomLevel={currentZoomLevel}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -81,6 +81,11 @@ export type ConsolidatedSegmentData = {
|
|||||||
|
|
||||||
export type TimelineZoomDirection = "in" | "out" | null;
|
export type TimelineZoomDirection = "in" | "out" | null;
|
||||||
|
|
||||||
|
export type ZoomLevel = {
|
||||||
|
segmentDuration: number;
|
||||||
|
timestampSpread: number;
|
||||||
|
};
|
||||||
|
|
||||||
export enum ThreatLevel {
|
export enum ThreatLevel {
|
||||||
SUSPICIOUS = 1,
|
SUSPICIOUS = 1,
|
||||||
DANGER = 2,
|
DANGER = 2,
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import {
|
|||||||
ReviewSeverity,
|
ReviewSeverity,
|
||||||
ReviewSummary,
|
ReviewSummary,
|
||||||
SegmentedReviewData,
|
SegmentedReviewData,
|
||||||
|
ZoomLevel,
|
||||||
} from "@/types/review";
|
} from "@/types/review";
|
||||||
import { getChunkedTimeRange } from "@/utils/timelineUtil";
|
import { getChunkedTimeRange } from "@/utils/timelineUtil";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
@ -501,7 +502,7 @@ function DetectionReview({
|
|||||||
timestampSpread: 15,
|
timestampSpread: 15,
|
||||||
});
|
});
|
||||||
|
|
||||||
const possibleZoomLevels = useMemo(
|
const possibleZoomLevels: ZoomLevel[] = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{ segmentDuration: 60, timestampSpread: 15 },
|
{ segmentDuration: 60, timestampSpread: 15 },
|
||||||
{ segmentDuration: 30, timestampSpread: 5 },
|
{ segmentDuration: 30, timestampSpread: 5 },
|
||||||
@ -517,6 +518,14 @@ function DetectionReview({
|
|||||||
[possibleZoomLevels],
|
[possibleZoomLevels],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const currentZoomLevel = useMemo(
|
||||||
|
() =>
|
||||||
|
possibleZoomLevels.findIndex(
|
||||||
|
(level) => level.segmentDuration === zoomSettings.segmentDuration,
|
||||||
|
),
|
||||||
|
[possibleZoomLevels, zoomSettings.segmentDuration],
|
||||||
|
);
|
||||||
|
|
||||||
const { isZooming, zoomDirection } = useTimelineZoom({
|
const { isZooming, zoomDirection } = useTimelineZoom({
|
||||||
zoomSettings,
|
zoomSettings,
|
||||||
zoomLevels: possibleZoomLevels,
|
zoomLevels: possibleZoomLevels,
|
||||||
@ -799,7 +808,7 @@ function DetectionReview({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-[65px] flex-row md:w-[110px]">
|
<div className="flex w-[65px] flex-row md:w-[110px]">
|
||||||
<div className="no-scrollbar w-[55px] md:w-[100px]">
|
<div className="no-scrollbar relative w-[55px] md:w-[100px]">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<Skeleton className="size-full" />
|
<Skeleton className="size-full" />
|
||||||
) : (
|
) : (
|
||||||
@ -821,6 +830,8 @@ function DetectionReview({
|
|||||||
dense={isMobile}
|
dense={isMobile}
|
||||||
isZooming={isZooming}
|
isZooming={isZooming}
|
||||||
zoomDirection={zoomDirection}
|
zoomDirection={zoomDirection}
|
||||||
|
possibleZoomLevels={possibleZoomLevels}
|
||||||
|
currentZoomLevel={currentZoomLevel}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -22,6 +22,7 @@ import {
|
|||||||
ReviewFilter,
|
ReviewFilter,
|
||||||
ReviewSegment,
|
ReviewSegment,
|
||||||
ReviewSummary,
|
ReviewSummary,
|
||||||
|
ZoomLevel,
|
||||||
} from "@/types/review";
|
} from "@/types/review";
|
||||||
import { getChunkedTimeDay } from "@/utils/timelineUtil";
|
import { getChunkedTimeDay } from "@/utils/timelineUtil";
|
||||||
import {
|
import {
|
||||||
@ -884,7 +885,7 @@ function Timeline({
|
|||||||
timestampSpread: 15,
|
timestampSpread: 15,
|
||||||
});
|
});
|
||||||
|
|
||||||
const possibleZoomLevels = useMemo(
|
const possibleZoomLevels: ZoomLevel[] = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{ segmentDuration: 30, timestampSpread: 15 },
|
{ segmentDuration: 30, timestampSpread: 15 },
|
||||||
{ segmentDuration: 15, timestampSpread: 5 },
|
{ segmentDuration: 15, timestampSpread: 5 },
|
||||||
@ -900,6 +901,14 @@ function Timeline({
|
|||||||
[possibleZoomLevels],
|
[possibleZoomLevels],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const currentZoomLevel = useMemo(
|
||||||
|
() =>
|
||||||
|
possibleZoomLevels.findIndex(
|
||||||
|
(level) => level.segmentDuration === zoomSettings.segmentDuration,
|
||||||
|
),
|
||||||
|
[possibleZoomLevels, zoomSettings.segmentDuration],
|
||||||
|
);
|
||||||
|
|
||||||
const { isZooming, zoomDirection } = useTimelineZoom({
|
const { isZooming, zoomDirection } = useTimelineZoom({
|
||||||
zoomSettings,
|
zoomSettings,
|
||||||
zoomLevels: possibleZoomLevels,
|
zoomLevels: possibleZoomLevels,
|
||||||
@ -1010,6 +1019,9 @@ function Timeline({
|
|||||||
onHandlebarDraggingChange={(scrubbing) => setScrubbing(scrubbing)}
|
onHandlebarDraggingChange={(scrubbing) => setScrubbing(scrubbing)}
|
||||||
isZooming={isZooming}
|
isZooming={isZooming}
|
||||||
zoomDirection={zoomDirection}
|
zoomDirection={zoomDirection}
|
||||||
|
onZoomChange={handleZoomChange}
|
||||||
|
possibleZoomLevels={possibleZoomLevels}
|
||||||
|
currentZoomLevel={currentZoomLevel}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Skeleton className="size-full" />
|
<Skeleton className="size-full" />
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user