@dnax/ui 0.0.67 → 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/components/QChart.vue +329 -19
- package/components/QSpreadsheet.vue +35 -17
- package/index.ts +11 -1
- package/lib/chart.axis.test.ts +1 -1
- package/lib/chart.bi.test.ts +839 -0
- package/lib/chart.modules.test.ts +232 -0
- package/lib/chart.test.ts +13 -4
- package/lib/chart.ts +1400 -80
- package/lib/chartFormat.ts +222 -0
- package/lib/chartStats.ts +178 -0
- package/lib/history.test.ts +78 -0
- package/lib/history.ts +72 -0
- package/package.json +1 -1
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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: [
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
|
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(
|
|
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 =
|
|
1232
|
+
let numericX = !binsOf.size
|
|
681
1233
|
let timeX = config.x?.type === "time"
|
|
682
1234
|
for (const m of marks) {
|
|
683
|
-
if (
|
|
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
|
-
: {
|
|
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.
|
|
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
|
-
? {
|
|
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({
|
|
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
|
-
|
|
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
|
-
...(
|
|
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
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
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:
|
|
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
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
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 = (
|
|
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:
|
|
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
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
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
|
|
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`
|
|
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) =>
|
|
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.
|