chartletv0.21.0chartlet.casoon.de

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.

Edit this page on GitHub · Docs for v0.21.0