@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.
@@ -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
+ }
@@ -0,0 +1,78 @@
1
+ // Historique undo/redo — le point critique est la **réactivité** : des piles en tableau
2
+ // simple laissent `canUndo` figé à `false` (boutons inertes, Ctrl+Z sans effet).
3
+ import { describe, expect, it } from "bun:test"
4
+ import { effect, nextTick } from "vue"
5
+ import { createHistory } from "./history"
6
+
7
+ describe("createHistory", () => {
8
+ it("canUndo / canRedo sont réactifs (piles remplacées, pas mutées)", async () => {
9
+ const h = createHistory<string>(10)
10
+ const seen: boolean[] = []
11
+ effect(() => {
12
+ seen.push(h.canUndo.value)
13
+ })
14
+ expect(seen).toEqual([false])
15
+
16
+ h.push("a")
17
+ await nextTick()
18
+ expect(seen.at(-1)).toBe(true)
19
+
20
+ h.undo("b")
21
+ await nextTick()
22
+ expect(h.canRedo.value).toBe(true)
23
+ expect(h.canUndo.value).toBe(false)
24
+
25
+ h.redo("c")
26
+ await nextTick()
27
+ expect(h.canUndo.value).toBe(true)
28
+ expect(seen).toContain(true)
29
+ })
30
+
31
+ it("dépile dans le bon ordre et empile l'état courant dans la pile opposée", () => {
32
+ const h = createHistory<number>()
33
+ h.push(1)
34
+ h.push(2)
35
+
36
+ expect(h.undo(99)).toBe(2)
37
+ expect(h.undo(98)).toBe(1)
38
+ expect(h.undo(97)).toBeNull()
39
+ expect(h.canUndo.value).toBe(false)
40
+ expect(h.canRedo.value).toBe(true)
41
+
42
+ expect(h.redo(50)).toBe(98)
43
+ expect(h.redo(51)).toBe(99)
44
+ expect(h.redo(52)).toBeNull()
45
+ expect(h.canUndo.value).toBe(true)
46
+ })
47
+
48
+ it("une nouvelle action vide la pile redo", () => {
49
+ const h = createHistory<number>()
50
+ h.push(1)
51
+ h.undo(2)
52
+ expect(h.canRedo.value).toBe(true)
53
+
54
+ h.push(3)
55
+ expect(h.canRedo.value).toBe(false)
56
+ expect(h.redo(4)).toBeNull()
57
+ })
58
+
59
+ it("borne la pile à `limit` (la plus ancienne entrée est oubliée)", () => {
60
+ const h = createHistory<number>(2)
61
+ h.push(1)
62
+ h.push(2)
63
+ h.push(3)
64
+ expect(h.undoStack.value).toEqual([2, 3])
65
+ expect(h.undo(0)).toBe(3)
66
+ expect(h.undo(0)).toBe(2)
67
+ expect(h.undo(0)).toBeNull()
68
+ })
69
+
70
+ it("clear vide les deux piles", () => {
71
+ const h = createHistory<number>()
72
+ h.push(1)
73
+ h.undo(2)
74
+ h.clear()
75
+ expect(h.canUndo.value).toBe(false)
76
+ expect(h.canRedo.value).toBe(false)
77
+ })
78
+ })
package/lib/history.ts ADDED
@@ -0,0 +1,72 @@
1
+ // Historique undo/redo réactif (QSpreadsheet).
2
+ //
3
+ // Les piles doivent être des `shallowRef` **remplacées** à chaque opération : un `push`
4
+ // sur un tableau simple ne déclenche aucune réactivité. C'est exactement le bug qui
5
+ // figeait `canUndo`/`canRedo` à `false` (boutons désactivés et Ctrl+Z inerte), même
6
+ // quand `pushHistory()` avait bien été appelé (suppression de ligne, tri, remplissage…).
7
+ import { computed, shallowRef } from "vue"
8
+ import type { ComputedRef, ShallowRef } from "vue"
9
+
10
+ export interface History<T> {
11
+ readonly undoStack: ShallowRef<T[]>
12
+ readonly redoStack: ShallowRef<T[]>
13
+ readonly canUndo: ComputedRef<boolean>
14
+ readonly canRedo: ComputedRef<boolean>
15
+ /** Enregistre un état **avant** mutation et vide la pile redo. */
16
+ push: (snapshot: T) => void
17
+ /** Dépile undo (et empile `current` dans redo) → instantané à restaurer. */
18
+ undo: (current: T) => T | null
19
+ /** Dépile redo (et empile `current` dans undo) → instantané à restaurer. */
20
+ redo: (current: T) => T | null
21
+ /** Vide les deux piles. */
22
+ clear: () => void
23
+ }
24
+
25
+ /**
26
+ * Piles d'historique bornées (`limit`, la plus ancienne entrée est oubliée).
27
+ * Toutes les opérations renvoient/réaffectent des tableaux **neufs** pour rester réactives.
28
+ */
29
+ export function createHistory<T>(limit = 100): History<T> {
30
+ const undoStack = shallowRef<T[]>([])
31
+ const redoStack = shallowRef<T[]>([])
32
+
33
+ const push = (snapshot: T) => {
34
+ const next = [...undoStack.value, snapshot]
35
+ undoStack.value = next.length > limit ? next.slice(next.length - limit) : next
36
+ redoStack.value = []
37
+ }
38
+
39
+ const undo = (current: T): T | null => {
40
+ const stack = undoStack.value
41
+ if (!stack.length) return null
42
+ const snapshot = stack[stack.length - 1]!
43
+ undoStack.value = stack.slice(0, -1)
44
+ redoStack.value = [...redoStack.value, current]
45
+ return snapshot
46
+ }
47
+
48
+ const redo = (current: T): T | null => {
49
+ const stack = redoStack.value
50
+ if (!stack.length) return null
51
+ const snapshot = stack[stack.length - 1]!
52
+ redoStack.value = stack.slice(0, -1)
53
+ undoStack.value = [...undoStack.value, current]
54
+ return snapshot
55
+ }
56
+
57
+ const clear = () => {
58
+ undoStack.value = []
59
+ redoStack.value = []
60
+ }
61
+
62
+ return {
63
+ undoStack,
64
+ redoStack,
65
+ canUndo: computed(() => undoStack.value.length > 0),
66
+ canRedo: computed(() => redoStack.value.length > 0),
67
+ push,
68
+ undo,
69
+ redo,
70
+ clear,
71
+ }
72
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dnax/ui",
3
- "version": "0.0.67",
3
+ "version": "0.0.70",
4
4
  "module": "index.ts",
5
5
  "description": "UI design System",
6
6
  "type": "module",