@dnax/ui 0.0.69 → 0.0.70
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/QChart.vue +228 -13
- package/components/QSpreadsheet.vue +8 -1
- package/index.ts +10 -1
- package/lib/chart.ts +59 -8
- package/lib/chartTable.test.ts +140 -0
- package/lib/chartTable.ts +193 -0
- package/package.json +1 -1
package/components/QChart.vue
CHANGED
|
@@ -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
|
|
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
|
|
319
|
-
return sortIndicator(column) === "
|
|
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
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
|
@@ -3158,10 +3158,17 @@ const endResize = () => {
|
|
|
3158
3158
|
window.removeEventListener("pointerup", endResize)
|
|
3159
3159
|
}
|
|
3160
3160
|
|
|
3161
|
-
const onPointerUpGlobal = () => {
|
|
3161
|
+
const onPointerUpGlobal = (e: PointerEvent) => {
|
|
3162
3162
|
isPointerDown.value = false
|
|
3163
3163
|
endResize()
|
|
3164
3164
|
endRowResize()
|
|
3165
|
+
const target = e.target as HTMLElement | null
|
|
3166
|
+
// Ne restaurer le focus de la grille que si le geste se termine **dans la grille**,
|
|
3167
|
+
// et jamais depuis l'éditeur / ses listes. Sinon `td.focus()` déclenche le `blur`
|
|
3168
|
+
// de l'input **avant** le `click` de l'option : `commitEdit()` ferme l'éditeur et
|
|
3169
|
+
// la valeur cliquée (`select` / `multiselect`) était perdue.
|
|
3170
|
+
if (!target?.closest?.(".q-spreadsheet__viewport")) return
|
|
3171
|
+
if (target.closest(".q-spreadsheet__editor")) return
|
|
3165
3172
|
const s = sel.value
|
|
3166
3173
|
if (s) focusCell(s.row, s.column)
|
|
3167
3174
|
}
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
206
|
-
if (
|
|
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
|
+
}
|