Skip to content

Commit 313acf1

Browse files
committed
sim:chart page fence: ECharts SSR to themed inline SVG; shared option builder
1 parent 347a267 commit 313acf1

7 files changed

Lines changed: 301 additions & 73 deletions

File tree

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

Lines changed: 2 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { memo, useEffect, useMemo, useRef, useState } from 'react'
44
import { getErrorMessage } from '@sim/utils/errors'
55
import type { EChartsOption } from 'echarts'
66
import { useTheme } from 'next-themes'
7+
import { buildChartRenderOption } from '@/lib/charts/option'
78
import { getColumnId } from '@/lib/table/column-keys'
89
import { useTable, useTableRowsSample } from '@/hooks/queries/tables'
910
import { PreviewLoadingFrame } from './preview-shared'
@@ -172,80 +173,8 @@ export function shapeTableRows(
172173
return out
173174
}
174175

175-
/**
176-
* Merges the resolved rows into the ECharts option as `dataset.source`. A spec
177-
* whose option already carries a dataset keeps it (fully self-contained static
178-
* charts); the file-level `title` fills in only when the option has none.
179-
*/
180176
function buildOption(spec: ChartSpec, rows: Array<Record<string, unknown>> | null): EChartsOption {
181-
const option = structuredClone(spec.option)
182-
if (rows && rows.length > 0) {
183-
// The resolved rows become the FIRST dataset (id "table", datasetIndex 0).
184-
// Spec-declared datasets follow it, so filter/sort transform datasets can
185-
// derive from the injected rows (transforms default to fromDatasetIndex 0,
186-
// or name it explicitly with fromDatasetId: "table").
187-
const injected = { id: 'table', source: rows }
188-
if (option.dataset === undefined) {
189-
option.dataset = injected
190-
} else if (Array.isArray(option.dataset)) {
191-
option.dataset = [injected, ...option.dataset]
192-
} else {
193-
option.dataset = [injected, option.dataset]
194-
}
195-
}
196-
if (option.backgroundColor === undefined) {
197-
option.backgroundColor = 'transparent'
198-
}
199-
if (spec.title && option.title === undefined) {
200-
option.title = { text: spec.title }
201-
}
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') {
222-
const legends = Array.isArray(option.legend) ? option.legend : [option.legend]
223-
for (const entry of legends) {
224-
if (entry === null || typeof entry !== 'object') continue
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'
232-
}
233-
}
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-
}
248-
return option as EChartsOption
177+
return buildChartRenderOption({ title: spec.title, option: spec.option, rows }) as EChartsOption
249178
}
250179

251180
function ChartErrorPanel({ message, content }: { message: string; content: string }) {

apps/sim/lib/charts/option.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
/**
2+
* Shared ECharts option assembly for Sim chart surfaces — the `.chart` file
3+
* viewer (client) and the sim-page `sim:chart` SSR renderer (server). Pure:
4+
* no React, no echarts import, JSON-in/JSON-out.
5+
*
6+
* Chrome layout is Sim-owned, content is spec-owned. Models reliably produce
7+
* colliding title/legend placements, so the renderer pins the title top-left
8+
* and the legend top-right on one chrome row (scrollable when long),
9+
* overriding any spec positions — the same split the pptx renderer makes
10+
* between slide chrome and slide content.
11+
*/
12+
13+
export interface ChartRenderInput {
14+
title?: string
15+
option: Record<string, unknown>
16+
rows?: Array<Record<string, unknown>> | null
17+
}
18+
19+
export function buildChartRenderOption({
20+
title,
21+
option: specOption,
22+
rows,
23+
}: ChartRenderInput): Record<string, unknown> {
24+
const option = structuredClone(specOption)
25+
if (rows && rows.length > 0) {
26+
// The resolved rows become the FIRST dataset (id "table", datasetIndex 0).
27+
// Spec-declared datasets follow it, so filter/sort transform datasets can
28+
// derive from the injected rows (transforms default to fromDatasetIndex 0,
29+
// or name it explicitly with fromDatasetId: "table").
30+
const injected = { id: 'table', source: rows }
31+
if (option.dataset === undefined) {
32+
option.dataset = injected
33+
} else if (Array.isArray(option.dataset)) {
34+
option.dataset = [injected, ...option.dataset]
35+
} else {
36+
option.dataset = [injected, option.dataset]
37+
}
38+
}
39+
if (option.backgroundColor === undefined) {
40+
option.backgroundColor = 'transparent'
41+
}
42+
if (title && option.title === undefined) {
43+
option.title = { text: title }
44+
}
45+
const hasTitle = option.title !== null && typeof option.title === 'object'
46+
if (hasTitle) {
47+
const titles = Array.isArray(option.title) ? option.title : [option.title]
48+
const primary = titles[0]
49+
if (primary !== null && typeof primary === 'object') {
50+
const t = primary as Record<string, unknown>
51+
t.left = 0
52+
t.top = 0
53+
t.right = undefined
54+
t.bottom = undefined
55+
}
56+
option.title = titles[0]
57+
}
58+
let hasLegend = false
59+
if (option.legend !== null && typeof option.legend === 'object') {
60+
const legends = Array.isArray(option.legend) ? option.legend : [option.legend]
61+
for (const entry of legends) {
62+
if (entry === null || typeof entry !== 'object') continue
63+
hasLegend = true
64+
const l = entry as Record<string, unknown>
65+
l.top = 2
66+
l.right = 0
67+
l.left = undefined
68+
l.bottom = undefined
69+
if (l.type === undefined) l.type = 'scroll'
70+
}
71+
}
72+
// Reserve a chrome row above the plot. Fill only what the spec left unset
73+
// inside grid — axis-name insets remain the spec's call.
74+
const chromeTop = hasTitle || hasLegend ? 48 : 16
75+
if (option.grid === undefined) {
76+
option.grid = { top: chromeTop, left: 12, right: 12, bottom: 12, containLabel: true }
77+
} else if (
78+
option.grid !== null &&
79+
typeof option.grid === 'object' &&
80+
!Array.isArray(option.grid)
81+
) {
82+
const g = option.grid as Record<string, unknown>
83+
if (g.top === undefined) g.top = chromeTop
84+
if (g.containLabel === undefined) g.containLabel = true
85+
}
86+
return option
87+
}

apps/sim/lib/workspace-files/artifact-stylesheet.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,27 @@ figcaption { font-size: 0.875em; line-height: 1.4285714; color: var(--text-prima
390390
.faq details > :not(summary) { padding: 0 1rem; }
391391
.faq details > :last-child { padding-bottom: 0.9rem; margin-bottom: 0; }
392392
393+
/* sim:chart — SSR'd ECharts SVGs, one per theme, swapped by the same
394+
data-theme stamp the rest of the sheet keys on. The SVG carries a fixed
395+
viewport; scale it to the text column. */
396+
.sim-chart { margin: 1.5rem 0; }
397+
.sim-chart svg { max-width: 100%; height: auto; display: block; margin: 0 auto; }
398+
.sim-chart .sim-chart-dark { display: none; }
399+
.sim-chart-placeholder {
400+
border: 1px dashed var(--border);
401+
border-radius: 0.75rem;
402+
padding: 2.5rem 1rem;
403+
text-align: center;
404+
color: var(--text-muted);
405+
font-size: var(--text-sm);
406+
}
407+
@media (prefers-color-scheme: dark) {
408+
:root:not([data-theme="light"]) .sim-chart .sim-chart-light { display: none; }
409+
:root:not([data-theme="light"]) .sim-chart .sim-chart-dark { display: block; }
410+
}
411+
[data-theme="dark"] .sim-chart .sim-chart-light { display: none; }
412+
[data-theme="dark"] .sim-chart .sim-chart-dark { display: block; }
413+
393414
/* The fumadocs callout: a rounded-xl bordered card at 14px with a rounded
394415
2px color bar down the start edge (the docs strip its shadow). */
395416
.callout {
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
// Side-effect import: registers the SSR sim:chart renderer with page-compile.
6+
import '@/lib/workspace-files/page-chart-ssr.server'
7+
import { compileSimPage } from '@/lib/workspace-files/page-compile'
8+
9+
function page(body: string): string {
10+
return `---\ntitle: Test\n---\n\n${body}\n`
11+
}
12+
13+
describe('sim:chart SSR rendering', () => {
14+
it('renders a chart fence to themed inline SVGs', () => {
15+
const source = page(
16+
[
17+
'```sim:chart Revenue by month',
18+
JSON.stringify({
19+
height: 240,
20+
option: {
21+
xAxis: { type: 'category', data: ['Jan', 'Feb'] },
22+
yAxis: { type: 'value' },
23+
series: [{ type: 'bar', data: [3, 5] }],
24+
},
25+
}),
26+
'```',
27+
].join('\n')
28+
)
29+
const diagnostics: string[] = []
30+
const html = compileSimPage(source, { diagnostics })
31+
expect(diagnostics).toEqual([])
32+
expect(html).toContain('<figure class="sim-chart">')
33+
expect(html).toContain('sim-chart-light')
34+
expect(html).toContain('sim-chart-dark')
35+
expect(html).toContain('<svg')
36+
expect(html).toContain('Revenue by month')
37+
})
38+
39+
it('injects rows as the dataset for encode-based options', () => {
40+
const source = page(
41+
[
42+
'```sim:chart',
43+
JSON.stringify({
44+
rows: [
45+
{ month: 'Jan', revenue: 10 },
46+
{ month: 'Feb', revenue: 20 },
47+
],
48+
option: {
49+
xAxis: { type: 'category' },
50+
yAxis: { type: 'value' },
51+
series: [{ type: 'bar', encode: { x: 'month', y: 'revenue' } }],
52+
},
53+
}),
54+
'```',
55+
].join('\n')
56+
)
57+
const diagnostics: string[] = []
58+
const html = compileSimPage(source, { diagnostics })
59+
expect(diagnostics).toEqual([])
60+
expect(html).toContain('<svg')
61+
expect(html).toContain('Feb')
62+
})
63+
64+
it('reports a diagnostic for a payload without an option object', () => {
65+
const source = page(['```sim:chart', '{"rows": []}', '```'].join('\n'))
66+
const diagnostics: string[] = []
67+
const html = compileSimPage(source, { diagnostics })
68+
expect(diagnostics).toHaveLength(1)
69+
expect(diagnostics[0]).toContain('sim:chart block skipped')
70+
expect(html).not.toContain('<figure class="sim-chart">')
71+
})
72+
})
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import * as echarts from 'echarts'
2+
import { buildChartRenderOption } from '@/lib/charts/option'
3+
import { registerChartFenceRenderer } from '@/lib/workspace-files/page-compile'
4+
5+
/**
6+
* Server-side `sim:chart` renderer: ECharts SSR to inline SVG, once per theme.
7+
* Importing this module registers it with page-compile (side-effect module —
8+
* import it from every server compile entrypoint). Pages are static documents,
9+
* so the embed is a styled snapshot; the live interactive artifact remains the
10+
* `.chart` file, which a page links with `[Name](sim:file/<id>)`.
11+
*/
12+
13+
const CHART_SSR_WIDTH = 860
14+
const CHART_SSR_DEFAULT_HEIGHT = 420
15+
const CHART_SSR_MIN_HEIGHT = 200
16+
const CHART_SSR_MAX_HEIGHT = 800
17+
18+
interface ChartFencePayload {
19+
option: Record<string, unknown>
20+
rows?: Array<Record<string, unknown>>
21+
height?: number
22+
}
23+
24+
function parsePayload(payload: unknown): ChartFencePayload | null {
25+
if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) return null
26+
const doc = payload as Record<string, unknown>
27+
if (doc.option === null || typeof doc.option !== 'object' || Array.isArray(doc.option)) {
28+
return null
29+
}
30+
if (doc.rows !== undefined && !Array.isArray(doc.rows)) return null
31+
return doc as unknown as ChartFencePayload
32+
}
33+
34+
function renderSvg(
35+
option: Record<string, unknown>,
36+
theme: 'light' | 'dark',
37+
height: number
38+
): string {
39+
const chart = echarts.init(null, theme === 'dark' ? 'dark' : undefined, {
40+
renderer: 'svg',
41+
ssr: true,
42+
width: CHART_SSR_WIDTH,
43+
height,
44+
})
45+
try {
46+
chart.setOption(option as Parameters<typeof chart.setOption>[0])
47+
return chart.renderToSVGString()
48+
} finally {
49+
chart.dispose()
50+
}
51+
}
52+
53+
function renderChartFence(payload: unknown, caption: string): string | null {
54+
const parsed = parsePayload(payload)
55+
if (!parsed) return null
56+
const height = Math.min(
57+
Math.max(Math.round(parsed.height ?? CHART_SSR_DEFAULT_HEIGHT), CHART_SSR_MIN_HEIGHT),
58+
CHART_SSR_MAX_HEIGHT
59+
)
60+
const option = buildChartRenderOption({ option: parsed.option, rows: parsed.rows ?? null })
61+
let light: string
62+
let dark: string
63+
try {
64+
light = renderSvg(option, 'light', height)
65+
dark = renderSvg(option, 'dark', height)
66+
} catch {
67+
return null
68+
}
69+
const figcaption = caption ? `<figcaption>${escapeHtml(caption)}</figcaption>` : ''
70+
return `<figure class="sim-chart"><div class="sim-chart-light">${light}</div><div class="sim-chart-dark">${dark}</div>${figcaption}</figure>`
71+
}
72+
73+
function escapeHtml(text: string): string {
74+
return text
75+
.replace(/&/g, '&amp;')
76+
.replace(/</g, '&lt;')
77+
.replace(/>/g, '&gt;')
78+
.replace(/"/g, '&quot;')
79+
}
80+
81+
registerChartFenceRenderer(renderChartFence)

0 commit comments

Comments
 (0)