@kylincloud/flamegraph 0.35.22 → 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.
- package/CHANGELOG.md +22 -0
- package/dist/FlameGraph/FlameGraphComponent/Flamegraph.d.ts +6 -1
- package/dist/FlameGraph/FlameGraphComponent/Flamegraph.d.ts.map +1 -1
- package/dist/FlameGraph/FlameGraphComponent/FlamegraphBreadcrumb.d.ts +16 -0
- package/dist/FlameGraph/FlameGraphComponent/FlamegraphBreadcrumb.d.ts.map +1 -0
- package/dist/FlameGraph/FlameGraphComponent/Flamegraph_render.d.ts +8 -0
- package/dist/FlameGraph/FlameGraphComponent/Flamegraph_render.d.ts.map +1 -1
- package/dist/FlameGraph/FlameGraphComponent/index.d.ts +13 -0
- package/dist/FlameGraph/FlameGraphComponent/index.d.ts.map +1 -1
- package/dist/FlameGraph/FlameGraphRenderer.d.ts +13 -1
- package/dist/FlameGraph/FlameGraphRenderer.d.ts.map +1 -1
- package/dist/ProfilerTable.d.ts.map +1 -1
- package/dist/Tooltip/Tooltip.d.ts.map +1 -1
- package/dist/format/format.d.ts +14 -0
- package/dist/format/format.d.ts.map +1 -1
- package/dist/i18n.d.ts +9 -0
- package/dist/i18n.d.ts.map +1 -1
- package/dist/index.cjs.js +3 -3
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.esm.js +4 -4
- package/dist/index.esm.js.map +1 -1
- package/dist/index.node.cjs.js +4 -4
- package/dist/index.node.cjs.js.map +1 -1
- package/dist/index.node.esm.js +4 -4
- package/dist/index.node.esm.js.map +1 -1
- package/dist/shims/Table.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/FlameGraph/FlameGraphComponent/Flamegraph.ts +7 -2
- package/src/FlameGraph/FlameGraphComponent/FlamegraphBreadcrumb.module.scss +106 -0
- package/src/FlameGraph/FlameGraphComponent/FlamegraphBreadcrumb.tsx +98 -0
- package/src/FlameGraph/FlameGraphComponent/Flamegraph_render.ts +54 -23
- package/src/FlameGraph/FlameGraphComponent/index.tsx +46 -3
- package/src/FlameGraph/FlameGraphRenderer.tsx +211 -6
- package/src/ProfilerTable.module.scss +1 -2
- package/src/ProfilerTable.tsx +52 -46
- package/src/Tooltip/Tooltip.tsx +1 -75
- package/src/format/format.ts +98 -0
- package/src/i18n.tsx +39 -0
- package/src/shims/Table.module.scss +9 -2
- package/src/shims/Table.tsx +44 -40
|
@@ -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>(),
|
|
@@ -275,6 +287,55 @@ class FlameGraphRenderer extends Component<
|
|
|
275
287
|
});
|
|
276
288
|
};
|
|
277
289
|
|
|
290
|
+
/**
|
|
291
|
+
* 检查指定名称的函数是否在当前聚焦节点的子树中
|
|
292
|
+
*/
|
|
293
|
+
isNameInFocusedSubtree = (name: string): boolean => {
|
|
294
|
+
const { focusedNode } = this.state.flamegraphConfigs;
|
|
295
|
+
|
|
296
|
+
// 如果没有聚焦节点,任何 name 都算"在子树中"
|
|
297
|
+
if (focusedNode.isNothing) {
|
|
298
|
+
return true;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const { flamebearer } = this.state;
|
|
302
|
+
if (!flamebearer || !flamebearer.levels) {
|
|
303
|
+
return true;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const { names, levels, format } = flamebearer;
|
|
307
|
+
const ff = createFF(format);
|
|
308
|
+
const focusedI = (focusedNode as any).value.i;
|
|
309
|
+
const focusedJ = (focusedNode as any).value.j;
|
|
310
|
+
|
|
311
|
+
// 获取聚焦节点的范围
|
|
312
|
+
const focusedLevel = levels[focusedI];
|
|
313
|
+
if (!focusedLevel) return true;
|
|
314
|
+
|
|
315
|
+
const focusedOffset = ff.getBarOffset(focusedLevel, focusedJ);
|
|
316
|
+
const focusedTotal = ff.getBarTotal(focusedLevel, focusedJ);
|
|
317
|
+
const focusedEnd = focusedOffset + focusedTotal;
|
|
318
|
+
|
|
319
|
+
// 遍历聚焦节点及其下面的所有层级,检查是否有匹配的函数名
|
|
320
|
+
for (let i = focusedI; i < levels.length; i++) {
|
|
321
|
+
const level = levels[i];
|
|
322
|
+
for (let j = 0; j < level.length; j += ff.jStep) {
|
|
323
|
+
const offset = ff.getBarOffset(level, j);
|
|
324
|
+
const total = ff.getBarTotal(level, j);
|
|
325
|
+
|
|
326
|
+
// 检查这个节点是否在聚焦范围内
|
|
327
|
+
if (offset >= focusedOffset && offset + total <= focusedEnd) {
|
|
328
|
+
const nameIndex = ff.getBarName(level, j);
|
|
329
|
+
if (names[nameIndex] === name) {
|
|
330
|
+
return true;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return false;
|
|
337
|
+
};
|
|
338
|
+
|
|
278
339
|
setActiveItem = (item: { name: string }) => {
|
|
279
340
|
const { name } = item;
|
|
280
341
|
|
|
@@ -288,10 +349,25 @@ class FlameGraphRenderer extends Component<
|
|
|
288
349
|
}
|
|
289
350
|
}
|
|
290
351
|
|
|
291
|
-
//
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
352
|
+
// 检查选中的 item 是否在当前聚焦的子树中
|
|
353
|
+
// 如果不在,需要重置聚焦状态,以便在火焰图中正确显示高亮
|
|
354
|
+
const isInSubtree = this.isNameInFocusedSubtree(name);
|
|
355
|
+
|
|
356
|
+
if (isInSubtree) {
|
|
357
|
+
// 在子树中,只更新选中项
|
|
358
|
+
this.setState({
|
|
359
|
+
selectedItem: Maybe.just(name),
|
|
360
|
+
});
|
|
361
|
+
} else {
|
|
362
|
+
// 不在子树中,重置聚焦并更新选中项
|
|
363
|
+
this.setState({
|
|
364
|
+
selectedItem: Maybe.just(name),
|
|
365
|
+
flamegraphConfigs: {
|
|
366
|
+
...this.state.flamegraphConfigs,
|
|
367
|
+
focusedNode: this.resetFlamegraphState.focusedNode,
|
|
368
|
+
},
|
|
369
|
+
});
|
|
370
|
+
}
|
|
295
371
|
};
|
|
296
372
|
|
|
297
373
|
getHighlightQuery = () => {
|
|
@@ -331,6 +407,66 @@ class FlameGraphRenderer extends Component<
|
|
|
331
407
|
});
|
|
332
408
|
};
|
|
333
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
|
+
|
|
334
470
|
// used as a variable instead of keeping in the state
|
|
335
471
|
// so that the flamegraph doesn't rerender unnecessarily
|
|
336
472
|
isDirty = () => {
|
|
@@ -381,6 +517,60 @@ class FlameGraphRenderer extends Component<
|
|
|
381
517
|
|
|
382
518
|
const toolbarVisible = this.shouldShowToolbar();
|
|
383
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
|
+
|
|
384
574
|
const flameGraphPane = (
|
|
385
575
|
<Graph
|
|
386
576
|
key="flamegraph-pane"
|
|
@@ -403,6 +593,23 @@ class FlameGraphRenderer extends Component<
|
|
|
403
593
|
toolbarVisible={toolbarVisible}
|
|
404
594
|
setPalette={this.handleSetPalette}
|
|
405
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
|
+
}
|
|
406
613
|
/>
|
|
407
614
|
);
|
|
408
615
|
|
|
@@ -510,8 +717,6 @@ class FlameGraphRenderer extends Component<
|
|
|
510
717
|
// // rightTicks?: number;
|
|
511
718
|
// } & addTicks;
|
|
512
719
|
|
|
513
|
-
const dataUnavailable =
|
|
514
|
-
!this.state.flamebearer || this.state.flamebearer.names.length <= 1;
|
|
515
720
|
const panes = decidePanesOrder(
|
|
516
721
|
this.state.view,
|
|
517
722
|
flameGraphPane,
|
package/src/ProfilerTable.tsx
CHANGED
|
@@ -426,11 +426,11 @@ const getTableBody = ({
|
|
|
426
426
|
return rows.length > 0
|
|
427
427
|
? { bodyRows: rows, type: 'filled' as const }
|
|
428
428
|
: {
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
429
|
+
value: (
|
|
430
|
+
<div className="unsupported-format">{messages.noItemsFound}</div>
|
|
431
|
+
),
|
|
432
|
+
type: 'not-filled' as const,
|
|
433
|
+
};
|
|
434
434
|
};
|
|
435
435
|
|
|
436
436
|
export interface ProfilerTableProps {
|
|
@@ -469,9 +469,15 @@ function Table({
|
|
|
469
469
|
const [anchorPoint, setAnchorPoint] = useState({ x: 0, y: 0 });
|
|
470
470
|
const [contextMenuTarget, setContextMenuTarget] = useState<string | null>(null);
|
|
471
471
|
|
|
472
|
-
//
|
|
472
|
+
// 双击处理:高亮 + 聚焦到火焰图
|
|
473
473
|
const handleDoubleClick = useCallback(
|
|
474
474
|
(name: string) => {
|
|
475
|
+
// 先确保该项被高亮(如果未高亮则高亮,如果已高亮则保持)
|
|
476
|
+
if (!selectedItem.isJust || selectedItem.value !== name) {
|
|
477
|
+
handleTableItemClick({ name });
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// 然后聚焦到火焰图
|
|
475
481
|
if (!onFocusOnNode) return;
|
|
476
482
|
|
|
477
483
|
const node = findNodeByName(flamebearer, name);
|
|
@@ -479,7 +485,7 @@ function Table({
|
|
|
479
485
|
onFocusOnNode(node.i, node.j);
|
|
480
486
|
}
|
|
481
487
|
},
|
|
482
|
-
[flamebearer, onFocusOnNode]
|
|
488
|
+
[flamebearer, onFocusOnNode, selectedItem, handleTableItemClick]
|
|
483
489
|
);
|
|
484
490
|
|
|
485
491
|
// 右键菜单处理
|
|
@@ -535,46 +541,46 @@ function Table({
|
|
|
535
541
|
() =>
|
|
536
542
|
isDoubles
|
|
537
543
|
? [
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
544
|
+
{
|
|
545
|
+
sortable: 1,
|
|
546
|
+
name: 'name' as const,
|
|
547
|
+
label: i18n.location,
|
|
548
|
+
},
|
|
549
|
+
{
|
|
550
|
+
sortable: 1,
|
|
551
|
+
name: 'baseline' as const,
|
|
552
|
+
label: i18n.baseline,
|
|
553
|
+
default: true,
|
|
554
|
+
},
|
|
555
|
+
{
|
|
556
|
+
sortable: 1,
|
|
557
|
+
name: 'comparison' as const,
|
|
558
|
+
label: i18n.comparison,
|
|
559
|
+
},
|
|
560
|
+
{
|
|
561
|
+
sortable: 1,
|
|
562
|
+
name: 'diff' as const,
|
|
563
|
+
label: i18n.diff,
|
|
564
|
+
},
|
|
565
|
+
]
|
|
560
566
|
: [
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
567
|
+
{
|
|
568
|
+
sortable: 1,
|
|
569
|
+
name: 'name' as const,
|
|
570
|
+
label: i18n.location,
|
|
571
|
+
},
|
|
572
|
+
{
|
|
573
|
+
sortable: 1,
|
|
574
|
+
name: 'self' as const,
|
|
575
|
+
label: i18n.self,
|
|
576
|
+
default: true,
|
|
577
|
+
},
|
|
578
|
+
{
|
|
579
|
+
sortable: 1,
|
|
580
|
+
name: 'total' as const,
|
|
581
|
+
label: i18n.total,
|
|
582
|
+
},
|
|
583
|
+
],
|
|
578
584
|
[i18n, isDoubles]
|
|
579
585
|
);
|
|
580
586
|
|
package/src/Tooltip/Tooltip.tsx
CHANGED
|
@@ -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,
|
package/src/format/format.ts
CHANGED
|
@@ -5,6 +5,104 @@ export function numberWithCommas(x: number): string {
|
|
|
5
5
|
return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
+
type NumericWithUnit = {
|
|
9
|
+
value: number;
|
|
10
|
+
unit: string;
|
|
11
|
+
hasLessThan: boolean;
|
|
12
|
+
sign: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function parseNumericWithUnit(
|
|
16
|
+
input?: string | number
|
|
17
|
+
): NumericWithUnit | null {
|
|
18
|
+
if (input == null) return null;
|
|
19
|
+
|
|
20
|
+
if (typeof input === 'number') {
|
|
21
|
+
return { value: input, unit: '', hasLessThan: false, sign: '' };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let s = String(input).trim().replace(/\u2212/g, '-');
|
|
25
|
+
if (!s) return null;
|
|
26
|
+
|
|
27
|
+
let hasLessThan = false;
|
|
28
|
+
if (s.startsWith('<')) {
|
|
29
|
+
hasLessThan = true;
|
|
30
|
+
s = s.slice(1).trim();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const m = s.match(/^([+\-]?)(\d*\.?\d+)\s*(.*)$/);
|
|
34
|
+
if (!m) return null;
|
|
35
|
+
|
|
36
|
+
const sign = m[1] || '';
|
|
37
|
+
const numStr = m[2];
|
|
38
|
+
const unit = (m[3] || '').trim();
|
|
39
|
+
|
|
40
|
+
const value = parseFloat(numStr);
|
|
41
|
+
if (!Number.isFinite(value)) return null;
|
|
42
|
+
|
|
43
|
+
return { value, unit, hasLessThan, sign };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function localizeDurationString(
|
|
47
|
+
input?: string,
|
|
48
|
+
isZh = false
|
|
49
|
+
): string | undefined {
|
|
50
|
+
if (!input || !isZh) return input;
|
|
51
|
+
|
|
52
|
+
const parsed = parseNumericWithUnit(input);
|
|
53
|
+
if (!parsed) return input;
|
|
54
|
+
|
|
55
|
+
const { unit } = parsed;
|
|
56
|
+
|
|
57
|
+
let unitZh = unit;
|
|
58
|
+
const u = unit.toLowerCase();
|
|
59
|
+
|
|
60
|
+
if (u === 'ms') unitZh = '毫秒';
|
|
61
|
+
else if (u === 'μs' || u === 'µs' || u === 'us') unitZh = '微秒';
|
|
62
|
+
else if (u === 'second' || u === 'seconds') unitZh = '秒';
|
|
63
|
+
else if (u === 'minute' || u === 'minutes' || u === 'min' || u === 'mins')
|
|
64
|
+
unitZh = '分钟';
|
|
65
|
+
else if (u === 'hour' || u === 'hours') unitZh = '小时';
|
|
66
|
+
else if (u === 'day' || u === 'days') unitZh = '天';
|
|
67
|
+
else if (u === 'month' || u === 'months') unitZh = '月';
|
|
68
|
+
else if (u === 'year' || u === 'years') unitZh = '年';
|
|
69
|
+
|
|
70
|
+
const m = String(input).match(/^([<\s]*)([+\-]?\d*\.?\d+)(.*)$/);
|
|
71
|
+
if (!m) return input;
|
|
72
|
+
|
|
73
|
+
const prefix = m[1] || '';
|
|
74
|
+
const numberPart = m[2] || '';
|
|
75
|
+
|
|
76
|
+
return `${prefix}${numberPart} ${unitZh}`.trim();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type SampleCountFormat = Array<{ value: number; label: string }>;
|
|
80
|
+
|
|
81
|
+
const defaultSampleCountFormat: SampleCountFormat = [
|
|
82
|
+
{ value: 1e15, label: 'Quad' },
|
|
83
|
+
{ value: 1e12, label: 'Tri' },
|
|
84
|
+
{ value: 1e9, label: 'B' },
|
|
85
|
+
{ value: 1e6, label: 'M' },
|
|
86
|
+
{ value: 1e3, label: 'K' },
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
export function formatSampleCount(
|
|
90
|
+
value: number,
|
|
91
|
+
format: SampleCountFormat = defaultSampleCountFormat
|
|
92
|
+
): string {
|
|
93
|
+
const absValue = Math.abs(value);
|
|
94
|
+
for (const { value: threshold, label } of format) {
|
|
95
|
+
if (absValue >= threshold) {
|
|
96
|
+
const scaled = value / threshold;
|
|
97
|
+
const fixed = scaled.toFixed(2);
|
|
98
|
+
const trimmed = fixed.replace(/\.?0+$/, '');
|
|
99
|
+
return `${trimmed} ${label}`;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return numberWithCommas(value);
|
|
104
|
+
}
|
|
105
|
+
|
|
8
106
|
export function formatPercent(ratio: number) {
|
|
9
107
|
const percent = ratioToPercent(ratio);
|
|
10
108
|
return `${percent}%`;
|
package/src/i18n.tsx
CHANGED
|
@@ -73,6 +73,15 @@ export type FlamegraphMessages = {
|
|
|
73
73
|
focusOnThisFunction: string;
|
|
74
74
|
tableDoubleClickToFocus: string;
|
|
75
75
|
tableRightClickForOptions: string;
|
|
76
|
+
|
|
77
|
+
// 火焰图聚焦时的 collapsed 提示
|
|
78
|
+
collapsedLevelsSingular: string; // "total (1 level collapsed)"
|
|
79
|
+
collapsedLevelsPlural: string; // "total (n levels collapsed)"
|
|
80
|
+
|
|
81
|
+
// Breadcrumb
|
|
82
|
+
ofTotal: string;
|
|
83
|
+
clearFocus: string;
|
|
84
|
+
sampleCountFormat: Array<{ value: number; label: string }>;
|
|
76
85
|
};
|
|
77
86
|
|
|
78
87
|
const defaultTooltipUnitTitles: Record<Units, TooltipUnitMessages> = {
|
|
@@ -227,6 +236,21 @@ export const defaultMessages: FlamegraphMessages = {
|
|
|
227
236
|
focusOnThisFunction: 'Focus on this function',
|
|
228
237
|
tableDoubleClickToFocus: 'Double-click to focus in flamegraph',
|
|
229
238
|
tableRightClickForOptions: 'Right-click for more options',
|
|
239
|
+
|
|
240
|
+
// 火焰图聚焦时的 collapsed 提示
|
|
241
|
+
collapsedLevelsSingular: 'total (1 level collapsed)',
|
|
242
|
+
collapsedLevelsPlural: 'total ({n} levels collapsed)',
|
|
243
|
+
|
|
244
|
+
// Breadcrumb
|
|
245
|
+
ofTotal: 'of total',
|
|
246
|
+
clearFocus: 'Clear focus',
|
|
247
|
+
sampleCountFormat: [
|
|
248
|
+
{ value: 1e15, label: 'Quad' },
|
|
249
|
+
{ value: 1e12, label: 'Tri' },
|
|
250
|
+
{ value: 1e9, label: 'B' },
|
|
251
|
+
{ value: 1e6, label: 'M' },
|
|
252
|
+
{ value: 1e3, label: 'K' },
|
|
253
|
+
],
|
|
230
254
|
};
|
|
231
255
|
|
|
232
256
|
export const zhCNMessages: FlamegraphMessages = {
|
|
@@ -286,9 +310,24 @@ export const zhCNMessages: FlamegraphMessages = {
|
|
|
286
310
|
focusOnThisFunction: '聚焦到此函数',
|
|
287
311
|
tableDoubleClickToFocus: '双击可聚焦到火焰图',
|
|
288
312
|
tableRightClickForOptions: '右键查看更多选项',
|
|
313
|
+
|
|
314
|
+
// 火焰图聚焦时的 collapsed 提示
|
|
315
|
+
collapsedLevelsSingular: '总计(已折叠 1 层)',
|
|
316
|
+
collapsedLevelsPlural: '总计(已折叠 {n} 层)',
|
|
317
|
+
|
|
318
|
+
// Breadcrumb
|
|
319
|
+
ofTotal: '占总量',
|
|
320
|
+
clearFocus: '清除聚焦',
|
|
321
|
+
sampleCountFormat: [
|
|
322
|
+
{ value: 1e12, label: '兆' },
|
|
323
|
+
{ value: 1e8, label: '亿' },
|
|
324
|
+
{ value: 1e4, label: '万' },
|
|
325
|
+
{ value: 1e3, label: '千' },
|
|
326
|
+
],
|
|
289
327
|
};
|
|
290
328
|
|
|
291
329
|
const I18nContext = createContext<FlamegraphMessages>(defaultMessages);
|
|
330
|
+
export const FlamegraphI18nContext = I18nContext;
|
|
292
331
|
|
|
293
332
|
export type FlamegraphI18nProviderProps = {
|
|
294
333
|
messages?: Partial<FlamegraphMessages>;
|
|
@@ -49,10 +49,17 @@
|
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
tr {
|
|
52
|
+
/* 选中行样式 - 使用更明显的 antd 风格蓝色 */
|
|
52
53
|
&.isRowSelected {
|
|
53
54
|
cursor: pointer;
|
|
54
|
-
|
|
55
|
-
|
|
55
|
+
/* antd 选中蓝色,更明显但不会与红绿/红蓝火焰图冲突 */
|
|
56
|
+
background: var(--ps-table-highlight-row-bg, #bae0ff) !important;
|
|
57
|
+
color: var(--ps-table-highlight-row-text, #0958d9);
|
|
58
|
+
|
|
59
|
+
/* 左侧添加强调边框 */
|
|
60
|
+
td:first-child {
|
|
61
|
+
box-shadow: inset 3px 0 0 0 var(--ps-table-highlight-border, #1677ff);
|
|
62
|
+
}
|
|
56
63
|
}
|
|
57
64
|
|
|
58
65
|
&.isRowDisabled td {
|