// Copyright 2022 The Parca Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import {useMemo, useRef, useState} from 'react';
import {Icon} from '@iconify/react';
import cx from 'classnames';
import * as d3 from 'd3';
import isEqual from 'fast-deep-equal';
import {useIntersectionObserver} from 'usehooks-ts';
import {LabelSet} from '@parca/client';
import {Button, useParcaContext} from '@parca/components';
import {TimelineGuide} from '../../TimelineGuide';
import {NumberDuo} from '../../utils';
import {DataPoint, SamplesGraph} from './SamplesGraph';
import {createLabelSetComparator, labelSetToString} from './labelSetUtils';
export type {DataPoint} from './SamplesGraph';
interface DragState {
stripIndex: number;
startX: number;
currentX: number;
}
interface Props {
loading?: boolean;
cpus: LabelSet[];
data: DataPoint[][];
selectedTimeframe: {
labels: LabelSet;
bounds: NumberDuo;
} | null;
onSelectedTimeframe: (labels: LabelSet, bounds: NumberDuo | undefined) => void;
width?: number;
bounds: NumberDuo;
stepMs: number;
}
const STRIP_HEIGHT = 24;
const LABEL_ROW_HEIGHT = 16; // text-xs label row above each strip
const GAP = 4; // gap-1 between flex children
const MAX_VISIBLE_STRIPS = 20;
const LOADING_STRIP_COUNT = 8;
const generateMockStripData = (
bounds: NumberDuo
): {cpus: LabelSet[]; data: DataPoint[][]; stepMs: number} => {
const stepMs = Math.max(Math.floor((bounds[1] - bounds[0]) / 240), 100);
const cpus: LabelSet[] = Array.from({length: LOADING_STRIP_COUNT}, (_, i) => ({
labels: [{name: 'cpu', value: String(i)}],
}));
let seed = 42;
const data = cpus.map(() => {
const points: DataPoint[] = [];
for (let ts = bounds[0]; ts < bounds[1]; ts += stepMs) {
seed = (seed * 16807 + 11) % 2147483647;
const value = (seed % 80) + 10;
seed = (seed * 16807 + 11) % 2147483647;
const sampleCount = (seed % 50) + 1;
points.push({timestamp: ts, value, sampleCount});
}
return points;
});
return {cpus, data, stepMs};
};
const getTimelineGuideHeight = (cpusCount: number, collapsedCount: number): number => {
const expandedCount = cpusCount - collapsedCount;
// Each expanded strip: label row + graph height
// Each collapsed strip: min-h-5 (20px)
// Gaps between strips (gap-1 = 4px)
const expandedTotal = expandedCount * (LABEL_ROW_HEIGHT + STRIP_HEIGHT);
const collapsedTotal = collapsedCount * 20; // min-h-5
const gaps = cpusCount * GAP + 20; // timeline header
return expandedTotal + collapsedTotal + gaps;
};
const stickyPx = 0;
const SamplesGraphContainer = ({
isSelected,
isCollapsed,
label: labelStr,
width,
onToggleCollapse,
data,
selectionBounds,
setSelectionBounds,
color,
stepMs,
onDragStart,
dragState,
stripIndex,
isAnyDragActive,
timeBounds,
loading,
}: {
isSelected: boolean;
isCollapsed: boolean;
label: string;
width: number | undefined;
onToggleCollapse: () => void;
data: DataPoint[];
selectionBounds: NumberDuo | undefined;
setSelectionBounds: (bounds: NumberDuo | undefined) => void;
color: (label: string) => string;
stepMs: number;
onDragStart: (stripIndex: number, startX: number) => void;
dragState: DragState | undefined;
stripIndex: number;
isAnyDragActive: boolean;
timeBounds: NumberDuo;
loading?: boolean;
}): JSX.Element => {
const {isIntersecting, ref} = useIntersectionObserver({
rootMargin: `${stickyPx}px 0px 0px 0px`,
});
const isSticky = useMemo(() => {
return isSelected && isIntersecting;
}, [isSelected, isIntersecting]);
return (
{loading === true ? (
) : (
<>
{labelStr}
>
)}
{!isCollapsed ? (
onDragStart(stripIndex, startX)}
dragState={dragState?.stripIndex === stripIndex ? dragState : undefined}
isAnyDragActive={isAnyDragActive}
timeBounds={timeBounds}
/>
) : null}
);
};
export const SamplesStrip = ({
loading,
cpus,
data,
selectedTimeframe,
onSelectedTimeframe,
width,
bounds,
stepMs,
}: Props): JSX.Element => {
const {isDarkMode} = useParcaContext();
const effectiveLoading = loading === true;
// When loading, use mock data to render a pixel-perfect skeleton
const mockData = useMemo(
() => (effectiveLoading && bounds[0] !== bounds[1] ? generateMockStripData(bounds) : null),
[effectiveLoading, bounds]
);
const effectiveCpus = mockData?.cpus ?? cpus;
const effectiveData = mockData?.data ?? data;
const effectiveStepMs = mockData?.stepMs ?? stepMs;
const [collapsedLabels, setCollapsedLabels] = useState>(new Set());
const [showAll, setShowAll] = useState(false);
const [dragState, setDragState] = useState(undefined);
const containerRef = useRef(null);
const isDragging = dragState !== undefined;
const {compare, keyOrder} = useMemo(
() => createLabelSetComparator(effectiveCpus),
[effectiveCpus]
);
const sortedItems = useMemo(() => {
const items = effectiveCpus.map((cpu, i) => ({
cpu,
data: effectiveData[i],
label: labelSetToString(cpu, keyOrder),
}));
return items.sort((a, b) => compare(a.cpu, b.cpu));
}, [effectiveCpus, effectiveData, compare, keyOrder]);
const hasMore = useMemo(() => sortedItems.length > MAX_VISIBLE_STRIPS, [sortedItems]);
const visibleItems = useMemo(
() => (showAll || !hasMore ? sortedItems : sortedItems.slice(0, MAX_VISIBLE_STRIPS)),
[sortedItems, showAll, hasMore]
);
// Deterministic color: hash the label string so the same label always gets the same color
// regardless of render order. When loading, use muted gray.
const color = useMemo(() => {
if (effectiveLoading) {
return (_label: string): string => (isDarkMode ? '#374151' : '#d1d5db');
}
const palette = d3.schemeObservable10;
const hashStr = (s: string): number => {
let h = 0;
for (let i = 0; i < s.length; i++) {
h = (Math.imul(31, h) + s.charCodeAt(i)) | 0;
}
return Math.abs(h);
};
return (label: string): string => palette[hashStr(label) % palette.length];
}, [effectiveLoading, isDarkMode]);
const handleDragStart = (stripIndex: number, startX: number): void => {
setDragState({stripIndex, startX, currentX: startX});
};
const handleMouseMove = (e: React.MouseEvent): void => {
if (dragState === undefined || containerRef.current === null) return;
const rect = containerRef.current.getBoundingClientRect();
const x = e.clientX - rect.left;
// Clamp to container bounds
const clampedX = Math.max(0, Math.min(x, width ?? rect.width));
setDragState({...dragState, currentX: clampedX});
};
const handleMouseUp = (e: React.MouseEvent): void => {
if (dragState === undefined || containerRef.current === null) return;
const rect = containerRef.current.getBoundingClientRect();
const x = e.clientX - rect.left;
const clampedX = Math.max(0, Math.min(x, width ?? rect.width));
const {stripIndex, startX} = dragState;
if (startX !== clampedX) {
const start = Math.min(startX, clampedX);
const end = Math.max(startX, clampedX);
// Convert pixel positions to timestamps
const innerWidth = width ?? rect.width;
const startTs = bounds[0] + (start / innerWidth) * (bounds[1] - bounds[0]);
const endTs = bounds[0] + (end / innerWidth) * (bounds[1] - bounds[0]);
onSelectedTimeframe(visibleItems[stripIndex].cpu, [startTs, endTs]);
}
setDragState(undefined);
};
const handleMouseLeave = (): void => {
setDragState(undefined);
};
if (!effectiveLoading && effectiveData.length === 0) {
return (
There is no data matching your filter criteria, please try changing the filter.
);
}
return (
visibleItems.some(item => item.label === l)).length
)}
margin={1}
elevateGuideLines
/>
{visibleItems.map((item, i) => {
const isCollapsed = collapsedLabels.has(item.label);
const isSelected = isEqual(item.cpu, selectedTimeframe?.labels);
return (
{
const newCollapsedLabels = new Set(collapsedLabels);
if (collapsedLabels.has(item.label)) {
newCollapsedLabels.delete(item.label);
} else {
newCollapsedLabels.add(item.label);
}
setCollapsedLabels(newCollapsedLabels);
}}
selectionBounds={isSelected ? selectedTimeframe?.bounds : undefined}
setSelectionBounds={newBounds => {
onSelectedTimeframe(item.cpu, newBounds);
}}
color={color}
stepMs={effectiveStepMs}
onDragStart={handleDragStart}
dragState={dragState}
stripIndex={i}
isAnyDragActive={isDragging}
timeBounds={bounds}
loading={effectiveLoading}
key={item.label}
/>
);
})}
{hasMore && !showAll && (
)}
);
};