[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>

sample.css

#sample_target {
    background-color: red;
}

sample.ts

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

Result(WebBrowser)

Result(Console)

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>

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;
}

c3.css

@import url("../../node_modules/c3/c3.min.css");

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;
    }
...

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;
    }
...

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);
    }
}

Result

34