Skip to content

Commit a38f006

Browse files
committed
sim:chart hydrated embeds: inline or .chart file refs, live table reads per serve
1 parent 313acf1 commit a38f006

12 files changed

Lines changed: 604 additions & 220 deletions

File tree

apps/sim/app/api/files/serve/[...path]/route.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,12 @@ async function resolveServableBytes(params: {
9393
const text = buffer.toString('utf8')
9494
if (isSimPageSource(text)) {
9595
return {
96-
buffer: Buffer.from(await renderSimPageDocumentWithAssets(text, { workspaceId }), 'utf8'),
96+
buffer: Buffer.from(
97+
// The principal lets referenced table-backed charts read CURRENT
98+
// rows under the viewer's own authorization on every serve.
99+
await renderSimPageDocumentWithAssets(text, { workspaceId, principal: filePrincipal }),
100+
'utf8'
101+
),
97102
contentType: 'text/html',
98103
}
99104
}

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

Lines changed: 1 addition & 160 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { getErrorMessage } from '@sim/utils/errors'
55
import type { EChartsOption } from 'echarts'
66
import { useTheme } from 'next-themes'
77
import { buildChartRenderOption } from '@/lib/charts/option'
8+
import { type ChartSpec, parseChartSpec, shapeTableRows } from '@/lib/charts/spec'
89
import { getColumnId } from '@/lib/table/column-keys'
910
import { useTable, useTableRowsSample } from '@/hooks/queries/tables'
1011
import { PreviewLoadingFrame } from './preview-shared'
@@ -13,166 +14,6 @@ import { PreviewLoadingFrame } from './preview-shared'
1314
const CHART_ROWS_MAX = 5000
1415
const 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-
17617
function buildOption(spec: ChartSpec, rows: Array<Record<string, unknown>> | null): EChartsOption {
17718
return buildChartRenderOption({ title: spec.title, option: spec.option, rows }) as EChartsOption
17819
}

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

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
} from '@/lib/workspace-files/artifact-stylesheet'
1515
import { compileSimPage, isSimPageSource } from '@/lib/workspace-files/page-compile'
1616
import { useHorizontalWheelScroll } from '@/app/workspace/[workspaceId]/files/components/file-viewer/use-horizontal-wheel-scroll'
17+
import { useWorkspaceFileBinary } from '@/hooks/queries/workspace-files'
1718
import { ChartPreview } from './chart-preview'
1819
import { type CsvImportFileDescriptor, useCsvTruncationImport } from './csv-import'
1920
import { DataTable } from './data-table'
@@ -80,7 +81,15 @@ export const PreviewPanel = memo(function PreviewPanel({
8081
const previewType = resolvePreviewType(mimeType, filename)
8182

8283
if (previewType === 'html')
83-
return <HtmlPreview content={content} isStreaming={isStreaming} workspaceId={workspaceId} />
84+
return (
85+
<HtmlPreview
86+
content={content}
87+
isStreaming={isStreaming}
88+
workspaceId={workspaceId}
89+
fileId={fileId}
90+
fileKey={fileKey}
91+
/>
92+
)
8493
if (previewType === 'csv')
8594
return (
8695
<CsvPreview
@@ -291,16 +300,35 @@ const HtmlPreview = memo(function HtmlPreview({
291300
content,
292301
isStreaming,
293302
workspaceId,
303+
fileId,
304+
fileKey,
294305
}: {
295306
content: string
296307
isStreaming?: boolean
297308
workspaceId?: string
309+
fileId?: string
310+
fileKey?: string
298311
}) {
299312
const { resolvedTheme } = useTheme()
300313
const router = useRouter()
301314
const batchedContent = useStreamBatchedValue(content, isStreaming === true, 2000)
315+
// A SAVED sim page prefers the server-compiled document — the pptx/docx
316+
// model: the serve route resolves chart references (reading a table's
317+
// CURRENT rows under the viewer's authorization) and inlines the chart
318+
// runtime, and reopening/refocusing refetches, so the page recompiles on
319+
// reload. While it loads — and always while streaming/editing — the client
320+
// compile below stands in, with chart figures as placeholders.
321+
const isSavedPage =
322+
Boolean(fileId && fileKey && workspaceId) && isStreaming !== true && isSimPageSource(content)
323+
const served = useWorkspaceFileBinary(workspaceId ?? '', fileId ?? '', fileKey ?? '', {
324+
enabled: isSavedPage,
325+
})
326+
const servedHtml = useMemo(
327+
() => (isSavedPage && served.data ? new TextDecoder().decode(served.data) : null),
328+
[isSavedPage, served.data]
329+
)
302330
const builtContent = buildHtmlPreviewDocument(
303-
batchedContent,
331+
servedHtml ?? batchedContent,
304332
resolvedTheme === 'dark' ? 'dark' : 'light',
305333
workspaceId
306334
)

apps/sim/lib/charts/fence.ts

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { buildChartRenderOption } from '@/lib/charts/option'
2+
3+
/**
4+
* The `sim:chart` page fence, rendered as PURE markup: the final ECharts
5+
* option rides inside the figure as JSON, and a single inline hydration
6+
* script (appended once per page by the server pass or the client preview)
7+
* boots every figure. That keeps the compiled page self-contained — fully
8+
* interactive in the app's sandboxed iframe (inline scripts are allowed) and
9+
* in a downloaded copy, which carries the library and the data with it.
10+
*
11+
* Two payload shapes are valid:
12+
* - inline: `{ option, rows?, height? }` — data baked into the page.
13+
* - reference: `{ file, height? }` — a `.chart` workspace file; emitted as a
14+
* marker figure the SERVER pass resolves (reading the file and, for table
15+
* sources, the table's CURRENT rows under the viewer's authorization), so
16+
* every in-app view is fresh while a download freezes that serve's data.
17+
*/
18+
19+
export const CHART_FENCE_DEFAULT_HEIGHT = 420
20+
const CHART_FENCE_MIN_HEIGHT = 200
21+
const CHART_FENCE_MAX_HEIGHT = 800
22+
23+
export interface ChartFenceInlinePayload {
24+
option: Record<string, unknown>
25+
rows?: Array<Record<string, unknown>>
26+
height?: number
27+
}
28+
29+
function clampHeight(height: unknown): number {
30+
const parsed = typeof height === 'number' ? Math.round(height) : CHART_FENCE_DEFAULT_HEIGHT
31+
return Math.min(Math.max(parsed, CHART_FENCE_MIN_HEIGHT), CHART_FENCE_MAX_HEIGHT)
32+
}
33+
34+
function escapeHtml(text: string): string {
35+
return text
36+
.replace(/&/g, '&amp;')
37+
.replace(/</g, '&lt;')
38+
.replace(/>/g, '&gt;')
39+
.replace(/"/g, '&quot;')
40+
}
41+
42+
/** A `</script` or `<!--` inside the JSON would terminate/confuse the carrier tag. */
43+
function escapeJsonForScriptTag(json: string): string {
44+
return json.replace(/</g, '\\u003c')
45+
}
46+
47+
/** Accepts `sim:file/<id>` or a bare file id; returns the bare id. */
48+
function chartFileRef(value: unknown): string | null {
49+
if (typeof value !== 'string' || value === '') return null
50+
const match = value.match(/^(?:sim:file\/)?([A-Za-z0-9-]+)$/)
51+
return match ? match[1] : null
52+
}
53+
54+
/** The resolved-figure markup: spec JSON carrier + canvas the hydrator fills. */
55+
export function renderResolvedChartFigure(
56+
payload: ChartFenceInlinePayload,
57+
caption: string
58+
): string {
59+
const height = clampHeight(payload.height)
60+
const option = buildChartRenderOption({ option: payload.option, rows: payload.rows ?? null })
61+
const spec = escapeJsonForScriptTag(JSON.stringify({ option }))
62+
const figcaption = caption ? `<figcaption>${escapeHtml(caption)}</figcaption>` : ''
63+
return `<figure class="sim-chart"><script type="application/json" class="sim-chart-spec">${spec}</script><div class="sim-chart-canvas" style="height:${height}px"><div class="sim-chart-placeholder">Chart</div></div>${figcaption}</figure>`
64+
}
65+
66+
/**
67+
* Renders a `sim:chart` fence payload to figure markup, or null when the
68+
* payload matches neither valid shape (the compiler reports a diagnostic).
69+
*/
70+
export function renderChartFenceMarkup(payload: unknown, caption: string): string | null {
71+
if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) return null
72+
const doc = payload as Record<string, unknown>
73+
74+
const fileRef = doc.file === undefined ? null : chartFileRef(doc.file)
75+
if (doc.file !== undefined) {
76+
if (fileRef === null) return null
77+
const height = clampHeight(doc.height)
78+
const figcaption = caption ? `<figcaption>${escapeHtml(caption)}</figcaption>` : ''
79+
return `<figure class="sim-chart" data-sim-chart-file="${escapeHtml(fileRef)}" data-sim-chart-height="${height}" data-sim-chart-caption="${escapeHtml(caption)}"><div class="sim-chart-placeholder">Chart — resolves from the workspace when the page is served</div>${figcaption}</figure>`
80+
}
81+
82+
if (doc.option === null || typeof doc.option !== 'object' || Array.isArray(doc.option)) {
83+
return null
84+
}
85+
if (doc.rows !== undefined && !Array.isArray(doc.rows)) return null
86+
return renderResolvedChartFigure(doc as unknown as ChartFenceInlinePayload, caption)
87+
}
88+
89+
/** Matches the unresolved reference markers emitted for `{"file": ...}` payloads. */
90+
export const CHART_REF_FIGURE_RE =
91+
/<figure class="sim-chart" data-sim-chart-file="([^"]+)" data-sim-chart-height="(\d+)" data-sim-chart-caption="([^"]*)">[\s\S]*?<\/figure>/g
92+
93+
export function chartUnavailableFigure(reason: string): string {
94+
return `<figure class="sim-chart"><div class="sim-chart-placeholder">${reason}</div></figure>`
95+
}
96+
97+
export function htmlHasChartFigures(html: string): boolean {
98+
return html.includes('class="sim-chart"')
99+
}
100+
101+
/**
102+
* The per-page bootstrap appended after the ECharts library source: hydrates
103+
* every resolved figure, themed by the page's `data-theme` stamp (falling
104+
* back to the OS scheme), and follows container resizes.
105+
*/
106+
export const CHART_HYDRATION_SNIPPET = `(() => {
107+
const stamped = document.documentElement.getAttribute('data-theme')
108+
const dark = stamped === 'dark' || (stamped !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches)
109+
for (const figure of document.querySelectorAll('figure.sim-chart')) {
110+
const specTag = figure.querySelector('script.sim-chart-spec')
111+
const canvas = figure.querySelector('.sim-chart-canvas')
112+
if (!specTag || !canvas) continue
113+
let spec
114+
try { spec = JSON.parse(specTag.textContent || '') } catch { continue }
115+
if (!spec || typeof spec.option !== 'object') continue
116+
canvas.textContent = ''
117+
const chart = echarts.init(canvas, dark ? 'dark' : undefined)
118+
try { chart.setOption(spec.option) } catch { chart.dispose(); continue }
119+
new ResizeObserver(() => chart.resize()).observe(canvas)
120+
}
121+
})()`

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/chart-preview.test.ts renamed to apps/sim/lib/charts/spec.test.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,7 @@
22
* @vitest-environment node
33
*/
44
import { describe, expect, it } from 'vitest'
5-
import {
6-
parseChartSpec,
7-
shapeTableRows,
8-
} from '@/app/workspace/[workspaceId]/files/components/file-viewer/chart-preview'
5+
import { parseChartSpec, shapeTableRows } from '@/lib/charts/spec'
96

107
const rows = [
118
{ month: '2024-01', region: 'NA', revenue: 100, conversion: 4 },

0 commit comments

Comments
 (0)