// This file is part of OpenTSDB.
// Copyright (C) 2010-2012 The OpenTSDB Authors.
//
// This program is free software: you can redistribute it and/or modify it
// under the terms of the GNU Lesser General Public License as published by
// the Free Software Foundation, either version 2.1 of the License, or (at your
// option) any later version. This program is distributed in the hope that it
// will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty
// of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser
// General Public License for more details. You should have received a copy
// of the GNU Lesser General Public License along with this program. If not,
// see <http://www.gnu.org/licenses/>.
package net.opentsdb.graph;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.TimeZone;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import net.opentsdb.core.DataPoint;
import net.opentsdb.core.DataPoints;
import net.opentsdb.meta.Annotation;
/**
* Produces files to generate graphs with Gnuplot.
* <p>
* This class takes a bunch of {@link DataPoints} instances and generates a
* Gnuplot script as well as the corresponding data files to feed to Gnuplot.
*/
public final class Plot {
private static final Logger LOG = LoggerFactory.getLogger(Plot.class);
/** Mask to use on 32-bit unsigned integers to avoid sign extension. */
private static final long UNSIGNED = 0x00000000FFFFFFFFL;
/** Default (current) timezone. */
private static final TimeZone DEFAULT_TZ = TimeZone.getDefault();
/** Start time (UNIX timestamp in seconds) on 32 bits ("unsigned" int). */
private final int start_time;
/** End time (UNIX timestamp in seconds) on 32 bits ("unsigned" int). */
private final int end_time;
/** All the DataPoints we want to plot. */
private ArrayList<DataPoints> datapoints =
new ArrayList<DataPoints>();
/** List of global annotations */
private List<Annotation> globals = null;
/** Per-DataPoints Gnuplot options. */
private ArrayList<String> options = new ArrayList<String>();
/** Global Gnuplot parameters. */
private Map<String, String> params;
/** Minimum width / height allowed. */
private static final short MIN_PIXELS = 100;
/** Width of the graph to generate, in pixels. */
private short width = (short) 1024;
/** Height of the graph to generate, in pixels. */
private short height = (short) 768;
/**
* Number of seconds of difference to apply in order to get local time.
* Gnuplot always renders timestamps in UTC, so we simply apply a delta
* to get local time.
*/
private final int utc_offset;
/**
* Constructor.
* @param start_time Timestamp of the start time of the graph.
* @param end_time Timestamp of the end time of the graph.
* @throws IllegalArgumentException if either timestamp is 0 or negative.
* @throws IllegalArgumentException if {@code start_time >= end_time}.
*/
public Plot(final long start_time, final long end_time) {
this(start_time, end_time, DEFAULT_TZ);
}
/**
* Constructor.
* @param start_time Timestamp of the start time of the graph.
* @param end_time Timestamp of the end time of the graph.
* @param tz Timezone to use to render the timestamps.
* If {@code null} the current timezone as of when the JVM started is used.
* @throws IllegalArgumentException if either timestamp is 0 or negative.
* @throws IllegalArgumentException if {@code start_time >= end_time}.
* @since 1.1
*/
public Plot(final long start_time, final long end_time, TimeZone tz) {
if ((start_time & 0xFFFFFFFF00000000L) != 0) {
throw new IllegalArgumentException("Invalid start time: " + start_time);
} else if ((end_time & 0xFFFFFFFF00000000L) != 0) {
throw new IllegalArgumentException("Invalid end time: " + end_time);
} else if (start_time >= end_time) {
throw new IllegalArgumentException("start time (" + start_time
+ ") is greater than or equal to end time: " + end_time);
}
this.start_time = (int) start_time;
this.end_time = (int) end_time;
if (tz == null) {
tz = DEFAULT_TZ;
}
this.utc_offset = tz.getOffset(System.currentTimeMillis()) / 1000;
}
/**
* Sets the global parameters for this plot.
* @param params Each entry is a Gnuplot setting that will be written as-is
* in the Gnuplot script file: {@code set KEY VALUE}.
* When the value is {@code null} the script will instead contain
* {@code unset KEY}.
* <p>
* Special parameters with a special meaning (since OpenTSDB 1.1):
* <ul>
* <li>{@code bgcolor}: Either {@code transparent} or an RGB color in
* hexadecimal (with a leading 'x' as in {@code x01AB23}).</li>
* <li>{@code fgcolor}: An RGB color in hexadecimal ({@code x42BEE7}).</li>
* </ul>
*/
public void setParams(final Map<String, String> params) {
this.params = params;
}
/**
* Sets the dimensions of the graph (in pixels).
* @param width The width of the graph produced (in pixels).
* @param height The height of the graph produced (in pixels).
* @throws IllegalArgumentException if the width or height are negative,
* zero or "too small" (e.g. less than 100x100 pixels).
*/
public void setDimensions(final short width, final short height) {
if (width < MIN_PIXELS || height < MIN_PIXELS) {
final String what = width < MIN_PIXELS ? "width" : "height";
throw new IllegalArgumentException(what + " smaller than " + MIN_PIXELS
+ " in " + width + 'x' + height);
}
this.width = width;
this.height = height;
}
/** @param globals A list of global annotation objects, may be null */
public void setGlobals(final List<Annotation> globals) {
this.globals = globals;
}
/**
* Adds some data points to this plot.
* @param datapoints The data points to plot.
* @param options The options to apply to this specific series.
*/
public void add(final DataPoints datapoints,
final String options) {
// Technically, we could check the number of data points in the
// datapoints argument in order to do something when there are none, but
// this is potentially expensive with a SpanGroup since it requires
// iterating through the entire SpanGroup. We'll check this later
// when we're trying to use the data, in order to avoid multiple passes
// through the entire data.
this.datapoints.add(datapoints);
this.options.add(options);
}
/**
* Returns a view on the datapoints in this plot.
* Do not attempt to modify the return value.
*/
public Iterable<DataPoints> getDataPoints() {
return datapoints;
}
/**
* Generates the Gnuplot script and data files.
* @param basepath The base path to use. A number of new files will be
* created and their names will all start with this string.
* @return The number of data points sent to Gnuplot. This can be less
* than the number of data points involved in the query due to things like
* aggregation or downsampling.
* @throws IOException if there was an error while writing one of the files.
*/
public int dumpToFiles(final String basepath) throws IOException {
int npoints = 0;
final int nseries = datapoints.size();
final String datafiles[] = nseries > 0 ? new String[nseries] : null;
for (int i = 0; i < nseries; i++) {
datafiles[i] = basepath + "_" + i + ".dat";
final PrintWriter datafile = new PrintWriter(datafiles[i]);
try {
for (final DataPoint d : datapoints.get(i)) {
final long ts = d.timestamp() / 1000;
if (ts >= (start_time & UNSIGNED) && ts <= (end_time & UNSIGNED)) {
npoints++;
}
datafile.print(ts + utc_offset);
datafile.print(' ');
if (d.isInteger()) {
datafile.print(d.longValue());
} else {
final double value = d.doubleValue();
if (value != value || Double.isInfinite(value)) {
throw new IllegalStateException("NaN or Infinity found in"
+ " datapoints #" + i + ": " + value + " d=" + d);
}
datafile.print(value);
}
datafile.print('\n');
}
} finally {
datafile.close();
}
}
if (npoints == 0) {
// Gnuplot doesn't like empty graphs when xrange and yrange aren't
// entirely defined, because it can't decide on good ranges with no
// data. We always set the xrange, but the yrange is supplied by the
// user. Let's make sure it defines a min and a max.
params.put("yrange", "[0:10]"); // Doesn't matter what values we use.
}
writeGnuplotScript(basepath, datafiles);
return npoints;
}
/**
* Generates the Gnuplot script.
* @param basepath The base path to use.
* @param datafiles The names of the data files that need to be plotted,
* in the order in which they ought to be plotted. It is assumed that
* the ith file will correspond to the ith entry in {@code datapoints}.
* Can be {@code null} if there's no data to plot.
*/
private void writeGnuplotScript(final String basepath,
final String[] datafiles) throws IOException {
final String script_path = basepath + ".gnuplot";
final PrintWriter gp = new PrintWriter(script_path);
try {
// XXX don't hardcode all those settings. At least not like that.
gp.append("set term png small size ")
// Why the fuck didn't they also add methods for numbers?
.append(Short.toString(width)).append(",")
.append(Short.toString(height));
final String smooth = params.remove("smooth");
final String fgcolor = params.remove("fgcolor");
String bgcolor = params.remove("bgcolor");
if (fgcolor != null && bgcolor == null) {
// We can't specify a fgcolor without specifying a bgcolor.
bgcolor = "xFFFFFF"; // So use a default.
}
if (bgcolor != null) {
if (fgcolor != null && "transparent".equals(bgcolor)) {
// In case we need to specify a fgcolor but we wanted a transparent
// background, we also need to pass a bgcolor otherwise the first
// hex color will be mistakenly taken as a bgcolor by Gnuplot.
bgcolor = "transparent xFFFFFF";
}
gp.append(' ').append(bgcolor);
}
if (fgcolor != null) {
gp.append(' ').append(fgcolor);
}
gp.append("\n"
+ "set xdata time\n"
+ "set timefmt \"%s\"\n"
+ "if (GPVAL_VERSION < 4.6) set xtics rotate; else set xtics rotate right\n"
+ "set output \"").append(basepath + ".png").append("\"\n"
+ "set xrange [\"")
.append(String.valueOf((start_time & UNSIGNED) + utc_offset))
.append("\":\"")
.append(String.valueOf((end_time & UNSIGNED) + utc_offset))
.append("\"]\n");
if (!params.containsKey("format x")) {
gp.append("set format x \"").append(xFormat()).append("\"\n");
}
final int nseries = datapoints.size();
if (nseries > 0) {
gp.write("set grid\n"
+ "set style data linespoints\n");
if (!params.containsKey("key")) {
gp.write("set key right box\n");
}
} else {
gp.write("unset key\n");
if (params == null || !params.containsKey("label")) {
gp.write("set label \"No data\" at graph 0.5,0.9 center\n");
}
}
if (params != null) {
for (final Map.Entry<String, String> entry : params.entrySet()) {
final String key = entry.getKey();
final String value = entry.getValue();
if (value != null) {
gp.append("set ").append(key)
.append(' ').append(value).write('\n');
} else {
gp.append("unset ").append(key).write('\n');
}
}
}
for (final String opts : options) {
if (opts.contains("x1y2")) {
// Create a second scale for the y-axis on the right-hand side.
gp.write("set y2tics border\n");
break;
}
}
// compile annotations to determine if we have any to graph
final List<Annotation> notes = new ArrayList<Annotation>();
for (int i = 0; i < nseries; i++) {
final DataPoints dp = datapoints.get(i);
final List<Annotation> series_notes = dp.getAnnotations();
if (series_notes != null && !series_notes.isEmpty()) {
notes.addAll(series_notes);
}
}
if (globals != null) {
notes.addAll(globals);
}
if (notes.size() > 0) {
Collections.sort(notes);
for(Annotation note : notes) {
String ts = Long.toString(note.getStartTime());
String value = new String(note.getDescription());
gp.append("set arrow from \"").append(ts).append("\", graph 0 to \"");
gp.append(ts).append("\", graph 1 nohead ls 3\n");
gp.append("set object rectangle at \"").append(ts);
gp.append("\", graph 0 size char (strlen(\"").append(value);
gp.append("\")), char 1 front fc rgbcolor \"white\"\n");
gp.append("set label \"").append(value).append("\" at \"");
gp.append(ts).append("\", graph 0 front center\n");
}
}
gp.write("plot ");
for (int i = 0; i < nseries; i++) {
final DataPoints dp = datapoints.get(i);
final String title = dp.metricName() + dp.getTags();
gp.append(" \"").append(datafiles[i]).append("\" using 1:2");
if (smooth != null) {
gp.append(" smooth ").append(smooth);
}
// TODO(tsuna): Escape double quotes in title.
gp.append(" title \"").append(title).write('"');
final String opts = options.get(i);
if (!opts.isEmpty()) {
gp.append(' ').write(opts);
}
if (i != nseries - 1) {
gp.print(", \\");
}
gp.write('\n');
}
if (nseries == 0) {
gp.write('0');
}
} finally {
gp.close();
LOG.info("Wrote Gnuplot script to " + script_path);
}
}
/**
* Finds some sensible default formatting for the X axis (time).
* @return The Gnuplot time format string to use.
*/
private String xFormat() {
long timespan = (end_time & UNSIGNED) - (start_time & UNSIGNED);
if (timespan < 2100) { // 35m
return "%H:%M:%S";
} else if (timespan < 86400) { // 1d
return "%H:%M";
} else if (timespan < 604800) { // 1w
return "%a %H:%M";
} else if (timespan < 1209600) { // 2w
return "%a %d %H:%M";
} else if (timespan < 7776000) { // 90d
return "%b %d";
} else {
return "%Y/%m/%d";
}
}
}