CGI Programming on the World Wide Web

Previous Chapter 7
Advanced Form Applications
Next
 

7.2 Survey/Poll and Pie Graphs

Forms and CGI programs make it easier to conduct surveys and polls on the Web. Let's look at an application that tabulates poll data and dynamically creates a pie graph illustrating the results.

This application actually consists of three distinct parts:

Here is the form that the user will see:

<HTML><HEAD><TITLE>Ice Cream Survey</TITLE></HEAD>
<BODY>
<H1>Favorite Ice Cream Survey</H1>
<HR>
<FORM ACTION="/cgi-bin/ice_cream.pl" METHOD="POST">
What is your favorite flavor of ice cream?
<P>
<INPUT TYPE="radio" NAME="ice_cream" VALUE="Vanilla" CHECKED>Vanilla<BR>
<INPUT TYPE="radio" NAME="ice_cream" VALUE="Strawberry">Strawberry<BR>
<INPUT TYPE="radio" NAME="ice_cream" VALUE="Chocolate">Chocolate<BR>
<INPUT TYPE="radio" NAME="ice_cream" VALUE="Other">Other<BR>
<P>
<INPUT TYPE="submit" VALUE="Submit the survey">
<INPUT TYPE="reset"  VALUE="Clear your choice">
</FORM>
<HR>
If you would like to see the current results, click
<A HREF="/cgi-bin/pie.pl/ice_cream.dat">here</A>.
</BODY>
</HTML>

It is a simple form that asks a single question. The form is shown in Figure 7.3.

Figure 7.3: Ice cream form

[Graphic: Figure 7-3]

Notice the use of extra path information in the HREF anchor at the bottom of the form (see code above). This path information represents the data file for this survey, ice.cream.dat, and will be stored in the environment variable PATH_INFO. We could have also used a query in the form of:

<A HREF="/cgi-bin/pie.pl?/ice_cream.dat">here</A>.

But since we are passing a filename, it seems more logical to pass the information as an extra path. If we were passing the information as a query string, we would have had to encode some of the characters.[1] Let's look at the format of the data file:

[1] There is also a potential security risk if the CGI program accepts a filename as a query. For example, a malicious user could access the program with a URL like:

http://your.machine/cgi-bin/pie.pl?%2e%2e%2f%2e%2e%2f%2e%2e%2fetc%2fpasswd
The query string decodes to "../../../etc/passwd". This could be a problem if the hacker guessed correctly, and the CGI program displays information from the file. A CGI programmer has to be very careful when evaluating queries.

Vanilla::Strawberry::Chocolate::Other
0::0::0::0
red::yellow::blue::green

As you can see, the string "::" separates each entity throughout the file. A unique separator should be used whenever you are dealing with data to ensure that it does not get mixed up with the data.

The first line contains all of the selections within the poll. The second line contains the actual data (initially, all values should be zero). And the last line represents the colors to be used to graph the options. In other words, red is used to draw the slice representing Vanilla in the pie graph. The range of colors is limited to the ones defined in the CGI pie graphics program, as you will see.

Processing the Form

The CGI program (ice_cream.pl) decodes the form information, tabulates it, and adds it to the data file. The program does not contain the form.

The program begins as follows:

#!/usr/local/bin/perl
$webmaster = "shishir\@bu\.edu";
$document_root = "/usr/local/bin/httpd_1.4.2/public";
$ice_cream_file = "/ice_cream.dat";
$full_path = $document_root . $ice_cream_file;
$exclusive_lock = 2;
$unlock = 8;
&parse_form_data(*poll);
$user_selection = $poll{'ice_cream'};

The form information is placed in the poll associative array. The parse_form_data subroutine is the same one we used previously. Since parse_form_data decodes both GET and POST submissions, users can submit their favorite flavor either with a GET query or through a form. The ice_cream field, which represents the user's selection, is stored in the user_selection variable.

if ( open (POLL, "<" . $full_path) ) {
    flock (POLL, $exclusive_lock);
    for ($loop=0; $loop < 3; $loop++) {
        $line[$loop] = <POLL>;
        $line[$loop] =~ s/\n$//;
    }

The data file is opened in read mode, and exclusively locked. The loop retrieves the first three lines from the file and stores it in the line array. Newline characters at the end of each line are removed. We use a regular expression to remove the last character rather than using the chop operator, because the third line may or may not have a newline character initially, and chop would automatically remove the last character, creating a potential problem.

    @options = split ("::", $line[0]);
    @data    = split ("::", $line[1]);
    $colors  = $line[2];
    flock (POLL, $unlock);
    close (POLL);

The first line of the file is split on the "::" delimiter and stored in the options array. Each element in this array represents a separate decision (or flavor) within the poll. The same process is repeated for the second line of the data file as well. The main reason for doing this is to find and increment the user-selected flavor, and write the information back to the file. However, the third line, which contains the color information, is not modified in any way.

    $item_no = 3;
    for ($loop=0; $loop <= $#options; $loop++) {
        if ($options[$loop] eq $user_selection) {
            $item_no = $loop;
            last;
        }
    }

The loop iterates through each flavor and compares it to the user selection. If there is a match, the item_no variable will point to the flavor in the array. If there is no match, item_no will have the default value of three, in which case, it equals "Other." The only reason it might not match is if the user accessed the script through a GET query and passed a flavor which is not included in the survey.

    $data[$item_no]++;

The data that represents the flavor is incremented.

    if ( open (POLL, ">" . $full_path) ) {
        flock (POLL, $exclusive_lock);

The file is opened in write, and not append, mode. As a result, the file will be overwritten.

        print POLL join ("::", @options), "\n";
        print POLL join ("::", @data), "\n";
        print POLL $colors, "\n";

Each element within the options and data arrays are joined with the "::" separator and written to the file. The color information is also written to the file.

        flock (POLL, $unlock);
        close (POLL);
        print "Content-type: text/html", "\n\n";
        
        print <<End_of_Thanks;
<HTML>
<HEAD><TITLE>Thank You!</TITLE></HEAD>
<BODY>
<H1>Thank You!</H1>
<HR>        
Thanks for participating in the Ice Cream survey. If you would like to see the
current results, click <A HREF="/cgi-bin/pie.pl${ice_cream_file}">here </A>.
</BODY></HTML>
End_of_Thanks

The file is unlocked and closed. A thank-you message, along with a link to the CGI program that graphs the data, is displayed.

    } else {
        &return_error (500, "Ice Cream Poll File Error",
                              "Cannot write to the poll data file [$full_path].");
    }
} else {
    &return_error (500, "Ice Cream Poll File Error",
                          "Cannot read from the poll data file [$full_path].");
}
exit (0);

If the file could not be opened successfully, error messages are sent to the client. Since both subroutines used by the ice_cream.pl program (return_error and parse_form_data) should be familiar to you by now, we won't bother to show them.

Drawing the Pie Chart

The pie.pl program reads the poll data file and outputs the results, as either a pie graph, or a simple text table, depending on the browser capabilities. The program can be accessed with the following URL:

http://your.machine/cgi-bin/pie.pl/ice_cream.dat

where we use extra path information to specify ice_cream.dat as the data file, located in the document root directory. On a graphic browser such as Netscape Navigator, the pie graph will look like Figure 7.4.

The program begins as follows:

#!/usr/local/bin/perl5
use GD;
$webmaster = "shishir\@bu\.edu";
$document_root = "/usr/local/bin/httpd_1.4.2/public";
&read_data_file (*slices, *slices_color, *slices_message);
$no_slices = &remove_empty_slices();

The gd graphics library is used to create the pie graph. The read_data_file subroutine reads the information from the data file and places the corresponding values in slices, slices_color, and slices_message arrays. The remove_empty_slices subroutine checks these three arrays for any zero values within the data, and returns the number of non-zero data values into the no_slices variable.

if ($no_slices == -1) {
    &no_data ();

When all of the values in the data file are zeros, the remove_empty_slices subroutine returns a value of -1. If a -1 is returned into the no_slices variable, the no_data subroutine is called to output a message explaining that there are no results in the data file.

} else {
    $nongraphic_browsers = 'Lynx|CERN-LineMode';
    $client_browser = $ENV{'HTTP_USER_AGENT'};
    if ($client_browser =~ /$nongraphic_browsers/) {
            &text_results();
        } else {
            &draw_pie ();
       }
}
exit(0);

If the client browser supports graphics, the draw_pie subroutine is called to display a pie graph. Otherwise, the text_results subroutine is called to display the results as text.

That's it for the main body of the program. The subroutines that do all the work follow.

The no_data subroutine displays a simple message explaining that there is no information in the data file.

sub no_data
{
    print "Content-type: text/html", "\n\n";
    
    print <<End_of_Message;
<HTML>
<HEAD><TITLE>Results</TITLE></HEAD>
<BODY>
<H1>No Results Available</H1>
<HR>
Sorry, no one has participated in this survey up to this point.
As a result, there is no data available. Try back later.
<HR>
</BODY></HTML>
End_of_Message
}

The draw_pie subroutine is responsible for drawing the actual pie graph.

sub draw_pie 
{
    local ( $legend_rect_size, $legend_rect, $max_length, $max_height,
            $pie_indent, $pie_length, $pie_height, $radius, @origin,
            $legend_indent, $legend_rect_to_text, $deg_to_rad, $image,
            $white, $black, $red, $yellow, $green, $blue, $orange,
            $percent, $loop, $degrees, $x, $y, $legend_x, $legend_y,
            $legend_rect_y, $text, $message);

The pie graph consists of various colored slices representing the different choices, and a legend that points out the color that represents each choice. All of the local variables needed to create the graph are defined.

    $legend_rect_size = 10;
    $legend_rect = $legend_rect_size * 2;

The legend_rect_size variable represents the length and height of each rectangle (actually a square) in the legend. legend_rect is simply the number of pixels from one rectangle to another, taking into account the spacing between adjacent rectangles.

    $max_length = 450;
    if ($no_slices > 8) {
        $max_height = 200 + ( ($no_slices - 8) * $legend_rect );
    } else {
        $max_height = 200;
    }

The length of the image is set to 450 pixels. However, the height of the image is based on the number of options (or flavors) within a poll. This is because the legend rectangles are drawn vertically. If there are eight options or less, the height is set to 200 pixels. On the other hand, if the number of options is greater than eight, the excess amount is multiplied by legend_rect and added to 200 to determine the height of the image.

    $pie_indent = 10;
    $pie_length = $pie_height = 200;
    $radius = $pie_height / 2;

The process of actually drawing the pie is very similar to drawing a clock (see Chapter 6, Hypermedia Documents). The pie is indented from the left and top edges by the value stored in pie_indent. The length and height of the pie graph is 200 pixels, and is constant. The radius of the pie is the diameter of the circle--represented by pie_length and pie_height --divided by two.

    @origin = ($radius + $pie_indent, $max_height / 2);
    $legend_indent = $pie_length + 40;
    $legend_rect_to_text = 25;
    $deg_to_rad = (atan2 (1, 1) * 4) / 180;

The origin is defined to be the center of the pie graph. The legend is spaced 40 pixels from the right edge of the graph. The legend_rect_to_text variable determines the amount of pixels from a legend rectangle to the start of the explanatory text.

    $image = new GD::Image ($max_length, $max_height);
    $white = $image->colorAllocate (255, 255, 255);
    $black = $image->colorAllocate(0, 0, 0);
    $red = $image->colorAllocate (255, 0, 0);
    $yellow = $image->colorAllocate (255, 255, 0);
    $green = $image->colorAllocate(0, 255, 0);
    $blue = $image->colorAllocate(0, 0, 255);
    $orange = $image->colorAllocate(255, 165, 0);

A new image is created, and some colors are allocated. As mentioned earlier, the colors that are specified in the data file are limited to the ones defined in the preceding code.

    grep ($_ = eval("\$$_"), @slices_color);

This is a new construct you have not seen before. It takes each element within the slices_color array, evaluates it at run-time, and stores the corresponding RGB index back in the index. It is equivalent to the following code:

for ($loop=0; $loop <= $no_slices; $loop++) {
    $temp_color = $slices_color[$loop];
    $slices_color[$loop] = eval("\$$temp_color");
}

As you can clearly see, the grep equivalent is so much more compact. The slices_color array contains the colors specified in the data file. And the colors above are also defined with English names. As a result, we can take a color from the data file, such as "yellow," and determine the RGB index by evaluating $yellow. This is exactly what the eval statement does.

    $image->arc (@origin, $pie_length, $pie_height, 0, 360, $black);

A black circle is drawn from the origin, i.e., the center of the pie graph.

    $percent = 0;
    for ($loop=0; $loop <= $no_slices; $loop++) {
        $percent += $slices[$loop];
        $degrees = int ($percent * 360) * $deg_to_rad;
        $image->line (  $origin[0],
                        $origin[1],
                        $origin[0] + ($radius * cos ($degrees)),
                        $origin[1] + ($radius * sin ($degrees)),
                        $slices_color[$loop] );
    }

The read_data_file subroutine, called at the beginning of the program, also calculates percentages for each option and stores them in the slices array. The proportion of votes that go to each flavor is called the "percentage" here, although it's actually a fraction of 1, not 100. For example, if there were a total of five votes cast with two votes for "Vanilla," the value for "Vanilla" would be 0.4.

The loop iterates through each percentage value and draws a line from the origin to the outer edge of the circle. Initially, the first percentage value is multiplied by 360 degrees to determine the angle at which the first line should be drawn. On each successive iteration through the loop, the percentage value represents the sum of all the percentage values up to that point. Then, this percentage value is used to draw the next line, until the sum of the total percentage values equal 100%.

    $percent = 0;
    for ($loop=0; $loop <= $no_slices; $loop++) {
        $percent += $slices[$loop];
        $degrees = int (($percent * 360) - 1) * $deg_to_rad;
    
        $x = $origin[0] + ( ($radius - 10) * cos ($degrees) );
        $y = $origin[1] + ( ($radius - 10) * sin ($degrees) );
    
        $image->fill ($x, $y, $slices_color[$loop]);
    }

This fills the areas represented by the various colored lines produced by the previous loop. The fill function in the gd library works in the same manner as the "paint bucket" operation in most drawing programs. It colors an area pixel by pixel until it reaches a pixel that contains a different color than that of the starting pixel. That is the reason why this loop and the previous one cannot be combined, as different colored lines must be drawn first. The starting pixel is calculated so that its angle-from the origin-is slightly less than that of the previously drawn line. As a result, when the fill function is called, the area between two differently colored lines is flooded with color.

    $legend_x = $legend_indent;
    $legend_y = ( $max_height - ($no_slices * $legend_rect) - 
                ($legend_rect * 0.75) ) / 2;

The legend's x coordinate is simply defined by the legend_indent variable. However, the y coordinate is calculated in such a way that the legend will be centered with respect to the pie graph.

    for ($loop=0; $loop <= $no_slices; $loop++) {
        $legend_rect_y = $legend_y + ($loop * $legend_rect);
        $text = pack ("A18", $slices_message[$loop]);

This loop draws the rectangles and the corresponding text. The y coordinate is incremented each time through the loop. The text variable reserves 18 characters for the explanatory text. If the text exceeds this limit, it is truncated. Otherwise, it is padded to the limit with spaces.

         $message = sprintf ("%s (%4.2f%%)", $text, $slices[$loop] * 100);

The message variable is formatted to display the text and the corresponding percentage value.

        $image->filledRectangle (   $legend_x, 
                                    $legend_rect_y,
                                    $legend_x + $legend_rect_size,
                                    $legend_rect_y + $legend_rect_size, 
                                    $slices_color[$loop] );
        $image->string ( gdSmallFont,
                         $legend_x + $legend_rect_to_text,
                         $legend_rect_y,
                         $message,
                         $black );
        }

The rectangle is drawn, and the text is displayed.

    $image->transparent($white);
    
    $| = 1;
    print "Content-type: image/gif", "\n\n";
    print $image->gif;
}

Finally, white is chosen as the transparent color to create a transparent image.

The draw_pie subroutine ends by printing the Content-type header (using a content type of image/gif) and then the image itself.

For non-graphic browsers, we want to be able to generate the results in text format. The text_results subroutine does just that.

sub text_results
{
    local ($text, $message, $loop);
    print "Content-type: text/html", "\n\n";
    
    print <<End_of_Results;
<HTML>
<HEAD><TITLE>Results</TITLE></HEAD>
<BODY>
<H1>Results</H1>
<HR>
<PRE>
End_of_Results
    for ($loop=0; $loop <= $no_slices; $loop++) {
        $text = pack ("A18", $slices_message[$loop]);
        $message = sprintf ("%s (%4.2f%%)", $text, $slices[$loop] * 100);
        print $message, "\n";
    }
    print "</PRE><HR>", "\n";
    print "</BODY></HTML>", "\n";
}

The data is formatted using the sprintf function and displayed. The string representing the flavor is limited to 18 characters.

The read_data_file subroutine opens and reads the ice_cream.dat file and returns the results.

sub read_data_file
{
    local (*slices, *slices_color, *slices_message) = @_;
    local (@line, $total_votes, $poll_file, $loop, $exclusive_lock, $unlock);
    
    $exclusive_lock = 2;
    $unlock = 8;
    if ($ENV{'PATH_INFO'}) {
        $poll_file = $document_root . $ENV{'PATH_INFO'};
    } else {
        &return_error (500, "Poll Data File Error",
                 "A poll data file has to be specified.");
    }

The environment variable PATH_INFO is checked to see if it contains any information. If a null string is returned, an error message is output. If a filename is specified, the server root directory is concatenated to the data file. Unlike a query, the leading "/" is returned as part of the variable.

    if ( open (POLL, "<" . $poll_file) ) {
        flock (POLL, $exclusive_lock);

The data file is opened in read mode. If the file cannot be opened, an error message is returned.

        for ($loop=0; $loop < 3; $loop++) {
            $line[$loop] = <POLL>;
            $line[$loop] =~ s/\n$//;
        }        
        @slices_message = split ("::", $line[0]);
        @slices         = split ("::", $line[1]);
        @slices_color   = split ("::", $line[2]);
       
        flock (POLL, $unlock);
        close (POLL);

Three lines are read from the data file. The lines are split on the "::" character and stored in arrays. The file is unlocked and closed.

        $total_votes = 0;
        for ($loop=0; $loop <= $#slices; $loop++) {
            $total_votes += $slices[$loop];
        }

The total number of votes is determined by adding each element of the slices array.

        if ($total_votes > 0) {
            grep ($_ = ($_ / $total_votes), @slices);
        }

Each element of the slices array is modified to contain the percentage value, instead of the number of votes. You should always check to see that the divisor is greater than zero, as Perl will return an "Illegal division by zero" error.

    } else {
        &return_error (500, "Poll Data File Error",
                 "Cannot read from the poll data file [$poll_file].");
    }
}

If the program cannot open the data file, an error message is displayed.

The final subroutine in pie.pl is remove_empty_slices.

sub remove_empty_slices
{
    local ($loop) = 0;
    while (defined ($slices[$loop])) {
        if ($slices[$loop] <= 0.0) {
            splice(@slices, $loop, 1);
            splice(@slices_color, $loop, 1);
            splice(@slices_message, $loop, 1);
        } else {
            $loop++;
        }
    }
    return ($#slices);
}

In order to save the program from processing choices (or flavors) that have zero votes, those elements and their corresponding colors and text are removed. The splice function removes an element from the array.


Previous Home Next
Guestbook Book Index Quiz/Test Form Application

HTML: The Definitive Guide CGI Programming JavaScript: The Definitive Guide Programming Perl WebMaster in a Nutshell