mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-09 10:26:25 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7dff8e868 |
@@ -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"],
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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] : []),
|
||||
|
||||
@@ -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.
|
||||
@@ -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:"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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]),
|
||||
})),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -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")
|
||||
})
|
||||
@@ -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))]
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "@tsconfig/bun/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||
"noUncheckedIndexedAccess": false
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,10 @@
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": []
|
||||
},
|
||||
"@opencode-ai/vega-lite#test": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": []
|
||||
},
|
||||
"@opencode-ai/app#test": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": []
|
||||
|
||||
Reference in New Issue
Block a user