import { useEffect, useMemo, useRef, useState } from 'react';
import type { CSSProperties, PointerEvent } from 'react';
import { X } from 'lucide-react';
import type { RequestId } from '../../shared/client';
import type { ProcessedRequest } from '../state/model';
import {
useNetworkActivityActions,
useSelectedRequestId,
} from '../state/hooks';
import {
formatTimelineOffset,
getTimelineBarTopOffset,
getTimelineModel,
getTimelineTrackTop,
isRequestActive,
TIMELINE_LAYOUT,
} from '../utils/timelineModel';
import type {
TimelineModel,
TimelineRangeSelection,
TimelineRow,
TimelineTick,
} from '../utils/timelineModel';
const REQUEST_TIMELINE_COLORS = {
error: 'bg-red-400',
primary: 'bg-gray-400',
active: 'bg-gray-500',
httpTtfb: 'bg-gray-200',
} as const;
const getPrimaryBarClassName = (request: ProcessedRequest) => {
if (request.status === 'failed' || request.status === 'error') {
return REQUEST_TIMELINE_COLORS.error;
}
return REQUEST_TIMELINE_COLORS.primary;
};
const getStyle = (
offsetPercent: number,
widthPercent: number,
): CSSProperties => ({
left: `${offsetPercent}%`,
width: `${widthPercent}%`,
});
const GridLines = ({ ticks }: { ticks: TimelineTick[] }) => {
return (
{ticks.map((tick) => (
))}
);
};
const getTickLabelStyle = (tick: TimelineTick): CSSProperties => {
if (tick.offsetPercent === 0) {
return {
left: 4,
};
}
if (tick.offsetPercent === 100) {
return {
right: 4,
};
}
return {
left: `${tick.offsetPercent}%`,
};
};
const TimelineTrack = ({
row,
isSelected,
onSelect,
shouldSuppressSelect,
}: {
row: TimelineRow;
isSelected: boolean;
onSelect: (requestId: RequestId) => void;
shouldSuppressSelect: () => boolean;
}) => {
const primaryBarClassName = row.isActive
? REQUEST_TIMELINE_COLORS.active
: getPrimaryBarClassName(row.request);
const isSplitHttpBar =
row.request.type === 'http' &&
row.ttfbPercent > 0 &&
row.receivePercent > 0;
const trackTop = getTimelineTrackTop(row.lane);
const barTop = getTimelineBarTopOffset();
const positionStyle = {
...getStyle(row.offsetPercent, row.widthPercent),
top: trackTop,
};
const durationLabel = row.isActive
? `${formatTimelineOffset(row.duration)}+`
: formatTimelineOffset(row.duration);
const label = `${row.request.method} ${row.request.name} - ${durationLabel}`;
return (
);
};
type DraftSelection = {
anchorPercent: number;
currentPercent: number;
startedOnTrack: boolean;
};
const clampPercent = (value: number) => Math.min(Math.max(value, 0), 100);
const getPointerPercent = (
event: PointerEvent,
element: HTMLDivElement,
) => {
const rect = element.getBoundingClientRect();
if (rect.width === 0) {
return 0;
}
return clampPercent(((event.clientX - rect.left) / rect.width) * 100);
};
const getSelectionStyle = (
range: TimelineRangeSelection,
timeline: TimelineModel,
): CSSProperties => {
const startPercent = clampPercent(
((range.startTime - timeline.rangeStart) / timeline.rangeDuration) * 100,
);
const endPercent = clampPercent(
((range.endTime - timeline.rangeStart) / timeline.rangeDuration) * 100,
);
const left = Math.min(startPercent, endPercent);
const width = Math.abs(endPercent - startPercent);
return {
left: `${left}%`,
width: `${width}%`,
top: TIMELINE_LAYOUT.rulerHeightPx,
};
};
const getDraftSelectionStyle = (draft: DraftSelection): CSSProperties => {
const left = Math.min(draft.anchorPercent, draft.currentPercent);
const width = Math.abs(draft.currentPercent - draft.anchorPercent);
return {
left: `${left}%`,
width: `${width}%`,
top: TIMELINE_LAYOUT.rulerHeightPx,
};
};
export type NetworkTimelineProps = {
requests: ProcessedRequest[];
selection: TimelineRangeSelection | null;
filteredRequestCount: number;
onSelectionChange: (selection: TimelineRangeSelection | null) => void;
};
export const NetworkTimeline = ({
requests,
selection,
filteredRequestCount,
onSelectionChange,
}: NetworkTimelineProps) => {
const actions = useNetworkActivityActions();
const selectedRequestId = useSelectedRequestId();
const [now, setNow] = useState(() => Date.now());
const [draftSelection, setDraftSelection] = useState(
null,
);
const chartRef = useRef(null);
const suppressTrackClickRef = useRef(false);
const hasActiveRequests = requests.some(isRequestActive);
useEffect(() => {
if (!hasActiveRequests) {
return;
}
const interval = window.setInterval(() => {
setNow(Date.now());
}, TIMELINE_LAYOUT.liveRefreshMs);
return () => window.clearInterval(interval);
}, [hasActiveRequests]);
const timeline = useMemo(() => {
return getTimelineModel(requests, now);
}, [requests, now]);
const onRequestSelect = (requestId: RequestId) => {
actions.setSelectedRequest(requestId);
};
const onPointerDown = (event: PointerEvent) => {
if (event.button !== 0 || requests.length === 0) {
return;
}
const chartElement = chartRef.current;
if (!chartElement) {
return;
}
const percent = getPointerPercent(event, chartElement);
const target = event.target;
const startedOnTrack =
target instanceof Element &&
target.closest('[data-timeline-track="true"]') !== null;
setDraftSelection({
anchorPercent: percent,
currentPercent: percent,
startedOnTrack,
});
chartElement.setPointerCapture(event.pointerId);
};
const onPointerMove = (event: PointerEvent) => {
if (!draftSelection) {
return;
}
const chartElement = chartRef.current;
if (!chartElement) {
return;
}
event.preventDefault();
const percent = getPointerPercent(event, chartElement);
setDraftSelection((current) =>
current ? { ...current, currentPercent: percent } : current,
);
};
const onPointerUp = (event: PointerEvent) => {
if (!draftSelection) {
return;
}
const chartElement = chartRef.current;
const currentPercent = chartElement
? getPointerPercent(event, chartElement)
: draftSelection.currentPercent;
const distance = Math.abs(currentPercent - draftSelection.anchorPercent);
if (distance > 1) {
const startOffset =
(Math.min(draftSelection.anchorPercent, currentPercent) / 100) *
timeline.rangeDuration;
const endOffset =
(Math.max(draftSelection.anchorPercent, currentPercent) / 100) *
timeline.rangeDuration;
onSelectionChange({
startTime: timeline.rangeStart + startOffset,
endTime: timeline.rangeStart + endOffset,
});
suppressTrackClickRef.current = true;
window.setTimeout(() => {
suppressTrackClickRef.current = false;
}, 0);
} else if (!draftSelection.startedOnTrack) {
onSelectionChange(null);
}
setDraftSelection(null);
if (chartElement?.hasPointerCapture(event.pointerId)) {
chartElement.releasePointerCapture(event.pointerId);
}
};
return (
{timeline.ticks.map((tick) => (
{tick.label}
))}
{selection && (
)}
{draftSelection && (
)}
{timeline.rows.map((row) => (
suppressTrackClickRef.current}
/>
))}
{selection && (
{filteredRequestCount} in range
)}
{timeline.hiddenRequestCount > 0 && (
Showing latest {timeline.rows.length} of{' '}
{timeline.totalRequestCount}
)}
);
};