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