zenozeng / svgcanvas

Draw on SVG using Canvas's 2D Context API
MIT License
66 stars 19 forks source link

C2S doesn't work with new version of Chart.js : Attempted to apply path command to node g #25

Open denis-migdal opened 2 years ago

denis-migdal commented 2 years ago

I got several issue trying to use C2S to export a graph generated with Chart.js

On stackoverflow, I found out that C2S was lacking some method (getContext, style, getAttribute, and addEventListener) (cf below).

However, new versions of Chart.js still seems to be doing something that C2S doesn't like.

Initially I used a npm package that seems to work (but without any git associated) at the exception of setTransform and resetTransform that weren't implemented ( https://www.npmjs.com/package/canvas-to-svg ). This npm package seems to be maintained by @rob-gordon (https://github.com/rob-gordon), and I think is from this git https://github.com/tone-row/canvas-to-svg

I then tried several versions of C2S. Yours seems to be one of the most maintained. Only the grid is drawn, and I don't see any error in the console (cf below) : "Attempted to apply path command to node g"

Would you know how I could make it works ?

Missing methods :


ctx.getContext = function (contextId) {
          if (contextId=="2d" || contextId=="2D") {
              return this;
          }
          return null;
}

ctx.style = function () {
          return this.__canvas.style
}

ctx.getAttribute = function (name) {
          return this[name];
}
//ctx.setTransform = function() {}
//ctx.resetTransform = function() {}

ctx.addEventListener =  function(type, listener, eventListenerOptions) {
          console.log("canvas2svg.addEventListener() not implemented.")
}

Minimal example :

import {Chart, LinearScale, ScatterController, PointElement, LineElement} from 'chart.js';
Chart.register(ScatterController, PointElement, LineElement, LinearScale);

import { Context } from "./svgcanvas-issue-fill-path/";

let config = {
        data: {
        datasets: [{
            type:'scatter',
            showLine: true,
            data: [ [3,0], [3,1],[5,5],[7,6],[12,1], [19,2]]
        }],
    },

        options: {
            animation: false,
            responsive:false,
            maintainAspectRatio: false
        }
    };

let ctx = new Context(500,500);

// @ts-ignore
ctx.getContext = function (contextId) {
          if (contextId=="2d" || contextId=="2D") {
              return this;
          }
          return null;
}

// @ts-ignore
ctx.style = function () {
          return this.__canvas.style
}

// @ts-ignore
ctx.getAttribute = function (name) {
          return this[name];
}
//ctx.setTransform = function() {}
//ctx.resetTransform = function() {}

// @ts-ignore
ctx.addEventListener =  function(type, listener, eventListenerOptions) {
          console.log("canvas2svg.addEventListener() not implemented.")
}

//let canvas = document.getElementById('test');
//new Chart(canvas.getContext('2d'), config);

new Chart(ctx, config);

let str = ctx.getSerializedSvg();

//download(str, 'test.svg', 'image/svg+xml');

Expected :

canvas

Result : test(52)

denis-migdal commented 2 years ago

Seems to be related to this issue : https://github.com/gliffy/canvas2svg/issues/44

denis-migdal commented 2 years ago

It seems that the issue is when Chart.js is trying to draw lines.

I found a dirty dirty workaroud : instead of just drawing a line, drawing 2 lines and ask Charts JS to fill the space between the 2 to make a line... that's dirty... but it works.

I'll post the workaround here, but I think fixing C2S still would be great.

denis-migdal commented 2 years ago

A little workaround function that does the trick until C2S is fixed.

img

There are little artifacts as I need to do better segment merging

// @ts-ignore
import { Context } from "svgcanvas";

import {Chart, ChartConfigurationCustomTypesPerDataset, LinearScale, ScatterController, PointElement, LineElement} from 'chart.js';
Chart.register(ScatterController, PointElement, LineElement, LinearScale);

function patch_linedata(data: number[][], width: number): {upline: [number,number][], downline: [number,number][]} {

    let dw = width/2;

    let segments = data.map( (_,idx) => [data[idx], data[idx+1]]);
    --segments.length;

    let angles = segments.map( ([a, b]) => {

        let dx = b[0] - a[0];
        let dy = b[1] - a[1];

        return Math.atan(dy/dx);
    });

    let upline   = new Array(segments.length*2);
    let downline = new Array(segments.length*2);
    for(let i = 0; i < segments.length; ++i) {

        let langle = Math.PI - (angles[i] + Math.PI/2);

        let dx =   Math.cos(langle) * dw;
        let dy = - Math.sin(langle) * dw;

        for(let j = 0; j < segments[i].length; ++j) {
            let point = segments[i][j];
            upline  [2*i+j] = [point[0]+dx, point[1]+dy];
            downline[2*i+j] = [point[0]-dx, point[1]-dy];
        }
    }

    if( upline[0][0] < downline[0][0] )
        downline.unshift( upline[0] );
    else
        upline  .unshift( downline[0] );

    let last_up   = upline  [upline  .length-1];
    let last_down = downline[downline.length-1];
    if( last_up[0] > last_down[0] )
        downline.push( last_up );
    else
        upline  .push( last_down );

    return {
        upline,
        downline
    }
}
function patch_context(ctx: Context) {

    // @ts-ignore
    ctx.getContext = function (contextId) {
        if (contextId=="2d" || contextId=="2D") {
            return this;
        }
        return null;
    }

    // @ts-ignore
    ctx.style = function () {
        return this.__canvas.style
    }

    // @ts-ignore
    ctx.getAttribute = function (name) {
        return this[name];
    }
    //ctx.setTransform = function() {}
    //ctx.resetTransform = function() {}

    // @ts-ignore
    ctx.addEventListener =  function(type, listener, eventListenerOptions) {
        console.log("canvas2svg.addEventListener() not implemented.")
    }
}
function patch_line(line_cfg: any,
                    config: ChartConfigurationCustomTypesPerDataset,
                    scales: any
                    ) {

    let data = line_cfg.data.map( (point: [number,number]) => [
                                    scales.x.getPixelForValue(point[0]),
                                    scales.y.getPixelForValue(point[1])
                                ]);

    let {upline, downline} = patch_linedata(data, line_cfg.borderWidth ?? 3); // 3 is default for line/scatter

    function px2val( data: [number, number][] ): [number,number][] {

        return data.map( point => [
                scales.x.getValueForPixel(point[0]),
                scales.y.getValueForPixel(point[1])
            ]);
    }

    let upline_cfg    = Object.assign({}, line_cfg,{
            data: px2val(upline),
            pointRadius: 0,
            fill: {
                target: '+1',
                above: line_cfg.borderColor,
                below: line_cfg.borderColor,
            }
        });

    let downline_cfg  = Object.assign({}, line_cfg, {
        data: px2val(downline),
        pointRadius: 0,
        fill: {
            //target: 'origin',
            //above: 'blue'
        }
    });

    config.data.datasets.push(upline_cfg, downline_cfg);
}
function patch_graph(config: ChartConfigurationCustomTypesPerDataset, scales: any) {

    let lines = config.data.datasets.filter(d => (d as any).showLine === true);

    for(let line of lines)
        patch_line(line, config, scales);
}

export function G2S() {

    let config: ChartConfigurationCustomTypesPerDataset = {
        data: {
            datasets: [{
                type:'scatter',
                showLine: true,
                borderColor: 'red',
                //borderWidth: 10,
                data: [ [3,0.2], [3,1],[5,5],[7,6],[12,1], [19,2], [20,2]]
            }],
        },
        options: {
            animation: false,
            responsive:false,
            maintainAspectRatio: false
        }
    };

    // compute scales...
    let ctx_o = new Context(500,500);
    patch_context(ctx_o);
    let chart = new Chart(ctx_o, config);

    let scales = chart.scales;

    let ctx = new Context(500,500);
    patch_context(ctx);
    patch_graph(config, scales);
    new Chart(ctx, config);

    return ctx.getSerializedSvg();
}
denis-migdal commented 2 years ago

With better segment merging to remove the artifacts.

result

Hopes it'll help someone someday ;)


function patch_linedata(data: number[][], width: number): {upline: [number,number][], downline: [number,number][]} {

    let dw = width/2;

    let segments = data.map( (_,idx) => [data[idx], data[idx+1]]);
    --segments.length;

    let angles = segments.map( ([a, b]) => {

        let dx = b[0] - a[0];
        let dy = b[1] - a[1];

        return Math.atan(dy/dx);
    });

    let upline   = new Array(segments.length*2);
    let downline = new Array(segments.length*2);
    for(let i = 0; i < segments.length; ++i) {

        let langle = Math.PI - (angles[i] + Math.PI/2);

        let dx =   Math.cos(langle) * dw;
        let dy = - Math.sin(langle) * dw;

        for(let j = 0; j < segments[i].length; ++j) {
            let point = segments[i][j];
            upline  [2*i+j] = [point[0]+dx, point[1]+dy];
            downline[2*i+j] = [point[0]-dx, point[1]-dy];
        }
    }

    function merge_segments(line : [number,number][]) {

        let result: [number,number][] = new Array(line.length/2+1);

        result[0] = line[0];
        result[result.length-1] = line[line.length-1];

        for(let i = 1; i < line.length/2; ++i) {

            type Segment = [ [number,number],[number,number] ];

            let seg1: Segment = [line[(i-1)*2], line[(i-1)*2+1]];
            let seg2: Segment = [line[i*2]    , line[i*2+1]];

            // y = ax + b
            function calcParams(seg: Segment) {

                let dx = seg[1][0] - seg[0][0];
                let dy = seg[1][1] - seg[0][1];

                let a = dy/dx;
                let b = seg[0][1] - a * seg[0][0];

                return [a, b];
            }

            let [a1, b1] = calcParams(seg1);
            let [a2, b2] = calcParams(seg2);

            if(a1 === a2 || Number.isNaN(a1 - b2) ) {
                result[i] = seg2[0];
                continue;
            }

            if( a1 === Number.POSITIVE_INFINITY || a1 === Number.NEGATIVE_INFINITY ) {
                let x = seg1[0][0];
                let y = a2 * x + b2;
                result[i] = [ x , y ];
                continue;
            }
            if( a2 === Number.POSITIVE_INFINITY || a2 === Number.NEGATIVE_INFINITY ) {
                let x = seg2[0][0];
                let y = a1 * x + b1;
                result[i] = [ x , y ];
                continue;
            }

            let x = -(b1-b2)/(a1-a2);
            let y = a1 * x + b1;

            result[i] = [ x, y ];
        }

        return result;
    }

    upline   = merge_segments(upline);
    downline = merge_segments(downline);

    if( upline[0][0] < downline[0][0] )
        downline.unshift( upline[0] );
    else
        upline  .unshift( downline[0] );

    let last_up   = upline  [upline  .length-1];
    let last_down = downline[downline.length-1];
    if( last_up[0] > last_down[0] )
        downline.push( last_up );
    else
        upline  .push( last_down );

    return {
        upline,
        downline
    }
}
Dean-NC commented 1 year ago

@denis-migdal Thanks very much for posting this. I'm using chart.js and jsPdf, and the PDF quality is good when printed (I use chart.js option devicePixelRatio: 2), but not great when viewing PDF on screen and zooming PDF. I'm considering SvgCanvas, but I see you found issues. Did your latest patch code work OK, or did you find more issues? I have scatter and bar charts, and only 1 chart with line (bar/line combo). However, if the only way to get SVG to PDF is going through canvas, then this might not provide any benefit to me.

ShreyaseeKamble commented 11 months ago

@denis-migdal I tried to replicate the same solution, but did not work in my case. Still the library gives same error for line and Radar chart.

This is the SVG which is getting generated in my case,

g path missing

denis-migdal commented 11 months ago

@denis-migdal Did your latest patch code work OK, or did you find more issues?

For my usage it seemed to work OK.

denis-migdal commented 11 months ago

@denis-migdal I tried to replicate the same solution, but did not work in my case. Still the library gives same error for line and Radar chart.

Can you provide a minimal example of your code reproducing it so I can test it ? It seems to work for my usage, but maybe there are other things causing issues.

ShreyaseeKamble commented 11 months ago

I won't be able to share the code here but when I tried to debug this code further in my case, I found following,

As we have referred scales as, let scales = chart.scales;

Inside patch_line function, issue

The data is coming as, issue2

Another approach, I tried to initailized scales with, let scales = config.options.scales; But this give me this error,

issue4

Could you please help me understand what should be passed as inside scales for this logic to work correctly in my case?

denis-migdal commented 11 months ago

I think you made a mistake when copying my code.

Indeed, I do :

let data = line_cfg.data.map( (point: [number,number]) => [
                                    scales.x.getPixelForValue(point[0]),
                                    scales.y.getPixelForValue(point[1])
                                ]);

But you wrote :

let data = line_cfg.data.map( (point: [number,number]) => {[
                                    scales.x.getPixelForValue(point[0]),
                                    scales.y.getPixelForValue(point[1])
                                ]});

Yeah took me quite sometime to see it xD.

ShreyaseeKamble commented 10 months ago

Not a mistake, I was debugging the code and to add the debugger Ihad added brackets to make it a function but anyways even after removing, It gives same error.

denis-migdal commented 10 months ago

Not a mistake, I was debugging the code and to add the debugger Ihad added brackets to make it a function

If you add bracket you also need to add a return.

after removing, It gives same error.

To be fair I do not really understand how C2S is working internally, I just noticed lines weren't printed but area fill was, so I replaced the lines by area filling.

I'll advise you to try using it on empty graph, then adding elements little by little to see exactly what is causing the issue.

ShreyaseeKamble commented 10 months ago

Sure will try to figure out the exact problem and solve it. Thanks!

jiayihu commented 8 months ago

See my comment in the original repo for an alternative on how to hotfix this: https://github.com/gliffy/canvas2svg/issues/44#issuecomment-1961132569

denis-migdal commented 8 months ago

See my comment in the original repo for an alternative on how to hotfix this: gliffy#44 (comment)

Thanks, I'll try to test that someday. My temporary fix was a very dirty one as I do not really know how C2S is working, I'm happy a cleaner version has been found.

I think gliffy gave up on C2S, as it's been years since the repo has been updated. I don't know which repository is the new maintained one.

jiayihu commented 8 months ago

Yes maintining a repo is time consuming and not worth it if you don't use it anymore in your job. zenozeng also has pretty much abandoned the repo. Can't blame any of them.

I'd like to fork and create a TS version of it, so at least contributing would be easier but it would probably become an additional unmaintained repo over time 😄

Back to the fix, my alternative is just an hotfix, not a proper fix, but it seems to work well for me and I work on a complex Miro-like collaborative canvas.

denis-migdal commented 8 months ago

I'd like to fork and create a TS version of it, so at least contributing would be easier but it would probably become an additional unmaintained repo over time 😄

It'll be easier if someone created a C2S organization so that the owner(s) would just have to manage the rights, i.e. add or remove users to the organization members.

jimvella commented 6 days ago

It's a bit clearer in this version https://github.com/insinfo/canvas2svg/blob/b877064ae0e4c8d05be4a4aa0fec6cd6b449a8f1/canvas2svg.js#L1131

I think the error messages are a red herring and that the issue is chart.js is communicating lines via a Path2D object passed via canvas.stroke(path) rather than directly calling moveTo/lineTo on the canvas. Path2D is opaque - and C2S doesn't have a way of interpreting and so does nothing. I've monkey patched Path2D in my local project to record invocations to subsequently be applied by C2S, but it's not really a proper way to go about it.