@dnax/ui 0.0.43 → 0.0.44
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 +533 -0
- package/components/QDatePicker.vue +95 -14
- package/components/internal/QDateCalendar.vue +112 -19
- package/index.ts +21 -1
- package/lib/chart.test.ts +991 -0
- package/lib/chart.ts +1340 -0
- package/lib/color.test.ts +81 -0
- package/lib/color.ts +60 -0
- package/lib/datePicker.test.ts +122 -0
- package/lib/datePicker.ts +114 -0
- package/package.json +2 -9
- package/scripts/generate-exports.ts +13 -0
- package/styles/main.css +251 -130
- package/components/QEditorJs.vue +0 -146
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
|
+
}
|