Graph.pm 22.2 KB
Newer Older
1
=pod
2
3
4

=head1 NAME

5
    Bio::EnsEMBL::Hive::Utils::Graph
6
7
8

=head1 SYNOPSIS

9
    my $g = Bio::EnsEMBL::Hive::Utils::Graph->new( $hive_pipeline );
10
11
    my $graphviz = $g->build();
    $graphviz->as_png('location.png');
12
13
14

=head1 DESCRIPTION

15
16
17
18
19
20
21
    This is a module for converting a hive database's flow of analyses, control 
    rules and dataflows into the GraphViz model language. This information can
    then be converted to an image or to the dot language for further manipulation
    in GraphViz.

=head1 LICENSE

22
    Copyright [1999-2015] Wellcome Trust Sanger Institute and the EMBL-European Bioinformatics Institute
23
24
25
26
27
28
29
30
31

    Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
    You may obtain a copy of the License at

         http://www.apache.org/licenses/LICENSE-2.0

    Unless required by applicable law or agreed to in writing, software distributed under the License
    is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    See the License for the specific language governing permissions and limitations under the License.
32

33
=head1 CONTACT
34

35
  Please subscribe to the Hive mailing list:  http://listserver.ebi.ac.uk/mailman/listinfo/ehive-users  to discuss Hive-related questions or to be notified of our updates
36
37
38
39
40

=head1 APPENDIX

    The rest of the documentation details each of the object methods.
    Internal methods are usually preceded with a _
41
42
43

=cut

44
45
46

package Bio::EnsEMBL::Hive::Utils::Graph;

47
48
49
use strict;
use warnings;

Leo Gordon's avatar
Leo Gordon committed
50
use Bio::EnsEMBL::Hive::Analysis;
51
use Bio::EnsEMBL::Hive::Utils qw(destringify throw);
52
use Bio::EnsEMBL::Hive::Utils::GraphViz;
53
use Bio::EnsEMBL::Hive::Utils::Config;
54

55
56
use base ('Bio::EnsEMBL::Hive::Configurable');

57
58
59

=head2 new()

60
  Arg [1] : Bio::EnsEMBL::Hive::HivePipeline $pipeline;
61
62
63
64
65
              The adaptor to get information from
  Arg [2] : (optional) string $config_file_name;
                  A JSON file name to initialize the Config object with.
                  If one is not given then we don't pass anything into Config's constructor,
                  which results in loading configuration from Config's standard locations.
66
67
68
69
70
71
72
  Returntype : Graph object
  Exceptions : If the parameters are not as required
  Status     : Beta
  
=cut

sub new {
73
    my $class       = shift @_;
74
    my $pipeline    = shift @_;
75

76
    my $self = bless({}, ref($class) || $class);
77

78
79
    $self->pipeline( $pipeline );

80
81
82
    my $config = Bio::EnsEMBL::Hive::Utils::Config->new( @_ );
    $self->config($config);
    $self->context( [ 'Graph' ] );
83

84
    return $self;
85
86
87
88
89
90
91
92
93
94
95
96
97
}


=head2 graph()

  Arg [1] : The GraphViz instance created by this module
  Returntype : GraphViz
  Exceptions : None
  Status     : Beta

=cut

sub graph {
98
99
    my ($self) = @_;

100
    if(! exists $self->{'_graph'}) {
101
        my $padding  = $self->config_get('Pad') || 0;
102
        $self->{'_graph'} = Bio::EnsEMBL::Hive::Utils::GraphViz->new( name => 'AnalysisWorkflow', ratio => qq{compress"; pad = "$padding}  ); # injection hack!
103
    }
104
    return $self->{'_graph'};
105
106
107
}


108
=head2 pipeline()
109

110
111
  Arg [1] : The HivePipeline instance
  Returntype : HivePipeline
112
113
114

=cut

115
sub pipeline {
116
117
118
    my $self = shift @_;

    if(@_) {
119
        $self->{'_pipeline'} = shift @_;
120
121
    }

122
    return $self->{'_pipeline'};
123
124
125
}


126
sub _analysis_node_name {
127
    my ($self, $analysis) = @_;
128

129
    my $analysis_node_name = 'analysis_' . $analysis->display_name( $self->pipeline );
130
131
    $analysis_node_name=~s/\W/__/g;
    return $analysis_node_name;
132
133
}

134

135
sub _table_node_name {
136
    my ($self, $df_rule) = @_;
137

138
    my $table_node_name = 'table_' . $df_rule->to_analysis->display_name( $self->pipeline ) .
139
                ($self->config_get('DuplicateTables') ?  '_'.$df_rule->from_analysis->logic_name : '');
140
141
    $table_node_name=~s/\W/__/g;
    return $table_node_name;
142
143
}

144

145
sub _midpoint_name {
146
    my ($df_rule) = @_;
147

148
    if($df_rule and scalar($df_rule)=~/\((\w+)\)/) {     # a unique id of a df_rule assuming dbIDs are not available
149
150
        return 'dfr_'.$1.'_mp';
    } else {
151
        throw("Wrong argument to _midpoint_name");
152
    }
153
154
}

155
156
157
158
159
160
161
162
163
164
165

=head2 build()

  Returntype : The GraphViz object built & populated
  Exceptions : Raised if there are issues with accessing the database
  Description : Builds the graph object and returns it.
  Status     : Beta

=cut

sub build {
166
    my ($self) = @_;
167

168
    my $pipeline    = $self->pipeline;
169
170
171

        # NB: this is a very approximate algorithm with rough edges!
        # It will not find all start nodes in cyclic components!
172
    foreach my $source_analysis ( $pipeline->collection_of('Analysis')->list ) {
173
        if( !$source_analysis->inflow_rules_count and $source_analysis->is_local_to($pipeline) ) {    # if there is no dataflow into this analysis
174
                # run the recursion in each component that has a non-cyclic start:
175
            $self->_propagate_allocation( $source_analysis );
176
177
178
        }
    }

179
180
    if( $self->config_get('DisplayDetails') ) {
        $self->_add_pipeline_label( $pipeline->display_name );
181
    }
Leo Gordon's avatar
Leo Gordon committed
182

183
    foreach my $analysis ( $pipeline->collection_of('Analysis')->list ) {
184
        $self->_add_analysis_node($analysis);
185
    }
186
187
188
    foreach my $foreign_analysis ( values %{ $self->{'_foreign_analyses'}} ) {
        $self->_add_analysis_node($foreign_analysis);
    }
189

190
    if($self->config_get('DisplayStretched') ) {    # put each analysis before its' funnel midpoint
191
        foreach my $analysis ( $pipeline->collection_of('Analysis')->list ) {
192
            if($analysis->{'_funnel_dfr'}) {    # this should only affect analyses that have a funnel
193
                my $from = $self->_analysis_node_name( $analysis );
194
                my $to   = _midpoint_name( $analysis->{'_funnel_dfr'} );
195
                $self->graph->add_edge( $from => $to,
196
197
198
199
200
201
202
                    color     => 'black',
                    style     => 'invis',   # toggle visibility by changing 'invis' to 'dashed'
                );
            }
        }
    }

203
    if($self->config_get('DisplaySemaphoreBoxes') ) {
204
205
        my %cluster_2_nodes = ();

206
        foreach my $analysis ( $pipeline->collection_of('Analysis')->list ) {
207
            if(my $funnel = $analysis->{'_funnel_dfr'}) {
208
                push @{$cluster_2_nodes{ _midpoint_name( $funnel ) } }, $self->_analysis_node_name( $analysis );
209
            }
210
211

            foreach my $df_rule ( @{ $analysis->dataflow_rules_collection } ) {
212
                if( $df_rule->is_a_funnel_rule and ! $df_rule->{'_funnel_dfr'} ) {
213
214
215

                    push @{$cluster_2_nodes{ '' }}, _midpoint_name( $df_rule );     # top-level funnels define clusters (top-level "boxes")

216
                } elsif( UNIVERSAL::isa($df_rule->to_analysis, 'Bio::EnsEMBL::Hive::NakedTable') ) {
217
218
219
220
221
222
223
224
225

                    if(my $funnel = $df_rule->to_analysis->{'_funnel_dfr'}) {
                        push @{$cluster_2_nodes{ _midpoint_name( $funnel ) } }, $self->_table_node_name( $df_rule );    # table belongs to the same "box" as the dataflow source
                    }
                }

                if(my $funnel = $df_rule->{'_funnel_dfr'}) {
                    push @{$cluster_2_nodes{ _midpoint_name( $funnel ) } }, _midpoint_name( $df_rule ); # midpoints of rules that have a funnel live inside "boxes"
                }
226
227
228
229
            }
        }

        $self->graph->cluster_2_nodes( \%cluster_2_nodes );
230
231
        $self->graph->colour_scheme( $self->config_get('Box', 'ColourScheme') );
        $self->graph->colour_offset( $self->config_get('Box', 'ColourOffset') );
232
233
234
235
236
237
    }

    return $self->graph();
}


238
239
sub _propagate_allocation {
    my ($self, $source_analysis ) = @_;
240

Leo Gordon's avatar
Leo Gordon committed
241
    foreach my $df_rule ( @{ $source_analysis->dataflow_rules_collection } ) {
242
243
244
        my $target_object       = $df_rule->to_analysis
            or die "Could not fetch a target object for url='".$df_rule->to_analysis_url."', please check your database for consistency.\n";

245
        my $target_node_name;
246

247
        if(UNIVERSAL::isa($target_object, 'Bio::EnsEMBL::Hive::Analysis')) {
248
            $target_node_name = $self->_analysis_node_name( $target_object );
249
        } elsif(UNIVERSAL::isa($target_object, 'Bio::EnsEMBL::Hive::NakedTable')) {
250
            $target_node_name = $self->_table_node_name( $df_rule );
251
        } elsif(UNIVERSAL::isa($target_object, 'Bio::EnsEMBL::Hive::Accumulator')) {
252
            next;
253
        } else {
254
            warn("Do not know how to handle the type '".ref($target_object)."'");
255
            next;
256
        }
257

258
259
        my $proposed_funnel_dfr;    # will depend on whether we start a new semaphore

260
        # --------------- first assign the rules (their midpoints if applicable) --------------------
261

262
263
        my $funnel_dataflow_rule;
        if( $funnel_dataflow_rule = $df_rule->funnel_dataflow_rule ) {   # if there is a new semaphore, the dfrs involved (their midpoints) will also have to be allocated
264
            $funnel_dataflow_rule->{'_funnel_dfr'} = $source_analysis->{'_funnel_dfr'}; # draw the funnel's midpoint outside of the box
265
266

            $proposed_funnel_dfr = $df_rule->{'_funnel_dfr'} = $funnel_dataflow_rule;       # if we do start a new semaphore, report to the new funnel (based on common funnel rule's midpoint)
267
        } else {
268
            $proposed_funnel_dfr = $source_analysis->{'_funnel_dfr'} || ''; # if we don't start a new semaphore, inherit the allocation of the source
269
        }
270

271
272
        # --------------- then assign the target_objects --------------------------------------------

273
            # we allocate on first-come basis at the moment:
274
        if( exists $target_object->{'_funnel_dfr'} ) {  # node is already allocated?
275

276
277
278
279
            my $known_funnel_dfr = $target_object->{'_funnel_dfr'};

            if( $known_funnel_dfr eq $proposed_funnel_dfr) {
                # warn "analysis '$target_node_name' has already been allocated to the same '$known_funnel_dfr' by another branch";
280
            } else {
281
                # warn "analysis '$target_node_name' has already been allocated to '$known_funnel_dfr' however this branch would allocate it to '$proposed_funnel_dfr'";
282
283
            }

284
            if($funnel_dataflow_rule) {  # correction for multiple entries into the same box (probably needs re-thinking)
285
                $df_rule->{'_funnel_dfr'} = $target_object->{'_funnel_dfr'};
286
287
288
            }

        } else {
289
290
            # warn "allocating analysis '$target_node_name' to '$proposed_funnel_dfr'";
            $target_object->{'_funnel_dfr'} = $proposed_funnel_dfr;
291

292
            if(UNIVERSAL::isa($target_object, 'Bio::EnsEMBL::Hive::Analysis')) {
293
                $self->_propagate_allocation( $target_object );
294
            }
295
296
        }
    }
297
298
}

299

300
301
sub _add_pipeline_label {
    my ($self, $pipeline_label) = @_;
302

303
    my $node_fontname  = $self->config_get('Node', 'Details', 'Font');
304
    $self->graph()->add_node( 'Details',
305
        label     => $pipeline_label,
306
307
        fontname  => $node_fontname,
        shape     => 'plaintext',
308
309
310
    );
}

311

312
sub _add_analysis_node {
313
    my ($self, $analysis) = @_;
314

315
    my $analysis_stats = $analysis->stats();
316

317
318
319
320
321
322
    my ($breakout_label, $total_job_count, $count_hash)   = $analysis_stats->job_count_breakout();
    my $analysis_status                                   = $analysis_stats->status;
    my $analysis_status_colour                            = $self->config_get('Node', 'AnalysisStatus', $analysis_status, 'Colour');
    my $style                                             = $analysis->can_be_empty() ? 'dashed, filled' : 'filled' ;
    my $node_fontname                                     = $self->config_get('Node', 'AnalysisStatus', $analysis_status, 'Font');
    my $display_stats                                     = $self->config_get('DisplayStats');
323
    my $hive_pipeline                                     = $self->pipeline;
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341

    my $colspan = 0;
    my $bar_chart = '';

    if( $display_stats eq 'barchart' ) {
        foreach my $count_method (qw(SEMAPHORED READY INPROGRESS DONE FAILED)) {
            if(my $count=$count_hash->{lc($count_method).'_job_count'}) {
                $bar_chart .= '<td bgcolor="'.$self->config_get('Node', 'JobStatus', $count_method, 'Colour').'" width="'.int(100*$count/$total_job_count).'%">'.$count.lc(substr($count_method,0,1)).'</td>';
                ++$colspan;
            }
        }
        if($colspan != 1) {
            $bar_chart .= '<td>='.$total_job_count.'</td>';
            ++$colspan;
        }
    }

    $colspan ||= 1;
342
    my $analysis_label  = '<<table border="0" cellborder="0" cellspacing="0" cellpadding="1"><tr><td colspan="'.$colspan.'">'.$analysis->display_name( $hive_pipeline ).' ('.($analysis->dbID || 'unstored').')</td></tr>';
343
344
345
346
347
348
349
350
351
    if( $display_stats ) {
        $analysis_label    .= qq{<tr><td colspan="$colspan"> </td></tr>};
        if( $display_stats eq 'barchart') {
            $analysis_label    .= qq{<tr>$bar_chart</tr>};
        } elsif( $display_stats eq 'text') {
            $analysis_label    .= qq{<tr><td colspan="$colspan">$breakout_label</td></tr>};
        }
    }

352
    if( my $job_limit = $self->config_get('DisplayJobs') ) {
353
354
355
356
357
        if(my $job_adaptor = $analysis->adaptor && $analysis->adaptor->db->get_AnalysisJobAdaptor) {
            my @jobs = sort {$a->dbID <=> $b->dbID} @{ $job_adaptor->fetch_some_by_analysis_id_limit( $analysis->dbID, $job_limit+1 )};
            $analysis->jobs_collection( \@jobs );
        }

358
        my @jobs = @{ $analysis->jobs_collection };
359
360
361
362
363
364

        my $hit_limit;
        if(scalar(@jobs)>$job_limit) {
            pop @jobs;
            $hit_limit = 1;
        }
365
366
367
368
369

        $analysis_label    .= '<tr><td colspan="'.$colspan.'"> </td></tr>';
        foreach my $job (@jobs) {
            my $input_id = $job->input_id;
            my $status   = $job->status;
370
            my $job_id   = $job->dbID || 'unstored';
371
372
373
374
375
            $input_id=~s/\>/&gt;/g;
            $input_id=~s/\</&lt;/g;
            $input_id=~s/\{|\}//g;
            $analysis_label    .= qq{<tr><td colspan="$colspan" bgcolor="}.$self->config_get('Node', 'JobStatus', $status, 'Colour').qq{">$job_id [$status]: $input_id</td></tr>};
        }
376
377

        if($hit_limit) {
378
            $analysis_label    .= qq{<tr><td colspan="$colspan">[ + }.($total_job_count-$job_limit).qq{ more jobs ]</td></tr>};
379
        }
380
381
    }
    $analysis_label    .= '</table>>';
382
  
383
    $self->graph->add_node( $self->_analysis_node_name( $analysis ),
384
385
386
387
388
389
        label       => $analysis_label,
        shape       => 'record',
        fontname    => $node_fontname,
        style       => $style,
        fillcolor   => $analysis_status_colour,
    );
Leo Gordon's avatar
Leo Gordon committed
390
391
392

    $self->_add_control_rules( $analysis->control_rules_collection );
    $self->_add_dataflow_rules( $analysis->dataflow_rules_collection );
393
394
395
}


396
sub _add_control_rules {
397
398
399
400
    my ($self, $ctrl_rules) = @_;

    my $control_colour = $self->config_get('Edge', 'Control', 'Colour');
    my $graph = $self->graph();
401

402
403
404
405
        #The control rules are always from and to an analysis so no need to search for odd cases here
    foreach my $c_rule ( @$ctrl_rules ) {
        my $condition_analysis  = $c_rule->condition_analysis;
        my $ctrled_analysis     = $c_rule->ctrled_analysis;
406

407
408
        my $ctrled_is_local     = $ctrled_analysis->is_local_to( $self->pipeline );
        my $condition_is_local  = $condition_analysis->is_local_to( $self->pipeline );
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423

        if($ctrled_is_local and !$condition_is_local) {
            $self->{'_foreign_analyses'}{ $condition_analysis->display_name($self->pipeline) } = $condition_analysis;
        }

        next unless( $ctrled_is_local or $condition_is_local or $self->{'_foreign_analyses'}{ $condition_analysis->display_name($self->pipeline) } );

        my $from_node_name      = $self->_analysis_node_name( $condition_analysis );
        my $to_node_name        = $self->_analysis_node_name( $ctrled_analysis );

        $graph->add_edge( $from_node_name => $to_node_name,
            color => $control_colour,
            arrowhead => 'tee',
        );
    }
424
425
}

426

427
sub _add_dataflow_rules {
428
    my ($self, $dataflow_rules) = @_;
429

430
    my $graph = $self->graph();
431
432
433
434
    my $dataflow_colour     = $self->config_get('Edge', 'Data', 'Colour');
    my $semablock_colour    = $self->config_get('Edge', 'Semablock', 'Colour');
    my $accu_colour         = $self->config_get('Edge', 'Accu', 'Colour');
    my $df_edge_fontname    = $self->config_get('Edge', 'Data', 'Font');
435

436
    foreach my $df_rule ( @$dataflow_rules ) {
437
    
438
439
440
        my ($from_analysis, $branch_code, $funnel_dataflow_rule) =
            ($df_rule->from_analysis, $df_rule->branch_code, $df_rule->funnel_dataflow_rule);

441
442
443
        my $input_id_template = $self->config_get('DisplayInputIDTemplate') ? $df_rule->input_id_template : undef;
        $input_id_template = join(",\n", sort keys( %{destringify($input_id_template)} )) if $input_id_template;

444
445
446
        my $target_object       = $df_rule->to_analysis
            or die "Could not fetch a target object for url='".$df_rule->to_analysis_url."', please check your database for consistency.\n";

447
        my $from_node_name = $self->_analysis_node_name( $from_analysis );
448
        my $target_node_name;
449
    
450
            # Different treatment for analyses and tables:
451
        if(UNIVERSAL::isa($target_object, 'Bio::EnsEMBL::Hive::Analysis')) {
452

453
            $target_node_name = $self->_analysis_node_name( $target_object );
454

455
456
            my $from_is_local   = $from_analysis->is_local_to( $self->pipeline );
            my $target_is_local = $target_object->is_local_to( $self->pipeline );
457
458
459
460
461
462
463

            if($from_is_local and !$target_is_local) {
                $self->{'_foreign_analyses'}{ $target_object->display_name($self->pipeline) } = $target_object;
            }

            next unless( $from_is_local or $target_is_local or $self->{'_foreign_analyses'}{ $target_object->display_name($self->pipeline) } );

464
        } elsif(UNIVERSAL::isa($target_object, 'Bio::EnsEMBL::Hive::NakedTable')) {
465

466
            $target_node_name = $self->_table_node_name( $df_rule );
467
            $self->_add_table_node($target_node_name, $target_object);
468

469
        } elsif(UNIVERSAL::isa($target_object, 'Bio::EnsEMBL::Hive::Accumulator')) {
470

471
            die "Could not find funnel analysis for the ".$target_object->toString."\n" unless($from_analysis->{'_funnel_dfr'});
472
            $target_node_name = _midpoint_name( $from_analysis->{'_funnel_dfr'} );
473

474
        } else {
475
            warn("Do not know how to handle the type '".ref($target_object)."'");
476
477
478
            next;
        }

479
            # a rule needs a midpoint either if it HAS a funnel or if it IS a funnel
480
        if( $funnel_dataflow_rule or $df_rule->is_a_funnel_rule ) {
481
            my $midpoint_name = _midpoint_name( $df_rule );
482

483
            $graph->add_node( $midpoint_name,   # midpoint itself
484
                color       => $dataflow_colour,
485
486
                label       => '',
                shape       => 'point',
487
488
489
                fixedsize   => 1,
                width       => 0.01,
                height      => 0.01,
490
            );
491
            $graph->add_edge( $from_node_name => $midpoint_name, # first half of the two-part arrow
492
                color       => $dataflow_colour,
493
                arrowhead   => 'none',
494
                fontname    => $df_edge_fontname,
495
                fontcolor   => $dataflow_colour,
496
                label       => '#'.$branch_code.($input_id_template ? ":\n".$input_id_template : ''),
497
            );
498
            $graph->add_edge( $midpoint_name => $target_node_name,   # second half of the two-part arrow
499
                color     => $dataflow_colour,
500
            );
501
            if($funnel_dataflow_rule) {
502
                $graph->add_edge( $midpoint_name => _midpoint_name( $funnel_dataflow_rule ),   # semaphore inter-rule link
503
                    color     => $semablock_colour,
504
505
506
507
508
509
                    style     => 'dashed',
                    arrowhead => 'tee',
                    dir       => 'both',
                    arrowtail => 'crow',
                );
            }
510
        } elsif(UNIVERSAL::isa($target_object, 'Bio::EnsEMBL::Hive::Accumulator')) {
511
                # one-part dashed arrow:
512
            $graph->add_edge( $from_node_name => $target_node_name,
513
514
                color       => $accu_colour,
                style       => 'dashed',
515
                label       => '#'.$branch_code.":\n".$target_object->display_name( $self->pipeline ),
516
517
518
519
520
                fontname    => $df_edge_fontname,
                fontcolor   => $accu_colour,
                dir         => 'both',
                arrowtail   => 'crow',
            );
521
        } else {
522
                # one-part solid arrow:
523
            $graph->add_edge( $from_node_name => $target_node_name,
524
                color       => $dataflow_colour,
525
                fontname    => $df_edge_fontname,
526
                fontcolor   => $dataflow_colour,
527
                label       => '#'.$branch_code.($input_id_template ? ":\n".$input_id_template : ''),
528
            );
529
        } # /if( "$df_rule needs a midpoint" )
530
    } # /foreach my $df_rule (@$dataflow_rules)
531

532
533
}

534

535
sub _add_table_node {
536
    my ($self, $table_node_name, $naked_table) = @_;
537

538
    my $node_fontname   = $self->config_get('Node', 'Table', 'Font');
539
    my (@column_names, $columns, $table_data, $data_limit, $hit_limit);
540

541
    my $hive_pipeline   = $self->pipeline;
542

543
    if( $data_limit = $self->config_get('DisplayData') and my $naked_table_adaptor = $naked_table->adaptor ) {
544

545
        @column_names = sort keys %{$naked_table_adaptor->column_set};
546
        $columns = scalar(@column_names);
547
        $table_data = $naked_table_adaptor->fetch_all( 'LIMIT '.($data_limit+1) );
548
549
550
551
552

        if(scalar(@$table_data)>$data_limit) {
            pop @$table_data;
            $hit_limit = 1;
        }
553
554
    }

555
    my $table_label = '<<table border="0" cellborder="0" cellspacing="0" cellpadding="1"><tr><td colspan="'.($columns||1).'">'. $naked_table->display_name( $hive_pipeline ) .'</td></tr>';
556

557
    if( $self->config_get('DisplayData') and $columns) {
558
559
560
561
562
        $table_label .= '<tr><td colspan="'.$columns.'"> </td></tr>';
        $table_label .= '<tr>'.join('', map { qq{<td bgcolor="lightblue" border="1">$_</td>} } @column_names).'</tr>';
        foreach my $row (@$table_data) {
            $table_label .= '<tr>'.join('', map { qq{<td>$_</td>} } @{$row}{@column_names}).'</tr>';
        }
563
564
565
        if($hit_limit) {
            $table_label  .= qq{<tr><td colspan="$columns">[ more data ]</td></tr>};
        }
566
567
568
    }
    $table_label .= '</table>>';

569
    $self->graph()->add_node( $table_node_name, 
570
571
572
573
574
        label => $table_label,
        shape => 'record',
        fontname => $node_fontname,
        color => $self->config_get('Node', 'Table', 'Colour'),
    );
575
576
}

Leo Gordon's avatar
Leo Gordon committed
577
1;