@dnax/ui 0.0.67 → 0.0.70

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,222 @@
1
+ // Formatage des valeurs de `<q-chart>` (axes, info-bulles, étiquettes, tableaux).
2
+ //
3
+ // Le traducteur (`chartToECharts`) reste **pur** : ce module ne lit ni le DOM ni la locale
4
+ // du navigateur. La locale vient de la prop `locale` (défaut : `fr-FR`, cohérent avec le
5
+ // reste du design system), éventuellement surchargée par un format.
6
+ //
7
+ // Rien n'est formaté tant que l'appelant ne demande rien : sans `format`, une valeur sort
8
+ // telle quelle (`String(value)`), donc aucun changement de comportement rétroactif.
9
+
10
+ /** Format « court » : une chaîne suffit (`format: "compact"`) */
11
+ export type QChartFormatKind =
12
+ | "number"
13
+ | "decimal"
14
+ | "percent"
15
+ | "compact"
16
+ | "currency"
17
+ | "date"
18
+ | "time"
19
+ | "datetime"
20
+
21
+ /** Contexte passé aux formateurs (fonction ou objet) */
22
+ export interface QChartFormatContext {
23
+ /** Locale de repli (celle du graphique) */
24
+ locale?: string
25
+ /** Index de la donnée dans sa marque */
26
+ index?: number
27
+ /** Ligne d'origine */
28
+ row?: any
29
+ /** Nom de la série / de la catégorie, si connu */
30
+ name?: string
31
+ }
32
+
33
+ /** Format complet (objet) */
34
+ export interface QChartFormatOptions {
35
+ /** Famille de formatage */
36
+ style?: QChartFormatKind
37
+ /** Locale BCP 47 (`fr-FR`, `en-US`…) — prioritaire sur celle du graphique */
38
+ locale?: string
39
+ /** Devise ISO 4217 pour `currency` (`XOF` par défaut → « F CFA ») */
40
+ currency?: string
41
+ /** Fuseau horaire IANA (`Africa/Dakar`…) pour les formats de date */
42
+ timeZone?: string
43
+ minimumFractionDigits?: number
44
+ maximumFractionDigits?: number
45
+ /** `date` / `datetime` : style de la partie date */
46
+ dateStyle?: "full" | "long" | "medium" | "short"
47
+ /** `time` / `datetime` : style de la partie heure */
48
+ timeStyle?: "full" | "long" | "medium" | "short"
49
+ /** Texte ajouté avant / après la valeur formatée */
50
+ prefix?: string
51
+ suffix?: string
52
+ }
53
+
54
+ /** Un format : chaîne courte, objet d'options, ou fonction `(value, ctx) => string` */
55
+ export type QChartFormat =
56
+ | QChartFormatKind
57
+ | QChartFormatOptions
58
+ | ((value: any, ctx?: QChartFormatContext) => string)
59
+
60
+ /** Locale par défaut de tous les formats (l'application peut la changer par prop) */
61
+ export const DEFAULT_LOCALE = "fr-FR"
62
+
63
+ /** Devise par défaut : franc CFA (lisible en `fr-FR` : « 1 234 F CFA ») */
64
+ export const DEFAULT_CURRENCY = "XOF"
65
+
66
+ const isObject = (v: any): v is QChartFormatOptions => v !== null && typeof v === "object"
67
+
68
+ /** Normalise un format en options exploitables (`undefined` si aucun format demandé) */
69
+ export function normalizeFormat(
70
+ format: QChartFormat | undefined,
71
+ ): QChartFormatOptions | undefined {
72
+ if (format === undefined || format === null) return undefined
73
+ if (typeof format === "string") return { style: format }
74
+ if (typeof format === "function") return undefined
75
+ if (isObject(format)) return format
76
+ return undefined
77
+ }
78
+
79
+ /** Nombre fini ? (`null`, `""`, `NaN` sont écartés) */
80
+ const finite = (v: any): v is number => typeof v === "number" && Number.isFinite(v)
81
+
82
+ /** `value` → `Date` (nombre = timestamp, chaîne = ISO / date lisible) */
83
+ function toDate(value: any): Date | undefined {
84
+ if (value instanceof Date) return Number.isNaN(value.getTime()) ? undefined : value
85
+ if (finite(value)) {
86
+ // Un timestamp en **secondes** (API usuelles) est ramené en millisecondes.
87
+ const ms = Math.abs(value) < 1e11 ? value * 1000 : value
88
+ const d = new Date(ms)
89
+ return Number.isNaN(d.getTime()) ? undefined : d
90
+ }
91
+ if (typeof value === "string" && value.trim() !== "") {
92
+ const d = new Date(value)
93
+ return Number.isNaN(d.getTime()) ? undefined : d
94
+ }
95
+ return undefined
96
+ }
97
+
98
+ const dateFormatted = (value: any, options: QChartFormatOptions, locale: string): string | undefined => {
99
+ const date = toDate(value)
100
+ if (!date) return undefined
101
+ const style = options.style
102
+ const opts: Intl.DateTimeFormatOptions = { timeZone: options.timeZone }
103
+ if (style === "date" || style === "datetime") {
104
+ opts.dateStyle = options.dateStyle ?? (style === "datetime" ? "short" : "medium")
105
+ }
106
+ if (style === "time" || style === "datetime") {
107
+ opts.timeStyle = options.timeStyle ?? "short"
108
+ }
109
+ return new Intl.DateTimeFormat(locale, opts).format(date)
110
+ }
111
+
112
+ const numberFormatted = (value: any, options: QChartFormatOptions, locale: string): string | undefined => {
113
+ const style = options.style ?? "number"
114
+ if (typeof value === "boolean") return String(value)
115
+ if (!finite(value)) return undefined
116
+ const digits: Intl.NumberFormatOptions = {}
117
+ if (options.minimumFractionDigits !== undefined) digits.minimumFractionDigits = options.minimumFractionDigits
118
+ if (options.maximumFractionDigits !== undefined) digits.maximumFractionDigits = options.maximumFractionDigits
119
+ if (style === "compact") {
120
+ return new Intl.NumberFormat(locale, {
121
+ notation: "compact",
122
+ compactDisplay: "short",
123
+ maximumFractionDigits: options.maximumFractionDigits ?? 1,
124
+ }).format(value)
125
+ }
126
+ if (style === "currency") {
127
+ return new Intl.NumberFormat(locale, {
128
+ style: "currency",
129
+ currency: options.currency ?? DEFAULT_CURRENCY,
130
+ ...digits,
131
+ }).format(value)
132
+ }
133
+ if (style === "percent") {
134
+ // La valeur est **déjà en pourcentage** (42 → « 42 % ») : `Intl` multiplie par 100,
135
+ // on divise donc avant (et non l'inverse, qui donnerait 4200 %).
136
+ return new Intl.NumberFormat(locale, {
137
+ style: "percent",
138
+ maximumFractionDigits: options.maximumFractionDigits ?? 1,
139
+ ...digits,
140
+ }).format(value / 100)
141
+ }
142
+ // `number` / `decimal` / style absent : nombre localisé
143
+ return new Intl.NumberFormat(locale, {
144
+ maximumFractionDigits: options.maximumFractionDigits ?? 2,
145
+ ...digits,
146
+ }).format(value)
147
+ }
148
+
149
+ /** Formate une valeur selon `format`.
150
+ *
151
+ * - `undefined` / `null` → chaîne vide ;
152
+ * - sans `format` → `String(value)` (aucun formatage implicite) ;
153
+ * - `percent` attend une valeur **en pourcentage** (`42` → « 42 % ») ;
154
+ * - `currency` utilise `XOF` (F CFA) par défaut ;
155
+ * - une date peut être un `Date`, un timestamp (s/ms) ou une chaîne ISO. */
156
+ export function formatValue(
157
+ value: any,
158
+ format?: QChartFormat,
159
+ ctx: QChartFormatContext = {},
160
+ ): string {
161
+ if (typeof format === "function") {
162
+ const out = format(value, ctx)
163
+ return out === undefined || out === null ? "" : String(out)
164
+ }
165
+ if (value === undefined || value === null || value === "") return ""
166
+ const options = normalizeFormat(format)
167
+ if (!options) return String(value)
168
+ const locale = options.locale ?? ctx.locale ?? DEFAULT_LOCALE
169
+ const temporal =
170
+ options.style === "date" || options.style === "time" || options.style === "datetime"
171
+ const text = temporal
172
+ ? (dateFormatted(value, options, locale) ?? String(value))
173
+ : (numberFormatted(value, options, locale) ?? String(value))
174
+ return `${options.prefix ?? ""}${text}${options.suffix ?? ""}`
175
+ }
176
+
177
+ /** Un format de pourcentage sans `%` (pour composer soi-même un libellé) */
178
+ export function formatPercent(value: any, format?: QChartFormat, ctx?: QChartFormatContext): string {
179
+ const options = normalizeFormat(format)
180
+ const merged: QChartFormat = { ...(options ?? {}), style: "percent" }
181
+ return formatValue(value, merged, ctx)
182
+ }
183
+
184
+
185
+ /** Formateur ECharts pour un axe (`axisLabel.formatter`), ou `undefined` si rien n'est demandé. */
186
+ export function axisFormatter(
187
+ format: QChartFormat | undefined,
188
+ ctx: QChartFormatContext = {},
189
+ ): ((value: any) => string) | undefined {
190
+ if (format === undefined || format === null) return undefined
191
+ return (value: any) => formatValue(value, format, ctx)
192
+ }
193
+
194
+ /** Graduation d'un axe **temporel** selon sa granularité.
195
+ * `auto` (défaut) laisse le moteur choisir : aucune fonction n'est renvoyée. */
196
+ export function timeTickFormatter(
197
+ unit: "auto" | "day" | "week" | "month" | "quarter" | "year" | undefined,
198
+ locale = DEFAULT_LOCALE,
199
+ timeZone?: string,
200
+ ): ((value: any) => string) | undefined {
201
+ if (!unit || unit === "auto") return undefined
202
+ return (value: any) => {
203
+ const date = toDate(value)
204
+ if (!date) return String(value ?? "")
205
+ if (unit === "quarter") {
206
+ const quarter = Math.floor(date.getUTCMonth() / 3) + 1
207
+ const year = String(date.getUTCFullYear()).slice(2)
208
+ return `T${quarter} ${year}`
209
+ }
210
+ const opts: Intl.DateTimeFormatOptions = { timeZone }
211
+ if (unit === "day" || unit === "week") {
212
+ opts.day = "numeric"
213
+ opts.month = "short"
214
+ } else if (unit === "month") {
215
+ opts.month = "short"
216
+ opts.year = "2-digit"
217
+ } else {
218
+ opts.year = "numeric"
219
+ }
220
+ return new Intl.DateTimeFormat(locale, opts).format(date)
221
+ }
222
+ }
@@ -0,0 +1,178 @@
1
+ // Calculs statistiques du traducteur de graphiques — **purs**, testables sans DOM.
2
+ //
3
+ // Utilisés par les marques `histogram`, `boxplot` et `waterfall` (P1 de `QChart`).
4
+ // Aucune notion de couleur ni de rendu : uniquement des nombres, pour que les tests
5
+ // portent sur la sémantique (tranches, quartiles, cumul) et non sur ECharts.
6
+
7
+ /** Une tranche d'histogramme */
8
+ export interface QChartBin {
9
+ /** Borne basse (incluse) */
10
+ x0: number
11
+ /** Borne haute (exclue, sauf la dernière tranche) */
12
+ x1: number
13
+ /** Nombre de valeurs dans la tranche */
14
+ count: number
15
+ /** Libellé par défaut (`x0 – x1`) — l'appelant peut le reformater */
16
+ label: string
17
+ }
18
+
19
+ /** Arrondit un nombre de borne pour les libellés (évite `0.30000000000000004`) */
20
+ const clean = (v: number) => Number(v.toPrecision(12))
21
+
22
+ /** Histogramme : répartit des valeurs numériques en `bins` tranches.
23
+ *
24
+ * - `bins` par défaut : `ceil(sqrt(n))` (règle de la racine), borné à `[1, 40]` ;
25
+ * - `domain` force les bornes ; sinon `[min, max]` des valeurs ;
26
+ * - une valeur `null` / non finie est ignorée ;
27
+ * - la borne haute de la **dernière** tranche est inclusive (la valeur maximale compte). */
28
+ export function histogram(
29
+ values: readonly any[],
30
+ options: { bins?: number; domain?: [number, number] } = {},
31
+ ): QChartBin[] {
32
+ const nums = values.filter((v): v is number => typeof v === "number" && Number.isFinite(v))
33
+ if (nums.length === 0) return []
34
+ let lo = options.domain?.[0] ?? Math.min(...nums)
35
+ let hi = options.domain?.[1] ?? Math.max(...nums)
36
+ if (!Number.isFinite(lo) || !Number.isFinite(hi)) return []
37
+ if (hi < lo) [lo, hi] = [hi, lo]
38
+ const requested = Math.max(1, Math.min(40, Math.round(options.bins ?? Math.ceil(Math.sqrt(nums.length)))))
39
+ // Domaine dégénéré (une seule valeur) : une tranche autour de la valeur
40
+ if (hi === lo) {
41
+ return [{ x0: lo, x1: hi, count: nums.length, label: `${clean(lo)}` }]
42
+ }
43
+ // Le nombre de tranches demandé est **respecté** : pas d'arrondi « joli » qui le changerait.
44
+ const step = (hi - lo) / requested
45
+ const bins: QChartBin[] = Array.from({ length: requested }, (_, i) => ({
46
+ x0: clean(lo + i * step),
47
+ x1: clean(lo + (i + 1) * step),
48
+ count: 0,
49
+ label: "",
50
+ }))
51
+ for (const v of nums) {
52
+ // La valeur maximale tombe dans la dernière tranche (borne haute inclusive)
53
+ let i = Math.floor((v - lo) / step)
54
+ if (i < 0) i = 0
55
+ if (i >= requested) i = requested - 1
56
+ const bin = bins[i]
57
+ if (bin) bin.count++
58
+ }
59
+ for (const bin of bins) bin.label = `${clean(bin.x0)} – ${clean(bin.x1)}`
60
+ return bins
61
+ }
62
+
63
+ /** Quantile (interpolation linéaire, méthode R-7 — celle d'Excel `QUARTILE.INC`) */
64
+ export function quantile(sorted: readonly number[], p: number): number {
65
+ if (sorted.length === 0) return Number.NaN
66
+ const pos = (sorted.length - 1) * Math.max(0, Math.min(1, p))
67
+ const lower = Math.floor(pos)
68
+ const upper = Math.ceil(pos)
69
+ if (lower === upper) return sorted[lower]!
70
+ return sorted[lower]! + (sorted[upper]! - sorted[lower]!) * (pos - lower)
71
+ }
72
+
73
+ /** Boîte à moustaches (cinq nombres) */
74
+ export interface QChartBox {
75
+ min: number
76
+ q1: number
77
+ median: number
78
+ q3: number
79
+ max: number
80
+ }
81
+
82
+ /** Statistiques d'une série de valeurs (`undefined` si aucune valeur numérique) */
83
+ export function boxStats(values: readonly any[]): QChartBox | undefined {
84
+ const nums = values
85
+ .filter((v): v is number => typeof v === "number" && Number.isFinite(v))
86
+ .slice()
87
+ .sort((a, b) => a - b)
88
+ if (nums.length === 0) return undefined
89
+ return {
90
+ min: nums[0]!,
91
+ q1: quantile(nums, 0.25),
92
+ median: quantile(nums, 0.5),
93
+ q3: quantile(nums, 0.75),
94
+ max: nums[nums.length - 1]!,
95
+ }
96
+ }
97
+
98
+ /** Ajustement affine par moindres carrés — `undefined` si les abscisses sont toutes égales */
99
+ export function linearFit(
100
+ points: readonly (readonly [number, number])[],
101
+ ): { slope: number; intercept: number } | undefined {
102
+ const pts = points.filter(
103
+ ([x, y]) => typeof x === "number" && typeof y === "number" && Number.isFinite(x) && Number.isFinite(y),
104
+ )
105
+ if (pts.length < 2) return undefined
106
+ const n = pts.length
107
+ const sumX = pts.reduce((s, [x]) => s + x, 0)
108
+ const sumY = pts.reduce((s, [, y]) => s + y, 0)
109
+ const meanX = sumX / n
110
+ const meanY = sumY / n
111
+ let num = 0
112
+ let den = 0
113
+ for (const [x, y] of pts) {
114
+ num += (x - meanX) * (y - meanY)
115
+ den += (x - meanX) ** 2
116
+ }
117
+ if (den === 0) return undefined
118
+ const slope = num / den
119
+ return { slope, intercept: meanY - slope * meanX }
120
+ }
121
+
122
+ /** Étape d'un graphique en cascade (waterfall) */
123
+ export interface QChartWaterfallStep {
124
+ /** Valeur de départ de la barre (le cumul précédent) */
125
+ base: number
126
+ /** Hauteur signée de la barre (le delta) */
127
+ delta: number
128
+ /** Cumul après l'étape */
129
+ total: number
130
+ /** Ligne de **total** (barre posée depuis zéro) ? */
131
+ totalRow: boolean
132
+ }
133
+
134
+ /** Décompose des deltas en barres flottantes cumulées.
135
+ *
136
+ * `flags[i] === true` marque une ligne de **total** : la barre part de zéro et sa hauteur
137
+ * est le cumul courant (elle ne s'ajoute pas une seconde fois au cumul). */
138
+ export function waterfallSteps(
139
+ values: readonly any[],
140
+ flags: readonly any[] = [],
141
+ ): QChartWaterfallStep[] {
142
+ const steps: QChartWaterfallStep[] = []
143
+ let running = 0
144
+ for (let i = 0; i < values.length; i++) {
145
+ const raw = values[i]
146
+ const value = typeof raw === "number" && Number.isFinite(raw) ? raw : 0
147
+ const totalRow = flags[i] === true || flags[i] === "total"
148
+ if (totalRow) {
149
+ steps.push({ base: 0, delta: running, total: running, totalRow: true })
150
+ continue
151
+ }
152
+ steps.push({ base: running, delta: value, total: running + value, totalRow: false })
153
+ running += value
154
+ }
155
+ return steps
156
+ }
157
+
158
+ /** Répartition en pourcentage (somme des valeurs absolues, `0` évité).
159
+ * Une valeur nulle ou non finie vaut `0` : la part reste calculable. */
160
+ export function percentShares(values: readonly any[]): number[] {
161
+ const nums = values.map((v) => (typeof v === "number" && Number.isFinite(v) ? v : 0))
162
+ const total = nums.reduce((s, v) => s + Math.abs(v), 0)
163
+ return total === 0 ? nums.map(() => 0) : nums.map((v) => (Math.abs(v) / total) * 100)
164
+ }
165
+
166
+ /** Somme par catégorie — sert au calcul des **empilements 100 %**. */
167
+ export function categoryTotals(
168
+ entries: readonly { category: any; value: any }[],
169
+ ): Map<string, number> {
170
+ const totals = new Map<string, number>()
171
+ for (const { category, value } of entries) {
172
+ if (category === undefined || category === null) continue
173
+ if (typeof value !== "number" || !Number.isFinite(value)) continue
174
+ const key = String(category)
175
+ totals.set(key, (totals.get(key) ?? 0) + value)
176
+ }
177
+ return totals
178
+ }
@@ -0,0 +1,140 @@
1
+ // Tests des **micro-visualisations de cellule** de la marque `table` (barres de données,
2
+ // barres divergentes, heat, sparklines) — purs, sans DOM.
3
+ import { describe, expect, it } from "bun:test"
4
+ import { cellBarGeometry, cellScale, cellVisualOf, sparklineGeometry } from "./chartTable"
5
+ import { tableModel } from "./chart"
6
+
7
+ describe("cellVisualOf", () => {
8
+ it("`true` = barre de données, objet rendu tel quel, absent = rien", () => {
9
+ expect(cellVisualOf(true)).toEqual({ type: "bar" })
10
+ expect(cellVisualOf(undefined)).toBeUndefined()
11
+ expect(cellVisualOf(false)).toBeUndefined()
12
+ expect(cellVisualOf({ type: "delta" })).toEqual({ type: "delta" })
13
+ })
14
+ })
15
+
16
+ describe("cellScale", () => {
17
+ it("bornes observées, ramenées à zéro", () => {
18
+ expect(cellScale([10, 50, 100], { type: "bar" })).toEqual({ min: 0, max: 100, maxAbs: 100 })
19
+ expect(cellScale([-10, 20], { type: "delta" })).toEqual({ min: -10, max: 20, maxAbs: 20 })
20
+ })
21
+
22
+ it("bornes explicites (`min` / `max`) et valeurs non numériques ignorées", () => {
23
+ expect(cellScale([10, null, "x", 30], { type: "bar", min: 0, max: 200 })).toEqual({
24
+ min: 0,
25
+ max: 200,
26
+ maxAbs: 200,
27
+ })
28
+ expect(cellScale([], { type: "bar" })).toEqual({ min: 0, max: 0, maxAbs: Number.EPSILON })
29
+ })
30
+ })
31
+
32
+ describe("cellBarGeometry", () => {
33
+ const scale = { min: 0, max: 100, maxAbs: 100 }
34
+
35
+ it("`bar` : barre à gauche proportionnelle à la valeur", () => {
36
+ expect(cellBarGeometry(50, { type: "bar" }, scale)).toEqual({
37
+ width: 50,
38
+ left: 0,
39
+ ratio: 0.5,
40
+ diverging: false,
41
+ sign: 1,
42
+ })
43
+ expect(cellBarGeometry(-25, { type: "bar" }, scale)?.width).toBe(25)
44
+ expect(cellBarGeometry(0, { type: "bar" }, scale)?.width).toBe(0)
45
+ })
46
+
47
+ it("`delta` : divergente depuis 50 %, positives à droite", () => {
48
+ expect(cellBarGeometry(25, { type: "delta" }, scale)).toMatchObject({
49
+ left: 50,
50
+ width: 12.5,
51
+ diverging: true,
52
+ sign: 1,
53
+ })
54
+ expect(cellBarGeometry(-25, { type: "delta" }, scale)).toMatchObject({
55
+ left: 37.5,
56
+ width: 12.5,
57
+ diverging: true,
58
+ sign: -1,
59
+ })
60
+ })
61
+
62
+ it("`heat` : barre pleine largeur, l'intensité porte la valeur", () => {
63
+ expect(cellBarGeometry(25, { type: "heat" }, scale)).toMatchObject({ left: 0, width: 100, ratio: 0.25 })
64
+ })
65
+
66
+ it("aucune barre pour une sparkline ou une valeur manquante", () => {
67
+ expect(cellBarGeometry(10, { type: "sparkline" }, scale)).toBeUndefined()
68
+ expect(cellBarGeometry(undefined, { type: "bar" }, scale)).toBeUndefined()
69
+ expect(cellBarGeometry("x", { type: "bar" }, scale)).toBeUndefined()
70
+ })
71
+ })
72
+
73
+ describe("sparklineGeometry", () => {
74
+ it("trace la ligne, la surface et le point final", () => {
75
+ const spark = sparklineGeometry([1, 2, 3], { point: "last" })!
76
+ expect(spark.points.length).toBe(3)
77
+ expect(spark.path.startsWith("M2,")).toBe(true)
78
+ expect(spark.path).toContain("L98,2")
79
+ expect(spark.areaPath).toContain("Z")
80
+ expect(spark.marker).toEqual(spark.points[2]!)
81
+ })
82
+
83
+ it("met en évidence `first`, `high`, `low` ou rien", () => {
84
+ const first = sparklineGeometry([3, 1, 7], { point: "first" })!
85
+ expect(first.marker).toEqual(first.points[0]!)
86
+ const high = sparklineGeometry([3, 1, 7], { point: "high" })!
87
+ expect(high.marker).toEqual(high.points[2]!)
88
+ const low = sparklineGeometry([3, 1, 7], { point: "low" })!
89
+ expect(low.marker).toEqual(low.points[1]!)
90
+ expect(sparklineGeometry([3, 1, 7], { point: false })!.marker).toBeUndefined()
91
+ })
92
+
93
+ it("valeurs constantes : ligne au milieu ; aucune valeur : rien", () => {
94
+ const flat = sparklineGeometry([5, 5, 5])!
95
+ expect(flat.points.every((p) => p.y === flat.points[0]!.y)).toBe(true)
96
+ expect(flat.points[0]!.y).toBe(12)
97
+ expect(sparklineGeometry([])).toBeUndefined()
98
+ expect(sparklineGeometry(undefined)).toBeUndefined()
99
+ })
100
+ })
101
+
102
+ describe("tableModel — échelles et totaux des micro-visualisations", () => {
103
+ const rows = [
104
+ { type: "A", orders: 100, target: 10, trend: [1, 2, 3] },
105
+ { type: "B", orders: 50, target: -5, trend: [3, 2, 1] },
106
+ ]
107
+ const mark = {
108
+ type: "table" as const,
109
+ data: rows,
110
+ totals: { label: "Total" },
111
+ columns: [
112
+ { field: "type", label: "Type" },
113
+ { field: "orders", label: "Orders", cell: { type: "bar" as const } },
114
+ { field: "target", label: "vs Target", cell: { type: "delta" as const } },
115
+ { field: "trend", label: "Trend", cell: { type: "sparkline" as const } },
116
+ ],
117
+ }
118
+
119
+ it("calcule une échelle par colonne visualisée (jamais pour une sparkline)", () => {
120
+ const model = tableModel(mark)
121
+ expect(model.cellScales).toEqual({
122
+ orders: { min: 0, max: 100, maxAbs: 100 },
123
+ target: { min: -5, max: 10, maxAbs: 10 },
124
+ })
125
+ expect(model.cellScales?.trend).toBeUndefined()
126
+ })
127
+
128
+ it("expose les valeurs **brutes** des totaux pour les barres du pied de table", () => {
129
+ const model = tableModel(mark)
130
+ expect(model.totalValues).toEqual({ orders: 150, target: 5 })
131
+ expect(model.totals).toEqual({ type: "Total", orders: "150", target: "5" })
132
+ })
133
+
134
+ it("sans `cell`, pas d'échelle (modèle inchangé)", () => {
135
+ const model = tableModel({ type: "table", data: rows, columns: ["type", "orders"] })
136
+ expect(model.cellScales).toBeUndefined()
137
+ expect(model.totalValues).toBeUndefined()
138
+ expect(model.totals).toBeUndefined()
139
+ })
140
+ })