Graph.pm 21.9 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->relative_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->relative_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
172
    foreach my $source_analysis ( @{ $pipeline->get_source_analyses } ) {
            # run the recursion in each component that has a non-cyclic start:
        $self->_propagate_allocation( $source_analysis );
173
    }
174
175
176
177
    foreach my $cyclic_analysis ( $pipeline->collection_of( 'Analysis' )->list ) {
        next if(defined $cyclic_analysis->{'_funnel_dfr'});
        $self->_propagate_allocation( $cyclic_analysis );
    }
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
184
185
    foreach my $source_analysis ( @{ $pipeline->get_source_analyses } ) {
            # run the recursion in each component that has a non-cyclic start:
        $self->_add_analysis_node( $source_analysis );
186
    }
187
188
189
    foreach my $cyclic_analysis ( $pipeline->collection_of( 'Analysis' )->list ) {
        next if($self->{'_created_analysis'}{ $cyclic_analysis });
        $self->_add_analysis_node( $cyclic_analysis );
190
    }
191

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

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

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

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

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

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

                    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"
                }
228
229
230
231
            }
        }

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

    return $self->graph();
}


240
sub _propagate_allocation {
241
    my ($self, $source_object, $source_rule, $curr_allocation ) = @_;
242

243
    $curr_allocation ||= '';
244

245
246
247
    if(!exists $source_object->{'_funnel_dfr'} ) {     # only allocate on the first-come basis:
        $source_object->{'_funnel_dfr'} = $curr_allocation;
        if($source_rule) { $source_rule->{'_funnel_dfr'} = $curr_allocation; }
248

249
        if(UNIVERSAL::isa($source_object, 'Bio::EnsEMBL::Hive::Analysis')) {
250

251
            foreach my $group ( @{ $source_object->get_grouped_dataflow_rules } ) {
252

253
                my ($df_rule, $fan_dfrs) = @$group;
254

255
256
                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";
257

258
                unless(UNIVERSAL::isa($target_object, 'Bio::EnsEMBL::Hive::Accumulator')) {
259

260
261
                        # the funnel itself points at the factory analysis, to draw the funnel's midpoint outside of the box:
                    $self->_propagate_allocation( $target_object, @$fan_dfrs && $df_rule, $curr_allocation );
262

263
264
265
266
267
268
269
                        # all fan members point to the funnel:
                    foreach my $fan_dfr (@$fan_dfrs) {
                        $self->_propagate_allocation( $fan_dfr->to_analysis, $fan_dfr, $df_rule );
                    }
                } # /unless
            } # /foreach group
        } # if Analysis
270

271
272
    } elsif($source_rule) {
        $source_rule->{'_funnel_dfr'} = $source_object->{'_funnel_dfr'};    # correction for multiple entries into the same box (probably needs re-thinking)
273
    }
274
275
}

276

277
278
sub _add_pipeline_label {
    my ($self, $pipeline_label) = @_;
279

280
    my $node_fontname  = $self->config_get('Node', 'Details', 'Font');
281
    $self->graph()->add_node( 'Details',
282
        label     => $pipeline_label,
283
284
        fontname  => $node_fontname,
        shape     => 'plaintext',
285
286
287
    );
}

288

289
sub _add_analysis_node {
290
    my ($self, $analysis) = @_;
291

292
293
294
295
    my $this_analysis_node_name                           = $self->_analysis_node_name( $analysis );

    return $this_analysis_node_name if($self->{'_created_analysis'}{ $analysis }++);   # making sure every Analysis node gets created no more than once

296
    my $analysis_stats = $analysis->stats();
297

298
299
300
301
302
303
    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');
304
    my $hive_pipeline                                     = $self->pipeline;
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322

    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;
323
    my $analysis_label  = '<<table border="0" cellborder="0" cellspacing="0" cellpadding="1"><tr><td colspan="'.$colspan.'">'.$analysis->relative_display_name( $hive_pipeline ).' ('.($analysis->dbID || 'unstored').')</td></tr>';
324
325
326
327
328
329
330
331
332
    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>};
        }
    }

333
    if( my $job_limit = $self->config_get('DisplayJobs') ) {
334
335
336
337
338
        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 );
        }

339
        my @jobs = @{ $analysis->jobs_collection };
340
341
342
343
344
345

        my $hit_limit;
        if(scalar(@jobs)>$job_limit) {
            pop @jobs;
            $hit_limit = 1;
        }
346
347
348
349
350

        $analysis_label    .= '<tr><td colspan="'.$colspan.'"> </td></tr>';
        foreach my $job (@jobs) {
            my $input_id = $job->input_id;
            my $status   = $job->status;
351
            my $job_id   = $job->dbID || 'unstored';
352
353
354
355
356
            $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>};
        }
357
358

        if($hit_limit) {
359
            $analysis_label    .= qq{<tr><td colspan="$colspan">[ + }.($total_job_count-$job_limit).qq{ more jobs ]</td></tr>};
360
        }
361
362
    }
    $analysis_label    .= '</table>>';
363
  
364
    $self->graph->add_node( $this_analysis_node_name,
365
366
367
368
369
370
        label       => $analysis_label,
        shape       => 'record',
        fontname    => $node_fontname,
        style       => $style,
        fillcolor   => $analysis_status_colour,
    );
Leo Gordon's avatar
Leo Gordon committed
371
372

    $self->_add_control_rules( $analysis->control_rules_collection );
373
    $self->_add_dataflow_rules( $analysis );
374
375

    return $this_analysis_node_name;
376
377
378
}


379
sub _add_control_rules {
380
381
382
383
    my ($self, $ctrl_rules) = @_;

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

385
386
387
388
        #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;
389

390
391
        my $ctrled_is_local     = $ctrled_analysis->is_local_to( $self->pipeline );
        my $condition_is_local  = $condition_analysis->is_local_to( $self->pipeline );
392

393
        if($ctrled_is_local and !$condition_is_local) {     # register a new "near neighbour" node if it's reachable by following one rule "out":
394
            $self->{'_foreign_analyses'}{ $condition_analysis->relative_display_name($self->pipeline) } = $condition_analysis;
395
396
        }

397
        next unless( $ctrled_is_local or $condition_is_local or $self->{'_foreign_analyses'}{ $condition_analysis->relative_display_name($self->pipeline) } );
398
399
400
401
402
403
404
405
406

        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',
        );
    }
407
408
}

409

410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
sub _branch_and_template {
    my ($self, $df_rule) = @_;

    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;

    return '#'.$df_rule->branch_code.($input_id_template ? ":\n".$input_id_template : '');
}


sub _twopart_arrow {
    my ($self, $df_rule) = @_;

    my $graph               = $self->graph();
    my $dataflow_colour     = $self->config_get('Edge', 'Data', 'Colour');
    my $df_edge_fontname    = $self->config_get('Edge', 'Data', 'Font');

    my $midpoint_name       = _midpoint_name( $df_rule );

    my $from_node_name      = $self->_analysis_node_name( $df_rule->from_analysis );
    my $target_node_name    = $self->_analysis_node_name( $df_rule->to_analysis );

    $graph->add_node( $midpoint_name,   # midpoint itself
        color       => $dataflow_colour,
        label       => '',
        shape       => 'point',
        fixedsize   => 1,
        width       => 0.01,
        height      => 0.01,
    );
    $graph->add_edge( $from_node_name => $midpoint_name, # first half of the two-part arrow
        color       => $dataflow_colour,
        arrowhead   => 'none',
        fontname    => $df_edge_fontname,
        fontcolor   => $dataflow_colour,
        label       => $self->_branch_and_template( $df_rule ),
    );
    $graph->add_edge( $midpoint_name => $target_node_name,   # second half of the two-part arrow
        color     => $dataflow_colour,
    );

    return $midpoint_name;
}


455
sub _add_dataflow_rules {
456
    my ($self, $from_analysis) = @_;
457

458
    my $graph               = $self->graph();
459
460
461
462
    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');
463

464
    foreach my $group ( @{ $from_analysis->get_grouped_dataflow_rules } ) {
465

466
        my ($df_rule, $fan_dfrs) = @$group;
467

468
        my $from_node_name  = $self->_analysis_node_name( $from_analysis );
469
        my $target_node_name;
470
        my $target_object   = $df_rule->to_analysis
471
472
            or die "Could not fetch a target object for url='".$df_rule->to_analysis_url."', please check your database for consistency.\n";

473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
        if(UNIVERSAL::isa($target_object, 'Bio::EnsEMBL::Hive::Accumulator')) {

            my $funnel_analysis = $from_analysis->{'_funnel_dfr'}
                or die "Could not find funnel analysis for the ".$target_object->toString."\n";

                # one-part dashed arrow:
            $graph->add_edge( $from_node_name => _midpoint_name( $funnel_analysis ),
                color       => $accu_colour,
                style       => 'dashed',
                label       => '#'.$df_rule->branch_code.":\n".$target_object->relative_display_name( $self->pipeline ),
                fontname    => $df_edge_fontname,
                fontcolor   => $accu_colour,
                dir         => 'both',
                arrowtail   => 'crow',
            );
            next;

        } elsif(UNIVERSAL::isa($target_object, 'Bio::EnsEMBL::Hive::Analysis')) {    # skip some *really* foreign dataflow rules:
491

492
            my $from_is_local   = $df_rule->from_analysis->is_local_to( $self->pipeline );
493
            my $target_is_local = $target_object->is_local_to( $self->pipeline );
494

495
            if($from_is_local and !$target_is_local) {  # register a new "near neighbour" node if it's reachable by following one rule "out":
496
                $self->{'_foreign_analyses'}{ $target_object->relative_display_name($self->pipeline) } = $target_object;
497
498
            }

499
            next unless( $from_is_local or $target_is_local or $self->{'_foreign_analyses'}{ $target_object->relative_display_name($self->pipeline) } );
500
501
502
503
504
505

            $target_node_name = $self->_add_analysis_node( $target_object );

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

            $target_node_name = $self->_add_table_node( $df_rule );
506
        }
507

508
        if(@$fan_dfrs) {    # semaphored funnel case => all rules have an Analysis target and have two parts:
509

510
            my $funnel_midpoint_name = $self->_twopart_arrow( $df_rule );
511

512
            foreach my $fan_dfr (@$fan_dfrs) {
513
514
515
516
517
518

                my $fan_target_object = $fan_dfr->to_analysis;
                die "All semaphored fan rules must be wired to Analyses" unless(UNIVERSAL::isa($fan_target_object, 'Bio::EnsEMBL::Hive::Analysis'));

                $self->_add_analysis_node( $fan_target_object );

519
                my $fan_midpoint_name = $self->_twopart_arrow( $fan_dfr );
520

521
522
                    # add a semaphore inter-rule blocking arc:
                $graph->add_edge( $fan_midpoint_name => $funnel_midpoint_name,
523
                    color     => $semablock_colour,
524
525
526
527
528
529
                    style     => 'dashed',
                    arrowhead => 'tee',
                    dir       => 'both',
                    arrowtail => 'crow',
                );
            }
530

531
        } else {    # one-part solid arrow either to an analysis or to a table:
532
            $graph->add_edge( $from_node_name => $target_node_name,
533
                color       => $dataflow_colour,
534
                fontname    => $df_edge_fontname,
535
                fontcolor   => $dataflow_colour,
536
                label       => $self->_branch_and_template( $df_rule ),
537
            );
538
        }
539

540
    } # /foreach my $group
541
542
}

543

544
sub _add_table_node {
545
    my ($self, $df_rule) = @_;
546

547
548
549
550
    my $node_fontname           = $self->config_get('Node', 'Table', 'Font');
    my $hive_pipeline           = $self->pipeline;
    my $naked_table             = $df_rule->to_analysis;
    my $this_table_node_name    = $self->_table_node_name( $df_rule );
551

552
    my (@column_names, $columns, $table_data, $data_limit, $hit_limit);
553

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

556
        @column_names = sort keys %{$naked_table_adaptor->column_set};
557
        $columns = scalar(@column_names);
558
        $table_data = $naked_table_adaptor->fetch_all( 'LIMIT '.($data_limit+1) );
559
560
561
562
563

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

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

568
    if( $self->config_get('DisplayData') and $columns) {
569
570
571
572
573
        $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>';
        }
574
575
576
        if($hit_limit) {
            $table_label  .= qq{<tr><td colspan="$columns">[ more data ]</td></tr>};
        }
577
578
579
    }
    $table_label .= '</table>>';

580
    $self->graph()->add_node( $this_table_node_name,
581
582
583
584
585
        label => $table_label,
        shape => 'record',
        fontname => $node_fontname,
        color => $self->config_get('Node', 'Table', 'Colour'),
    );
586
587

    return $this_table_node_name;
588
589
}

Leo Gordon's avatar
Leo Gordon committed
590
1;