I often see people asking for recommendations for libraries to build charts for the web. I always find this question a bit odd especially when it comes up in a large organization because beneath it, there's really two things going on:
1) The asker thinks that charting is a difficult problem that they need a library to solve.
2) The asker doesn't understand there are major considerations depending on context.
There are many charting options and in my opinion there's not really any I'd recommend. Many are fine if you don't really care about the look, but most fall apart when you try to modify certain things. Then there's d3.js which is somehow marketed as a charting library but is really just jquery for SVGs. I guess that's why people pick it, they think it does something interesting for charting when really they need to do all the work themselves which is why it can produce custom charts.
Anyway. That's a bit of a rant. What I want to demonstrate is that charting is pretty easy. If you've ever had to build a typeahead, calendar, or a custom select you've built something much harder to get right. You also don't need d3 or any of that. Secondly, you really should consider exactly how you're going to go about this because it matters.
Choices
So I alluded to choices. There are multiple ways to display things on the web and it's important to pick the right one. For charts you'll probably look at:
- SVG
- Canvas
- WebGL
- CSS
And they all have different trade-offs.
Svg
Pros:
- Can be manipulated like DOM
- Look good at any DPI
- Can be constructed server-side without libraries
- Can be exported
- Easy to get certain effects like animation
Cons:
- Runtime performance suffers, especially if there are a lot of points
- Not as portable as you might think
- Not pixel accurate
SVGs will look great on any DPI screen. You can very easily add interactive pieces like tooltips to elements because they are just part of the DOM and because they are text-based you can easily manipulate them without specialized graphics libraries. D3 is exclusively SVG as are many charting libraries but that doesn't mean you should automatically go with it. If you have a lot of points then SVG can be very taxing as you have all the DOM overhead while doing drawing. Redraws are also expensive. It can also be hard to get things pixel accurate.
Canvas
Pros:
- Drawing is fast
- API is intuitive
- Can export as image with right-click
- Fine-grained control over pixels
Cons:
- Scaling requires more work
- Interactivity like tooltips are hard
- Animation is hard
- Requires separate image library on server
Over all canvas is fast even with a lot of points and you have a lot of fine-grained control. Certain effects can be harder to accomplish but users can always right-click and save as a png which is handy for them. The downside is you can't really share code with the server, DPI scaling is something you don't get for free and interactivity is harder.
WebGL
Pros:
- Blazing fast drawing
- Extreme flexibility in effects
- Can easily scale
- Right-click export to png works
Cons:
- Huge learning curve
- Lots of code
- Interactivity is harder
- Animation is hard
- Limited sharing with server
WebGL is of course very fast and as such you can make certain types of charts that would be completely unfeasible in either SVG or canvas. You get a ton of control over drawing as well using shaders. However, you need to overcome a huge learning curve if you are new to it, it'll require a lot of verbose code and like with canvas it's not really easy to make things interactive or share code with the server for SSR.
CSS
Pros:
- Very flexible
- No JS required
- Scalable
- Can get accessibility for free
- Animation isn't too bad
Cons:
- Not exportable
- Requires very modern browser
- Difficult to implements yourself
CSS-only is pretty novel. Only a few libraries support it but it has interesting benefits in that you can prerender the HTML and not need any JS. It is more limited than SVG though. It's also fairly difficult to implement yourself and will likely need to use more modern CSS feature to make it easier but for simple charting this might be a good choice because if you do it right, accessibility based on HTML tables is free.
Building you own
For this I'm going to start with a simple SVG chart. This is because it has probably the widest set of uses and not too much overhead with code.
Some of the requirements:
- Will have 2 modes
- Chart a list of samples
- Chart a continuous function
- We can set the bounds
- For points we can set shape, color and size
Boilerplate
class WcSvgGraph extends HTMLElement {
#points = [];
#width = 320;
#height = 240;
#xmax = 100;
#xmin = -100;
#ymax = 100;
#ymin = -100;
#func;
#step = 1;
static observedAttributes = ["points", "func", "step", "width", "height", "xmin", "xmax", "ymin", "ymax"];
constructor() {
super();
this.bind(this);
}
bind(element) {
element.attachEvents.bind(element);
}
render() {
}
attachEvents() {
}
connectedCallback() {
this.render();
this.attachEvents();
}
attributeChangedCallback(name, oldValue, newValue) {
this[name] = newValue;
}
set points(value) {
}
get points() {
return this.#points;
}
set width(value) {
this.#width = parseFloat(value);
}
get width() {
return this.#width;
}
set height(value) {
this.#height = parseFloat(value);
}
get height() {
return this.#height;
}
set xmax(value) {
this.#xmax = parseFloat(value);
}
get xmax() {
return this.#xmax;
}
set xmin(value) {
this.#xmin = parseFloat(value);
}
get xmin() {
return this.#xmin;
}
set ymax(value) {
this.#ymax = parseFloat(value);
}
get ymax() {
return this.#ymax;
}
set ymin(value) {
this.#ymin = parseFloat(value);
}
get ymin() {
return this.#ymin;
}
set step(value) {
this.#step = parseFloat(value);
}
}
customElements.define("wc-svg-graph", WcSvgGraph);
If you've followed my other posts you've seen this all before so I'm going to fill out the attributes setters for the things I'm going to need here.
Mapping the points
To get points values we do a bit of mapping from an tuple of 5 values to an object:
set points(value) {
if(typeof(value) === "string"){
value = JSON.parse(value);
}
value = value.map(p => ({
x: p[0],
y: p[1],
color: p[2] ?? pointDefaults.color,
size: p[3] ?? pointDefaults.size,
shape: p[4] ?? pointDefaults.shape
}));
this.#points = value;
this.render();
}
First we check if the value is a string, if so it probably came from an attribute and we need to convert it to an array. There's a lot of validation we could do but I think it's better to keep it simple. Then we turn the tuple into an an object and fill in the the omitted values with defaults. The reason we do it like this is because the user might pass in values from code, and it's way more efficient to do so. The JSON attribute is more of a handy shortcut if you just want to write raw HTML. pointDefaults
is just a plain object with size, shape and color for when we don't specify.
Drawing
Let's start by setting up our SVG:
//render(){
if(!this.shadowRoot){
this.attachShadow({ mode: "open" });
}
this.innerHTML = "";
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("width", this.#width);
svg.setAttribute("height", this.#height);
Nothing fancy. Remember SVGs need to use createElementNS. Next we need a background:
const background = document.createElementNS("http://www.w3.org/2000/svg", "rect");
background.setAttribute("width", this.#width);
background.setAttribute("height", this.#height);
background.setAttribute("fill", "white");
svg.appendChild(background);
I've chosen white, but you can choose whatever you want or even make it configurable. Next let's add some guides:
const guides = document.createElementNS("http://www.w3.org/2000/svg", "path");
guides.setAttribute("stroke-width", 1.0);
guides.setAttribute("stroke", "black");
guides.setAttribute("d", `M0,${this.#height / 2} H${this.#width} M${this.#width / 2},0 V${this.#height}`);
svg.appendChild(guides);
All this does is put a cross-hair on the center of the plot, note that we are not doing anything to say that this needs to be set at 0,0, that will all be controlled by the chart window attributes, it'll always be on whatever is the center is. If necessary we can remove this or fix it up to be more flexible.
Let's chart the points:
if (this.#func) {
} else if (this.#points) {
this.#points
.map((p, i) => createShape(
p.shape,
[
windowValue(p.x, this.#xmin, this.#xmax) * this.#width,
windowValue(p.y, this.#ymin, this.#ymax, true) * this.#height
],
p.size,
p.color))
.forEach(c => svg.appendChild(c));
We'll come back to the function part. Here we'll iterate through our points. For each we create a shape. Nearly all of the magic comes from a single function windowValue
.
function windowValue(v, vmin, vmax, flipped = false) {
v = flipped ? -v : v;
return (v - vmin) / (vmax - vmin);
}
windowValue
takes a value and figures out the ratio of where it falls between two other values. This is actually the exact opposite of finding the value of a ratio between two values (lerp) and is often referred to as a "reverse lerp." I've also added a handy "flipped" parameter to deal with drawing y-axis stuff. Once we have this ratio then we just multiply it times the height or width depending on which dimension we're measuring.
To draw the point we'll create the function createShape
:
function createShape(shape, [x, y], size, color){
switch(shape){
case "circle": {
const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
circle.setAttribute("cx", x);
circle.setAttribute("cy", y);
circle.setAttribute("r", size);
circle.setAttribute("fill", color);
return circle;
}
case "square": {
const halfSize = size / 2;
const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect");
rect.setAttribute("x", x - halfSize);
rect.setAttribute("y", y - halfSize);
rect.setAttribute("width", size);
rect.setAttribute("height", size);
rect.setAttribute("fill", color);
return rect;
}
}
}
Nothing fancy here. I've added two shapes, you can add more as you see fit.
All that's left is to append the SVG to the shadow DOM:
this.shadowRoot.appendChild(svg);
Here's how you can use it:
<wc-svg-graph points='[[10,10], [20,20], [30,30], [40,40], [10,20,"blue"], [20,30,"blue"], [70,95,"blue"],[-20,-20,"magenta", 5, "square"]]'></wc-svg-graph>
It looks like this:
And we have a simple scatterplot.
Continuous Functions
Continuous functions are also useful to draw. But how do we take in a function? Perhaps the simplest way is to literally take a js function.
set func(value) {
this.#func = new Function(["x"], value);
this.render();
}
This is super useful but considered very naughty because you are essentially opening yourself up to XSS. For some cases like data exploration that's not too much of an issue, but don't do this for apps used by other people. What this line does is construct a new function using value
as the body text. You can think of the "body" as the part of the function between the curly braces. The first parameter is the argument list, so in this case we have "x" representing the position along the x axis and we expect the function to return our y. These will be classic JS functions so you need a valid return value when you write the body or it won't work correctly.
Anyway with this we still want to be able to reuse all our point drawing code so we should add the ability to do that. I think the easiest way is to manually specify defaults and take those:
#defaultShape = "circle";
#defaultSize = 1;
#defaultColor = "#000"
//Remember to add to observed attributes!
set defaultSize(value){
this.#defaultSize = parseFloat(value);
}
set defaultShape(value) {
this.#defaultShape = value;
}
set defaultColor(value) {
this.#defaultColor = value;
}
I'm leaving shape and color without validation because it's easier but you can add that. Specifically color is hard because I want to accept color names and non-RGB values. We also need our old friend:
function hyphenCaseToCamelCase(text) {
return text.replace(/-([a-z])/g, g => g[1].toUpperCase());
}
attributeChangedCallback(name, oldValue, newValue) {
this[hyphenCaseToCamelCase(name)] = newValue;
}
To turn the hyphenated attribute names into camelCase properties.
And now we can set our own defaults without needed to verbosely set them in the point tuple. So back in render...
//...
if (this.#func) {
for (let x = this.#xmin; x < this.#xmax; x += this.#step) {
const y = this.#func(x);
const shape = createShape(
this.#defaultShape,
[
windowValue(x, this.#xmin, this.#xmax) * this.#width,
windowValue(y, this.#ymin, this.#ymax, true) * this.#height
],
this.#defaultSize,
this.#defaultColor
);
svg.appendChild(shape);
}
} else if (this.#points) { //...
We just nibble away at the function with the step value.
We can use it like this:
<wc-svg-graph func="return 75 * Math.sin(5 * x)" xmin="-3" xmax="3" step="0.01"></wc-svg-graph>
To produce this:
A continuous curve
Especially for functions plotting just the points isn't exactly what we want though with a small step we get pretty close. Lets say we had samples of a wave form but they might be a little more sparse and we want the function to look continuous what we'd really like to do is connect the dots. I'm going to do this with with a new attribute:
#continuous = false;
set continuous(value){
this.#continuous = value !== undefined;
}
Easy. Now we need to modify our drawing code. The connecting line will be a single SVG path. We're doing two different draw loops depending on the type and it would be nice to squish that into one so we don't need code for it twice.
/*drawing setup, background and guides, remain the same*/
let points;
if(this.#func){
points = [];
for (let x = this.#xmin; x < this.#xmax; x += this.#step) {
const y = this.#func(x);
points.push({ x, y, color: this.#defaultColor, size: this.#defaultSize, shape: this.#defaultShape});
}
} else {
points = this.#points;
}
points = points.map(p => ({
x: windowValue(p.x, this.#xmin, this.#xmax) * this.#width,
y: windowValue(p.y, this.#ymin, this.#ymax, true) * this.#height,
color: p.color,
size: p.size,
shape: p.shape
}));
for(const point of points){
const shape = createShape(
point.shape,
[point.x, point.y],
point.size,
point.color
);
svg.appendChild(shape);
}
this.shadowRoot.appendChild(svg);
First we get the absolute points, then we scale them down to chart space, then we draw the line. The output will be the same but because we've broken things into a single process of steps it's much easier to add things. Now we just put in a conditional step between the the chart space mapping and drawing:
if(this.#continuous){
const pathData = ["M"];
pathData.push(points[0].x.toFixed(2), points[0].y.toFixed(2));
for (let i = 1; i < points.length; i++) {
pathData.push("L", points[i].x.toFixed(2), points[i].y.toFixed(2));
}
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute("fill", "none");
path.setAttribute("stroke-width", 1.0);
path.setAttribute("stroke", this.#defaultColor);
path.setAttribute("d", pathData.join(" "));
svg.appendChild(path);
}
We're just drawing a path of lines connecting each point. I also limit the decimal to 2 place otherwise it gets huge. One downside of doing it in a single path is that we can't change the color per line segment. If you want that you should make individual lines segments and then you could use the previous point color or something. You can also make new attributes for stroke thickness. In fact if you add one more points to the end of the path to move down to the axis you can add a fill color for volumetric graphs. If you don't want points just set them to be size 0. It's all really easy to change. Anyway here's what it looks like with and without the continuous
attribute set:
So there we have scatterplots and line graphs and we didn't even need external dependencies. Next time we can look at more features, and maybe even try a WebGL implementation.
Top comments (2)
You can replace many of those appendChild with append
Thanks for the tip, I should use that one more.