JavaScript runtimes
Render charts from Node.js, Cloudflare Workers or a Vite build with the WebAssembly renderer in the npm package.
@casoon/chartlet contains the renderer compiled to WebAssembly. It produces the same bytes as
the CLI and the crate, and it needs no Rust toolchain.
Node.js
renderChart loads chartlet.wasm from the package on first use and renders synchronously:
import { renderChart } from '@casoon/chartlet';
const { content, warnings } = renderChart(spec, { format: 'svg', idPrefix: 'revenue' });
The options are those of the CLI: format, table, idPrefix, variant (desktop, mobile,
print or social), view (the key of a view of an architecture model), perspective (the key of a perspective
to show in place of the one the model or the view selects), theme (a bundled theme by its name, or a theme
file as an object; see Customize the look), strict, allowWarnings,
manifest and hooks. When
options.binary or the environment variable CHARTLET_BIN names a chartlet executable,
renderChart calls that CLI instead.
Text alternative
A page that wraps the SVG profile in its own accessible figure needs the words the chart would
otherwise carry. alternative: true returns them as data, in the language of the
specification’s locale:
const { content, alternative } = renderChart(spec, { format: 'svg', alternative: true });
alternative.description; // the description the SVG carries in <desc>
alternative.table; // { caption, columns, rows }: the data table of the HTML profile as text
Each row starts with its category; values are written as the chart writes them, including a
declared decimals or thousandsSeparator. From Rust, chartlet::text_alternative(&spec)
returns the same.
With allViews: true, alternative is that of the model’s default view, and alternatives
lists that of every view on the page, in its order, each with its view key:
const { alternatives } = renderChart(model, { allViews: true, alternative: true });
alternatives.map(({ view, description }) => `${view}: ${description}`);
Structured diagnostics
renderChart throws on an invalid specification. renderChartDetailed takes the same arguments
and reports the outcome as data, the document the CLI writes with --diagnostics json:
import { renderChartDetailed } from '@casoon/chartlet';
const result = renderChartDetailed(spec, { format: 'svg' });
if (!result.ok) {
console.error(result.error.code, result.error.path, result.error.message);
}
for (const { code, path, message } of result.warnings) {
console.warn(code, path, message);
}
A successful result also carries content, styleHashes and, with manifest: true, manifest.
spec may be JSON text instead of an object; malformed text is reported as invalid_json. A
missing WebAssembly build or CLI still throws. createRenderer returns renderChartDetailed too.
Provenance
With manifest: true, the result also carries manifest, the object the CLI writes with
--manifest: chartlet version, schema version, SHA-256 of the canonical specification and of
content, format, variant, ID prefix and warnings, without a timestamp. See
Provenance for the fields.
const { content, manifest } = renderChart(spec, { format: 'svg', manifest: true });
// manifest.outputHash === `sha256:${sha256Hex(content)}`
The WebAssembly build and the CLI path return the same manifest; the CLI path reads it from a temporary file that it removes again.
Runtimes that import WebAssembly
Cloudflare Workers (workerd) do not compile WebAssembly from bytes at run time; the module has to
be imported so that the bundler compiles it ahead of time. @casoon/chartlet/wasm takes that
module and has no Node.js imports:
import wasm from '@casoon/chartlet/chartlet.wasm';
import { createRenderer } from '@casoon/chartlet/wasm';
const { renderChart } = createRenderer(wasm);
export default {
fetch() {
const { content } = renderChart(spec, { idPrefix: 'revenue' });
return new Response(content, { headers: { 'content-type': 'text/html' } });
},
};
Wrangler bundles the .wasm import as a compiled module without further configuration.
Vite
In a browser bundle, let Vite emit the file and compile it once before rendering:
import wasmUrl from '@casoon/chartlet/chartlet.wasm?url';
import { createRenderer } from '@casoon/chartlet/wasm';
const module = await WebAssembly.compileStreaming(fetch(wasmUrl));
const { renderChart } = createRenderer(module);
renderChart from createRenderer takes the same options and returns the same result as the
Node.js entry; binary is ignored.
Shared stylesheet
By default every chart carries its whole stylesheet, scoped to its root, so an SVG file stands
alone. On a page with many charts that repeats about 1.5–4 KB of identical CSS per chart, and a
mobile variant repeats it again. With styles: 'external' a chart carries only what is its own —
the colors its layers declare — and usually no <style> at all; the page loads the shared
stylesheet once:
import '@casoon/chartlet/chartlet.css'; // or link the file; 16 KB, about 2 KB gzipped
const { content } = renderChart(spec, { format: 'html', styles: 'external' });
The stylesheet is the same for every chart, so it caches across pages, and as a file it needs no
CSP hash. stylesheet() returns it as a string; the CLI prints it with chartlet stylesheet and
renders with --styles external. A chart looks the same either way.
The full file covers every chart type. A site that renders only some of them can serve just
those: stylesheet({ types: ['bar', 'time'] }) or chartlet stylesheet --types bar,time keeps
the common rules (about 2 KB) and the rules of the named types.
A site that links a stylesheet per type, only on the pages that use it, takes the parts apart:
| Part | JavaScript | CLI |
|---|---|---|
| Common: colors, text, grid, axes | stylesheet({ types: [] }) |
chartlet stylesheet --common |
| One or more types, without the common part | stylesheet({ types: ['time'], common: false }) |
chartlet stylesheet --types time --no-common |
These are guarantees, not details of the output: the common part is the same for every type and
comes first; each type’s part styles only charts of that type, mostly through .chartlet-type-<type>,
so the parts of two types never style the same element and load in any order after the common part. The full stylesheet for some
types is the common part followed by the part of each type in the order named. Rust has the same
as stylesheet_common() and stylesheet_types(&[…]).
Formatters and linters
Charts, HTML fragments and stylesheets that you write into the project’s source tree are
generated: the next build overwrites them, so a formatter that rewrites them only creates noise.
chartlet’s output is minified on purpose and has no lint findings, but Biome and Prettier report
its formatting. chartlet stylesheet marks its file with a first line, /* generated by chartlet - do not edit */; the other outputs carry no header, because they are embedded in pages byte for
byte. Exclude the folder you write them to:
{ "files": { "includes": ["**", "!**/public/charts"] } }
for Biome in biome.json, and
public/charts/
in .prettierignore for Prettier. Use the folder your build writes to; public/charts is an
example.
Architecture builder
@casoon/chartlet/builder assembles an architecture or flow specification in code. It builds nothing but
the regular JSON specification: spec() returns a plain object to inspect, serialize or pass to
renderChart, so the JSON stays the way out. Importing it renders and writes nothing, and it
keeps no state between builders.
import { architecture, id } from '@casoon/chartlet/builder';
import { renderChart } from '@casoon/chartlet';
// A building block is a plain function that derives its ids from a name.
function workers(a, name, region) {
const zone = a.boundary(id(name, 'zone'), `Region ${name}`, { in: region });
const queue = a.component(id(name, 'queue'), 'Jobs', { kind: 'queue', in: zone });
const pool = [1, 2, 3].map((n) => a.component(id(name, 'worker', n), `Worker ${n}`, { in: zone }));
a.fanOut(queue, pool, { label: 'delivers' });
return { queue, pool };
}
const a = architecture({ orientation: 'portrait' });
const cloud = a.boundary('cloud', 'Cloud');
const api = a.component('api', 'API', { in: cloud });
const db = a.component('db', 'Orders', { kind: 'database' });
for (const name of ['eu', 'us']) {
const { queue, pool } = workers(a, name, cloud);
a.connect(api, queue, { label: 'enqueues' }).fanIn(pool, db, { label: 'writes' });
}
const spec = a.spec({ title: 'Workers in two regions' });
const { content } = renderChart(spec, { format: 'svg' });
| Call | Adds |
|---|---|
boundary(id, label, fields), component(id, label, fields) |
One element; returns its id |
connect(from, to, fields) |
One connection |
fanOut(from, [to, …], fields), fanIn([from, …], to, fields) |
One connection per listed component, in their order |
chain([a, b, c, …], fields) |
A connection from each component to the next |
mainPath([…]) or mainPath({ from, to }) |
The main path |
spec(fields) |
Returns the specification with fields at the top level (title, width, mobile, …) |
Ids are always yours: written out, or joined from parts with id(...), so the same code gives the
same ids in the same order, the order of the calls. There is no connection between two lists:
connect takes one component on each side, fanOut and fanIn one list on one side. fields
are copied into the specification as they are (kind, sublabel, technology, dash,
weight, step and fields added later), and architecture(settings) puts settings into the
architecture object. The builder checks only what it adds: an id used twice, an id that is
not one, a reference to an element not declared before (in to a boundary, connections and the
main path to components), and a list where one id is expected all throw at the call. Everything
else is the validation of renderChart, against the same schema.
When to use it: a diagram written once, like the examples shop-architecture and
tenant-architecture, reads as well as JSON or an object literal — rebuilt with the builder it
is no shorter, since nothing repeats. The builder pays off where parts repeat: a building block
used for several regions returns its ids instead of making the caller repeat them in templates
and merge three lists, and a wrong reference is reported at the call that made it rather than as
a path into the finished specification.
Flow builder
flow(settings) builds a flow chart the same way: settings go into the flow object, ids are
yours, references are checked at the call, and spec(fields) returns the plain specification.
import { flow } from '@casoon/chartlet/builder';
const f = flow();
const dev = f.lane('dev', 'Development');
const ci = f.lane('ci', 'Continuous integration');
const commit = f.node('commit', 'Commit pushed', { kind: 'start', lane: dev });
const build = f.node('build', 'Build', { lane: ci });
const tests = f.node('tests', 'Run tests', { lane: ci });
const done = f.node('done', 'Merged', { kind: 'end', lane: dev });
f.chain([commit, build, tests, done])
.group('Quality gates', [build, tests])
.mainPath({ from: commit, to: done });
const spec = f.spec({ title: 'From commit to merge' });
| Call | Adds |
|---|---|
lane(id, label), node(id, label, fields) |
One lane or step; returns its id. fields.lane names a lane declared before |
group(label, [step, …]) |
A frame around the listed steps |
connect, fanOut, fanIn, chain |
Edges between steps, as in the architecture builder |
mainPath([…]) or mainPath({ from, to }) |
The main path |
sameRank([step, …]) |
One set of steps at one position along the flow; each call adds a set |
spec(fields) |
Returns the specification with fields at the top level |
Lanes and steps have ids of their own, so a lane and a step may share one; a group has none. The
examples order-flow and release-flow rebuild with it byte for byte.
Interactive module
@casoon/chartlet/interactive is an optional browser module, with no dependencies, for charts
rendered with hooks: true: a crosshair that reads out values by pointer and keyboard, series
toggles, scroll stations and playback. It reads the values from the chart’s data table and the
geometry from data-* hooks, so it never lays the chart out again:
import { enhance, crosshair, toggle } from '@casoon/chartlet/interactive';
enhance(document.querySelector('#power').closest('figure'), { crosshair, toggle });
See Interaction for the features and the hooks. The Rust
CLI writes the hooks with --hooks but ships no JavaScript.
Content Security Policy
A chart carries its styles in inline <style> elements, which a strict style-src blocks unless
it names them by hash. Both entries return those hashes with the content: styleHashes holds the
CSP source expression ('sha256-<base64>') of every distinct <style> element in content, in
order of first appearance. The hash is taken over the text between the tags, exactly as written.
As a response header:
const { content, styleHashes } = renderChart(spec, { idPrefix: 'revenue' });
return new Response(content, {
headers: {
'content-type': 'text/html',
'content-security-policy': `default-src 'self'; style-src 'self' ${styleHashes.join(' ')}`,
},
});
Astro (6 and newer, with security.csp enabled) writes the policy itself and takes hashes without
the quotes. Add them per page:
---
import { renderChart } from '@casoon/chartlet';
import spec from '../data/monthly-revenue.json';
const { content, styleHashes } = renderChart(spec, { idPrefix: 'monthly-revenue' });
for (const hash of styleHashes) {
Astro.csp?.insertStyleHash(hash.slice(1, -1));
}
---
<Fragment set:html={content} />
or for every page in astro.config.mjs:
import { defineConfig } from 'astro/config';
import { renderChart } from '@casoon/chartlet';
import spec from './src/data/monthly-revenue.json' with { type: 'json' };
const { styleHashes } = renderChart(spec, { idPrefix: 'monthly-revenue' });
export default defineConfig({
security: {
csp: {
styleDirective: { hashes: styleHashes.map((hash) => hash.slice(1, -1)) },
},
},
});
The styles depend on the specification, the format and the idPrefix, so hash the same render
you embed. The WebAssembly build computes the hashes in the renderer, so createRenderer returns
them synchronously in every runtime; the CLI path computes the same values with node:crypto.