TER1 (Statement): 98.16%
TER2 (Branch): 96.46%
TER3 (LCSAJ): 100.0% (32/32)
Approximate LCSAJ segments: 397
● Covered — this LCSAJ path was executed during testing.
● Not covered — this LCSAJ path was never executed. These are the paths to focus on.
Multiple dots on a line indicate that multiple control-flow paths begin at that line. Hovering over any dot shows:
start → end → jump
Uncovered paths show [NOT COVERED] in the tooltip.
1: package HTML::D3; 2: 3: use 5.010; 4: use strict; 5: use warnings; 6: 7: use Carp qw(carp); 8: use JSON::MaybeXS; 9: 10: my $_JSON = JSON::MaybeXS->new(utf8 => 0); 11: use Object::Configure; 12: use Params::Get; 13: use Params::Validate::Strict; 14: use Scalar::Util qw(blessed looks_like_number); 15: 16: =head1 NAME 17: 18: HTML::D3 - A simple Perl module for generating charts using D3.js. 19: 20: =head1 VERSION 21: 22: Version 0.20 23: 24: =cut 25: 26: our $VERSION = '0.20'; 27: 28: =head1 SYNOPSIS 29: 30: use HTML::D3; 31: 32: my $chart = HTML::D3->new( 33: width => 1024, 34: height => 768, 35: title => 'Sample Bar Chart' 36: ); 37: 38: my $data = [ 39: ['Category 1', 10], 40: ['Category 2', 20], 41: ['Category 3', 30] 42: ]; 43: 44: my $html = $chart->render_bar_chart($data); 45: print $html; 46: 47: $chart = HTML::D3->new(title => 'Sales Data'); 48: 49: $data = [ 50: ['Product A', 100], 51: ['Product B', 150], 52: ['Product C', 200] 53: ]; 54: 55: $html = $chart->render_line_chart($data); 56: print $html; 57: 58: =head1 DESCRIPTION 59: 60: HTML::D3 is a Perl module that provides functionality to create simple charts using D3.js. 61: The module generates HTML and JavaScript code to render the chart in a web browser. 62: 63: =head1 METHODS 64: 65: The C<=head3 API SPECIFICATION> subsections use L<Params::Validate::Strict> 66: schema syntax (C<< type => 'arrayref' >> etc.) as a documentation convention. 67: The module is also used at runtime in C<new()> to validate constructor 68: arguments; it is therefore a required runtime dependency. The schemas describe 69: the parameter contract in machine-readable notation and can be plumbed into a 70: WAF or test generator if desired. 71: 72: =head2 new 73: 74: my $chart = HTML::D3->new(%args); 75: 76: Creates a new HTML::D3 object. 77: Accepts the following optional arguments: 78: 79: =over 4 80: 81: =item * C<width> - The width of the chart (default: 800). 82: 83: =item * C<height> - The height of the chart (default: 600). 84: 85: =item * C<title> - The title of the chart (default: 'Chart'). 86: 87: =item * C<responsive> - If true, SVG elements are rendered with 88: C<viewBox> and C<width="100%" height="auto"> instead of fixed pixel 89: dimensions, so the chart scales fluidly with its container. 90: For snippet methods the per-call C<< opts => { responsive => 1 } >> 91: takes precedence over this object-level setting (default: 0). 92: 93: =back 94: 95: =cut 96: 97: sub new 98: { ●99 → 122 → 134 99: my $class = shift; 100: 101: 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: if(!defined($class)) {Mutants (Total: 1, Killed: 1, Survived: 0)
123: if((scalar keys %{$params}) > 0) {
Mutants (Total: 4, Killed: 4, Survived: 0)
124: carp(__PACKAGE__, ' use ->new() not ::new() to instantiate'); 125: 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: $class = __PACKAGE__; 130: } elsif(blessed($class)) { 131: return bless { %{$class}, %{$params} }, ref($class);
Mutants (Total: 2, Killed: 2, Survived: 0)
132: } 133: 134: $params = Object::Configure::configure($class, $params); 135: 136: return bless {
Mutants (Total: 2, Killed: 2, Survived: 0)
137: width => $params->{width} || 800, 138: height => $params->{height} || 600, 139: title => $params->{title} || 'Chart', 140: responsive => $params->{responsive} || 0, 141: }, $class; 142: } 143: 144: =head2 render_bar_chart 145: 146: my $html = $chart->render_bar_chart($data); 147: 148: Generates HTML and JavaScript code to render a bar chart. Accepts the following arguments: 149: 150: =over 4 151: 152: =item * C<$data> - An array reference containing data points. Each data point should 153: be an array reference with two elements: the label (string) and the value (numeric). 154: 155: =back 156: 157: Returns a string containing the HTML and JavaScript code for the chart. 158: 159: =head3 Errors 160: 161: =over 4 162: 163: =item * Throws C<Data is not optional> when C<$data> is C<undef>. 164: 165: =item * Throws C<Data must be an array of arrays> when C<$data> is not an ARRAY reference. 166: 167: =back 168: 169: =head3 API SPECIFICATION 170: 171: =head4 Input 172: 173: { 174: data => { 175: type => 'arrayref', 176: element_type => [ 'string', 'number' ] 177: } 178: } 179: 180: Each element of C<$data> is C<[ Str, Num ]>; passing C<undef> or a 181: non-arrayref dies. 182: 183: =head4 Output 184: 185: Str -- complete HTML5 document starting with C<< <!DOCTYPE html> >>; 186: D3.js loaded from CDN; bar chart rendered with C<d3.scaleBand>. 187: 188: =cut 189: 190: # Method to render a bar chart with given data 191: sub render_bar_chart { 192: my ($self, $data) = @_; 193: 194: die 'Data is not optional' if(!defined($data)); 195: 196: # Validate input data to ensure it is an array of arrays 197: 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: map { { label => $_->[0], value => $_->[1] } } @$data 202: ]); 203: 204: # Generate HTML and D3.js JavaScript for rendering the bar chart 205: my $html = $self->_preamble(); 206: $html .= $self->_head(); 207: $html .= <<"HTML"; 208: <body> 209: <h1 style="text-align: center;">$self->{title}</h1> 210: <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: return $html;
Mutants (Total: 2, Killed: 2, Survived: 0)
261: } 262: 263: =head2 render_animated_bar_chart 264: 265: my $html = $chart->render_animated_bar_chart($data); 266: 267: Generates HTML and JavaScript code to render a bar chart where each bar grows 268: upward from the baseline on page load. Bars are staggered so they rise 269: one-after-another from left to right. 270: Accepts the following arguments: 271: 272: =over 4 273: 274: =item * C<$data> - An array reference of data points. Each data point is an 275: array reference with two elements: the label (string) and the value (numeric). 276: 277: =back 278: 279: Returns a string containing the complete HTML5 document. 280: 281: =head3 Errors 282: 283: =over 4 284: 285: =item * Throws C<Data is not optional> when C<$data> is C<undef>. 286: 287: =item * Throws C<Data must be an array of arrays> when C<$data> is not an ARRAY reference. 288: 289: =back 290: 291: =head3 API SPECIFICATION 292: 293: =head4 Input 294: 295: { 296: data => { type => 'arrayref' }, 297: } 298: 299: Each element of C<$data> is C<[ Str, Num ]>; passing C<undef> or a 300: non-arrayref dies. 301: 302: =head4 Output 303: 304: Str -- complete HTML5 document; each bar animates from height=0 upward 305: using C<d3.transition()> with a staggered per-bar delay. 306: 307: =cut 308: 309: sub render_animated_bar_chart { 310: my ($self, $data) = @_; 311: 312: die 'Data is not optional' if(!defined($data)); 313: die 'Data must be an array of arrays' unless ref($data) eq 'ARRAY'; 314: 315: my $json_data = $_JSON->encode([ 316: map { { label => $_->[0], value => $_->[1] } } @$data 317: ]); 318: 319: my $html = $self->_preamble(); 320: $html .= $self->_head(); 321: $html .= <<"HTML"; 322: <body> 323: <h1 style="text-align: center;">$self->{title}</h1> 324: <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: return $html;
Mutants (Total: 2, Killed: 2, Survived: 0)
377: } 378: 379: =head2 render_line_chart 380: 381: my $html = $chart->render_line_chart($data); 382: 383: Generates HTML and JavaScript code to render a line chart. Accepts the following arguments: 384: 385: =over 4 386: 387: =item * C<$data> - An array reference containing data points. Each data point should 388: be an array reference with two elements: the label (string) and the value (numeric). 389: 390: =back 391: 392: Returns a string containing the HTML and JavaScript code for the chart. 393: 394: =head3 Errors 395: 396: =over 4 397: 398: =item * Throws C<Data must be an array of arrays> when C<$data> is not an ARRAY reference. 399: 400: =back 401: 402: =head3 API SPECIFICATION 403: 404: =head4 Input 405: 406: { 407: data => { type => 'arrayref' }, 408: } 409: 410: Each element of C<$data> is C<[ Str, Num ]>; passing C<undef> or a 411: non-arrayref dies. 412: 413: =head4 Output 414: 415: Str -- complete HTML5 document; line chart with C<d3.scalePoint> and C<d3.line()>. 416: 417: =cut 418: 419: sub render_line_chart { 420: my ($self, $data) = @_; 421: 422: # Validate input data 423: 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: map { { label => $_->[0], value => $_->[1] } } @$data 428: ]); 429: 430: # Generate HTML and D3.js code 431: my $html = $self->_preamble(); 432: $html .= $self->_head(); 433: $html .= <<"HTML"; 434: <body> 435: <h1 style="text-align: center;">$self->{title}</h1> 436: <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: return $html;
Mutants (Total: 2, Killed: 2, Survived: 0)
494: } 495: 496: =head2 render_animated_line_chart 497: 498: my $html = $chart->render_animated_line_chart($data); 499: 500: Generates HTML and JavaScript code to render a line chart where the line 501: draws itself from left to right on page load, followed by each data-point 502: circle fading in once the line is complete. 503: Accepts the following arguments: 504: 505: =over 4 506: 507: =item * C<$data> - An array reference of data points. Each data point is an 508: array reference with two elements: the label (string) and the value (numeric). 509: 510: =back 511: 512: Returns a string containing the complete HTML5 document. 513: 514: =head3 Errors 515: 516: =over 4 517: 518: =item * Throws C<Data must be an array of arrays> when C<$data> is not an ARRAY reference. 519: 520: =back 521: 522: =head3 API SPECIFICATION 523: 524: =head4 Input 525: 526: { 527: data => { type => 'arrayref' }, 528: } 529: 530: Each element of C<$data> is C<[ Str, Num ]>; passing C<undef> or a 531: non-arrayref dies. 532: 533: =head4 Output 534: 535: Str -- complete HTML5 document; the line path animates via 536: C<stroke-dashoffset> with C<d3.easeLinear>; data-point circles 537: fade in with C<opacity> after the line transition completes. 538: 539: =cut 540: 541: sub render_animated_line_chart { 542: my ($self, $data) = @_; 543: 544: die 'Data must be an array of arrays' unless ref($data) eq 'ARRAY'; 545: 546: my $json_data = $_JSON->encode([ 547: map { { label => $_->[0], value => $_->[1] } } @$data 548: ]); 549: 550: my $html = $self->_preamble(); 551: $html .= $self->_head(); 552: $html .= <<"HTML"; 553: <body> 554: <h1 style="text-align: center;">$self->{title}</h1> 555: <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: return $html;
Mutants (Total: 2, Killed: 2, Survived: 0)
627: } 628: 629: =head2 render_pie_chart 630: 631: my $html = $chart->render_pie_chart($data); 632: my $html = $chart->render_pie_chart($data, { separator => ':' }); 633: 634: Generates HTML and JavaScript code to render a pie chart. 635: Each slice is coloured with C<d3.schemeCategory10>; percentage labels appear 636: inside each slice and a colour legend is shown to the right of the pie. 637: Accepts the following arguments: 638: 639: =over 4 640: 641: =item * C<$data> - An array reference of data points. Each data point is an 642: array reference with two elements: the label (string) and the value (numeric). 643: 644: =item * C<%opts> - Optional hashref of options. 645: 646: =over 8 647: 648: =item * C<separator> (string, default C<'/'>) - Character shown between the 649: label and value in the SVG legend. 650: 651: =back 652: 653: =back 654: 655: Returns a string containing the complete HTML5 document. 656: 657: =head3 Errors 658: 659: =over 4 660: 661: =item * Throws C<Data is not optional> when C<$data> is C<undef>. 662: 663: =item * Throws C<Data must be an array of arrays> when C<$data> is not an ARRAY reference. 664: 665: =back 666: 667: =head3 API SPECIFICATION 668: 669: =head4 Input 670: 671: { 672: data => { 673: type => 'arrayref', 674: element_type => [ 'string', 'number' ] 675: }, 676: opts => { type => 'hashref', optional => 1, default => {} }, 677: } 678: 679: Each element of C<$data> is C<[ Str, Num ]>; passing C<undef> or a 680: non-arrayref dies. 681: Recognised C<opts> key: C<separator> (string, default C<'/'>) 682: - character shown between label and value in the SVG legend. 683: 684: =head4 Output 685: 686: Str -- complete HTML5 document; pie rendered with C<d3.pie()> and 687: C<d3.arc()>; slices coloured with C<d3.schemeCategory10>; 688: percentage label inside each slice; legend to the right. 689: 690: =cut 691: 692: sub render_pie_chart { 693: my ($self, $data, $opts) = @_; 694: $opts //= {}; 695: 696: die 'Data is not optional' if(!defined($data)); 697: die 'Data must be an array of arrays' unless ref($data) eq 'ARRAY'; 698: 699: my $separator = $opts->{separator} // '/'; 700: 701: my $json_data = $_JSON->encode([ 702: map { { label => $_->[0], value => $_->[1] } } @$data 703: ]); 704: 705: my $html = $self->_preamble(); 706: $html .= $self->_head(); 707: $html .= <<"HTML"; 708: <body> 709: <h1 style="text-align: center;">$self->{title}</h1> 710: <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: return $html;
Mutants (Total: 2, Killed: 2, Survived: 0)
784: } 785: 786: =head2 render_animated_pie_chart 787: 788: my $html = $chart->render_animated_pie_chart($data); 789: my $html = $chart->render_animated_pie_chart($data, { separator => ':' }); 790: 791: Generates HTML and JavaScript code to render an animated pie chart where each 792: slice fans out from zero angle on page load using C<attrTween> and 793: C<d3.interpolate>. Percentage labels fade in once all slices are drawn. 794: Accepts the following arguments: 795: 796: =over 4 797: 798: =item * C<$data> - An array reference of data points. Each data point is an 799: array reference with two elements: the label (string) and the value (numeric). 800: 801: =item * C<%opts> - Optional hashref of options. 802: 803: =over 8 804: 805: =item * C<separator> (string, default C<'/'>) - Character shown between the 806: label and value in the SVG legend. 807: 808: =back 809: 810: =back 811: 812: Returns a string containing the complete HTML5 document. 813: 814: =head3 Errors 815: 816: =over 4 817: 818: =item * Throws C<Data is not optional> when C<$data> is C<undef>. 819: 820: =item * Throws C<Data must be an array of arrays> when C<$data> is not an ARRAY reference. 821: 822: =back 823: 824: =head3 API SPECIFICATION 825: 826: =head4 Input 827: 828: { 829: data => { 830: type => 'arrayref', 831: element_type => [ 'string', 'number' ] 832: }, 833: opts => { type => 'hashref', optional => 1, default => {} }, 834: } 835: 836: Each element of C<$data> is C<[ Str, Num ]>; passing C<undef> or a 837: non-arrayref dies. 838: Recognised C<opts> key: C<separator> (string, default C<'/'>) 839: - character shown between label and value in the SVG legend. 840: 841: =head4 Output 842: 843: Str -- complete HTML5 document; slices animate via C<attrTween> with 844: C<d3.interpolate> (1000 ms); percentage labels fade in afterwards. 845: 846: =cut 847: 848: sub render_animated_pie_chart { 849: my ($self, $data, $opts) = @_; 850: $opts //= {}; 851: 852: die 'Data is not optional' if(!defined($data)); 853: die 'Data must be an array of arrays' unless ref($data) eq 'ARRAY'; 854: 855: my $separator = $opts->{separator} // '/'; 856: 857: my $json_data = $_JSON->encode([ 858: map { { label => $_->[0], value => $_->[1] } } @$data 859: ]); 860: 861: my $html = $self->_preamble(); 862: $html .= $self->_head(); 863: $html .= <<"HTML"; 864: <body> 865: <h1 style="text-align: center;">$self->{title}</h1> 866: <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: return $html;
Mutants (Total: 2, Killed: 2, Survived: 0)
952: } 953: 954: =head2 render_pie_chart_snippet 955: 956: my $fragment = $chart->render_pie_chart_snippet(\@slices); 957: my $fragment = $chart->render_pie_chart_snippet(\@slices, \%opts); 958: # $fragment->{svg_id} - always 'pie_chart' 959: # $fragment->{html} - embeddable fragment; caller must load D3 v7 960: 961: Generates an embeddable pie or donut chart fragment for use in existing HTML 962: layouts. Returns C<{ svg_id =E<gt> 'pie_chart', html =E<gt> Str }>. The 963: caller is responsible for loading D3 v7 before embedding the fragment. 964: 965: =head3 Data format 966: 967: Each element of C<\@slices> is C<[$label, $value]> or C<[$label, $value, \%extra]>. 968: Negative values are silently converted to their absolute value. Zero-value 969: slices are silently omitted. C<\%extra> key/value pairs are shown as 970: additional rows in the hover tooltip. 971: 972: =head3 Options (C<\%opts>) 973: 974: =over 4 975: 976: =item * C<animated> (bool, default 0) - fan slices in from arc-length 0 on 977: first render using C<attrTween> / C<d3.easeBackOut> (800 ms, staggered). 978: Respects C<prefers-reduced-motion>. 979: 980: =item * C<donut> (bool, default 0) - render as a donut chart (inner radius 981: 38% of outer radius); the total sum appears in the centre hole. 982: 983: =item * C<sort_slices> (string, default C<'none'>) - C<'value'> for 984: largest-first, C<'label'> for alphabetical, C<'none'> for input order. 985: 986: =item * C<max_slices> (int, default 0) - when E<gt> 0, only the top N-1 987: slices are shown individually; the rest are collapsed into an C<"Other"> slice. 988: 989: =item * C<legend> (bool, default 1) - render an HTML legend panel beside the chart. 990: 991: =item * C<color_scheme> (string, default C<'tableau10'>) - D3 categorical 992: colour scheme. Supported: C<tableau10>, C<category10>, C<set2>, C<set3>, 993: C<paired>. 994: 995: =item * C<separator> (string, default C<'/'>) - Character shown between the 996: label and value in each legend entry (e.g. C<'/'> produces 997: C<Label / 12.34 (42.0%)>, C<':'> produces C<Label : 12.34 (42.0%)>). 998: 999: =back 1000: 1001: =head3 Errors 1002: 1003: =over 4 1004: 1005: =item * Throws C<Data must be an array of arrays> when C<\@slices> is not an ARRAY reference. 1006: 1007: =back 1008: 1009: =head3 API SPECIFICATION 1010: 1011: =head4 Input 1012: 1013: { 1014: data => { type => 'arrayref' }, 1015: opts => { type => 'hashref', optional => 1, default => {} }, 1016: } 1017: 1018: Each element of C<$data> is C<[ Str, Num ]> or C<[ Str, Num, HashRef ]>; 1019: passing C<undef> or a non-arrayref dies. 1020: Recognised C<opts> keys: C<animated> (boolean, default C<0>), 1021: C<donut> (boolean, default C<0>), C<sort_slices> (string: C<'value'>, 1022: C<'label'>, or C<'none'>; default C<'none'>), C<max_slices> (integer, 1023: default C<0>), C<legend> (boolean, default C<1>), 1024: C<color_scheme> (string, default C<'tableau10'>), 1025: C<separator> (string, default C<'/'> - shown between label and value in 1026: legend entries). 1027: 1028: =head4 Output 1029: 1030: HashRef -- C<{ svg_id =E<gt> 'pie_chart', html =E<gt> Str }>; 1031: embeddable fragment; no DOCTYPE, no page shell, no D3 CDN tag. 1032: 1033: =cut 1034: 1035: sub render_pie_chart_snippet { ●1036 → 1051 → 1060 1036: my ($self, $data, $opts) = @_; 1037: $opts //= {}; 1038: 1039: die 'Data must be an array of arrays' unless ref($data) eq 'ARRAY'; 1040: 1041: my $animated = $opts->{animated} ? 1 : 0; 1042: my $donut = $opts->{donut} ? 1 : 0; 1043: my $sort_slices = $opts->{sort_slices} // 'none'; 1044: my $max_slices = int($opts->{max_slices} // 0); 1045: my $show_legend = exists $opts->{legend} ? ($opts->{legend} ? 1 : 0) : 1; 1046: my $color_scheme = $opts->{color_scheme} // 'tableau10'; 1047: my $separator = $opts->{separator} // '/'; 1048: 1049: # Normalise: absolute values, drop zeros, pull optional extra hashref 1050: my @slices; 1051: for my $pt (@$data) { 1052: my $val = abs($pt->[1] // 0); 1053: next if $val == 0;
Mutants (Total: 1, Killed: 1, Survived: 0)
1054: my %s = (label => $pt->[0], value => $val); 1055: $s{extra} = $pt->[2] if ref($pt->[2]) eq 'HASH'; 1056: push @slices, \%s; 1057: } 1058: 1059: # Sort before max_slices so the "top N" is stable ●1060 → 1060 → 1067 1060: if ($sort_slices eq 'value') {
Mutants (Total: 1, Killed: 1, Survived: 0)
1061: @slices = sort { $b->{value} <=> $a->{value} } @slices; 1062: } elsif ($sort_slices eq 'label') { 1063: @slices = sort { $a->{label} cmp $b->{label} } @slices; 1064: } 1065: 1066: # Collapse tail into "Other" ●1067 → 1067 → 1076 1067: if ($max_slices >= 2 && scalar(@slices) > $max_slices) {
1068: my @by_val = sort { $b->{value} <=> $a->{value} } @slices; 1069: my @top = @by_val[0 .. $max_slices - 2]; 1070: my $other = 0; 1071: $other += $_->{value} for @by_val[$max_slices - 1 .. $#by_val]; 1072: push @top, { label => 'Other', value => $other }; 1073: @slices = @top; 1074: } 1075: ●1076 → 1108 → 1130 1076: my $json_data = $_JSON->encode(\@slices); 1077: 1078: my $svg_id = $opts->{id} // 'pie_chart'; 1079: my $tip_id = $svg_id . '_tip'; 1080: my $leg_id = $svg_id . '_legend'; 1081: my $wrap_id = $svg_id . '_wrap'; 1082: my $width = $self->{width}; 1083: my $height = $self->{height}; 1084: my $svg_attrs = ($opts->{responsive} // $self->{responsive}) 1085: ? qq{viewBox="0 0 $width $height" width="100%" height="auto"} 1086: : qq{width="$width" height="$height"}; 1087: 1088: my $inner_radius_js = $donut ? 'radius * 0.38' : '0'; 1089: 1090: # Animation initialisation (empty when not animated) 1091: 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: 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: my $anim_draw_js; 1108: if ($animated) {Mutants (Total: 7, Killed: 4, Survived: 3)
- NUM_BOUNDARY_1067_18_>: Numeric boundary flip >= to >
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- NUM_BOUNDARY_1067_18_<: Numeric boundary flip >= to <
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- NUM_BOUNDARY_1067_18_<=: Numeric boundary flip >= to <=
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );Mutants (Total: 1, Killed: 1, Survived: 0)
1109: $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: $anim_draw_js = " paths.attr(\"d\", arc);\n"; 1127: } 1128: 1129: # Centre label in donut hole 1130: 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: 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: my $legend_div_html = $show_legend 1156: ? qq( <div id="$leg_id" class="bi-pie-legend"></div>\n) 1157: : ''; 1158: 1159: 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: return { svg_id => $svg_id, html => $html }; 1275: } 1276: 1277: =head2 render_heatmap_snippet 1278: 1279: my $fragment = $chart->render_heatmap_snippet(\@triples); 1280: my $fragment = $chart->render_heatmap_snippet(\@triples, \%opts); 1281: # $fragment->{svg_id} - always 'heatmap' 1282: # $fragment->{html} - embeddable fragment; caller must load D3 v7 1283: 1284: Generates an embeddable grid heatmap for use in existing HTML layouts. 1285: Each cell sits at the intersection of an X-axis label and a Y-axis label; 1286: its colour encodes the cell's numeric value using a sequential D3 colour 1287: scale. Returns C<{ svg_id =E<gt> 'heatmap', html =E<gt> Str }>. The 1288: caller is responsible for loading D3 v7 before embedding the fragment. 1289: 1290: =head3 Data format 1291: 1292: Each element of C<\@triples> is C<[$x_label, $y_label, $value]>. 1293: C<$value> must be numeric or C<undef> (C<undef> rows are silently 1294: skipped). Zero is a valid value and maps to the lightest cell colour. 1295: The caller is responsible for any aggregation: if multiple triples share 1296: the same (x_label, y_label) pair, the last one wins. 1297: 1298: =head3 Options (C<\%opts>) 1299: 1300: =over 4 1301: 1302: =item * C<color_scheme> (string, default C<'YlOrRd'>) - D3 sequential 1303: colour scheme. Supported: C<YlOrRd>, C<Blues>, C<Greens>, C<Purples>, 1304: C<RdPu>, C<YlGnBu>. 1305: 1306: =item * C<x_label> (string, default C<''>) - Axis title below the X axis. 1307: The value is JavaScript-escaped (backslash, double-quote, newline, carriage 1308: return) before being embedded in the page; other characters are taken 1309: literally. 1310: 1311: =item * C<y_label> (string, default C<''>) - Axis title left of the Y axis. 1312: Same JS-escaping as C<x_label> applies. 1313: 1314: =item * C<val_label> (string, default C<'Value'>) - Tooltip value label. 1315: Same JS-escaping as C<x_label> applies. 1316: 1317: =item * C<show_values> (bool, default 0) - Print value inside each cell. 1318: Auto-suppressed when any cell is narrower than 28 px. 1319: 1320: =item * C<cell_padding> (int 0-8, default 2) - Gap in pixels between cells. 1321: 1322: =item * C<legend> (bool, default 1) - Render a colour-scale legend bar. 1323: 1324: =item * C<animated> (bool, default 0) - Fade cells in on first load. 1325: Respects C<prefers-reduced-motion>. 1326: 1327: =back 1328: 1329: =head3 Errors 1330: 1331: =over 4 1332: 1333: =item * Dies with C<Data must be an array of arrays> when C<\@triples> 1334: is not an ARRAY reference. 1335: 1336: =item * Dies with C<Each data point must be an array reference> when a 1337: triple element is not an arrayref. 1338: 1339: =item * Dies with C<Each data point must have at least 3 elements> when 1340: a triple has fewer than 3 elements. 1341: 1342: =item * Dies with C<Value must be numeric> when C<$value> is defined but 1343: not numeric. 1344: 1345: =item * Dies with C<Unknown color_scheme: E<lt>nameE<gt>> for an 1346: unsupported C<color_scheme> value. 1347: 1348: =item * Dies with C<cell_padding must be between 0 and 8> when 1349: C<cell_padding> is outside the valid range. 1350: 1351: =back 1352: 1353: =head3 Side Effects 1354: 1355: Appends a tooltip C<div> to the page when the fragment is rendered in 1356: the browser. 1357: 1358: =head3 API SPECIFICATION 1359: 1360: =head4 Input 1361: 1362: { 1363: data => { type => 'arrayref' }, 1364: opts => { type => 'hashref', optional => 1, default => {} }, 1365: } 1366: 1367: Each element of C<$data> is C<[ Str, Str, Num|undef ]>. 1368: Recognised C<opts> keys: C<color_scheme> (string, default C<'YlOrRd'>), 1369: C<x_label> (string, default C<''>), C<y_label> (string, default C<''>), 1370: C<val_label> (string, default C<'Value'>), C<show_values> (boolean, 1371: default C<0>), C<cell_padding> (integer 0-8, default C<2>), 1372: C<legend> (boolean, default C<1>), C<animated> (boolean, default C<0>). 1373: 1374: =head4 Output 1375: 1376: HashRef -- C<{ svg_id =E<gt> 'heatmap', html =E<gt> Str }>; 1377: embeddable fragment; no DOCTYPE, no page shell, no D3 CDN tag. 1378: 1379: =cut 1380: 1381: sub render_heatmap_snippet { ●1382 → 1413 → 1423 1382: my ($self, $data, $opts) = @_; 1383: $opts //= {}; 1384: 1385: die 'Data must be an array of arrays' unless ref($data) eq 'ARRAY'; 1386: 1387: 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: my $color_scheme = $opts->{color_scheme} // 'YlOrRd'; 1397: die "Unknown color_scheme: $color_scheme" 1398: unless exists $color_scheme_map{$color_scheme}; 1399: my $d3_interpolator = $color_scheme_map{$color_scheme}; 1400: 1401: my $cell_padding = defined($opts->{cell_padding}) ? int($opts->{cell_padding}) : 2; 1402: die 'cell_padding must be between 0 and 8' 1403: unless $cell_padding >= 0 && $cell_padding <= 8;
1404: 1405: my $x_label = $opts->{x_label} // ''; 1406: my $y_label = $opts->{y_label} // ''; 1407: my $val_label = $opts->{val_label} // 'Value'; 1408: my $show_values = $opts->{show_values} ? 1 : 0; 1409: my $legend = exists $opts->{legend} ? ($opts->{legend} ? 1 : 0) : 1; 1410: my $animated = $opts->{animated} ? 1 : 0; 1411: 1412: my @triples; 1413: for my $pt (@$data) { 1414: die 'Each data point must be an array reference' 1415: unless ref($pt) eq 'ARRAY'; 1416: die 'Each data point must have at least 3 elements' 1417: unless scalar(@$pt) >= 3;Mutants (Total: 6, Killed: 0, Survived: 6)
- NUM_BOUNDARY_1403_46_<: Numeric boundary flip <= to <
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- NUM_BOUNDARY_1403_46_>: Numeric boundary flip <= to >
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- NUM_BOUNDARY_1403_46_>=: Numeric boundary flip <= to >=
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- NUM_BOUNDARY_1403_24_>: Numeric boundary flip >= to >
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- NUM_BOUNDARY_1403_24_<: Numeric boundary flip >= to <
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- NUM_BOUNDARY_1403_24_<=: Numeric boundary flip >= to <=
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );Mutants (Total: 3, Killed: 3, Survived: 0)
1418: next unless defined $pt->[2]; 1419: die 'Value must be numeric' unless looks_like_number($pt->[2]); 1420: push @triples, { x => $pt->[0], y => $pt->[1], v => $pt->[2] + 0 }; 1421: } 1422: 1423: 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: my $s = $_; 1428: $s =~ s/\\/\\\\/g; 1429: $s =~ s/"/\\"/g; 1430: $s =~ s/\n/\\n/g; 1431: $s =~ s/\r/\\r/g; 1432: $s 1433: } ($x_label, $y_label, $val_label); 1434: 1435: my $svg_id = $opts->{id} // 'heatmap'; 1436: my $tip_id = $svg_id . '_tip'; 1437: 1438: my $width = $self->{width}; 1439: my $height = $self->{height}; 1440: my $svg_attrs = ($opts->{responsive} // $self->{responsive}) 1441: ? qq{viewBox="0 0 $width $height" width="100%" height="auto"} 1442: : qq{width="$width" height="$height"}; 1443: 1444: my $margin_top = 30; 1445: my $margin_right = $legend ? 65 : 20; 1446: my $margin_bottom = $x_label ? 60 : 40; 1447: my $margin_left = $y_label ? 80 : 60; 1448: my $inner_w = $width - $margin_left - $margin_right; 1449: my $inner_h = $height - $margin_top - $margin_bottom; 1450: 1451: 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: 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: 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: return { svg_id => $svg_id, html => $html }; 1624: } 1625: 1626: =head2 render_bar_chart_snippet 1627: 1628: my $result = $chart->render_bar_chart_snippet(\@bars); 1629: my $result = $chart->render_bar_chart_snippet(\@bars, \%opts); 1630: 1631: Generates an embeddable D3.js v7 bar chart fragment. Returns a hashref 1632: C<{ svg_id =E<gt> 'bar_chart', html =E<gt> $str }> where C<$str> is a 1633: self-contained HTML fragment (no page shell, no D3 CDN tag) that the caller 1634: embeds directly after loading D3.js. C<$str> is a Perl character string 1635: with the UTF-8 flag set (or pure ASCII when all labels are ASCII). 1636: 1637: Each element of C<\@bars> is an array reference: 1638: 1639: [ $label, $value ] 1640: [ $label, $value, \%extra ] 1641: 1642: C<$label> is the category name (string); C<$value> is a non-negative number 1643: (negative values are silently converted to their absolute value); the optional 1644: C<\%extra> hashref supplies additional key/value pairs shown in the hover 1645: tooltip. Data points with an undefined C<$value> are silently skipped. 1646: 1647: =head3 Options (C<\%opts>) 1648: 1649: =over 4 1650: 1651: =item C<orientation> (string, default C<'vertical'>) 1652: 1653: C<'vertical'> draws bars rising from a horizontal category axis. 1654: C<'horizontal'> draws bars extending from a vertical category axis. 1655: 1656: =item C<sort_bars> (string, default C<'none'>) 1657: 1658: Sort order applied before rendering: C<'value'> sorts descending by bar 1659: height, C<'label'> sorts ascending alphabetically, C<'none'> preserves the 1660: input order. 1661: 1662: =item C<max_bars> (integer, default C<0>) 1663: 1664: When non-zero, only the first C<max_bars> entries (after any sort) are 1665: rendered; the remaining entries are collapsed into a single C<Other> bar 1666: whose value equals their sum. Zero means no limit. 1667: 1668: =item C<color> (string, default C<'steelblue'>) 1669: 1670: Either a CSS colour value (e.g. C<'steelblue'>, C<'#4e79a7'>) applied to all 1671: bars, or the special token C<'categorical'>, which colours each bar 1672: distinctively using D3's Tableau-10 palette. 1673: 1674: =item C<show_values> (bool, default C<0>) 1675: 1676: When true, the numeric value of each bar is printed above vertical bars or 1677: to the right of horizontal bars. 1678: 1679: =item C<animated> (bool, default C<0>) 1680: 1681: When true, bars grow from the baseline on page load: vertical bars rise from 1682: the bottom; horizontal bars extend from the left. An 800 ms D3 transition 1683: with a per-bar stagger up to 100 ms is used. The animation is suppressed 1684: when C<prefers-reduced-motion> is set in the viewer's OS. 1685: 1686: =item C<value_label> (string, default C<'Value'>) 1687: 1688: Label shown in the hover tooltip before the numeric value. The value is 1689: JavaScript-escaped before being embedded in the page. 1690: 1691: =item C<x_label> (string, default C<''>) 1692: 1693: When non-empty, a text label is rendered below the bottom axis. The value 1694: is JavaScript-escaped before being embedded in the page. 1695: 1696: =back 1697: 1698: =head3 Errors 1699: 1700: =over 4 1701: 1702: =item * Throws C<Data must be an array of arrays> when C<$data> is not an 1703: ARRAY reference. 1704: 1705: =item * Throws C<Each data point must be an array reference> when an element 1706: is not an array reference. 1707: 1708: =item * Throws C<Each data point must have at least 2 elements> when an 1709: element has fewer than 2 items. 1710: 1711: =item * Throws C<Value must be numeric> when a C<$value> is not a number. 1712: 1713: =item * Throws C<orientation must be 'vertical' or 'horizontal'> on an 1714: invalid orientation value. 1715: 1716: =item * Throws C<sort_bars must be 'value', 'label', or 'none'> on an 1717: invalid sort_bars value. 1718: 1719: =back 1720: 1721: =head3 API SPECIFICATION 1722: 1723: =head4 Input 1724: 1725: { 1726: data => { type => 'arrayref' }, 1727: opts => { type => 'hashref', optional => 1 }, 1728: orientation => { type => 'string', memberof => [ 'vertical', 'horizontal' ], optional => 1 }, 1729: sort_bars => { type => 'string', memberof => [ 'value', 'label', 'none' ], optional => 1 } 1730: } 1731: 1732: Each element of C<$data>: C<[ Str, Num ]> or C<[ Str, Num, HashRef ]>; 1733: undef C<$value> silently skipped; negative C<$value> becomes positive. 1734: 1735: =head4 Output 1736: 1737: HashRef -- { svg_id => 'bar_chart', html => Str } 1738: html is a Perl character string (UTF-8 flag set, or pure ASCII). 1739: 1740: =cut 1741: 1742: sub render_bar_chart_snippet { ●1743 → 1765 → 1781 1743: my ($self, $data, $opts) = @_; 1744: $opts //= {}; 1745: 1746: die 'Data must be an array of arrays' unless ref($data) eq 'ARRAY'; 1747: 1748: my $orientation = $opts->{orientation} // 'vertical'; 1749: die "orientation must be 'vertical' or 'horizontal'" 1750: unless $orientation eq 'vertical' || $orientation eq 'horizontal'; 1751: 1752: my $sort_bars = $opts->{sort_bars} // 'none'; 1753: die "sort_bars must be 'value', 'label', or 'none'" 1754: unless grep { $sort_bars eq $_ } qw(value label none); 1755: 1756: my $max_bars = int($opts->{max_bars} // 0); 1757: my $color = $opts->{color} // 'steelblue'; 1758: my $show_values = $opts->{show_values} ? 1 : 0; 1759: my $animated = $opts->{animated} ? 1 : 0; 1760: my $value_label = $opts->{value_label} // 'Value'; 1761: my $x_label = $opts->{x_label} // ''; 1762: 1763: # Normalise data points; negative values become positive (bars show magnitude) 1764: my @bars; 1765: for my $pt (@$data) { 1766: die 'Each data point must be an array reference' 1767: unless ref($pt) eq 'ARRAY'; 1768: die 'Each data point must have at least 2 elements' 1769: unless scalar(@$pt) >= 2;
Mutants (Total: 3, Killed: 3, Survived: 0)
1770: next unless defined $pt->[1]; 1771: die 'Value must be numeric' unless looks_like_number($pt->[1]); 1772: push @bars, { 1773: label => defined($pt->[0]) ? "$pt->[0]" : '', 1774: value => abs($pt->[1] + 0), 1775: (scalar(@$pt) >= 3 && ref($pt->[2]) eq 'HASH'
Mutants (Total: 3, Killed: 3, Survived: 0)
1776: ? (extra => $pt->[2]) : ()), 1777: }; 1778: } 1779: 1780: # Sort before optional truncation ●1781 → 1781 → 1788 1781: if ($sort_bars eq 'value') {
Mutants (Total: 1, Killed: 1, Survived: 0)
1782: @bars = sort { $b->{value} <=> $a->{value} } @bars; 1783: } elsif ($sort_bars eq 'label') { 1784: @bars = sort { $a->{label} cmp $b->{label} } @bars; 1785: } 1786: 1787: # Truncate and collapse the tail into an "Other" bar ●1788 → 1788 → 1796 1788: if ($max_bars && scalar(@bars) > $max_bars) {
Mutants (Total: 4, Killed: 4, Survived: 0)
1789: my @rest = splice @bars, $max_bars; 1790: my $other = 0; 1791: $other += $_->{value} for @rest; 1792: push @bars, { label => 'Other', value => $other } if @rest; 1793: } 1794: 1795: # Serialise to JSON for embedding in the <script> block ●1796 → 1831 → 1844 1796: my $json_data = $_JSON->encode([map { 1797: my %h = (label => $_->{label}, value => $_->{value} + 0); 1798: $h{extra} = $_->{extra} if exists $_->{extra}; 1799: \%h; 1800: } @bars]); 1801: 1802: # Escape strings for safe embedding inside JS double-quoted string literals 1803: my $js_esc = sub { 1804: my $s = shift; 1805: $s =~ s/\\/\\\\/g; 1806: $s =~ s/"/\\"/g; 1807: $s =~ s/\n/\\n/g; 1808: $s =~ s/\r/\\r/g; 1809: $s 1810: }; 1811: my $value_label_esc = $js_esc->($value_label); 1812: my $x_label_esc = $js_esc->($x_label); 1813: 1814: my $is_categorical = ($color eq 'categorical') ? 1 : 0; 1815: my $color_esc = $is_categorical ? 'steelblue' : $js_esc->($color); 1816: 1817: my $svg_id = $opts->{id} // 'bar_chart'; 1818: my $tip_id = $svg_id . '_tip'; 1819: 1820: my $width = $self->{width}; 1821: my $height = $self->{height}; 1822: my $svg_attrs = ($opts->{responsive} // $self->{responsive}) 1823: ? 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: my $rotate_labels = ($orientation eq 'vertical' && scalar(@bars) > 8) ? 1 : 0;
Mutants (Total: 3, Killed: 3, Survived: 0)
1828: 1829: # Margins depend on orientation, label rotation, and optional features 1830: my ($margin_top, $margin_right, $margin_bottom, $margin_left); 1831: if ($orientation eq 'vertical') {
1832: $margin_top = 20; 1833: $margin_right = 20; 1834: $margin_bottom = $rotate_labels 1835: ? ($x_label ? 105 : 85) 1836: : ($x_label ? 55 : 40); 1837: $margin_left = 55; 1838: } else { 1839: $margin_top = 20; 1840: $margin_right = $show_values ? 60 : 20; 1841: $margin_bottom = $x_label ? 55 : 40; 1842: $margin_left = 130; 1843: } ●1844 → 1865 → 1892 1844: my $inner_w = $width - $margin_left - $margin_right; 1845: my $inner_h = $height - $margin_top - $margin_bottom; 1846: 1847: # JS colour initialiser and per-bar fill callback 1848: 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: my $color_func_js = 'function(d) { return colorOf(d.label); }'; 1853: 1854: # Rotate x-axis tick labels (vertical orientation with many bars only) 1855: 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: my $show_values_block = ''; 1865: if ($show_values) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_1831_2: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomesMutants (Total: 1, Killed: 1, Survived: 0)
1866: if ($orientation eq 'vertical') {
1867: $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: $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 → 1893 → 1908 1892: my $x_label_block = ''; 1893: if ($x_label) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_1866_3: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomesMutants (Total: 1, Killed: 1, Survived: 0)
1894: my $lx = $margin_left + int($inner_w / 2); 1895: my $ly = $height - 5; 1896: $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 → 1909 → 1937 1908: my $anim_block = ''; 1909: if ($animated) {
Mutants (Total: 1, Killed: 1, Survived: 0)
1910: if ($orientation eq 'vertical') {
1911: $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: $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 → 1938 → 2036 1937: my $chart_js; 1938: if ($orientation eq 'vertical') {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_1910_3: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomesMutants (Total: 1, Killed: 1, Survived: 0)
1939: $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: $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: 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: return { svg_id => $svg_id, html => $html }; 2077: } 2078: 2079: =head2 render_line_chart_with_tooltips 2080: 2081: $html = $chart->render_line_chart_with_tooltips($data); 2082: 2083: Generates HTML and JavaScript code to render a line chart with mouseover tooltips. 2084: Accepts the following arguments: 2085: 2086: =over 4 2087: 2088: =item * C<$data> - An array reference containing data points. Each data point should 2089: be an array reference with two elements: the label (string) and the value (numeric). 2090: 2091: =back 2092: 2093: Returns a string containing the HTML and JavaScript code for the chart. 2094: The JavaScript tooltip strings use C<< <\/b> >> (with a backslash) rather than 2095: C<< </b> >> to satisfy html-tidy's requirement that C<< </ >> followed by a 2096: letter not appear literally inside C<< <script> >> blocks. 2097: 2098: =head3 Errors 2099: 2100: =over 4 2101: 2102: =item * Throws C<Data must be an array of arrays> when C<$data> is not an ARRAY reference. 2103: 2104: =back 2105: 2106: =head3 API SPECIFICATION 2107: 2108: =head4 Input 2109: 2110: { 2111: data => { type => 'arrayref' }, 2112: } 2113: 2114: Each element of C<$data> is C<[ Str, Num ]>; passing C<undef> or a 2115: non-arrayref dies. 2116: 2117: =head4 Output 2118: 2119: Str -- complete HTML5 document; mouseover tooltip reveals label and value. 2120: Tooltip strings use C<< <\/b> >> not C<< </b> >>. 2121: 2122: =cut 2123: 2124: sub render_line_chart_with_tooltips 2125: { 2126: my ($self, $data) = @_; 2127: 2128: # Validate input data 2129: 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: map { { label => $_->[0], value => $_->[1] } } @$data 2134: ]); 2135: 2136: # Generate HTML and D3.js code 2137: my $html = $self->_preamble(); 2138: $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: <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: return $html;
Mutants (Total: 2, Killed: 2, Survived: 0)
2232: } 2233: 2234: =head2 render_line_chart_snippet 2235: 2236: my $fragment = $chart->render_line_chart_snippet($data); 2237: my $fragment = $chart->render_line_chart_snippet($data, \%opts); 2238: # $fragment->{svg_id} - the id attribute of the <svg> element 2239: # $fragment->{html} - embeddable HTML fragment (style + svg + script) 2240: 2241: Generates an embeddable HTML fragment for a line chart with mouseover tooltips. 2242: Unlike C<render_line_chart_with_tooltips>, this method returns a fragment with 2243: no C<<!DOCTYPE>>, C<<html>>, C<<head>>, or C<<body>> wrapper, suitable for 2244: splicing directly into a Mojolicious TT (or any other) layout. 2245: 2246: The caller is responsible for loading D3 in the page C<<head>>, e.g.: 2247: 2248: <script src="https://d3js.org/d3.v7.min.js"></script> 2249: 2250: =head3 Arguments 2251: 2252: =over 4 2253: 2254: =item * C<$data> - An array reference of data points. Each point is an array 2255: reference with two required elements - the label (string) and the value 2256: (numeric) - and an optional third element: a hash reference of extra key/value 2257: pairs to display in the tooltip after the label and value rows. 2258: 2259: [$x, $y] # basic point 2260: [$x, $y, \%row] # point with extra tooltip data 2261: 2262: =item * C<\%opts> - Optional hash reference of rendering options: 2263: 2264: =over 4 2265: 2266: =item * C<id> (string, default C<'chart'>) - Override the C<id> attribute of 2267: the C<<svg>> element. Use this when embedding multiple charts on the same page. 2268: 2269: =item * C<responsive> (boolean, default C<0>) - If true, emit 2270: C<viewBox="0 0 W H" width="100%" height="auto"> instead of fixed pixel 2271: dimensions, so the chart scales with its container. Falls back to 2272: C<< $self->{responsive} >> when not set per call. 2273: 2274: =back 2275: 2276: =back 2277: 2278: =head3 Return value 2279: 2280: A hash reference with: 2281: 2282: =over 4 2283: 2284: =item * C<svg_id> - The C<id> attribute used on the C<<svg>> element. 2285: 2286: =item * C<html> - The embeddable fragment string (Perl character string). 2287: 2288: =back 2289: 2290: =head3 Errors 2291: 2292: =over 4 2293: 2294: =item * Dies with C<'Data must be an array of arrays'> when C<$data> is not an 2295: ARRAY reference. 2296: 2297: =back 2298: 2299: =head3 Side effects 2300: 2301: None. The method is read-only. 2302: 2303: =head3 API SPECIFICATION 2304: 2305: { 2306: data => { type => 'arrayref' }, 2307: opts => { 2308: type => 'hashref', 2309: optional => 1, 2310: keys => { 2311: id => { type => 'string', optional => 1 }, 2312: responsive => { type => 'boolean', optional => 1 }, 2313: }, 2314: }, 2315: } 2316: 2317: =cut 2318: 2319: sub render_line_chart_snippet 2320: { 2321: my ($self, $data, $opts) = @_; 2322: $opts //= {}; 2323: 2324: die 'Data must be an array of arrays' unless ref($data) eq 'ARRAY'; 2325: 2326: my $json_data = $_JSON->encode([ 2327: map { 2328: my $point = { label => $_->[0], value => $_->[1] }; 2329: $point->{extra} = $_->[2] if ref($_->[2]) eq 'HASH'; 2330: $point 2331: } @$data 2332: ]); 2333: 2334: my $svg_id = $opts->{id} // 'chart'; 2335: my $tip_id = $svg_id . '_tooltip'; 2336: my $w = $self->{width}; 2337: my $h = $self->{height}; 2338: my $svg_attrs = ($opts->{responsive} // $self->{responsive}) 2339: ? qq{viewBox="0 0 $w $h" width="100%" height="auto"} 2340: : qq{width="$w" height="$h" style="border: 1px solid black;"}; 2341: 2342: 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: return { svg_id => $svg_id, html => $html }; 2429: } 2430: 2431: =head2 render_zoomable_line_chart_snippet 2432: 2433: my $fragment = $chart->render_zoomable_line_chart_snippet($data); 2434: my $fragment = $chart->render_zoomable_line_chart_snippet($data, { animated => 1 }); 2435: # $fragment->{svg_id} - the id attribute of the <svg> element 2436: # $fragment->{html} - embeddable HTML fragment (style + button + svg + script) 2437: 2438: Like C<render_line_chart_snippet>, but adds brush-to-zoom: the user can drag 2439: across a range of the x-axis to zoom into that region. A I<Reset zoom> button 2440: (hidden until a zoom is active) returns the chart to its original extent. 2441: Subsequent brushes on the zoomed view zoom in further; Reset always returns to 2442: the full dataset. 2443: 2444: The caller is responsible for loading D3 in the page C<<head>>. 2445: 2446: Accepts the same arguments as C<render_line_chart_snippet>: an array reference 2447: of data points, each C<[$x, $y]> or C<[$x, $y, \%extra]>, plus an optional 2448: second argument C<$opts> (hashref). 2449: 2450: =head3 Options 2451: 2452: =over 4 2453: 2454: =item * C<animated> (boolean, default C<0>) - when true, the initial page load 2455: animates the line drawing left-to-right via the C<stroke-dashoffset> technique 2456: (1200 ms, C<d3.easeLinear>), then fades in data-point circles after the line 2457: finishes (300 ms after a 1200 ms delay). Respects 2458: C<prefers-reduced-motion>: when the user has requested reduced motion the line 2459: is drawn immediately at full opacity. Subsequent zoom and reset redraws are 2460: never animated regardless of this flag. 2461: 2462: =back 2463: 2464: =head3 API SPECIFICATION 2465: 2466: =head4 Input 2467: 2468: { 2469: data => { type => 'arrayref' }, 2470: opts => { type => 'hashref', optional => 1, default => {} }, 2471: } 2472: 2473: Each element of C<$data> is C<[ Str, Num ]> or C<[ Str, Num, HashRef ]>; 2474: passing C<undef> or a non-arrayref dies. 2475: Recognised C<opts> key: C<animated> (boolean, default C<0>). 2476: 2477: =head4 Output 2478: 2479: HashRef -- C<{ svg_id =E<gt> 'chart', html =E<gt> Str }>; 2480: embeddable fragment; no DOCTYPE, no page shell, no D3 CDN tag. 2481: 2482: =head3 Errors 2483: 2484: Dies with I<Data must be an array of arrays> if C<$data> is not an arrayref. 2485: 2486: =cut 2487: 2488: sub render_zoomable_line_chart_snippet 2489: { ●2490 → 2539 → 2610 2490: my ($self, $data, $opts) = @_; 2491: $opts //= {}; 2492: 2493: die 'Data must be an array of arrays' unless ref($data) eq 'ARRAY'; 2494: 2495: my $json_data = $_JSON->encode([ 2496: map { 2497: my $point = { label => $_->[0], value => $_->[1] }; 2498: $point->{extra} = $_->[2] if ref($_->[2]) eq 'HASH'; 2499: $point 2500: } @$data 2501: ]); 2502: 2503: my $svg_id = $opts->{id} // 'chart'; 2504: my $tip_id = $svg_id . '_tooltip'; 2505: my $rst_id = $svg_id . '_reset'; 2506: my $w = $self->{width}; 2507: my $h = $self->{height}; 2508: my $svg_attrs = ($opts->{responsive} // $self->{responsive}) 2509: ? 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: 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: my $init_flag = $opts->{animated} ? 'let initialDrawDone = false;' : ''; 2537: 2538: my $redraw_body; 2539: if ($opts->{animated}) {
Mutants (Total: 1, Killed: 1, Survived: 0)
2540: $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: $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: 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: return { svg_id => $svg_id, html => $html }; 2716: } 2717: 2718: =head2 render_multi_series_line_chart_with_tooltips 2719: 2720: $html = $chart->render_multi_series_line_chart_with_tooltips($data); 2721: 2722: Generates HTML and JavaScript code to render a chart of many lines with mouseover tooltips. 2723: 2724: Accepts the following arguments: 2725: 2726: =over 4 2727: 2728: =item * C<$data> - An array reference of series hashes. Each element is a hashref 2729: with a C<name> key (string) and a C<data> key (array reference of C<< {label, value} >> 2730: hashrefs). 2731: 2732: [ 2733: { name => 'Series A', data => [{ label => 'Jan', value => 100 }, ...] }, 2734: ... 2735: ] 2736: 2737: =back 2738: 2739: Returns a string containing the HTML and JavaScript code for the chart. 2740: Tooltip strings use C<< <\/b> >> rather than C<< </b> >> for html-tidy compliance. 2741: 2742: =head3 Errors 2743: 2744: =over 4 2745: 2746: =item * Throws C<Data must be an array of hashes> when C<$data> is not an ARRAY reference. 2747: 2748: =back 2749: 2750: =head3 API SPECIFICATION 2751: 2752: =head4 Input 2753: 2754: { 2755: data => { type => 'arrayref' }, 2756: } 2757: 2758: Each element of C<$data> is a hashref with keys C<name> (string) and 2759: C<data> (arrayref of hashrefs with C<label> and C<value> keys); 2760: passing C<undef> or a non-arrayref dies. 2761: 2762: =head4 Output 2763: 2764: Str -- complete HTML5 document; one coloured line per series with mouseover tooltips. 2765: 2766: =cut 2767: 2768: sub render_multi_series_line_chart_with_tooltips 2769: { 2770: my ($self, $data) = @_; 2771: 2772: # Validate input data 2773: die 'Data must be an array of hashes' unless ref($data) eq 'ARRAY'; 2774: 2775: my $json_data = $_JSON->encode($data); 2776: 2777: # Generate HTML and D3.js code 2778: my $html = $self->_preamble(); 2779: $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: <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: return $html;
Mutants (Total: 2, Killed: 2, Survived: 0)
2883: } 2884: 2885: =head2 render_multi_series_line_chart_with_animated_tooltips 2886: 2887: $html = $chart->render_multi_series_line_chart_with_animated_tooltips($data); 2888: 2889: Generates HTML and JavaScript code to render a chart of many lines with animated mouseover tooltips. 2890: 2891: Accepts the following arguments: 2892: 2893: =over 4 2894: 2895: =item * C<$data> - Same format as C<render_multi_series_line_chart_with_tooltips>: 2896: an array reference of C<< { name, data } >> series hashes. 2897: 2898: =back 2899: 2900: Returns a string containing the complete HTML5 document. 2901: The tooltip appears with a CSS C<translateY> slide-in animation. 2902: Tooltip strings use C<< <\/b> >> for html-tidy compliance. 2903: 2904: =head3 Errors 2905: 2906: =over 4 2907: 2908: =item * Throws C<Data must be an array of hashes> when C<$data> is not an ARRAY reference. 2909: 2910: =back 2911: 2912: =head3 API SPECIFICATION 2913: 2914: =head4 Input 2915: 2916: { 2917: data => { type => 'arrayref' }, 2918: } 2919: 2920: Each element of C<$data> is a hashref with keys C<name> (string) and 2921: C<data> (arrayref of hashrefs with C<label> and C<value> keys); 2922: passing C<undef> or a non-arrayref dies. 2923: 2924: =head4 Output 2925: 2926: Str -- complete HTML5 document; animated tooltip uses CSS translateY transition. 2927: 2928: =cut 2929: 2930: sub render_multi_series_line_chart_with_animated_tooltips 2931: { 2932: my ($self, $data) = @_; 2933: 2934: # Validate input data 2935: die 'Data must be an array of hashes' unless ref($data) eq 'ARRAY'; 2936: 2937: # Generate JSON for data 2938: my $json_data = $_JSON->encode($data); 2939: 2940: # Generate HTML and D3.js code 2941: my $html = $self->_preamble(); 2942: $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: <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: return $html;
Mutants (Total: 2, Killed: 2, Survived: 0)
3049: } 3050: 3051: =head2 render_multi_series_line_chart_with_legends 3052: 3053: $html = $chart->render_multi_series_line_chart_with_legends($data); 3054: 3055: Generates HTML and JavaScript code to render a chart of many lines with a static 3056: colour legend. Each series gets a labelled colour swatch in the legend area. 3057: 3058: Accepts the following arguments: 3059: 3060: =over 4 3061: 3062: =item * C<$data> - Same format as C<render_multi_series_line_chart_with_tooltips>: 3063: an array reference of C<< { name, data } >> series hashes. 3064: 3065: =back 3066: 3067: Returns a string containing the complete HTML5 document. The stylesheet defines 3068: a C<.legend> CSS class used by the D3-generated legend elements. 3069: 3070: =head3 Errors 3071: 3072: =over 4 3073: 3074: =item * Throws C<Data must be an array of hashes> when C<$data> is not an ARRAY reference. 3075: 3076: =back 3077: 3078: =head3 API SPECIFICATION 3079: 3080: =head4 Input 3081: 3082: { 3083: data => { type => 'arrayref' }, 3084: } 3085: 3086: Each element of C<$data> is a hashref with keys C<name> (string) and 3087: C<data> (arrayref of hashrefs with C<label> and C<value> keys); 3088: passing C<undef> or a non-arrayref dies. 3089: 3090: =head4 Output 3091: 3092: Str -- complete HTML5 document; static colour legend rendered as SVG C<g> elements 3093: with the C<.legend> CSS class applied via D3 C<.attr("class", "legend")>. 3094: 3095: =cut 3096: 3097: sub render_multi_series_line_chart_with_legends { 3098: my($self, $data) = @_; 3099: 3100: # Validate input data 3101: die 'Data must be an array of hashes' unless ref($data) eq 'ARRAY'; 3102: 3103: # Generate JSON for data 3104: my $json_data = $_JSON->encode($data); 3105: 3106: # Generate HTML and D3.js code 3107: my $html = $self->_preamble(); 3108: $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: <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: return $html;
Mutants (Total: 2, Killed: 2, Survived: 0)
3252: } 3253: 3254: =head2 render_multi_series_line_chart_with_interactive_legends 3255: 3256: $html = $chart->render_multi_series_line_chart_with_interactive_legends($data); 3257: 3258: 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. 3259: 3260: Accepts the following arguments: 3261: 3262: =over 4 3263: 3264: =item * C<$data> - Same format as C<render_multi_series_line_chart_with_tooltips>: 3265: an array reference of C<< { name, data } >> series hashes. 3266: 3267: =back 3268: 3269: Returns a string containing the complete HTML5 document. Clicking a legend entry 3270: toggles that series' opacity using an C<isVisible> boolean flag in the D3 click 3271: handler (opacity is set to C<isVisible ? 0 : 1> on each click). 3272: 3273: =head3 Errors 3274: 3275: =over 4 3276: 3277: =item * Throws C<Data must be an array of hashes> when C<$data> is not an ARRAY reference. 3278: 3279: =back 3280: 3281: =head3 API SPECIFICATION 3282: 3283: =head4 Input 3284: 3285: { 3286: data => { type => 'arrayref' }, 3287: } 3288: 3289: Each element of C<$data> is a hashref with keys C<name> (string) and 3290: C<data> (arrayref of hashrefs with C<label> and C<value> keys); 3291: passing C<undef> or a non-arrayref dies. 3292: 3293: =head4 Output 3294: 3295: Str -- complete HTML5 document; legend clicks toggle series visibility. 3296: The C<isVisible> JS variable tracks current visibility state. 3297: Opacity toggled by C<isVisible ? 0 : 1>. 3298: 3299: =cut 3300: 3301: sub render_multi_series_line_chart_with_interactive_legends 3302: { 3303: my ($self, $data) = @_; 3304: 3305: # Validate input data 3306: die 'Data must be an array of hashes' unless ref($data) eq 'ARRAY'; 3307: 3308: # Generate JSON for data 3309: my $json_data = $_JSON->encode($data); 3310: 3311: # Generate HTML and D3.js code 3312: my $html = $self->_preamble(); 3313: $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: <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: return $html;
Mutants (Total: 2, Killed: 2, Survived: 0)
3460: } 3461: 3462: =head2 render_multi_series_line_chart_snippet 3463: 3464: my $fragment = $chart->render_multi_series_line_chart_snippet(\@series); 3465: my $fragment = $chart->render_multi_series_line_chart_snippet(\@series, \%opts); 3466: # $fragment->{svg_id} - the id attribute of the <svg> element 3467: # $fragment->{html} - embeddable HTML fragment (style + svg + script) 3468: 3469: Generates an embeddable multi-series line chart fragment. Unlike 3470: C<render_multi_series_line_chart_with_interactive_legends>, this method returns 3471: a bare fragment with no C<< <html> >>, C<< <head> >>, or C<< <body> >> wrapper; 3472: D3.js must be loaded by the host page. 3473: 3474: =head3 Arguments 3475: 3476: =over 4 3477: 3478: =item * C<\@series> - An array reference of series. Each series is a hash 3479: reference with a C<name> (string) and a C<data> array reference of 3480: C<< { label => Str, value => Num } >> points: 3481: 3482: [ 3483: { name => 'North', data => [ { label => 'Jan', value => 12 }, ... ] }, 3484: { name => 'South', data => [ { label => 'Jan', value => 8 }, ... ] }, 3485: ] 3486: 3487: Series need not share labels: the X domain is the union of every series' labels, 3488: in first-seen order. One line and one set of dots is drawn per series, and a 3489: clickable legend toggles each series on and off. 3490: 3491: =item * C<\%opts> - Optional hash reference: 3492: 3493: =over 4 3494: 3495: =item * C<scheme> (string, default C<'tableau10'>) - D3 categorical colour 3496: scheme (for example C<'category10'>, C<'set2'>, C<'set3'>, C<'paired'>). 3497: 3498: =item * C<x_label> (string, default C<''>) - Label shown below the X axis. 3499: 3500: =item * C<y_label> (string, default C<''>) - Label shown beside the Y axis. 3501: 3502: =item * C<animated> (boolean, default 0) - If true each line draws itself left 3503: to right by animating its stroke dash offset. Respects 3504: C<prefers-reduced-motion>. 3505: 3506: =item * C<responsive> (boolean, optional) - SVG responsive sizing override. 3507: 3508: =item * C<id> (string, default C<'chart'>) - C<id> of the C<<svg>> element. 3509: 3510: =back 3511: 3512: =back 3513: 3514: Returns C<< { svg_id => 'chart', html => Str } >>. 3515: 3516: =head3 Errors 3517: 3518: =over 4 3519: 3520: =item * Throws C<Data must be an array of series> when C<\@series> is not an 3521: ARRAY reference. 3522: 3523: =item * Throws C<Each series must be a hashref with name and data> when a 3524: series is not a hashref, has no C<name>, or has a C<data> that is not an ARRAY 3525: reference. 3526: 3527: =back 3528: 3529: =head3 API SPECIFICATION 3530: 3531: =head4 Input 3532: 3533: { 3534: data => { type => 'arrayref' }, 3535: opts => { 3536: type => 'hashref', 3537: optional => 1, 3538: keys => { 3539: scheme => { type => 'string', optional => 1, default => 'tableau10' }, 3540: x_label => { type => 'string', optional => 1, default => '' }, 3541: y_label => { type => 'string', optional => 1, default => '' }, 3542: animated => { type => 'boolean', optional => 1, default => 0 }, 3543: responsive => { type => 'boolean', optional => 1 }, 3544: id => { type => 'string', optional => 1, default => 'chart' }, 3545: }, 3546: }, 3547: } 3548: 3549: =head4 Output 3550: 3551: HashRef -- { svg_id => 'chart', html => Str } 3552: 3553: =cut 3554: 3555: sub render_multi_series_line_chart_snippet 3556: { ●3557 → 3561 → 3566 3557: my ($self, $series_data, $opts) = @_; 3558: $opts //= {}; 3559: 3560: die 'Data must be an array of series' unless ref($series_data) eq 'ARRAY'; 3561: for my $s (@$series_data) { 3562: die 'Each series must be a hashref with name and data' 3563: unless ref($s) eq 'HASH' && defined $s->{name} && ref($s->{data}) eq 'ARRAY'; 3564: } 3565: 3566: my $json_data = $_JSON->encode($series_data); 3567: 3568: my $scheme = $opts->{scheme} // 'tableau10';
Mutants (Total: 2, Killed: 2, Survived: 0)
3569: my $x_label = $opts->{x_label} // ''; 3570: my $y_label = $opts->{y_label} // ''; 3571: my $animated = $opts->{animated} // 0; 3572: my $svg_id = $opts->{id} // 'chart'; 3573: my $tip_id = $svg_id . '_tip'; 3574:
Mutants (Total: 3, Killed: 3, Survived: 0)
3575: (my $scheme_js = $scheme) =~ s/^([a-z])/\u$1/; 3576: 3577: my $js_esc = sub { 3578: my $s = shift; 3579: $s =~ s/\\/\\\\/g; $s =~ s/"/\\"/g; 3580: $s =~ s/\n/\\n/g; $s =~ s/\r/\\r/g; 3581: return $s; 3582: }; 3583: 3584: my $x_label_esc = $js_esc->($x_label); 3585: my $y_label_esc = $js_esc->($y_label); 3586: 3587: my $width = $self->{width}; 3588: my $height = $self->{height}; 3589: my $svg_attrs = ($opts->{responsive} // $self->{responsive}) 3590: ? qq{viewBox="0 0 $width $height" width="100%" height="auto"} 3591: : qq{width="$width" height="$height"}; 3592: 3593: my $margin_right = 150; # room for legend 3594: my $margin_left = $y_label ? 60 : 45; 3595: my $margin_top = 20; 3596: my $margin_bottom = $x_label ? 60 : 40; 3597: my $inner_w = $width - $margin_left - $margin_right; 3598: my $inner_h = $height - $margin_top - $margin_bottom; 3599: 3600: 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: 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: 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: 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: return { svg_id => $svg_id, html => $html }; 3735: } 3736: 3737: =head2 render_scatter_chart_snippet 3738: 3739: my $fragment = $chart->render_scatter_chart_snippet($data); 3740: my $fragment = $chart->render_scatter_chart_snippet($data, \%opts); 3741: # $fragment->{svg_id} - the id attribute of the <svg> element 3742: # $fragment->{html} - embeddable HTML fragment (style + svg + script) 3743: 3744: Generates an embeddable HTML fragment for a scatter plot with mouseover 3745: tooltips. Returns a fragment with no C<<!DOCTYPE>>, C<<html>>, C<<head>>, or 3746: C<<body>> wrapper; the caller is responsible for loading D3 in the page. 3747: 3748: =head3 Arguments 3749: 3750: =over 4 3751: 3752: =item * C<$data> - An array reference of data points. Each point is an array 3753: reference with two required numeric elements (x, y), an optional third hash 3754: reference of extra key/value pairs shown as extra tooltip rows, and an optional 3755: fourth hash reference carrying the C<series> (colour group) and C<size> (bubble 3756: radius) dimensions: 3757: 3758: [$x, $y] # basic point 3759: [$x, $y, \%row] # point with extra tooltip data 3760: [$x, $y, \%row, { series => 'North', size => 42 }] # colour and size 3761: 3762: =item * C<\%opts> - Optional hash reference: 3763: 3764: =over 4 3765: 3766: =item * C<id> (string, default C<'scatter_chart'>) - C<id> of the C<<svg>> element. 3767: 3768: =item * C<color> (CSS colour or C<'categorical'>, default C<'steelblue'>) - 3769: Fill colour for the data points. C<'categorical'> uses the Tableau-10 palette. 3770: 3771: =item * C<x_label> (string, default C<''>) - Label for the X axis. 3772: 3773: =item * C<y_label> (string, default C<''>) - Label for the Y axis. 3774: 3775: =item * C<value_label> (string, default C<'Value'>) - Prefix for the tooltip value row. 3776: 3777: =item * C<colour_by_series> (boolean, default 0) - Colour each point by its 3778: C<series> value instead of by point index, and draw a legend listing the series 3779: to the right of the plot. Ignored for points with no C<series> value. 3780: 3781: =item * C<scheme> (string, default C<'tableau10'>) - D3 categorical colour 3782: scheme used when C<colour_by_series> is set, for example C<'category10'>, 3783: C<'set2'>, C<'set3'>, or C<'paired'>. 3784: 3785: =item * C<series_col> (string, default C<'Series'>) - Row label for the series 3786: value in the tooltip. 3787: 3788: =item * C<size_col> (string, default C<'Size'>) - Row label for the size value 3789: in the tooltip. 3790: 3791: =item * C<radius_range> (arrayref of two numbers, default C<[3, 15]>) - Pixel 3792: radii mapped from the smallest and largest observed C<size> value. The mapping 3793: is by area (C<d3.scaleSqrt>), so a point twice as large is drawn with four 3794: times the area. A point with no C<size> value is drawn at the lower radius. 3795: 3796: =item * C<animated> (boolean, default 0) - If true, circles fade in from 3797: opacity 0 on page load (400 ms). Respects C<prefers-reduced-motion>. 3798: 3799: =item * C<responsive> (boolean, default 0) - See C<render_bar_chart_snippet>. 3800: 3801: =back 3802: 3803: =back 3804: 3805: =head3 Return value 3806: 3807: A hash reference with C<svg_id> (string) and C<html> (Perl character string). 3808: 3809: =head3 Errors 3810: 3811: =over 4 3812: 3813: =item * Dies with C<'Data must be an array of arrays'> when C<$data> is not an ARRAY ref. 3814: 3815: =item * Dies with C<'Each data point must be an array reference'> when an element is not an ARRAY ref. 3816: 3817: =item * Dies with C<'Each data point must have at least 2 elements'> when a point has fewer than 2 items. 3818: 3819: =item * Dies with C<'X value must be numeric'> when the x element is not a number. 3820: 3821: =item * Dies with C<'Y value must be numeric'> when the y element is not a number. 3822: 3823: =back 3824: 3825: =head3 API SPECIFICATION 3826: 3827: { 3828: data => { type => 'arrayref' }, 3829: opts => {
3830: type => 'hashref', 3831: optional => 1, 3832: keys => { 3833: id => { type => 'string', optional => 1 }, 3834: color => { type => 'string', optional => 1, default => 'steelblue' }, 3835: x_label => { type => 'string', optional => 1, default => '' }, 3836: y_label => { type => 'string', optional => 1, default => '' }, 3837: value_label => { type => 'string', optional => 1, default => 'Value' },Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_3829_3: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes🧪 Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_3829_3: Replace return expression with undef
LOW: Mutation survived, but impact may be minor🧪 Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );Mutants (Total: 2, Killed: 2, Survived: 0)
3838: colour_by_series => { type => 'boolean', optional => 1, default => 0 }, 3839: scheme => { type => 'string', optional => 1, default => 'tableau10' }, 3840: series_col => { type => 'string', optional => 1, default => '' }, 3841: size_col => { type => 'string', optional => 1, default => '' }, 3842: radius_range => { type => 'arrayref', optional => 1, default => [3, 15] }, 3843: animated => { type => 'boolean', optional => 1, default => 0 }, 3844: responsive => { type => 'boolean', optional => 1, default => 0 }, 3845: }, 3846: }, 3847: } 3848: 3849: =cut 3850: 3851: sub render_scatter_chart_snippet 3852: { ●3853 → 3882 → 3897 3853: my ($self, $data, $opts) = @_; 3854: $opts //= {}; 3855: 3856: die 'Data must be an array of arrays' unless ref($data) eq 'ARRAY'; 3857: 3858: my $color = $opts->{color} // 'steelblue'; 3859: my $x_label = $opts->{x_label} // ''; 3860: my $y_label = $opts->{y_label} // ''; 3861: my $value_label = $opts->{value_label} // 'Value'; 3862: my $animated = $opts->{animated} // 0; 3863: my $colour_by_series = $opts->{colour_by_series} // 0; 3864: my $scheme = $opts->{scheme} // 'tableau10'; 3865: my $series_col = $opts->{series_col} // ''; 3866: my $size_col = $opts->{size_col} // ''; 3867: my $radius_range = $opts->{radius_range} // [3, 15]; 3868: 3869: my $js_esc = sub { 3870: my $s = shift; 3871: $s =~ s/\\/\\\\/g; 3872: $s =~ s/"/\\"/g; 3873: $s =~ s/\n/\\n/g; 3874: $s =~ s/\r/\\r/g; 3875: return $s; 3876: }; 3877: 3878: # Capitalise the first letter of the scheme name for the d3.scheme* accessor. 3879: (my $scheme_js = $scheme) =~ s/^([a-z])/\u$1/; 3880: 3881: my @points; 3882: for my $pt (@$data) { 3883: die 'Each data point must be an array reference' unless ref($pt) eq 'ARRAY'; 3884: die 'Each data point must have at least 2 elements' unless scalar(@$pt) >= 2; 3885: die 'X value must be numeric' unless looks_like_number($pt->[0]); 3886: die 'Y value must be numeric' unless looks_like_number($pt->[1]); 3887: my $p = { x => $pt->[0] + 0, y => $pt->[1] + 0 }; 3888: $p->{extra} = $pt->[2] if ref($pt->[2]) eq 'HASH'; 3889: if (ref($pt->[3]) eq 'HASH') { 3890: $p->{series} = $pt->[3]{series} if defined $pt->[3]{series}; 3891: $p->{size} = $pt->[3]{size}+0 if defined $pt->[3]{size} && looks_like_number($pt->[3]{size}); 3892: } 3893: push @points, $p; 3894: } 3895: 3896: # Collect series names and size range for JS initialisation. ●3897 → 3900 → 3910 3897: my %seen_series; 3898: my @series_names; 3899: my ($size_min, $size_max); 3900: for my $p (@points) { 3901: if (defined $p->{series} && !$seen_series{$p->{series}}++) { 3902: push @series_names, $p->{series}; 3903: } 3904: if (defined $p->{size}) { 3905: $size_min = $p->{size} if !defined $size_min || $p->{size} < $size_min; 3906: $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 → 3945 → 3955 3910: my $has_series = $colour_by_series && @series_names; 3911: my $has_size = defined $size_min; 3912: 3913: my $json_data = $_JSON->encode(\@points); 3914: my $json_series = $_JSON->encode(\@series_names); 3915: my $x_label_esc = $js_esc->($x_label); 3916: my $y_label_esc = $js_esc->($y_label); 3917: my $val_label_esc = $js_esc->($value_label); 3918: my $series_lbl_esc = $js_esc->($series_col || 'Series'); 3919: my $size_lbl_esc = $js_esc->($size_col || 'Size'); 3920: 3921: my $is_categorical = ($color eq 'categorical') ? 1 : 0; 3922: my $color_esc = ($is_categorical || $has_series) ? 'steelblue' : $js_esc->($color); 3923: 3924: my $svg_id = $opts->{id} // 'scatter_chart'; 3925: my $tip_id = $svg_id . '_tip'; 3926: my $leg_id = $svg_id . '_leg'; 3927: 3928: my $width = $self->{width}; 3929: my $height = $self->{height}; 3930: 3931: # Reserve right margin for legend when series colouring is active. 3932: my $margin_right = $has_series ? 140 : 20; 3933: my $svg_attrs = ($opts->{responsive} // $self->{responsive}) 3934: ? qq{viewBox="0 0 $width $height" width="100%" height="auto"} 3935: : qq{width="$width" height="$height"}; 3936: 3937: my $margin_bottom = $x_label ? 60 : 40; 3938: my $margin_left = $y_label ? 70 : 50; 3939: my $margin_top = 20; 3940: my $inner_w = $width - $margin_left - $margin_right; 3941: my $inner_h = $height - $margin_top - $margin_bottom; 3942: 3943: # Color initialisation JS. 3944: my $color_init_js; 3945: if ($has_series) { 3946: $color_init_js = "var seriesNames = $json_series;\n" 3947: . " var color = d3.scaleOrdinal(d3.scheme${scheme_js}).domain(seriesNames);"; 3948: } elsif ($is_categorical) { 3949: $color_init_js = "var color = d3.scaleOrdinal(d3.schemeTableau10).domain(data.map(function(d,i){return i;}));"; 3950: } else { 3951: $color_init_js = "var color = function() { return \"$color_esc\"; };"; 3952: } 3953: 3954: # Size scale JS (bubble mode). ●3955 → 3959 → 3967 3955: my $r_min = $radius_range->[0] // 3; 3956: my $r_max = $radius_range->[1] // 15; 3957: my $size_init_js = ''; 3958: my $radius_js = '5'; 3959: if ($has_size && defined $size_min) { 3960: my $smin = $size_min + 0; 3961: my $smax = $size_max + 0; 3962: $size_init_js = "var rScale = d3.scaleSqrt().domain([$smin,$smax]).range([$r_min,$r_max]).clamp(true);"; 3963: $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: 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: 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: 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: 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: 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: 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: 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: 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:
Mutants (Total: 1, Killed: 1, Survived: 0)
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")
Mutants (Total: 2, Killed: 2, Survived: 0)
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: return { svg_id => $svg_id, html => $html }; 4119: } 4120: 4121: =head2 render_table_snippet 4122: 4123: my $fragment = $chart->render_table_snippet($data); 4124: my $fragment = $chart->render_table_snippet($data, \%opts); 4125: # $fragment->{table_id} - the id attribute of the <table> element 4126: # $fragment->{html} - embeddable HTML fragment (style + table + script) 4127: 4128: Generates an embeddable HTML fragment for a sortable, filterable data table. 4129: Returns a fragment with no page-shell wrapper; the caller loads D3 if needed. 4130: 4131: =head3 Arguments 4132: 4133: =over 4 4134: 4135: =item * C<$data> - An array reference of row array references. The first row 4136: is treated as the header row. Every subsequent row must have the same number 4137: of columns as the header. 4138: 4139: [ ['Name', 'Value', 'Category'], # header 4140: ['Alpha', 300, 'A'], 4141: ['Beta', 150, 'B'], 4142: ] 4143: 4144: =item * C<\%opts> - Optional hash reference: 4145: 4146: =over 4 4147: 4148: =item * C<id> (string, default C<'data_table'>) - C<id> of the C<<table>> element. 4149: 4150: =item * C<sortable> (boolean, default 1) - If true, clicking a column header 4151: sorts the table by that column (toggle ascending/descending). 4152: 4153: =item * C<caption> (string, default C<''>) - Optional C<<caption>> element text. 4154: 4155: =back 4156: 4157: =back 4158: 4159: =head3 Return value 4160: 4161: A hash reference with C<table_id> (string) and C<html> (Perl character string). 4162: Note: returns C<table_id>, not C<svg_id>, because this method renders an HTML 4163: table rather than an SVG chart. 4164: 4165: =head3 Errors 4166: 4167: =over 4 4168: 4169: =item * Dies with C<'Data must be an array of arrays'> when C<$data> is not an ARRAY ref. 4170: 4171: =item * Dies with C<'Data must have at least one row (header row)'> when C<$data> is empty. 4172: 4173: =item * Dies with C<'Each row must be an array reference'> when a row is not an ARRAY ref. 4174: 4175: =back 4176: 4177: =head3 API SPECIFICATION 4178: 4179: { 4180: data => { type => 'arrayref' }, 4181: opts => { 4182: type => 'hashref', 4183: optional => 1, 4184: keys => { 4185: id => { type => 'string', optional => 1, default => 'data_table' }, 4186: sortable => { type => 'boolean', optional => 1, default => 1 }, 4187: caption => { type => 'string', optional => 1, default => '' }, 4188: }, 4189: }, 4190: } 4191: 4192: =cut 4193: 4194: sub render_table_snippet 4195: { ●4196 → 4227 → 4231 4196: my ($self, $data, $opts) = @_; 4197: $opts //= {}; 4198: 4199: die 'Data must be an array of arrays' unless ref($data) eq 'ARRAY'; 4200: die 'Data must have at least one row (header row)' unless @$data; 4201: die 'Each row must be an array reference' unless ref($data->[0]) eq 'ARRAY'; 4202: 4203: my $sortable = exists($opts->{sortable}) ? $opts->{sortable} : 1; 4204: my $caption = $opts->{caption} // ''; 4205: my $table_id = $opts->{id} // 'data_table'; 4206: 4207: my $js_esc = sub { 4208: my $s = shift; 4209: $s =~ s/\\/\\\\/g; 4210: $s =~ s/"/\\"/g; 4211: $s =~ s/\n/\\n/g; 4212: $s =~ s/\r/\\r/g; 4213: return $s; 4214: }; 4215: 4216: my $html_esc = sub { 4217: my $s = shift // ''; 4218: $s =~ s/&/&/g; 4219: $s =~ s/</</g; 4220: $s =~ s/>/>/g; 4221: return $s; 4222: }; 4223: 4224: my $headers = $data->[0]; 4225: my @rows = @{$data}[1 .. $#$data]; 4226: 4227: for my $row (@rows) { 4228: die 'Each row must be an array reference' unless ref($row) eq 'ARRAY'; 4229: } 4230: ●4231 → 4249 → 4253 4231: my $json_data = $_JSON->encode([ 4232: map { 4233: my @row = @$_; 4234: \@row 4235: } @$data 4236: ]); 4237: 4238: my $caption_html = $caption 4239: ? '<caption>' . $html_esc->($caption) . '</caption>' 4240: : ''; 4241: 4242: my $sort_th_class = $sortable ? ' class="dt-sortable"' : ''; 4243: my $sort_cursor_css = $sortable ? 'cursor:pointer; user-select:none;' : ''; 4244: 4245: my @th_cells = map { '<th' . $sort_th_class . '>' . $html_esc->($_) . '</th>' } @$headers; 4246: my $thead_html = '<tr>' . join('', @th_cells) . '</tr>'; 4247: 4248: my @tbody_rows; 4249: for my $row (@rows) { 4250: my @tds = map { '<td>' . $html_esc->($_) . '</td>' } @$row; 4251: push @tbody_rows, '<tr>' . join('', @tds) . '</tr>'; 4252: } 4253: my $tbody_html = join("\n ", @tbody_rows); 4254: 4255: 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: 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: return { table_id => $table_id, html => $html }; 4315: } 4316: 4317: =head2 render_stacked_bar_snippet 4318: 4319: my $result = $chart->render_stacked_bar_snippet(\@rows, \%opts); 4320: 4321: Generates an embeddable stacked bar chart (no page shell, no CDN tag). 4322: 4323: Each element of C<\@rows> is a hashref with three keys named by C<opts>: 4324: C<cat_col> (the X-axis grouping), C<seg_col> (the stacking segment), and 4325: C<val_col> (the numeric measure). Multiple rows sharing the same 4326: cat+seg combination are summed. 4327: 4328: Accepted C<\%opts> keys: 4329: 4330: =over 4 4331: 4332: =item * C<cat_col> (string, default C<'cat'>) - row key for the X-axis category. 4333: 4334: =item * C<seg_col> (string, default C<'seg'>) - row key for the stacking segment. 4335: 4336: =item * C<val_col> (string, default C<'val'>) - row key for the numeric value. 4337: 4338: =item * C<orient> (string C<'v'> or C<'h'>, default C<'v'>) - bar orientation. 4339:Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_4106_2: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomesMutants (Total: 2, Killed: 2, Survived: 0)
4340: =item * C<scheme> (string, default C<'tableau10'>) - D3 categorical colour scheme. 4341: Supported: C<tableau10>, C<category10>, C<set2>, C<set3>, C<paired>. 4342: 4343: =item * C<x_label> (string, optional) - axis label shown below the category axis. 4344: 4345: =item * C<sort> (string, optional) - sort categories: C<'value'> (desc total) 4346: or C<'label'> (alpha). 4347: 4348: =item * C<id> (string, optional) - override the default C<svg_id> value. 4349: 4350: =item * C<responsive> (boolean, optional) - SVG responsive sizing override. 4351: 4352: =back 4353: 4354: Returns C<{ svg_id =E<gt> 'stacked_bar', html =E<gt> Str }>. 4355: 4356: =head3 Errors 4357: 4358: =over 4 4359: 4360: =item * Throws C<Data must be an array of hashes> when C<$data> is not an ARRAY reference. 4361: 4362: =item * Throws C<Each data point must be a hash reference> when a row is not a hashref. 4363: 4364: =item * Throws C<Value must be numeric> when a value field is not a number. 4365: 4366: =item * Throws C<orient must be 'v' or 'h'> for an invalid C<orient> value. 4367: 4368: =item * Throws C<sort must be 'value' or 'label'> for an invalid C<sort> value. 4369: 4370: =back 4371: 4372: =head3 API SPECIFICATION 4373: 4374: =head4 Input 4375: 4376: { 4377: data => { type => 'arrayref' }, 4378: opts => { 4379: type => 'hashref', 4380: optional => 1, 4381: keys => { 4382: cat_col => { type => 'string', optional => 1, default => 'cat' }, 4383: seg_col => { type => 'string', optional => 1, default => 'seg' }, 4384: val_col => { type => 'string', optional => 1, default => 'val' }, 4385: orient => { type => 'string', optional => 1, default => 'v' }, 4386: scheme => { type => 'string', optional => 1, default => 'tableau10' }, 4387: x_label => { type => 'string', optional => 1, default => '' }, 4388: sort => { type => 'string', optional => 1 }, 4389: id => { type => 'string', optional => 1, default => 'stacked_bar' }, 4390: responsive => { type => 'boolean', optional => 1 }, 4391: }, 4392: }, 4393: } 4394: 4395: =head4 Output 4396: 4397: HashRef -- { svg_id => Str, html => Str } 4398: 4399: =cut 4400: 4401: sub render_stacked_bar_snippet 4402: { ●4403 → 4422 → 4431 4403: my ($self, $data, $opts) = @_; 4404: $opts //= {}; 4405: 4406: die 'Data must be an array of hashes' unless ref($data) eq 'ARRAY'; 4407: 4408: my $cat_col = $opts->{cat_col} // 'cat'; 4409: my $seg_col = $opts->{seg_col} // 'seg'; 4410: my $val_col = $opts->{val_col} // 'val'; 4411: my $orient = $opts->{orient} // 'v'; 4412: my $scheme = $opts->{scheme} // 'tableau10'; 4413: my $x_label = $opts->{x_label} // ''; 4414: my $sort_by = $opts->{sort}; 4415: 4416: die "orient must be 'v' or 'h'" 4417: unless $orient eq 'v' || $orient eq 'h'; 4418: die "sort must be 'value' or 'label'" 4419: if defined($sort_by) && $sort_by ne 'value' && $sort_by ne 'label'; 4420: 4421: my (%cats_seen, %segs_seen, %pivot, @cat_order); 4422: for my $row (@$data) { 4423: die 'Each data point must be a hash reference' unless ref($row) eq 'HASH'; 4424: die "Value must be numeric" unless looks_like_number($row->{$val_col} // 'x'); 4425: my ($cat, $seg, $val) = ($row->{$cat_col} // '', $row->{$seg_col} // '', $row->{$val_col} + 0); 4426: push @cat_order, $cat unless $cats_seen{$cat}++; 4427: $segs_seen{$seg} = 1; 4428: $pivot{$cat}{$seg} = ($pivot{$cat}{$seg} // 0) + $val; 4429: } 4430: ●4431 → 4434 → 4445 4431: my @segs = keys %segs_seen; 4432: my @cats = @cat_order; 4433: 4434: if (defined $sort_by && $sort_by eq 'label') { 4435: @cats = sort @cats; 4436: } elsif (defined $sort_by && $sort_by eq 'value') { 4437: @cats = sort { 4438: my ($ta, $tb) = (0, 0); 4439: $ta += ($pivot{$a}{$_} // 0) for @segs; 4440: $tb += ($pivot{$b}{$_} // 0) for @segs; 4441: $tb <=> $ta 4442: } @cats; 4443: } 4444: ●4445 → 4490 → 4532 4445: my @rows = map { 4446: my $cat = $_; 4447: my %r = (cat => $cat); 4448: $r{$_} = ($pivot{$cat}{$_} // 0) for @segs; 4449: \%r 4450: } @cats; 4451: 4452: my $json_data = $_JSON->encode(\@rows); 4453: my $json_segs = $_JSON->encode(\@segs); 4454: 4455: my $js_esc = sub { 4456: my $s = shift; 4457: $s =~ s/\\/\\\\/g; $s =~ s/"/\\"/g; 4458: $s =~ s/\n/\\n/g; $s =~ s/\r/\\r/g; 4459: return $s; 4460: }; 4461: 4462: my $x_label_esc = $js_esc->($x_label); 4463: my $svg_id = $opts->{id} // 'stacked_bar'; 4464: my $tip_id = $svg_id . '_tip'; 4465: my $width = $self->{width}; 4466: my $height = $self->{height}; 4467: my $svg_attrs = ($opts->{responsive} // $self->{responsive}) 4468: ? qq{viewBox="0 0 $width $height" width="100%" height="auto"} 4469: : qq{width="$width" height="$height"}; 4470: 4471: my $is_v = ($orient eq 'v') ? 1 : 0; 4472: my $margin_top = 20; 4473: my $margin_right = 130; 4474: my $margin_bottom = $is_v ? ($x_label ? 60 : 40) : 40; 4475: my $margin_left = $is_v ? 55 : 130; 4476: my $inner_w = $width - $margin_left - $margin_right; 4477: my $inner_h = $height - $margin_top - $margin_bottom; 4478: my $legend_x = $margin_left + $inner_w + 12; 4479: 4480: 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: my ($scales_js, $axes_js, $rect_attrs_js); 4490: if ($is_v) { 4491: $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: $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: $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: $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: $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: $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: 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;})
Mutants (Total: 2, Killed: 2, Survived: 0)
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;
Mutants (Total: 1, Killed: 1, Survived: 0)
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: return { svg_id => $svg_id, html => $html }; 4607: } 4608: 4609: =head2 render_combo_snippet 4610: 4611: my $result = $chart->render_combo_snippet(\@rows, \%opts); 4612: 4613: Generates an embeddable combo chart: bars for one measure on the left Y 4614: axis, a line for another measure on the right Y axis, sharing the same 4615: X (category) axis. 4616: 4617: Each element of C<\@rows> is a hashref with at least three keys named by 4618: C<opts>: C<x_col> (X-axis label), C<bar_col> (bar measure), and 4619: C<line_col> (line measure). 4620: 4621: Accepted C<\%opts> keys: 4622: 4623: =over 4 4624: 4625: =item * C<x_col> (string, default C<'x'>) - row key for the X-axis label. 4626: 4627: =item * C<bar_col> (string, default C<'bar_val'>) - row key for the bar measure. 4628: 4629: =item * C<line_col> (string, default C<'line_val'>) - row key for the line measure. 4630: 4631: =item * C<bar_label> (string, default C<'Bar'>) - legend/axis label for bars. 4632: 4633: =item * C<line_label> (string, default C<'Line'>) - legend/axis label for line. 4634: 4635: =item * C<bar_color> (string, optional CSS colour, default C<'steelblue'>) - bar fill. 4636: 4637: =item * C<line_color> (string, optional CSS colour, default C<'#e15759'>) - line stroke. 4638: 4639: =item * C<id> (string, optional) - override the default C<svg_id> value. 4640: 4641: =item * C<responsive> (boolean, optional) - SVG responsive sizing override. 4642: 4643: =back 4644: 4645: Returns C<{ svg_id =E<gt> 'combo_chart', html =E<gt> Str }>. 4646: 4647: =head3 Errors 4648: 4649: =over 4 4650: 4651: =item * Throws C<Data must be an array of hashes> when C<$data> is not an ARRAY reference. 4652: 4653: =item * Throws C<Each data point must be a hash reference> when a row is not a hashref. 4654: 4655: =item * Throws C<Bar value must be numeric> when a bar value field is not a number. 4656: 4657: =item * Throws C<Line value must be numeric> when a line value field is not a number. 4658: 4659: =back 4660: 4661: =head3 API SPECIFICATION 4662: 4663: =head4 Input 4664: 4665: { 4666: data => { type => 'arrayref' }, 4667: opts => { 4668: type => 'hashref', 4669: optional => 1, 4670: keys => { 4671: x_col => { type => 'string', optional => 1, default => 'x' }, 4672: bar_col => { type => 'string', optional => 1, default => 'bar_val' }, 4673: line_col => { type => 'string', optional => 1, default => 'line_val' }, 4674: bar_label => { type => 'string', optional => 1, default => 'Bar' }, 4675: line_label => { type => 'string', optional => 1, default => 'Line' }, 4676: bar_color => { type => 'string', optional => 1, default => 'steelblue' }, 4677: line_color => { type => 'string', optional => 1, default => '#e15759' }, 4678: id => { type => 'string', optional => 1, default => 'combo_chart' }, 4679: responsive => { type => 'boolean', optional => 1 }, 4680: }, 4681: }, 4682: } 4683: 4684: =head4 Output 4685: 4686: HashRef -- { svg_id => Str, html => Str } 4687: 4688: =cut 4689: 4690: sub render_combo_snippet 4691: { ●4692 → 4706 → 4717 4692: my ($self, $data, $opts) = @_; 4693: $opts //= {}; 4694: 4695: die 'Data must be an array of hashes' unless ref($data) eq 'ARRAY'; 4696: 4697: my $x_col = $opts->{x_col} // 'x'; 4698: my $bar_col = $opts->{bar_col} // 'bar_val'; 4699: my $line_col = $opts->{line_col} // 'line_val'; 4700: my $bar_label = $opts->{bar_label} // 'Bar'; 4701: my $line_label = $opts->{line_label} // 'Line'; 4702: my $bar_color = $opts->{bar_color} // 'steelblue'; 4703: my $line_color = $opts->{line_color} // '#e15759'; 4704: 4705: my @points; 4706: for my $row (@$data) { 4707: die 'Each data point must be a hash reference' unless ref($row) eq 'HASH'; 4708: die 'Bar value must be numeric' unless looks_like_number($row->{$bar_col} // 'x'); 4709: 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: line => $row->{$line_col} + 0, 4714: }; 4715: } 4716: 4717: my $json_data = $_JSON->encode(\@points); 4718: 4719: my $js_esc = sub { 4720: my $s = shift; 4721: $s =~ s/\\/\\\\/g; $s =~ s/"/\\"/g; 4722: $s =~ s/\n/\\n/g; $s =~ s/\r/\\r/g; 4723: return $s; 4724: }; 4725: 4726: my $bar_label_esc = $js_esc->($bar_label); 4727: my $line_label_esc = $js_esc->($line_label); 4728: my $bar_color_esc = $js_esc->($bar_color); 4729: my $line_color_esc = $js_esc->($line_color); 4730: 4731: my $svg_id = $opts->{id} // 'combo_chart'; 4732: my $tip_id = $svg_id . '_tip'; 4733: my $width = $self->{width}; 4734: my $height = $self->{height}; 4735: my $svg_attrs = ($opts->{responsive} // $self->{responsive}) 4736: ? qq{viewBox="0 0 $width $height" width="100%" height="auto"} 4737: : qq{width="$width" height="$height"}; 4738: 4739: my $margin_top = 20; 4740: my $margin_right = 70; 4741: my $margin_bottom = 55; 4742: my $margin_left = 60; 4743: my $inner_w = $width - $margin_left - $margin_right; 4744: my $inner_h = $height - $margin_top - $margin_bottom; 4745: 4746: 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()
Mutants (Total: 1, Killed: 1, Survived: 0)
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)")Mutants (Total: 4, Killed: 1, Survived: 3)
- NUM_BOUNDARY_4780_84_>: Numeric boundary flip >= to >
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- NUM_BOUNDARY_4780_84_<: Numeric boundary flip >= to <
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- NUM_BOUNDARY_4780_84_<=: Numeric boundary flip >= to <=
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );Mutants (Total: 2, Killed: 2, Survived: 0)
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: return { svg_id => $svg_id, html => $html }; 4857: } 4858: 4859: =head2 render_treemap_snippet 4860: 4861: my $result = $chart->render_treemap_snippet(\@rows_or_tree, \%opts); 4862: 4863: Generates an embeddable treemap (no page shell, no CDN tag). 4864: Leaf area is proportional to the numeric measure. 4865: 4866: Accepts two data formats: 4867: 4868: =over 4 4869: 4870: =item * B<Flat path> (arrayref of hashrefs): each row must have a C<path> 4871: key (e.g. C<'Root/A/X'>) and a C<value> key. The module converts this 4872: to a D3 stratify hierarchy in JS. Override the path separator with 4873: C<opts-E<gt>{path_sep}>. 4874: 4875: =item * B<Nested tree> (hashref): a pre-built D3-compatible hierarchy 4876: with C<name>/C<children>/C<value> structure. Passed directly to 4877: C<d3.hierarchy()>. 4878: 4879: =back 4880: 4881: Accepted C<\%opts> keys: 4882: 4883: =over 4 4884: 4885: =item * C<path_sep> (string, default C<'/'>) - path separator (flat format only). 4886: 4887: =item * C<scheme> (string, default C<'tableau10'>) - D3 categorical colour scheme. 4888: Supported: C<tableau10>, C<category10>, C<set2>, C<set3>, C<paired>. 4889: 4890: =item * C<label> (boolean, default C<1>) - show node labels inside cells. 4891: 4892: =item * C<id> (string, optional) - override the default C<svg_id> value. 4893: 4894: =item * C<responsive> (boolean, optional) - SVG responsive sizing override. 4895: 4896: =back 4897: 4898: Returns C<{ svg_id =E<gt> 'treemap', html =E<gt> Str }>. 4899:
Mutants (Total: 2, Killed: 2, Survived: 0)
4900: =head3 Errors 4901: 4902: =over 4 4903: 4904: =item * Throws C<Data must be an array of hashes or a hash tree> 4905: when C<$data> is neither an ARRAY nor a HASH reference. 4906: 4907: =item * Throws C<Each flat row must be a hash reference> when a flat row is not a hashref. 4908: 4909: =item * Throws C<Each flat row must have a 'path' key> when a flat row lacks C<path>. 4910: 4911: =item * Throws C<Each flat row must have a numeric 'value' key> when a value is not numeric. 4912: 4913: =back 4914:
Mutants (Total: 2, Killed: 2, Survived: 0)
4915: =head3 API SPECIFICATION 4916: 4917: =head4 Input 4918: 4919: { 4920: data => { type => 'arrayref | hashref' }, 4921: opts => {
Mutants (Total: 2, Killed: 2, Survived: 0)
4922: type => 'hashref', 4923: optional => 1, 4924: keys => { 4925: path_sep => { type => 'string', optional => 1, default => '/' }, 4926: scheme => { type => 'string', optional => 1, default => 'tableau10' }, 4927: label => { type => 'boolean', optional => 1, default => 1 }, 4928: id => { type => 'string', optional => 1, default => 'treemap' }, 4929: responsive => { type => 'boolean', optional => 1 }, 4930: }, 4931: }, 4932: } 4933: 4934: =head4 Output 4935: 4936: HashRef -- { svg_id => Str, html => Str } 4937:
4938: =cut 4939: 4940: sub render_treemap_snippet 4941: { ●4942 → 4960 → 5002 4942: my ($self, $data, $opts) = @_; 4943: $opts //= {}; 4944: 4945: die 'Data must be an array of hashes or a hash tree' 4946: unless ref($data) eq 'ARRAY' || ref($data) eq 'HASH'; 4947: 4948: my $path_sep = $opts->{path_sep} // '/'; 4949: my $scheme = $opts->{scheme} // 'tableau10'; 4950: my $label = exists($opts->{label}) ? ($opts->{label} ? 1 : 0) : 1; 4951: 4952: my $js_esc = sub { 4953: my $s = shift; 4954: $s =~ s/\\/\\\\/g; $s =~ s/"/\\"/g; 4955: $s =~ s/\n/\\n/g; $s =~ s/\r/\\r/g; 4956: return $s; 4957: }; 4958: 4959: my ($json_data, $build_js); 4960: if (ref($data) eq 'ARRAY') { 4961: for my $row (@$data) { 4962: die 'Each flat row must be a hash reference' unless ref($row) eq 'HASH'; 4963: 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: unless looks_like_number($row->{value} // 'x'); 4966: } 4967: my @flat = map { { path => "$_->{path}", value => $_->{value} + 0 } } @$data; 4968: $json_data = $_JSON->encode(\@flat); 4969: my $sep_esc = $js_esc->($path_sep); 4970: $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: $json_data = $_JSON->encode($data); 4995: $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: my $svg_id = $opts->{id} // 'treemap'; 5003: my $tip_id = $svg_id . '_tip'; 5004: my $width = $self->{width}; 5005: my $height = $self->{height}; 5006: my $svg_attrs = ($opts->{responsive} // $self->{responsive}) 5007: ? qq{viewBox="0 0 $width $height" width="100%" height="auto"} 5008: : qq{width="$width" height="$height"}; 5009: 5010: 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: 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: 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: return { svg_id => $svg_id, html => $html }; 5085: } 5086: 5087: =head2 render_histogram_snippet 5088: 5089: my $result = $chart->render_histogram_snippet(\@values, \%opts); 5090: 5091: Generates an embeddable frequency histogram (no page shell, no CDN tag). 5092: C<\@values> is an arrayref of numeric scalars (already extracted and 5093: coerced by the caller). 5094: 5095: Accepted C<\%opts> keys: 5096: 5097: =over 4 5098: 5099: =item * C<bins> (integer or C<undef>, default C<20>) - number of bins. 5100: Pass C<undef> for automatic bin count using Sturges' rule: 5101: C<ceil(log2(n) + 1)>. Ignored when C<binned> is set. 5102: 5103: =item * C<binned> (boolean, default 0) - treat C<$data> as pre-computed bins 5104: rather than as raw values. Each element is a hashref with a C<count> and 5105: either a C<label> or a numeric C<lo>/C<hi> pair from which the label C<[lo,hi)> 5106: is derived: 5107: 5108: { label => '[0,10)', count => 3 } 5109: { lo => 10, hi => 20, count => 5 } 5110: 5111: The caller owns the binning, so C<d3.bin()> is never called: one bar is drawn 5112: per entry, in the order supplied, and the supplied counts are authoritative. 5113: 5114: =item * C<scheme> (string, optional) - D3 categorical colour scheme (for example 5115: C<'tableau10'> or C<'category10'>) whose first colour is used as the bar fill 5116: when C<color> is not given. 5117: 5118: =item * C<x_label> (string, optional) - label shown below the X axis. 5119: 5120: =item * C<color> (string, default C<'steelblue'>) - bar fill colour. 5121: 5122: =item * C<id> (string, optional) - override the default C<svg_id> value. 5123: 5124: =item * C<responsive> (boolean, optional) - SVG responsive sizing override. 5125: 5126: =back 5127: 5128: Returns C<{ svg_id =E<gt> 'histogram', html =E<gt> Str }>. 5129: 5130: =head3 Errors 5131: 5132: =over 4 5133: 5134: =item * Throws C<Data must be an array of numeric values> when C<$data> is 5135: not an ARRAY reference. 5136: 5137: =item * Throws C<Each value must be numeric> when any element is non-numeric. 5138: 5139: =item * Throws C<bins must be a positive integer> when C<bins> is defined 5140: but not a positive integer. 5141: 5142: =item * Throws C<Data must contain at least one bin> when C<binned> is set and 5143: C<$data> holds no bins. 5144: 5145: =item * Throws C<Each pre-binned entry must be a hash reference> when a 5146: pre-binned element is not a hashref. 5147: 5148: =item * Throws C<Bin count must be numeric> when a pre-binned C<count> is 5149: missing or not numeric. 5150: 5151: =item * Throws C<Bin count must not be negative> when a pre-binned C<count> is 5152: negative. 5153: 5154: =item * Throws C<Each pre-binned entry must have a label or numeric lo and hi> 5155: when a pre-binned entry has neither a C<label> nor a numeric C<lo>/C<hi> pair. 5156: 5157: =back 5158: 5159: =head3 API SPECIFICATION 5160: 5161: =head4 Input 5162: 5163: { 5164: data => { type => 'arrayref' }, 5165: opts => { 5166: type => 'hashref', 5167: optional => 1, 5168: keys => { 5169: bins => { type => 'integer', optional => 1, default => 20 }, 5170: binned => { type => 'boolean', optional => 1, default => 0 }, 5171: x_label => { type => 'string', optional => 1, default => '' }, 5172: color => { type => 'string', optional => 1, default => 'steelblue' }, 5173: scheme => { type => 'string', optional => 1 }, 5174: id => { type => 'string', optional => 1, default => 'histogram' }, 5175: responsive => { type => 'boolean', optional => 1 }, 5176: }, 5177: }, 5178: } 5179: 5180: =head4 Output 5181: 5182: HashRef -- { svg_id => Str, html => Str } 5183: 5184: =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 → 5203 → 5223 5191: my ($self, $bins, $opts) = @_; 5192: 5193: my $js_esc = sub { 5194: my $s = shift; 5195: $s =~ s/\\/\\\\/g; $s =~ s/"/\\"/g; 5196: $s =~ s/\n/\\n/g; $s =~ s/\r/\\r/g; 5197: return $s; 5198: }; 5199: 5200: die 'Data must contain at least one bin' unless @$bins; 5201: 5202: my @norm; 5203: for my $b (@$bins) { 5204: die 'Each pre-binned entry must be a hash reference' 5205: unless ref($b) eq 'HASH'; 5206: die 'Bin count must be numeric' 5207: unless defined($b->{count}) && looks_like_number($b->{count}); 5208: die 'Bin count must not be negative' unless $b->{count} + 0 >= 0; 5209: 5210: my $lo = (defined($b->{lo}) && looks_like_number($b->{lo})) ? $b->{lo} + 0 : undef; 5211: my $hi = (defined($b->{hi}) && looks_like_number($b->{hi})) ? $b->{hi} + 0 : undef; 5212: 5213: my $label = $b->{label}; 5214: if(!defined($label) || !length($label)) { 5215: die 'Each pre-binned entry must have a label or numeric lo and hi' 5216: unless defined($lo) && defined($hi); 5217: $label = sprintf('[%g,%g)', $lo, $hi); 5218: } 5219: 5220: push @norm, { label => "$label", count => $b->{count} + 0 }; 5221: } 5222: ●5223 → 5246 → 5253 5223: my $json_data = $_JSON->encode(\@norm); 5224: 5225: my $x_label = $opts->{x_label} // ''; 5226: my $svg_id = $opts->{id} // 'histogram'; 5227: my $tip_id = $svg_id . '_tip'; 5228: my $width = $self->{width}; 5229: my $height = $self->{height}; 5230: my $svg_attrs = ($opts->{responsive} // $self->{responsive}) 5231: ? qq{viewBox="0 0 $width $height" width="100%" height="auto"} 5232: : qq{width="$width" height="$height"}; 5233: 5234: my $margin_bottom = $x_label ? 60 : 40; 5235: my $margin_left = 55; 5236: my $margin_top = 20; 5237: my $margin_right = 20; 5238: my $inner_w = $width - $margin_left - $margin_right; 5239: my $inner_h = $height - $margin_top - $margin_bottom; 5240: 5241: 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: my $fill_js = '"steelblue"'; 5246: if(defined $opts->{color}) { 5247: $fill_js = '"' . $js_esc->($opts->{color}) . '"'; 5248: } elsif(defined $opts->{scheme}) { 5249: (my $scheme_js = $opts->{scheme}) =~ s/^([a-z])/\u$1/; 5250: $fill_js = "d3.scheme${scheme_js}[0]"; 5251: } 5252: 5253: 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: 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: return { svg_id => $svg_id, html => $html }; 5326: } 5327: 5328: sub render_histogram_snippet 5329: { ●5330 → 5336 → 5340 5330: my ($self, $data, $opts) = @_; 5331: $opts //= {}; 5332: 5333: 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: if ($opts->{binned}) { 5337: return $self->_render_histogram_binned($data, $opts); 5338: } 5339: ●5340 → 5341 → 5346 5340: my $bins_opt = exists($opts->{bins}) ? $opts->{bins} : 20; 5341: if (defined $bins_opt) { 5342: 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 → 5347 → 5352 5346: my @values; 5347: for my $v (@$data) { 5348: die 'Each value must be numeric' unless looks_like_number($v // 'x'); 5349: push @values, $v + 0; 5350: } 5351: 5352: my $json_data = $_JSON->encode(\@values); 5353: 5354: my $js_esc = sub { 5355: my $s = shift; 5356: $s =~ s/\\/\\\\/g; $s =~ s/"/\\"/g; 5357: $s =~ s/\n/\\n/g; $s =~ s/\r/\\r/g; 5358: return $s; 5359: }; 5360: 5361: my $x_label = $opts->{x_label} // ''; 5362: my $color = $opts->{color} // 'steelblue'; 5363: 5364: my $x_label_esc = $js_esc->($x_label); 5365: my $color_esc = $js_esc->($color); 5366: 5367: my $svg_id = $opts->{id} // 'histogram'; 5368: my $tip_id = $svg_id . '_tip'; 5369: my $width = $self->{width}; 5370: my $height = $self->{height}; 5371: my $svg_attrs = ($opts->{responsive} // $self->{responsive}) 5372: ? qq{viewBox="0 0 $width $height" width="100%" height="auto"} 5373: : qq{width="$width" height="$height"}; 5374: 5375: my $margin_top = 20; 5376: my $margin_right = 20; 5377: my $margin_bottom = $x_label ? 60 : 40; 5378: my $margin_left = 55; 5379: my $inner_w = $width - $margin_left - $margin_right; 5380: my $inner_h = $height - $margin_top - $margin_bottom; 5381: 5382: my $bins_js = defined($bins_opt) 5383: ? "var numBins = $bins_opt;" 5384: : 'var numBins = Math.ceil(Math.log2(values.length) + 1);'; 5385: 5386: 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: 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: return { svg_id => $svg_id, html => $html }; 5454: } 5455: 5456: sub _preamble 5457: { 5458: my $html = <<'HTML'; 5459: <!DOCTYPE html> 5460: <html lang="en"> 5461: HTML 5462: return $html; 5463: } 5464: 5465: sub _head 5466: { 5467: my $self = shift; 5468: 5469: 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: return $html; 5478: } 5479: 5480: sub _svg_attrs 5481: { 5482: my ($self, $override) = @_; 5483: my $responsive = defined($override) ? $override : $self->{responsive}; 5484: 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: my ($self, $opts, $container_id, $script) = @_; 5495: my $w = $self->{width}; 5496: my $h = $self->{height}; 5497: my $svg_attrs = ($opts->{responsive} // $self->{responsive}) 5498: ? qq{viewBox="0 0 $w $h" width="100%" height="auto"} 5499: : qq{width="$w" height="$h"}; 5500: 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: =head1 SUPPORT 5510: 5511: This module is provided as-is without any warranty. 5512: 5513: Please report any bugs or feature requests to C<bug-html-d3 at rt.cpan.org>, 5514: or through the web interface at 5515: L<http://rt.cpan.org/NoAuth/ReportBug.html?Queue=HTML-D3>. 5516: I will be notified, and then you'll 5517: automatically be notified of progress on your bug as I make changes. 5518: 5519: You can find documentation for this module with the perldoc command. 5520: 5521: perldoc HTML::D3 5522: 5523: You can also look for information at: 5524: 5525: =head1 BUGS 5526: 5527: It would help to have the render routine to return the head and body components separately. 5528: 5529: =head1 SEE ALSO 5530: 5531: =over 4 5532: 5533: =item * L<Configure an Object at Runtime|Object::Configure> 5534: 5535: =item * L<Test Dashboard|https://nigelhorne.github.io/HTML-D3/coverage/> 5536: 5537: =back 5538: 5539: =head1 AUTHOR 5540: 5541: Nigel Horne <njh@nigelhorne.com> 5542: 5543: =encoding UTF-8 5544: 5545: =head1 FORMAL SPECIFICATION 5546: 5547: =head2 render_bar_chart 5548: 5549: render_bar_chart : HTML::D3 à (ArrayRef | undef) â Str ⪠⥠5550: 5551: pre data = undef â die "Data is not optional" 5552: pre ref(data) â 'ARRAY' â die "Data must be an array of arrays" 5553: post result â Str 5554: post "<!DOCTYPE" â result 5555: post â d â data . d[0] â result 5556: 5557: =head2 render_line_chart 5558: 5559: render_line_chart : HTML::D3 à (ArrayRef | undef) â Str ⪠⥠5560: 5561: pre ref(data) â 'ARRAY' â die "Data must be an array of arrays" 5562: post result â Str 5563: post "<!DOCTYPE" â result 5564: post "d3.scalePoint" â result â§ "d3.line()" â result 5565: 5566: =head2 render_animated_bar_chart 5567: 5568: render_animated_bar_chart : HTML::D3 à (ArrayRef | undef) â Str ⪠⥠5569: 5570: pre data = undef â die "Data is not optional" 5571: pre ref(data) â 'ARRAY' â die "Data must be an array of arrays" 5572: post result â Str 5573: post "<!DOCTYPE" â result 5574: post ".transition()" â result â§ ".delay(" â result 5575: 5576: =head2 render_animated_line_chart 5577: 5578: render_animated_line_chart : HTML::D3 à (ArrayRef | undef) â Str ⪠⥠5579: 5580: pre ref(data) â 'ARRAY' â die "Data must be an array of arrays" 5581: post result â Str 5582: post "<!DOCTYPE" â result 5583: post "stroke-dashoffset" â result â§ "d3.easeLinear" â result 5584: 5585: =head2 render_pie_chart 5586: 5587: render_pie_chart : HTML::D3 à (ArrayRef | undef) à (HashRef | undef) â Str ⪠⥠5588: 5589: pre data = undef â die "Data is not optional" 5590: pre ref(data) â 'ARRAY' â die "Data must be an array of arrays" 5591: post result â Str 5592: post "<!DOCTYPE" â result 5593: post "d3.pie()" â result â§ "d3.arc()" â result â§ "d3.schemeCategory10" â result 5594: post opts.separator = S â " S " â result (SVG legend: label S value) 5595: 5596: =head2 render_animated_pie_chart 5597: 5598: render_animated_pie_chart : HTML::D3 à (ArrayRef | undef) à (HashRef | undef) â Str ⪠⥠5599: 5600: pre data = undef â die "Data is not optional" 5601: pre ref(data) â 'ARRAY' â die "Data must be an array of arrays" 5602: post result â Str 5603: post "<!DOCTYPE" â result 5604: post "attrTween" â result â§ "d3.interpolate" â result 5605: post opts.separator = S â " S " â result (SVG legend: label S value) 5606: 5607: =head2 render_line_chart_snippet 5608: 5609: render_line_chart_snippet : 5610: HTML::D3 à (ArrayRef | undef) à (HashRef | undef) â HashRef ⪠⥠5611: 5612: pre ref(data) â 'ARRAY' â die "Data must be an array of arrays" 5613: post result â HashRef 5614: post result.svg_id = opts.id // "chart" 5615: post result.html â Str 5616: post "<!DOCTYPE" â result.html 5617: 5618: =head2 render_zoomable_line_chart_snippet 5619: 5620: render_zoomable_line_chart_snippet : 5621: HTML::D3 à (ArrayRef | undef) à (HashRef | undef) â HashRef ⪠⥠5622: 5623: pre ref(data) â 'ARRAY' â die "Data must be an array of arrays" 5624: post result â HashRef 5625: post result.svg_id = opts.id // "chart" 5626: post result.html â Str 5627: post "<!DOCTYPE" â result.html 5628: post "d3.brushX()" â result.html 5629: post opts.animated = 1 â "stroke-dashoffset" â result.html 5630: â§ "initialDrawDone" â result.html 5631: 5632: =head2 render_pie_chart_snippet 5633: 5634: render_pie_chart_snippet : 5635: HTML::D3 à (ArrayRef | undef) à (HashRef | undef) â HashRef ⪠⥠5636: 5637: pre ref(data) â 'ARRAY' â die "Data must be an array of arrays" 5638: pre â d â data . d[1] < 0 â d[1] := |d[1]| -- negative â absolute 5639: pre â d â data . d[1] = 0 â d â result -- zero â omitted 5640: post result â HashRef 5641: post result.svg_id = "pie_chart" 5642: post result.html â Str 5643: post "<!DOCTYPE" â result.html 5644: post "d3.pie()" â result.html â§ "schemeTableau10" â result.html 5645: post opts.animated = 1 â "attrTween" â result.html 5646: â§ "initialDrawDone" â result.html 5647: post opts.donut = 1 â "innerRadius" â result.html 5648: post opts.max_slices = N â§ N ⥠2 â§ |data| > N 5649: â |result_slices| = N â§ "Other" â result_labels 5650: post opts.separator = S â " S " â result.html (HTML legend: label S value (pct%)) 5651: 5652: =head2 render_heatmap_snippet 5653: 5654: render_heatmap_snippet : 5655: HTML::D3 à (ArrayRef | undef) à (HashRef | undef) â HashRef ⪠⥠5656: 5657: pre ref(data) â 'ARRAY' â die "Data must be an array of arrays" 5658: pre â pt â data . ref(pt) â 'ARRAY' 5659: â die "Each data point must be an array reference" 5660: pre â pt â data . |pt| < 3 â die "Each data point must have at least 3 elements" 5661: pre â pt â data . defined(pt[2]) ⧠¬numeric(pt[2]) 5662: â die "Value must be numeric" 5663: pre opts.color_scheme = S â§ S â {YlOrRd,Blues,Greens,Purples,RdPu,YlGnBu} 5664: â die "Unknown color_scheme: S" 5665: pre opts.cell_padding = N â§ (N < 0 ⨠N > 8) 5666: â die "cell_padding must be between 0 and 8" 5667: pre â pt â data . pt[2] = undef â pt â result -- undef rows skipped 5668: pre â ptâ,ptâ â data . ptâ[0]=ptâ[0] â§ ptâ[1]=ptâ[1] 5669: â last-write wins 5670: post result â HashRef 5671: post result.svg_id = "heatmap" 5672: post result.html â Str 5673: post "<!DOCTYPE" â result.html 5674: post "scaleSequential" â result.html 5675: post opts.animated = 1 â "prefers-reduced-motion" â result.html 5676: post opts.legend = 1 â "linearGradient" â result.html 5677: 5678: =head2 render_bar_chart_snippet 5679: 5680: render_bar_chart_snippet : 5681: HTML::D3 à (ArrayRef | undef) à (HashRef | undef) â HashRef ⪠⥠5682: 5683: pre ref(data) â 'ARRAY' â die "Data must be an array of arrays" 5684: pre â pt â data . ref(pt) â 'ARRAY' 5685: â die "Each data point must be an array reference" 5686: pre â pt â data . |pt| < 2 â die "Each data point must have at least 2 elements" 5687: pre â pt â data . defined(pt[1]) ⧠¬numeric(pt[1]) 5688: â die "Value must be numeric" 5689: pre opts.orientation â {'vertical','horizontal'} 5690: â die "orientation must be 'vertical' or 'horizontal'" 5691: pre opts.sort_bars â {'value','label','none'} 5692: â die "sort_bars must be 'value', 'label', or 'none'" 5693: pre â pt â data . pt[1] < 0 â pt[1] := |pt[1]| -- negative â absolute 5694: pre â pt â data . pt[1] = undef â pt â result -- undef rows skipped 5695: post result â HashRef 5696: post result.svg_id = opts.id // "bar_chart" 5697: post result.html â Str 5698: post "<!DOCTYPE" â result.html 5699: post "d3.scaleBand" â result.html â§ "d3.scaleLinear" â result.html 5700: post opts.animated = 1 â "prefers-reduced-motion" â result.html 5701: post opts.color = 'categorical' â "schemeTableau10" â result.html 5702: post opts.max_bars = N â§ N ⥠2 â§ |data| > N 5703: â |result_bars| = N â§ "Other" â result_labels 5704: 5705: =head2 render_multi_series_line_chart_snippet 5706: 5707: render_multi_series_line_chart_snippet : 5708: HTML::D3 à (ArrayRef | undef) à (HashRef | undef) â HashRef ⪠⥠5709: 5710: pre ref(data) â 'ARRAY' â die "Data must be an array of series" 5711: pre â s â data . ref(s) â 'HASH' 5712: â die "Each series must be a hashref with name and data" 5713: pre â s â data . ¬defined(s.name) ⨠ref(s.data) â 'ARRAY' 5714: â die "Each series must be a hashref with name and data" 5715: post result â HashRef 5716: post result.svg_id = opts.id // "chart" 5717: post result.html â Str 5718: post "<!DOCTYPE" â result.html 5719: post "d3.scalePoint" â result.html â§ "d3.scaleLinear" â result.html 5720: post "d3.line()" â result.html 5721: post #{path â result.html : class = "msl-line"} = |data| -- one path per series 5722: post opts.x_label â '' â "msl-axis-label" â result.html 5723: post opts.y_label â '' â "msl-axis-label" â result.html 5724: post opts.scheme = S â "scheme" + capitalise(S) â result.html 5725: post opts.animated = 1 â "stroke-dashoffset" â result.html 5726: â§ "prefers-reduced-motion" â result.html 5727: 5728: =head2 render_scatter_chart_snippet 5729: 5730: render_scatter_chart_snippet : 5731: HTML::D3 à (ArrayRef | undef) à (HashRef | undef) â HashRef ⪠⥠5732: 5733: pre ref(data) â 'ARRAY' â die "Data must be an array of arrays" 5734: pre â pt â data . ref(pt) â 'ARRAY' 5735: â die "Each data point must be an array reference" 5736: pre â pt â data . |pt| < 2 â die "Each data point must have at least 2 elements" 5737: pre â pt â data . ¬numeric(pt[0]) 5738: â die "X value must be numeric" 5739: pre â pt â data . ¬numeric(pt[1]) 5740: â die "Y value must be numeric" 5741: post result â HashRef 5742: post result.svg_id = opts.id // "scatter_chart" 5743: post result.html â Str 5744: post "<!DOCTYPE" â result.html 5745: post "d3.scaleLinear" â result.html 5746: post opts.animated = 1 â "prefers-reduced-motion" â result.html 5747: post opts.colour_by_series = 1 â§ â pt â data . defined(pt[3].series) 5748: â "scaleOrdinal" â result.html 5749: â§ "seriesNames" â result.html 5750: â§ legend drawn (margin right = 140) 5751: post â pt â data . defined(pt[3].size) 5752: â "d3.scaleSqrt" â result.html â§ "rScale" â result.html 5753: post opts.radius_range = [A,B] 5754: â "range([A,B])" â result.html 5755: post â pt â data . pt[3].size = undef 5756: â "rScale" â result.html -- fixed radius 5757: 5758: =head2 render_table_snippet 5759: 5760: render_table_snippet : 5761: HTML::D3 à (ArrayRef | undef) à (HashRef | undef) â HashRef ⪠⥠5762: 5763: pre ref(data) â 'ARRAY' â die "Data must be an array of arrays" 5764: pre |data| = 0 â die "Data must have at least one row (header row)" 5765: pre â row â data . ref(row) â 'ARRAY' 5766: â die "Each row must be an array reference" 5767: post result â HashRef 5768: post result.table_id = opts.id // "data_table" 5769: post result.html â Str 5770: post "<!DOCTYPE" â result.html 5771: post opts.sortable â 0 â "dt-sortable" â result.html 5772: 5773: =head2 render_stacked_bar_snippet 5774: 5775: render_stacked_bar_snippet : 5776: HTML::D3 à (ArrayRef | undef) à (HashRef | undef) â HashRef ⪠⥠5777: 5778: pre ref(data) â 'ARRAY' â die "Data must be an array of hashes" 5779: pre â row â data . ref(row) â 'HASH' 5780: â die "Each data point must be a hash reference" 5781: pre â row â data . ¬numeric(row[val_col]) 5782: â die "Value must be numeric" 5783: pre opts.orient â {'v','h'} â die "orient must be 'v' or 'h'" 5784: pre defined(opts.sort) â§ opts.sort â {'value','label'} 5785: â die "sort must be 'value' or 'label'" 5786: post result â HashRef 5787: post result.svg_id = opts.id // "stacked_bar" 5788: post result.html â Str 5789: post "<!DOCTYPE" â result.html 5790: post "d3.stack()" â result.html 5791: post "SCHEMES" â result.html 5792: 5793: =head2 render_combo_snippet 5794: 5795: render_combo_snippet : 5796: HTML::D3 à (ArrayRef | undef) à (HashRef | undef) â HashRef ⪠⥠5797: 5798: pre ref(data) â 'ARRAY' â die "Data must be an array of hashes" 5799: pre â row â data . ref(row) â 'HASH' 5800: â die "Each data point must be a hash reference" 5801: pre â row â data . ¬numeric(row[bar_col]) 5802: â die "Bar value must be numeric" 5803: pre â row â data . ¬numeric(row[line_col]) 5804: â die "Line value must be numeric" 5805: post result â HashRef 5806: post result.svg_id = opts.id // "combo_chart" 5807: post result.html â Str 5808: post "<!DOCTYPE" â result.html 5809: post "d3.scaleBand" â result.html â§ "d3.scaleLinear" â result.html 5810: post "axisRight" â result.html 5811: post "</b>" â result.html â§ "<\/b>" â result.html 5812: 5813: =head2 render_treemap_snippet 5814: 5815: render_treemap_snippet : 5816: HTML::D3 à (ArrayRef | HashRef | undef) à (HashRef | undef) â HashRef ⪠⥠5817: 5818: pre ref(data) â {'ARRAY','HASH'} 5819: â die "Data must be an array of hashes or a hash tree" 5820: pre ref(data) = 'ARRAY' â§ â row â data . ref(row) â 'HASH' 5821: â die "Each flat row must be a hash reference" 5822: pre ref(data) = 'ARRAY' â§ â row â data . ¬exists(row.path) 5823: â die "Each flat row must have a 'path' key" 5824: pre ref(data) = 'ARRAY' â§ â row â data . ¬numeric(row.value) 5825: â die "Each flat row must have a numeric 'value' key" 5826: post result â HashRef 5827: post result.svg_id = opts.id // "treemap" 5828: post result.html â Str 5829: post "<!DOCTYPE" â result.html 5830: post "d3.treemap()" â result.html 5831: post ref(data) = 'ARRAY' â "d3.stratify()" â result.html 5832: post ref(data) = 'HASH' â "d3.hierarchy(" â result.html 5833: 5834: =head2 render_histogram_snippet 5835: 5836: render_histogram_snippet : 5837: HTML::D3 à (ArrayRef | undef) à (HashRef | undef) â HashRef ⪠⥠5838: 5839: pre ref(data) â 'ARRAY' â die "Data must be an array of numeric values" 5840: pre â v â data . ¬numeric(v) â die "Each value must be numeric" 5841: pre defined(opts.bins) â§ (¬integer(opts.bins) ⨠opts.bins < 1) 5842: â die "bins must be a positive integer" 5843: pre opts.binned = 1 â§ |data| = 0 5844: â die "Data must contain at least one bin" 5845: pre opts.binned = 1 â§ â b â data . ref(b) â 'HASH' 5846: â die "Each pre-binned entry must be a hash reference" 5847: pre opts.binned = 1 â§ â b â data . ¬numeric(b.count) 5848: â die "Bin count must be numeric" 5849: pre opts.binned = 1 â§ â b â data . b.count < 0 5850: â die "Bin count must not be negative" 5851: pre opts.binned = 1 â§ â b â data . ¬defined(b.label) ⧠¬(numeric(b.lo) â§ numeric(b.hi)) 5852: â die "Each pre-binned entry must have a label or numeric lo and hi" 5853: pre opts.binned = 1 ⧠¬defined(b.label) â§ numeric(b.lo) â§ numeric(b.hi) 5854: â b.label := "[lo,hi)" 5855: post result â HashRef 5856: post result.svg_id = opts.id // "histogram" 5857: post result.html â Str 5858: post "<!DOCTYPE" â result.html 5859: post opts.binned â 1 â "d3.bin()" â result.html 5860: post opts.binned = 1 â "d3.bin()" â result.html â§ "scaleBand" â result.html 5861: post opts.color defined â c(clone) -- explicit colour is the bar fill 5862: post opts.scheme defined â "scheme" â result.html 5863: post opts.bins = undef â "Math.log2" â result.html 5864: post opts.x_label â '' â "hg-axis-label" â result.html 5865: 5866: =head2 render_line_chart_with_tooltips 5867: 5868: render_line_chart_with_tooltips : HTML::D3 à (ArrayRef | undef) â Str ⪠⥠5869: 5870: pre ref(data) â 'ARRAY' â die "Data must be an array of arrays" 5871: post result â Str 5872: post "<!DOCTYPE" â result 5873: post "mouseover" â result 5874: post "</b>" â result â§ "<\/b>" â result 5875: 5876: =head2 render_multi_series_line_chart_with_tooltips 5877: 5878: render_multi_series_line_chart_with_tooltips : HTML::D3 à (ArrayRef | undef) â Str ⪠⥠5879: 5880: pre ref(data) â 'ARRAY' â die "Data must be an array of hashes" 5881: post result â Str 5882: post "<!DOCTYPE" â result 5883: post "</b>" â result â§ "<\/b>" â result 5884: 5885: =head2 render_multi_series_line_chart_with_animated_tooltips 5886: 5887: render_multi_series_line_chart_with_animated_tooltips : HTML::D3 à (ArrayRef | undef) â Str ⪠⥠5888: 5889: pre ref(data) â 'ARRAY' â die "Data must be an array of hashes" 5890: post result â Str 5891: post "<!DOCTYPE" â result 5892: post "translateY" â result 5893: post "</b>" â result â§ "<\/b>" â result 5894: 5895: =head2 render_multi_series_line_chart_with_legends 5896: 5897: render_multi_series_line_chart_with_legends : HTML::D3 à (ArrayRef | undef) â Str ⪠⥠5898: 5899: pre ref(data) â 'ARRAY' â die "Data must be an array of hashes" 5900: post result â Str 5901: post "<!DOCTYPE" â result 5902: post ".legend" â result 5903: 5904: =head2 render_multi_series_line_chart_with_interactive_legends 5905: 5906: render_multi_series_line_chart_with_interactive_legends : HTML::D3 à (ArrayRef | undef) â Str ⪠⥠5907: 5908: pre ref(data) â 'ARRAY' â die "Data must be an array of hashes" 5909: post result â Str 5910: post "<!DOCTYPE" â result 5911: post "isVisible" â result 5912: post "isVisible ? 0 : 1" â result 5913: post ".legend" â result 5914: 5915: =head1 LICENSE AND COPYRIGHT 5916: 5917: Copyright 2025-2026 Nigel Horne. 5918: 5919: Usage is subject to the GPL2 licence terms. 5920: If you use it, 5921: please let me know. 5922: 5923: =cut 5924: 5925: 1;Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_4937_2: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes🧪 Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_4937_2: Replace return expression with undef
LOW: Mutation survived, but impact may be minor🧪 Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );