@antv/infographic 0.2.15 → 0.2.17
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/README.md +27 -0
- package/README.zh-CN.md +27 -0
- package/dist/infographic.min.js +130 -129
- package/dist/infographic.min.js.map +1 -1
- package/esm/constants/service.d.ts +1 -1
- package/esm/constants/service.js +1 -1
- package/esm/designs/structures/chart-line.js +2 -1
- package/esm/designs/structures/sequence-interaction.js +36 -15
- package/esm/designs/structures/sequence-timeline.d.ts +1 -0
- package/esm/designs/structures/sequence-timeline.js +4 -2
- package/esm/exporter/png.js +2 -2
- package/esm/exporter/svg.js +176 -2
- package/esm/exporter/types.d.ts +10 -0
- package/esm/options/parser.js +8 -6
- package/esm/options/types.d.ts +3 -3
- package/esm/renderer/renderer.js +1 -1
- package/esm/resource/loaders/search.js +2 -3
- package/esm/runtime/options.js +1 -1
- package/esm/syntax/index.js +56 -10
- package/esm/syntax/mapper.js +20 -6
- package/esm/syntax/parser.js +89 -3
- package/esm/syntax/types.d.ts +1 -1
- package/esm/templates/built-in.js +2 -2
- package/esm/templates/registry.d.ts +1 -0
- package/esm/templates/registry.js +6 -0
- package/esm/templates/utils.d.ts +1 -0
- package/esm/templates/utils.js +63 -0
- package/esm/themes/built-in.js +3 -0
- package/esm/version.d.ts +1 -1
- package/esm/version.js +1 -1
- package/lib/constants/service.d.ts +1 -1
- package/lib/constants/service.js +1 -1
- package/lib/designs/structures/chart-line.js +2 -1
- package/lib/designs/structures/sequence-interaction.js +36 -15
- package/lib/designs/structures/sequence-timeline.d.ts +1 -0
- package/lib/designs/structures/sequence-timeline.js +4 -2
- package/lib/exporter/png.js +2 -2
- package/lib/exporter/svg.js +176 -2
- package/lib/exporter/types.d.ts +10 -0
- package/lib/options/parser.js +7 -5
- package/lib/options/types.d.ts +3 -3
- package/lib/renderer/renderer.js +1 -1
- package/lib/resource/loaders/search.js +2 -3
- package/lib/runtime/options.js +1 -1
- package/lib/syntax/index.js +56 -10
- package/lib/syntax/mapper.js +20 -6
- package/lib/syntax/parser.js +89 -3
- package/lib/syntax/types.d.ts +1 -1
- package/lib/templates/built-in.js +2 -2
- package/lib/templates/registry.d.ts +1 -0
- package/lib/templates/registry.js +7 -0
- package/lib/templates/utils.d.ts +1 -0
- package/lib/templates/utils.js +66 -0
- package/lib/themes/built-in.js +3 -0
- package/lib/version.d.ts +1 -1
- package/lib/version.js +1 -1
- package/package.json +1 -1
- package/src/constants/service.ts +1 -1
- package/src/designs/structures/chart-line.tsx +3 -1
- package/src/designs/structures/sequence-interaction.tsx +92 -46
- package/src/designs/structures/sequence-timeline.tsx +18 -15
- package/src/exporter/png.ts +3 -2
- package/src/exporter/svg.ts +209 -2
- package/src/exporter/types.ts +10 -0
- package/src/options/parser.ts +7 -6
- package/src/options/types.ts +3 -3
- package/src/renderer/renderer.ts +1 -1
- package/src/resource/loaders/search.ts +2 -2
- package/src/runtime/options.ts +1 -1
- package/src/syntax/index.ts +71 -10
- package/src/syntax/mapper.ts +20 -6
- package/src/syntax/parser.ts +111 -3
- package/src/syntax/types.ts +1 -0
- package/src/templates/built-in.ts +2 -2
- package/src/templates/registry.ts +6 -0
- package/src/templates/utils.ts +87 -0
- package/src/themes/built-in.ts +4 -0
- package/src/version.ts +1 -1
|
@@ -100,6 +100,7 @@ const DEFAULT_ITEM_HEIGHT = 50;
|
|
|
100
100
|
const FONT_SIZE = 14;
|
|
101
101
|
const ARROW_SIZE = 14;
|
|
102
102
|
const CORNER_RADIUS_NODE = 6;
|
|
103
|
+
const LIFELINE_MASK_GAP = 2;
|
|
103
104
|
const LANE_PADDING = 60;
|
|
104
105
|
|
|
105
106
|
const BTN_HALF_SIZE = 12;
|
|
@@ -419,39 +420,6 @@ export const SequenceInteractionFlow: ComponentType<
|
|
|
419
420
|
const defsElements: JSXElement[] = [];
|
|
420
421
|
const btnElements: JSXElement[] = [];
|
|
421
422
|
|
|
422
|
-
// 绘制生命线
|
|
423
|
-
if (showLifeline) {
|
|
424
|
-
lanes.forEach((_lane, laneIndex) => {
|
|
425
|
-
const centerX = getLaneCenterX(laneIndex);
|
|
426
|
-
const startY = padding + headerOffset;
|
|
427
|
-
const endY = totalHeight - padding;
|
|
428
|
-
|
|
429
|
-
decorElements.push(
|
|
430
|
-
<Path
|
|
431
|
-
d={`M ${centerX} ${startY} L ${centerX} ${endY}`}
|
|
432
|
-
stroke={colorBorder}
|
|
433
|
-
strokeWidth={lifelineWidth}
|
|
434
|
-
strokeDasharray="5,5"
|
|
435
|
-
fill="none"
|
|
436
|
-
data-element-type="shape"
|
|
437
|
-
/>,
|
|
438
|
-
);
|
|
439
|
-
|
|
440
|
-
// 绘制生命线末端箭头(实心)
|
|
441
|
-
decorElements.push(
|
|
442
|
-
...createArrowElements(
|
|
443
|
-
centerX,
|
|
444
|
-
endY,
|
|
445
|
-
Math.PI / 2,
|
|
446
|
-
'triangle',
|
|
447
|
-
colorBorder,
|
|
448
|
-
1,
|
|
449
|
-
10,
|
|
450
|
-
),
|
|
451
|
-
);
|
|
452
|
-
});
|
|
453
|
-
}
|
|
454
|
-
|
|
455
423
|
// 绘制泳道标题
|
|
456
424
|
if (showLaneHeader) {
|
|
457
425
|
lanes.forEach((lane, laneIndex) => {
|
|
@@ -523,19 +491,6 @@ export const SequenceInteractionFlow: ComponentType<
|
|
|
523
491
|
options,
|
|
524
492
|
);
|
|
525
493
|
|
|
526
|
-
// 添加节点背景遮挡层,防止生命线虚线透过半透明节点显示
|
|
527
|
-
// 只在节点中心放置窄条遮挡生命线,避免圆角处露出白色背景
|
|
528
|
-
const maskStripWidth = lifelineWidth + 6;
|
|
529
|
-
decorElements.push(
|
|
530
|
-
<Rect
|
|
531
|
-
x={centerX - maskStripWidth / 2}
|
|
532
|
-
y={y}
|
|
533
|
-
width={maskStripWidth}
|
|
534
|
-
height={itemHeight}
|
|
535
|
-
fill={colorBg}
|
|
536
|
-
/>,
|
|
537
|
-
);
|
|
538
|
-
|
|
539
494
|
// 构造类似 hierarchy-tree 的 _originalIndex
|
|
540
495
|
const originalIndex = [laneIndex, rowIndex];
|
|
541
496
|
// 附加到数据上,确保 Item 组件能正确识别
|
|
@@ -631,6 +586,97 @@ export const SequenceInteractionFlow: ComponentType<
|
|
|
631
586
|
);
|
|
632
587
|
});
|
|
633
588
|
|
|
589
|
+
// 绘制生命线(使用 mask 挖空节点区域,避免虚线穿透半透明节点)
|
|
590
|
+
if (showLifeline) {
|
|
591
|
+
// 预先按泳道分组节点,避免每条泳道都遍历全部节点
|
|
592
|
+
const nodeRectsByLane = new Map<
|
|
593
|
+
number,
|
|
594
|
+
{ x: number; y: number; width: number; height: number }[]
|
|
595
|
+
>();
|
|
596
|
+
nodeLayoutById.forEach((layout) => {
|
|
597
|
+
let list = nodeRectsByLane.get(layout.laneIndex);
|
|
598
|
+
if (!list) {
|
|
599
|
+
list = [];
|
|
600
|
+
nodeRectsByLane.set(layout.laneIndex, list);
|
|
601
|
+
}
|
|
602
|
+
list.push({
|
|
603
|
+
x: layout.x,
|
|
604
|
+
y: layout.y,
|
|
605
|
+
width: layout.width,
|
|
606
|
+
height: layout.height,
|
|
607
|
+
});
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
lanes.forEach((_lane, laneIndex) => {
|
|
611
|
+
const centerX = getLaneCenterX(laneIndex);
|
|
612
|
+
const startY = padding + headerOffset;
|
|
613
|
+
const endY = totalHeight - padding;
|
|
614
|
+
|
|
615
|
+
const laneNodeRects = nodeRectsByLane.get(laneIndex) ?? [];
|
|
616
|
+
|
|
617
|
+
// 如果该泳道有节点,创建 mask 来挖空节点区域
|
|
618
|
+
let lifelineMaskAttr: string | undefined;
|
|
619
|
+
if (laneNodeRects.length > 0) {
|
|
620
|
+
const maskId = `lifeline-mask-${instanceId}-${laneIndex}`;
|
|
621
|
+
defsElements.push(
|
|
622
|
+
<mask
|
|
623
|
+
id={maskId}
|
|
624
|
+
maskUnits="userSpaceOnUse"
|
|
625
|
+
x={0}
|
|
626
|
+
y={0}
|
|
627
|
+
width={totalWidth}
|
|
628
|
+
height={totalHeight}
|
|
629
|
+
>
|
|
630
|
+
{/* 白色底:显示所有线条 */}
|
|
631
|
+
<Rect
|
|
632
|
+
x={0}
|
|
633
|
+
y={0}
|
|
634
|
+
width={totalWidth}
|
|
635
|
+
height={totalHeight}
|
|
636
|
+
fill="white"
|
|
637
|
+
/>
|
|
638
|
+
{/* 黑色块:在节点位置挖空生命线,上下各留 LIFELINE_MASK_GAP 间距 */}
|
|
639
|
+
{laneNodeRects.map((rect) => (
|
|
640
|
+
<Rect
|
|
641
|
+
x={rect.x}
|
|
642
|
+
y={rect.y - LIFELINE_MASK_GAP}
|
|
643
|
+
width={rect.width}
|
|
644
|
+
height={rect.height + LIFELINE_MASK_GAP * 2}
|
|
645
|
+
fill="black"
|
|
646
|
+
/>
|
|
647
|
+
))}
|
|
648
|
+
</mask>,
|
|
649
|
+
);
|
|
650
|
+
lifelineMaskAttr = `url(#${maskId})`;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
decorElements.push(
|
|
654
|
+
<Path
|
|
655
|
+
d={`M ${centerX} ${startY} L ${centerX} ${endY}`}
|
|
656
|
+
stroke={colorBorder}
|
|
657
|
+
strokeWidth={lifelineWidth}
|
|
658
|
+
strokeDasharray="5,5"
|
|
659
|
+
fill="none"
|
|
660
|
+
data-element-type="shape"
|
|
661
|
+
mask={lifelineMaskAttr}
|
|
662
|
+
/>,
|
|
663
|
+
);
|
|
664
|
+
|
|
665
|
+
// 绘制生命线末端箭头(实心)
|
|
666
|
+
decorElements.push(
|
|
667
|
+
...createArrowElements(
|
|
668
|
+
centerX,
|
|
669
|
+
endY,
|
|
670
|
+
Math.PI / 2,
|
|
671
|
+
'triangle',
|
|
672
|
+
colorBorder,
|
|
673
|
+
1,
|
|
674
|
+
10,
|
|
675
|
+
),
|
|
676
|
+
);
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
|
|
634
680
|
// 添加新泳道按钮 (最右侧)
|
|
635
681
|
const lastLaneRightX = getLaneCenterX(lanes.length - 1) + laneWidth / 2;
|
|
636
682
|
const newLaneX =
|
|
@@ -15,12 +15,13 @@ import type { BaseStructureProps } from './types';
|
|
|
15
15
|
export interface SequenceTimelineProps extends BaseStructureProps {
|
|
16
16
|
gap?: number;
|
|
17
17
|
lineOffset?: number;
|
|
18
|
+
showStepLabels?: boolean;
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
export const SequenceTimeline: ComponentType<SequenceTimelineProps> = (
|
|
21
22
|
props,
|
|
22
23
|
) => {
|
|
23
|
-
const { Title, Item, data, gap = 10, options } = props;
|
|
24
|
+
const { Title, Item, data, gap = 10, showStepLabels = true, options } = props;
|
|
24
25
|
const { title, desc, items = [] } = data;
|
|
25
26
|
|
|
26
27
|
const titleContent = Title ? <Title title={title} desc={desc} /> : null;
|
|
@@ -90,20 +91,22 @@ export const SequenceTimeline: ComponentType<SequenceTimelineProps> = (
|
|
|
90
91
|
const nodeY = itemY + itemBounds.height / 2;
|
|
91
92
|
const indexes = [index];
|
|
92
93
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
94
|
+
if (showStepLabels) {
|
|
95
|
+
decorElements.push(
|
|
96
|
+
<Text
|
|
97
|
+
x={stepLabelX}
|
|
98
|
+
y={nodeY}
|
|
99
|
+
width={70}
|
|
100
|
+
fontSize={18}
|
|
101
|
+
fontWeight="bold"
|
|
102
|
+
alignHorizontal="left"
|
|
103
|
+
alignVertical="middle"
|
|
104
|
+
fill={palette[index % palette.length]}
|
|
105
|
+
>
|
|
106
|
+
{`STEP ${index + 1}`}
|
|
107
|
+
</Text>,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
107
110
|
|
|
108
111
|
itemElements.push(
|
|
109
112
|
<Item
|
package/src/exporter/png.ts
CHANGED
|
@@ -6,8 +6,9 @@ export async function exportToPNGString(
|
|
|
6
6
|
svg: SVGSVGElement,
|
|
7
7
|
options: Omit<PNGExportOptions, 'type'> = {},
|
|
8
8
|
): Promise<string> {
|
|
9
|
-
const { dpr = globalThis.devicePixelRatio ?? 2 } =
|
|
10
|
-
|
|
9
|
+
const { dpr = globalThis.devicePixelRatio ?? 2, removeBackground = false } =
|
|
10
|
+
options;
|
|
11
|
+
const node = await exportToSVG(svg, { removeBackground });
|
|
11
12
|
|
|
12
13
|
const { width, height } = getViewBox(node);
|
|
13
14
|
|
package/src/exporter/svg.ts
CHANGED
|
@@ -10,6 +10,8 @@ import {
|
|
|
10
10
|
import { embedFonts } from './font';
|
|
11
11
|
import type { SVGExportOptions } from './types';
|
|
12
12
|
|
|
13
|
+
const VIEWBOX_CHANGE_TOLERANCE = 0.5;
|
|
14
|
+
|
|
13
15
|
export async function exportToSVGString(
|
|
14
16
|
svg: SVGSVGElement,
|
|
15
17
|
options: Omit<SVGExportOptions, 'type'> = {},
|
|
@@ -19,13 +21,209 @@ export async function exportToSVGString(
|
|
|
19
21
|
return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(str);
|
|
20
22
|
}
|
|
21
23
|
|
|
24
|
+
function getExportViewBox(svg: SVGSVGElement) {
|
|
25
|
+
if (svg.hasAttribute('viewBox')) return getViewBox(svg);
|
|
26
|
+
|
|
27
|
+
const width = parseAbsoluteLength(svg.getAttribute('width'));
|
|
28
|
+
const height = parseAbsoluteLength(svg.getAttribute('height'));
|
|
29
|
+
if (width > 0 && height > 0) {
|
|
30
|
+
return { x: 0, y: 0, width, height };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const rect = svg.getBoundingClientRect();
|
|
34
|
+
if (rect.width > 0 && rect.height > 0) {
|
|
35
|
+
return { x: 0, y: 0, width: rect.width, height: rect.height };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function parseAbsoluteLength(value: string | null): number {
|
|
42
|
+
if (!value) return Number.NaN;
|
|
43
|
+
const trimmed = value.trim();
|
|
44
|
+
if (!trimmed) return Number.NaN;
|
|
45
|
+
if (!/^[-+]?(?:\d+\.?\d*|\.\d+)(?:px)?$/.test(trimmed)) return Number.NaN;
|
|
46
|
+
return Number.parseFloat(trimmed);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function measureSpanContentHeight(span: HTMLElement): number {
|
|
50
|
+
const prevHeight = span.style.height;
|
|
51
|
+
const prevOverflow = span.style.overflow;
|
|
52
|
+
try {
|
|
53
|
+
span.style.height = 'max-content';
|
|
54
|
+
span.style.overflow = 'hidden';
|
|
55
|
+
void span.offsetHeight; // force reflow
|
|
56
|
+
return span.scrollHeight;
|
|
57
|
+
} finally {
|
|
58
|
+
span.style.height = prevHeight;
|
|
59
|
+
span.style.overflow = prevOverflow;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function measureSpanContentWidth(span: HTMLElement): number {
|
|
64
|
+
const prevWidth = span.style.width;
|
|
65
|
+
const prevOverflow = span.style.overflow;
|
|
66
|
+
try {
|
|
67
|
+
span.style.width = 'max-content';
|
|
68
|
+
span.style.overflow = 'hidden';
|
|
69
|
+
void span.offsetWidth; // force reflow
|
|
70
|
+
return span.scrollWidth;
|
|
71
|
+
} finally {
|
|
72
|
+
span.style.width = prevWidth;
|
|
73
|
+
span.style.overflow = prevOverflow;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Returns [left, top, right, bottom] in SVG coordinates for a foreignObject,
|
|
78
|
+
// accounting for flex alignment: bottom/center-aligned content can overflow,
|
|
79
|
+
// and horizontally aligned content can overflow as well.
|
|
80
|
+
function getFOContentBoundsInSVG(
|
|
81
|
+
fo: SVGForeignObjectElement,
|
|
82
|
+
content: HTMLElement,
|
|
83
|
+
toSVGCoord: (x: number, y: number) => SVGPoint,
|
|
84
|
+
): [number, number, number, number] {
|
|
85
|
+
const foRect = fo.getBoundingClientRect();
|
|
86
|
+
const foTopLeft = toSVGCoord(foRect.left, foRect.top);
|
|
87
|
+
const foBottomRight = toSVGCoord(foRect.right, foRect.bottom);
|
|
88
|
+
|
|
89
|
+
const foLeftSVG = foTopLeft.x;
|
|
90
|
+
const foTopSVG = foTopLeft.y;
|
|
91
|
+
const foRightSVG = foBottomRight.x;
|
|
92
|
+
const foBottomSVG = foBottomRight.y;
|
|
93
|
+
|
|
94
|
+
const foWidthSVG = foRightSVG - foLeftSVG;
|
|
95
|
+
const foHeightSVG = foBottomSVG - foTopSVG;
|
|
96
|
+
|
|
97
|
+
const svgUnitsPerClientPxY =
|
|
98
|
+
foRect.height > 0 ? foHeightSVG / foRect.height : 1;
|
|
99
|
+
const svgUnitsPerClientPxX = foRect.width > 0 ? foWidthSVG / foRect.width : 1;
|
|
100
|
+
|
|
101
|
+
// Measure actual content dimensions
|
|
102
|
+
const realScrollHeight = measureSpanContentHeight(content);
|
|
103
|
+
const contentHeightSVG =
|
|
104
|
+
realScrollHeight > 0
|
|
105
|
+
? realScrollHeight * svgUnitsPerClientPxY
|
|
106
|
+
: foHeightSVG;
|
|
107
|
+
|
|
108
|
+
const realScrollWidth = measureSpanContentWidth(content);
|
|
109
|
+
const contentWidthSVG =
|
|
110
|
+
realScrollWidth > 0 ? realScrollWidth * svgUnitsPerClientPxX : foWidthSVG;
|
|
111
|
+
|
|
112
|
+
const computedStyle = window.getComputedStyle(content);
|
|
113
|
+
const alignItems = computedStyle.alignItems;
|
|
114
|
+
const justifyContent = computedStyle.justifyContent;
|
|
115
|
+
|
|
116
|
+
// Calculate vertical bounds
|
|
117
|
+
let top: number, bottom: number;
|
|
118
|
+
if (alignItems === 'flex-end' || alignItems === 'end') {
|
|
119
|
+
top = foBottomSVG - contentHeightSVG;
|
|
120
|
+
bottom = foBottomSVG;
|
|
121
|
+
} else if (alignItems === 'center') {
|
|
122
|
+
const overflowY = contentHeightSVG - foHeightSVG;
|
|
123
|
+
top = foTopSVG - overflowY / 2;
|
|
124
|
+
bottom = foBottomSVG + overflowY / 2;
|
|
125
|
+
} else {
|
|
126
|
+
top = foTopSVG;
|
|
127
|
+
bottom = foTopSVG + contentHeightSVG;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Calculate horizontal bounds
|
|
131
|
+
let left: number, right: number;
|
|
132
|
+
if (
|
|
133
|
+
justifyContent === 'flex-end' ||
|
|
134
|
+
justifyContent === 'end' ||
|
|
135
|
+
justifyContent === 'right'
|
|
136
|
+
) {
|
|
137
|
+
left = foRightSVG - contentWidthSVG;
|
|
138
|
+
right = foRightSVG;
|
|
139
|
+
} else if (justifyContent === 'center') {
|
|
140
|
+
const overflowX = contentWidthSVG - foWidthSVG;
|
|
141
|
+
left = foLeftSVG - overflowX / 2;
|
|
142
|
+
right = foRightSVG + overflowX / 2;
|
|
143
|
+
} else {
|
|
144
|
+
left = foLeftSVG;
|
|
145
|
+
right = foLeftSVG + contentWidthSVG;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return [left, top, right, bottom];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Computes a viewBox that fully covers all foreignObject text content,
|
|
153
|
+
* accounting for overflow caused by flex alignment (bottom/center align
|
|
154
|
+
* can push content outside the foreignObject bounds).
|
|
155
|
+
*/
|
|
156
|
+
function computeFullViewBox(svg: SVGSVGElement): string | null {
|
|
157
|
+
const viewBox = getExportViewBox(svg);
|
|
158
|
+
if (!viewBox) return null;
|
|
159
|
+
|
|
160
|
+
if (typeof svg.getScreenCTM !== 'function') return null;
|
|
161
|
+
const screenCTM = svg.getScreenCTM();
|
|
162
|
+
if (!screenCTM) return null;
|
|
163
|
+
const inverseCTM = screenCTM.inverse();
|
|
164
|
+
|
|
165
|
+
const toSVGCoord = (clientX: number, clientY: number) => {
|
|
166
|
+
const pt = svg.createSVGPoint();
|
|
167
|
+
pt.x = clientX;
|
|
168
|
+
pt.y = clientY;
|
|
169
|
+
return pt.matrixTransform(inverseCTM);
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
let minX = viewBox.x;
|
|
173
|
+
let minY = viewBox.y;
|
|
174
|
+
let maxX = viewBox.x + viewBox.width;
|
|
175
|
+
let maxY = viewBox.y + viewBox.height;
|
|
176
|
+
|
|
177
|
+
svg
|
|
178
|
+
.querySelectorAll<SVGForeignObjectElement>('foreignObject')
|
|
179
|
+
.forEach((fo) => {
|
|
180
|
+
const content = fo.firstElementChild as HTMLElement;
|
|
181
|
+
if (!content) return;
|
|
182
|
+
const [left, top, right, bottom] = getFOContentBoundsInSVG(
|
|
183
|
+
fo,
|
|
184
|
+
content,
|
|
185
|
+
toSVGCoord,
|
|
186
|
+
);
|
|
187
|
+
minX = Math.min(minX, left);
|
|
188
|
+
minY = Math.min(minY, top);
|
|
189
|
+
maxX = Math.max(maxX, right);
|
|
190
|
+
maxY = Math.max(maxY, bottom);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
const newX = minX;
|
|
194
|
+
const newY = minY;
|
|
195
|
+
const newWidth = maxX - newX;
|
|
196
|
+
const newHeight = maxY - newY;
|
|
197
|
+
if (
|
|
198
|
+
newWidth <= viewBox.width + VIEWBOX_CHANGE_TOLERANCE &&
|
|
199
|
+
newHeight <= viewBox.height + VIEWBOX_CHANGE_TOLERANCE &&
|
|
200
|
+
newX >= viewBox.x - VIEWBOX_CHANGE_TOLERANCE &&
|
|
201
|
+
newY >= viewBox.y - VIEWBOX_CHANGE_TOLERANCE
|
|
202
|
+
)
|
|
203
|
+
return null;
|
|
204
|
+
|
|
205
|
+
return `${newX} ${newY} ${newWidth} ${newHeight}`;
|
|
206
|
+
}
|
|
207
|
+
|
|
22
208
|
export async function exportToSVG(
|
|
23
209
|
svg: SVGSVGElement,
|
|
24
210
|
options: Omit<SVGExportOptions, 'type'> = {},
|
|
25
211
|
) {
|
|
26
|
-
const {
|
|
212
|
+
const {
|
|
213
|
+
removeBackground = false,
|
|
214
|
+
embedResources = true,
|
|
215
|
+
removeIds = false,
|
|
216
|
+
} = options;
|
|
27
217
|
const clonedSVG = svg.cloneNode(true) as SVGSVGElement;
|
|
28
|
-
|
|
218
|
+
|
|
219
|
+
if (typeof document !== 'undefined') {
|
|
220
|
+
const fullViewBox = computeFullViewBox(svg);
|
|
221
|
+
if (fullViewBox) {
|
|
222
|
+
clonedSVG.setAttribute('viewBox', fullViewBox);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const { width, height } = getViewBox(clonedSVG);
|
|
29
227
|
setAttributes(clonedSVG, { width, height });
|
|
30
228
|
|
|
31
229
|
if (removeIds) {
|
|
@@ -36,6 +234,9 @@ export async function exportToSVG(
|
|
|
36
234
|
}
|
|
37
235
|
await embedFonts(clonedSVG, embedResources);
|
|
38
236
|
|
|
237
|
+
if (removeBackground) {
|
|
238
|
+
removeSVGBackground(clonedSVG);
|
|
239
|
+
}
|
|
39
240
|
cleanSVG(clonedSVG);
|
|
40
241
|
|
|
41
242
|
return clonedSVG;
|
|
@@ -322,6 +523,12 @@ function cleanSVG(svg: SVGSVGElement) {
|
|
|
322
523
|
clearDataset(svg);
|
|
323
524
|
}
|
|
324
525
|
|
|
526
|
+
function removeSVGBackground(svg: SVGSVGElement) {
|
|
527
|
+
svg.style.removeProperty('background-color');
|
|
528
|
+
const background = getElementByRole(svg, ElementTypeEnum.Background);
|
|
529
|
+
background?.remove();
|
|
530
|
+
}
|
|
531
|
+
|
|
325
532
|
function removeBtnGroup(svg: SVGSVGElement) {
|
|
326
533
|
const btnGroup = getElementByRole(svg, ElementTypeEnum.BtnsGroup);
|
|
327
534
|
btnGroup?.remove();
|
package/src/exporter/types.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
export interface SVGExportOptions {
|
|
2
2
|
type: 'svg';
|
|
3
|
+
/**
|
|
4
|
+
* 是否移除背景(SVG 背景样式 + 背景矩形)
|
|
5
|
+
* @default false
|
|
6
|
+
*/
|
|
7
|
+
removeBackground?: boolean;
|
|
3
8
|
/**
|
|
4
9
|
* 是否将远程资源嵌入到 SVG 中
|
|
5
10
|
* @default true
|
|
@@ -14,6 +19,11 @@ export interface SVGExportOptions {
|
|
|
14
19
|
|
|
15
20
|
export interface PNGExportOptions {
|
|
16
21
|
type: 'png';
|
|
22
|
+
/**
|
|
23
|
+
* 是否移除背景(SVG 背景样式 + 背景矩形)
|
|
24
|
+
* @default false
|
|
25
|
+
*/
|
|
26
|
+
removeBackground?: boolean;
|
|
17
27
|
/**
|
|
18
28
|
* 设备像素比,默认为浏览器的 devicePixelRatio
|
|
19
29
|
* @default globalThis.devicePixelRatio || 2
|
package/src/options/parser.ts
CHANGED
|
@@ -3,13 +3,13 @@ import {
|
|
|
3
3
|
DesignOptions,
|
|
4
4
|
getItem,
|
|
5
5
|
getStructure,
|
|
6
|
-
getTemplate,
|
|
7
6
|
NullableParsedDesignsOptions,
|
|
8
7
|
ParsedDesignsOptions,
|
|
9
8
|
Title,
|
|
10
9
|
} from '../designs';
|
|
11
10
|
import { getPaletteColor } from '../renderer';
|
|
12
11
|
import type { TemplateOptions } from '../templates';
|
|
12
|
+
import { getTemplate, resolveTemplateKey } from '../templates/registry';
|
|
13
13
|
import { generateThemeColors, getTheme, type ThemeConfig } from '../themes';
|
|
14
14
|
import type { Data, ItemDatum, ParsedData } from '../types';
|
|
15
15
|
import {
|
|
@@ -32,14 +32,15 @@ export function parseOptions(
|
|
|
32
32
|
data,
|
|
33
33
|
...restOptions
|
|
34
34
|
} = options;
|
|
35
|
+
const resolvedTemplate = template ? resolveTemplateKey(template) : undefined;
|
|
35
36
|
|
|
36
37
|
const parsedContainer =
|
|
37
38
|
typeof container === 'string'
|
|
38
39
|
? document.querySelector(container) || document.createElement('div')
|
|
39
40
|
: container;
|
|
40
41
|
|
|
41
|
-
const templateOptions: TemplateOptions | undefined =
|
|
42
|
-
? getTemplate(
|
|
42
|
+
const templateOptions: TemplateOptions | undefined = resolvedTemplate
|
|
43
|
+
? getTemplate(resolvedTemplate)
|
|
43
44
|
: undefined;
|
|
44
45
|
const mergedThemeConfig = merge(
|
|
45
46
|
{},
|
|
@@ -52,7 +53,7 @@ export function parseOptions(
|
|
|
52
53
|
: undefined;
|
|
53
54
|
|
|
54
55
|
const parsed: Partial<ParsedInfographicOptions> = {
|
|
55
|
-
container: parsedContainer as
|
|
56
|
+
container: parsedContainer as Element | ShadowRoot,
|
|
56
57
|
padding: parsePadding(padding),
|
|
57
58
|
};
|
|
58
59
|
|
|
@@ -63,10 +64,10 @@ export function parseOptions(
|
|
|
63
64
|
|
|
64
65
|
Object.assign(parsed, restOptions);
|
|
65
66
|
|
|
66
|
-
const parsedData = parseData(data,
|
|
67
|
+
const parsedData = parseData(data, resolvedTemplate);
|
|
67
68
|
if (parsedData) parsed.data = parsedData;
|
|
68
69
|
|
|
69
|
-
if (
|
|
70
|
+
if (resolvedTemplate) parsed.template = resolvedTemplate;
|
|
70
71
|
if (templateOptions?.design || design) {
|
|
71
72
|
const designOptions = {
|
|
72
73
|
...(resolvedThemeConfig
|
package/src/options/types.ts
CHANGED
|
@@ -5,8 +5,8 @@ import type { Data, Padding, ParsedData } from '../types';
|
|
|
5
5
|
import type { Path } from '../utils';
|
|
6
6
|
|
|
7
7
|
export interface InfographicOptions {
|
|
8
|
-
/**
|
|
9
|
-
container?: string |
|
|
8
|
+
/** 容器,可以是选择器、Element 或 ShadowRoot */
|
|
9
|
+
container?: string | Element | ShadowRoot;
|
|
10
10
|
/** 宽度 */
|
|
11
11
|
width?: number | string;
|
|
12
12
|
/** 高度 */
|
|
@@ -37,7 +37,7 @@ export interface InfographicOptions {
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
export interface ParsedInfographicOptions {
|
|
40
|
-
container:
|
|
40
|
+
container: Element | ShadowRoot;
|
|
41
41
|
width?: number | string;
|
|
42
42
|
height?: number | string;
|
|
43
43
|
padding?: Padding;
|
package/src/renderer/renderer.ts
CHANGED
|
@@ -11,8 +11,8 @@ const queryIcon = async (query: string): Promise<string | null> => {
|
|
|
11
11
|
const response = await fetchWithCache(url);
|
|
12
12
|
if (!response.ok) return null;
|
|
13
13
|
const result = await response.json();
|
|
14
|
-
if (!result?.
|
|
15
|
-
return (result.data
|
|
14
|
+
if (!result?.success || !Array.isArray(result.data)) return null;
|
|
15
|
+
return (result.data[0] as string) || null;
|
|
16
16
|
} catch (error) {
|
|
17
17
|
console.error(`Failed to query icon for "${query}":`, error);
|
|
18
18
|
return null;
|