@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
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// QChart — graphique ECharts piloté par des **marks** :
|
|
3
|
+
// <q-chart :marks="[{ type: 'bar', data: rows, x: 'mois', y: 'ventes', fill: 'primary' }]" />
|
|
4
|
+
// Une marque est un objet `{ type, data, x, y, fill, … }` ; les canaux acceptent
|
|
5
|
+
// le nom d'un champ, des valeurs explicites ou une fonction d'accès. Types :
|
|
6
|
+
// `line`, `area`, `bar`, `dot`, `text`, `rule`. Voir `lib/chart.ts`.
|
|
7
|
+
//
|
|
8
|
+
// ECharts dessine en canvas → le graphe est monté côté client (onMounted) ; le
|
|
9
|
+
// conteneur, sa hauteur et son `aria-label` sortent dès le prerender.
|
|
10
|
+
</script>
|
|
11
|
+
|
|
12
|
+
<script setup lang="ts">
|
|
13
|
+
import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from "vue"
|
|
14
|
+
import { chartToECharts, COLOR_TOKENS } from "../lib/chart"
|
|
15
|
+
import { normalizeCssColor } from "../lib/color"
|
|
16
|
+
import { legendPickOf, nextLegendPick, pickFromEvent, sameSelection, tableModel } from "../lib/chart"
|
|
17
|
+
import type { QChartAxis, QChartColumn, QChartLegend, QChartMark, QChartPick } from "../lib/chart"
|
|
18
|
+
|
|
19
|
+
interface Props {
|
|
20
|
+
/** Marques à rendre : `{ type: 'line', data, x, y }`, `{ type: 'bar', … }`… */
|
|
21
|
+
marks?: QChartMark[]
|
|
22
|
+
/** Axe X (type, libellé, bornes, grille) */
|
|
23
|
+
x?: QChartAxis
|
|
24
|
+
/** Axe Y (type, libellé, bornes, grille) */
|
|
25
|
+
y?: QChartAxis
|
|
26
|
+
/** Hauteur du conteneur (nombre = px, sinon valeur CSS) */
|
|
27
|
+
height?: number | string
|
|
28
|
+
/** Titre du graphique */
|
|
29
|
+
title?: string
|
|
30
|
+
/** Palette (tokens `primary`, `secondary`… ou couleurs CSS) des séries non colorées */
|
|
31
|
+
colors?: string[]
|
|
32
|
+
/** Légende : `false` pour la masquer, `true` pour la forcer (défaut : dès qu'une série est
|
|
33
|
+
* nommée), ou `{ position: 'top' | 'bottom' | 'left' | 'right', offset, align }` */
|
|
34
|
+
legend?: boolean | QChartLegend
|
|
35
|
+
/** Info-bulle au survol (défaut : true) */
|
|
36
|
+
tooltip?: boolean
|
|
37
|
+
/** Réaction des marques liées à la sélection : `filter` (défaut) **retire** les données non
|
|
38
|
+
* liées, `dim` les **estompe** (`dimOpacity`) en gardant tout le contexte visible. */
|
|
39
|
+
linkMode?: "filter" | "dim"
|
|
40
|
+
/** Opacité des éléments non sélectionnés en mode `dim` (défaut : 0.25) */
|
|
41
|
+
dimOpacity?: number
|
|
42
|
+
/** **Groupe de liaison** : les `<q-chart>` d'un même groupe se synchronisent
|
|
43
|
+
* (`echarts.connect`) — survol/curseur d'axe, info-bulle, légende, zoom et mise en
|
|
44
|
+
* évidence d'un élément. */
|
|
45
|
+
group?: string
|
|
46
|
+
/** Élément **sélectionné** (`QChartPick`, typiquement le `@pick` d'un autre graphique) :
|
|
47
|
+
* les éléments qui portent ce `name` sont mis en évidence (`highlight`) dans toutes les
|
|
48
|
+
* séries. `null` efface la sélection. */
|
|
49
|
+
selected?: QChartPick | null
|
|
50
|
+
/** Options ECharts brutes, fusionnées en dernier (échappatoire) */
|
|
51
|
+
options?: Record<string, any>
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
55
|
+
marks: () => [],
|
|
56
|
+
height: 280,
|
|
57
|
+
legend: undefined,
|
|
58
|
+
tooltip: true,
|
|
59
|
+
// mêmes défauts que le traducteur : un composant qui n'est pas renseigné (la marque `table`,
|
|
60
|
+
// rendue en HTML, lit ses props directement) se comporte comme les graphiques.
|
|
61
|
+
linkMode: "filter",
|
|
62
|
+
dimOpacity: 0.25,
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
const emit = defineEmits<{
|
|
66
|
+
/** Émis une fois, avec l'instance ECharts */
|
|
67
|
+
ready: [chart: any]
|
|
68
|
+
/** Clic sur un élément du graphique (point, barre, part, cellule…) ou sur un élément de
|
|
69
|
+
* légende (`legend.action: 'select'`) — payload normalisé. `null` = sélection effacée
|
|
70
|
+
* (re-clic sur l'élément déjà sélectionné). */
|
|
71
|
+
pick: [pick: QChartPick | null]
|
|
72
|
+
/** La sélection vient d'être **effacée par l'utilisateur** — payload : l'élément lâché
|
|
73
|
+
* (`{ name, origin: 'legend', data }`). Émis juste avant le `@pick` à `null`, pour qui a
|
|
74
|
+
* besoin de savoir *ce qui* a été désélectionné (journal, requête, analytics). Jamais émis
|
|
75
|
+
* quand c'est l'application qui remet `selected` à `null` : ce serait un écho. */
|
|
76
|
+
unpick: [pick: QChartPick]
|
|
77
|
+
}>()
|
|
78
|
+
|
|
79
|
+
const el = ref<HTMLElement | null>(null)
|
|
80
|
+
const chart = shallowRef<any>(null)
|
|
81
|
+
let modulePromise: Promise<any> | null = null
|
|
82
|
+
let resizeObserver: ResizeObserver | undefined
|
|
83
|
+
let themeObserver: MutationObserver | undefined
|
|
84
|
+
|
|
85
|
+
/** Charge ECharts (modules enregistrés une seule fois, arbre secoué). */
|
|
86
|
+
const loadECharts = () => {
|
|
87
|
+
modulePromise ??= (async () => {
|
|
88
|
+
const [core, charts, components, renderers] = await Promise.all([
|
|
89
|
+
import("echarts/core"),
|
|
90
|
+
import("echarts/charts"),
|
|
91
|
+
import("echarts/components"),
|
|
92
|
+
import("echarts/renderers"),
|
|
93
|
+
])
|
|
94
|
+
core.use([
|
|
95
|
+
charts.BarChart,
|
|
96
|
+
charts.LineChart,
|
|
97
|
+
charts.ScatterChart,
|
|
98
|
+
charts.PieChart, // marque `pie` (camembert / anneau)
|
|
99
|
+
charts.HeatmapChart, // marque `heatmap` (carte de chaleur)
|
|
100
|
+
components.GridComponent,
|
|
101
|
+
components.TooltipComponent,
|
|
102
|
+
components.LegendComponent,
|
|
103
|
+
components.MarkLineComponent,
|
|
104
|
+
components.VisualMapComponent, // échelle de couleurs des cartes de chaleur
|
|
105
|
+
renderers.CanvasRenderer,
|
|
106
|
+
])
|
|
107
|
+
return core
|
|
108
|
+
})()
|
|
109
|
+
return modulePromise
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Canvas 2D de 1×1 px, mémoïsé : sert à **normaliser les couleurs CSS** (cf. `lib/color.ts`). */
|
|
113
|
+
let probeCtx: CanvasRenderingContext2D | null | undefined
|
|
114
|
+
const colorContext = () => {
|
|
115
|
+
if (probeCtx !== undefined) return probeCtx
|
|
116
|
+
if (typeof document === "undefined") return (probeCtx = null)
|
|
117
|
+
const canvas = document.createElement("canvas")
|
|
118
|
+
canvas.width = canvas.height = 1
|
|
119
|
+
probeCtx = canvas.getContext("2d")
|
|
120
|
+
return probeCtx
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Couleurs déjà normalisées (les tokens ne changent pas d'un rendu à l'autre) */
|
|
124
|
+
const colorCache = new Map<string, string>()
|
|
125
|
+
|
|
126
|
+
/** Normalise n'importe quelle couleur CSS (`oklch()`, `color-mix()`, `rgb(0 0 0 / .5)`) en
|
|
127
|
+
* `#rrggbb` / `rgba()`, la seule forme que le parseur de zrender (moteur d'ECharts) sait
|
|
128
|
+
* relire. Sans ça, `liftColor()` — appelé sur l'état de survol — renvoie `undefined` et
|
|
129
|
+
* l'élément survolé devient invisible (cf. `lib/color.ts`).
|
|
130
|
+
* Renvoie `undefined` si la couleur n'est pas reconnue (ou hors navigateur). */
|
|
131
|
+
const normalizeColor = (value: string): string | undefined => {
|
|
132
|
+
if (typeof value !== "string") return undefined
|
|
133
|
+
const cached = colorCache.get(value)
|
|
134
|
+
if (cached !== undefined) return cached
|
|
135
|
+
const out = normalizeCssColor(value, colorContext())
|
|
136
|
+
if (out !== undefined) colorCache.set(value, out)
|
|
137
|
+
return out
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Couleurs lues sur les tokens CSS du conteneur → suit clair/sombre et le thème hôte */
|
|
141
|
+
const themeOf = () => {
|
|
142
|
+
const node = el.value
|
|
143
|
+
if (!node || typeof getComputedStyle !== "function") return { theme: {}, tokens: {} }
|
|
144
|
+
const cs = getComputedStyle(node)
|
|
145
|
+
const read = (name: string) => cs.getPropertyValue(name).trim()
|
|
146
|
+
/** Valeur normalisée d'un token, ou `raw` (déjà lisible) */
|
|
147
|
+
const token = (name: string, fallback = "") => {
|
|
148
|
+
const raw = read(name) || fallback
|
|
149
|
+
return normalizeColor(raw) ?? raw
|
|
150
|
+
}
|
|
151
|
+
const tokens: Record<string, string> = {}
|
|
152
|
+
for (const name of COLOR_TOKENS) {
|
|
153
|
+
const raw = read(`--${name}`)
|
|
154
|
+
if (raw) tokens[name] = normalizeColor(raw) ?? raw
|
|
155
|
+
}
|
|
156
|
+
return {
|
|
157
|
+
theme: {
|
|
158
|
+
text: token("--foreground", "#1d1d1d"),
|
|
159
|
+
muted: token("--muted-foreground", "rgba(0, 0, 0, 0.55)"),
|
|
160
|
+
grid: token("--border", "rgba(0, 0, 0, 0.1)"),
|
|
161
|
+
},
|
|
162
|
+
tokens,
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const build = () => {
|
|
167
|
+
const { theme, tokens } = themeOf()
|
|
168
|
+
return chartToECharts({
|
|
169
|
+
marks: props.marks,
|
|
170
|
+
x: props.x,
|
|
171
|
+
y: props.y,
|
|
172
|
+
colors: props.colors,
|
|
173
|
+
title: props.title,
|
|
174
|
+
legend: props.legend,
|
|
175
|
+
tooltip: props.tooltip,
|
|
176
|
+
// Sélection partagée : nourrit la jointure des marques qui déclarent `link`
|
|
177
|
+
selection: props.selected ?? null,
|
|
178
|
+
// Ce que deviennent les lignes non liées : retirées (`filter`) ou estompées (`dim`)
|
|
179
|
+
linkMode: props.linkMode,
|
|
180
|
+
dimOpacity: props.dimOpacity,
|
|
181
|
+
options: props.options,
|
|
182
|
+
theme,
|
|
183
|
+
tokens,
|
|
184
|
+
normalizeColor,
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Relie un graphique à son groupe. ECharts veut le groupe **sur l'instance**
|
|
189
|
+
* (`chart.group`) ET le groupe déclaré comme connecté (`echarts.connect`) : l'un sans
|
|
190
|
+
* l'autre ne synchronise rien (vérifié au navigateur). Une fois les deux posés, les actions
|
|
191
|
+
* « partageables » déclenchées dans un graphique sont rejouées sur les autres charts du
|
|
192
|
+
* même groupe. */
|
|
193
|
+
const linkChart = (group: string, instance: any, api: any) => {
|
|
194
|
+
instance.group = group
|
|
195
|
+
api.connect(group)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const tableMark = computed(() => (props.marks ?? []).find((mark) => mark.type === "table"))
|
|
199
|
+
const table = computed(() =>
|
|
200
|
+
tableMark.value
|
|
201
|
+
? tableModel(tableMark.value, props.selected ?? null, (props.linkMode as "filter" | "dim") ?? "filter")
|
|
202
|
+
: null,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
/** Champ qui porte la clé partagée d'une ligne : le `localField` de `link`, sinon le premier
|
|
206
|
+
* champ de la première colonne. C'est ce qui alimente `@pick`, la sélection et la mise en
|
|
207
|
+
* évidence — une ligne se désigne par la même clé que les autres marques. */
|
|
208
|
+
const rowKey = (row: any) => {
|
|
209
|
+
const link = typeof tableMark.value?.link === "string" ? tableMark.value.link : tableMark.value?.link?.localField
|
|
210
|
+
const field = link ?? table.value?.columns[0]?.field
|
|
211
|
+
return field !== undefined && row ? row[field] : undefined
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Ligne **hors** sélection, en mode `dim` sur une table liée : elle reste affichée, estompée.
|
|
215
|
+
* Même règle que les séries : sans `link` la marque ne réagit pas, et sans sélection rien
|
|
216
|
+
* n'est estompé. */
|
|
217
|
+
const isDimmed = (row: any) =>
|
|
218
|
+
props.linkMode === "dim" && !!tableMark.value?.link && !!props.selected && !isSelected(row)
|
|
219
|
+
|
|
220
|
+
/** La ligne est-elle celle qui est sélectionnée ? (même clé, d'où qu'elle vienne) */
|
|
221
|
+
const isSelected = (row: any) => {
|
|
222
|
+
const key = rowKey(row)
|
|
223
|
+
const selected = props.selected
|
|
224
|
+
return key !== undefined && !!selected && typeof selected === "object" && String(selected.name) === String(key)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Valeur affichée d'une cellule : `format` de la colonne sinon la valeur telle quelle */
|
|
228
|
+
const cellValue = (column: QChartColumn, row: any) => {
|
|
229
|
+
const value = row?.[column.field]
|
|
230
|
+
const formatted = column.format?.(value, row)
|
|
231
|
+
if (formatted !== undefined) return formatted
|
|
232
|
+
return value === null || value === undefined ? "" : String(value)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Sélection émise pour un élément — avec la même règle de re-clic que le reste */
|
|
236
|
+
const emitPick = (pick: QChartPick) => {
|
|
237
|
+
if (sameSelection(props.selected, pick)) {
|
|
238
|
+
emit("unpick", pick)
|
|
239
|
+
emit("pick", null)
|
|
240
|
+
return
|
|
241
|
+
}
|
|
242
|
+
emit("pick", pick)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Clic sur une ligne de table : elle se désigne par sa clé partagée */
|
|
246
|
+
const pickRow = (row: any, dataIndex: number) => {
|
|
247
|
+
const markIndex = (props.marks ?? []).findIndex((mark) => mark.type === "table")
|
|
248
|
+
const name = rowKey(row)
|
|
249
|
+
emitPick({
|
|
250
|
+
...(name !== undefined ? { name } : {}),
|
|
251
|
+
markIndex,
|
|
252
|
+
markType: "table",
|
|
253
|
+
dataIndex,
|
|
254
|
+
origin: "mark",
|
|
255
|
+
data: row,
|
|
256
|
+
})
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const render = async () => {
|
|
260
|
+
// Une marque `table` remplace le tracé : on libère l'instance si elle existait
|
|
261
|
+
if (tableMark.value) {
|
|
262
|
+
chart.value?.dispose()
|
|
263
|
+
chart.value = undefined
|
|
264
|
+
return
|
|
265
|
+
}
|
|
266
|
+
if (!el.value || typeof window === "undefined") return
|
|
267
|
+
const echarts = await loadECharts()
|
|
268
|
+
if (!el.value) return
|
|
269
|
+
if (!chart.value) {
|
|
270
|
+
chart.value = echarts.init(el.value)
|
|
271
|
+
if (props.group) linkChart(props.group, chart.value, echarts)
|
|
272
|
+
// Clic sur un élément → `@pick` (payload normalisé, cf. `pickFromEvent`). Re-clic sur
|
|
273
|
+
// l'élément **déjà sélectionné** → désélection, comme pour une entrée de légende : les
|
|
274
|
+
// filtres croisés se relâchent (`@unpick` porte l'élément lâché, `@pick` porte `null`).
|
|
275
|
+
chart.value.on("click", (params: any) => {
|
|
276
|
+
// Le moteur ne transmet que sa forme interne (`data: { value: [catégorie, valeur] }`) :
|
|
277
|
+
// on **restaure la ligne d'origine** depuis `marks`, comme pour un clic de légende — c'est
|
|
278
|
+
// elle qui alimente les jointures `link.foreignField`, et c'est la doc qui l'annonce.
|
|
279
|
+
const engine = pickFromEvent(params)
|
|
280
|
+
if (engine.name === undefined && engine.dataIndex === undefined) return
|
|
281
|
+
const identity = legendPickOf(props.marks, String(engine.name), engine.markIndex)
|
|
282
|
+
const pick: QChartPick = {
|
|
283
|
+
...engine,
|
|
284
|
+
// `origin` reste celui du moteur (`mark`) : `legendPickOf` ne fait que compléter
|
|
285
|
+
// l'identité — la ligne d'origine, la position dans `marks`, la valeur (pas la paire
|
|
286
|
+
// interne `[catégorie, valeur]`).
|
|
287
|
+
origin: engine.origin ?? identity.origin,
|
|
288
|
+
markIndex: identity.markIndex ?? engine.markIndex,
|
|
289
|
+
markName: identity.markName ?? engine.markName,
|
|
290
|
+
markType: identity.markType ?? engine.markType,
|
|
291
|
+
dataIndex: identity.dataIndex ?? engine.dataIndex,
|
|
292
|
+
data: identity.data ?? engine.data,
|
|
293
|
+
value: identity.value ?? engine.value,
|
|
294
|
+
}
|
|
295
|
+
emitPick(pick)
|
|
296
|
+
})
|
|
297
|
+
// Clic sur la **légende** : avec `legend.action: 'select'`, la légende sert de sélecteur —
|
|
298
|
+
// ECharts a basculé l'élément (il masquerait la part/la série), on le rétablit puis on
|
|
299
|
+
// émet le pick ; un second clic sur le même nom efface la sélection.
|
|
300
|
+
chart.value.on("legendselectchanged", (event: any) => {
|
|
301
|
+
const legend = props.legend && typeof props.legend === "object" ? props.legend : undefined
|
|
302
|
+
if (legend?.action !== "select") return
|
|
303
|
+
const name = event?.name
|
|
304
|
+
if (name === undefined || name === null) return
|
|
305
|
+
// Le moteur a basculé l'entrée : on la rétablit, la sélection est notre affaire
|
|
306
|
+
if (event?.selected?.[name] === false) chart.value?.dispatchAction({ type: "legendSelect", name })
|
|
307
|
+
// Même identité qu'un clic sur un élément : marque (index, nom, type) + donnée (index,
|
|
308
|
+
// ligne, valeur) — le clic de légende ne porte que le nom.
|
|
309
|
+
const entry = legendPickOf(props.marks, name)
|
|
310
|
+
// Re-clic sur le nom courant → `null` (désélection) ; sinon on bascule sur ce nom.
|
|
311
|
+
// La désélection est signalée deux fois : `@unpick` porte l'élément lâché, `@pick`
|
|
312
|
+
// porte l'état (`null`).
|
|
313
|
+
const next = nextLegendPick(props.selected, name, entry.data)
|
|
314
|
+
if (next === null) emit("unpick", entry)
|
|
315
|
+
emit("pick", next === null ? null : entry)
|
|
316
|
+
})
|
|
317
|
+
emit("ready", chart.value)
|
|
318
|
+
}
|
|
319
|
+
chart.value.setOption(build(), { notMerge: true })
|
|
320
|
+
chart.value.resize()
|
|
321
|
+
applySelection()
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
onMounted(async () => {
|
|
325
|
+
await render()
|
|
326
|
+
if (el.value && typeof ResizeObserver !== "undefined") {
|
|
327
|
+
resizeObserver = new ResizeObserver(() => chart.value?.resize())
|
|
328
|
+
resizeObserver.observe(el.value)
|
|
329
|
+
}
|
|
330
|
+
// Le basculement clair/sombre change une classe sur <html> : on relit les tokens
|
|
331
|
+
if (typeof MutationObserver !== "undefined" && typeof document !== "undefined") {
|
|
332
|
+
themeObserver = new MutationObserver(() => {
|
|
333
|
+
chart.value?.setOption(build(), { notMerge: true })
|
|
334
|
+
})
|
|
335
|
+
themeObserver.observe(document.documentElement, {
|
|
336
|
+
attributes: true,
|
|
337
|
+
attributeFilter: ["class", "data-theme"],
|
|
338
|
+
})
|
|
339
|
+
}
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
watch(
|
|
343
|
+
// `selected` en fait partie : la sélection change **l'option** (lignes jointes filtrées ou
|
|
344
|
+
// estompées) — sans lui, les autres graphiques liés ne suivaient pas.
|
|
345
|
+
() => [
|
|
346
|
+
props.marks,
|
|
347
|
+
props.selected,
|
|
348
|
+
props.x,
|
|
349
|
+
props.y,
|
|
350
|
+
props.title,
|
|
351
|
+
props.colors,
|
|
352
|
+
props.legend,
|
|
353
|
+
props.tooltip,
|
|
354
|
+
props.options,
|
|
355
|
+
],
|
|
356
|
+
async () => {
|
|
357
|
+
// `nextTick` : le template a pu passer d'une table à un tracé (ou l'inverse)
|
|
358
|
+
await nextTick()
|
|
359
|
+
void render()
|
|
360
|
+
},
|
|
361
|
+
{ deep: true },
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
// Le groupe peut arriver après le montage (`:group="groupe"` calculé) : on relie alors les
|
|
365
|
+
// graphiques déjà montés du même groupe.
|
|
366
|
+
watch(
|
|
367
|
+
() => props.group,
|
|
368
|
+
async (group) => {
|
|
369
|
+
if (!group) return
|
|
370
|
+
const echarts = await loadECharts()
|
|
371
|
+
if (props.group === group && chart.value) linkChart(group, chart.value, echarts)
|
|
372
|
+
},
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
/** Sélection venue de l'extérieur (souvent le `@pick` d'un autre graphique) → mise en
|
|
376
|
+
* évidence des éléments qui portent ce nom, dans toutes les séries. */
|
|
377
|
+
const applySelection = () => {
|
|
378
|
+
// ⚠️ ne pas nommer la locale `chart` : elle masquerait la ref du même nom (TDZ).
|
|
379
|
+
const instance = chart.value
|
|
380
|
+
if (!instance) return
|
|
381
|
+
// En mode `dim`, c'est l'état **`select`** qui ramène la sélection au plein contraste (les
|
|
382
|
+
// autres gardent l'opacité réduite) ; sinon c'est `highlight`. Les deux locales sont
|
|
383
|
+
// déclarées **avant** tout usage (piège TDZ).
|
|
384
|
+
const dim = props.linkMode === "dim"
|
|
385
|
+
const selection = props.selected
|
|
386
|
+
instance.dispatchAction({ type: dim ? "unselect" : "downplay" })
|
|
387
|
+
if (!selection || typeof selection !== "object") return
|
|
388
|
+
// Le `name` est la clé partagée entre graphiques : visé dans toutes les séries.
|
|
389
|
+
// Sans nom (sélection réduite à une identité), on cible l'élément précis.
|
|
390
|
+
if (selection.name !== undefined) {
|
|
391
|
+
instance.dispatchAction(dim ? { type: "select", name: selection.name } : { type: "highlight", name: selection.name })
|
|
392
|
+
} else if (selection.markIndex !== undefined && selection.dataIndex !== undefined) {
|
|
393
|
+
const target = { seriesIndex: selection.markIndex, dataIndex: selection.dataIndex }
|
|
394
|
+
instance.dispatchAction(dim ? { type: "select", ...target } : { type: "highlight", ...target })
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
watch(() => props.selected, applySelection)
|
|
399
|
+
|
|
400
|
+
onBeforeUnmount(() => {
|
|
401
|
+
resizeObserver?.disconnect()
|
|
402
|
+
themeObserver?.disconnect()
|
|
403
|
+
chart.value?.dispose()
|
|
404
|
+
chart.value = null
|
|
405
|
+
})
|
|
406
|
+
|
|
407
|
+
defineExpose({
|
|
408
|
+
/** Instance ECharts (échappatoire : `chart.value?.dispatchAction(...)`) */
|
|
409
|
+
chart,
|
|
410
|
+
/** Force un nouveau rendu */
|
|
411
|
+
refresh: () => void render(),
|
|
412
|
+
})
|
|
413
|
+
</script>
|
|
414
|
+
|
|
415
|
+
<template>
|
|
416
|
+
<!-- Marque `table` : les données en HTML (sélection, jointure et `@pick` comme le reste) -->
|
|
417
|
+
<div
|
|
418
|
+
v-if="table"
|
|
419
|
+
class="q-chart q-chart--table"
|
|
420
|
+
:style="{ maxHeight: typeof height === 'number' ? `${height}px` : height }"
|
|
421
|
+
role="table"
|
|
422
|
+
:aria-label="title || 'Table'"
|
|
423
|
+
>
|
|
424
|
+
<table :class="`is-${table.separator}`">
|
|
425
|
+
<thead>
|
|
426
|
+
<tr>
|
|
427
|
+
<th v-for="column in table.columns" :key="column.field" :style="{ textAlign: column.align }">
|
|
428
|
+
{{ column.label }}
|
|
429
|
+
</th>
|
|
430
|
+
</tr>
|
|
431
|
+
</thead>
|
|
432
|
+
<tbody>
|
|
433
|
+
<tr
|
|
434
|
+
v-for="(row, index) in table.rows"
|
|
435
|
+
:key="index"
|
|
436
|
+
:class="{ 'is-selected': isSelected(row), 'is-dimmed': isDimmed(row) }"
|
|
437
|
+
:style="isDimmed(row) ? { opacity: dimOpacity } : undefined"
|
|
438
|
+
@click="pickRow(row, index)"
|
|
439
|
+
>
|
|
440
|
+
<td v-for="column in table.columns" :key="column.field" :style="{ textAlign: column.align }">
|
|
441
|
+
{{ cellValue(column, row) }}
|
|
442
|
+
</td>
|
|
443
|
+
</tr>
|
|
444
|
+
</tbody>
|
|
445
|
+
</table>
|
|
446
|
+
<p v-if="table.rows.length === 0" class="q-chart__empty">No rows</p>
|
|
447
|
+
</div>
|
|
448
|
+
|
|
449
|
+
<div
|
|
450
|
+
v-else
|
|
451
|
+
ref="el"
|
|
452
|
+
class="q-chart"
|
|
453
|
+
:style="{ height: typeof height === 'number' ? `${height}px` : height }"
|
|
454
|
+
role="img"
|
|
455
|
+
:aria-label="title || 'Chart'"
|
|
456
|
+
></div>
|
|
457
|
+
</template>
|
|
458
|
+
|
|
459
|
+
<style scoped>
|
|
460
|
+
/* Marque `table` : mêmes jetons que le reste, en-tête collant, chiffres alignés.
|
|
461
|
+
Les **séparateurs** se règlent par marque (`separator`) : `horizontal` par défaut, puis
|
|
462
|
+
`vertical`, `grid` (les deux) ou `none`. */
|
|
463
|
+
.q-chart--table {
|
|
464
|
+
overflow: auto;
|
|
465
|
+
font-size: 13px;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
.q-chart--table table {
|
|
469
|
+
width: 100%;
|
|
470
|
+
border-collapse: collapse;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
.q-chart--table th {
|
|
474
|
+
position: sticky;
|
|
475
|
+
top: 0;
|
|
476
|
+
z-index: 1;
|
|
477
|
+
padding: 6px 10px;
|
|
478
|
+
font-weight: 600;
|
|
479
|
+
color: var(--muted-foreground, #71717a);
|
|
480
|
+
text-align: start;
|
|
481
|
+
border-bottom: 1px solid var(--border, #e4e4e7);
|
|
482
|
+
background: var(--card, #fff);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
.q-chart--table td {
|
|
486
|
+
padding: 6px 10px;
|
|
487
|
+
font-variant-numeric: tabular-nums;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/* `horizontal` (défaut) — un trait sous chaque rangée (l'en-tête garde le sien, plus marqué) */
|
|
491
|
+
.q-chart--table table.is-horizontal td {
|
|
492
|
+
border-bottom: 1px solid var(--border, #f1f1f3);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/* `vertical` — un trait entre les colonnes */
|
|
496
|
+
.q-chart--table table.is-vertical th + th,
|
|
497
|
+
.q-chart--table table.is-vertical td + td {
|
|
498
|
+
border-left: 1px solid var(--border, #f1f1f3);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/* `grid` — les deux, en gardant l'en-tête plus marqué */
|
|
502
|
+
.q-chart--table table.is-grid th,
|
|
503
|
+
.q-chart--table table.is-grid td {
|
|
504
|
+
border: 1px solid var(--border, #f1f1f3);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/* `none` — plus aucun trait de rangée (l'en-tête garde le sien) */
|
|
508
|
+
.q-chart--table table.is-none td {
|
|
509
|
+
border-bottom: 0;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
.q-chart--table tbody tr {
|
|
513
|
+
cursor: pointer;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
.q-chart--table tbody tr:hover {
|
|
517
|
+
background: var(--accent, #f4f4f5);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
.q-chart--table tbody tr.is-selected {
|
|
521
|
+
box-shadow: inset 2px 0 0 var(--primary, #2563eb);
|
|
522
|
+
background: var(--accent, #f4f4f5);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
.q-chart__empty {
|
|
526
|
+
margin: 8px 10px;
|
|
527
|
+
font-size: 12px;
|
|
528
|
+
color: var(--muted-foreground, #71717a);
|
|
529
|
+
}
|
|
530
|
+
.q-chart {
|
|
531
|
+
width: 100%;
|
|
532
|
+
}
|
|
533
|
+
</style>
|