| File: | blib/lib/HTML/D3.pm |
| Coverage: | 94.7% |
| line | stmt | bran | cond | sub | time | code |
|---|---|---|---|---|---|---|
| 1 | package 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 | ||||||
| 10 | my $_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 | ||||||
| 26 | our $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 | ||||||
| 97 | sub 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 | |||||
| 191 | sub 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> | |||||
| 258 | HTML | |||||
| 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 | ||||||
| 309 | sub 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> | |||||
| 374 | HTML | |||||
| 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 | ||||||
| 419 | sub 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> | |||||
| 491 | HTML | |||||
| 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 | ||||||
| 541 | sub 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> | |||||
| 624 | HTML | |||||
| 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 | ||||||
| 692 | sub 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> | |||||
| 781 | HTML | |||||
| 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 | ||||||
| 848 | sub 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> | |||||
| 949 | HTML | |||||
| 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 | ||||||
| 1035 | sub 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); | |||||
| 1104 | LANIM | |||||
| 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 | } | |||||
| 1124 | ANIM_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)); | |||||
| 1137 | DONUT | |||||
| 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 | }); | |||||
| 1153 | LEGEND_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, '&').replace(/</g, '<').replace(/>/g, '>'); | |||||
| 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> | |||||
| 1272 | HTML | |||||
| 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 | ||||||
| 1381 | sub 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 | } | |||||
| 1460 | ANIM | |||||
| 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 | } | |||||
| 1489 | LEGBLOCK | |||||
| 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, '&').replace(/</g, '<').replace(/>/g, '>'); | |||||
| 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> | |||||
| 1621 | HTML | |||||
| 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 | ||||||
| 1742 | sub 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"); | |||||
| 1861 | ROT | |||||
| 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(); }); | |||||
| 1876 | SVB | |||||
| 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(); }); | |||||
| 1887 | SVB | |||||
| 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"); | |||||
| 1904 | XLB | |||||
| 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 | } | |||||
| 1921 | ANIM | |||||
| 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 | } | |||||
| 1932 | ANIM | |||||
| 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 | |||||
| 1985 | JS | |||||
| 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 | |||||
| 2033 | JS | |||||
| 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, '&').replace(/</g, '<').replace(/>/g, '>'); | |||||
| 2067 | } | |||||
| 2068 | ||||||
| 2069 | var svg = d3.select("#$svg_id"); | |||||
| 2070 | var tip = d3.select("#$tip_id"); | |||||
| 2071 | ||||||
| 2072 | $chart_js})(); | |||||
| 2073 | </script> | |||||
| 2074 | HTML | |||||
| 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 | ||||||
| 2124 | sub 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> | |||||
| 2229 | HTML | |||||
| 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 | ||||||
| 2319 | sub 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> | |||||
| 2426 | HTML | |||||
| 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 | ||||||
| 2488 | sub 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 | }) | |||||
| 2534 | HANDLERS | |||||
| 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 | } | |||||
| 2589 | ANIM | |||||
| 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)); | |||||
| 2607 | PLAIN | |||||
| 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> | |||||
| 2713 | HTML | |||||
| 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 | ||||||
| 2768 | sub 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> | |||||
| 2880 | HTML | |||||
| 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 | ||||||
| 2930 | sub 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> | |||||
| 3046 | HTML | |||||
| 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 | ||||||
| 3097 | sub 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> | |||||
| 3249 | HTML | |||||
| 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 | ||||||
| 3301 | sub 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> | |||||
| 3457 | HTML | |||||
| 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 | ||||||
| 3555 | sub 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"); | |||||
| 3607 | XLB | |||||
| 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"); | |||||
| 3617 | YLB | |||||
| 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 | } | |||||
| 3632 | ANIM | |||||
| 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, '&').replace(/</g, '<').replace(/>/g, '>'); | |||||
| 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> | |||||
| 3732 | HTML | |||||
| 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 | ||||||
| 3851 | sub 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 | } | |||||
| 3982 | ANIM | |||||
| 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"); | |||||
| 3991 | XLB | |||||
| 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"); | |||||
| 4001 | YLB | |||||
| 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 | }); | |||||
| 4014 | LEG | |||||
| 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, '&').replace(/</g, '<').replace(/>/g, '>'); | |||||
| 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> | |||||
| 4116 | HTML | |||||
| 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 | ||||||
| 4194 | sub 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/&/&/g; | |||
| 4219 | 150 | 84 | $s =~ s/</</g; | |||
| 4220 | 150 | 78 | $s =~ s/>/>/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 | }); | |||||
| 4282 | SORT | |||||
| 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> | |||||
| 4312 | HTML | |||||
| 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 | ||||||
| 4401 | sub 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"); | |||||
| 4487 | XLB | |||||
| 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]); | |||||
| 4498 | SCALES | |||||
| 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)); | |||||
| 4503 | AXES | |||||
| 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()) | |||||
| 4509 | RECTS | |||||
| 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]); | |||||
| 4518 | SCALES | |||||
| 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)); | |||||
| 4523 | AXES | |||||
| 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()) | |||||
| 4529 | RECTS | |||||
| 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> | |||||
| 4604 | HTML | |||||
| 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 | ||||||
| 4690 | sub 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> | |||||
| 4854 | HTML | |||||
| 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 | ||||||
| 4940 | sub 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;}); | |||||
| 4992 | BUILDJS | |||||
| 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;}); | |||||
| 4999 | BUILDJS | |||||
| 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 | }); | |||||
| 5030 | LBLJS | |||||
| 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> | |||||
| 5082 | HTML | |||||
| 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}, ... ] | |||||
| 5189 | sub _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"); | |||||
| 5260 | XLB | |||||
| 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, '&').replace(/</g, '<').replace(/>/g, '>'); | |||||
| 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> | |||||
| 5323 | HTML | |||||
| 5324 | ||||||
| 5325 | 7 | 28 | return { svg_id => $svg_id, html => $html }; | |||
| 5326 | } | |||||
| 5327 | ||||||
| 5328 | sub 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"); | |||||
| 5393 | XLB | |||||
| 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> | |||||
| 5451 | HTML | |||||
| 5452 | ||||||
| 5453 | 9 | 24 | return { svg_id => $svg_id, html => $html }; | |||
| 5454 | } | |||||
| 5455 | ||||||
| 5456 | sub _preamble | |||||
| 5457 | { | |||||
| 5458 | 35 | 69 | my $html = <<'HTML'; | |||
| 5459 | <!DOCTYPE html> | |||||
| 5460 | <html lang="en"> | |||||
| 5461 | HTML | |||||
| 5462 | 35 | 38 | return $html; | |||
| 5463 | } | |||||
| 5464 | ||||||
| 5465 | sub _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> | |||||
| 5476 | HTML | |||||
| 5477 | 25 | 35 | return $html; | |||
| 5478 | } | |||||
| 5479 | ||||||
| 5480 | sub _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. | |||||
| 5492 | sub _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 | |||||
| 5506 | SHELL | |||||
| 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 | ||||||
| 5925 | 1; | |||||