@aiaiai-pt/design-system 0.34.0 → 0.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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>;
@@ -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,4 @@
1
+ import type { WidgetProps } from "./types";
2
+ declare const EChartWidget: import("svelte").Component<WidgetProps, {}, "">;
3
+ type EChartWidget = ReturnType<typeof EChartWidget>;
4
+ export default EChartWidget;
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Host-agnostic display transforms (#499 criterion A) — the pure value→text +
3
+ * status-badge + geo helpers the list/detail surfaces of BOTH hosts (the
4
+ * citizen portal AND the staff admin) share. Previously duplicated: the portal
5
+ * kept them in `portal/src/lib/renderer/{cells,columns}.ts`; the admin re-rolled
6
+ * equivalents inline (`renderCell`/`formatValue`). Lifting them here makes the
7
+ * value-redaction boundary one tested function instead of two drifting ones.
8
+ *
9
+ * Two host forks are parameters, not separate copies (the reconciliation #499
10
+ * called out before sharing):
11
+ * - object fallback: the portal REDACTS an unknown object (never leak a blob
12
+ * into the public DOM — a hard rule); the admin JSON-dumps it for operators.
13
+ * → `formatScalar(value, { objectFallback: "redact" | "json" })`, default
14
+ * "redact" (the safe default; a caller opts INTO dumping).
15
+ * - date detection: the portal detects timestamps by VALUE shape (the public
16
+ * schema strips created_at/updated_at, so type-driven detection misses
17
+ * them); the admin knows the field is a datetime from its schema TYPE.
18
+ * → `displayCell(value, locale, { treatAsDate })` — `treatAsDate` forces
19
+ * date formatting (admin, from schema type); otherwise value-shape
20
+ * detection (`isIsoTimestamp`) applies (portal).
21
+ *
22
+ * Everything here is PURE and dependency-free.
23
+ */
24
+ export type ObjectFallback = "redact" | "json";
25
+ /**
26
+ * Pure value → display string. Scalars stringify; arrays join their non-empty
27
+ * formatted members; a disclosed relationship object ({id,label}|{name}|{title})
28
+ * renders its human label then its id. An UNKNOWN object renders per
29
+ * `objectFallback`: "redact" (default) → "" (never dumps internals — the public
30
+ * security boundary); "json" → pretty JSON (the admin operator view).
31
+ */
32
+ export declare function formatScalar(value: unknown, opts?: {
33
+ objectFallback?: ObjectFallback;
34
+ }): string;
35
+ /** True for an ISO-8601 timestamp STRING (not a bare date). Value-shape
36
+ * detection — used where the schema can't tell us the field is a datetime. */
37
+ export declare function isIsoTimestamp(value: unknown): value is string;
38
+ /** Locale date for an ISO timestamp (date-only, UTC-stable so SSR == hydrate);
39
+ * the raw string when unparseable. */
40
+ export declare function formatTimestamp(value: string, locale: string): string;
41
+ /**
42
+ * The single display boundary for list cells / detail values. Formats a value
43
+ * as a locale date when it's a timestamp — either because `opts.treatAsDate`
44
+ * forces it (the admin, from the schema field type) or because the value itself
45
+ * looks like an ISO timestamp (the portal, value-shape). Everything else goes
46
+ * through `formatScalar` (with the host's object-fallback policy).
47
+ */
48
+ export declare function displayCell(value: unknown, locale: string, opts?: {
49
+ treatAsDate?: boolean;
50
+ objectFallback?: ObjectFallback;
51
+ }): string;
52
+ /** Operator copy for a status value (falls back to the raw value). `labels` is
53
+ * untrusted DATA — a non-object/array is ignored. */
54
+ export declare function statusLabel(value: unknown, labels: unknown): string;
55
+ /** DS Badge variant for a status value (neutral when unmapped). `variants` is
56
+ * untrusted DATA — a non-object/array is ignored. */
57
+ export declare function statusVariant(value: unknown, variants: unknown): string;
58
+ /** Resolve a status value to a `{ label, variant }` pair in one call. */
59
+ export declare function resolveStatusBadge(value: unknown, opts?: {
60
+ labels?: unknown;
61
+ variants?: unknown;
62
+ }): {
63
+ label: string;
64
+ variant: string;
65
+ };
66
+ /**
67
+ * Extract a `[lon, lat]` pair from a GeoJSON-ish Point value (the shape both a
68
+ * detail row's location FK and a MapPicker value use), or null when it isn't a
69
+ * usable point. Pure — the map widget decides how to render the result.
70
+ */
71
+ export declare function geoPoint(value: unknown): [number, number] | null;
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Host-agnostic display transforms (#499 criterion A) — the pure value→text +
3
+ * status-badge + geo helpers the list/detail surfaces of BOTH hosts (the
4
+ * citizen portal AND the staff admin) share. Previously duplicated: the portal
5
+ * kept them in `portal/src/lib/renderer/{cells,columns}.ts`; the admin re-rolled
6
+ * equivalents inline (`renderCell`/`formatValue`). Lifting them here makes the
7
+ * value-redaction boundary one tested function instead of two drifting ones.
8
+ *
9
+ * Two host forks are parameters, not separate copies (the reconciliation #499
10
+ * called out before sharing):
11
+ * - object fallback: the portal REDACTS an unknown object (never leak a blob
12
+ * into the public DOM — a hard rule); the admin JSON-dumps it for operators.
13
+ * → `formatScalar(value, { objectFallback: "redact" | "json" })`, default
14
+ * "redact" (the safe default; a caller opts INTO dumping).
15
+ * - date detection: the portal detects timestamps by VALUE shape (the public
16
+ * schema strips created_at/updated_at, so type-driven detection misses
17
+ * them); the admin knows the field is a datetime from its schema TYPE.
18
+ * → `displayCell(value, locale, { treatAsDate })` — `treatAsDate` forces
19
+ * date formatting (admin, from schema type); otherwise value-shape
20
+ * detection (`isIsoTimestamp`) applies (portal).
21
+ *
22
+ * Everything here is PURE and dependency-free.
23
+ */
24
+
25
+ export type ObjectFallback = "redact" | "json";
26
+
27
+ /**
28
+ * Pure value → display string. Scalars stringify; arrays join their non-empty
29
+ * formatted members; a disclosed relationship object ({id,label}|{name}|{title})
30
+ * renders its human label then its id. An UNKNOWN object renders per
31
+ * `objectFallback`: "redact" (default) → "" (never dumps internals — the public
32
+ * security boundary); "json" → pretty JSON (the admin operator view).
33
+ */
34
+ export function formatScalar(
35
+ value: unknown,
36
+ opts: { objectFallback?: ObjectFallback } = {},
37
+ ): string {
38
+ // `== null` catches both null and undefined.
39
+ if (value == null) return "";
40
+ if (typeof value === "string") return value;
41
+ if (typeof value === "number" || typeof value === "boolean") {
42
+ return String(value);
43
+ }
44
+ if (Array.isArray(value)) {
45
+ return value
46
+ .map((v) => formatScalar(v, opts))
47
+ .filter(Boolean)
48
+ .join(", ");
49
+ }
50
+ if (typeof value === "object") {
51
+ const o = value as Record<string, unknown>;
52
+ for (const k of ["label", "name", "title"]) {
53
+ const v = o[k];
54
+ if (typeof v === "string" && v) return v;
55
+ }
56
+ const id = o.id;
57
+ if (typeof id === "string" || typeof id === "number") return String(id);
58
+ // No human handle and no id → fall back per host policy.
59
+ if (opts.objectFallback === "json") {
60
+ try {
61
+ return JSON.stringify(value, null, 2);
62
+ } catch {
63
+ return "";
64
+ }
65
+ }
66
+ return "";
67
+ }
68
+ return "";
69
+ }
70
+
71
+ const ISO_TIMESTAMP_RE =
72
+ /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})$/;
73
+
74
+ /** True for an ISO-8601 timestamp STRING (not a bare date). Value-shape
75
+ * detection — used where the schema can't tell us the field is a datetime. */
76
+ export function isIsoTimestamp(value: unknown): value is string {
77
+ return typeof value === "string" && ISO_TIMESTAMP_RE.test(value);
78
+ }
79
+
80
+ /** Locale date for an ISO timestamp (date-only, UTC-stable so SSR == hydrate);
81
+ * the raw string when unparseable. */
82
+ export function formatTimestamp(value: string, locale: string): string {
83
+ const parsed = new Date(value);
84
+ if (Number.isNaN(parsed.getTime())) return value;
85
+ try {
86
+ return new Intl.DateTimeFormat(locale || "en", {
87
+ dateStyle: "medium",
88
+ timeZone: "UTC",
89
+ }).format(parsed);
90
+ } catch {
91
+ return value;
92
+ }
93
+ }
94
+
95
+ /**
96
+ * The single display boundary for list cells / detail values. Formats a value
97
+ * as a locale date when it's a timestamp — either because `opts.treatAsDate`
98
+ * forces it (the admin, from the schema field type) or because the value itself
99
+ * looks like an ISO timestamp (the portal, value-shape). Everything else goes
100
+ * through `formatScalar` (with the host's object-fallback policy).
101
+ */
102
+ export function displayCell(
103
+ value: unknown,
104
+ locale: string,
105
+ opts: { treatAsDate?: boolean; objectFallback?: ObjectFallback } = {},
106
+ ): string {
107
+ if (opts.treatAsDate && typeof value === "string" && value) {
108
+ return formatTimestamp(value, locale);
109
+ }
110
+ if (isIsoTimestamp(value)) return formatTimestamp(value, locale);
111
+ return formatScalar(value, { objectFallback: opts.objectFallback });
112
+ }
113
+
114
+ /** Operator copy for a status value (falls back to the raw value). `labels` is
115
+ * untrusted DATA — a non-object/array is ignored. */
116
+ export function statusLabel(value: unknown, labels: unknown): string {
117
+ const raw = formatScalar(value);
118
+ if (labels && typeof labels === "object" && !Array.isArray(labels)) {
119
+ const mapped = (labels as Record<string, unknown>)[raw];
120
+ if (typeof mapped === "string" && mapped) return mapped;
121
+ }
122
+ return raw;
123
+ }
124
+
125
+ /** DS Badge variant for a status value (neutral when unmapped). `variants` is
126
+ * untrusted DATA — a non-object/array is ignored. */
127
+ export function statusVariant(value: unknown, variants: unknown): string {
128
+ const raw = formatScalar(value);
129
+ if (variants && typeof variants === "object" && !Array.isArray(variants)) {
130
+ const mapped = (variants as Record<string, unknown>)[raw];
131
+ if (typeof mapped === "string" && mapped) return mapped;
132
+ }
133
+ return "neutral";
134
+ }
135
+
136
+ /** Resolve a status value to a `{ label, variant }` pair in one call. */
137
+ export function resolveStatusBadge(
138
+ value: unknown,
139
+ opts: { labels?: unknown; variants?: unknown } = {},
140
+ ): { label: string; variant: string } {
141
+ return {
142
+ label: statusLabel(value, opts.labels),
143
+ variant: statusVariant(value, opts.variants),
144
+ };
145
+ }
146
+
147
+ /**
148
+ * Extract a `[lon, lat]` pair from a GeoJSON-ish Point value (the shape both a
149
+ * detail row's location FK and a MapPicker value use), or null when it isn't a
150
+ * usable point. Pure — the map widget decides how to render the result.
151
+ */
152
+ export function geoPoint(value: unknown): [number, number] | null {
153
+ if (!value || typeof value !== "object") return null;
154
+ const coords = (value as { coordinates?: unknown }).coordinates;
155
+ if (!Array.isArray(coords) || coords.length < 2) return null;
156
+ const lon = coords[0];
157
+ const lat = coords[1];
158
+ if (typeof lon !== "number" || typeof lat !== "number") return null;
159
+ if (!Number.isFinite(lon) || !Number.isFinite(lat)) return null;
160
+ return [lon, lat];
161
+ }
@@ -22,6 +22,7 @@ import {
22
22
  } from "./dispatch";
23
23
  import type { Block, OntologySchema, WidgetKind, WidgetProps } from "./types";
24
24
 
25
+ import EChartWidget from "./EChartWidget.svelte";
25
26
  import ResultsChartWidget from "./ResultsChartWidget.svelte";
26
27
  import StatGridWidget from "./StatGridWidget.svelte";
27
28
 
@@ -67,6 +68,7 @@ const _entries: RegistryEntry<WidgetComponent>[] = [
67
68
  // DS base: the two clean widgets shipped by this package.
68
69
  byKind("stat-grid", "kpi", StatGridWidget),
69
70
  byTypeOnKind("results-chart", "aggregate", ResultsChartWidget),
71
+ byTypeOnKind("chart", "aggregate", EChartWidget),
70
72
  ];
71
73
 
72
74
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiaiai-pt/design-system",
3
- "version": "0.34.0",
3
+ "version": "0.36.0",
4
4
  "description": "Design system tokens and Svelte components for aiaiai products",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -67,12 +67,14 @@
67
67
  "@codemirror/view": "^6.40.0",
68
68
  "@lezer/highlight": "^1.0.0",
69
69
  "date-fns": "^4.1.0",
70
+ "echarts": "^5.5.0",
70
71
  "ol": "^10.0.0",
71
72
  "svelte": "^5.0.0"
72
73
  },
73
74
  "devDependencies": {
74
75
  "@sveltejs/package": "^2.5.7",
75
76
  "date-fns": "^4.1.0",
77
+ "echarts": "^5.6.0",
76
78
  "ol": "^10.8.0",
77
79
  "svelte": "^5.55.3",
78
80
  "svelte-check": "^4.4.6",