@dnax/ui 0.0.43 → 0.0.45

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/chart.ts ADDED
@@ -0,0 +1,1340 @@
1
+ // Marks de graphique → options du moteur de rendu.
2
+ //
3
+ // Une marque est un **objet littéral** : `{ type: "line", data, x, y, stroke }`.
4
+ // Les canaux acceptent 3 formes :
5
+ //
6
+ // x: "month" → nom de champ dans chaque ligne
7
+ // x: ["Jan", "Feb"] → valeurs (ou constante si un seul élément)
8
+ // x: (d, i) => d.month → fonction d'accès
9
+ //
10
+ // Marques : `line`, `area`, `bar`, `dot`, `text`, `image`, `rule`, `pie`, `heatmap`.
11
+ // { type: "bar", orientation: "horizontal" } → barres horizontales (axes inversés)
12
+ // { type: "rule", y: [0] } → ligne de repère horizontale (défaut)
13
+ // { type: "rule", x: [0] } → ligne de repère verticale
14
+ // { type: "text", x, y, text, dx, dy } → un libellé par ligne (Plot.text)
15
+ // { type: "image", x, y, src, width, height } → une image par ligne (scatter d'images)
16
+ // { type: "pie", x, y, innerRadius } → camembert / anneau (hors axes)
17
+ // { type: "heatmap", x, y, fill: "temp" } → carte de chaleur (2 axes catégories)
18
+ //
19
+ // Canaux : x, y, fill, stroke, strokeWidth, opacity, r, symbol, title, text, src, width,
20
+ // height, rotate, fontSize, dx, dy, name, z (une série + une entrée de légende par valeur)
21
+ // et stack.
22
+ //
23
+ // `text` (Plot.text) : `textAnchor` (start | middle | end), `lineAnchor` (top | middle |
24
+ // bottom), `dx`/`dy`, `fontSize`, `fontWeight`, `fontFamily`, `fontStyle`, `lineHeight`,
25
+ // `lineWidth` (ems → retour à la ligne), `textOverflow`, `fill` (couleur du texte) et
26
+ // `stroke` + `strokeWidth` (halo autour du texte).
27
+ //
28
+ // Les options **de chaque marque** sont listées dans `MARK_OPTIONS` — source de vérité,
29
+ // analysée au build par la doc (`docd/scripts/mark-parse.ts`) pour documenter l'API de
30
+ // chaque marque.
31
+
32
+ /** Valeur d'un canal : nom de champ, valeurs explicites ou fonction d'accès */
33
+ export type QChartChannel = string | readonly any[] | ((d: any, i: number) => any) | number
34
+
35
+ /** Familles de marques */
36
+ export type QChartMarkType =
37
+ | "line"
38
+ | "area"
39
+ | "bar"
40
+ | "dot"
41
+ | "text"
42
+ | "image"
43
+ | "rule"
44
+ | "pie"
45
+ | "heatmap"
46
+ /** Données en **table** (HTML, pas de canvas) — mêmes données, même `link`, même sélection */
47
+ | "table"
48
+
49
+ /** Sens des axes : `horizontal` inverse les axes (catégories sur l'axe Y) */
50
+ export type QChartOrientation = "vertical" | "horizontal"
51
+
52
+ /** Colonne d'une marque `table`. Une colonne se déclare en toutes lettres (`{ field, label }`)
53
+ * ou par son seul champ (`'month'`). Sans `columns`, les colonnes sont déduites des clés des
54
+ * lignes, dans leur ordre d'apparition. */
55
+ export interface QChartColumn {
56
+ /** Champ lu dans chaque ligne */
57
+ field: string
58
+ /** En-tête affiché (défaut : le champ) */
59
+ label?: string
60
+ /** Alignement du contenu — `start` (défaut), `center`, `end` (nombres) */
61
+ align?: "start" | "center" | "end"
62
+ /** Formate la valeur affichée (reçoit la valeur et la ligne) */
63
+ format?: (value: any, row: any) => string
64
+ }
65
+
66
+ /** Séparateurs d'une marque `table` : `horizontal` (défaut — un trait sous chaque rangée),
67
+ * `vertical` (entre les colonnes), `grid` (les deux) ou `none`. */
68
+ export type QChartTableSeparator = "horizontal" | "vertical" | "grid" | "none"
69
+
70
+ /** Modèle prêt à rendre d'une marque `table` */
71
+ export interface QChartTable {
72
+ columns: QChartColumn[]
73
+ /** Lignes **jointes** à la sélection (même règle que les autres marques) */
74
+ rows: any[]
75
+ /** Séparateurs demandés par la marque (`separator`, défaut `horizontal`) */
76
+ separator: QChartTableSeparator
77
+ }
78
+
79
+ /** 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).
81
+ *
82
+ * `mode` a le même sens que sur les graphiques : `filter` ne garde que les lignes liées, `dim`
83
+ * les **garde toutes** (c'est au rendu d'estomper les autres, comme pour une série). */
84
+ export function tableModel(
85
+ mark: QChartMark | undefined,
86
+ selection?: any,
87
+ mode: QChartLinkMode = "filter",
88
+ ): QChartTable {
89
+ if (!mark) return { columns: [], rows: [], separator: "horizontal" }
90
+ const separator = mark.separator ?? "horizontal"
91
+ const all = mark.data ?? []
92
+ const rows = mode === "dim" ? all : linkedRows(all, chartLink(mark.link), selection)
93
+ const declared = mark.columns ?? []
94
+ const columns: QChartColumn[] = declared.map((column) =>
95
+ typeof column === "string" ? { field: column, label: column } : { ...column, label: column.label ?? column.field },
96
+ )
97
+ if (columns.length === 0) {
98
+ const seen = new Set<string>()
99
+ for (const row of rows) {
100
+ if (row === null || typeof row !== "object") continue
101
+ for (const key of Object.keys(row)) {
102
+ if (!seen.has(key)) {
103
+ seen.add(key)
104
+ columns.push({ field: key, label: key })
105
+ }
106
+ }
107
+ }
108
+ }
109
+ return { columns, rows, separator }
110
+ }
111
+
112
+ /** A single mark: `{ type: 'line', data, x, y, … }` */
113
+ export interface QChartMark {
114
+ /** Mark family (`line`, `area`, `bar`, `dot`, `image`, `text`, `rule`) */
115
+ type: QChartMarkType
116
+ /** Data: rows (objects) or plain values */
117
+ data?: any[]
118
+ /** **Jointure** de cette marque à la sélection partagée : `link: 'month'` (champ local) ou
119
+ * `link: { localField, foreignField }`. Sans sélection, la marque affiche tout. */
120
+ link?: string | QChartLink
121
+ /** Separator lines of a `table` mark's cells: `horizontal` (default — one line under each
122
+ * row), `vertical` (between columns), `grid` (both) or `none`. */
123
+ separator?: QChartTableSeparator
124
+ /** Marque `table` : les colonnes à afficher (défaut : déduites des clés des lignes) */
125
+ columns?: (string | QChartColumn)[]
126
+ /** Axis direction (default: `vertical`) */
127
+ orientation?: QChartOrientation
128
+ /** Position on the X axis */
129
+ x?: QChartChannel
130
+ /** Position on the Y axis */
131
+ y?: QChartChannel
132
+ /** Fill: a dnax.ui token (`primary`, `chart-1`…) or any CSS color, or a channel */
133
+ fill?: QChartChannel
134
+ /** Stroke: a dnax.ui token or any CSS color, or a channel */
135
+ stroke?: QChartChannel
136
+ /** Stroke width — the line (`line`, `area`) or the outline / halo (`dot`, `text`). A channel is allowed. */
137
+ strokeWidth?: number | QChartChannel
138
+ /** Opacity of the mark (0 → 1) */
139
+ opacity?: number | QChartChannel
140
+ /** `pie`: outer radius — a number (pixels) or a CSS length (`70%` by default) */
141
+ radius?: number | string
142
+ /** `pie`: inner radius (number or CSS length). Given, the pie becomes a **donut**. */
143
+ innerRadius?: number | string
144
+ /** `pie`: starting angle in degrees — `90` (the default) starts at 12 o'clock, angles growing counter-clockwise */
145
+ startAngle?: number
146
+ /** `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. */
148
+ labels?: boolean | "name" | "value" | "percent" | "name-value" | "name-percent"
149
+ /** `dot`: point radius — a constant, or a channel for a bubble chart */
150
+ r?: number | QChartChannel
151
+ /** `dot`: point shape — `circle` (default), `rect`, `triangle`, `diamond`, `pin`, `none`… */
152
+ symbol?: string
153
+ /** `image`: URL of each image — a constant when it starts with `.`, `/` or a protocol, otherwise a channel (a field of each row). Required. */
154
+ src?: QChartChannel
155
+ /** `image`: `true` paints the image inside a **circle** (round marker) — serve the image at
156
+ * the marker size (the pattern is anchored at the top-left, a bigger source is cropped).
157
+ * `stroke` + `strokeWidth` then draw a ring around it. */
158
+ round?: boolean
159
+ /** `image`: width in pixels (default 16, or `2 * r`) */
160
+ width?: number | QChartChannel
161
+ /** `image`: height in pixels (default 16, or `2 * r`) */
162
+ height?: number | QChartChannel
163
+ /** Rotation in degrees, **clockwise** (like Plot) — the sign is flipped for you, because the underlying label rotation turns the other way */
164
+ rotate?: number | QChartChannel
165
+ /** `text`: font size in pixels (default 11) — constant or channel */
166
+ fontSize?: number | QChartChannel
167
+ /** `text`: font weight (`bold`, `600`…) */
168
+ fontWeight?: string | number
169
+ /** `text`: font family (the host font by default) */
170
+ fontFamily?: string
171
+ /** `text`: font style (`italic`, `oblique`) */
172
+ fontStyle?: string
173
+ /** `text`: line height in pixels, once the label has several lines */
174
+ lineHeight?: number
175
+ /** `text`: width in **ems** — beyond it the text wraps (Plot's `lineWidth`) */
176
+ lineWidth?: number
177
+ /** `text`: lines wider than `lineWidth` — `ellipsis` truncates with an ellipsis, `clip` cuts hard. Plot's `-start` / `-middle` variants fall back to the end. */
178
+ textOverflow?: "ellipsis" | "ellipsis-end" | "clip" | "clip-end"
179
+ /** `text`: horizontal anchor — `start` (text runs to the right of the point), `middle` (centred, default), `end` (text runs to the left) */
180
+ textAnchor?: "start" | "middle" | "center" | "end"
181
+ /** `text`: vertical anchor — `top` (text hangs below the point), `middle` (centred, default), `bottom` (text sits above the point) */
182
+ lineAnchor?: "top" | "middle" | "bottom"
183
+ /** `text`: horizontal offset of the label, in pixels (Plot's `dx`) */
184
+ dx?: number | QChartChannel
185
+ /** `text`: vertical offset of the label, in pixels (Plot's `dy`) */
186
+ dy?: number | QChartChannel
187
+ /** Per-point tooltip */
188
+ title?: QChartChannel
189
+ /** `text`: the label — defaults to the row itself (primitive data), otherwise its index */
190
+ text?: QChartChannel
191
+ /** Series name (legend entry) */
192
+ name?: string
193
+ /** Grouping: one series (and one legend entry) per value of the channel */
194
+ z?: QChartChannel
195
+ /** Stack the series */
196
+ stack?: boolean
197
+ }
198
+
199
+ /** Options accepted by **each** mark (keys of `QChartMark`).
200
+ *
201
+ * Source of truth for the docs: `docd/scripts/mark-parse.ts` reads this literal at build
202
+ * time and prints the mark's own API at the end of every `/docs/charts/<mark>` page.
203
+ * Adding an option to a mark = adding its key here (otherwise it never shows up in the docs).
204
+ *
205
+ * Every series mark takes `data`, `x`, `y`, `opacity`, `title`, `name`, `z`, `stack` and
206
+ * `orientation` (`rule` reference lines only carry positions and a stroke, and `pie`
207
+ * slices are one per row: no `z`, `stack` or `orientation`). */
208
+ export const MARK_OPTIONS = {
209
+ line: ["data", "link", "x", "y", "stroke", "strokeWidth", "opacity", "title", "name", "z", "stack", "orientation"],
210
+ area: ["data", "link", "x", "y", "fill", "stroke", "strokeWidth", "opacity", "title", "name", "z", "stack", "orientation"],
211
+ bar: ["data", "link", "x", "y", "fill", "stroke", "strokeWidth", "opacity", "title", "name", "z", "stack", "orientation"],
212
+ dot: ["data", "link", "x", "y", "fill", "stroke", "strokeWidth", "opacity", "r", "symbol", "title", "name", "z", "stack", "orientation"],
213
+ image: [
214
+ "data",
215
+ "link",
216
+ "x",
217
+ "y",
218
+ "src",
219
+ "round",
220
+ "width",
221
+ "height",
222
+ "r",
223
+ "rotate",
224
+ "stroke",
225
+ "strokeWidth",
226
+ "opacity",
227
+ "title",
228
+ "name",
229
+ "z",
230
+ "stack",
231
+ "orientation",
232
+ ],
233
+ text: [
234
+ "data",
235
+ "link",
236
+ "x",
237
+ "y",
238
+ "text",
239
+ "fill",
240
+ "stroke",
241
+ "strokeWidth",
242
+ "opacity",
243
+ "fontSize",
244
+ "fontWeight",
245
+ "fontFamily",
246
+ "fontStyle",
247
+ "lineHeight",
248
+ "lineWidth",
249
+ "textOverflow",
250
+ "textAnchor",
251
+ "lineAnchor",
252
+ "dx",
253
+ "dy",
254
+ "rotate",
255
+ "title",
256
+ "name",
257
+ "z",
258
+ "stack",
259
+ "orientation",
260
+ ],
261
+ rule: ["data", "link", "x", "y", "stroke", "fill", "strokeWidth"],
262
+ pie: ["data", "link", "x", "y", "fill", "radius", "innerRadius", "startAngle", "labels", "opacity", "title", "name"],
263
+ heatmap: ["data", "link", "x", "y", "fill", "labels", "opacity", "title", "name"],
264
+ table: ["data", "link", "columns", "separator"],
265
+ } as const satisfies Record<QChartMarkType, readonly (keyof QChartMark)[]>
266
+
267
+ /** Configuration d'axe */
268
+ export interface QChartAxis {
269
+ /** Espace entre les nombres et l'axe, en pixels (défaut : 16 sur l'axe des ordonnées) */
270
+ margin?: number
271
+ /** `band` (catégories — défaut si l'axe contient du texte), `linear`, `time`, `log` */
272
+ type?: "band" | "linear" | "time" | "log"
273
+ label?: string
274
+ min?: number | string
275
+ max?: number | string
276
+ /** Afficher les lignes de grille (défaut : oui) */
277
+ grid?: boolean
278
+ }
279
+
280
+ /** Élément désigné dans un graphique (clic, survol) — émis par `<q-chart>` sur `@pick`.
281
+ * Le vocabulaire est celui des **marques** (`markName`, `markIndex`, `markType`) : jamais
282
+ * celui du moteur de rendu. */
283
+ export interface QChartPick {
284
+ /** Nom de la donnée : le `name` du point, ou la catégorie sur un axe `band` */
285
+ name?: string
286
+ /** Valeur du point (un nombre, ou `[x, y]` sur une marque cartésienne) */
287
+ value?: any
288
+ /** Nom de la **marque** cliquée (son `name` — l'entrée de légende) */
289
+ markName?: string
290
+ /** Index de la marque dans la propriété `marks` */
291
+ markIndex?: number
292
+ /** **Type** de la marque cliquée (`bar`, `line`, `pie`, `heatmap`…) */
293
+ markType?: string
294
+ /** Index de la donnée dans sa marque */
295
+ dataIndex?: number
296
+ /** D'où vient le clic : `mark` (un élément du graphique) ou `legend` (une entrée de légende) */
297
+ origin?: "mark" | "legend"
298
+ /** **Donnée brute** de l'élément (la ligne d'origine) — c'est elle qui alimente les
299
+ * jointures `link.foreignField` des autres graphiques. */
300
+ data?: any
301
+ }
302
+
303
+ /** Identité d'un élément par son **nom** dans les marques du graphique : la **marque**
304
+ * (index, `name`, `type`) et la **donnée** (index, ligne, valeur). Un clic de légende ne porte
305
+ * que le nom : cette fonction reconstruit le `QChartPick` complet, pour que `selected` ait la
306
+ * même forme quelle que soit l'origine du clic. */
307
+ export function legendPickOf(
308
+ marks: readonly QChartMark[] | undefined,
309
+ name: string,
310
+ /** Marque à essayer **en premier** — le clic transmet sa position ; la recherche est globale
311
+ * ensuite (deux marques peuvent partager un nom, comme `line` et `bar` sur les mois). */
312
+ markHint?: number,
313
+ ): QChartPick {
314
+ const pick: QChartPick = { name, origin: "legend" }
315
+ const order = [...(marks?.keys() ?? [])]
316
+ if (markHint !== undefined && order.includes(markHint)) {
317
+ order.splice(order.indexOf(markHint), 1)
318
+ order.unshift(markHint)
319
+ }
320
+ for (const markIndex of order) {
321
+ const mark = marks![markIndex]
322
+ const rows = mark.data ?? []
323
+ for (let dataIndex = 0; dataIndex < rows.length; dataIndex++) {
324
+ const row = rows[dataIndex]
325
+ if (row === null || typeof row !== "object") continue
326
+ const label = typeof mark.x === "string" ? (row as any)[mark.x] : undefined
327
+ const matched =
328
+ label !== undefined
329
+ ? String(label) === String(name)
330
+ : Object.values(row).some((v) => typeof v !== "object" && v !== null && String(v) === String(name))
331
+ if (!matched) continue
332
+ pick.markIndex = markIndex
333
+ pick.markName = mark.name
334
+ pick.markType = mark.type
335
+ pick.dataIndex = dataIndex
336
+ pick.data = row
337
+ const ordinate = typeof mark.y === "string" ? (row as any)[mark.y] : undefined
338
+ if (ordinate !== undefined) pick.value = ordinate
339
+ return pick
340
+ }
341
+ }
342
+ return pick
343
+ }
344
+
345
+ /** Deux sélections désignent-elles le **même élément** ? Le `name` d'abord — c'est la clé
346
+ * partagée entre graphiques (re-cliquer « Fév », même dans une autre marque, c'est la même
347
+ * sélection) ; à défaut la position (marque + donnée). Sert au **re-clic qui désélectionne**,
348
+ * pour les éléments comme pour la légende. */
349
+ export function sameSelection(current: QChartPick | null | undefined, candidate: QChartPick): boolean {
350
+ if (!current || !candidate) return false
351
+ if (current.name !== undefined && candidate.name !== undefined) {
352
+ return String(current.name) === String(candidate.name)
353
+ }
354
+ return (
355
+ current.markIndex !== undefined &&
356
+ current.markIndex === candidate.markIndex &&
357
+ current.dataIndex !== undefined &&
358
+ current.dataIndex === candidate.dataIndex
359
+ )
360
+ }
361
+
362
+ /** Sélection suivante après un clic de **légende** en mode `select` : le nom déjà sélectionné
363
+ * → `null` (l'utilisateur **désélectionne** : le filtre se relâche), un autre nom → ce nom
364
+ * (on bascule). Le composant rétablit au passage l'entrée de légende que le moteur a
365
+ * basculée, pour que la part / la série reste visible. */
366
+ export function nextLegendPick(
367
+ current: QChartPick | null | undefined,
368
+ name: string,
369
+ data?: any,
370
+ ) : QChartPick | null {
371
+ // Même règle que pour un clic sur un élément (cf. `sameSelection`) : re-cliquer le nom
372
+ // courant **désélectionne**.
373
+ if (current && current.name !== undefined && String(current.name) === String(name)) return null
374
+ return { name, origin: "legend", data }
375
+ }
376
+
377
+ /** Normalise l'événement de clic du moteur en `QChartPick` — les clés absentes sont omises,
378
+ * pour que le payload reste comparable et sérialisable. */
379
+ export function pickFromEvent(params: any): QChartPick {
380
+ const pick: QChartPick = {}
381
+ const put = (key: keyof QChartPick, value: any) => {
382
+ if (value !== undefined && value !== null) pick[key] = value
383
+ }
384
+ put("name", params?.name)
385
+ put("value", params?.value)
386
+ put("markName", params?.seriesName)
387
+ put("markIndex", params?.seriesIndex)
388
+ put("markType", params?.seriesType)
389
+ put("dataIndex", params?.dataIndex)
390
+ // `series` (le moteur) devient `mark` (notre vocabulaire)
391
+ put("origin", params?.componentType === "series" ? "mark" : params?.componentType === "legend" ? "legend" : undefined)
392
+ put("data", params?.data)
393
+ return pick
394
+ }
395
+
396
+ /** Jointure d'une marque à la **sélection partagée** (façon `localField`/`foreignField` de
397
+ * Mongo) : la marque ne garde que les lignes dont `localField` correspond à la clé de la
398
+ * sélection. Écrire `link: 'month'` est le raccourci de `link: { localField: 'month' }`. */
399
+ export interface QChartLink {
400
+ /** Le champ de **cette** marque qui porte la clé de jointure (côté local) */
401
+ localField: string
402
+ /** Le champ de la **source** de la sélection (côté étranger), résolu dans la donnée du clic
403
+ * (`pick.data[foreignField]`), puis dans le pick lui-même, puis dans son `name`.
404
+ * Défaut : `localField`. */
405
+ foreignField?: string
406
+ }
407
+
408
+ /** Normalise `link: 'month'` en `{ localField: 'month' }` */
409
+ export function chartLink(link?: string | QChartLink): QChartLink | undefined {
410
+ if (!link) return undefined
411
+ return typeof link === "string" ? { localField: link } : link
412
+ }
413
+
414
+ /** Clé de jointure d'une sélection : `pick.data[foreignField]`, sinon `pick[foreignField]`,
415
+ * sinon le `name` du pick ; un scalaire est sa propre clé. `undefined` = pas de sélection. */
416
+ export function linkValue(link: QChartLink, selection: any): any {
417
+ if (selection === null || selection === undefined) return undefined
418
+ const field = link.foreignField ?? link.localField
419
+ if (typeof selection !== "object") return selection
420
+ for (const candidate of [selection.data?.[field], selection[field], selection.name]) {
421
+ if (candidate !== undefined && candidate !== null) return candidate
422
+ }
423
+ return undefined
424
+ }
425
+
426
+ /** Lignes d'une marque **jointes** à la sélection : filtre sur `localField`. Deux garde-fous —
427
+ * sans sélection, ou quand aucune ligne ne porte le champ (jointure impossible), la marque
428
+ * garde toutes ses données : un graphique ne doit pas se vider sur une clé mal orthographiée. */
429
+ export function linkedRows(rows: any[], link: QChartLink | undefined, selection: any): any[] {
430
+ if (!link || selection === null || selection === undefined || rows.length === 0) return rows
431
+ if (!rows.some((row) => row !== null && typeof row === "object" && link.localField in row)) return rows
432
+ const key = linkValue(link, selection)
433
+ if (key === undefined || key === null) return rows
434
+ return rows.filter((row) => String(row?.[link.localField]) === String(key))
435
+ }
436
+
437
+ /** Réaction d'une marque liée à une sélection : `filter` retire les lignes non liées, `dim`
438
+ * les estompe (opacité réduite) et garde tout le contexte visible. */
439
+ export type QChartLinkMode = "filter" | "dim"
440
+
441
+ /** Placement de la légende (position autour de la zone de tracé, marge, alignement) */
442
+ export interface QChartLegend {
443
+ /** Côté où se trouve la légende — `top` (défaut), `bottom`, `left`, `right` */
444
+ position?: "top" | "bottom" | "left" | "right"
445
+ /** Marge entre la légende et la zone de tracé, en pixels (défaut 12) */
446
+ offset?: number
447
+ /** Alignement le long du bord — `start` (défaut), `center`, `end` */
448
+ align?: "start" | "center" | "end"
449
+ /** Un clic sur un élément de légende : `toggle` (défaut — masque la série) ou
450
+ * `select` (la légende sert de **sélecteur** : le clic émet `@pick` et l'état des séries
451
+ * est préservé, pour filtrer les autres graphiques). */
452
+ action?: "toggle" | "select"
453
+ }
454
+
455
+ /** Entrée de `chartToECharts()` — ce que `<q-chart>` construit à partir de ses props */
456
+ export interface QChartConfig {
457
+ marks: QChartMark[]
458
+ x?: QChartAxis
459
+ y?: QChartAxis
460
+ /** Palette : tokens dnax.ui (`primary`, `secondary`…) ou couleurs CSS */
461
+ colors?: string[]
462
+ /** Titre du graphique */
463
+ title?: string
464
+ /** Légende : `false` pour la masquer, `true` pour la forcer (défaut : dès qu'une série
465
+ * est nommée) ou un objet `{ position, offset, align }` */
466
+ legend?: boolean | QChartLegend
467
+ /** Info-bulle (défaut : oui) */
468
+ tooltip?: boolean
469
+ /** Ce que deviennent les lignes **non liées** par `link` : `filter` (défaut) les retire, `dim`
470
+ * les **estompe** (`dimOpacity`) et laisse la place à la sélection. */
471
+ linkMode?: QChartLinkMode
472
+ /** Opacité des éléments non sélectionnés en mode `dim` — 0.25 par défaut */
473
+ dimOpacity?: number
474
+ /** **Sélection partagée** (le `@pick` d'un graphique, ou la clé choisie par l'application) :
475
+ * les marques qui déclarent `link` ne gardent que les lignes jointes (`localField` ←
476
+ * `foreignField`). `null` = aucune sélection, tout est affiché. */
477
+ selection?: any
478
+ /** Thème : couleurs de texte/grille (résolues depuis les tokens CSS) */
479
+ theme?: { text?: string; muted?: string; grid?: string }
480
+ /** Valeurs des tokens de couleur (relues sur l'élément → suit le thème hôte) */
481
+ tokens?: Record<string, string>
482
+ /** Normalise une couleur CSS en une forme que zrender relit (`#rrggbb` / `rgba()`).
483
+ * Injecté par `QChart` (canvas 2D) ; absent hors navigateur → couleurs telles quelles. */
484
+ normalizeColor?: (value: string) => string | undefined
485
+ /** Options de rendu brutes, fusionnées en dernier (échappatoire) */
486
+ options?: Record<string, any>
487
+ }
488
+
489
+ const DEFAULT_COLORS = [
490
+ "chart-1",
491
+ "chart-2",
492
+ "chart-3",
493
+ "chart-4",
494
+ "chart-5",
495
+ "chart-6",
496
+ ]
497
+
498
+ /** Tokens de couleur relus sur l'élément par `QChart` (`tokens`) : le graphique suit le
499
+ * thème de l'hôte (clair/sombre, thème Docd, shadcn-vue…).
500
+ *
501
+ * `secondary` et `accent` sont volontairement **exclus** de cette liste : chez un hôte
502
+ * shadcn-vue ce sont des **surfaces** (`--secondary` ≈ blanc en clair, gris foncé en
503
+ * sombre — cf. `--background`) et non des couleurs. Les résoudre donnait des lignes de
504
+ * la couleur du fond, donc invisibles en clair comme en sombre. Ils gardent donc la
505
+ * valeur Material de dnax (`TOKEN_FALLBACKS`). Même logique pour la palette : les
506
+ * séries se colorent avec les tokens `--chart-N`, prévus pour ça. */
507
+ export const COLOR_TOKENS = [
508
+ "primary",
509
+ "info",
510
+ "positive",
511
+ "warning",
512
+ "negative",
513
+ "dark",
514
+ "chart-1",
515
+ "chart-2",
516
+ "chart-3",
517
+ "chart-4",
518
+ "chart-5",
519
+ "chart-6",
520
+ ] as const
521
+
522
+ /** Valeur d'un token quand le thème hôte n'en fournit pas (palette Material dnax) */
523
+ const TOKEN_FALLBACKS: Record<string, string> = {
524
+ primary: "#1976d2",
525
+ secondary: "#26a69a",
526
+ accent: "#9c27b0",
527
+ info: "#31ccec",
528
+ positive: "#21ba45",
529
+ warning: "#f2c037",
530
+ negative: "#c10015",
531
+ dark: "#1d1d1d",
532
+ "chart-1": "#1976d2",
533
+ "chart-2": "#26a69a",
534
+ "chart-3": "#9c27b0",
535
+ "chart-4": "#31ccec",
536
+ "chart-5": "#21ba45",
537
+ "chart-6": "#f2c037",
538
+ }
539
+
540
+ const isToken = (v: any): v is string => typeof v === "string" && v in TOKEN_FALLBACKS
541
+
542
+ /** Style de l'info-bulle : carte translucide floutée (verre), rayon 3px, ombre portée.
543
+ * `var(--card)` rend la surface solidaire du thème hôte (clair/sombre) sans relecture. */
544
+ const TOOLTIP_EXTRA_CSS = [
545
+ "backdrop-filter: blur(10px)",
546
+ "-webkit-backdrop-filter: blur(10px)",
547
+ "border-radius: 3px",
548
+ "box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1)",
549
+ "padding: 8px 12px",
550
+ ].join("; ")
551
+
552
+ const tooltipStyle = (theme: { text?: string; grid?: string }) => ({
553
+ backgroundColor: "color-mix(in srgb, var(--card, #ffffff) 70%, transparent)",
554
+ textStyle: { color: theme.text ?? "#333" },
555
+ borderColor: theme.grid ?? "rgba(0, 0, 0, 0.1)",
556
+ borderWidth: 1,
557
+ extraCssText: TOOLTIP_EXTRA_CSS,
558
+ })
559
+
560
+ const isPlainObject = (v: any) => v !== null && typeof v === "object" && !(v instanceof Date)
561
+
562
+ const isArrayLike = (v: any) => Array.isArray(v)
563
+
564
+ /** Couleur littérale ? (`#hex`, `rgb()`, `hsl()`, `oklch()`, nom CSS ou token dnax.ui) */
565
+ const looksLikeColor = (v: string) =>
566
+ /^(#|rgba?\(|hsla?\(|oklch\(|oklab\(|lch\(|lab\(|color\()/i.test(v) || /^[a-z]+$/i.test(v)
567
+
568
+ /** Valeur d'un canal pour la ligne `i` (les primitives servent d'identité) */
569
+ export function channelValue(
570
+ channel: QChartChannel | undefined,
571
+ data: any[],
572
+ i: number,
573
+ ): any {
574
+ if (typeof channel === "function") return channel(data[i], i)
575
+ if (typeof channel === "object" && channel !== null) {
576
+ const arr = channel as readonly any[]
577
+ return arr.length > 1 ? arr[i] : arr[0]
578
+ }
579
+ const row = data[i] as any
580
+ if (channel === undefined) return isPlainObject(row) ? undefined : row
581
+ if (typeof channel === "number") return channel
582
+ return isPlainObject(row) ? row[channel] : undefined
583
+ }
584
+
585
+ const isHorizontal = (m: QChartMark) => m.orientation === "horizontal"
586
+ const isRule = (m: QChartMark) => m.type === "rule"
587
+
588
+ /** `textAnchor` (Plot) → `label.align` (ECharts) */
589
+ const TEXT_ALIGN: Record<string, "left" | "center" | "right"> = {
590
+ start: "left",
591
+ middle: "center",
592
+ center: "center",
593
+ end: "right",
594
+ }
595
+
596
+ /** `lineAnchor` (Plot) → `label.verticalAlign` (ECharts) */
597
+ const LINE_ANCHOR: Record<string, "top" | "middle" | "bottom"> = {
598
+ top: "top",
599
+ middle: "middle",
600
+ bottom: "bottom",
601
+ }
602
+
603
+ /** Contenu d'un libellé (marque `text`) : le canal `text`, sinon la ligne elle-même
604
+ * (données primitives), sinon son index — comme le `text` par défaut de Plot. */
605
+ export function textValue(m: QChartMark, data: any[], i: number): any {
606
+ const v = channelValue(m.text, data, i)
607
+ if (v !== undefined && v !== null) return v
608
+ const row = data[i]
609
+ return isPlainObject(row) ? i : row
610
+ }
611
+
612
+ /** Construit l'option ECharts à partir des marks (voir `QChart`) */
613
+ export function chartToECharts(config: QChartConfig): Record<string, any> {
614
+ const marks = config.marks ?? []
615
+ const tokens = config.tokens ?? {}
616
+
617
+ /** Couleur exploitable par ECharts/zrender.
618
+ *
619
+ * zrender ne sait lire que `#hex`, `rgb(a,b,c)`, `hsl(h,s%,l%)` et les noms CSS ;
620
+ * sur une couleur moderne (`oklch()`, `color-mix()`, `rgb(0 0 0 / .5)` — c'est-à-dire
621
+ * tous les tokens d'un thème shadcn/Docd) son parseur échoue. Or l'état `emphasis`
622
+ * (survol) **recalcule la couleur** de l'élément :
623
+ * `emphasisStyle.fill = liftColor(fromFill)` (`echarts/lib/util/states.js`)
624
+ * `liftColor()` renvoie alors `undefined` → la forme est dessinée sans remplissage →
625
+ * la barre/la ligne DISPARAÎT au survol. On normalise donc en amont (`QChart` le fait
626
+ * via un canvas 2D, seul moyen portable de sérialiser n'importe quelle couleur CSS). */
627
+ const color = (v: any): any => {
628
+ const c = isToken(v) ? tokens[v] ?? TOKEN_FALLBACKS[v] : v
629
+ if (typeof c !== "string" || !config.normalizeColor) return c
630
+ return config.normalizeColor(c) ?? c
631
+ }
632
+ const resolve = color
633
+ const raw = config.colors?.length ? config.colors : DEFAULT_COLORS
634
+ const palette = raw.map(resolve)
635
+
636
+ const theme = {
637
+ text: resolve(config.theme?.text),
638
+ muted: resolve(config.theme?.muted),
639
+ grid: resolve(config.theme?.grid),
640
+ }
641
+
642
+ /** Lignes d'une marque, **jointes** à la sélection partagée (`link` + `selection`).
643
+ * Point d'application unique : toutes les familles de marques en héritent. */
644
+ const dataOf = (m: QChartMark) => {
645
+ const link = chartLink(m.link)
646
+ // Mode `dim` : la marque garde **toutes** ses lignes (l'estompage est un style, cf. plus bas)
647
+ if ((config.linkMode ?? "filter") === "dim") return m.data ?? []
648
+ return linkedRows(m.data ?? [], link, config.selection)
649
+ }
650
+
651
+ // Orientation : donnée par la première marque de série. Toutes les marques
652
+ // doivent partager la même orientation (`pie` n'est pas posé sur les axes → ignoré).
653
+ const first = marks.find((m) => !isRule(m) && m.type !== "pie" && m.type !== "table") ?? marks[0]
654
+ const horizontal = !!first && isHorizontal(first)
655
+
656
+ const abscissaOf = (m: QChartMark) => m.x ?? m.y
657
+ const ordinateOf = (m: QChartMark) => m.y ?? m.x
658
+
659
+ /** Ligne en **paire** `[abscisse, ordonnée]` (raccourci Plot : ni `x` ni `y` donnés) */
660
+ const pairRowOf = (m: QChartMark, i: number): any[] | null => {
661
+ if (m.x !== undefined || m.y !== undefined) return null
662
+ const row = (m.data ?? [])[i]
663
+ return isArrayLike(row) ? (row as any[]) : null
664
+ }
665
+
666
+ /** Canal « couleur » : token dnax.ui, sinon champ de données, sinon couleur littérale */
667
+ const colorValue = (channel: QChartChannel | undefined, data: any[], i: number): any => {
668
+ if (typeof channel === "string") {
669
+ if (isToken(channel)) return channel
670
+ const row = data[0] as any
671
+ if (isPlainObject(row) && channel in row) return channelValue(channel, data, i)
672
+ return looksLikeColor(channel) ? channel : undefined
673
+ }
674
+ return channelValue(channel, data, i)
675
+ }
676
+
677
+ // ─── Domaine des catégories (axe « band ») ───
678
+ const categories: string[] = []
679
+ const seen = new Set<string>()
680
+ let numericX = true
681
+ let timeX = config.x?.type === "time"
682
+ for (const m of marks) {
683
+ if (isRule(m) || m.type === "pie" || m.type === "table") continue
684
+ const data = dataOf(m)
685
+ const ch = abscissaOf(m)
686
+ for (let i = 0; i < data.length; i++) {
687
+ const pair = pairRowOf(m, i)
688
+ const v = pair ? pair[0] : channelValue(ch, data, i)
689
+ if (v === undefined || v === null) continue
690
+ if (v instanceof Date || (typeof v === "string" && timeX)) {
691
+ const ts = v instanceof Date ? v.getTime() : Date.parse(v)
692
+ if (Number.isFinite(ts)) categories.push(String(ts))
693
+ continue
694
+ }
695
+ if (typeof v !== "number") numericX = false
696
+ const key = String(v)
697
+ if (!seen.has(key)) {
698
+ seen.add(key)
699
+ categories.push(key)
700
+ }
701
+ }
702
+ }
703
+ if (config.x?.type === "time") timeX = true
704
+ const axisType = config.x?.type
705
+ ? { band: "category", linear: "value", log: "log", time: "time" }[config.x.type]
706
+ : numericX && !timeX
707
+ ? "value"
708
+ : timeX
709
+ ? "time"
710
+ : "category"
711
+
712
+ // `heatmap` : les **lignes** sont un second axe catégorie (`y`), dans l'ordre d'apparition
713
+ const rowCategories: string[] = []
714
+ {
715
+ const seenRows = new Set<string>()
716
+ for (const m of marks) {
717
+ if (m.type !== "heatmap") continue
718
+ const data = dataOf(m)
719
+ for (let i = 0; i < data.length; i++) {
720
+ const v = channelValue(m.y, data, i)
721
+ if (v === undefined || v === null) continue
722
+ const key = String(v)
723
+ if (!seenRows.has(key)) {
724
+ seenRows.add(key)
725
+ rowCategories.push(key)
726
+ }
727
+ }
728
+ }
729
+ }
730
+
731
+ /** Valeur d'abscisse prête pour ECharts : résout le canal, la paire, et l'axe temps */
732
+ const solveX = (channel: QChartChannel | undefined, m: QChartMark, i: number): any => {
733
+ const pair = pairRowOf(m, i)
734
+ const v = pair ? pair[0] : channelValue(channel, dataOf(m), i)
735
+ if (axisType !== "time") return v
736
+ if (v instanceof Date) return v.getTime()
737
+ const ts = Date.parse(String(v))
738
+ return Number.isFinite(ts) ? ts : v
739
+ }
740
+ /** Paire `[abscisse, ordonnée]` attendue par ECharts (inversée si marque horizontale) */
741
+ const pairOf = (m: QChartMark, i: number): [any, any] | null => {
742
+ const pair = pairRowOf(m, i)
743
+ const cat = solveX(abscissaOf(m), m, i)
744
+ const val = pair ? pair[1] : channelValue(ordinateOf(m), dataOf(m), i)
745
+ if (cat === undefined || cat === null || val === undefined || val === null) return null
746
+ return horizontal ? [val, cat] : [cat, val]
747
+ }
748
+
749
+ const series: Record<string, any>[] = []
750
+ let colorIndex = 0
751
+ const colorFor = (explicit?: any) => {
752
+ if (explicit !== undefined && explicit !== null && explicit !== "") return resolve(explicit)
753
+ return palette[colorIndex++ % palette.length]
754
+ }
755
+
756
+ const rulesY: any[] = []
757
+ const rulesX: any[] = []
758
+ /** Échelles de couleurs des cartes de chaleur (valeur numérique → palette) */
759
+ const visualMaps: any[] = []
760
+
761
+ for (const m of marks) {
762
+ // Une marque `table` n'est pas une série : elle est rendue en HTML par `<q-chart>`
763
+ if (m.type === "table") continue
764
+ const data = dataOf(m)
765
+
766
+ // Camembert / anneau — famille **hors axes** (ni grid ni xAxis/yAxis) : une part par
767
+ // ligne, `x` = le libellé de la part, `y` = sa valeur. Sans `fill`, ECharts colorie
768
+ // les parts avec la palette (`color`), une part après l'autre.
769
+ if (m.type === "pie") {
770
+ const formatter =
771
+ m.labels === "value"
772
+ ? "{c}"
773
+ : m.labels === "percent"
774
+ ? "{d}%"
775
+ : m.labels === "name-value"
776
+ ? "{b}: {c}"
777
+ : m.labels === "name-percent"
778
+ ? "{b} {d}%"
779
+ : "{b}"
780
+ const sliceOpacity = typeof m.opacity === "number" ? m.opacity : undefined
781
+ const slices = data.map((_, i) => {
782
+ const slice: Record<string, any> = {
783
+ name: channelValue(m.x, data, i) ?? String(i),
784
+ value: channelValue(m.y, data, i) ?? 0,
785
+ }
786
+ const color = resolve(colorValue(m.fill, data, i))
787
+ const style: Record<string, any> = {}
788
+ if (typeof color === "string" && looksLikeColor(color)) style.color = color
789
+ if (sliceOpacity !== undefined) style.opacity = sliceOpacity
790
+ if (Object.keys(style).length) slice.itemStyle = style
791
+ if (m.title !== undefined) slice.tooltip = { formatter: String(channelValue(m.title, data, i)) }
792
+ return slice
793
+ })
794
+ const outer = m.radius ?? "70%"
795
+ series.push({
796
+ type: "pie",
797
+ name: m.name,
798
+ z: series.length,
799
+ radius: m.innerRadius !== undefined ? [m.innerRadius, outer] : outer,
800
+ ...(typeof m.startAngle === "number" ? { startAngle: m.startAngle } : {}),
801
+ ...(m.labels === false
802
+ ? { label: { show: false }, labelLine: { show: false } }
803
+ : { label: { formatter } }),
804
+ data: slices,
805
+ })
806
+ continue
807
+ }
808
+
809
+ // Carte de chaleur — `x` = la colonne, `y` = la ligne (**deux axes catégories**).
810
+ // `fill` porte la **valeur** de la cellule (comme le `fill` d'un rectangle de Plot) :
811
+ // un canal numérique → échelle de couleurs bâtie sur la palette ; une couleur (token ou
812
+ // CSS) → toutes les cellules de cette couleur.
813
+ // ECharts **exige** un `visualMap` pour une série `heatmap` : il est donc toujours émis,
814
+ // **caché** quand la couleur est constante (échelle dégénérée `[c, c]`).
815
+ if (m.type === "heatmap") {
816
+ const columnIndex = new Map(categories.map((c, i) => [c, i]))
817
+ const rowIndex = new Map(rowCategories.map((c, i) => [c, i]))
818
+ const raw = data.map((_, i) => colorValue(m.fill, data, i))
819
+ const numeric = raw.length > 0 && raw.every((v) => typeof v === "number")
820
+ const uniform = numeric ? undefined : resolve(raw[0] ?? m.fill)
821
+ const cells: any[] = []
822
+ for (let i = 0; i < data.length; i++) {
823
+ const column = columnIndex.get(String(solveX(m.x, m, i)))
824
+ const row = rowIndex.get(String(channelValue(m.y, data, i)))
825
+ if (column === undefined || row === undefined) continue
826
+ const cell: Record<string, any> = {
827
+ // Nom de la cellule → l'info-bulle ECharts par défaut (« ligne · colonne »)
828
+ name: `${rowCategories[row]} · ${categories[column]}`,
829
+ value: [column, row, numeric ? raw[i] : 1],
830
+ }
831
+ if (m.title !== undefined) cell.tooltip = { formatter: String(channelValue(m.title, data, i)) }
832
+ cells.push(cell)
833
+ }
834
+ const values = numeric ? (raw as number[]) : [0, 1]
835
+ // Couleur constante → échelle dégénérée `[c, c]` (sinon la palette sert de rampe)
836
+ const flat = typeof uniform === "string" && looksLikeColor(uniform) ? uniform : (palette[0] ?? "#1976d2")
837
+ visualMaps.push({
838
+ min: Math.min(...values),
839
+ max: Math.max(...values),
840
+ inRange: { color: numeric ? palette : [flat, flat] },
841
+ show: numeric, // une couleur constante n'a pas d'échelle à montrer
842
+ orient: "horizontal",
843
+ left: 0,
844
+ bottom: 0,
845
+ itemWidth: 12,
846
+ itemHeight: 100,
847
+ textStyle: { color: theme.muted },
848
+ })
849
+ const cellOpacity = typeof m.opacity === "number" ? m.opacity : undefined
850
+ series.push({
851
+ type: "heatmap",
852
+ name: m.name,
853
+ z: series.length,
854
+ data: cells,
855
+ ...(cellOpacity !== undefined ? { itemStyle: { opacity: cellOpacity } } : {}),
856
+ ...(m.labels === true || m.labels === "value"
857
+ ? { label: { show: true, formatter: "{@[2]}", color: theme.text } }
858
+ : {}),
859
+ })
860
+ continue
861
+ }
862
+
863
+ if (isRule(m)) {
864
+ // Repère : **horizontal par défaut** (valeurs sur l'axe Y, comme `Plot.ruleY`),
865
+ // c'est-à-dire le cas courant du seuil/objectif. `x` — sans `y` — donne un
866
+ // repère vertical ; `data` (tableau de valeurs) remplace les valeurs du canal.
867
+ const vertical = m.x !== undefined && m.y === undefined
868
+ const ch = vertical ? m.x : m.y ?? m.x
869
+ const values: any[] =
870
+ data.length && !isPlainObject(data[0])
871
+ ? data
872
+ : Array.from(
873
+ { length: data.length || (isArrayLike(ch) ? (ch as any[]).length : 1) },
874
+ (_, i) => channelValue(ch, data, i),
875
+ )
876
+ const lineStyle: Record<string, any> = { type: "dashed" }
877
+ if (theme.grid) lineStyle.color = theme.grid
878
+ const stroke = resolve(m.stroke ?? m.fill)
879
+ if (typeof stroke === "string" && looksLikeColor(stroke)) lineStyle.color = stroke
880
+ if (typeof m.strokeWidth === "number") lineStyle.width = m.strokeWidth
881
+ const list = vertical ? rulesX : rulesY
882
+ for (const v of values) {
883
+ if (v === undefined || v === null) continue
884
+ list.push({ [vertical ? "xAxis" : "yAxis"]: v, lineStyle: { ...lineStyle } })
885
+ }
886
+ continue
887
+ }
888
+
889
+ // Regroupement `z` → une série (et une entrée de légende) par valeur
890
+ const zChannel = m.z
891
+ const groups: { key: string | null; indexes: number[] }[] = []
892
+ if (zChannel !== undefined) {
893
+ const map = new Map<string, number[]>()
894
+ for (let i = 0; i < data.length; i++) {
895
+ const key = String(channelValue(zChannel, data, i) ?? "")
896
+ const arr = map.get(key)
897
+ if (arr) arr.push(i)
898
+ else map.set(key, [i])
899
+ }
900
+ for (const [key, indexes] of map) groups.push({ key, indexes })
901
+ } else {
902
+ groups.push({ key: null, indexes: data.map((_, i) => i) })
903
+ }
904
+
905
+ for (const g of groups) {
906
+ const points = g.indexes
907
+ .map((i) => {
908
+ const pair = pairOf(m, i)
909
+ return pair ? { value: pair, index: i } : null
910
+ })
911
+ .filter((p): p is { value: [any, any]; index: number } => p !== null)
912
+
913
+ // `stroke` est la couleur de la marque (ligne, contour), `fill` celle des surfaces.
914
+ // Pour un `dot` : `fill` colore le point et `stroke` en dessine le **contour** —
915
+ // dès lors que `fill` est aussi donné ; un `stroke` seul colore le point, comme
916
+ // avant (rétrocompatible, et c'est alors `fill` qui n'est pas renseigné).
917
+ // `text` : comme un `dot`, `fill` colore la marque (ici le texte) ; un `stroke`
918
+ // seul colore aussi — sinon c'est le halo (cf. `outline` ci-dessous).
919
+ const primary = m.type === "dot" || m.type === "text" ? (m.fill ?? m.stroke) : (m.stroke ?? m.fill)
920
+ const stroke = colorValue(primary, data, g.indexes[0] ?? 0)
921
+ const fill = resolve(colorValue(m.fill, data, g.indexes[0] ?? 0))
922
+ const baseColor = colorFor(stroke ?? fill)
923
+ // Contour (`dot`) / halo (`text`) : `stroke`, dès que `fill` est aussi donné.
924
+ const outline =
925
+ (m.type === "dot" || m.type === "text") && m.fill !== undefined
926
+ ? resolve(colorValue(m.stroke, data, g.indexes[0] ?? 0))
927
+ : undefined
928
+ const radius = channelValue(m.r, data, g.indexes[0] ?? 0)
929
+
930
+ // `image` : taille et rotation au premier point ; les autres sont posées par
931
+ // point dans `perPoint` (comme `r` pour les bulles).
932
+ const scale = channelValue(m.r, data, g.indexes[0] ?? 0)
933
+ const imageW = channelValue(m.width, data, g.indexes[0] ?? 0)
934
+ const imageH = channelValue(m.height, data, g.indexes[0] ?? 0)
935
+ const fallbackSize = typeof scale === "number" ? scale * 2 : 16
936
+ const imageSize: [number, number] = [
937
+ typeof imageW === "number" ? imageW : typeof imageH === "number" ? imageH : fallbackSize,
938
+ typeof imageH === "number" ? imageH : typeof imageW === "number" ? imageW : fallbackSize,
939
+ ]
940
+ const rotate0 = channelValue(m.rotate, data, g.indexes[0] ?? 0)
941
+ // `text` : police et décalage au premier point — les autres points sont posés par
942
+ // `perPoint` quand le canal varie (comme `r` pour les bulles).
943
+ const fontSize0 = channelValue(m.fontSize, data, g.indexes[0] ?? 0)
944
+ const dx0 = channelValue(m.dx, data, g.indexes[0] ?? 0)
945
+ const dy0 = channelValue(m.dy, data, g.indexes[0] ?? 0)
946
+ // `src` est une constante si elle commence par `.`, `/` ou un protocole — sinon
947
+ // c'est un canal (un champ de données), comme dans Observable Plot.
948
+ const constantSrc =
949
+ typeof m.src === "string" && /^([./]|[a-z][a-z0-9+.-]*:)/i.test(m.src) ? m.src : undefined
950
+
951
+ const name = g.key ?? m.name
952
+
953
+ const common = {
954
+ name,
955
+ z: series.length,
956
+ ...(m.stack ? { stack: "total" } : {}),
957
+ ...(theme.muted ? { markLineColor: theme.muted } : {}),
958
+ }
959
+
960
+ /** Valeur au point `p` si elle diffère du défaut donné (sinon `undefined`) */
961
+ const perPointValue = (channel: QChartChannel | undefined, base: any, i: number) => {
962
+ const v = channelValue(channel, data, i)
963
+ return v === base ? undefined : v
964
+ }
965
+
966
+ const perPoint = (p: { value: [any, any]; index: number }) => {
967
+ const item: Record<string, any> = { value: p.value }
968
+ const c = resolve(colorValue(m.fill, data, p.index))
969
+ if (typeof c === "string" && looksLikeColor(c) && c !== fill) item.itemStyle = { color: c }
970
+ if (m.type === "dot") {
971
+ // Rayon par point → graphique à bulles
972
+ const r = channelValue(m.r, data, p.index)
973
+ if (typeof r === "number" && r !== radius) item.symbolSize = r * 2 + 2
974
+ } else if (m.type === "image") {
975
+ const src = channelValue(m.src, data, p.index)
976
+ if (m.round === true) {
977
+ // Pastille ronde : l'image est peinte en **motif** dans le symbole `circle`
978
+ if (typeof src === "string") item.itemStyle = { color: { image: src, repeat: "no-repeat" } }
979
+ } else if (!constantSrc && typeof src === "string") {
980
+ item.symbol = `image://${src}`
981
+ }
982
+ const w = perPointValue(m.width, imageSize[0], p.index)
983
+ const h = perPointValue(m.height, imageSize[1], p.index)
984
+ if (typeof w === "number" || typeof h === "number") {
985
+ item.symbolSize = [typeof w === "number" ? w : imageSize[0], typeof h === "number" ? h : imageSize[1]]
986
+ }
987
+ const rotate = perPointValue(m.rotate, rotate0, p.index)
988
+ if (typeof rotate === "number") item.symbolRotate = rotate
989
+ }
990
+ if (m.type === "text") {
991
+ // Le libellé vit dans `label` ; quand le canal varie d'un point à l'autre,
992
+ // ECharts fusionne le `label` de l'élément avec celui de la série (le premier
993
+ // prime sur les clés qu'il donne).
994
+ const value = textValue(m, data, p.index)
995
+ item.label = { formatter: value === undefined || value === null ? "" : String(value) }
996
+ const c = resolve(colorValue(m.fill, data, p.index))
997
+ if (typeof c === "string" && looksLikeColor(c) && c !== fill) item.label.color = c
998
+ const size = perPointValue(m.fontSize, fontSize0, p.index)
999
+ if (typeof size === "number") item.label.fontSize = size
1000
+ // `rotate` est horaire (Plot) ; `label.rotate` d'ECharts est anti-horaire
1001
+ const rot = perPointValue(m.rotate, rotate0, p.index)
1002
+ if (typeof rot === "number") item.label.rotate = -rot
1003
+ const dx = perPointValue(m.dx, dx0, p.index)
1004
+ const dy = perPointValue(m.dy, dy0, p.index)
1005
+ if (typeof dx === "number" || typeof dy === "number") {
1006
+ item.label.offset = [typeof dx === "number" ? dx : dx0 ?? 0, typeof dy === "number" ? dy : dy0 ?? 0]
1007
+ }
1008
+ if (m.title !== undefined) item.tooltip = { formatter: String(channelValue(m.title, data, p.index)) }
1009
+ } else {
1010
+ const t = m.title !== undefined ? channelValue(m.title, data, p.index) : undefined
1011
+ if (t !== undefined) item.tooltip = { formatter: String(t) }
1012
+ }
1013
+ return item
1014
+ }
1015
+
1016
+ const opacity = typeof m.opacity === "number" ? m.opacity : undefined
1017
+
1018
+ if (m.type === "bar") {
1019
+ series.push({
1020
+ ...common,
1021
+ type: "bar",
1022
+ data: points.map(perPoint),
1023
+ itemStyle: { color: baseColor, borderRadius: [3, 3, 0, 0], ...(opacity !== undefined ? { opacity } : {}) },
1024
+ barMaxWidth: 42,
1025
+ })
1026
+ } else if (m.type === "line" || m.type === "area") {
1027
+ const isArea = m.type === "area"
1028
+ series.push({
1029
+ ...common,
1030
+ type: "line",
1031
+ data: points.map(perPoint),
1032
+ smooth: false,
1033
+ symbol: "circle",
1034
+ symbolSize: isArea ? 0 : 5,
1035
+ lineStyle: {
1036
+ width: typeof m.strokeWidth === "number" ? m.strokeWidth : 2,
1037
+ color: baseColor,
1038
+ ...(opacity !== undefined ? { opacity } : {}),
1039
+ },
1040
+ ...(isArea ? { areaStyle: { color: baseColor, opacity: 0.18 } } : {}),
1041
+ })
1042
+ } else if (m.type === "dot") {
1043
+ series.push({
1044
+ ...common,
1045
+ type: "scatter",
1046
+ symbol: typeof m.symbol === "string" ? m.symbol : "circle",
1047
+ data: points.map(perPoint),
1048
+ symbolSize: typeof radius === "number" ? radius * 2 + 2 : 9,
1049
+ itemStyle: {
1050
+ color: baseColor,
1051
+ ...(typeof outline === "string" && looksLikeColor(outline)
1052
+ ? {
1053
+ borderColor: outline,
1054
+ borderWidth: typeof m.strokeWidth === "number" ? m.strokeWidth : 1,
1055
+ }
1056
+ : {}),
1057
+ ...(opacity !== undefined ? { opacity } : {}),
1058
+ },
1059
+ })
1060
+ } else if (m.type === "image") {
1061
+ const round = m.round === true
1062
+ // Deux rendus :
1063
+ // - `round: true` : un symbole `circle` **rempli par un motif** image
1064
+ // (`itemStyle.color.image`, posé par point). zrender ne sait pas découper un
1065
+ // symbole : le motif est peint à la taille **native** de l'image, ancré en haut à
1066
+ // gauche du marqueur → servir l'image au format du marqueur.
1067
+ // - sinon `image://` : ECharts dessine l'image dans `symbolSize`, aspect conservé
1068
+ // (`symbolKeepAspect`), sans découpe possible.
1069
+ // `opacity` par défaut à 1 : la valeur 0.8 de `scatter` délave les images.
1070
+ const ring = resolve(colorValue(m.stroke, data, g.indexes[0] ?? 0))
1071
+ series.push({
1072
+ ...common,
1073
+ type: "scatter",
1074
+ ...(round
1075
+ ? { symbol: "circle" }
1076
+ : constantSrc
1077
+ ? { symbol: `image://${constantSrc}` }
1078
+ : {}),
1079
+ ...(round ? {} : { symbolKeepAspect: true }),
1080
+ ...(typeof rotate0 === "number" ? { symbolRotate: rotate0 } : {}),
1081
+ data: points.map(perPoint),
1082
+ symbolSize: imageSize,
1083
+ itemStyle: {
1084
+ opacity: opacity ?? 1,
1085
+ // Anneau (`stroke` + `strokeWidth`) — surtout lisible sur une pastille ronde
1086
+ ...(typeof ring === "string" && looksLikeColor(ring)
1087
+ ? {
1088
+ borderColor: ring,
1089
+ borderWidth: typeof m.strokeWidth === "number" ? m.strokeWidth : round ? 2 : 1,
1090
+ }
1091
+ : {}),
1092
+ },
1093
+ })
1094
+ } else if (m.type === "text") {
1095
+ // Libellé (Plot.text) : le point n'est qu'une **ancre invisible** (1 px,
1096
+ // transparente) — tout le rendu est dans `label`. Par défaut le texte est
1097
+ // **centré** sur (x, y) (`middle` / `middle`, comme Plot sans `frameAnchor`) ;
1098
+ // `textAnchor` / `lineAnchor` le décollent et `dx` / `dy` le décalent en pixels.
1099
+ const fontSize = typeof fontSize0 === "number" ? fontSize0 : 11
1100
+ // Plot exprime `lineWidth` en **ems** → largeur en pixels pour ECharts.
1101
+ const wrap = typeof m.lineWidth === "number" ? { width: m.lineWidth * fontSize } : {}
1102
+ const overflow =
1103
+ m.textOverflow !== undefined
1104
+ ? { overflow: "truncate", ellipsis: /^ellipsis/.test(m.textOverflow) ? "…" : "" }
1105
+ : typeof m.lineWidth === "number"
1106
+ ? { overflow: "break" }
1107
+ : {}
1108
+ series.push({
1109
+ ...common,
1110
+ type: "scatter",
1111
+ data: points.map(perPoint),
1112
+ symbolSize: 1,
1113
+ // `opacity: 1` : sans ça le libellé hérite des 0.8 par défaut d'une série
1114
+ // `scatter` (`defaultOpacity` du symbole) et le texte sort délavé.
1115
+ itemStyle: { color: color("transparent"), opacity: 1 },
1116
+ label: {
1117
+ show: true,
1118
+ position: "inside",
1119
+ align: TEXT_ALIGN[m.textAnchor ?? "middle"] ?? "center",
1120
+ verticalAlign: LINE_ANCHOR[m.lineAnchor ?? "middle"] ?? "middle",
1121
+ color: baseColor,
1122
+ fontSize,
1123
+ ...(m.fontWeight !== undefined ? { fontWeight: m.fontWeight } : {}),
1124
+ ...(m.fontFamily !== undefined ? { fontFamily: m.fontFamily } : {}),
1125
+ ...(m.fontStyle !== undefined ? { fontStyle: m.fontStyle } : {}),
1126
+ ...(typeof m.lineHeight === "number" ? { lineHeight: m.lineHeight } : {}),
1127
+ ...wrap,
1128
+ ...overflow,
1129
+ // `rotate` est horaire (Plot) ; `label.rotate` d'ECharts est anti-horaire
1130
+ ...(typeof rotate0 === "number" ? { rotate: -rotate0 } : {}),
1131
+ ...(typeof dx0 === "number" || typeof dy0 === "number"
1132
+ ? { offset: [dx0 ?? 0, dy0 ?? 0] }
1133
+ : {}),
1134
+ // Halo (Plot : `stroke` + `strokeWidth`, `paintOrder: 'stroke'` par défaut)
1135
+ ...(typeof outline === "string" && looksLikeColor(outline)
1136
+ ? {
1137
+ textBorderColor: outline,
1138
+ textBorderWidth: typeof m.strokeWidth === "number" ? m.strokeWidth : 3,
1139
+ }
1140
+ : {}),
1141
+ ...(opacity !== undefined ? { opacity } : {}),
1142
+ },
1143
+ })
1144
+ }
1145
+ }
1146
+ }
1147
+
1148
+ // Les règles sont posées sur la première série (ou une série vide hôte)
1149
+ if (rulesY.length || rulesX.length) {
1150
+ if (!series.length) {
1151
+ series.push({ type: "line", data: [], silent: true, symbol: "none", lineStyle: { opacity: 0 } })
1152
+ }
1153
+ series[0]!.markLine = {
1154
+ silent: true,
1155
+ symbol: "none",
1156
+ animation: false,
1157
+ data: [...rulesY, ...rulesX],
1158
+ ...(theme.muted ? { lineStyle: { color: theme.muted, type: "dashed" } } : {}),
1159
+ }
1160
+ }
1161
+
1162
+ // ─── Légende : position (haut par défaut), alignement, marge, place réservée ───
1163
+ // La légende et la zone de tracé ne doivent pas se toucher : la marge (`offset`) est
1164
+ // **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)
1166
+ const legendConfig: QChartLegend = typeof config.legend === "object" && config.legend !== null ? config.legend : {}
1167
+ const legendPosition = legendConfig.position ?? "top"
1168
+ // `offset` par défaut : 24 px — une respiration **conséquente** entre la légende et la
1169
+ // zone de tracé (la première étiquette de graduation est collée au bord du grid, donc une
1170
+ // marge faible se lit comme un chevauchement).
1171
+ const legendGap = typeof legendConfig.offset === "number" ? legendConfig.offset : 24
1172
+ const legendAlign = legendConfig.align === "center" ? "center" : legendConfig.align === "end" ? "right" : 0
1173
+ /** Hauteur d'une entrée de légende (itemHeight 10 + respiration) */
1174
+ const LEGEND_LINE = 14
1175
+ /** Largeur réservée à une légende verticale (gauche / droite) */
1176
+ const LEGEND_COLUMN = 90
1177
+ const legendTop = config.title ? 26 : 0
1178
+ const legendTopBand = legendOn && legendPosition === "top" ? legendTop + LEGEND_LINE + legendGap : 0
1179
+ /** Place sous le graphique quand une échelle de couleurs est visible (`heatmap`) */
1180
+ const scaleBand = visualMaps.some((vm) => vm.show !== false) ? 30 : 0
1181
+ /** Bandeau du **nom d'axe** (nom de l'axe y au-dessus, nom de l'axe x en dessous) */
1182
+ const axisNameTop = config.y?.label ? 16 : 0
1183
+ const axisNameBottom = config.x?.label ? 16 : 0
1184
+ const legendOption = !legendOn
1185
+ ? { show: false }
1186
+ : {
1187
+ textStyle: { color: theme.muted },
1188
+ itemWidth: 10,
1189
+ itemHeight: 10,
1190
+ ...(legendPosition === "left"
1191
+ ? { orient: "vertical", left: 0, top: "middle" }
1192
+ : legendPosition === "right"
1193
+ ? { orient: "vertical", right: 0, top: "middle" }
1194
+ : legendPosition === "bottom"
1195
+ ? { bottom: scaleBand, left: legendAlign }
1196
+ : { top: legendTop, left: legendAlign }),
1197
+ }
1198
+ const gridBox = {
1199
+ left: 8 + (legendOn && legendPosition === "left" ? LEGEND_COLUMN + legendGap : 0),
1200
+ right: 16 + (legendOn && legendPosition === "right" ? LEGEND_COLUMN + legendGap : 0),
1201
+ // Légende → nom d'axe → zone de tracé, sans recouvrement
1202
+ top: (legendTopBand > 0 ? legendTopBand : config.title ? 44 : 16) + axisNameTop,
1203
+ bottom:
1204
+ 8 +
1205
+ axisNameBottom +
1206
+ scaleBand +
1207
+ (legendOn && legendPosition === "bottom" ? LEGEND_LINE + legendGap : 0),
1208
+ containLabel: true,
1209
+ }
1210
+
1211
+ 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
+ const axisLine = { lineStyle: { color: theme.grid } }
1216
+
1217
+ const hasHeatmap = series.some((s) => s.type === "heatmap")
1218
+ const categoryAxis = (axis: QChartAxis | undefined, data: string[] = categories, heat = false) => ({
1219
+ type: "category",
1220
+ data,
1221
+ name: axis?.label,
1222
+ nameTextStyle: axisLabel,
1223
+ axisLabel: { ...axisLabel, hideOverlap: true },
1224
+ axisLine,
1225
+ axisTick: { show: false },
1226
+ splitLine: { show: false },
1227
+ // Carte de chaleur : quadrillage des cases
1228
+ ...(heat ? { splitArea: { show: true } } : {}),
1229
+ boundaryGap: hasHeatmap || series.some((s) => s.type === "bar"),
1230
+ })
1231
+ const valueAxis = (axis: QChartAxis | undefined, zero: boolean) => ({
1232
+ type: axis?.type === "log" ? "log" : "value",
1233
+ name: axis?.label,
1234
+ nameTextStyle: axisLabel,
1235
+ min: axis?.min ?? (zero ? 0 : undefined),
1236
+ max: axis?.max,
1237
+ axisLabel:
1238
+ typeof axis?.margin === "number" ? { ...valueAxisLabel, margin: axis.margin } : valueAxisLabel,
1239
+ axisLine: { show: false },
1240
+ axisTick: { show: false },
1241
+ splitLine: { show: axis?.grid ?? true, lineStyle: { color: theme.grid } },
1242
+ })
1243
+
1244
+ // Orientation horizontale : catégories sur l'axe Y
1245
+ const hasBar = series.some((s) => s.type === "bar")
1246
+ const xAxis = hasHeatmap
1247
+ ? categoryAxis(config.x, categories, true)
1248
+ : horizontal
1249
+ ? valueAxis(config.x, hasBar)
1250
+ : axisType === "category"
1251
+ ? categoryAxis(config.x)
1252
+ : valueAxis(config.x, false)
1253
+ const yAxis = hasHeatmap
1254
+ ? categoryAxis(config.y, rowCategories, true)
1255
+ : horizontal
1256
+ ? axisType === "category"
1257
+ ? categoryAxis(config.y)
1258
+ : valueAxis(config.y, false)
1259
+ : valueAxis(config.y, hasBar)
1260
+
1261
+ // Familles **hors axes** (`pie` seul) : ni grid ni xAxis/yAxis — sinon ECharts laisse
1262
+ // un cadre fantôme et décale le titre.
1263
+ const cartesian = series.some(
1264
+ (s) => s.type === "bar" || s.type === "line" || s.type === "scatter" || s.type === "heatmap",
1265
+ )
1266
+
1267
+ // Un tooltip d'axe suppose des données **par catégorie** : vrai pour tout cartésien, sauf si
1268
+ // l'info est par point (`title`) ou si le graphique ne porte que des étiquettes (`text`).
1269
+ // (`cartesian` inclut la heatmap, qui se survole cellule par cellule : on ne s'en sert pas ici.)
1270
+ const axisTooltip =
1271
+ series.some((s) => s.type === "bar" || s.type === "line") ||
1272
+ (series.some((s) => s.type === "scatter") &&
1273
+ !series.some((s) => s.type === "heatmap") &&
1274
+ !marks.some((m) => m.title !== undefined || m.type === "text"))
1275
+
1276
+ const option: Record<string, any> = {
1277
+ ...(config.title
1278
+ ? { title: { text: config.title, left: 0, textStyle: { fontSize: 13, fontWeight: 600, color: theme.text } } }
1279
+ : {}),
1280
+ ...(cartesian
1281
+ ? {
1282
+ grid: gridBox,
1283
+ xAxis,
1284
+ yAxis,
1285
+ }
1286
+ : {}),
1287
+ ...(visualMaps.length ? { visualMap: visualMaps } : {}),
1288
+ tooltip:
1289
+ config.tooltip === false
1290
+ ? { show: false }
1291
+ : {
1292
+ // **`axis` par défaut sur tout graphique cartésien** (bar, line, dot…) : on compare
1293
+ // les séries le long de l'axe. On reste en `item` quand l'information est vraiment
1294
+ // **par point** : une marque `title` (elle ne s'afficherait jamais dans un tooltip
1295
+ // d'axe) ou un graphique d'étiquettes (`text`, dont la donnée est le libellé).
1296
+ trigger: axisTooltip ? "axis" : "item",
1297
+ ...(axisTooltip
1298
+ ? { axisPointer: { type: series.some((s) => s.type === "bar") ? "shadow" : "line" } }
1299
+ : {}),
1300
+ confine: true,
1301
+ ...tooltipStyle(theme),
1302
+ },
1303
+ legend: legendOption,
1304
+ color: palette,
1305
+ series,
1306
+ }
1307
+
1308
+ // ── Mode `dim` : estomper au lieu de retirer ────────────────────────────────
1309
+ // Tout est dessiné, les éléments non liés passent en opacité réduite et **l'état `select`
1310
+ // de la série ramène la sélection au plein contraste** (ECharts n'a pas d'état « non
1311
+ // sélectionné » : on baisse le style par défaut, la sélection le relève).
1312
+ // **Seulement s'il y a une sélection** : au repos, tout doit être à plein contraste
1313
+ const picked = config.selection
1314
+ const hasSelection =
1315
+ !!picked && (typeof picked !== "object" || picked.name !== undefined || picked.dataIndex !== undefined)
1316
+ if ((config.linkMode ?? "filter") === "dim" && hasSelection) {
1317
+ const dim = typeof config.dimOpacity === "number" ? config.dimOpacity : 0.25
1318
+ for (const one of option.series ?? []) {
1319
+ one.selectedMode = "single"
1320
+ // Seule l'opacité change : on ne touche ni au rayon (celui des barres vient du
1321
+ // traducteur : `borderRadius: [3, 3, 0, 0]`) ni au reste du style de la marque.
1322
+ one.itemStyle = { ...(one.itemStyle ?? {}), opacity: dim }
1323
+ if (one.type === "line" || one.type === "area") {
1324
+ one.lineStyle = { ...(one.lineStyle ?? {}), opacity: dim }
1325
+ }
1326
+ // Le style `select` **par défaut** du moteur pose `borderColor: primary, borderWidth: 2`
1327
+ // autour de l'élément sélectionné : on annule la **bordure** (largeur + couleur), et rien
1328
+ // d'autre — le rayon, lui, reste celui de la marque (les barres gardent leurs coins).
1329
+ const selectStyle = {
1330
+ itemStyle: { opacity: 1, borderColor: "transparent", borderWidth: 0 },
1331
+ }
1332
+ one.select =
1333
+ one.type === "line" || one.type === "area"
1334
+ ? { ...selectStyle, lineStyle: { opacity: 1 } }
1335
+ : selectStyle
1336
+ }
1337
+ }
1338
+
1339
+ return { ...option, ...(config.options ?? {}) }
1340
+ }