@aiaiai-pt/design-system 0.33.0 → 0.35.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 +266 -0
- package/components/EChart.svelte.d.ts +63 -0
- package/components/index.js +3 -0
- package/components/renderer/EChartWidget.svelte +78 -0
- package/components/renderer/EChartWidget.svelte.d.ts +4 -0
- package/components/renderer/ResultsChartWidget.svelte +77 -0
- package/components/renderer/ResultsChartWidget.svelte.d.ts +4 -0
- package/components/renderer/StatGridWidget.svelte +33 -0
- package/components/renderer/StatGridWidget.svelte.d.ts +4 -0
- package/components/renderer/aggregate.d.ts +34 -0
- package/components/renderer/aggregate.ts +72 -0
- package/components/renderer/data-provider.d.ts +82 -0
- package/components/renderer/data-provider.ts +162 -0
- package/components/renderer/dispatch.d.ts +63 -0
- package/components/renderer/dispatch.ts +99 -0
- package/components/renderer/registry.d.ts +48 -0
- package/components/renderer/registry.ts +101 -0
- package/components/renderer/resolve-data.d.ts +109 -0
- package/components/renderer/resolve-data.ts +619 -0
- package/components/renderer/types.d.ts +164 -0
- package/components/renderer/types.ts +250 -0
- package/components/renderer/vote.d.ts +121 -0
- package/components/renderer/vote.ts +253 -0
- package/package.json +17 -3
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component EChart
|
|
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.
|
|
24
|
+
|
|
25
|
+
@example
|
|
26
|
+
<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 },
|
|
33
|
+
]}
|
|
34
|
+
locale="pt"
|
|
35
|
+
/>
|
|
36
|
+
-->
|
|
37
|
+
<script module>
|
|
38
|
+
/**
|
|
39
|
+
* @typedef {{ label: string, value: number }} ChartItem
|
|
40
|
+
*/
|
|
41
|
+
</script>
|
|
42
|
+
|
|
43
|
+
<script>
|
|
44
|
+
import { watchTheme } from "./map-utils.js";
|
|
45
|
+
|
|
46
|
+
let {
|
|
47
|
+
/** @type {ChartItem[]} The categorical rows to plot. */
|
|
48
|
+
items = [],
|
|
49
|
+
/** @type {string} Accessible caption / heading for the chart + table. */
|
|
50
|
+
caption = "Results",
|
|
51
|
+
/** @type {string} Column header for the category (localize it). */
|
|
52
|
+
labelHeader = "Item",
|
|
53
|
+
/** @type {string} Column header for the value (localize it). */
|
|
54
|
+
valueHeader = "Value",
|
|
55
|
+
/** @type {string} BCP-47 locale for number formatting. */
|
|
56
|
+
locale = "en",
|
|
57
|
+
/** @type {string} CSS block-size for the chart canvas. */
|
|
58
|
+
height = "20rem",
|
|
59
|
+
/** @type {string} */
|
|
60
|
+
class: className = "",
|
|
61
|
+
...rest
|
|
62
|
+
} = $props();
|
|
63
|
+
|
|
64
|
+
/** @param {number} value @returns {string} */
|
|
65
|
+
function fmt(value) {
|
|
66
|
+
return new Intl.NumberFormat(locale).format(value);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** @type {HTMLElement | undefined} */
|
|
70
|
+
let container = $state();
|
|
71
|
+
|
|
72
|
+
// The ECharts instance + the canvas-renderer chart option are owned by the
|
|
73
|
+
// mount effect; this effect (re-)builds the option whenever `items`, the
|
|
74
|
+
// locale, or the theme change. `_chart` is hoisted so the data effect can
|
|
75
|
+
// re-setOption without re-initialising the canvas.
|
|
76
|
+
/** @type {import('echarts/core').EChartsType | undefined} */
|
|
77
|
+
let _chart = $state();
|
|
78
|
+
|
|
79
|
+
/** Build the ECharts option from current items + the container's tokens. */
|
|
80
|
+
function buildOption() {
|
|
81
|
+
const el = container;
|
|
82
|
+
if (!el) return {};
|
|
83
|
+
const cs = getComputedStyle(el);
|
|
84
|
+
const token = (name, fallback) => {
|
|
85
|
+
const v = cs.getPropertyValue(name).trim();
|
|
86
|
+
return v || fallback;
|
|
87
|
+
};
|
|
88
|
+
const accent = token("--color-accent", "#2563eb");
|
|
89
|
+
const textPrimary = token("--color-text-primary", "#1f2937");
|
|
90
|
+
const textSecondary = token("--color-text-secondary", "#6b7280");
|
|
91
|
+
const border = token("--color-border", "#e5e7eb");
|
|
92
|
+
|
|
93
|
+
// ECharts plots the category axis bottom-up; reverse so the highest value
|
|
94
|
+
// sits at the top (matching ResultsChart's top-down order). `items` is
|
|
95
|
+
// already sorted desc by the consumer.
|
|
96
|
+
const ordered = [...items].reverse();
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
animation: false,
|
|
100
|
+
grid: { left: 8, right: 16, top: 8, bottom: 8, containLabel: true },
|
|
101
|
+
tooltip: {
|
|
102
|
+
trigger: "item",
|
|
103
|
+
/** @param {{ name: string, value: number }} p */
|
|
104
|
+
formatter: (p) => `${p.name}: ${fmt(p.value)}`,
|
|
105
|
+
},
|
|
106
|
+
xAxis: {
|
|
107
|
+
type: "value",
|
|
108
|
+
axisLabel: { color: textSecondary, formatter: (/** @type {number} */ v) => fmt(v) },
|
|
109
|
+
axisLine: { lineStyle: { color: border } },
|
|
110
|
+
splitLine: { lineStyle: { color: border } },
|
|
111
|
+
},
|
|
112
|
+
yAxis: {
|
|
113
|
+
type: "category",
|
|
114
|
+
data: ordered.map((it) => it.label),
|
|
115
|
+
axisLabel: { color: textPrimary },
|
|
116
|
+
axisLine: { lineStyle: { color: border } },
|
|
117
|
+
axisTick: { show: false },
|
|
118
|
+
},
|
|
119
|
+
series: [
|
|
120
|
+
{
|
|
121
|
+
type: "bar",
|
|
122
|
+
data: ordered.map((it) => it.value),
|
|
123
|
+
itemStyle: { color: accent, borderRadius: [0, 4, 4, 0] },
|
|
124
|
+
barMaxWidth: 28,
|
|
125
|
+
},
|
|
126
|
+
],
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
$effect(() => {
|
|
131
|
+
if (!container) return;
|
|
132
|
+
|
|
133
|
+
let disposed = false;
|
|
134
|
+
/** @type {import('echarts/core').EChartsType | undefined} */
|
|
135
|
+
let chart;
|
|
136
|
+
/** @type {ResizeObserver | undefined} */
|
|
137
|
+
let ro;
|
|
138
|
+
/** @type {(() => void) | undefined} */
|
|
139
|
+
let disposeTheme;
|
|
140
|
+
|
|
141
|
+
(async () => {
|
|
142
|
+
try {
|
|
143
|
+
const [core, charts, components, renderers] = await Promise.all([
|
|
144
|
+
import("echarts/core"),
|
|
145
|
+
import("echarts/charts"),
|
|
146
|
+
import("echarts/components"),
|
|
147
|
+
import("echarts/renderers"),
|
|
148
|
+
]);
|
|
149
|
+
if (disposed || !container) return;
|
|
150
|
+
|
|
151
|
+
core.use([
|
|
152
|
+
charts.BarChart,
|
|
153
|
+
components.GridComponent,
|
|
154
|
+
components.TooltipComponent,
|
|
155
|
+
renderers.CanvasRenderer,
|
|
156
|
+
]);
|
|
157
|
+
|
|
158
|
+
chart = core.init(container, null, { renderer: "canvas" });
|
|
159
|
+
_chart = chart;
|
|
160
|
+
chart.setOption(buildOption());
|
|
161
|
+
|
|
162
|
+
ro = new ResizeObserver(() => chart?.resize());
|
|
163
|
+
ro.observe(container);
|
|
164
|
+
|
|
165
|
+
disposeTheme = watchTheme(() => chart?.setOption(buildOption()));
|
|
166
|
+
} catch (err) {
|
|
167
|
+
// Fail soft: the <table> below is the accessible source of truth, so a
|
|
168
|
+
// failed chart load degrades to data-only, never to a broken page.
|
|
169
|
+
// eslint-disable-next-line no-console
|
|
170
|
+
console.error("EChart: failed to render", err);
|
|
171
|
+
}
|
|
172
|
+
})();
|
|
173
|
+
|
|
174
|
+
return () => {
|
|
175
|
+
disposed = true;
|
|
176
|
+
disposeTheme?.();
|
|
177
|
+
ro?.disconnect();
|
|
178
|
+
chart?.dispose();
|
|
179
|
+
_chart = undefined;
|
|
180
|
+
};
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// Re-apply the option when items / locale change after mount (the canvas
|
|
184
|
+
// persists; only the data is swapped). Guarded on `_chart` so it no-ops
|
|
185
|
+
// before init and after dispose.
|
|
186
|
+
$effect(() => {
|
|
187
|
+
// touch reactive deps so the effect re-runs when they change
|
|
188
|
+
void items;
|
|
189
|
+
void locale;
|
|
190
|
+
if (_chart) _chart.setOption(buildOption());
|
|
191
|
+
});
|
|
192
|
+
</script>
|
|
193
|
+
|
|
194
|
+
{#if items.length > 0}
|
|
195
|
+
<figure class="echart {className}" aria-label={caption} {...rest}>
|
|
196
|
+
<!-- Decorative visual: the data lives in the table below. -->
|
|
197
|
+
<div
|
|
198
|
+
class="echart-canvas"
|
|
199
|
+
bind:this={container}
|
|
200
|
+
aria-hidden="true"
|
|
201
|
+
style="block-size: {height}"
|
|
202
|
+
></div>
|
|
203
|
+
|
|
204
|
+
<!-- Accessible source of truth — always rendered (ARTE #3/#6). Named via
|
|
205
|
+
aria-label, not a <caption>: position:absolute/clip is unreliable on a
|
|
206
|
+
display:table-caption element (it leaks visibly in some engines). -->
|
|
207
|
+
<table class="echart-table" aria-label={caption}>
|
|
208
|
+
<thead>
|
|
209
|
+
<tr>
|
|
210
|
+
<th scope="col">{labelHeader}</th>
|
|
211
|
+
<th scope="col">{valueHeader}</th>
|
|
212
|
+
</tr>
|
|
213
|
+
</thead>
|
|
214
|
+
<tbody>
|
|
215
|
+
{#each items as item, i (item.label + i)}
|
|
216
|
+
<tr>
|
|
217
|
+
<th scope="row">{item.label}</th>
|
|
218
|
+
<td>{fmt(item.value)}</td>
|
|
219
|
+
</tr>
|
|
220
|
+
{/each}
|
|
221
|
+
</tbody>
|
|
222
|
+
</table>
|
|
223
|
+
</figure>
|
|
224
|
+
{/if}
|
|
225
|
+
|
|
226
|
+
<style>
|
|
227
|
+
.echart {
|
|
228
|
+
margin: 0;
|
|
229
|
+
display: flex;
|
|
230
|
+
flex-direction: column;
|
|
231
|
+
gap: var(--space-md);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
.echart-canvas {
|
|
235
|
+
inline-size: 100%;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
.echart-table {
|
|
239
|
+
inline-size: 100%;
|
|
240
|
+
border-collapse: collapse;
|
|
241
|
+
font-size: var(--type-body-sm-size);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
.echart-table th,
|
|
245
|
+
.echart-table td {
|
|
246
|
+
text-align: start;
|
|
247
|
+
padding: var(--space-2xs) var(--space-xs);
|
|
248
|
+
border-block-end: 1px solid var(--color-border);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
.echart-table thead th {
|
|
252
|
+
color: var(--color-text-secondary);
|
|
253
|
+
font-weight: 600;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
.echart-table tbody th {
|
|
257
|
+
font-weight: 400;
|
|
258
|
+
color: var(--color-text-primary);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
.echart-table td {
|
|
262
|
+
text-align: end;
|
|
263
|
+
font-variant-numeric: tabular-nums;
|
|
264
|
+
color: var(--color-text-primary);
|
|
265
|
+
}
|
|
266
|
+
</style>
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
export default EChart;
|
|
2
|
+
export type ChartItem = {
|
|
3
|
+
label: string;
|
|
4
|
+
value: number;
|
|
5
|
+
};
|
|
6
|
+
type EChart = {
|
|
7
|
+
$on?(type: string, callback: (e: any) => void): () => void;
|
|
8
|
+
$set?(props: Partial<$$ComponentProps>): void;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* EChart
|
|
12
|
+
*
|
|
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.
|
|
21
|
+
*
|
|
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.
|
|
27
|
+
*
|
|
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.
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* <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
|
+
* ]}
|
|
43
|
+
* locale="pt"
|
|
44
|
+
* />
|
|
45
|
+
*/
|
|
46
|
+
declare const EChart: import("svelte").Component<{
|
|
47
|
+
items?: any[];
|
|
48
|
+
caption?: string;
|
|
49
|
+
labelHeader?: string;
|
|
50
|
+
valueHeader?: string;
|
|
51
|
+
locale?: string;
|
|
52
|
+
height?: string;
|
|
53
|
+
class?: string;
|
|
54
|
+
} & Record<string, any>, {}, "">;
|
|
55
|
+
type $$ComponentProps = {
|
|
56
|
+
items?: any[];
|
|
57
|
+
caption?: string;
|
|
58
|
+
labelHeader?: string;
|
|
59
|
+
valueHeader?: string;
|
|
60
|
+
locale?: string;
|
|
61
|
+
height?: string;
|
|
62
|
+
class?: string;
|
|
63
|
+
} & Record<string, any>;
|
package/components/index.js
CHANGED
|
@@ -102,6 +102,9 @@ export { default as PhaseTimeline } from "./PhaseTimeline.svelte";
|
|
|
102
102
|
// encoding of its data); RankingBoard is a semantic ordered leaderboard.
|
|
103
103
|
export { default as RankingBoard } from "./RankingBoard.svelte";
|
|
104
104
|
export { default as ResultsChart } from "./ResultsChart.svelte";
|
|
105
|
+
// EChart (#498) — the ECharts-backed sibling of ResultsChart: same data-table
|
|
106
|
+
// a11y contract, lazy-loads echarts into its own async chunk.
|
|
107
|
+
export { default as EChart } from "./EChart.svelte";
|
|
105
108
|
|
|
106
109
|
// Account / preferences (#105 Phase 5) — the citizen account area's
|
|
107
110
|
// notification-preferences panel: a toggle list over a citizen's subscriptions.
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import EChart from "../EChart.svelte";
|
|
3
|
+
import type { WidgetProps } from "./types";
|
|
4
|
+
import { toRankedItems } from "./aggregate";
|
|
5
|
+
|
|
6
|
+
// `chart` widget (#498) — an ECharts bar chart over a public aggregate VIEW,
|
|
7
|
+
// 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
|
+
let { data, props, ownsH1, locale }: WidgetProps = $props();
|
|
15
|
+
|
|
16
|
+
const rows = $derived(
|
|
17
|
+
data && typeof data === "object" && Array.isArray((data as { items?: unknown }).items)
|
|
18
|
+
? ((data as { items: Record<string, unknown>[] }).items)
|
|
19
|
+
: [],
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
const labelField = $derived(
|
|
23
|
+
typeof props.label_field === "string" && props.label_field
|
|
24
|
+
? props.label_field
|
|
25
|
+
: "label",
|
|
26
|
+
);
|
|
27
|
+
const valueField = $derived(
|
|
28
|
+
typeof props.value_field === "string" && props.value_field
|
|
29
|
+
? props.value_field
|
|
30
|
+
: "value",
|
|
31
|
+
);
|
|
32
|
+
const limit = $derived(
|
|
33
|
+
typeof props.limit === "number" && props.limit > 0 ? props.limit : 0,
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
const items = $derived(
|
|
37
|
+
toRankedItems(rows, { labelField, valueField, limit }),
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
const heading = $derived(
|
|
41
|
+
typeof props.title === "string" && props.title ? props.title : "",
|
|
42
|
+
);
|
|
43
|
+
const caption = $derived(heading || "Results");
|
|
44
|
+
const labelHeader = $derived(
|
|
45
|
+
typeof props.label_header === "string" && props.label_header
|
|
46
|
+
? props.label_header
|
|
47
|
+
: "Item",
|
|
48
|
+
);
|
|
49
|
+
const valueHeader = $derived(
|
|
50
|
+
typeof props.value_header === "string" && props.value_header
|
|
51
|
+
? props.value_header
|
|
52
|
+
: "Value",
|
|
53
|
+
);
|
|
54
|
+
</script>
|
|
55
|
+
|
|
56
|
+
{#if items.length > 0}
|
|
57
|
+
<section class="widget-band">
|
|
58
|
+
{#if heading}
|
|
59
|
+
{#if ownsH1}<h1>{heading}</h1>{:else}<h2>{heading}</h2>{/if}
|
|
60
|
+
{/if}
|
|
61
|
+
<EChart
|
|
62
|
+
{items}
|
|
63
|
+
{caption}
|
|
64
|
+
{labelHeader}
|
|
65
|
+
{valueHeader}
|
|
66
|
+
locale={locale ?? "en"}
|
|
67
|
+
/>
|
|
68
|
+
</section>
|
|
69
|
+
{/if}
|
|
70
|
+
|
|
71
|
+
<style>
|
|
72
|
+
.widget-band {
|
|
73
|
+
width: 100%;
|
|
74
|
+
max-width: var(--content-width-narrow);
|
|
75
|
+
margin-inline: auto;
|
|
76
|
+
padding-inline: var(--content-padding-x);
|
|
77
|
+
}
|
|
78
|
+
</style>
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import ResultsChart from "../ResultsChart.svelte";
|
|
3
|
+
import type { WidgetProps } from "./types";
|
|
4
|
+
import { toRankedItems } from "./aggregate";
|
|
5
|
+
|
|
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.
|
|
13
|
+
let { data, props, ownsH1, locale }: WidgetProps = $props();
|
|
14
|
+
|
|
15
|
+
const rows = $derived(
|
|
16
|
+
data && typeof data === "object" && Array.isArray((data as { items?: unknown }).items)
|
|
17
|
+
? ((data as { items: Record<string, unknown>[] }).items)
|
|
18
|
+
: [],
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
const labelField = $derived(
|
|
22
|
+
typeof props.label_field === "string" && props.label_field
|
|
23
|
+
? props.label_field
|
|
24
|
+
: "label",
|
|
25
|
+
);
|
|
26
|
+
const valueField = $derived(
|
|
27
|
+
typeof props.value_field === "string" && props.value_field
|
|
28
|
+
? props.value_field
|
|
29
|
+
: "value",
|
|
30
|
+
);
|
|
31
|
+
const limit = $derived(
|
|
32
|
+
typeof props.limit === "number" && props.limit > 0 ? props.limit : 0,
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
const items = $derived(
|
|
36
|
+
toRankedItems(rows, { labelField, valueField, limit }),
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
const heading = $derived(
|
|
40
|
+
typeof props.title === "string" && props.title ? props.title : "",
|
|
41
|
+
);
|
|
42
|
+
const caption = $derived(heading || "Results");
|
|
43
|
+
const labelHeader = $derived(
|
|
44
|
+
typeof props.label_header === "string" && props.label_header
|
|
45
|
+
? props.label_header
|
|
46
|
+
: "Item",
|
|
47
|
+
);
|
|
48
|
+
const valueHeader = $derived(
|
|
49
|
+
typeof props.value_header === "string" && props.value_header
|
|
50
|
+
? props.value_header
|
|
51
|
+
: "Value",
|
|
52
|
+
);
|
|
53
|
+
</script>
|
|
54
|
+
|
|
55
|
+
{#if items.length > 0}
|
|
56
|
+
<section class="widget-band">
|
|
57
|
+
{#if heading}
|
|
58
|
+
{#if ownsH1}<h1>{heading}</h1>{:else}<h2>{heading}</h2>{/if}
|
|
59
|
+
{/if}
|
|
60
|
+
<ResultsChart
|
|
61
|
+
{items}
|
|
62
|
+
{caption}
|
|
63
|
+
{labelHeader}
|
|
64
|
+
{valueHeader}
|
|
65
|
+
locale={locale ?? "en"}
|
|
66
|
+
/>
|
|
67
|
+
</section>
|
|
68
|
+
{/if}
|
|
69
|
+
|
|
70
|
+
<style>
|
|
71
|
+
.widget-band {
|
|
72
|
+
width: 100%;
|
|
73
|
+
max-width: var(--content-width-narrow);
|
|
74
|
+
margin-inline: auto;
|
|
75
|
+
padding-inline: var(--content-padding-x);
|
|
76
|
+
}
|
|
77
|
+
</style>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import StatGrid from "../StatGrid.svelte";
|
|
3
|
+
import StatCard from "../StatCard.svelte";
|
|
4
|
+
import type { WidgetProps } from "./types";
|
|
5
|
+
|
|
6
|
+
// `kpi` widget — public stat grid (counts, KPIs). Adapts a list of stat
|
|
7
|
+
// items into DS StatCards inside a StatGrid. The public KPI read endpoint is
|
|
8
|
+
// not wired yet (resolveData fails closed for `kind: "kpi"`); registered here
|
|
9
|
+
// and fed once a slice adds the surface. Defensive over `data` (live) /
|
|
10
|
+
// `props` (seeded preview).
|
|
11
|
+
let { data, props }: WidgetProps = $props();
|
|
12
|
+
|
|
13
|
+
interface Stat {
|
|
14
|
+
label?: string;
|
|
15
|
+
value?: string;
|
|
16
|
+
variant?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const view = $derived((data ?? {}) as Record<string, unknown>);
|
|
20
|
+
const stats = $derived<Stat[]>(
|
|
21
|
+
(Array.isArray(view.stats)
|
|
22
|
+
? view.stats
|
|
23
|
+
: Array.isArray(props.stats)
|
|
24
|
+
? props.stats
|
|
25
|
+
: []) as Stat[],
|
|
26
|
+
);
|
|
27
|
+
</script>
|
|
28
|
+
|
|
29
|
+
<StatGrid>
|
|
30
|
+
{#each stats as stat (stat.label)}
|
|
31
|
+
<StatCard label={stat.label} value={stat.value} variant={stat.variant} />
|
|
32
|
+
{/each}
|
|
33
|
+
</StatGrid>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Aggregate-view row shaping (#105 Phase 4) — PURE, vertical-agnostic.
|
|
3
|
+
*
|
|
4
|
+
* Turns the rows of a public aggregate VIEW (served by the BFF public-view lane,
|
|
5
|
+
* #250 M3 — `{ items: [{ <label_field>, <value_field>, … }] }`) into the
|
|
6
|
+
* `{ label, value }` shape the DS `RankingBoard` / `ResultsChart` render, sorted
|
|
7
|
+
* by value descending and optionally capped. Nothing here is civic-specific:
|
|
8
|
+
* the label/value columns are config (`label_field` / `value_field`), so any
|
|
9
|
+
* `group_by + count/sum` view (votes per proposal, reports per category,
|
|
10
|
+
* consumption per meter) becomes a chart/board from config alone.
|
|
11
|
+
*
|
|
12
|
+
* Coercion is deliberate and fail-soft: a missing/blank label falls back to the
|
|
13
|
+
* empty string (the widget decides whether to drop it), a non-numeric value
|
|
14
|
+
* coerces to 0 — an aggregate read never throws on a stray row.
|
|
15
|
+
*/
|
|
16
|
+
export interface RankedItem {
|
|
17
|
+
label: string;
|
|
18
|
+
value: number;
|
|
19
|
+
}
|
|
20
|
+
export interface ToRankedOptions {
|
|
21
|
+
/** Key in each row holding the category label. Default `label`. */
|
|
22
|
+
labelField?: string;
|
|
23
|
+
/** Key holding the numeric aggregate. Default `value`. */
|
|
24
|
+
valueField?: string;
|
|
25
|
+
/** Cap the result to the top-N after sorting. 0/absent → no cap. */
|
|
26
|
+
limit?: number;
|
|
27
|
+
/** Drop rows whose label coerces to empty. Default true. */
|
|
28
|
+
dropBlankLabels?: boolean;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Map raw view rows → sorted `{ label, value }`. Stable sort by value desc
|
|
32
|
+
* (ties keep input order); rows are never mutated.
|
|
33
|
+
*/
|
|
34
|
+
export declare function toRankedItems(rows: ReadonlyArray<Record<string, unknown>>, opts?: ToRankedOptions): RankedItem[];
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Aggregate-view row shaping (#105 Phase 4) — PURE, vertical-agnostic.
|
|
3
|
+
*
|
|
4
|
+
* Turns the rows of a public aggregate VIEW (served by the BFF public-view lane,
|
|
5
|
+
* #250 M3 — `{ items: [{ <label_field>, <value_field>, … }] }`) into the
|
|
6
|
+
* `{ label, value }` shape the DS `RankingBoard` / `ResultsChart` render, sorted
|
|
7
|
+
* by value descending and optionally capped. Nothing here is civic-specific:
|
|
8
|
+
* the label/value columns are config (`label_field` / `value_field`), so any
|
|
9
|
+
* `group_by + count/sum` view (votes per proposal, reports per category,
|
|
10
|
+
* consumption per meter) becomes a chart/board from config alone.
|
|
11
|
+
*
|
|
12
|
+
* Coercion is deliberate and fail-soft: a missing/blank label falls back to the
|
|
13
|
+
* empty string (the widget decides whether to drop it), a non-numeric value
|
|
14
|
+
* coerces to 0 — an aggregate read never throws on a stray row.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export interface RankedItem {
|
|
18
|
+
label: string;
|
|
19
|
+
value: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ToRankedOptions {
|
|
23
|
+
/** Key in each row holding the category label. Default `label`. */
|
|
24
|
+
labelField?: string;
|
|
25
|
+
/** Key holding the numeric aggregate. Default `value`. */
|
|
26
|
+
valueField?: string;
|
|
27
|
+
/** Cap the result to the top-N after sorting. 0/absent → no cap. */
|
|
28
|
+
limit?: number;
|
|
29
|
+
/** Drop rows whose label coerces to empty. Default true. */
|
|
30
|
+
dropBlankLabels?: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function toNumber(value: unknown): number {
|
|
34
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : 0;
|
|
35
|
+
if (typeof value === "string" && value.trim() !== "") {
|
|
36
|
+
const n = Number(value);
|
|
37
|
+
return Number.isFinite(n) ? n : 0;
|
|
38
|
+
}
|
|
39
|
+
return 0;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Map raw view rows → sorted `{ label, value }`. Stable sort by value desc
|
|
44
|
+
* (ties keep input order); rows are never mutated.
|
|
45
|
+
*/
|
|
46
|
+
export function toRankedItems(
|
|
47
|
+
rows: ReadonlyArray<Record<string, unknown>>,
|
|
48
|
+
opts: ToRankedOptions = {},
|
|
49
|
+
): RankedItem[] {
|
|
50
|
+
const labelField = opts.labelField || "label";
|
|
51
|
+
const valueField = opts.valueField || "value";
|
|
52
|
+
const dropBlank = opts.dropBlankLabels !== false;
|
|
53
|
+
|
|
54
|
+
const mapped = rows.map((row) => ({
|
|
55
|
+
label: row[labelField] == null ? "" : String(row[labelField]),
|
|
56
|
+
value: toNumber(row[valueField]),
|
|
57
|
+
}));
|
|
58
|
+
|
|
59
|
+
const kept = dropBlank ? mapped.filter((it) => it.label !== "") : mapped;
|
|
60
|
+
|
|
61
|
+
// Stable desc sort: decorate with the original index so equal values keep
|
|
62
|
+
// their input order (Array.prototype.sort is spec-stable, but the index tie-
|
|
63
|
+
// break documents the intent and is robust to value-equality edge cases).
|
|
64
|
+
const sorted = kept
|
|
65
|
+
.map((it, i) => ({ it, i }))
|
|
66
|
+
.sort((a, b) => b.it.value - a.it.value || a.i - b.i)
|
|
67
|
+
.map(({ it }) => it);
|
|
68
|
+
|
|
69
|
+
return opts.limit && opts.limit > 0
|
|
70
|
+
? sorted.slice(0, Math.floor(opts.limit))
|
|
71
|
+
: sorted;
|
|
72
|
+
}
|