| 1 | package Vee::Utils::SVG; |
|---|
| 2 | |
|---|
| 3 | use strict; |
|---|
| 4 | use warnings; |
|---|
| 5 | use constant PI => 3.14159265359; |
|---|
| 6 | use List::Util qw/sum/; |
|---|
| 7 | use Exporter 'import'; |
|---|
| 8 | our @EXPORT = qw/piechart histogram/; |
|---|
| 9 | |
|---|
| 10 | =head1 NAME |
|---|
| 11 | |
|---|
| 12 | Vee::Controller::Utils::SVG - Catalyst Controller |
|---|
| 13 | |
|---|
| 14 | =head1 DESCRIPTION |
|---|
| 15 | |
|---|
| 16 | Catalyst Controller. |
|---|
| 17 | |
|---|
| 18 | =head1 METHODS |
|---|
| 19 | |
|---|
| 20 | =cut |
|---|
| 21 | |
|---|
| 22 | =head2 piechart |
|---|
| 23 | |
|---|
| 24 | THIS IS NOT A METHOD. |
|---|
| 25 | |
|---|
| 26 | Returns an SVG pie chart, given a list of fields and a list of data points. |
|---|
| 27 | Eventually I'd like to split this off into its own module. |
|---|
| 28 | |
|---|
| 29 | =cut |
|---|
| 30 | |
|---|
| 31 | my $default_svg_pie_css = <<END_CSS; |
|---|
| 32 | circle#pie { |
|---|
| 33 | stroke: black; |
|---|
| 34 | stroke-width: 2px; |
|---|
| 35 | fill: white; |
|---|
| 36 | } |
|---|
| 37 | |
|---|
| 38 | path.slice { |
|---|
| 39 | fill-opacity: 0.67; |
|---|
| 40 | fill: white; |
|---|
| 41 | stroke: black; |
|---|
| 42 | stroke-width: 1px; |
|---|
| 43 | } |
|---|
| 44 | |
|---|
| 45 | rect.legend-frame { |
|---|
| 46 | fill: white; |
|---|
| 47 | fill-opacity: 0.0; |
|---|
| 48 | stroke: transparent; |
|---|
| 49 | stroke-width: 1px; |
|---|
| 50 | } |
|---|
| 51 | |
|---|
| 52 | text.legend-label { |
|---|
| 53 | text-anchor: start; |
|---|
| 54 | dominant-baseline: middle; |
|---|
| 55 | } |
|---|
| 56 | |
|---|
| 57 | .field:hover .slice { |
|---|
| 58 | fill-opacity: 0.33; |
|---|
| 59 | } |
|---|
| 60 | .field:hover .legend-frame { |
|---|
| 61 | fill-opacity: 0.75; |
|---|
| 62 | stroke: #c0c0c0; |
|---|
| 63 | } |
|---|
| 64 | |
|---|
| 65 | #field1 .slice, #field1 .legend-box { fill: #ff0000; } |
|---|
| 66 | #field2 .slice, #field2 .legend-box { fill: #ff8000; } |
|---|
| 67 | #field3 .slice, #field3 .legend-box { fill: #ffff00; } |
|---|
| 68 | #field4 .slice, #field4 .legend-box { fill: #80ff00; } |
|---|
| 69 | #field5 .slice, #field5 .legend-box { fill: #00ff00; } |
|---|
| 70 | #field6 .slice, #field6 .legend-box { fill: #00ff80; } |
|---|
| 71 | #field7 .slice, #field7 .legend-box { fill: #00ffff; } |
|---|
| 72 | #field8 .slice, #field8 .legend-box { fill: #0080ff; } |
|---|
| 73 | #field9 .slice, #field9 .legend-box { fill: #0000ff; } |
|---|
| 74 | #field10 .slice, #field10 .legend-box { fill: #8000ff; } |
|---|
| 75 | #field11 .slice, #field11 .legend-box { fill: #ff00ff; } |
|---|
| 76 | #field12 .slice, #field12 .legend-box { fill: #ff0080; } |
|---|
| 77 | END_CSS |
|---|
| 78 | |
|---|
| 79 | sub piechart { |
|---|
| 80 | my ($fields, $data, $opts) = @_; |
|---|
| 81 | my @fields = @$fields; |
|---|
| 82 | my @data = @$data; |
|---|
| 83 | |
|---|
| 84 | my $total = sum @data; |
|---|
| 85 | |
|---|
| 86 | # some default values that should be overridden! |
|---|
| 87 | my $scale = $opts->{scale} || 1; |
|---|
| 88 | my $pie_radius = 100 * $scale; |
|---|
| 89 | my $pie_padding = 10; |
|---|
| 90 | |
|---|
| 91 | # calculated now for convenience |
|---|
| 92 | my $pie_diameter = $pie_radius * 2; |
|---|
| 93 | my $width = $pie_diameter + $pie_padding * 2; |
|---|
| 94 | my $height = $pie_diameter + $pie_padding * 2; |
|---|
| 95 | $width *= 2; |
|---|
| 96 | # scaling makes things dumb |
|---|
| 97 | $width *= 2 if ($scale < 1); |
|---|
| 98 | my ($center_x, $center_y) = ($pie_radius + $pie_padding) x 2; |
|---|
| 99 | |
|---|
| 100 | my $svg = qq[<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.0//EN" "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">\n]; |
|---|
| 101 | $svg .= qq[<svg width="$width" height="$height" viewBox="0 0 $width $height" xmlns="http://www.w3.org/2000/svg">\n]; |
|---|
| 102 | $svg .= qq[<style type="text/css">\n]; |
|---|
| 103 | $svg .= $default_svg_pie_css; |
|---|
| 104 | $svg .= qq[</style>\n]; |
|---|
| 105 | |
|---|
| 106 | # if there are no fields and/or data, this won't work! |
|---|
| 107 | if (!@fields || !@data || $total < 1) { |
|---|
| 108 | $svg .= qq[<g class="field">]; |
|---|
| 109 | $svg .= qq[ <text x="18" y="12" class="legend-label">There is no data to display.</text>\n</g>\n</svg>]; |
|---|
| 110 | return $svg; |
|---|
| 111 | } |
|---|
| 112 | |
|---|
| 113 | my $legend_left = $pie_diameter + $pie_padding * 3; |
|---|
| 114 | my $legend_row_height = $pie_diameter / @fields; |
|---|
| 115 | my $legend_box_size = $legend_row_height * 2 / 3; |
|---|
| 116 | |
|---|
| 117 | $svg .= qq[<circle cx="$center_x" cy="$center_y" r="$pie_radius" id="pie"/>\n]; |
|---|
| 118 | |
|---|
| 119 | my $angle = 0; |
|---|
| 120 | my $last_x = sprintf '%.3f', $center_x + $pie_radius * cos $angle; |
|---|
| 121 | my $last_y = sprintf '%.3f', $center_y + $pie_radius * sin $angle; |
|---|
| 122 | for my $i (0 .. $#data) { |
|---|
| 123 | my $field = $fields[$i]; |
|---|
| 124 | my $datum = $data[$i]; |
|---|
| 125 | my $ratio = $datum / $total; |
|---|
| 126 | |
|---|
| 127 | $angle += $ratio * 2 * PI; |
|---|
| 128 | $angle /=2 if ($ratio == 1); #if angle is 360, make it 180 (see hack below) |
|---|
| 129 | my $to_x = sprintf '%.3f', $center_x + $pie_radius * cos $angle; |
|---|
| 130 | my $to_y = sprintf '%.3f', $center_y + $pie_radius * sin $angle; |
|---|
| 131 | |
|---|
| 132 | $svg .= qq[<g class="field" id="field@{[ $i + 1 ]}" title="@{[ $datum . sprintf(" (%.0f%%)", ($ratio * 100)) ]}">\n]; |
|---|
| 133 | |
|---|
| 134 | # slice itself |
|---|
| 135 | $svg .= qq[ <path d="]; |
|---|
| 136 | $svg .= qq[ M$last_x $last_y ]; |
|---|
| 137 | # kind of hacky: if the angle is 360, draw 180 degrees of the circle, then draw the other 180 degrees |
|---|
| 138 | # breaks in firefox otherwise |
|---|
| 139 | if ($ratio == 1) { |
|---|
| 140 | $svg .= qq[ A$pie_radius $pie_radius 0 1,1 $to_x $to_y ]; |
|---|
| 141 | $svg .= qq[ A$pie_radius $pie_radius 0 1,1 $last_x $last_y ]; |
|---|
| 142 | } else { |
|---|
| 143 | $svg .= qq[ A$pie_radius $pie_radius 0 @{[ $ratio > 0.5 ? 1 : 0 ]},1 $to_x $to_y ]; |
|---|
| 144 | } |
|---|
| 145 | $svg .= qq[ L$center_x $center_y ] unless ($ratio == 0 || $ratio == 1); |
|---|
| 146 | $svg .= qq[ Z ]; |
|---|
| 147 | $svg .= qq[" class="slice"/>\n]; |
|---|
| 148 | |
|---|
| 149 | # legend |
|---|
| 150 | my $legend_row_y = $pie_padding + $legend_row_height * $i; |
|---|
| 151 | my $legend_box_y = $legend_row_y + $legend_row_height * 0.1667; # top of box |
|---|
| 152 | my $legend_box_x = $legend_left + $legend_row_height * 0.1667; # left of box |
|---|
| 153 | my $legend_label_x = $legend_left + $legend_row_height; |
|---|
| 154 | my $legend_label_y = $legend_row_y + $legend_row_height * 0.5; # middle of text |
|---|
| 155 | $svg .= qq[ <rect x="$legend_left" y="$legend_row_y" width="@{[ $width - $legend_left ]}" height="$legend_row_height" class="legend-frame"/>\n]; |
|---|
| 156 | $svg .= qq[ <rect x="$legend_box_x" y="$legend_box_y" width="$legend_box_size" height="$legend_box_size" rx="2" class="legend-box"/>\n]; |
|---|
| 157 | $svg .= qq[ <text x="$legend_label_x" y="$legend_label_y" class="legend-label">$field ]; |
|---|
| 158 | $svg .= qq[</text>\n]; |
|---|
| 159 | |
|---|
| 160 | $svg .= qq[</g>\n]; |
|---|
| 161 | |
|---|
| 162 | $last_x = $to_x; |
|---|
| 163 | $last_y = $to_y; |
|---|
| 164 | } |
|---|
| 165 | |
|---|
| 166 | $svg .= qq[ </svg> ]; |
|---|
| 167 | |
|---|
| 168 | return $svg; |
|---|
| 169 | } |
|---|
| 170 | |
|---|
| 171 | =head2 histogram |
|---|
| 172 | |
|---|
| 173 | THIS IS NOT A METHOD. |
|---|
| 174 | |
|---|
| 175 | Returns an SVG histogram, given a list of data points. |
|---|
| 176 | Eventually I'd like to split this off into its own module. |
|---|
| 177 | |
|---|
| 178 | =cut |
|---|
| 179 | |
|---|
| 180 | my $default_svg_histogram_css = <<END_CSS; |
|---|
| 181 | circle#pie { |
|---|
| 182 | stroke: black; |
|---|
| 183 | stroke-width: 2px; |
|---|
| 184 | fill: white; |
|---|
| 185 | } |
|---|
| 186 | |
|---|
| 187 | path.slice { |
|---|
| 188 | fill-opacity: 0.7; |
|---|
| 189 | fill: white; |
|---|
| 190 | stroke: black; |
|---|
| 191 | stroke-width: 1px; |
|---|
| 192 | } |
|---|
| 193 | |
|---|
| 194 | #field1 .slice { fill: #ff0000; } |
|---|
| 195 | #field2 .slice { fill: #ff8000; } |
|---|
| 196 | #field3 .slice { fill: #ffff00; } |
|---|
| 197 | #field4 .slice { fill: #80ff00; } |
|---|
| 198 | #field5 .slice { fill: #00ff00; } |
|---|
| 199 | #field6 .slice { fill: #00ff80; } |
|---|
| 200 | #field7 .slice { fill: #00ffff; } |
|---|
| 201 | #field8 .slice { fill: #0080ff; } |
|---|
| 202 | #field9 .slice { fill: #0000ff; } |
|---|
| 203 | #field10 .slice { fill: #8000ff; } |
|---|
| 204 | #field11 .slice { fill: #ff00ff; } |
|---|
| 205 | #field12 .slice { fill: #ff0080; } |
|---|
| 206 | END_CSS |
|---|
| 207 | |
|---|
| 208 | sub histogram { |
|---|
| 209 | my ($data, %opts) = @_; |
|---|
| 210 | my @data = @$data; |
|---|
| 211 | my @fields; |
|---|
| 212 | |
|---|
| 213 | my $total = sum @data; |
|---|
| 214 | |
|---|
| 215 | # some default values that should be overridden! |
|---|
| 216 | my $pie_radius = 100; |
|---|
| 217 | my $pie_padding = 10; |
|---|
| 218 | my $pie_diameter = $pie_radius * 2; |
|---|
| 219 | my $width = $pie_diameter + $pie_padding * 2; |
|---|
| 220 | my $height = $pie_diameter + $pie_padding * 2; |
|---|
| 221 | |
|---|
| 222 | my ($center_x, $center_y) = ($pie_radius + $pie_padding) x 2; |
|---|
| 223 | |
|---|
| 224 | my $svg = qq[<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.0//EN" "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">\n]; |
|---|
| 225 | $svg .= qq[<svg width="$width" height="$height" viewBox="0 0 $width $height" xmlns="http://www.w3.org/2000/svg">\n]; |
|---|
| 226 | $svg .= qq[<style type="text/css">\n]; |
|---|
| 227 | $svg .= $default_svg_pie_css; |
|---|
| 228 | $svg .= qq[</style>\n]; |
|---|
| 229 | $svg .= qq[<circle cx="$center_x" cy="$center_y" r="$pie_radius" id="pie"/>\n]; |
|---|
| 230 | |
|---|
| 231 | my $angle = 0; |
|---|
| 232 | my $last_x = sprintf '%.3f', $center_x + $pie_radius * cos $angle; |
|---|
| 233 | my $last_y = sprintf '%.3f', $center_y + $pie_radius * sin $angle; |
|---|
| 234 | for my $i (0 .. $#data) { |
|---|
| 235 | my $field = $fields[$i]; |
|---|
| 236 | my $datum = $data[$i]; |
|---|
| 237 | my $ratio = $datum / $total; |
|---|
| 238 | |
|---|
| 239 | $angle += $ratio * 2 * PI; |
|---|
| 240 | $angle /=2 if ($ratio == 1); #if angle is 360, make it 180 (see hack below) |
|---|
| 241 | my $to_x = sprintf '%.3f', $center_x + $pie_radius * cos $angle; |
|---|
| 242 | my $to_y = sprintf '%.3f', $center_y + $pie_radius * sin $angle; |
|---|
| 243 | |
|---|
| 244 | $svg .= qq[<g class="field" id="field@{[ $i + 1 ]}">\n]; |
|---|
| 245 | |
|---|
| 246 | $svg .= qq[ <path d="]; |
|---|
| 247 | $svg .= qq[ M$last_x $last_y ]; |
|---|
| 248 | # kind of hacky: if the angle is 360, draw 180 degrees of the circle, then draw the other 180 degrees |
|---|
| 249 | # breaks in firefox otherwise |
|---|
| 250 | if ($ratio == 1) { |
|---|
| 251 | $svg .= qq[ A$pie_radius $pie_radius 0 1,1 $to_x $to_y ]; |
|---|
| 252 | $svg .= qq[ A$pie_radius $pie_radius 0 1,1 $last_x $last_y ]; |
|---|
| 253 | } else { |
|---|
| 254 | $svg .= qq[ A$pie_radius $pie_radius 0 @{[ $ratio > 0.5 ? 1 : 0 ]},1 $to_x $to_y ]; |
|---|
| 255 | } |
|---|
| 256 | $svg .= qq[ L$center_x $center_y ]; |
|---|
| 257 | $svg .= qq[ Z ]; |
|---|
| 258 | $svg .= qq[" class="slice"/>\n]; |
|---|
| 259 | $svg .= qq[</g>\n]; |
|---|
| 260 | |
|---|
| 261 | $last_x = $to_x; |
|---|
| 262 | $last_y = $to_y; |
|---|
| 263 | } |
|---|
| 264 | |
|---|
| 265 | $svg .= qq[ </svg> ]; |
|---|
| 266 | |
|---|
| 267 | return $svg; |
|---|
| 268 | } |
|---|
| 269 | |
|---|
| 270 | =head1 AUTHOR |
|---|
| 271 | |
|---|
| 272 | Maintainer: Alex "Eevee" Munroe (C<veekun@veekun.com>) |
|---|
| 273 | |
|---|
| 274 | See the included F<AUTHORS> file for a full list of contributers. |
|---|
| 275 | |
|---|
| 276 | =head1 LICENSE |
|---|
| 277 | |
|---|
| 278 | See the included F<LICENSE> file. |
|---|
| 279 | |
|---|
| 280 | =cut |
|---|
| 281 | |
|---|
| 282 | 1; |
|---|