@dnax/ui 0.0.66 → 0.0.69

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,12 @@
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
+
36
+ export type { QChartFormat, QChartFormatKind, QChartFormatOptions } from "./chartFormat"
37
+
32
38
  /** Valeur d'un canal : nom de champ, valeurs explicites ou fonction d'accès */
33
39
  export type QChartChannel = string | readonly any[] | ((d: any, i: number) => any) | number
34
40
 
@@ -45,6 +51,27 @@ export type QChartMarkType =
45
51
  | "heatmap"
46
52
  /** Données en **table** (HTML, pas de canvas) — mêmes données, même `link`, même sélection */
47
53
  | "table"
54
+ // ── Analyses métier (P1) ──
55
+ /** Bande de seuil / zone d'alerte (`y0` → `y1`) — `markArea` */
56
+ | "band"
57
+ /** Jauge (valeur, cible, plages de performance) — hors axes */
58
+ | "gauge"
59
+ /** Entonnoir — hors axes */
60
+ | "funnel"
61
+ /** Radar multicritères — hors axes */
62
+ | "radar"
63
+ /** Treemap hiérarchique — hors axes */
64
+ | "treemap"
65
+ /** Sunburst hiérarchique — hors axes */
66
+ | "sunburst"
67
+ /** Flux entre étapes — hors axes */
68
+ | "sankey"
69
+ /** Boîte à moustaches — cinq nombres par catégorie */
70
+ | "boxplot"
71
+ /** Histogramme — binning natif (`bins`, `domain`) */
72
+ | "histogram"
73
+ /** Cascade — cumul et barres flottantes */
74
+ | "waterfall"
48
75
 
49
76
  /** Sens des axes : `horizontal` inverse les axes (catégories sur l'axe Y) */
50
77
  export type QChartOrientation = "vertical" | "horizontal"
@@ -61,6 +88,12 @@ export interface QChartColumn {
61
88
  align?: "start" | "center" | "end"
62
89
  /** Formate la valeur affichée (reçoit la valeur et la ligne) */
63
90
  format?: (value: any, row: any) => string
91
+ /** Largeur de la colonne (nombre = px, sinon valeur CSS) */
92
+ width?: number | string
93
+ /** La colonne est-elle **triable** ? (défaut : `true` quand la table est triable) */
94
+ sortable?: boolean
95
+ /** Agrégat de la ligne de **totaux** (`sum`, `avg`, `min`, `max`, `count`) */
96
+ summary?: "sum" | "avg" | "min" | "max" | "count"
64
97
  }
65
98
 
66
99
  /** Séparateurs d'une marque `table` : `horizontal` (défaut — un trait sous chaque rangée),
@@ -70,14 +103,63 @@ export type QChartTableSeparator = "horizontal" | "vertical" | "grid" | "none"
70
103
  /** Modèle prêt à rendre d'une marque `table` */
71
104
  export interface QChartTable {
72
105
  columns: QChartColumn[]
73
- /** Lignes **jointes** à la sélection (même règle que les autres marques) */
106
+ /** Lignes **jointes** à la sélection (même règle que les autres marques), puis triées et paginées */
74
107
  rows: any[]
75
108
  /** Séparateurs demandés par la marque (`separator`, défaut `horizontal`) */
76
109
  separator: QChartTableSeparator
110
+ /** Toutes les lignes visibles (avant pagination) — export CSV et totaux */
111
+ allRows?: any[]
112
+ /** Page courante (0-based) et nombre de pages (quand `pageSize` est donné) */
113
+ page?: number
114
+ pages?: number
115
+ pageSize?: number
116
+ /** Tri courant */
117
+ sortBy?: string
118
+ sortDesc?: boolean
119
+ /** Ligne de **totaux** déjà formatée (clé = champ) */
120
+ totals?: Record<string, string>
121
+ }
122
+
123
+ /** État de vue d'une table (tri / page), piloté par le composant */
124
+ export interface QChartTableView {
125
+ sortBy?: string
126
+ sortDesc?: boolean
127
+ page?: number
128
+ }
129
+
130
+ /** Compare deux valeurs de cellule : nombres d'abord, puis texte ; les vides en dernier. */
131
+ function compareCells(a: any, b: any): number {
132
+ const ea = a === null || a === undefined || Number.isNaN(a)
133
+ const eb = b === null || b === undefined || Number.isNaN(b)
134
+ if (ea || eb) return ea && eb ? 0 : ea ? 1 : -1
135
+ if (typeof a === "number" && typeof b === "number") return a - b
136
+ const sa = String(a)
137
+ const sb = String(b)
138
+ return sa < sb ? -1 : sa > sb ? 1 : 0
139
+ }
140
+
141
+ /** Agrège une colonne pour la ligne de totaux (`undefined` = pas d'agrégat) */
142
+ function summarize(column: QChartColumn, rows: any[]): string | undefined {
143
+ const raw = rows.map((row) => row?.[column.field])
144
+ const nums = raw.filter((v): v is number => typeof v === "number" && Number.isFinite(v))
145
+ const mode = column.summary ?? (nums.length === raw.filter((v) => v !== null && v !== undefined).length && nums.length > 0 ? "sum" : undefined)
146
+ if (!mode) return undefined
147
+ if (mode === "count") return String(rows.length)
148
+ if (nums.length === 0) return ""
149
+ const value =
150
+ mode === "sum"
151
+ ? nums.reduce((s, v) => s + v, 0)
152
+ : mode === "avg"
153
+ ? nums.reduce((s, v) => s + v, 0) / nums.length
154
+ : mode === "min"
155
+ ? Math.min(...nums)
156
+ : Math.max(...nums)
157
+ return column.format ? column.format(value, undefined) : String(Number(value.toPrecision(12)))
77
158
  }
78
159
 
79
160
  /** 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).
161
+ * sélection partagée (comme n'importe quelle marque liée), puis **tri**, **pagination** et
162
+ * **totaux** (options de la marque, ou `view` pour un état interactif).
81
163
  *
82
164
  * `mode` a le même sens que sur les graphiques : `filter` ne garde que les lignes liées, `dim`
83
165
  * les **garde toutes** (c'est au rendu d'estomper les autres, comme pour une série). */
@@ -85,18 +167,19 @@ export function tableModel(
85
167
  mark: QChartMark | undefined,
86
168
  selection?: any,
87
169
  mode: QChartLinkMode = "filter",
170
+ view: QChartTableView = {},
88
171
  ): QChartTable {
89
172
  if (!mark) return { columns: [], rows: [], separator: "horizontal" }
90
173
  const separator = mark.separator ?? "horizontal"
91
174
  const all = mark.data ?? []
92
- const rows = mode === "dim" ? all : linkedRows(all, chartLink(mark.link), selection)
175
+ const visible = mode === "dim" ? all : linkedRows(all, chartLink(mark.link), selection)
93
176
  const declared = mark.columns ?? []
94
177
  const columns: QChartColumn[] = declared.map((column) =>
95
178
  typeof column === "string" ? { field: column, label: column } : { ...column, label: column.label ?? column.field },
96
179
  )
97
180
  if (columns.length === 0) {
98
181
  const seen = new Set<string>()
99
- for (const row of rows) {
182
+ for (const row of visible) {
100
183
  if (row === null || typeof row !== "object") continue
101
184
  for (const key of Object.keys(row)) {
102
185
  if (!seen.has(key)) {
@@ -106,7 +189,98 @@ export function tableModel(
106
189
  }
107
190
  }
108
191
  }
109
- return { columns, rows, separator }
192
+
193
+ // ── Tri ──
194
+ const sortBy = view.sortBy ?? mark.sortBy
195
+ const sortDesc = !!(view.sortDesc ?? mark.sortDesc ?? false)
196
+ const sorted = sortBy ? [...visible].sort((a, b) => compareCells(a?.[sortBy], b?.[sortBy])) : visible
197
+ if (sortBy && sortDesc) sorted.reverse()
198
+
199
+ // ── Totaux (sur toutes les lignes visibles, pas seulement la page) ──
200
+ let totals: Record<string, string> | undefined
201
+ if (mark.totals) {
202
+ const label = typeof mark.totals === "object" ? (mark.totals.label ?? "") : ""
203
+ const out: Record<string, string> = {}
204
+ columns.forEach((column, i) => {
205
+ const value = i === 0 && label ? label : summarize(column, sorted)
206
+ if (value !== undefined) out[column.field] = value
207
+ })
208
+ if (Object.keys(out).length) totals = out
209
+ }
210
+
211
+ // ── Pagination ──
212
+ const pageSize = typeof mark.pageSize === "number" && mark.pageSize > 0 ? Math.floor(mark.pageSize) : 0
213
+ const pages = pageSize > 0 ? Math.max(1, Math.ceil(sorted.length / pageSize)) : undefined
214
+ const page = pages ? Math.max(0, Math.min(pages - 1, Math.floor(view.page ?? mark.page ?? 0))) : undefined
215
+ const rows = pages && page !== undefined ? sorted.slice(page * pageSize, page * pageSize + pageSize) : sorted
216
+
217
+ const model: QChartTable = { columns, rows, separator }
218
+ if (mark.sortBy !== undefined || sortBy !== undefined) {
219
+ model.sortBy = sortBy
220
+ model.sortDesc = sortDesc
221
+ }
222
+ if (pages !== undefined) {
223
+ model.allRows = sorted
224
+ model.page = page
225
+ model.pages = pages
226
+ model.pageSize = pageSize
227
+ } else if (totals) {
228
+ model.allRows = sorted
229
+ }
230
+ if (totals) model.totals = totals
231
+ return model
232
+ }
233
+
234
+ /** Export CSV d'un modèle de table (RFC 4180 : guillemets doublés, séparateur déduit du séparateur
235
+ * de champs, pas de ligne d'en-tête ajoutée si `header: false`). Ne dépend d'aucun DOM. */
236
+ export function tableCsv(
237
+ model: QChartTable,
238
+ options: { separator?: string; header?: boolean; label?: string } = {},
239
+ ): string {
240
+ const delimiter = options.separator ?? ","
241
+ const escape = (value: any) => {
242
+ const text = value === null || value === undefined ? "" : String(value)
243
+ return new RegExp(`["\n\r${delimiter}]`).test(text) ? `"${text.replace(/"/g, '""')}"` : text
244
+ }
245
+ const rows = model.allRows ?? model.rows
246
+ const lines: string[] = []
247
+ if (options.header !== false) {
248
+ lines.push(model.columns.map((column) => escape(column.label ?? column.field)).join(delimiter))
249
+ }
250
+ for (const row of rows) {
251
+ lines.push(model.columns.map((column) => escape(row?.[column.field])).join(delimiter))
252
+ }
253
+ if (model.totals && options.label !== undefined) {
254
+ lines.push(
255
+ model.columns
256
+ .map((column, i) => escape(model.totals?.[column.field] ?? (i === 0 ? options.label : "")))
257
+ .join(delimiter),
258
+ )
259
+ }
260
+ return lines.join("\r\n")
261
+ }
262
+
263
+ /** Seuil déclaratif de couleur (barres, points, aires) : la première règle qui matche
264
+ * colore l'élément. Sans `op`, `gt` (strictement supérieur). */
265
+ export interface QChartThreshold {
266
+ value: number
267
+ op?: "gt" | "gte" | "lt" | "lte" | "eq" | "neq"
268
+ /** Token dnax.ui ou couleur CSS */
269
+ color: string
270
+ }
271
+
272
+ /** Plage de performance d'une `gauge` (zone colorée de l'arc) */
273
+ export interface QChartGaugeBand {
274
+ /** Borne haute — en valeur d'axe, ou en **fraction** `0..1` avec `fraction: true` */
275
+ to: number
276
+ color: string
277
+ fraction?: boolean
278
+ }
279
+
280
+ /** Ligne de totaux d'une marque `table` */
281
+ export interface QChartTableTotals {
282
+ /** Libellé de la première colonne (« Total ») */
283
+ label?: string
110
284
  }
111
285
 
112
286
  /** A single mark: `{ type: 'line', data, x, y, … }` */
@@ -144,8 +318,16 @@ export interface QChartMark {
144
318
  /** `pie`: starting angle in degrees — `90` (the default) starts at 12 o'clock, angles growing counter-clockwise */
145
319
  startAngle?: number
146
320
  /** `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. */
321
+ * `heatmap`: `true` prints the value inside each cell.
322
+ * `bar` / `line` / `area`: `true` or `value` prints the value, `name` the category, `percent`
323
+ * the share of the category — `labelFormat` formate le nombre. */
148
324
  labels?: boolean | "name" | "value" | "percent" | "name-value" | "name-percent"
325
+ /** Position des étiquettes de données (`top`, `bottom`, `inside`, `left`, `right`…) */
326
+ labelPosition?: string
327
+ /** Format des étiquettes de données (axes, barres, lignes, aires) */
328
+ labelFormat?: QChartFormat
329
+ /** Seuils de couleur par point (bar, dot, area) — la première règle qui matche gagne */
330
+ thresholds?: QChartThreshold[]
149
331
  /** `dot`: point radius — a constant, or a channel for a bubble chart */
150
332
  r?: number | QChartChannel
151
333
  /** `dot`: point shape — `circle` (default), `rect`, `triangle`, `diamond`, `pin`, `none`… */
@@ -192,8 +374,64 @@ export interface QChartMark {
192
374
  name?: string
193
375
  /** Grouping: one series (and one legend entry) per value of the channel */
194
376
  z?: QChartChannel
195
- /** Stack the series */
196
- stack?: boolean
377
+ /** Stack the series — `true` empile, `"percent"` empile **à 100 %** (valeurs normalisées
378
+ * par catégorie), une autre chaîne nomme le groupe d'empilement. */
379
+ stack?: boolean | "percent" | string
380
+ /** Axe des ordonnées visé — `left` (défaut) ou `right` (axe secondaire, cf. `yRight`) */
381
+ yAxis?: "left" | "right"
382
+ /** `bar` **flottante** : canal de la base par point (`y0`) — waterfall, budget/réalisé, bullet */
383
+ base?: QChartChannel
384
+ /** `bar` : couleur de la base (défaut : transparente) */
385
+ baseFill?: string
386
+ /** `line` / `area` : relie les points manquants au lieu de couper le trait */
387
+ connectNulls?: boolean
388
+ /** `line` / `area` : courbe lissée (`true`) ou tension numérique */
389
+ smooth?: boolean | number
390
+ /** `dot` : ajoute une **ligne de tendance** (régression linéaire) */
391
+ trend?: boolean
392
+ /** `rule` / `band` : libellé du repère et sa position (`start`, `middle`, `end`, `insideTop`…) */
393
+ label?: string
394
+ /** `band` : bornes basses / hautes verticales (axe Y) */
395
+ y0?: QChartChannel
396
+ y1?: QChartChannel
397
+ /** `band` : bornes pour une bande **verticale** (axe X) */
398
+ x0?: QChartChannel
399
+ x1?: QChartChannel
400
+ /** `histogram` : nombre de tranches (défaut : règle de la racine carrée) */
401
+ bins?: number
402
+ /** `histogram` : domaine forcé `[min, max]` */
403
+ domain?: [number, number]
404
+ /** `sankey` / `treemap` / `sunburst` : canal de la valeur (poids, surface) */
405
+ value?: QChartChannel
406
+ /** `treemap` / `sunburst` : canal du **parent** (hiérarchie) */
407
+ parent?: QChartChannel
408
+ /** `boxplot` : canal de la **liste de valeurs** brutes (calcul des cinq nombres) */
409
+ values?: QChartChannel
410
+ /** `boxplot` : canal des **statistiques précalculées** (`[min, q1, médiane, q3, max]`) */
411
+ stats?: QChartChannel
412
+ /** `gauge` / `radar` : borne basse / haute de l'échelle */
413
+ min?: number
414
+ max?: number
415
+ /** `gauge` : valeur de la **cible** (aiguille secondaire annotée) */
416
+ target?: number | QChartChannel
417
+ /** `gauge` : plages de performance (arc coloré) */
418
+ bands?: QChartGaugeBand[]
419
+ /** `gauge` / `funnel` : suffixe d'unité affiché dans le détail (`%`, `F CFA`…) */
420
+ unit?: string
421
+ /** `waterfall` : couleur des deltas négatifs (défaut : token `negative`) */
422
+ negativeFill?: QChartChannel
423
+ /** `waterfall` : canal qui marque les lignes de **total** (`true` → barre depuis zéro) */
424
+ isTotal?: QChartChannel
425
+ /** `table` : tri initial (champ, sens) — interactif au clic sur l'en-tête */
426
+ sortBy?: string
427
+ sortDesc?: boolean
428
+ /** `table` : pagination (`pageSize` lignes par page, `page` 0-based) */
429
+ pageSize?: number
430
+ page?: number
431
+ /** `table` : ligne de **totaux** (agrégats des colonnes, `summary`) */
432
+ totals?: boolean | QChartTableTotals
433
+ /** Info-bulle **par marque** : reçoit les paramètres du point et la valeur brute */
434
+ tooltip?: (params: any, value: any) => string
197
435
  }
198
436
 
199
437
  /** Options accepted by **each** mark (keys of `QChartMark`).
@@ -206,10 +444,95 @@ export interface QChartMark {
206
444
  * `orientation` (`rule` reference lines only carry positions and a stroke, and `pie`
207
445
  * slices are one per row: no `z`, `stack` or `orientation`). */
208
446
  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"],
447
+ line: [
448
+ "data",
449
+ "link",
450
+ "x",
451
+ "y",
452
+ "stroke",
453
+ "strokeWidth",
454
+ "opacity",
455
+ "title",
456
+ "name",
457
+ "z",
458
+ "stack",
459
+ "orientation",
460
+ "yAxis",
461
+ "connectNulls",
462
+ "smooth",
463
+ "labels",
464
+ "labelPosition",
465
+ "labelFormat",
466
+ "thresholds",
467
+ "tooltip",
468
+ ],
469
+ area: [
470
+ "data",
471
+ "link",
472
+ "x",
473
+ "y",
474
+ "fill",
475
+ "stroke",
476
+ "strokeWidth",
477
+ "opacity",
478
+ "title",
479
+ "name",
480
+ "z",
481
+ "stack",
482
+ "orientation",
483
+ "yAxis",
484
+ "connectNulls",
485
+ "smooth",
486
+ "labels",
487
+ "labelPosition",
488
+ "labelFormat",
489
+ "thresholds",
490
+ "tooltip",
491
+ ],
492
+ bar: [
493
+ "data",
494
+ "link",
495
+ "x",
496
+ "y",
497
+ "fill",
498
+ "stroke",
499
+ "strokeWidth",
500
+ "opacity",
501
+ "title",
502
+ "name",
503
+ "z",
504
+ "stack",
505
+ "orientation",
506
+ "yAxis",
507
+ "base",
508
+ "baseFill",
509
+ "labels",
510
+ "labelPosition",
511
+ "labelFormat",
512
+ "thresholds",
513
+ "tooltip",
514
+ ],
515
+ dot: [
516
+ "data",
517
+ "link",
518
+ "x",
519
+ "y",
520
+ "fill",
521
+ "stroke",
522
+ "strokeWidth",
523
+ "opacity",
524
+ "r",
525
+ "symbol",
526
+ "trend",
527
+ "thresholds",
528
+ "title",
529
+ "name",
530
+ "z",
531
+ "stack",
532
+ "orientation",
533
+ "yAxis",
534
+ "tooltip",
535
+ ],
213
536
  image: [
214
537
  "data",
215
538
  "link",
@@ -258,10 +581,140 @@ export const MARK_OPTIONS = {
258
581
  "stack",
259
582
  "orientation",
260
583
  ],
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"],
584
+ rule: ["data", "link", "x", "y", "stroke", "fill", "strokeWidth", "label", "labelPosition"],
585
+ band: ["data", "link", "x", "y", "x0", "x1", "y0", "y1", "fill", "stroke", "opacity", "label", "labelPosition"],
586
+ pie: [
587
+ "data",
588
+ "link",
589
+ "x",
590
+ "y",
591
+ "fill",
592
+ "radius",
593
+ "innerRadius",
594
+ "startAngle",
595
+ "labels",
596
+ "labelPosition",
597
+ "opacity",
598
+ "title",
599
+ "name",
600
+ ],
601
+ heatmap: ["data", "link", "x", "y", "fill", "labels", "labelFormat", "opacity", "title", "name"],
602
+ gauge: [
603
+ "data",
604
+ "link",
605
+ "x",
606
+ "y",
607
+ "min",
608
+ "max",
609
+ "target",
610
+ "bands",
611
+ "unit",
612
+ "fill",
613
+ "opacity",
614
+ "title",
615
+ "name",
616
+ "labelFormat",
617
+ ],
618
+ funnel: [
619
+ "data",
620
+ "link",
621
+ "x",
622
+ "y",
623
+ "fill",
624
+ "opacity",
625
+ "labels",
626
+ "labelPosition",
627
+ "labelFormat",
628
+ "title",
629
+ "name",
630
+ ],
631
+ radar: [
632
+ "data",
633
+ "link",
634
+ "x",
635
+ "y",
636
+ "z",
637
+ "fill",
638
+ "stroke",
639
+ "strokeWidth",
640
+ "opacity",
641
+ "min",
642
+ "max",
643
+ "labels",
644
+ "title",
645
+ "name",
646
+ ],
647
+ treemap: ["data", "link", "x", "parent", "value", "fill", "opacity", "labels", "labelFormat", "title", "name"],
648
+ sunburst: [
649
+ "data",
650
+ "link",
651
+ "x",
652
+ "parent",
653
+ "value",
654
+ "fill",
655
+ "radius",
656
+ "opacity",
657
+ "labels",
658
+ "labelFormat",
659
+ "title",
660
+ "name",
661
+ ],
662
+ sankey: ["data", "link", "x", "y", "value", "fill", "opacity", "title", "name"],
663
+ boxplot: [
664
+ "data",
665
+ "link",
666
+ "x",
667
+ "y",
668
+ "values",
669
+ "stats",
670
+ "fill",
671
+ "stroke",
672
+ "opacity",
673
+ "title",
674
+ "name",
675
+ "yAxis",
676
+ ],
677
+ histogram: [
678
+ "data",
679
+ "link",
680
+ "x",
681
+ "bins",
682
+ "domain",
683
+ "fill",
684
+ "opacity",
685
+ "labels",
686
+ "labelPosition",
687
+ "labelFormat",
688
+ "title",
689
+ "name",
690
+ ],
691
+ waterfall: [
692
+ "data",
693
+ "link",
694
+ "x",
695
+ "y",
696
+ "fill",
697
+ "negativeFill",
698
+ "isTotal",
699
+ "labels",
700
+ "labelPosition",
701
+ "labelFormat",
702
+ "opacity",
703
+ "title",
704
+ "name",
705
+ "yAxis",
706
+ ],
707
+ table: [
708
+ "data",
709
+ "link",
710
+ "columns",
711
+ "separator",
712
+ "sortBy",
713
+ "sortDesc",
714
+ "pageSize",
715
+ "page",
716
+ "totals",
717
+ ],
265
718
  } as const satisfies Record<QChartMarkType, readonly (keyof QChartMark)[]>
266
719
 
267
720
  /** Configuration d'axe */
@@ -275,6 +728,34 @@ export interface QChartAxis {
275
728
  max?: number | string
276
729
  /** Afficher les lignes de grille (défaut : oui) */
277
730
  grid?: boolean
731
+ /** Format des **graduations** (`compact`, `percent`, `currency`, `date`… ou fonction) */
732
+ format?: QChartFormat
733
+ /** Locale des graduations (défaut : celle du graphique) */
734
+ locale?: string
735
+ /** Axe **temporel** : granularité des libellés (`auto` par défaut) */
736
+ timeUnit?: "auto" | "day" | "week" | "month" | "quarter" | "year"
737
+ /** Axe temporel : fuseau horaire IANA (`Africa/Dakar`, `Europe/Paris`…) */
738
+ timeZone?: string
739
+ /** Rotation des libellés, en degrés (ex. `-35` pour gagner de la place) */
740
+ labelRotate?: number
741
+ /** Nombre de graduations souhaité (indicatif) */
742
+ splitNumber?: number
743
+ /** Masquer tout l'axe */
744
+ show?: boolean
745
+ /** Interligne ou intervalle des libellés : `auto` (défaut) ou un nombre (`0` = tous) */
746
+ interval?: number | "auto"
747
+ }
748
+
749
+ /** Info-bulle personnalisable */
750
+ export interface QChartTooltip {
751
+ /** Formateur **global** : reçoit les paramètres ECharts, renvoie du HTML / du texte */
752
+ formatter?: (params: any) => string
753
+ /** Formate **chaque valeur** affichée (axe comme point) */
754
+ valueFormatter?: (value: any, params?: any) => string
755
+ /** Trier les séries de l'info-bulle d'axe par valeur décroissante */
756
+ sortDesc?: boolean
757
+ /** Garder le `valueFormatter` aussi dans le **nom de la donnée** (`name`) */
758
+ nameFormatter?: (name: any, params?: any) => string
278
759
  }
279
760
 
280
761
  /** Élément désigné dans un graphique (clic, survol) — émis par `<q-chart>` sur `@pick`.
@@ -319,6 +800,7 @@ export function legendPickOf(
319
800
  }
320
801
  for (const markIndex of order) {
321
802
  const mark = marks![markIndex]
803
+ if (!mark) continue
322
804
  const rows = mark.data ?? []
323
805
  for (let dataIndex = 0; dataIndex < rows.length; dataIndex++) {
324
806
  const row = rows[dataIndex]
@@ -457,6 +939,12 @@ export interface QChartConfig {
457
939
  marks: QChartMark[]
458
940
  x?: QChartAxis
459
941
  y?: QChartAxis
942
+ /** Axe des ordonnées **secondaire**, à droite (combo volume + taux) */
943
+ yRight?: QChartAxis
944
+ /** Locale de l'axe et des formats par défaut (`fr-FR`) */
945
+ locale?: string
946
+ /** Mode **compact** : marges réduites, graduations discrètes, légende masquée par défaut */
947
+ compact?: boolean
460
948
  /** Palette : tokens dnax.ui (`primary`, `secondary`…) ou couleurs CSS */
461
949
  colors?: string[]
462
950
  /** Titre du graphique */
@@ -464,8 +952,8 @@ export interface QChartConfig {
464
952
  /** Légende : `false` pour la masquer, `true` pour la forcer (défaut : dès qu'une série
465
953
  * est nommée) ou un objet `{ position, offset, align }` */
466
954
  legend?: boolean | QChartLegend
467
- /** Info-bulle (défaut : oui) */
468
- tooltip?: boolean
955
+ /** Info-bulle : `false` pour l'éteindre, ou `{ formatter, valueFormatter, sortDesc }` */
956
+ tooltip?: boolean | QChartTooltip
469
957
  /** Ce que deviennent les lignes **non liées** par `link` : `filter` (défaut) les retire, `dim`
470
958
  * les **estompe** (`dimOpacity`) et laisse la place à la sélection. */
471
959
  linkMode?: QChartLinkMode
@@ -639,6 +1127,60 @@ export function chartToECharts(config: QChartConfig): Record<string, any> {
639
1127
  grid: resolve(config.theme?.grid),
640
1128
  }
641
1129
 
1130
+ /** Locale des nombres / dates (les formats y retombent quand ils n'en donnent pas) */
1131
+ const locale = config.locale ?? DEFAULT_LOCALE
1132
+ /** Mode mini-widget : marges réduites, axes et légende effacés */
1133
+ const compact = config.compact === true
1134
+ /** Format de valeur pour une info-bulle / une étiquette */
1135
+ const formatText = (value: any, format?: QChartFormat) => formatValue(value, format, { locale })
1136
+
1137
+ /** Familles posées sur les axes (les autres sont hors axes : pie, gauge, funnel…) */
1138
+ const CARTESIAN = new Set<QChartMarkType>([
1139
+ "line",
1140
+ "area",
1141
+ "bar",
1142
+ "dot",
1143
+ "text",
1144
+ "image",
1145
+ "heatmap",
1146
+ "boxplot",
1147
+ "histogram",
1148
+ "waterfall",
1149
+ ])
1150
+ const isCartesianMark = (m: QChartMark) => CARTESIAN.has(m.type)
1151
+
1152
+ /** Groupe d'empilement d'une marque (`true` / `percent` → « total », une chaîne la nomme) */
1153
+ const stackGroupOf = (m: QChartMark): string | undefined => {
1154
+ if (!m.stack) return undefined
1155
+ if (m.stack === true || m.stack === "percent") return "total"
1156
+ return String(m.stack)
1157
+ }
1158
+ /** Groupes qui doivent être normalisés à 100 % */
1159
+ const percentGroups = new Set<string>()
1160
+ for (const m of marks) if (m.stack === "percent") percentGroups.add(stackGroupOf(m) ?? "total")
1161
+
1162
+ /** Seuil de couleur applicable à une valeur */
1163
+ const thresholdOf = (value: any, thresholds?: QChartThreshold[]): string | undefined => {
1164
+ if (!thresholds?.length || typeof value !== "number" || !Number.isFinite(value)) return undefined
1165
+ for (const rule of thresholds) {
1166
+ const op = rule.op ?? "gt"
1167
+ const hit =
1168
+ op === "gt"
1169
+ ? value > rule.value
1170
+ : op === "gte"
1171
+ ? value >= rule.value
1172
+ : op === "lt"
1173
+ ? value < rule.value
1174
+ : op === "lte"
1175
+ ? value <= rule.value
1176
+ : op === "eq"
1177
+ ? value === rule.value
1178
+ : value !== rule.value
1179
+ if (hit) return resolve(rule.color)
1180
+ }
1181
+ return undefined
1182
+ }
1183
+
642
1184
  /** Lignes d'une marque, **jointes** à la sélection partagée (`link` + `selection`).
643
1185
  * Point d'application unique : toutes les familles de marques en héritent. */
644
1186
  const dataOf = (m: QChartMark) => {
@@ -650,7 +1192,7 @@ export function chartToECharts(config: QChartConfig): Record<string, any> {
650
1192
 
651
1193
  // Orientation : donnée par la première marque de série. Toutes les marques
652
1194
  // 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]
1195
+ const first = marks.find(isCartesianMark) ?? marks[0]
654
1196
  const horizontal = !!first && isHorizontal(first)
655
1197
 
656
1198
  const abscissaOf = (m: QChartMark) => m.x ?? m.y
@@ -674,15 +1216,36 @@ export function chartToECharts(config: QChartConfig): Record<string, any> {
674
1216
  return channelValue(channel, data, i)
675
1217
  }
676
1218
 
1219
+ // ─── Histogrammes : les tranches sont calculées **avant** les catégories (leurs libellés
1220
+ // deviennent l'axe) ───
1221
+ const binsOf = new Map<QChartMark, ReturnType<typeof histogram>>()
1222
+ for (const m of marks) {
1223
+ if (m.type !== "histogram") continue
1224
+ const data = dataOf(m)
1225
+ const raw = data.map((_, i) => channelValue(m.x, data, i))
1226
+ binsOf.set(m, histogram(raw, { bins: m.bins, domain: m.domain }))
1227
+ }
1228
+
677
1229
  // ─── Domaine des catégories (axe « band ») ───
678
1230
  const categories: string[] = []
679
1231
  const seen = new Set<string>()
680
- let numericX = true
1232
+ let numericX = !binsOf.size
681
1233
  let timeX = config.x?.type === "time"
682
1234
  for (const m of marks) {
683
- if (isRule(m) || m.type === "pie" || m.type === "table") continue
1235
+ if (!isCartesianMark(m)) continue
684
1236
  const data = dataOf(m)
685
1237
  const ch = abscissaOf(m)
1238
+ // Histogramme : une catégorie par tranche (borne basse → haute)
1239
+ const bins = binsOf.get(m)
1240
+ if (bins) {
1241
+ for (const bin of bins) {
1242
+ if (!seen.has(bin.label)) {
1243
+ seen.add(bin.label)
1244
+ categories.push(bin.label)
1245
+ }
1246
+ }
1247
+ continue
1248
+ }
686
1249
  for (let i = 0; i < data.length; i++) {
687
1250
  const pair = pairRowOf(m, i)
688
1251
  const v = pair ? pair[0] : channelValue(ch, data, i)
@@ -746,6 +1309,113 @@ export function chartToECharts(config: QChartConfig): Record<string, any> {
746
1309
  return horizontal ? [val, cat] : [cat, val]
747
1310
  }
748
1311
 
1312
+ /** Catégorie brute d'un point (même résolution que `pairOf`, sans l'orientation) */
1313
+ const categoryOf = (m: QChartMark, i: number): any => {
1314
+ const pair = pairRowOf(m, i)
1315
+ return pair ? pair[0] : solveX(abscissaOf(m), m, i)
1316
+ }
1317
+ /** Valeur brute d'un point (axe des ordonnées) */
1318
+ const ordinateValueOf = (m: QChartMark, i: number): any => {
1319
+ const pair = pairRowOf(m, i)
1320
+ return pair ? pair[1] : channelValue(ordinateOf(m), dataOf(m), i)
1321
+ }
1322
+
1323
+ // ─── Empilement 100 % : ECharts ne normalise pas, on calcule les totaux par catégorie ───
1324
+ const stackTotals = new Map<string, Map<string, number>>()
1325
+ for (const group of percentGroups) {
1326
+ const entries: { category: any; value: any }[] = []
1327
+ for (const m of marks) {
1328
+ if (stackGroupOf(m) !== group || !isCartesianMark(m)) continue
1329
+ const data = dataOf(m)
1330
+ for (let i = 0; i < data.length; i++) {
1331
+ entries.push({ category: categoryOf(m, i), value: ordinateValueOf(m, i) })
1332
+ }
1333
+ }
1334
+ stackTotals.set(group, categoryTotals(entries))
1335
+ }
1336
+
1337
+ /** Paire normalisée : dans un groupe `percent`, l'ordonnée devient une part (0→100) */
1338
+ const scaledPairOf = (m: QChartMark, i: number): [any, any] | null => {
1339
+ const pair = pairOf(m, i)
1340
+ if (!pair) return null
1341
+ const group = stackGroupOf(m)
1342
+ const totals = group && percentGroups.has(group) ? stackTotals.get(group) : undefined
1343
+ if (!totals) return pair
1344
+ const total = totals.get(String(categoryOf(m, i))) ?? 0
1345
+ const scale = (v: any) =>
1346
+ typeof v === "number" ? (total === 0 ? 0 : (v / total) * 100) : v
1347
+ return horizontal ? [scale(pair[0]), pair[1]] : [pair[0], scale(pair[1])]
1348
+ }
1349
+
1350
+ /** Total par catégorie **du graphique** — sert aux étiquettes `percent` hors empilement */
1351
+ let chartLabelTotals: Map<string, number> | undefined
1352
+ const labelTotalsOf = (m: QChartMark) => {
1353
+ if (chartLabelTotals) return chartLabelTotals
1354
+ const entries: { category: any; value: any }[] = []
1355
+ for (const mark of marks) {
1356
+ if (mark.type !== "bar" && mark.type !== "line" && mark.type !== "area" && mark.type !== "dot" && mark.type !== "waterfall") continue
1357
+ const data = dataOf(mark)
1358
+ for (let i = 0; i < data.length; i++) {
1359
+ entries.push({ category: categoryOf(mark, i), value: ordinateValueOf(mark, i) })
1360
+ }
1361
+ }
1362
+ chartLabelTotals = categoryTotals(entries)
1363
+ return chartLabelTotals
1364
+ }
1365
+
1366
+ /** Valeur d'ordonnée portée par un point ECharts (`{ value: [cat, val] }` ou scalaire) */
1367
+ const ordinateOfPoint = (p: any): any => {
1368
+ const v = p?.value ?? p?.data?.value ?? p?.data
1369
+ if (Array.isArray(v)) return horizontal ? v[0] : v[1]
1370
+ return v
1371
+ }
1372
+ /** Catégorie portée par un point ECharts */
1373
+ const categoryOfPoint = (p: any): any => {
1374
+ const v = p?.value ?? p?.data?.value ?? p?.data
1375
+ if (Array.isArray(v)) return horizontal ? v[1] : v[0]
1376
+ return p?.name ?? p?.axisValue
1377
+ }
1378
+
1379
+ /** Étiquettes de données (`labels`) d'une marque cartésienne */
1380
+ const dataLabelsOf = (m: QChartMark): Record<string, any> | undefined => {
1381
+ if (!m.labels) return undefined
1382
+ const position = m.labelPosition ?? "top"
1383
+ let formatter: any
1384
+ if (m.labels === "name") {
1385
+ formatter = "{b}"
1386
+ } else if (m.labels === "percent") {
1387
+ if (m.labelFormat) {
1388
+ formatter = (p: any) => {
1389
+ const group = stackGroupOf(m)
1390
+ const totals = group && percentGroups.has(group) ? stackTotals.get(group) : labelTotalsOf(m)
1391
+ const total = totals?.get(String(categoryOfPoint(p))) ?? 0
1392
+ const value = Number(ordinateOfPoint(p) ?? 0)
1393
+ const share = total === 0 ? 0 : (Math.abs(value) / Math.abs(total)) * 100
1394
+ return formatText(share, { ...(typeof m.labelFormat === "object" ? m.labelFormat : {}), style: "percent" })
1395
+ }
1396
+ } else if (stackGroupOf(m) && percentGroups.has(stackGroupOf(m)!)) {
1397
+ formatter = (p: any) => formatText(Number(ordinateOfPoint(p) ?? 0), "percent")
1398
+ } else {
1399
+ formatter = (p: any) => {
1400
+ const total = labelTotalsOf(m).get(String(categoryOfPoint(p))) ?? 0
1401
+ const value = Number(ordinateOfPoint(p) ?? 0)
1402
+ const share = total === 0 ? 0 : (Math.abs(value) / Math.abs(total)) * 100
1403
+ return formatText(share, "percent")
1404
+ }
1405
+ }
1406
+ } else if (m.labelFormat) {
1407
+ formatter = (p: any) => formatText(ordinateOfPoint(p), m.labelFormat)
1408
+ } else {
1409
+ formatter = "{c}"
1410
+ }
1411
+ return {
1412
+ show: true,
1413
+ position,
1414
+ formatter,
1415
+ color: theme.text,
1416
+ }
1417
+ }
1418
+
749
1419
  const series: Record<string, any>[] = []
750
1420
  let colorIndex = 0
751
1421
  const colorFor = (explicit?: any) => {
@@ -755,6 +1425,11 @@ export function chartToECharts(config: QChartConfig): Record<string, any> {
755
1425
 
756
1426
  const rulesY: any[] = []
757
1427
  const rulesX: any[] = []
1428
+ /** Zones de seuil (`band`) — `markArea` posé plus bas sur la première série */
1429
+ const bandsY: any[] = []
1430
+ const bandsX: any[] = []
1431
+ /** Configuration `radar` (indicateurs), une par marque `radar` */
1432
+ const radarOptions: any[] = []
758
1433
  /** Échelles de couleurs des cartes de chaleur (valeur numérique → palette) */
759
1434
  const visualMaps: any[] = []
760
1435
 
@@ -763,6 +1438,48 @@ export function chartToECharts(config: QChartConfig): Record<string, any> {
763
1438
  if (m.type === "table") continue
764
1439
  const data = dataOf(m)
765
1440
 
1441
+ // Bande de seuil / zone d'alerte : `markArea` — horizontale par défaut (`y0` → `y1`),
1442
+ // verticale avec `x0` / `x1`. Le libellé (`label`) est posé sur la zone.
1443
+ if (m.type === "band") {
1444
+ const color = resolve(colorValue(m.fill, data, 0) ?? m.fill)
1445
+ const style: Record<string, any> = {}
1446
+ if (typeof color === "string" && looksLikeColor(color)) style.color = color
1447
+ if (typeof m.stroke === "string") {
1448
+ const border = resolve(m.stroke)
1449
+ if (typeof border === "string" && looksLikeColor(border)) style.borderColor = border
1450
+ }
1451
+ if (typeof m.opacity === "number") style.opacity = m.opacity
1452
+ const labelCfg = m.label
1453
+ ? { show: true, formatter: m.label, position: m.labelPosition ?? "insideTop", color: theme.muted }
1454
+ : { show: false }
1455
+ const vertical = m.x0 !== undefined || m.x1 !== undefined
1456
+ const loChannel = vertical ? m.x0 : (m.y0 ?? m.y)
1457
+ const hiChannel = vertical ? m.x1 : (m.y1 ?? m.y)
1458
+ /** Une chaîne qui **n'est pas** un champ de la donnée est une constante (`x0: "Jan"`) */
1459
+ const boundValue = (channel: QChartChannel | undefined, i: number) => {
1460
+ if (typeof channel === "string") {
1461
+ const row = data[0]
1462
+ if (!isPlainObject(row) || !(channel in row)) return channel
1463
+ }
1464
+ return channelValue(channel, data, i)
1465
+ }
1466
+ const count = Math.max(
1467
+ data.length || 1,
1468
+ isArrayLike(loChannel) ? loChannel.length : 1,
1469
+ isArrayLike(hiChannel) ? hiChannel.length : 1,
1470
+ )
1471
+ for (let i = 0; i < count; i++) {
1472
+ const lo = boundValue(loChannel, i)
1473
+ const hi = boundValue(hiChannel, i)
1474
+ if (lo === undefined || hi === undefined) continue
1475
+ const area = vertical
1476
+ ? [{ xAxis: lo, itemStyle: { ...style }, label: { ...labelCfg } }, { xAxis: hi }]
1477
+ : [{ yAxis: lo, itemStyle: { ...style }, label: { ...labelCfg } }, { yAxis: hi }]
1478
+ ;(vertical ? bandsX : bandsY).push(area)
1479
+ }
1480
+ continue
1481
+ }
1482
+
766
1483
  // Camembert / anneau — famille **hors axes** (ni grid ni xAxis/yAxis) : une part par
767
1484
  // ligne, `x` = le libellé de la part, `y` = sa valeur. Sans `fill`, ECharts colorie
768
1485
  // les parts avec la palette (`color`), une part après l'autre.
@@ -791,6 +1508,20 @@ export function chartToECharts(config: QChartConfig): Record<string, any> {
791
1508
  if (m.title !== undefined) slice.tooltip = { formatter: String(channelValue(m.title, data, i)) }
792
1509
  return slice
793
1510
  })
1511
+ // Format personnalisé : le formateur reçoit le point ECharts (`value`, `percent`, `name`)
1512
+ const formatted = m.labelFormat
1513
+ ? (p: any) => {
1514
+ if (m.labels === "percent") {
1515
+ return formatText(p.percent, { style: "percent", ...(typeof m.labelFormat === "object" ? m.labelFormat : {}) })
1516
+ }
1517
+ const text = formatText(p.value, m.labelFormat)
1518
+ if (m.labels === "name-value") return `${p.name}: ${text}`
1519
+ if (m.labels === "name-percent") {
1520
+ return `${p.name} ${formatText(p.percent, { style: "percent" })}`
1521
+ }
1522
+ return m.labels === "value" ? text : p.name
1523
+ }
1524
+ : formatter
794
1525
  const outer = m.radius ?? "70%"
795
1526
  series.push({
796
1527
  type: "pie",
@@ -800,7 +1531,12 @@ export function chartToECharts(config: QChartConfig): Record<string, any> {
800
1531
  ...(typeof m.startAngle === "number" ? { startAngle: m.startAngle } : {}),
801
1532
  ...(m.labels === false
802
1533
  ? { label: { show: false }, labelLine: { show: false } }
803
- : { label: { formatter } }),
1534
+ : {
1535
+ label: {
1536
+ formatter: formatted,
1537
+ ...(m.labelPosition ? { position: m.labelPosition } : {}),
1538
+ },
1539
+ }),
804
1540
  data: slices,
805
1541
  })
806
1542
  continue
@@ -816,22 +1552,25 @@ export function chartToECharts(config: QChartConfig): Record<string, any> {
816
1552
  const columnIndex = new Map(categories.map((c, i) => [c, i]))
817
1553
  const rowIndex = new Map(rowCategories.map((c, i) => [c, i]))
818
1554
  const raw = data.map((_, i) => colorValue(m.fill, data, i))
819
- const numeric = raw.length > 0 && raw.every((v) => typeof v === "number")
1555
+ const numeric = raw.some((v) => typeof v === "number")
820
1556
  const uniform = numeric ? undefined : resolve(raw[0] ?? m.fill)
821
1557
  const cells: any[] = []
822
1558
  for (let i = 0; i < data.length; i++) {
823
1559
  const column = columnIndex.get(String(solveX(m.x, m, i)))
824
1560
  const row = rowIndex.get(String(channelValue(m.y, data, i)))
825
1561
  if (column === undefined || row === undefined) continue
1562
+ // Valeur manquante en mode numérique : la case reste **vide** (et transparente)
1563
+ const missing = numeric && (typeof raw[i] !== "number" || !Number.isFinite(raw[i]))
826
1564
  const cell: Record<string, any> = {
827
1565
  // Nom de la cellule → l'info-bulle ECharts par défaut (« ligne · colonne »)
828
1566
  name: `${rowCategories[row]} · ${categories[column]}`,
829
- value: [column, row, numeric ? raw[i] : 1],
1567
+ value: [column, row, numeric ? (missing ? NaN : raw[i]) : 1],
830
1568
  }
1569
+ if (missing) cell.itemStyle = { opacity: 0 }
831
1570
  if (m.title !== undefined) cell.tooltip = { formatter: String(channelValue(m.title, data, i)) }
832
1571
  cells.push(cell)
833
1572
  }
834
- const values = numeric ? (raw as number[]) : [0, 1]
1573
+ const values = numeric ? (raw.filter((v) => typeof v === "number") as number[]) : [0, 1]
835
1574
  // Couleur constante → échelle dégénérée `[c, c]` (sinon la palette sert de rampe)
836
1575
  const flat = typeof uniform === "string" && looksLikeColor(uniform) ? uniform : (palette[0] ?? "#1976d2")
837
1576
  visualMaps.push({
@@ -839,6 +1578,7 @@ export function chartToECharts(config: QChartConfig): Record<string, any> {
839
1578
  max: Math.max(...values),
840
1579
  inRange: { color: numeric ? palette : [flat, flat] },
841
1580
  show: numeric, // une couleur constante n'a pas d'échelle à montrer
1581
+ seriesIndex: series.length, // n'affecte pas les autres séries (ex. un `dot` coloré)
842
1582
  orient: "horizontal",
843
1583
  left: 0,
844
1584
  bottom: 0,
@@ -854,7 +1594,15 @@ export function chartToECharts(config: QChartConfig): Record<string, any> {
854
1594
  data: cells,
855
1595
  ...(cellOpacity !== undefined ? { itemStyle: { opacity: cellOpacity } } : {}),
856
1596
  ...(m.labels === true || m.labels === "value"
857
- ? { label: { show: true, formatter: "{@[2]}", color: theme.text } }
1597
+ ? {
1598
+ label: {
1599
+ show: true,
1600
+ formatter: m.labelFormat
1601
+ ? (p: any) => formatText(p.value?.[2], m.labelFormat)
1602
+ : "{@[2]}",
1603
+ color: theme.text,
1604
+ },
1605
+ }
858
1606
  : {}),
859
1607
  })
860
1608
  continue
@@ -879,10 +1627,346 @@ export function chartToECharts(config: QChartConfig): Record<string, any> {
879
1627
  if (typeof stroke === "string" && looksLikeColor(stroke)) lineStyle.color = stroke
880
1628
  if (typeof m.strokeWidth === "number") lineStyle.width = m.strokeWidth
881
1629
  const list = vertical ? rulesX : rulesY
1630
+ const labelCfg = m.label
1631
+ ? {
1632
+ show: true,
1633
+ formatter: m.label,
1634
+ position: m.labelPosition ?? "end",
1635
+ color: theme.muted,
1636
+ }
1637
+ : undefined
882
1638
  for (const v of values) {
883
1639
  if (v === undefined || v === null) continue
884
- list.push({ [vertical ? "xAxis" : "yAxis"]: v, lineStyle: { ...lineStyle } })
1640
+ list.push({
1641
+ [vertical ? "xAxis" : "yAxis"]: v,
1642
+ lineStyle: { ...lineStyle },
1643
+ ...(labelCfg ? { label: { ...labelCfg } } : {}),
1644
+ })
1645
+ }
1646
+ continue
1647
+ }
1648
+
1649
+ // ─── Jauge : valeur, plages de performance (`bands`) et cible annotée ───
1650
+ if (m.type === "gauge") {
1651
+ const value = Number(ordinateValueOf(m, 0) ?? 0)
1652
+ const min = m.min ?? 0
1653
+ const max = m.max ?? 100
1654
+ const span = max - min || 1
1655
+ const label = channelValue(m.x, data, 0) ?? m.name ?? ""
1656
+ const target = m.target !== undefined ? Number(channelValue(m.target, data, 0)) : undefined
1657
+ const fmt = (v: any) => {
1658
+ const text = formatText(v, m.labelFormat)
1659
+ return m.unit ? `${text} ${m.unit}` : text
1660
+ }
1661
+ // Plages : `fraction` (0→1) ou valeurs d'axe, converties en fractions pour ECharts
1662
+ const bands = (m.bands ?? [])
1663
+ .map((band) => {
1664
+ const to = band.fraction ? band.to : (band.to - min) / span
1665
+ return [Math.max(0, Math.min(1, to)), resolve(band.color)] as [number, string]
1666
+ })
1667
+ .sort((a, b) => a[0] - b[0])
1668
+ const axisColors = bands.length ? bands : ([[1, colorFor(m.fill)]] as [number, string][])
1669
+ if (axisColors[0]![0] !== 0) axisColors.unshift([0, axisColors[0]![1]])
1670
+ series.push({
1671
+ type: "gauge",
1672
+ name: m.name,
1673
+ z: series.length,
1674
+ min,
1675
+ max,
1676
+ radius: m.radius ?? "75%",
1677
+ startAngle: 210,
1678
+ endAngle: -30,
1679
+ progress: { show: true, width: 12, itemStyle: { color: colorFor(m.fill) } },
1680
+ axisLine: { lineStyle: { width: 12, color: axisColors } },
1681
+ axisTick: { show: false },
1682
+ splitLine: { show: false },
1683
+ axisLabel: { show: true, color: theme.muted, distance: 14, formatter: (v: number) => formatText(v, m.labelFormat) },
1684
+ pointer: { show: true, width: 4, itemStyle: { color: theme.text } },
1685
+ anchor: { show: true, size: 8, itemStyle: { color: theme.text } },
1686
+ // La cible s'affiche sous la valeur : une coordonnée polaire n'accueille pas de `markLine`
1687
+ detail: {
1688
+ valueAnimation: true,
1689
+ color: theme.text,
1690
+ fontSize: 20,
1691
+ lineHeight: 22,
1692
+ offsetCenter: [0, target !== undefined ? "46%" : "60%"],
1693
+ formatter: (v: number) => (target !== undefined ? `${fmt(v)}\nCible ${fmt(target)}` : fmt(v)),
1694
+ },
1695
+ title: { show: !!label, offsetCenter: [0, target !== undefined ? "88%" : "82%"], color: theme.muted, fontSize: 11 },
1696
+ data: [{ name: String(label), value }],
1697
+ ...(typeof m.opacity === "number" ? { itemStyle: { opacity: m.opacity } } : {}),
1698
+ })
1699
+ continue
1700
+ }
1701
+
1702
+ // ─── Entonnoir : une part par ligne ───
1703
+ if (m.type === "funnel") {
1704
+ const slices = data.map((_, i) => {
1705
+ const name = String(channelValue(m.x, data, i) ?? i)
1706
+ const value = Number(channelValue(m.y, data, i) ?? 0)
1707
+ const c = resolve(colorValue(m.fill, data, i))
1708
+ return {
1709
+ name,
1710
+ value,
1711
+ ...(typeof c === "string" && looksLikeColor(c) ? { itemStyle: { color: c } } : {}),
1712
+ }
1713
+ })
1714
+ series.push({
1715
+ type: "funnel",
1716
+ name: m.name,
1717
+ z: series.length,
1718
+ top: 12,
1719
+ bottom: 12,
1720
+ left: "10%",
1721
+ right: "10%",
1722
+ minSize: "20%",
1723
+ gap: 2,
1724
+ data: slices,
1725
+ ...(m.labels === false
1726
+ ? { label: { show: false } }
1727
+ : {
1728
+ label: {
1729
+ show: true,
1730
+ position: m.labelPosition ?? "inside",
1731
+ color: theme.text,
1732
+ formatter: (p: any) =>
1733
+ m.labelFormat ? `${p.name}: ${formatText(p.value, m.labelFormat)}` : `${p.name}: ${p.value}`,
1734
+ },
1735
+ }),
1736
+ ...(typeof m.opacity === "number" ? { itemStyle: { opacity: m.opacity } } : {}),
1737
+ })
1738
+ continue
1739
+ }
1740
+
1741
+ // ─── Radar : `x` = l'indicateur, `y` = la valeur, `z` = la série ───
1742
+ if (m.type === "radar") {
1743
+ const indicators: string[] = []
1744
+ const seenIndicator = new Set<string>()
1745
+ const byGroup = new Map<string, Map<string, number>>()
1746
+ for (let i = 0; i < data.length; i++) {
1747
+ const indicator = String(channelValue(m.x, data, i) ?? i)
1748
+ if (!seenIndicator.has(indicator)) {
1749
+ seenIndicator.add(indicator)
1750
+ indicators.push(indicator)
1751
+ }
1752
+ const key = String(channelValue(m.z, data, i) ?? m.name ?? "")
1753
+ if (!byGroup.has(key)) byGroup.set(key, new Map())
1754
+ byGroup.get(key)!.set(indicator, Number(ordinateValueOf(m, i)) || 0)
1755
+ }
1756
+ const flat = [...byGroup.values()].flatMap((vals) => [...vals.values()])
1757
+ const radarMax = m.max ?? Math.max(1, ...flat) * 1.2
1758
+ radarOptions.push({
1759
+ indicator: indicators.map((name) => ({ name, max: radarMax, min: m.min ?? 0 })),
1760
+ axisLabel: { color: theme.muted },
1761
+ axisLine: { lineStyle: { color: theme.grid } },
1762
+ splitLine: { lineStyle: { color: theme.grid } },
1763
+ splitArea: { show: false },
1764
+ })
1765
+ const radarIndex = radarOptions.length - 1
1766
+ const entries = [...byGroup.entries()].map(([key, vals], index) => {
1767
+ const c = resolve(palette[index % palette.length])
1768
+ return {
1769
+ name: key || String(m.name ?? ""),
1770
+ value: indicators.map((indicator) => vals.get(indicator) ?? 0),
1771
+ lineStyle: { color: c, width: typeof m.strokeWidth === "number" ? m.strokeWidth : 2 },
1772
+ itemStyle: { color: c },
1773
+ areaStyle: { color: c, opacity: typeof m.opacity === "number" ? m.opacity : 0.18 },
1774
+ }
1775
+ })
1776
+ series.push({ type: "radar", name: m.name, z: series.length, radarIndex, symbolSize: 4, data: entries })
1777
+ continue
1778
+ }
1779
+
1780
+ // ─── Treemap / sunburst : hiérarchie par `parent` ───
1781
+ if (m.type === "treemap" || m.type === "sunburst") {
1782
+ const nameOf = (i: number) => String(channelValue(m.x, data, i) ?? i)
1783
+ const nodes = new Map<string, any>()
1784
+ const roots: any[] = []
1785
+ for (let i = 0; i < data.length; i++) {
1786
+ const node: Record<string, any> = { name: nameOf(i) }
1787
+ const value = m.value !== undefined ? channelValue(m.value, data, i) : m.y !== undefined ? channelValue(m.y, data, i) : undefined
1788
+ if (value !== undefined) node.value = Number(value)
1789
+ const c = resolve(colorValue(m.fill, data, i))
1790
+ if (typeof c === "string" && looksLikeColor(c)) node.itemStyle = { color: c }
1791
+ nodes.set(node.name, node)
1792
+ }
1793
+ for (let i = 0; i < data.length; i++) {
1794
+ const node = nodes.get(nameOf(i))!
1795
+ const parentName = m.parent !== undefined ? channelValue(m.parent, data, i) : undefined
1796
+ const parent = parentName !== undefined && parentName !== null ? nodes.get(String(parentName)) : undefined
1797
+ if (parent && parent !== node) (parent.children ??= []).push(node)
1798
+ else roots.push(node)
1799
+ }
1800
+ // Un nœud **interne** tient sa surface de ses enfants : sa valeur explicite (souvent 0)
1801
+ // fausserait le calcul, on la retire.
1802
+ for (const node of nodes.values()) {
1803
+ if (node.children?.length && !node.value) delete node.value
1804
+ }
1805
+ series.push({
1806
+ type: m.type,
1807
+ name: m.name,
1808
+ z: series.length,
1809
+ data: roots,
1810
+ ...(m.type === "sunburst" ? { radius: m.radius ?? ["15%", "90%"] } : {}),
1811
+ ...(m.type === "treemap" ? { roam: false, nodeClick: false, breadcrumb: { show: false } } : {}),
1812
+ ...(m.labels === false
1813
+ ? { label: { show: false } }
1814
+ : m.labelFormat
1815
+ ? { label: { formatter: (p: any) => formatText(p.value, m.labelFormat) } }
1816
+ : {}),
1817
+ })
1818
+ continue
1819
+ }
1820
+
1821
+ // ─── Sankey : flux source → cible ───
1822
+ if (m.type === "sankey") {
1823
+ const links: any[] = []
1824
+ const names: string[] = []
1825
+ const seenNode = new Set<string>()
1826
+ for (let i = 0; i < data.length; i++) {
1827
+ const source = String(channelValue(m.x ?? "source", data, i) ?? "")
1828
+ const target = String(channelValue(m.y ?? "target", data, i) ?? "")
1829
+ const value = Number(channelValue(m.value ?? "value", data, i) ?? 0)
1830
+ links.push({ source, target, value })
1831
+ for (const name of [source, target]) {
1832
+ if (name && !seenNode.has(name)) {
1833
+ seenNode.add(name)
1834
+ names.push(name)
1835
+ }
1836
+ }
1837
+ }
1838
+ series.push({
1839
+ type: "sankey",
1840
+ name: m.name,
1841
+ z: series.length,
1842
+ data: names.map((name) => ({ name })),
1843
+ links,
1844
+ left: 8,
1845
+ right: 8,
1846
+ top: 12,
1847
+ bottom: 12,
1848
+ emphasis: { focus: "adjacency" },
1849
+ lineStyle: { color: "gradient", opacity: 0.35, curveness: 0.5 },
1850
+ label: { color: theme.text },
1851
+ ...(typeof m.opacity === "number" ? { itemStyle: { opacity: m.opacity } } : {}),
1852
+ })
1853
+ continue
1854
+ }
1855
+
1856
+ // ─── Boîte à moustaches : cinq nombres, bruts (`values`) ou précalculés (`stats`) ───
1857
+ if (m.type === "boxplot") {
1858
+ const boxes: any[] = []
1859
+ for (let i = 0; i < data.length; i++) {
1860
+ const category = categoryOf(m, i)
1861
+ if (category === undefined || category === null) continue
1862
+ let stats: any = m.stats !== undefined ? channelValue(m.stats, data, i) : undefined
1863
+ if (!stats && m.values !== undefined) stats = boxStats(channelValue(m.values, data, i) ?? [])
1864
+ if (!stats) continue
1865
+ const five = Array.isArray(stats) ? stats : [stats.min, stats.q1, stats.median, stats.q3, stats.max]
1866
+ if (five.some((v: any) => typeof v !== "number" || !Number.isFinite(v))) continue
1867
+ boxes.push({ name: String(category), value: five })
885
1868
  }
1869
+ const border = colorFor(m.stroke ?? m.fill)
1870
+ series.push({
1871
+ type: "boxplot",
1872
+ name: m.name,
1873
+ z: series.length,
1874
+ data: boxes,
1875
+ boxWidth: [7, 40],
1876
+ itemStyle: {
1877
+ color: "transparent",
1878
+ borderColor: border,
1879
+ borderWidth: typeof m.strokeWidth === "number" ? m.strokeWidth : 2,
1880
+ ...(typeof m.opacity === "number" ? { opacity: m.opacity } : {}),
1881
+ },
1882
+ })
1883
+ continue
1884
+ }
1885
+
1886
+ // ─── Histogramme : tranches calculées en amont (`binsOf`) ───
1887
+ if (m.type === "histogram") {
1888
+ const bins = binsOf.get(m) ?? []
1889
+ series.push({
1890
+ type: "bar",
1891
+ name: m.name,
1892
+ z: series.length,
1893
+ barCategoryGap: "2%",
1894
+ data: bins.map((bin) => ({ name: bin.label, value: bin.count })),
1895
+ itemStyle: {
1896
+ color: colorFor(m.fill),
1897
+ borderRadius: [2, 2, 0, 0],
1898
+ ...(typeof m.opacity === "number" ? { opacity: m.opacity } : {}),
1899
+ },
1900
+ ...(dataLabelsOf(m) ? { label: dataLabelsOf(m) } : {}),
1901
+ })
1902
+ continue
1903
+ }
1904
+
1905
+ // ─── Cascade : barres flottantes cumulées + connecteurs ───
1906
+ if (m.type === "waterfall") {
1907
+ const values = data.map((_, i) => ordinateValueOf(m, i))
1908
+ const flags = data.map((_, i) => (m.isTotal !== undefined ? channelValue(m.isTotal, data, i) : false))
1909
+ const steps = waterfallSteps(values, flags)
1910
+ const positive = resolve(colorValue(m.fill, data, 0) ?? m.fill)
1911
+ const negative = resolve(colorValue(m.negativeFill, data, 0) ?? "negative")
1912
+ const totalColor = palette[colorIndex++ % palette.length]
1913
+ const items = steps.map((step, i) => {
1914
+ const color = step.totalRow
1915
+ ? totalColor
1916
+ : step.delta < 0
1917
+ ? negative
1918
+ : typeof positive === "string" && looksLikeColor(positive)
1919
+ ? positive
1920
+ : palette[0]
1921
+ return {
1922
+ name: String(categoryOf(m, i) ?? i),
1923
+ value: [Math.min(step.base, step.total), Math.max(step.base, step.total)],
1924
+ itemStyle: { color, ...(typeof m.opacity === "number" ? { opacity: m.opacity } : {}) },
1925
+ }
1926
+ })
1927
+ const connectors = steps
1928
+ .slice(0, -1)
1929
+ .map((step, i) => {
1930
+ const from = categories[i]
1931
+ const to = categories[i + 1]
1932
+ if (from === undefined || to === undefined) return null
1933
+ return [
1934
+ { coord: [from, step.total] },
1935
+ { coord: [to, step.total] },
1936
+ ]
1937
+ })
1938
+ .filter((pair): pair is { coord: (string | number)[] }[] => pair !== null)
1939
+ const labels = dataLabelsOf(m)
1940
+ series.push({
1941
+ type: "bar",
1942
+ name: m.name,
1943
+ z: series.length,
1944
+ barMaxWidth: 42,
1945
+ data: items,
1946
+ ...(labels
1947
+ ? {
1948
+ label: {
1949
+ ...labels,
1950
+ formatter: (p: any) => {
1951
+ const step = steps[p.dataIndex]
1952
+ if (!step) return ""
1953
+ return formatText(step.totalRow ? step.total : step.delta, m.labelFormat)
1954
+ },
1955
+ },
1956
+ }
1957
+ : {}),
1958
+ ...(connectors.length
1959
+ ? {
1960
+ markLine: {
1961
+ silent: true,
1962
+ symbol: "none",
1963
+ animation: false,
1964
+ data: connectors,
1965
+ lineStyle: { color: theme.muted, type: "dashed", width: 1 },
1966
+ },
1967
+ }
1968
+ : {}),
1969
+ })
886
1970
  continue
887
1971
  }
888
1972
 
@@ -905,7 +1989,16 @@ export function chartToECharts(config: QChartConfig): Record<string, any> {
905
1989
  for (const g of groups) {
906
1990
  const points = g.indexes
907
1991
  .map((i) => {
908
- const pair = pairOf(m, i)
1992
+ // `line` / `area` : un trou garde sa catégorie avec une ordonnée `null` — la ligne
1993
+ // se coupe (ou se relie avec `connectNulls`). Les autres marques sautent le point.
1994
+ if (m.type === "line" || m.type === "area") {
1995
+ const scaled = scaledPairOf(m, i)
1996
+ if (scaled) return { value: scaled, index: i }
1997
+ const cat = categoryOf(m, i)
1998
+ if (cat === undefined || cat === null) return null
1999
+ return { value: (horizontal ? [null, cat] : [cat, null]) as [any, any], index: i }
2000
+ }
2001
+ const pair = scaledPairOf(m, i)
909
2002
  return pair ? { value: pair, index: i } : null
910
2003
  })
911
2004
  .filter((p): p is { value: [any, any]; index: number } => p !== null)
@@ -949,12 +2042,18 @@ export function chartToECharts(config: QChartConfig): Record<string, any> {
949
2042
  typeof m.src === "string" && /^([./]|[a-z][a-z0-9+.-]*:)/i.test(m.src) ? m.src : undefined
950
2043
 
951
2044
  const name = g.key ?? m.name
2045
+ const stackGroup = stackGroupOf(m)
952
2046
 
953
2047
  const common = {
954
2048
  name,
955
2049
  z: series.length,
956
- ...(m.stack ? { stack: "total" } : {}),
2050
+ ...(stackGroup ? { stack: stackGroup } : {}),
2051
+ ...(m.yAxis === "right" && !horizontal ? { yAxisIndex: 1 } : {}),
957
2052
  ...(theme.muted ? { markLineColor: theme.muted } : {}),
2053
+ // Info-bulle **par marque** : reçoit les paramètres du point et la valeur brute
2054
+ ...(m.tooltip
2055
+ ? { tooltip: { formatter: (p: any) => m.tooltip!(p, ordinateOfPoint(p)) } }
2056
+ : {}),
958
2057
  }
959
2058
 
960
2059
  /** Valeur au point `p` si elle diffère du défaut donné (sinon `undefined`) */
@@ -1010,26 +2109,80 @@ export function chartToECharts(config: QChartConfig): Record<string, any> {
1010
2109
  const t = m.title !== undefined ? channelValue(m.title, data, p.index) : undefined
1011
2110
  if (t !== undefined) item.tooltip = { formatter: String(t) }
1012
2111
  }
2112
+ // Seuils déclaratifs : ils colorent le point (pas les images ni le texte)
2113
+ if (m.type !== "image" && m.type !== "text") {
2114
+ const threshold = thresholdOf(p.value[horizontal ? 0 : 1], m.thresholds)
2115
+ if (threshold) item.itemStyle = { ...(item.itemStyle ?? {}), color: threshold }
2116
+ }
1013
2117
  return item
1014
2118
  }
1015
2119
 
1016
2120
  const opacity = typeof m.opacity === "number" ? m.opacity : undefined
1017
2121
 
1018
2122
  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
- })
2123
+ const labels = dataLabelsOf(m)
2124
+ if (m.base !== undefined) {
2125
+ // Barre **flottante** : ECharts n'accepte pas `[cat, [y0, y1]]` — on empile une base
2126
+ // invisible et le delta visible dans **le même groupe** d'empilement.
2127
+ const stackName = `${stackGroup ?? "bar"}__base_${series.length}`
2128
+ const at = horizontal ? 0 : 1
2129
+ const baseColorFill = resolve(m.baseFill) ?? "transparent"
2130
+ const bases = points.map((p) => {
2131
+ const raw = Number(channelValue(m.base, data, p.index))
2132
+ const base = Number.isFinite(raw) ? raw : 0
2133
+ return horizontal ? [base, p.value[1]] : [p.value[0], base]
2134
+ })
2135
+ series.push({
2136
+ type: "bar",
2137
+ z: series.length,
2138
+ stack: stackName,
2139
+ silent: true,
2140
+ legendHoverLink: false,
2141
+ tooltip: { show: false },
2142
+ emphasis: { disabled: true },
2143
+ barMaxWidth: 42,
2144
+ data: bases.map((value) => ({ value, itemStyle: { color: baseColorFill } })),
2145
+ itemStyle: { color: baseColorFill },
2146
+ // Marqueur interne : la base ne participe ni à la légende, ni à l'estompage
2147
+ __base: true,
2148
+ })
2149
+ const tops = points.map((p, k) => {
2150
+ const base = Number((bases[k] as any)[at])
2151
+ const value = Number(p.value[at])
2152
+ const delta = (Number.isFinite(value) ? value : 0) - (Number.isFinite(base) ? base : 0)
2153
+ const item = perPoint(p)
2154
+ item.value = horizontal ? [delta, p.value[1]] : [p.value[0], delta]
2155
+ return item
2156
+ })
2157
+ series.push({
2158
+ ...common,
2159
+ type: "bar",
2160
+ z: series.length,
2161
+ stack: stackName,
2162
+ data: tops,
2163
+ itemStyle: { color: baseColor, borderRadius: [3, 3, 0, 0], ...(opacity !== undefined ? { opacity } : {}) },
2164
+ barMaxWidth: 42,
2165
+ ...(labels ? { label: labels } : {}),
2166
+ })
2167
+ } else {
2168
+ series.push({
2169
+ ...common,
2170
+ type: "bar",
2171
+ data: points.map(perPoint),
2172
+ itemStyle: { color: baseColor, borderRadius: [3, 3, 0, 0], ...(opacity !== undefined ? { opacity } : {}) },
2173
+ barMaxWidth: 42,
2174
+ ...(labels ? { label: labels } : {}),
2175
+ })
2176
+ }
1026
2177
  } else if (m.type === "line" || m.type === "area") {
1027
2178
  const isArea = m.type === "area"
2179
+ const labels = dataLabelsOf(m)
1028
2180
  series.push({
1029
2181
  ...common,
1030
2182
  type: "line",
1031
2183
  data: points.map(perPoint),
1032
- smooth: false,
2184
+ smooth: m.smooth === true ? true : typeof m.smooth === "number" ? m.smooth : false,
2185
+ ...(m.connectNulls ? { connectNulls: true } : {}),
1033
2186
  symbol: "circle",
1034
2187
  symbolSize: isArea ? 0 : 5,
1035
2188
  lineStyle: {
@@ -1038,16 +2191,38 @@ export function chartToECharts(config: QChartConfig): Record<string, any> {
1038
2191
  ...(opacity !== undefined ? { opacity } : {}),
1039
2192
  },
1040
2193
  ...(isArea ? { areaStyle: { color: baseColor, opacity: 0.18 } } : {}),
2194
+ ...(labels ? { label: labels } : {}),
1041
2195
  })
1042
2196
  } else if (m.type === "dot") {
2197
+ // `fill` numérique → **échelle de couleurs** (visualMap dédié à cette série).
2198
+ // La valeur de couleur voyage en **3ᵉ dimension** du point (`[x, y, v]`), sinon
2199
+ // le moteur n'a rien à mapper.
2200
+ const fills = g.indexes.map((i) => colorValue(m.fill, data, i))
2201
+ const numericFill = fills.length > 0 && fills.every((v) => typeof v === "number")
2202
+ const dotData = points.map((p) => {
2203
+ const item = perPoint(p)
2204
+ if (numericFill) item.value = [...p.value, Number(colorValue(m.fill, data, p.index))]
2205
+ return item
2206
+ })
2207
+ if (numericFill) {
2208
+ const nums = fills as number[]
2209
+ visualMaps.push({
2210
+ show: false,
2211
+ seriesIndex: series.length,
2212
+ dimension: 2,
2213
+ min: Math.min(...nums),
2214
+ max: Math.max(...nums),
2215
+ inRange: { color: palette },
2216
+ })
2217
+ }
1043
2218
  series.push({
1044
2219
  ...common,
1045
2220
  type: "scatter",
1046
2221
  symbol: typeof m.symbol === "string" ? m.symbol : "circle",
1047
- data: points.map(perPoint),
2222
+ data: dotData,
1048
2223
  symbolSize: typeof radius === "number" ? radius * 2 + 2 : 9,
1049
2224
  itemStyle: {
1050
- color: baseColor,
2225
+ ...(numericFill ? {} : { color: baseColor }),
1051
2226
  ...(typeof outline === "string" && looksLikeColor(outline)
1052
2227
  ? {
1053
2228
  borderColor: outline,
@@ -1145,6 +2320,54 @@ export function chartToECharts(config: QChartConfig): Record<string, any> {
1145
2320
  }
1146
2321
  }
1147
2322
 
2323
+ // Ligne de tendance des nuages de points (`trend: true`) — régression linéaire, deux points
2324
+ for (const m of marks) {
2325
+ if (m.type !== "dot" || !m.trend) continue
2326
+ const data = dataOf(m)
2327
+ const pts: [number, number][] = []
2328
+ for (let i = 0; i < data.length; i++) {
2329
+ const cat = categoryOf(m, i)
2330
+ const val = ordinateValueOf(m, i)
2331
+ if (
2332
+ typeof cat === "number" &&
2333
+ typeof val === "number" &&
2334
+ Number.isFinite(cat) &&
2335
+ Number.isFinite(val)
2336
+ ) {
2337
+ pts.push([cat, val])
2338
+ }
2339
+ }
2340
+ const fit = linearFit(pts)
2341
+ if (!fit) continue
2342
+ const xs = pts.map(([x]) => x)
2343
+ const x0 = Math.min(...xs)
2344
+ const x1 = Math.max(...xs)
2345
+ const at = (x: number) => fit.slope * x + fit.intercept
2346
+ series.push({
2347
+ type: "line",
2348
+ z: series.length,
2349
+ silent: true,
2350
+ symbol: "none",
2351
+ legendHoverLink: false,
2352
+ tooltip: { show: false },
2353
+ lineStyle: { color: theme.muted, type: "dashed", width: 1.5 },
2354
+ data: horizontal
2355
+ ? [{ value: [at(x0), x0] }, { value: [at(x1), x1] }]
2356
+ : [{ value: [x0, at(x0)] }, { value: [x1, at(x1)] }],
2357
+ __trend: true,
2358
+ })
2359
+ }
2360
+
2361
+ // Zones de seuil (`band`) : `markArea` sur la première série cartésienne
2362
+ if (bandsY.length || bandsX.length) {
2363
+ if (!series.length) {
2364
+ series.push({ type: "line", data: [], silent: true, symbol: "none", lineStyle: { opacity: 0 } })
2365
+ }
2366
+ const host =
2367
+ series.find((s) => s.type === "bar" || s.type === "line" || s.type === "scatter") ?? series[0]!
2368
+ host.markArea = { silent: true, animation: false, data: [...bandsY, ...bandsX] }
2369
+ }
2370
+
1148
2371
  // Les règles sont posées sur la première série (ou une série vide hôte)
1149
2372
  if (rulesY.length || rulesX.length) {
1150
2373
  if (!series.length) {
@@ -1162,7 +2385,7 @@ export function chartToECharts(config: QChartConfig): Record<string, any> {
1162
2385
  // ─── Légende : position (haut par défaut), alignement, marge, place réservée ───
1163
2386
  // La légende et la zone de tracé ne doivent pas se toucher : la marge (`offset`) est
1164
2387
  // **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)
2388
+ const legendOn = config.legend === false ? false : config.legend === true || (!compact && series.some((s) => !!s.name))
1166
2389
  const legendConfig: QChartLegend = typeof config.legend === "object" && config.legend !== null ? config.legend : {}
1167
2390
  const legendPosition = legendConfig.position ?? "top"
1168
2391
  // `offset` par défaut : 24 px — une respiration **conséquente** entre la légende et la
@@ -1195,51 +2418,91 @@ export function chartToECharts(config: QChartConfig): Record<string, any> {
1195
2418
  ? { bottom: scaleBand, left: legendAlign }
1196
2419
  : { top: legendTop, left: legendAlign }),
1197
2420
  }
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
- }
2421
+ /** Axe des ordonnées **secondaire** (marques avec `yAxis: 'right'`) */
2422
+ const hasRightAxis = series.some((s) => s.yAxisIndex === 1)
2423
+ const gridBox = compact
2424
+ ? {
2425
+ left: 2,
2426
+ right: hasRightAxis ? 12 : 2,
2427
+ top: config.title ? 20 : 2,
2428
+ bottom: 2,
2429
+ containLabel: true,
2430
+ }
2431
+ : {
2432
+ left: 8 + (legendOn && legendPosition === "left" ? LEGEND_COLUMN + legendGap : 0),
2433
+ right:
2434
+ 16 +
2435
+ (legendOn && legendPosition === "right" ? LEGEND_COLUMN + legendGap : 0) +
2436
+ (hasRightAxis ? 12 : 0),
2437
+ // Légende → nom d'axe → zone de tracé, sans recouvrement
2438
+ top: (legendTopBand > 0 ? legendTopBand : config.title ? 44 : 16) + axisNameTop,
2439
+ bottom:
2440
+ 8 +
2441
+ axisNameBottom +
2442
+ scaleBand +
2443
+ (legendOn && legendPosition === "bottom" ? LEGEND_LINE + legendGap : 0),
2444
+ containLabel: true,
2445
+ }
1210
2446
 
1211
2447
  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
2448
  const axisLine = { lineStyle: { color: theme.grid } }
1216
2449
 
2450
+ /** Libellés d'axe : format demandé, granularité temporelle, rotation, mode compact */
2451
+ const axisLabelOptions = (axis: QChartAxis | undefined, temporal = false) => {
2452
+ const formatted =
2453
+ axisFormatter(axis?.format, { locale: axis?.locale ?? locale }) ??
2454
+ (temporal
2455
+ ? timeTickFormatter(axis?.timeUnit, axis?.locale ?? locale, axis?.timeZone)
2456
+ : undefined)
2457
+ const opt: Record<string, any> = { ...axisLabel, hideOverlap: true }
2458
+ if (formatted) opt.formatter = formatted
2459
+ if (typeof axis?.labelRotate === "number") opt.rotate = axis.labelRotate
2460
+ if (axis?.interval !== undefined && axis.interval !== "auto") opt.interval = axis.interval
2461
+ if (compact || axis?.show === false) opt.show = false
2462
+ return opt
2463
+ }
2464
+
1217
2465
  const hasHeatmap = series.some((s) => s.type === "heatmap")
1218
- const categoryAxis = (axis: QChartAxis | undefined, data: string[] = categories, heat = false) => ({
2466
+ const categoryAxis = (
2467
+ axis: QChartAxis | undefined,
2468
+ data: string[] = categories,
2469
+ heat = false,
2470
+ temporal = false,
2471
+ ) => ({
1219
2472
  type: "category",
2473
+ show: axis?.show !== false,
1220
2474
  data,
1221
- name: axis?.label,
2475
+ name: compact ? undefined : axis?.label,
1222
2476
  nameTextStyle: axisLabel,
1223
- axisLabel: { ...axisLabel, hideOverlap: true },
1224
- axisLine,
2477
+ axisLabel: axisLabelOptions(axis, temporal),
2478
+ axisLine: { ...axisLine, show: !compact && axis?.show !== false },
1225
2479
  axisTick: { show: false },
1226
2480
  splitLine: { show: false },
1227
2481
  // Carte de chaleur : quadrillage des cases
1228
2482
  ...(heat ? { splitArea: { show: true } } : {}),
1229
2483
  boundaryGap: hasHeatmap || series.some((s) => s.type === "bar"),
1230
2484
  })
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
- })
2485
+ const valueAxis = (axis: QChartAxis | undefined, zero: boolean, temporal = false) => {
2486
+ const label = axisLabelOptions(axis, temporal)
2487
+ if (!temporal) label.margin = typeof axis?.margin === "number" ? axis.margin : 16
2488
+ else if (typeof axis?.margin === "number") label.margin = axis.margin
2489
+ return {
2490
+ type: axis?.type === "log" ? "log" : temporal ? "time" : "value",
2491
+ show: axis?.show !== false,
2492
+ name: compact ? undefined : axis?.label,
2493
+ nameTextStyle: axisLabel,
2494
+ min: axis?.min ?? (zero ? 0 : undefined),
2495
+ max: axis?.max,
2496
+ ...(typeof axis?.splitNumber === "number" ? { splitNumber: axis.splitNumber } : {}),
2497
+ axisLabel: label,
2498
+ axisLine: { show: false },
2499
+ axisTick: { show: false },
2500
+ splitLine: {
2501
+ show: compact ? axis?.grid === true : axis?.grid ?? true,
2502
+ lineStyle: { color: theme.grid },
2503
+ },
2504
+ }
2505
+ }
1243
2506
 
1244
2507
  // ─── Nom d'axe : placement **toujours dans le cadre** ───
1245
2508
  // Axe **horizontal** : centré sous l'axe (`middle`). À la fin de l'axe (`end`, le défaut
@@ -1256,26 +2519,41 @@ export function chartToECharts(config: QChartConfig): Record<string, any> {
1256
2519
  // Orientation horizontale : catégories sur l'axe Y
1257
2520
  const hasBar = series.some((s) => s.type === "bar")
1258
2521
  const xAxisBase = hasHeatmap
1259
- ? categoryAxis(config.x, categories, true)
2522
+ ? categoryAxis(config.x, categories, true, axisType === "time")
1260
2523
  : horizontal
1261
- ? valueAxis(config.x, hasBar)
2524
+ ? valueAxis(config.x, hasBar, axisType === "time")
1262
2525
  : axisType === "category"
1263
2526
  ? categoryAxis(config.x)
1264
- : valueAxis(config.x, false)
2527
+ : valueAxis(config.x, false, axisType === "time")
1265
2528
  const yAxisBase = hasHeatmap
1266
2529
  ? categoryAxis(config.y, rowCategories, true)
1267
2530
  : horizontal
1268
2531
  ? axisType === "category"
1269
2532
  ? categoryAxis(config.y)
1270
- : valueAxis(config.y, false)
1271
- : valueAxis(config.y, hasBar)
2533
+ : valueAxis(config.y, false, config.y?.type === "time")
2534
+ : valueAxis(config.y, hasBar, config.y?.type === "time")
1272
2535
  const xAxis = config.x?.label ? { ...xAxisBase, ...axisNameX } : xAxisBase
1273
- const yAxis = config.y?.label ? { ...yAxisBase, ...axisNameY } : yAxisBase
2536
+ const yAxisLeft = config.y?.label ? { ...yAxisBase, ...axisNameY } : yAxisBase
2537
+ // Axe **secondaire** : mêmes règles, nom ancré à droite (sinon il sort du cadre)
2538
+ const yAxisRight = hasRightAxis
2539
+ ? {
2540
+ ...valueAxis(config.yRight, series.some((s) => s.yAxisIndex === 1 && s.type === "bar"), config.yRight?.type === "time"),
2541
+ ...(config.yRight?.label
2542
+ ? { nameLocation: "end" as const, nameTextStyle: { ...axisLabel, align: "right" as const } }
2543
+ : {}),
2544
+ }
2545
+ : undefined
2546
+ const yAxis = hasRightAxis ? [yAxisLeft, yAxisRight] : yAxisLeft
1274
2547
 
1275
- // Familles **hors axes** (`pie` seul) : ni grid ni xAxis/yAxis — sinon ECharts laisse
1276
- // un cadre fantôme et décale le titre.
2548
+ // Familles **hors axes** (`pie`, `gauge`, `radar`, `sankey`…) : ni grid ni xAxis/yAxis —
2549
+ // sinon ECharts laisse un cadre fantôme et décale le titre.
1277
2550
  const cartesian = series.some(
1278
- (s) => s.type === "bar" || s.type === "line" || s.type === "scatter" || s.type === "heatmap",
2551
+ (s) =>
2552
+ s.type === "bar" ||
2553
+ s.type === "line" ||
2554
+ s.type === "scatter" ||
2555
+ s.type === "heatmap" ||
2556
+ s.type === "boxplot",
1279
2557
  )
1280
2558
 
1281
2559
  // Un tooltip d'axe suppose des données **par catégorie** : vrai pour tout cartésien, sauf si
@@ -1287,6 +2565,40 @@ export function chartToECharts(config: QChartConfig): Record<string, any> {
1287
2565
  !series.some((s) => s.type === "heatmap") &&
1288
2566
  !marks.some((m) => m.title !== undefined || m.type === "text"))
1289
2567
 
2568
+ /** Info-bulle : `false`, `true`, ou configuration (formateurs, tri) */
2569
+ const tooltipConfig: QChartTooltip =
2570
+ typeof config.tooltip === "object" && config.tooltip !== null ? config.tooltip : {}
2571
+ /** Formateur d'axe par défaut enrichi : valeurs formatées, noms formatés, tri */
2572
+ const axisTooltipFormatter = (params: any) => {
2573
+ const list: any[] = Array.isArray(params) ? params : [params]
2574
+ const head = list[0]?.axisValueLabel ?? list[0]?.axisValue ?? ""
2575
+ const rows = tooltipConfig.sortDesc
2576
+ ? [...list].sort((a, b) => Number(ordinateOfPoint(b) ?? 0) - Number(ordinateOfPoint(a) ?? 0))
2577
+ : list
2578
+ const body = rows
2579
+ .map((p) => {
2580
+ const raw = ordinateOfPoint(p)
2581
+ const rawName = p.seriesName ?? p.name ?? ""
2582
+ const name = tooltipConfig.nameFormatter ? tooltipConfig.nameFormatter(rawName, p) : rawName
2583
+ const text = tooltipConfig.valueFormatter
2584
+ ? tooltipConfig.valueFormatter(raw, p)
2585
+ : raw === null || raw === undefined
2586
+ ? "—"
2587
+ : String(raw)
2588
+ return `${p.marker ?? ""}${name ? `${name}: ` : ""}${text}`
2589
+ })
2590
+ .join("<br/>")
2591
+ return `${head ? `<div style="margin-bottom:2px">${head}</div>` : ""}${body}`
2592
+ }
2593
+
2594
+ // `valueFormatter` par point : ECharts lit cette option **au niveau de la série** quand
2595
+ // l'info-bulle est en mode `item` (pas de formateur global).
2596
+ if (!axisTooltip && tooltipConfig.valueFormatter) {
2597
+ for (const one of series) {
2598
+ one.tooltip = { ...(one.tooltip ?? {}), valueFormatter: tooltipConfig.valueFormatter }
2599
+ }
2600
+ }
2601
+
1290
2602
  const option: Record<string, any> = {
1291
2603
  ...(config.title
1292
2604
  ? { title: { text: config.title, left: 0, textStyle: { fontSize: 13, fontWeight: 600, color: theme.text } } }
@@ -1298,6 +2610,7 @@ export function chartToECharts(config: QChartConfig): Record<string, any> {
1298
2610
  yAxis,
1299
2611
  }
1300
2612
  : {}),
2613
+ ...(radarOptions.length ? { radar: radarOptions.length === 1 ? radarOptions[0] : radarOptions } : {}),
1301
2614
  ...(visualMaps.length ? { visualMap: visualMaps } : {}),
1302
2615
  tooltip:
1303
2616
  config.tooltip === false
@@ -1312,6 +2625,11 @@ export function chartToECharts(config: QChartConfig): Record<string, any> {
1312
2625
  ? { axisPointer: { type: series.some((s) => s.type === "bar") ? "shadow" : "line" } }
1313
2626
  : {}),
1314
2627
  confine: true,
2628
+ ...(tooltipConfig.formatter
2629
+ ? { formatter: tooltipConfig.formatter }
2630
+ : axisTooltip && (tooltipConfig.valueFormatter || tooltipConfig.nameFormatter || tooltipConfig.sortDesc)
2631
+ ? { formatter: axisTooltipFormatter }
2632
+ : {}),
1315
2633
  ...tooltipStyle(theme),
1316
2634
  },
1317
2635
  legend: legendOption,
@@ -1330,6 +2648,8 @@ export function chartToECharts(config: QChartConfig): Record<string, any> {
1330
2648
  if ((config.linkMode ?? "filter") === "dim" && hasSelection) {
1331
2649
  const dim = typeof config.dimOpacity === "number" ? config.dimOpacity : 0.25
1332
2650
  for (const one of option.series ?? []) {
2651
+ // Les séries techniques (base d'une barre flottante, tendance) restent intactes
2652
+ if (one.__base || one.__trend) continue
1333
2653
  one.selectedMode = "single"
1334
2654
  // Seule l'opacité change : on ne touche ni au rayon (celui des barres vient du
1335
2655
  // traducteur : `borderRadius: [3, 3, 0, 0]`) ni au reste du style de la marque.