mirror of
https://github.com/seigler/neat-charts
synced 2025-07-27 01:16:09 +00:00
added composer.json, renamed library
This commit is contained in:
parent
331e0ec10b
commit
f98d0a4b08
7 changed files with 302 additions and 294 deletions
15
README.md
15
README.md
|
@ -1,7 +1,8 @@
|
||||||
# Dash-SVG-chart
|
# neat-charts
|
||||||
PHP project to generate cached SVG price charts
|
PHP project to generate clean-looking SVG price charts
|
||||||
|
|
||||||

|

|
||||||
|
24h of Dash price in Bitcoin from Poloniex.com
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
|
@ -14,20 +15,24 @@ Extract the files from https://github.com/seigler/Dash-SVG-chart/archive/master.
|
||||||
In your PHP file:
|
In your PHP file:
|
||||||
|
|
||||||
```php
|
```php
|
||||||
|
<?php
|
||||||
Header('Content-type: image/svg+xml; charset=utf-8');
|
Header('Content-type: image/svg+xml; charset=utf-8');
|
||||||
Header('Content-Disposition: inline; filename="chart-' . date('Y-m-d\THisT') . '.svg"');
|
Header('Content-Disposition: inline; filename="chart-' . date('Y-m-d\THisT') . '.svg"');
|
||||||
include 'buffer.php';
|
include 'buffer.php';
|
||||||
include 'SVGChartBuilder.php';
|
include 'NeatChart/LineChart.php';
|
||||||
|
|
||||||
/* your code here to generate $chartData */
|
/*
|
||||||
|
your code here to populate $chartData
|
||||||
|
*/
|
||||||
|
|
||||||
print SVGChartBuilder::renderStockChart($chartData, [
|
$chart = new NeatChart/LineChart($chartData, [ // all parameters optional
|
||||||
'width'=>800,
|
'width'=>800,
|
||||||
'height'=>250,
|
'height'=>250,
|
||||||
'lineColor'=>"#1C75BC",
|
'lineColor'=>"#1C75BC",
|
||||||
'labelColor'=>"#777",
|
'labelColor'=>"#777",
|
||||||
'smoothed'=>false
|
'smoothed'=>false
|
||||||
]);
|
]);
|
||||||
|
print $chart->render();
|
||||||
```
|
```
|
||||||
|
|
||||||
In your HTML:
|
In your HTML:
|
||||||
|
|
|
@ -1,276 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
class SVGChartBuilder {
|
|
||||||
private $options = [
|
|
||||||
'width' => 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 =
|
|
||||||
'<text x="'.(0.4 * $this->options['fontSize']).'" y="'.($this->options['fontSize'] * 0.4).'">'.($this->labelFormat($this->yMax, $labelPrecision + 1)).'</text>' .
|
|
||||||
'<text x="'.(0.4 * $this->options['fontSize']).'" y="'.($this->options['fontSize'] * 0.4 + $this->height).'">'.($this->labelFormat($this->yMin, $labelPrecision + 1)).'</text>';
|
|
||||||
|
|
||||||
// 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 .= '<text x="-'.(0.25 * $this->options['fontSize']).'" y="'.($labelHeight + $this->options['fontSize'] * 0.4).'">'.$this->labelFormat($labelY, $labelPrecision).'</text>';
|
|
||||||
$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 = '<?xml version="1.0" standalone="no"?>
|
|
||||||
<svg viewBox="-'.( $this->padding['left'] ).' -'.( $this->padding['top'] ).' '.( $this->options['width'] ).' '.( $this->options['height'] ).'" width="'.( $this->options['width'] ).'" height="'.( $this->options['height'] ).'" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
|
||||||
<defs>
|
|
||||||
<marker id="SVGChart-markerCircle-'.( $chartID ).'" markerWidth="2" markerHeight="2" refX="1" refY="1" markerUnits="strokeWidth">
|
|
||||||
<circle cx="1" cy="1" r="1" style="stroke: none; fill:'.( $this->options['lineColor'] ).';" />
|
|
||||||
</marker>
|
|
||||||
<linearGradient id="SVGChart-fadeFromNothing-'.( $chartID ).'" x1="0%" y1="0%" x2="100%" y2="0%">
|
|
||||||
<stop offset="0.5%" stop-color="'.( $this->options['lineColor'] ).'" stop-opacity="0"></stop>
|
|
||||||
<stop offset="5%" stop-color="'.( $this->options['lineColor'] ).'" stop-opacity="1"></stop>
|
|
||||||
<stop offset="100%" stop-color="'.( $this->options['lineColor'] ).'" stop-opacity="1"></stop>
|
|
||||||
</linearGradient>
|
|
||||||
<style type="text/css">
|
|
||||||
<![CDATA[ .SVGChart-'.( $chartID ).' .chart__gridLines {
|
|
||||||
font-family: sans-serif;
|
|
||||||
font-size: '.( $this->options['fontSize'] ).'px;
|
|
||||||
fill: '.( $this->options['labelColor'] ).';
|
|
||||||
text-anchor: end;
|
|
||||||
shape-rendering: crispEdges;
|
|
||||||
}
|
|
||||||
|
|
||||||
.SVGChart-'.( $chartID ).' .chart__gridLinePaths {
|
|
||||||
fill: none;
|
|
||||||
stroke: '.( $this->options['labelColor'] ).';
|
|
||||||
stroke-opacity: 0.75;
|
|
||||||
stroke-width: 1;
|
|
||||||
stroke-dasharray: 2, 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.SVGChart-'.( $chartID ).' .chart__plotLine {
|
|
||||||
fill: none;
|
|
||||||
stroke-width: '.( $this->options['fontSize'] / 3 ).';
|
|
||||||
stroke-linejoin: round;
|
|
||||||
stroke-linecap: round;
|
|
||||||
stroke: url(#SVGChart-fadeFromNothing-'.( $chartID ).');
|
|
||||||
marker-end: url(#SVGChart-markerCircle-'.( $chartID ).');
|
|
||||||
}
|
|
||||||
|
|
||||||
]]>
|
|
||||||
</style>
|
|
||||||
</defs>
|
|
||||||
<g class="SVGChart SVGChart-'.( $chartID ).'">
|
|
||||||
<g class="chart__gridLines">
|
|
||||||
<path class="chart__gridLinePaths" d="'.( $gridLines ).'" /> '.( $gridText ).'
|
|
||||||
</g>
|
|
||||||
<g class="chart__plotLine">
|
|
||||||
<path d="'.( $this->options['smoothed'] ? $chartSplines : $chartPoints ).'" />
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</svg>';
|
|
||||||
}
|
|
||||||
public function render() {
|
|
||||||
return $this->output;
|
|
||||||
}
|
|
||||||
}
|
|
8
composer.json
Normal file
8
composer.json
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"name": "seigler/neat-charts",
|
||||||
|
"autoload": {
|
||||||
|
"psr-0" : {
|
||||||
|
"NeatCharts\\LineChart" : "src"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -22,7 +22,7 @@
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
<h1>PHP SVG Chart Builder: chart demos</h1>
|
<h1>NeatCharts demo</h1>
|
||||||
</header>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
<section>
|
<section>
|
||||||
|
@ -34,10 +34,7 @@
|
||||||
<h2>Fake Stock Market Data</h2>
|
<h2>Fake Stock Market Data</h2>
|
||||||
<?php
|
<?php
|
||||||
ini_set('display_errors', 1);
|
ini_set('display_errors', 1);
|
||||||
|
require '../src/NeatCharts/LineChart.php'; // just use composer instead of this
|
||||||
spl_autoload_register(function ($class_name) {
|
|
||||||
include $class_name . ".php";
|
|
||||||
});
|
|
||||||
|
|
||||||
// fake up some stock market data
|
// fake up some stock market data
|
||||||
|
|
||||||
|
@ -54,7 +51,7 @@ for ($n = 0, $current = $offset + 0.5 * $scale; $n < 96; $n++) {
|
||||||
$chartData[$n] = $current;
|
$chartData[$n] = $current;
|
||||||
}
|
}
|
||||||
|
|
||||||
$stockChart = new SVGChartBuilder($chartData, [
|
$stockChart = new NeatCharts\LineChart($chartData, [
|
||||||
"width"=>500,
|
"width"=>500,
|
||||||
"height"=>150,
|
"height"=>150,
|
||||||
"fontSize"=>10
|
"fontSize"=>10
|
||||||
|
@ -80,7 +77,7 @@ for ($n = 0, $current = $start; $n < 12; $n++) {
|
||||||
$chartData[$n] = $current;
|
$chartData[$n] = $current;
|
||||||
}
|
}
|
||||||
|
|
||||||
$tempChart = new SVGChartBuilder($chartData, [
|
$tempChart = new NeatCharts\LineChart($chartData, [
|
||||||
"width"=>700,
|
"width"=>700,
|
||||||
"height"=>400,
|
"height"=>400,
|
||||||
"lineColor"=>"#D00",
|
"lineColor"=>"#D00",
|
|
@ -3,11 +3,8 @@
|
||||||
|
|
||||||
Header('Content-type: image/svg+xml; charset=utf-8');
|
Header('Content-type: image/svg+xml; charset=utf-8');
|
||||||
Header('Content-Disposition: inline; filename="Dash-24h-chart-' . date('Y-m-d\THisT') . '.svg"');
|
Header('Content-Disposition: inline; filename="Dash-24h-chart-' . date('Y-m-d\THisT') . '.svg"');
|
||||||
include 'buffer.php';
|
require 'buffer.php';
|
||||||
|
require '../src/NeatCharts/LineChart.php'; // really just use composer instead
|
||||||
spl_autoload_register(function ($class_name) {
|
|
||||||
include $class_name . ".php";
|
|
||||||
});
|
|
||||||
|
|
||||||
function getJson($url) {
|
function getJson($url) {
|
||||||
if (empty($url)) {
|
if (empty($url)) {
|
||||||
|
@ -43,7 +40,7 @@ foreach ($last24h as $item) {
|
||||||
$chartData[$item->date] = $item->weightedAverage;
|
$chartData[$item->date] = $item->weightedAverage;
|
||||||
}
|
}
|
||||||
|
|
||||||
$poloniexChart = new SVGChartBuilder($chartData, [
|
$poloniexChart = new NeatCharts\LineChart($chartData, [
|
||||||
'width'=>800,
|
'width'=>800,
|
||||||
'height'=>250,
|
'height'=>250,
|
||||||
'lineColor'=>"#1C75BC", // Dash blue
|
'lineColor'=>"#1C75BC", // Dash blue
|
277
src/NeatCharts/LineChart.php
Normal file
277
src/NeatCharts/LineChart.php
Normal file
|
@ -0,0 +1,277 @@
|
||||||
|
<?php
|
||||||
|
namespace NeatCharts {
|
||||||
|
class LineChart {
|
||||||
|
private $options = [
|
||||||
|
'width' => 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 =
|
||||||
|
'<text x="'.(0.4 * $this->options['fontSize']).'" y="'.($this->options['fontSize'] * 0.4).'">'.($this->labelFormat($this->yMax, $labelPrecision + 1)).'</text>' .
|
||||||
|
'<text x="'.(0.4 * $this->options['fontSize']).'" y="'.($this->options['fontSize'] * 0.4 + $this->height).'">'.($this->labelFormat($this->yMin, $labelPrecision + 1)).'</text>';
|
||||||
|
|
||||||
|
// 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 .= '<text x="-'.(0.25 * $this->options['fontSize']).'" y="'.($labelHeight + $this->options['fontSize'] * 0.4).'">'.$this->labelFormat($labelY, $labelPrecision).'</text>';
|
||||||
|
$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 = '<?xml version="1.0" standalone="no"?>
|
||||||
|
<svg viewBox="-'.( $this->padding['left'] ).' -'.( $this->padding['top'] ).' '.( $this->options['width'] ).' '.( $this->options['height'] ).'" width="'.( $this->options['width'] ).'" height="'.( $this->options['height'] ).'" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
|
<defs>
|
||||||
|
<marker id="SVGChart-markerCircle-'.( $chartID ).'" markerWidth="2" markerHeight="2" refX="1" refY="1" markerUnits="strokeWidth">
|
||||||
|
<circle cx="1" cy="1" r="1" style="stroke: none; fill:'.( $this->options['lineColor'] ).';" />
|
||||||
|
</marker>
|
||||||
|
<linearGradient id="SVGChart-fadeFromNothing-'.( $chartID ).'" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0.5%" stop-color="'.( $this->options['lineColor'] ).'" stop-opacity="0"></stop>
|
||||||
|
<stop offset="5%" stop-color="'.( $this->options['lineColor'] ).'" stop-opacity="1"></stop>
|
||||||
|
<stop offset="100%" stop-color="'.( $this->options['lineColor'] ).'" stop-opacity="1"></stop>
|
||||||
|
</linearGradient>
|
||||||
|
<style type="text/css">
|
||||||
|
<![CDATA[ .SVGChart-'.( $chartID ).' .chart__gridLines {
|
||||||
|
font-family: sans-serif;
|
||||||
|
font-size: '.( $this->options['fontSize'] ).'px;
|
||||||
|
fill: '.( $this->options['labelColor'] ).';
|
||||||
|
text-anchor: end;
|
||||||
|
shape-rendering: crispEdges;
|
||||||
|
}
|
||||||
|
|
||||||
|
.SVGChart-'.( $chartID ).' .chart__gridLinePaths {
|
||||||
|
fill: none;
|
||||||
|
stroke: '.( $this->options['labelColor'] ).';
|
||||||
|
stroke-opacity: 0.75;
|
||||||
|
stroke-width: 1;
|
||||||
|
stroke-dasharray: 2, 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.SVGChart-'.( $chartID ).' .chart__plotLine {
|
||||||
|
fill: none;
|
||||||
|
stroke-width: '.( $this->options['fontSize'] / 3 ).';
|
||||||
|
stroke-linejoin: round;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke: url(#SVGChart-fadeFromNothing-'.( $chartID ).');
|
||||||
|
marker-end: url(#SVGChart-markerCircle-'.( $chartID ).');
|
||||||
|
}
|
||||||
|
|
||||||
|
]]>
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<g class="SVGChart SVGChart-'.( $chartID ).'">
|
||||||
|
<g class="chart__gridLines">
|
||||||
|
<path class="chart__gridLinePaths" d="'.( $gridLines ).'" /> '.( $gridText ).'
|
||||||
|
</g>
|
||||||
|
<g class="chart__plotLine">
|
||||||
|
<path d="'.( $this->options['smoothed'] ? $chartSplines : $chartPoints ).'" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>';
|
||||||
|
}
|
||||||
|
public function render() {
|
||||||
|
return $this->output;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Add table
Add a link
Reference in a new issue