@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
package/components/EChart.svelte
CHANGED
|
@@ -1,64 +1,81 @@
|
|
|
1
1
|
<!--
|
|
2
2
|
@component EChart
|
|
3
3
|
|
|
4
|
-
A
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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="
|
|
28
|
-
labelHeader="
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
{
|
|
32
|
-
{
|
|
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 {
|
|
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}
|
|
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
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
|
255
|
-
// persists; only the
|
|
256
|
-
//
|
|
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
|
-
|
|
259
|
-
void items;
|
|
218
|
+
void chartData;
|
|
260
219
|
void locale;
|
|
261
|
-
void
|
|
262
|
-
|
|
263
|
-
|
|
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
|
|
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)
|
|
279
|
-
aria-label, not a <caption>:
|
|
280
|
-
display:table-caption element
|
|
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
|
-
|
|
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
|
|
252
|
+
{#each chartData.category as cat, r (cat + r)}
|
|
290
253
|
<tr>
|
|
291
|
-
<th scope="row">{
|
|
292
|
-
|
|
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
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
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="
|
|
37
|
-
* labelHeader="
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* {
|
|
41
|
-
* {
|
|
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
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
|
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
|
-
|
|
22
|
-
|
|
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 {
|
|
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}
|
|
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
|
-
|
|
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
|
|
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
|
|
96
|
+
{#each chartData.category as cat, i (cat + i)}
|
|
72
97
|
<div class="results-bar-row">
|
|
73
|
-
<span class="results-bar-label">{
|
|
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(
|
|
102
|
+
style="inline-size: {pct(barSeries?.data[i] ?? 0)}%"
|
|
78
103
|
></span>
|
|
79
104
|
</span>
|
|
80
|
-
<span class="results-bar-value">{fmt(
|
|
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)
|
|
86
|
-
aria-label, not a <caption>:
|
|
87
|
-
display:table-caption element
|
|
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
|
-
|
|
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
|
|
124
|
+
{#each chartData.category as cat, r (cat + r)}
|
|
97
125
|
<tr>
|
|
98
|
-
<th scope="row">{
|
|
99
|
-
|
|
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>
|