diff --git a/ui/packages/@quent/components/src/pivot-table/PivotedStatTable.tsx b/ui/packages/@quent/components/src/pivot-table/PivotedStatTable.tsx index c2c4da1e1..17fa3636b 100644 --- a/ui/packages/@quent/components/src/pivot-table/PivotedStatTable.tsx +++ b/ui/packages/@quent/components/src/pivot-table/PivotedStatTable.tsx @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 import { useMemo, useState, useCallback, useEffect, useRef } from 'react'; @@ -22,6 +22,7 @@ import { getSortValue, gradientBg, itemHasId, + numericSortingFn, } from './utils'; import type { GroupedDataTableGroupRenderMode, @@ -382,8 +383,10 @@ export function PivotedStatTable({ for (const row of visiblePivotedRows) { const v = getSortValue(row, stat, isAggregating, aggMode); if (v !== null) { - if (v < min) min = v; - if (v > max) max = v; + // Convert to number for gradient color math — precision loss is acceptable here + const n = Number(v); + if (n < min) min = n; + if (n > max) max = n; } } if (min !== Infinity) ranges.set(stat, { min, max }); @@ -566,6 +569,7 @@ export function PivotedStatTable({ header: stat, enableSorting: true, sortUndefined: 'last', + sortingFn: numericSortingFn, accessorFn: (row: PivotedRow) => getSortValue(row, stat, isAggregating, aggMode) ?? undefined, })); return [...groupCols, ...statCols]; diff --git a/ui/packages/@quent/components/src/pivot-table/buildPivotedRows.test.ts b/ui/packages/@quent/components/src/pivot-table/buildPivotedRows.test.ts index 7a29806d1..ccf9bbd4e 100644 --- a/ui/packages/@quent/components/src/pivot-table/buildPivotedRows.test.ts +++ b/ui/packages/@quent/components/src/pivot-table/buildPivotedRows.test.ts @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 import { describe, it, expect } from 'vitest'; @@ -163,10 +163,10 @@ describe('buildPivotedRows row clustering', () => { ]; const out = buildPivotedRows(rows, [brandIdx, fuelIdx], true); const agg = out[0]?.aggs.get('output_rows'); - expect(agg?.sum).toBe(4000); + expect(agg?.sum).toBe(4000n); expect(agg?.mean).toBe(2000); - expect(agg?.min).toBe(1000); - expect(agg?.max).toBe(3000); + expect(agg?.min).toBe(1000n); + expect(agg?.max).toBe(3000n); expect(agg?.count).toBe(2); expect(agg?.isNumeric).toBe(true); }); diff --git a/ui/packages/@quent/components/src/pivot-table/types.ts b/ui/packages/@quent/components/src/pivot-table/types.ts index bdac9a9c5..7049085f6 100644 --- a/ui/packages/@quent/components/src/pivot-table/types.ts +++ b/ui/packages/@quent/components/src/pivot-table/types.ts @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 import type { StatValue, ContinuousPaletteName } from '@quent/utils'; @@ -137,10 +137,10 @@ export interface GroupKeyEntry { } export interface PivotedRowAgg { - sum: number | null; + sum: number | bigint | null; mean: number | null; - min: number | null; - max: number | null; + min: number | bigint | null; + max: number | bigint | null; stdev: number | null; count: number; isNumeric: boolean; diff --git a/ui/packages/@quent/components/src/pivot-table/utils.ts b/ui/packages/@quent/components/src/pivot-table/utils.ts index 361d18374..d415f8ffc 100644 --- a/ui/packages/@quent/components/src/pivot-table/utils.ts +++ b/ui/packages/@quent/components/src/pivot-table/utils.ts @@ -1,9 +1,10 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 import { inferFieldFormatter, isNumericValue } from '@quent/utils'; import type { StatValue, ContinuousPaletteName } from '@quent/utils'; import { continuousColor } from '@quent/utils'; +import type { SortingFn } from '@tanstack/react-table'; // Re-exported for consumers that still import it from here; defined in `@quent/utils` export { isNumericValue }; @@ -32,6 +33,16 @@ export function formatNumericStat(n: number | bigint | null, statName: string): return inferFieldFormatter(statName)(n); } +export const numericSortingFn: SortingFn = (rowA, rowB, columnId) => { + const a = rowA.getValue(columnId); + const b = rowB.getValue(columnId); + if (a === b) return 0; + if (a == null) return 1; + if (b == null) return -1; + if (typeof a === typeof b) return a < b ? -1 : 1; + return (a as number) < (b as number) ? -1 : 1; +}; + /** * Returns true when any id in `items` is present in `target`. Equivalent to * `[...items].some(id => target.has(id))` but without allocating an @@ -56,14 +67,17 @@ export function formatStatValue(value: StatValue, statName: string): string { // --- color gradient --- export function gradientBg( - value: number, - min: number, - max: number, + value: number | bigint, + min: number | bigint, + max: number | bigint, palette: ContinuousPaletteName = 'blue', darkMode = false ): string | undefined { - if (min === max) return undefined; - const t = (value - min) / (max - min); + const vn = Number(value); + const mn = Number(min); + const mx = Number(max); + if (mn === mx) return undefined; + const t = (vn - mn) / (mx - mn); return continuousColor(t, palette, darkMode); } @@ -169,11 +183,11 @@ export function getSortValue( stat: string, isAgg: boolean, aggMode: AggMode -): number | null { +): number | bigint | null { if (!isAgg) { const v = row.values.get(stat); if (v === undefined) return null; - return isNumericValue(v) ? Number(v) : null; + return isNumericValue(v) ? v : null; } const agg = row.aggs.get(stat); if (!agg || !agg.isNumeric) return null; @@ -274,24 +288,25 @@ export function buildPivotedRows( : [...bucket.nums, ...bucket.bigints.map(Number)]; const hasNum = allNums.length > 0; - let sum: number | null = null; - let min: number | null = null; - let max: number | null = null; + let sum: number | bigint | null = null; + let min: number | bigint | null = null; + let max: number | bigint | null = null; let mean: number | null = null; let stdev: number | null = null; if (hasNum) { if (onlyBigints) { - // Use bigint arithmetic for sum/min/max to avoid precision loss - sum = Number(bucket.bigints.reduce((a, b) => a + b, 0n)); - min = Number(bucket.bigints.reduce((a, b) => (a < b ? a : b))); - max = Number(bucket.bigints.reduce((a, b) => (a > b ? a : b))); + // Use bigint arithmetic for sum/min/max + sum = bucket.bigints.reduce((a, b) => a + b, 0n); + min = bucket.bigints.reduce((a, b) => (a < b ? a : b)); + max = bucket.bigints.reduce((a, b) => (a > b ? a : b)); + mean = Number(sum) / bucket.bigints.length; } else { sum = allNums.reduce((a, b) => a + b, 0); min = Math.min(...allNums); max = Math.max(...allNums); + mean = sum / allNums.length; } - mean = sum / allNums.length; if (allNums.length > 1) { const variance = allNums.reduce((acc, v) => acc + (v - mean!) ** 2, 0) / (allNums.length - 1);