@kubex/zinc 1.0.106 → 1.0.108
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/dist/custom-elements.json +231 -83
- package/dist/vscode.html-custom-data.json +21 -10
- package/dist/web-types.json +66 -45
- package/dist/zn.d.ts +68 -34
- package/dist/zn.min.css +1 -1
- package/dist/zn.min.js +1020 -1907
- package/docs/_includes/sidebar.njk +1 -0
- package/docs/pages/components/chart.md +134 -8
- package/docs/pages/components/simple-chart.md +20 -5
- package/docs/pages/components/stat.md +531 -401
- package/docs/pages/getting-started/shimmer-text.md +81 -0
- package/package.json +5 -6
- package/scss/shared/index.scss +1 -0
- package/scss/shared/shimmer.scss +98 -0
- package/src/components/chart/builders.test.ts +211 -0
- package/src/components/chart/builders.ts +221 -0
- package/src/components/chart/chart.component.ts +182 -173
- package/src/components/chart/chart.scss +0 -1
- package/src/components/chart/chart.test.ts +48 -4
- package/src/components/editor/modules/context-menu/context-menu.ts +2 -1
- package/src/components/markdown-editor/markdown-editor.component.ts +21 -0
- package/src/components/markdown-editor/markdown-editor.scss +24 -1
- package/src/components/rating/rating.component.ts +2 -1
- package/src/components/simple-chart/simple-chart.component.ts +72 -180
- package/src/components/tabs/tabs.component.ts +6 -2
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
---
|
|
2
|
+
meta:
|
|
3
|
+
title: Shimmer Text
|
|
4
|
+
description: A global utility class that applies an animated shimmer sweep to text, suitable for loading, processing, or "thinking" states.
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Shimmer Text
|
|
8
|
+
|
|
9
|
+
`shimmer-text` is a global utility class that animates a horizontal highlight sweep across text colour. Use it to convey an in-progress state — AI generation, background processing, or any "working on it" indicator.
|
|
10
|
+
|
|
11
|
+
The class works on any element with text content, including inside Zinc components. Colour variants mirror the standard Zinc palette: `zn-primary`, `zn-accent`, `zn-info`, `zn-success`, `zn-warning`, `zn-error`.
|
|
12
|
+
|
|
13
|
+
When `prefers-reduced-motion: reduce` is set, the animation is suppressed and the text falls back to a solid colour matching the chosen variant.
|
|
14
|
+
|
|
15
|
+
## Examples
|
|
16
|
+
|
|
17
|
+
### Default
|
|
18
|
+
|
|
19
|
+
```html:preview
|
|
20
|
+
<span class="shimmer-text">Thinking…</span>
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
### Colour Variants
|
|
24
|
+
|
|
25
|
+
```html:preview
|
|
26
|
+
<div style="display: flex; flex-direction: column; gap: 8px;">
|
|
27
|
+
<span class="shimmer-text">Default</span>
|
|
28
|
+
<span class="shimmer-text zn-primary">Primary</span>
|
|
29
|
+
<span class="shimmer-text zn-accent">Accent</span>
|
|
30
|
+
<span class="shimmer-text zn-info">Info</span>
|
|
31
|
+
<span class="shimmer-text zn-success">Success</span>
|
|
32
|
+
<span class="shimmer-text zn-warning">Warning</span>
|
|
33
|
+
<span class="shimmer-text zn-error">Error</span>
|
|
34
|
+
</div>
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### Headings and Larger Text
|
|
38
|
+
|
|
39
|
+
The shimmer scales with whatever `font-size` is on the element, so it can be applied to headings or inline within prose.
|
|
40
|
+
|
|
41
|
+
```html:preview
|
|
42
|
+
<h2 class="shimmer-text zn-primary" style="margin: 0;">Generating summary…</h2>
|
|
43
|
+
<p style="margin: 8px 0 0;">
|
|
44
|
+
Please wait while we <span class="shimmer-text zn-info">analyse your data</span>.
|
|
45
|
+
</p>
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### Inside Zinc Components
|
|
49
|
+
|
|
50
|
+
Because `shimmer-text` is a plain class, it composes with any Zinc component that renders slotted text.
|
|
51
|
+
|
|
52
|
+
```html:preview
|
|
53
|
+
<zn-chip class="shimmer-text zn-accent">Processing</zn-chip>
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Tuning the Sweep
|
|
57
|
+
|
|
58
|
+
The effect mirrors the Claude CLI's character-level shimmer: a narrow, sharper highlight slides across text rather than a soft gradient wash. The class exposes these CSS custom properties — all sized in `ch` so they scale with the current font size.
|
|
59
|
+
|
|
60
|
+
| Variable | Default | Purpose |
|
|
61
|
+
|---|---|---|
|
|
62
|
+
| `--shimmer-base` | `rgb(var(--zn-text))` | Resting text colour. |
|
|
63
|
+
| `--shimmer-highlight` | `rgb(var(--zn-text-heading))` | Colour of the spotlight. |
|
|
64
|
+
| `--shimmer-core` | `0.5ch` | Half-width of the solid-colour core of the spotlight. |
|
|
65
|
+
| `--shimmer-spread` | `1.5ch` | Half-width of the full highlight including its fade-out edges. |
|
|
66
|
+
| `--shimmer-duration` | `2s` | Time for one full sweep cycle. |
|
|
67
|
+
|
|
68
|
+
```html:preview
|
|
69
|
+
<div style="display: flex; flex-direction: column; gap: 6px;">
|
|
70
|
+
<span class="shimmer-text">Default sweep</span>
|
|
71
|
+
<span class="shimmer-text" style="--shimmer-duration: 1s;">Faster sweep</span>
|
|
72
|
+
<span class="shimmer-text" style="--shimmer-duration: 4s;">Slower, gentler sweep</span>
|
|
73
|
+
<span class="shimmer-text" style="--shimmer-core: 1ch; --shimmer-spread: 3ch;">Wider spotlight</span>
|
|
74
|
+
</div>
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
The Claude CLI scales its sweep with message length (50ms per character plus 10 characters of offscreen pause on each side). To match that feel in CSS, set `--shimmer-duration` to roughly `(messageCharacters + 20) × 50ms`.
|
|
78
|
+
|
|
79
|
+
### Reduced Motion
|
|
80
|
+
|
|
81
|
+
The animation is wrapped in `@media (prefers-reduced-motion: no-preference)`. Users who have requested reduced motion see the static colour variant instead — no configuration required.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kubex/zinc",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.108",
|
|
4
4
|
"description": "A collection of web components for building web applications based off of @shoelace-style/Shoelace",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"web components",
|
|
@@ -46,9 +46,8 @@
|
|
|
46
46
|
"@lit/task": "^1.0.1",
|
|
47
47
|
"@shoelace-style/localize": "^3.2.1",
|
|
48
48
|
"air-datepicker": "^3.5.3",
|
|
49
|
-
"apexcharts": "^4.5.0",
|
|
50
|
-
"chart.js": "^4.4.8",
|
|
51
49
|
"composed-offset-position": "^0.0.6",
|
|
50
|
+
"echarts": "^6.0.0",
|
|
52
51
|
"emoji-mart": "^5.6.0",
|
|
53
52
|
"lit": "^2.7.6",
|
|
54
53
|
"marked": "^18.0.2",
|
|
@@ -77,9 +76,9 @@
|
|
|
77
76
|
"custom-element-vs-code-integration": "^1.5.0",
|
|
78
77
|
"custom-element-vuejs-integration": "^1.4.0",
|
|
79
78
|
"del": "^8.0.0",
|
|
80
|
-
"esbuild": "^0.
|
|
79
|
+
"esbuild": "^0.27.7",
|
|
81
80
|
"esbuild-plugin-replace": "^1.4.0",
|
|
82
|
-
"esbuild-sass-plugin": "^3.
|
|
81
|
+
"esbuild-sass-plugin": "^3.7.0",
|
|
83
82
|
"eslint": "^8.57.1",
|
|
84
83
|
"eslint-plugin-chai-expect": "^3.1.0",
|
|
85
84
|
"eslint-plugin-chai-friendly": "^0.7.2",
|
|
@@ -94,7 +93,7 @@
|
|
|
94
93
|
"jsdom": "^26.0.0",
|
|
95
94
|
"lodash": "^4.17.21",
|
|
96
95
|
"lunr": "^2.3.9",
|
|
97
|
-
"markdown-it": "^14.1.
|
|
96
|
+
"markdown-it": "^14.1.1",
|
|
98
97
|
"markdown-it-container": "^4.0.0",
|
|
99
98
|
"markdown-it-ins": "^4.0.0",
|
|
100
99
|
"markdown-it-kbd": "^2.2.2",
|
package/scss/shared/index.scss
CHANGED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
|
|
2
|
+
@keyframes shimmer-text-sweep {
|
|
3
|
+
0% {
|
|
4
|
+
background-position: 100% center;
|
|
5
|
+
}
|
|
6
|
+
100% {
|
|
7
|
+
background-position: 0% center;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
12
|
+
.shimmer-text {
|
|
13
|
+
--shimmer-base: rgb(var(--zn-text));
|
|
14
|
+
--shimmer-highlight: rgb(var(--zn-text-heading));
|
|
15
|
+
--shimmer-core: 0.5ch;
|
|
16
|
+
--shimmer-spread: 1.5ch;
|
|
17
|
+
--shimmer-duration: 1.5s;
|
|
18
|
+
|
|
19
|
+
display: inline-block;
|
|
20
|
+
width: fit-content;
|
|
21
|
+
max-width: 100%;
|
|
22
|
+
background-image:
|
|
23
|
+
linear-gradient(
|
|
24
|
+
90deg,
|
|
25
|
+
transparent calc(50% - var(--shimmer-spread)),
|
|
26
|
+
var(--shimmer-highlight) calc(50% - var(--shimmer-core)),
|
|
27
|
+
var(--shimmer-highlight) calc(50% + var(--shimmer-core)),
|
|
28
|
+
transparent calc(50% + var(--shimmer-spread))
|
|
29
|
+
),
|
|
30
|
+
linear-gradient(var(--shimmer-base), var(--shimmer-base));
|
|
31
|
+
background-size: 250% 100%, auto;
|
|
32
|
+
background-clip: text;
|
|
33
|
+
-webkit-background-clip: text;
|
|
34
|
+
-webkit-text-fill-color: transparent !important;
|
|
35
|
+
color: transparent !important;
|
|
36
|
+
animation: var(--shimmer-duration) linear infinite shimmer-text-sweep;
|
|
37
|
+
|
|
38
|
+
&.zn-error {
|
|
39
|
+
--shimmer-base: rgb(var(--zn-color-error));
|
|
40
|
+
--shimmer-highlight: color-mix(in srgb, rgb(var(--zn-color-error)) 55%, white);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
&.zn-success {
|
|
44
|
+
--shimmer-base: rgb(var(--zn-color-success));
|
|
45
|
+
--shimmer-highlight: color-mix(in srgb, rgb(var(--zn-color-success)) 55%, white);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
&.zn-info {
|
|
49
|
+
--shimmer-base: rgb(var(--zn-color-info));
|
|
50
|
+
--shimmer-highlight: color-mix(in srgb, rgb(var(--zn-color-info)) 55%, white);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
&.zn-warning {
|
|
54
|
+
--shimmer-base: rgb(var(--zn-color-warning));
|
|
55
|
+
--shimmer-highlight: color-mix(in srgb, rgb(var(--zn-color-warning)) 55%, white);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
&.zn-primary {
|
|
59
|
+
--shimmer-base: rgb(var(--zn-primary));
|
|
60
|
+
--shimmer-highlight: color-mix(in srgb, rgb(var(--zn-primary)) 55%, white);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
&.zn-accent {
|
|
64
|
+
--shimmer-base: rgb(var(--zn-accent));
|
|
65
|
+
--shimmer-highlight: color-mix(in srgb, rgb(var(--zn-accent)) 55%, white);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
@media (prefers-reduced-motion: reduce) {
|
|
71
|
+
.shimmer-text {
|
|
72
|
+
color: rgb(var(--zn-text));
|
|
73
|
+
|
|
74
|
+
&.zn-error {
|
|
75
|
+
color: rgb(var(--zn-color-error)) !important;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
&.zn-success {
|
|
79
|
+
color: rgb(var(--zn-color-success)) !important;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
&.zn-info {
|
|
83
|
+
color: rgb(var(--zn-color-info)) !important;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
&.zn-warning {
|
|
87
|
+
color: rgb(var(--zn-color-warning)) !important;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
&.zn-primary {
|
|
91
|
+
color: rgb(var(--zn-primary)) !important;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
&.zn-accent {
|
|
95
|
+
color: rgb(var(--zn-accent)) !important;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call */
|
|
2
|
+
import {
|
|
3
|
+
buildAreaOption,
|
|
4
|
+
buildBarOption,
|
|
5
|
+
type BuilderProps,
|
|
6
|
+
buildLineOption,
|
|
7
|
+
buildSankeyOption,
|
|
8
|
+
} from './builders';
|
|
9
|
+
import { expect } from '@open-wc/testing';
|
|
10
|
+
|
|
11
|
+
const baseProps: BuilderProps = {
|
|
12
|
+
type: 'bar',
|
|
13
|
+
data: [{ name: 'Sales', data: [10, 20, 30] }],
|
|
14
|
+
categories: ['Q1', 'Q2', 'Q3'],
|
|
15
|
+
stacked: false,
|
|
16
|
+
enableAnimations: false,
|
|
17
|
+
datapointSize: 1,
|
|
18
|
+
theme: 'light',
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
describe('buildBarOption', () => {
|
|
22
|
+
it('maps a single series into bar type with categories', () => {
|
|
23
|
+
const opt = buildBarOption(baseProps);
|
|
24
|
+
expect(opt.series).to.have.lengthOf(1);
|
|
25
|
+
expect((opt.series as any[])[0].type).to.equal('bar');
|
|
26
|
+
expect((opt.series as any[])[0].name).to.equal('Sales');
|
|
27
|
+
expect((opt.series as any[])[0].data).to.deep.equal([10, 20, 30]);
|
|
28
|
+
expect((opt.xAxis as any).type).to.equal('category');
|
|
29
|
+
expect((opt.xAxis as any).data).to.deep.equal(['Q1', 'Q2', 'Q3']);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('applies stacking when stacked=true', () => {
|
|
33
|
+
const opt = buildBarOption({
|
|
34
|
+
...baseProps,
|
|
35
|
+
stacked: true,
|
|
36
|
+
data: [
|
|
37
|
+
{ name: 'A', data: [1, 2] },
|
|
38
|
+
{ name: 'B', data: [3, 4] },
|
|
39
|
+
],
|
|
40
|
+
});
|
|
41
|
+
expect((opt.series as any[])[0].stack).to.equal('total');
|
|
42
|
+
expect((opt.series as any[])[1].stack).to.equal('total');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('does not stack when stacked=false', () => {
|
|
46
|
+
const opt = buildBarOption({
|
|
47
|
+
...baseProps,
|
|
48
|
+
data: [
|
|
49
|
+
{ name: 'A', data: [1, 2] },
|
|
50
|
+
{ name: 'B', data: [3, 4] },
|
|
51
|
+
],
|
|
52
|
+
});
|
|
53
|
+
expect((opt.series as any[])[0].stack).to.be.undefined;
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('appends y-axis suffix via formatter', () => {
|
|
57
|
+
const opt = buildBarOption({ ...baseProps, yAxisAppend: '%' });
|
|
58
|
+
const formatter = ((opt.yAxis as any).axisLabel.formatter) as (v: number) => string;
|
|
59
|
+
expect(formatter(42)).to.equal('42%');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('maps per-series color to itemStyle.color', () => {
|
|
63
|
+
const opt = buildBarOption({
|
|
64
|
+
...baseProps,
|
|
65
|
+
data: [{ name: 'Sales', data: [1, 2, 3], color: '#ff0000' }],
|
|
66
|
+
});
|
|
67
|
+
expect((opt.series as any[])[0].itemStyle.color).to.equal('#ff0000');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('maps colors array to option.color', () => {
|
|
71
|
+
const opt = buildBarOption({ ...baseProps, colors: ['#abc', '#def'] });
|
|
72
|
+
expect(opt.color).to.deep.equal(['#abc', '#def']);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('disables animation by default', () => {
|
|
76
|
+
const opt = buildBarOption(baseProps);
|
|
77
|
+
expect(opt.animation).to.equal(false);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('enables animation when enableAnimations=true', () => {
|
|
81
|
+
const opt = buildBarOption({ ...baseProps, enableAnimations: true });
|
|
82
|
+
expect(opt.animation).to.equal(true);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe('buildLineOption', () => {
|
|
87
|
+
const baseLineProps: BuilderProps = {
|
|
88
|
+
type: 'line',
|
|
89
|
+
data: [{ name: 'Revenue', data: [10, 20, 30] }],
|
|
90
|
+
categories: ['Jan', 'Feb', 'Mar'],
|
|
91
|
+
stacked: false,
|
|
92
|
+
enableAnimations: false,
|
|
93
|
+
datapointSize: 3,
|
|
94
|
+
theme: 'light',
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
it('maps a series to line type', () => {
|
|
98
|
+
const opt = buildLineOption(baseLineProps);
|
|
99
|
+
expect((opt.series as any[])[0].type).to.equal('line');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('applies symbolSize from datapointSize', () => {
|
|
103
|
+
const opt = buildLineOption(baseLineProps);
|
|
104
|
+
expect((opt.series as any[])[0].symbolSize).to.equal(3);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('uses datetime x-axis when xAxisType=datetime', () => {
|
|
108
|
+
const opt = buildLineOption({ ...baseLineProps, xAxisType: 'datetime' });
|
|
109
|
+
expect((opt.xAxis as any).type).to.equal('time');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('does not apply areaStyle for plain line', () => {
|
|
113
|
+
const opt = buildLineOption(baseLineProps);
|
|
114
|
+
expect((opt.series as any[])[0].areaStyle).to.be.undefined;
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe('buildAreaOption', () => {
|
|
119
|
+
const baseAreaProps: BuilderProps = {
|
|
120
|
+
type: 'area',
|
|
121
|
+
data: [{ name: 'Traffic', data: [100, 200, 300] }],
|
|
122
|
+
categories: ['Mon', 'Tue', 'Wed'],
|
|
123
|
+
stacked: false,
|
|
124
|
+
enableAnimations: false,
|
|
125
|
+
datapointSize: 1,
|
|
126
|
+
theme: 'light',
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
it('maps a series to line type with areaStyle set', () => {
|
|
130
|
+
const opt = buildAreaOption(baseAreaProps);
|
|
131
|
+
expect((opt.series as any[])[0].type).to.equal('line');
|
|
132
|
+
expect((opt.series as any[])[0].areaStyle).to.be.an('object');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('applies stacking when stacked=true', () => {
|
|
136
|
+
const opt = buildAreaOption({
|
|
137
|
+
...baseAreaProps,
|
|
138
|
+
stacked: true,
|
|
139
|
+
data: [
|
|
140
|
+
{ name: 'A', data: [1, 2] },
|
|
141
|
+
{ name: 'B', data: [3, 4] },
|
|
142
|
+
],
|
|
143
|
+
});
|
|
144
|
+
expect((opt.series as any[])[0].stack).to.equal('total');
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe('buildSankeyOption', () => {
|
|
149
|
+
const sankeyProps: BuilderProps = {
|
|
150
|
+
type: 'sankey',
|
|
151
|
+
data: [{
|
|
152
|
+
name: 'Flow',
|
|
153
|
+
data: [
|
|
154
|
+
{ source: 'A', target: 'B', value: 10 },
|
|
155
|
+
{ source: 'A', target: 'C', value: 5 },
|
|
156
|
+
{ source: 'B', target: 'D', value: 7 },
|
|
157
|
+
],
|
|
158
|
+
}],
|
|
159
|
+
categories: [],
|
|
160
|
+
stacked: false,
|
|
161
|
+
enableAnimations: false,
|
|
162
|
+
datapointSize: 1,
|
|
163
|
+
theme: 'light',
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
it('builds one sankey series with links mapped from edges', () => {
|
|
167
|
+
const opt = buildSankeyOption(sankeyProps);
|
|
168
|
+
const series = (opt.series as any[])[0];
|
|
169
|
+
expect(series.type).to.equal('sankey');
|
|
170
|
+
expect(series.links).to.deep.equal([
|
|
171
|
+
{ source: 'A', target: 'B', value: 10 },
|
|
172
|
+
{ source: 'A', target: 'C', value: 5 },
|
|
173
|
+
{ source: 'B', target: 'D', value: 7 },
|
|
174
|
+
]);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('derives unique nodes from edge source/target values', () => {
|
|
178
|
+
const opt = buildSankeyOption(sankeyProps);
|
|
179
|
+
const series = (opt.series as any[])[0];
|
|
180
|
+
const names = series.data.map((n: any) => n.name).sort();
|
|
181
|
+
expect(names).to.deep.equal(['A', 'B', 'C', 'D']);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('omits xAxis and yAxis (sankey has no cartesian axes)', () => {
|
|
185
|
+
const opt = buildSankeyOption(sankeyProps);
|
|
186
|
+
expect(opt.xAxis).to.be.undefined;
|
|
187
|
+
expect(opt.yAxis).to.be.undefined;
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('passes through explicit nodes when provided on the series object', () => {
|
|
191
|
+
const withNodes = {
|
|
192
|
+
...sankeyProps,
|
|
193
|
+
data: [{
|
|
194
|
+
name: 'Flow',
|
|
195
|
+
nodes: [
|
|
196
|
+
{ name: 'A', itemStyle: { color: '#f00' } },
|
|
197
|
+
{ name: 'B' }, { name: 'C' }, { name: 'D' },
|
|
198
|
+
],
|
|
199
|
+
data: sankeyProps.data[0].data,
|
|
200
|
+
}] as any,
|
|
201
|
+
};
|
|
202
|
+
const opt = buildSankeyOption(withNodes);
|
|
203
|
+
const series = (opt.series as any[])[0];
|
|
204
|
+
expect(series.data[0]).to.deep.equal({ name: 'A', itemStyle: { color: '#f00' } });
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('uses tooltip trigger=item (not axis) for sankey', () => {
|
|
208
|
+
const opt = buildSankeyOption(sankeyProps);
|
|
209
|
+
expect((opt.tooltip as any).trigger).to.equal('item');
|
|
210
|
+
});
|
|
211
|
+
});
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// src/components/chart/builders.ts
|
|
2
|
+
import type { EChartsOption } from 'echarts';
|
|
3
|
+
|
|
4
|
+
export type ChartType = 'area' | 'bar' | 'line' | 'sankey';
|
|
5
|
+
|
|
6
|
+
export interface SeriesItem {
|
|
7
|
+
name: string;
|
|
8
|
+
data: any[];
|
|
9
|
+
color?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface SankeyEdge {
|
|
13
|
+
source: string;
|
|
14
|
+
target: string;
|
|
15
|
+
value: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface BuilderProps {
|
|
19
|
+
type: ChartType;
|
|
20
|
+
data: SeriesItem[];
|
|
21
|
+
categories: string[];
|
|
22
|
+
xAxisType?: 'datetime' | 'category' | 'numeric';
|
|
23
|
+
yAxisAppend?: string;
|
|
24
|
+
stacked: boolean;
|
|
25
|
+
enableAnimations: boolean | number;
|
|
26
|
+
datapointSize: number;
|
|
27
|
+
colors?: string[];
|
|
28
|
+
theme: 'light' | 'dark';
|
|
29
|
+
smooth?: boolean;
|
|
30
|
+
scale?: boolean | number;
|
|
31
|
+
textColor?: string;
|
|
32
|
+
borderColor?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function commonOption(props: BuilderProps): EChartsOption {
|
|
36
|
+
const fallback = props.theme === 'dark' ? 'rgb(161, 161, 170)' : 'rgb(113, 113, 122)';
|
|
37
|
+
const textColor = props.textColor ?? fallback;
|
|
38
|
+
const animEnabled = props.enableAnimations !== false && props.enableAnimations !== 0;
|
|
39
|
+
const animDuration = typeof props.enableAnimations === 'number' ? props.enableAnimations : 1500;
|
|
40
|
+
return {
|
|
41
|
+
animation: animEnabled,
|
|
42
|
+
animationDuration: animDuration,
|
|
43
|
+
animationEasing: 'cubicOut',
|
|
44
|
+
...(props.colors ? { color: props.colors } : {}),
|
|
45
|
+
textStyle: { color: textColor },
|
|
46
|
+
tooltip: {
|
|
47
|
+
trigger: 'axis',
|
|
48
|
+
valueFormatter: props.yAxisAppend
|
|
49
|
+
? (v: number | string) => `${v}${props.yAxisAppend}`
|
|
50
|
+
: undefined,
|
|
51
|
+
},
|
|
52
|
+
legend: {
|
|
53
|
+
top: 0,
|
|
54
|
+
right: 0,
|
|
55
|
+
icon: 'circle',
|
|
56
|
+
textStyle: { color: textColor },
|
|
57
|
+
},
|
|
58
|
+
grid: {
|
|
59
|
+
left: 40,
|
|
60
|
+
right: 20,
|
|
61
|
+
top: 40,
|
|
62
|
+
bottom: 30,
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function hasNoData(data: SeriesItem[]): boolean {
|
|
68
|
+
return data.length === 0 || data.every((s) => !s.data || s.data.length === 0);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function buildYAxis(props: BuilderProps) {
|
|
72
|
+
const emptyOpts = hasNoData(props.data) ? { min: 0, max: 6 } : {};
|
|
73
|
+
const scaleOpts = props.scale
|
|
74
|
+
? {
|
|
75
|
+
scale: true,
|
|
76
|
+
...(typeof props.scale === 'number'
|
|
77
|
+
? { boundaryGap: [`${props.scale}%`, `${props.scale}%`] as [string, string] }
|
|
78
|
+
: {}),
|
|
79
|
+
}
|
|
80
|
+
: {};
|
|
81
|
+
const splitLineOpts = props.borderColor
|
|
82
|
+
? { splitLine: { lineStyle: { color: props.borderColor } } }
|
|
83
|
+
: {};
|
|
84
|
+
return {
|
|
85
|
+
type: 'value' as const,
|
|
86
|
+
...splitLineOpts,
|
|
87
|
+
...emptyOpts,
|
|
88
|
+
...scaleOpts,
|
|
89
|
+
axisLabel: props.yAxisAppend
|
|
90
|
+
? { formatter: (v: number) => `${v}${props.yAxisAppend}` }
|
|
91
|
+
: {},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function buildXAxis(props: BuilderProps, edgeToEdge = false) {
|
|
96
|
+
const axisLineOpts = props.borderColor
|
|
97
|
+
? { axisLine: { lineStyle: { color: props.borderColor } } }
|
|
98
|
+
: {};
|
|
99
|
+
if (props.xAxisType === 'datetime') return { type: 'time' as const, ...axisLineOpts };
|
|
100
|
+
if (props.xAxisType === 'numeric') return { type: 'value' as const, ...axisLineOpts };
|
|
101
|
+
return {
|
|
102
|
+
type: 'category' as const,
|
|
103
|
+
data: props.categories,
|
|
104
|
+
...(edgeToEdge ? { boundaryGap: false } : {}),
|
|
105
|
+
...axisLineOpts,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/* eslint-disable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-return */
|
|
110
|
+
function normalizeData(data: any[]): any[] {
|
|
111
|
+
return data.map((d: any) => {
|
|
112
|
+
if (d && typeof d === 'object' && !Array.isArray(d) && 'x' in d && 'y' in d) {
|
|
113
|
+
return [d.x, d.y];
|
|
114
|
+
}
|
|
115
|
+
return d;
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
/* eslint-enable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-return */
|
|
119
|
+
|
|
120
|
+
function seriesFromProps(
|
|
121
|
+
props: BuilderProps,
|
|
122
|
+
seriesType: 'bar' | 'line',
|
|
123
|
+
extra: (s: SeriesItem) => Record<string, unknown> = () => ({}),
|
|
124
|
+
) {
|
|
125
|
+
return props.data.map((s) => ({
|
|
126
|
+
type: seriesType,
|
|
127
|
+
name: s.name,
|
|
128
|
+
data: normalizeData(s.data),
|
|
129
|
+
...(s.color ? { itemStyle: { color: s.color } } : {}),
|
|
130
|
+
...(props.stacked ? { stack: 'total' } : {}),
|
|
131
|
+
...(props.enableAnimations !== false && props.enableAnimations !== 0 && seriesType === 'bar'
|
|
132
|
+
? { animationDelay: (idx: number) => idx * 50 }
|
|
133
|
+
: {}),
|
|
134
|
+
...extra(s),
|
|
135
|
+
}));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function buildBarOption(props: BuilderProps): EChartsOption {
|
|
139
|
+
return {
|
|
140
|
+
...commonOption(props),
|
|
141
|
+
xAxis: buildXAxis(props),
|
|
142
|
+
yAxis: buildYAxis(props),
|
|
143
|
+
series: seriesFromProps(props, 'bar'),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function buildLineOption(props: BuilderProps): EChartsOption {
|
|
148
|
+
return {
|
|
149
|
+
...commonOption(props),
|
|
150
|
+
xAxis: buildXAxis(props, true),
|
|
151
|
+
yAxis: buildYAxis(props),
|
|
152
|
+
series: seriesFromProps(props, 'line', () => ({
|
|
153
|
+
symbolSize: props.datapointSize,
|
|
154
|
+
smooth: props.smooth,
|
|
155
|
+
})),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function buildAreaOption(props: BuilderProps): EChartsOption {
|
|
160
|
+
return {
|
|
161
|
+
...commonOption(props),
|
|
162
|
+
xAxis: buildXAxis(props, true),
|
|
163
|
+
yAxis: buildYAxis(props),
|
|
164
|
+
series: seriesFromProps(props, 'line', () => ({
|
|
165
|
+
symbolSize: props.datapointSize,
|
|
166
|
+
smooth: props.smooth,
|
|
167
|
+
areaStyle: { opacity: 0.1 },
|
|
168
|
+
})),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function deriveNodes(edges: SankeyEdge[]): { name: string }[] {
|
|
173
|
+
const seen = new Set<string>();
|
|
174
|
+
const nodes: { name: string }[] = [];
|
|
175
|
+
for (const edge of edges) {
|
|
176
|
+
if (!seen.has(edge.source)) {
|
|
177
|
+
seen.add(edge.source);
|
|
178
|
+
nodes.push({ name: edge.source });
|
|
179
|
+
}
|
|
180
|
+
if (!seen.has(edge.target)) {
|
|
181
|
+
seen.add(edge.target);
|
|
182
|
+
nodes.push({ name: edge.target });
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return nodes;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function buildSankeyOption(props: BuilderProps): EChartsOption {
|
|
189
|
+
const first = props.data[0] ?? { name: '', data: [] };
|
|
190
|
+
const edges = (first.data ?? []) as SankeyEdge[];
|
|
191
|
+
const explicitNodes = (first as { nodes?: { name: string }[] }).nodes;
|
|
192
|
+
const nodes = explicitNodes ?? deriveNodes(edges);
|
|
193
|
+
const fallback = props.theme === 'dark' ? 'rgb(161, 161, 170)' : 'rgb(113, 113, 122)';
|
|
194
|
+
const textColor = props.textColor ?? fallback;
|
|
195
|
+
|
|
196
|
+
const animEnabled = props.enableAnimations !== false && props.enableAnimations !== 0;
|
|
197
|
+
const animDuration = typeof props.enableAnimations === 'number' ? props.enableAnimations : 1500;
|
|
198
|
+
return {
|
|
199
|
+
animation: animEnabled,
|
|
200
|
+
animationDuration: animDuration,
|
|
201
|
+
animationEasing: 'cubicOut',
|
|
202
|
+
...(props.colors ? { color: props.colors } : {}),
|
|
203
|
+
textStyle: { color: textColor },
|
|
204
|
+
tooltip: { trigger: 'item' },
|
|
205
|
+
series: [{
|
|
206
|
+
type: 'sankey',
|
|
207
|
+
name: first.name,
|
|
208
|
+
data: nodes,
|
|
209
|
+
links: edges,
|
|
210
|
+
label: {
|
|
211
|
+
color: textColor,
|
|
212
|
+
textBorderWidth: 0,
|
|
213
|
+
},
|
|
214
|
+
lineStyle: {
|
|
215
|
+
color: 'source',
|
|
216
|
+
opacity: props.theme === 'dark' ? 0.4 : 0.3,
|
|
217
|
+
},
|
|
218
|
+
emphasis: { focus: 'adjacency' },
|
|
219
|
+
}],
|
|
220
|
+
};
|
|
221
|
+
}
|