diff --git a/.changeset/neat-rows-mode.md b/.changeset/neat-rows-mode.md new file mode 100644 index 0000000000..d325c336aa --- /dev/null +++ b/.changeset/neat-rows-mode.md @@ -0,0 +1,5 @@ +--- +'@tanstack/table-core': minor +--- + +Add `mode` aggregation function to `aggregationFns` to compute statistical mode with first-encountered tie breaking. diff --git a/packages/table-core/src/features/row-aggregation/aggregationFns.ts b/packages/table-core/src/features/row-aggregation/aggregationFns.ts index 22fa2e716f..c77e65bf3d 100755 --- a/packages/table-core/src/features/row-aggregation/aggregationFns.ts +++ b/packages/table-core/src/features/row-aggregation/aggregationFns.ts @@ -282,6 +282,44 @@ export const aggregationFn_median = constructAggregationFn< }, }) +/** + * Computes the statistical mode (most frequent value) of the row values. + * If multiple values have the same maximum frequency, returns the first one encountered. + * Returns `undefined` when no rows are present. + */ +export const aggregationFn_mode = constructAggregationFn< + any, + any, + unknown, + unknown +>({ + aggregate: (context) => { + const rows = context.rows + if (!rows.length) return undefined + + const counts = new Map() + let maxCount = 0 + + for (let i = 0; i < rows.length; i++) { + const value = context.getValue(rows[i]!) + const count = (counts.get(value) ?? 0) + 1 + counts.set(value, count) + if (count > maxCount) { + maxCount = count + } + } + + for (let i = 0; i < rows.length; i++) { + const value = context.getValue(rows[i]!) + if (counts.get(value) === maxCount) { + return value + } + } + + return undefined + }, +}) + /** Collects distinct row values using JavaScript `Set` semantics. */ export const aggregationFn_unique = constructAggregationFn< any, @@ -373,6 +411,7 @@ export const aggregationFns = { extent: aggregationFn_extent, mean: aggregationFn_mean, median: aggregationFn_median, + mode: aggregationFn_mode, unique: aggregationFn_unique, uniqueCount: aggregationFn_uniqueCount, count: aggregationFn_count, diff --git a/packages/table-core/tests/unit/fns/aggregationFns.test.ts b/packages/table-core/tests/unit/fns/aggregationFns.test.ts index 44e125ad67..95614c5c0c 100644 --- a/packages/table-core/tests/unit/fns/aggregationFns.test.ts +++ b/packages/table-core/tests/unit/fns/aggregationFns.test.ts @@ -8,6 +8,7 @@ import { aggregationFn_mean, aggregationFn_median, aggregationFn_min, + aggregationFn_mode, aggregationFn_sum, aggregationFn_unique, aggregationFn_uniqueCount, @@ -119,6 +120,23 @@ describe('aggregation function definitions', () => { ).toBeUndefined() }) + it('calculates mode and returns first encountered value on tie', () => { + expect(aggregationFn_mode.aggregate(context(['a', 'b', 'b', 'a']))).toBe( + 'a', + ) + expect( + aggregationFn_mode.aggregate(context(['a', 'b', 'a', 'c', 'b', 'a'])), + ).toBe('a') + expect( + aggregationFn_mode.aggregate(context(['a', 'b', 'a', 'b', 'c'])), + ).toBe('a') + expect(aggregationFn_mode.aggregate(context([1, 2, 2, 3, 2, 1]))).toBe(2) + expect( + aggregationFn_mode.aggregate(context([null, undefined, null, 'x'])), + ).toBeNull() + expect(aggregationFn_mode.aggregate(context([]))).toBeUndefined() + }) + it('preserves custom definition result inference', () => { const joined = constructAggregationFn({ aggregate: ({ rows }) => rows.map((row) => row.id).join(','),