@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,66 @@
|
|
|
1
|
+
# Chart-type recipes
|
|
2
|
+
|
|
3
|
+
Each chart spec is `{ "type": <builder>, "title", "subtitle", "width", "height", "data": {…} }`.
|
|
4
|
+
Builders live in `scripts/lib/charts.mjs`. Below: the `data` shape and the brand tokens each type
|
|
5
|
+
leans on. See `examples/*.json` for a runnable spec of every type.
|
|
6
|
+
|
|
7
|
+
## Categories × values
|
|
8
|
+
|
|
9
|
+
**`bar` / `groupedBar` / `stackedBar`** — `data: { categories: [], series: [{ name, data: [] }] }`
|
|
10
|
+
- One series → `bar`; many → grouped; `stackedBar` stacks them (`stack:'total'`), rounding only the top.
|
|
11
|
+
- Tokens: `categorical` (series), `bar.itemStyle.borderRadius` (`shape.barRadius`), value axis grid.
|
|
12
|
+
|
|
13
|
+
**`hbar`** — same `data`; bars run horizontal (category on Y). Good for ranked lists / long labels.
|
|
14
|
+
|
|
15
|
+
## Trends
|
|
16
|
+
|
|
17
|
+
**`line`** — `data: { categories: [], series: [{ name, data: [] }] }`. Smooth, `shape.lineWidth`, markers `shape.symbolSize`.
|
|
18
|
+
|
|
19
|
+
**`area`** — same `data`; adds a vertical gradient fill per series (series color → transparent).
|
|
20
|
+
Use for volume/cumulative; cap at ~3 series so fills don't muddy.
|
|
21
|
+
|
|
22
|
+
## Parts of a whole
|
|
23
|
+
|
|
24
|
+
**`pie`** — `data: { items: [{ name, value }] }`. Radius ~66%, legend on the right.
|
|
25
|
+
|
|
26
|
+
**`donut`** — same `data` + optional `centerLabel` (e.g. a total). Inner/outer radius ring with a
|
|
27
|
+
centered figure. Prefer over pie when you want a headline number in the middle.
|
|
28
|
+
|
|
29
|
+
## Relationships
|
|
30
|
+
|
|
31
|
+
**`scatter`** — `data: { series: [{ name, points: [[x, y] | [x, y, size]] }] }, xName, yName`.
|
|
32
|
+
A 3rd value per point drives bubble size. Tokens: `categorical`, `scatter` symbol defaults.
|
|
33
|
+
|
|
34
|
+
## Single numbers
|
|
35
|
+
|
|
36
|
+
**`kpi`** — `data: { cards: [{ label, value, delta, deltaDir: 'up'|'down' }] }`. Renders graphic
|
|
37
|
+
number cards (surface panel, big value in the title font, ▲/▼ delta colored by `positive`/`negative`).
|
|
38
|
+
Set a wide-ish `width`, short `height` (~280). 3–5 cards reads best.
|
|
39
|
+
|
|
40
|
+
## Matrices
|
|
41
|
+
|
|
42
|
+
**`heatmap`** — `data: { x: [], y: [], values: [[xi, yi, v]] }, showValues?`. **Magnitude uses a
|
|
43
|
+
sequential single-hue ramp** built from the primary brand color (`seqRamp`), *not* the categorical
|
|
44
|
+
palette — that keeps it readable. `visualMap` legend sits below.
|
|
45
|
+
|
|
46
|
+
## Multi-dimensional
|
|
47
|
+
|
|
48
|
+
**`radar`** — `data: { indicators: [{ name, max }], series: [{ name, value: [] }] }`. Translucent
|
|
49
|
+
fills per series; axis/split lines use brand grid color. Keep to 2–3 series.
|
|
50
|
+
|
|
51
|
+
**`treemap`** — `data: { nodes: [{ name, value, children? }] }`. Nested rectangles sized by value;
|
|
52
|
+
gaps/borders use the background color so it reads on light or dark.
|
|
53
|
+
|
|
54
|
+
## Composite
|
|
55
|
+
|
|
56
|
+
**`dashboard`** — `data: { cards: [...], bar: { categories, series }, line: { categories, series } }`.
|
|
57
|
+
A KPI strip across the top (graphic) + a grouped bar (left grid) and a line (right grid) side by
|
|
58
|
+
side. Use a large canvas (e.g. 1200×760). This is the "one image, whole story" board.
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
### General tips
|
|
63
|
+
|
|
64
|
+
- Width/height: wide for time series and dashboards; squarer for pie/radar/treemap.
|
|
65
|
+
- Titles: short `title` + a `subtitle` carrying units ("$M", "thousands", date range).
|
|
66
|
+
- Series count: beyond ~6, the palette auto-extends, but consider grouping or small multiples.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# ECharts theming model + server-side rendering
|
|
2
|
+
|
|
3
|
+
How Apache ECharts theming and headless rendering work, and how this skill uses them.
|
|
4
|
+
|
|
5
|
+
## The theme object
|
|
6
|
+
|
|
7
|
+
ECharts merges a **theme object** under every option you set. Register it once, then init charts
|
|
8
|
+
with its name:
|
|
9
|
+
|
|
10
|
+
```js
|
|
11
|
+
import * as echarts from 'echarts';
|
|
12
|
+
echarts.registerTheme('brand', themeObject);
|
|
13
|
+
const chart = echarts.init(null, 'brand', { renderer: 'svg', ssr: true, width, height });
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
A theme object is plain data. The keys this skill sets (`lib/theme.mjs`):
|
|
17
|
+
|
|
18
|
+
| Key | Controls |
|
|
19
|
+
|---|---|
|
|
20
|
+
| `color: []` | The categorical series palette (cycled across series). |
|
|
21
|
+
| `backgroundColor` | Canvas background. |
|
|
22
|
+
| `textStyle` | Global font family / color / size — the base every text inherits. |
|
|
23
|
+
| `title`, `legend`, `tooltip` | Component typography + colors. |
|
|
24
|
+
| `categoryAxis`, `valueAxis`, `timeAxis`, `logAxis` | Axis line, ticks, labels, and `splitLine` per axis kind. |
|
|
25
|
+
| `bar`, `line`, `scatter`, `pie`, `radar`, … | Per-series-type defaults (e.g. `bar.itemStyle.borderRadius`, `line.lineStyle.width`, `line.symbolSize`). |
|
|
26
|
+
|
|
27
|
+
Anything not in the theme falls back to ECharts defaults; anything an option sets overrides the
|
|
28
|
+
theme. So builders only set **data + type-specific tweaks**; the brand look lives entirely in the theme.
|
|
29
|
+
|
|
30
|
+
Reference: <https://echarts.apache.org/en/option.html> (every property), and the built-in `dark`
|
|
31
|
+
theme is a good shape reference for the keys above.
|
|
32
|
+
|
|
33
|
+
## Server-side rendering to SVG (no browser)
|
|
34
|
+
|
|
35
|
+
ECharts supports SSR with the SVG renderer — no DOM, no canvas, no headless browser:
|
|
36
|
+
|
|
37
|
+
```js
|
|
38
|
+
const chart = echarts.init(null, theme, { renderer: 'svg', ssr: true, width: 900, height: 540 });
|
|
39
|
+
chart.setOption(option);
|
|
40
|
+
const svg = chart.renderToSVGString(); // a complete <svg>…</svg> string
|
|
41
|
+
chart.dispose();
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Notes:
|
|
45
|
+
- Set `option.animation = false` for static output (no transition artifacts).
|
|
46
|
+
- `width`/`height` are required in SSR (there's no element to measure).
|
|
47
|
+
- Output is a self-contained SVG; this skill then injects `@font-face` so the brand font renders
|
|
48
|
+
(SVG references fonts by name only — see `fonts-and-svg-embedding.md`).
|
|
49
|
+
|
|
50
|
+
Reference: <https://echarts.apache.org/handbook/en/how-to/cross-platform/server/>
|
|
51
|
+
|
|
52
|
+
## Gradients & visual encodings in pure option JSON
|
|
53
|
+
|
|
54
|
+
You don't need the `echarts.graphic.*` classes — the option accepts the object forms:
|
|
55
|
+
|
|
56
|
+
```js
|
|
57
|
+
areaStyle: { color: { type: 'linear', x: 0, y: 0, x2: 0, y2: 1,
|
|
58
|
+
colorStops: [ { offset: 0, color: 'rgba(37,99,235,.35)' }, { offset: 1, color: 'rgba(37,99,235,.02)' } ] } }
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Heatmaps/treemaps encode magnitude with `visualMap.inRange.color` — use a **sequential single-hue
|
|
62
|
+
ramp**, not the categorical palette (see `chart-type-recipes.md`).
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# Fonts: SVG embedding + PNG rasterization
|
|
2
|
+
|
|
3
|
+
The trickiest part of "branded" charts is making the brand **font** actually appear. ECharts
|
|
4
|
+
writes `font-family="Inter"` onto text nodes, but neither the SVG nor a rasterizer has the font
|
|
5
|
+
data. Handled in `scripts/lib/fonts.mjs`.
|
|
6
|
+
|
|
7
|
+
## Why we never bundle font binaries
|
|
8
|
+
|
|
9
|
+
The Loreto marketplace strips binary fonts (`.ttf`, `.otf`, `.woff`, `.woff2`) from published
|
|
10
|
+
packages, and they bloat the bundle. So fonts are **referenced**, not shipped:
|
|
11
|
+
- `fonts.faces[].url` — a webfont URL (woff2) for the SVG.
|
|
12
|
+
- `fonts.faces[].file` — an *optional* local font path for pixel-accurate PNG (not committed).
|
|
13
|
+
|
|
14
|
+
## SVG → font-accurate (the source of truth)
|
|
15
|
+
|
|
16
|
+
After ECharts produces the SVG, we inject an `@font-face` block built from the brand's `faces[]`
|
|
17
|
+
URLs, right after the opening `<svg>` tag:
|
|
18
|
+
|
|
19
|
+
```xml
|
|
20
|
+
<svg …><defs><style type="text/css"><![CDATA[
|
|
21
|
+
@font-face{font-family:'Inter';font-weight:400;src:url('https://…/inter-400.woff2') format('woff2');}
|
|
22
|
+
@font-face{font-family:'Space Grotesk';font-weight:600;src:url('https://…/space-grotesk-600.woff2') format('woff2');}
|
|
23
|
+
]]></style></defs> … </svg>
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
When the `.svg` is opened in any browser (or embedded in a page), the browser fetches the woff2 and
|
|
27
|
+
renders the real brand typeface. Good webfont sources: **fontsource via jsDelivr**
|
|
28
|
+
(`https://cdn.jsdelivr.net/fontsource/fonts/<family>@latest/latin-<weight>-normal.woff2`) or Google Fonts.
|
|
29
|
+
|
|
30
|
+
## PNG → resvg
|
|
31
|
+
|
|
32
|
+
PNG is produced from the SVG by `@resvg/resvg-js` (prebuilt native, no system toolchain). resvg
|
|
33
|
+
**cannot fetch remote `@font-face` URLs** — it renders with locally available fonts:
|
|
34
|
+
|
|
35
|
+
```js
|
|
36
|
+
new Resvg(svg, { font: {
|
|
37
|
+
fontFiles: [ /* faces[].file paths that exist on disk */ ],
|
|
38
|
+
loadSystemFonts: true,
|
|
39
|
+
defaultFontFamily: brand.fonts.family,
|
|
40
|
+
}}).render().asPng();
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
So PNG font behavior:
|
|
44
|
+
- If the brand font is **installed on the machine** or a `faces[].file` path is provided → pixel-accurate.
|
|
45
|
+
- Otherwise → resvg falls back to a system font. **Colors, layout, and sizing stay correct**; only the
|
|
46
|
+
typeface differs. The SVG remains font-accurate.
|
|
47
|
+
|
|
48
|
+
To make PNGs font-accurate for a brand, either install the font locally or add a `file` to its faces:
|
|
49
|
+
|
|
50
|
+
```json
|
|
51
|
+
{ "family": "Inter", "weight": 400, "url": "https://…/inter-400.woff2", "file": "fonts/Inter-Regular.ttf" }
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
(resvg wants `.ttf`/`.otf` for `fontFiles`; `.woff2` is for the SVG `@font-face`.)
|
|
55
|
+
|
|
56
|
+
## Rule of thumb
|
|
57
|
+
|
|
58
|
+
- Need crisp, scalable, font-accurate output for web/print → **use the SVG**.
|
|
59
|
+
- Need a raster for slides/email and you have the font installed or as a local file → **PNG is accurate**.
|
|
60
|
+
- PNG without the font locally → still fine for a quick look; ship the SVG for the final brand-perfect asset.
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
// charts.mjs — per-type ECharts `option` builders.
|
|
2
|
+
//
|
|
3
|
+
// Each builder takes (spec, theme) and returns an ECharts option. All shared styling
|
|
4
|
+
// (colours, fonts, axes, legend) comes from the registered theme, so builders only set
|
|
5
|
+
// data + type-specific touches. `spec.data` shape is documented per builder below.
|
|
6
|
+
|
|
7
|
+
import { withAlpha, mix, contrastRatio } from './palette.mjs';
|
|
8
|
+
|
|
9
|
+
// A readable sequential single-hue ramp (low→high) from a base brand colour, so a
|
|
10
|
+
// heatmap encodes magnitude monotonically instead of as an unreadable rainbow.
|
|
11
|
+
function seqRamp(base, bg) {
|
|
12
|
+
return [mix(base, bg, 0.88), mix(base, bg, 0.5), base, mix(base, '#ffffff', 0.22)];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Pick black or white for a label so it stays legible on any tile fill.
|
|
16
|
+
function labelOn(fill) {
|
|
17
|
+
return contrastRatio('#ffffff', fill) >= contrastRatio('#10100f', fill) ? '#ffffff' : '#10100f';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Recursively colour a treemap: each top segment gets a brand colour; children get a
|
|
21
|
+
// shade of their parent (same hue, nudged toward the background) so the segment reads
|
|
22
|
+
// as a family — and every node's label auto-contrasts against its own fill.
|
|
23
|
+
function colorizeTree(nodes, theme, depth = 0, parentColor = null) {
|
|
24
|
+
return (nodes || []).map((n, i) => {
|
|
25
|
+
const fill = parentColor ? mix(parentColor, theme._tokens.bg, 0.16 * depth) : theme.color[i % theme.color.length];
|
|
26
|
+
const node = { ...n, itemStyle: { color: fill }, label: { color: labelOn(fill) } };
|
|
27
|
+
if (n.children) node.children = colorizeTree(n.children, theme, depth + 1, fill);
|
|
28
|
+
return node;
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const titleBlock = (spec) => ({
|
|
33
|
+
title: { text: spec.title || '', subtext: spec.subtitle || '', left: 0, top: 14 },
|
|
34
|
+
});
|
|
35
|
+
const legendTop = (show) => (show ? { legend: { top: 18, right: 0 } } : {});
|
|
36
|
+
const axisTrigger = { tooltip: { trigger: 'axis' } };
|
|
37
|
+
|
|
38
|
+
// ── Bars ────────────────────────────────────────────────────────────────────
|
|
39
|
+
// spec.data = { categories: [...], series: [{ name, data: [...] }] }
|
|
40
|
+
function bar(spec, theme) {
|
|
41
|
+
const d = spec.data;
|
|
42
|
+
const multi = d.series.length > 1;
|
|
43
|
+
return {
|
|
44
|
+
...titleBlock(spec), ...legendTop(multi), ...axisTrigger,
|
|
45
|
+
xAxis: { type: 'category', data: d.categories },
|
|
46
|
+
yAxis: { type: 'value', name: spec.yName || '' },
|
|
47
|
+
series: d.series.map((s) => ({ type: 'bar', name: s.name, data: s.data })),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
function groupedBar(spec, theme) { return bar(spec, theme); }
|
|
51
|
+
function stackedBar(spec, theme) {
|
|
52
|
+
const opt = bar(spec, theme);
|
|
53
|
+
opt.series = opt.series.map((s) => ({ ...s, stack: 'total', itemStyle: { borderRadius: 0 } }));
|
|
54
|
+
if (opt.series.length) {
|
|
55
|
+
const r = theme._tokens.shape.barRadius;
|
|
56
|
+
const top = opt.series[opt.series.length - 1];
|
|
57
|
+
top.itemStyle = { borderRadius: [r, r, 0, 0] };
|
|
58
|
+
}
|
|
59
|
+
return opt;
|
|
60
|
+
}
|
|
61
|
+
function hbar(spec, theme) {
|
|
62
|
+
const d = spec.data;
|
|
63
|
+
const multi = d.series.length > 1;
|
|
64
|
+
return {
|
|
65
|
+
...titleBlock(spec), ...legendTop(multi), ...axisTrigger,
|
|
66
|
+
xAxis: { type: 'value', name: spec.xName || '' },
|
|
67
|
+
yAxis: { type: 'category', data: d.categories, axisLabel: { color: theme._tokens.muted } },
|
|
68
|
+
series: d.series.map((s) => ({
|
|
69
|
+
type: 'bar', name: s.name, data: s.data,
|
|
70
|
+
itemStyle: { borderRadius: [0, theme._tokens.shape.barRadius, theme._tokens.shape.barRadius, 0] },
|
|
71
|
+
})),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── Lines / area ──────────────────────────────────────────────────────────────
|
|
76
|
+
// spec.data = { categories: [...], series: [{ name, data: [...] }] }
|
|
77
|
+
function line(spec, theme) {
|
|
78
|
+
const d = spec.data;
|
|
79
|
+
const multi = d.series.length > 1;
|
|
80
|
+
return {
|
|
81
|
+
...titleBlock(spec), ...legendTop(multi), ...axisTrigger,
|
|
82
|
+
xAxis: { type: 'category', boundaryGap: false, data: d.categories },
|
|
83
|
+
yAxis: { type: 'value', name: spec.yName || '' },
|
|
84
|
+
series: d.series.map((s) => ({ type: 'line', name: s.name, data: s.data })),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
function area(spec, theme) {
|
|
88
|
+
const opt = line(spec, theme);
|
|
89
|
+
opt.series = opt.series.map((s, i) => ({
|
|
90
|
+
...s,
|
|
91
|
+
areaStyle: {
|
|
92
|
+
opacity: 0.9,
|
|
93
|
+
color: {
|
|
94
|
+
type: 'linear', x: 0, y: 0, x2: 0, y2: 1,
|
|
95
|
+
colorStops: [
|
|
96
|
+
{ offset: 0, color: withAlpha(theme.color[i % theme.color.length], 0.35) },
|
|
97
|
+
{ offset: 1, color: withAlpha(theme.color[i % theme.color.length], 0.02) },
|
|
98
|
+
],
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
}));
|
|
102
|
+
return opt;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ── Pie / donut ───────────────────────────────────────────────────────────────
|
|
106
|
+
// spec.data = { items: [{ name, value }] }
|
|
107
|
+
function pie(spec, theme) {
|
|
108
|
+
return {
|
|
109
|
+
...titleBlock(spec),
|
|
110
|
+
tooltip: { trigger: 'item' },
|
|
111
|
+
legend: { top: 'middle', right: 0, orient: 'vertical' },
|
|
112
|
+
series: [{
|
|
113
|
+
type: 'pie', radius: '66%', center: ['42%', '56%'],
|
|
114
|
+
data: spec.data.items, avoidLabelOverlap: true,
|
|
115
|
+
label: { formatter: '{b}\n{d}%' },
|
|
116
|
+
}],
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
function donut(spec, theme) {
|
|
120
|
+
const total = spec.data.items.reduce((a, x) => a + (x.value || 0), 0);
|
|
121
|
+
return {
|
|
122
|
+
...titleBlock(spec),
|
|
123
|
+
tooltip: { trigger: 'item' },
|
|
124
|
+
legend: { top: 'middle', right: 0, orient: 'vertical' },
|
|
125
|
+
graphic: {
|
|
126
|
+
type: 'text', left: '40%', top: '52%',
|
|
127
|
+
style: {
|
|
128
|
+
text: spec.centerLabel || total.toLocaleString(),
|
|
129
|
+
fill: theme._tokens.text, font: `600 26px ${theme._tokens.titleFamily}`,
|
|
130
|
+
textAlign: 'center',
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
series: [{
|
|
134
|
+
type: 'pie', radius: ['46%', '70%'], center: ['42%', '56%'],
|
|
135
|
+
data: spec.data.items, avoidLabelOverlap: true,
|
|
136
|
+
label: { formatter: '{b}\n{d}%' },
|
|
137
|
+
}],
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ── Scatter ───────────────────────────────────────────────────────────────────
|
|
142
|
+
// spec.data = { series: [{ name, points: [[x,y],[x,y,size]...] }], xName, yName }
|
|
143
|
+
function scatter(spec, theme) {
|
|
144
|
+
const d = spec.data;
|
|
145
|
+
return {
|
|
146
|
+
...titleBlock(spec), ...legendTop(d.series.length > 1),
|
|
147
|
+
tooltip: { trigger: 'item' },
|
|
148
|
+
xAxis: { type: 'value', name: spec.xName || '', scale: true },
|
|
149
|
+
yAxis: { type: 'value', name: spec.yName || '', scale: true },
|
|
150
|
+
series: d.series.map((s) => ({
|
|
151
|
+
type: 'scatter', name: s.name,
|
|
152
|
+
symbolSize: (p) => (p[2] ? Math.max(6, Math.sqrt(p[2]) * 2.2) : theme._tokens.shape.symbolSize + 4),
|
|
153
|
+
data: s.points,
|
|
154
|
+
})),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ── KPI number cards (graphic-based) ────────────────────────────────────────────
|
|
159
|
+
// spec.data = { cards: [{ label, value, delta, deltaDir: 'up'|'down' }] }
|
|
160
|
+
function kpi(spec, theme) {
|
|
161
|
+
const t = theme._tokens;
|
|
162
|
+
const W = spec.width || 1000, H = spec.height || 280;
|
|
163
|
+
const top = (spec.title ? 86 : 28), bottom = 24, gap = 20, padX = 2;
|
|
164
|
+
const cards = spec.data.cards || [];
|
|
165
|
+
const cardW = (W - padX * 2 - gap * (cards.length - 1)) / Math.max(1, cards.length);
|
|
166
|
+
const cardH = H - top - bottom;
|
|
167
|
+
const txt = (text, x, y, font, fill, align = 'left') =>
|
|
168
|
+
({ type: 'text', x, y, style: { text, font, fill, textAlign: align, textVerticalAlign: 'top' } });
|
|
169
|
+
const els = [];
|
|
170
|
+
cards.forEach((card, i) => {
|
|
171
|
+
const x = padX + i * (cardW + gap);
|
|
172
|
+
els.push({ type: 'rect', x, y: top, shape: { x: 0, y: 0, width: cardW, height: cardH, r: 12 },
|
|
173
|
+
style: { fill: t.surface, stroke: t.grid, lineWidth: 1 } });
|
|
174
|
+
els.push(txt(String(card.label || '').toUpperCase(), x + 22, top + 22,
|
|
175
|
+
`600 ${t.base - 2}px ${t.family}`, t.muted));
|
|
176
|
+
els.push(txt(String(card.value ?? ''), x + 22, top + 46,
|
|
177
|
+
`700 ${Math.round(t.base * 2.6)}px ${t.titleFamily}`, t.text));
|
|
178
|
+
if (card.delta != null) {
|
|
179
|
+
const up = card.deltaDir ? card.deltaDir === 'up' : Number(String(card.delta).replace(/[^\-0-9.]/g, '')) >= 0;
|
|
180
|
+
els.push(txt(`${up ? '▲' : '▼'} ${card.delta}`, x + 22, top + 46 + Math.round(t.base * 2.6) + 12,
|
|
181
|
+
`600 ${t.base}px ${t.family}`, up ? t.positive : t.negative));
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
return { ...titleBlock(spec), graphic: { elements: els } };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ── Heatmap ─────────────────────────────────────────────────────────────────────
|
|
188
|
+
// spec.data = { x: [...], y: [...], values: [[xi, yi, v], ...] }
|
|
189
|
+
function heatmap(spec, theme) {
|
|
190
|
+
const d = spec.data;
|
|
191
|
+
const vals = d.values.map((v) => v[2]);
|
|
192
|
+
return {
|
|
193
|
+
...titleBlock(spec),
|
|
194
|
+
tooltip: { position: 'top' },
|
|
195
|
+
grid: { top: 84, bottom: 90, left: 60, right: 28, containLabel: true },
|
|
196
|
+
xAxis: { type: 'category', data: d.x, splitArea: { show: true } },
|
|
197
|
+
yAxis: { type: 'category', data: d.y, splitArea: { show: true } },
|
|
198
|
+
visualMap: {
|
|
199
|
+
min: Math.min(...vals), max: Math.max(...vals), calculable: true,
|
|
200
|
+
orient: 'horizontal', left: 'center', bottom: 10,
|
|
201
|
+
inRange: { color: seqRamp(theme._tokens.color[0], theme._tokens.bg) },
|
|
202
|
+
textStyle: { color: theme._tokens.muted },
|
|
203
|
+
},
|
|
204
|
+
series: [{
|
|
205
|
+
type: 'heatmap', data: d.values,
|
|
206
|
+
label: { show: !!spec.showValues, color: theme._tokens.text },
|
|
207
|
+
itemStyle: { borderColor: theme._tokens.bg, borderWidth: 2 },
|
|
208
|
+
emphasis: { itemStyle: { shadowBlur: 8, shadowColor: withAlpha(theme._tokens.text, 0.3) } },
|
|
209
|
+
}],
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ── Radar ───────────────────────────────────────────────────────────────────────
|
|
214
|
+
// spec.data = { indicators: [{ name, max }], series: [{ name, value: [...] }] }
|
|
215
|
+
function radar(spec, theme) {
|
|
216
|
+
const t = theme._tokens;
|
|
217
|
+
return {
|
|
218
|
+
...titleBlock(spec), ...legendTop(spec.data.series.length > 1),
|
|
219
|
+
tooltip: {},
|
|
220
|
+
radar: {
|
|
221
|
+
indicator: spec.data.indicators, center: ['50%', '56%'], radius: '64%',
|
|
222
|
+
axisName: { color: t.muted, fontFamily: t.family },
|
|
223
|
+
axisLine: { lineStyle: { color: t.grid } },
|
|
224
|
+
splitLine: { lineStyle: { color: t.grid } },
|
|
225
|
+
splitArea: { areaStyle: { color: [withAlpha(t.text, 0.02), withAlpha(t.text, 0.05)] } },
|
|
226
|
+
},
|
|
227
|
+
series: [{
|
|
228
|
+
type: 'radar',
|
|
229
|
+
data: spec.data.series.map((s) => ({
|
|
230
|
+
name: s.name, value: s.value, areaStyle: { opacity: 0.18 }, lineStyle: { width: t.shape.lineWidth },
|
|
231
|
+
})),
|
|
232
|
+
}],
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ── Treemap ─────────────────────────────────────────────────────────────────────
|
|
237
|
+
// spec.data = { nodes: [{ name, value, children? }] }
|
|
238
|
+
function treemap(spec, theme) {
|
|
239
|
+
const t = theme._tokens;
|
|
240
|
+
return {
|
|
241
|
+
...titleBlock(spec),
|
|
242
|
+
tooltip: {},
|
|
243
|
+
series: [{
|
|
244
|
+
type: 'treemap', top: 78, roam: false, nodeClick: false,
|
|
245
|
+
breadcrumb: { show: false },
|
|
246
|
+
itemStyle: { borderColor: t.bg, borderWidth: 2, gapWidth: 2 },
|
|
247
|
+
label: { fontFamily: t.family, fontSize: t.base },
|
|
248
|
+
upperLabel: { show: false },
|
|
249
|
+
levels: [{ itemStyle: { borderWidth: 0, gapWidth: 2 } }],
|
|
250
|
+
data: colorizeTree(spec.data.nodes, theme),
|
|
251
|
+
}],
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ── Dashboard (composite: KPI strip + grouped bar + line, side by side) ──────────
|
|
256
|
+
// spec.data = { cards: [...], bar: {categories, series}, line: {categories, series} }
|
|
257
|
+
function dashboard(spec, theme) {
|
|
258
|
+
const t = theme._tokens;
|
|
259
|
+
const W = spec.width || 1200, H = spec.height || 720;
|
|
260
|
+
const kpiOpt = kpi({ ...spec, title: spec.title, subtitle: spec.subtitle, width: W, height: 200,
|
|
261
|
+
data: { cards: spec.data.cards || [] } }, theme);
|
|
262
|
+
const stripH = 200;
|
|
263
|
+
return {
|
|
264
|
+
...titleBlock(spec),
|
|
265
|
+
graphic: kpiOpt.graphic,
|
|
266
|
+
tooltip: { trigger: 'axis' },
|
|
267
|
+
legend: [
|
|
268
|
+
{ top: stripH + 6, left: '2%', data: (spec.data.bar.series || []).map((s) => s.name) },
|
|
269
|
+
{ top: stripH + 6, left: '54%', data: (spec.data.line.series || []).map((s) => s.name) },
|
|
270
|
+
],
|
|
271
|
+
grid: [
|
|
272
|
+
{ left: '2%', right: '52%', top: stripH + 48, bottom: 36, containLabel: true },
|
|
273
|
+
{ left: '52%', right: '2%', top: stripH + 48, bottom: 36, containLabel: true },
|
|
274
|
+
],
|
|
275
|
+
xAxis: [
|
|
276
|
+
{ gridIndex: 0, type: 'category', data: spec.data.bar.categories },
|
|
277
|
+
{ gridIndex: 1, type: 'category', boundaryGap: false, data: spec.data.line.categories },
|
|
278
|
+
],
|
|
279
|
+
yAxis: [{ gridIndex: 0, type: 'value' }, { gridIndex: 1, type: 'value' }],
|
|
280
|
+
series: [
|
|
281
|
+
...spec.data.bar.series.map((s) => ({ type: 'bar', name: s.name, data: s.data, xAxisIndex: 0, yAxisIndex: 0 })),
|
|
282
|
+
...spec.data.line.series.map((s) => ({ type: 'line', name: s.name, data: s.data, xAxisIndex: 1, yAxisIndex: 1 })),
|
|
283
|
+
],
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export const builders = {
|
|
288
|
+
bar, groupedBar, stackedBar, hbar, line, area, pie, donut, scatter, kpi, heatmap, radar, treemap, dashboard,
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
export function buildOption(spec, theme) {
|
|
292
|
+
const b = builders[spec.type];
|
|
293
|
+
if (!b) throw new Error(`Unknown chart type "${spec.type}". Known: ${Object.keys(builders).join(', ')}`);
|
|
294
|
+
const option = b(spec, theme);
|
|
295
|
+
option.animation = false; // static render
|
|
296
|
+
if (option.backgroundColor === undefined) option.backgroundColor = theme.backgroundColor;
|
|
297
|
+
return option;
|
|
298
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// fonts.mjs — make brand fonts actually render in the output.
|
|
2
|
+
//
|
|
3
|
+
// SVG: ECharts writes `font-family="…"` onto text nodes, but the file has no font data.
|
|
4
|
+
// We inject an @font-face <style> (pointing at each face's webfont URL) so a browser
|
|
5
|
+
// fetches and uses the real brand font when the .svg is opened.
|
|
6
|
+
//
|
|
7
|
+
// PNG: resvg cannot fetch remote @font-face URLs. It uses locally-available fonts — any
|
|
8
|
+
// `file` paths declared on the brand's faces, plus the system fonts. If the brand font
|
|
9
|
+
// isn't installed and no local file is given, PNG falls back to a system font (colours
|
|
10
|
+
// and layout stay correct; only the typeface differs). SVG is the font-accurate output.
|
|
11
|
+
//
|
|
12
|
+
// We never bundle binary font files in the package (the marketplace skips them); fonts
|
|
13
|
+
// are referenced by URL (for SVG) and optionally by a local path (for pixel-accurate PNG).
|
|
14
|
+
|
|
15
|
+
import { existsSync } from 'node:fs';
|
|
16
|
+
import { resolve, dirname } from 'node:path';
|
|
17
|
+
|
|
18
|
+
function faces(brand) {
|
|
19
|
+
const f = brand.fonts || {};
|
|
20
|
+
if (Array.isArray(f.faces) && f.faces.length) return f.faces;
|
|
21
|
+
// Minimal fallback: one face from the primary family with an optional single URL.
|
|
22
|
+
if (f.family && f.url) return [{ family: f.family, weight: f.weights?.regular || 400, url: f.url }];
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Build the @font-face CSS block from the brand's declared webfont URLs.
|
|
27
|
+
export function fontFaceCss(brand) {
|
|
28
|
+
const rules = faces(brand)
|
|
29
|
+
.filter((x) => x.url)
|
|
30
|
+
.map((x) => {
|
|
31
|
+
const fmt = x.url.endsWith('.woff2') ? 'woff2' : x.url.endsWith('.woff') ? 'woff'
|
|
32
|
+
: x.url.endsWith('.otf') ? 'opentype' : 'truetype';
|
|
33
|
+
return `@font-face{font-family:'${x.family}';font-style:${x.style || 'normal'};` +
|
|
34
|
+
`font-weight:${x.weight || 400};font-display:swap;src:url('${x.url}') format('${fmt}');}`;
|
|
35
|
+
});
|
|
36
|
+
return rules.join('\n');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Inject the @font-face rules (and a default text fill) into an ECharts SVG string.
|
|
40
|
+
export function embedFontFaces(svg, brand) {
|
|
41
|
+
const css = fontFaceCss(brand);
|
|
42
|
+
if (!css) return svg;
|
|
43
|
+
const styleEl = `<defs><style type="text/css"><![CDATA[\n${css}\n]]></style></defs>`;
|
|
44
|
+
// Insert right after the opening <svg ...> tag.
|
|
45
|
+
return svg.replace(/(<svg\b[^>]*>)/, `$1${styleEl}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Font options for @resvg/resvg-js: local font files that exist on disk + system fonts.
|
|
49
|
+
export function resvgFontOptions(brand, brandPath) {
|
|
50
|
+
const dir = brandPath ? dirname(resolve(brandPath)) : process.cwd();
|
|
51
|
+
const fontFiles = faces(brand)
|
|
52
|
+
.map((x) => x.file)
|
|
53
|
+
.filter(Boolean)
|
|
54
|
+
.map((p) => (p.startsWith('/') ? p : resolve(dir, p)))
|
|
55
|
+
.filter((p) => existsSync(p));
|
|
56
|
+
return {
|
|
57
|
+
fontFiles,
|
|
58
|
+
loadSystemFonts: true,
|
|
59
|
+
defaultFontFamily: (brand.fonts && brand.fonts.family) || 'sans-serif',
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// True if PNG output will be font-accurate (a local file exists for the brand family).
|
|
64
|
+
export function hasLocalFonts(brand, brandPath) {
|
|
65
|
+
return resvgFontOptions(brand, brandPath).fontFiles.length > 0;
|
|
66
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// palette.mjs — colour math + brand palette derivation.
|
|
2
|
+
//
|
|
3
|
+
// Pure functions, no I/O. Given a brand's (often short) list of categorical colours,
|
|
4
|
+
// derive a longer, visually-distinct, contrast-safe palette so charts with many series
|
|
5
|
+
// still read as on-brand. Also exposes the colour helpers the theme/chart builders use.
|
|
6
|
+
|
|
7
|
+
export function clamp(n, lo, hi) { return Math.min(hi, Math.max(lo, n)); }
|
|
8
|
+
|
|
9
|
+
export function hexToRgb(hex) {
|
|
10
|
+
let h = String(hex).trim().replace('#', '');
|
|
11
|
+
if (h.length === 3) h = h.split('').map((c) => c + c).join('');
|
|
12
|
+
const num = parseInt(h, 16);
|
|
13
|
+
return { r: (num >> 16) & 255, g: (num >> 8) & 255, b: num & 255 };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function rgbToHex({ r, g, b }) {
|
|
17
|
+
const to = (v) => clamp(Math.round(v), 0, 255).toString(16).padStart(2, '0');
|
|
18
|
+
return `#${to(r)}${to(g)}${to(b)}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function withAlpha(hex, a) {
|
|
22
|
+
const { r, g, b } = hexToRgb(hex);
|
|
23
|
+
return `rgba(${r}, ${g}, ${b}, ${a})`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Linear-blend two hex colours; t=0 → a, t=1 → b.
|
|
27
|
+
export function mix(a, b, t) {
|
|
28
|
+
const A = hexToRgb(a), B = hexToRgb(b);
|
|
29
|
+
return rgbToHex({ r: A.r + (B.r - A.r) * t, g: A.g + (B.g - A.g) * t, b: A.b + (B.b - A.b) * t });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function rgbToHsl({ r, g, b }) {
|
|
33
|
+
r /= 255; g /= 255; b /= 255;
|
|
34
|
+
const max = Math.max(r, g, b), min = Math.min(r, g, b);
|
|
35
|
+
let h = 0, s = 0; const l = (max + min) / 2;
|
|
36
|
+
if (max !== min) {
|
|
37
|
+
const d = max - min;
|
|
38
|
+
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
|
39
|
+
if (max === r) h = (g - b) / d + (g < b ? 6 : 0);
|
|
40
|
+
else if (max === g) h = (b - r) / d + 2;
|
|
41
|
+
else h = (r - g) / d + 4;
|
|
42
|
+
h /= 6;
|
|
43
|
+
}
|
|
44
|
+
return { h: h * 360, s, l };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function hslToRgb({ h, s, l }) {
|
|
48
|
+
h = ((h % 360) + 360) % 360 / 360;
|
|
49
|
+
if (s === 0) { const v = l * 255; return { r: v, g: v, b: v }; }
|
|
50
|
+
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
|
51
|
+
const p = 2 * l - q;
|
|
52
|
+
const hue = (t) => {
|
|
53
|
+
if (t < 0) t += 1; if (t > 1) t -= 1;
|
|
54
|
+
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
|
55
|
+
if (t < 1 / 2) return q;
|
|
56
|
+
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
|
57
|
+
return p;
|
|
58
|
+
};
|
|
59
|
+
return { r: hue(h + 1 / 3) * 255, g: hue(h) * 255, b: hue(h - 1 / 3) * 255 };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// WCAG relative luminance + contrast ratio (1..21).
|
|
63
|
+
function luminance(hex) {
|
|
64
|
+
const { r, g, b } = hexToRgb(hex);
|
|
65
|
+
const ch = (v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; };
|
|
66
|
+
return 0.2126 * ch(r) + 0.7152 * ch(g) + 0.0722 * ch(b);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function contrastRatio(a, b) {
|
|
70
|
+
const la = luminance(a), lb = luminance(b);
|
|
71
|
+
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Nudge a colour's lightness until it clears `min` contrast against `bg` (so series
|
|
75
|
+
// colours never disappear into a dark or light background). Direction is away from bg.
|
|
76
|
+
export function ensureContrast(hex, bg, min = 1.9) {
|
|
77
|
+
if (contrastRatio(hex, bg) >= min) return hex;
|
|
78
|
+
const goLighter = luminance(bg) < 0.4;
|
|
79
|
+
let { h, s, l } = rgbToHsl(hexToRgb(hex));
|
|
80
|
+
for (let i = 0; i < 24 && contrastRatio(rgbToHex(hslToRgb({ h, s, l })), bg) < min; i++) {
|
|
81
|
+
l = clamp(l + (goLighter ? 0.03 : -0.03), 0.06, 0.96);
|
|
82
|
+
}
|
|
83
|
+
return rgbToHex(hslToRgb({ h, s, l }));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Build a categorical palette of length `n` from the brand's base colours. Re-uses the
|
|
87
|
+
// brand colours first, then fills by rotating hue around the brand's average hue and
|
|
88
|
+
// alternating lightness — every entry contrast-checked against the background.
|
|
89
|
+
export function buildCategorical(base, n, bg) {
|
|
90
|
+
const out = [];
|
|
91
|
+
const seen = new Set();
|
|
92
|
+
for (const c of base || []) {
|
|
93
|
+
const fixed = ensureContrast(c, bg);
|
|
94
|
+
if (!seen.has(fixed.toLowerCase())) { out.push(fixed); seen.add(fixed.toLowerCase()); }
|
|
95
|
+
if (out.length >= n) return out;
|
|
96
|
+
}
|
|
97
|
+
if (out.length === 0) out.push(ensureContrast('#4e79a7', bg));
|
|
98
|
+
// Derive extra hues spread around the colour wheel from the seed colours.
|
|
99
|
+
const seeds = out.map((c) => rgbToHsl(hexToRgb(c)));
|
|
100
|
+
const avgS = seeds.reduce((a, x) => a + x.s, 0) / seeds.length;
|
|
101
|
+
const avgL = seeds.reduce((a, x) => a + x.l, 0) / seeds.length;
|
|
102
|
+
let k = 0;
|
|
103
|
+
const golden = 137.508; // golden-angle hue stepping → well-separated hues
|
|
104
|
+
let hue = seeds[0].h;
|
|
105
|
+
while (out.length < n && k < n * 4) {
|
|
106
|
+
hue = (hue + golden) % 360;
|
|
107
|
+
const l = clamp(avgL + (k % 2 ? 0.1 : -0.08), 0.32, 0.72);
|
|
108
|
+
const cand = ensureContrast(rgbToHex(hslToRgb({ h: hue, s: clamp(avgS, 0.35, 0.85), l })), bg);
|
|
109
|
+
if (!seen.has(cand.toLowerCase())) { out.push(cand); seen.add(cand.toLowerCase()); }
|
|
110
|
+
k++;
|
|
111
|
+
}
|
|
112
|
+
return out.slice(0, n);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// A gradient stop list across the brand palette — used by heatmap/treemap visualMaps.
|
|
116
|
+
export function gradientStops(base, bg) {
|
|
117
|
+
const cats = buildCategorical(base, Math.max(3, (base || []).length), bg);
|
|
118
|
+
return cats.length >= 2 ? cats : [mix(cats[0], bg, 0.6), cats[0]];
|
|
119
|
+
}
|