@dnax/ui 0.0.58 → 0.0.60
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/QSpreadsheet.vue +84 -18
- package/lib/chart.axis.test.ts +127 -0
- package/lib/chart.ts +17 -3
- package/lib/spreadsheetRows.test.ts +53 -0
- package/lib/spreadsheetRows.ts +34 -0
- package/package.json +1 -1
|
@@ -193,6 +193,7 @@ import {
|
|
|
193
193
|
import { checkValidation, type ArkCheck } from "../lib/spreadsheetValidation"
|
|
194
194
|
import { normalizeCellOptions } from "../lib/spreadsheetOptions"
|
|
195
195
|
import { inLockRect, lockRects, prefillForRow } from "../lib/spreadsheetZones"
|
|
196
|
+
import { canAddRows, canRemoveRows, clampRows } from "../lib/spreadsheetRows"
|
|
196
197
|
import {
|
|
197
198
|
DEFAULT_ROW_KEY,
|
|
198
199
|
diffDocuments,
|
|
@@ -264,6 +265,27 @@ interface Props {
|
|
|
264
265
|
* les noms de colonnes inconnus sont ignorés.
|
|
265
266
|
*/
|
|
266
267
|
prefill?: QSpreadsheetFill[]
|
|
268
|
+
/**
|
|
269
|
+
* Applique `prefill` **automatiquement** aux cellules **vides** dès que le document est prêt
|
|
270
|
+
* (chargement, remplacement de `rows` / `columns`) — sans avoir à appeler `applyPrefill()`.
|
|
271
|
+
* Le résultat est **écrit dans `rows`** (`update:rows`) : utiliser `v-model:rows` (ou écouter
|
|
272
|
+
* `@update:rows`) pour le recevoir. Sans historique ni marquage « modifié ». Une ligne
|
|
273
|
+
* **neuve** naît de toute façon préremplie. `false` → application sur appel explicite
|
|
274
|
+
* uniquement. (Défaut : `true`.)
|
|
275
|
+
*/
|
|
276
|
+
autoPrefill?: boolean
|
|
277
|
+
/**
|
|
278
|
+
* Nombre **minimum** de lignes : impossible de descendre en dessous (la suppression des lignes
|
|
279
|
+
* sélectionnées est refusée si elle passerait sous le plancher). Combiné avec `maxRows` à la
|
|
280
|
+
* **même** valeur, le nombre de lignes est **figé**. (Défaut : aucune limite.)
|
|
281
|
+
*/
|
|
282
|
+
minRows?: number
|
|
283
|
+
/**
|
|
284
|
+
* Nombre **maximum** de lignes : le bouton « + », l'insertion au curseur et l'import CSV
|
|
285
|
+
* s'arrêtent à cette limite (`:max-rows="1"` → feuille à **une seule** ligne). Les chargements
|
|
286
|
+
* programmatiques (`loadDocument`) ne sont pas bornés. (Défaut : aucune limite.)
|
|
287
|
+
*/
|
|
288
|
+
maxRows?: number
|
|
267
289
|
/** Affiche le numéro de ligne (colonne de gauche) */
|
|
268
290
|
showRowNumbers?: boolean
|
|
269
291
|
/** Affiche l'en-tête de colonne (lettre + label) */
|
|
@@ -499,6 +521,7 @@ const emit = defineEmits<{
|
|
|
499
521
|
"cell-edit-end": [payload: { row: number; column: string; canceled: boolean }]
|
|
500
522
|
"selection-change": [value: QSpreadsheetSelection | null]
|
|
501
523
|
"structure-change": [payload: { rows: Record<string, any>[]; columns: QSpreadsheetColumn[]; reason: string }]
|
|
524
|
+
"prefill": [payload: { count: number; mode: "auto" | "manual" }]
|
|
502
525
|
"update:dirty": [value: boolean]
|
|
503
526
|
"update:changes": [value: QSpreadsheetChanges]
|
|
504
527
|
}>()
|
|
@@ -542,6 +565,8 @@ const ensureRowKeys = (rows: Record<string, any>[] | undefined) => {
|
|
|
542
565
|
}
|
|
543
566
|
|
|
544
567
|
// ─── Données internes (copie éditable des rows) ───
|
|
568
|
+
// Préremplissage : tant que `false`, toute application auto est ignorée (avant montage).
|
|
569
|
+
let prefillReady = false
|
|
545
570
|
const state = ref<Record<string, any>[]>([])
|
|
546
571
|
watch(
|
|
547
572
|
() => props.rows,
|
|
@@ -553,6 +578,8 @@ watch(
|
|
|
553
578
|
state.value = v.map((r) => ({ ...r }))
|
|
554
579
|
// Lignes **externes** (nouveau document) → nouvelle référence de comparaison
|
|
555
580
|
if (trackReady) acceptChanges()
|
|
581
|
+
// Nouveau document → applique les défauts sur les cellules vides.
|
|
582
|
+
autoPrefill()
|
|
556
583
|
}
|
|
557
584
|
},
|
|
558
585
|
{ immediate: true },
|
|
@@ -571,6 +598,8 @@ watch(
|
|
|
571
598
|
cols.value = v.map((c) => ({ ...c }))
|
|
572
599
|
// Colonnes **externes** (nouveau schéma) → nouvelle référence de comparaison
|
|
573
600
|
if (trackReady) acceptChanges()
|
|
601
|
+
// Les zones de `prefill` dépendent des noms de colonnes → (re)applique.
|
|
602
|
+
autoPrefill()
|
|
574
603
|
}
|
|
575
604
|
},
|
|
576
605
|
{ immediate: true },
|
|
@@ -580,6 +609,12 @@ const pushCols = (next: QSpreadsheetColumn[]) => {
|
|
|
580
609
|
emit("update:columns", next)
|
|
581
610
|
}
|
|
582
611
|
|
|
612
|
+
// ─── Limites de lignes (`minRows` / `maxRows`) ───
|
|
613
|
+
/** Peut-on encore ajouter une ligne ? (bouton « + », insertion au curseur) */
|
|
614
|
+
const canAddRow = computed(() => canAddRows(state.value.length, 1, props.maxRows))
|
|
615
|
+
/** Peut-on retirer au moins une ligne ? (plancher `minRows`) */
|
|
616
|
+
const canRemoveRow = computed(() => canRemoveRows(state.value.length, 1, props.minRows))
|
|
617
|
+
|
|
583
618
|
const colIndex = (name: string) => cols.value.findIndex((c) => c.name === name)
|
|
584
619
|
const colNameAt = (i: number) => cols.value[i]?.name ?? ""
|
|
585
620
|
const colOf = (name: string) => cols.value.find((c) => c.name === name)
|
|
@@ -1279,11 +1314,11 @@ const blankRow = (at = state.value.length): Record<string, any> => {
|
|
|
1279
1314
|
}
|
|
1280
1315
|
|
|
1281
1316
|
/**
|
|
1282
|
-
*
|
|
1283
|
-
*
|
|
1284
|
-
* `rows`
|
|
1317
|
+
* Cœur du préremplissage : ne remplit que les cellules **vides** et renvoie le nombre rempli.
|
|
1318
|
+
* `mode` distingue l'application **automatique** (silencieuse) de l'appel **explicite**
|
|
1319
|
+
* (avec historique). Émet `update:rows` (si quelque chose a changé) puis `prefill`.
|
|
1285
1320
|
*/
|
|
1286
|
-
|
|
1321
|
+
function runPrefill(mode: "auto" | "manual"): number {
|
|
1287
1322
|
const fills = props.prefill
|
|
1288
1323
|
if (!fills?.length || props.disable) return 0
|
|
1289
1324
|
const names = cols.value.map((c) => c.name)
|
|
@@ -1300,14 +1335,35 @@ const applyPrefill = (): number => {
|
|
|
1300
1335
|
return out
|
|
1301
1336
|
})
|
|
1302
1337
|
if (!count) return 0
|
|
1303
|
-
pushHistory()
|
|
1338
|
+
if (mode === "manual") pushHistory()
|
|
1304
1339
|
state.value = next
|
|
1305
1340
|
emit("update:rows", next)
|
|
1341
|
+
emit("prefill", { count, mode })
|
|
1306
1342
|
return count
|
|
1307
1343
|
}
|
|
1308
1344
|
|
|
1345
|
+
/**
|
|
1346
|
+
* Comble les cellules **vides** d'après `prefill` (jamais une valeur existante) et renvoie le
|
|
1347
|
+
* nombre de cellules remplies. Appel **explicite** (avec historique) — l'application au
|
|
1348
|
+
* chargement est automatique (cf. `autoPrefill`).
|
|
1349
|
+
*/
|
|
1350
|
+
function applyPrefill(): number {
|
|
1351
|
+
return runPrefill("manual")
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
/**
|
|
1355
|
+
* Application **automatique** (document prêt, nouveau document) : silencieuse — sans
|
|
1356
|
+
* historique, pour ne pas polluer l'annulation ni marquer la grille « modifiée ». **Écrit le
|
|
1357
|
+
* résultat dans `rows`** (`update:rows`). Désactivable via `:auto-prefill="false"`.
|
|
1358
|
+
*/
|
|
1359
|
+
function autoPrefill(): void {
|
|
1360
|
+
if (!prefillReady || props.autoPrefill === false) return
|
|
1361
|
+
runPrefill("auto")
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1309
1364
|
const addRow = (at?: number) => {
|
|
1310
1365
|
if (props.readonly || props.disable) return
|
|
1366
|
+
if (!canAddRow.value) return
|
|
1311
1367
|
pushHistory()
|
|
1312
1368
|
const idx = at ?? state.value.length
|
|
1313
1369
|
const next = [...state.value]
|
|
@@ -1320,12 +1376,14 @@ const addRow = (at?: number) => {
|
|
|
1320
1376
|
|
|
1321
1377
|
const removeSelectedRows = () => {
|
|
1322
1378
|
if (props.readonly || props.disable) return
|
|
1323
|
-
pushHistory()
|
|
1324
|
-
purgeMergesAndRules()
|
|
1325
1379
|
const rect = selRect.value
|
|
1326
1380
|
if (!rect || !state.value.length) return
|
|
1327
1381
|
const indexes = new Set<number>()
|
|
1328
1382
|
for (let r = rect.r0; r <= rect.r1; r++) indexes.add(r)
|
|
1383
|
+
// Plancher `minRows` : on refuse si la suppression descendrait en dessous.
|
|
1384
|
+
if (!canRemoveRows(state.value.length, indexes.size, props.minRows)) return
|
|
1385
|
+
pushHistory()
|
|
1386
|
+
purgeMergesAndRules()
|
|
1329
1387
|
const sorted = [...indexes].sort((a, b) => b - a)
|
|
1330
1388
|
const next = [...state.value]
|
|
1331
1389
|
for (const i of sorted) next.splice(i, 1)
|
|
@@ -2591,6 +2649,7 @@ const doSortBy = (desc = false) => {
|
|
|
2591
2649
|
// ─── Insertion au curseur (lignes / colonnes) ───
|
|
2592
2650
|
const insertRowAt = (pos: "above" | "below") => {
|
|
2593
2651
|
if (!canEdit.value) return
|
|
2652
|
+
if (!canAddRow.value) return
|
|
2594
2653
|
pushHistory()
|
|
2595
2654
|
purgeMergesAndRules()
|
|
2596
2655
|
if (!state.value.length) {
|
|
@@ -2986,6 +3045,10 @@ const onFilterSearch = (e: Event) => {
|
|
|
2986
3045
|
}
|
|
2987
3046
|
|
|
2988
3047
|
onMounted(() => {
|
|
3048
|
+
// Préremplissage automatique en premier : la référence initiale ci-dessous doit
|
|
3049
|
+
// l'inclure, sinon les défauts compteraient comme des « modifications ».
|
|
3050
|
+
prefillReady = true
|
|
3051
|
+
autoPrefill()
|
|
2989
3052
|
// Référence initiale : à partir d'ici, toute différence avec elle = « modifié »
|
|
2990
3053
|
trackReady = true
|
|
2991
3054
|
acceptChanges()
|
|
@@ -3159,15 +3222,16 @@ watch(
|
|
|
3159
3222
|
const prevKey = localSheets.value[sheetIdx.value]?.key
|
|
3160
3223
|
let n = 1
|
|
3161
3224
|
localSheets.value = v.map((s) => {
|
|
3162
|
-
|
|
3163
|
-
|
|
3225
|
+
// `num` capturé AVANT l'incrément : clé **et** nom par défaut partagent le même numéro
|
|
3226
|
+
// (sinon le 1er onglet s'appelait « Sheet 2 »).
|
|
3227
|
+
const num = n++
|
|
3164
3228
|
// Clés injectées sur les objets du parent (comme pour `rows`) : chaque feuille est
|
|
3165
3229
|
// identifiable avant même d'être ouverte
|
|
3166
3230
|
ensureRowKeys(s.rows)
|
|
3167
3231
|
return {
|
|
3168
3232
|
...s,
|
|
3169
|
-
key,
|
|
3170
|
-
name: s.name ?? sheetName(
|
|
3233
|
+
key: s.key ?? "sheet-" + num,
|
|
3234
|
+
name: s.name ?? sheetName(num),
|
|
3171
3235
|
rows: (s.rows ?? []).map((r) => ({ ...r })),
|
|
3172
3236
|
columns: s.columns ? canonicalColumns(s.columns) : undefined,
|
|
3173
3237
|
}
|
|
@@ -3192,7 +3256,7 @@ watch([state, cols], () => {
|
|
|
3192
3256
|
|
|
3193
3257
|
const activeSheetName = computed(() => {
|
|
3194
3258
|
const s = localSheets.value[sheetIdx.value]
|
|
3195
|
-
if (s) return s.name ??
|
|
3259
|
+
if (s) return s.name ?? sheetName(sheetIdx.value + 1)
|
|
3196
3260
|
return props.sheets?.[0]?.name ?? sheetName(1)
|
|
3197
3261
|
})
|
|
3198
3262
|
const sheetRename = ref<{ idx: number; val: string } | null>(null)
|
|
@@ -3225,7 +3289,7 @@ const addSheet = () => {
|
|
|
3225
3289
|
// premier onglet (propriété sheets vide) — devient multi
|
|
3226
3290
|
const first: QSpreadsheetSheet = {
|
|
3227
3291
|
key: "sheet-1",
|
|
3228
|
-
name:
|
|
3292
|
+
name: sheetName(1),
|
|
3229
3293
|
columns: cols.value.map((c) => ({ ...c })),
|
|
3230
3294
|
rows: state.value.map((r) => ({ ...r })),
|
|
3231
3295
|
}
|
|
@@ -3622,6 +3686,8 @@ const importCsv = (text: string, opts: { delimiter?: string; headers?: boolean }
|
|
|
3622
3686
|
}))
|
|
3623
3687
|
data = rows
|
|
3624
3688
|
}
|
|
3689
|
+
// Plafond `maxRows` : l'import ne crée pas plus de lignes que la limite.
|
|
3690
|
+
data = clampRows(data, props.maxRows)
|
|
3625
3691
|
const rowObjs = data.map((row) => {
|
|
3626
3692
|
const obj: Record<string, any> = { [ROW_KEY]: newRowKey() }
|
|
3627
3693
|
colsArr.forEach((c, ci) => {
|
|
@@ -4395,11 +4461,11 @@ defineExpose({
|
|
|
4395
4461
|
</span>
|
|
4396
4462
|
|
|
4397
4463
|
<span class="q-spreadsheet__tb-group">
|
|
4398
|
-
<button class="q-spreadsheet__tool" type="button" :disabled="readonly || disable" title="Add row" aria-label="Add row" @click="addRow()">
|
|
4464
|
+
<button class="q-spreadsheet__tool" type="button" :disabled="readonly || disable || !canAddRow" title="Add row" aria-label="Add row" @click="addRow()">
|
|
4399
4465
|
<Icon :icon="icons.rows3" aria-hidden="true" />
|
|
4400
4466
|
<Icon :icon="icons.plus" class="q-spreadsheet__tool-badge" aria-hidden="true" />
|
|
4401
4467
|
</button>
|
|
4402
|
-
<button class="q-spreadsheet__tool" type="button" :disabled="readonly || disable || !selRect" title="Remove selected row(s)" aria-label="Remove rows" @click="removeSelectedRows">
|
|
4468
|
+
<button class="q-spreadsheet__tool" type="button" :disabled="readonly || disable || !selRect || !canRemoveRow" title="Remove selected row(s)" aria-label="Remove rows" @click="removeSelectedRows">
|
|
4403
4469
|
<Icon :icon="icons.minus" aria-hidden="true" />
|
|
4404
4470
|
</button>
|
|
4405
4471
|
<button class="q-spreadsheet__tool" type="button" :disabled="readonly || disable" title="Add column" aria-label="Add column" @click="addColumn()">
|
|
@@ -5012,13 +5078,13 @@ defineExpose({
|
|
|
5012
5078
|
</button>
|
|
5013
5079
|
|
|
5014
5080
|
<div class="q-spreadsheet__sep" />
|
|
5015
|
-
<button type="button" class="q-spreadsheet__mi" :disabled="!canEdit" @click="insertRowAt('above')">
|
|
5081
|
+
<button type="button" class="q-spreadsheet__mi" :disabled="!canEdit || !canAddRow" @click="insertRowAt('above')">
|
|
5016
5082
|
<Icon :icon="icons.arrowUp" aria-hidden="true" /> {{ t('insertRowAbove') }}
|
|
5017
5083
|
</button>
|
|
5018
|
-
<button type="button" class="q-spreadsheet__mi" :disabled="!canEdit" @click="insertRowAt('below')">
|
|
5084
|
+
<button type="button" class="q-spreadsheet__mi" :disabled="!canEdit || !canAddRow" @click="insertRowAt('below')">
|
|
5019
5085
|
<Icon :icon="icons.arrowDown" aria-hidden="true" /> {{ t('insertRowBelow') }}
|
|
5020
5086
|
</button>
|
|
5021
|
-
<button type="button" class="q-spreadsheet__mi" :disabled="!canEdit || !selRect" @click="removeSelectedRows">
|
|
5087
|
+
<button type="button" class="q-spreadsheet__mi" :disabled="!canEdit || !selRect || !canRemoveRow" @click="removeSelectedRows">
|
|
5022
5088
|
<Icon :icon="icons.trash2" aria-hidden="true" /> {{ t('deleteRows') }}
|
|
5023
5089
|
</button>
|
|
5024
5090
|
<div class="q-spreadsheet__sep" />
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Tests du **placement des noms d'axes** de `<q-chart>` : `chartToECharts()` produit bien des
|
|
2
|
+
// noms dans le cadre. Vérifié en rendant l'option en SVG via l'**SSR d'ECharts** — du JS pur,
|
|
3
|
+
// aucun navigateur (cf. règle projet).
|
|
4
|
+
//
|
|
5
|
+
// Rappel du bug corrigé : sans `nameLocation`, le moteur place le nom **à la fin** de l'axe —
|
|
6
|
+
// le nom X s'écrivait vers la droite (hors cadre) et le nom Y, centré sur la ligne d'axe,
|
|
7
|
+
// débordait à gauche (rogné) dès que le libellé était large.
|
|
8
|
+
import { describe, expect, it } from "bun:test"
|
|
9
|
+
import * as echarts from "echarts"
|
|
10
|
+
import { chartToECharts } from "./chart"
|
|
11
|
+
|
|
12
|
+
const THEME = { text: "#000000", muted: "#666666", grid: "#dddddd" }
|
|
13
|
+
/** Largeur approximative d'un caractère à 12px (mesure faite sur le rendu ECharts). */
|
|
14
|
+
const CHAR_W = 7
|
|
15
|
+
const FONT_H = 12
|
|
16
|
+
|
|
17
|
+
interface Box {
|
|
18
|
+
x0: number
|
|
19
|
+
x1: number
|
|
20
|
+
y0: number
|
|
21
|
+
y1: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function renderSvg(config: Record<string, any>, width: number, height: number): string {
|
|
25
|
+
const chart = echarts.init(null as any, null, {
|
|
26
|
+
renderer: "svg",
|
|
27
|
+
ssr: true,
|
|
28
|
+
width,
|
|
29
|
+
height,
|
|
30
|
+
})
|
|
31
|
+
chart.setOption(chartToECharts({ theme: THEME, ...config }))
|
|
32
|
+
const svg = chart.renderToSVGString()
|
|
33
|
+
chart.dispose()
|
|
34
|
+
return svg
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Boîte approximative du `<text>` d'un nom d'axe (via `translate` + `text-anchor`). */
|
|
38
|
+
function nameBox(svg: string, name: string): Box | undefined {
|
|
39
|
+
for (const line of svg.split("\n")) {
|
|
40
|
+
if (!line.includes("<text")) continue
|
|
41
|
+
const text = (line.match(/>([^<]*)<\/text>/) ?? [, ""])[1]
|
|
42
|
+
if (text !== name) continue
|
|
43
|
+
const m = line.match(/translate\(([-\d.]+) ([-\d.]+)\)/)
|
|
44
|
+
if (!m) continue
|
|
45
|
+
const x = Number(m[1])
|
|
46
|
+
const y = Number(m[2])
|
|
47
|
+
const anchor = (line.match(/text-anchor="([^"]+)"/) ?? [, "middle"])[1]
|
|
48
|
+
const w = text.length * CHAR_W
|
|
49
|
+
const x0 = anchor === "start" ? x : anchor === "end" ? x - w : x - w / 2
|
|
50
|
+
return { x0, x1: x0 + w, y0: y - FONT_H / 2, y1: y + FONT_H / 2 }
|
|
51
|
+
}
|
|
52
|
+
return undefined
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const inside = (b: Box, w: number, h: number) =>
|
|
56
|
+
b.x0 >= 0 && b.x1 <= w && b.y0 >= 0 && b.y1 <= h
|
|
57
|
+
|
|
58
|
+
const verticalConfig = {
|
|
59
|
+
marks: [
|
|
60
|
+
{
|
|
61
|
+
type: "line",
|
|
62
|
+
data: [
|
|
63
|
+
{ month: "Jan", value: 1_200_000 },
|
|
64
|
+
{ month: "Feb", value: 3_400_000 },
|
|
65
|
+
{ month: "Mar", value: 2_600_000 },
|
|
66
|
+
],
|
|
67
|
+
x: "month",
|
|
68
|
+
y: "value",
|
|
69
|
+
},
|
|
70
|
+
],
|
|
71
|
+
// libellés volontairement larges (le cas qui rognait le nom Y)
|
|
72
|
+
x: { label: "Month of the year" },
|
|
73
|
+
y: { label: "Revenue in thousands" },
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const horizontalConfig = {
|
|
77
|
+
marks: [
|
|
78
|
+
{
|
|
79
|
+
type: "bar",
|
|
80
|
+
orientation: "horizontal",
|
|
81
|
+
data: [
|
|
82
|
+
{ dept: "Engineering", value: 34 },
|
|
83
|
+
{ dept: "Sales", value: 26 },
|
|
84
|
+
],
|
|
85
|
+
x: "dept",
|
|
86
|
+
y: "value",
|
|
87
|
+
},
|
|
88
|
+
],
|
|
89
|
+
x: { label: "Headcount" },
|
|
90
|
+
y: { label: "Department" },
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
describe("noms d'axes — placement dans le cadre", () => {
|
|
94
|
+
it("X et Y restent dans le cadre (vertical, horizontal, petit canevas)", () => {
|
|
95
|
+
const cases: [Record<string, any>, number, number][] = [
|
|
96
|
+
[verticalConfig, 520, 280],
|
|
97
|
+
[horizontalConfig, 520, 280],
|
|
98
|
+
[verticalConfig, 300, 160],
|
|
99
|
+
]
|
|
100
|
+
for (const [config, w, h] of cases) {
|
|
101
|
+
const svg = renderSvg(config, w, h)
|
|
102
|
+
const bx = nameBox(svg, config.x.label)
|
|
103
|
+
const by = nameBox(svg, config.y.label)
|
|
104
|
+
expect(bx).toBeDefined()
|
|
105
|
+
expect(by).toBeDefined()
|
|
106
|
+
expect(inside(bx!, w, h)).toBe(true)
|
|
107
|
+
expect(inside(by!, w, h)).toBe(true)
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it("le nom X est centré sous l'axe, pas collé au bord droit", () => {
|
|
112
|
+
const svg = renderSvg(verticalConfig, 520, 280)
|
|
113
|
+
const bx = nameBox(svg, verticalConfig.x.label)!
|
|
114
|
+
// centré : commence bien après le bord gauche et finit bien avant le bord droit
|
|
115
|
+
expect(bx.x0).toBeGreaterThan(60)
|
|
116
|
+
expect(bx.x1).toBeLessThan(520 - 60)
|
|
117
|
+
// sous la zone de tracé
|
|
118
|
+
expect(bx.y0).toBeGreaterThan(200)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it("le nom Y est ancré à gauche sur la ligne d'axe (jamais rogné), en haut", () => {
|
|
122
|
+
const svg = renderSvg(verticalConfig, 520, 280)
|
|
123
|
+
const by = nameBox(svg, verticalConfig.y.label)!
|
|
124
|
+
expect(by.x0).toBeGreaterThan(0)
|
|
125
|
+
expect(by.y1).toBeLessThan(160)
|
|
126
|
+
})
|
|
127
|
+
})
|
package/lib/chart.ts
CHANGED
|
@@ -1180,7 +1180,7 @@ export function chartToECharts(config: QChartConfig): Record<string, any> {
|
|
|
1180
1180
|
const scaleBand = visualMaps.some((vm) => vm.show !== false) ? 30 : 0
|
|
1181
1181
|
/** Bandeau du **nom d'axe** (nom de l'axe y au-dessus, nom de l'axe x en dessous) */
|
|
1182
1182
|
const axisNameTop = config.y?.label ? 16 : 0
|
|
1183
|
-
const axisNameBottom = config.x?.label ?
|
|
1183
|
+
const axisNameBottom = config.x?.label ? 22 : 0
|
|
1184
1184
|
const legendOption = !legendOn
|
|
1185
1185
|
? { show: false }
|
|
1186
1186
|
: {
|
|
@@ -1241,22 +1241,36 @@ export function chartToECharts(config: QChartConfig): Record<string, any> {
|
|
|
1241
1241
|
splitLine: { show: axis?.grid ?? true, lineStyle: { color: theme.grid } },
|
|
1242
1242
|
})
|
|
1243
1243
|
|
|
1244
|
+
// ─── Nom d'axe : placement **toujours dans le cadre** ───
|
|
1245
|
+
// Axe **horizontal** : centré sous l'axe (`middle`). À la fin de l'axe (`end`, le défaut
|
|
1246
|
+
// ECharts) le nom s'écrivait vers la droite, hors du cadre.
|
|
1247
|
+
const axisNameX = { nameLocation: "middle" as const, nameGap: 32 }
|
|
1248
|
+
// Axe **vertical** : ancré à **gauche** (`align: left`) sur la ligne d'axe, en haut.
|
|
1249
|
+
// Un `nameGap` fixe ne peut pas convenir : un nom centré sur l'axe déborde à gauche, un nom
|
|
1250
|
+
// rotaté au milieu recouvre/est rogné par les nombres (largeur variable : `1,000` vs
|
|
1251
|
+
// `1,000,000`). Ancré à gauche, il s'étend vers la droite → jamais rogné, jamais superposé.
|
|
1252
|
+
const axisNameY = {
|
|
1253
|
+
nameLocation: "end" as const,
|
|
1254
|
+
nameTextStyle: { ...axisLabel, align: "left" as const },
|
|
1255
|
+
}
|
|
1244
1256
|
// Orientation horizontale : catégories sur l'axe Y
|
|
1245
1257
|
const hasBar = series.some((s) => s.type === "bar")
|
|
1246
|
-
const
|
|
1258
|
+
const xAxisBase = hasHeatmap
|
|
1247
1259
|
? categoryAxis(config.x, categories, true)
|
|
1248
1260
|
: horizontal
|
|
1249
1261
|
? valueAxis(config.x, hasBar)
|
|
1250
1262
|
: axisType === "category"
|
|
1251
1263
|
? categoryAxis(config.x)
|
|
1252
1264
|
: valueAxis(config.x, false)
|
|
1253
|
-
const
|
|
1265
|
+
const yAxisBase = hasHeatmap
|
|
1254
1266
|
? categoryAxis(config.y, rowCategories, true)
|
|
1255
1267
|
: horizontal
|
|
1256
1268
|
? axisType === "category"
|
|
1257
1269
|
? categoryAxis(config.y)
|
|
1258
1270
|
: valueAxis(config.y, false)
|
|
1259
1271
|
: valueAxis(config.y, hasBar)
|
|
1272
|
+
const xAxis = config.x?.label ? { ...xAxisBase, ...axisNameX } : xAxisBase
|
|
1273
|
+
const yAxis = config.y?.label ? { ...yAxisBase, ...axisNameY } : yAxisBase
|
|
1260
1274
|
|
|
1261
1275
|
// Familles **hors axes** (`pie` seul) : ni grid ni xAxis/yAxis — sinon ECharts laisse
|
|
1262
1276
|
// un cadre fantôme et décale le titre.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Tests unitaires des **limites de lignes** (`lib/spreadsheetRows.ts`) : `maxRows` / `minRows`.
|
|
2
|
+
import { describe, expect, it } from "bun:test"
|
|
3
|
+
import { canAddRows, canRemoveRows, clampRows, removalsAllowed, rowRoom } from "./spreadsheetRows"
|
|
4
|
+
|
|
5
|
+
describe("rowRoom / canAddRows", () => {
|
|
6
|
+
it("sans max : illimité", () => {
|
|
7
|
+
expect(rowRoom(3, undefined)).toBe(Number.POSITIVE_INFINITY)
|
|
8
|
+
expect(canAddRows(3, 99, undefined)).toBe(true)
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it("borne par le max", () => {
|
|
12
|
+
expect(rowRoom(0, 1)).toBe(1)
|
|
13
|
+
expect(rowRoom(1, 1)).toBe(0)
|
|
14
|
+
expect(rowRoom(5, 1)).toBe(0)
|
|
15
|
+
expect(canAddRows(0, 1, 1)).toBe(true)
|
|
16
|
+
expect(canAddRows(1, 1, 1)).toBe(false)
|
|
17
|
+
expect(canAddRows(0, 2, 1)).toBe(false)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it("ignore un max non fini (Infinity / NaN)", () => {
|
|
21
|
+
expect(rowRoom(4, Number.POSITIVE_INFINITY)).toBe(Number.POSITIVE_INFINITY)
|
|
22
|
+
expect(rowRoom(4, Number.NaN)).toBe(Number.POSITIVE_INFINITY)
|
|
23
|
+
})
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
describe("removalsAllowed / canRemoveRows", () => {
|
|
27
|
+
it("sans min : on peut tout retirer", () => {
|
|
28
|
+
expect(removalsAllowed(3, undefined)).toBe(3)
|
|
29
|
+
expect(canRemoveRows(3, 3, undefined)).toBe(true)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it("plancher par le min", () => {
|
|
33
|
+
expect(removalsAllowed(3, 1)).toBe(2)
|
|
34
|
+
expect(removalsAllowed(1, 1)).toBe(0)
|
|
35
|
+
expect(removalsAllowed(0, 1)).toBe(0)
|
|
36
|
+
expect(canRemoveRows(3, 2, 1)).toBe(true)
|
|
37
|
+
expect(canRemoveRows(3, 3, 1)).toBe(false)
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
describe("clampRows", () => {
|
|
42
|
+
it("tronque au-delà du max", () => {
|
|
43
|
+
expect(clampRows([1, 2, 3], 2)).toEqual([1, 2])
|
|
44
|
+
expect(clampRows([1], 5)).toEqual([1])
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it("renvoie la **même** référence si rien à couper (ou sans max)", () => {
|
|
48
|
+
const rows = [1, 2]
|
|
49
|
+
expect(clampRows(rows, 2)).toBe(rows)
|
|
50
|
+
expect(clampRows(rows, 5)).toBe(rows)
|
|
51
|
+
expect(clampRows(rows, undefined)).toBe(rows)
|
|
52
|
+
})
|
|
53
|
+
})
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// **Limites du nombre de lignes** de QSpreadsheet (`<q-spreadsheet>`) : props `maxRows` /
|
|
2
|
+
// `minRows`. Elles bornent les opérations **utilisateur** (ajout / insertion / suppression de
|
|
3
|
+
// lignes, import CSV) — jamais les chargements programmatiques (`loadDocument`), comme
|
|
4
|
+
// `readonly` / `disable` bornent les éditions.
|
|
5
|
+
//
|
|
6
|
+
// Logique **pure**, testable hors navigateur — comme `spreadsheetZones.ts`.
|
|
7
|
+
|
|
8
|
+
/** Nombre de lignes qu'on peut encore **ajouter** avant `max` (`Infinity` si pas de limite). */
|
|
9
|
+
export function rowRoom(current: number, max?: number): number {
|
|
10
|
+
if (typeof max !== "number" || !Number.isFinite(max)) return Number.POSITIVE_INFINITY
|
|
11
|
+
return Math.max(0, max - current)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Peut-on ajouter `count` lignes à un document de `current` lignes ? */
|
|
15
|
+
export function canAddRows(current: number, count = 1, max?: number): boolean {
|
|
16
|
+
return count <= rowRoom(current, max)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Nombre de lignes qu'on peut **retirer** au maximum avant d'atteindre `min` (plancher 0). */
|
|
20
|
+
export function removalsAllowed(current: number, min?: number): number {
|
|
21
|
+
if (typeof min !== "number" || !Number.isFinite(min)) return Math.max(0, current)
|
|
22
|
+
return Math.max(0, current - Math.max(0, min))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Peut-on retirer `count` lignes d'un document de `current` lignes (il en reste ≥ `min`) ? */
|
|
26
|
+
export function canRemoveRows(current: number, count = 1, min?: number): boolean {
|
|
27
|
+
return count <= removalsAllowed(current, min)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Plafonne une liste de lignes à `max` (renvoie la **même** référence si rien à couper). */
|
|
31
|
+
export function clampRows<T>(rows: T[], max?: number): T[] {
|
|
32
|
+
if (typeof max !== "number" || !Number.isFinite(max) || rows.length <= max) return rows
|
|
33
|
+
return rows.slice(0, max)
|
|
34
|
+
}
|