improve active line progress height with resize observer

This commit is contained in:
Josh Hawkins 2025-11-16 06:53:15 -06:00
parent 73f0ca663b
commit 987a7e4487

View File

@ -1,5 +1,6 @@
import useSWR from "swr"; import useSWR from "swr";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useResizeObserver } from "@/hooks/resize-observer";
import { Event } from "@/types/event"; import { Event } from "@/types/event";
import ActivityIndicator from "@/components/indicators/activity-indicator"; import ActivityIndicator from "@/components/indicators/activity-indicator";
import { TrackingDetailsSequence } from "@/types/timeline"; import { TrackingDetailsSequence } from "@/types/timeline";
@ -89,9 +90,16 @@ export function TrackingDetails({
}, [manualOverride, currentTime, annotationOffset]); }, [manualOverride, currentTime, annotationOffset]);
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
const timelineContainerRef = useRef<HTMLDivElement | null>(null);
const rowRefs = useRef<(HTMLDivElement | null)[]>([]);
const [_selectedZone, setSelectedZone] = useState(""); const [_selectedZone, setSelectedZone] = useState("");
const [_lifecycleZones, setLifecycleZones] = useState<string[]>([]); const [_lifecycleZones, setLifecycleZones] = useState<string[]>([]);
const [seekToTimestamp, setSeekToTimestamp] = useState<number | null>(null); const [seekToTimestamp, setSeekToTimestamp] = useState<number | null>(null);
const [lineBottomOffsetPx, setLineBottomOffsetPx] = useState<number>(32);
const [lineTopOffsetPx, setLineTopOffsetPx] = useState<number>(8);
const [blueLineHeightPx, setBlueLineHeightPx] = useState<number>(0);
const [timelineSize] = useResizeObserver(timelineContainerRef);
const aspectRatio = useMemo(() => { const aspectRatio = useMemo(() => {
if (!config) { if (!config) {
@ -225,7 +233,6 @@ export function TrackingDetails({
if (effectiveTime === undefined || event.start_time === undefined) { if (effectiveTime === undefined || event.start_time === undefined) {
return false; return false;
} }
// If an event has not ended yet, fall back to last timestamp in eventSequence // If an event has not ended yet, fall back to last timestamp in eventSequence
let eventEnd = event.end_time; let eventEnd = event.end_time;
if (eventEnd == null && eventSequence && eventSequence.length > 0) { if (eventEnd == null && eventSequence && eventSequence.length > 0) {
@ -238,57 +245,58 @@ export function TrackingDetails({
if (eventEnd == null) { if (eventEnd == null) {
return false; return false;
} }
return effectiveTime >= event.start_time && effectiveTime <= eventEnd; return effectiveTime >= event.start_time && effectiveTime <= eventEnd;
}, [effectiveTime, event.start_time, event.end_time, eventSequence]); }, [effectiveTime, event.start_time, event.end_time, eventSequence]);
// Calculate how far down the blue line should extend based on effectiveTime // Dynamically compute pixel offsets so the timeline line starts at the
const calculateLineHeight = useCallback(() => { // first row midpoint and ends at the last row midpoint. For accuracy,
if (!eventSequence || eventSequence.length === 0 || !isWithinEventRange) { // measure the center Y of each lifecycle row and interpolate the current
return 0; // effective time into a pixel position; then set the blue line height
} // so it reaches the center dot at the same time the dot becomes active.
useEffect(() => {
if (!timelineContainerRef.current || !eventSequence) return;
const currentTime = effectiveTime ?? 0; const containerRect = timelineContainerRef.current.getBoundingClientRect();
const validRefs = rowRefs.current.filter((r) => r !== null);
if (validRefs.length === 0) return;
// Find which events have been passed const centers = validRefs.map((n) => {
let lastPassedIndex = -1; const r = n.getBoundingClientRect();
for (let i = 0; i < eventSequence.length; i++) { return r.top + r.height / 2 - containerRect.top;
if (currentTime >= (eventSequence[i].timestamp ?? 0)) { });
lastPassedIndex = i;
} else { const topOffset = Math.max(0, centers[0]);
break; const bottomOffset = Math.max(
0,
containerRect.height - centers[centers.length - 1],
);
setLineTopOffsetPx(Math.round(topOffset));
setLineBottomOffsetPx(Math.round(bottomOffset));
const eff = effectiveTime ?? 0;
const timestamps = eventSequence.map((s) => s.timestamp ?? 0);
let pixelPos = centers[0];
if (eff <= timestamps[0]) {
pixelPos = centers[0];
} else if (eff >= timestamps[timestamps.length - 1]) {
pixelPos = centers[centers.length - 1];
} else {
for (let i = 0; i < timestamps.length - 1; i++) {
const t1 = timestamps[i];
const t2 = timestamps[i + 1];
if (eff >= t1 && eff <= t2) {
const ratio = t2 > t1 ? (eff - t1) / (t2 - t1) : 0;
pixelPos = centers[i] + ratio * (centers[i + 1] - centers[i]);
break;
}
} }
} }
// No events passed yet const bluePx = Math.round(Math.max(0, pixelPos - topOffset));
if (lastPassedIndex < 0) return 0; setBlueLineHeightPx(bluePx);
}, [eventSequence, timelineSize.width, timelineSize.height, effectiveTime]);
// All events passed
if (lastPassedIndex >= eventSequence.length - 1) return 100;
// Calculate percentage based on item position, not time
// Each item occupies an equal visual space regardless of time gaps
const itemPercentage = 100 / (eventSequence.length - 1);
// Find progress between current and next event for smooth transition
const currentEvent = eventSequence[lastPassedIndex];
const nextEvent = eventSequence[lastPassedIndex + 1];
const currentTimestamp = currentEvent.timestamp ?? 0;
const nextTimestamp = nextEvent.timestamp ?? 0;
// Calculate interpolation between the two events
const timeBetween = nextTimestamp - currentTimestamp;
const timeElapsed = currentTime - currentTimestamp;
const interpolation = timeBetween > 0 ? timeElapsed / timeBetween : 0;
// Base position plus interpolated progress to next item
return Math.min(
100,
lastPassedIndex * itemPercentage + interpolation * itemPercentage,
);
}, [eventSequence, effectiveTime, isWithinEventRange]);
const blueLineHeight = calculateLineHeight();
const videoSource = useMemo(() => { const videoSource = useMemo(() => {
// event.start_time and event.end_time are in DETECT stream time // event.start_time and event.end_time are in DETECT stream time
@ -545,12 +553,21 @@ export function TrackingDetails({
{t("detail.noObjectDetailData", { ns: "views/events" })} {t("detail.noObjectDetailData", { ns: "views/events" })}
</div> </div>
) : ( ) : (
<div className="-pb-2 relative mx-0"> <div
<div className="absolute -top-2 bottom-8 left-6 z-0 w-0.5 -translate-x-1/2 bg-secondary-foreground" /> className="-pb-2 relative mx-0"
ref={timelineContainerRef}
>
<div
className="absolute -top-2 left-6 z-0 w-0.5 -translate-x-1/2 bg-secondary-foreground"
style={{ bottom: lineBottomOffsetPx }}
/>
{isWithinEventRange && ( {isWithinEventRange && (
<div <div
className="absolute left-6 top-2 z-[5] max-h-[calc(100%-3rem)] w-0.5 -translate-x-1/2 bg-selected transition-all duration-300" className="absolute left-6 z-[5] w-0.5 -translate-x-1/2 bg-selected transition-all duration-300"
style={{ height: `${blueLineHeight}%` }} style={{
top: `${lineTopOffsetPx}px`,
height: `${blueLineHeightPx}px`,
}}
/> />
)} )}
<div className="space-y-2"> <div className="space-y-2">
@ -603,20 +620,26 @@ export function TrackingDetails({
: undefined; : undefined;
return ( return (
<LifecycleIconRow <div
key={`${item.timestamp}-${item.source_id ?? ""}-${idx}`} key={`${item.timestamp}-${item.source_id ?? ""}-${idx}`}
item={item} ref={(el) => {
isActive={isActive} rowRefs.current[idx] = el;
formattedEventTimestamp={formattedEventTimestamp} }}
ratio={ratio} >
areaPx={areaPx} <LifecycleIconRow
areaPct={areaPct} item={item}
onClick={() => handleLifecycleClick(item)} isActive={isActive}
setSelectedZone={setSelectedZone} formattedEventTimestamp={formattedEventTimestamp}
getZoneColor={getZoneColor} ratio={ratio}
effectiveTime={effectiveTime} areaPx={areaPx}
isTimelineActive={isWithinEventRange} areaPct={areaPct}
/> onClick={() => handleLifecycleClick(item)}
setSelectedZone={setSelectedZone}
getZoneColor={getZoneColor}
effectiveTime={effectiveTime}
isTimelineActive={isWithinEventRange}
/>
</div>
); );
})} })}
</div> </div>