File Coverage

File:blib/lib/HTML/D3.pm
Coverage:97.2%

linestmtbrancondsubtimecode
1package HTML::D3;
2
3
10
10
10
147437
6
97
use strict;
4
10
10
10
12
6
141
use warnings;
5
6
10
10
10
14
7
350
use JSON::MaybeXS;
7
10
10
10
2891
907723
182
use Object::Configure;
8
10
10
10
32
6
149
use Params::Get;
9
10
10
10
27
10
7334
use Scalar::Util;
10
11# TODO: add animated tooltips to charts with legends
12
13 - 21
=head1 NAME

HTML::D3 - A simple Perl module for generating charts using D3.js.

=head1 VERSION

Version 0.09

=cut
22
23our $VERSION = '0.09';
24
25 - 79
=head1 SYNOPSIS

    use HTML::D3;

    my $chart = HTML::D3->new(
        width => 1024,
        height => 768,
        title => 'Sample Bar Chart'
    );

    my $data = [
        ['Category 1', 10],
        ['Category 2', 20],
        ['Category 3', 30]
    ];

    my $html = $chart->render_bar_chart($data);
    print $html;

    $chart = HTML::D3->new(title => 'Sales Data');

    $data = [
        ['Product A', 100],
        ['Product B', 150],
        ['Product C', 200]
    ];

    $html = $chart->render_line_chart($data);
    print $html;

=head1 DESCRIPTION

HTML::D3 is a Perl module that provides functionality to create simple charts using D3.js.
The module generates HTML and JavaScript code to render the chart in a web browser.

=head1 METHODS

=head2 new

    my $chart = HTML::D3->new(%args);

Creates a new HTML::D3 object.
Accepts the following optional arguments:

=over 4

=item * C<width> - The width of the chart (default: 800).

=item * C<height> - The height of the chart (default: 600).

=item * C<title> - The title of the chart (default: 'Chart').

=back

=cut
80
81# Constructor to initialize chart properties
82sub new
83{
84
14
1116258
        my $class = shift;
85
86        # Handle hash or hashref arguments
87
14
42
        my $params = Params::Get::get_params(undef, @_) || {};
88
89
14
273
        if(!defined($class)) {
90
1
1
1
2
                if((scalar keys %{$params}) > 0) {
91                        # Using HTML::D3->new(), not HTML::D3::new()
92
0
0
                        carp(__PACKAGE__, ' use ->new() not ::new() to instantiate');
93
0
0
                        return;
94                }
95                # FIXME: this only works when no arguments are given
96
1
1
                $class = __PACKAGE__;
97        } elsif(Scalar::Util::blessed($class)) {
98                # If $class is an object, clone it with new arguments
99
2
2
2
2
4
6
                return bless { %{$class}, %{$params} }, ref($class);
100        }
101
102
12
40
        $params = Object::Configure::configure($class, $params);
103
104        # Return the blessed object
105        return bless {
106                width => $params->{width}  || 800,  # Default chart width
107                height => $params->{height} || 600,  # Default chart height
108
12
204026
                title => $params->{title}  || 'Chart',  # Default chart title
109        }, $class;
110}
111
112 - 127
=head2 render_bar_chart

    my $html = $chart->render_bar_chart($data);

Generates HTML and JavaScript code to render a bar chart. Accepts the following arguments:

=over 4

=item * C<$data> - An array reference containing data points. Each data point should
be an array reference with two elements: the label (string) and the value (numeric).

=back

Returns a string containing the HTML and JavaScript code for the chart.

=cut
128
129# Method to render a bar chart with given data
130sub render_bar_chart {
131
2
2562
        my ($self, $data) = @_;
132
133
2
3
        die 'Data is not optional' if(!defined($data));
134
135        # Validate input data to ensure it is an array of arrays
136
2
7
        die 'Data must be an array of arrays' unless ref($data) eq 'ARRAY';
137
138        # Generate JSON representation of data
139        my $json_data = encode_json([
140
1
3
2
11
                map { { label => $_->[0], value => $_->[1] } } @$data
141        ]);
142
143        # Generate HTML and D3.js JavaScript for rendering the bar chart
144
1
5
        my $html = $self->_preamble();
145
1
3
        $html .= $self->_head();
146
1
3
        $html .= <<"HTML";
147<body>
148    <h1 style="text-align: center;">$self->{title}</h1>
149    <svg id="chart" width="$self->{width}" height="$self->{height}" style="border: 1px solid black;"></svg>
150    <script>
151        const data = $json_data;
152
153        const svg = d3.select("#chart");
154        const margin = { top: 20, right: 30, bottom: 40, left: 40 };
155        const width = $self->{width} - margin.left - margin.right;
156        const height = $self->{height} - margin.top - margin.bottom;
157
158        // Set up scales for x and y axes
159        const x = d3.scaleBand()
160            .domain(data.map(d => d.label))
161            .range([0, width])
162            .padding(0.1);
163
164        const y = d3.scaleLinear()
165            .domain([0, d3.max(data, d => d.value)])
166            .nice()
167            .range([height, 0]);
168
169        const chart = svg.append("g")
170            .attr("transform", `translate(\${margin.left},\${margin.top})`);
171
172        // Add bars to the chart
173        chart.append("g")
174            .selectAll("rect")
175            .data(data)
176            .join("rect")
177            .attr("x", d => x(d.label))
178            .attr("y", d => y(d.value))
179            .attr("height", d => height - y(d.value))
180            .attr("width", x.bandwidth())
181            .attr("fill", "steelblue");
182
183        // Add the y-axis
184        chart.append("g")
185            .call(d3.axisLeft(y));
186
187        // Add the x-axis with labels rotated for better readability
188        chart.append("g")
189            .attr("transform", `translate(0,\${height})`)
190            .call(d3.axisBottom(x))
191            .selectAll("text")
192            .attr("transform", "rotate(-45)")
193            .style("text-anchor", "end");
194    </script>
195</body>
196</html>
197HTML
198
199
1
3
    return $html;
200}
201
202 - 217
=head2 render_line_chart

    my $html = $chart->render_line_chart($data);

Generates HTML and JavaScript code to render a line chart. Accepts the following arguments:

=over 4

=item * C<$data> - An array reference containing data points. Each data point should
be an array reference with two elements: the label (string) and the value (numeric).

=back

Returns a string containing the HTML and JavaScript code for the chart.

=cut
218
219sub render_line_chart {
220
2
2637
    my ($self, $data) = @_;
221
222    # Validate input data
223
2
9
    die 'Data must be an array of arrays' unless ref($data) eq 'ARRAY';
224
225    # Generate JSON for data
226    my $json_data = encode_json([
227
1
3
2
10
        map { { label => $_->[0], value => $_->[1] } } @$data
228    ]);
229
230    # Generate HTML and D3.js code
231
1
4
    my $html = $self->_preamble();
232
1
2
    $html .= $self->_head();
233
1
4
    $html .= <<"HTML";
234<body>
235    <h1 style="text-align: center;">$self->{title}</h1>
236    <svg id="chart" width="$self->{width}" height="$self->{height}" style="border: 1px solid black;"></svg>
237    <script>
238        const data = $json_data;
239
240        const svg = d3.select("#chart");
241        const margin = { top: 20, right: 30, bottom: 40, left: 40 };
242        const width = $self->{width} - margin.left - margin.right;
243        const height = $self->{height} - margin.top - margin.bottom;
244
245        const x = d3.scalePoint()
246            .domain(data.map(d => d.label))
247            .range([0, width]);
248
249        const y = d3.scaleLinear()
250            .domain([0, d3.max(data, d => d.value)])
251            .nice()
252            .range([height, 0]);
253
254        const chart = svg.append("g")
255            .attr("transform", `translate(\${margin.left},\${margin.top})`);
256
257        // Draw line
258        const line = d3.line()
259            .x(d => x(d.label))
260            .y(d => y(d.value));
261
262        chart.append("path")
263            .datum(data)
264            .attr("fill", "none")
265            .attr("stroke", "steelblue")
266            .attr("stroke-width", 2)
267            .attr("d", line);
268
269        // Add points to the line
270        chart.selectAll("circle")
271            .data(data)
272            .join("circle")
273            .attr("cx", d => x(d.label))
274            .attr("cy", d => y(d.value))
275            .attr("r", 4)
276            .attr("fill", "steelblue");
277
278        // Add axes
279        chart.append("g")
280            .call(d3.axisLeft(y));
281
282        chart.append("g")
283            .attr("transform", `translate(0,\${height})`)
284            .call(d3.axisBottom(x))
285            .selectAll("text")
286            .attr("transform", "rotate(-45)")
287            .style("text-anchor", "end");
288    </script>
289</body>
290</html>
291HTML
292
293
1
6
    return $html;
294}
295
296 - 312
=head2 render_line_chart_with_tooltips

    $html = $chart->render_line_chart_with_tooltips($data);

Generates HTML and JavaScript code to render a line chart with mouseover tooltips.
Accepts the following arguments:

=over 4

=item * C<$data> - An array reference containing data points. Each data point should
be an array reference with two elements: the label (string) and the value (numeric).

=back

Returns a string containing the HTML and JavaScript code for the chart.

=cut
313
314sub render_line_chart_with_tooltips
315{
316
2
2948
        my ($self, $data) = @_;
317
318        # Validate input data
319
2
8
        die 'Data must be an array of arrays' unless ref($data) eq 'ARRAY';
320
321        # Generate JSON for data
322        my $json_data = encode_json([
323
1
5
2
13
                map { { label => $_->[0], value => $_->[1] } } @$data
324        ]);
325
326        # Generate HTML and D3.js code
327
1
5
        my $html = $self->_preamble();
328
1
5
        $html .= <<"HTML";
329<head>
330    <meta charset="UTF-8">
331    <meta name="viewport" content="width=device-width, initial-scale=1.0">
332    <title>$self->{title}</title>
333    <script src="https://d3js.org/d3.v7.min.js"></script>
334    <style>
335        .tooltip {
336            position: absolute;
337            background-color: white;
338            border: 1px solid #ccc;
339            padding: 5px;
340            font-size: 12px;
341            pointer-events: none;
342            opacity: 0;
343            transition: opacity 0.2s ease-in-out;
344        }
345    </style>
346</head>
347<body>
348    <h1 style="text-align: center;">$self->{title}</h1>
349    <svg id="chart" width="$self->{width}" height="$self->{height}" style="border: 1px solid black;"></svg>
350    <div class="tooltip" id="tooltip"></div>
351    <script>
352        const data = $json_data;
353
354        const svg = d3.select("#chart");
355        const tooltip = d3.select("#tooltip");
356        const margin = { top: 20, right: 30, bottom: 40, left: 40 };
357        const width = $self->{width} - margin.left - margin.right;
358        const height = $self->{height} - margin.top - margin.bottom;
359
360        const x = d3.scalePoint()
361            .domain(data.map(d => d.label))
362            .range([0, width]);
363
364        const y = d3.scaleLinear()
365            .domain([0, d3.max(data, d => d.value)])
366            .nice()
367            .range([height, 0]);
368
369        const chart = svg.append("g")
370            .attr("transform", `translate(\${margin.left},\${margin.top})`);
371
372        // Draw line
373        const line = d3.line()
374            .x(d => x(d.label))
375            .y(d => y(d.value));
376
377        chart.append("path")
378            .datum(data)
379            .attr("fill", "none")
380            .attr("stroke", "steelblue")
381            .attr("stroke-width", 2)
382            .attr("d", line);
383
384        // Add points to the line
385        chart.selectAll("circle")
386            .data(data)
387            .join("circle")
388            .attr("cx", d => x(d.label))
389            .attr("cy", d => y(d.value))
390            .attr("r", 4)
391            .attr("fill", "steelblue")
392            .on("mouseover", (event, d) => {
393                tooltip.style("opacity", 1)
394                       .html(`Label: <b>\${d.label}<\/b><br>Value: <b>\${d.value}<\/b>`)
395                       .style("left", (event.pageX + 10) + "px")
396                       .style("top", (event.pageY - 30) + "px");
397            })
398            .on("mousemove", (event) => {
399                tooltip.style("left", (event.pageX + 10) + "px")
400                       .style("top", (event.pageY - 30) + "px");
401            })
402            .on("mouseout", () => {
403                tooltip.style("opacity", 0);
404            });
405
406        // Add axes
407        chart.append("g")
408            .call(d3.axisLeft(y));
409
410        chart.append("g")
411            .attr("transform", `translate(0,\${height})`)
412            .call(d3.axisBottom(x))
413            .selectAll("text")
414            .attr("transform", "rotate(-45)")
415            .style("text-anchor", "end");
416    </script>
417</body>
418</html>
419HTML
420
421
1
2
    return $html;
422}
423
424 - 463
=head2 render_line_chart_snippet

    my $fragment = $chart->render_line_chart_snippet($data);
    # $fragment->{svg_id} - the id attribute of the <svg> element
    # $fragment->{html}   - embeddable HTML fragment (style + svg + script)

Generates an embeddable HTML fragment for a line chart with mouseover tooltips.
Unlike C<render_line_chart_with_tooltips>, this method returns a fragment with
no C<<!DOCTYPE>>, C<<html>>, C<<head>>, or C<<body>> wrapper, suitable for
splicing directly into a Mojolicious TT (or any other) layout.

The caller is responsible for loading D3 in the page C<<head>>, e.g.:

    <script src="https://d3js.org/d3.v7.min.js"></script>

Accepts the following arguments:

=over 4

=item * C<$data> - An array reference of data points. Each point is an array
reference with two required elements - the label (string) and the value
(numeric) - and an optional third element: a hash reference of extra key/value
pairs to display in the tooltip after the label and value rows.

    [$x, $y]          # basic point
    [$x, $y, \%row]   # point with extra tooltip data

=back

Returns a hash reference with:

=over 4

=item * C<svg_id> - The C<id> attribute used on the C<<svg>> element.

=item * C<html> - The embeddable fragment string.

=back

=cut
464
465sub render_line_chart_snippet
466{
467
3
2853
        my ($self, $data) = @_;
468
469
3
10
        die 'Data must be an array of arrays' unless ref($data) eq 'ARRAY';
470
471        my $json_data = encode_json([
472                map {
473
2
5
16
6
                        my $point = { label => $_->[0], value => $_->[1] };
474
5
5
                        $point->{extra} = $_->[2] if ref($_->[2]) eq 'HASH';
475
5
14
                        $point
476                } @$data
477        ]);
478
479
2
4
        my $svg_id = 'chart';
480
481
2
5
        my $html = <<"HTML";
482<style>
483    .tooltip {
484        position: absolute;
485        background-color: white;
486        border: 1px solid #ccc;
487        padding: 5px;
488        font-size: 12px;
489        pointer-events: none;
490        opacity: 0;
491        transition: opacity 0.2s ease-in-out;
492    }
493</style>
494<svg id="$svg_id" width="$self->{width}" height="$self->{height}" style="border: 1px solid black;"></svg>
495<div class="tooltip" id="tooltip"></div>
496<script>
497    const data = $json_data;
498
499    const svg = d3.select("#$svg_id");
500    const tooltip = d3.select("#tooltip");
501    const margin = { top: 20, right: 30, bottom: 40, left: 40 };
502    const width = $self->{width} - margin.left - margin.right;
503    const height = $self->{height} - margin.top - margin.bottom;
504
505    const x = d3.scalePoint()
506        .domain(data.map(d => d.label))
507        .range([0, width]);
508
509    const y = d3.scaleLinear()
510        .domain([0, d3.max(data, d => d.value)])
511        .nice()
512        .range([height, 0]);
513
514    const chart = svg.append("g")
515        .attr("transform", `translate(\${margin.left},\${margin.top})`);
516
517    const line = d3.line()
518        .x(d => x(d.label))
519        .y(d => y(d.value));
520
521    chart.append("path")
522        .datum(data)
523        .attr("fill", "none")
524        .attr("stroke", "steelblue")
525        .attr("stroke-width", 2)
526        .attr("d", line);
527
528    chart.selectAll("circle")
529        .data(data)
530        .join("circle")
531        .attr("cx", d => x(d.label))
532        .attr("cy", d => y(d.value))
533        .attr("r", 4)
534        .attr("fill", "steelblue")
535        .on("mouseover", (event, d) => {
536            let ttHtml = `Label: <b>\${d.label}<\/b><br>Value: <b>\${d.value}<\/b>`;
537            if (d.extra) {
538                Object.entries(d.extra).forEach(([k, v]) => {
539                    ttHtml += `<br>\${k}: <b>\${v}<\/b>`;
540                });
541            }
542            tooltip.style("opacity", 1)
543                   .html(ttHtml)
544                   .style("left", (event.pageX + 10) + "px")
545                   .style("top", (event.pageY - 30) + "px");
546        })
547        .on("mousemove", (event) => {
548            tooltip.style("left", (event.pageX + 10) + "px")
549                   .style("top", (event.pageY - 30) + "px");
550        })
551        .on("mouseout", () => {
552            tooltip.style("opacity", 0);
553        });
554
555    chart.append("g")
556        .call(d3.axisLeft(y));
557
558    chart.append("g")
559        .attr("transform", `translate(0,\${height})`)
560        .call(d3.axisBottom(x))
561        .selectAll("text")
562        .attr("transform", "rotate(-45)")
563        .style("text-anchor", "end");
564</script>
565HTML
566
567
2
6
        return { svg_id => $svg_id, html => $html };
568}
569
570 - 587
=head2 render_multi_series_line_chart_with_tooltips

    $html = $chart->render_multi_series_line_chart_with_tooltips($data);

Generates HTML and JavaScript code to render a chart of many lines with mouseover tooltips.

Accepts the following arguments:

=over 4

=item * C<$data> - An reference to an array of hashes containing data points.
Each data point should be an array reference with two elements: the label (string) and the value (numeric).

=back

Returns a string containing the HTML and JavaScript code for the chart.

=cut
588
589sub render_multi_series_line_chart_with_tooltips
590{
591
2
3209
        my ($self, $data) = @_;
592
593        # Validate input data
594
2
8
        die 'Data must be an array of hashes' unless ref($data) eq 'ARRAY';
595
596
1
11
        my $json_data = encode_json($data);
597
598        # Generate HTML and D3.js code
599
1
3
        my $html = $self->_preamble();
600
1
5
        $html .= <<"HTML";
601<head>
602    <meta charset="UTF-8">
603    <meta name="viewport" content="width=device-width, initial-scale=1.0">
604    <title>$self->{title}</title>
605    <script src="https://d3js.org/d3.v7.min.js"></script>
606    <style>
607        .tooltip {
608            position: absolute;
609            background-color: white;
610            border: 1px solid #ccc;
611            padding: 5px;
612            font-size: 12px;
613            pointer-events: none;
614            opacity: 0;
615            transition: opacity 0.2s ease-in-out;
616        }
617    </style>
618</head>
619<body>
620    <h1 style="text-align: center;">$self->{title}</h1>
621    <svg id="chart" width="$self->{width}" height="$self->{height}" style="border: 1px solid black;"></svg>
622    <div class="tooltip" id="tooltip"></div>
623    <script>
624        const data = $json_data;
625
626        const svg = d3.select("#chart");
627        const tooltip = d3.select("#tooltip");
628        const margin = { top: 20, right: 30, bottom: 40, left: 40 };
629        const width = $self->{width} - margin.left - margin.right;
630        const height = $self->{height} - margin.top - margin.bottom;
631
632        const chart = svg.append("g")
633            .attr("transform", `translate(\${margin.left},\${margin.top})`);
634
635        // Extract all labels and flatten them into a unique array
636        const allLabels = Array.from(new Set(data.flatMap(series => series.data.map(d => d.label))));
637
638        const x = d3.scalePoint()
639            .domain(allLabels)
640            .range([0, width]);
641
642        const y = d3.scaleLinear()
643            .domain([0, d3.max(data.flatMap(series => series.data.map(d => d.value)))])
644            .nice()
645            .range([height, 0]);
646
647        // Define color scale for series
648        const color = d3.scaleOrdinal(d3.schemeCategory10);
649
650        // Add axes
651        chart.append("g")
652            .call(d3.axisLeft(y));
653
654        chart.append("g")
655            .attr("transform", `translate(0,\${height})`)
656            .call(d3.axisBottom(x))
657            .selectAll("text")
658            .attr("transform", "rotate(-45)")
659            .style("text-anchor", "end");
660
661        // Draw lines for each series
662        data.forEach((series, i) => {
663            const line = d3.line()
664                .x(d => x(d.label))
665                .y(d => y(d.value));
666
667            // Add line
668            chart.append("path")
669                .datum(series.data)
670                .attr("fill", "none")
671                .attr("stroke", color(i))
672                .attr("stroke-width", 2)
673                .attr("d", line);
674
675            // Add points and tooltips
676            chart.selectAll(\`circle.series-\${i}\`)
677                .data(series.data)
678                .join("circle")
679                .attr("class", \`series-\${i}\`)
680                .attr("cx", d => x(d.label))
681                .attr("cy", d => y(d.value))
682                .attr("r", 4)
683                .attr("fill", color(i))
684                .on("mouseover", (event, d) => {
685                    tooltip.style("opacity", 1)
686                           .html(\`Series: <b>\${series.name}<\/b><br>Label: <b>\${d.label}<\/b><br>Value: <b>\${d.value}<\/b>\`)
687                           .style("left", (event.pageX + 10) + "px")
688                           .style("top", (event.pageY - 30) + "px");
689                })
690                .on("mousemove", (event) => {
691                    tooltip.style("left", (event.pageX + 10) + "px")
692                           .style("top", (event.pageY - 30) + "px");
693                })
694                .on("mouseout", () => {
695                    tooltip.style("opacity", 0);
696                });
697        });
698    </script>
699</body>
700</html>
701HTML
702
703
1
3
    return $html;
704}
705
706 - 723
=head2 render_multi_series_line_chart_with_animated_tooltips

    $html = $chart->render_multi_series_line_chart_with_animated_tooltips($data);

Generates HTML and JavaScript code to render a chart of many lines with animated mouseover tooltips.

Accepts the following arguments:

=over 4

=item * C<$data> - An reference to an array of hashes containing data points.
Each data point should be an array reference with two elements: the label (string) and the value (numeric).

=back

Returns a string containing the HTML and JavaScript code for the chart.

=cut
724
725sub render_multi_series_line_chart_with_animated_tooltips
726{
727
2
3143
        my ($self, $data) = @_;
728
729        # Validate input data
730
2
8
        die 'Data must be an array of hashes' unless ref($data) eq 'ARRAY';
731
732        # Generate JSON for data
733
1
10
        my $json_data = encode_json($data);
734
735        # Generate HTML and D3.js code
736
1
3
        my $html = $self->_preamble();
737
1
9
        $html .= <<"HTML";
738<head>
739    <meta charset="UTF-8">
740    <meta name="viewport" content="width=device-width, initial-scale=1.0">
741    <title>$self->{title}</title>
742    <script src="https://d3js.org/d3.v7.min.js"></script>
743    <style>
744        .tooltip {
745            position: absolute;
746            background-color: white;
747            border: 1px solid #ccc;
748            padding: 5px;
749            font-size: 12px;
750            pointer-events: none;
751            opacity: 0;
752            transform: translateY(-10px);
753            transition: opacity 0.2s ease-in-out, transform 0.2s ease-in-out;
754        }
755    </style>
756</head>
757<body>
758    <h1 style="text-align: center;">$self->{title}</h1>
759    <svg id="chart" width="$self->{width}" height="$self->{height}" style="border: 1px solid black;"></svg>
760    <div class="tooltip" id="tooltip"></div>
761    <script>
762        const data = $json_data;
763
764        const svg = d3.select("#chart");
765        const tooltip = d3.select("#tooltip");
766        const margin = { top: 20, right: 30, bottom: 40, left: 40 };
767        const width = $self->{width} - margin.left - margin.right;
768        const height = $self->{height} - margin.top - margin.bottom;
769
770        const chart = svg.append("g")
771            .attr("transform", `translate(\${margin.left},\${margin.top})`);
772
773        // Extract all labels and flatten them into a unique array
774        const allLabels = Array.from(new Set(data.flatMap(series => series.data.map(d => d.label))));
775
776        const x = d3.scalePoint()
777            .domain(allLabels)
778            .range([0, width]);
779
780        const y = d3.scaleLinear()
781            .domain([0, d3.max(data.flatMap(series => series.data.map(d => d.value)))])
782            .nice()
783            .range([height, 0]);
784
785        // Define color scale for series
786        const color = d3.scaleOrdinal(d3.schemeCategory10);
787
788        // Add axes
789        chart.append("g")
790            .call(d3.axisLeft(y));
791
792        chart.append("g")
793            .attr("transform", `translate(0,\${height})`)
794            .call(d3.axisBottom(x))
795            .selectAll("text")
796            .attr("transform", "rotate(-45)")
797            .style("text-anchor", "end");
798
799        // Draw lines for each series
800        data.forEach((series, i) => {
801            const line = d3.line()
802                .x(d => x(d.label))
803                .y(d => y(d.value));
804
805            // Add line
806            chart.append("path")
807                .datum(series.data)
808                .attr("fill", "none")
809                .attr("stroke", color(i))
810                .attr("stroke-width", 2)
811                .attr("d", line);
812
813            // Add points and tooltips
814            chart.selectAll(\`circle.series-\${i}\`)
815                .data(series.data)
816                .join("circle")
817                .attr("class", \`series-\${i}\`)
818                .attr("cx", d => x(d.label))
819                .attr("cy", d => y(d.value))
820                .attr("r", 4)
821                .attr("fill", color(i))
822                .on("mouseover", (event, d) => {
823                    tooltip.style("opacity", 1)
824                           .style("transform", "translateY(0)")
825                           .html(\`Series: <b>\${series.name}<\/b><br>Label: <b>\${d.label}<\/b><br>Value: <b>\${d.value}<\/b>\`)
826                           .style("left", (event.pageX + 10) + "px")
827                           .style("top", (event.pageY - 30) + "px");
828                })
829                .on("mousemove", (event) => {
830                    tooltip.style("left", (event.pageX + 10) + "px")
831                           .style("top", (event.pageY - 30) + "px");
832                })
833                .on("mouseout", () => {
834                    tooltip.style("opacity", 0)
835                           .style("transform", "translateY(-10px)");
836                });
837        });
838    </script>
839</body>
840</html>
841HTML
842
843
1
3
    return $html;
844}
845
846 - 863
=head2 render_multi_series_line_chart_with_legends

    $html = $chart->render_multi_series_line_chart_with_legends($data);

Generates HTML and JavaScript code to render a chart of many lines with animated mouseover tooltips.

Accepts the following arguments:

=over 4

=item * C<$data> - An reference to an array of hashes containing data points.
Each data point should be an array reference with two elements: the label (string) and the value (numeric).

=back

Returns a string containing the HTML and JavaScript code for the chart.

=cut
864
865sub render_multi_series_line_chart_with_legends {
866
2
3476
        my($self, $data) = @_;
867
868        # Validate input data
869
2
8
        die 'Data must be an array of hashes' unless ref($data) eq 'ARRAY';
870
871        # Generate JSON for data
872
1
10
        my $json_data = encode_json($data);
873
874        # Generate HTML and D3.js code
875
1
4
        my $html = $self->_preamble();
876
1
8
        $html .= <<"HTML";
877<head>
878    <meta charset="UTF-8">
879    <meta name="viewport" content="width=device-width, initial-scale=1.0">
880    <title>$self->{title}</title>
881    <script src="https://d3js.org/d3.v7.min.js"></script>
882    <style>
883        .tooltip {
884            position: absolute;
885            background-color: white;
886            border: 1px solid #ccc;
887            padding: 5px;
888            font-size: 12px;
889            pointer-events: none;
890            opacity: 0;
891            transform: translateY(-10px);
892            transition: opacity 0.2s ease-in-out, transform 0.2s ease-in-out;
893        }
894        .legend {
895            font-size: 12px;
896            cursor: pointer;
897        }
898        .legend rect {
899            stroke-width: 1;
900            stroke: #ccc;
901        }
902    </style>
903</head>
904<body>
905    <h1 style="text-align: center;">$self->{title}</h1>
906    <svg id="chart" width="$self->{width}" height="$self->{height}" style="border: 1px solid black;"></svg>
907    <div class="tooltip" id="tooltip"></div>
908    <script>
909        const data = $json_data;
910
911        const svg = d3.select("#chart");
912        const tooltip = d3.select("#tooltip");
913        const margin = { top: 20, right: 120, bottom: 40, left: 40 };
914        const width = $self->{width} - margin.left - margin.right;
915        const height = $self->{height} - margin.top - margin.bottom;
916
917        const chart = svg.append("g")
918            .attr("transform", `translate(\${margin.left},\${margin.top})`);
919
920        const legendArea = svg.append("g")
921            .attr("transform", `translate(\${width + margin.left + 20},\${margin.top})`);
922
923        // Extract all labels and flatten them into a unique array
924        const allLabels = Array.from(new Set(data.flatMap(series => series.data.map(d => d.label))));
925
926        const x = d3.scalePoint()
927            .domain(allLabels)
928            .range([0, width]);
929
930        const y = d3.scaleLinear()
931            .domain([0, d3.max(data.flatMap(series => series.data.map(d => d.value)))])
932            .nice()
933            .range([height, 0]);
934
935        // Define color scale for series
936        const color = d3.scaleOrdinal(d3.schemeCategory10);
937
938        // Add axes
939        chart.append("g")
940            .call(d3.axisLeft(y));
941
942        chart.append("g")
943            .attr("transform", `translate(0,\${height})`)
944            .call(d3.axisBottom(x))
945            .selectAll("text")
946            .attr("transform", "rotate(-45)")
947            .style("text-anchor", "end");
948
949        // Draw lines for each series
950        data.forEach((series, i) => {
951            const line = d3.line()
952                .x(d => x(d.label))
953                .y(d => y(d.value));
954
955            // Add line
956            chart.append("path")
957                .datum(series.data)
958                .attr("fill", "none")
959                .attr("stroke", color(i))
960                .attr("stroke-width", 2)
961                .attr("class", \`line-\${i}\`)
962                .attr("d", line);
963
964            // Add points and tooltips
965            chart.selectAll(\`circle.series-\${i}\`)
966                .data(series.data)
967                .join("circle")
968                .attr("class", \`series-\${i}\`)
969                .attr("cx", d => x(d.label))
970                .attr("cy", d => y(d.value))
971                .attr("r", 4)
972                .attr("fill", color(i))
973                .on("mouseover", (event, d) => {
974                    tooltip.style("opacity", 1)
975                           .style("transform", "translateY(0)")
976                           .html(\`Series: <b>\${series.name}<\/b><br>Label: <b>\${d.label}<\/b><br>Value: <b>\${d.value}<\/b>\`)
977                           .style("left", (event.pageX + 10) + "px")
978                           .style("top", (event.pageY - 30) + "px");
979                })
980                .on("mousemove", (event) => {
981                    tooltip.style("left", (event.pageX + 10) + "px")
982                           .style("top", (event.pageY - 30) + "px");
983                })
984                .on("mouseout", () => {
985                    tooltip.style("opacity", 0)
986                           .style("transform", "translateY(-10px)");
987                });
988        });
989
990        // Add legend
991        data.forEach((series, i) => {
992            const legend = legendArea.append("g")
993                .attr("transform", `translate(0, \${i * 20})`)
994                .attr("class", "legend");
995
996            legend.append("rect")
997                .attr("width", 12)
998                .attr("height", 12)
999                .attr("fill", color(i));
1000
1001            legend.append("text")
1002                .attr("x", 20)
1003                .attr("y", 10)
1004                .text(series.name)
1005                .style("alignment-baseline", "middle");
1006
1007            // Optional: Interactive legend for toggling visibility (uncomment to use)
1008            // legend.on("click", () => {
1009            //     const visible = d3.selectAll(\`path.line-\${i}\`).style("opacity") === "1" ? 0 : 1;
1010            //     d3.selectAll(\`path.line-\${i}\`).style("opacity", visible);
1011            //     d3.selectAll(\`circle.series-\${i}\`).style("opacity", visible);
1012            // });
1013        });
1014    </script>
1015</body>
1016</html>
1017HTML
1018
1019
1
3
    return $html;
1020}
1021
1022 - 1039
=head2 render_multi_series_line_chart_with_interactive_legends

    $html = $chart->render_multi_series_line_chart_with_interactive_legends($data);

Generates HTML and JavaScript code to render a chart of many lines with interactive legends to filter, highlight or modify elements based on legend selections.

Accepts the following arguments:

=over 4

=item * C<$data> - An reference to an array of hashes containing data points.
Each data point should be an array reference with two elements: the label (string) and the value (numeric).

=back

Returns a string containing the HTML and JavaScript code for the chart.

=cut
1040
1041sub render_multi_series_line_chart_with_interactive_legends
1042{
1043
2
3659
        my ($self, $data) = @_;
1044
1045        # Validate input data
1046
2
9
        die 'Data must be an array of hashes' unless ref($data) eq 'ARRAY';
1047
1048        # Generate JSON for data
1049
1
10
        my $json_data = encode_json($data);
1050
1051        # Generate HTML and D3.js code
1052
1
3
        my $html = $self->_preamble();
1053
1
11
        $html .= <<"HTML";
1054<head>
1055    <meta charset="UTF-8">
1056    <meta name="viewport" content="width=device-width, initial-scale=1.0">
1057    <title>$self->{title}</title>
1058    <script src="https://d3js.org/d3.v7.min.js"></script>
1059    <style>
1060        .tooltip {
1061            position: absolute;
1062            background-color: white;
1063            border: 1px solid #ccc;
1064            padding: 5px;
1065            font-size: 12px;
1066            pointer-events: none;
1067            opacity: 0;
1068            transform: translateY(-10px);
1069            transition: opacity 0.2s ease-in-out, transform 0.2s ease-in-out;
1070        }
1071        .legend {
1072            font-size: 12px;
1073            cursor: pointer;
1074        }
1075        .legend rect {
1076            stroke-width: 1;
1077            stroke: #ccc;
1078        }
1079    </style>
1080</head>
1081<body>
1082    <h1 style="text-align: center;">$self->{title}</h1>
1083    <svg id="chart" width="$self->{width}" height="$self->{height}" style="border: 1px solid black;"></svg>
1084    <div class="tooltip" id="tooltip"></div>
1085    <script>
1086        const data = $json_data;
1087
1088        const svg = d3.select("#chart");
1089        const tooltip = d3.select("#tooltip");
1090        const margin = { top: 20, right: 150, bottom: 40, left: 40 };
1091        const width = $self->{width} - margin.left - margin.right;
1092        const height = $self->{height} - margin.top - margin.bottom;
1093
1094        const chart = svg.append("g")
1095            .attr("transform", `translate(\${margin.left},\${margin.top})`);
1096
1097        const legendArea = svg.append("g")
1098            .attr("transform", `translate(\${width + margin.left + 20},\${margin.top})`);
1099
1100         // Extract all labels and flatten them into a unique array
1101        const allLabels = Array.from(new Set(data.flatMap(series => series.data.map(d => d.label))));
1102
1103        const x = d3.scalePoint()
1104            .domain(allLabels)
1105            .range([0, width]);
1106
1107        const y = d3.scaleLinear()
1108            .domain([0, d3.max(data.flatMap(series => series.data.map(d => d.value)))])
1109            .nice()
1110            .range([height, 0]);
1111
1112        // Define color scale for series
1113        const color = d3.scaleOrdinal(d3.schemeCategory10);
1114
1115        // Add axes
1116        chart.append("g")
1117            .call(d3.axisLeft(y));
1118
1119        chart.append("g")
1120            .attr("transform", `translate(0,\${height})`)
1121            .call(d3.axisBottom(x))
1122            .selectAll("text")
1123            .attr("transform", "rotate(-45)")
1124            .style("text-anchor", "end");
1125
1126        // Draw lines for each series
1127        data.forEach((series, i) => {
1128            const line = d3.line()
1129                .x(d => x(d.label))
1130                .y(d => y(d.value));
1131
1132            // Add line
1133            chart.append("path")
1134                .datum(series.data)
1135                .attr("fill", "none")
1136                .attr("stroke", color(i))
1137                .attr("stroke-width", 2)
1138                .attr("class", \`line-\${i}\`)
1139                .attr("d", line);
1140
1141            // Add points and tooltips
1142            chart.selectAll(\`circle.series-\${i}\`)
1143                .data(series.data)
1144                .join("circle")
1145                .attr("class", \`series-\${i}\`)
1146                .attr("cx", d => x(d.label))
1147                .attr("cy", d => y(d.value))
1148                .attr("r", 4)
1149                .attr("fill", color(i))
1150                .on("mouseover", (event, d) => {
1151                    tooltip.style("opacity", 1)
1152                           .style("transform", "translateY(0)")
1153                           .html(\`Series: <b>\${series.name}<\/b><br>Label: <b>\${d.label}<\/b><br>Value: <b>\${d.value}<\/b>\`)
1154                           .style("left", (event.pageX + 10) + "px")
1155                           .style("top", (event.pageY - 30) + "px");
1156                })
1157                .on("mousemove", (event) => {
1158                    tooltip.style("left", (event.pageX + 10) + "px")
1159                           .style("top", (event.pageY - 30) + "px");
1160                })
1161                .on("mouseout", () => {
1162                    tooltip.style("opacity", 0)
1163                           .style("transform", "translateY(-10px)");
1164                });
1165        });
1166
1167        // Add legend with interactivity
1168        data.forEach((series, i) => {
1169            const legend = legendArea.append("g")
1170                .attr("transform", `translate(0, \${i * 20})`)
1171                .attr("class", "legend")
1172                .on("click", () => {
1173                    const isVisible = d3.selectAll(\`path.line-\${i}\`).style("opacity") === "1";
1174
1175                    // Toggle visibility
1176                    d3.selectAll(\`path.line-\${i}\`).style("opacity", isVisible ? 0 : 1);
1177                    d3.selectAll(\`circle.series-\${i}\`).style("opacity", isVisible ? 0 : 1);
1178
1179                    // Dim legend if series is hidden
1180                    legend.select("text").style("opacity", isVisible ? 0.5 : 1);
1181                });
1182
1183            legend.append("rect")
1184                .attr("width", 12)
1185                .attr("height", 12)
1186                .attr("fill", color(i));
1187
1188            legend.append("text")
1189                .attr("x", 20)
1190                .attr("y", 10)
1191                .text(series.name)
1192                .style("alignment-baseline", "middle");
1193        });
1194    </script>
1195</body>
1196</html>
1197HTML
1198
1199
1
2
    return $html;
1200}
1201
1202sub _preamble
1203{
1204
7
8
        my $html = <<'HTML';
1205<!DOCTYPE html>
1206<html lang="en">
1207HTML
1208
7
9
        return $html;
1209}
1210
1211sub _head
1212{
1213
2
2
        my $self = shift;
1214
1215
2
4
        my $html = <<"HTML";
1216<head>
1217        <meta charset="UTF-8">
1218        <meta name="viewport" content="width=device-width, initial-scale=1.0">
1219        <title>$self->{title}</title>
1220        <script src="https://d3js.org/d3.v7.min.js"></script>
1221</head>
1222HTML
1223
2
3
        return $html;
1224}
1225
1226 - 1268
=head1 SUPPORT

This module is provided as-is without any warranty.

Please report any bugs or feature requests to C<bug-html-d3 at rt.cpan.org>,
or through the web interface at
L<http://rt.cpan.org/NoAuth/ReportBug.html?Queue=HTML-D3>.
I will be notified, and then you'll
automatically be notified of progress on your bug as I make changes.

You can find documentation for this module with the perldoc command.

    perldoc HTML::D3

You can also look for information at:

=head1 BUGS

It would help to have the render routine to return the head and body components separately.

=head1 SEE ALSO

=over 4

=item * L<Configure an Object at Runtime|Object::Configure>

=item * L<Test Dashboard|https://nigelhorne.github.io/HTML-D3/coverage/>

=back

=head1 AUTHOR

Nigel Horne <njh@nigelhorne.com>

=head1 LICENSE AND COPYRIGHT

Copyright 2025-2026 Nigel Horne.

Usage is subject to the GPL2 licence terms.
If you use it,
please let me know.

=cut
1269
12701;