File Coverage

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

linestmtbrancondsubtimecode
1package HTML::D3;
2
3
27
27
1613436
35
use 5.010;
4
27
27
27
37
18
221
use strict;
5
27
27
27
41
22
603
use warnings;
6
7
27
27
27
47
24
850
use Carp qw(carp);
8
27
27
27
44
22
884
use JSON::MaybeXS;
9
10my $_JSON = JSON::MaybeXS->new(utf8 => 0);
11
27
27
27
5875
2191568
466
use Object::Configure;
12
27
27
27
95
22
457
use Params::Get;
13
27
27
27
63
24
424
use Params::Validate::Strict;
14
27
27
27
45
30
119756
use Scalar::Util qw(blessed looks_like_number);
15
16 - 24
=head1 NAME

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

=head1 VERSION

Version 0.20

=cut
25
26our $VERSION = '0.20';
27
28 - 95
=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

The C<=head3 API SPECIFICATION> subsections use L<Params::Validate::Strict>
schema syntax (C<< type => 'arrayref' >> etc.) as a documentation convention.
The module is also used at runtime in C<new()> to validate constructor
arguments; it is therefore a required runtime dependency.  The schemas describe
the parameter contract in machine-readable notation and can be plumbed into a
WAF or test generator if desired.

=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').

=item * C<responsive> - If true, SVG elements are rendered with
C<viewBox> and C<width="100%" height="auto"> instead of fixed pixel
dimensions, so the chart scales fluidly with its container.
For snippet methods the per-call C<< opts => { responsive => 1 } >>
takes precedence over this object-level setting (default: 0).

=back

=cut
96
97sub new
98{
99
252
801193
        my $class = shift;
100
101
252
566
        my $params = Params::Validate::Strict::validate_strict({
102                args => Params::Get::get_params(undef, \@_) || {},
103                schema => {
104                        height => {
105                                type => 'integer',
106                                optional => 1,
107                                minimum => 1,
108                        }, width => {
109                                type => 'integer',
110                                optional => 1,
111                                minimum => 1,
112                        }, title => {
113                                type => 'string',
114                                optional => 1,
115                        }, responsive => {
116                                type => 'boolean',
117                                optional => 1,
118                        }
119                }
120        });
121
122
252
33456
        if(!defined($class)) {
123
2
2
2
4
                if((scalar keys %{$params}) > 0) {
124
1
12
                        carp(__PACKAGE__, ' use ->new() not ::new() to instantiate');
125
1
221
                        return;
126                }
127                # When called as HTML::D3::new(undef) with no args, default to the package.
128                # Passing args via ::new() is unsupported and carps above.
129
1
2
                $class = __PACKAGE__;
130        } elsif(blessed($class)) {
131
4
4
4
3
7
13
                return bless { %{$class}, %{$params} }, ref($class);
132        }
133
134
247
363
        $params = Object::Configure::configure($class, $params);
135
136        return bless {
137                width      => $params->{width}  || 800,
138                height     => $params->{height} || 600,
139                title      => $params->{title}  || 'Chart',
140
247
684749
                responsive => $params->{responsive} || 0,
141        }, $class;
142}
143
144 - 188
=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.

=head3 Errors

=over 4

=item * Throws C<Data is not optional> when C<$data> is C<undef>.

=item * Throws C<Data must be an array of arrays> when C<$data> is not an ARRAY reference.

=back

=head3 API SPECIFICATION

=head4 Input

    {
        data => {
                type => 'arrayref',
                element_type => [ 'string', 'number' ]
        }
    }

    Each element of C<$data> is C<[ Str, Num ]>; passing C<undef> or a
    non-arrayref dies.

=head4 Output

    Str -- complete HTML5 document starting with C<< <!DOCTYPE html> >>;
           D3.js loaded from CDN; bar chart rendered with C<d3.scaleBand>.

=cut
189
190# Method to render a bar chart with given data
191sub render_bar_chart {
192
11
981
        my ($self, $data) = @_;
193
194
11
24
        die 'Data is not optional' if(!defined($data));
195
196        # Validate input data to ensure it is an array of arrays
197
9
25
        die 'Data must be an array of arrays' unless ref($data) eq 'ARRAY';
198
199        # Generate JSON representation of data
200        my $json_data = $_JSON->encode([
201
5
15
21
117
                map { { label => $_->[0], value => $_->[1] } } @$data
202        ]);
203
204        # Generate HTML and D3.js JavaScript for rendering the bar chart
205
5
70
        my $html = $self->_preamble();
206
5
11
        $html .= $self->_head();
207
5
6
        $html .= <<"HTML";
208<body>
209    <h1 style="text-align: center;">$self->{title}</h1>
210
5
12
    <svg id="chart" ${\ $self->_svg_attrs() }></svg>
211    <script>
212        const data = $json_data;
213
214        const svg = d3.select("#chart");
215        const margin = { top: 20, right: 30, bottom: 40, left: 40 };
216        const width = $self->{width} - margin.left - margin.right;
217        const height = $self->{height} - margin.top - margin.bottom;
218
219        // Set up scales for x and y axes
220        const x = d3.scaleBand()
221            .domain(data.map(d => d.label))
222            .range([0, width])
223            .padding(0.1);
224
225        const y = d3.scaleLinear()
226            .domain([0, d3.max(data, d => d.value)])
227            .nice()
228            .range([height, 0]);
229
230        const chart = svg.append("g")
231            .attr("transform", `translate(\${margin.left},\${margin.top})`);
232
233        // Add bars to the chart
234        chart.append("g")
235            .selectAll("rect")
236            .data(data)
237            .join("rect")
238            .attr("x", d => x(d.label))
239            .attr("y", d => y(d.value))
240            .attr("height", d => height - y(d.value))
241            .attr("width", x.bandwidth())
242            .attr("fill", "steelblue");
243
244        // Add the y-axis
245        chart.append("g")
246            .call(d3.axisLeft(y));
247
248        // Add the x-axis with labels rotated for better readability
249        chart.append("g")
250            .attr("transform", `translate(0,\${height})`)
251            .call(d3.axisBottom(x))
252            .selectAll("text")
253            .attr("transform", "rotate(-45)")
254            .style("text-anchor", "end");
255    </script>
256</body>
257</html>
258HTML
259
260
5
7
    return $html;
261}
262
263 - 307
=head2 render_animated_bar_chart

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

Generates HTML and JavaScript code to render a bar chart where each bar grows
upward from the baseline on page load.  Bars are staggered so they rise
one-after-another from left to right.
Accepts the following arguments:

=over 4

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

=back

Returns a string containing the complete HTML5 document.

=head3 Errors

=over 4

=item * Throws C<Data is not optional> when C<$data> is C<undef>.

=item * Throws C<Data must be an array of arrays> when C<$data> is not an ARRAY reference.

=back

=head3 API SPECIFICATION

=head4 Input

    {
        data => { type => 'arrayref' },
    }

    Each element of C<$data> is C<[ Str, Num ]>; passing C<undef> or a
    non-arrayref dies.

=head4 Output

    Str -- complete HTML5 document; each bar animates from height=0 upward
           using C<d3.transition()> with a staggered per-bar delay.

=cut
308
309sub render_animated_bar_chart {
310
9
899
        my ($self, $data) = @_;
311
312
9
18
        die 'Data is not optional' if(!defined($data));
313
7
19
        die 'Data must be an array of arrays' unless ref($data) eq 'ARRAY';
314
315        my $json_data = $_JSON->encode([
316
3
9
13
60
                map { { label => $_->[0], value => $_->[1] } } @$data
317        ]);
318
319
3
45
        my $html = $self->_preamble();
320
3
6
        $html .= $self->_head();
321
3
2
        $html .= <<"HTML";
322<body>
323    <h1 style="text-align: center;">$self->{title}</h1>
324
3
7
    <svg id="chart" ${\ $self->_svg_attrs() }></svg>
325    <script>
326        const data = $json_data;
327
328        const svg = d3.select("#chart");
329        const margin = { top: 20, right: 30, bottom: 40, left: 40 };
330        const width = $self->{width} - margin.left - margin.right;
331        const height = $self->{height} - margin.top - margin.bottom;
332
333        const x = d3.scaleBand()
334            .domain(data.map(d => d.label))
335            .range([0, width])
336            .padding(0.1);
337
338        const y = d3.scaleLinear()
339            .domain([0, d3.max(data, d => d.value)])
340            .nice()
341            .range([height, 0]);
342
343        const chart = svg.append("g")
344            .attr("transform", `translate(\${margin.left},\${margin.top})`);
345
346        // Each bar starts at the baseline (y=height, height=0) and grows upward.
347        chart.append("g")
348            .selectAll("rect")
349            .data(data)
350            .join("rect")
351            .attr("x", d => x(d.label))
352            .attr("y", height)
353            .attr("height", 0)
354            .attr("width", x.bandwidth())
355            .attr("fill", "steelblue")
356            .transition()
357            .duration(800)
358            .delay((d, i) => i * 100)
359            .attr("y", d => y(d.value))
360            .attr("height", d => height - y(d.value));
361
362        chart.append("g")
363            .call(d3.axisLeft(y));
364
365        chart.append("g")
366            .attr("transform", `translate(0,\${height})`)
367            .call(d3.axisBottom(x))
368            .selectAll("text")
369            .attr("transform", "rotate(-45)")
370            .style("text-anchor", "end");
371    </script>
372</body>
373</html>
374HTML
375
376
3
5
        return $html;
377}
378
379 - 417
=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.

=head3 Errors

=over 4

=item * Throws C<Data must be an array of arrays> when C<$data> is not an ARRAY reference.

=back

=head3 API SPECIFICATION

=head4 Input

    {
        data => { type => 'arrayref' },
    }

    Each element of C<$data> is C<[ Str, Num ]>; passing C<undef> or a
    non-arrayref dies.

=head4 Output

    Str -- complete HTML5 document; line chart with C<d3.scalePoint> and C<d3.line()>.

=cut
418
419sub render_line_chart {
420
5
130
        my ($self, $data) = @_;
421
422        # Validate input data
423
5
20
        die 'Data must be an array of arrays' unless ref($data) eq 'ARRAY';
424
425        # Generate JSON for data
426        my $json_data = $_JSON->encode([
427
3
9
12
61
                map { { label => $_->[0], value => $_->[1] } } @$data
428        ]);
429
430        # Generate HTML and D3.js code
431
3
42
        my $html = $self->_preamble();
432
3
7
        $html .= $self->_head();
433
3
4
        $html .= <<"HTML";
434<body>
435    <h1 style="text-align: center;">$self->{title}</h1>
436
3
8
    <svg id="chart" ${\ $self->_svg_attrs() }></svg>
437    <script>
438        const data = $json_data;
439
440        const svg = d3.select("#chart");
441        const margin = { top: 20, right: 30, bottom: 40, left: 40 };
442        const width = $self->{width} - margin.left - margin.right;
443        const height = $self->{height} - margin.top - margin.bottom;
444
445        const x = d3.scalePoint()
446            .domain(data.map(d => d.label))
447            .range([0, width]);
448
449        const y = d3.scaleLinear()
450            .domain([0, d3.max(data, d => d.value)])
451            .nice()
452            .range([height, 0]);
453
454        const chart = svg.append("g")
455            .attr("transform", `translate(\${margin.left},\${margin.top})`);
456
457        // Draw line
458        const line = d3.line()
459            .x(d => x(d.label))
460            .y(d => y(d.value));
461
462        chart.append("path")
463            .datum(data)
464            .attr("fill", "none")
465            .attr("stroke", "steelblue")
466            .attr("stroke-width", 2)
467            .attr("d", line);
468
469        // Add points to the line
470        chart.selectAll("circle")
471            .data(data)
472            .join("circle")
473            .attr("cx", d => x(d.label))
474            .attr("cy", d => y(d.value))
475            .attr("r", 4)
476            .attr("fill", "steelblue");
477
478        // Add axes
479        chart.append("g")
480            .call(d3.axisLeft(y));
481
482        chart.append("g")
483            .attr("transform", `translate(0,\${height})`)
484            .call(d3.axisBottom(x))
485            .selectAll("text")
486            .attr("transform", "rotate(-45)")
487            .style("text-anchor", "end");
488    </script>
489</body>
490</html>
491HTML
492
493
3
5
        return $html;
494}
495
496 - 539
=head2 render_animated_line_chart

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

Generates HTML and JavaScript code to render a line chart where the line
draws itself from left to right on page load, followed by each data-point
circle fading in once the line is complete.
Accepts the following arguments:

=over 4

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

=back

Returns a string containing the complete HTML5 document.

=head3 Errors

=over 4

=item * Throws C<Data must be an array of arrays> when C<$data> is not an ARRAY reference.

=back

=head3 API SPECIFICATION

=head4 Input

    {
        data => { type => 'arrayref' },
    }

    Each element of C<$data> is C<[ Str, Num ]>; passing C<undef> or a
    non-arrayref dies.

=head4 Output

    Str -- complete HTML5 document; the line path animates via
           C<stroke-dashoffset> with C<d3.easeLinear>; data-point circles
           fade in with C<opacity> after the line transition completes.

=cut
540
541sub render_animated_line_chart {
542
6
331
        my ($self, $data) = @_;
543
544
6
21
        die 'Data must be an array of arrays' unless ref($data) eq 'ARRAY';
545
546        my $json_data = $_JSON->encode([
547
3
9
14
64
                map { { label => $_->[0], value => $_->[1] } } @$data
548        ]);
549
550
3
43
        my $html = $self->_preamble();
551
3
7
        $html .= $self->_head();
552
3
5
        $html .= <<"HTML";
553<body>
554    <h1 style="text-align: center;">$self->{title}</h1>
555
3
6
    <svg id="chart" ${\ $self->_svg_attrs() }></svg>
556    <script>
557        const data = $json_data;
558
559        const svg = d3.select("#chart");
560        const margin = { top: 20, right: 30, bottom: 40, left: 40 };
561        const width = $self->{width} - margin.left - margin.right;
562        const height = $self->{height} - margin.top - margin.bottom;
563
564        const x = d3.scalePoint()
565            .domain(data.map(d => d.label))
566            .range([0, width]);
567
568        const y = d3.scaleLinear()
569            .domain([0, d3.max(data, d => d.value)])
570            .nice()
571            .range([height, 0]);
572
573        const chart = svg.append("g")
574            .attr("transform", `translate(\${margin.left},\${margin.top})`);
575
576        const line = d3.line()
577            .x(d => x(d.label))
578            .y(d => y(d.value));
579
580        // Animate the line drawing left-to-right using stroke-dashoffset.
581        const path = chart.append("path")
582            .datum(data)
583            .attr("fill", "none")
584            .attr("stroke", "steelblue")
585            .attr("stroke-width", 2)
586            .attr("d", line);
587
588        const totalLength = path.node().getTotalLength();
589
590        path
591            .attr("stroke-dasharray", totalLength)
592            .attr("stroke-dashoffset", totalLength)
593            .transition()
594            .duration(1500)
595            .ease(d3.easeLinear)
596            .attr("stroke-dashoffset", 0);
597
598        // Data-point circles fade in after the line finishes.
599        chart.selectAll("circle")
600            .data(data)
601            .join("circle")
602            .attr("cx", d => x(d.label))
603            .attr("cy", d => y(d.value))
604            .attr("r", 4)
605            .attr("fill", "steelblue")
606            .attr("opacity", 0)
607            .transition()
608            .delay(1500)
609            .duration(300)
610            .attr("opacity", 1);
611
612        chart.append("g")
613            .call(d3.axisLeft(y));
614
615        chart.append("g")
616            .attr("transform", `translate(0,\${height})`)
617            .call(d3.axisBottom(x))
618            .selectAll("text")
619            .attr("transform", "rotate(-45)")
620            .style("text-anchor", "end");
621    </script>
622</body>
623</html>
624HTML
625
626
3
4
        return $html;
627}
628
629 - 690
=head2 render_pie_chart

    my $html = $chart->render_pie_chart($data);
    my $html = $chart->render_pie_chart($data, { separator => ':' });

Generates HTML and JavaScript code to render a pie chart.
Each slice is coloured with C<d3.schemeCategory10>; percentage labels appear
inside each slice and a colour legend is shown to the right of the pie.
Accepts the following arguments:

=over 4

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

=item * C<%opts> - Optional hashref of options.

=over 8

=item * C<separator> (string, default C<'/'>) - Character shown between the
label and value in the SVG legend.

=back

=back

Returns a string containing the complete HTML5 document.

=head3 Errors

=over 4

=item * Throws C<Data is not optional> when C<$data> is C<undef>.

=item * Throws C<Data must be an array of arrays> when C<$data> is not an ARRAY reference.

=back

=head3 API SPECIFICATION

=head4 Input

    {
        data => {
                type => 'arrayref',
                element_type => [ 'string', 'number' ]
        },
        opts => { type => 'hashref', optional => 1, default => {} },
    }

    Each element of C<$data> is C<[ Str, Num ]>; passing C<undef> or a
    non-arrayref dies.
    Recognised C<opts> key: C<separator> (string, default C<'/'>)
    - character shown between label and value in the SVG legend.

=head4 Output

    Str -- complete HTML5 document; pie rendered with C<d3.pie()> and
           C<d3.arc()>; slices coloured with C<d3.schemeCategory10>;
           percentage label inside each slice; legend to the right.

=cut
691
692sub render_pie_chart {
693
11
933
        my ($self, $data, $opts) = @_;
694
11
25
        $opts //= {};
695
696
11
25
        die 'Data is not optional' if(!defined($data));
697
9
19
        die 'Data must be an array of arrays' unless ref($data) eq 'ARRAY';
698
699
5
12
        my $separator = $opts->{separator} // '/';
700
701        my $json_data = $_JSON->encode([
702
5
15
20
97
                map { { label => $_->[0], value => $_->[1] } } @$data
703        ]);
704
705
5
66
        my $html = $self->_preamble();
706
5
9
        $html .= $self->_head();
707
5
5
        $html .= <<"HTML";
708<body>
709    <h1 style="text-align: center;">$self->{title}</h1>
710
5
8
    <svg id="chart" ${\ $self->_svg_attrs() }></svg>
711    <script>
712        const data = $json_data;
713
714        const width = $self->{width};
715        const height = $self->{height};
716        const radius = Math.min(width, height) / 2 - 40;
717
718        const color = d3.scaleOrdinal(d3.schemeCategory10);
719
720        const pie = d3.pie()
721            .sort(null)
722            .value(d => d.value);
723
724        const arc = d3.arc()
725            .innerRadius(0)
726            .outerRadius(radius);
727
728        const labelArc = d3.arc()
729            .innerRadius(radius * 0.65)
730            .outerRadius(radius * 0.65);
731
732        const total = d3.sum(data, d => d.value);
733
734        const svg = d3.select("#chart");
735
736        const pieGroup = svg.append("g")
737            .attr("transform", `translate(\${width * 0.45},\${height / 2})`);
738
739        const arcs = pieGroup.selectAll(".arc")
740            .data(pie(data))
741            .join("g")
742            .attr("class", "arc");
743
744        arcs.append("path")
745            .attr("d", arc)
746            .attr("fill", d => color(d.data.label))
747            .attr("stroke", "white")
748            .style("stroke-width", "2px");
749
750        arcs.append("text")
751            .attr("transform", d => `translate(\${labelArc.centroid(d)})`)
752            .attr("text-anchor", "middle")
753            .attr("font-size", "11px")
754            .attr("fill", "white")
755            .attr("pointer-events", "none")
756            .text(d => Math.round(d.data.value / total * 100) + "%");
757
758        // Legend
759        const legend = svg.append("g")
760            .attr("transform", `translate(\${width * 0.72},\${(height - data.length * 22) / 2})`);
761
762        legend.selectAll("rect")
763            .data(pie(data))
764            .join("rect")
765            .attr("x", 0)
766            .attr("y", (d, i) => i * 22)
767            .attr("width", 14)
768            .attr("height", 14)
769            .attr("fill", d => color(d.data.label));
770
771        legend.selectAll("text")
772            .data(pie(data))
773            .join("text")
774            .attr("x", 20)
775            .attr("y", (d, i) => i * 22 + 11)
776            .attr("font-size", "12px")
777            .text(d => `\${d.data.label} $separator \${d.data.value}`);
778    </script>
779</body>
780</html>
781HTML
782
783
5
7
        return $html;
784}
785
786 - 846
=head2 render_animated_pie_chart

    my $html = $chart->render_animated_pie_chart($data);
    my $html = $chart->render_animated_pie_chart($data, { separator => ':' });

Generates HTML and JavaScript code to render an animated pie chart where each
slice fans out from zero angle on page load using C<attrTween> and
C<d3.interpolate>.  Percentage labels fade in once all slices are drawn.
Accepts the following arguments:

=over 4

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

=item * C<%opts> - Optional hashref of options.

=over 8

=item * C<separator> (string, default C<'/'>) - Character shown between the
label and value in the SVG legend.

=back

=back

Returns a string containing the complete HTML5 document.

=head3 Errors

=over 4

=item * Throws C<Data is not optional> when C<$data> is C<undef>.

=item * Throws C<Data must be an array of arrays> when C<$data> is not an ARRAY reference.

=back

=head3 API SPECIFICATION

=head4 Input

    {
        data => {
                type => 'arrayref',
                element_type => [ 'string', 'number' ]
        },
        opts => { type => 'hashref', optional => 1, default => {} },
    }

    Each element of C<$data> is C<[ Str, Num ]>; passing C<undef> or a
    non-arrayref dies.
    Recognised C<opts> key: C<separator> (string, default C<'/'>)
    - character shown between label and value in the SVG legend.

=head4 Output

    Str -- complete HTML5 document; slices animate via C<attrTween> with
           C<d3.interpolate> (1000 ms); percentage labels fade in afterwards.

=cut
847
848sub render_animated_pie_chart {
849
9
629
        my ($self, $data, $opts) = @_;
850
9
18
        $opts //= {};
851
852
9
23
        die 'Data is not optional' if(!defined($data));
853
7
14
        die 'Data must be an array of arrays' unless ref($data) eq 'ARRAY';
854
855
5
11
        my $separator = $opts->{separator} // '/';
856
857        my $json_data = $_JSON->encode([
858
5
15
18
120
                map { { label => $_->[0], value => $_->[1] } } @$data
859        ]);
860
861
5
67
        my $html = $self->_preamble();
862
5
7
        $html .= $self->_head();
863
5
6
        $html .= <<"HTML";
864<body>
865    <h1 style="text-align: center;">$self->{title}</h1>
866
5
8
    <svg id="chart" ${\ $self->_svg_attrs() }></svg>
867    <script>
868        const data = $json_data;
869
870        const width = $self->{width};
871        const height = $self->{height};
872        const radius = Math.min(width, height) / 2 - 40;
873
874        const color = d3.scaleOrdinal(d3.schemeCategory10);
875
876        const pie = d3.pie()
877            .sort(null)
878            .value(d => d.value);
879
880        const arc = d3.arc()
881            .innerRadius(0)
882            .outerRadius(radius);
883
884        const labelArc = d3.arc()
885            .innerRadius(radius * 0.65)
886            .outerRadius(radius * 0.65);
887
888        const total = d3.sum(data, d => d.value);
889
890        const svg = d3.select("#chart");
891
892        const pieGroup = svg.append("g")
893            .attr("transform", `translate(\${width * 0.45},\${height / 2})`);
894
895        const arcs = pieGroup.selectAll(".arc")
896            .data(pie(data))
897            .join("g")
898            .attr("class", "arc");
899
900        // Each slice fans out from zero angle using attrTween.
901        arcs.append("path")
902            .attr("fill", d => color(d.data.label))
903            .attr("stroke", "white")
904            .style("stroke-width", "2px")
905            .transition()
906            .duration(1000)
907            .attrTween("d", function(d) {
908                const i = d3.interpolate({ startAngle: 0, endAngle: 0 }, d);
909                return t => arc(i(t));
910            });
911
912        // Percentage labels fade in after slices finish drawing.
913        arcs.append("text")
914            .attr("transform", d => `translate(\${labelArc.centroid(d)})`)
915            .attr("text-anchor", "middle")
916            .attr("font-size", "11px")
917            .attr("fill", "white")
918            .attr("pointer-events", "none")
919            .attr("opacity", 0)
920            .text(d => Math.round(d.data.value / total * 100) + "%")
921            .transition()
922            .delay(1000)
923            .duration(300)
924            .attr("opacity", 1);
925
926        // Legend
927        const legend = svg.append("g")
928            .attr("transform", `translate(\${width * 0.72},\${(height - data.length * 22) / 2})`);
929
930        legend.selectAll("rect")
931            .data(pie(data))
932            .join("rect")
933            .attr("x", 0)
934            .attr("y", (d, i) => i * 22)
935            .attr("width", 14)
936            .attr("height", 14)
937            .attr("fill", d => color(d.data.label));
938
939        legend.selectAll("text")
940            .data(pie(data))
941            .join("text")
942            .attr("x", 20)
943            .attr("y", (d, i) => i * 22 + 11)
944            .attr("font-size", "12px")
945            .text(d => `\${d.data.label} $separator \${d.data.value}`);
946    </script>
947</body>
948</html>
949HTML
950
951
5
7
        return $html;
952}
953
954 - 1033
=head2 render_pie_chart_snippet

    my $fragment = $chart->render_pie_chart_snippet(\@slices);
    my $fragment = $chart->render_pie_chart_snippet(\@slices, \%opts);
    # $fragment->{svg_id} - always 'pie_chart'
    # $fragment->{html}   - embeddable fragment; caller must load D3 v7

Generates an embeddable pie or donut chart fragment for use in existing HTML
layouts.  Returns C<{ svg_id =E<gt> 'pie_chart', html =E<gt> Str }>.  The
caller is responsible for loading D3 v7 before embedding the fragment.

=head3 Data format

Each element of C<\@slices> is C<[$label, $value]> or C<[$label, $value, \%extra]>.
Negative values are silently converted to their absolute value.  Zero-value
slices are silently omitted.  C<\%extra> key/value pairs are shown as
additional rows in the hover tooltip.

=head3 Options (C<\%opts>)

=over 4

=item * C<animated> (bool, default 0) - fan slices in from arc-length 0 on
first render using C<attrTween> / C<d3.easeBackOut> (800 ms, staggered).
Respects C<prefers-reduced-motion>.

=item * C<donut> (bool, default 0) - render as a donut chart (inner radius
38% of outer radius); the total sum appears in the centre hole.

=item * C<sort_slices> (string, default C<'none'>) - C<'value'> for
largest-first, C<'label'> for alphabetical, C<'none'> for input order.

=item * C<max_slices> (int, default 0) - when E<gt> 0, only the top N-1
slices are shown individually; the rest are collapsed into an C<"Other"> slice.

=item * C<legend> (bool, default 1) - render an HTML legend panel beside the chart.

=item * C<color_scheme> (string, default C<'tableau10'>) - D3 categorical
colour scheme.  Supported: C<tableau10>, C<category10>, C<set2>, C<set3>,
C<paired>.

=item * C<separator> (string, default C<'/'>) - Character shown between the
label and value in each legend entry (e.g. C<'/'> produces
C<Label / 12.34 (42.0%)>, C<':'> produces C<Label : 12.34 (42.0%)>).

=back

=head3 Errors

=over 4

=item * Throws C<Data must be an array of arrays> when C<\@slices> is not an ARRAY reference.

=back

=head3 API SPECIFICATION

=head4 Input

    {
        data => { type => 'arrayref' },
        opts => { type => 'hashref', optional => 1, default => {} },
    }

    Each element of C<$data> is C<[ Str, Num ]> or C<[ Str, Num, HashRef ]>;
    passing C<undef> or a non-arrayref dies.
    Recognised C<opts> keys: C<animated> (boolean, default C<0>),
    C<donut> (boolean, default C<0>), C<sort_slices> (string: C<'value'>,
    C<'label'>, or C<'none'>; default C<'none'>), C<max_slices> (integer,
    default C<0>), C<legend> (boolean, default C<1>),
    C<color_scheme> (string, default C<'tableau10'>),
    C<separator> (string, default C<'/'> - shown between label and value in
    legend entries).

=head4 Output

    HashRef -- C<{ svg_id =E<gt> 'pie_chart', html =E<gt> Str }>;
               embeddable fragment; no DOCTYPE, no page shell, no D3 CDN tag.

=cut
1034
1035sub render_pie_chart_snippet {
1036
38
5890
        my ($self, $data, $opts) = @_;
1037
38
73
        $opts //= {};
1038
1039
38
63
        die 'Data must be an array of arrays' unless ref($data) eq 'ARRAY';
1040
1041
35
46
        my $animated     = $opts->{animated}    ? 1 : 0;
1042
35
32
        my $donut        = $opts->{donut}       ? 1 : 0;
1043
35
60
        my $sort_slices  = $opts->{sort_slices} // 'none';
1044
35
61
        my $max_slices   = int($opts->{max_slices} // 0);
1045
35
37
        my $show_legend  = exists $opts->{legend} ? ($opts->{legend} ? 1 : 0) : 1;
1046
35
49
        my $color_scheme = $opts->{color_scheme} // 'tableau10';
1047
35
55
        my $separator    = $opts->{separator}   // '/';
1048
1049        # Normalise: absolute values, drop zeros, pull optional extra hashref
1050
35
21
        my @slices;
1051
35
80
        for my $pt (@$data) {
1052
100
196
                my $val = abs($pt->[1] // 0);
1053
100
303
                next if $val == 0;
1054
97
81
                my %s = (label => $pt->[0], value => $val);
1055
97
215
                $s{extra} = $pt->[2] if ref($pt->[2]) eq 'HASH';
1056
97
179
                push @slices, \%s;
1057        }
1058
1059        # Sort before max_slices so the "top N" is stable
1060
35
79
        if ($sort_slices eq 'value') {
1061
1
3
3
3
                @slices = sort { $b->{value} <=> $a->{value} } @slices;
1062        } elsif ($sort_slices eq 'label') {
1063
0
0
0
0
                @slices = sort { $a->{label} cmp $b->{label} } @slices;
1064        }
1065
1066        # Collapse tail into "Other"
1067
35
42
        if ($max_slices >= 2 && scalar(@slices) > $max_slices) {
1068
2
16
5
12
                my @by_val = sort { $b->{value} <=> $a->{value} } @slices;
1069
2
6
                my @top    = @by_val[0 .. $max_slices - 2];
1070
2
2
                my $other  = 0;
1071
2
4
                $other += $_->{value} for @by_val[$max_slices - 1 .. $#by_val];
1072
2
2
                push @top, { label => 'Other', value => $other };
1073
2
4
                @slices = @top;
1074        }
1075
1076
35
174
        my $json_data = $_JSON->encode(\@slices);
1077
1078
35
57
        my $svg_id   = $opts->{id} // 'pie_chart';
1079
35
26
        my $tip_id   = $svg_id . '_tip';
1080
35
28
        my $leg_id   = $svg_id . '_legend';
1081
35
22
        my $wrap_id  = $svg_id . '_wrap';
1082
35
30
        my $width    = $self->{width};
1083
35
23
        my $height   = $self->{height};
1084        my $svg_attrs = ($opts->{responsive} // $self->{responsive})
1085
35
75
                ? qq{viewBox="0 0 $width $height" width="100%" height="auto"}
1086                : qq{width="$width" height="$height"};
1087
1088
35
32
        my $inner_radius_js = $donut ? 'radius * 0.38' : '0';
1089
1090        # Animation initialisation (empty when not animated)
1091
35
34
        my $anim_init_js = $animated
1092                ? 'var noAnim = window.matchMedia("(prefers-reduced-motion: reduce)").matches;' . "\n" .
1093                  "    let initialDrawDone = false;\n"
1094                : '';
1095
1096        # Legend fade-in as part of the animation block
1097
35
44
        my $legend_anim_js = ($animated && $show_legend) ? <<"LANIM" : '';
1098        d3.select("#$leg_id")
1099            .style("opacity", 0)
1100            .transition()
1101            .delay(data.length * 60 + 100)
1102            .duration(300)
1103            .style("opacity", 1);
1104LANIM
1105
1106        # Path drawing code — animated vs plain
1107
35
16
        my $anim_draw_js;
1108
35
24
        if ($animated) {
1109
4
5
                $anim_draw_js = <<"ANIM_DRAW";
1110    if (!initialDrawDone && !noAnim) {
1111        paths.attr("d", arc({ startAngle: 0, endAngle: 0 }))
1112            .transition()
1113            .duration(800)
1114            .ease(d3.easeBackOut.overshoot(1.2))
1115            .delay((d, i) => Math.min(i * 60, 300))
1116            .attrTween("d", function(d) {
1117                var interp = d3.interpolate({ startAngle: 0, endAngle: 0 }, d);
1118                return function(t) { return arc(interp(t)); };
1119            });
1120$legend_anim_js        initialDrawDone = true;
1121    } else {
1122        paths.attr("d", arc);
1123    }
1124ANIM_DRAW
1125        } else {
1126
31
25
                $anim_draw_js = "    paths.attr(\"d\", arc);\n";
1127        }
1128
1129        # Centre label in donut hole
1130
35
27
        my $donut_center_js = $donut ? <<'DONUT' : '';
1131    pieGroup.append("text")
1132        .attr("text-anchor", "middle")
1133        .attr("dominant-baseline", "middle")
1134        .attr("font-size", "16px")
1135        .attr("font-weight", "bold")
1136        .text(fmt(total));
1137DONUT
1138
1139        # HTML legend panel — built by D3 against the legend div
1140        # Single-quote heredoc so JS template-literal ${...} is preserved verbatim
1141
35
46
        my $legend_section_js = $show_legend ? <<"LEGEND_JS" : '';
1142    d3.select("#$leg_id")
1143        .selectAll(".bi-pie-legend-entry")
1144        .data(pieSlices)
1145        .join("div")
1146        .attr("class", "bi-pie-legend-entry")
1147        .attr("data-slice-index", (d, i) => i)
1148        .html((d, i) => {
1149            const pct = (d.data.value / total * 100).toFixed(1);
1150            const sw = '<span class="bi-pie-swatch" style="background:' + color(d.data.label) + '"></span>';
1151            return sw + ' ' + d.data.label + ' $separator ' + fmt(d.data.value) + ' (' + pct + '%)';
1152        });
1153LEGEND_JS
1154
1155
35
44
        my $legend_div_html = $show_legend
1156                ? qq(    <div id="$leg_id" class="bi-pie-legend"></div>\n)
1157                : '';
1158
1159
35
164
        my $html = <<"HTML";
1160<style>
1161    #$wrap_id {
1162        display: flex;
1163        flex-wrap: wrap;
1164        align-items: center;
1165    }
1166    .bi-pie-legend {
1167        padding-left: 16px;
1168        font-size: 13px;
1169    }
1170    .bi-pie-legend-entry {
1171        margin: 4px 0;
1172        white-space: nowrap;
1173    }
1174    .bi-pie-swatch {
1175        display: inline-block;
1176        width: 12px;
1177        height: 12px;
1178        border-radius: 2px;
1179        vertical-align: middle;
1180        margin-right: 4px;
1181    }
1182    .bi-pie-tooltip {
1183        position: absolute;
1184        background: rgba(255,255,255,0.95);
1185        border: 1px solid #ccc;
1186        border-radius: 4px;
1187        padding: 6px 10px;
1188        font-size: 12px;
1189        pointer-events: none;
1190        display: none;
1191        line-height: 1.6;
1192    }
1193</style>
1194<div id="$wrap_id">
1195    <svg id="$svg_id" $svg_attrs></svg>
1196$legend_div_html</div>
1197<div class="bi-pie-tooltip" id="$tip_id"></div>
1198<script>
1199    const data = $json_data;
1200
1201    function esc(s) {
1202        return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
1203    }
1204
1205    const SCHEMES = {
1206        tableau10:  d3.schemeTableau10,
1207        category10: d3.schemeCategory10,
1208        set2:       d3.schemeSet2,
1209        set3:       d3.schemeSet3,
1210        paired:     d3.schemePaired,
1211    };
1212    const color = d3.scaleOrdinal(SCHEMES['$color_scheme'] || d3.schemeTableau10)
1213        .domain(data.map(d => d.label));
1214
1215    const width  = $width;
1216    const height = $height;
1217    const radius = Math.min(width, height) / 2 - 40;
1218    const innerRadius = $inner_radius_js;
1219
1220    const arc      = d3.arc().innerRadius(innerRadius).outerRadius(radius);
1221    const labelArc = d3.arc().innerRadius(radius * 0.7).outerRadius(radius * 0.7);
1222
1223    const pie = d3.pie().sort(null).value(d => d.value);
1224    const pieSlices = pie(data);
1225
1226    const total = d3.sum(data, d => d.value);
1227    const fmt   = d3.format(",.2f");
1228
1229    $anim_init_js
1230    const svg     = d3.select("#$svg_id");
1231    const tooltip = d3.select("#$tip_id");
1232
1233    const pieGroup = svg.append("g")
1234        .attr("transform", \`translate(\${$width * 0.5},\${$height / 2})\`);
1235
1236    const arcs = pieGroup.selectAll(".arc")
1237        .data(pieSlices)
1238        .join("g")
1239        .attr("class", "arc");
1240
1241    const paths = arcs.append("path")
1242        .attr("fill", d => color(d.data.label))
1243        .attr("stroke", "white")
1244        .style("stroke-width", "2px")
1245        .attr("data-label", d => d.data.label)
1246        .attr("data-value", d => d.data.value)
1247        .on("mouseover", function(event, d) {
1248            const pct = (d.data.value / total * 100).toFixed(1);
1249            let tip = "<b>" + esc(d.data.label) + "<\\/b><br>" +
1250                      fmt(d.data.value) + " (" + pct + "%)";
1251            if (d.data.extra) {
1252                Object.entries(d.data.extra).forEach(([k, v]) => {
1253                    tip += "<br>" + esc(k) + ": " + esc(v);
1254                });
1255            }
1256            tooltip.html(tip)
1257                   .style("display", "block")
1258                   .style("left", (event.pageX + 12) + "px")
1259                   .style("top",  (event.pageY - 24) + "px");
1260        })
1261        .on("mousemove", function(event) {
1262            tooltip.style("left", (event.pageX + 12) + "px")
1263                   .style("top",  (event.pageY - 24) + "px");
1264        })
1265        .on("mouseout", function() {
1266            tooltip.style("display", "none");
1267        });
1268
1269$anim_draw_js
1270$donut_center_js
1271$legend_section_js</script>
1272HTML
1273
1274
35
135
        return { svg_id => $svg_id, html => $html };
1275}
1276
1277 - 1379
=head2 render_heatmap_snippet

    my $fragment = $chart->render_heatmap_snippet(\@triples);
    my $fragment = $chart->render_heatmap_snippet(\@triples, \%opts);
    # $fragment->{svg_id} - always 'heatmap'
    # $fragment->{html}   - embeddable fragment; caller must load D3 v7

Generates an embeddable grid heatmap for use in existing HTML layouts.
Each cell sits at the intersection of an X-axis label and a Y-axis label;
its colour encodes the cell's numeric value using a sequential D3 colour
scale.  Returns C<{ svg_id =E<gt> 'heatmap', html =E<gt> Str }>.  The
caller is responsible for loading D3 v7 before embedding the fragment.

=head3 Data format

Each element of C<\@triples> is C<[$x_label, $y_label, $value]>.
C<$value> must be numeric or C<undef> (C<undef> rows are silently
skipped).  Zero is a valid value and maps to the lightest cell colour.
The caller is responsible for any aggregation: if multiple triples share
the same (x_label, y_label) pair, the last one wins.

=head3 Options (C<\%opts>)

=over 4

=item * C<color_scheme> (string, default C<'YlOrRd'>) - D3 sequential
colour scheme.  Supported: C<YlOrRd>, C<Blues>, C<Greens>, C<Purples>,
C<RdPu>, C<YlGnBu>.

=item * C<x_label> (string, default C<''>) - Axis title below the X axis.
The value is JavaScript-escaped (backslash, double-quote, newline, carriage
return) before being embedded in the page; other characters are taken
literally.

=item * C<y_label> (string, default C<''>) - Axis title left of the Y axis.
Same JS-escaping as C<x_label> applies.

=item * C<val_label> (string, default C<'Value'>) - Tooltip value label.
Same JS-escaping as C<x_label> applies.

=item * C<show_values> (bool, default 0) - Print value inside each cell.
Auto-suppressed when any cell is narrower than 28 px.

=item * C<cell_padding> (int 0-8, default 2) - Gap in pixels between cells.

=item * C<legend> (bool, default 1) - Render a colour-scale legend bar.

=item * C<animated> (bool, default 0) - Fade cells in on first load.
Respects C<prefers-reduced-motion>.

=back

=head3 Errors

=over 4

=item * Dies with C<Data must be an array of arrays> when C<\@triples>
is not an ARRAY reference.

=item * Dies with C<Each data point must be an array reference> when a
triple element is not an arrayref.

=item * Dies with C<Each data point must have at least 3 elements> when
a triple has fewer than 3 elements.

=item * Dies with C<Value must be numeric> when C<$value> is defined but
not numeric.

=item * Dies with C<Unknown color_scheme: E<lt>nameE<gt>> for an
unsupported C<color_scheme> value.

=item * Dies with C<cell_padding must be between 0 and 8> when
C<cell_padding> is outside the valid range.

=back

=head3 Side Effects

Appends a tooltip C<div> to the page when the fragment is rendered in
the browser.

=head3 API SPECIFICATION

=head4 Input

    {
        data => { type => 'arrayref' },
        opts => { type => 'hashref', optional => 1, default => {} },
    }

    Each element of C<$data> is C<[ Str, Str, Num|undef ]>.
    Recognised C<opts> keys: C<color_scheme> (string, default C<'YlOrRd'>),
    C<x_label> (string, default C<''>), C<y_label> (string, default C<''>),
    C<val_label> (string, default C<'Value'>), C<show_values> (boolean,
    default C<0>), C<cell_padding> (integer 0-8, default C<2>),
    C<legend> (boolean, default C<1>), C<animated> (boolean, default C<0>).

=head4 Output

    HashRef -- C<{ svg_id =E<gt> 'heatmap', html =E<gt> Str }>;
               embeddable fragment; no DOCTYPE, no page shell, no D3 CDN tag.

=cut
1380
1381sub render_heatmap_snippet {
1382
64
8833
        my ($self, $data, $opts) = @_;
1383
64
84
        $opts //= {};
1384
1385
64
87
        die 'Data must be an array of arrays' unless ref($data) eq 'ARRAY';
1386
1387
61
141
        my %color_scheme_map = (
1388                YlOrRd  => 'd3.interpolateYlOrRd',
1389                Blues   => 'd3.interpolateBlues',
1390                Greens  => 'd3.interpolateGreens',
1391                Purples => 'd3.interpolatePurples',
1392                RdPu    => 'd3.interpolateRdPu',
1393                YlGnBu  => 'd3.interpolateYlGnBu',
1394        );
1395
1396
61
90
        my $color_scheme = $opts->{color_scheme} // 'YlOrRd';
1397        die "Unknown color_scheme: $color_scheme"
1398
61
72
                unless exists $color_scheme_map{$color_scheme};
1399
58
43
        my $d3_interpolator = $color_scheme_map{$color_scheme};
1400
1401
58
61
        my $cell_padding = defined($opts->{cell_padding}) ? int($opts->{cell_padding}) : 2;
1402
58
102
        die 'cell_padding must be between 0 and 8'
1403                unless $cell_padding >= 0 && $cell_padding <= 8;
1404
1405
53
77
        my $x_label    = $opts->{x_label}    // '';
1406
53
66
        my $y_label    = $opts->{y_label}    // '';
1407
53
64
        my $val_label  = $opts->{val_label}  // 'Value';
1408
53
50
        my $show_values = $opts->{show_values} ? 1 : 0;
1409
53
53
        my $legend     = exists $opts->{legend} ? ($opts->{legend} ? 1 : 0) : 1;
1410
53
48
        my $animated   = $opts->{animated}   ? 1 : 0;
1411
1412
53
30
        my @triples;
1413
53
93
        for my $pt (@$data) {
1414
165
716
                die 'Each data point must be an array reference'
1415                        unless ref($pt) eq 'ARRAY';
1416
162
329
                die 'Each data point must have at least 3 elements'
1417                        unless scalar(@$pt) >= 3;
1418
157
232
                next unless defined $pt->[2];
1419
154
337
                die 'Value must be numeric' unless looks_like_number($pt->[2]);
1420
151
320
                push @triples, { x => $pt->[0], y => $pt->[1], v => $pt->[2] + 0 };
1421        }
1422
1423
42
350
        my $json_data = $_JSON->encode(\@triples);
1424
1425        # encode_json only accepts refs; escape label strings manually for JS
1426        my ($x_label_esc, $y_label_esc, $val_label_esc) = map {
1427
42
126
44
66
                my $s = $_;
1428
126
90
                $s =~ s/\\/\\\\/g;
1429
126
62
                $s =~ s/"/\\"/g;
1430
126
59
                $s =~ s/\n/\\n/g;
1431
126
97
                $s =~ s/\r/\\r/g;
1432
126
99
                $s
1433        } ($x_label, $y_label, $val_label);
1434
1435
42
63
        my $svg_id = $opts->{id} // 'heatmap';
1436
42
29
        my $tip_id = $svg_id . '_tip';
1437
1438
42
29
        my $width  = $self->{width};
1439
42
28
        my $height = $self->{height};
1440        my $svg_attrs = ($opts->{responsive} // $self->{responsive})
1441
42
98
                ? qq{viewBox="0 0 $width $height" width="100%" height="auto"}
1442                : qq{width="$width" height="$height"};
1443
1444
42
28
        my $margin_top    = 30;
1445
42
31
        my $margin_right  = $legend ? 65 : 20;
1446
42
37
        my $margin_bottom = $x_label ? 60 : 40;
1447
42
28
        my $margin_left   = $y_label ? 80 : 60;
1448
42
29
        my $inner_w = $width  - $margin_left - $margin_right;
1449
42
20
        my $inner_h = $height - $margin_top  - $margin_bottom;
1450
1451
42
39
        my $anim_block = $animated ? <<"ANIM" : '';
1452    var noAnim = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
1453    if (!noAnim) {
1454        cellGs.attr("opacity", 0)
1455            .transition()
1456            .duration(300)
1457            .delay(function(d) { return yMap.get(d.y) * 50; })
1458            .attr("opacity", 1);
1459    }
1460ANIM
1461
1462
42
32
        my $legend_block = $legend ? <<"LEGBLOCK" : '';
1463    {
1464        var defs = svg.append("defs");
1465        var lgId = "heatmap-lg";
1466        var lg = defs.append("linearGradient")
1467            .attr("id", lgId)
1468            .attr("x1", "0").attr("y1", "1")
1469            .attr("x2", "0").attr("y2", "0");
1470        for (var i = 0; i <= 6; i++) {
1471            lg.append("stop")
1472                .attr("offset", (i / 6 * 100) + "%")
1473                .attr("stop-color", colorScale(maxV * i / 6));
1474        }
1475        var lgX = margin.left + innerW + 15;
1476        var lgBarH = Math.min(innerH, 150);
1477        var lgBarY = margin.top + (innerH - lgBarH) / 2;
1478        svg.append("rect")
1479            .attr("x", lgX)
1480            .attr("y", lgBarY)
1481            .attr("width", 12)
1482            .attr("height", lgBarH)
1483            .attr("fill", "url(#" + lgId + ")");
1484        var lgScale = d3.scaleLinear().domain([0, maxV]).range([lgBarH, 0]);
1485        svg.append("g")
1486            .attr("transform", "translate(" + (lgX + 12) + "," + lgBarY + ")")
1487            .call(d3.axisRight(lgScale).ticks(4));
1488    }
1489LEGBLOCK
1490
1491
42
182
        my $html = <<"HTML";
1492<style>
1493    #$svg_id { display: block; }
1494    #$tip_id {
1495        position: absolute;
1496        background: rgba(255,255,255,0.95);
1497        border: 1px solid #ccc;
1498        border-radius: 4px;
1499        padding: 6px 10px;
1500        font-size: 12px;
1501        pointer-events: none;
1502        display: none;
1503        line-height: 1.6;
1504    }
1505    .hm-cell-text {
1506        font-size: 10px;
1507        fill: #222;
1508        pointer-events: none;
1509        text-anchor: middle;
1510        dominant-baseline: middle;
1511    }
1512</style>
1513<svg id="$svg_id" $svg_attrs></svg>
1514<div id="$tip_id"></div>
1515<script>
1516(function() {
1517    var data = $json_data;
1518    var cellPad = $cell_padding;
1519    var showVals0 = $show_values;
1520    var xLabelStr = "$x_label_esc";
1521    var yLabelStr = "$y_label_esc";
1522    var valLabelStr = "$val_label_esc";
1523    var margin = { top: $margin_top, right: $margin_right, bottom: $margin_bottom, left: $margin_left };
1524    var innerW = $inner_w;
1525    var innerH = $inner_h;
1526
1527    function esc(s) {
1528        return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
1529    }
1530
1531    var xMap = new Map(), yMap = new Map();
1532    data.forEach(function(d) {
1533        if (!xMap.has(d.x)) xMap.set(d.x, xMap.size);
1534        if (!yMap.has(d.y)) yMap.set(d.y, yMap.size);
1535    });
1536    var xLabels = Array.from(xMap.keys());
1537    var yLabels = Array.from(yMap.keys());
1538
1539    var maxV = d3.max(data, function(d) { return d.v; }) || 0;
1540    var colorScale = d3.scaleSequential($d3_interpolator)
1541        .domain(maxV === 0 ? [0, 1] : [0, maxV]);
1542
1543    var xPad = Math.min(cellPad / Math.max(1, innerW / Math.max(1, xLabels.length)), 0.4);
1544    var yPad = Math.min(cellPad / Math.max(1, innerH / Math.max(1, yLabels.length)), 0.4);
1545
1546    var xScale = d3.scaleBand().domain(xLabels).range([0, innerW]).paddingInner(xPad);
1547    var yScale = d3.scaleBand().domain(yLabels).range([0, innerH]).paddingInner(yPad);
1548
1549    var showVals = showVals0 && xScale.bandwidth() >= 28 && yScale.bandwidth() >= 28;
1550
1551    var svg = d3.select("#$svg_id");
1552    var g = svg.append("g")
1553        .attr("transform", "translate(" + margin.left + "," + margin.top + ")");
1554
1555    g.append("g").attr("class", "hm-x-axis")
1556        .call(d3.axisTop(xScale).tickSize(0))
1557        .call(function(a) { a.select(".domain").remove(); });
1558    g.append("g").attr("class", "hm-y-axis")
1559        .call(d3.axisLeft(yScale).tickSize(0))
1560        .call(function(a) { a.select(".domain").remove(); });
1561
1562    if (xLabelStr) {
1563        svg.append("text")
1564            .attr("x", margin.left + innerW / 2)
1565            .attr("y", $height - 4)
1566            .attr("text-anchor", "middle")
1567            .attr("font-size", "12px")
1568            .text(xLabelStr);
1569    }
1570    if (yLabelStr) {
1571        svg.append("text")
1572            .attr("transform", "rotate(-90)")
1573            .attr("x", -(margin.top + innerH / 2))
1574            .attr("y", 14)
1575            .attr("text-anchor", "middle")
1576            .attr("font-size", "12px")
1577            .text(yLabelStr);
1578    }
1579
1580    var tip = d3.select("#$tip_id");
1581
1582    var gridMap = new Map();
1583    data.forEach(function(d) { gridMap.set(d.x + "\\t" + d.y, d); });
1584    var cells = Array.from(gridMap.values());
1585
1586    var cellGs = g.selectAll(".hm-cell")
1587        .data(cells)
1588        .join("g")
1589        .attr("class", "hm-cell")
1590        .attr("transform", function(d) {
1591            return "translate(" + xScale(d.x) + "," + yScale(d.y) + ")";
1592        });
1593
1594    cellGs.append("rect")
1595        .attr("width", xScale.bandwidth())
1596        .attr("height", yScale.bandwidth())
1597        .attr("fill", function(d) { return colorScale(d.v); })
1598        .on("mouseover", function(event, d) {
1599            tip.html("X: " + esc(d.x) + "<br>Y: " + esc(d.y) + "<br>" + esc(valLabelStr) + ": " + d.v.toLocaleString())
1600               .style("display", "block")
1601               .style("left", (event.pageX + 12) + "px")
1602               .style("top",  (event.pageY - 24) + "px");
1603        })
1604        .on("mousemove", function(event) {
1605            tip.style("left", (event.pageX + 12) + "px")
1606               .style("top",  (event.pageY - 24) + "px");
1607        })
1608        .on("mouseout", function() { tip.style("display", "none"); });
1609
1610    if (showVals) {
1611        cellGs.append("text")
1612            .attr("class", "hm-cell-text")
1613            .attr("x", xScale.bandwidth() / 2)
1614            .attr("y", yScale.bandwidth() / 2)
1615            .text(function(d) { return d.v.toLocaleString(); });
1616    }
1617
1618$legend_block
1619$anim_block})();
1620</script>
1621HTML
1622
1623
42
170
        return { svg_id => $svg_id, html => $html };
1624}
1625
1626 - 1740
=head2 render_bar_chart_snippet

    my $result = $chart->render_bar_chart_snippet(\@bars);
    my $result = $chart->render_bar_chart_snippet(\@bars, \%opts);

Generates an embeddable D3.js v7 bar chart fragment.  Returns a hashref
C<{ svg_id =E<gt> 'bar_chart', html =E<gt> $str }> where C<$str> is a
self-contained HTML fragment (no page shell, no D3 CDN tag) that the caller
embeds directly after loading D3.js.  C<$str> is a Perl character string
with the UTF-8 flag set (or pure ASCII when all labels are ASCII).

Each element of C<\@bars> is an array reference:

    [ $label, $value ]
    [ $label, $value, \%extra ]

C<$label> is the category name (string); C<$value> is a non-negative number
(negative values are silently converted to their absolute value); the optional
C<\%extra> hashref supplies additional key/value pairs shown in the hover
tooltip.  Data points with an undefined C<$value> are silently skipped.

=head3 Options (C<\%opts>)

=over 4

=item C<orientation> (string, default C<'vertical'>)

C<'vertical'> draws bars rising from a horizontal category axis.
C<'horizontal'> draws bars extending from a vertical category axis.

=item C<sort_bars> (string, default C<'none'>)

Sort order applied before rendering: C<'value'> sorts descending by bar
height, C<'label'> sorts ascending alphabetically, C<'none'> preserves the
input order.

=item C<max_bars> (integer, default C<0>)

When non-zero, only the first C<max_bars> entries (after any sort) are
rendered; the remaining entries are collapsed into a single C<Other> bar
whose value equals their sum.  Zero means no limit.

=item C<color> (string, default C<'steelblue'>)

Either a CSS colour value (e.g. C<'steelblue'>, C<'#4e79a7'>) applied to all
bars, or the special token C<'categorical'>, which colours each bar
distinctively using D3's Tableau-10 palette.

=item C<show_values> (bool, default C<0>)

When true, the numeric value of each bar is printed above vertical bars or
to the right of horizontal bars.

=item C<animated> (bool, default C<0>)

When true, bars grow from the baseline on page load: vertical bars rise from
the bottom; horizontal bars extend from the left.  An 800 ms D3 transition
with a per-bar stagger up to 100 ms is used.  The animation is suppressed
when C<prefers-reduced-motion> is set in the viewer's OS.

=item C<value_label> (string, default C<'Value'>)

Label shown in the hover tooltip before the numeric value.  The value is
JavaScript-escaped before being embedded in the page.

=item C<x_label> (string, default C<''>)

When non-empty, a text label is rendered below the bottom axis.  The value
is JavaScript-escaped before being embedded in the page.

=back

=head3 Errors

=over 4

=item * Throws C<Data must be an array of arrays> when C<$data> is not an
ARRAY reference.

=item * Throws C<Each data point must be an array reference> when an element
is not an array reference.

=item * Throws C<Each data point must have at least 2 elements> when an
element has fewer than 2 items.

=item * Throws C<Value must be numeric> when a C<$value> is not a number.

=item * Throws C<orientation must be 'vertical' or 'horizontal'> on an
invalid orientation value.

=item * Throws C<sort_bars must be 'value', 'label', or 'none'> on an
invalid sort_bars value.

=back

=head3 API SPECIFICATION

=head4 Input

    {
        data => { type => 'arrayref' },
        opts => { type => 'hashref', optional => 1 },
        orientation => { type => 'string', memberof => [ 'vertical', 'horizontal' ], optional => 1 },
        sort_bars => { type => 'string', memberof => [ 'value', 'label', 'none' ], optional => 1 }
    }

    Each element of C<$data>: C<[ Str, Num ]> or C<[ Str, Num, HashRef ]>;
    undef C<$value> silently skipped; negative C<$value> becomes positive.

=head4 Output

    HashRef -- { svg_id => 'bar_chart', html => Str }
    html is a Perl character string (UTF-8 flag set, or pure ASCII).

=cut
1741
1742sub render_bar_chart_snippet {
1743
81
9770
        my ($self, $data, $opts) = @_;
1744
81
130
        $opts //= {};
1745
1746
81
111
        die 'Data must be an array of arrays' unless ref($data) eq 'ARRAY';
1747
1748
78
123
        my $orientation = $opts->{orientation} // 'vertical';
1749
78
124
        die "orientation must be 'vertical' or 'horizontal'"
1750                unless $orientation eq 'vertical' || $orientation eq 'horizontal';
1751
1752
75
111
        my $sort_bars = $opts->{sort_bars} // 'none';
1753        die "sort_bars must be 'value', 'label', or 'none'"
1754
75
225
56
201
                unless grep { $sort_bars eq $_ } qw(value label none);
1755
1756
72
121
        my $max_bars    = int($opts->{max_bars} // 0);
1757
72
90
        my $color       = $opts->{color}       // 'steelblue';
1758
72
81
        my $show_values = $opts->{show_values} ? 1 : 0;
1759
72
57
        my $animated    = $opts->{animated}    ? 1 : 0;
1760
72
104
        my $value_label = $opts->{value_label} // 'Value';
1761
72
103
        my $x_label     = $opts->{x_label}     // '';
1762
1763        # Normalise data points; negative values become positive (bars show magnitude)
1764
72
42
        my @bars;
1765
72
158
        for my $pt (@$data) {
1766
255
1322
                die 'Each data point must be an array reference'
1767                        unless ref($pt) eq 'ARRAY';
1768
252
499
                die 'Each data point must have at least 2 elements'
1769                        unless scalar(@$pt) >= 2;
1770
249
371
                next unless defined $pt->[1];
1771
246
523
                die 'Value must be numeric' unless looks_like_number($pt->[1]);
1772
243
519
                push @bars, {
1773                        label => defined($pt->[0]) ? "$pt->[0]" : '',
1774                        value => abs($pt->[1] + 0),
1775                        (scalar(@$pt) >= 3 && ref($pt->[2]) eq 'HASH'
1776                                ? (extra => $pt->[2]) : ()),
1777                };
1778        }
1779
1780        # Sort before optional truncation
1781
63
394
        if ($sort_bars eq 'value') {
1782
7
35
18
28
                @bars = sort { $b->{value} <=> $a->{value} } @bars;
1783        } elsif ($sort_bars eq 'label') {
1784
2
8
4
7
                @bars = sort { $a->{label} cmp $b->{label} } @bars;
1785        }
1786
1787        # Truncate and collapse the tail into an "Other" bar
1788
63
80
        if ($max_bars && scalar(@bars) > $max_bars) {
1789
4
6
                my @rest  = splice @bars, $max_bars;
1790
4
4
                my $other = 0;
1791
4
7
                $other += $_->{value} for @rest;
1792
4
10
                push @bars, { label => 'Other', value => $other } if @rest;
1793        }
1794
1795        # Serialise to JSON for embedding in the <script> block
1796        my $json_data = $_JSON->encode([map {
1797
63
239
57
224
                my %h = (label => $_->{label}, value => $_->{value} + 0);
1798
239
175
                $h{extra} = $_->{extra} if exists $_->{extra};
1799
239
420
                \%h;
1800        } @bars]);
1801
1802        # Escape strings for safe embedding inside JS double-quoted string literals
1803        my $js_esc = sub {
1804
185
102
                my $s = shift;
1805
185
122
                $s =~ s/\\/\\\\/g;
1806
185
78
                $s =~ s/"/\\"/g;
1807
185
103
                $s =~ s/\n/\\n/g;
1808
185
87
                $s =~ s/\r/\\r/g;
1809
185
121
                $s
1810
63
137
        };
1811
63
68
        my $value_label_esc = $js_esc->($value_label);
1812
63
54
        my $x_label_esc     = $js_esc->($x_label);
1813
1814
63
94
        my $is_categorical = ($color eq 'categorical') ? 1 : 0;
1815
63
67
        my $color_esc      = $is_categorical ? 'steelblue' : $js_esc->($color);
1816
1817
63
101
        my $svg_id = $opts->{id} // 'bar_chart';
1818
63
44
        my $tip_id = $svg_id . '_tip';
1819
1820
63
45
        my $width  = $self->{width};
1821
63
48
        my $height = $self->{height};
1822        my $svg_attrs = ($opts->{responsive} // $self->{responsive})
1823
63
122
                ? qq{viewBox="0 0 $width $height" width="100%" height="auto"}
1824                : qq{width="$width" height="$height"};
1825
1826        # Rotate x-axis labels when there are more than 8 vertical bars
1827
63
119
        my $rotate_labels = ($orientation eq 'vertical' && scalar(@bars) > 8) ? 1 : 0;
1828
1829        # Margins depend on orientation, label rotation, and optional features
1830
63
51
        my ($margin_top, $margin_right, $margin_bottom, $margin_left);
1831
63
58
        if ($orientation eq 'vertical') {
1832
59
30
                $margin_top    = 20;
1833
59
38
                $margin_right  = 20;
1834
59
64
                $margin_bottom = $rotate_labels
1835                        ? ($x_label ? 105 : 85)
1836                        : ($x_label ? 55  : 40);
1837
59
38
                $margin_left   = 55;
1838        } else {
1839
4
3
                $margin_top    = 20;
1840
4
5
                $margin_right  = $show_values ? 60 : 20;
1841
4
5
                $margin_bottom = $x_label ? 55 : 40;
1842
4
27
                $margin_left   = 130;
1843        }
1844
63
59
        my $inner_w = $width  - $margin_left - $margin_right;
1845
63
48
        my $inner_h = $height - $margin_top  - $margin_bottom;
1846
1847        # JS colour initialiser and per-bar fill callback
1848
63
66
        my $color_init_js = $is_categorical
1849                ? 'var colorOf = d3.scaleOrdinal(d3.schemeTableau10)'
1850                  . ".domain(data.map(function(d){return d.label;}));"
1851                : "var colorOf = function() { return \"$color_esc\"; };";
1852
63
39
        my $color_func_js = 'function(d) { return colorOf(d.label); }';
1853
1854        # Rotate x-axis tick labels (vertical orientation with many bars only)
1855
63
52
        my $rotate_block = $rotate_labels ? <<"ROT" : '';
1856    g.selectAll(".bc-x-axis text")
1857        .attr("transform", "rotate(-45)")
1858        .attr("text-anchor", "end")
1859        .attr("dx", "-0.6em")
1860        .attr("dy", "0.15em");
1861ROT
1862
1863        # Optional inline value labels rendered on each bar
1864
63
39
        my $show_values_block = '';
1865
63
52
        if ($show_values) {
1866
4
8
                if ($orientation eq 'vertical') {
1867
3
3
                        $show_values_block = <<"SVB";
1868    g.selectAll(".bc-val-text").data(data).join("text")
1869        .attr("class",        "bc-val-text")
1870        .attr("x",            function(d) { return xScale(d.label) + xScale.bandwidth() / 2; })
1871        .attr("y",            function(d) { return yScale(d.value) - 4; })
1872        .attr("text-anchor",  "middle")
1873        .attr("font-size",    "11px")
1874        .attr("fill",         "#333")
1875        .text(function(d) { return d.value.toLocaleString(); });
1876SVB
1877                } else {
1878
1
1
                        $show_values_block = <<"SVB";
1879    g.selectAll(".bc-val-text").data(data).join("text")
1880        .attr("class",             "bc-val-text")
1881        .attr("x",                 function(d) { return xScale(d.value) + 4; })
1882        .attr("y",                 function(d) { return yScale(d.label) + yScale.bandwidth() / 2; })
1883        .attr("dominant-baseline", "middle")
1884        .attr("font-size",         "11px")
1885        .attr("fill",              "#333")
1886        .text(function(d) { return d.value.toLocaleString(); });
1887SVB
1888                }
1889        }
1890
1891        # Optional text label below the bottom axis
1892
63
45
        my $x_label_block = '';
1893
63
57
        if ($x_label) {
1894
4
8
                my $lx = $margin_left + int($inner_w / 2);
1895
4
16
                my $ly = $height - 5;
1896
4
8
                $x_label_block = <<"XLB";
1897    svg.append("text")
1898        .attr("x",           $lx)
1899        .attr("y",           $ly)
1900        .attr("text-anchor", "middle")
1901        .attr("font-size",   "12px")
1902        .attr("fill",        "#555")
1903        .text("$x_label_esc");
1904XLB
1905        }
1906
1907        # Optional grow-from-baseline animation on page load
1908
63
32
        my $anim_block = '';
1909
63
57
        if ($animated) {
1910
4
5
                if ($orientation eq 'vertical') {
1911
3
3
                        $anim_block = <<"ANIM";
1912    var noAnim = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
1913    if (!noAnim) {
1914        var delayPer = data.length > 1 ? Math.min(100, 600 / (data.length - 1)) : 0;
1915        bars.attr("height", 0).attr("y", innerH)
1916            .transition().duration(800)
1917            .delay(function(d, i) { return i * delayPer; })
1918            .attr("height", function(d) { return Math.max(0, innerH - yScale(d.value)); })
1919            .attr("y",      function(d) { return yScale(d.value); });
1920    }
1921ANIM
1922                } else {
1923
1
2
                        $anim_block = <<"ANIM";
1924    var noAnim = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
1925    if (!noAnim) {
1926        var delayPer = data.length > 1 ? Math.min(100, 600 / (data.length - 1)) : 0;
1927        bars.attr("width", 0)
1928            .transition().duration(800)
1929            .delay(function(d, i) { return i * delayPer; })
1930            .attr("width", function(d) { return Math.max(0, xScale(d.value)); });
1931    }
1932ANIM
1933                }
1934        }
1935
1936        # Orientation-specific D3 scale/axis/bar JavaScript
1937
63
37
        my $chart_js;
1938
63
54
        if ($orientation eq 'vertical') {
1939
59
180
                $chart_js = <<"JS";
1940    var g = svg.append("g")
1941        .attr("transform", "translate(" + margin.left + "," + margin.top + ")");
1942
1943    var xScale = d3.scaleBand()
1944        .domain(data.map(function(d) { return d.label; }))
1945        .range([0, innerW])
1946        .padding(0.2);
1947    var yMax = d3.max(data, function(d) { return d.value; }) || 0;
1948    var yScale = d3.scaleLinear()
1949        .domain([0, yMax]).nice()
1950        .range([innerH, 0]);
1951
1952    g.append("g").attr("class", "bc-x-axis")
1953        .attr("transform", "translate(0," + innerH + ")")
1954        .call(d3.axisBottom(xScale));
1955    g.append("g").attr("class", "bc-y-axis")
1956        .call(d3.axisLeft(yScale));
1957
1958$rotate_block    var bars = g.selectAll(".bc-bar").data(data).join("rect")
1959        .attr("class",  "bc-bar")
1960        .attr("x",      function(d) { return xScale(d.label); })
1961        .attr("y",      function(d) { return yScale(d.value); })
1962        .attr("width",  xScale.bandwidth())
1963        .attr("height", function(d) { return Math.max(0, innerH - yScale(d.value)); })
1964        .attr("fill",   $color_func_js)
1965        .attr("rx", 2)
1966        .on("mouseover", function(event, d) {
1967            var tc = "<strong>" + esc(d.label) + "<\\/strong><br>" + esc(valLabel) + ": " + d.value.toLocaleString();
1968            if (d.extra) {
1969                Object.entries(d.extra).forEach(function(kv) {
1970                    tc += "<br>" + esc(kv[0]) + ": " + esc(String(kv[1]));
1971                });
1972            }
1973            tip.html(tc)
1974               .style("display", "block")
1975               .style("left", (event.pageX + 12) + "px")
1976               .style("top",  (event.pageY - 24) + "px");
1977        })
1978        .on("mousemove", function(event) {
1979            tip.style("left", (event.pageX + 12) + "px")
1980               .style("top",  (event.pageY - 24) + "px");
1981        })
1982        .on("mouseout", function() { tip.style("display", "none"); });
1983
1984$anim_block$show_values_block$x_label_block
1985JS
1986        } else {
1987
4
12
                $chart_js = <<"JS";
1988    var g = svg.append("g")
1989        .attr("transform", "translate(" + margin.left + "," + margin.top + ")");
1990
1991    var yScale = d3.scaleBand()
1992        .domain(data.map(function(d) { return d.label; }))
1993        .range([0, innerH])
1994        .padding(0.2);
1995    var xMax = d3.max(data, function(d) { return d.value; }) || 0;
1996    var xScale = d3.scaleLinear()
1997        .domain([0, xMax]).nice()
1998        .range([0, innerW]);
1999
2000    g.append("g").attr("class", "bc-x-axis")
2001        .attr("transform", "translate(0," + innerH + ")")
2002        .call(d3.axisBottom(xScale));
2003    g.append("g").attr("class", "bc-y-axis")
2004        .call(d3.axisLeft(yScale));
2005
2006    var bars = g.selectAll(".bc-bar").data(data).join("rect")
2007        .attr("class",  "bc-bar")
2008        .attr("x",      0)
2009        .attr("y",      function(d) { return yScale(d.label); })
2010        .attr("width",  function(d) { return Math.max(0, xScale(d.value)); })
2011        .attr("height", yScale.bandwidth())
2012        .attr("fill",   $color_func_js)
2013        .attr("rx", 2)
2014        .on("mouseover", function(event, d) {
2015            var tc = "<strong>" + esc(d.label) + "<\\/strong><br>" + esc(valLabel) + ": " + d.value.toLocaleString();
2016            if (d.extra) {
2017                Object.entries(d.extra).forEach(function(kv) {
2018                    tc += "<br>" + esc(kv[0]) + ": " + esc(String(kv[1]));
2019                });
2020            }
2021            tip.html(tc)
2022               .style("display", "block")
2023               .style("left", (event.pageX + 12) + "px")
2024               .style("top",  (event.pageY - 24) + "px");
2025        })
2026        .on("mousemove", function(event) {
2027            tip.style("left", (event.pageX + 12) + "px")
2028               .style("top",  (event.pageY - 24) + "px");
2029        })
2030        .on("mouseout", function() { tip.style("display", "none"); });
2031
2032$anim_block$show_values_block$x_label_block
2033JS
2034        }
2035
2036
63
200
        my $html = <<"HTML";
2037<style>
2038    #$svg_id { display: block; }
2039    #$tip_id {
2040        position: absolute;
2041        background: rgba(255,255,255,0.95);
2042        border: 1px solid #ccc;
2043        border-radius: 4px;
2044        padding: 6px 10px;
2045        font-size: 12px;
2046        pointer-events: none;
2047        display: none;
2048        line-height: 1.6;
2049    }
2050    .bc-bar { cursor: default; }
2051    .bc-bar:hover { opacity: 0.8; }
2052    .bc-val-text { pointer-events: none; }
2053</style>
2054<svg id="$svg_id" $svg_attrs></svg>
2055<div id="$tip_id"></div>
2056<script>
2057(function() {
2058    var data     = $json_data;
2059    var innerW   = $inner_w;
2060    var innerH   = $inner_h;
2061    var valLabel = "$value_label_esc";
2062    var margin   = { top: $margin_top, right: $margin_right, bottom: $margin_bottom, left: $margin_left };
2063    $color_init_js
2064
2065    function esc(s) {
2066        return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
2067    }
2068
2069    var svg = d3.select("#$svg_id");
2070    var tip = d3.select("#$tip_id");
2071
2072$chart_js})();
2073</script>
2074HTML
2075
2076
63
246
        return { svg_id => $svg_id, html => $html };
2077}
2078
2079 - 2122
=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.
The JavaScript tooltip strings use C<< <\/b> >> (with a backslash) rather than
C<< </b> >> to satisfy html-tidy's requirement that C<< </ >> followed by a
letter not appear literally inside C<< <script> >> blocks.

=head3 Errors

=over 4

=item * Throws C<Data must be an array of arrays> when C<$data> is not an ARRAY reference.

=back

=head3 API SPECIFICATION

=head4 Input

    {
        data => { type => 'arrayref' },
    }

    Each element of C<$data> is C<[ Str, Num ]>; passing C<undef> or a
    non-arrayref dies.

=head4 Output

    Str -- complete HTML5 document; mouseover tooltip reveals label and value.
           Tooltip strings use C<< <\/b> >> not C<< </b> >>.

=cut
2123
2124sub render_line_chart_with_tooltips
2125{
2126
4
110
        my ($self, $data) = @_;
2127
2128        # Validate input data
2129
4
17
        die 'Data must be an array of arrays' unless ref($data) eq 'ARRAY';
2130
2131        # Generate JSON for data
2132        my $json_data = $_JSON->encode([
2133
2
6
8
40
                map { { label => $_->[0], value => $_->[1] } } @$data
2134        ]);
2135
2136        # Generate HTML and D3.js code
2137
2
27
        my $html = $self->_preamble();
2138
2
3
        $html .= <<"HTML";
2139<head>
2140    <meta charset="UTF-8">
2141    <meta name="viewport" content="width=device-width, initial-scale=1.0">
2142    <title>$self->{title}</title>
2143    <script src="https://d3js.org/d3.v7.min.js"></script>
2144    <style>
2145        .tooltip {
2146            position: absolute;
2147            background-color: white;
2148            border: 1px solid #ccc;
2149            padding: 5px;
2150            font-size: 12px;
2151            pointer-events: none;
2152            opacity: 0;
2153            transition: opacity 0.2s ease-in-out;
2154        }
2155    </style>
2156</head>
2157<body>
2158    <h1 style="text-align: center;">$self->{title}</h1>
2159
2
5
    <svg id="chart" ${\ $self->_svg_attrs() }></svg>
2160    <div class="tooltip" id="tooltip"></div>
2161    <script>
2162        const data = $json_data;
2163
2164        const svg = d3.select("#chart");
2165        const tooltip = d3.select("#tooltip");
2166        const margin = { top: 20, right: 30, bottom: 40, left: 40 };
2167        const width = $self->{width} - margin.left - margin.right;
2168        const height = $self->{height} - margin.top - margin.bottom;
2169
2170        const x = d3.scalePoint()
2171            .domain(data.map(d => d.label))
2172            .range([0, width]);
2173
2174        const y = d3.scaleLinear()
2175            .domain([0, d3.max(data, d => d.value)])
2176            .nice()
2177            .range([height, 0]);
2178
2179        const chart = svg.append("g")
2180            .attr("transform", `translate(\${margin.left},\${margin.top})`);
2181
2182        // Draw line
2183        const line = d3.line()
2184            .x(d => x(d.label))
2185            .y(d => y(d.value));
2186
2187        chart.append("path")
2188            .datum(data)
2189            .attr("fill", "none")
2190            .attr("stroke", "steelblue")
2191            .attr("stroke-width", 2)
2192            .attr("d", line);
2193
2194        // Add points to the line
2195        chart.selectAll("circle")
2196            .data(data)
2197            .join("circle")
2198            .attr("cx", d => x(d.label))
2199            .attr("cy", d => y(d.value))
2200            .attr("r", 4)
2201            .attr("fill", "steelblue")
2202            .on("mouseover", (event, d) => {
2203                tooltip.style("opacity", 1)
2204                       .html(`Label: <b>\${d.label}<\\/b><br>Value: <b>\${d.value}<\\/b>`)
2205                       .style("left", (event.pageX + 10) + "px")
2206                       .style("top", (event.pageY - 30) + "px");
2207            })
2208            .on("mousemove", (event) => {
2209                tooltip.style("left", (event.pageX + 10) + "px")
2210                       .style("top", (event.pageY - 30) + "px");
2211            })
2212            .on("mouseout", () => {
2213                tooltip.style("opacity", 0);
2214            });
2215
2216        // Add axes
2217        chart.append("g")
2218            .call(d3.axisLeft(y));
2219
2220        chart.append("g")
2221            .attr("transform", `translate(0,\${height})`)
2222            .call(d3.axisBottom(x))
2223            .selectAll("text")
2224            .attr("transform", "rotate(-45)")
2225            .style("text-anchor", "end");
2226    </script>
2227</body>
2228</html>
2229HTML
2230
2231
2
4
    return $html;
2232}
2233
2234 - 2317
=head2 render_line_chart_snippet

    my $fragment = $chart->render_line_chart_snippet($data);
    my $fragment = $chart->render_line_chart_snippet($data, \%opts);
    # $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>

=head3 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

=item * C<\%opts> - Optional hash reference of rendering options:

=over 4

=item * C<id> (string, default C<'chart'>) - Override the C<id> attribute of
the C<<svg>> element.  Use this when embedding multiple charts on the same page.

=item * C<responsive> (boolean, default C<0>) - If true, emit
C<viewBox="0 0 W H" width="100%" height="auto"> instead of fixed pixel
dimensions, so the chart scales with its container.  Falls back to
C<< $self->{responsive} >> when not set per call.

=back

=back

=head3 Return value

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 (Perl character string).

=back

=head3 Errors

=over 4

=item * Dies with C<'Data must be an array of arrays'> when C<$data> is not an
ARRAY reference.

=back

=head3 Side effects

None.  The method is read-only.

=head3 API SPECIFICATION

    {
        data => { type => 'arrayref' },
        opts => {
            type     => 'hashref',
            optional => 1,
            keys     => {
                id         => { type => 'string',  optional => 1 },
                responsive => { type => 'boolean', optional => 1 },
            },
        },
    }

=cut
2318
2319sub render_line_chart_snippet
2320{
2321
18
4072
        my ($self, $data, $opts) = @_;
2322
18
46
        $opts //= {};
2323
2324
18
48
        die 'Data must be an array of arrays' unless ref($data) eq 'ARRAY';
2325
2326        my $json_data = $_JSON->encode([
2327                map {
2328
15
40
50
78
                        my $point = { label => $_->[0], value => $_->[1] };
2329
40
228
                        $point->{extra} = $_->[2] if ref($_->[2]) eq 'HASH';
2330
40
172
                        $point
2331                } @$data
2332        ]);
2333
2334
15
84
        my $svg_id  = $opts->{id} // 'chart';
2335
15
19
        my $tip_id  = $svg_id . '_tooltip';
2336
15
16
        my $w = $self->{width};
2337
15
17
        my $h = $self->{height};
2338        my $svg_attrs = ($opts->{responsive} // $self->{responsive})
2339
15
45
                ? qq{viewBox="0 0 $w $h" width="100%" height="auto"}
2340                : qq{width="$w" height="$h" style="border: 1px solid black;"};
2341
2342
15
58
        my $html = <<"HTML";
2343<style>
2344    .tooltip {
2345        position: absolute;
2346        background-color: white;
2347        border: 1px solid #ccc;
2348        padding: 5px;
2349        font-size: 12px;
2350        pointer-events: none;
2351        opacity: 0;
2352        transition: opacity 0.2s ease-in-out;
2353    }
2354</style>
2355<svg id="$svg_id" $svg_attrs></svg>
2356<div class="tooltip" id="$tip_id"></div>
2357<script>
2358    const data = $json_data;
2359
2360    const svg = d3.select("#$svg_id");
2361    const tooltip = d3.select("#$tip_id");
2362    const margin = { top: 20, right: 30, bottom: 40, left: 40 };
2363    const width = $w - margin.left - margin.right;
2364    const height = $h - margin.top - margin.bottom;
2365
2366    const x = d3.scalePoint()
2367        .domain(data.map(d => d.label))
2368        .range([0, width]);
2369
2370    const y = d3.scaleLinear()
2371        .domain([0, d3.max(data, d => d.value)])
2372        .nice()
2373        .range([height, 0]);
2374
2375    const chart = svg.append("g")
2376        .attr("transform", `translate(\${margin.left},\${margin.top})`);
2377
2378    const line = d3.line()
2379        .x(d => x(d.label))
2380        .y(d => y(d.value));
2381
2382    chart.append("path")
2383        .datum(data)
2384        .attr("fill", "none")
2385        .attr("stroke", "steelblue")
2386        .attr("stroke-width", 2)
2387        .attr("d", line);
2388
2389    chart.selectAll("circle")
2390        .data(data)
2391        .join("circle")
2392        .attr("cx", d => x(d.label))
2393        .attr("cy", d => y(d.value))
2394        .attr("r", 4)
2395        .attr("fill", "steelblue")
2396        .on("mouseover", (event, d) => {
2397            let ttHtml = `Label: <b>\${d.label}<\\/b><br>Value: <b>\${d.value}<\\/b>`;
2398            if (d.extra) {
2399                Object.entries(d.extra).forEach(([k, v]) => {
2400                    ttHtml += `<br>\${k}: <b>\${v}<\\/b>`;
2401                });
2402            }
2403            tooltip.style("opacity", 1)
2404                   .html(ttHtml)
2405                   .style("left", (event.pageX + 10) + "px")
2406                   .style("top", (event.pageY - 30) + "px");
2407        })
2408        .on("mousemove", (event) => {
2409            tooltip.style("left", (event.pageX + 10) + "px")
2410                   .style("top", (event.pageY - 30) + "px");
2411        })
2412        .on("mouseout", () => {
2413            tooltip.style("opacity", 0);
2414        });
2415
2416    chart.append("g")
2417        .call(d3.axisLeft(y));
2418
2419    chart.append("g")
2420        .attr("transform", `translate(0,\${height})`)
2421        .call(d3.axisBottom(x))
2422        .selectAll("text")
2423        .attr("transform", "rotate(-45)")
2424        .style("text-anchor", "end");
2425</script>
2426HTML
2427
2428
15
48
        return { svg_id => $svg_id, html => $html };
2429}
2430
2431 - 2486
=head2 render_zoomable_line_chart_snippet

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

Like C<render_line_chart_snippet>, but adds brush-to-zoom: the user can drag
across a range of the x-axis to zoom into that region. A I<Reset zoom> button
(hidden until a zoom is active) returns the chart to its original extent.
Subsequent brushes on the zoomed view zoom in further; Reset always returns to
the full dataset.

The caller is responsible for loading D3 in the page C<<head>>.

Accepts the same arguments as C<render_line_chart_snippet>: an array reference
of data points, each C<[$x, $y]> or C<[$x, $y, \%extra]>, plus an optional
second argument C<$opts> (hashref).

=head3 Options

=over 4

=item * C<animated> (boolean, default C<0>) - when true, the initial page load
animates the line drawing left-to-right via the C<stroke-dashoffset> technique
(1200 ms, C<d3.easeLinear>), then fades in data-point circles after the line
finishes (300 ms after a 1200 ms delay).  Respects
C<prefers-reduced-motion>: when the user has requested reduced motion the line
is drawn immediately at full opacity.  Subsequent zoom and reset redraws are
never animated regardless of this flag.

=back

=head3 API SPECIFICATION

=head4 Input

    {
        data => { type => 'arrayref' },
        opts => { type => 'hashref', optional => 1, default => {} },
    }

    Each element of C<$data> is C<[ Str, Num ]> or C<[ Str, Num, HashRef ]>;
    passing C<undef> or a non-arrayref dies.
    Recognised C<opts> key: C<animated> (boolean, default C<0>).

=head4 Output

    HashRef -- C<{ svg_id =E<gt> 'chart', html =E<gt> Str }>;
               embeddable fragment; no DOCTYPE, no page shell, no D3 CDN tag.

=head3 Errors

Dies with I<Data must be an array of arrays> if C<$data> is not an arrayref.

=cut
2487
2488sub render_zoomable_line_chart_snippet
2489{
2490
25
8343
        my ($self, $data, $opts) = @_;
2491
25
52
        $opts //= {};
2492
2493
25
71
        die 'Data must be an array of arrays' unless ref($data) eq 'ARRAY';
2494
2495        my $json_data = $_JSON->encode([
2496                map {
2497
20
63
71
109
                        my $point = { label => $_->[0], value => $_->[1] };
2498
63
331
                        $point->{extra} = $_->[2] if ref($_->[2]) eq 'HASH';
2499
63
228
                        $point
2500                } @$data
2501        ]);
2502
2503
20
78
        my $svg_id = $opts->{id} // 'chart';
2504
20
24
        my $tip_id = $svg_id . '_tooltip';
2505
20
25
        my $rst_id = $svg_id . '_reset';
2506
20
25
        my $w      = $self->{width};
2507
20
15
        my $h      = $self->{height};
2508        my $svg_attrs = ($opts->{responsive} // $self->{responsive})
2509
20
66
                ? qq{viewBox="0 0 $w $h" width="100%" height="auto"}
2510                : qq{width="$w" height="$h" style="border: 1px solid black;"};
2511
2512        # Single-quote heredoc so <\/b> is preserved verbatim in the output —
2513        # double-quote would require <\\/b> to survive Perl interpolation.
2514
20
17
        my $circle_handlers = <<'HANDLERS';
2515                .on("mouseover", (event, d) => {
2516                    let ttHtml = `Label: <b>${d.label}<\/b><br>Value: <b>${d.value}<\/b>`;
2517                    if (d.extra) {
2518                        Object.entries(d.extra).forEach(([k, v]) => {
2519                            ttHtml += `<br>${k}: <b>${v}<\/b>`;
2520                        });
2521                    }
2522                    tooltip.style("opacity", 1)
2523                           .html(ttHtml)
2524                           .style("left", (event.pageX + 10) + "px")
2525                           .style("top",  (event.pageY - 30) + "px");
2526                })
2527                .on("mousemove", (event) => {
2528                    tooltip.style("left", (event.pageX + 10) + "px")
2529                           .style("top",  (event.pageY - 30) + "px");
2530                })
2531                .on("mouseout", () => {
2532                    tooltip.style("opacity", 0);
2533                })
2534HANDLERS
2535
2536
20
25
        my $init_flag   = $opts->{animated} ? 'let initialDrawDone = false;' : '';
2537
2538
20
13
        my $redraw_body;
2539
20
21
        if ($opts->{animated}) {
2540
4
14
                $redraw_body = <<"ANIM";
2541        if (!initialDrawDone) {
2542            var prefersReduced = window.matchMedia &&
2543                window.matchMedia('(prefers-reduced-motion: reduce)').matches;
2544            linePath.datum(newData).attr("d", lineFn);
2545            if (prefersReduced) {
2546                linePath.attr("stroke-dasharray", null).attr("stroke-dashoffset", null);
2547            } else {
2548                const L = linePath.node().getTotalLength();
2549                linePath
2550                    .attr("stroke-dasharray", L)
2551                    .attr("stroke-dashoffset", L)
2552                    .transition()
2553                    .duration(1200)
2554                    .ease(d3.easeLinear)
2555                    .attr("stroke-dashoffset", 0);
2556            }
2557            chart.selectAll("circle.pt")
2558                .data(newData, d => d.label)
2559                .join(
2560                    enter => enter.append("circle")
2561                        .attr("class", "pt")
2562                        .attr("r", 4)
2563                        .attr("fill", "steelblue")
2564                        .attr("cx", d => x(d.label))
2565                        .attr("cy", d => y(d.value))
2566                        .attr("opacity", prefersReduced ? 1 : 0)
2567                )
2568$circle_handlers                .transition()
2569                .duration(prefersReduced ? 0 : 300)
2570                .delay(prefersReduced ? 0 : 1200)
2571                .attr("opacity", 1);
2572            initialDrawDone = true;
2573        } else {
2574            linePath.datum(newData).transition(t).attr("d", lineFn);
2575            chart.selectAll("circle.pt")
2576                .data(newData, d => d.label)
2577                .join(
2578                    enter => enter.append("circle")
2579                        .attr("class", "pt")
2580                        .attr("r", 4)
2581                        .attr("fill", "steelblue")
2582                        .attr("cx", d => x(d.label))
2583                        .attr("cy", d => y(d.value))
2584                )
2585$circle_handlers                .transition(t)
2586                .attr("cx", d => x(d.label))
2587                .attr("cy", d => y(d.value));
2588        }
2589ANIM
2590        } else {
2591
16
21
                $redraw_body = <<"PLAIN";
2592        linePath.datum(newData).transition(t).attr("d", lineFn);
2593
2594        chart.selectAll("circle.pt")
2595            .data(newData, d => d.label)
2596            .join(
2597                enter => enter.append("circle")
2598                    .attr("class", "pt")
2599                    .attr("r", 4)
2600                    .attr("fill", "steelblue")
2601                    .attr("cx", d => x(d.label))
2602                    .attr("cy", d => y(d.value))
2603            )
2604$circle_handlers            .transition(t)
2605            .attr("cx", d => x(d.label))
2606            .attr("cy", d => y(d.value));
2607PLAIN
2608        }
2609
2610
20
102
        my $html = <<"HTML";
2611<style>
2612    .tooltip {
2613        position: absolute;
2614        background-color: white;
2615        border: 1px solid #ccc;
2616        padding: 5px;
2617        font-size: 12px;
2618        pointer-events: none;
2619        opacity: 0;
2620        transition: opacity 0.2s ease-in-out;
2621    }
2622    #$rst_id {
2623        display: none;
2624        margin-bottom: 4px;
2625        cursor: pointer;
2626    }
2627    .brush .selection {
2628        fill: steelblue;
2629        fill-opacity: 0.15;
2630        stroke: steelblue;
2631        stroke-width: 1;
2632    }
2633</style>
2634<button id="$rst_id">Reset zoom</button>
2635<svg id="$svg_id" $svg_attrs></svg>
2636<div class="tooltip" id="$tip_id"></div>
2637<script>
2638    const allData     = $json_data;
2639    let   currentData = allData.slice();
2640
2641    const svg      = d3.select("#$svg_id");
2642    const tooltip  = d3.select("#$tip_id");
2643    const resetBtn = d3.select("#$rst_id");
2644    const margin   = { top: 20, right: 30, bottom: 40, left: 40 };
2645    const width    = $w  - margin.left - margin.right;
2646    const height   = $h - margin.top  - margin.bottom;
2647
2648    const chart = svg.append("g")
2649        .attr("transform", `translate(\${margin.left},\${margin.top})`);
2650
2651    // Scales (domain set in redraw)
2652    const x = d3.scalePoint().range([0, width]);
2653    const y = d3.scaleLinear().range([height, 0]);
2654
2655    const lineFn = d3.line()
2656        .x(d => x(d.label))
2657        .y(d => y(d.value));
2658
2659    // Brush appended first so circles sit above it and receive mouse events
2660    const brush = d3.brushX()
2661        .extent([[0, 0], [width, height]])
2662        .on("end", brushed);
2663    const brushGroup = chart.append("g").attr("class", "brush").call(brush);
2664
2665    const linePath = chart.append("path")
2666        .attr("fill", "none")
2667        .attr("stroke", "steelblue")
2668        .attr("stroke-width", 2);
2669
2670    const yAxisG = chart.append("g");
2671    const xAxisG = chart.append("g").attr("transform", `translate(0,\${height})`);
2672
2673    $init_flag
2674
2675    function redraw(newData, ms) {
2676        x.domain(newData.map(d => d.label));
2677        y.domain([Math.min(0, d3.min(newData, d => d.value)), d3.max(newData, d => d.value)]).nice();
2678
2679        const t = svg.transition().duration(ms);
2680
2681        xAxisG.transition(t)
2682            .call(d3.axisBottom(x))
2683            .selectAll("text")
2684            .attr("transform", "rotate(-45)")
2685            .style("text-anchor", "end");
2686
2687        yAxisG.transition(t).call(d3.axisLeft(y));
2688
2689$redraw_body    }
2690
2691    redraw(currentData, 0);
2692
2693    function brushed(event) {
2694        if (!event.selection) return;
2695        const [x0, x1] = event.selection;
2696        const zoomed = currentData.filter(d => {
2697            const px = x(d.label);
2698            return px >= x0 - 1 && px <= x1 + 1;
2699        });
2700        brushGroup.call(brush.move, null);   // clear brush rectangle
2701        if (zoomed.length < 2) return;
2702        currentData = zoomed;
2703        redraw(currentData, 500);
2704        resetBtn.style("display", "inline-block");
2705    }
2706
2707    resetBtn.on("click", () => {
2708        currentData = allData.slice();
2709        redraw(currentData, 500);
2710        resetBtn.style("display", "none");
2711    });
2712</script>
2713HTML
2714
2715
20
57
        return { svg_id => $svg_id, html => $html };
2716}
2717
2718 - 2766
=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 array reference of series hashes. Each element is a hashref
with a C<name> key (string) and a C<data> key (array reference of C<< {label, value} >>
hashrefs).

    [
        { name => 'Series A', data => [{ label => 'Jan', value => 100 }, ...] },
        ...
    ]

=back

Returns a string containing the HTML and JavaScript code for the chart.
Tooltip strings use C<< <\/b> >> rather than C<< </b> >> for html-tidy compliance.

=head3 Errors

=over 4

=item * Throws C<Data must be an array of hashes> when C<$data> is not an ARRAY reference.

=back

=head3 API SPECIFICATION

=head4 Input

    {
        data => { type => 'arrayref' },
    }

    Each element of C<$data> is a hashref with keys C<name> (string) and
    C<data> (arrayref of hashrefs with C<label> and C<value> keys);
    passing C<undef> or a non-arrayref dies.

=head4 Output

    Str -- complete HTML5 document; one coloured line per series with mouseover tooltips.

=cut
2767
2768sub render_multi_series_line_chart_with_tooltips
2769{
2770
4
275
        my ($self, $data) = @_;
2771
2772        # Validate input data
2773
4
20
        die 'Data must be an array of hashes' unless ref($data) eq 'ARRAY';
2774
2775
2
13
        my $json_data = $_JSON->encode($data);
2776
2777        # Generate HTML and D3.js code
2778
2
152
        my $html = $self->_preamble();
2779
2
4
        $html .= <<"HTML";
2780<head>
2781    <meta charset="UTF-8">
2782    <meta name="viewport" content="width=device-width, initial-scale=1.0">
2783    <title>$self->{title}</title>
2784    <script src="https://d3js.org/d3.v7.min.js"></script>
2785    <style>
2786        .tooltip {
2787            position: absolute;
2788            background-color: white;
2789            border: 1px solid #ccc;
2790            padding: 5px;
2791            font-size: 12px;
2792            pointer-events: none;
2793            opacity: 0;
2794            transition: opacity 0.2s ease-in-out;
2795        }
2796    </style>
2797</head>
2798<body>
2799    <h1 style="text-align: center;">$self->{title}</h1>
2800
2
4
    <svg id="chart" ${\ $self->_svg_attrs() }></svg>
2801    <div class="tooltip" id="tooltip"></div>
2802    <script>
2803        const data = $json_data;
2804
2805        const svg = d3.select("#chart");
2806        const tooltip = d3.select("#tooltip");
2807        const margin = { top: 20, right: 30, bottom: 40, left: 40 };
2808        const width = $self->{width} - margin.left - margin.right;
2809        const height = $self->{height} - margin.top - margin.bottom;
2810
2811        const chart = svg.append("g")
2812            .attr("transform", `translate(\${margin.left},\${margin.top})`);
2813
2814        // Extract all labels and flatten them into a unique array
2815        const allLabels = Array.from(new Set(data.flatMap(series => series.data.map(d => d.label))));
2816
2817        const x = d3.scalePoint()
2818            .domain(allLabels)
2819            .range([0, width]);
2820
2821        const y = d3.scaleLinear()
2822            .domain([0, d3.max(data.flatMap(series => series.data.map(d => d.value)))])
2823            .nice()
2824            .range([height, 0]);
2825
2826        // Define color scale for series
2827        const color = d3.scaleOrdinal(d3.schemeCategory10);
2828
2829        // Add axes
2830        chart.append("g")
2831            .call(d3.axisLeft(y));
2832
2833        chart.append("g")
2834            .attr("transform", `translate(0,\${height})`)
2835            .call(d3.axisBottom(x))
2836            .selectAll("text")
2837            .attr("transform", "rotate(-45)")
2838            .style("text-anchor", "end");
2839
2840        // Draw lines for each series
2841        data.forEach((series, i) => {
2842            const line = d3.line()
2843                .x(d => x(d.label))
2844                .y(d => y(d.value));
2845
2846            // Add line
2847            chart.append("path")
2848                .datum(series.data)
2849                .attr("fill", "none")
2850                .attr("stroke", color(i))
2851                .attr("stroke-width", 2)
2852                .attr("d", line);
2853
2854            // Add points and tooltips
2855            chart.selectAll(\`circle.series-\${i}\`)
2856                .data(series.data)
2857                .join("circle")
2858                .attr("class", \`series-\${i}\`)
2859                .attr("cx", d => x(d.label))
2860                .attr("cy", d => y(d.value))
2861                .attr("r", 4)
2862                .attr("fill", color(i))
2863                .on("mouseover", (event, d) => {
2864                    tooltip.style("opacity", 1)
2865                           .html(\`Series: <b>\${series.name}<\\/b><br>Label: <b>\${d.label}<\\/b><br>Value: <b>\${d.value}<\\/b>\`)
2866                           .style("left", (event.pageX + 10) + "px")
2867                           .style("top", (event.pageY - 30) + "px");
2868                })
2869                .on("mousemove", (event) => {
2870                    tooltip.style("left", (event.pageX + 10) + "px")
2871                           .style("top", (event.pageY - 30) + "px");
2872                })
2873                .on("mouseout", () => {
2874                    tooltip.style("opacity", 0);
2875                });
2876        });
2877    </script>
2878</body>
2879</html>
2880HTML
2881
2882
2
4
    return $html;
2883}
2884
2885 - 2928
=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> - Same format as C<render_multi_series_line_chart_with_tooltips>:
an array reference of C<< { name, data } >> series hashes.

=back

Returns a string containing the complete HTML5 document.
The tooltip appears with a CSS C<translateY> slide-in animation.
Tooltip strings use C<< <\/b> >> for html-tidy compliance.

=head3 Errors

=over 4

=item * Throws C<Data must be an array of hashes> when C<$data> is not an ARRAY reference.

=back

=head3 API SPECIFICATION

=head4 Input

    {
        data => { type => 'arrayref' },
    }

    Each element of C<$data> is a hashref with keys C<name> (string) and
    C<data> (arrayref of hashrefs with C<label> and C<value> keys);
    passing C<undef> or a non-arrayref dies.

=head4 Output

    Str -- complete HTML5 document; animated tooltip uses CSS translateY transition.

=cut
2929
2930sub render_multi_series_line_chart_with_animated_tooltips
2931{
2932
4
271
        my ($self, $data) = @_;
2933
2934        # Validate input data
2935
4
20
        die 'Data must be an array of hashes' unless ref($data) eq 'ARRAY';
2936
2937        # Generate JSON for data
2938
2
13
        my $json_data = $_JSON->encode($data);
2939
2940        # Generate HTML and D3.js code
2941
2
149
        my $html = $self->_preamble();
2942
2
3
        $html .= <<"HTML";
2943<head>
2944    <meta charset="UTF-8">
2945    <meta name="viewport" content="width=device-width, initial-scale=1.0">
2946    <title>$self->{title}</title>
2947    <script src="https://d3js.org/d3.v7.min.js"></script>
2948    <style>
2949        .tooltip {
2950            position: absolute;
2951            background-color: white;
2952            border: 1px solid #ccc;
2953            padding: 5px;
2954            font-size: 12px;
2955            pointer-events: none;
2956            opacity: 0;
2957            transform: translateY(-10px);
2958            transition: opacity 0.2s ease-in-out, transform 0.2s ease-in-out;
2959        }
2960    </style>
2961</head>
2962<body>
2963    <h1 style="text-align: center;">$self->{title}</h1>
2964
2
5
    <svg id="chart" ${\ $self->_svg_attrs() }></svg>
2965    <div class="tooltip" id="tooltip"></div>
2966    <script>
2967        const data = $json_data;
2968
2969        const svg = d3.select("#chart");
2970        const tooltip = d3.select("#tooltip");
2971        const margin = { top: 20, right: 30, bottom: 40, left: 40 };
2972        const width = $self->{width} - margin.left - margin.right;
2973        const height = $self->{height} - margin.top - margin.bottom;
2974
2975        const chart = svg.append("g")
2976            .attr("transform", `translate(\${margin.left},\${margin.top})`);
2977
2978        // Extract all labels and flatten them into a unique array
2979        const allLabels = Array.from(new Set(data.flatMap(series => series.data.map(d => d.label))));
2980
2981        const x = d3.scalePoint()
2982            .domain(allLabels)
2983            .range([0, width]);
2984
2985        const y = d3.scaleLinear()
2986            .domain([0, d3.max(data.flatMap(series => series.data.map(d => d.value)))])
2987            .nice()
2988            .range([height, 0]);
2989
2990        // Define color scale for series
2991        const color = d3.scaleOrdinal(d3.schemeCategory10);
2992
2993        // Add axes
2994        chart.append("g")
2995            .call(d3.axisLeft(y));
2996
2997        chart.append("g")
2998            .attr("transform", `translate(0,\${height})`)
2999            .call(d3.axisBottom(x))
3000            .selectAll("text")
3001            .attr("transform", "rotate(-45)")
3002            .style("text-anchor", "end");
3003
3004        // Draw lines for each series
3005        data.forEach((series, i) => {
3006            const line = d3.line()
3007                .x(d => x(d.label))
3008                .y(d => y(d.value));
3009
3010            // Add line
3011            chart.append("path")
3012                .datum(series.data)
3013                .attr("fill", "none")
3014                .attr("stroke", color(i))
3015                .attr("stroke-width", 2)
3016                .attr("d", line);
3017
3018            // Add points and tooltips
3019            chart.selectAll(\`circle.series-\${i}\`)
3020                .data(series.data)
3021                .join("circle")
3022                .attr("class", \`series-\${i}\`)
3023                .attr("cx", d => x(d.label))
3024                .attr("cy", d => y(d.value))
3025                .attr("r", 4)
3026                .attr("fill", color(i))
3027                .on("mouseover", (event, d) => {
3028                    tooltip.style("opacity", 1)
3029                           .style("transform", "translateY(0)")
3030                           .html(\`Series: <b>\${series.name}<\\/b><br>Label: <b>\${d.label}<\\/b><br>Value: <b>\${d.value}<\\/b>\`)
3031                           .style("left", (event.pageX + 10) + "px")
3032                           .style("top", (event.pageY - 30) + "px");
3033                })
3034                .on("mousemove", (event) => {
3035                    tooltip.style("left", (event.pageX + 10) + "px")
3036                           .style("top", (event.pageY - 30) + "px");
3037                })
3038                .on("mouseout", () => {
3039                    tooltip.style("opacity", 0)
3040                           .style("transform", "translateY(-10px)");
3041                });
3042        });
3043    </script>
3044</body>
3045</html>
3046HTML
3047
3048
2
4
    return $html;
3049}
3050
3051 - 3095
=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 a static
colour legend. Each series gets a labelled colour swatch in the legend area.

Accepts the following arguments:

=over 4

=item * C<$data> - Same format as C<render_multi_series_line_chart_with_tooltips>:
an array reference of C<< { name, data } >> series hashes.

=back

Returns a string containing the complete HTML5 document. The stylesheet defines
a C<.legend> CSS class used by the D3-generated legend elements.

=head3 Errors

=over 4

=item * Throws C<Data must be an array of hashes> when C<$data> is not an ARRAY reference.

=back

=head3 API SPECIFICATION

=head4 Input

    {
        data => { type => 'arrayref' },
    }

    Each element of C<$data> is a hashref with keys C<name> (string) and
    C<data> (arrayref of hashrefs with C<label> and C<value> keys);
    passing C<undef> or a non-arrayref dies.

=head4 Output

    Str -- complete HTML5 document; static colour legend rendered as SVG C<g> elements
           with the C<.legend> CSS class applied via D3 C<.attr("class", "legend")>.

=cut
3096
3097sub render_multi_series_line_chart_with_legends {
3098
4
281
        my($self, $data) = @_;
3099
3100        # Validate input data
3101
4
19
        die 'Data must be an array of hashes' unless ref($data) eq 'ARRAY';
3102
3103        # Generate JSON for data
3104
2
12
        my $json_data = $_JSON->encode($data);
3105
3106        # Generate HTML and D3.js code
3107
2
145
        my $html = $self->_preamble();
3108
2
3
        $html .= <<"HTML";
3109<head>
3110    <meta charset="UTF-8">
3111    <meta name="viewport" content="width=device-width, initial-scale=1.0">
3112    <title>$self->{title}</title>
3113    <script src="https://d3js.org/d3.v7.min.js"></script>
3114    <style>
3115        .tooltip {
3116            position: absolute;
3117            background-color: white;
3118            border: 1px solid #ccc;
3119            padding: 5px;
3120            font-size: 12px;
3121            pointer-events: none;
3122            opacity: 0;
3123            transform: translateY(-10px);
3124            transition: opacity 0.2s ease-in-out, transform 0.2s ease-in-out;
3125        }
3126        .legend {
3127            font-size: 12px;
3128            cursor: pointer;
3129        }
3130        .legend rect {
3131            stroke-width: 1;
3132            stroke: #ccc;
3133        }
3134    </style>
3135</head>
3136<body>
3137    <h1 style="text-align: center;">$self->{title}</h1>
3138
2
7
    <svg id="chart" ${\ $self->_svg_attrs() }></svg>
3139    <div class="tooltip" id="tooltip"></div>
3140    <script>
3141        const data = $json_data;
3142
3143        const svg = d3.select("#chart");
3144        const tooltip = d3.select("#tooltip");
3145        const margin = { top: 20, right: 120, bottom: 40, left: 40 };
3146        const width = $self->{width} - margin.left - margin.right;
3147        const height = $self->{height} - margin.top - margin.bottom;
3148
3149        const chart = svg.append("g")
3150            .attr("transform", `translate(\${margin.left},\${margin.top})`);
3151
3152        const legendArea = svg.append("g")
3153            .attr("transform", `translate(\${width + margin.left + 20},\${margin.top})`);
3154
3155        // Extract all labels and flatten them into a unique array
3156        const allLabels = Array.from(new Set(data.flatMap(series => series.data.map(d => d.label))));
3157
3158        const x = d3.scalePoint()
3159            .domain(allLabels)
3160            .range([0, width]);
3161
3162        const y = d3.scaleLinear()
3163            .domain([0, d3.max(data.flatMap(series => series.data.map(d => d.value)))])
3164            .nice()
3165            .range([height, 0]);
3166
3167        // Define color scale for series
3168        const color = d3.scaleOrdinal(d3.schemeCategory10);
3169
3170        // Add axes
3171        chart.append("g")
3172            .call(d3.axisLeft(y));
3173
3174        chart.append("g")
3175            .attr("transform", `translate(0,\${height})`)
3176            .call(d3.axisBottom(x))
3177            .selectAll("text")
3178            .attr("transform", "rotate(-45)")
3179            .style("text-anchor", "end");
3180
3181        // Draw lines for each series
3182        data.forEach((series, i) => {
3183            const line = d3.line()
3184                .x(d => x(d.label))
3185                .y(d => y(d.value));
3186
3187            // Add line
3188            chart.append("path")
3189                .datum(series.data)
3190                .attr("fill", "none")
3191                .attr("stroke", color(i))
3192                .attr("stroke-width", 2)
3193                .attr("class", \`line-\${i}\`)
3194                .attr("d", line);
3195
3196            // Add points and tooltips
3197            chart.selectAll(\`circle.series-\${i}\`)
3198                .data(series.data)
3199                .join("circle")
3200                .attr("class", \`series-\${i}\`)
3201                .attr("cx", d => x(d.label))
3202                .attr("cy", d => y(d.value))
3203                .attr("r", 4)
3204                .attr("fill", color(i))
3205                .on("mouseover", (event, d) => {
3206                    tooltip.style("opacity", 1)
3207                           .style("transform", "translateY(0)")
3208                           .html(\`Series: <b>\${series.name}<\\/b><br>Label: <b>\${d.label}<\\/b><br>Value: <b>\${d.value}<\\/b>\`)
3209                           .style("left", (event.pageX + 10) + "px")
3210                           .style("top", (event.pageY - 30) + "px");
3211                })
3212                .on("mousemove", (event) => {
3213                    tooltip.style("left", (event.pageX + 10) + "px")
3214                           .style("top", (event.pageY - 30) + "px");
3215                })
3216                .on("mouseout", () => {
3217                    tooltip.style("opacity", 0)
3218                           .style("transform", "translateY(-10px)");
3219                });
3220        });
3221
3222        // Add legend
3223        data.forEach((series, i) => {
3224            const legend = legendArea.append("g")
3225                .attr("transform", `translate(0, \${i * 20})`)
3226                .attr("class", "legend");
3227
3228            legend.append("rect")
3229                .attr("width", 12)
3230                .attr("height", 12)
3231                .attr("fill", color(i));
3232
3233            legend.append("text")
3234                .attr("x", 20)
3235                .attr("y", 10)
3236                .text(series.name)
3237                .style("alignment-baseline", "middle");
3238
3239            // Optional: Interactive legend for toggling visibility (uncomment to use)
3240            // legend.on("click", () => {
3241            //     const visible = d3.selectAll(\`path.line-\${i}\`).style("opacity") === "1" ? 0 : 1;
3242            //     d3.selectAll(\`path.line-\${i}\`).style("opacity", visible);
3243            //     d3.selectAll(\`circle.series-\${i}\`).style("opacity", visible);
3244            // });
3245        });
3246    </script>
3247</body>
3248</html>
3249HTML
3250
3251
2
3
    return $html;
3252}
3253
3254 - 3299
=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> - Same format as C<render_multi_series_line_chart_with_tooltips>:
an array reference of C<< { name, data } >> series hashes.

=back

Returns a string containing the complete HTML5 document. Clicking a legend entry
toggles that series' opacity using an C<isVisible> boolean flag in the D3 click
handler (opacity is set to C<isVisible ? 0 : 1> on each click).

=head3 Errors

=over 4

=item * Throws C<Data must be an array of hashes> when C<$data> is not an ARRAY reference.

=back

=head3 API SPECIFICATION

=head4 Input

    {
        data => { type => 'arrayref' },
    }

    Each element of C<$data> is a hashref with keys C<name> (string) and
    C<data> (arrayref of hashrefs with C<label> and C<value> keys);
    passing C<undef> or a non-arrayref dies.

=head4 Output

    Str -- complete HTML5 document; legend clicks toggle series visibility.
           The C<isVisible> JS variable tracks current visibility state.
           Opacity toggled by C<isVisible ? 0 : 1>.

=cut
3300
3301sub render_multi_series_line_chart_with_interactive_legends
3302{
3303
4
263
        my ($self, $data) = @_;
3304
3305        # Validate input data
3306
4
16
        die 'Data must be an array of hashes' unless ref($data) eq 'ARRAY';
3307
3308        # Generate JSON for data
3309
2
12
        my $json_data = $_JSON->encode($data);
3310
3311        # Generate HTML and D3.js code
3312
2
145
        my $html = $self->_preamble();
3313
2
2
        $html .= <<"HTML";
3314<head>
3315    <meta charset="UTF-8">
3316    <meta name="viewport" content="width=device-width, initial-scale=1.0">
3317    <title>$self->{title}</title>
3318    <script src="https://d3js.org/d3.v7.min.js"></script>
3319    <style>
3320        .tooltip {
3321            position: absolute;
3322            background-color: white;
3323            border: 1px solid #ccc;
3324            padding: 5px;
3325            font-size: 12px;
3326            pointer-events: none;
3327            opacity: 0;
3328            transform: translateY(-10px);
3329            transition: opacity 0.2s ease-in-out, transform 0.2s ease-in-out;
3330        }
3331        .legend {
3332            font-size: 12px;
3333            cursor: pointer;
3334        }
3335        .legend rect {
3336            stroke-width: 1;
3337            stroke: #ccc;
3338        }
3339    </style>
3340</head>
3341<body>
3342    <h1 style="text-align: center;">$self->{title}</h1>
3343
2
4
    <svg id="chart" ${\ $self->_svg_attrs() }></svg>
3344    <div class="tooltip" id="tooltip"></div>
3345    <script>
3346        const data = $json_data;
3347
3348        const svg = d3.select("#chart");
3349        const tooltip = d3.select("#tooltip");
3350        const margin = { top: 20, right: 150, bottom: 40, left: 40 };
3351        const width = $self->{width} - margin.left - margin.right;
3352        const height = $self->{height} - margin.top - margin.bottom;
3353
3354        const chart = svg.append("g")
3355            .attr("transform", `translate(\${margin.left},\${margin.top})`);
3356
3357        const legendArea = svg.append("g")
3358            .attr("transform", `translate(\${width + margin.left + 20},\${margin.top})`);
3359
3360         // Extract all labels and flatten them into a unique array
3361        const allLabels = Array.from(new Set(data.flatMap(series => series.data.map(d => d.label))));
3362
3363        const x = d3.scalePoint()
3364            .domain(allLabels)
3365            .range([0, width]);
3366
3367        const y = d3.scaleLinear()
3368            .domain([0, d3.max(data.flatMap(series => series.data.map(d => d.value)))])
3369            .nice()
3370            .range([height, 0]);
3371
3372        // Define color scale for series
3373        const color = d3.scaleOrdinal(d3.schemeCategory10);
3374
3375        // Add axes
3376        chart.append("g")
3377            .call(d3.axisLeft(y));
3378
3379        chart.append("g")
3380            .attr("transform", `translate(0,\${height})`)
3381            .call(d3.axisBottom(x))
3382            .selectAll("text")
3383            .attr("transform", "rotate(-45)")
3384            .style("text-anchor", "end");
3385
3386        // Draw lines for each series
3387        data.forEach((series, i) => {
3388            const line = d3.line()
3389                .x(d => x(d.label))
3390                .y(d => y(d.value));
3391
3392            // Add line
3393            chart.append("path")
3394                .datum(series.data)
3395                .attr("fill", "none")
3396                .attr("stroke", color(i))
3397                .attr("stroke-width", 2)
3398                .attr("class", \`line-\${i}\`)
3399                .attr("d", line);
3400
3401            // Add points and tooltips
3402            chart.selectAll(\`circle.series-\${i}\`)
3403                .data(series.data)
3404                .join("circle")
3405                .attr("class", \`series-\${i}\`)
3406                .attr("cx", d => x(d.label))
3407                .attr("cy", d => y(d.value))
3408                .attr("r", 4)
3409                .attr("fill", color(i))
3410                .on("mouseover", (event, d) => {
3411                    tooltip.style("opacity", 1)
3412                           .style("transform", "translateY(0)")
3413                           .html(\`Series: <b>\${series.name}<\\/b><br>Label: <b>\${d.label}<\\/b><br>Value: <b>\${d.value}<\\/b>\`)
3414                           .style("left", (event.pageX + 10) + "px")
3415                           .style("top", (event.pageY - 30) + "px");
3416                })
3417                .on("mousemove", (event) => {
3418                    tooltip.style("left", (event.pageX + 10) + "px")
3419                           .style("top", (event.pageY - 30) + "px");
3420                })
3421                .on("mouseout", () => {
3422                    tooltip.style("opacity", 0)
3423                           .style("transform", "translateY(-10px)");
3424                });
3425        });
3426
3427        // Add legend with interactivity
3428        data.forEach((series, i) => {
3429            const legend = legendArea.append("g")
3430                .attr("transform", `translate(0, \${i * 20})`)
3431                .attr("class", "legend")
3432                .on("click", () => {
3433                    const isVisible = d3.selectAll(\`path.line-\${i}\`).style("opacity") === "1";
3434
3435                    // Toggle visibility
3436                    d3.selectAll(\`path.line-\${i}\`).style("opacity", isVisible ? 0 : 1);
3437                    d3.selectAll(\`circle.series-\${i}\`).style("opacity", isVisible ? 0 : 1);
3438
3439                    // Dim legend if series is hidden
3440                    legend.select("text").style("opacity", isVisible ? 0.5 : 1);
3441                });
3442
3443            legend.append("rect")
3444                .attr("width", 12)
3445                .attr("height", 12)
3446                .attr("fill", color(i));
3447
3448            legend.append("text")
3449                .attr("x", 20)
3450                .attr("y", 10)
3451                .text(series.name)
3452                .style("alignment-baseline", "middle");
3453        });
3454    </script>
3455</body>
3456</html>
3457HTML
3458
3459
2
4
    return $html;
3460}
3461
3462 - 3553
=head2 render_multi_series_line_chart_snippet

    my $fragment = $chart->render_multi_series_line_chart_snippet(\@series);
    my $fragment = $chart->render_multi_series_line_chart_snippet(\@series, \%opts);
    # $fragment->{svg_id} - the id attribute of the <svg> element
    # $fragment->{html}   - embeddable HTML fragment (style + svg + script)

Generates an embeddable multi-series line chart fragment.  Unlike
C<render_multi_series_line_chart_with_interactive_legends>, this method returns
a bare fragment with no C<< <html> >>, C<< <head> >>, or C<< <body> >> wrapper;
D3.js must be loaded by the host page.

=head3 Arguments

=over 4

=item * C<\@series> - An array reference of series.  Each series is a hash
reference with a C<name> (string) and a C<data> array reference of
C<< { label => Str, value => Num } >> points:

    [
        { name => 'North', data => [ { label => 'Jan', value => 12 }, ... ] },
        { name => 'South', data => [ { label => 'Jan', value =>  8 }, ... ] },
    ]

Series need not share labels: the X domain is the union of every series' labels,
in first-seen order.  One line and one set of dots is drawn per series, and a
clickable legend toggles each series on and off.

=item * C<\%opts> - Optional hash reference:

=over 4

=item * C<scheme> (string, default C<'tableau10'>) - D3 categorical colour
scheme (for example C<'category10'>, C<'set2'>, C<'set3'>, C<'paired'>).

=item * C<x_label> (string, default C<''>) - Label shown below the X axis.

=item * C<y_label> (string, default C<''>) - Label shown beside the Y axis.

=item * C<animated> (boolean, default 0) - If true each line draws itself left
to right by animating its stroke dash offset.  Respects
C<prefers-reduced-motion>.

=item * C<responsive> (boolean, optional) - SVG responsive sizing override.

=item * C<id> (string, default C<'chart'>) - C<id> of the C<<svg>> element.

=back

=back

Returns C<< { svg_id => 'chart', html => Str } >>.

=head3 Errors

=over 4

=item * Throws C<Data must be an array of series> when C<\@series> is not an
ARRAY reference.

=item * Throws C<Each series must be a hashref with name and data> when a
series is not a hashref, has no C<name>, or has a C<data> that is not an ARRAY
reference.

=back

=head3 API SPECIFICATION

=head4 Input

    {
        data => { type => 'arrayref' },
        opts => {
            type     => 'hashref',
            optional => 1,
            keys     => {
                scheme     => { type => 'string',  optional => 1, default => 'tableau10' },
                x_label    => { type => 'string',  optional => 1, default => '' },
                y_label    => { type => 'string',  optional => 1, default => '' },
                animated   => { type => 'boolean', optional => 1, default => 0 },
                responsive => { type => 'boolean', optional => 1 },
                id         => { type => 'string',  optional => 1, default => 'chart' },
            },
        },
    }

=head4 Output

    HashRef -- { svg_id => 'chart', html => Str }

=cut
3554
3555sub render_multi_series_line_chart_snippet
3556{
3557
7
6538
        my ($self, $series_data, $opts) = @_;
3558
7
10
        $opts //= {};
3559
3560
7
14
        die 'Data must be an array of series' unless ref($series_data) eq 'ARRAY';
3561
6
6
        for my $s (@$series_data) {
3562                die 'Each series must be a hashref with name and data'
3563
9
30
                        unless ref($s) eq 'HASH' && defined $s->{name} && ref($s->{data}) eq 'ARRAY';
3564        }
3565
3566
3
22
        my $json_data = $_JSON->encode($series_data);
3567
3568
3
6
        my $scheme   = $opts->{scheme}   // 'tableau10';
3569
3
4
        my $x_label  = $opts->{x_label} // '';
3570
3
8
        my $y_label  = $opts->{y_label} // '';
3571
3
9
        my $animated = $opts->{animated} // 0;
3572
3
6
        my $svg_id   = $opts->{id}       // 'chart';
3573
3
3
        my $tip_id   = $svg_id . '_tip';
3574
3575
3
11
        (my $scheme_js = $scheme) =~ s/^([a-z])/\u$1/;
3576
3577        my $js_esc = sub {
3578
6
3
                my $s = shift;
3579
6
6
3
5
                $s =~ s/\\/\\\\/g; $s =~ s/"/\\"/g;
3580
6
6
1
4
                $s =~ s/\n/\\n/g;  $s =~ s/\r/\\r/g;
3581
6
6
                return $s;
3582
3
5
        };
3583
3584
3
4
        my $x_label_esc = $js_esc->($x_label);
3585
3
2
        my $y_label_esc = $js_esc->($y_label);
3586
3587
3
5
        my $width  = $self->{width};
3588
3
2
        my $height = $self->{height};
3589        my $svg_attrs = ($opts->{responsive} // $self->{responsive})
3590
3
6
                ? qq{viewBox="0 0 $width $height" width="100%" height="auto"}
3591                : qq{width="$width" height="$height"};
3592
3593
3
3
        my $margin_right  = 150;  # room for legend
3594
3
3
        my $margin_left   = $y_label ? 60 : 45;
3595
3
1
        my $margin_top    = 20;
3596
3
4
        my $margin_bottom = $x_label ? 60 : 40;
3597
3
2
        my $inner_w = $width  - $margin_left - $margin_right;
3598
3
2
        my $inner_h = $height - $margin_top  - $margin_bottom;
3599
3600
3
5
        my $x_label_block = $x_label ? <<"XLB" : '';
3601    g.append("text")
3602        .attr("class","msl-axis-label")
3603        .attr("x", innerW / 2)
3604        .attr("y", innerH + (margin.bottom - 10))
3605        .attr("text-anchor","middle")
3606        .text("$x_label_esc");
3607XLB
3608
3609
3
3
        my $y_label_block = $y_label ? <<"YLB" : '';
3610    g.append("text")
3611        .attr("class","msl-axis-label")
3612        .attr("transform","rotate(-90)")
3613        .attr("x", -innerH / 2)
3614        .attr("y", -(margin.left - 14))
3615        .attr("text-anchor","middle")
3616        .text("$y_label_esc");
3617YLB
3618
3619
3
3
        my $anim_block = $animated ? <<"ANIM" : '';
3620    var noAnim = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
3621    if (!noAnim) {
3622        g.selectAll(".msl-line")
3623            .each(function() {
3624                var totalLen = this.getTotalLength();
3625                d3.select(this)
3626                    .attr("stroke-dasharray", totalLen)
3627                    .attr("stroke-dashoffset", totalLen)
3628                    .transition().duration(1200).ease(d3.easeLinear)
3629                    .attr("stroke-dashoffset", 0);
3630            });
3631    }
3632ANIM
3633
3634
3
11
        my $html = <<"HTML";
3635<style>
3636    #$tip_id {
3637        position:absolute;background:rgba(255,255,255,0.95);border:1px solid #ccc;
3638        border-radius:4px;padding:6px 10px;font-size:12px;pointer-events:none;
3639        display:none;line-height:1.6;
3640    }
3641    .msl-axis-label{font-size:11px;fill:#555;}
3642    .msl-line{fill:none;stroke-width:2;}
3643    .msl-dot{cursor:default;}
3644    .msl-leg-item{cursor:pointer;}
3645</style>
3646<svg id="$svg_id" $svg_attrs></svg>
3647<div id="$tip_id"></div>
3648<script>
3649(function(){
3650    var series = $json_data;
3651    var margin = {top:$margin_top,right:$margin_right,bottom:$margin_bottom,left:$margin_left};
3652    var innerW = $inner_w;
3653    var innerH = $inner_h;
3654    var svg = d3.select("#$svg_id");
3655    var g   = svg.append("g").attr("transform","translate("+margin.left+","+margin.top+")");
3656    var tip = d3.select("#$tip_id");
3657
3658    function esc(s) {
3659        return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
3660    }
3661
3662    var allLabels = Array.from(new Set(series.flatMap(function(s){return s.data.map(function(d){return d.label;});})));
3663    var allVals   = series.flatMap(function(s){return s.data.map(function(d){return d.value;});});
3664
3665    var xScale = d3.scalePoint().domain(allLabels).range([0,innerW]);
3666    var yScale = d3.scaleLinear()
3667        .domain([Math.min(0,d3.min(allVals)), d3.max(allVals)]).nice()
3668        .range([innerH,0]);
3669
3670    var color = d3.scaleOrdinal(d3.scheme$scheme_js).domain(series.map(function(s){return s.name;}));
3671
3672    g.append("g").attr("transform","translate(0,"+innerH+")")
3673        .call(d3.axisBottom(xScale))
3674        .selectAll("text").attr("transform","rotate(-45)").style("text-anchor","end");
3675    g.append("g").call(d3.axisLeft(yScale).ticks(6));
3676
3677    $x_label_block
3678    $y_label_block
3679
3680    var lineGen = d3.line()
3681        .x(function(d){return xScale(d.label);})
3682        .y(function(d){return yScale(d.value);});
3683
3684    series.forEach(function(s,i){
3685        g.append("path")
3686            .datum(s.data)
3687            .attr("class","msl-line")
3688            .attr("stroke",color(s.name))
3689            .attr("d",lineGen);
3690
3691        g.selectAll(".msl-dot-"+i)
3692            .data(s.data).join("circle")
3693            .attr("class","msl-dot msl-dot-"+i)
3694            .attr("cx",function(d){return xScale(d.label);})
3695            .attr("cy",function(d){return yScale(d.value);})
3696            .attr("r",4)
3697            .attr("fill",color(s.name))
3698            .on("mouseover",function(event,d){
3699                tip.style("display","block")
3700                   .html("<b>"+esc(d.label)+"<\\/b><br\/>"+esc(s.name)+": <b>"+d.value+"<\\/b>");
3701            })
3702            .on("mousemove",function(event){
3703                tip.style("left",(event.pageX+12)+"px").style("top",(event.pageY-24)+"px");
3704            })
3705            .on("mouseout",function(){tip.style("display","none");});
3706    });
3707
3708    // Legend
3709    var legG = svg.append("g")
3710        .attr("transform","translate("+(margin.left+innerW+12)+","+margin.top+")");
3711    series.forEach(function(s,i){
3712        var row = legG.append("g")
3713            .attr("class","msl-leg-item")
3714            .attr("transform","translate(0,"+(i*18)+")")
3715            .on("click",function(){
3716                var lines = g.selectAll(".msl-line").filter(function(d,j){return j===i;});
3717                var dots  = g.selectAll(".msl-dot-"+i);
3718                var vis   = lines.style("opacity") !== "0";
3719                lines.style("opacity", vis ? 0 : 1);
3720                dots.style("opacity",  vis ? 0 : 1);
3721                row.select("text").style("opacity", vis ? 0.4 : 1);
3722            });
3723        row.append("rect").attr("width",11).attr("height",11).attr("fill",color(s.name)).attr("rx",2);
3724        row.append("text").attr("x",15).attr("y",10)
3725            .style("font-size","11px").style("fill","#444")
3726            .text(s.name.length>14?s.name.slice(0,13)+"…":s.name);
3727    });
3728
3729    $anim_block
3730})();
3731</script>
3732HTML
3733
3734
3
12
        return { svg_id => $svg_id, html => $html };
3735}
3736
3737 - 3849
=head2 render_scatter_chart_snippet

    my $fragment = $chart->render_scatter_chart_snippet($data);
    my $fragment = $chart->render_scatter_chart_snippet($data, \%opts);
    # $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 scatter plot with mouseover
tooltips.  Returns a fragment with no C<<!DOCTYPE>>, C<<html>>, C<<head>>, or
C<<body>> wrapper; the caller is responsible for loading D3 in the page.

=head3 Arguments

=over 4

=item * C<$data> - An array reference of data points.  Each point is an array
reference with two required numeric elements (x, y), an optional third hash
reference of extra key/value pairs shown as extra tooltip rows, and an optional
fourth hash reference carrying the C<series> (colour group) and C<size> (bubble
radius) dimensions:

    [$x, $y]          # basic point
    [$x, $y, \%row]   # point with extra tooltip data
    [$x, $y, \%row, { series => 'North', size => 42 }]   # colour and size

=item * C<\%opts> - Optional hash reference:

=over 4

=item * C<id> (string, default C<'scatter_chart'>) - C<id> of the C<<svg>> element.

=item * C<color> (CSS colour or C<'categorical'>, default C<'steelblue'>) -
Fill colour for the data points.  C<'categorical'> uses the Tableau-10 palette.

=item * C<x_label> (string, default C<''>) - Label for the X axis.

=item * C<y_label> (string, default C<''>) - Label for the Y axis.

=item * C<value_label> (string, default C<'Value'>) - Prefix for the tooltip value row.

=item * C<colour_by_series> (boolean, default 0) - Colour each point by its
C<series> value instead of by point index, and draw a legend listing the series
to the right of the plot.  Ignored for points with no C<series> value.

=item * C<scheme> (string, default C<'tableau10'>) - D3 categorical colour
scheme used when C<colour_by_series> is set, for example C<'category10'>,
C<'set2'>, C<'set3'>, or C<'paired'>.

=item * C<series_col> (string, default C<'Series'>) - Row label for the series
value in the tooltip.

=item * C<size_col> (string, default C<'Size'>) - Row label for the size value
in the tooltip.

=item * C<radius_range> (arrayref of two numbers, default C<[3, 15]>) - Pixel
radii mapped from the smallest and largest observed C<size> value.  The mapping
is by area (C<d3.scaleSqrt>), so a point twice as large is drawn with four
times the area.  A point with no C<size> value is drawn at the lower radius.

=item * C<animated> (boolean, default 0) - If true, circles fade in from
opacity 0 on page load (400 ms).  Respects C<prefers-reduced-motion>.

=item * C<responsive> (boolean, default 0) - See C<render_bar_chart_snippet>.

=back

=back

=head3 Return value

A hash reference with C<svg_id> (string) and C<html> (Perl character string).

=head3 Errors

=over 4

=item * Dies with C<'Data must be an array of arrays'> when C<$data> is not an ARRAY ref.

=item * Dies with C<'Each data point must be an array reference'> when an element is not an ARRAY ref.

=item * Dies with C<'Each data point must have at least 2 elements'> when a point has fewer than 2 items.

=item * Dies with C<'X value must be numeric'> when the x element is not a number.

=item * Dies with C<'Y value must be numeric'> when the y element is not a number.

=back

=head3 API SPECIFICATION

    {
        data => { type => 'arrayref' },
        opts => {
            type     => 'hashref',
            optional => 1,
            keys     => {
                id          => { type => 'string',  optional => 1 },
                color       => { type => 'string',  optional => 1, default => 'steelblue' },
                x_label     => { type => 'string',  optional => 1, default => '' },
                y_label     => { type => 'string',  optional => 1, default => '' },
                value_label => { type => 'string',  optional => 1, default => 'Value' },
                colour_by_series => { type => 'boolean', optional => 1, default => 0 },
                scheme      => { type => 'string',  optional => 1, default => 'tableau10' },
                series_col  => { type => 'string',  optional => 1, default => '' },
                size_col    => { type => 'string',  optional => 1, default => '' },
                radius_range => { type => 'arrayref', optional => 1, default => [3, 15] },
                animated    => { type => 'boolean', optional => 1, default => 0 },
                responsive  => { type => 'boolean', optional => 1, default => 0 },
            },
        },
    }

=cut
3850
3851sub render_scatter_chart_snippet
3852{
3853
39
12807
        my ($self, $data, $opts) = @_;
3854
39
56
        $opts //= {};
3855
3856
39
65
        die 'Data must be an array of arrays' unless ref($data) eq 'ARRAY';
3857
3858
36
52
        my $color            = $opts->{color}            // 'steelblue';
3859
36
45
        my $x_label          = $opts->{x_label}          // '';
3860
36
44
        my $y_label          = $opts->{y_label}          // '';
3861
36
54
        my $value_label      = $opts->{value_label}      // 'Value';
3862
36
43
        my $animated         = $opts->{animated}         // 0;
3863
36
39
        my $colour_by_series = $opts->{colour_by_series} // 0;
3864
36
49
        my $scheme           = $opts->{scheme}           // 'tableau10';
3865
36
38
        my $series_col       = $opts->{series_col}       // '';
3866
36
45
        my $size_col         = $opts->{size_col}         // '';
3867
36
48
        my $radius_range     = $opts->{radius_range}     // [3, 15];
3868
3869        my $js_esc = sub {
3870
146
68
                my $s = shift;
3871
146
84
                $s =~ s/\\/\\\\/g;
3872
146
64
                $s =~ s/"/\\"/g;
3873
146
73
                $s =~ s/\n/\\n/g;
3874
146
65
                $s =~ s/\r/\\r/g;
3875
146
86
                return $s;
3876
36
64
        };
3877
3878        # Capitalise the first letter of the scheme name for the d3.scheme* accessor.
3879
36
111
        (my $scheme_js = $scheme) =~ s/^([a-z])/\u$1/;
3880
3881
36
29
        my @points;
3882
36
49
        for my $pt (@$data) {
3883
76
111
                die 'Each data point must be an array reference' unless ref($pt) eq 'ARRAY';
3884
73
132
                die 'Each data point must have at least 2 elements' unless scalar(@$pt) >= 2;
3885
70
119
                die 'X value must be numeric' unless looks_like_number($pt->[0]);
3886
67
120
                die 'Y value must be numeric' unless looks_like_number($pt->[1]);
3887
64
103
                my $p = { x => $pt->[0] + 0, y => $pt->[1] + 0 };
3888
64
144
                $p->{extra} = $pt->[2] if ref($pt->[2]) eq 'HASH';
3889
64
97
                if (ref($pt->[3]) eq 'HASH') {
3890
7
8
                        $p->{series} = $pt->[3]{series} if defined $pt->[3]{series};
3891
7
11
                        $p->{size}   = $pt->[3]{size}+0 if defined $pt->[3]{size} && looks_like_number($pt->[3]{size});
3892                }
3893
64
103
                push @points, $p;
3894        }
3895
3896        # Collect series names and size range for JS initialisation.
3897
24
45
        my %seen_series;
3898        my @series_names;
3899
24
0
        my ($size_min, $size_max);
3900
24
13
        for my $p (@points) {
3901
64
65
                if (defined $p->{series} && !$seen_series{$p->{series}}++) {
3902
4
3
                        push @series_names, $p->{series};
3903                }
3904
64
48
                if (defined $p->{size}) {
3905
4
7
                        $size_min = $p->{size} if !defined $size_min || $p->{size} < $size_min;
3906
4
8
                        $size_max = $p->{size} if !defined $size_max || $p->{size} > $size_max;
3907                }
3908        }
3909        # Colour and size are independent dimensions: a point may carry both.
3910
24
24
        my $has_series = $colour_by_series && @series_names;
3911
24
17
        my $has_size   = defined $size_min;
3912
3913
24
97
        my $json_data      = $_JSON->encode(\@points);
3914
24
23
        my $json_series    = $_JSON->encode(\@series_names);
3915
24
24
        my $x_label_esc    = $js_esc->($x_label);
3916
24
17
        my $y_label_esc    = $js_esc->($y_label);
3917
24
17
        my $val_label_esc  = $js_esc->($value_label);
3918
24
35
        my $series_lbl_esc = $js_esc->($series_col || 'Series');
3919
24
26
        my $size_lbl_esc   = $js_esc->($size_col   || 'Size');
3920
3921
24
23
        my $is_categorical = ($color eq 'categorical') ? 1 : 0;
3922
24
38
        my $color_esc      = ($is_categorical || $has_series) ? 'steelblue' : $js_esc->($color);
3923
3924
24
36
        my $svg_id = $opts->{id} // 'scatter_chart';
3925
24
19
        my $tip_id = $svg_id . '_tip';
3926
24
15
        my $leg_id = $svg_id . '_leg';
3927
3928
24
20
        my $width  = $self->{width};
3929
24
14
        my $height = $self->{height};
3930
3931        # Reserve right margin for legend when series colouring is active.
3932
24
26
        my $margin_right = $has_series ? 140 : 20;
3933        my $svg_attrs = ($opts->{responsive} // $self->{responsive})
3934
24
45
                ? qq{viewBox="0 0 $width $height" width="100%" height="auto"}
3935                : qq{width="$width" height="$height"};
3936
3937
24
27
        my $margin_bottom = $x_label ? 60 : 40;
3938
24
17
        my $margin_left   = $y_label ? 70 : 50;
3939
24
17
        my $margin_top    = 20;
3940
24
16
        my $inner_w = $width  - $margin_left - $margin_right;
3941
24
18
        my $inner_h = $height - $margin_top  - $margin_bottom;
3942
3943        # Color initialisation JS.
3944
24
14
        my $color_init_js;
3945
24
27
        if ($has_series) {
3946
2
2
                $color_init_js = "var seriesNames = $json_series;\n"
3947                        . "    var color = d3.scaleOrdinal(d3.scheme${scheme_js}).domain(seriesNames);";
3948        } elsif ($is_categorical) {
3949
0
0
                $color_init_js = "var color = d3.scaleOrdinal(d3.schemeTableau10).domain(data.map(function(d,i){return i;}));";
3950        } else {
3951
22
15
                $color_init_js = "var color = function() { return \"$color_esc\"; };";
3952        }
3953
3954        # Size scale JS (bubble mode).
3955
24
23
        my $r_min = $radius_range->[0] // 3;
3956
24
21
        my $r_max = $radius_range->[1] // 15;
3957
24
18
        my $size_init_js = '';
3958
24
12
        my $radius_js    = '5';
3959
24
26
        if ($has_size && defined $size_min) {
3960
2
1
                my $smin = $size_min + 0;
3961
2
2
                my $smax = $size_max + 0;
3962
2
2
                $size_init_js = "var rScale = d3.scaleSqrt().domain([$smin,$smax]).range([$r_min,$r_max]).clamp(true);";
3963
2
1
                $radius_js    = 'function(d){return d.size!=null?rScale(d.size):' . $r_min . ';}';
3964        }
3965
3966        # Series colouring: color by d.series; fallback to default.
3967
24
22
        my $fill_js = $has_series
3968                ? 'function(d){return d.series!=null?color(d.series):"' . $color_esc . '";}'
3969                : 'function(d,i){return color(i);}';
3970
3971
24
26
        my $anim_block = $animated ? <<"ANIM" : '';
3972    var noAnim = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
3973    if (!noAnim) {
3974        circles.attr("opacity", 0)
3975            .transition()
3976            .duration(400)
3977            .delay(function(d, i) { return i * 10; })
3978            .attr("opacity", 0.8);
3979    } else {
3980        circles.attr("opacity", 0.8);
3981    }
3982ANIM
3983
3984
24
20
        my $x_label_block = $x_label ? <<"XLB" : '';
3985    g.append("text")
3986        .attr("class", "sc-axis-label")
3987        .attr("x", $inner_w / 2)
3988        .attr("y", $inner_h + ($margin_bottom - 10))
3989        .attr("text-anchor", "middle")
3990        .text("$x_label_esc");
3991XLB
3992
3993
24
21
        my $y_label_block = $y_label ? <<"YLB" : '';
3994    g.append("text")
3995        .attr("class", "sc-axis-label")
3996        .attr("transform", "rotate(-90)")
3997        .attr("x", -$inner_h / 2)
3998        .attr("y", -($margin_left - 15))
3999        .attr("text-anchor", "middle")
4000        .text("$y_label_esc");
4001YLB
4002
4003        # Legend block (only when colour_by_series is true and we have series names).
4004
24
22
        my $legend_block = $has_series ? <<"LEG" : '';
4005    var legendG = svg.append("g")
4006        .attr("transform","translate(" + ($margin_left + $inner_w + 12) + "," + $margin_top + ")");
4007    var seriesNamesForLeg = $json_series;
4008    seriesNamesForLeg.forEach(function(name, i){
4009        var row = legendG.append("g").attr("transform","translate(0," + (i*18) + ")");
4010        row.append("rect").attr("width",11).attr("height",11).attr("fill",color(name)).attr("rx",2);
4011        row.append("text").attr("x",15).attr("y",10).style("font-size","11px").style("fill","#444")
4012            .text(name.length>14?name.slice(0,13)+"…":name);
4013    });
4014LEG
4015
4016
24
23
        my $series_tip_js = $has_series
4017                ? 'if(d.series!=null){tipHtml+="<br>"+esc("' . $js_esc->($series_col || 'Series') . '")+": "+esc(String(d.series));}'
4018                : '';
4019
24
21
        my $size_tip_js = $has_size
4020                ? 'if(d.size!=null){tipHtml+="<br>"+esc("' . $js_esc->($size_col || 'Size') . '")+": "+esc(String(d.size));}'
4021                : '';
4022
4023
24
116
        my $html = <<"HTML";
4024<style>
4025    #$tip_id {
4026        position: absolute;
4027        background: rgba(255,255,255,0.95);
4028        border: 1px solid #ccc;
4029        border-radius: 4px;
4030        padding: 6px 10px;
4031        font-size: 12px;
4032        pointer-events: none;
4033        display: none;
4034        line-height: 1.6;
4035    }
4036    .sc-circle { cursor: default; }
4037    .sc-axis-label { font-size: 11px; fill: #555; }
4038</style>
4039<svg id="$svg_id" $svg_attrs></svg>
4040<div id="$tip_id"></div>
4041<script>
4042(function() {
4043    var data = $json_data;
4044    var margin = { top: $margin_top, right: $margin_right, bottom: $margin_bottom, left: $margin_left };
4045    var innerW = $inner_w;
4046    var innerH = $inner_h;
4047    var valLabel = "$val_label_esc";
4048    $color_init_js
4049    $size_init_js
4050
4051    function esc(s) {
4052        return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
4053    }
4054
4055    var svg = d3.select("#$svg_id");
4056    var tip = d3.select("#$tip_id");
4057
4058    var g = svg.append("g")
4059        .attr("transform", "translate(" + margin.left + "," + margin.top + ")");
4060
4061    var xExt = d3.extent(data, function(d) { return d.x; });
4062    var yExt = d3.extent(data, function(d) { return d.y; });
4063    var xPad = (xExt[1] - xExt[0]) * 0.05 || 1;
4064    var yPad = (yExt[1] - yExt[0]) * 0.05 || 1;
4065
4066    var xScale = d3.scaleLinear()
4067        .domain([xExt[0] - xPad, xExt[1] + xPad])
4068        .range([0, innerW]);
4069
4070    var yScale = d3.scaleLinear()
4071        .domain([yExt[0] - yPad, yExt[1] + yPad])
4072        .nice()
4073        .range([innerH, 0]);
4074
4075    g.append("g")
4076        .attr("transform", "translate(0," + innerH + ")")
4077        .call(d3.axisBottom(xScale).ticks(6));
4078
4079    g.append("g")
4080        .call(d3.axisLeft(yScale).ticks(6));
4081
4082    var circles = g.selectAll(".sc-circle")
4083        .data(data)
4084        .join("circle")
4085        .attr("class", "sc-circle")
4086        .attr("cx", function(d) { return xScale(d.x); })
4087        .attr("cy", function(d) { return yScale(d.y); })
4088        .attr("r", $radius_js)
4089        .attr("fill", $fill_js)
4090        .attr("stroke", "white")
4091        .attr("stroke-width", 0.5)
4092        .on("mouseover", function(event, d) {
4093            var tipHtml = "<b>" + esc(String(d.x)) + "<\\/b>, <b>" + esc(String(d.y)) + "<\\/b>";
4094            if (d.extra) {
4095                Object.entries(d.extra).forEach(function(kv) {
4096                    tipHtml += "<br>" + esc(kv[0]) + ": " + esc(kv[1]);
4097                });
4098            }
4099            $series_tip_js
4100            $size_tip_js
4101            tip.html(tipHtml)
4102               .style("display", "block")
4103               .style("left", (event.pageX + 12) + "px")
4104               .style("top",  (event.pageY - 24) + "px");
4105        })
4106        .on("mousemove", function(event) {
4107            tip.style("left", (event.pageX + 12) + "px")
4108               .style("top",  (event.pageY - 24) + "px");
4109        })
4110        .on("mouseout", function() {
4111            tip.style("display", "none");
4112        });
4113
4114$anim_block$x_label_block$y_label_block$legend_block})();
4115</script>
4116HTML
4117
4118
24
630
        return { svg_id => $svg_id, html => $html };
4119}
4120
4121 - 4192
=head2 render_table_snippet

    my $fragment = $chart->render_table_snippet($data);
    my $fragment = $chart->render_table_snippet($data, \%opts);
    # $fragment->{table_id} - the id attribute of the <table> element
    # $fragment->{html}     - embeddable HTML fragment (style + table + script)

Generates an embeddable HTML fragment for a sortable, filterable data table.
Returns a fragment with no page-shell wrapper; the caller loads D3 if needed.

=head3 Arguments

=over 4

=item * C<$data> - An array reference of row array references.  The first row
is treated as the header row.  Every subsequent row must have the same number
of columns as the header.

    [ ['Name', 'Value', 'Category'],   # header
      ['Alpha',   300,  'A'],
      ['Beta',    150,  'B'],
    ]

=item * C<\%opts> - Optional hash reference:

=over 4

=item * C<id> (string, default C<'data_table'>) - C<id> of the C<<table>> element.

=item * C<sortable> (boolean, default 1) - If true, clicking a column header
sorts the table by that column (toggle ascending/descending).

=item * C<caption> (string, default C<''>) - Optional C<<caption>> element text.

=back

=back

=head3 Return value

A hash reference with C<table_id> (string) and C<html> (Perl character string).
Note: returns C<table_id>, not C<svg_id>, because this method renders an HTML
table rather than an SVG chart.

=head3 Errors

=over 4

=item * Dies with C<'Data must be an array of arrays'> when C<$data> is not an ARRAY ref.

=item * Dies with C<'Data must have at least one row (header row)'> when C<$data> is empty.

=item * Dies with C<'Each row must be an array reference'> when a row is not an ARRAY ref.

=back

=head3 API SPECIFICATION

    {
        data => { type => 'arrayref' },
        opts => {
            type     => 'hashref',
            optional => 1,
            keys     => {
                id       => { type => 'string',  optional => 1, default => 'data_table' },
                sortable => { type => 'boolean', optional => 1, default => 1 },
                caption  => { type => 'string',  optional => 1, default => '' },
            },
        },
    }

=cut
4193
4194sub render_table_snippet
4195{
4196
29
7675
        my ($self, $data, $opts) = @_;
4197
29
45
        $opts //= {};
4198
4199
29
53
        die 'Data must be an array of arrays'          unless ref($data) eq 'ARRAY';
4200
26
38
        die 'Data must have at least one row (header row)' unless @$data;
4201
23
45
        die 'Each row must be an array reference'      unless ref($data->[0]) eq 'ARRAY';
4202
4203
20
550
        my $sortable = exists($opts->{sortable}) ? $opts->{sortable} : 1;
4204
20
39
        my $caption  = $opts->{caption} // '';
4205
20
38
        my $table_id = $opts->{id} // 'data_table';
4206
4207        my $js_esc = sub {
4208
0
0
                my $s = shift;
4209
0
0
                $s =~ s/\\/\\\\/g;
4210
0
0
                $s =~ s/"/\\"/g;
4211
0
0
                $s =~ s/\n/\\n/g;
4212
0
0
                $s =~ s/\r/\\r/g;
4213
0
0
                return $s;
4214
20
31
        };
4215
4216        my $html_esc = sub {
4217
150
119
                my $s = shift // '';
4218
150
185
                $s =~ s/&/&amp;/g;
4219
150
84
                $s =~ s/</&lt;/g;
4220
150
78
                $s =~ s/>/&gt;/g;
4221
150
137
                return $s;
4222
20
32
        };
4223
4224
20
20
        my $headers = $data->[0];
4225
20
20
33
33
        my @rows    = @{$data}[1 .. $#$data];
4226
4227
20
35
        for my $row (@rows) {
4228
39
48
                die 'Each row must be an array reference' unless ref($row) eq 'ARRAY';
4229        }
4230
4231        my $json_data = $_JSON->encode([
4232                map {
4233
18
55
21
50
                        my @row = @$_;
4234                        \@row
4235
55
263
                } @$data
4236        ]);
4237
4238
18
48
        my $caption_html = $caption
4239                ? '<caption>' . $html_esc->($caption) . '</caption>'
4240                : '';
4241
4242
18
19
        my $sort_th_class   = $sortable ? ' class="dt-sortable"' : '';
4243
18
18
        my $sort_cursor_css = $sortable ? 'cursor:pointer; user-select:none;' : '';
4244
4245
18
47
17
31
        my @th_cells = map { '<th' . $sort_th_class . '>' . $html_esc->($_) . '</th>' } @$headers;
4246
18
26
        my $thead_html = '<tr>' . join('', @th_cells) . '</tr>';
4247
4248
18
8
        my @tbody_rows;
4249
18
14
        for my $row (@rows) {
4250
37
100
32
74
                my @tds = map { '<td>' . $html_esc->($_) . '</td>' } @$row;
4251
37
46
                push @tbody_rows, '<tr>' . join('', @tds) . '</tr>';
4252        }
4253
18
17
        my $tbody_html = join("\n    ", @tbody_rows);
4254
4255
18
18
        my $sort_script = $sortable ? <<"SORT" : '';
4256    var sortState = { col: -1, asc: true };
4257    var tbody = table.select('tbody');
4258    table.selectAll('th.dt-sortable').on('click', function(event, colIdx) {
4259        if (sortState.col === colIdx) {
4260            sortState.asc = !sortState.asc;
4261        } else {
4262            sortState.col = colIdx;
4263            sortState.asc = true;
4264        }
4265        var rows = tbody.selectAll('tr').data();
4266        rows.sort(function(a, b) {
4267            var va = a[colIdx], vb = b[colIdx];
4268            var na = parseFloat(va), nb = parseFloat(vb);
4269            if (!isNaN(na) && !isNaN(nb)) { va = na; vb = nb; }
4270            if (va < vb) return sortState.asc ? -1 : 1;
4271            if (va > vb) return sortState.asc ?  1 : -1;
4272            return 0;
4273        });
4274        tbody.selectAll('tr').data(rows).join('tr')
4275            .selectAll('td').data(function(d) { return d.slice(1); }).join('td')
4276            .text(function(d) { return d; });
4277        table.selectAll('th.dt-sortable')
4278            .attr('data-sort', function(d, i) {
4279                return i === sortState.col ? (sortState.asc ? 'asc' : 'desc') : null;
4280            });
4281    });
4282SORT
4283
4284
18
69
        my $html = <<"HTML";
4285<style>
4286    #$table_id {
4287        border-collapse: collapse;
4288        width: 100%;
4289        font-size: 13px;
4290    }
4291    #$table_id caption { font-weight: bold; margin-bottom: 6px; text-align: left; }
4292    #$table_id th, #$table_id td { border: 1px solid #ccc; padding: 6px 10px; text-align: left; }
4293    #$table_id th { background: #f5f5f5; $sort_cursor_css }
4294    #$table_id th[data-sort="asc"]::after  { content: " \\25B2"; font-size: 10px; }
4295    #$table_id th[data-sort="desc"]::after { content: " \\25BC"; font-size: 10px; }
4296    #$table_id tr:nth-child(even) { background: #fafafa; }
4297    #$table_id tr:hover { background: #f0f4ff; }
4298</style>
4299<table id="$table_id">
4300    $caption_html<thead>
4301    $thead_html</thead>
4302    <tbody>
4303    $tbody_html</tbody>
4304</table>
4305<script>
4306(function() {
4307    var allData = $json_data;
4308    var table = d3.select("#$table_id");
4309    var headers = allData[0];
4310$sort_script})();
4311</script>
4312HTML
4313
4314
18
83
        return { table_id => $table_id, html => $html };
4315}
4316
4317 - 4399
=head2 render_stacked_bar_snippet

    my $result = $chart->render_stacked_bar_snippet(\@rows, \%opts);

Generates an embeddable stacked bar chart (no page shell, no CDN tag).

Each element of C<\@rows> is a hashref with three keys named by C<opts>:
C<cat_col> (the X-axis grouping), C<seg_col> (the stacking segment), and
C<val_col> (the numeric measure).  Multiple rows sharing the same
cat+seg combination are summed.

Accepted C<\%opts> keys:

=over 4

=item * C<cat_col> (string, default C<'cat'>) - row key for the X-axis category.

=item * C<seg_col> (string, default C<'seg'>) - row key for the stacking segment.

=item * C<val_col> (string, default C<'val'>) - row key for the numeric value.

=item * C<orient> (string C<'v'> or C<'h'>, default C<'v'>) - bar orientation.

=item * C<scheme> (string, default C<'tableau10'>) - D3 categorical colour scheme.
Supported: C<tableau10>, C<category10>, C<set2>, C<set3>, C<paired>.

=item * C<x_label> (string, optional) - axis label shown below the category axis.

=item * C<sort> (string, optional) - sort categories: C<'value'> (desc total)
or C<'label'> (alpha).

=item * C<id> (string, optional) - override the default C<svg_id> value.

=item * C<responsive> (boolean, optional) - SVG responsive sizing override.

=back

Returns C<{ svg_id =E<gt> 'stacked_bar', html =E<gt> Str }>.

=head3 Errors

=over 4

=item * Throws C<Data must be an array of hashes> when C<$data> is not an ARRAY reference.

=item * Throws C<Each data point must be a hash reference> when a row is not a hashref.

=item * Throws C<Value must be numeric> when a value field is not a number.

=item * Throws C<orient must be 'v' or 'h'> for an invalid C<orient> value.

=item * Throws C<sort must be 'value' or 'label'> for an invalid C<sort> value.

=back

=head3 API SPECIFICATION

=head4 Input

    {
        data => { type => 'arrayref' },
        opts => {
            type     => 'hashref',
            optional => 1,
            keys     => {
                cat_col  => { type => 'string',  optional => 1, default => 'cat' },
                seg_col  => { type => 'string',  optional => 1, default => 'seg' },
                val_col  => { type => 'string',  optional => 1, default => 'val' },
                orient   => { type => 'string',  optional => 1, default => 'v' },
                scheme   => { type => 'string',  optional => 1, default => 'tableau10' },
                x_label  => { type => 'string',  optional => 1, default => '' },
                sort     => { type => 'string',  optional => 1 },
                id       => { type => 'string',  optional => 1, default => 'stacked_bar' },
                responsive => { type => 'boolean', optional => 1 },
            },
        },
    }

=head4 Output

    HashRef -- { svg_id => Str, html => Str }

=cut
4400
4401sub render_stacked_bar_snippet
4402{
4403
14
6594
        my ($self, $data, $opts) = @_;
4404
14
21
        $opts //= {};
4405
4406
14
15
        die 'Data must be an array of hashes' unless ref($data) eq 'ARRAY';
4407
4408
13
18
        my $cat_col = $opts->{cat_col} // 'cat';
4409
13
12
        my $seg_col = $opts->{seg_col} // 'seg';
4410
13
14
        my $val_col = $opts->{val_col} // 'val';
4411
13
16
        my $orient  = $opts->{orient}  // 'v';
4412
13
17
        my $scheme  = $opts->{scheme}  // 'tableau10';
4413
13
15
        my $x_label = $opts->{x_label} // '';
4414
13
5
        my $sort_by = $opts->{sort};
4415
4416
13
14
        die "orient must be 'v' or 'h'"
4417                unless $orient eq 'v' || $orient eq 'h';
4418
12
19
        die "sort must be 'value' or 'label'"
4419                if defined($sort_by) && $sort_by ne 'value' && $sort_by ne 'label';
4420
4421
11
7
        my (%cats_seen, %segs_seen, %pivot, @cat_order);
4422
11
11
        for my $row (@$data) {
4423
56
41
                die 'Each data point must be a hash reference' unless ref($row) eq 'HASH';
4424
55
49
                die "Value must be numeric" unless looks_like_number($row->{$val_col} // 'x');
4425
54
56
                my ($cat, $seg, $val) = ($row->{$cat_col} // '', $row->{$seg_col} // '', $row->{$val_col} + 0);
4426
54
41
                push @cat_order, $cat unless $cats_seen{$cat}++;
4427
54
30
                $segs_seen{$seg} = 1;
4428
54
59
                $pivot{$cat}{$seg} = ($pivot{$cat}{$seg} // 0) + $val;
4429        }
4430
4431
9
7
        my @segs = keys %segs_seen;
4432
9
5
        my @cats = @cat_order;
4433
4434
9
17
        if (defined $sort_by && $sort_by eq 'label') {
4435
1
3
                @cats = sort @cats;
4436        } elsif (defined $sort_by && $sort_by eq 'value') {
4437                @cats = sort {
4438
1
3
3
3
                        my ($ta, $tb) = (0, 0);
4439
3
5
                        $ta += ($pivot{$a}{$_} // 0) for @segs;
4440
3
4
                        $tb += ($pivot{$b}{$_} // 0) for @segs;
4441
3
1
                        $tb <=> $ta
4442                } @cats;
4443        }
4444
4445        my @rows = map {
4446
9
27
7
11
                my $cat = $_;
4447
27
22
                my %r = (cat => $cat);
4448
27
33
                $r{$_} = ($pivot{$cat}{$_} // 0) for @segs;
4449
27
20
                \%r
4450        } @cats;
4451
4452
9
32
        my $json_data = $_JSON->encode(\@rows);
4453
9
10
        my $json_segs = $_JSON->encode(\@segs);
4454
4455        my $js_esc = sub {
4456
9
5
                my $s = shift;
4457
9
9
6
6
                $s =~ s/\\/\\\\/g; $s =~ s/"/\\"/g;
4458
9
9
5
3
                $s =~ s/\n/\\n/g;  $s =~ s/\r/\\r/g;
4459
9
6
                return $s;
4460
9
10
        };
4461
4462
9
9
        my $x_label_esc = $js_esc->($x_label);
4463
9
9
        my $svg_id  = $opts->{id} // 'stacked_bar';
4464
9
6
        my $tip_id  = $svg_id . '_tip';
4465
9
9
        my $width   = $self->{width};
4466
9
4
        my $height  = $self->{height};
4467        my $svg_attrs = ($opts->{responsive} // $self->{responsive})
4468
9
13
                ? qq{viewBox="0 0 $width $height" width="100%" height="auto"}
4469                : qq{width="$width" height="$height"};
4470
4471
9
7
        my $is_v          = ($orient eq 'v') ? 1 : 0;
4472
9
6
        my $margin_top    = 20;
4473
9
2
        my $margin_right  = 130;
4474
9
10
        my $margin_bottom = $is_v ? ($x_label ? 60 : 40) : 40;
4475
9
6
        my $margin_left   = $is_v ? 55 : 130;
4476
9
5
        my $inner_w       = $width  - $margin_left - $margin_right;
4477
9
6
        my $inner_h       = $height - $margin_top  - $margin_bottom;
4478
9
5
        my $legend_x      = $margin_left + $inner_w + 12;
4479
4480
9
8
        my $x_label_block = $x_label ? <<"XLB" : '';
4481    g.append("text")
4482        .attr("class","sb-axis-label")
4483        .attr("x", innerW / 2)
4484        .attr("y", innerH + (margin.bottom - 10))
4485        .attr("text-anchor","middle")
4486        .text("$x_label_esc");
4487XLB
4488
4489
9
3
        my ($scales_js, $axes_js, $rect_attrs_js);
4490
9
7
        if ($is_v) {
4491
8
4
                $scales_js = <<'SCALES';
4492    var xScale = d3.scaleBand()
4493        .domain(data.map(function(d){return d.cat;}))
4494        .range([0, innerW]).padding(0.2);
4495    var yScale = d3.scaleLinear()
4496        .domain([0, d3.max(stackedData[stackedData.length-1], function(d){return d[1];})])
4497        .nice().range([innerH, 0]);
4498SCALES
4499
8
3
                $axes_js = <<'AXES';
4500    g.append("g").attr("class","sb-x-axis")
4501        .attr("transform","translate(0,"+innerH+")").call(d3.axisBottom(xScale));
4502    g.append("g").attr("class","sb-y-axis").call(d3.axisLeft(yScale).ticks(6));
4503AXES
4504
8
5
                $rect_attrs_js = <<'RECTS';
4505            .attr("x",      function(d){return xScale(d.data.cat);})
4506            .attr("y",      function(d){return yScale(d[1]);})
4507            .attr("height", function(d){return Math.max(0, yScale(d[0]) - yScale(d[1]));})
4508            .attr("width",  xScale.bandwidth())
4509RECTS
4510        } else {
4511
1
1
                $scales_js = <<'SCALES';
4512    var yScale = d3.scaleBand()
4513        .domain(data.map(function(d){return d.cat;}))
4514        .range([0, innerH]).padding(0.2);
4515    var xScale = d3.scaleLinear()
4516        .domain([0, d3.max(stackedData[stackedData.length-1], function(d){return d[1];})])
4517        .nice().range([0, innerW]);
4518SCALES
4519
1
0
                $axes_js = <<'AXES';
4520    g.append("g").attr("class","sb-x-axis")
4521        .attr("transform","translate(0,"+innerH+")").call(d3.axisBottom(xScale).ticks(6));
4522    g.append("g").attr("class","sb-y-axis").call(d3.axisLeft(yScale));
4523AXES
4524
1
1
                $rect_attrs_js = <<'RECTS';
4525            .attr("y",      function(d){return yScale(d.data.cat);})
4526            .attr("x",      function(d){return xScale(d[0]);})
4527            .attr("width",  function(d){return Math.max(0, xScale(d[1]) - xScale(d[0]));})
4528            .attr("height", yScale.bandwidth())
4529RECTS
4530        }
4531
4532
9
33
        my $html = <<"HTML";
4533<style>
4534    #$tip_id {
4535        position:absolute;background:rgba(255,255,255,0.95);border:1px solid #ccc;
4536        border-radius:4px;padding:6px 10px;font-size:12px;pointer-events:none;
4537        display:none;line-height:1.6;
4538    }
4539    .sb-axis-label{font-size:11px;fill:#555;}
4540    .sb-legend-item{cursor:pointer;}
4541</style>
4542<svg id="$svg_id" $svg_attrs></svg>
4543<div id="$tip_id"></div>
4544<script>
4545(function(){
4546    var data = $json_data;
4547    var segs = $json_segs;
4548    var margin = {top:$margin_top,right:$margin_right,bottom:$margin_bottom,left:$margin_left};
4549    var innerW = $inner_w;
4550    var innerH = $inner_h;
4551    var SCHEMES = {
4552        tableau10:d3.schemeTableau10, category10:d3.schemeCategory10,
4553        set2:d3.schemeSet2, set3:d3.schemeSet3, paired:d3.schemePaired
4554    };
4555    var color = d3.scaleOrdinal(SCHEMES['$scheme'] || d3.schemeTableau10).domain(segs);
4556    var hidden = {};
4557    var svg = d3.select("#$svg_id");
4558    var g = svg.append("g").attr("transform","translate("+margin.left+","+margin.top+")");
4559    var tip = d3.select("#$tip_id");
4560    var stackFn = d3.stack().order(d3.stackOrderNone).offset(d3.stackOffsetNone);
4561    var stackedData = stackFn.keys(segs)(data);
4562    $scales_js
4563    $axes_js
4564    $x_label_block
4565    function redraw(){
4566        var vis = segs.filter(function(s){return !hidden[s];});
4567        var sd = stackFn.keys(vis)(data);
4568        var layers = g.selectAll(".sb-layer").data(sd, function(d){return d.key;});
4569        var entered = layers.enter().append("g").attr("class","sb-layer")
4570            .attr("fill",function(d){return color(d.key);});
4571        entered.merge(layers)
4572            .selectAll(".sb-rect").data(function(d){return d;})
4573            .join("rect").attr("class","sb-rect")
4574            $rect_attrs_js
4575            .on("mouseover",function(event,d){
4576                var seg = d3.select(this.parentNode).datum().key;
4577                tip.style("display","block")
4578                   .html("<b>"+d.data.cat+"<\\/b><br\/>"+seg+": "+(d[1]-d[0]));
4579            })
4580            .on("mousemove",function(event){
4581                tip.style("left",(event.offsetX+12)+"px").style("top",(event.offsetY-28)+"px");
4582            })
4583            .on("mouseout",function(){tip.style("display","none");});
4584        layers.exit().remove();
4585    }
4586    redraw();
4587    var legend = svg.append("g").attr("transform","translate($legend_x,$margin_top)");
4588    segs.forEach(function(seg,i){
4589        var item = legend.append("g").attr("class","sb-legend-item")
4590            .attr("transform","translate(0,"+(i*20)+")")
4591            .on("click",function(){
4592                hidden[seg] = !hidden[seg];
4593                d3.select(this).select("rect").attr("opacity",hidden[seg]?0.3:1);
4594                d3.select(this).select("text").attr("opacity",hidden[seg]?0.4:1);
4595                redraw();
4596            });
4597        item.append("rect").attr("width",12).attr("height",12).attr("y",-10)
4598            .attr("fill",color(seg));
4599        item.append("text").attr("x",16).attr("font-size","11px").attr("fill","#333")
4600            .text(seg);
4601    });
4602})();
4603</script>
4604HTML
4605
4606
9
39
        return { svg_id => $svg_id, html => $html };
4607}
4608
4609 - 4688
=head2 render_combo_snippet

    my $result = $chart->render_combo_snippet(\@rows, \%opts);

Generates an embeddable combo chart: bars for one measure on the left Y
axis, a line for another measure on the right Y axis, sharing the same
X (category) axis.

Each element of C<\@rows> is a hashref with at least three keys named by
C<opts>: C<x_col> (X-axis label), C<bar_col> (bar measure), and
C<line_col> (line measure).

Accepted C<\%opts> keys:

=over 4

=item * C<x_col> (string, default C<'x'>) - row key for the X-axis label.

=item * C<bar_col> (string, default C<'bar_val'>) - row key for the bar measure.

=item * C<line_col> (string, default C<'line_val'>) - row key for the line measure.

=item * C<bar_label> (string, default C<'Bar'>) - legend/axis label for bars.

=item * C<line_label> (string, default C<'Line'>) - legend/axis label for line.

=item * C<bar_color> (string, optional CSS colour, default C<'steelblue'>) - bar fill.

=item * C<line_color> (string, optional CSS colour, default C<'#e15759'>) - line stroke.

=item * C<id> (string, optional) - override the default C<svg_id> value.

=item * C<responsive> (boolean, optional) - SVG responsive sizing override.

=back

Returns C<{ svg_id =E<gt> 'combo_chart', html =E<gt> Str }>.

=head3 Errors

=over 4

=item * Throws C<Data must be an array of hashes> when C<$data> is not an ARRAY reference.

=item * Throws C<Each data point must be a hash reference> when a row is not a hashref.

=item * Throws C<Bar value must be numeric> when a bar value field is not a number.

=item * Throws C<Line value must be numeric> when a line value field is not a number.

=back

=head3 API SPECIFICATION

=head4 Input

    {
        data => { type => 'arrayref' },
        opts => {
            type     => 'hashref',
            optional => 1,
            keys     => {
                x_col      => { type => 'string',  optional => 1, default => 'x' },
                bar_col    => { type => 'string',  optional => 1, default => 'bar_val' },
                line_col   => { type => 'string',  optional => 1, default => 'line_val' },
                bar_label  => { type => 'string',  optional => 1, default => 'Bar' },
                line_label => { type => 'string',  optional => 1, default => 'Line' },
                bar_color  => { type => 'string',  optional => 1, default => 'steelblue' },
                line_color => { type => 'string',  optional => 1, default => '#e15759' },
                id         => { type => 'string',  optional => 1, default => 'combo_chart' },
                responsive => { type => 'boolean', optional => 1 },
            },
        },
    }

=head4 Output

    HashRef -- { svg_id => Str, html => Str }

=cut
4689
4690sub render_combo_snippet
4691{
4692
10
5413
        my ($self, $data, $opts) = @_;
4693
10
15
        $opts //= {};
4694
4695
10
15
        die 'Data must be an array of hashes' unless ref($data) eq 'ARRAY';
4696
4697
9
11
        my $x_col      = $opts->{x_col}      // 'x';
4698
9
11
        my $bar_col    = $opts->{bar_col}     // 'bar_val';
4699
9
10
        my $line_col   = $opts->{line_col}    // 'line_val';
4700
9
18
        my $bar_label  = $opts->{bar_label}   // 'Bar';
4701
9
7
        my $line_label = $opts->{line_label}  // 'Line';
4702
9
12
        my $bar_color  = $opts->{bar_color}   // 'steelblue';
4703
9
15
        my $line_color = $opts->{line_color}  // '#e15759';
4704
4705
9
1
        my @points;
4706
9
9
        for my $row (@$data) {
4707
25
15
                die 'Each data point must be a hash reference' unless ref($row) eq 'HASH';
4708
24
33
                die 'Bar value must be numeric'  unless looks_like_number($row->{$bar_col}  // 'x');
4709
23
25
                die 'Line value must be numeric' unless looks_like_number($row->{$line_col} // 'x');
4710                push @points, {
4711                        x    => defined($row->{$x_col}) ? "$row->{$x_col}" : '',
4712                        bar  => $row->{$bar_col}  + 0,
4713
22
34
                        line => $row->{$line_col} + 0,
4714                };
4715        }
4716
4717
6
40
        my $json_data = $_JSON->encode(\@points);
4718
4719        my $js_esc = sub {
4720
24
16
                my $s = shift;
4721
24
24
9
17
                $s =~ s/\\/\\\\/g; $s =~ s/"/\\"/g;
4722
24
24
8
7
                $s =~ s/\n/\\n/g;  $s =~ s/\r/\\r/g;
4723
24
18
                return $s;
4724
6
8
        };
4725
4726
6
6
        my $bar_label_esc  = $js_esc->($bar_label);
4727
6
2
        my $line_label_esc = $js_esc->($line_label);
4728
6
2
        my $bar_color_esc  = $js_esc->($bar_color);
4729
6
2
        my $line_color_esc = $js_esc->($line_color);
4730
4731
6
7
        my $svg_id  = $opts->{id} // 'combo_chart';
4732
6
3
        my $tip_id  = $svg_id . '_tip';
4733
6
6
        my $width   = $self->{width};
4734
6
5
        my $height  = $self->{height};
4735        my $svg_attrs = ($opts->{responsive} // $self->{responsive})
4736
6
12
                ? qq{viewBox="0 0 $width $height" width="100%" height="auto"}
4737                : qq{width="$width" height="$height"};
4738
4739
6
4
        my $margin_top    = 20;
4740
6
3
        my $margin_right  = 70;
4741
6
4
        my $margin_bottom = 55;
4742
6
3
        my $margin_left   = 60;
4743
6
4
        my $inner_w = $width  - $margin_left - $margin_right;
4744
6
4
        my $inner_h = $height - $margin_top  - $margin_bottom;
4745
4746
6
17
        my $html = <<"HTML";
4747<style>
4748    #$tip_id {
4749        position:absolute;background:rgba(255,255,255,0.95);border:1px solid #ccc;
4750        border-radius:4px;padding:6px 10px;font-size:12px;pointer-events:none;
4751        display:none;line-height:1.6;
4752    }
4753    .cc-axis-label{font-size:11px;fill:#555;}
4754    .cc-dot{cursor:default;}
4755</style>
4756<svg id="$svg_id" $svg_attrs></svg>
4757<div id="$tip_id"></div>
4758<script>
4759(function(){
4760    var data = $json_data;
4761    var margin = {top:$margin_top,right:$margin_right,bottom:$margin_bottom,left:$margin_left};
4762    var innerW = $inner_w;
4763    var innerH = $inner_h;
4764    var barLabel  = "$bar_label_esc";
4765    var lineLabel = "$line_label_esc";
4766    var barColor  = "$bar_color_esc";
4767    var lineColor = "$line_color_esc";
4768    var svg = d3.select("#$svg_id");
4769    var g = svg.append("g").attr("transform","translate("+margin.left+","+margin.top+")");
4770    var tip = d3.select("#$tip_id");
4771
4772    var xScale = d3.scaleBand()
4773        .domain(data.map(function(d){return d.x;}))
4774        .range([0, innerW]).padding(0.25);
4775    var yLeft = d3.scaleLinear()
4776        .domain([0, d3.max(data,function(d){return d.bar;})]).nice()
4777        .range([innerH, 0]);
4778    var yRight = d3.scaleLinear()
4779        .domain([d3.min(data,function(d){return d.line;}), d3.max(data,function(d){return d.line;})])
4780        .nice().range([innerH, 0]);
4781
4782    g.append("g").attr("class","cc-x-axis")
4783        .attr("transform","translate(0,"+innerH+")").call(d3.axisBottom(xScale));
4784    g.append("g").attr("class","cc-y-left").call(d3.axisLeft(yLeft).ticks(6));
4785    g.append("g").attr("class","cc-y-right")
4786        .attr("transform","translate("+innerW+",0)").call(d3.axisRight(yRight).ticks(6));
4787
4788    // Left axis label
4789    g.append("text").attr("class","cc-axis-label")
4790        .attr("transform","rotate(-90)")
4791        .attr("x",-innerH/2).attr("y",-(margin.left-15))
4792        .attr("text-anchor","middle").text(barLabel);
4793    // Right axis label
4794    svg.append("text").attr("class","cc-axis-label")
4795        .attr("transform","rotate(90)")
4796        .attr("x",(margin.top+innerH/2)).attr("y",-(margin.left+innerW+margin.right-15))
4797        .attr("text-anchor","middle").text(lineLabel);
4798    // X axis label
4799    g.append("text").attr("class","cc-axis-label")
4800        .attr("x",innerW/2).attr("y",innerH+(margin.bottom-12))
4801        .attr("text-anchor","middle").text(barLabel+" vs "+lineLabel);
4802
4803    // Bars
4804    g.selectAll(".cc-bar").data(data).join("rect")
4805        .attr("class","cc-bar")
4806        .attr("x",function(d){return xScale(d.x);})
4807        .attr("y",function(d){return yLeft(d.bar);})
4808        .attr("width",xScale.bandwidth())
4809        .attr("height",function(d){return innerH - yLeft(d.bar);})
4810        .attr("fill",barColor).attr("opacity",0.8)
4811        .on("mouseover",function(event,d){
4812            tip.style("display","block")
4813               .html("<b>"+d.x+"<\\/b><br\/>"+barLabel+": "+d.bar+"<br\/>"+lineLabel+": "+d.line);
4814        })
4815        .on("mousemove",function(event){
4816            tip.style("left",(event.offsetX+12)+"px").style("top",(event.offsetY-28)+"px");
4817        })
4818        .on("mouseout",function(){tip.style("display","none");});
4819
4820    // Line path
4821    var line = d3.line()
4822        .x(function(d){return xScale(d.x) + xScale.bandwidth()/2;})
4823        .y(function(d){return yRight(d.line);});
4824    g.append("path").datum(data)
4825        .attr("fill","none").attr("stroke",lineColor).attr("stroke-width",2.5)
4826        .attr("d",line);
4827
4828    // Dots
4829    g.selectAll(".cc-dot").data(data).join("circle")
4830        .attr("class","cc-dot")
4831        .attr("cx",function(d){return xScale(d.x) + xScale.bandwidth()/2;})
4832        .attr("cy",function(d){return yRight(d.line);})
4833        .attr("r",4).attr("fill",lineColor).attr("stroke","#fff").attr("stroke-width",1.5)
4834        .on("mouseover",function(event,d){
4835            tip.style("display","block")
4836               .html("<b>"+d.x+"<\\/b><br\/>"+barLabel+": "+d.bar+"<br\/>"+lineLabel+": "+d.line);
4837        })
4838        .on("mousemove",function(event){
4839            tip.style("left",(event.offsetX+12)+"px").style("top",(event.offsetY-28)+"px");
4840        })
4841        .on("mouseout",function(){tip.style("display","none");});
4842
4843    // Legend
4844    var lg = svg.append("g").attr("transform","translate("+(margin.left+8)+","+(margin.top+8)+")");
4845    lg.append("rect").attr("width",12).attr("height",12).attr("fill",barColor).attr("opacity",0.8);
4846    lg.append("text").attr("x",16).attr("y",10).attr("font-size","11px").attr("fill","#333").text(barLabel);
4847    var lg2 = svg.append("g").attr("transform","translate("+(margin.left+8)+","+(margin.top+26)+")");
4848    lg2.append("line").attr("x1",0).attr("y1",6).attr("x2",12).attr("y2",6)
4849        .attr("stroke",lineColor).attr("stroke-width",2.5);
4850    lg2.append("circle").attr("cx",6).attr("cy",6).attr("r",4).attr("fill",lineColor);
4851    lg2.append("text").attr("x",16).attr("y",10).attr("font-size","11px").attr("fill","#333").text(lineLabel);
4852})();
4853</script>
4854HTML
4855
4856
6
21
        return { svg_id => $svg_id, html => $html };
4857}
4858
4859 - 4938
=head2 render_treemap_snippet

    my $result = $chart->render_treemap_snippet(\@rows_or_tree, \%opts);

Generates an embeddable treemap (no page shell, no CDN tag).
Leaf area is proportional to the numeric measure.

Accepts two data formats:

=over 4

=item * B<Flat path> (arrayref of hashrefs): each row must have a C<path>
key (e.g. C<'Root/A/X'>) and a C<value> key.  The module converts this
to a D3 stratify hierarchy in JS.  Override the path separator with
C<opts-E<gt>{path_sep}>.

=item * B<Nested tree> (hashref): a pre-built D3-compatible hierarchy
with C<name>/C<children>/C<value> structure.  Passed directly to
C<d3.hierarchy()>.

=back

Accepted C<\%opts> keys:

=over 4

=item * C<path_sep> (string, default C<'/'>)  - path separator (flat format only).

=item * C<scheme> (string, default C<'tableau10'>) - D3 categorical colour scheme.
Supported: C<tableau10>, C<category10>, C<set2>, C<set3>, C<paired>.

=item * C<label> (boolean, default C<1>) - show node labels inside cells.

=item * C<id> (string, optional) - override the default C<svg_id> value.

=item * C<responsive> (boolean, optional) - SVG responsive sizing override.

=back

Returns C<{ svg_id =E<gt> 'treemap', html =E<gt> Str }>.

=head3 Errors

=over 4

=item * Throws C<Data must be an array of hashes or a hash tree>
when C<$data> is neither an ARRAY nor a HASH reference.

=item * Throws C<Each flat row must be a hash reference> when a flat row is not a hashref.

=item * Throws C<Each flat row must have a 'path' key> when a flat row lacks C<path>.

=item * Throws C<Each flat row must have a numeric 'value' key> when a value is not numeric.

=back

=head3 API SPECIFICATION

=head4 Input

    {
        data => { type => 'arrayref | hashref' },
        opts => {
            type     => 'hashref',
            optional => 1,
            keys     => {
                path_sep => { type => 'string',  optional => 1, default => '/' },
                scheme   => { type => 'string',  optional => 1, default => 'tableau10' },
                label    => { type => 'boolean', optional => 1, default => 1 },
                id       => { type => 'string',  optional => 1, default => 'treemap' },
                responsive => { type => 'boolean', optional => 1 },
            },
        },
    }

=head4 Output

    HashRef -- { svg_id => Str, html => Str }

=cut
4939
4940sub render_treemap_snippet
4941{
4942
13
5640
        my ($self, $data, $opts) = @_;
4943
13
17
        $opts //= {};
4944
4945
13
21
        die 'Data must be an array of hashes or a hash tree'
4946                unless ref($data) eq 'ARRAY' || ref($data) eq 'HASH';
4947
4948
12
15
        my $path_sep = $opts->{path_sep} // '/';
4949
12
14
        my $scheme   = $opts->{scheme}   // 'tableau10';
4950
12
11
        my $label    = exists($opts->{label}) ? ($opts->{label} ? 1 : 0) : 1;
4951
4952        my $js_esc = sub {
4953
17
8
                my $s = shift;
4954
17
17
10
9
                $s =~ s/\\/\\\\/g; $s =~ s/"/\\"/g;
4955
17
17
8
9
                $s =~ s/\n/\\n/g;  $s =~ s/\r/\\r/g;
4956
17
38
                return $s;
4957
12
20
        };
4958
4959
12
9
        my ($json_data, $build_js);
4960
12
11
        if (ref($data) eq 'ARRAY') {
4961
11
6
                for my $row (@$data) {
4962
33
23
                        die 'Each flat row must be a hash reference' unless ref($row) eq 'HASH';
4963
32
27
                        die "Each flat row must have a 'path' key"  unless exists $row->{path};
4964                        die "Each flat row must have a numeric 'value' key"
4965
31
34
                                unless looks_like_number($row->{value} // 'x');
4966                }
4967
8
30
6
29
                my @flat = map { { path => "$_->{path}", value => $_->{value} + 0 } } @$data;
4968
8
27
                $json_data = $_JSON->encode(\@flat);
4969
8
6
                my $sep_esc = $js_esc->($path_sep);
4970
8
12
                $build_js = <<"BUILDJS";
4971    var sep = "$sep_esc";
4972    var nodeMap = {};
4973    rawData.forEach(function(r){
4974        var parts = r.path.split(sep);
4975        var id = r.path;
4976        if(!nodeMap[id]){ nodeMap[id] = {id:id, value:r.value}; }
4977        for(var i = parts.length-1; i >= 1; i--){
4978            var pid = parts.slice(0,i).join(sep);
4979            if(!nodeMap[pid]){ nodeMap[pid] = {id:pid, value:0}; }
4980        }
4981        var root0 = parts[0];
4982        if(!nodeMap[root0]){ nodeMap[root0] = {id:root0, value:0}; }
4983    });
4984    var nodes = Object.values(nodeMap).map(function(n){
4985        var parts = n.id.split(sep);
4986        return {id:n.id, parentId: parts.length>1 ? parts.slice(0,parts.length-1).join(sep) : null, value:n.value};
4987    });
4988    var root = d3.stratify().id(function(d){return d.id;})
4989        .parentId(function(d){return d.parentId;})(nodes)
4990        .sum(function(d){return d.value || 0;})
4991        .sort(function(a,b){return b.value - a.value;});
4992BUILDJS
4993        } else {
4994
1
10
                $json_data = $_JSON->encode($data);
4995
1
1
                $build_js = <<'BUILDJS';
4996    var root = d3.hierarchy(rawData)
4997        .sum(function(d){return d.value || 0;})
4998        .sort(function(a,b){return b.value - a.value;});
4999BUILDJS
5000        }
5001
5002
9
11
        my $svg_id = $opts->{id} // 'treemap';
5003
9
5
        my $tip_id = $svg_id . '_tip';
5004
9
6
        my $width  = $self->{width};
5005
9
7
        my $height = $self->{height};
5006        my $svg_attrs = ($opts->{responsive} // $self->{responsive})
5007
9
14
                ? qq{viewBox="0 0 $width $height" width="100%" height="auto"}
5008                : qq{width="$width" height="$height"};
5009
5010
9
6
        my $label_block = $label ? <<"LBLJS" : '';
5011        cell.append("text")
5012            .attr("class","tm-label")
5013            .attr("x",4).attr("y",14)
5014            .attr("font-size","11px").attr("fill","#fff")
5015            .text(function(d){
5016                var w = d.x1 - d.x0, h = d.y1 - d.y0;
5017                if(w < 24 || h < 14){ return ""; }
5018                var name = d.id ? d.id.split(sep||"/").pop() : (d.data.name||"");
5019                return name;
5020            })
5021            .each(function(d){
5022                var w = d.x1 - d.x0 - 6;
5023                var self2 = d3.select(this);
5024                var text = self2.text();
5025                while(self2.node().getComputedTextLength && self2.node().getComputedTextLength() > w && text.length > 1){
5026                    text = text.slice(0,-1);
5027                    self2.text(text + "…");
5028                }
5029            });
5030LBLJS
5031
5032
9
8
        my $html = <<"HTML";
5033<style>
5034    #$tip_id {
5035        position:absolute;background:rgba(255,255,255,0.95);border:1px solid #ccc;
5036        border-radius:4px;padding:6px 10px;font-size:12px;pointer-events:none;
5037        display:none;line-height:1.6;
5038    }
5039    .tm-cell{stroke:#fff;stroke-width:1;}
5040    .tm-label{pointer-events:none;}
5041</style>
5042<svg id="$svg_id" $svg_attrs></svg>
5043<div id="$tip_id"></div>
5044<script>
5045(function(){
5046    var rawData = $json_data;
5047    var SCHEMES = {
5048        tableau10:d3.schemeTableau10, category10:d3.schemeCategory10,
5049        set2:d3.schemeSet2, set3:d3.schemeSet3, paired:d3.schemePaired
5050    };
5051    var color = d3.scaleOrdinal(SCHEMES['$scheme'] || d3.schemeTableau10);
5052
9
6
    var sep = "${\$js_esc->($path_sep)}";
5053    var svg = d3.select("#$svg_id");
5054    var tip = d3.select("#$tip_id");
5055    $build_js
5056    var layout = d3.treemap().tile(d3.treemapSquarify)
5057        .size([$width, $height]).paddingOuter(2).paddingInner(1)(root);
5058    var leaves = root.leaves();
5059    var cell = svg.selectAll(".tm-cell-g")
5060        .data(leaves).join("g").attr("class","tm-cell-g")
5061        .attr("transform",function(d){return "translate("+d.x0+","+d.y0+")";});
5062    cell.append("rect")
5063        .attr("class","tm-cell")
5064        .attr("width",function(d){return Math.max(0,d.x1-d.x0);})
5065        .attr("height",function(d){return Math.max(0,d.y1-d.y0);})
5066        .attr("fill",function(d){
5067            var anc = d; while(anc.depth > 1){ anc = anc.parent; }
5068            return color(anc.id || anc.data.name || "");
5069        })
5070        .on("mouseover",function(event,d){
5071            var name = d.id ? d.id.split(sep).pop() : (d.data.name||"");
5072            tip.style("display","block")
5073               .html("<b>"+name+"<\\/b><br\/>"+d.value);
5074        })
5075        .on("mousemove",function(event){
5076            tip.style("left",(event.offsetX+12)+"px").style("top",(event.offsetY-28)+"px");
5077        })
5078        .on("mouseout",function(){tip.style("display","none");});
5079    $label_block
5080})();
5081</script>
5082HTML
5083
5084
9
26
        return { svg_id => $svg_id, html => $html };
5085}
5086
5087 - 5184
=head2 render_histogram_snippet

    my $result = $chart->render_histogram_snippet(\@values, \%opts);

Generates an embeddable frequency histogram (no page shell, no CDN tag).
C<\@values> is an arrayref of numeric scalars (already extracted and
coerced by the caller).

Accepted C<\%opts> keys:

=over 4

=item * C<bins> (integer or C<undef>, default C<20>) - number of bins.
Pass C<undef> for automatic bin count using Sturges' rule:
C<ceil(log2(n) + 1)>.  Ignored when C<binned> is set.

=item * C<binned> (boolean, default 0) - treat C<$data> as pre-computed bins
rather than as raw values.  Each element is a hashref with a C<count> and
either a C<label> or a numeric C<lo>/C<hi> pair from which the label C<[lo,hi)>
is derived:

    { label => '[0,10)', count => 3 }
    { lo => 10, hi => 20, count => 5 }

The caller owns the binning, so C<d3.bin()> is never called: one bar is drawn
per entry, in the order supplied, and the supplied counts are authoritative.

=item * C<scheme> (string, optional) - D3 categorical colour scheme (for example
C<'tableau10'> or C<'category10'>) whose first colour is used as the bar fill
when C<color> is not given.

=item * C<x_label> (string, optional) - label shown below the X axis.

=item * C<color> (string, default C<'steelblue'>) - bar fill colour.

=item * C<id> (string, optional) - override the default C<svg_id> value.

=item * C<responsive> (boolean, optional) - SVG responsive sizing override.

=back

Returns C<{ svg_id =E<gt> 'histogram', html =E<gt> Str }>.

=head3 Errors

=over 4

=item * Throws C<Data must be an array of numeric values> when C<$data> is
not an ARRAY reference.

=item * Throws C<Each value must be numeric> when any element is non-numeric.

=item * Throws C<bins must be a positive integer> when C<bins> is defined
but not a positive integer.

=item * Throws C<Data must contain at least one bin> when C<binned> is set and
C<$data> holds no bins.

=item * Throws C<Each pre-binned entry must be a hash reference> when a
pre-binned element is not a hashref.

=item * Throws C<Bin count must be numeric> when a pre-binned C<count> is
missing or not numeric.

=item * Throws C<Bin count must not be negative> when a pre-binned C<count> is
negative.

=item * Throws C<Each pre-binned entry must have a label or numeric lo and hi>
when a pre-binned entry has neither a C<label> nor a numeric C<lo>/C<hi> pair.

=back

=head3 API SPECIFICATION

=head4 Input

    {
        data => { type => 'arrayref' },
        opts => {
            type     => 'hashref',
            optional => 1,
            keys     => {
                bins    => { type => 'integer', optional => 1, default => 20 },
                binned  => { type => 'boolean', optional => 1, default => 0 },
                x_label => { type => 'string',  optional => 1, default => '' },
                color   => { type => 'string',  optional => 1, default => 'steelblue' },
                scheme  => { type => 'string',  optional => 1 },
                id      => { type => 'string',  optional => 1, default => 'histogram' },
                responsive => { type => 'boolean', optional => 1 },
            },
        },
    }

=head4 Output

    HashRef -- { svg_id => Str, html => Str }

=cut
5185
5186# _render_histogram_binned($self, \@bins, \%opts)
5187# Internal helper for the binned => 1 path of render_histogram_snippet.
5188# $bins: [ {label=>'[0,10)',count=>N}, ... ] or [ {lo=>0,hi=>10,count=>N}, ... ]
5189sub _render_histogram_binned
5190{
5191
14
6
        my ($self, $bins, $opts) = @_;
5192
5193        my $js_esc = sub {
5194
9
5
                my $s = shift;
5195
9
9
7
3
                $s =~ s/\\/\\\\/g; $s =~ s/"/\\"/g;
5196
9
9
8
3
                $s =~ s/\n/\\n/g;  $s =~ s/\r/\\r/g;
5197
9
7
                return $s;
5198
14
15
        };
5199
5200
14
19
        die 'Data must contain at least one bin' unless @$bins;
5201
5202
13
5
        my @norm;
5203
13
8
        for my $b (@$bins) {
5204
20
16
                die 'Each pre-binned entry must be a hash reference'
5205                        unless ref($b) eq 'HASH';
5206                die 'Bin count must be numeric'
5207
19
35
                        unless defined($b->{count}) && looks_like_number($b->{count});
5208
17
15
                die 'Bin count must not be negative' unless $b->{count} + 0 >= 0;
5209
5210
16
19
                my $lo = (defined($b->{lo}) && looks_like_number($b->{lo})) ? $b->{lo} + 0 : undef;
5211
16
18
                my $hi = (defined($b->{hi}) && looks_like_number($b->{hi})) ? $b->{hi} + 0 : undef;
5212
5213
16
7
                my $label = $b->{label};
5214
16
21
                if(!defined($label) || !length($label)) {
5215
4
10
                        die 'Each pre-binned entry must have a label or numeric lo and hi'
5216                                unless defined($lo) && defined($hi);
5217
2
7
                        $label = sprintf('[%g,%g)', $lo, $hi);
5218                }
5219
5220
14
19
                push @norm, { label => "$label", count => $b->{count} + 0 };
5221        }
5222
5223
7
21
        my $json_data = $_JSON->encode(\@norm);
5224
5225
7
8
        my $x_label   = $opts->{x_label} // '';
5226
7
9
        my $svg_id    = $opts->{id}      // 'histogram';
5227
7
4
        my $tip_id    = $svg_id . '_tip';
5228
7
4
        my $width     = $self->{width};
5229
7
4
        my $height    = $self->{height};
5230        my $svg_attrs = ($opts->{responsive} // $self->{responsive})
5231
7
18
                ? qq{viewBox="0 0 $width $height" width="100%" height="auto"}
5232                : qq{width="$width" height="$height"};
5233
5234
7
4
        my $margin_bottom = $x_label ? 60 : 40;
5235
7
4
        my $margin_left   = 55;
5236
7
1
        my $margin_top    = 20;
5237
7
6
        my $margin_right  = 20;
5238
7
2
        my $inner_w = $width  - $margin_left - $margin_right;
5239
7
8
        my $inner_h = $height - $margin_top  - $margin_bottom;
5240
5241
7
5
        my $x_label_esc = $js_esc->($x_label);
5242
5243        # Bar fill: an explicit colour wins, otherwise the first colour of the
5244        # requested D3 categorical scheme, otherwise the default colour.
5245
7
4
        my $fill_js = '"steelblue"';
5246
7
10
        if(defined $opts->{color}) {
5247
2
3
                $fill_js = '"' . $js_esc->($opts->{color}) . '"';
5248        } elsif(defined $opts->{scheme}) {
5249
1
4
                (my $scheme_js = $opts->{scheme}) =~ s/^([a-z])/\u$1/;
5250
1
2
                $fill_js = "d3.scheme${scheme_js}[0]";
5251        }
5252
5253
7
6
        my $x_label_block = $x_label ? <<"XLB" : '';
5254    g.append("text")
5255        .attr("class","hg-axis-label")
5256        .attr("x", innerW / 2)
5257        .attr("y", innerH + (margin.bottom - 10))
5258        .attr("text-anchor","middle")
5259        .text("$x_label_esc");
5260XLB
5261
5262
7
18
        my $html = <<"HTML";
5263<style>
5264    #$tip_id {
5265        position:absolute;background:rgba(255,255,255,0.95);border:1px solid #ccc;
5266        border-radius:4px;padding:6px 10px;font-size:12px;pointer-events:none;
5267        display:none;line-height:1.6;
5268    }
5269    .hg-axis-label{font-size:11px;fill:#555;}
5270    .hg-bar{cursor:default;}
5271</style>
5272<svg id="$svg_id" $svg_attrs></svg>
5273<div id="$tip_id"></div>
5274<script>
5275(function(){
5276    var bins = $json_data;
5277    var margin = {top:$margin_top,right:$margin_right,bottom:$margin_bottom,left:$margin_left};
5278    var innerW = $inner_w;
5279    var innerH = $inner_h;
5280    var svg = d3.select("#$svg_id");
5281    var g = svg.append("g").attr("transform","translate("+margin.left+","+margin.top+")");
5282    var tip = d3.select("#$tip_id");
5283
5284    function esc(s) {
5285        return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
5286    }
5287
5288    var xScale = d3.scaleBand()
5289        .domain(bins.map(function(d){return d.label;}))
5290        .range([0, innerW])
5291        .padding(0.05);
5292
5293    var yScale = d3.scaleLinear()
5294        .domain([0, d3.max(bins, function(d){return d.count;})]).nice()
5295        .range([innerH, 0]);
5296
5297    g.append("g").attr("class","hg-x-axis")
5298        .attr("transform","translate(0,"+innerH+")")
5299        .call(d3.axisBottom(xScale))
5300        .selectAll("text")
5301        .attr("transform","rotate(-45)")
5302        .style("text-anchor","end");
5303    g.append("g").attr("class","hg-y-axis").call(d3.axisLeft(yScale).ticks(6));
5304
5305    $x_label_block
5306    g.selectAll(".hg-bar").data(bins).join("rect")
5307        .attr("class","hg-bar")
5308        .attr("x",function(d){return xScale(d.label);})
5309        .attr("y",function(d){return yScale(d.count);})
5310        .attr("width",xScale.bandwidth())
5311        .attr("height",function(d){return innerH - yScale(d.count);})
5312        .attr("fill",$fill_js)
5313        .on("mouseover",function(event,d){
5314            tip.style("display","block")
5315               .html("<b>"+esc(d.label)+"<\\/b><br\/>Count: "+d.count);
5316        })
5317        .on("mousemove",function(event){
5318            tip.style("left",(event.offsetX+12)+"px").style("top",(event.offsetY-28)+"px");
5319        })
5320        .on("mouseout",function(){tip.style("display","none");});
5321})();
5322</script>
5323HTML
5324
5325
7
28
        return { svg_id => $svg_id, html => $html };
5326}
5327
5328sub render_histogram_snippet
5329{
5330
29
9062
        my ($self, $data, $opts) = @_;
5331
29
34
        $opts //= {};
5332
5333
29
41
        die 'Data must be an array of numeric values' unless ref($data) eq 'ARRAY';
5334
5335        # Pre-binned path: the caller has already computed the bins.
5336
27
24
        if ($opts->{binned}) {
5337
14
14
                return $self->_render_histogram_binned($data, $opts);
5338        }
5339
5340
13
13
        my $bins_opt = exists($opts->{bins}) ? $opts->{bins} : 20;
5341
13
7
        if (defined $bins_opt) {
5342
12
35
                die 'bins must be a positive integer'
5343                        unless looks_like_number($bins_opt) && $bins_opt == int($bins_opt) && $bins_opt >= 1;
5344        }
5345
5346
10
6
        my @values;
5347
10
6
        for my $v (@$data) {
5348
183
139
                die 'Each value must be numeric' unless looks_like_number($v // 'x');
5349
182
86
                push @values, $v + 0;
5350        }
5351
5352
9
22
        my $json_data = $_JSON->encode(\@values);
5353
5354        my $js_esc = sub {
5355
18
12
                my $s = shift;
5356
18
18
11
10
                $s =~ s/\\/\\\\/g; $s =~ s/"/\\"/g;
5357
18
18
8
7
                $s =~ s/\n/\\n/g;  $s =~ s/\r/\\r/g;
5358
18
12
                return $s;
5359
9
11
        };
5360
5361
9
11
        my $x_label = $opts->{x_label} // '';
5362
9
8
        my $color   = $opts->{color}   // 'steelblue';
5363
5364
9
6
        my $x_label_esc = $js_esc->($x_label);
5365
9
5
        my $color_esc   = $js_esc->($color);
5366
5367
9
8
        my $svg_id  = $opts->{id} // 'histogram';
5368
9
6
        my $tip_id  = $svg_id . '_tip';
5369
9
8
        my $width   = $self->{width};
5370
9
5
        my $height  = $self->{height};
5371        my $svg_attrs = ($opts->{responsive} // $self->{responsive})
5372
9
12
                ? qq{viewBox="0 0 $width $height" width="100%" height="auto"}
5373                : qq{width="$width" height="$height"};
5374
5375
9
5
        my $margin_top    = 20;
5376
9
4
        my $margin_right  = 20;
5377
9
6
        my $margin_bottom = $x_label ? 60 : 40;
5378
9
5
        my $margin_left   = 55;
5379
9
5
        my $inner_w = $width  - $margin_left - $margin_right;
5380
9
6
        my $inner_h = $height - $margin_top  - $margin_bottom;
5381
5382
9
7
        my $bins_js = defined($bins_opt)
5383                ? "var numBins = $bins_opt;"
5384                : 'var numBins = Math.ceil(Math.log2(values.length) + 1);';
5385
5386
9
6
        my $x_label_block = $x_label ? <<"XLB" : '';
5387    g.append("text")
5388        .attr("class","hg-axis-label")
5389        .attr("x", innerW / 2)
5390        .attr("y", innerH + (margin.bottom - 10))
5391        .attr("text-anchor","middle")
5392        .text("$x_label_esc");
5393XLB
5394
5395
9
22
        my $html = <<"HTML";
5396<style>
5397    #$tip_id {
5398        position:absolute;background:rgba(255,255,255,0.95);border:1px solid #ccc;
5399        border-radius:4px;padding:6px 10px;font-size:12px;pointer-events:none;
5400        display:none;line-height:1.6;
5401    }
5402    .hg-axis-label{font-size:11px;fill:#555;}
5403    .hg-bar{cursor:default;}
5404</style>
5405<svg id="$svg_id" $svg_attrs></svg>
5406<div id="$tip_id"></div>
5407<script>
5408(function(){
5409    var values = $json_data;
5410    var margin = {top:$margin_top,right:$margin_right,bottom:$margin_bottom,left:$margin_left};
5411    var innerW = $inner_w;
5412    var innerH = $inner_h;
5413    $bins_js
5414    var svg = d3.select("#$svg_id");
5415    var g = svg.append("g").attr("transform","translate("+margin.left+","+margin.top+")");
5416    var tip = d3.select("#$tip_id");
5417
5418    var xScale = d3.scaleLinear()
5419        .domain([d3.min(values), d3.max(values)]).nice()
5420        .range([0, innerW]);
5421
5422    var binner = d3.bin().domain(xScale.domain()).thresholds(xScale.ticks(numBins));
5423    var bins = binner(values);
5424
5425    var yScale = d3.scaleLinear()
5426        .domain([0, d3.max(bins, function(d){return d.length;})]).nice()
5427        .range([innerH, 0]);
5428
5429    g.append("g").attr("class","hg-x-axis")
5430        .attr("transform","translate(0,"+innerH+")").call(d3.axisBottom(xScale));
5431    g.append("g").attr("class","hg-y-axis").call(d3.axisLeft(yScale).ticks(6));
5432
5433    $x_label_block
5434    g.selectAll(".hg-bar").data(bins).join("rect")
5435        .attr("class","hg-bar")
5436        .attr("x",function(d){return xScale(d.x0)+1;})
5437        .attr("y",function(d){return yScale(d.length);})
5438        .attr("width",function(d){return Math.max(0, xScale(d.x1)-xScale(d.x0)-2);})
5439        .attr("height",function(d){return innerH - yScale(d.length);})
5440        .attr("fill","$color_esc")
5441        .on("mouseover",function(event,d){
5442            tip.style("display","block")
5443               .html("<b>"+d.x0.toFixed(2)+" – "+d.x1.toFixed(2)+"<\\/b><br\/>Count: "+d.length);
5444        })
5445        .on("mousemove",function(event){
5446            tip.style("left",(event.offsetX+12)+"px").style("top",(event.offsetY-28)+"px");
5447        })
5448        .on("mouseout",function(){tip.style("display","none");});
5449})();
5450</script>
5451HTML
5452
5453
9
24
        return { svg_id => $svg_id, html => $html };
5454}
5455
5456sub _preamble
5457{
5458
35
69
        my $html = <<'HTML';
5459<!DOCTYPE html>
5460<html lang="en">
5461HTML
5462
35
38
        return $html;
5463}
5464
5465sub _head
5466{
5467
25
53
        my $self = shift;
5468
5469
25
53
        my $html = <<"HTML";
5470<head>
5471        <meta charset="UTF-8">
5472        <meta name="viewport" content="width=device-width, initial-scale=1.0">
5473        <title>$self->{title}</title>
5474        <script src="https://d3js.org/d3.v7.min.js"></script>
5475</head>
5476HTML
5477
25
35
        return $html;
5478}
5479
5480sub _svg_attrs
5481{
5482
34
37
        my ($self, $override) = @_;
5483
34
52
        my $responsive = defined($override) ? $override : $self->{responsive};
5484
34
241
        return $responsive
5485                ? qq{viewBox="0 0 $self->{width} $self->{height}" width="100%" height="auto"}
5486                : qq{width="$self->{width}" height="$self->{height}" style="border: 1px solid black;"};
5487}
5488
5489# _render_snippet_shell($opts, $container_id, $script)
5490# Used exclusively by new snippet methods (scatter, table).
5491# Returns a minimal HTML fragment with a container div and the provided script block.
5492sub _render_snippet_shell
5493{
5494
0
        my ($self, $opts, $container_id, $script) = @_;
5495
0
        my $w = $self->{width};
5496
0
        my $h = $self->{height};
5497        my $svg_attrs = ($opts->{responsive} // $self->{responsive})
5498
0
                ? qq{viewBox="0 0 $w $h" width="100%" height="auto"}
5499                : qq{width="$w" height="$h"};
5500
0
        return <<"SHELL";
5501<div id="${container_id}_wrap" style="position:relative;">
5502    <svg id="$container_id" $svg_attrs></svg>
5503    <div id="${container_id}_tip" style="position:absolute;background:rgba(255,255,255,0.95);border:1px solid #ccc;border-radius:4px;padding:6px 10px;font-size:12px;pointer-events:none;display:none;line-height:1.6;"></div>
5504</div>
5505$script
5506SHELL
5507}
5508
5509 - 5923
=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>

=encoding UTF-8

=head1 FORMAL SPECIFICATION

=head2 render_bar_chart

    render_bar_chart : HTML::D3 × (ArrayRef | undef) → Str ∪ ⊥

    pre  data = undef              â‡’ die "Data is not optional"
    pre  ref(data) ≠ 'ARRAY'      â‡’ die "Data must be an array of arrays"
    post result ∈ Str
    post "<!DOCTYPE" ⊆ result
    post ∀ d ∈ data . d[0] ⊆ result

=head2 render_line_chart

    render_line_chart : HTML::D3 × (ArrayRef | undef) → Str ∪ ⊥

    pre  ref(data) ≠ 'ARRAY'  â‡’ die "Data must be an array of arrays"
    post result ∈ Str
    post "<!DOCTYPE" ⊆ result
    post "d3.scalePoint" ⊆ result ∧ "d3.line()" ⊆ result

=head2 render_animated_bar_chart

    render_animated_bar_chart : HTML::D3 × (ArrayRef | undef) → Str ∪ ⊥

    pre  data = undef              â‡’ die "Data is not optional"
    pre  ref(data) ≠ 'ARRAY'      â‡’ die "Data must be an array of arrays"
    post result ∈ Str
    post "<!DOCTYPE" ⊆ result
    post ".transition()" ⊆ result ∧ ".delay(" ⊆ result

=head2 render_animated_line_chart

    render_animated_line_chart : HTML::D3 × (ArrayRef | undef) → Str ∪ ⊥

    pre  ref(data) ≠ 'ARRAY'  â‡’ die "Data must be an array of arrays"
    post result ∈ Str
    post "<!DOCTYPE" ⊆ result
    post "stroke-dashoffset" ⊆ result ∧ "d3.easeLinear" ⊆ result

=head2 render_pie_chart

    render_pie_chart : HTML::D3 × (ArrayRef | undef) × (HashRef | undef) → Str ∪ ⊥

    pre  data = undef              â‡’ die "Data is not optional"
    pre  ref(data) ≠ 'ARRAY'      â‡’ die "Data must be an array of arrays"
    post result ∈ Str
    post "<!DOCTYPE" ⊆ result
    post "d3.pie()" ⊆ result ∧ "d3.arc()" ⊆ result ∧ "d3.schemeCategory10" ⊆ result
    post opts.separator = S        â‡’  " S " ⊆ result (SVG legend: label S value)

=head2 render_animated_pie_chart

    render_animated_pie_chart : HTML::D3 × (ArrayRef | undef) × (HashRef | undef) → Str ∪ ⊥

    pre  data = undef              â‡’ die "Data is not optional"
    pre  ref(data) ≠ 'ARRAY'      â‡’ die "Data must be an array of arrays"
    post result ∈ Str
    post "<!DOCTYPE" ⊆ result
    post "attrTween" ⊆ result ∧ "d3.interpolate" ⊆ result
    post opts.separator = S        â‡’  " S " ⊆ result (SVG legend: label S value)

=head2 render_line_chart_snippet

    render_line_chart_snippet :
        HTML::D3 × (ArrayRef | undef) × (HashRef | undef) → HashRef ∪ ⊥

    pre  ref(data) ≠ 'ARRAY'  â‡’ die "Data must be an array of arrays"
    post result ∈ HashRef
    post result.svg_id = opts.id // "chart"
    post result.html ∈ Str
    post "<!DOCTYPE" ∉ result.html

=head2 render_zoomable_line_chart_snippet

    render_zoomable_line_chart_snippet :
        HTML::D3 × (ArrayRef | undef) × (HashRef | undef) → HashRef ∪ ⊥

    pre  ref(data) ≠ 'ARRAY'  â‡’ die "Data must be an array of arrays"
    post result ∈ HashRef
    post result.svg_id = opts.id // "chart"
    post result.html ∈ Str
    post "<!DOCTYPE" ∉ result.html
    post "d3.brushX()" ⊆ result.html
    post opts.animated = 1  â‡’  "stroke-dashoffset" ⊆ result.html
                              âˆ§ "initialDrawDone" ⊆ result.html

=head2 render_pie_chart_snippet

    render_pie_chart_snippet :
        HTML::D3 × (ArrayRef | undef) × (HashRef | undef) → HashRef ∪ ⊥

    pre  ref(data) ≠ 'ARRAY'  â‡’ die "Data must be an array of arrays"
    pre  âˆ€ d ∈ data . d[1] < 0  â‡’  d[1] := |d[1]|      -- negative → absolute
    pre  âˆ€ d ∈ data . d[1] = 0  â‡’  d ∉ result           -- zero → omitted
    post result ∈ HashRef
    post result.svg_id = "pie_chart"
    post result.html ∈ Str
    post "<!DOCTYPE" ∉ result.html
    post "d3.pie()" ⊆ result.html ∧ "schemeTableau10" ⊆ result.html
    post opts.animated = 1  â‡’  "attrTween" ⊆ result.html
                              âˆ§ "initialDrawDone" ⊆ result.html
    post opts.donut = 1     â‡’  "innerRadius" ⊆ result.html
    post opts.max_slices = N ∧ N ≥ 2 ∧ |data| > N
                            â‡’  |result_slices| = N ∧ "Other" ∈ result_labels
    post opts.separator = S ⇒  " S " ⊆ result.html (HTML legend: label S value (pct%))

=head2 render_heatmap_snippet

    render_heatmap_snippet :
        HTML::D3 × (ArrayRef | undef) × (HashRef | undef) → HashRef ∪ ⊥

    pre  ref(data) ≠ 'ARRAY'       â‡’ die "Data must be an array of arrays"
    pre  âˆƒ pt ∈ data . ref(pt) ≠ 'ARRAY'
                                   â‡’ die "Each data point must be an array reference"
    pre  âˆƒ pt ∈ data . |pt| < 3   â‡’ die "Each data point must have at least 3 elements"
    pre  âˆƒ pt ∈ data . defined(pt[2]) ∧ ¬numeric(pt[2])
                                   â‡’ die "Value must be numeric"
    pre  opts.color_scheme = S ∧ S ∉ {YlOrRd,Blues,Greens,Purples,RdPu,YlGnBu}
                                   â‡’ die "Unknown color_scheme: S"
    pre  opts.cell_padding = N ∧ (N < 0 ∨ N > 8)
                                   â‡’ die "cell_padding must be between 0 and 8"
    pre  âˆ€ pt ∈ data . pt[2] = undef ⇒ pt ∉ result     -- undef rows skipped
    pre  âˆƒ pt₁,ptâ‚‚ ∈ data . pt₁[0]=ptâ‚‚[0] ∧ pt₁[1]=ptâ‚‚[1]
                                   â‡’ last-write wins
    post result ∈ HashRef
    post result.svg_id = "heatmap"
    post result.html ∈ Str
    post "<!DOCTYPE" ∉ result.html
    post "scaleSequential" ⊆ result.html
    post opts.animated = 1         â‡’ "prefers-reduced-motion" ⊆ result.html
    post opts.legend = 1           â‡’ "linearGradient" ⊆ result.html

=head2 render_bar_chart_snippet

    render_bar_chart_snippet :
        HTML::D3 × (ArrayRef | undef) × (HashRef | undef) → HashRef ∪ ⊥

    pre  ref(data) ≠ 'ARRAY'       â‡’ die "Data must be an array of arrays"
    pre  âˆƒ pt ∈ data . ref(pt) ≠ 'ARRAY'
                                   â‡’ die "Each data point must be an array reference"
    pre  âˆƒ pt ∈ data . |pt| < 2   â‡’ die "Each data point must have at least 2 elements"
    pre  âˆƒ pt ∈ data . defined(pt[1]) ∧ ¬numeric(pt[1])
                                   â‡’ die "Value must be numeric"
    pre  opts.orientation ∉ {'vertical','horizontal'}
                                   â‡’ die "orientation must be 'vertical' or 'horizontal'"
    pre  opts.sort_bars ∉ {'value','label','none'}
                                   â‡’ die "sort_bars must be 'value', 'label', or 'none'"
    pre  âˆ€ pt ∈ data . pt[1] < 0  â‡’  pt[1] := |pt[1]|      -- negative → absolute
    pre  âˆ€ pt ∈ data . pt[1] = undef ⇒ pt ∉ result          -- undef rows skipped
    post result ∈ HashRef
    post result.svg_id = opts.id // "bar_chart"
    post result.html ∈ Str
    post "<!DOCTYPE" ∉ result.html
    post "d3.scaleBand" ⊆ result.html ∧ "d3.scaleLinear" ⊆ result.html
    post opts.animated = 1         â‡’ "prefers-reduced-motion" ⊆ result.html
    post opts.color = 'categorical' ⇒ "schemeTableau10" ⊆ result.html
    post opts.max_bars = N ∧ N ≥ 2 ∧ |data| > N
                            â‡’ |result_bars| = N ∧ "Other" ∈ result_labels

=head2 render_multi_series_line_chart_snippet

    render_multi_series_line_chart_snippet :
        HTML::D3 × (ArrayRef | undef) × (HashRef | undef) → HashRef ∪ ⊥

    pre  ref(data) ≠ 'ARRAY'        â‡’ die "Data must be an array of series"
    pre  âˆƒ s ∈ data . ref(s) ≠ 'HASH'
                                    â‡’ die "Each series must be a hashref with name and data"
    pre  âˆƒ s ∈ data . ¬defined(s.name) ∨ ref(s.data) ≠ 'ARRAY'
                                    â‡’ die "Each series must be a hashref with name and data"
    post result ∈ HashRef
    post result.svg_id = opts.id // "chart"
    post result.html ∈ Str
    post "<!DOCTYPE" ∉ result.html
    post "d3.scalePoint" ⊆ result.html ∧ "d3.scaleLinear" ⊆ result.html
    post "d3.line()" ⊆ result.html
    post #{path ∈ result.html : class = "msl-line"} = |data|   -- one path per series
    post opts.x_label ≠ ''          â‡’ "msl-axis-label" ⊆ result.html
    post opts.y_label ≠ ''          â‡’ "msl-axis-label" ⊆ result.html
    post opts.scheme = S            â‡’ "scheme" + capitalise(S) ⊆ result.html
    post opts.animated = 1          â‡’ "stroke-dashoffset" ⊆ result.html
                                      âˆ§ "prefers-reduced-motion" ⊆ result.html

=head2 render_scatter_chart_snippet

    render_scatter_chart_snippet :
        HTML::D3 × (ArrayRef | undef) × (HashRef | undef) → HashRef ∪ ⊥

    pre  ref(data) ≠ 'ARRAY'       â‡’ die "Data must be an array of arrays"
    pre  âˆƒ pt ∈ data . ref(pt) ≠ 'ARRAY'
                                   â‡’ die "Each data point must be an array reference"
    pre  âˆƒ pt ∈ data . |pt| < 2   â‡’ die "Each data point must have at least 2 elements"
    pre  âˆƒ pt ∈ data . ¬numeric(pt[0])
                                   â‡’ die "X value must be numeric"
    pre  âˆƒ pt ∈ data . ¬numeric(pt[1])
                                   â‡’ die "Y value must be numeric"
    post result ∈ HashRef
    post result.svg_id = opts.id // "scatter_chart"
    post result.html ∈ Str
    post "<!DOCTYPE" ∉ result.html
    post "d3.scaleLinear" ⊆ result.html
    post opts.animated = 1         â‡’ "prefers-reduced-motion" ⊆ result.html
    post opts.colour_by_series = 1 ∧ ∃ pt ∈ data . defined(pt[3].series)
                                   â‡’ "scaleOrdinal" ⊆ result.html
                                     âˆ§ "seriesNames" ⊆ result.html
                                     âˆ§ legend drawn (margin right = 140)
    post ∃ pt ∈ data . defined(pt[3].size)
                                   â‡’ "d3.scaleSqrt" ⊆ result.html ∧ "rScale" ⊆ result.html
    post opts.radius_range = [A,B]
                                   â‡’ "range([A,B])" ⊆ result.html
    post ∀ pt ∈ data . pt[3].size = undef
                                   â‡’ "rScale" ∉ result.html      -- fixed radius

=head2 render_table_snippet

    render_table_snippet :
        HTML::D3 × (ArrayRef | undef) × (HashRef | undef) → HashRef ∪ ⊥

    pre  ref(data) ≠ 'ARRAY'       â‡’ die "Data must be an array of arrays"
    pre  |data| = 0                â‡’ die "Data must have at least one row (header row)"
    pre  âˆƒ row ∈ data . ref(row) ≠ 'ARRAY'
                                   â‡’ die "Each row must be an array reference"
    post result ∈ HashRef
    post result.table_id = opts.id // "data_table"
    post result.html ∈ Str
    post "<!DOCTYPE" ∉ result.html
    post opts.sortable ≠ 0         â‡’ "dt-sortable" ⊆ result.html

=head2 render_stacked_bar_snippet

    render_stacked_bar_snippet :
        HTML::D3 × (ArrayRef | undef) × (HashRef | undef) → HashRef ∪ ⊥

    pre  ref(data) ≠ 'ARRAY'        â‡’ die "Data must be an array of hashes"
    pre  âˆƒ row ∈ data . ref(row) ≠ 'HASH'
                                    â‡’ die "Each data point must be a hash reference"
    pre  âˆƒ row ∈ data . ¬numeric(row[val_col])
                                    â‡’ die "Value must be numeric"
    pre  opts.orient ∉ {'v','h'}    â‡’ die "orient must be 'v' or 'h'"
    pre  defined(opts.sort) ∧ opts.sort ∉ {'value','label'}
                                    â‡’ die "sort must be 'value' or 'label'"
    post result ∈ HashRef
    post result.svg_id = opts.id // "stacked_bar"
    post result.html ∈ Str
    post "<!DOCTYPE" ∉ result.html
    post "d3.stack()" ⊆ result.html
    post "SCHEMES" ⊆ result.html

=head2 render_combo_snippet

    render_combo_snippet :
        HTML::D3 × (ArrayRef | undef) × (HashRef | undef) → HashRef ∪ ⊥

    pre  ref(data) ≠ 'ARRAY'        â‡’ die "Data must be an array of hashes"
    pre  âˆƒ row ∈ data . ref(row) ≠ 'HASH'
                                    â‡’ die "Each data point must be a hash reference"
    pre  âˆƒ row ∈ data . ¬numeric(row[bar_col])
                                    â‡’ die "Bar value must be numeric"
    pre  âˆƒ row ∈ data . ¬numeric(row[line_col])
                                    â‡’ die "Line value must be numeric"
    post result ∈ HashRef
    post result.svg_id = opts.id // "combo_chart"
    post result.html ∈ Str
    post "<!DOCTYPE" ∉ result.html
    post "d3.scaleBand" ⊆ result.html ∧ "d3.scaleLinear" ⊆ result.html
    post "axisRight" ⊆ result.html
    post "</b>" ∉ result.html ∧ "<\/b>" ∈ result.html

=head2 render_treemap_snippet

    render_treemap_snippet :
        HTML::D3 × (ArrayRef | HashRef | undef) × (HashRef | undef) → HashRef ∪ ⊥

    pre  ref(data) ∉ {'ARRAY','HASH'}
                                    â‡’ die "Data must be an array of hashes or a hash tree"
    pre  ref(data) = 'ARRAY' ∧ ∃ row ∈ data . ref(row) ≠ 'HASH'
                                    â‡’ die "Each flat row must be a hash reference"
    pre  ref(data) = 'ARRAY' ∧ ∃ row ∈ data . ¬exists(row.path)
                                    â‡’ die "Each flat row must have a 'path' key"
    pre  ref(data) = 'ARRAY' ∧ ∃ row ∈ data . ¬numeric(row.value)
                                    â‡’ die "Each flat row must have a numeric 'value' key"
    post result ∈ HashRef
    post result.svg_id = opts.id // "treemap"
    post result.html ∈ Str
    post "<!DOCTYPE" ∉ result.html
    post "d3.treemap()" ⊆ result.html
    post ref(data) = 'ARRAY' ⇒ "d3.stratify()" ⊆ result.html
    post ref(data) = 'HASH'  â‡’ "d3.hierarchy(" ⊆ result.html

=head2 render_histogram_snippet

    render_histogram_snippet :
        HTML::D3 × (ArrayRef | undef) × (HashRef | undef) → HashRef ∪ ⊥

    pre  ref(data) ≠ 'ARRAY'        â‡’ die "Data must be an array of numeric values"
    pre  âˆƒ v ∈ data . ¬numeric(v)  â‡’ die "Each value must be numeric"
    pre  defined(opts.bins) ∧ (¬integer(opts.bins) ∨ opts.bins < 1)
                                    â‡’ die "bins must be a positive integer"
    pre  opts.binned = 1 ∧ |data| = 0
                                    â‡’ die "Data must contain at least one bin"
    pre  opts.binned = 1 ∧ ∃ b ∈ data . ref(b) ≠ 'HASH'
                                    â‡’ die "Each pre-binned entry must be a hash reference"
    pre  opts.binned = 1 ∧ ∃ b ∈ data . ¬numeric(b.count)
                                    â‡’ die "Bin count must be numeric"
    pre  opts.binned = 1 ∧ ∃ b ∈ data . b.count < 0
                                    â‡’ die "Bin count must not be negative"
    pre  opts.binned = 1 ∧ ∃ b ∈ data . ¬defined(b.label) ∧ ¬(numeric(b.lo) ∧ numeric(b.hi))
                                    â‡’ die "Each pre-binned entry must have a label or numeric lo and hi"
    pre  opts.binned = 1 ∧ ¬defined(b.label) ∧ numeric(b.lo) ∧ numeric(b.hi)
                                    â‡’ b.label := "[lo,hi)"
    post result ∈ HashRef
    post result.svg_id = opts.id // "histogram"
    post result.html ∈ Str
    post "<!DOCTYPE" ∉ result.html
    post opts.binned ≠ 1            â‡’ "d3.bin()" ⊆ result.html
    post opts.binned = 1            â‡’ "d3.bin()" ∉ result.html ∧ "scaleBand" ⊆ result.html
    post opts.color defined         â‡’ c(clone)      -- explicit colour is the bar fill
    post opts.scheme defined        â‡’ "scheme" ⊆ result.html
    post opts.bins = undef          â‡’ "Math.log2" ⊆ result.html
    post opts.x_label ≠ ''         â‡’ "hg-axis-label" ⊆ result.html

=head2 render_line_chart_with_tooltips

    render_line_chart_with_tooltips : HTML::D3 × (ArrayRef | undef) → Str ∪ ⊥

    pre  ref(data) ≠ 'ARRAY'  â‡’ die "Data must be an array of arrays"
    post result ∈ Str
    post "<!DOCTYPE" ⊆ result
    post "mouseover" ⊆ result
    post "</b>" ∉ result ∧ "<\/b>" ∈ result

=head2 render_multi_series_line_chart_with_tooltips

    render_multi_series_line_chart_with_tooltips : HTML::D3 × (ArrayRef | undef) → Str ∪ ⊥

    pre  ref(data) ≠ 'ARRAY'  â‡’ die "Data must be an array of hashes"
    post result ∈ Str
    post "<!DOCTYPE" ⊆ result
    post "</b>" ∉ result ∧ "<\/b>" ∈ result

=head2 render_multi_series_line_chart_with_animated_tooltips

    render_multi_series_line_chart_with_animated_tooltips : HTML::D3 × (ArrayRef | undef) → Str ∪ ⊥

    pre  ref(data) ≠ 'ARRAY'   â‡’ die "Data must be an array of hashes"
    post result ∈ Str
    post "<!DOCTYPE" ⊆ result
    post "translateY" ⊆ result
    post "</b>" ∉ result ∧ "<\/b>" ∈ result

=head2 render_multi_series_line_chart_with_legends

    render_multi_series_line_chart_with_legends : HTML::D3 × (ArrayRef | undef) → Str ∪ ⊥

    pre  ref(data) ≠ 'ARRAY'  â‡’ die "Data must be an array of hashes"
    post result ∈ Str
    post "<!DOCTYPE" ⊆ result
    post ".legend" ⊆ result

=head2 render_multi_series_line_chart_with_interactive_legends

    render_multi_series_line_chart_with_interactive_legends : HTML::D3 × (ArrayRef | undef) → Str ∪ ⊥

    pre  ref(data) ≠ 'ARRAY'            â‡’ die "Data must be an array of hashes"
    post result ∈ Str
    post "<!DOCTYPE" ⊆ result
    post "isVisible" ⊆ result
    post "isVisible ? 0 : 1" ⊆ result
    post ".legend" ⊆ result

=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
5924
59251;