This is Charming1. For Charming2, go to charmingjs.org. This is v1. For v2, go to charmingjs.org.

Why Charming?

On my programming journey, two tools have shaped me: Processing and D3.js. The former makes coding fun, while the latter makes coding elegant. What about Charming? Let’s see…

Inspiration

As I began my journey into computational art, I found P5.js and Processing to be excellent starting points. Their artist-centered APIs helped me get stuff drawn onto the screen quickly and easily, allowing me to focus on creative expression. I quickly published a series of artworks on OpenProcessing, instead of spending time wondering why the computer didn’t work as I expected before my time or patience ran out. Additionally, I also designed a creative coding language Charming.py with the same style of APIs, for ASCII art in the terminal.

However, when I became a front-end engineer in the field of visualization, I noticed a distinct shift in programming paradigms for both visualization grammars and front-end frameworks: from imperative to declarative. Declarative grammars can accelerate development, facilitate retargeting across platforms, and allow language-level optimizations. While P5.js, as the computational art framework for the web, is still imperative and I found myself using it less and less as I tasted the benefits of declarative programming. So I began to ponder whether it was possible to design and implement a declarative grammar specifically for computational art or creative coding.

After about two years of experience developing and maintaining the new 5.0 version of G2, I learned a lot from the complete process of designing a new grammar. At the same time, I observed a commonality between visualization and generative art: they are both data-driven to some extent. The difference is that visualization(information visualization more specifically) is typically driven by data derived from daily life activities, while computational art is driven by data generated by algorithms or computing.

Building on this observation, I established the foundational structure of Charming’s declarative grammar, and as I progressed, I discovered additional advantages. Declarative grammar decouples specification (the what) from execution (the how). This approach not only makes code more predictable but also diversifies the underlying rendering implementations. In addition, Charming incorporates best practices from G2.js and valuable parts from D3.js.

My hope with Charming is that you spend less time wrangling the machinery of programming and more time “using computing to tell stories”. Or put more simply: With Charming, you’ll express more, more easily.

So how?

Charming is declarative

Charming’s atomic operand is flow: a container holds data and shapes. Operators act on flow, modifying content. The instance app, created by cm.app, also serving as a namespace, provides datum and data methods for creating flows and placing values into them.

Any number of operators can be applied to selected data. The process operators pipe data through a series of pure functions to prepare data for rendering, such as cm.map, cm.each, and cm.filter. The append operators add a new shape for each processed datum in this flow, extracting columns of data and assigning them to shape attributes, thus allowing the convenient creation of nested structures. The transform operators derive and modify extracted shape attribute values before committing to the renderer.

Charming supports method chaining for brevity when applying multiple operators: the operator return value is a new flow. This allows authoring computational art declaratively and a clear relationship between the raw data and the drawn graphics can be seen through the declared operators.

For example, let’s draw a clover composed of multiple circles, whose positions are produced by a clover algorithm:

{
  const app = cm.app({
    width: 640,
    height: 640,
  });

  app
    .data(cm.range(120))
    .process(cm.map, (_, i, data) => (i * Math.PI) / data.length)
    .append(cm.circle, {
      x: (t) => Math.cos(t) * Math.cos(t * 3),
      y: (t) => Math.sin(t) * Math.cos(t * 3),
      r: (t) => t,
      fill: "black",
    })
    .transform(cm.mapPosition, { padding: 30 })
    .transform(cm.mapAttrs, { r: { range: [8, 15] } });

  const node = app.render().node();

  display(node);
}

It’s noteworthy that flow allows users to organize logic in a functional programming style, rather than the object-oriented approach recommended by Processing or P5.js, making the code more concise and better organized.

Charming is high performance

The data-driven API style provides Charming with the potential for high performance, as it can take advantage of WebGL’s batch rendering technique: rendering hundreds to thousands of objects or elements with just a single draw call. At the same time, some GLSL functions can be defined to offload expensive calculations to the GPU, thereby significantly increasing the FPS. It may also implement a WebGPU renderer in the future to accomplish this more easily with Compute Shader.

For example, to render specific number of circles while dynamically updating their positions, strokes and radii using trigonometric functions. While the Canvas renderer can maintain 60 FPS for up to 5.5k circles, the WebGL renderer can sustain the same 60 FPS for as many as 22k circles. Moreover, when faced with the task of rendering 100k circles, the WebGL renderer fails to draw them, but WebGL combined with GLSL attributes can still manage to render at a reduced frame rate of 10 FPS.

{
  const width = 700,
    height = 700,
    scale = 300,
    theta = cm.range(count, 0, cm.TWO_PI);

  function update(app) {
    const time = app.prop("frameCount") / 50;

    app.append(cm.clear, { fill: "black" });

    app
      .data(theta) // Bind Data.
      .append(cm.circle, {
        // Define some glsl attributes and interpolate some values.
        position: cm.glsl`vec2 position(float theta) {
          vec2 xy = vec2(
            cos(theta),
            sin(theta)) * (0.6 + 0.2 * cos(theta * 6.0 + cos(theta * 8.0 + ${time}))
          );
          return xy * ${scale} + vec2(${width / 2}, ${height / 2});
        }`,
        r: cm.glsl`float r(float theta) {
          float d = 0.2 + 0.12 * cos(theta * 9.0 - ${time} * 2.0);
          return d * ${scale};
        }`,
        stroke: cm.glsl`vec4 stroke(float theta) {
          float th = 8.0 * theta + ${time} * 2.0;
          vec3 rgb = 0.6 + 0.4 * vec3(
            cos(th - ${Math.PI} * 2.0 / 3.0),
            cos(th),
            cos(th - ${Math.PI} * 5.0 / 3.0)
          );
          return vec4(rgb, 0.0);
        }`,
        strokeOpacity: cm.glsl`float strokeOpacity(float theta) {
          return 0.15 * 2000.0 / ${count};
        }`,
      });
  }

  // Disposes app when rerunning cell.
  function dispose(app) {
    invalidation.then(() => app.dispose());
  }

  // Uses WebGL renderer.
  const app = cm.app({
    width,
    height,
    renderer: cm.webgl(),
  });

  const node = app.on("update", update).call(stats).call(dispose).start().node();

  display(node);
}

Charming supports shader art

GLSL functions are well-suited for Shader Art: the fill attribute of a rect shape can serve as the code for a fragment shader. Here is an example from shader toy.

{
  const width = 640;
  const height = 360;
  const palette = cm.glsl`vec3 palette(float t) {
    vec3 a = vec3(0.5, 0.5, 0.5);
    vec3 b = vec3(0.5, 0.5, 0.5);
    vec3 c = vec3(1.0, 1.0, 1.0);
    vec3 d = vec3(0.263, 0.416, 0.557);
    return a + b * cos(3.1415926 * 2.0 * (c * t + d));
  }`;

  function update(app) {
    const time = app.prop("frameCount") / 50;
    const fill = cm.glsl`vec4 fill(vec2 coord, vec4 color) {
      vec2 uv = (coord - vec2(${width}, ${height})) / ${height};
      vec2 uv0 = uv;
      vec3 rgb = vec3(0.0);
      for (float i = 0.0; i < 4.0; i++) {
        uv = fract(uv * 1.5) - 0.5;
        float d = length(uv) * exp(-length(uv0));
        vec3 col = ${palette}(length(uv0) + i * 0.4 + ${time} * 0.4);
        d = sin(d * 8.0 + ${time}) / 8.0;
        d = abs(d);
        d = pow(0.01 / d, 1.2);
        rgb += col * d;
      }
      return vec4(rgb, 1.0);
    }`;
    app.append(cm.rect, { x: 0, y: 0, width, height, fill });
  }

  function dispose(app) {
    invalidation.then(() => app.dispose());
  }

  const app = cm.app({
    renderer: cm.webgl(),
    width,
    height,
  });

  const node = app.on("update", update).call(dispose).start().node();

  display(node);
}

Charming supports ASCII art

In addition to high performance, Charming focuses on making ASCII art accessible for artists, designers, educators, beginners, and anyone else! It provided a consistent API for both styles, and the terminal canvas for ASCII art is embedded in JavaScript and uses a software rasterizer written in Rust compiled to WASM, to gain high performance hopefully.

{
  let fire = null;

  function update(app) {
    const width = app.prop("width");
    const height = app.prop("height");
    if (!fire) fire = createFire(width, height);
    updateFire(fire);
    drawFire(app, fire);
  }

  function dispose(app) {
    invalidation.then(() => app.dispose());
  }

  const app = cm.app({
    renderer: await cm.terminal(),
    frameRate: 15,
  });

  const node = app.on("update", update).call(dispose).start().node();

  display(node);
}

Charming is composable

Inspired by the component philosophy in React, Charming makes it easy to define custom composite shapes through pure function, such as this arrow shape:

function arrow(flow, { length, x, y, rotate, angle, ...options }) {
  const group = flow.append(cm.group, { x, y, rotate });
  const l1 = length.map((d) => d / 2);
  const l2 = length.map((d) => -d / 2);
  group.append(cm.link, { x: l2, y: 0, x1: l1, y1: 0, ...options });
  group.append(cm.link, {
    x: 0,
    y: 0,
    x1: l1,
    y1: 0,
    rotate: angle,
    transformOrigin: "end",
    ...options,
  });
  group.append(cm.link, {
    x: 0,
    y: 0,
    x1: l1,
    y1: 0,
    rotate: -angle,
    transformOrigin: "end",
    ...options,
  });
}

This composite shape can be used like any built-in shape, and supports transform operator as well. For example, to draw a flow field:

{
  const width = 640,
    height = 240,
    size = 16,
    cols = width / size,
    rows = height / size,
    noise = cm.randomNoise(),
    fields = cm.cross(cm.range(cols), cm.range(rows)).map(([x, y]) => ({ x, y, value: noise(y * 0.1, x * 0.1) }));

  const app = cm.app({ width, height });

  app
    .data(fields)
    .append(arrow, {
      x: (d) => d.x * size + size / 2,
      y: (d) => d.y * size + size / 2,
      rotate: (d) => d.value,
      angle: cm.constant(Math.PI / 6),
      rotate: (d) => d.value,
      stroke: (d) => d.value,
      length: (d) => d.value,
    })
    .transform(cm.mapAttrs, {
      rotate: { range: [0, cm.TWO_PI] },
      length: { range: [size * 0.3, size * 0.9] },
      stroke: { interpolate: d3.interpolateViridis },
    });

  const node = app.render().node();

  display(node);
}

In addition to simple composite shapes, Charming can also define complex shapes, for example, to define and use a barY shape to plot a bar chart. This greatly enhances the extensibility of Charming, making it easy to build its ecosystem.

{
  const app = cm.app({ width: 928, height: 500 });

  app.data(alphabet).append(barY, {
    x: (d) => d.letter,
    y: (d) => d.frequency,
  });

  const node = app.render().node();

  display(node);
}

Charming is lightweight

Charming is lightweight because it focuses solely on one thing: drawing shapes driven by data, with the minified core bundle coming it at just 25kb. The other features are exposed as helper modules and can be adopted incrementally. Each modules have a thoughtfully designed interface, letting users plug in their own functions or objects for processors, transforms, shapes…even custom renderers. In the future, there is a possibility to expand Charming with the development of various plugins, including a physics engine, plotting library, computational geometry toolkit, and image processing module.

Charming is beginner-friendly

Although Charming provides some high-level abstractions and modules, such as flow, transform, and scale, beginners are not required to understand them at all. They can simply regard it as a collection of syntactic sugar for the Canvas API, with a conventional control flow.

{
  const app = cm.app();
  for (let i = 0; i < 500; i++) {
    const x = cm.random(app.prop("width"));
    const y = cm.random(app.prop("height"));
    const radius = cm.randomInt(30);
    const r = cm.randomInt(255);
    const g = cm.randomInt(255);
    const b = cm.randomInt(255);
    app.append(cm.circle, {
      x,
      y,
      r: radius,
      fill: `rgb(${r}, ${g}, ${b})`,
    });
  }
  const node = app.render().node();

  display(node);
}

Compared to P5.js

While Charming tries its best to minimize the learning curve, it must be acknowledged that P5.js remains more accessible to beginners, particularly for those with no prior programming experience. Thus this brings some issues for advanced projects, such as namespace pollution, large bundle size and additional overhead introduced by the Friendly Error System (FES). Therefore, for those who are new to programming or JavaScript, P5.js is still the best starting point. For others, Charming seems to be a more promising option.

Compared to D3.js

D3.js is great, great of all time. Charming is able to work seamlessly with most of the helper modules from D3.js, such as d3-array and d3-scale, and it is encouraged to do so. But d3-selection is an exception, which can be considered the “renderer” for D3’s ecosystem. It is built on SVG, which excels at interactive visualizations but is less suited for animation-based computational art, owing to performance constraints. Although, in theory, d3-selection can work with any rendering technology that implements the SVG standard, the complexity of the standard would inevitably introduce additional runtime overhead and increase the bundle size. In a word, for the majority of visualizations, D3.js is the preferred choice, while for computational art or performance-centric visualizations, Charming.js may be the better option.

Appendix

function createFire(width, height) {
  return cm.range(height).map(() => cm.range(width).map(() => 0));
}
function updateFire(fire) {
  const h = fire.length;
  const w = fire[0].length;
  const max = h;
  const noise = cm.randomNoise(0, max);

  for (let y = 0; y < h - 1; y++) {
    for (let x = 0; x < w; x++) {
      const decay = cm.randomInt(0, 3);
      const spread = cm.randomInt(-1, 1);
      const index = Math.min(Math.max(0, x - spread), w - 1);
      const target = fire[y + 1][index];
      fire[y][x] = Math.max(0, target - decay);
    }
  }

  for (let x = 0; x < w; x++) {
    fire[h - 1][x] = noise(x / 10) | 0;
  }
}
function drawFire(app, fire) {
  const max = fire.length;

  app.append(cm.clear, { fill: "black" });

  app
    .data(fire)
    .append(cm.group, { x: 0, y: (_, i) => i })
    .data((d) => d)
    .append(cm.point, {
      y: 0,
      x: (_, i) => i,
      stroke0: (d) => (d === 0 ? " " : cm.randomChar()),
      stroke2: (d) => (d === 0 ? null : d),
    })
    .transform(cm.mapAttrs, {
      stroke2: {
        domain: [0, max],
        interpolate: d3.interpolateCool,
      },
    });
}
function barY(
  flow,
  { x, y, fill = "steelblue", marginLeft = 40, marginTop = 30, marginRight = 0, marginBottom = 30, ...rest },
) {
  const app = flow.app();
  const [data] = flow.data();
  const width = app.prop("width");
  const height = app.prop("height");

  const scaleX = d3.scaleBand(Array.from(new Set(x)), [marginLeft, width - marginRight]).padding(0.1);

  const scaleY = d3.scaleLinear([0, d3.max(y)], [height - marginBottom, marginTop]);

  const I = d3.range(data.length);
  const X = x.map(scaleX);
  const Y = y.map(scaleY);

  flow.data(I).append(cm.rect, {
    x: (i) => X[i],
    y: (i) => Y[i],
    width: scaleX.bandwidth(),
    height: (i) => scaleY(0) - Y[i],
    fill,
    ...rest,
  });

  flow
    .datum(0)
    .append(cm.group, { x: 0, y: height - marginBottom })
    .call(axisX, { scale: scaleX });

  flow.datum(0).append(cm.group, { x: marginLeft, y: 0 }).call(axisY, { scale: scaleY });
}
function axisY(flow, { scale }) {
  const range = scale.range();
  const ticks = scale.ticks();
  const tickY = (d) => scale(d);

  flow.data(ticks).append(cm.link, {
    x: 0,
    y: tickY,
    x1: -4,
    y1: tickY,
    stroke: "#000",
  });

  flow.data(ticks).append(cm.text, {
    text: (d) => d,
    x: -6,
    y: tickY,
    stroke: "#000",
    textAlign: "end",
    textBaseline: "middle",
  });
}
function axisX(flow, { scale }) {
  const range = scale.range();
  const ticks = scale.domain();
  const tickX = (d) => scale(d) + scale.bandwidth() / 2;

  flow.append(cm.link, {
    x: range[0],
    y: 0,
    x1: range[1],
    y1: 0,
    stroke: "#000",
  });

  flow.data(ticks).append(cm.link, {
    x: tickX,
    y: 0,
    x1: tickX,
    y1: 4,
    stroke: "#000",
  });

  flow.data(ticks).append(cm.text, {
    text: (d) => d,
    x: tickX,
    y: 6,
    stroke: "#000",
    textAlign: "center",
    textBaseline: "top",
  });
}