@@ -12,12 +12,22 @@ import { PreviewLoadingFrame } from './preview-shared'
1212const CHART_ROWS_MAX = 5000
1313const 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+
1519interface 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
2333interface 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