@parca/profile 0.19.121 → 0.19.123
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/dist/GraphTooltipArrow/Content.js +1 -1
- package/dist/MetricsGraph/useMetricsGraphDimensions.d.ts.map +1 -1
- package/dist/MetricsGraph/useMetricsGraphDimensions.js +5 -3
- package/dist/ProfileFlameChart/SamplesStrips/SamplesGraph/index.js +1 -1
- package/dist/ProfileFlameChart/index.d.ts.map +1 -1
- package/dist/ProfileFlameChart/index.js +11 -1
- package/dist/ProfileFlameGraph/FlameGraphArrow/MiniMap.d.ts +20 -0
- package/dist/ProfileFlameGraph/FlameGraphArrow/MiniMap.d.ts.map +1 -0
- package/dist/ProfileFlameGraph/FlameGraphArrow/MiniMap.js +173 -0
- package/dist/ProfileFlameGraph/FlameGraphArrow/ZoomControls.d.ts +11 -0
- package/dist/ProfileFlameGraph/FlameGraphArrow/ZoomControls.d.ts.map +1 -0
- package/dist/ProfileFlameGraph/FlameGraphArrow/ZoomControls.js +10 -0
- package/dist/ProfileFlameGraph/FlameGraphArrow/index.d.ts +1 -0
- package/dist/ProfileFlameGraph/FlameGraphArrow/index.d.ts.map +1 -1
- package/dist/ProfileFlameGraph/FlameGraphArrow/index.js +19 -8
- package/dist/ProfileFlameGraph/FlameGraphArrow/useZoom.d.ts +9 -0
- package/dist/ProfileFlameGraph/FlameGraphArrow/useZoom.d.ts.map +1 -0
- package/dist/ProfileFlameGraph/FlameGraphArrow/useZoom.js +88 -0
- package/dist/ProfileFlameGraph/index.d.ts +2 -1
- package/dist/ProfileFlameGraph/index.d.ts.map +1 -1
- package/dist/ProfileFlameGraph/index.js +4 -6
- package/dist/ProfileMetricsGraph/index.d.ts.map +1 -1
- package/dist/ProfileMetricsGraph/index.js +2 -1
- package/dist/ProfileSelector/MetricsGraphSection.d.ts +1 -2
- package/dist/ProfileSelector/MetricsGraphSection.d.ts.map +1 -1
- package/dist/ProfileSelector/MetricsGraphSection.js +4 -1
- package/dist/ProfileSelector/index.d.ts.map +1 -1
- package/dist/ProfileSelector/index.js +8 -3
- package/dist/TimelineGuide/index.js +1 -1
- package/dist/styles.css +1 -1
- package/package.json +3 -3
- package/src/GraphTooltipArrow/Content.tsx +1 -1
- package/src/MetricsGraph/useMetricsGraphDimensions.ts +7 -5
- package/src/ProfileFlameChart/SamplesStrips/SamplesGraph/index.tsx +1 -1
- package/src/ProfileFlameChart/index.tsx +23 -0
- package/src/ProfileFlameGraph/FlameGraphArrow/MiniMap.tsx +270 -0
- package/src/ProfileFlameGraph/FlameGraphArrow/ZoomControls.tsx +67 -0
- package/src/ProfileFlameGraph/FlameGraphArrow/index.tsx +97 -38
- package/src/ProfileFlameGraph/FlameGraphArrow/useZoom.ts +116 -0
- package/src/ProfileFlameGraph/index.tsx +6 -14
- package/src/ProfileMetricsGraph/index.tsx +5 -1
- package/src/ProfileSelector/MetricsGraphSection.tsx +3 -2
- package/src/ProfileSelector/index.tsx +7 -3
- package/src/TimelineGuide/index.tsx +2 -2
|
@@ -0,0 +1,270 @@
|
|
|
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 !== 0 ? 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
|
+
}, [
|
|
133
|
+
table,
|
|
134
|
+
width,
|
|
135
|
+
zoomedWidth,
|
|
136
|
+
totalHeight,
|
|
137
|
+
maxDepth,
|
|
138
|
+
colorBy,
|
|
139
|
+
colors,
|
|
140
|
+
isDarkMode,
|
|
141
|
+
profileSource,
|
|
142
|
+
]);
|
|
143
|
+
|
|
144
|
+
const isZoomed = zoomedWidth > width;
|
|
145
|
+
const sliderWidth = Math.max(20, (width / zoomedWidth) * width);
|
|
146
|
+
const sliderLeft = Math.min((scrollLeft / zoomedWidth) * width, width - sliderWidth);
|
|
147
|
+
|
|
148
|
+
const handleMouseDown = useCallback(
|
|
149
|
+
(e: React.MouseEvent) => {
|
|
150
|
+
e.preventDefault();
|
|
151
|
+
const rect = containerElRef.current?.getBoundingClientRect();
|
|
152
|
+
if (rect == null) return;
|
|
153
|
+
|
|
154
|
+
const clickX = e.clientX - rect.left;
|
|
155
|
+
|
|
156
|
+
// Check if clicking inside the slider
|
|
157
|
+
if (clickX >= sliderLeft && clickX <= sliderLeft + sliderWidth) {
|
|
158
|
+
// Start dragging
|
|
159
|
+
isDragging.current = true;
|
|
160
|
+
dragStartX.current = e.clientX;
|
|
161
|
+
dragStartScrollLeft.current = scrollLeft;
|
|
162
|
+
} else {
|
|
163
|
+
// Click-to-jump: center viewport at click position
|
|
164
|
+
const targetCenter = (clickX / width) * zoomedWidth;
|
|
165
|
+
const containerWidth = containerRef.current?.clientWidth ?? width;
|
|
166
|
+
const newScrollLeft = targetCenter - containerWidth / 2;
|
|
167
|
+
if (containerRef.current != null) {
|
|
168
|
+
containerRef.current.scrollLeft = Math.max(
|
|
169
|
+
0,
|
|
170
|
+
Math.min(newScrollLeft, zoomedWidth - containerWidth)
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
// Also start dragging from new position
|
|
174
|
+
isDragging.current = true;
|
|
175
|
+
dragStartX.current = e.clientX;
|
|
176
|
+
dragStartScrollLeft.current = containerRef.current?.scrollLeft ?? 0;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const handleMouseMove = (moveEvent: MouseEvent): void => {
|
|
180
|
+
if (!isDragging.current) return;
|
|
181
|
+
const delta = moveEvent.clientX - dragStartX.current;
|
|
182
|
+
const scrollDelta = delta * (zoomedWidth / width);
|
|
183
|
+
const containerWidth = containerRef.current?.clientWidth ?? width;
|
|
184
|
+
if (containerRef.current != null) {
|
|
185
|
+
containerRef.current.scrollLeft = Math.max(
|
|
186
|
+
0,
|
|
187
|
+
Math.min(dragStartScrollLeft.current + scrollDelta, zoomedWidth - containerWidth)
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const handleMouseUp = (): void => {
|
|
193
|
+
isDragging.current = false;
|
|
194
|
+
document.removeEventListener('mousemove', handleMouseMove);
|
|
195
|
+
document.removeEventListener('mouseup', handleMouseUp);
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
document.addEventListener('mousemove', handleMouseMove);
|
|
199
|
+
document.addEventListener('mouseup', handleMouseUp);
|
|
200
|
+
},
|
|
201
|
+
[sliderLeft, sliderWidth, scrollLeft, width, zoomedWidth, containerRef]
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
// Forward wheel events to the container so zoom (Ctrl+scroll) works on the minimap
|
|
205
|
+
useEffect(() => {
|
|
206
|
+
const el = containerElRef.current;
|
|
207
|
+
if (el == null) return;
|
|
208
|
+
|
|
209
|
+
const handleWheel = (e: WheelEvent): void => {
|
|
210
|
+
if (!e.ctrlKey && !e.metaKey) return;
|
|
211
|
+
e.preventDefault();
|
|
212
|
+
containerRef.current?.dispatchEvent(
|
|
213
|
+
new WheelEvent('wheel', {
|
|
214
|
+
deltaY: e.deltaY,
|
|
215
|
+
deltaX: e.deltaX,
|
|
216
|
+
ctrlKey: e.ctrlKey,
|
|
217
|
+
metaKey: e.metaKey,
|
|
218
|
+
clientX: e.clientX,
|
|
219
|
+
clientY: e.clientY,
|
|
220
|
+
bubbles: true,
|
|
221
|
+
})
|
|
222
|
+
);
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
el.addEventListener('wheel', handleWheel, {passive: false});
|
|
226
|
+
return () => {
|
|
227
|
+
el.removeEventListener('wheel', handleWheel);
|
|
228
|
+
};
|
|
229
|
+
}, [containerRef]);
|
|
230
|
+
|
|
231
|
+
if (width <= 0) return null;
|
|
232
|
+
|
|
233
|
+
return (
|
|
234
|
+
<div
|
|
235
|
+
ref={containerElRef}
|
|
236
|
+
className="relative select-none"
|
|
237
|
+
style={{width, height: MINIMAP_HEIGHT, cursor: isZoomed ? 'pointer' : 'default'}}
|
|
238
|
+
onMouseDown={isZoomed ? handleMouseDown : undefined}
|
|
239
|
+
>
|
|
240
|
+
<canvas
|
|
241
|
+
ref={canvasRef}
|
|
242
|
+
style={{
|
|
243
|
+
width,
|
|
244
|
+
height: MINIMAP_HEIGHT,
|
|
245
|
+
display: 'block',
|
|
246
|
+
visibility: isZoomed ? 'visible' : 'hidden',
|
|
247
|
+
}}
|
|
248
|
+
/>
|
|
249
|
+
{isZoomed && (
|
|
250
|
+
<>
|
|
251
|
+
{/* Left overlay */}
|
|
252
|
+
<div
|
|
253
|
+
className="absolute top-0 bottom-0 bg-black/30 dark:bg-black/50"
|
|
254
|
+
style={{left: 0, width: Math.max(0, sliderLeft)}}
|
|
255
|
+
/>
|
|
256
|
+
{/* Viewport slider */}
|
|
257
|
+
<div
|
|
258
|
+
className="absolute top-0 bottom-0 border-x-2 border-gray-500"
|
|
259
|
+
style={{left: sliderLeft, width: sliderWidth}}
|
|
260
|
+
/>
|
|
261
|
+
{/* Right overlay */}
|
|
262
|
+
<div
|
|
263
|
+
className="absolute top-0 bottom-0 bg-black/30 dark:bg-black/50"
|
|
264
|
+
style={{left: sliderLeft + sliderWidth, right: 0}}
|
|
265
|
+
/>
|
|
266
|
+
</>
|
|
267
|
+
)}
|
|
268
|
+
</div>
|
|
269
|
+
);
|
|
270
|
+
});
|
|
@@ -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
|
+
|
|
16
|
+
import {Icon} from '@iconify/react';
|
|
17
|
+
import {createPortal} from 'react-dom';
|
|
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
|
+
};
|
|
@@ -28,22 +28,27 @@ import {FlamegraphArrow} from '@parca/client';
|
|
|
28
28
|
import {FlameGraphSkeleton, SandwichFlameGraphSkeleton, useParcaContext} from '@parca/components';
|
|
29
29
|
import {USER_PREFERENCES, useCurrentColorProfile, useUserPreference} from '@parca/hooks';
|
|
30
30
|
import {ProfileType} from '@parca/parser';
|
|
31
|
-
import {getColorForFeature
|
|
31
|
+
import {getColorForFeature} from '@parca/store';
|
|
32
32
|
import {type ColorConfig} from '@parca/utilities';
|
|
33
33
|
|
|
34
34
|
import {ProfileSource} from '../../ProfileSource';
|
|
35
35
|
import {useProfileFilters} from '../../ProfileView/components/ProfileFilters/useProfileFilters';
|
|
36
36
|
import {useProfileViewContext} from '../../ProfileView/context/ProfileViewContext';
|
|
37
|
+
import {TimelineGuide} from '../../TimelineGuide';
|
|
37
38
|
import {alignedUint8Array} from '../../utils';
|
|
38
39
|
import ContextMenuWrapper, {ContextMenuWrapperRef} from './ContextMenuWrapper';
|
|
39
40
|
import {FlameNode, RowHeight, colorByColors} from './FlameGraphNodes';
|
|
40
41
|
import {MemoizedTooltip} from './MemoizedTooltip';
|
|
42
|
+
import {MiniMap} from './MiniMap';
|
|
41
43
|
import {TooltipProvider} from './TooltipContext';
|
|
44
|
+
import {ZoomControls} from './ZoomControls';
|
|
42
45
|
import {useBatchedRendering} from './useBatchedRendering';
|
|
43
46
|
import {useScrollViewport} from './useScrollViewport';
|
|
44
47
|
import {useVisibleNodes} from './useVisibleNodes';
|
|
48
|
+
import {useZoom} from './useZoom';
|
|
45
49
|
import {
|
|
46
50
|
CurrentPathFrame,
|
|
51
|
+
boundsFromProfileSource,
|
|
47
52
|
extractFeature,
|
|
48
53
|
extractFilenameFeature,
|
|
49
54
|
getCurrentPathFrameData,
|
|
@@ -93,6 +98,7 @@ interface FlameGraphArrowProps {
|
|
|
93
98
|
tooltipId?: string;
|
|
94
99
|
maxFrameCount?: number;
|
|
95
100
|
isExpanded?: boolean;
|
|
101
|
+
zoomControlsRef?: React.RefObject<HTMLDivElement | null>;
|
|
96
102
|
}
|
|
97
103
|
|
|
98
104
|
export const getMappingColors = (
|
|
@@ -145,14 +151,14 @@ export const FlameGraphArrow = memo(function FlameGraphArrow({
|
|
|
145
151
|
mappingsListFromMetadata,
|
|
146
152
|
filenamesListFromMetadata,
|
|
147
153
|
colorBy,
|
|
154
|
+
zoomControlsRef,
|
|
148
155
|
}: FlameGraphArrowProps): React.JSX.Element {
|
|
149
156
|
const [highlightSimilarStacksPreference] = useUserPreference<boolean>(
|
|
150
157
|
USER_PREFERENCES.HIGHLIGHT_SIMILAR_STACKS.key
|
|
151
158
|
);
|
|
152
159
|
const [hoveringRow, setHoveringRow] = useState<number | undefined>(undefined);
|
|
153
160
|
const [dockedMetainfo] = useUserPreference<boolean>(USER_PREFERENCES.GRAPH_METAINFO_DOCKED.key);
|
|
154
|
-
const isDarkMode =
|
|
155
|
-
const {perf} = useParcaContext();
|
|
161
|
+
const {perf, isDarkMode} = useParcaContext();
|
|
156
162
|
|
|
157
163
|
const table: Table = useMemo(() => {
|
|
158
164
|
const result = tableFromIPC(alignedUint8Array(arrow.record), {useBigInt: true});
|
|
@@ -261,6 +267,18 @@ export const FlameGraphArrow = memo(function FlameGraphArrow({
|
|
|
261
267
|
// Get the viewport of the container, this is used to determine which rows are visible.
|
|
262
268
|
const viewport = useScrollViewport(containerRef);
|
|
263
269
|
|
|
270
|
+
const isZoomEnabled = isFlameChart;
|
|
271
|
+
|
|
272
|
+
const {zoomLevel, zoomIn, zoomOut, resetZoom} = useZoom(
|
|
273
|
+
isZoomEnabled ? containerRef : {current: null}
|
|
274
|
+
);
|
|
275
|
+
const zoomedWidth = isZoomEnabled ? Math.round((width ?? 1) * zoomLevel) : width ?? 0;
|
|
276
|
+
|
|
277
|
+
// Reset zoom when the data changes (e.g. new query, different time range)
|
|
278
|
+
useEffect(() => {
|
|
279
|
+
resetZoom();
|
|
280
|
+
}, [table, resetZoom]);
|
|
281
|
+
|
|
264
282
|
// To find the selected row, we must walk the current path and look at which
|
|
265
283
|
// children of the current frame matches the path element exactly. Until the
|
|
266
284
|
// end, the row we find at the end is our selected row.
|
|
@@ -290,7 +308,7 @@ export const FlameGraphArrow = memo(function FlameGraphArrow({
|
|
|
290
308
|
table,
|
|
291
309
|
viewport,
|
|
292
310
|
total,
|
|
293
|
-
width:
|
|
311
|
+
width: zoomedWidth,
|
|
294
312
|
selectedRow,
|
|
295
313
|
effectiveDepth: deferredEffectiveDepth,
|
|
296
314
|
});
|
|
@@ -328,6 +346,15 @@ export const FlameGraphArrow = memo(function FlameGraphArrow({
|
|
|
328
346
|
tooltipId={tooltipId}
|
|
329
347
|
>
|
|
330
348
|
<div className="relative">
|
|
349
|
+
{isZoomEnabled && (
|
|
350
|
+
<ZoomControls
|
|
351
|
+
zoomLevel={zoomLevel}
|
|
352
|
+
zoomIn={zoomIn}
|
|
353
|
+
zoomOut={zoomOut}
|
|
354
|
+
resetZoom={resetZoom}
|
|
355
|
+
portalRef={zoomControlsRef}
|
|
356
|
+
/>
|
|
357
|
+
)}
|
|
331
358
|
<ContextMenuWrapper
|
|
332
359
|
ref={contextMenuRef}
|
|
333
360
|
menuId={MENU_ID}
|
|
@@ -352,49 +379,81 @@ export const FlameGraphArrow = memo(function FlameGraphArrow({
|
|
|
352
379
|
)}
|
|
353
380
|
</div>
|
|
354
381
|
)}
|
|
382
|
+
{isZoomEnabled && (
|
|
383
|
+
<MiniMap
|
|
384
|
+
containerRef={containerRef}
|
|
385
|
+
table={table}
|
|
386
|
+
width={width ?? 0}
|
|
387
|
+
zoomedWidth={zoomedWidth}
|
|
388
|
+
totalHeight={totalHeight}
|
|
389
|
+
maxDepth={deferredEffectiveDepth}
|
|
390
|
+
colorByColors={colorByColors}
|
|
391
|
+
colorBy={colorByValue}
|
|
392
|
+
profileSource={profileSource}
|
|
393
|
+
isDarkMode={isDarkMode}
|
|
394
|
+
scrollLeft={viewport.scrollLeft}
|
|
395
|
+
/>
|
|
396
|
+
)}
|
|
355
397
|
<div
|
|
356
398
|
ref={containerRef}
|
|
357
|
-
className=
|
|
399
|
+
className={`${
|
|
400
|
+
isZoomEnabled ? '[scrollbar-width:none] [&::-webkit-scrollbar]:hidden' : ''
|
|
401
|
+
} will-change-transform webkit-overflow-scrolling-touch contain ${
|
|
402
|
+
!isZoomEnabled ? 'overflow-auto' : ''
|
|
403
|
+
}`}
|
|
358
404
|
style={{
|
|
359
405
|
width: width ?? '100%',
|
|
406
|
+
...(isZoomEnabled ? {overflowX: 'scroll' as const, overflowY: 'auto' as const} : {}),
|
|
360
407
|
contain: 'layout style paint',
|
|
361
408
|
visibility: !showSkeleton ? 'visible' : 'hidden',
|
|
362
409
|
}}
|
|
363
410
|
>
|
|
364
|
-
<
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
key={row}
|
|
374
|
-
table={table}
|
|
375
|
-
row={row}
|
|
376
|
-
colors={colorByColors}
|
|
377
|
-
colorBy={colorByValue}
|
|
378
|
-
totalWidth={width ?? 1}
|
|
379
|
-
height={RowHeight}
|
|
380
|
-
darkMode={isDarkMode}
|
|
381
|
-
compareMode={compareMode}
|
|
382
|
-
colorForSimilarNodes={colorForSimilarNodes}
|
|
383
|
-
selectedRow={selectedRow}
|
|
384
|
-
onClick={() => handleRowClick(row)}
|
|
385
|
-
onContextMenu={displayMenu}
|
|
386
|
-
hoveringRow={highlightSimilarStacksPreference ? hoveringRow : undefined}
|
|
387
|
-
setHoveringRow={highlightSimilarStacksPreference ? setHoveringRow : noop}
|
|
388
|
-
isFlameChart={isFlameChart}
|
|
389
|
-
profileSource={profileSource}
|
|
390
|
-
isRenderedAsFlamegraph={isRenderedAsFlamegraph}
|
|
391
|
-
isInSandwichView={isInSandwichView}
|
|
392
|
-
maxDepth={maxDepth}
|
|
393
|
-
effectiveDepth={deferredEffectiveDepth}
|
|
394
|
-
tooltipId={tooltipId}
|
|
411
|
+
<div>
|
|
412
|
+
{isFlameChart && (
|
|
413
|
+
<TimelineGuide
|
|
414
|
+
bounds={boundsFromProfileSource(profileSource)}
|
|
415
|
+
width={zoomedWidth}
|
|
416
|
+
height={totalHeight}
|
|
417
|
+
margin={0}
|
|
418
|
+
ticks={12}
|
|
419
|
+
timeUnit="nanoseconds"
|
|
395
420
|
/>
|
|
396
|
-
)
|
|
397
|
-
|
|
421
|
+
)}
|
|
422
|
+
<svg
|
|
423
|
+
className="relative font-robotoMono"
|
|
424
|
+
width={zoomedWidth}
|
|
425
|
+
height={totalHeight}
|
|
426
|
+
preserveAspectRatio="xMinYMid"
|
|
427
|
+
ref={svg}
|
|
428
|
+
>
|
|
429
|
+
{batchedNodes.map(row => (
|
|
430
|
+
<FlameNode
|
|
431
|
+
key={row}
|
|
432
|
+
table={table}
|
|
433
|
+
row={row}
|
|
434
|
+
colors={colorByColors}
|
|
435
|
+
colorBy={colorByValue}
|
|
436
|
+
totalWidth={zoomedWidth}
|
|
437
|
+
height={RowHeight}
|
|
438
|
+
darkMode={isDarkMode}
|
|
439
|
+
compareMode={compareMode}
|
|
440
|
+
colorForSimilarNodes={colorForSimilarNodes}
|
|
441
|
+
selectedRow={selectedRow}
|
|
442
|
+
onClick={() => handleRowClick(row)}
|
|
443
|
+
onContextMenu={displayMenu}
|
|
444
|
+
hoveringRow={highlightSimilarStacksPreference ? hoveringRow : undefined}
|
|
445
|
+
setHoveringRow={highlightSimilarStacksPreference ? setHoveringRow : noop}
|
|
446
|
+
isFlameChart={isFlameChart}
|
|
447
|
+
profileSource={profileSource}
|
|
448
|
+
isRenderedAsFlamegraph={isRenderedAsFlamegraph}
|
|
449
|
+
isInSandwichView={isInSandwichView}
|
|
450
|
+
maxDepth={maxDepth}
|
|
451
|
+
effectiveDepth={deferredEffectiveDepth}
|
|
452
|
+
tooltipId={tooltipId}
|
|
453
|
+
/>
|
|
454
|
+
))}
|
|
455
|
+
</svg>
|
|
456
|
+
</div>
|
|
398
457
|
</div>
|
|
399
458
|
</div>
|
|
400
459
|
</TooltipProvider>
|
|
@@ -0,0 +1,116 @@
|
|
|
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 {useCallback, useEffect, useRef, useState} from 'react';
|
|
15
|
+
|
|
16
|
+
import {flushSync} from 'react-dom';
|
|
17
|
+
|
|
18
|
+
const MIN_ZOOM = 1.0;
|
|
19
|
+
const MAX_ZOOM = 20.0;
|
|
20
|
+
const BUTTON_ZOOM_STEP = 1.5;
|
|
21
|
+
// Sensitivity for trackpad/wheel zoom - smaller = smoother
|
|
22
|
+
const WHEEL_ZOOM_SENSITIVITY = 0.01;
|
|
23
|
+
|
|
24
|
+
interface UseZoomResult {
|
|
25
|
+
zoomLevel: number;
|
|
26
|
+
zoomIn: () => void;
|
|
27
|
+
zoomOut: () => void;
|
|
28
|
+
resetZoom: () => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const clampZoom = (zoom: number): number => {
|
|
32
|
+
return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, zoom));
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const useZoom = (containerRef: React.RefObject<HTMLDivElement | null>): UseZoomResult => {
|
|
36
|
+
const [zoomLevel, setZoomLevel] = useState(MIN_ZOOM);
|
|
37
|
+
const zoomLevelRef = useRef(MIN_ZOOM);
|
|
38
|
+
|
|
39
|
+
// Adjust scrollLeft so the content under focalX stays fixed after zoom change.
|
|
40
|
+
const adjustScroll = useCallback(
|
|
41
|
+
(oldZoom: number, newZoom: number, focalX: number) => {
|
|
42
|
+
const container = containerRef.current;
|
|
43
|
+
if (container === null) return;
|
|
44
|
+
|
|
45
|
+
const contentX = container.scrollLeft + focalX;
|
|
46
|
+
const ratio = contentX / oldZoom;
|
|
47
|
+
container.scrollLeft = ratio * newZoom - focalX;
|
|
48
|
+
},
|
|
49
|
+
[containerRef]
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
// Apply a new zoom level around a focal point
|
|
53
|
+
const applyZoom = useCallback(
|
|
54
|
+
(newZoom: number, focalX: number) => {
|
|
55
|
+
const oldZoom = zoomLevelRef.current;
|
|
56
|
+
if (newZoom === oldZoom) return;
|
|
57
|
+
zoomLevelRef.current = newZoom;
|
|
58
|
+
|
|
59
|
+
// flushSync ensures the DOM updates with the new content width before adjustScroll reads it
|
|
60
|
+
flushSync(() => setZoomLevel(newZoom));
|
|
61
|
+
adjustScroll(oldZoom, newZoom, focalX);
|
|
62
|
+
},
|
|
63
|
+
[adjustScroll]
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
const zoomIn = useCallback(() => {
|
|
67
|
+
const newZoom = clampZoom(zoomLevelRef.current * BUTTON_ZOOM_STEP);
|
|
68
|
+
const container = containerRef.current;
|
|
69
|
+
applyZoom(newZoom, container !== null ? container.clientWidth / 2 : 0);
|
|
70
|
+
}, [containerRef, applyZoom]);
|
|
71
|
+
|
|
72
|
+
const zoomOut = useCallback(() => {
|
|
73
|
+
const newZoom = clampZoom(zoomLevelRef.current / BUTTON_ZOOM_STEP);
|
|
74
|
+
const container = containerRef.current;
|
|
75
|
+
applyZoom(newZoom, container !== null ? container.clientWidth / 2 : 0);
|
|
76
|
+
}, [containerRef, applyZoom]);
|
|
77
|
+
|
|
78
|
+
const resetZoom = useCallback(() => {
|
|
79
|
+
zoomLevelRef.current = MIN_ZOOM;
|
|
80
|
+
setZoomLevel(MIN_ZOOM);
|
|
81
|
+
const container = containerRef.current;
|
|
82
|
+
if (container !== null) {
|
|
83
|
+
container.scrollLeft = 0;
|
|
84
|
+
}
|
|
85
|
+
}, [containerRef]);
|
|
86
|
+
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
const container = containerRef.current;
|
|
89
|
+
if (container === null) return;
|
|
90
|
+
|
|
91
|
+
const handleWheel = (e: WheelEvent): void => {
|
|
92
|
+
if (!e.ctrlKey && !e.metaKey) return;
|
|
93
|
+
e.preventDefault();
|
|
94
|
+
|
|
95
|
+
let delta = e.deltaY;
|
|
96
|
+
if (e.deltaMode === 1) {
|
|
97
|
+
delta *= 20;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Limiting the max zoom step per event to 15%, so to fix the huge jumps in Linux OS.
|
|
101
|
+
const MAX_FACTOR = 0.15;
|
|
102
|
+
const rawFactor = -delta * WHEEL_ZOOM_SENSITIVITY;
|
|
103
|
+
const zoomFactor = 1 + Math.max(-MAX_FACTOR, Math.min(MAX_FACTOR, rawFactor));
|
|
104
|
+
|
|
105
|
+
const newZoom = clampZoom(zoomLevelRef.current * zoomFactor);
|
|
106
|
+
applyZoom(newZoom, e.clientX - container.getBoundingClientRect().left);
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
container.addEventListener('wheel', handleWheel, {passive: false});
|
|
110
|
+
return () => {
|
|
111
|
+
container.removeEventListener('wheel', handleWheel);
|
|
112
|
+
};
|
|
113
|
+
}, [containerRef, applyZoom]);
|
|
114
|
+
|
|
115
|
+
return {zoomLevel, zoomIn, zoomOut, resetZoom};
|
|
116
|
+
};
|