@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.
@@ -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 type { QChartAxis, QChartColumn, QChartLegend, QChartMark, QChartPick } from "../lib/chart"
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
- tooltip?: boolean
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.VisualMapComponent, // échelle de couleurs des cartes de chaleur
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
- /** Valeur affichée d'une cellule : `format` de la colonne sinon la valeur telle quelle */
229
- const cellValue = (column: QChartColumn, row: any) => {
230
- const value = row?.[column.field]
231
- const formatted = column.format?.(value, row)
232
- if (formatted !== undefined) return formatted
233
- return value === null || value === undefined ? "" : String(value)
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 v-for="column in table.columns" :key="column.field" :style="{ textAlign: column.align }">
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="{ textAlign: column.align }">
442
- {{ cellValue(column, row) }}
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">No rows</p>
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
- ref="el"
453
- class="q-chart"
825
+ class="q-chart q-chart--plot"
454
826
  :style="{ height: typeof height === 'number' ? `${height}px` : height }"
455
- role="img"
456
- :aria-label="title || 'Chart'"
457
- ></div>
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>