@loreto-labs/skill-echarts 0.1.0
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 +32 -0
- package/actions.mjs +112 -0
- package/bin/cli.mjs +3 -0
- package/bin/mcp.mjs +5 -0
- package/command.md +9 -0
- package/loreto.skill.json +16 -0
- package/package.json +41 -0
- package/runtime/actions-core.mjs +115 -0
- package/runtime/argparse.mjs +70 -0
- package/runtime/cli-runner.mjs +112 -0
- package/runtime/mcp-runner.mjs +96 -0
- package/skill/README.md +85 -0
- package/skill/SKILL.md +164 -0
- package/skill/brands/apricot.json +30 -0
- package/skill/brands/onyx.json +31 -0
- package/skill/examples/01_bar.json +12 -0
- package/skill/examples/02_grouped_bar.json +16 -0
- package/skill/examples/03_stacked_bar.json +16 -0
- package/skill/examples/04_hbar.json +12 -0
- package/skill/examples/05_line.json +16 -0
- package/skill/examples/06_area.json +15 -0
- package/skill/examples/07_pie.json +16 -0
- package/skill/examples/08_donut.json +17 -0
- package/skill/examples/09_scatter.json +15 -0
- package/skill/examples/10_kpi.json +15 -0
- package/skill/examples/11_heatmap.json +21 -0
- package/skill/examples/12_radar.json +21 -0
- package/skill/examples/13_treemap.json +20 -0
- package/skill/examples/14_dashboard.json +28 -0
- package/skill/references/brand-style-mapping.md +58 -0
- package/skill/references/chart-type-recipes.md +66 -0
- package/skill/references/echarts-theming-model.md +62 -0
- package/skill/references/fonts-and-svg-embedding.md +60 -0
- package/skill/scripts/lib/charts.mjs +298 -0
- package/skill/scripts/lib/fonts.mjs +66 -0
- package/skill/scripts/lib/palette.mjs +119 -0
- package/skill/scripts/lib/theme.mjs +92 -0
- package/skill/scripts/package-lock.json +240 -0
- package/skill/scripts/package.json +15 -0
- package/skill/scripts/render-all.mjs +97 -0
- package/skill/scripts/render.mjs +98 -0
- package/skill/tests/test_building_branded_echarts_visualizations.py +92 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// theme.mjs — map a brand token object to an ECharts theme object.
|
|
2
|
+
//
|
|
3
|
+
// The theme carries everything that should look the same across every chart type:
|
|
4
|
+
// the categorical colour list, background, typography, axis treatment, legend/title
|
|
5
|
+
// styling, and per-series-type defaults (bar corner radius, line width, symbol size).
|
|
6
|
+
// Per-chart builders then only supply data + type-specific tweaks.
|
|
7
|
+
|
|
8
|
+
import { buildCategorical, withAlpha, mix } from './palette.mjs';
|
|
9
|
+
|
|
10
|
+
const DEFAULT_SHAPE = { barRadius: 4, lineWidth: 2.5, symbolSize: 8, gridline: 'dashed' };
|
|
11
|
+
|
|
12
|
+
export function buildTheme(brand) {
|
|
13
|
+
const c = brand.colors || {};
|
|
14
|
+
const f = brand.fonts || {};
|
|
15
|
+
const shape = { ...DEFAULT_SHAPE, ...(brand.shape || {}) };
|
|
16
|
+
|
|
17
|
+
const bg = c.background || (brand.mode === 'dark' ? '#0a0a0a' : '#ffffff');
|
|
18
|
+
const text = c.text || (brand.mode === 'dark' ? '#f5f4f1' : '#1a1d21');
|
|
19
|
+
const muted = c.textMuted || mix(text, bg, 0.45);
|
|
20
|
+
const axis = c.axis || mix(text, bg, 0.6);
|
|
21
|
+
const grid = c.grid || mix(text, bg, 0.86);
|
|
22
|
+
const family = f.family || 'system-ui, sans-serif';
|
|
23
|
+
const titleFamily = f.titleFamily || family;
|
|
24
|
+
const base = f.baseSize || 14;
|
|
25
|
+
|
|
26
|
+
// Expand the brand's categorical colours to a generous, distinct, contrast-safe set.
|
|
27
|
+
const color = buildCategorical(c.categorical || [], 12, bg);
|
|
28
|
+
|
|
29
|
+
const splitLineType = shape.gridline === 'none' ? 'solid' : shape.gridline;
|
|
30
|
+
const showSplit = shape.gridline !== 'none';
|
|
31
|
+
|
|
32
|
+
const axisCommon = {
|
|
33
|
+
axisLine: { lineStyle: { color: axis } },
|
|
34
|
+
axisTick: { show: false },
|
|
35
|
+
axisLabel: { color: muted, fontFamily: family, fontSize: base - 1 },
|
|
36
|
+
splitLine: { show: showSplit, lineStyle: { color: grid, type: splitLineType } },
|
|
37
|
+
nameTextStyle: { color: muted, fontFamily: family },
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const theme = {
|
|
41
|
+
color,
|
|
42
|
+
backgroundColor: bg,
|
|
43
|
+
textStyle: { fontFamily: family, color: text, fontSize: base },
|
|
44
|
+
|
|
45
|
+
title: {
|
|
46
|
+
textStyle: { fontFamily: titleFamily, color: text, fontSize: Math.round(base * 1.55), fontWeight: 600 },
|
|
47
|
+
subtextStyle: { fontFamily: family, color: muted, fontSize: base },
|
|
48
|
+
left: 0,
|
|
49
|
+
},
|
|
50
|
+
legend: {
|
|
51
|
+
textStyle: { color: muted, fontFamily: family, fontSize: base - 1 },
|
|
52
|
+
icon: 'roundRect',
|
|
53
|
+
itemWidth: 12, itemHeight: 12, itemGap: 16,
|
|
54
|
+
},
|
|
55
|
+
tooltip: {
|
|
56
|
+
backgroundColor: c.surface || mix(bg, text, 0.06),
|
|
57
|
+
borderColor: grid,
|
|
58
|
+
textStyle: { color: text, fontFamily: family },
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
categoryAxis: { ...axisCommon, splitLine: { show: false } },
|
|
62
|
+
valueAxis: axisCommon,
|
|
63
|
+
logAxis: axisCommon,
|
|
64
|
+
timeAxis: axisCommon,
|
|
65
|
+
|
|
66
|
+
grid: { left: 48, right: 28, top: 76, bottom: 56, containLabel: true },
|
|
67
|
+
|
|
68
|
+
// Per-series-type defaults.
|
|
69
|
+
bar: { itemStyle: { borderRadius: [shape.barRadius, shape.barRadius, 0, 0] }, barMaxWidth: 46 },
|
|
70
|
+
line: {
|
|
71
|
+
lineStyle: { width: shape.lineWidth },
|
|
72
|
+
symbol: 'circle', symbolSize: shape.symbolSize, smooth: 0.35,
|
|
73
|
+
emphasis: { focus: 'series' },
|
|
74
|
+
},
|
|
75
|
+
scatter: { symbolSize: shape.symbolSize + 4, itemStyle: { opacity: 0.85 } },
|
|
76
|
+
pie: {
|
|
77
|
+
itemStyle: { borderColor: bg, borderWidth: 2 },
|
|
78
|
+
label: { color: text, fontFamily: family },
|
|
79
|
+
labelLine: { lineStyle: { color: axis } },
|
|
80
|
+
},
|
|
81
|
+
radar: {
|
|
82
|
+
// radar axis colours are set per-option (the radar component, not series)
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
// Carry the resolved tokens so chart builders can reach them without re-deriving.
|
|
86
|
+
_tokens: { bg, text, muted, axis, grid, family, titleFamily, base, color, shape,
|
|
87
|
+
surface: c.surface || mix(bg, text, 0.05),
|
|
88
|
+
positive: c.positive || '#16a34a', negative: c.negative || '#dc2626',
|
|
89
|
+
withAlpha },
|
|
90
|
+
};
|
|
91
|
+
return theme;
|
|
92
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "branded-echarts-scripts",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"lockfileVersion": 3,
|
|
5
|
+
"requires": true,
|
|
6
|
+
"packages": {
|
|
7
|
+
"": {
|
|
8
|
+
"name": "branded-echarts-scripts",
|
|
9
|
+
"version": "0.1.0",
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"@resvg/resvg-js": "^2.6.2",
|
|
12
|
+
"echarts": "^5.5.1"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"node_modules/@resvg/resvg-js": {
|
|
16
|
+
"version": "2.6.2",
|
|
17
|
+
"resolved": "https://registry.npmjs.org/@resvg/resvg-js/-/resvg-js-2.6.2.tgz",
|
|
18
|
+
"integrity": "sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q==",
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">= 10"
|
|
21
|
+
},
|
|
22
|
+
"optionalDependencies": {
|
|
23
|
+
"@resvg/resvg-js-android-arm-eabi": "2.6.2",
|
|
24
|
+
"@resvg/resvg-js-android-arm64": "2.6.2",
|
|
25
|
+
"@resvg/resvg-js-darwin-arm64": "2.6.2",
|
|
26
|
+
"@resvg/resvg-js-darwin-x64": "2.6.2",
|
|
27
|
+
"@resvg/resvg-js-linux-arm-gnueabihf": "2.6.2",
|
|
28
|
+
"@resvg/resvg-js-linux-arm64-gnu": "2.6.2",
|
|
29
|
+
"@resvg/resvg-js-linux-arm64-musl": "2.6.2",
|
|
30
|
+
"@resvg/resvg-js-linux-x64-gnu": "2.6.2",
|
|
31
|
+
"@resvg/resvg-js-linux-x64-musl": "2.6.2",
|
|
32
|
+
"@resvg/resvg-js-win32-arm64-msvc": "2.6.2",
|
|
33
|
+
"@resvg/resvg-js-win32-ia32-msvc": "2.6.2",
|
|
34
|
+
"@resvg/resvg-js-win32-x64-msvc": "2.6.2"
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"node_modules/@resvg/resvg-js-android-arm-eabi": {
|
|
38
|
+
"version": "2.6.2",
|
|
39
|
+
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-android-arm-eabi/-/resvg-js-android-arm-eabi-2.6.2.tgz",
|
|
40
|
+
"integrity": "sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA==",
|
|
41
|
+
"cpu": [
|
|
42
|
+
"arm"
|
|
43
|
+
],
|
|
44
|
+
"optional": true,
|
|
45
|
+
"os": [
|
|
46
|
+
"android"
|
|
47
|
+
],
|
|
48
|
+
"engines": {
|
|
49
|
+
"node": ">= 10"
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
"node_modules/@resvg/resvg-js-android-arm64": {
|
|
53
|
+
"version": "2.6.2",
|
|
54
|
+
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-android-arm64/-/resvg-js-android-arm64-2.6.2.tgz",
|
|
55
|
+
"integrity": "sha512-VcOKezEhm2VqzXpcIJoITuvUS/fcjIw5NA/w3tjzWyzmvoCdd+QXIqy3FBGulWdClvp4g+IfUemigrkLThSjAQ==",
|
|
56
|
+
"cpu": [
|
|
57
|
+
"arm64"
|
|
58
|
+
],
|
|
59
|
+
"optional": true,
|
|
60
|
+
"os": [
|
|
61
|
+
"android"
|
|
62
|
+
],
|
|
63
|
+
"engines": {
|
|
64
|
+
"node": ">= 10"
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
"node_modules/@resvg/resvg-js-darwin-arm64": {
|
|
68
|
+
"version": "2.6.2",
|
|
69
|
+
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-darwin-arm64/-/resvg-js-darwin-arm64-2.6.2.tgz",
|
|
70
|
+
"integrity": "sha512-nmok2LnAd6nLUKI16aEB9ydMC6Lidiiq2m1nEBDR1LaaP7FGs4AJ90qDraxX+CWlVuRlvNjyYJTNv8qFjtL9+A==",
|
|
71
|
+
"cpu": [
|
|
72
|
+
"arm64"
|
|
73
|
+
],
|
|
74
|
+
"optional": true,
|
|
75
|
+
"os": [
|
|
76
|
+
"darwin"
|
|
77
|
+
],
|
|
78
|
+
"engines": {
|
|
79
|
+
"node": ">= 10"
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
"node_modules/@resvg/resvg-js-darwin-x64": {
|
|
83
|
+
"version": "2.6.2",
|
|
84
|
+
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-darwin-x64/-/resvg-js-darwin-x64-2.6.2.tgz",
|
|
85
|
+
"integrity": "sha512-GInyZLjgWDfsVT6+SHxQVRwNzV0AuA1uqGsOAW+0th56J7Nh6bHHKXHBWzUrihxMetcFDmQMAX1tZ1fZDYSRsw==",
|
|
86
|
+
"cpu": [
|
|
87
|
+
"x64"
|
|
88
|
+
],
|
|
89
|
+
"optional": true,
|
|
90
|
+
"os": [
|
|
91
|
+
"darwin"
|
|
92
|
+
],
|
|
93
|
+
"engines": {
|
|
94
|
+
"node": ">= 10"
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
"node_modules/@resvg/resvg-js-linux-arm-gnueabihf": {
|
|
98
|
+
"version": "2.6.2",
|
|
99
|
+
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm-gnueabihf/-/resvg-js-linux-arm-gnueabihf-2.6.2.tgz",
|
|
100
|
+
"integrity": "sha512-YIV3u/R9zJbpqTTNwTZM5/ocWetDKGsro0SWp70eGEM9eV2MerWyBRZnQIgzU3YBnSBQ1RcxRZvY/UxwESfZIw==",
|
|
101
|
+
"cpu": [
|
|
102
|
+
"arm"
|
|
103
|
+
],
|
|
104
|
+
"optional": true,
|
|
105
|
+
"os": [
|
|
106
|
+
"linux"
|
|
107
|
+
],
|
|
108
|
+
"engines": {
|
|
109
|
+
"node": ">= 10"
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
"node_modules/@resvg/resvg-js-linux-arm64-gnu": {
|
|
113
|
+
"version": "2.6.2",
|
|
114
|
+
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm64-gnu/-/resvg-js-linux-arm64-gnu-2.6.2.tgz",
|
|
115
|
+
"integrity": "sha512-zc2BlJSim7YR4FZDQ8OUoJg5holYzdiYMeobb9pJuGDidGL9KZUv7SbiD4E8oZogtYY42UZEap7dqkkYuA91pg==",
|
|
116
|
+
"cpu": [
|
|
117
|
+
"arm64"
|
|
118
|
+
],
|
|
119
|
+
"optional": true,
|
|
120
|
+
"os": [
|
|
121
|
+
"linux"
|
|
122
|
+
],
|
|
123
|
+
"engines": {
|
|
124
|
+
"node": ">= 10"
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
"node_modules/@resvg/resvg-js-linux-arm64-musl": {
|
|
128
|
+
"version": "2.6.2",
|
|
129
|
+
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm64-musl/-/resvg-js-linux-arm64-musl-2.6.2.tgz",
|
|
130
|
+
"integrity": "sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg==",
|
|
131
|
+
"cpu": [
|
|
132
|
+
"arm64"
|
|
133
|
+
],
|
|
134
|
+
"optional": true,
|
|
135
|
+
"os": [
|
|
136
|
+
"linux"
|
|
137
|
+
],
|
|
138
|
+
"engines": {
|
|
139
|
+
"node": ">= 10"
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
"node_modules/@resvg/resvg-js-linux-x64-gnu": {
|
|
143
|
+
"version": "2.6.2",
|
|
144
|
+
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-x64-gnu/-/resvg-js-linux-x64-gnu-2.6.2.tgz",
|
|
145
|
+
"integrity": "sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw==",
|
|
146
|
+
"cpu": [
|
|
147
|
+
"x64"
|
|
148
|
+
],
|
|
149
|
+
"optional": true,
|
|
150
|
+
"os": [
|
|
151
|
+
"linux"
|
|
152
|
+
],
|
|
153
|
+
"engines": {
|
|
154
|
+
"node": ">= 10"
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
"node_modules/@resvg/resvg-js-linux-x64-musl": {
|
|
158
|
+
"version": "2.6.2",
|
|
159
|
+
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-x64-musl/-/resvg-js-linux-x64-musl-2.6.2.tgz",
|
|
160
|
+
"integrity": "sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ==",
|
|
161
|
+
"cpu": [
|
|
162
|
+
"x64"
|
|
163
|
+
],
|
|
164
|
+
"optional": true,
|
|
165
|
+
"os": [
|
|
166
|
+
"linux"
|
|
167
|
+
],
|
|
168
|
+
"engines": {
|
|
169
|
+
"node": ">= 10"
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
"node_modules/@resvg/resvg-js-win32-arm64-msvc": {
|
|
173
|
+
"version": "2.6.2",
|
|
174
|
+
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-arm64-msvc/-/resvg-js-win32-arm64-msvc-2.6.2.tgz",
|
|
175
|
+
"integrity": "sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ==",
|
|
176
|
+
"cpu": [
|
|
177
|
+
"arm64"
|
|
178
|
+
],
|
|
179
|
+
"optional": true,
|
|
180
|
+
"os": [
|
|
181
|
+
"win32"
|
|
182
|
+
],
|
|
183
|
+
"engines": {
|
|
184
|
+
"node": ">= 10"
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
"node_modules/@resvg/resvg-js-win32-ia32-msvc": {
|
|
188
|
+
"version": "2.6.2",
|
|
189
|
+
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-ia32-msvc/-/resvg-js-win32-ia32-msvc-2.6.2.tgz",
|
|
190
|
+
"integrity": "sha512-har4aPAlvjnLcil40AC77YDIk6loMawuJwFINEM7n0pZviwMkMvjb2W5ZirsNOZY4aDbo5tLx0wNMREp5Brk+w==",
|
|
191
|
+
"cpu": [
|
|
192
|
+
"ia32"
|
|
193
|
+
],
|
|
194
|
+
"optional": true,
|
|
195
|
+
"os": [
|
|
196
|
+
"win32"
|
|
197
|
+
],
|
|
198
|
+
"engines": {
|
|
199
|
+
"node": ">= 10"
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
"node_modules/@resvg/resvg-js-win32-x64-msvc": {
|
|
203
|
+
"version": "2.6.2",
|
|
204
|
+
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-x64-msvc/-/resvg-js-win32-x64-msvc-2.6.2.tgz",
|
|
205
|
+
"integrity": "sha512-ZXtYhtUr5SSaBrUDq7DiyjOFJqBVL/dOBN7N/qmi/pO0IgiWW/f/ue3nbvu9joWE5aAKDoIzy/CxsY0suwGosQ==",
|
|
206
|
+
"cpu": [
|
|
207
|
+
"x64"
|
|
208
|
+
],
|
|
209
|
+
"optional": true,
|
|
210
|
+
"os": [
|
|
211
|
+
"win32"
|
|
212
|
+
],
|
|
213
|
+
"engines": {
|
|
214
|
+
"node": ">= 10"
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
"node_modules/echarts": {
|
|
218
|
+
"version": "5.6.0",
|
|
219
|
+
"resolved": "https://registry.npmjs.org/echarts/-/echarts-5.6.0.tgz",
|
|
220
|
+
"integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==",
|
|
221
|
+
"dependencies": {
|
|
222
|
+
"tslib": "2.3.0",
|
|
223
|
+
"zrender": "5.6.1"
|
|
224
|
+
}
|
|
225
|
+
},
|
|
226
|
+
"node_modules/tslib": {
|
|
227
|
+
"version": "2.3.0",
|
|
228
|
+
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
|
|
229
|
+
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="
|
|
230
|
+
},
|
|
231
|
+
"node_modules/zrender": {
|
|
232
|
+
"version": "5.6.1",
|
|
233
|
+
"resolved": "https://registry.npmjs.org/zrender/-/zrender-5.6.1.tgz",
|
|
234
|
+
"integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==",
|
|
235
|
+
"dependencies": {
|
|
236
|
+
"tslib": "2.3.0"
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "branded-echarts-scripts",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "Render brand-styled Apache ECharts charts to SVG + PNG, server-side (no browser).",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"render": "node render.mjs",
|
|
9
|
+
"all": "node render-all.mjs"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"echarts": "^5.5.1",
|
|
13
|
+
"@resvg/resvg-js": "^2.6.2"
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// render-all.mjs — render every chart spec in examples/ for every brand in brands/,
|
|
3
|
+
// writing out/<brand>/<chart>.svg (+ .png) and an out/index.html gallery you can open.
|
|
4
|
+
//
|
|
5
|
+
// node render-all.mjs
|
|
6
|
+
//
|
|
7
|
+
// This is the quickest way to eyeball the whole set and compare brands side by side.
|
|
8
|
+
|
|
9
|
+
import { readdirSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
10
|
+
import { resolve, join, basename, dirname } from 'node:path';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
import { renderChart } from './render.mjs';
|
|
13
|
+
|
|
14
|
+
const SCRIPTS = dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
const PKG = resolve(SCRIPTS, '..');
|
|
16
|
+
const BRANDS = join(PKG, 'brands');
|
|
17
|
+
const EXAMPLES = join(PKG, 'examples');
|
|
18
|
+
const OUT = join(PKG, 'out');
|
|
19
|
+
|
|
20
|
+
const jsonFiles = (dir) => readdirSync(dir).filter((f) => f.endsWith('.json')).sort();
|
|
21
|
+
|
|
22
|
+
async function main() {
|
|
23
|
+
const brands = jsonFiles(BRANDS).map((f) => ({
|
|
24
|
+
file: join(BRANDS, f), key: basename(f, '.json'), data: JSON.parse(readFileSync(join(BRANDS, f), 'utf8')),
|
|
25
|
+
}));
|
|
26
|
+
const specs = jsonFiles(EXAMPLES).map((f) => ({
|
|
27
|
+
key: basename(f, '.json'), data: JSON.parse(readFileSync(join(EXAMPLES, f), 'utf8')),
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
const gallery = {}; // brandKey -> [{chart, svg, png}]
|
|
31
|
+
let n = 0;
|
|
32
|
+
for (const brand of brands) {
|
|
33
|
+
const dir = join(OUT, brand.key);
|
|
34
|
+
mkdirSync(dir, { recursive: true });
|
|
35
|
+
gallery[brand.key] = [];
|
|
36
|
+
for (const spec of specs) {
|
|
37
|
+
const { svg, png } = await renderChart({ brand: brand.data, brandPath: brand.file, spec: spec.data, png: true });
|
|
38
|
+
const svgPath = join(dir, `${spec.key}.svg`);
|
|
39
|
+
writeFileSync(svgPath, svg);
|
|
40
|
+
let pngName = null;
|
|
41
|
+
if (png) { pngName = `${spec.key}.png`; writeFileSync(join(dir, pngName), png); }
|
|
42
|
+
// Keep the inline SVG markup so the gallery can be a single self-contained file.
|
|
43
|
+
gallery[brand.key].push({ chart: spec.key, svg: `${spec.key}.svg`, png: pngName, markup: svg });
|
|
44
|
+
n++;
|
|
45
|
+
}
|
|
46
|
+
console.log(`✓ ${brand.data.name || brand.key}: ${specs.length} charts → out/${brand.key}/`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Two galleries:
|
|
50
|
+
// • index.html — lightweight, references out/<brand>/*.png (open it IN PLACE).
|
|
51
|
+
// • gallery.html — SINGLE self-contained file (SVGs inlined): save/share it anywhere.
|
|
52
|
+
writeFileSync(join(OUT, 'index.html'), galleryHtml(brands, gallery, false));
|
|
53
|
+
writeFileSync(join(OUT, 'gallery.html'), galleryHtml(brands, gallery, true));
|
|
54
|
+
console.log(`\nRendered ${n} charts across ${brands.length} brands.`);
|
|
55
|
+
console.log(`Open in place: out/index.html`);
|
|
56
|
+
console.log(`Portable (save anywhere): out/gallery.html`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Give every chart's SVG element ids a unique namespace so many inlined SVGs on one
|
|
60
|
+
// page can't collide on clipPath/gradient ids (which are referenced by url(#id)).
|
|
61
|
+
function namespaceSvg(markup, tag) {
|
|
62
|
+
return markup
|
|
63
|
+
.replace(/\bid="([^"]+)"/g, (_, id) => `id="${tag}__${id}"`)
|
|
64
|
+
.replace(/url\(#([^)]+)\)/g, (_, id) => `url(#${tag}__${id})`)
|
|
65
|
+
.replace(/(xlink:href|href)="#([^"]+)"/g, (_, attr, id) => `${attr}="#${tag}__${id}"`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function galleryHtml(brands, gallery, inline) {
|
|
69
|
+
const sections = brands.map((b) => {
|
|
70
|
+
const cards = gallery[b.key].map((g, i) => {
|
|
71
|
+
const body = inline
|
|
72
|
+
? namespaceSvg(g.markup, `${b.key}_${i}`)
|
|
73
|
+
: `<img src="${b.key}/${g.png || g.svg}" alt="${g.chart}"/>`;
|
|
74
|
+
return `<figure>${body}<figcaption>${g.chart}</figcaption></figure>`;
|
|
75
|
+
}).join('\n');
|
|
76
|
+
return `<section><h2>${(b.data.name || b.key)} <small>(${b.data.mode || 'light'})</small></h2>
|
|
77
|
+
<div class="grid">${cards}</div></section>`;
|
|
78
|
+
}).join('\n');
|
|
79
|
+
const note = inline
|
|
80
|
+
? 'Self-contained — charts inlined as SVG (brand webfonts load from CDN). Works saved anywhere.'
|
|
81
|
+
: 'References the PNG files in this folder — open this file in place, do not move it.';
|
|
82
|
+
return `<!doctype html><meta charset="utf-8"><title>Branded ECharts — gallery</title>
|
|
83
|
+
<style>
|
|
84
|
+
body{margin:0;background:#101114;color:#e7e6e3;font:15px/1.5 system-ui,sans-serif;padding:32px}
|
|
85
|
+
h1{font-weight:700;letter-spacing:-.02em} h2{margin:36px 0 12px;border-bottom:1px solid #2a2c31;padding-bottom:8px}
|
|
86
|
+
small{color:#8a8f98;font-weight:400}
|
|
87
|
+
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(360px,1fr));gap:18px;align-items:start}
|
|
88
|
+
figure{margin:0;background:#17181c;border:1px solid #25272d;border-radius:12px;overflow:hidden}
|
|
89
|
+
figure img, figure svg{width:100%;height:auto;display:block}
|
|
90
|
+
figcaption{padding:8px 12px;color:#9aa0a8;font-size:13px;text-transform:capitalize}
|
|
91
|
+
</style>
|
|
92
|
+
<h1>Branded ECharts — visualization gallery</h1>
|
|
93
|
+
<p style="color:#9aa0a8">Same data, each brand's colours + fonts. ${note}</p>
|
|
94
|
+
${sections}`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
main().catch((e) => { console.error(e); process.exit(1); });
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// render.mjs — render one branded chart to SVG (+ PNG).
|
|
3
|
+
//
|
|
4
|
+
// node render.mjs --brand brands/onyx.json --chart examples/bar.json --out out/onyx/bar.svg
|
|
5
|
+
// node render.mjs --brand brands/apricot.json --chart examples/line.json --no-png
|
|
6
|
+
//
|
|
7
|
+
// Reads a brand token file + a chart spec file, server-side renders ECharts to SVG
|
|
8
|
+
// (no browser), embeds the brand @font-face, and writes SVG + a 2x PNG next to it.
|
|
9
|
+
|
|
10
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
11
|
+
import { dirname, resolve } from 'node:path';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
import * as echartsNS from 'echarts';
|
|
14
|
+
import { buildTheme } from './lib/theme.mjs';
|
|
15
|
+
import { buildOption } from './lib/charts.mjs';
|
|
16
|
+
import { embedFontFaces, resvgFontOptions, hasLocalFonts } from './lib/fonts.mjs';
|
|
17
|
+
|
|
18
|
+
const echarts = echartsNS.default || echartsNS;
|
|
19
|
+
let _themeSeq = 0;
|
|
20
|
+
|
|
21
|
+
// Render a chart. Returns { svg, png } where png is a Buffer or null.
|
|
22
|
+
export async function renderChart({ brand, brandPath, spec, png = true }) {
|
|
23
|
+
const theme = buildTheme(brand);
|
|
24
|
+
const { _tokens, ...themeForEcharts } = theme; // keep functions out of the registered theme
|
|
25
|
+
const name = `brand_${_themeSeq++}`;
|
|
26
|
+
echarts.registerTheme(name, themeForEcharts);
|
|
27
|
+
|
|
28
|
+
const width = spec.width || 900;
|
|
29
|
+
const height = spec.height || 560;
|
|
30
|
+
const chart = echarts.init(null, name, { renderer: 'svg', ssr: true, width, height });
|
|
31
|
+
chart.setOption(buildOption(spec, theme));
|
|
32
|
+
let svg = chart.renderToSVGString();
|
|
33
|
+
chart.dispose();
|
|
34
|
+
// Add a viewBox so the SVG scales to its container (gallery cards, embeds) while
|
|
35
|
+
// keeping aspect ratio; ECharts SSR emits fixed width/height but no viewBox.
|
|
36
|
+
if (!/<svg\b[^>]*\bviewBox=/.test(svg)) {
|
|
37
|
+
svg = svg.replace(/<svg\b/, `<svg viewBox="0 0 ${width} ${height}"`);
|
|
38
|
+
}
|
|
39
|
+
svg = embedFontFaces(svg, brand);
|
|
40
|
+
|
|
41
|
+
let pngBuf = null;
|
|
42
|
+
if (png) {
|
|
43
|
+
try {
|
|
44
|
+
const { Resvg } = await import('@resvg/resvg-js');
|
|
45
|
+
const r = new Resvg(svg, {
|
|
46
|
+
font: resvgFontOptions(brand, brandPath),
|
|
47
|
+
fitTo: { mode: 'width', value: width * 2 }, // 2x for crisp raster
|
|
48
|
+
background: brand.colors?.background || (brand.mode === 'dark' ? '#0a0a0a' : '#ffffff'),
|
|
49
|
+
});
|
|
50
|
+
pngBuf = r.render().asPng();
|
|
51
|
+
} catch (e) {
|
|
52
|
+
pngBuf = null;
|
|
53
|
+
if (!renderChart._warned) {
|
|
54
|
+
console.warn(` (PNG skipped: ${e.message}. SVG is unaffected.)`);
|
|
55
|
+
renderChart._warned = true;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return { svg, png: pngBuf };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function parseArgs(argv) {
|
|
63
|
+
const a = { png: true };
|
|
64
|
+
for (let i = 0; i < argv.length; i++) {
|
|
65
|
+
const k = argv[i];
|
|
66
|
+
if (k === '--brand') a.brand = argv[++i];
|
|
67
|
+
else if (k === '--chart') a.chart = argv[++i];
|
|
68
|
+
else if (k === '--out') a.out = argv[++i];
|
|
69
|
+
else if (k === '--no-png') a.png = false;
|
|
70
|
+
}
|
|
71
|
+
return a;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function main() {
|
|
75
|
+
const a = parseArgs(process.argv.slice(2));
|
|
76
|
+
if (!a.brand || !a.chart) {
|
|
77
|
+
console.error('Usage: node render.mjs --brand <brand.json> --chart <spec.json> [--out file.svg] [--no-png]');
|
|
78
|
+
process.exit(2);
|
|
79
|
+
}
|
|
80
|
+
const brand = JSON.parse(readFileSync(a.brand, 'utf8'));
|
|
81
|
+
const spec = JSON.parse(readFileSync(a.chart, 'utf8'));
|
|
82
|
+
const out = a.out || resolve('out', `${spec.type}.svg`);
|
|
83
|
+
mkdirSync(dirname(out), { recursive: true });
|
|
84
|
+
|
|
85
|
+
const { svg, png } = await renderChart({ brand, brandPath: a.brand, spec, png: a.png });
|
|
86
|
+
writeFileSync(out, svg);
|
|
87
|
+
console.log(`SVG → ${out}`);
|
|
88
|
+
if (png) { const p = out.replace(/\.svg$/, '.png'); writeFileSync(p, png); console.log(`PNG → ${p}`); }
|
|
89
|
+
if (a.png && !hasLocalFonts(brand, a.brand)) {
|
|
90
|
+
console.log(' note: PNG used a system-font fallback (no local font file for this brand). ' +
|
|
91
|
+
'SVG is font-accurate via the embedded webfont; set fonts.faces[].file for pixel-accurate PNG.');
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Run as CLI only when invoked directly (not when imported by render-all.mjs).
|
|
96
|
+
if (fileURLToPath(import.meta.url) === resolve(process.argv[1] || '')) {
|
|
97
|
+
main().catch((e) => { console.error(e); process.exit(1); });
|
|
98
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Functional test for the building-branded-echarts-visualizations skill.
|
|
4
|
+
|
|
5
|
+
It renders a chart with each demo brand by invoking the skill's own bundled, readable
|
|
6
|
+
render script (scripts/render.mjs) and asserts that the resulting SVG actually carries
|
|
7
|
+
that brand's colour and font — i.e. the brand styling really landed in the output.
|
|
8
|
+
|
|
9
|
+
python3 tests/test_building_branded_echarts_visualizations.py
|
|
10
|
+
|
|
11
|
+
Requires Node.js and `npm install` in scripts/. If either is missing the test SKIPS
|
|
12
|
+
(exit 0) with a clear message rather than failing — it has no other dependencies and
|
|
13
|
+
never touches the network or the environment.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import shutil
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
import tempfile
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
PKG = Path(__file__).resolve().parent.parent
|
|
24
|
+
SCRIPTS = PKG / "scripts"
|
|
25
|
+
RENDER = SCRIPTS / "render.mjs"
|
|
26
|
+
|
|
27
|
+
# (brand file, a hex from that brand's palette, a font-family that must appear)
|
|
28
|
+
CASES = [
|
|
29
|
+
("brands/onyx.json", "#f4f6f7", "Inter"),
|
|
30
|
+
("brands/apricot.json", "#ffe0c2", "Inter"),
|
|
31
|
+
]
|
|
32
|
+
CHART = "examples/01_bar.json"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _skip(msg: str) -> None:
|
|
36
|
+
print(f"SKIPPED: {msg}")
|
|
37
|
+
sys.exit(0)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def render_svg(brand_rel: str, out: Path) -> str:
|
|
41
|
+
"""Run the skill's render script for one brand+chart; return the SVG text."""
|
|
42
|
+
cmd = [
|
|
43
|
+
"node", str(RENDER),
|
|
44
|
+
"--brand", str(PKG / brand_rel),
|
|
45
|
+
"--chart", str(PKG / CHART),
|
|
46
|
+
"--out", str(out),
|
|
47
|
+
"--no-png", # SVG is the font-accurate output; PNG needs local fonts
|
|
48
|
+
]
|
|
49
|
+
subprocess.run(cmd, cwd=str(SCRIPTS), check=True, capture_output=True, text=True, timeout=120)
|
|
50
|
+
return out.read_text(encoding="utf-8")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def main() -> None:
|
|
54
|
+
if shutil.which("node") is None:
|
|
55
|
+
_skip("Node.js not found — install Node, then `cd scripts && npm install`.")
|
|
56
|
+
if not (SCRIPTS / "node_modules" / "echarts").exists():
|
|
57
|
+
_skip("dependencies not installed — run `cd scripts && npm install`.")
|
|
58
|
+
|
|
59
|
+
failures = []
|
|
60
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
61
|
+
for brand_rel, hex_code, font in CASES:
|
|
62
|
+
out = Path(tmp) / (Path(brand_rel).stem + ".svg")
|
|
63
|
+
try:
|
|
64
|
+
svg = render_svg(brand_rel, out)
|
|
65
|
+
except subprocess.CalledProcessError as e:
|
|
66
|
+
failures.append(f"{brand_rel}: render failed — {e.stderr.strip()[:200]}")
|
|
67
|
+
continue
|
|
68
|
+
|
|
69
|
+
brand = json.loads((PKG / brand_rel).read_text(encoding="utf-8"))
|
|
70
|
+
checks = {
|
|
71
|
+
"is an <svg>": svg.lstrip().startswith("<svg") or "<svg" in svg[:200],
|
|
72
|
+
f"contains brand colour {hex_code}": hex_code.lower() in svg.lower(),
|
|
73
|
+
f"references brand font '{font}'": font in svg,
|
|
74
|
+
"embeds @font-face": "@font-face" in svg,
|
|
75
|
+
}
|
|
76
|
+
missing = [name for name, ok in checks.items() if not ok]
|
|
77
|
+
label = brand.get("name", brand_rel)
|
|
78
|
+
if missing:
|
|
79
|
+
failures.append(f"{label}: missing → {', '.join(missing)}")
|
|
80
|
+
else:
|
|
81
|
+
print(f" PASS {label}: brand colour + font present in SVG")
|
|
82
|
+
|
|
83
|
+
if failures:
|
|
84
|
+
print("\nFAILED:")
|
|
85
|
+
for f in failures:
|
|
86
|
+
print(f" - {f}")
|
|
87
|
+
sys.exit(1)
|
|
88
|
+
print("\nPASSED")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
if __name__ == "__main__":
|
|
92
|
+
main()
|