How to build apps on top of the viz library. Covers the interface contract: spec mutation, event handling, controlled components, responsive behavior, and export.
This guide shows React, Vue, and Svelte examples for key integration patterns. The vanilla JS API is covered in its own sections since the interface is different (imperative rather than declarative).
For field-by-field type details, see the spec reference. For a tutorial, see getting started.
Every visualization starts as a plain JavaScript object (a VizSpec). There's no class to instantiate, no builder chain to follow. Change the object, pass it to the renderer, get a new chart.
// This is the whole interface contract
const spec = { mark: 'line', data: [...], encoding: {...} };The library uses an immutable update pattern: every spec change triggers a full recompile. The engine is fast enough that this is the intended workflow. Don't try to mutate the layout directly.
Switch chart types by changing the mark field. If the encoding channels are compatible (see encoding by chart type), the same spec works across types.
import { useState } from "react";
import { Chart } from "@opendata-ai/openchart-react";
import type { ChartType, ChartSpec } from "@opendata-ai/openchart-core";
const CHART_TYPES: ChartType[] = ["line", "column", "area", "bar"];
function ChartWithTypeSwitcher({ baseSpec }: { baseSpec: ChartSpec }) {
const [chartType, setChartType] = useState<ChartType>(baseSpec.type);
// Immutable update: spread the spec, override type
const spec = { ...baseSpec, type: chartType };
return (
<div>
<select
value={chartType}
onChange={(e) => setChartType(e.target.value as ChartType)}
>
{CHART_TYPES.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
<div style={{ width: "100%", height: 400 }}>
<Chart spec={spec} />
</div>
</div>
);
}<script setup lang="ts">
import { ref, computed } from "vue";
import { Chart } from "@opendata-ai/openchart-vue";
import type { ChartType, ChartSpec } from "@opendata-ai/openchart-core";
const props = defineProps<{ baseSpec: ChartSpec }>();
const CHART_TYPES: ChartType[] = ["line", "column", "area", "bar"];
const chartType = ref<ChartType>(props.baseSpec.type);
const spec = computed(() => ({ ...props.baseSpec, type: chartType.value }));
</script>
<template>
<select v-model="chartType">
<option v-for="t in CHART_TYPES" :key="t" :value="t">{{ t }}</option>
</select>
<div style="width: 100%; height: 400px">
<Chart :spec="spec" />
</div>
</template><script lang="ts">
import { Chart } from "@opendata-ai/openchart-svelte";
import type { ChartType, ChartSpec } from "@opendata-ai/openchart-core";
let { baseSpec }: { baseSpec: ChartSpec } = $props();
const CHART_TYPES: ChartType[] = ["line", "column", "area", "bar"];
let chartType: ChartType = $state(baseSpec.type);
const spec = $derived({ ...baseSpec, type: chartType });
</script>
<select bind:value={chartType}>
{#each CHART_TYPES as t}
<option value={t}>{t}</option>
{/each}
</select>
<div style="width: 100%; height: 400px">
<Chart {spec} />
</div>All framework components detect spec changes and call chart.update(spec) internally. No manual lifecycle management needed.
Switching between line, area, and column usually works without encoding changes since they all use x (temporal/ordinal) and y (quantitative).
Switching to bar requires swapping axes: bar expects the category on y and the value on x. Switching to pie/donut requires dropping x and making color required.
If the encoding doesn't match the new type, the engine returns a validation error. Check encoding requirements in the encoding rules table.
import { useState } from "react";
import { Chart } from "@opendata-ai/openchart-react";
import type { DarkMode } from "@opendata-ai/openchart-core";
function ChartWithDarkMode({ spec }: { spec: ChartSpec }) {
const [darkMode, setDarkMode] = useState<DarkMode>("off");
return (
<div>
<select
value={darkMode}
onChange={(e) => setDarkMode(e.target.value as DarkMode)}
>
<option value="off">Light</option>
<option value="force">Dark</option>
<option value="auto">System</option>
</select>
<div style={{ width: "100%", height: 400 }}>
<Chart spec={spec} darkMode={darkMode} />
</div>
</div>
);
}<script setup lang="ts">
import { ref } from "vue";
import { Chart } from "@opendata-ai/openchart-vue";
import type { DarkMode } from "@opendata-ai/openchart-core";
const darkMode = ref<DarkMode>("off");
</script>
<template>
<select v-model="darkMode">
<option value="off">Light</option>
<option value="force">Dark</option>
<option value="auto">System</option>
</select>
<div style="width: 100%; height: 400px">
<Chart :spec="spec" :dark-mode="darkMode" />
</div>
</template><script lang="ts">
import { Chart } from "@opendata-ai/openchart-svelte";
import type { DarkMode } from "@opendata-ai/openchart-core";
let darkMode: DarkMode = $state("off");
</script>
<select bind:value={darkMode}>
<option value="off">Light</option>
<option value="force">Dark</option>
<option value="auto">System</option>
</select>
<div style="width: 100%; height: 400px">
<Chart {spec} {darkMode} />
</div>The darkMode prop is reactive. Changing it destroys and recreates the chart instance with the new dark mode setting. The engine handles all color adaptations automatically (background swap, text inversion, palette brightness adjustment).
import { createChart } from "@opendata-ai/openchart-vanilla";
const chart = createChart(container, spec, { darkMode: "auto" });In vanilla mode, darkMode: 'auto' checks window.matchMedia('(prefers-color-scheme: dark)') at mount time. To react to live system preference changes, destroy and recreate the chart, or use a framework wrapper which handles this for you.
For more control over dark mode, all three frameworks provide a useDarkMode hook/composable that reactively tracks system preference:
React:
import { useDarkMode } from "@opendata-ai/openchart-react";
function App() {
const isDark = useDarkMode("auto");
return <div className={isDark ? "dark-bg" : "light-bg"}>...</div>;
}Vue:
<script setup lang="ts">
import { useDarkMode } from "@opendata-ai/openchart-vue";
const isDark = useDarkMode("auto");
</script>Svelte:
<script lang="ts">
import { useDarkMode } from "@opendata-ai/openchart-svelte";
const isDark = useDarkMode("auto");
</script>useDarkMode subscribes to matchMedia change events, so the value updates automatically when the user switches their system preference.
Browse built-in themes: theme gallery
Pass theme as a prop. It's deep-merged onto DEFAULT_THEME during compilation:
<Chart spec={spec} theme={{ colors: { background: "#f5f0e8" } }} />VizThemeProvider sets a theme for all descendant components. Individual components can still override with their own theme prop.
React:
import { VizThemeProvider, Chart, DataTable } from "@opendata-ai/openchart-react";
<VizThemeProvider theme={brandTheme}>
<Chart spec={revenueSpec} />
<DataTable spec={tableSpec} />
</VizThemeProvider>Vue:
<script setup lang="ts">
import { VizThemeProvider, Chart, DataTable } from "@opendata-ai/openchart-vue";
</script>
<template>
<VizThemeProvider :theme="brandTheme">
<Chart :spec="revenueSpec" />
<DataTable :spec="tableSpec" />
</VizThemeProvider>
</template>Svelte:
<script lang="ts">
import { VizThemeProvider, Chart, DataTable } from "@opendata-ai/openchart-svelte";
</script>
<VizThemeProvider theme={brandTheme}>
<Chart spec={revenueSpec} />
<DataTable spec={tableSpec} />
</VizThemeProvider>Each framework uses its native context system (React context, Vue provide/inject, Svelte context). A theme prop on a component takes precedence over the context theme.
If you're building UI that should match the chart theme (custom legends, data cards, etc.), read the theme from context:
React: useVizTheme() and useVizDarkMode()
import { useVizTheme } from "@opendata-ai/openchart-react";
function DataCard({ label, value }: { label: string; value: string }) {
const theme = useVizTheme();
return (
<div style={{ fontFamily: theme?.fonts?.family, color: theme?.colors?.text }}>
<span>{label}</span>
<strong>{value}</strong>
</div>
);
}Vue: useVizTheme() and useVizDarkMode()
<script setup lang="ts">
import { useVizTheme } from "@opendata-ai/openchart-vue";
const theme = useVizTheme();
</script>Svelte: getVizTheme() and getVizDarkMode()
<script lang="ts">
import { getVizTheme } from "@opendata-ai/openchart-svelte";
const theme = getVizTheme();
</script>By default, all charts display a "tryOpenData.ai" watermark in the bottom-right corner. To hide it, set watermark: false on the spec:
const spec = {
mark: "bar",
data: [...],
encoding: { x: { field: "category" }, y: { field: "value" } },
watermark: false,
};To disable the watermark globally without modifying every spec, pass it as a mount option:
// Vanilla
createChart(container, spec, { watermark: false });
createTable(container, tableSpec, { watermark: false });
createGraph(container, graphSpec, { watermark: false });
createSankey(container, sankeySpec, { watermark: false });Spec-level watermark takes precedence over the mount option when both are set.
Specs are plain objects. The library expects immutable updates: create a new object, don't mutate in place.
function addAnnotation(spec: ChartSpec, annotation: Annotation): ChartSpec {
return {
...spec,
annotations: [...(spec.annotations ?? []), annotation],
};
}
function removeAnnotation(spec: ChartSpec, index: number): ChartSpec {
return {
...spec,
annotations: spec.annotations?.filter((_, i) => i !== index),
};
}function setAxisFormat(
spec: ChartSpec,
channel: "x" | "y",
format: string,
): ChartSpec {
const encoding = { ...spec.encoding };
const ch = encoding[channel];
if (ch) {
encoding[channel] = { ...ch, axis: { ...ch.axis, format } };
}
return { ...spec, encoding };
}function filterData(
spec: ChartSpec,
predicate: (row: DataRow) => boolean,
): ChartSpec {
return { ...spec, data: spec.data.filter(predicate) };
}The <Chart> component compares specs via JSON.stringify. If the serialized form hasn't changed, it skips the update. But avoiding unnecessary object allocations is still good practice for complex component trees.
React: Use useMemo to stabilize the spec reference:
const spec = useMemo(
() => ({
mark: "line" as const,
data: filteredData,
encoding: { x: xChannel, y: yChannel },
chrome: { title: chartTitle },
}),
[filteredData, xChannel, yChannel, chartTitle],
);Vue: Use computed for the same effect:
const spec = computed(() => ({
mark: "line" as const,
data: filteredData.value,
encoding: { x: xChannel.value, y: yChannel.value },
chrome: { title: chartTitle.value },
}));Svelte: Use $derived to avoid recalculating on every render:
const spec = $derived({
mark: "line" as const,
data: filteredData,
encoding: { x: xChannel, y: yChannel },
chrome: { title: chartTitle },
});Handle clicks on data marks (bars, points, lines, arcs). All three frameworks expose the same event handlers:
React:
<Chart
spec={spec}
onMarkClick={(event) => console.log(event.datum)}
onMarkHover={(event) => setHighlighted(event.datum)}
onMarkLeave={() => setHighlighted(null)}
/>Vue:
<Chart
:spec="spec"
@mark-click="(event) => console.log(event.datum)"
@mark-hover="(event) => highlighted = event.datum"
@mark-leave="() => highlighted = null"
/>Svelte:
<Chart
{spec}
onMarkClick={(event) => console.log(event.datum)}
onMarkHover={(event) => highlighted = event.datum}
onMarkLeave={() => highlighted = null}
/>The MarkEvent object contains:
import type { MarkEvent } from "@opendata-ai/openchart-core";
// event.datum - the data row for this mark
// event.series - series identifier (color field value)
// event.position - { x, y } relative to chart container
// event.event - the raw browser MouseEventimport { useState, useMemo } from "react";
import { Chart } from "@opendata-ai/openchart-react";
import type { MarkEvent, ChartSpec } from "@opendata-ai/openchart-core";
interface SalesData {
region: string;
city?: string;
revenue: number;
}
function DrillDownChart({ data }: { data: SalesData[] }) {
const [selectedRegion, setSelectedRegion] = useState<string | null>(null);
const spec = useMemo((): ChartSpec => {
if (selectedRegion) {
// Drilled-down view: cities within the selected region
const cityData = data.filter(
(d) => d.region === selectedRegion && d.city,
);
return {
mark: "bar",
data: cityData,
encoding: {
x: { field: "revenue", type: "quantitative" },
y: { field: "city", type: "nominal" },
},
chrome: {
title: `Revenue: ${selectedRegion}`,
subtitle: "Click a bar for details. Press back to return.",
},
};
}
// Top-level view: revenue by region
const regionData = data.filter((d) => !d.city);
return {
mark: "bar",
data: regionData,
encoding: {
x: { field: "revenue", type: "quantitative" },
y: { field: "region", type: "nominal" },
},
chrome: {
title: "Revenue by region",
subtitle: "Click a bar to drill down",
},
};
}, [data, selectedRegion]);
const handleClick = (event: MarkEvent) => {
if (!selectedRegion) {
setSelectedRegion(event.datum.region as string);
}
};
return (
<div>
{selectedRegion && (
<button onClick={() => setSelectedRegion(null)}>Back to regions</button>
)}
<div style={{ width: "100%", height: 400 }}>
<Chart spec={spec} onMarkClick={handleClick} />
</div>
</div>
);
}Legend entries are interactive by default. Clicking a legend swatch hides/shows the corresponding series. To react to these toggles:
<Chart
spec={spec}
onLegendToggle={(series, visible) => {
console.log(`${series} is now ${visible ? "visible" : "hidden"}`);
}}
/>The vanilla adapter handles the DOM toggling (setting display: none on matching marks). The callback is informational, for cases where you need to sync legend state with external UI.
<Chart
spec={spec}
onAnnotationClick={(annotation, event) => {
// Open a detail panel for the clicked annotation
if (annotation.type === "text") {
showDetailPanel(annotation.label);
}
}}
/>The table manages its own sort, search, and pagination state internally. You get callbacks when state changes but don't need to manage state yourself.
React:
import { DataTable } from "@opendata-ai/openchart-react";
<DataTable
spec={tableSpec}
onSortChange={(sort) => console.log("Sort changed:", sort)}
onSearchChange={(query) => console.log("Search:", query)}
onPageChange={(page) => console.log("Page:", page)}
/>;Vue:
<template>
<DataTable
:spec="tableSpec"
@sort-change="(sort) => console.log('Sort changed:', sort)"
@search-change="(query) => console.log('Search:', query)"
@page-change="(page) => console.log('Page:', page)"
/>
</template>Svelte:
<DataTable
spec={tableSpec}
onSortChange={(sort) => console.log("Sort changed:", sort)}
onSearchChange={(query) => console.log("Search:", query)}
onPageChange={(page) => console.log("Page:", page)}
/>Pass sort, search, and/or page props to take over state management. When any of these are provided, the table reads state from props instead of managing it internally.
React:
import { useState } from "react";
import { DataTable } from "@opendata-ai/openchart-react";
import type { SortState } from "@opendata-ai/openchart-core";
function ControlledTable({ spec }: { spec: TableSpec }) {
const [sort, setSort] = useState<SortState | null>(null);
const [search, setSearch] = useState("");
const [page, setPage] = useState(0);
return (
<div>
<input
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(0); // Reset to first page on search
}}
placeholder="Search..."
/>
<button onClick={() => setSort({ column: "name", direction: "asc" })}>
Sort by name
</button>
<button onClick={() => setSort(null)}>Clear sort</button>
<DataTable
spec={spec}
sort={sort}
search={search}
page={page}
onSortChange={setSort}
onSearchChange={setSearch}
onPageChange={setPage}
/>
</div>
);
}Vue:
<script setup lang="ts">
import { ref } from "vue";
import { DataTable } from "@opendata-ai/openchart-vue";
import type { SortState } from "@opendata-ai/openchart-core";
const sort = ref<SortState | null>(null);
const search = ref("");
const page = ref(0);
function onSearch(value: string) {
search.value = value;
page.value = 0; // Reset to first page on search
}
</script>
<template>
<input :value="search" @input="onSearch($event.target.value)" placeholder="Search..." />
<button @click="sort = { column: 'name', direction: 'asc' }">Sort by name</button>
<button @click="sort = null">Clear sort</button>
<DataTable
:spec="spec"
:sort="sort"
:search="search"
:page="page"
@sort-change="(s) => sort = s"
@search-change="(q) => search = q"
@page-change="(p) => page = p"
/>
</template>Svelte:
<script lang="ts">
import { DataTable } from "@opendata-ai/openchart-svelte";
import type { SortState } from "@opendata-ai/openchart-core";
let sort: SortState | null = $state(null);
let search = $state("");
let page = $state(0);
function onSearch(value: string) {
search = value;
page = 0; // Reset to first page on search
}
</script>
<input value={search} oninput={(e) => onSearch(e.target.value)} placeholder="Search..." />
<button onclick={() => sort = { column: "name", direction: "asc" }}>Sort by name</button>
<button onclick={() => sort = null}>Clear sort</button>
<DataTable
{spec}
{sort}
{search}
{page}
onSortChange={(s) => sort = s}
onSearchChange={(q) => search = q}
onPageChange={(p) => page = p}
/>For a quick way to get controlled state without wiring it all manually. Available in all three frameworks:
React:
import { DataTable, useTableState } from "@opendata-ai/openchart-react";
function TableWithManagedState({ spec }: { spec: TableSpec }) {
const { sort, search, page, setSort, setSearch, setPage, resetState } =
useTableState();
return (
<div>
<button onClick={resetState}>Reset</button>
<DataTable
spec={spec}
sort={sort}
search={search}
page={page}
onSortChange={setSort}
onSearchChange={setSearch}
onPageChange={setPage}
/>
</div>
);
}Vue:
<script setup lang="ts">
import { DataTable, useTableState } from "@opendata-ai/openchart-vue";
const { sort, search, page, setSort, setSearch, setPage, resetState } =
useTableState();
</script>
<template>
<button @click="resetState">Reset</button>
<DataTable
:spec="spec"
:sort="sort"
:search="search"
:page="page"
@sort-change="setSort"
@search-change="setSearch"
@page-change="setPage"
/>
</template>Svelte:
<script lang="ts">
import { DataTable, useTableState } from "@opendata-ai/openchart-svelte";
const { sort, search, page, setSort, setSearch, setPage, resetState } =
useTableState();
</script>
<button onclick={resetState}>Reset</button>
<DataTable
{spec}
sort={sort}
search={search}
page={page}
onSortChange={setSort}
onSearchChange={setSearch}
onPageChange={setPage}
/>All versions accept optional initial values:
const state = useTableState({
sort: { column: "name", direction: "asc" },
search: "",
page: 0,
});For cases where you need direct access to the vanilla TableInstance:
React:
import { useTable } from "@opendata-ai/openchart-react";
function AdvancedTable({ spec }: { spec: TableSpec }) {
const { ref, table, state } = useTable(spec, {
onRowClick: (row) => console.log("Clicked:", row),
responsive: true,
});
return (
<div>
<p>Current page: {state.page}</p>
<p>Sort: {state.sort?.column ?? "none"}</p>
<button onClick={() => table?.export("csv")}>Export CSV</button>
<div ref={ref} style={{ width: "100%", height: 500 }} />
</div>
);
}Vue:
<script setup lang="ts">
import { useTable } from "@opendata-ai/openchart-vue";
const { containerRef, table, layout } = useTable(spec, {
onRowClick: (row) => console.log("Clicked:", row),
responsive: true,
});
</script>
<template>
<button @click="table?.export('csv')">Export CSV</button>
<div ref="containerRef" style="width: 100%; height: 500px" />
</template>Svelte:
<script lang="ts">
import { useTable } from "@opendata-ai/openchart-svelte";
const { action, table, layout } = useTable(spec, {
onRowClick: (row) => console.log("Clicked:", row),
responsive: true,
});
</script>
<button onclick={() => table?.export("csv")}>Export CSV</button>
<div use:action style="width: 100%; height: 500px"></div>Sort behavior follows a three-state cycle:
- Click column: sort ascending
- Click same column again: sort descending
- Click same column again: clear sort
Clicking a different column resets to ascending for that column.
By default, both charts and tables observe their container element with ResizeObserver. When the container resizes, the library recompiles at the new dimensions.
The responsive system also uses breakpoints to adjust layout strategy at smaller sizes:
- Label density: Fewer labels at smaller widths to avoid overcrowding
- Legend position: May shift from inline to top/bottom at narrow widths
- Annotation placement: Adjusts for available space
- Tables: Auto-apply compact mode at small breakpoints
The <Chart> and <DataTable> components render a wrapper div set to width: 100%; height: 100%. They fill whatever container you put them in.
For charts, the container needs an explicit height (charts don't have intrinsic height):
{
/* Works: explicit height */
}
<div style={{ width: "100%", height: 400 }}>
<Chart spec={spec} />
</div>;
{
/* Works: flex layout */
}
<div style={{ display: "flex", flexDirection: "column", height: "100vh" }}>
<header>...</header>
<div style={{ flex: 1 }}>
<Chart spec={spec} />
</div>
</div>;
{
/* Won't work: no height constraint, chart collapses to minimum */
}
<div>
<Chart spec={spec} />
</div>;Tables don't need explicit height since they expand with content. The wrapper has overflow: auto for scroll handling.
Set responsive: false on the spec or component prop to disable ResizeObserver. The chart renders once at the initial container dimensions and doesn't resize.
// Spec-level
const spec = { mark: "line", data, encoding, responsive: false };
// Vanilla adapter
createChart(container, spec, { responsive: false });When you need a chart at specific dimensions (for image export, thumbnails, etc.), set the container to the exact size you want, disable responsive, and export:
const container = document.createElement("div");
container.style.width = "1200px";
container.style.height = "800px";
document.body.appendChild(container);
const chart = createChart(container, spec, { responsive: false });
const pngBlob = await chart.export("png", { dpi: 2 });
chart.destroy();
container.remove();The ChartInstance returned by createChart supports three export formats:
const chart = createChart(container, spec);
// SVG: synchronous, returns an XML string
const svgString = chart.export("svg");
// PNG: async, returns a Blob. Optional DPI scaling (default: 2x for retina).
const pngBlob = await chart.export("png");
const hiResPng = await chart.export("png", { dpi: 3 });
// CSV: synchronous, returns the data as comma-separated text
const csvString = chart.export("csv");Tables support CSV export through TableInstance:
const table = createTable(container, tableSpec);
const csvString = table.export("csv");Table CSV export respects the current sort and search state but exports all rows (ignores pagination), so you get the full filtered dataset.
The library doesn't include download utilities, but wiring one up is straightforward:
function downloadBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
function downloadString(content: string, filename: string, mime: string) {
const blob = new Blob([content], { type: mime });
downloadBlob(blob, filename);
}
// Usage
const svgString = chart.export("svg");
downloadString(svgString, "chart.svg", "image/svg+xml");
const pngBlob = await chart.export("png");
downloadBlob(pngBlob, "chart.png");
const csvString = chart.export("csv");
downloadString(csvString, "data.csv", "text/csv");The <Chart> component doesn't expose the instance directly. For export access, use the useChart hook/composable:
React:
import { useChart } from "@opendata-ai/openchart-react";
function ExportableChart({ spec }: { spec: ChartSpec }) {
const { ref, chart } = useChart(spec, { darkMode: "off" });
const handleExport = async (format: "svg" | "png" | "csv") => {
if (!chart) return;
if (format === "png") {
const blob = await chart.export("png");
downloadBlob(blob, "chart.png");
} else {
const str = chart.export(format);
downloadString(str, `chart.${format}`, format === "svg" ? "image/svg+xml" : "text/csv");
}
};
return (
<div>
<button onClick={() => handleExport("svg")}>Export SVG</button>
<button onClick={() => handleExport("png")}>Export PNG</button>
<div ref={ref} style={{ width: "100%", height: 400 }} />
</div>
);
}Vue:
<script setup lang="ts">
import { useChart } from "@opendata-ai/openchart-vue";
const { containerRef, chart } = useChart(spec, { darkMode: "off" });
async function handleExport(format: "svg" | "png" | "csv") {
if (!chart.value) return;
if (format === "png") {
const blob = await chart.value.export("png");
downloadBlob(blob, "chart.png");
} else {
const str = chart.value.export(format);
downloadString(str, `chart.${format}`, format === "svg" ? "image/svg+xml" : "text/csv");
}
}
</script>
<template>
<button @click="handleExport('svg')">Export SVG</button>
<button @click="handleExport('png')">Export PNG</button>
<div ref="containerRef" style="width: 100%; height: 400px" />
</template>Svelte:
<script lang="ts">
import { useChart } from "@opendata-ai/openchart-svelte";
const { action, chart } = useChart(spec, { darkMode: "off" });
async function handleExport(format: "svg" | "png" | "csv") {
if (!chart) return;
if (format === "png") {
const blob = await chart.export("png");
downloadBlob(blob, "chart.png");
} else {
const str = chart.export(format);
downloadString(str, `chart.${format}`, format === "svg" ? "image/svg+xml" : "text/csv");
}
</script>
<button onclick={() => handleExport("svg")}>Export SVG</button>
<button onclick={() => handleExport("png")}>Export PNG</button>
<div use:action style="width: 100%; height: 400px"></div>For usage without a framework, the vanilla adapter provides direct instance control.
import { createChart } from "@opendata-ai/openchart-vanilla";
// Mount
const chart = createChart(container, spec, {
darkMode: "auto",
responsive: true,
onMarkClick: (event) => console.log(event.datum),
});
// Update spec (triggers full recompile + re-render)
chart.update(newSpec);
// Force resize (usually handled by ResizeObserver)
chart.resize();
// Read current layout for custom overlays or debugging
const layout = chart.layout;
console.log(layout.dimensions, layout.marks.length);
// Export
const svg = chart.export("svg");
// Clean up: removes DOM, disconnects observers
chart.destroy();import { createTable } from "@opendata-ai/openchart-vanilla";
const table = createTable(container, tableSpec, {
darkMode: "auto",
responsive: true,
onRowClick: (row) => console.log(row),
onStateChange: (state) => {
// state: { sort: SortState | null, search: string, page: number }
console.log("Table state:", state);
},
});
// Update spec
table.update(newTableSpec);
// Read/set state programmatically
const state = table.getState();
table.setState({ sort: { column: "name", direction: "asc" } });
table.setState({ search: "query", page: 0 });
// Export
const csv = table.export("csv");
// Clean up
table.destroy();Pass externalState in mount options to use controlled mode:
const table = createTable(container, tableSpec, {
externalState: { sort: null, search: "", page: 0 },
onStateChange: (state) => {
// Update your external state store, then call table.setState()
myStore.update(state);
table.setState(state);
},
});The library generates accessibility features automatically:
- Alt text: Auto-generated chart description (type, data summary, encoding)
- Screen reader table: Visually hidden HTML table with chart data, alongside the SVG
- ARIA attributes: Role, roledescription, and label on the chart container
- Keyboard navigation: Arrow keys navigate between marks, Enter/Space shows tooltip, Escape hides it
These are built into the vanilla adapter and inherited by all framework components (React, Vue, Svelte). No extra configuration needed.
For tables, the HTML structure uses semantic elements (<table>, <thead>, <th scope="col">, <tbody>) with ARIA grid roles, sort state attributes, and a live region that announces sort/search changes to screen readers.
Graphs render force-directed network visualizations on canvas. The input is a GraphSpec with nodes and edges instead of data and encoding. See the graphs guide for layout and encoding details, and the spec reference for the full type definition. Live examples: basic, search
import { Graph } from "@opendata-ai/openchart-react";
<Graph
spec={graphSpec}
theme={theme}
darkMode="auto"
onNodeClick={(node) => console.log("Clicked:", node)}
onNodeDoubleClick={(node) => console.log("Double-clicked:", node)}
onSelectionChange={(nodeIds) => console.log("Selected:", nodeIds)}
style={{ width: "100%", height: 500 }}
/>;<script setup lang="ts">
import { Graph } from "@opendata-ai/openchart-vue";
</script>
<template>
<Graph
:spec="graphSpec"
:theme="theme"
dark-mode="auto"
@node-click="(node) => console.log('Clicked:', node)"
@node-double-click="(node) => console.log('Double-clicked:', node)"
@selection-change="(nodeIds) => console.log('Selected:', nodeIds)"
style="width: 100%; height: 500px"
/>
</template><script lang="ts">
import { Graph } from "@opendata-ai/openchart-svelte";
</script>
<Graph
spec={graphSpec}
theme={theme}
darkMode="auto"
onNodeClick={(node) => console.log("Clicked:", node)}
onNodeDoubleClick={(node) => console.log("Double-clicked:", node)}
onSelectionChange={(nodeIds) => console.log("Selected:", nodeIds)}
style="width: 100%; height: 500px"
/>| Prop | Type | Description |
|---|---|---|
spec |
GraphSpec |
The graph spec. Required. |
theme |
ThemeConfig |
Theme overrides. |
darkMode |
DarkMode |
Dark mode: 'auto', 'force', or 'off'. |
onNodeClick |
(node: Record<string, unknown>) => void |
Fires when a node is clicked. |
onNodeDoubleClick |
(node: Record<string, unknown>) => void |
Fires when a node is double-clicked. |
onSelectionChange |
(nodeIds: string[]) => void |
Fires when the selected nodes change. |
For imperative control over the graph (search, zoom, selection), each framework provides a hook/composable:
React:
import { Graph, useGraph } from "@opendata-ai/openchart-react";
function SearchableGraph({ spec }: { spec: GraphSpec }) {
const { ref, search, clearSearch, zoomToFit, zoomToNode, selectNode } =
useGraph();
return (
<div>
<input
onChange={(e) => search(e.target.value)}
placeholder="Search nodes..."
/>
<button onClick={zoomToFit}>Fit all</button>
<button onClick={clearSearch}>Clear search</button>
<div style={{ width: "100%", height: 500 }}>
<Graph ref={ref} spec={spec} />
</div>
</div>
);
}Vue:
<script setup lang="ts">
import { Graph, useGraph } from "@opendata-ai/openchart-vue";
const { componentRef, search, clearSearch, zoomToFit } = useGraph();
</script>
<template>
<input @input="(e) => search(e.target.value)" placeholder="Search nodes..." />
<button @click="zoomToFit">Fit all</button>
<div style="width: 100%; height: 500px">
<Graph ref="componentRef" :spec="spec" />
</div>
</template>Svelte:
<script lang="ts">
import { Graph, useGraph } from "@opendata-ai/openchart-svelte";
const { action, search, clearSearch, zoomToFit } = useGraph(spec, {});
</script>
<input oninput={(e) => search(e.target.value)} placeholder="Search nodes..." />
<button onclick={zoomToFit}>Fit all</button>
<div style="width: 100%; height: 500px" use:action>
</div>All frameworks return the same control methods:
| Property | Type | Description |
|---|---|---|
ref |
RefObject |
Pass to <Graph ref={ref}>. |
search(query) |
(string) => void |
Highlight nodes matching the query. |
clearSearch() |
() => void |
Clear search highlights. |
zoomToFit() |
() => void |
Fit all nodes in the viewport. |
zoomToNode(id) |
(string) => void |
Center and zoom to a specific node. |
selectNode(id) |
(string) => void |
Programmatically select a node. |
getSelectedNodes() |
() => string[] |
Get currently selected node IDs. |
import { createGraph } from "@opendata-ai/openchart-vanilla";
const graph = createGraph(container, graphSpec, {
darkMode: "auto",
responsive: true,
onNodeClick: (node) => console.log("Clicked:", node),
onNodeDoubleClick: (node) => console.log("Double-clicked:", node),
onSelectionChange: (nodeIds) => console.log("Selected:", nodeIds),
});
// Imperative control
graph.search("alice");
graph.clearSearch();
graph.zoomToFit();
graph.zoomToNode("node-42");
graph.selectNode("node-42");
console.log(graph.getSelectedNodes());
// Update with new spec
graph.update(newGraphSpec);
// Clean up
graph.destroy();createGraph returns a GraphInstance with these methods:
| Method | What it does |
|---|---|
update(spec) |
Recompile and re-render with a new spec. |
search(query) |
Highlight nodes matching the query string. |
clearSearch() |
Clear search highlights. |
zoomToFit() |
Fit all nodes in the viewport. |
zoomToNode(id) |
Center and zoom to a specific node. |
selectNode(id) |
Programmatically select a node. |
getSelectedNodes() |
Get currently selected node IDs. |
resize() |
Manually trigger resize (usually handled by ResizeObserver). |
destroy() |
Remove DOM, stop simulation, disconnect observers. |
Nodes support click, drag, and double-click. Dragging a node pins it in place temporarily; when released, the simulation takes over again. Multi-select isn't built in (each click selects one node), but you can track selections via onSelectionChange.
The force simulation runs in a web worker to keep the main thread responsive. Nodes auto-fit to the viewport once the simulation settles.
- Chart types for a visual gallery with boilerplate specs and live examples
- Tables for data table features (heatmaps, sparklines, flags, and more)
- Graphs for network/relationship visualizations
- Spec reference for field-by-field type details
- Getting started for a hands-on tutorial
- Architecture for how the compilation pipeline works