DEV Community

Masui Masanori
Masui Masanori

Posted on

[C3.js][TypeScript] Save C3.js charts as images

Intro

Last time, I save C3.js charts as images.

But all of the CSS values were ignored.
So this time, I will try using CSS to save images.

Get stylesheet values

I can't get stylesheet values by TypeScript.

sample.html

<!DOCTYPE html>
<html lang="en">
    <head>
        <title>chart sample</title>
        <meta charset="utf-8">
        <link href="css/sample.css" rel="stylesheet" />
    </head>
    <body>
        <div id="sample_target">Hello</div>
        <script src="./js/sample.js"></script>
        <script>Page.init();</script>
    </body>
</html>
Enter fullscreen mode Exit fullscreen mode

sample.css

#sample_target {
    background-color: red;
}
Enter fullscreen mode Exit fullscreen mode

sample.ts

export function init(): void {
    const sample = document.getElementById("sample_target") as HTMLElement;
    console.log(sample.style.background);
    console.log(sample.style.backgroundColor);
...
}
Enter fullscreen mode Exit fullscreen mode

Result(WebBrowser)

Image description

Result(Console)



Enter fullscreen mode Exit fullscreen mode

This is because the images what are created from SVG don't have grid lines.

To get CSS values, I can use "document.styleSheets".

Each elements have stylesheet values per file.
Because I can get null from "document.styleSheets[0].title", I use "document.styleSheets[0].href" to find the target stylesheet file.

index.html

<!DOCTYPE html>
<html lang="en">
    <head>
        <title>chart sample</title>
        <meta charset="utf-8">
        <link href="css/chart.page.css" rel="stylesheet" />
        <link href="css/c3.css" rel="stylesheet" />
    </head>
    <body>
        <div id="chart_root"></div>
        <script src="./js/main.page.js"></script>
        <script>Page.init();</script>
    </body>
</html>
Enter fullscreen mode Exit fullscreen mode

chart.page.css

.solid_line line {
    stroke: #000000;
    stroke-dasharray: 1 0;
    stroke-linecap: round;
}
.dashed_line line {
    stroke: #9f9f9f;
    stroke-dasharray: 2 5;
    stroke-linecap: round;
}
Enter fullscreen mode Exit fullscreen mode

c3.css

@import url("../../node_modules/c3/c3.min.css");
Enter fullscreen mode Exit fullscreen mode

chartViewer.ts

...
    public saveImage(): void {
...
        const styleSheet = this.getStyleSheet("chart.page.css");
...
    }
...
    private getStyleSheet(targetName: string): CSSStyleSheet|null {
        for(let i = 0; i < document.styleSheets.length; i++) {
            const styleSheet = document.styleSheets[i];

            if(styleSheet?.href == null) {
                continue;
            }
            if(styleSheet.href.endsWith(targetName)) {
                return styleSheet;
            }
        }
        return null;
    }
...
Enter fullscreen mode Exit fullscreen mode

Get setting stylesheet values targets

Last time, I set styles to all lines of the chart.
In this time, I try separating them to set each styles.

Get class names and ids from CSSStyleSheet

I have to get class names and ids from "CSSStyleSheet" to find DOM targets.
I can get them from "document.styleSheets[0].cssRules".

One problem is because I only can get "CSSRule" from "document.styleSheets[0].cssRules[0]" and the type doesn't have "selectorText".

So I have to use type narrowing of TypeScript.

chartViewer.ts

...
    public saveImage(): void {
        const svg = this.getSvgRoot();
        if(svg == null) {
            console.error("svg was null");            
            return;
        }
        const styleSheet = this.getStyleSheet("chart.page.css");
        if(styleSheet == null) {
            console.error("styleSheet was null");            
            return;
        }
        this.setLineStyles(svg, styleSheet, "solid_line");
        this.setLineStyles(svg, styleSheet, "dashed_line");
...
    }
...
    private setLineStyles(svg: SVGElement, styleSheet: CSSStyleSheet, targetName: string): void {
        const lineElements = svg.querySelectorAll(`.c3 .${targetName} line`);
        if(lineElements.length <= 0) {
            return;
        }
        for(let i = 0; i < styleSheet.cssRules.length; i++) {
            const rule = styleSheet.cssRules[i];
            if(this.isCSSDeclaration(rule)) {
                if(rule.selectorText.includes(targetName)) {
                    for(let k = 0; k < lineElements.length; k++) {
                        const line = lineElements[k] as any;
                        if(line == null) {
                            continue;
                        }       
                        if("style" in line &&
                                line.style instanceof CSSStyleDeclaration) {
                            line.style.stroke = rule.style.stroke;
                            line.style.strokeDasharray = rule.style.strokeDasharray;
                            line.style.strokeLinecap = rule.style.strokeLinecap;
                        }
                    }
                }
            }
        }
    }
    private isCSSDeclaration(obj: any): obj is CSSStyleRule {
        if(obj == null) {
            return false;
        }
        if(((obj instanceof Object) &&
            ("selectorText" in obj) &&
            ("style" in obj)) == false) {
            return false;
        }
        if(typeof obj.selectorText !== "string") {
            return false;
        }
        if(obj.style instanceof CSSStyleDeclaration == false) {
            return false;
        }
        return true;
    }
...
Enter fullscreen mode Exit fullscreen mode

Full code(chartViewer.ts)

chartViewer.ts

import c3 from "c3";
import { ChartValues } from "./chart.type";

export class ChartViewer {
    private chartElement: HTMLElement;
    private chart: c3.ChartAPI|null = null;
    public constructor(root: HTMLElement) {
        this.chartElement = document.createElement("div");
        root.appendChild(this.chartElement);
    }
    public draw(value: ChartValues): void {
        const valueXList = this.getValueX(0, 10);
        const ticksX = this.getTicks(valueXList);
        const valueYList = value.values.map(v => v.y);
        const gridLineY = valueYList.map(t => this.generateGridLine(t));
        const gridLines = valueXList.map(t => this.generateGridLine(t));

        this.chart = c3.generate({
            bindto: this.chartElement,
            data: {
                x: "x",
                columns: [
                    ["data1", ...value.values.map(v => v.y)],
                    ["x", ...value.values.map(v => v.x)],
                ],
                types: {
                    data1: "line"
                },
            },
            axis: {
                x: {
                    min: 0,
                    max: 10,
                    tick: {
                        values: [...ticksX],
                        outer: false,
                    },
                    padding: { left: 0, }
                },
                y: {
                    min: 0,
                    padding: { bottom: 0, }
                }
            },
            grid: {
                x: {
                    show: false,
                    lines: [...gridLines],
                },
                y: {
                    show: false,
                    lines: [...gridLineY],
                }
            },
            interaction: {
                enabled: false,
            },
        });
    }
    public saveImage(): void {
        const svg = this.getSvgRoot();
        if(svg == null) {
            console.error("svg was null");            
            return;
        }        
        const styleSheet = this.getStyleSheet("chart.page.css");
        if(styleSheet == null) {
            console.error("styleSheet was null");            
            return;
        }
        this.setLineStyles(svg, styleSheet, "solid_line");
        this.setLineStyles(svg, styleSheet, "dashed_line");

        const chartPaths = svg.querySelectorAll(".c3-chart path");
        for(let i = 0; i < chartPaths.length; i++) {
            const path: any = chartPaths[i];
            if(this.hasStyle(path)) {
                path.style.fill = "none";
                path.style.stroke = "green";
            }
        }
        const lines = svg.querySelectorAll(".c3-axis line");
        const nodes = svg.querySelectorAll(".c3-axis path");
        const gridLines = Array.from(nodes).concat(Array.from(lines));
        for(let i = 0; i < gridLines.length; i++) {
            const line: any = gridLines[i];
            if(this.hasStyle(line)) {
                line.style.fill = "none";
                line.style.stroke = "red";
            }
        }
        const serializedImage = new XMLSerializer().serializeToString(svg);
        const image = new Image();
        image.onload = () => {
            const canvas = document.createElement("canvas");
            canvas.width = this.chartElement.clientWidth;
            canvas.height = this.chartElement.clientHeight;
            const ctx = canvas.getContext("2d");
            if(ctx == null) {
                console.error("ctx was null");
                return;
            }
            ctx.drawImage(image, 0, 0);
            document.body.appendChild(canvas);
        };
        image.src = "data:image/svg+xml;charset=utf-8;base64," + window.btoa(serializedImage);
    }
    private getStyleSheet(targetName: string): CSSStyleSheet|null {
        for(let i = 0; i < document.styleSheets.length; i++) {
            const styleSheet = document.styleSheets[i];

            if(styleSheet?.href == null) {
                continue;
            }
            if(styleSheet.href.endsWith(targetName)) {
                return styleSheet;
            }
        }
        return null;
    }
    private setLineStyles(svg: SVGElement, styleSheet: CSSStyleSheet, targetName: string): void {
        const lineElements = svg.querySelectorAll(`.c3 .${targetName} line`);
        if(lineElements.length <= 0) {
            return;
        }
        for(let i = 0; i < styleSheet.cssRules.length; i++) {
            const rule = styleSheet.cssRules[i];
            if(this.isCSSDeclaration(rule)) {
                if(rule.selectorText.includes(targetName)) {
                    for(let k = 0; k < lineElements.length; k++) {
                        const line = lineElements[k] as any;
                        if(line == null) {
                            continue;
                        }       
                        if("style" in line &&
                                line.style instanceof CSSStyleDeclaration) {
                            line.style.stroke = rule.style.stroke;
                            line.style.strokeDasharray = rule.style.strokeDasharray;
                            line.style.strokeLinecap = rule.style.strokeLinecap;
                        }
                    }
                }
            }
        }
    }
    private getValueX(from: number, to: number): readonly number[] {
        const results: number[] = [];
        for(let i = from; i <= to; i++) {
            if(i < to) {
                for(let j = 0.0; j < 1.0; j += 0.1) {
                    results.push(i + j);
                }
            }
        }
        return results;
    }
    private getTicks(values: readonly number[]): readonly string[] {
        const results: string[] = [];
        for(const v of values) {
            if(v === (Math.trunc(v))) {
                results.push(v.toString());
            } else {
                results.push("");
            }    
        }
        return results;
    }
    private generateGridLine(value: number): { value: string, class: string } {
        let lineClass = "";
        if(value === (Math.trunc(value))) {
            lineClass = "solid_line";
        } else {
            lineClass = "dashed_line";
        }
        return {
            value: value.toString(),
            class: lineClass,
        };
    }
    private getSvgRoot(): SVGElement|null {
        for(let i = 0; i < this.chartElement.children.length; i++) {
            if(this.chartElement.children[0] == null) {
                continue;
            }
            if(this.chartElement.children[0].tagName === "svg") {
                return this.chartElement.children[0] as SVGElement;
            }            
        }
        return null;
    }
    private isCSSDeclaration(obj: any): obj is CSSStyleRule {
        if(obj == null) {
            return false;
        }
        if(((obj instanceof Object) &&
            ("selectorText" in obj) &&
            ("style" in obj)) == false) {
            return false;
        }
        if(typeof obj.selectorText !== "string") {
            return false;
        }
        if(obj.style instanceof CSSStyleDeclaration == false) {
            return false;
        }
        return true;
    }
    private hasStyle(obj: any): obj is { style: CSSStyleDeclaration } {
        if((obj instanceof Object &&
            "style" in obj) === false ) {
            return false;
        }
        return (obj.style instanceof CSSStyleDeclaration);
    }
}
Enter fullscreen mode Exit fullscreen mode

Result

Image description

Discussion (0)