@dnax/ui 0.0.66 → 0.0.69

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.
@@ -13,16 +13,31 @@
13
13
  import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from "vue"
14
14
  import { chartToECharts, COLOR_TOKENS } 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 { useConfigLang } from "../lib/useComponentProps"
18
+ import type {
19
+ QChartAxis,
20
+ QChartColumn,
21
+ QChartLegend,
22
+ QChartMark,
23
+ QChartPick,
24
+ QChartTooltip,
25
+ } from "../lib/chart"
18
26
 
19
27
  interface Props {
20
28
  /** Marques à rendre : `{ type: 'line', data, x, y }`, `{ type: 'bar', … }`… */
21
29
  marks?: QChartMark[]
22
- /** Axe X (type, libellé, bornes, grille) */
30
+ /** Axe X (type, libellé, bornes, grille, format, granularité temporelle) */
23
31
  x?: QChartAxis
24
- /** Axe Y (type, libellé, bornes, grille) */
32
+ /** Axe Y (type, libellé, bornes, grille, format) */
25
33
  y?: QChartAxis
34
+ /** Axe Y **secondaire**, à droite — pour un combo volume (barres) + taux (ligne) :
35
+ * la marque visée déclare `yAxis: 'right'`. */
36
+ yRight?: QChartAxis
37
+ /** Locale des nombres / dates (`fr-FR` par défaut, sinon celle du provider) */
38
+ locale?: string
39
+ /** Mode **compact** (mini-widget) : marges réduites, axes et légende masqués par défaut */
40
+ compact?: boolean
26
41
  /** Hauteur du conteneur (nombre = px, sinon valeur CSS) */
27
42
  height?: number | string
28
43
  /** Titre du graphique */
@@ -32,8 +47,9 @@ interface Props {
32
47
  /** Légende : `false` pour la masquer, `true` pour la forcer (défaut : dès qu'une série est
33
48
  * nommée), ou `{ position: 'top' | 'bottom' | 'left' | 'right', offset, align }` */
34
49
  legend?: boolean | QChartLegend
35
- /** Info-bulle au survol (défaut : true) */
36
- tooltip?: boolean
50
+ /** Info-bulle au survol (défaut : true) — `false` l'éteint, un objet la personnalise
51
+ * (`formatter`, `valueFormatter`, `sortDesc`) */
52
+ tooltip?: boolean | QChartTooltip
37
53
  /** Réaction des marques liées à la sélection : `filter` (défaut) **retire** les données non
38
54
  * liées, `dim` les **estompe** (`dimOpacity`) en gardant tout le contexte visible. */
39
55
  linkMode?: "filter" | "dim"
@@ -47,6 +63,18 @@ interface Props {
47
63
  * les éléments qui portent ce `name` sont mis en évidence (`highlight`) dans toutes les
48
64
  * séries. `null` efface la sélection. */
49
65
  selected?: QChartPick | null
66
+ /** État **chargement** : superpose un message (slot `loading`) */
67
+ loading?: boolean
68
+ /** État **erreur** : `true` (message localisé) ou un texte à afficher (slot `error`) */
69
+ error?: string | boolean
70
+ /** État **vide** forcé (sinon détecté : aucune donnée) — slot `empty` */
71
+ empty?: boolean
72
+ /** Textes des états et de la table (prioritaires sur l'i18n du provider) */
73
+ texts?: Partial<
74
+ Record<"loading" | "empty" | "error" | "exportCsv" | "total" | "page" | "sortAscending" | "sortDescending", string>
75
+ >
76
+ /** Marque `table` : bouton d'**export CSV** */
77
+ exportable?: boolean
50
78
  /** Options ECharts brutes, fusionnées en dernier (échappatoire) */
51
79
  options?: Record<string, any>
52
80
  }
@@ -76,6 +104,37 @@ const emit = defineEmits<{
76
104
  unpick: [pick: QChartPick]
77
105
  }>()
78
106
 
107
+ // ─── i18n des états et de la table ────────────────────────────────────────────
108
+ const I18N = {
109
+ en: {
110
+ loading: "Loading…",
111
+ empty: "No data",
112
+ error: "Unable to display the chart",
113
+ exportCsv: "Export CSV",
114
+ total: "Total",
115
+ page: "Page {n} / {m}",
116
+ sortAscending: "Sort ascending",
117
+ sortDescending: "Sort descending",
118
+ },
119
+ fr: {
120
+ loading: "Chargement…",
121
+ empty: "Aucune donnée",
122
+ error: "Impossible d'afficher le graphique",
123
+ exportCsv: "Exporter en CSV",
124
+ total: "Total",
125
+ page: "Page {n} / {m}",
126
+ sortAscending: "Tri croissant",
127
+ sortDescending: "Tri décroissant",
128
+ },
129
+ } as const
130
+ type UiText = keyof (typeof I18N)["en"]
131
+ const configLang = useConfigLang()
132
+ const t = (key: UiText): string => props.texts?.[key] ?? I18N[configLang.value === "fr" ? "fr" : "en"][key]
133
+ const fmtPage = (n: number, total: number) =>
134
+ t("page").replace("{n}", String(n)).replace("{m}", String(total))
135
+ /** Locale des nombres / dates : prop, sinon celle du provider (`fr` → `fr-FR`) */
136
+ const locale = computed(() => props.locale ?? (configLang.value === "fr" ? "fr-FR" : "en-US"))
137
+
79
138
  const el = ref<HTMLElement | null>(null)
80
139
  const chart = shallowRef<any>(null)
81
140
  let modulePromise: Promise<any> | null = null
@@ -85,11 +144,12 @@ let themeObserver: MutationObserver | undefined
85
144
  /** Charge ECharts (modules enregistrés une seule fois, arbre secoué). */
86
145
  const loadECharts = () => {
87
146
  modulePromise ??= (async () => {
88
- const [core, charts, components, renderers] = await Promise.all([
147
+ const [core, charts, components, renderers, features] = await Promise.all([
89
148
  import("echarts/core"),
90
149
  import("echarts/charts"),
91
150
  import("echarts/components"),
92
151
  import("echarts/renderers"),
152
+ import("echarts/features"),
93
153
  ])
94
154
  core.use([
95
155
  charts.BarChart,
@@ -97,13 +157,25 @@ const loadECharts = () => {
97
157
  charts.ScatterChart,
98
158
  charts.PieChart, // marque `pie` (camembert / anneau)
99
159
  charts.HeatmapChart, // marque `heatmap` (carte de chaleur)
160
+ charts.BoxplotChart, // marque `boxplot` (boîte à moustaches)
161
+ charts.GaugeChart, // marque `gauge` (jauge)
162
+ charts.FunnelChart, // marque `funnel` (entonnoir)
163
+ charts.RadarChart, // marque `radar` (multicritères)
164
+ charts.TreemapChart, // marque `treemap`
165
+ charts.SunburstChart, // marque `sunburst`
166
+ charts.SankeyChart, // marque `sankey` (flux)
100
167
  components.GridComponent,
168
+ components.RadarComponent, // coordonnée polaire de la marque `radar`
101
169
  components.TooltipComponent,
102
170
  components.LegendComponent,
103
171
  components.TitleComponent, // titre (`title` prop)
104
172
  components.MarkLineComponent,
105
- components.VisualMapComponent, // échelle de couleurs des cartes de chaleur
173
+ components.MarkAreaComponent, // marque `band` (zones de seuil)
174
+ components.VisualMapComponent, // échelles de couleurs (heatmap, `fill` numérique d'un `dot`)
106
175
  renderers.CanvasRenderer,
176
+ // ECharts 6 : `grid.containLabel` vit désormais dans un module de compatibilité —
177
+ // sans lui l'option est ignorée **silencieusement** (les libellés débordent du cadre).
178
+ features.LegacyGridContainLabel,
107
179
  ])
108
180
  return core
109
181
  })()
@@ -170,6 +242,9 @@ const build = () => {
170
242
  marks: props.marks,
171
243
  x: props.x,
172
244
  y: props.y,
245
+ yRight: props.yRight,
246
+ locale: locale.value,
247
+ compact: props.compact,
173
248
  colors: props.colors,
174
249
  title: props.title,
175
250
  legend: props.legend,
@@ -197,12 +272,88 @@ const linkChart = (group: string, instance: any, api: any) => {
197
272
  }
198
273
 
199
274
  const tableMark = computed(() => (props.marks ?? []).find((mark) => mark.type === "table"))
275
+
276
+ /** État de vue **interactif** de la table (tri, page) — prioritaire sur les options de la marque */
277
+ const sortState = ref<{ field: string; desc: boolean } | null>(null)
278
+ const pageState = ref(0)
279
+
200
280
  const table = computed(() =>
201
281
  tableMark.value
202
- ? tableModel(tableMark.value, props.selected ?? null, (props.linkMode as "filter" | "dim") ?? "filter")
282
+ ? tableModel(tableMark.value, props.selected ?? null, (props.linkMode as "filter" | "dim") ?? "filter", {
283
+ ...(sortState.value ? { sortBy: sortState.value.field, sortDesc: sortState.value.desc } : {}),
284
+ ...(pageState.value ? { page: pageState.value } : {}),
285
+ })
203
286
  : null,
204
287
  )
205
288
 
289
+ /** Style d'une colonne (largeur fixe, alignement) */
290
+ const columnStyle = (column: QChartColumn) => ({
291
+ textAlign: column.align,
292
+ ...(column.width !== undefined
293
+ ? { width: typeof column.width === "number" ? `${column.width}px` : column.width }
294
+ : {}),
295
+ })
296
+
297
+ /** Tri suivant : `asc` → `desc` → aucun (l'ordre d'origine revient) */
298
+ const toggleSort = (column: QChartColumn) => {
299
+ if (column.sortable === false) return
300
+ const base =
301
+ sortState.value ??
302
+ (tableMark.value?.sortBy
303
+ ? { field: tableMark.value.sortBy, desc: !!tableMark.value.sortDesc }
304
+ : null)
305
+ if (!base || base.field !== column.field) sortState.value = { field: column.field, desc: false }
306
+ else if (!base.desc) sortState.value = { field: column.field, desc: true }
307
+ else sortState.value = null
308
+ pageState.value = 0
309
+ }
310
+
311
+ const sortIndicator = (column: QChartColumn) => {
312
+ const current = sortState.value ?? (tableMark.value?.sortBy ? { field: tableMark.value.sortBy, desc: !!tableMark.value.sortDesc } : null)
313
+ if (!current || current.field !== column.field) return ""
314
+ return current.desc ? "\u2193" : "\u2191"
315
+ }
316
+
317
+ const sortTitle = (column: QChartColumn) => {
318
+ if (column.sortable === false) return ""
319
+ return sortIndicator(column) === "\u2191" ? t("sortDescending") : t("sortAscending")
320
+ }
321
+
322
+ const goPage = (delta: number) => {
323
+ const model = table.value
324
+ if (!model?.pages) return
325
+ pageState.value = Math.max(0, Math.min(model.pages - 1, (model.page ?? 0) + delta))
326
+ }
327
+
328
+ /** Export CSV de **toutes** les lignes visibles (pas seulement la page) */
329
+ const exportCsv = () => {
330
+ const model = table.value
331
+ if (!model || typeof window === "undefined" || typeof document === "undefined") return
332
+ const csv = tableCsv(model, { label: t("total") })
333
+ const blob = new Blob([csv], { type: "text/csv;charset=utf-8" })
334
+ const url = URL.createObjectURL(blob)
335
+ const slug =
336
+ (props.title ?? "chart")
337
+ .toLowerCase()
338
+ .replace(/[^a-z0-9]+/g, "-")
339
+ .replace(/^-|-$/g, "") || "chart"
340
+ const anchor = document.createElement("a")
341
+ anchor.href = url
342
+ anchor.download = `${slug}.csv`
343
+ anchor.click()
344
+ URL.revokeObjectURL(url)
345
+ }
346
+
347
+ // Les données changent → le tri et la page repartent de zéro (sinon une page vide peut rester)
348
+ watch(
349
+ () => props.marks,
350
+ () => {
351
+ sortState.value = null
352
+ pageState.value = 0
353
+ },
354
+ { deep: true },
355
+ )
356
+
206
357
  /** Champ qui porte la clé partagée d'une ligne : le `localField` de `link`, sinon le premier
207
358
  * champ de la première colonne. C'est ce qui alimente `@pick`, la sélection et la mise en
208
359
  * évidence — une ligne se désigne par la même clé que les autres marques. */
@@ -247,16 +398,37 @@ const emitPick = (pick: QChartPick) => {
247
398
  const pickRow = (row: any, dataIndex: number) => {
248
399
  const markIndex = (props.marks ?? []).findIndex((mark) => mark.type === "table")
249
400
  const name = rowKey(row)
401
+ // Index **absolu** (toutes les lignes visibles), pas celui de la page courante
402
+ const all = table.value?.allRows
403
+ const absolute = all ? all.indexOf(row) : -1
250
404
  emitPick({
251
405
  ...(name !== undefined ? { name } : {}),
252
406
  markIndex,
253
407
  markType: "table",
254
- dataIndex,
408
+ dataIndex: absolute >= 0 ? absolute : dataIndex,
255
409
  origin: "mark",
256
410
  data: row,
257
411
  })
258
412
  }
259
413
 
414
+ /** Une donnée est-elle présente ? (marque `table` : lignes visibles ; sinon : données déclarées) */
415
+ const hasData = computed(() => {
416
+ const marks = props.marks ?? []
417
+ return marks.some((mark) => (mark.data?.length ?? 0) > 0)
418
+ })
419
+
420
+ /** État affiché en surimpression (`loading` > `error` > `empty`) */
421
+ const stateKind = computed<"loading" | "error" | "empty" | null>(() => {
422
+ if (props.loading) return "loading"
423
+ if (props.error) return "error"
424
+ if (props.empty ?? !hasData.value) return "empty"
425
+ return null
426
+ })
427
+
428
+ const errorMessage = computed(() =>
429
+ typeof props.error === "string" ? props.error : t("error"),
430
+ )
431
+
260
432
  const render = async () => {
261
433
  // Une marque `table` remplace le tracé : on libère l'instance si elle existait
262
434
  if (tableMark.value) {
@@ -348,6 +520,9 @@ watch(
348
520
  props.selected,
349
521
  props.x,
350
522
  props.y,
523
+ props.yRight,
524
+ props.locale,
525
+ props.compact,
351
526
  props.title,
352
527
  props.colors,
353
528
  props.legend,
@@ -414,7 +589,7 @@ defineExpose({
414
589
  </script>
415
590
 
416
591
  <template>
417
- <!-- Marque `table` : les données en HTML (sélection, jointure et `@pick` comme le reste) -->
592
+ <!-- Marque `table` : les données en HTML (sélection, jointure, tri, pagination et `@pick` comme le reste) -->
418
593
  <div
419
594
  v-if="table"
420
595
  class="q-chart q-chart--table"
@@ -422,11 +597,25 @@ defineExpose({
422
597
  role="table"
423
598
  :aria-label="title || 'Table'"
424
599
  >
600
+ <div v-if="exportable" class="q-chart__table-bar">
601
+ <button type="button" class="q-chart__table-action" @click="exportCsv">
602
+ {{ t("exportCsv") }}
603
+ </button>
604
+ </div>
425
605
  <table :class="`is-${table.separator}`">
426
606
  <thead>
427
607
  <tr>
428
- <th v-for="column in table.columns" :key="column.field" :style="{ textAlign: column.align }">
608
+ <th
609
+ v-for="column in table.columns"
610
+ :key="column.field"
611
+ :style="columnStyle(column)"
612
+ :title="sortTitle(column)"
613
+ :class="{ 'is-sortable': column.sortable !== false }"
614
+ :aria-sort="sortIndicator(column) === '↑' ? 'ascending' : sortIndicator(column) === '↓' ? 'descending' : 'none'"
615
+ @click="toggleSort(column)"
616
+ >
429
617
  {{ column.label }}
618
+ <span v-if="sortIndicator(column)" class="q-chart__sort">{{ sortIndicator(column) }}</span>
430
619
  </th>
431
620
  </tr>
432
621
  </thead>
@@ -438,23 +627,59 @@ defineExpose({
438
627
  :style="isDimmed(row) ? { opacity: dimOpacity } : undefined"
439
628
  @click="pickRow(row, index)"
440
629
  >
441
- <td v-for="column in table.columns" :key="column.field" :style="{ textAlign: column.align }">
630
+ <td v-for="column in table.columns" :key="column.field" :style="columnStyle(column)">
442
631
  {{ cellValue(column, row) }}
443
632
  </td>
444
633
  </tr>
445
634
  </tbody>
635
+ <tfoot v-if="table.totals">
636
+ <tr>
637
+ <td v-for="column in table.columns" :key="column.field" :style="columnStyle(column)">
638
+ {{ table.totals[column.field] ?? "" }}
639
+ </td>
640
+ </tr>
641
+ </tfoot>
446
642
  </table>
447
- <p v-if="table.rows.length === 0" class="q-chart__empty">No rows</p>
643
+ <p v-if="table.rows.length === 0" class="q-chart__empty">{{ t("empty") }}</p>
644
+ <div v-if="table.pages && table.pages > 1" class="q-chart__pager">
645
+ <button
646
+ type="button"
647
+ class="q-chart__table-action"
648
+ :disabled="(table.page ?? 0) <= 0"
649
+ @click="goPage(-1)"
650
+ >
651
+ ‹
652
+ </button>
653
+ <span class="q-chart__pager-label">{{ fmtPage((table.page ?? 0) + 1, table.pages) }}</span>
654
+ <button
655
+ type="button"
656
+ class="q-chart__table-action"
657
+ :disabled="(table.page ?? 0) >= table.pages - 1"
658
+ @click="goPage(1)"
659
+ >
660
+ ›
661
+ </button>
662
+ </div>
448
663
  </div>
449
664
 
450
665
  <div
451
666
  v-else
452
- ref="el"
453
- class="q-chart"
667
+ class="q-chart q-chart--plot"
454
668
  :style="{ height: typeof height === 'number' ? `${height}px` : height }"
455
- role="img"
456
- :aria-label="title || 'Chart'"
457
- ></div>
669
+ >
670
+ <div
671
+ ref="el"
672
+ class="q-chart__canvas"
673
+ role="img"
674
+ :aria-label="title || 'Chart'"
675
+ :aria-busy="stateKind === 'loading' || undefined"
676
+ ></div>
677
+ <div v-if="stateKind" class="q-chart__state" :class="`q-chart__state--${stateKind}`" role="status">
678
+ <slot v-if="stateKind === 'loading'" name="loading">{{ t("loading") }}</slot>
679
+ <slot v-else-if="stateKind === 'error'" name="error">{{ errorMessage }}</slot>
680
+ <slot v-else name="empty">{{ t("empty") }}</slot>
681
+ </div>
682
+ </div>
458
683
  </template>
459
684
 
460
685
  <style scoped>
@@ -531,4 +756,89 @@ defineExpose({
531
756
  .q-chart {
532
757
  width: 100%;
533
758
  }
759
+
760
+ /* ─── Tracé (canvas + surimpression d'état) ─── */
761
+ .q-chart--plot {
762
+ position: relative;
763
+ width: 100%;
764
+ }
765
+
766
+ .q-chart__canvas {
767
+ width: 100%;
768
+ height: 100%;
769
+ }
770
+
771
+ /* États `loading` / `empty` / `error` : une surimpression lisible sur le thème hôte */
772
+ .q-chart__state {
773
+ position: absolute;
774
+ inset: 0;
775
+ display: flex;
776
+ align-items: center;
777
+ justify-content: center;
778
+ padding: 12px;
779
+ font-size: 13px;
780
+ text-align: center;
781
+ color: var(--muted-foreground, #71717a);
782
+ background: color-mix(in srgb, var(--card, #ffffff) 76%, transparent);
783
+ backdrop-filter: blur(2px);
784
+ -webkit-backdrop-filter: blur(2px);
785
+ }
786
+
787
+ .q-chart__state--error {
788
+ color: var(--negative, #c10015);
789
+ }
790
+
791
+ /* ─── Table BI (barre d'outils, tri, totaux, pagination) ─── */
792
+ .q-chart__table-bar {
793
+ display: flex;
794
+ justify-content: flex-end;
795
+ padding: 4px 10px;
796
+ }
797
+
798
+ .q-chart__table-action {
799
+ padding: 2px 8px;
800
+ font: inherit;
801
+ font-size: 12px;
802
+ color: var(--muted-foreground, #71717a);
803
+ cursor: pointer;
804
+ background: transparent;
805
+ border: 1px solid var(--border, #e4e4e7);
806
+ border-radius: 4px;
807
+ }
808
+
809
+ .q-chart__table-action:hover:not(:disabled) {
810
+ color: var(--foreground, #18181b);
811
+ background: var(--accent, #f4f4f5);
812
+ }
813
+
814
+ .q-chart__table-action:disabled {
815
+ cursor: default;
816
+ opacity: 0.45;
817
+ }
818
+
819
+ .q-chart--table th.is-sortable {
820
+ cursor: pointer;
821
+ user-select: none;
822
+ }
823
+
824
+ .q-chart__sort {
825
+ margin-left: 4px;
826
+ font-size: 10px;
827
+ opacity: 0.75;
828
+ }
829
+
830
+ .q-chart--table tfoot td {
831
+ font-weight: 600;
832
+ border-top: 1px solid var(--border, #e4e4e7);
833
+ }
834
+
835
+ .q-chart__pager {
836
+ display: flex;
837
+ align-items: center;
838
+ justify-content: flex-end;
839
+ gap: 8px;
840
+ padding: 6px 10px;
841
+ font-size: 12px;
842
+ color: var(--muted-foreground, #71717a);
843
+ }
534
844
  </style>