@parca/profile 0.19.132 → 0.19.134

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 (54) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/dist/ProfileExplorer/ProfileExplorerSingle.d.ts.map +1 -1
  3. package/dist/ProfileExplorer/ProfileExplorerSingle.js +3 -9
  4. package/dist/ProfileFlameChart/SamplesStrips/index.d.ts +2 -2
  5. package/dist/ProfileFlameChart/SamplesStrips/index.d.ts.map +1 -1
  6. package/dist/ProfileFlameChart/SamplesStrips/index.js +61 -39
  7. package/dist/ProfileFlameChart/SamplesStrips/labelSetUtils.d.ts +7 -0
  8. package/dist/ProfileFlameChart/SamplesStrips/labelSetUtils.d.ts.map +1 -0
  9. package/dist/ProfileFlameChart/SamplesStrips/labelSetUtils.js +79 -0
  10. package/dist/ProfileFlameChart/index.d.ts +1 -2
  11. package/dist/ProfileFlameChart/index.d.ts.map +1 -1
  12. package/dist/ProfileFlameChart/index.js +14 -21
  13. package/dist/ProfileFlameGraph/FlameGraphArrow/MiniMap.d.ts +3 -0
  14. package/dist/ProfileFlameGraph/FlameGraphArrow/MiniMap.d.ts.map +1 -1
  15. package/dist/ProfileFlameGraph/FlameGraphArrow/MiniMap.js +89 -24
  16. package/dist/ProfileFlameGraph/FlameGraphArrow/ZoomControls.d.ts.map +1 -1
  17. package/dist/ProfileFlameGraph/FlameGraphArrow/ZoomControls.js +2 -1
  18. package/dist/ProfileFlameGraph/FlameGraphArrow/index.d.ts.map +1 -1
  19. package/dist/ProfileFlameGraph/FlameGraphArrow/index.js +2 -2
  20. package/dist/ProfileFlameGraph/FlameGraphArrow/useZoom.d.ts +4 -0
  21. package/dist/ProfileFlameGraph/FlameGraphArrow/useZoom.d.ts.map +1 -1
  22. package/dist/ProfileFlameGraph/FlameGraphArrow/useZoom.js +51 -10
  23. package/dist/ProfileFlameGraph/index.d.ts +0 -1
  24. package/dist/ProfileFlameGraph/index.d.ts.map +1 -1
  25. package/dist/ProfileFlameGraph/index.js +3 -8
  26. package/dist/ProfileView/components/DashboardItems/index.d.ts +1 -2
  27. package/dist/ProfileView/components/DashboardItems/index.d.ts.map +1 -1
  28. package/dist/ProfileView/components/DashboardItems/index.js +2 -2
  29. package/dist/ProfileView/index.d.ts +1 -1
  30. package/dist/ProfileView/index.d.ts.map +1 -1
  31. package/dist/ProfileView/index.js +1 -2
  32. package/dist/ProfileView/types/visualization.d.ts +0 -1
  33. package/dist/ProfileView/types/visualization.d.ts.map +1 -1
  34. package/dist/ProfileViewWithData.d.ts +1 -2
  35. package/dist/ProfileViewWithData.d.ts.map +1 -1
  36. package/dist/ProfileViewWithData.js +2 -2
  37. package/dist/TimelineGuide/index.js +1 -1
  38. package/dist/styles.css +1 -1
  39. package/package.json +4 -4
  40. package/src/ProfileExplorer/ProfileExplorerSingle.tsx +3 -14
  41. package/src/ProfileFlameChart/SamplesStrips/index.tsx +90 -49
  42. package/src/ProfileFlameChart/SamplesStrips/labelSetUtils.test.ts +73 -0
  43. package/src/ProfileFlameChart/SamplesStrips/labelSetUtils.ts +86 -0
  44. package/src/ProfileFlameChart/index.tsx +16 -45
  45. package/src/ProfileFlameGraph/FlameGraphArrow/MiniMap.tsx +119 -25
  46. package/src/ProfileFlameGraph/FlameGraphArrow/ZoomControls.tsx +3 -1
  47. package/src/ProfileFlameGraph/FlameGraphArrow/index.tsx +5 -3
  48. package/src/ProfileFlameGraph/FlameGraphArrow/useZoom.ts +78 -17
  49. package/src/ProfileFlameGraph/index.tsx +4 -24
  50. package/src/ProfileView/components/DashboardItems/index.tsx +0 -3
  51. package/src/ProfileView/index.tsx +0 -2
  52. package/src/ProfileView/types/visualization.ts +0 -1
  53. package/src/ProfileViewWithData.tsx +0 -3
  54. package/src/TimelineGuide/index.tsx +1 -1
package/CHANGELOG.md CHANGED
@@ -3,6 +3,14 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ ## [0.19.134](https://github.com/parca-dev/parca/compare/@parca/profile@0.19.133...@parca/profile@0.19.134) (2026-03-11)
7
+
8
+ **Note:** Version bump only for package @parca/profile
9
+
10
+ ## [0.19.133](https://github.com/parca-dev/parca/compare/@parca/profile@0.19.132...@parca/profile@0.19.133) (2026-03-04)
11
+
12
+ **Note:** Version bump only for package @parca/profile
13
+
6
14
  ## [0.19.132](https://github.com/parca-dev/parca/compare/@parca/profile@0.19.131...@parca/profile@0.19.132) (2026-03-03)
7
15
 
8
16
  **Note:** Version bump only for package @parca/profile
@@ -1 +1 @@
1
- {"version":3,"file":"ProfileExplorerSingle.d.ts","sourceRoot":"","sources":["../../src/ProfileExplorer/ProfileExplorerSingle.tsx"],"names":[],"mappings":"AAeA,OAAO,EAAC,kBAAkB,EAAC,MAAM,eAAe,CAAC;AACjD,OAAO,KAAK,EAAC,gBAAgB,EAAC,MAAM,kBAAkB,CAAC;AAMvD,UAAU,0BAA0B;IAClC,WAAW,EAAE,kBAAkB,CAAC;IAChC,UAAU,EAAE,gBAAgB,CAAC;CAC9B;AAED,QAAA,MAAM,qBAAqB,GAAI,8BAG5B,0BAA0B,KAAG,GAAG,CAAC,OAmCnC,CAAC;AAEF,eAAe,qBAAqB,CAAC"}
1
+ {"version":3,"file":"ProfileExplorerSingle.d.ts","sourceRoot":"","sources":["../../src/ProfileExplorer/ProfileExplorerSingle.tsx"],"names":[],"mappings":"AAeA,OAAO,EAAC,kBAAkB,EAAC,MAAM,eAAe,CAAC;AACjD,OAAO,KAAK,EAAC,gBAAgB,EAAC,MAAM,kBAAkB,CAAC;AAMvD,UAAU,0BAA0B;IAClC,WAAW,EAAE,kBAAkB,CAAC;IAChC,UAAU,EAAE,gBAAgB,CAAC;CAC9B;AAED,QAAA,MAAM,qBAAqB,GAAI,8BAG5B,0BAA0B,KAAG,GAAG,CAAC,OAwBnC,CAAC;AAEF,eAAe,qBAAqB,CAAC"}
@@ -11,19 +11,13 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
11
11
  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
12
  // See the License for the specific language governing permissions and
13
13
  // limitations under the License.
14
- import { useCallback, useState } from 'react';
14
+ import { useState } from 'react';
15
15
  import { ProfileViewWithData } from '..';
16
16
  import ProfileSelector from '../ProfileSelector';
17
17
  import { useQueryState } from '../hooks/useQueryState';
18
18
  const ProfileExplorerSingle = ({ queryClient, navigateTo, }) => {
19
19
  const [showMetricsGraph, setShowMetricsGraph] = useState(true);
20
- const { profileSource, setDraftTimeRange, commitDraft } = useQueryState({ suffix: '_a' });
21
- const handleSwitchToOneMinute = useCallback(() => {
22
- const now = Date.now();
23
- const from = now - 60000; // 1 minute ago
24
- setDraftTimeRange(from, now, 'relative:minute|1');
25
- commitDraft({ from, to: now, timeSelection: 'relative:minute|1' });
26
- }, [setDraftTimeRange, commitDraft]);
27
- return (_jsxs(_Fragment, { children: [_jsx("div", { className: "relative", children: _jsx(ProfileSelector, { queryClient: queryClient, closeProfile: () => { }, comparing: false, enforcedProfileName: '', navigateTo: navigateTo, suffix: "_a", showMetricsGraph: showMetricsGraph, setDisplayHideMetricsGraphButton: setShowMetricsGraph }) }), profileSource != null && (_jsx(ProfileViewWithData, { queryClient: queryClient, profileSource: profileSource, onSwitchToOneMinute: handleSwitchToOneMinute }))] }));
20
+ const { profileSource } = useQueryState({ suffix: '_a' });
21
+ return (_jsxs(_Fragment, { children: [_jsx("div", { className: "relative", children: _jsx(ProfileSelector, { queryClient: queryClient, closeProfile: () => { }, comparing: false, enforcedProfileName: '', navigateTo: navigateTo, suffix: "_a", showMetricsGraph: showMetricsGraph, setDisplayHideMetricsGraphButton: setShowMetricsGraph }) }), profileSource != null && (_jsx(ProfileViewWithData, { queryClient: queryClient, profileSource: profileSource }))] }));
28
22
  };
29
23
  export default ProfileExplorerSingle;
@@ -3,6 +3,7 @@ import { NumberDuo } from '../../utils';
3
3
  import { DataPoint } from './SamplesGraph';
4
4
  export type { DataPoint } from './SamplesGraph';
5
5
  interface Props {
6
+ loading?: boolean;
6
7
  cpus: LabelSet[];
7
8
  data: DataPoint[][];
8
9
  selectedTimeframe?: {
@@ -14,6 +15,5 @@ interface Props {
14
15
  bounds: NumberDuo;
15
16
  stepMs: number;
16
17
  }
17
- export declare const labelSetToString: (labelSet?: LabelSet) => string;
18
- export declare const SamplesStrip: ({ cpus, data, selectedTimeframe, onSelectedTimeframe, width, bounds, stepMs, }: Props) => JSX.Element;
18
+ export declare const SamplesStrip: ({ loading, cpus, data, selectedTimeframe, onSelectedTimeframe, width, bounds, stepMs, }: Props) => JSX.Element;
19
19
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/ProfileFlameChart/SamplesStrips/index.tsx"],"names":[],"mappings":"AAqBA,OAAO,EAAC,QAAQ,EAAC,MAAM,eAAe,CAAC;AAIvC,OAAO,EAAC,SAAS,EAAC,MAAM,aAAa,CAAC;AACtC,OAAO,EAAC,SAAS,EAAe,MAAM,gBAAgB,CAAC;AAEvD,YAAY,EAAC,SAAS,EAAC,MAAM,gBAAgB,CAAC;AAQ9C,UAAU,KAAK;IACb,IAAI,EAAE,QAAQ,EAAE,CAAC;IACjB,IAAI,EAAE,SAAS,EAAE,EAAE,CAAC;IACpB,iBAAiB,CAAC,EAAE;QAClB,MAAM,EAAE,QAAQ,CAAC;QACjB,MAAM,EAAE,SAAS,CAAC;KACnB,CAAC;IACF,mBAAmB,EAAE,CAAC,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,SAAS,GAAG,SAAS,KAAK,IAAI,CAAC;IAC/E,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,SAAS,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,eAAO,MAAM,gBAAgB,GAAI,WAAW,QAAQ,KAAG,MAoBtD,CAAC;AA8FF,eAAO,MAAM,YAAY,GAAI,gFAQ1B,KAAK,KAAG,GAAG,CAAC,OAiJd,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/ProfileFlameChart/SamplesStrips/index.tsx"],"names":[],"mappings":"AAqBA,OAAO,EAAC,QAAQ,EAAC,MAAM,eAAe,CAAC;AAIvC,OAAO,EAAC,SAAS,EAAC,MAAM,aAAa,CAAC;AACtC,OAAO,EAAC,SAAS,EAAe,MAAM,gBAAgB,CAAC;AAGvD,YAAY,EAAC,SAAS,EAAC,MAAM,gBAAgB,CAAC;AAQ9C,UAAU,KAAK;IACb,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,IAAI,EAAE,QAAQ,EAAE,CAAC;IACjB,IAAI,EAAE,SAAS,EAAE,EAAE,CAAC;IACpB,iBAAiB,CAAC,EAAE;QAClB,MAAM,EAAE,QAAQ,CAAC;QACjB,MAAM,EAAE,SAAS,CAAC;KACnB,CAAC;IACF,mBAAmB,EAAE,CAAC,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,SAAS,GAAG,SAAS,KAAK,IAAI,CAAC;IAC/E,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,SAAS,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC;CAChB;AAmID,eAAO,MAAM,YAAY,GAAI,yFAS1B,KAAK,KAAG,GAAG,CAAC,OAwKd,CAAC"}
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  // Copyright 2022 The Parca Authors
3
3
  // Licensed under the Apache License, Version 2.0 (the "License");
4
4
  // you may not use this file except in compliance with the License.
@@ -17,35 +17,46 @@ import cx from 'classnames';
17
17
  import * as d3 from 'd3';
18
18
  import isEqual from 'fast-deep-equal';
19
19
  import { useIntersectionObserver } from 'usehooks-ts';
20
- import { Button } from '@parca/components';
20
+ import { Button, useParcaContext } from '@parca/components';
21
21
  import { TimelineGuide } from '../../TimelineGuide';
22
22
  import { SamplesGraph } from './SamplesGraph';
23
- export const labelSetToString = (labelSet) => {
24
- if (labelSet === undefined) {
25
- return '{}';
26
- }
27
- let str = '{';
28
- let isFirst = true;
29
- for (const label of labelSet.labels) {
30
- if (!isFirst) {
31
- str += ', ';
32
- }
33
- else {
34
- isFirst = false;
35
- }
36
- str += `${label.name}: ${label.value}`;
37
- }
38
- str += '}';
39
- return str;
40
- };
23
+ import { createLabelSetComparator, labelSetToString } from './labelSetUtils';
41
24
  const STRIP_HEIGHT = 24;
25
+ const LABEL_ROW_HEIGHT = 16; // text-xs label row above each strip
26
+ const GAP = 4; // gap-1 between flex children
42
27
  const MAX_VISIBLE_STRIPS = 20;
28
+ const LOADING_STRIP_COUNT = 8;
29
+ const generateMockStripData = (bounds) => {
30
+ const stepMs = Math.max(Math.floor((bounds[1] - bounds[0]) / 240), 100);
31
+ const cpus = Array.from({ length: LOADING_STRIP_COUNT }, (_, i) => ({
32
+ labels: [{ name: 'cpu', value: String(i) }],
33
+ }));
34
+ let seed = 42;
35
+ const data = cpus.map(() => {
36
+ const points = [];
37
+ for (let ts = bounds[0]; ts < bounds[1]; ts += stepMs) {
38
+ seed = (seed * 16807 + 11) % 2147483647;
39
+ const value = (seed % 80) + 10;
40
+ seed = (seed * 16807 + 11) % 2147483647;
41
+ const sampleCount = (seed % 50) + 1;
42
+ points.push({ timestamp: ts, value, sampleCount });
43
+ }
44
+ return points;
45
+ });
46
+ return { cpus, data, stepMs };
47
+ };
43
48
  const getTimelineGuideHeight = (cpusCount, collapsedCount) => {
44
- return (STRIP_HEIGHT + 4) * (cpusCount - collapsedCount) + 20 * collapsedCount + 24 - 6;
49
+ const expandedCount = cpusCount - collapsedCount;
50
+ // Each expanded strip: label row + graph height
51
+ // Each collapsed strip: min-h-5 (20px)
52
+ // Gaps between strips (gap-1 = 4px)
53
+ const expandedTotal = expandedCount * (LABEL_ROW_HEIGHT + STRIP_HEIGHT);
54
+ const collapsedTotal = collapsedCount * 20; // min-h-5
55
+ const gaps = cpusCount * GAP + 20; // timeline header
56
+ return expandedTotal + collapsedTotal + gaps;
45
57
  };
46
58
  const stickyPx = 0;
47
- const SamplesGraphContainer = ({ isSelected, isCollapsed, cpu, width, onToggleCollapse, data, selectionBounds, setSelectionBounds, color, stepMs, onDragStart, dragState, stripIndex, isAnyDragActive, timeBounds, }) => {
48
- const labelStr = labelSetToString(cpu);
59
+ const SamplesGraphContainer = ({ isSelected, isCollapsed, label: labelStr, width, onToggleCollapse, data, selectionBounds, setSelectionBounds, color, stepMs, onDragStart, dragState, stripIndex, isAnyDragActive, timeBounds, loading, }) => {
49
60
  const { isIntersecting, ref } = useIntersectionObserver({
50
61
  rootMargin: `${stickyPx}px 0px 0px 0px`,
51
62
  });
@@ -56,30 +67,38 @@ const SamplesGraphContainer = ({ isSelected, isCollapsed, cpu, width, onToggleCo
56
67
  relative: !isSelected,
57
68
  'sticky z-30 bg-white dark:bg-black bg-opacity-75': isSelected,
58
69
  '!bg-opacity-100': isSticky,
59
- }), style: { width: width ?? 1468, top: isSelected ? stickyPx : undefined }, ref: ref, children: [_jsxs("div", { className: "text-xs absolute top-0 left-0 flex gap-[2px] items-center bg-white/50 dark:bg-black/50 px-1 rounded-sm cursor-pointer", style: {
60
- zIndex: 15,
61
- }, onClick: onToggleCollapse, children: [_jsx(Icon, { icon: isCollapsed ? 'bxs:right-arrow' : 'bxs:down-arrow' }), labelStr] }), !isCollapsed ? (_jsx(SamplesGraph, { data: data, height: STRIP_HEIGHT, width: width ?? 1468, fill: color(labelStr), selectionBounds: selectionBounds, setSelectionBounds: setSelectionBounds, stepMs: stepMs, onDragStart: (startX) => onDragStart(stripIndex, startX), dragState: dragState?.stripIndex === stripIndex ? dragState : undefined, isAnyDragActive: isAnyDragActive, timeBounds: timeBounds })) : null] }, labelStr));
70
+ }), style: { width: width ?? 1468, top: isSelected ? stickyPx : undefined }, ref: ref, children: [_jsx("div", { className: "text-xs flex gap-[2px] items-center px-1 cursor-pointer text-gray-600 dark:text-gray-400", onClick: loading === true ? undefined : onToggleCollapse, children: loading === true ? (_jsx("div", { className: "h-3 w-24 rounded bg-gray-200 dark:bg-gray-700 mb-1" })) : (_jsxs(_Fragment, { children: [_jsx(Icon, { icon: isCollapsed ? 'bxs:right-arrow' : 'bxs:down-arrow', className: "shrink-0" }), labelStr] })) }), !isCollapsed ? (_jsx(SamplesGraph, { data: data, height: STRIP_HEIGHT, width: width ?? 1468, fill: color(labelStr), selectionBounds: selectionBounds, setSelectionBounds: setSelectionBounds, stepMs: stepMs, onDragStart: (startX) => onDragStart(stripIndex, startX), dragState: dragState?.stripIndex === stripIndex ? dragState : undefined, isAnyDragActive: isAnyDragActive, timeBounds: timeBounds })) : null] }, labelStr));
62
71
  };
63
- export const SamplesStrip = ({ cpus, data, selectedTimeframe, onSelectedTimeframe, width, bounds, stepMs, }) => {
72
+ export const SamplesStrip = ({ loading, cpus, data, selectedTimeframe, onSelectedTimeframe, width, bounds, stepMs, }) => {
73
+ const { isDarkMode } = useParcaContext();
74
+ const effectiveLoading = loading === true;
75
+ // When loading, use mock data to render a pixel-perfect skeleton
76
+ const mockData = useMemo(() => (effectiveLoading && bounds[0] !== bounds[1] ? generateMockStripData(bounds) : null), [effectiveLoading, bounds]);
77
+ const effectiveCpus = mockData?.cpus ?? cpus;
78
+ const effectiveData = mockData?.data ?? data;
79
+ const effectiveStepMs = mockData?.stepMs ?? stepMs;
64
80
  const [collapsedLabels, setCollapsedLabels] = useState(new Set());
65
81
  const [showAll, setShowAll] = useState(false);
66
82
  const [dragState, setDragState] = useState(undefined);
67
83
  const containerRef = useRef(null);
68
84
  const isDragging = dragState !== undefined;
69
- // Sort cpus and data by label string for consistent ordering across reloads
85
+ const { compare, keyOrder } = useMemo(() => createLabelSetComparator(effectiveCpus), [effectiveCpus]);
70
86
  const sortedItems = useMemo(() => {
71
- const items = cpus.map((cpu, i) => ({
87
+ const items = effectiveCpus.map((cpu, i) => ({
72
88
  cpu,
73
- data: data[i],
74
- label: labelSetToString(cpu),
89
+ data: effectiveData[i],
90
+ label: labelSetToString(cpu, keyOrder),
75
91
  }));
76
- return items.sort((a, b) => a.label.localeCompare(b.label));
77
- }, [cpus, data]);
92
+ return items.sort((a, b) => compare(a.cpu, b.cpu));
93
+ }, [effectiveCpus, effectiveData, compare, keyOrder]);
78
94
  const hasMore = useMemo(() => sortedItems.length > MAX_VISIBLE_STRIPS, [sortedItems]);
79
95
  const visibleItems = useMemo(() => (showAll || !hasMore ? sortedItems : sortedItems.slice(0, MAX_VISIBLE_STRIPS)), [sortedItems, showAll, hasMore]);
80
96
  // Deterministic color: hash the label string so the same label always gets the same color
81
- // regardless of render order.
97
+ // regardless of render order. When loading, use muted gray.
82
98
  const color = useMemo(() => {
99
+ if (effectiveLoading) {
100
+ return (_label) => (isDarkMode ? '#374151' : '#d1d5db');
101
+ }
83
102
  const palette = d3.schemeObservable10;
84
103
  const hashStr = (s) => {
85
104
  let h = 0;
@@ -89,7 +108,7 @@ export const SamplesStrip = ({ cpus, data, selectedTimeframe, onSelectedTimefram
89
108
  return Math.abs(h);
90
109
  };
91
110
  return (label) => palette[hashStr(label) % palette.length];
92
- }, []);
111
+ }, [effectiveLoading, isDarkMode]);
93
112
  const handleDragStart = (stripIndex, startX) => {
94
113
  setDragState({ stripIndex, startX, currentX: startX });
95
114
  };
@@ -123,13 +142,16 @@ export const SamplesStrip = ({ cpus, data, selectedTimeframe, onSelectedTimefram
123
142
  const handleMouseLeave = () => {
124
143
  setDragState(undefined);
125
144
  };
126
- if (data.length === 0) {
145
+ if (!effectiveLoading && effectiveData.length === 0) {
127
146
  return (_jsx("span", { className: "flex justify-center my-10", children: "There is no data matching your filter criteria, please try changing the filter." }));
128
147
  }
129
- return (_jsxs("div", { ref: containerRef, className: cx('flex flex-col gap-1 relative my-0', { 'cursor-ew-resize': isDragging }), style: { width: width ?? '100%' }, onMouseMove: handleMouseMove, onMouseUp: handleMouseUp, onMouseLeave: handleMouseLeave, children: [_jsx(TimelineGuide, { bounds: [BigInt(0), BigInt(bounds[1] - bounds[0])], width: width ?? 1468, height: getTimelineGuideHeight(visibleItems.length, [...collapsedLabels].filter(l => visibleItems.some(item => item.label === l)).length), margin: 1 }), visibleItems.map((item, i) => {
148
+ return (_jsxs("div", { ref: containerRef, className: cx('flex flex-col gap-1 relative my-0', {
149
+ 'cursor-ew-resize': isDragging,
150
+ 'animate-pulse pointer-events-none': effectiveLoading,
151
+ }), style: { width: width ?? '100%' }, onMouseMove: effectiveLoading ? undefined : handleMouseMove, onMouseUp: effectiveLoading ? undefined : handleMouseUp, onMouseLeave: effectiveLoading ? undefined : handleMouseLeave, children: [_jsx(TimelineGuide, { bounds: [BigInt(0), BigInt(bounds[1] - bounds[0])], width: width ?? 1468, height: getTimelineGuideHeight(visibleItems.length, [...collapsedLabels].filter(l => visibleItems.some(item => item.label === l)).length), margin: 1 }), visibleItems.map((item, i) => {
130
152
  const isCollapsed = collapsedLabels.has(item.label);
131
153
  const isSelected = isEqual(item.cpu, selectedTimeframe?.labels);
132
- return (_jsx(SamplesGraphContainer, { isSelected: isSelected, isCollapsed: isCollapsed, cpu: item.cpu, width: width, data: item.data, onToggleCollapse: () => {
154
+ return (_jsx(SamplesGraphContainer, { isSelected: isSelected, isCollapsed: isCollapsed, label: item.label, width: width, data: item.data, onToggleCollapse: () => {
133
155
  const newCollapsedLabels = new Set(collapsedLabels);
134
156
  if (collapsedLabels.has(item.label)) {
135
157
  newCollapsedLabels.delete(item.label);
@@ -140,6 +162,6 @@ export const SamplesStrip = ({ cpus, data, selectedTimeframe, onSelectedTimefram
140
162
  setCollapsedLabels(newCollapsedLabels);
141
163
  }, selectionBounds: isSelected ? selectedTimeframe?.bounds : undefined, setSelectionBounds: newBounds => {
142
164
  onSelectedTimeframe(item.cpu, newBounds);
143
- }, color: color, stepMs: stepMs, onDragStart: handleDragStart, dragState: dragState, stripIndex: i, isAnyDragActive: isDragging, timeBounds: bounds }, item.label));
165
+ }, color: color, stepMs: effectiveStepMs, onDragStart: handleDragStart, dragState: dragState, stripIndex: i, isAnyDragActive: isDragging, timeBounds: bounds, loading: effectiveLoading }, item.label));
144
166
  }), hasMore && !showAll && (_jsxs(Button, { variant: "secondary", onClick: () => setShowAll(true), className: "w-fit mx-auto mt-2", children: ["Show all ", sortedItems.length, " rows"] }))] }));
145
167
  };
@@ -0,0 +1,7 @@
1
+ import { LabelSet } from '@parca/client';
2
+ export declare const labelSetToString: (labelSet: LabelSet | undefined, keyOrder?: string[]) => string;
3
+ export declare const createLabelSetComparator: (labelSets: LabelSet[]) => {
4
+ compare: (a: LabelSet, b: LabelSet) => number;
5
+ keyOrder: string[];
6
+ };
7
+ //# sourceMappingURL=labelSetUtils.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"labelSetUtils.d.ts","sourceRoot":"","sources":["../../../src/ProfileFlameChart/SamplesStrips/labelSetUtils.ts"],"names":[],"mappings":"AAaA,OAAO,EAAC,QAAQ,EAAC,MAAM,eAAe,CAAC;AA+BvC,eAAO,MAAM,gBAAgB,GAAI,UAAU,QAAQ,GAAG,SAAS,EAAE,WAAW,MAAM,EAAE,KAAG,MAatF,CAAC;AAIF,eAAO,MAAM,wBAAwB,GACnC,WAAW,QAAQ,EAAE,KACpB;IAAC,OAAO,EAAE,CAAC,CAAC,EAAE,QAAQ,EAAE,CAAC,EAAE,QAAQ,KAAK,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,EAAE,CAAA;CAsBpE,CAAC"}
@@ -0,0 +1,79 @@
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
+ // Determine which label keys have all-numeric values across every label set.
14
+ const getNumericKeys = (labelSets) => {
15
+ const numericKeys = new Set();
16
+ if (labelSets.length === 0)
17
+ return numericKeys;
18
+ const keyCandidates = new Set(labelSets[0].labels.map(l => l.name));
19
+ for (const key of keyCandidates) {
20
+ const allNumeric = labelSets.every(ls => {
21
+ const label = ls.labels.find(l => l.name === key);
22
+ return label != null && label.value !== '' && !isNaN(Number(label.value));
23
+ });
24
+ if (allNumeric)
25
+ numericKeys.add(key);
26
+ }
27
+ return numericKeys;
28
+ };
29
+ // Get key order: text keys first (sorted), then numeric keys (sorted).
30
+ const getSortedKeys = (labelSets, numericKeys) => {
31
+ const allKeys = new Set();
32
+ for (const ls of labelSets) {
33
+ for (const l of ls.labels)
34
+ allKeys.add(l.name);
35
+ }
36
+ return [...allKeys]
37
+ .filter(k => !numericKeys.has(k))
38
+ .sort()
39
+ .concat([...numericKeys].sort());
40
+ };
41
+ // Format a LabelSet as a string with keys ordered: text first, then numeric.
42
+ export const labelSetToString = (labelSet, keyOrder) => {
43
+ if (labelSet === undefined)
44
+ return '{}';
45
+ const labels = keyOrder != null
46
+ ? keyOrder
47
+ .map(key => labelSet.labels.find(l => l.name === key))
48
+ .filter((l) => l != null)
49
+ : labelSet.labels;
50
+ if (labels.length === 0)
51
+ return '{}';
52
+ return '{' + labels.map(l => `${l.name}: ${l.value}`).join(', ') + '}';
53
+ };
54
+ // Build a comparator for LabelSets: text keys first (for grouping), then numeric keys.
55
+ // Also returns the key order so labelSetToString can use the same ordering.
56
+ export const createLabelSetComparator = (labelSets) => {
57
+ const numericKeys = getNumericKeys(labelSets);
58
+ const keyOrder = getSortedKeys(labelSets, numericKeys);
59
+ const compare = (a, b) => {
60
+ const aMap = new Map(a.labels.map(l => [l.name, l.value]));
61
+ const bMap = new Map(b.labels.map(l => [l.name, l.value]));
62
+ for (const key of keyOrder) {
63
+ const aVal = aMap.get(key) ?? '';
64
+ const bVal = bMap.get(key) ?? '';
65
+ if (numericKeys.has(key)) {
66
+ const diff = Number(aVal) - Number(bVal);
67
+ if (diff !== 0)
68
+ return diff;
69
+ }
70
+ else {
71
+ const cmp = aVal.localeCompare(bVal);
72
+ if (cmp !== 0)
73
+ return cmp;
74
+ }
75
+ }
76
+ return 0;
77
+ };
78
+ return { compare, keyOrder };
79
+ };
@@ -13,8 +13,7 @@ interface ProfileFlameChartProps {
13
13
  isHalfScreen: boolean;
14
14
  metadataMappingFiles?: string[];
15
15
  metadataLoading?: boolean;
16
- onSwitchToOneMinute?: () => void;
17
16
  }
18
- export declare const ProfileFlameChart: ({ samplesData, queryClient, profileSource, width, total, filtered, profileType, isHalfScreen, metadataMappingFiles, metadataLoading, onSwitchToOneMinute, }: ProfileFlameChartProps) => JSX.Element;
17
+ export declare const ProfileFlameChart: ({ samplesData, queryClient, profileSource, width, total, filtered, profileType, isHalfScreen, metadataMappingFiles, metadataLoading, }: ProfileFlameChartProps) => JSX.Element;
19
18
  export default ProfileFlameChart;
20
19
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/ProfileFlameChart/index.tsx"],"names":[],"mappings":"AAeA,OAAO,EAAoC,kBAAkB,EAAC,MAAM,eAAe,CAAC;AAQpF,OAAO,EAAwB,WAAW,EAAQ,MAAM,eAAe,CAAC;AAKxE,OAAO,EAAsB,aAAa,EAAC,MAAM,kBAAkB,CAAC;AACpE,OAAO,KAAK,EAAC,WAAW,EAAC,MAAM,oCAAoC,CAAC;AA4CpE,UAAU,sBAAsB;IAC9B,WAAW,CAAC,EAAE,WAAW,CAAC;IAC1B,WAAW,EAAE,kBAAkB,CAAC;IAChC,aAAa,EAAE,aAAa,CAAC;IAC7B,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;IACjB,WAAW,CAAC,EAAE,WAAW,CAAC;IAC1B,YAAY,EAAE,OAAO,CAAC;IACtB,oBAAoB,CAAC,EAAE,MAAM,EAAE,CAAC;IAChC,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,mBAAmB,CAAC,EAAE,MAAM,IAAI,CAAC;CAClC;AA+BD,eAAO,MAAM,iBAAiB,GAAI,6JAY/B,sBAAsB,KAAG,GAAG,CAAC,OAqM/B,CAAC;AAEF,eAAe,iBAAiB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/ProfileFlameChart/index.tsx"],"names":[],"mappings":"AAeA,OAAO,EAAoC,kBAAkB,EAAC,MAAM,eAAe,CAAC;AAEpF,OAAO,EAAwB,WAAW,EAAQ,MAAM,eAAe,CAAC;AAKxE,OAAO,EAAsB,aAAa,EAAa,MAAM,kBAAkB,CAAC;AAChF,OAAO,KAAK,EAAC,WAAW,EAAC,MAAM,oCAAoC,CAAC;AA4CpE,UAAU,sBAAsB;IAC9B,WAAW,CAAC,EAAE,WAAW,CAAC;IAC1B,WAAW,EAAE,kBAAkB,CAAC;IAChC,aAAa,EAAE,aAAa,CAAC;IAC7B,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;IACjB,WAAW,CAAC,EAAE,WAAW,CAAC;IAC1B,YAAY,EAAE,OAAO,CAAC;IACtB,oBAAoB,CAAC,EAAE,MAAM,EAAE,CAAC;IAChC,eAAe,CAAC,EAAE,OAAO,CAAC;CAC3B;AA+BD,eAAO,MAAM,iBAAiB,GAAI,wIAW/B,sBAAsB,KAAG,GAAG,CAAC,OAgL/B,CAAC;AAEF,eAAe,iBAAiB,CAAC"}
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  // Copyright 2022 The Parca Authors
3
3
  // Licensed under the Apache License, Version 2.0 (the "License");
4
4
  // you may not use this file except in compliance with the License.
@@ -13,12 +13,12 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
13
13
  // limitations under the License.
14
14
  import { useEffect, useMemo, useRef } from 'react';
15
15
  import { QueryRequest_ReportType } from '@parca/client';
16
- import { Button, useParcaContext, useURLState, useURLStateCustom, } from '@parca/components';
16
+ import { useURLState, useURLStateCustom } from '@parca/components';
17
17
  import { Matcher, MatcherTypes, Query } from '@parca/parser';
18
- import { TimeUnits, formatDateTimeDownToMS, formatDuration } from '@parca/utilities';
18
+ import { TimeUnits, formatDate, formatDuration } from '@parca/utilities';
19
19
  import ProfileFlameGraph, { validateFlameChartQuery } from '../ProfileFlameGraph';
20
20
  import { boundsFromProfileSource } from '../ProfileFlameGraph/FlameGraphArrow/utils';
21
- import { MergedProfileSource } from '../ProfileSource';
21
+ import { MergedProfileSource, timeFormat } from '../ProfileSource';
22
22
  import { useQuery } from '../useQuery';
23
23
  import { SamplesStrip } from './SamplesStrips';
24
24
  const TimeframeStateSerializer = {
@@ -70,8 +70,7 @@ const createFilteredProfileSource = (profileSource, selectedTimeframe) => {
70
70
  const query = new Query(profileSource.query.profType, [...profileSource.query.matchers, ...dimensionMatchers], '');
71
71
  return new MergedProfileSource(mergeFrom, mergeTo, query);
72
72
  };
73
- export const ProfileFlameChart = ({ samplesData, queryClient, profileSource, width, total, filtered, profileType, isHalfScreen, metadataMappingFiles, metadataLoading, onSwitchToOneMinute, }) => {
74
- const { loader } = useParcaContext();
73
+ export const ProfileFlameChart = ({ samplesData, queryClient, profileSource, width, total, filtered, profileType, isHalfScreen, metadataMappingFiles, metadataLoading, }) => {
75
74
  const zoomControlsRef = useRef(null);
76
75
  const [selectedTimeframe, setSelectedTimeframe] = useURLStateCustom('flamechart_timeframe', TimeframeStateSerializer);
77
76
  // Read flamechart dimension from URL state to detect changes
@@ -131,35 +130,29 @@ export const ProfileFlameChart = ({ samplesData, queryClient, profileSource, wid
131
130
  const stepMs = samplesData.stepMs ?? 0;
132
131
  return { cpus, data, stepMs };
133
132
  }, [samplesData?.series, samplesData?.stepMs]);
134
- const { isValid, isNonDelta, isDurationTooLong } = validateFlameChartQuery(profileSource);
133
+ const { isValid, isNonDelta } = validateFlameChartQuery(profileSource);
135
134
  if (!isValid) {
136
- if (isDurationTooLong) {
137
- return (_jsxs("div", { className: "flex flex-col justify-center items-center p-10 text-center gap-4 text-sm", children: [_jsx("span", { children: "Flame chart is unavailable for queries longer than one minute. Try reducing the time range to one minute or selecting a point in the metrics graph." }), onSwitchToOneMinute != null && (_jsx(Button, { variant: "primary", onClick: onSwitchToOneMinute, children: "Switch to last 1 minute" }))] }));
138
- }
139
135
  const message = isNonDelta
140
136
  ? 'To use the Flame chart, please switch to a Delta profile.'
141
137
  : 'Flame chart is unavailable for this query.';
142
138
  return (_jsx("div", { className: "flex flex-col justify-center p-10 text-center gap-6 text-sm", children: message }));
143
139
  }
144
140
  const hasDimension = (flamechartDimension ?? []).length > 0;
145
- // Show loader while metadata labels are loading (needed for dimension auto-selection)
146
- if (metadataLoading === true) {
147
- return _jsx(_Fragment, { children: loader });
148
- }
149
- if (!hasDimension) {
141
+ const isStripsLoading = metadataLoading === true || !hasDimension || samplesData?.loading === true;
142
+ if (!hasDimension && metadataLoading !== true) {
150
143
  return (_jsx("div", { className: "flex justify-center items-center py-10 text-gray-500 dark:text-gray-400 text-sm", children: "Select a label in the \"Samples group by\" dropdown above to view the samples strips." }));
151
144
  }
152
- if (samplesData?.loading === true) {
153
- return _jsx(_Fragment, { children: loader });
154
- }
155
- return (_jsxs("div", { children: [stripsData.cpus.length > 0 && stripsData.data.length > 0 && (_jsx("div", { className: "mb-2", children: _jsx(SamplesStrip, { cpus: stripsData.cpus, data: stripsData.data, selectedTimeframe: selectedTimeframe, onSelectedTimeframe: handleSelectedTimeframe, width: width, bounds: [Number(timeBounds[0] / 1000000n), Number(timeBounds[1] / 1000000n)], stepMs: stripsData.stepMs }) })), selectedTimeframe != null &&
145
+ return (_jsxs("div", { children: [(isStripsLoading || (stripsData.cpus.length > 0 && stripsData.data.length > 0)) && (_jsx("div", { className: "mb-2", children: _jsx(SamplesStrip, { loading: isStripsLoading, cpus: stripsData.cpus, data: stripsData.data, selectedTimeframe: selectedTimeframe, onSelectedTimeframe: handleSelectedTimeframe, width: width, bounds: [Number(timeBounds[0] / 1000000n), Number(timeBounds[1] / 1000000n)], stepMs: stripsData.stepMs }) })), selectedTimeframe != null &&
156
146
  (() => {
157
147
  const labels = selectedTimeframe.labels.labels
158
148
  .map(l => `${l.name} = ${l.value}`)
159
149
  .join(', ');
160
150
  const durationMs = selectedTimeframe.bounds[1] - selectedTimeframe.bounds[0];
161
- const duration = formatDuration({ [TimeUnits.Milliseconds]: durationMs });
162
- return (_jsxs("div", { className: "flex items-center justify-between px-2 py-1", children: [_jsxs("div", { className: "text-xs font-medium text-gray-500 dark:text-gray-400", children: ["Samples matching ", labels, " over ", duration, " from", ' ', formatDateTimeDownToMS(selectedTimeframe.bounds[0]), " to", ' ', formatDateTimeDownToMS(selectedTimeframe.bounds[1])] }), _jsx("div", { ref: zoomControlsRef })] }));
151
+ const duration = durationMs < 5000
152
+ ? `${(durationMs / 1000).toFixed(1)}s`
153
+ : formatDuration({ [TimeUnits.Milliseconds]: durationMs });
154
+ const fmt = durationMs < 5000 ? "yyyy-MM-dd HH:mm:ss.SSS '(UTC)'" : timeFormat();
155
+ return (_jsxs("div", { className: "flex items-center justify-between px-2 py-1", children: [_jsxs("div", { className: "text-xs font-medium text-gray-500 dark:text-gray-400", children: ["Samples matching ", labels, " over ", duration, " from", ' ', formatDate(new Date(selectedTimeframe.bounds[0]), fmt), " to", ' ', formatDate(new Date(selectedTimeframe.bounds[1]), fmt)] }), _jsx("div", { ref: zoomControlsRef })] }));
163
156
  })(), selectedTimeframe != null && filteredProfileSource != null ? (_jsx(ProfileFlameGraph, { arrow: flamechartArrow, loading: flamechartLoading, error: flamechartError, profileSource: filteredProfileSource, width: width, total: flamechartTotal, filtered: flamechartFiltered, profileType: profileType, isHalfScreen: isHalfScreen, metadataMappingFiles: metadataMappingFiles, metadataLoading: metadataLoading, isFlameChart: true, curPathArrow: [], setNewCurPathArrow: () => { }, zoomControlsRef: zoomControlsRef })) : (_jsx("div", { className: "flex justify-center items-center py-10 text-gray-500 dark:text-gray-400 text-sm", children: "Select a time range in the samples strips above to view the flamechart." }))] }));
164
157
  };
165
158
  export default ProfileFlameChart;
@@ -14,6 +14,9 @@ interface MiniMapProps {
14
14
  profileSource: ProfileSource;
15
15
  isDarkMode: boolean;
16
16
  scrollLeft: number;
17
+ scrollLeftRef: React.RefObject<number>;
18
+ onZoomToPosition?: (normalizedX: number, targetZoom: number) => void;
19
+ onSetZoomWithScroll?: (zoom: number, scrollLeft: number) => void;
17
20
  }
18
21
  export declare const MiniMap: React.NamedExoticComponent<MiniMapProps>;
19
22
  export {};
@@ -1 +1 @@
1
- {"version":3,"file":"MiniMap.d.ts","sourceRoot":"","sources":["../../../src/ProfileFlameGraph/FlameGraphArrow/MiniMap.tsx"],"names":[],"mappings":"AAaA,OAAO,KAAuC,MAAM,OAAO,CAAC;AAE5D,OAAO,EAAC,KAAK,EAAC,MAAM,mBAAmB,CAAC;AAKxC,OAAO,EAAC,aAAa,EAAC,MAAM,qBAAqB,CAAC;AAClD,OAAO,EAAY,KAAK,aAAa,EAAC,MAAM,mBAAmB,CAAC;AAYhE,UAAU,YAAY;IACpB,YAAY,EAAE,KAAK,CAAC,SAAS,CAAC,cAAc,GAAG,IAAI,CAAC,CAAC;IACrD,KAAK,EAAE,KAAK,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,MAAM,CAAC;IACjB,aAAa,EAAE,aAAa,CAAC;IAC7B,OAAO,EAAE,MAAM,CAAC;IAChB,aAAa,EAAE,aAAa,CAAC;IAC7B,UAAU,EAAE,OAAO,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,eAAO,MAAM,OAAO,0CA8NlB,CAAC"}
1
+ {"version":3,"file":"MiniMap.d.ts","sourceRoot":"","sources":["../../../src/ProfileFlameGraph/FlameGraphArrow/MiniMap.tsx"],"names":[],"mappings":"AAaA,OAAO,KAAuC,MAAM,OAAO,CAAC;AAE5D,OAAO,EAAC,KAAK,EAAC,MAAM,mBAAmB,CAAC;AAKxC,OAAO,EAAC,aAAa,EAAC,MAAM,qBAAqB,CAAC;AAClD,OAAO,EAAY,KAAK,aAAa,EAAC,MAAM,mBAAmB,CAAC;AAYhE,UAAU,YAAY;IACpB,YAAY,EAAE,KAAK,CAAC,SAAS,CAAC,cAAc,GAAG,IAAI,CAAC,CAAC;IACrD,KAAK,EAAE,KAAK,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,MAAM,CAAC;IACjB,aAAa,EAAE,aAAa,CAAC;IAC7B,OAAO,EAAE,MAAM,CAAC;IAChB,aAAa,EAAE,aAAa,CAAC;IAC7B,UAAU,EAAE,OAAO,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC;IACnB,aAAa,EAAE,KAAK,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC;IACvC,gBAAgB,CAAC,EAAE,CAAC,WAAW,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,KAAK,IAAI,CAAC;IACrE,mBAAmB,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,KAAK,IAAI,CAAC;CAClE;AAED,eAAO,MAAM,OAAO,0CAyTlB,CAAC"}
@@ -18,7 +18,7 @@ import { RowHeight } from './FlameGraphNodes';
18
18
  import { FIELD_CUMULATIVE, FIELD_DEPTH, FIELD_FUNCTION_FILE_NAME, FIELD_MAPPING_FILE, FIELD_TIMESTAMP, } from './index';
19
19
  import { arrowToString, boundsFromProfileSource } from './utils';
20
20
  const MINIMAP_HEIGHT = 20;
21
- export const MiniMap = React.memo(function MiniMap({ containerRef, table, width, zoomedWidth, totalHeight, maxDepth, colorByColors: colors, colorBy, profileSource, isDarkMode, scrollLeft, }) {
21
+ export const MiniMap = React.memo(function MiniMap({ containerRef, table, width, zoomedWidth, totalHeight, maxDepth, colorByColors: colors, colorBy, profileSource, isDarkMode, scrollLeft: _scrollLeft, scrollLeftRef, onZoomToPosition, onSetZoomWithScroll, }) {
22
22
  const canvasRef = useRef(null);
23
23
  const containerElRef = useRef(null);
24
24
  const isDragging = useRef(false);
@@ -40,7 +40,6 @@ export const MiniMap = React.memo(function MiniMap({ containerRef, table, width,
40
40
  // Background
41
41
  ctx.fillStyle = isDarkMode ? '#374151' : '#f3f4f6';
42
42
  ctx.fillRect(0, 0, width, MINIMAP_HEIGHT);
43
- const xScale = width / zoomedWidth;
44
43
  const yScale = MINIMAP_HEIGHT / totalHeight;
45
44
  const tsBounds = boundsFromProfileSource(profileSource);
46
45
  const tsRange = Number(tsBounds[1]) - Number(tsBounds[0]);
@@ -63,11 +62,11 @@ export const MiniMap = React.memo(function MiniMap({ containerRef, table, width,
63
62
  const cumulative = Number(cumulativeCol.get(row) ?? 0n);
64
63
  if (cumulative <= 0)
65
64
  continue;
66
- const nodeWidth = (cumulative / tsRange) * zoomedWidth * xScale;
65
+ const nodeWidth = (cumulative / tsRange) * width;
67
66
  if (nodeWidth < 0.5)
68
67
  continue;
69
68
  const ts = tsCol != null ? Number(tsCol.get(row)) : 0;
70
- const x = ((ts - Number(tsBounds[0])) / tsRange) * zoomedWidth * xScale;
69
+ const x = ((ts - Number(tsBounds[0])) / tsRange) * width;
71
70
  const y = (depth - 1) * RowHeight * yScale;
72
71
  const h = Math.max(1, RowHeight * yScale);
73
72
  // Get color using same logic as useNodeColor
@@ -80,32 +79,89 @@ export const MiniMap = React.memo(function MiniMap({ containerRef, table, width,
80
79
  ctx.fillStyle = color ?? (isDarkMode ? '#6b7280' : '#9ca3af');
81
80
  ctx.fillRect(x, y, Math.max(0.5, nodeWidth), h);
82
81
  }
83
- }, [
84
- table,
85
- width,
86
- zoomedWidth,
87
- totalHeight,
88
- maxDepth,
89
- colorBy,
90
- colors,
91
- isDarkMode,
92
- profileSource,
93
- ]);
82
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- zoomedWidth intentionally excluded: canvas is zoom-independent
83
+ }, [table, width, totalHeight, maxDepth, colorBy, colors, isDarkMode, profileSource]);
94
84
  const isZoomed = zoomedWidth > width;
95
85
  const sliderWidth = Math.max(20, (width / zoomedWidth) * width);
96
- const sliderLeft = Math.min((scrollLeft / zoomedWidth) * width, width - sliderWidth);
86
+ // Use scrollLeftRef for positioning it's pre-set before flushSync during zoom changes,
87
+ // avoiding the 1-frame lag where viewport.scrollLeft is stale but zoomedWidth is already updated.
88
+ const currentScrollLeft = scrollLeftRef.current ?? 0;
89
+ const sliderLeft = Math.min((currentScrollLeft / zoomedWidth) * width, width - sliderWidth);
90
+ const EDGE_HIT_ZONE = 6;
97
91
  const handleMouseDown = useCallback((e) => {
98
92
  e.preventDefault();
99
93
  const rect = containerElRef.current?.getBoundingClientRect();
100
94
  if (rect == null)
101
95
  return;
102
96
  const clickX = e.clientX - rect.left;
103
- // Check if clicking inside the slider
104
- if (clickX >= sliderLeft && clickX <= sliderLeft + sliderWidth) {
105
- // Start dragging
97
+ // When not zoomed, clicking the minimap zooms into a +-50px region
98
+ if (!isZoomed) {
99
+ const regionPx = 100; // 50px on each side of the click
100
+ const targetZoom = width / regionPx;
101
+ onZoomToPosition?.(clickX / width, targetZoom);
102
+ return;
103
+ }
104
+ const sliderRight = sliderLeft + sliderWidth;
105
+ const isNearLeftEdge = Math.abs(clickX - sliderLeft) <= EDGE_HIT_ZONE && clickX <= sliderLeft + EDGE_HIT_ZONE;
106
+ const isNearRightEdge = Math.abs(clickX - sliderRight) <= EDGE_HIT_ZONE && clickX >= sliderRight - EDGE_HIT_ZONE;
107
+ // Edge drag: resize the zoomed region by dragging one bound
108
+ if (isNearLeftEdge || isNearRightEdge) {
109
+ const edge = isNearLeftEdge ? 'left' : 'right';
110
+ // The opposite edge stays fixed in minimap coordinates
111
+ const anchorPx = edge === 'left' ? sliderRight : sliderLeft;
112
+ const MIN_SLIDER_PX = 10;
113
+ let edgeRafId = null;
114
+ let pendingEdgeEvent = null;
115
+ const applyEdgeMove = () => {
116
+ edgeRafId = null;
117
+ const moveEvent = pendingEdgeEvent;
118
+ if (moveEvent == null)
119
+ return;
120
+ pendingEdgeEvent = null;
121
+ const moveRect = containerElRef.current?.getBoundingClientRect();
122
+ if (moveRect == null)
123
+ return;
124
+ let edgePx = moveEvent.clientX - moveRect.left;
125
+ edgePx = Math.max(0, Math.min(edgePx, width));
126
+ let newLeft;
127
+ let newRight;
128
+ if (edge === 'left') {
129
+ newLeft = Math.min(edgePx, anchorPx - MIN_SLIDER_PX);
130
+ newRight = anchorPx;
131
+ }
132
+ else {
133
+ newLeft = anchorPx;
134
+ newRight = Math.max(edgePx, anchorPx + MIN_SLIDER_PX);
135
+ }
136
+ const newSliderWidth = newRight - newLeft;
137
+ const newZoom = width / newSliderWidth;
138
+ const newScrollLeft = newLeft * newZoom;
139
+ onSetZoomWithScroll?.(newZoom, newScrollLeft);
140
+ };
141
+ const handleEdgeMove = (moveEvent) => {
142
+ pendingEdgeEvent = moveEvent;
143
+ if (edgeRafId === null) {
144
+ edgeRafId = requestAnimationFrame(applyEdgeMove);
145
+ }
146
+ };
147
+ const handleEdgeUp = () => {
148
+ if (edgeRafId !== null) {
149
+ cancelAnimationFrame(edgeRafId);
150
+ // Apply final position immediately on mouse up
151
+ applyEdgeMove();
152
+ }
153
+ document.removeEventListener('mousemove', handleEdgeMove);
154
+ document.removeEventListener('mouseup', handleEdgeUp);
155
+ };
156
+ document.addEventListener('mousemove', handleEdgeMove);
157
+ document.addEventListener('mouseup', handleEdgeUp);
158
+ return;
159
+ }
160
+ // Check if clicking inside the slider — start pan drag
161
+ if (clickX >= sliderLeft && clickX <= sliderRight) {
106
162
  isDragging.current = true;
107
163
  dragStartX.current = e.clientX;
108
- dragStartScrollLeft.current = scrollLeft;
164
+ dragStartScrollLeft.current = currentScrollLeft;
109
165
  }
110
166
  else {
111
167
  // Click-to-jump: center viewport at click position
@@ -137,7 +193,17 @@ export const MiniMap = React.memo(function MiniMap({ containerRef, table, width,
137
193
  };
138
194
  document.addEventListener('mousemove', handleMouseMove);
139
195
  document.addEventListener('mouseup', handleMouseUp);
140
- }, [sliderLeft, sliderWidth, scrollLeft, width, zoomedWidth, containerRef]);
196
+ }, [
197
+ sliderLeft,
198
+ sliderWidth,
199
+ currentScrollLeft,
200
+ width,
201
+ zoomedWidth,
202
+ containerRef,
203
+ isZoomed,
204
+ onZoomToPosition,
205
+ onSetZoomWithScroll,
206
+ ]);
141
207
  // Forward wheel events to the container so zoom (Ctrl+scroll) works on the minimap
142
208
  useEffect(() => {
143
209
  const el = containerElRef.current;
@@ -164,10 +230,9 @@ export const MiniMap = React.memo(function MiniMap({ containerRef, table, width,
164
230
  }, [containerRef]);
165
231
  if (width <= 0)
166
232
  return null;
167
- return (_jsxs("div", { ref: containerElRef, className: "relative select-none", style: { width, height: MINIMAP_HEIGHT, cursor: isZoomed ? 'pointer' : 'default' }, onMouseDown: isZoomed ? handleMouseDown : undefined, children: [_jsx("canvas", { ref: canvasRef, style: {
233
+ return (_jsxs("div", { ref: containerElRef, className: "relative select-none cursor-pointer", style: { width, height: MINIMAP_HEIGHT }, onMouseDown: handleMouseDown, children: [_jsx("canvas", { ref: canvasRef, style: {
168
234
  width,
169
235
  height: MINIMAP_HEIGHT,
170
236
  display: 'block',
171
- visibility: isZoomed ? 'visible' : 'hidden',
172
- } }), isZoomed && (_jsxs(_Fragment, { children: [_jsx("div", { className: "absolute top-0 bottom-0 bg-black/30 dark:bg-black/50", style: { left: 0, width: Math.max(0, sliderLeft) } }), _jsx("div", { className: "absolute top-0 bottom-0 border-x-2 border-gray-500", style: { left: sliderLeft, width: sliderWidth } }), _jsx("div", { className: "absolute top-0 bottom-0 bg-black/30 dark:bg-black/50", style: { left: sliderLeft + sliderWidth, right: 0 } })] }))] }));
237
+ } }), isZoomed && (_jsxs(_Fragment, { children: [_jsx("div", { className: "absolute top-0 bottom-0 bg-black/30 dark:bg-black/50", style: { left: 0, width: Math.max(0, sliderLeft) } }), _jsx("div", { className: "absolute top-0 bottom-0 border-x-2 border-gray-500", style: { left: sliderLeft, width: sliderWidth } }), _jsx("div", { className: "absolute top-0 bottom-0 cursor-col-resize", style: { left: sliderLeft - EDGE_HIT_ZONE, width: EDGE_HIT_ZONE * 2 } }), _jsx("div", { className: "absolute top-0 bottom-0 cursor-col-resize", style: { left: sliderLeft + sliderWidth - EDGE_HIT_ZONE, width: EDGE_HIT_ZONE * 2 } }), _jsx("div", { className: "absolute top-0 bottom-0 bg-black/30 dark:bg-black/50", style: { left: sliderLeft + sliderWidth, right: 0 } })] }))] }));
173
238
  });
@@ -1 +1 @@
1
- {"version":3,"file":"ZoomControls.d.ts","sourceRoot":"","sources":["../../../src/ProfileFlameGraph/FlameGraphArrow/ZoomControls.tsx"],"names":[],"mappings":"AAaA,OAAO,KAAK,MAAM,OAAO,CAAC;AAK1B,UAAU,iBAAiB;IACzB,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,IAAI,CAAC;IACnB,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB,SAAS,EAAE,MAAM,IAAI,CAAC;IACtB,SAAS,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC,cAAc,GAAG,IAAI,CAAC,CAAC;CACpD;AAED,eAAO,MAAM,YAAY,GAAI,uDAM1B,iBAAiB,KAAG,KAAK,CAAC,GAAG,CAAC,OAkChC,CAAC"}
1
+ {"version":3,"file":"ZoomControls.d.ts","sourceRoot":"","sources":["../../../src/ProfileFlameGraph/FlameGraphArrow/ZoomControls.tsx"],"names":[],"mappings":"AAaA,OAAO,KAAK,MAAM,OAAO,CAAC;AAO1B,UAAU,iBAAiB;IACzB,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,IAAI,CAAC;IACnB,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB,SAAS,EAAE,MAAM,IAAI,CAAC;IACtB,SAAS,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC,cAAc,GAAG,IAAI,CAAC,CAAC;CACpD;AAED,eAAO,MAAM,YAAY,GAAI,uDAM1B,iBAAiB,KAAG,KAAK,CAAC,GAAG,CAAC,OAkChC,CAAC"}