@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.
@@ -1,64 +1,81 @@
1
1
  <!--
2
2
  @component EChart
3
3
 
4
- A horizontal bar chart for a single categorical aggregate (votes per proposal,
5
- reports per category, …) rendered with Apache ECharts, that SHIPS ITS DATA
6
- TABLE same a11y contract as `ResultsChart`. The ECharts canvas is decorative
7
- (`aria-hidden`); the accompanying `<table>` is the accessible source of truth,
8
- always rendered server-side and client-side, so the data is reachable with
9
- JS off, CSS off, at any zoom, and to assistive tech. This is a hard
10
- requirement (WCAG / ARTE: a chart must not be the only encoding of its data),
11
- not a toggle.
12
-
13
- ECharts is LAZY-loaded: `echarts/core` + only the BarChart / Grid / Tooltip
14
- modules + the canvas renderer are dynamically `import()`ed inside the mount
15
- effect, so the heavy library lands in its OWN async chunk a page that never
16
- mounts a chart never pays for it. Effects are client-only in Svelte 5, so the
17
- canvas is never touched during SSR.
18
-
19
- Vertical-agnostic: `items` is already shaped to `{ label, value }` by the
20
- consumer (a widget over a public aggregate VIEW). Themed from the live
21
- `--color-*` semantic tokens read off the container, and re-themed on
22
- `data-theme` / `prefers-color-scheme` change (#244), so dark / high-contrast
23
- schemes ride through. Soft-empty: no items → renders nothing.
4
+ A categorical chart over a public aggregate VIEW, rendered with Apache ECharts,
5
+ that SHIPS ITS DATA TABLE same a11y contract as `ResultsChart`. The ECharts
6
+ canvas is decorative (`aria-hidden`); the accompanying `<table>` is the
7
+ accessible source of truth, always rendered server-side and client-side, so the
8
+ data is reachable with JS off, CSS off, at any zoom, and to assistive tech. This
9
+ is a hard requirement (WCAG / ARTE: a chart must not be the only encoding of its
10
+ data), not a toggle.
11
+
12
+ Declarative + multi-series (#176 follow-on): the data contract is the
13
+ column-aligned `{ category, series[] }` (`ChartData`) produced by `toSeriesData`
14
+ a single series is the degenerate case, ≥2 series is multi-series. Each series
15
+ carries its own `type` (bar/line/area/scatter/pie), optional `stack`, and
16
+ optional secondary `axis`; the ECharts `option` is built by the PURE
17
+ `buildChartOption` (unit-tested separately). Back-compat: the legacy
18
+ `items={[{label,value}]}` + `kind` props synthesise a single series.
19
+
20
+ ECharts is LAZY-loaded inside the mount effect, so the heavy library lands in
21
+ its OWN async chunk a page that never mounts a chart never pays for it.
22
+ Effects are client-only in Svelte 5, so the canvas is never touched during SSR.
23
+
24
+ Themed from the live `--color-*` semantic tokens read off the container, and
25
+ re-themed on `data-theme` / `prefers-color-scheme` change (#244). The per-series
26
+ palette is an alpha ramp off `--color-accent` — no hardcoded colour list.
27
+ Soft-empty: no series/category → renders nothing.
24
28
 
25
29
  @example
26
30
  <EChart
27
- caption="Votes per proposal"
28
- labelHeader="Proposal"
29
- valueHeader="Votes"
30
- items={[
31
- { label: "Ciclovia da Marginal", value: 1284 },
32
- { label: "Parque infantil de Gaia", value: 967 },
31
+ caption="Reports by status"
32
+ labelHeader="Status"
33
+ category={["Open", "In progress", "Resolved"]}
34
+ series={[
35
+ { name: "Open", type: "bar", data: [12, 0, 0] },
36
+ { name: "Resolved", type: "bar", data: [0, 0, 31] },
33
37
  ]}
38
+ legend
34
39
  locale="pt"
35
40
  />
36
41
  -->
37
42
  <script module>
38
43
  /**
39
44
  * @typedef {{ label: string, value: number }} ChartItem
45
+ * @typedef {import('./renderer/aggregate').ChartData} ChartData
46
+ * @typedef {import('./renderer/aggregate').SeriesData} SeriesData
40
47
  */
41
48
  </script>
42
49
 
43
50
  <script>
44
51
  import { watchTheme } from "./map-utils.js";
52
+ import { buildChartOption } from "./renderer/chart-option";
45
53
 
46
54
  let {
47
- /** @type {ChartItem[]} The categorical rows to plot. */
55
+ /** @type {string[]} The category (x / pie-angle) ticks. */
56
+ category = [],
57
+ /** @type {SeriesData[]} The series to plot (≥1; ≥2 = multi-series). */
58
+ series = [],
59
+ /** @type {boolean} Render a legend (needed once there are ≥2 series). */
60
+ legend = false,
61
+ /** @type {"horizontal" | "vertical" | undefined} Cartesian orientation. */
62
+ orientation = undefined,
63
+ /** @type {boolean} Enable a 2nd value axis for `axis: "secondary"` series. */
64
+ ySecondary = false,
65
+ /** @type {number} Pie/donut hole as a fraction of the radius (0 = full pie). */
66
+ innerRadius = 0,
67
+ /** @type {ChartItem[]} BACK-COMPAT: single-series `{label,value}` rows. */
48
68
  items = [],
69
+ /** @type {"bar" | "line" | "donut"} BACK-COMPAT: legacy single-series kind. */
70
+ kind = "bar",
49
71
  /** @type {string} Accessible caption / heading for the chart + table. */
50
72
  caption = "Results",
51
73
  /** @type {string} Column header for the category (localize it). */
52
74
  labelHeader = "Item",
53
- /** @type {string} Column header for the value (localize it). */
75
+ /** @type {string} Header for the (legacy single) value column (localize it). */
54
76
  valueHeader = "Value",
55
77
  /** @type {string} BCP-47 locale for number formatting. */
56
78
  locale = "en",
57
- /** @type {"bar" | "line" | "donut"} Chart kind (#176 Tier 1). `bar` is the
58
- * horizontal categorical bar; `line` plots the same series as a value line
59
- * over the category axis; `donut` is a ring pie. The data table below is
60
- * the accessible source of truth for every kind. */
61
- kind = "bar",
62
79
  /** @type {string} CSS block-size for the chart canvas. */
63
80
  height = "20rem",
64
81
  /** @type {string} */
@@ -71,17 +88,49 @@
71
88
  return new Intl.NumberFormat(locale).format(value);
72
89
  }
73
90
 
91
+ // Normalise to the declarative `{ category, series }` contract. When `series`
92
+ // is given we render it directly; otherwise we synthesise a single series from
93
+ // the legacy `items` + `kind` (donut → a pie series).
94
+ /** @type {ChartData} */
95
+ const chartData = $derived.by(() => {
96
+ if (series.length > 0) return { category, series };
97
+ if (items.length === 0) return { category: [], series: [] };
98
+ const t = kind === "donut" ? "pie" : kind;
99
+ return {
100
+ category: items.map((it) => it.label),
101
+ series: [
102
+ {
103
+ name: valueHeader,
104
+ type: /** @type {SeriesData["type"]} */ (t),
105
+ data: items.map((it) => it.value),
106
+ },
107
+ ],
108
+ };
109
+ });
110
+
111
+ // Legacy donut gets the ring hole even though the caller passes no innerRadius.
112
+ const effInnerRadius = $derived(
113
+ series.length === 0 && kind === "donut" ? 0.45 : innerRadius,
114
+ );
115
+
116
+ // The legacy single-series `kind="bar"` rendered a HORIZONTAL bar (category on
117
+ // the y-axis). Preserve that look for back-compat callers; the declarative
118
+ // path defaults to vertical (category on x) unless `orientation` says otherwise.
119
+ const effOrientation = $derived(
120
+ orientation ?? (series.length === 0 && kind === "bar" ? "horizontal" : undefined),
121
+ );
122
+
123
+ const hasData = $derived(
124
+ chartData.series.length > 0 && chartData.category.length > 0,
125
+ );
126
+
74
127
  /** @type {HTMLElement | undefined} */
75
128
  let container = $state();
76
129
 
77
- // The ECharts instance + the canvas-renderer chart option are owned by the
78
- // mount effect; this effect (re-)builds the option whenever `items`, the
79
- // locale, or the theme change. `_chart` is hoisted so the data effect can
80
- // re-setOption without re-initialising the canvas.
81
130
  /** @type {import('echarts/core').EChartsType | undefined} */
82
131
  let _chart = $state();
83
132
 
84
- /** Build the ECharts option from current items + the container's tokens. */
133
+ /** Build the ECharts option from current data + the container's tokens. */
85
134
  function buildOption() {
86
135
  const el = container;
87
136
  if (!el) return {};
@@ -90,110 +139,19 @@
90
139
  const v = cs.getPropertyValue(name).trim();
91
140
  return v || fallback;
92
141
  };
93
- const accent = token("--color-accent", "#2563eb");
94
- const textPrimary = token("--color-text-primary", "#1f2937");
95
- const textSecondary = token("--color-text-secondary", "#6b7280");
96
- const border = token("--color-border", "#e5e7eb");
97
-
98
- // Donut: a ring pie. No axes; the slice palette derives from the accent
99
- // (alpha ramp) so it stays on-theme without a hardcoded colour list.
100
- if (kind === "donut") {
101
- const palette = items.map((_, i) => {
102
- const alpha = Math.max(0.35, 1 - i * 0.12);
103
- return `color-mix(in srgb, ${accent} ${Math.round(alpha * 100)}%, transparent)`;
104
- });
105
- return {
106
- animation: false,
107
- tooltip: {
108
- trigger: "item",
109
- /** @param {{ name: string, value: number, percent: number }} p */
110
- formatter: (p) => `${p.name}: ${fmt(p.value)} (${p.percent}%)`,
111
- },
112
- color: palette,
113
- series: [
114
- {
115
- type: "pie",
116
- radius: ["45%", "72%"],
117
- data: items.map((it) => ({ name: it.label, value: it.value })),
118
- label: { color: textPrimary },
119
- labelLine: { lineStyle: { color: border } },
120
- },
121
- ],
122
- };
123
- }
124
-
125
- // Line: value over the category axis, in the consumer's order (desc by
126
- // value). Shares the same accent + token theming as the bar.
127
- if (kind === "line") {
128
- return {
129
- animation: false,
130
- grid: { left: 8, right: 16, top: 8, bottom: 8, containLabel: true },
131
- tooltip: {
132
- trigger: "axis",
133
- /** @param {{ name: string, value: number }[]} p */
134
- formatter: (p) => `${p[0]?.name}: ${fmt(p[0]?.value)}`,
135
- },
136
- xAxis: {
137
- type: "category",
138
- data: items.map((it) => it.label),
139
- axisLabel: { color: textPrimary },
140
- axisLine: { lineStyle: { color: border } },
141
- axisTick: { show: false },
142
- },
143
- yAxis: {
144
- type: "value",
145
- axisLabel: { color: textSecondary, formatter: (/** @type {number} */ v) => fmt(v) },
146
- axisLine: { lineStyle: { color: border } },
147
- splitLine: { lineStyle: { color: border } },
148
- },
149
- series: [
150
- {
151
- type: "line",
152
- data: items.map((it) => it.value),
153
- itemStyle: { color: accent },
154
- lineStyle: { color: accent },
155
- symbolSize: 7,
156
- smooth: false,
157
- },
158
- ],
159
- };
160
- }
161
-
162
- // Bar (default). ECharts plots the category axis bottom-up; reverse so the
163
- // highest value sits at the top (matching ResultsChart's top-down order).
164
- // `items` is already sorted desc by the consumer.
165
- const ordered = [...items].reverse();
166
-
167
- return {
168
- animation: false,
169
- grid: { left: 8, right: 16, top: 8, bottom: 8, containLabel: true },
170
- tooltip: {
171
- trigger: "item",
172
- /** @param {{ name: string, value: number }} p */
173
- formatter: (p) => `${p.name}: ${fmt(p.value)}`,
174
- },
175
- xAxis: {
176
- type: "value",
177
- axisLabel: { color: textSecondary, formatter: (/** @type {number} */ v) => fmt(v) },
178
- axisLine: { lineStyle: { color: border } },
179
- splitLine: { lineStyle: { color: border } },
142
+ return buildChartOption(chartData, {
143
+ tokens: {
144
+ accent: token("--color-accent", "#2563eb"),
145
+ textPrimary: token("--color-text-primary", "#1f2937"),
146
+ textSecondary: token("--color-text-secondary", "#6b7280"),
147
+ border: token("--color-border", "#e5e7eb"),
180
148
  },
181
- yAxis: {
182
- type: "category",
183
- data: ordered.map((it) => it.label),
184
- axisLabel: { color: textPrimary },
185
- axisLine: { lineStyle: { color: border } },
186
- axisTick: { show: false },
187
- },
188
- series: [
189
- {
190
- type: "bar",
191
- data: ordered.map((it) => it.value),
192
- itemStyle: { color: accent, borderRadius: [0, 4, 4, 0] },
193
- barMaxWidth: 28,
194
- },
195
- ],
196
- };
149
+ locale,
150
+ legend,
151
+ orientation: effOrientation,
152
+ ySecondary,
153
+ innerRadius: effInnerRadius,
154
+ });
197
155
  }
198
156
 
199
157
  $effect(() => {
@@ -221,8 +179,10 @@
221
179
  charts.BarChart,
222
180
  charts.LineChart,
223
181
  charts.PieChart,
182
+ charts.ScatterChart,
224
183
  components.GridComponent,
225
184
  components.TooltipComponent,
185
+ components.LegendComponent,
226
186
  renderers.CanvasRenderer,
227
187
  ]);
228
188
 
@@ -233,7 +193,7 @@
233
193
  ro = new ResizeObserver(() => chart?.resize());
234
194
  ro.observe(container);
235
195
 
236
- disposeTheme = watchTheme(() => chart?.setOption(buildOption()));
196
+ disposeTheme = watchTheme(() => chart?.setOption(buildOption(), true));
237
197
  } catch (err) {
238
198
  // Fail soft: the <table> below is the accessible source of truth, so a
239
199
  // failed chart load degrades to data-only, never to a broken page.
@@ -251,21 +211,21 @@
251
211
  };
252
212
  });
253
213
 
254
- // Re-apply the option when items / locale change after mount (the canvas
255
- // persists; only the data is swapped). Guarded on `_chart` so it no-ops
256
- // before init and after dispose.
214
+ // Re-apply the option when the data / locale / options change after mount (the
215
+ // canvas persists; only the option is swapped). `true` replaces (not merges)
216
+ // so stale axis/series components from a previous shape are cleared.
257
217
  $effect(() => {
258
- // touch reactive deps so the effect re-runs when they change
259
- void items;
218
+ void chartData;
260
219
  void locale;
261
- void kind;
262
- // A kind switch changes which axes/series exist; replace (not merge) the
263
- // option so stale axis components from the previous kind are cleared.
220
+ void legend;
221
+ void effOrientation;
222
+ void ySecondary;
223
+ void effInnerRadius;
264
224
  if (_chart) _chart.setOption(buildOption(), true);
265
225
  });
266
226
  </script>
267
227
 
268
- {#if items.length > 0}
228
+ {#if hasData}
269
229
  <figure class="echart {className}" aria-label={caption} {...rest}>
270
230
  <!-- Decorative visual: the data lives in the table below. -->
271
231
  <div
@@ -275,21 +235,26 @@
275
235
  style="block-size: {height}"
276
236
  ></div>
277
237
 
278
- <!-- Accessible source of truth — always rendered (ARTE #3/#6). Named via
279
- aria-label, not a <caption>: position:absolute/clip is unreliable on a
280
- display:table-caption element (it leaks visibly in some engines). -->
238
+ <!-- Accessible source of truth — always rendered (ARTE #3/#6), one value
239
+ column per series. Named via aria-label, not a <caption>:
240
+ position:absolute/clip is unreliable on a display:table-caption element
241
+ (it leaks visibly in some engines). -->
281
242
  <table class="echart-table" aria-label={caption}>
282
243
  <thead>
283
244
  <tr>
284
245
  <th scope="col">{labelHeader}</th>
285
- <th scope="col">{valueHeader}</th>
246
+ {#each chartData.series as s, si (si)}
247
+ <th scope="col">{s.name}</th>
248
+ {/each}
286
249
  </tr>
287
250
  </thead>
288
251
  <tbody>
289
- {#each items as item, i (item.label + i)}
252
+ {#each chartData.category as cat, r (cat + r)}
290
253
  <tr>
291
- <th scope="row">{item.label}</th>
292
- <td>{fmt(item.value)}</td>
254
+ <th scope="row">{cat}</th>
255
+ {#each chartData.series as s, si (si)}
256
+ <td>{fmt(s.data[r] ?? 0)}</td>
257
+ {/each}
293
258
  </tr>
294
259
  {/each}
295
260
  </tbody>
@@ -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 EChart = {
7
9
  $on?(type: string, callback: (e: any) => void): () => void;
8
10
  $set?(props: Partial<$$ComponentProps>): void;
@@ -10,56 +12,73 @@ type EChart = {
10
12
  /**
11
13
  * EChart
12
14
  *
13
- * A horizontal bar chart for a single categorical aggregate (votes per proposal,
14
- * reports per category, …) rendered with Apache ECharts, that SHIPS ITS DATA
15
- * TABLE same a11y contract as `ResultsChart`. The ECharts canvas is decorative
16
- * (`aria-hidden`); the accompanying `<table>` is the accessible source of truth,
17
- * always rendered server-side and client-side, so the data is reachable with
18
- * JS off, CSS off, at any zoom, and to assistive tech. This is a hard
19
- * requirement (WCAG / ARTE: a chart must not be the only encoding of its data),
20
- * not a toggle.
15
+ * A categorical chart over a public aggregate VIEW, rendered with Apache ECharts,
16
+ * that SHIPS ITS DATA TABLE same a11y contract as `ResultsChart`. The ECharts
17
+ * canvas is decorative (`aria-hidden`); the accompanying `<table>` is the
18
+ * accessible source of truth, always rendered server-side and client-side, so the
19
+ * data is reachable with JS off, CSS off, at any zoom, and to assistive tech. This
20
+ * is a hard requirement (WCAG / ARTE: a chart must not be the only encoding of its
21
+ * data), not a toggle.
21
22
  *
22
- * ECharts is LAZY-loaded: `echarts/core` + only the BarChart / Grid / Tooltip
23
- * modules + the canvas renderer are dynamically `import()`ed inside the mount
24
- * effect, so the heavy library lands in its OWN async chunk a page that never
25
- * mounts a chart never pays for it. Effects are client-only in Svelte 5, so the
26
- * canvas is never touched during SSR.
23
+ * Declarative + multi-series (#176 follow-on): the data contract is the
24
+ * column-aligned `{ category, series[] }` (`ChartData`) produced by `toSeriesData`
25
+ * a single series is the degenerate case, ≥2 series is multi-series. Each series
26
+ * carries its own `type` (bar/line/area/scatter/pie), optional `stack`, and
27
+ * optional secondary `axis`; the ECharts `option` is built by the PURE
28
+ * `buildChartOption` (unit-tested separately). Back-compat: the legacy
29
+ * `items={[{label,value}]}` + `kind` props synthesise a single series.
27
30
  *
28
- * Vertical-agnostic: `items` is already shaped to `{ label, value }` by the
29
- * consumer (a widget over a public aggregate VIEW). Themed from the live
30
- * `--color-*` semantic tokens read off the container, and re-themed on
31
- * `data-theme` / `prefers-color-scheme` change (#244), so dark / high-contrast
32
- * schemes ride through. Soft-empty: no items renders nothing.
31
+ * ECharts is LAZY-loaded inside the mount effect, so the heavy library lands in
32
+ * its OWN async chunk a page that never mounts a chart never pays for it.
33
+ * Effects are client-only in Svelte 5, so the canvas is never touched during SSR.
34
+ *
35
+ * Themed from the live `--color-*` semantic tokens read off the container, and
36
+ * re-themed on `data-theme` / `prefers-color-scheme` change (#244). The per-series
37
+ * palette is an alpha ramp off `--color-accent` — no hardcoded colour list.
38
+ * Soft-empty: no series/category → renders nothing.
33
39
  *
34
40
  * @example
35
41
  * <EChart
36
- * caption="Votes per proposal"
37
- * labelHeader="Proposal"
38
- * valueHeader="Votes"
39
- * items={[
40
- * { label: "Ciclovia da Marginal", value: 1284 },
41
- * { label: "Parque infantil de Gaia", value: 967 },
42
+ * caption="Reports by status"
43
+ * labelHeader="Status"
44
+ * category={["Open", "In progress", "Resolved"]}
45
+ * series={[
46
+ * { name: "Open", type: "bar", data: [12, 0, 0] },
47
+ * { name: "Resolved", type: "bar", data: [0, 0, 31] },
42
48
  * ]}
49
+ * legend
43
50
  * locale="pt"
44
51
  * />
45
52
  */
46
53
  declare const EChart: import("svelte").Component<{
54
+ category?: any[];
55
+ series?: any[];
56
+ legend?: boolean;
57
+ orientation?: any;
58
+ ySecondary?: boolean;
59
+ innerRadius?: number;
47
60
  items?: any[];
61
+ kind?: string;
48
62
  caption?: string;
49
63
  labelHeader?: string;
50
64
  valueHeader?: string;
51
65
  locale?: string;
52
- kind?: string;
53
66
  height?: string;
54
67
  class?: string;
55
68
  } & Record<string, any>, {}, "">;
56
69
  type $$ComponentProps = {
70
+ category?: any[];
71
+ series?: any[];
72
+ legend?: boolean;
73
+ orientation?: any;
74
+ ySecondary?: boolean;
75
+ innerRadius?: number;
57
76
  items?: any[];
77
+ kind?: string;
58
78
  caption?: string;
59
79
  labelHeader?: string;
60
80
  valueHeader?: string;
61
81
  locale?: string;
62
- kind?: string;
63
82
  height?: string;
64
83
  class?: string;
65
84
  } & Record<string, any>;
@@ -1,46 +1,53 @@
1
1
  <!--
2
2
  @component ResultsChart
3
3
 
4
- A horizontal bar chart for a single categorical aggregate (votes per proposal,
5
- reports per category, consumption per meter) that SHIPS ITS DATA TABLE. The
6
- visual bars are decorative (`aria-hidden`); the accompanying `<table>` is the
7
- accessible source of truth, always rendered — so the data is reachable with CSS
8
- off, at any zoom, and to assistive tech. This is a hard requirement (WCAG /
9
- ARTE: a chart must not be the only encoding of its data), not a toggle.
10
-
11
- Vertical-agnostic: `items` is already shaped to `{ label, value }`; the consumer
12
- (a portal widget over a public aggregate VIEW) maps the view columns and orders
13
- the rows. Dependency-free (pure CSS bars) so it stays a11y- and SSR-clean.
4
+ A dependency-free (pure-CSS) categorical chart that SHIPS ITS DATA TABLE the
5
+ SSR / no-JS fallback sibling of `EChart`. The visual bars are decorative
6
+ (`aria-hidden`); the accompanying `<table>` is the accessible source of truth,
7
+ always rendered — so the data is reachable with CSS off, at any zoom, and to
8
+ assistive tech. This is a hard requirement (WCAG / ARTE: a chart must not be the
9
+ only encoding of its data), not a toggle.
10
+
11
+ Declarative + multi-series (#176 follow-on): consumes the same column-aligned
12
+ `{ category, series[] }` (`ChartData`) as `EChart`. The data TABLE renders one
13
+ value column per series (full fidelity, no JS); the CSS bars render the FIRST
14
+ series only (a simple horizontal bar is the honest no-JS visual — stacked /
15
+ dual-axis geometry needs the canvas). Back-compat: legacy `items={[{label,
16
+ value}]}` synthesises a single series.
17
+
14
18
  Consumes semantic tokens so dark / high-contrast schemes (#244) ride through.
15
- Soft-empty: no items → renders nothing.
19
+ Soft-empty: no series/category → renders nothing.
16
20
 
17
21
  @example
18
22
  <ResultsChart
19
23
  caption="Votes per proposal"
20
24
  labelHeader="Proposal"
21
- valueHeader="Votes"
22
- items={[
23
- { label: "Ciclovia da Marginal", value: 1284 },
24
- { label: "Parque infantil de Gaia", value: 967 },
25
- ]}
25
+ category={["Ciclovia", "Parque"]}
26
+ series={[{ name: "Votes", type: "bar", data: [1284, 967] }]}
26
27
  locale="pt"
27
28
  />
28
29
  -->
29
30
  <script module>
30
31
  /**
31
32
  * @typedef {{ label: string, value: number }} ChartItem
33
+ * @typedef {import('./renderer/aggregate').ChartData} ChartData
34
+ * @typedef {import('./renderer/aggregate').SeriesData} SeriesData
32
35
  */
33
36
  </script>
34
37
 
35
38
  <script>
36
39
  let {
37
- /** @type {ChartItem[]} The categorical rows to plot. */
40
+ /** @type {string[]} The category ticks. */
41
+ category = [],
42
+ /** @type {SeriesData[]} The series (≥1; the table shows all, bars show #1). */
43
+ series = [],
44
+ /** @type {ChartItem[]} BACK-COMPAT: single-series `{label,value}` rows. */
38
45
  items = [],
39
46
  /** @type {string} Accessible caption / heading for the chart + table. */
40
47
  caption = "Results",
41
48
  /** @type {string} Column header for the category (localize it). */
42
49
  labelHeader = "Item",
43
- /** @type {string} Column header for the value (localize it). */
50
+ /** @type {string} Header for the (legacy single) value column (localize it). */
44
51
  valueHeader = "Value",
45
52
  /** @type {string} BCP-47 locale for number formatting. */
46
53
  locale = "en",
@@ -49,8 +56,26 @@
49
56
  ...rest
50
57
  } = $props();
51
58
 
59
+ /** @type {ChartData} */
60
+ const chartData = $derived.by(() => {
61
+ if (series.length > 0) return { category, series };
62
+ if (items.length === 0) return { category: [], series: [] };
63
+ return {
64
+ category: items.map((it) => it.label),
65
+ series: [
66
+ { name: valueHeader, type: /** @type {SeriesData["type"]} */ ("bar"), data: items.map((it) => it.value) },
67
+ ],
68
+ };
69
+ });
70
+
71
+ const hasData = $derived(
72
+ chartData.series.length > 0 && chartData.category.length > 0,
73
+ );
74
+
75
+ // CSS bars visualise the FIRST series only.
76
+ const barSeries = $derived(chartData.series[0]);
52
77
  const max = $derived(
53
- items.reduce((m, it) => (it.value > m ? it.value : m), 0),
78
+ (barSeries?.data ?? []).reduce((m, v) => (v > m ? v : m), 0),
54
79
  );
55
80
 
56
81
  /** @param {number} value @returns {string} */
@@ -64,39 +89,44 @@
64
89
  }
65
90
  </script>
66
91
 
67
- {#if items.length > 0}
92
+ {#if hasData}
68
93
  <figure class="results-chart {className}" aria-label={caption} {...rest}>
69
- <!-- Decorative visual: the data lives in the table below. -->
94
+ <!-- Decorative visual (first series): the full data lives in the table below. -->
70
95
  <div class="results-chart-bars" aria-hidden="true">
71
- {#each items as item, i (item.label + i)}
96
+ {#each chartData.category as cat, i (cat + i)}
72
97
  <div class="results-bar-row">
73
- <span class="results-bar-label">{item.label}</span>
98
+ <span class="results-bar-label">{cat}</span>
74
99
  <span class="results-bar-track">
75
100
  <span
76
101
  class="results-bar-fill"
77
- style="inline-size: {pct(item.value)}%"
102
+ style="inline-size: {pct(barSeries?.data[i] ?? 0)}%"
78
103
  ></span>
79
104
  </span>
80
- <span class="results-bar-value">{fmt(item.value)}</span>
105
+ <span class="results-bar-value">{fmt(barSeries?.data[i] ?? 0)}</span>
81
106
  </div>
82
107
  {/each}
83
108
  </div>
84
109
 
85
- <!-- Accessible source of truth — always rendered (ARTE #3/#6). Named via
86
- aria-label, not a <caption>: position:absolute/clip is unreliable on a
87
- display:table-caption element (it leaks visibly in some engines). -->
110
+ <!-- Accessible source of truth — always rendered (ARTE #3/#6), one value
111
+ column per series. Named via aria-label, not a <caption>:
112
+ position:absolute/clip is unreliable on a display:table-caption element
113
+ (it leaks visibly in some engines). -->
88
114
  <table class="results-chart-table" aria-label={caption}>
89
115
  <thead>
90
116
  <tr>
91
117
  <th scope="col">{labelHeader}</th>
92
- <th scope="col">{valueHeader}</th>
118
+ {#each chartData.series as s, si (si)}
119
+ <th scope="col">{s.name}</th>
120
+ {/each}
93
121
  </tr>
94
122
  </thead>
95
123
  <tbody>
96
- {#each items as item, i (item.label + i)}
124
+ {#each chartData.category as cat, r (cat + r)}
97
125
  <tr>
98
- <th scope="row">{item.label}</th>
99
- <td>{fmt(item.value)}</td>
126
+ <th scope="row">{cat}</th>
127
+ {#each chartData.series as s, si (si)}
128
+ <td>{fmt(s.data[r] ?? 0)}</td>
129
+ {/each}
100
130
  </tr>
101
131
  {/each}
102
132
  </tbody>