/* Copyright (c) 2008-2009 HomeAway, Inc. * All rights reserved. http://www.perf4j.org * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package org.perf4j.chart; import org.perf4j.GroupedTimingStatistics; import org.perf4j.TimingStatistics; import org.perf4j.helpers.StatsValueRetriever; import java.util.*; import java.net.URLEncoder; import java.io.UnsupportedEncodingException; import java.text.DecimalFormat; import java.text.SimpleDateFormat; import java.text.DecimalFormatSymbols; /** * This implementation of StatisticsChartGenerator creates a chart URL in the format expected by the Google Chart API. * * @see <a href="http://code.google.com/apis/chart/">Google Chart API</a> * @author Alex Devine */ public class GoogleChartGenerator implements StatisticsChartGenerator { /** * The DEFAULT_BASE_URL points to Google's charting server at chart.apis.google.com. */ public static final String DEFAULT_BASE_URL = "http://chart.apis.google.com/chart?"; /** * The maximum supported chart size is 300,000 pixels per the Google Chart API. */ public static final int MAX_POSSIBLE_CHART_SIZE = 300000; /** * The default chart width is 750 pixels. */ public static final int DEFAULT_CHART_WIDTH = 750; /** * The default chart height is 400 pixels. */ public static final int DEFAULT_CHART_HEIGHT = 400; /** * The default hex color codes used for the individual data series displayed on the chart. */ public static final String[] DEFAULT_SERIES_COLORS = { "ff0000", //red "00ff00", //green "0000ff", //blue "00ffff", //cyan "ff00ff", //magenta "ffff00", //yellow "000000", //black "d2b48c", //tan "ffa500", //orange "a020f0" //purple }; private StatsValueRetriever valueRetriever; private String baseUrl; private LinkedList<GroupedTimingStatistics> data = new LinkedList<GroupedTimingStatistics>(); private int width = DEFAULT_CHART_WIDTH; private int height = DEFAULT_CHART_HEIGHT; private int maxDataPoints = DEFAULT_MAX_DATA_POINTS; private Set<String> enabledTags = null; // --- Constructors --- /** * Default constructor creates a chart that displays mean execution values and uses the default Google Chart URL. */ public GoogleChartGenerator() { this(StatsValueRetriever.MEAN_VALUE_RETRIEVER, DEFAULT_BASE_URL); } /** * Creates a chart that uses the specified StatsValueRetriever to determine which values from the * TimingStatistic object to display. For example, a chart could be used to display mean values, transactions * per second, etc. * * @param statsValueRetriever The StatsPerTagDataValueExtractor that determines which value to display. */ public GoogleChartGenerator(StatsValueRetriever statsValueRetriever) { this(statsValueRetriever, DEFAULT_BASE_URL); } /** * Creates a chart that uses the specified StatsValueRetriever to determine which values from the * StatsPerTag object to display, and also allows the base chart URL to be overridden from the Google default. * * @param valueRetriever Determines which value (such as mean/min/max/etc) from the TimingStatistic to display on * the chart * @param baseUrl A value to override for the default base URL of "http://chart.apis.google.com/chart?" */ public GoogleChartGenerator(StatsValueRetriever valueRetriever, String baseUrl) { this.valueRetriever = valueRetriever; this.baseUrl = baseUrl; } // --- Bean properties --- /** * Gets the width of the chart that will be displayed * * @return The width of the chart in pixels, defaults to 750. */ public int getWidth() { return width; } /** * Sets the width of the chart in pixels. Note that the Google Charting API currently only supports a maximum * of 300,000 pixels for display, so width X height must be less than 300,000. * * @param width the width of the chart in pixels. */ public void setWidth(int width) { this.width = width; } /** * Gets the height of the chart that will be displayed * * @return The height of the chart in pixels, defaults to 400. */ public int getHeight() { return height; } /** * Sets the height of the chart in pixels. Note that the Google Charting API currently only supports a maximum * of 300,000 pixels for display, so width X height must be less than 300,000. * * @param height the height of the chart in pixels. */ public void setHeight(int height) { this.height = height; } /** * Gets the set of tag names for which values will be displayed on the chart. Each tag is represented as a * separate series on the chart. * * @return The set of enabled tag names, or null if ALL tags found in the GroupedTimingStatistics data will be * displayed. */ public Set<String> getEnabledTags() { return enabledTags; } /** * Sets the set of tag names for which values will be displayed on the chart. * * @param enabledTags The set of enabled tag names. If this method is not called, or if enabledTags is null, * then ALL tags from the GroupedTimingStatistics data will be displayed on the chart. */ public void setEnabledTags(Set<String> enabledTags) { this.enabledTags = enabledTags; } /** * Gets the maximum number of data points to display on a chart. If <tt>appendData</tt> is called more than * this number of times, then only the last maxDataPoints data items will be shown in any generated charts. * * @return the maximum number of data points that will be displayed */ public int getMaxDataPoints() { return maxDataPoints; } /** * Sets the maximum number of data points to display on a chart. * * @param maxDataPoints The maximum number of data points. */ public void setMaxDataPoints(int maxDataPoints) { this.maxDataPoints = maxDataPoints; } // --- Data methods --- public List<GroupedTimingStatistics> getData() { return Collections.unmodifiableList(this.data); } public synchronized void appendData(GroupedTimingStatistics statistics) { if (this.data.size() >= this.maxDataPoints) { this.data.removeFirst(); } this.data.add(statistics); } public synchronized String getChartUrl() { if (width * height > MAX_POSSIBLE_CHART_SIZE || width * height <= 0) { throw new IllegalArgumentException("The chart size must be between 0 and " + MAX_POSSIBLE_CHART_SIZE + " pixels. Current size is " + width + " x " + height); } StringBuilder retVal = new StringBuilder(baseUrl); //we use an x/y chart retVal.append("cht=lxy"); //set the size and title retVal.append("&chtt=").append(encodeUrl(valueRetriever.getValueName())); retVal.append("&chs=").append(width).append("x").append(height); //specify the axes that will have labels retVal.append("&chxt=x,x,y"); //convert the data to google chart params retVal.append(generateGoogleChartParams()); return retVal.toString(); } // --- helper methods --- /** * Helper method takes the list of data values and converts them to a String suitable for appending to a Google * Chart URL. * * @return the chart parameters that encode all of the data necessary to display the chart. */ @SuppressWarnings("unchecked") protected String generateGoogleChartParams() { long minTimeValue = Long.MAX_VALUE; long maxTimeValue = Long.MIN_VALUE; double maxDataValue = Double.MIN_VALUE; //this map stores all the data series. The key is the tag name (each tag represents a single series) and the //value contains two lists of numbers - the first list contains the X values for each point (which is time in //milliseconds) and the second list contains the y values, which are the data values pulled from dataWindows. Map<String, List<Number>[]> tagsToXDataAndYData = new TreeMap<String, List<Number>[]>(); for (GroupedTimingStatistics groupedTimingStatistics : data) { Map<String, TimingStatistics> statsByTag = groupedTimingStatistics.getStatisticsByTag(); long windowStartTime = groupedTimingStatistics.getStartTime(); long windowLength = groupedTimingStatistics.getStopTime() - windowStartTime; //keep track of the min/max time value, this is needed for scaling the chart parameters minTimeValue = Math.min(minTimeValue, windowStartTime); maxTimeValue = Math.max(maxTimeValue, windowStartTime); for (Map.Entry<String, TimingStatistics> tagWithData : statsByTag.entrySet()) { String tag = tagWithData.getKey(); if (this.enabledTags == null || this.enabledTags.contains(tag)) { //get the corresponding value from tagsToXDataAndYData List<Number>[] xAndYData = tagsToXDataAndYData.get(tagWithData.getKey()); if (xAndYData == null) { tagsToXDataAndYData.put(tag, xAndYData = new List[]{new ArrayList<Number>(), new ArrayList<Number>()}); } //the x data is the start time of the window, the y data is the value Number yValue = this.valueRetriever.getStatsValue(tagWithData.getValue(), windowLength); xAndYData[0].add(windowStartTime); xAndYData[1].add(yValue); //update the max data value, which is needed for scaling maxDataValue = Math.max(maxDataValue, yValue.doubleValue()); } } } //if it's empty, there's nothing to display if (tagsToXDataAndYData.isEmpty()) { return ""; } //set up the axis labels - we use the US decimal format locale to ensure the decimal separator is . and not , DecimalFormat decimalFormat = new DecimalFormat("##0.0", new DecimalFormatSymbols(Locale.US)); SimpleDateFormat dateFormat = new SimpleDateFormat("HH:mm:ss"); dateFormat.setTimeZone(GroupedTimingStatistics.getTimeZone()); //the y-axis label goes from 0 to the maximum data value String axisRangeParam = "&chxr=2,0," + decimalFormat.format(maxDataValue); //for the x-axis (time) labels, ideally we want one label for each data window, but support a maximum of 10 //labels so the chart doesn't get too crowded int stepSize = this.data.size() / 10 + 1; StringBuilder timeAxisLabels = new StringBuilder("&chxl=0:"); StringBuilder timeAxisLabelPositions = new StringBuilder("&chxp=0"); for (Iterator<GroupedTimingStatistics> iter = data.iterator(); iter.hasNext();) { GroupedTimingStatistics groupedTimingStatistics = iter.next(); long windowStartTime = groupedTimingStatistics.getStartTime(); String label = dateFormat.format(new Date(windowStartTime)); double position = 100.0 * (windowStartTime - minTimeValue) / (maxTimeValue - minTimeValue); timeAxisLabels.append("|").append(label); timeAxisLabelPositions.append(",").append(decimalFormat.format(position)); //skip over some windows if stepSize is greater than 1 for (int i = 1; i < stepSize && iter.hasNext(); i++) { iter.next(); } } //this next line appends a "Time" label in the middle of the bottom of the X axis timeAxisLabels.append("|1:|Time"); timeAxisLabelPositions.append("|1,50"); //display the gridlines double xAxisGridlineStepSize = this.data.size() > 2 ? 100.0 / (this.data.size() - 1) : 50.0; String gridlinesParam = "&chg=" + decimalFormat.format(xAxisGridlineStepSize) + ",10"; //at this point we should be able to normalize the data to 0 - 100 as required by the google chart API StringBuilder chartDataParam = new StringBuilder("&chd=t:"); StringBuilder chartColorsParam = new StringBuilder("&chco="); StringBuilder chartShapeMarkerParam = new StringBuilder("&chm="); StringBuilder chartLegendParam = new StringBuilder("&chdl="); //this loop is run once for each tag, i.e. each data series to be displayed on the chart int i = 0; for (Iterator<Map.Entry<String, List<Number>[]>> iter = tagsToXDataAndYData.entrySet().iterator(); iter.hasNext(); i++) { Map.Entry<String, List<Number>[]> tagWithXAndYData = iter.next(); //data param List<Number> xValues = tagWithXAndYData.getValue()[0]; chartDataParam.append(numberValuesToGoogleDataSeriesParam(xValues, minTimeValue, maxTimeValue)); chartDataParam.append("|"); List<Number> yValues = tagWithXAndYData.getValue()[1]; chartDataParam.append(numberValuesToGoogleDataSeriesParam(yValues, 0, maxDataValue)); //color param String color = DEFAULT_SERIES_COLORS[i % DEFAULT_SERIES_COLORS.length]; chartColorsParam.append(color); //the shape marker param puts a diamond (the d) at each data point (the -1) of size 5 pixels. chartShapeMarkerParam.append("d,").append(color).append(",").append(i).append(",-1,5.0"); //legend param chartLegendParam.append(tagWithXAndYData.getKey()); if (iter.hasNext()) { chartDataParam.append("|"); chartColorsParam.append(","); chartShapeMarkerParam.append("|"); chartLegendParam.append("|"); } } return chartDataParam.toString() + chartColorsParam + chartShapeMarkerParam + chartLegendParam + axisRangeParam + timeAxisLabels + timeAxisLabelPositions + gridlinesParam; } /** * This helper method is used to normalize a list of data values from 0 - 100 as required by the Google Chart * Data API, and from this data it constructs the series data URL param. * * @param values the values to be normalized * @param minPossibleValue the minimum possible value for the values * @param maxPossibleValue the maximmum possible value for the values * @return A Google Chart API data series using normal text encoding (see the Chart API docs) */ protected String numberValuesToGoogleDataSeriesParam(List<Number> values, double minPossibleValue, double maxPossibleValue) { StringBuilder retVal = new StringBuilder(); double valueRange = maxPossibleValue - minPossibleValue; DecimalFormat formatter = new DecimalFormat("##0.0", new DecimalFormatSymbols(Locale.US)); for (Iterator<Number> iter = values.iterator(); iter.hasNext();) { Number value = iter.next(); double normalizedNumber = 100.0 * (value.doubleValue() - minPossibleValue) / valueRange; retVal.append(formatter.format(normalizedNumber)); if (iter.hasNext()) { retVal.append(","); } } return retVal.toString(); } /** * Helper method encodes a string use as a URL parameter value. * * @param string the string to encode * @return the encoded string */ protected String encodeUrl(String string) { try { return URLEncoder.encode(string, "UTF-8"); } catch (UnsupportedEncodingException uee) { //can't happen; return string; } } }