Group
Extension

Chart-ECharts/lib/Chart/ECharts.pm

package Chart::ECharts;

use feature ':5.10';
use strict;
use utf8;
use warnings;

use Digest::SHA qw(sha1_hex);
use File::Basename;
use File::ShareDir qw(dist_file);
use File::Spec;
use IPC::Open3;
use JSON::PP ();

our $VERSION = '1.03';
$VERSION =~ tr/_//d;    ## no critic

use constant DEBUG => $ENV{ECHARTS_DEBUG} || 0;

sub new {

    my $class = shift;

    my %params = (
        charts_object    => 'ChartECharts',
        class            => 'chart-container',
        container_prefix => '',
        dataset          => [],
        events           => {},
        scripts          => [],
        height           => undef,
        id               => ('chart_' . get_random_id()),
        locale           => 'en',
        options          => {},
        renderer         => 'canvas',
        responsive       => 0,
        series           => [],
        styles           => ['min-width:auto', 'min-height:300px'],
        theme            => 'white',
        vertical         => 0,
        width            => undef,
        xAxis            => [],
        yAxis            => [],
        init_method      => 'event',
        init_event       => 'load',
        @_
    );

    my $self = {%params};

    $self->{js} = {};

    return bless $self, $class;

}

sub chart_id { shift->{id} }

sub init_function { join '_', 'init', shift->{id} }

sub set_options {
    my ($self, %options) = @_;
    $self->{options} = {%{$self->{options}}, %options};
}

sub set_option {
    Carp::carp 'DEPRECATED use $chart->set_options(%params)';
    shift->set_options(@_);
}

sub set_option_item {
    my ($self, $name, $params) = @_;
    $self->{options}->{$name} = $params;
}

sub set_title {
    my ($self, %params) = @_;
    $self->set_options(title => \%params);
}

sub set_tooltip {
    my ($self, %params) = @_;
    $self->set_options(tooltip => \%params);
}

sub set_toolbox {
    my ($self, %params) = @_;
    $self->set_options(toolbox => \%params);
}

sub set_legend {
    my ($self, %params) = @_;
    $self->set_options(legend => \%params);
}

sub set_timeline {
    my ($self, %params) = @_;
    $self->set_options(timeline => \%params);
}

sub set_data_zoom {
    my ($self, %params) = @_;
    $self->set_options(dataZoom => \%params);
}

sub add_data_zoom {

    my ($self, %params) = @_;

    $self->{options}->{dataZoom} //= [];
    push @{$self->{options}}, \%params;

}

sub get_random_id {
    return sha1_hex(join('', time, rand));
}

sub set_event {
    my ($self, $event, $callback) = @_;
    $self->{events}->{$event} = $callback;
}

sub add_script {
    my ($self, $script) = @_;
    push @{$self->{scripts}}, $script;
}

sub on { shift->set_event(@_) }

sub set_xAxis {
    my ($self, %axis) = @_;
    $self->{xAxis} = \%axis;
}

sub add_xAxis {
    my ($self, %axis) = @_;
    push @{$self->{xAxis}}, \%axis;
}

sub set_yAxis {
    my ($self, %axis) = @_;
    $self->{yAxis} = \%axis;
}

sub add_yAxis {
    my ($self, %axis) = @_;
    push @{$self->{yAxis}}, \%axis;
}

sub add_series {
    my ($self, %series) = @_;
    push @{$self->{series}}, \%series;
}

sub add_dataset {
    my ($self, %dataset) = @_;
    push @{$self->{dataset}}, \%dataset;
}

sub xAxis   { shift->{xAxis} }
sub yAxis   { shift->{yAxis} }
sub series  { shift->{series} }
sub dataset { shift->{dataset} }

sub default_options { {} }

sub options {

    my ($self) = @_;

    my $default_options = $self->default_options;
    my $global_options  = $self->{options};

    my $default_series_options = delete $default_options->{series} || {};
    my $series_options         = delete $global_options->{series}  || {};

    my $options = {series => $self->series};

    for (my $i = 0; $i < @{$options->{series}}; $i++) {
        $options->{series}->[$i] = {%{$options->{series}->[$i]}, %{$default_series_options}};
        $options->{series}->[$i] = {%{$options->{series}->[$i]}, %{$series_options}};
    }

    if (@{$self->dataset}) {
        if (scalar @{$self->dataset} == 1) {
            $options->{dataset} = $self->dataset->[0];
        }
        else {
            $options->{dataset} = \@{$self->dataset};
        }
    }

    $options = {%{$options}, %{$self->axies}, %{$default_options}, %{$global_options}};

    return $options;

}

sub axies {

    my ($self) = @_;

    if ($self->{vertical}) {
        return {xAxis => $self->yAxis, yAxis => $self->xAxis};
    }

    return {xAxis => $self->xAxis, yAxis => $self->yAxis};

}

sub render_script {

    my ($self, %params) = @_;

    my $chart_id      = $self->{id};
    my $charts_object = $self->{charts_object};
    my $theme         = $self->{theme};
    my $renderer      = $self->{renderer};
    my $locale        = $self->{locale};
    my $container     = join '', $self->{container_prefix}, $chart_id;
    my $init_event    = $self->{init_event};
    my $init_method   = $self->{init_method};

    my $wrap = $params{wrap} //= 0;

    Carp::croak 'Malformed chart "id" name'      if ($chart_id      !~ /^[a-zA-Z0-9_-]*$/);
    Carp::croak 'Malformed "charts_object" name' if ($charts_object !~ /^[a-zA-Z0-9_-]*$/);
    Carp::croak 'Malformed chart "theme"'        if ($theme         !~ /^[a-zA-Z0-9_-]*$/);
    Carp::croak 'Malformed chart "container"'    if ($container     !~ /^[a-zA-Z0-9_-]*$/);
    Carp::croak 'Malformed chart "locale"'       if ($locale        !~ /^[a-zA-Z_-]*$/);
    Carp::croak 'Malformed chart "renderer"'     if ($renderer      !~ /^(svg|canvas)$/);
    Carp::croak 'Malformed init event name'      if ($init_event    !~ /^[a-zA-Z\_\-\:]*$/);
    Carp::croak 'Unknown "init_method"'          if ($init_method   !~ /^(event|iife)$/);

    my $json = JSON::PP->new;

    $json->utf8->canonical->allow_nonref->allow_unknown->allow_blessed->convert_blessed->escape_slash(0);

    my $option = $json->encode($self->options);

    foreach my $identifier (keys %{$self->{js}}) {

        my $search  = qr/"\{JS:$identifier\}"/;
        my $replace = $self->{js}->{$identifier};

        $option =~ s/$search/$replace/;

    }

    my @script = ();

    my $init_options = $json->encode({locale => $locale, renderer => $renderer});

    my $extra_script = '';
    my $chart_events = '';
    my $responsive   = '';
    my $init_script  = qq[window.addEventListener('$init_event', init_$chart_id);];

    foreach my $script (@{$self->{scripts}}) {
        $extra_script .= qq[
            $script
        ];
    }

    foreach my $event (keys %{$self->{events}}) {
        my $callback = $self->{events}->{$event};
        $chart_events .= qq[
            chart.on('$event', function (params) { $callback });
        ];
    }

    if ($self->{responsive}) {
        $responsive = qq[
            window.addEventListener('resize', function () { chart.resize() });
        ];
    }

    if ($self->{init_method} eq 'iife') {
        $init_script = qq[(function(){ init_$chart_id() })();];
    }

    my $script = qq[
        if (!window.$charts_object) { window.$charts_object = {} }
        if (!window.$charts_object.charts) { window.$charts_object.charts = {} }

        function init_$chart_id() {

            let chartContainer = document.getElementById('$container');

            if (! chartContainer) { return false }

            let chart  = echarts.init(chartContainer, '$theme', $init_options);
            let option = $option;

            option && chart.setOption(option);

            window.$charts_object.charts["$chart_id"] = chart;

            $chart_events
            $extra_script
            $responsive

            return chart;

        }

        $init_script
    ];

    return "<script>\n$script\n</script>" if $wrap;

    return $script;

}

sub render_html {

    my ($self) = @_;

    my $style  = '';
    my @styles = @{$self->{styles}};

    push @styles, sprintf('width:%s',  $self->{width})  if ($self->{width});
    push @styles, sprintf('height:%s', $self->{height}) if ($self->{height});

    my $script = $self->render_script;

    my $chart_id        = $self->{id};
    my $container_id    = join '',  $self->{container_prefix}, $chart_id;
    my $styles          = join ';', @styles, $style;
    my $class_container = $self->{class};

    my $html = qq[
    <div id="$container_id" class="$class_container" style="$styles">
        <script>
            $script
        </script>
    </div>
    ];

    return $html;

}

sub render_image {

    my ($self, %params) = @_;

    my $render_script = dist_file('Chart-ECharts', 'render.cjs');

    my $node_path = delete $params{node_path};
    my $node_bin  = delete $params{node_bin} || '/usr/bin/node';
    my $output    = delete $params{output}   || Carp::croak 'Specify "output" file';
    my $format    = delete $params{format};
    my $width     = delete $params{width};
    my $height    = delete $params{height};
    my $option    = JSON::PP->new->encode($self->options);

    if (!$format) {

        my ($file, $dir, $suffix) = fileparse($output, ('.png', ',svg'));

        Carp::croak 'Unsupported output "format"' unless $suffix;

        ($format = $suffix) =~ s/\.//;

    }

    if ($format ne 'png' && $format ne 'svg') {
        Carp::croak 'Unknown output "format"';
    }

    if ($node_bin !~ /(node|node.exe)$/i) {
        Carp::croak 'Unknown node command';
    }

    if (!-e $node_bin && !-x _) {
        Carp::croak 'Node binary not found';
    }

    local $ENV{NODE_PATH} //= $node_path if $node_path;

    my @cmd = ($node_bin, $render_script, '--output', $output, '--format', $format, '--option', $option);

    push @cmd, '--width',  $width  if ($width);
    push @cmd, '--height', $height if ($height);

    DEBUG and say STDERR sprintf('Command: %s', join ' ', @cmd);

    my $pid = open3(my $stdin, my $stdout, my $stderr, @cmd);

    waitpid($pid, 0);
    my $exit_status = $? >> 8;

    if (DEBUG) {

        say STDERR "Enviroment Variables:";
        say STDERR sprintf("NODE_PATH=%s\n", $ENV{NODE_PATH} || '');

        if ($stderr) {
            say STDERR 'Command STDERR:';
            say STDERR <$stderr>;
        }

        if ($stdout) {
            say STDERR 'Command STDOUT:';
            say STDERR <$stdout>;
        }

        say STDERR "Command exit status: $exit_status";

    }


}

sub js {

    my ($self, $expression) = @_;

    my $identifier = sha1_hex($expression);
    $self->{js}->{$identifier} = $expression;

    return "{JS:$identifier}";

}

sub TO_JSON { shift->options }


1;

__END__

=encoding utf-8

=head1 NAME

Chart::ECharts - Apache ECharts wrapper for Perl

=head1 SYNOPSIS

    use Chart::ECharts;

    my $chart = Chart::ECharts->new( responsive => 1 );

    $chart->add_xAxis(
        type => 'category',
        data => ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
    );

    $chart->add_yAxis(type => 'value');

    $chart->add_series(
        name => 'series_name',
        type => 'bar',
        data => [120, 200, 150, 80, 70, 110, 130]
    );

    # Render in HTML
    say $chart->render_html;

    # Render chart image (require Node.js)
    $chart->render_image(
        output => 'charts/bar.png',
        width  => 800,
        height => 600
    );

=begin html

<a href = "https://raw.githubusercontent.com/giterlizzi/perl-Chart-ECharts/main/charts/bar.png">
<img src = "https://raw.githubusercontent.com/giterlizzi/perl-Chart-ECharts/main/charts/bar.png"
     alt = "Bar Chart" />
</a>

=end html

=head1 DESCRIPTION

L<Chart::ECharts> is a distribution that works as a wrapper for the Apache Echarts js library.

L<https://echarts.apache.org/>


=head2 METHODS

=over

=item $chart = Chart::ECharts->new(%params)

B<Params>

=over

=item C<charts_object>, Charts object accessible via C<window> object (default C<ChartEcharts>)

=item C<class>, Chart container CSS class (default C<chart-container>)

=item C<container_prefix>, Default chart container prefix (default C<undef>)

=item C<events>, Events (default C<[]>)

=item C<height>, Chart height

=item C<id>, Chart ID (default C<chart_ + "random string">)

=item C<locale>, Chart locale (default C<en>)

=item C<options>, EChart options (L<https://echarts.apache.org/en/option.html>) (default C<{}>)

=item C<renderer>, Default ECharts renrerer (default C<canvas>)

=item C<responsive>, Enable responsive feature

=item C<series>, Chart series (L<https://echarts.apache.org/en/option.html#series>)

=item C<dataset>, Chart dataset (L<https://echarts.apache.org/en/option.html#dataset>)

=item C<styles>, Default char styles (default C<['min-width:auto', 'min-height:300px']>)

=item C<theme>. Chart theme (default C<white>)

=item C<vertical>, Set the chart in vertical (default C<0>)

=item C<width>, Chart width

=item C<xAxis>, Chart X Axis (L<https://echarts.apache.org/en/option.html#xAxis>)

=item C<yAxis>, Chart Y Axis (L<https://echarts.apache.org/en/option.html#yAxis>)

=item C<init_method>, Change the chart init method (load C<event> - default, C<iife> Immediately Invoked Function Expression)

=item C<init_event>, Change the chart init C<event> name (C<load> - default)

=back

Return L<Chart::ECharts> object.

=item $chart->set_options(%options)

Set Apache EChart options (see Apache ECharts documentations L<https://echarts.apache.org/en/option.html>).

    $chart->set_options(
        title => {text => 'My Chart'},
        grid  => {left => 10, bottom => 10, right => 10, containLabel => \1}
    );

=item $chart->set_option(%params)

(DEPRECATED) Alias of C<set_options>

=item $chart->set_option_item($name, $params)

=item $chart->get_random_id

Get the random chart ID.

=item $chart->chart_id

Return the chart ID.

=item $chart->init_function

Return the init JS function name.

=item $chart->set_event($event, $callback)

Set a JS event.

    $chart->on('click', q{ console.log(params); });

=item $chart->on($event, $callback)

Alias of L<set_event>.

=item $chart->add_script($script)

Add custom JS script. All scripts are rendered via C<render_html> and C<render_script>.

    $chart->add_script(q{
        console.log('Hello World');
    });

=item $chart->add_xAxis(%axis)

Add single X axis (see Apache ECharts documentations L<https://echarts.apache.org/en/option.html#xAxis>).

    $chart->add_xAxis(
        type => 'category',
        data => ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
    );

=item $chart->set_xAxis(%axis)

Set X axis (see Apache ECharts documentations L<https://echarts.apache.org/en/option.html#xAxis>).

    $chart->set_xAxis(
        splitLine => {
            lineStyle => {
                type => 'dashed'
            }
        }
    );

=item $chart->add_yAxis(%axis)

Add single Y axis (see Apache ECharts documentations L<https://echarts.apache.org/en/option.html#yAxis>).

    $chart->add_yAxis(
        type => 'value'
    );

=item $chart->set_yAxis(%axis)

Set Y axis (see Apache ECharts documentations L<https://echarts.apache.org/en/option.html#yAxis>).

    $chart->set_yAxis(
        splitLine => {
            lineStyle => {
                type => 'dashed'
            }
        }
    );

=item $chart->add_series(%series)

Add single series (see Apache ECharts documentations L<https://echarts.apache.org/en/option.html#series>).

    $chart->add_series(
        name => 'series_name',
        type => 'bar',
        data => [120, 200, 150, 80, 70, 110, 130]
    );

=item $chart->add_dataset(%dataset)

Add dataset (see Apache ECharts documentations L<https://echarts.apache.org/en/option.html#dataset>).

    $chart->add_dataset(
        source => [
            ['product', '2015', '2016', '2017'],
            ['Matcha Latte', 43.3, 85.8, 93.7],
            ['Milk Tea', 83.1, 73.4, 55.1],
            ['Cheese Cocoa', 86.4, 65.2, 82.5],
            ['Walnut Brownie', 72.4, 53.9, 39.1]
        ]
    );

=item $chart->js($expression)

Embed arbritaty JS code in chart options.

    $chart->set_tooltip(
        valueFormatter => $chart->js( q{(value) => '$' + Math.round(value)} )
    );

=back


=head3 OPTION HELPERS

=over

=item $chart->set_title(%params)

Set the chart title

=item $chart->set_toolbox(%params)

Set the chart toolbox

=item $chart->set_tooltip(%params)

Set the chart tooltip

=item $chart->set_legend(%params)

Set the chart legend

=item $chart->set_timeline(%params)

Set the chart timeline

=item $chart->set_data_zoom(%params)

Set the chart data zoom

=item $chart->add_data_zoom(%params)

Add the chart data zoom

=back


=head3 PROPERTIES

=over

=item $chart->xAxis

Return X axis.

=item $chart->yAxis

Return Y axis.

=item $chart->series

Return chart series.

=item $chart->default_options

Return chart default options.

=item $chart->options

Return all chart options.

=item $chart->axies

Return X and Y axies.

=back


=head3 RENDERS

=over

=item $chart->render_script(%params)

Render the chart in JS.

    my $script = $chart->render_script;

=item $chart->render_html(%params)

Render the chart in HTML including the output of L<render_script> with a C<div> container.

=item $chart->render_image(%params)

Render the chart image file (require Node.js).

B<Parameters>

=over

=item C<node_path>, Node.js (aka C<node_modules>) path (default: C<$ENV{NODE_PATH}>)

=item C<node_bin>, Node.js binary (optional)

=item C<output>, Output file (required)

=item C<format>, Output file format (C<png> or C<svg>, optional)

=item C<width>, Image width (default: 400)

=item C<height>, Image height (default: 300)

=back

=item $chart->TO_JSON

Encode options in JSON.

=back

=head2 Embed Chart::ECharts in your web application:

=head3 Mojolicious

    use Mojolicious::Lite -signatures;

    helper render_chart => sub ($c, $chart) {
        Mojo::ByteStream->new($chart->render_html);
    };

    get '/chart' => sub ($c) {

        my $cool_chart = Chart::ECharts->new;

        # [...]

        $c->render('chart', cool_chart => $cool_chart);

    };

    app->start;

    __DATA__

    @@ default.html.ep
    <!DOCTYPE html>
    <html>
      <head>
        <meta charset="utf-8" />
        <title><%= title %></title>
        <!-- Include the ECharts file you just downloaded -->
        <script src="echarts.js"></script>
      </head>
      <body>
        %= content
      </body>
    </html>

    @@ chart.html.ep

    % layout 'default';
    % title 'My cool chart with Chart::ECharts';

    <h1>My cool chart with Chart::ECharts</h1>

    <p><% render_chart($cool_chart) %></p>

=head2 Setup Node.js

Install Apache ECharts >= 5.4 (L<https://www.npmjs.com/package/echarts>)
and Canvas >= 2.11 (L<https://www.npmjs.com/package/canvas>):

    $ cd your-project-path
    $ npm add canvas echarts

You can use the C<share/package.json> in the distribution directory:

    $ cd your-project-path
    $ cp <Chart-EChart-dist>/share/package.json .
    $ npm install

In your Perl script set the C<node_path> options (or set C<$ENV{NODE_PATH}> enviroment),
C<node_bin> if Node.js is not in C<$ENV{PATH}> and C<output> image file:

    local $ENV{NODE_PATH} = 'your-project-path/node_modules';

    $chart->render_image(
        output => 'charts/bar.png',
        width  => 800,
        height => 600
    );

=head2 Charts object

By default L<Chart::ECharts> expose an object in C<window.ChartECharts> object
(use C<charts_object> config to rename).

Properties:

=over

=item C<charts>, ARRAY of chart IDs

This property contains an ARRAY of all generated graphs (via C<render_html> and
C<render_script>). It is useful to allow customization of the graph via JS.

    my $chart = Chart::ECharts(id => 'myChart');

    # ...

    $chart->render_html;

    # in your JS

    if ('myChart' in window.ChartECharts.charts) {

        let myChart = window.ChartECharts.charts.myChart;

        let newLabels = [];
        let newData   = [];

        // Update chart data
        myChart.setOption({
            xAxis: {
                data: newLabels
            },
            series: [
                {
                    data: newData
                }
            ]
        });

    }

=back

=head2 Chart init

By default L<Chart::ECharts> use "load" event for init.

Immediately Invoked Function Expression (IIFE):

    my $chart = Chart::ECharts->new(init_method => 'iife', ...);

Custom event:

    my $chart = Chart::ECharts->new(init_method => 'event', init_event => 'app:initChart', ...);

    # in your JS
    window.dispatchEvent(new CustomEvent('app:initChart'));


=head1 SUPPORT

=head2 Bugs / Feature Requests

Please report any bugs or feature requests through the issue tracker
at L<https://github.com/giterlizzi/perl-Chart-ECharts/issues>.
You will be notified automatically of any progress on your issue.

=head2 Source Code

This is open source software.  The code repository is available for
public review and contribution under the terms of the license.

L<https://github.com/giterlizzi/perl-Chart-ECharts>

    git clone https://github.com/giterlizzi/perl-Chart-ECharts.git


=head1 AUTHOR

=over 4

=item * Giuseppe Di Terlizzi <gdt@cpan.org>

=back


=head1 LICENSE AND COPYRIGHT

This software is copyright (c) 2024-2025 by Giuseppe Di Terlizzi.

This is free software; you can redistribute it and/or modify it under
the same terms as the Perl 5 programming language system itself.

=cut


Powered by Groonga
Maintained by Kenichi Ishigaki <ishigaki@cpan.org>. If you find anything, submit it on GitHub.