diff --git a/README.md b/README.md index 52c8cc6..6ff6414 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,8 @@ -# Dash-SVG-chart -PHP project to generate cached SVG price charts +# neat-charts +PHP project to generate clean-looking SVG price charts ![Dash 24h price in BTC from Poloniex](http://cryptohistory.org/dash/24h/) +24h of Dash price in Bitcoin from Poloniex.com ## Requirements @@ -14,20 +15,24 @@ Extract the files from https://github.com/seigler/Dash-SVG-chart/archive/master. In your PHP file: ```php +800, 'height'=>250, 'lineColor'=>"#1C75BC", 'labelColor'=>"#777", 'smoothed'=>false ]); +print $chart->render(); ``` In your HTML: diff --git a/SVGChartBuilder.php b/SVGChartBuilder.php deleted file mode 100644 index 30dee2a..0000000 --- a/SVGChartBuilder.php +++ /dev/null @@ -1,276 +0,0 @@ - 800, - 'height' => 250, - 'lineColor' => '#000', - 'labelColor' => '#000', - 'smoothed' => false, - 'fontSize' => 15 - ]; - - private $width; - private $height; - private $output; - private $xMin; - private $xMax; - private $xRange; - private $yMin; - private $yMax; - private $yRange; - private $padding = ['top'=>10, 'right'=>10, 'bottom'=>10, 'left'=>10]; - - private function arrayGet($array, $key, $default = NULL) - { - return isset($array[$key]) ? $array[$key] : $default; - } - - private function labelFormat($float, $places, $minPlaces = 0) { - $value = number_format($float, max($minPlaces, $places)); - // add a trailing space if there's no decimal - return (strpos($value, '.') === false ? $value . '.' : $value); - } - - /* Transform data coords to chart coords */ - /* Transform data coords to chart coords */ - private function transformX($x) { - return round( - ($x - $this->xMin) / $this->xRange * $this->width - , 2); - } - private function transformY($y) { - return round( - // SVG has y axis reversed, 0 is at the top - ($this->yMax - $y) / $this->yRange * $this->height - , 2); - } - - private function getPrecision($value) { // thanks http://stackoverflow.com/a/21788335/5402566 - if (!is_numeric($value)) { return false; } - $decimal = $value - floor($value); //get the decimal portion of the number - if ($decimal == 0) { return 0; } //if it's a whole number - $precision = strlen(trim(number_format($decimal,10),'0')) - 1; //-2 to account for '0.' - return $precision; - } - - public function __construct($chartData, $options) { - $this->setOptions($options); - $this->setData($chartData); - } - - public function setOptions($options) { - $this->options = array_replace($this->options, $options); - $this->padding['left'] = $this->options['fontSize'] * 5; - $this->padding['top'] = $this->padding['bottom'] = $this->options['fontSize']; - } - - public function setData($chartData) { - // we assume $chartData is sorted by key and keys and values are all numeric - $previousX = $previousY = null; - end($chartData); - $this->xMax = key($chartData); - reset($chartData); - $this->xMin = key($chartData); - $this->xRange = $this->xMax - $this->xMin; - $count = count($chartData); - $deltaX = $this->xRange / $count; - $this->yMin = INF; // so the first comparison sets this to an actual value - $this->yMax = -INF; - $averageAbsSlope = 0; // we will add all of them then divide to get an average - $secants = []; // slope between this point and the previous one - $tangents = []; // slope across the point - - foreach ($chartData as $x => $y) { - if ($y < $this->yMin) { - $this->yMin = $y; - $yMinX = $x; - } - if ($y > $this->yMax) { - $this->yMax = $y; - $yMaxX = $x; - } - if (!is_null($previousY)) { - $averageAbsSlope += abs($y - $previousY); // just add up all the Y differences - $secants[$previousX] = ($y - $previousY) / $deltaX; - } - if ($x == $this->xMax) { - $secants[$x] = ($y - $previousY) / $deltaX; - } - $previousY = $y; - $previousX = $x; - } - $this->yRange = $this->yMax - $this->yMin; - $averageAbsSlope /= $this->yRange * $deltaX; // turn this absolute-deltas total into a slope - - if ($this->options['smoothed']) { - // take all these slopes and average them with their neighbors - // unless they change direction, then make them zero - // also restrict them a bit when they are very different - $previousSecant = $previousX = null; - foreach ($secants as $x => $secant) { - if (!is_null($previousSecant)) { - $tangents[$x] = ($secant + $previousSecant) / 2; - if ($secant == 0 || $previousSecant == 0 || $secant * $previousSecant <= 0) - { - $tangents[$x] = 0; - } else { - if ($tangents[$x] / $previousSecant > 3) { - $tangents[$x] = 3 * $previousSecant; - } else if ($tangents[$x] / $secant > 3) { - $tangents[$x] = 3 * $secant; - } - } - } - if ($x == $this->xMax) { - $tangents[$x] = $secant; - } - if ($x == $this->xMin) { - $tangents[$x] = $secant; - } - - $previousX = $x; - $previousSecant = $secant; - } - } - - /* - We want the height of the median y-delta to be the same as - the width of one x-delta, which puts the median slope at - 45 degrees. This improves comprehension. - http://vis4.net/blog/posts/doing-the-line-charts-right/ - */ - $aspectRatio = max(0.25, min(0.75, 1 / $averageAbsSlope)); - $this->width = $this->options['width'] - $this->padding['left'] - $this->padding['right']; - if (isset($this->options['height'])) { - $this->height = $this->options['height'] - $this->padding['top'] - $this->padding['bottom']; - } else { - $this->height = floor($aspectRatio * $this->width); - $this->options['height'] = $this->height + $this->padding['top'] + $this->padding['bottom']; - } - - $chartPoints = 'M'; - $chartSplines = 'M'. - $this->transformX($this->xMin).','. - $this->transformY($chartData[$this->xMin]); - if ($this->options['smoothed']) { - foreach ($chartData as $x => $y) { - $controlX = $deltaX / 3 / sqrt(1 + $tangents[$x]**2); - $controlY = $tangents[$x] * $controlX; - if ($x != $this->xMin) { - $chartSplines .= ' S'. - $this->transformX($x - $controlX).','. - $this->transformY($y - $controlY).' '. - $this->transformX($x).','. - $this->transformY($y); - } - } - } else { - foreach ($chartData as $x => $y) { - $chartPoints .= - $this->transformX($x).','. - $this->transformY($y) . ' '; - } - } - - $numLabels = 2 + ceil($this->height / $this->options['fontSize'] / 6); - $labelInterval = $this->yRange / $numLabels; - $labelModulation = 10 ** (1 + floor(-log($this->yRange / $numLabels, 10))); - - // 0.1 here is a fudge factor so we get multiples of 2.5 a little more often - if (fmod($labelInterval * $labelModulation, 2.5) < fmod($labelInterval * $labelModulation, 2) + 0.1) { - $labelModulation /= 2.5; - } else { - $labelModulation /= 2; - } - $labelInterval = ceil($labelInterval * $labelModulation) / $labelModulation; - $labelPrecision = $this->getPrecision($labelInterval); - - // Top and bottom grid lines - $gridLines = - 'M10,0 '.$this->width.',0 '. - ' M10,'.$this->height.','.$this->width.','.$this->height; - - // Top and bottom grid labels - $gridText = - ''.($this->labelFormat($this->yMax, $labelPrecision + 1)).'' . - ''.($this->labelFormat($this->yMin, $labelPrecision + 1)).''; - - // Main labels and grid lines - for ( - $labelY = $this->yMin - fmod($this->yMin, $labelInterval) + $labelInterval; // Start at the first "nice" Y value > min - $labelY < $this->yMax; // Keep going until max - $labelY += $labelInterval // Add Interval each iteration - ) { - $labelHeight = $this->transformY($labelY); - if ( // label is not too close to the min or max - $labelHeight < $this->height - 1.5 * $this->options['fontSize'] && - $labelHeight > $this->options['fontSize'] * 1.5 - ) { - $gridText .= ''.$this->labelFormat($labelY, $labelPrecision).''; - $gridLines .= ' M0,'.$labelHeight.' '.$this->width.','.$labelHeight; - } else if ( // label is too close - $labelHeight < $this->height - $this->options['fontSize'] * 0.75 && - $labelHeight > $this->options['fontSize'] * 0.75 - ) { - $gridLines .= ' M'.( // move grid line over when it's very close to the min or max label - $labelHeight < $this->height - $this->options['fontSize'] / 2 && $labelHeight > $this->options['fontSize'] / 2 ? 0 : $this->options['fontSize'] / 2 - ).','.$labelHeight.' '.$this->width.','.$labelHeight; - } - } - - $chartID = rand(); - $this->output = ' - - - - - - - - - - - - - - - '.( $gridText ).' - - - - - -'; - } - public function render() { - return $this->output; - } -} diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..b110962 --- /dev/null +++ b/composer.json @@ -0,0 +1,8 @@ +{ + "name": "seigler/neat-charts", + "autoload": { + "psr-0" : { + "NeatCharts\\LineChart" : "src" + } + } +} diff --git a/buffer.php b/demo/buffer.php similarity index 100% rename from buffer.php rename to demo/buffer.php diff --git a/demo.php b/demo/index.php similarity index 88% rename from demo.php rename to demo/index.php index 0a9deb2..dc5c05f 100644 --- a/demo.php +++ b/demo/index.php @@ -22,7 +22,7 @@
-

PHP SVG Chart Builder: chart demos

+

NeatCharts demo

@@ -34,10 +34,7 @@

Fake Stock Market Data

500, "height"=>150, "fontSize"=>10 @@ -80,7 +77,7 @@ for ($n = 0, $current = $start; $n < 12; $n++) { $chartData[$n] = $current; } -$tempChart = new SVGChartBuilder($chartData, [ +$tempChart = new NeatCharts\LineChart($chartData, [ "width"=>700, "height"=>400, "lineColor"=>"#D00", diff --git a/poloniex-dash-btc.php b/demo/poloniex-dash-btc.php similarity index 89% rename from poloniex-dash-btc.php rename to demo/poloniex-dash-btc.php index 6ba1e88..72fa0b8 100644 --- a/poloniex-dash-btc.php +++ b/demo/poloniex-dash-btc.php @@ -3,11 +3,8 @@ Header('Content-type: image/svg+xml; charset=utf-8'); Header('Content-Disposition: inline; filename="Dash-24h-chart-' . date('Y-m-d\THisT') . '.svg"'); -include 'buffer.php'; - -spl_autoload_register(function ($class_name) { - include $class_name . ".php"; -}); +require 'buffer.php'; +require '../src/NeatCharts/LineChart.php'; // really just use composer instead function getJson($url) { if (empty($url)) { @@ -43,7 +40,7 @@ foreach ($last24h as $item) { $chartData[$item->date] = $item->weightedAverage; } -$poloniexChart = new SVGChartBuilder($chartData, [ +$poloniexChart = new NeatCharts\LineChart($chartData, [ 'width'=>800, 'height'=>250, 'lineColor'=>"#1C75BC", // Dash blue diff --git a/src/NeatCharts/LineChart.php b/src/NeatCharts/LineChart.php new file mode 100644 index 0000000..9aae92a --- /dev/null +++ b/src/NeatCharts/LineChart.php @@ -0,0 +1,277 @@ + 800, + 'height' => 250, + 'lineColor' => '#000', + 'labelColor' => '#000', + 'smoothed' => false, + 'fontSize' => 15 + ]; + + private $width; + private $height; + private $output; + private $xMin; + private $xMax; + private $xRange; + private $yMin; + private $yMax; + private $yRange; + private $padding = ['top'=>10, 'right'=>10, 'bottom'=>10, 'left'=>10]; + + private function arrayGet($array, $key, $default = NULL) + { + return isset($array[$key]) ? $array[$key] : $default; + } + + private function labelFormat($float, $places, $minPlaces = 0) { + $value = number_format($float, max($minPlaces, $places)); + // add a trailing space if there's no decimal + return (strpos($value, '.') === false ? $value . '.' : $value); + } + + /* Transform data coords to chart coords */ + /* Transform data coords to chart coords */ + private function transformX($x) { + return round( + ($x - $this->xMin) / $this->xRange * $this->width + , 2); + } + private function transformY($y) { + return round( + // SVG has y axis reversed, 0 is at the top + ($this->yMax - $y) / $this->yRange * $this->height + , 2); + } + + private function getPrecision($value) { // thanks http://stackoverflow.com/a/21788335/5402566 + if (!is_numeric($value)) { return false; } + $decimal = $value - floor($value); //get the decimal portion of the number + if ($decimal == 0) { return 0; } //if it's a whole number + $precision = strlen(trim(number_format($decimal,10),'0')) - 1; //-2 to account for '0.' + return $precision; + } + + public function __construct($chartData, $options = []) { + $this->setOptions($options); + $this->setData($chartData); + } + + public function setOptions($options) { + $this->options = array_replace($this->options, $options); + $this->padding['left'] = $this->options['fontSize'] * 5; + $this->padding['top'] = $this->padding['bottom'] = $this->options['fontSize']; + } + + public function setData($chartData) { + // we assume $chartData is sorted by key and keys and values are all numeric + $previousX = $previousY = null; + end($chartData); + $this->xMax = key($chartData); + reset($chartData); + $this->xMin = key($chartData); + $this->xRange = $this->xMax - $this->xMin; + $count = count($chartData); + $deltaX = $this->xRange / $count; + $this->yMin = INF; // so the first comparison sets this to an actual value + $this->yMax = -INF; + $averageAbsSlope = 0; // we will add all of them then divide to get an average + $secants = []; // slope between this point and the previous one + $tangents = []; // slope across the point + + foreach ($chartData as $x => $y) { + if ($y < $this->yMin) { + $this->yMin = $y; + $yMinX = $x; + } + if ($y > $this->yMax) { + $this->yMax = $y; + $yMaxX = $x; + } + if (!is_null($previousY)) { + $averageAbsSlope += abs($y - $previousY); // just add up all the Y differences + $secants[$previousX] = ($y - $previousY) / $deltaX; + } + if ($x == $this->xMax) { + $secants[$x] = ($y - $previousY) / $deltaX; + } + $previousY = $y; + $previousX = $x; + } + $this->yRange = $this->yMax - $this->yMin; + $averageAbsSlope /= $this->yRange * $deltaX; // turn this absolute-deltas total into a slope + + if ($this->options['smoothed']) { + // take all these slopes and average them with their neighbors + // unless they change direction, then make them zero + // also restrict them a bit when they are very different + $previousSecant = $previousX = null; + foreach ($secants as $x => $secant) { + if (!is_null($previousSecant)) { + $tangents[$x] = ($secant + $previousSecant) / 2; + if ($secant == 0 || $previousSecant == 0 || $secant * $previousSecant <= 0) + { + $tangents[$x] = 0; + } else { + if ($tangents[$x] / $previousSecant > 3) { + $tangents[$x] = 3 * $previousSecant; + } else if ($tangents[$x] / $secant > 3) { + $tangents[$x] = 3 * $secant; + } + } + } + if ($x == $this->xMax) { + $tangents[$x] = $secant; + } + if ($x == $this->xMin) { + $tangents[$x] = $secant; + } + + $previousX = $x; + $previousSecant = $secant; + } + } + + /* + We want the height of the median y-delta to be the same as + the width of one x-delta, which puts the median slope at + 45 degrees. This improves comprehension. + http://vis4.net/blog/posts/doing-the-line-charts-right/ + */ + $aspectRatio = max(0.25, min(0.75, 1 / $averageAbsSlope)); + $this->width = $this->options['width'] - $this->padding['left'] - $this->padding['right']; + if (isset($this->options['height'])) { + $this->height = $this->options['height'] - $this->padding['top'] - $this->padding['bottom']; + } else { + $this->height = floor($aspectRatio * $this->width); + $this->options['height'] = $this->height + $this->padding['top'] + $this->padding['bottom']; + } + + $chartPoints = 'M'; + $chartSplines = 'M'. + $this->transformX($this->xMin).','. + $this->transformY($chartData[$this->xMin]); + if ($this->options['smoothed']) { + foreach ($chartData as $x => $y) { + $controlX = $deltaX / 3 / sqrt(1 + $tangents[$x]**2); + $controlY = $tangents[$x] * $controlX; + if ($x != $this->xMin) { + $chartSplines .= ' S'. + $this->transformX($x - $controlX).','. + $this->transformY($y - $controlY).' '. + $this->transformX($x).','. + $this->transformY($y); + } + } + } else { + foreach ($chartData as $x => $y) { + $chartPoints .= + $this->transformX($x).','. + $this->transformY($y) . ' '; + } + } + + $numLabels = 2 + ceil($this->height / $this->options['fontSize'] / 6); + $labelInterval = $this->yRange / $numLabels; + $labelModulation = 10 ** (1 + floor(-log($this->yRange / $numLabels, 10))); + + // 0.1 here is a fudge factor so we get multiples of 2.5 a little more often + if (fmod($labelInterval * $labelModulation, 2.5) < fmod($labelInterval * $labelModulation, 2) + 0.1) { + $labelModulation /= 2.5; + } else { + $labelModulation /= 2; + } + $labelInterval = ceil($labelInterval * $labelModulation) / $labelModulation; + $labelPrecision = $this->getPrecision($labelInterval); + + // Top and bottom grid lines + $gridLines = + 'M10,0 '.$this->width.',0 '. + ' M10,'.$this->height.','.$this->width.','.$this->height; + + // Top and bottom grid labels + $gridText = + ''.($this->labelFormat($this->yMax, $labelPrecision + 1)).'' . + ''.($this->labelFormat($this->yMin, $labelPrecision + 1)).''; + + // Main labels and grid lines + for ( + $labelY = $this->yMin - fmod($this->yMin, $labelInterval) + $labelInterval; // Start at the first "nice" Y value > min + $labelY < $this->yMax; // Keep going until max + $labelY += $labelInterval // Add Interval each iteration + ) { + $labelHeight = $this->transformY($labelY); + if ( // label is not too close to the min or max + $labelHeight < $this->height - 1.5 * $this->options['fontSize'] && + $labelHeight > $this->options['fontSize'] * 1.5 + ) { + $gridText .= ''.$this->labelFormat($labelY, $labelPrecision).''; + $gridLines .= ' M0,'.$labelHeight.' '.$this->width.','.$labelHeight; + } else if ( // label is too close + $labelHeight < $this->height - $this->options['fontSize'] * 0.75 && + $labelHeight > $this->options['fontSize'] * 0.75 + ) { + $gridLines .= ' M'.( // move grid line over when it's very close to the min or max label + $labelHeight < $this->height - $this->options['fontSize'] / 2 && $labelHeight > $this->options['fontSize'] / 2 ? 0 : $this->options['fontSize'] / 2 + ).','.$labelHeight.' '.$this->width.','.$labelHeight; + } + } + + $chartID = rand(); + $this->output = ' + + + + + + + + + + + + + + + '.( $gridText ).' + + + + + + '; + } + public function render() { + return $this->output; + } + } +}