Compare commits

...
Author SHA1 Message Date
Kit Langton b7dff8e868 feat(tui): render Vega-Lite charts as Unicode 2026-08-26 20:16:07 -04:00
15 changed files with 1885 additions and 0 deletions
+17
View File
@@ -901,6 +901,7 @@
"@opencode-ai/theme": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@opencode-ai/vega-lite": "workspace:*",
"@opentui/core": "catalog:",
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:",
@@ -1015,6 +1016,20 @@
"@typescript/native-preview": "catalog:",
},
},
"packages/vega-lite": {
"name": "@opencode-ai/vega-lite",
"version": "0.0.0",
"dependencies": {
"@opencode-ai/plugin": "workspace:*",
"@opentui/core": "catalog:",
"string-width": "catalog:",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
},
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.18.15",
@@ -2219,6 +2234,8 @@
"@opencode-ai/util": ["@opencode-ai/util@workspace:packages/util"],
"@opencode-ai/vega-lite": ["@opencode-ai/vega-lite@workspace:packages/vega-lite"],
"@opencode-ai/web": ["@opencode-ai/web@workspace:packages/web"],
"@opencode-ai/www": ["@opencode-ai/www@workspace:packages/www"],
+1
View File
@@ -86,6 +86,7 @@
"@opencode-ai/theme": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@opencode-ai/vega-lite": "workspace:*",
"@opentui/core": "catalog:",
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:",
+2
View File
@@ -9,6 +9,7 @@ import Plugins from "../feature-plugins/system/plugins"
import Storybook from "../feature-plugins/system/storybook"
import Latex from "@opencode-ai/latex/plugin"
import Merman from "@opencode-ai/merman/plugin"
import VegaLite from "@opencode-ai/vega-lite/plugin"
export const builtins = [
HomeFooter,
@@ -20,6 +21,7 @@ export const builtins = [
Plugins,
Merman,
Latex,
VegaLite,
// The storybook is a development tool; keep its route and palette commands out of
// normal launches and register it only for OPENCODE_STORY runs.
...(process.env.OPENCODE_STORY ? [Storybook] : []),
+89
View File
@@ -0,0 +1,89 @@
# Vega-Lite in the terminal
The built-in `opencode.vega-lite` plugin renders a small, text-only subset of
[Vega-Lite](https://vega.github.io/vega-lite/) inside `vega-lite` Markdown fences.
It uses Unicode bars and Braille plots, not images, a browser, or the Vega runtime.
## Supported subset
- Inline `data.values` containing 1-2,000 records.
- `bar`, `line`, `point`, and `circle` marks, as strings or `{ "type": "line" }`.
- Horizontal or vertical bars: one nominal/ordinal axis and one quantitative axis.
Each category must occur exactly once; aggregate your data before emitting it.
- Lines and scatter plots: two quantitative axes. Lines connect points in ascending
x order within each series.
- Nominal/ordinal `color` fields for up to eight series, using the terminal theme's
categorical palette rather than hard-coded colors.
- String chart titles, field titles, and `axis.title`. A null axis/field title hides
that title. Quantitative axes use linear scales including zero by default;
`scale.zero: false` fits the data for lines and points.
- Categorical `sort: "ascending"` (default), `"descending"`, or `null` for data order.
- Up to 40 bar categories. Narrow charts reflow; charts needing more space scroll
horizontally without wrapping. Long category labels are shortened to fit terminal
cells; numeric bar labels retain their exact values, using scrolling when needed.
Only finite JSON numbers are accepted for quantitative fields. Missing/null values
are not silently dropped. Fields must be direct record keys, not nested field paths.
Input is limited to 262,144 UTF-16 code units and labels to 120 Unicode code points.
Unsupported specifications remain visible as source. This includes remote URLs,
named datasets, transforms, aggregation, binning, stacking, temporal axes, custom
domains, log scales, layers, facets, interactive parameters, custom mark styling,
and browser-specific sizing/configuration. The renderer never fetches data or
evaluates expressions. Incomplete JSON stays as source until it forms a supported
chart; an invalid edit does not retain an older chart.
## Bars
````markdown
```vega-lite
{
"title": "Startup time (ms)",
"data": {
"values": [
{ "build": "Before", "ms": 320 },
{ "build": "After", "ms": 120 },
{ "build": "Cached", "ms": 30 }
]
},
"mark": "bar",
"encoding": {
"y": { "field": "build", "type": "nominal", "sort": null },
"x": { "field": "ms", "type": "quantitative" }
}
}
```
````
## Lines and points
````markdown
```vega-lite
{
"title": "Latency under load",
"data": {
"values": [
{ "requests": 1, "ms": 12, "build": "Before" },
{ "requests": 8, "ms": 20, "build": "Before" },
{ "requests": 16, "ms": 65, "build": "Before" },
{ "requests": 1, "ms": 8, "build": "After" },
{ "requests": 8, "ms": 11, "build": "After" },
{ "requests": 16, "ms": 24, "build": "After" }
]
},
"mark": "line",
"encoding": {
"x": { "field": "requests", "type": "quantitative" },
"y": { "field": "ms", "type": "quantitative" },
"color": { "field": "build", "type": "nominal" }
}
}
```
````
Change `"mark": "line"` to `"mark": "point"` for a scatter plot.
## Development
From this package, run `bun run test` and `bun typecheck`. From the repository root,
run `bun run dev:live` to try the built-in renderer against your existing sessions.
+25
View File
@@ -0,0 +1,25 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/vega-lite",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
"./markdown": "./src/markdown.ts",
"./plugin": "./src/plugin.ts"
},
"scripts": {
"test": "bun test --timeout 30000 --only-failures",
"typecheck": "tsgo --noEmit"
},
"dependencies": {
"@opencode-ai/plugin": "workspace:*",
"@opentui/core": "catalog:",
"string-width": "catalog:"
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:"
}
}
+215
View File
@@ -0,0 +1,215 @@
import { afterEach, expect, test } from "bun:test"
import {
BoxRenderable,
CodeRenderable,
MarkdownRenderable,
RGBA,
ScrollBoxRenderable,
SyntaxStyle,
TextRenderable,
createMarkdownCodeBlockRenderer,
} from "@opentui/core"
import { createTestRenderer } from "@opentui/core/testing"
import { createVegaLiteCodeBlockRenderer } from "./markdown"
const renderers: Awaited<ReturnType<typeof createTestRenderer>>["renderer"][] = []
const syntaxStyle = SyntaxStyle.fromStyles({ default: { fg: "#ffffff" } })
const spec = {
title: "Startup time",
data: {
values: [
{ build: "Before", ms: 320 },
{ build: "After", ms: 120 },
{ build: "Cached", ms: 30 },
],
},
mark: "bar",
encoding: {
y: { field: "build", type: "nominal", sort: null },
x: { field: "ms", type: "quantitative" },
},
}
const source = JSON.stringify(spec)
const fence = (value = source, language = "vega-lite") => `\`\`\`${language}\n${value}\n\`\`\``
afterEach(() => {
renderers.splice(0).forEach((renderer) => renderer.destroy())
})
async function setup(content: string, width = 80) {
const output = await createTestRenderer({ width, height: 60, remote: true, useThread: false })
renderers.push(output.renderer)
const palette = { text: "#abcdef", subdued: "#667788", series: ["#44aaff", "#ffbb44"] }
const markdown = new MarkdownRenderable(output.renderer, {
content,
syntaxStyle,
streaming: true,
internalBlockMode: "top-level",
renderNode: createMarkdownCodeBlockRenderer({
"vega-lite": createVegaLiteCodeBlockRenderer(output.renderer, () => palette),
}),
})
const parent = new BoxRenderable(output.renderer, { width: "100%" })
parent.add(markdown)
output.renderer.root.add(parent)
await output.renderOnce()
await output.renderOnce()
return { ...output, parent, markdown, palette }
}
test.each(["vega-lite", "VEGA-LITE", "vega-lite title=example"])("renders a %s fence", async (language) => {
const output = await setup(fence(source, language))
expect(output.markdown.getChildren()[0]).toBeInstanceOf(ScrollBoxRenderable)
expect(output.captureCharFrame()).toContain("Startup time")
expect(output.captureCharFrame()).toContain("Before")
expect(output.captureCharFrame()).toContain("320")
expect(output.captureCharFrame()).not.toContain('"encoding"')
})
test.each([
"{",
"null",
JSON.stringify({ ...spec, transform: [{ filter: "datum.ms > 100" }] }),
JSON.stringify({ ...spec, data: { url: "https://example.com/data.json" } }),
JSON.stringify({ ...spec, mark: "area" }),
JSON.stringify({ ...spec, encoding: { ...spec.encoding, x: { ...spec.encoding.x, aggregate: "mean" } } }),
])("preserves unsupported and incomplete specifications as source: %s", async (value) => {
const output = await setup(fence(value))
const block = output.markdown.getChildren()[0]
expect(block).toBeInstanceOf(CodeRenderable)
if (!(block instanceof CodeRenderable)) throw new Error("Expected source fallback")
expect(block.content).toBe(value)
})
test("renders complete JSON during streaming and preserves the final chart", async () => {
const output = await setup(`\`\`\`vega-lite\n${source.slice(0, -1)}`)
expect(output.markdown.getChildren()[0]).toBeInstanceOf(CodeRenderable)
output.markdown.content += "}"
await output.renderOnce()
await output.renderOnce()
expect(output.markdown.getChildren()[0]).toBeInstanceOf(ScrollBoxRenderable)
output.markdown.content += "\n```"
output.markdown.streaming = false
await output.renderOnce()
expect(output.markdown.getChildren()[0]).toBeInstanceOf(ScrollBoxRenderable)
})
test("does not retain a stale chart for an invalid edit or leak across fences", async () => {
const output = await setup(fence())
output.markdown.content = `${fence(`${source}!`)}\n\n${fence("{")}`
output.markdown.streaming = false
await output.renderOnce()
expect(output.markdown.getChildren().every((child) => child instanceof CodeRenderable)).toBe(true)
})
test("leaves unrelated fences alone", async () => {
const output = await setup(fence(source, "json"))
expect(output.markdown.getChildren()[0]).toBeInstanceOf(CodeRenderable)
})
test.each(["line", "point", "circle"])("renders %s marks with separate categorical series", async (mark) => {
const output = await setup(
fence(
JSON.stringify({
title: "Latency under load",
data: {
values: [
{ requests: 1, ms: 12, build: "Before" },
{ requests: 8, ms: 65, build: "Before" },
{ requests: 1, ms: 8, build: "After" },
{ requests: 8, ms: 24, build: "After" },
],
},
mark,
encoding: {
x: { field: "requests", type: "quantitative" },
y: { field: "ms", type: "quantitative" },
color: { field: "build", type: "nominal" },
},
}),
),
)
const plot = output.markdown.getChildren()[0]?.getChildren()[0]
if (!(plot instanceof TextRenderable)) throw new Error("Expected chart text")
expect(output.captureCharFrame()).toContain("Latency under load")
expect(output.captureCharFrame()).toContain("Before")
expect(output.captureCharFrame()).toContain("After")
expect(output.captureCharFrame()).toMatch(/[\u2801-\u28ff]/u)
expect(plot.chunks.some((chunk) => chunk.fg?.equals(RGBA.fromHex(output.palette.series[0])))).toBe(true)
expect(plot.chunks.some((chunk) => chunk.fg?.equals(RGBA.fromHex(output.palette.series[1])))).toBe(true)
})
test("a vertical chart scrolls rather than dropping crowded categories", async () => {
const output = await setup(
fence(
JSON.stringify({
data: { values: Array.from({ length: 20 }, (_, index) => ({ build: `B${index}`, ms: index + 1 })) },
mark: "bar",
encoding: {
x: { field: "build", type: "nominal", sort: null },
y: { field: "ms", type: "quantitative" },
},
}),
),
32,
)
const viewport = output.markdown.getChildren()[0]
if (!(viewport instanceof ScrollBoxRenderable)) throw new Error("Expected chart viewport")
expect(viewport.scrollWidth).toBeGreaterThan(32)
expect(output.captureCharFrame()).toContain("B0")
expect(output.captureCharFrame()).not.toContain("B19")
viewport.scrollLeft = viewport.scrollWidth
await output.renderOnce()
expect(output.captureCharFrame()).toContain("B19")
})
test("reflows to the parent width without wrapping charts or surrounding prose", async () => {
const output = await setup(`Before chart\n\n${fence()}\n\nAfter chart`)
const viewport = output.markdown.getChildren()[1]
if (!(viewport instanceof ScrollBoxRenderable)) throw new Error("Expected chart viewport")
const plot = viewport.getChildren()[0]
if (!(plot instanceof TextRenderable)) throw new Error("Expected chart text")
const wide = plot.width
output.parent.width = 32
await output.renderOnce()
await output.renderOnce()
expect(viewport.width).toBe(32)
expect(plot.width).toBeLessThan(wide)
expect(plot.width).toBeLessThanOrEqual(32)
expect(output.captureCharFrame()).toContain("Before chart")
expect(output.captureCharFrame()).toContain("After chart")
output.parent.width = 80
await output.renderOnce()
await output.renderOnce()
expect(plot.width).toBe(wide)
})
test("keeps a minimum readable width and supports horizontal scrolling", async () => {
const output = await setup(fence(), 18)
const viewport = output.markdown.getChildren()[0]
if (!(viewport instanceof ScrollBoxRenderable)) throw new Error("Expected chart viewport")
expect(viewport.width).toBe(18)
expect(viewport.scrollWidth).toBeGreaterThan(18)
await output.mockMouse.scroll(2, 2, "right")
await output.renderOnce()
expect(viewport.scrollLeft).toBeGreaterThan(0)
})
test("uses semantic colors and refreshes after a theme change", async () => {
const output = await setup(fence())
const block = () => output.markdown.getChildren()[0]?.getChildren()[0]
const plot = block()
if (!(plot instanceof TextRenderable)) throw new Error("Expected chart text")
expect(plot.chunks.some((chunk) => chunk.fg?.equals(RGBA.fromHex(output.palette.series[0])))).toBe(true)
expect(plot.chunks.some((chunk) => chunk.fg?.equals(RGBA.fromHex(output.palette.subdued)))).toBe(true)
expect(plot.chunks.some((chunk) => chunk.fg?.equals(RGBA.fromHex(output.palette.text)))).toBe(true)
output.palette.series[0] = "#bb33aa"
output.palette.subdued = "#123456"
output.markdown.refreshStyles()
await output.renderOnce()
await output.renderOnce()
const updated = block()
if (!(updated instanceof TextRenderable)) throw new Error("Expected chart text")
expect(updated.chunks.some((chunk) => chunk.fg?.equals(RGBA.fromHex("#bb33aa")))).toBe(true)
expect(updated.chunks.some((chunk) => chunk.fg?.equals(RGBA.fromHex("#123456")))).toBe(true)
})
+83
View File
@@ -0,0 +1,83 @@
import {
ScrollBoxRenderable,
StyledText,
TextRenderable,
parseColor,
type ColorInput,
type MarkdownCodeBlockRenderer,
type RenderContext,
} from "@opentui/core"
import { parseChart } from "./spec"
import { renderChart } from "./render"
export type VegaLiteOptions = {
text: ColorInput
subdued: ColorInput
series: readonly ColorInput[]
}
export function createVegaLiteCodeBlockRenderer(
context: RenderContext,
options: () => VegaLiteOptions,
): MarkdownCodeBlockRenderer {
return (token, render) => {
const chart = parseChart(token.text)
if (!chart) return render.defaultRender() ?? undefined
const palette = options()
const text = parseColor(palette.text)
const subdued = parseColor(palette.subdued)
const series = palette.series.map((color) => parseColor(color))
const plot = new TextRenderable(context, {
width: 24,
height: 1,
wrapMode: "none",
selectable: false,
flexShrink: 0,
})
const viewport = new ScrollBoxRenderable(context, {
width: "100%",
height: 1,
marginTop: 1,
flexShrink: 0,
scrollX: true,
scrollY: false,
onMouseScroll(event) {
if (event.modifiers.shift || event.scroll?.direction === "left" || event.scroll?.direction === "right") {
event.stopPropagation()
}
},
})
viewport.horizontalScrollBar.visible = false
viewport.verticalScrollBar.visible = false
viewport.add(plot)
let drawnWidth = 0
const resize = () => {
const width = Math.max(24, Math.min(120, viewport.width || context.width))
// Height changes also notify onSizeChange; only a new width needs another layout.
if (width === drawnWidth) return
drawnWidth = width
const layout = renderChart(chart, width)
plot.content = new StyledText(
layout.rows.flatMap((row, index) => [
...row.map((cell) => ({
__isChunk: true as const,
text: cell.text,
fg:
cell.role === "axis"
? subdued
: cell.role === "text"
? text
: (series[cell.role % series.length] ?? text),
})),
...(index < layout.height - 1 ? [{ __isChunk: true as const, text: "\n", fg: text }] : []),
]),
)
plot.width = layout.width
plot.height = layout.height
viewport.height = layout.height
}
viewport.onSizeChange = resize
resize()
return viewport
}
}
+16
View File
@@ -0,0 +1,16 @@
import { Plugin } from "@opencode-ai/plugin/tui"
import { createVegaLiteCodeBlockRenderer } from "./markdown"
export default Plugin.define({
id: "opencode.vega-lite",
setup(context) {
context.markdown.registerCodeBlockRenderer(
"vega-lite",
createVegaLiteCodeBlockRenderer(context.renderer, () => ({
text: context.theme.text.default,
subdued: context.theme.text.subdued,
series: context.theme.categorical.map((scale) => scale[context.themeMode === "dark" ? 300 : 700]),
})),
)
},
})
+472
View File
@@ -0,0 +1,472 @@
import { describe, expect, test } from "bun:test"
import stringWidth from "string-width"
import { renderChart } from "./render"
import type { Chart, ChartLayout } from "./types"
function numeric(mark: "line" | "point" = "line", values: number[] = [0, 5, 10]): Chart {
return {
mark,
x: { field: "x", title: "", type: "quantitative", zero: false },
y: { field: "y", title: "", type: "quantitative", zero: false },
points: values.map((value) => ({ x: value, y: value, series: 0 })),
series: [""],
}
}
function bars(horizontal = true): Chart {
const quantitative = { field: "value", title: "Value", type: "quantitative", zero: true } as const
const categorical = { field: "label", title: "", type: "nominal", zero: true } as const
return {
mark: "bar",
title: "Change",
x: horizontal ? quantitative : categorical,
y: horizontal ? categorical : quantitative,
points: [-5, 0, 10].map((value, index) => ({
x: horizontal ? value : ["Loss", "Zero", "Gain"][index],
y: horizontal ? ["Loss", "Zero", "Gain"][index] : value,
series: index,
})),
series: [""],
categories: ["Loss", "Zero", "Gain"],
}
}
function text(layout: ChartLayout) {
return layout.rows.map((row) => row.map((cell) => cell.text).join("")).join("\n")
}
function check(layout: ChartLayout, width: number) {
const lines = layout.rows.map((row) => row.map((cell) => cell.text).join(""))
expect(layout.height).toBe(lines.length)
expect(layout.width).toBe(Math.max(0, ...lines.map((line) => stringWidth(line))))
expect(layout.width).toBeLessThanOrEqual(width)
expect(text(layout)).not.toMatch(/NaN|Infinity|\x1b|\t/)
expect(
layout.rows.flat().every((cell) => cell.role === "text" || cell.role === "axis" || Number.isInteger(cell.role)),
).toBe(true)
}
function dots(layout: ChartLayout) {
return layout.rows
.flat()
.flatMap((cell) =>
Array.from(cell.text).filter(
(character) => character.codePointAt(0)! > 0x2800 && character.codePointAt(0)! <= 0x28ff,
),
)
.reduce((sum, character) => sum + (character.codePointAt(0)! - 0x2800).toString(2).replaceAll("0", "").length, 0)
}
describe("readable chart examples", () => {
test("signed horizontal bars", () => {
expect(text(renderChart(bars(), 40))).toMatchInlineSnapshot(`
"Change
Loss ██████████│ -5
Zero │ 0
Gain │█████████████████████ 10
──────────┼─────────────────────
-5 0 5 10
Value"
`)
})
test("signed vertical bars", () => {
expect(text(renderChart(bars(false), 30))).toMatchInlineSnapshot(`
"Change
Value
10│ ██████
│ ██████
│ ██████
5│ ██████
│ ██████
│ ██████
0┼───────────────────────────
│ ██████
│ ██████
-5│ ██████
└───────────────────────────
Loss Zero Gain"
`)
})
test("continuous Braille lines", () => {
const chart = numeric()
chart.title = "Trend"
chart.x.title = "Time"
chart.y.title = "Amount"
expect(text(renderChart(chart, 32))).toMatchInlineSnapshot(`
"Trend
Amount
10│ ⢀⠔⠊
│ ⢀⠤⠊⠁
7.5│ ⢀⡠⠊⠁
│ ⢀⡠⠒⠁
5│ ⡠⠔⠁
│ ⣀⠔⠉
│ ⢀⠔⠊
2.5│ ⢀⠤⠊⠁
│ ⢀⡠⠒⠁
0┼⡠⠔⠁─────────────────────────
└────────────────────────────
0 2.5 5 7.5 10
Time"
`)
})
test("scatter and numbered series legend", () => {
const chart = numeric("point")
chart.points = [
{ x: 0, y: 0, series: 0 },
{ x: 10, y: 10, series: 0 },
{ x: 0, y: 10, series: 1 },
{ x: 10, y: 0, series: 1 },
]
chart.series = ["North", "South"]
expect(text(renderChart(chart, 32))).toMatchInlineSnapshot(`
" 10│⠛ ⠛
7.5│
5│
2.5│
0┼⣤──────────────────────────⣤
└────────────────────────────
0 2.5 5 7.5 10
1 █ North 2 █ South"
`)
})
})
test("bounded responsive layouts report actual display widths", () => {
for (const chart of [bars(), bars(false), numeric(), numeric("point")]) {
for (const width of [24, 25, 32, 40, 80, 120, 500]) check(renderChart(chart, width), Math.min(width, 120))
}
expect(renderChart(numeric(), 80).width).toBeGreaterThan(renderChart(numeric(), 24).width)
expect(dots(renderChart(numeric(), 80))).toBeGreaterThan(dots(renderChart(numeric(), 24)))
expect(renderChart(numeric(), 0)).toEqual(renderChart(numeric(), 24))
expect(renderChart(numeric(), NaN)).toEqual(renderChart(numeric(), 80))
})
test("negative bars are left of zero, positive bars right, zero has no block", () => {
const chart = bars()
chart.title = undefined
const layout = renderChart(chart, 40)
const negative = layout.rows[0]
const positive = layout.rows[2]
expect(negative.findIndex((cell) => cell.role === 0)).toBeLessThan(
negative.findIndex((cell) => cell.text.includes("\u2502")),
)
expect(positive.findIndex((cell) => cell.role === 2)).toBeGreaterThan(
positive.findIndex((cell) => cell.text.includes("\u2502")),
)
expect(layout.rows[1].some((cell) => typeof cell.role === "number")).toBe(false)
expect(text(layout).split("\n")[0]).toEndWith("-5")
expect(text(layout).split("\n")[2]).toEndWith("10")
chart.x.zero = false
expect(renderChart(chart, 40)).toEqual(layout)
})
test("vertical bars grow away from a shared zero baseline", () => {
const layout = renderChart(bars(false), 32)
const baseline = layout.rows.findIndex((row) =>
row.some((cell) => cell.role === "axis" && cell.text.startsWith("\u253c")),
)
expect(baseline).toBeGreaterThan(0)
expect(layout.rows.flat().some((cell) => cell.role === 1)).toBe(false)
layout.rows.forEach((row, index) => {
if (row.some((cell) => cell.role === 0)) expect(index).toBeGreaterThan(baseline)
if (row.some((cell) => cell.role === 2)) expect(index).toBeLessThan(baseline)
})
})
test("positive bar lengths track magnitude with fractional blocks", () => {
const chart = bars()
chart.points = chart.points.map((point, index) => ({ ...point, x: [2, 4, 0][index] }))
const layout = renderChart(chart, 40)
const length = (series: number) =>
layout.rows
.flat()
.filter((cell) => cell.role === series)
.flatMap((cell) => Array.from(cell.text))
.reduce(
(sum, character) => sum + ("\u258f\u258e\u258d\u258c\u258b\u258a\u2589\u2588".indexOf(character) + 1) / 8,
0,
)
expect(Math.abs(length(0) * 2 - length(1))).toBeLessThanOrEqual(0.125)
expect(length(2)).toBe(0)
})
test("sub-cell signed bars retain a fractional mark on the correct side of zero", () => {
for (const horizontal of [true, false]) {
for (const values of [
[-1, 1000],
[-1000, 1],
[-Number.MIN_VALUE, Number.MAX_VALUE],
]) {
const chart = bars(horizontal)
chart.categories = ["A", "B"]
chart.points = values.map((value, index) => ({
x: horizontal ? value : chart.categories![index],
y: horizontal ? chart.categories![index] : value,
series: index,
}))
const layout = renderChart(chart, 24)
check(layout, horizontal ? 48 : 24)
expect(layout.rows.flat().some((cell) => cell.role === 0)).toBe(true)
expect(layout.rows.flat().some((cell) => cell.role === 1)).toBe(true)
const baseline = layout.rows.findIndex((row) =>
row.some((cell) => cell.role === "axis" && cell.text.startsWith("\u253c")),
)
if (!horizontal) {
layout.rows.forEach((row, index) => {
if (row.some((cell) => cell.role === 0)) expect(index).toBeGreaterThan(baseline)
if (row.some((cell) => cell.role === 1)) expect(index).toBeLessThan(baseline)
})
}
}
}
})
test("bar endpoints and ticks use the same coordinates despite a small signed minimum", () => {
for (const horizontal of [true, false]) {
const chart = bars(horizontal)
chart.title = undefined
chart.x.title = ""
chart.y.title = ""
chart.categories = ["Loss", "Half", "Max"]
chart.points = [-0.1, 5, 10].map((value, index) => ({
x: horizontal ? value : chart.categories![index],
y: horizontal ? chart.categories![index] : value,
series: index,
}))
const layout = renderChart(chart, 80)
for (const [series, value] of [
[1, 5],
[2, 10],
]) {
if (horizontal) {
const row = layout.rows[series].flatMap((cell) => Array.from(cell.text, (text) => ({ text, role: cell.role })))
const tick = Array.from(text(layout).split("\n").at(-1)!.matchAll(/\S+/g)).find(
(match) => Number(match[0]) === value,
)!
expect(tick).toBeDefined()
expect(row.findLastIndex((cell) => cell.role === series)).toBe(tick.index + Math.floor(tick[0].length / 2))
}
if (!horizontal) {
const tick = layout.rows.findIndex((row) => row[0].role === "text" && row[0].text.trim() === String(value))
expect(tick).toBeGreaterThanOrEqual(0)
expect(layout.rows.findIndex((row) => row.some((cell) => cell.role === series))).toBe(tick)
}
}
}
})
test("horizontal data labels round-trip exactly, scrolling rather than losing precision", () => {
const chart = bars()
chart.title = undefined
const values = [1000001, 1000002, Number.MAX_VALUE, -Number.MIN_VALUE, 0.1234567890123456]
chart.categories = values.map((_, index) => `C${index}`)
chart.points = values.map((value, index) => ({ x: value, y: chart.categories![index], series: index }))
const layout = renderChart(chart, 24)
check(layout, 48)
expect(layout.width).toBeGreaterThan(24)
values.forEach((value, index) => {
const label = text(layout).split("\n")[index].trim().split(/\s+/).at(-1)!
expect(label).toBe(String(value))
expect(Number(label)).toBe(value)
})
})
test("fitted tick labels preserve their increments rather than merely being unique", () => {
for (const expected of [
[1001, 1000.75, 1000.5, 1000.25, 1000],
[10.01, 10.0075, 10.005, 10.0025, 10],
[0.2, 0.175, 0.15, 0.125, 0.1],
]) {
const layout = renderChart(numeric("point", [expected.at(-1)!, expected[0]]), 100)
const labels = layout.rows
.slice(0, 10)
.map((row) => (row[0].role === "text" ? row[0].text.trim() : ""))
.filter(Boolean)
expect(labels).toEqual(expected.map(String))
const x = text(layout).split("\n").at(-1)!.trim().split(/\s+/)
expect(x).toEqual(labels.toReversed())
labels.forEach((label, index) => expect(Number(label)).toBe(expected[index]))
}
})
test("bar categories keep parser ordering", () => {
const chart = bars()
chart.categories = ["Gain", "Loss", "Zero"]
const lines = text(renderChart(chart, 40)).split("\n")
expect(lines[1]).toStartWith("Gain")
expect(lines[2]).toStartWith("Loss")
expect(lines[3]).toStartWith("Zero")
})
test("crowded vertical categories get natural scrolling width without dropping bars", () => {
const chart = bars(false)
chart.categories = Array.from({ length: 40 }, (_, index) => `Category ${index}`)
chart.points = chart.categories.map((category, index) => ({ x: category, y: 1, series: index }))
const layout = renderChart(chart, 24)
expect(layout.width).toBeGreaterThanOrEqual(200)
check(layout, 220)
expect(
new Set(
layout.rows
.flat()
.filter((cell) => typeof cell.role === "number")
.map((cell) => cell.role),
).size,
).toBe(40)
expect(text(layout)).toContain("Cat\u2026")
})
test("constant domains and zero-only bars remain finite and visible", () => {
for (const value of [0, 7, -7, Number.MIN_VALUE, -Number.MIN_VALUE, Number.MAX_VALUE, -Number.MAX_VALUE]) {
for (const mark of ["line", "point"] as const) {
const layout = renderChart(numeric(mark, [value]), 24)
check(layout, 24)
expect(dots(layout)).toBe(mark === "point" ? 4 : 1)
}
}
for (const horizontal of [true, false]) {
const chart = bars(horizontal)
chart.points = chart.points.map((point) => (horizontal ? { ...point, x: 0 } : { ...point, y: 0 }))
const layout = renderChart(chart, 24)
check(layout, 24)
expect(layout.rows.flat().some((cell) => typeof cell.role === "number")).toBe(false)
}
})
test("extreme and closely spaced domains have distinct finite numeric tick labels", () => {
for (const values of [
[-Number.MAX_VALUE, Number.MAX_VALUE],
[Number.MIN_VALUE, Number.MIN_VALUE * 2],
[-Number.MIN_VALUE * 2, Number.MIN_VALUE],
[1e-307, 1.1e-307],
[1e300, 1.0000000000000002e300],
[-1e308, -1e308 + 1e292],
[1e308, 1.0000000000000004e308],
]) {
for (const mark of ["line", "point"] as const) {
const layout = renderChart(numeric(mark, values), 24)
check(layout, 24)
expect(dots(layout)).toBeGreaterThan(0)
const labels = layout.rows
.filter((row) => row.some((cell) => cell.role === "axis" && /[\u2502\u253c]/.test(cell.text)))
.map((row) => (row[0].role === "text" ? row[0].text.trim() : ""))
.filter(Boolean)
expect(labels.length).toBeGreaterThanOrEqual(2)
expect(new Set(labels).size).toBe(labels.length)
expect(labels.every((label) => Number.isFinite(Number(label)))).toBe(true)
}
}
})
test("zero false fits a positive numeric domain, zero true includes the origin", () => {
const chart = numeric("point", [90, 100])
const fitted = renderChart(chart, 40)
chart.x.zero = true
chart.y.zero = true
const origin = renderChart(chart, 40)
expect(origin).not.toEqual(fitted)
expect(text(origin)).toMatch(/\n\s*0\u253c/)
expect(text(fitted)).not.toMatch(/\n\s*0\u253c/)
})
test("empty data and nonfinite numeric inputs cannot corrupt the layout", () => {
for (const mark of ["line", "point"] as const) {
check(renderChart(numeric(mark, []), 24), 24)
check(renderChart(numeric(mark, [NaN, Infinity, -Infinity, 0]), 24), 24)
}
})
test("line sorting is numeric within each series and never mutates its input", () => {
const chart = numeric("line", [20, -5, 100, 2])
const before = structuredClone(chart)
const layout = renderChart(chart, 40)
expect(chart).toEqual(before)
expect(layout).toEqual(renderChart({ ...chart, points: chart.points.toReversed() }, 40))
expect(layout).toEqual(renderChart(chart, 40))
})
test("a straight line is continuous at Braille pixel resolution", () => {
const chart = numeric("line", [0, 10])
const layout = renderChart(chart, 32)
const axis = layout.rows.find((row) => row.some((cell) => cell.text.startsWith("\u2514")))!
const columns = axis.find((cell) => cell.text.startsWith("\u2514"))!.text.length - 1
expect(dots(layout)).toBe(Math.max(columns * 2 - 1, 39) + 1)
expect(layout.rows.slice(0, 10).every((row) => row.some((cell) => cell.role === 0))).toBe(true)
})
test("scatter points have 2x2 dot clusters, and isolated series do not connect", () => {
expect(dots(renderChart(numeric("point", [0, 5, 10]), 32))).toBe(12)
const chart = numeric("line", [0, 10])
chart.points[1].series = 1
expect(dots(renderChart(chart, 32))).toBe(2)
})
test("overlapping series union their dots with deterministic lowest-series role", () => {
const chart = numeric("point", [0, 5, 10])
chart.points = [...chart.points.map((point) => ({ ...point, series: 1 })), ...chart.points]
const layout = renderChart(chart, 32)
expect(layout).toEqual(renderChart({ ...chart, points: chart.points.toReversed() }, 32))
expect(dots(layout)).toBe(12)
expect(
layout.rows
.flat()
.filter((cell) => typeof cell.role === "number")
.every((cell) => cell.role === 0),
).toBe(true)
})
test("Unicode labels use display width and keep CJK, combining and emoji graphemes intact", () => {
const chart = bars()
chart.title = "\u6771\u4eac e\u0301 \ud83d\udc69\u200d\ud83d\udcbb"
chart.categories = [
"\u6771\u4eac\u90fd\u9577\u3044",
"e\u0301e\u0301e\u0301e\u0301e\u0301e\u0301e\u0301",
"\ud83d\udc69\u200d\ud83d\udcbb\ud83d\udc69\u200d\ud83d\udcbb\ud83d\udc69\u200d\ud83d\udcbb\ud83d\udc69\u200d\ud83d\udcbb",
]
chart.points = chart.points.map((point, index) => ({ ...point, y: chart.categories![index] }))
const layout = renderChart(chart, 24)
check(layout, 24)
expect(text(layout)).toContain("\u6771\u4eac\u2026")
expect(text(layout)).toContain("e\u0301e\u0301e\u0301e\u0301e\u0301\u2026")
expect(text(layout)).toContain("\ud83d\udc69\u200d\ud83d\udcbb\ud83d\udc69\u200d\ud83d\udcbb\u2026")
expect(text(layout)).not.toContain("\u200d\u2026")
})
test("hidden titles and absent series labels add no metadata rows", () => {
const chart = numeric()
const layout = renderChart(chart, 32)
expect(layout.height).toBe(12)
chart.title = "Title"
chart.x.title = "Horizontal"
chart.y.title = "Vertical"
chart.series = ["First", "Second"]
const titled = renderChart(chart, 32)
expect(titled.height).toBe(16)
expect(text(titled)).toStartWith("Title\nVertical\n")
expect(text(titled)).toEndWith("Horizontal\n1 \u2588 First 2 \u2588 Second")
expect(
titled.rows
.at(-1)
?.filter((cell) => typeof cell.role === "number")
.map((cell) => cell.role),
).toEqual([0, 1])
})
test("labels cannot inject ANSI styles or terminal control characters", () => {
const chart = numeric()
chart.title = "\x1b[31mTitle\x1b[0m\nnext\tcolumn"
chart.series = ["\x1b[32mSeries\x1b[0m"]
const layout = renderChart(chart, 40)
check(layout, 40)
expect(text(layout)).toStartWith("Title next column")
expect(text(layout)).toEndWith("1 \u2588 Series")
})
+331
View File
@@ -0,0 +1,331 @@
import stringWidth from "string-width"
import type { Chart, ChartCell, ChartLayout } from "./types"
const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" })
const height = 10
export function renderChart(chart: Chart, width: number): ChartLayout {
const requested = Math.max(24, Math.min(120, Number.isFinite(width) ? Math.floor(width) : 80))
const points = chart.points.filter(
(point) =>
(chart.x.type !== "quantitative" || (typeof point.x === "number" && Number.isFinite(point.x))) &&
(chart.y.type !== "quantitative" || (typeof point.y === "number" && Number.isFinite(point.y))),
)
const horizontal = chart.mark === "bar" && chart.y.type !== "quantitative"
const x = scale(
points.map((point) => Number(point.x)),
chart.x.zero || chart.mark === "bar",
)
const y = scale(
points.map((point) => Number(point.y)),
chart.y.zero || chart.mark === "bar",
)
const categories = chart.categories ?? []
const labels = categories.map((category) => fit(category, Math.floor(requested / 4)))
const labelWidth = Math.max(...labels.map((label) => stringWidth(label)), 1)
const valueWidth = Math.max(...points.map((point) => String(point.x).length), 1)
const gutter = Math.max(...y.ticks.map((tick) => stringWidth(tick.label)), 1) + 1
// Natural widths scroll in Markdown: vertical categories retain five columns
// each; horizontal values stay exact with at least twelve plot columns.
const available = horizontal
? Math.max(requested, labelWidth + valueWidth + 14)
: chart.mark === "bar"
? Math.max(requested, gutter + categories.length * 5)
: requested
const rows: ChartCell[][] = []
if (chart.title) rows.push([{ text: fit(chart.title, available), role: "text" }])
if (chart.y.title) rows.push([{ text: fit(chart.y.title, available), role: "text" }])
if (horizontal) {
const values = new Map(points.map((point) => [String(point.y), point]))
const columns = available - labelWidth - valueWidth - 2
const baseline = x.at(0, columns)
categories.forEach((category, index) => {
const point = values.get(category)
const value = Number(point?.x ?? 0)
const cells: ChartCell[] = Array.from({ length: columns }, (_, column) => ({
text: column === baseline ? "\u2502" : " ",
role: "axis",
}))
const length = Math.abs(x.position(value, columns) - baseline)
for (let i = 0; i < Math.max(1, Math.abs(x.at(value, columns) - baseline)); i++) {
const column = baseline + (value < 0 ? -1 - i : 1 + i)
if (column < 0 || column >= columns || value === 0 || !point) continue
cells[column] = { text: block(Math.max(0.125, Math.min(1, length - i)), value < 0, false), role: point.series }
}
rows.push([
{
text: labels[index].padStart(labels[index].length + labelWidth - stringWidth(labels[index])) + " ",
role: "text",
},
...cells,
{ text: point ? " " + String(value) : "", role: "text" },
])
})
rows.push([
{ text: " ".repeat(labelWidth + 1), role: "text" },
{ text: "\u2500".repeat(baseline) + "\u253c" + "\u2500".repeat(columns - baseline - 1), role: "axis" },
])
rows.push([{ text: " ".repeat(labelWidth + 1), role: "text" }, ...ticks(x, columns)])
} else {
const columns = available - gutter
const grid: ChartCell[][] = Array.from({ length: height }, () =>
Array.from({ length: columns }, () => ({ text: " ", role: "axis" })),
)
const vertical = chart.mark === "bar"
const rowAt = (value: number) =>
vertical
? Math.round(height - 1 - y.position(value, height))
: Math.floor((height * 4 - 1 - y.at(value, height * 4)) / 4)
if (y.contains(0)) grid[rowAt(0)].forEach((cell) => (cell.text = "\u2500"))
if (vertical) {
const slot = columns / Math.max(1, categories.length)
const values = new Map(points.map((point) => [String(point.x), point]))
const baseline = rowAt(0)
categories.forEach((category, index) => {
const point = values.get(category)
const value = Number(point?.y ?? 0)
const length = Math.abs(height - 1 - y.position(value, height) - baseline)
const barWidth = Math.min(6, Math.max(1, Math.floor(slot) - 2))
const start = Math.floor((index + 0.5) * slot - barWidth / 2)
for (let i = 0; i < Math.max(1, Math.abs(rowAt(value) - baseline)); i++) {
const row = baseline + (value < 0 ? 1 + i : -1 - i)
if (row < 0 || row >= height || value === 0 || !point) continue
for (let column = start; column < start + barWidth; column++) {
grid[row][column] = {
text: block(Math.max(0.125, Math.min(1, length - i)), value < 0, true),
role: point.series,
}
}
}
})
} else {
if (x.contains(0)) {
const column = Math.floor(x.at(0, columns * 2) / 2)
if (column > 0 && column < columns - 1)
grid.forEach((row) => (row[column].text = row[column].text === "\u2500" ? "\u253c" : "\u2502"))
}
const pixels = Array.from({ length: height }, () =>
Array.from({ length: columns }, () => ({ bits: 0, series: 0 })),
)
const series = [...new Set(points.map((point) => point.series))].sort((a, b) => a - b)
series.forEach((series) => {
const ordered = points.filter((point) => point.series === series).toSorted((a, b) => Number(a.x) - Number(b.x))
ordered.forEach((point, index) => {
const previous = chart.mark === "line" && index > 0 ? ordered[index - 1] : point
const startX = x.at(Number(previous.x), columns * 2)
const startY = height * 4 - 1 - y.at(Number(previous.y), height * 4)
const endX = x.at(Number(point.x), columns * 2)
const endY = height * 4 - 1 - y.at(Number(point.y), height * 4)
const steps = Math.max(Math.abs(endX - startX), Math.abs(endY - startY))
const size = chart.mark === "point" ? 2 : 1
for (let i = 0; i <= steps; i++) {
for (let dx = 0; dx < size; dx++) {
for (let dy = 0; dy < size; dy++) {
const px =
Math.min(columns * 2 - size, Math.round(startX + ((endX - startX) * i) / Math.max(1, steps))) + dx
const py =
Math.min(height * 4 - size, Math.round(startY + ((endY - startY) * i) / Math.max(1, steps))) + dy
const cell = pixels[Math.floor(py / 4)][Math.floor(px / 2)]
// Braille can only have one foreground: union the dots and let the
// lowest series index own the cell, independent of input order.
cell.series = cell.bits === 0 ? series : Math.min(cell.series, series)
cell.bits |= [
[1, 2, 4, 64],
[8, 16, 32, 128],
][px % 2][py % 4]
}
}
}
})
})
pixels.forEach((row, r) =>
row.forEach((cell, c) => {
if (cell.bits) grid[r][c] = { text: String.fromCodePoint(0x2800 + cell.bits), role: cell.series }
}),
)
}
const labels = new Map(y.ticks.map((tick) => [rowAt(tick.value), tick.label]))
grid.forEach((row, index) => {
rows.push([
{ text: (labels.get(index) ?? "").padStart(gutter - 1), role: "text" },
{ text: y.contains(0) && index === rowAt(0) ? "\u253c" : "\u2502", role: "axis" },
...row,
])
})
rows.push([
{ text: " ".repeat(gutter - 1), role: "text" },
{ text: "\u2514" + "\u2500".repeat(columns), role: "axis" },
])
if (vertical) {
const slot = columns / Math.max(1, categories.length)
const row: ChartCell[] = [{ text: " ".repeat(gutter), role: "text" }]
categories.forEach((category, index) => {
const size = Math.floor((index + 1) * slot) - Math.floor(index * slot)
// Ellipsis is grapheme-safe; full category strings remain in the spec.
const label = fit(category, size - 1)
const left = Math.floor((size - stringWidth(label)) / 2)
row.push({ text: " ".repeat(left) + label + " ".repeat(size - left - stringWidth(label)), role: "text" })
})
rows.push(row)
} else rows.push([{ text: " ".repeat(gutter), role: "text" }, ...ticks(x, columns)])
}
if (chart.x.title) rows.push([{ text: fit(chart.x.title, available), role: "text" }])
;(
[
["x", x],
["y", y],
] as const
).forEach(([name, axis]) => {
if (!axis.offset || (name === "x" ? chart.x : chart.y).type !== "quantitative") return
rows.push([{ text: name + " offset", role: "text" }], [{ text: number(axis.offset, 17), role: "text" }])
})
chart.series.forEach((label, index) => {
if (!label) return
const mark = `${index + 1} \u2588 `
const entry: ChartCell[] = [
{ text: mark, role: index },
{ text: fit(label, available - mark.length), role: "text" },
]
const last = rows.at(-1)
if (
index > 0 &&
chart.series[index - 1] &&
last &&
stringWidth(last.map((cell) => cell.text).join("")) + 2 + stringWidth(entry.map((cell) => cell.text).join("")) <=
available
) {
last.push({ text: " ", role: "text" }, ...entry)
return
}
rows.push(entry)
})
const compact = rows.map((row) => {
const result: ChartCell[] = []
row.forEach((cell) => {
const last = result.at(-1)
if (last?.role === cell.role) last.text += cell.text
else if (cell.text) result.push({ ...cell })
})
while (result.length) {
const last = result.at(-1)!
last.text = last.text.trimEnd()
if (last.text) break
result.pop()
}
return result
})
return {
rows: compact,
width: Math.max(0, ...compact.map((row) => stringWidth(row.map((cell) => cell.text).join("")))),
height: compact.length,
}
}
function fit(value: string, width: number) {
const text = value.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "").replace(/[\x00-\x1f\x7f-\x9f]/g, " ")
if (width <= 0) return ""
if (stringWidth(text) <= width) return text
let result = ""
for (const part of segmenter.segment(text)) {
if (stringWidth(result + part.segment) > width - 1) break
result += part.segment
}
return result + "\u2026"
}
function number(value: number, precision: number) {
if (!value) return "0"
if (Math.abs(value) >= 1e5 || Math.abs(value) < 0.001) {
const parts = value.toExponential(precision - 1).split("e")
return Number(parts[0]) + "e" + Number(parts[1])
}
return Number(value.toPrecision(precision)).toString()
}
function scale(input: number[], zero: boolean) {
const values = input.filter(Number.isFinite)
const minimum = Math.min(...values, ...(zero || !values.length ? [0] : []))
const maximum = Math.max(...values, ...(zero || !values.length ? [0] : []))
// Normalize before subtracting: a finite pair such as [-MAX_VALUE, MAX_VALUE]
// has an infinite raw span. Normalization also protects subnormal domains.
const factor = Math.max(Math.abs(minimum), Math.abs(maximum)) || 1
const padding = minimum === maximum ? (minimum === 0 ? 1 : Math.max(0.1, Number.MIN_VALUE / factor)) : 0
const low = minimum === 0 && zero ? 0 : Math.max(-Number.MAX_VALUE / factor, minimum / factor - padding)
const high = Math.min(Number.MAX_VALUE / factor, maximum / factor + padding)
const rough = (high - low) / 4
const power = Math.max(Number.MIN_VALUE, 10 ** Math.floor(Math.log10(rough) + Math.log10(factor)))
const step =
[1, 2, 2.5, 5, 10]
.map((multiple) => (power * multiple) / factor)
.find((value) => value >= rough * (1 - Number.EPSILON * 4)) ?? rough
// Expand the domain, not the zero coordinate, so ticks and marks share one
// mapping. Preserve a tiny sign even when normalization underflows to zero.
const from = zero
? Math.max(-Number.MAX_VALUE / factor, Math.floor(low / step) * step || (minimum < 0 ? -step : 0))
: low
const to = zero
? Math.min(Number.MAX_VALUE / factor, Math.ceil(high / step) * step || (maximum > 0 ? step : 0))
: high
const span = to - from
const start = Math.ceil(from / step)
const candidates = Array.from({ length: 8 }, (_, index) => (start + index) * step)
.filter((value) => value >= from && value <= to)
.map((value) => value * factor)
.filter(Number.isFinite)
const unique = [...new Set(candidates)]
const ticks = unique.length > 1 ? unique : [...new Set([from * factor, to * factor])].filter(Number.isFinite)
const offset = span < Math.max(Math.abs(from), Math.abs(to)) * 0.0001 ? minimum : 0
const precision =
Array.from({ length: 15 }, (_, index) => index + 3).find(
(digits) =>
new Set(ticks.map((tick) => number(tick - offset, digits))).size === ticks.length &&
ticks.every(
(tick) =>
Math.abs(Number(number(tick - offset, digits)) + offset - tick) <=
Math.max(Number.MIN_VALUE, Math.abs(tick) * Number.EPSILON * 4),
),
) ?? 17
const position = (value: number, size: number) =>
Math.max(0, Math.min(1, (value / factor - from) / span)) * (size - 1)
return {
offset,
ticks: ticks.map((value) => ({ value, label: number(value - offset, precision) })),
position,
at: (value: number, size: number) => Math.round(position(value, size)),
contains: (value: number) => value / factor >= from && value / factor <= to,
}
}
function ticks(axis: ReturnType<typeof scale>, columns: number): ChartCell[] {
const row = Array.from({ length: columns }, () => " ")
let end = -1
axis.ticks.forEach((tick) => {
const start = Math.max(
0,
Math.min(columns - tick.label.length, axis.at(tick.value, columns) - Math.floor(tick.label.length / 2)),
)
if (start <= end || start + tick.label.length > columns) return
tick.label.split("").forEach((character, index) => (row[start + index] = character))
end = start + tick.label.length
})
return [{ text: row.join(""), role: "text" }]
}
function block(amount: number, negative: boolean, vertical: boolean) {
if (amount >= 1) return "\u2588"
if (negative)
return amount < 0.3125
? vertical
? "\u2594"
: "\u2595"
: amount < 0.75
? vertical
? "\u2580"
: "\u2590"
: "\u2588"
return (
vertical ? "\u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588" : "\u258f\u258e\u258d\u258c\u258b\u258a\u2589\u2588"
)[Math.max(0, Math.min(7, Math.round(amount * 8) - 1))]
}
+468
View File
@@ -0,0 +1,468 @@
import { describe, expect, test } from "bun:test"
import { parseChart } from "./spec"
function bar() {
return {
data: {
values: [
{ category: "B", amount: 2 },
{ category: "A", amount: -3 },
],
},
mark: "bar",
encoding: {
x: { field: "category", type: "nominal" },
y: { field: "amount", type: "quantitative" },
},
}
}
function scatter() {
return {
data: {
values: [
{ x: 3, y: 4 },
{ x: -1, y: 2 },
],
},
mark: "point",
encoding: {
x: { field: "x", type: "quantitative" },
y: { field: "y", type: "quantitative" },
},
}
}
function parse(value: unknown) {
return parseChart(JSON.stringify(value))
}
describe("parseChart", () => {
test("parses vertical bars with sorted categories and unchanged data order", () => {
expect(parse(bar())).toEqual({
mark: "bar",
x: { field: "category", title: "category", type: "nominal", zero: false },
y: { field: "amount", title: "amount", type: "quantitative", zero: true },
points: [
{ x: "B", y: 2, series: 0 },
{ x: "A", y: -3, series: 0 },
],
series: [""],
categories: ["A", "B"],
})
})
test("parses horizontal ordinal bars and supported metadata", () => {
const spec = bar()
expect(
parse({
...spec,
$schema: "https://vega.github.io/schema/vega-lite/v6.json",
description: "A chart",
title: "Amounts",
mark: { type: "bar" },
encoding: { x: spec.encoding.y, y: { ...spec.encoding.x, type: "ordinal" } },
}),
).toMatchObject({
mark: "bar",
title: "Amounts",
x: { type: "quantitative", zero: true },
y: { type: "ordinal", zero: false },
points: [
{ x: 2, y: "B", series: 0 },
{ x: -3, y: "A", series: 0 },
],
categories: ["A", "B"],
})
})
test.each(["line", "point", "circle"])("parses %s with two quantitative axes", (mark) => {
const spec = scatter()
expect(
parse({
...spec,
mark: { type: mark },
encoding: { ...spec.encoding, x: { ...spec.encoding.x, scale: { zero: false } } },
}),
).toEqual({
mark: mark === "circle" ? "point" : mark,
x: { field: "x", title: "x", type: "quantitative", zero: false },
y: { field: "y", title: "y", type: "quantitative", zero: true },
points: [
{ x: 3, y: 4, series: 0 },
{ x: -1, y: 2, series: 0 },
],
series: [""],
})
})
test.each([
[{ title: "Encoding title" }, "Encoding title"],
[{ title: null }, ""],
[{ axis: { title: "Axis title" }, title: "Ignored" }, "Axis title"],
[{ axis: { title: null }, title: "Ignored" }, ""],
[{ axis: {} }, "x"],
[{ title: "" }, ""],
])("resolves axis title %j", (options, title) => {
const spec = scatter()
expect(parse({ ...spec, encoding: { ...spec.encoding, x: { ...spec.encoding.x, ...options } } })?.x.title).toBe(
title,
)
})
test.each([
[undefined, [2, 10, 30]],
["ascending", [2, 10, 30]],
["descending", [30, 10, 2]],
[null, [10, 2, 30]],
])("sorts numeric categories with sort %j before stringifying", (sort, expected) => {
const spec = bar()
expect(
parse({
...spec,
data: { values: [10, 2, 30].map((category) => ({ category, amount: 1 })) },
encoding: { ...spec.encoding, x: { ...spec.encoding.x, sort } },
})?.categories,
).toEqual(expected.map(String))
})
test("sorts string categories lexically, not numerically", () => {
expect(
parse({ ...bar(), data: { values: ["2", "10"].map((category) => ({ category, amount: 1 })) } })?.categories,
).toEqual(["10", "2"])
})
test.each(["nominal", "ordinal"])("assigns stable sorted %s series without reordering points", (type) => {
const spec = scatter()
expect(
parse({
...spec,
data: { values: ["B", "A", "B"].map((group, x) => ({ x, y: x, group })) },
encoding: { ...spec.encoding, color: { field: "group", type } },
}),
).toMatchObject({
series: ["A", "B"],
points: [
{ x: 0, y: 0, series: 1 },
{ x: 1, y: 1, series: 0 },
{ x: 2, y: 2, series: 1 },
],
})
})
test("sorts numeric series numerically", () => {
const spec = scatter()
expect(
parse({
...spec,
data: { values: [10, 2, 10].map((group, x) => ({ x, y: x, group })) },
encoding: { ...spec.encoding, color: { field: "group", type: "nominal" } },
}),
).toMatchObject({ series: ["2", "10"], points: [{ series: 1 }, { series: 0 }, { series: 1 }] })
})
test.each(["transform", "layer", "config", "width", "height", "params", "resolve", "facet", "projection"])(
"rejects unsupported top-level %s",
(key) => expect(parse({ ...bar(), [key]: {} })).toBeUndefined(),
)
test.each(["url", "format", "name"])("rejects unsupported data.%s even with inline values", (key) => {
const spec = bar()
expect(parse({ ...spec, data: { ...spec.data, [key]: "ignored" } })).toBeUndefined()
})
test.each(["interpolate", "point", "orient", "color", "opacity", "size", "tooltip", "clip"])(
"rejects unsupported mark.%s",
(key) => expect(parse({ ...bar(), mark: { type: "bar", [key]: true } })).toBeUndefined(),
)
test.each(["size", "shape", "tooltip", "order", "detail", "xOffset", "yOffset", "x2", "row"])(
"rejects unsupported encoding.%s",
(key) => {
const spec = bar()
expect(parse({ ...spec, encoding: { ...spec.encoding, [key]: {} } })).toBeUndefined()
},
)
test.each([
{ aggregate: "sum" },
{ bin: true },
{ timeUnit: "year" },
{ stack: null },
{ value: 2 },
{ datum: 2 },
{ condition: {} },
{ format: ".2f" },
{ axis: null },
{ axis: false },
{ axis: { title: "OK", grid: false } },
{ axis: { title: 4 } },
{ title: {} },
{ title: ["two", "lines"] },
{ scale: null },
{ scale: { zero: false, type: "log" } },
{ scale: { domain: [0, 1] } },
{ scale: { nice: false } },
{ scale: { zero: 0 } },
{ sort: null },
{ type: "temporal" },
{ type: "Q" },
])("rejects unsupported quantitative axis options %j", (options) => {
const spec = scatter()
expect(parse({ ...spec, encoding: { ...spec.encoding, x: { ...spec.encoding.x, ...options } } })).toBeUndefined()
})
test.each([{ scale: {} }, { sort: "-x" }, { sort: ["B", "A"] }, { sort: { field: "amount" } }])(
"rejects unsupported categorical axis options %j",
(options) => {
const spec = bar()
expect(parse({ ...spec, encoding: { ...spec.encoding, x: { ...spec.encoding.x, ...options } } })).toBeUndefined()
},
)
test.each([
{ title: "Legend" },
{ title: null },
{ legend: null },
{ scale: {} },
{ sort: null },
{ aggregate: "count" },
{ condition: {} },
{ type: "quantitative" },
{ value: "red" },
])("rejects unsupported color options %j", (options) => {
const spec = scatter()
expect(
parse({
...spec,
encoding: { ...spec.encoding, color: { field: "x", type: "nominal", ...options } },
}),
).toBeUndefined()
})
test.each(["a.b", "a[0]", "a]", "a\\b", "a\\.b"])("rejects Vega field paths %j on every channel", (field) => {
const spec = scatter()
;["x", "y", "color"].forEach((channel) => {
expect(
parse({
...spec,
data: { values: [{ x: 1, y: 2, [field]: 3 }] },
encoding: { ...spec.encoding, [channel]: { field, type: channel === "color" ? "nominal" : "quantitative" } },
}),
).toBeUndefined()
})
})
test.each(["line", "point", "circle"])("rejects categorical axes for %s", (mark) => {
expect(parse({ ...bar(), mark })).toBeUndefined()
})
test("rejects bars without exactly one categorical axis or with zero excluded", () => {
const spec = bar()
expect(parse({ ...scatter(), mark: "bar" })).toBeUndefined()
expect(parse({ ...spec, encoding: { x: spec.encoding.x, y: spec.encoding.x } })).toBeUndefined()
expect(
parse({ ...spec, encoding: { ...spec.encoding, y: { ...spec.encoding.y, scale: { zero: false } } } }),
).toBeUndefined()
})
test.each([null, "2", "", true, {}, [], undefined].map((x) => ({ x })))(
"does not coerce quantitative values %j",
(input) => {
expect(
parse({
...scatter(),
data: {
values: [
{ x: 1, y: 1 },
{ x: input.x, y: 2 },
],
},
}),
).toBeUndefined()
expect(parse({ ...scatter(), data: { values: [{ x: 1, y: input.x }] } })).toBeUndefined()
},
)
test.each([null, true, {}, [], undefined].map((category) => ({ category })))(
"rejects invalid categorical and color values %j",
(input) => {
expect(parse({ ...bar(), data: { values: [{ category: input.category, amount: 1 }] } })).toBeUndefined()
const spec = scatter()
expect(
parse({
...spec,
data: { values: [{ x: 1, y: 2, category: input.category }] },
encoding: { ...spec.encoding, color: { field: "category", type: "nominal" } },
}),
).toBeUndefined()
},
)
test.each([Number.MAX_VALUE, -Number.MAX_VALUE, Number.MIN_VALUE, -Number.MIN_VALUE, 0, -0])(
"accepts finite numeric extreme %s",
(x) => expect(parse({ ...scatter(), data: { values: [{ x, y: x }] } })?.points[0].x).toBe(x === 0 ? 0 : x),
)
test.each(["1e999", "-1e999"])("rejects JSON numeric overflow %s in every utilized value", (overflow) => {
const spec = scatter()
expect(parseChart(JSON.stringify(spec).replace('"x":3', `"x":${overflow}`))).toBeUndefined()
expect(parseChart(JSON.stringify(bar()).replace('"B"', overflow))).toBeUndefined()
expect(
parseChart(
JSON.stringify({
...spec,
data: { values: [{ x: 1, y: 2, group: "overflow" }] },
encoding: { ...spec.encoding, color: { field: "group", type: "ordinal" } },
}).replace('"overflow"', overflow),
),
).toBeUndefined()
})
test("validates only utilized row fields", () => {
expect(
parse({
...scatter(),
data: { values: [{ x: 1, y: 2, unused: { nested: null }, ignored: "\u001b".repeat(121) }] },
}),
).toBeDefined()
})
test.each([null, [], "row", 2].map((row) => ({ row })))("rejects non-record row %j", (input) => {
expect(parse({ ...scatter(), data: { values: [input.row] } })).toBeUndefined()
})
test.each(
[
null,
[],
1,
"chart",
{},
{ ...bar(), data: null },
{ ...bar(), data: [] },
{ ...bar(), data: { values: null } },
{ ...bar(), data: { values: {} } },
{ ...bar(), data: { values: [] } },
{ ...bar(), mark: "area" },
{ ...bar(), mark: null },
{ ...bar(), mark: {} },
{ ...bar(), encoding: null },
{ ...bar(), encoding: {} },
{ ...bar(), encoding: { ...bar().encoding, x: null } },
{ ...bar(), encoding: { ...bar().encoding, color: null } },
{ ...bar(), title: null },
{ ...bar(), title: {} },
{ ...bar(), description: null },
{ ...bar(), $schema: 6 },
].map((spec) => ({ spec })),
)("rejects malformed spec %j", (input) => expect(parse(input.spec)).toBeUndefined())
test("returns undefined for every incomplete streaming prefix", () => {
const source = JSON.stringify(bar())
Array.from({ length: source.length }, (_, index) => {
expect(parseChart(source.slice(0, index))).toBeUndefined()
})
expect(parseChart(source)).toBeDefined()
expect(parseChart(source + " trailing")).toBeUndefined()
expect(parseChart("```vega-lite\n" + source + "\n```")).toBeUndefined()
})
test("enforces source size at exactly 256 Ki characters", () => {
const source = JSON.stringify(bar())
expect(parseChart(source.padEnd(256 * 1024, " "))).toBeDefined()
expect(parseChart(source.padEnd(256 * 1024 + 1, " "))).toBeUndefined()
})
test.each([1, 2000, 2001])("enforces row bounds at %s rows", (count) => {
const chart = parse({ ...scatter(), data: { values: Array.from({ length: count }, (_, x) => ({ x, y: x })) } })
expect(chart?.points.length).toBe(count <= 2000 ? count : undefined)
})
test.each([40, 41])("enforces category bounds at %s categories", (count) => {
const chart = parse({
...bar(),
data: { values: Array.from({ length: count }, (_, category) => ({ category, amount: 1 })) },
})
expect(chart?.categories?.length).toBe(count <= 40 ? count : undefined)
})
test.each([8, 9])("enforces series bounds at %s series", (count) => {
const spec = scatter()
const chart = parse({
...spec,
data: { values: Array.from({ length: count }, (_, x) => ({ x, y: x })) },
encoding: { ...spec.encoding, color: { field: "x", type: "nominal" } },
})
expect(chart?.series.length).toBe(count <= 8 ? count : undefined)
})
test.each([{ categories: ["A", "A"] }, { categories: [1, 1] }, { categories: [1, "1"] }, { categories: [0, -0] }])(
"rejects duplicate or colliding bar categories %j",
(input) => {
const spec = bar()
expect(
parse({
...spec,
data: { values: input.categories.map((category, group) => ({ category, amount: 1, group })) },
encoding: { ...spec.encoding, color: { field: "group", type: "nominal" } },
}),
).toBeUndefined()
},
)
test("rejects series identities that collide after stringification", () => {
const spec = scatter()
expect(
parse({
...spec,
data: { values: [1, "1"].map((group) => ({ x: 1, y: 2, group })) },
encoding: { ...spec.encoding, color: { field: "group", type: "ordinal" } },
}),
).toBeUndefined()
})
test("accepts ordinary Unicode and counts astral characters as single codepoints", () => {
const category = "\u{20000}".repeat(120)
expect(
parse({
...bar(),
title: "\u65e5\u672c\u8a9e",
data: { values: [{ category, amount: 1 }] },
})?.categories,
).toEqual([category])
expect(parse({ ...bar(), data: { values: [{ category: category + "x", amount: 1 }] } })).toBeUndefined()
})
test.each([
"x".repeat(121),
"\u0000",
"\n",
"\t",
"\u001b",
"\u007f",
"\u0085",
"\u061c",
"\u200e",
"\u200f",
"\u202e",
"\u2066",
"\u2069",
])("rejects unsafe or oversized rendered labels %j", (title) => {
const spec = bar()
expect(parse({ ...spec, title })).toBeUndefined()
expect(parse({ ...spec, data: { values: [{ category: title, amount: 1 }] } })).toBeUndefined()
expect(parse({ ...spec, encoding: { ...spec.encoding, x: { ...spec.encoding.x, title } } })).toBeUndefined()
expect(
parse({ ...spec, encoding: { ...spec.encoding, x: { ...spec.encoding.x, axis: { title } } } }),
).toBeUndefined()
expect(parse({ ...spec, encoding: { ...spec.encoding, x: { ...spec.encoding.x, field: title } } })).toBeUndefined()
expect(
parse({
...spec,
data: { values: [{ category: "A", amount: 1, group: title }] },
encoding: { ...spec.encoding, color: { field: "group", type: "nominal" } },
}),
).toBeUndefined()
})
})
+127
View File
@@ -0,0 +1,127 @@
import type { Axis, Chart } from "./types"
export function parseChart(source: string): Chart | undefined {
if (source.length > 256 * 1024) return undefined
try {
const spec: unknown = JSON.parse(source)
if (!record(spec, ["$schema", "description", "title", "data", "mark", "encoding"])) return undefined
if (spec.$schema !== undefined && typeof spec.$schema !== "string") return undefined
if (spec.description !== undefined && typeof spec.description !== "string") return undefined
if (spec.title !== undefined && !label(spec.title)) return undefined
if (!record(spec.data, ["values"]) || !Array.isArray(spec.data.values)) return undefined
if (spec.data.values.length < 1 || spec.data.values.length > 2000) return undefined
const mark = record(spec.mark, ["type"]) ? spec.mark.type : spec.mark
if (mark !== "bar" && mark !== "line" && mark !== "point" && mark !== "circle") return undefined
if (!record(spec.encoding, ["x", "y", "color"])) return undefined
if (!record(spec.encoding.x) || !record(spec.encoding.y)) return undefined
const x = axis(spec.encoding.x)
const y = axis(spec.encoding.y)
if (!x || !y) return undefined
if (mark === "bar") {
if ((x.type === "quantitative") === (y.type === "quantitative")) return undefined
if (!(x.type === "quantitative" ? x.zero : y.zero)) return undefined
}
if (mark !== "bar" && (x.type !== "quantitative" || y.type !== "quantitative")) return undefined
const color = spec.encoding.color
if (
color !== undefined &&
(!record(color, ["field", "type"]) ||
!field(color.field) ||
(color.type !== "nominal" && color.type !== "ordinal"))
)
return undefined
const colorField = typeof color?.field === "string" ? color.field : undefined
const values = spec.data.values.map((row: unknown) => {
if (!record(row)) return undefined
const horizontal = row[x.field]
const vertical = row[y.field]
const series = colorField === undefined ? "" : row[colorField]
if (!datum(horizontal, x.type) || !datum(vertical, y.type) || !datum(series, "nominal")) return undefined
return { x: horizontal, y: vertical, series }
})
const rows = values.filter((row) => row !== undefined)
if (rows.length !== values.length) return undefined
const series = domain(rows.map((row) => row.series))
if (!series || series.length > 8) return undefined
const categories =
mark !== "bar"
? undefined
: domain(
rows.map((row) => (x.type === "quantitative" ? row.y : row.x)),
x.type === "quantitative" ? spec.encoding.y.sort : spec.encoding.x.sort,
)
if (mark === "bar" && (!categories || categories.length !== rows.length || categories.length > 40)) return undefined
return {
mark: mark === "circle" ? "point" : mark,
...(spec.title === undefined ? {} : { title: spec.title }),
x,
y,
points: rows.map((row) => ({ x: row.x, y: row.y, series: series.indexOf(String(row.series)) })),
series,
...(categories === undefined ? {} : { categories }),
}
} catch {
return undefined
}
}
function axis(value: Record<string, unknown>): Axis | undefined {
if (!field(value.field)) return undefined
if (value.type !== "quantitative" && value.type !== "nominal" && value.type !== "ordinal") return undefined
if (!record(value, ["field", "type", "title", "axis", value.type === "quantitative" ? "scale" : "sort"]))
return undefined
if (value.title !== undefined && value.title !== null && !label(value.title)) return undefined
if (
value.axis !== undefined &&
(!record(value.axis, ["title"]) ||
(value.axis.title !== undefined && value.axis.title !== null && !label(value.axis.title)))
)
return undefined
if (
value.scale !== undefined &&
(!record(value.scale, ["zero"]) || (value.scale.zero !== undefined && typeof value.scale.zero !== "boolean"))
)
return undefined
if (value.sort !== undefined && value.sort !== null && value.sort !== "ascending" && value.sort !== "descending")
return undefined
const title = value.axis?.title !== undefined ? value.axis.title : value.title
return {
field: value.field,
type: value.type,
title: typeof title === "string" ? title : title === null ? "" : value.field,
zero: value.type === "quantitative" && value.scale?.zero !== false,
}
}
function record(value: unknown, keys?: string[]): value is Record<string, unknown> {
return (
typeof value === "object" &&
value !== null &&
!Array.isArray(value) &&
(keys === undefined || Object.keys(value).every((key) => keys.includes(key)))
)
}
function label(value: unknown): value is string {
return typeof value === "string" && Array.from(value).length <= 120 && !/[\p{Cc}\p{Bidi_Control}]/u.test(value)
}
function field(value: unknown): value is string {
return label(value) && !/[.[\]\\]/u.test(value)
}
function datum(value: unknown, type: Axis["type"]): value is number | string {
return typeof value === "number" ? Number.isFinite(value) : type !== "quantitative" && label(value)
}
function domain(values: (number | string)[], sort: unknown = "ascending"): string[] | undefined {
const unique = [...new Set(values)]
// The renderer uses string labels, so distinct Vega values must not collapse onto one label.
if (new Set(unique.map(String)).size !== unique.length) return undefined
if (sort !== null) unique.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0) * (sort === "descending" ? -1 : 1))
return unique.map(String)
}
+27
View File
@@ -0,0 +1,27 @@
export type Axis = {
field: string
title: string
type: "quantitative" | "nominal" | "ordinal"
zero: boolean
}
export type Chart = {
mark: "bar" | "line" | "point"
title?: string
x: Axis
y: Axis
points: { x: number | string; y: number | string; series: number }[]
series: string[]
categories?: string[]
}
export type ChartCell = {
text: string
role: "text" | "axis" | number
}
export type ChartLayout = {
rows: ChartCell[][]
width: number
height: number
}
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@tsconfig/bun/tsconfig.json",
"compilerOptions": {
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"noUncheckedIndexedAccess": false
}
}
+4
View File
@@ -57,6 +57,10 @@
"dependsOn": ["^build"],
"outputs": []
},
"@opencode-ai/vega-lite#test": {
"dependsOn": ["^build"],
"outputs": []
},
"@opencode-ai/app#test": {
"dependsOn": ["^build"],
"outputs": []