@dnax/ui 0.0.67 → 0.0.70
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/QChart.vue +552 -27
- package/components/QSpreadsheet.vue +43 -18
- package/index.ts +20 -1
- package/lib/chart.axis.test.ts +1 -1
- package/lib/chart.bi.test.ts +839 -0
- package/lib/chart.modules.test.ts +232 -0
- package/lib/chart.test.ts +13 -4
- package/lib/chart.ts +1451 -80
- package/lib/chartFormat.ts +222 -0
- package/lib/chartStats.ts +178 -0
- package/lib/chartTable.test.ts +140 -0
- package/lib/chartTable.ts +193 -0
- package/lib/history.test.ts +78 -0
- package/lib/history.ts +72 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|