@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.
package/lib/chart.ts CHANGED
@@ -29,6 +29,15 @@
29
29
  // analysée au build par la doc (`docd/scripts/mark-parse.ts`) pour documenter l'API de
30
30
  // chaque marque.
31
31
 
32
+ import { axisFormatter, DEFAULT_LOCALE, formatValue, timeTickFormatter } from "./chartFormat"
33
+ import type { QChartFormat } from "./chartFormat"
34
+ import { boxStats, categoryTotals, histogram, linearFit, waterfallSteps } from "./chartStats"
35
+ import { cellScale, cellVisualOf } from "./chartTable"
36
+ import type { QChartCellScale, QChartCellVisual } from "./chartTable"
37
+
38
+ export type { QChartFormat, QChartFormatKind, QChartFormatOptions } from "./chartFormat"
39
+ export type { QChartCellKind, QChartCellScale, QChartCellVisual } from "./chartTable"
40
+
32
41
  /** Valeur d'un canal : nom de champ, valeurs explicites ou fonction d'accès */
33
42
  export type QChartChannel = string | readonly any[] | ((d: any, i: number) => any) | number
34
43
 
@@ -45,6 +54,27 @@ export type QChartMarkType =
45
54
  | "heatmap"
46
55
  /** Données en **table** (HTML, pas de canvas) — mêmes données, même `link`, même sélection */
47
56
  | "table"
57
+ // ── Analyses métier (P1) ──
58
+ /** Bande de seuil / zone d'alerte (`y0` → `y1`) — `markArea` */
59
+ | "band"
60
+ /** Jauge (valeur, cible, plages de performance) — hors axes */
61
+ | "gauge"
62
+ /** Entonnoir — hors axes */
63
+ | "funnel"
64
+ /** Radar multicritères — hors axes */
65
+ | "radar"
66
+ /** Treemap hiérarchique — hors axes */
67
+ | "treemap"
68
+ /** Sunburst hiérarchique — hors axes */
69
+ | "sunburst"
70
+ /** Flux entre étapes — hors axes */
71
+ | "sankey"
72
+ /** Boîte à moustaches — cinq nombres par catégorie */
73
+ | "boxplot"
74
+ /** Histogramme — binning natif (`bins`, `domain`) */
75
+ | "histogram"
76
+ /** Cascade — cumul et barres flottantes */
77
+ | "waterfall"
48
78
 
49
79
  /** Sens des axes : `horizontal` inverse les axes (catégories sur l'axe Y) */
50
80
  export type QChartOrientation = "vertical" | "horizontal"
@@ -61,6 +91,15 @@ export interface QChartColumn {
61
91
  align?: "start" | "center" | "end"
62
92
  /** Formate la valeur affichée (reçoit la valeur et la ligne) */
63
93
  format?: (value: any, row: any) => string
94
+ /** Largeur de la colonne (nombre = px, sinon valeur CSS) */
95
+ width?: number | string
96
+ /** La colonne est-elle **triable** ? (défaut : `true` quand la table est triable) */
97
+ sortable?: boolean
98
+ /** Agrégat de la ligne de **totaux** (`sum`, `avg`, `min`, `max`, `count`) */
99
+ summary?: "sum" | "avg" | "min" | "max" | "count"
100
+ /** **Micro-visualisation** de la cellule (façon Power BI) : barre de données, barre
101
+ * divergente `delta`, intensité `heat` ou `sparkline`. `true` = barre de données. */
102
+ cell?: QChartCellVisual | boolean
64
103
  }
65
104
 
66
105
  /** Séparateurs d'une marque `table` : `horizontal` (défaut — un trait sous chaque rangée),
@@ -70,14 +109,72 @@ export type QChartTableSeparator = "horizontal" | "vertical" | "grid" | "none"
70
109
  /** Modèle prêt à rendre d'une marque `table` */
71
110
  export interface QChartTable {
72
111
  columns: QChartColumn[]
73
- /** Lignes **jointes** à la sélection (même règle que les autres marques) */
112
+ /** Lignes **jointes** à la sélection (même règle que les autres marques), puis triées et paginées */
74
113
  rows: any[]
75
114
  /** Séparateurs demandés par la marque (`separator`, défaut `horizontal`) */
76
115
  separator: QChartTableSeparator
116
+ /** Toutes les lignes visibles (avant pagination) — export CSV et totaux */
117
+ allRows?: any[]
118
+ /** Page courante (0-based) et nombre de pages (quand `pageSize` est donné) */
119
+ page?: number
120
+ pages?: number
121
+ pageSize?: number
122
+ /** Tri courant */
123
+ sortBy?: string
124
+ sortDesc?: boolean
125
+ /** Ligne de **totaux** déjà formatée (clé = champ) */
126
+ totals?: Record<string, string>
127
+ /** Valeurs **brutes** des agrégats de la ligne de totaux (pour ses barres de cellule) */
128
+ totalValues?: Record<string, number>
129
+ /** Bornes par colonne, calculées sur les lignes visibles (barres / heat / delta) */
130
+ cellScales?: Record<string, QChartCellScale>
131
+ }
132
+
133
+ /** État de vue d'une table (tri / page), piloté par le composant */
134
+ export interface QChartTableView {
135
+ sortBy?: string
136
+ sortDesc?: boolean
137
+ page?: number
138
+ }
139
+
140
+ /** Compare deux valeurs de cellule : nombres d'abord, puis texte ; les vides en dernier. */
141
+ function compareCells(a: any, b: any): number {
142
+ const ea = a === null || a === undefined || Number.isNaN(a)
143
+ const eb = b === null || b === undefined || Number.isNaN(b)
144
+ if (ea || eb) return ea && eb ? 0 : ea ? 1 : -1
145
+ if (typeof a === "number" && typeof b === "number") return a - b
146
+ const sa = String(a)
147
+ const sb = String(b)
148
+ return sa < sb ? -1 : sa > sb ? 1 : 0
149
+ }
150
+
151
+ /** Agrège une colonne pour la ligne de totaux (`undefined` = pas d'agrégat).
152
+ * Renvoie la valeur **brute** (pour les barres du pied de table) et son texte formaté. */
153
+ function aggregate(column: QChartColumn, rows: any[]): { value?: number; formatted?: string } | undefined {
154
+ const raw = rows.map((row) => row?.[column.field])
155
+ const nums = raw.filter((v): v is number => typeof v === "number" && Number.isFinite(v))
156
+ const nonEmpty = raw.filter((v) => v !== null && v !== undefined).length
157
+ const mode = column.summary ?? (nums.length === nonEmpty && nums.length > 0 ? "sum" : undefined)
158
+ if (!mode) return undefined
159
+ if (mode === "count") return { value: rows.length, formatted: String(rows.length) }
160
+ if (nums.length === 0) return { formatted: "" }
161
+ const value =
162
+ mode === "sum"
163
+ ? nums.reduce((s, v) => s + v, 0)
164
+ : mode === "avg"
165
+ ? nums.reduce((s, v) => s + v, 0) / nums.length
166
+ : mode === "min"
167
+ ? Math.min(...nums)
168
+ : Math.max(...nums)
169
+ return {
170
+ value,
171
+ formatted: column.format ? column.format(value, undefined) : String(Number(value.toPrecision(12))),
172
+ }
77
173
  }
78
174
 
79
175
  /** Modèle d'une marque `table` : colonnes déclarées ou déduites des lignes, lignes jointes à la
80
- * sélection partagée (comme n'importe quelle marque liée).
176
+ * sélection partagée (comme n'importe quelle marque liée), puis **tri**, **pagination** et
177
+ * **totaux** (options de la marque, ou `view` pour un état interactif).
81
178
  *
82
179
  * `mode` a le même sens que sur les graphiques : `filter` ne garde que les lignes liées, `dim`
83
180
  * les **garde toutes** (c'est au rendu d'estomper les autres, comme pour une série). */
@@ -85,18 +182,19 @@ export function tableModel(
85
182
  mark: QChartMark | undefined,
86
183
  selection?: any,
87
184
  mode: QChartLinkMode = "filter",
185
+ view: QChartTableView = {},
88
186
  ): QChartTable {
89
187
  if (!mark) return { columns: [], rows: [], separator: "horizontal" }
90
188
  const separator = mark.separator ?? "horizontal"
91
189
  const all = mark.data ?? []
92
- const rows = mode === "dim" ? all : linkedRows(all, chartLink(mark.link), selection)
190
+ const visible = mode === "dim" ? all : linkedRows(all, chartLink(mark.link), selection)
93
191
  const declared = mark.columns ?? []
94
192
  const columns: QChartColumn[] = declared.map((column) =>
95
193
  typeof column === "string" ? { field: column, label: column } : { ...column, label: column.label ?? column.field },
96
194
  )
97
195
  if (columns.length === 0) {
98
196
  const seen = new Set<string>()
99
- for (const row of rows) {
197
+ for (const row of visible) {
100
198
  if (row === null || typeof row !== "object") continue
101
199
  for (const key of Object.keys(row)) {
102
200
  if (!seen.has(key)) {
@@ -106,7 +204,122 @@ export function tableModel(
106
204
  }
107
205
  }
108
206
  }
109
- return { columns, rows, separator }
207
+
208
+ // ── Tri ──
209
+ const sortBy = view.sortBy ?? mark.sortBy
210
+ const sortDesc = !!(view.sortDesc ?? mark.sortDesc ?? false)
211
+ const sorted = sortBy ? [...visible].sort((a, b) => compareCells(a?.[sortBy], b?.[sortBy])) : visible
212
+ if (sortBy && sortDesc) sorted.reverse()
213
+
214
+ // ── Totaux (sur toutes les lignes visibles, pas seulement la page) ──
215
+ let totals: Record<string, string> | undefined
216
+ let totalValues: Record<string, number> | undefined
217
+ if (mark.totals) {
218
+ const label = typeof mark.totals === "object" ? (mark.totals.label ?? "") : ""
219
+ const out: Record<string, string> = {}
220
+ const raw: Record<string, number> = {}
221
+ columns.forEach((column, i) => {
222
+ const agg = aggregate(column, sorted)
223
+ if (i === 0 && label) out[column.field] = label
224
+ else if (agg?.formatted !== undefined) out[column.field] = agg.formatted
225
+ if (agg?.value !== undefined) raw[column.field] = agg.value
226
+ })
227
+ if (Object.keys(out).length) totals = out
228
+ if (Object.keys(raw).length) totalValues = raw
229
+ }
230
+
231
+ // ── Échelles des micro-visualisations (barres / heat / delta) ──
232
+ let cellScales: Record<string, QChartCellScale> | undefined
233
+ const visualColumns = columns.filter((column) => {
234
+ const visual = cellVisualOf(column.cell)
235
+ return !!visual && visual.type !== "sparkline"
236
+ })
237
+ if (visualColumns.length) {
238
+ cellScales = {}
239
+ for (const column of visualColumns) {
240
+ const visual = cellVisualOf(column.cell)!
241
+ cellScales[column.field] = cellScale(
242
+ sorted.map((row) => row?.[column.field]),
243
+ visual,
244
+ )
245
+ }
246
+ }
247
+
248
+ // ── Pagination ──
249
+ const pageSize = typeof mark.pageSize === "number" && mark.pageSize > 0 ? Math.floor(mark.pageSize) : 0
250
+ const pages = pageSize > 0 ? Math.max(1, Math.ceil(sorted.length / pageSize)) : undefined
251
+ const page = pages ? Math.max(0, Math.min(pages - 1, Math.floor(view.page ?? mark.page ?? 0))) : undefined
252
+ const rows = pages && page !== undefined ? sorted.slice(page * pageSize, page * pageSize + pageSize) : sorted
253
+
254
+ const model: QChartTable = { columns, rows, separator }
255
+ if (mark.sortBy !== undefined || sortBy !== undefined) {
256
+ model.sortBy = sortBy
257
+ model.sortDesc = sortDesc
258
+ }
259
+ if (pages !== undefined) {
260
+ model.allRows = sorted
261
+ model.page = page
262
+ model.pages = pages
263
+ model.pageSize = pageSize
264
+ } else if (totals) {
265
+ model.allRows = sorted
266
+ }
267
+ if (totals) model.totals = totals
268
+ if (totalValues) model.totalValues = totalValues
269
+ if (cellScales) model.cellScales = cellScales
270
+ return model
271
+ }
272
+
273
+ /** Export CSV d'un modèle de table (RFC 4180 : guillemets doublés, séparateur déduit du séparateur
274
+ * de champs, pas de ligne d'en-tête ajoutée si `header: false`). Ne dépend d'aucun DOM. */
275
+ export function tableCsv(
276
+ model: QChartTable,
277
+ options: { separator?: string; header?: boolean; label?: string } = {},
278
+ ): string {
279
+ const delimiter = options.separator ?? ","
280
+ const escape = (value: any) => {
281
+ const text = value === null || value === undefined ? "" : String(value)
282
+ return new RegExp(`["\n\r${delimiter}]`).test(text) ? `"${text.replace(/"/g, '""')}"` : text
283
+ }
284
+ const rows = model.allRows ?? model.rows
285
+ const lines: string[] = []
286
+ if (options.header !== false) {
287
+ lines.push(model.columns.map((column) => escape(column.label ?? column.field)).join(delimiter))
288
+ }
289
+ for (const row of rows) {
290
+ lines.push(model.columns.map((column) => escape(row?.[column.field])).join(delimiter))
291
+ }
292
+ if (model.totals && options.label !== undefined) {
293
+ lines.push(
294
+ model.columns
295
+ .map((column, i) => escape(model.totals?.[column.field] ?? (i === 0 ? options.label : "")))
296
+ .join(delimiter),
297
+ )
298
+ }
299
+ return lines.join("\r\n")
300
+ }
301
+
302
+ /** Seuil déclaratif de couleur (barres, points, aires) : la première règle qui matche
303
+ * colore l'élément. Sans `op`, `gt` (strictement supérieur). */
304
+ export interface QChartThreshold {
305
+ value: number
306
+ op?: "gt" | "gte" | "lt" | "lte" | "eq" | "neq"
307
+ /** Token dnax.ui ou couleur CSS */
308
+ color: string
309
+ }
310
+
311
+ /** Plage de performance d'une `gauge` (zone colorée de l'arc) */
312
+ export interface QChartGaugeBand {
313
+ /** Borne haute — en valeur d'axe, ou en **fraction** `0..1` avec `fraction: true` */
314
+ to: number
315
+ color: string
316
+ fraction?: boolean
317
+ }
318
+
319
+ /** Ligne de totaux d'une marque `table` */
320
+ export interface QChartTableTotals {
321
+ /** Libellé de la première colonne (« Total ») */
322
+ label?: string
110
323
  }
111
324
 
112
325
  /** A single mark: `{ type: 'line', data, x, y, … }` */
@@ -144,8 +357,16 @@ export interface QChartMark {
144
357
  /** `pie`: starting angle in degrees — `90` (the default) starts at 12 o'clock, angles growing counter-clockwise */
145
358
  startAngle?: number
146
359
  /** `pie`: content of the slice labels — `name` (default), `value`, `percent`, `name-value`, `name-percent`, or `false` to hide them.
147
- * `heatmap`: `true` prints the value inside each cell. */
360
+ * `heatmap`: `true` prints the value inside each cell.
361
+ * `bar` / `line` / `area`: `true` or `value` prints the value, `name` the category, `percent`
362
+ * the share of the category — `labelFormat` formate le nombre. */
148
363
  labels?: boolean | "name" | "value" | "percent" | "name-value" | "name-percent"
364
+ /** Position des étiquettes de données (`top`, `bottom`, `inside`, `left`, `right`…) */
365
+ labelPosition?: string
366
+ /** Format des étiquettes de données (axes, barres, lignes, aires) */
367
+ labelFormat?: QChartFormat
368
+ /** Seuils de couleur par point (bar, dot, area) — la première règle qui matche gagne */
369
+ thresholds?: QChartThreshold[]
149
370
  /** `dot`: point radius — a constant, or a channel for a bubble chart */
150
371
  r?: number | QChartChannel
151
372
  /** `dot`: point shape — `circle` (default), `rect`, `triangle`, `diamond`, `pin`, `none`… */
@@ -192,8 +413,64 @@ export interface QChartMark {
192
413
  name?: string
193
414
  /** Grouping: one series (and one legend entry) per value of the channel */
194
415
  z?: QChartChannel
195
- /** Stack the series */
196
- stack?: boolean
416
+ /** Stack the series — `true` empile, `"percent"` empile **à 100 %** (valeurs normalisées
417
+ * par catégorie), une autre chaîne nomme le groupe d'empilement. */
418
+ stack?: boolean | "percent" | string
419
+ /** Axe des ordonnées visé — `left` (défaut) ou `right` (axe secondaire, cf. `yRight`) */
420
+ yAxis?: "left" | "right"
421
+ /** `bar` **flottante** : canal de la base par point (`y0`) — waterfall, budget/réalisé, bullet */
422
+ base?: QChartChannel
423
+ /** `bar` : couleur de la base (défaut : transparente) */
424
+ baseFill?: string
425
+ /** `line` / `area` : relie les points manquants au lieu de couper le trait */
426
+ connectNulls?: boolean
427
+ /** `line` / `area` : courbe lissée (`true`) ou tension numérique */
428
+ smooth?: boolean | number
429
+ /** `dot` : ajoute une **ligne de tendance** (régression linéaire) */
430
+ trend?: boolean
431
+ /** `rule` / `band` : libellé du repère et sa position (`start`, `middle`, `end`, `insideTop`…) */
432
+ label?: string
433
+ /** `band` : bornes basses / hautes verticales (axe Y) */
434
+ y0?: QChartChannel
435
+ y1?: QChartChannel
436
+ /** `band` : bornes pour une bande **verticale** (axe X) */
437
+ x0?: QChartChannel
438
+ x1?: QChartChannel
439
+ /** `histogram` : nombre de tranches (défaut : règle de la racine carrée) */
440
+ bins?: number
441
+ /** `histogram` : domaine forcé `[min, max]` */
442
+ domain?: [number, number]
443
+ /** `sankey` / `treemap` / `sunburst` : canal de la valeur (poids, surface) */
444
+ value?: QChartChannel
445
+ /** `treemap` / `sunburst` : canal du **parent** (hiérarchie) */
446
+ parent?: QChartChannel
447
+ /** `boxplot` : canal de la **liste de valeurs** brutes (calcul des cinq nombres) */
448
+ values?: QChartChannel
449
+ /** `boxplot` : canal des **statistiques précalculées** (`[min, q1, médiane, q3, max]`) */
450
+ stats?: QChartChannel
451
+ /** `gauge` / `radar` : borne basse / haute de l'échelle */
452
+ min?: number
453
+ max?: number
454
+ /** `gauge` : valeur de la **cible** (aiguille secondaire annotée) */
455
+ target?: number | QChartChannel
456
+ /** `gauge` : plages de performance (arc coloré) */
457
+ bands?: QChartGaugeBand[]
458
+ /** `gauge` / `funnel` : suffixe d'unité affiché dans le détail (`%`, `F CFA`…) */
459
+ unit?: string
460
+ /** `waterfall` : couleur des deltas négatifs (défaut : token `negative`) */
461
+ negativeFill?: QChartChannel
462
+ /** `waterfall` : canal qui marque les lignes de **total** (`true` → barre depuis zéro) */
463
+ isTotal?: QChartChannel
464
+ /** `table` : tri initial (champ, sens) — interactif au clic sur l'en-tête */
465
+ sortBy?: string
466
+ sortDesc?: boolean
467
+ /** `table` : pagination (`pageSize` lignes par page, `page` 0-based) */
468
+ pageSize?: number
469
+ page?: number
470
+ /** `table` : ligne de **totaux** (agrégats des colonnes, `summary`) */
471
+ totals?: boolean | QChartTableTotals
472
+ /** Info-bulle **par marque** : reçoit les paramètres du point et la valeur brute */
473
+ tooltip?: (params: any, value: any) => string
197
474
  }
198
475
 
199
476
  /** Options accepted by **each** mark (keys of `QChartMark`).
@@ -206,10 +483,95 @@ export interface QChartMark {
206
483
  * `orientation` (`rule` reference lines only carry positions and a stroke, and `pie`
207
484
  * slices are one per row: no `z`, `stack` or `orientation`). */
208
485
  export const MARK_OPTIONS = {
209
- line: ["data", "link", "x", "y", "stroke", "strokeWidth", "opacity", "title", "name", "z", "stack", "orientation"],
210
- area: ["data", "link", "x", "y", "fill", "stroke", "strokeWidth", "opacity", "title", "name", "z", "stack", "orientation"],
211
- bar: ["data", "link", "x", "y", "fill", "stroke", "strokeWidth", "opacity", "title", "name", "z", "stack", "orientation"],
212
- dot: ["data", "link", "x", "y", "fill", "stroke", "strokeWidth", "opacity", "r", "symbol", "title", "name", "z", "stack", "orientation"],
486
+ line: [
487
+ "data",
488
+ "link",
489
+ "x",
490
+ "y",
491
+ "stroke",
492
+ "strokeWidth",
493
+ "opacity",
494
+ "title",
495
+ "name",
496
+ "z",
497
+ "stack",
498
+ "orientation",
499
+ "yAxis",
500
+ "connectNulls",
501
+ "smooth",
502
+ "labels",
503
+ "labelPosition",
504
+ "labelFormat",
505
+ "thresholds",
506
+ "tooltip",
507
+ ],
508
+ area: [
509
+ "data",
510
+ "link",
511
+ "x",
512
+ "y",
513
+ "fill",
514
+ "stroke",
515
+ "strokeWidth",
516
+ "opacity",
517
+ "title",
518
+ "name",
519
+ "z",
520
+ "stack",
521
+ "orientation",
522
+ "yAxis",
523
+ "connectNulls",
524
+ "smooth",
525
+ "labels",
526
+ "labelPosition",
527
+ "labelFormat",
528
+ "thresholds",
529
+ "tooltip",
530
+ ],
531
+ bar: [
532
+ "data",
533
+ "link",
534
+ "x",
535
+ "y",
536
+ "fill",
537
+ "stroke",
538
+ "strokeWidth",
539
+ "opacity",
540
+ "title",
541
+ "name",
542
+ "z",
543
+ "stack",
544
+ "orientation",
545
+ "yAxis",
546
+ "base",
547
+ "baseFill",
548
+ "labels",
549
+ "labelPosition",
550
+ "labelFormat",
551
+ "thresholds",
552
+ "tooltip",
553
+ ],
554
+ dot: [
555
+ "data",
556
+ "link",
557
+ "x",
558
+ "y",
559
+ "fill",
560
+ "stroke",
561
+ "strokeWidth",
562
+ "opacity",
563
+ "r",
564
+ "symbol",
565
+ "trend",
566
+ "thresholds",
567
+ "title",
568
+ "name",
569
+ "z",
570
+ "stack",
571
+ "orientation",
572
+ "yAxis",
573
+ "tooltip",
574
+ ],
213
575
  image: [
214
576
  "data",
215
577
  "link",
@@ -258,10 +620,140 @@ export const MARK_OPTIONS = {
258
620
  "stack",
259
621
  "orientation",
260
622
  ],
261
- rule: ["data", "link", "x", "y", "stroke", "fill", "strokeWidth"],
262
- pie: ["data", "link", "x", "y", "fill", "radius", "innerRadius", "startAngle", "labels", "opacity", "title", "name"],
263
- heatmap: ["data", "link", "x", "y", "fill", "labels", "opacity", "title", "name"],
264
- table: ["data", "link", "columns", "separator"],
623
+ rule: ["data", "link", "x", "y", "stroke", "fill", "strokeWidth", "label", "labelPosition"],
624
+ band: ["data", "link", "x", "y", "x0", "x1", "y0", "y1", "fill", "stroke", "opacity", "label", "labelPosition"],
625
+ pie: [
626
+ "data",
627
+ "link",
628
+ "x",
629
+ "y",
630
+ "fill",
631
+ "radius",
632
+ "innerRadius",
633
+ "startAngle",
634
+ "labels",
635
+ "labelPosition",
636
+ "opacity",
637
+ "title",
638
+ "name",
639
+ ],
640
+ heatmap: ["data", "link", "x", "y", "fill", "labels", "labelFormat", "opacity", "title", "name"],
641
+ gauge: [
642
+ "data",
643
+ "link",
644
+ "x",
645
+ "y",
646
+ "min",
647
+ "max",
648
+ "target",
649
+ "bands",
650
+ "unit",
651
+ "fill",
652
+ "opacity",
653
+ "title",
654
+ "name",
655
+ "labelFormat",
656
+ ],
657
+ funnel: [
658
+ "data",
659
+ "link",
660
+ "x",
661
+ "y",
662
+ "fill",
663
+ "opacity",
664
+ "labels",
665
+ "labelPosition",
666
+ "labelFormat",
667
+ "title",
668
+ "name",
669
+ ],
670
+ radar: [
671
+ "data",
672
+ "link",
673
+ "x",
674
+ "y",
675
+ "z",
676
+ "fill",
677
+ "stroke",
678
+ "strokeWidth",
679
+ "opacity",
680
+ "min",
681
+ "max",
682
+ "labels",
683
+ "title",
684
+ "name",
685
+ ],
686
+ treemap: ["data", "link", "x", "parent", "value", "fill", "opacity", "labels", "labelFormat", "title", "name"],
687
+ sunburst: [
688
+ "data",
689
+ "link",
690
+ "x",
691
+ "parent",
692
+ "value",
693
+ "fill",
694
+ "radius",
695
+ "opacity",
696
+ "labels",
697
+ "labelFormat",
698
+ "title",
699
+ "name",
700
+ ],
701
+ sankey: ["data", "link", "x", "y", "value", "fill", "opacity", "title", "name"],
702
+ boxplot: [
703
+ "data",
704
+ "link",
705
+ "x",
706
+ "y",
707
+ "values",
708
+ "stats",
709
+ "fill",
710
+ "stroke",
711
+ "opacity",
712
+ "title",
713
+ "name",
714
+ "yAxis",
715
+ ],
716
+ histogram: [
717
+ "data",
718
+ "link",
719
+ "x",
720
+ "bins",
721
+ "domain",
722
+ "fill",
723
+ "opacity",
724
+ "labels",
725
+ "labelPosition",
726
+ "labelFormat",
727
+ "title",
728
+ "name",
729
+ ],
730
+ waterfall: [
731
+ "data",
732
+ "link",
733
+ "x",
734
+ "y",
735
+ "fill",
736
+ "negativeFill",
737
+ "isTotal",
738
+ "labels",
739
+ "labelPosition",
740
+ "labelFormat",
741
+ "opacity",
742
+ "title",
743
+ "name",
744
+ "yAxis",
745
+ ],
746
+ table: [
747
+ "data",
748
+ "link",
749
+ "columns",
750
+ "separator",
751
+ "sortBy",
752
+ "sortDesc",
753
+ "pageSize",
754
+ "page",
755
+ "totals",
756
+ ],
265
757
  } as const satisfies Record<QChartMarkType, readonly (keyof QChartMark)[]>
266
758
 
267
759
  /** Configuration d'axe */
@@ -275,6 +767,34 @@ export interface QChartAxis {
275
767
  max?: number | string
276
768
  /** Afficher les lignes de grille (défaut : oui) */
277
769
  grid?: boolean
770
+ /** Format des **graduations** (`compact`, `percent`, `currency`, `date`… ou fonction) */
771
+ format?: QChartFormat
772
+ /** Locale des graduations (défaut : celle du graphique) */
773
+ locale?: string
774
+ /** Axe **temporel** : granularité des libellés (`auto` par défaut) */
775
+ timeUnit?: "auto" | "day" | "week" | "month" | "quarter" | "year"
776
+ /** Axe temporel : fuseau horaire IANA (`Africa/Dakar`, `Europe/Paris`…) */
777
+ timeZone?: string
778
+ /** Rotation des libellés, en degrés (ex. `-35` pour gagner de la place) */
779
+ labelRotate?: number
780
+ /** Nombre de graduations souhaité (indicatif) */
781
+ splitNumber?: number
782
+ /** Masquer tout l'axe */
783
+ show?: boolean
784
+ /** Interligne ou intervalle des libellés : `auto` (défaut) ou un nombre (`0` = tous) */
785
+ interval?: number | "auto"
786
+ }
787
+
788
+ /** Info-bulle personnalisable */
789
+ export interface QChartTooltip {
790
+ /** Formateur **global** : reçoit les paramètres ECharts, renvoie du HTML / du texte */
791
+ formatter?: (params: any) => string
792
+ /** Formate **chaque valeur** affichée (axe comme point) */
793
+ valueFormatter?: (value: any, params?: any) => string
794
+ /** Trier les séries de l'info-bulle d'axe par valeur décroissante */
795
+ sortDesc?: boolean
796
+ /** Garder le `valueFormatter` aussi dans le **nom de la donnée** (`name`) */
797
+ nameFormatter?: (name: any, params?: any) => string
278
798
  }
279
799
 
280
800
  /** Élément désigné dans un graphique (clic, survol) — émis par `<q-chart>` sur `@pick`.
@@ -319,6 +839,7 @@ export function legendPickOf(
319
839
  }
320
840
  for (const markIndex of order) {
321
841
  const mark = marks![markIndex]
842
+ if (!mark) continue
322
843
  const rows = mark.data ?? []
323
844
  for (let dataIndex = 0; dataIndex < rows.length; dataIndex++) {
324
845
  const row = rows[dataIndex]
@@ -457,6 +978,12 @@ export interface QChartConfig {
457
978
  marks: QChartMark[]
458
979
  x?: QChartAxis
459
980
  y?: QChartAxis
981
+ /** Axe des ordonnées **secondaire**, à droite (combo volume + taux) */
982
+ yRight?: QChartAxis
983
+ /** Locale de l'axe et des formats par défaut (`fr-FR`) */
984
+ locale?: string
985
+ /** Mode **compact** : marges réduites, graduations discrètes, légende masquée par défaut */
986
+ compact?: boolean
460
987
  /** Palette : tokens dnax.ui (`primary`, `secondary`…) ou couleurs CSS */
461
988
  colors?: string[]
462
989
  /** Titre du graphique */
@@ -464,8 +991,8 @@ export interface QChartConfig {
464
991
  /** Légende : `false` pour la masquer, `true` pour la forcer (défaut : dès qu'une série
465
992
  * est nommée) ou un objet `{ position, offset, align }` */
466
993
  legend?: boolean | QChartLegend
467
- /** Info-bulle (défaut : oui) */
468
- tooltip?: boolean
994
+ /** Info-bulle : `false` pour l'éteindre, ou `{ formatter, valueFormatter, sortDesc }` */
995
+ tooltip?: boolean | QChartTooltip
469
996
  /** Ce que deviennent les lignes **non liées** par `link` : `filter` (défaut) les retire, `dim`
470
997
  * les **estompe** (`dimOpacity`) et laisse la place à la sélection. */
471
998
  linkMode?: QChartLinkMode
@@ -539,6 +1066,18 @@ const TOKEN_FALLBACKS: Record<string, string> = {
539
1066
 
540
1067
  const isToken = (v: any): v is string => typeof v === "string" && v in TOKEN_FALLBACKS
541
1068
 
1069
+ /** Couleur de **cellule de table** (CSS) : un token `primary` / `chart-1` devient
1070
+ * `var(--primary, #1976d2)` ; toute autre valeur CSS est rendue telle quelle.
1071
+ * Les tokens d'un thème hôte qui ne sont pas dans `TOKEN_FALLBACKS` (ex. `muted-foreground`)
1072
+ * sont acceptés dès qu'ils contiennent un tiret — on leur donne `fallback` pour repli. */
1073
+ export function tokenCss(value: string | undefined, fallback = "currentColor"): string {
1074
+ if (!value) return fallback
1075
+ if (isToken(value) || /^[a-z][a-z0-9-]*-[a-z0-9-]+$/i.test(value)) {
1076
+ return `var(--${value}, ${TOKEN_FALLBACKS[value] ?? fallback})`
1077
+ }
1078
+ return value
1079
+ }
1080
+
542
1081
  /** Style de l'info-bulle : carte translucide floutée (verre), rayon 3px, ombre portée.
543
1082
  * `var(--card)` rend la surface solidaire du thème hôte (clair/sombre) sans relecture. */
544
1083
  const TOOLTIP_EXTRA_CSS = [
@@ -639,6 +1178,60 @@ export function chartToECharts(config: QChartConfig): Record<string, any> {
639
1178
  grid: resolve(config.theme?.grid),
640
1179
  }
641
1180
 
1181
+ /** Locale des nombres / dates (les formats y retombent quand ils n'en donnent pas) */
1182
+ const locale = config.locale ?? DEFAULT_LOCALE
1183
+ /** Mode mini-widget : marges réduites, axes et légende effacés */
1184
+ const compact = config.compact === true
1185
+ /** Format de valeur pour une info-bulle / une étiquette */
1186
+ const formatText = (value: any, format?: QChartFormat) => formatValue(value, format, { locale })
1187
+
1188
+ /** Familles posées sur les axes (les autres sont hors axes : pie, gauge, funnel…) */
1189
+ const CARTESIAN = new Set<QChartMarkType>([
1190
+ "line",
1191
+ "area",
1192
+ "bar",
1193
+ "dot",
1194
+ "text",
1195
+ "image",
1196
+ "heatmap",
1197
+ "boxplot",
1198
+ "histogram",
1199
+ "waterfall",
1200
+ ])
1201
+ const isCartesianMark = (m: QChartMark) => CARTESIAN.has(m.type)
1202
+
1203
+ /** Groupe d'empilement d'une marque (`true` / `percent` → « total », une chaîne la nomme) */
1204
+ const stackGroupOf = (m: QChartMark): string | undefined => {
1205
+ if (!m.stack) return undefined
1206
+ if (m.stack === true || m.stack === "percent") return "total"
1207
+ return String(m.stack)
1208
+ }
1209
+ /** Groupes qui doivent être normalisés à 100 % */
1210
+ const percentGroups = new Set<string>()
1211
+ for (const m of marks) if (m.stack === "percent") percentGroups.add(stackGroupOf(m) ?? "total")
1212
+
1213
+ /** Seuil de couleur applicable à une valeur */
1214
+ const thresholdOf = (value: any, thresholds?: QChartThreshold[]): string | undefined => {
1215
+ if (!thresholds?.length || typeof value !== "number" || !Number.isFinite(value)) return undefined
1216
+ for (const rule of thresholds) {
1217
+ const op = rule.op ?? "gt"
1218
+ const hit =
1219
+ op === "gt"
1220
+ ? value > rule.value
1221
+ : op === "gte"
1222
+ ? value >= rule.value
1223
+ : op === "lt"
1224
+ ? value < rule.value
1225
+ : op === "lte"
1226
+ ? value <= rule.value
1227
+ : op === "eq"
1228
+ ? value === rule.value
1229
+ : value !== rule.value
1230
+ if (hit) return resolve(rule.color)
1231
+ }
1232
+ return undefined
1233
+ }
1234
+
642
1235
  /** Lignes d'une marque, **jointes** à la sélection partagée (`link` + `selection`).
643
1236
  * Point d'application unique : toutes les familles de marques en héritent. */
644
1237
  const dataOf = (m: QChartMark) => {
@@ -650,7 +1243,7 @@ export function chartToECharts(config: QChartConfig): Record<string, any> {
650
1243
 
651
1244
  // Orientation : donnée par la première marque de série. Toutes les marques
652
1245
  // doivent partager la même orientation (`pie` n'est pas posé sur les axes → ignoré).
653
- const first = marks.find((m) => !isRule(m) && m.type !== "pie" && m.type !== "table") ?? marks[0]
1246
+ const first = marks.find(isCartesianMark) ?? marks[0]
654
1247
  const horizontal = !!first && isHorizontal(first)
655
1248
 
656
1249
  const abscissaOf = (m: QChartMark) => m.x ?? m.y
@@ -674,15 +1267,36 @@ export function chartToECharts(config: QChartConfig): Record<string, any> {
674
1267
  return channelValue(channel, data, i)
675
1268
  }
676
1269
 
1270
+ // ─── Histogrammes : les tranches sont calculées **avant** les catégories (leurs libellés
1271
+ // deviennent l'axe) ───
1272
+ const binsOf = new Map<QChartMark, ReturnType<typeof histogram>>()
1273
+ for (const m of marks) {
1274
+ if (m.type !== "histogram") continue
1275
+ const data = dataOf(m)
1276
+ const raw = data.map((_, i) => channelValue(m.x, data, i))
1277
+ binsOf.set(m, histogram(raw, { bins: m.bins, domain: m.domain }))
1278
+ }
1279
+
677
1280
  // ─── Domaine des catégories (axe « band ») ───
678
1281
  const categories: string[] = []
679
1282
  const seen = new Set<string>()
680
- let numericX = true
1283
+ let numericX = !binsOf.size
681
1284
  let timeX = config.x?.type === "time"
682
1285
  for (const m of marks) {
683
- if (isRule(m) || m.type === "pie" || m.type === "table") continue
1286
+ if (!isCartesianMark(m)) continue
684
1287
  const data = dataOf(m)
685
1288
  const ch = abscissaOf(m)
1289
+ // Histogramme : une catégorie par tranche (borne basse → haute)
1290
+ const bins = binsOf.get(m)
1291
+ if (bins) {
1292
+ for (const bin of bins) {
1293
+ if (!seen.has(bin.label)) {
1294
+ seen.add(bin.label)
1295
+ categories.push(bin.label)
1296
+ }
1297
+ }
1298
+ continue
1299
+ }
686
1300
  for (let i = 0; i < data.length; i++) {
687
1301
  const pair = pairRowOf(m, i)
688
1302
  const v = pair ? pair[0] : channelValue(ch, data, i)
@@ -746,6 +1360,113 @@ export function chartToECharts(config: QChartConfig): Record<string, any> {
746
1360
  return horizontal ? [val, cat] : [cat, val]
747
1361
  }
748
1362
 
1363
+ /** Catégorie brute d'un point (même résolution que `pairOf`, sans l'orientation) */
1364
+ const categoryOf = (m: QChartMark, i: number): any => {
1365
+ const pair = pairRowOf(m, i)
1366
+ return pair ? pair[0] : solveX(abscissaOf(m), m, i)
1367
+ }
1368
+ /** Valeur brute d'un point (axe des ordonnées) */
1369
+ const ordinateValueOf = (m: QChartMark, i: number): any => {
1370
+ const pair = pairRowOf(m, i)
1371
+ return pair ? pair[1] : channelValue(ordinateOf(m), dataOf(m), i)
1372
+ }
1373
+
1374
+ // ─── Empilement 100 % : ECharts ne normalise pas, on calcule les totaux par catégorie ───
1375
+ const stackTotals = new Map<string, Map<string, number>>()
1376
+ for (const group of percentGroups) {
1377
+ const entries: { category: any; value: any }[] = []
1378
+ for (const m of marks) {
1379
+ if (stackGroupOf(m) !== group || !isCartesianMark(m)) continue
1380
+ const data = dataOf(m)
1381
+ for (let i = 0; i < data.length; i++) {
1382
+ entries.push({ category: categoryOf(m, i), value: ordinateValueOf(m, i) })
1383
+ }
1384
+ }
1385
+ stackTotals.set(group, categoryTotals(entries))
1386
+ }
1387
+
1388
+ /** Paire normalisée : dans un groupe `percent`, l'ordonnée devient une part (0→100) */
1389
+ const scaledPairOf = (m: QChartMark, i: number): [any, any] | null => {
1390
+ const pair = pairOf(m, i)
1391
+ if (!pair) return null
1392
+ const group = stackGroupOf(m)
1393
+ const totals = group && percentGroups.has(group) ? stackTotals.get(group) : undefined
1394
+ if (!totals) return pair
1395
+ const total = totals.get(String(categoryOf(m, i))) ?? 0
1396
+ const scale = (v: any) =>
1397
+ typeof v === "number" ? (total === 0 ? 0 : (v / total) * 100) : v
1398
+ return horizontal ? [scale(pair[0]), pair[1]] : [pair[0], scale(pair[1])]
1399
+ }
1400
+
1401
+ /** Total par catégorie **du graphique** — sert aux étiquettes `percent` hors empilement */
1402
+ let chartLabelTotals: Map<string, number> | undefined
1403
+ const labelTotalsOf = (m: QChartMark) => {
1404
+ if (chartLabelTotals) return chartLabelTotals
1405
+ const entries: { category: any; value: any }[] = []
1406
+ for (const mark of marks) {
1407
+ if (mark.type !== "bar" && mark.type !== "line" && mark.type !== "area" && mark.type !== "dot" && mark.type !== "waterfall") continue
1408
+ const data = dataOf(mark)
1409
+ for (let i = 0; i < data.length; i++) {
1410
+ entries.push({ category: categoryOf(mark, i), value: ordinateValueOf(mark, i) })
1411
+ }
1412
+ }
1413
+ chartLabelTotals = categoryTotals(entries)
1414
+ return chartLabelTotals
1415
+ }
1416
+
1417
+ /** Valeur d'ordonnée portée par un point ECharts (`{ value: [cat, val] }` ou scalaire) */
1418
+ const ordinateOfPoint = (p: any): any => {
1419
+ const v = p?.value ?? p?.data?.value ?? p?.data
1420
+ if (Array.isArray(v)) return horizontal ? v[0] : v[1]
1421
+ return v
1422
+ }
1423
+ /** Catégorie portée par un point ECharts */
1424
+ const categoryOfPoint = (p: any): any => {
1425
+ const v = p?.value ?? p?.data?.value ?? p?.data
1426
+ if (Array.isArray(v)) return horizontal ? v[1] : v[0]
1427
+ return p?.name ?? p?.axisValue
1428
+ }
1429
+
1430
+ /** Étiquettes de données (`labels`) d'une marque cartésienne */
1431
+ const dataLabelsOf = (m: QChartMark): Record<string, any> | undefined => {
1432
+ if (!m.labels) return undefined
1433
+ const position = m.labelPosition ?? "top"
1434
+ let formatter: any
1435
+ if (m.labels === "name") {
1436
+ formatter = "{b}"
1437
+ } else if (m.labels === "percent") {
1438
+ if (m.labelFormat) {
1439
+ formatter = (p: any) => {
1440
+ const group = stackGroupOf(m)
1441
+ const totals = group && percentGroups.has(group) ? stackTotals.get(group) : labelTotalsOf(m)
1442
+ const total = totals?.get(String(categoryOfPoint(p))) ?? 0
1443
+ const value = Number(ordinateOfPoint(p) ?? 0)
1444
+ const share = total === 0 ? 0 : (Math.abs(value) / Math.abs(total)) * 100
1445
+ return formatText(share, { ...(typeof m.labelFormat === "object" ? m.labelFormat : {}), style: "percent" })
1446
+ }
1447
+ } else if (stackGroupOf(m) && percentGroups.has(stackGroupOf(m)!)) {
1448
+ formatter = (p: any) => formatText(Number(ordinateOfPoint(p) ?? 0), "percent")
1449
+ } else {
1450
+ formatter = (p: any) => {
1451
+ const total = labelTotalsOf(m).get(String(categoryOfPoint(p))) ?? 0
1452
+ const value = Number(ordinateOfPoint(p) ?? 0)
1453
+ const share = total === 0 ? 0 : (Math.abs(value) / Math.abs(total)) * 100
1454
+ return formatText(share, "percent")
1455
+ }
1456
+ }
1457
+ } else if (m.labelFormat) {
1458
+ formatter = (p: any) => formatText(ordinateOfPoint(p), m.labelFormat)
1459
+ } else {
1460
+ formatter = "{c}"
1461
+ }
1462
+ return {
1463
+ show: true,
1464
+ position,
1465
+ formatter,
1466
+ color: theme.text,
1467
+ }
1468
+ }
1469
+
749
1470
  const series: Record<string, any>[] = []
750
1471
  let colorIndex = 0
751
1472
  const colorFor = (explicit?: any) => {
@@ -755,6 +1476,11 @@ export function chartToECharts(config: QChartConfig): Record<string, any> {
755
1476
 
756
1477
  const rulesY: any[] = []
757
1478
  const rulesX: any[] = []
1479
+ /** Zones de seuil (`band`) — `markArea` posé plus bas sur la première série */
1480
+ const bandsY: any[] = []
1481
+ const bandsX: any[] = []
1482
+ /** Configuration `radar` (indicateurs), une par marque `radar` */
1483
+ const radarOptions: any[] = []
758
1484
  /** Échelles de couleurs des cartes de chaleur (valeur numérique → palette) */
759
1485
  const visualMaps: any[] = []
760
1486
 
@@ -763,6 +1489,48 @@ export function chartToECharts(config: QChartConfig): Record<string, any> {
763
1489
  if (m.type === "table") continue
764
1490
  const data = dataOf(m)
765
1491
 
1492
+ // Bande de seuil / zone d'alerte : `markArea` — horizontale par défaut (`y0` → `y1`),
1493
+ // verticale avec `x0` / `x1`. Le libellé (`label`) est posé sur la zone.
1494
+ if (m.type === "band") {
1495
+ const color = resolve(colorValue(m.fill, data, 0) ?? m.fill)
1496
+ const style: Record<string, any> = {}
1497
+ if (typeof color === "string" && looksLikeColor(color)) style.color = color
1498
+ if (typeof m.stroke === "string") {
1499
+ const border = resolve(m.stroke)
1500
+ if (typeof border === "string" && looksLikeColor(border)) style.borderColor = border
1501
+ }
1502
+ if (typeof m.opacity === "number") style.opacity = m.opacity
1503
+ const labelCfg = m.label
1504
+ ? { show: true, formatter: m.label, position: m.labelPosition ?? "insideTop", color: theme.muted }
1505
+ : { show: false }
1506
+ const vertical = m.x0 !== undefined || m.x1 !== undefined
1507
+ const loChannel = vertical ? m.x0 : (m.y0 ?? m.y)
1508
+ const hiChannel = vertical ? m.x1 : (m.y1 ?? m.y)
1509
+ /** Une chaîne qui **n'est pas** un champ de la donnée est une constante (`x0: "Jan"`) */
1510
+ const boundValue = (channel: QChartChannel | undefined, i: number) => {
1511
+ if (typeof channel === "string") {
1512
+ const row = data[0]
1513
+ if (!isPlainObject(row) || !(channel in row)) return channel
1514
+ }
1515
+ return channelValue(channel, data, i)
1516
+ }
1517
+ const count = Math.max(
1518
+ data.length || 1,
1519
+ isArrayLike(loChannel) ? loChannel.length : 1,
1520
+ isArrayLike(hiChannel) ? hiChannel.length : 1,
1521
+ )
1522
+ for (let i = 0; i < count; i++) {
1523
+ const lo = boundValue(loChannel, i)
1524
+ const hi = boundValue(hiChannel, i)
1525
+ if (lo === undefined || hi === undefined) continue
1526
+ const area = vertical
1527
+ ? [{ xAxis: lo, itemStyle: { ...style }, label: { ...labelCfg } }, { xAxis: hi }]
1528
+ : [{ yAxis: lo, itemStyle: { ...style }, label: { ...labelCfg } }, { yAxis: hi }]
1529
+ ;(vertical ? bandsX : bandsY).push(area)
1530
+ }
1531
+ continue
1532
+ }
1533
+
766
1534
  // Camembert / anneau — famille **hors axes** (ni grid ni xAxis/yAxis) : une part par
767
1535
  // ligne, `x` = le libellé de la part, `y` = sa valeur. Sans `fill`, ECharts colorie
768
1536
  // les parts avec la palette (`color`), une part après l'autre.
@@ -791,6 +1559,20 @@ export function chartToECharts(config: QChartConfig): Record<string, any> {
791
1559
  if (m.title !== undefined) slice.tooltip = { formatter: String(channelValue(m.title, data, i)) }
792
1560
  return slice
793
1561
  })
1562
+ // Format personnalisé : le formateur reçoit le point ECharts (`value`, `percent`, `name`)
1563
+ const formatted = m.labelFormat
1564
+ ? (p: any) => {
1565
+ if (m.labels === "percent") {
1566
+ return formatText(p.percent, { style: "percent", ...(typeof m.labelFormat === "object" ? m.labelFormat : {}) })
1567
+ }
1568
+ const text = formatText(p.value, m.labelFormat)
1569
+ if (m.labels === "name-value") return `${p.name}: ${text}`
1570
+ if (m.labels === "name-percent") {
1571
+ return `${p.name} ${formatText(p.percent, { style: "percent" })}`
1572
+ }
1573
+ return m.labels === "value" ? text : p.name
1574
+ }
1575
+ : formatter
794
1576
  const outer = m.radius ?? "70%"
795
1577
  series.push({
796
1578
  type: "pie",
@@ -800,7 +1582,12 @@ export function chartToECharts(config: QChartConfig): Record<string, any> {
800
1582
  ...(typeof m.startAngle === "number" ? { startAngle: m.startAngle } : {}),
801
1583
  ...(m.labels === false
802
1584
  ? { label: { show: false }, labelLine: { show: false } }
803
- : { label: { formatter } }),
1585
+ : {
1586
+ label: {
1587
+ formatter: formatted,
1588
+ ...(m.labelPosition ? { position: m.labelPosition } : {}),
1589
+ },
1590
+ }),
804
1591
  data: slices,
805
1592
  })
806
1593
  continue
@@ -816,22 +1603,25 @@ export function chartToECharts(config: QChartConfig): Record<string, any> {
816
1603
  const columnIndex = new Map(categories.map((c, i) => [c, i]))
817
1604
  const rowIndex = new Map(rowCategories.map((c, i) => [c, i]))
818
1605
  const raw = data.map((_, i) => colorValue(m.fill, data, i))
819
- const numeric = raw.length > 0 && raw.every((v) => typeof v === "number")
1606
+ const numeric = raw.some((v) => typeof v === "number")
820
1607
  const uniform = numeric ? undefined : resolve(raw[0] ?? m.fill)
821
1608
  const cells: any[] = []
822
1609
  for (let i = 0; i < data.length; i++) {
823
1610
  const column = columnIndex.get(String(solveX(m.x, m, i)))
824
1611
  const row = rowIndex.get(String(channelValue(m.y, data, i)))
825
1612
  if (column === undefined || row === undefined) continue
1613
+ // Valeur manquante en mode numérique : la case reste **vide** (et transparente)
1614
+ const missing = numeric && (typeof raw[i] !== "number" || !Number.isFinite(raw[i]))
826
1615
  const cell: Record<string, any> = {
827
1616
  // Nom de la cellule → l'info-bulle ECharts par défaut (« ligne · colonne »)
828
1617
  name: `${rowCategories[row]} · ${categories[column]}`,
829
- value: [column, row, numeric ? raw[i] : 1],
1618
+ value: [column, row, numeric ? (missing ? NaN : raw[i]) : 1],
830
1619
  }
1620
+ if (missing) cell.itemStyle = { opacity: 0 }
831
1621
  if (m.title !== undefined) cell.tooltip = { formatter: String(channelValue(m.title, data, i)) }
832
1622
  cells.push(cell)
833
1623
  }
834
- const values = numeric ? (raw as number[]) : [0, 1]
1624
+ const values = numeric ? (raw.filter((v) => typeof v === "number") as number[]) : [0, 1]
835
1625
  // Couleur constante → échelle dégénérée `[c, c]` (sinon la palette sert de rampe)
836
1626
  const flat = typeof uniform === "string" && looksLikeColor(uniform) ? uniform : (palette[0] ?? "#1976d2")
837
1627
  visualMaps.push({
@@ -839,6 +1629,7 @@ export function chartToECharts(config: QChartConfig): Record<string, any> {
839
1629
  max: Math.max(...values),
840
1630
  inRange: { color: numeric ? palette : [flat, flat] },
841
1631
  show: numeric, // une couleur constante n'a pas d'échelle à montrer
1632
+ seriesIndex: series.length, // n'affecte pas les autres séries (ex. un `dot` coloré)
842
1633
  orient: "horizontal",
843
1634
  left: 0,
844
1635
  bottom: 0,
@@ -854,7 +1645,15 @@ export function chartToECharts(config: QChartConfig): Record<string, any> {
854
1645
  data: cells,
855
1646
  ...(cellOpacity !== undefined ? { itemStyle: { opacity: cellOpacity } } : {}),
856
1647
  ...(m.labels === true || m.labels === "value"
857
- ? { label: { show: true, formatter: "{@[2]}", color: theme.text } }
1648
+ ? {
1649
+ label: {
1650
+ show: true,
1651
+ formatter: m.labelFormat
1652
+ ? (p: any) => formatText(p.value?.[2], m.labelFormat)
1653
+ : "{@[2]}",
1654
+ color: theme.text,
1655
+ },
1656
+ }
858
1657
  : {}),
859
1658
  })
860
1659
  continue
@@ -879,10 +1678,346 @@ export function chartToECharts(config: QChartConfig): Record<string, any> {
879
1678
  if (typeof stroke === "string" && looksLikeColor(stroke)) lineStyle.color = stroke
880
1679
  if (typeof m.strokeWidth === "number") lineStyle.width = m.strokeWidth
881
1680
  const list = vertical ? rulesX : rulesY
1681
+ const labelCfg = m.label
1682
+ ? {
1683
+ show: true,
1684
+ formatter: m.label,
1685
+ position: m.labelPosition ?? "end",
1686
+ color: theme.muted,
1687
+ }
1688
+ : undefined
882
1689
  for (const v of values) {
883
1690
  if (v === undefined || v === null) continue
884
- list.push({ [vertical ? "xAxis" : "yAxis"]: v, lineStyle: { ...lineStyle } })
1691
+ list.push({
1692
+ [vertical ? "xAxis" : "yAxis"]: v,
1693
+ lineStyle: { ...lineStyle },
1694
+ ...(labelCfg ? { label: { ...labelCfg } } : {}),
1695
+ })
1696
+ }
1697
+ continue
1698
+ }
1699
+
1700
+ // ─── Jauge : valeur, plages de performance (`bands`) et cible annotée ───
1701
+ if (m.type === "gauge") {
1702
+ const value = Number(ordinateValueOf(m, 0) ?? 0)
1703
+ const min = m.min ?? 0
1704
+ const max = m.max ?? 100
1705
+ const span = max - min || 1
1706
+ const label = channelValue(m.x, data, 0) ?? m.name ?? ""
1707
+ const target = m.target !== undefined ? Number(channelValue(m.target, data, 0)) : undefined
1708
+ const fmt = (v: any) => {
1709
+ const text = formatText(v, m.labelFormat)
1710
+ return m.unit ? `${text} ${m.unit}` : text
1711
+ }
1712
+ // Plages : `fraction` (0→1) ou valeurs d'axe, converties en fractions pour ECharts
1713
+ const bands = (m.bands ?? [])
1714
+ .map((band) => {
1715
+ const to = band.fraction ? band.to : (band.to - min) / span
1716
+ return [Math.max(0, Math.min(1, to)), resolve(band.color)] as [number, string]
1717
+ })
1718
+ .sort((a, b) => a[0] - b[0])
1719
+ const axisColors = bands.length ? bands : ([[1, colorFor(m.fill)]] as [number, string][])
1720
+ if (axisColors[0]![0] !== 0) axisColors.unshift([0, axisColors[0]![1]])
1721
+ series.push({
1722
+ type: "gauge",
1723
+ name: m.name,
1724
+ z: series.length,
1725
+ min,
1726
+ max,
1727
+ radius: m.radius ?? "75%",
1728
+ startAngle: 210,
1729
+ endAngle: -30,
1730
+ progress: { show: true, width: 12, itemStyle: { color: colorFor(m.fill) } },
1731
+ axisLine: { lineStyle: { width: 12, color: axisColors } },
1732
+ axisTick: { show: false },
1733
+ splitLine: { show: false },
1734
+ axisLabel: { show: true, color: theme.muted, distance: 14, formatter: (v: number) => formatText(v, m.labelFormat) },
1735
+ pointer: { show: true, width: 4, itemStyle: { color: theme.text } },
1736
+ anchor: { show: true, size: 8, itemStyle: { color: theme.text } },
1737
+ // La cible s'affiche sous la valeur : une coordonnée polaire n'accueille pas de `markLine`
1738
+ detail: {
1739
+ valueAnimation: true,
1740
+ color: theme.text,
1741
+ fontSize: 20,
1742
+ lineHeight: 22,
1743
+ offsetCenter: [0, target !== undefined ? "46%" : "60%"],
1744
+ formatter: (v: number) => (target !== undefined ? `${fmt(v)}\nCible ${fmt(target)}` : fmt(v)),
1745
+ },
1746
+ title: { show: !!label, offsetCenter: [0, target !== undefined ? "88%" : "82%"], color: theme.muted, fontSize: 11 },
1747
+ data: [{ name: String(label), value }],
1748
+ ...(typeof m.opacity === "number" ? { itemStyle: { opacity: m.opacity } } : {}),
1749
+ })
1750
+ continue
1751
+ }
1752
+
1753
+ // ─── Entonnoir : une part par ligne ───
1754
+ if (m.type === "funnel") {
1755
+ const slices = data.map((_, i) => {
1756
+ const name = String(channelValue(m.x, data, i) ?? i)
1757
+ const value = Number(channelValue(m.y, data, i) ?? 0)
1758
+ const c = resolve(colorValue(m.fill, data, i))
1759
+ return {
1760
+ name,
1761
+ value,
1762
+ ...(typeof c === "string" && looksLikeColor(c) ? { itemStyle: { color: c } } : {}),
1763
+ }
1764
+ })
1765
+ series.push({
1766
+ type: "funnel",
1767
+ name: m.name,
1768
+ z: series.length,
1769
+ top: 12,
1770
+ bottom: 12,
1771
+ left: "10%",
1772
+ right: "10%",
1773
+ minSize: "20%",
1774
+ gap: 2,
1775
+ data: slices,
1776
+ ...(m.labels === false
1777
+ ? { label: { show: false } }
1778
+ : {
1779
+ label: {
1780
+ show: true,
1781
+ position: m.labelPosition ?? "inside",
1782
+ color: theme.text,
1783
+ formatter: (p: any) =>
1784
+ m.labelFormat ? `${p.name}: ${formatText(p.value, m.labelFormat)}` : `${p.name}: ${p.value}`,
1785
+ },
1786
+ }),
1787
+ ...(typeof m.opacity === "number" ? { itemStyle: { opacity: m.opacity } } : {}),
1788
+ })
1789
+ continue
1790
+ }
1791
+
1792
+ // ─── Radar : `x` = l'indicateur, `y` = la valeur, `z` = la série ───
1793
+ if (m.type === "radar") {
1794
+ const indicators: string[] = []
1795
+ const seenIndicator = new Set<string>()
1796
+ const byGroup = new Map<string, Map<string, number>>()
1797
+ for (let i = 0; i < data.length; i++) {
1798
+ const indicator = String(channelValue(m.x, data, i) ?? i)
1799
+ if (!seenIndicator.has(indicator)) {
1800
+ seenIndicator.add(indicator)
1801
+ indicators.push(indicator)
1802
+ }
1803
+ const key = String(channelValue(m.z, data, i) ?? m.name ?? "")
1804
+ if (!byGroup.has(key)) byGroup.set(key, new Map())
1805
+ byGroup.get(key)!.set(indicator, Number(ordinateValueOf(m, i)) || 0)
1806
+ }
1807
+ const flat = [...byGroup.values()].flatMap((vals) => [...vals.values()])
1808
+ const radarMax = m.max ?? Math.max(1, ...flat) * 1.2
1809
+ radarOptions.push({
1810
+ indicator: indicators.map((name) => ({ name, max: radarMax, min: m.min ?? 0 })),
1811
+ axisLabel: { color: theme.muted },
1812
+ axisLine: { lineStyle: { color: theme.grid } },
1813
+ splitLine: { lineStyle: { color: theme.grid } },
1814
+ splitArea: { show: false },
1815
+ })
1816
+ const radarIndex = radarOptions.length - 1
1817
+ const entries = [...byGroup.entries()].map(([key, vals], index) => {
1818
+ const c = resolve(palette[index % palette.length])
1819
+ return {
1820
+ name: key || String(m.name ?? ""),
1821
+ value: indicators.map((indicator) => vals.get(indicator) ?? 0),
1822
+ lineStyle: { color: c, width: typeof m.strokeWidth === "number" ? m.strokeWidth : 2 },
1823
+ itemStyle: { color: c },
1824
+ areaStyle: { color: c, opacity: typeof m.opacity === "number" ? m.opacity : 0.18 },
1825
+ }
1826
+ })
1827
+ series.push({ type: "radar", name: m.name, z: series.length, radarIndex, symbolSize: 4, data: entries })
1828
+ continue
1829
+ }
1830
+
1831
+ // ─── Treemap / sunburst : hiérarchie par `parent` ───
1832
+ if (m.type === "treemap" || m.type === "sunburst") {
1833
+ const nameOf = (i: number) => String(channelValue(m.x, data, i) ?? i)
1834
+ const nodes = new Map<string, any>()
1835
+ const roots: any[] = []
1836
+ for (let i = 0; i < data.length; i++) {
1837
+ const node: Record<string, any> = { name: nameOf(i) }
1838
+ const value = m.value !== undefined ? channelValue(m.value, data, i) : m.y !== undefined ? channelValue(m.y, data, i) : undefined
1839
+ if (value !== undefined) node.value = Number(value)
1840
+ const c = resolve(colorValue(m.fill, data, i))
1841
+ if (typeof c === "string" && looksLikeColor(c)) node.itemStyle = { color: c }
1842
+ nodes.set(node.name, node)
1843
+ }
1844
+ for (let i = 0; i < data.length; i++) {
1845
+ const node = nodes.get(nameOf(i))!
1846
+ const parentName = m.parent !== undefined ? channelValue(m.parent, data, i) : undefined
1847
+ const parent = parentName !== undefined && parentName !== null ? nodes.get(String(parentName)) : undefined
1848
+ if (parent && parent !== node) (parent.children ??= []).push(node)
1849
+ else roots.push(node)
1850
+ }
1851
+ // Un nœud **interne** tient sa surface de ses enfants : sa valeur explicite (souvent 0)
1852
+ // fausserait le calcul, on la retire.
1853
+ for (const node of nodes.values()) {
1854
+ if (node.children?.length && !node.value) delete node.value
1855
+ }
1856
+ series.push({
1857
+ type: m.type,
1858
+ name: m.name,
1859
+ z: series.length,
1860
+ data: roots,
1861
+ ...(m.type === "sunburst" ? { radius: m.radius ?? ["15%", "90%"] } : {}),
1862
+ ...(m.type === "treemap" ? { roam: false, nodeClick: false, breadcrumb: { show: false } } : {}),
1863
+ ...(m.labels === false
1864
+ ? { label: { show: false } }
1865
+ : m.labelFormat
1866
+ ? { label: { formatter: (p: any) => formatText(p.value, m.labelFormat) } }
1867
+ : {}),
1868
+ })
1869
+ continue
1870
+ }
1871
+
1872
+ // ─── Sankey : flux source → cible ───
1873
+ if (m.type === "sankey") {
1874
+ const links: any[] = []
1875
+ const names: string[] = []
1876
+ const seenNode = new Set<string>()
1877
+ for (let i = 0; i < data.length; i++) {
1878
+ const source = String(channelValue(m.x ?? "source", data, i) ?? "")
1879
+ const target = String(channelValue(m.y ?? "target", data, i) ?? "")
1880
+ const value = Number(channelValue(m.value ?? "value", data, i) ?? 0)
1881
+ links.push({ source, target, value })
1882
+ for (const name of [source, target]) {
1883
+ if (name && !seenNode.has(name)) {
1884
+ seenNode.add(name)
1885
+ names.push(name)
1886
+ }
1887
+ }
1888
+ }
1889
+ series.push({
1890
+ type: "sankey",
1891
+ name: m.name,
1892
+ z: series.length,
1893
+ data: names.map((name) => ({ name })),
1894
+ links,
1895
+ left: 8,
1896
+ right: 8,
1897
+ top: 12,
1898
+ bottom: 12,
1899
+ emphasis: { focus: "adjacency" },
1900
+ lineStyle: { color: "gradient", opacity: 0.35, curveness: 0.5 },
1901
+ label: { color: theme.text },
1902
+ ...(typeof m.opacity === "number" ? { itemStyle: { opacity: m.opacity } } : {}),
1903
+ })
1904
+ continue
1905
+ }
1906
+
1907
+ // ─── Boîte à moustaches : cinq nombres, bruts (`values`) ou précalculés (`stats`) ───
1908
+ if (m.type === "boxplot") {
1909
+ const boxes: any[] = []
1910
+ for (let i = 0; i < data.length; i++) {
1911
+ const category = categoryOf(m, i)
1912
+ if (category === undefined || category === null) continue
1913
+ let stats: any = m.stats !== undefined ? channelValue(m.stats, data, i) : undefined
1914
+ if (!stats && m.values !== undefined) stats = boxStats(channelValue(m.values, data, i) ?? [])
1915
+ if (!stats) continue
1916
+ const five = Array.isArray(stats) ? stats : [stats.min, stats.q1, stats.median, stats.q3, stats.max]
1917
+ if (five.some((v: any) => typeof v !== "number" || !Number.isFinite(v))) continue
1918
+ boxes.push({ name: String(category), value: five })
885
1919
  }
1920
+ const border = colorFor(m.stroke ?? m.fill)
1921
+ series.push({
1922
+ type: "boxplot",
1923
+ name: m.name,
1924
+ z: series.length,
1925
+ data: boxes,
1926
+ boxWidth: [7, 40],
1927
+ itemStyle: {
1928
+ color: "transparent",
1929
+ borderColor: border,
1930
+ borderWidth: typeof m.strokeWidth === "number" ? m.strokeWidth : 2,
1931
+ ...(typeof m.opacity === "number" ? { opacity: m.opacity } : {}),
1932
+ },
1933
+ })
1934
+ continue
1935
+ }
1936
+
1937
+ // ─── Histogramme : tranches calculées en amont (`binsOf`) ───
1938
+ if (m.type === "histogram") {
1939
+ const bins = binsOf.get(m) ?? []
1940
+ series.push({
1941
+ type: "bar",
1942
+ name: m.name,
1943
+ z: series.length,
1944
+ barCategoryGap: "2%",
1945
+ data: bins.map((bin) => ({ name: bin.label, value: bin.count })),
1946
+ itemStyle: {
1947
+ color: colorFor(m.fill),
1948
+ borderRadius: [2, 2, 0, 0],
1949
+ ...(typeof m.opacity === "number" ? { opacity: m.opacity } : {}),
1950
+ },
1951
+ ...(dataLabelsOf(m) ? { label: dataLabelsOf(m) } : {}),
1952
+ })
1953
+ continue
1954
+ }
1955
+
1956
+ // ─── Cascade : barres flottantes cumulées + connecteurs ───
1957
+ if (m.type === "waterfall") {
1958
+ const values = data.map((_, i) => ordinateValueOf(m, i))
1959
+ const flags = data.map((_, i) => (m.isTotal !== undefined ? channelValue(m.isTotal, data, i) : false))
1960
+ const steps = waterfallSteps(values, flags)
1961
+ const positive = resolve(colorValue(m.fill, data, 0) ?? m.fill)
1962
+ const negative = resolve(colorValue(m.negativeFill, data, 0) ?? "negative")
1963
+ const totalColor = palette[colorIndex++ % palette.length]
1964
+ const items = steps.map((step, i) => {
1965
+ const color = step.totalRow
1966
+ ? totalColor
1967
+ : step.delta < 0
1968
+ ? negative
1969
+ : typeof positive === "string" && looksLikeColor(positive)
1970
+ ? positive
1971
+ : palette[0]
1972
+ return {
1973
+ name: String(categoryOf(m, i) ?? i),
1974
+ value: [Math.min(step.base, step.total), Math.max(step.base, step.total)],
1975
+ itemStyle: { color, ...(typeof m.opacity === "number" ? { opacity: m.opacity } : {}) },
1976
+ }
1977
+ })
1978
+ const connectors = steps
1979
+ .slice(0, -1)
1980
+ .map((step, i) => {
1981
+ const from = categories[i]
1982
+ const to = categories[i + 1]
1983
+ if (from === undefined || to === undefined) return null
1984
+ return [
1985
+ { coord: [from, step.total] },
1986
+ { coord: [to, step.total] },
1987
+ ]
1988
+ })
1989
+ .filter((pair): pair is { coord: (string | number)[] }[] => pair !== null)
1990
+ const labels = dataLabelsOf(m)
1991
+ series.push({
1992
+ type: "bar",
1993
+ name: m.name,
1994
+ z: series.length,
1995
+ barMaxWidth: 42,
1996
+ data: items,
1997
+ ...(labels
1998
+ ? {
1999
+ label: {
2000
+ ...labels,
2001
+ formatter: (p: any) => {
2002
+ const step = steps[p.dataIndex]
2003
+ if (!step) return ""
2004
+ return formatText(step.totalRow ? step.total : step.delta, m.labelFormat)
2005
+ },
2006
+ },
2007
+ }
2008
+ : {}),
2009
+ ...(connectors.length
2010
+ ? {
2011
+ markLine: {
2012
+ silent: true,
2013
+ symbol: "none",
2014
+ animation: false,
2015
+ data: connectors,
2016
+ lineStyle: { color: theme.muted, type: "dashed", width: 1 },
2017
+ },
2018
+ }
2019
+ : {}),
2020
+ })
886
2021
  continue
887
2022
  }
888
2023
 
@@ -905,7 +2040,16 @@ export function chartToECharts(config: QChartConfig): Record<string, any> {
905
2040
  for (const g of groups) {
906
2041
  const points = g.indexes
907
2042
  .map((i) => {
908
- const pair = pairOf(m, i)
2043
+ // `line` / `area` : un trou garde sa catégorie avec une ordonnée `null` — la ligne
2044
+ // se coupe (ou se relie avec `connectNulls`). Les autres marques sautent le point.
2045
+ if (m.type === "line" || m.type === "area") {
2046
+ const scaled = scaledPairOf(m, i)
2047
+ if (scaled) return { value: scaled, index: i }
2048
+ const cat = categoryOf(m, i)
2049
+ if (cat === undefined || cat === null) return null
2050
+ return { value: (horizontal ? [null, cat] : [cat, null]) as [any, any], index: i }
2051
+ }
2052
+ const pair = scaledPairOf(m, i)
909
2053
  return pair ? { value: pair, index: i } : null
910
2054
  })
911
2055
  .filter((p): p is { value: [any, any]; index: number } => p !== null)
@@ -949,12 +2093,18 @@ export function chartToECharts(config: QChartConfig): Record<string, any> {
949
2093
  typeof m.src === "string" && /^([./]|[a-z][a-z0-9+.-]*:)/i.test(m.src) ? m.src : undefined
950
2094
 
951
2095
  const name = g.key ?? m.name
2096
+ const stackGroup = stackGroupOf(m)
952
2097
 
953
2098
  const common = {
954
2099
  name,
955
2100
  z: series.length,
956
- ...(m.stack ? { stack: "total" } : {}),
2101
+ ...(stackGroup ? { stack: stackGroup } : {}),
2102
+ ...(m.yAxis === "right" && !horizontal ? { yAxisIndex: 1 } : {}),
957
2103
  ...(theme.muted ? { markLineColor: theme.muted } : {}),
2104
+ // Info-bulle **par marque** : reçoit les paramètres du point et la valeur brute
2105
+ ...(m.tooltip
2106
+ ? { tooltip: { formatter: (p: any) => m.tooltip!(p, ordinateOfPoint(p)) } }
2107
+ : {}),
958
2108
  }
959
2109
 
960
2110
  /** Valeur au point `p` si elle diffère du défaut donné (sinon `undefined`) */
@@ -1010,26 +2160,80 @@ export function chartToECharts(config: QChartConfig): Record<string, any> {
1010
2160
  const t = m.title !== undefined ? channelValue(m.title, data, p.index) : undefined
1011
2161
  if (t !== undefined) item.tooltip = { formatter: String(t) }
1012
2162
  }
2163
+ // Seuils déclaratifs : ils colorent le point (pas les images ni le texte)
2164
+ if (m.type !== "image" && m.type !== "text") {
2165
+ const threshold = thresholdOf(p.value[horizontal ? 0 : 1], m.thresholds)
2166
+ if (threshold) item.itemStyle = { ...(item.itemStyle ?? {}), color: threshold }
2167
+ }
1013
2168
  return item
1014
2169
  }
1015
2170
 
1016
2171
  const opacity = typeof m.opacity === "number" ? m.opacity : undefined
1017
2172
 
1018
2173
  if (m.type === "bar") {
1019
- series.push({
1020
- ...common,
1021
- type: "bar",
1022
- data: points.map(perPoint),
1023
- itemStyle: { color: baseColor, borderRadius: [3, 3, 0, 0], ...(opacity !== undefined ? { opacity } : {}) },
1024
- barMaxWidth: 42,
1025
- })
2174
+ const labels = dataLabelsOf(m)
2175
+ if (m.base !== undefined) {
2176
+ // Barre **flottante** : ECharts n'accepte pas `[cat, [y0, y1]]` — on empile une base
2177
+ // invisible et le delta visible dans **le même groupe** d'empilement.
2178
+ const stackName = `${stackGroup ?? "bar"}__base_${series.length}`
2179
+ const at = horizontal ? 0 : 1
2180
+ const baseColorFill = resolve(m.baseFill) ?? "transparent"
2181
+ const bases = points.map((p) => {
2182
+ const raw = Number(channelValue(m.base, data, p.index))
2183
+ const base = Number.isFinite(raw) ? raw : 0
2184
+ return horizontal ? [base, p.value[1]] : [p.value[0], base]
2185
+ })
2186
+ series.push({
2187
+ type: "bar",
2188
+ z: series.length,
2189
+ stack: stackName,
2190
+ silent: true,
2191
+ legendHoverLink: false,
2192
+ tooltip: { show: false },
2193
+ emphasis: { disabled: true },
2194
+ barMaxWidth: 42,
2195
+ data: bases.map((value) => ({ value, itemStyle: { color: baseColorFill } })),
2196
+ itemStyle: { color: baseColorFill },
2197
+ // Marqueur interne : la base ne participe ni à la légende, ni à l'estompage
2198
+ __base: true,
2199
+ })
2200
+ const tops = points.map((p, k) => {
2201
+ const base = Number((bases[k] as any)[at])
2202
+ const value = Number(p.value[at])
2203
+ const delta = (Number.isFinite(value) ? value : 0) - (Number.isFinite(base) ? base : 0)
2204
+ const item = perPoint(p)
2205
+ item.value = horizontal ? [delta, p.value[1]] : [p.value[0], delta]
2206
+ return item
2207
+ })
2208
+ series.push({
2209
+ ...common,
2210
+ type: "bar",
2211
+ z: series.length,
2212
+ stack: stackName,
2213
+ data: tops,
2214
+ itemStyle: { color: baseColor, borderRadius: [3, 3, 0, 0], ...(opacity !== undefined ? { opacity } : {}) },
2215
+ barMaxWidth: 42,
2216
+ ...(labels ? { label: labels } : {}),
2217
+ })
2218
+ } else {
2219
+ series.push({
2220
+ ...common,
2221
+ type: "bar",
2222
+ data: points.map(perPoint),
2223
+ itemStyle: { color: baseColor, borderRadius: [3, 3, 0, 0], ...(opacity !== undefined ? { opacity } : {}) },
2224
+ barMaxWidth: 42,
2225
+ ...(labels ? { label: labels } : {}),
2226
+ })
2227
+ }
1026
2228
  } else if (m.type === "line" || m.type === "area") {
1027
2229
  const isArea = m.type === "area"
2230
+ const labels = dataLabelsOf(m)
1028
2231
  series.push({
1029
2232
  ...common,
1030
2233
  type: "line",
1031
2234
  data: points.map(perPoint),
1032
- smooth: false,
2235
+ smooth: m.smooth === true ? true : typeof m.smooth === "number" ? m.smooth : false,
2236
+ ...(m.connectNulls ? { connectNulls: true } : {}),
1033
2237
  symbol: "circle",
1034
2238
  symbolSize: isArea ? 0 : 5,
1035
2239
  lineStyle: {
@@ -1038,16 +2242,38 @@ export function chartToECharts(config: QChartConfig): Record<string, any> {
1038
2242
  ...(opacity !== undefined ? { opacity } : {}),
1039
2243
  },
1040
2244
  ...(isArea ? { areaStyle: { color: baseColor, opacity: 0.18 } } : {}),
2245
+ ...(labels ? { label: labels } : {}),
1041
2246
  })
1042
2247
  } else if (m.type === "dot") {
2248
+ // `fill` numérique → **échelle de couleurs** (visualMap dédié à cette série).
2249
+ // La valeur de couleur voyage en **3ᵉ dimension** du point (`[x, y, v]`), sinon
2250
+ // le moteur n'a rien à mapper.
2251
+ const fills = g.indexes.map((i) => colorValue(m.fill, data, i))
2252
+ const numericFill = fills.length > 0 && fills.every((v) => typeof v === "number")
2253
+ const dotData = points.map((p) => {
2254
+ const item = perPoint(p)
2255
+ if (numericFill) item.value = [...p.value, Number(colorValue(m.fill, data, p.index))]
2256
+ return item
2257
+ })
2258
+ if (numericFill) {
2259
+ const nums = fills as number[]
2260
+ visualMaps.push({
2261
+ show: false,
2262
+ seriesIndex: series.length,
2263
+ dimension: 2,
2264
+ min: Math.min(...nums),
2265
+ max: Math.max(...nums),
2266
+ inRange: { color: palette },
2267
+ })
2268
+ }
1043
2269
  series.push({
1044
2270
  ...common,
1045
2271
  type: "scatter",
1046
2272
  symbol: typeof m.symbol === "string" ? m.symbol : "circle",
1047
- data: points.map(perPoint),
2273
+ data: dotData,
1048
2274
  symbolSize: typeof radius === "number" ? radius * 2 + 2 : 9,
1049
2275
  itemStyle: {
1050
- color: baseColor,
2276
+ ...(numericFill ? {} : { color: baseColor }),
1051
2277
  ...(typeof outline === "string" && looksLikeColor(outline)
1052
2278
  ? {
1053
2279
  borderColor: outline,
@@ -1145,6 +2371,54 @@ export function chartToECharts(config: QChartConfig): Record<string, any> {
1145
2371
  }
1146
2372
  }
1147
2373
 
2374
+ // Ligne de tendance des nuages de points (`trend: true`) — régression linéaire, deux points
2375
+ for (const m of marks) {
2376
+ if (m.type !== "dot" || !m.trend) continue
2377
+ const data = dataOf(m)
2378
+ const pts: [number, number][] = []
2379
+ for (let i = 0; i < data.length; i++) {
2380
+ const cat = categoryOf(m, i)
2381
+ const val = ordinateValueOf(m, i)
2382
+ if (
2383
+ typeof cat === "number" &&
2384
+ typeof val === "number" &&
2385
+ Number.isFinite(cat) &&
2386
+ Number.isFinite(val)
2387
+ ) {
2388
+ pts.push([cat, val])
2389
+ }
2390
+ }
2391
+ const fit = linearFit(pts)
2392
+ if (!fit) continue
2393
+ const xs = pts.map(([x]) => x)
2394
+ const x0 = Math.min(...xs)
2395
+ const x1 = Math.max(...xs)
2396
+ const at = (x: number) => fit.slope * x + fit.intercept
2397
+ series.push({
2398
+ type: "line",
2399
+ z: series.length,
2400
+ silent: true,
2401
+ symbol: "none",
2402
+ legendHoverLink: false,
2403
+ tooltip: { show: false },
2404
+ lineStyle: { color: theme.muted, type: "dashed", width: 1.5 },
2405
+ data: horizontal
2406
+ ? [{ value: [at(x0), x0] }, { value: [at(x1), x1] }]
2407
+ : [{ value: [x0, at(x0)] }, { value: [x1, at(x1)] }],
2408
+ __trend: true,
2409
+ })
2410
+ }
2411
+
2412
+ // Zones de seuil (`band`) : `markArea` sur la première série cartésienne
2413
+ if (bandsY.length || bandsX.length) {
2414
+ if (!series.length) {
2415
+ series.push({ type: "line", data: [], silent: true, symbol: "none", lineStyle: { opacity: 0 } })
2416
+ }
2417
+ const host =
2418
+ series.find((s) => s.type === "bar" || s.type === "line" || s.type === "scatter") ?? series[0]!
2419
+ host.markArea = { silent: true, animation: false, data: [...bandsY, ...bandsX] }
2420
+ }
2421
+
1148
2422
  // Les règles sont posées sur la première série (ou une série vide hôte)
1149
2423
  if (rulesY.length || rulesX.length) {
1150
2424
  if (!series.length) {
@@ -1162,7 +2436,7 @@ export function chartToECharts(config: QChartConfig): Record<string, any> {
1162
2436
  // ─── Légende : position (haut par défaut), alignement, marge, place réservée ───
1163
2437
  // La légende et la zone de tracé ne doivent pas se toucher : la marge (`offset`) est
1164
2438
  // **réservée** dans le `grid` (haut/bas/gauche/droite selon la position).
1165
- const legendOn = config.legend === false ? false : config.legend === true || series.some((s) => !!s.name)
2439
+ const legendOn = config.legend === false ? false : config.legend === true || (!compact && series.some((s) => !!s.name))
1166
2440
  const legendConfig: QChartLegend = typeof config.legend === "object" && config.legend !== null ? config.legend : {}
1167
2441
  const legendPosition = legendConfig.position ?? "top"
1168
2442
  // `offset` par défaut : 24 px — une respiration **conséquente** entre la légende et la
@@ -1195,51 +2469,91 @@ export function chartToECharts(config: QChartConfig): Record<string, any> {
1195
2469
  ? { bottom: scaleBand, left: legendAlign }
1196
2470
  : { top: legendTop, left: legendAlign }),
1197
2471
  }
1198
- const gridBox = {
1199
- left: 8 + (legendOn && legendPosition === "left" ? LEGEND_COLUMN + legendGap : 0),
1200
- right: 16 + (legendOn && legendPosition === "right" ? LEGEND_COLUMN + legendGap : 0),
1201
- // Légende → nom d'axe → zone de tracé, sans recouvrement
1202
- top: (legendTopBand > 0 ? legendTopBand : config.title ? 44 : 16) + axisNameTop,
1203
- bottom:
1204
- 8 +
1205
- axisNameBottom +
1206
- scaleBand +
1207
- (legendOn && legendPosition === "bottom" ? LEGEND_LINE + legendGap : 0),
1208
- containLabel: true,
1209
- }
2472
+ /** Axe des ordonnées **secondaire** (marques avec `yAxis: 'right'`) */
2473
+ const hasRightAxis = series.some((s) => s.yAxisIndex === 1)
2474
+ const gridBox = compact
2475
+ ? {
2476
+ left: 2,
2477
+ right: hasRightAxis ? 12 : 2,
2478
+ top: config.title ? 20 : 2,
2479
+ bottom: 2,
2480
+ containLabel: true,
2481
+ }
2482
+ : {
2483
+ left: 8 + (legendOn && legendPosition === "left" ? LEGEND_COLUMN + legendGap : 0),
2484
+ right:
2485
+ 16 +
2486
+ (legendOn && legendPosition === "right" ? LEGEND_COLUMN + legendGap : 0) +
2487
+ (hasRightAxis ? 12 : 0),
2488
+ // Légende → nom d'axe → zone de tracé, sans recouvrement
2489
+ top: (legendTopBand > 0 ? legendTopBand : config.title ? 44 : 16) + axisNameTop,
2490
+ bottom:
2491
+ 8 +
2492
+ axisNameBottom +
2493
+ scaleBand +
2494
+ (legendOn && legendPosition === "bottom" ? LEGEND_LINE + legendGap : 0),
2495
+ containLabel: true,
2496
+ }
1210
2497
 
1211
2498
  const axisLabel = { color: theme.muted }
1212
- /** Nombres de l'axe des ordonnées : un peu d'air entre eux et l'axe (défaut moteur : 8) —
1213
- * `grid.containLabel` réserve la place, rien d'autre à ajuster. */
1214
- const valueAxisLabel = { ...axisLabel, margin: 16 }
1215
2499
  const axisLine = { lineStyle: { color: theme.grid } }
1216
2500
 
2501
+ /** Libellés d'axe : format demandé, granularité temporelle, rotation, mode compact */
2502
+ const axisLabelOptions = (axis: QChartAxis | undefined, temporal = false) => {
2503
+ const formatted =
2504
+ axisFormatter(axis?.format, { locale: axis?.locale ?? locale }) ??
2505
+ (temporal
2506
+ ? timeTickFormatter(axis?.timeUnit, axis?.locale ?? locale, axis?.timeZone)
2507
+ : undefined)
2508
+ const opt: Record<string, any> = { ...axisLabel, hideOverlap: true }
2509
+ if (formatted) opt.formatter = formatted
2510
+ if (typeof axis?.labelRotate === "number") opt.rotate = axis.labelRotate
2511
+ if (axis?.interval !== undefined && axis.interval !== "auto") opt.interval = axis.interval
2512
+ if (compact || axis?.show === false) opt.show = false
2513
+ return opt
2514
+ }
2515
+
1217
2516
  const hasHeatmap = series.some((s) => s.type === "heatmap")
1218
- const categoryAxis = (axis: QChartAxis | undefined, data: string[] = categories, heat = false) => ({
2517
+ const categoryAxis = (
2518
+ axis: QChartAxis | undefined,
2519
+ data: string[] = categories,
2520
+ heat = false,
2521
+ temporal = false,
2522
+ ) => ({
1219
2523
  type: "category",
2524
+ show: axis?.show !== false,
1220
2525
  data,
1221
- name: axis?.label,
2526
+ name: compact ? undefined : axis?.label,
1222
2527
  nameTextStyle: axisLabel,
1223
- axisLabel: { ...axisLabel, hideOverlap: true },
1224
- axisLine,
2528
+ axisLabel: axisLabelOptions(axis, temporal),
2529
+ axisLine: { ...axisLine, show: !compact && axis?.show !== false },
1225
2530
  axisTick: { show: false },
1226
2531
  splitLine: { show: false },
1227
2532
  // Carte de chaleur : quadrillage des cases
1228
2533
  ...(heat ? { splitArea: { show: true } } : {}),
1229
2534
  boundaryGap: hasHeatmap || series.some((s) => s.type === "bar"),
1230
2535
  })
1231
- const valueAxis = (axis: QChartAxis | undefined, zero: boolean) => ({
1232
- type: axis?.type === "log" ? "log" : "value",
1233
- name: axis?.label,
1234
- nameTextStyle: axisLabel,
1235
- min: axis?.min ?? (zero ? 0 : undefined),
1236
- max: axis?.max,
1237
- axisLabel:
1238
- typeof axis?.margin === "number" ? { ...valueAxisLabel, margin: axis.margin } : valueAxisLabel,
1239
- axisLine: { show: false },
1240
- axisTick: { show: false },
1241
- splitLine: { show: axis?.grid ?? true, lineStyle: { color: theme.grid } },
1242
- })
2536
+ const valueAxis = (axis: QChartAxis | undefined, zero: boolean, temporal = false) => {
2537
+ const label = axisLabelOptions(axis, temporal)
2538
+ if (!temporal) label.margin = typeof axis?.margin === "number" ? axis.margin : 16
2539
+ else if (typeof axis?.margin === "number") label.margin = axis.margin
2540
+ return {
2541
+ type: axis?.type === "log" ? "log" : temporal ? "time" : "value",
2542
+ show: axis?.show !== false,
2543
+ name: compact ? undefined : axis?.label,
2544
+ nameTextStyle: axisLabel,
2545
+ min: axis?.min ?? (zero ? 0 : undefined),
2546
+ max: axis?.max,
2547
+ ...(typeof axis?.splitNumber === "number" ? { splitNumber: axis.splitNumber } : {}),
2548
+ axisLabel: label,
2549
+ axisLine: { show: false },
2550
+ axisTick: { show: false },
2551
+ splitLine: {
2552
+ show: compact ? axis?.grid === true : axis?.grid ?? true,
2553
+ lineStyle: { color: theme.grid },
2554
+ },
2555
+ }
2556
+ }
1243
2557
 
1244
2558
  // ─── Nom d'axe : placement **toujours dans le cadre** ───
1245
2559
  // Axe **horizontal** : centré sous l'axe (`middle`). À la fin de l'axe (`end`, le défaut
@@ -1256,26 +2570,41 @@ export function chartToECharts(config: QChartConfig): Record<string, any> {
1256
2570
  // Orientation horizontale : catégories sur l'axe Y
1257
2571
  const hasBar = series.some((s) => s.type === "bar")
1258
2572
  const xAxisBase = hasHeatmap
1259
- ? categoryAxis(config.x, categories, true)
2573
+ ? categoryAxis(config.x, categories, true, axisType === "time")
1260
2574
  : horizontal
1261
- ? valueAxis(config.x, hasBar)
2575
+ ? valueAxis(config.x, hasBar, axisType === "time")
1262
2576
  : axisType === "category"
1263
2577
  ? categoryAxis(config.x)
1264
- : valueAxis(config.x, false)
2578
+ : valueAxis(config.x, false, axisType === "time")
1265
2579
  const yAxisBase = hasHeatmap
1266
2580
  ? categoryAxis(config.y, rowCategories, true)
1267
2581
  : horizontal
1268
2582
  ? axisType === "category"
1269
2583
  ? categoryAxis(config.y)
1270
- : valueAxis(config.y, false)
1271
- : valueAxis(config.y, hasBar)
2584
+ : valueAxis(config.y, false, config.y?.type === "time")
2585
+ : valueAxis(config.y, hasBar, config.y?.type === "time")
1272
2586
  const xAxis = config.x?.label ? { ...xAxisBase, ...axisNameX } : xAxisBase
1273
- const yAxis = config.y?.label ? { ...yAxisBase, ...axisNameY } : yAxisBase
2587
+ const yAxisLeft = config.y?.label ? { ...yAxisBase, ...axisNameY } : yAxisBase
2588
+ // Axe **secondaire** : mêmes règles, nom ancré à droite (sinon il sort du cadre)
2589
+ const yAxisRight = hasRightAxis
2590
+ ? {
2591
+ ...valueAxis(config.yRight, series.some((s) => s.yAxisIndex === 1 && s.type === "bar"), config.yRight?.type === "time"),
2592
+ ...(config.yRight?.label
2593
+ ? { nameLocation: "end" as const, nameTextStyle: { ...axisLabel, align: "right" as const } }
2594
+ : {}),
2595
+ }
2596
+ : undefined
2597
+ const yAxis = hasRightAxis ? [yAxisLeft, yAxisRight] : yAxisLeft
1274
2598
 
1275
- // Familles **hors axes** (`pie` seul) : ni grid ni xAxis/yAxis — sinon ECharts laisse
1276
- // un cadre fantôme et décale le titre.
2599
+ // Familles **hors axes** (`pie`, `gauge`, `radar`, `sankey`…) : ni grid ni xAxis/yAxis —
2600
+ // sinon ECharts laisse un cadre fantôme et décale le titre.
1277
2601
  const cartesian = series.some(
1278
- (s) => s.type === "bar" || s.type === "line" || s.type === "scatter" || s.type === "heatmap",
2602
+ (s) =>
2603
+ s.type === "bar" ||
2604
+ s.type === "line" ||
2605
+ s.type === "scatter" ||
2606
+ s.type === "heatmap" ||
2607
+ s.type === "boxplot",
1279
2608
  )
1280
2609
 
1281
2610
  // Un tooltip d'axe suppose des données **par catégorie** : vrai pour tout cartésien, sauf si
@@ -1287,6 +2616,40 @@ export function chartToECharts(config: QChartConfig): Record<string, any> {
1287
2616
  !series.some((s) => s.type === "heatmap") &&
1288
2617
  !marks.some((m) => m.title !== undefined || m.type === "text"))
1289
2618
 
2619
+ /** Info-bulle : `false`, `true`, ou configuration (formateurs, tri) */
2620
+ const tooltipConfig: QChartTooltip =
2621
+ typeof config.tooltip === "object" && config.tooltip !== null ? config.tooltip : {}
2622
+ /** Formateur d'axe par défaut enrichi : valeurs formatées, noms formatés, tri */
2623
+ const axisTooltipFormatter = (params: any) => {
2624
+ const list: any[] = Array.isArray(params) ? params : [params]
2625
+ const head = list[0]?.axisValueLabel ?? list[0]?.axisValue ?? ""
2626
+ const rows = tooltipConfig.sortDesc
2627
+ ? [...list].sort((a, b) => Number(ordinateOfPoint(b) ?? 0) - Number(ordinateOfPoint(a) ?? 0))
2628
+ : list
2629
+ const body = rows
2630
+ .map((p) => {
2631
+ const raw = ordinateOfPoint(p)
2632
+ const rawName = p.seriesName ?? p.name ?? ""
2633
+ const name = tooltipConfig.nameFormatter ? tooltipConfig.nameFormatter(rawName, p) : rawName
2634
+ const text = tooltipConfig.valueFormatter
2635
+ ? tooltipConfig.valueFormatter(raw, p)
2636
+ : raw === null || raw === undefined
2637
+ ? "—"
2638
+ : String(raw)
2639
+ return `${p.marker ?? ""}${name ? `${name}: ` : ""}${text}`
2640
+ })
2641
+ .join("<br/>")
2642
+ return `${head ? `<div style="margin-bottom:2px">${head}</div>` : ""}${body}`
2643
+ }
2644
+
2645
+ // `valueFormatter` par point : ECharts lit cette option **au niveau de la série** quand
2646
+ // l'info-bulle est en mode `item` (pas de formateur global).
2647
+ if (!axisTooltip && tooltipConfig.valueFormatter) {
2648
+ for (const one of series) {
2649
+ one.tooltip = { ...(one.tooltip ?? {}), valueFormatter: tooltipConfig.valueFormatter }
2650
+ }
2651
+ }
2652
+
1290
2653
  const option: Record<string, any> = {
1291
2654
  ...(config.title
1292
2655
  ? { title: { text: config.title, left: 0, textStyle: { fontSize: 13, fontWeight: 600, color: theme.text } } }
@@ -1298,6 +2661,7 @@ export function chartToECharts(config: QChartConfig): Record<string, any> {
1298
2661
  yAxis,
1299
2662
  }
1300
2663
  : {}),
2664
+ ...(radarOptions.length ? { radar: radarOptions.length === 1 ? radarOptions[0] : radarOptions } : {}),
1301
2665
  ...(visualMaps.length ? { visualMap: visualMaps } : {}),
1302
2666
  tooltip:
1303
2667
  config.tooltip === false
@@ -1312,6 +2676,11 @@ export function chartToECharts(config: QChartConfig): Record<string, any> {
1312
2676
  ? { axisPointer: { type: series.some((s) => s.type === "bar") ? "shadow" : "line" } }
1313
2677
  : {}),
1314
2678
  confine: true,
2679
+ ...(tooltipConfig.formatter
2680
+ ? { formatter: tooltipConfig.formatter }
2681
+ : axisTooltip && (tooltipConfig.valueFormatter || tooltipConfig.nameFormatter || tooltipConfig.sortDesc)
2682
+ ? { formatter: axisTooltipFormatter }
2683
+ : {}),
1315
2684
  ...tooltipStyle(theme),
1316
2685
  },
1317
2686
  legend: legendOption,
@@ -1330,6 +2699,8 @@ export function chartToECharts(config: QChartConfig): Record<string, any> {
1330
2699
  if ((config.linkMode ?? "filter") === "dim" && hasSelection) {
1331
2700
  const dim = typeof config.dimOpacity === "number" ? config.dimOpacity : 0.25
1332
2701
  for (const one of option.series ?? []) {
2702
+ // Les séries techniques (base d'une barre flottante, tendance) restent intactes
2703
+ if (one.__base || one.__trend) continue
1333
2704
  one.selectedMode = "single"
1334
2705
  // Seule l'opacité change : on ne touche ni au rayon (celui des barres vient du
1335
2706
  // traducteur : `borderRadius: [3, 3, 0, 0]`) ni au reste du style de la marque.