@@ -5,6 +5,7 @@ import { getErrorMessage } from '@sim/utils/errors'
55import type { EChartsOption } from 'echarts'
66import { useTheme } from 'next-themes'
77import { buildChartRenderOption } from '@/lib/charts/option'
8+ import { type ChartSpec , parseChartSpec , shapeTableRows } from '@/lib/charts/spec'
89import { getColumnId } from '@/lib/table/column-keys'
910import { useTable , useTableRowsSample } from '@/hooks/queries/tables'
1011import { PreviewLoadingFrame } from './preview-shared'
@@ -13,166 +14,6 @@ import { PreviewLoadingFrame } from './preview-shared'
1314const CHART_ROWS_MAX = 5000
1415const CHART_ROWS_DEFAULT = 1000
1516
16- type ChartAggregateOp = 'sum' | 'avg' | 'min' | 'max' | 'count'
17-
18- const CHART_AGGREGATE_OPS = new Set < string > ( [ 'sum' , 'avg' , 'min' , 'max' , 'count' ] )
19-
20- interface ChartTableSource {
21- type : 'table'
22- tableId : string
23- filter ?: unknown
24- sort ?: unknown
25- limit ?: number
26- /** Group rows by these columns; requires `aggregate`. */
27- groupBy ?: string [ ]
28- /** Metric column → op, computed per group. */
29- aggregate ?: Record < string , ChartAggregateOp >
30- /** Fan the aggregated metric(s) out into one column per distinct value of this column. */
31- pivot ?: string
32- }
33-
34- interface ChartStaticSource {
35- type : 'static'
36- rows ?: Array < Record < string , unknown > >
37- }
38-
39- /**
40- * A `.chart` file (`text/x-sim-chart`): a declarative ECharts document. The
41- * `option` is a plain ECharts option object; `source` optionally supplies the
42- * data — inline rows, or a live read of a Sim table injected as
43- * `option.dataset.source` so the chart stays current with the table.
44- */
45- interface ChartSpec {
46- schema_version : number
47- title ?: string
48- source ?: ChartStaticSource | ChartTableSource
49- option : Record < string , unknown >
50- }
51-
52- export function parseChartSpec ( content : string ) : { spec ?: ChartSpec ; error ?: string } {
53- let raw : unknown
54- try {
55- raw = JSON . parse ( content )
56- } catch ( e ) {
57- return { error : getErrorMessage ( e , 'not valid JSON' ) }
58- }
59- if ( raw === null || typeof raw !== 'object' || Array . isArray ( raw ) ) {
60- return { error : 'chart document must be a JSON object' }
61- }
62- const doc = raw as Record < string , unknown >
63- if ( doc . schema_version !== 1 ) {
64- return { error : 'chart document must declare "schema_version": 1' }
65- }
66- if ( doc . option === null || typeof doc . option !== 'object' || Array . isArray ( doc . option ) ) {
67- return { error : 'chart document must declare an "option" object (an ECharts option)' }
68- }
69- const source = doc . source as ChartSpec [ 'source' ]
70- if ( source !== undefined ) {
71- if ( source === null || typeof source !== 'object' ) {
72- return { error : '"source" must be an object' }
73- }
74- if ( source . type === 'table' ) {
75- if ( typeof source . tableId !== 'string' || source . tableId === '' ) {
76- return { error : 'a table source must declare "tableId"' }
77- }
78- if ( source . groupBy !== undefined ) {
79- if ( ! Array . isArray ( source . groupBy ) || source . groupBy . some ( ( c ) => typeof c !== 'string' ) ) {
80- return { error : '"groupBy" must be an array of column names' }
81- }
82- if ( source . aggregate === null || typeof source . aggregate !== 'object' ) {
83- return { error : '"groupBy" requires an "aggregate" object ({column: op})' }
84- }
85- const ops = Object . values ( source . aggregate )
86- if ( ops . length === 0 || ops . some ( ( op ) => ! CHART_AGGREGATE_OPS . has ( String ( op ) ) ) ) {
87- return { error : '"aggregate" ops must be sum, avg, min, max, or count' }
88- }
89- }
90- if ( source . pivot !== undefined ) {
91- if ( typeof source . pivot !== 'string' ) return { error : '"pivot" must be a column name' }
92- if ( ! source . groupBy ) return { error : '"pivot" requires "groupBy" and "aggregate"' }
93- }
94- } else if ( source . type === 'static' ) {
95- if ( source . rows !== undefined && ! Array . isArray ( source . rows ) ) {
96- return { error : 'a static source\'s "rows" must be an array' }
97- }
98- } else {
99- return { error : '"source.type" must be "static" or "table"' }
100- }
101- }
102- return { spec : doc as unknown as ChartSpec }
103- }
104-
105- function aggregateValues (
106- rows : Array < Record < string , unknown > > ,
107- column : string ,
108- op : ChartAggregateOp
109- ) : number {
110- if ( op === 'count' ) return rows . length
111- const values = rows . map ( ( r ) => Number ( r [ column ] ) ) . filter ( ( n ) => Number . isFinite ( n ) )
112- if ( values . length === 0 ) return 0
113- switch ( op ) {
114- case 'sum' :
115- return values . reduce ( ( a , b ) => a + b , 0 )
116- case 'avg' :
117- return values . reduce ( ( a , b ) => a + b , 0 ) / values . length
118- case 'min' :
119- return Math . min ( ...values )
120- case 'max' :
121- return Math . max ( ...values )
122- }
123- }
124-
125- /**
126- * Client-side shaping for table sources: group → aggregate → optionally pivot
127- * one column's distinct values into per-value columns. Groups keep first-seen
128- * order, so the source's `sort` decides the category order. This is the whole
129- * "query engine" — deliberately tiny; anything fancier belongs in a static
130- * source with precomputed rows.
131- */
132- export function shapeTableRows (
133- rows : Array < Record < string , unknown > > ,
134- source : ChartTableSource
135- ) : Array < Record < string , unknown > > {
136- const { groupBy, aggregate, pivot } = source
137- if ( ! groupBy || groupBy . length === 0 || ! aggregate ) return rows
138-
139- const groups = new Map < string , Array < Record < string , unknown > > > ( )
140- for ( const row of rows ) {
141- const key = groupBy . map ( ( c ) => String ( row [ c ] ?? '' ) ) . join ( '\u0000' )
142- const bucket = groups . get ( key )
143- if ( bucket ) bucket . push ( row )
144- else groups . set ( key , [ row ] )
145- }
146-
147- const metrics = Object . entries ( aggregate )
148- const out : Array < Record < string , unknown > > = [ ]
149- for ( const bucket of groups . values ( ) ) {
150- const shaped : Record < string , unknown > = { }
151- for ( const c of groupBy ) shaped [ c ] = bucket [ 0 ] [ c ]
152- if ( pivot ) {
153- const byValue = new Map < string , Array < Record < string , unknown > > > ( )
154- for ( const row of bucket ) {
155- const value = String ( row [ pivot ] ?? '' )
156- const slice = byValue . get ( value )
157- if ( slice ) slice . push ( row )
158- else byValue . set ( value , [ row ] )
159- }
160- for ( const [ value , slice ] of byValue ) {
161- for ( const [ column , op ] of metrics ) {
162- const name = metrics . length === 1 ? value : `${ value } ${ column } `
163- shaped [ name ] = aggregateValues ( slice , column , op )
164- }
165- }
166- } else {
167- for ( const [ column , op ] of metrics ) {
168- shaped [ column ] = aggregateValues ( bucket , column , op )
169- }
170- }
171- out . push ( shaped )
172- }
173- return out
174- }
175-
17617function buildOption ( spec : ChartSpec , rows : Array < Record < string , unknown > > | null ) : EChartsOption {
17718 return buildChartRenderOption ( { title : spec . title , option : spec . option , rows } ) as EChartsOption
17819}
0 commit comments