diff --git a/SVGChartBuilder.php b/SVGChartBuilder.php index 4d5c803..86092d3 100644 --- a/SVGChartBuilder.php +++ b/SVGChartBuilder.php @@ -2,34 +2,72 @@ class SVGChartBuilder { - public static function renderStockChart($chartData, $width = 700) { - $absoluteDeltas = []; - $previousValue = null; - $yMin = $xMin = INF; - $yMax = $xMax = -INF; - - foreach ($chartData as $thisX => $thisY) { - if (!is_null($previousValue)) { - $absoluteDeltas[] = abs($thisY - $previousValue); - } - if ($thisY < $yMin) { - $yMin = $thisY; - $yMinX = $thisX; - } - if ($thisY > $yMax) { - $yMax = $thisY; - $yMaxX = $thisX; - } - $previousValue = $thisY; - } - $yRange = $yMax - $yMin; + public static function renderStockChart($chartData, $width = 700, $lineColor = "#FF2F00", $gridLabelColor = "#999", $smoothed = false) { + // we assume $chartData is sorted by key and keys and values are all numeric + $previousY = $previousY = null; end($chartData); $xMax = key($chartData); reset($chartData); $xMin = key($chartData); $xRange = $xMax - $xMin; $count = count($chartData); - $averageDelta = abs(array_sum($absoluteDeltas)/$count); + $deltaX = $xRange / $count; + $yMin = INF; // so the first comparison sets this to an actual value + $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 < $yMin) { + $yMin = $y; + $yMinX = $x; + } + if ($y > $yMax) { + $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 == $xMax) { + $secants[$x] = ($y - $previousY) / $deltaX; + } + $previousY = $y; + $previousX = $x; + } + $yRange = $yMax - $yMin; + $averageAbsSlope /= $yRange * $deltaX; // turn this absolute-deltas total into a slope + + // 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 == $xMax) { + $tangents[$x] = $secant; + } + if ($x == $xMin) { + $tangents[$x] = $secant; + } + + $previousX = $x; + $previousSecant = $secant; + } /* We want the height of the median y-delta to be the same as @@ -37,11 +75,13 @@ class SVGChartBuilder { 45 degrees. This improves comprehension. http://vis4.net/blog/posts/doing-the-line-charts-right/ */ - $aspectRatio = max(0.25, $yRange / $averageDelta / $count); + $aspectRatio = max(0.25, min(0.75, 1 / $averageAbsSlope)); $height = floor($aspectRatio * $width); - function labelFormat($float, $sigFigs, $minPlaces = 0) { - return number_format($float, max($minPlaces, $sigFigs)); + 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 */ @@ -66,58 +106,72 @@ class SVGChartBuilder { } $chartPoints = "M"; + $chartSplines = "M". + transformX($xMin, $xMin, $xRange, $width).",". + transformY($chartData[$xMin], $yMax, $yRange, $height); foreach ($chartData as $x => $y) { - $chartPoints .= transformX($x, $xMin, $xRange, $width) . ',' . transformY($y, $yMax, $yRange, $height) . ' - '; + $chartPoints .= + transformX($x, $xMin, $xRange, $width).",". + transformY($y, $yMax, $yRange, $height) . "\n"; + + $controlX = $deltaX / 3 / sqrt(1 + $tangents[$x]**2); + $controlY = $tangents[$x] * $controlX; + if ($x != $xMin) { + $chartSplines .= " S". + transformX($x - $controlX, $xMin, $xRange, $width).",". + transformY($y - $controlY, $yMax, $yRange, $height)." ". + transformX($x, $xMin, $xRange, $width).",". + transformY($y, $yMax, $yRange, $height); + } } - $numLabels = min(4, ceil($height / 40)); + $numLabels = 1 + ceil($height / 60); $labelInterval = $yRange / $numLabels; $labelModulation = 10 ** (1 + floor(-log($yRange / $numLabels, 10))); -// if (fmod($labelInterval, $labelModulation / 5) < $labelInterval * 0.5) { + + // 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 if (fmod($labelInterval, $labelModulation / 2) < $labelInterval * 0.25) { -// $labelModulation /= 2; -// } -// var_dump($labelInterval, $labelModulation, $labelInterval * $labelModulation, ceil($labelInterval * $labelModulation) / $labelModulation); + } else { + $labelModulation /= 2; + } $labelInterval = ceil($labelInterval * $labelModulation) / $labelModulation; - $labelPlaces = getPrecision($labelInterval); + $labelPrecision = getPrecision($labelInterval); // Top and bottom grid lines $gridLines = - "M10,0 ".$width.",0 - M10,".$height.",".$width.",".$height." - "; + "M10,0 ".$width.",0\n". + "M10,".$height.",".$width.",".$height."\n"; // Top and bottom grid labels $gridText = - ''.labelFormat($yMax, $labelPlaces + 1).'' . - ''.labelFormat($yMin, $labelPlaces + 1).''; + ''.labelFormat($yMax, $labelPrecision + 1).'' . + ''.labelFormat($yMin, $labelPrecision + 1).''; - // Start at the first "nice" Y value > min + 50% of the interval - // Keep going until max - 50% of the interval - // Add Interval each iteration + // Main labels and grid lines for ( - $labelY = $yMin - fmod($yMin, $labelInterval) + $labelInterval; - $labelY < $yMax; - $labelY += $labelInterval + $labelY = $yMin - fmod($yMin, $labelInterval) + $labelInterval; // Start at the first "nice" Y value > min + $labelY < $yMax; // Keep going until max + $labelY += $labelInterval // Add Interval each iteration ) { $labelHeight = transformY($labelY, $yMax, $yRange, $height); - if ( - $labelY < $yMax - 0.1 * $labelInterval && - $labelY > $yMin + 0.1 * $labelInterval + if ( // label is not too close to the min or max + $labelHeight < $height - 25 && + $labelHeight > 25 ) { + $gridText .= ''.labelFormat($labelY, $labelPrecision).''; $gridLines .= " M0,".$labelHeight." ".$width.",".$labelHeight; - } - if ( - $labelY < $yMax - 0.3 * $labelInterval && - $labelY > $yMin + 0.3 * $labelInterval - ) { - $gridText .= ''.labelFormat($labelY, $labelPlaces).''; - } + } else if ( // label is too close + $labelHeight < $height - 4 && + $labelHeight > 4 + ) { + $gridLines .= " M".( // move grid line over when it's very close to the min or max label + $labelHeight < $height - 10 && $labelHeight > 10 ? 0 : 10 + ).",".$labelHeight." ".$width.",".$labelHeight; + } } - return ' + print ' - - - + + +