Skip to content

Charming

Charming is a free, open-source, lightweight JavaScript library for creative coding. It offers a focused set of APIs for manipulating SVG, Canvas, and HTML in a data-driven style, and is designed to integrate seamlessly with other libraries such as D3 and p5.

examples

Why Charming?

SVG and Canvas are two powerful technologies for creating rich visual content in web applications. However, it's not easy to working with native SVG and Canvas APIs. For SVG, you must always specify the namespace:

js
const ns = "http://www.w3.org/2000/svg";
const svg = document.createElementNS(ns, "svg");
svg.setAttribute("viewBox", "0 0 200 100");
svg.setAttribute("width", "300");
svg.setAttribute("height", "150");

For Canvas, you need to handle devicePixelRatio to ensure crisp rendering across different display densities:

js
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
canvas.width = 300 * devicePixelRatio;
canvas.height = 150 * devicePixelRatio;
canvas.style.width = 300 + "px";
canvas.style.height = 150 + "px";
ctx.scale(devicePixelRatio, devicePixelRatio);

While numerous tools exist in the ecosystem to simplify these workflows, they often introduce additional abstractions and features that can increase complexity and reduce flexibility.

For those who want to access Canvas and SVG directly without writing too much boilerplate code, introducing Charming:

js
const svg = cm.svg("svg", {width: 300, height: 150, viewBox: "0 0 200 100"});
const ctx = cm.context2d({width: 300, height: 150});

That's it!

Oh, one more thing. Since most SVG creations are data-driven, Charming also has a declarative API to facilitate this process inspired by some existing visualization systems like AntV G2, Observable Plot and D3.

js
(() => {
  function circles(x, y, r, data = []) {
    if (r < 16) return;
    data.push({x, y, r});
    circles(x - r / 2, y, r * 0.5, data);
    circles(x + r / 2, y, r * 0.5, data);
    circles(x, y - r / 2, r * 0.5, data);
    circles(x, y + r / 2, r * 0.5, data);
    return data;
  }

  const svg = cm.svg("svg", {
    width: 480,
    height: 480,
    children: [
      cm.svg("circle", {
        data: circles(240, 240, 200),
        cx: ({d}) => d.x,
        cy: ({d}) => d.y,
        r: ({d}) => d.r,
        stroke: "black",
        fill: "transparent",
      }),
    ],
  });

  return svg;
})();
js
function circles(x, y, r, data = []) {
  if (r < 16) return;
  data.push({x, y, r});
  circles(x - r / 2, y, r * 0.5, data);
  circles(x + r / 2, y, r * 0.5, data);
  circles(x, y - r / 2, r * 0.5, data);
  circles(x, y + r / 2, r * 0.5, data);
  return data;
}

const svg = cm.svg("svg", {
  width: 480,
  height: 480,
  children: [
    cm.svg("circle", {
      data: circles(240, 240, 200),
      cx: ({d}) => d.x,
      cy: ({d}) => d.y,
      r: ({d}) => d.r,
      stroke: "black",
      fill: "transparent",
    }),
  ],
});

document.body.appendChild(svg);

Get Started

Charming is typically installed via a package manager such as Yarn or NPM.

sh
$ npm add -S charmingjs
sh
$ pnpm add -S charmingjs
sh
$ yarn add -S charmingjs
sh
$ bun add -S charmingjs

Charming can then be imported as a namespace:

js
import * as cm from "charmingjs";

In vanilla HTML, Charming can be imported as an ES module, say from jsDelivr:

html
<script type="module">
  import * as cm from "https://cdn.jsdelivr.net/npm/charmingjs/+esm";

  const svg = cm.svg("svg");
  document.body.append(svg);
</script>

Charming is also available as a UMD bundle for legacy browsers.

html
<script src="https://cdn.jsdelivr.net/npm/charmingjs"></script>
<script>
  const svg = cm.svg("svg");
  document.body.append(svg);
</script>

API Reference

  • cm.svg - creates an SVG element.
  • cm.html - creates an HTML element.
  • cm.tag - creates a new element factory.
  • cm.context2d - creates a 2D Canvas drawing context.
  • cm.attr - sets or gets attributes for HTML or SVG elements.

cm.svg(tag[, options])

Creates an SVG element with the specified tag. If the tag is not specified, returns null.

js
cm.svg();

Applying Attributes

If options is specified, applies the specified attributes to the created SVG element. For kebab-case attributes, specifying them in snake_case is also valid. For styles, specifying them with prefix "style_" or "style-".

js
cm.svg("svg", {
  width: 200,
  height: 100,
  viewBox: "0 0 200 100",
  // kebab-case attributes
  "stroke-width": 10,
  stroke_width: 10,
  // styles
  "style-background": "steelblue",
  style_background: "steelblue",
});

For the text content of SVG elements, specifying it as textContent:

js
(() => {
  return cm.svg("svg", {
    height: 30,
    children: [
      cm.svg("text", {
        dy: "1em",
        textContent: "Hello Charming!",
        fill: "steelblue",
      }),
    ],
  });
})();
js
cm.svg("text", {
  dy: "1em",
  textContent: "Hello Charming!",
  fill: "steelblue",
});

If options.data is specified, maps the data to a list of SVG elements and wraps them with a fragment element. If an attribute value is a constant, all the elements are given the same attribute value; otherwise, if an attribute value is a function, it is evaluated for each created element, in order, being passed a context object with the current datum (d), the current index (i), the current data (data), and the current node (node). The function's return value is then used to set each element's attribute.

js
(() => {
  return cm.svg("svg", {
    width: 200,
    height: 60,
    children: [
      cm.svg("circle", {
        data: [20, 70, 120],
        r: 20,
        cy: 30,
        cx: ({d}) => d,
        fill: ({i}) => `rgb(${i * 100}, ${i * 100}, ${i * 100})`,
      }),
    ],
  });
})();
js
cm.svg("circle", {
  data: [50, 100, 150],
  r: 20,
  cy: 30,
  cx: ({d}) => d,
  fill: ({i}) => {
    const b = i * 100;
    return `rgb(${b}, ${b}, ${b})`;
  },
});

Appending Nodes

If options.children is specified as an array of SVG elements, appends the elements to this SVG element. Falsy elements will be filtered out. Nested arrays will be flattened before appending.

js
cm.svg("svg", {
  width: 100,
  height: 60,
  children: [
    cm.svg("circle", {r: 20, cy: 30, cx: 20}),
    [cm.svg("rect", {width: 40, height: 40, y: 10, x: 50})], // Nested array,
    null, // Falsy node
    false, // Falsy node
  ],
});

If a child is a string, a text node will be created and appended to the parent node:

js
(() => {
  return cm.svg("svg", {
    height: 30,
    children: [
      cm.svg("g", {
        transform: `translate(0, 20)`,
        fill: "steelblue",
        children: [cm.svg("text", ["Hello Charming!"])],
      }),
    ],
  });
})();
js
cm.svg("g", {
  fill: "steelblue",
  children: [cm.svg("text", ["Hello Charming!"])],
});

If options is specified as an array, it's a convenient shorthand for {children: options}:

js
cm.svg("svg", [cm.svg("circle"), cm.svg("rect")]);

If options.data is specified, for each child in options.children, if it is a function, it is evaluated for each created element, in order, being passed a context object with the current datum (d), the current index (i), the current data (data), and the current node (node). The function's return value is then appended to the created element.

js
(() => {
  const g = cm.svg("g", {
    data: [0, 1, 2],
    transform: ({d}) => `translate(${d * 50 + 30}, 0)`,
    children: [
      ({d, i, data}) => {
        const a = i * 100;
        return cm.svg("circle", {
          r: 20,
          cy: 30,
          fill: `rgb(${a}, ${a}, ${a})`,
        });
      },
    ],
  });
  return cm.svg("svg", {width: 200, height: 60, children: [g]});
})();
js
cm.svg("g", {
  data: [0, 1, 2],
  transform: ({d}) => `translate(${(d + 1) * 50}, 0)`,
  children: [
    ({d, i, data}) => {
      const a = i * 100;
      return cm.svg("circle", {
        r: 20,
        cy: 30,
        fill: `rgb(${a}, ${a}, ${a})`,
      });
    },
  ],
});

If the child is a constant and the childOptions.data is specified, creates a list of child elements using the parent options.data and appends each child to each parent element.

js
(() => {
  const g = cm.svg("g", {
    data: [0, 1, 2],
    transform: ({d}) => `translate(${d * 50 + 30}, 0)`,
    children: [
      cm.svg("circle", {
        r: 20,
        cy: 30,
        fill: ({d, i, data}) => {
          const a = i * 100;
          return `rgb(${a}, ${a}, ${a})`;
        },
      }),
    ],
  });
  return cm.svg("svg", {width: 200, height: 60, children: [g]});
})();
js
cm.svg("g", {
  data: [0, 1, 2],
  transform: ({d}) => `translate(${d * 50 + 30}, 0)`,
  children: [
    cm.svg("circle", {
      r: 20,
      cy: 30,
      fill: ({d, i, data}) => {
        const a = i * 100;
        return `rgb(${a}, ${a}, ${a})`;
      },
    }),
  ],
});

If the child is a constant and the childOptions.data is specified as a constant, for each parent element, appends a list of child elements using the specified childOptions.data.

js
(() => {
  const g = cm.svg("g", {
    data: [0, 1],
    transform: ({d}) => `translate(30, ${d * 50})`,
    children: [
      cm.svg("circle", {
        data: [0, 1, 2],
        r: 20,
        cy: 30,
        cx: ({d}) => d * 50,
        fill: ({d, i, data}) => {
          const a = i * 100;
          return `rgb(${a}, ${a}, ${a})`;
        },
      }),
    ],
  });
  return cm.svg("svg", {width: 180, height: 110, children: [g]});
})();
js
cm.svg("g", {
  data: [0, 1],
  transform: ({d}) => `translate(30, ${d * 50})`,
  children: [
    cm.svg("circle", {
      data: [0, 1, 2],
      r: 20,
      cy: 30,
      cx: ({d}) => d * 50,
      fill: ({d, i, data}) => {
        const a = i * 100;
        return `rgb(${a}, ${a}, ${a})`;
      },
    }),
  ],
});

If the child is a constant and the childOptions.data is specified as a function, it is evaluated for each parent element, in order, being passed a context object with the current datum (d), the current index (i), the current data (data), and the current node (node). The function's return value is then used to create a list of child elements and append to the current parent element.

js
(() => {
  const g = cm.svg("g", {
    data: [
      [0, 1, 2],
      [3, 4, 5],
    ],
    transform: ({d, i}) => `translate(30, ${i * 50})`,
    children: [
      cm.svg("circle", {
        data: ({d}) => d,
        r: 20,
        cy: 30,
        cx: ({d, i}) => i * 50,
        fill: ({d, i, data}) => {
          const a = d * 40;
          return `rgb(${a}, ${a}, ${a})`;
        },
      }),
    ],
  });
  return cm.svg("svg", {width: 180, height: 110, children: [g]});
})();
js
cm.svg("g", {
  data: [
    [0, 1, 2],
    [3, 4, 5],
  ],
  transform: ({d, i}) => `translate(30, ${i * 50})`,
  children: [
    cm.svg("circle", {
      data: ({d}) => d,
      r: 20,
      cy: 30,
      cx: ({d, i}) => i * 50,
      fill: ({d, i, data}) => {
        const a = d * 40;
        return `rgb(${a}, ${a}, ${a})`;
      },
    }),
  ],
});

Handling Events

If an attribute starts with "on", adds a listener to the created element for the specified event typename. When a specified event is dispatched on the created element, the specified listener will be evaluated for the element, being passed a context object with the following attributes:

  • event: the current event,
  • node: the current node,
  • d: the current data, if the options.data is specified,
  • i: the current index, if the options.data is specified,
  • data: the current data, if the options.data is specified.
js
(() => {
  const onclick = ({event, node}) => {
    const current = cm.attr(node, "style-background");
    const next = current === "steelblue" ? "orange" : "steelblue";
    cm.attr(node, "style-background", next);
  };

  return cm.svg("svg", {
    onclick,
    width: 100,
    height: 100,
    style_background: "steelblue",
    style_cursor: "pointer",
  });
})();
js
const onclick = ({event, node}) => {
  const current = cm.attr(node, "style-background");
  const next = current === "steelblue" ? "orange" : "steelblue";
  cm.attr(node, "style-background", next);
};

cm.svg("svg", {
  onclick,
  width: 100,
  height: 100,
  style_background: "steelblue",
  style_cursor: "pointer",
});

If the attribute value is specified as an array, the first element of it will be specified as the listener, while the second element will specify the characteristics about the event listener, such as whether it is capturing or passive; see element.addEventListener.

js
cm.svg("svg", {onclick: [onclick, {capture: true}]});

cm.html(tag[, options])

Similar to cm.svg, but creates HTML elements instead.

js
cm.html("div", {
  style_background: "steelblue",
  style_width: "100px",
  style_height: "100px",
});

cm.tag(namespace)

Creates an element factory with the specified namespace. For example, creates a math factory for MathML:

js
(() => {
  const math = cm.tag("http://www.w3.org/1998/Math/MathML");

  return math("math", [
    math("mrow", [
      math("mrow", [math("mi", {textContent: "x"}), math("mo", {textContent: "∗"}), math("mn", {textContent: "2"})]),
      math("mo", {textContent: "+"}),
      math("mi", {textContent: "y"}),
    ]),
  ]);
})();
js
const math = cm.tag("http://www.w3.org/1998/Math/MathML");

const node = math("math", [
  math("mrow", [
    math("mrow", [
      // equivalent to math("mi", ["x"])
      math("mi", {textContent: "x"}),
      math("mo", {textContent: "∗"}),
      math("mn", {textContent: "2"}),
    ]),
    math("mo", {textContent: "+"}),
    math("mi", {textContent: "y"}),
  ]),
]);

cm.context2d(options)

Creates a pixel-perfect 2D Canvas drawing context with the specified options. The following options are supported:

  • width: the width for the Canvas element, required.
  • height: the height for the Canvas element, required.
  • dpr: the devicePixelRatio, defaults to window.devicePixelRatio.
  • container: the parent node of the Canvas element. If it is not specified, the Canvas doesn't append to the document. If a string is specified, use it as a selector to query the parent element and append the Canvas to it. If an HTML node is specified, append the node directly.
js
(() => {
  const context = cm.context2d({width: 100, height: 100});
  context.fillRect(0, 0, 100, 100);
  context.fillStyle = "white";
  context.beginPath();
  context.arc(50, 50, 25, 0, Math.PI * 2);
  context.fill();
  return context.canvas;
})();
js
const context = cm.context2d({width: 100, height: 100});
context.fillRect(0, 0, 100, 100);
context.fillStyle = "white";
context.beginPath();
context.arc(50, 50, 25, 0, Math.PI * 2);
context.fill();

cm.attr(node, key[, value])

If value is not specified, gets the current value of the specified key of the specified node attributes.

js
const svg = cm.svg("svg", {
  height: 100,
  style_background: "red",
});
js
svg.getAttribute("height");
js
cm.attr(svg, "style_background");

If value is specified, sets the specified attribute with the specified value to the specified node.

js
(() => {
  const svg = cm.svg("svg");
  cm.attr(svg, "width", 100);
  cm.attr(svg, "height", 100);
  cm.attr(svg, "style-background", "steelblue");
  cm.attr(svg, "style-cursor", "pointer");
  cm.attr(svg, "onclick", ({event, node}) => {
    const current = cm.attr(node, "style-background");
    const next = current === "steelblue" ? "orange" : "steelblue";
    cm.attr(node, "style-background", next);
  });
  return svg;
})();
js
const svg = cm.svg("svg");
cm.attr(svg, "width", 100);
cm.attr(svg, "height", 100);
cm.attr(svg, "style-background", "steelblue");
cm.attr(svg, "style-cursor", "pointer");
cm.attr(svg, "onclick", ({event, node}) => {
  const current = cm.attr(node, "style-background");
  const next = current === "steelblue" ? "orange" : "steelblue";
  cm.attr(node, "style-background", next);
});

If value is specified as null, remove that attribute.

js
cm.attr(input, "checked", null);
cm.attr(div, "class", null);
cm.attr(div, "style-color", null);
cm.attr(span, "textContent", null);

If an event listener is specified as null, removes that event listener. Otherwise, removes the older one if exists and adds the new one.

js
const svg = cm.svg("svg");
cm.attr(svg, "onclick", () => alert("hello charming"));
cm.attr(svg, "onclick", null);