@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.
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiaiai-pt/design-system",
3
- "version": "0.38.1",
3
+ "version": "0.40.0",
4
4
  "description": "Design system tokens and Svelte components for aiaiai products",
5
5
  "license": "MIT",
6
6
  "type": "module",