DEV Community

loading...

Graphing with Web Components 4: CSS

ndesmic
I like to make fun web things from scratch. Ideally build-less, framework-less, infrastructure-less and free from the annoyances of my day job.
Updated on ・9 min read

So far we've done our chart 3 ways svg, canvas, and webgl. This time we'll try drawing things with CSS. This will require some more out-of-the-box thinking since CSS is much more specific and not geared toward general drawing.

Boilerplate

As always we start with this boilerplate:

function windowValue(v, vmin, vmax, flipped = false) {
    v = flipped ? -v : v;
    return (v - vmin) / (vmax - vmin);
}
function hyphenCaseToCamelCase(text) {
    return text.replace(/-([a-z])/g, g => g[1].toUpperCase());
}

class WcGraphSvg extends HTMLElement {
    #points = [];
    #width = 320;
    #height = 240;
    #xmax = 100;
    #xmin = -100;
    #ymax = 100;
    #ymin = -100;
    #func;
    #step = 1;
    #thickness = 1;
    #continuous = false;

    #defaultShape = "circle";
    #defaultSize = 2;
    #defaultColor = "#F00"

    static observedAttributes = ["points", "func", "step", "width", "height", "xmin", "xmax", "ymin", "ymax", "default-shape", "default-size", "default-color", "continuous", "thickness"];
    constructor() {
        super();
        this.bind(this);
    }
    bind(element) {
        element.attachEvents.bind(element);
    }
    render() {
        if (!this.shadowRoot) {
            this.attachShadow({ mode: "open" });
        }
        this.shadowRoot.innerHTML = "";

        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;
        }
    }
    attachEvents() {

    }
    connectedCallback() {
        this.render();
        this.attachEvents();
    }
    attributeChangedCallback(name, oldValue, newValue) {
        this[hyphenCaseToCamelCase(name)] = newValue;
    }
    set points(value) {
        if (typeof (value) === "string") {
            value = JSON.parse(value);
        }

        value = value.map(p => ({
            x: p[0],
            y: p[1],
            color: p[2] ?? this.#defaultColor,
            size: p[3] ?? this.#defaultSize,
            shape: p[4] ?? this.#defaultShape
        }));

        this.#points = value;

        this.render();
    }
    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 func(value) {
        this.#func = new Function(["x"], value);
        this.render();
    }
    set step(value) {
        this.#step = parseFloat(value);
    }
    set defaultSize(value) {
        this.#defaultSize = parseFloat(value);
    }
    set defaultShape(value) {
        this.#defaultShape = value;
    }
    set defaultColor(value) {
        this.#defaultColor = value;
    }
    set continuous(value) {
        this.#continuous = value !== undefined;
    }
    set thickness(value) {
        this.#thickness = parseFloat(value);
    }
}

customElements.define("wc-graph-css", WcGraphCss);

Enter fullscreen mode Exit fullscreen mode

It's like the SVG but I've torn out the drawing code.

It's also worth mentioning that a CSS approach probably wouldn't draw on the client like this or require a custom element. I'm just doing this because it's easy to encapsulate it and it's easy to compare to the other versions but you really do want to pre-render this on the server as plain markup to get the benefits.

Representing a chart in DOM

Perhaps the best way to represent a chart in DOM is a table. It's already accessible so provided we can figure out how to style it we have a good fallback that could work even with a text-only browser, something the others can't easily do.

We'll print out the values right after the mapping a scaling, but first we need to forward the original Y value as it will be used in the table.

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,
    value: p.y,
    color: p.color,
    size: p.size,
    shape: p.shape
}));
Enter fullscreen mode Exit fullscreen mode

Then print the table:

const table = document.createElement("table");
const tbody = document.createElement("tbody");
table.append(tbody);
for (const point of points) {
    const tr = document.createElement("tr");
    const td = document.createElement("td");
    td.textContent = point.value;
    tr.append(td);
    tbody.append(tr);
}
table.append(tbody);
this.shadowRoot.append(table);
Enter fullscreen mode Exit fullscreen mode

Now we have a simple table that has a single cell per row with the value.

Displaying the table as a chart

First, let's use the height and width for the table:

table.style.width = this.#width + "px";
table.style.height = this.#height + "px";
Enter fullscreen mode Exit fullscreen mode

And then we'll insert our CSS file:

const style = document.createElement("link");
style.rel = "stylesheet";
style.href = "./wc-graph-css.css";
this.shadowRoot.append(style);
Enter fullscreen mode Exit fullscreen mode

From here we can put our attention on modifying the CSS.

The next thing we want to do is change from rows to columns. Rows are good for reading but most charts represent data on X/Y axes where Y is the dependent variable. So we need to remove the table display on the elements and lay them out horizontally.

table {
    display: inline-block;
}
tbody {
    display: grid;
    height: 100%;
}
tr {
    display: block;
    height: 100%;
}
td {
    display: block;
    height: 100%;
    width: 100%;
}
Enter fullscreen mode Exit fullscreen mode

This will lay them out horizontally using grid (and reset all the table styles) but how big should the columns be?

When we windowed we did scale each point to the correct amount but instead of absolute points on a canvas we need to build up the set of column widths. We can do this by calculating a new property which is the distance between the last column. For the very first column where there is no previous we'll simply take the distance to 0.

Before that we need to sort the points in case they were out of order:

points.sort((a,b) => b.x - a.x).reverse();
Enter fullscreen mode Exit fullscreen mode

Then we calculate the width of each segment:

points = points.map((p, i, arr) => ({
    ...p,
    ...{
        width: p.x - (arr[i - 1]?.x ?? 0)
    }
}));
Enter fullscreen mode Exit fullscreen mode

We're just doing a second iteration because it's easier once we already have the scaled x coordinates.

Now that the trs are lined up in the proper horizontal position we can then position the tds vertically:

tr {
    display: block;
    height: 100%;
    position: relative;
}
td {
    display: block;
    position: absolute;
    width: 100%;
}
Enter fullscreen mode Exit fullscreen mode
td.style.backgroundColor = point.color;
td.style.top = (point.y - point.size) + "px";
td.style.right = point.size * -1 + "px";
td.style.height = point.size + "px";
td.style.width = point.size + "px";
td.textContent = point.value;
Enter fullscreen mode Exit fullscreen mode

The points align on the right side of the column but we need to add (subtract since the right property is from the right side) the point radius (size) so that the point is centered horizontally. To align vertically we need to subtract the point radius from the y coordinate.

Here's a good point to stop and look at what we've done:

Screenshot 2021-05-23 113251

Looking good, plus we got labels for free. We can also add the stupid guides which every time I implement them I realize I had no idea where I was going with them (static interface pieces I never really did anything with). Since we're sticking to CSS let's to a CSS-centric way of drawing them, linear gradients!

table {
    display: block;
    background: linear-gradient(90deg, transparent, transparent calc(50% - 1px), black 50%, black 50%, transparent calc(50% + 1px), transparent), linear-gradient(0deg, transparent, transparent calc(50% - 1px), black 50%, black 50%, transparent calc(50% + 1px), transparent);
       font-size: 0;
}
Enter fullscreen mode Exit fullscreen mode

We can also slip in a font-size = 0 to hide the text labels so they look like the other versions. I also just realized a mistake I made in the previous APIs, size is interpreted as a width for rectangles and radius for circles which is why rectangles were always smaller. Oops. The magenta points is supposed to have a size of 2.5 which if we interpret like a radius would make a 5px square. We can abstract out the shape drawing like before:

function createShape(shape, [x, y], size, color, value){
    const td = document.createElement("td");
    td.style.backgroundColor = color;
    td.style.top = (y - size) + "px";
    td.style.height = size * 2 + "px";
    td.style.width = size * 2 + "px";
    td.style.right = point.size * -1 + "px";;
    td.textContent = value;

    switch(shape){
        case "circle": {
            td.style.borderRadius = size + "px";
        }
    }

    return td;
}
Enter fullscreen mode Exit fullscreen mode

For circles we just give half the width/height to the border radius (note that border-radius: 50% is based on the parent width and is pretty much never what you want). And let's compare:

Screenshot 2021-05-23 134814

It's visually identical with just CSS!

Custom properties

This is all fairly conventional but we want to move more of the code into CSS. This not only lets use change things up with just CSS but it also gives us access to another things we'll need later, pseudo-elements, as these cannot be directly set from JS. This can be done by leveraging CSS custom properties:

function createShape(shape, [x, y], size, color, value){
    const td = document.createElement("td");
    td.style.setProperty("--y", y + "px");
    td.style.setProperty("--size", size + "px");
    td.style.setProperty("--color", color);
    td.textContent = value;

    switch(shape){
        case "circle": {
            td.classList.add("circle");
        }
    }

    return td;
}
Enter fullscreen mode Exit fullscreen mode

Now we're setting no actually CSS directly, just giving some values so that the CSS can do it's thing.

td {
    display: block;
    position: relative;
    padding: 0;
    height: 100%;
    width: 100%;
}
td::after {
    content: "";
    display: block;
    position: absolute;
    top: calc(var(--y) - var(--size));
    height: calc(var(--size) * 2);
    width: calc(var(--size) * 2);
    background-color: var(--color);
        right: calc(var(--size) * -1);
}
td.circle::before {
    border-radius: var(--size);
}
Enter fullscreen mode Exit fullscreen mode

Now the td takes up the same space at the tr and the ::before pseudo element defines the point. We can use calcs to bring the calculations into CSS and we can use a class to determine the shape. This is much more CSS-centric and should look the exact same.

Continuous paths

Now here's where things get really interesting. We want lines to connect the dots but HTML elements tend to be squares so how could we do this? One way is to shape a div using clip-path which is how chart.css does it. We can shape the ::before pseudo element because it's stays in the background while the ::after "point" sits on top. We can't use the td because if we clip it then the point will get clipped too.

To get the proper clip path we need the previous Y value as well as the current Y value. Where we get the width we can add that as well:

points = points.map((p, i, arr) => ({
    ...p,
    ...{
        width: p.x - (arr[i - 1]?.x ?? 0),
        previousY: (arr[i - 1]?.y ?? 0)
    }
}));
Enter fullscreen mode Exit fullscreen mode

We also need to add it as a custom property in createShape (and take it in as a parameter):

td.style.setProperty("--prev-y", previousY + "px");
Enter fullscreen mode Exit fullscreen mode

And here's the CSS for everything under the td:

td {
    display: block;
    position: relative;
    padding: 0;
    height: 100%;
    width: 100%;
}
td::before {
    content: "";
    display: block;
    height: 100%;
    width: 100%;
    background-color: magenta;
    clip-path: polygon(0 var(--prev-y), 100% var(--y), 100% 100%, 0 100%);
}
tr:first-child td::before {
    content: none;
}
td::after {
    content: "";
    display: block;
    position: absolute;
    top: calc(var(--y) - var(--size));
    height: calc(var(--size) * 2);
    width: calc(var(--size) * 2);
    background-color: var(--color);
    right: right: calc(var(--size) * -1);
}
td.circle::after {
    border-radius: var(--size);
}
Enter fullscreen mode Exit fullscreen mode

Note that selector for tr:first-child td::before. We don't want to draw the first segment. And here's what happens:

Screenshot 2021-05-23 170629

We could turn this into some sort of volume chart. But to get the lines we need to shape the bottom as well.

td::before {
    content: "";
    display: block;
    height: 100%;
    width: 100%;
    background-color: magenta;
    clip-path: polygon(
        0 calc(var(--prev-y) - var(--size)), 
        100% calc(var(--y) - var(--size)), 
        100% calc(var(--y) - var(--size) + 1px), 
        0 calc(var(--prev-y) - var(--size) + 1px)
    );
}
Enter fullscreen mode Exit fullscreen mode

Unlike other ways of doing things we can either use individual points to color the line or a default color. Here I've chosen to use the default color for consistency with the SVG version. I've also added the thickness. At the same time we'll make continuous a class on the table (we could use the property but again the custom element is more for encapsulation).

table.style.setProperty("--default-color", this.#defaultColor);
table.style.setProperty("--thickness", this.#thickness + "px");
if(this.#continuous){
    table.classList.add("continuous");
}
Enter fullscreen mode Exit fullscreen mode

We can now update the CSS:

table.continuous td::before {
    content: "";
    display: block;
    height: 100%;
    width: 100%;
    background-color: var(--default-color);
    clip-path: polygon(
        0 calc(var(--prev-y) - var(--size) - var(--thickness) / 2), 
        100% calc(var(--y) - var(--size) - var(--thickness) / 2), 
        100% calc(var(--y) - var(--size) + var(--thickness) / 2), 
        0 calc(var(--prev-y) - var(--size) + var(--thickness) / 2)
    );
}
Enter fullscreen mode Exit fullscreen mode

Unfortunately, the thickness doesn't quite work:

Screenshot 2021-05-23 175746

The problem is that we're only expanding on the y-axis. There's actually no way to really improve with just CSS...yet. At least as of this post CSS trigonometric functions aren't available so we can't do it today.

Another deficiency is that we need to have continuous functions. We can't actually represent 2 Ys for the same x. Also sometimes CSS rounding isn't quite as accurate as SVGs. If you see some artifacts that's why. Still most of the stuff works, it's accessible and can be directly controlled in CSS.

Discussion (0)