Skip to content

Commit 347a267

Browse files
committed
Chart table sources gain groupBy/aggregate/pivot shaping; renderer-owned chrome
1 parent e076b0f commit 347a267

2 files changed

Lines changed: 260 additions & 12 deletions

File tree

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import {
6+
parseChartSpec,
7+
shapeTableRows,
8+
} from '@/app/workspace/[workspaceId]/files/components/file-viewer/chart-preview'
9+
10+
const rows = [
11+
{ month: '2024-01', region: 'NA', revenue: 100, conversion: 4 },
12+
{ month: '2024-01', region: 'EMEA', revenue: 50, conversion: 2 },
13+
{ month: '2024-02', region: 'NA', revenue: 200, conversion: 6 },
14+
{ month: '2024-02', region: 'EMEA', revenue: 80, conversion: 4 },
15+
]
16+
17+
describe('shapeTableRows', () => {
18+
it('passes rows through without groupBy', () => {
19+
expect(shapeTableRows(rows, { type: 'table', tableId: 't' })).toBe(rows)
20+
})
21+
22+
it('groups and aggregates, keeping first-seen group order', () => {
23+
const shaped = shapeTableRows(rows, {
24+
type: 'table',
25+
tableId: 't',
26+
groupBy: ['month'],
27+
aggregate: { revenue: 'sum', conversion: 'avg' },
28+
})
29+
expect(shaped).toEqual([
30+
{ month: '2024-01', revenue: 150, conversion: 3 },
31+
{ month: '2024-02', revenue: 280, conversion: 5 },
32+
])
33+
})
34+
35+
it('pivots a single metric into per-value columns', () => {
36+
const shaped = shapeTableRows(rows, {
37+
type: 'table',
38+
tableId: 't',
39+
groupBy: ['month'],
40+
aggregate: { revenue: 'sum' },
41+
pivot: 'region',
42+
})
43+
expect(shaped).toEqual([
44+
{ month: '2024-01', NA: 100, EMEA: 50 },
45+
{ month: '2024-02', NA: 200, EMEA: 80 },
46+
])
47+
})
48+
49+
it('prefixes pivot columns when several metrics are aggregated', () => {
50+
const shaped = shapeTableRows(rows, {
51+
type: 'table',
52+
tableId: 't',
53+
groupBy: ['month'],
54+
aggregate: { revenue: 'sum', conversion: 'avg' },
55+
pivot: 'region',
56+
})
57+
expect(shaped[0]).toEqual({
58+
month: '2024-01',
59+
'NA revenue': 100,
60+
'NA conversion': 4,
61+
'EMEA revenue': 50,
62+
'EMEA conversion': 2,
63+
})
64+
})
65+
66+
it('counts rows and ignores non-numeric values in numeric ops', () => {
67+
const noisy = [
68+
{ g: 'a', v: 1 },
69+
{ g: 'a', v: 'oops' },
70+
{ g: 'a', v: 3 },
71+
]
72+
expect(
73+
shapeTableRows(noisy, {
74+
type: 'table',
75+
tableId: 't',
76+
groupBy: ['g'],
77+
aggregate: { v: 'count' },
78+
})
79+
).toEqual([{ g: 'a', v: 3 }])
80+
expect(
81+
shapeTableRows(noisy, {
82+
type: 'table',
83+
tableId: 't',
84+
groupBy: ['g'],
85+
aggregate: { v: 'avg' },
86+
})
87+
).toEqual([{ g: 'a', v: 2 }])
88+
})
89+
})
90+
91+
describe('parseChartSpec table-shaping validation', () => {
92+
it('rejects groupBy without aggregate and bad ops', () => {
93+
const base = { schema_version: 1, option: {} }
94+
expect(
95+
parseChartSpec(
96+
JSON.stringify({
97+
...base,
98+
source: { type: 'table', tableId: 't', groupBy: ['m'] },
99+
})
100+
).error
101+
).toMatch(/aggregate/)
102+
expect(
103+
parseChartSpec(
104+
JSON.stringify({
105+
...base,
106+
source: { type: 'table', tableId: 't', groupBy: ['m'], aggregate: { v: 'median' } },
107+
})
108+
).error
109+
).toMatch(/ops/)
110+
expect(
111+
parseChartSpec(
112+
JSON.stringify({
113+
...base,
114+
source: { type: 'table', tableId: 't', pivot: 'region' },
115+
})
116+
).error
117+
).toMatch(/pivot/)
118+
})
119+
})

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/chart-preview.tsx

Lines changed: 141 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,22 @@ import { PreviewLoadingFrame } from './preview-shared'
1212
const CHART_ROWS_MAX = 5000
1313
const CHART_ROWS_DEFAULT = 1000
1414

15+
type ChartAggregateOp = 'sum' | 'avg' | 'min' | 'max' | 'count'
16+
17+
const CHART_AGGREGATE_OPS = new Set<string>(['sum', 'avg', 'min', 'max', 'count'])
18+
1519
interface ChartTableSource {
1620
type: 'table'
1721
tableId: string
1822
filter?: unknown
1923
sort?: unknown
2024
limit?: number
25+
/** Group rows by these columns; requires `aggregate`. */
26+
groupBy?: string[]
27+
/** Metric column → op, computed per group. */
28+
aggregate?: Record<string, ChartAggregateOp>
29+
/** Fan the aggregated metric(s) out into one column per distinct value of this column. */
30+
pivot?: string
2131
}
2232

2333
interface ChartStaticSource {
@@ -38,7 +48,7 @@ interface ChartSpec {
3848
option: Record<string, unknown>
3949
}
4050

41-
function parseChartSpec(content: string): { spec?: ChartSpec; error?: string } {
51+
export function parseChartSpec(content: string): { spec?: ChartSpec; error?: string } {
4252
let raw: unknown
4353
try {
4454
raw = JSON.parse(content)
@@ -64,6 +74,22 @@ function parseChartSpec(content: string): { spec?: ChartSpec; error?: string } {
6474
if (typeof source.tableId !== 'string' || source.tableId === '') {
6575
return { error: 'a table source must declare "tableId"' }
6676
}
77+
if (source.groupBy !== undefined) {
78+
if (!Array.isArray(source.groupBy) || source.groupBy.some((c) => typeof c !== 'string')) {
79+
return { error: '"groupBy" must be an array of column names' }
80+
}
81+
if (source.aggregate === null || typeof source.aggregate !== 'object') {
82+
return { error: '"groupBy" requires an "aggregate" object ({column: op})' }
83+
}
84+
const ops = Object.values(source.aggregate)
85+
if (ops.length === 0 || ops.some((op) => !CHART_AGGREGATE_OPS.has(String(op)))) {
86+
return { error: '"aggregate" ops must be sum, avg, min, max, or count' }
87+
}
88+
}
89+
if (source.pivot !== undefined) {
90+
if (typeof source.pivot !== 'string') return { error: '"pivot" must be a column name' }
91+
if (!source.groupBy) return { error: '"pivot" requires "groupBy" and "aggregate"' }
92+
}
6793
} else if (source.type === 'static') {
6894
if (source.rows !== undefined && !Array.isArray(source.rows)) {
6995
return { error: 'a static source\'s "rows" must be an array' }
@@ -75,6 +101,77 @@ function parseChartSpec(content: string): { spec?: ChartSpec; error?: string } {
75101
return { spec: doc as unknown as ChartSpec }
76102
}
77103

104+
function aggregateValues(
105+
rows: Array<Record<string, unknown>>,
106+
column: string,
107+
op: ChartAggregateOp
108+
): number {
109+
if (op === 'count') return rows.length
110+
const values = rows.map((r) => Number(r[column])).filter((n) => Number.isFinite(n))
111+
if (values.length === 0) return 0
112+
switch (op) {
113+
case 'sum':
114+
return values.reduce((a, b) => a + b, 0)
115+
case 'avg':
116+
return values.reduce((a, b) => a + b, 0) / values.length
117+
case 'min':
118+
return Math.min(...values)
119+
case 'max':
120+
return Math.max(...values)
121+
}
122+
}
123+
124+
/**
125+
* Client-side shaping for table sources: group → aggregate → optionally pivot
126+
* one column's distinct values into per-value columns. Groups keep first-seen
127+
* order, so the source's `sort` decides the category order. This is the whole
128+
* "query engine" — deliberately tiny; anything fancier belongs in a static
129+
* source with precomputed rows.
130+
*/
131+
export function shapeTableRows(
132+
rows: Array<Record<string, unknown>>,
133+
source: ChartTableSource
134+
): Array<Record<string, unknown>> {
135+
const { groupBy, aggregate, pivot } = source
136+
if (!groupBy || groupBy.length === 0 || !aggregate) return rows
137+
138+
const groups = new Map<string, Array<Record<string, unknown>>>()
139+
for (const row of rows) {
140+
const key = groupBy.map((c) => String(row[c] ?? '')).join('\u0000')
141+
const bucket = groups.get(key)
142+
if (bucket) bucket.push(row)
143+
else groups.set(key, [row])
144+
}
145+
146+
const metrics = Object.entries(aggregate)
147+
const out: Array<Record<string, unknown>> = []
148+
for (const bucket of groups.values()) {
149+
const shaped: Record<string, unknown> = {}
150+
for (const c of groupBy) shaped[c] = bucket[0][c]
151+
if (pivot) {
152+
const byValue = new Map<string, Array<Record<string, unknown>>>()
153+
for (const row of bucket) {
154+
const value = String(row[pivot] ?? '')
155+
const slice = byValue.get(value)
156+
if (slice) slice.push(row)
157+
else byValue.set(value, [row])
158+
}
159+
for (const [value, slice] of byValue) {
160+
for (const [column, op] of metrics) {
161+
const name = metrics.length === 1 ? value : `${value} ${column}`
162+
shaped[name] = aggregateValues(slice, column, op)
163+
}
164+
}
165+
} else {
166+
for (const [column, op] of metrics) {
167+
shaped[column] = aggregateValues(bucket, column, op)
168+
}
169+
}
170+
out.push(shaped)
171+
}
172+
return out
173+
}
174+
78175
/**
79176
* Merges the resolved rows into the ECharts option as `dataset.source`. A spec
80177
* whose option already carries a dataset keeps it (fully self-contained static
@@ -102,21 +199,52 @@ function buildOption(spec: ChartSpec, rows: Array<Record<string, unknown>> | nul
102199
if (spec.title && option.title === undefined) {
103200
option.title = { text: spec.title }
104201
}
105-
// Gentle layout defaults — fill in ONLY what the spec leaves unset. A title
106-
// and a legend both default to the top edge and overlap; when both are
107-
// present and the legend declares no position, drop it below the title.
108-
if (option.title !== undefined && option.legend !== null && typeof option.legend === 'object') {
202+
// Chrome layout is Sim-owned, content is spec-owned. Models reliably
203+
// produce colliding title/legend placements, so the renderer pins the
204+
// title top-left and the legend top-right on one chrome row (scrollable
205+
// when long), overriding any spec positions — the same split the pptx
206+
// renderer makes between slide chrome and slide content.
207+
const hasTitle = option.title !== null && typeof option.title === 'object'
208+
if (hasTitle) {
209+
const titles = Array.isArray(option.title) ? option.title : [option.title]
210+
const primary = titles[0]
211+
if (primary !== null && typeof primary === 'object') {
212+
const t = primary as Record<string, unknown>
213+
t.left = 0
214+
t.top = 0
215+
t.right = undefined
216+
t.bottom = undefined
217+
}
218+
option.title = titles[0]
219+
}
220+
let hasLegend = false
221+
if (option.legend !== null && typeof option.legend === 'object') {
109222
const legends = Array.isArray(option.legend) ? option.legend : [option.legend]
110-
let nextTop = 32
111223
for (const entry of legends) {
112224
if (entry === null || typeof entry !== 'object') continue
113-
const positioned = entry as Record<string, unknown>
114-
if (positioned.top === undefined && positioned.bottom === undefined) {
115-
positioned.top = nextTop
116-
nextTop += 28
117-
}
225+
hasLegend = true
226+
const l = entry as Record<string, unknown>
227+
l.top = 2
228+
l.right = 0
229+
l.left = undefined
230+
l.bottom = undefined
231+
if (l.type === undefined) l.type = 'scroll'
118232
}
119233
}
234+
// Reserve a chrome row above the plot. Fill only what the spec left unset
235+
// inside grid — axis-name insets remain the spec's call.
236+
const chromeTop = hasTitle || hasLegend ? 48 : 16
237+
if (option.grid === undefined) {
238+
option.grid = { top: chromeTop, left: 12, right: 12, bottom: 12, containLabel: true }
239+
} else if (
240+
option.grid !== null &&
241+
typeof option.grid === 'object' &&
242+
!Array.isArray(option.grid)
243+
) {
244+
const g = option.grid as Record<string, unknown>
245+
if (g.top === undefined) g.top = chromeTop
246+
if (g.containLabel === undefined) g.containLabel = true
247+
}
120248
return option as EChartsOption
121249
}
122250

@@ -200,13 +328,14 @@ export const ChartPreview = memo(function ChartPreview({
200328
// author sees in the table UI.
201329
const nameByStorageKey = new Map<string, string>()
202330
for (const col of columns) nameByStorageKey.set(getColumnId(col), col.name)
203-
return fetched.map((row) => {
331+
const named = fetched.map((row) => {
204332
const out: Record<string, unknown> = {}
205333
for (const [key, value] of Object.entries(row.data)) {
206334
out[nameByStorageKey.get(key) ?? key] = value
207335
}
208336
return out
209337
})
338+
return shapeTableRows(named, tableSource)
210339
}, [spec, tableSource, rowsQuery.data, tableQuery.data])
211340

212341
const option = useMemo(() => (spec ? buildOption(spec, rows) : null), [spec, rows])

0 commit comments

Comments
 (0)