Compare commits

...

22 Commits

Author SHA1 Message Date
aks07
7e2cf57819 Merge branch 'main' of github.com:SigNoz/signoz into feat/trace-flamegraph 2026-03-11 12:24:06 +05:30
aks07
dc9ebc5b26 feat: add test utils 2026-03-11 03:31:10 +05:30
aks07
398ab6e9d9 feat: add test cases for flamegraph 2026-03-11 03:28:35 +05:30
aks07
fec60671d8 feat: minor comment added 2026-03-11 03:03:49 +05:30
aks07
99259cc4e8 feat: remove unnecessary props 2026-03-10 16:01:24 +05:30
aks07
ca311717c2 feat: bg color for selected and hover spans 2026-03-06 23:16:35 +05:30
aks07
a614da2c65 fix: update color 2026-03-06 20:22:23 +05:30
aks07
ce18709002 fix: style fix 2026-03-06 20:03:42 +05:30
aks07
2b6977e891 feat: reduce timeline intervals 2026-03-06 20:01:50 +05:30
aks07
3e6eedbcab feat: fix style 2026-03-06 19:57:19 +05:30
aks07
fd9e3f0411 feat: fix style 2026-03-06 19:26:23 +05:30
aks07
e99465e030 Merge branch 'main' of github.com:SigNoz/signoz into feat/trace-flamegraph 2026-03-06 18:45:18 +05:30
aks07
9ad2db4b99 feat: scroll to selected span 2026-03-06 16:00:48 +05:30
aks07
07fd5f70ef feat: fix timerange unit selection when zoomed 2026-03-06 12:55:34 +05:30
aks07
ba79121795 feat: temp change 2026-03-06 12:49:06 +05:30
aks07
6e4e419b5e Merge branch 'main' of github.com:SigNoz/signoz into feat/trace-flamegraph 2026-03-06 10:30:41 +05:30
aks07
2f06afaf27 feat: handle click and hover with tooltip 2026-03-05 23:25:10 +05:30
aks07
f77c3cb23c feat: update span colors 2026-03-05 22:40:06 +05:30
aks07
9e3a8efcfc feat: zoom and drag added 2026-03-05 18:22:55 +05:30
aks07
8e325ba8b3 feat: added timeline v3 2026-03-05 12:31:17 +05:30
aks07
884f516766 feat: add text to spans 2026-03-03 12:20:06 +05:30
aks07
4bcbb4ffc3 feat: flamegraph canvas init 2026-03-02 19:21:23 +05:30
27 changed files with 3884 additions and 11 deletions

View File

@@ -0,0 +1,4 @@
.timeline-v3-container {
// flex: 1;
overflow: visible;
}

View File

@@ -0,0 +1,87 @@
import { useEffect, useState } from 'react';
import { useMeasure } from 'react-use';
import { useIsDarkMode } from 'hooks/useDarkMode';
import {
getIntervals,
getMinimumIntervalsBasedOnWidth,
Interval,
} from './utils';
import './TimelineV3.styles.scss';
interface ITimelineV3Props {
startTimestamp: number;
endTimestamp: number;
timelineHeight: number;
offsetTimestamp: number;
}
function TimelineV3(props: ITimelineV3Props): JSX.Element {
const {
startTimestamp,
endTimestamp,
timelineHeight,
offsetTimestamp,
} = props;
const [intervals, setIntervals] = useState<Interval[]>([]);
const [ref, { width }] = useMeasure<HTMLDivElement>();
const isDarkMode = useIsDarkMode();
useEffect(() => {
const spread = endTimestamp - startTimestamp;
if (spread < 0) {
return;
}
const minIntervals = getMinimumIntervalsBasedOnWidth(width);
const intervalisedSpread = (spread / minIntervals) * 1.0;
const intervals = getIntervals(intervalisedSpread, spread, offsetTimestamp);
setIntervals(intervals);
}, [startTimestamp, endTimestamp, width, offsetTimestamp]);
if (endTimestamp < startTimestamp) {
console.error(
'endTimestamp cannot be less than startTimestamp',
startTimestamp,
endTimestamp,
);
return <div />;
}
const strokeColor = isDarkMode ? ' rgb(192,193,195,0.8)' : 'black';
return (
<div ref={ref as never} className="timeline-v3-container">
<svg
width={width}
height={timelineHeight * 2.5}
xmlns="http://www.w3.org/2000/svg"
overflow="visible"
>
{intervals &&
intervals.length > 0 &&
intervals.map((interval, index) => (
<g
transform={`translate(${(interval.percentage * width) / 100},0)`}
key={`${interval.percentage + interval.label + index}`}
textAnchor="middle"
fontSize="0.6rem"
>
<text
x={index === intervals.length - 1 ? -10 : 0}
y={timelineHeight * 2}
fill={strokeColor}
>
{interval.label}
</text>
<line y1={0} y2={timelineHeight} stroke={strokeColor} strokeWidth="1" />
</g>
))}
</svg>
</div>
);
}
export default TimelineV3;

View File

@@ -0,0 +1,93 @@
import {
IIntervalUnit,
Interval,
INTERVAL_UNITS,
resolveTimeFromInterval,
} from 'components/TimelineV2/utils';
import { toFixed } from 'utils/toFixed';
export type { Interval };
/** Fewer intervals than TimelineV2 for a cleaner flamegraph ruler. */
export function getMinimumIntervalsBasedOnWidth(width: number): number {
if (width < 640) {
return 3;
}
if (width < 768) {
return 4;
}
if (width < 1024) {
return 5;
}
return 6;
}
/**
* Computes timeline intervals with offset-aware labels.
* Labels reflect absolute time from trace start (offsetTimestamp + elapsed),
* so when zoomed into a window, the first tick shows e.g. "50ms" not "0ms".
*/
export function getIntervals(
intervalSpread: number,
baseSpread: number,
offsetTimestamp: number,
): Interval[] {
const integerPartString = intervalSpread.toString().split('.')[0];
const integerPartLength = integerPartString.length;
const intervalSpreadNormalized =
intervalSpread < 1.0
? intervalSpread
: Math.floor(Number(integerPartString) / 10 ** (integerPartLength - 1)) *
10 ** (integerPartLength - 1);
// Unit must suit both: (1) tick granularity (intervalSpread) and (2) label magnitude
// (offsetTimestamp). When zoomed deep into a trace, labels show offsetTimestamp + elapsed,
// so we must pick a unit where that value is readable (e.g. "500.00s" not "500000.00ms").
const valueForUnitSelection = Math.max(offsetTimestamp, intervalSpread);
let intervalUnit: IIntervalUnit = INTERVAL_UNITS[0];
for (let idx = INTERVAL_UNITS.length - 1; idx >= 0; idx -= 1) {
const standardInterval = INTERVAL_UNITS[idx];
if (valueForUnitSelection * standardInterval.multiplier >= 1) {
intervalUnit = INTERVAL_UNITS[idx];
break;
}
}
const intervals: Interval[] = [
{
label: `${toFixed(
resolveTimeFromInterval(offsetTimestamp, intervalUnit),
2,
)}${intervalUnit.name}`,
percentage: 0,
},
];
let tempBaseSpread = baseSpread;
let elapsedIntervals = 0;
while (tempBaseSpread && intervals.length < 20) {
let intervalTime: number;
if (tempBaseSpread <= 1.5 * intervalSpreadNormalized) {
intervalTime = elapsedIntervals + tempBaseSpread;
tempBaseSpread = 0;
} else {
intervalTime = elapsedIntervals + intervalSpreadNormalized;
tempBaseSpread -= intervalSpreadNormalized;
}
elapsedIntervals = intervalTime;
const labelTime = offsetTimestamp + intervalTime;
intervals.push({
label: `${toFixed(resolveTimeFromInterval(labelTime, intervalUnit), 2)}${
intervalUnit.name
}`,
percentage: (intervalTime / baseSpread) * 100,
});
}
return intervals;
}

View File

@@ -33,6 +33,125 @@ const themeColors = {
purple: '#800080',
cyan: '#00FFFF',
},
traceDetailColorsV3: {
// Blues
dodgerBlue: '#2F80ED',
royalBlue: '#3366E6',
steelBlue: '#4682B4',
// Teals / Cyans
turquoise: '#00CEC9',
lagoon: '#1ABC9C',
cyanBright: '#22A6F2',
// Greens
emeraldGreen: '#27AE60',
mediumSeaGreen: '#3CB371',
limeGreen: '#A3E635',
// Yellows / Golds
festivalYellow: '#F2C94C',
sunflower: '#FFD93D',
warmAmber: '#FFCA28',
// Purples / Violets
mediumPurple: '#BB6BD9',
royalPurple: '#9B51E0',
orchid: '#DA77F2',
// Accent
neonViolet: '#C77DFF',
electricPurple: '#6C5CE7',
arcticBlue: '#48DBFB',
// Blues extended
blue1: '#1F63E0',
blue2: '#3A7AED',
blue3: '#5A9DF5',
blue4: '#2874A6',
blue5: '#2E86C1',
blue6: '#3498DB',
// Cyans
cyan1: '#00B0AA',
cyan2: '#33D6C2',
cyan3: '#66E9DA',
// Greens extended
green1: '#1E8449',
green2: '#2ECC71',
green3: '#58D68D',
green4: '#229954',
green5: '#27AE60',
green6: '#52BE80',
// Forest
forest1: '#27AE60',
forest2: '#2ECC71',
forest3: '#58D68D',
// Lime
lime1: '#A3E635',
lime2: '#B9F18D',
lime3: '#D4FFB0',
// Teals
teal1: '#009688',
teal2: '#1ABC9C',
teal3: '#48C9B0',
teal4: '#1ABC9C',
teal5: '#48C9B0',
teal6: '#76D7C4',
// Yellows
yellow1: '#F1C40F',
yellow2: '#F7DC6F',
yellow3: '#F9E79F',
// Gold
gold1: '#F39C12',
gold2: '#F1C40F',
gold3: '#F7DC6F',
gold4: '#B7950B',
gold5: '#F1C40F',
gold6: '#F4D03F',
// Mustard
mustard1: '#F1C40F',
mustard2: '#F7DC6F',
mustard3: '#F9E79F',
// Aqua
aqua1: '#00BFFF',
aqua2: '#1E90FF',
aqua3: '#63B8FF',
// Purple extended
purple1: '#8E44AD',
purple2: '#9B59B6',
purple3: '#BB8FCE',
violet1: '#8E44AD',
violet2: '#9B59B6',
violet3: '#BB8FCE',
violet4: '#7D3C98',
violet5: '#8E44AD',
violet6: '#9B59B6',
// Lavender
lavender1: '#9B59B6',
lavender2: '#AF7AC5',
lavender3: '#C39BD3',
// Oranges (safe ones, not red-ish)
orange4: '#D35400',
orange5: '#E67E22',
orange6: '#EB984E',
coral1: '#E67E22',
coral2: '#F39C12',
coral3: '#F5B041',
},
chartcolors: {
// Blues (3)
dodgerBlue: '#2F80ED',

View File

@@ -4,19 +4,11 @@ import ROUTES from 'constants/routes';
import history from 'lib/history';
import { Compass, Cone, TowerControl } from 'lucide-react';
import TraceDetailsV2 from './TraceDetailV2';
import TraceDetailsV3 from '../TraceDetailsV3';
import './TraceDetailV2.styles.scss';
interface INewTraceDetailProps {
items: {
label: JSX.Element;
key: string;
children: JSX.Element;
}[];
}
function NewTraceDetail(props: INewTraceDetailProps): JSX.Element {
function NewTraceDetail(props: any): JSX.Element {
const { items } = props;
return (
<div className="traces-module-container">
@@ -50,7 +42,7 @@ export default function TraceDetailsPage(): JSX.Element {
</div>
),
key: 'trace-details',
children: <TraceDetailsV2 />,
children: <TraceDetailsV3 />,
},
{
label: (

View File

@@ -0,0 +1,91 @@
.trace-details-header {
display: flex;
align-items: center;
padding: 0px 16px;
.previous-btn {
display: flex;
height: 30px;
padding: 6px 8px;
align-items: center;
gap: 4px;
border: 1px solid var(--bg-slate-300);
background: var(--bg-slate-500);
border-radius: 4px;
box-shadow: none;
}
.trace-name {
display: flex;
padding: 6px 8px;
margin-left: 6px;
align-items: center;
gap: 4px;
border: 1px solid var(--bg-slate-300);
border-radius: 4px 0px 0px 4px;
background: var(--bg-slate-500);
.drafting {
color: white;
}
.trace-id {
color: #fff;
font-family: Inter;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 18px;
letter-spacing: -0.07px;
}
}
.trace-id-value {
display: flex;
padding: 6px 8px;
justify-content: center;
align-items: center;
gap: 10px;
background: var(--bg-slate-400);
color: var(--Vanilla-400, #c0c1c3);
font-family: Inter;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 18px;
letter-spacing: -0.07px;
border: 1px solid var(--bg-slate-300);
border-left: unset;
border-radius: 0px 4px 4px 0px;
}
}
.lightMode {
.trace-details-header {
.previous-btn {
border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-200);
}
.trace-name {
border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-200);
border-right: none;
.drafting {
color: var(--bg-ink-100);
}
.trace-id {
color: var(--bg-ink-100);
}
}
.trace-id-value {
background: var(--bg-vanilla-300);
color: var(--bg-ink-400);
border: 1px solid var(--bg-vanilla-300);
}
}
}
// TODO: move to new css module name system

View File

@@ -0,0 +1,38 @@
import { useCallback } from 'react';
import { useParams } from 'react-router-dom';
import { Button, Typography } from 'antd';
import ROUTES from 'constants/routes';
import history from 'lib/history';
import { ArrowLeft } from 'lucide-react';
import { TraceDetailV2URLProps } from 'types/api/trace/getTraceV2';
import './TraceDetailsHeader.styles.scss';
function TraceDetailsHeader(): JSX.Element {
const { id: traceID } = useParams<TraceDetailV2URLProps>();
const handlePreviousBtnClick = useCallback((): void => {
const isSpaNavigate =
document.referrer &&
new URL(document.referrer).origin === window.location.origin;
if (isSpaNavigate) {
history.goBack();
} else {
history.push(ROUTES.TRACES_EXPLORER);
}
}, []);
return (
<div className="trace-details-header">
<Button className="previous-btn" onClick={handlePreviousBtnClick}>
<ArrowLeft size={14} />
</Button>
<div className="trace-name">
<Typography.Text className="trace-id">Trace ID</Typography.Text>
</div>
<Typography.Text className="trace-id-value">{traceID}</Typography.Text>
</div>
);
}
export default TraceDetailsHeader;

View File

@@ -0,0 +1,237 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import TimelineV3 from 'components/TimelineV3/TimelineV3';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { DEFAULT_ROW_HEIGHT } from './constants';
import { useCanvasSetup } from './hooks/useCanvasSetup';
import { useFlamegraphDrag } from './hooks/useFlamegraphDrag';
import { useFlamegraphDraw } from './hooks/useFlamegraphDraw';
import { useFlamegraphHover } from './hooks/useFlamegraphHover';
import { useFlamegraphZoom } from './hooks/useFlamegraphZoom';
import { useScrollToSpan } from './hooks/useScrollToSpan';
import { FlamegraphCanvasProps, SpanRect } from './types';
import { formatDuration } from './utils';
function FlamegraphCanvas(props: FlamegraphCanvasProps): JSX.Element {
const { spans, traceMetadata, firstSpanAtFetchLevel, onSpanClick } = props;
const isDarkMode = useIsDarkMode(); //TODO: see if can be removed or use a new hook
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const spanRectsRef = useRef<SpanRect[]>([]);
const [viewStartTs, setViewStartTs] = useState<number>(
traceMetadata.startTime,
);
const [viewEndTs, setViewEndTs] = useState<number>(traceMetadata.endTime);
const [scrollTop, setScrollTop] = useState<number>(0);
const [rowHeight, setRowHeight] = useState<number>(DEFAULT_ROW_HEIGHT);
// Mutable refs for zoom and drag hooks to read during rAF / mouse callbacks
const viewStartRef = useRef(viewStartTs);
const viewEndRef = useRef(viewEndTs);
const rowHeightRef = useRef(rowHeight);
const scrollTopRef = useRef(scrollTop);
useEffect(() => {
viewStartRef.current = viewStartTs;
}, [viewStartTs]);
useEffect(() => {
viewEndRef.current = viewEndTs;
}, [viewEndTs]);
useEffect(() => {
rowHeightRef.current = rowHeight;
}, [rowHeight]);
useEffect(() => {
scrollTopRef.current = scrollTop;
}, [scrollTop]);
useEffect(() => {
//TODO: see if this can be removed as once loaded the view start and end ts will not change
setViewStartTs(traceMetadata.startTime);
setViewEndTs(traceMetadata.endTime);
viewStartRef.current = traceMetadata.startTime;
viewEndRef.current = traceMetadata.endTime;
}, [traceMetadata.startTime, traceMetadata.endTime]);
const totalHeight = spans.length * rowHeight;
const { isOverFlamegraphRef } = useFlamegraphZoom({
canvasRef,
traceMetadata,
viewStartRef,
viewEndRef,
rowHeightRef,
setViewStartTs,
setViewEndTs,
setRowHeight,
});
const {
handleMouseDown,
handleMouseMove: handleDragMouseMove,
handleMouseUp,
handleDragMouseLeave,
suppressClickRef,
isDraggingRef,
} = useFlamegraphDrag({
canvasRef,
containerRef,
traceMetadata,
viewStartRef,
viewEndRef,
setViewStartTs,
setViewEndTs,
scrollTopRef,
setScrollTop,
totalHeight,
});
const {
hoveredSpanId,
handleHoverMouseMove,
handleHoverMouseLeave,
handleClick,
tooltipContent,
} = useFlamegraphHover({
canvasRef,
spanRectsRef,
traceMetadata,
viewStartTs,
viewEndTs,
isDraggingRef,
suppressClickRef,
onSpanClick,
isDarkMode,
});
const { drawFlamegraph } = useFlamegraphDraw({
canvasRef,
containerRef,
spans,
viewStartTs,
viewEndTs,
scrollTop,
rowHeight,
selectedSpanId: firstSpanAtFetchLevel || undefined,
hoveredSpanId: hoveredSpanId ?? '',
isDarkMode,
spanRectsRef,
});
useScrollToSpan({
firstSpanAtFetchLevel,
spans,
traceMetadata,
containerRef,
viewStartRef,
viewEndRef,
scrollTopRef,
rowHeight,
setViewStartTs,
setViewEndTs,
setScrollTop,
});
useCanvasSetup(canvasRef, containerRef, drawFlamegraph);
const handleMouseMove = useCallback(
(e: React.MouseEvent): void => {
handleDragMouseMove(e);
handleHoverMouseMove(e);
},
[handleDragMouseMove, handleHoverMouseMove],
);
const handleMouseLeave = useCallback((): void => {
isOverFlamegraphRef.current = false;
handleDragMouseLeave();
handleHoverMouseLeave();
}, [isOverFlamegraphRef, handleDragMouseLeave, handleHoverMouseLeave]);
// todo: move to a separate component/utils file
const tooltipElement = tooltipContent
? createPortal(
<div
style={{
position: 'fixed',
left: Math.min(tooltipContent.clientX + 15, window.innerWidth - 220),
top: Math.min(tooltipContent.clientY + 15, window.innerHeight - 100),
zIndex: 1000,
backgroundColor: 'rgba(30, 30, 30, 0.95)',
color: '#fff',
padding: '8px 12px',
borderRadius: 4,
fontSize: 12,
fontFamily: 'Inter, sans-serif',
boxShadow: '0 2px 8px rgba(0,0,0,0.3)',
pointerEvents: 'none',
}}
>
<div
style={{
fontWeight: 600,
marginBottom: 4,
color: tooltipContent.spanColor,
}}
>
{tooltipContent.spanName}
</div>
<div>Status: {tooltipContent.status}</div>
<div>Start: {tooltipContent.startMs.toFixed(2)} ms</div>
<div>Duration: {formatDuration(tooltipContent.durationMs * 1e6)}</div>
</div>,
document.body,
)
: null;
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
height: '100%',
padding: '0 15px',
}}
>
{tooltipElement}
<TimelineV3
startTimestamp={viewStartTs}
endTimestamp={viewEndTs}
offsetTimestamp={viewStartTs - traceMetadata.startTime}
timelineHeight={10}
/>
<div
ref={containerRef}
style={{
flex: 1,
overflow: 'hidden',
position: 'relative',
}}
onMouseEnter={(): void => {
isOverFlamegraphRef.current = true;
}}
onMouseLeave={handleMouseLeave}
>
<canvas
ref={canvasRef}
style={{
display: 'block',
width: '100%',
cursor: 'grab',
}}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onClick={handleClick}
/>
</div>
</div>
);
}
export default FlamegraphCanvas;

View File

@@ -0,0 +1,107 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useHistory, useLocation, useParams } from 'react-router-dom';
import useGetTraceFlamegraph from 'hooks/trace/useGetTraceFlamegraph';
import useUrlQuery from 'hooks/useUrlQuery';
import { TraceDetailFlamegraphURLProps } from 'types/api/trace/getTraceFlamegraph';
import FlamegraphCanvas from './FlamegraphCanvas';
//TODO: analyse if this is needed or not and move to separate file if needed else delete this enum.
enum TraceFlamegraphState {
LOADING = 'LOADING',
SUCCESS = 'SUCCESS',
NO_DATA = 'NO_DATA',
ERROR = 'ERROR',
FETCHING_WITH_OLD_DATA = 'FETCHING_WITH_OLD_DATA',
}
function TraceFlamegraph(): JSX.Element {
const { id: traceId } = useParams<TraceDetailFlamegraphURLProps>();
const urlQuery = useUrlQuery();
const history = useHistory();
const { search } = useLocation();
const [firstSpanAtFetchLevel, setFirstSpanAtFetchLevel] = useState<string>(
urlQuery.get('spanId') || '',
);
useEffect(() => {
setFirstSpanAtFetchLevel(urlQuery.get('spanId') || '');
}, [urlQuery]);
const handleSpanClick = useCallback(
(spanId: string): void => {
setFirstSpanAtFetchLevel(spanId);
const searchParams = new URLSearchParams(search);
//tood: use from query params constants
if (searchParams.get('spanId') !== spanId) {
searchParams.set('spanId', spanId);
history.replace({ search: searchParams.toString() });
}
},
[history, search],
);
const { data, isFetching, error } = useGetTraceFlamegraph({
traceId,
selectedSpanId: firstSpanAtFetchLevel,
});
const flamegraphState = useMemo(() => {
if (isFetching) {
if (data?.payload?.spans && data.payload.spans.length > 0) {
return TraceFlamegraphState.FETCHING_WITH_OLD_DATA;
}
return TraceFlamegraphState.LOADING;
}
if (error) {
return TraceFlamegraphState.ERROR;
}
if (data?.payload?.spans && data.payload.spans.length === 0) {
return TraceFlamegraphState.NO_DATA;
}
return TraceFlamegraphState.SUCCESS;
}, [error, isFetching, data]);
const spans = useMemo(() => data?.payload?.spans || [], [
data?.payload?.spans,
]);
const content = useMemo(() => {
switch (flamegraphState) {
case TraceFlamegraphState.LOADING:
return <div>Loading...</div>;
case TraceFlamegraphState.ERROR:
return <div>Error loading flamegraph</div>;
case TraceFlamegraphState.NO_DATA:
return <div>No data found for trace {traceId}</div>;
case TraceFlamegraphState.SUCCESS:
case TraceFlamegraphState.FETCHING_WITH_OLD_DATA:
return (
<FlamegraphCanvas
spans={spans}
firstSpanAtFetchLevel={firstSpanAtFetchLevel}
setFirstSpanAtFetchLevel={setFirstSpanAtFetchLevel}
onSpanClick={handleSpanClick}
traceMetadata={{
startTime: data?.payload?.startTimestampMillis || 0,
endTime: data?.payload?.endTimestampMillis || 0,
}}
/>
);
default:
return <div>Fetching the trace...</div>;
}
}, [
data?.payload?.endTimestampMillis,
data?.payload?.startTimestampMillis,
firstSpanAtFetchLevel,
flamegraphState,
spans,
traceId,
handleSpanClick,
]);
return <>{content}</>;
}
export default TraceFlamegraph;

View File

@@ -0,0 +1,525 @@
import { DASHED_BORDER_LINE_DASH, MIN_WIDTH_FOR_NAME } from '../constants';
import type { FlamegraphRowMetrics } from '../utils';
import { getFlamegraphRowMetrics } from '../utils';
import { drawEventDot, drawSpanBar } from '../utils';
import { MOCK_SPAN } from './testUtils';
jest.mock('container/TraceDetail/utils', () => ({
convertTimeToRelevantUnit: (): { time: number; timeUnitName: string } => ({
time: 50,
timeUnitName: 'ms',
}),
}));
/** Minimal 2D context for createStripePattern's internal canvas (jsdom getContext often returns null) */
const mockPatternCanvasCtx = {
beginPath: jest.fn(),
moveTo: jest.fn(),
lineTo: jest.fn(),
stroke: jest.fn(),
globalAlpha: 1,
};
const originalCreateElement = document.createElement.bind(document);
document.createElement = function (
tagName: string,
): ReturnType<typeof originalCreateElement> {
const el = originalCreateElement(tagName);
if (tagName.toLowerCase() === 'canvas') {
(el as HTMLCanvasElement).getContext = (() =>
mockPatternCanvasCtx as unknown) as HTMLCanvasElement['getContext'];
}
return el;
};
function createMockCtx(): jest.Mocked<CanvasRenderingContext2D> {
return ({
beginPath: jest.fn(),
roundRect: jest.fn(),
fill: jest.fn(),
stroke: jest.fn(),
save: jest.fn(),
restore: jest.fn(),
translate: jest.fn(),
rotate: jest.fn(),
fillRect: jest.fn(),
strokeRect: jest.fn(),
setLineDash: jest.fn(),
measureText: jest.fn(
(text: string) => ({ width: text.length * 6 } as TextMetrics),
),
createPattern: jest.fn(() => ({} as CanvasPattern)),
clip: jest.fn(),
rect: jest.fn(),
fillText: jest.fn(),
font: '',
fillStyle: '',
strokeStyle: '',
textAlign: '',
textBaseline: '',
lineWidth: 0,
globalAlpha: 1,
} as unknown) as jest.Mocked<CanvasRenderingContext2D>;
}
const METRICS: FlamegraphRowMetrics = getFlamegraphRowMetrics(24);
describe('Canvas Draw Utils', () => {
describe('drawSpanBar', () => {
it('draws rect + fill for normal span (no selected/hovered)', () => {
const ctx = createMockCtx();
const spanRectsArray: {
span: typeof MOCK_SPAN;
x: number;
y: number;
width: number;
height: number;
level: number;
}[] = [];
drawSpanBar({
ctx,
span: { ...MOCK_SPAN, event: [] },
x: 10,
y: 0,
width: 100,
levelIndex: 0,
spanRectsArray,
color: '#1890ff',
isDarkMode: false,
metrics: METRICS,
});
expect(ctx.beginPath).toHaveBeenCalled();
expect(ctx.roundRect).toHaveBeenCalledWith(10, 1, 100, 22, 2);
expect(ctx.fill).toHaveBeenCalled();
expect(ctx.stroke).not.toHaveBeenCalled();
expect(spanRectsArray).toHaveLength(1);
expect(spanRectsArray[0]).toMatchObject({
x: 10,
y: 1,
width: 100,
height: 22,
level: 0,
});
});
it('uses stripe pattern + dashed stroke + 2px when selected', () => {
const ctx = createMockCtx();
const spanRectsArray: {
span: typeof MOCK_SPAN;
x: number;
y: number;
width: number;
height: number;
level: number;
}[] = [];
drawSpanBar({
ctx,
span: { ...MOCK_SPAN, spanId: 'sel', event: [] },
x: 20,
y: 0,
width: 80,
levelIndex: 1,
spanRectsArray,
color: '#2F80ED',
isDarkMode: false,
metrics: METRICS,
selectedSpanId: 'sel',
});
expect(ctx.createPattern).toHaveBeenCalled();
expect(ctx.setLineDash).toHaveBeenCalledWith(DASHED_BORDER_LINE_DASH);
expect(ctx.strokeStyle).toBe('#2F80ED');
expect(ctx.lineWidth).toBe(2);
expect(ctx.stroke).toHaveBeenCalled();
expect(ctx.setLineDash).toHaveBeenLastCalledWith([]);
});
it('uses stripe pattern + solid stroke + 1px when hovered (not selected)', () => {
const ctx = createMockCtx();
const spanRectsArray: {
span: typeof MOCK_SPAN;
x: number;
y: number;
width: number;
height: number;
level: number;
}[] = [];
drawSpanBar({
ctx,
span: { ...MOCK_SPAN, spanId: 'hov', event: [] },
x: 30,
y: 0,
width: 60,
levelIndex: 0,
spanRectsArray,
color: '#2F80ED',
isDarkMode: false,
metrics: METRICS,
hoveredSpanId: 'hov',
});
expect(ctx.createPattern).toHaveBeenCalled();
expect(ctx.setLineDash).not.toHaveBeenCalled();
expect(ctx.lineWidth).toBe(1);
expect(ctx.stroke).toHaveBeenCalled();
});
it('pushes spanRectsArray with correct dimensions', () => {
const ctx = createMockCtx();
const spanRectsArray: {
span: typeof MOCK_SPAN;
x: number;
y: number;
width: number;
height: number;
level: number;
}[] = [];
drawSpanBar({
ctx,
span: { ...MOCK_SPAN, spanId: 'rect-test', event: [] },
x: 5,
y: 24,
width: 200,
levelIndex: 2,
spanRectsArray,
color: '#000',
isDarkMode: false,
metrics: METRICS,
});
expect(spanRectsArray[0]).toMatchObject({
x: 5,
y: 25,
width: 200,
height: 22,
level: 2,
});
expect(spanRectsArray[0].span.spanId).toBe('rect-test');
});
});
describe('drawSpanLabel (via drawSpanBar)', () => {
it('skips label when width < MIN_WIDTH_FOR_NAME', () => {
const ctx = createMockCtx();
const spanRectsArray: {
span: typeof MOCK_SPAN;
x: number;
y: number;
width: number;
height: number;
level: number;
}[] = [];
drawSpanBar({
ctx,
span: { ...MOCK_SPAN, name: 'long-span-name', event: [] },
x: 0,
y: 0,
width: MIN_WIDTH_FOR_NAME - 1,
levelIndex: 0,
spanRectsArray,
color: '#000',
isDarkMode: false,
metrics: METRICS,
});
expect(ctx.clip).not.toHaveBeenCalled();
expect(ctx.fillText).not.toHaveBeenCalled();
});
it('draws name only when width >= MIN_WIDTH_FOR_NAME but < MIN_WIDTH_FOR_NAME_AND_DURATION', () => {
const ctx = createMockCtx();
ctx.measureText = jest.fn(
(t: string) => ({ width: t.length * 6 } as TextMetrics),
);
drawSpanBar({
ctx,
span: { ...MOCK_SPAN, name: 'foo', event: [] },
x: 0,
y: 0,
width: 50,
levelIndex: 0,
spanRectsArray: [],
color: '#000',
isDarkMode: false,
metrics: METRICS,
});
expect(ctx.clip).toHaveBeenCalled();
expect(ctx.fillText).toHaveBeenCalled();
expect(ctx.textAlign).toBe('left');
});
it('draws name + duration when width >= MIN_WIDTH_FOR_NAME_AND_DURATION', () => {
const ctx = createMockCtx();
ctx.measureText = jest.fn(
(t: string) => ({ width: t.length * 6 } as TextMetrics),
);
drawSpanBar({
ctx,
span: { ...MOCK_SPAN, name: 'my-span', event: [] },
x: 0,
y: 0,
width: 100,
levelIndex: 0,
spanRectsArray: [],
color: '#000',
isDarkMode: false,
metrics: METRICS,
});
expect(ctx.fillText).toHaveBeenCalledTimes(2);
expect(ctx.fillText).toHaveBeenCalledWith(
'50ms',
expect.any(Number),
expect.any(Number),
);
expect(ctx.fillText).toHaveBeenCalledWith(
'my-span',
expect.any(Number),
expect.any(Number),
);
});
});
describe('truncateText (via drawSpanBar)', () => {
it('uses full text when it fits', () => {
const ctx = createMockCtx();
ctx.measureText = jest.fn(
(t: string) => ({ width: t.length * 4 } as TextMetrics),
);
drawSpanBar({
ctx,
span: { ...MOCK_SPAN, name: 'short', event: [] },
x: 0,
y: 0,
width: 100,
levelIndex: 0,
spanRectsArray: [],
color: '#000',
isDarkMode: false,
metrics: METRICS,
});
expect(ctx.fillText).toHaveBeenCalledWith(
'short',
expect.any(Number),
expect.any(Number),
);
});
it('truncates text when it exceeds available width', () => {
const ctx = createMockCtx();
ctx.measureText = jest.fn(
(t: string) =>
({
width: t.includes('...') ? 24 : t.length * 10,
} as TextMetrics),
);
drawSpanBar({
ctx,
span: { ...MOCK_SPAN, name: 'very-long-span-name', event: [] },
x: 0,
y: 0,
width: 50,
levelIndex: 0,
spanRectsArray: [],
color: '#000',
isDarkMode: false,
metrics: METRICS,
});
const fillTextCalls = (ctx.fillText as jest.Mock).mock.calls;
const nameArg = fillTextCalls.find((c) => c[0] !== '50ms')?.[0];
expect(nameArg).toBeDefined();
expect(nameArg).toMatch(/\.\.\.$/);
});
});
describe('drawEventDot', () => {
it('uses error styling when isError is true', () => {
const ctx = createMockCtx();
drawEventDot({
ctx,
x: 50,
y: 11,
isError: true,
isDarkMode: false,
eventDotSize: 6,
});
expect(ctx.save).toHaveBeenCalled();
expect(ctx.translate).toHaveBeenCalledWith(50, 11);
expect(ctx.rotate).toHaveBeenCalledWith(Math.PI / 4);
expect(ctx.fillStyle).toBe('rgb(220, 38, 38)');
expect(ctx.strokeStyle).toBe('rgb(153, 27, 27)');
expect(ctx.fillRect).toHaveBeenCalledWith(-3, -3, 6, 6);
expect(ctx.strokeRect).toHaveBeenCalledWith(-3, -3, 6, 6);
expect(ctx.restore).toHaveBeenCalled();
});
it('uses normal styling when isError is false', () => {
const ctx = createMockCtx();
drawEventDot({
ctx,
x: 0,
y: 0,
isError: false,
isDarkMode: false,
eventDotSize: 6,
});
expect(ctx.fillStyle).toBe('rgb(6, 182, 212)');
expect(ctx.strokeStyle).toBe('rgb(8, 145, 178)');
});
it('uses dark mode colors for error', () => {
const ctx = createMockCtx();
drawEventDot({
ctx,
x: 0,
y: 0,
isError: true,
isDarkMode: true,
eventDotSize: 6,
});
expect(ctx.fillStyle).toBe('rgb(239, 68, 68)');
expect(ctx.strokeStyle).toBe('rgb(185, 28, 28)');
});
it('uses dark mode colors for non-error', () => {
const ctx = createMockCtx();
drawEventDot({
ctx,
x: 0,
y: 0,
isError: false,
isDarkMode: true,
eventDotSize: 6,
});
expect(ctx.fillStyle).toBe('rgb(14, 165, 233)');
expect(ctx.strokeStyle).toBe('rgb(2, 132, 199)');
});
it('calls save, translate, rotate, restore', () => {
const ctx = createMockCtx();
drawEventDot({
ctx,
x: 10,
y: 20,
isError: false,
isDarkMode: false,
eventDotSize: 4,
});
expect(ctx.save).toHaveBeenCalled();
expect(ctx.translate).toHaveBeenCalledWith(10, 20);
expect(ctx.rotate).toHaveBeenCalledWith(Math.PI / 4);
expect(ctx.restore).toHaveBeenCalled();
});
});
describe('createStripePattern (via drawSpanBar)', () => {
it('uses pattern when createPattern returns non-null', () => {
const ctx = createMockCtx();
const mockPattern = {} as CanvasPattern;
(ctx.createPattern as jest.Mock).mockReturnValue(mockPattern);
drawSpanBar({
ctx,
span: { ...MOCK_SPAN, spanId: 'p', event: [] },
x: 0,
y: 0,
width: MIN_WIDTH_FOR_NAME - 1,
levelIndex: 0,
spanRectsArray: [],
color: '#000',
isDarkMode: false,
metrics: METRICS,
hoveredSpanId: 'p',
});
expect(ctx.createPattern).toHaveBeenCalled();
expect(ctx.fillStyle).toBe(mockPattern);
expect(ctx.fill).toHaveBeenCalled();
});
it('skips fill when createPattern returns null', () => {
const ctx = createMockCtx();
(ctx.createPattern as jest.Mock).mockReturnValue(null);
drawSpanBar({
ctx,
span: { ...MOCK_SPAN, spanId: 'p', event: [] },
x: 0,
y: 0,
width: MIN_WIDTH_FOR_NAME - 1,
levelIndex: 0,
spanRectsArray: [],
color: '#000',
isDarkMode: false,
metrics: METRICS,
selectedSpanId: 'p',
});
expect(ctx.fill).not.toHaveBeenCalled();
expect(ctx.stroke).toHaveBeenCalled();
});
});
describe('drawSpanBar with events', () => {
it('draws event dots for each span event', () => {
const ctx = createMockCtx();
const spanWithEvents = {
...MOCK_SPAN,
event: [
{
name: 'e1',
timeUnixNano: 1_010_000_000,
attributeMap: {},
isError: false,
},
{
name: 'e2',
timeUnixNano: 1_025_000_000,
attributeMap: {},
isError: true,
},
],
};
drawSpanBar({
ctx,
span: spanWithEvents,
x: 0,
y: 0,
width: 100,
levelIndex: 0,
spanRectsArray: [],
color: '#000',
isDarkMode: false,
metrics: METRICS,
});
expect(ctx.save).toHaveBeenCalledTimes(3);
expect(ctx.translate).toHaveBeenCalledTimes(2);
expect(ctx.fillRect).toHaveBeenCalledTimes(2);
});
});
});

View File

@@ -0,0 +1,54 @@
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
/** Minimal FlamegraphSpan for unit tests */
export const MOCK_SPAN: FlamegraphSpan = {
timestamp: 1000,
durationNano: 50_000_000, // 50ms
spanId: 'span-1',
parentSpanId: '',
traceId: 'trace-1',
hasError: false,
serviceName: 'test-service',
name: 'test-span',
level: 0,
event: [],
};
/** Nested spans structure for findSpanById tests */
export const MOCK_SPANS: FlamegraphSpan[][] = [
[
{
...MOCK_SPAN,
spanId: 'root',
parentSpanId: '',
level: 0,
},
],
[
{
...MOCK_SPAN,
spanId: 'child-a',
parentSpanId: 'root',
level: 1,
},
{
...MOCK_SPAN,
spanId: 'child-b',
parentSpanId: 'root',
level: 1,
},
],
[
{
...MOCK_SPAN,
spanId: 'grandchild',
parentSpanId: 'child-a',
level: 2,
},
],
];
export const MOCK_TRACE_METADATA = {
startTime: 0,
endTime: 1000,
};

View File

@@ -0,0 +1,196 @@
import React from 'react';
import { act, renderHook } from '@testing-library/react';
import { useFlamegraphDrag } from '../hooks/useFlamegraphDrag';
import { MOCK_TRACE_METADATA } from './testUtils';
function createMockCanvas(): HTMLCanvasElement {
const canvas = document.createElement('canvas');
canvas.getBoundingClientRect = jest.fn(
(): DOMRect =>
({
left: 0,
top: 0,
width: 800,
height: 400,
x: 0,
y: 0,
bottom: 400,
right: 800,
toJSON: (): Record<string, unknown> => ({}),
} as DOMRect),
);
return canvas;
}
function createMockContainer(): HTMLDivElement {
const div = document.createElement('div');
Object.defineProperty(div, 'clientHeight', { value: 400 });
return div;
}
const defaultArgs = {
canvasRef: { current: createMockCanvas() },
containerRef: { current: createMockContainer() },
traceMetadata: MOCK_TRACE_METADATA,
viewStartRef: { current: 0 },
viewEndRef: { current: 1000 },
setViewStartTs: jest.fn(),
setViewEndTs: jest.fn(),
scrollTopRef: { current: 0 },
setScrollTop: jest.fn(),
totalHeight: 1000,
};
describe('useFlamegraphDrag', () => {
beforeEach(() => {
jest.clearAllMocks();
defaultArgs.viewStartRef.current = 0;
defaultArgs.viewEndRef.current = 1000;
defaultArgs.scrollTopRef.current = 0;
});
it('starts drag state on mousedown', () => {
const { result } = renderHook(() => useFlamegraphDrag(defaultArgs));
act(() => {
result.current.handleMouseDown(({
button: 0,
clientX: 100,
clientY: 50,
preventDefault: jest.fn(),
} as unknown) as React.MouseEvent);
});
expect(result.current.isDraggingRef.current).toBe(true);
});
it('ignores non-left button mousedown', () => {
const { result } = renderHook(() => useFlamegraphDrag(defaultArgs));
act(() => {
result.current.handleMouseDown(({
button: 1,
clientX: 100,
clientY: 50,
preventDefault: jest.fn(),
} as unknown) as React.MouseEvent);
});
expect(result.current.isDraggingRef.current).toBe(false);
});
it('updates pan/scroll on mousemove', () => {
const { result } = renderHook(() => useFlamegraphDrag(defaultArgs));
act(() => {
result.current.handleMouseDown(({
button: 0,
clientX: 100,
clientY: 50,
preventDefault: jest.fn(),
} as unknown) as React.MouseEvent);
});
act(() => {
result.current.handleMouseMove(({
clientX: 150,
clientY: 100,
} as unknown) as React.MouseEvent);
});
expect(defaultArgs.setViewStartTs).toHaveBeenCalled();
expect(defaultArgs.setViewEndTs).toHaveBeenCalled();
expect(defaultArgs.setScrollTop).toHaveBeenCalled();
});
it('does not set suppressClickRef when movement is below threshold', () => {
const { result } = renderHook(() => useFlamegraphDrag(defaultArgs));
act(() => {
result.current.handleMouseDown(({
button: 0,
clientX: 100,
clientY: 50,
preventDefault: jest.fn(),
} as unknown) as React.MouseEvent);
});
act(() => {
result.current.handleMouseMove(({
clientX: 102,
clientY: 51,
} as unknown) as React.MouseEvent);
});
act(() => {
result.current.handleMouseUp();
});
expect(result.current.suppressClickRef.current).toBe(false);
});
it('sets suppressClickRef when drag exceeds threshold', () => {
const { result } = renderHook(() => useFlamegraphDrag(defaultArgs));
act(() => {
result.current.handleMouseDown(({
button: 0,
clientX: 100,
clientY: 50,
preventDefault: jest.fn(),
} as unknown) as React.MouseEvent);
});
act(() => {
result.current.handleMouseMove(({
clientX: 150,
clientY: 100,
} as unknown) as React.MouseEvent);
});
act(() => {
result.current.handleMouseUp();
});
expect(result.current.suppressClickRef.current).toBe(true);
});
it('resets drag state on mouseup', () => {
const { result } = renderHook(() => useFlamegraphDrag(defaultArgs));
act(() => {
result.current.handleMouseDown(({
button: 0,
clientX: 100,
clientY: 50,
preventDefault: jest.fn(),
} as unknown) as React.MouseEvent);
});
act(() => {
result.current.handleMouseUp();
});
expect(result.current.isDraggingRef.current).toBe(false);
});
it('cancels drag on mouseleave', () => {
const { result } = renderHook(() => useFlamegraphDrag(defaultArgs));
act(() => {
result.current.handleMouseDown(({
button: 0,
clientX: 100,
clientY: 50,
preventDefault: jest.fn(),
} as unknown) as React.MouseEvent);
});
act(() => {
result.current.handleDragMouseLeave();
});
expect(result.current.isDraggingRef.current).toBe(false);
});
});

View File

@@ -0,0 +1,174 @@
import type React from 'react';
import { act, renderHook } from '@testing-library/react';
import { useFlamegraphHover } from '../hooks/useFlamegraphHover';
import type { SpanRect } from '../types';
import { MOCK_SPAN, MOCK_TRACE_METADATA } from './testUtils';
function createMockCanvas(): HTMLCanvasElement {
const canvas = document.createElement('canvas');
canvas.width = 800;
canvas.height = 400;
canvas.getBoundingClientRect = jest.fn(
(): DOMRect =>
({
left: 0,
top: 0,
width: 800,
height: 400,
x: 0,
y: 0,
bottom: 400,
right: 800,
toJSON: (): Record<string, unknown> => ({}),
} as DOMRect),
);
return canvas;
}
const spanRect: SpanRect = {
span: { ...MOCK_SPAN, spanId: 'hover-span', name: 'test-span' },
x: 100,
y: 50,
width: 200,
height: 22,
level: 0,
};
const defaultArgs = {
canvasRef: { current: createMockCanvas() },
spanRectsRef: { current: [spanRect] },
traceMetadata: MOCK_TRACE_METADATA,
viewStartTs: MOCK_TRACE_METADATA.startTime,
viewEndTs: MOCK_TRACE_METADATA.endTime,
isDraggingRef: { current: false },
suppressClickRef: { current: false },
onSpanClick: jest.fn(),
isDarkMode: false,
};
describe('useFlamegraphHover', () => {
beforeEach(() => {
Object.defineProperty(window, 'devicePixelRatio', {
configurable: true,
value: 1,
});
jest.clearAllMocks();
defaultArgs.spanRectsRef.current = [spanRect];
defaultArgs.isDraggingRef.current = false;
defaultArgs.suppressClickRef.current = false;
});
it('sets hoveredSpanId and tooltipContent when hovering on span', () => {
const { result } = renderHook(() => useFlamegraphHover(defaultArgs));
act(() => {
result.current.handleHoverMouseMove({
clientX: 150,
clientY: 61,
} as React.MouseEvent);
});
expect(result.current.hoveredSpanId).toBe('hover-span');
expect(result.current.tooltipContent).not.toBeNull();
expect(result.current.tooltipContent?.spanName).toBe('test-span');
expect(result.current.tooltipContent?.clientX).toBe(150);
expect(result.current.tooltipContent?.clientY).toBe(61);
});
it('clears hover when moving to empty area', () => {
const { result } = renderHook(() => useFlamegraphHover(defaultArgs));
act(() => {
result.current.handleHoverMouseMove({
clientX: 150,
clientY: 61,
} as React.MouseEvent);
});
expect(result.current.hoveredSpanId).toBe('hover-span');
act(() => {
result.current.handleHoverMouseMove({
clientX: 10,
clientY: 10,
} as React.MouseEvent);
});
expect(result.current.hoveredSpanId).toBeNull();
expect(result.current.tooltipContent).toBeNull();
});
it('clears hover on mouse leave', () => {
const { result } = renderHook(() => useFlamegraphHover(defaultArgs));
act(() => {
result.current.handleHoverMouseMove({
clientX: 150,
clientY: 61,
} as React.MouseEvent);
});
act(() => {
result.current.handleHoverMouseLeave();
});
expect(result.current.hoveredSpanId).toBeNull();
expect(result.current.tooltipContent).toBeNull();
});
it('suppresses click when suppressClickRef is set', () => {
const { result } = renderHook(() => useFlamegraphHover(defaultArgs));
defaultArgs.suppressClickRef.current = true;
act(() => {
result.current.handleClick({
clientX: 150,
clientY: 61,
} as React.MouseEvent);
});
expect(defaultArgs.onSpanClick).not.toHaveBeenCalled();
});
it('calls onSpanClick when clicking on span', () => {
const { result } = renderHook(() => useFlamegraphHover(defaultArgs));
act(() => {
result.current.handleClick({
clientX: 150,
clientY: 61,
} as React.MouseEvent);
});
expect(defaultArgs.onSpanClick).toHaveBeenCalledWith('hover-span');
});
it('uses clientX/clientY for tooltip positioning', () => {
const { result } = renderHook(() => useFlamegraphHover(defaultArgs));
act(() => {
result.current.handleHoverMouseMove({
clientX: 200,
clientY: 60,
} as React.MouseEvent);
});
expect(result.current.tooltipContent?.clientX).toBe(200);
expect(result.current.tooltipContent?.clientY).toBe(60);
});
it('does not update hover during drag', () => {
const { result } = renderHook(() => useFlamegraphHover(defaultArgs));
defaultArgs.isDraggingRef.current = true;
act(() => {
result.current.handleHoverMouseMove({
clientX: 150,
clientY: 61,
} as React.MouseEvent);
});
expect(result.current.hoveredSpanId).toBeNull();
});
});

View File

@@ -0,0 +1,279 @@
import { act, renderHook } from '@testing-library/react';
import { DEFAULT_ROW_HEIGHT, MIN_VISIBLE_SPAN_MS } from '../constants';
import { useFlamegraphZoom } from '../hooks/useFlamegraphZoom';
import { MOCK_TRACE_METADATA } from './testUtils';
function createMockCanvas(): HTMLCanvasElement {
const canvas = document.createElement('canvas');
canvas.width = 800;
canvas.height = 400;
canvas.getBoundingClientRect = jest.fn(
(): DOMRect =>
({
left: 0,
top: 0,
width: 800,
height: 400,
x: 0,
y: 0,
bottom: 400,
right: 800,
toJSON: (): Record<string, unknown> => ({}),
} as DOMRect),
);
return canvas;
}
describe('useFlamegraphZoom', () => {
const traceMetadata = { ...MOCK_TRACE_METADATA };
beforeEach(() => {
Object.defineProperty(window, 'devicePixelRatio', {
configurable: true,
value: 1,
});
});
it('handleResetZoom restores traceMetadata.startTime/endTime', () => {
const setViewStartTs = jest.fn();
const setViewEndTs = jest.fn();
const setRowHeight = jest.fn();
const viewStartRef = { current: 100 };
const viewEndRef = { current: 500 };
const rowHeightRef = { current: 30 };
const canvasRef = { current: createMockCanvas() };
const { result } = renderHook(() =>
useFlamegraphZoom({
canvasRef,
traceMetadata,
viewStartRef,
viewEndRef,
rowHeightRef,
setViewStartTs,
setViewEndTs,
setRowHeight,
}),
);
act(() => {
result.current.handleResetZoom();
});
expect(setViewStartTs).toHaveBeenCalledWith(traceMetadata.startTime);
expect(setViewEndTs).toHaveBeenCalledWith(traceMetadata.endTime);
expect(setRowHeight).toHaveBeenCalledWith(DEFAULT_ROW_HEIGHT);
expect(viewStartRef.current).toBe(traceMetadata.startTime);
expect(viewEndRef.current).toBe(traceMetadata.endTime);
expect(rowHeightRef.current).toBe(DEFAULT_ROW_HEIGHT);
});
it('wheel zoom in decreases visible time range', async () => {
const setViewStartTs = jest.fn();
const setViewEndTs = jest.fn();
const setRowHeight = jest.fn();
const viewStartRef = { current: traceMetadata.startTime };
const viewEndRef = { current: traceMetadata.endTime };
const rowHeightRef = { current: DEFAULT_ROW_HEIGHT };
const canvas = createMockCanvas();
const canvasRef = { current: canvas };
renderHook(() =>
useFlamegraphZoom({
canvasRef,
traceMetadata,
viewStartRef,
viewEndRef,
rowHeightRef,
setViewStartTs,
setViewEndTs,
setRowHeight,
}),
);
const initialSpan = viewEndRef.current - viewStartRef.current;
await act(async () => {
canvas.dispatchEvent(
new WheelEvent('wheel', {
clientX: 400,
deltaY: -100,
bubbles: true,
}),
);
});
await act(async () => {
await new Promise((r) => requestAnimationFrame(r));
});
expect(setViewStartTs).toHaveBeenCalled();
expect(setViewEndTs).toHaveBeenCalled();
const [newStart] = setViewStartTs.mock.calls[0] ?? [];
const [newEnd] = setViewEndTs.mock.calls[0] ?? [];
if (newStart != null && newEnd != null) {
const newSpan = newEnd - newStart;
expect(newSpan).toBeLessThan(initialSpan);
}
});
it('wheel zoom out increases visible time range', async () => {
const setViewStartTs = jest.fn();
const setViewEndTs = jest.fn();
const setRowHeight = jest.fn();
const halfSpan = (traceMetadata.endTime - traceMetadata.startTime) / 2;
const viewStartRef = { current: traceMetadata.startTime + halfSpan * 0.25 };
const viewEndRef = { current: traceMetadata.startTime + halfSpan * 0.75 };
const rowHeightRef = { current: DEFAULT_ROW_HEIGHT };
const canvas = createMockCanvas();
const canvasRef = { current: canvas };
renderHook(() =>
useFlamegraphZoom({
canvasRef,
traceMetadata,
viewStartRef,
viewEndRef,
rowHeightRef,
setViewStartTs,
setViewEndTs,
setRowHeight,
}),
);
const initialSpan = viewEndRef.current - viewStartRef.current;
await act(async () => {
canvas.dispatchEvent(
new WheelEvent('wheel', {
clientX: 400,
deltaY: 100,
bubbles: true,
}),
);
});
await act(async () => {
await new Promise((r) => requestAnimationFrame(r));
});
expect(setViewStartTs).toHaveBeenCalled();
expect(setViewEndTs).toHaveBeenCalled();
const [newStart] = setViewStartTs.mock.calls[0] ?? [];
const [newEnd] = setViewEndTs.mock.calls[0] ?? [];
if (newStart != null && newEnd != null) {
const newSpan = newEnd - newStart;
expect(newSpan).toBeGreaterThanOrEqual(initialSpan);
}
});
it('clamps zoom to MIN_VISIBLE_SPAN_MS', async () => {
const setViewStartTs = jest.fn();
const setViewEndTs = jest.fn();
const setRowHeight = jest.fn();
const viewStartRef = { current: traceMetadata.startTime };
const viewEndRef = { current: traceMetadata.startTime + 100 };
const rowHeightRef = { current: DEFAULT_ROW_HEIGHT };
const canvas = createMockCanvas();
const canvasRef = { current: canvas };
renderHook(() =>
useFlamegraphZoom({
canvasRef,
traceMetadata,
viewStartRef,
viewEndRef,
rowHeightRef,
setViewStartTs,
setViewEndTs,
setRowHeight,
}),
);
await act(async () => {
canvas.dispatchEvent(
new WheelEvent('wheel', {
clientX: 400,
deltaY: 10000,
bubbles: true,
}),
);
});
await act(async () => {
await new Promise((r) => requestAnimationFrame(r));
});
const [newStart] = setViewStartTs.mock.calls[0] ?? [];
const [newEnd] = setViewEndTs.mock.calls[0] ?? [];
if (newStart != null && newEnd != null) {
const newSpan = newEnd - newStart;
expect(newSpan).toBeGreaterThanOrEqual(MIN_VISIBLE_SPAN_MS);
}
});
it('clamps viewStart/viewEnd to trace bounds', async () => {
const setViewStartTs = jest.fn();
const setViewEndTs = jest.fn();
const setRowHeight = jest.fn();
const viewStartRef = { current: traceMetadata.startTime };
const viewEndRef = { current: traceMetadata.endTime };
const rowHeightRef = { current: DEFAULT_ROW_HEIGHT };
const canvas = createMockCanvas();
const canvasRef = { current: canvas };
renderHook(() =>
useFlamegraphZoom({
canvasRef,
traceMetadata,
viewStartRef,
viewEndRef,
rowHeightRef,
setViewStartTs,
setViewEndTs,
setRowHeight,
}),
);
await act(async () => {
canvas.dispatchEvent(
new WheelEvent('wheel', {
clientX: 400,
deltaY: -5000,
bubbles: true,
}),
);
});
await act(async () => {
await new Promise((r) => requestAnimationFrame(r));
});
const [newStart] = setViewStartTs.mock.calls[0] ?? [];
const [newEnd] = setViewEndTs.mock.calls[0] ?? [];
if (newStart != null && newEnd != null) {
expect(newStart).toBeGreaterThanOrEqual(traceMetadata.startTime);
expect(newEnd).toBeLessThanOrEqual(traceMetadata.endTime);
}
});
it('returns isOverFlamegraphRef', () => {
const canvasRef = { current: createMockCanvas() };
const { result } = renderHook(() =>
useFlamegraphZoom({
canvasRef,
traceMetadata,
viewStartRef: { current: 0 },
viewEndRef: { current: 1000 },
rowHeightRef: { current: 24 },
setViewStartTs: jest.fn(),
setViewEndTs: jest.fn(),
setRowHeight: jest.fn(),
}),
);
expect(result.current.isOverFlamegraphRef).toBeDefined();
expect(result.current.isOverFlamegraphRef.current).toBe(false);
});
});

View File

@@ -0,0 +1,212 @@
import type { Dispatch, SetStateAction } from 'react';
import { useRef } from 'react';
import { act, render, waitFor } from '@testing-library/react';
import { useScrollToSpan } from '../hooks/useScrollToSpan';
import { MOCK_SPANS, MOCK_TRACE_METADATA } from './testUtils';
function TestWrapper({
firstSpanAtFetchLevel,
spans,
traceMetadata,
setViewStartTs,
setViewEndTs,
setScrollTop,
}: {
firstSpanAtFetchLevel: string;
spans: typeof MOCK_SPANS;
traceMetadata: typeof MOCK_TRACE_METADATA;
setViewStartTs: Dispatch<SetStateAction<number>>;
setViewEndTs: Dispatch<SetStateAction<number>>;
setScrollTop: Dispatch<SetStateAction<number>>;
}): JSX.Element {
const containerRef = useRef<HTMLDivElement>(null);
const viewStartRef = useRef(traceMetadata.startTime);
const viewEndRef = useRef(traceMetadata.endTime);
const scrollTopRef = useRef(0);
useScrollToSpan({
firstSpanAtFetchLevel,
spans,
traceMetadata,
containerRef,
viewStartRef,
viewEndRef,
scrollTopRef,
rowHeight: 24,
setViewStartTs,
setViewEndTs,
setScrollTop,
});
return <div ref={containerRef} data-testid="container" />;
}
describe('useScrollToSpan', () => {
beforeEach(() => {
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
configurable: true,
value: 400,
});
});
it('does not update when firstSpanAtFetchLevel is empty', async () => {
const setViewStartTs = jest.fn();
const setViewEndTs = jest.fn();
const setScrollTop = jest.fn();
render(
<TestWrapper
firstSpanAtFetchLevel=""
spans={MOCK_SPANS}
traceMetadata={MOCK_TRACE_METADATA}
setViewStartTs={setViewStartTs}
setViewEndTs={setViewEndTs}
setScrollTop={setScrollTop}
/>,
);
await waitFor(() => {
expect(setViewStartTs).not.toHaveBeenCalled();
expect(setViewEndTs).not.toHaveBeenCalled();
expect(setScrollTop).not.toHaveBeenCalled();
});
});
it('does not update when spans are empty', async () => {
const setViewStartTs = jest.fn();
const setViewEndTs = jest.fn();
const setScrollTop = jest.fn();
render(
<TestWrapper
firstSpanAtFetchLevel="root"
spans={[]}
traceMetadata={MOCK_TRACE_METADATA}
setViewStartTs={setViewStartTs}
setViewEndTs={setViewEndTs}
setScrollTop={setScrollTop}
/>,
);
await waitFor(() => {
expect(setViewStartTs).not.toHaveBeenCalled();
expect(setViewEndTs).not.toHaveBeenCalled();
expect(setScrollTop).not.toHaveBeenCalled();
});
});
it('does not update when target span not found', async () => {
const setViewStartTs = jest.fn();
const setViewEndTs = jest.fn();
const setScrollTop = jest.fn();
render(
<TestWrapper
firstSpanAtFetchLevel="nonexistent"
spans={MOCK_SPANS}
traceMetadata={MOCK_TRACE_METADATA}
setViewStartTs={setViewStartTs}
setViewEndTs={setViewEndTs}
setScrollTop={setScrollTop}
/>,
);
await waitFor(() => {
expect(setViewStartTs).not.toHaveBeenCalled();
expect(setViewEndTs).not.toHaveBeenCalled();
expect(setScrollTop).not.toHaveBeenCalled();
});
});
it('calls setters when target span found', async () => {
const setViewStartTs = jest.fn();
const setViewEndTs = jest.fn();
const setScrollTop = jest.fn();
const { getByTestId } = render(
<TestWrapper
firstSpanAtFetchLevel="grandchild"
spans={MOCK_SPANS}
traceMetadata={MOCK_TRACE_METADATA}
setViewStartTs={setViewStartTs}
setViewEndTs={setViewEndTs}
setScrollTop={setScrollTop}
/>,
);
expect(getByTestId('container')).toBeInTheDocument();
await waitFor(() => {
expect(setViewStartTs).toHaveBeenCalled();
expect(setViewEndTs).toHaveBeenCalled();
expect(setScrollTop).toHaveBeenCalled();
});
const [viewStart] = setViewStartTs.mock.calls[0];
const [viewEnd] = setViewEndTs.mock.calls[0];
const [scrollTop] = setScrollTop.mock.calls[0];
expect(viewEnd - viewStart).toBeGreaterThan(0);
expect(viewStart).toBeGreaterThanOrEqual(MOCK_TRACE_METADATA.startTime);
expect(viewEnd).toBeLessThanOrEqual(MOCK_TRACE_METADATA.endTime);
expect(scrollTop).toBeGreaterThanOrEqual(0);
});
it('centers span vertically (scrollTop centers span row)', async () => {
const setScrollTop = jest.fn();
await act(async () => {
render(
<TestWrapper
firstSpanAtFetchLevel="grandchild"
spans={MOCK_SPANS}
traceMetadata={MOCK_TRACE_METADATA}
setViewStartTs={jest.fn()}
setViewEndTs={jest.fn()}
setScrollTop={setScrollTop}
/>,
);
});
await waitFor(() => expect(setScrollTop).toHaveBeenCalled());
const [scrollTop] = setScrollTop.mock.calls[0];
const levelIndex = 2;
const rowHeight = 24;
const viewportHeight = 400;
const expectedCenter =
levelIndex * rowHeight - viewportHeight / 2 + rowHeight / 2;
expect(scrollTop).toBeCloseTo(Math.max(0, expectedCenter), -1);
});
it('zooms horizontally to span with 2x duration padding', async () => {
const setViewStartTs = jest.fn();
const setViewEndTs = jest.fn();
await act(async () => {
render(
<TestWrapper
firstSpanAtFetchLevel="root"
spans={MOCK_SPANS}
traceMetadata={MOCK_TRACE_METADATA}
setViewStartTs={setViewStartTs}
setViewEndTs={setViewEndTs}
setScrollTop={jest.fn()}
/>,
);
});
await waitFor(() => {
expect(setViewStartTs).toHaveBeenCalled();
expect(setViewEndTs).toHaveBeenCalled();
});
const [viewStart] = setViewStartTs.mock.calls[0];
const [viewEnd] = setViewEndTs.mock.calls[0];
const visibleWindow = viewEnd - viewStart;
const rootSpan = MOCK_SPANS[0][0];
const spanDurationMs = rootSpan.durationNano / 1e6;
expect(visibleWindow).toBeGreaterThanOrEqual(Math.max(spanDurationMs * 2, 5));
});
});

View File

@@ -0,0 +1,135 @@
import {
clamp,
findSpanById,
formatDuration,
getFlamegraphRowMetrics,
} from '../utils';
import { MOCK_SPANS } from './testUtils';
jest.mock('container/TraceDetail/utils', () => ({
convertTimeToRelevantUnit: (
valueMs: number,
): { time: number; timeUnitName: string } => {
if (valueMs === 0) {
return { time: 0, timeUnitName: 'ms' };
}
if (valueMs < 1) {
return { time: valueMs, timeUnitName: 'ms' };
}
if (valueMs < 1000) {
return { time: valueMs, timeUnitName: 'ms' };
}
if (valueMs < 60_000) {
return { time: valueMs / 1000, timeUnitName: 's' };
}
if (valueMs < 3_600_000) {
return { time: valueMs / 60_000, timeUnitName: 'm' };
}
return { time: valueMs / 3_600_000, timeUnitName: 'hr' };
},
}));
describe('Pure Math and Data Utils', () => {
describe('clamp', () => {
it('returns value when within range', () => {
expect(clamp(5, 0, 10)).toBe(5);
expect(clamp(-3, -5, 5)).toBe(-3);
});
it('returns min when value is below min', () => {
expect(clamp(-1, 0, 10)).toBe(0);
expect(clamp(2, 5, 10)).toBe(5);
});
it('returns max when value is above max', () => {
expect(clamp(11, 0, 10)).toBe(10);
expect(clamp(100, 0, 50)).toBe(50);
});
it('handles min === max', () => {
expect(clamp(5, 7, 7)).toBe(7);
expect(clamp(7, 7, 7)).toBe(7);
});
});
describe('findSpanById', () => {
it('finds span in first level', () => {
const result = findSpanById(MOCK_SPANS, 'root');
expect(result).not.toBeNull();
expect(result?.span.spanId).toBe('root');
expect(result?.levelIndex).toBe(0);
});
it('finds span in nested level', () => {
const result = findSpanById(MOCK_SPANS, 'grandchild');
expect(result).not.toBeNull();
expect(result?.span.spanId).toBe('grandchild');
expect(result?.levelIndex).toBe(2);
});
it('returns null when span not found', () => {
expect(findSpanById(MOCK_SPANS, 'nonexistent')).toBeNull();
});
it('handles empty spans', () => {
expect(findSpanById([], 'root')).toBeNull();
expect(findSpanById([[], []], 'root')).toBeNull();
});
});
describe('getFlamegraphRowMetrics', () => {
it('computes normal row height metrics (24px)', () => {
const m = getFlamegraphRowMetrics(24);
expect(m.ROW_HEIGHT).toBe(24);
expect(m.SPAN_BAR_HEIGHT).toBe(22);
expect(m.SPAN_BAR_Y_OFFSET).toBe(1);
expect(m.EVENT_DOT_SIZE).toBe(6);
});
it('clamps span bar height to max for large row heights', () => {
const m = getFlamegraphRowMetrics(100);
expect(m.SPAN_BAR_HEIGHT).toBe(22);
expect(m.SPAN_BAR_Y_OFFSET).toBe(39);
});
it('clamps span bar height to min for small row heights', () => {
const m = getFlamegraphRowMetrics(6);
expect(m.SPAN_BAR_HEIGHT).toBe(8);
// spanBarYOffset = floor((6-8)/2) = -1 when bar exceeds row height
expect(m.SPAN_BAR_Y_OFFSET).toBe(-1);
});
it('clamps event dot size within min/max', () => {
const mSmall = getFlamegraphRowMetrics(6);
expect(mSmall.EVENT_DOT_SIZE).toBe(4);
const mLarge = getFlamegraphRowMetrics(24);
expect(mLarge.EVENT_DOT_SIZE).toBe(6);
});
});
describe('formatDuration', () => {
it('formats nanos as ms', () => {
// 1e6 nanos = 1ms
expect(formatDuration(1_000_000)).toBe('1ms');
});
it('formats larger durations as s/m/hr', () => {
// 2e9 nanos = 2000ms = 2s
expect(formatDuration(2_000_000_000)).toBe('2s');
});
it('formats zero duration', () => {
expect(formatDuration(0)).toBe('0ms');
});
it('formats very small values', () => {
// 1000 nanos = 0.001ms → mock returns { time: 0.001, timeUnitName: 'ms' }
expect(formatDuration(1000)).toBe('0ms');
});
it('formats decimal seconds correctly', () => {
expect(formatDuration(1_500_000_000)).toBe('1.5s');
});
});
});

View File

@@ -0,0 +1,67 @@
import { getSpanColor } from '../utils';
import { MOCK_SPAN } from './testUtils';
const mockGenerateColor = jest.fn();
jest.mock('lib/uPlotLib/utils/generateColor', () => ({
generateColor: (key: string, colorMap: Record<string, string>): string =>
mockGenerateColor(key, colorMap),
}));
describe('Presentation / Styling Utils', () => {
beforeEach(() => {
jest.clearAllMocks();
mockGenerateColor.mockReturnValue('#2F80ED');
});
describe('getSpanColor', () => {
it('uses generated service color for normal span', () => {
mockGenerateColor.mockReturnValue('#1890ff');
const color = getSpanColor({
span: { ...MOCK_SPAN, hasError: false },
isDarkMode: false,
});
expect(mockGenerateColor).toHaveBeenCalledWith(
MOCK_SPAN.serviceName,
expect.any(Object),
);
expect(color).toBe('#1890ff');
});
it('overrides with error color in light mode when span has error', () => {
mockGenerateColor.mockReturnValue('#1890ff');
const color = getSpanColor({
span: { ...MOCK_SPAN, hasError: true },
isDarkMode: false,
});
expect(color).toBe('rgb(220, 38, 38)');
});
it('overrides with error color in dark mode when span has error', () => {
mockGenerateColor.mockReturnValue('#1890ff');
const color = getSpanColor({
span: { ...MOCK_SPAN, hasError: true },
isDarkMode: true,
});
expect(color).toBe('rgb(239, 68, 68)');
});
it('passes serviceName to generateColor', () => {
getSpanColor({
span: { ...MOCK_SPAN, serviceName: 'my-service' },
isDarkMode: false,
});
expect(mockGenerateColor).toHaveBeenCalledWith(
'my-service',
expect.any(Object),
);
});
});
});

View File

@@ -0,0 +1,36 @@
export const ROW_HEIGHT = 24;
export const SPAN_BAR_HEIGHT = 22;
export const SPAN_BAR_Y_OFFSET = Math.floor((ROW_HEIGHT - SPAN_BAR_HEIGHT) / 2);
export const EVENT_DOT_SIZE = 6;
// Span bar sizing relative to row height (used by getFlamegraphRowMetrics)
export const SPAN_BAR_HEIGHT_RATIO = SPAN_BAR_HEIGHT / ROW_HEIGHT;
export const MIN_SPAN_BAR_HEIGHT = 8;
export const MAX_SPAN_BAR_HEIGHT = SPAN_BAR_HEIGHT;
// Event dot sizing relative to span bar height
export const EVENT_DOT_SIZE_RATIO = EVENT_DOT_SIZE / SPAN_BAR_HEIGHT;
export const MIN_EVENT_DOT_SIZE = 4;
export const MAX_EVENT_DOT_SIZE = EVENT_DOT_SIZE;
export const LABEL_FONT = '11px Inter, sans-serif';
export const LABEL_PADDING_X = 8;
export const MIN_WIDTH_FOR_NAME = 30;
export const MIN_WIDTH_FOR_NAME_AND_DURATION = 80;
// Dynamic row height (vertical zoom) -- disabled for now (MIN === MAX)
export const MIN_ROW_HEIGHT = 24;
export const MAX_ROW_HEIGHT = 24;
export const DEFAULT_ROW_HEIGHT = MIN_ROW_HEIGHT;
// Zoom intensity -- how fast zoom reacts to wheel/pinch delta
export const PINCH_ZOOM_INTENSITY_H = 0.01;
export const SCROLL_ZOOM_INTENSITY_H = 0.0015;
export const PINCH_ZOOM_INTENSITY_V = 0.008;
export const SCROLL_ZOOM_INTENSITY_V = 0.001;
// Minimum visible time span in ms (prevents zooming to sub-pixel)
export const MIN_VISIBLE_SPAN_MS = 5;
// Selected span style (dashed border)
export const DASHED_BORDER_LINE_DASH = [4, 2];

View File

@@ -0,0 +1,55 @@
import { RefObject, useCallback, useEffect } from 'react';
export function useCanvasSetup(
canvasRef: RefObject<HTMLCanvasElement>,
containerRef: RefObject<HTMLDivElement>,
onDraw: () => void,
): void {
const updateCanvasSize = useCallback(() => {
const canvas = canvasRef.current;
const container = containerRef.current;
if (!canvas || !container) {
return;
}
const dpr = window.devicePixelRatio || 1;
const rect = container.getBoundingClientRect();
const viewportHeight = container.clientHeight;
canvas.style.width = `${rect.width}px`;
canvas.style.height = `${viewportHeight}px`;
const newWidth = Math.floor(rect.width * dpr);
const newHeight = Math.floor(viewportHeight * dpr);
if (canvas.width !== newWidth || canvas.height !== newHeight) {
canvas.width = newWidth;
canvas.height = newHeight;
onDraw();
}
}, [canvasRef, containerRef, onDraw]);
useEffect(() => {
const container = containerRef.current;
if (!container) {
return (): void => {};
}
const resizeObserver = new ResizeObserver(updateCanvasSize);
resizeObserver.observe(container);
updateCanvasSize();
// when dpr changes, update the canvas size
const dprQuery = window.matchMedia('(resolution: 1dppx)');
dprQuery.addEventListener('change', updateCanvasSize);
return (): void => {
resizeObserver.disconnect();
dprQuery.removeEventListener('change', updateCanvasSize);
};
}, [containerRef, updateCanvasSize]);
useEffect(() => {
onDraw();
}, [onDraw]);
}

View File

@@ -0,0 +1,178 @@
import {
Dispatch,
MouseEvent as ReactMouseEvent,
MutableRefObject,
RefObject,
SetStateAction,
useCallback,
useRef,
} from 'react';
import { ITraceMetadata } from '../types';
import { clamp } from '../utils';
interface UseFlamegraphDragArgs {
canvasRef: RefObject<HTMLCanvasElement>;
containerRef: RefObject<HTMLDivElement>;
traceMetadata: ITraceMetadata;
viewStartRef: MutableRefObject<number>;
viewEndRef: MutableRefObject<number>;
setViewStartTs: Dispatch<SetStateAction<number>>;
setViewEndTs: Dispatch<SetStateAction<number>>;
scrollTopRef: MutableRefObject<number>;
setScrollTop: Dispatch<SetStateAction<number>>;
totalHeight: number;
}
interface UseFlamegraphDragResult {
handleMouseDown: (e: ReactMouseEvent) => void;
handleMouseMove: (e: ReactMouseEvent) => void;
handleMouseUp: () => void;
handleDragMouseLeave: () => void;
suppressClickRef: MutableRefObject<boolean>;
isDraggingRef: MutableRefObject<boolean>;
}
const DRAG_THRESHOLD = 5;
export function useFlamegraphDrag(
args: UseFlamegraphDragArgs,
): UseFlamegraphDragResult {
const {
canvasRef,
containerRef,
traceMetadata,
viewStartRef,
viewEndRef,
setViewStartTs,
setViewEndTs,
scrollTopRef,
setScrollTop,
totalHeight,
} = args;
const isDraggingRef = useRef(false);
const dragStartRef = useRef<{ x: number; y: number } | null>(null);
const dragDistanceRef = useRef(0);
const suppressClickRef = useRef(false);
const clampScrollTop = useCallback(
(next: number): number => {
const container = containerRef.current;
if (!container) {
return 0;
}
const viewportHeight = container.clientHeight;
const maxScroll = Math.max(0, totalHeight - viewportHeight);
return clamp(next, 0, maxScroll);
},
[containerRef, totalHeight],
);
const handleMouseDown = useCallback(
(event: ReactMouseEvent): void => {
if (event.button !== 0) {
return;
}
event.preventDefault();
isDraggingRef.current = true;
dragStartRef.current = { x: event.clientX, y: event.clientY };
dragDistanceRef.current = 0;
const canvas = canvasRef.current;
if (canvas) {
canvas.style.cursor = 'grabbing';
}
},
[canvasRef],
);
const handleMouseMove = useCallback(
(event: ReactMouseEvent): void => {
if (!isDraggingRef.current || !dragStartRef.current) {
return;
}
const canvas = canvasRef.current;
if (!canvas) {
return;
}
const rect = canvas.getBoundingClientRect();
const deltaX = event.clientX - dragStartRef.current.x;
const deltaY = event.clientY - dragStartRef.current.y;
dragDistanceRef.current = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
// --- Horizontal pan ---
const timeSpan = viewEndRef.current - viewStartRef.current;
const deltaTime = (deltaX / rect.width) * timeSpan;
const newStart = viewStartRef.current - deltaTime;
const clampedStart = clamp(
newStart,
traceMetadata.startTime,
traceMetadata.endTime - timeSpan,
);
const clampedEnd = clampedStart + timeSpan;
viewStartRef.current = clampedStart;
viewEndRef.current = clampedEnd;
setViewStartTs(clampedStart);
setViewEndTs(clampedEnd);
// --- Vertical scroll pan ---
const nextScrollTop = clampScrollTop(scrollTopRef.current - deltaY);
scrollTopRef.current = nextScrollTop;
setScrollTop(nextScrollTop);
dragStartRef.current = { x: event.clientX, y: event.clientY };
},
[
canvasRef,
traceMetadata,
viewStartRef,
viewEndRef,
setViewStartTs,
setViewEndTs,
scrollTopRef,
setScrollTop,
clampScrollTop,
],
);
const handleMouseUp = useCallback((): void => {
const wasDrag = dragDistanceRef.current > DRAG_THRESHOLD;
suppressClickRef.current = wasDrag;
isDraggingRef.current = false;
dragStartRef.current = null;
dragDistanceRef.current = 0;
const canvas = canvasRef.current;
if (canvas) {
canvas.style.cursor = 'grab';
}
}, [canvasRef]);
const handleDragMouseLeave = useCallback((): void => {
isDraggingRef.current = false;
dragStartRef.current = null;
dragDistanceRef.current = 0;
const canvas = canvasRef.current;
if (canvas) {
canvas.style.cursor = 'grab';
}
}, [canvasRef]);
return {
handleMouseDown,
handleMouseMove,
handleMouseUp,
handleDragMouseLeave,
suppressClickRef,
isDraggingRef,
};
}

View File

@@ -0,0 +1,222 @@
import React, { RefObject, useCallback, useRef } from 'react';
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
import { SpanRect } from '../types';
import {
clamp,
drawSpanBar,
FlamegraphRowMetrics,
getFlamegraphRowMetrics,
getSpanColor,
} from '../utils';
interface UseFlamegraphDrawArgs {
canvasRef: RefObject<HTMLCanvasElement>;
containerRef: RefObject<HTMLDivElement>;
spans: FlamegraphSpan[][];
viewStartTs: number;
viewEndTs: number;
scrollTop: number;
rowHeight: number;
selectedSpanId: string | undefined;
hoveredSpanId: string;
isDarkMode: boolean;
spanRectsRef?: React.MutableRefObject<SpanRect[]>;
}
interface UseFlamegraphDrawResult {
drawFlamegraph: () => void;
spanRectsRef: RefObject<SpanRect[]>;
}
const OVERSCAN_ROWS = 4;
interface DrawLevelArgs {
ctx: CanvasRenderingContext2D;
levelSpans: FlamegraphSpan[];
levelIndex: number;
y: number;
viewStartTs: number;
timeSpan: number;
cssWidth: number;
selectedSpanId: string | undefined;
hoveredSpanId: string;
isDarkMode: boolean;
spanRectsArray: SpanRect[];
metrics: FlamegraphRowMetrics;
}
function drawLevel(args: DrawLevelArgs): void {
const {
ctx,
levelSpans,
levelIndex,
y,
viewStartTs,
timeSpan,
cssWidth,
selectedSpanId,
hoveredSpanId,
isDarkMode,
spanRectsArray,
metrics,
} = args;
const viewEndTs = viewStartTs + timeSpan;
for (let i = 0; i < levelSpans.length; i++) {
const span = levelSpans[i];
const spanStartMs = span.timestamp;
const spanEndMs = span.timestamp + span.durationNano / 1e6;
// Time culling -- skip spans entirely outside the visible time window
if (spanEndMs < viewStartTs || spanStartMs > viewEndTs) {
continue;
}
const leftOffset = ((spanStartMs - viewStartTs) / timeSpan) * cssWidth;
const rightEdge = ((spanEndMs - viewStartTs) / timeSpan) * cssWidth;
let width = rightEdge - leftOffset;
// Clamp to visible x-range
if (leftOffset < 0) {
width += leftOffset;
if (width <= 0) {
continue;
}
}
if (rightEdge > cssWidth) {
width = cssWidth - Math.max(0, leftOffset);
if (width <= 0) {
continue;
}
}
// Minimum 1px width so tiny spans remain visible
width = clamp(width, 1, Infinity);
const color = getSpanColor({ span, isDarkMode });
drawSpanBar({
ctx,
span,
x: Math.max(0, leftOffset),
y,
width,
levelIndex,
spanRectsArray,
color,
isDarkMode,
metrics,
selectedSpanId,
hoveredSpanId,
});
}
}
export function useFlamegraphDraw(
args: UseFlamegraphDrawArgs,
): UseFlamegraphDrawResult {
const {
canvasRef,
containerRef,
spans,
viewStartTs,
viewEndTs,
scrollTop,
rowHeight,
selectedSpanId,
hoveredSpanId,
isDarkMode,
spanRectsRef: spanRectsRefProp,
} = args;
const spanRectsRefInternal = useRef<SpanRect[]>([]);
const spanRectsRef = spanRectsRefProp ?? spanRectsRefInternal;
const drawFlamegraph = useCallback(() => {
const canvas = canvasRef.current;
const container = containerRef.current;
if (!canvas || !container) {
return;
}
const ctx = canvas.getContext('2d');
if (!ctx) {
return;
}
const dpr = window.devicePixelRatio || 1;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const timeSpan = viewEndTs - viewStartTs;
if (timeSpan <= 0) {
return;
}
const cssWidth = canvas.width / dpr;
const metrics = getFlamegraphRowMetrics(rowHeight);
// ---- Vertical clipping window ----
const viewportHeight = container.clientHeight;
//starts drawing OVERSCAN_ROWS(4) rows above the visible area.
const firstLevel = Math.max(
0,
Math.floor(scrollTop / metrics.ROW_HEIGHT) - OVERSCAN_ROWS,
);
// adds 2*OVERSCAN_ROWS extra rows above and below the visible area.
const visibleLevelCount =
Math.ceil(viewportHeight / metrics.ROW_HEIGHT) + 2 * OVERSCAN_ROWS;
const lastLevel = Math.min(spans.length - 1, firstLevel + visibleLevelCount);
ctx.clearRect(0, 0, cssWidth, viewportHeight);
const spanRectsArray: SpanRect[] = [];
// ---- Draw only visible levels ----
for (let levelIndex = firstLevel; levelIndex <= lastLevel; levelIndex++) {
const levelSpans = spans[levelIndex];
if (!levelSpans) {
continue;
}
drawLevel({
ctx,
levelSpans,
levelIndex,
y: levelIndex * metrics.ROW_HEIGHT - scrollTop,
viewStartTs,
timeSpan,
cssWidth,
selectedSpanId,
hoveredSpanId,
isDarkMode,
spanRectsArray,
metrics,
});
}
spanRectsRef.current = spanRectsArray;
}, [
canvasRef,
containerRef,
spanRectsRef,
spans,
viewStartTs,
viewEndTs,
scrollTop,
rowHeight,
selectedSpanId,
hoveredSpanId,
isDarkMode,
]);
// TODO: spanRectsRef is a flat array — hover scans all visible rects O(N).
// Upgrade to per-level buckets: spanRects[levelIndex] = [...] so hover can
// compute level from mouseY / ROW_HEIGHT and scan only that row.
// Further: binary search within a level by x (spans are sorted by start time)
// to reduce hover cost from O(N) to O(log N).
return { drawFlamegraph, spanRectsRef };
}

View File

@@ -0,0 +1,214 @@
import {
Dispatch,
MouseEvent as ReactMouseEvent,
MutableRefObject,
RefObject,
SetStateAction,
useCallback,
useState,
} from 'react';
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
import { SpanRect } from '../types';
import { ITraceMetadata } from '../types';
import { getSpanColor } from '../utils';
function getCanvasPointer(
canvas: HTMLCanvasElement,
clientX: number,
clientY: number,
): { cssX: number; cssY: number } | null {
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const cssWidth = canvas.width / dpr;
const cssHeight = canvas.height / dpr;
const cssX = (clientX - rect.left) * (cssWidth / rect.width);
const cssY = (clientY - rect.top) * (cssHeight / rect.height);
return { cssX, cssY };
}
function findSpanAtPosition(
cssX: number,
cssY: number,
spanRects: SpanRect[],
): FlamegraphSpan | null {
for (let i = spanRects.length - 1; i >= 0; i--) {
const r = spanRects[i];
if (
cssX >= r.x &&
cssX <= r.x + r.width &&
cssY >= r.y &&
cssY <= r.y + r.height
) {
return r.span;
}
}
return null;
}
export interface TooltipContent {
spanName: string;
status: 'ok' | 'warning' | 'error';
startMs: number;
durationMs: number;
clientX: number;
clientY: number;
spanColor: string;
}
interface UseFlamegraphHoverArgs {
canvasRef: RefObject<HTMLCanvasElement>;
spanRectsRef: MutableRefObject<SpanRect[]>;
traceMetadata: ITraceMetadata;
viewStartTs: number;
viewEndTs: number;
isDraggingRef: MutableRefObject<boolean>;
suppressClickRef: MutableRefObject<boolean>;
onSpanClick: (spanId: string) => void;
isDarkMode: boolean;
}
interface UseFlamegraphHoverResult {
hoveredSpanId: string | null;
setHoveredSpanId: Dispatch<SetStateAction<string | null>>;
handleHoverMouseMove: (e: ReactMouseEvent) => void;
handleHoverMouseLeave: () => void;
handleClick: (e: ReactMouseEvent) => void;
tooltipContent: TooltipContent | null;
}
export function useFlamegraphHover(
args: UseFlamegraphHoverArgs,
): UseFlamegraphHoverResult {
const {
canvasRef,
spanRectsRef,
traceMetadata,
viewStartTs,
viewEndTs,
isDraggingRef,
suppressClickRef,
onSpanClick,
isDarkMode,
} = args;
const [hoveredSpanId, setHoveredSpanId] = useState<string | null>(null);
const [tooltipContent, setTooltipContent] = useState<TooltipContent | null>(
null,
);
const isZoomed =
viewStartTs !== traceMetadata.startTime ||
viewEndTs !== traceMetadata.endTime;
const updateCursor = useCallback(
(canvas: HTMLCanvasElement, span: FlamegraphSpan | null): void => {
if (span) {
canvas.style.cursor = 'pointer';
} else if (isZoomed) {
canvas.style.cursor = 'grab';
} else {
canvas.style.cursor = 'default';
}
},
[isZoomed],
);
const handleHoverMouseMove = useCallback(
(e: ReactMouseEvent): void => {
if (isDraggingRef.current) {
return;
}
const canvas = canvasRef.current;
if (!canvas) {
return;
}
const pointer = getCanvasPointer(canvas, e.clientX, e.clientY);
if (!pointer) {
return;
}
const span = findSpanAtPosition(
pointer.cssX,
pointer.cssY,
spanRectsRef.current,
);
if (span) {
setHoveredSpanId(span.spanId);
setTooltipContent({
spanName: span.name || 'unknown',
status: span.hasError ? 'error' : 'ok',
startMs: span.timestamp - traceMetadata.startTime,
durationMs: span.durationNano / 1e6,
clientX: e.clientX,
clientY: e.clientY,
spanColor: getSpanColor({ span, isDarkMode }),
});
updateCursor(canvas, span);
} else {
setHoveredSpanId(null);
setTooltipContent(null);
updateCursor(canvas, null);
}
},
[
canvasRef,
spanRectsRef,
traceMetadata.startTime,
isDraggingRef,
updateCursor,
isDarkMode,
],
);
const handleHoverMouseLeave = useCallback((): void => {
setHoveredSpanId(null);
setTooltipContent(null);
const canvas = canvasRef.current;
if (canvas) {
updateCursor(canvas, null);
}
}, [canvasRef, updateCursor]);
const handleClick = useCallback(
(e: ReactMouseEvent): void => {
if (suppressClickRef.current) {
return;
}
const canvas = canvasRef.current;
if (!canvas) {
return;
}
const pointer = getCanvasPointer(canvas, e.clientX, e.clientY);
if (!pointer) {
return;
}
const span = findSpanAtPosition(
pointer.cssX,
pointer.cssY,
spanRectsRef.current,
);
if (span) {
onSpanClick(span.spanId);
}
},
[canvasRef, spanRectsRef, suppressClickRef, onSpanClick],
);
return {
hoveredSpanId,
setHoveredSpanId,
handleHoverMouseMove,
handleHoverMouseLeave,
handleClick,
tooltipContent,
};
}

View File

@@ -0,0 +1,224 @@
import {
Dispatch,
MutableRefObject,
RefObject,
SetStateAction,
useCallback,
useEffect,
useRef,
} from 'react';
import {
DEFAULT_ROW_HEIGHT,
MAX_ROW_HEIGHT,
MIN_ROW_HEIGHT,
MIN_VISIBLE_SPAN_MS,
PINCH_ZOOM_INTENSITY_H,
PINCH_ZOOM_INTENSITY_V,
SCROLL_ZOOM_INTENSITY_H,
SCROLL_ZOOM_INTENSITY_V,
} from '../constants';
import { ITraceMetadata } from '../types';
import { clamp } from '../utils';
interface UseFlamegraphZoomArgs {
canvasRef: RefObject<HTMLCanvasElement>;
traceMetadata: ITraceMetadata;
viewStartRef: MutableRefObject<number>;
viewEndRef: MutableRefObject<number>;
rowHeightRef: MutableRefObject<number>;
setViewStartTs: Dispatch<SetStateAction<number>>;
setViewEndTs: Dispatch<SetStateAction<number>>;
setRowHeight: Dispatch<SetStateAction<number>>;
}
interface UseFlamegraphZoomResult {
handleResetZoom: () => void;
isOverFlamegraphRef: MutableRefObject<boolean>;
}
function getCanvasPointer(
canvasRef: RefObject<HTMLCanvasElement>,
clientX: number,
): { cssX: number; cssWidth: number } | null {
const canvas = canvasRef.current;
if (!canvas) {
return null;
}
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const cssWidth = canvas.width / dpr;
const cssX = (clientX - rect.left) * (cssWidth / rect.width);
return { cssX, cssWidth };
}
export function useFlamegraphZoom(
args: UseFlamegraphZoomArgs,
): UseFlamegraphZoomResult {
const {
canvasRef,
traceMetadata,
viewStartRef,
viewEndRef,
rowHeightRef,
setViewStartTs,
setViewEndTs,
setRowHeight,
} = args;
const isOverFlamegraphRef = useRef(false);
const wheelDeltaRef = useRef(0);
const rafRef = useRef<number | null>(null);
const lastCursorXRef = useRef(0);
const lastCssWidthRef = useRef(1);
const lastIsPinchRef = useRef(false);
const lastWheelClientXRef = useRef<number | null>(null);
// Prevent browser zoom when pinching over the flamegraph
useEffect(() => {
const onWheel = (e: WheelEvent): void => {
if (isOverFlamegraphRef.current && e.ctrlKey) {
e.preventDefault();
}
};
window.addEventListener('wheel', onWheel, { passive: false, capture: true });
return (): void => {
window.removeEventListener('wheel', onWheel, {
capture: true,
} as EventListenerOptions);
};
}, []);
const applyWheelZoom = useCallback(() => {
rafRef.current = null;
const cssWidth = lastCssWidthRef.current || 1;
const cursorX = lastCursorXRef.current;
const fullSpanMs = traceMetadata.endTime - traceMetadata.startTime;
const oldStart = viewStartRef.current;
const oldEnd = viewEndRef.current;
const oldSpan = oldEnd - oldStart;
const deltaY = wheelDeltaRef.current;
wheelDeltaRef.current = 0;
if (deltaY === 0) {
return;
}
const zoomH = lastIsPinchRef.current
? PINCH_ZOOM_INTENSITY_H
: SCROLL_ZOOM_INTENSITY_H;
const zoomV = lastIsPinchRef.current
? PINCH_ZOOM_INTENSITY_V
: SCROLL_ZOOM_INTENSITY_V;
const factorH = Math.exp(deltaY * zoomH);
const factorV = Math.exp(deltaY * zoomV);
// --- Horizontal zoom ---
const desiredSpan = oldSpan * factorH;
const minSpanMs = Math.max(
MIN_VISIBLE_SPAN_MS,
oldSpan / Math.max(cssWidth, 1),
);
const clampedSpan = clamp(desiredSpan, minSpanMs, fullSpanMs);
const cursorRatio = clamp(cursorX / cssWidth, 0, 1);
const anchorTs = oldStart + cursorRatio * oldSpan;
let nextStart = anchorTs - cursorRatio * clampedSpan;
nextStart = clamp(
nextStart,
traceMetadata.startTime,
traceMetadata.endTime - clampedSpan,
);
const nextEnd = nextStart + clampedSpan;
// --- Vertical zoom (row height) ---
const desiredRow = rowHeightRef.current * (1 / factorV);
const nextRow = clamp(desiredRow, MIN_ROW_HEIGHT, MAX_ROW_HEIGHT);
// Write refs immediately so rapid wheel events read fresh values
viewStartRef.current = nextStart;
viewEndRef.current = nextEnd;
rowHeightRef.current = nextRow;
setViewStartTs(nextStart);
setViewEndTs(nextEnd);
setRowHeight(nextRow);
}, [
traceMetadata,
viewStartRef,
viewEndRef,
rowHeightRef,
setViewStartTs,
setViewEndTs,
setRowHeight,
]);
// Native wheel listener on the canvas (passive: false for reliable preventDefault)
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) {
return (): void => {};
}
const onWheel = (e: WheelEvent): void => {
e.preventDefault();
const pointer = getCanvasPointer(canvasRef, e.clientX);
if (!pointer) {
return;
}
// Flush accumulated delta if cursor moved significantly
if (lastWheelClientXRef.current !== null) {
const moved = Math.abs(e.clientX - lastWheelClientXRef.current);
if (moved > 6) {
wheelDeltaRef.current = 0;
}
}
lastWheelClientXRef.current = e.clientX;
lastIsPinchRef.current = e.ctrlKey;
lastCssWidthRef.current = pointer.cssWidth;
lastCursorXRef.current = pointer.cssX;
wheelDeltaRef.current += e.deltaY;
if (rafRef.current == null) {
rafRef.current = requestAnimationFrame(applyWheelZoom);
}
};
canvas.addEventListener('wheel', onWheel, { passive: false });
return (): void => {
canvas.removeEventListener('wheel', onWheel);
};
}, [canvasRef, applyWheelZoom]);
const handleResetZoom = useCallback(() => {
viewStartRef.current = traceMetadata.startTime;
viewEndRef.current = traceMetadata.endTime;
rowHeightRef.current = DEFAULT_ROW_HEIGHT;
setViewStartTs(traceMetadata.startTime);
setViewEndTs(traceMetadata.endTime);
setRowHeight(DEFAULT_ROW_HEIGHT);
}, [
traceMetadata,
viewStartRef,
viewEndRef,
rowHeightRef,
setViewStartTs,
setViewEndTs,
setRowHeight,
]);
return { handleResetZoom, isOverFlamegraphRef };
}

View File

@@ -0,0 +1,118 @@
import {
Dispatch,
MutableRefObject,
RefObject,
SetStateAction,
useEffect,
} from 'react';
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
import { MIN_VISIBLE_SPAN_MS } from '../constants';
import { ITraceMetadata } from '../types';
import { clamp, findSpanById, getFlamegraphRowMetrics } from '../utils';
interface UseScrollToSpanArgs {
firstSpanAtFetchLevel: string;
spans: FlamegraphSpan[][];
traceMetadata: ITraceMetadata;
containerRef: RefObject<HTMLDivElement>;
viewStartRef: MutableRefObject<number>;
viewEndRef: MutableRefObject<number>;
scrollTopRef: MutableRefObject<number>;
rowHeight: number;
setViewStartTs: Dispatch<SetStateAction<number>>;
setViewEndTs: Dispatch<SetStateAction<number>>;
setScrollTop: Dispatch<SetStateAction<number>>;
}
/**
* When firstSpanAtFetchLevel (from URL spanId) changes, scroll and zoom the
* flamegraph so the selected span is centered in view.
*/
export function useScrollToSpan(args: UseScrollToSpanArgs): void {
const {
firstSpanAtFetchLevel,
spans,
traceMetadata,
containerRef,
viewStartRef,
viewEndRef,
scrollTopRef,
rowHeight,
setViewStartTs,
setViewEndTs,
setScrollTop,
} = args;
useEffect(() => {
if (!firstSpanAtFetchLevel || spans.length === 0) {
return;
}
const result = findSpanById(spans, firstSpanAtFetchLevel);
if (!result) {
return;
}
const { span, levelIndex } = result;
const container = containerRef.current;
if (!container) {
return;
}
const metrics = getFlamegraphRowMetrics(rowHeight);
const viewportHeight = container.clientHeight;
const totalHeight = spans.length * metrics.ROW_HEIGHT;
const maxScroll = Math.max(0, totalHeight - viewportHeight);
// Vertical: center the span's row in the viewport
const targetScrollTop = clamp(
levelIndex * metrics.ROW_HEIGHT -
viewportHeight / 2 +
metrics.ROW_HEIGHT / 2,
0,
maxScroll,
);
// Horizontal: zoom to span with padding (2x span duration), center it
const spanStartMs = span.timestamp;
const spanEndMs = span.timestamp + span.durationNano / 1e6;
const spanDurationMs = spanEndMs - spanStartMs;
const spanCenterMs = (spanStartMs + spanEndMs) / 2;
const visibleWindowMs = Math.max(spanDurationMs * 2, MIN_VISIBLE_SPAN_MS);
const fullSpanMs = traceMetadata.endTime - traceMetadata.startTime;
const clampedWindow = clamp(visibleWindowMs, MIN_VISIBLE_SPAN_MS, fullSpanMs);
let targetViewStart = spanCenterMs - clampedWindow / 2;
let targetViewEnd = spanCenterMs + clampedWindow / 2;
targetViewStart = clamp(
targetViewStart,
traceMetadata.startTime,
traceMetadata.endTime - clampedWindow,
);
targetViewEnd = targetViewStart + clampedWindow;
// Apply immediately (instant jump)
viewStartRef.current = targetViewStart;
viewEndRef.current = targetViewEnd;
scrollTopRef.current = targetScrollTop;
setViewStartTs(targetViewStart);
setViewEndTs(targetViewEnd);
setScrollTop(targetScrollTop);
}, [
firstSpanAtFetchLevel,
spans,
traceMetadata,
containerRef,
viewStartRef,
viewEndRef,
scrollTopRef,
rowHeight,
setViewStartTs,
setViewEndTs,
setScrollTop,
]);
}

View File

@@ -0,0 +1,24 @@
import { Dispatch, SetStateAction } from 'react';
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
export interface ITraceMetadata {
startTime: number;
endTime: number;
}
export interface FlamegraphCanvasProps {
spans: FlamegraphSpan[][];
firstSpanAtFetchLevel: string;
setFirstSpanAtFetchLevel: Dispatch<SetStateAction<string>>;
onSpanClick: (spanId: string) => void;
traceMetadata: ITraceMetadata;
}
export interface SpanRect {
span: FlamegraphSpan;
x: number;
y: number;
width: number;
height: number;
level: number;
}

View File

@@ -0,0 +1,355 @@
import { themeColors } from 'constants/theme';
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
import {
DASHED_BORDER_LINE_DASH,
EVENT_DOT_SIZE_RATIO,
LABEL_FONT,
LABEL_PADDING_X,
MAX_EVENT_DOT_SIZE,
MAX_SPAN_BAR_HEIGHT,
MIN_EVENT_DOT_SIZE,
MIN_SPAN_BAR_HEIGHT,
MIN_WIDTH_FOR_NAME,
MIN_WIDTH_FOR_NAME_AND_DURATION,
SPAN_BAR_HEIGHT_RATIO,
} from './constants';
import { SpanRect } from './types';
export function clamp(v: number, min: number, max: number): number {
return Math.max(min, Math.min(max, v));
}
/** Create diagonal stripe pattern for selected/hovered span (repeating-linear-gradient -45deg style). */
function createStripePattern(
ctx: CanvasRenderingContext2D,
color: string,
): CanvasPattern | null {
const size = 20;
const patternCanvas = document.createElement('canvas');
patternCanvas.width = size;
patternCanvas.height = size;
const pCtx = patternCanvas.getContext('2d');
if (!pCtx) {
return null;
}
// Diagonal stripes at -45deg: 10px transparent, 10px colored (0.04 opacity), repeat
pCtx.globalAlpha = 0.04;
pCtx.strokeStyle = color;
pCtx.lineWidth = 10;
pCtx.lineCap = 'butt';
for (let i = -size; i < size * 2; i += size) {
pCtx.beginPath();
pCtx.moveTo(i + size, 0);
pCtx.lineTo(i, size);
pCtx.stroke();
}
pCtx.globalAlpha = 1;
return ctx.createPattern(patternCanvas, 'repeat');
}
export function findSpanById(
spans: FlamegraphSpan[][],
spanId: string,
): { span: FlamegraphSpan; levelIndex: number } | null {
for (let levelIndex = 0; levelIndex < spans.length; levelIndex++) {
const span = spans[levelIndex]?.find((s) => s.spanId === spanId);
if (span) {
return { span, levelIndex };
}
}
return null;
}
export interface FlamegraphRowMetrics {
ROW_HEIGHT: number;
SPAN_BAR_HEIGHT: number;
SPAN_BAR_Y_OFFSET: number;
EVENT_DOT_SIZE: number;
}
export function getFlamegraphRowMetrics(
rowHeight: number,
): FlamegraphRowMetrics {
const spanBarHeight = clamp(
Math.round(rowHeight * SPAN_BAR_HEIGHT_RATIO),
MIN_SPAN_BAR_HEIGHT,
MAX_SPAN_BAR_HEIGHT,
);
const spanBarYOffset = Math.floor((rowHeight - spanBarHeight) / 2);
const eventDotSize = clamp(
Math.round(spanBarHeight * EVENT_DOT_SIZE_RATIO),
MIN_EVENT_DOT_SIZE,
MAX_EVENT_DOT_SIZE,
);
return {
ROW_HEIGHT: rowHeight,
SPAN_BAR_HEIGHT: spanBarHeight,
SPAN_BAR_Y_OFFSET: spanBarYOffset,
EVENT_DOT_SIZE: eventDotSize,
};
}
interface GetSpanColorArgs {
span: FlamegraphSpan;
isDarkMode: boolean;
}
export function getSpanColor(args: GetSpanColorArgs): string {
const { span, isDarkMode } = args;
let color = generateColor(span.serviceName, themeColors.traceDetailColorsV3);
if (span.hasError) {
color = isDarkMode ? 'rgb(239, 68, 68)' : 'rgb(220, 38, 38)';
}
return color;
}
interface DrawEventDotArgs {
ctx: CanvasRenderingContext2D;
x: number;
y: number;
isError: boolean;
isDarkMode: boolean;
eventDotSize: number;
}
export function drawEventDot(args: DrawEventDotArgs): void {
const { ctx, x, y, isError, isDarkMode, eventDotSize } = args;
ctx.save();
ctx.translate(x, y);
ctx.rotate(Math.PI / 4);
if (isError) {
ctx.fillStyle = isDarkMode ? 'rgb(239, 68, 68)' : 'rgb(220, 38, 38)';
ctx.strokeStyle = isDarkMode ? 'rgb(185, 28, 28)' : 'rgb(153, 27, 27)';
} else {
ctx.fillStyle = isDarkMode ? 'rgb(14, 165, 233)' : 'rgb(6, 182, 212)';
ctx.strokeStyle = isDarkMode ? 'rgb(2, 132, 199)' : 'rgb(8, 145, 178)';
}
ctx.lineWidth = 1;
const half = eventDotSize / 2;
ctx.fillRect(-half, -half, eventDotSize, eventDotSize);
ctx.strokeRect(-half, -half, eventDotSize, eventDotSize);
ctx.restore();
}
interface DrawSpanBarArgs {
ctx: CanvasRenderingContext2D;
span: FlamegraphSpan;
x: number;
y: number;
width: number;
levelIndex: number;
spanRectsArray: SpanRect[];
color: string;
isDarkMode: boolean;
metrics: FlamegraphRowMetrics;
selectedSpanId?: string | null;
hoveredSpanId?: string | null;
}
export function drawSpanBar(args: DrawSpanBarArgs): void {
const {
ctx,
span,
x,
y,
width,
levelIndex,
spanRectsArray,
color,
isDarkMode,
metrics,
selectedSpanId,
hoveredSpanId,
} = args;
const spanY = y + metrics.SPAN_BAR_Y_OFFSET;
const isSelected = selectedSpanId === span.spanId;
const isHovered = hoveredSpanId === span.spanId;
const isSelectedOrHovered = isSelected || isHovered;
ctx.beginPath();
ctx.roundRect(x, spanY, width, metrics.SPAN_BAR_HEIGHT, 2);
if (isSelectedOrHovered) {
// Diagonal stripe pattern (repeating-linear-gradient -45deg style) + border in span color
const pattern = createStripePattern(ctx, color);
if (pattern) {
ctx.fillStyle = pattern;
ctx.fill();
}
if (isSelected) {
ctx.setLineDash(DASHED_BORDER_LINE_DASH);
}
ctx.strokeStyle = color;
ctx.lineWidth = isSelected ? 2 : 1;
ctx.stroke();
if (isSelected) {
ctx.setLineDash([]);
}
} else {
ctx.fillStyle = color;
ctx.fill();
}
spanRectsArray.push({
span,
x,
y: spanY,
width,
height: metrics.SPAN_BAR_HEIGHT,
level: levelIndex,
});
span.event?.forEach((event) => {
const spanDurationMs = span.durationNano / 1e6;
if (spanDurationMs <= 0) {
return;
}
const eventTimeMs = event.timeUnixNano / 1e6;
const eventOffsetPercent =
((eventTimeMs - span.timestamp) / spanDurationMs) * 100;
const clampedOffset = clamp(eventOffsetPercent, 1, 99);
const eventX = x + (clampedOffset / 100) * width;
const eventY = spanY + metrics.SPAN_BAR_HEIGHT / 2;
drawEventDot({
ctx,
x: eventX,
y: eventY,
isError: event.isError,
isDarkMode,
eventDotSize: metrics.EVENT_DOT_SIZE,
});
});
drawSpanLabel({
ctx,
span,
x,
y: spanY,
width,
color,
isSelectedOrHovered,
isDarkMode,
spanBarHeight: metrics.SPAN_BAR_HEIGHT,
});
}
export function formatDuration(durationNano: number): string {
const durationMs = durationNano / 1e6;
const { time, timeUnitName } = convertTimeToRelevantUnit(durationMs);
return `${parseFloat(time.toFixed(2))}${timeUnitName}`;
}
interface DrawSpanLabelArgs {
ctx: CanvasRenderingContext2D;
span: FlamegraphSpan;
x: number;
y: number;
width: number;
color: string;
isSelectedOrHovered: boolean;
isDarkMode: boolean;
spanBarHeight: number;
}
function drawSpanLabel(args: DrawSpanLabelArgs): void {
const {
ctx,
span,
x,
y,
width,
color,
isSelectedOrHovered,
isDarkMode,
spanBarHeight,
} = args;
if (width < MIN_WIDTH_FOR_NAME) {
return;
}
const name = span.name;
ctx.save();
// Clip text to span bar bounds
ctx.beginPath();
ctx.rect(x, y, width, spanBarHeight);
ctx.clip();
ctx.font = LABEL_FONT;
ctx.fillStyle = isSelectedOrHovered
? color
: isDarkMode
? 'rgba(0, 0, 0, 0.9)'
: 'rgba(255, 255, 255, 0.9)';
ctx.textBaseline = 'middle';
const textY = y + spanBarHeight / 2;
const leftX = x + LABEL_PADDING_X;
const rightX = x + width - LABEL_PADDING_X;
const availableWidth = width - LABEL_PADDING_X * 2;
if (width >= MIN_WIDTH_FOR_NAME_AND_DURATION) {
const duration = formatDuration(span.durationNano);
const durationWidth = ctx.measureText(duration).width;
const minGap = 6;
const nameSpace = availableWidth - durationWidth - minGap;
// Duration right-aligned
ctx.textAlign = 'right';
ctx.fillText(duration, rightX, textY);
// Name left-aligned, truncated to fit remaining space
if (nameSpace > 20) {
ctx.textAlign = 'left';
ctx.fillText(truncateText(ctx, name, nameSpace), leftX, textY);
}
} else {
// Name only, truncated to fit
ctx.textAlign = 'left';
ctx.fillText(truncateText(ctx, name, availableWidth), leftX, textY);
}
ctx.restore();
}
function truncateText(
ctx: CanvasRenderingContext2D,
text: string,
maxWidth: number,
): string {
const ellipsis = '...';
const ellipsisWidth = ctx.measureText(ellipsis).width;
if (ctx.measureText(text).width <= maxWidth) {
return text;
}
let lo = 0;
let hi = text.length;
while (lo < hi) {
const mid = Math.ceil((lo + hi) / 2);
if (ctx.measureText(text.slice(0, mid)).width + ellipsisWidth <= maxWidth) {
lo = mid;
} else {
hi = mid - 1;
}
}
return lo > 0 ? `${text.slice(0, lo)}${ellipsis}` : ellipsis;
}

View File

@@ -0,0 +1,37 @@
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from '@signozhq/resizable';
import TraceDetailsHeader from './TraceDetailsHeader/TraceDetailsHeader';
import TraceFlamegraph from './TraceFlamegraph/TraceFlamegraph';
function TraceDetailsV3(): JSX.Element {
return (
<div
style={{
height: 'calc(100vh - 90px)',
display: 'flex',
flexDirection: 'column',
}}
>
<TraceDetailsHeader />
<ResizablePanelGroup
direction="vertical"
autoSaveId="trace-details-v3-layout"
style={{ flex: 1 }}
>
<ResizablePanel defaultSize={40} minSize={20} maxSize={80}>
<TraceFlamegraph />
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel defaultSize={60} minSize={20}>
<div />
</ResizablePanel>
</ResizablePanelGroup>
</div>
);
}
export default TraceDetailsV3;