@dnax/ui 0.0.69 → 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,12 +11,15 @@
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
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"
17
19
  import { useConfigLang } from "../lib/useComponentProps"
18
20
  import type {
19
21
  QChartAxis,
22
+ QChartCellVisual,
20
23
  QChartColumn,
21
24
  QChartLegend,
22
25
  QChartMark,
@@ -295,8 +298,11 @@ const columnStyle = (column: QChartColumn) => ({
295
298
  })
296
299
 
297
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
+
298
304
  const toggleSort = (column: QChartColumn) => {
299
- if (column.sortable === false) return
305
+ if (!isSortable(column)) return
300
306
  const base =
301
307
  sortState.value ??
302
308
  (tableMark.value?.sortBy
@@ -315,8 +321,8 @@ const sortIndicator = (column: QChartColumn) => {
315
321
  }
316
322
 
317
323
  const sortTitle = (column: QChartColumn) => {
318
- if (column.sortable === false) return ""
319
- return sortIndicator(column) === "\u2191" ? t("sortDescending") : t("sortAscending")
324
+ if (!isSortable(column)) return ""
325
+ return sortIndicator(column) === "↑" ? t("sortDescending") : t("sortAscending")
320
326
  }
321
327
 
322
328
  const goPage = (delta: number) => {
@@ -376,12 +382,102 @@ const isSelected = (row: any) => {
376
382
  return key !== undefined && !!selected && typeof selected === "object" && String(selected.name) === String(key)
377
383
  }
378
384
 
379
- /** Valeur affichée d'une cellule : `format` de la colonne sinon la valeur telle quelle */
380
- const cellValue = (column: QChartColumn, row: any) => {
381
- const value = row?.[column.field]
382
- const formatted = column.format?.(value, row)
383
- if (formatted !== undefined) return formatted
384
- 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
+ }
385
481
  }
386
482
 
387
483
  /** Sélection émise pour un élément — avec la même règle de re-clic que le reste */
@@ -610,7 +706,7 @@ defineExpose({
610
706
  :key="column.field"
611
707
  :style="columnStyle(column)"
612
708
  :title="sortTitle(column)"
613
- :class="{ 'is-sortable': column.sortable !== false }"
709
+ :class="{ 'is-sortable': isSortable(column) }"
614
710
  :aria-sort="sortIndicator(column) === '↑' ? 'ascending' : sortIndicator(column) === '↓' ? 'descending' : 'none'"
615
711
  @click="toggleSort(column)"
616
712
  >
@@ -628,14 +724,76 @@ defineExpose({
628
724
  @click="pickRow(row, index)"
629
725
  >
630
726
  <td v-for="column in table.columns" :key="column.field" :style="columnStyle(column)">
631
- {{ cellValue(column, row) }}
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>
632
772
  </td>
633
773
  </tr>
634
774
  </tbody>
635
775
  <tfoot v-if="table.totals">
636
776
  <tr>
637
777
  <td v-for="column in table.columns" :key="column.field" :style="columnStyle(column)">
638
- {{ table.totals[column.field] ?? "" }}
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>
639
797
  </td>
640
798
  </tr>
641
799
  </tfoot>
@@ -748,6 +906,63 @@ defineExpose({
748
906
  background: var(--accent, #f4f4f5);
749
907
  }
750
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
+
751
966
  .q-chart__empty {
752
967
  margin: 8px 10px;
753
968
  font-size: 12px;
@@ -3158,10 +3158,17 @@ const endResize = () => {
3158
3158
  window.removeEventListener("pointerup", endResize)
3159
3159
  }
3160
3160
 
3161
- const onPointerUpGlobal = () => {
3161
+ const onPointerUpGlobal = (e: PointerEvent) => {
3162
3162
  isPointerDown.value = false
3163
3163
  endResize()
3164
3164
  endRowResize()
3165
+ const target = e.target as HTMLElement | null
3166
+ // Ne restaurer le focus de la grille que si le geste se termine **dans la grille**,
3167
+ // et jamais depuis l'éditeur / ses listes. Sinon `td.focus()` déclenche le `blur`
3168
+ // de l'input **avant** le `click` de l'option : `commitEdit()` ferme l'éditeur et
3169
+ // la valeur cliquée (`select` / `multiselect`) était perdue.
3170
+ if (!target?.closest?.(".q-spreadsheet__viewport")) return
3171
+ if (target.closest(".q-spreadsheet__editor")) return
3165
3172
  const s = sel.value
3166
3173
  if (s) focusCell(s.row, s.column)
3167
3174
  }
package/index.ts CHANGED
@@ -203,7 +203,7 @@ export { DEFAULT_LAYOUT_VIEW, parseView, qLayoutKey } from "./lib/layout"
203
203
  export type { LayoutView, LayoutZone, LayoutZonePlacement, QLayoutContext } from "./lib/layout"
204
204
  export { vRipple } from "./lib/ripple"
205
205
  export type { RippleOptions, RippleValue } from "./lib/ripple"
206
- export { chartToECharts, channelValue, pickFromEvent, tableCsv, tableModel, textValue } from "./lib/chart"
206
+ export { chartToECharts, channelValue, pickFromEvent, tableCsv, tableModel, textValue, tokenCss } from "./lib/chart"
207
207
  export type {
208
208
  QChartAxis,
209
209
  QChartChannel,
@@ -226,6 +226,15 @@ export type {
226
226
  } from "./lib/chart"
227
227
  export { DEFAULT_CURRENCY, DEFAULT_LOCALE, formatValue } from "./lib/chartFormat"
228
228
  export { boxStats, histogram, linearFit, waterfallSteps } from "./lib/chartStats"
229
+ export { cellBarGeometry, cellScale, cellVisualOf, sparklineGeometry } from "./lib/chartTable"
230
+ export type {
231
+ QChartCellBar,
232
+ QChartCellKind,
233
+ QChartCellScale,
234
+ QChartCellVisual,
235
+ QChartSparkPoint,
236
+ QChartSparkline,
237
+ } from "./lib/chartTable"
229
238
  export { vTouchPan } from "./lib/touchPan"
230
239
  export type { PanDetails, TouchPanHandler, PanDirection } from "./lib/touchPan"
231
240
  export { vTouchHold } from "./lib/touchHold"
package/lib/chart.ts CHANGED
@@ -32,8 +32,11 @@
32
32
  import { axisFormatter, DEFAULT_LOCALE, formatValue, timeTickFormatter } from "./chartFormat"
33
33
  import type { QChartFormat } from "./chartFormat"
34
34
  import { boxStats, categoryTotals, histogram, linearFit, waterfallSteps } from "./chartStats"
35
+ import { cellScale, cellVisualOf } from "./chartTable"
36
+ import type { QChartCellScale, QChartCellVisual } from "./chartTable"
35
37
 
36
38
  export type { QChartFormat, QChartFormatKind, QChartFormatOptions } from "./chartFormat"
39
+ export type { QChartCellKind, QChartCellScale, QChartCellVisual } from "./chartTable"
37
40
 
38
41
  /** Valeur d'un canal : nom de champ, valeurs explicites ou fonction d'accès */
39
42
  export type QChartChannel = string | readonly any[] | ((d: any, i: number) => any) | number
@@ -94,6 +97,9 @@ export interface QChartColumn {
94
97
  sortable?: boolean
95
98
  /** Agrégat de la ligne de **totaux** (`sum`, `avg`, `min`, `max`, `count`) */
96
99
  summary?: "sum" | "avg" | "min" | "max" | "count"
100
+ /** **Micro-visualisation** de la cellule (façon Power BI) : barre de données, barre
101
+ * divergente `delta`, intensité `heat` ou `sparkline`. `true` = barre de données. */
102
+ cell?: QChartCellVisual | boolean
97
103
  }
98
104
 
99
105
  /** Séparateurs d'une marque `table` : `horizontal` (défaut — un trait sous chaque rangée),
@@ -118,6 +124,10 @@ export interface QChartTable {
118
124
  sortDesc?: boolean
119
125
  /** Ligne de **totaux** déjà formatée (clé = champ) */
120
126
  totals?: Record<string, string>
127
+ /** Valeurs **brutes** des agrégats de la ligne de totaux (pour ses barres de cellule) */
128
+ totalValues?: Record<string, number>
129
+ /** Bornes par colonne, calculées sur les lignes visibles (barres / heat / delta) */
130
+ cellScales?: Record<string, QChartCellScale>
121
131
  }
122
132
 
123
133
  /** État de vue d'une table (tri / page), piloté par le composant */
@@ -138,14 +148,16 @@ function compareCells(a: any, b: any): number {
138
148
  return sa < sb ? -1 : sa > sb ? 1 : 0
139
149
  }
140
150
 
141
- /** Agrège une colonne pour la ligne de totaux (`undefined` = pas d'agrégat) */
142
- function summarize(column: QChartColumn, rows: any[]): string | undefined {
151
+ /** Agrège une colonne pour la ligne de totaux (`undefined` = pas d'agrégat).
152
+ * Renvoie la valeur **brute** (pour les barres du pied de table) et son texte formaté. */
153
+ function aggregate(column: QChartColumn, rows: any[]): { value?: number; formatted?: string } | undefined {
143
154
  const raw = rows.map((row) => row?.[column.field])
144
155
  const nums = raw.filter((v): v is number => typeof v === "number" && Number.isFinite(v))
145
- const mode = column.summary ?? (nums.length === raw.filter((v) => v !== null && v !== undefined).length && nums.length > 0 ? "sum" : undefined)
156
+ const nonEmpty = raw.filter((v) => v !== null && v !== undefined).length
157
+ const mode = column.summary ?? (nums.length === nonEmpty && nums.length > 0 ? "sum" : undefined)
146
158
  if (!mode) return undefined
147
- if (mode === "count") return String(rows.length)
148
- if (nums.length === 0) return ""
159
+ if (mode === "count") return { value: rows.length, formatted: String(rows.length) }
160
+ if (nums.length === 0) return { formatted: "" }
149
161
  const value =
150
162
  mode === "sum"
151
163
  ? nums.reduce((s, v) => s + v, 0)
@@ -154,7 +166,10 @@ function summarize(column: QChartColumn, rows: any[]): string | undefined {
154
166
  : mode === "min"
155
167
  ? Math.min(...nums)
156
168
  : Math.max(...nums)
157
- return column.format ? column.format(value, undefined) : String(Number(value.toPrecision(12)))
169
+ return {
170
+ value,
171
+ formatted: column.format ? column.format(value, undefined) : String(Number(value.toPrecision(12))),
172
+ }
158
173
  }
159
174
 
160
175
  /** Modèle d'une marque `table` : colonnes déclarées ou déduites des lignes, lignes jointes à la
@@ -198,14 +213,36 @@ export function tableModel(
198
213
 
199
214
  // ── Totaux (sur toutes les lignes visibles, pas seulement la page) ──
200
215
  let totals: Record<string, string> | undefined
216
+ let totalValues: Record<string, number> | undefined
201
217
  if (mark.totals) {
202
218
  const label = typeof mark.totals === "object" ? (mark.totals.label ?? "") : ""
203
219
  const out: Record<string, string> = {}
220
+ const raw: Record<string, number> = {}
204
221
  columns.forEach((column, i) => {
205
- const value = i === 0 && label ? label : summarize(column, sorted)
206
- if (value !== undefined) out[column.field] = value
222
+ const agg = aggregate(column, sorted)
223
+ if (i === 0 && label) out[column.field] = label
224
+ else if (agg?.formatted !== undefined) out[column.field] = agg.formatted
225
+ if (agg?.value !== undefined) raw[column.field] = agg.value
207
226
  })
208
227
  if (Object.keys(out).length) totals = out
228
+ if (Object.keys(raw).length) totalValues = raw
229
+ }
230
+
231
+ // ── Échelles des micro-visualisations (barres / heat / delta) ──
232
+ let cellScales: Record<string, QChartCellScale> | undefined
233
+ const visualColumns = columns.filter((column) => {
234
+ const visual = cellVisualOf(column.cell)
235
+ return !!visual && visual.type !== "sparkline"
236
+ })
237
+ if (visualColumns.length) {
238
+ cellScales = {}
239
+ for (const column of visualColumns) {
240
+ const visual = cellVisualOf(column.cell)!
241
+ cellScales[column.field] = cellScale(
242
+ sorted.map((row) => row?.[column.field]),
243
+ visual,
244
+ )
245
+ }
209
246
  }
210
247
 
211
248
  // ── Pagination ──
@@ -228,6 +265,8 @@ export function tableModel(
228
265
  model.allRows = sorted
229
266
  }
230
267
  if (totals) model.totals = totals
268
+ if (totalValues) model.totalValues = totalValues
269
+ if (cellScales) model.cellScales = cellScales
231
270
  return model
232
271
  }
233
272
 
@@ -1027,6 +1066,18 @@ const TOKEN_FALLBACKS: Record<string, string> = {
1027
1066
 
1028
1067
  const isToken = (v: any): v is string => typeof v === "string" && v in TOKEN_FALLBACKS
1029
1068
 
1069
+ /** Couleur de **cellule de table** (CSS) : un token `primary` / `chart-1` devient
1070
+ * `var(--primary, #1976d2)` ; toute autre valeur CSS est rendue telle quelle.
1071
+ * Les tokens d'un thème hôte qui ne sont pas dans `TOKEN_FALLBACKS` (ex. `muted-foreground`)
1072
+ * sont acceptés dès qu'ils contiennent un tiret — on leur donne `fallback` pour repli. */
1073
+ export function tokenCss(value: string | undefined, fallback = "currentColor"): string {
1074
+ if (!value) return fallback
1075
+ if (isToken(value) || /^[a-z][a-z0-9-]*-[a-z0-9-]+$/i.test(value)) {
1076
+ return `var(--${value}, ${TOKEN_FALLBACKS[value] ?? fallback})`
1077
+ }
1078
+ return value
1079
+ }
1080
+
1030
1081
  /** Style de l'info-bulle : carte translucide floutée (verre), rayon 3px, ombre portée.
1031
1082
  * `var(--card)` rend la surface solidaire du thème hôte (clair/sombre) sans relecture. */
1032
1083
  const TOOLTIP_EXTRA_CSS = [
@@ -0,0 +1,140 @@
1
+ // Tests des **micro-visualisations de cellule** de la marque `table` (barres de données,
2
+ // barres divergentes, heat, sparklines) — purs, sans DOM.
3
+ import { describe, expect, it } from "bun:test"
4
+ import { cellBarGeometry, cellScale, cellVisualOf, sparklineGeometry } from "./chartTable"
5
+ import { tableModel } from "./chart"
6
+
7
+ describe("cellVisualOf", () => {
8
+ it("`true` = barre de données, objet rendu tel quel, absent = rien", () => {
9
+ expect(cellVisualOf(true)).toEqual({ type: "bar" })
10
+ expect(cellVisualOf(undefined)).toBeUndefined()
11
+ expect(cellVisualOf(false)).toBeUndefined()
12
+ expect(cellVisualOf({ type: "delta" })).toEqual({ type: "delta" })
13
+ })
14
+ })
15
+
16
+ describe("cellScale", () => {
17
+ it("bornes observées, ramenées à zéro", () => {
18
+ expect(cellScale([10, 50, 100], { type: "bar" })).toEqual({ min: 0, max: 100, maxAbs: 100 })
19
+ expect(cellScale([-10, 20], { type: "delta" })).toEqual({ min: -10, max: 20, maxAbs: 20 })
20
+ })
21
+
22
+ it("bornes explicites (`min` / `max`) et valeurs non numériques ignorées", () => {
23
+ expect(cellScale([10, null, "x", 30], { type: "bar", min: 0, max: 200 })).toEqual({
24
+ min: 0,
25
+ max: 200,
26
+ maxAbs: 200,
27
+ })
28
+ expect(cellScale([], { type: "bar" })).toEqual({ min: 0, max: 0, maxAbs: Number.EPSILON })
29
+ })
30
+ })
31
+
32
+ describe("cellBarGeometry", () => {
33
+ const scale = { min: 0, max: 100, maxAbs: 100 }
34
+
35
+ it("`bar` : barre à gauche proportionnelle à la valeur", () => {
36
+ expect(cellBarGeometry(50, { type: "bar" }, scale)).toEqual({
37
+ width: 50,
38
+ left: 0,
39
+ ratio: 0.5,
40
+ diverging: false,
41
+ sign: 1,
42
+ })
43
+ expect(cellBarGeometry(-25, { type: "bar" }, scale)?.width).toBe(25)
44
+ expect(cellBarGeometry(0, { type: "bar" }, scale)?.width).toBe(0)
45
+ })
46
+
47
+ it("`delta` : divergente depuis 50 %, positives à droite", () => {
48
+ expect(cellBarGeometry(25, { type: "delta" }, scale)).toMatchObject({
49
+ left: 50,
50
+ width: 12.5,
51
+ diverging: true,
52
+ sign: 1,
53
+ })
54
+ expect(cellBarGeometry(-25, { type: "delta" }, scale)).toMatchObject({
55
+ left: 37.5,
56
+ width: 12.5,
57
+ diverging: true,
58
+ sign: -1,
59
+ })
60
+ })
61
+
62
+ it("`heat` : barre pleine largeur, l'intensité porte la valeur", () => {
63
+ expect(cellBarGeometry(25, { type: "heat" }, scale)).toMatchObject({ left: 0, width: 100, ratio: 0.25 })
64
+ })
65
+
66
+ it("aucune barre pour une sparkline ou une valeur manquante", () => {
67
+ expect(cellBarGeometry(10, { type: "sparkline" }, scale)).toBeUndefined()
68
+ expect(cellBarGeometry(undefined, { type: "bar" }, scale)).toBeUndefined()
69
+ expect(cellBarGeometry("x", { type: "bar" }, scale)).toBeUndefined()
70
+ })
71
+ })
72
+
73
+ describe("sparklineGeometry", () => {
74
+ it("trace la ligne, la surface et le point final", () => {
75
+ const spark = sparklineGeometry([1, 2, 3], { point: "last" })!
76
+ expect(spark.points.length).toBe(3)
77
+ expect(spark.path.startsWith("M2,")).toBe(true)
78
+ expect(spark.path).toContain("L98,2")
79
+ expect(spark.areaPath).toContain("Z")
80
+ expect(spark.marker).toEqual(spark.points[2]!)
81
+ })
82
+
83
+ it("met en évidence `first`, `high`, `low` ou rien", () => {
84
+ const first = sparklineGeometry([3, 1, 7], { point: "first" })!
85
+ expect(first.marker).toEqual(first.points[0]!)
86
+ const high = sparklineGeometry([3, 1, 7], { point: "high" })!
87
+ expect(high.marker).toEqual(high.points[2]!)
88
+ const low = sparklineGeometry([3, 1, 7], { point: "low" })!
89
+ expect(low.marker).toEqual(low.points[1]!)
90
+ expect(sparklineGeometry([3, 1, 7], { point: false })!.marker).toBeUndefined()
91
+ })
92
+
93
+ it("valeurs constantes : ligne au milieu ; aucune valeur : rien", () => {
94
+ const flat = sparklineGeometry([5, 5, 5])!
95
+ expect(flat.points.every((p) => p.y === flat.points[0]!.y)).toBe(true)
96
+ expect(flat.points[0]!.y).toBe(12)
97
+ expect(sparklineGeometry([])).toBeUndefined()
98
+ expect(sparklineGeometry(undefined)).toBeUndefined()
99
+ })
100
+ })
101
+
102
+ describe("tableModel — échelles et totaux des micro-visualisations", () => {
103
+ const rows = [
104
+ { type: "A", orders: 100, target: 10, trend: [1, 2, 3] },
105
+ { type: "B", orders: 50, target: -5, trend: [3, 2, 1] },
106
+ ]
107
+ const mark = {
108
+ type: "table" as const,
109
+ data: rows,
110
+ totals: { label: "Total" },
111
+ columns: [
112
+ { field: "type", label: "Type" },
113
+ { field: "orders", label: "Orders", cell: { type: "bar" as const } },
114
+ { field: "target", label: "vs Target", cell: { type: "delta" as const } },
115
+ { field: "trend", label: "Trend", cell: { type: "sparkline" as const } },
116
+ ],
117
+ }
118
+
119
+ it("calcule une échelle par colonne visualisée (jamais pour une sparkline)", () => {
120
+ const model = tableModel(mark)
121
+ expect(model.cellScales).toEqual({
122
+ orders: { min: 0, max: 100, maxAbs: 100 },
123
+ target: { min: -5, max: 10, maxAbs: 10 },
124
+ })
125
+ expect(model.cellScales?.trend).toBeUndefined()
126
+ })
127
+
128
+ it("expose les valeurs **brutes** des totaux pour les barres du pied de table", () => {
129
+ const model = tableModel(mark)
130
+ expect(model.totalValues).toEqual({ orders: 150, target: 5 })
131
+ expect(model.totals).toEqual({ type: "Total", orders: "150", target: "5" })
132
+ })
133
+
134
+ it("sans `cell`, pas d'échelle (modèle inchangé)", () => {
135
+ const model = tableModel({ type: "table", data: rows, columns: ["type", "orders"] })
136
+ expect(model.cellScales).toBeUndefined()
137
+ expect(model.totalValues).toBeUndefined()
138
+ expect(model.totals).toBeUndefined()
139
+ })
140
+ })
@@ -0,0 +1,193 @@
1
+ // Micro-visualisations **dans les cellules** d'une marque `table` (façon Power BI) :
2
+ // barres de données, barres divergentes (`vs. target`), intensité de cellule et sparklines.
3
+ //
4
+ // Tout est **pur** : les échelles sont calculées sur les lignes visibles et la géométrie est
5
+ // renvoyée en pourcentages / en tracé SVG. `QChart.vue` ne fait que poser des styles.
6
+
7
+ import type { QChartFormat } from "./chartFormat"
8
+
9
+ /** Famille de visualisation d'une cellule */
10
+ export type QChartCellKind = "bar" | "delta" | "heat" | "sparkline"
11
+
12
+ /** Configuration d'une cellule (`column.cell`). `cell: true` équivaut à `{ type: "bar" }`. */
13
+ export interface QChartCellVisual {
14
+ /** Type de micro-visualisation */
15
+ type: QChartCellKind
16
+ /** Couleur principale : barre, ligne de sparkline, valeurs positives */
17
+ color?: string
18
+ /** Couleur des valeurs **négatives** (type `delta`) et des barres descendantes */
19
+ negativeColor?: string
20
+ /** Couleur du point de sparkline (défaut : `color`) */
21
+ pointColor?: string
22
+ /** Borne basse de l'échelle (défaut : `min(0, valeurs)`) */
23
+ min?: number
24
+ /** Borne haute de l'échelle (défaut : `max(0, valeurs)`) */
25
+ max?: number
26
+ /** Opacité de la barre (défauts : `bar` 0.18, `delta` 0.85, `heat` calculée) */
27
+ opacity?: number
28
+ /** `sparkline` : champ de la ligne qui porte la série (tableau de nombres) */
29
+ field?: string
30
+ /** `sparkline` : accès direct à la série (prioritaire sur `field`) */
31
+ values?: (row: any) => readonly number[] | undefined
32
+ /** `sparkline` : point mis en évidence (défaut : `last`) */
33
+ point?: "last" | "first" | "high" | "low" | false
34
+ /** `sparkline` : remplir la surface sous la ligne */
35
+ area?: boolean
36
+ /** `delta` : afficher ▲ / ▼ (défaut : oui pour `delta`, non sinon) */
37
+ arrow?: boolean
38
+ /** `delta` : imprimer la valeur **absolue** (défaut : oui — la flèche porte le signe) */
39
+ absolute?: boolean
40
+ /** Format de la valeur affichée (`compact`, `percent`, `currency`…) si la colonne n'a pas de `format` */
41
+ format?: QChartFormat
42
+ }
43
+
44
+ /** Bornes d'une colonne, calculées sur les lignes visibles */
45
+ export interface QChartCellScale {
46
+ min: number
47
+ max: number
48
+ /** Plus grande amplitude par rapport à zéro (barres divergentes) */
49
+ maxAbs: number
50
+ }
51
+
52
+ const finite = (v: any): v is number => typeof v === "number" && Number.isFinite(v)
53
+
54
+ /** Normalise `column.cell` : `true` → barre de données, objet → tel quel. */
55
+ export function cellVisualOf(
56
+ cell: QChartCellVisual | boolean | undefined,
57
+ ): QChartCellVisual | undefined {
58
+ if (cell === true) return { type: "bar" }
59
+ return cell || undefined
60
+ }
61
+
62
+ const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v))
63
+
64
+ /** Échelle d'une colonne : bornes explicites (`cell.min` / `cell.max`) ou observées. */
65
+ export function cellScale(values: readonly any[], cell: QChartCellVisual): QChartCellScale {
66
+ const nums = values.filter(finite)
67
+ const min = cell.min ?? (nums.length ? Math.min(0, ...nums) : 0)
68
+ const max = cell.max ?? (nums.length ? Math.max(0, ...nums) : 0)
69
+ const maxAbs = Math.max(Math.abs(min), Math.abs(max), Number.EPSILON)
70
+ return { min, max, maxAbs }
71
+ }
72
+
73
+ /** Géométrie d'une barre de cellule (en % de la largeur / hauteur de la cellule). */
74
+ export interface QChartCellBar {
75
+ /** Largeur, en % de la cellule */
76
+ width: number
77
+ /** Décalage depuis la gauche, en % */
78
+ left: number
79
+ /** Part de l'échelle (0 → 1) — sert à l'intensité du type `heat` */
80
+ ratio: number
81
+ /** Barre **divergente** (part de 50 %, négatifs vers la gauche) ? */
82
+ diverging: boolean
83
+ /** Signe de la valeur (-1, 0, 1) */
84
+ sign: -1 | 0 | 1
85
+ }
86
+
87
+ /** Positionne la barre d'une cellule.
88
+ *
89
+ * - `bar` : barre **à gauche**, longueur proportionnelle à `|valeur| / maxAbs` ;
90
+ * - `delta` : barre **divergente** depuis le centre (50 %), positives à droite ;
91
+ * - `heat` : barre pleine largeur, l'opacité porte l'intensité ;
92
+ * - `sparkline` : pas de barre (`undefined`). */
93
+ export function cellBarGeometry(
94
+ value: any,
95
+ cell: QChartCellVisual,
96
+ scale: QChartCellScale,
97
+ ): QChartCellBar | undefined {
98
+ if (cell.type === "sparkline" || !finite(value)) return undefined
99
+ const sign: -1 | 0 | 1 = value > 0 ? 1 : value < 0 ? -1 : 0
100
+ const span = scale.max - scale.min
101
+ const ratio = span > 0 ? clamp((value - scale.min) / span, 0, 1) : 0
102
+ if (cell.type === "delta") {
103
+ const half = clamp((Math.abs(value) / scale.maxAbs) * 50, 0, 50)
104
+ return {
105
+ width: half,
106
+ left: value >= 0 ? 50 : 50 - half,
107
+ ratio: clamp(Math.abs(value) / scale.maxAbs, 0, 1),
108
+ diverging: true,
109
+ sign,
110
+ }
111
+ }
112
+ if (cell.type === "heat") {
113
+ return { width: 100, left: 0, ratio, diverging: false, sign }
114
+ }
115
+ // `bar` : longueur proportionnelle à l'amplitude, ancrée à gauche
116
+ return {
117
+ width: clamp((Math.abs(value) / scale.maxAbs) * 100, 0, 100),
118
+ left: 0,
119
+ ratio: clamp(Math.abs(value) / scale.maxAbs, 0, 1),
120
+ diverging: false,
121
+ sign,
122
+ }
123
+ }
124
+
125
+ /** Point d'un tracé de sparkline */
126
+ export interface QChartSparkPoint {
127
+ x: number
128
+ y: number
129
+ }
130
+
131
+ /** Tracé d'une sparkline, dans un `viewBox` de `width` × `height` */
132
+ export interface QChartSparkline {
133
+ path: string
134
+ areaPath: string
135
+ points: QChartSparkPoint[]
136
+ /** Point mis en évidence (`point: false` → aucun) */
137
+ marker?: QChartSparkPoint
138
+ }
139
+
140
+ const round2 = (v: number) => Math.round(v * 100) / 100
141
+
142
+ /** Construit la polyligne d'une sparkline.
143
+ *
144
+ * `viewBox` par défaut : `100 × 24`, avec 2 px de marge. Les valeurs non finies sont
145
+ * ignorées. Sans aucune valeur, `undefined` (la cellule affiche son texte). */
146
+ export function sparklineGeometry(
147
+ values: readonly any[] | undefined,
148
+ options: {
149
+ point?: "last" | "first" | "high" | "low" | false
150
+ width?: number
151
+ height?: number
152
+ padding?: number
153
+ } = {},
154
+ ): QChartSparkline | undefined {
155
+ const nums = (values ?? []).filter(finite)
156
+ if (nums.length === 0) return undefined
157
+ const width = options.width ?? 100
158
+ const height = options.height ?? 24
159
+ const padding = options.padding ?? 2
160
+ const innerW = Math.max(1, width - padding * 2)
161
+ const innerH = Math.max(1, height - padding * 2)
162
+ const min = Math.min(...nums)
163
+ const max = Math.max(...nums)
164
+ const flat = max === min
165
+ const span = max - min || 1
166
+ const stepX = nums.length > 1 ? innerW / (nums.length - 1) : 0
167
+ const points: QChartSparkPoint[] = nums.map((v, i) => ({
168
+ x: padding + i * stepX,
169
+ y: padding + (flat ? innerH / 2 : (1 - (v - min) / span) * innerH),
170
+ }))
171
+ const path = points.map((p, i) => `${i === 0 ? "M" : "L"}${round2(p.x)},${round2(p.y)}`).join(" ")
172
+ const first = points[0]!
173
+ const last = points[points.length - 1]!
174
+ const areaPath = `${path} L${round2(last.x)},${round2(height - padding)} L${round2(first.x)},${round2(
175
+ height - padding,
176
+ )} Z`
177
+ const markerIndex =
178
+ options.point === false
179
+ ? undefined
180
+ : options.point === "first"
181
+ ? 0
182
+ : options.point === "high"
183
+ ? nums.indexOf(max)
184
+ : options.point === "low"
185
+ ? nums.indexOf(min)
186
+ : points.length - 1
187
+ return {
188
+ path,
189
+ areaPath,
190
+ points,
191
+ ...(markerIndex !== undefined && points[markerIndex] ? { marker: points[markerIndex] } : {}),
192
+ }
193
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dnax/ui",
3
- "version": "0.0.69",
3
+ "version": "0.0.70",
4
4
  "module": "index.ts",
5
5
  "description": "UI design System",
6
6
  "type": "module",