| File: | blib/lib/HTML/D3.pm |
| Coverage: | 97.2% |
| line | stmt | bran | cond | sub | time | code |
|---|---|---|---|---|---|---|
| 1 | package HTML::D3; | |||||
| 2 | ||||||
| 3 | 10 10 10 | 147437 6 97 | use strict; | |||
| 4 | 10 10 10 | 12 6 141 | use warnings; | |||
| 5 | ||||||
| 6 | 10 10 10 | 14 7 350 | use JSON::MaybeXS; | |||
| 7 | 10 10 10 | 2891 907723 182 | use Object::Configure; | |||
| 8 | 10 10 10 | 32 6 149 | use Params::Get; | |||
| 9 | 10 10 10 | 27 10 7334 | use Scalar::Util; | |||
| 10 | ||||||
| 11 | # TODO: add animated tooltips to charts with legends | |||||
| 12 | ||||||
| 13 - 21 | =head1 NAME HTML::D3 - A simple Perl module for generating charts using D3.js. =head1 VERSION Version 0.09 =cut | |||||
| 22 | ||||||
| 23 | our $VERSION = '0.09'; | |||||
| 24 | ||||||
| 25 - 79 | =head1 SYNOPSIS use HTML::D3; my $chart = HTML::D3->new( width => 1024, height => 768, title => 'Sample Bar Chart' ); my $data = [ ['Category 1', 10], ['Category 2', 20], ['Category 3', 30] ]; my $html = $chart->render_bar_chart($data); print $html; $chart = HTML::D3->new(title => 'Sales Data'); $data = [ ['Product A', 100], ['Product B', 150], ['Product C', 200] ]; $html = $chart->render_line_chart($data); print $html; =head1 DESCRIPTION HTML::D3 is a Perl module that provides functionality to create simple charts using D3.js. The module generates HTML and JavaScript code to render the chart in a web browser. =head1 METHODS =head2 new my $chart = HTML::D3->new(%args); Creates a new HTML::D3 object. Accepts the following optional arguments: =over 4 =item * C<width> - The width of the chart (default: 800). =item * C<height> - The height of the chart (default: 600). =item * C<title> - The title of the chart (default: 'Chart'). =back =cut | |||||
| 80 | ||||||
| 81 | # Constructor to initialize chart properties | |||||
| 82 | sub new | |||||
| 83 | { | |||||
| 84 | 14 | 1116258 | my $class = shift; | |||
| 85 | ||||||
| 86 | # Handle hash or hashref arguments | |||||
| 87 | 14 | 42 | my $params = Params::Get::get_params(undef, @_) || {}; | |||
| 88 | ||||||
| 89 | 14 | 273 | if(!defined($class)) { | |||
| 90 | 1 1 | 1 2 | if((scalar keys %{$params}) > 0) { | |||
| 91 | # Using HTML::D3->new(), not HTML::D3::new() | |||||
| 92 | 0 | 0 | carp(__PACKAGE__, ' use ->new() not ::new() to instantiate'); | |||
| 93 | 0 | 0 | return; | |||
| 94 | } | |||||
| 95 | # FIXME: this only works when no arguments are given | |||||
| 96 | 1 | 1 | $class = __PACKAGE__; | |||
| 97 | } elsif(Scalar::Util::blessed($class)) { | |||||
| 98 | # If $class is an object, clone it with new arguments | |||||
| 99 | 2 2 2 | 2 4 6 | return bless { %{$class}, %{$params} }, ref($class); | |||
| 100 | } | |||||
| 101 | ||||||
| 102 | 12 | 40 | $params = Object::Configure::configure($class, $params); | |||
| 103 | ||||||
| 104 | # Return the blessed object | |||||
| 105 | return bless { | |||||
| 106 | width => $params->{width} || 800, # Default chart width | |||||
| 107 | height => $params->{height} || 600, # Default chart height | |||||
| 108 | 12 | 204026 | title => $params->{title} || 'Chart', # Default chart title | |||
| 109 | }, $class; | |||||
| 110 | } | |||||
| 111 | ||||||
| 112 - 127 | =head2 render_bar_chart my $html = $chart->render_bar_chart($data); Generates HTML and JavaScript code to render a bar chart. Accepts the following arguments: =over 4 =item * C<$data> - An array reference containing data points. Each data point should be an array reference with two elements: the label (string) and the value (numeric). =back Returns a string containing the HTML and JavaScript code for the chart. =cut | |||||
| 128 | ||||||
| 129 | # Method to render a bar chart with given data | |||||
| 130 | sub render_bar_chart { | |||||
| 131 | 2 | 2562 | my ($self, $data) = @_; | |||
| 132 | ||||||
| 133 | 2 | 3 | die 'Data is not optional' if(!defined($data)); | |||
| 134 | ||||||
| 135 | # Validate input data to ensure it is an array of arrays | |||||
| 136 | 2 | 7 | die 'Data must be an array of arrays' unless ref($data) eq 'ARRAY'; | |||
| 137 | ||||||
| 138 | # Generate JSON representation of data | |||||
| 139 | my $json_data = encode_json([ | |||||
| 140 | 1 3 | 2 11 | map { { label => $_->[0], value => $_->[1] } } @$data | |||
| 141 | ]); | |||||
| 142 | ||||||
| 143 | # Generate HTML and D3.js JavaScript for rendering the bar chart | |||||
| 144 | 1 | 5 | my $html = $self->_preamble(); | |||
| 145 | 1 | 3 | $html .= $self->_head(); | |||
| 146 | 1 | 3 | $html .= <<"HTML"; | |||
| 147 | <body> | |||||
| 148 | <h1 style="text-align: center;">$self->{title}</h1> | |||||
| 149 | <svg id="chart" width="$self->{width}" height="$self->{height}" style="border: 1px solid black;"></svg> | |||||
| 150 | <script> | |||||
| 151 | const data = $json_data; | |||||
| 152 | ||||||
| 153 | const svg = d3.select("#chart"); | |||||
| 154 | const margin = { top: 20, right: 30, bottom: 40, left: 40 }; | |||||
| 155 | const width = $self->{width} - margin.left - margin.right; | |||||
| 156 | const height = $self->{height} - margin.top - margin.bottom; | |||||
| 157 | ||||||
| 158 | // Set up scales for x and y axes | |||||
| 159 | const x = d3.scaleBand() | |||||
| 160 | .domain(data.map(d => d.label)) | |||||
| 161 | .range([0, width]) | |||||
| 162 | .padding(0.1); | |||||
| 163 | ||||||
| 164 | const y = d3.scaleLinear() | |||||
| 165 | .domain([0, d3.max(data, d => d.value)]) | |||||
| 166 | .nice() | |||||
| 167 | .range([height, 0]); | |||||
| 168 | ||||||
| 169 | const chart = svg.append("g") | |||||
| 170 | .attr("transform", `translate(\${margin.left},\${margin.top})`); | |||||
| 171 | ||||||
| 172 | // Add bars to the chart | |||||
| 173 | chart.append("g") | |||||
| 174 | .selectAll("rect") | |||||
| 175 | .data(data) | |||||
| 176 | .join("rect") | |||||
| 177 | .attr("x", d => x(d.label)) | |||||
| 178 | .attr("y", d => y(d.value)) | |||||
| 179 | .attr("height", d => height - y(d.value)) | |||||
| 180 | .attr("width", x.bandwidth()) | |||||
| 181 | .attr("fill", "steelblue"); | |||||
| 182 | ||||||
| 183 | // Add the y-axis | |||||
| 184 | chart.append("g") | |||||
| 185 | .call(d3.axisLeft(y)); | |||||
| 186 | ||||||
| 187 | // Add the x-axis with labels rotated for better readability | |||||
| 188 | chart.append("g") | |||||
| 189 | .attr("transform", `translate(0,\${height})`) | |||||
| 190 | .call(d3.axisBottom(x)) | |||||
| 191 | .selectAll("text") | |||||
| 192 | .attr("transform", "rotate(-45)") | |||||
| 193 | .style("text-anchor", "end"); | |||||
| 194 | </script> | |||||
| 195 | </body> | |||||
| 196 | </html> | |||||
| 197 | HTML | |||||
| 198 | ||||||
| 199 | 1 | 3 | return $html; | |||
| 200 | } | |||||
| 201 | ||||||
| 202 - 217 | =head2 render_line_chart my $html = $chart->render_line_chart($data); Generates HTML and JavaScript code to render a line chart. Accepts the following arguments: =over 4 =item * C<$data> - An array reference containing data points. Each data point should be an array reference with two elements: the label (string) and the value (numeric). =back Returns a string containing the HTML and JavaScript code for the chart. =cut | |||||
| 218 | ||||||
| 219 | sub render_line_chart { | |||||
| 220 | 2 | 2637 | my ($self, $data) = @_; | |||
| 221 | ||||||
| 222 | # Validate input data | |||||
| 223 | 2 | 9 | die 'Data must be an array of arrays' unless ref($data) eq 'ARRAY'; | |||
| 224 | ||||||
| 225 | # Generate JSON for data | |||||
| 226 | my $json_data = encode_json([ | |||||
| 227 | 1 3 | 2 10 | map { { label => $_->[0], value => $_->[1] } } @$data | |||
| 228 | ]); | |||||
| 229 | ||||||
| 230 | # Generate HTML and D3.js code | |||||
| 231 | 1 | 4 | my $html = $self->_preamble(); | |||
| 232 | 1 | 2 | $html .= $self->_head(); | |||
| 233 | 1 | 4 | $html .= <<"HTML"; | |||
| 234 | <body> | |||||
| 235 | <h1 style="text-align: center;">$self->{title}</h1> | |||||
| 236 | <svg id="chart" width="$self->{width}" height="$self->{height}" style="border: 1px solid black;"></svg> | |||||
| 237 | <script> | |||||
| 238 | const data = $json_data; | |||||
| 239 | ||||||
| 240 | const svg = d3.select("#chart"); | |||||
| 241 | const margin = { top: 20, right: 30, bottom: 40, left: 40 }; | |||||
| 242 | const width = $self->{width} - margin.left - margin.right; | |||||
| 243 | const height = $self->{height} - margin.top - margin.bottom; | |||||
| 244 | ||||||
| 245 | const x = d3.scalePoint() | |||||
| 246 | .domain(data.map(d => d.label)) | |||||
| 247 | .range([0, width]); | |||||
| 248 | ||||||
| 249 | const y = d3.scaleLinear() | |||||
| 250 | .domain([0, d3.max(data, d => d.value)]) | |||||
| 251 | .nice() | |||||
| 252 | .range([height, 0]); | |||||
| 253 | ||||||
| 254 | const chart = svg.append("g") | |||||
| 255 | .attr("transform", `translate(\${margin.left},\${margin.top})`); | |||||
| 256 | ||||||
| 257 | // Draw line | |||||
| 258 | const line = d3.line() | |||||
| 259 | .x(d => x(d.label)) | |||||
| 260 | .y(d => y(d.value)); | |||||
| 261 | ||||||
| 262 | chart.append("path") | |||||
| 263 | .datum(data) | |||||
| 264 | .attr("fill", "none") | |||||
| 265 | .attr("stroke", "steelblue") | |||||
| 266 | .attr("stroke-width", 2) | |||||
| 267 | .attr("d", line); | |||||
| 268 | ||||||
| 269 | // Add points to the line | |||||
| 270 | chart.selectAll("circle") | |||||
| 271 | .data(data) | |||||
| 272 | .join("circle") | |||||
| 273 | .attr("cx", d => x(d.label)) | |||||
| 274 | .attr("cy", d => y(d.value)) | |||||
| 275 | .attr("r", 4) | |||||
| 276 | .attr("fill", "steelblue"); | |||||
| 277 | ||||||
| 278 | // Add axes | |||||
| 279 | chart.append("g") | |||||
| 280 | .call(d3.axisLeft(y)); | |||||
| 281 | ||||||
| 282 | chart.append("g") | |||||
| 283 | .attr("transform", `translate(0,\${height})`) | |||||
| 284 | .call(d3.axisBottom(x)) | |||||
| 285 | .selectAll("text") | |||||
| 286 | .attr("transform", "rotate(-45)") | |||||
| 287 | .style("text-anchor", "end"); | |||||
| 288 | </script> | |||||
| 289 | </body> | |||||
| 290 | </html> | |||||
| 291 | HTML | |||||
| 292 | ||||||
| 293 | 1 | 6 | return $html; | |||
| 294 | } | |||||
| 295 | ||||||
| 296 - 312 | =head2 render_line_chart_with_tooltips $html = $chart->render_line_chart_with_tooltips($data); Generates HTML and JavaScript code to render a line chart with mouseover tooltips. Accepts the following arguments: =over 4 =item * C<$data> - An array reference containing data points. Each data point should be an array reference with two elements: the label (string) and the value (numeric). =back Returns a string containing the HTML and JavaScript code for the chart. =cut | |||||
| 313 | ||||||
| 314 | sub render_line_chart_with_tooltips | |||||
| 315 | { | |||||
| 316 | 2 | 2948 | my ($self, $data) = @_; | |||
| 317 | ||||||
| 318 | # Validate input data | |||||
| 319 | 2 | 8 | die 'Data must be an array of arrays' unless ref($data) eq 'ARRAY'; | |||
| 320 | ||||||
| 321 | # Generate JSON for data | |||||
| 322 | my $json_data = encode_json([ | |||||
| 323 | 1 5 | 2 13 | map { { label => $_->[0], value => $_->[1] } } @$data | |||
| 324 | ]); | |||||
| 325 | ||||||
| 326 | # Generate HTML and D3.js code | |||||
| 327 | 1 | 5 | my $html = $self->_preamble(); | |||
| 328 | 1 | 5 | $html .= <<"HTML"; | |||
| 329 | <head> | |||||
| 330 | <meta charset="UTF-8"> | |||||
| 331 | <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |||||
| 332 | <title>$self->{title}</title> | |||||
| 333 | <script src="https://d3js.org/d3.v7.min.js"></script> | |||||
| 334 | <style> | |||||
| 335 | .tooltip { | |||||
| 336 | position: absolute; | |||||
| 337 | background-color: white; | |||||
| 338 | border: 1px solid #ccc; | |||||
| 339 | padding: 5px; | |||||
| 340 | font-size: 12px; | |||||
| 341 | pointer-events: none; | |||||
| 342 | opacity: 0; | |||||
| 343 | transition: opacity 0.2s ease-in-out; | |||||
| 344 | } | |||||
| 345 | </style> | |||||
| 346 | </head> | |||||
| 347 | <body> | |||||
| 348 | <h1 style="text-align: center;">$self->{title}</h1> | |||||
| 349 | <svg id="chart" width="$self->{width}" height="$self->{height}" style="border: 1px solid black;"></svg> | |||||
| 350 | <div class="tooltip" id="tooltip"></div> | |||||
| 351 | <script> | |||||
| 352 | const data = $json_data; | |||||
| 353 | ||||||
| 354 | const svg = d3.select("#chart"); | |||||
| 355 | const tooltip = d3.select("#tooltip"); | |||||
| 356 | const margin = { top: 20, right: 30, bottom: 40, left: 40 }; | |||||
| 357 | const width = $self->{width} - margin.left - margin.right; | |||||
| 358 | const height = $self->{height} - margin.top - margin.bottom; | |||||
| 359 | ||||||
| 360 | const x = d3.scalePoint() | |||||
| 361 | .domain(data.map(d => d.label)) | |||||
| 362 | .range([0, width]); | |||||
| 363 | ||||||
| 364 | const y = d3.scaleLinear() | |||||
| 365 | .domain([0, d3.max(data, d => d.value)]) | |||||
| 366 | .nice() | |||||
| 367 | .range([height, 0]); | |||||
| 368 | ||||||
| 369 | const chart = svg.append("g") | |||||
| 370 | .attr("transform", `translate(\${margin.left},\${margin.top})`); | |||||
| 371 | ||||||
| 372 | // Draw line | |||||
| 373 | const line = d3.line() | |||||
| 374 | .x(d => x(d.label)) | |||||
| 375 | .y(d => y(d.value)); | |||||
| 376 | ||||||
| 377 | chart.append("path") | |||||
| 378 | .datum(data) | |||||
| 379 | .attr("fill", "none") | |||||
| 380 | .attr("stroke", "steelblue") | |||||
| 381 | .attr("stroke-width", 2) | |||||
| 382 | .attr("d", line); | |||||
| 383 | ||||||
| 384 | // Add points to the line | |||||
| 385 | chart.selectAll("circle") | |||||
| 386 | .data(data) | |||||
| 387 | .join("circle") | |||||
| 388 | .attr("cx", d => x(d.label)) | |||||
| 389 | .attr("cy", d => y(d.value)) | |||||
| 390 | .attr("r", 4) | |||||
| 391 | .attr("fill", "steelblue") | |||||
| 392 | .on("mouseover", (event, d) => { | |||||
| 393 | tooltip.style("opacity", 1) | |||||
| 394 | .html(`Label: <b>\${d.label}<\/b><br>Value: <b>\${d.value}<\/b>`) | |||||
| 395 | .style("left", (event.pageX + 10) + "px") | |||||
| 396 | .style("top", (event.pageY - 30) + "px"); | |||||
| 397 | }) | |||||
| 398 | .on("mousemove", (event) => { | |||||
| 399 | tooltip.style("left", (event.pageX + 10) + "px") | |||||
| 400 | .style("top", (event.pageY - 30) + "px"); | |||||
| 401 | }) | |||||
| 402 | .on("mouseout", () => { | |||||
| 403 | tooltip.style("opacity", 0); | |||||
| 404 | }); | |||||
| 405 | ||||||
| 406 | // Add axes | |||||
| 407 | chart.append("g") | |||||
| 408 | .call(d3.axisLeft(y)); | |||||
| 409 | ||||||
| 410 | chart.append("g") | |||||
| 411 | .attr("transform", `translate(0,\${height})`) | |||||
| 412 | .call(d3.axisBottom(x)) | |||||
| 413 | .selectAll("text") | |||||
| 414 | .attr("transform", "rotate(-45)") | |||||
| 415 | .style("text-anchor", "end"); | |||||
| 416 | </script> | |||||
| 417 | </body> | |||||
| 418 | </html> | |||||
| 419 | HTML | |||||
| 420 | ||||||
| 421 | 1 | 2 | return $html; | |||
| 422 | } | |||||
| 423 | ||||||
| 424 - 463 | =head2 render_line_chart_snippet
my $fragment = $chart->render_line_chart_snippet($data);
# $fragment->{svg_id} - the id attribute of the <svg> element
# $fragment->{html} - embeddable HTML fragment (style + svg + script)
Generates an embeddable HTML fragment for a line chart with mouseover tooltips.
Unlike C<render_line_chart_with_tooltips>, this method returns a fragment with
no C<<!DOCTYPE>>, C<<html>>, C<<head>>, or C<<body>> wrapper, suitable for
splicing directly into a Mojolicious TT (or any other) layout.
The caller is responsible for loading D3 in the page C<<head>>, e.g.:
<script src="https://d3js.org/d3.v7.min.js"></script>
Accepts the following arguments:
=over 4
=item * C<$data> - An array reference of data points. Each point is an array
reference with two required elements - the label (string) and the value
(numeric) - and an optional third element: a hash reference of extra key/value
pairs to display in the tooltip after the label and value rows.
[$x, $y] # basic point
[$x, $y, \%row] # point with extra tooltip data
=back
Returns a hash reference with:
=over 4
=item * C<svg_id> - The C<id> attribute used on the C<<svg>> element.
=item * C<html> - The embeddable fragment string.
=back
=cut | |||||
| 464 | ||||||
| 465 | sub render_line_chart_snippet | |||||
| 466 | { | |||||
| 467 | 3 | 2853 | my ($self, $data) = @_; | |||
| 468 | ||||||
| 469 | 3 | 10 | die 'Data must be an array of arrays' unless ref($data) eq 'ARRAY'; | |||
| 470 | ||||||
| 471 | my $json_data = encode_json([ | |||||
| 472 | map { | |||||
| 473 | 2 5 | 16 6 | my $point = { label => $_->[0], value => $_->[1] }; | |||
| 474 | 5 | 5 | $point->{extra} = $_->[2] if ref($_->[2]) eq 'HASH'; | |||
| 475 | 5 | 14 | $point | |||
| 476 | } @$data | |||||
| 477 | ]); | |||||
| 478 | ||||||
| 479 | 2 | 4 | my $svg_id = 'chart'; | |||
| 480 | ||||||
| 481 | 2 | 5 | my $html = <<"HTML"; | |||
| 482 | <style> | |||||
| 483 | .tooltip { | |||||
| 484 | position: absolute; | |||||
| 485 | background-color: white; | |||||
| 486 | border: 1px solid #ccc; | |||||
| 487 | padding: 5px; | |||||
| 488 | font-size: 12px; | |||||
| 489 | pointer-events: none; | |||||
| 490 | opacity: 0; | |||||
| 491 | transition: opacity 0.2s ease-in-out; | |||||
| 492 | } | |||||
| 493 | </style> | |||||
| 494 | <svg id="$svg_id" width="$self->{width}" height="$self->{height}" style="border: 1px solid black;"></svg> | |||||
| 495 | <div class="tooltip" id="tooltip"></div> | |||||
| 496 | <script> | |||||
| 497 | const data = $json_data; | |||||
| 498 | ||||||
| 499 | const svg = d3.select("#$svg_id"); | |||||
| 500 | const tooltip = d3.select("#tooltip"); | |||||
| 501 | const margin = { top: 20, right: 30, bottom: 40, left: 40 }; | |||||
| 502 | const width = $self->{width} - margin.left - margin.right; | |||||
| 503 | const height = $self->{height} - margin.top - margin.bottom; | |||||
| 504 | ||||||
| 505 | const x = d3.scalePoint() | |||||
| 506 | .domain(data.map(d => d.label)) | |||||
| 507 | .range([0, width]); | |||||
| 508 | ||||||
| 509 | const y = d3.scaleLinear() | |||||
| 510 | .domain([0, d3.max(data, d => d.value)]) | |||||
| 511 | .nice() | |||||
| 512 | .range([height, 0]); | |||||
| 513 | ||||||
| 514 | const chart = svg.append("g") | |||||
| 515 | .attr("transform", `translate(\${margin.left},\${margin.top})`); | |||||
| 516 | ||||||
| 517 | const line = d3.line() | |||||
| 518 | .x(d => x(d.label)) | |||||
| 519 | .y(d => y(d.value)); | |||||
| 520 | ||||||
| 521 | chart.append("path") | |||||
| 522 | .datum(data) | |||||
| 523 | .attr("fill", "none") | |||||
| 524 | .attr("stroke", "steelblue") | |||||
| 525 | .attr("stroke-width", 2) | |||||
| 526 | .attr("d", line); | |||||
| 527 | ||||||
| 528 | chart.selectAll("circle") | |||||
| 529 | .data(data) | |||||
| 530 | .join("circle") | |||||
| 531 | .attr("cx", d => x(d.label)) | |||||
| 532 | .attr("cy", d => y(d.value)) | |||||
| 533 | .attr("r", 4) | |||||
| 534 | .attr("fill", "steelblue") | |||||
| 535 | .on("mouseover", (event, d) => { | |||||
| 536 | let ttHtml = `Label: <b>\${d.label}<\/b><br>Value: <b>\${d.value}<\/b>`; | |||||
| 537 | if (d.extra) { | |||||
| 538 | Object.entries(d.extra).forEach(([k, v]) => { | |||||
| 539 | ttHtml += `<br>\${k}: <b>\${v}<\/b>`; | |||||
| 540 | }); | |||||
| 541 | } | |||||
| 542 | tooltip.style("opacity", 1) | |||||
| 543 | .html(ttHtml) | |||||
| 544 | .style("left", (event.pageX + 10) + "px") | |||||
| 545 | .style("top", (event.pageY - 30) + "px"); | |||||
| 546 | }) | |||||
| 547 | .on("mousemove", (event) => { | |||||
| 548 | tooltip.style("left", (event.pageX + 10) + "px") | |||||
| 549 | .style("top", (event.pageY - 30) + "px"); | |||||
| 550 | }) | |||||
| 551 | .on("mouseout", () => { | |||||
| 552 | tooltip.style("opacity", 0); | |||||
| 553 | }); | |||||
| 554 | ||||||
| 555 | chart.append("g") | |||||
| 556 | .call(d3.axisLeft(y)); | |||||
| 557 | ||||||
| 558 | chart.append("g") | |||||
| 559 | .attr("transform", `translate(0,\${height})`) | |||||
| 560 | .call(d3.axisBottom(x)) | |||||
| 561 | .selectAll("text") | |||||
| 562 | .attr("transform", "rotate(-45)") | |||||
| 563 | .style("text-anchor", "end"); | |||||
| 564 | </script> | |||||
| 565 | HTML | |||||
| 566 | ||||||
| 567 | 2 | 6 | return { svg_id => $svg_id, html => $html }; | |||
| 568 | } | |||||
| 569 | ||||||
| 570 - 587 | =head2 render_multi_series_line_chart_with_tooltips $html = $chart->render_multi_series_line_chart_with_tooltips($data); Generates HTML and JavaScript code to render a chart of many lines with mouseover tooltips. Accepts the following arguments: =over 4 =item * C<$data> - An reference to an array of hashes containing data points. Each data point should be an array reference with two elements: the label (string) and the value (numeric). =back Returns a string containing the HTML and JavaScript code for the chart. =cut | |||||
| 588 | ||||||
| 589 | sub render_multi_series_line_chart_with_tooltips | |||||
| 590 | { | |||||
| 591 | 2 | 3209 | my ($self, $data) = @_; | |||
| 592 | ||||||
| 593 | # Validate input data | |||||
| 594 | 2 | 8 | die 'Data must be an array of hashes' unless ref($data) eq 'ARRAY'; | |||
| 595 | ||||||
| 596 | 1 | 11 | my $json_data = encode_json($data); | |||
| 597 | ||||||
| 598 | # Generate HTML and D3.js code | |||||
| 599 | 1 | 3 | my $html = $self->_preamble(); | |||
| 600 | 1 | 5 | $html .= <<"HTML"; | |||
| 601 | <head> | |||||
| 602 | <meta charset="UTF-8"> | |||||
| 603 | <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |||||
| 604 | <title>$self->{title}</title> | |||||
| 605 | <script src="https://d3js.org/d3.v7.min.js"></script> | |||||
| 606 | <style> | |||||
| 607 | .tooltip { | |||||
| 608 | position: absolute; | |||||
| 609 | background-color: white; | |||||
| 610 | border: 1px solid #ccc; | |||||
| 611 | padding: 5px; | |||||
| 612 | font-size: 12px; | |||||
| 613 | pointer-events: none; | |||||
| 614 | opacity: 0; | |||||
| 615 | transition: opacity 0.2s ease-in-out; | |||||
| 616 | } | |||||
| 617 | </style> | |||||
| 618 | </head> | |||||
| 619 | <body> | |||||
| 620 | <h1 style="text-align: center;">$self->{title}</h1> | |||||
| 621 | <svg id="chart" width="$self->{width}" height="$self->{height}" style="border: 1px solid black;"></svg> | |||||
| 622 | <div class="tooltip" id="tooltip"></div> | |||||
| 623 | <script> | |||||
| 624 | const data = $json_data; | |||||
| 625 | ||||||
| 626 | const svg = d3.select("#chart"); | |||||
| 627 | const tooltip = d3.select("#tooltip"); | |||||
| 628 | const margin = { top: 20, right: 30, bottom: 40, left: 40 }; | |||||
| 629 | const width = $self->{width} - margin.left - margin.right; | |||||
| 630 | const height = $self->{height} - margin.top - margin.bottom; | |||||
| 631 | ||||||
| 632 | const chart = svg.append("g") | |||||
| 633 | .attr("transform", `translate(\${margin.left},\${margin.top})`); | |||||
| 634 | ||||||
| 635 | // Extract all labels and flatten them into a unique array | |||||
| 636 | const allLabels = Array.from(new Set(data.flatMap(series => series.data.map(d => d.label)))); | |||||
| 637 | ||||||
| 638 | const x = d3.scalePoint() | |||||
| 639 | .domain(allLabels) | |||||
| 640 | .range([0, width]); | |||||
| 641 | ||||||
| 642 | const y = d3.scaleLinear() | |||||
| 643 | .domain([0, d3.max(data.flatMap(series => series.data.map(d => d.value)))]) | |||||
| 644 | .nice() | |||||
| 645 | .range([height, 0]); | |||||
| 646 | ||||||
| 647 | // Define color scale for series | |||||
| 648 | const color = d3.scaleOrdinal(d3.schemeCategory10); | |||||
| 649 | ||||||
| 650 | // Add axes | |||||
| 651 | chart.append("g") | |||||
| 652 | .call(d3.axisLeft(y)); | |||||
| 653 | ||||||
| 654 | chart.append("g") | |||||
| 655 | .attr("transform", `translate(0,\${height})`) | |||||
| 656 | .call(d3.axisBottom(x)) | |||||
| 657 | .selectAll("text") | |||||
| 658 | .attr("transform", "rotate(-45)") | |||||
| 659 | .style("text-anchor", "end"); | |||||
| 660 | ||||||
| 661 | // Draw lines for each series | |||||
| 662 | data.forEach((series, i) => { | |||||
| 663 | const line = d3.line() | |||||
| 664 | .x(d => x(d.label)) | |||||
| 665 | .y(d => y(d.value)); | |||||
| 666 | ||||||
| 667 | // Add line | |||||
| 668 | chart.append("path") | |||||
| 669 | .datum(series.data) | |||||
| 670 | .attr("fill", "none") | |||||
| 671 | .attr("stroke", color(i)) | |||||
| 672 | .attr("stroke-width", 2) | |||||
| 673 | .attr("d", line); | |||||
| 674 | ||||||
| 675 | // Add points and tooltips | |||||
| 676 | chart.selectAll(\`circle.series-\${i}\`) | |||||
| 677 | .data(series.data) | |||||
| 678 | .join("circle") | |||||
| 679 | .attr("class", \`series-\${i}\`) | |||||
| 680 | .attr("cx", d => x(d.label)) | |||||
| 681 | .attr("cy", d => y(d.value)) | |||||
| 682 | .attr("r", 4) | |||||
| 683 | .attr("fill", color(i)) | |||||
| 684 | .on("mouseover", (event, d) => { | |||||
| 685 | tooltip.style("opacity", 1) | |||||
| 686 | .html(\`Series: <b>\${series.name}<\/b><br>Label: <b>\${d.label}<\/b><br>Value: <b>\${d.value}<\/b>\`) | |||||
| 687 | .style("left", (event.pageX + 10) + "px") | |||||
| 688 | .style("top", (event.pageY - 30) + "px"); | |||||
| 689 | }) | |||||
| 690 | .on("mousemove", (event) => { | |||||
| 691 | tooltip.style("left", (event.pageX + 10) + "px") | |||||
| 692 | .style("top", (event.pageY - 30) + "px"); | |||||
| 693 | }) | |||||
| 694 | .on("mouseout", () => { | |||||
| 695 | tooltip.style("opacity", 0); | |||||
| 696 | }); | |||||
| 697 | }); | |||||
| 698 | </script> | |||||
| 699 | </body> | |||||
| 700 | </html> | |||||
| 701 | HTML | |||||
| 702 | ||||||
| 703 | 1 | 3 | return $html; | |||
| 704 | } | |||||
| 705 | ||||||
| 706 - 723 | =head2 render_multi_series_line_chart_with_animated_tooltips $html = $chart->render_multi_series_line_chart_with_animated_tooltips($data); Generates HTML and JavaScript code to render a chart of many lines with animated mouseover tooltips. Accepts the following arguments: =over 4 =item * C<$data> - An reference to an array of hashes containing data points. Each data point should be an array reference with two elements: the label (string) and the value (numeric). =back Returns a string containing the HTML and JavaScript code for the chart. =cut | |||||
| 724 | ||||||
| 725 | sub render_multi_series_line_chart_with_animated_tooltips | |||||
| 726 | { | |||||
| 727 | 2 | 3143 | my ($self, $data) = @_; | |||
| 728 | ||||||
| 729 | # Validate input data | |||||
| 730 | 2 | 8 | die 'Data must be an array of hashes' unless ref($data) eq 'ARRAY'; | |||
| 731 | ||||||
| 732 | # Generate JSON for data | |||||
| 733 | 1 | 10 | my $json_data = encode_json($data); | |||
| 734 | ||||||
| 735 | # Generate HTML and D3.js code | |||||
| 736 | 1 | 3 | my $html = $self->_preamble(); | |||
| 737 | 1 | 9 | $html .= <<"HTML"; | |||
| 738 | <head> | |||||
| 739 | <meta charset="UTF-8"> | |||||
| 740 | <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |||||
| 741 | <title>$self->{title}</title> | |||||
| 742 | <script src="https://d3js.org/d3.v7.min.js"></script> | |||||
| 743 | <style> | |||||
| 744 | .tooltip { | |||||
| 745 | position: absolute; | |||||
| 746 | background-color: white; | |||||
| 747 | border: 1px solid #ccc; | |||||
| 748 | padding: 5px; | |||||
| 749 | font-size: 12px; | |||||
| 750 | pointer-events: none; | |||||
| 751 | opacity: 0; | |||||
| 752 | transform: translateY(-10px); | |||||
| 753 | transition: opacity 0.2s ease-in-out, transform 0.2s ease-in-out; | |||||
| 754 | } | |||||
| 755 | </style> | |||||
| 756 | </head> | |||||
| 757 | <body> | |||||
| 758 | <h1 style="text-align: center;">$self->{title}</h1> | |||||
| 759 | <svg id="chart" width="$self->{width}" height="$self->{height}" style="border: 1px solid black;"></svg> | |||||
| 760 | <div class="tooltip" id="tooltip"></div> | |||||
| 761 | <script> | |||||
| 762 | const data = $json_data; | |||||
| 763 | ||||||
| 764 | const svg = d3.select("#chart"); | |||||
| 765 | const tooltip = d3.select("#tooltip"); | |||||
| 766 | const margin = { top: 20, right: 30, bottom: 40, left: 40 }; | |||||
| 767 | const width = $self->{width} - margin.left - margin.right; | |||||
| 768 | const height = $self->{height} - margin.top - margin.bottom; | |||||
| 769 | ||||||
| 770 | const chart = svg.append("g") | |||||
| 771 | .attr("transform", `translate(\${margin.left},\${margin.top})`); | |||||
| 772 | ||||||
| 773 | // Extract all labels and flatten them into a unique array | |||||
| 774 | const allLabels = Array.from(new Set(data.flatMap(series => series.data.map(d => d.label)))); | |||||
| 775 | ||||||
| 776 | const x = d3.scalePoint() | |||||
| 777 | .domain(allLabels) | |||||
| 778 | .range([0, width]); | |||||
| 779 | ||||||
| 780 | const y = d3.scaleLinear() | |||||
| 781 | .domain([0, d3.max(data.flatMap(series => series.data.map(d => d.value)))]) | |||||
| 782 | .nice() | |||||
| 783 | .range([height, 0]); | |||||
| 784 | ||||||
| 785 | // Define color scale for series | |||||
| 786 | const color = d3.scaleOrdinal(d3.schemeCategory10); | |||||
| 787 | ||||||
| 788 | // Add axes | |||||
| 789 | chart.append("g") | |||||
| 790 | .call(d3.axisLeft(y)); | |||||
| 791 | ||||||
| 792 | chart.append("g") | |||||
| 793 | .attr("transform", `translate(0,\${height})`) | |||||
| 794 | .call(d3.axisBottom(x)) | |||||
| 795 | .selectAll("text") | |||||
| 796 | .attr("transform", "rotate(-45)") | |||||
| 797 | .style("text-anchor", "end"); | |||||
| 798 | ||||||
| 799 | // Draw lines for each series | |||||
| 800 | data.forEach((series, i) => { | |||||
| 801 | const line = d3.line() | |||||
| 802 | .x(d => x(d.label)) | |||||
| 803 | .y(d => y(d.value)); | |||||
| 804 | ||||||
| 805 | // Add line | |||||
| 806 | chart.append("path") | |||||
| 807 | .datum(series.data) | |||||
| 808 | .attr("fill", "none") | |||||
| 809 | .attr("stroke", color(i)) | |||||
| 810 | .attr("stroke-width", 2) | |||||
| 811 | .attr("d", line); | |||||
| 812 | ||||||
| 813 | // Add points and tooltips | |||||
| 814 | chart.selectAll(\`circle.series-\${i}\`) | |||||
| 815 | .data(series.data) | |||||
| 816 | .join("circle") | |||||
| 817 | .attr("class", \`series-\${i}\`) | |||||
| 818 | .attr("cx", d => x(d.label)) | |||||
| 819 | .attr("cy", d => y(d.value)) | |||||
| 820 | .attr("r", 4) | |||||
| 821 | .attr("fill", color(i)) | |||||
| 822 | .on("mouseover", (event, d) => { | |||||
| 823 | tooltip.style("opacity", 1) | |||||
| 824 | .style("transform", "translateY(0)") | |||||
| 825 | .html(\`Series: <b>\${series.name}<\/b><br>Label: <b>\${d.label}<\/b><br>Value: <b>\${d.value}<\/b>\`) | |||||
| 826 | .style("left", (event.pageX + 10) + "px") | |||||
| 827 | .style("top", (event.pageY - 30) + "px"); | |||||
| 828 | }) | |||||
| 829 | .on("mousemove", (event) => { | |||||
| 830 | tooltip.style("left", (event.pageX + 10) + "px") | |||||
| 831 | .style("top", (event.pageY - 30) + "px"); | |||||
| 832 | }) | |||||
| 833 | .on("mouseout", () => { | |||||
| 834 | tooltip.style("opacity", 0) | |||||
| 835 | .style("transform", "translateY(-10px)"); | |||||
| 836 | }); | |||||
| 837 | }); | |||||
| 838 | </script> | |||||
| 839 | </body> | |||||
| 840 | </html> | |||||
| 841 | HTML | |||||
| 842 | ||||||
| 843 | 1 | 3 | return $html; | |||
| 844 | } | |||||
| 845 | ||||||
| 846 - 863 | =head2 render_multi_series_line_chart_with_legends $html = $chart->render_multi_series_line_chart_with_legends($data); Generates HTML and JavaScript code to render a chart of many lines with animated mouseover tooltips. Accepts the following arguments: =over 4 =item * C<$data> - An reference to an array of hashes containing data points. Each data point should be an array reference with two elements: the label (string) and the value (numeric). =back Returns a string containing the HTML and JavaScript code for the chart. =cut | |||||
| 864 | ||||||
| 865 | sub render_multi_series_line_chart_with_legends { | |||||
| 866 | 2 | 3476 | my($self, $data) = @_; | |||
| 867 | ||||||
| 868 | # Validate input data | |||||
| 869 | 2 | 8 | die 'Data must be an array of hashes' unless ref($data) eq 'ARRAY'; | |||
| 870 | ||||||
| 871 | # Generate JSON for data | |||||
| 872 | 1 | 10 | my $json_data = encode_json($data); | |||
| 873 | ||||||
| 874 | # Generate HTML and D3.js code | |||||
| 875 | 1 | 4 | my $html = $self->_preamble(); | |||
| 876 | 1 | 8 | $html .= <<"HTML"; | |||
| 877 | <head> | |||||
| 878 | <meta charset="UTF-8"> | |||||
| 879 | <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |||||
| 880 | <title>$self->{title}</title> | |||||
| 881 | <script src="https://d3js.org/d3.v7.min.js"></script> | |||||
| 882 | <style> | |||||
| 883 | .tooltip { | |||||
| 884 | position: absolute; | |||||
| 885 | background-color: white; | |||||
| 886 | border: 1px solid #ccc; | |||||
| 887 | padding: 5px; | |||||
| 888 | font-size: 12px; | |||||
| 889 | pointer-events: none; | |||||
| 890 | opacity: 0; | |||||
| 891 | transform: translateY(-10px); | |||||
| 892 | transition: opacity 0.2s ease-in-out, transform 0.2s ease-in-out; | |||||
| 893 | } | |||||
| 894 | .legend { | |||||
| 895 | font-size: 12px; | |||||
| 896 | cursor: pointer; | |||||
| 897 | } | |||||
| 898 | .legend rect { | |||||
| 899 | stroke-width: 1; | |||||
| 900 | stroke: #ccc; | |||||
| 901 | } | |||||
| 902 | </style> | |||||
| 903 | </head> | |||||
| 904 | <body> | |||||
| 905 | <h1 style="text-align: center;">$self->{title}</h1> | |||||
| 906 | <svg id="chart" width="$self->{width}" height="$self->{height}" style="border: 1px solid black;"></svg> | |||||
| 907 | <div class="tooltip" id="tooltip"></div> | |||||
| 908 | <script> | |||||
| 909 | const data = $json_data; | |||||
| 910 | ||||||
| 911 | const svg = d3.select("#chart"); | |||||
| 912 | const tooltip = d3.select("#tooltip"); | |||||
| 913 | const margin = { top: 20, right: 120, bottom: 40, left: 40 }; | |||||
| 914 | const width = $self->{width} - margin.left - margin.right; | |||||
| 915 | const height = $self->{height} - margin.top - margin.bottom; | |||||
| 916 | ||||||
| 917 | const chart = svg.append("g") | |||||
| 918 | .attr("transform", `translate(\${margin.left},\${margin.top})`); | |||||
| 919 | ||||||
| 920 | const legendArea = svg.append("g") | |||||
| 921 | .attr("transform", `translate(\${width + margin.left + 20},\${margin.top})`); | |||||
| 922 | ||||||
| 923 | // Extract all labels and flatten them into a unique array | |||||
| 924 | const allLabels = Array.from(new Set(data.flatMap(series => series.data.map(d => d.label)))); | |||||
| 925 | ||||||
| 926 | const x = d3.scalePoint() | |||||
| 927 | .domain(allLabels) | |||||
| 928 | .range([0, width]); | |||||
| 929 | ||||||
| 930 | const y = d3.scaleLinear() | |||||
| 931 | .domain([0, d3.max(data.flatMap(series => series.data.map(d => d.value)))]) | |||||
| 932 | .nice() | |||||
| 933 | .range([height, 0]); | |||||
| 934 | ||||||
| 935 | // Define color scale for series | |||||
| 936 | const color = d3.scaleOrdinal(d3.schemeCategory10); | |||||
| 937 | ||||||
| 938 | // Add axes | |||||
| 939 | chart.append("g") | |||||
| 940 | .call(d3.axisLeft(y)); | |||||
| 941 | ||||||
| 942 | chart.append("g") | |||||
| 943 | .attr("transform", `translate(0,\${height})`) | |||||
| 944 | .call(d3.axisBottom(x)) | |||||
| 945 | .selectAll("text") | |||||
| 946 | .attr("transform", "rotate(-45)") | |||||
| 947 | .style("text-anchor", "end"); | |||||
| 948 | ||||||
| 949 | // Draw lines for each series | |||||
| 950 | data.forEach((series, i) => { | |||||
| 951 | const line = d3.line() | |||||
| 952 | .x(d => x(d.label)) | |||||
| 953 | .y(d => y(d.value)); | |||||
| 954 | ||||||
| 955 | // Add line | |||||
| 956 | chart.append("path") | |||||
| 957 | .datum(series.data) | |||||
| 958 | .attr("fill", "none") | |||||
| 959 | .attr("stroke", color(i)) | |||||
| 960 | .attr("stroke-width", 2) | |||||
| 961 | .attr("class", \`line-\${i}\`) | |||||
| 962 | .attr("d", line); | |||||
| 963 | ||||||
| 964 | // Add points and tooltips | |||||
| 965 | chart.selectAll(\`circle.series-\${i}\`) | |||||
| 966 | .data(series.data) | |||||
| 967 | .join("circle") | |||||
| 968 | .attr("class", \`series-\${i}\`) | |||||
| 969 | .attr("cx", d => x(d.label)) | |||||
| 970 | .attr("cy", d => y(d.value)) | |||||
| 971 | .attr("r", 4) | |||||
| 972 | .attr("fill", color(i)) | |||||
| 973 | .on("mouseover", (event, d) => { | |||||
| 974 | tooltip.style("opacity", 1) | |||||
| 975 | .style("transform", "translateY(0)") | |||||
| 976 | .html(\`Series: <b>\${series.name}<\/b><br>Label: <b>\${d.label}<\/b><br>Value: <b>\${d.value}<\/b>\`) | |||||
| 977 | .style("left", (event.pageX + 10) + "px") | |||||
| 978 | .style("top", (event.pageY - 30) + "px"); | |||||
| 979 | }) | |||||
| 980 | .on("mousemove", (event) => { | |||||
| 981 | tooltip.style("left", (event.pageX + 10) + "px") | |||||
| 982 | .style("top", (event.pageY - 30) + "px"); | |||||
| 983 | }) | |||||
| 984 | .on("mouseout", () => { | |||||
| 985 | tooltip.style("opacity", 0) | |||||
| 986 | .style("transform", "translateY(-10px)"); | |||||
| 987 | }); | |||||
| 988 | }); | |||||
| 989 | ||||||
| 990 | // Add legend | |||||
| 991 | data.forEach((series, i) => { | |||||
| 992 | const legend = legendArea.append("g") | |||||
| 993 | .attr("transform", `translate(0, \${i * 20})`) | |||||
| 994 | .attr("class", "legend"); | |||||
| 995 | ||||||
| 996 | legend.append("rect") | |||||
| 997 | .attr("width", 12) | |||||
| 998 | .attr("height", 12) | |||||
| 999 | .attr("fill", color(i)); | |||||
| 1000 | ||||||
| 1001 | legend.append("text") | |||||
| 1002 | .attr("x", 20) | |||||
| 1003 | .attr("y", 10) | |||||
| 1004 | .text(series.name) | |||||
| 1005 | .style("alignment-baseline", "middle"); | |||||
| 1006 | ||||||
| 1007 | // Optional: Interactive legend for toggling visibility (uncomment to use) | |||||
| 1008 | // legend.on("click", () => { | |||||
| 1009 | // const visible = d3.selectAll(\`path.line-\${i}\`).style("opacity") === "1" ? 0 : 1; | |||||
| 1010 | // d3.selectAll(\`path.line-\${i}\`).style("opacity", visible); | |||||
| 1011 | // d3.selectAll(\`circle.series-\${i}\`).style("opacity", visible); | |||||
| 1012 | // }); | |||||
| 1013 | }); | |||||
| 1014 | </script> | |||||
| 1015 | </body> | |||||
| 1016 | </html> | |||||
| 1017 | HTML | |||||
| 1018 | ||||||
| 1019 | 1 | 3 | return $html; | |||
| 1020 | } | |||||
| 1021 | ||||||
| 1022 - 1039 | =head2 render_multi_series_line_chart_with_interactive_legends $html = $chart->render_multi_series_line_chart_with_interactive_legends($data); Generates HTML and JavaScript code to render a chart of many lines with interactive legends to filter, highlight or modify elements based on legend selections. Accepts the following arguments: =over 4 =item * C<$data> - An reference to an array of hashes containing data points. Each data point should be an array reference with two elements: the label (string) and the value (numeric). =back Returns a string containing the HTML and JavaScript code for the chart. =cut | |||||
| 1040 | ||||||
| 1041 | sub render_multi_series_line_chart_with_interactive_legends | |||||
| 1042 | { | |||||
| 1043 | 2 | 3659 | my ($self, $data) = @_; | |||
| 1044 | ||||||
| 1045 | # Validate input data | |||||
| 1046 | 2 | 9 | die 'Data must be an array of hashes' unless ref($data) eq 'ARRAY'; | |||
| 1047 | ||||||
| 1048 | # Generate JSON for data | |||||
| 1049 | 1 | 10 | my $json_data = encode_json($data); | |||
| 1050 | ||||||
| 1051 | # Generate HTML and D3.js code | |||||
| 1052 | 1 | 3 | my $html = $self->_preamble(); | |||
| 1053 | 1 | 11 | $html .= <<"HTML"; | |||
| 1054 | <head> | |||||
| 1055 | <meta charset="UTF-8"> | |||||
| 1056 | <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |||||
| 1057 | <title>$self->{title}</title> | |||||
| 1058 | <script src="https://d3js.org/d3.v7.min.js"></script> | |||||
| 1059 | <style> | |||||
| 1060 | .tooltip { | |||||
| 1061 | position: absolute; | |||||
| 1062 | background-color: white; | |||||
| 1063 | border: 1px solid #ccc; | |||||
| 1064 | padding: 5px; | |||||
| 1065 | font-size: 12px; | |||||
| 1066 | pointer-events: none; | |||||
| 1067 | opacity: 0; | |||||
| 1068 | transform: translateY(-10px); | |||||
| 1069 | transition: opacity 0.2s ease-in-out, transform 0.2s ease-in-out; | |||||
| 1070 | } | |||||
| 1071 | .legend { | |||||
| 1072 | font-size: 12px; | |||||
| 1073 | cursor: pointer; | |||||
| 1074 | } | |||||
| 1075 | .legend rect { | |||||
| 1076 | stroke-width: 1; | |||||
| 1077 | stroke: #ccc; | |||||
| 1078 | } | |||||
| 1079 | </style> | |||||
| 1080 | </head> | |||||
| 1081 | <body> | |||||
| 1082 | <h1 style="text-align: center;">$self->{title}</h1> | |||||
| 1083 | <svg id="chart" width="$self->{width}" height="$self->{height}" style="border: 1px solid black;"></svg> | |||||
| 1084 | <div class="tooltip" id="tooltip"></div> | |||||
| 1085 | <script> | |||||
| 1086 | const data = $json_data; | |||||
| 1087 | ||||||
| 1088 | const svg = d3.select("#chart"); | |||||
| 1089 | const tooltip = d3.select("#tooltip"); | |||||
| 1090 | const margin = { top: 20, right: 150, bottom: 40, left: 40 }; | |||||
| 1091 | const width = $self->{width} - margin.left - margin.right; | |||||
| 1092 | const height = $self->{height} - margin.top - margin.bottom; | |||||
| 1093 | ||||||
| 1094 | const chart = svg.append("g") | |||||
| 1095 | .attr("transform", `translate(\${margin.left},\${margin.top})`); | |||||
| 1096 | ||||||
| 1097 | const legendArea = svg.append("g") | |||||
| 1098 | .attr("transform", `translate(\${width + margin.left + 20},\${margin.top})`); | |||||
| 1099 | ||||||
| 1100 | // Extract all labels and flatten them into a unique array | |||||
| 1101 | const allLabels = Array.from(new Set(data.flatMap(series => series.data.map(d => d.label)))); | |||||
| 1102 | ||||||
| 1103 | const x = d3.scalePoint() | |||||
| 1104 | .domain(allLabels) | |||||
| 1105 | .range([0, width]); | |||||
| 1106 | ||||||
| 1107 | const y = d3.scaleLinear() | |||||
| 1108 | .domain([0, d3.max(data.flatMap(series => series.data.map(d => d.value)))]) | |||||
| 1109 | .nice() | |||||
| 1110 | .range([height, 0]); | |||||
| 1111 | ||||||
| 1112 | // Define color scale for series | |||||
| 1113 | const color = d3.scaleOrdinal(d3.schemeCategory10); | |||||
| 1114 | ||||||
| 1115 | // Add axes | |||||
| 1116 | chart.append("g") | |||||
| 1117 | .call(d3.axisLeft(y)); | |||||
| 1118 | ||||||
| 1119 | chart.append("g") | |||||
| 1120 | .attr("transform", `translate(0,\${height})`) | |||||
| 1121 | .call(d3.axisBottom(x)) | |||||
| 1122 | .selectAll("text") | |||||
| 1123 | .attr("transform", "rotate(-45)") | |||||
| 1124 | .style("text-anchor", "end"); | |||||
| 1125 | ||||||
| 1126 | // Draw lines for each series | |||||
| 1127 | data.forEach((series, i) => { | |||||
| 1128 | const line = d3.line() | |||||
| 1129 | .x(d => x(d.label)) | |||||
| 1130 | .y(d => y(d.value)); | |||||
| 1131 | ||||||
| 1132 | // Add line | |||||
| 1133 | chart.append("path") | |||||
| 1134 | .datum(series.data) | |||||
| 1135 | .attr("fill", "none") | |||||
| 1136 | .attr("stroke", color(i)) | |||||
| 1137 | .attr("stroke-width", 2) | |||||
| 1138 | .attr("class", \`line-\${i}\`) | |||||
| 1139 | .attr("d", line); | |||||
| 1140 | ||||||
| 1141 | // Add points and tooltips | |||||
| 1142 | chart.selectAll(\`circle.series-\${i}\`) | |||||
| 1143 | .data(series.data) | |||||
| 1144 | .join("circle") | |||||
| 1145 | .attr("class", \`series-\${i}\`) | |||||
| 1146 | .attr("cx", d => x(d.label)) | |||||
| 1147 | .attr("cy", d => y(d.value)) | |||||
| 1148 | .attr("r", 4) | |||||
| 1149 | .attr("fill", color(i)) | |||||
| 1150 | .on("mouseover", (event, d) => { | |||||
| 1151 | tooltip.style("opacity", 1) | |||||
| 1152 | .style("transform", "translateY(0)") | |||||
| 1153 | .html(\`Series: <b>\${series.name}<\/b><br>Label: <b>\${d.label}<\/b><br>Value: <b>\${d.value}<\/b>\`) | |||||
| 1154 | .style("left", (event.pageX + 10) + "px") | |||||
| 1155 | .style("top", (event.pageY - 30) + "px"); | |||||
| 1156 | }) | |||||
| 1157 | .on("mousemove", (event) => { | |||||
| 1158 | tooltip.style("left", (event.pageX + 10) + "px") | |||||
| 1159 | .style("top", (event.pageY - 30) + "px"); | |||||
| 1160 | }) | |||||
| 1161 | .on("mouseout", () => { | |||||
| 1162 | tooltip.style("opacity", 0) | |||||
| 1163 | .style("transform", "translateY(-10px)"); | |||||
| 1164 | }); | |||||
| 1165 | }); | |||||
| 1166 | ||||||
| 1167 | // Add legend with interactivity | |||||
| 1168 | data.forEach((series, i) => { | |||||
| 1169 | const legend = legendArea.append("g") | |||||
| 1170 | .attr("transform", `translate(0, \${i * 20})`) | |||||
| 1171 | .attr("class", "legend") | |||||
| 1172 | .on("click", () => { | |||||
| 1173 | const isVisible = d3.selectAll(\`path.line-\${i}\`).style("opacity") === "1"; | |||||
| 1174 | ||||||
| 1175 | // Toggle visibility | |||||
| 1176 | d3.selectAll(\`path.line-\${i}\`).style("opacity", isVisible ? 0 : 1); | |||||
| 1177 | d3.selectAll(\`circle.series-\${i}\`).style("opacity", isVisible ? 0 : 1); | |||||
| 1178 | ||||||
| 1179 | // Dim legend if series is hidden | |||||
| 1180 | legend.select("text").style("opacity", isVisible ? 0.5 : 1); | |||||
| 1181 | }); | |||||
| 1182 | ||||||
| 1183 | legend.append("rect") | |||||
| 1184 | .attr("width", 12) | |||||
| 1185 | .attr("height", 12) | |||||
| 1186 | .attr("fill", color(i)); | |||||
| 1187 | ||||||
| 1188 | legend.append("text") | |||||
| 1189 | .attr("x", 20) | |||||
| 1190 | .attr("y", 10) | |||||
| 1191 | .text(series.name) | |||||
| 1192 | .style("alignment-baseline", "middle"); | |||||
| 1193 | }); | |||||
| 1194 | </script> | |||||
| 1195 | </body> | |||||
| 1196 | </html> | |||||
| 1197 | HTML | |||||
| 1198 | ||||||
| 1199 | 1 | 2 | return $html; | |||
| 1200 | } | |||||
| 1201 | ||||||
| 1202 | sub _preamble | |||||
| 1203 | { | |||||
| 1204 | 7 | 8 | my $html = <<'HTML'; | |||
| 1205 | <!DOCTYPE html> | |||||
| 1206 | <html lang="en"> | |||||
| 1207 | HTML | |||||
| 1208 | 7 | 9 | return $html; | |||
| 1209 | } | |||||
| 1210 | ||||||
| 1211 | sub _head | |||||
| 1212 | { | |||||
| 1213 | 2 | 2 | my $self = shift; | |||
| 1214 | ||||||
| 1215 | 2 | 4 | my $html = <<"HTML"; | |||
| 1216 | <head> | |||||
| 1217 | <meta charset="UTF-8"> | |||||
| 1218 | <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |||||
| 1219 | <title>$self->{title}</title> | |||||
| 1220 | <script src="https://d3js.org/d3.v7.min.js"></script> | |||||
| 1221 | </head> | |||||
| 1222 | HTML | |||||
| 1223 | 2 | 3 | return $html; | |||
| 1224 | } | |||||
| 1225 | ||||||
| 1226 - 1268 | =head1 SUPPORT This module is provided as-is without any warranty. Please report any bugs or feature requests to C<bug-html-d3 at rt.cpan.org>, or through the web interface at L<http://rt.cpan.org/NoAuth/ReportBug.html?Queue=HTML-D3>. I will be notified, and then you'll automatically be notified of progress on your bug as I make changes. You can find documentation for this module with the perldoc command. perldoc HTML::D3 You can also look for information at: =head1 BUGS It would help to have the render routine to return the head and body components separately. =head1 SEE ALSO =over 4 =item * L<Configure an Object at Runtime|Object::Configure> =item * L<Test Dashboard|https://nigelhorne.github.io/HTML-D3/coverage/> =back =head1 AUTHOR Nigel Horne <njh@nigelhorne.com> =head1 LICENSE AND COPYRIGHT Copyright 2025-2026 Nigel Horne. Usage is subject to the GPL2 licence terms. If you use it, please let me know. =cut | |||||
| 1269 | ||||||
| 1270 | 1; | |||||