diff --git a/src/Plots/Canvas.php b/src/Plots/Canvas.php new file mode 100644 index 000000000..0d1208bb0 --- /dev/null +++ b/src/Plots/Canvas.php @@ -0,0 +1,169 @@ +addPlot(function ($x) { return $x; }, 0, 100); + * $canvas->save(); + * + */ +class Canvas +{ + protected $width; + protected $height; + private $plot; + + /** + * Construct a new plotting canvas. + * + * The input arguments refer to the size of the canvas in pixels. Thus, + * when you run the save() method, the size of the resulting image file + * will be determined by these parameters. For example, running + * (new Canvas())->save() will produce an image with a default size of + * 700px by 500px. + * + * @param int $width The width of our canvas, in pixels + * @param int $height The height of our canvas, in pixels + */ + public function __construct(int $width = 700, int $height = 500) + { + if (!extension_loaded('gd')) { + if (!dl('gd.so')) { + echo "GD extension is not installed/loaded. Ensure it is setup + property and then try again"; + exit; + } + } + + $this->validateSize($width, $height); + + $this->width = $width; + $this->height = $height; + } + + /** + * Add a single plot to our canvas object. + * + * This method is used when we are including a single plot in a canvas + * object. To add multiple plots to a single canvas, use the addSubplot() + * method (to be added in a future release). + * + * The default interval of our plot is [0, 10]. + * + * @param callable $function The callback function we are plotting + * @param number $start The start of our plotting interval + * @param number $end The end of the plotting interval + * + * @return object The resulting Plot object constructed from our inputs + * and the parameters of the parent canvas object + */ + public function addPlot(callable $function, $start = 0, $end = 10): Plot + { + $width = $this->width; + $height = $this->height; + + list($start, $end) = $this->validateInterval($start, $end); + $this->plot = new Plot($function, $start, $end, $width, $height); + + return $this->plot; + } + + /** + * Modify the size of our canvas. + * + * Refer to the __construct() method for for further understanding of + * canvas sizes. + * + * @param int $width The width of our canvas, in pixels + * @param int $height The height of our canvas, in pixels + */ + public function size(int $width, int $height) + { + $this->validateSize($width, $height); + + $this->width = $width; + $this->height = $height; + + // If we've already added a plot to the canvas, adjust it's size as well + if (isset($this->plot)) { + $this->plot->size($width, $height); + } + } + + /** + * Draw plot(s) and output resulting canvas. + * + * Draw the plot object(s) stored within our canvas' plot parameter. Then, + * output the resulting canvas in a certain format. Currently, only + * JPG outputs are supported. More support should be added soon, such as + * outputting directly to a webpage, different file formats, etc. + * + * By default, this gives our canvas a white background. + */ + public function save() + { + header('Content-type: image/png'); + + $canvas = imagecreate($this->width, $this->height); + imagecolorallocate($canvas, 255, 255, 255); + + if (isset($this->plot)) { + $canvas = $this->plot->draw($canvas); + } + + imagejpeg($canvas, 'image-' . rand() . '.jpg'); + } + + /** + * Validate the input size of our canvus + * + * @throws Exception if $width or $height is negative + */ + public function validateSize(int $width, int $height) + { + if ($width < 0 || $height < 0) { + throw new \Exception("Canvas dimensions cannot be negative"); + } + } + + /** + * Valide that our input is a proper interval (not just a point). + * + * If the start point is greater than the input, swap the variables. + * + * @throws Exception if $start = $end (not an interval, just a point) + */ + public function validateInterval(int $start, int $end) + { + if ($start === $end) { + throw new \Exception("Start and end points the interval of our + graph cannot be the same. Your current input + would produce a graph over the interval + [{$start}, {$end}], which is just a single + point"); + } + + // Swap variables if start point is greater than end point + if ($start > $end) { + list($start, $end) = [$end, $start]; + } + + return [$start, $end]; + } +} diff --git a/src/Plots/Plot.php b/src/Plots/Plot.php new file mode 100644 index 000000000..4a1eecdb8 --- /dev/null +++ b/src/Plots/Plot.php @@ -0,0 +1,318 @@ +addPlot(function ($x) { return $x*sin($x); }, 0, 20); +* $plot->grid(true); +* $plot->yLabel("This is a working y-label"); +* $plot->xLabel("Time (seconds)"); +* $plot->title("Sample Title"); +* $plot->color("red"); +* $plot->thickness(5); +* $canvas->save(); +* +* There are plans to add support for the following: +* - A method to change the font for all text +* - A method to change the font size for all text +* - A method to change the font color for all text +* - A method to add more plots (functions) to the same plot +* - A child class for each text field (title and axis labels) which contains +* methods to adjust the color, size, and font of that specific text field +* - A method to add a yRange to our final plot +*/ +class Plot extends Canvas +{ + private $function; + private $start; + private $end; + + /** + * Construct a Plot object + * + * The constructed Plot object should be a child of an existing Canvas object. + * In the construction, we assign a callback function, and the start and end + * points of the interval to which we will graph the function. + * + * We should not construct a Plot object explicity (e.g. using new Plot). + * Rather, this constructor is accessed implicitly in the parent Canvas + * class, as it needs to correspond to an instance of Canvas. This is + * because the save() method draws our plot onto a specific instance of + * a GD image, and this image is initially built in the parent Canvas class. + * + * @param callable $function The callback function we are plotting + * @param number $start The start of our plotting interval + * @param number $end The end of the plotting interval + */ + public function __construct(callable $function, float $start, float $end) + { + list($start, $end) = $this->validateInterval($start, $end); + + parent::__construct(); + + $this->function = $function; + $this->start = $start; + $this->end = $end; + } + + /** + * Ajust the start and endpoint of the interval to which we are plotting + * a function. + * + * @param number $start The start of our plotting interval + * @param number $end The end of the plotting interval + */ + public function xRange(float $start, float $end) + { + list($start, $end) = $this->validateInterval($start, $end); + + $this->start = $start; + $this->end = $end; + } + + /** + * Turn the plot grid lines on or of, and specify the number of grids in + * each direction. + * + * @param bool $switch A boolean for if the grid is shown or not + * @param int $gridCountX The number of grid lines on the x-axis + * @param int $gridCountY The number of grid lines on the y-axis + * + * @throws Exception if $gridCountX or $gridCountY is negative + */ + public function grid(bool $switch = true, int $gridCountX = 10, int $gridCountY = 10) + { + if ($gridCountX < 0 || $gridCountY < 0) { + throw new \Exception("Number of grid lines cannot be negative"); + } + + $this->grid = $switch; + $this->gridCountX = 10; + $this->gridCountY = 10; + } + + /** + * Add a title (or modify the existing one) to our plot. + * + * @param string $title The title of our plot + */ + public function title(string $title) + { + $this->title = $title; + } + + /** + * Add a y-axis label (or modify the existing one) to our plot. + * + * @param string $label The y-axis of our plot + */ + public function yLabel(string $label) + { + $this->yLabel = $label; + } + + /** + * Add a x-axis label (or modify the existing one) to our plot. + * + * @param string $label The x-axis of our plot + */ + public function xLabel(string $label) + { + $this->xLabel = $label; + } + + /** + * Modify the color of our plot line/curve. + * + * Input is a string which can correspond to a number of preset colors. + * Currently, only red, green, and blue are supported, although more colors + * can easily be extended. + * + * If an input string does not match a supported color, the color will + * default to black. + * + * @param string $color The color of our plot line/curve + */ + public function color(string $color) + { + switch ($color) { + case 'red': + $color = [255, 0, 0]; + break; + case 'green': + $color = [0, 255, 0]; + break; + case 'blue': + $color = [0, 0, 255]; + break; + default: + $color = [0, 0, 0]; + } + + $this->color = $color; + } + + /** + * Modify the thickness of our plot line/curve. + * + * @param int $thickness The thickness of our plot line/curve + * + * @throws Exception if $thickness is negative + */ + public function thickness(int $thickness) + { + if ($thickness < 0) { + throw new \Exception("Thickness cannot be negative"); + } + + $this->thickness = $thickness; + } + + /** + * Draw the plot to our input canvas. + * + * Draw aspects of a single plot: x- and y-axis, (optional) x- and y-labels, + * x- and y-axis reference numbers, (optional) title, (optional) grid lines, + * and the actual plot itself. + * + * This method should not be called explicitly. Rather, it is accessed + * implicitly when you run the save() method on the parent canvas of + * a plot object. This ensures that a property $canvas property is + * created before it is passed to this method, which draws a plot onto + * a GD canvas. + * + * @param resource $canvas A GD resource passed in via a Canvas parent object + * + * @throws Exception if $canvas is not a GD resource + */ + public function draw($canvas) + { + // Verify the input is a GD resource + if (!(is_resource($canvas) && get_resource_type($canvas) === "gd")) { + throw new \Exception("The was an error constructing the canvas"); + } + + // Set convenience variables + $black = imagecolorallocate($canvas, 0, 0, 0); + $white = imagecolorallocate($canvas, 255, 255, 255); + $padding = 50; + + // Grab parameters or assign defaults + $width = $this->width; + $height = $this->height; + $title = $this->title ?? null; + $xLabel = $this->xLabel ?? null; + $yLabel = $this->yLabel ?? null; + $color = isset($this->color) ? imagecolorallocate($canvas, ... $this->color) : $black; + $thickness = $this->thickness ?? 3; + $grid = $this->grid ?? false; + $gridCountY = $this->gridCountY ?? null; + $gridCountX = $this->gridCountX ?? null; + $function = $this->function; + $start = $this->start; + $end = $this->end; + + // Determine if we need to add padding to make room for axis labels + $x_shift = isset($yLabel) ? 40 : 0; + $y_shift = isset($xLabel) ? 10 : 0; + + // Measure start and end points of plot on canvas + $plot_start_x = $padding + $x_shift; + $plot_start_y = imagesy($canvas) - ($padding + $y_shift); + $plot_end_x = imagesx($canvas) - $padding; + $plot_end_y = $padding; + + // Measure height and width of plot on canvas + $plot_width = $plot_end_x - $plot_start_x; + $plot_height = $plot_start_y - $plot_end_y; + + // Create axes + imagerectangle($canvas, $plot_start_x, $plot_end_y, $plot_end_x, $plot_start_y, $black); + + // Calculate function step size (h) and plot step size + $n = 1000; + $h = ($end - $start)/$n; + $plot_step_x = $plot_width/$n; + $plot_step_y = $plot_height/$n; + + // Calculate function values, min, max, and function scale + $image = []; + for ($i = 0; $i <= $n; $i++) { + $image[] = $function($start + $i*$h); + } + $min = min($image); + $max = max($image); + $function_scale = $plot_height/($max - $min); + + // Draw y-axis values and grid + $style = array_merge(array_fill(0, 1, $black), array_fill(0, 5, $white)); + imagesetstyle($canvas, $style); + for ($i = 0; $i <= $gridCountY; $i++) { + $value = round(($min + $i*($max - $min)/$gridCountY), 1); + $X₀ = $plot_start_x; + $Xₙ = $plot_end_x; + $Y₀ = $plot_start_y - $i*($plot_height/$gridCountY); + imagestring($canvas, 2, $X₀ - 10 - 6*strlen($value), $Y₀ - 8, $value, $black); + if ($i !== 0 && $i !== $gridCountY && $grid) { + imageline($canvas, $X₀, $Y₀, $Xₙ, $Y₀, IMG_COLOR_STYLED); + } + } + + // Draw x-axis values and grid + for ($i = 0; $i <= $gridCountX; $i++) { + $value = round(($start + $i*($end - $start)/$gridCountX), 1); + $X₀ = $plot_start_x + $i*($plot_width/$gridCountX); + $Y₀ = $plot_start_y; + $Yₙ = $plot_end_y; + imagestring($canvas, 2, $X₀ + 2 - strlen($value)*3, $Y₀ + 8, $value, $black); + if ($i !== 0 && $i !== $gridCountX && $grid) { + imageline($canvas, $X₀, $Y₀, $X₀, $Yₙ, IMG_COLOR_STYLED); + } + } + + // Draw title, x-axis title, y-axis title + if (isset($title)) { + imagestring($canvas, 5, ($width + $x_shift - strlen($title)*9)/2, 18, $title, $black); + } + if (isset($xLabel)) { + imagestring($canvas, 4, ($width + $x_shift - strlen($xLabel)*8)/2, $height - 30, $xLabel, $black); + } + if (isset($yLabel)) { + imagestringup($canvas, 4, 10, ($height - $y_shift + strlen($yLabel)*8)/2, $yLabel, $black); + } + + // Draw plot + imagesetthickness($canvas, $thickness); + for ($i = 0; $i < $n; $i++) { + $xᵢ = $plot_start_x + $i*$plot_step_x; + $xᵢ₊₁ = $plot_start_x + ($i+1)*$plot_step_x; + $f⟮xᵢ⟯ = $plot_start_y - ($image[$i]-$min)*$function_scale; + $f⟮xᵢ₊₁⟯ = $plot_start_y - ($image[$i+1]-$min)*$function_scale; + imageline($canvas, $xᵢ, $f⟮xᵢ⟯, $xᵢ₊₁, $f⟮xᵢ₊₁⟯, $color); + } + + return $canvas; + } +} diff --git a/tests/Plots/CanvasTest.php b/tests/Plots/CanvasTest.php new file mode 100644 index 000000000..dd9a15dfe --- /dev/null +++ b/tests/Plots/CanvasTest.php @@ -0,0 +1,44 @@ +setExpectedException('\Exception'); + new Canvas(-100, 500); + } + + public function testValidateSizeExceptionUpdate() + { + // Adjust to a negative size for canvas dimensions + $this->setExpectedException('\Exception'); + $canvas = new Canvas(); + $canvas->size(-100, 500); + } + + public function testValidateIntervalSet() + { + // The input interval is set to single point (start = end) + $this->setExpectedException('\Exception'); + $canvas = new Canvas(); + $function = function ($x) { + return 1; + }; + $canvas->addPlot($function, 0, 0); + } + + public function testValidateIntervalUpdate() + { + // The plot interval is adjusted to single point (start = end) + $this->setExpectedException('\Exception'); + $canvas = new Canvas(); + $function = function ($x) { + return 1; + }; + $canvas->addPlot($function, 0, 10); + $plot->xRange(10, 10); + } +} diff --git a/tests/Plots/PlotTest.php b/tests/Plots/PlotTest.php new file mode 100644 index 000000000..3cbe04e03 --- /dev/null +++ b/tests/Plots/PlotTest.php @@ -0,0 +1,44 @@ +setExpectedException('\Exception'); + $canvas = new Canvas(); + $function = function ($x) { + return 1; + }; + $canvas->addPlot($function, 0, 0); + $plot = $canvas->addPlot($function, 0, 10); + $plot->grid(true, -10, 5); + } + + public function testThicknessException() + { + // Giving a negative number + $this->setExpectedException('\Exception'); + $canvas = new Canvas(); + $function = function ($x) { + return 1; + }; + $plot = $canvas->addPlot($function, 0, 10); + $plot->thickness(-10); + } + + public function testPlotException() + { + // Giving a $canvas input which is not a GD resource + $not_a_gd_resource = "this is a string, not a GD resource"; + $this->setExpectedException('\Exception'); + $canvas = new Canvas(); + $function = function ($x) { + return 1; + }; + $plot = $canvas->addPlot($function, 0, 10); + $plot->draw($not_a_gd_resource); + } +}