@kylincloud/flamegraph 0.35.23 → 0.35.24

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 (31) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/FlameGraph/FlameGraphComponent/FlamegraphBreadcrumb.d.ts +16 -0
  3. package/dist/FlameGraph/FlameGraphComponent/FlamegraphBreadcrumb.d.ts.map +1 -0
  4. package/dist/FlameGraph/FlameGraphComponent/Flamegraph_render.d.ts +1 -0
  5. package/dist/FlameGraph/FlameGraphComponent/Flamegraph_render.d.ts.map +1 -1
  6. package/dist/FlameGraph/FlameGraphComponent/index.d.ts +13 -0
  7. package/dist/FlameGraph/FlameGraphComponent/index.d.ts.map +1 -1
  8. package/dist/FlameGraph/FlameGraphRenderer.d.ts +9 -1
  9. package/dist/FlameGraph/FlameGraphRenderer.d.ts.map +1 -1
  10. package/dist/Tooltip/Tooltip.d.ts.map +1 -1
  11. package/dist/format/format.d.ts +14 -0
  12. package/dist/format/format.d.ts.map +1 -1
  13. package/dist/i18n.d.ts +7 -0
  14. package/dist/i18n.d.ts.map +1 -1
  15. package/dist/index.cjs.js +3 -3
  16. package/dist/index.cjs.js.map +1 -1
  17. package/dist/index.esm.js +4 -4
  18. package/dist/index.esm.js.map +1 -1
  19. package/dist/index.node.cjs.js +4 -4
  20. package/dist/index.node.cjs.js.map +1 -1
  21. package/dist/index.node.esm.js +4 -4
  22. package/dist/index.node.esm.js.map +1 -1
  23. package/package.json +1 -1
  24. package/src/FlameGraph/FlameGraphComponent/FlamegraphBreadcrumb.module.scss +106 -0
  25. package/src/FlameGraph/FlameGraphComponent/FlamegraphBreadcrumb.tsx +98 -0
  26. package/src/FlameGraph/FlameGraphComponent/Flamegraph_render.ts +23 -21
  27. package/src/FlameGraph/FlameGraphComponent/index.tsx +33 -1
  28. package/src/FlameGraph/FlameGraphRenderer.tsx +144 -3
  29. package/src/Tooltip/Tooltip.tsx +1 -75
  30. package/src/format/format.ts +98 -0
  31. package/src/i18n.tsx +27 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kylincloud/flamegraph",
3
- "version": "0.35.23",
3
+ "version": "0.35.24",
4
4
  "description": "KylinCloud flamegraph renderer (Pyroscope-based)",
5
5
  "license": "Apache-2.0",
6
6
  "main": "dist/index.node.cjs.js",
@@ -0,0 +1,106 @@
1
+ .bar {
2
+ display: flex;
3
+ align-items: center;
4
+ justify-content: center;
5
+ width: 100%;
6
+ flex-wrap: wrap;
7
+ gap: 8px;
8
+ margin: 6px 0;
9
+ }
10
+
11
+ .chip {
12
+ display: inline-flex;
13
+ align-items: center;
14
+ gap: 6px;
15
+ padding: 4px 10px;
16
+ border-radius: 999px;
17
+ font-size: 12px;
18
+ line-height: 1.2;
19
+ background-color: var(--ps-ui-element-bg-primary);
20
+ border: 1px solid var(--ps-ui-border);
21
+ color: var(--ps-ui-foreground-text);
22
+ }
23
+
24
+ .focusTooltip {
25
+ position: relative;
26
+ }
27
+
28
+ .focusTooltip[data-tooltip]:hover::after,
29
+ .focusTooltip[data-tooltip]:hover::before {
30
+ opacity: 1;
31
+ transform: translate(-50%, -6px);
32
+ }
33
+
34
+ .focusTooltip[data-tooltip]::after {
35
+ content: attr(data-tooltip);
36
+ position: absolute;
37
+ left: 50%;
38
+ bottom: 100%;
39
+ transform: translate(-50%, -2px);
40
+ padding: 6px 8px;
41
+ border-radius: 6px;
42
+ background: var(--ps-ui-foreground);
43
+ color: var(--ps-ui-foreground-text);
44
+ border: 1px solid var(--ps-ui-border);
45
+ font-size: 12px;
46
+ white-space: nowrap;
47
+ pointer-events: none;
48
+ opacity: 0;
49
+ transition: opacity 120ms ease, transform 120ms ease;
50
+ z-index: 3;
51
+ }
52
+
53
+ .focusTooltip[data-tooltip]::before {
54
+ content: '';
55
+ position: absolute;
56
+ left: 50%;
57
+ bottom: 100%;
58
+ transform: translate(-50%, -2px);
59
+ border-width: 6px 6px 0 6px;
60
+ border-style: solid;
61
+ border-color: var(--ps-ui-border) transparent transparent transparent;
62
+ pointer-events: none;
63
+ opacity: 0;
64
+ transition: opacity 120ms ease, transform 120ms ease;
65
+ z-index: 2;
66
+ }
67
+
68
+ .separator {
69
+ opacity: 0.6;
70
+ }
71
+
72
+ .chipSeparator {
73
+ opacity: 0.6;
74
+ color: var(--ps-ui-foreground-text);
75
+ }
76
+
77
+ .unitLabel {
78
+ opacity: 0.8;
79
+ }
80
+
81
+ .focusIcon {
82
+ font-size: 12px;
83
+ display: inline-flex;
84
+ align-items: center;
85
+ color: currentColor;
86
+ }
87
+
88
+ .ofTotal {
89
+ opacity: 0.8;
90
+ }
91
+
92
+ .clearBtn {
93
+ margin-left: 4px;
94
+ padding: 0;
95
+ border: none;
96
+ background: transparent;
97
+ color: var(--ps-toolbar-icon-color);
98
+ cursor: pointer;
99
+ font-size: 14px;
100
+ line-height: 1;
101
+ }
102
+
103
+ .clearBtn:focus-visible {
104
+ outline: 1px solid var(--ps-ui-border);
105
+ border-radius: 4px;
106
+ }
@@ -0,0 +1,98 @@
1
+ import React from 'react';
2
+ import styles from './FlamegraphBreadcrumb.module.scss';
3
+
4
+ type FlamegraphBreadcrumbProps = {
5
+ totalValueText: string;
6
+ totalSamplesText: string;
7
+ samplesLabel: string;
8
+ unitLabel: string;
9
+ hasFocus: boolean;
10
+ focusPercent?: number;
11
+ focusLabel?: string;
12
+ ofTotalPlacement?: 'before' | 'after';
13
+ ofTotalLabel: string;
14
+ clearFocusLabel: string;
15
+ onClearFocus: () => void;
16
+ };
17
+
18
+ export default function FlamegraphBreadcrumb(
19
+ props: FlamegraphBreadcrumbProps
20
+ ) {
21
+ const {
22
+ totalValueText,
23
+ totalSamplesText,
24
+ samplesLabel,
25
+ unitLabel,
26
+ hasFocus,
27
+ focusPercent,
28
+ focusLabel,
29
+ ofTotalPlacement = 'after',
30
+ ofTotalLabel,
31
+ clearFocusLabel,
32
+ onClearFocus,
33
+ } = props;
34
+
35
+ const focusTooltip =
36
+ focusLabel && focusLabel.trim().length > 0 ? focusLabel : undefined;
37
+
38
+ return (
39
+ <div className={styles.bar} aria-live="polite">
40
+ <span className={styles.chip}>
41
+ <span>{totalValueText}</span>
42
+ <span className={styles.separator}>|</span>
43
+ <span>
44
+ {totalSamplesText} {samplesLabel}
45
+ </span>
46
+ <span className={styles.unitLabel}>({unitLabel})</span>
47
+ </span>
48
+ {hasFocus && focusPercent !== undefined && (
49
+ <>
50
+ <span className={styles.chipSeparator}>&gt;</span>
51
+ <span
52
+ className={`${styles.chip} ${styles.focusTooltip}`}
53
+ data-tooltip={focusTooltip}
54
+ >
55
+ <span className={styles.focusIcon} aria-hidden="true">
56
+ <svg
57
+ viewBox="0 0 24 24"
58
+ width="14"
59
+ height="14"
60
+ fill="none"
61
+ stroke="currentColor"
62
+ strokeWidth="1.6"
63
+ strokeLinecap="round"
64
+ strokeLinejoin="round"
65
+ >
66
+ <path d="M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6-10-6-10-6Z" />
67
+ <circle cx="12" cy="12" r="3.2" />
68
+ </svg>
69
+ </span>
70
+ {ofTotalPlacement === 'before' ? (
71
+ <>
72
+ <span className={styles.ofTotal}>{ofTotalLabel}</span>
73
+ <span>{focusPercent.toFixed(2)}%</span>
74
+ </>
75
+ ) : (
76
+ <>
77
+ <span>{focusPercent.toFixed(2)}%</span>
78
+ <span className={styles.ofTotal}>{ofTotalLabel}</span>
79
+ </>
80
+ )}
81
+ <button
82
+ type="button"
83
+ className={styles.clearBtn}
84
+ aria-label={clearFocusLabel}
85
+ onClick={(event) => {
86
+ event.preventDefault();
87
+ event.stopPropagation();
88
+ onClearFocus();
89
+ }}
90
+ >
91
+ ×
92
+ </button>
93
+ </span>
94
+ </>
95
+ )}
96
+ </div>
97
+ );
98
+ }
@@ -24,9 +24,9 @@ THIS SOFTWARE.
24
24
  /* eslint-disable no-continue */
25
25
  import { createFF, Flamebearer, SpyName } from '../../models';
26
26
  import {
27
- formatPercent,
28
27
  getFormatter,
29
28
  ratioToPercent,
29
+ localizeDurationString,
30
30
  } from '../../format/format';
31
31
  import { fitToCanvasRect } from '../../fitMode/fitMode';
32
32
  import { getRatios } from './utils';
@@ -53,12 +53,14 @@ import Flamegraph from './Flamegraph';
53
53
  export interface CanvasI18nMessages {
54
54
  collapsedLevelsSingular: string;
55
55
  collapsedLevelsPlural: string;
56
+ isZh?: boolean;
56
57
  }
57
58
 
58
59
  /** Default English messages (fallback) */
59
60
  const defaultCanvasMessages: CanvasI18nMessages = {
60
61
  collapsedLevelsSingular: 'total (1 level collapsed)',
61
62
  collapsedLevelsPlural: 'total ({n} levels collapsed)',
63
+ isZh: false,
62
64
  };
63
65
 
64
66
  type CanvasRendererConfig = Flamebearer & {
@@ -230,16 +232,16 @@ export default function RenderCanvas(props: CanvasRendererConfig) {
230
232
  j < level.length - ff.jStep &&
231
233
  barIndex + numBarTicks === ff.getBarOffset(level, j + ff.jStep) &&
232
234
  ff.getBarTotal(level, j + ff.jStep) * pxPerTick <=
233
- COLLAPSE_THRESHOLD &&
235
+ COLLAPSE_THRESHOLD &&
234
236
  isHighlighted ===
235
- ((props.highlightQuery &&
236
- nodeIsInQuery(
237
- j + ff.jStep + ff.jName,
238
- level,
239
- names,
240
- props.highlightQuery
241
- )) ||
242
- false)
237
+ ((props.highlightQuery &&
238
+ nodeIsInQuery(
239
+ j + ff.jStep + ff.jName,
240
+ level,
241
+ names,
242
+ props.highlightQuery
243
+ )) ||
244
+ false)
243
245
  ) {
244
246
  j += ff.jStep;
245
247
  numBarTicks += ff.getBarTotal(level, j);
@@ -315,9 +317,9 @@ export default function RenderCanvas(props: CanvasRendererConfig) {
315
317
  const longName = getLongName(
316
318
  shortName,
317
319
  numBarTicks,
318
- numTicks,
319
320
  sampleRate,
320
- formatter
321
+ formatter,
322
+ messages
321
323
  );
322
324
 
323
325
  // Set the font syle
@@ -372,20 +374,20 @@ function getFunctionName(
372
374
  return shortName;
373
375
  }
374
376
 
377
+ /**
378
+ * 生成条形文本的长名称
379
+ * 格式: functionName (value) - 对齐 Grafana 风格,不显示百分比
380
+ */
375
381
  function getLongName(
376
382
  shortName: string,
377
383
  numBarTicks: number,
378
- numTicks: number,
379
384
  sampleRate: number,
380
- formatter: ReturnType<typeof getFormatter>
385
+ formatter: ReturnType<typeof getFormatter>,
386
+ messages: CanvasI18nMessages
381
387
  ) {
382
- const ratio = numBarTicks / numTicks;
383
- const percent = formatPercent(ratio);
384
-
385
- const longName = `${shortName} (${percent}, ${formatter.format(
386
- numBarTicks,
387
- sampleRate
388
- )})`;
388
+ const formatted = formatter.format(numBarTicks, sampleRate);
389
+ const localized = localizeDurationString(formatted, !!messages.isZh);
390
+ const longName = `${shortName} (${localized || formatted})`;
389
391
 
390
392
  return longName;
391
393
  }
@@ -23,6 +23,7 @@ import { SandwichIcon, HeadFirstIcon, TailFirstIcon } from '../../Icons';
23
23
  import { PX_PER_LEVEL } from './constants';
24
24
  import Header from './Header';
25
25
  import { FlamegraphPalette } from './colorPalette';
26
+ import FlamegraphBreadcrumb from './FlamegraphBreadcrumb';
26
27
  import type { ViewTypes } from './viewTypes';
27
28
  import { FitModes, HeadMode, TailMode } from '../../fitMode/fitMode';
28
29
  import indexStyles from './styles.module.scss';
@@ -56,6 +57,20 @@ interface FlamegraphProps {
56
57
 
57
58
  /** 是否显示右键菜单中的「打开 Sandwich 视图」项,默认 true */
58
59
  enableSandwichView?: boolean;
60
+
61
+ breadcrumb?: {
62
+ totalValueText: string;
63
+ totalSamplesText: string;
64
+ samplesLabel: string;
65
+ unitLabel: string;
66
+ hasFocus: boolean;
67
+ focusPercent?: number;
68
+ focusLabel?: string;
69
+ ofTotalPlacement?: 'before' | 'after';
70
+ ofTotalLabel: string;
71
+ clearFocusLabel: string;
72
+ onClearFocus: () => void;
73
+ };
59
74
  }
60
75
 
61
76
  export default function FlameGraphComponent(props: FlamegraphProps) {
@@ -68,8 +83,9 @@ export default function FlameGraphComponent(props: FlamegraphProps) {
68
83
  () => ({
69
84
  collapsedLevelsSingular: i18n.collapsedLevelsSingular,
70
85
  collapsedLevelsPlural: i18n.collapsedLevelsPlural,
86
+ isZh: i18n.location !== 'Location',
71
87
  }),
72
- [i18n.collapsedLevelsSingular, i18n.collapsedLevelsPlural]
88
+ [i18n.collapsedLevelsSingular, i18n.collapsedLevelsPlural, i18n.location]
73
89
  );
74
90
 
75
91
  const [rightClickedNode, setRightClickedNode] = React.useState<
@@ -92,6 +108,7 @@ export default function FlameGraphComponent(props: FlamegraphProps) {
92
108
  selectedItem,
93
109
  updateView,
94
110
  enableSandwichView = true,
111
+ breadcrumb,
95
112
  } = props;
96
113
 
97
114
  const { onZoom, onReset, isDirty, onFocusOnNode } = props;
@@ -356,6 +373,21 @@ export default function FlameGraphComponent(props: FlamegraphProps) {
356
373
  'vertical-orientation': flamebearer.format === 'double',
357
374
  })}
358
375
  >
376
+ {breadcrumb && (
377
+ <FlamegraphBreadcrumb
378
+ totalValueText={breadcrumb.totalValueText}
379
+ totalSamplesText={breadcrumb.totalSamplesText}
380
+ samplesLabel={breadcrumb.samplesLabel}
381
+ unitLabel={breadcrumb.unitLabel}
382
+ hasFocus={breadcrumb.hasFocus}
383
+ focusPercent={breadcrumb.focusPercent}
384
+ focusLabel={breadcrumb.focusLabel}
385
+ ofTotalPlacement={breadcrumb.ofTotalPlacement}
386
+ ofTotalLabel={breadcrumb.ofTotalLabel}
387
+ clearFocusLabel={breadcrumb.clearFocusLabel}
388
+ onClearFocus={breadcrumb.onClearFocus}
389
+ />
390
+ )}
359
391
  {/* Header 已简化,仅在 single 模式下显示标题 */}
360
392
  {/* DiffLegend 已移到 Toolbar */}
361
393
  {headerVisible && (
@@ -30,6 +30,15 @@ import { ViewTypes } from './FlameGraphComponent/viewTypes';
30
30
  import { GraphVizPane } from './FlameGraphComponent/GraphVizPane';
31
31
  import { isSameFlamebearer } from './uniqueness';
32
32
  import { normalize } from './normalize';
33
+ import {
34
+ formatSampleCount,
35
+ getFormatter,
36
+ localizeDurationString,
37
+ } from '../format/format';
38
+ import {
39
+ FlamegraphI18nContext,
40
+ type FlamegraphMessages,
41
+ } from '../i18n';
33
42
 
34
43
  // Refers to a node in the flamegraph
35
44
  interface Node {
@@ -92,6 +101,9 @@ class FlameGraphRenderer extends Component<
92
101
  FlamegraphRendererProps,
93
102
  FlamegraphRendererState
94
103
  > {
104
+ static contextType = FlamegraphI18nContext;
105
+ declare context: FlamegraphMessages;
106
+
95
107
  resetFlamegraphState = {
96
108
  focusedNode: Maybe.nothing<Node>(),
97
109
  zoom: Maybe.nothing<Node>(),
@@ -395,6 +407,66 @@ class FlameGraphRenderer extends Component<
395
407
  });
396
408
  };
397
409
 
410
+ clearFocus = () => {
411
+ this.setState((prevState) => ({
412
+ ...prevState,
413
+ flamegraphConfigs: {
414
+ ...prevState.flamegraphConfigs,
415
+ ...this.resetFlamegraphState,
416
+ },
417
+ }));
418
+ };
419
+
420
+ getActiveRange = () => {
421
+ const { flamebearer } = this.state;
422
+ const { zoom, focusedNode } = this.state.flamegraphConfigs;
423
+ const totalTicks = flamebearer.numTicks;
424
+
425
+ if (!totalTicks) {
426
+ return { rangeMin: 0, rangeMax: 1 };
427
+ }
428
+
429
+ const ff = createFF(flamebearer.format);
430
+
431
+ const getRangeFromNode = (node: Node) => {
432
+ const level = flamebearer.levels[node.i];
433
+ if (!level) {
434
+ return { rangeMin: 0, rangeMax: 1 };
435
+ }
436
+
437
+ const offset = ff.getBarOffset(level, node.j);
438
+ const total = ff.getBarTotal(level, node.j);
439
+ return {
440
+ rangeMin: offset / totalTicks,
441
+ rangeMax: (offset + total) / totalTicks,
442
+ };
443
+ };
444
+
445
+ return zoom.match({
446
+ Just: (z) => {
447
+ return focusedNode.match({
448
+ Just: (f) => {
449
+ const focusRange = getRangeFromNode(f);
450
+ const zoomRange = getRangeFromNode(z);
451
+ if (
452
+ focusRange.rangeMax - focusRange.rangeMin <
453
+ zoomRange.rangeMax - zoomRange.rangeMin
454
+ ) {
455
+ return focusRange;
456
+ }
457
+ return zoomRange;
458
+ },
459
+ Nothing: () => getRangeFromNode(z),
460
+ });
461
+ },
462
+ Nothing: () =>
463
+ focusedNode.match({
464
+ Just: (f) => getRangeFromNode(f),
465
+ Nothing: () => ({ rangeMin: 0, rangeMax: 1 }),
466
+ }),
467
+ });
468
+ };
469
+
398
470
  // used as a variable instead of keeping in the state
399
471
  // so that the flamegraph doesn't rerender unnecessarily
400
472
  isDirty = () => {
@@ -445,6 +517,60 @@ class FlameGraphRenderer extends Component<
445
517
 
446
518
  const toolbarVisible = this.shouldShowToolbar();
447
519
 
520
+ const dataUnavailable =
521
+ !this.state.flamebearer || this.state.flamebearer.names.length <= 1;
522
+ const i18n = this.context;
523
+ const isZh = i18n.location !== 'Location';
524
+ const totalTicks = this.state.flamebearer.numTicks;
525
+ const formatter = getFormatter(
526
+ totalTicks,
527
+ this.state.flamebearer.sampleRate,
528
+ this.state.flamebearer.units
529
+ );
530
+ const totalValueRaw = formatter.format(
531
+ totalTicks,
532
+ this.state.flamebearer.sampleRate
533
+ );
534
+ const totalValueText =
535
+ localizeDurationString(totalValueRaw, isZh) || totalValueRaw;
536
+ const totalSamplesText = formatSampleCount(
537
+ totalTicks,
538
+ i18n.sampleCountFormat
539
+ );
540
+ const samplesLabel = i18n.tooltipSamples.replace(/[::]\s*$/, '');
541
+ const unitLabel =
542
+ i18n.tooltipUnitTitles[this.state.flamebearer.units]?.formattedValue ||
543
+ i18n.tooltipUnitTitles.unknown.formattedValue;
544
+ const activeRange = this.getActiveRange();
545
+ const hasFocus =
546
+ activeRange.rangeMin > 0 || activeRange.rangeMax < 1;
547
+ const activeRangeTicks = Math.max(
548
+ 0,
549
+ Math.min(
550
+ totalTicks,
551
+ (activeRange.rangeMax - activeRange.rangeMin) * totalTicks
552
+ )
553
+ );
554
+ const focusPercent =
555
+ totalTicks > 0 ? (activeRangeTicks / totalTicks) * 100 : 0;
556
+ const focusLabel = (() => {
557
+ const ff = createFF(this.state.flamebearer.format);
558
+ const focusNode = this.state.flamegraphConfigs.focusedNode.mapOrElse(
559
+ () =>
560
+ this.state.flamegraphConfigs.zoom.mapOrElse(() => null, (z) => z),
561
+ (f) => f
562
+ );
563
+ if (!focusNode) {
564
+ return '';
565
+ }
566
+ const level = this.state.flamebearer.levels[focusNode.i];
567
+ if (!level) {
568
+ return '';
569
+ }
570
+ const nameIndex = ff.getBarName(level, focusNode.j);
571
+ return this.state.flamebearer.names[nameIndex] || '';
572
+ })();
573
+
448
574
  const flameGraphPane = (
449
575
  <Graph
450
576
  key="flamegraph-pane"
@@ -467,6 +593,23 @@ class FlameGraphRenderer extends Component<
467
593
  toolbarVisible={toolbarVisible}
468
594
  setPalette={this.handleSetPalette}
469
595
  enableSandwichView={this.props.enableSandwichView}
596
+ breadcrumb={
597
+ dataUnavailable
598
+ ? undefined
599
+ : {
600
+ totalValueText,
601
+ totalSamplesText,
602
+ samplesLabel,
603
+ unitLabel,
604
+ hasFocus,
605
+ focusPercent,
606
+ focusLabel,
607
+ ofTotalPlacement: isZh ? 'before' : 'after',
608
+ ofTotalLabel: i18n.ofTotal,
609
+ clearFocusLabel: i18n.clearFocus,
610
+ onClearFocus: this.clearFocus,
611
+ }
612
+ }
470
613
  />
471
614
  );
472
615
 
@@ -574,8 +717,6 @@ class FlameGraphRenderer extends Component<
574
717
  // // rightTicks?: number;
575
718
  // } & addTicks;
576
719
 
577
- const dataUnavailable =
578
- !this.state.flamebearer || this.state.flamebearer.names.length <= 1;
579
720
  const panes = decidePanesOrder(
580
721
  this.state.view,
581
722
  flameGraphPane,
@@ -671,4 +812,4 @@ function decidePanesOrder(
671
812
  }
672
813
  }
673
814
 
674
- export default FlameGraphRenderer;
815
+ export default FlameGraphRenderer;
@@ -20,6 +20,7 @@ import LeftClickIcon from './LeftClickIcon';
20
20
  import styles from './Tooltip.module.scss';
21
21
  import { useFlamegraphI18n } from '../i18n';
22
22
  import type { FlamegraphPalette } from '../FlameGraph/FlameGraphComponent/colorPalette';
23
+ import { localizeDurationString, parseNumericWithUnit } from '../format/format';
23
24
 
24
25
  export type TooltipData = {
25
26
  units: Units;
@@ -56,81 +57,6 @@ export interface TooltipProps {
56
57
  ) => void;
57
58
  }
58
59
 
59
- /** 解析各种带单位的字符串,例如:
60
- * "0.63 seconds" / "0.63 分钟" / "< 1 ms" / "+0.24minutes"
61
- */
62
- function parseNumericWithUnit(
63
- input?: string | number
64
- ): { value: number; unit: string; hasLessThan: boolean; sign: string } | null {
65
- if (input == null) return null;
66
-
67
- if (typeof input === 'number') {
68
- return { value: input, unit: '', hasLessThan: false, sign: '' };
69
- }
70
-
71
- // 统一 Unicode 减号
72
- let s = String(input).trim().replace(/\u2212/g, '-');
73
- if (!s) return null;
74
-
75
- let hasLessThan = false;
76
- if (s.startsWith('<')) {
77
- hasLessThan = true;
78
- s = s.slice(1).trim();
79
- }
80
-
81
- const m = s.match(/^([+\-]?)(\d*\.?\d+)\s*(.*)$/);
82
- if (!m) return null;
83
-
84
- const sign = m[1] || '';
85
- const numStr = m[2];
86
- const unit = (m[3] || '').trim();
87
-
88
- const value = parseFloat(numStr);
89
- if (!Number.isFinite(value)) return null;
90
-
91
- return { value, unit, hasLessThan, sign };
92
- }
93
-
94
- /** 把 DurationFormatter 输出的英文单位翻译成中文,并保证数字和单位之间有空格 */
95
- function localizeDurationString(
96
- input?: string,
97
- isZh = false
98
- ): string | undefined {
99
- if (!input || !isZh) return input;
100
-
101
- const parsed = parseNumericWithUnit(input);
102
- if (!parsed) return input;
103
-
104
- const { hasLessThan, unit, sign, value } = parsed;
105
-
106
- let unitZh = unit;
107
- const u = unit.toLowerCase();
108
-
109
- if (u === 'ms') unitZh = '毫秒';
110
- else if (u === 'μs' || u === 'µs' || u === 'us') unitZh = '微秒';
111
- else if (u === 'second' || u === 'seconds') unitZh = '秒';
112
- else if (u === 'minute' || u === 'minutes' || u === 'min' || u === 'mins')
113
- unitZh = '分钟';
114
- else if (u === 'hour' || u === 'hours') unitZh = '小时';
115
- else if (u === 'day' || u === 'days') unitZh = '天';
116
- else if (u === 'month' || u === 'months') unitZh = '月';
117
- else if (u === 'year' || u === 'years') unitZh = '年';
118
-
119
- // 尽量保留原来的数值部分
120
- const m = String(input)
121
- .trim()
122
- .replace(/\u2212/g, '-')
123
- .match(/^<?\s*([+\-]?)(\d*\.?\d+)/);
124
- const valueStr =
125
- m && m[2] ? `${m[1] || ''}${m[2]}` : `${sign}${Math.abs(value)}`;
126
-
127
- const prefix = hasLessThan ? '< ' : '';
128
- if (!unitZh) {
129
- return `${prefix}${valueStr}`.trim();
130
- }
131
- return `${prefix}${valueStr} ${unitZh}`.trim();
132
- }
133
-
134
60
  /** CPU 时间差异:构造 "+0.24 minutes" / "-0.12 s" 这种文本(注意带空格) */
135
61
  function formatTimeDiff(
136
62
  baselineFormatted?: string,