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