@dnax/ui 0.0.67 → 0.0.70
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/QChart.vue +552 -27
- package/components/QSpreadsheet.vue +43 -18
- package/index.ts +20 -1
- package/lib/chart.axis.test.ts +1 -1
- package/lib/chart.bi.test.ts +839 -0
- package/lib/chart.modules.test.ts +232 -0
- package/lib/chart.test.ts +13 -4
- package/lib/chart.ts +1451 -80
- package/lib/chartFormat.ts +222 -0
- package/lib/chartStats.ts +178 -0
- package/lib/chartTable.test.ts +140 -0
- package/lib/chartTable.ts +193 -0
- package/lib/history.test.ts +78 -0
- package/lib/history.ts +72 -0
- package/package.json +1 -1
package/components/QChart.vue
CHANGED
|
@@ -11,18 +11,36 @@
|
|
|
11
11
|
|
|
12
12
|
<script setup lang="ts">
|
|
13
13
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from "vue"
|
|
14
|
-
import { chartToECharts, COLOR_TOKENS } from "../lib/chart"
|
|
14
|
+
import { chartToECharts, COLOR_TOKENS, tokenCss } from "../lib/chart"
|
|
15
15
|
import { normalizeCssColor } from "../lib/color"
|
|
16
|
-
import { legendPickOf, nextLegendPick, pickFromEvent, sameSelection, tableModel } from "../lib/chart"
|
|
17
|
-
import
|
|
16
|
+
import { legendPickOf, nextLegendPick, pickFromEvent, sameSelection, tableCsv, tableModel } from "../lib/chart"
|
|
17
|
+
import { cellBarGeometry, cellVisualOf, sparklineGeometry } from "../lib/chartTable"
|
|
18
|
+
import { formatValue } from "../lib/chartFormat"
|
|
19
|
+
import { useConfigLang } from "../lib/useComponentProps"
|
|
20
|
+
import type {
|
|
21
|
+
QChartAxis,
|
|
22
|
+
QChartCellVisual,
|
|
23
|
+
QChartColumn,
|
|
24
|
+
QChartLegend,
|
|
25
|
+
QChartMark,
|
|
26
|
+
QChartPick,
|
|
27
|
+
QChartTooltip,
|
|
28
|
+
} from "../lib/chart"
|
|
18
29
|
|
|
19
30
|
interface Props {
|
|
20
31
|
/** Marques à rendre : `{ type: 'line', data, x, y }`, `{ type: 'bar', … }`… */
|
|
21
32
|
marks?: QChartMark[]
|
|
22
|
-
/** Axe X (type, libellé, bornes, grille) */
|
|
33
|
+
/** Axe X (type, libellé, bornes, grille, format, granularité temporelle) */
|
|
23
34
|
x?: QChartAxis
|
|
24
|
-
/** Axe Y (type, libellé, bornes, grille) */
|
|
35
|
+
/** Axe Y (type, libellé, bornes, grille, format) */
|
|
25
36
|
y?: QChartAxis
|
|
37
|
+
/** Axe Y **secondaire**, à droite — pour un combo volume (barres) + taux (ligne) :
|
|
38
|
+
* la marque visée déclare `yAxis: 'right'`. */
|
|
39
|
+
yRight?: QChartAxis
|
|
40
|
+
/** Locale des nombres / dates (`fr-FR` par défaut, sinon celle du provider) */
|
|
41
|
+
locale?: string
|
|
42
|
+
/** Mode **compact** (mini-widget) : marges réduites, axes et légende masqués par défaut */
|
|
43
|
+
compact?: boolean
|
|
26
44
|
/** Hauteur du conteneur (nombre = px, sinon valeur CSS) */
|
|
27
45
|
height?: number | string
|
|
28
46
|
/** Titre du graphique */
|
|
@@ -32,8 +50,9 @@ interface Props {
|
|
|
32
50
|
/** Légende : `false` pour la masquer, `true` pour la forcer (défaut : dès qu'une série est
|
|
33
51
|
* nommée), ou `{ position: 'top' | 'bottom' | 'left' | 'right', offset, align }` */
|
|
34
52
|
legend?: boolean | QChartLegend
|
|
35
|
-
/** Info-bulle au survol (défaut : true)
|
|
36
|
-
|
|
53
|
+
/** Info-bulle au survol (défaut : true) — `false` l'éteint, un objet la personnalise
|
|
54
|
+
* (`formatter`, `valueFormatter`, `sortDesc`) */
|
|
55
|
+
tooltip?: boolean | QChartTooltip
|
|
37
56
|
/** Réaction des marques liées à la sélection : `filter` (défaut) **retire** les données non
|
|
38
57
|
* liées, `dim` les **estompe** (`dimOpacity`) en gardant tout le contexte visible. */
|
|
39
58
|
linkMode?: "filter" | "dim"
|
|
@@ -47,6 +66,18 @@ interface Props {
|
|
|
47
66
|
* les éléments qui portent ce `name` sont mis en évidence (`highlight`) dans toutes les
|
|
48
67
|
* séries. `null` efface la sélection. */
|
|
49
68
|
selected?: QChartPick | null
|
|
69
|
+
/** État **chargement** : superpose un message (slot `loading`) */
|
|
70
|
+
loading?: boolean
|
|
71
|
+
/** État **erreur** : `true` (message localisé) ou un texte à afficher (slot `error`) */
|
|
72
|
+
error?: string | boolean
|
|
73
|
+
/** État **vide** forcé (sinon détecté : aucune donnée) — slot `empty` */
|
|
74
|
+
empty?: boolean
|
|
75
|
+
/** Textes des états et de la table (prioritaires sur l'i18n du provider) */
|
|
76
|
+
texts?: Partial<
|
|
77
|
+
Record<"loading" | "empty" | "error" | "exportCsv" | "total" | "page" | "sortAscending" | "sortDescending", string>
|
|
78
|
+
>
|
|
79
|
+
/** Marque `table` : bouton d'**export CSV** */
|
|
80
|
+
exportable?: boolean
|
|
50
81
|
/** Options ECharts brutes, fusionnées en dernier (échappatoire) */
|
|
51
82
|
options?: Record<string, any>
|
|
52
83
|
}
|
|
@@ -76,6 +107,37 @@ const emit = defineEmits<{
|
|
|
76
107
|
unpick: [pick: QChartPick]
|
|
77
108
|
}>()
|
|
78
109
|
|
|
110
|
+
// ─── i18n des états et de la table ────────────────────────────────────────────
|
|
111
|
+
const I18N = {
|
|
112
|
+
en: {
|
|
113
|
+
loading: "Loading…",
|
|
114
|
+
empty: "No data",
|
|
115
|
+
error: "Unable to display the chart",
|
|
116
|
+
exportCsv: "Export CSV",
|
|
117
|
+
total: "Total",
|
|
118
|
+
page: "Page {n} / {m}",
|
|
119
|
+
sortAscending: "Sort ascending",
|
|
120
|
+
sortDescending: "Sort descending",
|
|
121
|
+
},
|
|
122
|
+
fr: {
|
|
123
|
+
loading: "Chargement…",
|
|
124
|
+
empty: "Aucune donnée",
|
|
125
|
+
error: "Impossible d'afficher le graphique",
|
|
126
|
+
exportCsv: "Exporter en CSV",
|
|
127
|
+
total: "Total",
|
|
128
|
+
page: "Page {n} / {m}",
|
|
129
|
+
sortAscending: "Tri croissant",
|
|
130
|
+
sortDescending: "Tri décroissant",
|
|
131
|
+
},
|
|
132
|
+
} as const
|
|
133
|
+
type UiText = keyof (typeof I18N)["en"]
|
|
134
|
+
const configLang = useConfigLang()
|
|
135
|
+
const t = (key: UiText): string => props.texts?.[key] ?? I18N[configLang.value === "fr" ? "fr" : "en"][key]
|
|
136
|
+
const fmtPage = (n: number, total: number) =>
|
|
137
|
+
t("page").replace("{n}", String(n)).replace("{m}", String(total))
|
|
138
|
+
/** Locale des nombres / dates : prop, sinon celle du provider (`fr` → `fr-FR`) */
|
|
139
|
+
const locale = computed(() => props.locale ?? (configLang.value === "fr" ? "fr-FR" : "en-US"))
|
|
140
|
+
|
|
79
141
|
const el = ref<HTMLElement | null>(null)
|
|
80
142
|
const chart = shallowRef<any>(null)
|
|
81
143
|
let modulePromise: Promise<any> | null = null
|
|
@@ -85,11 +147,12 @@ let themeObserver: MutationObserver | undefined
|
|
|
85
147
|
/** Charge ECharts (modules enregistrés une seule fois, arbre secoué). */
|
|
86
148
|
const loadECharts = () => {
|
|
87
149
|
modulePromise ??= (async () => {
|
|
88
|
-
const [core, charts, components, renderers] = await Promise.all([
|
|
150
|
+
const [core, charts, components, renderers, features] = await Promise.all([
|
|
89
151
|
import("echarts/core"),
|
|
90
152
|
import("echarts/charts"),
|
|
91
153
|
import("echarts/components"),
|
|
92
154
|
import("echarts/renderers"),
|
|
155
|
+
import("echarts/features"),
|
|
93
156
|
])
|
|
94
157
|
core.use([
|
|
95
158
|
charts.BarChart,
|
|
@@ -97,13 +160,25 @@ const loadECharts = () => {
|
|
|
97
160
|
charts.ScatterChart,
|
|
98
161
|
charts.PieChart, // marque `pie` (camembert / anneau)
|
|
99
162
|
charts.HeatmapChart, // marque `heatmap` (carte de chaleur)
|
|
163
|
+
charts.BoxplotChart, // marque `boxplot` (boîte à moustaches)
|
|
164
|
+
charts.GaugeChart, // marque `gauge` (jauge)
|
|
165
|
+
charts.FunnelChart, // marque `funnel` (entonnoir)
|
|
166
|
+
charts.RadarChart, // marque `radar` (multicritères)
|
|
167
|
+
charts.TreemapChart, // marque `treemap`
|
|
168
|
+
charts.SunburstChart, // marque `sunburst`
|
|
169
|
+
charts.SankeyChart, // marque `sankey` (flux)
|
|
100
170
|
components.GridComponent,
|
|
171
|
+
components.RadarComponent, // coordonnée polaire de la marque `radar`
|
|
101
172
|
components.TooltipComponent,
|
|
102
173
|
components.LegendComponent,
|
|
103
174
|
components.TitleComponent, // titre (`title` prop)
|
|
104
175
|
components.MarkLineComponent,
|
|
105
|
-
components.
|
|
176
|
+
components.MarkAreaComponent, // marque `band` (zones de seuil)
|
|
177
|
+
components.VisualMapComponent, // échelles de couleurs (heatmap, `fill` numérique d'un `dot`)
|
|
106
178
|
renderers.CanvasRenderer,
|
|
179
|
+
// ECharts 6 : `grid.containLabel` vit désormais dans un module de compatibilité —
|
|
180
|
+
// sans lui l'option est ignorée **silencieusement** (les libellés débordent du cadre).
|
|
181
|
+
features.LegacyGridContainLabel,
|
|
107
182
|
])
|
|
108
183
|
return core
|
|
109
184
|
})()
|
|
@@ -170,6 +245,9 @@ const build = () => {
|
|
|
170
245
|
marks: props.marks,
|
|
171
246
|
x: props.x,
|
|
172
247
|
y: props.y,
|
|
248
|
+
yRight: props.yRight,
|
|
249
|
+
locale: locale.value,
|
|
250
|
+
compact: props.compact,
|
|
173
251
|
colors: props.colors,
|
|
174
252
|
title: props.title,
|
|
175
253
|
legend: props.legend,
|
|
@@ -197,12 +275,91 @@ const linkChart = (group: string, instance: any, api: any) => {
|
|
|
197
275
|
}
|
|
198
276
|
|
|
199
277
|
const tableMark = computed(() => (props.marks ?? []).find((mark) => mark.type === "table"))
|
|
278
|
+
|
|
279
|
+
/** État de vue **interactif** de la table (tri, page) — prioritaire sur les options de la marque */
|
|
280
|
+
const sortState = ref<{ field: string; desc: boolean } | null>(null)
|
|
281
|
+
const pageState = ref(0)
|
|
282
|
+
|
|
200
283
|
const table = computed(() =>
|
|
201
284
|
tableMark.value
|
|
202
|
-
? tableModel(tableMark.value, props.selected ?? null, (props.linkMode as "filter" | "dim") ?? "filter"
|
|
285
|
+
? tableModel(tableMark.value, props.selected ?? null, (props.linkMode as "filter" | "dim") ?? "filter", {
|
|
286
|
+
...(sortState.value ? { sortBy: sortState.value.field, sortDesc: sortState.value.desc } : {}),
|
|
287
|
+
...(pageState.value ? { page: pageState.value } : {}),
|
|
288
|
+
})
|
|
203
289
|
: null,
|
|
204
290
|
)
|
|
205
291
|
|
|
292
|
+
/** Style d'une colonne (largeur fixe, alignement) */
|
|
293
|
+
const columnStyle = (column: QChartColumn) => ({
|
|
294
|
+
textAlign: column.align,
|
|
295
|
+
...(column.width !== undefined
|
|
296
|
+
? { width: typeof column.width === "number" ? `${column.width}px` : column.width }
|
|
297
|
+
: {}),
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
/** Tri suivant : `asc` → `desc` → aucun (l'ordre d'origine revient) */
|
|
301
|
+
const isSortable = (column: QChartColumn) =>
|
|
302
|
+
column.sortable ?? cellVisualOf(column.cell)?.type !== "sparkline"
|
|
303
|
+
|
|
304
|
+
const toggleSort = (column: QChartColumn) => {
|
|
305
|
+
if (!isSortable(column)) return
|
|
306
|
+
const base =
|
|
307
|
+
sortState.value ??
|
|
308
|
+
(tableMark.value?.sortBy
|
|
309
|
+
? { field: tableMark.value.sortBy, desc: !!tableMark.value.sortDesc }
|
|
310
|
+
: null)
|
|
311
|
+
if (!base || base.field !== column.field) sortState.value = { field: column.field, desc: false }
|
|
312
|
+
else if (!base.desc) sortState.value = { field: column.field, desc: true }
|
|
313
|
+
else sortState.value = null
|
|
314
|
+
pageState.value = 0
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const sortIndicator = (column: QChartColumn) => {
|
|
318
|
+
const current = sortState.value ?? (tableMark.value?.sortBy ? { field: tableMark.value.sortBy, desc: !!tableMark.value.sortDesc } : null)
|
|
319
|
+
if (!current || current.field !== column.field) return ""
|
|
320
|
+
return current.desc ? "\u2193" : "\u2191"
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const sortTitle = (column: QChartColumn) => {
|
|
324
|
+
if (!isSortable(column)) return ""
|
|
325
|
+
return sortIndicator(column) === "↑" ? t("sortDescending") : t("sortAscending")
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const goPage = (delta: number) => {
|
|
329
|
+
const model = table.value
|
|
330
|
+
if (!model?.pages) return
|
|
331
|
+
pageState.value = Math.max(0, Math.min(model.pages - 1, (model.page ?? 0) + delta))
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Export CSV de **toutes** les lignes visibles (pas seulement la page) */
|
|
335
|
+
const exportCsv = () => {
|
|
336
|
+
const model = table.value
|
|
337
|
+
if (!model || typeof window === "undefined" || typeof document === "undefined") return
|
|
338
|
+
const csv = tableCsv(model, { label: t("total") })
|
|
339
|
+
const blob = new Blob([csv], { type: "text/csv;charset=utf-8" })
|
|
340
|
+
const url = URL.createObjectURL(blob)
|
|
341
|
+
const slug =
|
|
342
|
+
(props.title ?? "chart")
|
|
343
|
+
.toLowerCase()
|
|
344
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
345
|
+
.replace(/^-|-$/g, "") || "chart"
|
|
346
|
+
const anchor = document.createElement("a")
|
|
347
|
+
anchor.href = url
|
|
348
|
+
anchor.download = `${slug}.csv`
|
|
349
|
+
anchor.click()
|
|
350
|
+
URL.revokeObjectURL(url)
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Les données changent → le tri et la page repartent de zéro (sinon une page vide peut rester)
|
|
354
|
+
watch(
|
|
355
|
+
() => props.marks,
|
|
356
|
+
() => {
|
|
357
|
+
sortState.value = null
|
|
358
|
+
pageState.value = 0
|
|
359
|
+
},
|
|
360
|
+
{ deep: true },
|
|
361
|
+
)
|
|
362
|
+
|
|
206
363
|
/** Champ qui porte la clé partagée d'une ligne : le `localField` de `link`, sinon le premier
|
|
207
364
|
* champ de la première colonne. C'est ce qui alimente `@pick`, la sélection et la mise en
|
|
208
365
|
* évidence — une ligne se désigne par la même clé que les autres marques. */
|
|
@@ -225,12 +382,102 @@ const isSelected = (row: any) => {
|
|
|
225
382
|
return key !== undefined && !!selected && typeof selected === "object" && String(selected.name) === String(key)
|
|
226
383
|
}
|
|
227
384
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
385
|
+
// ─── Micro-visualisations de cellule (barres / delta / heat / sparkline) ──────
|
|
386
|
+
|
|
387
|
+
/** Cellule prête à rendre (le composant ne calcule plus rien dans le template) */
|
|
388
|
+
interface RenderedCell {
|
|
389
|
+
kind: "text" | "bar" | "delta" | "heat" | "sparkline"
|
|
390
|
+
text: string
|
|
391
|
+
bar?: { style: Record<string, string | number> }
|
|
392
|
+
textStyle?: Record<string, string>
|
|
393
|
+
arrow?: string
|
|
394
|
+
spark?: { path: string; areaPath: string; marker?: { x: number; y: number } }
|
|
395
|
+
lineColor: string
|
|
396
|
+
pointColor: string
|
|
397
|
+
area: boolean
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** Couleur CSS d'un token (`primary` → `var(--primary, #1976d2)`) ou d'une couleur libre */
|
|
401
|
+
const cssColor = (value: string | undefined, fallback: string) => tokenCss(value, fallback)
|
|
402
|
+
|
|
403
|
+
/** Rend une cellule : texte + (optionnellement) une barre, un delta ou une sparkline.
|
|
404
|
+
*
|
|
405
|
+
* `override` sert à la ligne de **totaux** : on lui donne la valeur brute agrégée et le
|
|
406
|
+
* texte déjà formaté par `tableModel`. */
|
|
407
|
+
const renderCell = (
|
|
408
|
+
column: QChartColumn,
|
|
409
|
+
row: any,
|
|
410
|
+
override?: { value?: any; text?: string },
|
|
411
|
+
): RenderedCell => {
|
|
412
|
+
const visual: QChartCellVisual | undefined = cellVisualOf(column.cell)
|
|
413
|
+
const rawValue = override ? override.value : row?.[column.field]
|
|
414
|
+
const numeric = typeof rawValue === "number" && Number.isFinite(rawValue)
|
|
415
|
+
/** Un `delta` affiche la valeur **absolue** : la flèche porte le signe */
|
|
416
|
+
const shown = visual?.type === "delta" && visual.absolute !== false && numeric ? Math.abs(rawValue) : rawValue
|
|
417
|
+
const text =
|
|
418
|
+
override?.text ??
|
|
419
|
+
column.format?.(shown, row) ??
|
|
420
|
+
(visual?.format ? formatValue(shown, visual.format, { locale: locale.value }) : undefined) ??
|
|
421
|
+
// Sparkline : le texte de repli (accessibilité, CSV) est la **dernière valeur**
|
|
422
|
+
(Array.isArray(shown)
|
|
423
|
+
? shown.length
|
|
424
|
+
? String(shown[shown.length - 1])
|
|
425
|
+
: ""
|
|
426
|
+
: shown === null || shown === undefined
|
|
427
|
+
? ""
|
|
428
|
+
: String(shown))
|
|
429
|
+
|
|
430
|
+
if (visual?.type === "sparkline") {
|
|
431
|
+
const series = override
|
|
432
|
+
? undefined
|
|
433
|
+
: visual.values
|
|
434
|
+
? visual.values(row)
|
|
435
|
+
: visual.field
|
|
436
|
+
? row?.[visual.field]
|
|
437
|
+
: undefined
|
|
438
|
+
const spark = sparklineGeometry(Array.isArray(series) ? series : [], { point: visual.point ?? "last" })
|
|
439
|
+
return {
|
|
440
|
+
kind: "sparkline",
|
|
441
|
+
text,
|
|
442
|
+
spark,
|
|
443
|
+
lineColor: cssColor(visual.color, "var(--muted-foreground, #71717a)"),
|
|
444
|
+
pointColor: cssColor(visual.pointColor ?? visual.negativeColor, "var(--negative, #c10015)"),
|
|
445
|
+
area: visual.area === true,
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
if (visual) {
|
|
450
|
+
const scale = table.value?.cellScales?.[column.field]
|
|
451
|
+
const bar = numeric && scale ? cellBarGeometry(rawValue, visual, scale) : undefined
|
|
452
|
+
const positive = cssColor(
|
|
453
|
+
visual.color,
|
|
454
|
+
visual.type === "delta" ? "var(--primary, #1976d2)" : "var(--muted-foreground, #71717a)",
|
|
455
|
+
)
|
|
456
|
+
const negative = cssColor(visual.negativeColor, "var(--negative, #c10015)")
|
|
457
|
+
if (bar) {
|
|
458
|
+
const color = bar.sign < 0 ? negative : positive
|
|
459
|
+
const opacity = visual.opacity ?? (visual.type === "delta" ? 0.85 : visual.type === "heat" ? bar.ratio : 0.18)
|
|
460
|
+
const showArrow = (visual.arrow ?? visual.type === "delta") && bar.sign !== 0
|
|
461
|
+
return {
|
|
462
|
+
kind: visual.type,
|
|
463
|
+
text,
|
|
464
|
+
bar: { style: { left: `${bar.left}%`, width: `${bar.width}%`, background: color, opacity } },
|
|
465
|
+
...(visual.type === "delta" && bar.sign !== 0 ? { textStyle: { color } } : {}),
|
|
466
|
+
...(showArrow ? { arrow: bar.sign > 0 ? "▲" : "▼" } : {}),
|
|
467
|
+
lineColor: color,
|
|
468
|
+
pointColor: negative,
|
|
469
|
+
area: false,
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
return {
|
|
475
|
+
kind: "text",
|
|
476
|
+
text,
|
|
477
|
+
lineColor: "currentColor",
|
|
478
|
+
pointColor: "var(--negative, #c10015)",
|
|
479
|
+
area: false,
|
|
480
|
+
}
|
|
234
481
|
}
|
|
235
482
|
|
|
236
483
|
/** Sélection émise pour un élément — avec la même règle de re-clic que le reste */
|
|
@@ -247,16 +494,37 @@ const emitPick = (pick: QChartPick) => {
|
|
|
247
494
|
const pickRow = (row: any, dataIndex: number) => {
|
|
248
495
|
const markIndex = (props.marks ?? []).findIndex((mark) => mark.type === "table")
|
|
249
496
|
const name = rowKey(row)
|
|
497
|
+
// Index **absolu** (toutes les lignes visibles), pas celui de la page courante
|
|
498
|
+
const all = table.value?.allRows
|
|
499
|
+
const absolute = all ? all.indexOf(row) : -1
|
|
250
500
|
emitPick({
|
|
251
501
|
...(name !== undefined ? { name } : {}),
|
|
252
502
|
markIndex,
|
|
253
503
|
markType: "table",
|
|
254
|
-
dataIndex,
|
|
504
|
+
dataIndex: absolute >= 0 ? absolute : dataIndex,
|
|
255
505
|
origin: "mark",
|
|
256
506
|
data: row,
|
|
257
507
|
})
|
|
258
508
|
}
|
|
259
509
|
|
|
510
|
+
/** Une donnée est-elle présente ? (marque `table` : lignes visibles ; sinon : données déclarées) */
|
|
511
|
+
const hasData = computed(() => {
|
|
512
|
+
const marks = props.marks ?? []
|
|
513
|
+
return marks.some((mark) => (mark.data?.length ?? 0) > 0)
|
|
514
|
+
})
|
|
515
|
+
|
|
516
|
+
/** État affiché en surimpression (`loading` > `error` > `empty`) */
|
|
517
|
+
const stateKind = computed<"loading" | "error" | "empty" | null>(() => {
|
|
518
|
+
if (props.loading) return "loading"
|
|
519
|
+
if (props.error) return "error"
|
|
520
|
+
if (props.empty ?? !hasData.value) return "empty"
|
|
521
|
+
return null
|
|
522
|
+
})
|
|
523
|
+
|
|
524
|
+
const errorMessage = computed(() =>
|
|
525
|
+
typeof props.error === "string" ? props.error : t("error"),
|
|
526
|
+
)
|
|
527
|
+
|
|
260
528
|
const render = async () => {
|
|
261
529
|
// Une marque `table` remplace le tracé : on libère l'instance si elle existait
|
|
262
530
|
if (tableMark.value) {
|
|
@@ -348,6 +616,9 @@ watch(
|
|
|
348
616
|
props.selected,
|
|
349
617
|
props.x,
|
|
350
618
|
props.y,
|
|
619
|
+
props.yRight,
|
|
620
|
+
props.locale,
|
|
621
|
+
props.compact,
|
|
351
622
|
props.title,
|
|
352
623
|
props.colors,
|
|
353
624
|
props.legend,
|
|
@@ -414,7 +685,7 @@ defineExpose({
|
|
|
414
685
|
</script>
|
|
415
686
|
|
|
416
687
|
<template>
|
|
417
|
-
<!-- Marque `table` : les données en HTML (sélection, jointure et `@pick` comme le reste) -->
|
|
688
|
+
<!-- Marque `table` : les données en HTML (sélection, jointure, tri, pagination et `@pick` comme le reste) -->
|
|
418
689
|
<div
|
|
419
690
|
v-if="table"
|
|
420
691
|
class="q-chart q-chart--table"
|
|
@@ -422,11 +693,25 @@ defineExpose({
|
|
|
422
693
|
role="table"
|
|
423
694
|
:aria-label="title || 'Table'"
|
|
424
695
|
>
|
|
696
|
+
<div v-if="exportable" class="q-chart__table-bar">
|
|
697
|
+
<button type="button" class="q-chart__table-action" @click="exportCsv">
|
|
698
|
+
{{ t("exportCsv") }}
|
|
699
|
+
</button>
|
|
700
|
+
</div>
|
|
425
701
|
<table :class="`is-${table.separator}`">
|
|
426
702
|
<thead>
|
|
427
703
|
<tr>
|
|
428
|
-
<th
|
|
704
|
+
<th
|
|
705
|
+
v-for="column in table.columns"
|
|
706
|
+
:key="column.field"
|
|
707
|
+
:style="columnStyle(column)"
|
|
708
|
+
:title="sortTitle(column)"
|
|
709
|
+
:class="{ 'is-sortable': isSortable(column) }"
|
|
710
|
+
:aria-sort="sortIndicator(column) === '↑' ? 'ascending' : sortIndicator(column) === '↓' ? 'descending' : 'none'"
|
|
711
|
+
@click="toggleSort(column)"
|
|
712
|
+
>
|
|
429
713
|
{{ column.label }}
|
|
714
|
+
<span v-if="sortIndicator(column)" class="q-chart__sort">{{ sortIndicator(column) }}</span>
|
|
430
715
|
</th>
|
|
431
716
|
</tr>
|
|
432
717
|
</thead>
|
|
@@ -438,23 +723,121 @@ defineExpose({
|
|
|
438
723
|
:style="isDimmed(row) ? { opacity: dimOpacity } : undefined"
|
|
439
724
|
@click="pickRow(row, index)"
|
|
440
725
|
>
|
|
441
|
-
<td v-for="column in table.columns" :key="column.field" :style="
|
|
442
|
-
|
|
726
|
+
<td v-for="column in table.columns" :key="column.field" :style="columnStyle(column)">
|
|
727
|
+
<div
|
|
728
|
+
v-for="cell in [renderCell(column, row)]"
|
|
729
|
+
:key="column.field"
|
|
730
|
+
class="q-chart__cell"
|
|
731
|
+
:class="{
|
|
732
|
+
'q-chart__cell--num': column.align === 'end',
|
|
733
|
+
'q-chart__cell--delta': cell.kind === 'delta',
|
|
734
|
+
'q-chart__cell--sparkline': cell.kind === 'sparkline',
|
|
735
|
+
}"
|
|
736
|
+
:role="cell.kind === 'sparkline' ? 'img' : undefined"
|
|
737
|
+
:aria-label="cell.kind === 'sparkline' ? cell.text : undefined"
|
|
738
|
+
:title="cell.kind === 'sparkline' ? cell.text : undefined"
|
|
739
|
+
>
|
|
740
|
+
<span v-if="cell.bar" class="q-chart__cell-bar" :style="cell.bar.style" aria-hidden="true" />
|
|
741
|
+
<svg
|
|
742
|
+
v-if="cell.kind === 'sparkline' && cell.spark"
|
|
743
|
+
class="q-chart__spark"
|
|
744
|
+
viewBox="0 0 100 24"
|
|
745
|
+
preserveAspectRatio="none"
|
|
746
|
+
aria-hidden="true"
|
|
747
|
+
>
|
|
748
|
+
<path v-if="cell.area" :d="cell.spark.areaPath" :fill="cell.lineColor" opacity="0.15" />
|
|
749
|
+
<path
|
|
750
|
+
:d="cell.spark.path"
|
|
751
|
+
fill="none"
|
|
752
|
+
:stroke="cell.lineColor"
|
|
753
|
+
stroke-width="1.5"
|
|
754
|
+
vector-effect="non-scaling-stroke"
|
|
755
|
+
/>
|
|
756
|
+
<circle
|
|
757
|
+
v-if="cell.spark.marker"
|
|
758
|
+
:cx="cell.spark.marker.x"
|
|
759
|
+
:cy="cell.spark.marker.y"
|
|
760
|
+
r="2.4"
|
|
761
|
+
:fill="cell.pointColor"
|
|
762
|
+
/>
|
|
763
|
+
</svg>
|
|
764
|
+
<span
|
|
765
|
+
v-if="cell.kind !== 'sparkline' || !cell.spark"
|
|
766
|
+
class="q-chart__cell-value"
|
|
767
|
+
:style="cell.textStyle"
|
|
768
|
+
>
|
|
769
|
+
{{ cell.text }}<span v-if="cell.arrow" class="q-chart__cell-arrow">{{ cell.arrow }}</span>
|
|
770
|
+
</span>
|
|
771
|
+
</div>
|
|
443
772
|
</td>
|
|
444
773
|
</tr>
|
|
445
774
|
</tbody>
|
|
775
|
+
<tfoot v-if="table.totals">
|
|
776
|
+
<tr>
|
|
777
|
+
<td v-for="column in table.columns" :key="column.field" :style="columnStyle(column)">
|
|
778
|
+
<div
|
|
779
|
+
v-for="cell in [
|
|
780
|
+
renderCell(column, undefined, {
|
|
781
|
+
value: table.totalValues?.[column.field],
|
|
782
|
+
text: table.totals?.[column.field] ?? '',
|
|
783
|
+
}),
|
|
784
|
+
]"
|
|
785
|
+
:key="column.field"
|
|
786
|
+
class="q-chart__cell"
|
|
787
|
+
:class="{
|
|
788
|
+
'q-chart__cell--num': column.align === 'end',
|
|
789
|
+
'q-chart__cell--delta': cell.kind === 'delta',
|
|
790
|
+
}"
|
|
791
|
+
>
|
|
792
|
+
<span v-if="cell.bar" class="q-chart__cell-bar" :style="cell.bar.style" aria-hidden="true" />
|
|
793
|
+
<span class="q-chart__cell-value" :style="cell.textStyle">
|
|
794
|
+
{{ cell.text }}<span v-if="cell.arrow" class="q-chart__cell-arrow">{{ cell.arrow }}</span>
|
|
795
|
+
</span>
|
|
796
|
+
</div>
|
|
797
|
+
</td>
|
|
798
|
+
</tr>
|
|
799
|
+
</tfoot>
|
|
446
800
|
</table>
|
|
447
|
-
<p v-if="table.rows.length === 0" class="q-chart__empty">
|
|
801
|
+
<p v-if="table.rows.length === 0" class="q-chart__empty">{{ t("empty") }}</p>
|
|
802
|
+
<div v-if="table.pages && table.pages > 1" class="q-chart__pager">
|
|
803
|
+
<button
|
|
804
|
+
type="button"
|
|
805
|
+
class="q-chart__table-action"
|
|
806
|
+
:disabled="(table.page ?? 0) <= 0"
|
|
807
|
+
@click="goPage(-1)"
|
|
808
|
+
>
|
|
809
|
+
‹
|
|
810
|
+
</button>
|
|
811
|
+
<span class="q-chart__pager-label">{{ fmtPage((table.page ?? 0) + 1, table.pages) }}</span>
|
|
812
|
+
<button
|
|
813
|
+
type="button"
|
|
814
|
+
class="q-chart__table-action"
|
|
815
|
+
:disabled="(table.page ?? 0) >= table.pages - 1"
|
|
816
|
+
@click="goPage(1)"
|
|
817
|
+
>
|
|
818
|
+
›
|
|
819
|
+
</button>
|
|
820
|
+
</div>
|
|
448
821
|
</div>
|
|
449
822
|
|
|
450
823
|
<div
|
|
451
824
|
v-else
|
|
452
|
-
|
|
453
|
-
class="q-chart"
|
|
825
|
+
class="q-chart q-chart--plot"
|
|
454
826
|
:style="{ height: typeof height === 'number' ? `${height}px` : height }"
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
827
|
+
>
|
|
828
|
+
<div
|
|
829
|
+
ref="el"
|
|
830
|
+
class="q-chart__canvas"
|
|
831
|
+
role="img"
|
|
832
|
+
:aria-label="title || 'Chart'"
|
|
833
|
+
:aria-busy="stateKind === 'loading' || undefined"
|
|
834
|
+
></div>
|
|
835
|
+
<div v-if="stateKind" class="q-chart__state" :class="`q-chart__state--${stateKind}`" role="status">
|
|
836
|
+
<slot v-if="stateKind === 'loading'" name="loading">{{ t("loading") }}</slot>
|
|
837
|
+
<slot v-else-if="stateKind === 'error'" name="error">{{ errorMessage }}</slot>
|
|
838
|
+
<slot v-else name="empty">{{ t("empty") }}</slot>
|
|
839
|
+
</div>
|
|
840
|
+
</div>
|
|
458
841
|
</template>
|
|
459
842
|
|
|
460
843
|
<style scoped>
|
|
@@ -523,6 +906,63 @@ defineExpose({
|
|
|
523
906
|
background: var(--accent, #f4f4f5);
|
|
524
907
|
}
|
|
525
908
|
|
|
909
|
+
/* ─── Micro-visualisations de cellule (barres, delta, sparkline) ─── */
|
|
910
|
+
.q-chart__cell {
|
|
911
|
+
position: relative;
|
|
912
|
+
display: flex;
|
|
913
|
+
align-items: center;
|
|
914
|
+
min-height: 20px;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
.q-chart__cell--num {
|
|
918
|
+
justify-content: flex-end;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
.q-chart__cell-bar {
|
|
922
|
+
position: absolute;
|
|
923
|
+
top: 1px;
|
|
924
|
+
bottom: 1px;
|
|
925
|
+
left: 0;
|
|
926
|
+
border-radius: 2px;
|
|
927
|
+
pointer-events: none;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
.q-chart__cell-value {
|
|
931
|
+
position: relative;
|
|
932
|
+
z-index: 1;
|
|
933
|
+
font-variant-numeric: tabular-nums;
|
|
934
|
+
white-space: nowrap;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
/* `delta` : axe médian, les barres divergent de part et d'autre */
|
|
938
|
+
.q-chart__cell--delta::before {
|
|
939
|
+
content: "";
|
|
940
|
+
position: absolute;
|
|
941
|
+
top: 3px;
|
|
942
|
+
bottom: 3px;
|
|
943
|
+
left: 50%;
|
|
944
|
+
width: 1px;
|
|
945
|
+
background: var(--border, #e4e4e7);
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
.q-chart__cell-arrow {
|
|
949
|
+
margin-left: 4px;
|
|
950
|
+
font-size: 9px;
|
|
951
|
+
vertical-align: 1px;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
.q-chart__cell--sparkline {
|
|
955
|
+
justify-content: stretch;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
.q-chart__spark {
|
|
959
|
+
display: block;
|
|
960
|
+
width: 100%;
|
|
961
|
+
height: 22px;
|
|
962
|
+
min-width: 46px;
|
|
963
|
+
overflow: visible;
|
|
964
|
+
}
|
|
965
|
+
|
|
526
966
|
.q-chart__empty {
|
|
527
967
|
margin: 8px 10px;
|
|
528
968
|
font-size: 12px;
|
|
@@ -531,4 +971,89 @@ defineExpose({
|
|
|
531
971
|
.q-chart {
|
|
532
972
|
width: 100%;
|
|
533
973
|
}
|
|
974
|
+
|
|
975
|
+
/* ─── Tracé (canvas + surimpression d'état) ─── */
|
|
976
|
+
.q-chart--plot {
|
|
977
|
+
position: relative;
|
|
978
|
+
width: 100%;
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
.q-chart__canvas {
|
|
982
|
+
width: 100%;
|
|
983
|
+
height: 100%;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
/* États `loading` / `empty` / `error` : une surimpression lisible sur le thème hôte */
|
|
987
|
+
.q-chart__state {
|
|
988
|
+
position: absolute;
|
|
989
|
+
inset: 0;
|
|
990
|
+
display: flex;
|
|
991
|
+
align-items: center;
|
|
992
|
+
justify-content: center;
|
|
993
|
+
padding: 12px;
|
|
994
|
+
font-size: 13px;
|
|
995
|
+
text-align: center;
|
|
996
|
+
color: var(--muted-foreground, #71717a);
|
|
997
|
+
background: color-mix(in srgb, var(--card, #ffffff) 76%, transparent);
|
|
998
|
+
backdrop-filter: blur(2px);
|
|
999
|
+
-webkit-backdrop-filter: blur(2px);
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
.q-chart__state--error {
|
|
1003
|
+
color: var(--negative, #c10015);
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
/* ─── Table BI (barre d'outils, tri, totaux, pagination) ─── */
|
|
1007
|
+
.q-chart__table-bar {
|
|
1008
|
+
display: flex;
|
|
1009
|
+
justify-content: flex-end;
|
|
1010
|
+
padding: 4px 10px;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
.q-chart__table-action {
|
|
1014
|
+
padding: 2px 8px;
|
|
1015
|
+
font: inherit;
|
|
1016
|
+
font-size: 12px;
|
|
1017
|
+
color: var(--muted-foreground, #71717a);
|
|
1018
|
+
cursor: pointer;
|
|
1019
|
+
background: transparent;
|
|
1020
|
+
border: 1px solid var(--border, #e4e4e7);
|
|
1021
|
+
border-radius: 4px;
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
.q-chart__table-action:hover:not(:disabled) {
|
|
1025
|
+
color: var(--foreground, #18181b);
|
|
1026
|
+
background: var(--accent, #f4f4f5);
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
.q-chart__table-action:disabled {
|
|
1030
|
+
cursor: default;
|
|
1031
|
+
opacity: 0.45;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
.q-chart--table th.is-sortable {
|
|
1035
|
+
cursor: pointer;
|
|
1036
|
+
user-select: none;
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
.q-chart__sort {
|
|
1040
|
+
margin-left: 4px;
|
|
1041
|
+
font-size: 10px;
|
|
1042
|
+
opacity: 0.75;
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
.q-chart--table tfoot td {
|
|
1046
|
+
font-weight: 600;
|
|
1047
|
+
border-top: 1px solid var(--border, #e4e4e7);
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
.q-chart__pager {
|
|
1051
|
+
display: flex;
|
|
1052
|
+
align-items: center;
|
|
1053
|
+
justify-content: flex-end;
|
|
1054
|
+
gap: 8px;
|
|
1055
|
+
padding: 6px 10px;
|
|
1056
|
+
font-size: 12px;
|
|
1057
|
+
color: var(--muted-foreground, #71717a);
|
|
1058
|
+
}
|
|
534
1059
|
</style>
|