@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.
@@ -3,6 +3,8 @@ export type ChartItem = {
3
3
  label: string;
4
4
  value: number;
5
5
  };
6
+ export type ChartData = import("./renderer/aggregate").ChartData;
7
+ export type SeriesData = import("./renderer/aggregate").SeriesData;
6
8
  type ResultsChart = {
7
9
  $on?(type: string, callback: (e: any) => void): () => void;
8
10
  $set?(props: Partial<$$ComponentProps>): void;
@@ -10,32 +12,35 @@ type ResultsChart = {
10
12
  /**
11
13
  * ResultsChart
12
14
  *
13
- * A horizontal bar chart for a single categorical aggregate (votes per proposal,
14
- * reports per category, consumption per meter) that SHIPS ITS DATA TABLE. The
15
- * visual bars are decorative (`aria-hidden`); the accompanying `<table>` is the
16
- * accessible source of truth, always rendered — so the data is reachable with CSS
17
- * off, at any zoom, and to assistive tech. This is a hard requirement (WCAG /
18
- * ARTE: a chart must not be the only encoding of its data), not a toggle.
15
+ * A dependency-free (pure-CSS) categorical chart that SHIPS ITS DATA TABLE the
16
+ * SSR / no-JS fallback sibling of `EChart`. The visual bars are decorative
17
+ * (`aria-hidden`); the accompanying `<table>` is the accessible source of truth,
18
+ * always rendered — so the data is reachable with CSS off, at any zoom, and to
19
+ * assistive tech. This is a hard requirement (WCAG / ARTE: a chart must not be the
20
+ * only encoding of its data), not a toggle.
21
+ *
22
+ * Declarative + multi-series (#176 follow-on): consumes the same column-aligned
23
+ * `{ category, series[] }` (`ChartData`) as `EChart`. The data TABLE renders one
24
+ * value column per series (full fidelity, no JS); the CSS bars render the FIRST
25
+ * series only (a simple horizontal bar is the honest no-JS visual — stacked /
26
+ * dual-axis geometry needs the canvas). Back-compat: legacy `items={[{label,
27
+ * value}]}` synthesises a single series.
19
28
  *
20
- * Vertical-agnostic: `items` is already shaped to `{ label, value }`; the consumer
21
- * (a portal widget over a public aggregate VIEW) maps the view columns and orders
22
- * the rows. Dependency-free (pure CSS bars) so it stays a11y- and SSR-clean.
23
29
  * Consumes semantic tokens so dark / high-contrast schemes (#244) ride through.
24
- * Soft-empty: no items → renders nothing.
30
+ * Soft-empty: no series/category → renders nothing.
25
31
  *
26
32
  * @example
27
33
  * <ResultsChart
28
34
  * caption="Votes per proposal"
29
35
  * labelHeader="Proposal"
30
- * valueHeader="Votes"
31
- * items={[
32
- * { label: "Ciclovia da Marginal", value: 1284 },
33
- * { label: "Parque infantil de Gaia", value: 967 },
34
- * ]}
36
+ * category={["Ciclovia", "Parque"]}
37
+ * series={[{ name: "Votes", type: "bar", data: [1284, 967] }]}
35
38
  * locale="pt"
36
39
  * />
37
40
  */
38
41
  declare const ResultsChart: import("svelte").Component<{
42
+ category?: any[];
43
+ series?: any[];
39
44
  items?: any[];
40
45
  caption?: string;
41
46
  labelHeader?: string;
@@ -44,6 +49,8 @@ declare const ResultsChart: import("svelte").Component<{
44
49
  class?: string;
45
50
  } & Record<string, any>, {}, "">;
46
51
  type $$ComponentProps = {
52
+ category?: any[];
53
+ series?: any[];
47
54
  items?: any[];
48
55
  caption?: string;
49
56
  labelHeader?: string;
@@ -1,19 +1,22 @@
1
1
  <script lang="ts">
2
2
  import EChart from "../EChart.svelte";
3
3
  import type { WidgetProps } from "./types";
4
- import { toRankedItems } from "./aggregate";
4
+ import { toRankedItems, toSeriesData, type ChartSpec } from "./aggregate";
5
+ import { asChartSpec } from "./chart-spec";
5
6
 
6
- // `chart` widget (#498) — an ECharts bar chart over a public aggregate VIEW,
7
+ // `chart` / `echart` widget — an ECharts chart over a public aggregate VIEW,
7
8
  // the richer sibling of `results-chart` (pure-CSS bars). Type-ranked on
8
- // `kind: "aggregate"` (out-ranks the kind default) so a `type: "chart"` block
9
- // renders the ECharts widget while `type: "results-chart"` renders the CSS
10
- // one. Same a11y contract: EChart ships its data table (ARTE #3/#6 a chart
11
- // is never the only encoding). Same data path as ResultsChartWidget: reads
12
- // the `{ items }` rows and projects `{ label, value }` via `toRankedItems`,
13
- // so NO resolve-data / provider change is needed. Soft-empty on no rows.
14
- // `kind` is bound by the registry wrapper (line-chart / donut-chart), NOT by
15
- // the dispatcher (which renders this widget with the standard WidgetProps and
16
- // no kind → the `chart` key stays the ECharts bar). #176 Tier 1.
9
+ // `kind: "aggregate"`. Same a11y contract: EChart ships its data table (ARTE
10
+ // #3/#6 a chart is never the only encoding). Same data path as
11
+ // ResultsChartWidget: reads the `{ items }` rows; NO resolve-data / provider
12
+ // change needed.
13
+ //
14
+ // Two authoring modes (#176 follow-on):
15
+ // DECLARATIVE — `props.chart_spec` (category column + ≥1 series of value
16
+ // columns, each with its own type/stack/axis + chart options). Projected
17
+ // via `toSeriesData`; multi-series is the ≥2-series case.
18
+ // • LEGACY — `props.label_field` / `value_field` (+ registry `kind`). A single
19
+ // series via `toRankedItems`. Kept for back-compat reads.
17
20
  let { data, props, ownsH1, locale, kind = "bar" }: WidgetProps & {
18
21
  kind?: "bar" | "line" | "donut";
19
22
  } = $props();
@@ -24,6 +27,11 @@
24
27
  : [],
25
28
  );
26
29
 
30
+ // Declarative spec (validated/whitelisted) — null when the block is legacy.
31
+ const spec = $derived<ChartSpec | null>(asChartSpec(props.chart_spec));
32
+ const chartData = $derived(spec ? toSeriesData(rows, spec) : null);
33
+
34
+ // Legacy single-series projection (only used when there is no spec).
27
35
  const labelField = $derived(
28
36
  typeof props.label_field === "string" && props.label_field
29
37
  ? props.label_field
@@ -37,9 +45,12 @@
37
45
  const limit = $derived(
38
46
  typeof props.limit === "number" && props.limit > 0 ? props.limit : 0,
39
47
  );
48
+ const legacyItems = $derived(
49
+ spec ? [] : toRankedItems(rows, { labelField, valueField, limit }),
50
+ );
40
51
 
41
- const items = $derived(
42
- toRankedItems(rows, { labelField, valueField, limit }),
52
+ const hasData = $derived(
53
+ chartData ? chartData.series.length > 0 && chartData.category.length > 0 : legacyItems.length > 0,
43
54
  );
44
55
 
45
56
  const heading = $derived(
@@ -58,19 +69,33 @@
58
69
  );
59
70
  </script>
60
71
 
61
- {#if items.length > 0}
72
+ {#if hasData}
62
73
  <section class="widget-band">
63
74
  {#if heading}
64
75
  {#if ownsH1}<h1>{heading}</h1>{:else}<h2>{heading}</h2>{/if}
65
76
  {/if}
66
- <EChart
67
- {items}
68
- {caption}
69
- {labelHeader}
70
- {valueHeader}
71
- {kind}
72
- locale={locale ?? "en"}
73
- />
77
+ {#if chartData}
78
+ <EChart
79
+ category={chartData.category}
80
+ series={chartData.series}
81
+ legend={spec?.options?.legend ?? (chartData.series.length > 1)}
82
+ orientation={spec?.options?.orientation}
83
+ ySecondary={spec?.options?.y_secondary ?? false}
84
+ innerRadius={spec?.options?.inner_radius ?? 0}
85
+ {caption}
86
+ {labelHeader}
87
+ locale={locale ?? "en"}
88
+ />
89
+ {:else}
90
+ <EChart
91
+ items={legacyItems}
92
+ {caption}
93
+ {labelHeader}
94
+ {valueHeader}
95
+ {kind}
96
+ locale={locale ?? "en"}
97
+ />
98
+ {/if}
74
99
  </section>
75
100
  {/if}
76
101
 
@@ -1,15 +1,16 @@
1
1
  <script lang="ts">
2
2
  import ResultsChart from "../ResultsChart.svelte";
3
3
  import type { WidgetProps } from "./types";
4
- import { toRankedItems } from "./aggregate";
4
+ import { toRankedItems, toSeriesData, type ChartSpec } from "./aggregate";
5
+ import { asChartSpec } from "./chart-spec";
5
6
 
6
- // `results-chart` widget (#105 Phase 4) — a bar chart over a public aggregate
7
- // VIEW that SHIPS its data table (ARTE #3/#6: a chart is never the only
8
- // encoding). Type-ranked on `kind: "aggregate"` (out-ranks the board default)
9
- // so a `type: "results-chart"` block renders the chart while the bare kind
10
- // renders the board. Vertical-agnostic: `props.label_field` / `value_field`
11
- // name the view columns; `props.label_header` / `value_header` localise the
12
- // table headers. Soft-empty on no rows (optional slot). Flush band.
7
+ // `results-chart` widget (#105 Phase 4) — the dependency-free (pure-CSS) SSR /
8
+ // no-JS fallback over a public aggregate VIEW that SHIPS its data table (ARTE
9
+ // #3/#6: a chart is never the only encoding). Type-ranked on `kind:
10
+ // "aggregate"`. Same two authoring modes as EChartWidget (#176 follow-on):
11
+ // DECLARATIVE `props.chart_spec` projected via `toSeriesData`; the table
12
+ // renders ALL series, the CSS bars render the first.
13
+ // LEGACY — `props.label_field` / `value_field` via `toRankedItems`.
13
14
  let { data, props, ownsH1, locale }: WidgetProps = $props();
14
15
 
15
16
  const rows = $derived(
@@ -18,6 +19,9 @@
18
19
  : [],
19
20
  );
20
21
 
22
+ const spec = $derived<ChartSpec | null>(asChartSpec(props.chart_spec));
23
+ const chartData = $derived(spec ? toSeriesData(rows, spec) : null);
24
+
21
25
  const labelField = $derived(
22
26
  typeof props.label_field === "string" && props.label_field
23
27
  ? props.label_field
@@ -31,9 +35,12 @@
31
35
  const limit = $derived(
32
36
  typeof props.limit === "number" && props.limit > 0 ? props.limit : 0,
33
37
  );
38
+ const legacyItems = $derived(
39
+ spec ? [] : toRankedItems(rows, { labelField, valueField, limit }),
40
+ );
34
41
 
35
- const items = $derived(
36
- toRankedItems(rows, { labelField, valueField, limit }),
42
+ const hasData = $derived(
43
+ chartData ? chartData.series.length > 0 && chartData.category.length > 0 : legacyItems.length > 0,
37
44
  );
38
45
 
39
46
  const heading = $derived(
@@ -52,18 +59,28 @@
52
59
  );
53
60
  </script>
54
61
 
55
- {#if items.length > 0}
62
+ {#if hasData}
56
63
  <section class="widget-band">
57
64
  {#if heading}
58
65
  {#if ownsH1}<h1>{heading}</h1>{:else}<h2>{heading}</h2>{/if}
59
66
  {/if}
60
- <ResultsChart
61
- {items}
62
- {caption}
63
- {labelHeader}
64
- {valueHeader}
65
- locale={locale ?? "en"}
66
- />
67
+ {#if chartData}
68
+ <ResultsChart
69
+ category={chartData.category}
70
+ series={chartData.series}
71
+ {caption}
72
+ {labelHeader}
73
+ locale={locale ?? "en"}
74
+ />
75
+ {:else}
76
+ <ResultsChart
77
+ items={legacyItems}
78
+ {caption}
79
+ {labelHeader}
80
+ {valueHeader}
81
+ locale={locale ?? "en"}
82
+ />
83
+ {/if}
67
84
  </section>
68
85
  {/if}
69
86
 
@@ -32,3 +32,66 @@ export interface ToRankedOptions {
32
32
  * (ties keep input order); rows are never mutated.
33
33
  */
34
34
  export declare function toRankedItems(rows: ReadonlyArray<Record<string, unknown>>, opts?: ToRankedOptions): RankedItem[];
35
+ /** The curated series render kinds (whitelist; unknown → dropped upstream). */
36
+ export type ChartSeriesType = "bar" | "line" | "area" | "scatter" | "pie";
37
+ /** One series: a view value column rendered as `type`. */
38
+ export interface ChartSeriesSpec {
39
+ /** View output (value) column this series plots. */
40
+ column: string;
41
+ /** Render geometry for this series. */
42
+ type: ChartSeriesType;
43
+ /** Legend / table header label. Defaults to `column`. */
44
+ name?: string;
45
+ /** Bars/areas sharing a `stack` id are stacked together. */
46
+ stack?: string;
47
+ /** `secondary` pins the series to the 2nd value axis (dual-axis). */
48
+ axis?: "primary" | "secondary";
49
+ }
50
+ /** Chart-level enumerated options (no strings that become code). */
51
+ export interface ChartOptions {
52
+ /** Render a legend (needed once there are ≥2 series). */
53
+ legend?: boolean;
54
+ /** Cartesian orientation: `vertical` = category on x (default). */
55
+ orientation?: "horizontal" | "vertical";
56
+ /** Enable a 2nd value axis for `axis: "secondary"` series. */
57
+ y_secondary?: boolean;
58
+ /** Pie/donut hole as a fraction of the outer radius (0 = full pie). */
59
+ inner_radius?: number;
60
+ }
61
+ /** The declarative chart spec carried on a chart block's props. */
62
+ export interface ChartSpec {
63
+ /** Aggregate VIEW code (also lives on the block binding/entity). */
64
+ view_code?: string;
65
+ /** The category (x / pie-angle) column. */
66
+ category: {
67
+ column: string;
68
+ };
69
+ /** ≥1 series; ≥2 = multi-series. */
70
+ series: ChartSeriesSpec[];
71
+ options?: ChartOptions;
72
+ /** Defensive client-side row cap (server `?limit=` is authoritative). */
73
+ limit?: number;
74
+ /** Echoes the binding order term (for reference; ordering is server-side). */
75
+ order_by?: string;
76
+ }
77
+ /** A column-aligned series ready for the renderer. */
78
+ export interface SeriesData {
79
+ name: string;
80
+ type: ChartSeriesType;
81
+ data: number[];
82
+ stack?: string;
83
+ axis?: "primary" | "secondary";
84
+ }
85
+ /** The shape `EChart` / `ResultsChart` render: aligned category + series. */
86
+ export interface ChartData {
87
+ category: string[];
88
+ series: SeriesData[];
89
+ }
90
+ /**
91
+ * Project resolved VIEW rows → `{ category, series[] }` per a `ChartSpec`.
92
+ * Pure: rows are never mutated and never re-sorted (order is the binding's
93
+ * job). Blank-category rows are dropped (they have no axis tick) — dropping a
94
+ * row removes that index from EVERY series so the columns stay aligned. A
95
+ * non-numeric cell coerces to 0 (fail-soft, same as `toRankedItems`).
96
+ */
97
+ export declare function toSeriesData(rows: ReadonlyArray<Record<string, unknown>>, spec: ChartSpec): ChartData;
@@ -70,3 +70,126 @@ export function toRankedItems(
70
70
  ? sorted.slice(0, Math.floor(opts.limit))
71
71
  : sorted;
72
72
  }
73
+
74
+ /*
75
+ * ────────────────────────────────────────────────────────────────────────────
76
+ * Declarative multi-series chart spec (#176 follow-on) — PURE, JSON-only.
77
+ *
78
+ * The single-series `{label,value}` projection above is the degenerate "one
79
+ * series" case. The declarative model binds a chart block to a public aggregate
80
+ * VIEW and a set of OUTPUT COLUMNS: one `category` column (the x / pie-angle
81
+ * axis) and ≥1 `series`, each naming a value column + a render `type`. Multi-
82
+ * series is then just "≥2 series", not a bespoke feature.
83
+ *
84
+ * Three load-bearing constraints (see dev_docs/specs/176-declarative-chart-
85
+ * authoring.md): (1) JSON-ONLY — no operator-authored functions ever reach the
86
+ * runtime (TH-08); the DS supplies all behaviours. (2) BIND COLUMNS, not data —
87
+ * a series references a view output column, resolved at render via the existing
88
+ * DataProvider. (3) CURATED WHITELIST — every `type`/`option` is an enum/number/
89
+ * boolean; unknown keys are ignored, never passed through.
90
+ *
91
+ * `toSeriesData` is the multi-series analogue of `toRankedItems`: it maps the
92
+ * resolved VIEW rows into the column-aligned `{ category, series[] }` the DS
93
+ * `EChart` renders. Row ORDER and the row CAP are the binding's job (forwarded
94
+ * to the BFF as `?order_by=` / `?limit=`, applied server-side); this shaper is a
95
+ * pure column projection over the rows it is handed — it never re-sorts. A
96
+ * `limit` here is honoured only as a defensive client-side cap.
97
+ */
98
+
99
+ /** The curated series render kinds (whitelist; unknown → dropped upstream). */
100
+ export type ChartSeriesType = "bar" | "line" | "area" | "scatter" | "pie";
101
+
102
+ /** One series: a view value column rendered as `type`. */
103
+ export interface ChartSeriesSpec {
104
+ /** View output (value) column this series plots. */
105
+ column: string;
106
+ /** Render geometry for this series. */
107
+ type: ChartSeriesType;
108
+ /** Legend / table header label. Defaults to `column`. */
109
+ name?: string;
110
+ /** Bars/areas sharing a `stack` id are stacked together. */
111
+ stack?: string;
112
+ /** `secondary` pins the series to the 2nd value axis (dual-axis). */
113
+ axis?: "primary" | "secondary";
114
+ }
115
+
116
+ /** Chart-level enumerated options (no strings that become code). */
117
+ export interface ChartOptions {
118
+ /** Render a legend (needed once there are ≥2 series). */
119
+ legend?: boolean;
120
+ /** Cartesian orientation: `vertical` = category on x (default). */
121
+ orientation?: "horizontal" | "vertical";
122
+ /** Enable a 2nd value axis for `axis: "secondary"` series. */
123
+ y_secondary?: boolean;
124
+ /** Pie/donut hole as a fraction of the outer radius (0 = full pie). */
125
+ inner_radius?: number;
126
+ }
127
+
128
+ /** The declarative chart spec carried on a chart block's props. */
129
+ export interface ChartSpec {
130
+ /** Aggregate VIEW code (also lives on the block binding/entity). */
131
+ view_code?: string;
132
+ /** The category (x / pie-angle) column. */
133
+ category: { column: string };
134
+ /** ≥1 series; ≥2 = multi-series. */
135
+ series: ChartSeriesSpec[];
136
+ options?: ChartOptions;
137
+ /** Defensive client-side row cap (server `?limit=` is authoritative). */
138
+ limit?: number;
139
+ /** Echoes the binding order term (for reference; ordering is server-side). */
140
+ order_by?: string;
141
+ }
142
+
143
+ /** A column-aligned series ready for the renderer. */
144
+ export interface SeriesData {
145
+ name: string;
146
+ type: ChartSeriesType;
147
+ data: number[];
148
+ stack?: string;
149
+ axis?: "primary" | "secondary";
150
+ }
151
+
152
+ /** The shape `EChart` / `ResultsChart` render: aligned category + series. */
153
+ export interface ChartData {
154
+ category: string[];
155
+ series: SeriesData[];
156
+ }
157
+
158
+ /**
159
+ * Project resolved VIEW rows → `{ category, series[] }` per a `ChartSpec`.
160
+ * Pure: rows are never mutated and never re-sorted (order is the binding's
161
+ * job). Blank-category rows are dropped (they have no axis tick) — dropping a
162
+ * row removes that index from EVERY series so the columns stay aligned. A
163
+ * non-numeric cell coerces to 0 (fail-soft, same as `toRankedItems`).
164
+ */
165
+ export function toSeriesData(
166
+ rows: ReadonlyArray<Record<string, unknown>>,
167
+ spec: ChartSpec,
168
+ ): ChartData {
169
+ const categoryCol = spec.category?.column;
170
+ const series = Array.isArray(spec.series) ? spec.series : [];
171
+ if (!categoryCol || series.length === 0) {
172
+ return { category: [], series: [] };
173
+ }
174
+
175
+ // Drop blank-category rows first so every series stays index-aligned.
176
+ const kept = rows.filter((row) => {
177
+ const c = row[categoryCol];
178
+ return c != null && String(c) !== "";
179
+ });
180
+
181
+ const capped =
182
+ spec.limit && spec.limit > 0 ? kept.slice(0, Math.floor(spec.limit)) : kept;
183
+
184
+ const category = capped.map((row) => String(row[categoryCol]));
185
+
186
+ const shaped: SeriesData[] = series.map((s) => ({
187
+ name: s.name && s.name.trim() ? s.name : s.column,
188
+ type: s.type,
189
+ data: capped.map((row) => toNumber(row[s.column])),
190
+ ...(s.stack ? { stack: s.stack } : {}),
191
+ ...(s.axis ? { axis: s.axis } : {}),
192
+ }));
193
+
194
+ return { category, series: shaped };
195
+ }
@@ -0,0 +1,54 @@
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
+ import type { ChartData } from "./aggregate";
23
+ /** Live semantic tokens read off the chart container. */
24
+ export interface ChartTokens {
25
+ accent: string;
26
+ textPrimary: string;
27
+ textSecondary: string;
28
+ border: string;
29
+ }
30
+ export interface BuildChartOptionOpts {
31
+ tokens: ChartTokens;
32
+ /** BCP-47 locale for number formatting. */
33
+ locale?: string;
34
+ /** Render a legend (auto-on is the caller's decision). */
35
+ legend?: boolean;
36
+ /** Cartesian orientation; `vertical` (category on x) is the default. */
37
+ orientation?: "horizontal" | "vertical";
38
+ /** Enable a 2nd value axis for `axis: "secondary"` series. */
39
+ ySecondary?: boolean;
40
+ /** Pie/donut hole as a fraction of the outer radius (0 = full pie). */
41
+ innerRadius?: number;
42
+ }
43
+ /**
44
+ * Per-series / per-slice colour: an alpha ramp off the accent so N series stay
45
+ * distinguishable AND on-theme without a hardcoded palette. `color-mix` keeps
46
+ * it in the live colour space, so it re-themes with the tokens.
47
+ */
48
+ export declare function seriesColor(accent: string, i: number): string;
49
+ /**
50
+ * Build the ECharts `option` for a declarative chart. Pie (single-measure) and
51
+ * cartesian (bar/line/area/scatter, optionally stacked / dual-axis) are the two
52
+ * shapes; the first series' `type` selects pie vs cartesian.
53
+ */
54
+ export declare function buildChartOption(data: ChartData, opts: BuildChartOptionOpts): Record<string, unknown>;