@dnax/ui 0.0.69 → 0.0.71

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;
@@ -4,7 +4,8 @@
4
4
  // édition en place (Entrée / F2 / double-clic / frappe directe),
5
5
  // lignes & colonnes (ajouter / insérer / supprimer),
6
6
  // tri, copier/coller, undo/redo, et cellules typées
7
- // (texte, nombre, entier, booléen, date, datetime, select / multiselect avec badges colorés).
7
+ // texte, nombre, entier, booléen, date, datetime, **time (heure)**, select / multiselect avec
8
+ // badges colorés).
8
9
  // Suivi des modifications : `v-model:dirty` (état) + `v-model:changes` (delta lignes / feuilles
9
10
  // ajoutées, modifiées, supprimées), calculé en comparant le document à une **référence**
10
11
  // (`lib/spreadsheetChanges.ts`).
@@ -44,6 +45,7 @@ export type QSpreadsheetCellType =
44
45
  | "boolean"
45
46
  | "date"
46
47
  | "datetime"
48
+ | "time"
47
49
  | "select"
48
50
  | "multiselect"
49
51
 
@@ -185,6 +187,9 @@ import {
185
187
  isBlankValue,
186
188
  ISO_DATE,
187
189
  isoAddDays,
190
+ normalizeTime,
191
+ secondsToTime,
192
+ timeToSeconds,
188
193
  shiftFormulaRefs,
189
194
  functionQueryAt,
190
195
  rankSuggestions,
@@ -877,6 +882,8 @@ const cellText = (row: number, column: string): string => {
877
882
  let out: any = ev === null || ev === undefined ? "" : ev
878
883
  // Apostrophe littérale (paste values) : affichée sans le préfixe
879
884
  if (typeof out === "string" && out.startsWith("'")) out = out.slice(1)
885
+ // Heure : toujours `HH:MM` à l'affichage (données importées ou anciennes comprises)
886
+ if (col?.type === "time" && typeof out === "string") out = normalizeTime(out) ?? out
880
887
  if (col?.format && out !== "") out = col.format(out, state.value[row] ?? {})
881
888
  if (out === null || out === undefined || out === "") return ""
882
889
  return String(out)
@@ -988,6 +995,8 @@ const editorInputType = computed(() => {
988
995
  return "date"
989
996
  case "datetime":
990
997
  return "datetime-local"
998
+ case "time":
999
+ return "time"
991
1000
  default:
992
1001
  // PAS de `type="number"` pour number/integer : (1) le navigateur refuse
993
1002
  // `=` (formule) ou un "-" isolé dans un input number, (2) `v-model` sur
@@ -1022,6 +1031,8 @@ const startEdit = (row: number, column: string, initial?: string) => {
1022
1031
  } else {
1023
1032
  let rawStr = String(raw ?? "")
1024
1033
  if (rawStr.startsWith("'")) rawStr = rawStr.slice(1)
1034
+ // Heure : l'input natif n'accepte que `HH:MM` — on normalise dès l'ouverture
1035
+ if (col?.type === "time") rawStr = normalizeTime(rawStr) ?? rawStr
1025
1036
  draft.value = String(initial ?? rawStr)
1026
1037
  }
1027
1038
  editing.value = { row, column }
@@ -1099,6 +1110,11 @@ const coerceValue = (col: QSpreadsheetColumn | undefined, text: unknown, old: an
1099
1110
  if (col?.type === "boolean") {
1100
1111
  return raw === "true" ? true : raw === "false" ? false : old
1101
1112
  }
1113
+ if (col?.type === "time") {
1114
+ // Heure valide → `HH:MM` (`HH:MM:SS` si les secondes sont non nulles) ; invalide → ancienne
1115
+ const time = normalizeTime(raw)
1116
+ return time === undefined ? old : time
1117
+ }
1102
1118
  if (col?.type === "select") {
1103
1119
  const opt = cellOptionsOf(col).find(
1104
1120
  (o) => String(o.label ?? "") === raw || String(o.value) === raw,
@@ -3065,6 +3081,19 @@ const applyFill = () => {
3065
3081
  const d1 = +new Date(s1 + "T00:00:00Z")
3066
3082
  const step = Math.round((d1 - d0) / 86400000)
3067
3083
  if (step !== 0) val = isoAddDays(s1, step * (rr - b.r1))
3084
+ } else if (
3085
+ b.r1 > b.r0 &&
3086
+ col?.type === "time" &&
3087
+ typeof s0 === "string" &&
3088
+ typeof s1 === "string"
3089
+ ) {
3090
+ // Série d'heures : 09:00 puis 10:00 → 11:00, 12:00… (modulo 24 h)
3091
+ const t0 = timeToSeconds(s0)
3092
+ const t1 = timeToSeconds(s1)
3093
+ if (t0 !== undefined && t1 !== undefined && t1 !== t0) {
3094
+ const step = (t1 - t0) / (b.r1 - b.r0)
3095
+ val = secondsToTime(t1 + step * (rr - b.r1))
3096
+ }
3068
3097
  }
3069
3098
  } else if (
3070
3099
  !fillCopy.value &&
@@ -3081,6 +3110,18 @@ const applyFill = () => {
3081
3110
  if (b.c1 > b.c0 && typeof s0 === "number" && typeof s1 === "number" && s1 !== s0) {
3082
3111
  const step = (s1 - s0) / (b.c1 - b.c0)
3083
3112
  val = s1 + step * (cc - b.c1)
3113
+ } else if (
3114
+ b.c1 > b.c0 &&
3115
+ col?.type === "time" &&
3116
+ typeof s0 === "string" &&
3117
+ typeof s1 === "string"
3118
+ ) {
3119
+ const t0 = timeToSeconds(s0)
3120
+ const t1 = timeToSeconds(s1)
3121
+ if (t0 !== undefined && t1 !== undefined && t1 !== t0) {
3122
+ const step = (t1 - t0) / (b.c1 - b.c0)
3123
+ val = secondsToTime(t1 + step * (cc - b.c1))
3124
+ }
3084
3125
  }
3085
3126
  }
3086
3127
  // Recopie du motif (source cyclique) — vers le bas, la droite, le haut,
@@ -3158,10 +3199,17 @@ const endResize = () => {
3158
3199
  window.removeEventListener("pointerup", endResize)
3159
3200
  }
3160
3201
 
3161
- const onPointerUpGlobal = () => {
3202
+ const onPointerUpGlobal = (e: PointerEvent) => {
3162
3203
  isPointerDown.value = false
3163
3204
  endResize()
3164
3205
  endRowResize()
3206
+ const target = e.target as HTMLElement | null
3207
+ // Ne restaurer le focus de la grille que si le geste se termine **dans la grille**,
3208
+ // et jamais depuis l'éditeur / ses listes. Sinon `td.focus()` déclenche le `blur`
3209
+ // de l'input **avant** le `click` de l'option : `commitEdit()` ferme l'éditeur et
3210
+ // la valeur cliquée (`select` / `multiselect`) était perdue.
3211
+ if (!target?.closest?.(".q-spreadsheet__viewport")) return
3212
+ if (target.closest(".q-spreadsheet__editor")) return
3165
3213
  const s = sel.value
3166
3214
  if (s) focusCell(s.row, s.column)
3167
3215
  }
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
+ }
@@ -14,8 +14,47 @@ import {
14
14
  csvSplitLine,
15
15
  parseCsv,
16
16
  deriveCols,
17
+ normalizeTime,
18
+ parseTimeOfDay,
19
+ secondsToTime,
20
+ timeToSeconds,
17
21
  } from "./spreadsheet"
18
22
 
23
+ describe("heures (type `time`)", () => {
24
+ it("décompose les formes usuelles", () => {
25
+ expect(parseTimeOfDay("09:30")).toEqual({ hours: 9, minutes: 30, seconds: 0 })
26
+ expect(parseTimeOfDay("9h5")).toEqual({ hours: 9, minutes: 5, seconds: 0 })
27
+ expect(parseTimeOfDay("9")).toEqual({ hours: 9, minutes: 0, seconds: 0 })
28
+ expect(parseTimeOfDay("23:59:58")).toEqual({ hours: 23, minutes: 59, seconds: 58 })
29
+ // vide → cellule effacée ; null/undefined → pas une heure
30
+ expect(parseTimeOfDay("")).toBeNull()
31
+ expect(parseTimeOfDay(null)).toBeUndefined()
32
+ })
33
+
34
+ it("refuse les heures invalides", () => {
35
+ for (const value of ["24:00", "9:75", "9:30:75", "abc", "1:2:3:4", "-1:00"]) {
36
+ expect(parseTimeOfDay(value), value).toBeUndefined()
37
+ }
38
+ })
39
+
40
+ it("normalise en `HH:MM` (secondes conservées si non nulles)", () => {
41
+ expect(normalizeTime("9:5")).toBe("09:05")
42
+ expect(normalizeTime("9h30")).toBe("09:30")
43
+ expect(normalizeTime("09:30:00")).toBe("09:30")
44
+ expect(normalizeTime("09:30:07")).toBe("09:30:07")
45
+ expect(normalizeTime("")).toBeNull()
46
+ expect(normalizeTime("nope")).toBeUndefined()
47
+ })
48
+
49
+ it("séries d'heures : secondes, modulo 24 h, valeurs négatives", () => {
50
+ expect(timeToSeconds("01:00")).toBe(3600)
51
+ expect(secondsToTime(3600)).toBe("01:00")
52
+ expect(secondsToTime(25 * 3600)).toBe("01:00")
53
+ expect(secondsToTime(-3600)).toBe("23:00")
54
+ expect(secondsToTime(3661)).toBe("01:01:01")
55
+ })
56
+ })
57
+
19
58
  describe("lettres de colonnes", () => {
20
59
  it("colLetter : 0→A, 25→Z, 26→AA, 27→AB, 701→ZZ, 702→AAA", () => {
21
60
  expect(colLetter(0)).toBe("A")
@@ -247,6 +247,74 @@ export const isoAddDays = (iso: string, days: number): string => {
247
247
  return d.toISOString().slice(0, 10)
248
248
  }
249
249
 
250
+ /** Heure du jour : `HH:MM` ou `HH:MM:SS` (toujours sur deux chiffres) */
251
+ export const TIME_OF_DAY = /^\d{2}:\d{2}(?::\d{2})?$/
252
+
253
+ /**
254
+ * Décompose une **heure** saisie librement. Accepte `9`, `09:30`, `9:5`, `9h30`, `09:30:15`.
255
+ *
256
+ * - `null` → chaîne vide (cellule effacée) ;
257
+ * - `undefined` → ce n'est pas une heure valide (`24:00`, `9:75`, `abc`…).
258
+ *
259
+ * Le parsing est volontairement **local** (pas de `Date`) : aucune conversion de fuseau ne
260
+ * doit intervenir sur une heure de la journée.
261
+ */
262
+ export function parseTimeOfDay(
263
+ value: any,
264
+ ): { hours: number; minutes: number; seconds: number } | null | undefined {
265
+ if (value === null || value === undefined) return undefined
266
+ const raw = String(value).trim()
267
+ if (raw === "") return null
268
+ const match = raw.match(/^(\d{1,2})(?:[:h](\d{1,2}))?(?::(\d{1,2}))?$/i)
269
+ if (!match) return undefined
270
+ const hours = Number(match[1])
271
+ const minutes = match[2] === undefined ? 0 : Number(match[2])
272
+ const seconds = match[3] === undefined ? 0 : Number(match[3])
273
+ if (
274
+ !Number.isInteger(hours) ||
275
+ !Number.isInteger(minutes) ||
276
+ !Number.isInteger(seconds) ||
277
+ hours > 23 ||
278
+ minutes > 59 ||
279
+ seconds > 59
280
+ ) {
281
+ return undefined
282
+ }
283
+ return { hours, minutes, seconds }
284
+ }
285
+
286
+ const two = (n: number) => String(n).padStart(2, "0")
287
+
288
+ /**
289
+ * Normalise une heure en `HH:MM` — les secondes ne sont conservées que si elles sont non
290
+ * nulles (`HH:MM:SS`). `null` = vide, `undefined` = invalide (l'appelant garde l'ancienne
291
+ * valeur, comme pour un nombre refusé).
292
+ */
293
+ export function normalizeTime(value: any): string | null | undefined {
294
+ const parsed = parseTimeOfDay(value)
295
+ if (parsed === null || parsed === undefined) return parsed
296
+ const base = `${two(parsed.hours)}:${two(parsed.minutes)}`
297
+ return parsed.seconds ? `${base}:${two(parsed.seconds)}` : base
298
+ }
299
+
300
+ /** Secondes depuis minuit (séries d'auto-remplissage) — `undefined` si invalide */
301
+ export function timeToSeconds(value: any): number | undefined {
302
+ const parsed = parseTimeOfDay(value)
303
+ if (!parsed) return undefined
304
+ return parsed.hours * 3600 + parsed.minutes * 60 + parsed.seconds
305
+ }
306
+
307
+ /** Secondes depuis minuit → `HH:MM` (`HH:MM:SS` si nécessaire), **modulo 24 h** */
308
+ export function secondsToTime(total: number): string {
309
+ if (!Number.isFinite(total)) return "00:00"
310
+ const day = 86400
311
+ const normalized = ((Math.round(total) % day) + day) % day
312
+ const hours = Math.floor(normalized / 3600)
313
+ const minutes = Math.floor((normalized % 3600) / 60)
314
+ const seconds = normalized % 60
315
+ return seconds ? `${two(hours)}:${two(minutes)}:${two(seconds)}` : `${two(hours)}:${two(minutes)}`
316
+ }
317
+
250
318
  /** Échappe une valeur pour un CSV (quote si délimiteur / guillemet / newline) */
251
319
  export const csvEscape = (s: unknown, delim: string): string => {
252
320
  const str = s === null || s === undefined ? "" : String(s)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dnax/ui",
3
- "version": "0.0.69",
3
+ "version": "0.0.71",
4
4
  "module": "index.ts",
5
5
  "description": "UI design System",
6
6
  "type": "module",