@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.
Files changed (45) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/dist/GraphTooltipArrow/Content.js +1 -1
  3. package/dist/MetricsGraph/useMetricsGraphDimensions.d.ts.map +1 -1
  4. package/dist/MetricsGraph/useMetricsGraphDimensions.js +5 -3
  5. package/dist/ProfileFlameChart/SamplesStrips/SamplesGraph/index.js +1 -1
  6. package/dist/ProfileFlameChart/index.d.ts.map +1 -1
  7. package/dist/ProfileFlameChart/index.js +11 -1
  8. package/dist/ProfileFlameGraph/FlameGraphArrow/MiniMap.d.ts +20 -0
  9. package/dist/ProfileFlameGraph/FlameGraphArrow/MiniMap.d.ts.map +1 -0
  10. package/dist/ProfileFlameGraph/FlameGraphArrow/MiniMap.js +173 -0
  11. package/dist/ProfileFlameGraph/FlameGraphArrow/ZoomControls.d.ts +11 -0
  12. package/dist/ProfileFlameGraph/FlameGraphArrow/ZoomControls.d.ts.map +1 -0
  13. package/dist/ProfileFlameGraph/FlameGraphArrow/ZoomControls.js +10 -0
  14. package/dist/ProfileFlameGraph/FlameGraphArrow/index.d.ts +1 -0
  15. package/dist/ProfileFlameGraph/FlameGraphArrow/index.d.ts.map +1 -1
  16. package/dist/ProfileFlameGraph/FlameGraphArrow/index.js +19 -8
  17. package/dist/ProfileFlameGraph/FlameGraphArrow/useZoom.d.ts +9 -0
  18. package/dist/ProfileFlameGraph/FlameGraphArrow/useZoom.d.ts.map +1 -0
  19. package/dist/ProfileFlameGraph/FlameGraphArrow/useZoom.js +88 -0
  20. package/dist/ProfileFlameGraph/index.d.ts +2 -1
  21. package/dist/ProfileFlameGraph/index.d.ts.map +1 -1
  22. package/dist/ProfileFlameGraph/index.js +4 -6
  23. package/dist/ProfileMetricsGraph/index.d.ts.map +1 -1
  24. package/dist/ProfileMetricsGraph/index.js +2 -1
  25. package/dist/ProfileSelector/MetricsGraphSection.d.ts +1 -2
  26. package/dist/ProfileSelector/MetricsGraphSection.d.ts.map +1 -1
  27. package/dist/ProfileSelector/MetricsGraphSection.js +4 -1
  28. package/dist/ProfileSelector/index.d.ts.map +1 -1
  29. package/dist/ProfileSelector/index.js +8 -3
  30. package/dist/TimelineGuide/index.js +1 -1
  31. package/dist/styles.css +1 -1
  32. package/package.json +3 -3
  33. package/src/GraphTooltipArrow/Content.tsx +1 -1
  34. package/src/MetricsGraph/useMetricsGraphDimensions.ts +7 -5
  35. package/src/ProfileFlameChart/SamplesStrips/SamplesGraph/index.tsx +1 -1
  36. package/src/ProfileFlameChart/index.tsx +23 -0
  37. package/src/ProfileFlameGraph/FlameGraphArrow/MiniMap.tsx +270 -0
  38. package/src/ProfileFlameGraph/FlameGraphArrow/ZoomControls.tsx +67 -0
  39. package/src/ProfileFlameGraph/FlameGraphArrow/index.tsx +97 -38
  40. package/src/ProfileFlameGraph/FlameGraphArrow/useZoom.ts +116 -0
  41. package/src/ProfileFlameGraph/index.tsx +6 -14
  42. package/src/ProfileMetricsGraph/index.tsx +5 -1
  43. package/src/ProfileSelector/MetricsGraphSection.tsx +3 -2
  44. package/src/ProfileSelector/index.tsx +7 -3
  45. 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, selectDarkMode, useAppSelector} from '@parca/store';
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 = useAppSelector(selectDarkMode);
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: width ?? 1,
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="overflow-auto scrollbar-thin scrollbar-thumb-gray-400 scrollbar-track-gray-100 dark:scrollbar-thumb-gray-600 dark:scrollbar-track-gray-800 will-change-transform scroll-smooth webkit-overflow-scrolling-touch contain"
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
- <svg
365
- className="font-robotoMono"
366
- width={width ?? 0}
367
- height={totalHeight}
368
- preserveAspectRatio="xMinYMid"
369
- ref={svg}
370
- >
371
- {batchedNodes.map(row => (
372
- <FlameNode
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
- </svg>
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
+ };