@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.
- package/components/QChart.vue +329 -19
- package/components/QSpreadsheet.vue +194 -37
- package/index.ts +11 -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 +1400 -80
- package/lib/chartFormat.ts +222 -0
- package/lib/chartStats.ts +178 -0
- package/lib/formula.test.ts +8 -0
- package/lib/formula.ts +4 -2
- package/lib/history.test.ts +78 -0
- package/lib/history.ts +72 -0
- package/lib/spreadsheet.test.ts +110 -0
- package/lib/spreadsheet.ts +204 -5
- package/package.json +1 -1
- package/styles/main.css +65 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// Formatage des valeurs de `<q-chart>` (axes, info-bulles, étiquettes, tableaux).
|
|
2
|
+
//
|
|
3
|
+
// Le traducteur (`chartToECharts`) reste **pur** : ce module ne lit ni le DOM ni la locale
|
|
4
|
+
// du navigateur. La locale vient de la prop `locale` (défaut : `fr-FR`, cohérent avec le
|
|
5
|
+
// reste du design system), éventuellement surchargée par un format.
|
|
6
|
+
//
|
|
7
|
+
// Rien n'est formaté tant que l'appelant ne demande rien : sans `format`, une valeur sort
|
|
8
|
+
// telle quelle (`String(value)`), donc aucun changement de comportement rétroactif.
|
|
9
|
+
|
|
10
|
+
/** Format « court » : une chaîne suffit (`format: "compact"`) */
|
|
11
|
+
export type QChartFormatKind =
|
|
12
|
+
| "number"
|
|
13
|
+
| "decimal"
|
|
14
|
+
| "percent"
|
|
15
|
+
| "compact"
|
|
16
|
+
| "currency"
|
|
17
|
+
| "date"
|
|
18
|
+
| "time"
|
|
19
|
+
| "datetime"
|
|
20
|
+
|
|
21
|
+
/** Contexte passé aux formateurs (fonction ou objet) */
|
|
22
|
+
export interface QChartFormatContext {
|
|
23
|
+
/** Locale de repli (celle du graphique) */
|
|
24
|
+
locale?: string
|
|
25
|
+
/** Index de la donnée dans sa marque */
|
|
26
|
+
index?: number
|
|
27
|
+
/** Ligne d'origine */
|
|
28
|
+
row?: any
|
|
29
|
+
/** Nom de la série / de la catégorie, si connu */
|
|
30
|
+
name?: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Format complet (objet) */
|
|
34
|
+
export interface QChartFormatOptions {
|
|
35
|
+
/** Famille de formatage */
|
|
36
|
+
style?: QChartFormatKind
|
|
37
|
+
/** Locale BCP 47 (`fr-FR`, `en-US`…) — prioritaire sur celle du graphique */
|
|
38
|
+
locale?: string
|
|
39
|
+
/** Devise ISO 4217 pour `currency` (`XOF` par défaut → « F CFA ») */
|
|
40
|
+
currency?: string
|
|
41
|
+
/** Fuseau horaire IANA (`Africa/Dakar`…) pour les formats de date */
|
|
42
|
+
timeZone?: string
|
|
43
|
+
minimumFractionDigits?: number
|
|
44
|
+
maximumFractionDigits?: number
|
|
45
|
+
/** `date` / `datetime` : style de la partie date */
|
|
46
|
+
dateStyle?: "full" | "long" | "medium" | "short"
|
|
47
|
+
/** `time` / `datetime` : style de la partie heure */
|
|
48
|
+
timeStyle?: "full" | "long" | "medium" | "short"
|
|
49
|
+
/** Texte ajouté avant / après la valeur formatée */
|
|
50
|
+
prefix?: string
|
|
51
|
+
suffix?: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Un format : chaîne courte, objet d'options, ou fonction `(value, ctx) => string` */
|
|
55
|
+
export type QChartFormat =
|
|
56
|
+
| QChartFormatKind
|
|
57
|
+
| QChartFormatOptions
|
|
58
|
+
| ((value: any, ctx?: QChartFormatContext) => string)
|
|
59
|
+
|
|
60
|
+
/** Locale par défaut de tous les formats (l'application peut la changer par prop) */
|
|
61
|
+
export const DEFAULT_LOCALE = "fr-FR"
|
|
62
|
+
|
|
63
|
+
/** Devise par défaut : franc CFA (lisible en `fr-FR` : « 1 234 F CFA ») */
|
|
64
|
+
export const DEFAULT_CURRENCY = "XOF"
|
|
65
|
+
|
|
66
|
+
const isObject = (v: any): v is QChartFormatOptions => v !== null && typeof v === "object"
|
|
67
|
+
|
|
68
|
+
/** Normalise un format en options exploitables (`undefined` si aucun format demandé) */
|
|
69
|
+
export function normalizeFormat(
|
|
70
|
+
format: QChartFormat | undefined,
|
|
71
|
+
): QChartFormatOptions | undefined {
|
|
72
|
+
if (format === undefined || format === null) return undefined
|
|
73
|
+
if (typeof format === "string") return { style: format }
|
|
74
|
+
if (typeof format === "function") return undefined
|
|
75
|
+
if (isObject(format)) return format
|
|
76
|
+
return undefined
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Nombre fini ? (`null`, `""`, `NaN` sont écartés) */
|
|
80
|
+
const finite = (v: any): v is number => typeof v === "number" && Number.isFinite(v)
|
|
81
|
+
|
|
82
|
+
/** `value` → `Date` (nombre = timestamp, chaîne = ISO / date lisible) */
|
|
83
|
+
function toDate(value: any): Date | undefined {
|
|
84
|
+
if (value instanceof Date) return Number.isNaN(value.getTime()) ? undefined : value
|
|
85
|
+
if (finite(value)) {
|
|
86
|
+
// Un timestamp en **secondes** (API usuelles) est ramené en millisecondes.
|
|
87
|
+
const ms = Math.abs(value) < 1e11 ? value * 1000 : value
|
|
88
|
+
const d = new Date(ms)
|
|
89
|
+
return Number.isNaN(d.getTime()) ? undefined : d
|
|
90
|
+
}
|
|
91
|
+
if (typeof value === "string" && value.trim() !== "") {
|
|
92
|
+
const d = new Date(value)
|
|
93
|
+
return Number.isNaN(d.getTime()) ? undefined : d
|
|
94
|
+
}
|
|
95
|
+
return undefined
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const dateFormatted = (value: any, options: QChartFormatOptions, locale: string): string | undefined => {
|
|
99
|
+
const date = toDate(value)
|
|
100
|
+
if (!date) return undefined
|
|
101
|
+
const style = options.style
|
|
102
|
+
const opts: Intl.DateTimeFormatOptions = { timeZone: options.timeZone }
|
|
103
|
+
if (style === "date" || style === "datetime") {
|
|
104
|
+
opts.dateStyle = options.dateStyle ?? (style === "datetime" ? "short" : "medium")
|
|
105
|
+
}
|
|
106
|
+
if (style === "time" || style === "datetime") {
|
|
107
|
+
opts.timeStyle = options.timeStyle ?? "short"
|
|
108
|
+
}
|
|
109
|
+
return new Intl.DateTimeFormat(locale, opts).format(date)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const numberFormatted = (value: any, options: QChartFormatOptions, locale: string): string | undefined => {
|
|
113
|
+
const style = options.style ?? "number"
|
|
114
|
+
if (typeof value === "boolean") return String(value)
|
|
115
|
+
if (!finite(value)) return undefined
|
|
116
|
+
const digits: Intl.NumberFormatOptions = {}
|
|
117
|
+
if (options.minimumFractionDigits !== undefined) digits.minimumFractionDigits = options.minimumFractionDigits
|
|
118
|
+
if (options.maximumFractionDigits !== undefined) digits.maximumFractionDigits = options.maximumFractionDigits
|
|
119
|
+
if (style === "compact") {
|
|
120
|
+
return new Intl.NumberFormat(locale, {
|
|
121
|
+
notation: "compact",
|
|
122
|
+
compactDisplay: "short",
|
|
123
|
+
maximumFractionDigits: options.maximumFractionDigits ?? 1,
|
|
124
|
+
}).format(value)
|
|
125
|
+
}
|
|
126
|
+
if (style === "currency") {
|
|
127
|
+
return new Intl.NumberFormat(locale, {
|
|
128
|
+
style: "currency",
|
|
129
|
+
currency: options.currency ?? DEFAULT_CURRENCY,
|
|
130
|
+
...digits,
|
|
131
|
+
}).format(value)
|
|
132
|
+
}
|
|
133
|
+
if (style === "percent") {
|
|
134
|
+
// La valeur est **déjà en pourcentage** (42 → « 42 % ») : `Intl` multiplie par 100,
|
|
135
|
+
// on divise donc avant (et non l'inverse, qui donnerait 4200 %).
|
|
136
|
+
return new Intl.NumberFormat(locale, {
|
|
137
|
+
style: "percent",
|
|
138
|
+
maximumFractionDigits: options.maximumFractionDigits ?? 1,
|
|
139
|
+
...digits,
|
|
140
|
+
}).format(value / 100)
|
|
141
|
+
}
|
|
142
|
+
// `number` / `decimal` / style absent : nombre localisé
|
|
143
|
+
return new Intl.NumberFormat(locale, {
|
|
144
|
+
maximumFractionDigits: options.maximumFractionDigits ?? 2,
|
|
145
|
+
...digits,
|
|
146
|
+
}).format(value)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Formate une valeur selon `format`.
|
|
150
|
+
*
|
|
151
|
+
* - `undefined` / `null` → chaîne vide ;
|
|
152
|
+
* - sans `format` → `String(value)` (aucun formatage implicite) ;
|
|
153
|
+
* - `percent` attend une valeur **en pourcentage** (`42` → « 42 % ») ;
|
|
154
|
+
* - `currency` utilise `XOF` (F CFA) par défaut ;
|
|
155
|
+
* - une date peut être un `Date`, un timestamp (s/ms) ou une chaîne ISO. */
|
|
156
|
+
export function formatValue(
|
|
157
|
+
value: any,
|
|
158
|
+
format?: QChartFormat,
|
|
159
|
+
ctx: QChartFormatContext = {},
|
|
160
|
+
): string {
|
|
161
|
+
if (typeof format === "function") {
|
|
162
|
+
const out = format(value, ctx)
|
|
163
|
+
return out === undefined || out === null ? "" : String(out)
|
|
164
|
+
}
|
|
165
|
+
if (value === undefined || value === null || value === "") return ""
|
|
166
|
+
const options = normalizeFormat(format)
|
|
167
|
+
if (!options) return String(value)
|
|
168
|
+
const locale = options.locale ?? ctx.locale ?? DEFAULT_LOCALE
|
|
169
|
+
const temporal =
|
|
170
|
+
options.style === "date" || options.style === "time" || options.style === "datetime"
|
|
171
|
+
const text = temporal
|
|
172
|
+
? (dateFormatted(value, options, locale) ?? String(value))
|
|
173
|
+
: (numberFormatted(value, options, locale) ?? String(value))
|
|
174
|
+
return `${options.prefix ?? ""}${text}${options.suffix ?? ""}`
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Un format de pourcentage sans `%` (pour composer soi-même un libellé) */
|
|
178
|
+
export function formatPercent(value: any, format?: QChartFormat, ctx?: QChartFormatContext): string {
|
|
179
|
+
const options = normalizeFormat(format)
|
|
180
|
+
const merged: QChartFormat = { ...(options ?? {}), style: "percent" }
|
|
181
|
+
return formatValue(value, merged, ctx)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
/** Formateur ECharts pour un axe (`axisLabel.formatter`), ou `undefined` si rien n'est demandé. */
|
|
186
|
+
export function axisFormatter(
|
|
187
|
+
format: QChartFormat | undefined,
|
|
188
|
+
ctx: QChartFormatContext = {},
|
|
189
|
+
): ((value: any) => string) | undefined {
|
|
190
|
+
if (format === undefined || format === null) return undefined
|
|
191
|
+
return (value: any) => formatValue(value, format, ctx)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Graduation d'un axe **temporel** selon sa granularité.
|
|
195
|
+
* `auto` (défaut) laisse le moteur choisir : aucune fonction n'est renvoyée. */
|
|
196
|
+
export function timeTickFormatter(
|
|
197
|
+
unit: "auto" | "day" | "week" | "month" | "quarter" | "year" | undefined,
|
|
198
|
+
locale = DEFAULT_LOCALE,
|
|
199
|
+
timeZone?: string,
|
|
200
|
+
): ((value: any) => string) | undefined {
|
|
201
|
+
if (!unit || unit === "auto") return undefined
|
|
202
|
+
return (value: any) => {
|
|
203
|
+
const date = toDate(value)
|
|
204
|
+
if (!date) return String(value ?? "")
|
|
205
|
+
if (unit === "quarter") {
|
|
206
|
+
const quarter = Math.floor(date.getUTCMonth() / 3) + 1
|
|
207
|
+
const year = String(date.getUTCFullYear()).slice(2)
|
|
208
|
+
return `T${quarter} ${year}`
|
|
209
|
+
}
|
|
210
|
+
const opts: Intl.DateTimeFormatOptions = { timeZone }
|
|
211
|
+
if (unit === "day" || unit === "week") {
|
|
212
|
+
opts.day = "numeric"
|
|
213
|
+
opts.month = "short"
|
|
214
|
+
} else if (unit === "month") {
|
|
215
|
+
opts.month = "short"
|
|
216
|
+
opts.year = "2-digit"
|
|
217
|
+
} else {
|
|
218
|
+
opts.year = "numeric"
|
|
219
|
+
}
|
|
220
|
+
return new Intl.DateTimeFormat(locale, opts).format(date)
|
|
221
|
+
}
|
|
222
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// Calculs statistiques du traducteur de graphiques — **purs**, testables sans DOM.
|
|
2
|
+
//
|
|
3
|
+
// Utilisés par les marques `histogram`, `boxplot` et `waterfall` (P1 de `QChart`).
|
|
4
|
+
// Aucune notion de couleur ni de rendu : uniquement des nombres, pour que les tests
|
|
5
|
+
// portent sur la sémantique (tranches, quartiles, cumul) et non sur ECharts.
|
|
6
|
+
|
|
7
|
+
/** Une tranche d'histogramme */
|
|
8
|
+
export interface QChartBin {
|
|
9
|
+
/** Borne basse (incluse) */
|
|
10
|
+
x0: number
|
|
11
|
+
/** Borne haute (exclue, sauf la dernière tranche) */
|
|
12
|
+
x1: number
|
|
13
|
+
/** Nombre de valeurs dans la tranche */
|
|
14
|
+
count: number
|
|
15
|
+
/** Libellé par défaut (`x0 – x1`) — l'appelant peut le reformater */
|
|
16
|
+
label: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Arrondit un nombre de borne pour les libellés (évite `0.30000000000000004`) */
|
|
20
|
+
const clean = (v: number) => Number(v.toPrecision(12))
|
|
21
|
+
|
|
22
|
+
/** Histogramme : répartit des valeurs numériques en `bins` tranches.
|
|
23
|
+
*
|
|
24
|
+
* - `bins` par défaut : `ceil(sqrt(n))` (règle de la racine), borné à `[1, 40]` ;
|
|
25
|
+
* - `domain` force les bornes ; sinon `[min, max]` des valeurs ;
|
|
26
|
+
* - une valeur `null` / non finie est ignorée ;
|
|
27
|
+
* - la borne haute de la **dernière** tranche est inclusive (la valeur maximale compte). */
|
|
28
|
+
export function histogram(
|
|
29
|
+
values: readonly any[],
|
|
30
|
+
options: { bins?: number; domain?: [number, number] } = {},
|
|
31
|
+
): QChartBin[] {
|
|
32
|
+
const nums = values.filter((v): v is number => typeof v === "number" && Number.isFinite(v))
|
|
33
|
+
if (nums.length === 0) return []
|
|
34
|
+
let lo = options.domain?.[0] ?? Math.min(...nums)
|
|
35
|
+
let hi = options.domain?.[1] ?? Math.max(...nums)
|
|
36
|
+
if (!Number.isFinite(lo) || !Number.isFinite(hi)) return []
|
|
37
|
+
if (hi < lo) [lo, hi] = [hi, lo]
|
|
38
|
+
const requested = Math.max(1, Math.min(40, Math.round(options.bins ?? Math.ceil(Math.sqrt(nums.length)))))
|
|
39
|
+
// Domaine dégénéré (une seule valeur) : une tranche autour de la valeur
|
|
40
|
+
if (hi === lo) {
|
|
41
|
+
return [{ x0: lo, x1: hi, count: nums.length, label: `${clean(lo)}` }]
|
|
42
|
+
}
|
|
43
|
+
// Le nombre de tranches demandé est **respecté** : pas d'arrondi « joli » qui le changerait.
|
|
44
|
+
const step = (hi - lo) / requested
|
|
45
|
+
const bins: QChartBin[] = Array.from({ length: requested }, (_, i) => ({
|
|
46
|
+
x0: clean(lo + i * step),
|
|
47
|
+
x1: clean(lo + (i + 1) * step),
|
|
48
|
+
count: 0,
|
|
49
|
+
label: "",
|
|
50
|
+
}))
|
|
51
|
+
for (const v of nums) {
|
|
52
|
+
// La valeur maximale tombe dans la dernière tranche (borne haute inclusive)
|
|
53
|
+
let i = Math.floor((v - lo) / step)
|
|
54
|
+
if (i < 0) i = 0
|
|
55
|
+
if (i >= requested) i = requested - 1
|
|
56
|
+
const bin = bins[i]
|
|
57
|
+
if (bin) bin.count++
|
|
58
|
+
}
|
|
59
|
+
for (const bin of bins) bin.label = `${clean(bin.x0)} – ${clean(bin.x1)}`
|
|
60
|
+
return bins
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Quantile (interpolation linéaire, méthode R-7 — celle d'Excel `QUARTILE.INC`) */
|
|
64
|
+
export function quantile(sorted: readonly number[], p: number): number {
|
|
65
|
+
if (sorted.length === 0) return Number.NaN
|
|
66
|
+
const pos = (sorted.length - 1) * Math.max(0, Math.min(1, p))
|
|
67
|
+
const lower = Math.floor(pos)
|
|
68
|
+
const upper = Math.ceil(pos)
|
|
69
|
+
if (lower === upper) return sorted[lower]!
|
|
70
|
+
return sorted[lower]! + (sorted[upper]! - sorted[lower]!) * (pos - lower)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Boîte à moustaches (cinq nombres) */
|
|
74
|
+
export interface QChartBox {
|
|
75
|
+
min: number
|
|
76
|
+
q1: number
|
|
77
|
+
median: number
|
|
78
|
+
q3: number
|
|
79
|
+
max: number
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Statistiques d'une série de valeurs (`undefined` si aucune valeur numérique) */
|
|
83
|
+
export function boxStats(values: readonly any[]): QChartBox | undefined {
|
|
84
|
+
const nums = values
|
|
85
|
+
.filter((v): v is number => typeof v === "number" && Number.isFinite(v))
|
|
86
|
+
.slice()
|
|
87
|
+
.sort((a, b) => a - b)
|
|
88
|
+
if (nums.length === 0) return undefined
|
|
89
|
+
return {
|
|
90
|
+
min: nums[0]!,
|
|
91
|
+
q1: quantile(nums, 0.25),
|
|
92
|
+
median: quantile(nums, 0.5),
|
|
93
|
+
q3: quantile(nums, 0.75),
|
|
94
|
+
max: nums[nums.length - 1]!,
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Ajustement affine par moindres carrés — `undefined` si les abscisses sont toutes égales */
|
|
99
|
+
export function linearFit(
|
|
100
|
+
points: readonly (readonly [number, number])[],
|
|
101
|
+
): { slope: number; intercept: number } | undefined {
|
|
102
|
+
const pts = points.filter(
|
|
103
|
+
([x, y]) => typeof x === "number" && typeof y === "number" && Number.isFinite(x) && Number.isFinite(y),
|
|
104
|
+
)
|
|
105
|
+
if (pts.length < 2) return undefined
|
|
106
|
+
const n = pts.length
|
|
107
|
+
const sumX = pts.reduce((s, [x]) => s + x, 0)
|
|
108
|
+
const sumY = pts.reduce((s, [, y]) => s + y, 0)
|
|
109
|
+
const meanX = sumX / n
|
|
110
|
+
const meanY = sumY / n
|
|
111
|
+
let num = 0
|
|
112
|
+
let den = 0
|
|
113
|
+
for (const [x, y] of pts) {
|
|
114
|
+
num += (x - meanX) * (y - meanY)
|
|
115
|
+
den += (x - meanX) ** 2
|
|
116
|
+
}
|
|
117
|
+
if (den === 0) return undefined
|
|
118
|
+
const slope = num / den
|
|
119
|
+
return { slope, intercept: meanY - slope * meanX }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Étape d'un graphique en cascade (waterfall) */
|
|
123
|
+
export interface QChartWaterfallStep {
|
|
124
|
+
/** Valeur de départ de la barre (le cumul précédent) */
|
|
125
|
+
base: number
|
|
126
|
+
/** Hauteur signée de la barre (le delta) */
|
|
127
|
+
delta: number
|
|
128
|
+
/** Cumul après l'étape */
|
|
129
|
+
total: number
|
|
130
|
+
/** Ligne de **total** (barre posée depuis zéro) ? */
|
|
131
|
+
totalRow: boolean
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Décompose des deltas en barres flottantes cumulées.
|
|
135
|
+
*
|
|
136
|
+
* `flags[i] === true` marque une ligne de **total** : la barre part de zéro et sa hauteur
|
|
137
|
+
* est le cumul courant (elle ne s'ajoute pas une seconde fois au cumul). */
|
|
138
|
+
export function waterfallSteps(
|
|
139
|
+
values: readonly any[],
|
|
140
|
+
flags: readonly any[] = [],
|
|
141
|
+
): QChartWaterfallStep[] {
|
|
142
|
+
const steps: QChartWaterfallStep[] = []
|
|
143
|
+
let running = 0
|
|
144
|
+
for (let i = 0; i < values.length; i++) {
|
|
145
|
+
const raw = values[i]
|
|
146
|
+
const value = typeof raw === "number" && Number.isFinite(raw) ? raw : 0
|
|
147
|
+
const totalRow = flags[i] === true || flags[i] === "total"
|
|
148
|
+
if (totalRow) {
|
|
149
|
+
steps.push({ base: 0, delta: running, total: running, totalRow: true })
|
|
150
|
+
continue
|
|
151
|
+
}
|
|
152
|
+
steps.push({ base: running, delta: value, total: running + value, totalRow: false })
|
|
153
|
+
running += value
|
|
154
|
+
}
|
|
155
|
+
return steps
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Répartition en pourcentage (somme des valeurs absolues, `0` évité).
|
|
159
|
+
* Une valeur nulle ou non finie vaut `0` : la part reste calculable. */
|
|
160
|
+
export function percentShares(values: readonly any[]): number[] {
|
|
161
|
+
const nums = values.map((v) => (typeof v === "number" && Number.isFinite(v) ? v : 0))
|
|
162
|
+
const total = nums.reduce((s, v) => s + Math.abs(v), 0)
|
|
163
|
+
return total === 0 ? nums.map(() => 0) : nums.map((v) => (Math.abs(v) / total) * 100)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Somme par catégorie — sert au calcul des **empilements 100 %**. */
|
|
167
|
+
export function categoryTotals(
|
|
168
|
+
entries: readonly { category: any; value: any }[],
|
|
169
|
+
): Map<string, number> {
|
|
170
|
+
const totals = new Map<string, number>()
|
|
171
|
+
for (const { category, value } of entries) {
|
|
172
|
+
if (category === undefined || category === null) continue
|
|
173
|
+
if (typeof value !== "number" || !Number.isFinite(value)) continue
|
|
174
|
+
const key = String(category)
|
|
175
|
+
totals.set(key, (totals.get(key) ?? 0) + value)
|
|
176
|
+
}
|
|
177
|
+
return totals
|
|
178
|
+
}
|
package/lib/formula.test.ts
CHANGED
|
@@ -184,6 +184,14 @@ describe("fonctions dates", () => {
|
|
|
184
184
|
})
|
|
185
185
|
})
|
|
186
186
|
|
|
187
|
+
describe("espaces autour de la formule", () => {
|
|
188
|
+
const e = harness([[]])
|
|
189
|
+
it("accepte des espaces avant le `=` (isFormulaRaw les tolère)", () => {
|
|
190
|
+
expect(e(" =1+2")).toBe(3)
|
|
191
|
+
expect(e(" =SUM(1,2) ")).toBe(3)
|
|
192
|
+
})
|
|
193
|
+
})
|
|
194
|
+
|
|
187
195
|
describe("math & erreurs de type", () => {
|
|
188
196
|
const e = harness([[]])
|
|
189
197
|
it("ABS / ROUND / INT / MOD / SQRT / POWER", () => {
|
package/lib/formula.ts
CHANGED
|
@@ -1349,9 +1349,11 @@ export const FORMULA_FUNCTION_NAMES: string[] = [
|
|
|
1349
1349
|
]),
|
|
1350
1350
|
].sort()
|
|
1351
1351
|
|
|
1352
|
-
/** Évalue une formule ("=…" optionnel). Retourne une valeur ou une FormulaError. */
|
|
1352
|
+
/** Évalue une formule ("=…" optionnel, espaces ignorés). Retourne une valeur ou une FormulaError. */
|
|
1353
1353
|
export function evaluateFormula(source: string, ctx: FormulaContext): FormulaValue {
|
|
1354
|
-
|
|
1354
|
+
// `isFormulaRaw` accepte des espaces avant le `=` : le moteur doit en faire autant.
|
|
1355
|
+
const trimmed = source.trim()
|
|
1356
|
+
const src = trimmed.startsWith("=") ? trimmed.slice(1) : trimmed
|
|
1355
1357
|
try {
|
|
1356
1358
|
const parser = new Parser(src)
|
|
1357
1359
|
const node = parser.parseExpr()
|
|
@@ -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
|
+
}
|