Skip to content

Commit 0f1869b

Browse files
committed
Flamecharts zoom option
1 parent 608a097 commit 0f1869b

File tree

7 files changed

+541
-52
lines changed

7 files changed

+541
-52
lines changed

ui/packages/shared/profile/src/ProfileFlameChart/SamplesStrips/SamplesGraph/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ const ZoomWindow = ({
156156
left: zoomWindowState[0],
157157
}}
158158
className={cx(
159-
'bg-gray-500/50 dark:bg-gray-100/90 absolute top-0 border-x-2 border-gray-900 dark:border-gray-100 z-20'
159+
'bg-gray-500/50 dark:bg-gray-400/90 absolute top-0 border-x-2 border-gray-900 dark:border-gray-100 z-20'
160160
)}
161161
>
162162
<div

ui/packages/shared/profile/src/ProfileFlameChart/index.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
type OptionsCustom,
2323
} from '@parca/components';
2424
import {Matcher, MatcherTypes, ProfileType, Query} from '@parca/parser';
25+
import {TimeUnits, formatDateTimeDownToMS, formatDuration} from '@parca/utilities';
2526

2627
import ProfileFlameGraph, {validateFlameChartQuery} from '../ProfileFlameGraph';
2728
import {boundsFromProfileSource} from '../ProfileFlameGraph/FlameGraphArrow/utils';
@@ -127,6 +128,7 @@ export const ProfileFlameChart = ({
127128
onSwitchToOneMinute,
128129
}: ProfileFlameChartProps): JSX.Element => {
129130
const {loader} = useParcaContext();
131+
const zoomControlsRef = useRef<HTMLDivElement>(null);
130132

131133
const [selectedTimeframe, setSelectedTimeframe] = useURLStateCustom<
132134
SelectedTimeframe | undefined
@@ -275,6 +277,21 @@ export const ProfileFlameChart = ({
275277
</div>
276278
)}
277279

280+
{/* Selected timeframe description + zoom controls */}
281+
{selectedTimeframe != null && (() => {
282+
const labels = selectedTimeframe.labels.labels.map(l => `${l.name} = ${l.value}`).join(', ');
283+
const durationMs = selectedTimeframe.bounds[1] - selectedTimeframe.bounds[0];
284+
const duration = formatDuration({[TimeUnits.Milliseconds]: durationMs});
285+
return (
286+
<div className="flex items-center justify-between px-2 py-1">
287+
<div className="text-xs font-medium text-gray-500 dark:text-gray-400">
288+
Samples matching {labels} over {duration} from {formatDateTimeDownToMS(selectedTimeframe.bounds[0])} to {formatDateTimeDownToMS(selectedTimeframe.bounds[1])}
289+
</div>
290+
<div ref={zoomControlsRef} />
291+
</div>
292+
);
293+
})()}
294+
278295
{/* Flamegraph visualization - only shown when a time range is selected in the strips */}
279296
{selectedTimeframe != null && filteredProfileSource != null ? (
280297
<ProfileFlameGraph
@@ -292,6 +309,7 @@ export const ProfileFlameChart = ({
292309
isFlameChart={true}
293310
curPathArrow={[]}
294311
setNewCurPathArrow={() => {}}
312+
zoomControlsRef={zoomControlsRef}
295313
/>
296314
) : (
297315
<div className="flex justify-center items-center py-10 text-gray-500 dark:text-gray-400 text-sm">
Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
// Copyright 2022 The Parca Authors
2+
// Licensed under the Apache License, Version 2.0 (the "License");
3+
// you may not use this file except in compliance with the License.
4+
// You may obtain a copy of the License at
5+
//
6+
// http://www.apache.org/licenses/LICENSE-2.0
7+
//
8+
// Unless required by applicable law or agreed to in writing, software
9+
// distributed under the License is distributed on an "AS IS" BASIS,
10+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
// See the License for the specific language governing permissions and
12+
// limitations under the License.
13+
14+
import React, { useCallback, useEffect, useRef } from 'react';
15+
16+
import { Table } from '@uwdata/flechette';
17+
18+
import { EVERYTHING_ELSE } from '@parca/store';
19+
import { getLastItem } from '@parca/utilities';
20+
21+
import { ProfileSource } from '../../ProfileSource';
22+
import { RowHeight, type colorByColors } from './FlameGraphNodes';
23+
import {
24+
FIELD_CUMULATIVE,
25+
FIELD_DEPTH,
26+
FIELD_FUNCTION_FILE_NAME,
27+
FIELD_MAPPING_FILE,
28+
FIELD_TIMESTAMP,
29+
} from './index';
30+
import { arrowToString, boundsFromProfileSource } from './utils';
31+
32+
const MINIMAP_HEIGHT = 20;
33+
34+
interface MiniMapProps {
35+
containerRef: React.RefObject<HTMLDivElement | null>;
36+
table: Table;
37+
width: number;
38+
zoomedWidth: number;
39+
totalHeight: number;
40+
maxDepth: number;
41+
colorByColors: colorByColors;
42+
colorBy: string;
43+
profileSource: ProfileSource;
44+
isDarkMode: boolean;
45+
scrollLeft: number;
46+
}
47+
48+
export const MiniMap = React.memo(function MiniMap({
49+
containerRef,
50+
table,
51+
width,
52+
zoomedWidth,
53+
totalHeight,
54+
maxDepth,
55+
colorByColors: colors,
56+
colorBy,
57+
profileSource,
58+
isDarkMode,
59+
scrollLeft,
60+
}: MiniMapProps): React.JSX.Element | null {
61+
const canvasRef = useRef<HTMLCanvasElement>(null);
62+
const containerElRef = useRef<HTMLDivElement>(null);
63+
const isDragging = useRef(false);
64+
const dragStartX = useRef(0);
65+
const dragStartScrollLeft = useRef(0);
66+
67+
// Render minimap canvas
68+
useEffect(() => {
69+
const canvas = canvasRef.current;
70+
if (canvas == null || width <= 0 || zoomedWidth <= 0) return;
71+
72+
const dpr = window.devicePixelRatio || 1;
73+
canvas.width = width * dpr;
74+
canvas.height = MINIMAP_HEIGHT * dpr;
75+
76+
const ctx = canvas.getContext('2d');
77+
if (ctx == null) return;
78+
79+
ctx.scale(dpr, dpr);
80+
ctx.clearRect(0, 0, width, MINIMAP_HEIGHT);
81+
82+
// Background
83+
ctx.fillStyle = isDarkMode ? '#374151' : '#f3f4f6';
84+
ctx.fillRect(0, 0, width, MINIMAP_HEIGHT);
85+
86+
const xScale = width / zoomedWidth;
87+
const yScale = MINIMAP_HEIGHT / totalHeight;
88+
89+
const tsBounds = boundsFromProfileSource(profileSource);
90+
const tsRange = Number(tsBounds[1]) - Number(tsBounds[0]);
91+
if (tsRange <= 0) return;
92+
93+
const depthCol = table.getChild(FIELD_DEPTH);
94+
const cumulativeCol = table.getChild(FIELD_CUMULATIVE);
95+
const tsCol = table.getChild(FIELD_TIMESTAMP);
96+
const mappingCol = table.getChild(FIELD_MAPPING_FILE);
97+
const filenameCol = table.getChild(FIELD_FUNCTION_FILE_NAME);
98+
99+
if (depthCol == null || cumulativeCol == null) return;
100+
101+
const numRows = table.numRows;
102+
103+
for (let row = 0; row < numRows; row++) {
104+
const depth = depthCol.get(row) ?? 0;
105+
if (depth === 0) continue; // skip root
106+
107+
if (depth > maxDepth) continue;
108+
109+
const cumulative = Number(cumulativeCol.get(row) ?? 0n);
110+
if (cumulative <= 0) continue;
111+
112+
const nodeWidth = (cumulative / tsRange) * zoomedWidth * xScale;
113+
if (nodeWidth < 0.5) continue;
114+
115+
const ts = tsCol != null ? Number(tsCol.get(row)) : 0;
116+
const x = ((ts - Number(tsBounds[0])) / tsRange) * zoomedWidth * xScale;
117+
const y = (depth - 1) * RowHeight * yScale;
118+
const h = Math.max(1, RowHeight * yScale);
119+
120+
// Get color using same logic as useNodeColor
121+
const colorAttribute =
122+
colorBy === 'filename'
123+
? arrowToString(filenameCol?.get(row))
124+
: colorBy === 'binary'
125+
? arrowToString(mappingCol?.get(row))
126+
: null;
127+
128+
const color = colors[getLastItem(colorAttribute ?? '') ?? EVERYTHING_ELSE];
129+
ctx.fillStyle = color ?? (isDarkMode ? '#6b7280' : '#9ca3af');
130+
ctx.fillRect(x, y, Math.max(0.5, nodeWidth), h);
131+
}
132+
}, [table, width, zoomedWidth, totalHeight, maxDepth, colorBy, colors, isDarkMode, profileSource]);
133+
134+
const isZoomed = zoomedWidth > width;
135+
const sliderWidth = Math.max(20, (width / zoomedWidth) * width);
136+
const sliderLeft = Math.min((scrollLeft / zoomedWidth) * width, width - sliderWidth);
137+
138+
const handleMouseDown = useCallback(
139+
(e: React.MouseEvent) => {
140+
e.preventDefault();
141+
const rect = containerElRef.current?.getBoundingClientRect();
142+
if (rect == null) return;
143+
144+
const clickX = e.clientX - rect.left;
145+
146+
// Check if clicking inside the slider
147+
if (clickX >= sliderLeft && clickX <= sliderLeft + sliderWidth) {
148+
// Start dragging
149+
isDragging.current = true;
150+
dragStartX.current = e.clientX;
151+
dragStartScrollLeft.current = scrollLeft;
152+
} else {
153+
// Click-to-jump: center viewport at click position
154+
const targetCenter = (clickX / width) * zoomedWidth;
155+
const containerWidth = containerRef.current?.clientWidth ?? width;
156+
const newScrollLeft = targetCenter - containerWidth / 2;
157+
if (containerRef.current != null) {
158+
containerRef.current.scrollLeft = Math.max(
159+
0,
160+
Math.min(newScrollLeft, zoomedWidth - containerWidth)
161+
);
162+
}
163+
// Also start dragging from new position
164+
isDragging.current = true;
165+
dragStartX.current = e.clientX;
166+
dragStartScrollLeft.current = containerRef.current?.scrollLeft ?? 0;
167+
}
168+
169+
const handleMouseMove = (moveEvent: MouseEvent): void => {
170+
if (!isDragging.current) return;
171+
const delta = moveEvent.clientX - dragStartX.current;
172+
const scrollDelta = delta * (zoomedWidth / width);
173+
const containerWidth = containerRef.current?.clientWidth ?? width;
174+
if (containerRef.current != null) {
175+
containerRef.current.scrollLeft = Math.max(
176+
0,
177+
Math.min(dragStartScrollLeft.current + scrollDelta, zoomedWidth - containerWidth)
178+
);
179+
}
180+
};
181+
182+
const handleMouseUp = (): void => {
183+
isDragging.current = false;
184+
document.removeEventListener('mousemove', handleMouseMove);
185+
document.removeEventListener('mouseup', handleMouseUp);
186+
};
187+
188+
document.addEventListener('mousemove', handleMouseMove);
189+
document.addEventListener('mouseup', handleMouseUp);
190+
},
191+
[sliderLeft, sliderWidth, scrollLeft, width, zoomedWidth, containerRef]
192+
);
193+
194+
// Forward wheel events to the container so zoom (Ctrl+scroll) works on the minimap
195+
useEffect(() => {
196+
const el = containerElRef.current;
197+
if (el == null) return;
198+
199+
const handleWheel = (e: WheelEvent): void => {
200+
if (!e.ctrlKey && !e.metaKey) return;
201+
e.preventDefault();
202+
containerRef.current?.dispatchEvent(
203+
new WheelEvent('wheel', {
204+
deltaY: e.deltaY,
205+
deltaX: e.deltaX,
206+
ctrlKey: e.ctrlKey,
207+
metaKey: e.metaKey,
208+
clientX: e.clientX,
209+
clientY: e.clientY,
210+
bubbles: true,
211+
})
212+
);
213+
};
214+
215+
el.addEventListener('wheel', handleWheel, { passive: false });
216+
return () => {
217+
el.removeEventListener('wheel', handleWheel);
218+
};
219+
}, [containerRef]);
220+
221+
if (width <= 0) return null;
222+
223+
return (
224+
<div
225+
ref={containerElRef}
226+
className="relative select-none"
227+
style={{ width, height: MINIMAP_HEIGHT, cursor: isZoomed ? 'pointer' : 'default' }}
228+
onMouseDown={isZoomed ? handleMouseDown : undefined}
229+
>
230+
<canvas
231+
ref={canvasRef}
232+
style={{ width, height: MINIMAP_HEIGHT, display: 'block', visibility: isZoomed ? 'visible' : 'hidden' }}
233+
/>
234+
{isZoomed && (
235+
<>
236+
{/* Left overlay */}
237+
<div
238+
className="absolute top-0 bottom-0 bg-black/30 dark:bg-black/50"
239+
style={{ left: 0, width: Math.max(0, sliderLeft) }}
240+
/>
241+
{/* Viewport slider */}
242+
<div
243+
className="absolute top-0 bottom-0 border-x-2 border-gray-500"
244+
style={{ left: sliderLeft, width: sliderWidth }}
245+
/>
246+
{/* Right overlay */}
247+
<div
248+
className="absolute top-0 bottom-0 bg-black/30 dark:bg-black/50"
249+
style={{ left: sliderLeft + sliderWidth, right: 0 }}
250+
/>
251+
</>
252+
)}
253+
</div>
254+
);
255+
});
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
// Copyright 2022 The Parca Authors
2+
// Licensed under the Apache License, Version 2.0 (the "License");
3+
// you may not use this file except in compliance with the License.
4+
// You may obtain a copy of the License at
5+
//
6+
// http://www.apache.org/licenses/LICENSE-2.0
7+
//
8+
// Unless required by applicable law or agreed to in writing, software
9+
// distributed under the License is distributed on an "AS IS" BASIS,
10+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
// See the License for the specific language governing permissions and
12+
// limitations under the License.
13+
14+
import React from 'react';
15+
import {createPortal} from 'react-dom';
16+
17+
import {Icon} from '@iconify/react';
18+
19+
interface ZoomControlsProps {
20+
zoomLevel: number;
21+
zoomIn: () => void;
22+
zoomOut: () => void;
23+
resetZoom: () => void;
24+
portalRef?: React.RefObject<HTMLDivElement | null>;
25+
}
26+
27+
export const ZoomControls = ({
28+
zoomLevel,
29+
zoomIn,
30+
zoomOut,
31+
resetZoom,
32+
portalRef,
33+
}: ZoomControlsProps): React.JSX.Element => {
34+
const controls = (
35+
<div className="flex items-center gap-1 rounded-md border border-gray-200 bg-white/90 px-1 py-0.5 shadow-sm backdrop-blur-sm dark:border-gray-600 dark:bg-gray-800/90">
36+
<button
37+
onClick={zoomOut}
38+
disabled={zoomLevel <= 1}
39+
className="rounded p-1 text-gray-600 hover:bg-gray-100 disabled:opacity-30 dark:text-gray-300 dark:hover:bg-gray-700"
40+
title="Zoom out"
41+
>
42+
<Icon icon="mdi:minus" width={16} height={16} />
43+
</button>
44+
<button
45+
onClick={resetZoom}
46+
className="min-w-[3rem] px-1 text-center text-xs text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700 rounded"
47+
title="Reset zoom"
48+
>
49+
{Math.round(zoomLevel * 100)}%
50+
</button>
51+
<button
52+
onClick={zoomIn}
53+
disabled={zoomLevel >= 20}
54+
className="rounded p-1 text-gray-600 hover:bg-gray-100 disabled:opacity-30 dark:text-gray-300 dark:hover:bg-gray-700"
55+
title="Zoom in"
56+
>
57+
<Icon icon="mdi:plus" width={16} height={16} />
58+
</button>
59+
</div>
60+
);
61+
62+
if (portalRef?.current != null) {
63+
return createPortal(controls, portalRef.current);
64+
}
65+
66+
return controls;
67+
};

0 commit comments

Comments
 (0)