@aiaiai-pt/design-system 0.38.1 → 0.40.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/components/EChart.svelte +123 -158
- package/components/EChart.svelte.d.ts +45 -26
- package/components/ResultsChart.svelte +62 -32
- package/components/ResultsChart.svelte.d.ts +22 -15
- package/components/renderer/EChartWidget.svelte +47 -22
- package/components/renderer/ResultsChartWidget.svelte +35 -18
- package/components/renderer/aggregate.d.ts +63 -0
- package/components/renderer/aggregate.ts +123 -0
- package/components/renderer/chart-option.d.ts +54 -0
- package/components/renderer/chart-option.ts +256 -0
- package/components/renderer/chart-spec.d.ts +21 -0
- package/components/renderer/chart-spec.ts +109 -0
- package/package.json +1 -1
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Declarative chart → ECharts `option` builder (#176 follow-on) — PURE.
|
|
3
|
+
*
|
|
4
|
+
* The single place that turns a column-aligned `{ category, series[] }`
|
|
5
|
+
* (`ChartData`, produced by `toSeriesData`) into an Apache ECharts `option`.
|
|
6
|
+
* Extracted out of `EChart.svelte` so it is unit-testable with real inputs
|
|
7
|
+
* (no mocking ECharts): feed it a `ChartData` + theme tokens and assert on the
|
|
8
|
+
* produced option's structure.
|
|
9
|
+
*
|
|
10
|
+
* Theming is token-driven (the caller reads the live `--color-*` tokens off the
|
|
11
|
+
* chart container and passes them in) — there is NO hardcoded colour list; the
|
|
12
|
+
* per-series / per-slice palette is an ALPHA RAMP off the single `--color-accent`
|
|
13
|
+
* so any number of series stays on-theme through dark / high-contrast schemes.
|
|
14
|
+
*
|
|
15
|
+
* The option CONTAINS DS-supplied formatter functions (locale number formatting,
|
|
16
|
+
* tooltip composition). That is deliberate and does NOT violate the JSON-only
|
|
17
|
+
* constraint — the constraint forbids OPERATOR-authored functions in the spec;
|
|
18
|
+
* the behaviours here are the DS's own, chosen from the curated surface. The
|
|
19
|
+
* returned object is handed straight to `chart.setOption` in-process; it is not
|
|
20
|
+
* serialised.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { ChartData, SeriesData } from "./aggregate";
|
|
24
|
+
|
|
25
|
+
/** Live semantic tokens read off the chart container. */
|
|
26
|
+
export interface ChartTokens {
|
|
27
|
+
accent: string;
|
|
28
|
+
textPrimary: string;
|
|
29
|
+
textSecondary: string;
|
|
30
|
+
border: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface BuildChartOptionOpts {
|
|
34
|
+
tokens: ChartTokens;
|
|
35
|
+
/** BCP-47 locale for number formatting. */
|
|
36
|
+
locale?: string;
|
|
37
|
+
/** Render a legend (auto-on is the caller's decision). */
|
|
38
|
+
legend?: boolean;
|
|
39
|
+
/** Cartesian orientation; `vertical` (category on x) is the default. */
|
|
40
|
+
orientation?: "horizontal" | "vertical";
|
|
41
|
+
/** Enable a 2nd value axis for `axis: "secondary"` series. */
|
|
42
|
+
ySecondary?: boolean;
|
|
43
|
+
/** Pie/donut hole as a fraction of the outer radius (0 = full pie). */
|
|
44
|
+
innerRadius?: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A localised number formatter (DS-supplied — never operator code). */
|
|
48
|
+
function makeFmt(locale: string): (value: number) => string {
|
|
49
|
+
const nf = new Intl.NumberFormat(locale);
|
|
50
|
+
return (value: number) => nf.format(value);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Per-series / per-slice colour: an alpha ramp off the accent so N series stay
|
|
55
|
+
* distinguishable AND on-theme without a hardcoded palette. `color-mix` keeps
|
|
56
|
+
* it in the live colour space, so it re-themes with the tokens.
|
|
57
|
+
*/
|
|
58
|
+
export function seriesColor(accent: string, i: number): string {
|
|
59
|
+
const alpha = Math.max(0.35, 1 - i * 0.15);
|
|
60
|
+
return `color-mix(in srgb, ${accent} ${Math.round(alpha * 100)}%, transparent)`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** True when the chart should render as a pie/donut (single-measure mode). */
|
|
64
|
+
function isPie(series: ReadonlyArray<SeriesData>): boolean {
|
|
65
|
+
return series.length > 0 && series[0].type === "pie";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function buildPieOption(
|
|
69
|
+
data: ChartData,
|
|
70
|
+
opts: BuildChartOptionOpts,
|
|
71
|
+
): Record<string, unknown> {
|
|
72
|
+
const { tokens, innerRadius = 0 } = opts;
|
|
73
|
+
const fmt = makeFmt(opts.locale || "en");
|
|
74
|
+
const first = data.series[0];
|
|
75
|
+
const inner = `${Math.round(Math.min(Math.max(innerRadius, 0), 0.9) * 100)}%`;
|
|
76
|
+
const palette = data.category.map((_, i) => seriesColor(tokens.accent, i));
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
animation: false,
|
|
80
|
+
tooltip: {
|
|
81
|
+
trigger: "item",
|
|
82
|
+
formatter: (p: { name: string; value: number; percent: number }) =>
|
|
83
|
+
`${p.name}: ${fmt(p.value)} (${p.percent}%)`,
|
|
84
|
+
},
|
|
85
|
+
...(opts.legend
|
|
86
|
+
? { legend: { show: true, textStyle: { color: tokens.textPrimary } } }
|
|
87
|
+
: {}),
|
|
88
|
+
color: palette,
|
|
89
|
+
series: [
|
|
90
|
+
{
|
|
91
|
+
type: "pie",
|
|
92
|
+
radius: [inner, "72%"],
|
|
93
|
+
data: data.category.map((name, i) => ({
|
|
94
|
+
name,
|
|
95
|
+
value: first?.data[i] ?? 0,
|
|
96
|
+
})),
|
|
97
|
+
label: { color: tokens.textPrimary },
|
|
98
|
+
labelLine: { lineStyle: { color: tokens.border } },
|
|
99
|
+
},
|
|
100
|
+
],
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Map one shaped series → its ECharts series object. */
|
|
105
|
+
function toEchartsSeries(
|
|
106
|
+
s: SeriesData,
|
|
107
|
+
i: number,
|
|
108
|
+
opts: BuildChartOptionOpts,
|
|
109
|
+
): Record<string, unknown> {
|
|
110
|
+
const { tokens, orientation, ySecondary } = opts;
|
|
111
|
+
const horizontal = orientation === "horizontal";
|
|
112
|
+
const color = seriesColor(tokens.accent, i);
|
|
113
|
+
// The value dimension is y for vertical charts, x for horizontal.
|
|
114
|
+
const axisKey = horizontal ? "xAxisIndex" : "yAxisIndex";
|
|
115
|
+
const axisIndex = s.axis === "secondary" && ySecondary ? 1 : 0;
|
|
116
|
+
const common: Record<string, unknown> = {
|
|
117
|
+
name: s.name,
|
|
118
|
+
[axisKey]: axisIndex,
|
|
119
|
+
...(s.stack ? { stack: s.stack } : {}),
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
if (s.type === "scatter") {
|
|
123
|
+
return {
|
|
124
|
+
...common,
|
|
125
|
+
type: "scatter",
|
|
126
|
+
data: s.data,
|
|
127
|
+
itemStyle: { color },
|
|
128
|
+
symbolSize: 10,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (s.type === "line" || s.type === "area") {
|
|
133
|
+
return {
|
|
134
|
+
...common,
|
|
135
|
+
type: "line",
|
|
136
|
+
data: s.data,
|
|
137
|
+
itemStyle: { color },
|
|
138
|
+
lineStyle: { color },
|
|
139
|
+
symbolSize: 7,
|
|
140
|
+
smooth: false,
|
|
141
|
+
...(s.type === "area" ? { areaStyle: { opacity: 0.25 } } : {}),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// bar (default)
|
|
146
|
+
return {
|
|
147
|
+
...common,
|
|
148
|
+
type: "bar",
|
|
149
|
+
data: s.data,
|
|
150
|
+
itemStyle: {
|
|
151
|
+
color,
|
|
152
|
+
borderRadius: horizontal ? [0, 4, 4, 0] : [4, 4, 0, 0],
|
|
153
|
+
},
|
|
154
|
+
barMaxWidth: 28,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function buildCartesianOption(
|
|
159
|
+
data: ChartData,
|
|
160
|
+
opts: BuildChartOptionOpts,
|
|
161
|
+
): Record<string, unknown> {
|
|
162
|
+
const { tokens, legend, orientation, ySecondary } = opts;
|
|
163
|
+
const horizontal = orientation === "horizontal";
|
|
164
|
+
const fmt = makeFmt(opts.locale || "en");
|
|
165
|
+
|
|
166
|
+
// ECharts plots a category axis bottom-up; for a HORIZONTAL chart that puts
|
|
167
|
+
// the first row at the bottom. Reverse category + every series' data together
|
|
168
|
+
// so the first row reads at the top (matching ResultsChart's top-down order).
|
|
169
|
+
let category = data.category;
|
|
170
|
+
let series = data.series;
|
|
171
|
+
if (horizontal) {
|
|
172
|
+
category = [...data.category].reverse();
|
|
173
|
+
series = data.series.map((s) => ({ ...s, data: [...s.data].reverse() }));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const categoryAxis = {
|
|
177
|
+
type: "category" as const,
|
|
178
|
+
data: category,
|
|
179
|
+
axisLabel: { color: tokens.textPrimary },
|
|
180
|
+
axisLine: { lineStyle: { color: tokens.border } },
|
|
181
|
+
axisTick: { show: false },
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const valueAxis = (secondary: boolean) => ({
|
|
185
|
+
type: "value" as const,
|
|
186
|
+
axisLabel: {
|
|
187
|
+
color: tokens.textSecondary,
|
|
188
|
+
formatter: (v: number) => fmt(v),
|
|
189
|
+
},
|
|
190
|
+
axisLine: { lineStyle: { color: tokens.border } },
|
|
191
|
+
// Hide the 2nd axis' grid lines to avoid a double grid.
|
|
192
|
+
splitLine: { show: !secondary, lineStyle: { color: tokens.border } },
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
const valueAxes = ySecondary
|
|
196
|
+
? [valueAxis(false), valueAxis(true)]
|
|
197
|
+
: [valueAxis(false)];
|
|
198
|
+
|
|
199
|
+
const echSeries = series.map((s, i) => toEchartsSeries(s, i, opts));
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
animation: false,
|
|
203
|
+
grid: {
|
|
204
|
+
left: 8,
|
|
205
|
+
right: 16,
|
|
206
|
+
top: legend ? 32 : 8,
|
|
207
|
+
bottom: 8,
|
|
208
|
+
containLabel: true,
|
|
209
|
+
},
|
|
210
|
+
tooltip: {
|
|
211
|
+
trigger: "axis",
|
|
212
|
+
axisPointer: { type: "shadow" },
|
|
213
|
+
formatter: (
|
|
214
|
+
ps: Array<{
|
|
215
|
+
axisValueLabel?: string;
|
|
216
|
+
name?: string;
|
|
217
|
+
seriesName?: string;
|
|
218
|
+
value: number;
|
|
219
|
+
}>,
|
|
220
|
+
) => {
|
|
221
|
+
const head = ps[0]?.axisValueLabel ?? ps[0]?.name ?? "";
|
|
222
|
+
const lines = ps.map((p) => `${p.seriesName ?? ""}: ${fmt(p.value)}`);
|
|
223
|
+
return [head, ...lines].join("<br/>");
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
...(legend
|
|
227
|
+
? {
|
|
228
|
+
legend: {
|
|
229
|
+
show: true,
|
|
230
|
+
data: series.map((s) => s.name),
|
|
231
|
+
textStyle: { color: tokens.textPrimary },
|
|
232
|
+
},
|
|
233
|
+
}
|
|
234
|
+
: {}),
|
|
235
|
+
xAxis: horizontal ? valueAxes : categoryAxis,
|
|
236
|
+
yAxis: horizontal ? categoryAxis : valueAxes,
|
|
237
|
+
series: echSeries,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Build the ECharts `option` for a declarative chart. Pie (single-measure) and
|
|
243
|
+
* cartesian (bar/line/area/scatter, optionally stacked / dual-axis) are the two
|
|
244
|
+
* shapes; the first series' `type` selects pie vs cartesian.
|
|
245
|
+
*/
|
|
246
|
+
export function buildChartOption(
|
|
247
|
+
data: ChartData,
|
|
248
|
+
opts: BuildChartOptionOpts,
|
|
249
|
+
): Record<string, unknown> {
|
|
250
|
+
if (!data || data.series.length === 0 || data.category.length === 0) {
|
|
251
|
+
return {};
|
|
252
|
+
}
|
|
253
|
+
return isPie(data.series)
|
|
254
|
+
? buildPieOption(data, opts)
|
|
255
|
+
: buildCartesianOption(data, opts);
|
|
256
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render-side chart-spec validation + whitelist (#176 follow-on) — PURE.
|
|
3
|
+
*
|
|
4
|
+
* `props.chart_spec` reaches the widget as UNTRUSTED `unknown` (block props are
|
|
5
|
+
* operator-authored). Even though the backend validates the spec on write, the
|
|
6
|
+
* renderer re-validates on read and enforces the CURATED WHITELIST here — the
|
|
7
|
+
* third load-bearing constraint: every `type` / option is an enum / number /
|
|
8
|
+
* boolean drawn from a fixed set; anything unknown is DROPPED, never passed
|
|
9
|
+
* through to ECharts. This keeps the render path closed (TH-08): no operator
|
|
10
|
+
* string ever becomes behaviour.
|
|
11
|
+
*
|
|
12
|
+
* Returns a clean `ChartSpec` or `null` (block is legacy / spec unusable). Pure
|
|
13
|
+
* and dependency-free so it unit-tests trivially.
|
|
14
|
+
*/
|
|
15
|
+
import type { ChartSpec } from "./aggregate";
|
|
16
|
+
/**
|
|
17
|
+
* Validate + whitelist a raw `chart_spec`. Returns null when it is absent or
|
|
18
|
+
* unusable (no category column, or no valid series survive the whitelist) — the
|
|
19
|
+
* caller then falls back to the legacy single-series path.
|
|
20
|
+
*/
|
|
21
|
+
export declare function asChartSpec(raw: unknown): ChartSpec | null;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render-side chart-spec validation + whitelist (#176 follow-on) — PURE.
|
|
3
|
+
*
|
|
4
|
+
* `props.chart_spec` reaches the widget as UNTRUSTED `unknown` (block props are
|
|
5
|
+
* operator-authored). Even though the backend validates the spec on write, the
|
|
6
|
+
* renderer re-validates on read and enforces the CURATED WHITELIST here — the
|
|
7
|
+
* third load-bearing constraint: every `type` / option is an enum / number /
|
|
8
|
+
* boolean drawn from a fixed set; anything unknown is DROPPED, never passed
|
|
9
|
+
* through to ECharts. This keeps the render path closed (TH-08): no operator
|
|
10
|
+
* string ever becomes behaviour.
|
|
11
|
+
*
|
|
12
|
+
* Returns a clean `ChartSpec` or `null` (block is legacy / spec unusable). Pure
|
|
13
|
+
* and dependency-free so it unit-tests trivially.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type {
|
|
17
|
+
ChartOptions,
|
|
18
|
+
ChartSeriesSpec,
|
|
19
|
+
ChartSeriesType,
|
|
20
|
+
ChartSpec,
|
|
21
|
+
} from "./aggregate";
|
|
22
|
+
|
|
23
|
+
const SERIES_TYPES: ReadonlySet<ChartSeriesType> = new Set([
|
|
24
|
+
"bar",
|
|
25
|
+
"line",
|
|
26
|
+
"area",
|
|
27
|
+
"scatter",
|
|
28
|
+
"pie",
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
const ORIENTATIONS = new Set(["horizontal", "vertical"]);
|
|
32
|
+
|
|
33
|
+
function isObj(v: unknown): v is Record<string, unknown> {
|
|
34
|
+
return typeof v === "object" && v !== null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function nonEmptyStr(v: unknown): v is string {
|
|
38
|
+
return typeof v === "string" && v.trim() !== "";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Whitelist one series; null if its column or type is missing/unknown. */
|
|
42
|
+
function parseSeries(raw: unknown): ChartSeriesSpec | null {
|
|
43
|
+
if (!isObj(raw)) return null;
|
|
44
|
+
if (!nonEmptyStr(raw.column)) return null;
|
|
45
|
+
if (
|
|
46
|
+
typeof raw.type !== "string" ||
|
|
47
|
+
!SERIES_TYPES.has(raw.type as ChartSeriesType)
|
|
48
|
+
) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
const out: ChartSeriesSpec = {
|
|
52
|
+
column: raw.column,
|
|
53
|
+
type: raw.type as ChartSeriesType,
|
|
54
|
+
};
|
|
55
|
+
if (nonEmptyStr(raw.name)) out.name = raw.name;
|
|
56
|
+
if (nonEmptyStr(raw.stack)) out.stack = raw.stack;
|
|
57
|
+
if (raw.axis === "primary" || raw.axis === "secondary") out.axis = raw.axis;
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Whitelist the chart-level options; unknown keys are dropped. */
|
|
62
|
+
function parseOptions(raw: unknown): ChartOptions {
|
|
63
|
+
if (!isObj(raw)) return {};
|
|
64
|
+
const out: ChartOptions = {};
|
|
65
|
+
if (typeof raw.legend === "boolean") out.legend = raw.legend;
|
|
66
|
+
if (
|
|
67
|
+
typeof raw.orientation === "string" &&
|
|
68
|
+
ORIENTATIONS.has(raw.orientation)
|
|
69
|
+
) {
|
|
70
|
+
out.orientation = raw.orientation as "horizontal" | "vertical";
|
|
71
|
+
}
|
|
72
|
+
if (typeof raw.y_secondary === "boolean") out.y_secondary = raw.y_secondary;
|
|
73
|
+
if (
|
|
74
|
+
typeof raw.inner_radius === "number" &&
|
|
75
|
+
Number.isFinite(raw.inner_radius)
|
|
76
|
+
) {
|
|
77
|
+
out.inner_radius = Math.min(Math.max(raw.inner_radius, 0), 0.9);
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Validate + whitelist a raw `chart_spec`. Returns null when it is absent or
|
|
84
|
+
* unusable (no category column, or no valid series survive the whitelist) — the
|
|
85
|
+
* caller then falls back to the legacy single-series path.
|
|
86
|
+
*/
|
|
87
|
+
export function asChartSpec(raw: unknown): ChartSpec | null {
|
|
88
|
+
if (!isObj(raw)) return null;
|
|
89
|
+
const category = raw.category;
|
|
90
|
+
if (!isObj(category) || !nonEmptyStr(category.column)) return null;
|
|
91
|
+
|
|
92
|
+
const rawSeries = Array.isArray(raw.series) ? raw.series : [];
|
|
93
|
+
const series = rawSeries
|
|
94
|
+
.map(parseSeries)
|
|
95
|
+
.filter((s): s is ChartSeriesSpec => s !== null);
|
|
96
|
+
if (series.length === 0) return null;
|
|
97
|
+
|
|
98
|
+
const spec: ChartSpec = {
|
|
99
|
+
category: { column: category.column },
|
|
100
|
+
series,
|
|
101
|
+
options: parseOptions(raw.options),
|
|
102
|
+
};
|
|
103
|
+
if (nonEmptyStr(raw.view_code)) spec.view_code = raw.view_code;
|
|
104
|
+
if (typeof raw.limit === "number" && raw.limit > 0) {
|
|
105
|
+
spec.limit = Math.floor(raw.limit);
|
|
106
|
+
}
|
|
107
|
+
if (nonEmptyStr(raw.order_by)) spec.order_by = raw.order_by;
|
|
108
|
+
return spec;
|
|
109
|
+
}
|