How to set up the project, run tests, add chart types, add table cell types, and submit changes.
- Bun (package manager and script runner)
- Node.js 18+ (runtime for TypeScript tooling)
cd openchart
bun installThat's it. Bun installs all workspace packages and links the internal dependencies.
bun run dev # Start the examples dev server (Ladle) at localhost:6006
bun run build # Build all packages in dependency order
bun run test # Run all tests
bun run typecheck # Type-check all packages
bun run lint # Lint all packagesThe dev server uses Ladle to render stories from examples/src/. Each story is a standalone React component that demonstrates a feature. Open it in a browser to see your changes live.
openchart/
packages/
core/ Types, theme, colors, a11y, locale (no DOM)
engine/ Headless compiler: spec in, layout out
vanilla/ DOM rendering: SVG charts, HTML tables
react/ React components wrapping vanilla
examples/ Ladle stories for visual development
docs/ Documentation
Dependency direction: core <- engine <- vanilla <- react. Never import upstream.
For a deeper look at how the packages fit together, see docs/architecture.md.
Chart types follow a registry pattern. Each chart registers itself when its module is imported. Use packages/engine/src/charts/bar/ as a template (it's the simplest one).
packages/engine/src/charts/<type>/
index.ts # Registry hook + exports
compute.ts # Mark computation
labels.ts # Label positioning
__tests__/
compute.test.ts # Tests
compute.ts exports a function that takes the normalized spec, resolved scales, chart area rect, and layout strategy. It returns an array of marks.
// packages/engine/src/charts/<type>/compute.ts
import type { Rect } from "@opendata-ai/openchart-core";
import type { LayoutStrategy } from "@opendata-ai/openchart-core";
import type { NormalizedChartSpec } from "../../compiler/types";
import type { ResolvedScales } from "../../layout/scales";
export function computeMyTypeMarks(
spec: NormalizedChartSpec,
scales: ResolvedScales,
chartArea: Rect,
strategy: LayoutStrategy,
): MyMark[] {
// Map data rows to positioned marks using the scales
// Return an array of marks with all positions and colors computed
}Look at charts/bar/compute.ts for a concrete example.
labels.ts exports a function that computes value labels for the marks.
// packages/engine/src/charts/<type>/labels.ts
import type { Rect, ResolvedLabel } from "@opendata-ai/openchart-core";
export function computeMyTypeLabels(
marks: MyMark[],
chartArea: Rect,
): ResolvedLabel[] {
// Compute label positions relative to marks
}index.ts composes compute + labels into a ChartRenderer and registers it:
// packages/engine/src/charts/<type>/index.ts
import type { Mark } from "@opendata-ai/openchart-core";
import { registerChartRenderer } from "../registry";
import type { ChartRenderer } from "../registry";
import { computeMyTypeMarks } from "./compute";
import { computeMyTypeLabels } from "./labels";
const myTypeRenderer: ChartRenderer = (spec, scales, chartArea, strategy) => {
const marks = computeMyTypeMarks(spec, scales, chartArea, strategy);
const labels = computeMyTypeLabels(marks, chartArea);
for (let i = 0; i < marks.length && i < labels.length; i++) {
marks[i].label = labels[i];
}
return marks as Mark[];
};
registerChartRenderer("<type>", myTypeRenderer);
export { computeMyTypeMarks } from "./compute";
export { computeMyTypeLabels } from "./labels";-
Add the import to
packages/engine/src/index.ts:import "./charts/<type>/index";
This import triggers self-registration at module load time.
-
Add the type string to the
ChartTypeunion inpackages/core/src/types/spec.ts:export type ChartType = 'line' | 'area' | 'bar' | ... | '<type>';
-
Add encoding rules in
packages/core/src/types/encoding.ts(which channels are required/optional for this type).
// packages/engine/src/charts/<type>/__tests__/compute.test.ts
import { describe, it, expect } from "vitest";
import { computeMyTypeMarks } from "../compute";
// Create fixture factory functions at the top
function createTestSpec() {
/* ... */
}
function createTestScales() {
/* ... */
}
describe("computeMyTypeMarks", () => {
it("returns marks for each data point", () => {
const marks = computeMyTypeMarks(spec, scales, chartArea, strategy);
expect(marks).toHaveLength(4);
});
});Create examples/src/charts/<type>.stories.tsx with at least one basic example.
Table cells use a discriminated union on the cellType field. Each cell type has a type definition in core, a computation step in engine, and a renderer in vanilla.
Add the interface to packages/core/src/types/layout.ts:
export interface MyFeatureTableCell extends TableCellBase {
cellType: "myFeature";
// Type-specific fields
computedWidth: number;
}Add the new type to the TableCell union in the same file.
If there's a user-facing config option, add it to packages/core/src/types/table.ts:
export interface MyFeatureColumnConfig {
someOption?: string;
maxValue?: number;
}Then add it as an optional field on ColumnConfig:
export interface ColumnConfig {
// ...existing fields
myFeature?: MyFeatureColumnConfig;
}Create packages/engine/src/tables/my-feature.ts that computes the type-specific data:
export function computeMyFeature(
values: unknown[],
config: MyFeatureColumnConfig,
): Map<number, MyFeatureData> {
// Compute per-row data for this visual feature
}Wire it into packages/engine/src/tables/compile-table.ts, following the existing precedence chain (sparkline > bar > heatmap > image > flag > categoryColors).
Add a render function to packages/vanilla/src/renderers/table-cells.ts:
export function renderMyFeatureCell(
cell: MyFeatureTableCell,
td: HTMLTableCellElement,
): void {
// Create DOM elements for the visual feature
}Add CSS classes (prefixed oc-table-myFeature) to the appropriate CSS partial in packages/core/src/styles/. Include both light and dark mode variants (dark mode goes in the .oc-dark class in dark.css).
- Framework: Vitest with workspace projects
- Location: Co-located
__tests__/directories next to the source file - File naming:
<source-file>.test.ts - Environment:
nodefor core and engine,happy-domfor vanilla and react
import { describe, it, expect } from "vitest";
// Fixture factories at the top of the file
function createTestSpec(): ChartSpec {
return {
type: "line",
data: [{ x: 1, y: 2 }],
encoding: {
x: { field: "x", type: "quantitative" },
y: { field: "y", type: "quantitative" },
},
};
}
describe("myFunction", () => {
it("does the thing", () => {
const result = myFunction(createTestSpec());
expect(result).toBeDefined();
});
});Run tests from the repo root:
bun run test # All tests
bun run test -- --watch # Watch mode
bun run test -- path/to/file # Single file- TypeScript strict mode: All packages use strict configuration. No
anytypes. - Named exports only: No default exports. Every export goes through the barrel
index.ts. - JSDoc: Required on all public types and exported functions. Brief description, then
@param/@returnswhere useful. Skip JSDoc on internal helpers unless the intent isn't obvious. - CSS class naming: Prefix with
oc-. Use BEM-ish nesting:oc-table-sort-btn,oc-tooltip-value. - CSS custom properties: Prefix with
--oc-. Dark mode overrides go in.oc-dark.
See CONVENTIONS.md for the full set of architectural decisions and patterns.
Releases are automated via GitHub Actions. The process:
- Update versions in all four
packages/*/package.jsonfiles to the new version number. - Update
CHANGELOG.mdwith the new version and date. Move items from[Unreleased]to the new version section. - Commit:
git commit -m "release: v0.x.0" - Tag:
git tag v0.x.0 - Push:
git push origin main --tags
The publish workflow runs automatically on version tags. It builds, tests, rewrites workspace:* dependencies to the real version, and publishes all four packages to npm in dependency order.
The NPM_TOKEN secret is a granular npm token scoped to the @opendata-ai org. Granular tokens expire every 90 days, so the token needs periodic rotation in the repo's GitHub Actions secrets.
- One concern per PR. A new chart type, a bug fix, or a refactor. Not all three at once.
- Tests are required for new features and bug fixes. The engine is particularly well-suited for unit testing since it's pure computation.
- Type-check passes: Run
bun run typecheckbefore opening a PR. - Stories help: If your change has a visual component, add or update a story in
examples/src/. - Keep the dependency direction: Core imports nothing. Engine imports core. Vanilla imports engine and core. React imports vanilla, engine, and core. Never the other way.